@loopwise/sdk 0.1.0-beta.1 → 0.1.0-beta.3

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 ADDED
@@ -0,0 +1,69 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@loopwise/sdk` are documented here.
4
+
5
+ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
+ Versioning: [SemVer](https://semver.org/) with pre-1.0 semantics — minor bumps
7
+ (`0.x → 0.x+1`) may include breaking changes; beta tags (`0.1.0-beta.N`)
8
+ follow the same rules as a single 0.x line.
9
+
10
+ ## [Unreleased]
11
+
12
+ ## [0.1.0-beta.3] — 2026-05-20
13
+
14
+ ### Added
15
+
16
+ - `LoopwiseConfig.site` — preferred name for the site this client reads
17
+ from. Accepts a subdomain (`'acme'`) or full domain
18
+ (`'acme.myteachify.com'`, `'nii.school'`). Replaces the deprecated
19
+ `tenant` field; both fields are accepted, `site` wins when both are set.
20
+ - `LoopwiseError.site` — mirror of the deprecated `tenant` property.
21
+ Catch-and-read consumers can migrate at their own pace.
22
+
23
+ ### Deprecated
24
+
25
+ - `LoopwiseConfig.tenant` — renamed to `site`. Both fields still work;
26
+ `site` wins when both are set. **Removed in `0.2.0`.**
27
+ - `LoopwiseError.tenant` — renamed to `site`. **Removed in `0.2.0`.**
28
+
29
+ ### Removed
30
+
31
+ - `LoopwiseErrorCode = '…' | 'TENANT_NOT_FOUND'` — the literal was never
32
+ emitted by any SDK code path (it was a reserved/aspirational shape
33
+ that never got runtime mapping). Removed instead of renaming to
34
+ `'SITE_NOT_FOUND'` — no point preserving phantom API surface. If a
35
+ future SDK release adds actual runtime mapping (e.g. translating
36
+ Rails' `School-000` GraphQL error into a typed code), the canonical
37
+ `'SITE_NOT_FOUND'` literal will be added then. Removal is type-only
38
+ (narrowing checks like `case 'TENANT_NOT_FOUND':` were unreachable
39
+ branches in any consumer code).
40
+
41
+ ### Changed (non-breaking)
42
+
43
+ - README + every user-visible JSDoc reframed to use "site" language
44
+ instead of "tenant" / "multi-tenant" / "BFF derives tenant from host"
45
+ — SDK consumers don't need to learn the platform's multi-tenancy
46
+ model, just the SDK's behaviour and limits (`createClient({})` reads
47
+ from the page's own site; `createClient({ site: '…' })` reads from a
48
+ different site at the cost of authenticated calls).
49
+
50
+ ## [0.1.0-beta.2] — 2026-05-19
51
+
52
+ ### Fixed
53
+
54
+ - `orders.get` returns `null` from a null GraphQL payload instead of
55
+ throwing.
56
+ - `orders.get` passes the `id` as an inline GraphQL argument rather than
57
+ a `$variable`, avoiding collision when composed via `batch()`.
58
+
59
+ ### Documentation
60
+
61
+ - `orders.get` JSDoc clarifies `OrderItem.amount` is per-unit price
62
+ (not line total), and null-guards the `tradeNo` URL-param lookup
63
+ example.
64
+
65
+ ## [0.1.0-beta.1]
66
+
67
+ Initial public beta. `courses.list/get`, `posts.list/get`, `orders.get`,
68
+ `batch()`, raw `query()`. Schema-lock pipeline. Same-origin BFF and
69
+ explicit cross-origin endpoint resolution.
package/README.md CHANGED
@@ -15,15 +15,15 @@ Same package powers all three; the difference is the loader.
15
15
  ```ts
16
16
  import { createClient } from '@loopwise/sdk';
17
17
 
18
- // Browser, same-origin (the page is served from the tenant origin):
18
+ // Reads from the site this page is on. No config needed.
19
19
  const loopwise = createClient({});
20
20
  const { items } = await loopwise.courses.list({ limit: 20 });
21
21
  ```
22
22
 
23
- That's the entire happy path **when the page is served from the tenant
24
- origin** (`acme.kaik.io`, `acme.myteachify.com`, `nii.school`, etc.). The BFF
25
- on that origin derives the tenant from the request host and binds it to the
26
- response on the trusted server side.
23
+ That's it. The SDK calls the site that's serving the page
24
+ (`acme.kaik.io`, `acme.myteachify.com`, `nii.school`, etc.) and the
25
+ site's API binds the response to itself — you never identify the site
26
+ yourself. To call a different site, see [Calling a different site](#calling-a-different-site).
27
27
 
28
28
  ## Quick start — single-file HTML (no build)
29
29
 
@@ -32,44 +32,47 @@ response on the trusted server side.
32
32
  <script type="module">
33
33
  import { createClient } from 'https://esm.sh/@loopwise/sdk@beta';
34
34
 
35
- // Same-origin variant — works when the HTML is served from the tenant origin
36
- // (e.g. acme.kaik.io). Empty config; BFF derives the tenant from host.
35
+ // Reads from the site this HTML is loaded on.
37
36
  const loopwise = createClient({});
38
37
  const { items } = await loopwise.courses.list({ limit: 20 });
39
38
  document.body.textContent = items.map(c => c.title).join(', ');
40
39
  </script>
41
40
  ```
42
41
 
43
- ## Cross-origin (Loopwise Pages preview, SSR, build pipelines)
42
+ ## Calling a different site
44
43
 
45
- When the SDK runs **outside** a tenant origin, pass `tenant` explicitly so
46
- the SDK can pick the right Rails origin to call.
44
+ When your code runs **somewhere other than the site itself** — a Loopwise
45
+ Pages preview at `<scriptName>.loopwise.page`, a build / SSR pipeline,
46
+ server-to-server tooling — name the site you want to read from:
47
47
 
48
48
  ```ts
49
49
  const loopwise = createClient({
50
- tenant: 'acme', // required cross-origin
51
- apiKey: process.env.LOOPWISE_API_KEY, // required in SSR / build (browser ignores)
50
+ site: 'acme', // 'acme' or 'acme.myteachify.com'
51
+ apiKey: process.env.LOOPWISE_API_KEY, // build/SSR only; browser ignores
52
52
  });
53
53
  ```
