web/satellite/vuetify-poc: add upgrade account dialog

This change adds the account upgrade dialog with the first information
step. It allows a user to toggle on this dialog from the account
dropdown or the dashboard.

Issue: https://github.com/storj/storj/issues/6288
https://github.com/storj/storj/issues/6292

Change-Id: Ide87612994c999759150c8aa85ead3866e9df1f5
This commit is contained in:
Wilfred Asomani 2023-09-20 11:15:52 +00:00
parent c14e4b1eb4
commit 1e3da9f276
14 changed files with 435 additions and 13 deletions

View File

@ -14,7 +14,7 @@
</v-col>
<v-col>
<h4 class="text-right">{{ available }}</h4>
<p class="text-right text-medium-emphasis"><small>{{ cta }}</small></p>
<p class="text-cursor-pointer text-right text-medium-emphasis" @click="emit('ctaClick')"><small>{{ cta }}</small></p>
</v-col>
</v-row>
</v-card-item>
@ -32,4 +32,8 @@ const props = defineProps<{
available: string;
cta: string;
}>();
const emit = defineEmits<{
ctaClick: [];
}>();
</script>

View File

@ -48,7 +48,7 @@
</v-form>
</v-card-item>
<v-card-item class="px-8 py-0">
<a class="text-decoration-underline" style="cursor: pointer;" @click="toggleRecoveryCodeState">
<a class="text-decoration-underline text-cursor-pointer" @click="toggleRecoveryCodeState">
{{ useRecoveryCode ? "or use 2FA code" : "or use a recovery code" }}
</a>
</v-card-item>

View File

@ -49,7 +49,7 @@
</v-form>
</v-card-item>
<v-card-item class="px-8 py-0">
<a class="text-decoration-underline" style="cursor: pointer;" @click="toggleRecoveryCodeState">
<a class="text-decoration-underline text-cursor-pointer" @click="toggleRecoveryCodeState">
{{ useRecoveryCode ? "or use 2FA code" : "or use a recovery code" }}
</a>
</v-card-item>

View File

@ -0,0 +1,34 @@
// Copyright (C) 2023 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<v-row class="pa-0 flex-nowrap">
<v-col class="pa-2" cols="1">
<img v-if="!isPro" src="@/../static/images/modals/upgradeFlow/greyCheckmark.svg" alt="checkmark">
<img v-else src="@/../static/images/modals/upgradeFlow/greenCheckmark.svg" alt="checkmark">
</v-col>
<v-col class="pa-2" cols="11">
<p class="font-weight-bold">
{{ title }}
<v-tooltip v-if="$slots.moreInfo" max-width="200px" location="top" activator="parent">
<slot name="moreInfo" />
</v-tooltip>
</p>
<p>{{ info }}</p>
</v-col>
</v-row>
</template>
<script setup lang="ts">
import { VCol, VRow, VTooltip } from 'vuetify/components';
const props = withDefaults(defineProps<{
isPro?: boolean;
title: string;
info: string;
}>(), {
isPro: false,
title: '',
info: '',
});
</script>

View File

