diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..1295ee63 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "editor.formatOnSave": true +} diff --git a/src/components/IDKitWidget/BaseWidget.tsx b/src/components/IDKitWidget/BaseWidget.tsx index 936c1ca1..4a4e7399 100644 --- a/src/components/IDKitWidget/BaseWidget.tsx +++ b/src/components/IDKitWidget/BaseWidget.tsx @@ -1,5 +1,6 @@ import root from 'react-shadow' import { FC, useMemo } from 'react' +import { IDKITStage } from '@/types' import builtStyles from '@build/index.css' import ErrorState from './States/ErrorState' import SuccessState from './States/SuccessState' @@ -10,7 +11,7 @@ import EnterPhoneState from './States/EnterPhoneState' import VerifyCodeState from './States/VerifyCodeState' import { AnimatePresence, motion } from 'framer-motion' import QuestionMarkIcon from '../Icons/QuestionMarkIcon' -import useIDKitStore, { IDKITStage, IDKitStore } from '@/store/idkit' +import useIDKitStore, { IDKitStore } from '@/store/idkit' import { ArrowLongLeftIcon, XMarkIcon } from '@heroicons/react/20/solid' const getParams = ({ open, onOpenChange, stage, setStage }: IDKitStore) => ({ diff --git a/src/components/IDKitWidget/States/EnterPhoneState.tsx b/src/components/IDKitWidget/States/EnterPhoneState.tsx index ed746889..1935d099 100644 --- a/src/components/IDKitWidget/States/EnterPhoneState.tsx +++ b/src/components/IDKitWidget/States/EnterPhoneState.tsx @@ -1,27 +1,48 @@ +import { IDKITStage } from '@/types' import { motion } from 'framer-motion' import PhoneInput from '@/components/PhoneInput' import WorldIDIcon from '@/components/WorldIDIcon' -import useIDKitStore, { IDKITStage, IDKitStore } from '@/store/idkit' +import useIDKitStore, { IDKitStore } from '@/store/idkit' +import { requestCode, isRequestCodeError } from '@/services/phone' -const getParams = ({ phoneNumber, setStage }: IDKitStore) => ({ +const getParams = ({ processing, phoneNumber, actionId, setStage, setProcessing }: IDKitStore) => ({ + processing, phoneNumber, + actionId, useWorldID: () => setStage(IDKITStage.WORLD_ID), - onSubmit: () => setStage(IDKITStage.ENTER_CODE), + onSubmit: async () => { + try { + setProcessing(true) + // FIXME: ph_distinct_id + await requestCode(phoneNumber, actionId, '') + setProcessing(false) + setStage(IDKITStage.ENTER_CODE) + } catch (error) { + setProcessing(false) + if (isRequestCodeError(error) && error.code !== 'server_error') { + // FIXME: Error toast here + console.error(error) + } else { + setStage(IDKITStage.ERROR) + } + } + }, }) const EnterPhoneState = () => { - const { phoneNumber, useWorldID, onSubmit } = useIDKitStore(getParams) + const { phoneNumber, processing, useWorldID, onSubmit } = useIDKitStore(getParams) return (

+ {/* TODO: Caption should be a config option */} Verify your phone number for free gasless transactions.

We'll take care of the rest!

- +

We'll call or text to confirm your number. No data is stored.

@@ -46,11 +67,12 @@ const EnterPhoneState = () => { transition={{ layout: { duration: 0.15 } }} onClick={onSubmit} layoutId="submit-button" - disabled={!phoneNumber} + disabled={!phoneNumber || processing} className="inline-flex items-center px-8 py-3 border border-transparent font-medium rounded-full shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-indigo-600" > + {/* TODO: Nicer loading state */} - Continue + {processing ? 'Loading ...' : 'Continue'}
diff --git a/src/components/IDKitWidget/States/SuccessState.tsx b/src/components/IDKitWidget/States/SuccessState.tsx index 2792964c..046452c8 100644 --- a/src/components/IDKitWidget/States/SuccessState.tsx +++ b/src/components/IDKitWidget/States/SuccessState.tsx @@ -13,7 +13,8 @@ const SuccessState = () => {

Success! 🎉

- Your phone number is verified and access to glassless transactions is enabled. + {/* TODO: This caption should be a config option */} + Your phone number is verified and access to gasless transactions is enabled.

diff --git a/src/components/IDKitWidget/States/VerifyCodeState.tsx b/src/components/IDKitWidget/States/VerifyCodeState.tsx index 1f0bc51c..250546c9 100644 --- a/src/components/IDKitWidget/States/VerifyCodeState.tsx +++ b/src/components/IDKitWidget/States/VerifyCodeState.tsx @@ -1,30 +1,54 @@ import { useRef } from 'react' +import { IDKITStage } from '@/types' import { motion } from 'framer-motion' import WorldIDIcon from '@/components/WorldIDIcon' import SMSCodeInput from '@/components/SMSCodeInput' import ResendButton from '@/components/ResendButton' -import useIDKitStore, { IDKITStage, IDKitStore } from '@/store/idkit' +import useIDKitStore, { IDKitStore } from '@/store/idkit' +import { verifyCode, isVerifyCodeError } from '@/services/phone' -const getParams = ({ code, setStage }: IDKitStore) => ({ +const getParams = ({ processing, phoneNumber, code, actionId, setStage, setProcessing, setCode }: IDKitStore) => ({ + processing, + phoneNumber, code, - onSubmit: () => setStage(IDKITStage.SUCCESS), + actionId, + setCode, + onSubmit: async () => { + try { + setProcessing(true) + // FIXME: Add ph_distinct_id + await verifyCode(phoneNumber, code, actionId, '') + setProcessing(false) + setStage(IDKITStage.SUCCESS) + } catch (error) { + setProcessing(false) + setCode('') + if (isVerifyCodeError(error)) { + // FIXME: show error toast here + console.error(error) + } else { + setStage(IDKITStage.ERROR) + } + } + }, useWorldID: () => setStage(IDKITStage.WORLD_ID), }) const VerifyCodeState = () => { const submitRef = useRef(null) - const { code, onSubmit, useWorldID } = useIDKitStore(getParams) + const { processing, code, onSubmit, useWorldID } = useIDKitStore(getParams) return (

+ {/* TODO: Allow app to set this caption from settings */} Verify your phone number for free gasless transactions.

We'll take care of the rest!

- +

Did not receive a code? or{' '}

diff --git a/src/components/PhoneInput.tsx b/src/components/PhoneInput.tsx index 5c832882..3f39679e 100644 --- a/src/components/PhoneInput.tsx +++ b/src/components/PhoneInput.tsx @@ -1,11 +1,11 @@ import { phone } from 'phone' -import { useEffect, useState } from 'react' +import { memo, useEffect, useState } from 'react' import CountryCodeSelect from './CountryCodeSelect' import useIDKitStore, { IDKitStore } from '@/store/idkit' const getParams = ({ setPhoneNumber }: IDKitStore) => ({ setFullPhone: setPhoneNumber }) -const PhoneInput = () => { +const PhoneInput = ({ disabled, onSubmit }: { disabled?: boolean; onSubmit?: () => Promise | void }) => { const { setFullPhone } = useIDKitStore(getParams) const [countryCode, setCountryCode] = useState('1') const [phoneNumber, setPhoneNumber] = useState('') @@ -40,10 +40,12 @@ const PhoneInput = () => { placeholder="Phone number" onChange={e => setPhoneNumber(e.target.value)} className="block w-full rounded-md border-transparent bg-transparent pl-24 focus:ring-transparent focus:border-transparent sm:text-sm" + disabled={disabled} + onKeyDown={e => e.key === 'Enter' && onSubmit?.()} /> ) } -export default PhoneInput +export default memo(PhoneInput) diff --git a/src/components/SMSCodeInput.tsx b/src/components/SMSCodeInput.tsx index 89605985..08dd7f61 100644 --- a/src/components/SMSCodeInput.tsx +++ b/src/components/SMSCodeInput.tsx @@ -11,7 +11,7 @@ const fillValues = (value: string): Array6 => { const getParams = ({ setCode }: IDKitStore) => ({ setCode }) -const SMSCodeInput = ({ submitRef }: { submitRef: RefObject }) => { +const SMSCodeInput = ({ submitRef, disabled }: { submitRef: RefObject; disabled?: boolean }) => { const { setCode } = useIDKitStore(getParams) const inputsRefs = useMemo(() => new Array(6).fill(null).map(() => createRef()), []) @@ -149,11 +149,13 @@ const SMSCodeInput = ({ submitRef }: { submitRef: RefObject } value={values[i]} inputMode="numeric" autoComplete="one-time-code" + autoFocus={i === 0} onFocus={() => onInputFocus(i)} onPaste={event => onInputPaste(event, i)} onChange={event => onInputChange(event, i)} onKeyDown={event => onInputKeyDown(event, i)} className="w-12 h-14 border-0 bg-gray-100 rounded-xl text-center" + disabled={disabled} /> ))} diff --git a/src/services/phone.ts b/src/services/phone.ts new file mode 100644 index 00000000..64ac69e6 --- /dev/null +++ b/src/services/phone.ts @@ -0,0 +1,67 @@ +import { IPhoneSignal } from '@/types' + +const API_BASE_URL = 'https://developer.worldcoin.org/api/v1' + +export async function requestCode(phone_number: string, action_id: string, ph_distinct_id: string) { + const res = await fetch(`${API_BASE_URL}/phone/request`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + phone_number, + action_id, + channel: 'sms', // FIXME + ph_distinct_id, + }), + }) + if (res.ok) { + return + } + throw await res.json() +} + +export async function verifyCode(phone_number: string, code: string, action_id: string, ph_distinct_id: string) { + const res = await fetch(`${API_BASE_URL}/phone/verify`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + phone_number, + code, + action_id, + ph_distinct_id, + }), + }) + if (res.ok) { + return (await res.json()) as IPhoneSignal + } + throw await res.json() +} + +export interface RequestCodeError { + code: 'timeout' | 'max_attempts' | 'server_error' + details: string +} + +export function isRequestCodeError(error: unknown): error is RequestCodeError { + return ( + typeof error === 'object' && + error !== null && + Object.prototype.hasOwnProperty.call(error as Record, 'code') + ) +} + +export interface VerifyCodeError { + code: 'invalid_code' + details: string +} + +export function isVerifyCodeError(error: unknown): error is VerifyCodeError { + return ( + typeof error === 'object' && + error !== null && + Object.prototype.hasOwnProperty.call(error as Record, 'code') + ) +} diff --git a/src/store/idkit.ts b/src/store/idkit.ts index 27700ec5..efa793e8 100644 --- a/src/store/idkit.ts +++ b/src/store/idkit.ts @@ -1,26 +1,21 @@ import create from 'zustand' - -export enum IDKITStage { - ENTER_PHONE = 'ENTER_PHONE', - ENTER_CODE = 'ENTER_CODE', - WORLD_ID = 'WORLD_ID', - SUCCESS = 'SUCCESS', - ERROR = 'ERROR', -} +import { IDKITStage } from '@/types' export type IDKitStore = { open: boolean + phoneNumber: string code: string actionId: string stage: IDKITStage - phoneNumber: string + processing: boolean // Whether an async request is being processed and we show a loading state in the UI retryFlow: () => void - setCode: (code: string) => void setOpen: (open: boolean) => void onOpenChange: (open: boolean) => void setStage: (stage: IDKITStage) => void setActionId: (actionId: string) => void setPhoneNumber: (phoneNumber: string) => void + setCode: (code: string) => void + setProcessing: (processing: boolean) => void } const useIDKitStore = create()(set => ({ @@ -29,16 +24,17 @@ const useIDKitStore = create()(set => ({ actionId: '', phoneNumber: '', stage: IDKITStage.ENTER_PHONE, + processing: false, setOpen: open => set({ open }), + setPhoneNumber: phoneNumber => set({ phoneNumber }), setCode: code => set({ code }), + setActionId: actionId => set({ actionId }), setStage: stage => set({ stage }), - setActionId: (actionId: string) => set({ actionId }), - setPhoneNumber: (phoneNumber: string) => set({ phoneNumber }), retryFlow: () => set({ stage: IDKITStage.ENTER_PHONE, phoneNumber: '' }), + setProcessing: (processing: boolean) => set({ processing }), onOpenChange: open => { if (open) return set({ open }) - - set({ open, phoneNumber: '', stage: IDKITStage.ENTER_PHONE }) + set({ open, phoneNumber: '', code: '', processing: false, stage: IDKITStage.ENTER_PHONE }) }, })) diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 00000000..21c972af --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,13 @@ +export enum IDKITStage { + ENTER_PHONE = 'ENTER_PHONE', + ENTER_CODE = 'ENTER_CODE', + WORLD_ID = 'WORLD_ID', + SUCCESS = 'SUCCESS', + ERROR = 'ERROR', +} + +export interface IPhoneSignal { + success: true + nullifier_hash: string + signature: string +}