@knocklabs/client 0.13.1 → 0.14.0
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 +6 -0
- package/dist/cjs/clients/guide/client.js +2 -0
- package/dist/cjs/clients/guide/client.js.map +1 -0
- package/dist/cjs/clients/users/index.js +1 -1
- package/dist/cjs/clients/users/index.js.map +1 -1
- package/dist/cjs/index.js +1 -1
- package/dist/esm/clients/guide/client.mjs +225 -0
- package/dist/esm/clients/guide/client.mjs.map +1 -0
- package/dist/esm/clients/users/index.mjs +30 -13
- package/dist/esm/clients/users/index.mjs.map +1 -1
- package/dist/esm/index.mjs +9 -7
- package/dist/esm/index.mjs.map +1 -1
- package/dist/types/clients/guide/client.d.ts +103 -0
- package/dist/types/clients/guide/client.d.ts.map +1 -0
- package/dist/types/clients/guide/index.d.ts +3 -0
- package/dist/types/clients/guide/index.d.ts.map +1 -0
- package/dist/types/clients/users/index.d.ts +3 -0
- package/dist/types/clients/users/index.d.ts.map +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +3 -2
- package/src/clients/guide/client.ts +541 -0
- package/src/clients/guide/index.ts +7 -0
- package/src/clients/users/index.ts +27 -0
- package/src/index.ts +1 -0
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
import { GenericData } from "@knocklabs/types";
|
|
2
|
+
import { Store } from "@tanstack/store";
|
|
3
|
+
import { Channel, Socket } from "phoenix";
|
|
4
|
+
|
|
5
|
+
import Knock from "../../knock";
|
|
6
|
+
|
|
7
|
+
const sortGuides = (guides: KnockGuide[]) => {
|
|
8
|
+
return [...guides].sort(
|
|
9
|
+
(a, b) =>
|
|
10
|
+
b.priority - a.priority ||
|
|
11
|
+
new Date(b.inserted_at).getTime() - new Date(a.inserted_at).getTime(),
|
|
12
|
+
);
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
//
|
|
16
|
+
// Guides API (via User client)
|
|
17
|
+
//
|
|
18
|
+
|
|
19
|
+
export const guidesApiRootPath = (userId: string | undefined | null) =>
|
|
20
|
+
`/v1/users/${userId}/guides`;
|
|
21
|
+
|
|
22
|
+
interface StepMessageState {
|
|
23
|
+
id: string;
|
|
24
|
+
seen_at: string | null;
|
|
25
|
+
read_at: string | null;
|
|
26
|
+
interacted_at: string | null;
|
|
27
|
+
archived_at: string | null;
|
|
28
|
+
link_clicked_at: string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface GuideStepData {
|
|
32
|
+
ref: string;
|
|
33
|
+
schema_key: string;
|
|
34
|
+
schema_semver: string;
|
|
35
|
+
schema_variant_key: string;
|
|
36
|
+
message: StepMessageState;
|
|
37
|
+
// eslint-disable-next-line
|
|
38
|
+
content: any;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface GuideData {
|
|
42
|
+
__typename: "Guide";
|
|
43
|
+
channel_id: string;
|
|
44
|
+
id: string;
|
|
45
|
+
key: string;
|
|
46
|
+
priority: number;
|
|
47
|
+
type: string;
|
|
48
|
+
semver: string;
|
|
49
|
+
steps: GuideStepData[];
|
|
50
|
+
inserted_at: string;
|
|
51
|
+
updated_at: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface KnockGuideStep extends GuideStepData {
|
|
55
|
+
markAsSeen: () => void;
|
|
56
|
+
markAsInteracted: (params?: { metadata?: GenericData }) => void;
|
|
57
|
+
markAsArchived: () => void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface KnockGuide extends GuideData {
|
|
61
|
+
steps: KnockGuideStep[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type GetGuidesQueryParams = {
|
|
65
|
+
data?: string;
|
|
66
|
+
tenant?: string;
|
|
67
|
+
type?: string;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
type GetGuidesResponse = {
|
|
71
|
+
entries: GuideData[];
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export type GuideEngagementEventBaseParams = {
|
|
75
|
+
// Base params required for all engagement update events
|
|
76
|
+
message_id: string;
|
|
77
|
+
channel_id: string;
|
|
78
|
+
guide_key: string;
|
|
79
|
+
guide_id: string;
|
|
80
|
+
guide_step_ref: string;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
type MarkAsSeenParams = GuideEngagementEventBaseParams & {
|
|
84
|
+
// Rendered step content seen by the recipient
|
|
85
|
+
content: GenericData;
|
|
86
|
+
// Target params
|
|
87
|
+
data?: GenericData;
|
|
88
|
+
tenant?: string;
|
|
89
|
+
};
|
|
90
|
+
type MarkAsInteractedParams = GuideEngagementEventBaseParams;
|
|
91
|
+
type MarkAsArchivedParams = GuideEngagementEventBaseParams;
|
|
92
|
+
|
|
93
|
+
type MarkGuideAsResponse = {
|
|
94
|
+
status: "ok";
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
type SocketEventType = "guide.added" | "guide.updated" | "guide.removed";
|
|
98
|
+
|
|
99
|
+
type SocketEventPayload<E extends SocketEventType, D> = {
|
|
100
|
+
topic: string;
|
|
101
|
+
event: E;
|
|
102
|
+
data: D;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
type GuideAddedEvent = SocketEventPayload<
|
|
106
|
+
"guide.added",
|
|
107
|
+
{ guide: GuideData; eligible: true }
|
|
108
|
+
>;
|
|
109
|
+
|
|
110
|
+
type GuideUpdatedEvent = SocketEventPayload<
|
|
111
|
+
"guide.updated",
|
|
112
|
+
{ guide: GuideData; eligible: boolean }
|
|
113
|
+
>;
|
|
114
|
+
|
|
115
|
+
type GuideRemovedEvent = SocketEventPayload<
|
|
116
|
+
"guide.removed",
|
|
117
|
+
{ guide: Pick<GuideData, "key"> }
|
|
118
|
+
>;
|
|
119
|
+
|
|
120
|
+
type GuideSocketEvent = GuideAddedEvent | GuideUpdatedEvent | GuideRemovedEvent;
|
|
121
|
+
|
|
122
|
+
//
|
|
123
|
+
// Guides client
|
|
124
|
+
//
|
|
125
|
+
|
|
126
|
+
type QueryKey = string;
|
|
127
|
+
|
|
128
|
+
type QueryStatus = {
|
|
129
|
+
status: "loading" | "ok" | "error";
|
|
130
|
+
error?: Error;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
type StoreState = {
|
|
134
|
+
guides: KnockGuide[];
|
|
135
|
+
queries: Record<QueryKey, QueryStatus>;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
type QueryFilterParams = Pick<GetGuidesQueryParams, "type">;
|
|
139
|
+
|
|
140
|
+
export type SelectFilterParams = {
|
|
141
|
+
key?: string;
|
|
142
|
+
type?: string;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export type TargetParams = {
|
|
146
|
+
data?: GenericData | undefined;
|
|
147
|
+
tenant?: string | undefined;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export class KnockGuideClient {
|
|
151
|
+
public store: Store<StoreState, (state: StoreState) => StoreState>;
|
|
152
|
+
|
|
153
|
+
// Phoenix channels for real time guide updates over websocket
|
|
154
|
+
private socket: Socket | undefined;
|
|
155
|
+
private socketChannel: Channel | undefined;
|
|
156
|
+
private socketChannelTopic: string;
|
|
157
|
+
private socketEventTypes = ["guide.added", "guide.updated", "guide.removed"];
|
|
158
|
+
|
|
159
|
+
constructor(
|
|
160
|
+
readonly knock: Knock,
|
|
161
|
+
readonly channelId: string,
|
|
162
|
+
readonly targetParams: TargetParams = {},
|
|
163
|
+
) {
|
|
164
|
+
this.store = new Store<StoreState>({
|
|
165
|
+
guides: [],
|
|
166
|
+
queries: {},
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// In server environments we might not have a socket connection.
|
|
170
|
+
const { socket: maybeSocket } = this.knock.client();
|
|
171
|
+
this.socket = maybeSocket;
|
|
172
|
+
this.socketChannelTopic = `guides:${channelId}`;
|
|
173
|
+
|
|
174
|
+
this.knock.log("[Guide] Initialized a guide client");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async fetch(opts?: { filters?: QueryFilterParams }) {
|
|
178
|
+
this.knock.failIfNotAuthenticated();
|
|
179
|
+
this.knock.log("[Guide] Loading all eligible guides");
|
|
180
|
+
|
|
181
|
+
const queryParams = this.buildQueryParams(opts?.filters);
|
|
182
|
+
const queryKey = this.formatQueryKey(queryParams);
|
|
183
|
+
|
|
184
|
+
// If already fetched before, then noop.
|
|
185
|
+
const maybeQueryStatus = this.store.state.queries[queryKey];
|
|
186
|
+
if (maybeQueryStatus) {
|
|
187
|
+
return maybeQueryStatus;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Mark this query status as loading.
|
|
191
|
+
this.store.setState((state) => ({
|
|
192
|
+
...state,
|
|
193
|
+
queries: { ...state.queries, [queryKey]: { status: "loading" } },
|
|
194
|
+
}));
|
|
195
|
+
|
|
196
|
+
let queryStatus: QueryStatus;
|
|
197
|
+
try {
|
|
198
|
+
const data = await this.knock.user.getGuides<
|
|
199
|
+
GetGuidesQueryParams,
|
|
200
|
+
GetGuidesResponse
|
|
201
|
+
>(this.channelId, queryParams);
|
|
202
|
+
queryStatus = { status: "ok" };
|
|
203
|
+
|
|
204
|
+
this.store.setState((state) => ({
|
|
205
|
+
...state,
|
|
206
|
+
// For now assume a single fetch to get all eligible guides. When/if
|
|
207
|
+
// we implement incremental loads, then this will need to be a merge
|
|
208
|
+
// and sort operation.
|
|
209
|
+
guides: data.entries.map((g) => this.localCopy(g)),
|
|
210
|
+
queries: { ...state.queries, [queryKey]: queryStatus },
|
|
211
|
+
}));
|
|
212
|
+
} catch (e) {
|
|
213
|
+
queryStatus = { status: "error", error: e as Error };
|
|
214
|
+
|
|
215
|
+
this.store.setState((state) => ({
|
|
216
|
+
...state,
|
|
217
|
+
queries: { ...state.queries, [queryKey]: queryStatus },
|
|
218
|
+
}));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return queryStatus;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
subscribe() {
|
|
225
|
+
if (!this.socket) return;
|
|
226
|
+
this.knock.failIfNotAuthenticated();
|
|
227
|
+
this.knock.log("[Guide] Subscribing to real time updates");
|
|
228
|
+
|
|
229
|
+
// Ensure a live socket connection if not yet connected.
|
|
230
|
+
if (!this.socket.isConnected()) {
|
|
231
|
+
this.socket.connect();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// If there's an existing connected channel, then disconnect.
|
|
235
|
+
if (this.socketChannel) {
|
|
236
|
+
this.unsubscribe();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Join the channel topic and subscribe to supported events.
|
|
240
|
+
const params = { ...this.targetParams, user_id: this.knock.userId };
|
|
241
|
+
const newChannel = this.socket.channel(this.socketChannelTopic, params);
|
|
242
|
+
|
|
243
|
+
for (const eventType of this.socketEventTypes) {
|
|
244
|
+
newChannel.on(eventType, (payload) => this.handleSocketEvent(payload));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (["closed", "errored"].includes(newChannel.state)) {
|
|
248
|
+
newChannel.join();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Track the joined channel.
|
|
252
|
+
this.socketChannel = newChannel;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
unsubscribe() {
|
|
256
|
+
if (!this.socketChannel) return;
|
|
257
|
+
this.knock.log("[Guide] Unsubscribing from real time updates");
|
|
258
|
+
|
|
259
|
+
// Unsubscribe from the socket events and leave the channel.
|
|
260
|
+
for (const eventType of this.socketEventTypes) {
|
|
261
|
+
this.socketChannel.off(eventType);
|
|
262
|
+
}
|
|
263
|
+
this.socketChannel.leave();
|
|
264
|
+
|
|
265
|
+
// Unset the channel.
|
|
266
|
+
this.socketChannel = undefined;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private handleSocketEvent(payload: GuideSocketEvent) {
|
|
270
|
+
const { event, data } = payload;
|
|
271
|
+
|
|
272
|
+
switch (event) {
|
|
273
|
+
case "guide.added":
|
|
274
|
+
return this.addGuide(payload);
|
|
275
|
+
|
|
276
|
+
case "guide.updated":
|
|
277
|
+
return data.eligible
|
|
278
|
+
? this.replaceOrAddGuide(payload)
|
|
279
|
+
: this.removeGuide(payload);
|
|
280
|
+
|
|
281
|
+
case "guide.removed":
|
|
282
|
+
return this.removeGuide(payload);
|
|
283
|
+
|
|
284
|
+
default:
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
//
|
|
290
|
+
// Store selector
|
|
291
|
+
//
|
|
292
|
+
|
|
293
|
+
select(state: StoreState, filters: SelectFilterParams = {}) {
|
|
294
|
+
// TODO(KNO-7790): Need to evaluate activation rules also.
|
|
295
|
+
|
|
296
|
+
return state.guides.filter((guide) => {
|
|
297
|
+
if (filters.type && filters.type !== guide.type) {
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (filters.key && filters.key !== guide.key) {
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return true;
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
//
|
|
310
|
+
// Engagement event handlers
|
|
311
|
+
//
|
|
312
|
+
// Make an optimistic update on the client side first, then send an engagement
|
|
313
|
+
// event to the backend.
|
|
314
|
+
//
|
|
315
|
+
|
|
316
|
+
async markAsSeen(guide: GuideData, step: GuideStepData) {
|
|
317
|
+
this.knock.log(
|
|
318
|
+
`[Guide] Marking as seen (Guide key: ${guide.key}, Step ref:${step.ref})`,
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
const updatedStep = this.setStepMessageAttrs(guide.key, step.ref, {
|
|
322
|
+
seen_at: new Date().toISOString(),
|
|
323
|
+
});
|
|
324
|
+
if (!updatedStep) return;
|
|
325
|
+
|
|
326
|
+
const params = {
|
|
327
|
+
...this.buildEngagementEventBaseParams(guide, updatedStep),
|
|
328
|
+
content: updatedStep.content,
|
|
329
|
+
data: this.targetParams.data,
|
|
330
|
+
tenant: this.targetParams.tenant,
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
this.knock.user.markGuideStepAs<MarkAsSeenParams, MarkGuideAsResponse>(
|
|
334
|
+
"seen",
|
|
335
|
+
params,
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
return updatedStep;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async markAsInteracted(
|
|
342
|
+
guide: GuideData,
|
|
343
|
+
step: GuideStepData,
|
|
344
|
+
metadata?: GenericData,
|
|
345
|
+
) {
|
|
346
|
+
this.knock.log(
|
|
347
|
+
`[Guide] Marking as interacted (Guide key: ${guide.key}, Step ref:${step.ref})`,
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
const ts = new Date().toISOString();
|
|
351
|
+
const updatedStep = this.setStepMessageAttrs(guide.key, step.ref, {
|
|
352
|
+
read_at: ts,
|
|
353
|
+
interacted_at: ts,
|
|
354
|
+
});
|
|
355
|
+
if (!updatedStep) return;
|
|
356
|
+
|
|
357
|
+
const params = {
|
|
358
|
+
...this.buildEngagementEventBaseParams(guide, updatedStep),
|
|
359
|
+
metadata,
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
this.knock.user.markGuideStepAs<
|
|
363
|
+
MarkAsInteractedParams,
|
|
364
|
+
MarkGuideAsResponse
|
|
365
|
+
>("interacted", params);
|
|
366
|
+
|
|
367
|
+
return updatedStep;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async markAsArchived(guide: GuideData, step: GuideStepData) {
|
|
371
|
+
this.knock.log(
|
|
372
|
+
`[Guide] Marking as archived (Guide key: ${guide.key}, Step ref:${step.ref})`,
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
const updatedStep = this.setStepMessageAttrs(guide.key, step.ref, {
|
|
376
|
+
archived_at: new Date().toISOString(),
|
|
377
|
+
});
|
|
378
|
+
if (!updatedStep) return;
|
|
379
|
+
|
|
380
|
+
const params = this.buildEngagementEventBaseParams(guide, updatedStep);
|
|
381
|
+
|
|
382
|
+
this.knock.user.markGuideStepAs<MarkAsArchivedParams, MarkGuideAsResponse>(
|
|
383
|
+
"archived",
|
|
384
|
+
params,
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
return updatedStep;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
//
|
|
391
|
+
// Helpers
|
|
392
|
+
//
|
|
393
|
+
|
|
394
|
+
private localCopy(remoteGuide: GuideData) {
|
|
395
|
+
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
396
|
+
const self = this;
|
|
397
|
+
|
|
398
|
+
// Build a local copy with helper methods added.
|
|
399
|
+
const localGuide = { ...remoteGuide };
|
|
400
|
+
|
|
401
|
+
localGuide.steps = remoteGuide.steps.map(({ message, ...rest }) => {
|
|
402
|
+
const localStep = {
|
|
403
|
+
...rest,
|
|
404
|
+
message: { ...message },
|
|
405
|
+
markAsSeen() {
|
|
406
|
+
// Send a seen event if it has not been previously seen.
|
|
407
|
+
if (this.message.seen_at) return;
|
|
408
|
+
return self.markAsSeen(localGuide, this);
|
|
409
|
+
},
|
|
410
|
+
markAsInteracted({ metadata }: { metadata?: GenericData } = {}) {
|
|
411
|
+
// Always send an interaction event through.
|
|
412
|
+
return self.markAsInteracted(localGuide, this, metadata);
|
|
413
|
+
},
|
|
414
|
+
markAsArchived() {
|
|
415
|
+
// Send an archived event if it has not been previously archived.
|
|
416
|
+
if (this.message.archived_at) return;
|
|
417
|
+
return self.markAsArchived(localGuide, this);
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
// Bind all engagement action handler methods to the local step object so
|
|
422
|
+
// they can operate on itself.
|
|
423
|
+
localStep.markAsSeen = localStep.markAsSeen.bind(localStep);
|
|
424
|
+
localStep.markAsInteracted = localStep.markAsInteracted.bind(localStep);
|
|
425
|
+
localStep.markAsArchived = localStep.markAsArchived.bind(localStep);
|
|
426
|
+
|
|
427
|
+
return localStep;
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
return localGuide as KnockGuide;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
private buildQueryParams(filterParams: QueryFilterParams = {}) {
|
|
434
|
+
// Combine the target params with the given filter params.
|
|
435
|
+
const combinedParams = { ...this.targetParams, ...filterParams };
|
|
436
|
+
|
|
437
|
+
// Prune out any keys that have an undefined or null value.
|
|
438
|
+
let params = Object.fromEntries(
|
|
439
|
+
Object.entries(combinedParams).filter(
|
|
440
|
+
([_k, v]) => v !== undefined && v !== null,
|
|
441
|
+
),
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
// Encode target data as a JSON string, if provided.
|
|
445
|
+
params = params.data
|
|
446
|
+
? { ...params, data: JSON.stringify(params.data) }
|
|
447
|
+
: params;
|
|
448
|
+
|
|
449
|
+
return params as GetGuidesQueryParams;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
private formatQueryKey(queryParams: GenericData) {
|
|
453
|
+
const sortedKeys = Object.keys(queryParams).sort();
|
|
454
|
+
|
|
455
|
+
const queryStr = sortedKeys
|
|
456
|
+
.map(
|
|
457
|
+
(key) =>
|
|
458
|
+
`${encodeURIComponent(key)}=${encodeURIComponent(queryParams[key])}`,
|
|
459
|
+
)
|
|
460
|
+
.join("&");
|
|
461
|
+
|
|
462
|
+
const basePath = guidesApiRootPath(this.knock.userId);
|
|
463
|
+
return queryStr ? `${basePath}?${queryStr}` : basePath;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private setStepMessageAttrs(
|
|
467
|
+
guideKey: string,
|
|
468
|
+
stepRef: string,
|
|
469
|
+
attrs: Partial<StepMessageState>,
|
|
470
|
+
) {
|
|
471
|
+
let updatedStep: KnockGuideStep | undefined;
|
|
472
|
+
|
|
473
|
+
this.store.setState((state) => {
|
|
474
|
+
const guides = state.guides.map((guide) => {
|
|
475
|
+
if (guide.key !== guideKey) return guide;
|
|
476
|
+
|
|
477
|
+
const steps = guide.steps.map((step) => {
|
|
478
|
+
if (step.ref !== stepRef) return step;
|
|
479
|
+
|
|
480
|
+
// Mutate in place and maintain the same obj ref so to make it easier
|
|
481
|
+
// to use in hook deps.
|
|
482
|
+
step.message = { ...step.message, ...attrs };
|
|
483
|
+
updatedStep = step;
|
|
484
|
+
|
|
485
|
+
return step;
|
|
486
|
+
});
|
|
487
|
+
return { ...guide, steps };
|
|
488
|
+
});
|
|
489
|
+
return { ...state, guides };
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
return updatedStep;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
private buildEngagementEventBaseParams(
|
|
496
|
+
guide: GuideData,
|
|
497
|
+
step: GuideStepData,
|
|
498
|
+
) {
|
|
499
|
+
return {
|
|
500
|
+
message_id: step.message.id,
|
|
501
|
+
channel_id: guide.channel_id,
|
|
502
|
+
guide_key: guide.key,
|
|
503
|
+
guide_id: guide.id,
|
|
504
|
+
guide_step_ref: step.ref,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
private addGuide({ data }: GuideAddedEvent) {
|
|
509
|
+
const guide = this.localCopy(data.guide);
|
|
510
|
+
|
|
511
|
+
this.store.setState((state) => {
|
|
512
|
+
return { ...state, guides: sortGuides([...state.guides, guide]) };
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
private replaceOrAddGuide({ data }: GuideUpdatedEvent) {
|
|
517
|
+
const guide = this.localCopy(data.guide);
|
|
518
|
+
|
|
519
|
+
this.store.setState((state) => {
|
|
520
|
+
let replaced = false;
|
|
521
|
+
|
|
522
|
+
const guides = state.guides.map((g) => {
|
|
523
|
+
if (g.key !== guide.key) return g;
|
|
524
|
+
replaced = true;
|
|
525
|
+
return guide;
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
return {
|
|
529
|
+
...state,
|
|
530
|
+
guides: replaced ? sortGuides(guides) : sortGuides([...guides, guide]),
|
|
531
|
+
};
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
private removeGuide({ data }: GuideUpdatedEvent | GuideRemovedEvent) {
|
|
536
|
+
this.store.setState((state) => {
|
|
537
|
+
const guides = state.guides.filter((g) => g.key !== data.guide.key);
|
|
538
|
+
return { ...state, guides };
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
}
|
|
@@ -3,6 +3,10 @@ import { GenericData } from "@knocklabs/types";
|
|
|
3
3
|
import { ApiResponse } from "../../api";
|
|
4
4
|
import { ChannelData, User } from "../../interfaces";
|
|
5
5
|
import Knock from "../../knock";
|
|
6
|
+
import {
|
|
7
|
+
GuideEngagementEventBaseParams,
|
|
8
|
+
guidesApiRootPath,
|
|
9
|
+
} from "../guide/client";
|
|
6
10
|
import {
|
|
7
11
|
GetPreferencesOptions,
|
|
8
12
|
PreferenceOptions,
|
|
@@ -112,6 +116,29 @@ class UserClient {
|
|
|
112
116
|
return this.handleResponse<ChannelData<T>>(result);
|
|
113
117
|
}
|
|
114
118
|
|
|
119
|
+
async getGuides<P, R>(channelId: string, params: P) {
|
|
120
|
+
const result = await this.instance.client().makeRequest({
|
|
121
|
+
method: "GET",
|
|
122
|
+
url: `${guidesApiRootPath(this.instance.userId)}/${channelId}`,
|
|
123
|
+
params,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return this.handleResponse<R>(result);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async markGuideStepAs<P extends GuideEngagementEventBaseParams, R>(
|
|
130
|
+
status: "seen" | "interacted" | "archived",
|
|
131
|
+
params: P,
|
|
132
|
+
) {
|
|
133
|
+
const result = await this.instance.client().makeRequest({
|
|
134
|
+
method: "PUT",
|
|
135
|
+
url: `${guidesApiRootPath(this.instance.userId)}/messages/${params.message_id}/${status}`,
|
|
136
|
+
data: params,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
return this.handleResponse<R>(result);
|
|
140
|
+
}
|
|
141
|
+
|
|
115
142
|
private handleResponse<T>(response: ApiResponse) {
|
|
116
143
|
if (response.statusCode === "error") {
|
|
117
144
|
throw new Error(response.error || response.body);
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import FeedClient, { Feed } from "./clients/feed";
|
|
|
2
2
|
import Knock from "./knock";
|
|
3
3
|
|
|
4
4
|
export * from "./interfaces";
|
|
5
|
+
export * from "./clients/guide";
|
|
5
6
|
export * from "./clients/feed/types";
|
|
6
7
|
export * from "./clients/feed/interfaces";
|
|
7
8
|
export * from "./clients/objects";
|