updated code

about to test something

generate programatically

beginning of the frontend

stripe integration page seems to be working

add user

put functionality behind conditional tag

connect frontend working well

adding environment variables to save success and failure url

bugfix

bugfix

final clean up

adding credit card page

seems to be coming along

calls successfully coming from the frontend

fixing up frontend

cleaning up

frontend coming along

client secret working

basic frontend in place

adding tip page

adding more to the tip frontend

frontend almost done

tabs coming along

one last thing to do for frontend

adding explainer text as custom function

putting finishing touches on tabs

support tabs working well

disable fiat toggle when card not connected

fix frontend gui bug

bugfix and pull out label function

fix symbol for tip gui

modal when card is not yet saved

fix fiat disabled bug

knowing whether card is added programatically

sending tip with frontend

tip functionality working

show unpaid balance

add frontend for card add section

update frontend

update frontend

bugfix

change to use react instead of css

update how stripe is instantiated

fix bug

use customer setup

coming along

working but needs optimization

persist if card is saved

adding anonymous tip functionality

fix nan bug

build stripe endpoints programatically

show for all users for time being

allow the stripe key to automatically switch to live environment

bugfix

bugfix

fix jslint

fix channel page support button

better docs

show customer transactions on frontend

basic table in place

various page updates per jeremys notes

showing card details

nicer tip history table

add better prompt to add card on file viewer page

some linting

time

put connect account behind fiat enabled

no persist fiat mode

wallet calls

tip stuff
This commit is contained in:
zeppi 2021-07-03 13:19:23 -04:00 committed by jessopb
parent 10c04a7991
commit bec50829c1
19 changed files with 1309 additions and 51 deletions

View file

@ -63,3 +63,5 @@ AUTO_FOLLOW_CHANNELS=lbry://@lbry#3fda836a92faaceedfe398225fb9b2ee2ed1f01a
# PINNED_URI_2=$/discover?t=lbrytvpaidbeta&fee_amount=>0&claim_type=stream&channel_ids=5af39f818f668d8c00943c9326c5201c4fe3c423,cda9c4e92f19d6fe0764524a2012056e06ca2055,760da3ba3dd85830a843beaaed543a89b7a367e7,40c36948f0da072dcba3e4833e90f71e16de78be,e8f68563d242f6ac9784dcbc41dd86c28a9391d6,7236fc5d2783ea7314d9076ae6c8a250e3992d1a,cf7792c2a37d0d76aaaff84aff0b99a8c791429d,8316ac90764fedf3147799b7b81a6575a9cc398e,8627af93c1a1219150f06b698f4b33e6ed2f1c1e,8972a1bd06de5186e5e89292b05aac8aaa817791,c5b0b17838df2f6c31162f64d55f60f34ae8bfc6,f576d5dba905fc179de880c3fe3eb3281ea74f59,97dd77c93c9603cbb2583f3589f7f5a6c92baa43,f399d873e0c37cf24de9569b5f22bbb30a5c6709,dba870d0620d41b2b9a152c961e0c06cf875ccfc,ca1fd651c9d14bf2e5088bb2aa0146ee7aeb2ae0,50ad846a4b1543b847bf3fdafb7b45f6b2f5844c,e09ff5abe9fb44dd0dd0576894a6db60a6211603,7b6f7517f6b816827d076fa0eaad550aa315a4e7,2068452c41d8da3bd68961335da0072a99258a1a,5da63df97c8255ae94a88940695b8471657dd5a1,3645cf2f5d0bdac0523f945be1c3ff60758f7845,4da85b12244839d6368b9290f1619ff9514ab2a8,4ad942982e43326c7700b1b6443049b3cfd82161,55304f219244abf82f684f759cc0c7769242f3b4,8f42e5b592bb7f7a03f4a94a86a41b1236bb099f,e2a014d885a48f5be2dc6409610996337312facb,c18996ca488753f714d36d4654715927c1d7f9c2,ebc4214424cfa683a7046e1f794fea1e44788d84,06b6d6d6a893fb589ec2ded948f5122856921ed5,07e4546674268fc0222b2ca22d31d0549dc217ee,060940e41973d4f7f16d72a2733138e931c35f41,f8d6eccd887c9cebd36b1d42aa349279b7f5c3ed,68098b8426f967b8d04cc566348b5c128823219e,2bfe6cdb24a21bdc1b76fb7c416edd50e9e85945,1f9bb08bfa2259629f4aaa9ed40f97e9a41b6fa1,2f20148495612946675fe1c8ea99171e4d950b81,bc6938fa1e09e840056c2e831abf9664f397c472,2a6194792beac5130641e932b5ac6e5a99b5ca4f,185ba2bd547a5e4a77d29fe6c1484f47db5e058f,29cc7f6081268eaa5b3f2946e0cd0b952a94812c,49389450b1241f5d8f4c8c4271a3eb56bba33965,ffdc62ac2f7549398d3aca9d2119e83d80d588d5,d7a4d2808074b0c55d6b239f69d90e7a4930f943,d58aa4a0b2f6c2504c3abce8de3f1afb71800acc,77ae23dc7eb8a75609881d4548a79e4935a89d37,f79bce8a60fbece671f6265adc39f6469f3b9b8c,051995fdf0af634e4911704057a551e9392e62b1
# PINNED_LABEL_2=Paid Beta
# Stripe
STRIPE_PUBLIC_KEY='pk_test_NoL1JWL7i1ipfhVId5KfDZgo'

View file

