@medalsocial/meda 3.0.0 → 3.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 (88) hide show
  1. package/README.md +36 -2
  2. package/dist/booking/add-child-sheet.d.ts +38 -0
  3. package/dist/booking/add-child-sheet.js +88 -0
  4. package/dist/booking/booking-skeleton.d.ts +24 -0
  5. package/dist/booking/booking-skeleton.js +17 -0
  6. package/dist/booking/confirmation.d.ts +85 -0
  7. package/dist/booking/confirmation.js +102 -0
  8. package/dist/booking/details-screen.d.ts +118 -0
  9. package/dist/booking/details-screen.js +316 -0
  10. package/dist/booking/format.d.ts +64 -0
  11. package/dist/booking/format.js +1 -0
  12. package/dist/booking/index.d.ts +36 -0
  13. package/dist/booking/index.js +41 -0
  14. package/dist/booking/internal/label-parts.d.ts +28 -0
  15. package/dist/booking/internal/label-parts.js +57 -0
  16. package/dist/booking/internal/ui.d.ts +33 -0
  17. package/dist/booking/internal/ui.js +35 -0
  18. package/dist/booking/label-keys.d.ts +34 -0
  19. package/dist/booking/label-keys.js +56 -0
  20. package/dist/booking/labels.d.ts +15 -0
  21. package/dist/booking/labels.js +17 -0
  22. package/dist/booking/live-status.d.ts +14 -0
  23. package/dist/booking/live-status.js +17 -0
  24. package/dist/booking/login-panel.d.ts +108 -0
  25. package/dist/booking/login-panel.js +239 -0
  26. package/dist/booking/login-sheet.d.ts +41 -0
  27. package/dist/booking/login-sheet.js +49 -0
  28. package/dist/booking/manage-screen.d.ts +113 -0
  29. package/dist/booking/manage-screen.js +372 -0
  30. package/dist/booking/otp-slots.d.ts +42 -0
  31. package/dist/booking/otp-slots.js +109 -0
  32. package/dist/booking/portal/account-card.d.ts +24 -0
  33. package/dist/booking/portal/account-card.js +18 -0
  34. package/dist/booking/portal/action-result.d.ts +30 -0
  35. package/dist/booking/portal/action-result.js +18 -0
  36. package/dist/booking/portal/age-confirm-card.d.ts +39 -0
  37. package/dist/booking/portal/age-confirm-card.js +42 -0
  38. package/dist/booking/portal/child-cards.d.ts +65 -0
  39. package/dist/booking/portal/child-cards.js +59 -0
  40. package/dist/booking/portal/data-controls.d.ts +48 -0
  41. package/dist/booking/portal/data-controls.js +104 -0
  42. package/dist/booking/portal/family-editor.d.ts +141 -0
  43. package/dist/booking/portal/family-editor.js +422 -0
  44. package/dist/booking/portal/logout-button.d.ts +21 -0
  45. package/dist/booking/portal/logout-button.js +27 -0
  46. package/dist/booking/portal/portal-shell.d.ts +71 -0
  47. package/dist/booking/portal/portal-shell.js +38 -0
  48. package/dist/booking/portal/portal-unreachable.d.ts +31 -0
  49. package/dist/booking/portal/portal-unreachable.js +19 -0
  50. package/dist/booking/portal/profile-form.d.ts +75 -0
  51. package/dist/booking/portal/profile-form.js +144 -0
  52. package/dist/booking/portal/rebook-cards.d.ts +44 -0
  53. package/dist/booking/portal/rebook-cards.js +35 -0
  54. package/dist/booking/portal/upcoming-bookings.d.ts +65 -0
  55. package/dist/booking/portal/upcoming-bookings.js +84 -0
  56. package/dist/booking/portal/vipps-link-row.d.ts +54 -0
  57. package/dist/booking/portal/vipps-link-row.js +80 -0
  58. package/dist/booking/portal/visit-history.d.ts +63 -0
  59. package/dist/booking/portal/visit-history.js +73 -0
  60. package/dist/booking/service-screen.d.ts +140 -0
  61. package/dist/booking/service-screen.js +235 -0
  62. package/dist/booking/slots.d.ts +12 -0
  63. package/dist/booking/slots.js +5 -0
  64. package/dist/booking/stylist-screen.d.ts +128 -0
  65. package/dist/booking/stylist-screen.js +229 -0
  66. package/dist/booking/summary-bar.d.ts +26 -0
  67. package/dist/booking/summary-bar.js +32 -0
  68. package/dist/booking/time-screen.d.ts +191 -0
  69. package/dist/booking/time-screen.js +419 -0
  70. package/dist/booking/types.d.ts +284 -0
  71. package/dist/booking/types.js +15 -0
  72. package/dist/booking/vipps-button.d.ts +49 -0
  73. package/dist/booking/vipps-button.js +33 -0
  74. package/dist/booking/who-screen.d.ts +121 -0
  75. package/dist/booking/who-screen.js +164 -0
  76. package/dist/primitives/checkbox.d.ts +23 -0
  77. package/dist/primitives/checkbox.js +20 -0
  78. package/dist/primitives/field.d.ts +47 -0
  79. package/dist/primitives/field.js +48 -0
  80. package/dist/primitives/index.d.ts +8 -0
  81. package/dist/primitives/index.js +4 -0
  82. package/dist/primitives/sheet.d.ts +68 -0
  83. package/dist/primitives/sheet.js +303 -0
  84. package/dist/primitives/textarea.d.ts +14 -0
  85. package/dist/primitives/textarea.js +13 -0
  86. package/dist/styles/booking.css +10 -0
  87. package/package.json +15 -8
  88. package/skills/components/SKILL.md +53 -0
