@reblu/site-client 5.17.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,21 @@
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
+
13
+ ## 5.18.0
14
+
15
+ ### Minor Changes
16
+
17
+ - 07667bc: Single-step checkout, pre-payment summary (REB-1197): `client.shop.quote(body, { visitorIp? })` calls `POST /api/site/shop/quote`; `@reblu/site-next/shop-route-handlers` adds `createShopQuoteRoute()` (forwards the `sb-*` auth cookies, like checkout, so the staff discount is quoted too); `@reblu/site-next/shop` adds `useShopQuote(input, { endpoint?, debounceMs? })`, which re-asks the quote whenever the cart, the code or the delivery choice changes and drops stale answers.
18
+
3
19
  ## 5.17.0
4
20
 
5
21
  ### Minor Changes
package/README.md CHANGED
@@ -204,8 +204,8 @@ resource; list methods return `{ data, meta? }` (pagination metadata preserved).
204
204
  | `pages` | `list(params?)`, `bySlug(slug)`, `featured(category, opts?)`, `slugs()` |
205
205
  | `dataRequests` | `submit(body, options?)` (write) |
206
206
  | `withdrawalRequests` | `submit(body, options?)` (write) — REB-1201 withdrawal form |
207
- | `shop` | `products(params?)`, `facets(params?)`, `bySlug(slug)`, `categories()`, `config()`, `checkout(body)`, `validateCart(body)`, `applyDiscount(body)`, `checkEmail(body)`, `order(sessionId)` |
208
- | `academy` | `courses(params?)`, `bySlug(slug)`, `categories()`, `lessonPreview(courseSlug, lessonSlug)`, `enrollmentRequest(body, options?)` (write) |
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)`, `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
@@ -298,9 +298,16 @@ failing.
298
298
 
299
299
  ### Write endpoints
300
300
 
301
- `shop.checkout`, `shop.validateCart` and `shop.applyDiscount` send typed
302
- `@reblu/site-contracts` request bodies without caching. Checkout error
303
- handling uses `ApiError.body` as shown above.
301
+ `shop.checkout`, `shop.quote`, `shop.validateCart` and `shop.applyDiscount`
302
+ send typed `@reblu/site-contracts` request bodies without caching. Checkout
303
+ error handling uses `ApiError.body` as shown above.
304
+
305
+ `shop.quote(body, opts?)` (REB-1197) returns the pre-payment summary of the
306
+ single-step checkout (`ShopQuote`): lines, discount, delivery mode, shipping,
307
+ tax, weight, delivery term, the small print with `{total}` filled, and the
308
+ final total, priced by the same function as `shop.checkout` — the total shown
309
+ is the total Stripe charges. Server-side, pass `{ visitorIp }`: a quote with a
310
+ `discountCode` counts against the per-visitor discount limit.
304
311
 
305
312
  `shop.checkEmail` is **deprecated** (REB-834) and removed in the next major.
306
313
  The endpoint used to say whether an email had an account — an enumeration
@@ -350,6 +357,47 @@ const ack = await client.withdrawalRequests.submit({
350
357
  Same rejections as `dataRequests.submit` (`400`, `422`, `429`). For the drop-in
351
358
  form see `WithdrawalRequestForm` in `@reblu/site-blocks/client`.
352
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
+
353
401
  #### Contact form metadata — the page the lead came from (REB-1077)
354
402
 
355
403
  Build the contact form's `metadata` with `buildContactFormMetadata()` in the
@@ -399,7 +447,7 @@ await client.contactForm.submit(payload, {
399
447
  })
400
448
  ```
401
449
 
402
- `shop.checkEmail` and `academy.enrollmentRequest` take the same `options`
450
+ `shop.checkEmail`, `academy.checkout` and `academy.enrollmentRequest` take the same `options`
403
451
  (REB-1006): base-api keys their per-IP limits on `x-reblu-visitor-ip` too.
404
452
 
405
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
  *
@@ -527,6 +622,17 @@ function makeShopNamespace(ctx) {
527
622
  "/api/site/shop/validate-cart",
528
623
  body
529
624
  ),
625
+ /**
626
+ * REB-1197 — the pre-payment summary of the single-step checkout: lines,
627
+ * discount, delivery, shipping, tax, weight, delivery term and the final
628
+ * total, priced by the same function as `checkout` (the total shown is the
629
+ * total Stripe charges). Holds nothing: no discount slot, no session.
630
+ *
631
+ * Server-side, pass `options.visitorIp` (see `visitorIpFromHeaders`): a
632
+ * quote with a `discountCode` counts against the same per-visitor limit
633
+ * as `applyDiscount`.
634
+ */
635
+ quote: (body, options) => requestMutation(ctx, "/api/site/shop/quote", body, options),
530
636
  /** Validate a discount code against the current cart subtotal. */
531
637
  applyDiscount: (body) => requestMutation(
532
638
  ctx,
@@ -651,6 +757,7 @@ function createRebluClient(config) {
651
757
  };
652
758
  }
653
759
 
760
+ exports.AcademyCheckoutError = AcademyCheckoutError;
654
761
  exports.AmbiguousPageSlugError = AmbiguousPageSlugError;
655
762
  exports.ApiError = ApiError;
656
763
  exports.DEFAULT_CHECKOUT_REDIRECT_HOSTS = DEFAULT_CHECKOUT_REDIRECT_HOSTS;