Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added public/assets/images/clock_warning.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions src/components/layout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import clsx from 'clsx';
import Cookies from 'js-cookie';
import { observer } from 'mobx-react-lite';
import { Outlet } from 'react-router-dom';
import PositionsBanner, { PositionsBannerMobile, PositionsBannerModal } from '@/components/positions-banner';
import PWAUpdateNotification from '@/components/pwa-update-notification';
import { api_base } from '@/external/bot-skeleton';
import { useOfflineDetection } from '@/hooks/useOfflineDetection';
Expand Down Expand Up @@ -247,12 +248,15 @@ const Layout = observer(() => {
'quick-strategy-active': is_quick_strategy_active && !isDesktop,
})}
>
{!isCallbackPage && !isEndpointPage && <PositionsBanner />}
{!isCallbackPage && !isEndpointPage && <PositionsBannerMobile />}
{!isCallbackPage && <AppHeader isAuthenticating={isAuthenticating || !isInitialAuthCheckComplete} />}
<Body>
<Outlet />
</Body>
{!isCallbackPage && isDesktop && <Footer />}
<PWAUpdateNotification />
{!isCallbackPage && !isEndpointPage && <PositionsBannerModal />}
</div>
);
});
Expand Down
9 changes: 9 additions & 0 deletions src/components/positions-banner/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import PositionsBanner from './positions-banner';
import PositionsBannerMobile from './positions-banner-mobile';
import PositionsBannerModal from './positions-banner-modal';
import './positions-banner.scss';
import './positions-banner-modal.scss';

export { PositionsBannerMobile, PositionsBannerModal };
export { markPositionsBannerSeen, usePositionsBannerSeen } from './use-positions-banner-seen';
export default PositionsBanner;
42 changes: 42 additions & 0 deletions src/components/positions-banner/positions-banner-mobile.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { observer } from 'mobx-react-lite';
import { standalone_routes } from '@/components/shared';
import { useStore } from '@/hooks/useStore';
import { LabelPairedChevronRightSmBoldIcon, LabelPairedCircleInfoSmRegularIcon } from '@deriv/quill-icons/LabelPaired';
import { CaptionText } from '@deriv-com/quill-ui';
import { Localize } from '@deriv-com/translations';
import { useDevice } from '@deriv-com/ui';

const PositionsBannerMobile = observer(() => {
const { isDesktop } = useDevice();
const store = useStore();
const is_logged_in = store?.client?.is_logged_in;

if (isDesktop || !is_logged_in) return null;

const handleClick = () => {
window.location.assign(standalone_routes.reports);
};

return (
<button
type='button'
className='positions-banner positions-banner--mobile'
onClick={handleClick}
aria-label='Review your reports before the Deriv Bot upgrade'
>
<LabelPairedCircleInfoSmRegularIcon
className='positions-banner__icon'
fill='var(--component-textIcon-normal-prominent)'
/>
<CaptionText className='positions-banner__text'>
<Localize i18n_default_text='System is upgrading. Close positions by 13 June.' />
</CaptionText>
<LabelPairedChevronRightSmBoldIcon
className='positions-banner__chevron'
fill='var(--component-textIcon-normal-prominent)'
/>
</button>
);
});

export default PositionsBannerMobile;
19 changes: 19 additions & 0 deletions src/components/positions-banner/positions-banner-modal.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
.positions-banner-modal {
&__content {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2.4rem;
text-align: left;
}

&__description {
color: var(--component-textIcon-normal-default);
}

&__cta {
width: 100%;
margin-top: 1.6rem;
border-radius: 2.4rem;
}
}
95 changes: 95 additions & 0 deletions src/components/positions-banner/positions-banner-modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import React from 'react';
import { observer } from 'mobx-react-lite';
import { getUrlBase, standalone_routes } from '@/components/shared';
import { useStore } from '@/hooks/useStore';
import { Button, Modal, Text } from '@deriv-com/quill-ui';
import { Localize } from '@deriv-com/translations';
import { useDevice } from '@deriv-com/ui';
import { markPositionsBannerSeen, usePositionsBannerSeen } from './use-positions-banner-seen';