@ -49,6 +49,7 @@ const config = {
PINNED_URI_2: process.env.PINNED_URI_2,
PINNED_LABEL_2: process.env.PINNED_LABEL_2,
KNOWN_APP_DOMAINS: process.env.KNOWN_APP_DOMAINS ? process.env.KNOWN_APP_DOMAINS.split(',') : [],
STRIPE_PUBLIC_KEY: process.env.STRIPE_PUBLIC_KEY,
};
config.URL_LOCAL = `http://localhost:${config.WEB_SERVER_PORT}`;

View file

@ -111,6 +111,7 @@ class CardVerify extends React.Component {
CardVerify.stripeHandler = StripeCheckout.configure({
key: this.props.stripeKey,
});
if (this.hasPendingClick) {
this.showStripeDialog();
}

View file

@ -12,6 +12,15 @@ import { doToggleTagFollowDesktop } from 'redux/actions/tags';
import { makeSelectClientSetting, selectShowMatureContent, selectLanguage } from 'redux/selectors/settings';
import { selectModerationBlockList } from 'redux/selectors/comments';
import ClaimListDiscover from './view';
import { createSelector } from 'reselect';
const selectState = state => state.claims || {};
export const selectClaimsById = createSelector(
selectState,
state => state.byId || {}
);
const select = (state) => ({
followedTags: selectFollowedTags(state),

View file

@ -66,6 +66,7 @@ const RewardsPage = React.lazy(() => import('page/rewards' /* webpackChunkName:
const RewardsVerifyPage = React.lazy(() => import('page/rewardsVerify' /* webpackChunkName: "secondary" */));
const SearchPage = React.lazy(() => import('page/search' /* webpackChunkName: "secondary" */));
const SettingsAdvancedPage = React.lazy(() => import('page/settingsAdvanced' /* webpackChunkName: "secondary" */));
const SettingsStripeCard = React.lazy(() => import('page/settingsStripeCard' /* webpackChunkName: "secondary" */));
const SettingsCreatorPage = React.lazy(() => import('page/settingsCreator' /* webpackChunkName: "secondary" */));
const SettingsNotificationsPage = React.lazy(() =>
import('page/settingsNotifications' /* webpackChunkName: "secondary" */)
@ -288,6 +289,7 @@ function AppRouter(props: Props) {
component={isAuthenticated || !IS_WEB ? ChannelsFollowingPage : DiscoverPage}
/>
<PrivateRoute {...props} path={`/$/${PAGES.SETTINGS_NOTIFICATIONS}`} component={SettingsNotificationsPage} />
<PrivateRoute {...props} path={`/$/${PAGES.SETTINGS_STRIPE_CARD}`} component={SettingsStripeCard} />
<PrivateRoute
{...props}
exact

View file

@ -0,0 +1,13 @@
import { connect } from 'react-redux';
import { withRouter } from 'react-router';
import StripeAccountConnection from './view';
import { selectUser } from 'redux/selectors/user';
// function that receives state parameter and returns object of functions that accept state
const select = (state) => ({
user: selectUser(state),
});
const perform = (dispatch) => ({});
export default withRouter(connect(select, perform)(StripeAccountConnection));

View file

@ -0,0 +1,194 @@
// @flow
import * as ICONS from 'constants/icons';
import React from 'react';
import Button from 'component/button';
import Card from 'component/common/card';
import { Lbryio } from 'lbryinc';
import { URL, WEBPACK_WEB_PORT, STRIPE_PUBLIC_KEY } from 'config';
const isDev = process.env.NODE_ENV !== 'production';
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';
}
let successStripeRedirectUrl, failureStripeRedirectUrl;
let successEndpoint = '/$/wallet';
let failureEndpoint = '/$/wallet';
if (isDev) {
successStripeRedirectUrl = 'http://localhost:' + WEBPACK_WEB_PORT + successEndpoint;
failureStripeRedirectUrl = 'http://localhost:' + WEBPACK_WEB_PORT + failureEndpoint;
} else {
successStripeRedirectUrl = URL + successEndpoint;
failureStripeRedirectUrl = URL + failureEndpoint;
}
type Props = {
source: string,
user: User,
};
type State = {
error: boolean,
loading: boolean,
content: ?string,
stripeConnectionUrl: string,
alreadyUpdated: boolean,
accountConfirmed: boolean,
unpaidBalance: string
};
class StripeAccountConnection extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
error: false,
content: null,
loading: true,
accountConfirmed: false,
accountPendingConfirmation: false,
unpaidBalance: 0,
};
}
componentDidMount() {
const { user } = this.props;
this.experimentalUiEnabled = user && user.experimental_ui;
var that = this;
function getAndSetAccountLink() {
Lbryio.call('account', 'link', {
return_url: successStripeRedirectUrl,
refresh_url: failureStripeRedirectUrl,
environment: stripeEnvironment,
}, 'post').then(accountLinkResponse => {
// stripe link for user to navigate to and confirm account
const stripeConnectionUrl = accountLinkResponse.url;
that.setState({
stripeConnectionUrl,
accountPendingConfirmation: true,
});
});
}
// call the account status endpoint
Lbryio.call('account', 'status', {
environment: stripeEnvironment,
}, 'post').then(accountStatusResponse => {
// if charges already enabled, no need to generate an account link
if (accountStatusResponse.charges_enabled) {
// account has already been confirmed
const yetToBeCashedOutBalance = accountStatusResponse.total_received_unpaid;
if (yetToBeCashedOutBalance) {
that.setState({
unpaidBalance: yetToBeCashedOutBalance,
});
}
console.log(accountStatusResponse.total_received_unpaid);
that.setState({
accountConfirmed: true,
});
} else {
// get stripe link and set it on the frontend
getAndSetAccountLink();
}
}).catch(function(error) {
// errorString passed from the API (with a 403 error)
const errorString = 'account not linked to user, please link first';
// if it's beamer's error indicating the account is not linked yet
if (error.message.indexOf(errorString) > -1) {
// get stripe link and set it on the frontend
getAndSetAccountLink();
} else {
// not an error from Beamer, throw it
throw new Error(error);
}
});
}
render() {
const { stripeConnectionUrl, accountConfirmed, accountPendingConfirmation, unpaidBalance } = this.state;
const { user } = this.props;
if (user.fiat_enabled) {
return (
<Card
title={<div className="table__header-text">{__(`Connect A Bank Account`)}</div>}
isBodyList
body={
<div>
{/* show while waiting for account status */}
{!accountConfirmed && !accountPendingConfirmation &&
<div className="card__body-actions">
<div>
<div>
<h3>Getting your Bank Account Connection status...</h3>
</div>
</div>
</div>
}
{/* user has yet to complete their integration */}
{!accountConfirmed && accountPendingConfirmation &&
<div className="card__body-actions">
<div>
<div>
<h3>Connect your Bank Account to Odysee to receive donations directly from users</h3>
</div>
<div className="section__actions">
<a href={stripeConnectionUrl}>
<Button
button="secondary"
label={__('Connect Your Bank Account')}
icon={ICONS.FINANCE}
/>
</a>
</div>
</div>
</div>
}
{/* user has completed their integration */}
{accountConfirmed &&
<div className="card__body-actions">
<div>
<div>
<h3>Congratulations! Your account has been connected with Odysee.</h3>
{unpaidBalance > 0 ? <div><br></br>
<h3>Your account balance is ${unpaidBalance/100} USD. When the functionality exists you will be able to withdraw your balance.</h3>
</div> : <div><br></br>
<h3>Your account balance is $0 USD. When you receive a tip you will see it here.</h3>
</div>}
</div>
<div className="section__actions">
<a href="/$/wallet">
<Button
button="secondary"
label={__('View Your Transaction History')}
icon={ICONS.SETTINGS}
/>
</a>
</div>
</div>
</div>
}
</div>
}
/>
);
} else {
return (<></>);
}
}
}
export default StripeAccountConnection;

View file

@ -71,7 +71,7 @@ const WalletBalance = (props: Props) => {
Your total balance. All of this is yours, but some %lbc% is in use on channels and content right now.
</I18nMessage>
) : (
<span>{__('Your total balance.')}</span>
<span>{__('Your total balance')}</span>
)
}
actions={

View file

@ -14,6 +14,8 @@ import { doOpenModal, doHideModal } from 'redux/actions/app';
import { withRouter } from 'react-router';
import { makeSelectClientSetting } from 'redux/selectors/settings';
import { selectActiveChannelClaim, selectIncognito } from 'redux/selectors/app';
import { doToast } from 'redux/actions/notifications';
import { selectUserVerifiedEmail } from 'redux/selectors/user';
const select = (state, props) => ({
isPending: selectIsSendingSupport(state),
@ -26,12 +28,14 @@ const select = (state, props) => ({
fetchingChannels: selectFetchingMyChannels(state),
activeChannelClaim: selectActiveChannelClaim(state),
incognito: selectIncognito(state),
isAuthenticated: Boolean(selectUserVerifiedEmail(state)),
});
const perform = dispatch => ({
openModal: (modal, props) => dispatch(doOpenModal(modal, props)),
closeModal: () => dispatch(doHideModal()),
sendSupport: (params, isSupport) => dispatch(doSendTip(params, isSupport)),
doToast: (options) => dispatch(doToast(options)),
});
export default withRouter(connect(select, perform)(WalletSendTip));

View file

@ -15,9 +15,20 @@ 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 = {
@ -26,15 +37,17 @@ type Props = {
title: string,
claim: StreamClaim,
isPending: boolean,
sendSupport: (SupportParams, boolean) => void,
isSupport: boolean,
sendSupport: (SupportParams, boolean) => void, // function that comes from lbry-redux
closeModal: () => void,
balance: number,
isSupport: boolean,
fetchingChannels: boolean,
instantTipEnabled: boolean,
instantTipMax: { amount: number, currency: string },
activeChannelClaim: ?ChannelClaim,
incognito: boolean,
doToast: ({ message: string }) => void,
isAuthenticated: boolean,
};
function WalletSendTip(props: Props) {
@ -52,20 +65,107 @@ function WalletSendTip(props: Props) {
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 [sendAsTip, setSendAsTip] = usePersistedState('comment-support:sendAsTip', true);
const [isConfirming, setIsConfirming] = React.useState(false);
const { claim_id: claimId } = claim;
const { channelName } = parseURI(uri);
const noBalance = balance === 0;
const tipAmount = useCustomTip ? customTipAmount : presetTipAmount;
const isSupport = claimIsMine || !sendAsTip;
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;
// TODO: come up with a better way to do this,
// TODO: waiting 100ms to wait for token to populate
// check if creator has an account 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]);
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(TAB_LBC);
let iconToUse, explainerText;
if (activeTab === TAB_BOOST) {
iconToUse = ICONS.LBC;
explainerText = 'This refundable boost will improve the discoverability of this content while active. ';
} else if (activeTab === TAB_FIAT) {
iconToUse = ICONS.FINANCE;
explainerText = 'Show this channel your appreciation by sending a donation of cash 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;
@ -83,34 +183,35 @@ function WalletSendTip(props: Props) {
} else if (tipAmount > balance) {
tipError = __('Not enough Credits');
}
setTipError(tipError);
}, [tipAmount, balance, setTipError]);
//
function sendSupportOrConfirm(instantTipMaxAmount = null) {
let selectedChannelId;
if (!incognito && activeChannelClaim) {
selectedChannelId = activeChannelClaim.claim_id;
}
if (
!isSupport &&
!isConfirming &&
(!instantTipMaxAmount || !instantTipEnabled || tipAmount > instantTipMaxAmount)
) {
// send a tip
if (!isConfirming && (!instantTipMaxAmount || !instantTipEnabled || tipAmount > instantTipMaxAmount)) {
setIsConfirming(true);
} else {
// send a boost
const supportParams: SupportParams = { amount: tipAmount, claim_id: claimId };
if (selectedChannelId) {
supportParams.channel_id = selectedChannelId;
// 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) {
if (instantTipEnabled) {
// 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 {
@ -119,6 +220,44 @@ function WalletSendTip(props: Props) {
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
channel_name: tipChannelName,
channel_claim_id: channelClaimId,
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,
}),
});
console.log(customerTipResponse);
})
.catch(function (error) {
console.log(error);
doToast({ message: error.message, isError: true });
});
closeModal();
}
// if it's a boost (?)
} else {
sendSupportOrConfirm();
}
@ -130,8 +269,49 @@ function WalletSendTip(props: Props) {
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
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 'Boost This Content';
} else if (activeTab === TAB_FIAT) {
return 'Send a $' + displayAmount + ' Tip';
} else if (activeTab === TAB_LBC) {
return 'Send a ' + displayAmount + ' LBC Tip';
}
}
function shouldDisableAmountSelector(amount) {
return (
(amount > balance && activeTab !== TAB_FIAT) ||
(activeTab === TAB_FIAT && (!hasCardSaved || !canReceiveFiatTip))
);
}
function setConfirmLabel() {
if (activeTab === TAB_LBC) {
return 'Tipping LBC';
} 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>}
@ -155,41 +335,65 @@ function WalletSendTip(props: Props) {
}
/>
) : (
// if there is lbc, the main tip/boost gui with the 3 tabs at the top
<Card
title={<LbcSymbol postfix={claimIsMine ? __('Boost your content') : __('Support this content')} size={22} />}
subtitle={
<React.Fragment>
{!claimIsMine && (
<div className="section">
{/* tip LBC tab button */}
<Button
key="tip"
icon={ICONS.SUPPORT}
icon={ICONS.LBC}
label={__('Tip')}
button="alt"
onClick={() => setSendAsTip(true)}
className={classnames('button-toggle', { 'button-toggle--active': sendAsTip })}
onClick={() => {
if (!isConfirming) {
setActiveTab(TAB_LBC);
}
}}
className={classnames('button-toggle', { 'button-toggle--active': activeTab === TAB_LBC })}
/>
{/* tip fiat tab button */}
<Button
key="tip-fiat"
icon={ICONS.FINANCE}
label={__('Tip')}
button="alt"
onClick={() => {
if (!isConfirming) {
setActiveTab(TAB_FIAT);
}
}}
className={classnames('button-toggle', { 'button-toggle--active': activeTab === TAB_FIAT })}
/>
{/* tip LBC tab button */}
<Button
key="boost"
icon={ICONS.TRENDING}
label={__('Boost')}
button="alt"
onClick={() => setSendAsTip(false)}
className={classnames('button-toggle', { 'button-toggle--active': !sendAsTip })}
onClick={() => {
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">
{isSupport
? __(
'This will increase the overall bid amount for this content, which will boost its ability to be discovered while active.'
)
: __('Show this channel your appreciation by sending a donation.')}{' '}
<Button label={__('Learn more')} button="link" href="https://lbry.com/faq/tipping" />
{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">
@ -200,9 +404,9 @@ function WalletSendTip(props: Props) {
<div className="confirm__value">
{activeChannelClaim && !incognito ? activeChannelClaim.name : __('Anonymous')}
</div>
<div className="confirm__label">{__(isSupport ? 'Boosting' : 'Tipping')}</div>
<div className="confirm__label">{setConfirmLabel()}</div>
<div className="confirm__value">
<LbcSymbol postfix={tipAmount} size={22} />
{activeTab === TAB_FIAT ? <p>$ {tipAmount}</p> : <LbcSymbol postfix={tipAmount} size={22} />}
</div>
</div>
</div>
@ -223,34 +427,45 @@ function WalletSendTip(props: Props) {
<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={amount > balance}
disabled={shouldDisableAmountSelector(amount)}
button="alt"
className={classnames('button-toggle button-toggle--expandformobile', {
'button-toggle--active': tipAmount === amount,
'button-toggle--active': tipAmount === amount && !useCustomTip,
'button-toggle--disabled': amount > balance,
})}
label={amount}
icon={ICONS.LBC}
icon={iconToUse}
onClick={() => {
setPresetTipAmount(amount);
setUseCustomTip(false);
}}
/>
))}
<Button
button="alt"
className={classnames('button-toggle button-toggle--expandformobile', {
'button-toggle--active': !DEFAULT_TIP_AMOUNTS.includes(tipAmount),
'button-toggle--active': useCustomTip, // set as active
})}
icon={ICONS.LBC}
icon={iconToUse}
label={__('Custom')}
onClick={() => setUseCustomTip(true)}
disabled={activeTab === TAB_FIAT && (!hasCardSaved || !canReceiveFiatTip)}
/>
{DEFAULT_TIP_AMOUNTS.some((val) => val > balance) && (
{DEFAULT_TIP_AMOUNTS.some((val) => val > balance) && activeTab !== TAB_FIAT && (
<Button
button="secondary"
className="button-toggle-group-action"
@ -269,15 +484,19 @@ function WalletSendTip(props: Props) {
label={
<React.Fragment>
{__('Custom support amount')}{' '}
<I18nMessage
tokens={{ lbc_balance: <CreditAmount precision={4} amount={balance} showLBC={false} /> }}
>
(%lbc_balance% Credits available)
</I18nMessage>
{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}
error={tipError && activeTab !== TAB_FIAT}
min="0"
step="any"
type="number"
@ -288,22 +507,31 @@ function WalletSendTip(props: Props) {
</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 || !tipAmount}
label={
isSupport
? __('Boost This Content')
: __('Send a %amount% Tip', { amount: tipAmount ? `${tipAmount} Credit` : '' })
disabled={
fetchingChannels ||
isPending ||
(tipError && activeTab !== TAB_FIAT) ||
!tipAmount ||
(activeTab === TAB_FIAT && (!hasCardSaved || !canReceiveFiatTip))
}
label={buildButtonText()}
/>
{fetchingChannels && <span className="help">{__('Loading your channels...')}</span>}
</div>
<WalletSpendableBalanceHelp />
{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>
)}
</>
)
}

View file

@ -38,6 +38,7 @@ exports.REWARDS_VERIFY = 'rewards/verify';
exports.REPOST_NEW = 'repost';
exports.SEND = 'send';
exports.SETTINGS = 'settings';
exports.SETTINGS_STRIPE_CARD = 'settings/card';
exports.SETTINGS_NOTIFICATIONS = 'settings/notifications';
exports.SETTINGS_ADVANCED = 'settings/advanced';
exports.SETTINGS_BLOCKED_MUTED = 'settings/block_and_mute';

View file

@ -205,6 +205,21 @@ class SettingsPage extends React.PureComponent<Props, State> {
}}
className="card-stack"
>
<Card
title={__('Add card to tip creators in USD')}
actions={
<div className="section__actions">
<Button
button="secondary"
label={__('Manage Card')}
icon={ICONS.WALLET}
navigate={`/$/${PAGES.SETTINGS_STRIPE_CARD}`}
/>
</div>
}
/>
<Card title={__('Language')} actions={<SettingLanguage />} />
{homepages && Object.keys(homepages).length > 1 && (
<Card title={__('Homepage')} actions={<HomepageSelector />} />

View file

@ -0,0 +1,19 @@
import { connect } from 'react-redux';
import { doSetClientSetting } from 'redux/actions/settings';
import { selectosNotificationsEnabled } from 'redux/selectors/settings';
import { selectUserVerifiedEmail } from 'redux/selectors/user';
import { selectUserEmail } from 'redux/selectors/user';
import SettingsPage from './view';
const select = state => ({
osNotificationsEnabled: selectosNotificationsEnabled(state),
isAuthenticated: Boolean(selectUserVerifiedEmail(state)),
email: selectUserEmail(state),
});
const perform = dispatch => ({
setClientSetting: (key, value) => dispatch(doSetClientSetting(key, value)),
});
export default connect(select, perform)(SettingsPage);

View file

@ -0,0 +1,428 @@
// @flow
/* eslint-disable no-undef */
/* eslint-disable react/prop-types */
import React from 'react';
import Page from 'component/page';
import Card from 'component/common/card';
import { SETTINGS } from 'lbry-redux';
import { Lbryio } from 'lbryinc';
import { STRIPE_PUBLIC_KEY } from 'config';
import classnames from 'classnames';
import moment from 'moment';
let scriptLoading = false;
let scriptLoaded = false;
let scriptDidError = false;
const dateFormat = {
month: 'short',
day: 'numeric',
year: 'numeric',
};
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';
}
type Props = {
disabled: boolean,
label: ?string,
email: ?string,
}
class CardVerify extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
open: false,
scriptFailedToLoad: false,
currentFlowStage: 'loading', // loading, confirmingCard, cardConfirmed
customerTransactions: [],
pageTitle: 'Add Card',
userCardDetails: {},
};
}
componentDidMount() {
var that = this;
const script = document.createElement('script');
script.src = 'https://js.stripe.com/v3/';
script.async = true;
// $FlowFixMe
document.body.appendChild(script);
// public key of the stripe account
var publicKey = STRIPE_PUBLIC_KEY;
// client secret of the SetupIntent (don't share with anyone but customer)
var clientSecret = '';
// setting a timeout to let the client secret populate
// TODO: fix this, should be a cleaner way
setTimeout(function() {
// check if customer has card setup already
Lbryio.call('customer', 'status', {
environment: stripeEnvironment,
}, 'post').then(customerStatusResponse => {
// user has a card saved if their defaultPaymentMethod has an id
const defaultPaymentMethod = customerStatusResponse.Customer.invoice_settings.default_payment_method;
var userHasAlreadySetupPayment = Boolean(defaultPaymentMethod && defaultPaymentMethod.id);
// show different frontend if user already has card
if (userHasAlreadySetupPayment) {
var card = customerStatusResponse.PaymentMethods[0].card;
var cardDetails = {
brand: card.brand,
expiryYear: card.exp_year,
expiryMonth: card.exp_month,
lastFour: card.last4
};
that.setState({
currentFlowStage: 'cardConfirmed',
pageTitle: 'Tip History',
userCardDetails: cardDetails,
});
// get customer transactions
Lbryio.call('customer', 'list', {
environment: stripeEnvironment,
}, 'post').then(customerTransactionsResponse => {
that.setState({
customerTransactions: customerTransactionsResponse,
})
console.log(customerTransactionsResponse);
});
// otherwise, prompt them to save a card
} else {
that.setState({
currentFlowStage: 'confirmingCard',
});
// get a payment method secret for frontend
Lbryio.call('customer', 'setup', {
environment: stripeEnvironment,
}, 'post').then(customerSetupResponse => {
console.log(customerSetupResponse);
clientSecret = customerSetupResponse.client_secret;
// instantiate stripe elements
setupStripe();
});
}
// if the status call fails, either an actual error or need to run setup first
}).catch(function(error) {
console.log(error);
// errorString passed from the API (with a 403 error)
const errorString = 'user as customer is not setup yet';
// if it's beamer's error indicating the account is not linked yet
if (error.message.indexOf(errorString) > -1) {
// send them to save a card
that.setState({
currentFlowStage: 'confirmingCard',
});
// get a payment method secret for frontend
Lbryio.call('customer', 'setup', {
environment: stripeEnvironment,
}, 'post').then(customerSetupResponse => {
console.log(customerSetupResponse);
clientSecret = customerSetupResponse.client_secret;
// instantiate stripe elements
setupStripe();
});
} else {
console.log('Unseen before error');
}
});
}, 250);
function setupStripe() {
// TODO: have to fix this, using so that the script is available
setTimeout(function() {
var stripeElements = function(publicKey, setupIntent) {
var stripe = Stripe(publicKey);
var elements = stripe.elements();
// Element styles
var style = {
base: {
fontSize: '16px',
color: '#32325d',
fontFamily:
'-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif',
fontSmoothing: 'antialiased',
'::placeholder': {
color: 'rgba(0,0,0,0.4)',
},
},
};
var card = elements.create('card', { style: style });
card.mount('#card-element');
// Element focus ring
card.on('focus', function() {
var el = document.getElementById('card-element');
el.classList.add('focused');
});
card.on('blur', function() {
var el = document.getElementById('card-element');
el.classList.remove('focused');
});
card.on('ready', function() {
card.focus();
});
var email = that.props.email;
function submitForm(event) {
event.preventDefault();
// if client secret wasn't loaded properly
if (!clientSecret) {
var displayErrorText = 'There was an error in generating your payment method. Please contact a developer';
var displayError = document.getElementById('card-errors');
displayError.textContent = displayErrorText;
return;
}
changeLoadingState(true);
stripe.confirmCardSetup(clientSecret, {
payment_method: {
card: card,
billing_details: { email: email },
},
}).then(function(result) {
if (result.error) {
console.log(result);
changeLoadingState(false);
var displayError = document.getElementById('card-errors');
displayError.textContent = result.error.message;
} else {
// The PaymentMethod was successfully set up
// hide and show the proper divs
orderComplete(stripe, clientSecret);
}
});
}
// Handle payment submission when user clicks the pay button.
var button = document.getElementById('submit');
button.addEventListener('click', function(event) {
submitForm(event);
});
// currently doesn't work because the iframe javascript context is different
// would be nice though if it's even technically possible
// window.addEventListener('keyup', function(event) {
// if (event.keyCode === 13) {
// submitForm(event);
// }
// }, false);
};
// TODO: possible bug here where clientSecret isn't done
stripeElements(publicKey, clientSecret);
// Show a spinner on payment submission
var changeLoadingState = function(isLoading) {
if (isLoading) {
document.querySelector('button').disabled = true;
document.querySelector('#spinner').classList.remove('hidden');
document.querySelector('#button-text').classList.add('hidden');
} else {
document.querySelector('button').disabled = false;
document.querySelector('#spinner').classList.add('hidden');
document.querySelector('#button-text').classList.remove('hidden');
}
};
// shows a success / error message when the payment is complete
var orderComplete = function(stripe, clientSecret) {
stripe.retrieveSetupIntent(clientSecret).then(function(result) {
Lbryio.call('customer', 'status', {
environment: stripeEnvironment,
}, 'post').then(customerStatusResponse => {
var card = customerStatusResponse.PaymentMethods[0].card;
var cardDetails = {
brand: card.brand,
expiryYear: card.exp_year,
expiryMonth: card.exp_month,
lastFour: card.last4,
};
that.setState({
currentFlowStage: 'cardConfirmed',
pageTitle: 'Tip History',
userCardDetails: cardDetails,
});
});
console.log(result);
changeLoadingState(false);
});
};
}, 0);
}
}
componentDidUpdate() {
if (!scriptLoading) {
this.updateStripeHandler();
}
}
componentWillUnmount() {
if (this.loadPromise) {
this.loadPromise.reject();
}
if (CardVerify.stripeHandler && this.state.open) {
CardVerify.stripeHandler.close();
}
}
onScriptLoaded = () => {
// if (!CardVerify.stripeHandler) {
// CardVerify.stripeHandler = StripeCheckout.configure({
// key: 'pk_test_NoL1JWL7i1ipfhVId5KfDZgo',
// });
//
// if (this.hasPendingClick) {
// this.showStripeDialog();
// }
// }
};
onScriptError = (...args) => {
this.setState({ scriptFailedToLoad: true });
};
onClosed = () => {
this.setState({ open: false });
};
updateStripeHandler() {
// if (!CardVerify.stripeHandler) {
// CardVerify.stripeHandler = StripeCheckout.configure({
// key: this.props.stripeKey,
// });
// }
}
render() {
const { scriptFailedToLoad } = this.props;
const { currentFlowStage, customerTransactions, pageTitle, userCardDetails } = this.state;
return (
<Page backout={{ title: pageTitle, backLabel: __('Done') }} noFooter noSideNavigation>
<div>
{scriptFailedToLoad && (
<div className="error__text">There was an error connecting to Stripe. Please try again later.</div>
)}
</div>
{currentFlowStage === 'loading' && <div className="headerCard toConfirmCard">
<Card
title={__('Connect your card with Odysee')}
subtitle={__('Getting your card connection status...')}
/>
</div>}
{currentFlowStage === 'confirmingCard' && <div className="sr-root">
<div className="sr-main">
<div className="sr-payment-form card cardInput">
<div className="sr-form-row">
<label className="payment-details">
Card Details
</label>
<div className="sr-input sr-element sr-card-element" id="card-element" />
</div>
<div className="sr-field-error" id="card-errors" role="alert" />
<button className="linkButton" id="submit">
<div className="spinner hidden" id="spinner" />
<span id="button-text">Add Card</span>
</button>
</div>
</div>
</div>}
{currentFlowStage === 'cardConfirmed' && <div className="successCard">
<Card
title={__('Card Details')}
body={<>
<h4 className="grey-text">Brand: {userCardDetails.brand.toUpperCase()} &nbsp;
Last 4: {userCardDetails.lastFour} &nbsp;
Expires: {userCardDetails.expiryMonth}/{userCardDetails.expiryYear} &nbsp;
</h4>
</>}
/>
<br />
{(!customerTransactions || customerTransactions.length === 0) && <Card
title={__('Tip History')}
subtitle={__('You have not sent any tips yet. When you do they will appear here. ')}
/>}
{customerTransactions && customerTransactions.length > 0 && <Card
title={__('Tip History')}
body={<><div className="table__wrapper">
<table className="table table--transactions">
<thead>
<tr>
<th className="date-header">{__('Date')}</th>
<th>{<>{__('Receiving Channel Name')}</>}</th>
<th>{__('Amount (USD)')} </th>
<th>{__('Anonymous')}</th>
</tr>
</thead>
<tbody>
{customerTransactions &&
customerTransactions.map((transaction) => (
<tr key={transaction.name + transaction.created_at}>
<td>{moment(transaction.created_at).format('LLL')}</td>
<td>{transaction.channel_name}</td>
<td>${transaction.tipped_amount / 100}</td>
<td>{transaction.private_tip ? 'Yes' : 'No'}</td>
</tr>
))}
</tbody>
</table>
</div></>}
/>}
</div>}
</Page>
);
}
}
export default CardVerify;
/* eslint-enable no-undef */
/* eslint-enable react/prop-types */

View file

@ -1,6 +1,7 @@
import { connect } from 'react-redux';
import { selectTotalBalance } from 'lbry-redux';
import { doOpenModal } from 'redux/actions/app';
import { selectUser } from 'redux/selectors/user';
import Wallet from './view';
const select = state => ({

View file

@ -3,6 +3,7 @@ import React from 'react';
import { withRouter } from 'react-router';
import WalletBalance from 'component/walletBalance';
import TxoList from 'component/txoList';
import StripeAccountConnection from 'component/stripeAccountConnection';
import Page from 'component/page';
import Spinner from 'component/spinner';
import YrblWalletEmpty from 'component/yrblWalletEmpty';
@ -33,6 +34,7 @@ const WalletPage = (props: Props) => {
) : (
<div className="card-stack">
<WalletBalance />
<StripeAccountConnection />
<TxoList search={search} />
</div>
)}

View file

@ -65,3 +65,6 @@
@import 'component/wunderbar';
@import 'component/yrbl';
@import 'component/empty';
@import 'component/stripe-card';
@import 'component/wallet-tip-send';

View file

@ -0,0 +1,331 @@
.date-header {
width: 30%;
}
.grey-text {
color: rgb(156, 163, 175)
}
/* Layout */
.sr-root {
display: flex;
flex-direction: row;
width: 100%;
max-width: 980px;
padding: 48px;
align-content: center;
justify-content: center;
height: 400px;
margin: 0 auto;
}
.sr-picker {
display: flex;
justify-content: space-between;
margin-bottom: 25px;
}
.sr-picker button {
background: none !important;
border: none;
padding: 0 !important;
/*optional*/
font-family: arial, sans-serif;
/*input has OS specific font-family*/
color: var(--accent-color);
cursor: pointer;
width: 75px;
box-shadow: none;
border-radius: 0;
}
.sr-picker button:hover,
.sr-picker button:focus,
.sr-picker button.selected {
outline: none;
box-shadow: none;
border-radius: 0;
}
.sr-picker button:hover,
.sr-picker button:focus {
border-bottom: 2px solid var(--secondary-color);
}
.sr-picker button.selected {
border-bottom: 2px solid var(--accent-color);
}
.sr-field-error {
color: var(--font-color);
text-align: left;
font-size: 13px;
line-height: 17px;
margin-top: 12px;
max-width: 269px;
}
/* Inputs */
.sr-input {
border: 1px solid var(--gray-border);
border-radius: var(--radius);
padding: 5px 12px;
height: 44px;
width: 100%;
transition: box-shadow 0.2s ease;
background: white;
-moz-appearance: none;
-webkit-appearance: none;
appearance: none;
width:284px;
}
.sr-input:focus {
box-shadow: 0 0 0 1px rgba(50, 151, 211, 0.3), 0 1px 1px 0 rgba(0, 0, 0, 0.07),
0 0 0 4px rgba(50, 151, 211, 0.3);
outline: none;
z-index: 9;
}
.sr-input::placeholder {
color: var(--gray-light);
}
.sr-result {
height: 44px;
-webkit-transition: height 1s ease;
-moz-transition: height 1s ease;
-o-transition: height 1s ease;
transition: height 1s ease;
color: var(--font-color);
overflow: auto;
}
.sr-result code {
overflow: scroll;
}
.sr-result.expand {
height: 350px;
}
.sr-combo-inputs-row {
box-shadow: 0px 0px 0px 0.5px rgba(50, 50, 93, 0.1),
0px 2px 5px 0px rgba(50, 50, 93, 0.1), 0px 1px 1.5px 0px rgba(0, 0, 0, 0.07);
border-radius: 7px;
}
/* Form */
.sr-form-row {
margin: 16px 0;
}
//label {
// font-size: 13px;
// font-weight: 500;
// margin-bottom: 8px;
// display: inline-block;
//}
/* Buttons and links */
button.linkButton {
background-color: rgb(30, 166, 114);
border-radius: 6px;
color: white;
border: 0;
padding: 12px 16px;
margin-top: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
display: block;
box-shadow: 0px 4px 5.5px 0px rgba(0, 0, 0, 0.07);
width: 100%;
max-width:280px;
}
button.linkButton:hover {
filter: contrast(115%);
}
button.linkButton:active {
transform: translateY(0px) scale(0.98);
filter: brightness(0.9);
}
button.linkButton:disabled {
opacity: 0.5;
cursor: none;
}
//a {
// color: var(--link-color);
// text-decoration: none;
// transition: all 0.2s ease;
//}
//
//a:hover {
// filter: brightness(0.8);
//}
//
//a:active {
// filter: brightness(0.5);
//}
/* Code block */
code,
pre {
font-family: "SF Mono", "IBM Plex Mono", "Menlo", monospace;
font-size: 12px;
}
/* Stripe Element placeholder */
.sr-card-element {
padding-top: 12px;
}
/* Responsiveness */
@media (max-width: 720px) {
.sr-root {
flex-direction: column;
justify-content: flex-start;
padding: 48px 20px;
min-width: 320px;
}
.sr-header__logo {
background-position: center;
}
.sr-payment-summary {
text-align: center;
}
.sr-payment-form {
padding: 10px 34px;
}
.sr-content {
display: none;
}
.sr-main {
width: 100%;
height: 305px;
box-shadow: 0px 0px 0px 0.5px rgba(50, 50, 93, 0.1),
0px 2px 5px 0px rgba(50, 50, 93, 0.1),
0px 1px 1.5px 0px rgba(0, 0, 0, 0.07);
border-radius: 6px;
padding: 10px 20px !important;
}
}
/* todo: spinner/processing state, errors, animations */
.spinner,
.spinner:before,
.spinner:after {
border-radius: 50%;
}
.spinner {
color: #ffffff;
font-size: 22px;
text-indent: -99999px;
margin: 0px auto;
position: relative;
width: 20px;
height: 20px;
box-shadow: inset 0 0 0 2px;
-webkit-transform: translateZ(0);
-ms-transform: translateZ(0);
transform: translateZ(0);
}
.spinner:before,
.spinner:after {
position: absolute;
content: "";
}
.spinner:before {
width: 10.4px;
height: 20.4px;
background: var(--accent-color);
border-radius: 20.4px 0 0 20.4px;
top: -0.2px;
left: -0.2px;
-webkit-transform-origin: 10.4px 10.2px;
transform-origin: 10.4px 10.2px;
-webkit-animation: loading 2s infinite ease 1.5s;
animation: loading 2s infinite ease 1.5s;
}
.spinner:after {
width: 10.4px;
height: 10.2px;
background: var(--accent-color);
border-radius: 0 10.2px 10.2px 0;
top: -0.1px;
left: 10.2px;
-webkit-transform-origin: 0px 10.2px;
transform-origin: 0px 10.2px;
-webkit-animation: loading 2s infinite ease;
animation: loading 2s infinite ease;
}
@-webkit-keyframes loading {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
@keyframes loading {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
/* Animated form */
.sr-root {
animation: 0.4s form-in;
animation-fill-mode: both;
animation-timing-function: ease;
}
.hidden {
display: none;
}
@keyframes field-in {
0% {
opacity: 0;
transform: translateY(8px) scale(0.95);
}
100% {
opacity: 1;
transform: translateY(0px) scale(1);
}
}
@keyframes form-in {
0% {
opacity: 0;
transform: scale(0.98);
}
100% {
opacity: 1;
transform: scale(1);
}
}
.sr-payment-form {
padding: 10px 35px;
padding-bottom:28px;
}
.payment-details {
font-size:16px;
margin-bottom:4px;
}
.sr-field-error {
font-size:15px;
}
//.successCard, .toConfirmCard {
// max-width: 94%
//}

View file

@ -0,0 +1,4 @@
.add-card-prompt {
margin-top: -21px;
margin-bottom: -21px;
}