lbry-desktop/ui/page/odyseeMembership/view.jsx
infinite-persistence 1e67a5cc7f
[on hold recsys work] Recommended For You (#782)
* Factor out lighthouse-result processing code for FYP re-use.

The FYP results will be in the same format as LH.

* Recsys: add ability to pass in specific uuid to use

For FYP, we want to pass the UUID as a param when searching for recommendations. The search comes before the recsys entry creation, so we need to generate the UUID first when searching, and then tell recsys to use that specific ID.

* Redux: fetch and store FYP

Note that the gid cannot be used as "hash" for the uri list -- it doesn't necessarily change when the list changes, so we can't use it to optimize redux.  For now, just always update/render when re-fetched.

* UI for FYP

* Mark rendered FYPs

* Pass the FYP ID down the same way as Collection ID

Not ideal, but at least it's in the same pattern as existing code for now. The whole prop-drilling problem with the claim components will be fixed together later.

* Include 'gid' and 'uuid' in recommendation search

* Allow users to mark recommendations that they dislike

* Pass auth-token to all FYP requests + remove beacon use

beacons are unreliable and often blocked

* Only show FYP for members

* FYP readme page

* small fixes

* fyp

Co-authored-by: Thomas Zarebczan <thomas.zarebczan@gmail.com>
2022-03-15 15:07:31 -04:00

730 lines
28 KiB
JavaScript

/* eslint-disable no-console */
// @flow
import React from 'react';
import moment from 'moment';
import Page from 'component/page';
import Spinner from 'component/spinner';
import { Lbryio } from 'lbryinc';
import { getStripeEnvironment } from 'util/stripe';
import * as ICONS from 'constants/icons';
import * as PAGES from 'constants/pages';
import * as MODALS from 'constants/modal_types';
import Card from 'component/common/card';
import MembershipSplash from 'component/membershipSplash';
import Button from 'component/button';
import ChannelSelector from 'component/channelSelector';
import PremiumBadge from 'component/common/premium-badge';
import I18nMessage from 'component/i18nMessage';
import useGetUserMemberships from 'effects/use-get-user-memberships';
import usePersistedState from 'effects/use-persisted-state';
import { fetchLocaleApi } from 'locale';
let stripeEnvironment = getStripeEnvironment();
const isDev = process.env.NODE_ENV !== 'production';
// odysee channel information since the memberships are only for Odysee
const odyseeChannelId = '80d2590ad04e36fb1d077a9b9e3a8bba76defdf8';
const odyseeChannelName = '@odysee';
type Props = {
history: { action: string, push: (string) => void, replace: (string) => void },
location: { search: string, pathname: string },
totalBalance: ?number,
openModal: (string, {}) => void,
activeChannelClaim: ?ChannelClaim,
channels: ?Array<ChannelClaim>,
claimsByUri: { [string]: any },
fetchUserMemberships: (claimIdCsv: string) => void,
incognito: boolean,
updateUserOdyseeMembershipStatus: () => void,
user: ?User,
};
const OdyseeMembershipPage = (props: Props) => {
const {
openModal,
activeChannelClaim,
channels,
claimsByUri,
fetchUserMemberships,
updateUserOdyseeMembershipStatus,
incognito,
user,
} = props;
const userChannelName = activeChannelClaim ? activeChannelClaim.name : '';
const userChannelClaimId = activeChannelClaim && activeChannelClaim.claim_id;
const [cardSaved, setCardSaved] = React.useState();
const [membershipOptions, setMembershipOptions] = React.useState();
const [userMemberships, setUserMemberships] = React.useState();
const [currencyToUse, setCurrencyToUse] = React.useState('usd');
const [canceledMemberships, setCanceledMemberships] = React.useState();
const [activeMemberships, setActiveMemberships] = React.useState();
const [purchasedMemberships, setPurchasedMemberships] = React.useState([]);
const [hasShownModal, setHasShownModal] = React.useState(false);
const [shouldFetchUserMemberships, setFetchUserMemberships] = React.useState(true);
const [apiError, setApiError] = React.useState(false);
const [showHelp, setShowHelp] = usePersistedState('premium-help-seen', true);
const hasMembership = activeMemberships && activeMemberships.length > 0;
const channelUrls = channels && channels.map((channel) => channel.permanent_url);
// check if membership data for user is already fetched, if it's needed then fetch it
useGetUserMemberships(shouldFetchUserMemberships, channelUrls, claimsByUri, (value) => {
fetchUserMemberships(value);
setFetchUserMemberships(false);
});
async function populateMembershipData() {
try {
// show the memberships the user is subscribed to
const response = await Lbryio.call(
'membership',
'mine',
{
environment: stripeEnvironment,
},
'post'
);
let activeMemberships = [];
let canceledMemberships = [];
let purchasedMemberships = [];
for (const membership of response) {
// if it's autorenewing it's considered 'active'
const isActive = membership.Membership.auto_renew;
if (isActive) {
activeMemberships.push(membership);
} else {
canceledMemberships.push(membership);
}
purchasedMemberships.push(membership.Membership.membership_id);
}
// hide the other membership options if there's already a purchased membership
if (activeMemberships.length > 0) {
setMembershipOptions(false);
}
setActiveMemberships(activeMemberships);
setCanceledMemberships(canceledMemberships);
setPurchasedMemberships(purchasedMemberships);
// update the state to show the badge
fetchUserMemberships(userChannelClaimId || '');
setUserMemberships(response);
} catch (err) {
setApiError(true);
console.log(err);
}
setFetchUserMemberships(false);
}
React.useEffect(() => {
if (!shouldFetchUserMemberships) setFetchUserMemberships(true);
}, [shouldFetchUserMemberships]);
// make calls to backend and populate all the data for the frontend
React.useEffect(function () {
// TODO: this should be refactored to make these calls in parallel
(async function () {
try {
// check if there is a payment method
const response = await Lbryio.call(
'customer',
'status',
{
environment: stripeEnvironment,
},
'post'
);
// hardcoded to first card
const hasAPaymentCard = Boolean(response && response.PaymentMethods && response.PaymentMethods[0]);
setCardSaved(hasAPaymentCard);
} catch (err) {
const customerDoesntExistError = 'user as customer is not setup yet';
if (err.message === customerDoesntExistError) {
setCardSaved(false);
} else {
setApiError(true);
console.log(err);
}
}
try {
// check the available membership for odysee.com
const response = await Lbryio.call(
'membership',
'list',
{
environment: stripeEnvironment,
channel_id: odyseeChannelId,
channel_name: odyseeChannelName,
},
'post'
);
// hide other options if there's already a membership
if (activeMemberships && activeMemberships.length > 0) {
setMembershipOptions(false);
} else {
setMembershipOptions(response);
}
} catch (err) {
setApiError(true);
console.log(err);
}
try {
// use EUR if from European continent
const localeResponse = await fetchLocaleApi();
const isFromEurope = localeResponse?.data?.continent === 'EU';
if (isFromEurope) setCurrencyToUse('eur');
} catch (err) {
console.log(err);
}
populateMembershipData();
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// we are still waiting from the backend if any of these are undefined
const stillWaitingFromBackend =
purchasedMemberships === undefined ||
cardSaved === undefined ||
membershipOptions === undefined ||
userMemberships === undefined ||
currencyToUse === undefined;
const formatDate = function (date) {
return moment(new Date(date)).format('MMMM DD YYYY');
};
// clear membership data
const deleteData = async function () {
await Lbryio.call(
'membership',
'clear',
{
environment: 'test',
},
'post'
);
// $FlowFixMe
location.reload();
};
// dont pass channel name and id when calling purchase
const noChannelsOrIncognitoMode = incognito || !channels;
// TODO: can clean this up, some repeating text
function buildPurchaseString(price, interval, plan) {
let featureString = '';
// generate different strings depending on other conditions
if (plan === 'Premium' && !noChannelsOrIncognitoMode) {
featureString = (
<I18nMessage
tokens={{
user_channel_name: <b className="membership-bolded">{userChannelName}</b>,
}}
>
Your badge will be shown for your %user_channel_name% channel in all areas of the app, and can be added to two
additional channels in the future for free.
</I18nMessage>
);
} else if (plan === 'Premium+' && !noChannelsOrIncognitoMode) {
// user has channel selected
featureString = (
<I18nMessage
tokens={{
user_channel_name: <b className="membership-bolded">{userChannelName}</b>,
}}
>
The no ads feature applies site-wide for all channels and your badge will be shown for your
%user_channel_name% channel in all areas of the app, and can be added to two additional channels in the future
for free.
</I18nMessage>
);
} else if (plan === 'Premium' && !channels) {
// user has no channels
featureString =
'You currently have no channels. To show your badge on a channel, please create a channel first. ' +
'If you register a channel later you will be able to show a badge for up to three channels.';
} else if (plan === 'Premium+' && !channels) {
// user has no channels
featureString =
'The no ads feature applies site-wide. You currently have no channels. To show your badge on a channel, please create a channel first. ' +
'If you register a channel later you will be able to show a badge for up to three channels.';
} else if (plan === 'Premium' && incognito) {
// user has incognito selected
featureString =
'You currently have no channel selected and will not have a badge be visible, if you want to show a badge you can select a channel now, ' +
'or you can show a badge for up to three channels in the future for free.';
} else if (plan === 'Premium+' && incognito) {
// user has incognito selected
featureString =
'The no ads feature applies site-wide. You currently have no channel selected and will not have a badge be visible, ' +
'if you want to show a badge you can select a channel now, or you can show a badge for up to three channels in the future for free.';
}
const priceDisplayString = __(
'You are purchasing a %displayInterval% %plan% membership, ' +
'that is active immediately and will renew %displayInterval% at a price of %displayCurrency% %currencySymbol%' +
price / 100 +
'. ',
{
displayInterval: interval + 'ly',
displayCurrency: currencyToUse.toUpperCase(),
currencySymbol: currencyToUse === 'usd' ? '$' : '€',
plan,
}
);
let purchaseString = (
<>
{priceDisplayString}
{featureString}
{__(
' You can cancel Premium at any time (no refunds) and you can also close this window and choose a different membership option.'
)}
</>
);
return purchaseString;
}
const purchaseMembership = function (e, membershipOption, price) {
e.preventDefault();
e.stopPropagation();
const planName = membershipOption.Membership.name;
const membershipId = e.currentTarget.getAttribute('membership-id');
const priceId = e.currentTarget.getAttribute('price-id');
const purchaseString = buildPurchaseString(price.unit_amount, price.recurring.interval, planName);
openModal(MODALS.CONFIRM_ODYSEE_MEMBERSHIP, {
membershipId,
userChannelClaimId: noChannelsOrIncognitoMode ? undefined : userChannelClaimId,
userChannelName: noChannelsOrIncognitoMode ? undefined : userChannelName,
priceId,
purchaseString,
plan: planName,
populateMembershipData,
setMembershipOptions,
updateUserOdyseeMembershipStatus,
user,
});
};
const cancelMembership = async function (e, membership) {
const membershipId = e.currentTarget.getAttribute('membership-id');
const cancellationString =
'You are cancelling your Odysee Premium. You will still have access to all the paid ' +
'features until the point of the expiration of your current membership, at which point you will not be charged ' +
'again and your membership will no longer be active. At this time, there is no way to subscribe to another membership if you cancel and there are no refunds.';
openModal(MODALS.CONFIRM_ODYSEE_MEMBERSHIP, {
membershipId,
hasMembership,
purchaseString: __(cancellationString),
populateMembershipData,
});
};
function convertIntervalVariableToString(price) {
const interval = price.recurring.interval;
if (interval === 'year') {
return __('Yearly');
} else if (interval === 'month') {
return __('Monthly');
}
}
function capitalizedInterval(planInterval) {
if (planInterval === 'year') {
return __('Year');
} else {
return __('Month');
}
}
function buildCurrencyDisplay(priceObject) {
let currencySymbol;
if (priceObject.currency === 'eur') {
currencySymbol = '€';
} else if (priceObject.currency === 'usd') {
currencySymbol = '$';
}
const currency = priceObject.currency.toUpperCase();
return currency + ' ' + currencySymbol;
}
const urlSearchParams = new URLSearchParams(window.location.search);
const params = Object.fromEntries(urlSearchParams.entries());
const { interval, plan } = params;
const planValue = params.plan;
// description to be shown under plan name
function getPlanDescription(plan, active?) {
if (plan === 'Premium') {
return 'Badge on profile, livestreaming, automatic rewards confirmation, and early access to new features';
// if there's more plans added this needs to be expanded
} else if (active) {
return 'All Premium features, and no ads';
} else {
return 'Badge on profile, livestreaming, automatic rewards confirmation, early access to new features, and no ads';
}
}
// add a bit of a delay otherwise it's a bit jarring
const timeoutValue = 300;
// if user already selected plan, wait a bit (so it's not jarring) and open modal
React.useEffect(() => {
if (!stillWaitingFromBackend && planValue && cardSaved) {
const delayTimeout = setTimeout(function () {
// clear query params
window.history.replaceState(null, null, window.location.pathname);
setHasShownModal(true);
// open confirm purchase
// $FlowFixMe
document.querySelector('[plan="' + plan + '"][interval="' + interval + '"]').click();
}, timeoutValue);
return () => clearTimeout(delayTimeout);
}
}, [stillWaitingFromBackend, planValue, cardSaved]);
const helpText = (
<div className="section__subtitle">
<p>
{__(
`First of all, thank you for considering or purchasing a membership, it means a ton to us! A few important details to know: `
)}
</p>
<p>
<ul>
<li>
{__(
`Exclusive and early access features include: recommended content on homepage, livestreaming, and the ability to post odysee hyperlinks + images in comments. Account is also automatically eligible for Rewards. More to come later.`
)}
</li>
<li>
{__(
`The yearly Premium+ membership has a discount compared to monthly, and Premium is only available yearly.`
)}
</li>
<li>{__(`These are limited time rates, so get in early!`)}</li>
<li>
{__(
`There may be higher tiers available in the future for creators and anyone else who wants to support us.`
)}
</li>
<li>
{__(`Badges will be displayed on a single channel to start, with an option to add on two more later on.`)}
</li>
<li>
{__(`Cannot upgrade or downgrade a membership at this time. Refunds are not available. Choose wisely.`)}
</li>
</ul>
</p>
</div>
);
return (
<>
<Page className="premium-wrapper">
{/** splash frontend **/}
{!stillWaitingFromBackend && !apiError && purchasedMemberships.length === 0 && !planValue && !hasShownModal ? (
<MembershipSplash pageLocation={'confirmPage'} currencyToUse={currencyToUse} />
) : (
/** odysee membership page **/
<div className={'card-stack'}>
{!stillWaitingFromBackend && cardSaved !== false && (
<>
<h1 style={{ fontSize: '23px' }}>{__('Odysee Premium')}</h1>
{/* let user switch channel */}
<div style={{ marginTop: '10px' }}>
<ChannelSelector uri={activeChannelClaim && activeChannelClaim.permanent_url} />
{/* explainer help text */}
<Card
titleActions={
<Button
button="close"
icon={showHelp ? ICONS.UP : ICONS.DOWN}
onClick={() => setShowHelp(!showHelp)}
/>
}
title={__('Get More Information')}
subtitle={<>{__(`Expand to learn more about how Odysee Premium works`)} </>}
actions={showHelp && helpText}
className={'premium-explanation-text'}
/>
</div>
</>
)}
{/** available memberships **/}
{/* if they have a card and don't have a membership yet */}
{!stillWaitingFromBackend && membershipOptions && purchasedMemberships.length < 1 && cardSaved !== false && (
<>
<div className="card__title-section">
<h2 className="card__title">{__('Available Memberships')}</h2>
</div>
<Card>
{membershipOptions.map((membershipOption, i) => (
<>
<div key={i}>
{purchasedMemberships && !purchasedMemberships.includes(membershipOption.Membership.id) && (
<>
<div className="premium-option">
{/* plan title */}
<h4 className="membership_title">
{__(membershipOption.Membership.name)}
<PremiumBadge membership={membershipOption.Membership.name} />
</h4>
{/* plan description */}
<h4 className="membership_subtitle">
{__(getPlanDescription(membershipOption.Membership.name))}
</h4>
<>
{/* display different plans */}
{membershipOption.Prices.map((price) => (
<>
{/* dont show a monthly Premium membership option (yearly only) */}
{!(
price.recurring.interval === 'month' &&
membershipOption.Membership.name === 'Premium'
) && (
<>
{price.currency === currencyToUse && (
<div>
<h4 className="membership_info">
<b>{__('Interval')}:</b> {convertIntervalVariableToString(price)}
</h4>
<h4 className="membership_info">
<b>{__('Price')}:</b> {buildCurrencyDisplay(price)}
{price.unit_amount / 100}/{capitalizedInterval(price.recurring.interval)}
</h4>
<Button
button="primary"
onClick={(e) => purchaseMembership(e, membershipOption, price)}
membership-id={membershipOption.Membership.id}
membership-subscription-period={membershipOption.Membership.type}
price-id={price.id}
className="membership_button"
label={__('Join via %displayInterval% membership', {
displayInterval: price.recurring.interval,
})}
icon={ICONS.FINANCE}
interval={price.recurring.interval}
plan={membershipOption.Membership.name}
/>
</div>
)}
</>
)}
</>
))}
</>
</div>
</>
)}
</div>
</>
))}
</Card>
</>
)}
{!stillWaitingFromBackend && cardSaved === true && (
<>
<div className="card__title-section">
<h2 className="card__title">{__('Your Active Memberships')}</h2>
</div>
<Card>
{/** * list of active memberships from user ***/}
<div>
{/* <h1 style={{ fontSize: '19px' }}>Active Memberships</h1> */}
{!stillWaitingFromBackend && activeMemberships && activeMemberships.length === 0 && (
<>
<h4>{__('You currently have no active memberships')}</h4>
</>
)}
{/** active memberships **/}
{!stillWaitingFromBackend &&
activeMemberships &&
activeMemberships.map((membership) => (
<>
<div className="premium-option">
{/* membership name */}
<h4 className="membership_title">
{membership.MembershipDetails.name}
<PremiumBadge membership={membership.MembershipDetails.name} />
</h4>
{/* description section */}
<h4 className="membership_subtitle">
{__(getPlanDescription(membership.MembershipDetails.name, 'active'))}
</h4>
{/* registered on */}
<h4 className="membership_info">
<b>{__('Registered On:')}</b> {formatDate(membership.Membership.created_at)}
</h4>
{/* autorenews at */}
<h4 className="membership_info">
<b>{__('Auto-Renews On')}:</b>{' '}
{formatDate(membership.Subscription.current_period_end * 1000)}
</h4>
{!stillWaitingFromBackend && membership.type === 'yearly' && (
<>
<h4 className="membership_info">
<b>{__('Membership Period Options:')}</b> {__('Yearly')}
</h4>
{/* TODO: this looks wrong, should support EUR as well */}
<h4 className="membership_info">
${(membership.cost_usd * 12) / 100} {__('USD For A One Year Membership')} ($
{membership.cost_usd / 100} {__('Per Month')})
</h4>
</>
)}
{/* cancel membership button */}
<Button
button="alt"
membership-id={membership.Membership.membership_id}
onClick={(e) => cancelMembership(e, membership)}
className="cancel-membership-button"
label={__('Cancel membership')}
icon={ICONS.FINANCE}
/>
</div>
</>
))}
</div>
</Card>
<>
{/** canceled memberships **/}
<div className="card__title-section">
<h2 className="card__title">{__('Canceled Memberships')}</h2>
</div>
<Card>
{canceledMemberships && canceledMemberships.length === 0 && (
<>
<h4>{__('You currently have no canceled memberships')}</h4>
</>
)}
{canceledMemberships &&
canceledMemberships.map((membership) => (
<>
<h4 className="membership_title">
{membership.MembershipDetails.name}
<PremiumBadge membership={membership.MembershipDetails.name} />
</h4>
<div className="premium-option">
<h4 className="membership_info">
<b>{__('Registered On')}:</b> {formatDate(membership.Membership.created_at)}
</h4>
<h4 className="membership_info">
<b>{__('Canceled On')}:</b> {formatDate(membership.Subscription.canceled_at * 1000)}
</h4>
<h4 className="membership_info">
<b>{__('Still Valid Until')}:</b> {formatDate(membership.Membership.expires)}
</h4>
</div>
</>
))}
</Card>
</>
</>
)}
{/** send user to add card if they don't have one yet */}
{!stillWaitingFromBackend && cardSaved === false && (
<div>
<br />
<h2 className={'getPaymentCard'}>
{__('Please save a card as a payment method so you can join Odysee Premium')}
</h2>
<h2 className={'getPaymentCard'}>{__('After the card is added, click Back')}</h2>
<Button
button="primary"
label={__('Add A Card')}
icon={ICONS.SETTINGS}
navigate={`/$/${PAGES.SETTINGS_STRIPE_CARD}`}
className="membership_button"
style={{ maxWidth: '151px' }}
/>
</div>
)}
{/** loading section **/}
{stillWaitingFromBackend && !apiError && (
<div className="main--empty">
<Spinner />
</div>
)}
{/** loading section **/}
{stillWaitingFromBackend && apiError && (
<div className="main--empty">
<h1 style={{ fontSize: '19px' }}>
{__('Sorry, there was an error, please contact support or try again later')}
</h1>
</div>
)}
{/** clear membership data (only available on dev) **/}
{isDev && cardSaved && purchasedMemberships.length > 0 && (
<>
<h1 style={{ marginTop: '30px', fontSize: '20px' }}>
{__('Clear Membership Data (Only Available On Dev)')}
</h1>
<div>
<Button
button="primary"
label={__('Clear Membership Data')}
icon={ICONS.SETTINGS}
className="membership_button"
onClick={deleteData}
/>
</div>
</>
)}
</div>
)}
</Page>
</>
);
};
export default OdyseeMembershipPage;