@rocapine/react-native-onboarding 1.11.1 → 1.12.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.
Files changed (72) hide show
  1. package/dist/evaluateCondition.d.ts +6 -0
  2. package/dist/evaluateCondition.d.ts.map +1 -0
  3. package/dist/evaluateCondition.js +48 -0
  4. package/dist/evaluateCondition.js.map +1 -0
  5. package/dist/index.d.ts +3 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +12 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/infra/provider/OnboardingProvider.d.ts +2 -0
  10. package/dist/infra/provider/OnboardingProvider.d.ts.map +1 -1
  11. package/dist/infra/provider/OnboardingProvider.js +8 -0
  12. package/dist/infra/provider/OnboardingProvider.js.map +1 -1
  13. package/dist/resolveNextStepNumber.d.ts +11 -0
  14. package/dist/resolveNextStepNumber.d.ts.map +1 -0
  15. package/dist/resolveNextStepNumber.js +39 -0
  16. package/dist/resolveNextStepNumber.js.map +1 -0
  17. package/dist/steps/Carousel/types.d.ts +29 -8
  18. package/dist/steps/Carousel/types.d.ts.map +1 -1
  19. package/dist/steps/Carousel/types.js +1 -8
  20. package/dist/steps/Carousel/types.js.map +1 -1
  21. package/dist/steps/Commitment/types.d.ts +29 -8
  22. package/dist/steps/Commitment/types.d.ts.map +1 -1
  23. package/dist/steps/Commitment/types.js +1 -8
  24. package/dist/steps/Commitment/types.js.map +1 -1
  25. package/dist/steps/ComposableScreen/elements/InputElement.d.ts +2 -2
  26. package/dist/steps/ComposableScreen/types.d.ts +29 -4
  27. package/dist/steps/ComposableScreen/types.d.ts.map +1 -1
  28. package/dist/steps/ComposableScreen/types.js +1 -7
  29. package/dist/steps/ComposableScreen/types.js.map +1 -1
  30. package/dist/steps/Loader/types.d.ts +29 -8
  31. package/dist/steps/Loader/types.d.ts.map +1 -1
  32. package/dist/steps/Loader/types.js +1 -8
  33. package/dist/steps/Loader/types.js.map +1 -1
  34. package/dist/steps/MediaContent/types.d.ts +29 -8
  35. package/dist/steps/MediaContent/types.d.ts.map +1 -1
  36. package/dist/steps/MediaContent/types.js +1 -8
  37. package/dist/steps/MediaContent/types.js.map +1 -1
  38. package/dist/steps/Picker/types.d.ts +30 -8
  39. package/dist/steps/Picker/types.d.ts.map +1 -1
  40. package/dist/steps/Picker/types.js +2 -8
  41. package/dist/steps/Picker/types.js.map +1 -1
  42. package/dist/steps/Question/types.d.ts +30 -8
  43. package/dist/steps/Question/types.d.ts.map +1 -1
  44. package/dist/steps/Question/types.js +2 -8
  45. package/dist/steps/Question/types.js.map +1 -1
  46. package/dist/steps/Ratings/types.d.ts +29 -8
  47. package/dist/steps/Ratings/types.d.ts.map +1 -1
  48. package/dist/steps/Ratings/types.js +1 -8
  49. package/dist/steps/Ratings/types.js.map +1 -1
  50. package/dist/steps/common.types.d.ts +109 -0
  51. package/dist/steps/common.types.d.ts.map +1 -1
  52. package/dist/steps/common.types.js +52 -1
  53. package/dist/steps/common.types.js.map +1 -1
  54. package/dist/types.d.ts +2 -0
  55. package/dist/types.d.ts.map +1 -1
  56. package/package.json +5 -2
  57. package/src/__tests__/evaluateCondition.test.ts +167 -0
  58. package/src/__tests__/resolveNextStepNumber.test.ts +309 -0
  59. package/src/evaluateCondition.ts +50 -0
  60. package/src/index.ts +19 -0
  61. package/src/infra/provider/OnboardingProvider.tsx +11 -1
  62. package/src/resolveNextStepNumber.ts +41 -0
  63. package/src/steps/Carousel/types.ts +2 -9
  64. package/src/steps/Commitment/types.ts +2 -9
  65. package/src/steps/ComposableScreen/types.ts +2 -8
  66. package/src/steps/Loader/types.ts +2 -9
  67. package/src/steps/MediaContent/types.ts +2 -14
  68. package/src/steps/Picker/types.ts +3 -9
  69. package/src/steps/Question/types.ts +3 -9
  70. package/src/steps/Ratings/types.ts +2 -9
  71. package/src/steps/common.types.ts +72 -0
  72. package/src/types.ts +3 -0
