@umituz/react-native-design-system 4.28.4 → 4.28.6
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/package.json +1 -1
- package/src/atoms/AtomicInput.tsx +2 -2
- package/src/index.ts +1 -1
- package/src/molecules/Divider/Divider.tsx +2 -3
- package/src/molecules/Divider/types.ts +22 -5
- package/src/molecules/StepHeader/StepHeader.constants.ts +48 -0
- package/src/molecules/StepHeader/StepHeader.tsx +29 -23
- package/src/molecules/StepProgress/StepProgress.constants.ts +23 -0
- package/src/molecules/StepProgress/StepProgress.tsx +9 -6
- package/src/molecules/avatar/Avatar.constants.ts +20 -2
- package/src/molecules/avatar/Avatar.tsx +5 -3
- package/src/molecules/avatar/Avatar.utils.ts +4 -4
- package/src/molecules/avatar/AvatarGroup.tsx +2 -2
- package/src/molecules/listitem/styles/listItemStyles.ts +2 -3
- package/src/molecules/navigation/hooks/useAppFocusEffect.ts +14 -11
- package/src/molecules/navigation/hooks/useAppIsFocused.ts +1 -2
- package/src/molecules/navigation/hooks/useAppNavigation.ts +88 -118
- package/src/molecules/navigation/hooks/useAppRoute.ts +26 -27
- package/src/onboarding/domain/entities/ChatMessage.ts +19 -0
- package/src/onboarding/domain/entities/ChatStep.ts +72 -0
- package/src/onboarding/index.ts +29 -0
- package/src/onboarding/infrastructure/hooks/useChatAnimations.ts +145 -0
- package/src/onboarding/presentation/components/chat/ChatMessage.tsx +166 -0
- package/src/onboarding/presentation/components/chat/ChatOptionButton.tsx +145 -0
- package/src/onboarding/presentation/components/chat/TypingIndicator.tsx +99 -0
- package/src/onboarding/presentation/components/chat/index.ts +12 -0
- package/src/onboarding/presentation/hooks/useChatOnboarding.ts +278 -0
- package/src/onboarding/presentation/screens/ChatOnboardingScreen.tsx +276 -0
- package/src/utils/index.ts +13 -0
- package/src/utils/responsiveUtils.ts +110 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat Message Component
|
|
3
|
+
*
|
|
4
|
+
* Displays a single message in the chat interface
|
|
5
|
+
* Lazy loads animations for optimal bundle size
|
|
6
|
+
* Fully responsive - uses safe area insets and responsive sizing
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import React, { memo, useEffect, useMemo } from "react";
|
|
10
|
+
import { View, Text, StyleSheet, ViewStyle, Dimensions } from "react-native";
|
|
11
|
+
import { useResponsive } from "../../../../responsive/useResponsive";
|
|
12
|
+
|
|
13
|
+
import type { ChatMessage as ChatMessageEntity } from "../../../domain/entities/ChatMessage";
|
|
14
|
+
|
|
15
|
+
export interface ChatMessageProps {
|
|
16
|
+
/** Message data */
|
|
17
|
+
message: ChatMessageEntity;
|
|
18
|
+
|
|
19
|
+
/** Message background color */
|
|
20
|
+
backgroundColor?: string;
|
|
21
|
+
|
|
22
|
+
/** Message text color */
|
|
23
|
+
textColor?: string;
|
|
24
|
+
|
|
25
|
+
/** Important message highlight color */
|
|
26
|
+
importantColor?: string;
|
|
27
|
+
|
|
28
|
+
/** User message background color */
|
|
29
|
+
userBackgroundColor?: string;
|
|
30
|
+
|
|
31
|
+
/** User message text color */
|
|
32
|
+
userTextColor?: string;
|
|
33
|
+
|
|
34
|
+
/** Additional styles */
|
|
35
|
+
style?: ViewStyle;
|
|
36
|
+
|
|
37
|
+
/** Enable animations (default: true) */
|
|
38
|
+
animate?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Chat message bubble component
|
|
43
|
+
* Responsive design: Uses safe area insets and scales padding/font sizes
|
|
44
|
+
*/
|
|
45
|
+
export const ChatMessage = memo(
|
|
46
|
+
({
|
|
47
|
+
message,
|
|
48
|
+
backgroundColor = "#FFFFFF",
|
|
49
|
+
textColor = "#000000",
|
|
50
|
+
importantColor = "#FF6B6B",
|
|
51
|
+
userBackgroundColor = "#3B82F6",
|
|
52
|
+
userTextColor = "#FFFFFF",
|
|
53
|
+
style,
|
|
54
|
+
animate = true,
|
|
55
|
+
}: ChatMessageProps) => {
|
|
56
|
+
const responsive = useResponsive();
|
|
57
|
+
const [opacity, setOpacity] = React.useState(0);
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (!animate) {
|
|
61
|
+
setOpacity(1);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Simple fade-in without Reanimated
|
|
66
|
+
const timer = setTimeout(() => {
|
|
67
|
+
setOpacity(1);
|
|
68
|
+
}, 50);
|
|
69
|
+
|
|
70
|
+
return () => clearTimeout(timer);
|
|
71
|
+
}, [animate]);
|
|
72
|
+
|
|
73
|
+
// Responsive padding based on screen size (uses central spacingMultiplier)
|
|
74
|
+
const padding = useMemo(() => Math.floor(12 * responsive.spacingMultiplier), [responsive.spacingMultiplier]);
|
|
75
|
+
const fontSize = useMemo(() => Math.floor(16 * responsive.spacingMultiplier), [responsive.spacingMultiplier]);
|
|
76
|
+
const lineHeight = useMemo(() => Math.floor(22 * responsive.spacingMultiplier), [responsive.spacingMultiplier]);
|
|
77
|
+
const borderRadius = useMemo(() => Math.floor(16 * responsive.spacingMultiplier), [responsive.spacingMultiplier]);
|
|
78
|
+
const marginBottom = useMemo(() => Math.floor(8 * responsive.spacingMultiplier), [responsive.spacingMultiplier]);
|
|
79
|
+
const borderWidth = useMemo(() => Math.max(2, Math.floor(2 * responsive.spacingMultiplier)), [responsive.spacingMultiplier]);
|
|
80
|
+
const maxTextWidth = useMemo(() => {
|
|
81
|
+
// On tablets, allow wider messages (up to 90% vs 80% on mobile)
|
|
82
|
+
const screenWidth = Dimensions.get('window').width;
|
|
83
|
+
const baseMaxWidth = responsive.insets.left + responsive.insets.right > 20 ? 0.9 : 0.8;
|
|
84
|
+
return Math.floor(screenWidth * baseMaxWidth);
|
|
85
|
+
}, [responsive.insets.left, responsive.insets.right]);
|
|
86
|
+
|
|
87
|
+
const containerStyle = useMemo(
|
|
88
|
+
() => [
|
|
89
|
+
styles.container,
|
|
90
|
+
{
|
|
91
|
+
maxWidth: maxTextWidth,
|
|
92
|
+
padding,
|
|
93
|
+
borderRadius,
|
|
94
|
+
marginBottom,
|
|
95
|
+
},
|
|
96
|
+
message.isUser ? styles.userContainer : styles.botContainer,
|
|
97
|
+
{
|
|
98
|
+
backgroundColor: message.isUser ? userBackgroundColor : backgroundColor,
|
|
99
|
+
opacity,
|
|
100
|
+
},
|
|
101
|
+
message.isImportant && !message.isUser ? {
|
|
102
|
+
borderWidth,
|
|
103
|
+
borderColor: "#FF6B6B",
|
|
104
|
+
} : null,
|
|
105
|
+
style,
|
|
106
|
+
],
|
|
107
|
+
[
|
|
108
|
+
padding,
|
|
109
|
+
borderRadius,
|
|
110
|
+
marginBottom,
|
|
111
|
+
borderWidth,
|
|
112
|
+
maxTextWidth,
|
|
113
|
+
backgroundColor,
|
|
114
|
+
userBackgroundColor,
|
|
115
|
+
message.isUser,
|
|
116
|
+
message.isImportant,
|
|
117
|
+
opacity,
|
|
118
|
+
style,
|
|
119
|
+
]
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const textStyle = useMemo(
|
|
123
|
+
() => [
|
|
124
|
+
styles.text,
|
|
125
|
+
{
|
|
126
|
+
fontSize,
|
|
127
|
+
lineHeight,
|
|
128
|
+
color: message.isUser ? userTextColor : textColor,
|
|
129
|
+
},
|
|
130
|
+
message.isImportant && !message.isUser
|
|
131
|
+
? { color: importantColor, fontWeight: "600" as const }
|
|
132
|
+
: null,
|
|
133
|
+
],
|
|
134
|
+
[fontSize, lineHeight, textColor, userTextColor, message.isUser, message.isImportant, importantColor]
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<View style={containerStyle}>
|
|
139
|
+
<Text style={textStyle}>{message.text}</Text>
|
|
140
|
+
</View>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
ChatMessage.displayName = "ChatMessage";
|
|
146
|
+
|
|
147
|
+
const styles = StyleSheet.create({
|
|
148
|
+
container: {
|
|
149
|
+
// maxWidth is set dynamically based on screen size
|
|
150
|
+
padding: 12, // default, will be overridden by responsive value
|
|
151
|
+
borderRadius: 16, // default, will be overridden
|
|
152
|
+
marginBottom: 8, // default, will be overridden
|
|
153
|
+
},
|
|
154
|
+
userContainer: {
|
|
155
|
+
alignSelf: "flex-end",
|
|
156
|
+
borderBottomRightRadius: 4,
|
|
157
|
+
},
|
|
158
|
+
botContainer: {
|
|
159
|
+
alignSelf: "flex-start",
|
|
160
|
+
borderBottomLeftRadius: 4,
|
|
161
|
+
},
|
|
162
|
+
text: {
|
|
163
|
+
fontSize: 16, // default, will be overridden
|
|
164
|
+
lineHeight: 22, // default, will be overridden
|
|
165
|
+
},
|
|
166
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat Option Button Component
|
|
3
|
+
*
|
|
4
|
+
* Displays a selectable option in the chat interface
|
|
5
|
+
* Lazy loads animations for optimal bundle size
|
|
6
|
+
* Fully responsive - scales padding, font sizes, and icon sizes
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import React, { memo, useMemo } from "react";
|
|
10
|
+
import { TouchableOpacity, Text, StyleSheet, ViewStyle, View } from "react-native";
|
|
11
|
+
import { useResponsive } from "../../../../responsive/useResponsive";
|
|
12
|
+
import type { ChatOption } from "../../../domain/entities/ChatStep";
|
|
13
|
+
|
|
14
|
+
export interface ChatOptionButtonProps {
|
|
15
|
+
/** Option data */
|
|
16
|
+
option: ChatOption;
|
|
17
|
+
|
|
18
|
+
/** Button press handler */
|
|
19
|
+
onPress: (option: ChatOption) => void;
|
|
20
|
+
|
|
21
|
+
/** Button background color */
|
|
22
|
+
backgroundColor?: string;
|
|
23
|
+
|
|
24
|
+
/** Button text color */
|
|
25
|
+
textColor?: string;
|
|
26
|
+
|
|
27
|
+
/** Button border color */
|
|
28
|
+
borderColor?: string;
|
|
29
|
+
|
|
30
|
+
/** Whether button is disabled */
|
|
31
|
+
disabled?: boolean;
|
|
32
|
+
|
|
33
|
+
/** Additional styles */
|
|
34
|
+
style?: ViewStyle;
|
|
35
|
+
|
|
36
|
+
/** Enable animations (default: true) */
|
|
37
|
+
animate?: boolean;
|
|
38
|
+
|
|
39
|
+
/** Custom icon component */
|
|
40
|
+
renderIcon?: (iconName: string) => React.ReactNode;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Chat option button component
|
|
45
|
+
* Responsive design: Scales padding, font size, and min-width based on screen size
|
|
46
|
+
*/
|
|
47
|
+
export const ChatOptionButton = memo(
|
|
48
|
+
({
|
|
49
|
+
option,
|
|
50
|
+
onPress,
|
|
51
|
+
backgroundColor = "#F3F4F6",
|
|
52
|
+
textColor = "#1F2937",
|
|
53
|
+
borderColor = "#E5E7EB",
|
|
54
|
+
disabled = false,
|
|
55
|
+
style,
|
|
56
|
+
renderIcon,
|
|
57
|
+
}: ChatOptionButtonProps) => {
|
|
58
|
+
const responsive = useResponsive();
|
|
59
|
+
|
|
60
|
+
const handlePress = () => {
|
|
61
|
+
if (!disabled) {
|
|
62
|
+
onPress(option);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// Responsive sizing based on screen width and safe area
|
|
67
|
+
const padding = useMemo(() => Math.floor(14 * responsive.spacingMultiplier), [responsive.spacingMultiplier]);
|
|
68
|
+
const borderRadius = useMemo(() => Math.floor(12 * responsive.spacingMultiplier), [responsive.spacingMultiplier]);
|
|
69
|
+
const fontSize = useMemo(() => Math.floor(16 * responsive.spacingMultiplier), [responsive.spacingMultiplier]);
|
|
70
|
+
const marginBottom = useMemo(() => Math.floor(8 * responsive.spacingMultiplier), [responsive.spacingMultiplier]);
|
|
71
|
+
const iconMargin = useMemo(() => Math.floor(12 * responsive.spacingMultiplier), [responsive.spacingMultiplier]);
|
|
72
|
+
|
|
73
|
+
// Responsive min-width: tablets allow wider buttons
|
|
74
|
+
const minButtonWidth = useMemo(() => {
|
|
75
|
+
const screenWidth = responsive.insets.left + responsive.insets.right + 375; // Approximate base width
|
|
76
|
+
const baseMinWidth = 200;
|
|
77
|
+
const scaledMinWidth = Math.floor(baseMinWidth * (screenWidth / 375) * responsive.spacingMultiplier);
|
|
78
|
+
return Math.min(scaledMinWidth, baseMinWidth * 1.5); // Cap at 1.5x for very large screens
|
|
79
|
+
}, [responsive.insets.left, responsive.insets.right, responsive.spacingMultiplier]);
|
|
80
|
+
|
|
81
|
+
const buttonStyle = useMemo(
|
|
82
|
+
() => [
|
|
83
|
+
styles.button,
|
|
84
|
+
{
|
|
85
|
+
padding,
|
|
86
|
+
borderRadius,
|
|
87
|
+
marginBottom,
|
|
88
|
+
minWidth: minButtonWidth,
|
|
89
|
+
backgroundColor,
|
|
90
|
+
borderColor,
|
|
91
|
+
opacity: disabled ? 0.5 : 1,
|
|
92
|
+
},
|
|
93
|
+
style,
|
|
94
|
+
],
|
|
95
|
+
[padding, borderRadius, marginBottom, minButtonWidth, backgroundColor, borderColor, disabled, style]
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const textStyle = useMemo(
|
|
99
|
+
() => [
|
|
100
|
+
styles.text,
|
|
101
|
+
{
|
|
102
|
+
fontSize,
|
|
103
|
+
color: textColor,
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
[fontSize, textColor]
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<TouchableOpacity
|
|
111
|
+
style={buttonStyle}
|
|
112
|
+
onPress={handlePress}
|
|
113
|
+
disabled={disabled}
|
|
114
|
+
activeOpacity={0.7}
|
|
115
|
+
>
|
|
116
|
+
{option.icon && renderIcon && (
|
|
117
|
+
<View style={[styles.iconContainer, { marginRight: iconMargin }]}>{renderIcon(option.icon)}</View>
|
|
118
|
+
)}
|
|
119
|
+
<Text style={textStyle}>{option.label}</Text>
|
|
120
|
+
</TouchableOpacity>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
ChatOptionButton.displayName = "ChatOptionButton";
|
|
126
|
+
|
|
127
|
+
const styles = StyleSheet.create({
|
|
128
|
+
button: {
|
|
129
|
+
flexDirection: "row",
|
|
130
|
+
alignItems: "center",
|
|
131
|
+
padding: 14, // default, will be overridden by responsive value
|
|
132
|
+
borderRadius: 12, // default, will be overridden
|
|
133
|
+
borderWidth: 1,
|
|
134
|
+
marginBottom: 8, // default, will be overridden
|
|
135
|
+
minWidth: 200, // default, will be overridden
|
|
136
|
+
},
|
|
137
|
+
iconContainer: {
|
|
138
|
+
marginRight: 12, // default, will be overridden
|
|
139
|
+
},
|
|
140
|
+
text: {
|
|
141
|
+
fontSize: 16, // default, will be overridden
|
|
142
|
+
fontWeight: "600",
|
|
143
|
+
flex: 1,
|
|
144
|
+
},
|
|
145
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typing Indicator Component
|
|
3
|
+
*
|
|
4
|
+
* Shows animated typing dots for bot messages
|
|
5
|
+
* Uses CSS animations for minimal bundle impact
|
|
6
|
+
* Fully responsive - scales dot size and spacing on larger screens
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import React, { memo, useEffect, useState, useMemo } from "react";
|
|
10
|
+
import { View, StyleSheet, ViewStyle } from "react-native";
|
|
11
|
+
import { useResponsive } from "../../../../responsive/useResponsive";
|
|
12
|
+
|
|
13
|
+
export interface TypingIndicatorProps {
|
|
14
|
+
/** Dot color */
|
|
15
|
+
dotColor?: string;
|
|
16
|
+
|
|
17
|
+
/** Container background color */
|
|
18
|
+
backgroundColor?: string;
|
|
19
|
+
|
|
20
|
+
/** Additional styles */
|
|
21
|
+
style?: ViewStyle;
|
|
22
|
+
|
|
23
|
+
/** Animation duration in ms */
|
|
24
|
+
duration?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Typing indicator dots component
|
|
29
|
+
* Responsive design: Scales dot size and spacing based on screen size
|
|
30
|
+
*/
|
|
31
|
+
export const TypingIndicator = memo(
|
|
32
|
+
({
|
|
33
|
+
dotColor = "#9CA3AF",
|
|
34
|
+
backgroundColor = "#F3F4F6",
|
|
35
|
+
style,
|
|
36
|
+
duration = 800,
|
|
37
|
+
}: TypingIndicatorProps) => {
|
|
38
|
+
const responsive = useResponsive();
|
|
39
|
+
const [phase, setPhase] = useState(0);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
const interval = setInterval(() => {
|
|
43
|
+
setPhase((prev) => (prev + 1) % 4);
|
|
44
|
+
}, duration / 4);
|
|
45
|
+
|
|
46
|
+
return () => clearInterval(interval);
|
|
47
|
+
}, [duration]);
|
|
48
|
+
|
|
49
|
+
// Responsive sizing - dots scale slightly on tablets
|
|
50
|
+
const dotSize = useMemo(() => Math.floor(8 * Math.max(1, responsive.spacingMultiplier * 0.8)), [responsive.spacingMultiplier]);
|
|
51
|
+
const dotRadius = useMemo(() => Math.floor(dotSize / 2), [dotSize]);
|
|
52
|
+
const dotSpacing = useMemo(() => Math.floor(3 * responsive.spacingMultiplier), [responsive.spacingMultiplier]);
|
|
53
|
+
|
|
54
|
+
const padding = useMemo(() => Math.floor(12 * responsive.spacingMultiplier), [responsive.spacingMultiplier]);
|
|
55
|
+
const borderRadius = useMemo(() => Math.floor(16 * responsive.spacingMultiplier), [responsive.spacingMultiplier]);
|
|
56
|
+
const marginBottom = useMemo(() => Math.floor(8 * responsive.spacingMultiplier), [responsive.spacingMultiplier]);
|
|
57
|
+
const minIndicatorWidth = useMemo(() => Math.floor(60 * responsive.spacingMultiplier), [responsive.spacingMultiplier]);
|
|
58
|
+
|
|
59
|
+
const dotStyle = (index: number) => [
|
|
60
|
+
styles.dot,
|
|
61
|
+
{
|
|
62
|
+
width: dotSize,
|
|
63
|
+
height: dotSize,
|
|
64
|
+
borderRadius: dotRadius,
|
|
65
|
+
marginHorizontal: dotSpacing,
|
|
66
|
+
backgroundColor: dotColor,
|
|
67
|
+
opacity: phase === index ? 1 : 0.3,
|
|
68
|
+
},
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<View style={[styles.container, { backgroundColor, padding, borderRadius, marginBottom, minWidth: minIndicatorWidth }, style]}>
|
|
73
|
+
<View style={dotStyle(0)} />
|
|
74
|
+
<View style={dotStyle(1)} />
|
|
75
|
+
<View style={dotStyle(2)} />
|
|
76
|
+
</View>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
TypingIndicator.displayName = "TypingIndicator";
|
|
82
|
+
|
|
83
|
+
const styles = StyleSheet.create({
|
|
84
|
+
container: {
|
|
85
|
+
flexDirection: "row",
|
|
86
|
+
alignItems: "center",
|
|
87
|
+
padding: 12, // default, will be overridden
|
|
88
|
+
borderRadius: 16, // default, will be overridden
|
|
89
|
+
alignSelf: "flex-start",
|
|
90
|
+
marginBottom: 8, // default, will be overridden
|
|
91
|
+
minWidth: 60, // default, will be overridden
|
|
92
|
+
},
|
|
93
|
+
dot: {
|
|
94
|
+
width: 8, // default, will be overridden
|
|
95
|
+
height: 8, // default, will be overridden
|
|
96
|
+
borderRadius: 4, // default, will be overridden
|
|
97
|
+
marginHorizontal: 3, // default, will be overridden
|
|
98
|
+
},
|
|
99
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat Components Exports
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export { ChatMessage as ChatMessageComponent } from "./ChatMessage";
|
|
6
|
+
export type { ChatMessageProps } from "./ChatMessage";
|
|
7
|
+
|
|
8
|
+
export { ChatOptionButton } from "./ChatOptionButton";
|
|
9
|
+
export type { ChatOptionButtonProps } from "./ChatOptionButton";
|
|
10
|
+
|
|
11
|
+
export { TypingIndicator } from "./TypingIndicator";
|
|
12
|
+
export type { TypingIndicatorProps } from "./TypingIndicator";
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat Onboarding Hook
|
|
3
|
+
*
|
|
4
|
+
* Core state management for chat-based onboarding flows
|
|
5
|
+
* Generic and reusable across all apps
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
|
9
|
+
|
|
10
|
+
import type { ChatStep, ChatOption } from "../../domain/entities/ChatStep";
|
|
11
|
+
import type { ChatMessage } from "../../domain/entities/ChatMessage";
|
|
12
|
+
|
|
13
|
+
export interface UseChatOnboardingOptions {
|
|
14
|
+
/** Chat onboarding flow configuration */
|
|
15
|
+
flow: Record<string, ChatStep>;
|
|
16
|
+
|
|
17
|
+
/** Initial step ID (default: first step in flow) */
|
|
18
|
+
initialStepId?: string;
|
|
19
|
+
|
|
20
|
+
/** Callback when onboarding completes */
|
|
21
|
+
onComplete?: () => void;
|
|
22
|
+
|
|
23
|
+
/** Callback when user skips onboarding */
|
|
24
|
+
onSkip?: () => void;
|
|
25
|
+
|
|
26
|
+
/** Delay between messages (ms) */
|
|
27
|
+
messageDelay?: number;
|
|
28
|
+
|
|
29
|
+
/** Store progress persistently */
|
|
30
|
+
storageKey?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface UseChatOnboardingReturn {
|
|
34
|
+
/** Current chat step */
|
|
35
|
+
currentStep: ChatStep | null;
|
|
36
|
+
|
|
37
|
+
/** Current step ID */
|
|
38
|
+
currentStepId: string;
|
|
39
|
+
|
|
40
|
+
/** Array of chat messages */
|
|
41
|
+
messages: ChatMessage[];
|
|
42
|
+
|
|
43
|
+
/** Whether to show options */
|
|
44
|
+
showOptions: boolean;
|
|
45
|
+
|
|
46
|
+
/** Whether processing user input */
|
|
47
|
+
isProcessing: boolean;
|
|
48
|
+
|
|
49
|
+
/** Whether to show typing indicator */
|
|
50
|
+
showTypingIndicator: boolean;
|
|
51
|
+
|
|
52
|
+
/** Handle option selection */
|
|
53
|
+
handleOptionSelect: (option: ChatOption) => void;
|
|
54
|
+
|
|
55
|
+
/** Reset onboarding flow */
|
|
56
|
+
handleReset: () => void;
|
|
57
|
+
|
|
58
|
+
/** Navigate to specific step */
|
|
59
|
+
setCurrentStepId: (stepId: string) => void;
|
|
60
|
+
|
|
61
|
+
/** Submit name input */
|
|
62
|
+
handleSubmitName: (name: string) => void;
|
|
63
|
+
|
|
64
|
+
/** Skip current step */
|
|
65
|
+
handleSkip: () => void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Hook for managing chat onboarding state
|
|
70
|
+
*/
|
|
71
|
+
export const useChatOnboarding = ({
|
|
72
|
+
flow,
|
|
73
|
+
initialStepId,
|
|
74
|
+
onComplete,
|
|
75
|
+
onSkip,
|
|
76
|
+
messageDelay = 500,
|
|
77
|
+
}: UseChatOnboardingOptions): UseChatOnboardingReturn => {
|
|
78
|
+
// Get initial step ID
|
|
79
|
+
const getInitialStepId = useCallback(() => {
|
|
80
|
+
if (initialStepId) return initialStepId;
|
|
81
|
+
const stepIds = Object.keys(flow);
|
|
82
|
+
return stepIds.length > 0 ? stepIds[0] : "";
|
|
83
|
+
}, [flow, initialStepId]);
|
|
84
|
+
|
|
85
|
+
// State
|
|
86
|
+
const [currentStepId, setCurrentStepId] = useState<string>(getInitialStepId);
|
|
87
|
+
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
88
|
+
const [showOptions, setShowOptions] = useState<boolean>(false);
|
|
89
|
+
const [isProcessing, setIsProcessing] = useState<boolean>(false);
|
|
90
|
+
const [showTypingIndicator, setShowTypingIndicator] = useState<boolean>(false);
|
|
91
|
+
|
|
92
|
+
// Refs
|
|
93
|
+
const messageTimersRef = useRef<NodeJS.Timeout[]>([]);
|
|
94
|
+
const autoAdvanceTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
95
|
+
|
|
96
|
+
// Memoize current step
|
|
97
|
+
const currentStep = useMemo(() => {
|
|
98
|
+
return flow[currentStepId] || null;
|
|
99
|
+
}, [flow, currentStepId]);
|
|
100
|
+
|
|
101
|
+
// Clear all timers
|
|
102
|
+
const clearTimers = useCallback(() => {
|
|
103
|
+
messageTimersRef.current.forEach((timer) => {
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
});
|
|
106
|
+
messageTimersRef.current = [];
|
|
107
|
+
|
|
108
|
+
if (autoAdvanceTimerRef.current) {
|
|
109
|
+
clearTimeout(autoAdvanceTimerRef.current);
|
|
110
|
+
autoAdvanceTimerRef.current = null;
|
|
111
|
+
}
|
|
112
|
+
}, []);
|
|
113
|
+
|
|
114
|
+
// Load messages for current step
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (!currentStep) return;
|
|
117
|
+
|
|
118
|
+
clearTimers();
|
|
119
|
+
setMessages([]);
|
|
120
|
+
setShowOptions(false);
|
|
121
|
+
setShowTypingIndicator(true);
|
|
122
|
+
|
|
123
|
+
const timers: NodeJS.Timeout[] = [];
|
|
124
|
+
|
|
125
|
+
// Show typing indicator first
|
|
126
|
+
const hideTypingDelay = currentStep.messages.length > 0 ? 300 : 0;
|
|
127
|
+
timers.push(
|
|
128
|
+
setTimeout(() => {
|
|
129
|
+
setShowTypingIndicator(false);
|
|
130
|
+
}, hideTypingDelay)
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
// Add messages with staggered delays
|
|
134
|
+
let accumulatedDelay = hideTypingDelay;
|
|
135
|
+
currentStep.messages.forEach((msg, index) => {
|
|
136
|
+
const delay = accumulatedDelay + (index > 0 ? messageDelay : 0);
|
|
137
|
+
timers.push(
|
|
138
|
+
setTimeout(() => {
|
|
139
|
+
setMessages((prev) => [
|
|
140
|
+
...prev,
|
|
141
|
+
{
|
|
142
|
+
text: msg,
|
|
143
|
+
isUser: false,
|
|
144
|
+
},
|
|
145
|
+
]);
|
|
146
|
+
}, delay)
|
|
147
|
+
);
|
|
148
|
+
accumulatedDelay = delay + msg.length * 10; // Dynamic delay based on message length
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Auto-advance if configured
|
|
152
|
+
if (currentStep.autoNext) {
|
|
153
|
+
const autoAdvanceDelay = accumulatedDelay + (currentStep.delay || 2000);
|
|
154
|
+
const timer = setTimeout(() => {
|
|
155
|
+
setCurrentStepId(currentStep.autoNext!);
|
|
156
|
+
}, autoAdvanceDelay);
|
|
157
|
+
autoAdvanceTimerRef.current = timer;
|
|
158
|
+
} else {
|
|
159
|
+
// Show options after messages
|
|
160
|
+
const optionsDelay = accumulatedDelay + 500;
|
|
161
|
+
timers.push(
|
|
162
|
+
setTimeout(() => {
|
|
163
|
+
if (currentStep.options && currentStep.options.length > 0) {
|
|
164
|
+
setShowOptions(true);
|
|
165
|
+
}
|
|
166
|
+
}, optionsDelay)
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
messageTimersRef.current = timers;
|
|
171
|
+
|
|
172
|
+
return () => {
|
|
173
|
+
clearTimers();
|
|
174
|
+
};
|
|
175
|
+
}, [currentStep, messageDelay, clearTimers]);
|
|
176
|
+
|
|
177
|
+
// Handle option selection
|
|
178
|
+
const handleOptionSelect = useCallback(
|
|
179
|
+
(option: ChatOption) => {
|
|
180
|
+
if (isProcessing) return;
|
|
181
|
+
|
|
182
|
+
setIsProcessing(true);
|
|
183
|
+
setShowOptions(false);
|
|
184
|
+
|
|
185
|
+
// Add user message
|
|
186
|
+
setMessages((prev) => [
|
|
187
|
+
...prev,
|
|
188
|
+
{
|
|
189
|
+
text: option.label,
|
|
190
|
+
isUser: true,
|
|
191
|
+
},
|
|
192
|
+
]);
|
|
193
|
+
|
|
194
|
+
// Navigate to next step
|
|
195
|
+
setTimeout(() => {
|
|
196
|
+
setCurrentStepId(option.next);
|
|
197
|
+
setIsProcessing(false);
|
|
198
|
+
}, 600);
|
|
199
|
+
},
|
|
200
|
+
[isProcessing]
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
// Handle name submission
|
|
204
|
+
const handleSubmitName = useCallback(
|
|
205
|
+
(name: string) => {
|
|
206
|
+
if (!currentStep || isProcessing) return;
|
|
207
|
+
|
|
208
|
+
setIsProcessing(true);
|
|
209
|
+
|
|
210
|
+
// Skip if empty and allowed
|
|
211
|
+
if (!name.trim() && currentStep.skipIfEmpty) {
|
|
212
|
+
setCurrentStepId(currentStep.next || "");
|
|
213
|
+
setIsProcessing(false);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Add user message
|
|
218
|
+
setMessages((prev) => [
|
|
219
|
+
...prev,
|
|
220
|
+
{
|
|
221
|
+
text: name.trim(),
|
|
222
|
+
isUser: true,
|
|
223
|
+
},
|
|
224
|
+
]);
|
|
225
|
+
|
|
226
|
+
// Navigate to next step
|
|
227
|
+
setTimeout(() => {
|
|
228
|
+
if (currentStep?.next) {
|
|
229
|
+
setCurrentStepId(currentStep.next);
|
|
230
|
+
}
|
|
231
|
+
setIsProcessing(false);
|
|
232
|
+
}, 600);
|
|
233
|
+
},
|
|
234
|
+
[currentStep, isProcessing]
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
// Handle skip
|
|
238
|
+
const handleSkip = useCallback(() => {
|
|
239
|
+
clearTimers();
|
|
240
|
+
onSkip?.();
|
|
241
|
+
}, [clearTimers, onSkip]);
|
|
242
|
+
|
|
243
|
+
// Handle reset
|
|
244
|
+
const handleReset = useCallback(() => {
|
|
245
|
+
clearTimers();
|
|
246
|
+
setCurrentStepId(getInitialStepId());
|
|
247
|
+
setMessages([]);
|
|
248
|
+
setShowOptions(false);
|
|
249
|
+
setIsProcessing(false);
|
|
250
|
+
setShowTypingIndicator(false);
|
|
251
|
+
}, [clearTimers, getInitialStepId]);
|
|
252
|
+
|
|
253
|
+
// Handle completion
|
|
254
|
+
useEffect(() => {
|
|
255
|
+
if (currentStep?.isComplete) {
|
|
256
|
+
const timer = setTimeout(() => {
|
|
257
|
+
onComplete?.();
|
|
258
|
+
}, currentStep.delay || 2000);
|
|
259
|
+
|
|
260
|
+
return () => clearTimeout(timer);
|
|
261
|
+
}
|
|
262
|
+
return undefined;
|
|
263
|
+
}, [currentStep, onComplete]);
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
currentStep,
|
|
267
|
+
currentStepId,
|
|
268
|
+
messages,
|
|
269
|
+
showOptions,
|
|
270
|
+
isProcessing,
|
|
271
|
+
showTypingIndicator,
|
|
272
|
+
handleOptionSelect,
|
|
273
|
+
handleReset,
|
|
274
|
+
setCurrentStepId,
|
|
275
|
+
handleSubmitName,
|
|
276
|
+
handleSkip,
|
|
277
|
+
};
|
|
278
|
+
};
|