Preorder content functionality (#1743)

* adding preorder button

* adding preorder modal

* frontend mostly done

* check if its already purchased

* refresh page after purchase

* smooth out purchase process

* check if user has card saved

* handle case where its the users own upload

* fix transaction listing order bug

* cleaning up code for merge

* fix lint errors

* fix flow errors

* allow eur purchases

* support eur on customer transaction page

* fix css
This commit is contained in:
mayeaux 2022-06-24 02:58:32 +02:00 committed by GitHub
parent 017df02816
commit 4f47779303
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 557 additions and 15 deletions

View file

@ -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),
};
};

View file

@ -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) {
<div className="media__actions">
{ENABLE_FILE_REACTIONS && <FileReactions uri={uri} />}
<ClaimSupportButton uri={uri} fileAction />
{ !isAPreorder && <ClaimSupportButton uri={uri} fileAction />}
<ClaimCollectionAddButton uri={uri} fileAction />

View file

@ -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);

View file

@ -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 && (<div>
<Button
// ref={buttonRef}
iconColor="red"
className={'preorder-button'}
// largestLabel={isMobile && shrinkOnMobile ? '' : subscriptionLabel}
icon={fiatIconToUse}
button="primary"
label={`Preorder now for ${fiatSymbolToUse}${preorderTag}`}
// title={titlePrefix}
requiresAuth
onClick={() => doOpenModal(MODALS.PREORDER_CONTENT, { uri, checkIfAlreadyPurchased })}
/>
</div>)}
{preorderTag && hasAlreadyPreordered && !myUpload && (<div>
<Button
// ref={buttonRef}
iconColor="red"
className={'preorder-button'}
// largestLabel={isMobile && shrinkOnMobile ? '' : subscriptionLabel}
button="primary"
label={'You have preordered this content'}
// title={titlePrefix}
requiresAuth
/>
</div>)}
{preorderTag && myUpload && (<div>
<Button
// ref={buttonRef}
iconColor="red"
className={'preorder-button'}
// largestLabel={isMobile && shrinkOnMobile ? '' : subscriptionLabel}
button="primary"
label={'You cannot preorder your own content'}
// title={titlePrefix}
/>
</div>)}
</>
);
}

View file

@ -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));

View file

@ -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<void>,
?(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 (
<Form onSubmit={handleSubmit}>
{!waitingForBackend && (
<Card
title={titleText}
className={'preorder-content-modal'}
subtitle={
<>
{/* short explainer under the button */}
<div className="section__subtitle">{explainerText}</div>
</>
}
actions={
// confirm purchase card
<>
{/* */}
<div className="handle-submit-area">
<Button
autoFocus
onClick={handleSubmit}
button="primary"
// label={__('Confirm')}
label={buildButtonText()}
disabled={!hasCardSaved}
/>
{!hasCardSaved && (
<>
<div className="add-card-prompt">
<Button navigate={`/$/${PAGES.SETTINGS_STRIPE_CARD}`} label={__('Add a Card')} button="link" />
{' ' + __('To Preorder Content')}
</div>
</>
)}
</div>
</>
}
/>
)}
{/* processing payment card */}
{waitingForBackend && (
<Card
title={titleText}
className={'preorder-content-modal-loading'}
subtitle={
<>
{/* short explainer under the button */}
<div className="section__subtitle">{'Processing your purchase...'}</div>
</>
}
/>
)}
</Form>
);
}

View file

@ -1,3 +1,3 @@
import FiatAccountHistory from './view';
import WalletFiatAccountHistory from './view';
export default FiatAccountHistory;
export default WalletFiatAccountHistory;

View file

@ -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;

View file

@ -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) => {
<th className="date-header">{__('Date')}</th>
<th className="channelName-header">{<>{__('Receiving Channel Name')}</>}</th>
<th className="location-header">{__('Tip Location')}</th>
<th className="amount-header">{__('Amount (USD)')} </th>
<th className="amount-header">{__('Amount')} </th>
<th className="card-header">{__('Card Last 4')}</th>
<th className="anonymous-header">{__('Anonymous')}</th>
</tr>
@ -88,7 +104,10 @@ const WalletBalance = (props: Props) => {
/>
</td>
{/* how much tipped */}
<td>${transaction.tipped_amount / 100}</td>
<td>
{getSymbol(transaction)}
{transaction.tipped_amount / 100} {getCurrencyIso(transaction)}
</td>
{/* TODO: this is incorrect need it per transactions not per user */}
{/* last four of credit card */}
<td>{lastFour}</td>
@ -108,4 +127,4 @@ const WalletBalance = (props: Props) => {
);
};
export default WalletBalance;
export default WalletFiatPaymentHistory;

View file

@ -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';

View file

@ -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);

View file

@ -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<Props> {
render() {
const { uri, doHideModal, checkIfAlreadyPurchased } = this.props;
return (
<Modal onAborted={doHideModal} isOpen type="card">
<PreorderContent
uri={uri}
onCancel={doHideModal}
checkIfAlreadyPurchased={checkIfAlreadyPurchased}
/>
</Modal>
);
}
}
export default ModalSendTip;

View file

@ -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" */)),

View file

@ -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 && (
<div className="file-page__secondary-content">
<section className="file-page__media-actions">
<PreorderButton uri={uri} />
{claimIsMine && isLivestream && (
<div className="livestream__creator-message">
<h4>{__('Only visible to you')}</h4>

View file

@ -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);
});
};

View file

@ -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(

View file

@ -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';

View file

@ -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;
}
}