@@ -0,0 +1,34 @@
1
+ import { ADD_CHILD_SHEET_LABEL_KEYS } from './add-child-sheet.js';
2
+ import { BOOKING_SKELETON_LABEL_KEYS } from './booking-skeleton.js';
3
+ import { CONFIRMATION_LABEL_KEYS } from './confirmation.js';
4
+ import { DETAILS_SCREEN_LABEL_KEYS } from './details-screen.js';
5
+ import { LOGIN_SHEET_LABEL_KEYS } from './login-sheet.js';
6
+ import { MANAGE_SCREEN_LABEL_KEYS } from './manage-screen.js';
7
+ import { CHILD_CARDS_LABEL_KEYS } from './portal/child-cards.js';
8
+ import { DATA_CONTROLS_LABEL_KEYS } from './portal/data-controls.js';
9
+ import { FAMILY_EDITOR_LABEL_KEYS } from './portal/family-editor.js';
10
+ import { LOGOUT_BUTTON_LABEL_KEYS } from './portal/logout-button.js';
11
+ import { PORTAL_SHELL_LABEL_KEYS } from './portal/portal-shell.js';
12
+ import { PORTAL_UNREACHABLE_LABEL_KEYS } from './portal/portal-unreachable.js';
13
+ import { PROFILE_FORM_LABEL_KEYS } from './portal/profile-form.js';
14
+ import { REBOOK_CARDS_LABEL_KEYS } from './portal/rebook-cards.js';
15
+ import { UPCOMING_BOOKINGS_LABEL_KEYS } from './portal/upcoming-bookings.js';
16
+ import { VIPPS_LINK_ROW_LABEL_KEYS } from './portal/vipps-link-row.js';
17
+ import { VISIT_HISTORY_LABEL_KEYS } from './portal/visit-history.js';
18
+ import { SERVICE_SCREEN_LABEL_KEYS } from './service-screen.js';
19
+ import { STYLIST_SCREEN_LABEL_KEYS } from './stylist-screen.js';
20
+ import { SUMMARY_BAR_LABEL_KEYS } from './summary-bar.js';
21
+ import { TIME_SCREEN_LABEL_KEYS } from './time-screen.js';
22
+ import { WHO_SCREEN_LABEL_KEYS } from './who-screen.js';
23
+ /**
24
+ * Every label key any booking screen reads, once. A complete pack for a
25
+ * locale is a `BookingLabels`; each screen's `labels` prop is a subset of it,
26
+ * so the same merged pack can be handed to every screen.
27
+ *
28
+ * (Screens that embed another screen already include its keys, e.g. the
29
+ * login sheet carries the login panel's, OTP slots' and Vipps button's.)
30
+ */
31
+ export declare const BOOKING_LABEL_KEYS: readonly ("addChild.trigger" | "addChild.title" | "addChild.description.saved" | "addChild.description.local" | "addChild.name" | "addChild.birthYear" | "addChild.birthYearPlaceholder" | "addChild.birthMonth" | "addChild.birthMonthUnknown" | "addChild.notes" | "addChild.missing" | "addChild.submit" | "addChild.close" | "bookingSkeleton.opening" | "confirmation.heading" | "confirmation.serviceFor" | "confirmation.when" | "confirmation.party.heading" | "confirmation.party.line" | "confirmation.party.stylist" | "confirmation.party.total" | "confirmation.calendar" | "confirmation.manage" | "confirmation.manageFor" | "confirmation.portal" | "confirmation.startOver" | "details.heading" | "details.phone.label" | "details.phone.prefix" | "details.phone.error" | "details.name.label" | "details.known.label" | "details.known.for" | "details.known.self" | "details.known.adult" | "details.known.child" | "details.child.name" | "details.child.year" | "details.child.yearPlaceholder" | "details.email.label" | "details.email.help" | "details.email.error" | "details.notes.label" | "details.notes.placeholder" | "details.terms.text" | "details.terms.link" | "details.marketing.text" | "details.error.maxParty" | "details.error.slotTaken" | "details.error.conflict" | "details.error.invalidInput" | "details.error.unconfigured" | "details.error.upstreamError" | "details.error.inProgress" | "details.call.link" | "details.call.none" | "details.submit" | "otp.label" | "vipps.button" | "vipps.unavailable" | "vipps.throttled" | "login.heading" | "login.codeHeading" | "login.or" | "login.emailIntro" | "login.emailLabel" | "login.sendCode" | "login.codeLabel" | "login.submitCode" | "login.codeHelp" | "login.codeHelpVipps" | "login.vippsSentTo" | "login.vippsSentToUnknown" | "login.cooldownWait" | "login.cooldownReady" | "login.resendIn" | "login.resend" | "login.switchEmail" | "login.useEmailInstead" | "login.notice.sent" | "login.notice.resent" | "login.notice.unreachable" | "login.notice.badEmail" | "login.notice.invalid" | "login.notice.throttled" | "login.notice.vippsInvalid" | "login.notice.vippsConflict" | "loginSheet.prompt" | "loginSheet.trigger" | "loginSheet.intro" | "loginSheet.vipps" | "loginSheet.close" | "time.heading" | "time.dayStrip.legend" | "time.nearest" | "time.current" | "time.nextFree" | "time.shortNotice" | "time.weekend.pct" | "time.weekend.mixed" | "time.month.show" | "time.month.hide" | "time.month.previous" | "time.month.next" | "time.month.heading" | "time.month.note" | "time.party.sequential" | "time.party.parallel" | "time.party.seat" | "time.party.seatSeparator" | "time.parallel.offer.two" | "time.parallel.offer.other" | "time.parallel.accept" | "time.empty.closed" | "time.empty.over" | "time.empty.full" | "time.empty.stylist" | "time.empty.unknown" | "time.empty.callLink" | "time.empty.callLinkAria" | "time.empty.callSuffix" | "time.empty.callPlain" | "time.loading" | "time.skeleton.dayPlaceholder" | "time.taken.message" | "time.taken.close" | "manage.heading" | "manage.serviceFallback" | "manage.card.named" | "manage.card.when" | "manage.card.whenWithStylist" | "manage.appointment.named" | "manage.appointment.namedSibilant" | "manage.appointment.unnamed" | "manage.ringUs" | "manage.ringUsOn" | "manage.callUs" | "manage.callUsOn" | "manage.error.windowPassed" | "manage.error.slotTaken" | "manage.error.notFound" | "manage.error.conflict" | "manage.error.invalidInput" | "manage.error.unavailable" | "manage.freeUntil" | "manage.remaining.in" | "manage.remaining.daysAndHours" | "manage.remaining.underMinute" | "manage.remaining.day.one" | "manage.remaining.day.other" | "manage.remaining.hour.one" | "manage.remaining.hour.other" | "manage.remaining.minute.one" | "manage.remaining.minute.other" | "manage.change" | "manage.cancel" | "manage.callToChange" | "manage.callToCancel" | "manage.blocked" | "manage.blocked.suffix" | "manage.blocked.plain" | "manage.calendar" | "manage.closed.cancelled" | "manage.closed.completed" | "manage.closed.noShow" | "manage.closed.pending" | "manage.closed.pendingCall" | "manage.closed.pendingCallPlain" | "manage.bookNew" | "manage.party.heading" | "manage.party.justThisOne" | "manage.party.unnamed" | "manage.party.note" | "manage.party.noteSuffix" | "manage.back" | "manage.reschedule.lead" | "manage.reschedule.leadWithStylist" | "manage.reschedule.failed" | "manage.reschedule.failedSuffix" | "manage.reschedule.loading" | "manage.move.question" | "manage.move.questionWithStylist" | "manage.move.surcharge" | "manage.move.confirm" | "manage.move.pickAnother" | "manage.cancel.question" | "manage.cancel.reasonLegend" | "manage.cancel.reason.illness" | "manage.cancel.reason.noLongerSuits" | "manage.cancel.reason.other" | "manage.cancel.confirm" | "manage.cancel.keep" | "manage.cancelled" | "manage.cancelled.findNew" | "manage.moved" | "manage.movedTo" | "manage.moved.view" | "manage.moved.noLink" | "manage.moved.noLinkSuffix" | "manage.portal" | "childCards.empty" | "childCards.neverVisited" | "childCards.lastVisit" | "childCards.lastVisitNoService" | "childCards.book" | "childCards.stylist" | "childCards.next" | "childCards.none" | "dataControls.heading" | "dataControls.export" | "dataControls.delete" | "dataControls.confirmHeading" | "dataControls.consequences" | "dataControls.confirmLabel" | "dataControls.confirmHelp" | "dataControls.wrongWord" | "dataControls.deleteForever" | "dataControls.cancel" | "dataControls.unreachable" | "dataControls.confirmWord" | "ageConfirm.heading" | "ageConfirm.lead" | "ageConfirm.legend" | "ageConfirm.birthYear" | "ageConfirm.birthMonth" | "ageConfirm.birthMonthUnknown" | "ageConfirm.confirm" | "ageConfirm.dismiss" | "familyEditor.heading" | "familyEditor.lead" | "familyEditor.empty" | "familyEditor.legend" | "familyEditor.add" | "familyEditor.name" | "familyEditor.birthYear" | "familyEditor.birthYearPlaceholder" | "familyEditor.birthMonth" | "familyEditor.birthMonthUnknown" | "familyEditor.stylist" | "familyEditor.stylistNone" | "familyEditor.stylistFormer" | "familyEditor.notes" | "familyEditor.notesPlaceholder" | "familyEditor.newRow" | "familyEditor.save" | "familyEditor.remove" | "familyEditor.removeNamed" | "familyEditor.removeRow" | "familyEditor.saved" | "familyEditor.savedNoDetails" | "familyEditor.partial" | "familyEditor.removed" | "familyEditor.unreachable" | "familyEditor.ageConfirmed" | "familyEditor.ageSaved" | "logout.button" | "logout.unreachable" | "portalShell.nav" | "portalShell.navBar" | "portalUnreachable.heading" | "portalUnreachable.body" | "portalUnreachable.retry" | "portalUnreachable.call" | "profileForm.heading" | "profileForm.firstName" | "profileForm.lastName" | "profileForm.phone" | "profileForm.email" | "profileForm.emailHelp" | "profileForm.save" | "profileForm.saved" | "profileForm.restored" | "profileForm.marketing" | "profileForm.unreachable" | "rebook.heading" | "rebook.empty" | "rebook.serviceFallback" | "rebook.anyStylist" | "rebook.cta" | "upcoming.heading" | "upcoming.empty" | "upcoming.book" | "upcoming.next" | "upcoming.today" | "upcoming.tomorrow" | "upcoming.inDays" | "upcoming.when" | "upcoming.serviceFallback" | "upcoming.anyStylist" | "upcoming.manage" | "upcoming.noManage" | "upcoming.noManageWithPhone" | "upcoming.call" | "vippsLink.heading" | "vippsLink.linked" | "vippsLink.pitch" | "vippsLink.button" | "vippsLink.success" | "vippsLink.conflict" | "vippsLink.failed" | "vippsLink.unavailable" | "vippsLink.throttled" | "history.heading" | "history.empty" | "history.serviceFallback" | "history.count.one" | "history.count.other" | "history.unknownAmount" | "service.heading" | "service.categoriesLegend" | "service.sameAsLast" | "service.chooseOther" | "service.duration" | "service.phoneOnly" | "service.phoneOnlyLinkLabel" | "service.ageDivider.named" | "service.ageDivider.unnamed" | "service.party.listLabel" | "service.party.adultAlone" | "service.party.nothingFits" | "service.party.remove" | "stylist.heading" | "stylist.firstAvailable" | "stylist.firstAvailableSubtitle" | "stylist.firstAvailableBadge" | "stylist.pendingName" | "stylist.nextAvailable" | "stylist.loading" | "stylist.party.legend" | "stylist.party.sequential" | "stylist.party.sequentialMinutes" | "stylist.party.parallel.two" | "stylist.party.parallel.three" | "stylist.party.parallel.other" | "stylist.party.parallelMinutes" | "stylist.party.parallelNote.two" | "stylist.party.parallelNote.three" | "stylist.party.parallelNote.other" | "summary.placeholder.who" | "summary.placeholder.service" | "summary.next" | "who.heading" | "who.guest.groupLabel" | "who.guest.children.one" | "who.guest.children.other" | "who.guest.adult" | "who.guest.addedList" | "who.family.legend" | "who.family.self" | "who.family.limit")[];
32
+ export type BookingLabelKey = (typeof WHO_SCREEN_LABEL_KEYS)[number] | (typeof ADD_CHILD_SHEET_LABEL_KEYS)[number] | (typeof SERVICE_SCREEN_LABEL_KEYS)[number] | (typeof STYLIST_SCREEN_LABEL_KEYS)[number] | (typeof TIME_SCREEN_LABEL_KEYS)[number] | (typeof MANAGE_SCREEN_LABEL_KEYS)[number] | (typeof DETAILS_SCREEN_LABEL_KEYS)[number] | (typeof SUMMARY_BAR_LABEL_KEYS)[number] | (typeof CONFIRMATION_LABEL_KEYS)[number] | (typeof BOOKING_SKELETON_LABEL_KEYS)[number] | (typeof LOGIN_SHEET_LABEL_KEYS)[number] | (typeof PORTAL_SHELL_LABEL_KEYS)[number] | (typeof PORTAL_UNREACHABLE_LABEL_KEYS)[number] | (typeof UPCOMING_BOOKINGS_LABEL_KEYS)[number] | (typeof VISIT_HISTORY_LABEL_KEYS)[number] | (typeof REBOOK_CARDS_LABEL_KEYS)[number] | (typeof CHILD_CARDS_LABEL_KEYS)[number] | (typeof FAMILY_EDITOR_LABEL_KEYS)[number] | (typeof PROFILE_FORM_LABEL_KEYS)[number] | (typeof DATA_CONTROLS_LABEL_KEYS)[number] | (typeof LOGOUT_BUTTON_LABEL_KEYS)[number] | (typeof VIPPS_LINK_ROW_LABEL_KEYS)[number];
33
+ /** A complete label pack for one locale. */
34
+ export type BookingLabels = Record<BookingLabelKey, string>;
@@ -0,0 +1,56 @@
1
+ import { ADD_CHILD_SHEET_LABEL_KEYS } from './add-child-sheet.js';
2
+ import { BOOKING_SKELETON_LABEL_KEYS } from './booking-skeleton.js';
3
+ import { CONFIRMATION_LABEL_KEYS } from './confirmation.js';
4
+ import { DETAILS_SCREEN_LABEL_KEYS } from './details-screen.js';
5
+ import { LOGIN_SHEET_LABEL_KEYS } from './login-sheet.js';
6
+ import { MANAGE_SCREEN_LABEL_KEYS } from './manage-screen.js';
7
+ import { CHILD_CARDS_LABEL_KEYS } from './portal/child-cards.js';
8
+ import { DATA_CONTROLS_LABEL_KEYS } from './portal/data-controls.js';
9
+ import { FAMILY_EDITOR_LABEL_KEYS } from './portal/family-editor.js';
10
+ import { LOGOUT_BUTTON_LABEL_KEYS } from './portal/logout-button.js';
11
+ import { PORTAL_SHELL_LABEL_KEYS } from './portal/portal-shell.js';
12
+ import { PORTAL_UNREACHABLE_LABEL_KEYS } from './portal/portal-unreachable.js';
13
+ import { PROFILE_FORM_LABEL_KEYS } from './portal/profile-form.js';
14
+ import { REBOOK_CARDS_LABEL_KEYS } from './portal/rebook-cards.js';
15
+ import { UPCOMING_BOOKINGS_LABEL_KEYS } from './portal/upcoming-bookings.js';
16
+ import { VIPPS_LINK_ROW_LABEL_KEYS } from './portal/vipps-link-row.js';
17
+ import { VISIT_HISTORY_LABEL_KEYS } from './portal/visit-history.js';
18
+ import { SERVICE_SCREEN_LABEL_KEYS } from './service-screen.js';
19
+ import { STYLIST_SCREEN_LABEL_KEYS } from './stylist-screen.js';
20
+ import { SUMMARY_BAR_LABEL_KEYS } from './summary-bar.js';
21
+ import { TIME_SCREEN_LABEL_KEYS } from './time-screen.js';
22
+ import { WHO_SCREEN_LABEL_KEYS } from './who-screen.js';
23
+ /**
24
+ * Every label key any booking screen reads, once. A complete pack for a
25
+ * locale is a `BookingLabels`; each screen's `labels` prop is a subset of it,
26
+ * so the same merged pack can be handed to every screen.
27
+ *
28
+ * (Screens that embed another screen already include its keys, e.g. the
29
+ * login sheet carries the login panel's, OTP slots' and Vipps button's.)
30
+ */
31
+ export const BOOKING_LABEL_KEYS = [
32
+ ...new Set([
33
+ ...WHO_SCREEN_LABEL_KEYS,
34
+ ...ADD_CHILD_SHEET_LABEL_KEYS,
35
+ ...SERVICE_SCREEN_LABEL_KEYS,
36
+ ...STYLIST_SCREEN_LABEL_KEYS,
37
+ ...TIME_SCREEN_LABEL_KEYS,
38
+ ...MANAGE_SCREEN_LABEL_KEYS,
39
+ ...DETAILS_SCREEN_LABEL_KEYS,
40
+ ...SUMMARY_BAR_LABEL_KEYS,
41
+ ...CONFIRMATION_LABEL_KEYS,
42
+ ...BOOKING_SKELETON_LABEL_KEYS,
43
+ ...LOGIN_SHEET_LABEL_KEYS,
44
+ ...PORTAL_SHELL_LABEL_KEYS,
45
+ ...PORTAL_UNREACHABLE_LABEL_KEYS,
46
+ ...UPCOMING_BOOKINGS_LABEL_KEYS,
47
+ ...VISIT_HISTORY_LABEL_KEYS,
48
+ ...REBOOK_CARDS_LABEL_KEYS,
49
+ ...CHILD_CARDS_LABEL_KEYS,
50
+ ...FAMILY_EDITOR_LABEL_KEYS,
51
+ ...PROFILE_FORM_LABEL_KEYS,
52
+ ...DATA_CONTROLS_LABEL_KEYS,
53
+ ...LOGOUT_BUTTON_LABEL_KEYS,
54
+ ...VIPPS_LINK_ROW_LABEL_KEYS,
55
+ ]),
56
+ ];
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Copy for the booking screens.
3
+ *
4
+ * Every word a booking screen shows arrives through its `labels` prop — meda
5
+ * ships no default brand and no default language. A screen's labels are a flat
6
+ * record of strings keyed `'<screen>.<thing>'` (e.g. `'who.heading'`), so one
7
+ * merged pack (`BookingLabels`) can be handed to every screen, and a customer
8
+ * can override a single sentence without touching the rest.
9
+ *
10
+ * Values are plain strings, so a pack is serialisable (it can travel from a
11
+ * server component to the client as a prop). Variable parts are `{name}`
12
+ * placeholders, filled by `fillLabel`.
13
+ */
14
+ /** Replaces `{key}` placeholders in `template`. Unknown keys are left as written. */
15
+ export declare function fillLabel(template: string, values: Readonly<Record<string, string | number>>): string;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Copy for the booking screens.
3
+ *
4
+ * Every word a booking screen shows arrives through its `labels` prop — meda
5
+ * ships no default brand and no default language. A screen's labels are a flat
6
+ * record of strings keyed `'<screen>.<thing>'` (e.g. `'who.heading'`), so one
7
+ * merged pack (`BookingLabels`) can be handed to every screen, and a customer
8
+ * can override a single sentence without touching the rest.
9
+ *
10
+ * Values are plain strings, so a pack is serialisable (it can travel from a
11
+ * server component to the client as a prop). Variable parts are `{name}`
12
+ * placeholders, filled by `fillLabel`.
13
+ */
14
+ /** Replaces `{key}` placeholders in `template`. Unknown keys are left as written. */
15
+ export function fillLabel(template, values) {
16
+ return template.replace(/\{(\w+)\}/g, (match, key) => Object.hasOwn(values, key) ? String(values[key]) : match);
17
+ }
@@ -0,0 +1,14 @@
1
+ export interface LiveStatusProps {
2
+ /** The sentence to announce; `null` empties the region. */
3
+ text: string | null;
4
+ /** Visually hidden by default. */
5
+ className?: string;
6
+ }
7
+ /**
8
+ * A polite live region that is mounted EMPTY and filled after mount.
9
+ *
10
+ * Screen readers announce a change inside a live region they already know
11
+ * about; a region that arrives with its text already in it is often read as
12
+ * nothing at all. So the text lands one commit after the element does.
13
+ */
14
+ export declare function LiveStatus({ text, className }: LiveStatusProps): import("react").JSX.Element;
@@ -0,0 +1,17 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useEffect, useState } from 'react';
4
+ /**
5
+ * A polite live region that is mounted EMPTY and filled after mount.
6
+ *
7
+ * Screen readers announce a change inside a live region they already know
8
+ * about; a region that arrives with its text already in it is often read as
9
+ * nothing at all. So the text lands one commit after the element does.
10
+ */
11
+ export function LiveStatus({ text, className = 'sr-only' }) {
12
+ const [shown, setShown] = useState(null);
13
+ useEffect(() => {
14
+ setShown(text);
15
+ }, [text]);
16
+ return (_jsx("p", { role: "status", "aria-live": "polite", className: className, children: shown }));
17
+ }
@@ -0,0 +1,108 @@
1
+ import { type Dispatch, type ReactNode, type SetStateAction } from 'react';
2
+ import { type OtpSlotsLabels, type OtpSlotsSlot } from './otp-slots.js';
3
+ import { type SlotClassNames } from './slots.js';
4
+ import type { BookingGuardian } from './types.js';
5
+ import { type VippsButtonLabels, type VippsButtonSlot, type VippsStartAction } from './vipps-button.js';
6
+ export declare const LOGIN_PANEL_OWN_LABEL_KEYS: readonly ["login.heading", "login.codeHeading", "login.or", "login.emailIntro", "login.emailLabel", "login.sendCode", "login.codeLabel", "login.submitCode", "login.codeHelp", "login.codeHelpVipps", "login.vippsSentTo", "login.vippsSentToUnknown", "login.cooldownWait", "login.cooldownReady", "login.resendIn", "login.resend", "login.switchEmail", "login.useEmailInstead", "login.notice.sent", "login.notice.resent", "login.notice.unreachable", "login.notice.badEmail", "login.notice.invalid", "login.notice.throttled", "login.notice.vippsInvalid", "login.notice.vippsConflict"];
7
+ export declare const LOGIN_PANEL_LABEL_KEYS: readonly ["login.heading", "login.codeHeading", "login.or", "login.emailIntro", "login.emailLabel", "login.sendCode", "login.codeLabel", "login.submitCode", "login.codeHelp", "login.codeHelpVipps", "login.vippsSentTo", "login.vippsSentToUnknown", "login.cooldownWait", "login.cooldownReady", "login.resendIn", "login.resend", "login.switchEmail", "login.useEmailInstead", "login.notice.sent", "login.notice.resent", "login.notice.unreachable", "login.notice.badEmail", "login.notice.invalid", "login.notice.throttled", "login.notice.vippsInvalid", "login.notice.vippsConflict", "otp.label", "vipps.button", "vipps.unavailable", "vipps.throttled"];
8
+ /**
9
+ * Placeholders: `login.codeHelp` `{email}`, `login.vippsSentTo` `{to}`,
10
+ * `login.cooldownWait` `{seconds}`, `login.resendIn` `{time}` («0:30»).
11
+ */
12
+ export type LoginPanelLabels = Record<(typeof LOGIN_PANEL_OWN_LABEL_KEYS)[number], string> & OtpSlotsLabels & VippsButtonLabels;
13
+ /**
14
+ * - `root`: the panel's column.
15
+ * - `notice`: a non-error sentence in the live region (errors use `Field.Error`).
16
+ * - `form`: both forms (e-mail, code).
17
+ * - `input`: the e-mail input.
18
+ * - `submit`: both forms' submit buttons.
19
+ * - `divider`: the «or» rule between Vipps and e-mail.
20
+ * - `links`: the row under the code form; `link`: each of its buttons.
21
+ */
22
+ export type LoginPanelSlot = 'root' | 'notice' | 'form' | 'input' | 'submit' | 'divider' | 'links' | 'link';
23
+ /** What asking for a code answered. `invalidEmail`: the address was refused. */
24
+ export type LoginStartResult = {
25
+ ok: true;
26
+ } | {
27
+ ok: false;
28
+ reason: 'unreachable' | 'invalidEmail';
29
+ };
30
+ /** What checking a code answered. `conflict`: the Vipps account belongs to another profile. */
31
+ export type LoginVerifyResult = {
32
+ ok: true;
33
+ guardian: BookingGuardian | null;
34
+ } | {
35
+ ok: false;
36
+ reason: 'invalid' | 'throttled' | 'conflict' | 'unreachable';
37
+ };
38
+ /** A Vipps return that asks for the e-mailed code. `to` is a masked address, or `null`. */
39
+ export interface VippsConfirm {
40
+ to: string | null;
41
+ }
42
+ /**
43
+ * The part of a login that must outlive the dialog it is drawn in: a user
44
+ * who closes a sheet to check their mail should land back on the code screen
45
+ * for the address the code went to, with the resend wait still running.
46
+ */
47
+ export interface LoginPanelMemory {
48
+ /** `vipps`: confirming a Vipps login with a code e-mailed to the address on file. */
49
+ mode: 'start' | 'code' | 'vipps';
50
+ email: string;
51
+ /** The address a code was actually sent to — pinned when the request leaves. */
52
+ sentTo: string;
53
+ /** When «send a new code» opens again (epoch ms); `0` before any code went out. */
54
+ resendAt: number;
55
+ }
56
+ export declare const FRESH_LOGIN_MEMORY: LoginPanelMemory;
57
+ /** Where a Vipps return that asks for the code starts: on the code, for `to`. */
58
+ export declare function loginMemoryFor(vippsConfirm: VippsConfirm | null | undefined): LoginPanelMemory;
59
+ export interface LoginPanelProps {
60
+ labels: LoginPanelLabels;
61
+ /** Ask for a code for `email` (already trimmed). Resolve `ok` whether or not the address is known. */
62
+ onStartLogin: (email: string) => Promise<LoginStartResult>;
63
+ /**
64
+ * Check a code. `mode: 'email'` carries the address the code went to;
65
+ * `mode: 'vipps'` is a Vipps confirm, checked by the code alone.
66
+ */
67
+ onVerify: (code: string, context: {
68
+ mode: 'email' | 'vipps';
69
+ email: string;
70
+ }) => Promise<LoginVerifyResult>;
71
+ /** A good code. `guardian` is who logged in, or `null` when that read failed. */
72
+ onSignedIn: (guardian: BookingGuardian | null) => void;
73
+ /** The Vipps start action. Absent: no Vipps button and no «or» rule. */
74
+ onVipps?: VippsStartAction;
75
+ /** Where a Vipps login should land. */
76
+ vippsNext?: string | null;
77
+ /** Replaces the Vipps button's label. */
78
+ vippsLabel?: string;
79
+ /** Start on the Vipps confirm code screen (uncontrolled memory only). */
80
+ vippsConfirm?: VippsConfirm | null;
81
+ /** Controlled memory, for a caller whose panel unmounts (a sheet). */
82
+ memory?: LoginPanelMemory;
83
+ onMemoryChange?: Dispatch<SetStateAction<LoginPanelMemory>>;
84
+ /** Draws the heading («Log in» / «Check your e-mail»). Default: none — the page has its own. */
85
+ heading?: (text: string) => ReactNode;
86
+ /** Drawn above the Vipps button on the first screen. */
87
+ intro?: ReactNode;
88
+ /** Wait between codes. Default 30 s. */
89
+ resendCooldownMs?: number;
90
+ classNames?: SlotClassNames<LoginPanelSlot>;
91
+ otpClassNames?: SlotClassNames<OtpSlotsSlot>;
92
+ vippsClassNames?: SlotClassNames<VippsButtonSlot>;
93
+ }
94
+ /**
95
+ * The login itself: Vipps and an e-mail address, then the code that address
96
+ * received. Knows nothing about where it is drawn — the heading, the intro and
97
+ * what a good code leads to are the caller's.
98
+ *
99
+ * Two things it is careful NOT to say: whether the address is a customer (the
100
+ * «sent» copy is conditional, and `onStartLogin` should resolve `ok` for every
101
+ * address), and that a code was sent when asking for one failed.
102
+ *
103
+ * THE CODE FIELD fills from one paste, sends itself on the sixth digit, and
104
+ * empties itself and takes the caret back after a wrong code. «Send a new code»
105
+ * waits between asks, so an impatient tap does not spend the rate-limit
106
+ * allowance on a code that is still valid.
107
+ */
108
+ export declare function LoginPanel({ labels, onStartLogin, onVerify, onSignedIn, onVipps, vippsNext, vippsLabel, vippsConfirm, memory: memoryProp, onMemoryChange, heading, intro, resendCooldownMs, classNames, otpClassNames, vippsClassNames, }: LoginPanelProps): import("react").JSX.Element;
@@ -0,0 +1,239 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useEffect, useId, useRef, useState, useTransition, } from 'react';
4
+ import { Field } from '../primitives/field.js';
5
+ import { Input } from '../primitives/input.js';
6
+ import { renderLabel } from './internal/label-parts.js';
7
+ import { BOOKING_INPUT_CLASS, BookingButton } from './internal/ui.js';
8
+ import { OTP_SLOTS_LABEL_KEYS, OtpSlots, } from './otp-slots.js';
9
+ import { slotClass } from './slots.js';
10
+ import { VIPPS_BUTTON_LABEL_KEYS, VippsButton, } from './vipps-button.js';
11
+ // ---------------------------------------------------------------------------
12
+ // Contract
13
+ // ---------------------------------------------------------------------------
14
+ export const LOGIN_PANEL_OWN_LABEL_KEYS = [
15
+ 'login.heading',
16
+ 'login.codeHeading',
17
+ 'login.or',
18
+ 'login.emailIntro',
19
+ 'login.emailLabel',
20
+ 'login.sendCode',
21
+ 'login.codeLabel',
22
+ 'login.submitCode',
23
+ 'login.codeHelp',
24
+ 'login.codeHelpVipps',
25
+ 'login.vippsSentTo',
26
+ 'login.vippsSentToUnknown',
27
+ 'login.cooldownWait',
28
+ 'login.cooldownReady',
29
+ 'login.resendIn',
30
+ 'login.resend',
31
+ 'login.switchEmail',
32
+ 'login.useEmailInstead',
33
+ 'login.notice.sent',
34
+ 'login.notice.resent',
35
+ 'login.notice.unreachable',
36
+ 'login.notice.badEmail',
37
+ 'login.notice.invalid',
38
+ 'login.notice.throttled',
39
+ 'login.notice.vippsInvalid',
40
+ 'login.notice.vippsConflict',
41
+ ];
42
+ export const LOGIN_PANEL_LABEL_KEYS = [
43
+ ...LOGIN_PANEL_OWN_LABEL_KEYS,
44
+ ...OTP_SLOTS_LABEL_KEYS,
45
+ ...VIPPS_BUTTON_LABEL_KEYS,
46
+ ];
47
+ export const FRESH_LOGIN_MEMORY = {
48
+ mode: 'start',
49
+ email: '',
50
+ sentTo: '',
51
+ resendAt: 0,
52
+ };
53
+ /** Where a Vipps return that asks for the code starts: on the code, for `to`. */
54
+ export function loginMemoryFor(vippsConfirm) {
55
+ return vippsConfirm
56
+ ? { mode: 'vipps', email: '', sentTo: vippsConfirm.to ?? '', resendAt: 0 }
57
+ : FRESH_LOGIN_MEMORY;
58
+ }
59
+ /** The notices that describe a mistake in the field they sit under. */
60
+ const FIELD_ERRORS = new Set([
61
+ 'badEmail',
62
+ 'invalid',
63
+ 'throttled',
64
+ 'vippsInvalid',
65
+ 'vippsConflict',
66
+ ]);
67
+ /** «0:30» — minutes and zero-padded seconds, the way a phone shows a timer. */
68
+ function timer(seconds) {
69
+ return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`;
70
+ }
71
+ function verifyNotice(reason, vipps) {
72
+ if (reason === 'invalid')
73
+ return vipps ? 'vippsInvalid' : 'invalid';
74
+ if (reason === 'conflict')
75
+ return 'vippsConflict';
76
+ return reason;
77
+ }
78
+ // ---------------------------------------------------------------------------
79
+ // Panel
80
+ // ---------------------------------------------------------------------------
81
+ /**
82
+ * The login itself: Vipps and an e-mail address, then the code that address
83
+ * received. Knows nothing about where it is drawn — the heading, the intro and
84
+ * what a good code leads to are the caller's.
85
+ *
86
+ * Two things it is careful NOT to say: whether the address is a customer (the
87
+ * «sent» copy is conditional, and `onStartLogin` should resolve `ok` for every
88
+ * address), and that a code was sent when asking for one failed.
89
+ *
90
+ * THE CODE FIELD fills from one paste, sends itself on the sixth digit, and
91
+ * empties itself and takes the caret back after a wrong code. «Send a new code»
92
+ * waits between asks, so an impatient tap does not spend the rate-limit
93
+ * allowance on a code that is still valid.
94
+ */
95
+ export function LoginPanel({ labels, onStartLogin, onVerify, onSignedIn, onVipps, vippsNext = null, vippsLabel, vippsConfirm = null, memory: memoryProp, onMemoryChange, heading = () => null, intro, resendCooldownMs = 30_000, classNames, otpClassNames, vippsClassNames, }) {
96
+ const id = useId();
97
+ const [heldHere, setHeldHere] = useState(() => loginMemoryFor(vippsConfirm));
98
+ const controlled = memoryProp !== undefined && onMemoryChange !== undefined;
99
+ const { mode, email, sentTo, resendAt } = controlled ? memoryProp : heldHere;
100
+ const setMemory = controlled ? onMemoryChange : setHeldHere;
101
+ const remember = (patch) => setMemory((previous) => ({ ...previous, ...patch }));
102
+ const setEmail = (next) => remember({ email: next });
103
+ const [code, setCode] = useState('');
104
+ const [notice, setNotice] = useState(null);
105
+ const [pending, startTransition] = useTransition();
106
+ /** The instant the countdown last read. */
107
+ const [now, setNow] = useState(() => Date.now());
108
+ /** Put the caret back into the code field once it is usable again. */
109
+ const [focusCode, setFocusCode] = useState(mode === 'code');
110
+ /** A verify in flight, read synchronously: the sixth digit and a submit can
111
+ * arrive in the same tick, before `pending` has rendered. */
112
+ const verifying = useRef(false);
113
+ const [checking, setChecking] = useState(false);
114
+ const codeRef = useRef(null);
115
+ const emailId = `${id}-email`;
116
+ const codeId = `${id}-code`;
117
+ const noticeId = `${id}-notice`;
118
+ const waitSeconds = Math.max(0, Math.ceil((resendAt - now) / 1000));
119
+ // One tick a second while the wait is on, and none at all once it is over.
120
+ useEffect(() => {
121
+ if (mode !== 'code' || resendAt <= Date.now())
122
+ return;
123
+ const interval = setInterval(() => {
124
+ const tick = Date.now();
125
+ setNow(tick);
126
+ if (tick >= resendAt)
127
+ clearInterval(interval);
128
+ }, 1000);
129
+ return () => clearInterval(interval);
130
+ }, [mode, resendAt]);
131
+ // A disabled input cannot hold focus, so the caret goes back when the field
132
+ // is usable again.
133
+ useEffect(() => {
134
+ if (!focusCode || pending || mode !== 'code')
135
+ return;
136
+ setFocusCode(false);
137
+ codeRef.current?.querySelector('input')?.focus();
138
+ }, [focusCode, pending, mode]);
139
+ function requestCode(address, resend) {
140
+ startTransition(async () => {
141
+ let result;
142
+ try {
143
+ result = await onStartLogin(address);
144
+ }
145
+ catch {
146
+ result = { ok: false, reason: 'unreachable' };
147
+ }
148
+ if (!result.ok) {
149
+ setNotice(result.reason === 'invalidEmail' ? 'badEmail' : 'unreachable');
150
+ return;
151
+ }
152
+ const sentAt = Date.now();
153
+ setCode('');
154
+ setNotice(resend ? 'resent' : 'sent');
155
+ setNow(sentAt);
156
+ remember({ mode: 'code', sentTo: address, resendAt: sentAt + resendCooldownMs });
157
+ setFocusCode(true);
158
+ });
159
+ }
160
+ function submitEmail(event) {
161
+ event.preventDefault();
162
+ const trimmed = email.trim();
163
+ setEmail(trimmed);
164
+ requestCode(trimmed, false);
165
+ }
166
+ function verify(value) {
167
+ if (verifying.current)
168
+ return;
169
+ verifying.current = true;
170
+ setChecking(true);
171
+ const vipps = mode === 'vipps';
172
+ startTransition(async () => {
173
+ try {
174
+ let outcome;
175
+ try {
176
+ outcome = await onVerify(value.trim(), {
177
+ mode: vipps ? 'vipps' : 'email',
178
+ email: sentTo,
179
+ });
180
+ }
181
+ catch {
182
+ outcome = { ok: false, reason: 'unreachable' };
183
+ }
184
+ if (outcome.ok) {
185
+ setNotice(null);
186
+ onSignedIn(outcome.guardian);
187
+ return;
188
+ }
189
+ const next = verifyNotice(outcome.reason, vipps);
190
+ setNotice(next);
191
+ if (next === 'invalid' || next === 'vippsInvalid') {
192
+ // Empty, and the caret back in it: the six digits on screen are
193
+ // known to be wrong, and retyping over them is slower than typing.
194
+ setCode('');
195
+ setFocusCode(true);
196
+ }
197
+ }
198
+ finally {
199
+ verifying.current = false;
200
+ setChecking(false);
201
+ }
202
+ });
203
+ }
204
+ function submitCode(event) {
205
+ event.preventDefault();
206
+ verify(code);
207
+ }
208
+ function switchEmail() {
209
+ remember({ mode: 'start' });
210
+ setCode('');
211
+ setNotice(null);
212
+ }
213
+ const isFieldError = notice !== null && FIELD_ERRORS.has(notice);
214
+ const noticeText = notice === null ? null : labels[`login.notice.${notice}`];
215
+ // One live region for the whole flow, ABOVE the switch between the two
216
+ // screens so it stays mounted across it: a region that arrives already
217
+ // holding its sentence is often read as nothing at all. It doubles as the
218
+ // description of whichever input the notice is about.
219
+ const liveRegion = (_jsx("div", { "aria-live": "polite", children: noticeText &&
220
+ (isFieldError ? (_jsx(Field.Error, { id: noticeId, children: noticeText })) : (_jsx("p", { id: noticeId, className: slotClass(classNames, 'notice', 'text-sm text-muted-foreground'), children: noticeText }))) }));
221
+ // The countdown on the button changes every second; this region says only
222
+ // the two things worth hearing — how long, and that it is over.
223
+ const cooldownSentence = waitSeconds > 0
224
+ ? renderLabel(labels['login.cooldownWait'], { seconds: Math.ceil(resendCooldownMs / 1000) })
225
+ : labels['login.cooldownReady'];
226
+ const cooldownRegion = (_jsx("p", { "aria-live": "polite", className: "sr-only", children: mode === 'code' ? cooldownSentence : null }));
227
+ const link = slotClass(classNames, 'link', 'font-semibold text-primary underline underline-offset-4 disabled:no-underline disabled:opacity-60');
228
+ return (_jsxs("div", { className: slotClass(classNames, 'root', 'space-y-6'), children: [heading(mode === 'start' ? labels['login.heading'] : labels['login.codeHeading']), mode === 'vipps' && (_jsx("p", { className: "text-muted-foreground", children: sentTo === ''
229
+ ? labels['login.vippsSentToUnknown']
230
+ : renderLabel(labels['login.vippsSentTo'], { to: sentTo }) })), liveRegion, cooldownRegion, mode === 'start' ? (_jsxs(_Fragment, { children: [intro, onVipps && (_jsxs(_Fragment, { children: [_jsx(VippsButton, { onVipps: onVipps, next: vippsNext, label: vippsLabel, labels: labels, classNames: vippsClassNames }), _jsxs("div", { className: slotClass(classNames, 'divider', 'flex items-center gap-4 text-sm text-muted-foreground'), "aria-hidden": "true", children: [_jsx("span", { className: "h-px flex-1 bg-border" }), labels['login.or'], _jsx("span", { className: "h-px flex-1 bg-border" })] })] })), _jsx("p", { className: "text-muted-foreground", children: labels['login.emailIntro'] }), _jsxs("form", { onSubmit: submitEmail, className: slotClass(classNames, 'form', 'space-y-6'), children: [_jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: emailId, children: labels['login.emailLabel'] }), _jsx(Input, { id: emailId, type: "email", autoComplete: "email", required: true, value: email, "aria-describedby": noticeText ? noticeId : undefined, "aria-invalid": isFieldError || undefined, onChange: (event) => setEmail(event.target.value), className: slotClass(classNames, 'input', BOOKING_INPUT_CLASS) })] }), _jsx(BookingButton, { type: "submit", size: "lg", className: slotClass(classNames, 'submit', 'w-full'), disabled: pending, children: labels['login.sendCode'] })] })] })) : (_jsxs(_Fragment, { children: [_jsxs("form", { onSubmit: submitCode, className: slotClass(classNames, 'form', 'space-y-6'), children: [_jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: codeId, children: labels['login.codeLabel'] }), _jsx("div", { ref: codeRef, children: _jsx(OtpSlots, { id: codeId, value: code,
231
+ // Read-only rather than disabled while a request is out, so
232
+ // the caret stays in the field instead of falling out of a
233
+ // dialog; a wrong code then only has to empty it.
234
+ readOnly: checking, invalid: isFieldError, describedBy: `${codeId}-help${noticeText ? ` ${noticeId}` : ''}`, labels: labels, classNames: otpClassNames, onChange: setCode, onComplete: verify }) }), _jsx(Field.Description, { id: `${codeId}-help`, children: mode === 'vipps'
235
+ ? labels['login.codeHelpVipps']
236
+ : renderLabel(labels['login.codeHelp'], { email: sentTo }) })] }), _jsx(BookingButton, { type: "submit", size: "lg", className: slotClass(classNames, 'submit', 'w-full'), disabled: pending, children: labels['login.submitCode'] })] }), _jsxs("div", { className: slotClass(classNames, 'links', 'flex flex-wrap items-center gap-4 text-sm'), children: [mode !== 'vipps' && (_jsx("button", { type: "button", disabled: pending || waitSeconds > 0, onClick: () => requestCode(sentTo, true), className: link, children: waitSeconds > 0
237
+ ? renderLabel(labels['login.resendIn'], { time: timer(waitSeconds) })
238
+ : labels['login.resend'] })), _jsx("button", { type: "button", disabled: pending, onClick: switchEmail, className: link, children: mode === 'vipps' ? labels['login.useEmailInstead'] : labels['login.switchEmail'] })] })] }))] }));
239
+ }
@@ -0,0 +1,41 @@
1
+ import { type LoginPanelLabels, type LoginPanelProps } from './login-panel.js';
2
+ import { type SlotClassNames } from './slots.js';
3
+ import type { BookingGuardian } from './types.js';
4
+ export declare const LOGIN_SHEET_OWN_LABEL_KEYS: readonly ["loginSheet.prompt", "loginSheet.trigger", "loginSheet.intro", "loginSheet.vipps", "loginSheet.close"];
5
+ export declare const LOGIN_SHEET_LABEL_KEYS: readonly ["loginSheet.prompt", "loginSheet.trigger", "loginSheet.intro", "loginSheet.vipps", "loginSheet.close", "login.heading", "login.codeHeading", "login.or", "login.emailIntro", "login.emailLabel", "login.sendCode", "login.codeLabel", "login.submitCode", "login.codeHelp", "login.codeHelpVipps", "login.vippsSentTo", "login.vippsSentToUnknown", "login.cooldownWait", "login.cooldownReady", "login.resendIn", "login.resend", "login.switchEmail", "login.useEmailInstead", "login.notice.sent", "login.notice.resent", "login.notice.unreachable", "login.notice.badEmail", "login.notice.invalid", "login.notice.throttled", "login.notice.vippsInvalid", "login.notice.vippsConflict", "otp.label", "vipps.button", "vipps.unavailable", "vipps.throttled"];
6
+ /** `loginSheet.prompt` holds a `{trigger}` placeholder where the trigger button goes. */
7
+ export type LoginSheetLabels = Record<(typeof LOGIN_SHEET_OWN_LABEL_KEYS)[number], string> & LoginPanelLabels;
8
+ /**
9
+ * - `prompt`: the «have an account? Log in» row.
10
+ * - `trigger`: the button inside it.
11
+ * - `content`: the sheet (`Sheet.Content`).
12
+ * - `title`, `description`: the sheet's heading and intro.
13
+ * - `close`: the close button.
14
+ */
15
+ export type LoginSheetSlot = 'prompt' | 'trigger' | 'content' | 'title' | 'description' | 'close';
16
+ export interface LoginSheetProps extends Pick<LoginPanelProps, 'onStartLogin' | 'onVerify' | 'onVipps' | 'vippsConfirm' | 'resendCooldownMs' | 'otpClassNames' | 'vippsClassNames'> {
17
+ labels: LoginSheetLabels;
18
+ /**
19
+ * A good code. The sheet has already closed itself. `guardian` is who
20
+ * logged in, or `null` when that read failed.
21
+ */
22
+ onSignedIn: (guardian: BookingGuardian | null) => void;
23
+ /** Where a Vipps login should land (the caller's resume URL). */
24
+ resumePath: string;
25
+ /** Draw the prompt row. Off for a sheet opened for a Vipps confirm alone. Default `true`. */
26
+ trigger?: boolean;
27
+ classNames?: SlotClassNames<LoginSheetSlot>;
28
+ panelClassNames?: LoginPanelProps['classNames'];
29
+ }
30
+ /**
31
+ * The login as a booking flow offers it: a prompt row that opens a modal
32
+ * sheet (bottom sheet on phones, centred dialog from `md` up). It is an OFFER
33
+ * — the flow never waits on it — and it never navigates: a good code hands
34
+ * the user back through `onSignedIn` and the flow fills its fields in place.
35
+ *
36
+ * The login's memory (which screen, which address, the resend wait) lives
37
+ * here, above the sheet's content, which unmounts on close — so closing the
38
+ * sheet to check the mail and reopening it lands back on the code screen with
39
+ * the wait still running.
40
+ */
41
+ export declare function LoginSheet({ labels, onStartLogin, onVerify, onVipps, onSignedIn, resumePath, vippsConfirm, trigger, resendCooldownMs, classNames, panelClassNames, otpClassNames, vippsClassNames, }: LoginSheetProps): import("react").JSX.Element;
@@ -0,0 +1,49 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { XIcon } from 'lucide-react';
4
+ import { useState } from 'react';
5
+ import { Sheet } from '../primitives/sheet.js';
6
+ import { labelParts } from './internal/label-parts.js';
7
+ import { BookingButton } from './internal/ui.js';
8
+ import { LOGIN_PANEL_LABEL_KEYS, LoginPanel, loginMemoryFor, } from './login-panel.js';
9
+ import { slotClass } from './slots.js';
10
+ export const LOGIN_SHEET_OWN_LABEL_KEYS = [
11
+ 'loginSheet.prompt',
12
+ 'loginSheet.trigger',
13
+ 'loginSheet.intro',
14
+ 'loginSheet.vipps',
15
+ 'loginSheet.close',
16
+ ];
17
+ export const LOGIN_SHEET_LABEL_KEYS = [
18
+ ...LOGIN_SHEET_OWN_LABEL_KEYS,
19
+ ...LOGIN_PANEL_LABEL_KEYS,
20
+ ];
21
+ /**
22
+ * The login as a booking flow offers it: a prompt row that opens a modal
23
+ * sheet (bottom sheet on phones, centred dialog from `md` up). It is an OFFER
24
+ * — the flow never waits on it — and it never navigates: a good code hands
25
+ * the user back through `onSignedIn` and the flow fills its fields in place.
26
+ *
27
+ * The login's memory (which screen, which address, the resend wait) lives
28
+ * here, above the sheet's content, which unmounts on close — so closing the
29
+ * sheet to check the mail and reopening it lands back on the code screen with
30
+ * the wait still running.
31
+ */
32
+ export function LoginSheet({ labels, onStartLogin, onVerify, onVipps, onSignedIn, resumePath, vippsConfirm = null, trigger = true, resendCooldownMs, classNames, panelClassNames, otpClassNames, vippsClassNames, }) {
33
+ // Open from the start for a Vipps return: the user is here to type a code.
34
+ const [open, setOpen] = useState(vippsConfirm !== null);
35
+ const [memory, setMemory] = useState(() => loginMemoryFor(vippsConfirm));
36
+ /**
37
+ * Set by a good code. The prompt row goes with it — the caller knows the
38
+ * user now — so the sheet has no trigger to hand focus back to, and the
39
+ * caller can put it on its own heading instead.
40
+ */
41
+ const [signedIn, setSignedIn] = useState(false);
42
+ return (_jsxs(Sheet, { open: open, onOpenChange: setOpen, children: [trigger && !signedIn && (_jsx("p", { className: slotClass(classNames, 'prompt', 'text-sm text-muted-foreground'), children: labelParts(labels['loginSheet.prompt'], {
43
+ trigger: (_jsx(Sheet.Trigger, { className: slotClass(classNames, 'trigger', 'font-semibold text-primary underline underline-offset-4'), children: labels['loginSheet.trigger'] })),
44
+ }) })), _jsxs(Sheet.Content, { className: slotClass(classNames, 'content', 'z-50'), children: [_jsx(LoginPanel, { labels: labels, memory: memory, onMemoryChange: setMemory, onStartLogin: onStartLogin, onVerify: onVerify, onVipps: onVipps, vippsNext: resumePath, vippsLabel: labels['loginSheet.vipps'], resendCooldownMs: resendCooldownMs, classNames: panelClassNames, otpClassNames: otpClassNames, vippsClassNames: vippsClassNames, onSignedIn: (guardian) => {
45
+ setSignedIn(true);
46
+ setOpen(false);
47
+ onSignedIn(guardian);
48
+ }, heading: (text) => (_jsx(Sheet.Title, { className: slotClass(classNames, 'title', 'pe-10 font-sans text-2xl font-bold'), children: text })), intro: _jsx(Sheet.Description, { className: slotClass(classNames, 'description', 'text-muted-foreground'), children: labels['loginSheet.intro'] }) }), _jsx(Sheet.Close, { render: _jsxs(BookingButton, { variant: "ghost", size: "icon-sm", className: slotClass(classNames, 'close', 'absolute top-4 right-4'), children: [_jsx(XIcon, { "aria-hidden": "true" }), _jsx("span", { className: "sr-only", children: labels['loginSheet.close'] })] }) })] })] }));
49
+ }