@peektravel/app-utilities 0.1.1 → 0.1.2

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 CHANGED
@@ -7,26 +7,15 @@ high-level `PeekAccessService` and the plain data shapes it returns.
7
7
 
8
8
  ## Install
9
9
 
10
- This package is published to **GitHub Packages** (a private registry), not the
11
- public npm registry. Point the `@peek-travel` scope at GitHub Packages once per
12
- consuming project by adding an `.npmrc` next to its `package.json`:
13
-
14
- ```ini
15
- @peek-travel:registry=https://npm.pkg.github.com
16
- //npm.pkg.github.com/:_authToken=${NPM_TOKEN}
17
- ```
18
-
19
- Then install (and later update) it like any other dependency:
10
+ This package is published to the **public npm registry**. Install (and later
11
+ update) it like any other dependency no registry config or auth token needed:
20
12
 
21
13
  ```bash
22
14
  npm install @peektravel/app-utilities
23
15
  npm update @peektravel/app-utilities
24
16
  ```
25
17
 
26
- `NPM_TOKEN` must be a GitHub token with the `read:packages` scope. Locally that's
27
- a personal access token in your environment; in cloud builds (Firebase
28
- Functions / Google Cloud Build, CI) set it as a build secret/env var. See
29
- [Releasing](#releasing-github-packages) for how new versions are published.
18
+ See [Releasing](#releasing) for how new versions are published.
30
19
 
31
20
  ## Usage
32
21
 
@@ -207,13 +196,90 @@ both `import` and `require` consumers (including the Node 22 / CommonJS Firebase
207
196
  Functions runtime) resolve correctly. Its only runtime dependency is
208
197
  `jsonwebtoken`.
209
198
 
210
- ## Releasing (GitHub Packages)
199
+ ## UI components (`/ui`)
200
+
201
+ The package also ships framework-agnostic **Web Components** ported from the Peek
202
+ Odyssey design system, under a separate browser-only subpath so the server
203
+ library stays DOM-free. They work in any HTML page — no framework required.
204
+
205
+ ```ts
206
+ // Registers every <ody-*> custom element as a side effect.
207
+ import '@peektravel/app-utilities/ui';
208
+ import '@peektravel/app-utilities/ui/tokens.css';
209
+ import '@peektravel/app-utilities/ui/odyssey.css';
210
+ ```
211
+
212
+ ```html
213
+ <ody-button variant="primary" left-icon="plus">New booking</ody-button>
214
+ <ody-tag color="success" icon="check">Confirmed</ody-tag>
215
+ <ody-alert variant="warning" heading="Heads up">This can't be undone.</ody-alert>
216
+ <ody-input label="Guest name" placeholder="Jane Doe"></ody-input>
217
+ ```
218
+
219
+ Coverage spans display (button, tag, alert, card, status-dot, message, icon,
220
+ loading-spinner/bar, divider), layout (empty-state, breadcrumb, stat-summary,
221
+ inline-list, list-item, product-indicator, toggle-button, section, two-column,
222
+ collapsible-section), form inputs (input, inline/search/money/percentage input,
223
+ checkbox, radio-button-group, checkbox-group), interactive (accordion,
224
+ collapsible, tabs, copy-button, check-in-status, option, split-button,
225
+ table-header), overlays (modal, popover, tooltip, panel, toast), and data &
226
+ selection (dropdown-single, dropdown-multi, datepicker, table — all vanilla and
227
+ dependency-free, following WAI-ARIA combobox/listbox/grid patterns).
228
+
229
+ Interactive components reflect state and emit `CustomEvent`s; grouped components
230
+ (tabs, radio/checkbox groups, toggle group) take a JSON `options`/`tabs`
231
+ attribute. Exported classes/types and helpers (`iconSvg`, `registerIcon`,
232
+ `portal`, `position`, `toast`) are available from `@peektravel/app-utilities/ui`
233
+ for subclassing or typing.
234
+
235
+ **Try the gallery:** `npm run sample` builds the package and serves
236
+ `examples/ui-gallery.html`, which shows every component with its variants.
237
+
238
+ ### Localization
239
+
240
+ Content you pass in (labels, headings, options, cell data) is already yours to
241
+ localize. The components' **own** built-in strings (aria-labels like "Close" /
242
+ "Clear", the date picker's "Select date" and month-nav labels, the dropdown
243
+ "Search" / "No options", the check-in-status labels) are translatable two ways:
244
+
245
+ ```ts
246
+ import { registerTranslation } from '@peektravel/app-utilities/ui';
247
+
248
+ registerTranslation('es', {
249
+ close: 'Cerrar', clear: 'Borrar', search: 'Buscar',
250
+ checkInReturned: 'Devuelto', /* … */
251
+ });
252
+ ```
253
+
254
+ Each component resolves its language from the nearest `lang` attribute
255
+ (`<html lang="es">` localizes everything; a subtree `lang` overrides it), and
256
+ re-renders automatically when the language or a registered bundle changes.
257
+ English is the built-in default. For a one-off, a per-instance attribute wins:
258
+ `<ody-panel close-label="Cerrar">`, `<ody-datepicker next-month-label="…">`,
259
+ `<ody-check-in-status label="…">`.
260
+
261
+ The **date picker** displays dates with `Intl.DateTimeFormat` for the resolved
262
+ `lang` — a readable, localized label (e.g. "Jun 15, 2026" / "15 jun 2026")
263
+ rather than the raw ISO string. Its `value` attribute and `change` payload stay
264
+ machine-readable ISO `yyyy-mm-dd` (range as `start/end`). Tune the displayed
265
+ form with `display-format` (`short` | `medium` | `long` | `full`) or take full
266
+ control with the `formatDate` property. Weekday/month names and day labels in
267
+ the calendar are likewise `Intl`-localized, so they aren't in the term catalog.
268
+
269
+ > A few Odyssey components remain unported: `nested-multi-select`,
270
+ > `location-autocomplete` (Google Maps API), `filter-menu` / `filter-menu-single`,
271
+ > `accordion-checkbox`, and `datepicker-with-presets`. The dropdowns, the single
272
+ > date picker, and the data table were rebuilt here as lightweight,
273
+ > dependency-free vanilla components rather than ported from their
274
+ > third-party-coupled Ember originals.
275
+
276
+ ## Releasing
211
277
 
212
278
  Releases are automated. Pushing a `v*.*.*` git tag triggers
213
279
  `.github/workflows/publish.yml`, which typechecks, lints, runs the test suite
214
- (95% coverage gate), then publishes to GitHub Packages. `npm publish` runs
215
- `prepublishOnly` first, so the build plus `publint` + `attw` checks gate every
216
- release.
280
+ (95% coverage gate), then publishes to the public npm registry. `npm publish`
281
+ runs `prepublishOnly` first, so the build plus `publint` + `attw` checks gate
282
+ every release.
217
283
 
218
284
  To cut a release:
219
285
 
@@ -223,13 +289,14 @@ git push --follow-tags # pushes the commit + tag; the workflow publishes
223
289
  ```
224
290
 
225
291
  The workflow asserts the tag matches the `package.json` version, so the two
226
- never drift. The repo's built-in `GITHUB_TOKEN` (with `packages: write`) handles
227
- publish auth no personal token needed in CI. Consumers then pick the new
228
- version up with a normal `npm update` (see [Install](#install)).
292
+ never drift. Publish auth uses an `NPM_TOKEN` repository secret (an npm
293
+ automation token with publish rights to the `@peektravel` scope), exposed to
294
+ `npm publish` as `NODE_AUTH_TOKEN`. Consumers then pick the new version up with
295
+ a normal `npm update` (see [Install](#install)).
229
296
 
230
- > One-time setup: the package scope `@peek-travel` must match the GitHub org
231
- > that owns the package, and the repo needs the GitHub Actions permission to
232
- > write packages (granted by the `packages: write` permission in the workflow).
297
+ > One-time setup: add an `NPM_TOKEN` secret to the repository (Settings →
298
+ > Secrets and variables Actions). Generate it on npmjs.com as an **Automation**
299
+ > token so it bypasses 2FA in CI.
233
300
 
234
301
  ## Development
235
302
 
@@ -249,13 +316,15 @@ npm run lint # eslint (flat config)
249
316
  and [`@arethetypeswrong/cli`](https://github.com/arethetypeswrong/arethetypeswrong.github.io)
250
317
  to verify the `exports` map and type resolution are correct for both module
251
318
  systems. The publish workflow runs these automatically — see
252
- [Releasing](#releasing-github-packages).
319
+ [Releasing](#releasing).
253
320
 
254
321
  ## Project layout
255
322
 
256
323
  ```
257
- src/ source (public API barrel: src/index.ts)
258
- test/ vitest unit tests
324
+ src/ server library source (public API barrel: src/index.ts)
325
+ src/ui/ Web Components + Odyssey CSS (barrel: src/ui/index.ts)
326
+ test/ vitest unit tests (test/ui/* run under happy-dom)
327
+ examples/ui-gallery.html component gallery (npm run sample)
259
328
  dist/ build output (generated, git-ignored)
260
329
  docs/internal/ maintainer docs (ARCHITECTURE.md — not shipped)
261
330
  llms.txt AI-agent quickstart (shipped in the package)
package/dist/index.cjs CHANGED
@@ -2151,6 +2151,132 @@ var ResourcePoolService = class {
2151
2151
  }
2152
2152
  };
2153
2153
 
2154
+ // src/internal/reviews/review-converter.ts
2155
+ var ISO_DATE_LENGTH = 10;
2156
+ function toDateOnly(isoDateTime) {
2157
+ return isoDateTime.slice(0, ISO_DATE_LENGTH);
2158
+ }
2159
+ function fromReviewNode(node) {
2160
+ const guides = (node.guides ?? []).map((guide) => ({
2161
+ id: guide.id,
2162
+ name: guide.name
2163
+ }));
2164
+ return {
2165
+ id: node.id,
2166
+ productId: node.activity?.id ?? "",
2167
+ productName: node.activity?.name ?? "",
2168
+ guides,
2169
+ customerName: node.name ?? null,
2170
+ customerEmail: node.email ?? null,
2171
+ activityDate: toDateOnly(node.purchasedFor),
2172
+ reviewDate: toDateOnly(node.reviewedAt),
2173
+ rating: node.rating,
2174
+ comment: node.comment ?? null
2175
+ };
2176
+ }
2177
+
2178
+ // src/internal/reviews/review-cursor.ts
2179
+ function encodeCursor(offset, pageSize) {
2180
+ const start = Math.max(0, offset - pageSize + 1);
2181
+ return Buffer.from(`range:${start}..${offset},${offset}`).toString("base64");
2182
+ }
2183
+
2184
+ // src/internal/reviews/review-queries.ts
2185
+ var REVIEWS_QUERY = `
2186
+ query Reviews($first: Int, $filter: ReviewFilter, $after: String) {
2187
+ reviews(first: $first, filter: $filter, after: $after) {
2188
+ edges {
2189
+ node {
2190
+ activity {
2191
+ id
2192
+ name
2193
+ }
2194
+ guides {
2195
+ id
2196
+ name
2197
+ }
2198
+ id
2199
+ name
2200
+ email
2201
+ rating
2202
+ comment
2203
+ reviewedAt
2204
+ purchasedFor
2205
+ }
2206
+ cursor
2207
+ }
2208
+ }
2209
+ }
2210
+ `;
2211
+ function buildReviewsVariables(params) {
2212
+ return {
2213
+ first: params.first,
2214
+ filter: { activityIds: [params.activityId] },
2215
+ after: params.after
2216
+ };
2217
+ }
2218
+
2219
+ // src/internal/reviews/review-service.ts
2220
+ var DEFAULT_REVIEW_COUNT = 50;
2221
+ var MIN_REVIEW_COUNT = 1;
2222
+ var MAX_REVIEW_COUNT = 50;
2223
+ var ERROR_PRODUCT_ID_REQUIRED = "productId is required";
2224
+ var ERROR_INVALID_REVIEW_COUNT = `reviewCount must be an integer between ${MIN_REVIEW_COUNT} and ${MAX_REVIEW_COUNT}`;
2225
+ var ERROR_INVALID_REVIEW_OFFSET = "reviewOffset must be a non-negative integer";
2226
+ var ReviewService = class {
2227
+ constructor(client) {
2228
+ this.client = client;
2229
+ }
2230
+ client;
2231
+ /**
2232
+ * Returns up to `reviewCount` reviews for an activity in **descending order
2233
+ * by review date (newest first)**, skipping the `reviewOffset` newest reviews
2234
+ * before collecting.
2235
+ *
2236
+ * The gateway cursor resumes *after* a given absolute offset, so to skip the
2237
+ * first `reviewOffset` reviews the request is anchored on the review just
2238
+ * before the window. A `reviewOffset` of 0 needs no cursor and is sent
2239
+ * without one.
2240
+ *
2241
+ * @param productId - The activity (product) id to fetch reviews for.
2242
+ * @param reviewCount - How many reviews to return (1–50). Default: 50.
2243
+ * @param reviewOffset - How many of the newest reviews to skip first
2244
+ * (0-based). Default: 0 (start at the newest review).
2245
+ *
2246
+ * @throws {Error} when `productId` is empty, `reviewCount` is not an integer
2247
+ * in 1–50, or `reviewOffset` is not a non-negative integer.
2248
+ *
2249
+ * @example
2250
+ * ```ts
2251
+ * const reviews = await peek
2252
+ * .getReviewService()
2253
+ * .getReviews("87cdf37f-1872-42cb-b0bd-518312624fc1", 25, 50);
2254
+ * ```
2255
+ */
2256
+ async getReviews(productId, reviewCount = DEFAULT_REVIEW_COUNT, reviewOffset = 0) {
2257
+ this.validate(productId, reviewCount, reviewOffset);
2258
+ const after = reviewOffset > 0 ? encodeCursor(reviewOffset - 1, reviewCount) : null;
2259
+ const body = await this.client.request(
2260
+ SALES_ENDPOINT,
2261
+ REVIEWS_QUERY,
2262
+ buildReviewsVariables({ activityId: productId, first: reviewCount, after })
2263
+ );
2264
+ const edges = body.data?.reviews?.edges ?? [];
2265
+ return edges.map((edge) => fromReviewNode(edge.node));
2266
+ }
2267
+ validate(productId, reviewCount, reviewOffset) {
2268
+ if (!productId) {
2269
+ throw new Error(ERROR_PRODUCT_ID_REQUIRED);
2270
+ }
2271
+ if (!Number.isInteger(reviewCount) || reviewCount < MIN_REVIEW_COUNT || reviewCount > MAX_REVIEW_COUNT) {
2272
+ throw new Error(ERROR_INVALID_REVIEW_COUNT);
2273
+ }
2274
+ if (!Number.isInteger(reviewOffset) || reviewOffset < 0) {
2275
+ throw new Error(ERROR_INVALID_REVIEW_OFFSET);
2276
+ }
2277
+ }
2278
+ };
2279
+
2154
2280
  // src/internal/timeslots/timeslot-converter.ts
2155
2281
  function fromTimeslotNodes(nodes, productId) {
2156
2282
  if (!Array.isArray(nodes) || nodes.length === 0) {
@@ -2798,6 +2924,7 @@ var PeekAccessService = class {
2798
2924
  availabilityService;
2799
2925
  membershipService;
2800
2926
  bookingService;
2927
+ reviewService;
2801
2928
  constructor(config) {
2802
2929
  requireNonEmpty(config.installId, "installId");
2803
2930
  requireNonEmpty(config.jwtSecret, "jwtSecret");
@@ -2934,6 +3061,16 @@ var PeekAccessService = class {
2934
3061
  }
2935
3062
  return this.bookingService;
2936
3063
  }
3064
+ /**
3065
+ * Returns the {@link ReviewService} for this install, bound to the shared
3066
+ * authenticated transport. The instance is created lazily and reused.
3067
+ */
3068
+ getReviewService() {
3069
+ if (!this.reviewService) {
3070
+ this.reviewService = new ReviewService(this.client);
3071
+ }
3072
+ return this.reviewService;
3073
+ }
2937
3074
  };
2938
3075
  function requireNonEmpty(value, name) {
2939
3076
  if (!value) {
@@ -2955,6 +3092,7 @@ exports.PromoCodeService = PromoCodeService;
2955
3092
  exports.RateLimitError = RateLimitError;
2956
3093
  exports.ResellerService = ResellerService;
2957
3094
  exports.ResourcePoolService = ResourcePoolService;
3095
+ exports.ReviewService = ReviewService;
2958
3096
  exports.TimeslotService = TimeslotService;
2959
3097
  exports.noopLogger = noopLogger;
2960
3098
  //# sourceMappingURL=index.cjs.map