@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 +10 -0
- package/README.md +43 -2
- package/dist/index.cjs +96 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +33 -2
- package/dist/index.d.ts +33 -2
- package/dist/index.js +96 -1
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
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;
|