@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 +16 -0
- package/README.md +54 -6
- package/dist/index.cjs +107 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +34 -2
- package/dist/index.d.ts +34 -2
- package/dist/index.js +107 -1
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
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`
|
|
302
|
-
`@reblu/site-contracts` request bodies without caching. Checkout
|
|
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;
|