@@ -0,0 +1,167 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { evaluateLeaf, evaluateCondition } from "../evaluateCondition";
3
+ import type { Condition } from "../evaluateCondition";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // evaluateLeaf — operator coverage
7
+ // ---------------------------------------------------------------------------
8
+
9
+ describe("evaluateLeaf — eq", () => {
10
+ it("matches equal strings", () => expect(evaluateLeaf({ variable: "x", operator: "eq", value: "male" }, { x: "male" })).toBe(true));
11
+ it("rejects unequal strings", () => expect(evaluateLeaf({ variable: "x", operator: "eq", value: "male" }, { x: "female" })).toBe(false));
12
+ it("coerces number to string for comparison", () => expect(evaluateLeaf({ variable: "x", operator: "eq", value: "42" }, { x: 42 })).toBe(true));
13
+ it("undefined variable yields false", () => expect(evaluateLeaf({ variable: "x", operator: "eq", value: "foo" }, {})).toBe(false));
14
+ });
15
+
16
+ describe("evaluateLeaf — neq", () => {
17
+ it("passes when values differ", () => expect(evaluateLeaf({ variable: "x", operator: "neq", value: "male" }, { x: "female" })).toBe(true));
18
+ it("fails when values equal", () => expect(evaluateLeaf({ variable: "x", operator: "neq", value: "male" }, { x: "male" })).toBe(false));
19
+ });
20
+
21
+ describe("evaluateLeaf — gt", () => {
22
+ it("passes when variable greater", () => expect(evaluateLeaf({ variable: "age", operator: "gt", value: 18 }, { age: 25 })).toBe(true));
23
+ it("fails when equal", () => expect(evaluateLeaf({ variable: "age", operator: "gt", value: 18 }, { age: 18 })).toBe(false));
24
+ it("fails when less", () => expect(evaluateLeaf({ variable: "age", operator: "gt", value: 18 }, { age: 10 })).toBe(false));
25
+ it("coerces string variable", () => expect(evaluateLeaf({ variable: "age", operator: "gt", value: 18 }, { age: "25" })).toBe(true));
26
+ it("coerces string value", () => expect(evaluateLeaf({ variable: "age", operator: "gt", value: "18" }, { age: 25 })).toBe(true));
27
+ });
28
+
29
+ describe("evaluateLeaf — lt", () => {
30
+ it("passes when variable less", () => expect(evaluateLeaf({ variable: "age", operator: "lt", value: 18 }, { age: 10 })).toBe(true));
31
+ it("fails when equal", () => expect(evaluateLeaf({ variable: "age", operator: "lt", value: 18 }, { age: 18 })).toBe(false));
32
+ it("fails when greater", () => expect(evaluateLeaf({ variable: "age", operator: "lt", value: 18 }, { age: 25 })).toBe(false));
33
+ });
34
+
35
+ describe("evaluateLeaf — gte", () => {
36
+ it("passes when greater", () => expect(evaluateLeaf({ variable: "x", operator: "gte", value: 5 }, { x: 6 })).toBe(true));
37
+ it("passes when equal", () => expect(evaluateLeaf({ variable: "x", operator: "gte", value: 5 }, { x: 5 })).toBe(true));
38
+ it("fails when less", () => expect(evaluateLeaf({ variable: "x", operator: "gte", value: 5 }, { x: 4 })).toBe(false));
39
+ });
40
+
41
+ describe("evaluateLeaf — lte", () => {
42
+ it("passes when less", () => expect(evaluateLeaf({ variable: "x", operator: "lte", value: 5 }, { x: 4 })).toBe(true));
43
+ it("passes when equal", () => expect(evaluateLeaf({ variable: "x", operator: "lte", value: 5 }, { x: 5 })).toBe(true));
44
+ it("fails when greater", () => expect(evaluateLeaf({ variable: "x", operator: "lte", value: 5 }, { x: 6 })).toBe(false));
45
+ });
46
+
47
+ describe("evaluateLeaf — contains (string)", () => {
48
+ it("passes when substring found", () => expect(evaluateLeaf({ variable: "bio", operator: "contains", value: "run" }, { bio: "I love running" })).toBe(true));
49
+ it("fails when substring absent", () => expect(evaluateLeaf({ variable: "bio", operator: "contains", value: "swim" }, { bio: "I love running" })).toBe(false));
50
+ it("coerces number variable to string", () => expect(evaluateLeaf({ variable: "code", operator: "contains", value: "4" }, { code: 42 })).toBe(true));
51
+ });
52
+
53
+ describe("evaluateLeaf — contains (array)", () => {
54
+ it("passes when element in array", () => expect(evaluateLeaf({ variable: "tags", operator: "contains", value: "sport" }, { tags: ["health", "sport"] })).toBe(true));
55
+ it("fails when element absent", () => expect(evaluateLeaf({ variable: "tags", operator: "contains", value: "music" }, { tags: ["health", "sport"] })).toBe(false));
56
+ });
57
+
58
+ describe("evaluateLeaf — in", () => {
59
+ it("passes when variable is in list", () => expect(evaluateLeaf({ variable: "gender", operator: "in", value: ["male", "female"] }, { gender: "male" })).toBe(true));
60
+ it("fails when variable not in list", () => expect(evaluateLeaf({ variable: "gender", operator: "in", value: ["male", "female"] }, { gender: "other" })).toBe(false));
61
+ it("returns false when value is not array", () => expect(evaluateLeaf({ variable: "x", operator: "in", value: "male" as any }, { x: "male" })).toBe(false));
62
+ it("empty list always false", () => expect(evaluateLeaf({ variable: "x", operator: "in", value: [] }, { x: "male" })).toBe(false));
63
+ it("coerces numeric raw to string before comparison", () => expect(evaluateLeaf({ variable: "age", operator: "in", value: ["18", "25"] }, { age: 18 })).toBe(true));
64
+ it("numeric raw not in string list returns false", () => expect(evaluateLeaf({ variable: "age", operator: "in", value: ["30", "40"] }, { age: 18 })).toBe(false));
65
+ });
66
+
67
+ describe("evaluateLeaf — not_in", () => {
68
+ it("passes when variable not in list", () => expect(evaluateLeaf({ variable: "gender", operator: "not_in", value: ["male", "female"] }, { gender: "other" })).toBe(true));
69
+ it("fails when variable is in list", () => expect(evaluateLeaf({ variable: "gender", operator: "not_in", value: ["male", "female"] }, { gender: "male" })).toBe(false));
70
+ it("returns true when value is not array (safe default)", () => expect(evaluateLeaf({ variable: "x", operator: "not_in", value: "male" as any }, { x: "male" })).toBe(true));
71
+ it("coerces numeric raw to string before comparison", () => expect(evaluateLeaf({ variable: "age", operator: "not_in", value: ["18", "25"] }, { age: 30 })).toBe(true));
72
+ it("numeric raw in string list returns false", () => expect(evaluateLeaf({ variable: "age", operator: "not_in", value: ["18", "25"] }, { age: 18 })).toBe(false));
73
+ });
74
+
75
+ describe("evaluateLeaf — unknown operator", () => {
76
+ it("returns false for unknown operator", () => expect(evaluateLeaf({ variable: "x", operator: "xor" as any, value: "y" }, { x: "y" })).toBe(false));
77
+ });
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // evaluateCondition — logic groups
81
+ // ---------------------------------------------------------------------------
82
+
83
+ describe("evaluateCondition — AND group", () => {
84
+ const and: Condition = {
85
+ logic: "and",
86
+ conditions: [
87
+ { variable: "age", operator: "gt", value: 18 },
88
+ { variable: "gender", operator: "eq", value: "male" },
89
+ ],
90
+ };
91
+
92
+ it("passes when all conditions true", () => expect(evaluateCondition(and, { age: 25, gender: "male" })).toBe(true));
93
+ it("fails when one condition false", () => expect(evaluateCondition(and, { age: 25, gender: "female" })).toBe(false));
94
+ it("fails when all conditions false", () => expect(evaluateCondition(and, { age: 10, gender: "female" })).toBe(false));
95
+ });
96
+
97
+ describe("evaluateCondition — OR group", () => {
98
+ const or: Condition = {
99
+ logic: "or",
100
+ conditions: [
101
+ { variable: "age", operator: "lt", value: 18 },
102
+ { variable: "gender", operator: "eq", value: "female" },
103
+ ],
104
+ };
105
+
106
+ it("passes when first condition true", () => expect(evaluateCondition(or, { age: 10, gender: "male" })).toBe(true));
107
+ it("passes when second condition true", () => expect(evaluateCondition(or, { age: 25, gender: "female" })).toBe(true));
108
+ it("passes when both true", () => expect(evaluateCondition(or, { age: 10, gender: "female" })).toBe(true));
109
+ it("fails when all false", () => expect(evaluateCondition(or, { age: 25, gender: "male" })).toBe(false));
110
+ });
111
+
112
+ describe("evaluateCondition — nested groups", () => {
113
+ // (age > 18 AND gender = male) OR (vip = true)
114
+ const nested: Condition = {
115
+ logic: "or",
116
+ conditions: [
117
+ {
118
+ logic: "and",
119
+ conditions: [
120
+ { variable: "age", operator: "gt", value: 18 },
121
+ { variable: "gender", operator: "eq", value: "male" },
122
+ ],
123
+ },
124
+ { variable: "vip", operator: "eq", value: "true" },
125
+ ],
126
+ };
127
+
128
+ it("passes via inner AND branch", () => expect(evaluateCondition(nested, { age: 25, gender: "male", vip: "false" })).toBe(true));
129
+ it("passes via vip branch", () => expect(evaluateCondition(nested, { age: 10, gender: "female", vip: "true" })).toBe(true));
130
+ it("fails when neither branch matches", () => expect(evaluateCondition(nested, { age: 10, gender: "male", vip: "false" })).toBe(false));
131
+ });
132
+
133
+ describe("evaluateCondition — deeply nested (3 levels)", () => {
134
+ // ((a=1 AND b=2) OR c=3) AND d=4
135
+ const deep: Condition = {
136
+ logic: "and",
137
+ conditions: [
138
+ {
139
+ logic: "or",
140
+ conditions: [
141
+ {
142
+ logic: "and",
143
+ conditions: [
144
+ { variable: "a", operator: "eq", value: "1" },
145
+ { variable: "b", operator: "eq", value: "2" },
146
+ ],
147
+ },
148
+ { variable: "c", operator: "eq", value: "3" },
149
+ ],
150
+ },
151
+ { variable: "d", operator: "eq", value: "4" },
152
+ ],
153
+ };
154
+
155
+ it("passes via a+b path with d", () => expect(evaluateCondition(deep, { a: "1", b: "2", c: "0", d: "4" })).toBe(true));
156
+ it("passes via c path with d", () => expect(evaluateCondition(deep, { a: "0", b: "0", c: "3", d: "4" })).toBe(true));
157
+ it("fails when d missing", () => expect(evaluateCondition(deep, { a: "1", b: "2", c: "3", d: "0" })).toBe(false));
158
+ it("fails when inner OR fails and d present", () => expect(evaluateCondition(deep, { a: "0", b: "0", c: "0", d: "4" })).toBe(false));
159
+ });
160
+
161
+ describe("evaluateCondition — leaf passthrough", () => {
162
+ it("delegates to evaluateLeaf directly", () => {
163
+ const leaf: Condition = { variable: "x", operator: "eq", value: "yes" };
164
+ expect(evaluateCondition(leaf, { x: "yes" })).toBe(true);
165
+ expect(evaluateCondition(leaf, { x: "no" })).toBe(false);
166
+ });
167
+ });
@@ -0,0 +1,309 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { resolveNextStepNumber } from "../resolveNextStepNumber";
3
+ import type { BaseStepType } from "../types";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Helpers
7
+ // ---------------------------------------------------------------------------
8
+
9
+ function makeStep(id: string, overrides: Partial<BaseStepType> = {}): BaseStepType {
10
+ return {
11
+ id,
12
+ type: "Question",
13
+ name: id,
14
+ displayProgressHeader: true,
15
+ nextStep: null,
16
+ ...overrides,
17
+ } as BaseStepType;
18
+ }
19
+
20
+ const steps = [
21
+ makeStep("s1"),
22
+ makeStep("s2"),
23
+ makeStep("s3"),
24
+ makeStep("s4"),
25
+ ];
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Linear progression (nextStep = null)
29
+ // ---------------------------------------------------------------------------
30
+
31
+ describe("linear progression", () => {
32
+ it("returns 2 from step 1", () => expect(resolveNextStepNumber(steps[0], {}, steps)).toBe(2));
33
+ it("returns 3 from step 2", () => expect(resolveNextStepNumber(steps[1], {}, steps)).toBe(3));
34
+ it("returns null from last step", () => expect(resolveNextStepNumber(steps[3], {}, steps)).toBe(null));
35
+ it("returns null when step not found in array", () => {
36
+ const orphan = makeStep("orphan");
37
+ expect(resolveNextStepNumber(orphan, {}, steps)).toBe(null);
38
+ });
39
+ it("returns null for single-step array", () => {
40
+ const single = [makeStep("only")];
41
+ expect(resolveNextStepNumber(single[0], {}, single)).toBe(null);
42
+ });
43
+ });
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Branching — condition match
47
+ // ---------------------------------------------------------------------------
48
+
49
+ describe("branch — first matching branch wins", () => {
50
+ it("routes to targetStepId of first matching branch", () => {
51
+ const step = makeStep("s1", {
52
+ nextStep: {
53
+ defaultTargetStepId: "s2",
54
+ branches: [
55
+ { condition: { variable: "gender", operator: "eq", value: "female" }, targetStepId: "s4" },
56
+ { condition: { variable: "gender", operator: "eq", value: "male" }, targetStepId: "s3" },
57
+ ],
58
+ },
59
+ });
60
+ expect(resolveNextStepNumber(step, { gender: "male" }, steps)).toBe(3);
61
+ });
62
+
63
+ it("first branch wins even if second also matches", () => {
64
+ const step = makeStep("s1", {
65
+ nextStep: {
66
+ defaultTargetStepId: "s2",
67
+ branches: [
68
+ { condition: { variable: "age", operator: "gt", value: 10 }, targetStepId: "s3" },
69
+ { condition: { variable: "age", operator: "gt", value: 5 }, targetStepId: "s4" },
70
+ ],
71
+ },
72
+ });
73
+ expect(resolveNextStepNumber(step, { age: 20 }, steps)).toBe(3);
74
+ });
75
+
76
+ it("skips non-matching branch, takes second", () => {
77
+ const step = makeStep("s1", {
78
+ nextStep: {
79
+ defaultTargetStepId: "s2",
80
+ branches: [
81
+ { condition: { variable: "gender", operator: "eq", value: "female" }, targetStepId: "s4" },
82
+ { condition: { variable: "gender", operator: "eq", value: "male" }, targetStepId: "s3" },
83
+ ],
84
+ },
85
+ });
86
+ expect(resolveNextStepNumber(step, { gender: "female" }, steps)).toBe(4);
87
+ });
88
+ });
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Branching — unconditional branch (null condition)
92
+ // ---------------------------------------------------------------------------
93
+
94
+ describe("branch — null condition (unconditional)", () => {
95
+ it("always matches null condition", () => {
96
+ const step = makeStep("s1", {
97
+ nextStep: {
98
+ defaultTargetStepId: "s2",
99
+ branches: [
100
+ { condition: null, targetStepId: "s4" },
101
+ ],
102
+ },
103
+ });
104
+ expect(resolveNextStepNumber(step, {}, steps)).toBe(4);
105
+ });
106
+
107
+ it("null condition after a non-matching branch acts as catch-all", () => {
108
+ const step = makeStep("s1", {
109
+ nextStep: {
110
+ defaultTargetStepId: "s2",
111
+ branches: [
112
+ { condition: { variable: "age", operator: "gt", value: 100 }, targetStepId: "s3" },
113
+ { condition: null, targetStepId: "s4" },
114
+ ],
115
+ },
116
+ });
117
+ expect(resolveNextStepNumber(step, { age: 25 }, steps)).toBe(4);
118
+ });
119
+ });
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Branching — defaultTargetStepId fallback
123
+ // ---------------------------------------------------------------------------
124
+
125
+ describe("branch — defaultTargetStepId fallback", () => {
126
+ it("uses defaultTargetStepId when no branch matches", () => {
127
+ const step = makeStep("s1", {
128
+ nextStep: {
129
+ defaultTargetStepId: "s3",
130
+ branches: [
131
+ { condition: { variable: "x", operator: "eq", value: "never" }, targetStepId: "s4" },
132
+ ],
133
+ },
134
+ });
135
+ expect(resolveNextStepNumber(step, { x: "something_else" }, steps)).toBe(3);
136
+ });
137
+
138
+ it("defaultTargetStepId with empty branches array still resolves", () => {
139
+ const step = makeStep("s1", {
140
+ nextStep: {
141
+ defaultTargetStepId: "s4",
142
+ branches: [],
143
+ },
144
+ });
145
+ expect(resolveNextStepNumber(step, {}, steps)).toBe(4);
146
+ });
147
+
148
+ it("falls back to linear when defaultTargetStepId not in steps", () => {
149
+ const step = makeStep("s1", {
150
+ nextStep: {
151
+ defaultTargetStepId: "does-not-exist",
152
+ branches: [],
153
+ },
154
+ });
155
+ expect(resolveNextStepNumber(step, {}, steps)).toBe(2);
156
+ });
157
+ });
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Branching — branch targetStepId not found
161
+ // ---------------------------------------------------------------------------
162
+
163
+ describe("branch — targetStepId not found", () => {
164
+ it("skips branch with unknown targetStepId, tries next branch", () => {
165
+ const step = makeStep("s1", {
166
+ nextStep: {
167
+ defaultTargetStepId: "s2",
168
+ branches: [
169
+ { condition: { variable: "x", operator: "eq", value: "y" }, targetStepId: "ghost" },
170
+ { condition: { variable: "x", operator: "eq", value: "y" }, targetStepId: "s3" },
171
+ ],
172
+ },
173
+ });
174
+ expect(resolveNextStepNumber(step, { x: "y" }, steps)).toBe(3);
175
+ });
176
+ });
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // Branching — compound conditions in branches
180
+ // ---------------------------------------------------------------------------
181
+
182
+ describe("branch — compound conditions", () => {
183
+ it("AND group in branch condition", () => {
184
+ const step = makeStep("s1", {
185
+ nextStep: {
186
+ defaultTargetStepId: "s2",
187
+ branches: [
188
+ {
189
+ condition: {
190
+ logic: "and",
191
+ conditions: [
192
+ { variable: "age", operator: "gte", value: 18 },
193
+ { variable: "gender", operator: "eq", value: "female" },
194
+ ],
195
+ },
196
+ targetStepId: "s4",
197
+ },
198
+ ],
199
+ },
200
+ });
201
+
202
+ expect(resolveNextStepNumber(step, { age: 25, gender: "female" }, steps)).toBe(4);
203
+ expect(resolveNextStepNumber(step, { age: 15, gender: "female" }, steps)).toBe(2);
204
+ expect(resolveNextStepNumber(step, { age: 25, gender: "male" }, steps)).toBe(2);
205
+ });
206
+
207
+ it("OR group in branch condition", () => {
208
+ const step = makeStep("s1", {
209
+ nextStep: {
210
+ defaultTargetStepId: "s2",
211
+ branches: [
212
+ {
213
+ condition: {
214
+ logic: "or",
215
+ conditions: [
216
+ { variable: "vip", operator: "eq", value: "true" },
217
+ { variable: "age", operator: "lt", value: 13 },
218
+ ],
219
+ },
220
+ targetStepId: "s3",
221
+ },
222
+ ],
223
+ },
224
+ });
225
+
226
+ expect(resolveNextStepNumber(step, { vip: "true", age: 25 }, steps)).toBe(3);
227
+ expect(resolveNextStepNumber(step, { vip: "false", age: 10 }, steps)).toBe(3);
228
+ expect(resolveNextStepNumber(step, { vip: "false", age: 20 }, steps)).toBe(2);
229
+ });
230
+ });
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // Self-loop guard
234
+ // ---------------------------------------------------------------------------
235
+
236
+ describe("self-loop guard", () => {
237
+ it("skips branch where targetStepId equals currentStep.id", () => {
238
+ const step = makeStep("s1", {
239
+ nextStep: {
240
+ defaultTargetStepId: "s2",
241
+ branches: [
242
+ { condition: null, targetStepId: "s1" }, // would self-loop
243
+ { condition: null, targetStepId: "s3" },
244
+ ],
245
+ },
246
+ });
247
+ expect(resolveNextStepNumber(step, {}, steps)).toBe(3);
248
+ });
249
+
250
+ it("falls back to linear when only self-targeting branch matches", () => {
251
+ const step = makeStep("s1", {
252
+ nextStep: {
253
+ defaultTargetStepId: "s2",
254
+ branches: [
255
+ { condition: null, targetStepId: "s1" }, // would self-loop, only branch
256
+ ],
257
+ },
258
+ });
259
+ // defaultTargetStepId is "s2" which is valid → returns 2
260
+ expect(resolveNextStepNumber(step, {}, steps)).toBe(2);
261
+ });
262
+
263
+ it("skips defaultTargetStepId when it equals currentStep.id, falls back to linear", () => {
264
+ const step = makeStep("s2", {
265
+ nextStep: {
266
+ defaultTargetStepId: "s2", // self-reference
267
+ branches: [],
268
+ },
269
+ });
270
+ expect(resolveNextStepNumber(step, {}, steps)).toBe(3);
271
+ });
272
+
273
+ it("returns null when only route is self-loop and no linear next", () => {
274
+ const last = makeStep("s4", {
275
+ nextStep: {
276
+ defaultTargetStepId: "s4", // self-reference, and s4 is last
277
+ branches: [],
278
+ },
279
+ });
280
+ expect(resolveNextStepNumber(last, {}, steps)).toBe(null);
281
+ });
282
+ });
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // Edge cases
286
+ // ---------------------------------------------------------------------------
287
+
288
+ describe("edge cases", () => {
289
+ it("empty steps array returns null", () => {
290
+ expect(resolveNextStepNumber(makeStep("s1"), {}, [])).toBe(null);
291
+ });
292
+
293
+ it("empty variables object, no branch matches → linear", () => {
294
+ const step = makeStep("s1", {
295
+ nextStep: {
296
+ defaultTargetStepId: "s3",
297
+ branches: [
298
+ { condition: { variable: "gender", operator: "eq", value: "male" }, targetStepId: "s4" },
299
+ ],
300
+ },
301
+ });
302
+ expect(resolveNextStepNumber(step, {}, [step, makeStep("s2"), makeStep("s3"), makeStep("s4")])).toBe(3);
303
+ });
304
+
305
+ it("steps array with one element and current step last returns null", () => {
306
+ const s = makeStep("only");
307
+ expect(resolveNextStepNumber(s, {}, [s])).toBe(null);
308
+ });
309
+ });
@@ -0,0 +1,50 @@
1
+ import type { LeafCondition, ConditionGroup } from "./steps/common.types";
2
+
3
+ export type Condition = LeafCondition | ConditionGroup;
4
+
5
+ export function isConditionGroup(c: Condition): c is ConditionGroup {
6
+ return "logic" in c && "conditions" in c;
7
+ }
8
+
9
+ function coerceToNumber(v: unknown): number {
10
+ return typeof v === "string" ? parseFloat(v) : Number(v);
11
+ }
12
+
13
+ export function evaluateLeaf(condition: LeafCondition, variables: Record<string, unknown>): boolean {
14
+ const raw = variables[condition.variable];
15
+ const { operator, value } = condition;
16
+
17
+ switch (operator) {
18
+ case "eq":
19
+ return String(raw) === String(value);
20
+ case "neq":
21
+ return String(raw) !== String(value);
22
+ case "gt":
23
+ return coerceToNumber(raw) > coerceToNumber(value);
24
+ case "lt":
25
+ return coerceToNumber(raw) < coerceToNumber(value);
26
+ case "gte":
27
+ return coerceToNumber(raw) >= coerceToNumber(value);
28
+ case "lte":
29
+ return coerceToNumber(raw) <= coerceToNumber(value);
30
+ case "contains":
31
+ return Array.isArray(raw)
32
+ ? raw.includes(value)
33
+ : String(raw).includes(String(value));
34
+ case "in":
35
+ return Array.isArray(value) ? value.includes(String(raw)) : false;
36
+ case "not_in":
37
+ return Array.isArray(value) ? !value.includes(String(raw)) : true;
38
+ default:
39
+ return false;
40
+ }
41
+ }
42
+
43
+ export function evaluateCondition(condition: Condition, variables: Record<string, unknown>): boolean {
44
+ if (isConditionGroup(condition)) {
45
+ return condition.logic === "and"
46
+ ? condition.conditions.every((c) => evaluateCondition(c, variables))
47
+ : condition.conditions.some((c) => evaluateCondition(c, variables));
48
+ }
49
+ return evaluateLeaf(condition, variables);
50
+ }
package/src/index.ts CHANGED
@@ -4,3 +4,22 @@ export * from "./types";
4
4
  export * from "./onboarding-example";
