From cd9ba8ff4ffb53e42a2763dd119b6b834493bad8 Mon Sep 17 00:00:00 2001 From: Daniel Hiller Date: Sat, 18 Apr 2026 13:21:43 +0000 Subject: [PATCH] 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) --- client/src/components/common/Fixed/Fixed.jsx | 4 +- .../common/PromoBanner/PromoBanner.jsx | 111 ++++++++++++++++++ .../PromoBanner/PromoBanner.module.scss | 60 ++++++++++ .../common/Static/Static.module.scss | 14 +-- .../users/UserActionsStep/UserActionsStep.jsx | 13 +- .../UserActionsStep.module.scss | 12 +- client/src/locales/ar-YE/core.js | 7 ++ client/src/locales/bg-BG/core.js | 7 ++ client/src/locales/ca-ES/core.js | 7 ++ client/src/locales/cs-CZ/core.js | 7 ++ client/src/locales/da-DK/core.js | 7 ++ client/src/locales/de-DE/core.js | 7 ++ client/src/locales/el-GR/core.js | 8 ++ client/src/locales/en-GB/core.js | 7 ++ client/src/locales/en-US/core.js | 7 ++ client/src/locales/es-ES/core.js | 7 ++ client/src/locales/et-EE/core.js | 7 ++ client/src/locales/fa-IR/core.js | 7 ++ client/src/locales/fi-FI/core.js | 7 ++ client/src/locales/fr-FR/core.js | 7 ++ client/src/locales/hu-HU/core.js | 7 ++ client/src/locales/id-ID/core.js | 7 ++ client/src/locales/it-IT/core.js | 7 ++ client/src/locales/ja-JP/core.js | 7 ++ client/src/locales/ko-KR/core.js | 7 ++ client/src/locales/nl-NL/core.js | 7 ++ client/src/locales/pl-PL/core.js | 7 ++ client/src/locales/pt-BR/core.js | 7 ++ client/src/locales/pt-PT/core.js | 7 ++ client/src/locales/ro-RO/core.js | 7 ++ client/src/locales/ru-RU/core.js | 7 ++ client/src/locales/sk-SK/core.js | 7 ++ client/src/locales/sr-Cyrl-RS/core.js | 7 ++ client/src/locales/sr-Latn-RS/core.js | 7 ++ client/src/locales/sv-SE/core.js | 7 ++ client/src/locales/tr-TR/core.js | 7 ++ client/src/locales/uk-UA/core.js | 7 ++ client/src/locales/uz-UZ/core.js | 7 ++ client/src/locales/vi-VN/core.js | 7 ++ client/src/locales/zh-CN/core.js | 7 ++ client/src/locales/zh-TW/core.js | 7 ++ 41 files changed, 450 insertions(+), 10 deletions(-) create mode 100644 client/src/components/common/PromoBanner/PromoBanner.jsx create mode 100644 client/src/components/common/PromoBanner/PromoBanner.module.scss diff --git a/client/src/components/common/Fixed/Fixed.jsx b/client/src/components/common/Fixed/Fixed.jsx index b57caefa..5cb44db7 100644 --- a/client/src/components/common/Fixed/Fixed.jsx +++ b/client/src/components/common/Fixed/Fixed.jsx @@ -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 (
+ {projectId === undefined && } {projectId && } diff --git a/client/src/components/common/PromoBanner/PromoBanner.jsx b/client/src/components/common/PromoBanner/PromoBanner.jsx new file mode 100644 index 00000000..fb94c6ed --- /dev/null +++ b/client/src/components/common/PromoBanner/PromoBanner.jsx @@ -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 ( + + ); +}); + +export default PromoBanner; diff --git a/client/src/components/common/PromoBanner/PromoBanner.module.scss b/client/src/components/common/PromoBanner/PromoBanner.module.scss new file mode 100644 index 00000000..032bfaeb --- /dev/null +++ b/client/src/components/common/PromoBanner/PromoBanner.module.scss @@ -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); + } +} diff --git a/client/src/components/common/Static/Static.module.scss b/client/src/components/common/Static/Static.module.scss index a9fd9268..c6424457 100644 --- a/client/src/components/common/Static/Static.module.scss +++ b/client/src/components/common/Static/Static.module.scss @@ -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)); } } diff --git a/client/src/components/users/UserActionsStep/UserActionsStep.jsx b/client/src/components/users/UserActionsStep/UserActionsStep.jsx index 0c0fcf60..d3fb78d8 100755 --- a/client/src/components/users/UserActionsStep/UserActionsStep.jsx +++ b/client/src/components/users/UserActionsStep/UserActionsStep.jsx @@ -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', })} + + + {withAdministration + ? t('common.upgradeTeamToPro', { context: 'title' }) + : t('common.discoverPlankaPro', { context: 'title' })} +