Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 1 addition & 67 deletions app/components/money-display.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { type VariantProps, cva } from 'class-variance-authority';
import type { Currency, CurrencyUnit } from '~/lib/money';
import { Money } from '~/lib/money';
import type { Money } from '~/lib/money';
import { cn } from '~/lib/utils';

const textVariants = cva('', {
Expand Down Expand Up @@ -54,72 +54,6 @@ type Variants = VariantProps<typeof textVariants> &
VariantProps<typeof symbolVariants> &
VariantProps<typeof valueVariants>;

interface MoneyInputDisplayProps<C extends Currency = Currency> {
/** Raw input value from user (e.g., "1", "1.", "1.0") */
inputValue: string;
currency: C;
unit: CurrencyUnit<C>;
locale?: string;
}

export function MoneyInputDisplay<C extends Currency>({
inputValue,
currency,
unit,
locale,
}: MoneyInputDisplayProps<C>) {
const money = new Money({ amount: inputValue, currency, unit });
const {
currencySymbol,
currencySymbolPosition,
integer,
numberOfDecimals,
decimalSeparator,
} = money.toLocalizedStringParts({
locale,
unit,
minimumFractionDigits: 'max',
});

// Get decimal part of the input value
const inputHasDecimalPoint = decimalSeparator
? inputValue.includes(decimalSeparator)
: false;
const inputDecimals = inputHasDecimalPoint
? inputValue.split(decimalSeparator)[1]
: '';

// If decimal part exists in the input value, pad with zeros to numberOfDecimals places
const needsPaddedZeros =
inputHasDecimalPoint && inputDecimals.length < numberOfDecimals;
const paddedZeros = needsPaddedZeros
? '0'.repeat(numberOfDecimals - inputDecimals.length)
: '';

const symbol = (
<span className={symbolVariants({ size: 'lg' })}>{currencySymbol}</span>
);

return (
<span className={textVariants({ size: 'lg' })}>
{currencySymbolPosition === 'prefix' && symbol}
<span className={valueVariants({ size: 'lg' })}>
{integer}
{(inputDecimals || needsPaddedZeros) && (
<>
<span>{decimalSeparator}</span>
<span>{inputDecimals}</span>
{paddedZeros && (
<span className="text-gray-400">{paddedZeros}</span>
)}
</>
)}
</span>
{currencySymbolPosition === 'suffix' && symbol}
</span>
);
}

type MoneyDisplayProps<C extends Currency = Currency> = {
money: Money<C>;
locale?: string;
Expand Down
138 changes: 31 additions & 107 deletions app/features/receive/receive-input.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,24 @@
import { getEncodedToken } from '@cashu/cashu-ts';
import { ArrowUpDown, Clipboard, Scan } from 'lucide-react';
import { MoneyDisplay, MoneyInputDisplay } from '~/components/money-display';
import { Numpad } from '~/components/numpad';
import { Clipboard, Scan } from 'lucide-react';
import {
ClosePageButton,
PageContent,
PageFooter,
PageHeader,
PageHeaderTitle,
} from '~/components/page';
import { Button } from '~/components/ui/button';
import { Skeleton } from '~/components/ui/skeleton';
import {
AccountSelector,
toAccountSelectorOption,
} from '~/features/accounts/account-selector';
import { accountOfflineToast } from '~/features/accounts/utils';
import { getDefaultUnit } from '~/features/shared/currencies';
import useAnimation from '~/hooks/use-animation';
import { useMoneyInput } from '~/hooks/use-money-input';
import {
MoneyInputLayout,
useMoneyInputField,
} from '~/features/shared/money-input-layout';
import { useRedirectTo } from '~/hooks/use-redirect-to';
import { useBuildLinkWithSearchParams } from '~/hooks/use-search-params-link';
import { useToast } from '~/hooks/use-toast';
import { extractCashuToken } from '~/lib/cashu';
import type { Money } from '~/lib/money';
import { readClipboard } from '~/lib/read-clipboard';
import {
LinkWithViewTransition,
Expand All @@ -32,44 +27,11 @@ import {
import { useAccount, useAccounts } from '../accounts/account-hooks';
import { useReceiveStore } from './receive-provider';

type ConvertedMoneySwitcherProps = {
onSwitchInputCurrency: () => void;
money?: Money;
};

const ConvertedMoneySwitcher = ({
onSwitchInputCurrency,
money,
}: ConvertedMoneySwitcherProps) => {
if (!money) {
return <Skeleton className="h-6 w-24" />;
}

return (
<button
type="button"
className="flex items-center gap-1"
onClick={onSwitchInputCurrency}
>
<MoneyDisplay
money={money}
unit={getDefaultUnit(money.currency)}
size="sm"
variant="muted"
/>
<ArrowUpDown className="mb-1 text-muted-foreground" />
</button>
);
};

export default function ReceiveInput() {
const navigate = useNavigateWithViewTransition();
const { toast } = useToast();
const { redirectTo } = useRedirectTo('/');
const buildLinkWithSearchParams = useBuildLinkWithSearchParams();
const { animationClass: shakeAnimationClass, start: startShakeAnimation } =
useAnimation({ name: 'shake' });

const receiveAccountId = useReceiveStore((s) => s.accountId);
const receiveAccount = useAccount(receiveAccountId);
const receiveAmount = useReceiveStore((s) => s.amount);
Expand All @@ -78,15 +40,7 @@ export default function ReceiveInput() {
const setReceiveAmount = useReceiveStore((s) => s.setAmount);
const { data: accounts } = useAccounts();

const {
rawInputValue,
maxInputDecimals,
inputValue,
convertedValue,
exchangeRateError,
handleNumberInput,
switchInputCurrency,
} = useMoneyInput({
const field = useMoneyInputField({
initialRawInputValue: receiveAmount?.toString(receiveCurrencyUnit) || '0',
initialInputCurrency: receiveAccount.currency,
initialOtherCurrency: receiveAccount.currency === 'BTC' ? 'USD' : 'BTC',
Expand All @@ -98,14 +52,14 @@ export default function ReceiveInput() {
return;
}

if (inputValue.currency === receiveAccount.currency) {
setReceiveAmount(inputValue);
if (field.inputValue.currency === receiveAccount.currency) {
setReceiveAmount(field.inputValue);
} else {
if (!convertedValue) {
if (!field.convertedValue) {
// Can't happen because when there is no converted value, the toggle will not be shown so input currency and receive currency must be the same
return;
}
setReceiveAmount(convertedValue);
setReceiveAmount(field.convertedValue);
}

const nextPath =
Expand Down Expand Up @@ -160,24 +114,24 @@ export default function ReceiveInput() {
<PageHeaderTitle>Receive</PageHeaderTitle>
</PageHeader>

<PageContent className="mx-auto flex flex-col items-center justify-between">
<div className="flex h-[124px] flex-col items-center gap-2">
<div className={shakeAnimationClass}>
<MoneyInputDisplay
inputValue={rawInputValue}
currency={inputValue.currency}
unit={getDefaultUnit(inputValue.currency)}
/>
</div>

{!exchangeRateError && (
<ConvertedMoneySwitcher
onSwitchInputCurrency={switchInputCurrency}
money={convertedValue}
/>
)}
</div>

<MoneyInputLayout
field={field}
onContinue={handleContinue}
actions={
<>
<button type="button" onClick={handlePaste}>
<Clipboard />
</button>
<LinkWithViewTransition
to={buildLinkWithSearchParams('/receive/scan')}
transition="slideUp"
applyTo="newView"
>
<Scan />
</LinkWithViewTransition>
</>
}
>
<div className="w-full max-w-sm sm:max-w-none">
<AccountSelector
accounts={accounts.map((account) =>
Expand All @@ -186,44 +140,14 @@ export default function ReceiveInput() {
selectedAccount={toAccountSelectorOption(receiveAccount)}
onSelect={(account) => {
setReceiveAccount(account);
if (account.currency !== inputValue.currency) {
switchInputCurrency();
if (account.currency !== field.inputValue.currency) {
field.switchInputCurrency();
}
}}
disabled={accounts.length === 1}
/>
</div>

<div className="flex w-full flex-col items-center gap-4 sm:items-start sm:justify-between">
<div className="grid w-full max-w-sm grid-cols-3 gap-4 sm:max-w-none">
<div className="flex items-center justify-start gap-4">
<button type="button" onClick={handlePaste}>
<Clipboard />
</button>

<LinkWithViewTransition
to={buildLinkWithSearchParams('/receive/scan')}
transition="slideUp"
applyTo="newView"
>
<Scan />
</LinkWithViewTransition>
</div>
<div /> {/* spacer */}
<Button onClick={handleContinue} disabled={inputValue.isZero()}>
Continue
</Button>
</div>
</div>
</PageContent>
<PageFooter className="sm:pb-14">
<Numpad
showDecimal={maxInputDecimals > 0}
onButtonClick={(value) => {
handleNumberInput(value, startShakeAnimation);
}}
/>
</PageFooter>
</MoneyInputLayout>
</>
);
}
Loading