diff --git a/src/components/pure-status-action-bar.tsx b/src/components/pure-status-action-bar.tsx index d3aa21401..d1f646f23 100644 --- a/src/components/pure-status-action-bar.tsx +++ b/src/components/pure-status-action-bar.tsx @@ -48,6 +48,7 @@ import DropdownMenu from 'soapbox/components/dropdown-menu/index.ts'; import PureStatusReactionWrapper from 'soapbox/components/pure-status-reaction-wrapper.tsx'; import StatusActionButton from 'soapbox/components/status-action-button.tsx'; import HStack from 'soapbox/components/ui/hstack.tsx'; +import { useZapCashuRequest } from 'soapbox/features/zap/hooks/useHooks.ts'; import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts'; import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts'; import { useDislike } from 'soapbox/hooks/useDislike.ts'; @@ -179,6 +180,9 @@ const PureStatusActionBar: React.FC = ({ const features = useFeatures(); const { boostModal, deleteModal } = useSettings(); + const { zapCashuList } = useZapCashuRequest(); + const isZappedCashu = zapCashuList.some((zapCashu)=> zapCashu === status.id); + const { account } = useOwnAccount(); const isStaff = account ? account.staff : false; const isAdmin = account ? account.admin : false; @@ -239,9 +243,9 @@ const PureStatusActionBar: React.FC = ({ const handleZapClick: React.EventHandler = (e) => { if (me) { - dispatch(openModal('ZAP_PAY_REQUEST', { status, account: status.account })); + dispatch(openModal('PAY_REQUEST', { status, account: status.account })); } else { - onOpenUnauthorizedModal('ZAP_PAY_REQUEST'); + onOpenUnauthorizedModal('PAY_REQUEST'); } }; @@ -759,6 +763,7 @@ const PureStatusActionBar: React.FC = ({ const canShare = ('share' in navigator) && (status.visibility === 'public' || status.visibility === 'group'); const acceptsZaps = status.account.ditto.accepts_zaps === true; + const acceptsZapsCashu = status.account.ditto.accepts_zaps_cashu === true; const spacing: { [key: string]: React.ComponentProps['space']; @@ -842,16 +847,16 @@ const PureStatusActionBar: React.FC = ({ /> )} - {(acceptsZaps) && ( + {(acceptsZaps || acceptsZapsCashu) && ( )} diff --git a/src/components/sidebar-menu.tsx b/src/components/sidebar-menu.tsx index 2401e1617..87adca801 100644 --- a/src/components/sidebar-menu.tsx +++ b/src/components/sidebar-menu.tsx @@ -13,6 +13,7 @@ import plusIcon from '@tabler/icons/outline/plus.svg'; import settingsIcon from '@tabler/icons/outline/settings.svg'; import userPlusIcon from '@tabler/icons/outline/user-plus.svg'; import userIcon from '@tabler/icons/outline/user.svg'; +import walletIcon from '@tabler/icons/outline/wallet.svg'; import xIcon from '@tabler/icons/outline/x.svg'; import clsx from 'clsx'; import { useCallback, useEffect, useRef, useState } from 'react'; @@ -213,6 +214,13 @@ const SidebarMenu: React.FC = (): JSX.Element | null => { onClick={onClose} /> + } + onClick={onClose} + /> + {(account.locked || followRequestsCount > 0) && ( { text={} /> + } + /> + = ({ const features = useFeatures(); const { boostModal, deleteModal } = useSettings(); + const { zapCashuList } = useZapCashuRequest(); + const isZappedCashu = zapCashuList.some((zapCashu)=> zapCashu === status.id); + const { account } = useOwnAccount(); const isStaff = account ? account.staff : false; const isAdmin = account ? account.admin : false; @@ -227,9 +231,9 @@ const StatusActionBar: React.FC = ({ const handleZapClick: React.EventHandler = (e) => { if (me) { - dispatch(openModal('ZAP_PAY_REQUEST', { status, account: status.account })); + dispatch(openModal('PAY_REQUEST', { status, account: status.account })); } else { - onOpenUnauthorizedModal('ZAP_PAY_REQUEST'); + onOpenUnauthorizedModal('PAY_REQUEST'); } }; @@ -749,6 +753,7 @@ const StatusActionBar: React.FC = ({ const canShare = ('share' in navigator) && (status.visibility === 'public' || status.visibility === 'group'); const acceptsZaps = status.account.ditto.accepts_zaps === true; + const acceptsZapsCashu = status.account.ditto.accepts_zaps_cashu === true; const spacing: { [key: string]: React.ComponentProps['space']; @@ -832,16 +837,16 @@ const StatusActionBar: React.FC = ({ /> )} - {(acceptsZaps) && ( + {(acceptsZaps || acceptsZapsCashu) && ( )} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index d401fcc2c..d8206f391 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -14,6 +14,8 @@ interface IButton { children?: React.ReactNode; /** Extra class names for the button. */ className?: string; + /** Extra class names for the icon. */ + iconClassName?: string; /** Prevent the button from being clicked. */ disabled?: boolean; /** Specifies the icon element as 'svg' or 'img'. */ @@ -49,6 +51,7 @@ const Button = forwardRef((props, ref): JSX.Element to, type = 'button', className, + iconClassName, } = props; const body = text || children; @@ -65,7 +68,7 @@ const Button = forwardRef((props, ref): JSX.Element return null; } - return ; + return ; }; const handleClick: React.MouseEventHandler = useCallback((event) => { diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx index cce1728a2..08663da25 100644 --- a/src/components/ui/tooltip.tsx +++ b/src/components/ui/tooltip.tsx @@ -78,7 +78,7 @@ const Tooltip: React.FC = (props) => { left: x ?? 0, ...styles, }} - className='pointer-events-none z-[100] whitespace-nowrap rounded bg-gray-800 px-2.5 py-1.5 text-xs font-medium text-gray-100 shadow dark:bg-gray-100 dark:text-gray-900' + className='pointer-events-none z-[100] max-w-[200px] whitespace-normal rounded bg-gray-800 px-2.5 py-1.5 text-xs font-medium text-gray-100 shadow dark:bg-gray-100 dark:text-gray-900 sm:max-w-[300px]' {...getFloatingProps()} > {text} diff --git a/src/features/account/components/header.tsx b/src/features/account/components/header.tsx index 2e9f8418d..d76cfecef 100644 --- a/src/features/account/components/header.tsx +++ b/src/features/account/components/header.tsx @@ -44,6 +44,7 @@ import VerificationBadge from 'soapbox/components/verification-badge.tsx'; import MovedNote from 'soapbox/features/account-timeline/components/moved-note.tsx'; import ActionButton from 'soapbox/features/ui/components/action-button.tsx'; import SubscriptionButton from 'soapbox/features/ui/components/subscription-button.tsx'; +import { usePaymentMethod } from 'soapbox/features/zap/usePaymentMethod.ts'; import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts'; import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts'; import { useFeatures } from 'soapbox/hooks/useFeatures.ts'; @@ -99,7 +100,7 @@ const messages = defineMessages({ profileExternal: { id: 'account.profile_external', defaultMessage: 'View profile on {domain}' }, header: { id: 'account.header.alt', defaultMessage: 'Profile header' }, subscribeFeed: { id: 'account.rss_feed', defaultMessage: 'Subscribe to RSS feed' }, - zap: { id: 'zap.send_to', defaultMessage: 'Send zaps to {target}' }, + method: { id: 'payment_method.send_to', defaultMessage: 'Send sats via {method} to {target}' }, }); interface IHeader { @@ -115,6 +116,8 @@ const Header: React.FC = ({ account }) => { const { account: ownAccount } = useOwnAccount(); const { follow } = useFollow(); + const { method: paymentMethod } = usePaymentMethod(); + const { software } = useAppSelector((state) => parseVersion(state.instance.version)); const { getOrCreateChatByAccountId } = useChats(); @@ -310,7 +313,7 @@ const Header: React.FC = ({ account }) => { }; const handleZapAccount: React.EventHandler = (e) => { - dispatch(openModal('ZAP_PAY_REQUEST', { account })); + dispatch(openModal('PAY_REQUEST', { account })); }; const makeMenu = () => { @@ -666,10 +669,10 @@ const Header: React.FC = ({ account }) => { ); }; @@ -677,6 +680,7 @@ const Header: React.FC = ({ account }) => { const info = makeInfo(); const menu = makeMenu(); const acceptsZaps = account.ditto.accepts_zaps === true; + const acceptsZapsCashu = account.ditto.accepts_zaps_cashu === true; return (
@@ -718,7 +722,7 @@ const Header: React.FC = ({ account }) => { {renderMessageButton()} {renderShareButton()} - {acceptsZaps && renderZapAccount()} + {(acceptsZaps || acceptsZapsCashu) && renderZapAccount()} {menu.length > 0 && ( diff --git a/src/features/status/components/status-interaction-bar.tsx b/src/features/status/components/status-interaction-bar.tsx index 62ee17cf7..8657d95e0 100644 --- a/src/features/status/components/status-interaction-bar.tsx +++ b/src/features/status/components/status-interaction-bar.tsx @@ -206,13 +206,13 @@ const StatusInteractionBar: React.FC = ({ status }): JSX. }; const getZaps = () => { - if (status.zaps_amount) { + if (status.zaps_amount || status.zaps_amount_cashu) { return ( - + ); diff --git a/src/features/ui/components/modal-root.tsx b/src/features/ui/components/modal-root.tsx index ba5894de8..ab761760a 100644 --- a/src/features/ui/components/modal-root.tsx +++ b/src/features/ui/components/modal-root.tsx @@ -43,7 +43,7 @@ import { UnauthorizedModal, VideoModal, EditRuleModal, - ZapPayRequestModal, + PayRequestModal, ZapSplitModal, ZapInvoiceModal, ZapsModal, @@ -89,6 +89,7 @@ const MODAL_COMPONENTS: Record> = { 'NOSTR_LOGIN': NostrLoginModal, 'NOSTR_SIGNUP': NostrSignupModal, 'ONBOARDING': OnboardingModal, + 'PAY_REQUEST': PayRequestModal, 'REACTIONS': ReactionsModal, 'REBLOGS': ReblogsModal, 'REPLY_MENTIONS': ReplyMentionsModal, @@ -98,7 +99,6 @@ const MODAL_COMPONENTS: Record> = { 'VIDEO': VideoModal, 'ZAPS': ZapsModal, 'ZAP_INVOICE': ZapInvoiceModal, - 'ZAP_PAY_REQUEST': ZapPayRequestModal, 'ZAP_SPLIT': ZapSplitModal, }; diff --git a/src/features/ui/components/modals/pay-request-modal.tsx b/src/features/ui/components/modals/pay-request-modal.tsx new file mode 100644 index 000000000..b6f89ad3c --- /dev/null +++ b/src/features/ui/components/modals/pay-request-modal.tsx @@ -0,0 +1,26 @@ +import React from 'react'; + +import Modal from 'soapbox/components/ui/modal.tsx'; +import PayRequestForm from 'soapbox/features/zap/components/pay-request-form.tsx'; + +import type { Status as StatusEntity, Account as AccountEntity } from 'soapbox/types/entities.ts'; + +interface IPayRequestModal { + account: AccountEntity; + status?: StatusEntity; + onClose:(type?: string) => void; +} + +const PayRequestModal: React.FC = ({ account, status, onClose }) => { + const onClickClose = () => { + onClose('PAY_REQUEST'); + }; + + return ( + + + + ); +}; + +export default PayRequestModal; diff --git a/src/features/ui/components/modals/zap-invoice.tsx b/src/features/ui/components/modals/zap-invoice.tsx index 3622c5b9c..13f50a942 100644 --- a/src/features/ui/components/modals/zap-invoice.tsx +++ b/src/features/ui/components/modals/zap-invoice.tsx @@ -45,7 +45,7 @@ const ZapInvoiceModal: React.FC = ({ account, invoice, splitData, o const { hasZapSplit, zapSplitAccounts, splitValues } = splitData; const onClickClose = () => { onClose('ZAP_INVOICE'); - dispatch(closeModal('ZAP_PAY_REQUEST')); + dispatch(closeModal('PAY_REQUEST')); }; const renderTitle = () => { diff --git a/src/features/ui/components/modals/zap-pay-request-modal.tsx b/src/features/ui/components/modals/zap-pay-request-modal.tsx deleted file mode 100644 index 6da1560ba..000000000 --- a/src/features/ui/components/modals/zap-pay-request-modal.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React from 'react'; - -import Modal from 'soapbox/components/ui/modal.tsx'; -import ZapPayRequestForm from 'soapbox/features/zap/components/zap-pay-request-form.tsx'; - -import type { Status as StatusEntity, Account as AccountEntity } from 'soapbox/types/entities.ts'; - -interface IZapPayRequestModal { - account: AccountEntity; - status?: StatusEntity; - onClose:(type?: string) => void; -} - -const ZapPayRequestModal: React.FC = ({ account, status, onClose }) => { - const onClickClose = () => { - onClose('ZAP_PAY_REQUEST'); - }; - - - return ( - - - - ); -}; - -export default ZapPayRequestModal; diff --git a/src/features/ui/components/modals/zaps-modal.tsx b/src/features/ui/components/modals/zaps-modal.tsx index e6bd1fbbf..083602452 100644 --- a/src/features/ui/components/modals/zaps-modal.tsx +++ b/src/features/ui/components/modals/zaps-modal.tsx @@ -8,6 +8,7 @@ import Modal from 'soapbox/components/ui/modal.tsx'; import Spinner from 'soapbox/components/ui/spinner.tsx'; import Text from 'soapbox/components/ui/text.tsx'; import AccountContainer from 'soapbox/containers/account-container.tsx'; +import { useWalletStore, useZappedByCashu } from 'soapbox/features/zap/hooks/useHooks.ts'; import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts'; import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts'; import { shortNumberFormat } from 'soapbox/utils/numbers.tsx'; @@ -16,6 +17,7 @@ interface IAccountWithZaps { id: string; comment: string; amount: number; + type: string; } interface IZapsModal { @@ -25,16 +27,23 @@ interface IZapsModal { const ZapsModal: React.FC = ({ onClose, statusId }) => { const dispatch = useAppDispatch(); + const zaps = useAppSelector((state) => state.user_lists.zapped_by.get(statusId)?.items); const next = useAppSelector((state) => state.user_lists.zapped_by.get(statusId)?.next); + const nutzaps = useWalletStore().nutzappedRecord[statusId]; + const { nextZaps } = useWalletStore(); + const { getNutzappedBy, expandNutzappedBy } = useZappedByCashu(); const accounts = useMemo((): ImmutableList | undefined => { - if (!zaps) return; + if (!zaps && !nutzaps) return; - return zaps - .map(({ account, amount, comment }) => ({ id: account, amount, comment })) - .flatten() as ImmutableList; - }, [zaps]); + const zappedAccounts = zaps?.map(({ account, amount, comment }) => ({ id: account, amount, comment, type: 'zap' })) || []; + const nutzappedAccounts = nutzaps?.map(({ account, amount, comment }) => ({ id: account.id, amount, comment, type: 'nutzap' })) || []; + + const combinedAccounts = [...zappedAccounts, ...nutzappedAccounts]; + + return ImmutableList(combinedAccounts); + }, [zaps, nutzaps]); const fetchData = () => { dispatch(fetchZaps(statusId)); @@ -42,6 +51,7 @@ const ZapsModal: React.FC = ({ onClose, statusId }) => { useEffect(() => { fetchData(); + getNutzappedBy(statusId); }, []); const onClickClose = () => { @@ -52,11 +62,14 @@ const ZapsModal: React.FC = ({ onClose, statusId }) => { if (next) { dispatch(expandZaps(statusId, next)); } + if (nextZaps) { + expandNutzappedBy(statusId); + } }; let body; - if (!zaps || !accounts) { + if (!zaps || !nutzaps || !accounts) { body = ; } else { const emptyMessage = ; @@ -70,13 +83,13 @@ const ZapsModal: React.FC = ({ onClose, statusId }) => { style={{ height: '80vh' }} useWindowScroll={false} onLoadMore={handleLoadMore} - hasMore={!!next} + hasMore={!!next || !!nextZaps} > {accounts.map((account, index) => { return (
- {shortNumberFormat(account.amount / 1000)} + {account.type === 'zap' ? shortNumberFormat(account.amount / 1000) : shortNumberFormat(account.amount)}
diff --git a/src/features/ui/components/pocket-wallet.tsx b/src/features/ui/components/pocket-wallet.tsx new file mode 100644 index 000000000..b7dd6485c --- /dev/null +++ b/src/features/ui/components/pocket-wallet.tsx @@ -0,0 +1,76 @@ +import arrowsDiagonalIcon from '@tabler/icons/outline/arrows-diagonal-2.svg'; +import arrowsDiagonalMinimizeIcon from '@tabler/icons/outline/arrows-diagonal-minimize.svg'; +import eyeClosedIcon from '@tabler/icons/outline/eye-closed.svg'; +import eyeIcon from '@tabler/icons/outline/eye.svg'; +import walletIcon from '@tabler/icons/outline/wallet.svg'; +import { useEffect, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import Button from 'soapbox/components/ui/button.tsx'; +import HStack from 'soapbox/components/ui/hstack.tsx'; +import Icon from 'soapbox/components/ui/icon.tsx'; +import Stack from 'soapbox/components/ui/stack.tsx'; +import Text from 'soapbox/components/ui/text.tsx'; +import Balance from 'soapbox/features/wallet/components/balance.tsx'; +import { useWallet } from 'soapbox/features/zap/hooks/useHooks.ts'; + +const messages = defineMessages({ + wallet: { id: 'wallet.title', defaultMessage: 'Wallet' }, + balance: { id: 'wallet.sats', defaultMessage: '{amount} sats' }, + withdraw: { id: 'wallet.button.withdraw', defaultMessage: 'Withdraw' }, + mint: { id: 'wallet.button.mint', defaultMessage: 'Mint' }, +}); + +const PocketWallet = () => { + const intl = useIntl(); + const { wallet } = useWallet(); + + const [expanded, setExpanded] = useState(false); + const [eyeClosed, setEyeClosed] = useState(() => { + const storedData = localStorage.getItem('soapbox:wallet:eye'); + return storedData ? JSON.parse(storedData) : false; + }); + + useEffect(() => { + localStorage.setItem('soapbox:wallet:eye', JSON.stringify(eyeClosed)); + }, [eyeClosed]); + + return ( + + + + + + {intl.formatMessage(messages.wallet)} + + + + + {!expanded && <> + { eyeClosed ? {intl.formatMessage({ id: 'wallet.hidden.balance', defaultMessage: '••••••' })} : + {intl.formatMessage(messages.balance, { amount: wallet?.balance })} + } + + + } + + + + + + + + {expanded && + + } + + + ); + +}; + +export default PocketWallet; \ No newline at end of file diff --git a/src/features/ui/index.tsx b/src/features/ui/index.tsx index aae4c5f33..f60504db0 100644 --- a/src/features/ui/index.tsx +++ b/src/features/ui/index.tsx @@ -72,6 +72,10 @@ import { Lists, Bookmarks, Settings, + Wallet, + WalletRelays, + WalletMints, + WalletTransactions, EditProfile, EditEmail, EditPassword, @@ -330,6 +334,10 @@ const SwitchingColumnsArea: React.FC = ({ children }) => + + + + diff --git a/src/features/ui/util/async-components.ts b/src/features/ui/util/async-components.ts index 65403ecdd..1db53d6a1 100644 --- a/src/features/ui/util/async-components.ts +++ b/src/features/ui/util/async-components.ts @@ -174,12 +174,17 @@ export const Relays = lazy(() => import('soapbox/features/admin/relays.tsx')); export const Rules = lazy(() => import('soapbox/features/admin/rules.tsx')); export const EditRuleModal = lazy(() => import('soapbox/features/ui/components/modals/edit-rule-modal.tsx')); export const AdminNostrRelays = lazy(() => import('soapbox/features/admin/nostr-relays.tsx')); -export const ZapPayRequestModal = lazy(() => import('soapbox/features/ui/components/modals/zap-pay-request-modal.tsx')); +export const PayRequestModal = lazy(() => import('soapbox/features/ui/components/modals/pay-request-modal.tsx')); export const ZapInvoiceModal = lazy(() => import('soapbox/features/ui/components/modals/zap-invoice.tsx')); export const ZapsModal = lazy(() => import('soapbox/features/ui/components/modals/zaps-modal.tsx')); export const ZapSplitModal = lazy(() => import('soapbox/features/ui/components/modals/zap-split/zap-split-modal.tsx')); export const CaptchaModal = lazy(() => import('soapbox/features/ui/components/modals/captcha-modal/captcha-modal.tsx')); export const NostrBunkerLogin = lazy(() => import('soapbox/features/nostr/nostr-bunker-login.tsx')); +export const Wallet = lazy(() => import('soapbox/features/wallet/index.tsx')); +export const WalletRelays = lazy(() => import('soapbox/features/wallet/components/wallet-relays.tsx')); +export const WalletMints = lazy(() => import('soapbox/features/wallet/components/wallet-mints.tsx')); +export const WalletTransactions = lazy(() => import('soapbox/features/wallet/components/wallet-transactions.tsx')); export const StreakModal = lazy(() => import('soapbox/features/ui/components/modals/streak-modal.tsx')); export const FollowsTimeline = lazy(() => import('soapbox/features/home-timeline/follows-timeline.tsx')); -export const CommunityTimeline = lazy(() => import('soapbox/features/home-timeline/community-timeline.tsx')); \ No newline at end of file +export const CommunityTimeline = lazy(() => import('soapbox/features/home-timeline/community-timeline.tsx')); +export const PocketWallet = lazy(() => import('soapbox/features/ui/components/pocket-wallet.tsx')); \ No newline at end of file diff --git a/src/features/wallet/components/balance.tsx b/src/features/wallet/components/balance.tsx new file mode 100644 index 000000000..45dcbbf10 --- /dev/null +++ b/src/features/wallet/components/balance.tsx @@ -0,0 +1,266 @@ +import cancelIcon from '@tabler/icons/outline/cancel.svg'; +import withdrawIcon from '@tabler/icons/outline/cash.svg'; +import mIcon from '@tabler/icons/outline/circle-dotted-letter-m.svg'; +import creditCardPayIcon from '@tabler/icons/outline/credit-card-pay.svg'; +import libraryPlusIcon from '@tabler/icons/outline/library-plus.svg'; +import QRCode from 'qrcode.react'; +import { useCallback, useEffect, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import CopyableInput from 'soapbox/components/copyable-input.tsx'; +import Button from 'soapbox/components/ui/button.tsx'; +import Divider from 'soapbox/components/ui/divider.tsx'; +import FormGroup from 'soapbox/components/ui/form-group.tsx'; +import Form from 'soapbox/components/ui/form.tsx'; +import HStack from 'soapbox/components/ui/hstack.tsx'; +import Input from 'soapbox/components/ui/input.tsx'; +import Stack from 'soapbox/components/ui/stack.tsx'; +import Text from 'soapbox/components/ui/text.tsx'; +import { SelectDropdown } from 'soapbox/features/forms/index.tsx'; +import { useTransactions, useWallet } from 'soapbox/features/zap/hooks/useHooks.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { useOwnAccount } from 'soapbox/hooks/useOwnAccount.ts'; +import { Quote, quoteSchema } from 'soapbox/schemas/wallet.ts'; +import toast from 'soapbox/toast.tsx'; + + + +const messages = defineMessages({ + amount: { id: 'wallet.balance.mint.amount', defaultMessage: 'Amount in sats' }, + cancel: { id: 'wallet.button.cancel', defaultMessage: 'Cancel' }, + balance: { id: 'wallet.sats', defaultMessage: '{amount} sats' }, + withdraw: { id: 'wallet.button.withdraw', defaultMessage: 'Withdraw' }, + mint: { id: 'wallet.button.mint', defaultMessage: 'Mint' }, + payment: { id: 'wallet.balance.mint.payment', defaultMessage: 'Make the payment to complete:' }, + paidMessage: { id: 'wallet.balance.mint.paid_message', defaultMessage: 'Your mint was successful, and your sats are now in your balance. Enjoy!' }, + unpaidMessage: { id: 'wallet.balance.mint.unpaid_message', defaultMessage: 'Your mint is still unpaid. Complete the payment to receive your sats.' }, + expired: { id: 'wallet.balance.expired', defaultMessage: 'Expired' }, +}); + +interface AmountProps { + amount: number; + onMintClick: () => void; +} + +interface NewMintProps { + list: string[]; + onBack: () => void; +} + +const openExtension = async (invoice: string) => { + try { + await window.webln?.enable(); + await window.webln?.sendPayment(invoice); + return undefined; + } catch (e) { + return invoice; + } +}; + +const Amount = ({ amount, onMintClick }: AmountProps) => { + const intl = useIntl(); + + return ( + + + {intl.formatMessage(messages.balance, { amount })} + + +
+ +
+ + + + + + + +
+ )} + + + ); +}; + +export default CreateWallet; \ No newline at end of file diff --git a/src/features/wallet/components/editable-lists.tsx b/src/features/wallet/components/editable-lists.tsx new file mode 100644 index 000000000..cd5bb15c3 --- /dev/null +++ b/src/features/wallet/components/editable-lists.tsx @@ -0,0 +1,61 @@ +import HStack from 'soapbox/components/ui/hstack.tsx'; +import Input from 'soapbox/components/ui/input.tsx'; +import Streamfield, { StreamfieldComponent } from 'soapbox/components/ui/streamfield.tsx'; + +interface IEditableList { + items: T[]; + setItems: (items: T[]) => void; +} + +const MintField: StreamfieldComponent = ({ value, onChange }) => { + return ( + + onChange(e.currentTarget.value)} + /> + + ); +}; + +const MintEditor: React.FC> = ({ items, setItems }) => { + const handleAdd = () => setItems([...items, '' ]); + const handleRemove = (index: number) => { + const newItems = [...items]; + newItems.splice(index, 1); + setItems(newItems); + }; + + return ; +}; + +const RelayField: StreamfieldComponent = ({ value, onChange }) => { + return ( + + onChange(e.currentTarget.value)} + /> + + ); +}; + +const RelayEditor: React.FC> = ({ items, setItems }) => { + const handleAdd = () => setItems([...items, '']); + const handleRemove = (index: number) => { + const newItems = [...items]; + newItems.splice(index, 1); + setItems(newItems); + }; + + return ; +}; + +export { RelayEditor, MintEditor }; +export type { IEditableList }; \ No newline at end of file diff --git a/src/features/wallet/components/transactions.tsx b/src/features/wallet/components/transactions.tsx new file mode 100644 index 000000000..a92554926 --- /dev/null +++ b/src/features/wallet/components/transactions.tsx @@ -0,0 +1,130 @@ +import arrowBarDownIcon from '@tabler/icons/outline/arrow-bar-down.svg'; +import arrowBarUpIcon from '@tabler/icons/outline/arrow-bar-up.svg'; +import questionIcon from '@tabler/icons/outline/question-mark.svg'; +import { FormattedDate, FormattedMessage } from 'react-intl'; + +import Divider from 'soapbox/components/ui/divider.tsx'; +import HStack from 'soapbox/components/ui/hstack.tsx'; +import Spinner from 'soapbox/components/ui/spinner.tsx'; +import Stack from 'soapbox/components/ui/stack.tsx'; +import SvgIcon from 'soapbox/components/ui/svg-icon.tsx'; +import Text from 'soapbox/components/ui/text.tsx'; +import { useTransactions } from 'soapbox/features/zap/hooks/useHooks.ts'; +import { useOwnAccount } from 'soapbox/hooks/useOwnAccount.ts'; + +const themes = { + default: '!text-gray-900 dark:!text-gray-100', + danger: '!text-danger-600', + primary: '!text-primary-600 dark:!text-accent-blue', + muted: '!text-gray-700 dark:!text-gray-600', + subtle: '!text-gray-400 dark:!text-gray-500', + success: '!text-success-600', + inherit: '!text-inherit', + white: '!text-white', + withdraw: '!text-orange-600 dark:!text-orange-300', +}; + +const groupByDate = (transactions: { amount: number; created_at: number; direction: 'in' | 'out' }[]) => { + return transactions.reduce((acc, transaction) => { + const dateKey = new Date(transaction.created_at * 1000).toDateString(); // Agrupa pelo dia + if (!acc[dateKey]) { + acc[dateKey] = []; + } + acc[dateKey].push(transaction); + return acc; + }, {} as Record); +}; + +const TransactionItem = ({ transaction, hasDivider = true }: { transaction: { amount: number; created_at: number; direction: 'in' | 'out' }; hasDivider?: boolean}) => { + let icon, type, messageColor; + const { direction, amount, created_at } = transaction; + + const formattedTime = ( + + ); + + switch (direction) { + case 'in': + icon = arrowBarDownIcon; + type = 'Received'; + messageColor = themes.success; + break; + case 'out': + icon = arrowBarUpIcon; + type = 'Sent'; + messageColor = themes.danger; + break; + default: + messageColor = 'default'; + type = 'Unknown'; + icon = questionIcon; + } + + return ( + + + +
+ +
+ + + {type} + +
+ + + + + {formattedTime} + + +
+ {hasDivider && } +
+ ); +}; + +interface ITransactions { + limit?: number; +} + +const Transactions = ({ limit }: ITransactions) => { + const { account } = useOwnAccount(); + const { transactions } = useTransactions(); + + if (!account) { + return null; + } + + if (!transactions) { + return ; + } + + if (transactions.length === 0) { + return ( + + ); + } + + const groupedTransactions = groupByDate(transactions.slice(0, limit)); + + return ( + + + {Object.entries(groupedTransactions).map(([date, transactions]) => ( + + + + + {transactions.map((transaction, index) => ( + + ))} + + ))} + + + ); +}; + +export default Transactions; \ No newline at end of file diff --git a/src/features/wallet/components/wallet-mints.tsx b/src/features/wallet/components/wallet-mints.tsx new file mode 100644 index 000000000..0ffabf0b2 --- /dev/null +++ b/src/features/wallet/components/wallet-mints.tsx @@ -0,0 +1,124 @@ +import { useEffect, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import Button from 'soapbox/components/ui/button.tsx'; +import { Column } from 'soapbox/components/ui/column.tsx'; +import Spinner from 'soapbox/components/ui/spinner.tsx'; +import Stack from 'soapbox/components/ui/stack.tsx'; +import Text from 'soapbox/components/ui/text.tsx'; +import { MintEditor } from 'soapbox/features/wallet/components/editable-lists.tsx'; +import { useWallet } from 'soapbox/features/zap/hooks/useHooks.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import toast from 'soapbox/toast.tsx'; +import { isURL } from 'soapbox/utils/auth.ts'; + +const messages = defineMessages({ + title: { id: 'wallet.mints', defaultMessage: 'Mints' }, + loadingError: { id: 'wallet.loading_error', defaultMessage: 'An unexpected error occurred while loading your wallet data.' }, + error: { id: 'wallet.mints.error', defaultMessage: 'Failed to update mints.' }, + empty: { id: 'wallet.mints.empty', defaultMessage: 'At least one mint is required.' }, + url: { id: 'wallet.invalid_url', defaultMessage: 'All strings must be valid URLs.' }, + success: { id: 'wallet.mints.success', defaultMessage: 'Mints updated with success!' }, + send: { id: 'common.send', defaultMessage: 'Send' }, +}); + +const WalletMints = () => { + const intl = useIntl(); + const api = useApi(); + const { wallet } = useWallet(); + + const [relays, setRelays] = useState([]); + const [initialMints, setInitialMints] = useState([]); + const [mints, setMints] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [hasError, setHasError] = useState(false); + + const handleClick = async () =>{ + if (mints.length === 0) { + toast.error(intl.formatMessage(messages.empty)); + return; + } + + if (mints.some((mint) => !isURL(mint))) { + toast.error(intl.formatMessage(messages.url)); + return; + } + + if (JSON.stringify(initialMints) === JSON.stringify(mints)) { + return; + } + try { + await api.put('/api/v1/ditto/cashu/wallet', { mints: mints, relays: relays }); + + setInitialMints(mints); + + toast.success(intl.formatMessage(messages.success)); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : intl.formatMessage(messages.error); + toast.error(errorMessage); + console.error(error); + } + }; + + useEffect( + () => { + setIsLoading(true); + setHasError(false); + + if (wallet) { + try { + setMints(wallet.mints ?? []); + setInitialMints(wallet.mints ?? []); + setRelays(wallet.relays ?? []); + } catch (error) { + console.error('Error setting wallet data:', error); + setHasError(true); + toast.error(intl.formatMessage(messages.loadingError)); + } finally { + setIsLoading(false); + } + } else { + // Handle the case when wallet is null or undefined + setIsLoading(false); + if (wallet === undefined) { // wallet is still loading + // Keep loading state true + setIsLoading(true); + } else if (wallet === null) { // wallet failed to load + setHasError(true); + toast.error(intl.formatMessage(messages.loadingError)); + } + } + }, [wallet, intl], + ); + + return ( + + {(() => { + if (isLoading) { + return ( + + + + ); + } else if (hasError) { + return ( + + {intl.formatMessage(messages.loadingError)} + + ); + } else { + return ( + + + + + ); + } + })()} + + ); +}; + +export default WalletMints; diff --git a/src/features/wallet/components/wallet-relays.tsx b/src/features/wallet/components/wallet-relays.tsx new file mode 100644 index 000000000..2b6b1e4a5 --- /dev/null +++ b/src/features/wallet/components/wallet-relays.tsx @@ -0,0 +1,121 @@ +import { useEffect, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import Button from 'soapbox/components/ui/button.tsx'; +import { Column } from 'soapbox/components/ui/column.tsx'; +import Spinner from 'soapbox/components/ui/spinner.tsx'; +import Stack from 'soapbox/components/ui/stack.tsx'; +import Text from 'soapbox/components/ui/text.tsx'; +import { RelayEditor } from 'soapbox/features/wallet/components/editable-lists.tsx'; +import { useWallet } from 'soapbox/features/zap/hooks/useHooks.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import toast from 'soapbox/toast.tsx'; +import { isURL } from 'soapbox/utils/auth.ts'; + +const messages = defineMessages({ + title: { id: 'wallet.relays', defaultMessage: 'Wallet Relays' }, + loadingError: { id: 'wallet.loading_error', defaultMessage: 'An unexpected error occurred while loading your wallet data.' }, + error: { id: 'wallet.relays.error', defaultMessage: 'Failed to update relays.' }, + empty: { id: 'wallet.relays.empty', defaultMessage: 'At least one relay is required.' }, + url: { id: 'wallet.invalid_url', defaultMessage: 'All strings must be valid URLs.' }, + success: { id: 'wallet.relays.success', defaultMessage: 'Relays updated with success!' }, + send: { id: 'common.send', defaultMessage: 'Send' }, +}); + +const WalletRelays = () => { + const intl = useIntl(); + const api = useApi(); + const { wallet } = useWallet(); + + const [relays, setRelays] = useState([]); + const [initialRelays, setInitialRelays] = useState([]); + const [mints, setMints] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [hasError, setHasError] = useState(false); + + const handleClick = async () =>{ + if (relays.length === 0) { + toast.error(intl.formatMessage(messages.empty)); + return; + } + + if (relays.some((relay) => !isURL(relay))) { + toast.error(intl.formatMessage(messages.url)); + return; + } + + if (JSON.stringify(initialRelays) === JSON.stringify(relays)) { + return; + } + + try { + await api.put('/api/v1/ditto/cashu/wallet', { mints: mints, relays: relays }); + setInitialRelays(relays); + toast.success(intl.formatMessage(messages.success)); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : intl.formatMessage(messages.error); + toast.error(errorMessage); + console.error(error); + } + }; + + useEffect( + () => { + setIsLoading(true); + setHasError(false); + + if (wallet) { + try { + setMints(wallet.mints ?? []); + setInitialRelays(wallet.relays ?? []); + setRelays(wallet.relays ?? []); + } catch (error) { + console.error('Error setting wallet data:', error); + setHasError(true); + toast.error(intl.formatMessage(messages.loadingError)); + } finally { + setIsLoading(false); + } + } else { + setIsLoading(false); + if (wallet === undefined) { + setIsLoading(true); + } else if (wallet === null) { + setHasError(true); + toast.error(intl.formatMessage(messages.loadingError)); + } + } + }, [wallet, intl], + ); + + return ( + + {(() => { + if (isLoading) { + return ( + + + + ); + } else if (hasError) { + return ( + + {intl.formatMessage(messages.loadingError)} + + ); + } else { + return ( + + + + + ); + } + })()} + + ); +}; + +export default WalletRelays; diff --git a/src/features/wallet/components/wallet-transactions.tsx b/src/features/wallet/components/wallet-transactions.tsx new file mode 100644 index 000000000..933e43ac5 --- /dev/null +++ b/src/features/wallet/components/wallet-transactions.tsx @@ -0,0 +1,57 @@ +import { useEffect, useRef, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import { Column } from 'soapbox/components/ui/column.tsx'; +import Spinner from 'soapbox/components/ui/spinner.tsx'; +import Transactions from 'soapbox/features/wallet/components/transactions.tsx'; +import { useTransactions } from 'soapbox/features/zap/hooks/useHooks.ts'; + +const messages = defineMessages({ + title: { id: 'wallet.transactions', defaultMessage: 'Transactions' }, + more: { id: 'wallet.transactions.show_more', defaultMessage: 'Show More' }, + loading: { id: 'wallet.loading', defaultMessage: 'Loading…' }, + noMoreTransactions: { id: 'wallet.transactions.no_more', defaultMessage: 'You reached the end of transactions' }, +}); + +const WalletTransactions = () => { + const intl = useIntl(); + const { isLoading, expandTransactions } = useTransactions(); + const observerRef = useRef(null); + const [showSpinner, setShowSpinner] = useState(false); + const [hasMoreTransactions, setHasMoreTransactions] = useState(true); + + useEffect(() => { + const observer = new IntersectionObserver( + async ([entry]) => { + if (entry.isIntersecting && !isLoading && hasMoreTransactions) { + setShowSpinner(true); + const hasMore = await expandTransactions(); + setHasMoreTransactions(hasMore); + } + }, + { rootMargin: '100px' }, + ); + + if (observerRef.current) { + observer.observe(observerRef.current); + } + + return () => observer.disconnect(); + }, [expandTransactions, isLoading, hasMoreTransactions]); + + return ( + + +
+ {showSpinner && isLoading && } + {!hasMoreTransactions && ( +
+ {intl.formatMessage(messages.noMoreTransactions)} +
+ )} +
+
+ ); +}; + +export default WalletTransactions; \ No newline at end of file diff --git a/src/features/wallet/index.tsx b/src/features/wallet/index.tsx new file mode 100644 index 000000000..60690095c --- /dev/null +++ b/src/features/wallet/index.tsx @@ -0,0 +1,196 @@ +import moreIcon from '@tabler/icons/outline/dots-circle-horizontal.svg'; +import { useEffect, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import List, { ListItem } from 'soapbox/components/list.tsx'; +import Button from 'soapbox/components/ui/button.tsx'; +import { Card, CardBody, CardHeader, CardTitle } from 'soapbox/components/ui/card.tsx'; +import { Column } from 'soapbox/components/ui/column.tsx'; +import Spinner from 'soapbox/components/ui/spinner.tsx'; +import Stack from 'soapbox/components/ui/stack.tsx'; +import Text from 'soapbox/components/ui/text.tsx'; +import { SelectDropdown } from 'soapbox/features/forms/index.tsx'; +import Balance from 'soapbox/features/wallet/components/balance.tsx'; +import CreateWallet from 'soapbox/features/wallet/components/create-wallet.tsx'; +import Transactions from 'soapbox/features/wallet/components/transactions.tsx'; +import { useTransactions, useWallet } from 'soapbox/features/zap/hooks/useHooks.ts'; +import { usePaymentMethod } from 'soapbox/features/zap/usePaymentMethod.ts'; +import { useOwnAccount } from 'soapbox/hooks/useOwnAccount.ts'; + +const messages = defineMessages({ + payment: { id: 'wallet.payment', defaultMessage: 'Payment Method' }, + relays: { id: 'wallet.relays', defaultMessage: 'Wallet Relays' }, + transactions: { id: 'wallet.transactions', defaultMessage: 'Transactions' }, + wallet: { id: 'wallet.title', defaultMessage: 'Wallet' }, + management: { id: 'wallet.management', defaultMessage: 'Wallet Management' }, + mints: { id: 'wallet.mints', defaultMessage: 'Mints' }, + more: { id: 'wallet.transactions.more', defaultMessage: 'More' }, + loading: { id: 'wallet.loading', defaultMessage: 'Loading…' }, + error: { id: 'wallet.loading_error', defaultMessage: 'An unexpected error occurred while loading your wallet data.' }, + retrying: { id: 'wallet.retrying', defaultMessage: 'Retrying in {seconds}s…' }, + retry: { id: 'wallet.retry', defaultMessage: 'Retry Now' }, +}); + +const paymentMethods = { + lightning: 'lightning', + cashu: 'cashu', +}; + +const RETRY_DELAY = 5000; + +/** User Wallet page. */ +const Wallet = () => { + const intl = useIntl(); + const [isRetrying, setIsRetrying] = useState(false); + const [retrySeconds, setRetrySeconds] = useState(RETRY_DELAY / 1000); + const [retryTimer, setRetryTimer] = useState(null); + + const { account } = useOwnAccount(); + const { wallet: walletData, isLoading, error, getWallet } = useWallet(); + const { method, changeMethod } = usePaymentMethod(); + const { transactions } = useTransactions(); + const hasTransactions = transactions && transactions.length > 0; + + // Function to handle manual retry + const handleRetry = () => { + // Clear any existing timer + if (retryTimer) { + clearInterval(retryTimer); + setRetryTimer(null); + } + + setIsRetrying(false); + getWallet(true); // Trigger wallet reload with error messages + }; + + // Setup automatic retry when there's an error + useEffect(() => { + if (error && !isLoading && !isRetrying) { + setIsRetrying(true); + setRetrySeconds(RETRY_DELAY / 1000); + + // Start countdown timer + const timer = setInterval(() => { + setRetrySeconds(prevSeconds => { + if (prevSeconds <= 1) { + clearInterval(timer); + handleRetry(); + return RETRY_DELAY / 1000; + } + return prevSeconds - 1; + }); + }, 1000); + + setRetryTimer(timer); + + // Cleanup timer on unmount + return () => { + clearInterval(timer); + }; + } + }, [error, isLoading, isRetrying]); + + // Cleanup timer on unmount + useEffect(() => { + return () => { + if (retryTimer) { + clearInterval(retryTimer); + } + }; + }, [retryTimer]); + + if (!account) return null; + + return ( + <> + {isLoading && ( + + + {intl.formatMessage(messages.loading)} + + )} + {error && !isLoading && ( + + {intl.formatMessage(messages.error)} + {error} + {isRetrying && ( + {intl.formatMessage(messages.retrying, { seconds: retrySeconds })} + )} + + + )} + {!isLoading && !error && ( + + + + + + + {walletData ? ( + <> + + + + + + + + + + + + + {hasTransactions &&
+ +
} +
+ + + + + + + + + + + ) => { + changeMethod((event.target.value as 'cashu' | 'lightning')); + }} + /> + + + + + ) : ( + <> + + + + + )} +
+
+ )} + + ); +}; + +export default Wallet; \ No newline at end of file diff --git a/src/features/zap/components/zap-pay-request-form.tsx b/src/features/zap/components/pay-request-form.tsx similarity index 50% rename from src/features/zap/components/zap-pay-request-form.tsx rename to src/features/zap/components/pay-request-form.tsx index 16e64c8d8..cddaa1626 100644 --- a/src/features/zap/components/zap-pay-request-form.tsx +++ b/src/features/zap/components/pay-request-form.tsx @@ -16,122 +16,173 @@ import pileCoin from 'soapbox/assets/icons/pile-coin.png'; import DisplayNameInline from 'soapbox/components/display-name-inline.tsx'; import Avatar from 'soapbox/components/ui/avatar.tsx'; import Button from 'soapbox/components/ui/button.tsx'; +import HStack from 'soapbox/components/ui/hstack.tsx'; import IconButton from 'soapbox/components/ui/icon-button.tsx'; import Input from 'soapbox/components/ui/input.tsx'; import Stack from 'soapbox/components/ui/stack.tsx'; import SvgIcon from 'soapbox/components/ui/svg-icon.tsx'; import Text from 'soapbox/components/ui/text.tsx'; +import { useZapCashuRequest } from 'soapbox/features/zap/hooks/useHooks.ts'; +import { usePaymentMethod } from 'soapbox/features/zap/usePaymentMethod.ts'; import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts'; import { emojifyText } from 'soapbox/utils/emojify.tsx'; -import ZapButton from './zap-button/zap-button.tsx'; +import PaymentButton from './zap-button/payment-button.tsx'; import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities.ts'; const ZAP_PRESETS = [ - { amount: 50, icon: coinIcon }, - { amount: 200, icon: coinStack }, - { amount: 1_000, icon: pileCoin }, - { amount: 3_000, icon: moneyBag }, - { amount: 5_000, icon: chestIcon }, + { buttonAmount: 50, icon: coinIcon }, + { buttonAmount: 200, icon: coinStack }, + { buttonAmount: 1_000, icon: pileCoin }, + { buttonAmount: 3_000, icon: moneyBag }, + { buttonAmount: 5_000, icon: chestIcon }, ]; -interface IZapPayRequestForm { +interface IPayRequestForm { status?: StatusEntity; account: AccountEntity; onClose?(): void; } -const closeIcon = xIcon; - const messages = defineMessages({ zap_button_rounded: { id: 'zap.button.text.rounded', defaultMessage: 'Zap {amount}K sats' }, - zap_button: { id: 'zap.button.text.raw', defaultMessage: 'Zap {amount} sats' }, - zap_commentPlaceholder: { id: 'zap.comment_input.placeholder', defaultMessage: 'Optional comment' }, + zap_button: { id: 'payment_method.button.text.raw', defaultMessage: 'Zap {amount} sats' }, + zap_commentPlaceholder: { id: 'payment_method.comment_input.placeholder', defaultMessage: 'Optional comment' }, }); -const ZapPayRequestForm = ({ account, status, onClose }: IZapPayRequestForm) => { +const PayRequestForm = ({ account, status, onClose }: IPayRequestForm) => { const intl = useIntl(); const dispatch = useAppDispatch(); const [zapComment, setZapComment] = useState(''); - const [zapAmount, setZapAmount] = useState(50); // amount in millisatoshi + const [amount, setAmount] = useState(50); const { zapArrays, zapSplitData, receiveAmount } = useZapSplit(status, account); const splitValues = zapSplitData.splitValues; - const hasZapSplit = zapArrays.length > 0; + const { method: paymentMethod, changeMethod } = usePaymentMethod(); + const isCashu = paymentMethod === 'cashu'; + const hasZapSplit = zapArrays.length > 0 && !isCashu; + const { zapCashuRequest } = useZapCashuRequest(); const handleSubmit = async (e?: React.FormEvent) => { e?.preventDefault(); const zapSplitAccounts = zapArrays.filter(zapData => zapData.account.id !== account.id); const splitData = { hasZapSplit, zapSplitAccounts, splitValues }; - const invoice = hasZapSplit ? await dispatch(zap(account, status, zapSplitData.receiveAmount * 1000, zapComment)) : await dispatch(zap(account, status, zapAmount * 1000, zapComment)); - // If invoice is undefined it means the user has paid through his extension - // In this case, we simply close the modal + if (isCashu) { + await zapCashuRequest(account, amount, zapComment, status); + dispatch(closeModal('PAY_REQUEST')); + return; + } + + const invoice = hasZapSplit + ? await dispatch(zap(account, status, zapSplitData.receiveAmount * 1000, zapComment)) + : await dispatch(zap(account, status, amount * 1000, zapComment)); if (!invoice) { - dispatch(closeModal('ZAP_PAY_REQUEST')); - // Dispatch the adm account + dispatch(closeModal('PAY_REQUEST')); + if (zapSplitAccounts.length > 0) { dispatch(openModal('ZAP_SPLIT', { zapSplitAccounts, splitValues })); } return; } - // open QR code modal - dispatch(closeModal('ZAP_PAY_REQUEST')); + + dispatch(closeModal('PAY_REQUEST')); dispatch(openModal('ZAP_INVOICE', { account, invoice, splitData })); }; const handleCustomAmount = (e: React.ChangeEvent) => { e?.preventDefault(); const inputAmount = e.target.value.replace(/[^0-9]/g, ''); - const maxSendable = 250000000; - // multiply by 1000 to convert from satoshi to millisatoshi - if (maxSendable * 1000 > Number(inputAmount)) { - setZapAmount(Number(inputAmount)); + + if (maxSendable * 1000 > Number(inputAmount) && !isCashu) { + setAmount(Number(inputAmount)); + } else if (maxSendable > Number(inputAmount)) { + setAmount(Number(inputAmount)); } }; - const renderZapButtonText = () => { - if (zapAmount >= 1000) { - return intl.formatMessage(messages.zap_button_rounded, { amount: Math.round((zapAmount / 1000) * 10) / 10 }); + const renderPaymentButtonText = () => { + if (amount >= 1000) { + return intl.formatMessage(messages.zap_button_rounded, { amount: Math.round((amount / 1000) * 10) / 10 }); } - return intl.formatMessage(messages.zap_button, { amount: zapAmount }); + return intl.formatMessage(messages.zap_button, { amount: amount }); }; useEffect(() => { - receiveAmount(zapAmount); - }, [zapAmount, zapArrays]); + receiveAmount(amount); + }, [amount, zapArrays]); return ( - - + + +
+ + +
+ + +
+ {zapArrays.length > 0 && isCashu && ( + + + + )}
- {ZAP_PRESETS.map(({ amount, icon }) => ( - setZapAmount(amount)} + {ZAP_PRESETS.map(({ buttonAmount, icon }) => ( + setAmount(buttonAmount)} className='m-0.5 sm:m-1' - selected={zapAmount === amount} + selected={buttonAmount === amount} icon={icon} - amount={amount} + amount={buttonAmount} /> ))}
@@ -139,11 +190,13 @@ const ZapPayRequestForm = ({ account, status, onClose }: IZapPayRequestForm) =>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */} - {hasZapSplit &&

sats

} + {hasZapSplit &&

+ {intl.formatMessage({ id: 'payment_method.unit', defaultMessage: 'sats' })} +

}
{hasZapSplit && ( @@ -164,12 +217,12 @@ const ZapPayRequestForm = ({ account, status, onClose }: IZapPayRequestForm) => {hasZapSplit ? -