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}
+10 -3
View File
@@ -217,9 +217,6 @@ export default {
twoFactorAuthentication: 'Zwei-Faktor-Authentifizierung',
unknownDevice: 'Unbekanntes Gerät',
upgradeTeamToPro_title: 'Team auf Pro upgraden',
proFeatureCalendar: '✨ Kalenderansicht für eure Boards',
proFeatureRecurringCards: '✨ Wiederkehrende Karten',
proFeatureGuestRoles: '✨ Gastrollen & externe Zusammenarbeit',
date: 'Datum',
deactivateUser_title: 'Benutzer deaktivieren',
defaultCardType_title: 'Standard-Kartentyp',
@@ -348,6 +345,16 @@ export default {
pressPasteShortcutToAddAttachmentFromClipboard:
'Tipp: Drücken Sie STRG-V (Cmd-V auf Mac), um einen Anhang aus der Zwischenablage hinzuzufügen.',
private: 'Privat',
proFeatureCalendar: '✨ Kalenderansicht für eure Boards',
proFeatureDashboard: '✨ Ein Dashboard über alle Projekte',
proFeatureExport: '✨ Karten als PDF für eure Berichte',
proFeatureGuestRoles: '✨ Gastrollen & externe Zusammenarbeit',
proFeatureMobile: '✨ PLANKA auf dem Handy und Tablet',
proFeatureRecurringCards: '✨ Wiederkehrende Karten',
proFeatureSso: '✨ Anmeldung am Firmenverzeichnis (SSO)',
proFeatureThemes: '✨ Fünf Themes und eure Akzentfarbe',
proFeatureTimeline: '✨ Timeline: das ganze Jahr auf einer Achse',
proTrialSelfHosted: '✨ PLANKA Pro 30 Tage auf eurem Server testen',
project: 'Projekt',
projectNotFound_title: 'Projekt nicht gefunden',
projectOwner: 'Projektleitung',
+10 -3
View File
@@ -195,9 +195,6 @@ export default {
twoFactorAuthentication: 'Two-Factor Authentication',
unknownDevice: 'Unknown device',
upgradeTeamToPro_title: 'Upgrade Team to Pro',
proFeatureCalendar: '✨ Calendar View for your boards',
proFeatureRecurringCards: '✨ Recurring Cards',
proFeatureGuestRoles: '✨ Guest Roles & External Collaboration',
date: 'Date',
deactivateUser_title: 'Deactivate User',
defaultCardType_title: 'Default Card Type',
@@ -326,6 +323,16 @@ export default {
pressPasteShortcutToAddAttachmentFromClipboard:
'Tip: press Ctrl-V (Cmd-V on Mac) to add an attachment from the clipboard.',
private: 'Private',
proFeatureCalendar: '✨ Calendar View for your boards',
proFeatureDashboard: '✨ One dashboard across every project',
proFeatureExport: '✨ Cards as PDF for your reporting',
proFeatureGuestRoles: '✨ Guest Roles & External Collaboration',
proFeatureMobile: '✨ PLANKA on phones and tablets',
proFeatureRecurringCards: '✨ Recurring Cards',
proFeatureSso: '✨ Sign in with your company directory (SSO)',
proFeatureThemes: '✨ Five themes and your own accent color',
proFeatureTimeline: '✨ Timeline: a whole year on one axis',
proTrialSelfHosted: '✨ Try PLANKA Pro for 30 days on your own server',
project: 'Project',
projectNotFound_title: 'Project Not Found',
projectOwner: 'Project owner',