web/satellite: show pending transactions during token upgrade flow

Made UI updates to reflect pending token payments during upgrade account flow.
So pending transactions with confirmations count are displayed on Add STORJ Tokens step of upgrade flow.
While this modal is open we make a request to storjscan once in 20 seconds to get recent confirmations count.
When all transactions become confirmed we show success view where a sum of STORJ tokens received is displayed.

Issue:
https://github.com/storj/storj/issues/5978

Change-Id: Icfdc1e5080ed58cea1822cb7d85551ba8064c636
This commit is contained in:
Vitalii 2023-07-14 16:05:45 +03:00
parent 63c8cfe4c3
commit 2d2f1b858e
7 changed files with 297 additions and 30 deletions

View File

@ -15,6 +15,7 @@ import {
TokenAmount,
NativePaymentHistoryItem,
Wallet,
PaymentWithConfirmations,
} from '@/types/payments';
import { HttpClient } from '@/utils/httpClient';
import { Time } from '@/utils/time';
@ -285,6 +286,39 @@ export class PaymentsHttpApi implements PaymentsApi {
return [];
}
/**
* Returns a list of STORJ token payments with confirmations.
*
* @returns list of native token payment items with confirmations
* @throws Error
*/
public async paymentsWithConfirmations(): Promise<PaymentWithConfirmations[]> {
const path = `${this.ROOT_PATH}/wallet/payments-with-confirmations`;
const response = await this.client.get(path);
if (!response.ok) {
throw new Error('Can not list token payment with confirmations');
}
const json = await response.json();
if (json && json.length) {
return json.map(item =>
new PaymentWithConfirmations(
item.to,
parseFloat(item.tokenValue),
parseFloat(item.usdValue),
item.transaction,
new Date(item.timestamp),
parseFloat(item.bonusTokens),
item.status,
item.confirmations,
),
);
}
return [];
}
/**
* applyCouponCode applies a coupon code.
*

View File

@ -2,7 +2,7 @@
// See LICENSE for copying information.
<template>
<UpgradeAccountWrapper title="Add STORJ Tokens">
<UpgradeAccountWrapper :title="title">
<template #content>
<div class="add-tokens">
<p class="add-tokens__info">
@ -52,38 +52,47 @@
/>
</div>
<div class="add-tokens__divider" />
<div class="add-tokens__send-info">
<h2 class="add-tokens__send-info__title">Send only STORJ Tokens to this deposit address.</h2>
<p class="add-tokens__send-info__message">
Sending anything else may result in the loss of your deposit.
</p>
</div>
<AddTokensStepBanner
:is-default="viewState === ViewState.Default"
:is-pending="viewState === ViewState.Pending"
:is-success="viewState === ViewState.Success"
:pending-payments="pendingPayments"
/>
</div>
</template>
</UpgradeAccountWrapper>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import QRCode from 'qrcode';
import { useBillingStore } from '@/store/modules/billingStore';
import { useConfigStore } from '@/store/modules/configStore';
import { useNotify } from '@/utils/hooks';
import { Wallet } from '@/types/payments';
import { PaymentStatus, PaymentWithConfirmations, Wallet } from '@/types/payments';
import { AnalyticsErrorEventSource } from '@/utils/constants/analyticsEventNames';
import UpgradeAccountWrapper from '@/components/modals/upgradeAccountFlow/UpgradeAccountWrapper.vue';
import VButton from '@/components/common/VButton.vue';
import VInfo from '@/components/common/VInfo.vue';
import AddTokensStepBanner from '@/components/modals/upgradeAccountFlow/AddTokensStepBanner.vue';
import InfoIcon from '@/../static/images/payments/infoIcon.svg';
enum ViewState {
Default,
Pending,
Success,
}
const configStore = useConfigStore();
const billingStore = useBillingStore();
const notify = useNotify();
const canvas = ref<HTMLCanvasElement>();
const intervalID = ref<NodeJS.Timer>();
const viewState = ref<ViewState>(ViewState.Default);
/**
* Returns wallet from store.
@ -99,6 +108,27 @@ const neededConfirmations = computed((): number => {
return configStore.state.config.neededTransactionConfirmations;
});
/**
* Returns pending payments from store.
*/
const pendingPayments = computed((): PaymentWithConfirmations[] => {
return billingStore.state.pendingPaymentsWithConfirmations;
});
/**
* Returns title based on payment statuses.
*/
const title = computed((): string => {
switch (viewState.value) {
case ViewState.Pending:
return 'Transaction pending...';
case ViewState.Success:
return 'Transaction Successful';
default:
return 'Add STORJ Tokens';
}
});
/**
* Copies address to user's clipboard.
*/
@ -107,11 +137,39 @@ function onCopyAddressClick(): void {
notify.success('Address copied to your clipboard');
}
/**
* Sets current view state depending on payment statuses.
*/
function setViewState(): void {
switch (true) {
case pendingPayments.value.some(p => p.status === PaymentStatus.Pending):
viewState.value = ViewState.Pending;
break;
case pendingPayments.value.some(p => p.status === PaymentStatus.Confirmed):
viewState.value = ViewState.Success;
break;
default:
viewState.value = ViewState.Default;
}
}
watch(() => pendingPayments.value, () => {
setViewState();
}, { deep: true });
/**
* Mounted lifecycle hook after initial render.
* Renders QR code.
*/
onMounted(async (): Promise<void> => {
setViewState();
intervalID.value = setInterval(async () => {
try {
await billingStore.getPaymentsWithConfirmations();
} catch { /* empty */ }
}, 20000); // get payments every 20 seconds.
if (!canvas.value) {
return;
}
@ -122,6 +180,14 @@ onMounted(async (): Promise<void> => {
notify.error(error.message, AnalyticsErrorEventSource.UPGRADE_ACCOUNT_MODAL);
}
});
onBeforeUnmount(() => {
clearInterval(intervalID.value);
if (viewState.value === ViewState.Success) {
billingStore.clearPendingPayments();
}
});
</script>
<style scoped lang="scss">
@ -225,27 +291,6 @@ onMounted(async (): Promise<void> => {
margin-top: 16px;
background-color: var(--c-grey-2);
}
&__send-info {
margin-top: 16px;
padding: 16px;
background: var(--c-yellow-1);
border: 1px solid var(--c-yellow-2);
box-shadow: 0 7px 20px rgb(0 0 0 / 15%);
border-radius: 10px;
&__title,
&__message {
font-size: 14px;
line-height: 20px;
color: var(--c-black);
text-align: left;
}
&__title {
font-family: 'font_bold', sans-serif;
}
}
}
:deep(.info__box) {

View File

@ -0,0 +1,124 @@
// Copyright (C) 2023 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<div class="banner" :class="{success: isSuccess}">
<template v-if="isDefault">
<h2 class="banner__title">Send only STORJ Tokens to this deposit address.</h2>
<p class="banner__message">
Sending anything else may result in the loss of your deposit.
</p>
</template>
<template v-if="isPending">
<div class="banner__row">
<PendingIcon />
<p class="banner__message">
<b>{{ stillPendingTransactions.length }} transaction{{ stillPendingTransactions.length > 1 ? 's' : '' }} pending...</b>
{{ txWithLeastConfirmations.confirmations }} of {{ neededConfirmations }} confirmations
</p>
</div>
</template>
<template v-if="isSuccess">
<div class="banner__row">
<CheckIcon />
<p class="banner__message">
Successful deposit of {{ totalValueCounter('tokenValue') }} STORJ tokens.
You received an additional bonus of {{ totalValueCounter('bonusTokens') }} STORJ tokens.
</p>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { PaymentStatus, PaymentWithConfirmations } from '@/types/payments';
import { useConfigStore } from '@/store/modules/configStore';
import PendingIcon from '@/../static/images/modals/upgradeFlow/pending.svg';
import CheckIcon from '@/../static/images/modals/upgradeFlow/check.svg';
const configStore = useConfigStore();
const props = defineProps<{
isDefault: boolean
isPending: boolean
isSuccess: boolean
pendingPayments: PaymentWithConfirmations[]
}>();
/**
* Returns an array of still pending transactions to correctly display confirmations count.
*/
const stillPendingTransactions = computed((): PaymentWithConfirmations[] => {
return props.pendingPayments.filter(p => p.status === PaymentStatus.Pending);
});
/**
* Returns transaction with the least confirmations count.
*/
const txWithLeastConfirmations = computed((): PaymentWithConfirmations => {
return stillPendingTransactions.value.reduce((minTx: PaymentWithConfirmations, currentTx: PaymentWithConfirmations) => {
if (currentTx.confirmations < minTx.confirmations) {
return currentTx;
}
return minTx;
}, props.pendingPayments[0]);
});
/**
* Returns needed confirmations count for each transaction from config store.
*/
const neededConfirmations = computed((): number => {
return configStore.state.config.neededTransactionConfirmations;
});
/**
* Calculates total count of provided payment field from the list (i.e. tokenValue or bonusTokens).
*/
function totalValueCounter(field: keyof PaymentWithConfirmations): string {
return props.pendingPayments.reduce((acc: number, curr: PaymentWithConfirmations) => {
return acc + (curr[field] as number);
}, 0).toLocaleString(undefined, { maximumFractionDigits: 2 });
}
</script>
<style scoped lang="scss">
.banner {
margin-top: 16px;
padding: 16px;
background: var(--c-yellow-1);
border: 1px solid var(--c-yellow-2);
box-shadow: 0 7px 20px rgb(0 0 0 / 15%);
border-radius: 10px;
&__title,
&__message {
font-size: 14px;
line-height: 20px;
color: var(--c-black);
text-align: left;
}
&__title {
font-family: 'font_bold', sans-serif;
}
&__row {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
svg {
min-width: 32px;
}
}
}
.success {
background: var(--c-green-4);
border-color: var(--c-green-5);
}
</style>

View File

@ -14,6 +14,8 @@ import {
PaymentsHistoryItem,
PaymentsHistoryItemStatus,
PaymentsHistoryItemType,
PaymentStatus,
PaymentWithConfirmations,
ProjectCharges,
ProjectUsagePriceModel,
Wallet,
@ -24,6 +26,7 @@ export class PaymentsState {
public balance: AccountBalance = new AccountBalance();
public creditCards: CreditCard[] = [];
public paymentsHistory: PaymentsHistoryItem[] = [];
public pendingPaymentsWithConfirmations: PaymentWithConfirmations[] = [];
public nativePaymentsHistory: NativePaymentHistoryItem[] = [];
public projectCharges: ProjectCharges = new ProjectCharges();
public usagePriceModel: ProjectUsagePriceModel = new ProjectUsagePriceModel();
@ -92,6 +95,10 @@ export const useBillingStore = defineStore('billing', () => {
});
}
function clearPendingPayments(): void {
state.pendingPaymentsWithConfirmations = [];
}
async function makeCardDefault(id: string): Promise<void> {
await api.makeCreditCardDefault(id);
@ -122,6 +129,25 @@ export const useBillingStore = defineStore('billing', () => {
state.nativePaymentsHistory = await api.nativePaymentsHistory();
}
async function getPaymentsWithConfirmations(): Promise<void> {
const newPayments = await api.paymentsWithConfirmations();
newPayments.forEach(newPayment => {
const oldPayment = state.pendingPaymentsWithConfirmations.find(old => old.transaction === newPayment.transaction);
if (newPayment.status === PaymentStatus.Pending) {
if (oldPayment) {
oldPayment.confirmations = newPayment.confirmations;
return;
}
state.pendingPaymentsWithConfirmations.push(newPayment);
return;
}
if (oldPayment) oldPayment.status = PaymentStatus.Confirmed;
});
}
async function getProjectUsageAndChargesCurrentRollup(): Promise<void> {
const now = new Date();
const endUTC = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), now.getUTCHours(), now.getUTCMinutes()));
@ -169,6 +195,7 @@ export const useBillingStore = defineStore('billing', () => {
state.nativePaymentsHistory = [];
state.projectCharges = new ProjectCharges();
state.usagePriceModel = new ProjectUsagePriceModel();
state.pendingPaymentsWithConfirmations = [];
state.startDate = new Date();
state.endDate = new Date();
state.coupon = null;
@ -214,6 +241,8 @@ export const useBillingStore = defineStore('billing', () => {
getNativePaymentsHistory,
getProjectUsageAndChargesCurrentRollup,
getProjectUsageAndChargesPreviousRollup,
getPaymentsWithConfirmations,
clearPendingPayments,
getProjectUsagePriceModel,
applyCouponCode,
getCoupon,

View File

@ -78,6 +78,14 @@ export interface PaymentsApi {
*/
nativePaymentsHistory(): Promise<NativePaymentHistoryItem[]>;
/**
* Returns a list of STORJ token payments with confirmations.
*
* @returns list of native token payment items with confirmations
* @throws Error
*/
paymentsWithConfirmations(): Promise<PaymentWithConfirmations[]>;
/**
* applyCouponCode applies a coupon code.
*
@ -625,6 +633,27 @@ export class NativePaymentHistoryItem {
}
}
export enum PaymentStatus {
Pending = 'pending',
Confirmed = 'confirmed',
}
/**
* PaymentWithConfirmation holds all information about token payment with confirmations count.
*/
export class PaymentWithConfirmations {
public constructor(
public readonly address: string = '',
public readonly tokenValue: number = 0,
public readonly usdValue: number = 0,
public readonly transaction: string = '',
public readonly timestamp: Date = new Date(),
public readonly bonusTokens: number = 0,
public status: PaymentStatus = PaymentStatus.Confirmed,
public confirmations: number = 0,
) { }
}
export class TokenAmount {
public constructor(
private readonly _value: string = '0.0',

View File

@ -0,0 +1,3 @@
<svg width="32" height="33" viewBox="0 0 32 33" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M28.1134 7.85248C28.8171 8.55617 28.8343 9.68641 28.1649 10.4109L28.1134 10.4645L14.2767 24.3011C14.0055 24.5724 13.6724 24.7453 13.3222 24.8193C12.7202 25.0204 12.0298 24.8935 11.5337 24.4294L11.5121 24.4089L3.74116 16.6383C3.01987 15.917 3.01987 14.7476 3.74116 14.0263C4.44485 13.3226 5.57509 13.3054 6.29958 13.9748L6.35315 14.0263L12.8406 20.5133L25.5014 7.85248C26.2227 7.1312 27.3921 7.1312 28.1134 7.85248Z" fill="#00AC26"/>
</svg>

After

Width:  |  Height:  |  Size: 549 B

View File

@ -0,0 +1,3 @@
<svg width="32" height="33" viewBox="0 0 32 33" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.0055 23.2474C16.7111 23.2474 17.2864 23.8055 17.3141 24.5044L17.3151 24.557V30.0021C17.3151 30.7254 16.7288 31.3117 16.0055 31.3117C15.2999 31.3117 14.7246 30.7536 14.6969 30.0548L14.6959 30.0021V24.557C14.6959 23.8338 15.2822 23.2474 16.0055 23.2474ZM22.3687 21.4247L22.412 21.4661L26.1877 25.2418C26.6991 25.7532 26.6991 26.5824 26.1877 27.0938C25.6904 27.591 24.8929 27.6048 24.3789 27.1352L24.3356 27.0938L20.56 23.3182C20.0486 22.8068 20.0486 21.9776 20.56 21.4661C21.0426 20.9835 21.8081 20.9563 22.3226 21.3845L22.3687 21.4247ZM11.5272 21.329C12.0231 21.8275 12.0348 22.6251 11.5638 23.1378L11.5223 23.181L7.627 27.0486C7.11422 27.5587 6.28503 27.5565 5.77495 27.0437C5.27904 26.5452 5.26734 25.7476 5.7383 25.2349L5.77985 25.1917L9.67511 21.3241C10.1879 20.814 11.0171 20.8162 11.5272 21.329ZM8.36483 15.602C9.08811 15.602 9.67444 16.1884 9.67444 16.9116C9.67444 17.6173 9.11636 18.1926 8.41751 18.2202L8.36483 18.2212H2.90921C2.18594 18.2212 1.59961 17.6349 1.59961 16.9116C1.59961 16.206 2.15769 15.6307 2.85654 15.6031L2.90921 15.602H8.36483ZM29.0962 15.602C29.8195 15.602 30.4058 16.1884 30.4058 16.9116C30.4058 17.6173 29.8478 18.1926 29.1489 18.2202L29.0962 18.2212H23.8611C23.1378 18.2212 22.5515 17.6349 22.5515 16.9116C22.5515 16.206 23.1096 15.6307 23.8085 15.6031L23.8611 15.602H29.0962ZM7.62368 6.67968L7.66703 6.72112L11.4556 10.5097C11.967 11.0211 11.967 11.8503 11.4556 12.3617C10.9583 12.8589 10.1608 12.8727 9.64684 12.4031L9.6035 12.3617L5.81497 8.57317C5.30354 8.06174 5.30354 7.23255 5.81497 6.72112C6.29757 6.23852 7.06312 6.21131 7.57762 6.6395L7.62368 6.67968ZM26.1927 6.73366C26.6886 7.2322 26.7003 8.02981 26.2294 8.54248L26.1878 8.58571L22.5032 12.2438C21.9904 12.7539 21.1612 12.7517 20.6512 12.2389C20.1552 11.7404 20.1435 10.9428 20.6145 10.4301L20.656 10.3869L24.3407 6.72877C24.8535 6.21869 25.6827 6.22088 26.1927 6.73366ZM16.0055 2.51172C16.7111 2.51172 17.2864 3.06979 17.3141 3.76865L17.3151 3.82132V9.05911C17.3151 9.78238 16.7288 10.3687 16.0055 10.3687C15.2999 10.3687 14.7246 9.81063 14.6969 9.11178L14.6959 9.05911V3.82132C14.6959 3.09805 15.2822 2.51172 16.0055 2.51172Z" fill="#FF8A00"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB