@@ -16,7 +16,7 @@ import { useRouter } from "next/navigation";
1616import { useCallback , useEffect , useMemo , useState } from "react" ;
1717import { toast } from "sonner" ;
1818
19- type Stage = "key" | "device" | "provision" | "done" ;
19+ type Stage = "key" | "device" | "phone" | " provision" | "done" ;
2020
2121interface DeviceState {
2222 user_code : string ;
@@ -34,15 +34,17 @@ interface TenantState {
3434const STEP_INDEX : Record < Stage , number > = {
3535 key : 0 ,
3636 device : 1 ,
37- provision : 1 ,
38- done : 2 ,
37+ phone : 2 ,
38+ provision : 2 ,
39+ done : 3 ,
3940} ;
40- const TOTAL_STEPS = 3 ;
41+ const TOTAL_STEPS = 4 ;
4142
4243export default function OnboardClient ( ) {
4344 const router = useRouter ( ) ;
4445 const [ stage , setStage ] = useState < Stage > ( "key" ) ;
4546 const [ apiKey , setApiKey ] = useState ( "" ) ;
47+ const [ userPhone , setUserPhone ] = useState ( "" ) ;
4648 const [ device , setDevice ] = useState < DeviceState | null > ( null ) ;
4749 const [ tenant , setTenant ] = useState < TenantState | null > ( null ) ;
4850 const [ busy , setBusy ] = useState ( false ) ;
@@ -98,7 +100,7 @@ export default function OnboardClient() {
98100 if ( cancelled ) return ;
99101 switch ( data . status ) {
100102 case "ok" :
101- setStage ( "provision " ) ;
103+ setStage ( "phone " ) ;
102104 return ;
103105 case "pending" :
104106 pollTimer = setTimeout ( poll , interval ) ;
@@ -144,7 +146,7 @@ export default function OnboardClient() {
144146 void fetch ( "/api/provision" , {
145147 method : "POST" ,
146148 headers : { "content-type" : "application/json" } ,
147- body : JSON . stringify ( { openaiKey : apiKey . trim ( ) } ) ,
149+ body : JSON . stringify ( { openaiKey : apiKey . trim ( ) , userPhone : userPhone . trim ( ) } ) ,
148150 } )
149151 . then ( async ( res ) => {
150152 if ( cancelled ) return ;
@@ -173,7 +175,7 @@ export default function OnboardClient() {
173175 return ( ) => {
174176 cancelled = true ;
175177 } ;
176- } , [ stage , apiKey ] ) ;
178+ } , [ stage , apiKey , userPhone ] ) ;
177179
178180 const activeIdx = STEP_INDEX [ stage ] ;
179181
@@ -194,6 +196,9 @@ export default function OnboardClient() {
194196 beginSpectrum = { beginSpectrum }
195197 device = { device }
196198 tenant = { tenant }
199+ userPhone = { userPhone }
200+ setUserPhone = { setUserPhone }
201+ onPhoneSubmit = { ( ) => setStage ( "provision" ) }
197202 />
198203 </ div >
199204 </ div >
@@ -205,6 +210,7 @@ function StageIcon({ stage }: { stage: Stage }) {
205210 case "key" :
206211 return < ChatGPTChip /> ;
207212 case "device" :
213+ case "phone" :
208214 return < SpectrumChip /> ;
209215 case "provision" :
210216 case "done" :
@@ -243,6 +249,9 @@ interface StageContentProps {
243249 beginSpectrum : ( ) => void ;
244250 device : DeviceState | null ;
245251 tenant : TenantState | null ;
252+ userPhone : string ;
253+ setUserPhone : ( v : string ) => void ;
254+ onPhoneSubmit : ( ) => void ;
246255}
247256
248257function StageContent ( {
@@ -253,6 +262,9 @@ function StageContent({
253262 beginSpectrum,
254263 device,
255264 tenant,
265+ userPhone,
266+ setUserPhone,
267+ onPhoneSubmit,
256268} : StageContentProps ) {
257269 switch ( stage ) {
258270 case "key" :
@@ -271,6 +283,16 @@ function StageContent({
271283 </ >
272284 ) ;
273285
286+ case "phone" :
287+ return (
288+ < PhoneStage
289+ userPhone = { userPhone }
290+ setUserPhone = { setUserPhone }
291+ busy = { busy }
292+ onSubmit = { onPhoneSubmit }
293+ />
294+ ) ;
295+
274296 case "provision" :
275297 return (
276298 < >
@@ -300,6 +322,103 @@ function StageContent({
300322 }
301323}
302324
325+ function PhoneStage ( {
326+ userPhone,
327+ setUserPhone,
328+ busy,
329+ onSubmit,
330+ } : {
331+ userPhone : string ;
332+ setUserPhone : ( v : string ) => void ;
333+ busy : boolean ;
334+ onSubmit : ( ) => void ;
335+ } ) {
336+ const [ shaking , setShaking ] = useState ( false ) ;
337+ const [ attempted , setAttempted ] = useState ( false ) ;
338+ const trimmed = userPhone . trim ( ) ;
339+ const isValid = useMemo ( ( ) => / ^ \+ [ 1 - 9 ] \d { 6 , 14 } $ / . test ( trimmed ) , [ trimmed ] ) ;
340+ const isEmpty = trimmed . length === 0 ;
341+
342+ const state : "valid" | "invalid" | "neutral" = isValid
343+ ? "valid"
344+ : attempted && ! isEmpty
345+ ? "invalid"
346+ : "neutral" ;
347+
348+ const handleSubmit = ( ) => {
349+ if ( ! isValid ) {
350+ setAttempted ( true ) ;
351+ setShaking ( true ) ;
352+ setTimeout ( ( ) => setShaking ( false ) , 420 ) ;
353+ toast . error ( "That doesn't look like a phone number" , {
354+ description : "Use E.164 format, e.g. +14155550123." ,
355+ } ) ;
356+ return ;
357+ }
358+ onSubmit ( ) ;
359+ } ;
360+
361+ return (
362+ < >
363+ < h1 className = "section-title fade-up fade-up-4 mt-4" > Your phone number</ h1 >
364+ < p className = "body-muted fade-up fade-up-5 mt-2 max-w-[24rem] text-balance" >
365+ Spectrum uses this to assign you a shared iMessage bot you can text.
366+ </ p >
367+ < div className = "fade-up fade-up-6 mt-7 w-full max-w-[28rem]" >
368+ < form
369+ className = { `flex w-full flex-col gap-3 ${ shaking ? "shake" : "" } ` }
370+ onSubmit = { ( e ) => {
371+ e . preventDefault ( ) ;
372+ handleSubmit ( ) ;
373+ } }
374+ noValidate
375+ >
376+ < div className = "relative" >
377+ < input
378+ className = "input-glass font-mono text-center text-[15px] tracking-[0.02em] pr-10"
379+ type = "tel"
380+ inputMode = "tel"
381+ placeholder = "+14155550123"
382+ autoComplete = "tel"
383+ spellCheck = { false }
384+ value = { userPhone }
385+ onChange = { ( e ) => {
386+ setUserPhone ( e . target . value ) ;
387+ if ( attempted ) setAttempted ( false ) ;
388+ } }
389+ disabled = { busy }
390+ aria-label = "Your phone number (E.164)"
391+ aria-invalid = { state === "invalid" || undefined }
392+ data-state = { state === "neutral" ? undefined : state }
393+ required
394+ />
395+ < span className = "pointer-events-none absolute right-3 top-1/2 -translate-y-1/2" >
396+ { state === "valid" ? (
397+ < Check size = { 16 } className = "text-[var(--color-success)]" />
398+ ) : state === "invalid" ? (
399+ < AlertCircle size = { 16 } className = "text-[var(--color-danger)]" />
400+ ) : null }
401+ </ span >
402+ </ div >
403+ < button
404+ type = "submit"
405+ className = "btn-pill-primary inline-flex w-full items-center justify-center"
406+ disabled = { busy || isEmpty }
407+ >
408+ { busy ? < Loader2 size = { 14 } className = "mr-1.5 animate-spin" /> : null }
409+ Continue
410+ { ! busy && < ArrowRight size = { 14 } className = "ml-1.5" /> }
411+ </ button >
412+ < p className = "mt-1 text-[12px] text-[var(--color-text-dim)]" >
413+ Include the country code. We never message you — Spectrum uses this to register your
414+ account.
415+ </ p >
416+ </ form >
417+ </ div >
418+ </ >
419+ ) ;
420+ }
421+
303422function KeyStage ( {
304423 apiKey,
305424 setApiKey,
0 commit comments