@founderhq/journeys
v0.3.67
Published
Config-driven interactive journey/questionnaire engine for React
Downloads
1,065
Readme
@founderhq/journeys
@founderhq/journeys is a config-driven React runtime for interactive onboarding and questionnaire flows.
What It Includes
- journey runtime with forward/back navigation
- conditional routing between steps
- computed variables derived from prior answers
- block-based informational pages
- reusable styles shipped as
styles.css
Install
pnpm add @founderhq/journeysBasic Usage
import { Journey } from "@founderhq/journeys";
import "@founderhq/journeys/styles.css";
export function Example({ config }: { config: import("@founderhq/journeys").JourneyConfig }) {
return <Journey config={config} storageKey="example-journey" />;
}You can also fetch a journey remotely by passing apiKey and journeyId instead of an inline config.
Injecting Runtime Data (initialAnswers)
Pass initialAnswers to seed the answer state at first render. Useful when journey config references runtime data via templates — e.g. live pricing plans from StoreKit, RevenueCat, or your own API:
<Journey
apiKey="fos_..."
journeyId="abc-123"
initialAnswers={{
pricingPlans: [
{
id: "pro_monthly",
name: "Pro",
price: { amount: 29, currency: "USD", period: "month", trial: { days: 7 } },
metadata: { stripePriceId: "price_xxx" },
},
],
}}
onEvent={(event) => {
if (event.type === "purchase_intent") {
// event.plan.metadata.stripePriceId → kick off Stripe checkout
}
}}
/>In the journey config, reference the injected value with a whole-value template:
{ type: "pricing_plans", props: { variable: "selectedPlan", plans: "${pricingPlans}" } }Keys declared in computedVariables are ignored in initialAnswers; computed values always come from their formulas.
Injecting Runtime Options (initialOptions)
Pass initialOptions to override option lists at render time without storing large dynamic lists in the journey config. Keys are answer keys: top-level steps use step.variable ?? step.id, and single_select / multi_select blocks use props.variable.
<Journey
apiKey="fos_..."
journeyId="abc-123"
initialOptions={{
country: countries.map((country) => ({
id: country.code,
label: country.name,
})),
}}
/>When a key is present in initialOptions, that list is used even if it is empty. When no key is present, the inspector-configured options remain the fallback.
Event Payloads
onEvent payloads keep user-provided answers and computed values separate. step_submit and navigate events include the rendered step config, but option arrays are omitted from those event configs so large initialOptions lists are not copied into every payload.
onEvent={(event) => {
// Raw/persisted answers only.
event.answers;
// Derived values from config.computedVariables.
event.computedVariables;
}}Pricing Plan Templates
When a user selects a plan, the block stores a flattened snapshot in answers[variable]. Templates anywhere downstream can reach into it with dotted paths:
You're subscribing to ${selectedPlan.name} — ${selectedPlan.amount} ${selectedPlan.currency}/${selectedPlan.period}.
${selectedPlan.trialDays} day free trial. Was ${selectedPlan.originalAmount}.Available paths: id, name, amount, currency, period, originalAmount, perUnitLabel, display, trialDays, introOffer, description, badge, icon, features, metadata.
Equality conditions on the variable continue to match the plan id ({ op: "equals", value: "pro_monthly" }).
Per-Plan Templates (currentPlan)
String fields anywhere inside a plan support templates, with currentPlan exposing this card's flattened plan fields (same shape as selectedPlan):
{
id: "pro_monthly",
name: "Pro",
price: { amount: 29, currency: "USD", period: "month", trial: { days: 7 } },
badge: "${currentPlan.trialDays} Days Free",
subtext: "${currentPlan.trialDays} day free trial, then ${currentPlan.amount} ${currentPlan.currency}/${currentPlan.period}",
}subtext is rendered as a small line under the price. Trial and intro-offer lines are no longer auto-rendered — set subtext explicitly if you want them.
Optional Discount Codes
Discount codes are opened from a normal button action, so the Journey author controls whether the UI appears and where the trigger lives:
{
type: "button",
props: {
label: "Have a coupon?",
action: {
type: "open_discount_code",
variable: "pricingDiscount",
planVariable: "selectedPlan",
},
},
}Pass onDiscountCodeApply to validate and reprice in the consumer app:
<Journey
config={config}
onDiscountCodeApply={async ({ code, plan }) => {
const result = await validateCoupon(code, plan?.id);
return result.valid
? {
valid: true,
code,
planId: plan?.id,
message: "Discount applied",
pricing: {
total: result.total,
currency: result.currency,
originalAmount: result.originalTotal,
},
metadata: result.metadata,
}
: { valid: false, reason: result.reason };
}}
/>Set pricing_plans.props.discountVariable to let cards replace the visible non-strike price from pricing.display, pricing.amount, or pricing.total. Set purchase.action.discountVariable to include the full applied discount answer in purchase_intent. Journeys never calculates billing amounts or provider rules; those belong in the consumer callback and metadata.
Config Shape
A journey config is centered around:
arcsLogical step groups and navigation order.stepsIndividual screens such assingle_select,multi_select,input,slider, andinfo_page.computedVariablesDerived values evaluated from the current answer set.
Development
pnpm --filter @founderhq/journeys dev
pnpm --filter @founderhq/journeys buildThe build emits ESM, CJS, type declarations, and dist/styles.css.