@ -0,0 +1,181 @@
// Copyright (C) 2023 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<v-dialog
v-model="model"
width="auto"
scrollable
min-width="460px"
:max-width="step === UpgradeAccountStep.Info || step === UpgradeAccountStep.PricingPlanSelection ? '700px' : '460px'"
transition="fade-transition"
:persistent="loading"
>
<v-card ref="content" rounded="xlg">
<v-card-item class="pl-7 py-4">
<template v-if="step === UpgradeAccountStep.Success" #prepend>
<img class="d-block" src="@/../static/images/modals/upgradeFlow/success.svg" alt="success">
</template>
<v-card-title class="font-weight-bold">{{ stepTitles[step] }}</v-card-title>
<template #append>
<v-btn
icon="$close"
variant="text"
size="small"
color="default"
@click="model = false"
/>
</template>
</v-card-item>
<v-divider class="mx-8" />
<v-card-item class="px-8 py-4">
<v-window v-model="step">
<v-window-item :value="UpgradeAccountStep.Info">
<UpgradeInfoStep
:loading="loading"
@upgrade="setSecondStep"
/>
</v-window-item>
</v-window>
</v-card-item>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { VBtn, VCard, VCardItem, VCardTitle, VDialog, VDivider, VWindow, VWindowItem } from 'vuetify/components';
import { useConfigStore } from '@/store/modules/configStore';
import { useAppStore } from '@poc/store/appStore';
import { useUsersStore } from '@/store/modules/usersStore';
import { useBillingStore } from '@/store/modules/billingStore';
import { useNotify } from '@/utils/hooks';
import { PaymentsHttpApi } from '@/api/payments';
import { AnalyticsErrorEventSource, AnalyticsEvent } from '@/utils/constants/analyticsEventNames';
import { User } from '@/types/users';
import { useAnalyticsStore } from '@/store/modules/analyticsStore';
import { PricingPlanInfo } from '@/types/common';
import UpgradeInfoStep from '@poc/components/dialogs/upgradeAccountFlow/UpgradeInfoStep.vue';
enum UpgradeAccountStep {
Info = 'infoStep',
Options = 'optionsStep',
AddCC = 'addCCStep',
AddTokens = 'addTokensStep',
Success = 'successStep',
PricingPlanSelection = 'pricingPlanSelectionStep',
PricingPlan = 'pricingPlanStep',
}
const analyticsStore = useAnalyticsStore();
const configStore = useConfigStore();
const appStore = useAppStore();
const usersStore = useUsersStore();
const billingStore = useBillingStore();
const notify = useNotify();
const payments: PaymentsHttpApi = new PaymentsHttpApi();
const step = ref<UpgradeAccountStep>(UpgradeAccountStep.Info);
const loading = ref<boolean>(false);
const plan = ref<PricingPlanInfo | null>(null);
const content = ref<HTMLElement | null>(null);
const model = computed<boolean>({
get: () => appStore.state.isUpgradeFlowDialogShown,
set: value => appStore.toggleUpgradeFlow(value),
});
const stepTitles = computed(() => {
return {
[UpgradeAccountStep.Info]: 'Your account',
[UpgradeAccountStep.Options]: 'Upgrade to Pro',
[UpgradeAccountStep.AddCC]: 'Add Credit Card',
[UpgradeAccountStep.AddTokens]: 'Add tokens',
[UpgradeAccountStep.Success]: 'Success',
[UpgradeAccountStep.PricingPlanSelection]: 'Upgrade',
[UpgradeAccountStep.PricingPlan]: plan.value?.title || '',
};
});
/**
* Claims wallet and sets add token step.
*/
async function onAddTokens(): Promise<void> {
if (loading.value) return;
loading.value = true;
try {
await billingStore.claimWallet();
analyticsStore.eventTriggered(AnalyticsEvent.ADD_FUNDS_CLICKED);
setStep(UpgradeAccountStep.AddTokens);
} catch (error) {
notify.notifyError(error, AnalyticsErrorEventSource.UPGRADE_ACCOUNT_MODAL);
}
loading.value = false;
}
/**
* Sets specific flow step.
*/
function setStep(s: UpgradeAccountStep) {
step.value = s;
}
function onSelectPricingPlan(p: PricingPlanInfo) {
plan.value = p;
setStep(UpgradeAccountStep.PricingPlan);
}
/**
* Sets second step in the flow (after user clicks to upgrade).
* Most users will go to the Options step, but if a user is eligible for a
* pricing plan (and pricing plans are enabled), they will be sent to the PricingPlan step.
*/
async function setSecondStep() {
if (loading.value) return;
loading.value = true;
const user: User = usersStore.state.user;
const pricingPkgsEnabled = configStore.state.config.pricingPackagesEnabled;
if (!pricingPkgsEnabled || !user.partner) {
setStep(UpgradeAccountStep.Options);
loading.value = false;
return;
}
let pkgAvailable = false;
try {
pkgAvailable = await payments.pricingPackageAvailable();
} catch (error) {
notify.notifyError(error, null);
setStep(UpgradeAccountStep.Options);
loading.value = false;
return;
}
if (!pkgAvailable) {
setStep(UpgradeAccountStep.Options);
loading.value = false;
return;
}
setStep(UpgradeAccountStep.PricingPlanSelection);
loading.value = false;
}
watch(content, (value) => {
if (!value) {
setStep(UpgradeAccountStep.Info);
plan.value = null;
}
});
</script>

View File

