feat: Widen the Pro banner rotation and split it by role
The banner still advertised the calendar, recurring cards and guest roles — features from 2.3 — to everyone who ran an instance. It now carries nine features up to 2.5.0, and the ten strings behind them sit in their alphabetical place rather than in the middle of the two-factor block, where the first three had ended up. Who sees what now depends on the role. An admin can put a trial on their own server today and a board user cannot, so admins get the trial line and land on planka.app/trial while everyone else gets the overview on /pro. The landing path is also what tells the two groups apart in the referral figures, since nothing is measured in the app. A full pass through nine features takes over two minutes, longer than most visits, and the rotation restarted at the first entry on every load. The tail of the list would have advertised nothing. The list now starts at an offset picked per visit, which spreads the features over the visitors instead of over the length of a session — the headline still opens every session, only the feature that follows it varies. The headline falls back per language. `proTrialSelfHosted` is new and exists in two locales; `discoverPlankaPro` exists in all of them. Where the sharper line is untranslated the softer one is used rather than a line of English, on the one string that opens every session and fills every second slot. The link still goes to the trial, and as translations land the check flips on its own. The thirty-day dismissal is untouched, deliberately: any version-aware dismissal would resurface the banner for every user at once on rollout, because the entries already in local storage carry no version.
This commit is contained in:
@@ -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 (
|
||||
<div ref={wrapperRef} className={styles.wrapper}>
|
||||
<a
|
||||
href={PRO_URL}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`${styles.textLink} ${visible ? styles.textVisible : styles.textHidden}`}
|
||||
>
|
||||
{t(`common.${TEXTS[textIndex]}`)}
|
||||
{t(`common.${texts[textIndex]}`)}
|
||||
<span className={styles.externalIcon}>↗</span>
|
||||
</a>
|
||||
<button
|
||||
|
||||
@@ -105,7 +105,11 @@ const UserActionsStep = React.memo(({ onClose }) => {
|
||||
})}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
href="https://planka.app/pro?ref=app-menu"
|
||||
href={
|
||||
withAdministration
|
||||
? 'https://planka.app/trial?ref=app-menu'
|
||||
: 'https://planka.app/pro?ref=app-menu'
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.proMenuItem}
|
||||
|
||||
Reference in New Issue
Block a user