refactor: update billing components for improved UX and localization
- Refactored BillingTopupDialog.vue to use localized strings for titles, subtitles, and labels. - Modified PaymentHistory.tsx to use conditional rendering for item details. - Enhanced PlanSelection.tsx with better prop handling and improved UI responsiveness. - Removed UpgradeDialog.vue and replaced it with a new UpgradePlan.tsx component for better structure and functionality. - Added logic to handle payment methods and top-up amounts in UpgradePlan.tsx. - Improved overall code readability and maintainability across billing components.
This commit is contained in:
@@ -1,28 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AppDialog from '@/components/ui/AppDialog.vue';
|
||||
import AppInput from '@/components/ui/AppInput.vue';
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
import { useUsageQuery } from '@/composables/useUsageQuery';
|
||||
import { getApiErrorMessage, getApiErrorPayload } from '@/lib/utils';
|
||||
import { getApiErrorMessage } from '@/lib/utils';
|
||||
import BillingTopupDialog from '@/routes/settings/Billing/components/BillingTopupDialog.vue';
|
||||
import BillingUsageSection from '@/routes/settings/Billing/components/BillingUsageSection.vue';
|
||||
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
|
||||
import type { Plan as ModelPlan } from '@/server/gen/proto/app/v1/common';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import SettingsRow from '../components/SettingsRow.vue';
|
||||
import PaymentHistory from './components/PaymentHistory';
|
||||
import PlanSelection from './components/PlanSelection';
|
||||
|
||||
const TERM_OPTIONS = [1, 3, 6, 12] as const;
|
||||
type UpgradePaymentMethod = 'wallet' | 'topup';
|
||||
import UpgradePlan from './components/UpgradePlan';
|
||||
|
||||
const toast = useAppToast();
|
||||
const auth = useAuthStore();
|
||||
const { t, i18next } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { refetch: refetchUsage } = useUsageQuery();
|
||||
|
||||
@@ -33,167 +29,38 @@ const topupPresets = [10, 20, 50, 100];
|
||||
|
||||
const upgradeDialogVisible = ref(false);
|
||||
const selectedPlan = ref<ModelPlan | null>(null);
|
||||
const selectedTermMonths = ref<number>(1);
|
||||
const selectedPaymentMethod = ref<UpgradePaymentMethod>('wallet');
|
||||
const purchaseTopupAmount = ref<number | null>(null);
|
||||
const purchaseLoading = ref(false);
|
||||
const purchaseError = ref<string | null>(null);
|
||||
|
||||
const currentPlanId = computed(() => auth.user?.plan_id || undefined);
|
||||
const selectedPlanId = computed(() => (upgradeDialogVisible.value ? selectedPlan.value?.id || '' : ''));
|
||||
const walletBalance = computed(() => auth.user?.wallet_balance || 0);
|
||||
|
||||
const selectedPlanId = computed(() => upgradeDialogVisible.value ? selectedPlan.value?.id || null : null);
|
||||
const selectedPlanPrice = computed(() => selectedPlan.value?.price || 0);
|
||||
const selectedTotalAmount = computed(() => selectedPlanPrice.value * selectedTermMonths.value);
|
||||
const selectedShortfall = computed(() => Math.max(selectedTotalAmount.value - walletBalance.value, 0));
|
||||
const selectedNeedsTopup = computed(() => selectedShortfall.value > 0.000001);
|
||||
const canSubmitUpgrade = computed(() => {
|
||||
if (!selectedPlan.value?.id || purchaseLoading.value) return false;
|
||||
if (!selectedNeedsTopup.value) return true;
|
||||
if (selectedPaymentMethod.value !== 'topup') return false;
|
||||
return (purchaseTopupAmount.value || 0) >= selectedShortfall.value && (purchaseTopupAmount.value || 0) > 0;
|
||||
});
|
||||
const upgradeSubmitLabel = computed(() => {
|
||||
if (selectedNeedsTopup.value && selectedPaymentMethod.value === 'topup') {
|
||||
return t('settings.billing.upgradeDialog.topupAndUpgrade');
|
||||
}
|
||||
|
||||
return t('settings.billing.upgradeDialog.payWithWallet');
|
||||
});
|
||||
|
||||
const getApiErrorData = (error: unknown) => getApiErrorPayload(error)?.data || null;
|
||||
|
||||
const refreshBillingState = async () => {
|
||||
await Promise.allSettled([
|
||||
auth.fetchMe(),
|
||||
// loadPaymentHistory(),
|
||||
refetchUsage(),
|
||||
]);
|
||||
};
|
||||
|
||||
// void loadPaymentHistory();
|
||||
|
||||
const resetUpgradeState = () => {
|
||||
selectedPlan.value = null;
|
||||
selectedTermMonths.value = 1;
|
||||
selectedPaymentMethod.value = 'wallet';
|
||||
purchaseTopupAmount.value = null;
|
||||
purchaseError.value = null;
|
||||
};
|
||||
|
||||
const openUpgradeDialog = (plan: ModelPlan) => {
|
||||
selectedPlan.value = plan;
|
||||
selectedTermMonths.value = 1;
|
||||
purchaseError.value = null;
|
||||
selectedPaymentMethod.value = walletBalance.value >= (plan.price || 0) ? 'wallet' : 'topup';
|
||||
purchaseTopupAmount.value = null;
|
||||
upgradeDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const closeUpgradeDialog = () => {
|
||||
if (purchaseLoading.value) return;
|
||||
upgradeDialogVisible.value = false;
|
||||
resetUpgradeState();
|
||||
selectedPlan.value = null;
|
||||
};
|
||||
|
||||
const onUpgradeDialogVisibilityChange = (visible: boolean) => {
|
||||
if (visible) {
|
||||
upgradeDialogVisible.value = true;
|
||||
return;
|
||||
}
|
||||
const handleUpgradeVisibilityChange = (visible: boolean) => {
|
||||
upgradeDialogVisible.value = visible;
|
||||
|
||||
closeUpgradeDialog();
|
||||
};
|
||||
|
||||
watch(selectedShortfall, (value) => {
|
||||
if (!upgradeDialogVisible.value) return;
|
||||
|
||||
if (value <= 0) {
|
||||
selectedPaymentMethod.value = 'wallet';
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedPaymentMethod.value === 'topup' && ((purchaseTopupAmount.value || 0) < value)) {
|
||||
purchaseTopupAmount.value = Number(value.toFixed(2));
|
||||
}
|
||||
});
|
||||
|
||||
const selectUpgradePaymentMethod = (method: UpgradePaymentMethod) => {
|
||||
selectedPaymentMethod.value = method;
|
||||
purchaseError.value = null;
|
||||
|
||||
if (method === 'topup' && selectedShortfall.value > 0 && ((purchaseTopupAmount.value || 0) < selectedShortfall.value)) {
|
||||
purchaseTopupAmount.value = Number(selectedShortfall.value.toFixed(2));
|
||||
if (!visible) {
|
||||
selectedPlan.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const updatePurchaseTopupAmount = (value: string | number | null) => {
|
||||
if (typeof value === 'number' || value === null) {
|
||||
purchaseTopupAmount.value = value;
|
||||
return;
|
||||
}
|
||||
|
||||
if (value === '') {
|
||||
purchaseTopupAmount.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
purchaseTopupAmount.value = Number.isNaN(parsed) ? null : parsed;
|
||||
};
|
||||
|
||||
const submitUpgrade = async () => {
|
||||
if (!selectedPlan.value?.id) return;
|
||||
|
||||
purchaseLoading.value = true;
|
||||
purchaseError.value = null;
|
||||
|
||||
try {
|
||||
const paymentMethod: UpgradePaymentMethod = selectedNeedsTopup.value ? selectedPaymentMethod.value : 'wallet';
|
||||
const payload: Parameters<typeof rpcClient.createPayment>[0] = {
|
||||
planId: selectedPlan.value.id,
|
||||
termMonths: selectedTermMonths.value,
|
||||
paymentMethod: paymentMethod,
|
||||
};
|
||||
|
||||
if (paymentMethod === 'topup') {
|
||||
payload.topupAmount = purchaseTopupAmount.value || selectedShortfall.value;
|
||||
}
|
||||
|
||||
await rpcClient.createPayment(payload);
|
||||
await refreshBillingState();
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('settings.billing.toast.subscriptionSuccessSummary'),
|
||||
detail: t('settings.billing.toast.subscriptionSuccessDetail', {
|
||||
plan: selectedPlan.value.name || '',
|
||||
term: t('settings.billing.termOption', { months: selectedTermMonths.value })
|
||||
// term: formatTermLabel(selectedTermMonths.value),
|
||||
}),
|
||||
life: 3000,
|
||||
});
|
||||
|
||||
closeUpgradeDialog();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
const errorData = getApiErrorData(error);
|
||||
const nextShortfall = typeof errorData?.shortfall === 'number'
|
||||
? errorData.shortfall
|
||||
: selectedShortfall.value;
|
||||
|
||||
if (nextShortfall > 0) {
|
||||
selectedPaymentMethod.value = 'topup';
|
||||
if ((purchaseTopupAmount.value || 0) < nextShortfall) {
|
||||
purchaseTopupAmount.value = Number(nextShortfall.toFixed(2));
|
||||
}
|
||||
}
|
||||
|
||||
purchaseError.value = getApiErrorMessage(error, t('settings.billing.toast.subscriptionFailedDetail'));
|
||||
} finally {
|
||||
purchaseLoading.value = false;
|
||||
}
|
||||
const handleUpgradeSuccess = async () => {
|
||||
await refreshBillingState();
|
||||
};
|
||||
|
||||
const handleTopup = async (amount: number) => {
|
||||
@@ -255,177 +122,31 @@ const selectPreset = (amount: number) => {
|
||||
</template>
|
||||
</SettingsRow>
|
||||
|
||||
<PlanSelection :current-plan-id="currentPlanId" :selectedPlanId="String(selectedPlanId)"
|
||||
@upgrade="openUpgradeDialog" />
|
||||
<PlanSelection
|
||||
:current-plan-id="currentPlanId"
|
||||
:selected-plan-id="selectedPlanId"
|
||||
@upgrade="openUpgradeDialog"
|
||||
/>
|
||||
<BillingUsageSection />
|
||||
<PaymentHistory />
|
||||
</SettingsSectionCard>
|
||||
|
||||
<AppDialog :visible="upgradeDialogVisible" :title="$t('settings.billing.upgradeDialog.title')"
|
||||
maxWidthClass="max-w-2xl" @update:visible="onUpgradeDialogVisibilityChange" @close="closeUpgradeDialog">
|
||||
<div v-if="selectedPlan" class="space-y-5">
|
||||
<div class="rounded-lg border border-border bg-muted/20 p-4">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase tracking-[0.18em] text-foreground/50">
|
||||
{{ $t('settings.billing.upgradeDialog.selectedPlan') }}
|
||||
</p>
|
||||
<h3 class="mt-1 text-lg font-semibold text-foreground">{{ selectedPlan.name }}</h3>
|
||||
<p class="mt-1 text-sm text-foreground/70">
|
||||
{{ selectedPlan.description || $t('settings.billing.availablePlansHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<BillingTopupDialog
|
||||
:visible="topupDialogVisible"
|
||||
:presets="topupPresets"
|
||||
:amount="topupAmount"
|
||||
:loading="topupLoading"
|
||||
@update:visible="topupDialogVisible = $event"
|
||||
@update:amount="topupAmount = $event"
|
||||
@selectPreset="selectPreset"
|
||||
@submit="handleTopup(topupAmount || 0)"
|
||||
/>
|
||||
|
||||
<div class="text-left md:text-right">
|
||||
<p class="text-xs text-foreground/50">{{ $t('settings.billing.upgradeDialog.basePrice') }}</p>
|
||||
<p class="mt-1 text-2xl font-semibold text-foreground">{{ auth.formatMoney(selectedPlan.price ||
|
||||
0)
|
||||
}}</p>
|
||||
<p class="text-xs text-foreground/60">{{ $t('settings.billing.upgradeDialog.perMonthBase') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">{{ $t('settings.billing.upgradeDialog.termTitle') }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-foreground/60">{{ $t('settings.billing.upgradeDialog.termHint') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<button v-for="months in TERM_OPTIONS" :key="months" type="button" :class="[
|
||||
'rounded-lg border px-4 py-3 text-left transition-all',
|
||||
selectedTermMonths === months
|
||||
? 'border-primary bg-primary/5 text-primary'
|
||||
: 'border-border bg-header text-foreground hover:border-primary/30 hover:bg-muted/30',
|
||||
]" @click="selectedTermMonths = months">
|
||||
<p class="text-sm font-medium">{{ $t('settings.billing.termOption', { months }) }}</p>
|
||||
<p class="mt-1 text-xs text-foreground/60">{{ auth.formatMoney((selectedPlan.price || 0) *
|
||||
months)
|
||||
}}</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-3">
|
||||
<div class="rounded-lg border border-border bg-header p-4">
|
||||
<p class="text-xs uppercase tracking-wide text-foreground/50">{{
|
||||
$t('settings.billing.upgradeDialog.totalLabel') }}</p>
|
||||
<p class="mt-2 text-xl font-semibold text-foreground">{{ auth.formatMoney(selectedTotalAmount) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border bg-header p-4">
|
||||
<p class="text-xs uppercase tracking-wide text-foreground/50">{{
|
||||
$t('settings.billing.upgradeDialog.walletBalanceLabel') }}</p>
|
||||
<p class="mt-2 text-xl font-semibold text-foreground">{{ auth.formatMoney(walletBalance) }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border p-4" :class="selectedNeedsTopup
|
||||
? 'border-warning/30 bg-warning/10'
|
||||
: 'border-success/20 bg-success/5'">
|
||||
<p class="text-xs uppercase tracking-wide text-foreground/50">{{
|
||||
$t('settings.billing.upgradeDialog.shortfallLabel') }}</p>
|
||||
<p class="mt-2 text-xl font-semibold" :class="selectedNeedsTopup ? 'text-warning' : 'text-success'">
|
||||
{{ auth.formatMoney(selectedShortfall) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedNeedsTopup" class="space-y-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">{{
|
||||
$t('settings.billing.upgradeDialog.paymentMethodTitle') }}</p>
|
||||
<p class="mt-1 text-xs text-foreground/60">{{ $t('settings.billing.upgradeDialog.paymentMethodHint')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<button type="button" :class="[
|
||||
'rounded-lg border p-4 text-left transition-all',
|
||||
selectedPaymentMethod === 'wallet'
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border bg-header hover:border-primary/30 hover:bg-muted/30',
|
||||
]" @click="selectUpgradePaymentMethod('wallet')">
|
||||
<p class="text-sm font-medium text-foreground">{{ $t('settings.billing.paymentMethod.wallet') }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-foreground/60">
|
||||
{{ $t('settings.billing.upgradeDialog.walletOptionDescription') }}
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<button type="button" :class="[
|
||||
'rounded-lg border p-4 text-left transition-all',
|
||||
selectedPaymentMethod === 'topup'
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border bg-header hover:border-primary/30 hover:bg-muted/30',
|
||||
]" @click="selectUpgradePaymentMethod('topup')">
|
||||
<p class="text-sm font-medium text-foreground">{{ $t('settings.billing.paymentMethod.topup') }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-foreground/60">
|
||||
{{ $t('settings.billing.upgradeDialog.topupOptionDescription', {
|
||||
shortfall:
|
||||
auth.formatMoney(selectedShortfall) }) }}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="rounded-lg border border-success/20 bg-success/5 p-4 text-sm text-success">
|
||||
{{ $t('settings.billing.upgradeDialog.walletCoveredHint') }}
|
||||
</div>
|
||||
|
||||
<div v-if="selectedNeedsTopup && selectedPaymentMethod === 'topup'" class="grid gap-2">
|
||||
<label class="text-sm font-medium text-foreground">{{
|
||||
$t('settings.billing.upgradeDialog.topupAmountLabel')
|
||||
}}</label>
|
||||
<AppInput :model-value="purchaseTopupAmount" type="number" min="0.01" step="0.01"
|
||||
:placeholder="$t('settings.billing.upgradeDialog.topupAmountPlaceholder')"
|
||||
@update:model-value="updatePurchaseTopupAmount" />
|
||||
<p class="text-xs text-foreground/60">
|
||||
{{ $t('settings.billing.upgradeDialog.topupAmountHint', {
|
||||
shortfall:
|
||||
auth.formatMoney(selectedShortfall)
|
||||
}) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedNeedsTopup && selectedPaymentMethod === 'wallet'"
|
||||
class="rounded-lg border border-warning/30 bg-warning/10 p-4 text-sm text-warning">
|
||||
{{ $t('settings.billing.upgradeDialog.walletInsufficientHint', {
|
||||
shortfall:
|
||||
auth.formatMoney(selectedShortfall) }) }}
|
||||
</div>
|
||||
|
||||
<div v-if="purchaseError" class="rounded-lg border border-danger bg-danger/10 p-4 text-sm text-danger">
|
||||
{{ purchaseError }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p class="text-xs text-foreground/60">
|
||||
{{ $t('settings.billing.upgradeDialog.footerHint') }}
|
||||
</p>
|
||||
<div class="flex justify-end gap-3">
|
||||
<AppButton variant="secondary" size="sm" :disabled="purchaseLoading" @click="closeUpgradeDialog">
|
||||
{{ $t('common.cancel') }}
|
||||
</AppButton>
|
||||
<AppButton size="sm" :loading="purchaseLoading" :disabled="!canSubmitUpgrade"
|
||||
@click="submitUpgrade">
|
||||
{{ upgradeSubmitLabel }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<BillingTopupDialog :visible="topupDialogVisible" :title="$t('settings.billing.topupDialog.title')"
|
||||
:subtitle="t('settings.billing.topupDialog.subtitle')" :presets="topupPresets" :amount="topupAmount"
|
||||
:loading="topupLoading" :custom-amount-label="t('settings.billing.topupDialog.customAmount')"
|
||||
:amount-placeholder="t('settings.billing.topupDialog.enterAmount')"
|
||||
:hint="t('settings.billing.topupDialog.hint')" :cancel-label="t('common.cancel')"
|
||||
:proceed-label="t('settings.billing.topupDialog.proceed')" @update:visible="topupDialogVisible = $event"
|
||||
@update:amount="topupAmount = $event" @selectPreset="selectPreset" @submit="handleTopup(topupAmount || 0)" />
|
||||
<UpgradePlan
|
||||
:visible="upgradeDialogVisible"
|
||||
:selected-plan="selectedPlan"
|
||||
@update:visible="handleUpgradeVisibilityChange"
|
||||
@close="closeUpgradeDialog"
|
||||
@success="handleUpgradeSuccess"
|
||||
/>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user