@funnelsgrove/runtime 0.7.27 → 0.7.29

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.
@@ -0,0 +1,11 @@
1
+ import type { FunnelUserAnswers } from '../sdk/userAnswers.js';
2
+ type FunnelAnswerDraftScope = {
3
+ funnelId: string | null | undefined;
4
+ funnelVersionId: string | null | undefined;
5
+ userId: string | null | undefined;
6
+ };
7
+ export declare const buildFunnelAnswerDraftStorageKey: (scope: FunnelAnswerDraftScope) => string | null;
8
+ export declare const readFunnelAnswerDraft: (scope: FunnelAnswerDraftScope, nowMs?: number) => FunnelUserAnswers | null;
9
+ export declare const writeFunnelAnswerDraft: (scope: FunnelAnswerDraftScope, answers: FunnelUserAnswers, nowMs?: number) => boolean;
10
+ export declare const clearFunnelAnswerDraft: (scope: FunnelAnswerDraftScope) => void;
11
+ export {};
@@ -0,0 +1,82 @@
1
+ const ANSWER_DRAFT_VERSION = 1;
2
+ const ANSWER_DRAFT_TTL_MS = 14 * 24 * 60 * 60 * 1000;
3
+ const MAX_ANSWER_DRAFT_BYTES = 65536;
4
+ const asNonEmptyPart = (value) => {
5
+ const normalized = value === null || value === void 0 ? void 0 : value.trim();
6
+ return normalized || null;
7
+ };
8
+ const isRecord = (value) => (Boolean(value) && typeof value === 'object' && !Array.isArray(value));
9
+ export const buildFunnelAnswerDraftStorageKey = (scope) => {
10
+ const funnelId = asNonEmptyPart(scope.funnelId);
11
+ const funnelVersionId = asNonEmptyPart(scope.funnelVersionId);
12
+ const userId = asNonEmptyPart(scope.userId);
13
+ if (!funnelId || !funnelVersionId || !userId) {
14
+ return null;
15
+ }
16
+ return `funnel:${funnelId}:${funnelVersionId}:${userId}:answer-draft`;
17
+ };
18
+ export const readFunnelAnswerDraft = (scope, nowMs = Date.now()) => {
19
+ const key = buildFunnelAnswerDraftStorageKey(scope);
20
+ if (!key || typeof window === 'undefined') {
21
+ return null;
22
+ }
23
+ try {
24
+ const raw = window.localStorage.getItem(key);
25
+ if (!raw) {
26
+ return null;
27
+ }
28
+ const parsed = JSON.parse(raw);
29
+ if (parsed.version !== ANSWER_DRAFT_VERSION
30
+ || typeof parsed.updatedAt !== 'number'
31
+ || !Number.isFinite(parsed.updatedAt)
32
+ || parsed.updatedAt > nowMs
33
+ || nowMs - parsed.updatedAt > ANSWER_DRAFT_TTL_MS
34
+ || !isRecord(parsed.answers)) {
35
+ window.localStorage.removeItem(key);
36
+ return null;
37
+ }
38
+ return parsed.answers;
39
+ }
40
+ catch (_a) {
41
+ try {
42
+ window.localStorage.removeItem(key);
43
+ }
44
+ catch (_b) {
45
+ // Storage may be unavailable in privacy-constrained browser contexts.
46
+ }
47
+ return null;
48
+ }
49
+ };
50
+ export const writeFunnelAnswerDraft = (scope, answers, nowMs = Date.now()) => {
51
+ const key = buildFunnelAnswerDraftStorageKey(scope);
52
+ if (!key || typeof window === 'undefined' || !isRecord(answers)) {
53
+ return false;
54
+ }
55
+ try {
56
+ const serialized = JSON.stringify({
57
+ version: ANSWER_DRAFT_VERSION,
58
+ updatedAt: nowMs,
59
+ answers,
60
+ });
61
+ if (new TextEncoder().encode(serialized).byteLength > MAX_ANSWER_DRAFT_BYTES) {
62
+ return false;
63
+ }
64
+ window.localStorage.setItem(key, serialized);
65
+ return true;
66
+ }
67
+ catch (_a) {
68
+ return false;
69
+ }
70
+ };
71
+ export const clearFunnelAnswerDraft = (scope) => {
72
+ const key = buildFunnelAnswerDraftStorageKey(scope);
73
+ if (!key || typeof window === 'undefined') {
74
+ return;
75
+ }
76
+ try {
77
+ window.localStorage.removeItem(key);
78
+ }
79
+ catch (_a) {
80
+ // Storage may be unavailable in privacy-constrained browser contexts.
81
+ }
82
+ };
@@ -4,7 +4,7 @@ import { useFunnelRuntimeConfig } from '../components/FunnelRuntimeConfigBoundar
4
4
  import { writeStepChoice } from '../sdk/userAnswers.js';
