@reblu/site-client 5.18.0 → 5.19.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,15 @@
1
1
  # @reblu/site-client
2
2
 
3
+ ## 5.19.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 9a83295: Guest course checkout (REB-1264). Additive only: every existing export behaves as before.
8
+ - `@reblu/site-contracts` exports `AcademyCheckoutRequest`, `AcademyCheckoutBilling`, `AcademyCheckoutSession` and `AcademyCheckoutErrorCode` (`POST /api/site/academy/checkout`).
9
+ - `@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`.
10
+ - `@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.
11
+ - `@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`.
12
+
3
13
  ## 5.18.0
4
14
 
5
15
  ### Minor Changes
package/README.md CHANGED
@@ -205,7 +205,7 @@ resource; list methods return `{ data, meta? }` (pagination metadata preserved).
205
205
  | `dataRequests` | `submit(body, options?)` (write) |
206
206
  | `withdrawalRequests` | `submit(body, options?)` (write) — REB-1201 withdrawal form |
207
207
  | `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) |
208
+ | `academy` | `courses(params?)`, `bySlug(slug)`, `categories()`, `lessonPreview(courseSlug, lessonSlug)`, `checkout(body, options?)` (write, REB-1264), `enrollmentRequest(body, options?)` (write) |
209
209
 
210
210
  Every method accepts an optional trailing `RebluCachePolicy` (`{ revalidate }`) to
211
211
  override caching per call. `blog.preview` is always uncached (draft content), and so is
@@ -357,6 +357,47 @@ const ack = await client.withdrawalRequests.submit({
357
357
  Same rejections as `dataRequests.submit` (`400`, `422`, `429`). For the drop-in
358
358
  form see `WithdrawalRequestForm` in `@reblu/site-blocks/client`.
359
359
 
360
+ #### Guest course checkout (REB-1264)
361
+
362
+ `academy.checkout(body, options?)` buys a course that needs no validation in
363
+ one step (`POST /api/site/academy/checkout`) and returns the Stripe Checkout
364
+ `{ url, sessionId }`; the account is created after payment. The answer never
365
+ depends on whether the email has an account. The `url` is re-checked like
366
+ `shop.checkout()`'s (`UnsafeCheckoutRedirectError`).
367
+
368
+ ```ts
369
+ import { AcademyCheckoutError, visitorIpFromHeaders } from '@reblu/site-client'
370
+
371
+ try {
372
+ const { url } = await client.academy.checkout(
373
+ {
374
+ courseSlug: 'intro',
375
+ firstName: 'Ada',
376
+ lastName: 'Lovelace',
377
+ email: 'ada@example.com',
378
+ acceptsTerms: true,
379
+ termsVersionId: shopConfig.checkout.termsVersion?.id, // when the tenant published terms
380
+ acceptsWithdrawalWaiver: true, // when course.withdrawalConsent is not null…
381
+ withdrawalWaiverVersion: course.withdrawalConsent?.version, // …and its version
382
+ successUrl: 'https://site.example/academy/intro?pago=ok&session_id={CHECKOUT_SESSION_ID}',
383
+ cancelUrl: 'https://site.example/academy/intro?pago=cancelado',
384
+ },
385
+ { visitorIp: visitorIpFromHeaders(request.headers) },
386
+ )
387
+ // redirect the buyer to `url`
388
+ } catch (e) {
389
+ if (e instanceof AcademyCheckoutError && e.code === 'ENROLLMENT_REQUEST_REQUIRED') {
390
+ // validation required or free course: use academy.enrollmentRequest instead
391
+ }
392
+ }
393
+ ```
394
+
395
+ A refusal throws `AcademyCheckoutError` (an `ApiError`) with the stable `code`
396
+ (`AcademyCheckoutErrorCode`), `details` (`field`, `reason`, `fieldErrors`
397
+ with one message per field, or `missing` on `INVOICING_NOT_READY`) and the server's `errorMessage`. For the drop-in
398
+ form see `CourseGuestCheckoutForm` in `@reblu/site-blocks/client`, with
399
+ `createAcademyCheckoutRoute` from `@reblu/site-next/route-handlers`.
400
+
360
401
  #### Contact form metadata — the page the lead came from (REB-1077)
361
402
 
362
403
  Build the contact form's `metadata` with `buildContactFormMetadata()` in the
@@ -406,7 +447,7 @@ await client.contactForm.submit(payload, {
406
447
  })
407
448
  ```
408
449
 
409
- `shop.checkEmail` and `academy.enrollmentRequest` take the same `options`
450
+ `shop.checkEmail`, `academy.checkout` and `academy.enrollmentRequest` take the same `options`
410
451
  (REB-1006): base-api keys their per-IP limits on `x-reblu-visitor-ip` too.
411
452
 
412
453
  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
  *
@@ -662,6 +757,7 @@ function createRebluClient(config) {
662
757
  };
663
758
  }
664
759
 
760
+ exports.AcademyCheckoutError = AcademyCheckoutError;
665
761
  exports.AmbiguousPageSlugError = AmbiguousPageSlugError;
666
762
  exports.ApiError = ApiError;
667
763
  exports.DEFAULT_CHECKOUT_REDIRECT_HOSTS = DEFAULT_CHECKOUT_REDIRECT_HOSTS;