@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 +93 -18
- package/dist/config.mjs +1 -2
- package/dist/index.d.mts +163 -10
- package/dist/index.mjs +273 -21
- package/package.json +7 -5
package/README.md
CHANGED
|
@@ -1,37 +1,78 @@
|
|
|
1
1
|
# @loopwise/sdk
|
|
2
2
|
|
|
3
|
-
Storefront GraphQL client for Loopwise / Teachify.
|
|
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
|
-
|
|
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
|
|
18
|
-
`acme.myteachify.com`,
|
|
19
|
-
|
|
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
|
-
|
|
43
|
+
## Cross-origin (Loopwise Pages preview, SSR, build pipelines)
|
|
22
44
|
|
|
23
|
-
When the SDK runs outside a tenant origin
|
|
24
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
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
|
|
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
|
-
*
|
|
164
|
-
*
|
|
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<
|
|
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 (
|
|
46
|
-
|
|
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.
|
|
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
|
-
*
|
|
359
|
-
*
|
|
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) =>
|
|
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.
|
|
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://
|
|
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.
|
|
52
|
+
"tsdown": "0.22.0",
|
|
51
53
|
"typescript": "5.9.3",
|
|
52
54
|
"vitest": "4.1.0"
|
|
53
55
|
},
|