@funnelsgrove/runtime 0.7.2 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,6 +20,7 @@ documented in [`docs/product-specs/funnel-template-runtime.md`](../../docs/produ
20
20
  - funnel context, flow controller, and generic runtime UI primitives
21
21
  - funnel-scoped state/storage helpers
22
22
  - browser-safe API client helpers used by funnels
23
+ - reactive browser query-string access through `useBrowserLocationSearch`
23
24
  - subscription handoff and subscription management screens whose copy stays funnel-local
24
25
  - test-mode developer info surfaces for funnel preview tooling
25
26
 
@@ -54,6 +55,9 @@ documented in [`docs/product-specs/funnel-template-runtime.md`](../../docs/produ
54
55
  - Do not import `@funnelsgrove/payments` or `@funnelsgrove/analytics` here for checkout or transport orchestration.
55
56
  - Runtime UI must be generic and copy-injectable.
56
57
  - Browser-only values must resolve after mount when SSR hydration can be affected.
58
+ - Funnel code must not copy `useSyncExternalStore`/`popstate` query-string hooks. Use
59
+ `useBrowserLocationSearch`; the shared controller also notifies it after
60
+ `history.pushState` and `history.replaceState` navigation.
57
61
  - Keep env mapping centralized in `config/env.config.ts`.
58
62
 
59
63
  ## Build, Test, Publish
package/dist/index.d.ts CHANGED
@@ -18,6 +18,7 @@ export * from './runtime/funnel-runtime.js';
18
18
  export type { FunnelNavigationOutcome, FunnelStepExitReason, } from './runtime/funnel-step-lifecycle.js';
19
19
  export * from './runtime/posthog-flags.js';
20
20
  export * from './runtime/use-funnel-flow-controller.js';
21
+ export * from './runtime/use-browser-location-search.js';
21
22
  export { useStepChoices } from './runtime/use-step-choices.js';
22
23
  export type { FunnelStepChoices } from './runtime/use-step-choices.js';
23
24
  export * from './runtime/preview-bridge.js';
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ export * from './runtime/funnel-step-metadata.validation.js';
20
20
  export * from './runtime/funnel-runtime.js';
21
21
  export * from './runtime/posthog-flags.js';
22
22
  export * from './runtime/use-funnel-flow-controller.js';
23
+ export * from './runtime/use-browser-location-search.js';
23
24
  export { useStepChoices } from './runtime/use-step-choices.js';
24
25
  export * from './runtime/preview-bridge.js';
25
26
  export * from './runtime/preview-definition-overrides.js';
@@ -1,8 +1,10 @@
1
1
  export declare const canUseDom: () => boolean;
2
+ export declare const FUNNEL_LOCATION_CHANGE_EVENT = "funnel:location-change";
2
3
  export declare const readWindowStorageValue: (storageKey: string) => string | null;
3
4
  export declare const writeWindowStorageValue: (storageKey: string, value: string) => void;
4
5
  export declare const removeWindowStorageValue: (storageKey: string) => void;
5
6
  export declare const dispatchWindowCustomEvent: <Detail>(eventType: string, detail: Detail) => void;
7
+ export declare const dispatchFunnelLocationChange: () => void;
6
8
  export declare const buildHostedStepLocation: (input: {
7
9
  currentHref: string;
8
10
  stepPath: string;
@@ -1,6 +1,7 @@
1
1
  export const canUseDom = () => {
2
2
  return typeof window !== 'undefined';
3
3
  };
4
+ export const FUNNEL_LOCATION_CHANGE_EVENT = 'funnel:location-change';
4
5
  export const readWindowStorageValue = (storageKey) => {
5
6
  if (!canUseDom()) {
6
7
  return null;
@@ -42,6 +43,14 @@ export const dispatchWindowCustomEvent = (eventType, detail) => {
42
43
  detail,
43
44
  }));
44
45
  };
46
+ export const dispatchFunnelLocationChange = () => {
47
+ if (!canUseDom()) {
48
+ return;
49
+ }
50
+ dispatchWindowCustomEvent(FUNNEL_LOCATION_CHANGE_EVENT, {
51
+ href: window.location.href,
52
+ });
53
+ };
45
54
  const getHostedFunnelBaseSegments = (pathname) => {
46
55
  const pathSegments = pathname.split('/').filter(Boolean);
47
56
  if (pathSegments[0] === 'published' && pathSegments[1] === 'f' && pathSegments[2]) {
@@ -0,0 +1,2 @@
1
+ export declare const getBrowserLocationSearch: () => string;
2
+ export declare const useBrowserLocationSearch: () => string;
@@ -0,0 +1,20 @@
1
+ 'use client';
2
+ import { useSyncExternalStore } from 'react';
3
+ import { FUNNEL_LOCATION_CHANGE_EVENT } from './browser-helpers.js';
4
+ const subscribeToBrowserLocationSearch = (onStoreChange) => {
5
+ if (typeof window === 'undefined') {
6
+ return () => undefined;
7
+ }
8
+ window.addEventListener('popstate', onStoreChange);
9
+ window.addEventListener(FUNNEL_LOCATION_CHANGE_EVENT, onStoreChange);
10
+ return () => {
11
+ window.removeEventListener('popstate', onStoreChange);
12
+ window.removeEventListener(FUNNEL_LOCATION_CHANGE_EVENT, onStoreChange);
13
+ };
14
+ };
15
+ export const getBrowserLocationSearch = () => {
16
+ return typeof window === 'undefined' ? '' : window.location.search;
17
+ };
18
+ export const useBrowserLocationSearch = () => {
19
+ return useSyncExternalStore(subscribeToBrowserLocationSearch, getBrowserLocationSearch, () => '');
20
+ };
@@ -4,7 +4,7 @@ import { writeStepChoice } from '../sdk/userAnswers.js';
4
4
  import { apiService } from '../services/api.service.js';
5
5
  import { logger } from '../services/logger.js';
6
6
  import { FUNNEL_ID, POSTHOG_API_HOST, POSTHOG_PROJECT_API_KEY, PROJECT_ID, } from '../services/runtime-api.config.js';
7
- import { buildHostedStepLocation, dispatchWindowCustomEvent, } from './browser-helpers.js';
7
+ import { buildHostedStepLocation, dispatchFunnelLocationChange, dispatchWindowCustomEvent, } from './browser-helpers.js';
8
8
  import { isPreviewStepLockRequested, resolveNextStepFromContext, shouldRunAutoAdvanceTimer, } from './funnel-flow.js';
9
9
  import { usePreviewBridge } from './preview-bridge.js';
10
10
  import { resolveExperimentAssignment, resolveForcedExperimentAssignment, } from './experiment-assignment.js';
@@ -422,6 +422,7 @@ export function useFunnelFlowController({ api = apiService, analytics, stepContr
422
422
  else {
423
423
  window.history.pushState({ stepId: safeStepId }, '', nextLocation);
424
424
  }
425
+ dispatchFunnelLocationChange();
425
426
  }
426
427
  setActiveStepId(safeStepId);
427
428
  }, [getPathForStep]);
@@ -1,11 +1,9 @@
1
1
  import type { FunnelExperimentDefinition } from '../config/funnel.experiments.types.js';
2
2
  import type { GeneratedOfferSet } from '../runtime/offer-set-runtime.js';
3
3
  export type FunnelRuntimeConfig = {
4
- schemaVersion: number;
4
+ schemaVersion: 2;
5
5
  funnelId: string;
6
6
  projectId: string;
7
- /** Present only on historical schema-v1 artifacts. */
8
- plans?: unknown[];
9
7
  offerSets: unknown[];
10
8
  experiments: unknown[];
11
9
  };
@@ -21,7 +19,7 @@ export type PublishedFunnelRuntimeConfig = {
21
19
  revisionId: string;
22
20
  sourceDeploymentId: string;
23
21
  sourceVersionId: string;
24
- schemaVersion: number;
22
+ schemaVersion: 2;
25
23
  publishedAt: string;
26
24
  config: FunnelRuntimeConfig;
27
25
  };
@@ -25,20 +25,7 @@ const deepFreeze = (value) => {
25
25
  }
26
26
  return value;
27
27
  };
28
- const getLegacyPlanById = (plans, projectBillingPlanId) => {
29
- var _a;
30
- return (projectBillingPlanId
31
- ? (_a = plans.find((plan) => pickString(plan, 'id') === projectBillingPlanId)) !== null && _a !== void 0 ? _a : null
32
- : null);
33
- };
34
- const getLegacyProviderMapping = (plan, mode) => {
35
- var _a;
36
- return ((_a = readRecordArray(plan === null || plan === void 0 ? void 0 : plan.providerMappings)
37
- .find((mapping) => (pickString(mapping, 'environment') === mode
38
- && pickBoolean(mapping, 'isActive', 'is_active') !== false))) !== null && _a !== void 0 ? _a : null);
39
- };
40
- const buildPlanModeConfig = (item, mode, legacyPlan = null) => {
41
- var _a, _b;
28
+ const buildPlanModeConfig = (item, mode) => {
42
29
  const metadata = isRecord(item.metadata) ? item.metadata : {};
43
30
  const directMappings = isRecord(item.providerMappingsByMode)
44
31
  ? item.providerMappingsByMode
@@ -51,20 +38,16 @@ const buildPlanModeConfig = (item, mode, legacyPlan = null) => {
51
38
  : isRecord(metadataMappings[mode])
52
39
  ? metadataMappings[mode]
53
40
  : {};
54
- const legacyMapping = getLegacyProviderMapping(legacyPlan, mode);
55
41
  const prefix = mode === 'test' ? 'test' : 'live';
56
42
  const providerPlanId = (pickString(item, `${prefix}ProviderPlanId`, `${prefix}_provider_plan_id`)
57
- || pickString(modeMapping, 'providerPlanId')
58
- || (legacyMapping ? pickString(legacyMapping, 'providerPlanId', 'provider_plan_id') : null));
43
+ || pickString(modeMapping, 'providerPlanId'));
59
44
  if (!providerPlanId) {
60
45
  return null;
61
46
  }
62
- const amountMinor = (_a = pickNumber(modeMapping, 'amountCents')) !== null && _a !== void 0 ? _a : (legacyMapping ? pickNumber(legacyMapping, 'amountMinor', 'amount_minor') : null);
63
- const interval = pickString(modeMapping, 'billingInterval')
64
- || (legacyMapping ? pickString(legacyMapping, 'interval') : null);
65
- const intervalCount = (_b = pickNumber(modeMapping, 'billingIntervalCount')) !== null && _b !== void 0 ? _b : (legacyMapping ? pickNumber(legacyMapping, 'intervalCount', 'interval_count') : null);
66
- const currency = pickString(modeMapping, 'currency')
67
- || (legacyMapping ? pickString(legacyMapping, 'currency') : null);
47
+ const amountMinor = pickNumber(modeMapping, 'amountCents');
48
+ const interval = pickString(modeMapping, 'billingInterval');
49
+ const intervalCount = pickNumber(modeMapping, 'billingIntervalCount');
50
+ const currency = pickString(modeMapping, 'currency');
68
51
  const amountMajor = amountMinor === null ? null : amountMinor / 100;
69
52
  const priceLabel = pickString(modeMapping, 'priceLabel') || (amountMajor === null
70
53
  ? null
@@ -90,7 +73,6 @@ const buildPlanModeConfig = (item, mode, legacyPlan = null) => {
90
73
  }).format(amountMajor)} today` })), (interval ? { billingInterval: interval } : {})), (intervalCount === null ? {} : { billingIntervalCount: intervalCount }));
91
74
  };
92
75
  const normalizePublishedOfferSets = (config) => {
93
- const legacyPlans = readRecordArray(config.plans);
94
76
  return readRecordArray(config.offerSets).map((offerSet) => {
95
77
  var _a;
96
78
  const key = pickString(offerSet, 'key');
@@ -105,15 +87,12 @@ const normalizePublishedOfferSets = (config) => {
105
87
  throw new Error(`Published runtime offer set "${key}" has an item without a plan key`);
106
88
  }
107
89
  const projectBillingPlanId = pickString(item, 'projectBillingPlanId', 'project_billing_plan_id');
108
- const legacyPlan = getLegacyPlanById(legacyPlans, projectBillingPlanId);
109
- const testMapping = buildPlanModeConfig(item, 'test', legacyPlan);
110
- const liveMapping = buildPlanModeConfig(item, 'live', legacyPlan);
90
+ const testMapping = buildPlanModeConfig(item, 'test');
91
+ const liveMapping = buildPlanModeConfig(item, 'live');
111
92
  const title = pickString(item, 'title', 'displayNameOverride', 'display_name_override')
112
- || pickString(metadata, 'title', 'publicPlanName', 'public_plan_name')
113
- || (legacyPlan ? pickString(legacyPlan, 'displayName', 'display_name') : null);
93
+ || pickString(metadata, 'title', 'publicPlanName', 'public_plan_name');
114
94
  const description = pickString(item, 'description')
115
- || pickString(metadata, 'description')
116
- || (legacyPlan ? pickString(legacyPlan, 'description') : null);
95
+ || pickString(metadata, 'description');
117
96
  return Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({ funnelPlanKey }, (pickString(metadata, 'runtimePlanKey', 'planKey', 'billingPlanKey')
118
97
  ? { runtimePlanKey: pickString(metadata, 'runtimePlanKey', 'planKey', 'billingPlanKey') }
119
98
  : {})), (projectBillingPlanId ? { projectBillingPlanId } : {})), (pickString(item, 'testProviderPlanId', 'test_provider_plan_id')
@@ -251,14 +230,13 @@ const parsePublishedRuntimeConfig = (value) => {
251
230
  if (typeof value.revisionId !== 'string'
252
231
  || typeof value.sourceDeploymentId !== 'string'
253
232
  || typeof value.sourceVersionId !== 'string'
254
- || typeof value.schemaVersion !== 'number'
233
+ || value.schemaVersion !== 2
255
234
  || typeof value.publishedAt !== 'string'
256
- || (schemaVersion !== 1 && schemaVersion !== 2)
235
+ || schemaVersion !== 2
257
236
  || typeof config.funnelId !== 'string'
258
237
  || typeof config.projectId !== 'string'
259
238
  || !Array.isArray(config.offerSets)
260
- || !Array.isArray(config.experiments)
261
- || (schemaVersion === 1 && !Array.isArray(config.plans))) {
239
+ || !Array.isArray(config.experiments)) {
262
240
  throw new Error('Funnel runtime config response is invalid');
263
241
  }
264
242
  return value;
@@ -212,10 +212,12 @@ export type FunnelSdkCreateOneTimePaymentIntentResponse = {
212
212
  };
213
213
  export type FunnelSdkChargeOneClickPaymentInput = {
214
214
  planId: string;
215
+ providerPlanId?: string | null;
215
216
  amountCents: number;
216
217
  analyticsMetadata?: Record<string, unknown> | null;
217
218
  checkoutSessionId?: string | null;
218
219
  currency?: string | null;
220
+ description?: string | null;
219
221
  environment?: FunnelSdkRuntimeMode;
220
222
  idempotencyKey?: string | null;
221
223
  runtimeConfig?: FunnelSdkRuntimeConfigOverrides;
@@ -329,8 +329,10 @@ class FunnelSdkService {
329
329
  runtimeConfig: input.runtimeConfig,
330
330
  body: {
331
331
  planId: input.planId,
332
+ providerPlanId: input.providerPlanId,
332
333
  amountCents: input.amountCents,
333
334
  currency: input.currency,
335
+ description: input.description,
334
336
  analyticsMetadata: input.analyticsMetadata,
335
337
  user_id: input.userId,
336
338
  stripe_customer_id: input.stripeCustomerId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@funnelsgrove/runtime",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/The-Solid-Grove/funnelsgrove.git",