diff --git a/jam-ui/src/components/common/JKModalDialog.js b/jam-ui/src/components/common/JKModalDialog.js index 15821ba45..3466066a0 100644 --- a/jam-ui/src/components/common/JKModalDialog.js +++ b/jam-ui/src/components/common/JKModalDialog.js @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import PropTypes from 'prop-types'; const JKModalDialog = (args) => { - const { show, title, children, onToggle, showFooter, ...rest } = args; + const { show, title, children, onToggle, showFooter, modalFooterButtons, ...rest } = args; const [modal, setModal] = useState(show); const toggle = () => { @@ -24,7 +24,16 @@ const JKModalDialog = (args) => { {children} {showFooter && ( - + {modalFooterButtons ? ( + <> + {modalFooterButtons.map((button, index) => ( + + ))} + + ) : () } + )} diff --git a/jam-ui/src/components/dashboard/JKDashboardMain.js b/jam-ui/src/components/dashboard/JKDashboardMain.js index 550667592..90356df84 100644 --- a/jam-ui/src/components/dashboard/JKDashboardMain.js +++ b/jam-ui/src/components/dashboard/JKDashboardMain.js @@ -36,6 +36,7 @@ import JKMusicSessionsLobby from '../page/JKMusicSessionsLobby'; import JKEditProfile from '../page/JKEditProfile'; import JKEditAccount from '../page/JKEditAccount'; +import JKAccountSubscription from '../page/JKAccountSubscription'; //import loadable from '@loadable/component'; @@ -261,6 +262,7 @@ function JKDashboardMain() { + {/*Redirect*/} diff --git a/jam-ui/src/components/page/JKAccountSubscription.js b/jam-ui/src/components/page/JKAccountSubscription.js new file mode 100644 index 000000000..c2ce004f5 --- /dev/null +++ b/jam-ui/src/components/page/JKAccountSubscription.js @@ -0,0 +1,57 @@ +import React, { useState, useEffect } from 'react'; +import { Row, Col, Card, CardBody } from 'reactstrap'; +import FalconCardHeader from '../common/FalconCardHeader'; +import { useTranslation } from 'react-i18next'; + +import JKSubscriptionPlan from '../profile/JKSubscriptionPlan'; +import JKSubscriptionPlaytime from '../profile/JKSubscriptionPlaytime'; + +import { getSubscription } from '../../helpers/rest'; + +const JKAccountSubscription = () => { + const { t } = useTranslation('account'); + const [userPlan, setUserPlan] = useState(null); + + useEffect(() => { + getSubscription() + .then(resp => resp.json()) + .then(data => { + setUserPlan(data); + }) + .catch(error => { + console.log('subscriptionError', error); + }); + }, []); + + const getDisplayName = planCode => { + if (planCode == '') { + planCode = null; + } + const plan = window.gon.global.subscription_codes.find(plan => plan.id === planCode); + if (plan) { + return plan.name; + } + return `Unknown plan code=${planCode}`; + }; + + return ( + + + + {userPlan ? ( + + + + + + + + + + ) : 'Loading...' } + + + ); +}; + +export default JKAccountSubscription; diff --git a/jam-ui/src/components/page/JKEditAccount.js b/jam-ui/src/components/page/JKEditAccount.js index 82d2ac005..1c7f15fc2 100644 --- a/jam-ui/src/components/page/JKEditAccount.js +++ b/jam-ui/src/components/page/JKEditAccount.js @@ -20,7 +20,7 @@ const JKEditAccount = () => { - + diff --git a/jam-ui/src/components/profile/JKEditPassword.js b/jam-ui/src/components/profile/JKEditPassword.js index c2f94307e..9e51d0e7a 100644 --- a/jam-ui/src/components/profile/JKEditPassword.js +++ b/jam-ui/src/components/profile/JKEditPassword.js @@ -93,7 +93,7 @@ const JKEditPassword = ({ setAlert, toggleAlert }) => {
Password
- + {t('identity.password_form.help_text')} diff --git a/jam-ui/src/components/profile/JKSubscriptionPlan.js b/jam-ui/src/components/profile/JKSubscriptionPlan.js new file mode 100644 index 000000000..f4eca88a4 --- /dev/null +++ b/jam-ui/src/components/profile/JKSubscriptionPlan.js @@ -0,0 +1,230 @@ +import React, { useState, useEffect } from 'react'; +import { Card, CardHeader, CardBody, Form, FormGroup, Label } from 'reactstrap'; +import Select from 'react-select'; +import { useTranslation } from 'react-i18next'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { useForm, Controller, set } from 'react-hook-form'; +import { toast } from 'react-toastify'; + +import JKModalDialog from '../common/JKModalDialog'; + +import { getSubscription, changeSubscription } from '../../helpers/rest'; + +function JKSubscriptionPlan({ userPlan, setUserPlan, getDisplayName }) { + const { t } = useTranslation('account'); + + const [plans, setPlans] = useState([]); + const [submitting, setSubmitting] = useState(false); + const [alertText, setAlertText] = useState(''); + const [showAlert, setShowAlert] = useState(false); + const toggleAlert = () => setShowAlert(!showAlert); + + const { + handleSubmit, + control, + formState: { errors }, + setError, + setValue, + getValues + } = useForm({ + defaultValues: { + plan_code: '' + } + }); + + useEffect(() => { + if (userPlan) { + setValue('plan_code', userPlan.desired_plan_code); + } + }, [userPlan]); + + + useEffect(() => { + if (window.gon) { + const monthly = [{ value: 'monthly', isDisabled: true, label: '-------- MONTHLY PLANS --------' }]; + const yearly = [{ value: 'yearly', isDisabled: true, label: '-------- YEARLY PLANS --------' }]; + window.gon.global.subscription_codes.forEach(plan => { + if (plan.cycle === 'month') { + monthly.push({ value: plan.id || '', label: `${plan.name} (${plan.price.toFixed(2)}/${plan.cycle})` }); + } + if (plan.cycle === 'year') { + yearly.push({ value: plan.id || '', label: `${plan.name} (${plan.price.toFixed(2)}/${plan.cycle})` }); + } + }); + const all = [...monthly, ...yearly]; + setPlans(all); + } + }, [window.gon]); + + const handleChange = selectedOption => { + console.log('selectedOption', selectedOption); + setError('plan_code', { type: 'manual', message: '' }); + setValue('plan_code', selectedOption.value); + }; + + const getDisplayNamePrice = planCode => { + if (planCode == '') { + planCode = null; + } + const plan = window.gon.global.subscription_codes.find(plan => plan.id === planCode); + if (plan) { + return plan.price; + } + return `Unknown plan code=${planCode}`; + }; + + const getDisplayCycle = planCode => { + if (planCode == '') { + planCode = null; + } + for (const subscriptionCode of window.gon.global.subscription_codes) { + if (planCode === subscriptionCode.id) { + if (subscriptionCode.cycle === 'year') { + return 'annual'; + } else { + return subscriptionCode.cycle + 'ly'; + } + } + } + return `Unknown plan code=${planCode}`; + }; + + const onSubmit = () => { + const planCode = getValues('plan_code'); + if (planCode === null) { + setError('plan_code', { + type: 'manual', + message: t('subscription.current_plan.validations.subscription_plan.required') + }); + setSubmitting(false); + return; + } else if (planCode === '') { + setAlertText(t('subscription.alerts.changed_to_free_plan')); + toggleAlert(); + } else { + setAlertText( + `You have selected the ${getDisplayName(planCode).toUpperCase()} ${getDisplayCycle( + planCode + ).toUpperCase()} PLAN and will be charged US$${getDisplayNamePrice(planCode)}.` + ); + toggleAlert(); + } + }; + + const updateSubscription = () => { + toggleAlert(); + setSubmitting(true); + const planCode = getValues('plan_code'); + changeSubscription(planCode) + .then(resp => { + console.log('changeSubscription', resp); + if (resp.ok) { + getSubscription() + .then(resp => resp.json()) + .then(data => { + console.log('subscriptionData', data); + setUserPlan(data); + setValue('plan_code', data.desired_plan_code); + toast.success(t('subscription.alerts.changed_plan_successfully')); + }) + .catch(error => { + console.log('subscriptionError', error); + }); + } + }) + .catch(error => { + setAlertText(`${t('subscription.alerts.failed_to_change_plan')}: ${error}`); + console.log('changeSubscriptionError', error); + setValue('plan_code', userPlan.desired_plan_code); + }) + .finally(() => { + setSubmitting(false); + }); + }; + + const cancelUpdateSubscription = () => { + setValue('plan_code', userPlan.desired_plan_code); + setSubmitting(false); + toggleAlert(); + }; + + return ( + <> + + +
{t('subscription.current_plan.title')}
+
+ + {t('subscription.current_plan.help_text')} +
+ {plans && userPlan && userPlan.plan_code && ( + <> + + + { + const plan = plans.find(plan => plan.value === value); + if (!plan) { + return + ); + }} + /> + {errors.plan_code &&
{errors.plan_code.message}
} +
+
+ + {submitting && } +
+
+ + { t('subscription.current_plan.notice.part1') }{ t('subscription.current_plan.notice.click_here') }{ t('subscription.current_plan.notice.part2') } + +
+ + )} +
+
+
+ + {alertText} + + + ); +} + +export default JKSubscriptionPlan; diff --git a/jam-ui/src/components/profile/JKSubscriptionPlaytime.js b/jam-ui/src/components/profile/JKSubscriptionPlaytime.js new file mode 100644 index 000000000..f391b67d8 --- /dev/null +++ b/jam-ui/src/components/profile/JKSubscriptionPlaytime.js @@ -0,0 +1,204 @@ +import React, { useEffect, useState, useRef } from 'react'; +import { Card, CardHeader, CardBody, Label } from 'reactstrap'; +import { useTranslation } from 'react-i18next'; +import { formatDateShort } from '../../helpers/utils'; +import { get } from 'react-hook-form'; + +function JKSubscriptionPlaytime({ userPlan, getDisplayName }) { + const { t } = useTranslation('account'); + + const [explanation, setExplanation] = useState(''); + const [warning, setWarning] = useState(''); + const [billingAddendum, setBillingAddendum] = useState(''); // [TODO: addendum to the warning message about billing, if needed] + const showPaymentInfoRef = useRef(false); + + const displayTime = until_time => { + if (until_time < 0) return 'no time'; + + const untilTime = getTimeRemaining(until_time * 1000); + + let timeString = ''; + if (untilTime.days !== 0) timeString += `${untilTime.days} days, `; + if (untilTime.hours !== 0 || timeString.length > 0) timeString += `${untilTime.hours} hours, `; + if (untilTime.minutes !== 0 || timeString.length > 0) timeString += `${untilTime.minutes} minutes `; + if (timeString === '') timeString = 'now!'; + + return timeString; + }; + + const getTimeRemaining = t => { + if (t < 0) t = -t; + + const seconds = Math.floor((t / 1000) % 60); + const minutes = Math.floor((t / 1000 / 60) % 60); + const hours = Math.floor((t / (1000 * 60 * 60)) % 24); + const days = Math.floor(t / (1000 * 60 * 60 * 24)); + + return { + total: t, + days: days, + hours: hours, + minutes: minutes, + seconds: seconds + }; + }; + + const getDisplayNamePrice = planCode => { + if (planCode == '') { + planCode = null; + } + const plan = window.gon.global.subscription_codes.find(plan => plan.id === planCode); + if (plan) { + return plan.price; + } + return `Unknown plan code=${planCode}`; + }; + + const getDisplayCycle = planCode => { + if (planCode == '') { + planCode = null; + } + for (const subscriptionCode of window.gon.global.subscription_codes) { + if (planCode === subscriptionCode.id) { + if (subscriptionCode.cycle === 'year') { + return 'annual'; + } else { + return subscriptionCode.cycle + 'ly'; + } + } + } + return `Unknown plan code=${planCode}`; + }; + + const planNameWithCycle = planCode => { + return getDisplayName(planCode) + ' (' + getDisplayCycle(planCode) + ')'; + }; + + useEffect(() => { + if (userPlan) { + let expl, + note, + warning, + billingAddendumTxt, + playtimeTxt = ''; + const adminOverride = userPlan.admin_override_plan_code; + const inTrail = userPlan.in_trail; + const hasBillingInfo = userPlan.has_billing_info; + const effectivePlanName = planNameWithCycle(userPlan.plan_code); + const desiredPlanCode = userPlan.desired_plan_code; + const desiredPlanName = planNameWithCycle(desiredPlanCode); + const hasPendingSubscription = userPlan.subscription.pending_subscription; + const canceledSubscription = userPlan.subscription.remaining_billing_cycles === 0; + + if (adminOverride) { + expl = `You have a ${effectivePlanName} account until your gifted plan ends ${formatDateShort( + userPlan.admin_override_ends_at + )}.`; + } else if (inTrail) { + if (desiredPlanCode) { + if (hasBillingInfo) { + note = `Billing starts for the ${desiredPlanName} plan after the trial ends.`; + } else { + warning = `You will drop to the free plan after the trial ends because you have not yet entered payment info.`; + showPaymentInfoRef.current = true; + } + } else { + if (hasBillingInfo) { + warning = `You will drop to the free plan after the trial ends because you have not selected a plan.`; + } else { + warning = `You will drop to the free plan after the trial ends because you have not yet entered payment info or selected a plan.`; + showPaymentInfoRef.current = true; + } + } + expl = `You have a ${effectivePlanName} account until your trial ends ${formatDateShort( + userPlan.trial_ends_at + )}. ${note}`; + } else { + // NOT admin override and NOT in trial + if (desiredPlanCode && !userPlan.plan_code && !hasBillingInfo) { + expl = `You have successfully upgraded your plan to the ${desiredPlanName} level, thank you!`; + warning = `For this plan to take effect, you must provide a payment method (e.g. a credit card), for the monthly subscription charge. Please click the Update Payment Method button to do this now.`; + //show_payment_info = true + showPaymentInfoRef.current = true; + } else { + if (desiredPlanCode) { + if (!hasBillingInfo) { + //show_payment_info = true + showPaymentInfoRef.current = true; + expl = `You have successfully upgraded your plan to the ${desiredPlanName} level, thank you`; + warning = `However, you must provide a payment method (e.g. a credit card), for the monthly subscription charge. Please click the Update Payment Method button to do this now.`; + } else { + expl = `You are currently on the ${effectivePlanName} level, thank you!`; + } + } else { + //free plan situation - not much to go on about + expl = `You are currently on the ${effectivePlanName} plan.`; + } + } + } + + setExplanation(expl); + setWarning(warning); + + //billingAddendum + if (hasPendingSubscription) { + if (userPlan.subscription.plan.plan_code !== userPlan.plan_code) { + billingAddendumTxt = ` You have paid only for the ${planNameWithCycle( + userPlan.subscription.plan.plan_code + )} level for the current billing cycle, so there will be a change to the ${this.planNameWithCycle( + this.props.subscription.subscription.pending_subscription.plan.plan_code + )} level on the next billing cycle.`; + } else { + billingAddendumTxt = ` And your plan and billing will switch to the ${planNameWithCycle( + userPlan.subscription.pending_subscription.plan.plan_code + )} level on the next billing cycle.`; + } + } else if (canceledSubscription && userPlan.desired_plan_code === null && userPlan.plan_code !== null) { + billingAddendumTxt = ` However, your cancelled ${effectivePlanName} plan is still active until the end of the billing cycle. You will be billed a final time at the ${planNameWithCycle( + userPlan.subscription.plan.plan_code + )} at end of this billing cycle.`; + } else { + billingAddendumTxt = ''; + } + setBillingAddendum(billingAddendumTxt); + } + }, [userPlan]); + + return ( + + +
{t('subscription.play_time.title')}
+
+ +
+ {userPlan && userPlan.subscription_rules.remaining_month_play_time ? ( +
+

+ You have {planNameWithCycle(userPlan.subscription_rules.remaining_month_play_time)} remaining + this month. Only the time you spend in a session with 2 or more people uses your session play time. +

+
+ ) : ( +
+

You have unlimited play time.

+
+ )} +
+ +
+ + {explanation && ( +
+ + +
+ )} + {warning &&
} + +
+ + + ); +} + +export default JKSubscriptionPlaytime; diff --git a/jam-ui/src/helpers/rest.js b/jam-ui/src/helpers/rest.js index 7e3536bc6..761582bb1 100644 --- a/jam-ui/src/helpers/rest.js +++ b/jam-ui/src/helpers/rest.js @@ -1,3 +1,4 @@ +import { error } from 'is_js'; import apiFetch from './apiFetch'; export const getMusicians = page => { @@ -277,4 +278,26 @@ export const postUserAppInteraction = (userId, options) => { .then(response => resolve(response)) .catch(error => reject(error)); }); +} + + +export const getSubscription = () => { + return new Promise((resolve, reject) => { + apiFetch('/recurly/get_subscription') + .then(response => resolve(response)) + .catch(error => reject(error)) + }); +} + + +export const changeSubscription = (plan_code) => { + const options = {plan_code} + return new Promise((resolve, reject) => { + apiFetch('/recurly/change_subscription', { + method: 'POST', + body: JSON.stringify(options) + }) + .then(response => resolve(response)) + .catch(error => reject(error)); + }) } \ No newline at end of file diff --git a/jam-ui/src/helpers/utils.js b/jam-ui/src/helpers/utils.js index a8a9746b6..d795eb38e 100644 --- a/jam-ui/src/helpers/utils.js +++ b/jam-ui/src/helpers/utils.js @@ -234,4 +234,16 @@ export const currencyFormat = (num) => { return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' } ).format(num); +} + +const days = new Array("Sun", "Mon", "Tue", + "Wed", "Thu", "Fri", "Sat"); + +const months = new Array("January", "February", "March", + "April", "May", "June", "July", "August", "September", + "October", "November", "December"); + +export const formatDateShort = (dateString) => { + const date = dateString instanceof Date ? dateString : new Date(dateString); + return months[date.getMonth()] + ' ' + date.getDate() + ', ' + date.getFullYear(); } \ No newline at end of file diff --git a/jam-ui/src/i18n/locales/en/account.json b/jam-ui/src/i18n/locales/en/account.json index a3791c522..aabe7cf5e 100644 --- a/jam-ui/src/i18n/locales/en/account.json +++ b/jam-ui/src/i18n/locales/en/account.json @@ -54,5 +54,35 @@ } + }, + "subscription": { + "page_title": "Subscription", + "current_plan": { + "title": "Current Plan", + "help_text": "Your JamKazam subscription plan is currently set to the plan displayed below. To change your plan, click the subscription plan box below, select a new plan from the list, and then click the Save Plan button.", + "subscription_plan": "Subscription Plan", + "validations": { + "subscription_plan": { + "required": "Subscription plan is required" + } + }, + "submit": "Save Plan", + "notice": { + "part1": "To compare the features available for different subscription plans ", + "click_here": "click here", + "part2": " to view a help article on our available plans." + + } + }, + "play_time": { + "title": "Play Time", + "description": "" + }, + "alerts": { + "title": "Subscription Plan Update", + "changed_to_free_plan": "You have chosen to go back down to the FREE PLAN. Your subscription will be canceled, and you will keep your plan until the end of the current billing cycle.", + "failed_to_change_plan": "Failed to update subscription plan. Please try again later. Please contact support@jamkazam.com if you continue to have problems.", + "changed_plan_successfully": "You have successfully updated your subscription plan." + } } } \ No newline at end of file diff --git a/jam-ui/src/i18n/locales/en/common.json b/jam-ui/src/i18n/locales/en/common.json index f04c7a44b..a9c5908c0 100644 --- a/jam-ui/src/i18n/locales/en/common.json +++ b/jam-ui/src/i18n/locales/en/common.json @@ -6,6 +6,8 @@ "actions": "Actions", "no_records": "No Records!", "close": "Close", + "cancel": "Cancel", + "ok": "OK", "navigation": { "home": "Home", "friends": "Friends", diff --git a/jam-ui/src/i18n/locales/es/account.json b/jam-ui/src/i18n/locales/es/account.json index 0320557fa..6c363b933 100644 --- a/jam-ui/src/i18n/locales/es/account.json +++ b/jam-ui/src/i18n/locales/es/account.json @@ -54,5 +54,35 @@ } + }, + "subscription": { + "page_title": "Suscripción", + "current_plan": { + "title": "plan actual", + "help_text": "Your JamKazam subscription plan is currently set to the plan displayed below. To change your plan, click the subscription plan box below, select a new plan from the list, and then click the Save Plan button.", + "subscription_plan": "Subscription Plan", + "validations": { + "subscription_plan": { + "required": "Subscription plan is required" + } + }, + "submit": "Save Plan", + "notice": { + "part1": "To compare the features available for different subscription plans ", + "click_here": "click here", + "part2": " to view a help article on our available plans." + + } + }, + "play_time": { + "title": "Tiempo de juego", + "description": "" + }, + "alerts": { + "title": "Subscription Plan Update", + "changed_to_free_plan": "You have chosen to go back down to the FREE PLAN. Your subscription will be canceled, and you will keep your plan until the end of the current billing cycle.", + "failed_to_change_plan": "Failed to update subscription plan. Please try again later. Please contact support@jamkazam.com if you continue to have problems.", + "changed_plan_successfully": "You have successfully updated your subscription plan." + } } } \ No newline at end of file