@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 +16 -0
- package/README.md +61 -2
- package/dist/index.cjs +110 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +44 -2
- package/dist/index.d.ts +44 -2
- package/dist/index.js +110 -2
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
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;
|