5
5
  import { apiService } from '../services/api.service.js';
6
6
  import { logger } from '../services/logger.js';
7
- import { FUNNEL_ID, POSTHOG_API_HOST, POSTHOG_PROJECT_API_KEY, PROJECT_ID, } from '../services/runtime-api.config.js';
7
+ import { FUNNEL_ID, FUNNEL_VERSION_ID, POSTHOG_API_HOST, POSTHOG_PROJECT_API_KEY, PROJECT_ID, } from '../services/runtime-api.config.js';
8
8
  import { buildHostedStepLocation, dispatchWindowCustomEvent, updateFunnelHistory, } from './browser-helpers.js';
9
9
  import { isPreviewStepLockRequested, resolveNextStepFromContext, shouldRunAutoAdvanceTimer, } from './funnel-flow.js';
10
10
  import { usePreviewBridge } from './preview-bridge.js';
@@ -19,6 +19,7 @@ import { isDevelopmentRuntime } from '../config/env.config.js';
19
19
  import { consumeChoiceCompletionMarker, enforceChoiceBypassGuard, registerInternalChoiceCommit, runAtomicChoiceCommit, } from './use-step-choices.js';
20
20
  import { createFunnelStepLifecycleState, finishVisit, startVisit, } from './funnel-step-lifecycle.js';
21
21
  import { createEmailCaptureSingleFlight, submitEmailCapture as submitEmailCapturePrimitive, } from './submit-email-capture.js';
