@loopwise/sdk 0.1.0-beta.0 → 0.1.0-beta.2

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/README.md CHANGED
@@ -1,37 +1,78 @@
1
1
  # @loopwise/sdk
2
2
 
3
- Storefront GraphQL client for Loopwise / Teachify. The SDK has no concept of
4
- "tenant" in its public surface — calls hit the same-origin BFF at
5
- `/api/graphql`, which derives the tenant from the request host and binds it
6
- to the response on the trusted server side.
3
+ Storefront GraphQL client for Loopwise / Teachify. Pick your path:
7
4
 
8
- ## Quick start
5
+ | You are producing… | Install | Module spec |
6
+ |---|---|---|
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
+ | 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)) | — |
10
+
11
+ Same package powers all three; the difference is the loader.
12
+
13
+ ## Quick start — bundler
9
14
 
10
15
  ```ts
11
16
  import { createClient } from '@loopwise/sdk';
12
17
 
18
+ // Browser, same-origin (the page is served from the tenant origin):
13
19
  const loopwise = createClient({});
14
20
  const { items } = await loopwise.courses.list({ limit: 20 });
15
21
  ```
16
22
 
17
- That's the entire happy path. Whether the page is on `acme.kaik.io`,
18
- `acme.myteachify.com`, or `nii.school`, the BFF on each origin already knows
19
- which tenant it's serving — the SDK just talks to its own origin.
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.
27
+
28
+ ## Quick start — single-file HTML (no build)
29
+
30
+ ```html
31
+ <!doctype html>
32
+ <script type="module">
33
+ import { createClient } from 'https://esm.sh/@loopwise/sdk@beta';
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.
37
+ const loopwise = createClient({});
38
+ const { items } = await loopwise.courses.list({ limit: 20 });
39
+ document.body.textContent = items.map(c => c.title).join(', ');
40
+ </script>
41
+ ```
20
42
 
21
- ### Cross-origin / SSR (advanced)
43
+ ## Cross-origin (Loopwise Pages preview, SSR, build pipelines)
22
44
 
23
- When the SDK runs outside a tenant origin (build pipeline, third-party site,
24
- SSR worker), pass `tenant` explicitly so the SDK knows where to send the
25
- request. An `apiKey` is required outside the browser to bypass cookie auth.
45
+ When the SDK runs **outside** a tenant origin, pass `tenant` explicitly so
46
+ the SDK can pick the right Rails origin to call.
26
47
 
27
48
  ```ts
28
49
  const loopwise = createClient({
29
- tenant: 'acme',
30
- apiKey: process.env.LOOPWISE_API_KEY, // SSR / build only
50
+ tenant: 'acme', // required cross-origin
51
+ apiKey: process.env.LOOPWISE_API_KEY, // required in SSR / build (browser ignores)
31
52
  });
32
53
  ```
33
54
 
34
- ## Cache option (`cache`)
55
+ ### Where this matters most: `deploy_static_page`
56
+
57
+ Pages deployed via `deploy_static_page` live at `https://<script>.loopwise.page/`
58
+ — that is **not** a tenant origin, so:
59
+
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.
65
+
66
+ ```ts
67
+ // Inside an HTML bundle deployed to https://lw-acme-home.loopwise.page/
68
+ const loopwise = createClient({ tenant: 'acme' });
69
+ const { items } = await loopwise.courses.list({ limit: 20 }, { cache: 60 });
70
+ ```
71
+
72
+ For preview UX that needs authenticated state, see
73
+ [Preview modes](#preview-modes) below.
74
+
75
+ ## `cache` option
35
76
 
36
77
  Read-only resource calls accept an opt-in `cache` option that flips the
37
78
  request to a GraphQL-over-HTTP `GET` and asks the BFF to apply a
@@ -79,12 +120,46 @@ is the explicit, type-system-visible declaration of "this must never be cached".
79
120
  per Vercel POP, depending on traffic. Use shorter TTLs for time-sensitive
80
121
  content; use `cache: 'no-store'` for admin previews.
81
122
 
123
+ ## Preview modes
124
+
125
+ Two preview surfaces exist, each with different capability:
126
+
127
+ | Surface | Origin | Same-origin BFF? | Authenticated viewer? | Use for |
128
+ |---|---|---|---|---|
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 |
131
+
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).
136
+
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).
143
+
144
+ ## React — coming soon
145
+
146
+ A `@loopwise/sdk-react` companion package is planned, providing:
147
+
148
+ - `<LoopwiseProvider client={loopwise}>` context provider
149
+ - `useCourses({ limit })`, `useCourse({ slug })` hooks with Suspense support
150
+ - `useBatch(...)` for composed queries
151
+ - Cache and refetch control matching the underlying SDK's `cache` option
152
+
153
+ Until it ships, the vanilla SDK works inside React — you'll just wire up
154
+ your own state (`useEffect` + `useState`, TanStack Query, etc.).
155
+
82
156
  ## Public API