54
54
 
55
- ### Where this matters most: `deploy_static_page`
55
+ `site` accepts a subdomain (`'acme'`) or a full domain
56
+ (`'acme.myteachify.com'`, `'nii.school'`). The SDK uses it to pick the
57
+ right API origin.
56
58
 
57
- Pages deployed via `deploy_static_page` live at `https://<script>.loopwise.page/`
58
- — that is **not** a tenant origin, so:
59
+ ### Limits when not on the site itself
59
60
 
60
- - The BFF same-origin trick does not apply. You **must** pass `tenant`.
61
- - Browser cross-origin requests carry no first-party cookies, so anything
62
- that requires a logged-in viewer (`cart`, `viewer.profile`,
63
- authenticated mutations) **will not work** at preview time.
64
- - Public, anonymous reads (`courses.list`, `posts.list`, etc.) are fine.
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.
64
+ Public, anonymous reads (`courses.list`, `posts.list`, etc.) work fine.
65
+
66
+ This is the dominant case for `deploy_static_page` bundles (which
67
+ serve at `<scriptName>.loopwise.page`):
65
68
 
66
69
  ```ts
67
70
  // Inside an HTML bundle deployed to https://lw-acme-home.loopwise.page/
68
- const loopwise = createClient({ tenant: 'acme' });
71
+ const loopwise = createClient({ site: 'acme' });
69
72
  const { items } = await loopwise.courses.list({ limit: 20 }, { cache: 60 });
70
73
  ```
71
74
 
