diff --git a/package.json b/package.json index 32246f86bd..f32056b213 100644 --- a/package.json +++ b/package.json @@ -7,10 +7,10 @@ "@dashlane/pqc-kem-kyber512-browser": "^1.0.0", "@headlessui/react": "^2.2.10", "@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", - "@internxt/ui": "=0.1.21", "@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 0753e29fac..f4aaaa5f42 100644 --- a/src/app/i18n/locales/de.json +++ b/src/app/i18n/locales/de.json @@ -1097,6 +1097,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 b8175b3871..e658be9dcd 100644 --- a/src/app/i18n/locales/en.json +++ b/src/app/i18n/locales/en.json @@ -1171,6 +1171,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 78a8608dda..c12ee5aee3 100644 --- a/src/app/i18n/locales/es.json +++ b/src/app/i18n/locales/es.json @@ -1148,6 +1148,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 dd2910a24a..2ca3666914 100644 --- a/src/app/i18n/locales/fr.json +++ b/src/app/i18n/locales/fr.json @@ -1100,6 +1100,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 9c0c68ecc9..a0c1b946f0 100644 --- a/src/app/i18n/locales/it.json +++ b/src/app/i18n/locales/it.json @@ -1206,6 +1206,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 2fadc1d4ec..cc70b553f6 100644 --- a/src/app/i18n/locales/ru.json +++ b/src/app/i18n/locales/ru.json @@ -1114,6 +1114,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 83ee1f5a01..eab084416c 100644 --- a/src/app/i18n/locales/tw.json +++ b/src/app/i18n/locales/tw.json @@ -1101,6 +1101,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 6a355bb30e..521c9acc0e 100644 --- a/src/app/i18n/locales/zh.json +++ b/src/app/i18n/locales/zh.json @@ -1136,6 +1136,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..a005481fec 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'; @@ -24,6 +23,12 @@ 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'; +import { + calculateUsedPercentage, + getReachedStorageWarningStage, + openUpgradeSpecialOffer, +} from 'views/Home/utils/storageWarning'; const SidenavPrimaryAction = ({ user, @@ -69,6 +74,10 @@ const SidenavWrapper = () => { const userUsage = planUsage > 0 ? bytesToString(planUsage) : '0GB'; const isReferralEligible = useAppSelector((state: RootState) => state.referrals.isEligible); + const isFreeUser = subscription?.type === 'free'; + const usedPercentage = calculateUsedPercentage(planUsage, planLimit); + const reachedStorageStage = isFreeUser ? getReachedStorageWarningStage(usedPercentage) : undefined; + useReferralParamsChange(); useEffect(() => { @@ -90,14 +99,17 @@ const SidenavWrapper = () => { return subscription?.type === 'free' || isLifetimeAvailable; }; - const handleUpgradeClick = () => { - navigationService.openPreferencesDialog({ - section: 'account', - subsection: 'plans', - workspaceUuid: selectedWorkspace?.workspaceUser.workspaceId, - }); - dispatch(uiActions.setIsPreferencesDialogOpen(true)); - }; + const handleUpgradeClick = openUpgradeSpecialOffer; + + const storageAdvertisement = reachedStorageStage?.advertisementKey ? ( + + +

{translate(reachedStorageStage.advertisementKey)}

