@funnelsgrove/runtime 0.1.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/README.md +42 -0
- package/dist/components/FunnelContext.d.ts +43 -0
- package/dist/components/FunnelContext.js +14 -0
- package/dist/components/FunnelEditorPanel.d.ts +22 -0
- package/dist/components/FunnelEditorPanel.js +116 -0
- package/dist/components/shared/PrimaryButton.d.ts +8 -0
- package/dist/components/shared/PrimaryButton.js +4 -0
- package/dist/config/builder-preview.protocol.d.ts +4 -0
- package/dist/config/builder-preview.protocol.js +4 -0
- package/dist/config/funnel-theme.d.ts +59 -0
- package/dist/config/funnel-theme.js +69 -0
- package/dist/config/funnel.manifest.types.d.ts +50 -0
- package/dist/config/funnel.manifest.types.js +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +21 -0
- package/dist/runtime/browser-helpers.d.ts +9 -0
- package/dist/runtime/browser-helpers.js +57 -0
- package/dist/runtime/experiment-assignment.d.ts +4 -0
- package/dist/runtime/experiment-assignment.js +32 -0
- package/dist/runtime/funnel-flow.d.ts +23 -0
- package/dist/runtime/funnel-flow.js +66 -0
- package/dist/runtime/funnel-manifest.validation.d.ts +2 -0
- package/dist/runtime/funnel-manifest.validation.js +63 -0
- package/dist/runtime/funnel-runtime.d.ts +34 -0
- package/dist/runtime/funnel-runtime.js +75 -0
- package/dist/runtime/preview-bridge.d.ts +51 -0
- package/dist/runtime/preview-bridge.js +230 -0
- package/dist/runtime/route-resolver.d.ts +18 -0
- package/dist/runtime/route-resolver.js +91 -0
- package/dist/runtime/use-funnel-flow-controller.d.ts +58 -0
- package/dist/runtime/use-funnel-flow-controller.js +523 -0
- package/dist/sdk/userAnswers.d.ts +45 -0
- package/dist/sdk/userAnswers.js +10 -0
- package/dist/services/api.service.d.ts +81 -0
- package/dist/services/api.service.js +346 -0
- package/dist/services/logger.d.ts +8 -0
- package/dist/services/logger.js +16 -0
- package/dist/services/preview-frame.service.d.ts +4 -0
- package/dist/services/preview-frame.service.js +31 -0
- package/dist/services/runtime-api.config.d.ts +7 -0
- package/dist/services/runtime-api.config.js +40 -0
- package/dist/services/runtime-mode.service.d.ts +5 -0
- package/dist/services/runtime-mode.service.js +113 -0
- package/dist/steps/types.d.ts +22 -0
- package/dist/steps/types.js +17 -0
- package/package.json +39 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { FunnelUserAnswers } from '../sdk/userAnswers';
|
|
2
|
+
export type AppUser = {
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
email: string;
|
|
6
|
+
attributes: FunnelUserAnswers;
|
|
7
|
+
};
|
|
8
|
+
export type FunnelEvent = {
|
|
9
|
+
userId: string;
|
|
10
|
+
eventType: string;
|
|
11
|
+
stepId?: string;
|
|
12
|
+
stepName?: string;
|
|
13
|
+
startedAt?: string;
|
|
14
|
+
endedAt?: string;
|
|
15
|
+
selected?: Record<string, unknown>;
|
|
16
|
+
metadata?: Record<string, unknown>;
|
|
17
|
+
};
|
|
18
|
+
export type StepCompletionEvent = {
|
|
19
|
+
userId: string;
|
|
20
|
+
stepId: string;
|
|
21
|
+
stepName: string;
|
|
22
|
+
startedAt: string;
|
|
23
|
+
endedAt: string;
|
|
24
|
+
selected: Record<string, unknown>;
|
|
25
|
+
};
|
|
26
|
+
export type ManageSubscriptionsResponse = {
|
|
27
|
+
user: {
|
|
28
|
+
id: string;
|
|
29
|
+
externalUserId: string;
|
|
30
|
+
email: string | null;
|
|
31
|
+
fullName: string | null;
|
|
32
|
+
subscriptionStatus: string | null;
|
|
33
|
+
} | null;
|
|
34
|
+
subscriptions: Array<{
|
|
35
|
+
id: string;
|
|
36
|
+
status: string;
|
|
37
|
+
providerSubscriptionId: string;
|
|
38
|
+
cancelAtPeriodEnd: boolean;
|
|
39
|
+
currentPeriodEnd: string | null;
|
|
40
|
+
environment: 'test' | 'live';
|
|
41
|
+
}>;
|
|
42
|
+
eventsCount: number;
|
|
43
|
+
};
|
|
44
|
+
export type TempPhotoUploadResult = {
|
|
45
|
+
key: string;
|
|
46
|
+
publicUrl: string;
|
|
47
|
+
contentType: string;
|
|
48
|
+
sizeBytes: number;
|
|
49
|
+
uploadedAt: string;
|
|
50
|
+
};
|
|
51
|
+
declare class ApiService {
|
|
52
|
+
private readFileAsDataUrl;
|
|
53
|
+
getOrCreateClientUserId(): string;
|
|
54
|
+
bootstrapSession(input?: {
|
|
55
|
+
userId?: string;
|
|
56
|
+
email?: string;
|
|
57
|
+
name?: string;
|
|
58
|
+
}): Promise<AppUser>;
|
|
59
|
+
updateUser(user: AppUser): Promise<AppUser>;
|
|
60
|
+
uploadTempPhoto(input: {
|
|
61
|
+
file: File;
|
|
62
|
+
externalUserId?: string;
|
|
63
|
+
source?: 'camera' | 'upload';
|
|
64
|
+
}): Promise<TempPhotoUploadResult>;
|
|
65
|
+
private sendEventAsync;
|
|
66
|
+
trackFunnelEvent(event: FunnelEvent): void;
|
|
67
|
+
trackStepStarted(input: {
|
|
68
|
+
userId: string;
|
|
69
|
+
stepId: string;
|
|
70
|
+
stepName: string;
|
|
71
|
+
startedAt: string;
|
|
72
|
+
}): void;
|
|
73
|
+
trackStepCompleted(event: StepCompletionEvent): void;
|
|
74
|
+
getManageSubscriptions(): Promise<ManageSubscriptionsResponse>;
|
|
75
|
+
updateSubscription(input: {
|
|
76
|
+
subscriptionId: string;
|
|
77
|
+
action: 'cancel' | 'renew';
|
|
78
|
+
}): Promise<ManageSubscriptionsResponse>;
|
|
79
|
+
}
|
|
80
|
+
export declare const apiService: ApiService;
|
|
81
|
+
export {};
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { buildMainApiUrl, buildSdkHeaders, FUNNEL_ID, getFunnelSdkPublishableKey, } from './runtime-api.config';
|
|
2
|
+
const USER_ID_STORAGE_KEY = 'funnel:external-user-id';
|
|
3
|
+
const canUseDom = () => {
|
|
4
|
+
return typeof window !== 'undefined';
|
|
5
|
+
};
|
|
6
|
+
const isRecord = (value) => {
|
|
7
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
8
|
+
};
|
|
9
|
+
const asRecord = (value) => {
|
|
10
|
+
return isRecord(value) ? value : {};
|
|
11
|
+
};
|
|
12
|
+
const asString = (value) => {
|
|
13
|
+
if (typeof value !== 'string') {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
const trimmed = value.trim();
|
|
17
|
+
return trimmed || null;
|
|
18
|
+
};
|
|
19
|
+
const asNumber = (value) => {
|
|
20
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
return value;
|
|
24
|
+
};
|
|
25
|
+
const normalizeUserId = (value) => {
|
|
26
|
+
if (!value || typeof value !== 'string') {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const trimmed = value.trim();
|
|
30
|
+
return trimmed || null;
|
|
31
|
+
};
|
|
32
|
+
const generateUserId = () => {
|
|
33
|
+
var _a;
|
|
34
|
+
if (canUseDom() && typeof ((_a = window.crypto) === null || _a === void 0 ? void 0 : _a.randomUUID) === 'function') {
|
|
35
|
+
return `sdk_user_${window.crypto.randomUUID().replace(/-/g, '')}`;
|
|
36
|
+
}
|
|
37
|
+
return `sdk_user_${Date.now()}_${Math.random().toString(16).slice(2, 10)}`;
|
|
38
|
+
};
|
|
39
|
+
const readStoredUserId = () => {
|
|
40
|
+
if (!canUseDom()) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
return normalizeUserId(window.localStorage.getItem(USER_ID_STORAGE_KEY));
|
|
44
|
+
};
|
|
45
|
+
const persistUserId = (value) => {
|
|
46
|
+
const normalized = normalizeUserId(value) || generateUserId();
|
|
47
|
+
if (canUseDom()) {
|
|
48
|
+
window.localStorage.setItem(USER_ID_STORAGE_KEY, normalized);
|
|
49
|
+
}
|
|
50
|
+
return normalized;
|
|
51
|
+
};
|
|
52
|
+
const buildSdkEvent = (event) => {
|
|
53
|
+
return {
|
|
54
|
+
eventType: event.eventType,
|
|
55
|
+
stepId: event.stepId || null,
|
|
56
|
+
stepName: event.stepName || null,
|
|
57
|
+
startedAt: event.startedAt || null,
|
|
58
|
+
endedAt: event.endedAt || null,
|
|
59
|
+
selected: event.selected || {},
|
|
60
|
+
metadata: event.metadata || {},
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
const toAppUser = (input) => {
|
|
64
|
+
var _a;
|
|
65
|
+
const apiUser = input.apiUser;
|
|
66
|
+
const apiDocument = asRecord(apiUser === null || apiUser === void 0 ? void 0 : apiUser.document);
|
|
67
|
+
const apiProgress = asRecord(apiDocument.progress);
|
|
68
|
+
const apiAttributes = asRecord(apiProgress.attributes);
|
|
69
|
+
return {
|
|
70
|
+
id: input.externalUserId,
|
|
71
|
+
name: asString(apiUser === null || apiUser === void 0 ? void 0 : apiUser.fullName) || input.fallbackName || '',
|
|
72
|
+
email: asString(apiUser === null || apiUser === void 0 ? void 0 : apiUser.email) || input.fallbackEmail || '',
|
|
73
|
+
attributes: Object.keys(apiAttributes).length > 0
|
|
74
|
+
? apiAttributes
|
|
75
|
+
: ((_a = input.fallbackAttributes) !== null && _a !== void 0 ? _a : {}),
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
const parseErrorMessage = async (response, fallbackMessage) => {
|
|
79
|
+
try {
|
|
80
|
+
const payload = (await response.json());
|
|
81
|
+
const apiError = asString(payload.error);
|
|
82
|
+
if (apiError) {
|
|
83
|
+
return apiError;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
catch (_a) {
|
|
87
|
+
// ignore JSON parsing errors
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const errorText = await response.text();
|
|
91
|
+
if (errorText.trim()) {
|
|
92
|
+
return errorText;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch (_b) {
|
|
96
|
+
// ignore text parsing errors
|
|
97
|
+
}
|
|
98
|
+
return fallbackMessage;
|
|
99
|
+
};
|
|
100
|
+
class ApiService {
|
|
101
|
+
readFileAsDataUrl(file) {
|
|
102
|
+
return new Promise((resolve, reject) => {
|
|
103
|
+
const reader = new FileReader();
|
|
104
|
+
reader.onload = () => {
|
|
105
|
+
if (typeof reader.result !== 'string' || !reader.result.trim()) {
|
|
106
|
+
reject(new Error('Failed to read image'));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
resolve(reader.result);
|
|
110
|
+
};
|
|
111
|
+
reader.onerror = () => reject(new Error('Failed to read image'));
|
|
112
|
+
reader.readAsDataURL(file);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
getOrCreateClientUserId() {
|
|
116
|
+
const existing = readStoredUserId();
|
|
117
|
+
if (existing) {
|
|
118
|
+
return existing;
|
|
119
|
+
}
|
|
120
|
+
return persistUserId(generateUserId());
|
|
121
|
+
}
|
|
122
|
+
async bootstrapSession(input) {
|
|
123
|
+
var _a;
|
|
124
|
+
const externalUserId = persistUserId((input === null || input === void 0 ? void 0 : input.userId) || this.getOrCreateClientUserId());
|
|
125
|
+
const publishableKey = getFunnelSdkPublishableKey();
|
|
126
|
+
if (!publishableKey) {
|
|
127
|
+
return toAppUser({
|
|
128
|
+
externalUserId,
|
|
129
|
+
fallbackName: input === null || input === void 0 ? void 0 : input.name,
|
|
130
|
+
fallbackEmail: input === null || input === void 0 ? void 0 : input.email,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
const response = await fetch(buildMainApiUrl('/sdk/public/users/bootstrap'), {
|
|
134
|
+
method: 'POST',
|
|
135
|
+
headers: buildSdkHeaders({
|
|
136
|
+
'Content-Type': 'application/json',
|
|
137
|
+
}),
|
|
138
|
+
body: JSON.stringify({
|
|
139
|
+
publishableKey,
|
|
140
|
+
funnelId: FUNNEL_ID || undefined,
|
|
141
|
+
externalUserId,
|
|
142
|
+
email: (input === null || input === void 0 ? void 0 : input.email) || undefined,
|
|
143
|
+
fullName: (input === null || input === void 0 ? void 0 : input.name) || undefined,
|
|
144
|
+
metadata: {
|
|
145
|
+
source: 'funnel-session',
|
|
146
|
+
},
|
|
147
|
+
}),
|
|
148
|
+
});
|
|
149
|
+
if (!response.ok) {
|
|
150
|
+
const errorMessage = await parseErrorMessage(response, 'Failed to bootstrap user session');
|
|
151
|
+
throw new Error(errorMessage);
|
|
152
|
+
}
|
|
153
|
+
const payload = (await response.json());
|
|
154
|
+
const persistedUserId = persistUserId(asString((_a = payload.user) === null || _a === void 0 ? void 0 : _a.externalUserId) ||
|
|
155
|
+
asString(payload.externalUserId) ||
|
|
156
|
+
externalUserId) || externalUserId;
|
|
157
|
+
return toAppUser({
|
|
158
|
+
apiUser: payload.user,
|
|
159
|
+
externalUserId: persistedUserId,
|
|
160
|
+
fallbackName: input === null || input === void 0 ? void 0 : input.name,
|
|
161
|
+
fallbackEmail: input === null || input === void 0 ? void 0 : input.email,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
async updateUser(user) {
|
|
165
|
+
const persistedUserId = persistUserId(user.id || this.getOrCreateClientUserId());
|
|
166
|
+
const publishableKey = getFunnelSdkPublishableKey();
|
|
167
|
+
if (!publishableKey) {
|
|
168
|
+
return toAppUser({
|
|
169
|
+
externalUserId: persistedUserId,
|
|
170
|
+
fallbackName: user.name,
|
|
171
|
+
fallbackEmail: user.email,
|
|
172
|
+
fallbackAttributes: user.attributes,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
const response = await fetch(buildMainApiUrl('/public/funnel-users/upsert'), {
|
|
176
|
+
method: 'POST',
|
|
177
|
+
headers: buildSdkHeaders({
|
|
178
|
+
'Content-Type': 'application/json',
|
|
179
|
+
}),
|
|
180
|
+
body: JSON.stringify({
|
|
181
|
+
publishableKey,
|
|
182
|
+
funnelId: FUNNEL_ID || undefined,
|
|
183
|
+
externalUserId: persistedUserId,
|
|
184
|
+
email: user.email || undefined,
|
|
185
|
+
fullName: user.name || undefined,
|
|
186
|
+
progress: {
|
|
187
|
+
attributes: user.attributes || {},
|
|
188
|
+
},
|
|
189
|
+
metadata: {
|
|
190
|
+
source: 'funnel',
|
|
191
|
+
},
|
|
192
|
+
}),
|
|
193
|
+
});
|
|
194
|
+
if (!response.ok) {
|
|
195
|
+
const errorMessage = await parseErrorMessage(response, 'Failed to update user');
|
|
196
|
+
throw new Error(errorMessage);
|
|
197
|
+
}
|
|
198
|
+
const payload = (await response.json());
|
|
199
|
+
return toAppUser({
|
|
200
|
+
apiUser: payload.user,
|
|
201
|
+
externalUserId: persistedUserId,
|
|
202
|
+
fallbackName: user.name,
|
|
203
|
+
fallbackEmail: user.email,
|
|
204
|
+
fallbackAttributes: user.attributes,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
async uploadTempPhoto(input) {
|
|
208
|
+
const externalUserId = persistUserId(input.externalUserId || this.getOrCreateClientUserId());
|
|
209
|
+
const publishableKey = getFunnelSdkPublishableKey();
|
|
210
|
+
const imageDataUrl = await this.readFileAsDataUrl(input.file);
|
|
211
|
+
if (!publishableKey) {
|
|
212
|
+
return {
|
|
213
|
+
key: `local_${externalUserId}_${Date.now()}`,
|
|
214
|
+
publicUrl: imageDataUrl,
|
|
215
|
+
contentType: input.file.type || 'image/jpeg',
|
|
216
|
+
sizeBytes: input.file.size || 0,
|
|
217
|
+
uploadedAt: new Date().toISOString(),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
const response = await fetch(buildMainApiUrl('/sdk/public/uploads/photos'), {
|
|
221
|
+
method: 'POST',
|
|
222
|
+
headers: buildSdkHeaders({
|
|
223
|
+
'Content-Type': 'application/json',
|
|
224
|
+
}),
|
|
225
|
+
body: JSON.stringify({
|
|
226
|
+
publishableKey,
|
|
227
|
+
funnelId: FUNNEL_ID || undefined,
|
|
228
|
+
externalUserId,
|
|
229
|
+
fileName: input.file.name || 'capture.jpg',
|
|
230
|
+
mimeType: input.file.type || undefined,
|
|
231
|
+
imageDataUrl,
|
|
232
|
+
source: input.source || 'upload',
|
|
233
|
+
}),
|
|
234
|
+
});
|
|
235
|
+
if (!response.ok) {
|
|
236
|
+
const errorMessage = await parseErrorMessage(response, 'Failed to upload photo');
|
|
237
|
+
throw new Error(errorMessage);
|
|
238
|
+
}
|
|
239
|
+
const payload = (await response.json());
|
|
240
|
+
const publicUrl = asString(payload.publicUrl);
|
|
241
|
+
const key = asString(payload.key);
|
|
242
|
+
const contentType = asString(payload.contentType) || input.file.type || 'image/jpeg';
|
|
243
|
+
const sizeBytes = asNumber(payload.sizeBytes) || input.file.size || 0;
|
|
244
|
+
const uploadedAt = asString(payload.uploadedAt) || new Date().toISOString();
|
|
245
|
+
if (!publicUrl || !key) {
|
|
246
|
+
throw new Error('Invalid photo upload response');
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
key,
|
|
250
|
+
publicUrl,
|
|
251
|
+
contentType,
|
|
252
|
+
sizeBytes,
|
|
253
|
+
uploadedAt,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
sendEventAsync(event) {
|
|
257
|
+
const publishableKey = getFunnelSdkPublishableKey();
|
|
258
|
+
if (!publishableKey) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const payload = JSON.stringify({
|
|
262
|
+
publishableKey,
|
|
263
|
+
funnelId: FUNNEL_ID || undefined,
|
|
264
|
+
externalUserId: event.userId,
|
|
265
|
+
events: [buildSdkEvent(event)],
|
|
266
|
+
});
|
|
267
|
+
const endpoint = buildMainApiUrl('/sdk/public/events');
|
|
268
|
+
if (canUseDom() && typeof navigator.sendBeacon === 'function') {
|
|
269
|
+
const blob = new Blob([payload], { type: 'application/json' });
|
|
270
|
+
const accepted = navigator.sendBeacon(endpoint, blob);
|
|
271
|
+
if (accepted) {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
void fetch(endpoint, {
|
|
276
|
+
method: 'POST',
|
|
277
|
+
headers: buildSdkHeaders({ 'Content-Type': 'application/json' }),
|
|
278
|
+
body: payload,
|
|
279
|
+
keepalive: true,
|
|
280
|
+
}).catch(() => {
|
|
281
|
+
// Never block UI if analytics delivery fails.
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
trackFunnelEvent(event) {
|
|
285
|
+
const userId = persistUserId(event.userId || this.getOrCreateClientUserId());
|
|
286
|
+
this.sendEventAsync(Object.assign(Object.assign({}, event), { userId }));
|
|
287
|
+
}
|
|
288
|
+
trackStepStarted(input) {
|
|
289
|
+
this.trackFunnelEvent({
|
|
290
|
+
userId: input.userId,
|
|
291
|
+
eventType: 'step_start',
|
|
292
|
+
stepId: input.stepId,
|
|
293
|
+
stepName: input.stepName,
|
|
294
|
+
startedAt: input.startedAt,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
trackStepCompleted(event) {
|
|
298
|
+
this.trackFunnelEvent({
|
|
299
|
+
userId: event.userId,
|
|
300
|
+
eventType: 'step_end',
|
|
301
|
+
stepId: event.stepId,
|
|
302
|
+
stepName: event.stepName,
|
|
303
|
+
startedAt: event.startedAt,
|
|
304
|
+
endedAt: event.endedAt,
|
|
305
|
+
selected: event.selected,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
async getManageSubscriptions() {
|
|
309
|
+
const externalUserId = this.getOrCreateClientUserId();
|
|
310
|
+
const query = new URLSearchParams({
|
|
311
|
+
externalUserId,
|
|
312
|
+
});
|
|
313
|
+
if (FUNNEL_ID) {
|
|
314
|
+
query.set('funnelId', FUNNEL_ID);
|
|
315
|
+
}
|
|
316
|
+
const response = await fetch(buildMainApiUrl(`/sdk/public/subscriptions?${query.toString()}`), {
|
|
317
|
+
method: 'GET',
|
|
318
|
+
cache: 'no-store',
|
|
319
|
+
headers: buildSdkHeaders(),
|
|
320
|
+
});
|
|
321
|
+
if (!response.ok) {
|
|
322
|
+
const errorMessage = await parseErrorMessage(response, 'Failed to load subscriptions');
|
|
323
|
+
throw new Error(errorMessage);
|
|
324
|
+
}
|
|
325
|
+
return (await response.json());
|
|
326
|
+
}
|
|
327
|
+
async updateSubscription(input) {
|
|
328
|
+
const externalUserId = this.getOrCreateClientUserId();
|
|
329
|
+
const response = await fetch(buildMainApiUrl(`/sdk/public/subscriptions/${encodeURIComponent(input.subscriptionId)}/${input.action}`), {
|
|
330
|
+
method: 'POST',
|
|
331
|
+
headers: buildSdkHeaders({
|
|
332
|
+
'Content-Type': 'application/json',
|
|
333
|
+
}),
|
|
334
|
+
body: JSON.stringify({
|
|
335
|
+
externalUserId,
|
|
336
|
+
funnelId: FUNNEL_ID || undefined,
|
|
337
|
+
}),
|
|
338
|
+
});
|
|
339
|
+
if (!response.ok) {
|
|
340
|
+
const errorMessage = await parseErrorMessage(response, 'Failed to update subscription');
|
|
341
|
+
throw new Error(errorMessage);
|
|
342
|
+
}
|
|
343
|
+
return (await response.json());
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
export const apiService = new ApiService();
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const makeLoggerMethod = (method) => {
|
|
2
|
+
return (...args) => {
|
|
3
|
+
const consoleMethod = console[method];
|
|
4
|
+
if (typeof consoleMethod === 'function') {
|
|
5
|
+
consoleMethod(...args);
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
console.log(...args);
|
|
9
|
+
};
|
|
10
|
+
};
|
|
11
|
+
export const logger = {
|
|
12
|
+
debug: makeLoggerMethod('debug'),
|
|
13
|
+
error: makeLoggerMethod('error'),
|
|
14
|
+
info: makeLoggerMethod('info'),
|
|
15
|
+
warn: makeLoggerMethod('warn'),
|
|
16
|
+
};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ManageSubscriptionsResponse } from './api.service';
|
|
2
|
+
export declare const isPreviewFrameRuntimeSearch: (search: string) => boolean;
|
|
3
|
+
export declare const isPreviewFrameRuntime: () => boolean;
|
|
4
|
+
export declare const buildPreviewManageSubscriptionsFallback: (externalUserId: string) => ManageSubscriptionsResponse;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
const canUseDom = () => {
|
|
3
|
+
return typeof window !== 'undefined';
|
|
4
|
+
};
|
|
5
|
+
export const isPreviewFrameRuntimeSearch = (search) => {
|
|
6
|
+
try {
|
|
7
|
+
return new URLSearchParams(search).get('previewFrame') === '1';
|
|
8
|
+
}
|
|
9
|
+
catch (_a) {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
export const isPreviewFrameRuntime = () => {
|
|
14
|
+
if (!canUseDom()) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
return isPreviewFrameRuntimeSearch(window.location.search);
|
|
18
|
+
};
|
|
19
|
+
export const buildPreviewManageSubscriptionsFallback = (externalUserId) => {
|
|
20
|
+
return {
|
|
21
|
+
user: {
|
|
22
|
+
id: 'preview-user',
|
|
23
|
+
externalUserId,
|
|
24
|
+
email: null,
|
|
25
|
+
fullName: 'Preview user',
|
|
26
|
+
subscriptionStatus: null,
|
|
27
|
+
},
|
|
28
|
+
subscriptions: [],
|
|
29
|
+
eventsCount: 0,
|
|
30
|
+
};
|
|
31
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const APP_DEALS_API_BASE_URL: string;
|
|
2
|
+
export declare const FUNNEL_SDK_PUBLISHABLE_KEY: string;
|
|
3
|
+
export declare const FUNNEL_ID: string;
|
|
4
|
+
export declare const hasRealFunnelSdkPublishableKey: (value?: string) => boolean;
|
|
5
|
+
export declare const getFunnelSdkPublishableKey: () => string | null;
|
|
6
|
+
export declare const buildMainApiUrl: (path: string) => string;
|
|
7
|
+
export declare const buildSdkHeaders: (headers?: Record<string, string>) => Record<string, string>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
var _a, _b, _c, _d;
|
|
2
|
+
const DEFAULT_API_BASE_URL = 'https://sdk-api.funnelsgrove.com';
|
|
3
|
+
const DEFAULT_FUNNEL_SDK_PUBLISHABLE_KEY = 'pk_test_app_deals_seed_public';
|
|
4
|
+
const trimTrailingSlash = (value) => {
|
|
5
|
+
return value.replace(/\/+$/, '');
|
|
6
|
+
};
|
|
7
|
+
const toNormalizedPath = (path) => {
|
|
8
|
+
return path.startsWith('/') ? path : `/${path}`;
|
|
9
|
+
};
|
|
10
|
+
const isPreviewFrameRuntime = () => {
|
|
11
|
+
if (typeof window === 'undefined') {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
return new URLSearchParams(window.location.search).get('previewFrame') === '1';
|
|
16
|
+
}
|
|
17
|
+
catch (_a) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
export const APP_DEALS_API_BASE_URL = trimTrailingSlash((_a = process.env.NEXT_PUBLIC_APP_DEALS_API_BASE_URL) !== null && _a !== void 0 ? _a : DEFAULT_API_BASE_URL);
|
|
22
|
+
export const FUNNEL_SDK_PUBLISHABLE_KEY = ((_c = (_b = process.env.NEXT_PUBLIC_FUNNEL_SDK_PUBLISHABLE_KEY) !== null && _b !== void 0 ? _b : process.env.NEXT_PUBLIC_FUNNEL_PUBLISHABLE_KEY) !== null && _c !== void 0 ? _c : DEFAULT_FUNNEL_SDK_PUBLISHABLE_KEY).trim();
|
|
23
|
+
export const FUNNEL_ID = ((_d = process.env.NEXT_PUBLIC_FUNNEL_ID) !== null && _d !== void 0 ? _d : '').trim();
|
|
24
|
+
export const hasRealFunnelSdkPublishableKey = (value = FUNNEL_SDK_PUBLISHABLE_KEY) => {
|
|
25
|
+
const normalized = value.trim();
|
|
26
|
+
return Boolean(normalized) && normalized !== DEFAULT_FUNNEL_SDK_PUBLISHABLE_KEY;
|
|
27
|
+
};
|
|
28
|
+
export const getFunnelSdkPublishableKey = () => {
|
|
29
|
+
if (isPreviewFrameRuntime()) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return hasRealFunnelSdkPublishableKey() ? FUNNEL_SDK_PUBLISHABLE_KEY : null;
|
|
33
|
+
};
|
|
34
|
+
export const buildMainApiUrl = (path) => {
|
|
35
|
+
return `${APP_DEALS_API_BASE_URL}${toNormalizedPath(path)}`;
|
|
36
|
+
};
|
|
37
|
+
export const buildSdkHeaders = (headers) => {
|
|
38
|
+
const publishableKey = getFunnelSdkPublishableKey();
|
|
39
|
+
return Object.assign(Object.assign({}, (publishableKey ? { 'x-sdk-publishable-key': publishableKey } : {})), (headers !== null && headers !== void 0 ? headers : {}));
|
|
40
|
+
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type RuntimeMode = 'test' | 'live';
|
|
2
|
+
export declare const isEditorEnabled: () => boolean;
|
|
3
|
+
export declare const getRuntimeMode: () => RuntimeMode;
|
|
4
|
+
export declare const setRuntimeMode: (mode: RuntimeMode) => void;
|
|
5
|
+
export declare const useRuntimeMode: () => [RuntimeMode, (mode: RuntimeMode) => void];
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
3
|
+
const EDITOR_MODE_STORAGE_KEY = 'funnel:editor:mode';
|
|
4
|
+
const MODE_CHANGE_EVENT = 'funnel:editor-mode-changed';
|
|
5
|
+
const canUseDom = () => {
|
|
6
|
+
return typeof window !== 'undefined';
|
|
7
|
+
};
|
|
8
|
+
const toRuntimeMode = (value) => {
|
|
9
|
+
return value === 'live' ? 'live' : 'test';
|
|
10
|
+
};
|
|
11
|
+
export const isEditorEnabled = () => {
|
|
12
|
+
if (!canUseDom()) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
return new URLSearchParams(window.location.search).get('editor') === 'true';
|
|
17
|
+
}
|
|
18
|
+
catch (_a) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
const readStoredEditorMode = () => {
|
|
23
|
+
if (!canUseDom()) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const storedValue = window.localStorage.getItem(EDITOR_MODE_STORAGE_KEY);
|
|
28
|
+
if (!storedValue) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
return toRuntimeMode(storedValue);
|
|
32
|
+
}
|
|
33
|
+
catch (_a) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
const emitModeChange = (mode) => {
|
|
38
|
+
if (!canUseDom()) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
window.dispatchEvent(new CustomEvent(MODE_CHANGE_EVENT, {
|
|
42
|
+
detail: {
|
|
43
|
+
mode,
|
|
44
|
+
},
|
|
45
|
+
}));
|
|
46
|
+
};
|
|
47
|
+
export const getRuntimeMode = () => {
|
|
48
|
+
if (!canUseDom()) {
|
|
49
|
+
return 'live';
|
|
50
|
+
}
|
|
51
|
+
if (!isEditorEnabled()) {
|
|
52
|
+
return 'live';
|
|
53
|
+
}
|
|
54
|
+
return readStoredEditorMode() || 'test';
|
|
55
|
+
};
|
|
56
|
+
export const setRuntimeMode = (mode) => {
|
|
57
|
+
if (!canUseDom()) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const normalizedMode = toRuntimeMode(mode);
|
|
61
|
+
try {
|
|
62
|
+
window.localStorage.setItem(EDITOR_MODE_STORAGE_KEY, normalizedMode);
|
|
63
|
+
}
|
|
64
|
+
catch (_a) {
|
|
65
|
+
// ignore storage failures
|
|
66
|
+
}
|
|
67
|
+
emitModeChange(normalizedMode);
|
|
68
|
+
};
|
|
69
|
+
const subscribeRuntimeMode = (listener) => {
|
|
70
|
+
if (!canUseDom()) {
|
|
71
|
+
return () => undefined;
|
|
72
|
+
}
|
|
73
|
+
const onModeChanged = (event) => {
|
|
74
|
+
var _a;
|
|
75
|
+
const customEvent = event;
|
|
76
|
+
listener(toRuntimeMode((_a = customEvent.detail) === null || _a === void 0 ? void 0 : _a.mode));
|
|
77
|
+
};
|
|
78
|
+
const onStorageChanged = (event) => {
|
|
79
|
+
if (event.key !== EDITOR_MODE_STORAGE_KEY) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
listener(getRuntimeMode());
|
|
83
|
+
};
|
|
84
|
+
window.addEventListener(MODE_CHANGE_EVENT, onModeChanged);
|
|
85
|
+
window.addEventListener('storage', onStorageChanged);
|
|
86
|
+
return () => {
|
|
87
|
+
window.removeEventListener(MODE_CHANGE_EVENT, onModeChanged);
|
|
88
|
+
window.removeEventListener('storage', onStorageChanged);
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
export const useRuntimeMode = () => {
|
|
92
|
+
const [mode, setMode] = useState('live');
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
const syncMode = () => {
|
|
95
|
+
setMode(getRuntimeMode());
|
|
96
|
+
};
|
|
97
|
+
syncMode();
|
|
98
|
+
const unsubscribe = subscribeRuntimeMode((nextMode) => {
|
|
99
|
+
setMode(nextMode);
|
|
100
|
+
});
|
|
101
|
+
window.addEventListener('popstate', syncMode);
|
|
102
|
+
return () => {
|
|
103
|
+
unsubscribe();
|
|
104
|
+
window.removeEventListener('popstate', syncMode);
|
|
105
|
+
};
|
|
106
|
+
}, []);
|
|
107
|
+
const updateMode = useCallback((nextMode) => {
|
|
108
|
+
const normalizedMode = toRuntimeMode(nextMode);
|
|
109
|
+
setRuntimeMode(normalizedMode);
|
|
110
|
+
setMode(normalizedMode);
|
|
111
|
+
}, []);
|
|
112
|
+
return [mode, updateMode];
|
|
113
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export declare const FUNNEL_STEP_TYPES: readonly ["intro_hero", "value_prop_story", "single_step_choice", "single_step_choice_emoji", "multi_select_choice", "form_input", "social_proof", "progress_interstitial", "paywall_offer", "upsell_offer", "checkout", "subscription_management", "subscription_handoff", "cancellation_offer", "summary_confirmation"];
|
|
2
|
+
export type FunnelStepType = (typeof FUNNEL_STEP_TYPES)[number];
|
|
3
|
+
export type FunnelStepActionBar = {
|
|
4
|
+
hidden?: boolean;
|
|
5
|
+
buttonText?: string;
|
|
6
|
+
buttonVariant?: 'muted';
|
|
7
|
+
autoAdvanceMs?: number;
|
|
8
|
+
};
|
|
9
|
+
export type FunnelStepKind = 'default' | 'paywall' | 'upsell' | 'manage-subscription' | 'subscription-handoff' | 'cancellation';
|
|
10
|
+
export type FunnelStepMeta = {
|
|
11
|
+
id: string;
|
|
12
|
+
/** Optional short name to make step intent obvious for non-engineers. */
|
|
13
|
+
name?: string;
|
|
14
|
+
/** Canonical structural taxonomy used across planning, runtime, and RAG examples. */
|
|
15
|
+
type: FunnelStepType;
|
|
16
|
+
/** Optional semantic class used by tooling (e.g. paywall detection). */
|
|
17
|
+
kind?: FunnelStepKind;
|
|
18
|
+
figmaNodeId: string;
|
|
19
|
+
title: string;
|
|
20
|
+
description: string;
|
|
21
|
+
actionBar?: FunnelStepActionBar;
|
|
22
|
+
};
|