@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 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/sdk-react`](#react--coming-soon)) | — |
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 — `cart`, `viewer.profile`,
63
- authenticated mutations — **will not work** from off-site code.
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/sdk-react` companion package is planned, providing:
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
- type LoopwiseErrorCode = 'NETWORK' | 'AUTH' | 'GRAPHQL' | 'RATE_LIMIT' | 'CONFIG';
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; /** @deprecated Renamed to `site`. Removed in 0.2.0. */
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]> } : true;
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]> } : T;
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 | 'no-store';
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: 'login_required' | 'paid' | 'public_access' | (string & {});
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 = 'PAID' | 'PENDING' | 'FAILED' | 'REFUNDED';
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
- if (!response.ok) throw new LoopwiseError("NETWORK", `Upstream ${response.status}`, {
138
- site: this.config.site,
139
- endpoint: this.endpoint
140
- });
141
- const body = await response.json();
142
- if (body.errors?.length) throw new LoopwiseError("GRAPHQL", body.errors[0]?.message ?? "GraphQL error", {
143
- site: this.config.site,
144
- endpoint: this.endpoint,
145
- graphqlErrors: body.errors
146
- });
147
- return body.data ?? null;
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",
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": "tsc --noEmit",
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": "0.22.0",
54
+ "tsdown": "catalog:",
54
55
  "typescript": "5.9.3",
55
- "vitest": "4.1.0"
56
+ "vitest": "catalog:"
56
57
  },
57
58
  "publishConfig": {
58
59
  "access": "public"