@afncdelacru/brady-chat 0.4.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.
@@ -1,337 +0,0 @@
1
- import React, { useState } from 'react';
2
- import { motion } from 'motion/react';
3
- import { useRecruitingFlow, UserRole } from './RecruitingFlowContext';
4
-
5
- interface ProgressiveContactFormProps {
6
- onSubmit: (data: any) => void;
7
- onCancel?: () => void;
8
- primaryCTA: string;
9
- secondaryCTA?: string;
10
- context: 'LO' | 'BM';
11
- }
12
-
13
- export function ProgressiveContactForm({
14
- onSubmit,
15
- onCancel,
16
- primaryCTA,
17
- secondaryCTA = "Not right now",
18
- context
19
- }: ProgressiveContactFormProps) {
20
- const { identityState, userIdentity, currentRole, setCurrentRole, sessionData, updateSessionData } = useRecruitingFlow();
21
-
22
- type RoleType = 'LO' | 'BM';
23
- const [formData, setFormData] = useState<{
24
- firstName: string;
25
- lastName: string;
26
- email: string;
27
- phone: string;
28
- role: RoleType;
29
- notes: string;
30
- }>({
31
- firstName: userIdentity.firstName || '',
32
- lastName: userIdentity.lastName || '',
33
- email: userIdentity.email || '',
34
- phone: userIdentity.phone || '',
35
- role: (currentRole === 'BM' || currentRole === 'LO') ? currentRole : (context === 'BM' ? 'BM' : 'LO'),
36
- notes: ''
37
- });
38
-
39
- const [showNotes, setShowNotes] = useState(false);
40
- const [roleChanged, setRoleChanged] = useState(false);
41
-
42
- const handleRoleChange = (newRole: UserRole) => {
43
- setFormData(prev => ({ ...prev, role: newRole }));
44
-
45
- // Detect role crossover
46
- if (newRole !== (context === 'BM' ? 'BM' : 'LO')) {
47
- setRoleChanged(true);
48
- setCurrentRole(newRole);
49
- } else {
50
- setRoleChanged(false);
51
- }
52
- };
53
-
54
- const handleSubmit = (e: React.FormEvent) => {
55
- e.preventDefault();
56
-
57
- // Update session data with role at submit
58
- updateSessionData({
59
- rolePrefAtSubmit: formData.role,
60
- submissionTimestamp: Date.now()
61
- });
62
-
63
- // Tag submission with all tracking data
64
- const submissionData = {
65
- ...formData,
66
- ...sessionData,
67
- intentLevel: sessionData,
68
- timestamp: Date.now()
69
- };
70
-
71
- onSubmit(submissionData);
72
- };
73
-
74
- const updateField = (field: string, value: string) => {
75
- setFormData(prev => ({ ...prev, [field]: value }));
76
- };
77
-
78
- // Render based on identity state
79
- const renderForm = () => {
80
- switch (identityState) {
81
- case 'KNOWN_RECRUIT':
82
- return (
83
- <>
84
- {/* Pre-filled email */}
85
- <div>
86
- <label className="block text-sm text-zinc-700 dark:text-zinc-300 mb-2">
87
- Email
88
- </label>
89
- <input
90
- type="email"
91
- value={formData.email}
92
- onChange={(e) => updateField('email', e.target.value)}
93
- className="w-full px-4 py-3 bg-zinc-50 dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-700 rounded-lg text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-[#4399D1] focus:border-transparent"
94
- required
95
- />
96
- </div>
97
-
98
- {/* Pre-filled phone if exists */}
99
- {formData.phone && (
100
- <div>
101
- <label className="block text-sm text-zinc-700 dark:text-zinc-300 mb-2">
102
- Mobile Phone
103
- </label>
104
- <input
105
- type="tel"
106
- value={formData.phone}
107
- onChange={(e) => updateField('phone', e.target.value)}
108
- className="w-full px-4 py-3 bg-zinc-50 dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-700 rounded-lg text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-[#4399D1] focus:border-transparent"
109
- />
110
- </div>
111
- )}
112
-
113
- {/* Optional notes (collapsed) */}
114
- {!showNotes ? (
115
- <button
116
- type="button"
117
- onClick={() => setShowNotes(true)}
118
- className="text-sm text-[#4399D1] hover:text-[#2B7AB8] transition-colors"
119
- >
120
- + Anything we should know before the conversation?
121
- </button>
122
- ) : (
123
- <div>
124
- <label className="block text-sm text-zinc-700 dark:text-zinc-300 mb-2">
125
- Additional Notes (Optional)
126
- </label>
127
- <textarea
128
- value={formData.notes}
129
- onChange={(e) => updateField('notes', e.target.value)}
130
- rows={3}
131
- className="w-full px-4 py-3 bg-zinc-50 dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-700 rounded-lg text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-[#4399D1] focus:border-transparent resize-none"
132
- placeholder="Any questions or specific scenarios you'd like to discuss..."
133
- />
134
- </div>
135
- )}
136
- </>
137
- );
138
-
139
- case 'SEMI_KNOWN':
140
- return (
141
- <>
142
- {/* Pre-filled name */}
143
- <div className="grid grid-cols-2 gap-4">
144
- <div>
145
- <label className="block text-sm text-zinc-700 dark:text-zinc-300 mb-2">
146
- First Name
147
- </label>
148
- <input
149
- type="text"
150
- value={formData.firstName}
151
- onChange={(e) => updateField('firstName', e.target.value)}
152
- className="w-full px-4 py-3 bg-zinc-50 dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-700 rounded-lg text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-[#4399D1] focus:border-transparent"
153
- required
154
- />
155
- </div>
156
- <div>
157
- <label className="block text-sm text-zinc-700 dark:text-zinc-300 mb-2">
158
- Last Name
159
- </label>
160
- <input
161
- type="text"
162
- value={formData.lastName}
163
- onChange={(e) => updateField('lastName', e.target.value)}
164
- className="w-full px-4 py-3 bg-zinc-50 dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-700 rounded-lg text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-[#4399D1] focus:border-transparent"
165
- required
166
- />
167
- </div>
168
- </div>
169
-
170
- {/* Email required */}
171
- <div>
172
- <label className="block text-sm text-zinc-700 dark:text-zinc-300 mb-2">
173
- Email
174
- </label>
175
- <input
176
- type="email"
177
- value={formData.email}
178
- onChange={(e) => updateField('email', e.target.value)}
179
- className="w-full px-4 py-3 bg-zinc-50 dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-700 rounded-lg text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-[#4399D1] focus:border-transparent"
180
- required
181
- />
182
- </div>
183
-
184
- {/* Phone optional */}
185
- <div>
186
- <label className="block text-sm text-zinc-700 dark:text-zinc-300 mb-2">
187
- Mobile Phone (Optional)
188
- </label>
189
- <input
190
- type="tel"
191
- value={formData.phone}
192
- onChange={(e) => updateField('phone', e.target.value)}
193
- className="w-full px-4 py-3 bg-zinc-50 dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-700 rounded-lg text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-[#4399D1] focus:border-transparent"
194
- />
195
- </div>
196
-
197
- {/* Role selector */}
198
- <div>
199
- <label className="block text-sm text-zinc-700 dark:text-zinc-300 mb-2">
200
- I'm exploring
201
- </label>
202
- <div className="flex gap-4">
203
- <label className="flex-1 flex items-center gap-3 px-4 py-3 bg-zinc-50 dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-700 rounded-lg cursor-pointer hover:border-[#4399D1] transition-colors">
204
- <input
205
- type="radio"
206
- name="role"
207
- value="LO"
208
- checked={formData.role === 'LO'}
209
- onChange={() => handleRoleChange('LO')}
210
- className="text-[#4399D1] focus:ring-[#4399D1]"
211
- />
212
- <span className="text-sm text-zinc-900 dark:text-white">Loan Officer</span>
213
- </label>
214
- <label className="flex-1 flex items-center gap-3 px-4 py-3 bg-zinc-50 dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-700 rounded-lg cursor-pointer hover:border-[#4399D1] transition-colors">
215
- <input
216
- type="radio"
217
- name="role"
218
- value="BM"
219
- checked={formData.role === 'BM'}
220
- onChange={() => handleRoleChange('BM')}
221
- className="text-[#4399D1] focus:ring-[#4399D1]"
222
- />
223
- <span className="text-sm text-zinc-900 dark:text-white">Branch Manager</span>
224
- </label>
225
- </div>
226
- </div>
227
- </>
228
- );
229
-
230
- case 'ANONYMOUS':
231
- default:
232
- return (
233
- <>
234
- {/* First name required */}
235
- <div>
236
- <label className="block text-sm text-zinc-700 dark:text-zinc-300 mb-2">
237
- First Name
238
- </label>
239
- <input
240
- type="text"
241
- value={formData.firstName}
242
- onChange={(e) => updateField('firstName', e.target.value)}
243
- className="w-full px-4 py-3 bg-zinc-50 dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-700 rounded-lg text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-[#4399D1] focus:border-transparent"
244
- required
245
- placeholder="Enter your first name"
246
- />
247
- </div>
248
-
249
- {/* Email required */}
250
- <div>
251
- <label className="block text-sm text-zinc-700 dark:text-zinc-300 mb-2">
252
- Email
253
- </label>
254
- <input
255
- type="email"
256
- value={formData.email}
257
- onChange={(e) => updateField('email', e.target.value)}
258
- className="w-full px-4 py-3 bg-zinc-50 dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-700 rounded-lg text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-[#4399D1] focus:border-transparent"
259
- required
260
- placeholder="your.email@example.com"
261
- />
262
- </div>
263
-
264
- {/* Role selector (optional) */}
265
- <div>
266
- <label className="block text-sm text-zinc-700 dark:text-zinc-300 mb-3">
267
- I'm exploring (optional)
268
- </label>
269
- <div className="flex gap-4">
270
- <label className="flex-1 flex items-center gap-3 px-4 py-3 bg-zinc-50 dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-700 rounded-lg cursor-pointer hover:border-[#4399D1] transition-colors">
271
- <input
272
- type="radio"
273
- name="role"
274
- value="LO"
275
- checked={formData.role === 'LO'}
276
- onChange={() => handleRoleChange('LO')}
277
- className="text-[#4399D1] focus:ring-[#4399D1]"
278
- />
279
- <span className="text-sm text-zinc-900 dark:text-white">Loan Officer</span>
280
- </label>
281
- <label className="flex-1 flex items-center gap-3 px-4 py-3 bg-zinc-50 dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-700 rounded-lg cursor-pointer hover:border-[#4399D1] transition-colors">
282
- <input
283
- type="radio"
284
- name="role"
285
- value="BM"
286
- checked={formData.role === 'BM'}
287
- onChange={() => handleRoleChange('BM')}
288
- className="text-[#4399D1] focus:ring-[#4399D1]"
289
- />
290
- <span className="text-sm text-zinc-900 dark:text-white">Branch Manager</span>
291
- </label>
292
- </div>
293
- </div>
294
- </>
295
- );
296
- }
297
- };
298
-
299
- return (
300
- <form onSubmit={handleSubmit} className="space-y-4">
301
- {/* Role Crossover Confirmation */}
302
- {roleChanged && (
303
- <motion.div
304
- initial={{ opacity: 0, height: 0 }}
305
- animate={{ opacity: 1, height: 'auto' }}
306
- className="p-3 bg-[#4399D1]/10 border border-[#4399D1]/20 rounded-lg"
307
- >
308
- <p className="text-sm text-[#4399D1]">
309
- Got it — we'll tailor this for a {formData.role === 'BM' ? 'Branch Manager' : 'Loan Officer'} perspective.
310
- </p>
311
- </motion.div>
312
- )}
313
-
314
- {/* Progressive Form Fields */}
315
- {renderForm()}
316
-
317
- {/* CTAs */}
318
- <div className="space-y-3 pt-4">
319
- <button
320
- type="submit"
321
- className="w-full px-6 py-3 bg-gradient-to-r from-[#4399D1] to-[#2B7AB8] hover:from-[#2B7AB8] hover:to-[#4399D1] text-white rounded-lg transition-all duration-300 shadow-lg"
322
- >
323
- {primaryCTA}
324
- </button>
325
- {onCancel && (
326
- <button
327
- type="button"
328
- onClick={onCancel}
329
- className="w-full px-6 py-3 bg-white dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 border border-zinc-300 dark:border-zinc-700 rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-700 transition-colors"
330
- >
331
- {secondaryCTA}
332
- </button>
333
- )}
334
- </div>
335
- </form>
336
- );
337
- }
@@ -1,259 +0,0 @@
1
- import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
2
-
3
- // Identity States
4
- export type IdentityState = 'KNOWN_RECRUIT' | 'SEMI_KNOWN' | 'ANONYMOUS';
5
-
6
- // Intent Levels
7
- export type IntentLevel = 'LOW' | 'MEDIUM' | 'HIGH';
8
-
9
- // User Role
10
- export type UserRole = 'LO' | 'BM' | 'UNKNOWN';
11
-
12
- // Page Context
13
- export type PageContext = 'LO' | 'BM';
14
-
15
- // User Identity Data
16
- interface UserIdentity {
17
- firstName?: string;
18
- lastName?: string;
19
- email?: string;
20
- phone?: string;
21
- role?: UserRole;
22
- }
23
-
24
- // Intent Tracking
25
- interface IntentData {
26
- level: IntentLevel;
27
- actions: string[];
28
- calculatorOpens: number;
29
- calculatorAdjustments: number;
30
- calculatorTimeSpent: number;
31
- highIntentActions: string[];
32
- }
33
-
34
- // Session Data for Tagging
35
- interface SessionData {
36
- identityState: IdentityState;
37
- pageContext: PageContext;
38
- pageType: 'Personalized' | 'Generic';
39
- calculatorsUsed: ('LO' | 'BM')[];
40
- userAdjustedInputs: boolean;
41
- rolePrefAtSubmit?: UserRole;
42
- submissionTimestamp?: number;
43
- }
44
-
45
- interface RecruitingFlowContextType {
46
- // Identity
47
- identityState: IdentityState;
48
- userIdentity: UserIdentity;
49
- updateUserIdentity: (data: Partial<UserIdentity>) => void;
50
-
51
- // Intent
52
- intentData: IntentData;
53
- trackAction: (action: string, type?: 'low' | 'medium' | 'high') => void;
54
- trackCalculatorOpen: (type: 'LO' | 'BM') => void;
55
- trackCalculatorAdjustment: () => void;
56
- trackCalculatorTime: (seconds: number) => void;
57
-
58
- // Role Management
59
- currentRole: UserRole;
60
- setCurrentRole: (role: UserRole) => void;
61
-
62
- // Session
63
- sessionData: SessionData;
64
- updateSessionData: (data: Partial<SessionData>) => void;
65
- }
66
-
67
- const RecruitingFlowContext = createContext<RecruitingFlowContextType | undefined>(undefined);
68
-
69
- interface RecruitingFlowProviderProps {
70
- children: ReactNode;
71
- initialIdentityState?: IdentityState;
72
- initialUserData?: UserIdentity;
73
- pageContext: PageContext;
74
- pageType: 'Personalized' | 'Generic';
75
- }
76
-
77
- export function RecruitingFlowProvider({
78
- children,
79
- initialIdentityState = 'ANONYMOUS',
80
- initialUserData = {},
81
- pageContext,
82
- pageType
83
- }: RecruitingFlowProviderProps) {
84
- const [identityState, setIdentityState] = useState<IdentityState>(initialIdentityState);
85
- const [userIdentity, setUserIdentity] = useState<UserIdentity>(initialUserData);
86
- const [currentRole, setCurrentRole] = useState<UserRole>(initialUserData.role || 'UNKNOWN');
87
-
88
- const [intentData, setIntentData] = useState<IntentData>({
89
- level: 'LOW',
90
- actions: [],
91
- calculatorOpens: 0,
92
- calculatorAdjustments: 0,
93
- calculatorTimeSpent: 0,
94
- highIntentActions: []
95
- });
96
-
97
- const [sessionData, setSessionData] = useState<SessionData>({
98
- identityState,
99
- pageContext,
100
- pageType,
101
- calculatorsUsed: [],
102
- userAdjustedInputs: false
103
- });
104
-
105
- // Update identity state when user data changes
106
- useEffect(() => {
107
- const hasName = !!(userIdentity.firstName && userIdentity.lastName);
108
- const hasContact = !!(userIdentity.email || userIdentity.phone);
109
-
110
- if (hasName && hasContact) {
111
- setIdentityState('KNOWN_RECRUIT');
112
- } else if (hasName) {
113
- setIdentityState('SEMI_KNOWN');
114
- } else {
115
- setIdentityState('ANONYMOUS');
116
- }
117
- }, [userIdentity]);
118
-
119
- // Update session data when identity state changes
120
- useEffect(() => {
121
- setSessionData(prev => ({ ...prev, identityState }));
122
- }, [identityState]);
123
-
124
- const updateUserIdentity = useCallback((data: Partial<UserIdentity>) => {
125
- setUserIdentity(prev => ({ ...prev, ...data }));
126
- }, []);
127
-
128
- const trackAction = useCallback((action: string, type: 'low' | 'medium' | 'high' = 'low') => {
129
- setIntentData(prev => {
130
- const newActions = [...prev.actions, action];
131
- let newLevel = prev.level;
132
- const newHighIntentActions = [...prev.highIntentActions];
133
-
134
- // Upgrade intent level (never downgrade)
135
- if (type === 'high') {
136
- newLevel = 'HIGH';
137
- newHighIntentActions.push(action);
138
- } else if (type === 'medium' && prev.level === 'LOW') {
139
- newLevel = 'MEDIUM';
140
- }
141
-
142
- return {
143
- ...prev,
144
- level: newLevel,
145
- actions: newActions,
146
- highIntentActions: newHighIntentActions
147
- };
148
- });
149
- }, []);
150
-
151
- const trackCalculatorOpen = useCallback((type: 'LO' | 'BM') => {
152
- setIntentData(prev => {
153
- const newOpens = prev.calculatorOpens + 1;
154
- const newActions = [...prev.actions];
155
- let newLevel = prev.level;
156
-
157
- // Multiple opens = medium intent
158
- if (newOpens > 1 && prev.level === 'LOW') {
159
- newLevel = 'MEDIUM';
160
- newActions.push('calculator_reopened');
161
- } else if (newOpens === 1) {
162
- newActions.push('calculator_opened');
163
- }
164
-
165
- return {
166
- ...prev,
167
- calculatorOpens: newOpens,
168
- level: newLevel,
169
- actions: newActions
170
- };
171
- });
172
-
173
- setSessionData(prev => {
174
- const calculatorsUsed = prev.calculatorsUsed.includes(type)
175
- ? prev.calculatorsUsed
176
- : [...prev.calculatorsUsed, type];
177
- return { ...prev, calculatorsUsed };
178
- });
179
- }, []);
180
-
181
- const trackCalculatorAdjustment = useCallback(() => {
182
- setIntentData(prev => {
183
- const newAdjustments = prev.calculatorAdjustments + 1;
184
- let newLevel = prev.level;
185
- const newActions = [...prev.actions];
186
-
187
- // Multiple adjustments = medium intent (only upgrade, never downgrade)
188
- if (newAdjustments >= 2 && prev.level === 'LOW') {
189
- newLevel = 'MEDIUM';
190
- newActions.push('calculator_adjusted_multiple');
191
- } else if (newAdjustments === 1) {
192
- newActions.push('calculator_adjusted');
193
- }
194
-
195
- return {
196
- ...prev,
197
- calculatorAdjustments: newAdjustments,
198
- level: newLevel,
199
- actions: newActions
200
- };
201
- });
202
-
203
- setSessionData(prev => ({ ...prev, userAdjustedInputs: true }));
204
- }, []);
205
-
206
- const trackCalculatorTime = useCallback((seconds: number) => {
207
- setIntentData(prev => {
208
- const newTimeSpent = prev.calculatorTimeSpent + seconds;
209
- let newLevel = prev.level;
210
- const newActions = [...prev.actions];
211
-
212
- // Lingering > 30 seconds = medium intent
213
- if (newTimeSpent > 30 && prev.level === 'LOW' && prev.calculatorTimeSpent <= 30) {
214
- newLevel = 'MEDIUM';
215
- newActions.push('calculator_lingered');
216
- }
217
-
218
- return {
219
- ...prev,
220
- calculatorTimeSpent: newTimeSpent,
221
- level: newLevel,
222
- actions: newActions
223
- };
224
- });
225
- }, []);
226
-
227
- const updateSessionData = useCallback((data: Partial<SessionData>) => {
228
- setSessionData(prev => ({ ...prev, ...data }));
229
- }, []);
230
-
231
- const value: RecruitingFlowContextType = {
232
- identityState,
233
- userIdentity,
234
- updateUserIdentity,
235
- intentData,
236
- trackAction,
237
- trackCalculatorOpen,
238
- trackCalculatorAdjustment,
239
- trackCalculatorTime,
240
- currentRole,
241
- setCurrentRole,
242
- sessionData,
243
- updateSessionData
244
- };
245
-
246
- return (
247
- <RecruitingFlowContext.Provider value={value}>
248
- {children}
249
- </RecruitingFlowContext.Provider>
250
- );
251
- }
252
-
253
- export function useRecruitingFlow() {
254
- const context = useContext(RecruitingFlowContext);
255
- if (context === undefined) {
256
- throw new Error('useRecruitingFlow must be used within a RecruitingFlowProvider');
257
- }
258
- return context;
259
- }