web/satellite: show limit notifications to paid tier users

This change causes paid tier users to see notifications in the project
dashboard when their usage is approaching or has reached their maximum
or custom usage limits.

Change-Id: I7b68fcdd7d62797b6b26869e109cfb0b193fdddb
This commit is contained in:
Jeremy Wharton 2023-08-23 21:09:41 -05:00 committed by Storj Robot
parent 31ec421299
commit 00194f54a2
17 changed files with 459 additions and 524 deletions

View File

@ -45,7 +45,7 @@ module.exports = {
},
{
'group': 'internal',
'pattern': '@?(poc)/components/**',
'pattern': '@?(poc)/{components,views}/**',
'position': 'after',
},
{

View File

@ -17,8 +17,8 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useNotify } from '@/utils/hooks';
import { useAppStore } from '@/store/modules/appStore';
import { useConfigStore } from '@/store/modules/configStore';
import ErrorPage from '@/views/ErrorPage.vue';
import ErrorPage from '@/views/ErrorPage.vue';
import BrandedLoader from '@/components/common/BrandedLoader.vue';
import NotificationArea from '@/components/notifications/NotificationArea.vue';

View File

@ -10,9 +10,25 @@
>
<InfoIcon class="notification-wrap__icon" />
<div class="notification-wrap__text">
<slot name="text" />
<p>
<span class="notification-wrap__text__title">{{ title }}</span>
{{ message }}
</p>
<template v-if="linkText">
<a
v-if="href"
class="notification-wrap__text__link"
:href="href"
target="_blank"
rel="noreferrer noopener"
@click.stop="onLinkClick"
>
{{ linkText }}
</a>
<p v-else class="notification-wrap__text__link" @click.stop="onLinkClick">{{ linkText }}</p>
</template>
</div>
<CloseIcon class="notification-wrap__close" @click="closeClicked" />
<CloseIcon class="notification-wrap__close" @click.stop="closeClicked" />
</div>
</template>
@ -24,13 +40,23 @@ import CloseIcon from '@/../static/images/notifications/closeSmall.svg';
const props = withDefaults(defineProps<{
severity?: 'info' | 'warning' | 'critical';
title?: string;
message?: string;
linkText?: string;
href?: string;
onClick?: () => void;
onLinkClick?: () => void;
onClose?: () => void;
dashboardRef: HTMLElement;
}>(), {
severity: 'info',
onClick: () => () => {},
onClose: () => () => {},
title: '',
message: '',
linkText: '',
href: '',
onClick: () => {},
onLinkClick: () => {},
onClose: () => {},
});
const isShown = ref<boolean>(true);
@ -39,9 +65,7 @@ const resizeObserver = ref<ResizeObserver>();
function closeClicked(): void {
isShown.value = false;
if (props.onClose) {
props.onClose();
}
props.onClose();
}
function onBannerResize(): void {
@ -104,7 +128,7 @@ watch(() => props.dashboardRef, () => {
border: 1px solid var(--c-yellow-2);
:deep(.icon-path) {
fill: var(--c-yellow-3) !important;
fill: var(--c-yellow-5) !important;
}
}
@ -126,7 +150,22 @@ watch(() => props.dashboardRef, () => {
display: flex;
align-items: center;
justify-content: space-between;
column-gap: 10px;
gap: 10px;
@media screen and (width <= 500px) {
flex-direction: column;
}
&__title {
font-family: 'font_medium', sans-serif;
}
&__link {
color: black;
text-decoration: underline !important;
white-space: nowrap;
cursor: pointer;
}
}
&__close {
@ -143,18 +182,4 @@ watch(() => props.dashboardRef, () => {
}
}
}
.bold {
font-family: 'font_bold', sans-serif;
}
.medium {
font-family: 'font_medium', sans-serif;
}
.link {
color: black;
text-decoration: underline !important;
cursor: pointer;
}
</style>

View File

@ -5,9 +5,29 @@
<v-modal :on-close="onClose">
<template #content>
<div class="modal">
<Icon class="modal__icon" :class="{ warning: severity === 'warning', critical: severity === 'critical' }" />
<h1 class="modal__title">{{ title }}</h1>
<p class="modal__info">To get more {{ limitType }} limit, upgrade to a Pro Account. You will still get {{ bytesToBase10String(limits.storageUsed) }} free storage and egress per month, and only pay what you use beyond that.</p>
<div class="modal__icon" :class="isHundred ? 'critical' : 'warning'">
<Icon />
</div>
<h1 class="modal__title">
<template v-if="isHundred">
Urgent! You've reached the {{ limitTypes }} limit{{ limitTypes.includes(' ') ? 's' : '' }} for your project.
</template>
<template v-else>
80% {{ limitTypes.charAt(0).toUpperCase() + limitTypes.slice(1) }} used
</template>
</h1>
<p class="modal__info">
<template v-if="!isPaidTier">
To get more {{ limitTypes }} limit{{ limitTypes.includes(' ') ? 's' : '' }}, upgrade to a Pro Account.
You will still get {{ bytesToBase10String(limits.storageUsed) }} free storage and egress per month, and only pay what you use beyond that.
</template>
<template v-else-if="isCustom">
You can increase your limits in the Project Settings page.
</template>
<template v-else>
To get higher limits, please contact support.
</template>
</p>
<div class="modal__buttons">
<VButton
label="Cancel"
@ -19,12 +39,13 @@
:on-press="onClose"
/>
<VButton
label="Upgrade"
:label="!isPaidTier ? 'Upgrade' : isCustom ? 'Edit Limits' : 'Request Higher Limit'"
height="40px"
width="48%"
font-size="13px"
class="modal__buttons__button upgrade"
:on-press="onUpgrade"
class="modal__buttons__button primary"
:on-press="onPrimaryClick"
:link="(isPaidTier && !isCustom) ? requestURL : undefined"
:is-white-blue="true"
/>
</div>
@ -35,63 +56,124 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { ProjectLimits } from '@/types/projects';
import { ProjectLimits, LimitThreshold, LimitThresholdsReached } from '@/types/projects';
import { useProjectsStore } from '@/store/modules/projectsStore';
import { bytesToBase10String } from '@/utils/strings';
import { useUsersStore } from '@/store/modules/usersStore';
import { useConfigStore } from '@/store/modules/configStore';
import { bytesToBase10String, humanizeArray } from '@/utils/strings';
import { useAnalyticsStore } from '@/store/modules/analyticsStore';
import { RouteConfig } from '@/types/router';
import VButton from '@/components/common/VButton.vue';
import VModal from '@/components/common/VModal.vue';
import Icon from '@/../static/images/project/chart.svg';
import Icon from '@/../static/images/notifications/info.svg';
const projectsStore = useProjectsStore();
const usersStore = useUsersStore();
const configStore = useConfigStore();
const analyticsStore = useAnalyticsStore();
const router = useRouter();
const props = defineProps<{
severity: 'warning' | 'critical';
title: string;
limitType: string;
reachedThresholds: LimitThresholdsReached;
threshold: LimitThreshold,
onUpgrade: () => void;
onClose: () => void
}>();
/**
* Returns whether the threshold represents 100% usage.
*/
const isHundred = computed((): boolean => props.threshold.toLowerCase().includes('hundred'));
/**
* Returns whether the usage limit threshold is for a custom limit.
*/
const isCustom = computed((): boolean => props.threshold.toLowerCase().includes('custom'));
/**
* Returns a string representing the usage types that have reached this limit threshold.
*/
const limitTypes = computed((): string => {
return humanizeArray(props.reachedThresholds[props.threshold]).toLowerCase();
});
/**
* Returns current limits from store.
*/
const limits = computed((): ProjectLimits => {
return projectsStore.state.currentLimits;
});
/**
* Returns whether user is in the paid tier.
*/
const isPaidTier = computed((): boolean => {
return usersStore.state.user.paidTier;
});
/**
* Returns the URL for the general request page from the store.
*/
const requestURL = computed((): string => {
return configStore.state.config.generalRequestURL;
});
/**
* Handles primary button click.
*/
function onPrimaryClick(): void {
if (!isPaidTier.value) {
props.onUpgrade();
return;
}
if (isCustom.value) {
analyticsStore.pageVisit(RouteConfig.EditProjectDetails.path);
router.push(RouteConfig.EditProjectDetails.path);
props.onClose();
}
}
</script>
<style scoped lang="scss">
.modal {
max-width: 500px;
width: 500px;
max-width: calc(100vw - 48px);
padding: 32px;
box-sizing: border-box;
font-family: 'font_regular', sans-serif;
text-align: left;
&__icon {
width: 64px;
height: 64px;
margin-bottom: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 24px;
:deep(svg) {
width: 46px;
height: 46px;
}
&.critical {
background-color: var(--c-pink-1);
:deep(.icon-background) {
fill: var(--c-red-1);
}
:deep(.icon-chart) {
fill: var(--c-red-2);
:deep(path) {
fill: var(--c-pink-4);
}
}
&.warning {
background-color: var(--c-yellow-1);
:deep(.icon-background) {
fill: var(--c-yellow-4);
}
:deep(.icon-chart) {
:deep(path) {
fill: var(--c-yellow-5);
}
}
@ -124,7 +206,7 @@ const limits = computed((): ProjectLimits => {
box-sizing: border-box;
letter-spacing: -0.02em;
&.upgrade {
&.primary {
margin-left: 8px;
}
}

View File

@ -4,21 +4,14 @@
<template>
<v-banner
severity="warning"
:title="bannerTextData.title"
:message="bannerTextData.body"
:link-text="isPaidTier ? 'Request Limit Increase' : 'Upgrade Now'"
:href="isPaidTier ? projectLimitsIncreaseRequestURL : undefined"
:dashboard-ref="dashboardRef"
:on-close="onClose"
>
<template #text>
<p><span class="bold">{{ bannerTextData.title }}</span> <span class="medium"> {{ bannerTextData.body }}</span></p>
<p v-if="!isPaidTier" class="link" @click.stop.self="onUpgradeClicked">Upgrade Now</p>
<a
v-else
:href="projectLimitsIncreaseRequestURL"
class="link"
target="_blank"
rel="noopener noreferrer"
>Request Limit Increase</a>
</template>
</v-banner>
:on-link-click="onUpgradeClicked"
/>
</template>
<script setup lang="ts">

View File

@ -1,55 +0,0 @@
// Copyright (C) 2023 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<v-banner
severity="info"
:dashboard-ref="dashboardRef"
:on-close="onCloseClick"
>
<template #text>
<p class="medium">
You can now update your session timeout from your
<span class="link" @click.stop.self="redirectToSettingsPage">account settings</span>
</p>
</template>
</v-banner>
</template>
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router';
import { RouteConfig } from '@/types/router';
import { useAppStore } from '@/store/modules/appStore';
import VBanner from '@/components/common/VBanner.vue';
const appStore = useAppStore();
const router = useRouter();
const route = useRoute();
const props = defineProps<{
dashboardRef: HTMLElement
}>();
/**
* Redirects to settings page.
*/
function redirectToSettingsPage(): void {
onCloseClick();
if (route.path.includes(RouteConfig.AllProjectsDashboard.path)) {
router.push(RouteConfig.AccountSettings.with(RouteConfig.Settings2).path);
return;
}
router.push(RouteConfig.Account.with(RouteConfig.Settings).path);
}
/**
* Closes notification.
*/
function onCloseClick(): void {
appStore.closeUpdateSessionTimeoutBanner();
}
</script>

View File

@ -8,9 +8,9 @@ import { useAppStore } from '@/store/modules/appStore';
import { useProjectsStore } from '@/store/modules/projectsStore';
import { useConfigStore } from '@/store/modules/configStore';
import { RouteConfig } from '@/types/router';
import AllDashboardArea from '@/views/all-dashboard/AllDashboardArea.vue';
import MyProjects from '@/views/all-dashboard/components/MyProjects.vue';
import AccessGrants from '@/components/accessGrants/AccessGrants.vue';
import CreateAccessGrantFlow from '@/components/accessGrants/createFlow/CreateAccessGrantFlow.vue';
import AccountArea from '@/components/account/AccountArea.vue';
@ -48,7 +48,7 @@ import NewSettingsArea from '@/components/account/NewSettingsArea.vue';
const ActivateAccount = () => import('@/views/ActivateAccount.vue');
const AuthorizeArea = () => import('@/views/AuthorizeArea.vue');
const DashboardArea = () => import('@/views/DashboardArea.vue');
const DashboardArea = () => import('@/views/dashboard/DashboardArea.vue');
const ForgotPassword = () => import('@/views/ForgotPassword.vue');
const LoginArea = () => import('@/views/LoginArea.vue');
const RegisterArea = () => import('@/views/registration/RegisterArea.vue');

View File

@ -2,7 +2,7 @@
// See LICENSE for copying information.
import { defineStore } from 'pinia';
import { computed, reactive } from 'vue';
import { computed, reactive, readonly } from 'vue';
import {
DataStamp,
@ -20,14 +20,15 @@ import {
import { ProjectsHttpApi } from '@/api/projects';
import { DEFAULT_PAGE_LIMIT } from '@/types/pagination';
const defaultSelectedProject = new Project('', '', '', '', '', true, 0);
const defaultSelectedInvitation = new ProjectInvitation('', '', '', '', new Date());
const DEFAULT_PROJECT = new Project('', '', '', '', '', true, 0);
const DEFAULT_INVITATION = new ProjectInvitation('', '', '', '', new Date());
export const DEFAULT_PROJECT_LIMITS = readonly(new ProjectLimits());
export class ProjectsState {
public projects: Project[] = [];
public selectedProject: Project = defaultSelectedProject;
public currentLimits: ProjectLimits = new ProjectLimits();
public totalLimits: ProjectLimits = new ProjectLimits();
public selectedProject: Project = DEFAULT_PROJECT;
public currentLimits: Readonly<ProjectLimits> = DEFAULT_PROJECT_LIMITS;
public totalLimits: Readonly<ProjectLimits> = DEFAULT_PROJECT_LIMITS;
public cursor: ProjectsCursor = new ProjectsCursor();
public page: ProjectsPage = new ProjectsPage();
public allocatedBandwidthChartData: DataStamp[] = [];
@ -36,7 +37,7 @@ export class ProjectsState {
public chartDataSince: Date = new Date();
public chartDataBefore: Date = new Date();
public invitations: ProjectInvitation[] = [];
public selectedInvitation: ProjectInvitation = defaultSelectedInvitation;
public selectedInvitation: ProjectInvitation = DEFAULT_INVITATION;
}
export const useProjectsStore = defineStore('projects', () => {
@ -73,7 +74,7 @@ export const useProjectsStore = defineStore('projects', () => {
return;
}
state.selectedProject = defaultSelectedProject;
state.selectedProject = DEFAULT_PROJECT;
}
async function getOwnedProjects(pageNumber: number, limit = DEFAULT_PAGE_LIMIT): Promise<void> {
@ -175,7 +176,10 @@ export const useProjectsStore = defineStore('projects', () => {
);
await api.update(state.selectedProject.id, project, limit);
state.currentLimits.storageLimit = limitsToUpdate.storageLimit;
state.currentLimits = readonly({
...state.currentLimits,
storageLimit: limitsToUpdate.storageLimit,
});
}
async function updateProjectBandwidthLimit(limitsToUpdate: ProjectLimits): Promise<void> {
@ -192,7 +196,10 @@ export const useProjectsStore = defineStore('projects', () => {
);
await api.update(state.selectedProject.id, project, limit);
state.currentLimits.bandwidthLimit = limitsToUpdate.bandwidthLimit;
state.currentLimits = readonly({
...state.currentLimits,
bandwidthLimit: limitsToUpdate.bandwidthLimit,
});
}
async function getProjectLimits(projectID: string): Promise<void> {
@ -225,8 +232,8 @@ export const useProjectsStore = defineStore('projects', () => {
function clear(): void {
state.projects = [];
state.selectedProject = defaultSelectedProject;
state.currentLimits = new ProjectLimits();
state.selectedProject = DEFAULT_PROJECT;
state.currentLimits = DEFAULT_PROJECT_LIMITS;
state.totalLimits = new ProjectLimits();
state.storageChartData = [];
state.allocatedBandwidthChartData = [];
@ -234,7 +241,7 @@ export const useProjectsStore = defineStore('projects', () => {
state.chartDataSince = new Date();
state.chartDataBefore = new Date();
state.invitations = [];
state.selectedInvitation = defaultSelectedInvitation;
state.selectedInvitation = DEFAULT_INVITATION;
}
function projectsCount(userID: string): number {

View File

@ -276,3 +276,18 @@ export enum FieldToChange {
Name = 'Name',
Description = 'Description',
}
export enum LimitThreshold {
Hundred = 'Hundred',
Eighty = 'Eighty',
CustomHundred = 'CustomHundred',
CustomEighty = 'CustomEighty',
}
export enum LimitType {
Storage = 'Storage',
Egress = 'Egress',
Segment = 'Segment',
}
export type LimitThresholdsReached = Record<LimitThreshold, LimitType[]>;

View File

@ -95,4 +95,18 @@ export function centsToDollars(cents: number) {
*/
export function bytesToBase10String(amountInBytes: number) {
return Size.toBase10String(amountInBytes);
}
}
/**
* Returns a human-friendly form of an array, inserting commas and "and"s where necessary.
* @param arr - the array
*/
export function humanizeArray(arr: string[]): string {
const len = arr.length;
switch (len) {
case 0: return '';
case 1: return arr[0];
case 2: return arr.join(' and ');
default: return `${arr.slice(0, len-1).join(', ')}, and ${arr[len-1]}`;
}
}

View File

@ -20,22 +20,6 @@
<router-view />
<limit-warning-modal
v-if="isHundredLimitModalShown && !isLoading"
severity="critical"
:on-close="() => setIsHundredLimitModalShown(false)"
:title="limitState.hundredModalTitle"
:limit-type="limitState.hundredModalLimitType"
:on-upgrade="togglePMModal"
/>
<limit-warning-modal
v-if="isEightyLimitModalShown && !isLoading"
severity="warning"
:on-close="() => setIsEightyLimitModalShown(false)"
:title="limitState.eightyModalTitle"
:limit-type="limitState.eightyModalLimitType"
:on-upgrade="togglePMModal"
/>
<AllModals />
</div>
</SessionWrapper>
@ -43,7 +27,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { MODALS } from '@/utils/constants/appStatePopUps';
@ -56,7 +40,6 @@ import { RouteConfig } from '@/types/router';
import { ErrorUnauthorized } from '@/api/errors/ErrorUnauthorized';
import { FetchState } from '@/utils/constants/fetchStateEnum';
import { CouponType } from '@/types/coupons';
import Heading from '@/views/all-dashboard/components/Heading.vue';
import { useABTestingStore } from '@/store/modules/abTestingStore';
import { useUsersStore } from '@/store/modules/usersStore';
import { useBillingStore } from '@/store/modules/billingStore';
@ -66,11 +49,11 @@ import { useProjectsStore } from '@/store/modules/projectsStore';
import { useConfigStore } from '@/store/modules/configStore';
import { useAnalyticsStore } from '@/store/modules/analyticsStore';
import Heading from '@/views/all-dashboard/components/Heading.vue';
import SessionWrapper from '@/components/utils/SessionWrapper.vue';
import BetaSatBar from '@/components/infoBars/BetaSatBar.vue';
import MFARecoveryCodeBar from '@/components/infoBars/MFARecoveryCodeBar.vue';
import AllModals from '@/components/modals/AllModals.vue';
import LimitWarningModal from '@/components/modals/LimitWarningModal.vue';
import LoaderImage from '@/../static/images/common/loadIcon.svg';
@ -90,94 +73,10 @@ const projectsStore = useProjectsStore();
// Minimum number of recovery codes before the recovery code warning bar is shown.
const recoveryCodeWarningThreshold = 4;
const isHundredLimitModalShown = ref<boolean>(false);
const isEightyLimitModalShown = ref<boolean>(false);
const isMyProjectsPage = computed((): boolean => {
return route.path === RouteConfig.AllProjectsDashboard.path;
});
/**
* Indicates if account was frozen due to billing issues.
*/
const isAccountFrozen = computed((): boolean => {
return usersStore.state.user.freezeStatus.frozen;
});
/**
* Returns all needed information for limit modal when bandwidth or storage close to limits.
*/
type LimitedState = {
eightyIsShown: boolean;
hundredIsShown: boolean;
eightyLabel: string;
eightyModalLimitType: string;
eightyModalTitle: string;
hundredLabel: string;
hundredModalTitle: string;
hundredModalLimitType: string;
}
const limitState = computed((): LimitedState => {
const result: LimitedState = {
eightyIsShown: false,
hundredIsShown: false,
eightyLabel: '',
eightyModalLimitType: '',
eightyModalTitle: '',
hundredLabel: '',
hundredModalTitle: '',
hundredModalLimitType: '',
};
if (usersStore.state.user.paidTier || isAccountFrozen.value) {
return result;
}
const currentLimits = projectsStore.state.currentLimits;
const limitTypeArr = [
{ name: 'egress', usedPercent: Math.round(currentLimits.bandwidthUsed * 100 / currentLimits.bandwidthLimit) },
{ name: 'storage', usedPercent: Math.round(currentLimits.storageUsed * 100 / currentLimits.storageLimit) },
{ name: 'segment', usedPercent: Math.round(currentLimits.segmentUsed * 100 / currentLimits.segmentLimit) },
];
const hundredPercent: string[] = [];
const eightyPercent: string[] = [];
limitTypeArr.forEach((limitType) => {
if (limitType.usedPercent >= 80) {
if (limitType.usedPercent >= 100) {
hundredPercent.push(limitType.name);
} else {
eightyPercent.push(limitType.name);
}
}
});
if (eightyPercent.length !== 0) {
result.eightyIsShown = true;
const eightyPercentString = eightyPercent.join(' and ');
result.eightyLabel = `You've used 80% of your ${eightyPercentString} limit. Avoid interrupting your usage by upgrading your account.`;
result.eightyModalTitle = `80% ${eightyPercentString} limit used`;
result.eightyModalLimitType = eightyPercentString;
}
if (hundredPercent.length !== 0) {
result.hundredIsShown = true;
const hundredPercentString = hundredPercent.join(' and ');
result.hundredLabel = `URGENT: Youve reached the ${hundredPercentString} limit for your project. Upgrade to avoid any service interruptions.`;
result.hundredModalTitle = `URGENT: Youve reached the ${hundredPercentString} limit for your project.`;
result.hundredModalLimitType = hundredPercentString;
}
return result;
});
/**
* Indicates if satellite is in beta.
*/
@ -200,14 +99,6 @@ const showMFARecoveryCodeBar = computed((): boolean => {
return user.isMFAEnabled && user.mfaRecoveryCodeCount < recoveryCodeWarningThreshold;
});
function setIsEightyLimitModalShown(value: boolean): void {
isEightyLimitModalShown.value = value;
}
function setIsHundredLimitModalShown(value: boolean): void {
isHundredLimitModalShown.value = value;
}
/**
* Generates new MFA recovery codes and toggles popup visibility.
*/
@ -227,18 +118,6 @@ function toggleMFARecoveryModal(): void {
appStore.updateActiveModal(MODALS.mfaRecovery);
}
/**
* Opens add payment method modal.
*/
function togglePMModal(): void {
isHundredLimitModalShown.value = false;
isEightyLimitModalShown.value = false;
if (!usersStore.state.user.paidTier) {
appStore.updateActiveModal(MODALS.upgradeAccount);
}
}
/**
* Lifecycle hook after initial render.
* Pre-fetches user`s and project information.

View File

@ -12,64 +12,35 @@
<v-banner
v-if="isAccountFrozen && parentRef"
class="all-dashboard-banners__freeze"
title="Your account was frozen due to billing issues."
message="Please update your payment information."
severity="critical"
link-text="To Billing Page"
:dashboard-ref="parentRef"
>
<template #text>
<p class="medium">Your account was frozen due to billing issues. Please update your payment information.</p>
<p class="link" @click.stop.self="redirectToBillingPage">To Billing Page</p>
</template>
</v-banner>
@link-click="redirectToBillingPage"
/>
<v-banner
v-if="isAccountWarned && parentRef"
class="all-dashboard-banners__warning"
title="Your account will be frozen soon due to billing issues."
message="Please update your payment information."
link-text="To Billing Page"
severity="warning"
:dashboard-ref="parentRef"
>
<template #text>
<p class="medium">Your account will be frozen soon due to billing issues. Please update your payment information.</p>
<p class="link" @click.stop.self="redirectToBillingPage">To Billing Page</p>
</template>
</v-banner>
<v-banner
v-if="limitState.hundredIsShown && parentRef"
class="all-dashboard-banners__hundred-limit"
severity="critical"
:on-click="() => setIsHundredLimitModalShown(true)"
:dashboard-ref="parentRef"
>
<template #text>
<p class="medium">{{ limitState.hundredLabel }}</p>
<p class="link" @click.stop.self="togglePMModal">Upgrade now</p>
</template>
</v-banner>
<v-banner
v-if="limitState.eightyIsShown && parentRef"
class="all-dashboard-banners__eighty-limit"
severity="warning"
:on-click="() => setIsEightyLimitModalShown(true)"
:dashboard-ref="parentRef"
>
<template #text>
<p class="medium">{{ limitState.eightyLabel }}</p>
<p class="link" @click.stop.self="togglePMModal">Upgrade now</p>
</template>
</v-banner>
:on-link-click="redirectToBillingPage"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { useUsersStore } from '@/store/modules/usersStore';
import { MODALS } from '@/utils/constants/appStatePopUps';
import { useAppStore } from '@/store/modules/appStore';
import { useProjectsStore } from '@/store/modules/projectsStore';
import { RouteConfig } from '@/types/router';
import VBanner from '@/components/common/VBanner.vue';
@ -79,88 +50,11 @@ const router = useRouter();
const usersStore = useUsersStore();
const appStore = useAppStore();
const projectsStore = useProjectsStore();
const props = defineProps<{
parentRef: HTMLElement;
}>();
const isHundredLimitModalShown = ref<boolean>(false);
const isEightyLimitModalShown = ref<boolean>(false);
/**
* Returns all needed information for limit banner and modal when bandwidth or storage close to limits.
*/
type LimitedState = {
eightyIsShown: boolean;
hundredIsShown: boolean;
eightyLabel: string;
eightyModalLimitType: string;
eightyModalTitle: string;
hundredLabel: string;
hundredModalTitle: string;
hundredModalLimitType: string;
}
const limitState = computed((): LimitedState => {
const result: LimitedState = {
eightyIsShown: false,
hundredIsShown: false,
eightyLabel: '',
eightyModalLimitType: '',
eightyModalTitle: '',
hundredLabel: '',
hundredModalTitle: '',
hundredModalLimitType: '',
};
if (usersStore.state.user.paidTier || isAccountFrozen.value) {
return result;
}
const currentLimits = projectsStore.state.currentLimits;
const limitTypeArr = [
{ name: 'egress', usedPercent: Math.round(currentLimits.bandwidthUsed * 100 / currentLimits.bandwidthLimit) },
{ name: 'storage', usedPercent: Math.round(currentLimits.storageUsed * 100 / currentLimits.storageLimit) },
{ name: 'segment', usedPercent: Math.round(currentLimits.segmentUsed * 100 / currentLimits.segmentLimit) },
];
const hundredPercent: string[] = [];
const eightyPercent: string[] = [];
limitTypeArr.forEach((limitType) => {
if (limitType.usedPercent >= 80) {
if (limitType.usedPercent >= 100) {
hundredPercent.push(limitType.name);
} else {
eightyPercent.push(limitType.name);
}
}
});
if (eightyPercent.length !== 0) {
result.eightyIsShown = true;
const eightyPercentString = eightyPercent.join(' and ');
result.eightyLabel = `You've used 80% of your ${eightyPercentString} limit. Avoid interrupting your usage by upgrading your account.`;
result.eightyModalTitle = `80% ${eightyPercentString} limit used`;
result.eightyModalLimitType = eightyPercentString;
}
if (hundredPercent.length !== 0) {
result.hundredIsShown = true;
const hundredPercentString = hundredPercent.join(' and ');
result.hundredLabel = `URGENT: Youve reached the ${hundredPercentString} limit for your project. Upgrade to avoid any service interruptions.`;
result.hundredModalTitle = `URGENT: Youve reached the ${hundredPercentString} limit for your project.`;
result.hundredModalLimitType = hundredPercentString;
}
return result;
});
/**
* Indicates if account was frozen due to billing issues.
*/
@ -193,20 +87,8 @@ const joinedWhileAgo = computed((): boolean => {
* Opens add payment method modal.
*/
function togglePMModal(): void {
isHundredLimitModalShown.value = false;
isEightyLimitModalShown.value = false;
if (!usersStore.state.user.paidTier) {
appStore.updateActiveModal(MODALS.upgradeAccount);
}
}
function setIsEightyLimitModalShown(value: boolean): void {
isEightyLimitModalShown.value = value;
}
function setIsHundredLimitModalShown(value: boolean): void {
isHundredLimitModalShown.value = value;
if (usersStore.state.user.paidTier) return;
appStore.updateActiveModal(MODALS.upgradeAccount);
}
/**
@ -224,9 +106,7 @@ async function redirectToBillingPage(): Promise<void> {
&__upgrade,
&__project-limit,
&__freeze,
&__warning,
&__hundred-limit,
&__eighty-limit {
&__warning {
margin: 20px 0 0;
box-shadow: 0 0 20px rgb(0 0 0 / 4%);
}

View File

@ -117,7 +117,6 @@ import { computed, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useNotify } from '@/utils/hooks';
import MyAccountButton from '@/views/all-dashboard/components/MyAccountButton.vue';
import { AnalyticsErrorEventSource, AnalyticsEvent } from '@/utils/constants/analyticsEventNames';
import { RouteConfig } from '@/types/router';
import { User } from '@/types/users';
@ -135,6 +134,7 @@ import { useObjectBrowserStore } from '@/store/modules/objectBrowserStore';
import { useConfigStore } from '@/store/modules/configStore';
import { useAnalyticsStore } from '@/store/modules/analyticsStore';
import MyAccountButton from '@/views/all-dashboard/components/MyAccountButton.vue';
import VButton from '@/components/common/VButton.vue';
import LogoIcon from '@/../static/images/logo.svg';

View File

@ -91,18 +91,18 @@ import {
} from '@/utils/constants/analyticsEventNames';
import { User } from '@/types/users';
import { MODALS } from '@/utils/constants/appStatePopUps';
import EmptyProjectItem from '@/views/all-dashboard/components/EmptyProjectItem.vue';
import ProjectItem from '@/views/all-dashboard/components/ProjectItem.vue';
import ProjectInvitationItem from '@/views/all-dashboard/components/ProjectInvitationItem.vue';
import { useUsersStore } from '@/store/modules/usersStore';
import { useAppStore } from '@/store/modules/appStore';
import { useProjectsStore } from '@/store/modules/projectsStore';
import { useConfigStore } from '@/store/modules/configStore';
import ProjectsTable from '@/views/all-dashboard/components/ProjectsTable.vue';
import AllProjectsDashboardBanners from '@/views/all-dashboard/components/AllProjectsDashboardBanners.vue';
import { useResize } from '@/composables/resize';
import { useAnalyticsStore } from '@/store/modules/analyticsStore';
import EmptyProjectItem from '@/views/all-dashboard/components/EmptyProjectItem.vue';
import ProjectItem from '@/views/all-dashboard/components/ProjectItem.vue';
import ProjectInvitationItem from '@/views/all-dashboard/components/ProjectInvitationItem.vue';
import ProjectsTable from '@/views/all-dashboard/components/ProjectsTable.vue';
import AllProjectsDashboardBanners from '@/views/all-dashboard/components/AllProjectsDashboardBanners.vue';
import VButton from '@/components/common/VButton.vue';
import VChip from '@/components/common/VChip.vue';

View File

@ -33,9 +33,9 @@ import { computed } from 'vue';
import { Project, ProjectInvitation } from '@/types/projects';
import { useProjectsStore } from '@/store/modules/projectsStore';
import ProjectTableItem from '@/views/all-dashboard/components/ProjectTableItem.vue';
import ProjectTableInvitationItem from '@/views/all-dashboard/components/ProjectTableInvitationItem.vue';
import VTable from '@/components/common/VTable.vue';
const projectsStore = useProjectsStore();

View File

@ -27,48 +27,31 @@
<v-banner
v-if="isAccountFrozen && !isLoading && dashboardContent"
title="Your account was frozen due to billing issues."
message="Please update your payment information."
link-text="To Billing Page"
severity="critical"
:dashboard-ref="dashboardContent"
>
<template #text>
<p class="medium">Your account was frozen due to billing issues. Please update your payment information.</p>
<p class="link" @click.stop.self="redirectToBillingPage">To Billing Page</p>
</template>
</v-banner>
:on-link-click="redirectToBillingPage"
/>
<v-banner
v-if="isAccountWarned && !isLoading && dashboardContent"
title="Your account will be frozen soon due to billing issues."
message="Please update your payment information."
link-text="To Billing Page"
severity="warning"
:dashboard-ref="dashboardContent"
>
<template #text>
<p class="medium">Your account will be frozen soon due to billing issues. Please update your payment information.</p>
<p class="link" @click.stop.self="redirectToBillingPage">To Billing Page</p>
</template>
</v-banner>
:on-link-click="redirectToBillingPage"
/>
<v-banner
v-if="limitState.hundredIsShown && !isLoading && dashboardContent"
severity="critical"
:on-click="() => setIsHundredLimitModalShown(true)"
<limit-warning-banners
v-if="dashboardContent"
:reached-thresholds="reachedThresholds"
:dashboard-ref="dashboardContent"
>
<template #text>
<p class="medium">{{ limitState.hundredLabel }}</p>
<p class="link" @click.stop.self="togglePMModal">Upgrade now</p>
</template>
</v-banner>
<v-banner
v-if="limitState.eightyIsShown && !isLoading && dashboardContent"
severity="warning"
:on-click="() => setIsEightyLimitModalShown(true)"
:dashboard-ref="dashboardContent"
>
<template #text>
<p class="medium">{{ limitState.eightyLabel }}</p>
<p class="link" @click.stop.self="togglePMModal">Upgrade now</p>
</template>
</v-banner>
:on-upgrade-click="togglePMModal"
:on-banner-click="thresh => limitModalThreshold = thresh"
/>
</div>
<router-view class="dashboard__wrap__main-area__content-wrap__container__content" />
<div class="dashboard__wrap__main-area__content-wrap__container__content banners-bottom">
@ -89,19 +72,10 @@
<p>Remaining session time: <b class="dashboard__debug-timer__bold">{{ session.debugTimerText.value }}</b></p>
</div>
<limit-warning-modal
v-if="isHundredLimitModalShown && !isLoading"
severity="critical"
:on-close="() => setIsHundredLimitModalShown(false)"
:title="limitState.hundredModalTitle"
:limit-type="limitState.hundredModalLimitType"
:on-upgrade="togglePMModal"
/>
<limit-warning-modal
v-if="isEightyLimitModalShown && !isLoading"
severity="warning"
:on-close="() => setIsEightyLimitModalShown(false)"
:title="limitState.eightyModalTitle"
:limit-type="limitState.eightyModalLimitType"
v-if="limitModalThreshold && !isLoading"
:reached-thresholds="reachedThresholds"
:threshold="limitModalThreshold"
:on-close="() => limitModalThreshold = null"
:on-upgrade="togglePMModal"
/>
<AllModals />
@ -112,13 +86,13 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { computed, onMounted, ref, toRaw } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ErrorUnauthorized } from '@/api/errors/ErrorUnauthorized';
import { RouteConfig } from '@/types/router';
import { CouponType } from '@/types/coupons';
import { Project } from '@/types/projects';
import { LimitThreshold, LimitType, Project, LimitThresholdsReached } from '@/types/projects';
import { FetchState } from '@/utils/constants/fetchStateEnum';
import { LocalData } from '@/utils/localData';
import { User } from '@/types/users';
@ -130,9 +104,10 @@ import { useUsersStore } from '@/store/modules/usersStore';
import { useBillingStore } from '@/store/modules/billingStore';
import { useAppStore } from '@/store/modules/appStore';
import { useAccessGrantsStore } from '@/store/modules/accessGrantsStore';
import { useProjectsStore } from '@/store/modules/projectsStore';
import { DEFAULT_PROJECT_LIMITS, useProjectsStore } from '@/store/modules/projectsStore';
import { useConfigStore } from '@/store/modules/configStore';
import { useAnalyticsStore } from '@/store/modules/analyticsStore';
import { Memory } from '@/utils/bytesSize';
import UploadNotification from '@/components/notifications/UploadNotification.vue';
import NavigationArea from '@/components/navigation/NavigationArea.vue';
@ -147,6 +122,7 @@ import UpgradeNotification from '@/components/notifications/UpgradeNotification.
import ProjectInvitationBanner from '@/components/notifications/ProjectInvitationBanner.vue';
import BrandedLoader from '@/components/common/BrandedLoader.vue';
import ObjectsUploadingModal from '@/components/modals/objectUpload/ObjectsUploadingModal.vue';
import LimitWarningBanners from '@/views/dashboard/components/LimitWarningBanners.vue';
import WarningIcon from '@/../static/images/notifications/circleWarning.svg';
@ -166,8 +142,7 @@ const route = useRoute();
// Minimum number of recovery codes before the recovery code warning bar is shown.
const recoveryCodeWarningThreshold = 4;
const isHundredLimitModalShown = ref<boolean>(false);
const isEightyLimitModalShown = ref<boolean>(false);
const limitModalThreshold = ref<LimitThreshold | null>(null);
const dashboardContent = ref<HTMLElement | null>(null);
@ -193,77 +168,60 @@ const isAccountWarned = computed((): boolean => {
});
/**
* Returns all needed information for limit banner and modal when bandwidth or storage close to limits.
* Returns which limit thresholds have been reached by which usage limit type.
*/
type LimitedState = {
eightyIsShown: boolean;
hundredIsShown: boolean;
eightyLabel: string;
eightyModalLimitType: string;
eightyModalTitle: string;
hundredLabel: string;
hundredModalTitle: string;
hundredModalLimitType: string;
}
const limitState = computed((): LimitedState => {
const result: LimitedState = {
eightyIsShown: false,
hundredIsShown: false,
eightyLabel: '',
eightyModalLimitType: '',
eightyModalTitle: '',
hundredLabel: '',
hundredModalTitle: '',
hundredModalLimitType: '',
const reachedThresholds = computed((): LimitThresholdsReached => {
const reached: LimitThresholdsReached = {
Eighty: [],
Hundred: [],
CustomEighty: [],
CustomHundred: [],
};
if (usersStore.state.user.paidTier || isAccountFrozen.value) {
return result;
}
const currentLimits = projectsStore.state.currentLimits;
const config = configStore.state.config;
const limitTypeArr = [
{ name: 'egress', usedPercent: Math.round(currentLimits.bandwidthUsed * 100 / currentLimits.bandwidthLimit) },
{ name: 'storage', usedPercent: Math.round(currentLimits.storageUsed * 100 / currentLimits.storageLimit) },
{ name: 'segment', usedPercent: Math.round(currentLimits.segmentUsed * 100 / currentLimits.segmentLimit) },
];
if (isAccountFrozen.value || currentLimits === DEFAULT_PROJECT_LIMITS) return reached;
const hundredPercent = [] as string[];
const eightyPercent = [] as string[];
type LimitInfo = {
used: number;
currentLimit: number;
paidLimit?: number;
};
limitTypeArr.forEach((limitType) => {
if (limitType.usedPercent >= 80) {
if (limitType.usedPercent >= 100) {
hundredPercent.push(limitType.name);
} else {
eightyPercent.push(limitType.name);
const info: Record<LimitType, LimitInfo> = {
Storage: {
used: currentLimits.storageUsed,
currentLimit: currentLimits.storageLimit,
paidLimit: parseConfigLimit(config.defaultPaidStorageLimit),
},
Egress: {
used: currentLimits.bandwidthUsed,
currentLimit: currentLimits.bandwidthLimit,
paidLimit: parseConfigLimit(config.defaultPaidBandwidthLimit),
},
Segment: {
used: currentLimits.segmentUsed,
currentLimit: currentLimits.segmentLimit,
},
};
(Object.entries(info) as [LimitType, LimitInfo][]).forEach(([limitType, info]) => {
const maxLimit = (isPaidTier.value && info.paidLimit) ? Math.max(info.currentLimit, info.paidLimit) : info.currentLimit;
if (info.used >= maxLimit) {
reached.Hundred.push(limitType);
} else if (info.used >= 0.8 * maxLimit) {
reached.Eighty.push(limitType);
} else if (isPaidTier.value) {
if (info.used >= info.currentLimit) {
reached.CustomHundred.push(limitType);
} else if (info.used >= 0.8 * info.currentLimit) {
reached.CustomEighty.push(limitType);
}
}
});
if (eightyPercent.length !== 0) {
result.eightyIsShown = true;
const eightyPercentString = eightyPercent.join(' and ');
result.eightyLabel = `You've used 80% of your ${eightyPercentString} limit. Avoid interrupting your usage by upgrading your account.`;
result.eightyModalTitle = `80% ${eightyPercentString} limit used`;
result.eightyModalLimitType = eightyPercentString;
}
if (hundredPercent.length !== 0) {
result.hundredIsShown = true;
const hundredPercentString = hundredPercent.join(' and ');
result.hundredLabel = `URGENT: Youve reached the ${hundredPercentString} limit for your project. Upgrade to avoid any service interruptions.`;
result.hundredModalTitle = `URGENT: Youve reached the ${hundredPercentString} limit for your project.`;
result.hundredModalLimitType = hundredPercentString;
}
return result;
return reached;
});
/**
@ -275,7 +233,7 @@ const isNavigationHidden = computed((): boolean => {
/* whether the paid tier banner should be shown */
const isPaidTierBannerShown = computed((): boolean => {
return !usersStore.state.user.paidTier
return !isPaidTier.value
&& !isOnboardingTour.value
&& joinedWhileAgo.value
&& isDashboardPage.value;
@ -346,6 +304,20 @@ const isDashboardPage = computed((): boolean => {
return route.name === RouteConfig.ProjectDashboard.name;
});
/**
* Returns whether user is in the paid tier.
*/
const isPaidTier = computed((): boolean => {
return usersStore.state.user.paidTier;
});
/**
* Returns the URL for the general request page from the store.
*/
const requestURL = computed((): string => {
return configStore.state.config.generalRequestURL;
});
/**
* Closes upload large files warning notification.
*/
@ -380,14 +352,6 @@ function selectProject(fetchedProjects: Project[]): void {
storeProject(fetchedProjects[0].id);
}
function setIsEightyLimitModalShown(value: boolean): void {
isEightyLimitModalShown.value = value;
}
function setIsHundredLimitModalShown(value: boolean): void {
isHundredLimitModalShown.value = value;
}
/**
* Toggles MFA recovery modal visibility.
*/
@ -411,12 +375,8 @@ async function generateNewMFARecoveryCodes(): Promise<void> {
* Opens add payment method modal.
*/
function togglePMModal(): void {
isHundredLimitModalShown.value = false;
isEightyLimitModalShown.value = false;
if (!usersStore.state.user.paidTier) {
appStore.updateActiveModal(MODALS.upgradeAccount);
}
if (isPaidTier.value) return;
appStore.updateActiveModal(MODALS.upgradeAccount);
}
/**
@ -426,6 +386,14 @@ async function redirectToBillingPage(): Promise<void> {
await router.push(RouteConfig.Account.with(RouteConfig.Billing.with(RouteConfig.BillingPaymentMethods)).path);
}
/**
* Parses limit value from config, returning it as a byte amount.
*/
function parseConfigLimit(limit: string): number {
const [value, unit] = limit.split(' ');
return parseFloat(value) * Memory[unit === 'B' ? 'Bytes' : unit];
}
/**
* Lifecycle hook after initial render.
* Pre-fetches user`s and project information.

View File

@ -0,0 +1,127 @@
// Copyright (C) 2023 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<v-banner
v-for="threshold in activeThresholds"
:key="threshold"
:title="bannerText[threshold].value.title"
:message="bannerText[threshold].value.message"
:severity="isHundred(threshold) ? 'critical' : 'warning'"
:link-text="!isPaidTier ? 'Upgrade Now' : isCustom(threshold) ? 'Edit Limits' : 'Contact Support'"
:href="(isPaidTier && !isCustom(threshold)) ? requestURL : undefined"
:dashboard-ref="dashboardRef"
:on-click="() => onBannerClick(threshold)"
:on-link-click="() => onLinkClick(threshold)"
/>
</template>
<script setup lang="ts">
import { computed, ComputedRef } from 'vue';
import { useRouter } from 'vue-router';
import { LimitThreshold, LimitThresholdsReached } from '@/types/projects';
import { humanizeArray } from '@/utils/strings';
import { useUsersStore } from '@/store/modules/usersStore';
import { RouteConfig } from '@/types/router';
import { useConfigStore } from '@/store/modules/configStore';
import { useAnalyticsStore } from '@/store/modules/analyticsStore';
import VBanner from '@/components/common/VBanner.vue';
const usersStore = useUsersStore();
const configStore = useConfigStore();
const analyticsStore = useAnalyticsStore();
const router = useRouter();
const props = defineProps<{
reachedThresholds: LimitThresholdsReached;
onBannerClick: (threshold: LimitThreshold) => void;
onUpgradeClick: () => void;
dashboardRef: HTMLElement;
}>();
/**
* Returns the limit thresholds that have been reached by at least 1 usage type.
*/
const activeThresholds = computed<LimitThreshold[]>(() => {
return (Object.keys(LimitThreshold) as LimitThreshold[]).filter(t => props.reachedThresholds[t].length);
});
/**
* Returns whether user is in the paid tier.
*/
const isPaidTier = computed<boolean>(() => {
return usersStore.state.user.paidTier;
});
type BannerText = {
title: string;
message: string;
};
const bannerText = {} as Record<LimitThreshold, ComputedRef<BannerText>>;
(Object.keys(LimitThreshold) as LimitThreshold[]).forEach(thresh => {
bannerText[thresh] = computed<BannerText>(() => {
let limitText = humanizeArray(props.reachedThresholds[thresh]).toLowerCase() + ' limit';
if (props.reachedThresholds[thresh].length > 1) limitText += 's';
const custom = isCustom(thresh);
const hundred = isHundred(thresh);
const title = hundred
? `URGENT: You've reached the ${limitText} for your project.`
: `You've used 80% of your ${limitText}.`;
let message: string;
if (!isPaidTier.value) {
message = hundred
? 'Upgrade to avoid any service interruptions.'
: 'Avoid interrupting your usage by upgrading your account.';
} else {
message = custom
? 'You can increase your limits in the Project Settings page.'
: 'Contact support to avoid any service interruptions.';
}
return { title, message };
});
});
/**
* Returns the URL for the general request page from the store.
*/
const requestURL = computed<string>(() => {
return configStore.state.config.generalRequestURL;
});
/**
* Returns whether the threshold represents 100% usage.
*/
function isHundred(threshold: LimitThreshold): boolean {
return threshold.toLowerCase().includes('hundred');
}
/**
* Returns whether the threshold is for a custom limit.
*/
function isCustom(threshold: LimitThreshold): boolean {
return threshold.toLowerCase().includes('custom');
}
/**
* Handles click event for link appended to banner.
*/
function onLinkClick(threshold: LimitThreshold): void {
if (!isPaidTier.value) {
props.onUpgradeClick();
return;
}
if (isCustom(threshold)) {
analyticsStore.pageVisit(RouteConfig.EditProjectDetails.path);
router.push(RouteConfig.EditProjectDetails.path);
return;
}
}
</script>