diff --git a/client/src/components/common/PromoBanner/PromoBanner.jsx b/client/src/components/common/PromoBanner/PromoBanner.jsx index fb94c6ed..44862cfc 100644 --- a/client/src/components/common/PromoBanner/PromoBanner.jsx +++ b/client/src/components/common/PromoBanner/PromoBanner.jsx @@ -3,31 +3,42 @@ * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md */ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useSelector } from 'react-redux'; import { useTranslation } from 'react-i18next'; import selectors from '../../../selectors'; +import { UserRoles } from '../../../constants/Enums'; import styles from './PromoBanner.module.scss'; +const TRIAL_URL = 'https://planka.app/trial?ref=app-banner'; const PRO_URL = 'https://planka.app/pro?ref=app-banner'; const DISMISS_DURATION_MS = 30 * 24 * 60 * 60 * 1000; const CYCLE_INTERVAL_MS = 8000; const CSS_VAR = '--promo-banner-height'; -const FEATURES = ['proFeatureCalendar', 'proFeatureRecurringCards', 'proFeatureGuestRoles']; - -// Alternates: main, sub1, main, sub2, main, sub3 -const TEXTS = [ - 'discoverPlankaPro', - FEATURES[0], - 'discoverPlankaPro', - FEATURES[1], - 'discoverPlankaPro', - FEATURES[2], +// The first three are the only ones every locale translates, so they lead. +const FEATURES = [ + 'proFeatureCalendar', + 'proFeatureRecurringCards', + 'proFeatureGuestRoles', + 'proFeatureMobile', + 'proFeatureThemes', + 'proFeatureDashboard', + 'proFeatureTimeline', + 'proFeatureExport', + 'proFeatureSso', ]; +// Alternates the headline with one feature at a time: main, first, main, second, … +// A full pass takes over two minutes, longer than most visits, so the list starts at +// an offset picked per visit. Without it the tail of it would never be seen by anyone. +function buildTexts(mainText, offset) { + const rotated = [...FEATURES.slice(offset), ...FEATURES.slice(0, offset)]; + return rotated.flatMap((feature) => [mainText, feature]); +} + function getDismissKey(userId) { return `planka_proBannerDismissed_${userId}`; } @@ -41,12 +52,39 @@ function isBannerDismissed(userId) { const PromoBanner = React.memo(() => { const userId = useSelector(selectors.selectCurrentUserId); + const isAdmin = useSelector( + (state) => selectors.selectCurrentUser(state).role === UserRoles.ADMIN, + ); + const [dismissed, setDismissed] = useState(() => isBannerDismissed(userId)); const [textIndex, setTextIndex] = useState(0); const [visible, setVisible] = useState(true); + const [featureOffset] = useState(() => Math.floor(Math.random() * FEATURES.length)); const wrapperRef = useRef(null); - const [t] = useTranslation(); + const [t, i18n] = useTranslation(); + const { resolvedLanguage } = i18n; + + // proTrialSelfHosted is new and exists in a couple of languages; discoverPlankaPro exists + // in all of them. The headline opens every session and fills every second slot, so where + // the sharper line is untranslated we take the softer one rather than a line of English. + // The link still goes to the trial. As translations land, exists() flips on its own. + const texts = useMemo(() => { + const hasTrialText = i18n.exists('common.proTrialSelfHosted', { + lng: resolvedLanguage, + fallbackLng: false, + }); + + return buildTexts( + isAdmin && hasTrialText ? 'proTrialSelfHosted' : 'discoverPlankaPro', + featureOffset, + ); + }, [isAdmin, featureOffset, i18n, resolvedLanguage]); + + // An admin can put a trial on their own server today, so they go straight to it; + // everyone else gets the overview, which leads on to the trial in its own time. + // The landing path is what tells the two groups apart in the referral figures. + const href = isAdmin ? TRIAL_URL : PRO_URL; useEffect(() => { if (dismissed) { @@ -66,13 +104,13 @@ const PromoBanner = React.memo(() => { const interval = setInterval(() => { setVisible(false); setTimeout(() => { - setTextIndex((i) => (i + 1) % TEXTS.length); + setTextIndex((i) => (i + 1) % texts.length); setVisible(true); }, 400); }, CYCLE_INTERVAL_MS); return () => clearInterval(interval); - }, [dismissed]); + }, [dismissed, texts]); const handleDismiss = useCallback( (e) => { @@ -88,12 +126,12 @@ const PromoBanner = React.memo(() => { return (