5
5
  // Hooks and providers
6
6
  export * from "./infra";
7
+ // Branching
8
+ export { resolveNextStepNumber } from "./resolveNextStepNumber";
9
+ export type {
10
+ LeafCondition,
11
+ ConditionGroup,
12
+ ConditionValue,
13
+ ConditionOperator,
14
+ Branch,
15
+ NextStep,
16
+ } from "./steps/common.types";
17
+ export {
18
+ BaseStepTypeSchema,
19
+ LeafConditionSchema,
20
+ ConditionGroupSchema,
21
+ BranchSchema,
22
+ NextStepSchema,
23
+ ConditionOperatorSchema,
24
+ ConditionValueSchema,
25
+ } from "./steps/common.types";
@@ -1,4 +1,4 @@
1
- import { createContext, useState } from "react";
1
+ import { createContext, useCallback, useState } from "react";
2
2
  import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
3
3
  import { OnboardingStudioClient } from "../../OnboardingStudioClient";
4
4
  import { getOnboardingQuery } from "../queries/getOnboarding.query";
@@ -32,6 +32,10 @@ export const OnboardingProvider = ({
32
32
  });
33
33
  const [totalSteps, setTotalSteps] = useState(0);
34
34
  const [onboarding, setOnboarding] = useState<Onboarding<OnboardingStepType> | null>(null);