@ -0,0 +1,139 @@
// Copyright (C) 2023 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<v-row>
<v-col v-if="!smAndDown" cols="6">
<h4 class="font-weight-bold mb-2">Free</h4>
<v-btn
block
disabled
color="grey"
>
Current
</v-btn>
<div class="border-sm rounded-lg pa-4 mt-3 mb-3">
<InfoBullet title="Projects" :info="freeProjects" />
<InfoBullet title="Storage" :info="`${freeUsageValue(user.projectStorageLimit)} limit`" />
<InfoBullet title="Egress" :info="`${freeUsageValue(user.projectBandwidthLimit)} limit`" />
<InfoBullet title="Segments" :info="`${user.projectSegmentLimit.toLocaleString()} segments limit`" />
<InfoBullet title="Link Sharing" info="Link sharing with Storj domain" />
</div>
</v-col>
<v-col :cols="smAndDown ? 12 : '6'">
<h4 class="font-weight-bold mb-2">Pro Account</h4>
<v-btn
class="mb-1"
block
color="success"
:loading="loading"
@click="emit('upgrade')"
>
Upgrade to Pro
</v-btn>
<div class="border-sm rounded-lg pa-4 mt-3 mb-3">
<InfoBullet is-pro title="Projects" :info="projectsInfo" />
<InfoBullet is-pro :title="storagePrice" :info="storagePriceInfo" />
<InfoBullet is-pro :title="downloadPrice" :info="downloadInfo">
<template v-if="downloadMoreInfo" #moreInfo>
<p>{{ downloadMoreInfo }}</p>
</template>
</InfoBullet>
<InfoBullet is-pro title="Segments" :info="segmentInfo">
<template #moreInfo>
<a
class="text-surface"
href="https://docs.storj.io/dcs/billing-payment-and-accounts-1/pricing/billing-and-payment"
target="_blank"
rel="noopener noreferrer"
>
Learn more about segments
</a>
</template>
</InfoBullet>
<InfoBullet is-pro title="Secure Custom Domains (HTTPS)" info="Link sharing with your domain" />
</div>
</v-col>
</v-row>
</template>
<script setup lang="ts">
import { computed, onBeforeMount, ref } from 'vue';
import { VBtn, VCol, VRow } from 'vuetify/components';
import { useDisplay } from 'vuetify';
import { useUsersStore } from '@/store/modules/usersStore';
import { useNotify } from '@/utils/hooks';
import { User } from '@/types/users';
import { Size } from '@/utils/bytesSize';
import InfoBullet from '@poc/components/dialogs/upgradeAccountFlow/InfoBullet.vue';
const usersStore = useUsersStore();
const notify = useNotify();
const { smAndDown } = useDisplay();
const props = defineProps<{
loading: boolean;
}>();
const emit = defineEmits<{
upgrade: [];
}>();
const storagePrice = ref<string>('Storage $0.004 GB / month');
const storagePriceInfo = ref<string>('25 GB free included');
const segmentInfo = ref<string>('$0.0000088 segment per month');
const projectsInfo = ref<string>('3 projects + more on request');
const downloadPrice = ref<string>('Egress $0.007 GB');
const downloadInfo = ref<string>('25 GB free every month');
const downloadMoreInfo = ref<string>('');
/**
* Returns user entity from store.
*/
const user = computed((): User => {
return usersStore.state.user;
});
/**
* Returns formatted free projects count.
*/
const freeProjects = computed((): string => {
return `${user.value.projectLimit} project${user.value.projectLimit > 1 ? 's' : ''}`;
});
/**
* Returns formatted free usage value.
*/
function freeUsageValue(value: number): string {
const size = new Size(value);
return `${size.formattedBytes} ${size.label}`;
}
/**
* Lifecycle hook before initial render.
* If applicable, loads additional clarifying text based on user partner.
*/
onBeforeMount(async () => {
try {
const partner = usersStore.state.user.partner;
const config = (await import('@poc/components/dialogs/upgradeAccountFlow/upgradeConfig.json')).default;
if (partner && config[partner]) {
if (config[partner].storagePrice) {
storagePrice.value = config[partner].storagePrice;
}
if (config[partner].downloadInfo) {
downloadInfo.value = config[partner].downloadInfo;
}
if (config[partner].downloadMoreInfo) {
downloadMoreInfo.value = config[partner].downloadMoreInfo;
}
}
} catch (e) {
notify.error('No configuration file for page.', null);
}
});
</script>

View File

@ -7,6 +7,8 @@
<default-bar show-nav-drawer-button />
<account-nav />
<default-view />
<UpgradeAccountDialog />
</session-wrapper>
</v-app>
</template>
@ -25,6 +27,7 @@ import { useNotify } from '@/utils/hooks';
import { AnalyticsErrorEventSource } from '@/utils/constants/analyticsEventNames';
import SessionWrapper from '@poc/components/utils/SessionWrapper.vue';
import UpgradeAccountDialog from '@poc/components/dialogs/upgradeAccountFlow/UpgradeAccountDialog.vue';
const appStore = useAppStore();
const usersStore = useUsersStore();

View File

