diff --git a/src/api/hooks/zap-split/useZapSplit.ts b/src/api/hooks/zap-split/useZapSplit.ts new file mode 100644 index 000000000..0c974012b --- /dev/null +++ b/src/api/hooks/zap-split/useZapSplit.ts @@ -0,0 +1,90 @@ +import { useState, useEffect } from 'react'; + +import { useApi } from 'soapbox/hooks'; +import { type ZapSplitData } from 'soapbox/schemas/zap-split'; + +import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities'; + +interface SplitValue { + id: string; + amountSplit: number; +} + +/** +* Custom hook to handle the logic for zap split calculations. +* +* This hook fetches zap split data from the server and calculates the amount to be received +* by the main account and the split amounts for other associated accounts. +* +* @param {StatusEntity | undefined} status - The current status entity. +* @param {AccountEntity} account - The account for which the zap split calculation is done. +* +* @returns {Object} An object containing the zap split arrays, zap split data, and a function to calculate the received amount. +* +* @property {ZapSplitData[]} zapArrays - Array of zap split data returned from the API. +* @property {Object} zapSplitData - Contains the total split amount, amount to receive, and individual split values. +* @property {Function} receiveAmount - A function to calculate the zap amount based on the split configuration. +*/ +const useZapSplit = (status: StatusEntity | undefined, account: AccountEntity) => { + const api = useApi(); + const [zapArrays, setZapArrays] = useState([]); + const [zapSplitData, setZapSplitData] = useState<{splitAmount: number; receiveAmount: number; splitValues: SplitValue[]}>({ splitAmount: Number(), receiveAmount: Number(), splitValues: [] }); + + const fetchZapSplit = async (id: string) => { + return await api.get(`/api/v1/ditto/${id}/zap_splits`); + }; + + const loadZapSplitData = async () => { + if (status) { + const data = (await fetchZapSplit(status.id)).data; + setZapArrays(data); + } + }; + + /** + * Calculates and updates the zap amount that the main account will receive + * and the split amounts for other accounts. + * + * @param {number} zapAmount - The total amount of zaps to be split. + */ + const receiveAmount = (zapAmount: number) => { + if (zapArrays.length > 0) { + const zapAmountPrincipal = zapArrays.find((zapSplit: ZapSplitData) => zapSplit.account.id === account.id); + const zapAmountOthers = zapArrays.filter((zapSplit: ZapSplitData) => zapSplit.account.id !== account.id); + + const totalWeightSplit = zapAmountOthers.reduce((e: number, b: ZapSplitData) => e + b.weight, 0); + const totalWeight = zapArrays.reduce((e: number, b: ZapSplitData) => e + b.weight, 0); + + if (zapAmountPrincipal) { + const receiveZapAmount = Math.floor(zapAmountPrincipal.weight * (zapAmount / totalWeight)); + const splitResult = zapAmount - receiveZapAmount; + + let totalRoundedSplit = 0; + const values = zapAmountOthers.map((zapData) => { + const result = Math.floor(zapData.weight * (splitResult / totalWeightSplit)); + totalRoundedSplit += result; + return { id: zapData.account.id, amountSplit: result }; + }); + + const difference = splitResult - totalRoundedSplit; + + if (difference !== 0 && values.length > 0) { + values[values.length - 1].amountSplit += difference; + } + + if (zapSplitData.receiveAmount !== receiveZapAmount || zapSplitData.splitAmount !== splitResult) { + setZapSplitData({ splitAmount: splitResult, receiveAmount: receiveZapAmount, splitValues: values }); + } + } + } + }; + + useEffect(() => { + loadZapSplitData(); + }, [status]); + + return { zapArrays, zapSplitData, receiveAmount }; +}; + +export default useZapSplit; +export { SplitValue }; \ No newline at end of file diff --git a/src/features/ui/components/modal-root.tsx b/src/features/ui/components/modal-root.tsx index 4b8827837..d2a843121 100644 --- a/src/features/ui/components/modal-root.tsx +++ b/src/features/ui/components/modal-root.tsx @@ -44,6 +44,7 @@ import { VideoModal, EditRuleModal, ZapPayRequestModal, + ZapSplitModal, ZapInvoiceModal, ZapsModal, } from 'soapbox/features/ui/util/async-components'; @@ -96,6 +97,7 @@ const MODAL_COMPONENTS: Record> = { 'ZAPS': ZapsModal, 'ZAP_INVOICE': ZapInvoiceModal, 'ZAP_PAY_REQUEST': ZapPayRequestModal, + 'ZAP_SPLIT': ZapSplitModal, }; export type ModalType = keyof typeof MODAL_COMPONENTS | null; diff --git a/src/features/ui/components/modals/onboarding-flow-modal/steps/avatar-step.tsx b/src/features/ui/components/modals/onboarding-flow-modal/steps/avatar-step.tsx index c61836ff5..105522b2b 100644 --- a/src/features/ui/components/modals/onboarding-flow-modal/steps/avatar-step.tsx +++ b/src/features/ui/components/modals/onboarding-flow-modal/steps/avatar-step.tsx @@ -75,7 +75,7 @@ const AvatarSelectionModal: React.FC = ({ onClose, onNext
- + diff --git a/src/features/ui/components/modals/zap-invoice.tsx b/src/features/ui/components/modals/zap-invoice.tsx index e416f3eae..7d215c2b9 100644 --- a/src/features/ui/components/modals/zap-invoice.tsx +++ b/src/features/ui/components/modals/zap-invoice.tsx @@ -2,11 +2,13 @@ import { QRCodeCanvas } from 'qrcode.react'; import React from 'react'; import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; -import { closeModal } from 'soapbox/actions/modals'; +import { closeModal, openModal } from 'soapbox/actions/modals'; +import { SplitValue } from 'soapbox/api/hooks/zap-split/useZapSplit'; import CopyableInput from 'soapbox/components/copyable-input'; -import { Modal, Button, Stack, Avatar } from 'soapbox/components/ui'; +import { Modal, Button, Stack, Avatar, HStack } from 'soapbox/components/ui'; import IconButton from 'soapbox/components/ui/icon-button/icon-button'; import { useAppDispatch } from 'soapbox/hooks'; +import { ZapSplitData } from 'soapbox/schemas/zap-split'; import type { Account as AccountEntity } from 'soapbox/types/entities'; @@ -14,18 +16,26 @@ const closeIcon = require('@tabler/icons/outline/x.svg'); const messages = defineMessages({ zap_open_wallet: { id: 'zap.open_wallet', defaultMessage: 'Open Wallet' }, + zap_next: { id: 'zap.next', defaultMessage: 'Next' }, }); +interface ISplitData { + hasZapSplit: boolean; + zapSplitAccounts: ZapSplitData[]; + splitValues: SplitValue[]; +} + interface IZapInvoice{ account: AccountEntity; invoice: string; + splitData: ISplitData; onClose:(type?: string) => void; } -const ZapInvoiceModal: React.FC = ({ account, invoice, onClose }) => { +const ZapInvoiceModal: React.FC = ({ account, invoice, splitData, onClose }) => { const intl = useIntl(); const dispatch = useAppDispatch(); - + const { hasZapSplit, zapSplitAccounts, splitValues } = splitData; const onClickClose = () => { onClose('ZAP_INVOICE'); dispatch(closeModal('ZAP_PAY_REQUEST')); @@ -35,6 +45,10 @@ const ZapInvoiceModal: React.FC = ({ account, invoice, onClose }) = return ; }; + const handleNext = () => { + onClose('ZAP_INVOICE'); + dispatch(openModal('ZAP_SPLIT', { zapSplitAccounts, splitValues })); + }; return ( @@ -48,9 +62,12 @@ const ZapInvoiceModal: React.FC = ({ account, invoice, onClose }) =
- -
} + + + ); +}; + +export default ZapSplit; + diff --git a/src/features/ui/util/async-components.ts b/src/features/ui/util/async-components.ts index 6c33e0dc3..49abf96c8 100644 --- a/src/features/ui/util/async-components.ts +++ b/src/features/ui/util/async-components.ts @@ -179,3 +179,4 @@ export const AdminNostrRelays = lazy(() => import('soapbox/features/admin/nostr- export const ZapPayRequestModal = lazy(() => import('soapbox/features/ui/components/modals/zap-pay-request-modal')); export const ZapInvoiceModal = lazy(() => import('soapbox/features/ui/components/modals/zap-invoice')); export const ZapsModal = lazy(() => import('soapbox/features/ui/components/modals/zaps-modal')); +export const ZapSplitModal = lazy(() => import('soapbox/features/ui/components/modals/zap-split/zap-split-modal')); diff --git a/src/features/zap/components/zap-pay-request-form.tsx b/src/features/zap/components/zap-pay-request-form.tsx index 095b4e00b..02b60393e 100644 --- a/src/features/zap/components/zap-pay-request-form.tsx +++ b/src/features/zap/components/zap-pay-request-form.tsx @@ -1,9 +1,10 @@ -import closeIcon from '@tabler/icons/outline/x.svg'; -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; +import { Link } from 'react-router-dom'; import { zap } from 'soapbox/actions/interactions'; import { openModal, closeModal } from 'soapbox/actions/modals'; +import useZapSplit from 'soapbox/api/hooks/zap-split/useZapSplit'; import chestIcon from 'soapbox/assets/icons/chest.png'; import coinStack from 'soapbox/assets/icons/coin-stack.png'; import coinIcon from 'soapbox/assets/icons/coin.png'; @@ -13,7 +14,6 @@ import DisplayNameInline from 'soapbox/components/display-name-inline'; import { Stack, Button, Input, Avatar, Text } from 'soapbox/components/ui'; import IconButton from 'soapbox/components/ui/icon-button/icon-button'; import { useAppDispatch } from 'soapbox/hooks'; -import { shortNumberFormat } from 'soapbox/utils/numbers'; import ZapButton from './zap-button/zap-button'; @@ -33,29 +33,44 @@ interface IZapPayRequestForm { onClose?(): void; } +const closeIcon = require('@tabler/icons/outline/x.svg'); + 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' }, }); const ZapPayRequestForm = ({ account, status, onClose }: IZapPayRequestForm) => { + const intl = useIntl(); const dispatch = useAppDispatch(); const [zapComment, setZapComment] = useState(''); - // amount in millisatoshi - const [zapAmount, setZapAmount] = useState(50); + const [zapAmount, setZapAmount] = useState(50); // amount in millisatoshi + const { zapArrays, zapSplitData, receiveAmount } = useZapSplit(status, account); + const splitValues = zapSplitData.splitValues; + const hasZapSplit = zapArrays.length > 0; const handleSubmit = async (e?: React.FormEvent) => { e?.preventDefault(); - const invoice = await dispatch(zap(account, status, zapAmount * 1000, zapComment)); + 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 (!invoice) { dispatch(closeModal('ZAP_PAY_REQUEST')); + // Dispatch the adm account + if (zapSplitAccounts.length > 0) { + dispatch(openModal('ZAP_SPLIT', { zapSplitAccounts, splitValues })); + } return; } // open QR code modal - dispatch(openModal('ZAP_INVOICE', { invoice, account })); + dispatch(closeModal('ZAP_PAY_REQUEST')); + dispatch(openModal('ZAP_INVOICE', { account, invoice, splitData })); }; const handleCustomAmount = (e: React.ChangeEvent) => { @@ -69,6 +84,17 @@ const ZapPayRequestForm = ({ account, status, onClose }: IZapPayRequestForm) => } }; + const renderZapButtonText = () => { + if (zapAmount >= 1000) { + return intl.formatMessage(messages.zap_button_rounded, { amount: Math.round((zapAmount / 1000) * 10) / 10 }); + } + return intl.formatMessage(messages.zap_button, { amount: zapAmount }); + }; + + useEffect(() => { + receiveAmount(zapAmount); + }, [zapAmount, zapArrays]); + return ( @@ -101,29 +127,43 @@ const ZapPayRequestForm = ({ account, status, onClose }: IZapPayRequestForm) =>
- {/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */} -

- sats -

+ {hasZapSplit &&

sats

}
+ {hasZapSplit && + + } +
setZapComment(e.target.value)} type='text' placeholder={intl.formatMessage(messages.zap_commentPlaceholder)} />
- + {hasZapSplit ? + +