35
+ const [variables, setVariables] = useState<Record<string, any>>({});
36
+ const setVariable = useCallback((name: string, value: any) => {
37
+ setVariables((prev) => ({ ...prev, [name]: value }));
38
+ }, []);
35
39
 
36
40
  queryClient.prefetchQuery(getOnboardingQuery(client, locale, customAudienceParams, setOnboarding))
37
41
 
@@ -49,6 +53,8 @@ export const OnboardingProvider = ({
49
53
  customAudienceParams,
50
54
  onboarding,
51
55
  setOnboarding,
56
+ variables,
57
+ setVariable,
52
58
  }}
53
59
  >
54
60
  {children}
@@ -67,6 +73,8 @@ export const OnboardingProgressContext = createContext<{
67
73
  customAudienceParams: Record<string, any>;
68
74
  onboarding: Onboarding<OnboardingStepType> | null;
69
75
  setOnboarding: (onboarding: Onboarding<OnboardingStepType>) => void;
76
+ variables: Record<string, any>;
77
+ setVariable: (name: string, value: any) => void;
70
78
  }>({
71
79
  activeStep: { number: 0, displayProgressHeader: false },
72
80
  setActiveStep: () => { },
@@ -77,4 +85,6 @@ export const OnboardingProgressContext = createContext<{
77
85
  customAudienceParams: {},
78
86
  onboarding: null,
79
87
  setOnboarding: () => { },
88
+ variables: {},
89
+ setVariable: () => { },
80
90
  });
@@ -0,0 +1,41 @@
1
+ import type { BaseStepType } from "./types";
2
+ import { evaluateCondition } from "./evaluateCondition";
3
+
4
+ /**
5
+ * Resolves the 1-indexed step number to navigate to after the current step.
6
+ * Returns null when the onboarding should end (no next step exists).
7
+ *
8
+ * @param currentStep - The step that just completed
9
+ * @param variables - Global variable store (build a merged copy before calling if you just set a variable)
10
+ * @param steps - Full ordered steps array
11
+ */
12
+ export function resolveNextStepNumber(
13
+ currentStep: BaseStepType,
14
+ variables: Record<string, any>,
15
+ steps: BaseStepType[]
16
+ ): number | null {
17
+ const linearNext = (): number | null => {
18
+ const idx = steps.findIndex((s) => s.id === currentStep.id);
19
+ if (idx === -1 || idx + 1 >= steps.length) return null;
20
+ return idx + 2;
21
+ };
22
+
23
+ const { nextStep } = currentStep;
24
+
25
+ if (nextStep == null) return linearNext();
26
+
27
+ for (const branch of nextStep.branches) {
28
+ if (branch.targetStepId === currentStep.id) continue;
29
+ if (branch.condition === null || evaluateCondition(branch.condition, variables)) {
30
+ const idx = steps.findIndex((s) => s.id === branch.targetStepId);
31
+ if (idx !== -1) return idx + 1;
32
+ }
33
+ }
34
+
35
+ if (nextStep.defaultTargetStepId !== currentStep.id) {
36
+ const defaultIdx = steps.findIndex((s) => s.id === nextStep.defaultTargetStepId);
37
+ if (defaultIdx !== -1) return defaultIdx + 1;
38
+ }
39
+
40
+ return linearNext();
41
+ }
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { ButtonSectionSchema, CustomPayloadSchema } from "../common.types";
2
+ import { BaseStepTypeSchema } from "../common.types";
3
3
 
4
4
  export const CarouselScreenSchema = z.object({
5
5
  mediaUrl: z.string(),
@@ -11,16 +11,9 @@ export const CarouselStepPayloadSchema = z.object({
11
11
  screens: z.array(CarouselScreenSchema),
12
12
  });
13
13
 
14
- export const CarouselStepTypeSchema = z.object({
15
- id: z.string(),
14
+ export const CarouselStepTypeSchema = BaseStepTypeSchema.extend({
16
15
  type: z.literal("Carousel"),
17
- name: z.string(),
18
- displayProgressHeader: z.boolean(),
19
16
  payload: CarouselStepPayloadSchema,
20
- customPayload: CustomPayloadSchema,
21
- continueButtonLabel: z.string().optional().default("Continue"),
22
- buttonSection: ButtonSectionSchema.optional(),
23
- figmaUrl: z.string().nullish(),
24
17
  });
25
18
 
26
19
  export type CarouselStepType = z.infer<typeof CarouselStepTypeSchema>;