flowboard-react 0.3.0 → 0.4.1

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/src/Flowboard.ts CHANGED
@@ -3,7 +3,11 @@ import { ClientContext } from './core/clientContext';
3
3
  import { ResolverService } from './core/resolverService';
4
4
  import { OnboardingRepository } from './core/onboardingRepository';
5
5
  import { AnalyticsManager } from './core/analyticsManager';
6
- import type { FlowboardData, FlowboardLaunchOptions } from './types/flowboard';
6
+ import type {
7
+ FlowboardData,
8
+ FlowboardLaunchByIdOptions,
9
+ FlowboardLaunchOptions,
10
+ } from './types/flowboard';
7
11
 
8
12
  export type LaunchPayload = {
9
13
  data: FlowboardData;
@@ -72,19 +76,7 @@ export class Flowboard {
72
76
  );
73
77
  }
74
78
 
75
- try {
76
- const context =
77
- AnalyticsManager.instance.clientContextSnapshot ??
78
- (await ClientContext.create());
79
- AnalyticsManager.instance.configure({
80
- enabled: options.enableAnalytics ?? true,
81
- context,
82
- });
83
- } catch (error) {
84
- Flowboard.log(
85
- `Failed to configure analytics for launch: ${String(error)}`
86
- );
87
- }
79
+ await Flowboard.configureAnalyticsForLaunch(options.enableAnalytics);
88
80
 
89
81
  let data = await Flowboard.repository.getOnboardingJson();
90
82
 
@@ -117,6 +109,48 @@ export class Flowboard {
117
109
  return;
118
110
  }
119
111
 
112
+ await Flowboard.launchResolvedOnboarding(data, options);
113
+ }
114
+
115
+ static async launchOnboardingById(
116
+ onboardingId: string,
117
+ options: FlowboardLaunchByIdOptions = {}
118
+ ): Promise<void> {
119
+ Flowboard.log(`Attempting to launch onboarding by id: ${onboardingId}`);
120
+ if (!Flowboard.appId) {
121
+ throw new Error(
122
+ 'Flowboard.init() must be called before launchOnboardingById'
123
+ );
124
+ }
125
+
126
+ const normalizedOnboardingId = onboardingId.trim();
127
+ if (!normalizedOnboardingId) {
128
+ throw new Error('onboardingId must be a non-empty string');
129
+ }
130
+
131
+ await Flowboard.configureAnalyticsForLaunch(options.enableAnalytics);
132
+
133
+ const locale = await Flowboard.resolveLocale(options.locale);
134
+
135
+ let data: FlowboardData;
136
+ try {
137
+ data = await Flowboard.service.fetchOnboardingById({
138
+ onboardingId: normalizedOnboardingId,
139
+ locale,
140
+ });
141
+ } catch (error) {
142
+ Flowboard.log(`Failed to load onboarding by id: ${String(error)}`);
143
+ Alert.alert('Failed to load onboarding', String(error));
144
+ return;
145
+ }
146
+
147
+ await Flowboard.launchResolvedOnboarding(data, options);
148
+ }
149
+
150
+ private static async launchResolvedOnboarding(
151
+ data: FlowboardData,
152
+ options: FlowboardLaunchOptions
153
+ ): Promise<void> {
120
154
  const shouldResumeProgress =
121
155
  options.resumeProgress === true && options.alwaysRestart !== true;
122
156
 
@@ -159,6 +193,39 @@ export class Flowboard {
159
193
  });
160
194
  }
161
195
 
