From 68843234cfb44a550cf00a57efc598d811873117 Mon Sep 17 00:00:00 2001 From: chen Date: Thu, 12 Nov 2020 21:44:51 +0800 Subject: [PATCH 1/8] [pricing] [annual] [enrich] Introduced an option to show annual prices in annual increments or monthly increments. --- src/components/FreemiusPricingMain.js | 3 +- src/components/packages/Package.js | 58 ++++++++++++++++++--------- src/entities/Pricing.js | 33 ++++++++++++++- 3 files changed, 74 insertions(+), 20 deletions(-) diff --git a/src/components/FreemiusPricingMain.js b/src/components/FreemiusPricingMain.js index 8aeb443..391e8b3 100644 --- a/src/components/FreemiusPricingMain.js +++ b/src/components/FreemiusPricingMain.js @@ -59,7 +59,8 @@ class FreemiusPricingMain extends Component { selectedBillingCycle : Pricing.getBillingCyclePeriod(FSConfig.billing_cycle), selectedCurrency : this.getDefaultCurrency(), selectedLicenseQuantity : this.getDefaultLicenseQuantity(), - upgradingToPlanID : null + upgradingToPlanID : null, + showAnnualInMonthly : FSConfig.show_annual_in_monthly }; this.changeBillingCycle = this.changeBillingCycle.bind(this); diff --git a/src/components/packages/Package.js b/src/components/packages/Package.js index 467a659..ece1dbd 100644 --- a/src/components/packages/Package.js +++ b/src/components/packages/Package.js @@ -100,7 +100,7 @@ class Package extends Component { return label; } - getUndiscountedPrice(planPackage, selectedPricing) { + getUndiscountedPrice(planPackage, selectedPricing, selectedPricingCycleLabel) { if ( BillingCycleString.ANNUAL !== this.context.selectedBillingCycle || ! (this.context.annualDiscount > 0) @@ -112,7 +112,15 @@ class Package extends Component { return } - return
Normally {this.context.currencySymbols[this.context.selectedCurrency]}{selectedPricing.getMonthlyAmount(BillingCycle.MONTHLY, true)} / mo
; + let amount; + + if ('mo' === selectedPricingCycleLabel) { + amount = selectedPricing.getMonthlyAmount(BillingCycle.MONTHLY, true); + } else { + amount = selectedPricing.getYearlyAmount(BillingCycle.MONTHLY, true); + } + + return
Normally {this.context.currencySymbols[this.context.selectedCurrency]}{amount} / {selectedPricingCycleLabel}
; } getSitesLabel(planPackage, selectedPricing, pricingLicenses) { @@ -160,16 +168,18 @@ class Package extends Component { } render() { - let isSinglePlan = this.props.isSinglePlan, - planPackage = this.props.planPackage, - installPlanLicensesCount = this.props.installPlanLicensesCount, - currentLicenseQuantities = this.props.currentLicenseQuantities, - pricingLicenses = null, - selectedLicenseQuantity = this.context.selectedLicenseQuantity, - pricingCollection = {}, - selectedPricing = null, - selectedPricingAmount = null, - supportLabel = null; + let isSinglePlan = this.props.isSinglePlan, + planPackage = this.props.planPackage, + installPlanLicensesCount = this.props.installPlanLicensesCount, + currentLicenseQuantities = this.props.currentLicenseQuantities, + pricingLicenses = null, + selectedLicenseQuantity = this.context.selectedLicenseQuantity, + pricingCollection = {}, + selectedPricing = null, + selectedPricingAmount = null, + supportLabel = null, + showAnnualInMonthly = this.context.showAnnualInMonthly, + selectedPricingCycleLabel = 'mo'; if (this.props.isFirstPlanPackage) { Package.contextInstallPlanFound = false; @@ -200,9 +210,21 @@ class Package extends Component { this.previouslySelectedPricingByPlan[planPackage.id] = selectedPricing; - selectedPricingAmount = ((BillingCycleString.ANNUAL === this.context.selectedBillingCycle) ? - Helper.formatNumber(selectedPricing.getMonthlyAmount(BillingCycle.ANNUAL), 'en-US') : - selectedPricing[`${this.context.selectedBillingCycle}_price`]).toString(); + if (BillingCycleString.ANNUAL === this.context.selectedBillingCycle) + { + if (true === showAnnualInMonthly || (Helper.isUndefinedOrNull(showAnnualInMonthly) && selectedPricing.hasMonthlyPrice())) { + selectedPricingAmount = selectedPricing.getMonthlyAmount(BillingCycle.ANNUAL, true); + } + + if (false === showAnnualInMonthly || (Helper.isUndefinedOrNull(showAnnualInMonthly) && ! selectedPricing.hasMonthlyPrice())) { + selectedPricingAmount = selectedPricing.getYearlyAmount(BillingCycle.ANNUAL, true); + selectedPricingCycleLabel = 'yr'; + } + + } else { + selectedPricingAmount = selectedPricing[`${this.context.selectedBillingCycle}_price`].toString(); + } + } if ( ! planPackage.hasAnySupport()) { @@ -248,7 +270,7 @@ class Package extends Component { packageClassName += ' fs-featured-plan'; } - const selectedAmountInteger = Helper.formatNumber(parseInt(selectedPricingAmount.split('.')[0])); + const selectedAmountInteger = Helper.formatNumber(parseInt(selectedPricingAmount.split('.')[0]), 'en-US'); const selectedAmountFraction = Helper.formatFraction(selectedPricingAmount.split('.')[1]); return
  • @@ -258,7 +280,7 @@ class Package extends Component {

    {planPackage.description_lines}

    - {this.getUndiscountedPrice(planPackage, selectedPricing)} + {this.getUndiscountedPrice(planPackage, selectedPricing, selectedPricingCycleLabel)}
    { ! planPackage.is_free_plan ? this.context.currencySymbols[this.context.selectedCurrency] : ''} {planPackage.is_free_plan ? 'Free' : selectedAmountInteger} @@ -267,7 +289,7 @@ class Package extends Component { { ! planPackage.is_free_plan && BillingCycleString.LIFETIME !== this.context.selectedBillingCycle && - / mo + / {selectedPricingCycleLabel} }
    diff --git a/src/entities/Pricing.js b/src/entities/Pricing.js index 835fff2..3553bb2 100644 --- a/src/entities/Pricing.js +++ b/src/entities/Pricing.js @@ -205,7 +205,38 @@ export class Pricing { amount = parseFloat(amount); if (format) { - amount = Helper.formatNumber(amount); + amount = Helper.formatNumber(amount, 'en-US'); + } + + return amount; + } + + /** + * @param {int} billingCycle One of the following: 1, 12, 0 (for lifetime). + * @param {boolean} [format] If true, the number 1299 for example will become 1,299. + * + * @return {string|number} + */ + getYearlyAmount(billingCycle, format) { + let amount = .0; + + switch (billingCycle) { + case BillingCycle.MONTHLY: + amount = this.hasMonthlyPrice() ? + this.monthly_price * 12 : + this.annual_price; + break; + case BillingCycle.ANNUAL: + amount = this.hasAnnualPrice() ? + this.annual_price : + this.monthly_price * 12; + break; + } + + amount = parseFloat(amount); + + if (format) { + amount = Helper.formatNumber(amount, 'en-US'); } return amount; From 5aed7887f366a3054c53e0fff5eaa64c36c0d38b Mon Sep 17 00:00:00 2001 From: Leo Fajardo Date: Thu, 31 Oct 2024 04:31:52 +0800 Subject: [PATCH 2/8] [pricing] [annual] Synced with develop. --- src/components/FreemiusPricingMain.js | 1576 ++++++++++-------- src/components/Loader/index.js | 45 + src/components/Loader/style.scss | 118 ++ src/components/Package/index.js | 600 +++++++ src/components/Package/style.scss | 359 ++++ src/components/PackagesContainer/index.js | 677 ++++++++ src/components/PackagesContainer/style.scss | 170 ++ src/components/Placeholder/index.js | 16 + src/components/Tooltip/index.js | 131 ++ src/components/Tooltip/style.scss | 81 + src/components/packages/Package.js | 388 ----- src/components/packages/PackagesContainer.js | 526 ------ src/components/packages/Placeholder.js | 16 - 13 files changed, 3081 insertions(+), 1622 deletions(-) create mode 100644 src/components/Loader/index.js create mode 100644 src/components/Loader/style.scss create mode 100644 src/components/Package/index.js create mode 100644 src/components/Package/style.scss create mode 100644 src/components/PackagesContainer/index.js create mode 100644 src/components/PackagesContainer/style.scss create mode 100644 src/components/Placeholder/index.js create mode 100644 src/components/Tooltip/index.js create mode 100644 src/components/Tooltip/style.scss delete mode 100644 src/components/packages/Package.js delete mode 100644 src/components/packages/PackagesContainer.js delete mode 100644 src/components/packages/Placeholder.js diff --git a/src/components/FreemiusPricingMain.js b/src/components/FreemiusPricingMain.js index 391e8b3..2611850 100644 --- a/src/components/FreemiusPricingMain.js +++ b/src/components/FreemiusPricingMain.js @@ -1,4 +1,4 @@ -import React, {Component, Fragment} from 'react'; +import React, { Component, Fragment } from 'react'; import '.././assets/scss/App.scss'; @@ -9,807 +9,999 @@ import badgeComodo from '.././assets/img/comodo-short-green.png'; import defaultPluginIcon from '.././assets/img/plugin-icon.png'; import defaultThemeIcon from '.././assets/img/theme-icon.png'; -import {Plan} from "../entities/Plan"; -import {Plugin} from "../entities/Plugin"; -import {BillingCycleString, CurrencySymbol, DefaultCurrency, Pricing, UnlimitedLicenses} from '.././entities/Pricing'; -import {PlanManager} from '.././services/PlanManager'; -import FSPricingContext from ".././FSPricingContext"; +import { Plan } from '../entities/Plan'; +import { Plugin } from '../entities/Plugin'; +import { + BillingCycleString, + CurrencySymbol, + DefaultCurrency, + DiscountsModel, + Pricing, + UnlimitedLicenses, +} from '.././entities/Pricing'; +import { PlanManager } from '.././services/PlanManager'; +import FSPricingContext from '.././FSPricingContext'; import Section from './Section'; import PeriodSelector from './PeriodSelector'; import CurrencySelector from './CurrencySelector'; -import PackagesContainer from './packages/PackagesContainer'; +import PackagesContainer from './PackagesContainer'; import Badges from './Badges'; import Testimonials from './testimonials/Testimonials'; import Faq from './faq/Faq'; -import RefundPolicy from "./RefundPolicy"; -import {FSConfig} from "../index"; -import {RequestManager} from "../services/RequestManager"; -import {PageManager} from "../services/PageManager"; -import {Helper} from "../Helper"; -import {TrackingManager} from "../services/TrackingManager"; -import {FS} from "../postmessage"; -import Loader from "./Loader"; -import TrialConfirmationModal from "./TrialConfirmationModal"; +import RefundPolicy from './RefundPolicy'; +import { FSConfig } from '../index'; +import { RequestManager } from '../services/RequestManager'; +import { PageManager } from '../services/PageManager'; +import { Helper } from '../Helper'; +import { TrackingManager } from '../services/TrackingManager'; +import { FS } from '../postmessage'; +import Loader from './Loader'; +import TrialConfirmationModal from './TrialConfirmationModal'; class FreemiusPricingMain extends Component { - static contextType = FSPricingContext; - - constructor (props) { - super(props); - - this.state = { - active_installs : 0, - annualDiscount : 0, - billingCycles : [], - currencies : [], - downloads : 0, - faq : [], - firstPaidPlan : null, - featuredPlan : null, - isActivatingTrial : false, - isPayPalSupported : false, - isNetworkTrial : false, - isTrial : ('true' === FSConfig.trial || true === FSConfig.trial), - pendingConfirmationTrialPlan: null, - plugin : {}, - plans : [], - selectedPlanID : null, - reviews : [], - selectedBillingCycle : Pricing.getBillingCyclePeriod(FSConfig.billing_cycle), - selectedCurrency : this.getDefaultCurrency(), - selectedLicenseQuantity : this.getDefaultLicenseQuantity(), - upgradingToPlanID : null, - showAnnualInMonthly : FSConfig.show_annual_in_monthly - }; - - this.changeBillingCycle = this.changeBillingCycle.bind(this); - this.changeCurrency = this.changeCurrency.bind(this); - this.changeLicenses = this.changeLicenses.bind(this); - this.changePlan = this.changePlan.bind(this); - this.getModuleIcon = this.getModuleIcon.bind(this); - this.startTrial = this.startTrial.bind(this); - this.toggleRefundPolicyModal = this.toggleRefundPolicyModal.bind(this); - this.upgrade = this.upgrade.bind(this); - } - - appendScripts() { - let script = null; - - if ( ! this.hasInstallContext()) { - script = document.createElement("script"); - script.src = (this.isProduction() ? 'https://checkout.freemius.com' : 'http://checkout.freemius-local.com:8080') + '/checkout.js'; - script.async = true; - document.body.appendChild(script); - } - - if ( ! this.isSandboxPaymentsMode()) { - // ga - (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function() { - (i[r].q=i[r].q||[]).push(arguments)};i[r].l=1*new Date();a=s.createElement(o); - m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) - })(window,document,'script','//www.google-analytics.com/analytics.js','ga'); - } - } - - /** - * Updates the state with the selected billing cycle. - * - * @param {Object} e - */ - changeBillingCycle (e) { - this.setState({selectedBillingCycle: e.currentTarget.dataset.billingCycle}); + static contextType = FSPricingContext; + + constructor(props) { + super(props); + + this.state = { + active_installs : 0, + annualDiscount : 0, + billingCycles : [], + currencies : [], + downloads : 0, + faq : [], + firstPaidPlan : null, + featuredPlan : null, + isActivatingTrial : false, + isPayPalSupported : false, + isNetworkTrial : false, + isTrial : ('true' === FSConfig.trial || true === FSConfig.trial), + pendingConfirmationTrialPlan: null, + plugin : {}, + plans : [], + selectedPlanID : null, + reviews : [], + selectedBillingCycle : Pricing.getBillingCyclePeriod(FSConfig.billing_cycle), + selectedCurrency : this.getDefaultCurrency(), + selectedLicenseQuantity : this.getDefaultLicenseQuantity(), + upgradingToPlanID : null, + license : FSConfig.license, + showAnnualInMonthly : FSConfig.show_annual_in_monthly, + }; + + this.changeBillingCycle = this.changeBillingCycle.bind(this); + this.changeCurrency = this.changeCurrency.bind(this); + this.changeLicenses = this.changeLicenses.bind(this); + this.changePlan = this.changePlan.bind(this); + this.getModuleIcon = this.getModuleIcon.bind(this); + this.startTrial = this.startTrial.bind(this); + this.toggleRefundPolicyModal = this.toggleRefundPolicyModal.bind(this); + this.upgrade = this.upgrade.bind(this); + } + + appendScripts() { + let script = null; + + if (!this.hasInstallContext()) { + script = document.createElement('script'); + script.src = + (this.isProduction() + ? 'https://checkout.freemius.com' + : 'http://checkout.freemius-local.com:8080') + '/checkout.js'; + script.async = true; + document.body.appendChild(script); } - /** - * Updates the state with the selected currency. - * - * @param {object} e - */ - changeCurrency (e) { - this.setState({selectedCurrency: e.currentTarget.value}); + if (!this.isSandboxPaymentsMode()) { + // ga + (function (i, s, o, g, r, a, m) { + i['GoogleAnalyticsObject'] = r; + i[r] = + i[r] || + function () { + (i[r].q = i[r].q || []).push(arguments); + }; + i[r].l = 1 * new Date(); + a = s.createElement(o); + m = s.getElementsByTagName(o)[0]; + a.async = 1; + a.src = g; + m.parentNode.insertBefore(a, m); + })( + window, + document, + 'script', + '//www.google-analytics.com/analytics.js', + 'ga' + ); } - - /** - * Updates the state with the selected license quantity. - * - * @param {object} e - */ - changeLicenses(e) { - let pricingID = e.currentTarget.value, - selectedLicenseQuantity = this.state.selectedLicenseQuantity; - - for (let plan of this.state.plans) { - if (Helper.isUndefinedOrNull(plan.pricing)) { - continue; - } - - for (let pricing of plan.pricing) { - if (pricingID != pricing.id) { - continue; - } - - selectedLicenseQuantity = pricing.getLicenses(); - - break; - } + } + + /** + * Updates the state with the selected billing cycle. + * + * @param {Object} e + */ + changeBillingCycle(e) { + this.setState({ + selectedBillingCycle: e.currentTarget.dataset.billingCycle, + }); + } + + /** + * Updates the state with the selected currency. + * + * @param {object} e + */ + changeCurrency(e) { + this.setState({ selectedCurrency: e.currentTarget.value }); + } + + /** + * Updates the state with the selected license quantity. + * + * @param {object} e + */ + changeLicenses(e) { + let pricingID = e.currentTarget.value, + selectedLicenseQuantity = this.state.selectedLicenseQuantity; + + for (let plan of this.state.plans) { + if (Helper.isUndefinedOrNull(plan.pricing)) { + continue; + } + + for (let pricing of plan.pricing) { + if (pricingID != pricing.id) { + continue; } - this.setState({selectedLicenseQuantity: selectedLicenseQuantity}); - } - - changePlan(e) { - let selectedPlanID = e.target.value ? - e.target.value : - (e.target.dataset.planId ? - e.target.dataset.planId : - e.target.parentNode.dataset.planId); - - e.preventDefault(); + selectedLicenseQuantity = pricing.getLicenses(); - this.setState({selectedPlanID: selectedPlanID}); + break; + } } - getModuleIcon() { - let defaultIconUrl = ('theme' === this.state.plugin.type) ? - defaultThemeIcon : - defaultPluginIcon; - - return ( - - {`${this.state.plugin.type}-logo`}/ - - ); + this.setState({ selectedLicenseQuantity: selectedLicenseQuantity }); + } + + changePlan(e) { + let selectedPlanID = e.target.value + ? e.target.value + : e.target.dataset.planId + ? e.target.dataset.planId + : e.target.parentNode.dataset.planId; + + e.preventDefault(); + + this.setState({ selectedPlanID: selectedPlanID }); + } + + getModuleIcon() { + let defaultIconUrl = + 'theme' === this.state.plugin.type ? defaultThemeIcon : defaultPluginIcon; + + return ( + + {`${this.state.plugin.type}-logo`} + + ); + } + + componentDidMount() { + this.fetchPricingData(); + } + + /** + * @return {string} Defaults to `usd` if the currency that was passed in the config is not valid. + */ + getDefaultCurrency() { + if ( + !Helper.isNonEmptyString(FSConfig.currency) && + !CurrencySymbol[FSConfig.currency] + ) { + return DefaultCurrency; } - componentDidMount() { - this.fetchPricingData(); + return FSConfig.currency; + } + + /** + * @return {string} Defaults to `1` if the license quantity that was passed in the config is not valid. + */ + getDefaultLicenseQuantity() { + if ('unlimited' === FSConfig.licenses) { + return 0; } - /** - * @return {string} Defaults to `usd` if the currency that was passed in the config is not valid. - */ - getDefaultCurrency() { + return Helper.isNumeric(FSConfig.licenses) ? FSConfig.licenses : 1; + } + + /** + * @param {number} planID + * + * @return {Pricing} + */ + getSelectedPlanPricing(planID) { + for (let plan of this.state.plans) { + if (planID != plan.id) { + continue; + } + + for (let pricing of plan.pricing) { if ( - ! Helper.isNonEmptyString(FSConfig.currency) && - ! CurrencySymbol[FSConfig.currency] + pricing.getLicenses() == this.state.selectedLicenseQuantity && + pricing.currency === this.state.selectedCurrency ) { - return DefaultCurrency; + return pricing; } - - return FSConfig.currency; + } } - /** - * @return {string} Defaults to `1` if the license quantity that was passed in the config is not valid. - */ - getDefaultLicenseQuantity() { - if ('unlimited' === FSConfig.licenses) { - return 0; - } - - return Helper.isNumeric(FSConfig.licenses) ? - FSConfig.licenses : - 1; + return null; + } + + /** + * @return {boolean} + */ + hasInstallContext() { + return !Helper.isUndefinedOrNull(this.state.install); + } + + /** + * @return {boolean} + */ + isDashboardMode() { + return 'dashboard' === FSConfig.mode; + } + + /** + * @return {boolean} + */ + isEmbeddedDashboardMode() { + if (!this.isDashboardMode()) { + return false; } - /** - * @param {number} planID - * - * @return {Pricing} - */ - getSelectedPlanPricing(planID) { - for (let plan of this.state.plans) { - if (planID != plan.id) { - continue; - } - - for (let pricing of plan.pricing) { - if ( - pricing.getLicenses() == this.state.selectedLicenseQuantity && - pricing.currency === this.state.selectedCurrency - ) { - return pricing; - } - } - } - - return null; - } + return Helper.isUndefinedOrNull(FS.PostMessage.parent_url()); + } - /** - * @return {boolean} - */ - hasInstallContext() { - return ( ! Helper.isUndefinedOrNull(this.state.install)); + /** + * @return {boolean} + */ + isProduction() { + if (!Helper.isUndefinedOrNull(FSConfig.is_production)) { + return FSConfig.is_production; } - /** - * @return {boolean} - */ - isDashboardMode() { - return ('dashboard' === FSConfig.mode); - } - - /** - * @return {boolean} - */ - isEmbeddedDashboardMode() { - if ( ! this.isDashboardMode()) { - return false; - } - - return (Helper.isUndefinedOrNull(FS.PostMessage.parent_url())); - } - - /** - * @return {boolean} - */ - isProduction() { - if ( ! Helper.isUndefinedOrNull(FSConfig.is_production)) { - return FSConfig.is_production; + return -1 === ['3000', '8080'].indexOf(window.location.port); + } + + /** + * @return {boolean} + */ + isSandboxPaymentsMode() { + return ( + Helper.isNonEmptyString(FSConfig.sandbox) && + Helper.isNumeric(FSConfig.s_ctx_ts) + ); + } + + startTrial(planID) { + this.setState({ + isActivatingTrial: true, + upgradingToPlanID: planID, + }); + + let endpointUrl = this.isEmbeddedDashboardMode() + ? FSConfig.request_handler_url + : FSConfig.fs_wp_endpoint_url + '/action/service/subscribe/trial/'; + + RequestManager.getInstance() + .request(endpointUrl, { + prev_url: window.location.href, + pricing_action: 'start_trial', + plan_id: planID, + }) + .then(result => { + if (result.success) { + // Track trial start. + this.trackingManager.track('started'); + + const parentUrl = FS.PostMessage.parent_url(); + + const page = + this.state.plugin.menu_slug + + (this.hasInstallContext() ? '-account' : ''); + + if (!Helper.isNonEmptyString(parentUrl)) { + if (Helper.isNonEmptyString(FSConfig.next)) { + // Fix the `page` query string parameter, if no install context is available. + let nextPage = FSConfig.next; + + if (!this.hasInstallContext()) { + nextPage = nextPage.replace(/page=[^&]+/, `page=${page}`); + } + + PageManager.getInstance().redirect(nextPage); + } + } else { + FS.PostMessage.post('forward', { + url: PageManager.getInstance().addQueryArgs(parentUrl, { + page, + fs_action: this.state.plugin.unique_affix + '_sync_license', + plugin_id: this.state.plugin.id, + }), + }); + } } - return (-1 === ['3000', '8080'].indexOf(window.location.port)); - } - - /** - * @return {boolean} - */ - isSandboxPaymentsMode() { - return (Helper.isNonEmptyString(FSConfig.sandbox) && Helper.isNumeric(FSConfig.s_ctx_ts)); - } - - startTrial(planID) { this.setState({ - 'isActivatingTrial': true, - 'upgradingToPlanID': planID + isActivatingTrial: false, + pendingConfirmationTrialPlan: null, + upgradingToPlanID: null, }); + }); + } - let endpointUrl = this.isEmbeddedDashboardMode() ? - FSConfig.request_handler_url : - FSConfig.fs_wp_endpoint_url + '/action/service/subscribe/trial/'; - - RequestManager.getInstance().request(endpointUrl, { - prev_url : window.location.href, - pricing_action: 'start_trial', - plan_id : planID - }).then(result => { - if (result.success) { - // Track trial start. - this.trackingManager.track('started'); - - const parentUrl = FS.PostMessage.parent_url(); - - if ( ! Helper.isNonEmptyString(parentUrl)) { - if (Helper.isNonEmptyString(FSConfig.next)) { - PageManager.getInstance().redirect(FSConfig.next); - } - } else { - FS.PostMessage.post('forward', { - url: PageManager.getInstance().addQueryArgs(parentUrl, { - page : this.state.plugin.menu_slug + '-account', - fs_action: this.state.plugin.unique_affix + '_sync_license', - plugin_id: this.state.plugin.id - }) - }); - } - } + toggleRefundPolicyModal(evt) { + evt.preventDefault(); - this.setState({ - isActivatingTrial : false, - pendingConfirmationTrialPlan: null, - upgradingToPlanID : null - }); - }); - } + this.setState({ showRefundPolicyModal: !this.state.showRefundPolicyModal }); + } - toggleRefundPolicyModal(evt) { - evt.preventDefault(); + upgrade(plan, pricing) { + if (PlanManager.getInstance().isFreePlan(plan.pricing)) { + return; + } - this.setState({showRefundPolicyModal: ! this.state.showRefundPolicyModal}); + if (!this.isEmbeddedDashboardMode()) { + let handler = window.FS.Checkout.configure({ + plugin_id: this.state.plugin.id, + public_key: this.state.plugin.public_key, + sandbox_token: Helper.isNonEmptyString(FSConfig.sandbox_token) + ? FSConfig.sandbox_token + : null, + timestamp: Helper.isNonEmptyString(FSConfig.sandbox_token) + ? FSConfig.timestamp + : null, + }); + + let params = { + name: this.state.plugin.title, + plan_id: plan.id, + success: function (response) { + console.log(response); + }, + }; + + if (null !== pricing) { + params.pricing_id = pricing.id; + } else { + params.licenses = + UnlimitedLicenses == this.state.selectedLicenseQuantity + ? null + : this.state.selectedLicenseQuantity; + } + + handler.open(params); + + return; } - upgrade(plan, pricing) { - if (PlanManager.getInstance().isFreePlan(plan.pricing)) { - return; + if (this.state.isTrial && !plan.requiresSubscription()) { + if (this.hasInstallContext()) { + this.startTrial(plan.id); + } else { + if (Helper.isUndefinedOrNull(FS.PostMessage.parent_url())) { + this.setState({ pendingConfirmationTrialPlan: plan }); + } else { + FS.PostMessage.post('start_trial', { + plugin_id: this.state.plugin.id, + plan_id: plan.id, + plan_name: plan.name, + plan_title: plan.title, + trial_period: plan.trial_period, + }); + } + } + } else { + if (null === pricing) { + pricing = this.getSelectedPlanPricing(plan.id); + } + + let parentUrl = FS.PostMessage.parent_url(), + hasParentUrl = Helper.isNonEmptyString(parentUrl), + billingCycle = this.state.selectedBillingCycle; + + if (this.state.skipDirectlyToPayPal) { + let data = {}, + trial_period = plan.trial_period; + + if (trial_period > 0) { + data.trial_period = trial_period; + + if (this.hasInstallContext()) { + data.user_id = this.state.install.user_id; + } } - if ( ! this.isEmbeddedDashboardMode()) { - let handler = window.FS.Checkout.configure({ - plugin_id : this.state.plugin.id, - public_key : this.state.plugin.public_key, - sandbox_token: Helper.isNonEmptyString(FSConfig.sandbox_token) ? FSConfig.sandbox_token : null, - timestamp : Helper.isNonEmptyString(FSConfig.sandbox_token) ? FSConfig.timestamp: null - }); - - let params = { - name : this.state.plugin.title, - plan_id: plan.id, - success: function (response) { - console.log(response); - } - }; - - if (null !== pricing) { - params.pricing_id = pricing.id; - } else { - params.licenses = (UnlimitedLicenses == this.state.selectedLicenseQuantity) ? - null : - this.state.selectedLicenseQuantity; - } + let params = { + plan_id: plan.id, + pricing_id: pricing.id, + billing_cycle: billingCycle, + }; - handler.open(params); + if (hasParentUrl) { + FS.PostMessage.post('forward', { + url: PageManager.getInstance().addQueryArgs( + FSConfig.fs_wp_endpoint_url + + '/action/service/paypal/express-checkout/', + params + ), + }); + } else { + params.prev_url = window.location.href; - return; + PageManager.getInstance().redirect( + FSConfig.fs_wp_endpoint_url + + '/action/service/paypal/express-checkout/', + params + ); } + } else { + let urlParams = { + checkout: 'true', + plan_id: plan.id, + plan_name: plan.name, + billing_cycle: billingCycle, + pricing_id: pricing.id, + currency: this.state.selectedCurrency, + }; + // Handle trial mode which requires payment method, this must go through the checkout. if (this.state.isTrial) { - if (this.hasInstallContext()) { - this.startTrial(plan.id); - } else { - if (Helper.isUndefinedOrNull(FS.PostMessage.parent_url())) { - this.setState({pendingConfirmationTrialPlan: plan}); - } else { - FS.PostMessage.post('start_trial', { - plugin_id : this.state.plugin.id, - plan_id : plan.id, - plan_name : plan.name, - plan_title : plan.title, - trial_period: plan.trial_period - }); - } - } - } else { - if (null === pricing) { - pricing = this.getSelectedPlanPricing(plan.id); - } - - let parentUrl = FS.PostMessage.parent_url(), - hasParentUrl = Helper.isNonEmptyString(parentUrl), - billingCycle = this.state.selectedBillingCycle; - - if (this.state.skipDirectlyToPayPal) { - let data = {}, - trial_period = plan.trial_period; - - if (trial_period > 0) { - data.trial_period = trial_period; - - if (this.hasInstallContext()) { - data.user_id = this.state.install.user_id; - } - } - - let params = { - plan_id : plan.id, - pricing_id : pricing.id, - billing_cycle : billingCycle - }; - - if (hasParentUrl) { - FS.PostMessage.post('forward', { - url: PageManager.getInstance().addQueryArgs(FSConfig.fs_wp_endpoint_url + '/action/service/paypal/express-checkout/', params) - }); - } else { - params.prev_url = window.location.href; + urlParams.trial = 'true'; + } - PageManager.getInstance().redirect(FSConfig.fs_wp_endpoint_url + '/action/service/paypal/express-checkout/', params); - } - } else { - let urlParams = { - checkout : 'true', - plan_id : plan.id, - plan_name : plan.name, - billing_cycle: billingCycle, - pricing_id : pricing.id, - currency : this.state.selectedCurrency - }; - - if ( ! hasParentUrl) { - PageManager.getInstance().redirect(window.location.href, urlParams); - } else { - FS.PostMessage.post('forward', { - url: PageManager.getInstance().addQueryArgs( - parentUrl, - {...urlParams, ...{page: this.state.plugin.menu_slug + '-pricing'}} - ) - }); - } - } + if (!hasParentUrl) { + PageManager.getInstance().redirect(window.location.href, urlParams); + } else { + FS.PostMessage.post('forward', { + url: PageManager.getInstance().addQueryArgs(parentUrl, { + ...urlParams, + ...{ page: this.state.plugin.menu_slug + '-pricing' }, + }), + }); } + } } + } + + fetchPricingData() { + let params = { + pricing_action: 'fetch_pricing_data', + trial: this.state.isTrial, + is_sandbox: this.isSandboxPaymentsMode(), + }; + + RequestManager.getInstance() + .request(FSConfig.request_handler_url, params) + .then(pricingData => { + if (pricingData.data) { + pricingData = pricingData.data; + } - fetchPricingData() { - let params = { - pricing_action: 'fetch_pricing_data', - trial : this.state.isTrial, - is_sandbox : this.isSandboxPaymentsMode() - }; - - RequestManager.getInstance().request(FSConfig.request_handler_url, params).then(pricingData => { - if (pricingData.data) { - pricingData = pricingData.data; - } - - if ( ! pricingData.plans) { - return; - } - - let billingCycles = {}, - currencies = {}, - hasAnnualCycle = false, - hasAnyPlanWithSupport = false, - hasEmailSupportForAllPaidPlans = true, - hasEmailSupportForAllPlans = true, - featuredPlan = null, - firstPaidPlan = null, - hasLifetimePricing = false, - hasMonthlyCycle = false, - licenseQuantities = {}, - paidPlansCount = 0, - planManager = PlanManager.getInstance(pricingData.plans), - plansCount = 0, - planSingleSitePricingCollection = [], - priorityEmailSupportPlanID = null, - selectedBillingCycle = this.state.selectedBillingCycle, - paidPlanWithTrial = null, - isNetworkTrial = false, - isTrial = ('true' === pricingData.trial_mode || true === pricingData.trial_mode), - trialUtilized = ('true' === pricingData.trial_utilized || true === pricingData.trial_utilized); + if (!pricingData.plans) { + return; + } - for (let planIndex = 0; planIndex < pricingData.plans.length; planIndex ++) { - if ( ! pricingData.plans.hasOwnProperty(planIndex)) { - continue; - } + let billingCycles = {}, + currencies = {}, + hasAnnualCycle = false, + hasAnyPlanWithSupport = false, + hasEmailSupportForAllPaidPlans = true, + hasEmailSupportForAllPlans = true, + featuredPlan = null, + firstPaidPlan = null, + hasLifetimePricing = false, + hasMonthlyCycle = false, + licenseQuantities = {}, + paidPlansCount = 0, + planManager = PlanManager.getInstance(pricingData.plans), + plansCount = 0, + planSingleSitePricingCollection = [], + priorityEmailSupportPlanID = null, + selectedBillingCycle = this.state.selectedBillingCycle, + paidPlanWithTrial = null, + isNetworkTrial = false, + isTrial = + 'true' === pricingData.trial_mode || + true === pricingData.trial_mode, + trialUtilized = + 'true' === pricingData.trial_utilized || + true === pricingData.trial_utilized; + + for ( + let planIndex = 0; + planIndex < pricingData.plans.length; + planIndex++ + ) { + if (!pricingData.plans.hasOwnProperty(planIndex)) { + continue; + } - if (pricingData.plans[planIndex].is_hidden) { - // Remove plan from the collection. - pricingData.plans.splice(planIndex, 1); + if (pricingData.plans[planIndex].is_hidden) { + // Remove plan from the collection. + pricingData.plans.splice(planIndex, 1); - planIndex --; + planIndex--; - continue; - } + continue; + } - plansCount ++; + plansCount++; - pricingData.plans[planIndex] = new Plan(pricingData.plans[planIndex]); + pricingData.plans[planIndex] = new Plan(pricingData.plans[planIndex]); - let plan = pricingData.plans[planIndex]; + let plan = pricingData.plans[planIndex]; - if (plan.is_featured) { - featuredPlan = plan; - } + if (plan.is_featured) { + featuredPlan = plan; + } - if (Helper.isUndefinedOrNull(plan.features)) { - plan.features = []; - } + if (Helper.isUndefinedOrNull(plan.features)) { + plan.features = []; + } - let pricingCollection = plan.pricing; + let pricingCollection = plan.pricing; - if (Helper.isUndefinedOrNull(pricingCollection)) { - continue; - } + if (Helper.isUndefinedOrNull(pricingCollection)) { + continue; + } - for (let pricingIndex = 0; pricingIndex < pricingCollection.length; pricingIndex ++) { - if ( ! pricingCollection.hasOwnProperty(pricingIndex)) { - continue; - } + for ( + let pricingIndex = 0; + pricingIndex < pricingCollection.length; + pricingIndex++ + ) { + if (!pricingCollection.hasOwnProperty(pricingIndex)) { + continue; + } - pricingCollection[pricingIndex] = new Pricing(pricingCollection[pricingIndex]); + pricingCollection[pricingIndex] = new Pricing( + pricingCollection[pricingIndex] + ); - let pricing = pricingCollection[pricingIndex]; + let pricing = pricingCollection[pricingIndex]; - if (null != pricing.monthly_price) { - billingCycles[BillingCycleString.MONTHLY] = true; - } + if (null != pricing.monthly_price) { + billingCycles[BillingCycleString.MONTHLY] = true; + } - if (null != pricing.annual_price) { - billingCycles[BillingCycleString.ANNUAL] = true; - } + if (null != pricing.annual_price) { + billingCycles[BillingCycleString.ANNUAL] = true; + } - if (null != pricing.lifetime_price) { - billingCycles[BillingCycleString.LIFETIME] = true; - } + if (null != pricing.lifetime_price) { + billingCycles[BillingCycleString.LIFETIME] = true; + } - currencies[pricing.currency] = true; + currencies[pricing.currency] = true; - let licenses = pricing.getLicenses(); + let licenses = pricing.getLicenses(); - if ( ! licenseQuantities[pricing.currency]) { - licenseQuantities[pricing.currency] = {}; - } + if (!licenseQuantities[pricing.currency]) { + licenseQuantities[pricing.currency] = {}; + } - licenseQuantities[pricing.currency][licenses] = true; - } + licenseQuantities[pricing.currency][licenses] = true; + } - let isPaidPlan = planManager.isPaidPlan(pricingCollection); + let isPaidPlan = planManager.isPaidPlan(pricingCollection); - if (isPaidPlan && null === firstPaidPlan) { - firstPaidPlan = plan; - } + if (isPaidPlan && null === firstPaidPlan) { + firstPaidPlan = plan; + } - if ( ! plan.hasEmailSupport()) { - hasEmailSupportForAllPlans = false; + if (!plan.hasEmailSupport()) { + hasEmailSupportForAllPlans = false; - if (isPaidPlan) { - hasEmailSupportForAllPaidPlans = false; - } - } else { - if ( ! plan.hasSuccessManagerSupport()) { - priorityEmailSupportPlanID = plan.id; - } - } + if (isPaidPlan) { + hasEmailSupportForAllPaidPlans = false; + } + } else { + if (!plan.hasSuccessManagerSupport()) { + priorityEmailSupportPlanID = plan.id; + } + } - if ( ! hasAnyPlanWithSupport && plan.hasAnySupport()) { - hasAnyPlanWithSupport = true; - } + if (!hasAnyPlanWithSupport && plan.hasAnySupport()) { + hasAnyPlanWithSupport = true; + } - if (isPaidPlan) { - paidPlansCount ++; + if (isPaidPlan) { + paidPlansCount++; - let singleSitePricing = planManager.getSingleSitePricing(pricingCollection, this.state.selectedCurrency); - if (null !== singleSitePricing) { - planSingleSitePricingCollection.push(singleSitePricing); - } - } - } + let singleSitePricing = planManager.getSingleSitePricing( + pricingCollection, + this.state.selectedCurrency + ); + if (null !== singleSitePricing) { + planSingleSitePricingCollection.push(singleSitePricing); + } + } + } - if ( - isTrial && - ( - ! Helper.isUndefinedOrNull(FSConfig.is_network_admin) && - ( - 'true' === FSConfig.is_network_admin || - true === FSConfig.is_network_admin - ) - ) - ) { - isNetworkTrial = true; - - /** - * Trial mode in the network level is currently disabled since the trial logic allows only one trial per user per product. - */ - isTrial = false; - } + if ( + isTrial && + !Helper.isUndefinedOrNull(FSConfig.is_network_admin) && + ('true' === FSConfig.is_network_admin || + true === FSConfig.is_network_admin) + ) { + isNetworkTrial = true; - if (isTrial) { - for (let plan of pricingData.plans) { - if (plan.is_hidden) { - continue; - } - - if (plan.pricing && ! planManager.isFreePlan(plan.pricing)) { - if (plan.hasTrial()) { - paidPlanWithTrial = plan; - break; - } - } - } - - if (null === paidPlanWithTrial) { - // Didn't find any paid plans with trial in it. - isTrial = false; - } - } + /** + * Trial mode in the network level is currently disabled since the trial logic allows only one trial per user per product. + */ + isTrial = false; + } - if (null != billingCycles.annual) { - hasAnnualCycle = true; - } + if (isTrial) { + for (let plan of pricingData.plans) { + if (plan.is_hidden) { + continue; + } - if (null != billingCycles.monthly) { - hasMonthlyCycle = true; - } + if (plan.pricing && !planManager.isFreePlan(plan.pricing)) { + if (plan.hasTrial()) { + paidPlanWithTrial = plan; + break; + } + } + } - if (null != billingCycles.lifetime) { - hasLifetimePricing = true; - } + if (null === paidPlanWithTrial) { + // Didn't find any paid plans with trial in it. + isTrial = false; + } + } - let plugin = new Plugin(pricingData.plugin); + if (null != billingCycles.annual) { + hasAnnualCycle = true; + } - let parentUrl = FS.PostMessage.parent_url(); + if (null != billingCycles.monthly) { + hasMonthlyCycle = true; + } - if (Helper.isNonEmptyString(FSConfig.menu_slug)) { - plugin.menu_slug = FSConfig.menu_slug; - } else if (Helper.isNonEmptyString(parentUrl)) { - let page = PageManager.getInstance().getQuerystringParam(parentUrl, 'page'); + if (null != billingCycles.lifetime) { + hasLifetimePricing = true; + } - plugin.menu_slug = page.substring(0, page.length - ('-pricing').length); - } + if (Helper.isUndefinedOrNull(billingCycles[selectedBillingCycle])) { + if (hasAnnualCycle) { + selectedBillingCycle = BillingCycleString.ANNUAL; + } else if (hasMonthlyCycle) { + selectedBillingCycle = BillingCycleString.MONTHLY; + } else { + selectedBillingCycle = BillingCycleString.LIFETIME; + } + } - plugin.unique_affix = ( ! Helper.isUndefinedOrNull(FSConfig.unique_affix)) ? - FSConfig.unique_affix : - (plugin.slug + ('theme' === plugin.type ? '-theme' : '')); - - this.setState({ - active_installs : pricingData.active_installs, - allPlansSingleSitePrices : pricingData.all_plans_single_site_pricing, - annualDiscount : (hasAnnualCycle && hasMonthlyCycle) ? - planManager.largestAnnualDiscount(planSingleSitePricingCollection) : - 0, - billingCycles : Object.keys(billingCycles), - currencies : Object.keys(currencies), - currencySymbols : {usd: '$', eur: '€', gbp: '£'}, - downloads : pricingData.downloads, - hasAnnualCycle : hasAnnualCycle, - hasEmailSupportForAllPaidPlans: hasEmailSupportForAllPaidPlans, - hasEmailSupportForAllPlans : hasEmailSupportForAllPlans, - featuredPlan : featuredPlan, - firstPaidPlan : firstPaidPlan, - hasLifetimePricing : hasLifetimePricing, - hasMonthlyCycle : hasMonthlyCycle, - hasPremiumVersion : ('true' === pricingData.plugin.has_premium_version || true === pricingData.plugin.has_premium_version), - install : pricingData.install, - isPayPalSupported : ('true' === pricingData.is_paypal_supported || true === pricingData.is_paypal_supported), - licenseQuantities : licenseQuantities, - paidPlansCount : paidPlansCount, - paidPlanWithTrial : paidPlanWithTrial, - plans : pricingData.plans, - plansCount : plansCount, - plugin : plugin, - priorityEmailSupportPlanID : priorityEmailSupportPlanID, - reviews : pricingData.reviews, - selectedBillingCycle : selectedBillingCycle, - skipDirectlyToPayPal : ('true' === pricingData.skip_directly_to_paypal || true === pricingData.skip_directly_to_paypal), - isNetworkTrial : isNetworkTrial, - isTrial : isTrial, - trialUtilized : trialUtilized, - showRefundPolicyModal : false - }); - - this.appendScripts(); - - this.trackingManager = TrackingManager.getInstance({ - billingCycle: Pricing.getBillingCyclePeriod(this.state.selectedBillingCycle), - isTrialMode : this.state.isTrial, - isSandbox : this.isSandboxPaymentsMode(), - isPaidTrial : false, - isProduction: this.isProduction(), - pageMode : this.isDashboardMode() ? 'dashboard' : 'page', - pluginID : this.state.plugin.id, - type : this.state.plugin.type, - uid : this.hasInstallContext() ? this.state.install.id : null, - userID : (this.hasInstallContext() ? this.state.install.user_id : null) - }); - - FS.PostMessage.init_child(); - FS.PostMessage.postHeight(); - }); - } + let plugin = new Plugin(pricingData.plugin); - render() { - let pricingData = this.state; + let parentUrl = FS.PostMessage.parent_url(); - if ( ! pricingData.plugin.id) { - const leftPos = document.querySelector(FSConfig.selector).getBoundingClientRect().left; + if (Helper.isNonEmptyString(FSConfig.menu_slug)) { + plugin.menu_slug = FSConfig.menu_slug; + } else if (Helper.isNonEmptyString(parentUrl)) { + let page = PageManager.getInstance().getQuerystringParam( + parentUrl, + 'page' + ); - return ; + plugin.menu_slug = page.substring(0, page.length - '-pricing'.length); } - let featuredPlan = pricingData.featuredPlan, - trialUtilized = false; + plugin.unique_affix = !Helper.isUndefinedOrNull(FSConfig.unique_affix) + ? FSConfig.unique_affix + : plugin.slug + ('theme' === plugin.type ? '-theme' : ''); - if (null !== featuredPlan) { - let hasAnyVisiblePricing = false; + this.setState({ + active_installs: pricingData.active_installs, + allPlansSingleSitePrices: pricingData.all_plans_single_site_pricing, + annualDiscount: + hasAnnualCycle && hasMonthlyCycle + ? planManager.largestAnnualDiscount( + planSingleSitePricingCollection + ) + : 0, + billingCycles: Object.keys(billingCycles), + currencies: Object.keys(currencies), + currencySymbols: { usd: '$', eur: '€', gbp: '£' }, + discountsModel: FSConfig?.discounts_model ?? DiscountsModel.ABSOLUTE, + downloads: pricingData.downloads, + hasAnnualCycle: hasAnnualCycle, + hasEmailSupportForAllPaidPlans: hasEmailSupportForAllPaidPlans, + hasEmailSupportForAllPlans: hasEmailSupportForAllPlans, + featuredPlan: featuredPlan, + firstPaidPlan: firstPaidPlan, + hasLifetimePricing: hasLifetimePricing, + hasMonthlyCycle: hasMonthlyCycle, + hasPremiumVersion: + 'true' === pricingData.plugin.has_premium_version || + true === pricingData.plugin.has_premium_version, + install: pricingData.install, + isPayPalSupported: + 'true' === pricingData.is_paypal_supported || + true === pricingData.is_paypal_supported, + licenseQuantities: licenseQuantities, + paidPlansCount: paidPlansCount, + paidPlanWithTrial: paidPlanWithTrial, + plans: pricingData.plans, + plansCount: plansCount, + plugin: plugin, + priorityEmailSupportPlanID: priorityEmailSupportPlanID, + reviews: pricingData.reviews, + selectedBillingCycle: selectedBillingCycle, + skipDirectlyToPayPal: + 'true' === pricingData.skip_directly_to_paypal || + true === pricingData.skip_directly_to_paypal, + isNetworkTrial: isNetworkTrial, + isTrial: isTrial, + trialUtilized: trialUtilized, + showRefundPolicyModal: false, + }); - for (let pricing of featuredPlan.pricing) { - if (pricing.is_hidden) { - continue; - } + this.appendScripts(); + + this.trackingManager = TrackingManager.getInstance({ + billingCycle: Pricing.getBillingCyclePeriod( + this.state.selectedBillingCycle + ), + isTrialMode: this.state.isTrial, + isSandbox: this.isSandboxPaymentsMode(), + isPaidTrial: false, + isProduction: this.isProduction(), + pageMode: this.isDashboardMode() ? 'dashboard' : 'page', + pluginID: this.state.plugin.id, + type: this.state.plugin.type, + uid: this.hasInstallContext() ? this.state.install.id : null, + userID: this.hasInstallContext() ? this.state.install.user_id : null, + }); - let pricingLicenses = pricing.getLicenses(); + FS.PostMessage.init_child(); + FS.PostMessage.postHeight(); + }); + } + + render() { + let pricingData = this.state; + + if (!pricingData.plugin.id) { + const leftPos = document + .querySelector(FSConfig.selector) + .getBoundingClientRect().left; + + return ( + + ); + } - if (pricingLicenses != pricingData.selectedLicenseQuantity) { - continue; - } + let featuredPlan = pricingData.featuredPlan, + trialUtilized = false; - if (pricing.currency != pricingData.selectedCurrency) { - continue; - } + if (null !== featuredPlan) { + let hasAnyVisiblePricing = false; - if ( ! pricing.supportsBillingCycle(pricingData.selectedBillingCycle)) { - continue; - } + for (let pricing of featuredPlan.pricing) { + if (pricing.is_hidden) { + continue; + } - hasAnyVisiblePricing = true; - break; - } + let pricingLicenses = pricing.getLicenses(); - if ( ! hasAnyVisiblePricing) { - featuredPlan = null; - } + if (pricingLicenses != pricingData.selectedLicenseQuantity) { + continue; } - let trialMessage = null; + if (pricing.currency != pricingData.selectedCurrency) { + continue; + } - if (pricingData.trialUtilized || pricingData.isNetworkTrial) { - if (pricingData.isNetworkTrial) { - trialMessage = 'Multisite network level trials are currently not supported. Apologies for the inconvenience.'; - } else if ( ! pricingData.isTrial) { - let supportEmailAddress = this.state.plugin.main_support_email_address; + if (!pricing.supportsBillingCycle(pricingData.selectedBillingCycle)) { + continue; + } - trialMessage = Sorry, but you have already utilized a trial. Please contact us if you still want to test the paid version.; - } else { - trialMessage = 'Trial was already utilized for this site and only enabled for testing purposes since you are running in a sandbox mode.'; - } + hasAnyVisiblePricing = true; + break; + } - trialMessage =
    {trialMessage}
    ; - } + if (!hasAnyVisiblePricing) { + featuredPlan = null; + } + } - return ( - -
    - {trialMessage} -
    -
    -

    Plans and Pricing

    -

    Choose your plan and upgrade in minutes!

    -
    -
    - {this.getModuleIcon()} -

    {pricingData.plugin.title}

    -
    -
    -
    -
    - {pricingData.annualDiscount > 0 && -
    Save up to {pricingData.annualDiscount}% on Yearly Pricing!
    - } - {this.state.isTrial && -
    -

    Start your {pricingData.paidPlanWithTrial.trial_period}-day free trial

    -

    {( ! pricingData.paidPlanWithTrial.requiresSubscription()) ? 'No credit card required, includes all available features.' : `No commitment for ${pricingData.paidPlanWithTrial.trial_period} days - cancel anytime!`}

    -
    - } - {pricingData.billingCycles.length > 1 && ( ! this.state.isTrial || pricingData.paidPlanWithTrial.requiresSubscription()) && -
    - -
    - } - {pricingData.currencies.length > 1 && -
    - -
    - } -
    - -
    -
    -

    Need more sites, custom implementation and dedicated support?

    -

    We got you covered! Click here to contact us and we'll scope a plan that's tailored to your needs.

    -
    - {(pricingData.plugin.hasRefundPolicy() && ( ! this.state.isTrial || trialUtilized)) && -
    - -
    - } -
    - -
    -
    - {( ! Helper.isUndefinedOrNull(this.state.reviews) && this.state.reviews.length > 0) &&
    - -
    } -
    - -
    -
    - {pricingData.isActivatingTrial && - - } - { ! pricingData.isActivatingTrial && null !== pricingData.pendingConfirmationTrialPlan && - this.setState({pendingConfirmationTrialPlan: null})} startTrialHandler={this.startTrial}/> - } -
    -
    + let trialMessage = null; + + if (pricingData.trialUtilized || pricingData.isNetworkTrial) { + if (pricingData.isNetworkTrial) { + trialMessage = + 'Multisite network level trials are currently not supported. Apologies for the inconvenience.'; + } else if (!pricingData.isTrial) { + let supportEmailAddress = this.state.plugin.main_support_email_address; + + trialMessage = ( + + Sorry, but you have already utilized a trial. Please{' '} + contact us if you + still want to test the paid version. + ); + } else { + trialMessage = + 'Trial was already utilized for this site and only enabled for testing purposes since you are running in a sandbox mode.'; + } + + trialMessage =
    {trialMessage}
    ; } + + return ( + +
    + {trialMessage} +
    +
    +

    Plans and Pricing

    +

    Choose your plan and upgrade in minutes!

    +
    +
    + {this.getModuleIcon()} +

    + {pricingData.plugin.title} +

    +
    +
    +
    +
    + {pricingData.annualDiscount > 0 && ( +
    +
    + Save up to {pricingData.annualDiscount}% on Yearly Pricing! +
    +
    + )} + {this.state.isTrial && ( +
    +

    + Start your {pricingData.paidPlanWithTrial.trial_period}-day + free trial +

    +

    + {!pricingData.paidPlanWithTrial.requiresSubscription() + ? 'No credit card required, includes all available features.' + : `No commitment for ${pricingData.paidPlanWithTrial.trial_period} days - cancel anytime!`} +

    +
    + )} + {pricingData.billingCycles.length > 1 && + (!this.state.isTrial || + pricingData.paidPlanWithTrial.requiresSubscription()) && ( +
    + +
    + )} + {pricingData.currencies.length > 1 && ( +
    + +
    + )} +
    + +
    +
    +

    + Need more sites, custom implementation and dedicated support? +

    +

    + We got you covered!{' '} + + Click here to contact us + {' '} + and we'll scope a plan that's tailored to your needs. +

    +
    + {pricingData.plugin.hasRefundPolicy() && + (!this.state.isTrial || trialUtilized) && ( +
    + +
    + )} +
    + +
    +
    + {!Helper.isUndefinedOrNull(this.state.reviews) && + this.state.reviews.length > 0 && ( +
    + +
    + )} +
    + +
    +
    + {pricingData.isActivatingTrial && ( + + )} + {!pricingData.isActivatingTrial && + null !== pricingData.pendingConfirmationTrialPlan && ( + + this.setState({ pendingConfirmationTrialPlan: null }) + } + startTrialHandler={this.startTrial} + /> + )} +
    +
    + ); + } } -export default FreemiusPricingMain; \ No newline at end of file +export default FreemiusPricingMain; diff --git a/src/components/Loader/index.js b/src/components/Loader/index.js new file mode 100644 index 0000000..367fb97 --- /dev/null +++ b/src/components/Loader/index.js @@ -0,0 +1,45 @@ +import React, { Component } from 'react'; +import { Helper } from '../../Helper'; + +import './style.scss'; + +/** + * @author Leo Fajardo + */ +class Loader extends Component { + constructor(props) { + super(props); + } + + getFSSdkLoaderBar() { + return ( +
    + {Array.from({ length: 8 }).map((_, i) => ( +
    + ))} +
    + ); + } + + render() { + const { isEmbeddedDashboardMode, ...domProps } = this.props; + + return ( +
    +
    +
    + {Helper.isNonEmptyString(this.props.title) && ( + {this.props.title} + )} + {isEmbeddedDashboardMode ? this.getFSSdkLoaderBar() : } +
    +
    +
    + ); + } +} + +export default Loader; diff --git a/src/components/Loader/style.scss b/src/components/Loader/style.scss new file mode 100644 index 0000000..ebf46e5 --- /dev/null +++ b/src/components/Loader/style.scss @@ -0,0 +1,118 @@ +@import '../../assets/scss/vars'; + +// The complex selectors are intentional because how Loader component is +// sometimes rendered. +// 1. It could be rendered directly inside `#fs_pricing_wrapper` which comes from the server. +// 2. It could be rendered inside #fs_pricing_app which comes from our React App. +#fs_pricing_app, +#fs_pricing_wrapper, +#fs_pricing_wrapper #fs_pricing_app { + .fs-modal { + position: fixed; + top: 0; + right: 0; + left: 0; + bottom: 0; + z-index: 1000; + zoom: 1; + text-align: left; + display: block !important; + + .fs-modal-content-container { + display: block; + position: absolute; + left: 50%; + background: $fsds-background-color; + box-shadow: 0 0 8px 2px rgba(0, 0, 0, 0.3); + + .fs-modal-header { + background: $fsds-primary-accent-color; + padding: 15px; + + h3, + .fs-modal-close { + color: $fsds-background-color; + } + } + + .fs-modal-content { + font-size: 1.2em; + } + } + } + + .fs-modal--loading { + background-color: rgba(0, 0, 0, 0.3); + + .fs-modal-content-container { + width: 220px; + margin-left: -126px; + padding: 15px; + border: 1px solid $fsds-divider-color; + text-align: center; + top: 50%; + + span { + display: block; + font-weight: bold; + font-size: 16px; + text-align: center; + color: $fsds-primary-accent-color; + margin-bottom: 10px; + } + + .fs-ajax-loader { + width: 160px; + } + + i { + display: block; + width: 128px; + margin: 0 auto; + height: 15px; + background: url(//img.freemius.com/blue-loader.gif); + } + } + } + + .fs-modal--refund-policy, + .fs-modal--trial-confirmation { + background: rgba(0, 0, 0, 0.7); + + .fs-modal-content-container { + width: 510px; + margin-left: -255px; + top: 20%; + + .fs-modal-header .fs-modal-close { + line-height: 24px; + font-size: 24px; + position: absolute; + top: -12px; + right: -12px; + cursor: pointer; + } + + .fs-modal-content { + height: 100%; + padding: 1px 15px; + } + + .fs-modal-footer { + padding: 10px; + text-align: right; + border-top: 1px solid $fsds-border-color; + background: $fsds-background-shade; + + .fs-button--approve-trial { + margin: 0 7px; + } + } + } + } + + .fs-modal--trial-confirmation .fs-button { + width: auto; + font-size: 13px; + } +} diff --git a/src/components/Package/index.js b/src/components/Package/index.js new file mode 100644 index 0000000..cb53d8f --- /dev/null +++ b/src/components/Package/index.js @@ -0,0 +1,600 @@ +import React, { Component, Fragment } from 'react'; +import FSPricingContext from '../../FSPricingContext'; +import { + BillingCycle, + BillingCycleString, + UnlimitedLicenses, +} from '../../entities/Pricing'; +import { PlanManager } from '../../services/PlanManager'; +import Tooltip from '../Tooltip'; +import Icon from '../Icon'; +import { Helper } from '../../Helper'; +import { Plan } from '../../entities/Plan'; +import Placeholder from '../Placeholder'; + +import './style.scss'; + +class Package extends Component { + static contextType = FSPricingContext; + static contextInstallPlanFound = false; + + /** + * If we unset it (or set it to `undefined`) it will use the browser's locale. + * For now we are going to use the 'en-US' locale, until we start supporting other locales in our checkout for a consistent experience. + * + * @author Vova Feldman + */ + static locale = 'en-US'; + + previouslySelectedPricingByPlan = {}; + + constructor(props) { + super(props); + } + + /** + * @return {string} Returns `Billed Annually`, `Billed Once`, or `Billed Monthly`. + */ + billingCycleLabel() { + let label = 'Billed '; + + if (BillingCycleString.ANNUAL === this.context.selectedBillingCycle) + label += 'Annually'; + else if (BillingCycleString.LIFETIME === this.context.selectedBillingCycle) + label += 'Once'; + else label += 'Monthly'; + + return label; + } + + changeLicenses(e) { + let target = e.currentTarget; + + if ('tr' !== target.tagName.toLowerCase()) { + target = target.closest('tr'); + } + + let pricingID = target.dataset['pricingId']; + + document.getElementById(`pricing_${pricingID}`).click(); + } + + /** + * @returns {Plan|null} + */ + getContextPlan() { + // If current install has no plan, then it is never a downgrade. + if ( + Helper.isUndefinedOrNull(this.context.install) || + Helper.isUndefinedOrNull(this.context.install.plan_id) + ) { + return null; + } + + return PlanManager.getInstance().getPlanByID(this.context.install.plan_id); + } + + /** + * @returns {'upgrade'|'downgrade'|'none'} + */ + getPlanChangeType() { + const plan = this.props.planPackage; + const contextPlan = this.getContextPlan(); + + // If current install has no plan, then it is never a downgrade. + if (!contextPlan) { + return 'upgrade'; + } + + if (PlanManager.getInstance().isFreePlan(contextPlan.pricing)) { + return 'upgrade'; + } + + // At this point, the install has a plan. Now we need to compare the given plan with the context plan. + + // If the given plan is free, then it is always a downgrade. + if (PlanManager.getInstance().isFreePlan(plan.pricing)) { + return 'downgrade'; + } + + // Now if the given plan is higher than the context plan, then it is a upgrade and vice-versa. + const planCompareResult = PlanManager.getInstance().comparePlanByIDs( + plan.id, + contextPlan.id + ); + + if (planCompareResult > 0) { + return 'upgrade'; + } + + if (planCompareResult < 0) { + return 'downgrade'; + } + + // We are on the same plan. Now we need to compare the license count. + const activeLicenseQuantity = + this.props.installPlanLicensesCount ?? UnlimitedLicenses; + + const selectedLicenseQuantity = + (this.props.isSinglePlan + ? plan.selectedPricing?.licenses + : this.context.selectedLicenseQuantity) ?? UnlimitedLicenses; + + if (activeLicenseQuantity < selectedLicenseQuantity) { + return 'upgrade'; + } + + if (activeLicenseQuantity > selectedLicenseQuantity) { + return 'downgrade'; + } + + return 'none'; + } + + /** + * @param {'upgrade'|'downgrade'|'none'} planChangeType + * + * @return {string|Fragment} + */ + getCtaButtonLabel(planChangeType) { + const plan = this.props.planPackage; + + if ( + this.context.isActivatingTrial && + this.context.upgradingToPlanID == plan.id + ) { + return 'Activating...'; + } + + if (this.context.isTrial && plan.hasTrial()) { + return ( + + Start my free {plan.trial_period} days + + ); + } + + const contextPlan = this.getContextPlan(); + + const isPayingUser = + !this.context.isTrial && + contextPlan && + !this.isInstallInTrial(this.context.install) && + PlanManager.getInstance().isPaidPlan(contextPlan.pricing); + + switch (planChangeType) { + case 'downgrade': + return 'Downgrade'; + case 'none': + return 'Your Plan'; + + default: + case 'upgrade': + return `Upgrade${!isPayingUser ? ' Now' : ''}`; + } + } + + getUndiscountedPrice(planPackage, selectedPricing, selectedPricingCycleLabel) { + if ( + BillingCycleString.ANNUAL !== this.context.selectedBillingCycle || + !(this.context.annualDiscount > 0) + ) { + return ; + } + + if (planPackage.is_free_plan || null === selectedPricing) { + return ; + } + + let amount; + + if ('mo' === selectedPricingCycleLabel) { + amount = selectedPricing.getMonthlyAmount(BillingCycle.MONTHLY, true, Package.locale); + } else { + amount = selectedPricing.getYearlyAmount(BillingCycle.MONTHLY, true, Package.locale); + } + + return ( +
    + Normally {this.context.currencySymbols[this.context.selectedCurrency]} + {amount}{' '} + / selectedPricingCycleLabel +
    + ); + } + + getSitesLabel(planPackage, selectedPricing, pricingLicenses) { + if (planPackage.is_free_plan) { + return ; + } + + return ( +
    + {selectedPricing.sitesLabel()} + {!planPackage.is_free_plan && ( + + + If you are running a multi-site network, each site in the network + requires a license. + {pricingLicenses.length > 0 + ? 'Therefore, if you need to use it on multiple sites, check out our multi-site prices.' + : ''} + + + )} +
    + ); + } + + /** + * @param {Object} pricing Pricing entity. + * @param {string} [locale] The country code and language code combination (e.g. 'fr-FR'). + * + * @return {string} The price label in this format: `$4.99 / mo` or `$4.99 / year` + */ + priceLabel(pricing, locale) { + let pricingData = this.context, + label = '', + price = pricing[pricingData.selectedBillingCycle + '_price']; + + label += pricingData.currencySymbols[pricingData.selectedCurrency]; + label += Helper.formatNumber(price, locale); + + if (BillingCycleString.MONTHLY === pricingData.selectedBillingCycle) + label += ' / mo'; + else if (BillingCycleString.ANNUAL === pricingData.selectedBillingCycle) + label += ' / year'; + + return label; + } + + isInstallInTrial(install) { + if ( + !Helper.isNumeric(install.trial_plan_id) || + Helper.isUndefinedOrNull(install.trial_ends) + ) { + return false; + } + + return Date.parse(install.trial_ends) > new Date().getTime(); + } + + render() { + let isSinglePlan = this.props.isSinglePlan, + planPackage = this.props.planPackage, + currentLicenseQuantities = this.props.currentLicenseQuantities, + pricingLicenses = null, + selectedLicenseQuantity = this.context.selectedLicenseQuantity, + pricingCollection = {}, + selectedPricing = null, + selectedPricingAmount = null, + supportLabel = null, + showAnnualInMonthly = this.context.showAnnualInMonthly, + selectedPricingCycleLabel = 'mo'; + + if (this.props.isFirstPlanPackage) { + Package.contextInstallPlanFound = false; + } + + if (!planPackage.is_free_plan) { + pricingCollection = planPackage.pricingCollection; + pricingLicenses = planPackage.pricingLicenses; + selectedPricing = planPackage.selectedPricing; + + if (!selectedPricing) { + if ( + !this.previouslySelectedPricingByPlan[planPackage.id] || + this.context.selectedCurrency !== + this.previouslySelectedPricingByPlan[planPackage.id].currency || + !this.previouslySelectedPricingByPlan[ + planPackage.id + ].supportsBillingCycle(this.context.selectedBillingCycle) + ) { + /** + * Select the first pricing if there's no previously selected pricing that matches the selected license quantity and currency. + */ + this.previouslySelectedPricingByPlan[planPackage.id] = + pricingCollection[pricingLicenses[0]]; + } + + selectedPricing = this.previouslySelectedPricingByPlan[planPackage.id]; + + selectedLicenseQuantity = selectedPricing.getLicenses(); + } + + this.previouslySelectedPricingByPlan[planPackage.id] = selectedPricing; + + if (BillingCycleString.ANNUAL === this.context.selectedBillingCycle) + { + if (true === showAnnualInMonthly || (Helper.isUndefinedOrNull(showAnnualInMonthly) && selectedPricing.hasMonthlyPrice())) { + selectedPricingAmount = Helper.formatNumber(selectedPricing.getMonthlyAmount(BillingCycle.ANNUAL), Package.locale); + } + + if (false === showAnnualInMonthly || (Helper.isUndefinedOrNull(showAnnualInMonthly) && ! selectedPricing.hasMonthlyPrice())) { + selectedPricingAmount = Helper.formatNumber(selectedPricing.getYearlyAmount(BillingCycle.ANNUAL), Package.locale); + selectedPricingCycleLabel = 'yr'; + } + } else { + selectedPricingAmount = selectedPricing[`${this.context.selectedBillingCycle}_price`].toString(); + } + } + + if (!planPackage.hasAnySupport()) { + supportLabel = 'No Support'; + } else if (planPackage.hasSuccessManagerSupport()) { + supportLabel = 'Priority Phone, Email & Chat Support'; + } else { + let supportedChannels = []; + + if (planPackage.hasPhoneSupport()) { + supportedChannels.push('Phone'); + } + + if (planPackage.hasSkypeSupport()) { + supportedChannels.push('Skype'); + } + + if (planPackage.hasEmailSupport()) { + supportedChannels.push( + (this.context.priorityEmailSupportPlanID == planPackage.id + ? 'Priority ' + : '') + 'Email' + ); + } + + if (planPackage.hasForumSupport()) { + supportedChannels.push('Forum'); + } + + if (planPackage.hasKnowledgeBaseSupport()) { + supportedChannels.push('Help Center'); + } + + if (1 === supportedChannels.length) { + supportLabel = `${supportedChannels[0]} Support`; + } else { + supportLabel = + supportedChannels.slice(0, supportedChannels.length - 1).join(', ') + + ' & ' + + supportedChannels[supportedChannels.length - 1] + + ' Support'; + } + } + + let packageClassName = 'fs-package'; + let isFeatured = false; + + if (planPackage.is_free_plan) { + packageClassName += ' fs-free-plan'; + } else if (!isSinglePlan && planPackage.is_featured) { + packageClassName += ' fs-featured-plan'; + isFeatured = true; + } + + const localDecimalSeparator = Helper.formatNumber(0.1, Package.locale)[1]; + + let selectedAmountInteger, selectedAmountFraction; + + if (selectedPricingAmount) { + const amountParts = selectedPricingAmount.split('.'); + + selectedAmountInteger = Helper.formatNumber(parseInt(amountParts[0], 10)); + selectedAmountFraction = Helper.formatFraction(amountParts[1]); + } + + const planChangeType = this.getPlanChangeType(); + + return ( +
  • +
    +

    + Most Popular +

    +
    +
    +

    + + {isSinglePlan ? selectedPricing.sitesLabel() : planPackage.title} + +

    +

    + {planPackage.description_lines} +

    + {this.getUndiscountedPrice(planPackage, selectedPricing, selectedPricingCycleLabel)} +
    + + {!planPackage.is_free_plan + ? this.context.currencySymbols[this.context.selectedCurrency] + : ''} + + + + {planPackage.is_free_plan ? 'Free' : selectedAmountInteger} + + + + + {!planPackage.is_free_plan + ? localDecimalSeparator + selectedAmountFraction + : ''} + + {!planPackage.is_free_plan && + BillingCycleString.LIFETIME !== + this.context.selectedBillingCycle && ( + / {selectedPricingCycleLabel} + )} + +
    +
    + {!planPackage.is_free_plan ? ( + {this.billingCycleLabel()} + ) : ( + + )} +
    + {this.getSitesLabel(planPackage, selectedPricing, pricingLicenses)} +
    + {null !== supportLabel && ( +
    + {supportLabel} +
    + )} +
      + {planPackage.highlighted_features.map(feature => { + if (!Helper.isNonEmptyString(feature.title)) { + return ( +
    • + +
    • + ); + } + + return ( +
    • + + + {feature.value} + + {feature.title} + + {Helper.isNonEmptyString(feature.description) && ( + + {feature.description} + + )} +
    • + ); + })} +
    +
    + {!isSinglePlan && ( + + + {Object.keys(currentLicenseQuantities).map(licenseQuantity => { + let pricing = pricingCollection[licenseQuantity]; + + if (Helper.isUndefinedOrNull(pricing)) { + return ( + + + + + + ); + } + + let isPricingLicenseQuantitySelected = + selectedLicenseQuantity == licenseQuantity; + + let multiSiteDiscount = + PlanManager.getInstance().calculateMultiSiteDiscount( + pricing, + this.context.selectedBillingCycle, + this.context.discountsModel + ); + + return ( + + + {multiSiteDiscount > 0 ? ( + + ) : ( + + )} + + + ); + })} + +
    + +
    + + {pricing.sitesLabel()} + + Save {multiSiteDiscount}% + + {this.priceLabel(pricing, Package.locale)} +
    + )} +
    + +
    +
      + {planPackage.nonhighlighted_features.map(feature => { + if (!Helper.isNonEmptyString(feature.title)) { + return ( +
    • + +
    • + ); + } + + const featureTitle = + 0 === feature.id.indexOf('all_plan_') ? ( + {feature.title} + ) : ( + feature.title + ); + + return ( +
    • + + {featureTitle} + {Helper.isNonEmptyString(feature.description) && ( + + {feature.description} + + )} +
    • + ); + })} +
    +
    +
  • + ); + } +} + +export default Package; diff --git a/src/components/Package/style.scss b/src/components/Package/style.scss new file mode 100644 index 0000000..7f97241 --- /dev/null +++ b/src/components/Package/style.scss @@ -0,0 +1,359 @@ +@import '../../assets/scss/vars'; + +#root, +#fs_pricing_app { + .fs-package { + display: inline-block; + vertical-align: top; + background: $fsds-dark-background-text-color; + border-bottom: 3px solid $fsds-border-color; + width: 315px; + box-sizing: border-box; + + &:first-child, + & + .fs-package { + border-left: 1px solid $fsds-divider-color; + } + + &:last-child { + border-right: 1px solid $fsds-divider-color; + } + + &:not(.fs-featured-plan) { + &:first-child { + border-top-left-radius: 10px; + + .fs-plan-title { + border-top-left-radius: 9px; + } + } + + &:last-child { + border-top-right-radius: 10px; + + .fs-plan-title { + border-top-right-radius: 9px; + } + } + } + + .fs-package-content { + vertical-align: middle; + padding-bottom: 30px; + } + + .fs-plan-title { + padding: 10px 0; + background: $fsds-background-shade; + text-transform: uppercase; + border-bottom: 1px solid $fsds-divider-color; + border-top: 1px solid $fsds-divider-color; + width: 100%; + text-align: center; + + &:last-child { + border-right: none; + } + } + + .fs-plan-description, + .fs-undiscounted-price, + .fs-licenses, + .fs-upgrade-button, + .fs-plan-features { + margin-top: 10px; + } + + .fs-plan-description { + text-transform: uppercase; + } + + .fs-undiscounted-price { + margin: auto; + position: relative; + display: inline-block; + color: $fsds-muted-text-color; + top: 6px; + + &::after { + display: block; + content: ''; + position: absolute; + height: 1px; + background-color: $fsds-error-color; + left: -4px; + right: -4px; + top: 50%; + transform: translateY(-50%) skewY(1deg); + } + } + + .fs-selected-pricing-amount { + margin: 5px 0; + + .fs-currency-symbol { + font-size: 39px; + } + + .fs-selected-pricing-amount-integer { + font-size: 58px; + margin: 0 5px; + } + + .fs-currency-symbol, + .fs-selected-pricing-amount-integer, + .fs-selected-pricing-amount-fraction-container { + display: inline-block; + vertical-align: middle; + + &:not(.fs-selected-pricing-amount-integer) { + line-height: 18px; + } + + .fs-selected-pricing-amount-fraction, + .fs-selected-pricing-amount-cycle { + display: block; + font-size: 12px; + } + + .fs-selected-pricing-amount-fraction { + vertical-align: top; + } + + .fs-selected-pricing-amount-cycle { + vertical-align: bottom; + } + } + + .fs-selected-pricing-amount-fraction-container { + color: $fsds-muted-text-color; + } + } + + .fs-selected-pricing-amount-free { + font-size: 48px; + } + + .fs-selected-pricing-cycle { + margin-bottom: 5px; + text-transform: uppercase; + color: $fsds-muted-text-color; + } + + .fs-selected-pricing-license-quantity { + color: $fsds-muted-text-color; + + .fs-tooltip { + margin-left: 5px; + } + } + + .fs-upgrade-button-container { + padding: 0 13px; + display: block; + + .fs-upgrade-button { + margin-top: 20px; + margin-bottom: 5px; + } + } + + .fs-plan-features { + text-align: left; + margin-left: 13px; + + li { + font-size: 16px; + display: flex; + margin-bottom: 8px; + + &:not(:first-child) { + margin-top: 8px; + } + + > span, + .fs-tooltip { + font-size: small; + vertical-align: middle; + display: inline-block; + } + + .fs-feature-title { + margin: 0 5px; + color: $fsds-muted-text-color; + max-width: 260px; + overflow-wrap: break-word; + } + } + } + + .fs-support-and-main-features { + margin-top: 12px; + padding-top: 18px; + padding-bottom: 18px; + color: $fsds-muted-text-color; + + .fs-plan-support { + margin-bottom: 15px; + } + + .fs-plan-features-with-value { + li { + font-size: small; + + .fs-feature-title { + margin: 0 2px; + } + + &:not(:first-child) { + margin-top: 5px; + } + } + } + } + + .fs-plan-features-with-value { + color: $fsds-muted-text-color; + } + + .fs-license-quantities { + border-collapse: collapse; + position: relative; + width: 100%; + + &, + input { + cursor: pointer; + } + + .fs-license-quantity-discount span { + background-color: $fsds-background-color; + border: 1px solid $fsds-primary-accent-color; + color: $fsds-primary-accent-color; + display: inline; + padding: 4px 8px; + border-radius: 4px; + font-weight: bold; + margin: 0 5px; + white-space: nowrap; + + &.fs-license-quantity-no-discount { + visibility: hidden; + } + } + + .fs-license-quantity-container { + line-height: 30px; + border-top: 1px solid $fsds-background-shade; + font-size: small; + color: $fsds-muted-text-color; + + &:last-child { + border-bottom: 1px solid $fsds-background-shade; + &.fs-license-quantity-selected { + border-bottom-color: $fsds-divider-color; + } + } + + &.fs-license-quantity-selected { + background: $fsds-background-shade; + border-color: $fsds-divider-color; + color: $fsds-text-color; + + + .fs-license-quantity-container { + border-top-color: $fsds-divider-color; + } + } + + > td:not(.fs-license-quantity-discount):not(.fs-license-quantity-price) { + text-align: left; + } + } + + .fs-license-quantity, + .fs-license-quantity-discount, + .fs-license-quantity-price { + vertical-align: middle; + } + + .fs-license-quantity { + position: relative; + white-space: nowrap; + + input { + position: relative; + margin-top: -1px; + margin-left: 7px; + margin-right: 7px; + } + } + + .fs-license-quantity-price { + position: relative; + margin-right: auto; + padding-right: 7px; + white-space: nowrap; + font-variant-numeric: tabular-nums; + text-align: right; + } + } + + &.fs-free-plan { + .fs-license-quantity-container:not(:last-child) { + border-color: transparent; + } + } + + .fs-most-popular { + display: none; + } + + &.fs-featured-plan { + .fs-most-popular { + display: block; + line-height: 2.8em; + margin-top: -2.8em; + border-radius: 10px 10px 0 0; + color: $fsds-text-color; + background: $fsds-package-popular-background; + text-transform: uppercase; + font-size: 14px; + } + + .fs-plan-title { + color: $fsds-dark-background-text-color; + background: $fsds-primary-accent-color; + border-top-color: $fsds-primary-accent-color; + border-bottom-color: $fsds-primary-accent-color; + } + + .fs-selected-pricing-license-quantity { + color: $fsds-primary-accent-color; + } + + .fs-license-quantity-discount span { + background: $fsds-primary-accent-color; + color: $fsds-dark-background-text-color; + } + + .fs-license-quantities .fs-license-quantity-selected { + background: $fsds-primary-accent-color; + border-color: $fsds-primary-accent-color; + color: $fsds-dark-background-text-color; + + + .fs-license-quantity-container { + border-top-color: $fsds-primary-accent-color; + } + + &:last-child { + border-bottom-color: $fsds-primary-accent-color; + } + + .fs-license-quantity-discount span { + background: $fsds-background-color; + color: $fsds-primary-accent-color-hover; + } + } + } + } +} diff --git a/src/components/PackagesContainer/index.js b/src/components/PackagesContainer/index.js new file mode 100644 index 0000000..4cb884b --- /dev/null +++ b/src/components/PackagesContainer/index.js @@ -0,0 +1,677 @@ +import React, { Component, Fragment } from 'react'; +import FSPricingContext from '../../FSPricingContext'; +import { BillingCycleString } from '../../entities/Pricing'; +import { PlanManager } from '../../services/PlanManager'; +import { Helper } from '../../Helper'; +import { Plan } from '../../entities/Plan'; +import Package from '../Package'; +import Icon from '../Icon'; +import Placeholder from '../Placeholder'; +import { debounce } from '../../utils/debounce'; + +import './style.scss'; + +class PackagesContainer extends Component { + static contextType = FSPricingContext; + + slider = null; + + constructor(props) { + super(props); + } + + /** + * @return {string} Returns `Billed Annually`, `Billed Once`, or `Billed Monthly`. + */ + billingCycleLabel() { + let label = 'Billed '; + + if (BillingCycleString.ANNUAL === this.context.selectedBillingCycle) + label += 'Annually'; + else if (BillingCycleString.LIFETIME === this.context.selectedBillingCycle) + label += 'Once'; + else label += 'Monthly'; + + return label; + } + + /** + * @param {Object} pricing Pricing entity. + * + * @return {string} The price label in this format: `$4.99 / mo` or `$4.99 / year` + */ + priceLabel(pricing) { + let pricingData = this.context, + label = '', + price = pricing[pricingData.selectedBillingCycle + '_price']; + + label += pricingData.currencySymbols[pricingData.selectedCurrency]; + label += Helper.formatNumber(price); + + if (BillingCycleString.MONTHLY === pricingData.selectedBillingCycle) + label += ' / mo'; + else if (BillingCycleString.ANNUAL === pricingData.selectedBillingCycle) + label += ' / year'; + + return label; + } + + componentDidMount() { + this.slider = (function () { + let firstVisibleIndex, + $plansAndPricingSection, + $track, + $packages, + $packagesContainer, + $nextPackage, + $prevPackage, + $packagesMenu, + $packagesTab, + defaultNextPrevPreviewWidth, + cardMinWidth, + maxMobileScreenWidth, + cardWidth, + nextPrevPreviewWidth, + screenWidth, + visibleCards, + mobileSectionOffset; + + let init = function () { + firstVisibleIndex = 0; + $plansAndPricingSection = document.querySelector( + '.fs-section--plans-and-pricing' + ); + $track = $plansAndPricingSection.querySelector('.fs-section--packages'); + $packages = $track.querySelectorAll('.fs-package'); + $packagesContainer = $track.querySelector('.fs-packages'); + $nextPackage = + $plansAndPricingSection.querySelector('.fs-next-package'); + $prevPackage = + $plansAndPricingSection.querySelector('.fs-prev-package'); + $packagesMenu = + $plansAndPricingSection.querySelector('.fs-packages-menu'); + $packagesTab = + $plansAndPricingSection.querySelector('.fs-packages-tab'); + defaultNextPrevPreviewWidth = 60; + cardMinWidth = 315; + maxMobileScreenWidth = 768; + mobileSectionOffset = 20; + }; + + const isMobileDevice = function () { + const sectionComputedStyle = window.getComputedStyle( + $plansAndPricingSection + ), + sectionWidth = parseFloat(sectionComputedStyle.width); + + return sectionWidth < cardMinWidth * 2 - mobileSectionOffset; + }; + + let slide = function (selectedIndex, leftOffset) { + let leftPos = + -1 * selectedIndex * cardWidth + (leftOffset ? leftOffset : 0) - 1; + + $packagesContainer.style.left = leftPos + 'px'; + }; + + let nextSlide = function () { + firstVisibleIndex++; + + let leftOffset = 0; + + if (!isMobileDevice() && screenWidth > maxMobileScreenWidth) { + leftOffset = defaultNextPrevPreviewWidth; + + if (firstVisibleIndex + visibleCards >= $packages.length) { + $nextPackage.style.visibility = 'hidden'; + $packagesContainer.parentNode.classList.remove('fs-has-next-plan'); + + if (firstVisibleIndex - 1 > 0) { + leftOffset *= 2; + } + } + + if (firstVisibleIndex > 0) { + $prevPackage.style.visibility = 'visible'; + $packagesContainer.parentNode.classList.add('fs-has-previous-plan'); + } + } + + slide(firstVisibleIndex, leftOffset); + }; + + let prevSlide = function () { + firstVisibleIndex--; + + let leftOffset = 0; + + if (!isMobileDevice() && screenWidth > maxMobileScreenWidth) { + if (firstVisibleIndex - 1 < 0) { + $prevPackage.style.visibility = 'hidden'; + $packagesContainer.parentNode.classList.remove( + 'fs-has-previous-plan' + ); + } + + if (firstVisibleIndex + visibleCards <= $packages.length) { + $nextPackage.style.visibility = 'visible'; + $packagesContainer.parentNode.classList.add('fs-has-next-plan'); + + if (firstVisibleIndex > 0) { + leftOffset = defaultNextPrevPreviewWidth; + } + } + } + + slide(firstVisibleIndex, leftOffset); + }; + + let adjustPackages = function () { + $packagesContainer.parentNode.classList.remove('fs-has-previous-plan'); + $packagesContainer.parentNode.classList.remove('fs-has-next-plan'); + + screenWidth = window.outerWidth; + + let sectionComputedStyle = window.getComputedStyle( + $plansAndPricingSection + ), + sectionWidth = parseFloat(sectionComputedStyle.width), + sectionLeftPos = 0, + isMobile = screenWidth <= maxMobileScreenWidth || isMobileDevice(); + + nextPrevPreviewWidth = defaultNextPrevPreviewWidth; + + if (isMobile) { + visibleCards = 1; + cardWidth = sectionWidth; + } else { + visibleCards = Math.floor(sectionWidth / cardMinWidth); + + if (visibleCards === $packages.length) { + nextPrevPreviewWidth = 0; + } else if (visibleCards < $packages.length) { + visibleCards = Math.floor( + (sectionWidth - nextPrevPreviewWidth) / cardMinWidth + ); + + if (visibleCards + 1 < $packages.length) { + nextPrevPreviewWidth *= 2; + visibleCards = Math.floor( + (sectionWidth - nextPrevPreviewWidth) / cardMinWidth + ); + } + } + + cardWidth = cardMinWidth; + } + + $packagesContainer.style.width = cardWidth * $packages.length + 'px'; + + sectionWidth = + visibleCards * cardWidth + (!isMobile ? nextPrevPreviewWidth : 0); + + $packagesContainer.parentNode.style.width = sectionWidth + 'px'; + + $packagesContainer.style.left = sectionLeftPos + 'px'; + + if (!isMobile && visibleCards < $packages.length) { + $nextPackage.style.visibility = 'visible'; + + /** + * Center the prev and next buttons on the available space on the left and right sides of the packages collection. + */ + let packagesContainerParentMargin = parseFloat( + window.getComputedStyle($packagesContainer.parentNode).marginLeft + ), + sectionPadding = parseFloat(sectionComputedStyle.paddingLeft), + prevButtonRightPos = -sectionPadding, + nextButtonRightPos = sectionWidth + packagesContainerParentMargin, + nextPrevWidth = parseFloat( + window.getComputedStyle($nextPackage).width + ); + + $prevPackage.style.left = + prevButtonRightPos + + (sectionPadding + packagesContainerParentMargin - nextPrevWidth) / + 2 + + 'px'; + $nextPackage.style.left = + nextButtonRightPos + + (sectionPadding + packagesContainerParentMargin - nextPrevWidth) / + 2 + + 'px'; + + $packagesContainer.parentNode.classList.add('fs-has-next-plan'); + } else { + $prevPackage.style.visibility = 'hidden'; + $nextPackage.style.visibility = 'hidden'; + } + + for (let $package of $packages) { + $package.style.width = cardWidth + 'px'; + } + + if ($packagesMenu) { + firstVisibleIndex = $packagesMenu.selectedIndex; + } else if ($packagesTab) { + let $tabs = $packagesTab.querySelectorAll('li'); + + for (let i = 0; i < $tabs.length; i++) { + let $tab = $tabs[i]; + + if ($tab.classList.contains('fs-package-tab--selected')) { + firstVisibleIndex = i; + break; + } + } + } + + if (firstVisibleIndex > 0) { + firstVisibleIndex--; + nextSlide(); + } + }; + + init(); + adjustPackages(); + + const handlePackagesMenuChange = evt => { + firstVisibleIndex = evt.target.selectedIndex - 1; + nextSlide(); + }; + + if ($packagesMenu) { + $packagesMenu.addEventListener('change', handlePackagesMenuChange); + } + + const debouncedAdjustPackages = debounce(adjustPackages, 250); + + $nextPackage.addEventListener('click', nextSlide); + $prevPackage.addEventListener('click', prevSlide); + window.addEventListener('resize', debouncedAdjustPackages); + + return { + adjustPackages, + clearEventListeners() { + $nextPackage.removeEventListener('click', nextSlide); + $prevPackage.removeEventListener('click', prevSlide); + window.removeEventListener('resize', debouncedAdjustPackages); + if ($packagesMenu) { + $packagesMenu.removeEventListener( + 'change', + handlePackagesMenuChange + ); + } + }, + }; + })(); + } + + componentWillUnmount() { + this.slider?.clearEventListeners(); + } + + componentDidUpdate(prevProps, prevState, snapshot) { + this.slider?.adjustPackages(); + } + + render() { + let packages = null, + licenseQuantities = + this.context.licenseQuantities[this.context.selectedCurrency], + licenseQuantitiesCount = Object.keys(licenseQuantities).length, + currentLicenseQuantities = {}, + isSinglePlan = false; + + if (this.context.paidPlansCount > 1 || 1 === licenseQuantitiesCount) { + // If there are more than one paid plans, create a package component for each plan. + packages = this.context.plans; + } else { + // If there is only one paid plan and it supports multi-license options, create a package component for license quantity. + packages = []; + + let paidPlan = null; + + for (paidPlan of this.context.plans) { + if (PlanManager.getInstance().isHiddenOrFreePlan(paidPlan)) { + continue; + } + + break; + } + + for (let pricing of paidPlan.pricing) { + if ( + pricing.is_hidden || + this.context.selectedCurrency !== pricing.currency || + !pricing.supportsBillingCycle(this.context.selectedBillingCycle) + ) { + continue; + } + + let planClone = Object.assign(new Plan(), paidPlan); + + planClone.pricing = [pricing]; + + packages.push(planClone); + } + + isSinglePlan = true; + } + + let visiblePlanPackages = [], + maxHighlightedFeaturesCount = 0, + maxNonHighlightedFeaturesCount = 0, + prevNonHighlightedFeatures = {}, + maxPlanDescriptionLinesCount = 0, + prevPlanPackage = null, + installPlanLicensesCount = 0; + + for (let planPackage of packages) { + if (planPackage.is_hidden) { + continue; + } + + let isFreePlan = PlanManager.getInstance().isFreePlan( + planPackage.pricing + ); + + if (isFreePlan) { + if (this.context.paidPlansCount >= 3) { + continue; + } + + planPackage.is_free_plan = isFreePlan; + } else { + planPackage.pricingCollection = {}; + + planPackage.pricing.map(pricing => { + let licenses = pricing.getLicenses(); + + if ( + pricing.is_hidden || + this.context.selectedCurrency !== pricing.currency + ) { + return; + } + + if ( + !pricing.supportsBillingCycle(this.context.selectedBillingCycle) + ) { + return; + } + + planPackage.pricingCollection[licenses] = pricing; + + if ( + isSinglePlan || + this.context.selectedLicenseQuantity == licenses + ) { + planPackage.selectedPricing = pricing; + } + + if ( + this.context.license && + this.context.license.pricing_id == pricing.id + ) { + installPlanLicensesCount = pricing.licenses; + } + }); + + let pricingLicenses = Object.keys(planPackage.pricingCollection); + + if (0 === pricingLicenses.length) { + continue; + } + + planPackage.pricingLicenses = pricingLicenses; + } + + planPackage.highlighted_features = []; + planPackage.nonhighlighted_features = []; + + if (null !== prevPlanPackage) { + planPackage.nonhighlighted_features.push({ + id: `all_plan_${prevPlanPackage.id}_features`, + title: `All ${prevPlanPackage.title} Features`, + }); + } + + if (planPackage.hasSuccessManagerSupport()) { + planPackage.nonhighlighted_features.push({ + id: `plan_${planPackage.id}_personal_success_manager`, + title: 'Personal Success Manager', + }); + } + + if (!Helper.isNonEmptyString(planPackage.description)) { + planPackage.description_lines = []; + } else { + planPackage.description_lines = planPackage.description + .split('\n') + .map((item, key) => { + return ( + + {item} +
    +
    + ); + }); + } + + maxPlanDescriptionLinesCount = Math.max( + maxPlanDescriptionLinesCount, + planPackage.description_lines.length + ); + + visiblePlanPackages.push(planPackage); + + if (Helper.isUndefinedOrNull(planPackage.features)) { + continue; + } + + for (let feature of planPackage.features) { + if (!feature.is_featured) { + continue; + } + + if ( + Helper.isNonEmptyString(feature.value) || + Helper.isNumeric(feature.value) + ) { + planPackage.highlighted_features.push(feature); + } else if ( + isSinglePlan || + Helper.isUndefinedOrNull( + prevNonHighlightedFeatures[`f_${feature.id}`] + ) + ) { + planPackage.nonhighlighted_features.push(feature); + + prevNonHighlightedFeatures[`f_${feature.id}`] = true; + } + } + + maxHighlightedFeaturesCount = Math.max( + maxHighlightedFeaturesCount, + planPackage.highlighted_features.length + ); + maxNonHighlightedFeaturesCount = Math.max( + maxNonHighlightedFeaturesCount, + planPackage.nonhighlighted_features.length + ); + + if (!isFreePlan) { + for (let pricing of planPackage.pricing) { + if ( + pricing.is_hidden || + this.context.selectedCurrency !== pricing.currency || + !pricing.supportsBillingCycle(this.context.selectedBillingCycle) + ) { + continue; + } + + currentLicenseQuantities[pricing.getLicenses()] = true; + } + } + + if (!isSinglePlan) { + prevPlanPackage = planPackage; + } + } + + let packageComponents = [], + isFirstPlanPackage = true, + hasFeaturedPlan = false, + mobileTabs = [], + mobileDropdownOptions = [], + selectedPlanOrPricingID = this.context.selectedPlanID; + + for (let visiblePlanPackage of visiblePlanPackages) { + if ( + visiblePlanPackage.highlighted_features.length < + maxHighlightedFeaturesCount + ) { + const total = + maxHighlightedFeaturesCount - + visiblePlanPackage.highlighted_features.length; + + for (let i = 0; i < total; i++) { + visiblePlanPackage.highlighted_features.push({ id: `filler_${i}` }); + } + } + + if ( + visiblePlanPackage.nonhighlighted_features.length < + maxNonHighlightedFeaturesCount + ) { + const total = + maxNonHighlightedFeaturesCount - + visiblePlanPackage.nonhighlighted_features.length; + + for (let i = 0; i < total; i++) { + visiblePlanPackage.nonhighlighted_features.push({ + id: `filler_${i}`, + }); + } + } + + if ( + visiblePlanPackage.description_lines.length < + maxPlanDescriptionLinesCount + ) { + const total = + maxPlanDescriptionLinesCount - + visiblePlanPackage.description_lines.length; + + for (let i = 0; i < total; i++) { + visiblePlanPackage.description_lines.push( + + ); + } + } + + if ( + visiblePlanPackage.is_featured && + !isSinglePlan && + this.context.paidPlansCount > 1 + ) { + hasFeaturedPlan = true; + } + + const visiblePlanOrPricingID = isSinglePlan + ? visiblePlanPackage.pricing[0].id + : visiblePlanPackage.id; + + if (!selectedPlanOrPricingID && isFirstPlanPackage) { + selectedPlanOrPricingID = visiblePlanOrPricingID; + } + + mobileTabs.push( +
  • + + {isSinglePlan + ? visiblePlanPackage.pricing[0].sitesLabel() + : visiblePlanPackage.title} + +
  • + ); + + mobileDropdownOptions.push( + + ); + + packageComponents.push( + + ); + + if (isFirstPlanPackage) { + isFirstPlanPackage = false; + } + } + + return ( + + +
    + {packageComponents.length > 3 && ( + + )} + {packageComponents.length <= 3 && ( +
      {mobileTabs}
    + )} +
      {packageComponents}
    +
    + +
    + ); + } +} + +export default PackagesContainer; diff --git a/src/components/PackagesContainer/style.scss b/src/components/PackagesContainer/style.scss new file mode 100644 index 0000000..358d118 --- /dev/null +++ b/src/components/PackagesContainer/style.scss @@ -0,0 +1,170 @@ +#root, +#fs_pricing_app { + .fs-section--packages { + display: inline-block; + width: 100%; + position: relative; + + .fs-packages-menu { + display: none; + flex-wrap: wrap; + justify-content: center; + } + + .fs-packages-tab { + display: none; + } + + .fs-package-tab { + display: inline-block; + flex: 1; + + a { + display: block; + padding: 4px 10px 7px; + border-bottom: 2px solid transparent; + color: #000; + text-align: center; + text-decoration: none; + } + + &.fs-package-tab--selected { + a { + border-color: #0085ba; + } + } + } + + .fs-packages-nav { + position: relative; + overflow: hidden; + margin: auto; + + &:before, + &:after { + position: absolute; + top: 0; + bottom: 0; + width: 60px; + margin-bottom: 32px; + } + + &:before { + z-index: 1; + } + + &.fs-has-previous-plan:before { + content: ''; + left: 0; + background: linear-gradient(to right, #cccccc96, transparent); + } + + &.fs-has-next-plan:after { + content: ''; + right: 0; + background: linear-gradient(to left, #cccccc96, transparent); + } + + &.fs-has-featured-plan:before, + &.fs-has-featured-plan:after { + top: 2.8em; + } + } + + .fs-prev-package, + .fs-next-package { + position: absolute; + top: 50%; + margin-top: -11px; + cursor: pointer; + font-size: 48px; + z-index: 1; + } + + .fs-prev-package { + visibility: hidden; + z-index: 2; + } + + .fs-has-featured-plan .fs-packages { + margin-top: 2.8em; + } + + .fs-packages { + width: auto; + display: flex; + flex-direction: row; + margin-left: auto; + margin-right: auto; + margin-bottom: 30px; + border-top-right-radius: 10px; + position: relative; + transition: left 500ms ease, right 500ms ease; + padding-top: 5px; + + &:before { + content: ''; + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 100px; + height: 100px; + } + } + } + + @media only screen and (max-width: 768px) { + .fs-section--plans-and-pricing .fs-section--packages { + .fs-next-package, + .fs-prev-package { + display: none; + } + + .fs-packages-menu { + display: block; + font-size: 24px; + margin: 0 auto 10px; + } + + .fs-packages-tab { + display: flex; + font-size: 18px; + margin: 0 auto 10px; + } + + .fs-packages, + .fs-package { + .fs-most-popular { + display: none; + } + } + + .fs-has-featured-plan .fs-packages { + margin-top: 0; + } + } + } + + @media only screen and (max-width: 455px) { + .fs-section--plans-and-pricing + .fs-section--packages + .fs-packages + .fs-package { + width: 100%; + } + + .fs-section--plans-and-pricing { + padding: 10px; + } + } + + @media only screen and (max-width: 375px) { + .fs-section--plans-and-pricing + .fs-section--packages + .fs-packages + .fs-package { + width: 100%; + } + } +} diff --git a/src/components/Placeholder/index.js b/src/components/Placeholder/index.js new file mode 100644 index 0000000..35430bf --- /dev/null +++ b/src/components/Placeholder/index.js @@ -0,0 +1,16 @@ +import React, { Component } from 'react'; + +/** + * @author Leo Fajardo + */ +class Placeholder extends Component { + constructor(props) { + super(props); + } + + render() { + return
    ; + } +} + +export default Placeholder; diff --git a/src/components/Tooltip/index.js b/src/components/Tooltip/index.js new file mode 100644 index 0000000..4abf7f5 --- /dev/null +++ b/src/components/Tooltip/index.js @@ -0,0 +1,131 @@ +import React, { useEffect, useRef, useState } from 'react'; +import Icon from '../Icon'; + +import './style.scss'; + +export default function Tooltip({ children }) { + // Enum: 'none' | top | right | top-right + const [currentTooltipPosition, setCurrentTooltipPosition] = useState('none'); + const tooltipMessageRef = useRef(null); + + const showTooltip = () => { + // If for some reason, our tooltip `span` still hasn't rendered, then bail. + if (!tooltipMessageRef.current) { + return; + } + + // Do the calculate when react is about to flush the state so that there's no race + // condition during the react-fibre execution. + setCurrentTooltipPosition(currentPosition => { + // Don't recalculate if already showing. + if ('none' !== currentPosition) { + // NOTE: This does not cause a re-render. + return currentPosition; + } + + const rect = tooltipMessageRef.current.getBoundingClientRect(); + + // This calculation is patchy and is breaking a lot of react standards + // but we are doing it to "make it work™️" as our tooltip use-case is + // very limited and we would rather avoid adding another library if + // we could. + const packagesContainer = + tooltipMessageRef.current.closest('.fs-packages-nav'); + const packagesRect = packagesContainer.getBoundingClientRect(); + + // The width of the tooltip is hard-coded in the CSS (./style.scss) + const WIDTH_OF_TOOLTIP_CONTAINER = 200; + + // Need 50px breathing space after the tooltip to make a better "UX". + const BREATHING_SPACE = 50; + + let spaceAvailableOnRightOfPackagesContainer = + packagesRect.right - rect.right; + let neededTooltipSpaceOnRight = + WIDTH_OF_TOOLTIP_CONTAINER + BREATHING_SPACE; + + // First try to position it on right. + let position = 'right'; + + // If space available on the right is not enough, then try to show it to the top. + if ( + neededTooltipSpaceOnRight > spaceAvailableOnRightOfPackagesContainer + ) { + position = 'top'; + neededTooltipSpaceOnRight = + WIDTH_OF_TOOLTIP_CONTAINER / 2 + BREATHING_SPACE; + + // If there's not enough space, then show it to the top-right. + if ( + neededTooltipSpaceOnRight > spaceAvailableOnRightOfPackagesContainer + ) { + position = 'top-right'; + } + + /** + * The top-right positioning is kind of our fail-safe, because + * if the `neededTooltipSpaceOnRight` is still greater than + * `spaceAvailableOnRightOfPackagesContainer`, then the wrapper + * packages does not have sufficient width itself. + * + * This is a very edge case scenario and we could probably show + * a dialog here, but it is not needed at the moment. + */ + } + + return position; + }); + }; + + const hideTooltip = () => { + setCurrentTooltipPosition('none'); + }; + + // Add a listener to the document to flush out the active tooltip if any. + useEffect(() => { + if (currentTooltipPosition === 'none') { + // return a no-op + return () => {}; + } + + // Since we are showing the tooltip, clear it if clicking somewhere else. + const handler = e => { + // But not, if clicking the tooltip initiator. + if ( + e.target === tooltipMessageRef.current || + tooltipMessageRef.current.contains(e.target) + ) { + return; + } + + setCurrentTooltipPosition('none'); + }; + + document.addEventListener('click', handler); + + // Clear it after unmount + return () => { + document.removeEventListener('click', handler); + }; + }, [currentTooltipPosition]); + + return ( + + + + {children} + + + ); +} diff --git a/src/components/Tooltip/style.scss b/src/components/Tooltip/style.scss new file mode 100644 index 0000000..36311da --- /dev/null +++ b/src/components/Tooltip/style.scss @@ -0,0 +1,81 @@ +@import '../../assets/scss/vars'; + +#root, +#fs_pricing_app { + .fs-tooltip { + cursor: help; + position: relative; + color: inherit; + + .fs-tooltip-message { + position: absolute; + width: 200px; + background: $fsds-background-darkest; + z-index: 1; + display: none; + border-radius: 4px; + color: $fsds-dark-background-text-color; + padding: 8px; + text-align: left; + line-height: 18px; + + &:before { + content: ''; + position: absolute; + z-index: 1; + } + + &:not(.fs-tooltip-message--position-none) { + display: block; + } + + // Positioning on the right + &.fs-tooltip-message--position-right { + transform: translate(0, -50%); + left: 30px; + top: 8px; + + &::before { + left: -8px; + top: 50%; + margin-top: -6px; + border-top: 6px solid transparent; + border-bottom: 6px solid transparent; + border-right: 8px solid $fsds-background-darkest; + } + } + + // Positioning on the top + &.fs-tooltip-message--position-top { + left: 50%; + bottom: 30px; + transform: translate(-50%, 0); + + &::before { + left: 50%; + bottom: -8px; + margin-left: -6px; + border-right: 6px solid transparent; + border-left: 6px solid transparent; + border-top: 8px solid $fsds-background-darkest; + } + } + + // Positioning on the top + &.fs-tooltip-message--position-top-right { + right: -10px; + bottom: 30px; + // transform: translate(-50%, 0); + + &::before { + right: 10px; + bottom: -8px; + margin-left: -6px; + border-right: 6px solid transparent; + border-left: 6px solid transparent; + border-top: 8px solid $fsds-background-darkest; + } + } + } + } +} diff --git a/src/components/packages/Package.js b/src/components/packages/Package.js deleted file mode 100644 index ece1dbd..0000000 --- a/src/components/packages/Package.js +++ /dev/null @@ -1,388 +0,0 @@ -import React, {Component, Fragment} from 'react'; -import FSPricingContext from "../../FSPricingContext"; -import {BillingCycle, BillingCycleString} from "../../entities/Pricing"; -import {PlanManager} from "../../services/PlanManager"; -import Tooltip from "../Tooltip"; -import Icon from "../Icon"; -import {Helper} from "../../Helper"; -import {Plan} from "../../entities/Plan"; -import Placeholder from "./Placeholder"; - -class Package extends Component { - static contextType = FSPricingContext; - static noBillingCycleSupportLicenses = {}; - static contextInstallPlanFound = false; - - previouslySelectedPricingByPlan = {}; - - constructor(props) { - super(props); - } - - /** - * @return {string} Returns `Billed Annually`, `Billed Once`, or `Billed Monthly`. - */ - billingCycleLabel() { - let label = 'Billed '; - - if (BillingCycleString.ANNUAL === this.context.selectedBillingCycle) - label += 'Annually'; - else if (BillingCycleString.LIFETIME === this.context.selectedBillingCycle) - label += 'Once'; - else - label += 'Monthly'; - - return label; - } - - changeLicenses(e) { - let target = e.currentTarget; - - if ('tr' !== target.tagName.toLowerCase()) { - target = target.closest('tr'); - } - - let pricingID = target.dataset['pricingId']; - - document.getElementById(`pricing_${pricingID}`).click(); - } - - /** - * @param {Plan} plan - * @param {int} installPlanLicensesCount - * - * @return {string|Fragment} - */ - getCtaButtonLabel(plan, installPlanLicensesCount) { - if (this.context.isActivatingTrial && this.context.upgradingToPlanID == plan.id) { - return 'Activating...'; - } - - let hasInstallContext = ( ! Helper.isUndefinedOrNull(this.context.install)), - isContextInstallPlan = (hasInstallContext && this.context.install.plan_id == plan.id), - currentPlanLicensesCount = installPlanLicensesCount, - isFreePlan = PlanManager.getInstance().isFreePlan(plan.pricing); - - if (isContextInstallPlan) { - Package.contextInstallPlanFound = true; - } - - let label = '', - installPlan = isContextInstallPlan ? - plan : - ( - hasInstallContext ? - PlanManager.getInstance().getPlanByID(this.context.install.plan_id) : - null - ); - - let isPayingUser = ( - ! this.context.isTrial && - (null !== installPlan) && - ! this.isInstallInTrial(this.context.install) && - PlanManager.getInstance().isPaidPlan(installPlan.pricing) - ); - - if (isContextInstallPlan || ( ! hasInstallContext && isFreePlan)) { - label = (currentPlanLicensesCount > 1) ? - 'Downgrade' : - ((1 == currentPlanLicensesCount ? 'Your Plan' : 'Upgrade')); - } else if (isFreePlan) { - label = 'Downgrade'; - } else if (this.context.isTrial && plan.hasTrial()) { - label = Start my free {plan.trial_period} days; - } else if (isPayingUser && ! Package.contextInstallPlanFound) { - label = 'Downgrade'; - } else { - label = 'Upgrade Now'; - } - - return label; - } - - getUndiscountedPrice(planPackage, selectedPricing, selectedPricingCycleLabel) { - if ( - BillingCycleString.ANNUAL !== this.context.selectedBillingCycle || - ! (this.context.annualDiscount > 0) - ) { - return ; - } - - if (planPackage.is_free_plan || null === selectedPricing) { - return - } - - let amount; - - if ('mo' === selectedPricingCycleLabel) { - amount = selectedPricing.getMonthlyAmount(BillingCycle.MONTHLY, true); - } else { - amount = selectedPricing.getYearlyAmount(BillingCycle.MONTHLY, true); - } - - return
    Normally {this.context.currencySymbols[this.context.selectedCurrency]}{amount} / {selectedPricingCycleLabel}
    ; - } - - getSitesLabel(planPackage, selectedPricing, pricingLicenses) { - if (planPackage.is_free_plan) { - return - } - - return
    - {selectedPricing.sitesLabel()} - { ! planPackage.is_free_plan && - - If you are running a multi-site network, each site in the network requires a license.{pricingLicenses.length > 0 ? 'Therefore, if you need to use it on multiple sites, check out our multi-site prices.' : ''} - - } -
    - } - - /** - * @param {Object} pricing Pricing entity. - * - * @return {string} The price label in this format: `$4.99 / mo` or `$4.99 / year` - */ - priceLabel(pricing) { - let pricingData = this.context, - label = '', - price = pricing[pricingData.selectedBillingCycle + '_price']; - - label += pricingData.currencySymbols[pricingData.selectedCurrency]; - label += Helper.formatNumber(price); - - if (BillingCycleString.MONTHLY === pricingData.selectedBillingCycle) - label += ' / mo'; - else if (BillingCycleString.ANNUAL === pricingData.selectedBillingCycle) - label += ' / year'; - - return label; - } - - isInstallInTrial(install) { - if ( ! Helper.isNumeric(install.trial_plan_id) || Helper.isUndefinedOrNull(install.trial_ends)) { - return false; - } - - return (Date.parse(install.trial_ends) > new Date().getTime()); - } - - render() { - let isSinglePlan = this.props.isSinglePlan, - planPackage = this.props.planPackage, - installPlanLicensesCount = this.props.installPlanLicensesCount, - currentLicenseQuantities = this.props.currentLicenseQuantities, - pricingLicenses = null, - selectedLicenseQuantity = this.context.selectedLicenseQuantity, - pricingCollection = {}, - selectedPricing = null, - selectedPricingAmount = null, - supportLabel = null, - showAnnualInMonthly = this.context.showAnnualInMonthly, - selectedPricingCycleLabel = 'mo'; - - if (this.props.isFirstPlanPackage) { - Package.contextInstallPlanFound = false; - Package.noBillingCycleSupportLicenses = {}; - } - - if ( ! planPackage.is_free_plan) { - pricingCollection = planPackage.pricingCollection; - pricingLicenses = planPackage.pricingLicenses; - selectedPricing = planPackage.selectedPricing; - - if ( ! selectedPricing) { - if ( - ! this.previouslySelectedPricingByPlan[planPackage.id] || - this.context.selectedCurrency !== this.previouslySelectedPricingByPlan[planPackage.id].currency || - ! this.previouslySelectedPricingByPlan[planPackage.id].supportsBillingCycle(this.context.selectedBillingCycle) - ) { - /** - * Select the first pricing if there's no previously selected pricing that matches the selected license quantity and currency. - */ - this.previouslySelectedPricingByPlan[planPackage.id] = pricingCollection[pricingLicenses[0]]; - } - - selectedPricing = this.previouslySelectedPricingByPlan[planPackage.id]; - - selectedLicenseQuantity = selectedPricing.getLicenses(); - } - - this.previouslySelectedPricingByPlan[planPackage.id] = selectedPricing; - - if (BillingCycleString.ANNUAL === this.context.selectedBillingCycle) - { - if (true === showAnnualInMonthly || (Helper.isUndefinedOrNull(showAnnualInMonthly) && selectedPricing.hasMonthlyPrice())) { - selectedPricingAmount = selectedPricing.getMonthlyAmount(BillingCycle.ANNUAL, true); - } - - if (false === showAnnualInMonthly || (Helper.isUndefinedOrNull(showAnnualInMonthly) && ! selectedPricing.hasMonthlyPrice())) { - selectedPricingAmount = selectedPricing.getYearlyAmount(BillingCycle.ANNUAL, true); - selectedPricingCycleLabel = 'yr'; - } - - } else { - selectedPricingAmount = selectedPricing[`${this.context.selectedBillingCycle}_price`].toString(); - } - - } - - if ( ! planPackage.hasAnySupport()) { - supportLabel = 'No Support'; - } else if (planPackage.hasSuccessManagerSupport()) { - supportLabel = 'Priority Phone, Email & Chat Support'; - } else { - let supportedChannels = []; - - if (planPackage.hasPhoneSupport()) { - supportedChannels.push('Phone'); - } - - if (planPackage.hasSkypeSupport()) { - supportedChannels.push('Skype'); - } - - if (planPackage.hasEmailSupport()) { - supportedChannels.push((this.context.priorityEmailSupportPlanID == planPackage.id ? 'Priority ' : '') + 'Email'); - } - - if (planPackage.hasForumSupport()) { - supportedChannels.push('Forum'); - } - - if (planPackage.hasKnowledgeBaseSupport()) { - supportedChannels.push('Help Center'); - } - - if (1 === supportedChannels.length) { - supportLabel = `${supportedChannels[0]} Support`; - } else { - supportLabel = supportedChannels.slice(0, supportedChannels.length - 1).join(', ') + - ' & ' + supportedChannels[supportedChannels.length-1] + ' Support'; - } - } - - let packageClassName = 'fs-package'; - - if (planPackage.is_free_plan) { - packageClassName += ' fs-free-plan'; - } else if ( ! isSinglePlan && planPackage.is_featured) { - packageClassName += ' fs-featured-plan'; - } - - const selectedAmountInteger = Helper.formatNumber(parseInt(selectedPricingAmount.split('.')[0]), 'en-US'); - const selectedAmountFraction = Helper.formatFraction(selectedPricingAmount.split('.')[1]); - - return
  • -

    Most Popular

    -
    -

    {planPackage.title}

    -

    - {planPackage.description_lines} -

    - {this.getUndiscountedPrice(planPackage, selectedPricing, selectedPricingCycleLabel)} -
    - { ! planPackage.is_free_plan ? this.context.currencySymbols[this.context.selectedCurrency] : ''} - {planPackage.is_free_plan ? 'Free' : selectedAmountInteger} - - { ! planPackage.is_free_plan ? '.' + selectedAmountFraction : ''} - { - ! planPackage.is_free_plan && - BillingCycleString.LIFETIME !== this.context.selectedBillingCycle && - / {selectedPricingCycleLabel} - } - -
    -
    { ! planPackage.is_free_plan ? {this.billingCycleLabel()} : }
    - {this.getSitesLabel(planPackage, selectedPricing, pricingLicenses)} -
    - {null !== supportLabel &&
    {supportLabel}
    } -
      - {planPackage.highlighted_features.map(feature => { - if ( ! Helper.isNonEmptyString(feature.title)) { - return
    • ; - } - - return
    • - - {feature.value} - {feature.title} - - {Helper.isNonEmptyString(feature.description) && {feature.description}} -
    • ; - } - )} -
    -
    - { ! isSinglePlan && - - { - Object.keys(currentLicenseQuantities).map(licenseQuantity => { - let pricing = pricingCollection[licenseQuantity]; - - if (Helper.isUndefinedOrNull(pricing)) { - return ; - } - - let isPricingLicenseQuantitySelected = (selectedLicenseQuantity == licenseQuantity); - - let multiSiteDiscount = PlanManager.getInstance().calculateMultiSiteDiscount(pricing, this.context.selectedBillingCycle); - - return ( - - - { - multiSiteDiscount > 0 ? - : - - } - - - ); - }) - } -
    - - {pricing.sitesLabel()} - Save {multiSiteDiscount}%{this.priceLabel(pricing)}
    } -
    - -
    -
      - {planPackage.nonhighlighted_features.map(feature => { - if ( ! Helper.isNonEmptyString(feature.title)) { - return
    • ; - } - - const featureTitle = (0 === feature.id.indexOf('all_plan_')) ? - {feature.title} : - feature.title; - - return
    • - - {featureTitle} - {Helper.isNonEmptyString(feature.description) && {feature.description}} -
    • - }) - } -
    -
    -
  • ; - } -} - -export default Package; \ No newline at end of file diff --git a/src/components/packages/PackagesContainer.js b/src/components/packages/PackagesContainer.js deleted file mode 100644 index 99bf727..0000000 --- a/src/components/packages/PackagesContainer.js +++ /dev/null @@ -1,526 +0,0 @@ -import React, { Component, Fragment } from 'react'; -import FSPricingContext from "../../FSPricingContext"; -import { BillingCycleString } from "../../entities/Pricing"; -import { PlanManager } from "../../services/PlanManager"; -import { Helper } from "../../Helper"; -import { Plan } from "../../entities/Plan"; -import Package from "./Package"; -import Icon from "../Icon"; -import Placeholder from "./Placeholder"; - -class PackagesContainer extends Component { - static contextType = FSPricingContext; - - slider = null; - - constructor(props) { - super(props); - } - - /** - * @return {string} Returns `Billed Annually`, `Billed Once`, or `Billed Monthly`. - */ - billingCycleLabel() { - let label = 'Billed '; - - if (BillingCycleString.ANNUAL === this.context.selectedBillingCycle) - label += 'Annually'; - else if (BillingCycleString.LIFETIME === this.context.selectedBillingCycle) - label += 'Once'; - else - label += 'Monthly'; - - return label; - } - - /** - * @param {Object} pricing Pricing entity. - * - * @return {string} The price label in this format: `$4.99 / mo` or `$4.99 / year` - */ - priceLabel(pricing) { - let pricingData = this.context, - label = '', - price = pricing[pricingData.selectedBillingCycle + '_price']; - - label += pricingData.currencySymbols[pricingData.selectedCurrency]; - label += Helper.formatNumber(price); - - if (BillingCycleString.MONTHLY === pricingData.selectedBillingCycle) - label += ' / mo'; - else if (BillingCycleString.ANNUAL === pricingData.selectedBillingCycle) - label += ' / year'; - - return label; - } - - initSlider() { - setTimeout(() => { - if (null !== this.slider) { - this.slider.adjustPackages(); - return; - } - - this.slider = (function() { - let firstVisibleIndex, - $plansAndPricingSection, - $track, - $packages, - $packagesContainer, - $nextPackage, - $prevPackage, - $packagesMenu, - $packagesTab, - defaultNextPrevPreviewWidth, - cardMinWidth, - maxMobileScreenWidth, - cardWidth, - nextPrevPreviewWidth, - screenWidth, - visibleCards; - - let init = function () { - firstVisibleIndex = 0; - $plansAndPricingSection = document.querySelector('.fs-section--plans-and-pricing'); - $track = $plansAndPricingSection.querySelector('.fs-section--packages'); - $packages = $track.querySelectorAll('.fs-package'); - $packagesContainer = $track.querySelector('.fs-packages'); - $nextPackage = $plansAndPricingSection.querySelector('.fs-next-package'); - $prevPackage = $plansAndPricingSection.querySelector('.fs-prev-package'); - $packagesMenu = $plansAndPricingSection.querySelector('.fs-packages-menu'); - $packagesTab = $plansAndPricingSection.querySelector('.fs-packages-tab'); - defaultNextPrevPreviewWidth = 60; - cardMinWidth = 315; - maxMobileScreenWidth = 768; - }; - - let slide = function (selectedIndex, leftOffset) { - let leftPos = (-1 * selectedIndex * cardWidth) + (leftOffset ? leftOffset : 0); - - $packagesContainer.style.left = (leftPos + 'px'); - }; - - let nextSlide = function () { - firstVisibleIndex++; - - let leftOffset = 0; - - if (screenWidth > maxMobileScreenWidth) { - leftOffset = defaultNextPrevPreviewWidth; - - if (firstVisibleIndex + visibleCards >= $packages.length) { - $nextPackage.style.visibility = 'hidden'; - $packagesContainer.parentNode.classList.remove('fs-has-next-plan'); - - if (firstVisibleIndex - 1 > 0) { - leftOffset *= 2; - } - } - - if (firstVisibleIndex > 0) { - $prevPackage.style.visibility = 'visible'; - $packagesContainer.parentNode.classList.add('fs-has-previous-plan'); - } - } - - slide(firstVisibleIndex, leftOffset); - }; - - let prevSlide = function () { - firstVisibleIndex--; - - let leftOffset = 0; - - if (screenWidth > maxMobileScreenWidth) { - if (firstVisibleIndex - 1 < 0) { - $prevPackage.style.visibility = 'hidden'; - $packagesContainer.parentNode.classList.remove('fs-has-previous-plan'); - } - - if (firstVisibleIndex + visibleCards <= $packages.length) { - $nextPackage.style.visibility = 'visible'; - $packagesContainer.parentNode.classList.add('fs-has-next-plan'); - - if (firstVisibleIndex > 0) { - leftOffset = defaultNextPrevPreviewWidth; - } - } - } - - slide(firstVisibleIndex, leftOffset); - }; - - let adjustPackages = function () { - $packagesContainer.parentNode.classList.remove('fs-has-previous-plan'); - $packagesContainer.parentNode.classList.remove('fs-has-next-plan'); - - screenWidth = window.outerWidth; - - let sectionComputedStyle = window.getComputedStyle($plansAndPricingSection), - sectionWidth = parseFloat(sectionComputedStyle.width), - sectionLeftPos = 0, - isMobile = (screenWidth <= maxMobileScreenWidth); - - nextPrevPreviewWidth = defaultNextPrevPreviewWidth; - - if (isMobile) { - visibleCards = 1; - cardWidth = sectionWidth; - } else { - visibleCards = Math.floor(sectionWidth / cardMinWidth); - - if (visibleCards === $packages.length) { - nextPrevPreviewWidth = 0; - } else if (visibleCards < $packages.length) { - visibleCards = Math.floor((sectionWidth - nextPrevPreviewWidth) / cardMinWidth); - - if (visibleCards + 1 < $packages.length) { - nextPrevPreviewWidth *= 2; - visibleCards = Math.floor((sectionWidth - nextPrevPreviewWidth) / cardMinWidth); - } - } - - cardWidth = cardMinWidth; - } - - $packagesContainer.style.width = (cardWidth * $packages.length) + 'px'; - - sectionWidth = (visibleCards * cardWidth) + ( ! isMobile ? nextPrevPreviewWidth : 0); - - $packagesContainer.parentNode.style.width = (sectionWidth + 'px'); - - $packagesContainer.style.left = (sectionLeftPos + 'px'); - - if ( ! isMobile && visibleCards < $packages.length) { - $nextPackage.style.visibility = 'visible'; - - /** - * Center the prev and next buttons on the available space on the left and right sides of the packages collection. - */ - let packagesContainerParentMargin = parseFloat(window.getComputedStyle($packagesContainer.parentNode).marginLeft), - sectionPadding = parseFloat(sectionComputedStyle.paddingLeft), - prevButtonRightPos = -sectionPadding, - nextButtonRightPos = (sectionWidth + packagesContainerParentMargin), - nextPrevWidth = parseFloat(window.getComputedStyle($nextPackage).width); - - $prevPackage.style.left = (prevButtonRightPos + (sectionPadding + packagesContainerParentMargin - nextPrevWidth) / 2) + 'px'; - $nextPackage.style.left = (nextButtonRightPos + (sectionPadding + packagesContainerParentMargin - nextPrevWidth) / 2) + 'px'; - - $packagesContainer.parentNode.classList.add('fs-has-next-plan'); - } else { - $prevPackage.style.visibility = 'hidden'; - $nextPackage.style.visibility = 'hidden'; - } - - for (let $package of $packages) { - $package.style.width = (cardWidth + 'px'); - } - - if ($packagesMenu) { - firstVisibleIndex = $packagesMenu.selectedIndex; - } else if ($packagesTab) { - let $tabs = $packagesTab.querySelectorAll('li'); - - for (let i = 0; i < $tabs.length; i ++) { - let $tab = $tabs[i]; - - if ($tab.classList.contains('fs-package-tab--selected')) { - firstVisibleIndex = i; - break; - } - } - } - - if (firstVisibleIndex > 0) { - firstVisibleIndex --; - nextSlide(); - } - }; - - init(); - adjustPackages(); - - if ($packagesMenu) { - $packagesMenu.addEventListener('change', function(evt) { - firstVisibleIndex = (evt.target.selectedIndex - 1); - nextSlide(); - }); - } - - $nextPackage.addEventListener('click', nextSlide); - $prevPackage.addEventListener('click', prevSlide); - window.addEventListener('resize', adjustPackages); - - return { - adjustPackages: function() { - init(); - adjustPackages(); - } - }; - })(); - }, 10); - } - - render() { - let packages = null, - licenseQuantities = this.context.licenseQuantities[this.context.selectedCurrency], - licenseQuantitiesCount = Object.keys(licenseQuantities).length, - currentLicenseQuantities = {}, - isSinglePlan = false; - - if (this.context.paidPlansCount > 1 || 1 === licenseQuantitiesCount) { - // If there are more than one paid plans, create a package component for each plan. - packages = this.context.plans; - } else { - // If there is only one paid plan and it supports multi-license options, create a package component for license quantity. - packages = []; - - let paidPlan = null; - - for (paidPlan of this.context.plans) { - if (PlanManager.getInstance().isHiddenOrFreePlan(paidPlan)) { - continue; - } - - break; - } - - for (let pricing of paidPlan.pricing) { - if ( - pricing.is_hidden || - this.context.selectedCurrency !== pricing.currency || - ! pricing.supportsBillingCycle(this.context.selectedBillingCycle) - ) { - continue; - } - - let planClone = Object.assign(new Plan(), paidPlan); - - planClone.pricing = [pricing]; - - packages.push(planClone); - } - - isSinglePlan = true; - } - - - let visiblePlanPackages = [], - maxHighlightedFeaturesCount = 0, - maxNonHighlightedFeaturesCount = 0, - prevNonHighlightedFeatures = {}, - maxPlanDescriptionLinesCount = 0, - prevPlanPackage = null, - installPlanLicensesCount = 0; - - for (let planPackage of packages) { - if (planPackage.is_hidden) { - continue; - } - - let isFreePlan = PlanManager.getInstance().isFreePlan(planPackage.pricing); - - if (isFreePlan) { - if (this.context.paidPlansCount >= 3) { - continue; - } - - planPackage.is_free_plan = isFreePlan; - } else { - planPackage.pricingCollection = {}; - - planPackage.pricing.map(pricing => { - let licenses = pricing.getLicenses(); - - if ( - pricing.is_hidden || - this.context.selectedCurrency !== pricing.currency || - ! Helper.isUndefinedOrNull(Package.noBillingCycleSupportLicenses[licenses]) - ) { - return; - } - - if ( ! pricing.supportsBillingCycle(this.context.selectedBillingCycle)) { - Package.noBillingCycleSupportLicenses[licenses] = true; - - return; - } - - planPackage.pricingCollection[licenses] = pricing; - - if (isSinglePlan || this.context.selectedLicenseQuantity == licenses) { - planPackage.selectedPricing = pricing; - } - - if (this.context.license && this.context.license.pricing_id == pricing.id) { - installPlanLicensesCount = pricing.licenses; - } - }); - - let pricingLicenses = Object.keys(planPackage.pricingCollection); - - if (0 === pricingLicenses.length) { - continue; - } - - planPackage.pricingLicenses = pricingLicenses; - } - - planPackage.highlighted_features = []; - planPackage.nonhighlighted_features = []; - - if (null !== prevPlanPackage) { - planPackage.nonhighlighted_features.push({ - id : `all_plan_${prevPlanPackage.id}_features`, - title: `All ${prevPlanPackage.title} Features` - }); - } - - if (planPackage.hasSuccessManagerSupport()) { - planPackage.nonhighlighted_features.push({id: `plan_${planPackage.id}_personal_success_manager`, title: 'Personal Success Manager'}); - } - - if ( ! Helper.isNonEmptyString(planPackage.description)) { - planPackage.description_lines = []; - } else { - planPackage.description_lines = planPackage.description.split('\n').map((item, key) => { - return {item}
    - }) - } - - maxPlanDescriptionLinesCount = Math.max(maxPlanDescriptionLinesCount, planPackage.description_lines.length); - - visiblePlanPackages.push(planPackage); - - if (Helper.isUndefinedOrNull(planPackage.features)) { - continue; - } - - for (let feature of planPackage.features) { - if ( ! feature.is_featured) { - continue; - } - - if (Helper.isNonEmptyString(feature.value) || Helper.isNumeric(feature.value)) { - planPackage.highlighted_features.push(feature); - } else if ( - isSinglePlan || - Helper.isUndefinedOrNull(prevNonHighlightedFeatures[`f_${feature.id}`]) - ) { - planPackage.nonhighlighted_features.push(feature); - - prevNonHighlightedFeatures[`f_${feature.id}`] = true; - } - } - - maxHighlightedFeaturesCount = Math.max(maxHighlightedFeaturesCount, planPackage.highlighted_features.length); - maxNonHighlightedFeaturesCount = Math.max(maxNonHighlightedFeaturesCount, planPackage.nonhighlighted_features.length); - - if ( ! isFreePlan) { - for (let pricing of planPackage.pricing) { - if ( - pricing.is_hidden || - this.context.selectedCurrency !== pricing.currency || - ! pricing.supportsBillingCycle(this.context.selectedBillingCycle) - ) { - continue; - } - - currentLicenseQuantities[pricing.getLicenses()] = true; - } - } - - if ( ! isSinglePlan) { - prevPlanPackage = planPackage; - } - } - - let packageComponents = [], - isFirstPlanPackage = true, - hasFeaturedPlan = false, - mobileTabs = [], - mobileDropdownOptions = [], - selectedPlanID = this.context.selectedPlanID; - - for (let visiblePlanPackage of visiblePlanPackages) { - if (visiblePlanPackage.highlighted_features.length < maxHighlightedFeaturesCount) { - const total = (maxHighlightedFeaturesCount - visiblePlanPackage.highlighted_features.length); - - for (let i = 0; i < total; i ++) { - visiblePlanPackage.highlighted_features.push({id: `filler_${i}`}); - } - } - - if (visiblePlanPackage.nonhighlighted_features.length < maxNonHighlightedFeaturesCount) { - const total = (maxNonHighlightedFeaturesCount - visiblePlanPackage.nonhighlighted_features.length); - - for (let i = 0; i < total; i ++) { - visiblePlanPackage.nonhighlighted_features.push({id: `filler_${i}`}); - } - } - - if (visiblePlanPackage.description_lines.length < maxPlanDescriptionLinesCount) { - const total = (maxPlanDescriptionLinesCount - visiblePlanPackage.description_lines.length); - - for (let i = 0; i < total; i ++) { - visiblePlanPackage.description_lines.push(); - } - } - - if (visiblePlanPackage.is_featured && ! isSinglePlan && this.context.paidPlansCount > 1) { - hasFeaturedPlan = true; - } - - if ( ! selectedPlanID && isFirstPlanPackage) { - selectedPlanID = visiblePlanPackage.id; - } - - mobileTabs.push( -
  • {visiblePlanPackage.title}
  • - ); - - mobileDropdownOptions.push( - - ); - - packageComponents.push( - - ); - - if (isFirstPlanPackage) { - isFirstPlanPackage = false; - } - } - - this.initSlider(); - - return - -
    - {packageComponents.length > 3 && } - {packageComponents.length <= 3 &&
      {mobileTabs}
    } -
      {packageComponents}
    -
    - -
    - } -} - -export default PackagesContainer; \ No newline at end of file diff --git a/src/components/packages/Placeholder.js b/src/components/packages/Placeholder.js deleted file mode 100644 index 28d1d77..0000000 --- a/src/components/packages/Placeholder.js +++ /dev/null @@ -1,16 +0,0 @@ -import React, {Component} from 'react'; - -/** - * @author Leo Fajardo - */ -class Placeholder extends Component { - constructor (props) { - super(props); - } - - render() { - return
    ; - } -} - -export default Placeholder; \ No newline at end of file From 3a823ef63ec9cc226eed06bec1f55bf942a94bd3 Mon Sep 17 00:00:00 2001 From: Swashata Ghosh Date: Thu, 14 Nov 2024 20:09:32 +0530 Subject: [PATCH 3/8] [icon] [update] Support custom config for replacing plugin icons. --- README.md | 8 ++++++++ src/components/FreemiusPricingMain.js | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8c96c2a..9273671 100644 --- a/README.md +++ b/README.md @@ -149,3 +149,11 @@ an unexpected way, you can use this: ## How to setup the development environment for contributions Please our [Contributors Guide](CONTRIBUTING.md). + +## Filters + +- `pricing/show_annual_in_monthly` - Set the value to `false` to make the annual + pricing display number in annual cycle (instead of monthly cycle). +- `plugin_icon` - See + [documentation](https://freemius.com/help/documentation/wordpress-sdk/opt-in-message/#opt_in_icon_customization), + the same filter is used for the pricing page. diff --git a/src/components/FreemiusPricingMain.js b/src/components/FreemiusPricingMain.js index 5b72e9d..48fdb98 100644 --- a/src/components/FreemiusPricingMain.js +++ b/src/components/FreemiusPricingMain.js @@ -187,7 +187,7 @@ class FreemiusPricingMain extends Component { return ( From 9f03543a99b374f8063c652d2bf5d212ed74b337 Mon Sep 17 00:00:00 2001 From: Swashata Ghosh Date: Thu, 14 Nov 2024 20:59:48 +0530 Subject: [PATCH 4/8] [billing-cycle] [fix] Don't render billing cycle for hidden pricing. --- src/components/FreemiusPricingMain.js | 6 +++--- src/components/Package/index.js | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/FreemiusPricingMain.js b/src/components/FreemiusPricingMain.js index 48fdb98..1134bc2 100644 --- a/src/components/FreemiusPricingMain.js +++ b/src/components/FreemiusPricingMain.js @@ -587,15 +587,15 @@ class FreemiusPricingMain extends Component { let pricing = pricingCollection[pricingIndex]; - if (null != pricing.monthly_price) { + if (null != pricing.monthly_price && !pricing.is_hidden) { billingCycles[BillingCycleString.MONTHLY] = true; } - if (null != pricing.annual_price) { + if (null != pricing.annual_price && !pricing.is_hidden) { billingCycles[BillingCycleString.ANNUAL] = true; } - if (null != pricing.lifetime_price) { + if (null != pricing.lifetime_price && !pricing.is_hidden) { billingCycles[BillingCycleString.LIFETIME] = true; } diff --git a/src/components/Package/index.js b/src/components/Package/index.js index cccc5ea..abce709 100644 --- a/src/components/Package/index.js +++ b/src/components/Package/index.js @@ -209,7 +209,7 @@ class Package extends Component { return (
    Normally {this.context.currencySymbols[this.context.selectedCurrency]} - {amount} / selectedPricingCycleLabel + {amount} / {selectedPricingCycleLabel}
    ); } From c9e2fd1b6bfd3773fd8c88acb96c97ce0b3ba5c6 Mon Sep 17 00:00:00 2001 From: Swashata Ghosh Date: Thu, 14 Nov 2024 21:06:08 +0530 Subject: [PATCH 5/8] [free-plan] [fix] Fix button of the free plan incorrectly showing upgrade action. --- src/components/Package/index.js | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/components/Package/index.js b/src/components/Package/index.js index abce709..ebe9def 100644 --- a/src/components/Package/index.js +++ b/src/components/Package/index.js @@ -86,14 +86,27 @@ class Package extends Component { return 'upgrade'; } - if (PlanManager.getInstance().isFreePlan(contextPlan.pricing)) { + const isContextPricingFree = PlanManager.getInstance().isFreePlan( + contextPlan.pricing + ); + const isPlanPricingFree = PlanManager.getInstance().isFreePlan( + plan.pricing + ); + + // There are some cases where we will show the Free plan, especially if we are on free plan. + // For example, there's only one plan of the product and the plan doesn't have multiple pricings. + if (isContextPricingFree && isPlanPricingFree) { + return 'none'; + } + + if (isContextPricingFree) { return 'upgrade'; } // At this point, the install has a plan. Now we need to compare the given plan with the context plan. // If the given plan is free, then it is always a downgrade. - if (PlanManager.getInstance().isFreePlan(plan.pricing)) { + if (isPlanPricingFree) { return 'downgrade'; } From 5391143a7585a8fc739cbe4404fb61a2f189cbb6 Mon Sep 17 00:00:00 2001 From: Swashata Ghosh Date: Thu, 14 Nov 2024 21:39:41 +0530 Subject: [PATCH 6/8] [package] [new] Introduce a config to show the free plan even for single package setup. --- README.md | 3 +++ src/components/PackagesContainer/index.js | 7 ++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9273671..4d9528b 100644 --- a/README.md +++ b/README.md @@ -157,3 +157,6 @@ Please our [Contributors Guide](CONTRIBUTING.md). - `plugin_icon` - See [documentation](https://freemius.com/help/documentation/wordpress-sdk/opt-in-message/#opt_in_icon_customization), the same filter is used for the pricing page. +- `pricing/disable_single_package` - Set the value to `true` to disable the + enhanced appearance of the single package plan, where every pricing takes a + new column. diff --git a/src/components/PackagesContainer/index.js b/src/components/PackagesContainer/index.js index 4cb884b..a9d87f7 100644 --- a/src/components/PackagesContainer/index.js +++ b/src/components/PackagesContainer/index.js @@ -10,6 +10,7 @@ import Placeholder from '../Placeholder'; import { debounce } from '../../utils/debounce'; import './style.scss'; +import { FSConfig } from '../../index'; class PackagesContainer extends Component { static contextType = FSPricingContext; @@ -323,7 +324,11 @@ class PackagesContainer extends Component { currentLicenseQuantities = {}, isSinglePlan = false; - if (this.context.paidPlansCount > 1 || 1 === licenseQuantitiesCount) { + if ( + this.context.paidPlansCount > 1 || + 1 === licenseQuantitiesCount || + true === FSConfig.disable_single_package + ) { // If there are more than one paid plans, create a package component for each plan. packages = this.context.plans; } else { From 47919f0775cebe1164c628385ef8d8141f6b22f3 Mon Sep 17 00:00:00 2001 From: Swashata Ghosh Date: Mon, 25 Nov 2024 19:45:59 +0530 Subject: [PATCH 7/8] [doc] Document the filter of the pricing page. --- README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.md b/README.md index 4d9528b..f41de75 100644 --- a/README.md +++ b/README.md @@ -160,3 +160,20 @@ Please our [Contributors Guide](CONTRIBUTING.md). - `pricing/disable_single_package` - Set the value to `true` to disable the enhanced appearance of the single package plan, where every pricing takes a new column. +- `pricing/css_path` - Set the value to the path of your custom CSS file to + override the default CSS. The path should be absolute (just like the + `plugin_icon` or the `freemius_pricing_js_path` filters). + +### Adding custom CSS in your Plugin + +Set the custom CSS path using the `pricing/css_path` filter: + +```php +add_filter( 'pricing/css_path', 'my_custom_pricing_css_path' ); +``` From a1d1c195d06f2192734788f47e0d902bc83933ee Mon Sep 17 00:00:00 2001 From: Swashata Ghosh Date: Tue, 26 Nov 2024 13:47:17 +0530 Subject: [PATCH 8/8] [version] 1.1.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fa19d60..5b8e4ad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "freemius-pricing-page", - "version": "1.0.0", + "version": "1.1.0", "private": true, "dependencies": { "@fortawesome/fontawesome-svg-core": "^1.2.30",