diff --git a/src/routes/settings/Billing/Billing.vue b/src/routes/settings/Billing/Billing.vue index 0f6b5a3..9fb553b 100644 --- a/src/routes/settings/Billing/Billing.vue +++ b/src/routes/settings/Billing/Billing.vue @@ -1,28 +1,24 @@ - \ No newline at end of file diff --git a/src/routes/settings/Billing/components/UpgradePlan.tsx b/src/routes/settings/Billing/components/UpgradePlan.tsx new file mode 100644 index 0000000..1acf5c7 --- /dev/null +++ b/src/routes/settings/Billing/components/UpgradePlan.tsx @@ -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, + 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(1); + const selectedPaymentMethod = ref('wallet'); + const purchaseTopupAmount = ref(null); + const purchaseLoading = ref(false); + const purchaseError = ref(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[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 () => ( + { emit('update:visible', visible); }} + onClose={() => { emit('close'); }} + v-slots={{ + default: () => ( + <> + {props.selectedPlan ? ( +
+
+
+
+

+ {t('settings.billing.upgradeDialog.selectedPlan')} +

+

{props.selectedPlan.name}

+

+ {props.selectedPlan.description || t('settings.billing.availablePlansHint')} +

+
+ +
+

{t('settings.billing.upgradeDialog.basePrice')}

+

+ {auth.formatMoney(props.selectedPlan.price || 0)} +

+

{t('settings.billing.upgradeDialog.perMonthBase')}

+
+
+
+ +
+
+

{t('settings.billing.upgradeDialog.termTitle')}

+

{t('settings.billing.upgradeDialog.termHint')}

+
+ +
+ {TERM_OPTIONS.map((months) => ( + + ))} +
+
+ +
+
+

+ {t('settings.billing.upgradeDialog.totalLabel')} +

+

+ {auth.formatMoney(selectedTotalAmount.value)} +

+
+
+

+ {t('settings.billing.upgradeDialog.walletBalanceLabel')} +

+

+ {auth.formatMoney(walletBalance.value)} +

+
+
+

+ {t('settings.billing.upgradeDialog.shortfallLabel')} +

+

+ {auth.formatMoney(selectedShortfall.value)} +

+
+
+ + {selectedNeedsTopup.value ? ( +
+
+

+ {t('settings.billing.upgradeDialog.paymentMethodTitle')} +

+

{t('settings.billing.upgradeDialog.paymentMethodHint')}

+
+ +
+ + + +
+
+ ) : ( +
+ {t('settings.billing.upgradeDialog.walletCoveredHint')} +
+ )} + + {selectedNeedsTopup.value && selectedPaymentMethod.value === 'topup' && ( +
+ + +

+ {t('settings.billing.upgradeDialog.topupAmountHint', { shortfall: auth.formatMoney(selectedShortfall.value) })} +

+
+ )} + + {selectedNeedsTopup.value && selectedPaymentMethod.value === 'wallet' && ( +
+ {t('settings.billing.upgradeDialog.walletInsufficientHint', { shortfall: auth.formatMoney(selectedShortfall.value) })} +
+ )} + + {purchaseError.value && ( +
+ {purchaseError.value} +
+ )} +
+ ) : null} + + ), + footer: () => ( +
+

{t('settings.billing.upgradeDialog.footerHint')}

+
+ + {t('common.cancel')} + + + {upgradeSubmitLabel.value} + +
+
+ ), + }} + /> + ); + }, +}); + +export default UpgradePlan;