diff --git a/ui/component/fileActions/index.js b/ui/component/fileActions/index.js index 38eb6a723..e93a4ad36 100644 --- a/ui/component/fileActions/index.js +++ b/ui/component/fileActions/index.js @@ -5,6 +5,7 @@ import { selectHasChannels, makeSelectTagInClaimOrChannelForUri, selectClaimIsNsfwForUri, + selectPreorderTag, } from 'redux/selectors/claims'; import { makeSelectStreamingUrlForUri } from 'redux/selectors/file_info'; import { doPrepareEdit } from 'redux/actions/publish'; @@ -32,6 +33,7 @@ const select = (state, props) => { streamingUrl: makeSelectStreamingUrlForUri(uri)(state), disableDownloadButton: makeSelectTagInClaimOrChannelForUri(uri, DISABLE_DOWNLOAD_BUTTON_TAG)(state), isMature: selectClaimIsNsfwForUri(state, uri), + isAPreorder: selectPreorderTag(state, props.uri), }; }; diff --git a/ui/component/fileActions/view.jsx b/ui/component/fileActions/view.jsx index dff8123f8..5ad70b2c9 100644 --- a/ui/component/fileActions/view.jsx +++ b/ui/component/fileActions/view.jsx @@ -35,6 +35,7 @@ type Props = { doToast: (data: { message: string }) => void, doDownloadUri: (uri: string) => void, isMature: boolean, + isAPreorder: boolean, }; export default function FileActions(props: Props) { @@ -54,6 +55,7 @@ export default function FileActions(props: Props) { doToast, doDownloadUri, isMature, + isAPreorder, } = props; const { @@ -115,7 +117,7 @@ export default function FileActions(props: Props) {
{ENABLE_FILE_REACTIONS && } - + { !isAPreorder && } diff --git a/ui/component/preorderButton/index.js b/ui/component/preorderButton/index.js new file mode 100644 index 000000000..f8fb2f42d --- /dev/null +++ b/ui/component/preorderButton/index.js @@ -0,0 +1,23 @@ +import { connect } from 'react-redux'; +import { selectPreorderTag, selectClaimForUri, selectClaimIsMine } from 'redux/selectors/claims'; +import ClaimTags from './view'; +import { doOpenModal } from 'redux/actions/app'; +import * as SETTINGS from 'constants/settings'; +import { selectClientSetting } from 'redux/selectors/settings'; + +const select = (state, props) => { + const claim = selectClaimForUri(state, props.uri); + + return { + preorderTag: selectPreorderTag(state, props.uri), + claimIsMine: selectClaimIsMine(state, claim), + claim, + preferredCurrency: selectClientSetting(state, SETTINGS.PREFERRED_CURRENCY), + }; +}; + +const perform = { + doOpenModal, +}; + +export default connect(select, perform)(ClaimTags); diff --git a/ui/component/preorderButton/view.jsx b/ui/component/preorderButton/view.jsx new file mode 100644 index 000000000..e0345eedf --- /dev/null +++ b/ui/component/preorderButton/view.jsx @@ -0,0 +1,111 @@ +// @flow +import * as React from 'react'; +import * as MODALS from 'constants/modal_types'; +import Button from 'component/button'; +import * as ICONS from 'constants/icons'; +import { Lbryio } from 'lbryinc'; +import { getStripeEnvironment } from 'util/stripe'; +let stripeEnvironment = getStripeEnvironment(); + +type Props = { + preorderTag: string, + doOpenModal: (string, {}) => void, + claim: StreamClaim, + uri: string, + claimIsMine: boolean, + preferredCurrency: string, +}; + +export default function PreorderButton(props: Props) { + const { preorderTag, doOpenModal, uri, claim, claimIsMine, preferredCurrency } = props; + + const claimId = claim.claim_id; + const myUpload = claimIsMine; + + const [hasAlreadyPreordered, setHasAlreadyPreordered] = React.useState(false); + + function getPaymentHistory() { + return Lbryio.call( + 'customer', + 'list', + { + environment: stripeEnvironment, + }, + 'post' + ); + } + + async function checkIfAlreadyPurchased() { + try { + // get card payments customer has made + let customerTransactionResponse = await getPaymentHistory(); + + let matchingTransaction = false; + for (const transaction of customerTransactionResponse) { + if (claimId === transaction.source_claim_id) { + matchingTransaction = true; + } + } + + if (matchingTransaction) { + setHasAlreadyPreordered(true); + } + } catch (err) { + console.log(err); + } + } + + let fiatIconToUse = ICONS.FINANCE; + let fiatSymbolToUse = '$'; + if (preferredCurrency === 'EUR') { + fiatIconToUse = ICONS.EURO; + fiatSymbolToUse = '€'; + } + + // populate customer payment data + React.useEffect(() => { + checkIfAlreadyPurchased(); + }, []); + + return ( + <> + {preorderTag && !hasAlreadyPreordered && !myUpload && (
+
)} + {preorderTag && hasAlreadyPreordered && !myUpload && (
+
)} + {preorderTag && myUpload && (
+
)} + + ); +} diff --git a/ui/component/preorderContent/index.js b/ui/component/preorderContent/index.js new file mode 100644 index 000000000..d1f445aa6 --- /dev/null +++ b/ui/component/preorderContent/index.js @@ -0,0 +1,45 @@ +import { connect } from 'react-redux'; +import { + selectClaimForUri, + selectPreorderTag, +} from 'redux/selectors/claims'; +import { doHideModal } from 'redux/actions/app'; +import { preOrderPurchase } from 'redux/actions/wallet'; +import { selectClientSetting } from 'redux/selectors/settings'; +import { selectActiveChannelClaim } from 'redux/selectors/app'; +import { withRouter } from 'react-router'; +import * as SETTINGS from 'constants/settings'; +import { getChannelIdFromClaim, getChannelNameFromClaim } from 'util/claim'; +import WalletSendTip from './view'; + +const select = (state, props) => { + const { uri } = props; + + const claim = selectClaimForUri(state, uri, false); + const { claim_id: claimId, value_type: claimType } = claim || {}; + + // setup variables for backend tip API + const channelClaimId = getChannelIdFromClaim(claim); + const tipChannelName = getChannelNameFromClaim(claim); + + const activeChannelClaim = selectActiveChannelClaim(state); + const { name: activeChannelName, claim_id: activeChannelId } = activeChannelClaim || {}; + + return { + activeChannelName, + activeChannelId, + claimId, + claimType, + channelClaimId, + tipChannelName, + preferredCurrency: selectClientSetting(state, SETTINGS.PREFERRED_CURRENCY), + preorderTag: selectPreorderTag(state, props.uri), + }; +}; + +const perform = { + doHideModal, + preOrderPurchase, +}; + +export default withRouter(connect(select, perform)(WalletSendTip)); diff --git a/ui/component/preorderContent/view.jsx b/ui/component/preorderContent/view.jsx new file mode 100644 index 000000000..0e9dc79cb --- /dev/null +++ b/ui/component/preorderContent/view.jsx @@ -0,0 +1,190 @@ +// @flow +import { Form } from 'component/common/form'; +import { Lbryio } from 'lbryinc'; +import * as PAGES from 'constants/pages'; +import Button from 'component/button'; +import Card from 'component/common/card'; +import React from 'react'; + +import { getStripeEnvironment } from 'util/stripe'; +const stripeEnvironment = getStripeEnvironment(); + +type TipParams = { tipAmount: number, tipChannelName: string, channelClaimId: string }; +type UserParams = { activeChannelName: ?string, activeChannelId: ?string }; + +type Props = { + activeChannelId?: string, + activeChannelName?: string, + claimId: string, + claimType?: string, + channelClaimId?: string, + tipChannelName?: string, + claimIsMine: boolean, + isSupport: boolean, + isTipOnly?: boolean, + customText?: string, + doHideModal: () => void, + setAmount?: (number) => void, + preferredCurrency: string, + preOrderPurchase: ( + TipParams, + anonymous: boolean, + UserParams, + claimId: string, + stripe: ?string, + preferredCurrency: string, + ?(any) => Promise, + ?(any) => void + ) => void, + preorderTag: string, + checkIfAlreadyPurchased: () => void, +}; + +export default function PreorderContent(props: Props) { + const { + activeChannelId, + activeChannelName, + claimId, + channelClaimId, + tipChannelName, + doHideModal, + preOrderPurchase, + preferredCurrency, + preorderTag, + checkIfAlreadyPurchased, + } = props; + + // set the purchase amount once the preorder tag is selected + React.useEffect(() => { + setTipAmount(Number(preorderTag)); + }, [preorderTag]); + + /** STATE **/ + const [tipAmount, setTipAmount] = React.useState(0); + + const [waitingForBackend, setWaitingForBackend] = React.useState(false); + + const [hasCardSaved, setHasSavedCard] = React.useState(true); + + // check if user has a payment method saved + React.useEffect(() => { + if (!stripeEnvironment) return; + + 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)); + }); + }, [setHasSavedCard]); + + // text for modal header + const titleText = 'Preorder Your Content'; + + // icon to use or explainer text to show per tab + let explainerText = + 'This content is not available yet but you' + ' can pre-order it now so you can access it as soon as it goes live'; + + // when the form button is clicked + function handleSubmit() { + const tipParams: TipParams = { + tipAmount, + tipChannelName: tipChannelName || '', + channelClaimId: channelClaimId || '', + }; + const userParams: UserParams = { activeChannelName, activeChannelId }; + + async function checkIfFinished() { + await checkIfAlreadyPurchased(); + doHideModal(); + } + + setWaitingForBackend(true); + + // hit backend to send tip + preOrderPurchase( + tipParams, + !activeChannelId, + userParams, + claimId, + stripeEnvironment, + preferredCurrency, + checkIfFinished, + doHideModal + ); + } + + let fiatSymbolToUse = '$'; + if (preferredCurrency === 'EUR') { + fiatSymbolToUse = '€'; + } + + function buildButtonText() { + return `Preorder your content for ${fiatSymbolToUse}${tipAmount.toString()}`; + } + + return ( +
+ {!waitingForBackend && ( + + {/* short explainer under the button */} +
{explainerText}
+ + } + actions={ + // confirm purchase card + <> + {/* */} +
+
+ + )} +
+ + } + /> + )} + {/* processing payment card */} + {waitingForBackend && ( + + {/* short explainer under the button */} +
{'Processing your purchase...'}
+ + } + /> + )} + + ); +} diff --git a/ui/component/walletFiatAccountHistory/index.js b/ui/component/walletFiatAccountHistory/index.js index 8fed953a8..deecbf411 100644 --- a/ui/component/walletFiatAccountHistory/index.js +++ b/ui/component/walletFiatAccountHistory/index.js @@ -1,3 +1,3 @@ -import FiatAccountHistory from './view'; +import WalletFiatAccountHistory from './view'; -export default FiatAccountHistory; +export default WalletFiatAccountHistory; diff --git a/ui/component/walletFiatAccountHistory/view.jsx b/ui/component/walletFiatAccountHistory/view.jsx index 317e8a14f..b10f09ced 100644 --- a/ui/component/walletFiatAccountHistory/view.jsx +++ b/ui/component/walletFiatAccountHistory/view.jsx @@ -8,17 +8,13 @@ type Props = { transactions: any, }; -const WalletBalance = (props: Props) => { +const WalletFiatAccountHistory = (props: Props) => { // receive transactions from parent component const { transactions } = props; - let accountTransactions; - - // reverse so most recent payments come first - if (transactions && transactions.length) { - accountTransactions = transactions.reverse(); - } + let accountTransactions = transactions; + // TODO: should add pagination here // if there are more than 10 transactions, limit it to 10 for the frontend // if (accountTransactions && accountTransactions.length > 10) { // accountTransactions.length = 10; @@ -104,4 +100,4 @@ const WalletBalance = (props: Props) => { ); }; -export default WalletBalance; +export default WalletFiatAccountHistory; diff --git a/ui/component/walletFiatPaymentHistory/view.jsx b/ui/component/walletFiatPaymentHistory/view.jsx index f52fa61fc..2ada92275 100644 --- a/ui/component/walletFiatPaymentHistory/view.jsx +++ b/ui/component/walletFiatPaymentHistory/view.jsx @@ -11,12 +11,28 @@ type Props = { transactions: any, }; -const WalletBalance = (props: Props) => { +const WalletFiatPaymentHistory = (props: Props) => { // receive transactions from parent component const { transactions: accountTransactions } = props; const [lastFour, setLastFour] = React.useState(); + function getSymbol(transaction) { + if (transaction.currency === 'eur') { + return '€'; + } else { + return '$'; + } + } + + function getCurrencyIso(transaction) { + if (transaction.currency === 'eur') { + return 'EUR'; + } else { + return 'USD'; + } + } + function getCustomerStatus() { return Lbryio.call( 'customer', @@ -53,7 +69,7 @@ const WalletBalance = (props: Props) => { {__('Date')} {<>{__('Receiving Channel Name')}} {__('Tip Location')} - {__('Amount (USD)')} + {__('Amount')} {__('Card Last 4')} {__('Anonymous')} @@ -88,7 +104,10 @@ const WalletBalance = (props: Props) => { /> {/* how much tipped */} - ${transaction.tipped_amount / 100} + + {getSymbol(transaction)} + {transaction.tipped_amount / 100} {getCurrencyIso(transaction)} + {/* TODO: this is incorrect need it per transactions not per user */} {/* last four of credit card */} {lastFour} @@ -108,4 +127,4 @@ const WalletBalance = (props: Props) => { ); }; -export default WalletBalance; +export default WalletFiatPaymentHistory; diff --git a/ui/constants/modal_types.js b/ui/constants/modal_types.js index baa1adba8..ba46bc93a 100644 --- a/ui/constants/modal_types.js +++ b/ui/constants/modal_types.js @@ -19,6 +19,7 @@ export const REWARD_GENERATED_CODE = 'reward_generated_code'; export const AFFIRM_PURCHASE = 'affirm_purchase'; export const CONFIRM_CLAIM_REVOKE = 'confirm_claim_revoke'; export const SEND_TIP = 'send_tip'; +export const PREORDER_CONTENT = 'preorder_content'; export const CONFIRM_SEND_TIP = 'confirm_send_tip'; export const REPOST = 'repost'; export const SOCIAL_SHARE = 'social_share'; diff --git a/ui/modal/modalPreorderContent/index.js b/ui/modal/modalPreorderContent/index.js new file mode 100644 index 000000000..6200986d6 --- /dev/null +++ b/ui/modal/modalPreorderContent/index.js @@ -0,0 +1,9 @@ +import { connect } from 'react-redux'; +import { doHideModal } from 'redux/actions/app'; +import ModalSendTip from './view'; + +const perform = { + doHideModal, +}; + +export default connect(null, perform)(ModalSendTip); diff --git a/ui/modal/modalPreorderContent/view.jsx b/ui/modal/modalPreorderContent/view.jsx new file mode 100644 index 000000000..4354b4d7f --- /dev/null +++ b/ui/modal/modalPreorderContent/view.jsx @@ -0,0 +1,28 @@ +// @flow +import React from 'react'; +import { Modal } from 'modal/modal'; +import PreorderContent from 'component/preorderContent'; + +type Props = { + uri: string, + doHideModal: () => void, + checkIfAlreadyPurchased: () => void, +}; + +class ModalSendTip extends React.PureComponent { + render() { + const { uri, doHideModal, checkIfAlreadyPurchased } = this.props; + + return ( + + + + ); + } +} + +export default ModalSendTip; diff --git a/ui/modal/modalRouter/view.jsx b/ui/modal/modalRouter/view.jsx index 407101658..f51f4daee 100644 --- a/ui/modal/modalRouter/view.jsx +++ b/ui/modal/modalRouter/view.jsx @@ -38,6 +38,7 @@ const MAP = Object.freeze({ [MODALS.MIN_CHANNEL_AGE]: lazyImport(() => import('modal/modalMinChannelAge' /* webpackChunkName: "modalMinChannelAge" */)), [MODALS.MOBILE_SEARCH]: lazyImport(() => import('modal/modalMobileSearch' /* webpackChunkName: "modalMobileSearch" */)), [MODALS.PHONE_COLLECTION]: lazyImport(() => import('modal/modalPhoneCollection' /* webpackChunkName: "modalPhoneCollection" */)), + [MODALS.PREORDER_CONTENT]: lazyImport(() => import('modal/modalPreorderContent' /* webpackChunkName: "modalPreorderContent" */)), [MODALS.PUBLISH]: lazyImport(() => import('modal/modalPublish' /* webpackChunkName: "modalPublish" */)), [MODALS.PUBLISH_PREVIEW]: lazyImport(() => import('modal/modalPublishPreview' /* webpackChunkName: "modalPublishPreview" */)), [MODALS.REPOST]: lazyImport(() => import('modal/modalRepost' /* webpackChunkName: "modalRepost" */)), diff --git a/ui/page/file/view.jsx b/ui/page/file/view.jsx index 485e43f94..bc47e69ca 100644 --- a/ui/page/file/view.jsx +++ b/ui/page/file/view.jsx @@ -16,6 +16,7 @@ import Button from 'component/button'; import Empty from 'component/common/empty'; import SwipeableDrawer from 'component/swipeableDrawer'; import DrawerExpandButton from 'component/swipeableDrawerExpand'; +import PreorderButton from 'component/preorderButton'; import { useIsMobile, useIsMobileLandscape } from 'effects/use-screensize'; const CommentsList = lazyImport(() => import('component/commentsList' /* webpackChunkName: "comments" */)); @@ -224,6 +225,7 @@ export default function FilePage(props: Props) { {!isMarkdown && (
+ {claimIsMine && isLivestream && (

{__('Only visible to you')}

diff --git a/ui/redux/actions/wallet.js b/ui/redux/actions/wallet.js index 134b5199e..dfeecba4d 100644 --- a/ui/redux/actions/wallet.js +++ b/ui/redux/actions/wallet.js @@ -797,3 +797,55 @@ export const doSendCashTip = ( ); }); }; + +export const preOrderPurchase = ( + tipParams, + anonymous, + userParams, + claimId, + stripeEnvironment, + preferredCurrency, + successCallback, + failureCallback +) => (dispatch) => { + Lbryio.call( + 'customer', + 'tip', + { + // round to fix issues with floating point numbers + amount: Math.round(100 * tipParams.tipAmount), // convert from dollars to cents + creator_channel_name: tipParams.tipChannelName, // creator_channel_name + creator_channel_claim_id: tipParams.channelClaimId, + tipper_channel_name: userParams.activeChannelName, + tipper_channel_claim_id: userParams.activeChannelId, + currency: preferredCurrency || 'USD', + anonymous: anonymous, + source_claim_id: claimId, + environment: stripeEnvironment, + }, + 'post' + ) + .then((customerTipResponse) => { + dispatch( + doToast({ + message: __('Preorder completed successfully'), + subMessage: __("You will be able to see the content as soon as it's available!"), + // linkText: `${fiatSymbol}${tipParams.tipAmount} ⇒ ${tipParams.tipChannelName}`, + // linkTarget: '/wallet', + }) + ); + + if (successCallback) successCallback(customerTipResponse); + }) + .catch((error) => { + // show error message from Stripe if one exists (being passed from backend by Beamer's API currently) + dispatch( + doToast({ + message: error.message || __('Sorry, there was an error in processing your payment!'), + isError: true, + }) + ); + + if (failureCallback) failureCallback(error); + }); +}; diff --git a/ui/redux/selectors/claims.js b/ui/redux/selectors/claims.js index 773f89dd7..24774bcce 100644 --- a/ui/redux/selectors/claims.js +++ b/ui/redux/selectors/claims.js @@ -680,6 +680,11 @@ export const selectTagsForUri = createCachedSelector(selectMetadataForUri, (meta return metadata && metadata.tags ? metadata.tags.filter((tag) => !INTERNAL_TAGS.includes(tag)) : []; })((state, uri) => String(uri)); +export const selectPreorderTag = createCachedSelector(selectMetadataForUri, (metadata: ?GenericMetadata) => { + const matchingTag = metadata && metadata.tags && metadata.tags.find((tag) => tag.includes('preorder:')); + if (matchingTag) return matchingTag.slice(9); +})((state, uri) => String(uri)); + export const selectFetchingClaimSearchByQuery = (state: State) => selectState(state).fetchingClaimSearchByQuery || {}; export const selectFetchingClaimSearch = createSelector( diff --git a/ui/scss/all.scss b/ui/scss/all.scss index 062d92ae0..5e218026b 100644 --- a/ui/scss/all.scss +++ b/ui/scss/all.scss @@ -46,6 +46,7 @@ @import 'component/notification'; @import 'component/nudge'; @import 'component/pagination'; +@import 'component/preorder-button'; @import 'component/post'; @import 'component/purchase'; @import 'component/placeholder'; diff --git a/ui/scss/component/_preorder-button.scss b/ui/scss/component/_preorder-button.scss new file mode 100644 index 000000000..314910790 --- /dev/null +++ b/ui/scss/component/_preorder-button.scss @@ -0,0 +1,55 @@ +.preorder-button { + background-color: var(--color-primary); + margin-top: 2px; + margin-bottom: 2px; + + svg { + stroke: white; + } +} + +.preorder-content-modal { + max-width: 580px; +} + +section.preorder-content-modal { + h2.card__title, .section__subtitle, .handle-submit-area { + text-align: center; + } + + .button { + margin: 0 auto; + } + + .card__title-section { + margin: 0 auto; + } + + .section__subtitle { + max-width: 421px; + } + + .card__main-actions { + padding-top: 0px; + } + + .add-card-prompt { + margin: 0 auto; + margin-top: 11px; + } +} + + +section.preorder-content-modal-loading { + h2.card__title, .section__subtitle { + text-align: center; + } + + .card__title-section { + margin: 0 auto; + } + + .section__subtitle { + margin-top: 27px; + } +}