72
- For preview UX that needs authenticated state, see
75
+ For preview UX that needs an authenticated viewer, see
73
76
  [Preview modes](#preview-modes) below.
74
77
 
75
78
  ## `cache` option
@@ -124,22 +127,21 @@ content; use `cache: 'no-store'` for admin previews.
124
127
 
125
128
  Two preview surfaces exist, each with different capability:
126
129
 
127
- | Surface | Origin | Same-origin BFF? | Authenticated viewer? | Use for |
130
+ | Surface | URL | Hosted on the site? | Authenticated viewer? | Use for |
128
131
  |---|---|---|---|---|
129
- | **Loopwise Pages preview** | `<scriptName>.loopwise.page` | ❌ cross-origin | ❌ no first-party cookies | Anonymous storefront, marketing pages, public listings |
130
- | **Tenant-origin preview** | `<tenant-origin>/_preview/<scriptName>/` | ✅ same-origin | ✅ admin can preview as a logged-in viewer | Cart, checkout, member-gated content, full storefront |
132
+ | **Loopwise Pages preview** | `<scriptName>.loopwise.page` | ❌ separate origin | ❌ no first-party cookies | Anonymous storefront, marketing pages, public listings |
133
+ | **Site-origin preview** | `<site>/_preview/<scriptName>/` | ✅ same origin as the site | ✅ admins can preview signed in | Cart, checkout, member-gated content, full storefront |
131
134
 
132
- The tenant origin can be `<sub>.kaik.io`, `<sub>.myteachify.com`, or a
133
- custom FQDN like `nii.school`. `deploy_static_page` returns both
134
- surfaces (`preview_url` + `tenant_preview_url_template` with a
135
- `<TENANT_ORIGIN>` placeholder).
135
+ The site URL can be `<sub>.kaik.io`, `<sub>.myteachify.com`, or a custom
136
+ domain like `nii.school`. `deploy_static_page` returns both URLs in its
137
+ response.
136
138
 
137
- Pick the surface that matches what you're testing — anonymous storefront
138
- reads on Surface A (faster, edge-cacheable), authenticated flows on
139
- Surface B (logged-in only, never edge-cached). Surface B requires
140
- relative asset paths in your bundle (the proxy injects `<base>` so
141
- relative paths resolve under `/_preview/<scriptName>/`; root-absolute
142
- paths like `/style.css` still go to the tenant root and 404).
139
+ Pick the surface that matches what you're testing — anonymous reads on
140
+ Surface A (faster, edge-cacheable), authenticated flows on Surface B
141
+ (signed-in only, never edge-cached). Surface B requires relative asset
142
+ paths in your bundle (the proxy injects `<base>` so relative paths
143
+ resolve under `/_preview/<scriptName>/`; root-absolute paths like
144
+ `/style.css` still go to the site root and 404).
143
145
 
144
146
  ## React — coming soon
145
147
 
@@ -155,10 +157,12 @@ your own state (`useEffect` + `useState`, TanStack Query, etc.).
155
157
 
156
158
  ## Public API
157
159
 
158
- - `createClient(config)` — storefront client. `config.tenant` is optional in
159
- the browser when the page is on a tenant origin (BFF derives it from
160
- host); required cross-origin and in SSR / build.
160
+ - `createClient(config)` — storefront client. Pass `config.site` only
161
+ when calling a different site than the one your code is running on
162
+ (see [Calling a different site](#calling-a-different-site)).
161
163
  - `loopwise.courses.list(args, options?)` / `.get(args, options?)`.
164
+ - `loopwise.posts.list(args, options?)` / `.get(args, options?)`.
165
+ - `loopwise.orders.get(args, options?)` — post-checkout pixel-tracking pattern; see the method's JSDoc for the `?tradeNo=…` → `fbq('track', 'Purchase', …)` recipe.
162
166
  - `loopwise.batch(builders, options?)` — composes N queries into one HTTP request.
163
167
  - `loopwise.query<T>(document, variables?, operationName?, options?)` — raw GraphQL escape hatch.
164
168
 
package/dist/config.d.mts CHANGED
@@ -1,10 +1,22 @@
1
1
  //#region src/config.d.ts
2
2
  interface LoopwiseConfig {
3
3
  /**
4
- * Tenant identifier (subdomain "acme" or full FQDN "nii.school").
4
+ * The site this client reads from. Accepts a subdomain (`'acme'`) or a
5
+ * full domain (`'acme.myteachify.com'`, `'nii.school'`).
5
6
  *
6
- * Optional in browser runtime — SDK auto-resolves from `window.location.hostname`
7
- * (e.g. `acme.kaik.io` -> `"acme"`). Required in build/SSR runtime.
7
+ * Omit when your code runs on the site itself — the SDK calls the
8
+ * page's own origin. Required when running off-origin: cross-domain
9
+ * embeds (Loopwise Pages previews at `*.loopwise.page`), SSR / build
10
+ * pipelines, server-to-server tooling.
11
+ *
12
+ * Cross-origin requests carry no first-party cookies, so authenticated
13
+ * calls (`cart`, `viewer.profile`, mutations that need a signed-in
14
+ * visitor) won't work in that mode — public reads do.
15
+ */
16
+ site?: string;
17
+ /**
18
+ * @deprecated Renamed to `site`. Both fields work for now; if both are
19
+ * set, `site` wins. **Will be removed in `0.2.0`.**
8
20
  */
9
21
  tenant?: string;
10
22
  /**
@@ -15,9 +27,9 @@ interface LoopwiseConfig {
15
27
  */
16
28
  apiKey?: string;
17
29
  /**
18
- * Override the GraphQL endpoint. Defaults to:
19
- * - Browser: same-origin `/api/graphql`
20
- * - Build/SSR: `https://{tenant}/api/graphql` (or kaik.io fallback)
30
+ * Override the GraphQL endpoint. You normally never set this — the SDK
31
+ * picks the right URL from `site` (or the page's own origin when `site`
32
+ * is omitted).
21
33
  */
22
34
  endpoint?: string;
23
35
  }
package/dist/config.mjs CHANGED
@@ -2,6 +2,5 @@
2
2
  function defineConfig(config) {
3
3
  return config;
4
4
  }
5
-
6
5
  //#endregion
7
- export { defineConfig };
6
+ export { defineConfig };
package/dist/index.d.mts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { LoopwiseConfig } from "./config.mjs";
2
2
 
3
3
  //#region src/error.d.ts
4
- type LoopwiseErrorCode = 'NETWORK' | 'AUTH' | 'GRAPHQL' | 'RATE_LIMIT' | 'TENANT_NOT_FOUND' | 'CONFIG';
4
+ type LoopwiseErrorCode = 'NETWORK' | 'AUTH' | 'GRAPHQL' | 'RATE_LIMIT' | 'CONFIG';
5
5
  interface GraphQLError {
6
6
  message: string;
7
7
  path?: (string | number)[];
@@ -9,11 +9,15 @@ interface GraphQLError {
9
9
  }
10
10
  declare class LoopwiseError extends Error {
11
11
  readonly code: LoopwiseErrorCode;
12
+ /** The site this client was configured for. */
13
+ readonly site?: string;
14
+ /** @deprecated Renamed to `site`. Holds the same resolved value. Removed in 0.2.0. */
12
15
  readonly tenant?: string;
13
16
  readonly endpoint?: string;
14
17
  readonly graphqlErrors?: GraphQLError[];
15
18
  readonly requestId?: string;
16
19
  constructor(code: LoopwiseErrorCode, message: string, detail?: {
20
+ site?: string; /** @deprecated Renamed to `site`. Removed in 0.2.0. */
17
21
  tenant?: string;
18
22
  endpoint?: string;
19
23
  graphqlErrors?: GraphQLError[];
@@ -79,9 +83,7 @@ declare class QueryBuilder<T> implements PromiseLike<T> {
79
83
  //#endregion
80
84
  //#region src/client.d.ts
81
85
  declare class LoopwiseClient implements BatchedFetcher {
82
- readonly config: {
83
- tenant?: string;
84
- } & LoopwiseConfig;
86
+ readonly config: LoopwiseConfig;
85
87
  private endpoint;
86
88
  constructor(config: LoopwiseConfig);
87
89
  private defaultEndpoint;
@@ -119,6 +121,34 @@ interface Lecturer {
119
121
  name: string;
120
122
  avatar: string | null;
121
123
  }
124
+ interface Post {
125
+ id: string;
126
+ slug: string;
127
+ title: string;
128
+ subtitle: string | null;
129
+ excerpt: string | null;
130
+ /** Full body HTML. Truncated server-side when access is gated and the
131
+ * viewer doesn't have permission (login_required without auth, or paid
132
+ * without an active subscription). */
133
+ body: string;
134
+ /** Maps to schema `Post.cover_photo`. */
135
+ coverImage: string | null;
136
+ /** Unix timestamp (seconds since epoch). */
137
+ publishedAt: number;
138
+ /** Unix timestamp of the last update — schema sources this from the
139
+ * post's `updated_at`, so for any persisted record this is populated
140
+ * (typically equal to or after `publishedAt`). The `null` is included
141
+ * for forward-compat with a hypothetical "never modified" sentinel
142
+ * but isn't observed in current production data. */
143
+ modifiedAt: number | null;
144
+ /** Known values: `login_required`, `paid`, `public_access`. The
145
+ * `string & {}` escape hatch keeps autocomplete on the known set
146
+ * while accepting forward-compat values the schema may add. */
147
+ accessType: 'login_required' | 'paid' | 'public_access' | (string & {});
148
+ tags: string[] | null;
149
+ noindex: boolean;
150
+ author: Lecturer | null;
151
+ }
122
152
  interface PageInfo {
123
153
  page: number;
124
154
  totalPages: number;
@@ -130,18 +160,42 @@ interface ListResult<T> {
130
160
  items: T[];
131
161
  pageInfo: PageInfo;
132
162
  }
163
+ /** Simplified order status. Mirrors the storefront `Order.status` enum. */
164
+ type OrderStatus = 'PAID' | 'PENDING' | 'FAILED' | 'REFUNDED';
165
+ interface OrderItem {
166
+ /**
167
+ * Meta-pixel-aligned identifier (Lineitem#tracking_id server-side).
168
+ * Use directly in `content_ids` / `contents[].id` for Purchase events.
169
+ */
170
+ id: string;
171
+ name: string | null;
172
+ quantity: number;
173
+ /** Per-unit price in the order currency. Maps to FB Pixel `contents[].item_price`. */
174
+ amount: number;
175
+ }
176
+ interface Order {
177
+ /** Stable order identifier — currently equals `Payment#trade_no` server-side. */
178
+ id: string;
179
+ status: OrderStatus;
180
+ /** Total paid amount in the order currency. Maps to FB Pixel `value`. */
181
+ total: number;
182
+ currency: string;
183
+ items: OrderItem[];
184
+ /** Unix timestamp (seconds since epoch). */
185
+ createdAt: number;
186
+ }
133
187
  //#endregion
134
188
  //#region src/resources/courses.d.ts
135
- declare const DEFAULT_LIST_FIELDS: Selection<Course>;
136
- declare const DEFAULT_GET_FIELDS: Selection<Course>;
137
- interface CourseListArgs<S extends Selection<Course> = typeof DEFAULT_LIST_FIELDS> {
189
+ declare const DEFAULT_LIST_FIELDS$1: Selection<Course>;
190
+ declare const DEFAULT_GET_FIELDS$2: Selection<Course>;
191
+ interface CourseListArgs<S extends Selection<Course> = typeof DEFAULT_LIST_FIELDS$1> {
138
192
  limit?: number;
139
193
  page?: number;
140
194
  /** SDK `limit` maps to schema `perPage`. Schema also has its own `limit`
141
195
  * which we expose as `topN` if needed (not in v0). */
142
196
  select?: S;
143
197
  }
144
- interface CourseGetArgs<S extends Selection<Course> = typeof DEFAULT_GET_FIELDS> {
198
+ interface CourseGetArgs<S extends Selection<Course> = typeof DEFAULT_GET_FIELDS$2> {
145
199
  slug: string;
146
200
  select?: S;
147
201
  }
@@ -149,26 +203,126 @@ declare class CoursesResource {
149
203
  private client;
150
204
  constructor(client: LoopwiseClient);
151
205
  /**
152
- * List courses for the current tenant.
206
+ * List published courses.
153
207
  * @example
154
208
  * const { items, pageInfo } = await loopwise.courses.list({ limit: 20 })
155
209
  * @example
156
210
  * // Opt into GET + Cache-Control hint (60s):
157
211
  * await loopwise.courses.list({ limit: 20 }, { cache: 60 })
158
212
  */
159
- list<S extends Selection<Course> = typeof DEFAULT_LIST_FIELDS>(args?: CourseListArgs<S>, options?: FetchOptions): QueryBuilder<ListResult<Selected<Course, S>>>;
213
+ list<S extends Selection<Course> = typeof DEFAULT_LIST_FIELDS$1>(args?: CourseListArgs<S>, options?: FetchOptions): QueryBuilder<ListResult<Selected<Course, S>>>;
214
+ /**
215
+ * Get a single course by slug.
216
+ *
217
+ * Throws `LoopwiseError("GRAPHQL")` when the slug is not found
218
+ * (Storefront `Queries::Course` raises `service_error("Course-003")`
219
+ * on a `null: false` field). Wrap with try/catch to render 404 routes:
220
+ *
221
+ * @example
222
+ * try {
223
+ * const course = await loopwise.courses.get({ slug: 'finance' })
224
+ * // ...
225
+ * } catch (e) {
226
+ * if (e instanceof LoopwiseError && e.code === 'GRAPHQL') {
227
+ * return Astro.redirect('/404')
228
+ * }
229
+ * throw e
230
+ * }
231
+ */
232
+ get<S extends Selection<Course> = typeof DEFAULT_GET_FIELDS$2>(args: CourseGetArgs<S>, options?: FetchOptions): QueryBuilder<Selected<Course, S>>;
233
+ }
234
+ //#endregion
235
+ //#region src/resources/orders.d.ts
236
+ declare const DEFAULT_GET_FIELDS$1: Selection<Order>;
237
+ interface OrderGetArgs<S extends Selection<Order> = typeof DEFAULT_GET_FIELDS$1> {
238
+ /**
239
+ * Order identifier — the value passed via `?tradeNo=...` on the post-checkout
240
+ * redirect to the merchant's success URL. Internally equals
241
+ * `Payment#trade_no` server-side.
242
+ */
243
+ id: string;
244
+ select?: S;
245
+ }
246
+ declare class OrdersResource {
247
+ private client;
248
+ constructor(client: LoopwiseClient);
249
+ /**
250
+ * Look up a PII-safe order projection by id (the value the storefront
251
+ * appends to a merchant's `checkoutSuccessUrl` as `?tradeNo=...`).
252
+ *
253
+ * Returns `null` when the id doesn't resolve on this site — does NOT
254
+ * throw. Use the returned `status` to gate Meta Pixel `Purchase` events:
255
+ *
256
+ * @example
257
+ * const id = new URLSearchParams(location.search).get('tradeNo');
258
+ * if (!id) return;
259
+ * const order = await loopwise.orders.get({ id });
260
+ * if (order?.status === 'PAID') {
261
+ * fbq('track', 'Purchase', {
262
+ * value: order.total,
263
+ * currency: order.currency,
264
+ * contents: order.items.map(i => ({
265
+ * id: i.id, name: i.name, quantity: i.quantity, item_price: i.amount,
266
+ * })),
267
+ * content_ids: order.items.map(i => i.id),
268
+ * content_type: 'product',
269
+ * num_items: order.items.reduce((s, i) => s + i.quantity, 0),
270
+ * }, { eventID: order.id });
271
+ * }
272
+ */
273
+ get<S extends Selection<Order> = typeof DEFAULT_GET_FIELDS$1>(args: OrderGetArgs<S>, options?: FetchOptions): QueryBuilder<Selected<Order, S> | null>;
274
+ }
275
+ //#endregion
276
+ //#region src/resources/posts.d.ts
277
+ declare const DEFAULT_LIST_FIELDS: Selection<Post>;
278
+ declare const DEFAULT_GET_FIELDS: Selection<Post>;
279
+ interface PostListArgs<S extends Selection<Post> = typeof DEFAULT_LIST_FIELDS> {
280
+ limit?: number;
281
+ page?: number;
282
+ /** SDK `limit` maps to schema `perPage`. */
283
+ select?: S;
284
+ }
285
+ interface PostGetArgs<S extends Selection<Post> = typeof DEFAULT_GET_FIELDS> {
286
+ slug: string;
287
+ select?: S;
288
+ }
289
+ declare class PostsResource {
290
+ private client;
291
+ constructor(client: LoopwiseClient);
292
+ /**
293
+ * List published posts, ordered by publishedAt desc.
294
+ * @example
295
+ * const { items, pageInfo } = await loopwise.posts.list({ limit: 10 })
296
+ * @example
297
+ * await loopwise.posts.list({ limit: 10 }, { cache: 60 })
298
+ */
299
+ list<S extends Selection<Post> = typeof DEFAULT_LIST_FIELDS>(args?: PostListArgs<S>, options?: FetchOptions): QueryBuilder<ListResult<Selected<Post, S>>>;
160
300
  /**
161
- * Get a single course by slug. Returns `null` if not found.
301
+ * Get a single post by slug.
302
+ *
303
+ * Throws `LoopwiseError("GRAPHQL")` when the slug is not found
304
+ * (Storefront `Queries::Post` raises `service_error("Post-000")` on a
305
+ * `null: false` field). Wrap with try/catch to render 404 routes:
306
+ *
162
307
  * @example
163
- * const course = await loopwise.courses.get({ slug: 'finance' })
164
- * if (!course) return Astro.redirect('/404')
308
+ * try {
309
+ * const post = await loopwise.posts.get({ slug: 'hello-world' })
310
+ * // ...
311
+ * } catch (e) {
312
+ * if (e instanceof LoopwiseError && e.code === 'GRAPHQL') {
313
+ * return Astro.redirect('/404')
314
+ * }
315
+ * throw e
316
+ * }
165
317
  */
166
- get<S extends Selection<Course> = typeof DEFAULT_GET_FIELDS>(args: CourseGetArgs<S>, options?: FetchOptions): QueryBuilder<Selected<Course, S> | null>;
318
+ get<S extends Selection<Post> = typeof DEFAULT_GET_FIELDS>(args: PostGetArgs<S>, options?: FetchOptions): QueryBuilder<Selected<Post, S>>;
167
319
  }
168
320
  //#endregion
169
321
  //#region src/index.d.ts
170
322
  interface Loopwise {
171
323
  readonly courses: CoursesResource;
324
+ readonly orders: OrdersResource;
325
+ readonly posts: PostsResource;
172
326
  /**
173
327
  * Compose multiple resource queries into a single GraphQL HTTP request.
174
328
  * @example
@@ -181,13 +335,22 @@ interface Loopwise {
181
335
  /**
182
336
  * Raw GraphQL escape hatch. Use when a typed resource method doesn't cover
183
337
  * what you need (new schema fields, custom queries, ad-hoc inspection).
338
+ * Variables are sent as-is; errors are still mapped to LoopwiseError.
339
+ *
340
+ * Most storefront fields (`courses`, `posts`, `order`, ...) take a
341
+ * required `subdomain: String!` argument. The BFF rewrites that variable
342
+ * to the trusted server-derived value on every request, so consumers
343
+ * declaring `$subdomain` see whatever value the BFF binds — not what
344
+ * they pass.
345
+ *
184
346
  * @example
185
- * const data = await loopwise.query<{ school: { name: string } }>(`
186
- * query { school(subdomain: "acme") { name } }
347
+ * // Minimal shape demo (always valid, no scoping required):
348
+ * const data = await loopwise.query<{ __typename: string }>(`
349
+ * query { __typename }
187
350
  * `)
188
351
  */
189
352
  query<T = unknown>(document: string, variables?: Record<string, unknown>, operationName?: string, options?: FetchOptions): Promise<T>;
190
353
  }
191
354
  declare function createClient(config: LoopwiseConfig): Loopwise;
192
355
  //#endregion
193
- export { type Course, type FetchOptions, type Lecturer, type ListResult, Loopwise, type LoopwiseConfig, LoopwiseError, type PageInfo, QueryBuilder, type Selected, type Selection, createClient };
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 };
package/dist/index.mjs CHANGED
@@ -1,6 +1,9 @@
1
1
  //#region src/error.ts
2
2
  var LoopwiseError = class extends Error {
3
3
  code;
4
+ /** The site this client was configured for. */
5
+ site;
6
+ /** @deprecated Renamed to `site`. Holds the same resolved value. Removed in 0.2.0. */
4
7
  tenant;
5
8
  endpoint;
6
9
  graphqlErrors;
@@ -9,20 +12,20 @@ var LoopwiseError = class extends Error {
9
12
  super(message);
10
13
  this.name = "LoopwiseError";
11
14
  this.code = code;
12
- this.tenant = detail.tenant;
15
+ const site = detail.site ?? detail.tenant;
16
+ this.site = site;
17
+ this.tenant = site;
13
18
  this.endpoint = detail.endpoint;
14
19
  this.graphqlErrors = detail.graphqlErrors;
15
20
  this.requestId = detail.requestId;
16
21
  }
17
22
  };
18
-
19
23
  //#endregion
20
24
  //#region src/runtime.ts
21
25
  function detectRuntime() {
22
26
  if (typeof window !== "undefined" && typeof window.document !== "undefined") return "browser";
23
27
  return "ssr";
24
28
  }
25
-
26
29
  //#endregion
27
30
  //#region src/client.ts
28
31
  const GET_URL_MAX_LENGTH = 2e3;
@@ -31,20 +34,21 @@ var LoopwiseClient = class {
31
34
  config;
32
35
  endpoint;
33
36
  constructor(config) {
34
- const tenant = config.tenant ?? void 0;
35
- if (detectRuntime() !== "browser" && !tenant) throw new LoopwiseError("CONFIG", "tenant is required outside browser runtime. Pass it to createClient({ tenant }).");
37
+ const site = config.site ?? config.tenant;
38
+ if (detectRuntime() !== "browser" && !site) throw new LoopwiseError("CONFIG", "site is required outside browser runtime. Pass it to createClient({ site }).");
36
39
  this.config = {
37
40
  ...config,
38
- tenant
41
+ site,
42
+ tenant: site
39
43
  };
40
44
  this.endpoint = config.endpoint ?? this.defaultEndpoint();
41
45
  }
42
46
  defaultEndpoint() {
43
- const tenant = this.config.tenant;
44
- if (detectRuntime() === "browser" && !tenant) return "/api/graphql";
45
- const t = tenant;
46
- if (t.includes(".")) return `https://${t}/api/graphql`;
47
- return `https://${t}.kaik.io/api/graphql`;
47
+ const site = this.config.site;
48
+ if (detectRuntime() === "browser" && !site) return "/api/graphql";
49
+ const s = site;
50
+ if (s.includes(".")) return `https://${s}/api/graphql`;
51
+ return `https://${s}.kaik.io/api/graphql`;
48
52
  }
49
53
  async execute(descriptor, options = {}) {
50
54
  const document = renderSingleDocument(descriptor);
@@ -117,26 +121,26 @@ var LoopwiseClient = class {
117
121
  });
118
122
  } catch (err) {
119
123
  throw new LoopwiseError("NETWORK", `Network error: ${err.message}`, {
120
- tenant: this.config.tenant,
124
+ site: this.config.site,
121
125
  endpoint: this.endpoint
122
126
  });
123
127
  }
124
128
  if (response.status === 401 || response.status === 403) throw new LoopwiseError("AUTH", `Auth failed (${response.status})`, {
125
- tenant: this.config.tenant,
129
+ site: this.config.site,
126
130
  endpoint: this.endpoint,
127
131
  requestId: response.headers.get("x-request-id") ?? void 0
128
132
  });
129
133
  if (response.status === 429) throw new LoopwiseError("RATE_LIMIT", "Rate limited", {
130
- tenant: this.config.tenant,
134
+ site: this.config.site,
131
135
  endpoint: this.endpoint
132
136
  });
133
137
  if (!response.ok) throw new LoopwiseError("NETWORK", `Upstream ${response.status}`, {
134
- tenant: this.config.tenant,
138
+ site: this.config.site,
135
139
  endpoint: this.endpoint
136
140
  });
137
141
  const body = await response.json();
138
142
  if (body.errors?.length) throw new LoopwiseError("GRAPHQL", body.errors[0]?.message ?? "GraphQL error", {
139
- tenant: this.config.tenant,
143
+ site: this.config.site,
140
144
  endpoint: this.endpoint,
141
145
  graphqlErrors: body.errors
142
146
  });
@@ -234,7 +238,6 @@ function literal(value) {
234
238
  if (typeof value === "object") return `{ ${Object.entries(value).filter(([, v]) => v !== void 0).map(([k, v]) => `${k}: ${literal(v)}`).join(", ")} }`;
235
239
  throw new LoopwiseError("CONFIG", `Unsupported argument type for GraphQL literal: ${typeof value}`);
236
240
  }
237
-
238
241
  //#endregion
239
242
  //#region src/query.ts
240
243
  var QueryBuilder = class {
@@ -250,7 +253,6 @@ var QueryBuilder = class {
250
253
  return this.fetcher.execute(this.descriptor, this.fetchOptions).then(onFulfilled, onRejected);
251
254
  }
252
255
  };
253
-
254
256
  //#endregion
255
257
  //#region src/schema/courses.ts
256
258
  /** Map SDK Course selection -> schema field selection set. */
@@ -300,16 +302,15 @@ function mapCoursePage(raw) {
300
302
  }
301
303
  };
302
304
  }
303
-
304
305
  //#endregion
305
306
  //#region src/resources/courses.ts
306
- const DEFAULT_LIST_FIELDS = {
307
+ const DEFAULT_LIST_FIELDS$1 = {
307
308
  id: true,
308
309
  slug: true,
309
310
  title: true,
310
311
  coverImage: true
311
312
  };
312
- const DEFAULT_GET_FIELDS = {
313
+ const DEFAULT_GET_FIELDS$2 = {
313
314
  id: true,
314
315
  slug: true,
315
316
  title: true,
@@ -321,18 +322,19 @@ const DEFAULT_GET_FIELDS = {
321
322
  avatar: true
322
323
  }
323
324
  };
324
- function tenantVars(client) {
325
+ function tenantVars$2(client) {
325
326
  return { subdomain: {
326
327
  type: "String!",
327
- value: client.config.tenant ?? ""
328
+ value: client.config.site ?? ""
328
329
  } };
329
330
  }
330
331
  var CoursesResource = class {
332
+ client;
331
333
  constructor(client) {
332
334
  this.client = client;
333
335
  }
334
336
  /**
335
- * List courses for the current tenant.
337
+ * List published courses.
336
338
  * @example
337
339
  * const { items, pageInfo } = await loopwise.courses.list({ limit: 20 })
338
340
  * @example
@@ -340,7 +342,7 @@ var CoursesResource = class {
340
342
  * await loopwise.courses.list({ limit: 20 }, { cache: 60 })
341
343
  */
342
344
  list(args = {}, options = {}) {
343
- const selectionSet = `nodes { ${buildCourseSelection(args.select ?? DEFAULT_LIST_FIELDS)} } currentPage totalPages nodesCount hasNextPage hasPreviousPage`;
345
+ const selectionSet = `nodes { ${buildCourseSelection(args.select ?? DEFAULT_LIST_FIELDS$1)} } currentPage totalPages nodesCount hasNextPage hasPreviousPage`;
344
346
  return new QueryBuilder(this.client, {
345
347
  operation: "CourseList",
346
348
  field: "courses",
@@ -349,35 +351,290 @@ var CoursesResource = class {
349
351
  page: args.page,
350
352
  perPage: args.limit
351
353
  },
352
- vars: tenantVars(this.client),
354
+ vars: tenantVars$2(this.client),
353
355
  transform: (raw) => mapCoursePage(raw)
354
356
  }, options);
355
357
  }
356
358
  /**
357
- * Get a single course by slug. Returns `null` if not found.
359
+ * Get a single course by slug.
360
+ *
361
+ * Throws `LoopwiseError("GRAPHQL")` when the slug is not found
362
+ * (Storefront `Queries::Course` raises `service_error("Course-003")`
363
+ * on a `null: false` field). Wrap with try/catch to render 404 routes:
364
+ *
358
365
  * @example
359
- * const course = await loopwise.courses.get({ slug: 'finance' })
360
- * if (!course) return Astro.redirect('/404')
366
+ * try {
367
+ * const course = await loopwise.courses.get({ slug: 'finance' })
368
+ * // ...
369
+ * } catch (e) {
370
+ * if (e instanceof LoopwiseError && e.code === 'GRAPHQL') {
371
+ * return Astro.redirect('/404')
372
+ * }
373
+ * throw e
374
+ * }
361
375
  */
362
376
  get(args, options = {}) {
363
- const sel = args.select ?? DEFAULT_GET_FIELDS;
377
+ const sel = args.select ?? DEFAULT_GET_FIELDS$2;
364
378
  return new QueryBuilder(this.client, {
365
379
  operation: "CourseGet",
366
380
  field: "course",
367
381
  selectionSet: buildCourseSelection(sel),
368
382
  args: { slug: args.slug },
383
+ vars: tenantVars$2(this.client),
384
+ transform: (raw) => mapCourse(raw)
385
+ }, options);
386
+ }
387
+ };
388
+ //#endregion
389
+ //#region src/schema/orders.ts
390
+ /** Map SDK Order selection -> schema field selection set. */
391
+ function buildOrderSelection(select) {
392
+ const parts = ["id"];
393
+ const sel = select;
394
+ if (sel.status) parts.push("status");
395
+ if (sel.total) parts.push("total");
396
+ if (sel.currency) parts.push("currency");
397
+ if (sel.createdAt) parts.push("createdAt");
398
+ if (sel.items && typeof sel.items === "object") {
399
+ const itemSel = sel.items;
400
+ const itemParts = ["id"];
401
+ if (itemSel.name) itemParts.push("name");
402
+ if (itemSel.quantity) itemParts.push("quantity");
403
+ if (itemSel.amount) itemParts.push("amount");
404
+ parts.push(`items { ${itemParts.join(" ")} }`);
405
+ }
406
+ return parts.join(" ");
407
+ }
408
+ function mapOrderItem(raw) {
409
+ const out = { id: raw.id };
410
+ if (raw.name !== void 0) out.name = raw.name ?? null;
411
+ if (raw.quantity !== void 0) out.quantity = raw.quantity;
412
+ if (raw.amount !== void 0) out.amount = raw.amount;
413
+ return out;
414
+ }
415
+ function mapOrder(raw) {
416
+ const out = { id: raw.id };
417
+ if (raw.status !== void 0) out.status = raw.status;
418
+ if (raw.total !== void 0) out.total = raw.total;
419
+ if (raw.currency !== void 0) out.currency = raw.currency;
420
+ if (raw.createdAt !== void 0) out.createdAt = raw.createdAt;
421
+ if (raw.items !== void 0) out.items = raw.items.map(mapOrderItem);
422
+ return out;
423
+ }
424
+ //#endregion
425
+ //#region src/resources/orders.ts
426
+ const DEFAULT_GET_FIELDS$1 = {
427
+ id: true,
428
+ status: true,
429
+ total: true,
430
+ currency: true,
431
+ createdAt: true,
432
+ items: {
433
+ id: true,
434
+ name: true,
435
+ quantity: true,
436
+ amount: true
437
+ }
438
+ };
439
+ function tenantVars$1(client) {
440
+ return { subdomain: {
441
+ type: "String!",
442
+ value: client.config.site ?? ""
443
+ } };
444
+ }
445
+ var OrdersResource = class {
446
+ client;
447
+ constructor(client) {
448
+ this.client = client;
449
+ }
450
+ /**
451
+ * Look up a PII-safe order projection by id (the value the storefront
452
+ * appends to a merchant's `checkoutSuccessUrl` as `?tradeNo=...`).
453
+ *
454
+ * Returns `null` when the id doesn't resolve on this site — does NOT
455
+ * throw. Use the returned `status` to gate Meta Pixel `Purchase` events:
456
+ *
457
+ * @example
458
+ * const id = new URLSearchParams(location.search).get('tradeNo');
459
+ * if (!id) return;
460
+ * const order = await loopwise.orders.get({ id });
461
+ * if (order?.status === 'PAID') {
462
+ * fbq('track', 'Purchase', {
463
+ * value: order.total,
464
+ * currency: order.currency,
465
+ * contents: order.items.map(i => ({
466
+ * id: i.id, name: i.name, quantity: i.quantity, item_price: i.amount,
467
+ * })),
468
+ * content_ids: order.items.map(i => i.id),
469
+ * content_type: 'product',
470
+ * num_items: order.items.reduce((s, i) => s + i.quantity, 0),
471
+ * }, { eventID: order.id });
472
+ * }
473
+ */
474
+ get(args, options = {}) {
475
+ const sel = args.select ?? DEFAULT_GET_FIELDS$1;
476
+ return new QueryBuilder(this.client, {
477
+ operation: "OrderGet",
478
+ field: "order",
479
+ selectionSet: buildOrderSelection(sel),
480
+ args: { id: args.id },
481
+ vars: tenantVars$1(this.client),
482
+ transform: (raw) => raw == null ? null : mapOrder(raw)
483
+ }, options);
484
+ }
485
+ };
486
+ //#endregion
487
+ //#region src/schema/posts.ts
488
+ /** Map SDK Post selection -> schema field selection set. */
489
+ function buildPostSelection(select) {
490
+ const parts = ["id", "slug"];
491
+ const sel = select;
492
+ if (sel.title) parts.push("title");
493
+ if (sel.subtitle) parts.push("subtitle");
494
+ if (sel.excerpt) parts.push("excerpt");
495
+ if (sel.body) parts.push("body");
496
+ if (sel.coverImage) parts.push("coverPhoto");
497
+ if (sel.publishedAt) parts.push("publishedAt");
498
+ if (sel.modifiedAt) parts.push("modifiedAt");
499
+ if (sel.accessType) parts.push("accessType");
500
+ if (sel.tags) parts.push("tags");
501
+ if (sel.noindex) parts.push("noindex");
502
+ if (sel.author && typeof sel.author === "object") {
503
+ const auth = sel.author;
504
+ const authParts = ["id"];
505
+ if (auth.name) authParts.push("name");
506
+ if (auth.avatar) authParts.push("avatar");
507
+ parts.push(`author { ${authParts.join(" ")} }`);
508
+ }
509
+ return parts.join(" ");
510
+ }
511
+ function mapPost(raw) {
512
+ const out = {
513
+ id: raw.id,
514
+ slug: raw.slug
515
+ };
516
+ if (raw.title !== void 0) out.title = raw.title;
517
+ if (raw.subtitle !== void 0) out.subtitle = raw.subtitle ?? null;
518
+ if (raw.excerpt !== void 0) out.excerpt = raw.excerpt ?? null;
519
+ if (raw.body !== void 0) out.body = raw.body;
520
+ if (raw.coverPhoto !== void 0) out.coverImage = raw.coverPhoto ?? null;
521
+ if (raw.publishedAt !== void 0) out.publishedAt = raw.publishedAt;
522
+ if (raw.modifiedAt !== void 0) out.modifiedAt = raw.modifiedAt ?? null;
523
+ if (raw.accessType !== void 0) out.accessType = raw.accessType;
524
+ if (raw.tags !== void 0) out.tags = raw.tags ?? null;
525
+ if (raw.noindex !== void 0) out.noindex = raw.noindex;
526
+ if (raw.author !== void 0) out.author = raw.author ? mapLecturer(raw.author) : null;
527
+ return out;
528
+ }
529
+ function mapPostPage(raw) {
530
+ return {
531
+ items: raw.nodes.map(mapPost),
532
+ pageInfo: {
533
+ page: raw.currentPage,
534
+ totalPages: raw.totalPages,
535
+ totalCount: raw.nodesCount,
536
+ hasNext: raw.hasNextPage,
537
+ hasPrev: raw.hasPreviousPage
538
+ }
539
+ };
540
+ }
541
+ //#endregion
542
+ //#region src/resources/posts.ts
543
+ const DEFAULT_LIST_FIELDS = {
544
+ id: true,
545
+ slug: true,
546
+ title: true,
547
+ excerpt: true,
548
+ coverImage: true,
549
+ publishedAt: true
550
+ };
551
+ const DEFAULT_GET_FIELDS = {
552
+ id: true,
553
+ slug: true,
554
+ title: true,
555
+ subtitle: true,
556
+ excerpt: true,
557
+ body: true,
558
+ coverImage: true,
559
+ publishedAt: true,
560
+ modifiedAt: true,
561
+ accessType: true,
562
+ author: {
563
+ id: true,
564
+ name: true,
565
+ avatar: true
566
+ }
567
+ };
568
+ function tenantVars(client) {
569
+ return { subdomain: {
570
+ type: "String!",
571
+ value: client.config.site ?? ""
572
+ } };
573
+ }
574
+ var PostsResource = class {
575
+ client;
576
+ constructor(client) {
577
+ this.client = client;
578
+ }
579
+ /**
580
+ * List published posts, ordered by publishedAt desc.
581
+ * @example
582
+ * const { items, pageInfo } = await loopwise.posts.list({ limit: 10 })
583
+ * @example
584
+ * await loopwise.posts.list({ limit: 10 }, { cache: 60 })
585
+ */
586
+ list(args = {}, options = {}) {
587
+ const selectionSet = `nodes { ${buildPostSelection(args.select ?? DEFAULT_LIST_FIELDS)} } currentPage totalPages nodesCount hasNextPage hasPreviousPage`;
588
+ return new QueryBuilder(this.client, {
589
+ operation: "PostList",
590
+ field: "posts",
591
+ selectionSet,
592
+ args: {
593
+ page: args.page,
594
+ perPage: args.limit
595
+ },
596
+ vars: tenantVars(this.client),
597
+ transform: (raw) => mapPostPage(raw)
598
+ }, options);
599
+ }
600
+ /**
601
+ * Get a single post by slug.
602
+ *
603
+ * Throws `LoopwiseError("GRAPHQL")` when the slug is not found
604
+ * (Storefront `Queries::Post` raises `service_error("Post-000")` on a
605
+ * `null: false` field). Wrap with try/catch to render 404 routes:
606
+ *
607
+ * @example
608
+ * try {
609
+ * const post = await loopwise.posts.get({ slug: 'hello-world' })
610
+ * // ...
611
+ * } catch (e) {
612
+ * if (e instanceof LoopwiseError && e.code === 'GRAPHQL') {
613
+ * return Astro.redirect('/404')
614
+ * }
615
+ * throw e
616
+ * }
617
+ */
618
+ get(args, options = {}) {
619
+ const sel = args.select ?? DEFAULT_GET_FIELDS;
620
+ return new QueryBuilder(this.client, {
621
+ operation: "PostGet",
622
+ field: "post",
623
+ selectionSet: buildPostSelection(sel),
624
+ args: { slug: args.slug },
369
625
  vars: tenantVars(this.client),
370
- transform: (raw) => raw == null ? null : mapCourse(raw)
626
+ transform: (raw) => mapPost(raw)
371
627
  }, options);
372
628
  }
373
629
  };
374
-
375
630
  //#endregion
376
631
  //#region src/index.ts
377
632
  function createClient(config) {
378
633
  const client = new LoopwiseClient(config);
379
634
  return {
380
635
  courses: new CoursesResource(client),
636
+ orders: new OrdersResource(client),
637
+ posts: new PostsResource(client),
381
638
  batch(builders, options) {
382
639
  return client.batch(builders, options);
383
640
  },
@@ -386,6 +643,5 @@ function createClient(config) {
386
643
  }
387
644
  };
388
645
  }
389
-
390
646
  //#endregion
391
- export { LoopwiseError, QueryBuilder, createClient };
647
+ export { LoopwiseError, QueryBuilder, createClient };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loopwise/sdk",
3
- "version": "0.1.0-beta.1",
3
+ "version": "0.1.0-beta.3",
4
4
  "description": "Loopwise platform SDK — agent-friendly headless API client",
5
5
  "repository": {
6
6
  "type": "git",
@@ -37,17 +37,20 @@
37
37
  "files": [
38
38
  "dist/*.mjs",
39
39
  "dist/*.d.mts",
40
- "README.md"
40
+ "README.md",
41
+ "CHANGELOG.md"
41
42
  ],
42
43
  "scripts": {
43
44
  "build": "tsdown",
44
45
  "dev": "tsdown --watch",
45
46
  "typecheck": "tsc --noEmit",
46
- "test": "vitest run"
47
+ "test": "vitest run",
48
+ "regen:lock": "node scripts/regen-lock.mjs"
47
49
  },
48
50
  "devDependencies": {
51
+ "graphql": "16.13.1",
49
52
  "tsconfig": "workspace:*",
50
- "tsdown": "0.20.3",
53
+ "tsdown": "0.22.0",
51
54
  "typescript": "5.9.3",
52
55
  "vitest": "4.1.0"
53
56
  },