// @flow import * as ICONS from 'constants/icons'; import * as PAGES from 'constants/pages'; import React from 'react'; import Button from 'component/button'; import { FormField, Form } from 'component/common/form'; import { MINIMUM_PUBLISH_BID } from 'constants/claim'; import CreditAmount from 'component/common/credit-amount'; import I18nMessage from 'component/i18nMessage'; import { Lbryio } from 'lbryinc'; import Card from 'component/common/card'; import classnames from 'classnames'; import ChannelSelector from 'component/channelSelector'; import LbcSymbol from 'component/common/lbc-symbol'; import { parseURI } from 'util/lbryURI'; import usePersistedState from 'effects/use-persisted-state'; import WalletSpendableBalanceHelp from 'component/walletSpendableBalanceHelp'; import { getStripeEnvironment } from 'util/stripe'; let stripeEnvironment = getStripeEnvironment(); const DEFAULT_TIP_AMOUNTS = [1, 5, 25, 100]; const MINIMUM_FIAT_TIP = 1; const MAXIMUM_FIAT_TIP = 1000; const DEFAULT_TIP_ERROR = __('Sorry, there was an error in processing your payment!'); const TAB_BOOST = 'TabBoost'; const TAB_FIAT = 'TabFiat'; const TAB_LBC = 'TabLBC'; type SupportParams = { amount: number, claim_id: string, channel_id?: string }; type Props = { uri: string, claimIsMine: boolean, title: string, claim: StreamClaim, isPending: boolean, isSupport: boolean, sendSupport: (SupportParams, boolean) => void, closeModal: () => void, balance: number, fetchingChannels: boolean, instantTipEnabled: boolean, instantTipMax: { amount: number, currency: string }, activeChannelClaim: ?ChannelClaim, incognito: boolean, doToast: ({ message: string }) => void, isAuthenticated: boolean, }; function WalletSendTip(props: Props) { const { uri, title, isPending, claimIsMine, balance, claim = {}, instantTipEnabled, instantTipMax, sendSupport, closeModal, fetchingChannels, incognito, activeChannelClaim, doToast, isAuthenticated, } = props; /** REACT STATE **/ const [presetTipAmount, setPresetTipAmount] = usePersistedState('comment-support:presetTip', DEFAULT_TIP_AMOUNTS[0]); const [customTipAmount, setCustomTipAmount] = usePersistedState('comment-support:customTip', 1.0); const [useCustomTip, setUseCustomTip] = usePersistedState('comment-support:useCustomTip', false); const [isConfirming, setIsConfirming] = React.useState(false); // only allow certain creators to receive tips const [canReceiveFiatTip, setCanReceiveFiatTip] = React.useState(); // dont persist because it needs to be calc'd per creator // show things conditionally based on if a user has a card already const [hasCardSaved, setHasSavedCard] = usePersistedState('comment-support:hasCardSaved', false); // show the tip error on the frontend const [tipError, setTipError] = React.useState(); // denote which tab to show on the frontend const [activeTab, setActiveTab] = usePersistedState(TAB_BOOST); // handle default active tab React.useEffect(() => { // force to boost tab if it's someone's own upload if (claimIsMine) { setActiveTab(TAB_BOOST); } else { // or set LBC tip as the default if none is set yet if (!activeTab || activeTab === 'undefined') { setActiveTab(TAB_LBC); } } }, []); // alphanumeric claim id const { claim_id: claimId } = claim; // channel name used in url const { channelName } = parseURI(uri); const activeChannelName = activeChannelClaim && activeChannelClaim.name; const activeChannelId = activeChannelClaim && activeChannelClaim.claim_id; // setup variables for backend tip API let channelClaimId, tipChannelName; // if there is a signing channel it's on a file if (claim.signing_channel) { channelClaimId = claim.signing_channel.claim_id; tipChannelName = claim.signing_channel.name; // otherwise it's on the channel page } else { channelClaimId = claim.claim_id; tipChannelName = claim.name; } const sourceClaimId = claim.claim_id; // check if creator has a payment method saved React.useEffect(() => { if (channelClaimId && isAuthenticated && stripeEnvironment) { Lbryio.call( 'customer', 'status', { environment: stripeEnvironment, }, 'post' ).then((customerStatusResponse) => { const defaultPaymentMethodId = customerStatusResponse.Customer && customerStatusResponse.Customer.invoice_settings && customerStatusResponse.Customer.invoice_settings.default_payment_method && customerStatusResponse.Customer.invoice_settings.default_payment_method.id; setHasSavedCard(Boolean(defaultPaymentMethodId)); }); } }, [channelClaimId, isAuthenticated, stripeEnvironment]); // focus tip element if it exists React.useEffect(() => { const tipInputElement = document.getElementById('tip-input'); if (tipInputElement) { tipInputElement.focus(); } }, []); // check if user can receive tips React.useEffect(() => { if (channelClaimId && stripeEnvironment) { Lbryio.call( 'account', 'check', { channel_claim_id: channelClaimId, channel_name: tipChannelName, environment: stripeEnvironment, }, 'post' ) .then((accountCheckResponse) => { if (accountCheckResponse === true && canReceiveFiatTip !== true) { setCanReceiveFiatTip(true); } else { setCanReceiveFiatTip(false); } }) .catch(function (error) { // console.log(error); }); } }, [channelClaimId, stripeEnvironment]); // if user has no balance, used to show conditional frontend const noBalance = balance === 0; // the tip amount, based on if a preset or custom tip amount is being used const tipAmount = useCustomTip ? customTipAmount : presetTipAmount; // get type of claim (stream/channel/repost/collection) for display on frontend function getClaimTypeText() { if (claim.value_type === 'stream') { return __('Content'); } else if (claim.value_type === 'channel') { return __('Channel'); } else if (claim.value_type === 'repost') { return __('Repost'); } else if (claim.value_type === 'collection') { return __('List'); } else { return __('Claim'); } } const claimTypeText = getClaimTypeText(); // icon to use or explainer text to show per tab let iconToUse; let explainerText = ''; if (activeTab === TAB_BOOST) { iconToUse = ICONS.LBC; explainerText = __('This refundable boost will improve the discoverability of this %claimTypeText% while active.', { claimTypeText, }); } else if (activeTab === TAB_FIAT) { iconToUse = ICONS.FINANCE; explainerText = __('Show this channel your appreciation by sending a donation in USD.'); // if (!hasCardSaved) { // explainerText += __('You must add a card to use this functionality.'); // } } else if (activeTab === TAB_LBC) { iconToUse = ICONS.LBC; explainerText = __('Show this channel your appreciation by sending a donation of Credits.'); } const isSupport = claimIsMine || activeTab === TAB_BOOST; React.useEffect(() => { // Regex for number up to 8 decimal places let regexp; let tipError; if (tipAmount === 0) { tipError = __('Amount must be a positive number'); } else if (!tipAmount || typeof tipAmount !== 'number') { tipError = __('Amount must be a number'); } // if it's not fiat, aka it's boost or lbc tip else if (activeTab !== TAB_FIAT) { regexp = RegExp(/^(\d*([.]\d{0,8})?)$/); const validTipInput = regexp.test(String(tipAmount)); if (!validTipInput) { tipError = __('Amount must have no more than 8 decimal places'); } else if (!validTipInput) { tipError = __('Amount must have no more than 8 decimal places'); } else if (tipAmount === balance) { tipError = __('Please decrease the amount to account for transaction fees'); } else if (tipAmount > balance) { tipError = __('Not enough Credits'); } else if (tipAmount < MINIMUM_PUBLISH_BID) { tipError = __('Amount must be higher'); } // if tip fiat tab } else { regexp = RegExp(/^(\d*([.]\d{0,2})?)$/); const validTipInput = regexp.test(String(tipAmount)); if (!validTipInput) { tipError = __('Amount must have no more than 2 decimal places'); } else if (tipAmount < MINIMUM_FIAT_TIP) { tipError = __('Amount must be at least one dollar'); } else if (tipAmount > MAXIMUM_FIAT_TIP) { tipError = __('Amount cannot be over 1000 dollars'); } } setTipError(tipError); }, [tipAmount, balance, setTipError, activeTab]); // make call to the backend to send lbc or fiat function sendSupportOrConfirm(instantTipMaxAmount = null) { // send a tip if (!isConfirming && (!instantTipMaxAmount || !instantTipEnabled || tipAmount > instantTipMaxAmount)) { setIsConfirming(true); } else { // send a boost const supportParams: SupportParams = { amount: tipAmount, claim_id: claimId }; // include channel name if donation not anonymous if (activeChannelClaim && !incognito) { supportParams.channel_id = activeChannelClaim.claim_id; } // send tip/boost sendSupport(supportParams, isSupport); closeModal(); } } // when the form button is clicked function handleSubmit() { if (tipAmount && claimId) { // send an instant tip (no need to go to an exchange first) if (instantTipEnabled && activeTab !== TAB_FIAT) { if (instantTipMax.currency === 'LBC') { sendSupportOrConfirm(instantTipMax.amount); } else { // Need to convert currency of instant purchase maximum before trying to send support Lbryio.getExchangeRates().then(({ LBC_USD }) => { sendSupportOrConfirm(instantTipMax.amount / LBC_USD); }); } // sending fiat tip } else if (activeTab === TAB_FIAT) { if (!isConfirming) { setIsConfirming(true); } else if (isConfirming) { let sendAnonymously = !activeChannelClaim || incognito; // hit backend to send tip Lbryio.call( 'customer', 'tip', { // round to fix issues with floating point numbers amount: Math.round(100 * tipAmount), // convert from dollars to cents creator_channel_name: tipChannelName, // creator_channel_name creator_channel_claim_id: channelClaimId, tipper_channel_name: sendAnonymously ? '' : activeChannelName, tipper_channel_claim_id: sendAnonymously ? '' : activeChannelId, currency: 'USD', anonymous: sendAnonymously, source_claim_id: sourceClaimId, environment: stripeEnvironment, }, 'post' ) .then((customerTipResponse) => { doToast({ message: __("You sent $%amount% as a tip to %tipChannelName%, I'm sure they appreciate it!", { amount: tipAmount, tipChannelName, }), }); }) .catch(function (error) { // show error message from Stripe if one exists (being passed from backend by Beamer's API currently) let displayError; if (error.message) { displayError = error.message; } else { displayError = DEFAULT_TIP_ERROR; } doToast({ message: displayError, isError: true }); }); closeModal(); } // if it's a boost (?) } else { sendSupportOrConfirm(); } } } const countDecimals = function (value) { const text = value.toString(); const index = text.indexOf('.'); return text.length - index - 1; }; function handleCustomPriceChange(event: SyntheticInputEvent<*>) { let tipAmountAsString = event.target.value; let tipAmount = parseFloat(tipAmountAsString); const howManyDecimals = countDecimals(tipAmountAsString); // fiat tip input if (activeTab === TAB_FIAT) { if (Number.isNaN(tipAmount)) { setCustomTipAmount(''); } // allow maximum of two decimal places if (howManyDecimals > 2) { tipAmount = Math.floor(tipAmount * 100) / 100; } // remove decimals, and then get number of digits const howManyDigits = Math.trunc(tipAmount).toString().length; if (howManyDigits > 4 && tipAmount !== 1000) { setTipError('Amount cannot be over 1000 dollars'); setCustomTipAmount(tipAmount); } else if (tipAmount > 1000) { setTipError('Amount cannot be over 1000 dollars'); setCustomTipAmount(tipAmount); } else { setCustomTipAmount(tipAmount); } // LBC tip input } else { // TODO: this is a bit buggy, needs a touchup // if (howManyDecimals > 9) { // // only allows up to 8 decimal places // tipAmount = Number(tipAmount.toString().match(/^-?\d+(?:\.\d{0,8})?/)[0]); // // setTipError('Please only use up to 8 decimals'); // } setCustomTipAmount(tipAmount); } } function buildButtonText() { // test if frontend will show up as isNan function isNan(tipAmount) { // testing for NaN ES5 style https://stackoverflow.com/a/35912757/3973137 // also sometimes it's returned as a string // eslint-disable-next-line if (tipAmount !== tipAmount || tipAmount === 'NaN') { return true; } return false; } function convertToTwoDecimals(number) { return (Math.round(number * 100) / 100).toFixed(2); } const amountToShow = activeTab === TAB_FIAT ? convertToTwoDecimals(tipAmount) : tipAmount; // if it's a valid number display it, otherwise do an empty string const displayAmount = !isNan(tipAmount) ? amountToShow : ''; // build button text based on tab if (activeTab === TAB_BOOST) { return claimIsMine ? __('Boost Your %claimTypeText%', { claimTypeText }) : __('Boost This %claimTypeText%', { claimTypeText }); } else if (activeTab === TAB_FIAT) { return __('Send a $%displayAmount% Tip', { displayAmount }); } else if (activeTab === TAB_LBC) { return __('Send a %displayAmount% Credit Tip', { displayAmount }); } } // dont allow user to click send button function shouldDisableAmountSelector(amount) { return ( (amount > balance && activeTab !== TAB_FIAT) || (activeTab === TAB_FIAT && (!hasCardSaved || !canReceiveFiatTip)) ); } // showed on confirm page above amount function setConfirmLabel() { if (activeTab === TAB_LBC) { return __('Tipping Credit'); } else if (activeTab === TAB_FIAT) { return __('Tipping Fiat (USD)'); } else if (activeTab === TAB_BOOST) { return __('Boosting'); } } return (
{/* if there is no LBC balance, show user frontend to get credits */} {/* if there is lbc, the main tip/boost gui with the 3 tabs at the top */} } subtitle={ {!claimIsMine && (
{/* tip LBC tab button */}
)} {/* short explainer under the button */}
{explainerText + ' '} {/* {activeTab === TAB_FIAT && !hasCardSaved &&
} actions={ // confirmation modal, allow user to confirm or cancel transaction isConfirming ? ( <>
{__('To --[the tip recipient]--')}
{channelName || title}
{__('From --[the tip sender]--')}
{activeChannelClaim && !incognito ? activeChannelClaim.name : __('Anonymous')}
{setConfirmLabel()}
{activeTab === TAB_FIAT ? (

$ {(Math.round(tipAmount * 100) / 100).toFixed(2)}

) : ( )}
) : !((activeTab === TAB_LBC || activeTab === TAB_BOOST) && noBalance) ? ( <>
{/* prompt to save a card */} {activeTab === TAB_FIAT && !hasCardSaved && (

)} {/* section to pick tip/boost amount */}
{DEFAULT_TIP_AMOUNTS.map((amount) => (
{useCustomTip && (
{__('Custom support amount')}{' '} {activeTab !== TAB_FIAT ? ( }} > (%lbc_balance% Credits available) ) : ( 'in USD' )} } error={tipError} min="0" step="any" type="number" style={{ width: activeTab === TAB_FIAT ? '99px' : '160px', }} placeholder="1.23" value={customTipAmount} onChange={(event) => handleCustomPriceChange(event)} />
)} {/* send tip/boost button */}
{activeTab !== TAB_FIAT ? ( ) : !canReceiveFiatTip ? (
{__('Only creators that verify cash accounts can receive tips')}
) : (
{__('The payment will be made from your saved card')}
)} ) : ( // if it's LBC and there is no balance, you can prompt to purchase LBC }}>Supporting content requires %lbc% } subtitle={ }}> With %lbc%, you can send tips to your favorite creators, or help boost their content for more people to see. } actions={
} /> ) } /> ); } export default WalletSendTip;