lbry-desktop/ui/component/walletSendTip/view.jsx
Anthony 7bb5df97fd Stripe 2
show visible card and add remove card button

show your transactions even if you dont have a card

fix presentational issues

show your transactions even if you dont have a card

fix presentational issues

add link to channel section

update yarn

show donation location

add remove card modal still needs completion and also changed how stripe is used on settings stripe card page

add confirm remove card modal to router

move bank account stuff to settings page

move account functionality to settings page

continuing to move account transactions to settings

list transactions for creator

updating copy

touchup tip error

do a better job autofocusing

bugfix

show an error on the card page if api returns 500

building out frontend for comment tip

display dollar sign if its a fiat tip

more frontend work

more frontend work

more frontend bug fixes

working with hardcoded payment intent id

working but with one bug

bugfixed

add toast if payment fails

add add card button

cant get claim id but otherwise done

more frontend work

call is working

show fiat for livestream comments

add is fiat on comments

round and show values properly

dont allow review if tiperror

copy displaying properly

disable buttons conditionally properly

remove card button working

remove card working with a workaround by refreshing page

bugfix

send toast when tip on comment

jeremy frontend changes

only show cart on lbc
2021-07-17 13:19:33 -04:00

593 lines
22 KiB
JavaScript

// @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 'lbry-redux';
import usePersistedState from 'effects/use-persisted-state';
import WalletSpendableBalanceHelp from 'component/walletSpendableBalanceHelp';
import { STRIPE_PUBLIC_KEY } from 'config';
let stripeEnvironment = 'test';
// if the key contains pk_live it's a live key
// update the environment for the calls to the backend to indicate which environment to hit
if (STRIPE_PUBLIC_KEY.indexOf('pk_live') > -1) {
stripeEnvironment = 'live';
}
const DEFAULT_TIP_AMOUNTS = [1, 5, 25, 100];
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, // function that comes from lbry-redux
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;
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 [tipError, setTipError] = React.useState();
const [isConfirming, setIsConfirming] = React.useState(false);
const { claim_id: claimId } = claim;
const { channelName } = parseURI(uri);
const activeChannelName = activeChannelClaim && activeChannelClaim.name;
const activeChannelId = activeChannelClaim && activeChannelClaim.claim_id;
const [canReceiveFiatTip, setCanReceiveFiatTip] = React.useState(); // dont persist because it needs to be calc'd per creator
const [hasCardSaved, setHasSavedCard] = usePersistedState('comment-support:hasCardSaved', false);
// setup variables for 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) {
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]);
// check if creator has an account saved
React.useEffect(() => {
var tipInputElement = document.getElementById('tip-input');
if (tipInputElement) { tipInputElement.focus() }
}, []);
React.useEffect(() => {
if (channelClaimId) {
Lbryio.call(
'account',
'check',
{
channel_claim_id: channelClaimId,
channel_name: tipChannelName,
environment: stripeEnvironment,
},
'post'
)
.then((accountCheckResponse) => {
if (accountCheckResponse === true && canReceiveFiatTip !== true) {
setCanReceiveFiatTip(true);
}
})
.catch(function (error) {
// console.log(error);
});
}
}, [channelClaimId]);
const noBalance = balance === 0;
const tipAmount = useCustomTip ? customTipAmount : presetTipAmount;
const [activeTab, setActiveTab] = React.useState(claimIsMine ? TAB_BOOST : TAB_LBC);
function setClaimTypeText() {
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 = setClaimTypeText();
let iconToUse, 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
const regexp = RegExp(/^(\d*([.]\d{0,8})?)$/);
const validTipInput = regexp.test(String(tipAmount));
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) {
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 {
if (tipAmount < 1) {
tipError = __('Amount must be at least one dollar');
} else if (tipAmount > 1000) {
tipError = __('Amount cannot be over 1000 dollars');
}
}
setTipError(tipError);
}, [tipAmount, balance, setTipError, activeTab]);
//
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;
Lbryio.call(
'customer',
'tip',
{
amount: 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) {
var displayError = 'Sorry, there was an error in processing your payment!';
if (error.message !== 'payment intent failed to confirm') {
displayError = error.message;
}
doToast({ message: displayError, isError: true });
});
closeModal();
}
// if it's a boost (?)
} else {
sendSupportOrConfirm();
}
}
}
function handleCustomPriceChange(event: SyntheticInputEvent<*>) {
const tipAmount = parseFloat(event.target.value);
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;
}
// if it's a valid number display it, otherwise do an empty string
const displayAmount = !isNan(tipAmount) ? tipAmount : '';
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 });
}
}
function shouldDisableAmountSelector(amount) {
return (
(amount > balance && activeTab !== TAB_FIAT) || (activeTab === TAB_FIAT && (!hasCardSaved || !canReceiveFiatTip))
);
}
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 (
<Form onSubmit={handleSubmit}>
{/* if there is no LBC balance, show user frontend to get credits */}
{noBalance ? (
<Card
title={<I18nMessage tokens={{ lbc: <LbcSymbol size={22} /> }}>Supporting content requires %lbc%</I18nMessage>}
subtitle={
<I18nMessage tokens={{ lbc: <LbcSymbol /> }}>
With %lbc%, you can send tips to your favorite creators, or help boost their content for more people to
see.
</I18nMessage>
}
actions={
<div className="section__actions">
<Button
icon={ICONS.REWARDS}
button="primary"
label={__('Earn Rewards')}
navigate={`/$/${PAGES.REWARDS}`}
/>
<Button icon={ICONS.BUY} button="secondary" label={__('Buy/Swap Credits')} navigate={`/$/${PAGES.BUY}`} />
<Button button="link" label={__('Nevermind')} onClick={closeModal} />
</div>
}
/>
) : (
// if there is lbc, the main tip/boost gui with the 3 tabs at the top
<Card
title={<LbcSymbol postfix={claimIsMine ? __('Boost Your %claimTypeText%', {claimTypeText}) : __('Support This %claimTypeText%', {claimTypeText})} size={22} />}
subtitle={
<React.Fragment>
{!claimIsMine && (
<div className="section">
{/* tip LBC tab button */}
<Button
key="tip"
icon={ICONS.LBC}
label={__('Tip')}
button="alt"
onClick={() => {
var tipInputElement = document.getElementById('tip-input');
if (tipInputElement) { tipInputElement.focus() }
if (!isConfirming) {
setActiveTab(TAB_LBC);
}
}}
className={classnames('button-toggle', { 'button-toggle--active': activeTab === TAB_LBC })}
/>
{/* tip fiat tab button */}
{/* @if TARGET='web' */}
<Button
key="tip-fiat"
icon={ICONS.FINANCE}
label={__('Tip')}
button="alt"
onClick={() => {
var tipInputElement = document.getElementById('tip-input');
if (tipInputElement) { tipInputElement.focus() }
if (!isConfirming) {
setActiveTab(TAB_FIAT);
}
}}
className={classnames('button-toggle', { 'button-toggle--active': activeTab === TAB_FIAT })}
/>
{/* @endif */}
{/* tip LBC tab button */}
<Button
key="boost"
icon={ICONS.TRENDING}
label={__('Boost')}
button="alt"
onClick={() => {
var tipInputElement = document.getElementById('tip-input');
if (tipInputElement) { tipInputElement.focus() }
if (!isConfirming) {
setActiveTab(TAB_BOOST);
}
}}
className={classnames('button-toggle', { 'button-toggle--active': activeTab === TAB_BOOST })}
/>
</div>
)}
{/* short explainer under the button */}
<div className="section__subtitle">
{explainerText}
{/* {activeTab === TAB_FIAT && !hasCardSaved && <Button navigate={`/$/${PAGES.SETTINGS_STRIPE_CARD}`} label={__('Add A Card')} button="link" />} */}
{<Button label={__('Learn more')} button="link" href="https://lbry.com/faq/tipping" />}
</div>
</React.Fragment>
}
actions={
// confirmation modal, allow user to confirm or cancel transaction
isConfirming ? (
<>
<div className="section section--padded card--inline confirm__wrapper">
<div className="section">
<div className="confirm__label">{__('To --[the tip recipient]--')}</div>
<div className="confirm__value">{channelName || title}</div>
<div className="confirm__label">{__('From --[the tip sender]--')}</div>
<div className="confirm__value">
{activeChannelClaim && !incognito ? activeChannelClaim.name : __('Anonymous')}
</div>
<div className="confirm__label">{setConfirmLabel()}</div>
<div className="confirm__value">
{activeTab === TAB_FIAT ? <p>$ {tipAmount}</p> : <LbcSymbol postfix={tipAmount} size={22} />}
</div>
</div>
</div>
<div className="section__actions">
<Button
autoFocus
onClick={handleSubmit}
button="primary"
disabled={isPending}
label={__('Confirm')}
/>
<Button button="link" label={__('Cancel')} onClick={() => setIsConfirming(false)} />
</div>
</>
) : (
<>
<div className="section">
<ChannelSelector />
</div>
{activeTab === TAB_FIAT && !hasCardSaved && (
<h3 className="add-card-prompt">
<Button navigate={`/$/${PAGES.SETTINGS_STRIPE_CARD}`} label={__('Add a Card')} button="link" /> To
{__('Tip Creators')}
</h3>
)}
{/* section to pick tip/boost amount */}
<div className="section">
{DEFAULT_TIP_AMOUNTS.map((amount) => (
<Button
key={amount}
disabled={shouldDisableAmountSelector(amount)}
button="alt"
className={classnames('button-toggle button-toggle--expandformobile', {
'button-toggle--active': tipAmount === amount && !useCustomTip,
'button-toggle--disabled': amount > balance,
})}
label={amount}
icon={iconToUse}
onClick={() => {
setPresetTipAmount(amount);
setUseCustomTip(false);
}}
/>
))}
<Button
button="alt"
className={classnames('button-toggle button-toggle--expandformobile', {
'button-toggle--active': useCustomTip, // set as active
})}
icon={iconToUse}
label={__('Custom')}
onClick={() => setUseCustomTip(true)}
// disabled if it's receive fiat and there is no card or creator can't receive tips
disabled={activeTab === TAB_FIAT && (!hasCardSaved || !canReceiveFiatTip)}
/>
{DEFAULT_TIP_AMOUNTS.some((val) => val > balance) && activeTab !== TAB_FIAT && (
<Button
button="secondary"
className="button-toggle-group-action"
icon={ICONS.BUY}
title={__('Buy or swap more LBRY Credits')}
navigate={`/$/${PAGES.BUY}`}
/>
)}
</div>
{useCustomTip && (
<div className="section">
<FormField
autoFocus
name="tip-input"
label={
<React.Fragment>
{__('Custom support amount')}{' '}
{activeTab !== TAB_FIAT ? (
<I18nMessage
tokens={{ lbc_balance: <CreditAmount precision={4} amount={balance} showLBC={false} /> }}
>
(%lbc_balance% Credits available)
</I18nMessage>
) : (
'in USD'
)}
</React.Fragment>
}
className="form-field--price-amount"
error={tipError && activeTab !== TAB_FIAT}
min="0"
step="any"
type="number"
placeholder="1.23"
value={customTipAmount}
onChange={(event) => handleCustomPriceChange(event)}
/>
</div>
)}
{/* send tip/boost button */}
<div className="section__actions">
<Button
autoFocus
icon={isSupport ? ICONS.TRENDING : ICONS.SUPPORT}
button="primary"
type="submit"
disabled={
fetchingChannels ||
isPending ||
(tipError && activeTab !== TAB_FIAT) ||
!tipAmount ||
(activeTab === TAB_FIAT && (!hasCardSaved || !canReceiveFiatTip))
}
label={buildButtonText()}
/>
{fetchingChannels && <span className="help">{__('Loading your channels...')}</span>}
</div>
{activeTab !== TAB_FIAT ? (
<WalletSpendableBalanceHelp />
) : !canReceiveFiatTip ? (
<div className="help">{__('Only select creators can receive tips at this time')}</div>
) : (
<div className="help">{__('The payment will be made from your saved card')}</div>
)}
</>
)
}
/>
)}
</Form>
);
}
export default WalletSendTip;