@ -6,6 +6,8 @@
<session-wrapper>
<default-bar />
<default-view />
<UpgradeAccountDialog />
</session-wrapper>
</v-app>
</template>
@ -22,6 +24,7 @@ import { AnalyticsErrorEventSource } from '@/utils/constants/analyticsEventNames
import { useNotify } from '@/utils/hooks';
import SessionWrapper from '@poc/components/utils/SessionWrapper.vue';
import UpgradeAccountDialog from '@poc/components/dialogs/upgradeAccountFlow/UpgradeAccountDialog.vue';
const usersStore = useUsersStore();
const notify = useNotify();

View File

@ -106,7 +106,7 @@
<v-divider class="my-2" />
<v-list-item link class="my-1 rounded-lg" @click="closeSideNav">
<v-list-item v-if="!isPaidTier" link class="my-1 rounded-lg" @click="toggleUpgradeFlow">
<template #prepend>
<img src="@poc/assets/icon-upgrade.svg" alt="Upgrade">
</template>
@ -245,6 +245,11 @@ function closeSideNav(): void {
if (mdAndDown.value) appStore.toggleNavigationDrawer(false);
}
function toggleUpgradeFlow(): void {
closeSideNav();
appStore.toggleUpgradeFlow(true);
}
/**
* Logs out user and navigates to login page.
*/

View File

@ -10,6 +10,8 @@
<default-bar show-nav-drawer-button />
<ProjectNav />
<default-view />
<UpgradeAccountDialog />
</session-wrapper>
</v-app>
</template>
@ -36,6 +38,7 @@ import { AnalyticsErrorEventSource } from '@/utils/constants/analyticsEventNames
import { useNotify } from '@/utils/hooks';
import SessionWrapper from '@poc/components/utils/SessionWrapper.vue';
import UpgradeAccountDialog from '@poc/components/dialogs/upgradeAccountFlow/UpgradeAccountDialog.vue';
const router = useRouter();
const route = useRoute();

View File

