@loopwise/sdk 0.1.0-beta.3 → 0.1.0-beta.5
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 +79 -1
- package/README.md +151 -4
- package/dist/config.d.mts +11 -0
- package/dist/index.d.mts +210 -11
- package/dist/index.mjs +279 -11
- package/package.json +5 -4
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,77 @@ follow the same rules as a single 0.x line.
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## [0.1.0-beta.5] — 2026-10-15
|
|
13
|
+
|
|
14
|
+
Member actions on a Loopwise Page. A customer wanted signed-in members to
|
|
15
|
+
rate a course from their own page UI; that worked only through hand-written
|
|
16
|
+
GraphQL with several silent traps (a cached call reads everyone as signed
|
|
17
|
+
out, an omitted comment wipes the stored text). This release wraps it:
|
|
18
|
+
`auth`, `courses.reviewState` and `reviews` (TEACH-24177, #17705, #17711).
|
|
19
|
+
The server now also refuses ratings on courses with ratings off
|
|
20
|
+
(TEACH-24153) and scores outside 0.5–5 in half steps (TEACH-24176, #17704).
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- `loopwise.auth.getMe()` / `isAuthenticated()` — the member signed in to
|
|
25
|
+
the site, read from the page's own sign-in cookie. (TEACH-24177,
|
|
26
|
+
covers the core of TEACH-18410)
|
|
27
|
+
- `loopwise.courses.reviewState({ slug })` — one request returning the
|
|
28
|
+
member's review status for a course (`ratings_off`, `signed_out`,
|
|
29
|
+
`not_purchased`, `can_review`, `reviewed`), their review, the rating
|
|
30
|
+
summary and a `loginUrl` back to the page. (TEACH-24177)
|
|
31
|
+
- `loopwise.reviews.submit({ courseId, score, comment })` and
|
|
32
|
+
`loopwise.reviews.delete({ courseId, reviewId })`. `submit` checks the
|
|
33
|
+
score (0.5–5 in steps of 0.5) before sending. `comment` is required
|
|
34
|
+
and replaces the stored text (`""` clears it); leaving it out rejects
|
|
35
|
+
with `CONFIG` instead of clearing the text by accident.
|
|
36
|
+
(TEACH-24177)
|
|
37
|
+
- `LoopwiseError` codes `AUTH_REQUIRED`, `NOT_SIGNED_IN`,
|
|
38
|
+
`NOT_PURCHASED`, `RATINGS_OFF`, `INVALID_SCORE`. Member methods reject
|
|
39
|
+
with `AUTH_REQUIRED` server-side and off-origin (a `*.loopwise.page`
|
|
40
|
+
preview) instead of answering as a signed-out visitor, and never use
|
|
41
|
+
the cached `GET` path. `GraphQLError` gains the server's `code`; it and
|
|
42
|
+
`LoopwiseErrorCode` are now exported types. (TEACH-24177)
|
|
43
|
+
- New exported types: `Member`, `MemberReview`, `CourseRating`,
|
|
44
|
+
`CourseReviewState`, `CourseReviewStatus`. (TEACH-24177)
|
|
45
|
+
|
|
46
|
+
### Security
|
|
47
|
+
|
|
48
|
+
- Storefront `Meeting.roomUrl` is empty unless the viewer is an attendee,
|
|
49
|
+
school staff, or a school-bound admin credential. (TEACH-22983)
|
|
50
|
+
|
|
51
|
+
## [0.1.0-beta.4] — 2026-09-10
|
|
52
|
+
|
|
53
|
+
Storefront schema pinning for browser clients. A design partner could
|
|
54
|
+
not tell a honoured `API-Version` pin from an ignored header because the
|
|
55
|
+
same-origin `/api/graphql` proxy dropped it (TEACH-22755, #16417); the
|
|
56
|
+
proxy now forwards it, answers the CORS preflight for off-origin callers
|
|
57
|
+
(TEACH-22760, #16429), and this release gives the SDK a first-class way
|
|
58
|
+
to send the pin.
|
|
59
|
+
|
|
60
|
+
### Added
|
|
61
|
+
|
|
62
|
+
- `LoopwiseConfig.apiVersion` — pins the storefront schema to a release
|
|
63
|
+
date. Sent as the `API-Version` header on every request (GET and POST);
|
|
64
|
+
the server echoes the resolved version in the `API-Version` response
|
|
65
|
+
header. A blank value throws `LoopwiseError` code `'CONFIG'` at
|
|
66
|
+
`createClient()`, because the server would treat it as absent and serve
|
|
67
|
+
the baseline silently. (TEACH-22755)
|
|
68
|
+
|
|
69
|
+
### Changed
|
|
70
|
+
|
|
71
|
+
- **Breaking:** a non-2xx response whose body is a GraphQL errors payload
|
|
72
|
+
(objects with a `message`) now raises `LoopwiseError` with code
|
|
73
|
+
`'GRAPHQL'` and the errors on `graphqlErrors`, instead of `'NETWORK'` /
|
|
74
|
+
`Upstream <status>`. The first such response is the 400
|
|
75
|
+
`INVALID_API_VERSION` for a malformed `apiVersion`. Migration: code that
|
|
76
|
+
retries or branches on `code === 'NETWORK'` for a 4xx/5xx carrying a
|
|
77
|
+
GraphQL body should also handle `'GRAPHQL'` (check
|
|
78
|
+
`graphqlErrors[0].extensions.code` for the server's reason). Status
|
|
79
|
+
precedence is unchanged: 401/403 stay `'AUTH'` and 429 stays
|
|
80
|
+
`'RATE_LIMIT'` whatever their body, and other non-2xx responses without a
|
|
81
|
+
GraphQL body stay `'NETWORK'`.
|
|
82
|
+
|
|
12
83
|
## [0.1.0-beta.3] — 2026-05-20
|
|
13
84
|
|
|
14
85
|
### Added
|
|
@@ -62,8 +133,15 @@ follow the same rules as a single 0.x line.
|
|
|
62
133
|
(not line total), and null-guards the `tradeNo` URL-param lookup
|
|
63
134
|
example.
|
|
64
135
|
|
|
65
|
-
## [0.1.0-beta.1]
|
|
136
|
+
## [0.1.0-beta.1] — 2026-05-05
|
|
66
137
|
|
|
67
138
|
Initial public beta. `courses.list/get`, `posts.list/get`, `orders.get`,
|
|
68
139
|
`batch()`, raw `query()`. Schema-lock pipeline. Same-origin BFF and
|
|
69
140
|
explicit cross-origin endpoint resolution.
|
|
141
|
+
|
|
142
|
+
[Unreleased]: https://github.com/kaikhq/manekineko/compare/@loopwise/sdk@0.1.0-beta.5...HEAD
|
|
143
|
+
[0.1.0-beta.5]: https://github.com/kaikhq/manekineko/compare/@loopwise/sdk@0.1.0-beta.4...@loopwise/sdk@0.1.0-beta.5
|
|
144
|
+
[0.1.0-beta.4]: https://github.com/kaikhq/manekineko/compare/@loopwise/sdk@0.1.0-beta.3...@loopwise/sdk@0.1.0-beta.4
|
|
145
|
+
[0.1.0-beta.3]: https://github.com/kaikhq/manekineko/compare/@loopwise/sdk@0.1.0-beta.2...@loopwise/sdk@0.1.0-beta.3
|
|
146
|
+
[0.1.0-beta.2]: https://github.com/kaikhq/manekineko/compare/@loopwise/sdk@0.1.0-beta.1...@loopwise/sdk@0.1.0-beta.2
|
|
147
|
+
[0.1.0-beta.1]: https://github.com/kaikhq/manekineko/compare/@loopwise/sdk@0.1.0-beta.0...@loopwise/sdk@0.1.0-beta.1
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Storefront GraphQL client for Loopwise / Teachify. Pick your path:
|
|
|
6
6
|
|---|---|---|
|
|
7
7
|
| A single self-contained HTML file (no build step — for `deploy_static_page`, a Claude Design artifact, or a CodePen) | none — load from a CDN | `https://esm.sh/@loopwise/sdk@beta` |
|
|
8
8
|
| A project with a bundler (Vite / Astro / Next.js / Remix) | `npm install @loopwise/sdk@beta` | `@loopwise/sdk` |
|
|
9
|
-
| **React** UI in either of the above | (see [`@loopwise/
|
|
9
|
+
| **React** UI in either of the above | (see [`@loopwise/react`](#react--coming-soon)) | — |
|
|
10
10
|
|
|
11
11
|
Same package powers all three; the difference is the loader.
|
|
12
12
|
|
|
@@ -59,10 +59,21 @@ right API origin.
|
|
|
59
59
|
### Limits when not on the site itself
|
|
60
60
|
|
|
61
61
|
Cross-origin requests **do not carry first-party cookies**. So calls
|
|
62
|
-
that require a signed-in visitor — `
|
|
63
|
-
authenticated
|
|
62
|
+
that require a signed-in visitor — `auth.getMe`, `courses.reviewState`,
|
|
63
|
+
`reviews.*`, hand-written authenticated queries — **will not work** from
|
|
64
|
+
off-site code; the SDK's member methods reject with `AUTH_REQUIRED`.
|
|
64
65
|
Public, anonymous reads (`courses.list`, `posts.list`, etc.) work fine.
|
|
65
66
|
|
|
67
|
+
The site's `/api/graphql` answers the browser's CORS preflight with a
|
|
68
|
+
static `Access-Control-Allow-Origin: *` and no credentials. The
|
|
69
|
+
`Access-Control-Allow-Headers` grant names the non-safelisted headers
|
|
70
|
+
this SDK sends (`content-type` with a JSON value, `api-version`,
|
|
71
|
+
`x-loopwise-cache`); CORS-safelisted headers such as `Accept` need no
|
|
72
|
+
grant. A hand-rolled `fetch` that adds any other non-safelisted header
|
|
73
|
+
(`Authorization`, for one) fails the preflight, and
|
|
74
|
+
`credentials: 'include'` is refused by the browser. The response exposes
|
|
75
|
+
`API-Version` and `X-Request-Id`.
|
|
76
|
+
|
|
66
77
|
This is the dominant case for `deploy_static_page` bundles (which
|
|
67
78
|
serve at `<scriptName>.loopwise.page`):
|
|
68
79
|
|
|
@@ -75,6 +86,108 @@ const { items } = await loopwise.courses.list({ limit: 20 }, { cache: 60 });
|
|
|
75
86
|
For preview UX that needs an authenticated viewer, see
|
|
76
87
|
[Preview modes](#preview-modes) below.
|
|
77
88
|
|
|
89
|
+
## Member actions on a Loopwise Page
|
|
90
|
+
|
|
91
|
+
A Loopwise Page served from the site's own domain (`acme.kaik.io/lw/...`,
|
|
92
|
+
`nii.school/reviews`) can act for the member who is signed in to that
|
|
93
|
+
site: read who they are, show their review state for a course, and save
|
|
94
|
+
or delete their review. You write no GraphQL.
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
import { createClient, LoopwiseError } from '@loopwise/sdk';
|
|
98
|
+
|
|
99
|
+
const loopwise = createClient({});
|
|
100
|
+
|
|
101
|
+
const me = await loopwise.auth.getMe(); // { id, name } or null
|
|
102
|
+
|
|
103
|
+
let state = await loopwise.courses.reviewState({ slug: 'finance' });
|
|
104
|
+
// state.status: 'ratings_off' | 'signed_out' | 'not_purchased' | 'can_review' | 'reviewed'
|
|
105
|
+
// state.courseId, state.review ({ id, score, comment } | null),
|
|
106
|
+
// state.rating ({ average, total } | null), state.loginUrl
|
|
107
|
+
|
|
108
|
+
// Re-read after every write so the page (and the next click) never acts
|
|
109
|
+
// on a stale review.
|
|
110
|
+
const refresh = async () => {
|
|
111
|
+
state = await loopwise.courses.reviewState({ slug: 'finance' });
|
|
112
|
+
render(state);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const onError = (e) => {
|
|
116
|
+
if (e instanceof LoopwiseError && e.code === 'NOT_SIGNED_IN') {
|
|
117
|
+
location.href = state.loginUrl;
|
|
118
|
+
} else {
|
|
119
|
+
showError(e); // INVALID_SCORE, RATINGS_OFF, NOT_PURCHASED, …
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
form.addEventListener('submit', async (event) => {
|
|
124
|
+
event.preventDefault();
|
|
125
|
+
submitButton.disabled = true; // one email per click, not per double-click
|
|
126
|
+
try {
|
|
127
|
+
await loopwise.reviews.submit({
|
|
128
|
+
courseId: state.courseId,
|
|
129
|
+
score: Number(form.score.value), // 0.5, 1, 1.5 … 5
|
|
130
|
+
comment: form.comment.value, // required; '' clears it
|
|
131
|
+
});
|
|
132
|
+
await refresh();
|
|
133
|
+
} catch (e) {
|
|
134
|
+
onError(e);
|
|
135
|
+
} finally {
|
|
136
|
+
submitButton.disabled = false;
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
deleteButton.addEventListener('click', async () => {
|
|
141
|
+
if (!state.review) return;
|
|
142
|
+
deleteButton.disabled = true;
|
|
143
|
+
try {
|
|
144
|
+
await loopwise.reviews.delete({ courseId: state.courseId, reviewId: state.review.id });
|
|
145
|
+
await refresh();
|
|
146
|
+
} catch (e) {
|
|
147
|
+
onError(e);
|
|
148
|
+
} finally {
|
|
149
|
+
deleteButton.disabled = false;
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
The rules these methods enforce, and why:
|
|
155
|
+
|
|
156
|
+
- **Same origin only.** The member is known from the site's sign-in
|
|
157
|
+
cookie, which the browser sends only to the page's own origin. Server
|
|
158
|
+
code, SSR, and a `*.loopwise.page` preview have no member. There every
|
|
159
|
+
member method rejects with `LoopwiseError` code `'AUTH_REQUIRED'` and
|
|
160
|
+
sends nothing, instead of reporting everyone as signed out.
|
|
161
|
+
- **Fetch member state in the browser, after load.** Loopwise Page HTML is
|
|
162
|
+
cached at the CDN and shared by every visitor, so never render member
|
|
163
|
+
state into the HTML. Render a neutral shell and fill it in from
|
|
164
|
+
`reviewState()` on the client.
|
|
165
|
+
- **No `cache` on member calls.** A cached call is a cookie-less `GET`,
|
|
166
|
+
which would read every member as signed out. Member methods take no
|
|
167
|
+
options and always `POST`.
|
|
168
|
+
- **Submit on an explicit action.** Each `reviews.submit` emails the site
|
|
169
|
+
owner. Call it from a Submit button, never from autosave or on every
|
|
170
|
+
star change.
|
|
171
|
+
- **One review per member per course.** `submit` creates it the first time
|
|
172
|
+
and replaces it after that; `comment` replaces the stored text too, so
|
|
173
|
+
pass the existing text to keep it.
|
|
174
|
+
|
|
175
|
+
`reviewState` checks in this order: `ratings_off` (reviews are off for the
|
|
176
|
+
course; hide the UI), `signed_out` (link to `loginUrl`, which returns to
|
|
177
|
+
this page after sign-in), `reviewed` (the member's review is in
|
|
178
|
+
`review`), `not_purchased` (signed in but not enrolled), `can_review`.
|
|
179
|
+
|
|
180
|
+
| `LoopwiseError.code` | When |
|
|
181
|
+
|---|---|
|
|
182
|
+
| `AUTH_REQUIRED` | Called server-side or off-origin. Nothing was sent. |
|
|
183
|
+
| `INVALID_SCORE` | `score` is not 0.5–5 in steps of 0.5. Checked before sending. |
|
|
184
|
+
| `NOT_SIGNED_IN` | No member is signed in. Send them to `loginUrl`. |
|
|
185
|
+
| `NOT_PURCHASED` | The member is not enrolled in the course. |
|
|
186
|
+
| `RATINGS_OFF` | The site turned reviews off for the course. |
|
|
187
|
+
| `CONFIG` | A required argument (`slug`, `courseId`, `reviewId`, `comment`) is missing. Nothing was sent. |
|
|
188
|
+
| `AUTH`, `RATE_LIMIT`, `NETWORK` | Transport failures (HTTP 401/403, 429, other), the same as for every call. They are never reported as a signed-out member. |
|
|
189
|
+
| `GRAPHQL` | Anything else, such as an unknown slug or another member's review; the server's errors are on `graphqlErrors`. |
|
|
190
|
+
|
|
78
191
|
## `cache` option
|
|
79
192
|
|
|
80
193
|
Read-only resource calls accept an opt-in `cache` option that flips the
|
|
@@ -123,6 +236,37 @@ is the explicit, type-system-visible declaration of "this must never be cached".
|
|
|
123
236
|
per Vercel POP, depending on traffic. Use shorter TTLs for time-sensitive
|
|
124
237
|
content; use `cache: 'no-store'` for admin previews.
|
|
125
238
|
|
|
239
|
+
## `apiVersion` option
|
|
240
|
+
|
|
241
|
+
The storefront schema is versioned by release date. Pin the one your code
|
|
242
|
+
was built against and the SDK sends it as the `API-Version` header on
|
|
243
|
+
every request, GET and POST alike:
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
const loopwise = createClient({ apiVersion: '2027-01-01' });
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Omit it to get the baseline schema. The server echoes the version it
|
|
250
|
+
resolved in the `API-Version` response header (and lists the header in
|
|
251
|
+
`Vary`), and this holds through the same-origin `/api/graphql` proxy too.
|
|
252
|
+
The SDK's methods return parsed data only and do not surface response
|
|
253
|
+
headers, so confirm the pin once with the browser's network inspector or
|
|
254
|
+
a direct `fetch` to the same endpoint, not through an SDK return value:
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
const res = await fetch('/api/graphql', {
|
|
258
|
+
method: 'POST',
|
|
259
|
+
headers: { 'content-type': 'application/json', 'API-Version': '2027-01-01' },
|
|
260
|
+
body: JSON.stringify({ query: '{ __typename }' }),
|
|
261
|
+
});
|
|
262
|
+
res.headers.get('API-Version'); // '2027-01-01' when the pin is honoured
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
A value that is not a date is rejected with a 400 whose body is a GraphQL
|
|
266
|
+
errors payload; the SDK raises it as a `LoopwiseError` with code
|
|
267
|
+
`'GRAPHQL'` and `graphqlErrors[0].extensions.code === 'INVALID_API_VERSION'`,
|
|
268
|
+
so a malformed pin does fail loudly through the SDK.
|
|
269
|
+
|
|
126
270
|
## Preview modes
|
|
127
271
|
|
|
128
272
|
Two preview surfaces exist, each with different capability:
|
|
@@ -145,7 +289,7 @@ resolve under `/_preview/<scriptName>/`; root-absolute paths like
|
|
|
145
289
|
|
|
146
290
|
## React — coming soon
|
|
147
291
|
|
|
148
|
-
A `@loopwise/
|
|
292
|
+
A `@loopwise/react` companion package is planned, providing:
|
|
149
293
|
|
|
150
294
|
- `<LoopwiseProvider client={loopwise}>` context provider
|
|
151
295
|
- `useCourses({ limit })`, `useCourse({ slug })` hooks with Suspense support
|
|
@@ -163,6 +307,9 @@ your own state (`useEffect` + `useState`, TanStack Query, etc.).
|
|
|
163
307
|
- `loopwise.courses.list(args, options?)` / `.get(args, options?)`.
|
|
164
308
|
- `loopwise.posts.list(args, options?)` / `.get(args, options?)`.
|
|
165
309
|
- `loopwise.orders.get(args, options?)` — post-checkout pixel-tracking pattern; see the method's JSDoc for the `?tradeNo=…` → `fbq('track', 'Purchase', …)` recipe.
|
|
310
|
+
- `loopwise.auth.getMe()` / `.isAuthenticated()` — the signed-in member; see [Member actions on a Loopwise Page](#member-actions-on-a-loopwise-page).
|
|
311
|
+
- `loopwise.courses.reviewState({ slug })` — the member's review status for a course.
|
|
312
|
+
- `loopwise.reviews.submit({ courseId, score, comment })` / `.delete({ courseId, reviewId })`.
|
|
166
313
|
- `loopwise.batch(builders, options?)` — composes N queries into one HTTP request.
|
|
167
314
|
- `loopwise.query<T>(document, variables?, operationName?, options?)` — raw GraphQL escape hatch.
|
|
168
315
|
|
package/dist/config.d.mts
CHANGED
|
@@ -32,6 +32,17 @@ interface LoopwiseConfig {
|
|
|
32
32
|
* is omitted).
|
|
33
33
|
*/
|
|
34
34
|
endpoint?: string;
|
|
35
|
+
/**
|
|
36
|
+
* Pin the storefront schema to a release date (`'2027-01-01'`). Sent as
|
|
37
|
+
* the `API-Version` header on every request; omit it to get the baseline
|
|
38
|
+
* schema. The server echoes the resolved version in the `API-Version`
|
|
39
|
+
* response header; SDK methods return parsed data only, so read that
|
|
40
|
+
* header with a network inspector or a direct `fetch`. A value that is
|
|
41
|
+
* not a date is rejected with a 400 whose GraphQL error carries
|
|
42
|
+
* `extensions.code: 'INVALID_API_VERSION'` (surfaced as a
|
|
43
|
+
* `LoopwiseError` with code `'GRAPHQL'`).
|
|
44
|
+
*/
|
|
45
|
+
apiVersion?: string;
|
|
35
46
|
}
|
|
36
47
|
declare function defineConfig(config: LoopwiseConfig): LoopwiseConfig;
|
|
37
48
|
//#endregion
|
package/dist/index.d.mts
CHANGED
|
@@ -1,9 +1,27 @@
|
|
|
1
1
|
import { LoopwiseConfig } from "./config.mjs";
|
|
2
|
-
|
|
3
2
|
//#region src/error.d.ts
|
|
4
|
-
|
|
3
|
+
/**
|
|
4
|
+
* What went wrong, as a stable string to branch on.
|
|
5
|
+
*
|
|
6
|
+
* - `NETWORK`, `AUTH`, `RATE_LIMIT`: transport failures.
|
|
7
|
+
* - `GRAPHQL`: the server answered with an error not mapped below; the raw
|
|
8
|
+
* errors are on `LoopwiseError.graphqlErrors`.
|
|
9
|
+
* - `CONFIG`: the client or a call was set up wrong; nothing was sent.
|
|
10
|
+
* - `AUTH_REQUIRED`: a member action (`auth.*`, `courses.reviewState`,
|
|
11
|
+
* `reviews.*`) ran where no member can be signed in — server-side, or in a
|
|
12
|
+
* browser whose page is on another origin than the API (a
|
|
13
|
+
* `*.loopwise.page` preview). Nothing was sent.
|
|
14
|
+
* - `NOT_SIGNED_IN`: a review needs a signed-in member and there is none.
|
|
15
|
+
* - `NOT_PURCHASED`: the member is signed in but not enrolled in the course.
|
|
16
|
+
* - `RATINGS_OFF`: the site turned reviews off for the course.
|
|
17
|
+
* - `INVALID_SCORE`: a review score outside 0.5 to 5 in half-star steps.
|
|
18
|
+
* Checked before sending, and mapped from the server's `Rate-001`.
|
|
19
|
+
*/
|
|
20
|
+
type LoopwiseErrorCode = "NETWORK" | "AUTH" | "GRAPHQL" | "RATE_LIMIT" | "CONFIG" | "AUTH_REQUIRED" | "NOT_SIGNED_IN" | "NOT_PURCHASED" | "RATINGS_OFF" | "INVALID_SCORE";
|
|
5
21
|
interface GraphQLError {
|
|
6
22
|
message: string;
|
|
23
|
+
/** Storefront service error code, such as `User-000` or `Rate-002`. */
|
|
24
|
+
code?: string;
|
|
7
25
|
path?: (string | number)[];
|
|
8
26
|
extensions?: Record<string, unknown>;
|
|
9
27
|
}
|
|
@@ -17,7 +35,8 @@ declare class LoopwiseError extends Error {
|
|
|
17
35
|
readonly graphqlErrors?: GraphQLError[];
|
|
18
36
|
readonly requestId?: string;
|
|
19
37
|
constructor(code: LoopwiseErrorCode, message: string, detail?: {
|
|
20
|
-
site?: string;
|
|
38
|
+
site?: string;
|
|
39
|
+
/** @deprecated Renamed to `site`. Removed in 0.2.0. */
|
|
21
40
|
tenant?: string;
|
|
22
41
|
endpoint?: string;
|
|
23
42
|
graphqlErrors?: GraphQLError[];
|
|
@@ -26,9 +45,9 @@ declare class LoopwiseError extends Error {
|
|
|
26
45
|
}
|
|
27
46
|
//#endregion
|
|
28
47
|
//#region src/select.d.ts
|
|
29
|
-
type SelectionInner<T> = T extends Array<infer U> ? SelectionInner<U> : T extends object ? { [K in keyof T]?: SelectionInner<T[K]
|
|
48
|
+
type SelectionInner<T> = T extends Array<infer U> ? SelectionInner<U> : T extends object ? { [K in keyof T]?: SelectionInner<T[K]>; } : true;
|
|
30
49
|
type Selection<T> = SelectionInner<NonNullable<T>>;
|
|
31
|
-
type SelectedInner<T, S> = T extends null | undefined ? T : S extends true ? T : T extends Array<infer U> ? Array<SelectedInner<U, S>> : T extends object ? { [K in keyof S & keyof T]: SelectedInner<T[K], S[K]
|
|
50
|
+
type SelectedInner<T, S> = T extends null | undefined ? T : S extends true ? T : T extends Array<infer U> ? Array<SelectedInner<U, S>> : T extends object ? { [K in keyof S & keyof T]: SelectedInner<T[K], S[K]>; } : T;
|
|
32
51
|
type Selected<T, S> = SelectedInner<T, S>;
|
|
33
52
|
//#endregion
|
|
34
53
|
//#region src/query.d.ts
|
|
@@ -64,7 +83,7 @@ interface FetchOptions {
|
|
|
64
83
|
* `Cache-Control: public, s-maxage=<seconds>`. `'no-store'` forces POST and
|
|
65
84
|
* skips any cache hint. Omit for the legacy POST behaviour.
|
|
66
85
|
*/
|
|
67
|
-
cache?: number |
|
|
86
|
+
cache?: number | "no-store";
|
|
68
87
|
}
|
|
69
88
|
interface BatchedFetcher {
|
|
70
89
|
/**
|
|
@@ -82,6 +101,15 @@ declare class QueryBuilder<T> implements PromiseLike<T> {
|
|
|
82
101
|
}
|
|
83
102
|
//#endregion
|
|
84
103
|
//#region src/client.d.ts
|
|
104
|
+
interface GraphQLPayload {
|
|
105
|
+
query: string;
|
|
106
|
+
variables?: Record<string, unknown>;
|
|
107
|
+
operationName?: string;
|
|
108
|
+
}
|
|
109
|
+
interface GraphQLResponse {
|
|
110
|
+
data?: Record<string, unknown> | null;
|
|
111
|
+
errors?: GraphQLError[];
|
|
112
|
+
}
|
|
85
113
|
declare class LoopwiseClient implements BatchedFetcher {
|
|
86
114
|
readonly config: LoopwiseConfig;
|
|
87
115
|
private endpoint;
|
|
@@ -96,8 +124,17 @@ declare class LoopwiseClient implements BatchedFetcher {
|
|
|
96
124
|
raw<T = unknown>(document: string, variables?: Record<string, unknown>, operationName?: string, options?: FetchOptions): Promise<T>;
|
|
97
125
|
batch<R extends Record<string, unknown>>(builders: { [K in keyof R]: {
|
|
98
126
|
descriptor: QueryDescriptor;
|
|
99
|
-
} }, options?: FetchOptions): Promise<R>;
|
|
127
|
+
}; }, options?: FetchOptions): Promise<R>;
|
|
128
|
+
/**
|
|
129
|
+
* POST a document on behalf of the signed-in member and return the whole
|
|
130
|
+
* GraphQL response, errors included, so the caller can map service error
|
|
131
|
+
* codes. Refuses wherever the member's `token` cookie cannot ride along.
|
|
132
|
+
*/
|
|
133
|
+
member(payload: GraphQLPayload): Promise<GraphQLResponse>;
|
|
134
|
+
graphqlError(errors: GraphQLError[], code?: LoopwiseErrorCode, message?: string): LoopwiseError;
|
|
135
|
+
private assertMemberOrigin;
|
|
100
136
|
private fetchGraphQL;
|
|
137
|
+
private request;
|
|
101
138
|
private shouldUseGet;
|
|
102
139
|
private buildGetUrl;
|
|
103
140
|
private resolveApiKey;
|
|
@@ -144,7 +181,7 @@ interface Post {
|
|
|
144
181
|
/** Known values: `login_required`, `paid`, `public_access`. The
|
|
145
182
|
* `string & {}` escape hatch keeps autocomplete on the known set
|
|
146
183
|
* while accepting forward-compat values the schema may add. */
|
|
147
|
-
accessType:
|
|
184
|
+
accessType: "login_required" | "paid" | "public_access" | (string & {});
|
|
148
185
|
tags: string[] | null;
|
|
149
186
|
noindex: boolean;
|
|
150
187
|
author: Lecturer | null;
|
|
@@ -160,8 +197,58 @@ interface ListResult<T> {
|
|
|
160
197
|
items: T[];
|
|
161
198
|
pageInfo: PageInfo;
|
|
162
199
|
}
|
|
200
|
+
/** The member signed in to this site. */
|
|
201
|
+
interface Member {
|
|
202
|
+
id: string;
|
|
203
|
+
/** Display name; `null` when the member never set one. */
|
|
204
|
+
name: string | null;
|
|
205
|
+
}
|
|
206
|
+
/** A member's own review of a course. One per member per course. */
|
|
207
|
+
interface MemberReview {
|
|
208
|
+
id: string;
|
|
209
|
+
/** 0.5 to 5 in half-star steps. */
|
|
210
|
+
score: number;
|
|
211
|
+
/** Review text; `""` when the member left none. */
|
|
212
|
+
comment: string;
|
|
213
|
+
}
|
|
214
|
+
/** A course's public rating summary. */
|
|
215
|
+
interface CourseRating {
|
|
216
|
+
/** Mean score across visible reviews. */
|
|
217
|
+
average: number;
|
|
218
|
+
/** Number of visible reviews. */
|
|
219
|
+
total: number;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* What the visitor can do about reviewing a course, checked in this order:
|
|
223
|
+
*
|
|
224
|
+
* - `ratings_off`: the site turned reviews off for this course. Hide the UI.
|
|
225
|
+
* - `signed_out`: no member is signed in. Link to `loginUrl`.
|
|
226
|
+
* - `reviewed`: the member already reviewed it; `review` holds their score
|
|
227
|
+
* and comment. Submitting again replaces it.
|
|
228
|
+
* - `not_purchased`: signed in, but not enrolled. Only enrolled members can
|
|
229
|
+
* review.
|
|
230
|
+
* - `can_review`: enrolled and not reviewed yet.
|
|
231
|
+
*/
|
|
232
|
+
type CourseReviewStatus = "ratings_off" | "signed_out" | "not_purchased" | "can_review" | "reviewed";
|
|
233
|
+
interface CourseReviewState {
|
|
234
|
+
status: CourseReviewStatus;
|
|
235
|
+
/**
|
|
236
|
+
* Pass to `reviews.submit({ courseId })`, and with `review.id` (when
|
|
237
|
+
* `review` is not `null`) to `reviews.delete({ courseId, reviewId })`.
|
|
238
|
+
*/
|
|
239
|
+
courseId: string;
|
|
240
|
+
/** The signed-in member's own review, or `null` when they have none. */
|
|
241
|
+
review: MemberReview | null;
|
|
242
|
+
/** The course's rating summary; `null` when ratings are off. */
|
|
243
|
+
rating: CourseRating | null;
|
|
244
|
+
/**
|
|
245
|
+
* The site's default sign-in page, returning to the current page after
|
|
246
|
+
* sign-in: `/login?redirect=<path>`.
|
|
247
|
+
*/
|
|
248
|
+
loginUrl: string;
|
|
249
|
+
}
|
|
163
250
|
/** Simplified order status. Mirrors the storefront `Order.status` enum. */
|
|
164
|
-
type OrderStatus =
|
|
251
|
+
type OrderStatus = "PAID" | "PENDING" | "FAILED" | "REFUNDED";
|
|
165
252
|
interface OrderItem {
|
|
166
253
|
/**
|
|
167
254
|
* Meta-pixel-aligned identifier (Lineitem#tracking_id server-side).
|
|
@@ -185,6 +272,27 @@ interface Order {
|
|
|
185
272
|
createdAt: number;
|
|
186
273
|
}
|
|
187
274
|
//#endregion
|
|
275
|
+
//#region src/resources/auth.d.ts
|
|
276
|
+
/**
|
|
277
|
+
* The member signed in to this site, read from the page's own sign-in
|
|
278
|
+
* cookie. Works only in the browser on the site's own domain; elsewhere
|
|
279
|
+
* every method rejects with `LoopwiseError` code `'AUTH_REQUIRED'` instead
|
|
280
|
+
* of reporting a signed-out visitor.
|
|
281
|
+
*/
|
|
282
|
+
declare class AuthResource {
|
|
283
|
+
private client;
|
|
284
|
+
constructor(client: LoopwiseClient);
|
|
285
|
+
/**
|
|
286
|
+
* The signed-in member, or `null` for a signed-out visitor.
|
|
287
|
+
* @example
|
|
288
|
+
* const me = await loopwise.auth.getMe()
|
|
289
|
+
* greeting.textContent = me ? `Hi ${me.name ?? ''}` : 'Sign in'
|
|
290
|
+
*/
|
|
291
|
+
getMe(): Promise<Member | null>;
|
|
292
|
+
/** Whether a member is signed in to this site. */
|
|
293
|
+
isAuthenticated(): Promise<boolean>;
|
|
294
|
+
}
|
|
295
|
+
//#endregion
|
|
188
296
|
//#region src/resources/courses.d.ts
|
|
189
297
|
declare const DEFAULT_LIST_FIELDS$1: Selection<Course>;
|
|
190
298
|
declare const DEFAULT_GET_FIELDS$2: Selection<Course>;
|
|
@@ -230,6 +338,30 @@ declare class CoursesResource {
|
|
|
230
338
|
* }
|
|
231
339
|
*/
|
|
232
340
|
get<S extends Selection<Course> = typeof DEFAULT_GET_FIELDS$2>(args: CourseGetArgs<S>, options?: FetchOptions): QueryBuilder<Selected<Course, S>>;
|
|
341
|
+
/**
|
|
342
|
+
* What the signed-in member can do about reviewing a course, in one
|
|
343
|
+
* request: whether reviews are on, whether they are signed in and
|
|
344
|
+
* enrolled, their own review, and the course's rating summary.
|
|
345
|
+
*
|
|
346
|
+
* Member state: call it in the browser on the site's own domain, after
|
|
347
|
+
* the page loads. It never uses the `cache` option, and it rejects with
|
|
348
|
+
* `LoopwiseError` code `AUTH_REQUIRED` server-side or off-origin rather
|
|
349
|
+
* than report every visitor as signed out. An unknown slug rejects with
|
|
350
|
+
* `GRAPHQL`.
|
|
351
|
+
*
|
|
352
|
+
* @example
|
|
353
|
+
* const state = await loopwise.courses.reviewState({ slug: 'finance' })
|
|
354
|
+
* switch (state.status) {
|
|
355
|
+
* case 'ratings_off': return hide()
|
|
356
|
+
* case 'signed_out': return showLink(state.loginUrl)
|
|
357
|
+
* case 'not_purchased': return showText('Enroll to review this course')
|
|
358
|
+
* case 'can_review': return showForm()
|
|
359
|
+
* case 'reviewed': return showForm(state.review)
|
|
360
|
+
* }
|
|
361
|
+
*/
|
|
362
|
+
reviewState(args: {
|
|
363
|
+
slug: string;
|
|
364
|
+
}): Promise<CourseReviewState>;
|
|
233
365
|
}
|
|
234
366
|
//#endregion
|
|
235
367
|
//#region src/resources/orders.d.ts
|
|
@@ -318,11 +450,78 @@ declare class PostsResource {
|
|
|
318
450
|
get<S extends Selection<Post> = typeof DEFAULT_GET_FIELDS>(args: PostGetArgs<S>, options?: FetchOptions): QueryBuilder<Selected<Post, S>>;
|
|
319
451
|
}
|
|
320
452
|
//#endregion
|
|
453
|
+
//#region src/resources/reviews.d.ts
|
|
454
|
+
interface ReviewSubmitArgs {
|
|
455
|
+
/** `courseId` from `courses.reviewState()`. */
|
|
456
|
+
courseId: string;
|
|
457
|
+
/** 0.5, 1, 1.5, … 5. Anything else rejects with `INVALID_SCORE` before sending. */
|
|
458
|
+
score: number;
|
|
459
|
+
/**
|
|
460
|
+
* The review text, always sent: an update replaces the stored comment with
|
|
461
|
+
* this value, so pass the existing text to keep it and `""` to clear it.
|
|
462
|
+
* Leaving it out rejects with `CONFIG` rather than clear it by accident.
|
|
463
|
+
*/
|
|
464
|
+
comment: string;
|
|
465
|
+
}
|
|
466
|
+
interface ReviewDeleteArgs {
|
|
467
|
+
/** `courseId` from `courses.reviewState()`. */
|
|
468
|
+
courseId: string;
|
|
469
|
+
/**
|
|
470
|
+
* The review to delete: the member's own is `review.id` from
|
|
471
|
+
* `courses.reviewState()`; an owner or manager may pass any review's id.
|
|
472
|
+
*/
|
|
473
|
+
reviewId: string;
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Course reviews written by the signed-in member, plus deletion for the
|
|
477
|
+
* site's owner and managers (moderation). Browser on the site's own
|
|
478
|
+
* domain only; elsewhere every method rejects with `AUTH_REQUIRED`.
|
|
479
|
+
*
|
|
480
|
+
* Each submit emails the site owner. Call `submit` from an explicit member
|
|
481
|
+
* action (a Submit button), never from autosave or on every star change.
|
|
482
|
+
*/
|
|
483
|
+
declare class ReviewsResource {
|
|
484
|
+
private client;
|
|
485
|
+
private auth;
|
|
486
|
+
constructor(client: LoopwiseClient, auth: AuthResource);
|
|
487
|
+
/**
|
|
488
|
+
* Create the member's review of a course, or replace it if they already
|
|
489
|
+
* have one. Resolves to the stored review.
|
|
490
|
+
*
|
|
491
|
+
* Rejects with `LoopwiseError` code `INVALID_SCORE`, `NOT_SIGNED_IN`,
|
|
492
|
+
* `NOT_PURCHASED`, `RATINGS_OFF` or `AUTH_REQUIRED`.
|
|
493
|
+
*
|
|
494
|
+
* @example
|
|
495
|
+
* form.addEventListener('submit', async (event) => {
|
|
496
|
+
* event.preventDefault()
|
|
497
|
+
* const review = await loopwise.reviews.submit({
|
|
498
|
+
* courseId: state.courseId,
|
|
499
|
+
* score: Number(form.score.value),
|
|
500
|
+
* comment: form.comment.value,
|
|
501
|
+
* })
|
|
502
|
+
* })
|
|
503
|
+
*/
|
|
504
|
+
submit(args: ReviewSubmitArgs): Promise<MemberReview>;
|
|
505
|
+
/**
|
|
506
|
+
* Delete a review of a course.
|
|
507
|
+
*
|
|
508
|
+
* The server allows the review's author, and the site's owner or
|
|
509
|
+
* managers (for moderation); anyone else is refused. Rejects with
|
|
510
|
+
* `NOT_SIGNED_IN` or `AUTH_REQUIRED`; a review the member may not delete,
|
|
511
|
+
* or one that no longer exists, rejects with `GRAPHQL`.
|
|
512
|
+
*/
|
|
513
|
+
delete(args: ReviewDeleteArgs): Promise<void>;
|
|
514
|
+
private mapError;
|
|
515
|
+
private notSignedIn;
|
|
516
|
+
}
|
|
517
|
+
//#endregion
|
|
321
518
|
//#region src/index.d.ts
|
|
322
519
|
interface Loopwise {
|
|
520
|
+
readonly auth: AuthResource;
|
|
323
521
|
readonly courses: CoursesResource;
|
|
324
522
|
readonly orders: OrdersResource;
|
|
325
523
|
readonly posts: PostsResource;
|
|
524
|
+
readonly reviews: ReviewsResource;
|
|
326
525
|
/**
|
|
327
526
|
* Compose multiple resource queries into a single GraphQL HTTP request.
|
|
328
527
|
* @example
|
|
@@ -331,7 +530,7 @@ interface Loopwise {
|
|
|
331
530
|
* recent: loopwise.courses.list({ limit: 3, page: 2 }),
|
|
332
531
|
* }, { cache: 60 })
|
|
333
532
|
*/
|
|
334
|
-
batch<R extends Record<string, QueryBuilder<unknown>>>(builders: R, options?: FetchOptions): Promise<{ [K in keyof R]: R[K] extends QueryBuilder<infer T> ? T : never }>;
|
|
533
|
+
batch<R extends Record<string, QueryBuilder<unknown>>>(builders: R, options?: FetchOptions): Promise<{ [K in keyof R]: R[K] extends QueryBuilder<infer T> ? T : never; }>;
|
|
335
534
|
/**
|
|
336
535
|
* Raw GraphQL escape hatch. Use when a typed resource method doesn't cover
|
|
337
536
|
* what you need (new schema fields, custom queries, ad-hoc inspection).
|
|
@@ -353,4 +552,4 @@ interface Loopwise {
|
|
|
353
552
|
}
|
|
354
553
|
declare function createClient(config: LoopwiseConfig): Loopwise;
|
|
355
554
|
//#endregion
|
|
356
|
-
export { type Course, type FetchOptions, type Lecturer, type ListResult, Loopwise, type LoopwiseConfig, LoopwiseError, type Order, type OrderItem, type OrderStatus, type PageInfo, type Post, QueryBuilder, type Selected, type Selection, createClient };
|
|
555
|
+
export { type Course, type CourseRating, type CourseReviewState, type CourseReviewStatus, type FetchOptions, type GraphQLError, type Lecturer, type ListResult, Loopwise, type LoopwiseConfig, LoopwiseError, type LoopwiseErrorCode, type Member, type MemberReview, type Order, type OrderItem, type OrderStatus, type PageInfo, type Post, QueryBuilder, type Selected, type Selection, createClient };
|
package/dist/index.mjs
CHANGED
|
@@ -36,6 +36,7 @@ var LoopwiseClient = class {
|
|
|
36
36
|
constructor(config) {
|
|
37
37
|
const site = config.site ?? config.tenant;
|
|
38
38
|
if (detectRuntime() !== "browser" && !site) throw new LoopwiseError("CONFIG", "site is required outside browser runtime. Pass it to createClient({ site }).");
|
|
39
|
+
if (config.apiVersion !== void 0 && config.apiVersion.trim() === "") throw new LoopwiseError("CONFIG", "apiVersion must be a release date (YYYY-MM-DD) or omitted; a blank value would be served as the baseline schema.");
|
|
39
40
|
this.config = {
|
|
40
41
|
...config,
|
|
41
42
|
site,
|
|
@@ -97,10 +98,41 @@ var LoopwiseClient = class {
|
|
|
97
98
|
}
|
|
98
99
|
return result;
|
|
99
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* POST a document on behalf of the signed-in member and return the whole
|
|
103
|
+
* GraphQL response, errors included, so the caller can map service error
|
|
104
|
+
* codes. Refuses wherever the member's `token` cookie cannot ride along.
|
|
105
|
+
*/
|
|
106
|
+
async member(payload) {
|
|
107
|
+
this.assertMemberOrigin();
|
|
108
|
+
return this.request(payload, { cache: "no-store" });
|
|
109
|
+
}
|
|
110
|
+
graphqlError(errors, code = "GRAPHQL", message) {
|
|
111
|
+
return new LoopwiseError(code, message ?? errors[0]?.message ?? "GraphQL error", {
|
|
112
|
+
site: this.config.site,
|
|
113
|
+
endpoint: this.endpoint,
|
|
114
|
+
graphqlErrors: errors
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
assertMemberOrigin() {
|
|
118
|
+
const detail = {
|
|
119
|
+
site: this.config.site,
|
|
120
|
+
endpoint: this.endpoint
|
|
121
|
+
};
|
|
122
|
+
if (detectRuntime() !== "browser") throw new LoopwiseError("AUTH_REQUIRED", "Member actions run only in the browser on the site's own domain; server-side code has no signed-in member.", detail);
|
|
123
|
+
const target = new URL(this.endpoint, window.location.href).origin;
|
|
124
|
+
if (target !== window.location.origin) throw new LoopwiseError("AUTH_REQUIRED", `Member actions need the page and the API on one origin; this page is ${window.location.origin} but the API is ${target}, so the member's sign-in cookie is not sent.`, detail);
|
|
125
|
+
}
|
|
100
126
|
async fetchGraphQL(payload, options = {}) {
|
|
127
|
+
const body = await this.request(payload, options);
|
|
128
|
+
if (body.errors?.length) throw this.graphqlError(body.errors);
|
|
129
|
+
return body.data ?? null;
|
|
130
|
+
}
|
|
131
|
+
async request(payload, options) {
|
|
101
132
|
const headers = { accept: "application/graphql-response+json, application/json" };
|
|
102
133
|
const apiKey = this.resolveApiKey();
|
|
103
134
|
if (apiKey) headers.authorization = `Bearer ${apiKey}`;
|
|
135
|
+
if (this.config.apiVersion) headers["api-version"] = this.config.apiVersion;
|
|
104
136
|
const useGet = this.shouldUseGet(payload, options);
|
|
105
137
|
if (typeof options.cache === "number" && options.cache > 0) headers["x-loopwise-cache"] = String(options.cache);
|
|
106
138
|
let response;
|
|
@@ -134,17 +166,17 @@ var LoopwiseClient = class {
|
|
|
134
166
|
site: this.config.site,
|
|
135
167
|
endpoint: this.endpoint
|
|
136
168
|
});
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
147
|
-
return body
|
|
169
|
+
let body;
|
|
170
|
+
if (response.ok) body = await response.json();
|
|
171
|
+
else {
|
|
172
|
+
const errorBody = await parseGraphQLErrorBody(response);
|
|
173
|
+
if (!errorBody) throw new LoopwiseError("NETWORK", `Upstream ${response.status}`, {
|
|
174
|
+
site: this.config.site,
|
|
175
|
+
endpoint: this.endpoint
|
|
176
|
+
});
|
|
177
|
+
body = errorBody;
|
|
178
|
+
}
|
|
179
|
+
return body;
|
|
148
180
|
}
|
|
149
181
|
shouldUseGet(payload, options) {
|
|
150
182
|
if (typeof options.cache !== "number" || options.cache <= 0) return false;
|
|
@@ -168,6 +200,18 @@ var LoopwiseClient = class {
|
|
|
168
200
|
return globalThis.process?.env?.LOOPWISE_API_KEY;
|
|
169
201
|
}
|
|
170
202
|
};
|
|
203
|
+
async function parseGraphQLErrorBody(response) {
|
|
204
|
+
let parsed;
|
|
205
|
+
try {
|
|
206
|
+
parsed = JSON.parse(await response.text());
|
|
207
|
+
} catch {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
211
|
+
const errors = parsed.errors;
|
|
212
|
+
if (!Array.isArray(errors) || errors.length === 0) return null;
|
|
213
|
+
return errors.every((e) => e !== null && typeof e === "object" && typeof e.message === "string") ? parsed : null;
|
|
214
|
+
}
|
|
171
215
|
function firstOperationKeyword(document) {
|
|
172
216
|
let i = 0;
|
|
173
217
|
if (document.charCodeAt(0) === 65279) i = 1;
|
|
@@ -254,6 +298,58 @@ var QueryBuilder = class {
|
|
|
254
298
|
}
|
|
255
299
|
};
|
|
256
300
|
//#endregion
|
|
301
|
+
//#region src/schema/auth.ts
|
|
302
|
+
const ME_QUERY = "query Me($subdomain: String!) { user(subdomain: $subdomain) { id name } }";
|
|
303
|
+
function mapMember(raw) {
|
|
304
|
+
return {
|
|
305
|
+
id: raw.id,
|
|
306
|
+
name: raw.name ?? null
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
function serviceCode(error) {
|
|
310
|
+
if (typeof error.code === "string") return error.code;
|
|
311
|
+
const ext = error.extensions?.code;
|
|
312
|
+
return typeof ext === "string" ? ext : void 0;
|
|
313
|
+
}
|
|
314
|
+
function isForeignMemberError(error) {
|
|
315
|
+
return error.path?.[0] === "user" && serviceCode(error) === "User-000";
|
|
316
|
+
}
|
|
317
|
+
//#endregion
|
|
318
|
+
//#region src/resources/auth.ts
|
|
319
|
+
/**
|
|
320
|
+
* The member signed in to this site, read from the page's own sign-in
|
|
321
|
+
* cookie. Works only in the browser on the site's own domain; elsewhere
|
|
322
|
+
* every method rejects with `LoopwiseError` code `'AUTH_REQUIRED'` instead
|
|
323
|
+
* of reporting a signed-out visitor.
|
|
324
|
+
*/
|
|
325
|
+
var AuthResource = class {
|
|
326
|
+
client;
|
|
327
|
+
constructor(client) {
|
|
328
|
+
this.client = client;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* The signed-in member, or `null` for a signed-out visitor.
|
|
332
|
+
* @example
|
|
333
|
+
* const me = await loopwise.auth.getMe()
|
|
334
|
+
* greeting.textContent = me ? `Hi ${me.name ?? ''}` : 'Sign in'
|
|
335
|
+
*/
|
|
336
|
+
async getMe() {
|
|
337
|
+
const body = await this.client.member({
|
|
338
|
+
query: ME_QUERY,
|
|
339
|
+
variables: { subdomain: this.client.config.site ?? "" },
|
|
340
|
+
operationName: "Me"
|
|
341
|
+
});
|
|
342
|
+
const errors = body.errors ?? [];
|
|
343
|
+
if (errors.length > 0 && !errors.every(isForeignMemberError)) throw this.client.graphqlError(errors);
|
|
344
|
+
const raw = body.data?.user;
|
|
345
|
+
return raw ? mapMember(raw) : null;
|
|
346
|
+
}
|
|
347
|
+
/** Whether a member is signed in to this site. */
|
|
348
|
+
async isAuthenticated() {
|
|
349
|
+
return await this.getMe() !== null;
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
//#endregion
|
|
257
353
|
//#region src/schema/courses.ts
|
|
258
354
|
/** Map SDK Course selection -> schema field selection set. */
|
|
259
355
|
function buildCourseSelection(select) {
|
|
@@ -303,6 +399,25 @@ function mapCoursePage(raw) {
|
|
|
303
399
|
};
|
|
304
400
|
}
|
|
305
401
|
//#endregion
|
|
402
|
+
//#region src/schema/reviews.ts
|
|
403
|
+
const REVIEW_STATE_QUERY = "query CourseReviewState($subdomain: String!, $slug: String!) { user(subdomain: $subdomain) { id } course(subdomain: $subdomain, slug: $slug) { id isRatingActive isPurchased reviewed { id score comment } rating { average total } } }";
|
|
404
|
+
const SUBMIT_REVIEW_MUTATION = "mutation SubmitReview($resourceInput: ResourceInput!, $rateInput: RateInput!) { createOrUpdateRate(resourceInput: $resourceInput, rateInput: $rateInput) { rate { id score comment } } }";
|
|
405
|
+
const DELETE_REVIEW_MUTATION = "mutation DeleteReview($subdomain: String!, $resourceInput: ResourceInput!, $rateId: String!) { deleteRate(subdomain: $subdomain, resourceInput: $resourceInput, rateId: $rateId) { success } }";
|
|
406
|
+
function mapMemberReview(raw) {
|
|
407
|
+
return {
|
|
408
|
+
id: raw.id,
|
|
409
|
+
score: raw.score,
|
|
410
|
+
comment: raw.comment ?? ""
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
function isValidScore(score) {
|
|
414
|
+
return typeof score === "number" && Number.isFinite(score) && score >= .5 && score <= 5 && Number.isInteger(score * 2);
|
|
415
|
+
}
|
|
416
|
+
function currentLoginUrl() {
|
|
417
|
+
const { pathname, search, hash } = window.location;
|
|
418
|
+
return `/login?redirect=${encodeURIComponent(pathname + search + hash)}`;
|
|
419
|
+
}
|
|
420
|
+
//#endregion
|
|
306
421
|
//#region src/resources/courses.ts
|
|
307
422
|
const DEFAULT_LIST_FIELDS$1 = {
|
|
308
423
|
id: true,
|
|
@@ -384,7 +499,59 @@ var CoursesResource = class {
|
|
|
384
499
|
transform: (raw) => mapCourse(raw)
|
|
385
500
|
}, options);
|
|
386
501
|
}
|
|
502
|
+
/**
|
|
503
|
+
* What the signed-in member can do about reviewing a course, in one
|
|
504
|
+
* request: whether reviews are on, whether they are signed in and
|
|
505
|
+
* enrolled, their own review, and the course's rating summary.
|
|
506
|
+
*
|
|
507
|
+
* Member state: call it in the browser on the site's own domain, after
|
|
508
|
+
* the page loads. It never uses the `cache` option, and it rejects with
|
|
509
|
+
* `LoopwiseError` code `AUTH_REQUIRED` server-side or off-origin rather
|
|
510
|
+
* than report every visitor as signed out. An unknown slug rejects with
|
|
511
|
+
* `GRAPHQL`.
|
|
512
|
+
*
|
|
513
|
+
* @example
|
|
514
|
+
* const state = await loopwise.courses.reviewState({ slug: 'finance' })
|
|
515
|
+
* switch (state.status) {
|
|
516
|
+
* case 'ratings_off': return hide()
|
|
517
|
+
* case 'signed_out': return showLink(state.loginUrl)
|
|
518
|
+
* case 'not_purchased': return showText('Enroll to review this course')
|
|
519
|
+
* case 'can_review': return showForm()
|
|
520
|
+
* case 'reviewed': return showForm(state.review)
|
|
521
|
+
* }
|
|
522
|
+
*/
|
|
523
|
+
async reviewState(args) {
|
|
524
|
+
if (typeof args?.slug !== "string" || args.slug === "") throw new LoopwiseError("CONFIG", "slug is required");
|
|
525
|
+
const body = await this.client.member({
|
|
526
|
+
query: REVIEW_STATE_QUERY,
|
|
527
|
+
variables: {
|
|
528
|
+
subdomain: this.client.config.site ?? "",
|
|
529
|
+
slug: args.slug
|
|
530
|
+
},
|
|
531
|
+
operationName: "CourseReviewState"
|
|
532
|
+
});
|
|
533
|
+
const errors = body.errors ?? [];
|
|
534
|
+
if (errors.length > 0 && !errors.every(isForeignMemberError)) throw this.client.graphqlError(errors);
|
|
535
|
+
const course = body.data?.course;
|
|
536
|
+
if (!course) throw this.client.graphqlError(errors, "GRAPHQL", `No course with slug "${args.slug}"`);
|
|
537
|
+
const signedIn = body.data?.user != null;
|
|
538
|
+
const review = signedIn && course.reviewed ? mapMemberReview(course.reviewed) : null;
|
|
539
|
+
return {
|
|
540
|
+
status: reviewStatus(course, signedIn, review !== null),
|
|
541
|
+
courseId: course.id,
|
|
542
|
+
review,
|
|
543
|
+
rating: course.rating ?? null,
|
|
544
|
+
loginUrl: currentLoginUrl()
|
|
545
|
+
};
|
|
546
|
+
}
|
|
387
547
|
};
|
|
548
|
+
function reviewStatus(course, signedIn, reviewed) {
|
|
549
|
+
if (!course.isRatingActive) return "ratings_off";
|
|
550
|
+
if (!signedIn) return "signed_out";
|
|
551
|
+
if (reviewed) return "reviewed";
|
|
552
|
+
if (!course.isPurchased) return "not_purchased";
|
|
553
|
+
return "can_review";
|
|
554
|
+
}
|
|
388
555
|
//#endregion
|
|
389
556
|
//#region src/schema/orders.ts
|
|
390
557
|
/** Map SDK Order selection -> schema field selection set. */
|
|
@@ -628,13 +795,114 @@ var PostsResource = class {
|
|
|
628
795
|
}
|
|
629
796
|
};
|
|
630
797
|
//#endregion
|
|
798
|
+
//#region src/resources/reviews.ts
|
|
799
|
+
/**
|
|
800
|
+
* Course reviews written by the signed-in member, plus deletion for the
|
|
801
|
+
* site's owner and managers (moderation). Browser on the site's own
|
|
802
|
+
* domain only; elsewhere every method rejects with `AUTH_REQUIRED`.
|
|
803
|
+
*
|
|
804
|
+
* Each submit emails the site owner. Call `submit` from an explicit member
|
|
805
|
+
* action (a Submit button), never from autosave or on every star change.
|
|
806
|
+
*/
|
|
807
|
+
var ReviewsResource = class {
|
|
808
|
+
client;
|
|
809
|
+
auth;
|
|
810
|
+
constructor(client, auth) {
|
|
811
|
+
this.client = client;
|
|
812
|
+
this.auth = auth;
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Create the member's review of a course, or replace it if they already
|
|
816
|
+
* have one. Resolves to the stored review.
|
|
817
|
+
*
|
|
818
|
+
* Rejects with `LoopwiseError` code `INVALID_SCORE`, `NOT_SIGNED_IN`,
|
|
819
|
+
* `NOT_PURCHASED`, `RATINGS_OFF` or `AUTH_REQUIRED`.
|
|
820
|
+
*
|
|
821
|
+
* @example
|
|
822
|
+
* form.addEventListener('submit', async (event) => {
|
|
823
|
+
* event.preventDefault()
|
|
824
|
+
* const review = await loopwise.reviews.submit({
|
|
825
|
+
* courseId: state.courseId,
|
|
826
|
+
* score: Number(form.score.value),
|
|
827
|
+
* comment: form.comment.value,
|
|
828
|
+
* })
|
|
829
|
+
* })
|
|
830
|
+
*/
|
|
831
|
+
async submit(args) {
|
|
832
|
+
assertCourseId(args.courseId);
|
|
833
|
+
if (!isValidScore(args.score)) throw new LoopwiseError("INVALID_SCORE", `score must be 0.5 to 5 in steps of 0.5; got ${String(args.score)}`);
|
|
834
|
+
const { comment } = args;
|
|
835
|
+
if (typeof comment !== "string") throw new LoopwiseError("CONFIG", "comment is required: pass the review text, or \"\" to clear it");
|
|
836
|
+
const body = await this.client.member({
|
|
837
|
+
query: SUBMIT_REVIEW_MUTATION,
|
|
838
|
+
variables: {
|
|
839
|
+
resourceInput: {
|
|
840
|
+
resourceType: "Course",
|
|
841
|
+
resourceId: args.courseId
|
|
842
|
+
},
|
|
843
|
+
rateInput: {
|
|
844
|
+
score: args.score,
|
|
845
|
+
comment
|
|
846
|
+
}
|
|
847
|
+
},
|
|
848
|
+
operationName: "SubmitReview"
|
|
849
|
+
});
|
|
850
|
+
if (body.errors?.length) throw await this.mapError(body.errors);
|
|
851
|
+
const payload = body.data?.createOrUpdateRate;
|
|
852
|
+
if (!payload?.rate) throw new LoopwiseError("GRAPHQL", "The review was not saved", { site: this.client.config.site });
|
|
853
|
+
return mapMemberReview(payload.rate);
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* Delete a review of a course.
|
|
857
|
+
*
|
|
858
|
+
* The server allows the review's author, and the site's owner or
|
|
859
|
+
* managers (for moderation); anyone else is refused. Rejects with
|
|
860
|
+
* `NOT_SIGNED_IN` or `AUTH_REQUIRED`; a review the member may not delete,
|
|
861
|
+
* or one that no longer exists, rejects with `GRAPHQL`.
|
|
862
|
+
*/
|
|
863
|
+
async delete(args) {
|
|
864
|
+
assertCourseId(args.courseId);
|
|
865
|
+
if (typeof args.reviewId !== "string" || args.reviewId === "") throw new LoopwiseError("CONFIG", "reviewId is required");
|
|
866
|
+
const body = await this.client.member({
|
|
867
|
+
query: DELETE_REVIEW_MUTATION,
|
|
868
|
+
variables: {
|
|
869
|
+
subdomain: this.client.config.site ?? "",
|
|
870
|
+
resourceInput: {
|
|
871
|
+
resourceType: "Course",
|
|
872
|
+
resourceId: args.courseId
|
|
873
|
+
},
|
|
874
|
+
rateId: args.reviewId
|
|
875
|
+
},
|
|
876
|
+
operationName: "DeleteReview"
|
|
877
|
+
});
|
|
878
|
+
if (body.errors?.length) throw (body.errors.some((e) => serviceCode(e) === "User-000") ? await this.auth.isAuthenticated() : true) ? this.client.graphqlError(body.errors) : this.notSignedIn(body.errors);
|
|
879
|
+
if (!(body.data?.deleteRate)?.success) throw new LoopwiseError("GRAPHQL", "The review was not deleted", { site: this.client.config.site });
|
|
880
|
+
}
|
|
881
|
+
async mapError(errors) {
|
|
882
|
+
const codes = errors.map(serviceCode);
|
|
883
|
+
if (codes.includes("Rate-001")) return this.client.graphqlError(errors, "INVALID_SCORE", "score must be 0.5 to 5 in steps of 0.5");
|
|
884
|
+
if (codes.includes("Rate-002")) return this.client.graphqlError(errors, "RATINGS_OFF", "Reviews are turned off for this course");
|
|
885
|
+
if (codes.includes("User-000")) return await this.auth.isAuthenticated() ? this.client.graphqlError(errors, "NOT_PURCHASED", "Only members enrolled in this course can review it") : this.notSignedIn(errors);
|
|
886
|
+
return this.client.graphqlError(errors);
|
|
887
|
+
}
|
|
888
|
+
notSignedIn(errors) {
|
|
889
|
+
return this.client.graphqlError(errors, "NOT_SIGNED_IN", "Sign in to review this course");
|
|
890
|
+
}
|
|
891
|
+
};
|
|
892
|
+
function assertCourseId(courseId) {
|
|
893
|
+
if (typeof courseId !== "string" || courseId === "") throw new LoopwiseError("CONFIG", "courseId is required");
|
|
894
|
+
}
|
|
895
|
+
//#endregion
|
|
631
896
|
//#region src/index.ts
|
|
632
897
|
function createClient(config) {
|
|
633
898
|
const client = new LoopwiseClient(config);
|
|
899
|
+
const auth = new AuthResource(client);
|
|
634
900
|
return {
|
|
901
|
+
auth,
|
|
635
902
|
courses: new CoursesResource(client),
|
|
636
903
|
orders: new OrdersResource(client),
|
|
637
904
|
posts: new PostsResource(client),
|
|
905
|
+
reviews: new ReviewsResource(client, auth),
|
|
638
906
|
batch(builders, options) {
|
|
639
907
|
return client.batch(builders, options);
|
|
640
908
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loopwise/sdk",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.5",
|
|
4
4
|
"description": "Loopwise platform SDK — agent-friendly headless API client",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -43,16 +43,17 @@
|
|
|
43
43
|
"scripts": {
|
|
44
44
|
"build": "tsdown",
|
|
45
45
|
"dev": "tsdown --watch",
|
|
46
|
-
"typecheck": "
|
|
46
|
+
"typecheck": "tsgo --noEmit",
|
|
47
47
|
"test": "vitest run",
|
|
48
48
|
"regen:lock": "node scripts/regen-lock.mjs"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
+
"@kaikhq/graphql-proxy": "workspace:*",
|
|
51
52
|
"graphql": "16.13.1",
|
|
52
53
|
"tsconfig": "workspace:*",
|
|
53
|
-
"tsdown": "
|
|
54
|
+
"tsdown": "catalog:",
|
|
54
55
|
"typescript": "5.9.3",
|
|
55
|
-
"vitest": "
|
|
56
|
+
"vitest": "catalog:"
|
|
56
57
|
},
|
|
57
58
|
"publishConfig": {
|
|
58
59
|
"access": "public"
|