196
+ private static async configureAnalyticsForLaunch(
197
+ enableAnalytics: boolean | undefined
198
+ ): Promise<void> {
199
+ try {
200
+ const context =
201
+ AnalyticsManager.instance.clientContextSnapshot ??
202
+ (await ClientContext.create());
203
+ AnalyticsManager.instance.configure({
204
+ enabled: enableAnalytics ?? true,
205
+ context,
206
+ });
207
+ } catch (error) {
208
+ Flowboard.log(
209
+ `Failed to configure analytics for launch: ${String(error)}`
210
+ );
211
+ }
212
+ }
213
+
214
+ private static async resolveLocale(
215
+ requestedLocale: string | undefined
216
+ ): Promise<string> {
217
+ const normalized = requestedLocale?.trim();
218
+ if (normalized) {
219
+ return normalized;
220
+ }
221
+
222
+ const context =
223
+ AnalyticsManager.instance.clientContextSnapshot ??
224
+ (await ClientContext.create());
225
+
226
+ return context.locale || 'en_US';
227
+ }
228
+
162
229
  private static async initialize(): Promise<void> {
163
230
  Flowboard.log('Checking local cache...');
164
231
  const cached = await Flowboard.repository.getOnboardingJson();
@@ -1594,6 +1594,9 @@ function WheelPicker({
1594
1594
  }),
1595
1595
  [formData, itemTemplate, options, properties]
1596
1596
  );
1597
+ const wheelScrollY = React.useRef(new Animated.Value(0)).current;
1598
+ const wheelScrollRef = React.useRef<any>(null);
1599
+ const lastCommittedWheelValue = React.useRef<string | null>(null);
1597
1600
 