+
+ ) : undefined; const handleReferralClick = () => { if (user) { @@ -141,6 +153,9 @@ const SidenavWrapper = () => { onUpgradeClick: handleUpgradeClick, upgradeLabel: isUpgradeAvailable() ? translate('preferences.account.plans.upgrade') : undefined, isLoading: isLoadingPlanUsage && isLoadingPlanLimit && isLoadingBusinessLimitAndUsage, + barClassName: reachedStorageStage?.barClassName, + 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..4cf57e5a05 100644 --- a/src/views/Home/components/WarningMessageWrapper.tsx +++ b/src/views/Home/components/WarningMessageWrapper.tsx @@ -1,49 +1,69 @@ -import { connect, useDispatch } from 'react-redux'; -import navigationService from 'services/navigation.service'; +import { Fragment } 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 { openUpgradeSpecialOffer, REACHING_USAGE_BANNER_TRANSLATION_KEY } from 'views/Home/utils/storageWarning'; +import { useStorageWarningBanner } from 'views/Home/hooks/useStorageWarningBanner'; + +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 = ({ - planLimit, - planUsage, - isLoadingPlanLimit, - isLoadingPlanUsage, -}: WarningMessageWrapperProps): JSX.Element => { - const dispatch = useDispatch(); - const selectedWorkspace = useAppSelector(workspacesSelectors.getSelectedWorkspace); - - const onUpgradeButtonClicked = () => { - navigationService.openPreferencesDialog({ - section: 'account', - subsection: 'plans', - workspaceUuid: selectedWorkspace?.workspaceUser.workspaceId, - }); - dispatch(uiActions.setIsPreferencesDialogOpen(true)); - }; - - 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); - - if (plansNotFetched || areNotNumbers || !isLimitReached || isLoading) return <>; - - return ; +const WarningMessageWrapper = (props: WarningMessageWrapperProps): JSX.Element => { + const { translate } = useTranslationContext(); + const banner = useStorageWarningBanner(props); + + if (!banner) return <>; + + const { reachedStage, usedPercentage, onCloseButtonClick } = banner; + const { planUsage, planLimit } = props; + + const baseKey = `${REACHING_USAGE_BANNER_TRANSLATION_KEY}.${reachedStage.key}`; + const description = ( +
+ {renderDescriptionLine(translate(`${baseKey}.descriptionLabelLine1`))} + {renderDescriptionLine(translate(`${baseKey}.descriptionLabelLine2`))} +
+ ); + + const barPercentage = Math.min(Math.ceil(usedPercentage), 100); + + return ( + + ); }; export default connect((state: RootState) => ({ @@ -51,4 +71,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/Home/hooks/useStorageWarningBanner.test.ts b/src/views/Home/hooks/useStorageWarningBanner.test.ts new file mode 100644 index 0000000000..49ed4b84ab --- /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 'views/Home/utils/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'); + }); +}); diff --git a/src/views/Home/hooks/useStorageWarningBanner.ts b/src/views/Home/hooks/useStorageWarningBanner.ts new file mode 100644 index 0000000000..00058546b1 --- /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 'views/Home/utils/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 }; +}; diff --git a/src/views/Home/utils/storageWarning.test.ts b/src/views/Home/utils/storageWarning.test.ts new file mode 100644 index 0000000000..b0f574e392 --- /dev/null +++ b/src/views/Home/utils/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/utils/storageWarning.ts b/src/views/Home/utils/storageWarning.ts new file mode 100644 index 0000000000..207fac8fa9 --- /dev/null +++ b/src/views/Home/utils/storageWarning.ts @@ -0,0 +1,87 @@ +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 { + 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'); +}; + +export const readDismissals = (): StorageWarningDismissals => { + try { + const raw = localStorageService.get(DISMISSALS_STORAGE_KEY); + const parsed = raw ? JSON.parse(raw) : null; + return typeof parsed === 'object' && parsed !== null ? parsed : {}; + } catch (error) { + console.error('Failed to read storage warning dismissals', error); + return {}; + } +}; + +export const writeDismissal = (stageKey: StorageWarning, dismissedAt: number): void => { + try { + const dismissals = readDismissals(); + dismissals[stageKey] = dismissedAt; + localStorageService.set(DISMISSALS_STORAGE_KEY, JSON.stringify(dismissals)); + } catch (error) { + console.error('Failed to write storage warning dismissal', error); + } +}; + +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; +}; + +export type StorageWarningDismissals = Partial>; 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/yarn.lock b/yarn.lock index 2a0ee56e22..2ab2becc53 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1913,6 +1913,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" @@ -1940,10 +1947,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"