feat: refactor billing components and add payment history
- Remove BillingWalletRow.vue component. - Update PlayerConfigsTable.vue to use JSX syntax and improve rendering logic. - Enhance auth store with currency and date formatting utilities. - Add ListIcon and MoneyCheck icon components. - Implement PaymentHistory component for displaying payment history with download functionality. - Create PlanSelection component for selecting billing plans with improved UI. - Introduce UpgradeDialog component for handling plan upgrades and payment methods.
This commit is contained in:
3
bun.lock
3
bun.lock
@@ -17,6 +17,7 @@
|
|||||||
"@unhead/vue": "^2.1.12",
|
"@unhead/vue": "^2.1.12",
|
||||||
"@vueuse/core": "^14.2.1",
|
"@vueuse/core": "^14.2.1",
|
||||||
"aws4fetch": "^1.0.20",
|
"aws4fetch": "^1.0.20",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"hono": "^4.12.7",
|
"hono": "^4.12.7",
|
||||||
"i18next": "^25.8.18",
|
"i18next": "^25.8.18",
|
||||||
@@ -433,6 +434,8 @@
|
|||||||
|
|
||||||
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
||||||
|
|
||||||
|
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||||
|
|
||||||
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||||
|
|
||||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||||
|
|||||||
4
components.d.ts
vendored
4
components.d.ts
vendored
@@ -57,8 +57,10 @@ declare module 'vue' {
|
|||||||
Layout: typeof import('./src/components/icons/Layout.vue')['default']
|
Layout: typeof import('./src/components/icons/Layout.vue')['default']
|
||||||
LayoutDashboard: typeof import('./src/components/icons/LayoutDashboard.vue')['default']
|
LayoutDashboard: typeof import('./src/components/icons/LayoutDashboard.vue')['default']
|
||||||
LinkIcon: typeof import('./src/components/icons/LinkIcon.vue')['default']
|
LinkIcon: typeof import('./src/components/icons/LinkIcon.vue')['default']
|
||||||
|
ListIcon: typeof import('./src/components/icons/ListIcon.vue')['default']
|
||||||
LockIcon: typeof import('./src/components/icons/LockIcon.vue')['default']
|
LockIcon: typeof import('./src/components/icons/LockIcon.vue')['default']
|
||||||
MailIcon: typeof import('./src/components/icons/MailIcon.vue')['default']
|
MailIcon: typeof import('./src/components/icons/MailIcon.vue')['default']
|
||||||
|
MoneyCheck: typeof import('./src/components/icons/MoneyCheck.vue')['default']
|
||||||
MonitorIcon: typeof import('./src/components/icons/MonitorIcon.vue')['default']
|
MonitorIcon: typeof import('./src/components/icons/MonitorIcon.vue')['default']
|
||||||
NotificationDrawer: typeof import('./src/components/NotificationDrawer.vue')['default']
|
NotificationDrawer: typeof import('./src/components/NotificationDrawer.vue')['default']
|
||||||
OfflineOverlay: typeof import('./src/components/OfflineOverlay.vue')['default']
|
OfflineOverlay: typeof import('./src/components/OfflineOverlay.vue')['default']
|
||||||
@@ -142,8 +144,10 @@ declare global {
|
|||||||
const Layout: typeof import('./src/components/icons/Layout.vue')['default']
|
const Layout: typeof import('./src/components/icons/Layout.vue')['default']
|
||||||
const LayoutDashboard: typeof import('./src/components/icons/LayoutDashboard.vue')['default']
|
const LayoutDashboard: typeof import('./src/components/icons/LayoutDashboard.vue')['default']
|
||||||
const LinkIcon: typeof import('./src/components/icons/LinkIcon.vue')['default']
|
const LinkIcon: typeof import('./src/components/icons/LinkIcon.vue')['default']
|
||||||
|
const ListIcon: typeof import('./src/components/icons/ListIcon.vue')['default']
|
||||||
const LockIcon: typeof import('./src/components/icons/LockIcon.vue')['default']
|
const LockIcon: typeof import('./src/components/icons/LockIcon.vue')['default']
|
||||||
const MailIcon: typeof import('./src/components/icons/MailIcon.vue')['default']
|
const MailIcon: typeof import('./src/components/icons/MailIcon.vue')['default']
|
||||||
|
const MoneyCheck: typeof import('./src/components/icons/MoneyCheck.vue')['default']
|
||||||
const MonitorIcon: typeof import('./src/components/icons/MonitorIcon.vue')['default']
|
const MonitorIcon: typeof import('./src/components/icons/MonitorIcon.vue')['default']
|
||||||
const NotificationDrawer: typeof import('./src/components/NotificationDrawer.vue')['default']
|
const NotificationDrawer: typeof import('./src/components/NotificationDrawer.vue')['default']
|
||||||
const OfflineOverlay: typeof import('./src/components/OfflineOverlay.vue')['default']
|
const OfflineOverlay: typeof import('./src/components/OfflineOverlay.vue')['default']
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"@unhead/vue": "^2.1.12",
|
"@unhead/vue": "^2.1.12",
|
||||||
"@vueuse/core": "^14.2.1",
|
"@vueuse/core": "^14.2.1",
|
||||||
"aws4fetch": "^1.0.20",
|
"aws4fetch": "^1.0.20",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"hono": "^4.12.7",
|
"hono": "^4.12.7",
|
||||||
"i18next": "^25.8.18",
|
"i18next": "^25.8.18",
|
||||||
|
|||||||
@@ -503,7 +503,7 @@
|
|||||||
"username": "Username",
|
"username": "Username",
|
||||||
"email": "Email Address",
|
"email": "Email Address",
|
||||||
"storageUsage": "Storage Usage",
|
"storageUsage": "Storage Usage",
|
||||||
"storageUsedOfLimit": "{{used}} of {{limit}} used",
|
"storageUsedOfLimit": "{{used}} used",
|
||||||
"editProfile": "Edit Profile",
|
"editProfile": "Edit Profile",
|
||||||
"changePassword": "Change Password"
|
"changePassword": "Change Password"
|
||||||
},
|
},
|
||||||
@@ -534,9 +534,9 @@
|
|||||||
"processing": "Processing...",
|
"processing": "Processing...",
|
||||||
"upgrade": "Upgrade",
|
"upgrade": "Upgrade",
|
||||||
"storage": "Storage",
|
"storage": "Storage",
|
||||||
"storageUsedOfLimit": "{{used}} of {{limit}} used",
|
"storageUsedOfLimit": "{{used}} used",
|
||||||
"totalVideos": "Total videos",
|
"totalVideos": "Total videos",
|
||||||
"totalVideosUsedOfLimit": "{{used}} of {{limit}} videos",
|
"totalVideosUsedOfLimit": "{{used}} videos",
|
||||||
"paymentHistory": "Payment History",
|
"paymentHistory": "Payment History",
|
||||||
"paymentHistorySubtitle": "Your past payments and invoices",
|
"paymentHistorySubtitle": "Your past payments and invoices",
|
||||||
"noPaymentHistory": "No payment history found.",
|
"noPaymentHistory": "No payment history found.",
|
||||||
@@ -552,9 +552,9 @@
|
|||||||
},
|
},
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"activeTitle": "Plan active",
|
"activeTitle": "Plan active",
|
||||||
"activeDescription": " {{plan}} is active until {{date}}",
|
"activeDescription": " Active until {{date}}",
|
||||||
"expiringTitle": "Expiring soon",
|
"expiringTitle": "Expiring soon",
|
||||||
"expiringDescription": " {{plan}} expires on {{date}}",
|
"expiringDescription": " Expires on {{date}}",
|
||||||
"expiredTitle": "Plan expired",
|
"expiredTitle": "Plan expired",
|
||||||
"expiredDescription": "Your last subscription ended on {{date}}",
|
"expiredDescription": "Your last subscription ended on {{date}}",
|
||||||
"freeTitle": "Free access",
|
"freeTitle": "Free access",
|
||||||
|
|||||||
@@ -503,7 +503,7 @@
|
|||||||
"username": "Tên người dùng",
|
"username": "Tên người dùng",
|
||||||
"email": "Địa chỉ email",
|
"email": "Địa chỉ email",
|
||||||
"storageUsage": "Dung lượng sử dụng",
|
"storageUsage": "Dung lượng sử dụng",
|
||||||
"storageUsedOfLimit": "Đã dùng {{used}} trên {{limit}}",
|
"storageUsedOfLimit": "Đã dùng {{used}}",
|
||||||
"editProfile": "Chỉnh sửa hồ sơ",
|
"editProfile": "Chỉnh sửa hồ sơ",
|
||||||
"changePassword": "Đổi mật khẩu"
|
"changePassword": "Đổi mật khẩu"
|
||||||
},
|
},
|
||||||
@@ -534,9 +534,9 @@
|
|||||||
"processing": "Đang xử lý...",
|
"processing": "Đang xử lý...",
|
||||||
"upgrade": "Nâng cấp",
|
"upgrade": "Nâng cấp",
|
||||||
"storage": "Dung lượng",
|
"storage": "Dung lượng",
|
||||||
"storageUsedOfLimit": "Đã dùng {{used}} trên {{limit}}",
|
"storageUsedOfLimit": "Đã dùng {{used}}",
|
||||||
"totalVideos": "Tổng video",
|
"totalVideos": "Tổng video",
|
||||||
"totalVideosUsedOfLimit": "{{used}} trên {{limit}} video",
|
"totalVideosUsedOfLimit": "{{used}} video",
|
||||||
"paymentHistory": "Lịch sử thanh toán",
|
"paymentHistory": "Lịch sử thanh toán",
|
||||||
"paymentHistorySubtitle": "Các khoản thanh toán và hóa đơn trước đây của bạn",
|
"paymentHistorySubtitle": "Các khoản thanh toán và hóa đơn trước đây của bạn",
|
||||||
"noPaymentHistory": "Không tìm thấy lịch sử thanh toán.",
|
"noPaymentHistory": "Không tìm thấy lịch sử thanh toán.",
|
||||||
@@ -551,9 +551,9 @@
|
|||||||
},
|
},
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"activeTitle": "Gói đang hoạt động",
|
"activeTitle": "Gói đang hoạt động",
|
||||||
"activeDescription": " {{plan}} có hiệu lực đến {{date}}",
|
"activeDescription": " Hiệu lực đến {{date}}",
|
||||||
"expiringTitle": "Sắp hết hạn",
|
"expiringTitle": "Sắp hết hạn",
|
||||||
"expiringDescription": " {{plan}} sẽ hết hạn vào {{date}}",
|
"expiringDescription": "Hết hạn vào {{date}}",
|
||||||
"expiredTitle": "Gói đã hết hạn",
|
"expiredTitle": "Gói đã hết hạn",
|
||||||
"expiredDescription": "Gói gần nhất của bạn đã kết thúc vào {{date}}",
|
"expiredDescription": "Gói gần nhất của bạn đã kết thúc vào {{date}}",
|
||||||
"freeTitle": "Gói miễn phí",
|
"freeTitle": "Gói miễn phí",
|
||||||
|
|||||||
7
src/components/icons/ListIcon.vue
Normal file
7
src/components/icons/ListIcon.vue
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 468 468"><path d="M10 170v224c0 35 29 64 64 64h320c35 0 64-29 64-64V170H10zm96 88c0-13 11-24 24-24h208c13 0 24 11 24 24s-11 24-24 24H130c-13 0-24-11-24-24zm0 112c0-13 11-24 24-24h208c13 0 24 11 24 24s-11 24-24 24H130c-13 0-24-11-24-24z" fill="color-mix(in srgb, var(--colors-primary-DEFAULT) 40%, transparent)"/><path d="M74 10c-35 0-64 29-64 64v96h448V74c0-35-29-64-64-64H74zm240 48h64c7 0 12 4 15 10 2 6 1 13-4 17l-32 32c-6 7-16 7-22 0l-32-32c-5-4-6-11-4-17 3-6 9-10 15-10z" fill="var(--colors-primary-DEFAULT)"/></svg>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-10 -226 468 468"><path d="M64-184h320c18 0 32 14 32 32v64H32v-64c0-18 14-32 32-32zM32-56h384v224c0 18-14 32-32 32H64c-18 0-32-14-32-32V-56zM0-152v320c0 35 29 64 64 64h320c35 0 64-29 64-64v-320c0-35-29-64-64-64H64c-35 0-64 29-64 64zM112 8c-9 0-16 7-16 16s7 16 16 16h224c9 0 16-7 16-16s-7-16-16-16H112zm0 96c-9 0-16 7-16 16s7 16 16 16h224c9 0 16-7 16-16s-7-16-16-16H112zm200-260c-5 0-9 3-11 7-2 5-1 10 3 14l24 24c4 4 12 4 17 0l24-24c3-4 4-9 2-14-2-4-6-7-11-7h-48z" fill="currentColor"/></svg>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
defineProps<{ filled?: boolean }>();
|
||||||
|
</script>
|
||||||
10
src/components/icons/MoneyCheck.vue
Normal file
10
src/components/icons/MoneyCheck.vue
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
defineProps<{
|
||||||
|
filled?: boolean;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 610 500"><path d="M10 74c0-35 29-64 64-64h384c35 0 64 29 64 64v73c-21 3-41 13-58 29l-58 58H306c-13 0-24 11-24 24s11 24 24 24h52l-64 64c-13 14-23 30-28 48H74c-35 0-64-29-64-64V74zm76 93c0 25 19 47 44 51l42 7c6 1 10 7 10 13 0 7-5 12-12 12h-56c-11 0-20 9-20 20s9 20 20 20h24v4c0 11 9 20 20 20s20-9 20-20v-5c25-4 44-25 44-51s-18-48-44-52l-41-7c-6-1-11-6-11-12 0-7 6-13 13-13h47c11 0 20-9 20-20s-9-20-20-20h-8v-4c0-11-9-20-20-20s-20 9-20 20v4c-29 0-52 24-52 53zm196-21c0 13 11 24 24 24h128c13 0 24-11 24-24s-11-24-24-24H306c-13 0-24 11-24 24z" fill="color-mix(in srgb, var(--colors-primary-DEFAULT) 40%, transparent)"/><path d="m298 473 12-60c3-12 9-24 18-33l119-119 80 80-119 119c-9 9-20 15-33 18l-59 12h-3c-8 0-15-7-15-15v-3zm0 0zm251-154-80-80 29-29c22-22 58-22 80 0s22 58 0 80l-29 29z" fill="var(--colors-primary-DEFAULT)"/></svg>
|
||||||
|
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 607 500"><path d="M74 42h384c18 0 32 14 32 32v81c11-3 22-5 32-5V74c0-35-28-64-64-64H74c-35 0-64 29-64 64v256c0 35 29 64 64 64h189l1-4c1-9 4-19 8-28H74c-17 0-32-14-32-32V74c0-18 15-32 32-32zm240 192c-8 0-16 7-16 16s8 16 16 16h44l32-32h-76zm-16-80c0 9 8 16 16 16h96c9 0 16-7 16-16s-7-16-16-16h-96c-8 0-16 7-16 16zM170 98c-8 0-16 7-16 16v8h-1c-26 0-47 21-47 46 0 23 17 42 39 46l45 8c7 1 12 7 12 14 0 8-6 14-14 14h-58c-8 0-16 7-16 16s8 16 16 16h24v8c0 9 8 16 16 16 9 0 16-7 16-16v-8h2c26 0 46-21 46-46 0-23-16-42-38-46l-46-8c-6-1-12-7-12-14 0-8 7-14 15-14h49c9 0 16-7 16-16s-7-16-16-16h-16v-8c0-9-7-16-16-16zm182 288 102-102 50 51-102 102c-4 5-11 8-17 9l-51 8 9-51c1-6 4-12 8-17zm0 0zm124-125 21-20c14-14 37-14 51 0s14 36 0 50l-21 21-51-51zM311 398l-12 75c0 1-1 1-1 2 0 8 7 15 15 15h3l74-13c13-2 26-8 35-17l145-146c27-26 27-69 0-96-26-26-69-26-96 0L329 364c-9 9-16 21-18 34z" fill="currentColor"/></svg>
|
||||||
|
</template>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { cva } from "class-variance-authority";
|
||||||
|
import { ButtonHTMLAttributes, computed } from 'vue';
|
||||||
type UiButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
|
type UiButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||||
type UiButtonSize = 'sm' | 'md' | 'lg';
|
type UiButtonSize = 'sm' | 'md' | 'lg';
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ const props = withDefaults(
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
type?: 'button' | 'submit' | 'reset';
|
type?: 'button' | 'submit' | 'reset';
|
||||||
|
onClick?: ButtonHTMLAttributes['onClick'];
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
variant: 'primary',
|
variant: 'primary',
|
||||||
@@ -24,32 +26,37 @@ const props = withDefaults(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const isDisabled = computed(() => props.disabled || props.loading);
|
const isDisabled = computed(() => props.disabled || props.loading);
|
||||||
|
const buttonVariants = cva(":uno: inline-flex items-center justify-center gap-2 rounded-md border font-medium whitespace-nowrap shadow-[0_1px_0_rgba(27,31,36,0.04),0_1px_3px_rgba(27,31,36,0.12)] outline-none transition-[transform,box-shadow,background-color,border-color,color,opacity] duration-150 ease-out active:translate-y-[0.5px] hover:shadow-[0_2px_0_rgba(27,31,36,0.06)] disabled:cursor-not-allowed disabled:opacity-60 focus-visible:ring-4",
|
||||||
const classes = computed(() => {
|
{
|
||||||
const variants: Record<UiButtonVariant, string> = {
|
variants: {
|
||||||
|
variant: {
|
||||||
primary: 'border-transparent bg-primary text-white hover:bg-primaryHover focus-visible:ring-primary/25',
|
primary: 'border-transparent bg-primary text-white hover:bg-primaryHover focus-visible:ring-primary/25',
|
||||||
secondary: 'border-border bg-white text-text hover:bg-header focus-visible:ring-#0969da/20',
|
secondary: 'border-border bg-white text-text hover:bg-header focus-visible:ring-#0969da/20',
|
||||||
ghost: 'border-transparent bg-transparent text-text hover:bg-header focus-visible:ring-#0969da/20 shadow-none',
|
ghost: 'border-transparent bg-transparent text-text hover:bg-header focus-visible:ring-#0969da/20 shadow-none',
|
||||||
danger: 'border-transparent bg-danger text-white hover:opacity-92 focus-visible:ring-danger/20',
|
danger: 'border-transparent bg-danger text-white hover:opacity-92 focus-visible:ring-danger/20',
|
||||||
};
|
},
|
||||||
|
size: {
|
||||||
const sizes: Record<UiButtonSize, string> = {
|
|
||||||
sm: 'min-h-[28px] px-3 text-[12px] leading-[20px]',
|
sm: 'min-h-[28px] px-3 text-[12px] leading-[20px]',
|
||||||
md: 'min-h-[32px] px-3 text-[14px] leading-[20px]',
|
md: 'min-h-[32px] px-3 text-[14px] leading-[20px]',
|
||||||
lg: 'min-h-[36px] px-4 text-[14px] leading-[20px]',
|
lg: 'min-h-[36px] px-4 text-[14px] leading-[20px]',
|
||||||
};
|
},
|
||||||
|
block: {
|
||||||
|
true: 'w-full',
|
||||||
|
false: '',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: props.variant,
|
||||||
|
size: props.size,
|
||||||
|
block: props.block,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return [
|
|
||||||
'inline-flex items-center justify-center gap-2 rounded-md border font-medium whitespace-nowrap shadow-primer outline-none transition-[transform,box-shadow,background-color,border-color,color,opacity] duration-150 ease-out active:translate-y-[0.5px] hover:shadow-[0_2px_0_rgba(27,31,36,0.06)] disabled:cursor-not-allowed disabled:opacity-60 focus-visible:ring-4',
|
|
||||||
variants[props.variant],
|
|
||||||
sizes[props.size],
|
|
||||||
props.block ? 'w-full' : '',
|
|
||||||
].join(' ');
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<button :type="type" :disabled="isDisabled" :class="classes" :aria-busy="loading || undefined">
|
<button :type="type" :disabled="isDisabled" :class="cn(buttonVariants({variant, size, block}))" v-on:click="onClick" :aria-busy="loading || undefined">
|
||||||
<span
|
<span
|
||||||
v-if="loading"
|
v-if="loading"
|
||||||
class="h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-current border-r-transparent"
|
class="h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-current border-r-transparent"
|
||||||
|
|||||||
@@ -2,14 +2,9 @@
|
|||||||
import XIcon from '@/components/icons/XIcon.vue';
|
import XIcon from '@/components/icons/XIcon.vue';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useTranslation } from 'i18next-vue';
|
import { useTranslation } from 'i18next-vue';
|
||||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
import { onBeforeUnmount, watch } from 'vue';
|
||||||
|
|
||||||
// Ensure client-side only rendering to avoid hydration mismatch
|
// Ensure client-side only rendering to avoid hydration mismatch
|
||||||
const isMounted = ref(false);
|
|
||||||
onMounted(() => {
|
|
||||||
isMounted.value = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -43,8 +38,14 @@ watch(
|
|||||||
() => props.visible,
|
() => props.visible,
|
||||||
(v) => {
|
(v) => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
if (v) window.addEventListener('keydown', onKeydown);
|
if (v) {
|
||||||
else window.removeEventListener('keydown', onKeydown);
|
document.body.style.overflow = 'hidden';
|
||||||
|
window.addEventListener('keydown', onKeydown);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
document.body.style.overflow = 'unset';
|
||||||
|
window.removeEventListener('keydown', onKeydown);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
@@ -56,7 +57,8 @@ onBeforeUnmount(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Teleport v-if="isMounted" to="body">
|
<ClientOnly>
|
||||||
|
<Teleport to="body">
|
||||||
<Transition
|
<Transition
|
||||||
enter-active-class="transition-all duration-200 ease-out"
|
enter-active-class="transition-all duration-200 ease-out"
|
||||||
enter-from-class="opacity-0"
|
enter-from-class="opacity-0"
|
||||||
@@ -68,21 +70,21 @@ onBeforeUnmount(() => {
|
|||||||
<div v-if="visible" class="fixed inset-0 z-[9999]">
|
<div v-if="visible" class="fixed inset-0 z-[9999]">
|
||||||
<!-- Backdrop -->
|
<!-- Backdrop -->
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 bg-black/30"
|
class="absolute inset-0 bg-black/40"
|
||||||
@click="closable && close()"
|
@click="closable && close()"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Panel -->
|
<!-- Panel -->
|
||||||
<div class="absolute inset-0 flex items-center justify-center p-4">
|
<div class="absolute inset-0 flex items-center justify-center p-4">
|
||||||
<div :class="cn('w-full bg-header border border-border rounded-lg shadow-lg overflow-hidden', maxWidthClass)">
|
<div :class="cn('w-full bg-white border border-border rounded-lg shadow-lg overflow-hidden', maxWidthClass)">
|
||||||
<!-- Header slot -->
|
<!-- Header slot -->
|
||||||
<div v-if="$slots.header" class="px-5 py-4 border-b border-border">
|
<div v-if="$slots.header" class="px-5 py-4 border-b border-border">
|
||||||
<slot name="header" :close="close" />
|
<slot name="header" :close="close" />
|
||||||
</div>
|
</div>
|
||||||
<!-- Default title -->
|
<!-- Default title -->
|
||||||
<div v-else-if="title" class="flex items-center justify-between gap-3 px-5 py-4 border-b border-border">
|
<div v-else-if="title" class="flex items-center justify-between gap-3 px-5 py-4 border-b border-border">
|
||||||
<h3 class="text-sm font-semibold text-foreground">
|
<h3 class="font-semibold text-foreground">
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
@@ -97,7 +99,7 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div class="p-5">
|
<div class="p-5 max-h-[80vh] overflow-y-auto">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -110,4 +112,5 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
|
</ClientOnly>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
100
src/lib/utils.ts
100
src/lib/utils.ts
@@ -5,7 +5,10 @@ import { twMerge } from "tailwind-merge";
|
|||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
export function debounce<Func extends (...args: any[]) => any>(func: Func, wait: number): Func {
|
export function debounce<Func extends (...args: any[]) => any>(
|
||||||
|
func: Func,
|
||||||
|
wait: number
|
||||||
|
): Func {
|
||||||
let timeout: ReturnType<typeof setTimeout> | null;
|
let timeout: ReturnType<typeof setTimeout> | null;
|
||||||
return function (this: any, ...args: any[]) {
|
return function (this: any, ...args: any[]) {
|
||||||
if (timeout) clearTimeout(timeout);
|
if (timeout) clearTimeout(timeout);
|
||||||
@@ -39,7 +42,7 @@ export function getImageAspectRatio(url: string): Promise<AspectInfo> {
|
|||||||
width: w,
|
width: w,
|
||||||
height: h,
|
height: h,
|
||||||
ratio: `${w / g}:${h / g}`,
|
ratio: `${w / g}:${h / g}`,
|
||||||
float: w / h
|
float: w / h,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -50,9 +53,9 @@ export function getImageAspectRatio(url: string): Promise<AspectInfo> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const formatBytes = (bytes?: number) => {
|
export const formatBytes = (bytes?: number) => {
|
||||||
if (!bytes) return '0 B';
|
if (!bytes) return "0 B";
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
const value = parseFloat((bytes / Math.pow(k, i)).toFixed(2));
|
const value = parseFloat((bytes / Math.pow(k, i)).toFixed(2));
|
||||||
return `${value} ${sizes[i]}`;
|
return `${value} ${sizes[i]}`;
|
||||||
@@ -60,44 +63,89 @@ export const formatBytes = (bytes?: number) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const formatDuration = (seconds?: number) => {
|
export const formatDuration = (seconds?: number) => {
|
||||||
if (!seconds) return '0:00';
|
if (!seconds) return "0:00";
|
||||||
const h = Math.floor(seconds / 3600);
|
const h = Math.floor(seconds / 3600);
|
||||||
const m = Math.floor((seconds % 3600) / 60);
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
const s = Math.floor(seconds % 60);
|
const s = Math.floor(seconds % 60);
|
||||||
|
|
||||||
if (h > 0) {
|
if (h > 0) {
|
||||||
return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
return `${h}:${m.toString().padStart(2, "0")}:${s
|
||||||
|
.toString()
|
||||||
|
.padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatDate = (dateString: string = "", dateOnly: boolean = false) => {
|
export const formatDate = (
|
||||||
if (!dateString) return '';
|
dateString: string = "",
|
||||||
const locale = typeof document !== 'undefined'
|
dateOnly: boolean = false
|
||||||
? document.documentElement.lang === 'vi' ? 'vi-VN' : 'en-US'
|
) => {
|
||||||
: 'en-US';
|
if (!dateString) return "";
|
||||||
|
const locale =
|
||||||
|
typeof document !== "undefined"
|
||||||
|
? document.documentElement.lang === "vi"
|
||||||
|
? "vi-VN"
|
||||||
|
: "en-US"
|
||||||
|
: "en-US";
|
||||||
return new Date(dateString).toLocaleDateString(locale, {
|
return new Date(dateString).toLocaleDateString(locale, {
|
||||||
month: 'short',
|
month: "short",
|
||||||
day: 'numeric',
|
day: "numeric",
|
||||||
year: 'numeric',
|
year: "numeric",
|
||||||
...(dateOnly ? {} : { hour: '2-digit', minute: '2-digit' })
|
...(dateOnly ? {} : { hour: "2-digit", minute: "2-digit" }),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
type Status = "success" | "failed" | "pending" | string;
|
||||||
export const getStatusSeverity = (status: string = "") => {
|
export const getStatusSeverity = (status: Status = "") => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'success':
|
case "success":
|
||||||
case 'ready':
|
case "ready":
|
||||||
return 'success';
|
return "success";
|
||||||
case 'failed':
|
case "failed":
|
||||||
return 'danger';
|
return "danger";
|
||||||
case 'pending':
|
case "pending":
|
||||||
return 'warn';
|
return "warn";
|
||||||
default:
|
default:
|
||||||
return 'info';
|
return "info";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
export const getStatusStyles = (status: Status = "") => {
|
||||||
|
switch (status) {
|
||||||
|
case "success":
|
||||||
|
return "bg-success/10 text-success";
|
||||||
|
case "failed":
|
||||||
|
return "bg-danger/10 text-danger";
|
||||||
|
case "pending":
|
||||||
|
return "bg-warning/10 text-warning";
|
||||||
|
default:
|
||||||
|
return "bg-info/10 text-info";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
export const isAdmin = (role: string = "") => {
|
export const isAdmin = (role: string = "") => {
|
||||||
const r = String(role).toLowerCase();
|
const r = String(role).toLowerCase();
|
||||||
return r === "admin" || r === "superadmin";
|
return r === "admin" || r === "superadmin";
|
||||||
};
|
};
|
||||||
|
export type ApiErrorPayload = {
|
||||||
|
code?: number;
|
||||||
|
message?: string;
|
||||||
|
data?: Record<string, any>;
|
||||||
|
};
|
||||||
|
export const getApiErrorPayload = (error: unknown): ApiErrorPayload | null => {
|
||||||
|
if (!error || typeof error !== "object") return null;
|
||||||
|
const candidate = error as {
|
||||||
|
error?: ApiErrorPayload;
|
||||||
|
data?: ApiErrorPayload;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (candidate.error && typeof candidate.error === "object")
|
||||||
|
return candidate.error;
|
||||||
|
if (candidate.data && typeof candidate.data === "object")
|
||||||
|
return candidate.data;
|
||||||
|
if (candidate.message) return { message: candidate.message };
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
export const getApiErrorMessage = (error: unknown, fallback: string) => {
|
||||||
|
const payload = getApiErrorPayload(error);
|
||||||
|
return payload?.message || fallback;
|
||||||
|
};
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useTranslation } from 'i18next-vue';
|
|||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import AdsVastDialog from './components/AdsVastDialog.vue';
|
import AdsVastDialog from './components/AdsVastDialog.vue';
|
||||||
import AdsVastNotices from './components/AdsVastNotices.vue';
|
import AdsVastNotices from './components/AdsVastNotices.vue';
|
||||||
import AdsVastTable from './components/AdsVastTable.tsx';
|
import AdsVastTable from './components/AdsVastTable';
|
||||||
import AdsVastToolbar from './components/AdsVastToolbar.vue';
|
import AdsVastToolbar from './components/AdsVastToolbar.vue';
|
||||||
import type {
|
import type {
|
||||||
AdTemplate,
|
AdTemplate,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { defineComponent, computed, type PropType } from 'vue';
|
|
||||||
import { useTranslation } from 'i18next-vue';
|
|
||||||
import { useAppToast } from '@/composables/useAppToast';
|
import { useAppToast } from '@/composables/useAppToast';
|
||||||
import type { ColumnDef } from '@tanstack/vue-table';
|
import type { ColumnDef } from '@tanstack/vue-table';
|
||||||
|
import { useTranslation } from 'i18next-vue';
|
||||||
|
import { computed, defineComponent, type PropType } from 'vue';
|
||||||
import type { AdTemplate } from '../types';
|
import type { AdTemplate } from '../types';
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
|
|||||||
@@ -5,63 +5,31 @@ import AppDialog from '@/components/ui/AppDialog.vue';
|
|||||||
import AppInput from '@/components/ui/AppInput.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 { formatBytes } from '@/lib/utils';
|
import { getApiErrorMessage, getApiErrorPayload } from '@/lib/utils';
|
||||||
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
|
|
||||||
import BillingHistorySection from '@/routes/settings/Billing/components/BillingHistorySection.vue';
|
|
||||||
import BillingPlansSection from '@/routes/settings/Billing/components/BillingPlansSection.vue';
|
|
||||||
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 BillingWalletRow from '@/routes/settings/Billing/components/BillingWalletRow.vue';
|
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
|
||||||
import type { Plan as ModelPlan, PaymentHistoryItem as PaymentHistoryApiItem } 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 { useQuery } from '@pinia/colada';
|
|
||||||
import { useTranslation } from 'i18next-vue';
|
import { useTranslation } from 'i18next-vue';
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import SettingsRow from '../components/SettingsRow.vue';
|
||||||
|
import PaymentHistory from './components/PaymentHistory';
|
||||||
|
import PlanSelection from './components/PlanSelection';
|
||||||
|
|
||||||
const TERM_OPTIONS = [1, 3, 6, 12] as const;
|
const TERM_OPTIONS = [1, 3, 6, 12] as const;
|
||||||
type UpgradePaymentMethod = 'wallet' | 'topup';
|
type UpgradePaymentMethod = 'wallet' | 'topup';
|
||||||
|
|
||||||
type InvoiceDownloadResponse = {
|
|
||||||
filename?: string;
|
|
||||||
contentType?: string;
|
|
||||||
content?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type PaymentHistoryItem = {
|
|
||||||
id: string;
|
|
||||||
date: string;
|
|
||||||
amount: number;
|
|
||||||
plan: string;
|
|
||||||
status: string;
|
|
||||||
invoiceId: string;
|
|
||||||
currency: string;
|
|
||||||
kind: string;
|
|
||||||
details?: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type ApiErrorPayload = {
|
|
||||||
code?: number;
|
|
||||||
message?: string;
|
|
||||||
data?: Record<string, any>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const toast = useAppToast();
|
const toast = useAppToast();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const { t, i18next } = useTranslation();
|
const { t, i18next } = useTranslation();
|
||||||
|
|
||||||
const { data: plansResponse, isLoading } = useQuery({
|
const { refetch: refetchUsage } = useUsageQuery();
|
||||||
key: () => ['billing-plans'],
|
|
||||||
query: () => rpcClient.listPlans(),
|
|
||||||
});
|
|
||||||
const { data: usageSnapshot, refetch: refetchUsage } = useUsageQuery();
|
|
||||||
|
|
||||||
const topupDialogVisible = ref(false);
|
const topupDialogVisible = ref(false);
|
||||||
const topupAmount = ref<number | null>(null);
|
const topupAmount = ref<number | null>(null);
|
||||||
const topupLoading = ref(false);
|
const topupLoading = ref(false);
|
||||||
const historyLoading = ref(false);
|
|
||||||
const downloadingInvoiceId = ref<string | null>(null);
|
|
||||||
const topupPresets = [10, 20, 50, 100];
|
const topupPresets = [10, 20, 50, 100];
|
||||||
const paymentHistory = ref<PaymentHistoryItem[]>([]);
|
|
||||||
|
|
||||||
const upgradeDialogVisible = ref(false);
|
const upgradeDialogVisible = ref(false);
|
||||||
const selectedPlan = ref<ModelPlan | null>(null);
|
const selectedPlan = ref<ModelPlan | null>(null);
|
||||||
@@ -71,40 +39,8 @@ const purchaseTopupAmount = ref<number | null>(null);
|
|||||||
const purchaseLoading = ref(false);
|
const purchaseLoading = ref(false);
|
||||||
const purchaseError = ref<string | null>(null);
|
const purchaseError = ref<string | null>(null);
|
||||||
|
|
||||||
const plans = computed(() => plansResponse.value?.plans || [] as ModelPlan[]);
|
|
||||||
|
|
||||||
const currentPlanId = computed(() => auth.user?.plan_id || undefined);
|
const currentPlanId = computed(() => auth.user?.plan_id || undefined);
|
||||||
const currentPlan = computed(() => plans.value.find(plan => plan.id === currentPlanId.value));
|
|
||||||
const currentPlanName = computed(() => currentPlan.value?.name || t('settings.billing.unknownPlan'));
|
|
||||||
const walletBalance = computed(() => auth.user?.wallet_balance || 0);
|
const walletBalance = computed(() => auth.user?.wallet_balance || 0);
|
||||||
const storageUsed = computed(() => usageSnapshot.value?.totalStorage ?? 0);
|
|
||||||
const uploadsUsed = computed(() => usageSnapshot.value?.totalVideos ?? 0);
|
|
||||||
const storageLimit = computed(() => {
|
|
||||||
const activePlan = plans.value.find(plan => plan.id === currentPlanId.value);
|
|
||||||
return activePlan?.storageLimit || 10737418240;
|
|
||||||
});
|
|
||||||
const uploadsLimit = computed(() => {
|
|
||||||
const activePlan = plans.value.find(plan => plan.id === currentPlanId.value);
|
|
||||||
return activePlan?.uploadLimit || 50;
|
|
||||||
});
|
|
||||||
const storagePercentage = computed(() =>
|
|
||||||
Math.min(Math.round((storageUsed.value / storageLimit.value) * 100), 100),
|
|
||||||
);
|
|
||||||
const uploadsPercentage = computed(() =>
|
|
||||||
Math.min(Math.round((uploadsUsed.value / uploadsLimit.value) * 100), 100),
|
|
||||||
);
|
|
||||||
|
|
||||||
const localeTag = computed(() => i18next.resolvedLanguage === 'vi' ? 'vi-VN' : 'en-US');
|
|
||||||
const currencyFormatter = computed(() => new Intl.NumberFormat(localeTag.value, {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'USD',
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
}));
|
|
||||||
const shortDateFormatter = computed(() => new Intl.DateTimeFormat(localeTag.value, {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
}));
|
|
||||||
|
|
||||||
const selectedPlanId = computed(() => upgradeDialogVisible.value ? selectedPlan.value?.id || null : null);
|
const selectedPlanId = computed(() => upgradeDialogVisible.value ? selectedPlan.value?.id || null : null);
|
||||||
const selectedPlanPrice = computed(() => selectedPlan.value?.price || 0);
|
const selectedPlanPrice = computed(() => selectedPlan.value?.price || 0);
|
||||||
@@ -125,195 +61,17 @@ const upgradeSubmitLabel = computed(() => {
|
|||||||
return t('settings.billing.upgradeDialog.payWithWallet');
|
return t('settings.billing.upgradeDialog.payWithWallet');
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatMoney = (amount: number) => currencyFormatter.value.format(amount);
|
|
||||||
|
|
||||||
const formatDuration = (seconds?: number) => {
|
|
||||||
if (!seconds) return t('settings.billing.durationMinutes', { minutes: 0 });
|
|
||||||
if (seconds < 0) return t('settings.billing.durationMinutes', { minutes: -1 }).replace("-1", "∞")
|
|
||||||
return t('settings.billing.durationMinutes', { minutes: Math.floor(seconds / 60) });
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatHistoryDate = (value?: string) => {
|
|
||||||
if (!value) return '-';
|
|
||||||
const date = new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) return '-';
|
|
||||||
return shortDateFormatter.value.format(date);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTermLabel = (months: number) => t('settings.billing.termOption', { months });
|
|
||||||
|
|
||||||
const formatPaymentMethodLabel = (value?: string) => {
|
|
||||||
switch ((value || '').toLowerCase()) {
|
|
||||||
case 'topup':
|
|
||||||
return t('settings.billing.paymentMethod.topup');
|
|
||||||
case 'wallet':
|
|
||||||
default:
|
|
||||||
return t('settings.billing.paymentMethod.wallet');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPlanStorageText = (plan: ModelPlan) => t('settings.billing.planStorage', { storage: formatBytes(plan.storageLimit || 0) });
|
|
||||||
const getPlanDurationText = (plan: ModelPlan) => t('settings.billing.planDuration', { duration: formatDuration(plan.durationLimit) });
|
|
||||||
const getPlanUploadsText = (plan: ModelPlan) => t('settings.billing.planUploads', { count: plan.uploadLimit || 0 });
|
|
||||||
|
|
||||||
const getStatusStyles = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'success':
|
|
||||||
return 'bg-success/10 text-success';
|
|
||||||
case 'failed':
|
|
||||||
return 'bg-danger/10 text-danger';
|
|
||||||
case 'pending':
|
|
||||||
return 'bg-warning/10 text-warning';
|
|
||||||
default:
|
|
||||||
return 'bg-info/10 text-info';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusLabel = (status: string) => {
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
success: t('settings.billing.status.success'),
|
|
||||||
failed: t('settings.billing.status.failed'),
|
|
||||||
pending: t('settings.billing.status.pending'),
|
|
||||||
};
|
|
||||||
return map[status] || status;
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeHistoryStatus = (status?: string) => {
|
|
||||||
switch ((status || '').toLowerCase()) {
|
|
||||||
case 'success':
|
|
||||||
case 'succeeded':
|
|
||||||
case 'paid':
|
|
||||||
return 'success';
|
|
||||||
case 'failed':
|
|
||||||
case 'error':
|
|
||||||
case 'canceled':
|
|
||||||
case 'cancelled':
|
|
||||||
return 'failed';
|
|
||||||
case 'pending':
|
|
||||||
case 'processing':
|
|
||||||
default:
|
|
||||||
return 'pending';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getApiErrorPayload = (error: unknown): ApiErrorPayload | null => {
|
|
||||||
if (!error || typeof error !== 'object') return null;
|
|
||||||
const candidate = error as { error?: ApiErrorPayload; data?: ApiErrorPayload; message?: string };
|
|
||||||
|
|
||||||
if (candidate.error && typeof candidate.error === 'object') return candidate.error;
|
|
||||||
if (candidate.data && typeof candidate.data === 'object') return candidate.data;
|
|
||||||
if (candidate.message) return { message: candidate.message };
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getApiErrorMessage = (error: unknown, fallback: string) => {
|
|
||||||
const payload = getApiErrorPayload(error);
|
|
||||||
return payload?.message || fallback;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getApiErrorData = (error: unknown) => getApiErrorPayload(error)?.data || null;
|
const getApiErrorData = (error: unknown) => getApiErrorPayload(error)?.data || null;
|
||||||
|
|
||||||
const mapHistoryItem = (item: PaymentHistoryApiItem): PaymentHistoryItem => {
|
|
||||||
const details: string[] = [];
|
|
||||||
|
|
||||||
if (item.kind !== 'wallet_topup' && item.termMonths) {
|
|
||||||
details.push(formatTermLabel(item.termMonths));
|
|
||||||
}
|
|
||||||
if (item.kind !== 'wallet_topup' && item.paymentMethod) {
|
|
||||||
details.push(formatPaymentMethodLabel(item.paymentMethod));
|
|
||||||
}
|
|
||||||
if (item.kind !== 'wallet_topup' && item.expiresAt) {
|
|
||||||
details.push(t('settings.billing.history.validUntil', { date: formatHistoryDate(item.expiresAt) }));
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: item.id || '',
|
|
||||||
date: formatHistoryDate(item.createdAt),
|
|
||||||
amount: item.amount || 0,
|
|
||||||
plan: item.kind === 'wallet_topup'
|
|
||||||
? t('settings.billing.walletTopup')
|
|
||||||
: (item.planName || t('settings.billing.unknownPlan')),
|
|
||||||
status: normalizeHistoryStatus(item.status),
|
|
||||||
invoiceId: item.invoiceId || '-',
|
|
||||||
currency: item.currency || 'USD',
|
|
||||||
kind: item.kind || 'subscription',
|
|
||||||
details,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadPaymentHistory = async () => {
|
|
||||||
historyLoading.value = true;
|
|
||||||
try {
|
|
||||||
const response = await rpcClient.listPaymentHistory();
|
|
||||||
paymentHistory.value = (response.payments || []).map(mapHistoryItem);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
paymentHistory.value = [];
|
|
||||||
} finally {
|
|
||||||
historyLoading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const refreshBillingState = async () => {
|
const refreshBillingState = async () => {
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
auth.fetchMe(),
|
auth.fetchMe(),
|
||||||
loadPaymentHistory(),
|
// loadPaymentHistory(),
|
||||||
refetchUsage(),
|
refetchUsage(),
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
void loadPaymentHistory();
|
// void loadPaymentHistory();
|
||||||
|
|
||||||
const subscriptionSummary = computed(() => {
|
|
||||||
const expiresAt = auth.user?.planExpiresAt || auth.user?.plan_expires_at;
|
|
||||||
const formattedDate = formatHistoryDate(expiresAt);
|
|
||||||
|
|
||||||
if (auth.user?.plan_id) {
|
|
||||||
if (auth.user?.plan_expiring_soon && expiresAt) {
|
|
||||||
return {
|
|
||||||
title: t('settings.billing.subscription.expiringTitle'),
|
|
||||||
description: t('settings.billing.subscription.expiringDescription', {
|
|
||||||
plan: currentPlanName.value,
|
|
||||||
date: formattedDate,
|
|
||||||
}),
|
|
||||||
tone: 'warning' as const,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (expiresAt) {
|
|
||||||
return {
|
|
||||||
title: t('settings.billing.subscription.activeTitle'),
|
|
||||||
description: t('settings.billing.subscription.activeDescription', {
|
|
||||||
plan: currentPlanName.value,
|
|
||||||
date: formattedDate,
|
|
||||||
}),
|
|
||||||
tone: 'default' as const,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: t('settings.billing.subscription.activeTitle'),
|
|
||||||
description: currentPlanName.value,
|
|
||||||
tone: 'default' as const,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (expiresAt) {
|
|
||||||
return {
|
|
||||||
title: t('settings.billing.subscription.expiredTitle'),
|
|
||||||
description: t('settings.billing.subscription.expiredDescription', { date: formattedDate }),
|
|
||||||
tone: 'warning' as const,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: t('settings.billing.subscription.freeTitle'),
|
|
||||||
description: t('settings.billing.subscription.freeDescription'),
|
|
||||||
tone: 'default' as const,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const resetUpgradeState = () => {
|
const resetUpgradeState = () => {
|
||||||
selectedPlan.value = null;
|
selectedPlan.value = null;
|
||||||
@@ -410,7 +168,8 @@ const submitUpgrade = async () => {
|
|||||||
summary: t('settings.billing.toast.subscriptionSuccessSummary'),
|
summary: t('settings.billing.toast.subscriptionSuccessSummary'),
|
||||||
detail: t('settings.billing.toast.subscriptionSuccessDetail', {
|
detail: t('settings.billing.toast.subscriptionSuccessDetail', {
|
||||||
plan: selectedPlan.value.name || '',
|
plan: selectedPlan.value.name || '',
|
||||||
term: formatTermLabel(selectedTermMonths.value),
|
term: t('settings.billing.termOption', { months: selectedTermMonths.value })
|
||||||
|
// term: formatTermLabel(selectedTermMonths.value),
|
||||||
}),
|
}),
|
||||||
life: 3000,
|
life: 3000,
|
||||||
});
|
});
|
||||||
@@ -446,7 +205,7 @@ const handleTopup = async (amount: number) => {
|
|||||||
toast.add({
|
toast.add({
|
||||||
severity: 'success',
|
severity: 'success',
|
||||||
summary: t('settings.billing.toast.topupSuccessSummary'),
|
summary: t('settings.billing.toast.topupSuccessSummary'),
|
||||||
detail: t('settings.billing.toast.topupSuccessDetail', { amount: formatMoney(amount) }),
|
detail: t('settings.billing.toast.topupSuccessDetail', { amount: auth.formatMoney(amount) }),
|
||||||
life: 3000,
|
life: 3000,
|
||||||
});
|
});
|
||||||
topupDialogVisible.value = false;
|
topupDialogVisible.value = false;
|
||||||
@@ -464,51 +223,6 @@ const handleTopup = async (amount: number) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownloadInvoice = async (item: PaymentHistoryItem) => {
|
|
||||||
if (!item.id) return;
|
|
||||||
|
|
||||||
downloadingInvoiceId.value = item.id;
|
|
||||||
toast.add({
|
|
||||||
severity: 'info',
|
|
||||||
summary: t('settings.billing.toast.downloadingSummary'),
|
|
||||||
detail: t('settings.billing.toast.downloadingDetail', { invoiceId: item.invoiceId }),
|
|
||||||
life: 2000,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await rpcClient.downloadInvoice({ id: item.id }) as InvoiceDownloadResponse;
|
|
||||||
const content = response.content || '';
|
|
||||||
const contentType = response.contentType || 'text/plain;charset=utf-8';
|
|
||||||
const filename = response.filename || `${item.invoiceId}.txt`;
|
|
||||||
const blob = new Blob([content], { type: contentType });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const anchor = document.createElement('a');
|
|
||||||
anchor.href = url;
|
|
||||||
anchor.download = filename;
|
|
||||||
document.body.appendChild(anchor);
|
|
||||||
anchor.click();
|
|
||||||
document.body.removeChild(anchor);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
|
|
||||||
toast.add({
|
|
||||||
severity: 'success',
|
|
||||||
summary: t('settings.billing.toast.downloadedSummary'),
|
|
||||||
detail: t('settings.billing.toast.downloadedDetail', { invoiceId: item.invoiceId }),
|
|
||||||
life: 3000,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
toast.add({
|
|
||||||
severity: 'error',
|
|
||||||
summary: t('settings.billing.toast.downloadFailedSummary'),
|
|
||||||
detail: getApiErrorMessage(error, t('settings.billing.toast.downloadFailedDetail')),
|
|
||||||
life: 5000,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
downloadingInvoiceId.value = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openTopupDialog = () => {
|
const openTopupDialog = () => {
|
||||||
topupAmount.value = null;
|
topupAmount.value = null;
|
||||||
topupDialogVisible.value = true;
|
topupDialogVisible.value = true;
|
||||||
@@ -520,206 +234,168 @@ const selectPreset = (amount: number) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<SettingsSectionCard
|
<SettingsSectionCard :title="$t('settings.content.billing.title')"
|
||||||
:title="t('settings.content.billing.title')"
|
:description="$t('settings.content.billing.subtitle')">
|
||||||
:description="t('settings.content.billing.subtitle')"
|
<SettingsRow :title="$t('settings.billing.walletBalance')"
|
||||||
>
|
:description="$t('settings.billing.currentBalance', { balance: auth.formatMoney(walletBalance) })"
|
||||||
<BillingWalletRow
|
iconBoxClass="bg-primary/10">
|
||||||
:title="t('settings.billing.walletBalance')"
|
<template #icon>
|
||||||
:description="t('settings.billing.currentBalance', { balance: formatMoney(walletBalance) })"
|
<CoinsIcon class="w-5 h-5 text-primary" />
|
||||||
:button-label="t('settings.billing.topUp')"
|
</template>
|
||||||
:subscription-title="subscriptionSummary.title"
|
|
||||||
:subscription-description="subscriptionSummary.description"
|
|
||||||
:subscription-tone="subscriptionSummary.tone"
|
|
||||||
@topup="openTopupDialog"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<BillingPlansSection
|
<template #actions>
|
||||||
:title="t('settings.billing.availablePlans')"
|
<div class="flex flex-col items-end gap-2">
|
||||||
:description="t('settings.billing.availablePlansHint')"
|
<AppButton size="sm" @click="openTopupDialog">
|
||||||
:is-loading="isLoading"
|
<template #icon>
|
||||||
:plans="plans"
|
<PlusIcon class="w-4 h-4" />
|
||||||
:current-plan-id="currentPlanId"
|
</template>
|
||||||
:selecting-plan-id="selectedPlanId"
|
{{ $t('settings.billing.topUp') }}
|
||||||
:format-money="formatMoney"
|
</AppButton>
|
||||||
:get-plan-storage-text="getPlanStorageText"
|
</div>
|
||||||
:get-plan-duration-text="getPlanDurationText"
|
</template>
|
||||||
:get-plan-uploads-text="getPlanUploadsText"
|
</SettingsRow>
|
||||||
:current-plan-label="t('settings.billing.currentPlan')"
|
|
||||||
:selecting-label="t('settings.billing.upgradeDialog.selecting')"
|
|
||||||
:choose-label="t('settings.billing.upgradeDialog.choosePlan')"
|
|
||||||
@select="openUpgradeDialog"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<BillingUsageSection
|
<PlanSelection :current-plan-id="currentPlanId" :selectedPlanId="String(selectedPlanId)"
|
||||||
:storage-title="t('settings.billing.storage')"
|
@upgrade="openUpgradeDialog" />
|
||||||
:storage-description="t('settings.billing.storageUsedOfLimit', { used: formatBytes(storageUsed), limit: formatBytes(storageLimit) })"
|
<BillingUsageSection />
|
||||||
:storage-percentage="storagePercentage"
|
<PaymentHistory />
|
||||||
:uploads-title="t('settings.billing.totalVideos')"
|
|
||||||
:uploads-description="t('settings.billing.totalVideosUsedOfLimit', { used: uploadsUsed, limit: uploadsLimit })"
|
|
||||||
:uploads-percentage="uploadsPercentage"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<BillingHistorySection
|
|
||||||
:title="t('settings.billing.paymentHistory')"
|
|
||||||
:description="t('settings.billing.paymentHistorySubtitle')"
|
|
||||||
:items="paymentHistory"
|
|
||||||
:loading="historyLoading"
|
|
||||||
:downloading-id="downloadingInvoiceId"
|
|
||||||
:format-money="formatMoney"
|
|
||||||
:get-status-styles="getStatusStyles"
|
|
||||||
:get-status-label="getStatusLabel"
|
|
||||||
:date-label="t('settings.billing.table.date')"
|
|
||||||
:amount-label="t('settings.billing.table.amount')"
|
|
||||||
:plan-label="t('settings.billing.table.plan')"
|
|
||||||
:status-label="t('settings.billing.table.status')"
|
|
||||||
:invoice-label="t('settings.billing.table.invoice')"
|
|
||||||
:empty-label="t('settings.billing.noPaymentHistory')"
|
|
||||||
:download-label="t('settings.billing.download')"
|
|
||||||
@download="handleDownloadInvoice"
|
|
||||||
/>
|
|
||||||
</SettingsSectionCard>
|
</SettingsSectionCard>
|
||||||
|
|
||||||
<AppDialog
|
<AppDialog :visible="upgradeDialogVisible" :title="$t('settings.billing.upgradeDialog.title')"
|
||||||
:visible="upgradeDialogVisible"
|
maxWidthClass="max-w-2xl" @update:visible="onUpgradeDialogVisibilityChange" @close="closeUpgradeDialog">
|
||||||
:title="t('settings.billing.upgradeDialog.title')"
|
|
||||||
maxWidthClass="max-w-2xl"
|
|
||||||
@update:visible="onUpgradeDialogVisibilityChange"
|
|
||||||
@close="closeUpgradeDialog"
|
|
||||||
>
|
|
||||||
<div v-if="selectedPlan" class="space-y-5">
|
<div v-if="selectedPlan" class="space-y-5">
|
||||||
<div class="rounded-lg border border-border bg-muted/20 p-4">
|
<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 class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs font-medium uppercase tracking-[0.18em] text-foreground/50">
|
<p class="text-xs font-medium uppercase tracking-[0.18em] text-foreground/50">
|
||||||
{{ t('settings.billing.upgradeDialog.selectedPlan') }}
|
{{ $t('settings.billing.upgradeDialog.selectedPlan') }}
|
||||||
</p>
|
</p>
|
||||||
<h3 class="mt-1 text-lg font-semibold text-foreground">{{ selectedPlan.name }}</h3>
|
<h3 class="mt-1 text-lg font-semibold text-foreground">{{ selectedPlan.name }}</h3>
|
||||||
<p class="mt-1 text-sm text-foreground/70">
|
<p class="mt-1 text-sm text-foreground/70">
|
||||||
{{ selectedPlan.description || t('settings.billing.availablePlansHint') }}
|
{{ selectedPlan.description || $t('settings.billing.availablePlansHint') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-left md:text-right">
|
<div class="text-left md:text-right">
|
||||||
<p class="text-xs text-foreground/50">{{ t('settings.billing.upgradeDialog.basePrice') }}</p>
|
<p class="text-xs text-foreground/50">{{ $t('settings.billing.upgradeDialog.basePrice') }}</p>
|
||||||
<p class="mt-1 text-2xl font-semibold text-foreground">{{ formatMoney(selectedPlan.price || 0) }}</p>
|
<p class="mt-1 text-2xl font-semibold text-foreground">{{ auth.formatMoney(selectedPlan.price ||
|
||||||
<p class="text-xs text-foreground/60">{{ t('settings.billing.upgradeDialog.perMonthBase') }}</p>
|
0)
|
||||||
|
}}</p>
|
||||||
|
<p class="text-xs text-foreground/60">{{ $t('settings.billing.upgradeDialog.perMonthBase') }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-foreground">{{ t('settings.billing.upgradeDialog.termTitle') }}</p>
|
<p class="text-sm font-medium text-foreground">{{ $t('settings.billing.upgradeDialog.termTitle') }}
|
||||||
<p class="mt-1 text-xs text-foreground/60">{{ t('settings.billing.upgradeDialog.termHint') }}</p>
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-foreground/60">{{ $t('settings.billing.upgradeDialog.termHint') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-3 md:grid-cols-4">
|
<div class="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
<button
|
<button v-for="months in TERM_OPTIONS" :key="months" type="button" :class="[
|
||||||
v-for="months in TERM_OPTIONS"
|
|
||||||
:key="months"
|
|
||||||
type="button"
|
|
||||||
:class="[
|
|
||||||
'rounded-lg border px-4 py-3 text-left transition-all',
|
'rounded-lg border px-4 py-3 text-left transition-all',
|
||||||
selectedTermMonths === months
|
selectedTermMonths === months
|
||||||
? 'border-primary bg-primary/5 text-primary'
|
? 'border-primary bg-primary/5 text-primary'
|
||||||
: 'border-border bg-header text-foreground hover:border-primary/30 hover:bg-muted/30',
|
: 'border-border bg-header text-foreground hover:border-primary/30 hover:bg-muted/30',
|
||||||
]"
|
]" @click="selectedTermMonths = months">
|
||||||
@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) *
|
||||||
<p class="text-sm font-medium">{{ formatTermLabel(months) }}</p>
|
months)
|
||||||
<p class="mt-1 text-xs text-foreground/60">{{ formatMoney((selectedPlan.price || 0) * months) }}</p>
|
}}</p>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-3 md:grid-cols-3">
|
<div class="grid gap-3 md:grid-cols-3">
|
||||||
<div class="rounded-lg border border-border bg-header p-4">
|
<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="text-xs uppercase tracking-wide text-foreground/50">{{
|
||||||
<p class="mt-2 text-xl font-semibold text-foreground">{{ formatMoney(selectedTotalAmount) }}</p>
|
$t('settings.billing.upgradeDialog.totalLabel') }}</p>
|
||||||
|
<p class="mt-2 text-xl font-semibold text-foreground">{{ auth.formatMoney(selectedTotalAmount) }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-lg border border-border bg-header p-4">
|
<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="text-xs uppercase tracking-wide text-foreground/50">{{
|
||||||
<p class="mt-2 text-xl font-semibold text-foreground">{{ formatMoney(walletBalance) }}</p>
|
$t('settings.billing.upgradeDialog.walletBalanceLabel') }}</p>
|
||||||
|
<p class="mt-2 text-xl font-semibold text-foreground">{{ auth.formatMoney(walletBalance) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="rounded-lg border p-4" :class="selectedNeedsTopup
|
||||||
class="rounded-lg border p-4"
|
|
||||||
:class="selectedNeedsTopup
|
|
||||||
? 'border-warning/30 bg-warning/10'
|
? 'border-warning/30 bg-warning/10'
|
||||||
: 'border-success/20 bg-success/5'"
|
: 'border-success/20 bg-success/5'">
|
||||||
>
|
<p class="text-xs uppercase tracking-wide text-foreground/50">{{
|
||||||
<p class="text-xs uppercase tracking-wide text-foreground/50">{{ t('settings.billing.upgradeDialog.shortfallLabel') }}</p>
|
$t('settings.billing.upgradeDialog.shortfallLabel') }}</p>
|
||||||
<p class="mt-2 text-xl font-semibold" :class="selectedNeedsTopup ? 'text-warning' : 'text-success'">
|
<p class="mt-2 text-xl font-semibold" :class="selectedNeedsTopup ? 'text-warning' : 'text-success'">
|
||||||
{{ formatMoney(selectedShortfall) }}
|
{{ auth.formatMoney(selectedShortfall) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="selectedNeedsTopup" class="space-y-3">
|
<div v-if="selectedNeedsTopup" class="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-foreground">{{ t('settings.billing.upgradeDialog.paymentMethodTitle') }}</p>
|
<p class="text-sm font-medium text-foreground">{{
|
||||||
<p class="mt-1 text-xs text-foreground/60">{{ t('settings.billing.upgradeDialog.paymentMethodHint') }}</p>
|
$t('settings.billing.upgradeDialog.paymentMethodTitle') }}</p>
|
||||||
|
<p class="mt-1 text-xs text-foreground/60">{{ $t('settings.billing.upgradeDialog.paymentMethodHint')
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-3 md:grid-cols-2">
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
<button
|
<button type="button" :class="[
|
||||||
type="button"
|
|
||||||
:class="[
|
|
||||||
'rounded-lg border p-4 text-left transition-all',
|
'rounded-lg border p-4 text-left transition-all',
|
||||||
selectedPaymentMethod === 'wallet'
|
selectedPaymentMethod === 'wallet'
|
||||||
? 'border-primary bg-primary/5'
|
? 'border-primary bg-primary/5'
|
||||||
: 'border-border bg-header hover:border-primary/30 hover:bg-muted/30',
|
: 'border-border bg-header hover:border-primary/30 hover:bg-muted/30',
|
||||||
]"
|
]" @click="selectUpgradePaymentMethod('wallet')">
|
||||||
@click="selectUpgradePaymentMethod('wallet')"
|
<p class="text-sm font-medium text-foreground">{{ $t('settings.billing.paymentMethod.wallet') }}
|
||||||
>
|
</p>
|
||||||
<p class="text-sm font-medium text-foreground">{{ t('settings.billing.paymentMethod.wallet') }}</p>
|
|
||||||
<p class="mt-1 text-xs text-foreground/60">
|
<p class="mt-1 text-xs text-foreground/60">
|
||||||
{{ t('settings.billing.upgradeDialog.walletOptionDescription') }}
|
{{ $t('settings.billing.upgradeDialog.walletOptionDescription') }}
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button type="button" :class="[
|
||||||
type="button"
|
|
||||||
:class="[
|
|
||||||
'rounded-lg border p-4 text-left transition-all',
|
'rounded-lg border p-4 text-left transition-all',
|
||||||
selectedPaymentMethod === 'topup'
|
selectedPaymentMethod === 'topup'
|
||||||
? 'border-primary bg-primary/5'
|
? 'border-primary bg-primary/5'
|
||||||
: 'border-border bg-header hover:border-primary/30 hover:bg-muted/30',
|
: 'border-border bg-header hover:border-primary/30 hover:bg-muted/30',
|
||||||
]"
|
]" @click="selectUpgradePaymentMethod('topup')">
|
||||||
@click="selectUpgradePaymentMethod('topup')"
|
<p class="text-sm font-medium text-foreground">{{ $t('settings.billing.paymentMethod.topup') }}
|
||||||
>
|
</p>
|
||||||
<p class="text-sm font-medium text-foreground">{{ t('settings.billing.paymentMethod.topup') }}</p>
|
|
||||||
<p class="mt-1 text-xs text-foreground/60">
|
<p class="mt-1 text-xs text-foreground/60">
|
||||||
{{ t('settings.billing.upgradeDialog.topupOptionDescription', { shortfall: formatMoney(selectedShortfall) }) }}
|
{{ $t('settings.billing.upgradeDialog.topupOptionDescription', {
|
||||||
|
shortfall:
|
||||||
|
auth.formatMoney(selectedShortfall) }) }}
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="rounded-lg border border-success/20 bg-success/5 p-4 text-sm text-success">
|
<div v-else class="rounded-lg border border-success/20 bg-success/5 p-4 text-sm text-success">
|
||||||
{{ t('settings.billing.upgradeDialog.walletCoveredHint') }}
|
{{ $t('settings.billing.upgradeDialog.walletCoveredHint') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="selectedNeedsTopup && selectedPaymentMethod === 'topup'" class="grid gap-2">
|
<div v-if="selectedNeedsTopup && selectedPaymentMethod === 'topup'" class="grid gap-2">
|
||||||
<label class="text-sm font-medium text-foreground">{{ t('settings.billing.upgradeDialog.topupAmountLabel') }}</label>
|
<label class="text-sm font-medium text-foreground">{{
|
||||||
<AppInput
|
$t('settings.billing.upgradeDialog.topupAmountLabel')
|
||||||
:model-value="purchaseTopupAmount"
|
}}</label>
|
||||||
type="number"
|
<AppInput :model-value="purchaseTopupAmount" type="number" min="0.01" step="0.01"
|
||||||
min="0.01"
|
:placeholder="$t('settings.billing.upgradeDialog.topupAmountPlaceholder')"
|
||||||
step="0.01"
|
@update:model-value="updatePurchaseTopupAmount" />
|
||||||
:placeholder="t('settings.billing.upgradeDialog.topupAmountPlaceholder')"
|
|
||||||
@update:model-value="updatePurchaseTopupAmount"
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-foreground/60">
|
<p class="text-xs text-foreground/60">
|
||||||
{{ t('settings.billing.upgradeDialog.topupAmountHint', { shortfall: formatMoney(selectedShortfall) }) }}
|
{{ $t('settings.billing.upgradeDialog.topupAmountHint', {
|
||||||
|
shortfall:
|
||||||
|
auth.formatMoney(selectedShortfall)
|
||||||
|
}) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div v-if="selectedNeedsTopup && selectedPaymentMethod === 'wallet'"
|
||||||
v-if="selectedNeedsTopup && selectedPaymentMethod === 'wallet'"
|
class="rounded-lg border border-warning/30 bg-warning/10 p-4 text-sm text-warning">
|
||||||
class="rounded-lg border border-warning/30 bg-warning/10 p-4 text-sm text-warning"
|
{{ $t('settings.billing.upgradeDialog.walletInsufficientHint', {
|
||||||
>
|
shortfall:
|
||||||
{{ t('settings.billing.upgradeDialog.walletInsufficientHint', { shortfall: formatMoney(selectedShortfall) }) }}
|
auth.formatMoney(selectedShortfall) }) }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="purchaseError" class="rounded-lg border border-danger bg-danger/10 p-4 text-sm text-danger">
|
<div v-if="purchaseError" class="rounded-lg border border-danger bg-danger/10 p-4 text-sm text-danger">
|
||||||
@@ -730,23 +406,14 @@ const selectPreset = (amount: number) => {
|
|||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<p class="text-xs text-foreground/60">
|
<p class="text-xs text-foreground/60">
|
||||||
{{ t('settings.billing.upgradeDialog.footerHint') }}
|
{{ $t('settings.billing.upgradeDialog.footerHint') }}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex justify-end gap-3">
|
<div class="flex justify-end gap-3">
|
||||||
<AppButton
|
<AppButton variant="secondary" size="sm" :disabled="purchaseLoading" @click="closeUpgradeDialog">
|
||||||
variant="secondary"
|
{{ $t('common.cancel') }}
|
||||||
size="sm"
|
|
||||||
:disabled="purchaseLoading"
|
|
||||||
@click="closeUpgradeDialog"
|
|
||||||
>
|
|
||||||
{{ t('common.cancel') }}
|
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<AppButton
|
<AppButton size="sm" :loading="purchaseLoading" :disabled="!canSubmitUpgrade"
|
||||||
size="sm"
|
@click="submitUpgrade">
|
||||||
:loading="purchaseLoading"
|
|
||||||
:disabled="!canSubmitUpgrade"
|
|
||||||
@click="submitUpgrade"
|
|
||||||
>
|
|
||||||
{{ upgradeSubmitLabel }}
|
{{ upgradeSubmitLabel }}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -754,22 +421,11 @@ const selectPreset = (amount: number) => {
|
|||||||
</template>
|
</template>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
|
|
||||||
<BillingTopupDialog
|
<BillingTopupDialog :visible="topupDialogVisible" :title="$t('settings.billing.topupDialog.title')"
|
||||||
:visible="topupDialogVisible"
|
:subtitle="t('settings.billing.topupDialog.subtitle')" :presets="topupPresets" :amount="topupAmount"
|
||||||
:title="t('settings.billing.topupDialog.title')"
|
:loading="topupLoading" :custom-amount-label="t('settings.billing.topupDialog.customAmount')"
|
||||||
: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')"
|
:amount-placeholder="t('settings.billing.topupDialog.enterAmount')"
|
||||||
:hint="t('settings.billing.topupDialog.hint')"
|
:hint="t('settings.billing.topupDialog.hint')" :cancel-label="t('common.cancel')"
|
||||||
:cancel-label="t('common.cancel')"
|
:proceed-label="t('settings.billing.topupDialog.proceed')" @update:visible="topupDialogVisible = $event"
|
||||||
:proceed-label="t('settings.billing.topupDialog.proceed')"
|
@update:amount="topupAmount = $event" @selectPreset="selectPreset" @submit="handleTopup(topupAmount || 0)" />
|
||||||
:format-money="formatMoney"
|
|
||||||
@update:visible="topupDialogVisible = $event"
|
|
||||||
@update:amount="topupAmount = $event"
|
|
||||||
@selectPreset="selectPreset"
|
|
||||||
@submit="handleTopup(topupAmount || 0)"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import DownloadIcon from '@/components/icons/DownloadIcon.vue';
|
|
||||||
|
|
||||||
type PaymentHistoryItem = {
|
|
||||||
id: string;
|
|
||||||
date: string;
|
|
||||||
amount: number;
|
|
||||||
plan: string;
|
|
||||||
status: string;
|
|
||||||
invoiceId: string;
|
|
||||||
currency: string;
|
|
||||||
kind: string;
|
|
||||||
details?: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
items: PaymentHistoryItem[];
|
|
||||||
loading?: boolean;
|
|
||||||
downloadingId?: string | null;
|
|
||||||
formatMoney: (amount: number) => string;
|
|
||||||
getStatusStyles: (status: string) => string;
|
|
||||||
getStatusLabel: (status: string) => string;
|
|
||||||
dateLabel: string;
|
|
||||||
amountLabel: string;
|
|
||||||
planLabel: string;
|
|
||||||
statusLabel: string;
|
|
||||||
invoiceLabel: string;
|
|
||||||
emptyLabel: string;
|
|
||||||
downloadLabel: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'download', item: PaymentHistoryItem): void;
|
|
||||||
}>();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="px-6 py-4">
|
|
||||||
<div class="flex items-center gap-4 mb-4">
|
|
||||||
<div class="w-10 h-10 rounded-md bg-info/10 flex items-center justify-center shrink-0">
|
|
||||||
<DownloadIcon class="w-5 h-5 text-info" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-foreground">{{ title }}</p>
|
|
||||||
<p class="text-xs text-foreground/60 mt-0.5">{{ description }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="border border-border rounded-lg overflow-hidden">
|
|
||||||
<div class="grid grid-cols-12 gap-4 px-4 py-3 text-xs font-medium text-foreground/60 uppercase tracking-wider bg-muted/30">
|
|
||||||
<div class="col-span-3">{{ dateLabel }}</div>
|
|
||||||
<div class="col-span-2">{{ amountLabel }}</div>
|
|
||||||
<div class="col-span-3">{{ planLabel }}</div>
|
|
||||||
<div class="col-span-2">{{ statusLabel }}</div>
|
|
||||||
<div class="col-span-2 text-right">{{ invoiceLabel }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="loading" class="px-4 py-6 space-y-3">
|
|
||||||
<div v-for="index in 3" :key="index" class="grid grid-cols-12 gap-4 items-center animate-pulse">
|
|
||||||
<div class="col-span-3 h-4 rounded bg-muted/50" />
|
|
||||||
<div class="col-span-2 h-4 rounded bg-muted/50" />
|
|
||||||
<div class="col-span-3 h-4 rounded bg-muted/50" />
|
|
||||||
<div class="col-span-2 h-6 rounded bg-muted/50" />
|
|
||||||
<div class="col-span-2 h-8 rounded bg-muted/50" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="items.length === 0" class="text-center py-12 text-foreground/60">
|
|
||||||
<div class="w-16 h-16 rounded-full bg-muted/50 flex items-center justify-center mx-auto mb-4">
|
|
||||||
<DownloadIcon class="w-8 h-8 text-foreground/40" />
|
|
||||||
</div>
|
|
||||||
<p>{{ emptyLabel }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<div
|
|
||||||
v-for="item in items"
|
|
||||||
:key="item.id"
|
|
||||||
class="grid grid-cols-12 gap-4 px-4 py-3 items-center hover:bg-muted/30 transition-all border-t border-border"
|
|
||||||
>
|
|
||||||
<div class="col-span-3">
|
|
||||||
<p class="text-sm font-medium text-foreground">{{ item.date }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="col-span-2">
|
|
||||||
<p class="text-sm text-foreground">{{ formatMoney(item.amount) }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="col-span-3">
|
|
||||||
<p class="text-sm text-foreground">{{ item.plan }}</p>
|
|
||||||
<p v-if="item.details?.length" class="mt-1 text-xs text-foreground/60">
|
|
||||||
{{ item.details.join(' · ') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="col-span-2">
|
|
||||||
<span :class="`inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium ${getStatusStyles(item.status)}`">
|
|
||||||
{{ getStatusLabel(item.status) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="col-span-2 flex justify-end">
|
|
||||||
<button
|
|
||||||
class="flex items-center gap-2 px-3 py-1.5 text-sm text-foreground/70 hover:text-foreground hover:bg-muted/50 rounded-md transition-all disabled:opacity-60 disabled:cursor-wait"
|
|
||||||
:disabled="downloadingId === item.id"
|
|
||||||
@click="emit('download', item)"
|
|
||||||
>
|
|
||||||
<DownloadIcon class="w-4 h-4" />
|
|
||||||
<span>{{ downloadingId === item.id ? '...' : downloadLabel }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
|
||||||
import CreditCardIcon from '@/components/icons/CreditCardIcon.vue';
|
|
||||||
import type { Plan as ModelPlan } from '@/server/gen/proto/app/v1/common';
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
isLoading: boolean;
|
|
||||||
plans: ModelPlan[];
|
|
||||||
currentPlanId?: string;
|
|
||||||
selectingPlanId?: string | null;
|
|
||||||
formatMoney: (amount: number) => string;
|
|
||||||
getPlanStorageText: (plan: ModelPlan) => string;
|
|
||||||
getPlanDurationText: (plan: ModelPlan) => string;
|
|
||||||
getPlanUploadsText: (plan: ModelPlan) => string;
|
|
||||||
currentPlanLabel: string;
|
|
||||||
selectingLabel: string;
|
|
||||||
chooseLabel: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'select', plan: ModelPlan): void;
|
|
||||||
}>();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="px-6 py-4">
|
|
||||||
<div class="flex items-center gap-4 mb-4">
|
|
||||||
<div class="w-10 h-10 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
|
|
||||||
<CreditCardIcon class="w-5 h-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-foreground">{{ title }}</p>
|
|
||||||
<p class="text-xs text-foreground/60 mt-0.5">{{ description }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="isLoading" class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div v-for="i in 3" :key="i">
|
|
||||||
<div class="h-[200px] rounded-lg bg-muted/50 animate-pulse"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div
|
|
||||||
v-for="plan in plans.sort((a,b) => (a.price || 0) - (b.price || 0))"
|
|
||||||
:key="plan.id"
|
|
||||||
:class="[
|
|
||||||
'border rounded-lg p-4 hover:bg-muted/30 transition-all flex flex-col',
|
|
||||||
plan.id === currentPlanId ? 'border-primary/40 bg-primary/5' : 'border-border',
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<div class="mb-3">
|
|
||||||
<div class="flex items-center justify-between gap-3">
|
|
||||||
<h3 class="text-lg font-semibold text-foreground">{{ plan.name }}</h3>
|
|
||||||
<span
|
|
||||||
v-if="plan.id === currentPlanId"
|
|
||||||
class="inline-flex items-center rounded-full bg-primary/10 px-2 py-1 text-[11px] font-medium text-primary"
|
|
||||||
>
|
|
||||||
{{ currentPlanLabel }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-foreground/60 mt-1 min-h-[2.5rem]">{{ plan.description }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<span class="text-2xl font-bold text-foreground">{{ formatMoney(plan.price || 0) }}</span>
|
|
||||||
<span class="text-foreground/60 text-sm"> / {{ $t('settings.billing.cycle.'+plan.cycle) }}</span>
|
|
||||||
</div>
|
|
||||||
<ul class="space-y-2 mb-4 text-sm">
|
|
||||||
<li
|
|
||||||
v-for="feature in plan.features || []"
|
|
||||||
:key="feature"
|
|
||||||
class="flex items-center gap-2 text-foreground/70"
|
|
||||||
>
|
|
||||||
<CheckIcon class="w-4 h-4 text-success shrink-0" />
|
|
||||||
{{ feature }}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<button
|
|
||||||
v-if="plan.id !== currentPlanId"
|
|
||||||
:disabled="selectingPlanId === plan.id"
|
|
||||||
:class="[
|
|
||||||
'w-full py-2 px-4 rounded-md text-sm font-medium transition-all mt-a',
|
|
||||||
selectingPlanId === plan.id
|
|
||||||
? 'bg-muted/50 text-foreground/60 cursor-wait'
|
|
||||||
: 'bg-primary text-white hover:bg-primary/90'
|
|
||||||
]"
|
|
||||||
@click="emit('select', plan)"
|
|
||||||
>
|
|
||||||
{{ selectingPlanId === plan.id ? selectingLabel : chooseLabel }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,53 +1,39 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import ActivityIcon from '@/components/icons/ActivityIcon.vue';
|
import ActivityIcon from '@/components/icons/ActivityIcon.vue';
|
||||||
import UploadIcon from '@/components/icons/UploadIcon.vue';
|
import Video from '@/components/icons/Video.vue';
|
||||||
|
import { useUsageQuery } from '@/composables/useUsageQuery';
|
||||||
|
import { formatBytes } from '@/lib/utils';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{}>();
|
||||||
storageTitle: string;
|
const { data: usageSnapshot } = useUsageQuery();
|
||||||
storageDescription: string;
|
const dataList = computed(() => [
|
||||||
storagePercentage: number;
|
{
|
||||||
uploadsTitle: string;
|
id: 'storage',
|
||||||
uploadsDescription: string;
|
title: 'settings.billing.storage',
|
||||||
uploadsPercentage: number;
|
description: { key: 'settings.billing.storageUsedOfLimit', params: { used: formatBytes(usageSnapshot.value?.totalStorage ?? 0) } },
|
||||||
}>();
|
icon: ActivityIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'videos',
|
||||||
|
title: 'settings.billing.totalVideos',
|
||||||
|
description: { key: 'settings.billing.totalVideosUsedOfLimit', params: { used: usageSnapshot.value?.totalVideos ?? 0 } },
|
||||||
|
icon: Video,
|
||||||
|
},
|
||||||
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="px-6 py-4 hover:bg-muted/30 transition-all">
|
<div class="px-6 py-4 hover:bg-muted/30 transition-all grid grid-cols-1 md:grid-cols-2 gap-6 rounded-md">
|
||||||
<div class="flex items-center gap-4 mb-3">
|
<div v-for="item in dataList" :key="item.id" class="flex items-center gap-4">
|
||||||
<div class="w-10 h-10 rounded-md bg-accent/10 flex items-center justify-center shrink-0">
|
<div class="w-10 h-10 rounded-md bg-accent/10 flex items-center justify-center shrink-0">
|
||||||
<ActivityIcon class="w-5 h-5 text-accent" />
|
<component :is="item.icon" class="w-5 h-5 text-accent" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="hover:underline">
|
||||||
<p class="text-sm font-medium text-foreground">{{ storageTitle }}</p>
|
<p class="text-sm font-medium text-foreground">{{ $t(item.title) }}</p>
|
||||||
<p class="text-xs text-foreground/60 mt-0.5">{{ storageDescription }}</p>
|
<p class="text-xs text-foreground/60 mt-0.5">{{ $t(item.description.key, item.description.params) }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full bg-muted/50 rounded-full overflow-hidden" style="height: 6px">
|
|
||||||
<div
|
|
||||||
class="bg-primary h-full rounded-full transition-all duration-300"
|
|
||||||
:style="{ width: `${storagePercentage}%` }"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="px-6 py-4 hover:bg-muted/30 transition-all border-t border-border">
|
|
||||||
<div class="flex items-center gap-4 mb-3">
|
|
||||||
<div class="w-10 h-10 rounded-md bg-info/10 flex items-center justify-center shrink-0">
|
|
||||||
<UploadIcon class="w-5 h-5 text-info" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-foreground">{{ uploadsTitle }}</p>
|
|
||||||
<p class="text-xs text-foreground/60 mt-0.5">{{ uploadsDescription }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="w-full bg-muted/50 rounded-full overflow-hidden" style="height: 6px">
|
|
||||||
<div
|
|
||||||
class="bg-info h-full rounded-full transition-all duration-300"
|
|
||||||
:style="{ width: `${uploadsPercentage}%` }"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import AppButton from '@/components/ui/AppButton.vue';
|
|
||||||
import CoinsIcon from '@/components/icons/CoinsIcon.vue';
|
|
||||||
import PlusIcon from '@/components/icons/PlusIcon.vue';
|
|
||||||
import SettingsRow from '@/routes/settings/components/SettingsRow.vue';
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
buttonLabel: string;
|
|
||||||
subscriptionTitle?: string;
|
|
||||||
subscriptionDescription?: string;
|
|
||||||
subscriptionTone?: 'default' | 'warning';
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'topup'): void;
|
|
||||||
}>();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<SettingsRow :title="title" :description="description" iconBoxClass="bg-primary/10">
|
|
||||||
<template #icon>
|
|
||||||
<CoinsIcon class="w-5 h-5 text-primary" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #actions>
|
|
||||||
<div class="flex flex-col items-end gap-2">
|
|
||||||
<!-- <div
|
|
||||||
v-if="subscriptionTitle || subscriptionDescription"
|
|
||||||
class="rounded-md border px-3 py-2 text-right"
|
|
||||||
:class="subscriptionTone === 'warning'
|
|
||||||
? 'border-warning/30 bg-warning/10 text-warning'
|
|
||||||
: 'border-border bg-muted/30 text-foreground/70'"
|
|
||||||
>
|
|
||||||
<p v-if="subscriptionTitle" class="text-xs font-medium">{{ subscriptionTitle }}</p>
|
|
||||||
<p v-if="subscriptionDescription" class="mt-0.5 text-xs">{{ subscriptionDescription }}</p>
|
|
||||||
</div> -->
|
|
||||||
|
|
||||||
<AppButton size="sm" @click="emit('topup')">
|
|
||||||
<template #icon>
|
|
||||||
<PlusIcon class="w-4 h-4" />
|
|
||||||
</template>
|
|
||||||
{{ buttonLabel }}
|
|
||||||
</AppButton>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</SettingsRow>
|
|
||||||
</template>
|
|
||||||
200
src/routes/settings/Billing/components/PaymentHistory.tsx
Normal file
200
src/routes/settings/Billing/components/PaymentHistory.tsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import { client } from "@/api/rpcclient";
|
||||||
|
import DownloadIcon from "@/components/icons/DownloadIcon.vue";
|
||||||
|
import ListIcon from "@/components/icons/ListIcon.vue";
|
||||||
|
import { useAppToast } from "@/composables/useAppToast";
|
||||||
|
import { getApiErrorMessage, getStatusStyles } from "@/lib/utils";
|
||||||
|
import { PaymentHistoryItem } from "@/server/gen/proto/app/v1/common";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
import { useQuery } from "@pinia/colada";
|
||||||
|
import { useTranslation } from "i18next-vue";
|
||||||
|
|
||||||
|
const normalizeHistoryStatus = (status?: string) => {
|
||||||
|
switch ((status || '').toLowerCase()) {
|
||||||
|
case 'success':
|
||||||
|
case 'succeeded':
|
||||||
|
case 'paid':
|
||||||
|
return 'success';
|
||||||
|
case 'failed':
|
||||||
|
case 'error':
|
||||||
|
case 'canceled':
|
||||||
|
case 'cancelled':
|
||||||
|
return 'failed';
|
||||||
|
case 'pending':
|
||||||
|
case 'processing':
|
||||||
|
default:
|
||||||
|
return 'pending';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const PaymentHistory = defineComponent({
|
||||||
|
name: 'PaymentHistory',
|
||||||
|
setup(props, ctx) {
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const toast = useAppToast();
|
||||||
|
const downloadingInvoiceId = ref<string | null>(null);
|
||||||
|
|
||||||
|
const formatTermLabel = (months: number) => t('settings.billing.termOption', { months });
|
||||||
|
const formatPaymentMethodLabel = (value?: string) => {
|
||||||
|
switch ((value || '').toLowerCase()) {
|
||||||
|
case 'topup':
|
||||||
|
return t('settings.billing.paymentMethod.topup');
|
||||||
|
case 'wallet':
|
||||||
|
default:
|
||||||
|
return t('settings.billing.paymentMethod.wallet');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const mapHistoryItem = (item: PaymentHistoryItem) => {
|
||||||
|
const details: string[] = [];
|
||||||
|
|
||||||
|
if (item.kind !== 'wallet_topup' && item.termMonths) {
|
||||||
|
details.push(formatTermLabel(item.termMonths));
|
||||||
|
}
|
||||||
|
if (item.kind !== 'wallet_topup' && item.paymentMethod) {
|
||||||
|
details.push(formatPaymentMethodLabel(item.paymentMethod));
|
||||||
|
}
|
||||||
|
if (item.kind !== 'wallet_topup' && item.expiresAt) {
|
||||||
|
details.push(t('settings.billing.history.validUntil', { date: auth.formatHistoryDate(item.expiresAt) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.id || '',
|
||||||
|
date: auth.formatHistoryDate(item.createdAt),
|
||||||
|
amount: item.amount || 0,
|
||||||
|
plan: item.kind === 'wallet_topup'
|
||||||
|
? t('settings.billing.walletTopup')
|
||||||
|
: (item.planName || t('settings.billing.unknownPlan')),
|
||||||
|
status: normalizeHistoryStatus(item.status),
|
||||||
|
invoiceId: item.invoiceId || '-',
|
||||||
|
currency: item.currency || 'USD',
|
||||||
|
kind: item.kind || 'subscription',
|
||||||
|
details,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
key: ['paymentHistory'],
|
||||||
|
query: () => client.listPaymentHistory().then(res => (res.payments || []).map(mapHistoryItem)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDownloadInvoice = async (item: PaymentHistoryItem) => {
|
||||||
|
if (!item.id) return;
|
||||||
|
|
||||||
|
downloadingInvoiceId.value = item.id;
|
||||||
|
toast.add({
|
||||||
|
severity: 'info',
|
||||||
|
summary: t('settings.billing.toast.downloadingSummary'),
|
||||||
|
detail: t('settings.billing.toast.downloadingDetail', { invoiceId: item.invoiceId }),
|
||||||
|
life: 2000,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await client.downloadInvoice({ id: item.id });
|
||||||
|
const content = response.content || '';
|
||||||
|
const contentType = response.contentType || 'text/plain;charset=utf-8';
|
||||||
|
const filename = response.filename || `${item.invoiceId}.txt`;
|
||||||
|
const blob = new Blob([content], { type: contentType });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = filename;
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
document.body.removeChild(anchor);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: t('settings.billing.toast.downloadedSummary'),
|
||||||
|
detail: t('settings.billing.toast.downloadedDetail', { invoiceId: item.invoiceId }),
|
||||||
|
life: 3000,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: t('settings.billing.toast.downloadFailedSummary'),
|
||||||
|
detail: getApiErrorMessage(error, t('settings.billing.toast.downloadFailedDetail')),
|
||||||
|
life: 5000,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
downloadingInvoiceId.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return () => (
|
||||||
|
<div class="px-6 py-4">
|
||||||
|
<div class="flex items-center gap-4 mb-4">
|
||||||
|
<div class="w-10 h-10 rounded-md bg-info/10 flex items-center justify-center shrink-0">
|
||||||
|
<ListIcon class="w-6 h-6 text-info" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-foreground">{t('settings.billing.paymentHistory')}</p>
|
||||||
|
<p class="text-xs text-foreground/60 mt-0.5">{t('settings.billing.paymentHistorySubtitle')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border border-border rounded-lg overflow-hidden">
|
||||||
|
<div class="grid grid-cols-12 gap-4 px-4 py-3 text-xs font-medium text-foreground/60 uppercase tracking-wider bg-muted/30">
|
||||||
|
<div class="col-span-3">{t('settings.billing.table.date')}</div>
|
||||||
|
<div class="col-span-2">{t('settings.billing.table.amount')}</div>
|
||||||
|
<div class="col-span-3">{t('settings.billing.table.plan')}</div>
|
||||||
|
<div class="col-span-2">{t('settings.billing.table.status')}</div>
|
||||||
|
<div class="col-span-2 text-right">{t('settings.billing.table.invoice')}</div>
|
||||||
|
</div>
|
||||||
|
{isLoading.value && (<div class="px-4 py-6 space-y-3">
|
||||||
|
{Array.from({ length: 3 }).map((_, index) => (
|
||||||
|
<div key={index} class="grid grid-cols-12 gap-4 items-center animate-pulse">
|
||||||
|
<div class="col-span-3 h-4 rounded bg-muted/50" />
|
||||||
|
<div class="col-span-2 h-4 rounded bg-muted/50" />
|
||||||
|
<div class="col-span-3 h-4 rounded bg-muted/50" />
|
||||||
|
<div class="col-span-2 h-6 rounded bg-muted/50" />
|
||||||
|
<div class="col-span-2 h-8 rounded bg-muted/50" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>)}
|
||||||
|
{data.value?.length === 0 && !isLoading.value && (<div class="text-center py-12 text-foreground/60">
|
||||||
|
<div class="w-16 h-16 rounded-full bg-muted/50 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<DownloadIcon class="w-8 h-8 text-foreground/40" />
|
||||||
|
</div>
|
||||||
|
<p>{t('settings.billing.noPaymentHistory')}</p>
|
||||||
|
</div>)}
|
||||||
|
|
||||||
|
{data.value?.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
class="grid grid-cols-12 gap-4 px-4 py-3 items-center hover:bg-muted/30 transition-all border-t border-border"
|
||||||
|
>
|
||||||
|
<div class="col-span-3">
|
||||||
|
<p class="text-sm font-medium text-foreground">{item.date}</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2">
|
||||||
|
<p class="text-sm text-foreground">{auth.formatMoney(item.amount)}</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-3">
|
||||||
|
<p class="text-sm text-foreground">{item.plan}</p>
|
||||||
|
<p v-if="item.details?.length" class="mt-1 text-xs text-foreground/60">
|
||||||
|
{item.details.join(' · ')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2">
|
||||||
|
<span class={`inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium ${getStatusStyles(item.status)}`}>
|
||||||
|
{t('settings.billing.status.' + item.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2 flex justify-end">
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-2 px-3 py-1.5 text-sm text-foreground/70 hover:text-foreground hover:bg-muted/50 rounded-md transition-all disabled:opacity-60 disabled:cursor-wait"
|
||||||
|
disabled={downloadingInvoiceId.value === item.id}
|
||||||
|
onClick={() => handleDownloadInvoice(item)}
|
||||||
|
>
|
||||||
|
<DownloadIcon class="w-4 h-4" />
|
||||||
|
<span>{downloadingInvoiceId.value === item.id ? '...' : t('settings.billing.download')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
export default PaymentHistory;
|
||||||
158
src/routes/settings/Billing/components/PlanSelection.tsx
Normal file
158
src/routes/settings/Billing/components/PlanSelection.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { client as rpcClient } from '@/api/rpcclient';
|
||||||
|
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||||
|
import CreditCardIcon from '@/components/icons/CreditCardIcon.vue';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { useQuery } from '@pinia/colada';
|
||||||
|
import { useTranslation } from 'i18next-vue';
|
||||||
|
import { computed, defineComponent } from 'vue';
|
||||||
|
const PlanSelection = defineComponent({
|
||||||
|
name: 'PlanSelection',
|
||||||
|
props: {
|
||||||
|
currentPlanId: { type: String, default: '', required: true },
|
||||||
|
selectedPlanId: { type: String, default: null, required: true },
|
||||||
|
},
|
||||||
|
emits: ['upgrade'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
key: () => ['billing-plans'],
|
||||||
|
query: () => rpcClient.listPlans(),
|
||||||
|
});
|
||||||
|
const subscriptionSummary = computed(() => {
|
||||||
|
const expiresAt = auth.user?.planExpiresAt || auth.user?.plan_expires_at;
|
||||||
|
const formattedDate = auth.formatHistoryDate(expiresAt);
|
||||||
|
const currentPlanName = data.value?.plans?.find((p) => p.id === auth.user?.plan_id)?.name || t('settings.billing.subscription.unknownPlan');
|
||||||
|
if (auth.user?.plan_id) {
|
||||||
|
if (auth.user?.plan_expiring_soon && expiresAt) {
|
||||||
|
return {
|
||||||
|
title: t('settings.billing.subscription.expiringTitle'),
|
||||||
|
description: t('settings.billing.subscription.expiringDescription', {
|
||||||
|
date: formattedDate,
|
||||||
|
}),
|
||||||
|
tone: 'warning' as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expiresAt) {
|
||||||
|
return {
|
||||||
|
title: t('settings.billing.subscription.activeTitle'),
|
||||||
|
description: t('settings.billing.subscription.activeDescription', {
|
||||||
|
date: formattedDate,
|
||||||
|
}),
|
||||||
|
tone: 'default' as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t('settings.billing.subscription.activeTitle'),
|
||||||
|
description: currentPlanName,
|
||||||
|
tone: 'default' as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expiresAt) {
|
||||||
|
return {
|
||||||
|
title: t('settings.billing.subscription.expiredTitle'),
|
||||||
|
description: t('settings.billing.subscription.expiredDescription', { date: formattedDate }),
|
||||||
|
tone: 'warning' as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t('settings.billing.subscription.freeTitle'),
|
||||||
|
description: t('settings.billing.subscription.freeDescription'),
|
||||||
|
tone: 'default' as const,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// Sắp xếp plan theo giá tăng dần
|
||||||
|
const sortedPlans = computed(() =>
|
||||||
|
[...(data.value?.plans || [])].sort((a, b) => (a.price || 0) - (b.price || 0))
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => (
|
||||||
|
<div class="px-6 py-4">
|
||||||
|
{/* Header Section */}
|
||||||
|
<div class="flex items-center gap-4 mb-4">
|
||||||
|
<div class="w-10 h-10 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
|
||||||
|
<CreditCardIcon class="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-foreground">{t('settings.billing.availablePlans')}</p>
|
||||||
|
<p class="text-xs text-foreground/60 mt-0.5">{t('settings.billing.availablePlansHint')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading State */}
|
||||||
|
{isLoading.value ? (
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<div class="h-[200px] rounded-lg bg-muted/50 animate-pulse"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Plans Grid */
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{sortedPlans.value.map((plan) => (
|
||||||
|
<div
|
||||||
|
key={plan.id}
|
||||||
|
class={[
|
||||||
|
'border rounded-lg p-4 hover:bg-muted/30 transition-all flex flex-col',
|
||||||
|
plan.id === props.currentPlanId ? 'border-primary/40 bg-primary/5' : 'border-border',
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<h3 class="text-lg font-semibold text-foreground">{plan.name}</h3>
|
||||||
|
{plan.id === props.currentPlanId && (
|
||||||
|
<span class={cn("inline-flex items-center rounded-full bg-primary/10 px-2 py-1 text-[11px] font-medium text-primary", subscriptionSummary.value.tone === 'warning' && 'bg-warning/10 text-warning')}>
|
||||||
|
{subscriptionSummary.value.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-foreground/60 mt-1 min-h-[2.5rem]">{plan.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<span class="text-2xl font-bold text-foreground">{auth.formatMoney(plan.price || 0)}</span>
|
||||||
|
<span class="text-foreground/60 text-sm"> / {t(`settings.billing.cycle.${plan.cycle}`)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="space-y-2 mb-4 text-sm">
|
||||||
|
{(plan.features || []).map((feature: string) => (
|
||||||
|
<li key={feature} class="flex items-center gap-2 text-foreground/70">
|
||||||
|
<CheckIcon class="w-4 h-4 text-success shrink-0" />
|
||||||
|
{feature}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{plan.id !== props.currentPlanId && (
|
||||||
|
<button
|
||||||
|
disabled={props.selectedPlanId === plan.id}
|
||||||
|
class={[
|
||||||
|
'w-full py-2 px-4 rounded-md text-sm font-medium transition-all mt-auto',
|
||||||
|
props.selectedPlanId === plan.id
|
||||||
|
? 'bg-muted/50 text-foreground/60 cursor-wait'
|
||||||
|
: 'bg-primary text-white hover:bg-primary/90',
|
||||||
|
]}
|
||||||
|
onClick={() => emit('upgrade', plan)}
|
||||||
|
>
|
||||||
|
{props.selectedPlanId === plan.id
|
||||||
|
? t('settings.billing.upgradeDialog.selecting')
|
||||||
|
: t('settings.billing.upgradeDialog.choosePlan')
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
export default PlanSelection;
|
||||||
189
src/routes/settings/Billing/components/UpgradeDialog.vue
Normal file
189
src/routes/settings/Billing/components/UpgradeDialog.vue
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
<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>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="tsx">
|
||||||
import LinkIcon from '@/components/icons/LinkIcon.vue';
|
import LinkIcon from '@/components/icons/LinkIcon.vue';
|
||||||
import PencilIcon from '@/components/icons/PencilIcon.vue';
|
import PencilIcon from '@/components/icons/PencilIcon.vue';
|
||||||
import TrashIcon from '@/components/icons/TrashIcon.vue';
|
import TrashIcon from '@/components/icons/TrashIcon.vue';
|
||||||
@@ -8,9 +8,9 @@ import BaseTable from '@/components/ui/BaseTable.vue';
|
|||||||
import SettingsTableSkeleton from '@/routes/settings/components/SettingsTableSkeleton.vue';
|
import SettingsTableSkeleton from '@/routes/settings/components/SettingsTableSkeleton.vue';
|
||||||
import type { ColumnDef } from '@tanstack/vue-table';
|
import type { ColumnDef } from '@tanstack/vue-table';
|
||||||
import { useTranslation } from 'i18next-vue';
|
import { useTranslation } from 'i18next-vue';
|
||||||
import { computed, h } from 'vue';
|
import { computed } from 'vue';
|
||||||
import PlayerConfigSettingsBadges from './PlayerConfigSettingsBadges.vue';
|
|
||||||
import type { PlayerConfig } from '../types';
|
import type { PlayerConfig } from '../types';
|
||||||
|
import PlayerConfigSettingsBadges from './PlayerConfigSettingsBadges.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
configs: PlayerConfig[];
|
configs: PlayerConfig[];
|
||||||
@@ -37,19 +37,30 @@ const columns = computed<ColumnDef<PlayerConfig>[]>(() => [
|
|||||||
id: 'config',
|
id: 'config',
|
||||||
header: t('settings.playerConfigs.table.name'),
|
header: t('settings.playerConfigs.table.name'),
|
||||||
accessorFn: row => row.name,
|
accessorFn: row => row.name,
|
||||||
cell: ({ row }) => h('div', [
|
cell: ({ row }) => (
|
||||||
h('div', { class: 'flex flex-wrap items-center gap-2' }, [
|
<div>
|
||||||
h('span', { class: 'text-sm font-medium text-foreground cursor-pointer hover:underline', onClick: () => emit('edit', row.original) }, row.original.name),
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
row.original.isDefault
|
<span
|
||||||
? h('span', {
|
class="text-sm font-medium text-foreground cursor-pointer hover:underline"
|
||||||
class: 'inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary',
|
onClick={() => emit('edit', row.original)}
|
||||||
}, t('settings.playerConfigs.defaultBadge'))
|
>
|
||||||
: null,
|
{row.original.name}
|
||||||
]),
|
</span>
|
||||||
row.original.description
|
{row.original.isDefault && (
|
||||||
? h('p', { class: 'mt-0.5 text-xs text-foreground/50' }, row.original.description)
|
<span class="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||||
: h('p', { class: 'mt-0.5 text-xs text-foreground/40' }, t('settings.playerConfigs.createdOn', { date: row.original.createdAt || '-' })),
|
{t('settings.playerConfigs.defaultBadge')}
|
||||||
]),
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{row.original.description ? (
|
||||||
|
<p class="mt-0.5 text-xs text-foreground/50">{row.original.description}</p>
|
||||||
|
) : (
|
||||||
|
<p class="mt-0.5 text-xs text-foreground/40">
|
||||||
|
{t('settings.playerConfigs.createdOn', { date: row.original.createdAt || '-' })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
meta: {
|
meta: {
|
||||||
headerClass: 'px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50',
|
headerClass: 'px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50',
|
||||||
cellClass: 'px-6 py-3',
|
cellClass: 'px-6 py-3',
|
||||||
@@ -69,7 +80,7 @@ const columns = computed<ColumnDef<PlayerConfig>[]>(() => [
|
|||||||
row.encrytionM3u8 ? 'encrytionM3u8' : '',
|
row.encrytionM3u8 ? 'encrytionM3u8' : '',
|
||||||
row.logoUrl ? 'logo' : '',
|
row.logoUrl ? 'logo' : '',
|
||||||
].filter(Boolean).join(', '),
|
].filter(Boolean).join(', '),
|
||||||
cell: ({ row }) => h(PlayerConfigSettingsBadges, { config: row.original }),
|
cell: ({ row }) => <PlayerConfigSettingsBadges config={row.original} />,
|
||||||
meta: {
|
meta: {
|
||||||
headerClass: 'px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50',
|
headerClass: 'px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50',
|
||||||
cellClass: 'px-6 py-3',
|
cellClass: 'px-6 py-3',
|
||||||
@@ -79,13 +90,23 @@ const columns = computed<ColumnDef<PlayerConfig>[]>(() => [
|
|||||||
id: 'status',
|
id: 'status',
|
||||||
header: t('common.status'),
|
header: t('common.status'),
|
||||||
accessorFn: row => Number(row.isActive),
|
accessorFn: row => Number(row.isActive),
|
||||||
cell: ({ row }) => h('div', { class: 'text-center' }, [
|
cell: ({ row }) => (
|
||||||
h(AppSwitch, {
|
<div class="text-center">
|
||||||
modelValue: row.original.isActive,
|
<AppSwitch
|
||||||
disabled: !props.canManageExistingConfig || props.saving || props.deletingId !== null || props.defaultingId !== null || props.togglingId === row.original.id,
|
modelValue={row.original.isActive}
|
||||||
'onUpdate:modelValue': (value: boolean) => emit('toggle-active', { config: row.original, value }),
|
disabled={
|
||||||
}),
|
!props.canManageExistingConfig ||
|
||||||
]),
|
props.saving ||
|
||||||
|
props.deletingId !== null ||
|
||||||
|
props.defaultingId !== null ||
|
||||||
|
props.togglingId === row.original.id
|
||||||
|
}
|
||||||
|
onUpdate:modelValue={(value: boolean) =>
|
||||||
|
emit('toggle-active', { config: row.original, value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
meta: {
|
meta: {
|
||||||
headerClass: 'px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-foreground/50',
|
headerClass: 'px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-foreground/50',
|
||||||
cellClass: 'px-6 py-3 text-center',
|
cellClass: 'px-6 py-3 text-center',
|
||||||
@@ -95,35 +116,48 @@ const columns = computed<ColumnDef<PlayerConfig>[]>(() => [
|
|||||||
id: 'actions',
|
id: 'actions',
|
||||||
header: t('common.actions'),
|
header: t('common.actions'),
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
cell: ({ row }) => h('div', { class: 'flex flex-wrap items-center justify-end gap-2' }, [
|
cell: ({ row }) => (<div class="flex flex-wrap items-center justify-end gap-2">
|
||||||
row.original.isDefault
|
{row.original.isDefault ? (
|
||||||
? h('span', {
|
<span class="inline-flex items-center rounded-full bg-primary/10 px-2 py-1 text-xs font-medium text-primary">
|
||||||
class: 'inline-flex items-center rounded-full bg-primary/10 px-2 py-1 text-xs font-medium text-primary',
|
{t('settings.playerConfigs.actions.default')}
|
||||||
}, t('settings.playerConfigs.actions.default'))
|
</span>
|
||||||
: h(AppButton, {
|
) : (
|
||||||
variant: 'ghost',
|
<AppButton
|
||||||
size: 'sm',
|
variant="ghost"
|
||||||
loading: props.defaultingId === row.original.id,
|
size="sm"
|
||||||
disabled: !props.canManageExistingConfig || props.saving || props.deletingId !== null || props.togglingId !== null || props.defaultingId !== null || !row.original.isActive,
|
loading={props.defaultingId === row.original.id}
|
||||||
onClick: () => emit('set-default', row.original),
|
disabled={
|
||||||
}, () => t('settings.playerConfigs.actions.setDefault')),
|
!props.canManageExistingConfig ||
|
||||||
h(AppButton, {
|
props.saving ||
|
||||||
variant: 'ghost',
|
props.deletingId !== null ||
|
||||||
size: 'sm',
|
props.togglingId !== null ||
|
||||||
disabled: !props.canManageExistingConfig,
|
props.defaultingId !== null ||
|
||||||
onClick: () => emit('edit', row.original),
|
!row.original.isActive
|
||||||
}, {
|
}
|
||||||
icon: () => h(PencilIcon, { class: 'h-4 w-4' }),
|
onClick={() => emit('set-default', row.original)}
|
||||||
}),
|
>
|
||||||
h(AppButton, {
|
{t('settings.playerConfigs.actions.setDefault')}
|
||||||
variant: 'ghost',
|
</AppButton>
|
||||||
size: 'sm',
|
)}
|
||||||
disabled: !props.canDeleteConfig,
|
<AppButton
|
||||||
onClick: () => emit('delete', row.original),
|
variant="ghost"
|
||||||
}, {
|
size="sm"
|
||||||
icon: () => h(TrashIcon, { class: 'h-4 w-4 text-danger' }),
|
disabled={!props.canManageExistingConfig}
|
||||||
}),
|
onClick={() => emit('edit', row.original)}
|
||||||
]),
|
v-slots={{
|
||||||
|
icon: () => <PencilIcon class="h-4 w-4" />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<AppButton
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={!props.canDeleteConfig}
|
||||||
|
onClick={() => emit('delete', row.original)}
|
||||||
|
v-slots={{
|
||||||
|
icon: () => <TrashIcon class="h-4 w-4 text-danger" />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>),
|
||||||
meta: {
|
meta: {
|
||||||
headerClass: 'px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-foreground/50 [&>div]:justify-center',
|
headerClass: 'px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-foreground/50 [&>div]:justify-center',
|
||||||
cellClass: 'px-6 py-3 text-right',
|
cellClass: 'px-6 py-3 text-right',
|
||||||
|
|||||||
@@ -41,9 +41,26 @@ export const useAuthStore = defineStore("auth", () => {
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const initialized = ref(false);
|
const initialized = ref(false);
|
||||||
|
const localeTag = computed(() => i18next.resolvedLanguage === 'vi' ? 'vi-VN' : 'en-US');
|
||||||
|
const currencyFormatter = computed(() => new Intl.NumberFormat(localeTag.value, {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}));
|
||||||
|
const shortDateFormatter = computed(() => new Intl.DateTimeFormat(localeTag.value, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
}));
|
||||||
|
const formatHistoryDate = (value?: string) => {
|
||||||
|
if (!value) return '-';
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return '-';
|
||||||
|
return shortDateFormatter.value.format(date);
|
||||||
|
};
|
||||||
|
const formatMoney = (amount: number) => currencyFormatter.value.format(amount);
|
||||||
|
|
||||||
let mqttClient: TinyMqttClient | undefined;
|
let mqttClient: TinyMqttClient | undefined;
|
||||||
|
|
||||||
const clearMqttClient = () => {
|
const clearMqttClient = () => {
|
||||||
mqttClient?.disconnect();
|
mqttClient?.disconnect();
|
||||||
mqttClient = undefined;
|
mqttClient = undefined;
|
||||||
@@ -233,6 +250,8 @@ export const useAuthStore = defineStore("auth", () => {
|
|||||||
changePassword,
|
changePassword,
|
||||||
setLanguage,
|
setLanguage,
|
||||||
logout,
|
logout,
|
||||||
|
formatHistoryDate,
|
||||||
|
formatMoney,
|
||||||
$reset: () => {
|
$reset: () => {
|
||||||
clearMqttClient();
|
clearMqttClient();
|
||||||
clearState();
|
clearState();
|
||||||
|
|||||||
Reference in New Issue
Block a user