@ -6,6 +6,7 @@ import { reactive } from 'vue';
class AppState {
public isNavigationDrawerShown = true;
public isUpgradeFlowDialogShown = false;
public pathBeforeAccountPage: string | null = null;
}
@ -16,18 +17,24 @@ export const useAppStore = defineStore('vuetifyApp', () => {
state.isNavigationDrawerShown = isShown ?? !state.isNavigationDrawerShown;
}
function toggleUpgradeFlow(isShown?: boolean): void {
state.isUpgradeFlowDialogShown = isShown ?? !state.isUpgradeFlowDialogShown;
}
function setPathBeforeAccountPage(path: string) {
state.pathBeforeAccountPage = path;
}
function clear(): void {
state.isNavigationDrawerShown = true;
state.isUpgradeFlowDialogShown = false;
state.pathBeforeAccountPage = null;
}
return {
state,
toggleNavigationDrawer,
toggleUpgradeFlow,
setPathBeforeAccountPage,
clear,
};

View File

@ -318,4 +318,9 @@ table {
// Positions
.pos-relative {
position: relative !important;
}
// text styles
.text-cursor-pointer {
cursor: pointer;
}

View File

@ -3,11 +3,20 @@
<template>
<v-container>
<PageTitleComponent title="Project Overview" />
<PageSubtitleComponent
:subtitle="`Your ${limits.objectCount.toLocaleString()} files are stored in ${limits.segmentCount.toLocaleString()} segments around the world.`"
link="https://docs.storj.io/dcs/pricing#per-segment-fee"
/>
<v-row align="center" justify="space-between">
<v-col cols="12" md="auto">
<PageTitleComponent title="Project Overview" />
<PageSubtitleComponent
:subtitle="`Your ${limits.objectCount.toLocaleString()} files are stored in ${limits.segmentCount.toLocaleString()} segments around the world.`"
link="https://docs.storj.io/dcs/pricing#per-segment-fee"
/>
</v-col>
<v-col v-if="!isPaidTier" cols="auto">
<v-btn @click="appStore.toggleUpgradeFlow(true)">
Upgrade plan
</v-btn>
</v-col>
</v-row>
<v-row class="d-flex align-center justify-center mt-2">
<v-col cols="12" md="6">
@ -59,10 +68,10 @@
<v-row class="d-flex align-center justify-center">
<v-col cols="12" md="6">
<UsageProgressComponent title="Storage" :progress="storageUsedPercent" :used="`${usedLimitFormatted(limits.storageUsed)} Used`" :limit="`Limit: ${usedLimitFormatted(limits.storageLimit)}`" :available="`${usedLimitFormatted(availableStorage)} Available`" cta="Need more?" />
<UsageProgressComponent title="Storage" :progress="storageUsedPercent" :used="`${usedLimitFormatted(limits.storageUsed)} Used`" :limit="`Limit: ${usedLimitFormatted(limits.storageLimit)}`" :available="`${usedLimitFormatted(availableStorage)} Available`" cta="Need more?" @cta-click="onNeedMoreClicked(LimitToChange.Storage)" />
</v-col>
<v-col cols="12" md="6">
<UsageProgressComponent title="Download" :progress="egressUsedPercent" :used="`${usedLimitFormatted(limits.bandwidthUsed)} Used`" :limit="`Limit: ${usedLimitFormatted(limits.bandwidthLimit)}`" :available="`${usedLimitFormatted(availableEgress)} Available`" cta="Need more?" />
<UsageProgressComponent title="Download" :progress="egressUsedPercent" :used="`${usedLimitFormatted(limits.bandwidthUsed)} Used`" :limit="`Limit: ${usedLimitFormatted(limits.bandwidthLimit)}`" :available="`${usedLimitFormatted(availableEgress)} Available`" cta="Need more?" @cta-click="onNeedMoreClicked(LimitToChange.Bandwidth)" />
</v-col>
<v-col cols="12" md="6">
<UsageProgressComponent title="Segments" :progress="segmentUsedPercent" :used="`${limits.segmentUsed} Used`" :limit="`Limit: ${limits.segmentLimit}`" :available="`${availableSegment} Available`" cta="Learn more" />
@ -76,11 +85,13 @@
<buckets-data-table />
</v-col>
</v-container>
<edit-project-limit-dialog v-model="isEditLimitDialogShown" :limit-type="limitToChange" />
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { VContainer, VRow, VCol, VCard, VCardTitle } from 'vuetify/components';
import { VBtn, VCard, VCardTitle, VCol, VContainer, VRow } from 'vuetify/components';
import { ComponentPublicInstance } from '@vue/runtime-core';
import { useUsersStore } from '@/store/modules/usersStore';
@ -89,11 +100,12 @@ import { useProjectMembersStore } from '@/store/modules/projectMembersStore';
import { useAccessGrantsStore } from '@/store/modules/accessGrantsStore';
import { useBillingStore } from '@/store/modules/billingStore';
import { useBucketsStore } from '@/store/modules/bucketsStore';
import { DataStamp, Project, ProjectLimits } from '@/types/projects';
import { DataStamp, LimitToChange, Project, ProjectLimits } from '@/types/projects';
import { Dimensions, Size } from '@/utils/bytesSize';
import { ChartUtils } from '@/utils/chart';
import { AnalyticsErrorEventSource } from '@/utils/constants/analyticsEventNames';
import { useNotify } from '@/utils/hooks';
import { useAppStore } from '@poc/store/appStore';
import PageTitleComponent from '@poc/components/PageTitleComponent.vue';
import PageSubtitleComponent from '@poc/components/PageSubtitleComponent.vue';
@ -102,7 +114,9 @@ import UsageProgressComponent from '@poc/components/UsageProgressComponent.vue';
import BandwidthChart from '@/components/project/dashboard/BandwidthChart.vue';
import StorageChart from '@/components/project/dashboard/StorageChart.vue';
import BucketsDataTable from '@poc/components/BucketsDataTable.vue';
import EditProjectLimitDialog from '@poc/components/dialogs/EditProjectLimitDialog.vue';
const appStore = useAppStore();
const usersStore = useUsersStore();
const projectsStore = useProjectsStore();
const pmStore = useProjectMembersStore();
@ -114,6 +128,8 @@ const notify = useNotify();
const chartWidth = ref<number>(0);
const chartContainer = ref<ComponentPublicInstance>();
const isEditLimitDialogShown = ref<boolean>(false);
const limitToChange = ref<LimitToChange>(LimitToChange.Storage);
/**
* Returns percent of coupon used.
@ -144,6 +160,13 @@ const couponRemainingPercent = computed((): number => {
return 100 - couponProgress.value;
});
/**
* Whether the user is in paid tier.
*/
const isPaidTier = computed((): boolean => {
return usersStore.state.user.paidTier;
});
/**
* Returns formatted amount.
*/
@ -294,6 +317,20 @@ function getDimension(dataStamps: DataStamp[]): Dimensions {
return new Size(maxValue).label;
}
/**
* Conditionally opens the upgrade dialog
* or the edit limit dialog.
*/
function onNeedMoreClicked(source: LimitToChange): void {
if (!usersStore.state.user.paidTier) {
appStore.toggleUpgradeFlow(true);
return;
}
limitToChange.value = source;
isEditLimitDialogShown.value = true;
}
/**
* Lifecycle hook after initial render.
* Fetches project limits.