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' })} +