develop-updateui #1
@@ -1,28 +1,24 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { client as rpcClient } from '@/api/rpcclient';
|
import { client as rpcClient } from '@/api/rpcclient';
|
||||||
import AppButton from '@/components/ui/AppButton.vue';
|
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 { useAppToast } from '@/composables/useAppToast';
|
||||||
import { useUsageQuery } from '@/composables/useUsageQuery';
|
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 BillingTopupDialog from '@/routes/settings/Billing/components/BillingTopupDialog.vue';
|
||||||
import BillingUsageSection from '@/routes/settings/Billing/components/BillingUsageSection.vue';
|
import BillingUsageSection from '@/routes/settings/Billing/components/BillingUsageSection.vue';
|
||||||
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
|
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
|
||||||
import type { Plan as ModelPlan } from '@/server/gen/proto/app/v1/common';
|
import type { Plan as ModelPlan } from '@/server/gen/proto/app/v1/common';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import { useTranslation } from 'i18next-vue';
|
import { useTranslation } from 'i18next-vue';
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import SettingsRow from '../components/SettingsRow.vue';
|
import SettingsRow from '../components/SettingsRow.vue';
|
||||||
import PaymentHistory from './components/PaymentHistory';
|
import PaymentHistory from './components/PaymentHistory';
|
||||||
import PlanSelection from './components/PlanSelection';
|
import PlanSelection from './components/PlanSelection';
|
||||||
|
import UpgradePlan from './components/UpgradePlan';
|
||||||
const TERM_OPTIONS = [1, 3, 6, 12] as const;
|
|
||||||
type UpgradePaymentMethod = 'wallet' | 'topup';
|
|
||||||
|
|
||||||
const toast = useAppToast();
|
const toast = useAppToast();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const { t, i18next } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { refetch: refetchUsage } = useUsageQuery();
|
const { refetch: refetchUsage } = useUsageQuery();
|
||||||
|
|
||||||
@@ -33,167 +29,38 @@ const topupPresets = [10, 20, 50, 100];
|
|||||||
|
|
||||||
const upgradeDialogVisible = ref(false);
|
const upgradeDialogVisible = ref(false);
|
||||||
const selectedPlan = ref<ModelPlan | null>(null);
|
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 currentPlanId = computed(() => auth.user?.plan_id || undefined);
|
||||||
|
const selectedPlanId = computed(() => (upgradeDialogVisible.value ? selectedPlan.value?.id || '' : ''));
|
||||||
const walletBalance = computed(() => auth.user?.wallet_balance || 0);
|
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 () => {
|
const refreshBillingState = async () => {
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
auth.fetchMe(),
|
auth.fetchMe(),
|
||||||
// loadPaymentHistory(),
|
|
||||||
refetchUsage(),
|
refetchUsage(),
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
// void loadPaymentHistory();
|
|
||||||
|
|
||||||
const resetUpgradeState = () => {
|
|
||||||
selectedPlan.value = null;
|
|
||||||
selectedTermMonths.value = 1;
|
|
||||||
selectedPaymentMethod.value = 'wallet';
|
|
||||||
purchaseTopupAmount.value = null;
|
|
||||||
purchaseError.value = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const openUpgradeDialog = (plan: ModelPlan) => {
|
const openUpgradeDialog = (plan: ModelPlan) => {
|
||||||
selectedPlan.value = plan;
|
selectedPlan.value = plan;
|
||||||
selectedTermMonths.value = 1;
|
|
||||||
purchaseError.value = null;
|
|
||||||
selectedPaymentMethod.value = walletBalance.value >= (plan.price || 0) ? 'wallet' : 'topup';
|
|
||||||
purchaseTopupAmount.value = null;
|
|
||||||
upgradeDialogVisible.value = true;
|
upgradeDialogVisible.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeUpgradeDialog = () => {
|
const closeUpgradeDialog = () => {
|
||||||
if (purchaseLoading.value) return;
|
|
||||||
upgradeDialogVisible.value = false;
|
upgradeDialogVisible.value = false;
|
||||||
resetUpgradeState();
|
selectedPlan.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onUpgradeDialogVisibilityChange = (visible: boolean) => {
|
const handleUpgradeVisibilityChange = (visible: boolean) => {
|
||||||
if (visible) {
|
upgradeDialogVisible.value = visible;
|
||||||
upgradeDialogVisible.value = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
closeUpgradeDialog();
|
if (!visible) {
|
||||||
};
|
selectedPlan.value = null;
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updatePurchaseTopupAmount = (value: string | number | null) => {
|
const handleUpgradeSuccess = async () => {
|
||||||
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();
|
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) => {
|
const handleTopup = async (amount: number) => {
|
||||||
@@ -255,177 +122,31 @@ const selectPreset = (amount: number) => {
|
|||||||
</template>
|
</template>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
|
||||||
<PlanSelection :current-plan-id="currentPlanId" :selectedPlanId="String(selectedPlanId)"
|
<PlanSelection
|
||||||
@upgrade="openUpgradeDialog" />
|
:current-plan-id="currentPlanId"
|
||||||
|
:selected-plan-id="selectedPlanId"
|
||||||
|
@upgrade="openUpgradeDialog"
|
||||||
|
/>
|
||||||
<BillingUsageSection />
|
<BillingUsageSection />
|
||||||
<PaymentHistory />
|
<PaymentHistory />
|
||||||
</SettingsSectionCard>
|
</SettingsSectionCard>
|
||||||
|
|
||||||
<AppDialog :visible="upgradeDialogVisible" :title="$t('settings.billing.upgradeDialog.title')"
|
<BillingTopupDialog
|
||||||
maxWidthClass="max-w-2xl" @update:visible="onUpgradeDialogVisibilityChange" @close="closeUpgradeDialog">
|
:visible="topupDialogVisible"
|
||||||
<div v-if="selectedPlan" class="space-y-5">
|
:presets="topupPresets"
|
||||||
<div class="rounded-lg border border-border bg-muted/20 p-4">
|
:amount="topupAmount"
|
||||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
:loading="topupLoading"
|
||||||
<div>
|
@update:visible="topupDialogVisible = $event"
|
||||||
<p class="text-xs font-medium uppercase tracking-[0.18em] text-foreground/50">
|
@update:amount="topupAmount = $event"
|
||||||
{{ $t('settings.billing.upgradeDialog.selectedPlan') }}
|
@selectPreset="selectPreset"
|
||||||
</p>
|
@submit="handleTopup(topupAmount || 0)"
|
||||||
<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">
|
<UpgradePlan
|
||||||
<p class="text-xs text-foreground/50">{{ $t('settings.billing.upgradeDialog.basePrice') }}</p>
|
:visible="upgradeDialogVisible"
|
||||||
<p class="mt-1 text-2xl font-semibold text-foreground">{{ auth.formatMoney(selectedPlan.price ||
|
:selected-plan="selectedPlan"
|
||||||
0)
|
@update:visible="handleUpgradeVisibilityChange"
|
||||||
}}</p>
|
@close="closeUpgradeDialog"
|
||||||
<p class="text-xs text-foreground/60">{{ $t('settings.billing.upgradeDialog.perMonthBase') }}
|
@success="handleUpgradeSuccess"
|
||||||
</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)" />
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,21 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||||
import AppButton from '@/components/ui/AppButton.vue';
|
import AppButton from '@/components/ui/AppButton.vue';
|
||||||
import AppDialog from '@/components/ui/AppDialog.vue';
|
import AppDialog from '@/components/ui/AppDialog.vue';
|
||||||
import AppInput from '@/components/ui/AppInput.vue';
|
import AppInput from '@/components/ui/AppInput.vue';
|
||||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
presets: number[];
|
presets: number[];
|
||||||
amount: number | null;
|
amount: number | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
customAmountLabel: string;
|
|
||||||
amountPlaceholder: string;
|
|
||||||
hint: string;
|
|
||||||
cancelLabel: string;
|
|
||||||
proceedLabel: string;
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -30,12 +23,12 @@ const emit = defineEmits<{
|
|||||||
<AppDialog
|
<AppDialog
|
||||||
:visible="visible"
|
:visible="visible"
|
||||||
@update:visible="emit('update:visible', $event)"
|
@update:visible="emit('update:visible', $event)"
|
||||||
:title="title"
|
:title="$t('settings.billing.topupDialog.title')"
|
||||||
maxWidthClass="max-w-md"
|
maxWidthClass="max-w-md"
|
||||||
>
|
>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<p class="text-sm text-foreground/70">
|
<p class="text-sm text-foreground/70">
|
||||||
{{ subtitle }}
|
{{ $t('settings.billing.topupDialog.subtitle') }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="grid grid-cols-4 gap-3">
|
<div class="grid grid-cols-4 gap-3">
|
||||||
@@ -55,13 +48,13 @@ const emit = defineEmits<{
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<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">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-lg font-semibold text-foreground">$</span>
|
<span class="text-lg font-semibold text-foreground">$</span>
|
||||||
<AppInput
|
<AppInput
|
||||||
:model-value="amount"
|
:model-value="amount"
|
||||||
type="number"
|
type="number"
|
||||||
:placeholder="amountPlaceholder"
|
:placeholder="$t('settings.billing.topupDialog.enterAmount')"
|
||||||
inputClass="flex-1"
|
inputClass="flex-1"
|
||||||
min="1"
|
min="1"
|
||||||
step="1"
|
step="1"
|
||||||
@@ -73,7 +66,7 @@ const emit = defineEmits<{
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-muted/30 rounded-md p-3 text-xs text-foreground/60">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -85,7 +78,7 @@ const emit = defineEmits<{
|
|||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
@click="emit('update:visible', false)"
|
@click="emit('update:visible', false)"
|
||||||
>
|
>
|
||||||
{{ cancelLabel }}
|
{{ $t('common.cancel') }}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<AppButton
|
<AppButton
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -96,7 +89,7 @@ const emit = defineEmits<{
|
|||||||
<template #icon>
|
<template #icon>
|
||||||
<CheckIcon class="w-4 h-4" />
|
<CheckIcon class="w-4 h-4" />
|
||||||
</template>
|
</template>
|
||||||
{{ proceedLabel }}
|
{{ $t('settings.billing.topupDialog.proceed') }}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -171,9 +171,13 @@ const PaymentHistory = defineComponent({
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-span-3">
|
<div class="col-span-3">
|
||||||
<p class="text-sm text-foreground">{item.plan}</p>
|
<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(' · ')}
|
{item.details.join(' · ')}
|
||||||
</p>
|
</p>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-span-2">
|
<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)}`}>
|
<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 CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||||
import CreditCardIcon from '@/components/icons/CreditCardIcon.vue';
|
import CreditCardIcon from '@/components/icons/CreditCardIcon.vue';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { Plan as ModelPlan } from '@/server/gen/proto/app/v1/common';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import { useQuery } from '@pinia/colada';
|
import { useQuery } from '@pinia/colada';
|
||||||
import { useTranslation } from 'i18next-vue';
|
import { useTranslation } from 'i18next-vue';
|
||||||
@@ -9,10 +10,15 @@ import { computed, defineComponent } from 'vue';
|
|||||||
const PlanSelection = defineComponent({
|
const PlanSelection = defineComponent({
|
||||||
name: 'PlanSelection',
|
name: 'PlanSelection',
|
||||||
props: {
|
props: {
|
||||||
currentPlanId: { type: String, default: '', required: true },
|
currentPlanId: String,
|
||||||
selectedPlanId: { type: String, default: null, required: true },
|
selectedPlanId: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emits: {
|
||||||
|
"upgrade": (plan: ModelPlan) => true,
|
||||||
},
|
},
|
||||||
emits: ['upgrade'],
|
|
||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
@@ -20,11 +26,16 @@ const PlanSelection = defineComponent({
|
|||||||
key: () => ['billing-plans'],
|
key: () => ['billing-plans'],
|
||||||
query: () => rpcClient.listPlans(),
|
query: () => rpcClient.listPlans(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const currentPlanId = computed(() => props.currentPlanId || undefined);
|
||||||
|
|
||||||
const subscriptionSummary = computed(() => {
|
const subscriptionSummary = computed(() => {
|
||||||
const expiresAt = auth.user?.planExpiresAt || auth.user?.plan_expires_at;
|
const expiresAt = auth.user?.planExpiresAt || auth.user?.plan_expires_at;
|
||||||
const formattedDate = auth.formatHistoryDate(expiresAt);
|
const formattedDate = auth.formatHistoryDate(expiresAt);
|
||||||
const currentPlanName = data.value?.plans?.find((p) => p.id === auth.user?.plan_id)?.name || t('settings.billing.subscription.unknownPlan');
|
const currentPlanName = data.value?.plans?.find((plan) => plan.id === currentPlanId.value)?.name
|
||||||
if (auth.user?.plan_id) {
|
|| t('settings.billing.subscription.unknownPlan');
|
||||||
|
|
||||||
|
if (currentPlanId.value) {
|
||||||
if (auth.user?.plan_expiring_soon && expiresAt) {
|
if (auth.user?.plan_expiring_soon && expiresAt) {
|
||||||
return {
|
return {
|
||||||
title: t('settings.billing.subscription.expiringTitle'),
|
title: t('settings.billing.subscription.expiringTitle'),
|
||||||
@@ -65,86 +76,85 @@ const PlanSelection = defineComponent({
|
|||||||
description: t('settings.billing.subscription.freeDescription'),
|
description: t('settings.billing.subscription.freeDescription'),
|
||||||
tone: 'default' as const,
|
tone: 'default' as const,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
// Sắp xếp plan theo giá tăng dần
|
|
||||||
const sortedPlans = computed(() =>
|
const sortedPlans = computed(() =>
|
||||||
[...(data.value?.plans || [])].sort((a, b) => (a.price || 0) - (b.price || 0))
|
[...(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 () => (
|
return () => (
|
||||||
<div class="px-6 py-4">
|
<div class="px-6 py-4">
|
||||||
{/* Header Section */}
|
<div class="mb-4 flex items-center gap-4">
|
||||||
<div class="flex items-center gap-4 mb-4">
|
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-primary/10">
|
||||||
<div class="w-10 h-10 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
|
<CreditCardIcon class="h-5 w-5 text-primary" />
|
||||||
<CreditCardIcon class="w-5 h-5 text-primary" />
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-foreground">{t('settings.billing.availablePlans')}</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Loading State */}
|
|
||||||
{isLoading.value ? (
|
{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) => (
|
{[1, 2, 3].map((i) => (
|
||||||
<div key={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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* Plans Grid */
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
{sortedPlans.value.map((plan) => (
|
{sortedPlans.value.map((plan) => (
|
||||||
<div
|
<div
|
||||||
key={plan.id}
|
key={plan.id}
|
||||||
class={[
|
class={[
|
||||||
'border rounded-lg p-4 hover:bg-muted/30 transition-all flex flex-col',
|
'flex flex-col rounded-lg border p-4 transition-all hover:bg-muted/30',
|
||||||
plan.id === props.currentPlanId ? 'border-primary/40 bg-primary/5' : 'border-border',
|
isCurrentPlan(plan.id) ? 'border-primary/40 bg-primary/5' : 'border-border',
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<h3 class="text-lg font-semibold text-foreground">{plan.name}</h3>
|
<h3 class="text-lg font-semibold text-foreground">{plan.name}</h3>
|
||||||
{plan.id === props.currentPlanId && (
|
{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')}>
|
<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}
|
{subscriptionSummary.value.description}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<span class="text-2xl font-bold text-foreground">{auth.formatMoney(plan.price || 0)}</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
<ul class="space-y-2 mb-4 text-sm">
|
<ul class="mb-4 space-y-2 text-sm">
|
||||||
{(plan.features || []).map((feature: string) => (
|
{(plan.features || []).map((feature: string) => (
|
||||||
<li key={feature} class="flex items-center gap-2 text-foreground/70">
|
<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}
|
{feature}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{plan.id !== props.currentPlanId && (
|
{!isCurrentPlan(plan.id) && (
|
||||||
<button
|
<button
|
||||||
disabled={props.selectedPlanId === plan.id}
|
disabled={isSelectingPlan(plan.id)}
|
||||||
class={[
|
class={[
|
||||||
'w-full py-2 px-4 rounded-md text-sm font-medium transition-all mt-auto',
|
'mt-auto w-full rounded-md px-4 py-2 text-sm font-medium transition-all',
|
||||||
props.selectedPlanId === plan.id
|
isSelectingPlan(plan.id)
|
||||||
? 'bg-muted/50 text-foreground/60 cursor-wait'
|
? 'cursor-wait bg-muted/50 text-foreground/60'
|
||||||
: 'bg-primary text-white hover:bg-primary/90',
|
: 'bg-primary text-white hover:bg-primary/90',
|
||||||
]}
|
]}
|
||||||
onClick={() => emit('upgrade', plan)}
|
onClick={() => emit('upgrade', plan)}
|
||||||
>
|
>
|
||||||
{props.selectedPlanId === plan.id
|
{isSelectingPlan(plan.id)
|
||||||
? t('settings.billing.upgradeDialog.selecting')
|
? t('settings.billing.upgradeDialog.selecting')
|
||||||
: t('settings.billing.upgradeDialog.choosePlan')
|
: t('settings.billing.upgradeDialog.choosePlan')}
|
||||||
}
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -155,4 +165,5 @@ const PlanSelection = defineComponent({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default PlanSelection;
|
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