const PositionsBannerModal = observer(() => {
const store = useStore();
const is_logged_in = store?.client?.is_logged_in;
const { isMobile } = useDevice();

const [is_modal_open, setIsModalOpen] = React.useState(false);
// Shared hook — also consumed by TourStartDialog so the welcome/tour
// suppresses itself while this modal is pending on mobile.
const is_seen = usePositionsBannerSeen();
const timeout_ref = React.useRef<ReturnType<typeof setTimeout>>();

const onClose = () => {
markPositionsBannerSeen();
setIsModalOpen(false);
};

const onReview = () => {
markPositionsBannerSeen();
setIsModalOpen(false);
// Reports lives on app.deriv.com — full navigation is intentional.
window.location.assign(standalone_routes.reports);
};

React.useEffect(() => {
// Modal is mobile-only, logged-in-only, and one-time-per-browser.
if (!isMobile || !is_logged_in || is_seen) return undefined;

// Delay matches OnboardingGuide. Opening synchronously on mount races
// with the CSSTransition setState inside Quill UI's modal during the
// initial commit phase and can cause an intermittent crash.
timeout_ref.current = setTimeout(() => setIsModalOpen(true), 800);
return () => clearTimeout(timeout_ref.current);
}, [isMobile, is_logged_in, is_seen]);

// Conditionally mount — modal is mobile-only, logged-in-only, and one-time-per-browser.
if (!isMobile || !is_logged_in || is_seen) return null;

return (
<Modal
isOpened={is_modal_open}
isNonExpandable
isMobile={isMobile}
showHandleBar
showCrossIcon={false}
shouldCloseModalOnSwipeDown
toggleModal={onClose}
showPrimaryButton={false}
hasFooter={false}
className='positions-banner-modal'
>
<Modal.Header
image={<img src={getUrlBase('/assets/images/clock_warning.png')} alt='' width={96} height={96} />}
className='positions-banner-modal__header'
/>
<Modal.Body>
<div className='positions-banner-modal__content'>
<Text as='h2' size='lg' bold className='positions-banner-modal__title'>
<Localize i18n_default_text='Close positions by 13 June' />
</Text>
<Text size='md' className='positions-banner-modal__description'>
<Localize i18n_default_text="We're upgrading Deriv Bot on 13 June at 06:00 UTC." />
</Text>
<Text size='md' className='positions-banner-modal__description'>
<Localize i18n_default_text='Any running bots and open positions will be automatically closed at this time, so please review and close yours beforehand.' />
</Text>
<Text size='md' className='positions-banner-modal__description'>
<Localize i18n_default_text="You'll be able to run new bots after the upgrade." />
</Text>
<Button
className='positions-banner-modal__cta'
variant='primary'
color='coral'
size='lg'
fullWidth
label={<Localize i18n_default_text='Review positions' />}
onClick={onReview}
/>
</div>
</Modal.Body>
</Modal>
);
});

// Layout re-renders often (auth/currency state churn) — memoise to avoid
// pointless re-renders while the modal sits in the tree.
export default React.memo(PositionsBannerModal);
44 changes: 44 additions & 0 deletions src/components/positions-banner/positions-banner.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
.positions-banner {
display: flex;
align-items: center;
gap: 0.8rem;
width: 100%;
min-height: 3.6rem; // Figma: 36px desktop
padding: 0.8rem 1.6rem;
flex-shrink: 0; // never collapse inside the Layout flex column
background-color: var(--semantic-color-yellow-solid-surface-normal-lowest);
border-bottom: 1px solid var(--general-section-2);
color: var(--text-general);
font-size: var(--text-size-xs);
line-height: 1.4;

&__icon,
&__chevron {
flex-shrink: 0;
}

&__text {
flex: 1;
}

&__link {
color: var(--brand-red-coral);
text-decoration: underline;
cursor: pointer;
}

// Mobile variant — button-styled banner with chevron
&--mobile {
height: 3.2rem; // Figma: 32px mobile
min-height: 3.2rem;
padding: 0 1.6rem;
border: none;
border-bottom: 1px solid var(--semantic-color-slate-solid-surface-frame-mid);
color: var(--component-textIcon-normal-prominent);
cursor: pointer;
text-align: start;

// Strip default <button> background — the .positions-banner yellow stays.
appearance: none;
}
}
45 changes: 45 additions & 0 deletions src/components/positions-banner/positions-banner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import React from 'react';
import { observer } from 'mobx-react-lite';
import { standalone_routes } from '@/components/shared';
import { useStore } from '@/hooks/useStore';
import { LabelPairedCircleInfoSmRegularIcon } from '@deriv/quill-icons/LabelPaired';
import { Localize } from '@deriv-com/translations';
import { useDevice } from '@deriv-com/ui';

const PositionsBanner = observer(() => {
const { isDesktop } = useDevice();
const store = useStore();
const is_logged_in = store?.client?.is_logged_in;

if (!isDesktop || !is_logged_in) return null;

const handleReviewClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
// Reports is hosted on app.deriv.com, not in-app — full navigation is correct.
window.location.assign(standalone_routes.reports);
};

return (
<div className='positions-banner' role='status' aria-live='polite'>
<LabelPairedCircleInfoSmRegularIcon
className='positions-banner__icon'
fill='var(--component-textIcon-normal-default)'
/>
<span className='positions-banner__text'>
<Localize
i18n_default_text="We're upgrading Deriv Bot on 13 June at 06:00 UTC. Any running bots and open positions will be automatically closed at this time, so please <0>review and close yours</0> beforehand. You'll be able to run new bots after the upgrade."
components={[
<a
key={0}
href={standalone_routes.reports}
onClick={handleReviewClick}
className='positions-banner__link'
/>,
]}
/>
</span>
</div>
);
});

