feat: Add Pro features discovery banner

Adds a dismissible banner in the topbar that informs users about
PLANKA Pro features.

- Dismissible per-user (stored in localStorage)
- Reappears after 30 days
- Rotates between main message and 3 feature highlights
- Links to planka.app/pro (with ref parameter for anonymous source attribution)
This commit is contained in:
Daniel Hiller
2026-04-18 13:32:22 +00:00
parent 4e23f3c8a9
commit cd9ba8ff4f
41 changed files with 450 additions and 10 deletions
+3 -1
View File
@@ -1,5 +1,5 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Copyright (c) 2026 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
@@ -8,6 +8,7 @@ import { useSelector } from 'react-redux';
import selectors from '../../../selectors';
import Header from '../Header';
import PromoBanner from '../PromoBanner/PromoBanner';
import Favorites from '../Favorites';
import HomeActions from '../HomeActions';
import Project from '../../projects/Project';
@@ -22,6 +23,7 @@ const Fixed = React.memo(() => {
return (
<div className={styles.wrapper}>
<Header />
<PromoBanner />
<Favorites />
{projectId === undefined && <HomeActions />}
{projectId && <Project />}
@@ -0,0 +1,111 @@
/*!
* Copyright (c) 2026 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import selectors from '../../../selectors';
import styles from './PromoBanner.module.scss';
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],
];
function getDismissKey(userId) {
return `planka_proBannerDismissed_${userId}`;
}
function isBannerDismissed(userId) {
const stored = localStorage.getItem(getDismissKey(userId));
if (!stored) return false;
return Date.now() - Date.parse(stored) < DISMISS_DURATION_MS;
}
const PromoBanner = React.memo(() => {
const userId = useSelector(selectors.selectCurrentUserId);
const [dismissed, setDismissed] = useState(() => isBannerDismissed(userId));
const [textIndex, setTextIndex] = useState(0);
const [visible, setVisible] = useState(true);
const wrapperRef = useRef(null);
const [t] = useTranslation();
useEffect(() => {
if (dismissed) {
document.documentElement.style.removeProperty(CSS_VAR);
return undefined;
}
const height = wrapperRef.current ? wrapperRef.current.offsetHeight : 0;
document.documentElement.style.setProperty(CSS_VAR, `${height}px`);
return () => document.documentElement.style.removeProperty(CSS_VAR);
}, [dismissed]);
useEffect(() => {
if (dismissed) return undefined;
const interval = setInterval(() => {
setVisible(false);
setTimeout(() => {
setTextIndex((i) => (i + 1) % TEXTS.length);
setVisible(true);
}, 400);
}, CYCLE_INTERVAL_MS);
return () => clearInterval(interval);
}, [dismissed]);
const handleDismiss = useCallback(
(e) => {
e.preventDefault();
localStorage.setItem(getDismissKey(userId), new Date().toISOString());
setDismissed(true);
},
[userId],
);
if (dismissed) return null;
return (
<div ref={wrapperRef} className={styles.wrapper}>
<a
href={PRO_URL}
target="_blank"
rel="noopener noreferrer"
className={`${styles.textLink} ${visible ? styles.textVisible : styles.textHidden}`}
>
{t(`common.${TEXTS[textIndex]}`)}
<span className={styles.externalIcon}>↗</span>
</a>
<button
type="button"
title={t('common.dismissProBannerFor30Days')}
className={styles.closeButton}
onClick={handleDismiss}
>
×
</button>
</div>
);
});
export default PromoBanner;
@@ -0,0 +1,60 @@
/*!
* Copyright (c) 2026 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.wrapper {
align-items: center;
background: rgba(0, 0, 0, 0.18);
display: flex;
gap: 8px;
justify-content: center;
padding: 5px 16px;
}
.textLink {
color: #fff;
cursor: pointer;
flex: 1 1 auto;
font-size: 13px;
text-align: center;
text-decoration: none;
&:hover {
color: #bdff22;
}
}
.externalIcon {
font-size: 11px;
margin-left: 4px;
opacity: 0.7;
vertical-align: super;
}
.textVisible {
opacity: 1;
transition: opacity 0.4s ease;
}
.textHidden {
opacity: 0;
transition: opacity 0.4s ease;
}
.closeButton {
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.45);
cursor: pointer;
flex: 0 0 auto;
font-size: 18px;
line-height: 1;
padding: 0 2px;
}
.closeButton:hover {
color: rgba(255, 255, 255, 0.9);
}
}
@@ -1,5 +1,5 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Copyright (c) 2026 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
@@ -37,15 +37,15 @@
.wrapper {
height: 100%;
margin-top: 116px;
margin-top: calc(116px + var(--promo-banner-height, 0px));
}
.wrapperBoard {
margin-top: 174px;
margin-top: calc(174px + var(--promo-banner-height, 0px));
}
.wrapperBoardWithFavorites {
margin-top: 264px;
margin-top: calc(264px + var(--promo-banner-height, 0px));
}
.wrapperFlex {
@@ -57,11 +57,11 @@
}
.wrapperProject {
margin-top: 98px;
margin-top: calc(98px + var(--promo-banner-height, 0px));
}
.wrapperProjectWithFavorites {
margin-top: 188px;
margin-top: calc(188px + var(--promo-banner-height, 0px));
}
.wrapperTransitioning {
@@ -73,6 +73,6 @@
}
.wrapperWithFavorites {
margin-top: 206px;
margin-top: calc(206px + var(--promo-banner-height, 0px));
}
}
@@ -1,5 +1,5 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Copyright (c) 2026 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
@@ -104,6 +104,17 @@ const UserActionsStep = React.memo(({ onClose }) => {
context: 'title',
})}
</Menu.Item>
<Menu.Item
href="https://planka.app/pro?ref=app-menu"
target="_blank"
rel="noopener noreferrer"
className={styles.proMenuItem}
>
<Icon name="gem" className={styles.proMenuItemIcon} />
{withAdministration
? t('common.upgradeTeamToPro', { context: 'title' })
: t('common.discoverPlankaPro', { context: 'title' })}
</Menu.Item>
<hr className={styles.divider} />
<Menu.Item
{...logoutMenuItemProps} // eslint-disable-line react/jsx-props-no-spreading
@@ -1,5 +1,5 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Copyright (c) 2026 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
@@ -25,4 +25,14 @@
float: left;
margin: 0 0.5em 0 0;
}
.proMenuItem {
margin: 0;
padding-left: 14px;
}
.proMenuItemIcon {
float: left;
margin: 0 0.5em 0 0;
}
}