@loopwise/sdk 0.1.0-beta.4 → 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 +41 -1
- package/README.md +108 -2
- package/dist/index.d.mts +201 -2
- package/dist/index.mjs +255 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,45 @@ 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
|
+
|
|
12
51
|
## [0.1.0-beta.4] — 2026-09-10
|
|
13
52
|
|
|
14
53
|
Storefront schema pinning for browser clients. A design partner could
|
|
@@ -100,7 +139,8 @@ Initial public beta. `courses.list/get`, `posts.list/get`, `orders.get`,
|
|
|
100
139
|
`batch()`, raw `query()`. Schema-lock pipeline. Same-origin BFF and
|
|
101
140
|
explicit cross-origin endpoint resolution.
|
|
102
141
|
|
|
103
|
-
[Unreleased]: https://github.com/kaikhq/manekineko/compare/@loopwise/sdk@0.1.0-beta.
|
|
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
|
|
104
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
|
|
105
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
|
|
106
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
|
package/README.md
CHANGED
|
@@ -59,8 +59,9 @@ 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
|
|
|
66
67
|
The site's `/api/graphql` answers the browser's CORS preflight with a
|
|
@@ -85,6 +86,108 @@ const { items } = await loopwise.courses.list({ limit: 20 }, { cache: 60 });
|
|
|
85
86
|
For preview UX that needs an authenticated viewer, see
|
|
86
87
|
[Preview modes](#preview-modes) below.
|
|
87
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
|
+
|
|
88
191
|
## `cache` option
|
|
89
192
|
|
|
90
193
|
Read-only resource calls accept an opt-in `cache` option that flips the
|
|
@@ -204,6 +307,9 @@ your own state (`useEffect` + `useState`, TanStack Query, etc.).
|
|
|
204
307
|
- `loopwise.courses.list(args, options?)` / `.get(args, options?)`.
|
|
205
308
|
- `loopwise.posts.list(args, options?)` / `.get(args, options?)`.
|
|
206
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 })`.
|
|
207
313
|
- `loopwise.batch(builders, options?)` — composes N queries into one HTTP request.
|
|
208
314
|
- `loopwise.query<T>(document, variables?, operationName?, options?)` — raw GraphQL escape hatch.
|
|
209
315
|
|
package/dist/index.d.mts
CHANGED
|
@@ -1,8 +1,27 @@
|
|
|
1
1
|
import { LoopwiseConfig } from "./config.mjs";
|
|
2
2
|
//#region src/error.d.ts
|
|
3
|
-
|
|
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";
|
|
4
21
|
interface GraphQLError {
|
|
5
22
|
message: string;
|
|
23
|
+
/** Storefront service error code, such as `User-000` or `Rate-002`. */
|
|
24
|
+
code?: string;
|
|
6
25
|
path?: (string | number)[];
|
|
7
26
|
extensions?: Record<string, unknown>;
|
|
8
27
|
}
|
|
@@ -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;
|
|
@@ -97,7 +125,16 @@ declare class LoopwiseClient implements BatchedFetcher {
|
|
|
97
125
|
batch<R extends Record<string, unknown>>(builders: { [K in keyof R]: {
|
|
98
126
|
descriptor: QueryDescriptor;
|
|
99
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;
|
|
@@ -160,6 +197,56 @@ 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
251
|
type OrderStatus = "PAID" | "PENDING" | "FAILED" | "REFUNDED";
|
|
165
252
|
interface OrderItem {
|
|
@@ -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
|
|
@@ -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
|
@@ -98,7 +98,37 @@ var LoopwiseClient = class {
|
|
|
98
98
|
}
|
|
99
99
|
return result;
|
|
100
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
|
+
}
|
|
101
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) {
|
|
102
132
|
const headers = { accept: "application/graphql-response+json, application/json" };
|
|
103
133
|
const apiKey = this.resolveApiKey();
|
|
104
134
|
if (apiKey) headers.authorization = `Bearer ${apiKey}`;
|
|
@@ -146,12 +176,7 @@ var LoopwiseClient = class {
|
|
|
146
176
|
});
|
|
147
177
|
body = errorBody;
|
|
148
178
|
}
|
|
149
|
-
|
|
150
|
-
site: this.config.site,
|
|
151
|
-
endpoint: this.endpoint,
|
|
152
|
-
graphqlErrors: body.errors
|
|
153
|
-
});
|
|
154
|
-
return body.data ?? null;
|
|
179
|
+
return body;
|
|
155
180
|
}
|
|
156
181
|
shouldUseGet(payload, options) {
|
|
157
182
|
if (typeof options.cache !== "number" || options.cache <= 0) return false;
|
|
@@ -273,6 +298,58 @@ var QueryBuilder = class {
|
|
|
273
298
|
}
|
|
274
299
|
};
|
|
275
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
|
|
276
353
|
//#region src/schema/courses.ts
|
|
277
354
|
/** Map SDK Course selection -> schema field selection set. */
|
|
278
355
|
function buildCourseSelection(select) {
|
|
@@ -322,6 +399,25 @@ function mapCoursePage(raw) {
|
|
|
322
399
|
};
|
|
323
400
|
}
|
|
324
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
|
|
325
421
|
//#region src/resources/courses.ts
|
|
326
422
|
const DEFAULT_LIST_FIELDS$1 = {
|
|
327
423
|
id: true,
|
|
@@ -403,7 +499,59 @@ var CoursesResource = class {
|
|
|
403
499
|
transform: (raw) => mapCourse(raw)
|
|
404
500
|
}, options);
|
|
405
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
|
+
}
|
|
406
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
|
+
}
|
|
407
555
|
//#endregion
|
|
408
556
|
//#region src/schema/orders.ts
|
|
409
557
|
/** Map SDK Order selection -> schema field selection set. */
|
|
@@ -647,13 +795,114 @@ var PostsResource = class {
|
|
|
647
795
|
}
|
|
648
796
|
};
|
|
649
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
|
|
650
896
|
//#region src/index.ts
|
|
651
897
|
function createClient(config) {
|
|
652
898
|
const client = new LoopwiseClient(config);
|
|
899
|
+
const auth = new AuthResource(client);
|
|
653
900
|
return {
|
|
901
|
+
auth,
|
|
654
902
|
courses: new CoursesResource(client),
|
|
655
903
|
orders: new OrdersResource(client),
|
|
656
904
|
posts: new PostsResource(client),
|
|
905
|
+
reviews: new ReviewsResource(client, auth),
|
|
657
906
|
batch(builders, options) {
|
|
658
907
|
return client.batch(builders, options);
|
|
659
908
|
},
|