1598
1601
  const toggleSelection = (value: string) => {
1599
1602
  setSelectedValues((prev) => {
@@ -1620,6 +1623,78 @@ function WheelPicker({
1620
1623
 
1621
1624
  const layout = properties.layout ?? 'wheel';
1622
1625
  const spacing = Number(properties.spacing ?? 8);
1626
+ const wheelItemHeight = Math.max(1, Number(properties.wheelItemHeight ?? 48));
1627
+ const requestedVisible = Math.max(
1628
+ 3,
1629
+ Math.floor(Number(properties.visibleItems ?? 5))
1630
+ );
1631
+ const visibleItems =
1632
+ requestedVisible % 2 === 0 ? requestedVisible + 1 : requestedVisible;
1633
+ const wheelRowStride = wheelItemHeight + Math.max(0, spacing);
1634
+ const wheelContainerHeight = wheelItemHeight * visibleItems;
1635
+
1636
+ const clampWheelIndex = React.useCallback(
1637
+ (index: number) =>
1638
+ Math.max(0, Math.min(index, Math.max(0, resolvedOptions.length - 1))),
1639
+ [resolvedOptions.length]
1640
+ );
1641
+
1642
+ const getWheelSelectedIndex = React.useCallback(() => {
1643
+ if (resolvedOptions.length === 0) return 0;
1644
+ const targetValue = selectedValues[0];
1645
+ if (targetValue === undefined) return 0;
1646
+ const found = resolvedOptions.findIndex(
1647
+ (option) => String(option.value ?? '') === String(targetValue)
1648
+ );
1649
+ return found >= 0 ? found : 0;
1650
+ }, [resolvedOptions, selectedValues]);
1651
+
1652
+ const commitWheelSelection = React.useCallback(
1653
+ (index: number) => {
1654
+ const safeIndex = clampWheelIndex(index);
1655
+ const selectedOption = resolvedOptions[safeIndex];
1656
+ if (!selectedOption) return;
1657
+ const value = String(selectedOption.value ?? '');
1658
+ if (!value) return;
1659
+
1660
+ setSelectedValues((prev) => {
1661
+ if (prev.length === 1 && prev[0] === value) {
1662
+ return prev;
1663
+ }
1664
+ return [value];
1665
+ });
1666
+
1667
+ if (lastCommittedWheelValue.current !== value) {
1668
+ lastCommittedWheelValue.current = value;
1669
+ onChanged(value);
1670
+ if (properties.autoGoNext === true) {
1671
+ requestAnimationFrame(() =>
1672
+ onAction('next', { selectedValue: value })
1673
+ );
1674
+ }
1675
+ }
1676
+ },
1677
+ [clampWheelIndex, onAction, onChanged, properties.autoGoNext, resolvedOptions]
1678
+ );
1679
+
1680
+ React.useEffect(() => {
1681
+ if (layout !== 'wheel' || resolvedOptions.length === 0) return;
1682
+ const selectedIndex = getWheelSelectedIndex();
1683
+ const offsetY = selectedIndex * wheelRowStride;
1684
+ requestAnimationFrame(() => {
1685
+ wheelScrollRef.current?.scrollTo({
1686
+ y: offsetY,
1687
+ animated: false,
1688
+ });
1689
+ wheelScrollY.setValue(offsetY);
1690
+ });
1691
+ }, [
1692
+ getWheelSelectedIndex,
1693
+ layout,
1694
+ resolvedOptions.length,
1695
+ wheelRowStride,
1696
+ wheelScrollY,
1697
+ ]);
1623
1698
 
1624
1699
  const renderItem = (option: Record<string, any>, index: number) => {
1625
1700
  const value = String(option.value ?? '');
@@ -1697,36 +1772,136 @@ function WheelPicker({
1697
1772
  }
1698
1773
 
1699
1774
  if (layout === 'wheel') {
1700
- const itemHeight = Math.max(1, Number(properties.wheelItemHeight ?? 48));
1701
- const requestedVisible = Math.max(
1702
- 3,
1703
- Math.floor(Number(properties.visibleItems ?? 5))
1775
+ const centerPadding = Math.max(
1776
+ 0,
1777
+ (wheelContainerHeight - wheelRowStride) / 2
1778
+ );
1779
+ const centerLineTop = (wheelContainerHeight - wheelItemHeight) / 2;
1780
+ const baseUnselectedStyle =
1781
+ properties.unselectedStyle ?? properties.selectedStyle ?? {};
1782
+ const overlayBorderColor = parseColor(
1783
+ properties.wheelCenterBorderColor ??
1784
+ properties.wheelSelectedBorderColor ??
1785
+ baseUnselectedStyle.borderColor ??
1786
+ '#D6D6DC'
1787
+ );
1788
+ const overlayBackgroundColor = parseColor(
1789
+ properties.wheelCenterBackgroundColor ?? '#24FFFFFF'
1704
1790
  );
1705
- const visibleItems =
1706
- requestedVisible % 2 === 0 ? requestedVisible + 1 : requestedVisible;
1707
- const containerHeight = itemHeight * visibleItems;
1708
- const centerPadding = Math.max(0, (containerHeight - itemHeight) / 2);
1791
+
1792
+ const handleWheelMomentumEnd = (event: any) => {
1793
+ const offsetY = Number(event?.nativeEvent?.contentOffset?.y ?? 0);
1794
+ const settledIndex = clampWheelIndex(Math.round(offsetY / wheelRowStride));
1795
+ const targetOffset = settledIndex * wheelRowStride;
1796
+ if (Math.abs(targetOffset - offsetY) > 0.5) {
1797
+ wheelScrollRef.current?.scrollTo({
1798
+ y: targetOffset,
1799
+ animated: true,
1800
+ });
1801
+ }
1802
+ commitWheelSelection(settledIndex);
1803
+ };
1709
1804
 
1710
1805
  return (
1711
- <View style={{ height: containerHeight }}>
1712
- <ScrollView
1806
+ <View style={{ height: wheelContainerHeight, overflow: 'hidden' }}>
1807
+ <Animated.ScrollView
1808
+ ref={wheelScrollRef}
1713
1809
  showsVerticalScrollIndicator={false}
1810
+ bounces={false}
1811
+ decelerationRate="fast"
1812
+ snapToInterval={wheelRowStride}
1813
+ snapToAlignment="start"
1814
+ disableIntervalMomentum={false}
1815
+ onMomentumScrollEnd={handleWheelMomentumEnd}
1714
1816
  contentContainerStyle={{ paddingVertical: centerPadding }}
1817
+ onScroll={Animated.event(
1818
+ [{ nativeEvent: { contentOffset: { y: wheelScrollY } } }],
1819
+ { useNativeDriver: true }
1820
+ )}
1821
+ scrollEventThrottle={16}
1715
1822
  >
1716
- {resolvedOptions.map((option, index) => (
1717
- <View
1718
- key={`wheel-column-option-${index}`}
1719
- style={{
1720
- minHeight: itemHeight,
1721
- justifyContent: 'center',
1722
- marginBottom:
1723
- spacing > 0 && index < resolvedOptions.length - 1 ? spacing : 0,
1724
- }}
1725
- >
1726
- {renderItem(option, index)}
1727
- </View>
1728
- ))}
1729
- </ScrollView>
1823
+ {resolvedOptions.map((option, index) => {
1824
+ const inputRange = [
1825
+ (index - 2) * wheelRowStride,
1826
+ (index - 1) * wheelRowStride,
1827
+ index * wheelRowStride,
1828
+ (index + 1) * wheelRowStride,
1829
+ (index + 2) * wheelRowStride,
1830
+ ];
1831
+ const opacity = wheelScrollY.interpolate({
1832
+ inputRange,
1833
+ outputRange: [0.35, 0.6, 1, 0.6, 0.35],
1834
+ extrapolate: 'clamp',
1835
+ });
1836
+ const scale = wheelScrollY.interpolate({
1837
+ inputRange,
1838
+ outputRange: [0.82, 0.91, 1, 0.91, 0.82],
1839
+ extrapolate: 'clamp',
1840
+ });
1841
+ const rotateX = wheelScrollY.interpolate({
1842
+ inputRange,
1843
+ outputRange: ['55deg', '28deg', '0deg', '-28deg', '-55deg'],
1844
+ extrapolate: 'clamp',
1845
+ });
1846
+ const translateY = wheelScrollY.interpolate({
1847
+ inputRange,
1848
+ outputRange: [-10, -4, 0, 4, 10],
1849
+ extrapolate: 'clamp',
1850
+ });
1851
+
1852
+ return (
1853
+ <Animated.View
1854
+ key={`wheel-column-option-${index}`}
1855
+ style={{
1856
+ height: wheelRowStride,
1857
+ justifyContent: 'center',
1858
+ opacity,
1859
+ transform: [
1860
+ { perspective: 1200 },
1861
+ { translateY },
1862
+ { rotateX },
1863
+ { scale },
1864
+ ],
1865
+ }}
1866
+ >
1867
+ <View style={{ minHeight: wheelItemHeight, justifyContent: 'center' }}>
1868
+ {renderItem(option, index)}
1869
+ </View>
1870
+ </Animated.View>
1871
+ );
1872
+ })}
1873
+ </Animated.ScrollView>
1874
+
1875
+ <View
1876
+ pointerEvents="none"
1877
+ style={{
1878
+ position: 'absolute',
1879
+ left: 0,
1880
+ right: 0,
1881
+ top: centerLineTop,
1882
+ height: wheelItemHeight,
1883
+ borderRadius: 14,
1884
+ borderWidth: 1,
1885
+ borderColor: overlayBorderColor,
1886
+ backgroundColor: overlayBackgroundColor,
1887
+ }}
1888
+ />
1889
+ <LinearGradient
1890
+ pointerEvents="none"
1891
+ colors={['rgba(255,255,255,0.92)', 'rgba(255,255,255,0)']}
1892
+ style={{ position: 'absolute', top: 0, left: 0, right: 0, height: centerPadding }}
1893
+ />
1894
+ <LinearGradient
1895
+ pointerEvents="none"
1896
+ colors={['rgba(255,255,255,0)', 'rgba(255,255,255,0.92)']}
1897
+ style={{
1898
+ position: 'absolute',
1899
+ bottom: 0,
1900
+ left: 0,
1901
+ right: 0,
1902
+ height: centerPadding,
1903
+ }}
1904
+ />
1730
1905
  </View>
1731
1906
  );
1732
1907
  }
@@ -2,12 +2,16 @@ import type { ClientContext } from './clientContext';
2
2
  import type { FlowboardData } from '../types/flowboard';
3
3
 
4
4
  const DEFAULT_ENDPOINT = 'https://test-638704832888.europe-west1.run.app';
5
+ const SPECIFIC_ONBOARDING_ENDPOINT =
6
+ 'https://onboardsolo-638704832888.europe-west1.run.app';
5
7
 
6
8
  export class ResolverService {
7
9
  private endpoint: string;
10
+ private specificOnboardingEndpoint: string;
8
11
 
9
12
  constructor(endpoint?: string) {
10
13
  this.endpoint = endpoint ?? DEFAULT_ENDPOINT;
14
+ this.specificOnboardingEndpoint = SPECIFIC_ONBOARDING_ENDPOINT;
11
15
  }
12
16
 
13
17
  async fetchOnboardingJson(params: {
@@ -52,7 +56,45 @@ export class ResolverService {
52
56
  }
53
57
 
54
58
  const body = (await response.json()) as FlowboardData;
59
+ this.applyHeaderMetadata(response, body);
55
60
 
61
+ return body;
62
+ }
63
+
64
+ async fetchOnboardingById(params: {
65
+ onboardingId: string;
66
+ locale: string;
67
+ }): Promise<FlowboardData> {
68
+ const payload = {
69
+ onboardingId: params.onboardingId,
70
+ locale: params.locale,
71
+ };
72
+
73
+ let response: Response;
74
+ try {
75
+ response = await fetch(this.specificOnboardingEndpoint, {
76
+ method: 'POST',
77
+ headers: { 'Content-Type': 'application/json' },
78
+ body: JSON.stringify(payload),
79
+ });
80
+ } catch (error) {
81
+ throw new Error(`Failed to connect to resolver: ${String(error)}`);
82
+ }
83
+
84
+ if (!response.ok) {
85
+ const body = await response.text();
86
+ throw new Error(
87
+ `Failed to load onboarding config: ${response.status} ${body}`
88
+ );
89
+ }
90
+
91
+ const body = (await response.json()) as FlowboardData;
92
+ this.applyHeaderMetadata(response, body);
93
+
94
+ return body;
95
+ }
96
+
97
+ private applyHeaderMetadata(response: Response, body: FlowboardData): void {
56
98
  const flowId = response.headers.get('x-flowboard-flow-id');
57
99
  if (flowId) body.flow_id = flowId;
58
100
  const variantId = response.headers.get('x-flowboard-variant-id');
@@ -63,7 +105,5 @@ export class ResolverService {
63
105
  if (bucket) body.bucket = bucket;
64
106
  const experimentId = response.headers.get('x-flowboard-experiment-id');
65
107
  if (experimentId) body.experiment_id = experimentId;
66
-
67
- return body;
68
108
  }
69
109
  }
package/src/index.tsx CHANGED
@@ -8,6 +8,7 @@ export type {
8
8
  OnboardingEndCallback,
9
9
  OnStepChangeCallback,
10
10
  FlowboardLaunchOptions,
11
+ FlowboardLaunchByIdOptions,
11
12
  FlowboardData,
12
13
  } from './types/flowboard';
13
14
  export type { FlowboardFlowProps } from './components/FlowboardFlow';
@@ -49,3 +49,7 @@ export type FlowboardLaunchOptions = {
49
49
  alwaysRestart?: boolean;
50
50
  resumeProgress?: boolean;
51
51
  };
52
+
53
+ export type FlowboardLaunchByIdOptions = FlowboardLaunchOptions & {
54
+ locale?: string;
55
+ };