develop-updateui #1
@@ -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);
|
||||
const handleUpgradeSuccess = async () => {
|
||||
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 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>
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AppDialog from '@/components/ui/AppDialog.vue';
|
||||
import AppInput from '@/components/ui/AppInput.vue';
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
|
||||
defineProps<{
|
||||
visible: boolean;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
presets: number[];
|
||||
amount: number | null;
|
||||
loading: boolean;
|
||||
customAmountLabel: string;
|
||||
amountPlaceholder: string;
|
||||
hint: string;
|
||||
cancelLabel: string;
|
||||
proceedLabel: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -30,12 +23,12 @@ const emit = defineEmits<{
|
||||
<AppDialog
|
||||
:visible="visible"
|
||||
@update:visible="emit('update:visible', $event)"
|
||||
:title="title"
|
||||
:title="$t('settings.billing.topupDialog.title')"
|
||||
maxWidthClass="max-w-md"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-foreground/70">
|
||||
{{ subtitle }}
|
||||
{{ $t('settings.billing.topupDialog.subtitle') }}
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-4 gap-3">
|
||||
@@ -55,13 +48,13 @@ const emit = defineEmits<{
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-foreground">{{ customAmountLabel }}</label>
|
||||
<label class="text-sm font-medium text-foreground">{{ $t('settings.billing.topupDialog.customAmount') }}</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-lg font-semibold text-foreground">$</span>
|
||||
<AppInput
|
||||
:model-value="amount"
|
||||
type="number"
|
||||
:placeholder="amountPlaceholder"
|
||||
:placeholder="$t('settings.billing.topupDialog.enterAmount')"
|
||||
inputClass="flex-1"
|
||||
min="1"
|
||||
step="1"
|
||||
@@ -73,7 +66,7 @@ const emit = defineEmits<{
|
||||
</div>
|
||||
|
||||
<div class="bg-muted/30 rounded-md p-3 text-xs text-foreground/60">
|
||||
<p>{{ hint }}</p>
|
||||
<p>{{ $t('settings.billing.topupDialog.hint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -85,7 +78,7 @@ const emit = defineEmits<{
|
||||
:disabled="loading"
|
||||
@click="emit('update:visible', false)"
|
||||
>
|
||||
{{ cancelLabel }}
|
||||
{{ $t('common.cancel') }}
|
||||
</AppButton>
|
||||
<AppButton
|
||||
size="sm"
|
||||
@@ -96,7 +89,7 @@ const emit = defineEmits<{
|
||||
<template #icon>
|
||||
<CheckIcon class="w-4 h-4" />
|
||||
</template>
|
||||
{{ proceedLabel }}
|
||||
{{ $t('settings.billing.topupDialog.proceed') }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -171,9 +171,13 @@ const PaymentHistory = defineComponent({
|
||||
</div>
|
||||
<div class="col-span-3">
|
||||
<p class="text-sm text-foreground">{item.plan}</p>
|
||||
<p v-if="item.details?.length" class="mt-1 text-xs text-foreground/60">
|
||||
{
|
||||
item.details?.length ? (
|
||||
<p class="mt-1 text-xs text-foreground/60">
|
||||
{item.details.join(' · ')}
|
||||
</p>
|
||||
) : null
|
||||
}
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<span class={`inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium ${getStatusStyles(item.status)}`}>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { client as rpcClient } from '@/api/rpcclient';
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import CreditCardIcon from '@/components/icons/CreditCardIcon.vue';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Plan as ModelPlan } from '@/server/gen/proto/app/v1/common';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useQuery } from '@pinia/colada';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
@@ -9,10 +10,15 @@ import { computed, defineComponent } from 'vue';
|
||||
const PlanSelection = defineComponent({
|
||||
name: 'PlanSelection',
|
||||
props: {
|
||||
currentPlanId: { type: String, default: '', required: true },
|
||||
selectedPlanId: { type: String, default: null, required: true },
|
||||
currentPlanId: String,
|
||||
selectedPlanId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
emits: {
|
||||
"upgrade": (plan: ModelPlan) => true,
|
||||
},
|
||||
emits: ['upgrade'],
|
||||
setup(props, { emit }) {
|
||||
const { t } = useTranslation();
|
||||
const auth = useAuthStore();
|
||||
@@ -20,11 +26,16 @@ const PlanSelection = defineComponent({
|
||||
key: () => ['billing-plans'],
|
||||
query: () => rpcClient.listPlans(),
|
||||
});
|
||||
|
||||
const currentPlanId = computed(() => props.currentPlanId || undefined);
|
||||
|
||||
const subscriptionSummary = computed(() => {
|
||||
const expiresAt = auth.user?.planExpiresAt || auth.user?.plan_expires_at;
|
||||
const formattedDate = auth.formatHistoryDate(expiresAt);
|
||||
const currentPlanName = data.value?.plans?.find((p) => p.id === auth.user?.plan_id)?.name || t('settings.billing.subscription.unknownPlan');
|
||||
if (auth.user?.plan_id) {
|
||||
const currentPlanName = data.value?.plans?.find((plan) => plan.id === currentPlanId.value)?.name
|
||||
|| t('settings.billing.subscription.unknownPlan');
|
||||
|
||||
if (currentPlanId.value) {
|
||||
if (auth.user?.plan_expiring_soon && expiresAt) {
|
||||
return {
|
||||
title: t('settings.billing.subscription.expiringTitle'),
|
||||
@@ -66,85 +77,84 @@ const PlanSelection = defineComponent({
|
||||
tone: 'default' as const,
|
||||
};
|
||||
});
|
||||
// Sắp xếp plan theo giá tăng dần
|
||||
|
||||
const sortedPlans = computed(() =>
|
||||
[...(data.value?.plans || [])].sort((a, b) => (a.price || 0) - (b.price || 0))
|
||||
);
|
||||
|
||||
const isCurrentPlan = (planId?: string) => planId === currentPlanId.value;
|
||||
const isSelectingPlan = (planId?: string) => planId === props.selectedPlanId;
|
||||
|
||||
return () => (
|
||||
<div class="px-6 py-4">
|
||||
{/* Header Section */}
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="w-10 h-10 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<CreditCardIcon class="w-5 h-5 text-primary" />
|
||||
<div class="mb-4 flex items-center gap-4">
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-primary/10">
|
||||
<CreditCardIcon class="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">{t('settings.billing.availablePlans')}</p>
|
||||
<p class="text-xs text-foreground/60 mt-0.5">{t('settings.billing.availablePlansHint')}</p>
|
||||
<p class="mt-0.5 text-xs text-foreground/60">{t('settings.billing.availablePlansHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading.value ? (
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i}>
|
||||
<div class="h-[200px] rounded-lg bg-muted/50 animate-pulse"></div>
|
||||
<div class="h-[200px] animate-pulse rounded-lg bg-muted/50"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* Plans Grid */
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{sortedPlans.value.map((plan) => (
|
||||
<div
|
||||
key={plan.id}
|
||||
class={[
|
||||
'border rounded-lg p-4 hover:bg-muted/30 transition-all flex flex-col',
|
||||
plan.id === props.currentPlanId ? 'border-primary/40 bg-primary/5' : 'border-border',
|
||||
'flex flex-col rounded-lg border p-4 transition-all hover:bg-muted/30',
|
||||
isCurrentPlan(plan.id) ? 'border-primary/40 bg-primary/5' : 'border-border',
|
||||
]}
|
||||
>
|
||||
<div class="mb-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h3 class="text-lg font-semibold text-foreground">{plan.name}</h3>
|
||||
{plan.id === props.currentPlanId && (
|
||||
<span class={cn("inline-flex items-center rounded-full bg-primary/10 px-2 py-1 text-[11px] font-medium text-primary", subscriptionSummary.value.tone === 'warning' && 'bg-warning/10 text-warning')}>
|
||||
{isCurrentPlan(plan.id) && (
|
||||
<span class={cn('inline-flex items-center rounded-full bg-primary/10 px-2 py-1 text-[11px] font-medium text-primary', subscriptionSummary.value.tone === 'warning' && 'bg-warning/10 text-warning')}>
|
||||
{subscriptionSummary.value.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p class="text-sm text-foreground/60 mt-1 min-h-[2.5rem]">{plan.description}</p>
|
||||
<p class="mt-1 min-h-[2.5rem] text-sm text-foreground/60">{plan.description}</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<span class="text-2xl font-bold text-foreground">{auth.formatMoney(plan.price || 0)}</span>
|
||||
<span class="text-foreground/60 text-sm"> / {t(`settings.billing.cycle.${plan.cycle}`)}</span>
|
||||
<span class="text-sm text-foreground/60"> / {t(`settings.billing.cycle.${plan.cycle}`)}</span>
|
||||
</div>
|
||||
|
||||
<ul class="space-y-2 mb-4 text-sm">
|
||||
<ul class="mb-4 space-y-2 text-sm">
|
||||
{(plan.features || []).map((feature: string) => (
|
||||
<li key={feature} class="flex items-center gap-2 text-foreground/70">
|
||||
<CheckIcon class="w-4 h-4 text-success shrink-0" />
|
||||
<CheckIcon class="h-4 w-4 shrink-0 text-success" />
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{plan.id !== props.currentPlanId && (
|
||||
{!isCurrentPlan(plan.id) && (
|
||||
<button
|
||||
disabled={props.selectedPlanId === plan.id}
|
||||
disabled={isSelectingPlan(plan.id)}
|
||||
class={[
|
||||
'w-full py-2 px-4 rounded-md text-sm font-medium transition-all mt-auto',
|
||||
props.selectedPlanId === plan.id
|
||||
? 'bg-muted/50 text-foreground/60 cursor-wait'
|
||||
'mt-auto w-full rounded-md px-4 py-2 text-sm font-medium transition-all',
|
||||
isSelectingPlan(plan.id)
|
||||
? 'cursor-wait bg-muted/50 text-foreground/60'
|
||||
: 'bg-primary text-white hover:bg-primary/90',
|
||||
]}
|
||||
onClick={() => emit('upgrade', plan)}
|
||||
>
|
||||
{props.selectedPlanId === plan.id
|
||||
{isSelectingPlan(plan.id)
|
||||
? t('settings.billing.upgradeDialog.selecting')
|
||||
: t('settings.billing.upgradeDialog.choosePlan')
|
||||
}
|
||||
: t('settings.billing.upgradeDialog.choosePlan')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -155,4 +165,5 @@ const PlanSelection = defineComponent({
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export default PlanSelection;
|
||||
@@ -1,189 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import AppDialog from '@/components/ui/AppDialog.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
defineProps<{
|
||||
visible: boolean;
|
||||
selectedPlan: {
|
||||
name: string;
|
||||
description?: string;
|
||||
price: number;
|
||||
} | null;
|
||||
selectedTermMonths: number;
|
||||
walletBalance: number;
|
||||
purchaseTopupAmount: number | null;
|
||||
purchaseError: string | null;
|
||||
purchaseLoading: boolean;
|
||||
onUpgradeDialogVisibilityChange: (visible: boolean) => void;
|
||||
selectUpgradePaymentMethod: (method: 'wallet' | 'topup') => void;
|
||||
closeUpgradeDialog: () => void;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', value: boolean): void;
|
||||
(e: 'selectUpgradePaymentMethod', method: 'wallet' | 'topup'): void;
|
||||
(e: 'closeUpgradeDialog'): void;
|
||||
}>();
|
||||
const TERM_OPTIONS = [1, 3, 6, 12] as const;
|
||||
|
||||
const auth = useAuthStore();
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog :visible="visible" :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>
|
||||
|
||||
<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>
|
||||
</template>
|
||||
396
src/routes/settings/Billing/components/UpgradePlan.tsx
Normal file
396
src/routes/settings/Billing/components/UpgradePlan.tsx
Normal file
@@ -0,0 +1,396 @@
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import { computed, defineComponent, ref, watch, type PropType } from 'vue';
|
||||
|
||||
import { client } 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 { getApiErrorMessage, getApiErrorPayload } from '@/lib/utils';
|
||||
import type { Plan as ModelPlan } from '@/server/gen/proto/app/v1/common';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
const TERM_OPTIONS = [1, 3, 6, 12] as const;
|
||||
|
||||
type UpgradePaymentMethod = 'wallet' | 'topup';
|
||||
|
||||
const UpgradePlan = defineComponent({
|
||||
name: 'UpgradePlan',
|
||||
props: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
selectedPlan: {
|
||||
type: Object as PropType<ModelPlan | null>,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
emits: {
|
||||
'update:visible': (_visible: boolean) => true,
|
||||
close: () => true,
|
||||
success: () => true,
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const toast = useAppToast();
|
||||
const auth = useAuthStore();
|
||||
const { t } = useTranslation();
|
||||
|
||||
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 walletBalance = computed(() => auth.user?.wallet_balance || 0);
|
||||
const selectedPlanPrice = computed(() => props.selectedPlan?.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 (!props.selectedPlan?.id || purchaseLoading.value) return false;
|
||||
if (!selectedNeedsTopup.value) return true;
|
||||
if (selectedPaymentMethod.value !== 'topup') return false;
|
||||
|
||||
const topupAmount = purchaseTopupAmount.value || 0;
|
||||
return topupAmount >= selectedShortfall.value && topupAmount > 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 resetUpgradeState = (plan: ModelPlan | null = props.selectedPlan) => {
|
||||
const shortfall = Math.max((plan?.price || 0) - walletBalance.value, 0);
|
||||
const needsTopup = shortfall > 0.000001;
|
||||
|
||||
selectedTermMonths.value = 1;
|
||||
selectedPaymentMethod.value = needsTopup ? 'topup' : 'wallet';
|
||||
purchaseTopupAmount.value = needsTopup ? Number(shortfall.toFixed(2)) : null;
|
||||
purchaseLoading.value = false;
|
||||
purchaseError.value = null;
|
||||
};
|
||||
|
||||
const emitClose = () => {
|
||||
emit('update:visible', false);
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
if (purchaseLoading.value) return;
|
||||
emitClose();
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
resetUpgradeState(props.selectedPlan);
|
||||
return;
|
||||
}
|
||||
|
||||
resetUpgradeState(null);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.selectedPlan?.id,
|
||||
(planId, previousPlanId) => {
|
||||
if (!props.visible) return;
|
||||
if (planId === previousPlanId) return;
|
||||
resetUpgradeState(props.selectedPlan);
|
||||
},
|
||||
);
|
||||
|
||||
watch(selectedShortfall, (value) => {
|
||||
if (!props.visible) return;
|
||||
|
||||
if (value <= 0) {
|
||||
selectedPaymentMethod.value = 'wallet';
|
||||
purchaseTopupAmount.value = null;
|
||||
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));
|
||||
}
|
||||
};
|
||||
|
||||
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 (!props.selectedPlan?.id) return;
|
||||
|
||||
purchaseLoading.value = true;
|
||||
purchaseError.value = null;
|
||||
|
||||
try {
|
||||
const paymentMethod: UpgradePaymentMethod = selectedNeedsTopup.value ? selectedPaymentMethod.value : 'wallet';
|
||||
const payload: Parameters<typeof client.createPayment>[0] = {
|
||||
planId: props.selectedPlan.id,
|
||||
termMonths: selectedTermMonths.value,
|
||||
paymentMethod,
|
||||
};
|
||||
|
||||
if (paymentMethod === 'topup') {
|
||||
payload.topupAmount = purchaseTopupAmount.value || selectedShortfall.value;
|
||||
}
|
||||
|
||||
await client.createPayment(payload);
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('settings.billing.toast.subscriptionSuccessSummary'),
|
||||
detail: t('settings.billing.toast.subscriptionSuccessDetail', {
|
||||
plan: props.selectedPlan.name || '',
|
||||
term: t('settings.billing.termOption', { months: selectedTermMonths.value }),
|
||||
}),
|
||||
life: 3000,
|
||||
});
|
||||
|
||||
emit('success');
|
||||
emitClose();
|
||||
} 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;
|
||||
}
|
||||
};
|
||||
|
||||
return () => (
|
||||
<AppDialog
|
||||
visible={props.visible}
|
||||
closable={!purchaseLoading.value}
|
||||
title={t('settings.billing.upgradeDialog.title')}
|
||||
maxWidthClass="max-w-2xl"
|
||||
onUpdate:visible={(visible: boolean) => { emit('update:visible', visible); }}
|
||||
onClose={() => { emit('close'); }}
|
||||
v-slots={{
|
||||
default: () => (
|
||||
<>
|
||||
{props.selectedPlan ? (
|
||||
<div 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">{props.selectedPlan.name}</h3>
|
||||
<p class="mt-1 text-sm text-foreground/70">
|
||||
{props.selectedPlan.description || t('settings.billing.availablePlansHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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(props.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">
|
||||
{TERM_OPTIONS.map((months) => (
|
||||
<button
|
||||
key={months}
|
||||
type="button"
|
||||
class={[
|
||||
'rounded-lg border px-4 py-3 text-left transition-all',
|
||||
selectedTermMonths.value === months
|
||||
? 'border-primary bg-primary/5 text-primary'
|
||||
: 'border-border bg-header text-foreground hover:border-primary/30 hover:bg-muted/30',
|
||||
]}
|
||||
onClick={() => (selectedTermMonths.value = months)}
|
||||
>
|
||||
<p class="text-sm font-medium">{t('settings.billing.termOption', { months })}</p>
|
||||
<p class="mt-1 text-xs text-foreground/60">
|
||||
{auth.formatMoney((props.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.value)}
|
||||
</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.value)}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class={[
|
||||
'rounded-lg border p-4',
|
||||
selectedNeedsTopup.value ? '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',
|
||||
selectedNeedsTopup.value ? 'text-warning' : 'text-success',
|
||||
]}>
|
||||
{auth.formatMoney(selectedShortfall.value)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedNeedsTopup.value ? (
|
||||
<div 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.value === 'wallet' ? 'border-primary bg-primary/5' : 'border-border bg-header hover:border-primary/30 hover:bg-muted/30',
|
||||
]}
|
||||
onClick={() => 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.value === 'topup' ? 'border-primary bg-primary/5' : 'border-border bg-header hover:border-primary/30 hover:bg-muted/30',
|
||||
]}
|
||||
onClick={() => 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.value) })}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div class="rounded-lg border border-success/20 bg-success/5 p-4 text-sm text-success">
|
||||
{t('settings.billing.upgradeDialog.walletCoveredHint')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedNeedsTopup.value && selectedPaymentMethod.value === 'topup' && (
|
||||
<div class="grid gap-2">
|
||||
<label class="text-sm font-medium text-foreground">{t('settings.billing.upgradeDialog.topupAmountLabel')}</label>
|
||||
<AppInput
|
||||
modelValue={purchaseTopupAmount.value}
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
placeholder={t('settings.billing.upgradeDialog.topupAmountPlaceholder')}
|
||||
onUpdate:modelValue={updatePurchaseTopupAmount}
|
||||
/>
|
||||
<p class="text-xs text-foreground/60">
|
||||
{t('settings.billing.upgradeDialog.topupAmountHint', { shortfall: auth.formatMoney(selectedShortfall.value) })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedNeedsTopup.value && selectedPaymentMethod.value === 'wallet' && (
|
||||
<div 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.value) })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{purchaseError.value && (
|
||||
<div class="rounded-lg border border-danger bg-danger/10 p-4 text-sm text-danger">
|
||||
{purchaseError.value}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
),
|
||||
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.value} onClick={closeDialog}>
|
||||
{t('common.cancel')}
|
||||
</AppButton>
|
||||
<AppButton size="sm" loading={purchaseLoading.value} disabled={!canSubmitUpgrade.value} onClick={submitUpgrade}>
|
||||
{upgradeSubmitLabel.value}
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export default UpgradePlan;
|
||||
Reference in New Issue
Block a user