@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 +86 -0
- package/dist/enhance/index.d.mts +181 -0
- package/dist/enhance/index.mjs +533 -0
- package/dist/index.d.mts +912 -0
- package/dist/index.mjs +1423 -0
- package/dist/manifest/index.d.mts +118 -0
- package/dist/manifest/index.mjs +264 -0
- package/package.json +48 -0
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 };
|