Skip to content

Commit aa49957

Browse files
authored
refactor(withdraw): model flow state (#64)
Refs #45
1 parent e347910 commit aa49957

5 files changed

Lines changed: 646 additions & 514 deletions

File tree

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import type { WithdrawalQuote } from "@/core/modules/withdrawal/withdrawal-actions.ts"
2+
import type { SparkExitSpeed } from "@/core/spark/spark-wallet.ts"
3+
import type { TranslationKey } from "@/i18n/resources.ts"
4+
5+
export interface WithdrawResult {
6+
readonly txid: string | null
7+
readonly status: string
8+
}
9+
10+
export type WithdrawState =
11+
| { readonly step: "form" }
12+
| {
13+
readonly step: "review"
14+
readonly address: string
15+
readonly quote: WithdrawalQuote
16+
readonly exitSpeed: SparkExitSpeed
17+
readonly confirming: boolean
18+
readonly confirmError: TranslationKey | null
19+
}
20+
| { readonly step: "result"; readonly result: WithdrawResult }
21+
22+
export type WithdrawAction =
23+
| {
24+
readonly type: "OPEN_REVIEW"
25+
readonly address: string
26+
readonly quote: WithdrawalQuote
27+
}
28+
| { readonly type: "BACK" }
29+
| { readonly type: "SET_EXIT_SPEED"; readonly exitSpeed: SparkExitSpeed }
30+
| { readonly type: "CONFIRM_STARTED" }
31+
| { readonly type: "CONFIRM_FAILED"; readonly error: TranslationKey }
32+
| { readonly type: "CONFIRM_FINISHED" }
33+
| { readonly type: "SHOW_RESULT"; readonly result: WithdrawResult }
34+
35+
export const initialWithdrawState: WithdrawState = { step: "form" }
36+
37+
export const withdrawReducer = (
38+
state: WithdrawState,
39+
action: WithdrawAction
40+
): WithdrawState => {
41+
switch (action.type) {
42+
case "OPEN_REVIEW":
43+
return {
44+
step: "review",
45+
address: action.address,
46+
quote: action.quote,
47+
exitSpeed: "medium",
48+
confirming: false,
49+
confirmError: null,
50+
}
51+
case "BACK":
52+
return initialWithdrawState
53+
case "SET_EXIT_SPEED":
54+
return state.step === "review"
55+
? { ...state, exitSpeed: action.exitSpeed }
56+
: state
57+
case "CONFIRM_STARTED":
58+
return state.step === "review"
59+
? { ...state, confirming: true, confirmError: null }
60+
: state
61+
case "CONFIRM_FAILED":
62+
return state.step === "review"
63+
? { ...state, confirming: false, confirmError: action.error }
64+
: state
65+
case "CONFIRM_FINISHED":
66+
return state.step === "review" ? { ...state, confirming: false } : state
67+
case "SHOW_RESULT":
68+
return { step: "result", result: action.result }
69+
}
70+
}
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
import type { AbortError } from "@evolu/common"
2+
import assertNever from "assert-never"
3+
import {
4+
ClipboardPasteIcon,
5+
LoaderCircleIcon,
6+
ScanLineIcon,
7+
} from "lucide-react"
8+
import { type FormEvent, useState } from "react"
9+
import { toast } from "sonner"
10+
11+
import { Button } from "@/components/ui/button.tsx"
12+
import {
13+
Card,
14+
CardContent,
15+
CardDescription,
16+
CardFooter,
17+
CardHeader,
18+
CardTitle,
19+
} from "@/components/ui/card.tsx"
20+
import { Checkbox } from "@/components/ui/checkbox.tsx"
21+
import {
22+
Field,
23+
FieldContent,
24+
FieldDescription,
25+
FieldError,
26+
FieldGroup,
27+
FieldLabel,
28+
} from "@/components/ui/field.tsx"
29+
import { Input } from "@/components/ui/input.tsx"
30+
import type { AccountId } from "@/core/modules/account/account-types.ts"
31+
import { PositiveIntegerSchema } from "@/core/modules/shared/schema.ts"
32+
import {
33+
quoteWithdrawal,
34+
type WithdrawalQuote,
35+
} from "@/core/modules/withdrawal/withdrawal-actions.ts"
36+
import type { QuoteWithdrawalError } from "@/core/modules/withdrawal/withdrawal-types.ts"
37+
import { isValidBitcoinAddress } from "@/core/modules/withdrawal/withdrawal-utils.ts"
38+
import { useAppRun } from "@/hooks/use-app-run.ts"
39+
import { useLocale } from "@/hooks/use-locale.ts"
40+
import { useTranslation } from "@/hooks/use-translation.ts"
41+
import type { TranslationKey } from "@/i18n/resources.ts"
42+
import { WithdrawQrScanner } from "./withdraw-qr-scanner.tsx"
43+
import {
44+
formatSatsAmount,
45+
type ScannedBitcoinAddress,
46+
} from "./withdraw-utils.ts"
47+
48+
const quoteErrorKey = (
49+
error: QuoteWithdrawalError | AbortError
50+
): TranslationKey => {
51+
switch (error.type) {
52+
case "AbortError":
53+
return "withdraw.quoteError.generic"
54+
case "WithdrawalAccountNotFound":
55+
return "withdraw.error.accountNotFound"
56+
case "InvalidBitcoinAddress":
57+
return "withdraw.address.invalid"
58+
case "InsufficientWithdrawalBalance":
59+
return "withdraw.error.insufficientBalance"
60+
case "WithdrawalQuoteFailed":
61+
return "withdraw.quoteError.generic"
62+
}
63+
64+
return assertNever(error)
65+
}
66+
67+
export function WithdrawFormStep({
68+
accountId,
69+
availableSats,
70+
onReview,
71+
}: {
72+
readonly accountId: AccountId
73+
readonly availableSats: number | null
74+
readonly onReview: (address: string, quote: WithdrawalQuote) => void
75+
}) {
76+
const appRun = useAppRun()
77+
const { t } = useTranslation()
78+
const locale = useLocale()
79+
const [address, setAddress] = useState("")
80+
const [addressError, setAddressError] = useState<TranslationKey | null>(null)
81+
const [amountInput, setAmountInput] = useState("")
82+
const [withdrawAll, setWithdrawAll] = useState(false)
83+
const [scannerOpen, setScannerOpen] = useState(false)
84+
const [quotePending, setQuotePending] = useState(false)
85+
const [quoteError, setQuoteError] = useState<TranslationKey | null>(null)
86+
87+
const applyScannedAddress = (scanned: ScannedBitcoinAddress) => {
88+
setAddress(scanned.address)
89+
setAddressError(null)
90+
if (scanned.amountSats !== undefined) {
91+
setWithdrawAll(false)
92+
setAmountInput(String(scanned.amountSats))
93+
}
94+
setScannerOpen(false)
95+
}
96+
97+
const pasteAddress = async () => {
98+
try {
99+
const clipboardText = await navigator.clipboard.readText()
100+
setAddress(clipboardText.trim())
101+
setAddressError(null)
102+
} catch {
103+
toast.error(t("withdraw.address.pasteError"))
104+
}
105+
}
106+
107+
const submitForm = async (event: FormEvent<HTMLFormElement>) => {
108+
event.preventDefault()
109+
setAddressError(null)
110+
setQuoteError(null)
111+
112+
const trimmedAddress = address.trim()
113+
if (!isValidBitcoinAddress(trimmedAddress)) {
114+
setAddressError("withdraw.address.invalid")
115+
return
116+
}
117+
118+
const amountResult = withdrawAll
119+
? null
120+
: PositiveIntegerSchema.safeParse(Math.trunc(Number(amountInput)))
121+
if (!withdrawAll && (!amountInput || !amountResult?.success)) {
122+
setQuoteError("withdraw.amount.invalid")
123+
return
124+
}
125+
126+
setQuotePending(true)
127+
try {
128+
await using run = appRun()
129+
const quoteResult = await run(
130+
quoteWithdrawal({
131+
accountId,
132+
onchainAddress: trimmedAddress,
133+
amountSats: amountResult?.success ? amountResult.data : undefined,
134+
})
135+
)
136+
137+
if (!quoteResult.ok) {
138+
setQuoteError(quoteErrorKey(quoteResult.error))
139+
return
140+
}
141+
142+
onReview(trimmedAddress, quoteResult.value)
143+
} finally {
144+
setQuotePending(false)
145+
}
146+
}
147+
148+
return (
149+
<>
150+
<form onSubmit={(event) => void submitForm(event)}>
151+
<Card>
152+
<CardHeader>
153+
<CardTitle>{t("withdraw.form.title")}</CardTitle>
154+
<CardDescription>{t("withdraw.form.description")}</CardDescription>
155+
</CardHeader>
156+
<CardContent>
157+
<FieldGroup>
158+
<Field>
159+
<FieldLabel htmlFor="withdraw-address">
160+
{t("withdraw.address.label")}
161+
</FieldLabel>
162+
<div className="flex gap-2">
163+
<Input
164+
id="withdraw-address"
165+
value={address}
166+
placeholder={t("withdraw.address.placeholder")}
167+
aria-invalid={addressError !== null}
168+
onChange={(event) => {
169+
setAddress(event.target.value)
170+
setAddressError(null)
171+
}}
172+
/>
173+
<Button
174+
type="button"
175+
variant="outline"
176+
size="icon"
177+
aria-label={t("withdraw.address.paste")}
178+
onClick={() => void pasteAddress()}
179+
>
180+
<ClipboardPasteIcon />
181+
</Button>
182+
<Button
183+
type="button"
184+
variant="outline"
185+
size="icon"
186+
aria-label={t("withdraw.address.scan")}
187+
onClick={() => setScannerOpen(true)}
188+
>
189+
<ScanLineIcon />
190+
</Button>
191+
</div>
192+
<FieldError>{addressError ? t(addressError) : null}</FieldError>
193+
</Field>
194+
195+
<Field>
196+
<FieldLabel htmlFor="withdraw-amount">
197+
{t("withdraw.amount.label")}
198+
</FieldLabel>
199+
<Input
200+
id="withdraw-amount"
201+
type="number"
202+
inputMode="numeric"
203+
min={1}
204+
step={1}
205+
disabled={withdrawAll}
206+
value={amountInput}
207+
placeholder={t("withdraw.amount.placeholder")}
208+
onChange={(event) => setAmountInput(event.target.value)}
209+
/>
210+
{availableSats !== null ? (
211+
<FieldDescription>
212+
{t("withdraw.amount.available", {
213+
amount: formatSatsAmount(availableSats, locale),
214+
})}
215+
</FieldDescription>
216+
) : null}
217+
</Field>
218+
219+
<Field orientation="horizontal">
220+
<Checkbox
221+
id="withdraw-all"
222+
checked={withdrawAll}
223+
onCheckedChange={(checked) =>
224+
setWithdrawAll(checked === true)
225+
}
226+
/>
227+
<FieldContent>
228+
<FieldLabel htmlFor="withdraw-all">
229+
{t("withdraw.all.label")}
230+
</FieldLabel>
231+
<FieldDescription>
232+
{t("withdraw.all.description")}
233+
</FieldDescription>
234+
</FieldContent>
235+
</Field>
236+
237+
<FieldError>{quoteError ? t(quoteError) : null}</FieldError>
238+
</FieldGroup>
239+
</CardContent>
240+
<CardFooter>
241+
<Button type="submit" disabled={quotePending}>
242+
{quotePending ? (
243+
<LoaderCircleIcon className="animate-spin" />
244+
) : null}
245+
{quotePending
246+
? t("withdraw.quotePending")
247+
: t("withdraw.continue")}
248+
</Button>
249+
</CardFooter>
250+
</Card>
251+
</form>
252+
253+
{scannerOpen ? (
254+
<WithdrawQrScanner
255+
onScan={applyScannedAddress}
256+
onClose={() => setScannerOpen(false)}
257+
/>
258+
) : null}
259+
</>
260+
)
261+
}

0 commit comments

Comments
 (0)