StickerRequestForm.tsx
1 import React from 'react' 2 import { Box, Text, useInput } from 'ink' 3 import TextInput from './TextInput.js' 4 import Link from 'ink-link' 5 // import figures from 'figures' (not used after refactoring) 6 import { validateField, ValidationError } from '../utils/validate.js' 7 import { openBrowser } from '../utils/browser.js' 8 import { getTheme } from '../utils/theme.js' 9 import { logEvent } from '../services/statsig.js' 10 import { logError } from '../utils/log.js' 11 import { 12 AnimatedClaudeAsterisk, 13 ClaudeAsteriskSize, 14 } from './AnimatedClaudeAsterisk.js' 15 import { useTerminalSize } from '../hooks/useTerminalSize.js' 16 17 export type FormData = { 18 name: string 19 email: string 20 address1: string 21 address2: string 22 city: string 23 state: string 24 zip: string 25 phone: string 26 usLocation: boolean 27 } 28 29 interface StickerRequestFormProps { 30 onSubmit: (data: FormData) => void 31 onClose: () => void 32 googleFormURL?: string 33 } 34 35 export function StickerRequestForm({ 36 onSubmit, 37 onClose, 38 }: StickerRequestFormProps) { 39 const [googleFormURL, setGoogleFormURL] = React.useState('') 40 const { rows } = useTerminalSize() 41 42 // Determine the appropriate asterisk size based on terminal height 43 // Small ASCII art is 5 lines tall, large is 22 lines 44 // Need to account for the form content too which needs about 18-22 lines minimum 45 const getAsteriskSize = (): ClaudeAsteriskSize => { 46 // Large terminals (can fit large ASCII art + form content comfortably) 47 if (rows >= 50) { 48 return 'large' 49 } 50 // Medium terminals (can fit medium ASCII art + form content) 51 else if (rows >= 35) { 52 return 'medium' 53 } 54 // Small terminals or any other case 55 else { 56 return 'small' 57 } 58 } 59 60 // Animation logic is now handled by the AnimatedClaudeAsterisk component 61 62 // Function to generate Google Form URL 63 const generateGoogleFormURL = (data: FormData) => { 64 // URL encode all form values 65 const name = encodeURIComponent(data.name || '') 66 const email = encodeURIComponent(data.email || '') 67 const phone = encodeURIComponent(data.phone || '') 68 const address1 = encodeURIComponent(data.address1 || '') 69 const address2 = encodeURIComponent(data.address2 || '') 70 const city = encodeURIComponent(data.city || '') 71 const state = encodeURIComponent(data.state || '') 72 // Set country as United States since we're only shipping there 73 const country = encodeURIComponent('USA') 74 75 return `https://docs.google.com/forms/d/e/1FAIpQLSfYhWr1a-t4IsvS2FKyEH45HRmHKiPUycvAlFKaD0NugqvfDA/viewform?usp=pp_url&entry.2124017765=${name}&entry.1522143766=${email}&entry.1730584532=${phone}&entry.1700407131=${address1}&entry.109484232=${address2}&entry.1209468849=${city}&entry.222866183=${state}&entry.1042966503=${country}` 76 } 77 78 const [formState, setFormState] = React.useState<Partial<FormData>>({}) 79 const [currentField, setCurrentField] = React.useState<keyof FormData>('name') 80 const [inputValue, setInputValue] = React.useState('') 81 const [cursorOffset, setCursorOffset] = React.useState(0) 82 const [error, setError] = React.useState<ValidationError | null>(null) 83 const [showingSummary, setShowingSummary] = React.useState(false) 84 const [showingNonUsMessage, setShowingNonUsMessage] = React.useState(false) 85 86 const [selectedYesNo, setSelectedYesNo] = React.useState<'yes' | 'no'>('yes') 87 const theme = getTheme() 88 89 const fields: Array<{ key: keyof FormData; label: string }> = [ 90 { key: 'name', label: 'Name' }, 91 { key: 'usLocation', label: 'Are you in the United States? (y/n)' }, 92 { key: 'email', label: 'Email' }, 93 { key: 'phone', label: 'Phone Number' }, 94 { key: 'address1', label: 'Address Line 1' }, 95 { key: 'address2', label: 'Address Line 2 (optional)' }, 96 { key: 'city', label: 'City' }, 97 { key: 'state', label: 'State' }, 98 { key: 'zip', label: 'ZIP Code' }, 99 ] 100 101 // Helper to navigate to the next field 102 const goToNextField = (currentKey: keyof FormData) => { 103 // Log form progression 104 const currentIndex = fields.findIndex(f => f.key === currentKey) 105 const nextIndex = currentIndex + 1 106 107 if (currentIndex === -1) throw new Error('Invalid field state') 108 const nextField = fields[nextIndex] 109 if (!nextField) throw new Error('Invalid field state') 110 111 // Log field completion event 112 logEvent('sticker_form_field_completed', { 113 field_name: currentKey, 114 field_index: currentIndex.toString(), 115 next_field: nextField.key, 116 form_progress: `${nextIndex}/${fields.length}`, 117 }) 118 119 setCurrentField(nextField.key) 120 const newValue = formState[nextField.key]?.toString() || '' 121 setInputValue(newValue) 122 setCursorOffset(newValue.length) 123 setError(null) 124 } 125 126 useInput((input, key) => { 127 // Exit on Escape, Ctrl-C, or Ctrl-D 128 if (key.escape || (key.ctrl && (input === 'c' || input === 'd'))) { 129 onClose() 130 return 131 } 132 133 // Handle return key on non-US message screen 134 if (showingNonUsMessage && key.return) { 135 onClose() 136 return 137 } 138 139 // Handle Y/N keypresses and arrow navigation for US location question 140 if (currentField === 'usLocation' && !showingSummary) { 141 // Arrow key navigation for Yes/No 142 if (key.leftArrow || key.rightArrow) { 143 setSelectedYesNo(prev => (prev === 'yes' ? 'no' : 'yes')) 144 return 145 } 146 147 if (key.return) { 148 if (selectedYesNo === 'yes') { 149 const newState = { ...formState, [currentField]: true } 150 setFormState(newState) 151 152 // Move to next field 153 goToNextField(currentField) 154 } else { 155 setShowingNonUsMessage(true) 156 } 157 return 158 } 159 160 // Handle direct Y/N keypresses 161 const normalized = input.toLowerCase() 162 if (['y', 'yes'].includes(normalized)) { 163 const newState = { ...formState, [currentField]: true } 164 setFormState(newState) 165 166 // Move to next field 167 goToNextField(currentField) 168 return 169 } 170 if (['n', 'no'].includes(normalized)) { 171 setShowingNonUsMessage(true) 172 return 173 } 174 } 175 176 // Allows tabbing between form fields with validation 177 if (!showingSummary) { 178 if (key.tab) { 179 if (key.shift) { 180 const currentIndex = fields.findIndex(f => f.key === currentField) 181 if (currentIndex === -1) throw new Error('Invalid field state') 182 const prevIndex = (currentIndex - 1 + fields.length) % fields.length 183 const prevField = fields[prevIndex] 184 if (!prevField) throw new Error('Invalid field index') 185 setCurrentField(prevField.key) 186 const newValue = formState[prevField.key]?.toString() || '' 187 setInputValue(newValue) 188 setCursorOffset(newValue.length) 189 setError(null) 190 return 191 } 192 193 if (currentField !== 'address2' && currentField !== 'usLocation') { 194 const currentValue = inputValue.trim() 195 const validationError = validateField(currentField, currentValue) 196 if (validationError) { 197 setError({ 198 message: 'Please fill out this field before continuing', 199 }) 200 return 201 } 202 const newState = { ...formState, [currentField]: currentValue } 203 setFormState(newState) 204 } 205 206 // Find the next field index with modulo wrap-around 207 const currentIndex = fields.findIndex(f => f.key === currentField) 208 if (currentIndex === -1) throw new Error('Invalid field state') 209 const nextIndex = (currentIndex + 1) % fields.length 210 const nextField = fields[nextIndex] 211 if (!nextField) throw new Error('Invalid field index') 212 213 // Use our helper to navigate to this field 214 setCurrentField(nextField.key) 215 const newValue = formState[nextField.key]?.toString() || '' 216 setInputValue(newValue) 217 setCursorOffset(newValue.length) 218 setError(null) 219 return 220 } 221 } 222 223 if (showingSummary) { 224 if (key.return) { 225 onSubmit(formState as FormData) 226 } 227 } 228 }) 229 230 const handleSubmit = (value: string) => { 231 if (!value && currentField === 'address2') { 232 const newState = { ...formState, [currentField]: '' } 233 setFormState(newState) 234 goToNextField(currentField) 235 return 236 } 237 238 const validationError = validateField(currentField, value) 239 if (validationError) { 240 setError(validationError) 241 return 242 } 243 244 if (currentField === 'state' && formState.zip) { 245 const zipError = validateField('zip', formState.zip) 246 if (zipError) { 247 setError({ 248 message: 'The existing ZIP code is not valid for this state', 249 }) 250 return 251 } 252 } 253 254 const newState = { ...formState, [currentField]: value } 255 setFormState(newState) 256 setError(null) 257 258 const currentIndex = fields.findIndex(f => f.key === currentField) 259 if (currentIndex === -1) throw new Error('Invalid field state') 260 261 if (currentIndex < fields.length - 1) { 262 goToNextField(currentField) 263 } else { 264 setShowingSummary(true) 265 } 266 } 267 268 const currentFieldDef = fields.find(f => f.key === currentField) 269 if (!currentFieldDef) throw new Error('Invalid field state') 270 271 // Generate Google Form URL for summary view and open it automatically 272 if (showingSummary && !googleFormURL) { 273 const url = generateGoogleFormURL(formState as FormData) 274 setGoogleFormURL(url) 275 276 // Log reaching the summary page 277 logEvent('sticker_form_summary_reached', { 278 fields_completed: Object.keys(formState).length.toString(), 279 }) 280 281 // Auto-open the URL in the user's browser 282 openBrowser(url).catch(err => { 283 logError(err) 284 }) 285 } 286 287 const classifiedHeaderText = `╔══════════════════════════════╗ 288 ║ CLASSIFIED ║ 289 ╚══════════════════════════════╝` 290 const headerText = `You've discovered Claude's top secret sticker distribution operation!` 291 292 // Helper function to render the header section 293 const renderHeader = () => ( 294 <> 295 <Box flexDirection="column" alignItems="center" justifyContent="center"> 296 <Text>{classifiedHeaderText}</Text> 297 <Text bold color={theme.claude}> 298 {headerText} 299 </Text> 300 </Box> 301 {!showingSummary && ( 302 <Box justifyContent="center"> 303 <AnimatedClaudeAsterisk 304 size={getAsteriskSize()} 305 cycles={getAsteriskSize() === 'large' ? 4 : undefined} 306 /> 307 </Box> 308 )} 309 </> 310 ) 311 312 // Helper function to render the footer section 313 const renderFooter = () => ( 314 <Box marginLeft={1}> 315 {showingNonUsMessage || showingSummary ? ( 316 <Text color={theme.suggestion} bold> 317 Press Enter to return to base 318 </Text> 319 ) : ( 320 <Text color={theme.secondaryText}> 321 {currentField === 'usLocation' ? ( 322 <> 323 ←/→ arrows to select · Enter to confirm · Y/N keys also work · Esc 324 Esc to abort mission 325 </> 326 ) : ( 327 <> 328 Enter to continue · Tab/Shift+Tab to navigate · Esc to abort 329 mission 330 </> 331 )} 332 </Text> 333 )} 334 </Box> 335 ) 336 337 // Helper function to render the main content based on current state 338 const renderContent = () => { 339 if (showingSummary) { 340 return ( 341 <> 342 <Box> 343 <Text color={theme.suggestion} bold> 344 Please review your shipping information: 345 </Text> 346 </Box> 347 348 <Box flexDirection="column"> 349 {fields 350 .filter(f => f.key !== 'usLocation') 351 .map(field => ( 352 <Box key={field.key} marginLeft={3}> 353 <Text> 354 <Text bold color={theme.text}> 355 {field.label}: 356 </Text>{' '} 357 <Text 358 color={ 359 !formState[field.key] ? theme.secondaryText : theme.text 360 } 361 > 362 {formState[field.key] || '(empty)'} 363 </Text> 364 </Text> 365 </Box> 366 ))} 367 </Box> 368 369 {/* Google Form URL with improved instructions */} 370 <Box marginTop={1} marginBottom={1} flexDirection="column"> 371 <Box> 372 <Text color={theme.text}>Submit your sticker request:</Text> 373 </Box> 374 <Box marginTop={1}> 375 <Link url={googleFormURL}> 376 <Text color={theme.success} underline> 377 ➜ Click here to open Google Form 378 </Text> 379 </Link> 380 </Box> 381 <Box marginTop={1}> 382 <Text color={theme.secondaryText} italic> 383 (You can still edit your info on the form) 384 </Text> 385 </Box> 386 </Box> 387 </> 388 ) 389 } else if (showingNonUsMessage) { 390 return ( 391 <> 392 <Box marginY={1}> 393 <Text color={theme.error} bold> 394 Mission Not Available 395 </Text> 396 </Box> 397 398 <Box flexDirection="column" marginY={1}> 399 <Text color={theme.text}> 400 We're sorry, but the Claude sticker deployment mission is 401 only available within the United States. 402 </Text> 403 <Box marginTop={1}> 404 <Text color={theme.text}> 405 Future missions may expand to other territories. Stay tuned for 406 updates. 407 </Text> 408 </Box> 409 </Box> 410 </> 411 ) 412 } else { 413 return ( 414 <> 415 <Box flexDirection="column"> 416 <Text color={theme.text}> 417 Please provide your coordinates for the sticker deployment 418 mission. 419 </Text> 420 <Text color={theme.secondaryText}> 421 Currently only shipping within the United States. 422 </Text> 423 </Box> 424 425 <Box flexDirection="column"> 426 <Box flexDirection="row" marginLeft={2}> 427 {fields.map((f, i) => ( 428 <React.Fragment key={f.key}> 429 <Text 430 color={ 431 f.key === currentField 432 ? theme.suggestion 433 : theme.secondaryText 434 } 435 > 436 {f.key === currentField ? ( 437 `[${f.label}]` 438 ) : formState[f.key] ? ( 439 <Text color={theme.secondaryText}>●</Text> 440 ) : ( 441 '○' 442 )} 443 </Text> 444 {i < fields.length - 1 && <Text> </Text>} 445 </React.Fragment> 446 ))} 447 </Box> 448 <Box marginLeft={2}> 449 <Text color={theme.secondaryText}> 450 Field {fields.findIndex(f => f.key === currentField) + 1} of{' '} 451 {fields.length} 452 </Text> 453 </Box> 454 </Box> 455 456 <Box flexDirection="column" marginX={2}> 457 {currentField === 'usLocation' ? ( 458 // Special Yes/No Buttons for US Location 459 <Box flexDirection="row"> 460 <Text 461 color={ 462 selectedYesNo === 'yes' 463 ? theme.success 464 : theme.secondaryText 465 } 466 bold 467 > 468 {selectedYesNo === 'yes' ? '●' : '○'} YES 469 </Text> 470 <Text> </Text> 471 <Text 472 color={ 473 selectedYesNo === 'no' ? theme.error : theme.secondaryText 474 } 475 bold 476 > 477 {selectedYesNo === 'no' ? '●' : '○'} NO 478 </Text> 479 </Box> 480 ) : ( 481 // Regular TextInput for other fields 482 <TextInput 483 value={inputValue} 484 onChange={setInputValue} 485 onSubmit={handleSubmit} 486 placeholder={currentFieldDef.label} 487 cursorOffset={cursorOffset} 488 onChangeCursorOffset={setCursorOffset} 489 columns={40} 490 /> 491 )} 492 {error && ( 493 <Box marginTop={1}> 494 <Text color={theme.error} bold> 495 ✗ {error.message} 496 </Text> 497 </Box> 498 )} 499 </Box> 500 </> 501 ) 502 } 503 } 504 505 // Main render with consistent structure 506 return ( 507 <Box flexDirection="column" paddingLeft={1}> 508 <Box 509 borderColor={theme.claude} 510 borderStyle="round" 511 flexDirection="column" 512 gap={1} 513 padding={1} 514 paddingLeft={2} 515 width={100} 516 > 517 {renderHeader()} 518 {renderContent()} 519 </Box> 520 {renderFooter()} 521 </Box> 522 ) 523 }