@reblu/site-client 5.18.0 → 5.20.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 CHANGED
@@ -1,5 +1,21 @@
1
1
  # @reblu/site-client
2
2
 
3
+ ## 5.20.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 93143eb: Reviews (REB-1272). `@reblu/site-contracts` exports the `Review` and `ReviewSource` types of `GET /api/site/reviews`. `@reblu/site-client` adds `client.reviews.list({ pageId?, limit? }, cache?)`: the tenant's published reviews in the order set in nexus, optionally only one page's (404 without the tenant's `reviews` module). `@reblu/site-blocks` adds `Reviews`, a Server Component drop-in: a responsive grid with an accessible star rating, the author, the date, the text and a «Ver en <fuente>» link to the original (new tab, `rel="noopener nofollow"`), plus an optional tenant `notice` above or below the grid. Every existing export and namespace is unchanged.
8
+
9
+ ## 5.19.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 9a83295: Guest course checkout (REB-1264). Additive only: every existing export behaves as before.
14
+ - `@reblu/site-contracts` exports `AcademyCheckoutRequest`, `AcademyCheckoutBilling`, `AcademyCheckoutSession` and `AcademyCheckoutErrorCode` (`POST /api/site/academy/checkout`).
15
+ - `@reblu/site-client` adds `client.academy.checkout(body, options?)` → `{ url, sessionId }` (Stripe URL re-checked like `shop.checkout()`); a refusal throws `AcademyCheckoutError` (an `ApiError`) with the stable `code` and `details`.
16
+ - `@reblu/site-next` adds `createAcademyCheckoutRoute()` in `./route-handlers`: forwards `x-reblu-visitor-ip`, fills `successUrl`/`cancelUrl` on `coursePath` and re-checks the Stripe URL. `createShopCheckoutRoute` now shares that code, unchanged in behaviour.
17
+ - `@reblu/site-blocks` adds the drop-in `CourseGuestCheckoutForm` (`./client`): name, surname, email, terms box, the course's consent box, optional full invoice, total and pay notice; it gives way to the request form on `ENROLLMENT_REQUEST_REQUIRED`. The root entry adds `courseSellsAsGuest` and `toAcademyCheckoutInvoiceFields`.
18
+
3
19
  ## 5.18.0
4
20
 
5
21
  ### Minor Changes
package/README.md CHANGED
@@ -199,13 +199,14 @@ resource; list methods return `{ data, meta? }` (pagination metadata preserved).
199
199
  | `analytics` | `get()` |
200
200
  | `legal` | `get()` — privacy, terms, cookies, `legalNotice`, `withdrawalPolicy` |
201
201
  | `trustBadges` | `list(params?)` — `{ type? }`; only active badges inside their validity window |
202
+ | `reviews` | `list(params?)` — `{ pageId?, limit? }`; published reviews only (REB-1272). 404 without the tenant's `reviews` module |
202
203
  | `ctas` | `list(slot)` |
203
204
  | `blog` | `list(params?)`, `bySlug(slug)`, `related(slug)`, `categories()`, `slugs()`, `preview(id)` |
204
205
  | `pages` | `list(params?)`, `bySlug(slug)`, `featured(category, opts?)`, `slugs()` |
205
206
  | `dataRequests` | `submit(body, options?)` (write) |
206
207
  | `withdrawalRequests` | `submit(body, options?)` (write) — REB-1201 withdrawal form |
207
208
  | `shop` | `products(params?)`, `facets(params?)`, `bySlug(slug)`, `categories()`, `config()`, `checkout(body)`, `quote(body, opts?)`, `validateCart(body)`, `applyDiscount(body)`, `checkEmail(body)`, `order(sessionId)` |
208
- | `academy` | `courses(params?)`, `bySlug(slug)`, `categories()`, `lessonPreview(courseSlug, lessonSlug)`, `enrollmentRequest(body, options?)` (write) |
209
+ | `academy` | `courses(params?)`, `bySlug(slug)`, `categories()`, `lessonPreview(courseSlug, lessonSlug)`, `checkout(body, options?)` (write, REB-1264), `enrollmentRequest(body, options?)` (write) |
209
210
 
