@v-office/website-headless 0.0.1

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/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # @v-office/website-headless
2
+
3
+ The **behavior layer** for v-office websites. The invariant: **this package
4
+ never contains UI.** It is `.ts` only. Imported UI lives one layer up in
5
+ `@v-office/website-components` and renders over the hooks here; the
6
+ structural requirements that UI must satisfy (`checkout/structure.ts`) live
7
+ HERE, so a site's own UI is held to the same contract.
8
+
9
+ ```
10
+ @v-office/sdk-core domain contracts + PMS adapters
11
+ @v-office/website-sdk website facade (static + live namespaces)
12
+ @v-office/website-headless ← THIS: orchestration hooks, validation, legal
13
+ gates, derived manifest, enhancement engines
14
+ @v-office/website-components imported UI over these hooks (a site may copy and own it)
15
+ userland pages, brand components, tokens, copy
16
+ ```
17
+
18
+ ## Entry points
19
+
20
+ | Import | Contents |
21
+ | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
22
+ | `@v-office/website-headless` | Framework-free runtime: `createCheckoutFlow` — the whole checkout machine (stay → quote → selections → § 7 gate → book → pay) as a store with `getState` / `subscribe` / actions / `update` / `start` / `dispose`; `createStore`, the primitive every flow here builds on; `fetchQuote` + the quote view model, `book()`/`pay()` runners, the payment-return snapshot, validation rules, `CHECKOUT_PARTS` + `assertCheckoutStructure`, `logSdkFailure` |
23
+ | `…/manifest` | `siteManifest()` Astro integration — writes `dist/site-manifest.json` with installed package versions and per-surface states derived from the site's imports (`imported` = imports the component from website-components, `custom` = imports the surface's hook without it; see `manifest/surfaces.ts`). Three signals cross-checked per build. Node-only; import it in `astro.config` and nowhere else |
24
+ | `…/enhance` | Progressive enhancement for server-rendered markup: card image strips, history-backed content overlays, the rental gallery's layered viewer. They bind site markup and own none of it |
25
+
26
+ The flows are stores: a UI calls `getState()`, `subscribe(listener)` and the
27
+ actions, and feeds its inputs through `update()`. No framework code lives here.
28
+ `@v-office/website-components` (React) ships `useCheckoutFlow` for React sites
29
+ that render their own booking UI; a site that wants neither writes the binding
30
+ itself — it is this small:
31
+
32
+ ```ts
33
+ import { useEffect, useRef, useSyncExternalStore } from "react";
34
+ import {
35
+ createCheckoutFlow,
36
+ type CheckoutFlow,
37
+ type CheckoutFlowOptions,
38
+ } from "@v-office/website-headless";
39
+
40
+ export function useCheckoutFlow(options: CheckoutFlowOptions) {
41
+ const ref = useRef<CheckoutFlow | null>(null);
42
+ ref.current ??= createCheckoutFlow(options);
43
+ const flow = ref.current;
44
+ useEffect(() => flow.update(options));
45
+ useEffect(() => {
46
+ flow.start();
47
+ return () => flow.dispose();
48
+ }, [flow]);
49
+ const state = useSyncExternalStore(flow.subscribe, flow.getState, flow.getState);
50
+ return { ...state, ...flow };
51
+ }
52
+ ```
53
+
54
+ Vue: a `shallowRef` set in `subscribe`; Svelte: the store contract is the same
55
+ two functions; a plain `<script>` island: call `getState()` in the listener.
56
+
57
+ ## The one hard rule
58
+
59
+ **This package never constructs an SDK instance, never renders markup and never depends on a UI framework.**
60
+ Construction needs site credentials (`src/lib/voffice.ts` in each site);
61
+ rendering is the components' or the site's job. Every function takes the SDK
62
+ instance (or a `getSdk` thunk) from the caller. Copy/messages are always
63
+ parameters — the rules live here, the words live in the site.
64
+
65
+ ## Development
66
+
67
+ ```bash
68
+ pnpm install
69
+ pnpm build # tsdown → dist/ (index, manifest, enhance; .mjs + .d.mts)
70
+ pnpm check # tsc · oxlint · oxfmt --check
71
+ pnpm test # vitest, jsdom environment
72
+ ```
73
+
74
+ The tests cover the pure rules (validation, insurance gate, stay helpers,
75
+ quote adapter, manifest derivation), the checkout flow driven as a store
76
+ against a fake SDK, and the structural gate against rendered markup. Nothing
77
+ books or pays for real. vitest is used because the structural gate needs a
78
+ DOM (jsdom); the SDKs use `node --test` because they do not.
79
+
80
+ ## Publishing
81
+
82
+ The **Publish** workflow (`.github/workflows/publishSDK.yml`, choice
83
+ `website-headless`) publishes the exact version in `package.json` to npm after
84
+ build, check and tests pass; it never bumps versions. Set the version by hand,
85
+ merge, run the workflow. Consumers pin the exact version and receive updates
86
+ through fleet rollouts (docs/website-platform/process/06-package-rollout.md).
@@ -0,0 +1,181 @@
1
+ //#region src/enhance/carousel.d.ts
2
+ /**
3
+ * Per-card image carousel: drives the prev/next chevrons by calling
4
+ * `scrollTo` on the natively-scrolling image track (overflow-x: auto
5
+ * with scroll-snap on the track on touch devices, or scroll-snap
6
+ * none + JS-driven scrollTo on hover-capable devices).
7
+ *
8
+ * Native scroll matters for two reasons:
9
+ * 1. Lazy-loaded `<img loading="lazy">` only triggers load when
10
+ * the image enters the visible viewport via real scroll. With
11
+ * transform-based shifting, the browser doesn't recognize the
12
+ * image as "in view", so subsequent images stay un-loaded
13
+ * (white squares) when the user clicks next.
14
+ * 2. The scroll-snap behavior native scroll provides on touch is
15
+ * what the iOS swipe momentum/snap relies on; transform-based
16
+ * shifting can't reproduce it.
17
+ *
18
+ * Dots are optional and only updated when present.
19
+ */
20
+ declare function initImageCarousels(selector?: string): void;
21
+ declare function initRentalCarousel(viewport: HTMLElement): void;
22
+ //#endregion
23
+ //#region src/enhance/overlay.d.ts
24
+ /**
25
+ * The rental page's history-integrated overlay — one controller, two callers.
26
+ *
27
+ * WHY IT EXISTS. The amenities modal and the description modal each carried
28
+ * their own copy of this, and the two were identical: diffed after renaming the
29
+ * modal noun, they differed in line wrapping and nothing else. Roughly seventy
30
+ * lines of history handling, scroll locking, ESC and focus written twice, which
31
+ * is two places for the same behaviour to be corrected in and one of them to be
32
+ * forgotten.
33
+ *
34
+ * WHAT IT DOES NOT DECIDE. Nothing visual. The card, the backdrop, the header
35
+ * bar and the scroll region are the component's; this file opens and closes
36
+ * them. It also states no selectors of its own — every one is passed in, so the
37
+ * markup and the string that finds it stay in the same file and a grep for
38
+ * `data-amenities-overlay` still lands on the component that owns it.
39
+ *
40
+ * WHY `hidden` AND NOT `<dialog>`. This controller inherited the mechanism
41
+ * rather than choosing it: every overlay on the page — the gallery lightbox,
42
+ * the fullscreen viewer, the floor plan, the map — shows and hides by toggling
43
+ * the `hidden` attribute, — see the view-transition helper below for why that stayed when
44
+ * the fades went in. A second mechanism here would mean two ways to open a
45
+ * layer on one page and two ways for the crossfade to have to work. Moving all
46
+ * five onto `<dialog>` is a real question and a separate one; moving one is
47
+ * not an improvement.
48
+ *
49
+ * WHY THE HISTORY ENTRY. A modal that swallows the back button is the single
50
+ * worst thing a phone page can do: the gesture that means „go back" navigates
51
+ * away from the rental instead of closing what is over it. Opening pushes one
52
+ * state, and every close routes through `history.back()`, so the X, the ESC
53
+ * key, a click beside the card, the browser's own button and the trackpad
54
+ * swipe all take the same path and leave the history stack level.
55
+ *
56
+ * NOT ADOPTED BY THE OTHER TWO OVERLAYS on the rental page, and both for a stated
57
+ * reason.
58
+ * `RentalMap` is a fullscreen layer with no backdrop to click, no scroll region
59
+ * to reset and unconditional focus on open; taking it would mean three option
60
+ * flags to describe one caller, which is the API bending rather than the
61
+ * callers converging. `RentalGallery`'s controller is `is:inline` and cannot
62
+ * import anything at all — see the note in `gallery.ts`.
63
+ */
64
+ /** Run `mutate` inside a view transition where the browser has one and the
65
+ * visitor has not asked for less motion; run it directly otherwise. */
66
+ declare function withViewTransition(mutate: () => void): void;
67
+ /** Install the page's single pair of modality listeners. Safe to call often. */
68
+ declare function trackInputModality(): void;
69
+ /** Focus `el` only when the last real input was a keyboard one. */
70
+ declare function focusForKeyboard(el: HTMLElement | null): void;
71
+ /** Every selector the controller needs, stated by the caller. Each is scoped to
72
+ * `root`, so two overlays on one page cannot reach into each other. */
73
+ interface HistoryOverlayOptions {
74
+ /** The component's own root — the element that wraps BOTH the page-side
75
+ * trigger and the overlay, so one lookup finds everything. */
76
+ root: string;
77
+ /** The dimmed backdrop. It carries the `hidden` toggle, and a click that
78
+ * lands on it rather than on the card inside dismisses. */
79
+ overlay: string;
80
+ /** The inner scroll region, reset to the top on close. It is not the card:
81
+ * the card is the clip and the header, the block inside it is the scroller. */
82
+ scroller: string;
83
+ /** The trigger on the page. */
84
+ open: string;
85
+ /** The X in the overlay's header. */
86
+ close: string;
87
+ /** Value stored on the pushed history entry, so a close can tell ITS OWN
88
+ * state from someone else's before calling `back()`. Unique per overlay. */
89
+ historyKey: string;
90
+ }
91
+ /** Bind one overlay. Returns silently when the markup is not on this page, so
92
+ * a component whose overlay self-hides costs nothing. */
93
+ declare function initHistoryOverlay(options: HistoryOverlayOptions): void;
94
+ //#endregion
95
+ //#region src/enhance/gallery.d.ts
96
+ /**
97
+ * The gallery's layered overlays, as an engine the component configures.
98
+ *
99
+ * WHAT IT OWNS: opening and closing a stack of layers, the browser-history
100
+ * entry each one pushes, the scroll lock, layered ESC, focus on open and
101
+ * restore on close, the lazily-loaded photo reveal, the scroll-snap viewer's
102
+ * index and its prev/next stepping, and keeping a page-level carousel on the
103
+ * photo the visitor was last looking at.
104
+ *
105
+ * WHAT IT DECIDES: nothing visual. It states **no selector of its own** — every
106
+ * one is passed in, the rule `overlay.ts` already set, so the markup and the
107
+ * string that finds it stay in the same file and a grep for
108
+ * `data-gallery-lightbox` still lands on the component that owns it. It sets no
109
+ * class, reads no colour and renders no markup.
110
+ *
111
+ * WHY THE LAYERS ARE A LIST. The controller this replaces hardcoded its three
112
+ * layers and their nesting order in three separate places — the `popstate`
113
+ * handler, the `keydown` handler and the body-scroll restore — and opened with
114
+ * `if (!lightbox || !lightboxClose || !fullscreen || !fsTrack) return`. A page
115
+ * that opens the viewer straight from the photos, with no intermediate grid,
116
+ * therefore got **no gallery at all** and no error saying so. That is not a
117
+ * hypothetical composition: it is the obvious one for a site whose photos are
118
+ * already a full-width strip. Layers are now whatever the caller lists,
119
+ * outermost first, and a layer whose markup is absent is simply not bound.
120
+ *
121
+ * WHY IT IS A MODULE AND NOT `is:inline`. It was inline from the gallery's first
122
+ * commit with no reason recorded, and the cost was stated in two places: the
123
+ * four-line copy of `withViewTransition` it had to carry, and the third copy of
124
+ * the input-modality listeners `overlay.ts` names in its own header. An
125
+ * `is:inline` script is processed by neither Vite nor Astro and can import
126
+ * nothing, so both copies were forced rather than chosen.
127
+ *
128
+ * The one thing that changes by importing: the emitted script crosses Vite's
129
+ * `assetsInlineLimit` (4096 bytes), so Astro links it as an external module
130
+ * instead of writing it into the page. Any gate that located MARKUP by
131
+ * searching the built HTML was, until it read markup only, able to match the
132
+ * selector literals in the inlined source — see the note in the blueprints'
133
+ * `fact-check.py`. That is fixed there rather than worked around here.
134
+ */
135
+ /** One overlay the gallery can open. Absent markup → not bound, no error. */
136
+ interface GalleryLayer {
137
+ /** Stable name, used for this layer's history entry. */
138
+ name: string;
139
+ /** The panel that carries the `hidden` toggle. */
140
+ panel: string;
141
+ /** Everything that opens it. */
142
+ open: string;
143
+ /** The X inside it. */
144
+ close?: string;
145
+ /** Backdrop: a click that lands on it and not on the card inside dismisses. */
146
+ backdrop?: string;
147
+ /** Inner scroll region, reset to the top on close. Not the card — the card is
148
+ * the clip and the header, the block inside it is the scroller. */
149
+ scroller?: string;
150
+ /** This layer is the photo viewer: its openers carry an image index and it
151
+ * drives `track`. */
152
+ opensAt?: "index";
153
+ /** Preload the panel's image on the first hover/focus of any opener, so the
154
+ * panel does not open onto an empty surface while the bytes arrive. */
155
+ warmOnIntent?: boolean;
156
+ /** Publish the panel image's real aspect ratio as `--fp-ar` once known, so a
157
+ * wrong width/height guess does not size the box. */
158
+ fitImage?: string;
159
+ }
160
+ interface GalleryOptions {
161
+ /** The element wrapping every trigger and every layer, so one lookup finds
162
+ * them all and two galleries on a page cannot reach into each other. */
163
+ root: string;
164
+ /** Horizontal scroll-snap track inside the viewer layer. */
165
+ track?: string;
166
+ trackPrev?: string;
167
+ trackNext?: string;
168
+ /** Element whose text becomes `n / total`. */
169
+ counter?: string;
170
+ /** A page-level carousel kept in sync with the viewer on close. */
171
+ carousel?: string;
172
+ carouselCounter?: string;
173
+ /** Attribute on a viewer opener carrying its image index. */
174
+ indexAttr?: string;
175
+ /** Outermost first. */
176
+ layers: GalleryLayer[];
177
+ }
178
+ /** Bind one gallery. Returns silently when its root is not on this page. */
179
+ declare function initGallery(options: GalleryOptions): void;
180
+ //#endregion
181
+ export { type GalleryLayer, type GalleryOptions, type HistoryOverlayOptions, focusForKeyboard, initGallery, initHistoryOverlay, initImageCarousels, initRentalCarousel, trackInputModality, withViewTransition };