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:
Daniel Hiller
2026-09-08 19:54:07 +02:00
parent de4d768831
commit 4e15748e4b
4 changed files with 79 additions and 23 deletions
@@ -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}