From 0836757ee02cb239fce4cceaee004c6bafa96c4e Mon Sep 17 00:00:00 2001 From: jaaaaavier Date: Fri, 19 Jun 2026 15:55:26 +0200 Subject: [PATCH 1/7] feat: new banners for storage advertisement --- package.json | 2 +- src/app/i18n/locales/de.json | 21 +++ src/app/i18n/locales/en.json | 21 +++ src/app/i18n/locales/es.json | 21 +++ src/app/i18n/locales/fr.json | 21 +++ src/app/i18n/locales/it.json | 21 +++ src/app/i18n/locales/ru.json | 21 +++ src/app/i18n/locales/tw.json | 21 +++ src/app/i18n/locales/zh.json | 21 +++ src/components/SidenavWrapper.tsx | 55 ++++++- src/index.scss | 22 +++ src/views/Backups/BackupsView.tsx | 4 +- .../DriveExplorer/DriveExplorer.tsx | 5 +- src/views/Home/components/WarningMessage.tsx | 18 --- .../Home/components/WarningMessageWrapper.tsx | 152 +++++++++++++++--- src/views/Shared/SharedView.tsx | 4 +- tailwind.config.ts | 8 + yarn.lock | 8 +- 18 files changed, 391 insertions(+), 55 deletions(-) delete mode 100644 src/views/Home/components/WarningMessage.tsx diff --git a/package.json b/package.json index 87991bddf6..c32b2ca41e 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "@internxt/css-config": "1.1.0", "@internxt/lib": "1.4.1", "@internxt/sdk": "=1.17.8", - "@internxt/ui": "=0.1.21", + "@internxt/ui": "=0.1.22", "@phosphor-icons/react": "^2.1.7", "@popperjs/core": "^2.11.6", "@reduxjs/toolkit": "^1.6.0", diff --git a/src/app/i18n/locales/de.json b/src/app/i18n/locales/de.json index 75a631def5..368e12cab1 100644 --- a/src/app/i18n/locales/de.json +++ b/src/app/i18n/locales/de.json @@ -1096,6 +1096,27 @@ "title": "Cleaner freischalten", "description": "Upgrade jetzt, um deine Dateien zu optimieren und Speicherplatz freizugeben." } + }, + "reachingUsageBanner": { + "lowWarning": { + "title": "Hol dir mehr Speicherplatz für deine Dateien", + "descriptionLabelLine1": "Schalte zusätzlichen Speicher mit einem exklusiven **Rabatt von 85 %** auf dein Upgrade frei", + "descriptionLabelLine2": "Greife auf erweiterte Funktionen wie Dateiversionsverlauf, Rclone, NAS-Unterstützung, Premium-Support und mehr zu" + }, + "midWarning": { + "title": "Dein Speicher füllt sich", + "descriptionLabelLine1": "Führe heute ein Upgrade mit einem exklusiven **Rabatt von 85 %** durch und lade weiter ohne Unterbrechungen hoch", + "descriptionLabelLine2": "Erhalte mehr Speicher sowie erweiterte Funktionen wie Dateiversionsverlauf, NAS-Unterstützung, Rclone-Integration und Premium-Support", + "sidenavStorageText": "Mehr Speicher 85 % Rabatt" + }, + "highWarning": { + "title": "Dein Speicher ist fast voll", + "descriptionLabelLine1": "Möglicherweise kannst du bald keine neuen Dateien mehr hochladen", + "descriptionLabelLine2": "Führe jetzt ein Upgrade mit einem exklusiven Rabatt von 85 % durch, um deine Dateien ohne Grenzen weiter zu speichern und zu synchronisieren", + "sidenavStorageText": "Speicher kaufen 85 % Rabatt" + }, + "close": "Schließen", + "cta": "Angebot sichern" } }, "form": { diff --git a/src/app/i18n/locales/en.json b/src/app/i18n/locales/en.json index 6fd23360fe..7bceda95b9 100644 --- a/src/app/i18n/locales/en.json +++ b/src/app/i18n/locales/en.json @@ -1170,6 +1170,27 @@ "title": "Unlock Cleaner", "description": "Upgrade now to keep your files optimized and free up space." } + }, + "reachingUsageBanner": { + "lowWarning": { + "title": "Get more space for your files", + "descriptionLabelLine1": "Unlock additional storage with an exclusive **85% discount** on your upgrade", + "descriptionLabelLine2": "Access advanced features like file version history, Rclone, NAS support, premium support, and more" + }, + "midWarning": { + "title": "Your storage is filling up", + "descriptionLabelLine1": "Upgrade today with an exclusive **85% discount** and keep uploading without interruptions", + "descriptionLabelLine2": "Get more storage plus advanced features like file version history, NAS support, Rclone integration, and premium support", + "sidenavStorageText": "More space 85% off" + }, + "highWarning": { + "title": "Your storage is almost full", + "descriptionLabelLine1": "You may soon be unable to upload new files", + "descriptionLabelLine2": "Upgrade now with an exclusive 85% discount to continue storing and syncing your files without limits", + "sidenavStorageText": "Buy space 85% off" + }, + "close": "Close", + "cta": "Get offer" } }, "form": { diff --git a/src/app/i18n/locales/es.json b/src/app/i18n/locales/es.json index 4965d264b3..ba50dbfb1e 100644 --- a/src/app/i18n/locales/es.json +++ b/src/app/i18n/locales/es.json @@ -1147,6 +1147,27 @@ "title": "Desbloquear Cleaner", "description": "Mejora tu plan ahora para mantener tus archivos optimizados y liberar espacio." } + }, + "reachingUsageBanner": { + "lowWarning": { + "title": "Consigue más espacio para tus archivos", + "descriptionLabelLine1": "Desbloquea almacenamiento adicional con un **85% de descuento** exclusivo en tu mejora", + "descriptionLabelLine2": "Accede a funciones avanzadas como el historial de versiones de archivos, Rclone, compatibilidad con NAS, soporte premium y mucho más" + }, + "midWarning": { + "title": "Tu almacenamiento se está llenando", + "descriptionLabelLine1": "Mejora hoy con un **85% de descuento** exclusivo y sigue subiendo archivos sin interrupciones", + "descriptionLabelLine2": "Consigue más almacenamiento además de funciones avanzadas como el historial de versiones de archivos, compatibilidad con NAS, integración con Rclone y soporte premium", + "sidenavStorageText": "Más espacio 85% dto." + }, + "highWarning": { + "title": "Tu almacenamiento está casi lleno", + "descriptionLabelLine1": "Es posible que pronto no puedas subir nuevos archivos", + "descriptionLabelLine2": "Mejora ahora con un 85% de descuento exclusivo para seguir almacenando y sincronizando tus archivos sin límites", + "sidenavStorageText": "Compra espacio 85% dto." + }, + "close": "Cerrar", + "cta": "Obtener oferta" } }, "form": { diff --git a/src/app/i18n/locales/fr.json b/src/app/i18n/locales/fr.json index 5a25d6480e..5b5baf5859 100644 --- a/src/app/i18n/locales/fr.json +++ b/src/app/i18n/locales/fr.json @@ -1099,6 +1099,27 @@ "title": "Débloquer Cleaner", "description": "Mettez à niveau pour optimiser vos fichiers et libérer de l’espace." } + }, + "reachingUsageBanner": { + "lowWarning": { + "title": "Obtenez plus d'espace pour vos fichiers", + "descriptionLabelLine1": "Débloquez un espace de stockage supplémentaire avec une **réduction exclusive de 85 %** sur votre mise à niveau", + "descriptionLabelLine2": "Accédez à des fonctionnalités avancées comme l'historique des versions de fichiers, Rclone, la prise en charge NAS, le support premium et bien plus encore" + }, + "midWarning": { + "title": "Votre espace de stockage se remplit", + "descriptionLabelLine1": "Passez à la version supérieure dès aujourd'hui avec une **réduction exclusive de 85 %** et continuez à envoyer vos fichiers sans interruption", + "descriptionLabelLine2": "Obtenez plus de stockage ainsi que des fonctionnalités avancées comme l'historique des versions de fichiers, la prise en charge NAS, l'intégration Rclone et le support premium", + "sidenavStorageText": "Plus d'espace -85 %" + }, + "highWarning": { + "title": "Votre espace de stockage est presque plein", + "descriptionLabelLine1": "Vous pourriez bientôt ne plus pouvoir envoyer de nouveaux fichiers", + "descriptionLabelLine2": "Passez à la version supérieure dès maintenant avec une réduction exclusive de 85 % pour continuer à stocker et synchroniser vos fichiers sans limites", + "sidenavStorageText": "Acheter de l'espace -85 %" + }, + "close": "Fermer", + "cta": "Profiter de l'offre" } }, "form": { diff --git a/src/app/i18n/locales/it.json b/src/app/i18n/locales/it.json index 7a72d2d33b..4dc1cce1df 100644 --- a/src/app/i18n/locales/it.json +++ b/src/app/i18n/locales/it.json @@ -1205,6 +1205,27 @@ "title": "Sblocca Cleaner", "description": "Aggiorna ora per mantenere i tuoi file ottimizzati e liberare spazio." } + }, + "reachingUsageBanner": { + "lowWarning": { + "title": "Ottieni più spazio per i tuoi file", + "descriptionLabelLine1": "Sblocca spazio di archiviazione aggiuntivo con uno **sconto esclusivo dell'85%** sul tuo upgrade", + "descriptionLabelLine2": "Accedi a funzionalità avanzate come la cronologia delle versioni dei file, Rclone, il supporto NAS, il supporto premium e molto altro" + }, + "midWarning": { + "title": "Il tuo spazio di archiviazione si sta riempiendo", + "descriptionLabelLine1": "Esegui l'upgrade oggi con uno **sconto esclusivo dell'85%** e continua a caricare senza interruzioni", + "descriptionLabelLine2": "Ottieni più spazio di archiviazione e funzionalità avanzate come la cronologia delle versioni dei file, il supporto NAS, l'integrazione con Rclone e il supporto premium", + "sidenavStorageText": "Più spazio -85%" + }, + "highWarning": { + "title": "Il tuo spazio di archiviazione è quasi pieno", + "descriptionLabelLine1": "Presto potresti non essere più in grado di caricare nuovi file", + "descriptionLabelLine2": "Esegui subito l'upgrade con uno sconto esclusivo dell'85% per continuare ad archiviare e sincronizzare i tuoi file senza limiti", + "sidenavStorageText": "Acquista spazio -85%" + }, + "close": "Chiudi", + "cta": "Ottieni l'offerta" } }, "form": { diff --git a/src/app/i18n/locales/ru.json b/src/app/i18n/locales/ru.json index 10778c2d1e..9073dcbb57 100644 --- a/src/app/i18n/locales/ru.json +++ b/src/app/i18n/locales/ru.json @@ -1113,6 +1113,27 @@ "title": "Разблокировать Cleaner", "description": "Обновите план, чтобы оптимизировать файлы и освободить место." } + }, + "reachingUsageBanner": { + "lowWarning": { + "title": "Получите больше места для ваших файлов", + "descriptionLabelLine1": "Откройте дополнительное хранилище с эксклюзивной **скидкой 85%** на повышение тарифа", + "descriptionLabelLine2": "Получите доступ к расширенным функциям, таким как история версий файлов, Rclone, поддержка NAS, премиум-поддержка и многое другое" + }, + "midWarning": { + "title": "Ваше хранилище заполняется", + "descriptionLabelLine1": "Перейдите на улучшенный тариф сегодня с эксклюзивной **скидкой 85%** и продолжайте загружать файлы без перерывов", + "descriptionLabelLine2": "Получите больше места, а также расширенные функции, такие как история версий файлов, поддержка NAS, интеграция с Rclone и премиум-поддержка", + "sidenavStorageText": "Больше места -85%" + }, + "highWarning": { + "title": "Ваше хранилище почти заполнено", + "descriptionLabelLine1": "Возможно, скоро вы не сможете загружать новые файлы", + "descriptionLabelLine2": "Перейдите на улучшенный тариф сейчас с эксклюзивной скидкой 85%, чтобы продолжать хранить и синхронизировать файлы без ограничений", + "sidenavStorageText": "Купить место -85%" + }, + "close": "Закрыть", + "cta": "Получить предложение" } }, "form": { diff --git a/src/app/i18n/locales/tw.json b/src/app/i18n/locales/tw.json index 62850c43d8..c27a44c8e0 100644 --- a/src/app/i18n/locales/tw.json +++ b/src/app/i18n/locales/tw.json @@ -1100,6 +1100,27 @@ "title": "解鎖 Cleaner", "description": "升級以最佳化您的檔案並釋放空間。" } + }, + "reachingUsageBanner": { + "lowWarning": { + "title": "為你的檔案取得更多空間", + "descriptionLabelLine1": "升級時享有專屬 **85% 折扣**,解鎖額外儲存空間", + "descriptionLabelLine2": "使用進階功能,例如檔案版本記錄、Rclone、NAS 支援、優先客服等" + }, + "midWarning": { + "title": "你的儲存空間即將用盡", + "descriptionLabelLine1": "今天就以專屬 **85% 折扣**升級,持續上傳不中斷", + "descriptionLabelLine2": "取得更多儲存空間,以及檔案版本記錄、NAS 支援、Rclone 整合和優先客服等進階功能", + "sidenavStorageText": "更多空間 85% 折扣" + }, + "highWarning": { + "title": "你的儲存空間幾乎已滿", + "descriptionLabelLine1": "你可能很快將無法上傳新檔案", + "descriptionLabelLine2": "立即以專屬 85% 折扣升級,不受限制地繼續儲存與同步你的檔案", + "sidenavStorageText": "購買空間 85% 折扣" + }, + "close": "關閉", + "cta": "取得優惠" } }, "form": { diff --git a/src/app/i18n/locales/zh.json b/src/app/i18n/locales/zh.json index 83eb262963..82b42228ab 100644 --- a/src/app/i18n/locales/zh.json +++ b/src/app/i18n/locales/zh.json @@ -1135,6 +1135,27 @@ "title": "解锁 Cleaner", "description": "升级以优化您的文件并释放存储空间。" } + }, + "reachingUsageBanner": { + "lowWarning": { + "title": "为你的文件获取更多空间", + "descriptionLabelLine1": "升级时享受专属 **85% 折扣**,解锁额外存储空间", + "descriptionLabelLine2": "使用高级功能,例如文件版本历史记录、Rclone、NAS 支持、优先客服等" + }, + "midWarning": { + "title": "你的存储空间即将用满", + "descriptionLabelLine1": "今天就以专属 **85% 折扣**升级,持续上传不中断", + "descriptionLabelLine2": "获取更多存储空间,以及文件版本历史记录、NAS 支持、Rclone 集成和优先客服等高级功能", + "sidenavStorageText": "更多空间 85% 折扣" + }, + "highWarning": { + "title": "你的存储空间几乎已满", + "descriptionLabelLine1": "你可能很快将无法上传新文件", + "descriptionLabelLine2": "立即以专属 85% 折扣升级,不受限制地继续存储和同步你的文件", + "sidenavStorageText": "购买空间 85% 折扣" + }, + "close": "关闭", + "cta": "获取优惠" } }, "form": { diff --git a/src/components/SidenavWrapper.tsx b/src/components/SidenavWrapper.tsx index 228702306e..2530ef4261 100644 --- a/src/components/SidenavWrapper.tsx +++ b/src/components/SidenavWrapper.tsx @@ -24,6 +24,35 @@ import { useReferralParamsChange } from 'views/Drive/hooks/useReferralParamsChan import WorkspaceSelectorContainer from 'views/Home/components/WorkspaceSelectorContainer'; import WorkspaceSelectorSkeleton from 'views/Home/components/WorkspaceSelectorSkeleton'; import ReferralBanner from './ReferralBanner'; +import { CloudWarning } from '@phosphor-icons/react'; + +type StorageWarning = 'lowWarning' | 'midWarning' | 'highWarning'; + +interface StorageStageConfig { + key: StorageWarning; + threshold: number; + barClassName: string; + containerClassName?: string; + advertisementKey?: string; +} + +const STORAGE_STAGES: StorageStageConfig[] = [ + { key: 'lowWarning', threshold: 60, barClassName: 'bg-yellow-60', containerClassName: 'pb-5' }, + { + key: 'midWarning', + threshold: 80, + barClassName: 'bg-orange-60', + containerClassName: 'pb-5', + advertisementKey: 'modals.reachingUsageBanner.midWarning.sidenavStorageText', + }, + { + key: 'highWarning', + threshold: 95, + barClassName: 'bg-danger', + containerClassName: 'pb rounded-lg bg-alert border border-alert-dark', + advertisementKey: 'modals.reachingUsageBanner.highWarning.sidenavStorageText', + }, +]; const SidenavPrimaryAction = ({ user, @@ -69,6 +98,12 @@ const SidenavWrapper = () => { const userUsage = planUsage > 0 ? bytesToString(planUsage) : '0GB'; const isReferralEligible = useAppSelector((state: RootState) => state.referrals.isEligible); + const isFreeUser = subscription?.type === 'free'; + const usedPercentage = planLimit > 0 ? (planUsage / planLimit) * 100 : 0; + const reachedStorageStage = isFreeUser + ? [...STORAGE_STAGES].reverse().find((stage) => usedPercentage >= stage.threshold) + : undefined; + useReferralParamsChange(); useEffect(() => { @@ -91,14 +126,19 @@ const SidenavWrapper = () => { }; const handleUpgradeClick = () => { - navigationService.openPreferencesDialog({ - section: 'account', - subsection: 'plans', - workspaceUuid: selectedWorkspace?.workspaceUser.workspaceId, - }); - dispatch(uiActions.setIsPreferencesDialogOpen(true)); + window.open('https://internxt.com/specialoffer', '_blank', 'noopener,noreferrer'); }; + const storageAdvertisement = reachedStorageStage?.advertisementKey ? ( + + +

{translate(reachedStorageStage.advertisementKey)}

+
+ ) : undefined; + const handleReferralClick = () => { if (user) { referralService.openPanel( @@ -141,6 +181,9 @@ const SidenavWrapper = () => { onUpgradeClick: handleUpgradeClick, upgradeLabel: isUpgradeAvailable() ? translate('preferences.account.plans.upgrade') : undefined, isLoading: isLoadingPlanUsage && isLoadingPlanLimit && isLoadingBusinessLimitAndUsage, + barClassName: reachedStorageStage?.barClassName ?? 'bg-primary', + containerClassName: reachedStorageStage?.containerClassName, + advertisement: storageAdvertisement, }} /> {isReferralEligible && ( diff --git a/src/index.scss b/src/index.scss index 1cba907bb3..fe61e743a4 100644 --- a/src/index.scss +++ b/src/index.scss @@ -28,8 +28,10 @@ --color-red: 255 13 0; --color-red-dark: 230 11 0; --color-orange: 255 149 0; + --color-orange-60: 229 134 0; --color-orange-dark: 230 134 0; --color-yellow: 255 204 0; + --color-yellow-60: 229 183 0; --color-yellow-dark: 230 184 0; --color-green: 50 195 86; --color-green-dark: 45 174 77; @@ -51,6 +53,9 @@ --color-gray-80: 58 58 59; --color-gray-90: 44 44 48; --color-gray-100: 24 24 27; + --color-alert: 255 249 229; + --color-alert-dark: 255 244 204; + --color-danger: 229 11 0; } :root.dark { @@ -61,8 +66,10 @@ --color-red: 255 61 51; --color-red-dark: 255 36 26; --color-orange: 255 164 36; + --color-orange-60: 229 147 32; --color-orange-dark: 255 153 10; --color-yellow: 255 214 51; + --color-yellow-60: 229 192 45; --color-yellow-dark: 255 209 26; --color-green: 72 208 106; --color-green-dark: 52 203 90; @@ -84,6 +91,9 @@ --color-gray-80: 229 229 235; --color-gray-90: 243 243 248; --color-gray-100: 249 249 252; + --color-alert: 76 64 15; + --color-alert-dark: 127 107 25; + --color-danger: 229 54 45; } } @@ -96,8 +106,10 @@ --color-red: 255 61 51; --color-red-dark: 255 36 26; --color-orange: 255 164 36; + --color-orange-60: 229 147 32; --color-orange-dark: 255 153 10; --color-yellow: 255 214 51; + --color-yellow-60: 229 192 45; --color-yellow-dark: 255 209 26; --color-green: 72 208 106; --color-green-dark: 52 203 90; @@ -119,6 +131,10 @@ --color-gray-80: 229 229 235; --color-gray-90: 243 243 248; --color-gray-100: 249 249 252; + --color-alert: 76 64 15; + --color-alert-dark: 127 107 25; + --color-danger: 229 54 45; + } :root:not(.dark) { @@ -129,8 +145,10 @@ --color-red: 255 13 0; --color-red-dark: 230 11 0; --color-orange: 255 149 0; + --color-orange-60: 229 134 0; --color-orange-dark: 230 134 0; --color-yellow: 255 204 0; + --color-yellow-60: 229 183 0; --color-yellow-dark: 230 184 0; --color-green: 50 195 86; --color-green-dark: 45 174 77; @@ -152,6 +170,10 @@ --color-gray-80: 58 58 59; --color-gray-90: 44 44 48; --color-gray-100: 24 24 27; + --color-alert: 255 249 229; + --color-alert-dark: 255 244 204; + --color-danger: 229 11 0; + } } diff --git a/src/views/Backups/BackupsView.tsx b/src/views/Backups/BackupsView.tsx index e81b0d360e..fcc8f67575 100644 --- a/src/views/Backups/BackupsView.tsx +++ b/src/views/Backups/BackupsView.tsx @@ -263,7 +263,9 @@ export default function BackupsView(): JSX.Element {

{translate('backups.your-devices')}

)} - +
+ +
{body} ); diff --git a/src/views/Drive/components/DriveExplorer/DriveExplorer.tsx b/src/views/Drive/components/DriveExplorer/DriveExplorer.tsx index 736c5822b7..e111e4937c 100644 --- a/src/views/Drive/components/DriveExplorer/DriveExplorer.tsx +++ b/src/views/Drive/components/DriveExplorer/DriveExplorer.tsx @@ -645,8 +645,9 @@ const DriveExplorer = (props: DriveExplorerProps): JSX.Element => { )}
- - +
+ +
void }): JSX.Element => { - return ( -
- - - {t('error.storageIsFull')}.  - {t('error.storageIsFullDescription')} - - -
- ); -}; diff --git a/src/views/Home/components/WarningMessageWrapper.tsx b/src/views/Home/components/WarningMessageWrapper.tsx index 515c584b2b..7643423feb 100644 --- a/src/views/Home/components/WarningMessageWrapper.tsx +++ b/src/views/Home/components/WarningMessageWrapper.tsx @@ -1,17 +1,86 @@ -import { connect, useDispatch } from 'react-redux'; -import navigationService from 'services/navigation.service'; +import { Fragment, useState } from 'react'; +import { connect } from 'react-redux'; import { RootState } from 'app/store'; -import { useAppSelector } from 'app/store/hooks'; import { planSelectors } from 'app/store/slices/plan'; -import { uiActions } from 'app/store/slices/ui'; -import workspacesSelectors from 'app/store/slices/workspaces/workspaces.selectors'; -import { WarningMessage } from './WarningMessage'; +import { UsageWarningBanner } from '@internxt/ui'; +import { bytesToString } from 'app/drive/services/size.service'; +import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; +import { useTaskManagerGetNotifications } from 'app/tasks/hooks'; +import { TaskStatus, TaskType } from 'app/tasks/types'; + +type UsageWarning = 'lowWarning' | 'midWarning' | 'highWarning'; + +interface StageConfig { + key: UsageWarning; + threshold: number; + cooldownDays: number; + barClassName: string; +} + +const STAGES: StageConfig[] = [ + { key: 'lowWarning', threshold: 60, cooldownDays: 7, barClassName: 'bg-yellow-60' }, + { key: 'midWarning', threshold: 80, cooldownDays: 3, barClassName: 'bg-orange-60' }, + { key: 'highWarning', threshold: 95, cooldownDays: 3, barClassName: 'bg-danger' }, +]; + +const DAY_IN_MS = 24 * 60 * 60 * 1000; +const DISMISSALS_STORAGE_KEY = 'storageWarningBannerDismissals'; +const UPLOAD_IN_PROGRESS_STATUSES = [ + TaskStatus.Pending, + TaskStatus.Encrypting, + TaskStatus.InProcess, + TaskStatus.Paused, +]; + +type Dismissals = Partial>; + +const readDismissals = (): Dismissals => { + try { + const raw = localStorage.getItem(DISMISSALS_STORAGE_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw); + return typeof parsed === 'object' && parsed !== null ? parsed : {}; + } catch { + return {}; + } +}; + +const writeDismissal = (stageKey: UsageWarning, dismissedAt: number): void => { + try { + const dismissals = readDismissals(); + dismissals[stageKey] = dismissedAt; + localStorage.setItem(DISMISSALS_STORAGE_KEY, JSON.stringify(dismissals)); + } catch { + // + } +}; + +const isStageInCooldown = (stage: StageConfig, dismissals: Dismissals, now: number): boolean => { + const dismissedAt = dismissals[stage.key]; + if (!dismissedAt) return false; + return now < dismissedAt + stage.cooldownDays * DAY_IN_MS; +}; + +const renderDescriptionLine = (line: string): JSX.Element => ( + <> + {line + .split('**') + .map((segment, index) => + index % 2 === 1 ? ( + {segment} + ) : ( + {segment} + ), + )} + +); type WarningMessageWrapperProps = { planLimit: number; planUsage: number; isLoadingPlanLimit: boolean; isLoadingPlanUsage: boolean; + isFreeUser: boolean; }; const WarningMessageWrapper = ({ @@ -19,31 +88,69 @@ const WarningMessageWrapper = ({ planUsage, isLoadingPlanLimit, isLoadingPlanUsage, + isFreeUser, }: WarningMessageWrapperProps): JSX.Element => { - const dispatch = useDispatch(); - const selectedWorkspace = useAppSelector(workspacesSelectors.getSelectedWorkspace); + const { translate } = useTranslationContext(); + + const [sessionDismissedStage, setSessionDismissedStage] = useState(null); + + const uploadNotifications = useTaskManagerGetNotifications({ status: UPLOAD_IN_PROGRESS_STATUSES }); + const isUploading = uploadNotifications.some( + (notification) => notification.action === TaskType.UploadFile || notification.action === TaskType.UploadFolder, + ); const onUpgradeButtonClicked = () => { - navigationService.openPreferencesDialog({ - section: 'account', - subsection: 'plans', - workspaceUuid: selectedWorkspace?.workspaceUser.workspaceId, - }); - dispatch(uiActions.setIsPreferencesDialogOpen(true)); + window.open('https://internxt.com/specialoffer', '_blank', 'noopener,noreferrer'); }; - const isLimitReached = planUsage >= planLimit; const isLoading = isLoadingPlanLimit || isLoadingPlanUsage; const plansNotFetched = planUsage === 0 && planLimit === 0; - const areNotNumbers = - (planUsage !== 0 && !planUsage) || - (planLimit !== 0 && !planLimit) || - Number.isNaN(planUsage) || - Number.isNaN(planLimit); + const areNotValidNumbers = + !Number.isFinite(planUsage) || !Number.isFinite(planLimit) || planLimit <= 0 || planUsage < 0; + + if (!isFreeUser || isLoading || plansNotFetched || areNotValidNumbers || isUploading) return <>; + + const usedPercentage = (planUsage / planLimit) * 100; + + const reachedStage = [...STAGES].reverse().find((stage) => usedPercentage >= stage.threshold); + if (!reachedStage) return <>; + + const now = Date.now(); + const dismissals = readDismissals(); + + const isHiddenThisSession = sessionDismissedStage === reachedStage.key; + if (isHiddenThisSession || isStageInCooldown(reachedStage, dismissals, now)) return <>; + + const onCloseButtonClick = () => { + writeDismissal(reachedStage.key, Date.now()); + setSessionDismissedStage(reachedStage.key); + }; + + const baseKey = `modals.reachingUsageBanner.${reachedStage.key}`; + const description = ( +
+ {renderDescriptionLine(translate(`${baseKey}.descriptionLabelLine1`))} + {renderDescriptionLine(translate(`${baseKey}.descriptionLabelLine2`))} +
+ ); - if (plansNotFetched || areNotNumbers || !isLimitReached || isLoading) return <>; + const barPercentage = Math.min(Math.ceil(usedPercentage), 100); - return ; + return ( + + ); }; export default connect((state: RootState) => ({ @@ -51,4 +158,5 @@ export default connect((state: RootState) => ({ planLimit: planSelectors.planLimitToShow(state), isLoadingPlanLimit: state.plan.isLoadingPlanLimit, isLoadingPlanUsage: state.plan.isLoadingPlanUsage, + isFreeUser: planSelectors.subscriptionToShow(state)?.type === 'free', }))(WarningMessageWrapper); diff --git a/src/views/Shared/SharedView.tsx b/src/views/Shared/SharedView.tsx index 0d0bf32920..dc4ec972f1 100644 --- a/src/views/Shared/SharedView.tsx +++ b/src/views/Shared/SharedView.tsx @@ -535,7 +535,9 @@ function SharedView({ selectedWorkspace={selectedWorkspace} />
- +
+ +
{ /* DRAG AND DROP */ diff --git a/tailwind.config.ts b/tailwind.config.ts index 53da1eee0e..4c53907501 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -3,4 +3,12 @@ import config from '@internxt/css-config'; export default { ...config, content: ['./node_modules/@internxt/ui/dist/**/*.{js,ts,jsx,tsx}', './src/**/*.{js,ts,jsx,tsx}'], + theme: { + ...config.theme, + colors: { + ...config.theme.colors, + 'orange-60': 'rgb(var(--color-orange-60) / )', + danger: 'rgb(var(--color-danger) / )', + }, + }, }; diff --git a/yarn.lock b/yarn.lock index 8c60138186..b6e720c2df 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1879,10 +1879,10 @@ dependencies: axios "^1.16.0" -"@internxt/ui@=0.1.21": - version "0.1.21" - resolved "https://registry.yarnpkg.com/@internxt/ui/-/ui-0.1.21.tgz#169793db123abc19e98d1039cb4d91632e9cfc6d" - integrity sha512-MHpAe4J3X0qVPoV12pR4HhsbgyCe4bBLLHHC2PwMicC5WKsBfg2rOD2dbzoHTmeZt0ddWWGT7f8dwb4/qfO7Sg== +"@internxt/ui@=0.1.22": + version "0.1.22" + resolved "https://registry.yarnpkg.com/@internxt/ui/-/ui-0.1.22.tgz#c4ee8969a333e16f3a5899327b71036d6a400b48" + integrity sha512-e5tvOBjTEgZc5/hDBfmhSm5dU4XA7NxOoUOkxeU8KjkBVHYnb0F30CHIY1zHTS1XugxfaUVrzrSZr94stE/YfQ== dependencies: "@internxt/css-config" "1.1.0" "@phosphor-icons/react" "^2.1.10" From 58538a8b51b2410fa7bb756a22e73609e2be27fd Mon Sep 17 00:00:00 2001 From: jaaaaavier Date: Mon, 22 Jun 2026 08:48:11 +0200 Subject: [PATCH 2/7] remove unused import --- src/components/SidenavWrapper.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/SidenavWrapper.tsx b/src/components/SidenavWrapper.tsx index 2530ef4261..06cde9330c 100644 --- a/src/components/SidenavWrapper.tsx +++ b/src/components/SidenavWrapper.tsx @@ -10,7 +10,6 @@ import { RootState } from 'app/store'; import { useAppDispatch, useAppSelector } from 'app/store/hooks'; import { planSelectors } from 'app/store/slices/plan'; import { sharedThunks } from 'app/store/slices/sharedLinks'; -import { uiActions } from 'app/store/slices/ui'; import logo from 'assets/icons/small-logo.svg'; import { useSidenavCollapsed } from 'hooks/useSidenavCollapsed'; import { useSidenavNavigation } from 'hooks/useSidenavNavigation'; From 31245c10c579f5acae5e6ac8b0c5ac226e782e7f Mon Sep 17 00:00:00 2001 From: jaaaaavier Date: Mon, 22 Jun 2026 09:35:17 +0200 Subject: [PATCH 3/7] remove tailwind config --- src/components/SidenavWrapper.tsx | 2 +- tailwind.config.ts | 8 -------- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/src/components/SidenavWrapper.tsx b/src/components/SidenavWrapper.tsx index 06cde9330c..bdd3416fe0 100644 --- a/src/components/SidenavWrapper.tsx +++ b/src/components/SidenavWrapper.tsx @@ -180,7 +180,7 @@ const SidenavWrapper = () => { onUpgradeClick: handleUpgradeClick, upgradeLabel: isUpgradeAvailable() ? translate('preferences.account.plans.upgrade') : undefined, isLoading: isLoadingPlanUsage && isLoadingPlanLimit && isLoadingBusinessLimitAndUsage, - barClassName: reachedStorageStage?.barClassName ?? 'bg-primary', + barClassName: reachedStorageStage?.barClassName, containerClassName: reachedStorageStage?.containerClassName, advertisement: storageAdvertisement, }} diff --git a/tailwind.config.ts b/tailwind.config.ts index 4c53907501..53da1eee0e 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -3,12 +3,4 @@ import config from '@internxt/css-config'; export default { ...config, content: ['./node_modules/@internxt/ui/dist/**/*.{js,ts,jsx,tsx}', './src/**/*.{js,ts,jsx,tsx}'], - theme: { - ...config.theme, - colors: { - ...config.theme.colors, - 'orange-60': 'rgb(var(--color-orange-60) / )', - danger: 'rgb(var(--color-danger) / )', - }, - }, }; From 33986d9d280308b5bc1e03d27301cba215536de2 Mon Sep 17 00:00:00 2001 From: jaaaaavier Date: Mon, 22 Jun 2026 12:46:41 +0200 Subject: [PATCH 4/7] feat: update css library version --- package.json | 2 +- yarn.lock | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 510f0ad428..81830a21fb 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "@dashlane/pqc-kem-kyber512-browser": "^1.0.0", "@headlessui/react": "1.7.5", "@iconscout/react-unicons": "^1.1.6", - "@internxt/css-config": "1.1.0", + "@internxt/css-config": "1.1.1", "@internxt/lib": "1.4.1", "@internxt/ui": "=0.1.22", "@internxt/sdk": "=1.17.9", diff --git a/yarn.lock b/yarn.lock index 92e89fa0c7..c319a79d56 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1852,6 +1852,13 @@ dependencies: tailwindcss "^4.1.17" +"@internxt/css-config@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@internxt/css-config/-/css-config-1.1.1.tgz#0f6a41b9ba2d182b006155c5930cc59c455b0f51" + integrity sha512-RP7YZdvQZxzxGm1FZxbLw3PA/ojueHXJvzagUTTIdV4ev23OAcF80FAQZx4aTfX1mnMfQeFr0dOutqaPrBReqA== + dependencies: + tailwindcss "^4.1.17" + "@internxt/eslint-config-internxt@1.0.10": version "1.0.10" resolved "https://registry.yarnpkg.com/@internxt/eslint-config-internxt/-/eslint-config-internxt-1.0.10.tgz#19025c479f84617a59484660f2f9c983ff3bc4ec" From a4ab95698b72c45ee20d9f998a6872e182f50993 Mon Sep 17 00:00:00 2001 From: jaaaaavier Date: Thu, 25 Jun 2026 15:21:53 +0200 Subject: [PATCH 5/7] refactor: starct storage warning banner logic into custom hook --- src/app/store/slices/plan/storageWarning.ts | 85 +++++++++++++ src/components/SidenavWrapper.tsx | 43 ++----- .../Home/components/WarningMessageWrapper.tsx | 113 ++---------------- .../Home/hooks/useStorageWarningBanner.ts | 70 +++++++++++ 4 files changed, 176 insertions(+), 135 deletions(-) create mode 100644 src/app/store/slices/plan/storageWarning.ts create mode 100644 src/views/Home/hooks/useStorageWarningBanner.ts diff --git a/src/app/store/slices/plan/storageWarning.ts b/src/app/store/slices/plan/storageWarning.ts new file mode 100644 index 0000000000..1322989fae --- /dev/null +++ b/src/app/store/slices/plan/storageWarning.ts @@ -0,0 +1,85 @@ +export type StorageWarning = 'lowWarning' | 'midWarning' | 'highWarning'; + +export interface StorageWarningStage { + key: StorageWarning; + threshold: number; + cooldownDays: number; + barClassName: string; + containerClassName?: string; + advertisementKey?: string; +} + +export const REACHING_USAGE_BANNER_TRANSLATION_KEY = 'modals.reachingUsageBanner'; +export const SPECIAL_OFFER_URL = 'https://internxt.com/specialoffer'; + +export const STORAGE_WARNING_STAGES: StorageWarningStage[] = [ + { + key: 'lowWarning', + threshold: 60, + cooldownDays: 7, + barClassName: 'bg-yellow-60', + containerClassName: 'pb-5', + }, + { + key: 'midWarning', + threshold: 80, + cooldownDays: 3, + barClassName: 'bg-orange-60', + containerClassName: 'pb-5', + advertisementKey: `${REACHING_USAGE_BANNER_TRANSLATION_KEY}.midWarning.sidenavStorageText`, + }, + { + key: 'highWarning', + threshold: 95, + cooldownDays: 3, + barClassName: 'bg-danger', + containerClassName: 'pb-5 rounded-lg bg-alert border border-alert-dark', + advertisementKey: `${REACHING_USAGE_BANNER_TRANSLATION_KEY}.highWarning.sidenavStorageText`, + }, +]; + +export const calculateUsedPercentage = (planUsage: number, planLimit: number): number => + planLimit > 0 ? (planUsage / planLimit) * 100 : 0; + +export const getReachedStorageWarningStage = (usedPercentage: number): StorageWarningStage | undefined => + [...STORAGE_WARNING_STAGES].reverse().find((stage) => usedPercentage >= stage.threshold); + +export const openUpgradeSpecialOffer = (): void => { + window.open(SPECIAL_OFFER_URL, '_blank', 'noopener,noreferrer'); +}; + +const DAY_IN_MS = 24 * 60 * 60 * 1000; +const DISMISSALS_STORAGE_KEY = 'storageWarningBannerDismissals'; + +export type StorageWarningDismissals = Partial>; + +export const readDismissals = (): StorageWarningDismissals => { + try { + const raw = localStorage.getItem(DISMISSALS_STORAGE_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw); + return typeof parsed === 'object' && parsed !== null ? parsed : {}; + } catch { + return {}; + } +}; + +export const writeDismissal = (stageKey: StorageWarning, dismissedAt: number): void => { + try { + const dismissals = readDismissals(); + dismissals[stageKey] = dismissedAt; + localStorage.setItem(DISMISSALS_STORAGE_KEY, JSON.stringify(dismissals)); + } catch { + // + } +}; + +export const isStageInCooldown = ( + stage: StorageWarningStage, + dismissals: StorageWarningDismissals, + now: number, +): boolean => { + const dismissedAt = dismissals[stage.key]; + if (!dismissedAt) return false; + return now < dismissedAt + stage.cooldownDays * DAY_IN_MS; +}; diff --git a/src/components/SidenavWrapper.tsx b/src/components/SidenavWrapper.tsx index bdd3416fe0..6ea0233c83 100644 --- a/src/components/SidenavWrapper.tsx +++ b/src/components/SidenavWrapper.tsx @@ -24,34 +24,11 @@ import WorkspaceSelectorContainer from 'views/Home/components/WorkspaceSelectorC import WorkspaceSelectorSkeleton from 'views/Home/components/WorkspaceSelectorSkeleton'; import ReferralBanner from './ReferralBanner'; import { CloudWarning } from '@phosphor-icons/react'; - -type StorageWarning = 'lowWarning' | 'midWarning' | 'highWarning'; - -interface StorageStageConfig { - key: StorageWarning; - threshold: number; - barClassName: string; - containerClassName?: string; - advertisementKey?: string; -} - -const STORAGE_STAGES: StorageStageConfig[] = [ - { key: 'lowWarning', threshold: 60, barClassName: 'bg-yellow-60', containerClassName: 'pb-5' }, - { - key: 'midWarning', - threshold: 80, - barClassName: 'bg-orange-60', - containerClassName: 'pb-5', - advertisementKey: 'modals.reachingUsageBanner.midWarning.sidenavStorageText', - }, - { - key: 'highWarning', - threshold: 95, - barClassName: 'bg-danger', - containerClassName: 'pb rounded-lg bg-alert border border-alert-dark', - advertisementKey: 'modals.reachingUsageBanner.highWarning.sidenavStorageText', - }, -]; +import { + calculateUsedPercentage, + getReachedStorageWarningStage, + openUpgradeSpecialOffer, +} from 'app/store/slices/plan/storageWarning'; const SidenavPrimaryAction = ({ user, @@ -98,10 +75,8 @@ const SidenavWrapper = () => { const isReferralEligible = useAppSelector((state: RootState) => state.referrals.isEligible); const isFreeUser = subscription?.type === 'free'; - const usedPercentage = planLimit > 0 ? (planUsage / planLimit) * 100 : 0; - const reachedStorageStage = isFreeUser - ? [...STORAGE_STAGES].reverse().find((stage) => usedPercentage >= stage.threshold) - : undefined; + const usedPercentage = calculateUsedPercentage(planUsage, planLimit); + const reachedStorageStage = isFreeUser ? getReachedStorageWarningStage(usedPercentage) : undefined; useReferralParamsChange(); @@ -124,9 +99,7 @@ const SidenavWrapper = () => { return subscription?.type === 'free' || isLifetimeAvailable; }; - const handleUpgradeClick = () => { - window.open('https://internxt.com/specialoffer', '_blank', 'noopener,noreferrer'); - }; + const handleUpgradeClick = openUpgradeSpecialOffer; const storageAdvertisement = reachedStorageStage?.advertisementKey ? ( diff --git a/src/views/Home/components/WarningMessageWrapper.tsx b/src/views/Home/components/WarningMessageWrapper.tsx index 7643423feb..7c9b8eafa5 100644 --- a/src/views/Home/components/WarningMessageWrapper.tsx +++ b/src/views/Home/components/WarningMessageWrapper.tsx @@ -1,65 +1,12 @@ -import { Fragment, useState } from 'react'; +import { Fragment } from 'react'; import { connect } from 'react-redux'; import { RootState } from 'app/store'; import { planSelectors } from 'app/store/slices/plan'; import { UsageWarningBanner } from '@internxt/ui'; import { bytesToString } from 'app/drive/services/size.service'; import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; -import { useTaskManagerGetNotifications } from 'app/tasks/hooks'; -import { TaskStatus, TaskType } from 'app/tasks/types'; - -type UsageWarning = 'lowWarning' | 'midWarning' | 'highWarning'; - -interface StageConfig { - key: UsageWarning; - threshold: number; - cooldownDays: number; - barClassName: string; -} - -const STAGES: StageConfig[] = [ - { key: 'lowWarning', threshold: 60, cooldownDays: 7, barClassName: 'bg-yellow-60' }, - { key: 'midWarning', threshold: 80, cooldownDays: 3, barClassName: 'bg-orange-60' }, - { key: 'highWarning', threshold: 95, cooldownDays: 3, barClassName: 'bg-danger' }, -]; - -const DAY_IN_MS = 24 * 60 * 60 * 1000; -const DISMISSALS_STORAGE_KEY = 'storageWarningBannerDismissals'; -const UPLOAD_IN_PROGRESS_STATUSES = [ - TaskStatus.Pending, - TaskStatus.Encrypting, - TaskStatus.InProcess, - TaskStatus.Paused, -]; - -type Dismissals = Partial>; - -const readDismissals = (): Dismissals => { - try { - const raw = localStorage.getItem(DISMISSALS_STORAGE_KEY); - if (!raw) return {}; - const parsed = JSON.parse(raw); - return typeof parsed === 'object' && parsed !== null ? parsed : {}; - } catch { - return {}; - } -}; - -const writeDismissal = (stageKey: UsageWarning, dismissedAt: number): void => { - try { - const dismissals = readDismissals(); - dismissals[stageKey] = dismissedAt; - localStorage.setItem(DISMISSALS_STORAGE_KEY, JSON.stringify(dismissals)); - } catch { - // - } -}; - -const isStageInCooldown = (stage: StageConfig, dismissals: Dismissals, now: number): boolean => { - const dismissedAt = dismissals[stage.key]; - if (!dismissedAt) return false; - return now < dismissedAt + stage.cooldownDays * DAY_IN_MS; -}; +import { openUpgradeSpecialOffer, REACHING_USAGE_BANNER_TRANSLATION_KEY } from 'app/store/slices/plan/storageWarning'; +import { useStorageWarningBanner } from 'views/Home/hooks/useStorageWarningBanner'; const renderDescriptionLine = (line: string): JSX.Element => ( <> @@ -83,50 +30,16 @@ type WarningMessageWrapperProps = { isFreeUser: boolean; }; -const WarningMessageWrapper = ({ - planLimit, - planUsage, - isLoadingPlanLimit, - isLoadingPlanUsage, - isFreeUser, -}: WarningMessageWrapperProps): JSX.Element => { +const WarningMessageWrapper = (props: WarningMessageWrapperProps): JSX.Element => { const { translate } = useTranslationContext(); + const banner = useStorageWarningBanner(props); - const [sessionDismissedStage, setSessionDismissedStage] = useState(null); - - const uploadNotifications = useTaskManagerGetNotifications({ status: UPLOAD_IN_PROGRESS_STATUSES }); - const isUploading = uploadNotifications.some( - (notification) => notification.action === TaskType.UploadFile || notification.action === TaskType.UploadFolder, - ); - - const onUpgradeButtonClicked = () => { - window.open('https://internxt.com/specialoffer', '_blank', 'noopener,noreferrer'); - }; - - const isLoading = isLoadingPlanLimit || isLoadingPlanUsage; - const plansNotFetched = planUsage === 0 && planLimit === 0; - const areNotValidNumbers = - !Number.isFinite(planUsage) || !Number.isFinite(planLimit) || planLimit <= 0 || planUsage < 0; - - if (!isFreeUser || isLoading || plansNotFetched || areNotValidNumbers || isUploading) return <>; - - const usedPercentage = (planUsage / planLimit) * 100; - - const reachedStage = [...STAGES].reverse().find((stage) => usedPercentage >= stage.threshold); - if (!reachedStage) return <>; - - const now = Date.now(); - const dismissals = readDismissals(); - - const isHiddenThisSession = sessionDismissedStage === reachedStage.key; - if (isHiddenThisSession || isStageInCooldown(reachedStage, dismissals, now)) return <>; + if (!banner) return <>; - const onCloseButtonClick = () => { - writeDismissal(reachedStage.key, Date.now()); - setSessionDismissedStage(reachedStage.key); - }; + const { reachedStage, usedPercentage, onCloseButtonClick } = banner; + const { planUsage, planLimit } = props; - const baseKey = `modals.reachingUsageBanner.${reachedStage.key}`; + const baseKey = `${REACHING_USAGE_BANNER_TRANSLATION_KEY}.${reachedStage.key}`; const description = (
{renderDescriptionLine(translate(`${baseKey}.descriptionLabelLine1`))} @@ -143,12 +56,12 @@ const WarningMessageWrapper = ({ usage={bytesToString(planUsage)} limit={bytesToString(planLimit)} percentage={barPercentage} - upgradeLabel={translate('modals.reachingUsageBanner.cta')} - closeButtonLabel={translate('modals.reachingUsageBanner.close')} - onUpgradeClick={onUpgradeButtonClicked} + upgradeLabel={translate(`${REACHING_USAGE_BANNER_TRANSLATION_KEY}.cta`)} + closeButtonLabel={translate(`${REACHING_USAGE_BANNER_TRANSLATION_KEY}.close`)} + onUpgradeClick={openUpgradeSpecialOffer} onCloseButtonClick={onCloseButtonClick} barClassName={reachedStage.barClassName} - isLoading={isLoading} + isLoading={false} /> ); }; diff --git a/src/views/Home/hooks/useStorageWarningBanner.ts b/src/views/Home/hooks/useStorageWarningBanner.ts new file mode 100644 index 0000000000..81e30fa6b0 --- /dev/null +++ b/src/views/Home/hooks/useStorageWarningBanner.ts @@ -0,0 +1,70 @@ +import { useState } from 'react'; +import { useTaskManagerGetNotifications } from 'app/tasks/hooks'; +import { TaskStatus, TaskType } from 'app/tasks/types'; +import { + calculateUsedPercentage, + getReachedStorageWarningStage, + isStageInCooldown, + readDismissals, + writeDismissal, + StorageWarning, + StorageWarningStage, +} from 'app/store/slices/plan/storageWarning'; + +const UPLOAD_IN_PROGRESS_STATUSES = [ + TaskStatus.Pending, + TaskStatus.Encrypting, + TaskStatus.InProcess, + TaskStatus.Paused, +]; + +interface UseStorageWarningBannerParams { + planLimit: number; + planUsage: number; + isLoadingPlanLimit: boolean; + isLoadingPlanUsage: boolean; + isFreeUser: boolean; +} + +interface StorageWarningBanner { + reachedStage: StorageWarningStage; + usedPercentage: number; + onCloseButtonClick: () => void; +} + +export const useStorageWarningBanner = ({ + planLimit, + planUsage, + isLoadingPlanLimit, + isLoadingPlanUsage, + isFreeUser, +}: UseStorageWarningBannerParams): StorageWarningBanner | null => { + const [sessionDismissedStage, setSessionDismissedStage] = useState(null); + + const uploadNotifications = useTaskManagerGetNotifications({ status: UPLOAD_IN_PROGRESS_STATUSES }); + const isUploading = uploadNotifications.some( + (notification) => notification.action === TaskType.UploadFile || notification.action === TaskType.UploadFolder, + ); + + const isLoading = isLoadingPlanLimit || isLoadingPlanUsage; + const plansNotFetched = planUsage === 0 && planLimit === 0; + const areNotValidNumbers = + !Number.isFinite(planUsage) || !Number.isFinite(planLimit) || planLimit <= 0 || planUsage < 0; + + if (!isFreeUser || isLoading || plansNotFetched || areNotValidNumbers || isUploading) return null; + + const usedPercentage = calculateUsedPercentage(planUsage, planLimit); + + const reachedStage = getReachedStorageWarningStage(usedPercentage); + if (!reachedStage) return null; + + const isHiddenThisSession = sessionDismissedStage === reachedStage.key; + if (isHiddenThisSession || isStageInCooldown(reachedStage, readDismissals(), Date.now())) return null; + + const onCloseButtonClick = () => { + writeDismissal(reachedStage.key, Date.now()); + setSessionDismissedStage(reachedStage.key); + }; + + return { reachedStage, usedPercentage, onCloseButtonClick }; +}; From 7809063d9d7bd7c5c3ba4b90dd7bd06645f674dc Mon Sep 17 00:00:00 2001 From: jaaaaavier Date: Thu, 25 Jun 2026 15:38:09 +0200 Subject: [PATCH 6/7] feat: add coverage --- .../store/slices/plan/storageWarning.test.ts | 139 ++++++++++++++++++ .../hooks/useStorageWarningBanner.test.ts | 132 +++++++++++++++++ 2 files changed, 271 insertions(+) create mode 100644 src/app/store/slices/plan/storageWarning.test.ts create mode 100644 src/views/Home/hooks/useStorageWarningBanner.test.ts diff --git a/src/app/store/slices/plan/storageWarning.test.ts b/src/app/store/slices/plan/storageWarning.test.ts new file mode 100644 index 0000000000..9979a1d82b --- /dev/null +++ b/src/app/store/slices/plan/storageWarning.test.ts @@ -0,0 +1,139 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { + calculateUsedPercentage, + getReachedStorageWarningStage, + isStageInCooldown, + openUpgradeSpecialOffer, + readDismissals, + writeDismissal, + SPECIAL_OFFER_URL, + STORAGE_WARNING_STAGES, + StorageWarningStage, +} from './storageWarning'; + +const DAY_IN_MS = 24 * 60 * 60 * 1000; +const DISMISSALS_STORAGE_KEY = 'storageWarningBannerDismissals'; + +const stageByKey = (key: StorageWarningStage['key']): StorageWarningStage => + STORAGE_WARNING_STAGES.find((stage) => stage.key === key) as StorageWarningStage; + +describe('calculateUsedPercentage', () => { + test('When usage is part of the available limit, then it returns the percentage used', () => { + expect(calculateUsedPercentage(50, 200)).toBe(25); + }); + + test('When the limit is zero, then it returns zero to avoid dividing by zero', () => { + expect(calculateUsedPercentage(50, 0)).toBe(0); + }); +}); + +describe('getReachedStorageWarningStage', () => { + test('When usage is below every threshold, then no stage is reached', () => { + expect(getReachedStorageWarningStage(50)).toBeUndefined(); + }); + + test('When usage crosses the lowest threshold, then the low warning stage is reached', () => { + expect(getReachedStorageWarningStage(65)?.key).toBe('lowWarning'); + }); + + test('When usage crosses the middle threshold, then the mid warning stage is reached', () => { + expect(getReachedStorageWarningStage(85)?.key).toBe('midWarning'); + }); + + test('When usage is almost at the limit, then the most severe stage is reached', () => { + expect(getReachedStorageWarningStage(96)?.key).toBe('highWarning'); + }); + + test('When usage matches a threshold exactly, then that stage is reached', () => { + expect(getReachedStorageWarningStage(60)?.key).toBe('lowWarning'); + }); +}); + +describe('openUpgradeSpecialOffer', () => { + test('When the upgrade offer is opened, then the special offer page is opened in a safe new tab', () => { + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + + openUpgradeSpecialOffer(); + + expect(openSpy).toHaveBeenCalledWith(SPECIAL_OFFER_URL, '_blank', 'noopener,noreferrer'); + openSpy.mockRestore(); + }); +}); + +describe('storage warning dismissals persistence', () => { + beforeEach(() => { + localStorage.clear(); + vi.restoreAllMocks(); + }); + + afterEach(() => { + localStorage.clear(); + vi.restoreAllMocks(); + }); + + test('When no dismissal has been stored yet, then reading dismissals returns an empty record', () => { + expect(readDismissals()).toEqual({}); + }); + + test('When a stage is dismissed, then its dismissal time can be read back', () => { + writeDismissal('lowWarning', 1000); + + expect(readDismissals()).toEqual({ lowWarning: 1000 }); + }); + + test('When several stages are dismissed, then each dismissal time is kept', () => { + writeDismissal('lowWarning', 1000); + writeDismissal('highWarning', 2000); + + expect(readDismissals()).toEqual({ lowWarning: 1000, highWarning: 2000 }); + }); + + test('When the same stage is dismissed again, then its dismissal time is overwritten', () => { + writeDismissal('lowWarning', 1000); + writeDismissal('lowWarning', 5000); + + expect(readDismissals()).toEqual({ lowWarning: 5000 }); + }); + + test('When the stored dismissals are corrupted, then reading them falls back to an empty record', () => { + localStorage.setItem(DISMISSALS_STORAGE_KEY, 'not-valid-json'); + + expect(readDismissals()).toEqual({}); + }); + + test('When the stored dismissals are not an object, then reading them falls back to an empty record', () => { + localStorage.setItem(DISMISSALS_STORAGE_KEY, JSON.stringify('a string')); + + expect(readDismissals()).toEqual({}); + }); + + test('When persisting a dismissal fails, then the error is swallowed without throwing', () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('storage full'); + }); + + expect(() => writeDismissal('lowWarning', 1000)).not.toThrow(); + }); +}); + +describe('isStageInCooldown', () => { + const now = 10 * DAY_IN_MS; + + test('When a stage has never been dismissed, then it is not in cooldown', () => { + expect(isStageInCooldown(stageByKey('lowWarning'), {}, now)).toBe(false); + }); + + test('When a stage was dismissed within its cooldown window, then it is still in cooldown', () => { + const lowWarning = stageByKey('lowWarning'); + const dismissedAt = now - 2 * DAY_IN_MS; + + expect(isStageInCooldown(lowWarning, { lowWarning: dismissedAt }, now)).toBe(true); + }); + + test('When the cooldown window of a dismissed stage has elapsed, then it is no longer in cooldown', () => { + const lowWarning = stageByKey('lowWarning'); + const dismissedAt = now - (lowWarning.cooldownDays + 1) * DAY_IN_MS; + + expect(isStageInCooldown(lowWarning, { lowWarning: dismissedAt }, now)).toBe(false); + }); +}); diff --git a/src/views/Home/hooks/useStorageWarningBanner.test.ts b/src/views/Home/hooks/useStorageWarningBanner.test.ts new file mode 100644 index 0000000000..7df08fbda1 --- /dev/null +++ b/src/views/Home/hooks/useStorageWarningBanner.test.ts @@ -0,0 +1,132 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('app/tasks/hooks', () => ({ + useTaskManagerGetNotifications: vi.fn(), +})); + +import { useStorageWarningBanner } from './useStorageWarningBanner'; +import { useTaskManagerGetNotifications } from 'app/tasks/hooks'; +import { TaskType } from 'app/tasks/types'; +import { writeDismissal } from 'app/store/slices/plan/storageWarning'; + +const mockUseTaskManagerGetNotifications = useTaskManagerGetNotifications as ReturnType; + +const DAY_IN_MS = 24 * 60 * 60 * 1000; + +type BannerParams = Parameters[0]; + +const buildParams = (overrides: Partial = {}): BannerParams => ({ + planLimit: 100, + planUsage: 65, + isLoadingPlanLimit: false, + isLoadingPlanUsage: false, + isFreeUser: true, + ...overrides, +}); + +const setActiveNotifications = (notifications: { action: TaskType }[]) => { + mockUseTaskManagerGetNotifications.mockReturnValue(notifications); +}; + +describe('useStorageWarningBanner', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + setActiveNotifications([]); + }); + + test('When a free user keeps usage below the first warning threshold, then no banner is shown', () => { + const { result } = renderHook(() => useStorageWarningBanner(buildParams({ planUsage: 50 }))); + + expect(result.current).toBeNull(); + }); + + test('When a free user crosses the low usage threshold, then the low warning banner is shown', () => { + const { result } = renderHook(() => useStorageWarningBanner(buildParams({ planUsage: 65 }))); + + expect(result.current?.reachedStage.key).toBe('lowWarning'); + expect(result.current?.usedPercentage).toBe(65); + }); + + test('When a free user reaches a high usage level, then the mid warning banner is shown', () => { + const { result } = renderHook(() => useStorageWarningBanner(buildParams({ planUsage: 85 }))); + + expect(result.current?.reachedStage.key).toBe('midWarning'); + }); + + test('When a free user nearly fills their storage, then the most severe warning banner is shown', () => { + const { result } = renderHook(() => useStorageWarningBanner(buildParams({ planUsage: 96 }))); + + expect(result.current?.reachedStage.key).toBe('highWarning'); + }); + + test('When the user is on a paid plan, then no banner is shown regardless of usage', () => { + const { result } = renderHook(() => useStorageWarningBanner(buildParams({ planUsage: 96, isFreeUser: false }))); + + expect(result.current).toBeNull(); + }); + + test('When plan usage and limit are still loading, then no banner is shown', () => { + const { result } = renderHook(() => + useStorageWarningBanner(buildParams({ isLoadingPlanUsage: true, isLoadingPlanLimit: true })), + ); + + expect(result.current).toBeNull(); + }); + + test('When plan data has not been fetched yet, then no banner is shown', () => { + const { result } = renderHook(() => useStorageWarningBanner(buildParams({ planUsage: 0, planLimit: 0 }))); + + expect(result.current).toBeNull(); + }); + + test('When plan values are invalid, then no banner is shown', () => { + const { result } = renderHook(() => useStorageWarningBanner(buildParams({ planUsage: -10 }))); + + expect(result.current).toBeNull(); + }); + + test('When an upload is in progress, then no banner is shown', () => { + setActiveNotifications([{ action: TaskType.UploadFile }]); + + const { result } = renderHook(() => useStorageWarningBanner(buildParams({ planUsage: 96 }))); + + expect(result.current).toBeNull(); + }); + + test('When a non-upload task is in progress, then the banner is still shown', () => { + setActiveNotifications([{ action: TaskType.DownloadFile }]); + + const { result } = renderHook(() => useStorageWarningBanner(buildParams({ planUsage: 65 }))); + + expect(result.current?.reachedStage.key).toBe('lowWarning'); + }); + + test('When the user dismisses the banner, then it stays hidden for the rest of the session', () => { + const { result, rerender } = renderHook(() => useStorageWarningBanner(buildParams({ planUsage: 65 }))); + + act(() => { + result.current?.onCloseButtonClick(); + }); + rerender(); + + expect(result.current).toBeNull(); + }); + + test('When a stage was dismissed within its cooldown window, then the banner stays hidden', () => { + writeDismissal('lowWarning', Date.now()); + + const { result } = renderHook(() => useStorageWarningBanner(buildParams({ planUsage: 65 }))); + + expect(result.current).toBeNull(); + }); + + test('When the cooldown window of a dismissed stage has elapsed, then the banner is shown again', () => { + writeDismissal('lowWarning', Date.now() - 8 * DAY_IN_MS); + + const { result } = renderHook(() => useStorageWarningBanner(buildParams({ planUsage: 65 }))); + + expect(result.current?.reachedStage.key).toBe('lowWarning'); + }); +}); From a097b926175a3af9b0edb9f494d53316751e8f4c Mon Sep 17 00:00:00 2001 From: jaaaaavier Date: Fri, 26 Jun 2026 11:56:39 +0200 Subject: [PATCH 7/7] refactor: after revision --- src/components/SidenavWrapper.tsx | 2 +- .../Home/components/WarningMessageWrapper.tsx | 2 +- .../hooks/useStorageWarningBanner.test.ts | 2 +- .../Home/hooks/useStorageWarningBanner.ts | 2 +- .../Home/utils}/storageWarning.test.ts | 2 +- .../Home/utils}/storageWarning.ts | 28 ++++++++++--------- 6 files changed, 20 insertions(+), 18 deletions(-) rename src/{app/store/slices/plan => views/Home/utils}/storageWarning.test.ts (99%) rename src/{app/store/slices/plan => views/Home/utils}/storageWarning.ts (82%) diff --git a/src/components/SidenavWrapper.tsx b/src/components/SidenavWrapper.tsx index 6ea0233c83..a005481fec 100644 --- a/src/components/SidenavWrapper.tsx +++ b/src/components/SidenavWrapper.tsx @@ -28,7 +28,7 @@ import { calculateUsedPercentage, getReachedStorageWarningStage, openUpgradeSpecialOffer, -} from 'app/store/slices/plan/storageWarning'; +} from 'views/Home/utils/storageWarning'; const SidenavPrimaryAction = ({ user, diff --git a/src/views/Home/components/WarningMessageWrapper.tsx b/src/views/Home/components/WarningMessageWrapper.tsx index 7c9b8eafa5..4cf57e5a05 100644 --- a/src/views/Home/components/WarningMessageWrapper.tsx +++ b/src/views/Home/components/WarningMessageWrapper.tsx @@ -5,7 +5,7 @@ import { planSelectors } from 'app/store/slices/plan'; import { UsageWarningBanner } from '@internxt/ui'; import { bytesToString } from 'app/drive/services/size.service'; import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; -import { openUpgradeSpecialOffer, REACHING_USAGE_BANNER_TRANSLATION_KEY } from 'app/store/slices/plan/storageWarning'; +import { openUpgradeSpecialOffer, REACHING_USAGE_BANNER_TRANSLATION_KEY } from 'views/Home/utils/storageWarning'; import { useStorageWarningBanner } from 'views/Home/hooks/useStorageWarningBanner'; const renderDescriptionLine = (line: string): JSX.Element => ( diff --git a/src/views/Home/hooks/useStorageWarningBanner.test.ts b/src/views/Home/hooks/useStorageWarningBanner.test.ts index 7df08fbda1..49ed4b84ab 100644 --- a/src/views/Home/hooks/useStorageWarningBanner.test.ts +++ b/src/views/Home/hooks/useStorageWarningBanner.test.ts @@ -8,7 +8,7 @@ vi.mock('app/tasks/hooks', () => ({ import { useStorageWarningBanner } from './useStorageWarningBanner'; import { useTaskManagerGetNotifications } from 'app/tasks/hooks'; import { TaskType } from 'app/tasks/types'; -import { writeDismissal } from 'app/store/slices/plan/storageWarning'; +import { writeDismissal } from 'views/Home/utils/storageWarning'; const mockUseTaskManagerGetNotifications = useTaskManagerGetNotifications as ReturnType; diff --git a/src/views/Home/hooks/useStorageWarningBanner.ts b/src/views/Home/hooks/useStorageWarningBanner.ts index 81e30fa6b0..00058546b1 100644 --- a/src/views/Home/hooks/useStorageWarningBanner.ts +++ b/src/views/Home/hooks/useStorageWarningBanner.ts @@ -9,7 +9,7 @@ import { writeDismissal, StorageWarning, StorageWarningStage, -} from 'app/store/slices/plan/storageWarning'; +} from 'views/Home/utils/storageWarning'; const UPLOAD_IN_PROGRESS_STATUSES = [ TaskStatus.Pending, diff --git a/src/app/store/slices/plan/storageWarning.test.ts b/src/views/Home/utils/storageWarning.test.ts similarity index 99% rename from src/app/store/slices/plan/storageWarning.test.ts rename to src/views/Home/utils/storageWarning.test.ts index 9979a1d82b..b0f574e392 100644 --- a/src/app/store/slices/plan/storageWarning.test.ts +++ b/src/views/Home/utils/storageWarning.test.ts @@ -55,7 +55,7 @@ describe('openUpgradeSpecialOffer', () => { openUpgradeSpecialOffer(); - expect(openSpy).toHaveBeenCalledWith(SPECIAL_OFFER_URL, '_blank', 'noopener,noreferrer'); + expect(openSpy).toHaveBeenCalledWith(SPECIAL_OFFER_URL, '_blank', 'noopener noreferrer'); openSpy.mockRestore(); }); }); diff --git a/src/app/store/slices/plan/storageWarning.ts b/src/views/Home/utils/storageWarning.ts similarity index 82% rename from src/app/store/slices/plan/storageWarning.ts rename to src/views/Home/utils/storageWarning.ts index 1322989fae..207fac8fa9 100644 --- a/src/app/store/slices/plan/storageWarning.ts +++ b/src/views/Home/utils/storageWarning.ts @@ -1,3 +1,8 @@ +import localStorageService from 'services/local-storage.service'; + +const DAY_IN_MS = 24 * 60 * 60 * 1000; +const DISMISSALS_STORAGE_KEY = 'storageWarningBannerDismissals'; + export type StorageWarning = 'lowWarning' | 'midWarning' | 'highWarning'; export interface StorageWarningStage { @@ -45,21 +50,16 @@ export const getReachedStorageWarningStage = (usedPercentage: number): StorageWa [...STORAGE_WARNING_STAGES].reverse().find((stage) => usedPercentage >= stage.threshold); export const openUpgradeSpecialOffer = (): void => { - window.open(SPECIAL_OFFER_URL, '_blank', 'noopener,noreferrer'); + window.open(SPECIAL_OFFER_URL, '_blank', 'noopener noreferrer'); }; -const DAY_IN_MS = 24 * 60 * 60 * 1000; -const DISMISSALS_STORAGE_KEY = 'storageWarningBannerDismissals'; - -export type StorageWarningDismissals = Partial>; - export const readDismissals = (): StorageWarningDismissals => { try { - const raw = localStorage.getItem(DISMISSALS_STORAGE_KEY); - if (!raw) return {}; - const parsed = JSON.parse(raw); + const raw = localStorageService.get(DISMISSALS_STORAGE_KEY); + const parsed = raw ? JSON.parse(raw) : null; return typeof parsed === 'object' && parsed !== null ? parsed : {}; - } catch { + } catch (error) { + console.error('Failed to read storage warning dismissals', error); return {}; } }; @@ -68,9 +68,9 @@ export const writeDismissal = (stageKey: StorageWarning, dismissedAt: number): v try { const dismissals = readDismissals(); dismissals[stageKey] = dismissedAt; - localStorage.setItem(DISMISSALS_STORAGE_KEY, JSON.stringify(dismissals)); - } catch { - // + localStorageService.set(DISMISSALS_STORAGE_KEY, JSON.stringify(dismissals)); + } catch (error) { + console.error('Failed to write storage warning dismissal', error); } }; @@ -83,3 +83,5 @@ export const isStageInCooldown = ( if (!dismissedAt) return false; return now < dismissedAt + stage.cooldownDays * DAY_IN_MS; }; + +export type StorageWarningDismissals = Partial>;