210
211
  Every method accepts an optional trailing `RebluCachePolicy` (`{ revalidate }`) to
211
212
  override caching per call. `blog.preview` is always uncached (draft content), and so is
@@ -229,6 +230,23 @@ const { data: badges } = await client.trustBadges.list({}, { revalidate: 3600 })
229
230
  `{ revalidate: false }` (or `0`) fetches on every request when a badge must
230
231
  disappear the moment its window closes.
231
232
 
233
+ #### Reviews (REB-1272)
234
+
235
+ `reviews.list()` returns the tenant's **published** reviews in the order set in
236
+ nexus («Reseñas»). Each one carries its source and an `https://` link to the
237
+ original, so a visitor can check it. Pass a page's `id` for only the reviews
238
+ linked to that page (a treatment, a specialty, a professional's profile), and
239
+ `limit` for at most that many:
240
+
241
+ ```ts
242
+ const { data: home } = await client.reviews.list({ limit: 6 }, { revalidate: 3600 })
243
+ const { data: forPage } = await client.reviews.list({ pageId: page.id })
244
+ ```
245
+
246
+ The endpoint answers 404 when the tenant does not have the `reviews` module, so
247
+ catch it and render nothing: `.catch(() => ({ data: [] }))`. Render the list
248
+ with `Reviews` from `@reblu/site-blocks`.
249
+
232
250
  `pages.bySlug(slug)` returns a `PageDetail` whose `blocks[]` mirror their
233
251
  parent section's `heading`/`backgroundColor`/`cta`, with embed blocks
234
252
  (`doctor_embed`/`specialty_embed`) additionally carrying a server-resolved
@@ -357,6 +375,47 @@ const ack = await client.withdrawalRequests.submit({
357
375
  Same rejections as `dataRequests.submit` (`400`, `422`, `429`). For the drop-in
358
376
  form see `WithdrawalRequestForm` in `@reblu/site-blocks/client`.
359
377
 
378
+ #### Guest course checkout (REB-1264)
379
+
380
+ `academy.checkout(body, options?)` buys a course that needs no validation in
381
+ one step (`POST /api/site/academy/checkout`) and returns the Stripe Checkout
382
+ `{ url, sessionId }`; the account is created after payment. The answer never
383
+ depends on whether the email has an account. The `url` is re-checked like
384
+ `shop.checkout()`'s (`UnsafeCheckoutRedirectError`).
385
+
386
+ ```ts
387
+ import { AcademyCheckoutError, visitorIpFromHeaders } from '@reblu/site-client'
388
+
389
+ try {
390
+ const { url } = await client.academy.checkout(
391
+ {
392
+ courseSlug: 'intro',
393
+ firstName: 'Ada',
394
+ lastName: 'Lovelace',
395
+ email: 'ada@example.com',
396
+ acceptsTerms: true,
397
+ termsVersionId: shopConfig.checkout.termsVersion?.id, // when the tenant published terms
398
+ acceptsWithdrawalWaiver: true, // when course.withdrawalConsent is not null…
399
+ withdrawalWaiverVersion: course.withdrawalConsent?.version, // …and its version
400
+ successUrl: 'https://site.example/academy/intro?pago=ok&session_id={CHECKOUT_SESSION_ID}',
401
+ cancelUrl: 'https://site.example/academy/intro?pago=cancelado',
402
+ },
403
+ { visitorIp: visitorIpFromHeaders(request.headers) },
404
+ )
405
+ // redirect the buyer to `url`
406
+ } catch (e) {
407
+ if (e instanceof AcademyCheckoutError && e.code === 'ENROLLMENT_REQUEST_REQUIRED') {
408
+ // validation required or free course: use academy.enrollmentRequest instead
409
+ }
410
+ }
411
+ ```
412
+
413
+ A refusal throws `AcademyCheckoutError` (an `ApiError`) with the stable `code`
414
+ (`AcademyCheckoutErrorCode`), `details` (`field`, `reason`, `fieldErrors`
415
+ with one message per field, or `missing` on `INVOICING_NOT_READY`) and the server's `errorMessage`. For the drop-in
416
+ form see `CourseGuestCheckoutForm` in `@reblu/site-blocks/client`, with
417
+ `createAcademyCheckoutRoute` from `@reblu/site-next/route-handlers`.
418
+
360
419
  #### Contact form metadata — the page the lead came from (REB-1077)
361
420
 
362
421
  Build the contact form's `metadata` with `buildContactFormMetadata()` in the
@@ -406,7 +465,7 @@ await client.contactForm.submit(payload, {
406
465
  })
407
466
  ```
408
467
 
409
- `shop.checkEmail` and `academy.enrollmentRequest` take the same `options`
468
+ `shop.checkEmail`, `academy.checkout` and `academy.enrollmentRequest` take the same `options`
410
469
  (REB-1006): base-api keys their per-IP limits on `x-reblu-visitor-ip` too.
411
470
 
412
471
  Without `visitorIp` base-api applies only the per-tenant limit to the public
package/dist/index.cjs CHANGED
@@ -131,6 +131,69 @@ function apiErrorRawBody(error) {
131
131
  const prefix = `API ${error.status} @ ${error.path}: `;
132
132
  return error.message.startsWith(prefix) ? error.message.slice(prefix.length) : error.message;
133
133
  }
134
+ function toAcademyCheckoutDetails(value) {
135
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
136
+ const raw = value;
137
+ const details = {};
138
+ if (typeof raw.field === "string") details.field = raw.field;
139
+ if (typeof raw.reason === "string") details.reason = raw.reason;
140
+ if (Array.isArray(raw.missing) && raw.missing.every((item) => typeof item === "string")) {
141
+ details.missing = raw.missing;
142
+ }
143
+ if (typeof raw.fieldErrors === "object" && raw.fieldErrors !== null && !Array.isArray(raw.fieldErrors)) {
144
+ const fieldErrors = Object.fromEntries(
145
+ Object.entries(raw.fieldErrors).filter(
146
+ (entry) => typeof entry[1] === "string"
147
+ )
148
+ );
149
+ if (Object.keys(fieldErrors).length > 0) details.fieldErrors = fieldErrors;
150
+ }
151
+ return Object.keys(details).length > 0 ? details : void 0;
152
+ }
153
+ function parseAcademyCheckoutBody(rawBody) {
154
+ try {
155
+ const parsed = JSON.parse(rawBody);
156
+ if (typeof parsed !== "object" || parsed === null) return {};
157
+ const details = toAcademyCheckoutDetails(parsed.details);
158
+ return {
159
+ ...typeof parsed.error === "string" ? { error: parsed.error } : {},
160
+ ...isAcademyCheckoutErrorCode(parsed.code) ? { code: parsed.code } : {},
161
+ ...details ? { details } : {}
162
+ };
163
+ } catch {
164
+ return {};
165
+ }
166
+ }
167
+ var ACADEMY_CHECKOUT_CODE_SET = {
168
+ INVALID_INPUT: true,
169
+ RATE_LIMITED: true,
170
+ COURSE_NOT_FOUND: true,
171
+ SALES_CLOSED: true,
172
+ ENROLLMENT_REQUEST_REQUIRED: true,
173
+ INVOICING_NOT_READY: true,
174
+ TERMS_NOT_ACCEPTED: true,
175
+ TERMS_VERSION_CHANGED: true,
176
+ CONSENT_REQUIRED: true,
177
+ CONSENT_VERSION_CHANGED: true,
178
+ INVALID_BILLING: true
179
+ };
180
+ function isAcademyCheckoutErrorCode(value) {
181
+ return typeof value === "string" && Object.hasOwn(ACADEMY_CHECKOUT_CODE_SET, value);
182
+ }
183
+ var AcademyCheckoutError = class extends ApiError {
184
+ code;
185
+ details;
186
+ errorMessage;
187
+ constructor(source) {
188
+ const rawBody = apiErrorRawBody(source);
189
+ super(source.status, source.path, rawBody);
190
+ this.name = "AcademyCheckoutError";
191
+ const parsed = parseAcademyCheckoutBody(rawBody);
192
+ this.code = parsed.code;
193
+ this.details = parsed.details;
194
+ this.errorMessage = parsed.error;
195
+ }
196
+ };
134
197
 
135
198
  // src/client.ts
136
199
  var RETRY_AFTER_CAP_MS = 2e3;
@@ -305,6 +368,38 @@ function makeAcademyNamespace(ctx) {
305
368
  categories: (cache) => requestList(ctx, "/api/site/academy/categories", {
306
369
  cachePolicy: cache
307
370
  }),
371
+ /**
372
+ * REB-1264 — guest checkout of a course that needs no validation: name,
373
+ * email, the accepted terms version, the course's consent box version
374
+ * and, optionally, full-invoice data. Returns the Stripe Checkout `url`
375
+ * to send the buyer to; the account is created after payment. The
376
+ * answer never depends on whether the email has an account.
377
+ *
378
+ * The `url` is re-checked like `shop.checkout()` (`https:` on
379
+ * `ctx.checkoutRedirectHosts`, else {@link UnsafeCheckoutRedirectError}).
380
+ * A refusal throws {@link AcademyCheckoutError} with its stable `code`;
381
+ * on `ENROLLMENT_REQUEST_REQUIRED` use `enrollmentRequest` instead.
382
+ *
383
+ * Server-side, pass `options.visitorIp` (see `visitorIpFromHeaders`):
384
+ * base-api keys this route's per-visitor limit on it.
385
+ */
386
+ checkout: async (body, options) => {
387
+ let session;
388
+ try {
389
+ session = await requestMutation(
390
+ ctx,
391
+ "/api/site/academy/checkout",
392
+ body,
393
+ options
394
+ );
395
+ } catch (error) {
396
+ throw error instanceof ApiError ? new AcademyCheckoutError(error) : error;
397
+ }
398
+ if (!isSafeCheckoutRedirectUrl(session.url, ctx.checkoutRedirectHosts)) {
399
+ throw new UnsafeCheckoutRedirectError(session.url, ctx.checkoutRedirectHosts);
400
+ }
401
+ return session;
402
+ },
308
403
  /**
309
404
  * Submit an anonymous enrollment request for a course.
310
405
  *
@@ -577,6 +672,18 @@ function makeShopNamespace(ctx) {
577
672
  };
578
673
  }
579
674
 
675
+ // src/domains/reviews.ts
676
+ function makeReviewsNamespace(ctx) {
677
+ function list(params = {}, cache) {
678
+ return requestList(
679
+ ctx,
680
+ buildUrl("/api/site/reviews", { pageId: params.pageId, limit: params.limit }),
681
+ { cachePolicy: cache }
682
+ );
683
+ }
684
+ return { list };
685
+ }
686
+
580
687
  // src/domains/trust-badges.ts
581
688
  function isCachePolicy(value) {
582
689
  return Object.prototype.hasOwnProperty.call(value, "revalidate");
@@ -658,10 +765,12 @@ function createRebluClient(config) {
658
765
  contactForm: makeContactFormNamespace(ctx),
659
766
  leads: makeLeadsNamespace(ctx),
660
767
  dataRequests: makeDataRequestsNamespace(ctx),
661
- withdrawalRequests: makeWithdrawalRequestsNamespace(ctx)
768
+ withdrawalRequests: makeWithdrawalRequestsNamespace(ctx),
769
+ reviews: makeReviewsNamespace(ctx)
662
770
  };
663
771
  }
664
772
 
773
+ exports.AcademyCheckoutError = AcademyCheckoutError;
665
774
  exports.AmbiguousPageSlugError = AmbiguousPageSlugError;
666
775
  exports.ApiError = ApiError;
667
776
  exports.DEFAULT_CHECKOUT_REDIRECT_HOSTS = DEFAULT_CHECKOUT_REDIRECT_HOSTS;