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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user