/ src / components / StickerRequestForm.tsx
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&apos;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  }