22
+ import { clearFunnelAnswerDraft, readFunnelAnswerDraft, writeFunnelAnswerDraft, } from './funnel-answer-draft.js';
22
23
  const isTerminalCompletion = (stepMeta, outcome) => {
23
24
  var _a;
24
25
  return (outcome.type === 'complete'
@@ -322,14 +323,25 @@ export function useFunnelFlowController({ api = apiService, analytics, stepContr
322
323
  }, [initialStepId, resolveFallbackStepId, resolveRuntimeInitialStepId]);
323
324
  const isSubscriptionManagementRuntime = ((_g = stepById[safeInitialStepId]) === null || _g === void 0 ? void 0 : _g.kind) === 'manage-subscription';
324
325
  const lifecycleEventsSuppressed = isPreviewRuntime || editorModeEnabled || isSubscriptionManagementRuntime;
326
+ const [initialRuntimeIdentity] = useState(() => {
327
+ const urlAttributes = resolveUrlUserAttributes();
328
+ const clientUserId = isSubscriptionManagementRuntime
329
+ ? urlAttributes.userId || ''
330
+ : api.getOrCreateClientUserId();
331
+ return { clientUserId, urlAttributes };
332
+ });
325
333
  const [activeStepId, setActiveStepId] = useState(safeInitialStepId);
326
- const [attributes, setAttributes] = useState(() => (Object.assign({}, (initialAttributes || {}))));
334
+ const [attributes, setAttributes] = useState(() => (Object.assign(Object.assign({}, (isSubscriptionManagementRuntime || isPreviewRuntime
335
+ ? {}
336
+ : readFunnelAnswerDraft({
337
+ funnelId: FUNNEL_ID,
338
+ funnelVersionId: FUNNEL_VERSION_ID,
339
+ userId: initialRuntimeIdentity.clientUserId,
340
+ }) || {})), (initialAttributes || {}))));
327
341
  const [user, setUser] = useState(() => ({
328
- id: isSubscriptionManagementRuntime
329
- ? resolveUrlUserAttributes().userId || ''
330
- : api.getOrCreateClientUserId(),
331
- name: '',
332
- email: '',
342
+ id: initialRuntimeIdentity.clientUserId,
343
+ name: initialRuntimeIdentity.urlAttributes.name || '',
344
+ email: initialRuntimeIdentity.urlAttributes.email || '',
333
345
  attributes: {},
334
346
  document: {},
335
347
  completedSteps: [],
@@ -353,6 +365,7 @@ export function useFunnelFlowController({ api = apiService, analytics, stepContr
353
365
  const stepLifecycleStateRef = useRef(createFunnelStepLifecycleState());
354
366
  const activeVisitTokenRef = useRef(null);
355
367
  const emailCaptureSingleFlightRef = useRef(createEmailCaptureSingleFlight());
368
+ const answersCommittedRef = useRef(false);
356
369
  const pendingEmailCaptureNavigationRef = useRef(null);
357
370
  const appliedInitialStepIdRef = useRef(safeInitialStepId);
358
371
  const safeActiveStepId = useMemo(() => { var _a; return (_a = resolveRenderableStepId(activeStepId)) !== null && _a !== void 0 ? _a : safeInitialStepId; }, [activeStepId, resolveRenderableStepId, safeInitialStepId]);
@@ -474,6 +487,16 @@ export function useFunnelFlowController({ api = apiService, analytics, stepContr
474
487
  useEffect(() => {
475
488
  attributesRef.current = attributes;
476
489
  }, [attributes]);
490
+ useEffect(() => {
491
+ if (isPreviewRuntime || isSubscriptionManagementRuntime || answersCommittedRef.current) {
492
+ return;
493
+ }
494
+ writeFunnelAnswerDraft({
495
+ funnelId: FUNNEL_ID,
496
+ funnelVersionId: FUNNEL_VERSION_ID,
497
+ userId: user.id,
498
+ }, attributes);
499
+ }, [attributes, isPreviewRuntime, isSubscriptionManagementRuntime, user.id]);
477
500
  useEffect(() => {
478
501
  postHogFeatureFlagsRef.current = postHogFeatureFlags;
479
502
  }, [postHogFeatureFlags]);
@@ -522,12 +545,12 @@ export function useFunnelFlowController({ api = apiService, analytics, stepContr
522
545
  setPostHogReady(true);
523
546
  return;
524
547
  }
525
- const localUserId = api.getOrCreateClientUserId();
548
+ const localUserId = initialRuntimeIdentity.clientUserId;
526
549
  const bootstrapCandidateUserId = api.getBootstrapCandidateUserId(localUserId);
527
550
  const initialAttribution = collectCurrentFunnelAttribution();
528
551
  initialAttributionRef.current = initialAttribution;
529
552
  setUserBootstrapped(isPreviewRuntime);
530
- setUser((prev) => (Object.assign(Object.assign({}, prev), { id: localUserId })));
553
+ setUser((prev) => (Object.assign(Object.assign({}, prev), { id: localUserId, name: prev.name || urlUserAttributes.name || '', email: prev.email || urlUserAttributes.email || '' })));
531
554
  if (isPreviewRuntime) {
532
555
  return;
533
556
  }
@@ -551,8 +574,6 @@ export function useFunnelFlowController({ api = apiService, analytics, stepContr
551
574
  const sessionBootstrap = api
552
575
  .bootstrapSession({
553
576
  userId: bootstrapCandidateUserId || localUserId,
554
- email: urlUserAttributes.email || undefined,
555
- name: urlUserAttributes.name || undefined,
556
577
  attribution: initialAttribution,
557
578
  })
558
579
  .then((sessionUser) => {
@@ -560,8 +581,13 @@ export function useFunnelFlowController({ api = apiService, analytics, stepContr
560
581
  const sessionAttributes = Object.keys(sessionUser.attributes || {}).length > 0
561
582
  ? sessionUser.attributes
562
583
  : null;
563
- if (sessionAttributes) {
564
- setAttributes((prev) => (Object.assign(Object.assign({}, sessionAttributes), prev)));
584
+ const sessionDraft = readFunnelAnswerDraft({
585
+ funnelId: FUNNEL_ID,
586
+ funnelVersionId: FUNNEL_VERSION_ID,
587
+ userId: sessionUser.id,
588
+ });
589
+ if (sessionAttributes || sessionDraft) {
590
+ setAttributes((prev) => (Object.assign(Object.assign(Object.assign({}, (sessionAttributes || {})), (sessionDraft || {})), prev)));
565
591
  }
566
592
  initialAttributionRef.current = resolveFunnelEventAttribution(sessionUser.document, initialAttribution);
567
593
  setUser((prev) => (Object.assign(Object.assign({}, prev), { id: sessionUser.id || prev.id, name: sessionUser.name || prev.name, email: sessionUser.email || prev.email, document: sessionUser.document, attributes: sessionAttributes ? Object.assign(Object.assign({}, sessionAttributes), prev.attributes) : prev.attributes })));
@@ -602,6 +628,7 @@ export function useFunnelFlowController({ api = apiService, analytics, stepContr
602
628
  return cancelAttributionFailOpen;
603
629
  }, [
604
630
  api,
631
+ initialRuntimeIdentity.clientUserId,
605
632
  isPreviewRuntime,
606
633
  isSubscriptionManagementRuntime,
607
634
  sessionReplayEnabled,
@@ -640,25 +667,9 @@ export function useFunnelFlowController({ api = apiService, analytics, stepContr
640
667
  const completionAttributes = Object.assign({}, attributesRef.current);
641
668
  dispatchWindowCustomEvent('funnel:step-completed', record);
642
669
  setUser((prev) => {
643
- var _a, _b;
670
+ var _a;
644
671
  const updatedSteps = [...((_a = prev.completedSteps) !== null && _a !== void 0 ? _a : []), record];
645
672
  const updatedUser = Object.assign(Object.assign({}, prev), { attributes: Object.assign(Object.assign({}, prev.attributes), completionAttributes), document: prev.document, completedSteps: updatedSteps });
646
- if (!isPreviewRuntime) {
647
- api
648
- .updateUser({
649
- id: updatedUser.id,
650
- name: updatedUser.name,
651
- email: updatedUser.email,
652
- attributes: Object.assign(Object.assign({}, updatedUser.attributes), { completedSteps: updatedSteps }),
653
- document: (_b = updatedUser.document) !== null && _b !== void 0 ? _b : {},
654
- }, {
655
- attribution: collectCurrentFunnelAttribution(),
656
- persistClientIdentity: false,
657
- })
658
- .catch((error) => {
659
- logger.error('Failed to persist step completion:', error);
660
- });
661
- }
662
673
  return updatedUser;
663
674
  });
664
675
  if (!isPreviewRuntime) {
@@ -670,7 +681,7 @@ export function useFunnelFlowController({ api = apiService, analytics, stepContr
670
681
  logger.error('Failed to track step completion:', error);
671
682
  }
672
683
  }
673
- }, [analytics, api, attributionReady, isPreviewRuntime, lifecycleEventsSuppressed, stepById, stepContractVersion]);
684
+ }, [analytics, attributionReady, isPreviewRuntime, lifecycleEventsSuppressed, stepById, stepContractVersion]);
674
685
  const emitStepExit = useCallback((intent) => {
675
686
  var _a;
676
687
  const meta = stepById[intent.visit.stepId];
@@ -785,11 +796,33 @@ export function useFunnelFlowController({ api = apiService, analytics, stepContr
785
796
  pendingEmailCaptureNavigationRef.current = null;
786
797
  return true;
787
798
  },
788
- persist: (normalizedEmail) => api.captureEmail({
789
- userId: submittedUserId,
790
- email: normalizedEmail,
791
- environment: getRuntimeMode(),
792
- }),
799
+ persist: async (normalizedEmail) => {
800
+ const result = await api.captureEmail({
801
+ userId: submittedUserId,
802
+ email: normalizedEmail,
803
+ environment: getRuntimeMode(),
804
+ answers: Object.assign({}, attributesRef.current),
805
+ });
806
+ answersCommittedRef.current = true;
807
+ clearFunnelAnswerDraft({
808
+ funnelId: FUNNEL_ID,
809
+ funnelVersionId: FUNNEL_VERSION_ID,
810
+ userId: submittedUserId,
811
+ });
812
+ clearFunnelAnswerDraft({
813
+ funnelId: FUNNEL_ID,
814
+ funnelVersionId: FUNNEL_VERSION_ID,
815
+ userId: initialRuntimeIdentity.clientUserId,
816
+ });
817
+ if (result.canonicalUser) {
818
+ clearFunnelAnswerDraft({
819
+ funnelId: FUNNEL_ID,
820
+ funnelVersionId: FUNNEL_VERSION_ID,
821
+ userId: result.canonicalUser.id,
822
+ });
823
+ }
824
+ return result;
825
+ },
793
826
  applyUser: (capturedUser) => {
794
827
  setIsReturningSubscriber(false);
795
828
  currentUserIdRef.current = capturedUser.id;
@@ -855,6 +888,7 @@ export function useFunnelFlowController({ api = apiService, analytics, stepContr
855
888
  api,
856
889
  analytics,
857
890
  claimActiveVisit,
891
+ initialRuntimeIdentity.clientUserId,
858
892
  isFunnelStepId,
859
893
  isPreviewRuntime,
860
894
  navigateToStep,
@@ -59,7 +59,6 @@ export type EmailCaptureResult = {
59
59
  activeSubscription: boolean;
60
60
  };
61
61
  declare class ApiService {
62
- private readFileAsDataUrl;
63
62
  getOrCreateClientUserId(): string;
64
63
  persistCanonicalUserId(userId: string): string;
65
64
  getSubscriptionManagementUserId(): string | null;
@@ -81,6 +80,7 @@ declare class ApiService {
81
80
  userId: string;
82
81
  email: string;
83
82
  environment: 'test' | 'live';
83
+ answers?: FunnelUserAnswers;
84
84
  }): Promise<EmailCaptureResult>;
85
85
  syncUrlUserAttributes(input: {
86
86
  user: AppUserInput;
@@ -194,20 +194,6 @@ const toAppUser = (input) => {
194
194
  };
195
195
  };
196
196
  class ApiService {
197
- readFileAsDataUrl(file) {
198
- return new Promise((resolve, reject) => {
199
- const reader = new FileReader();
200
- reader.onload = () => {
201
- if (typeof reader.result !== 'string' || !reader.result.trim()) {
202
- reject(new Error('Failed to read image'));
203
- return;
204
- }
205
- resolve(reader.result);
206
- };
207
- reader.onerror = () => reject(new Error('Failed to read image'));
208
- reader.readAsDataURL(file);
209
- });
210
- }
211
197
  getOrCreateClientUserId() {
212
198
  const existing = readStoredUserId(FUNNEL_ID);
213
199
  if (existing) {
@@ -267,8 +253,8 @@ class ApiService {
267
253
  async bootstrapSession(input) {
268
254
  var _a, _b, _c;
269
255
  const urlUserAttributes = resolveUrlUserAttributes();
270
- const inputEmail = (input === null || input === void 0 ? void 0 : input.email) || urlUserAttributes.email || undefined;
271
- const inputName = (input === null || input === void 0 ? void 0 : input.name) || urlUserAttributes.name || undefined;
256
+ const inputEmail = (input === null || input === void 0 ? void 0 : input.email) || undefined;
257
+ const inputName = (input === null || input === void 0 ? void 0 : input.name) || undefined;
272
258
  const candidateUserId = (input === null || input === void 0 ? void 0 : input.userId) || urlUserAttributes.userId;
273
259
  const userId = await this.resolveBootstrapUserId(candidateUserId);
274
260
  const publishableKey = getFunnelSdkPublishableKey();
@@ -358,6 +344,7 @@ class ApiService {
358
344
  userId,
359
345
  email,
360
346
  environment: input.environment,
347
+ answers: input.answers,
361
348
  });
362
349
  const eventId = asString(payload.eventId);
363
350
  const responseUser = isRecord(payload.user) ? payload.user : null;
@@ -465,8 +452,8 @@ class ApiService {
465
452
  async uploadTempPhoto(input) {
466
453
  const userId = persistUserId(input.userId || this.getOrCreateClientUserId(), FUNNEL_ID);
467
454
  const publishableKey = getFunnelSdkPublishableKey();
468
- const imageDataUrl = await this.readFileAsDataUrl(input.file);
469
455
  if (!publishableKey) {
456
+ const imageDataUrl = URL.createObjectURL(input.file);
470
457
  return {
471
458
  key: `local_${userId}_${Date.now()}`,
472
459
  publicUrl: imageDataUrl,
@@ -475,11 +462,11 @@ class ApiService {
475
462
  uploadedAt: new Date().toISOString(),
476
463
  };
477
464
  }
478
- const payload = await funnelSdkService.uploadPhoto({
465
+ const payload = await funnelSdkService.uploadPhotoFile({
479
466
  userId,
480
467
  fileName: input.file.name || 'capture.jpg',
481
- mimeType: input.file.type || undefined,
482
- imageDataUrl,
468
+ contentType: input.file.type || undefined,
469
+ file: input.file,
483
470
  source: input.source || 'upload',
484
471
  });
485
472
  const publicUrl = asString(payload.publicUrl);
@@ -27,6 +27,7 @@ export type FunnelSdkCaptureEmailInput = {
27
27
  userId: string;
28
28
  email: string;
29
29
  environment: FunnelSdkRuntimeMode;
30
+ answers?: FunnelSdkJsonObject;
30
31
  runtimeConfig?: FunnelSdkRuntimeConfigOverrides;
31
32
  };
32
33
  export type FunnelSdkCaptureEmailResponse = {
@@ -77,6 +78,25 @@ export type FunnelSdkUploadPhotoInput = {
77
78
  userId?: string | null;
78
79
  runtimeConfig?: FunnelSdkRuntimeConfigOverrides;
79
80
  };
81
+ export type FunnelSdkUploadPhotoFileInput = {
82
+ file: Blob;
83
+ fileName: string;
84
+ contentType?: string | null;
85
+ source?: 'camera' | 'upload' | string | null;
86
+ userId?: string | null;
87
+ runtimeConfig?: FunnelSdkRuntimeConfigOverrides;
88
+ };
89
+ export type FunnelSdkPreparedPhotoUpload = {
90
+ key: string;
91
+ publicUrl: string;
92
+ uploadUrl: string;
93
+ uploadHeaders: Record<string, string>;
94
+ contentType: string;
95
+ sizeBytes: number;
96
+ };
97
+ export type FunnelSdkUploadedPhoto = Omit<FunnelSdkPreparedPhotoUpload, 'uploadUrl' | 'uploadHeaders'> & {
98
+ uploadedAt: string;
99
+ };
80
100
  export type FunnelSdkCreateHostedCheckoutSessionInput = {
81
101
  planId: string;
82
102
  offerSetId?: string | null;
@@ -263,6 +283,10 @@ declare class FunnelSdkService {
263
283
  claimUserSubscription<ResponsePayload = FunnelSdkJsonObject>(input: FunnelSdkClaimUserSubscriptionInput): Promise<ResponsePayload>;
264
284
  updateFunnelUser<ResponsePayload = FunnelSdkJsonObject>(input: FunnelSdkUpdateFunnelUserInput): Promise<ResponsePayload>;
265
285
  uploadPhoto<ResponsePayload = FunnelSdkJsonObject>(input: FunnelSdkUploadPhotoInput): Promise<ResponsePayload>;
286
+ preparePhotoUpload(input: Omit<FunnelSdkUploadPhotoFileInput, 'file'> & {
287
+ sizeBytes: number;
288
+ }): Promise<FunnelSdkPreparedPhotoUpload>;
289
+ uploadPhotoFile(input: FunnelSdkUploadPhotoFileInput): Promise<FunnelSdkUploadedPhoto>;
266
290
  listPaymentPlans(input?: {
267
291
  environment?: FunnelSdkRuntimeMode;
268
292
  offerSetId?: string | null;
@@ -122,6 +122,7 @@ class FunnelSdkService {
122
122
  user_id: input.userId,
123
123
  email: input.email,
124
124
  environment: input.environment,
125
+ answers: input.answers,
125
126
  stepContractVersion: CURRENT_STEP_CONTRACT_VERSION,
126
127
  },
127
128
  errorMessage: 'Failed to capture email',
@@ -186,6 +187,48 @@ class FunnelSdkService {
186
187
  errorMessage: 'Failed to upload photo',
187
188
  });
188
189
  }
190
+ async preparePhotoUpload(input) {
191
+ const { funnelId } = resolveRuntimeConfig(input.runtimeConfig);
192
+ return this.postJson('/sdk/public/uploads/photos/prepare', {
193
+ runtimeConfig: input.runtimeConfig,
194
+ body: {
195
+ funnelId,
196
+ userId: input.userId,
197
+ fileName: input.fileName,
198
+ contentType: input.contentType,
199
+ sizeBytes: input.sizeBytes,
200
+ source: input.source,
201
+ },
202
+ includeRuntimeFields: false,
203
+ includePublishableKey: false,
204
+ errorMessage: 'Failed to prepare photo upload',
205
+ });
206
+ }
207
+ async uploadPhotoFile(input) {
208
+ const prepared = await this.preparePhotoUpload({
209
+ fileName: input.fileName,
210
+ contentType: input.contentType,
211
+ sizeBytes: input.file.size,
212
+ source: input.source,
213
+ userId: input.userId,
214
+ runtimeConfig: input.runtimeConfig,
215
+ });
216
+ const response = await fetch(prepared.uploadUrl, {
217
+ method: 'PUT',
218
+ headers: prepared.uploadHeaders,
219
+ body: input.file,
220
+ });
221
+ if (!response.ok) {
222
+ throw new Error(`Failed to upload photo (${response.status})`);
223
+ }
224
+ return {
225
+ key: prepared.key,
226
+ publicUrl: prepared.publicUrl,
227
+ contentType: prepared.contentType,
228
+ sizeBytes: prepared.sizeBytes,
229
+ uploadedAt: new Date().toISOString(),
230
+ };
231
+ }
189
232
  async listPaymentPlans(input = {}) {
190
233
  const { funnelId } = resolveRuntimeConfig(input.runtimeConfig);
191
234
  return this.getJson('/sdk/public/payments/plans', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@funnelsgrove/runtime",
3
- "version": "0.7.27",
3
+ "version": "0.7.29",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/The-Solid-Grove/funnelsgrove.git",