83
157
 
84
- - `createClient(config)` — storefront client. `config.tenant` is optional
85
- in the browser (BFF derives it from host) and required in SSR / build.
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.
86
161
  - `loopwise.courses.list(args, options?)` / `.get(args, options?)`.
87
162
  - `loopwise.batch(builders, options?)` — composes N queries into one HTTP request.
88
- - `loopwise.query<T>(document, variables?, operationName?, options?)` — raw GraphQL.
163
+ - `loopwise.query<T>(document, variables?, operationName?, options?)` — raw GraphQL escape hatch.
89
164
 
90
165
  See `apps/sdk-runtime-poc/` for a runnable browser validation page.
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
@@ -119,6 +119,34 @@ interface Lecturer {
119
119
  name: string;
120
120
  avatar: string | null;
121
121
  }
122
+ interface Post {
123
+ id: string;
124
+ slug: string;
125
+ title: string;
126
+ subtitle: string | null;
127
+ excerpt: string | null;
128
+ /** Full body HTML. Truncated server-side when access is gated and the
129
+ * viewer doesn't have permission (login_required without auth, or paid
130
+ * without an active subscription). */
131
+ body: string;
132
+ /** Maps to schema `Post.cover_photo`. */
133
+ coverImage: string | null;
134
+ /** Unix timestamp (seconds since epoch). */
135
+ publishedAt: number;
136
+ /** Unix timestamp of the last update — schema sources this from the
137
+ * post's `updated_at`, so for any persisted record this is populated
138
+ * (typically equal to or after `publishedAt`). The `null` is included
139
+ * for forward-compat with a hypothetical "never modified" sentinel
140
+ * but isn't observed in current production data. */
141
+ modifiedAt: number | null;
142
+ /** Known values: `login_required`, `paid`, `public_access`. The
143
+ * `string & {}` escape hatch keeps autocomplete on the known set
144
+ * while accepting forward-compat values the schema may add. */
145
+ accessType: 'login_required' | 'paid' | 'public_access' | (string & {});
146
+ tags: string[] | null;
147
+ noindex: boolean;
148
+ author: Lecturer | null;
149
+ }
122
150
  interface PageInfo {
123
151
  page: number;
124
152
  totalPages: number;
@@ -130,18 +158,42 @@ interface ListResult<T> {
130
158
  items: T[];
131
159
  pageInfo: PageInfo;
132
160
  }
161
+ /** Simplified order status. Mirrors the storefront `Order.status` enum. */
162
+ type OrderStatus = 'PAID' | 'PENDING' | 'FAILED' | 'REFUNDED';
163
+ interface OrderItem {
164
+ /**
165
+ * Meta-pixel-aligned identifier (Lineitem#tracking_id server-side).
166
+ * Use directly in `content_ids` / `contents[].id` for Purchase events.
167
+ */
168
+ id: string;
169
+ name: string | null;
170
+ quantity: number;
171
+ /** Per-unit price in the order currency. Maps to FB Pixel `contents[].item_price`. */
172
+ amount: number;
173
+ }
174
+ interface Order {
175
+ /** Stable order identifier — currently equals `Payment#trade_no` server-side. */
176
+ id: string;
177
+ status: OrderStatus;
178
+ /** Total paid amount in the order currency. Maps to FB Pixel `value`. */
179
+ total: number;
180
+ currency: string;
181
+ items: OrderItem[];
182
+ /** Unix timestamp (seconds since epoch). */
183
+ createdAt: number;
184
+ }
133
185
  //#endregion
134
186
  //#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> {
187
+ declare const DEFAULT_LIST_FIELDS$1: Selection<Course>;
188
+ declare const DEFAULT_GET_FIELDS$2: Selection<Course>;
189
+ interface CourseListArgs<S extends Selection<Course> = typeof DEFAULT_LIST_FIELDS$1> {
138
190
  limit?: number;
139
191
  page?: number;
140
192
  /** SDK `limit` maps to schema `perPage`. Schema also has its own `limit`
141
193
  * which we expose as `topN` if needed (not in v0). */
142
194
  select?: S;
143
195
  }
144
- interface CourseGetArgs<S extends Selection<Course> = typeof DEFAULT_GET_FIELDS> {
196
+ interface CourseGetArgs<S extends Selection<Course> = typeof DEFAULT_GET_FIELDS$2> {
145
197
  slug: string;
146
198
  select?: S;
147
199
  }
@@ -156,19 +208,120 @@ declare class CoursesResource {
156
208
  * // Opt into GET + Cache-Control hint (60s):
157
209
  * await loopwise.courses.list({ limit: 20 }, { cache: 60 })
158
210
  */
159
- list<S extends Selection<Course> = typeof DEFAULT_LIST_FIELDS>(args?: CourseListArgs<S>, options?: FetchOptions): QueryBuilder<ListResult<Selected<Course, S>>>;
211
+ list<S extends Selection<Course> = typeof DEFAULT_LIST_FIELDS$1>(args?: CourseListArgs<S>, options?: FetchOptions): QueryBuilder<ListResult<Selected<Course, S>>>;
212
+ /**
213
+ * Get a single course by slug.
214
+ *
215
+ * Throws `LoopwiseError("GRAPHQL")` when the slug is not found
216
+ * (Storefront `Queries::Course` raises `service_error("Course-003")`
217
+ * on a `null: false` field). Wrap with try/catch to render 404 routes:
218
+ *
219
+ * @example
220
+ * try {
221
+ * const course = await loopwise.courses.get({ slug: 'finance' })
222
+ * // ...
223
+ * } catch (e) {
224
+ * if (e instanceof LoopwiseError && e.code === 'GRAPHQL') {
225
+ * return Astro.redirect('/404')
226
+ * }
227
+ * throw e
228
+ * }
229
+ */
230
+ get<S extends Selection<Course> = typeof DEFAULT_GET_FIELDS$2>(args: CourseGetArgs<S>, options?: FetchOptions): QueryBuilder<Selected<Course, S>>;
231
+ }
232
+ //#endregion
233
+ //#region src/resources/orders.d.ts
234
+ declare const DEFAULT_GET_FIELDS$1: Selection<Order>;
235
+ interface OrderGetArgs<S extends Selection<Order> = typeof DEFAULT_GET_FIELDS$1> {
236
+ /**
237
+ * Order identifier — the value passed via `?tradeNo=...` on the post-checkout
238
+ * redirect to the merchant's success URL. Internally equals
239
+ * `Payment#trade_no` server-side.
240
+ */
241
+ id: string;
242
+ select?: S;
243
+ }
244
+ declare class OrdersResource {
245
+ private client;
246
+ constructor(client: LoopwiseClient);
247
+ /**
248
+ * Look up a PII-safe order projection by id (the value the storefront
249
+ * appends to a merchant's `checkoutSuccessUrl` as `?tradeNo=...`).
250
+ *
251
+ * Returns `null`-shaped result via the GraphQL `null` payload when the
252
+ * id doesn't resolve in the current tenant — does NOT throw. Use the
253
+ * returned `status` to gate Meta Pixel `Purchase` events:
254
+ *
255
+ * @example
256
+ * const id = new URLSearchParams(location.search).get('tradeNo');
257
+ * if (!id) return;
258
+ * const order = await loopwise.orders.get({ id });
259
+ * if (order?.status === 'PAID') {
260
+ * fbq('track', 'Purchase', {
261
+ * value: order.total,
262
+ * currency: order.currency,
263
+ * contents: order.items.map(i => ({
264
+ * id: i.id, name: i.name, quantity: i.quantity, item_price: i.amount,
265
+ * })),
266
+ * content_ids: order.items.map(i => i.id),
267
+ * content_type: 'product',
268
+ * num_items: order.items.reduce((s, i) => s + i.quantity, 0),
269
+ * }, { eventID: order.id });
270
+ * }
271
+ */
272
+ get<S extends Selection<Order> = typeof DEFAULT_GET_FIELDS$1>(args: OrderGetArgs<S>, options?: FetchOptions): QueryBuilder<Selected<Order, S> | null>;
273
+ }
274
+ //#endregion
275
+ //#region src/resources/posts.d.ts
276
+ declare const DEFAULT_LIST_FIELDS: Selection<Post>;
277
+ declare const DEFAULT_GET_FIELDS: Selection<Post>;
278
+ interface PostListArgs<S extends Selection<Post> = typeof DEFAULT_LIST_FIELDS> {
279
+ limit?: number;
280
+ page?: number;
281
+ /** SDK `limit` maps to schema `perPage`. */
282
+ select?: S;
283
+ }
284
+ interface PostGetArgs<S extends Selection<Post> = typeof DEFAULT_GET_FIELDS> {
285
+ slug: string;
286
+ select?: S;
287
+ }
288
+ declare class PostsResource {
289
+ private client;
290
+ constructor(client: LoopwiseClient);
291
+ /**
292
+ * List published posts for the current tenant, ordered by publishedAt desc.
293
+ * @example
294
+ * const { items, pageInfo } = await loopwise.posts.list({ limit: 10 })
295
+ * @example
296
+ * await loopwise.posts.list({ limit: 10 }, { cache: 60 })
297
+ */
298
+ list<S extends Selection<Post> = typeof DEFAULT_LIST_FIELDS>(args?: PostListArgs<S>, options?: FetchOptions): QueryBuilder<ListResult<Selected<Post, S>>>;
160
299
  /**
161
- * Get a single course by slug. Returns `null` if not found.
300
+ * Get a single post by slug.
301
+ *
302
+ * Throws `LoopwiseError("GRAPHQL")` when the slug is not found
303
+ * (Storefront `Queries::Post` raises `service_error("Post-000")` on a
304
+ * `null: false` field). Wrap with try/catch to render 404 routes:
305
+ *
162
306
  * @example
163
- * const course = await loopwise.courses.get({ slug: 'finance' })
164
- * if (!course) return Astro.redirect('/404')
307
+ * try {
308
+ * const post = await loopwise.posts.get({ slug: 'hello-world' })
309
+ * // ...
310
+ * } catch (e) {
311
+ * if (e instanceof LoopwiseError && e.code === 'GRAPHQL') {
312
+ * return Astro.redirect('/404')
313
+ * }
314
+ * throw e
315
+ * }
165
316
  */
166
- get<S extends Selection<Course> = typeof DEFAULT_GET_FIELDS>(args: CourseGetArgs<S>, options?: FetchOptions): QueryBuilder<Selected<Course, S> | null>;
317
+ get<S extends Selection<Post> = typeof DEFAULT_GET_FIELDS>(args: PostGetArgs<S>, options?: FetchOptions): QueryBuilder<Selected<Post, S>>;
167
318
  }
168
319
  //#endregion
169
320
  //#region src/index.d.ts
170
321
  interface Loopwise {
171
322
  readonly courses: CoursesResource;
323
+ readonly orders: OrdersResource;
324
+ readonly posts: PostsResource;
172
325
  /**
173
326
  * Compose multiple resource queries into a single GraphQL HTTP request.
174
327
  * @example
@@ -190,4 +343,4 @@ interface Loopwise {
190
343
  }
191
344
  declare function createClient(config: LoopwiseConfig): Loopwise;
192
345
  //#endregion
193
- export { type Course, type FetchOptions, type Lecturer, type ListResult, Loopwise, type LoopwiseConfig, LoopwiseError, type PageInfo, QueryBuilder, type Selected, type Selection, createClient };
346
+ 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
@@ -15,14 +15,12 @@ var LoopwiseError = class extends Error {
15
15
  this.requestId = detail.requestId;
16
16
  }
17
17
  };
18
-
19
18
  //#endregion
20
19
  //#region src/runtime.ts
21
20
  function detectRuntime() {
22
21
  if (typeof window !== "undefined" && typeof window.document !== "undefined") return "browser";
23
22
  return "ssr";
24
23
  }
25
-
26
24
  //#endregion
27
25
  //#region src/client.ts
28
26
  const GET_URL_MAX_LENGTH = 2e3;
@@ -40,10 +38,11 @@ var LoopwiseClient = class {
40
38
  this.endpoint = config.endpoint ?? this.defaultEndpoint();
41
39
  }
42
40
  defaultEndpoint() {
43
- if (detectRuntime() === "browser") return "/api/graphql";
44
41
  const tenant = this.config.tenant;
45
- if (tenant.includes(".")) return `https://${tenant}/api/graphql`;
46
- return `https://${tenant}.kaik.io/api/graphql`;
42
+ if (detectRuntime() === "browser" && !tenant) return "/api/graphql";
43
+ const t = tenant;
44
+ if (t.includes(".")) return `https://${t}/api/graphql`;
45
+ return `https://${t}.kaik.io/api/graphql`;
47
46
  }
48
47
  async execute(descriptor, options = {}) {
49
48
  const document = renderSingleDocument(descriptor);
@@ -233,7 +232,6 @@ function literal(value) {
233
232
  if (typeof value === "object") return `{ ${Object.entries(value).filter(([, v]) => v !== void 0).map(([k, v]) => `${k}: ${literal(v)}`).join(", ")} }`;
234
233
  throw new LoopwiseError("CONFIG", `Unsupported argument type for GraphQL literal: ${typeof value}`);
235
234
  }
236
-
237
235
  //#endregion
238
236
  //#region src/query.ts
239
237
  var QueryBuilder = class {
@@ -249,7 +247,6 @@ var QueryBuilder = class {
249
247
  return this.fetcher.execute(this.descriptor, this.fetchOptions).then(onFulfilled, onRejected);
250
248
  }
251
249
  };
252
-
253
250
  //#endregion
254
251
  //#region src/schema/courses.ts
255
252
  /** Map SDK Course selection -> schema field selection set. */
@@ -299,16 +296,15 @@ function mapCoursePage(raw) {
299
296
  }
300
297
  };
301
298
  }
302
-
303
299
  //#endregion
304
300
  //#region src/resources/courses.ts
305
- const DEFAULT_LIST_FIELDS = {
301
+ const DEFAULT_LIST_FIELDS$1 = {
306
302
  id: true,
307
303
  slug: true,
308
304
  title: true,
309
305
  coverImage: true
310
306
  };
311
- const DEFAULT_GET_FIELDS = {
307
+ const DEFAULT_GET_FIELDS$2 = {
312
308
  id: true,
313
309
  slug: true,
314
310
  title: true,
@@ -320,13 +316,14 @@ const DEFAULT_GET_FIELDS = {
320
316
  avatar: true
321
317
  }
322
318
  };
323
- function tenantVars(client) {
319
+ function tenantVars$2(client) {
324
320
  return { subdomain: {
325
321
  type: "String!",
326
322
  value: client.config.tenant ?? ""
327
323
  } };
328
324
  }
329
325
  var CoursesResource = class {
326
+ client;
330
327
  constructor(client) {
331
328
  this.client = client;
332
329
  }
@@ -339,7 +336,7 @@ var CoursesResource = class {
339
336
  * await loopwise.courses.list({ limit: 20 }, { cache: 60 })
340
337
  */
341
338
  list(args = {}, options = {}) {
342
- const selectionSet = `nodes { ${buildCourseSelection(args.select ?? DEFAULT_LIST_FIELDS)} } currentPage totalPages nodesCount hasNextPage hasPreviousPage`;
339
+ const selectionSet = `nodes { ${buildCourseSelection(args.select ?? DEFAULT_LIST_FIELDS$1)} } currentPage totalPages nodesCount hasNextPage hasPreviousPage`;
343
340
  return new QueryBuilder(this.client, {
344
341
  operation: "CourseList",
345
342
  field: "courses",
@@ -348,35 +345,291 @@ var CoursesResource = class {
348
345
  page: args.page,
349
346
  perPage: args.limit
350
347
  },
351
- vars: tenantVars(this.client),
348
+ vars: tenantVars$2(this.client),
352
349
  transform: (raw) => mapCoursePage(raw)
353
350
  }, options);
354
351
  }
355
352
  /**
356
- * Get a single course by slug. Returns `null` if not found.
353
+ * Get a single course by slug.
354
+ *
355
+ * Throws `LoopwiseError("GRAPHQL")` when the slug is not found
356
+ * (Storefront `Queries::Course` raises `service_error("Course-003")`
357
+ * on a `null: false` field). Wrap with try/catch to render 404 routes:
358
+ *
357
359
  * @example
358
- * const course = await loopwise.courses.get({ slug: 'finance' })
359
- * if (!course) return Astro.redirect('/404')
360
+ * try {
361
+ * const course = await loopwise.courses.get({ slug: 'finance' })
362
+ * // ...
363
+ * } catch (e) {
364
+ * if (e instanceof LoopwiseError && e.code === 'GRAPHQL') {
365
+ * return Astro.redirect('/404')
366
+ * }
367
+ * throw e
368
+ * }
360
369
  */
361
370
  get(args, options = {}) {
362
- const sel = args.select ?? DEFAULT_GET_FIELDS;
371
+ const sel = args.select ?? DEFAULT_GET_FIELDS$2;
363
372
  return new QueryBuilder(this.client, {
364
373
  operation: "CourseGet",
365
374
  field: "course",
366
375
  selectionSet: buildCourseSelection(sel),
367
376
  args: { slug: args.slug },
377
+ vars: tenantVars$2(this.client),
378
+ transform: (raw) => mapCourse(raw)
379
+ }, options);
380
+ }
381
+ };
382
+ //#endregion
383
+ //#region src/schema/orders.ts
384
+ /** Map SDK Order selection -> schema field selection set. */
385
+ function buildOrderSelection(select) {
386
+ const parts = ["id"];
387
+ const sel = select;
388
+ if (sel.status) parts.push("status");
389
+ if (sel.total) parts.push("total");
390
+ if (sel.currency) parts.push("currency");
391
+ if (sel.createdAt) parts.push("createdAt");
392
+ if (sel.items && typeof sel.items === "object") {
393
+ const itemSel = sel.items;
394
+ const itemParts = ["id"];
395
+ if (itemSel.name) itemParts.push("name");
396
+ if (itemSel.quantity) itemParts.push("quantity");
397
+ if (itemSel.amount) itemParts.push("amount");
398
+ parts.push(`items { ${itemParts.join(" ")} }`);
399
+ }
400
+ return parts.join(" ");
401
+ }
402
+ function mapOrderItem(raw) {
403
+ const out = { id: raw.id };
404
+ if (raw.name !== void 0) out.name = raw.name ?? null;
405
+ if (raw.quantity !== void 0) out.quantity = raw.quantity;
406
+ if (raw.amount !== void 0) out.amount = raw.amount;
407
+ return out;
408
+ }
409
+ function mapOrder(raw) {
410
+ const out = { id: raw.id };
411
+ if (raw.status !== void 0) out.status = raw.status;
412
+ if (raw.total !== void 0) out.total = raw.total;
413
+ if (raw.currency !== void 0) out.currency = raw.currency;
414
+ if (raw.createdAt !== void 0) out.createdAt = raw.createdAt;
415
+ if (raw.items !== void 0) out.items = raw.items.map(mapOrderItem);
416
+ return out;
417
+ }
418
+ //#endregion
419
+ //#region src/resources/orders.ts
420
+ const DEFAULT_GET_FIELDS$1 = {
421
+ id: true,
422
+ status: true,
423
+ total: true,
424
+ currency: true,
425
+ createdAt: true,
426
+ items: {
427
+ id: true,
428
+ name: true,
429
+ quantity: true,
430
+ amount: true
431
+ }
432
+ };
433
+ function tenantVars$1(client) {
434
+ return { subdomain: {
435
+ type: "String!",
436
+ value: client.config.tenant ?? ""
437
+ } };
438
+ }
439
+ var OrdersResource = class {
440
+ client;
441
+ constructor(client) {
442
+ this.client = client;
443
+ }
444
+ /**
445
+ * Look up a PII-safe order projection by id (the value the storefront
446
+ * appends to a merchant's `checkoutSuccessUrl` as `?tradeNo=...`).
447
+ *
448
+ * Returns `null`-shaped result via the GraphQL `null` payload when the
449
+ * id doesn't resolve in the current tenant — does NOT throw. Use the
450
+ * returned `status` to gate Meta Pixel `Purchase` events:
451
+ *
452
+ * @example
453
+ * const id = new URLSearchParams(location.search).get('tradeNo');
454
+ * if (!id) return;
455
+ * const order = await loopwise.orders.get({ id });
456
+ * if (order?.status === 'PAID') {
457
+ * fbq('track', 'Purchase', {
458
+ * value: order.total,
459
+ * currency: order.currency,
460
+ * contents: order.items.map(i => ({
461
+ * id: i.id, name: i.name, quantity: i.quantity, item_price: i.amount,
462
+ * })),
463
+ * content_ids: order.items.map(i => i.id),
464
+ * content_type: 'product',
465
+ * num_items: order.items.reduce((s, i) => s + i.quantity, 0),
466
+ * }, { eventID: order.id });
467
+ * }
468
+ */
469
+ get(args, options = {}) {
470
+ const sel = args.select ?? DEFAULT_GET_FIELDS$1;
471
+ return new QueryBuilder(this.client, {
472
+ operation: "OrderGet",
473
+ field: "order",
474
+ selectionSet: buildOrderSelection(sel),
475
+ args: { id: args.id },
476
+ vars: tenantVars$1(this.client),
477
+ transform: (raw) => raw == null ? null : mapOrder(raw)
478
+ }, options);
479
+ }
480
+ };
481
+ //#endregion
482
+ //#region src/schema/posts.ts
483
+ /** Map SDK Post selection -> schema field selection set. */
484
+ function buildPostSelection(select) {
485
+ const parts = ["id", "slug"];
486
+ const sel = select;
487
+ if (sel.title) parts.push("title");
488
+ if (sel.subtitle) parts.push("subtitle");
489
+ if (sel.excerpt) parts.push("excerpt");
490
+ if (sel.body) parts.push("body");
491
+ if (sel.coverImage) parts.push("coverPhoto");
492
+ if (sel.publishedAt) parts.push("publishedAt");
493
+ if (sel.modifiedAt) parts.push("modifiedAt");
494
+ if (sel.accessType) parts.push("accessType");
495
+ if (sel.tags) parts.push("tags");
496
+ if (sel.noindex) parts.push("noindex");
497
+ if (sel.author && typeof sel.author === "object") {
498
+ const auth = sel.author;
499
+ const authParts = ["id"];
500
+ if (auth.name) authParts.push("name");
501
+ if (auth.avatar) authParts.push("avatar");
502
+ parts.push(`author { ${authParts.join(" ")} }`);
503
+ }
504
+ return parts.join(" ");
505
+ }
506
+ function mapPost(raw) {
507
+ const out = {
508
+ id: raw.id,
509
+ slug: raw.slug
510
+ };
511
+ if (raw.title !== void 0) out.title = raw.title;
512
+ if (raw.subtitle !== void 0) out.subtitle = raw.subtitle ?? null;
513
+ if (raw.excerpt !== void 0) out.excerpt = raw.excerpt ?? null;
514
+ if (raw.body !== void 0) out.body = raw.body;
515
+ if (raw.coverPhoto !== void 0) out.coverImage = raw.coverPhoto ?? null;
516
+ if (raw.publishedAt !== void 0) out.publishedAt = raw.publishedAt;
517
+ if (raw.modifiedAt !== void 0) out.modifiedAt = raw.modifiedAt ?? null;
518
+ if (raw.accessType !== void 0) out.accessType = raw.accessType;
519
+ if (raw.tags !== void 0) out.tags = raw.tags ?? null;
520
+ if (raw.noindex !== void 0) out.noindex = raw.noindex;
521
+ if (raw.author !== void 0) out.author = raw.author ? mapLecturer(raw.author) : null;
522
+ return out;
523
+ }
524
+ function mapPostPage(raw) {
525
+ return {
526
+ items: raw.nodes.map(mapPost),
527
+ pageInfo: {
528
+ page: raw.currentPage,
529
+ totalPages: raw.totalPages,
530
+ totalCount: raw.nodesCount,
531
+ hasNext: raw.hasNextPage,
532
+ hasPrev: raw.hasPreviousPage
533
+ }
534
+ };
535
+ }
536
+ //#endregion
537
+ //#region src/resources/posts.ts
538
+ const DEFAULT_LIST_FIELDS = {
539
+ id: true,
540
+ slug: true,
541
+ title: true,
542
+ excerpt: true,
543
+ coverImage: true,
544
+ publishedAt: true
545
+ };
546
+ const DEFAULT_GET_FIELDS = {
547
+ id: true,
548
+ slug: true,
549
+ title: true,
550
+ subtitle: true,
551
+ excerpt: true,
552
+ body: true,
553
+ coverImage: true,
554
+ publishedAt: true,
555
+ modifiedAt: true,
556
+ accessType: true,
557
+ author: {
558
+ id: true,
559
+ name: true,
560
+ avatar: true
561
+ }
562
+ };
563
+ function tenantVars(client) {
564
+ return { subdomain: {
565
+ type: "String!",
566
+ value: client.config.tenant ?? ""
567
+ } };
568
+ }
569
+ var PostsResource = class {
570
+ client;
571
+ constructor(client) {
572
+ this.client = client;
573
+ }
574
+ /**
575
+ * List published posts for the current tenant, ordered by publishedAt desc.
576
+ * @example
577
+ * const { items, pageInfo } = await loopwise.posts.list({ limit: 10 })
578
+ * @example
579
+ * await loopwise.posts.list({ limit: 10 }, { cache: 60 })
580
+ */
581
+ list(args = {}, options = {}) {
582
+ const selectionSet = `nodes { ${buildPostSelection(args.select ?? DEFAULT_LIST_FIELDS)} } currentPage totalPages nodesCount hasNextPage hasPreviousPage`;
583
+ return new QueryBuilder(this.client, {
584
+ operation: "PostList",
585
+ field: "posts",
586
+ selectionSet,
587
+ args: {
588
+ page: args.page,
589
+ perPage: args.limit
590
+ },
591
+ vars: tenantVars(this.client),
592
+ transform: (raw) => mapPostPage(raw)
593
+ }, options);
594
+ }
595
+ /**
596
+ * Get a single post by slug.
597
+ *
598
+ * Throws `LoopwiseError("GRAPHQL")` when the slug is not found
599
+ * (Storefront `Queries::Post` raises `service_error("Post-000")` on a
600
+ * `null: false` field). Wrap with try/catch to render 404 routes:
601
+ *
602
+ * @example
603
+ * try {
604
+ * const post = await loopwise.posts.get({ slug: 'hello-world' })
605
+ * // ...
606
+ * } catch (e) {
607
+ * if (e instanceof LoopwiseError && e.code === 'GRAPHQL') {
608
+ * return Astro.redirect('/404')
609
+ * }
610
+ * throw e
611
+ * }
612
+ */
613
+ get(args, options = {}) {
614
+ const sel = args.select ?? DEFAULT_GET_FIELDS;
615
+ return new QueryBuilder(this.client, {
616
+ operation: "PostGet",
617
+ field: "post",
618
+ selectionSet: buildPostSelection(sel),
619
+ args: { slug: args.slug },
368
620
  vars: tenantVars(this.client),
369
- transform: (raw) => raw == null ? null : mapCourse(raw)
621
+ transform: (raw) => mapPost(raw)
370
622
  }, options);
371
623
  }
372
624
  };
373
-
374
625
  //#endregion
375
626
  //#region src/index.ts
376
627
  function createClient(config) {
377
628
  const client = new LoopwiseClient(config);
378
629
  return {
379
630
  courses: new CoursesResource(client),
631
+ orders: new OrdersResource(client),
632
+ posts: new PostsResource(client),
380
633
  batch(builders, options) {
381
634
  return client.batch(builders, options);
382
635
  },
@@ -385,6 +638,5 @@ function createClient(config) {
385
638
  }
386
639
  };
387
640
  }
388
-
389
641
  //#endregion
390
- export { LoopwiseError, QueryBuilder, createClient };
642
+ export { LoopwiseError, QueryBuilder, createClient };
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@loopwise/sdk",
3
- "version": "0.1.0-beta.0",
3
+ "version": "0.1.0-beta.2",
4
4
  "description": "Loopwise platform SDK — agent-friendly headless API client",
5
5
  "repository": {
6
6
  "type": "git",
7
- "url": "https://github.com/kaikhq/manekineko.git",
7
+ "url": "git+https://github.com/kaikhq/manekineko.git",
8
8
  "directory": "packages/sdk"
9
9
  },
10
- "homepage": "https://github.com/kaikhq/manekineko/tree/develop/packages/sdk#readme",
10
+ "homepage": "https://www.npmjs.com/package/@loopwise/sdk",
11
11
  "bugs": {
12
12
  "url": "https://github.com/kaikhq/manekineko/issues"
13
13
  },
@@ -43,11 +43,13 @@
43
43
  "build": "tsdown",
44
44
  "dev": "tsdown --watch",
45
45
  "typecheck": "tsc --noEmit",
46
- "test": "vitest run"
46
+ "test": "vitest run",
47
+ "regen:lock": "node scripts/regen-lock.mjs"
47
48
  },
48
49
  "devDependencies": {
50
+ "graphql": "16.13.1",
49
51
  "tsconfig": "workspace:*",
50
- "tsdown": "0.20.3",
52
+ "tsdown": "0.22.0",
51
53
  "typescript": "5.9.3",
52
54
  "vitest": "4.1.0"
53
55
  },