refactor: update billing components for improved UX and localization

- Refactored BillingTopupDialog.vue to use localized strings for titles, subtitles, and labels.
- Modified PaymentHistory.tsx to use conditional rendering for item details.
- Enhanced PlanSelection.tsx with better prop handling and improved UI responsiveness.
- Removed UpgradeDialog.vue and replaced it with a new UpgradePlan.tsx component for better structure and functionality.
- Added logic to handle payment methods and top-up amounts in UpgradePlan.tsx.
- Improved overall code readability and maintainability across billing components.
This commit is contained in:
2026-03-24 19:09:15 +07:00
parent 698abcec22
commit 5350f421f9
6 changed files with 521 additions and 585 deletions

View File

@@ -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) { await refreshBillingState();
purchaseTopupAmount.value = value;
return;
}
if (value === '') {
purchaseTopupAmount.value = null;
return;
}
const parsed = Number(value);
purchaseTopupAmount.value = Number.isNaN(parsed) ? null : parsed;
};
const submitUpgrade = async () => {
if (!selectedPlan.value?.id) return;
purchaseLoading.value = true;
purchaseError.value = null;
try {
const paymentMethod: UpgradePaymentMethod = selectedNeedsTopup.value ? selectedPaymentMethod.value : 'wallet';
const payload: Parameters<typeof rpcClient.createPayment>[0] = {
planId: selectedPlan.value.id,
termMonths: selectedTermMonths.value,
paymentMethod: paymentMethod,
};
if (paymentMethod === 'topup') {
payload.topupAmount = purchaseTopupAmount.value || selectedShortfall.value;
}
await rpcClient.createPayment(payload);
await refreshBillingState();
toast.add({
severity: 'success',
summary: t('settings.billing.toast.subscriptionSuccessSummary'),
detail: t('settings.billing.toast.subscriptionSuccessDetail', {
plan: selectedPlan.value.name || '',
term: t('settings.billing.termOption', { months: selectedTermMonths.value })
// term: formatTermLabel(selectedTermMonths.value),
}),
life: 3000,
});
closeUpgradeDialog();
} catch (error) {
console.error(error);
const errorData = getApiErrorData(error);
const nextShortfall = typeof errorData?.shortfall === 'number'
? errorData.shortfall
: selectedShortfall.value;
if (nextShortfall > 0) {
selectedPaymentMethod.value = 'topup';
if ((purchaseTopupAmount.value || 0) < nextShortfall) {
purchaseTopupAmount.value = Number(nextShortfall.toFixed(2));
}
}
purchaseError.value = getApiErrorMessage(error, t('settings.billing.toast.subscriptionFailedDetail'));
} finally {
purchaseLoading.value = false;
}
}; };
const 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>

View File

@@ -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>

View File

@@ -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.join(' · ')} item.details?.length ? (
</p> <p class="mt-1 text-xs text-foreground/60">
{item.details.join(' · ')}
</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)}`}>

View File

@@ -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,142 +10,151 @@ 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();
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
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'),
description: t('settings.billing.subscription.expiringDescription', { description: t('settings.billing.subscription.expiringDescription', {
date: formattedDate, date: formattedDate,
}), }),
tone: 'warning' as const, tone: 'warning' as const,
}; };
} }
if (expiresAt) { if (expiresAt) {
return { return {
title: t('settings.billing.subscription.activeTitle'), title: t('settings.billing.subscription.activeTitle'),
description: t('settings.billing.subscription.activeDescription', { description: t('settings.billing.subscription.activeDescription', {
date: formattedDate, date: formattedDate,
}), }),
tone: 'default' as const, tone: 'default' as const,
}; };
} }
return { return {
title: t('settings.billing.subscription.activeTitle'), title: t('settings.billing.subscription.activeTitle'),
description: currentPlanName, description: currentPlanName,
tone: 'default' as const, tone: 'default' as const,
}; };
} }
if (expiresAt) { if (expiresAt) {
return { return {
title: t('settings.billing.subscription.expiredTitle'), title: t('settings.billing.subscription.expiredTitle'),
description: t('settings.billing.subscription.expiredDescription', { date: formattedDate }), description: t('settings.billing.subscription.expiredDescription', { date: formattedDate }),
tone: 'warning' as const, tone: 'warning' as const,
}; };
} }
return { return {
title: t('settings.billing.subscription.freeTitle'), title: t('settings.billing.subscription.freeTitle'),
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;

View File

@@ -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>

View 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;