export default PositionsBanner;
40 changes: 40 additions & 0 deletions src/components/positions-banner/use-positions-banner-seen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import React from 'react';

/**
* Source of truth for whether the user has dismissed the mobile positions
* banner modal. Backed by localStorage so it persists across sessions, and
* exposed via a custom event so multiple subscribers (the modal itself, plus
* suppressors like TourStartDialog) stay in sync within the same tab.
*
* Cross-tab updates already work for free via the native `storage` event,
* but the most common case — modal and tour-start-dialog mounted in the
* same tab — needs the custom event because `storage` doesn't fire in the
* tab that triggered the write.
*/

export const POSITIONS_BANNER_SEEN_KEY = 'positions_banner_seen';
const EVENT_NAME = 'positions-banner-seen-change';

const readSeen = (): boolean => localStorage.getItem(POSITIONS_BANNER_SEEN_KEY) === 'true';

export const markPositionsBannerSeen = () => {
localStorage.setItem(POSITIONS_BANNER_SEEN_KEY, 'true');
window.dispatchEvent(new Event(EVENT_NAME));
};

export const usePositionsBannerSeen = (): boolean => {
const [is_seen, setIsSeen] = React.useState<boolean>(readSeen);

React.useEffect(() => {
const handler = () => setIsSeen(readSeen());
window.addEventListener(EVENT_NAME, handler);
// Also pick up cross-tab dismissals.
window.addEventListener('storage', handler);
return () => {
window.removeEventListener(EVENT_NAME, handler);
window.removeEventListener('storage', handler);
};
}, []);

return is_seen;
};
10 changes: 8 additions & 2 deletions src/pages/dashboard/info-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react';
import classNames from 'classnames';
import { observer } from 'mobx-react-lite';
import { usePositionsBannerSeen } from '@/components/positions-banner/use-positions-banner-seen';
import Modal from '@/components/shared_ui/modal';
import Text from '@/components/shared_ui/text';
import { DBOT_TABS } from '@/constants/bot-contents';
Expand Down Expand Up @@ -34,14 +35,19 @@ const InfoPanel = observer(() => {
};
};

// Suppress the Welcome / Guide / FAQs panel while the mobile positions-
// banner modal is pending, so the upgrade notice gets first attention.
const is_positions_banner_seen = usePositionsBannerSeen();
const is_positions_banner_pending = !isDesktop && !is_positions_banner_seen;

const handleClose = () => {
setInfoPanelVisibility(false);
setIsTourOpen(false);
localStorage.setItem('dbot_should_show_info', JSON.stringify(Date.now()));
};

React.useEffect(() => {
if (is_tnc_needed) {
if (is_tnc_needed || is_positions_banner_pending) {
setIsTourOpen(false);
} else {
if (is_info_panel_visible) {
Expand All @@ -50,7 +56,7 @@ const InfoPanel = observer(() => {
setIsTourOpen(false);
}
}
}, [is_tnc_needed, is_info_panel_visible]);
}, [is_tnc_needed, is_positions_banner_pending, is_info_panel_visible]);

const renderInfo = () => (
<div className='db-info-panel'>
Expand Down
8 changes: 6 additions & 2 deletions src/pages/tutorials/dbot-tours/common/tour-start-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react';
import { observer } from 'mobx-react-lite';
import { usePositionsBannerSeen } from '@/components/positions-banner/use-positions-banner-seen';
import Dialog from '@/components/shared_ui/dialog';
import Text from '@/components/shared_ui/text';
import { DBOT_TABS } from '@/constants/bot-contents';
Expand Down Expand Up @@ -35,9 +36,12 @@ const TourStartDialog = observer(() => {
const tour_dialog_action = getTourDialogAction(!isDesktop);
const [is_tour_open, setIsTourOpen] = React.useState(false);
const is_tnc_needed = useIsTNCNeeded();
// Suppress welcome/tour while the mobile positions-banner modal is pending.
const is_positions_banner_seen = usePositionsBannerSeen();
const is_positions_banner_pending = !isDesktop && !is_positions_banner_seen;

React.useEffect(() => {
if (is_tnc_needed || is_platform_migrated) {
if (is_tnc_needed || is_platform_migrated || is_positions_banner_pending) {
setIsTourOpen(false);
} else {
if (is_tour_dialog_visible) {
Expand All @@ -46,7 +50,7 @@ const TourStartDialog = observer(() => {
setIsTourOpen(false);
}
}
}, [is_tnc_needed, is_platform_migrated, is_tour_dialog_visible]);
}, [is_tnc_needed, is_platform_migrated, is_positions_banner_pending, is_tour_dialog_visible]);

const getTourContent = () => {
return (
Expand Down
Loading
Loading