@medalsocial/booking 0.0.0-placeholder.0 → 0.2.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.
- package/CHANGELOG.md +27 -0
- package/LICENSE +201 -0
- package/NOTICE +6 -0
- package/README.md +220 -1
- package/dist/core/age.d.mts +34 -0
- package/dist/core/age.mjs +95 -0
- package/dist/core/age.mjs.map +1 -0
- package/dist/core/attempt-store.d.mts +111 -0
- package/dist/core/attempt-store.mjs +159 -0
- package/dist/core/attempt-store.mjs.map +1 -0
- package/dist/core/categories.d.mts +27 -0
- package/dist/core/categories.mjs +42 -0
- package/dist/core/categories.mjs.map +1 -0
- package/dist/core/clock.d.mts +33 -0
- package/dist/core/clock.mjs +251 -0
- package/dist/core/clock.mjs.map +1 -0
- package/dist/core/config.d.mts +178 -0
- package/dist/core/config.mjs +394 -0
- package/dist/core/config.mjs.map +1 -0
- package/dist/core/consent.d.mts +14 -0
- package/dist/core/consent.mjs +17 -0
- package/dist/core/consent.mjs.map +1 -0
- package/dist/core/deep-link.d.mts +40 -0
- package/dist/core/deep-link.mjs +160 -0
- package/dist/core/deep-link.mjs.map +1 -0
- package/dist/core/display-name.d.mts +18 -0
- package/dist/core/display-name.mjs +30 -0
- package/dist/core/display-name.mjs.map +1 -0
- package/dist/core/draft-store.d.mts +89 -0
- package/dist/core/draft-store.mjs +131 -0
- package/dist/core/draft-store.mjs.map +1 -0
- package/dist/core/dto.d.mts +46 -0
- package/dist/core/dto.mjs +157 -0
- package/dist/core/dto.mjs.map +1 -0
- package/dist/core/ics.d.mts +63 -0
- package/dist/core/ics.mjs +130 -0
- package/dist/core/ics.mjs.map +1 -0
- package/dist/core/index.d.mts +35 -0
- package/dist/core/index.mjs +32 -0
- package/dist/core/labels.d.mts +88 -0
- package/dist/core/labels.mjs +93 -0
- package/dist/core/labels.mjs.map +1 -0
- package/dist/core/machine.d.mts +645 -0
- package/dist/core/machine.mjs +988 -0
- package/dist/core/machine.mjs.map +1 -0
- package/dist/core/money.d.mts +12 -0
- package/dist/core/money.mjs +51 -0
- package/dist/core/money.mjs.map +1 -0
- package/dist/core/next-available.d.mts +20 -0
- package/dist/core/next-available.mjs +36 -0
- package/dist/core/next-available.mjs.map +1 -0
- package/dist/core/next-free.d.mts +25 -0
- package/dist/core/next-free.mjs +44 -0
- package/dist/core/next-free.mjs.map +1 -0
- package/dist/core/party-slots.d.mts +79 -0
- package/dist/core/party-slots.mjs +229 -0
- package/dist/core/party-slots.mjs.map +1 -0
- package/dist/core/paths.d.mts +11 -0
- package/dist/core/paths.mjs +56 -0
- package/dist/core/paths.mjs.map +1 -0
- package/dist/core/phone.d.mts +17 -0
- package/dist/core/phone.mjs +58 -0
- package/dist/core/phone.mjs.map +1 -0
- package/dist/core/portal/cookies.d.mts +29 -0
- package/dist/core/portal/dto.d.mts +140 -0
- package/dist/core/portal/dto.mjs +142 -0
- package/dist/core/portal/dto.mjs.map +1 -0
- package/dist/core/portal/family.d.mts +39 -0
- package/dist/core/portal/family.mjs +39 -0
- package/dist/core/portal/family.mjs.map +1 -0
- package/dist/core/portal/login-input.d.mts +29 -0
- package/dist/core/portal/login-input.mjs +29 -0
- package/dist/core/portal/login-input.mjs.map +1 -0
- package/dist/core/portal/next-path.d.mts +12 -0
- package/dist/core/portal/next-path.mjs +98 -0
- package/dist/core/portal/next-path.mjs.map +1 -0
- package/dist/core/portal/return-path.d.mts +7 -0
- package/dist/core/portal/return-path.mjs +49 -0
- package/dist/core/portal/return-path.mjs.map +1 -0
- package/dist/core/portal/session-cookie.d.mts +27 -0
- package/dist/core/portal/session-cookie.mjs +30 -0
- package/dist/core/portal/session-cookie.mjs.map +1 -0
- package/dist/core/portal/vipps-link.d.mts +56 -0
- package/dist/core/portal/vipps-link.mjs +136 -0
- package/dist/core/portal/vipps-link.mjs.map +1 -0
- package/dist/core/portal/vipps-return.d.mts +39 -0
- package/dist/core/portal/vipps-return.mjs +69 -0
- package/dist/core/portal/vipps-return.mjs.map +1 -0
- package/dist/core/rebook-store.d.mts +28 -0
- package/dist/core/rebook-store.mjs +60 -0
- package/dist/core/rebook-store.mjs.map +1 -0
- package/dist/core/restore-gate.d.mts +40 -0
- package/dist/core/restore-gate.mjs +96 -0
- package/dist/core/restore-gate.mjs.map +1 -0
- package/dist/core/stores.d.mts +8 -0
- package/dist/core/stores.mjs +23 -0
- package/dist/core/stores.mjs.map +1 -0
- package/dist/core/types.d.mts +245 -0
- package/dist/core/visit.d.mts +26 -0
- package/dist/core/visit.mjs +40 -0
- package/dist/core/visit.mjs.map +1 -0
- package/dist/core/wire.d.mts +37 -0
- package/dist/next/cache/memory.d.mts +14 -0
- package/dist/next/cache/memory.mjs +51 -0
- package/dist/next/cache/memory.mjs.map +1 -0
- package/dist/next/cache/next-data.d.mts +6 -0
- package/dist/next/cache/next-data.mjs +35 -0
- package/dist/next/cache/next-data.mjs.map +1 -0
- package/dist/next/cache/noop.d.mts +6 -0
- package/dist/next/cache/noop.mjs +15 -0
- package/dist/next/cache/noop.mjs.map +1 -0
- package/dist/next/cache/workers.d.mts +29 -0
- package/dist/next/cache/workers.mjs +74 -0
- package/dist/next/cache/workers.mjs.map +1 -0
- package/dist/next/catalogue.d.mts +38 -0
- package/dist/next/catalogue.mjs +215 -0
- package/dist/next/catalogue.mjs.map +1 -0
- package/dist/next/handler.d.mts +11 -0
- package/dist/next/handler.mjs +150 -0
- package/dist/next/handler.mjs.map +1 -0
- package/dist/next/index.d.mts +17 -0
- package/dist/next/index.mjs +14 -0
- package/dist/next/loaders.d.mts +79 -0
- package/dist/next/loaders.mjs +195 -0
- package/dist/next/loaders.mjs.map +1 -0
- package/dist/next/manage-dto.d.mts +6 -0
- package/dist/next/manage-dto.mjs +28 -0
- package/dist/next/manage-dto.mjs.map +1 -0
- package/dist/next/medal.d.mts +105 -0
- package/dist/next/medal.mjs +155 -0
- package/dist/next/medal.mjs.map +1 -0
- package/dist/next/options.d.mts +158 -0
- package/dist/next/options.mjs +65 -0
- package/dist/next/options.mjs.map +1 -0
- package/dist/next/portal/guardian.d.mts +42 -0
- package/dist/next/portal/guardian.mjs +76 -0
- package/dist/next/portal/guardian.mjs.map +1 -0
- package/dist/next/portal/medal-portal.d.mts +161 -0
- package/dist/next/portal/medal-portal.mjs +825 -0
- package/dist/next/portal/medal-portal.mjs.map +1 -0
- package/dist/next/portal/session.d.mts +38 -0
- package/dist/next/portal/session.mjs +40 -0
- package/dist/next/portal/session.mjs.map +1 -0
- package/dist/next/portal/vipps-flash.d.mts +14 -0
- package/dist/next/portal/vipps-flash.mjs +41 -0
- package/dist/next/portal/vipps-flash.mjs.map +1 -0
- package/dist/next/portal.d.mts +205 -0
- package/dist/next/portal.mjs +469 -0
- package/dist/next/portal.mjs.map +1 -0
- package/dist/next/request.d.mts +51 -0
- package/dist/next/request.mjs +123 -0
- package/dist/next/request.mjs.map +1 -0
- package/dist/next/routes/catalogue-routes.mjs +344 -0
- package/dist/next/routes/catalogue-routes.mjs.map +1 -0
- package/dist/next/routes/create.mjs +460 -0
- package/dist/next/routes/create.mjs.map +1 -0
- package/dist/next/routes/manage.mjs +191 -0
- package/dist/next/routes/manage.mjs.map +1 -0
- package/dist/next/routes/portal-routes.mjs +312 -0
- package/dist/next/routes/portal-routes.mjs.map +1 -0
- package/dist/next/routes/shared.mjs +162 -0
- package/dist/next/routes/shared.mjs.map +1 -0
- package/dist/next/routes/vipps-return.mjs +166 -0
- package/dist/next/routes/vipps-return.mjs.map +1 -0
- package/dist/next/runtime.d.mts +49 -0
- package/dist/next/runtime.mjs +94 -0
- package/dist/next/runtime.mjs.map +1 -0
- package/dist/next/seed.d.mts +71 -0
- package/dist/next/seed.mjs +495 -0
- package/dist/next/seed.mjs.map +1 -0
- package/dist/next/server.d.mts +26 -0
- package/dist/next/server.mjs +63 -0
- package/dist/next/server.mjs.map +1 -0
- package/dist/react/BookingLink.d.mts +61 -0
- package/dist/react/BookingLink.mjs +102 -0
- package/dist/react/BookingLink.mjs.map +1 -0
- package/dist/react/BookingWizard.d.mts +49 -0
- package/dist/react/BookingWizard.mjs +598 -0
- package/dist/react/BookingWizard.mjs.map +1 -0
- package/dist/react/LoginPageQuery.d.mts +37 -0
- package/dist/react/LoginPageQuery.mjs +106 -0
- package/dist/react/LoginPageQuery.mjs.map +1 -0
- package/dist/react/LoginSheet.d.mts +49 -0
- package/dist/react/LoginSheet.mjs +98 -0
- package/dist/react/LoginSheet.mjs.map +1 -0
- package/dist/react/ManageBooking.d.mts +50 -0
- package/dist/react/ManageBooking.mjs +197 -0
- package/dist/react/ManageBooking.mjs.map +1 -0
- package/dist/react/PortalDashboard.d.mts +10 -0
- package/dist/react/Provider.d.mts +82 -0
- package/dist/react/Provider.mjs +132 -0
- package/dist/react/Provider.mjs.map +1 -0
- package/dist/react/actions.d.mts +133 -0
- package/dist/react/actions.mjs +90 -0
- package/dist/react/actions.mjs.map +1 -0
- package/dist/react/index.d.mts +28 -0
- package/dist/react/index.mjs +20 -0
- package/dist/react/kit.d.mts +54 -0
- package/dist/react/kit.mjs +127 -0
- package/dist/react/kit.mjs.map +1 -0
- package/dist/react/labels/index.d.mts +23 -0
- package/dist/react/labels/index.mjs +44 -0
- package/dist/react/labels/index.mjs.map +1 -0
- package/dist/react/labels/login.d.mts +10 -0
- package/dist/react/labels/login.mjs +13 -0
- package/dist/react/labels/login.mjs.map +1 -0
- package/dist/react/labels/manage.d.mts +15 -0
- package/dist/react/labels/manage.mjs +19 -0
- package/dist/react/labels/manage.mjs.map +1 -0
- package/dist/react/labels/portal.d.mts +25 -0
- package/dist/react/labels/portal.mjs +41 -0
- package/dist/react/labels/portal.mjs.map +1 -0
- package/dist/react/labels/screens.d.mts +11 -0
- package/dist/react/labels/screens.mjs +721 -0
- package/dist/react/labels/screens.mjs.map +1 -0
- package/dist/react/labels/wizard.d.mts +76 -0
- package/dist/react/labels/wizard.mjs +97 -0
- package/dist/react/labels/wizard.mjs.map +1 -0
- package/dist/react/link/index.d.mts +5 -0
- package/dist/react/link/index.mjs +5 -0
- package/dist/react/portal/SessionTouch.d.mts +19 -0
- package/dist/react/portal/SessionTouch.mjs +40 -0
- package/dist/react/portal/SessionTouch.mjs.map +1 -0
- package/dist/react/portal/age-prompt.d.mts +10 -0
- package/dist/react/portal/age-prompt.mjs +21 -0
- package/dist/react/portal/age-prompt.mjs.map +1 -0
- package/dist/react/portal/book-again.d.mts +18 -0
- package/dist/react/portal/book-again.mjs +21 -0
- package/dist/react/portal/book-again.mjs.map +1 -0
- package/dist/react/portal/child-summary.d.mts +17 -0
- package/dist/react/portal/child-summary.mjs +45 -0
- package/dist/react/portal/child-summary.mjs.map +1 -0
- package/dist/react/portal/dashboard.d.mts +74 -0
- package/dist/react/portal/dashboard.mjs +204 -0
- package/dist/react/portal/dashboard.mjs.map +1 -0
- package/dist/react/portal/leave.d.mts +17 -0
- package/dist/react/portal/leave.mjs +21 -0
- package/dist/react/portal/leave.mjs.map +1 -0
- package/dist/react/portal/sections.d.mts +12 -0
- package/dist/react/portal/sections.mjs +48 -0
- package/dist/react/portal/sections.mjs.map +1 -0
- package/dist/react/portal/tabs.d.mts +23 -0
- package/dist/react/portal/tabs.mjs +36 -0
- package/dist/react/portal/tabs.mjs.map +1 -0
- package/dist/react/portal/vipps-flash.d.mts +11 -0
- package/dist/react/portal/vipps-flash.mjs +18 -0
- package/dist/react/portal/vipps-flash.mjs.map +1 -0
- package/dist/react/portal/wired.mjs +202 -0
- package/dist/react/portal/wired.mjs.map +1 -0
- package/dist/react/screen-labels.mjs +14 -0
- package/dist/react/screen-labels.mjs.map +1 -0
- package/dist/react/shared.d.mts +13 -0
- package/dist/react/shared.mjs +7 -0
- package/dist/react/use-next-free.d.mts +19 -0
- package/dist/react/use-next-free.mjs +56 -0
- package/dist/react/use-next-free.mjs.map +1 -0
- package/dist/react/useBooking.d.mts +179 -0
- package/dist/react/useBooking.mjs +1331 -0
- package/dist/react/useBooking.mjs.map +1 -0
- package/dist/react/wizard/adapters.mjs +189 -0
- package/dist/react/wizard/adapters.mjs.map +1 -0
- package/dist/react/wizard/index.d.mts +11 -0
- package/dist/react/wizard/index.mjs +6 -0
- package/dist/react/wizard-error.mjs +29 -0
- package/dist/react/wizard-error.mjs.map +1 -0
- package/package.json +126 -5
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { normaliseCategory } from "./categories.mjs";
|
|
2
|
+
//#region src/core/dto.ts
|
|
3
|
+
/**
|
|
4
|
+
* Where Medal's wire shape becomes this site's — one definition per shape.
|
|
5
|
+
*
|
|
6
|
+
* `medal-client.ts` deliberately refuses to do this. It types the wire honestly,
|
|
7
|
+
* nullable throughout, and leaves what a missing price or duration ought to mean
|
|
8
|
+
* to the layer that renders it. This is that layer, and it is a module rather
|
|
9
|
+
* than a private function in one route because four places now need the same
|
|
10
|
+
* answers: the services route, the resources route, the manage page and the
|
|
11
|
+
* booking page.
|
|
12
|
+
*
|
|
13
|
+
* The defaults are chosen to be visibly wrong rather than plausibly wrong. A
|
|
14
|
+
* missing duration is `0`, not 30: a wrong-but-believable number would size a
|
|
15
|
+
* slot against a service the salon never described. `maxPerBooking` is the one
|
|
16
|
+
* that must not be `0` — `partyLimit` takes `Math.min` over it, JS coerces
|
|
17
|
+
* `null` to 0 there, and a limit of zero silently refuses to add the second
|
|
18
|
+
* child to a family booking. `1` is the conservative reading of a service nobody
|
|
19
|
+
* has set a party size for: bookable, but alone.
|
|
20
|
+
*
|
|
21
|
+
* Types only from the SDK (`./wire`), never values: the service step imports
|
|
22
|
+
* `normaliseServiceCategory` from here into the browser, and `import type` is
|
|
23
|
+
* erased, so the client bundle never carries the SDK.
|
|
24
|
+
*
|
|
25
|
+
* `createDto(config)` binds the three that read the site's config — the
|
|
26
|
+
* category list, the fallback category and the avatar path.
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* `category` is a free string on the wire, and the salon can add one in the
|
|
30
|
+
* dashboard at any time. Grouping strictly by the five known keys would drop
|
|
31
|
+
* that service off whichever page is grouping — bookable, priced, and invisible
|
|
32
|
+
* — so anything unrecognised joins the catch-all the seed already uses for
|
|
33
|
+
* threading, machine cuts and ear piercing.
|
|
34
|
+
*/
|
|
35
|
+
function normaliseServiceCategory(config, category) {
|
|
36
|
+
return normaliseCategory(config, category);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* `category` stays a free string here rather than being narrowed: `WizardService`
|
|
40
|
+
* types it as one, and the two consumers group by it with their own labels.
|
|
41
|
+
* `normaliseServiceCategory` is what either of them applies at the point of
|
|
42
|
+
* grouping.
|
|
43
|
+
*/
|
|
44
|
+
function toBookingServiceDto(config, service) {
|
|
45
|
+
return {
|
|
46
|
+
id: service.id,
|
|
47
|
+
name: service.name ?? "",
|
|
48
|
+
category: service.category ?? config.fallbackCategory,
|
|
49
|
+
durationMinutes: service.duration_minutes ?? 0,
|
|
50
|
+
bufferBeforeMinutes: service.buffer_before_minutes ?? 0,
|
|
51
|
+
bufferAfterMinutes: service.buffer_after_minutes ?? 0,
|
|
52
|
+
priceOre: service.price_ore ?? 0,
|
|
53
|
+
maxPerBooking: service.max_per_booking ?? 1,
|
|
54
|
+
weekendSurchargePct: service.weekend_surcharge_pct ?? 0,
|
|
55
|
+
bookableOnline: service.bookable_online,
|
|
56
|
+
...ageBound("ageMinYears", service.age_min_years),
|
|
57
|
+
...ageBound("ageMaxYears", service.age_max_years)
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function ageBound(key, value) {
|
|
61
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 120 ? { [key]: value } : {};
|
|
62
|
+
}
|
|
63
|
+
/** What a resource id may look like before the avatar route will look it up
|
|
64
|
+
* — and before this module will build a URL out of it. */
|
|
65
|
+
const RESOURCE_ID_SHAPE = /^[\w-]{1,64}$/;
|
|
66
|
+
/**
|
|
67
|
+
* The stylist's photo on THIS site, not Medal's presigned R2 URL.
|
|
68
|
+
*
|
|
69
|
+
* The presigned URL is re-signed on every read, so the browser saw a new URL
|
|
70
|
+
* per page and cached nothing; the proxy route gives each stylist one stable,
|
|
71
|
+
* cacheable address. An id the route would refuse gets no photo (initials)
|
|
72
|
+
* rather than a broken image.
|
|
73
|
+
*/
|
|
74
|
+
function avatarPath(config, resourceId) {
|
|
75
|
+
return RESOURCE_ID_SHAPE.test(resourceId) ? `${config.paths.avatar}/${resourceId}` : null;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* A stylist nobody has given a position lands after everyone who has one.
|
|
79
|
+
*
|
|
80
|
+
* `MAX_SAFE_INTEGER` rather than `0`, which is what a coalesce-to-falsy would
|
|
81
|
+
* produce: step 2 sorts ascending, so a zero would put the salon's least
|
|
82
|
+
* configured stylist at the top of the list, above the person they deliberately
|
|
83
|
+
* ordered first.
|
|
84
|
+
*
|
|
85
|
+
* The name is `''` for the same reason the services route leaves a nameless
|
|
86
|
+
* service `''` — the salon can see the blank and fix it, where dropping the row
|
|
87
|
+
* would make a stylist they configured unbookable with nothing on screen to say
|
|
88
|
+
* why.
|
|
89
|
+
*/
|
|
90
|
+
function toBookingResourceDto(config, resource) {
|
|
91
|
+
return {
|
|
92
|
+
id: resource.id,
|
|
93
|
+
name: resource.name ?? "",
|
|
94
|
+
photoUrl: resource.photo_url ? avatarPath(config, resource.id) : null,
|
|
95
|
+
bio: resource.bio,
|
|
96
|
+
serviceIds: resource.service_ids,
|
|
97
|
+
sortOrder: resource.sort_order ?? Number.MAX_SAFE_INTEGER
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Zero or one slot, so a timeless one is dropped rather than emitted.
|
|
102
|
+
*
|
|
103
|
+
* `start_ts` is nullable on the wire like every other timestamp. A `NaN` carried
|
|
104
|
+
* through would render as an «Invalid Date» chip that books nothing, and would
|
|
105
|
+
* reach `createBooking` as a `start_ts` the engine rejects.
|
|
106
|
+
*/
|
|
107
|
+
function toBookingSlotDto(slot) {
|
|
108
|
+
const startTs = slot.start_ts === null ? NaN : Date.parse(slot.start_ts);
|
|
109
|
+
if (!Number.isFinite(startTs)) return [];
|
|
110
|
+
return [{
|
|
111
|
+
startTs,
|
|
112
|
+
resourceId: slot.resource_id
|
|
113
|
+
}];
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Zero or one open day, so a date the wire could not describe is dropped rather
|
|
117
|
+
* than emitted.
|
|
118
|
+
*
|
|
119
|
+
* Dropping is the SAFE failure here, and it is worth being explicit about why,
|
|
120
|
+
* because the two directions are not symmetric. A date missing from this list
|
|
121
|
+
* reads downstream as «the salon keeps no hours then», which at worst shows
|
|
122
|
+
* «Stengt» beside a day that had none of its slots taken — visible, and it
|
|
123
|
+
* costs the visitor a tap on another day. A date emitted with a nonsense
|
|
124
|
+
* `opensTs` reads as open, and `NaN` compares false against every clock check
|
|
125
|
+
* the step makes, so the day would silently fall through to «Fullt» — the exact
|
|
126
|
+
* false claim about the business this whole path exists to stop.
|
|
127
|
+
*
|
|
128
|
+
* `lastStartTs` is the one field allowed to be null after this: there, null is
|
|
129
|
+
* an ANSWER (open on paper, shut on the day) rather than an unreadable value.
|
|
130
|
+
*/
|
|
131
|
+
function toBookingDayDto(day) {
|
|
132
|
+
if (day.date === null) return [];
|
|
133
|
+
const opensTs = day.opens_ts === null ? NaN : Date.parse(day.opens_ts);
|
|
134
|
+
const closesTs = day.closes_ts === null ? NaN : Date.parse(day.closes_ts);
|
|
135
|
+
if (!Number.isFinite(opensTs) || !Number.isFinite(closesTs)) return [];
|
|
136
|
+
const parsedLastStart = day.last_start_ts === null ? NaN : Date.parse(day.last_start_ts);
|
|
137
|
+
return [{
|
|
138
|
+
dayKey: day.date,
|
|
139
|
+
opensTs,
|
|
140
|
+
closesTs,
|
|
141
|
+
lastStartTs: Number.isFinite(parsedLastStart) ? parsedLastStart : null
|
|
142
|
+
}];
|
|
143
|
+
}
|
|
144
|
+
function createDto(config) {
|
|
145
|
+
return {
|
|
146
|
+
normaliseServiceCategory: (category) => normaliseServiceCategory(config, category),
|
|
147
|
+
toBookingServiceDto: (service) => toBookingServiceDto(config, service),
|
|
148
|
+
avatarPath: (resourceId) => avatarPath(config, resourceId),
|
|
149
|
+
toBookingResourceDto: (resource) => toBookingResourceDto(config, resource),
|
|
150
|
+
toBookingSlotDto,
|
|
151
|
+
toBookingDayDto
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
//#endregion
|
|
155
|
+
export { RESOURCE_ID_SHAPE, createDto, toBookingDayDto, toBookingSlotDto };
|
|
156
|
+
|
|
157
|
+
//# sourceMappingURL=dto.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dto.mjs","names":[],"sources":["../../src/core/dto.ts"],"sourcesContent":["/**\n * Where Medal's wire shape becomes this site's — one definition per shape.\n *\n * `medal-client.ts` deliberately refuses to do this. It types the wire honestly,\n * nullable throughout, and leaves what a missing price or duration ought to mean\n * to the layer that renders it. This is that layer, and it is a module rather\n * than a private function in one route because four places now need the same\n * answers: the services route, the resources route, the manage page and the\n * booking page.\n *\n * The defaults are chosen to be visibly wrong rather than plausibly wrong. A\n * missing duration is `0`, not 30: a wrong-but-believable number would size a\n * slot against a service the salon never described. `maxPerBooking` is the one\n * that must not be `0` — `partyLimit` takes `Math.min` over it, JS coerces\n * `null` to 0 there, and a limit of zero silently refuses to add the second\n * child to a family booking. `1` is the conservative reading of a service nobody\n * has set a party size for: bookable, but alone.\n *\n * Types only from the SDK (`./wire`), never values: the service step imports\n * `normaliseServiceCategory` from here into the browser, and `import type` is\n * erased, so the client bundle never carries the SDK.\n *\n * `createDto(config)` binds the three that read the site's config — the\n * category list, the fallback category and the avatar path.\n */\n\nimport { normaliseCategory } from './categories';\nimport type { BookingConfig } from './config';\nimport type { BookingDayDto, BookingResourceDto, BookingServiceDto, BookingSlotDto } from './types';\nimport type { MedalResource, MedalScheduleDay, MedalService, MedalSlot } from './wire';\n\ntype DtoConfig = Pick<BookingConfig, 'categories' | 'fallbackCategory' | 'paths'>;\n\n/**\n * `category` is a free string on the wire, and the salon can add one in the\n * dashboard at any time. Grouping strictly by the five known keys would drop\n * that service off whichever page is grouping — bookable, priced, and invisible\n * — so anything unrecognised joins the catch-all the seed already uses for\n * threading, machine cuts and ear piercing.\n */\nfunction normaliseServiceCategory(config: DtoConfig, category: string): string {\n return normaliseCategory(config, category);\n}\n\n/**\n * `category` stays a free string here rather than being narrowed: `WizardService`\n * types it as one, and the two consumers group by it with their own labels.\n * `normaliseServiceCategory` is what either of them applies at the point of\n * grouping.\n */\nfunction toBookingServiceDto(config: DtoConfig, service: MedalService): BookingServiceDto {\n return {\n id: service.id,\n name: service.name ?? '',\n category: service.category ?? config.fallbackCategory,\n durationMinutes: service.duration_minutes ?? 0,\n // Zero is the wire's own default and the ordinary value, so unlike\n // `durationMinutes` there is nothing visibly wrong about coalescing to it.\n bufferBeforeMinutes: service.buffer_before_minutes ?? 0,\n bufferAfterMinutes: service.buffer_after_minutes ?? 0,\n priceOre: service.price_ore ?? 0,\n maxPerBooking: service.max_per_booking ?? 1,\n weekendSurchargePct: service.weekend_surcharge_pct ?? 0,\n bookableOnline: service.bookable_online,\n // Absent rather than null, the wizard's rule: a service with no range is\n // for everybody, and a nonsense value is treated as no range at all.\n ...ageBound('ageMinYears', service.age_min_years),\n ...ageBound('ageMaxYears', service.age_max_years),\n };\n}\n\nfunction ageBound<K extends 'ageMinYears' | 'ageMaxYears'>(\n key: K,\n value: unknown\n): Partial<Record<K, number>> {\n return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 120\n ? ({ [key]: value } as Record<K, number>)\n : {};\n}\n\n/** What a resource id may look like before the avatar route will look it up\n * — and before this module will build a URL out of it. */\nexport const RESOURCE_ID_SHAPE = /^[\\w-]{1,64}$/;\n\n/**\n * The stylist's photo on THIS site, not Medal's presigned R2 URL.\n *\n * The presigned URL is re-signed on every read, so the browser saw a new URL\n * per page and cached nothing; the proxy route gives each stylist one stable,\n * cacheable address. An id the route would refuse gets no photo (initials)\n * rather than a broken image.\n */\nfunction avatarPath(config: DtoConfig, resourceId: string): string | null {\n return RESOURCE_ID_SHAPE.test(resourceId) ? `${config.paths.avatar}/${resourceId}` : null;\n}\n\n/**\n * A stylist nobody has given a position lands after everyone who has one.\n *\n * `MAX_SAFE_INTEGER` rather than `0`, which is what a coalesce-to-falsy would\n * produce: step 2 sorts ascending, so a zero would put the salon's least\n * configured stylist at the top of the list, above the person they deliberately\n * ordered first.\n *\n * The name is `''` for the same reason the services route leaves a nameless\n * service `''` — the salon can see the blank and fix it, where dropping the row\n * would make a stylist they configured unbookable with nothing on screen to say\n * why.\n */\nfunction toBookingResourceDto(config: DtoConfig, resource: MedalResource): BookingResourceDto {\n return {\n id: resource.id,\n name: resource.name ?? '',\n photoUrl: resource.photo_url ? avatarPath(config, resource.id) : null,\n bio: resource.bio,\n serviceIds: resource.service_ids,\n sortOrder: resource.sort_order ?? Number.MAX_SAFE_INTEGER,\n };\n}\n\n/**\n * Zero or one slot, so a timeless one is dropped rather than emitted.\n *\n * `start_ts` is nullable on the wire like every other timestamp. A `NaN` carried\n * through would render as an «Invalid Date» chip that books nothing, and would\n * reach `createBooking` as a `start_ts` the engine rejects.\n */\nexport function toBookingSlotDto(slot: MedalSlot): BookingSlotDto[] {\n const startTs = slot.start_ts === null ? Number.NaN : Date.parse(slot.start_ts);\n if (!Number.isFinite(startTs)) return [];\n return [{ startTs, resourceId: slot.resource_id }];\n}\n\n/**\n * Zero or one open day, so a date the wire could not describe is dropped rather\n * than emitted.\n *\n * Dropping is the SAFE failure here, and it is worth being explicit about why,\n * because the two directions are not symmetric. A date missing from this list\n * reads downstream as «the salon keeps no hours then», which at worst shows\n * «Stengt» beside a day that had none of its slots taken — visible, and it\n * costs the visitor a tap on another day. A date emitted with a nonsense\n * `opensTs` reads as open, and `NaN` compares false against every clock check\n * the step makes, so the day would silently fall through to «Fullt» — the exact\n * false claim about the business this whole path exists to stop.\n *\n * `lastStartTs` is the one field allowed to be null after this: there, null is\n * an ANSWER (open on paper, shut on the day) rather than an unreadable value.\n */\nexport function toBookingDayDto(day: MedalScheduleDay): BookingDayDto[] {\n if (day.date === null) return [];\n const opensTs = day.opens_ts === null ? Number.NaN : Date.parse(day.opens_ts);\n const closesTs = day.closes_ts === null ? Number.NaN : Date.parse(day.closes_ts);\n if (!Number.isFinite(opensTs) || !Number.isFinite(closesTs)) return [];\n\n // A `last_start_ts` that is present but unreadable becomes null rather than\n // NaN: null already means «shut on the day», which is the conservative thing\n // to say about a value we cannot read, and it is a case the step handles.\n const parsedLastStart = day.last_start_ts === null ? Number.NaN : Date.parse(day.last_start_ts);\n return [\n {\n dayKey: day.date,\n opensTs,\n closesTs,\n lastStartTs: Number.isFinite(parsedLastStart) ? parsedLastStart : null,\n },\n ];\n}\n\nexport interface Dto {\n /** A free-string Medal category as one of the configured keys, or the fallback. */\n normaliseServiceCategory(category: string): string;\n toBookingServiceDto(service: MedalService): BookingServiceDto;\n /** This site's stable avatar URL for a stylist, or `null` for an id the route would refuse. */\n avatarPath(resourceId: string): string | null;\n toBookingResourceDto(resource: MedalResource): BookingResourceDto;\n toBookingSlotDto: typeof toBookingSlotDto;\n toBookingDayDto: typeof toBookingDayDto;\n}\n\nexport function createDto(config: DtoConfig): Dto {\n return {\n normaliseServiceCategory: (category) => normaliseServiceCategory(config, category),\n toBookingServiceDto: (service) => toBookingServiceDto(config, service),\n avatarPath: (resourceId) => avatarPath(config, resourceId),\n toBookingResourceDto: (resource) => toBookingResourceDto(config, resource),\n toBookingSlotDto,\n toBookingDayDto,\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAwCA,SAAS,yBAAyB,QAAmB,UAA0B;CAC7E,OAAO,kBAAkB,QAAQ,QAAQ;AAC3C;;;;;;;AAQA,SAAS,oBAAoB,QAAmB,SAA0C;CACxF,OAAO;EACL,IAAI,QAAQ;EACZ,MAAM,QAAQ,QAAQ;EACtB,UAAU,QAAQ,YAAY,OAAO;EACrC,iBAAiB,QAAQ,oBAAoB;EAG7C,qBAAqB,QAAQ,yBAAyB;EACtD,oBAAoB,QAAQ,wBAAwB;EACpD,UAAU,QAAQ,aAAa;EAC/B,eAAe,QAAQ,mBAAmB;EAC1C,qBAAqB,QAAQ,yBAAyB;EACtD,gBAAgB,QAAQ;EAGxB,GAAG,SAAS,eAAe,QAAQ,aAAa;EAChD,GAAG,SAAS,eAAe,QAAQ,aAAa;CAClD;AACF;AAEA,SAAS,SACP,KACA,OAC4B;CAC5B,OAAO,OAAO,UAAU,YAAY,OAAO,UAAU,KAAK,KAAK,SAAS,KAAK,SAAS,MACjF,GAAG,MAAM,MAAM,IAChB,CAAC;AACP;;;AAIA,MAAa,oBAAoB;;;;;;;;;AAUjC,SAAS,WAAW,QAAmB,YAAmC;CACxE,OAAO,kBAAkB,KAAK,UAAU,IAAI,GAAG,OAAO,MAAM,OAAO,GAAG,eAAe;AACvF;;;;;;;;;;;;;;AAeA,SAAS,qBAAqB,QAAmB,UAA6C;CAC5F,OAAO;EACL,IAAI,SAAS;EACb,MAAM,SAAS,QAAQ;EACvB,UAAU,SAAS,YAAY,WAAW,QAAQ,SAAS,EAAE,IAAI;EACjE,KAAK,SAAS;EACd,YAAY,SAAS;EACrB,WAAW,SAAS,cAAc,OAAO;CAC3C;AACF;;;;;;;;AASA,SAAgB,iBAAiB,MAAmC;CAClE,MAAM,UAAU,KAAK,aAAa,OAAO,MAAa,KAAK,MAAM,KAAK,QAAQ;CAC9E,IAAI,CAAC,OAAO,SAAS,OAAO,GAAG,OAAO,CAAC;CACvC,OAAO,CAAC;EAAE;EAAS,YAAY,KAAK;CAAY,CAAC;AACnD;;;;;;;;;;;;;;;;;AAkBA,SAAgB,gBAAgB,KAAwC;CACtE,IAAI,IAAI,SAAS,MAAM,OAAO,CAAC;CAC/B,MAAM,UAAU,IAAI,aAAa,OAAO,MAAa,KAAK,MAAM,IAAI,QAAQ;CAC5E,MAAM,WAAW,IAAI,cAAc,OAAO,MAAa,KAAK,MAAM,IAAI,SAAS;CAC/E,IAAI,CAAC,OAAO,SAAS,OAAO,KAAK,CAAC,OAAO,SAAS,QAAQ,GAAG,OAAO,CAAC;CAKrE,MAAM,kBAAkB,IAAI,kBAAkB,OAAO,MAAa,KAAK,MAAM,IAAI,aAAa;CAC9F,OAAO,CACL;EACE,QAAQ,IAAI;EACZ;EACA;EACA,aAAa,OAAO,SAAS,eAAe,IAAI,kBAAkB;CACpE,CACF;AACF;AAaA,SAAgB,UAAU,QAAwB;CAChD,OAAO;EACL,2BAA2B,aAAa,yBAAyB,QAAQ,QAAQ;EACjF,sBAAsB,YAAY,oBAAoB,QAAQ,OAAO;EACrE,aAAa,eAAe,WAAW,QAAQ,UAAU;EACzD,uBAAuB,aAAa,qBAAqB,QAAQ,QAAQ;EACzE;EACA;CACF;AACF"}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
//#region src/core/ics.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* One iCalendar object, built by hand.
|
|
4
|
+
*
|
|
5
|
+
* A dependency for this would be four hundred lines of RFC 5545 the salon does
|
|
6
|
+
* not need, and the parts that actually bite — CRLF, folding measured in octets
|
|
7
|
+
* rather than characters, and UTC stamps — are the parts a library would hide
|
|
8
|
+
* rather than the parts it would save.
|
|
9
|
+
*
|
|
10
|
+
* Pure and free of React for the same reason `clock.ts` is: the
|
|
11
|
+
* confirmation screen inlines the result into a `data:` URL, and the
|
|
12
|
+
* reschedule mail will attach the same bytes.
|
|
13
|
+
*/
|
|
14
|
+
export interface IcsEvent {
|
|
15
|
+
/**
|
|
16
|
+
* Stable for the life of the appointment, and deliberately not regenerated on
|
|
17
|
+
* a reschedule: the UID is the only thing that tells a calendar the new
|
|
18
|
+
* invitation is the old one moved rather than a second haircut.
|
|
19
|
+
*/
|
|
20
|
+
uid: string;
|
|
21
|
+
startTs: number;
|
|
22
|
+
endTs: number;
|
|
23
|
+
summary: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
location?: string;
|
|
26
|
+
/**
|
|
27
|
+
* `PUBLISH` is «here is an appointment, add it» — the confirmation screen's
|
|
28
|
+
* «Legg til i kalender». `REQUEST` is «this replaces what you already have»,
|
|
29
|
+
* and is what a reschedule mail must send: a PUBLISH with a new time leaves
|
|
30
|
+
* the old appointment sitting in the customer's calendar alongside the new
|
|
31
|
+
* one, which is worse than sending nothing at all.
|
|
32
|
+
*/
|
|
33
|
+
method?: 'PUBLISH' | 'REQUEST';
|
|
34
|
+
/**
|
|
35
|
+
* The revision counter, and the half of the reschedule promise that clients
|
|
36
|
+
* actually enforce. A REQUEST carrying a UID they already hold *and* a
|
|
37
|
+
* SEQUENCE they have already seen is a replay: Outlook and Google keep the
|
|
38
|
+
* appointment they have. Every reschedule must therefore hand in a number
|
|
39
|
+
* higher than the last one sent for that booking.
|
|
40
|
+
*/
|
|
41
|
+
sequence?: number;
|
|
42
|
+
/** When this object was produced. Injectable so it can be asserted; there is
|
|
43
|
+
* no other reason to pass it. */
|
|
44
|
+
stampTs?: number;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The same bytes as a link target.
|
|
48
|
+
*
|
|
49
|
+
* `charset=utf-8` is not decoration — without it the media type defaults to
|
|
50
|
+
* US-ASCII and «Fødselsdagsklipp» arrives mangled in the calendar entry.
|
|
51
|
+
*/
|
|
52
|
+
export declare function icsDataUrl(ics: string): string;
|
|
53
|
+
export interface Ics {
|
|
54
|
+
buildIcs(events: IcsEvent | readonly IcsEvent[]): string;
|
|
55
|
+
icsDataUrl: typeof icsDataUrl;
|
|
56
|
+
}
|
|
57
|
+
export declare function createIcs(config: {
|
|
58
|
+
ics: {
|
|
59
|
+
prodId: string;
|
|
60
|
+
};
|
|
61
|
+
}): Ics;
|
|
62
|
+
//#endregion
|
|
63
|
+
//# sourceMappingURL=ics.d.mts.map
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
//#region src/core/ics.ts
|
|
2
|
+
/**
|
|
3
|
+
* One iCalendar object, built by hand.
|
|
4
|
+
*
|
|
5
|
+
* A dependency for this would be four hundred lines of RFC 5545 the salon does
|
|
6
|
+
* not need, and the parts that actually bite — CRLF, folding measured in octets
|
|
7
|
+
* rather than characters, and UTC stamps — are the parts a library would hide
|
|
8
|
+
* rather than the parts it would save.
|
|
9
|
+
*
|
|
10
|
+
* Pure and free of React for the same reason `clock.ts` is: the
|
|
11
|
+
* confirmation screen inlines the result into a `data:` URL, and the
|
|
12
|
+
* reschedule mail will attach the same bytes.
|
|
13
|
+
*/
|
|
14
|
+
/** RFC 5545 §3.1: a content line is at most 75 octets, excluding the CRLF. */
|
|
15
|
+
const MAX_OCTETS = 75;
|
|
16
|
+
const CRLF = "\r\n";
|
|
17
|
+
/**
|
|
18
|
+
* `20260903T130000Z` — a UTC date-time, which is the one form that means the
|
|
19
|
+
* same thing to every calendar that opens it.
|
|
20
|
+
*
|
|
21
|
+
* Through `toISOString()` rather than the salon clock deliberately. Everything
|
|
22
|
+
* the visitor *reads* is Oslo time and comes from `clock.ts`; this is not
|
|
23
|
+
* read by anyone, it is the instant itself, and a floating local time here
|
|
24
|
+
* would land the appointment three hours out for the family booking from Spain.
|
|
25
|
+
*/
|
|
26
|
+
function utcStamp(ts) {
|
|
27
|
+
return new Date(ts).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* RFC 5545 §3.3.11. `;` and `,` are the format's own separators, so an
|
|
31
|
+
* unescaped one in «Noe vi bør vite?» ends the property early and puts the rest
|
|
32
|
+
* of the parent's sentence where a parameter belongs.
|
|
33
|
+
*
|
|
34
|
+
* Backslash first, or the escapes added below would be escaped again.
|
|
35
|
+
*/
|
|
36
|
+
function escapeText(value) {
|
|
37
|
+
return value.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\r\n|\r|\n/g, "\\n").replace(/[\u0000-\u0008\u000B-\u001F\u007F]/g, "");
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Fold to 75 **octets**, not 75 characters.
|
|
41
|
+
*
|
|
42
|
+
* Norwegian is not ASCII: `ø` and `å` are two octets each, so a fold counted in
|
|
43
|
+
* characters overruns the limit on exactly the summaries this salon produces.
|
|
44
|
+
* Counting octets and cutting blindly is the opposite failure — the cut lands
|
|
45
|
+
* between the two halves of one letter and the client decodes `�` — so the
|
|
46
|
+
* boundary walks back off any UTF-8 continuation octet (`10xxxxxx`) first.
|
|
47
|
+
*
|
|
48
|
+
* Continuation lines spend one octet on their leading space, so they carry 74.
|
|
49
|
+
*/
|
|
50
|
+
function fold(line) {
|
|
51
|
+
const bytes = new TextEncoder().encode(line);
|
|
52
|
+
if (bytes.length <= MAX_OCTETS) return line;
|
|
53
|
+
const decoder = new TextDecoder();
|
|
54
|
+
const chunks = [];
|
|
55
|
+
let offset = 0;
|
|
56
|
+
let capacity = MAX_OCTETS;
|
|
57
|
+
while (offset < bytes.length) {
|
|
58
|
+
let end = Math.min(offset + capacity, bytes.length);
|
|
59
|
+
while (end > offset + 1 && end < bytes.length && (bytes[end] & 192) === 128) end -= 1;
|
|
60
|
+
chunks.push(decoder.decode(bytes.subarray(offset, end)));
|
|
61
|
+
offset = end;
|
|
62
|
+
capacity = 74;
|
|
63
|
+
}
|
|
64
|
+
return chunks.join(`${CRLF} `);
|
|
65
|
+
}
|
|
66
|
+
/** Absent stays absent: `LOCATION:` with nothing after it renders as a blank
|
|
67
|
+
* address line, which reads as a salon that has lost its address. */
|
|
68
|
+
function textProperty(name, value) {
|
|
69
|
+
return value === void 0 || value.trim().length === 0 ? null : `${name}:${escapeText(value)}`;
|
|
70
|
+
}
|
|
71
|
+
/** One VEVENT. Split out because a calendar may hold several of them and the
|
|
72
|
+
* wrapper around them is written once. */
|
|
73
|
+
function eventLines(event) {
|
|
74
|
+
return [
|
|
75
|
+
"BEGIN:VEVENT",
|
|
76
|
+
`UID:${event.uid.replace(/[\u0000-\u001F\u007F]/g, "")}`,
|
|
77
|
+
`SEQUENCE:${event.sequence ?? 0}`,
|
|
78
|
+
`DTSTAMP:${utcStamp(event.stampTs ?? Date.now())}`,
|
|
79
|
+
`DTSTART:${utcStamp(event.startTs)}`,
|
|
80
|
+
`DTEND:${utcStamp(event.endTs)}`,
|
|
81
|
+
textProperty("SUMMARY", event.summary),
|
|
82
|
+
textProperty("DESCRIPTION", event.description),
|
|
83
|
+
textProperty("LOCATION", event.location),
|
|
84
|
+
"END:VEVENT"
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* One calendar, holding one appointment or several.
|
|
89
|
+
*
|
|
90
|
+
* Several is the family visit: each child is its own booking with its own
|
|
91
|
+
* manage link, moved on its own, so each needs a VEVENT of its own with that
|
|
92
|
+
* child's booking id as the UID. A single event spanning the whole visit could
|
|
93
|
+
* only ever carry one of those ids, and every other child's reschedule would
|
|
94
|
+
* then add a second entry rather than move the one already in the calendar.
|
|
95
|
+
*
|
|
96
|
+
* `METHOD` is a property of the VCALENDAR rather than of an event, so it is
|
|
97
|
+
* taken from the first — a list whose events disagree about it is asking for
|
|
98
|
+
* something the format cannot express.
|
|
99
|
+
*/
|
|
100
|
+
function buildIcs(prodId, events) {
|
|
101
|
+
const list = Array.isArray(events) ? events : [events];
|
|
102
|
+
return `${[
|
|
103
|
+
"BEGIN:VCALENDAR",
|
|
104
|
+
"VERSION:2.0",
|
|
105
|
+
`PRODID:${prodId}`,
|
|
106
|
+
"CALSCALE:GREGORIAN",
|
|
107
|
+
`METHOD:${list[0]?.method ?? "PUBLISH"}`,
|
|
108
|
+
...list.flatMap(eventLines),
|
|
109
|
+
"END:VCALENDAR"
|
|
110
|
+
].filter((line) => line !== null).map(fold).join(CRLF)}${CRLF}`;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The same bytes as a link target.
|
|
114
|
+
*
|
|
115
|
+
* `charset=utf-8` is not decoration — without it the media type defaults to
|
|
116
|
+
* US-ASCII and «Fødselsdagsklipp» arrives mangled in the calendar entry.
|
|
117
|
+
*/
|
|
118
|
+
function icsDataUrl(ics) {
|
|
119
|
+
return `data:text/calendar;charset=utf-8,${encodeURIComponent(ics)}`;
|
|
120
|
+
}
|
|
121
|
+
function createIcs(config) {
|
|
122
|
+
return {
|
|
123
|
+
buildIcs: (events) => buildIcs(config.ics.prodId, events),
|
|
124
|
+
icsDataUrl
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
//#endregion
|
|
128
|
+
export { createIcs, icsDataUrl };
|
|
129
|
+
|
|
130
|
+
//# sourceMappingURL=ics.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ics.mjs","names":[],"sources":["../../src/core/ics.ts"],"sourcesContent":["/**\n * One iCalendar object, built by hand.\n *\n * A dependency for this would be four hundred lines of RFC 5545 the salon does\n * not need, and the parts that actually bite — CRLF, folding measured in octets\n * rather than characters, and UTC stamps — are the parts a library would hide\n * rather than the parts it would save.\n *\n * Pure and free of React for the same reason `clock.ts` is: the\n * confirmation screen inlines the result into a `data:` URL, and the\n * reschedule mail will attach the same bytes.\n */\n\n/** RFC 5545 §3.1: a content line is at most 75 octets, excluding the CRLF. */\nconst MAX_OCTETS = 75;\n\nconst CRLF = '\\r\\n';\n\nexport interface IcsEvent {\n /**\n * Stable for the life of the appointment, and deliberately not regenerated on\n * a reschedule: the UID is the only thing that tells a calendar the new\n * invitation is the old one moved rather than a second haircut.\n */\n uid: string;\n startTs: number;\n endTs: number;\n summary: string;\n description?: string;\n location?: string;\n /**\n * `PUBLISH` is «here is an appointment, add it» — the confirmation screen's\n * «Legg til i kalender». `REQUEST` is «this replaces what you already have»,\n * and is what a reschedule mail must send: a PUBLISH with a new time leaves\n * the old appointment sitting in the customer's calendar alongside the new\n * one, which is worse than sending nothing at all.\n */\n method?: 'PUBLISH' | 'REQUEST';\n /**\n * The revision counter, and the half of the reschedule promise that clients\n * actually enforce. A REQUEST carrying a UID they already hold *and* a\n * SEQUENCE they have already seen is a replay: Outlook and Google keep the\n * appointment they have. Every reschedule must therefore hand in a number\n * higher than the last one sent for that booking.\n */\n sequence?: number;\n /** When this object was produced. Injectable so it can be asserted; there is\n * no other reason to pass it. */\n stampTs?: number;\n}\n\n/**\n * `20260903T130000Z` — a UTC date-time, which is the one form that means the\n * same thing to every calendar that opens it.\n *\n * Through `toISOString()` rather than the salon clock deliberately. Everything\n * the visitor *reads* is Oslo time and comes from `clock.ts`; this is not\n * read by anyone, it is the instant itself, and a floating local time here\n * would land the appointment three hours out for the family booking from Spain.\n */\nfunction utcStamp(ts: number): string {\n return new Date(ts)\n .toISOString()\n .replace(/[-:]/g, '')\n .replace(/\\.\\d{3}Z$/, 'Z');\n}\n\n/**\n * RFC 5545 §3.3.11. `;` and `,` are the format's own separators, so an\n * unescaped one in «Noe vi bør vite?» ends the property early and puts the rest\n * of the parent's sentence where a parameter belongs.\n *\n * Backslash first, or the escapes added below would be escaped again.\n */\nfunction escapeText(value: string): string {\n return (\n value\n .replace(/\\\\/g, '\\\\\\\\')\n .replace(/;/g, '\\\\;')\n .replace(/,/g, '\\\\,')\n // Every line break, in each of its three spellings: a lone CR is a line\n // end to more than one calendar parser, and an unescaped one would start\n // a property of the visitor's choosing.\n .replace(/\\r\\n|\\r|\\n/g, '\\\\n')\n // TEXT admits no other control character (RFC 5545 §3.3.11) but HTAB.\n // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping them is the point.\n .replace(/[\\u0000-\\u0008\\u000B-\\u001F\\u007F]/g, '')\n );\n}\n\n/**\n * Fold to 75 **octets**, not 75 characters.\n *\n * Norwegian is not ASCII: `ø` and `å` are two octets each, so a fold counted in\n * characters overruns the limit on exactly the summaries this salon produces.\n * Counting octets and cutting blindly is the opposite failure — the cut lands\n * between the two halves of one letter and the client decodes `�` — so the\n * boundary walks back off any UTF-8 continuation octet (`10xxxxxx`) first.\n *\n * Continuation lines spend one octet on their leading space, so they carry 74.\n */\nfunction fold(line: string): string {\n const bytes = new TextEncoder().encode(line);\n if (bytes.length <= MAX_OCTETS) return line;\n\n const decoder = new TextDecoder();\n const chunks: string[] = [];\n let offset = 0;\n let capacity = MAX_OCTETS;\n\n while (offset < bytes.length) {\n let end = Math.min(offset + capacity, bytes.length);\n while (end > offset + 1 && end < bytes.length && (bytes[end] & 0b1100_0000) === 0b1000_0000) {\n end -= 1;\n }\n chunks.push(decoder.decode(bytes.subarray(offset, end)));\n offset = end;\n capacity = MAX_OCTETS - 1;\n }\n\n return chunks.join(`${CRLF} `);\n}\n\n/** Absent stays absent: `LOCATION:` with nothing after it renders as a blank\n * address line, which reads as a salon that has lost its address. */\nfunction textProperty(name: string, value: string | undefined): string | null {\n return value === undefined || value.trim().length === 0 ? null : `${name}:${escapeText(value)}`;\n}\n\n/** One VEVENT. Split out because a calendar may hold several of them and the\n * wrapper around them is written once. */\nfunction eventLines(event: IcsEvent): (string | null)[] {\n return [\n 'BEGIN:VEVENT',\n // UID is not TEXT, so it has no escape: a line break in one would start a\n // property of its own. Booking ids never hold one; nothing here relies on it.\n // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping them is the point.\n `UID:${event.uid.replace(/[\\u0000-\\u001F\\u007F]/g, '')}`,\n `SEQUENCE:${event.sequence ?? 0}`,\n `DTSTAMP:${utcStamp(event.stampTs ?? Date.now())}`,\n `DTSTART:${utcStamp(event.startTs)}`,\n `DTEND:${utcStamp(event.endTs)}`,\n textProperty('SUMMARY', event.summary),\n textProperty('DESCRIPTION', event.description),\n textProperty('LOCATION', event.location),\n 'END:VEVENT',\n ];\n}\n\n/**\n * One calendar, holding one appointment or several.\n *\n * Several is the family visit: each child is its own booking with its own\n * manage link, moved on its own, so each needs a VEVENT of its own with that\n * child's booking id as the UID. A single event spanning the whole visit could\n * only ever carry one of those ids, and every other child's reschedule would\n * then add a second entry rather than move the one already in the calendar.\n *\n * `METHOD` is a property of the VCALENDAR rather than of an event, so it is\n * taken from the first — a list whose events disagree about it is asking for\n * something the format cannot express.\n */\nfunction buildIcs(prodId: string, events: IcsEvent | readonly IcsEvent[]): string {\n const list = Array.isArray(events) ? (events as readonly IcsEvent[]) : [events as IcsEvent];\n const lines: (string | null)[] = [\n 'BEGIN:VCALENDAR',\n 'VERSION:2.0',\n // The PRODID identifies the software, and RFC 5545 requires one (the site's\n // `config.ics.prodId`). Nothing reads it; a missing one makes strict\n // parsers reject the file.\n `PRODID:${prodId}`,\n 'CALSCALE:GREGORIAN',\n `METHOD:${list[0]?.method ?? 'PUBLISH'}`,\n ...list.flatMap(eventLines),\n 'END:VCALENDAR',\n ];\n\n // Trailing CRLF included: the last content line is a line like any other, and\n // a file that ends mid-line is one a strict parser is entitled to refuse.\n return `${lines\n .filter((line) => line !== null)\n .map(fold)\n .join(CRLF)}${CRLF}`;\n}\n\n/**\n * The same bytes as a link target.\n *\n * `charset=utf-8` is not decoration — without it the media type defaults to\n * US-ASCII and «Fødselsdagsklipp» arrives mangled in the calendar entry.\n */\nexport function icsDataUrl(ics: string): string {\n return `data:text/calendar;charset=utf-8,${encodeURIComponent(ics)}`;\n}\n\nexport interface Ics {\n buildIcs(events: IcsEvent | readonly IcsEvent[]): string;\n icsDataUrl: typeof icsDataUrl;\n}\n\nexport function createIcs(config: { ics: { prodId: string } }): Ics {\n return {\n buildIcs: (events) => buildIcs(config.ics.prodId, events),\n icsDataUrl,\n };\n}\n"],"mappings":";;;;;;;;;;;;;;AAcA,MAAM,aAAa;AAEnB,MAAM,OAAO;;;;;;;;;;AA4Cb,SAAS,SAAS,IAAoB;CACpC,OAAO,IAAI,KAAK,EAAE,CAAC,CAChB,YAAY,CAAC,CACb,QAAQ,SAAS,EAAE,CAAC,CACpB,QAAQ,aAAa,GAAG;AAC7B;;;;;;;;AASA,SAAS,WAAW,OAAuB;CACzC,OACE,MACG,QAAQ,OAAO,MAAM,CAAC,CACtB,QAAQ,MAAM,KAAK,CAAC,CACpB,QAAQ,MAAM,KAAK,CAAC,CAIpB,QAAQ,eAAe,KAAK,CAAC,CAG7B,QAAQ,uCAAuC,EAAE;AAExD;;;;;;;;;;;;AAaA,SAAS,KAAK,MAAsB;CAClC,MAAM,QAAQ,IAAI,YAAY,CAAC,CAAC,OAAO,IAAI;CAC3C,IAAI,MAAM,UAAU,YAAY,OAAO;CAEvC,MAAM,UAAU,IAAI,YAAY;CAChC,MAAM,SAAmB,CAAC;CAC1B,IAAI,SAAS;CACb,IAAI,WAAW;CAEf,OAAO,SAAS,MAAM,QAAQ;EAC5B,IAAI,MAAM,KAAK,IAAI,SAAS,UAAU,MAAM,MAAM;EAClD,OAAO,MAAM,SAAS,KAAK,MAAM,MAAM,WAAW,MAAM,OAAO,SAAiB,KAC9E,OAAO;EAET,OAAO,KAAK,QAAQ,OAAO,MAAM,SAAS,QAAQ,GAAG,CAAC,CAAC;EACvD,SAAS;EACT,WAAW;CACb;CAEA,OAAO,OAAO,KAAK,GAAG,KAAK,EAAE;AAC/B;;;AAIA,SAAS,aAAa,MAAc,OAA0C;CAC5E,OAAO,UAAU,KAAA,KAAa,MAAM,KAAK,CAAC,CAAC,WAAW,IAAI,OAAO,GAAG,KAAK,GAAG,WAAW,KAAK;AAC9F;;;AAIA,SAAS,WAAW,OAAoC;CACtD,OAAO;EACL;EAIA,OAAO,MAAM,IAAI,QAAQ,0BAA0B,EAAE;EACrD,YAAY,MAAM,YAAY;EAC9B,WAAW,SAAS,MAAM,WAAW,KAAK,IAAI,CAAC;EAC/C,WAAW,SAAS,MAAM,OAAO;EACjC,SAAS,SAAS,MAAM,KAAK;EAC7B,aAAa,WAAW,MAAM,OAAO;EACrC,aAAa,eAAe,MAAM,WAAW;EAC7C,aAAa,YAAY,MAAM,QAAQ;EACvC;CACF;AACF;;;;;;;;;;;;;;AAeA,SAAS,SAAS,QAAgB,QAAgD;CAChF,MAAM,OAAO,MAAM,QAAQ,MAAM,IAAK,SAAiC,CAAC,MAAkB;CAgB1F,OAAO,GAAG;EAdR;EACA;EAIA,UAAU;EACV;EACA,UAAU,KAAK,EAAE,EAAE,UAAU;EAC7B,GAAG,KAAK,QAAQ,UAAU;EAC1B;CAKY,CAAC,CACZ,QAAQ,SAAS,SAAS,IAAI,CAAC,CAC/B,IAAI,IAAI,CAAC,CACT,KAAK,IAAI,IAAI;AAClB;;;;;;;AAQA,SAAgB,WAAW,KAAqB;CAC9C,OAAO,oCAAoC,mBAAmB,GAAG;AACnE;AAOA,SAAgB,UAAU,QAA0C;CAClE,OAAO;EACL,WAAW,WAAW,SAAS,OAAO,IAAI,QAAQ,MAAM;EACxD;CACF;AACF"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { BookingLabel, BookingLabels, CoreLabels, KeyedLabels, LABEL_PACKS, fill, fillParts, isBookingLabel, labelPackFor, labelText, resolveLabels } from "./labels.mjs";
|
|
2
|
+
import { BookingCategory, BookingConfig, BookingConfigError, BookingConfigInput, BookingDaypart, resolveBookingConfig } from "./config.mjs";
|
|
3
|
+
import { Age, AgeBounds, AgeRange, ageOnDay, createAge, fitsAge } from "./age.mjs";
|
|
4
|
+
import { BOOKED_FOR_NAME_MAX_LENGTH, SELF_KEY, Wizard, WizardAction, WizardCatalogue, WizardConfig, WizardItem, WizardPerson, WizardPrefill, WizardService, WizardState, WizardStep, canAdvance, canGoToStep, createWizard, guestChild, initialState, isGuestSeat, itemResourceIds, itemStartTimes, showsPartyMode, visitEndTs, visitMinutes, visitTailMinutes } from "./machine.mjs";
|
|
5
|
+
import { BookingDayDto, BookingFamilyMember, BookingGuardian, BookingLastVisit, BookingManageDto, BookingResourceDto, BookingServiceDto, BookingSlotDto, BookingSubmission, BookingSubmissionItem } from "./types.mjs";
|
|
6
|
+
import { ATTEMPT_TTL_MS, AttemptStore, BookingAttempt, ConfirmedBooking, SubmittedVisit, attemptStorageKey, createAttemptStore } from "./attempt-store.mjs";
|
|
7
|
+
import { adultCategoryFor, adultEquivalent, categoryOrder, childCategory, isChildCategory, normaliseCategory } from "./categories.mjs";
|
|
8
|
+
import { Clock, ClockConfig, createClock } from "./clock.mjs";
|
|
9
|
+
import { CONSENT_SOURCE, MarketingConsent, marketingConsent } from "./consent.mjs";
|
|
10
|
+
import { DeepLinks, createDeepLinks, parseParty, resourceMatches, serviceMatches, slugify, stylistSlug, withBookingQuery } from "./deep-link.mjs";
|
|
11
|
+
import { initialsOf, stylistDisplayName } from "./display-name.mjs";
|
|
12
|
+
import { DRAFT_MAX_AGE_MS, DraftStore, WizardDraft, WizardDraftItem, createDraftStore, draftStorageKey } from "./draft-store.mjs";
|
|
13
|
+
import { MedalManageSummary, MedalResource, MedalScheduleDay, MedalService, MedalSlot } from "./wire.mjs";
|
|
14
|
+
import { Dto, RESOURCE_ID_SHAPE, createDto, toBookingDayDto, toBookingSlotDto } from "./dto.mjs";
|
|
15
|
+
import { Ics, IcsEvent, createIcs, icsDataUrl } from "./ics.mjs";
|
|
16
|
+
import { Money, createMoney } from "./money.mjs";
|
|
17
|
+
import { earliestOpening, firstOpeningPerResource } from "./next-available.mjs";
|
|
18
|
+
import { NEXT_FREE_TTL_MS, NextFree, NextFreeClient, createNextFree } from "./next-free.mjs";
|
|
19
|
+
import { PartySeat, PartySlot, PartySlots, SlotsByService, createPartySlots, findPartySlots, firstPartyStartPerResource, slotKeysFor } from "./party-slots.mjs";
|
|
20
|
+
import { Paths, createPaths } from "./paths.mjs";
|
|
21
|
+
import { Phone, createPhone, telHref } from "./phone.mjs";
|
|
22
|
+
import { CookieAttributes, CookieJar, CookieSource } from "./portal/cookies.mjs";
|
|
23
|
+
import { PortalBookingDto, PortalDto, PortalFamilyMemberDto, PortalLabels, PortalProfileDto, RebookSuggestion, createPortalDto, monthOrNull, rebookSuggestions, toPortalProfileDto } from "./portal/dto.mjs";
|
|
24
|
+
import { FamilyKey, completedNewestFirst, firstBookingFor, upcomingSoonestFirst } from "./portal/family.mjs";
|
|
25
|
+
import { VerifyLoginFailure, codeField, emailField, verifyLoginInput } from "./portal/login-input.mjs";
|
|
26
|
+
import { PortalNextPath, createPortalNextPath } from "./portal/next-path.mjs";
|
|
27
|
+
import { ReturnPathGuard, createReturnPath } from "./portal/return-path.mjs";
|
|
28
|
+
import { SessionCookie, createSessionCookie, isPortalSessionToken } from "./portal/session-cookie.mjs";
|
|
29
|
+
import { VippsLinkCookies, createVippsLinkCookies, isVippsToken, mintBrowserBinding, vippsLinkVerifyPath } from "./portal/vipps-link.mjs";
|
|
30
|
+
import { VIPPS_CONFIRM_QUERY, maskedAddress, stripVippsReturn, vippsConfirmFrom } from "./portal/vipps-return.mjs";
|
|
31
|
+
import { RebookStore, createRebookStore, rebookStorageKey } from "./rebook-store.mjs";
|
|
32
|
+
import { RESTORE_FALLBACK_MS, RestoreGate, createRestoreGate, restorePendingInStorage } from "./restore-gate.mjs";
|
|
33
|
+
import { Stores, createStores } from "./stores.mjs";
|
|
34
|
+
import { VisitTiming, visitKey, visitKeyOfIds, visitOf, visitOfServices, visitPriceOre, visitServicesOf } from "./visit.mjs";
|
|
35
|
+
export { ATTEMPT_TTL_MS, Age, AgeBounds, AgeRange, AttemptStore, BOOKED_FOR_NAME_MAX_LENGTH, BookingAttempt, BookingCategory, BookingConfig, BookingConfigError, BookingConfigInput, BookingDayDto, BookingDaypart, BookingFamilyMember, BookingGuardian, BookingLabel, BookingLabels, BookingLastVisit, BookingManageDto, BookingResourceDto, BookingServiceDto, BookingSlotDto, BookingSubmission, BookingSubmissionItem, CONSENT_SOURCE, Clock, ClockConfig, ConfirmedBooking, CookieAttributes, CookieJar, CookieSource, CoreLabels, DRAFT_MAX_AGE_MS, DeepLinks, DraftStore, Dto, FamilyKey, Ics, IcsEvent, KeyedLabels, LABEL_PACKS, MarketingConsent, MedalManageSummary, MedalResource, MedalScheduleDay, MedalService, MedalSlot, Money, NEXT_FREE_TTL_MS, NextFree, NextFreeClient, PartySeat, PartySlot, PartySlots, Paths, Phone, PortalBookingDto, PortalDto, PortalFamilyMemberDto, PortalLabels, PortalNextPath, PortalProfileDto, RESOURCE_ID_SHAPE, RESTORE_FALLBACK_MS, RebookStore, RebookSuggestion, RestoreGate, ReturnPathGuard, SELF_KEY, SessionCookie, SlotsByService, Stores, SubmittedVisit, VIPPS_CONFIRM_QUERY, VerifyLoginFailure, VippsLinkCookies, VisitTiming, Wizard, WizardAction, WizardCatalogue, WizardConfig, WizardDraft, WizardDraftItem, WizardItem, WizardPerson, WizardPrefill, WizardService, WizardState, WizardStep, adultCategoryFor, adultEquivalent, ageOnDay, attemptStorageKey, canAdvance, canGoToStep, categoryOrder, childCategory, codeField, completedNewestFirst, createAge, createAttemptStore, createClock, createDeepLinks, createDraftStore, createDto, createIcs, createMoney, createNextFree, createPartySlots, createPaths, createPhone, createPortalDto, createPortalNextPath, createRebookStore, createRestoreGate, createReturnPath, createSessionCookie, createStores, createVippsLinkCookies, createWizard, draftStorageKey, earliestOpening, emailField, fill, fillParts, findPartySlots, firstBookingFor, firstOpeningPerResource, firstPartyStartPerResource, fitsAge, guestChild, icsDataUrl, initialState, initialsOf, isBookingLabel, isChildCategory, isGuestSeat, isPortalSessionToken, isVippsToken, itemResourceIds, itemStartTimes, labelPackFor, labelText, marketingConsent, maskedAddress, mintBrowserBinding, monthOrNull, normaliseCategory, parseParty, rebookStorageKey, rebookSuggestions, resolveBookingConfig, resolveLabels, resourceMatches, restorePendingInStorage, serviceMatches, showsPartyMode, slotKeysFor, slugify, stripVippsReturn, stylistDisplayName, stylistSlug, telHref, toBookingDayDto, toBookingSlotDto, toPortalProfileDto, upcomingSoonestFirst, verifyLoginInput, vippsConfirmFrom, vippsLinkVerifyPath, visitEndTs, visitKey, visitKeyOfIds, visitMinutes, visitOf, visitOfServices, visitPriceOre, visitServicesOf, visitTailMinutes, withBookingQuery };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { adultCategoryFor, adultEquivalent, categoryOrder, childCategory, isChildCategory, normaliseCategory } from "./categories.mjs";
|
|
2
|
+
import { LABEL_PACKS, fill, fillParts, isBookingLabel, labelPackFor, labelText, resolveLabels } from "./labels.mjs";
|
|
3
|
+
import { ageOnDay, createAge, fitsAge } from "./age.mjs";
|
|
4
|
+
import { ATTEMPT_TTL_MS, attemptStorageKey, createAttemptStore } from "./attempt-store.mjs";
|
|
5
|
+
import { createClock } from "./clock.mjs";
|
|
6
|
+
import { BookingConfigError, resolveBookingConfig } from "./config.mjs";
|
|
7
|
+
import { CONSENT_SOURCE, marketingConsent } from "./consent.mjs";
|
|
8
|
+
import { initialsOf, stylistDisplayName } from "./display-name.mjs";
|
|
9
|
+
import { createDeepLinks, parseParty, resourceMatches, serviceMatches, slugify, stylistSlug, withBookingQuery } from "./deep-link.mjs";
|
|
10
|
+
import { DRAFT_MAX_AGE_MS, createDraftStore, draftStorageKey } from "./draft-store.mjs";
|
|
11
|
+
import { RESOURCE_ID_SHAPE, createDto, toBookingDayDto, toBookingSlotDto } from "./dto.mjs";
|
|
12
|
+
import { createIcs, icsDataUrl } from "./ics.mjs";
|
|
13
|
+
import { createMoney } from "./money.mjs";
|
|
14
|
+
import { visitKey, visitKeyOfIds, visitOf, visitOfServices, visitPriceOre, visitServicesOf } from "./visit.mjs";
|
|
15
|
+
import { BOOKED_FOR_NAME_MAX_LENGTH, SELF_KEY, canAdvance, canGoToStep, createWizard, guestChild, initialState, isGuestSeat, itemResourceIds, itemStartTimes, showsPartyMode, visitEndTs, visitMinutes, visitTailMinutes } from "./machine.mjs";
|
|
16
|
+
import { earliestOpening, firstOpeningPerResource } from "./next-available.mjs";
|
|
17
|
+
import { NEXT_FREE_TTL_MS, createNextFree } from "./next-free.mjs";
|
|
18
|
+
import { createPartySlots, findPartySlots, firstPartyStartPerResource, slotKeysFor } from "./party-slots.mjs";
|
|
19
|
+
import { createPaths } from "./paths.mjs";
|
|
20
|
+
import { createPhone, telHref } from "./phone.mjs";
|
|
21
|
+
import { createPortalDto, monthOrNull, rebookSuggestions, toPortalProfileDto } from "./portal/dto.mjs";
|
|
22
|
+
import { completedNewestFirst, firstBookingFor, upcomingSoonestFirst } from "./portal/family.mjs";
|
|
23
|
+
import { codeField, emailField, verifyLoginInput } from "./portal/login-input.mjs";
|
|
24
|
+
import { createReturnPath } from "./portal/return-path.mjs";
|
|
25
|
+
import { createPortalNextPath } from "./portal/next-path.mjs";
|
|
26
|
+
import { createSessionCookie, isPortalSessionToken } from "./portal/session-cookie.mjs";
|
|
27
|
+
import { createVippsLinkCookies, isVippsToken, mintBrowserBinding, vippsLinkVerifyPath } from "./portal/vipps-link.mjs";
|
|
28
|
+
import { VIPPS_CONFIRM_QUERY, maskedAddress, stripVippsReturn, vippsConfirmFrom } from "./portal/vipps-return.mjs";
|
|
29
|
+
import { createRebookStore, rebookStorageKey } from "./rebook-store.mjs";
|
|
30
|
+
import { RESTORE_FALLBACK_MS, createRestoreGate, restorePendingInStorage } from "./restore-gate.mjs";
|
|
31
|
+
import { createStores } from "./stores.mjs";
|
|
32
|
+
export { ATTEMPT_TTL_MS, BOOKED_FOR_NAME_MAX_LENGTH, BookingConfigError, CONSENT_SOURCE, DRAFT_MAX_AGE_MS, LABEL_PACKS, NEXT_FREE_TTL_MS, RESOURCE_ID_SHAPE, RESTORE_FALLBACK_MS, SELF_KEY, VIPPS_CONFIRM_QUERY, adultCategoryFor, adultEquivalent, ageOnDay, attemptStorageKey, canAdvance, canGoToStep, categoryOrder, childCategory, codeField, completedNewestFirst, createAge, createAttemptStore, createClock, createDeepLinks, createDraftStore, createDto, createIcs, createMoney, createNextFree, createPartySlots, createPaths, createPhone, createPortalDto, createPortalNextPath, createRebookStore, createRestoreGate, createReturnPath, createSessionCookie, createStores, createVippsLinkCookies, createWizard, draftStorageKey, earliestOpening, emailField, fill, fillParts, findPartySlots, firstBookingFor, firstOpeningPerResource, firstPartyStartPerResource, fitsAge, guestChild, icsDataUrl, initialState, initialsOf, isBookingLabel, isChildCategory, isGuestSeat, isPortalSessionToken, isVippsToken, itemResourceIds, itemStartTimes, labelPackFor, labelText, marketingConsent, maskedAddress, mintBrowserBinding, monthOrNull, normaliseCategory, parseParty, rebookStorageKey, rebookSuggestions, resolveBookingConfig, resolveLabels, resourceMatches, restorePendingInStorage, serviceMatches, showsPartyMode, slotKeysFor, slugify, stripVippsReturn, stylistDisplayName, stylistSlug, telHref, toBookingDayDto, toBookingSlotDto, toPortalProfileDto, upcomingSoonestFirst, verifyLoginInput, vippsConfirmFrom, vippsLinkVerifyPath, visitEndTs, visitKey, visitKeyOfIds, visitMinutes, visitOf, visitOfServices, visitPriceOre, visitServicesOf, visitTailMinutes, withBookingQuery };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
//#region src/core/labels.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* The words the pure core puts into strings it builds itself — the sticky
|
|
4
|
+
* summary line, a day said relative to today, an age.
|
|
5
|
+
*
|
|
6
|
+
* Everything else a booking screen says belongs to the screen (the meda
|
|
7
|
+
* `/booking` screens take their copy through a `labels` prop). These are the
|
|
8
|
+
* few sentences that are assembled out of state, so the rule that assembles
|
|
9
|
+
* them and the words it uses have to travel together.
|
|
10
|
+
*
|
|
11
|
+
* A template has `{name}` holes and is a `BookingLabel`: a string or an array
|
|
12
|
+
* of strings, so a label pack stays serialisable (it rides in
|
|
13
|
+
* `BookingConfig.labels`, which is a page prop). Rendered as element children
|
|
14
|
+
* (`fillParts`), a string is ONE text node once filled and an array is one
|
|
15
|
+
* text node per element; as text (`fill`), an array's elements are joined.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* One label. A `string` renders as one text node once filled, like a template
|
|
19
|
+
* literal; a `string[]` renders one text node per element, each filled with
|
|
20
|
+
* the same values: `['Steg ', '{step}', ' av ', '{total}']` is four nodes.
|
|
21
|
+
* Text-only targets (`aria-label`, ICS, a file name) read it joined.
|
|
22
|
+
*/
|
|
23
|
+
export type BookingLabel = string | readonly string[];
|
|
24
|
+
/** Whether `value` is a `BookingLabel`: a string, or an array of strings. */
|
|
25
|
+
export declare function isBookingLabel(value: unknown): value is BookingLabel;
|
|
26
|
+
/** The labels the core reads. `/react` (P3) adds the screen copy on top. */
|
|
27
|
+
export interface CoreLabels {
|
|
28
|
+
/** `i dag` — mid-sentence, for «Neste ledige i dag 14:15». */
|
|
29
|
+
'clock.today': BookingLabel;
|
|
30
|
+
'clock.tomorrow': BookingLabel;
|
|
31
|
+
/** `I dag` — the same day, sized for a chip in the date strip. */
|
|
32
|
+
'clock.todayChip': BookingLabel;
|
|
33
|
+
'clock.tomorrowChip': BookingLabel;
|
|
34
|
+
/** `{day} {time}` — «i dag 14:15». */
|
|
35
|
+
'clock.when': BookingLabel;
|
|
36
|
+
/** `{date} kl. {time}` — the manage page's whole appointment in one line. */
|
|
37
|
+
'clock.dateTime': BookingLabel;
|
|
38
|
+
/** `{min} år`. */
|
|
39
|
+
'age.exact': BookingLabel;
|
|
40
|
+
/** `{min}–{max} år`, when only the birth year is known. */
|
|
41
|
+
'age.range': BookingLabel;
|
|
42
|
+
/** The summary bar's stylist part for a null preference. */
|
|
43
|
+
'summary.firstAvailable': BookingLabel;
|
|
44
|
+
/** The summary bar's time part before an hour is picked. */
|
|
45
|
+
'summary.pickTime': BookingLabel;
|
|
46
|
+
/** `{count} tjenester` — a family's summary. */
|
|
47
|
+
'summary.services': BookingLabel;
|
|
48
|
+
/** `{minutes} min totalt`. */
|
|
49
|
+
'summary.totalMinutes': BookingLabel;
|
|
50
|
+
/** `{count} barn` — guest children on step 1. */
|
|
51
|
+
'people.children': BookingLabel;
|
|
52
|
+
/** The logged-in parent's own seat. */
|
|
53
|
+
'people.self': BookingLabel;
|
|
54
|
+
/** A guest grown-up's seat. */
|
|
55
|
+
'people.adult': BookingLabel;
|
|
56
|
+
}
|
|
57
|
+
/** Per-key copy for config-defined groups: `daypart.<key>`, `category.<key>`. */
|
|
58
|
+
export type KeyedLabels = {
|
|
59
|
+
[key: `daypart.${string}`]: BookingLabel;
|
|
60
|
+
[key: `category.${string}`]: BookingLabel;
|
|
61
|
+
};
|
|
62
|
+
export type BookingLabels = CoreLabels & KeyedLabels;
|
|
63
|
+
/** The built-in packs, by language subtag. */
|
|
64
|
+
export declare const LABEL_PACKS: Readonly<Record<'nb' | 'en', Readonly<BookingLabels>>>;
|
|
65
|
+
/** Norwegian in any of its three spellings reads the `nb` pack; anything else `en`. */
|
|
66
|
+
export declare function labelPackFor(locale: string): 'nb' | 'en';
|
|
67
|
+
/** The built-in pack for `locale`, with the site's own words over it. */
|
|
68
|
+
export declare function resolveLabels(locale: string, overrides?: Partial<BookingLabels> | null): Readonly<BookingLabels>;
|
|
69
|
+
/** A label as plain text: an array's elements joined, a string as it is. */
|
|
70
|
+
export declare function labelText(label: BookingLabel): string;
|
|
71
|
+
/**
|
|
72
|
+
* `fill('{count} barn', { count: 2 })` → «2 barn», as plain text: an array is
|
|
73
|
+
* filled element by element and joined. An unknown hole stays as written.
|
|
74
|
+
*/
|
|
75
|
+
export declare function fill(template: BookingLabel, values: Readonly<Record<string, string | number>>): string;
|
|
76
|
+
/**
|
|
77
|
+
* A filled label as the text nodes it renders as: one for a string (like a
|
|
78
|
+
* template literal), one per element for an array, each element filled with
|
|
79
|
+
* the same values — `fillParts(['Steg ', '{step}', ' av {total}'], { step: 3,
|
|
80
|
+
* total: 4 })` → `['Steg ', '3', ' av 4']`. Rendered as element children,
|
|
81
|
+
* each entry becomes its own text node, so a pack decides where a sentence
|
|
82
|
+
* breaks (a browser lays text out per text node, and the same sentence split
|
|
83
|
+
* differently can land a sub-pixel apart). Use `fill` where the target takes
|
|
84
|
+
* text only (`aria-label`, `title`, ICS). Empty pieces are dropped.
|
|
85
|
+
*/
|
|
86
|
+
export declare function fillParts(template: BookingLabel, values: Readonly<Record<string, string | number>>): string[];
|
|
87
|
+
//#endregion
|
|
88
|
+
//# sourceMappingURL=labels.d.mts.map
|