From 47a39b3e1d11f88077439a2a7e520003e88a6331 Mon Sep 17 00:00:00 2001 From: XK4MiLX Date: Thu, 12 Feb 2026 22:11:25 +0100 Subject: [PATCH 1/4] [FEAT] Add billing page with payment history - Add new billing/payments page with payment history table - Display currently running apps with instance count - Add BillingService for API integration - Use sticky backend for authenticated API requests - Add billing menu item to UserProfile dropdown - Implement export functionality - Add filters for transaction types and date ranges - Show payment summary statistics (total paid, registrations) - Display app information with Docker icons - Add custom scrollbar styling matching table design --- index.html | 19 +- src/layouts/components/UserProfile.vue | 20 +- src/pages/billing/payments.vue | 2670 ++++++++++++++++++++++++ src/services/BillingService.js | 38 + 4 files changed, 2731 insertions(+), 16 deletions(-) create mode 100644 src/pages/billing/payments.vue create mode 100644 src/services/BillingService.js diff --git a/index.html b/index.html index cf80385c..1a1cfb8c 100644 --- a/index.html +++ b/index.html @@ -73,10 +73,10 @@ background-color: var(--initial-loader-bg, #fff) !important; color: var(--initial-text-color, #000) !important; transition: none !important; + overflow: hidden; } html { - overflow-x: hidden; - overflow-y: scroll; + overflow: hidden; background-color: var(--initial-loader-bg, #fff) !important; } body, #app { @@ -88,15 +88,14 @@ min-height: 100vh; } #loading-bg { - position: absolute; + position: fixed; display: flex; flex-direction: column; align-items: center; justify-content: center; background: var(--initial-loader-bg, #fff); - block-size: 100%; + inset: 0; gap: 2rem 0; - inline-size: 100%; } .loading-wrapper { position: relative; @@ -420,6 +419,9 @@ // Remove loader immediately for specific pages const loader = document.getElementById('loading-bg') if (loader) loader.remove() + // Restore overflow + document.documentElement.style.overflow = '' + document.body.style.overflow = '' } else { // Normal loader behavior for other pages window.addEventListener('app-ready', () => { @@ -429,7 +431,12 @@ const loader = document.getElementById('loading-bg') if (loader) { loader.classList.add('fade-out') - setTimeout(() => loader.remove(), 800) + setTimeout(() => { + loader.remove() + // Restore overflow after loader is removed + document.documentElement.style.overflow = '' + document.body.style.overflow = '' + }, 800) } }) }) diff --git a/src/layouts/components/UserProfile.vue b/src/layouts/components/UserProfile.vue index 1aafe7cc..2f27b870 100644 --- a/src/layouts/components/UserProfile.vue +++ b/src/layouts/components/UserProfile.vue @@ -125,6 +125,13 @@ async function logout() { const userProfileList = [ { type: "divider" }, + { + type: "navItem", + icon: "tabler-credit-card", + title: "Billing & Payments", + to: "/billing/payments", + }, + { type: "divider" }, // { // type: "navItem", @@ -136,16 +143,6 @@ const userProfileList = [ // icon: "tabler-settings", // title: "Settings", // }, - // { - // type: "navItem", - // icon: "tabler-file-dollar", - // title: "Billing Plan", - // badgeProps: { - // color: "error", - // content: "4", - // }, - // }, - // { type: "divider" }, // { // type: "navItem", @@ -239,11 +236,14 @@ const userProfileList = [ diff --git a/src/pages/billing/payments.vue b/src/pages/billing/payments.vue new file mode 100644 index 00000000..2b0f32a0 --- /dev/null +++ b/src/pages/billing/payments.vue @@ -0,0 +1,2670 @@ + + + + + + + diff --git a/src/services/BillingService.js b/src/services/BillingService.js new file mode 100644 index 00000000..609538ab --- /dev/null +++ b/src/services/BillingService.js @@ -0,0 +1,38 @@ +import Api from '@/services/ApiClient' + +export default { + /** + * Get payment history for the authenticated user + * Uses the /apps/permanentmessages endpoint with owner filter + * @param {string} zelidauthHeader - Authentication header + * @param {string} zelid - User's ZelID + * @returns {Promise} API response with payment transactions + */ + getMyPaymentHistory(zelidauthHeader, zelid) { + const axiosConfig = { + headers: { + zelidauth: zelidauthHeader, + 'x-apicache-bypass': true, + }, + } + + return Api().get(`/apps/permanentmessages?owner=${zelid}`, axiosConfig) + }, + + /** + * Get all active apps for the authenticated user + * @param {string} zelidauthHeader - Authentication header + * @param {string} zelid - User's ZelID + * @returns {Promise} API response with active apps + */ + getMyApps(zelidauthHeader, zelid) { + const axiosConfig = { + headers: { + zelidauth: zelidauthHeader, + 'x-apicache-bypass': true, + }, + } + + return Api().get(`/apps/globalappsspecifications?owner=${zelid}`, axiosConfig) + }, +} From a478a4355d00948e3669e46de7469ce869ca3453 Mon Sep 17 00:00:00 2001 From: XK4MiLX Date: Tue, 17 Feb 2026 11:36:45 +0100 Subject: [PATCH 2/4] Enhance UI, transaction detection and i18n coverage - Fix global CSS leak from payments.vue affecting navbar font (scoped .v-menu rules via :has()) - Add smart transaction type detection: Registration / Renewal / Update based on spec version and expire field comparison - Add custom pagination to transaction dialog matching main table style - Add colored bordered stat cards in transaction dialog - Update all 20 locale files with complete billing translation structure (transactionTable, stats, chart sections were missing) - Fix Polish plural forms and remove colons from chart control labels - Improve mobile responsiveness of category pills (font, icon visibility) - Reposition items-per-page control to left in table footer - Hide footer label on mobile - Various chip color and icon improvements --- src/layouts/components/UserProfile.vue | 6 +- src/pages/billing/payments.vue | 815 ++++++++++++++++--------- src/plugins/1.router/guards.js | 7 + src/plugins/i18n/locales/ar.json | 102 ++++ src/plugins/i18n/locales/bn.json | 102 ++++ src/plugins/i18n/locales/de.json | 102 ++++ src/plugins/i18n/locales/en.json | 106 ++++ src/plugins/i18n/locales/es.json | 102 ++++ src/plugins/i18n/locales/fr.json | 102 ++++ src/plugins/i18n/locales/hi.json | 102 ++++ src/plugins/i18n/locales/id.json | 102 ++++ src/plugins/i18n/locales/it.json | 102 ++++ src/plugins/i18n/locales/ja.json | 102 ++++ src/plugins/i18n/locales/jv.json | 102 ++++ src/plugins/i18n/locales/ko.json | 102 ++++ src/plugins/i18n/locales/mr.json | 102 ++++ src/plugins/i18n/locales/pa.json | 102 ++++ src/plugins/i18n/locales/pl.json | 107 ++++ src/plugins/i18n/locales/pt.json | 102 ++++ src/plugins/i18n/locales/ru.json | 102 ++++ src/plugins/i18n/locales/ta.json | 102 ++++ src/plugins/i18n/locales/te.json | 102 ++++ src/plugins/i18n/locales/tr.json | 102 ++++ src/plugins/i18n/locales/vi.json | 102 ++++ src/plugins/i18n/locales/zh-CN.json | 102 ++++ 25 files changed, 2782 insertions(+), 299 deletions(-) diff --git a/src/layouts/components/UserProfile.vue b/src/layouts/components/UserProfile.vue index 2f27b870..c9065e92 100644 --- a/src/layouts/components/UserProfile.vue +++ b/src/layouts/components/UserProfile.vue @@ -128,7 +128,7 @@ const userProfileList = [ { type: "navItem", icon: "tabler-credit-card", - title: "Billing & Payments", + titleKey: "userProfile.billingAndPayments", to: "/billing/payments", }, { type: "divider" }, @@ -247,7 +247,7 @@ const userProfileList = [ /> - {{ item.title }} + {{ item.titleKey ? $t(item.titleKey) : item.title }} @@ -842,6 +851,66 @@ {{ item.txid.substring(0, 12) }}... + + + @@ -856,46 +925,52 @@ - Export Payment History + {{ $t('pages.billing.exportDialog.title') }}

- Select the time period for your export: + {{ $t('pages.billing.exportDialog.dateRange') }}

- - - - - - - - + + + + + + + + +
- + - + @@ -947,7 +1022,7 @@ size="small" @click="exportDialogOpen = false" > - Cancel + {{ $t('pages.billing.exportDialog.cancel') }} - Export CSV + {{ $t('pages.billing.exportDialog.export') }} @@ -970,10 +1045,11 @@ import { useRouter } from 'vue-router' import { useFluxStore } from '@/stores/flux' import { storeToRefs } from 'pinia' import { useSEO } from '@/composables/useSEO' +import { useI18n } from 'vue-i18n' import BillingService from '@/services/BillingService' import { Doughnut, Line } from 'vue-chartjs' import { Chart as ChartJS, ArcElement, Tooltip, Legend, LineElement, CategoryScale, LinearScale, PointElement, Filler } from 'chart.js' -import { useTheme } from 'vuetify' +import { useTheme, useDisplay } from 'vuetify' import LoadingSpinner from '@/components/Marketplace/LoadingSpinner.vue' import flatPickr from 'vue-flatpickr-component' import 'flatpickr/dist/flatpickr.css' @@ -984,6 +1060,8 @@ const router = useRouter() const fluxStore = useFluxStore() const { zelid } = storeToRefs(fluxStore) const theme = useTheme() +const { t } = useI18n() +const { smAndDown } = useDisplay() // SEO configuration - exclude from search engines and robots useSEO({ @@ -1066,11 +1144,30 @@ const flatpickrConfig = { allowInput: true, } -const aggregationLabels = { - daily: 'Daily', - weekly: 'Weekly', - monthly: 'Monthly', -} +const aggregationLabels = computed(() => ({ + daily: t('pages.billing.chart.aggregation.daily'), + weekly: t('pages.billing.chart.aggregation.weekly'), + monthly: t('pages.billing.chart.aggregation.monthly'), +})) + +const aggregationItems = computed(() => [ + { value: 'daily', title: t('pages.billing.chart.aggregation.daily') }, + { value: 'weekly', title: t('pages.billing.chart.aggregation.weekly') }, + { value: 'monthly', title: t('pages.billing.chart.aggregation.monthly') }, +]) + +const timeRangeItems = computed(() => [ + { value: '7d', title: t('pages.billing.chart.timeRanges.7d') }, + { value: '30d', title: t('pages.billing.chart.timeRanges.30d') }, + { value: '90d', title: t('pages.billing.chart.timeRanges.90d') }, + { value: '1y', title: t('pages.billing.chart.timeRanges.1y') }, + { value: 'all', title: t('pages.billing.chart.timeRanges.all') }, +]) + +const displayModeItems = computed(() => [ + { value: 'monthly', title: t('pages.billing.chart.displayModes.periodTotal') }, + { value: 'cumulative', title: t('pages.billing.chart.displayModes.cumulative') }, +]) // Toggle category pills function toggleCategory(category) { @@ -1109,22 +1206,22 @@ const uniqueApps = computed(() => { // Table headers -const headers = [ - { title: 'App Name', key: 'appName', sortable: true }, - { title: 'Total Paid', key: 'totalPaid', sortable: true }, - { title: 'Payments', key: 'count', sortable: true }, - { title: 'Avg Payment', key: 'avgPayment', sortable: true }, - { title: 'Last Payment', key: 'lastPayment', sortable: true }, - { title: 'Actions', key: 'actions', sortable: false }, -] - -const transactionHeaders = [ - { title: 'Type', key: 'type', sortable: true }, - { title: 'Amount', key: 'amount', sortable: true }, - { title: 'Date', key: 'timestamp', sortable: true }, - { title: 'Block', key: 'height', sortable: true }, - { title: 'Transaction ID', key: 'txid', sortable: false }, -] +const headers = computed(() => [ + { title: t('pages.billing.table.appName'), key: 'appName', sortable: true }, + { title: t('pages.billing.table.totalPaid'), key: 'totalPaid', sortable: true }, + { title: t('pages.billing.table.payments'), key: 'count', sortable: true }, + { title: t('pages.billing.table.avgPayment'), key: 'avgPayment', sortable: true }, + { title: t('pages.billing.lastTransaction'), key: 'lastPayment', sortable: true }, + { title: t('pages.billing.table.actions'), key: 'actions', sortable: false }, +]) + +const transactionHeaders = computed(() => [ + { title: t('pages.billing.transactionTable.type'), key: 'type', sortable: true }, + { title: t('pages.billing.transactionTable.amount'), key: 'amount', sortable: true }, + { title: t('pages.billing.transactionTable.date'), key: 'timestamp', sortable: true }, + { title: t('pages.billing.transactionTable.blockHeight'), key: 'height', sortable: true }, + { title: t('pages.billing.transactionTable.txid'), key: 'txid', sortable: false }, +]) // Helper function to check if transaction is a free update (0.02 FLUX) const isFreeUpdate = (tx) => { @@ -1317,6 +1414,47 @@ const visiblePages = computed(() => { return pages }) +// Dialog transaction table pagination +const dialogPage = ref(1) +const dialogItemsPerPage = 10 + +const dialogTotalPages = computed(() => { + return Math.ceil((selectedApp.value?.transactions?.length || 0) / dialogItemsPerPage) +}) + +const dialogStartItem = computed(() => { + return (dialogPage.value - 1) * dialogItemsPerPage + 1 +}) + +const dialogEndItem = computed(() => { + return Math.min(dialogPage.value * dialogItemsPerPage, selectedApp.value?.transactions?.length || 0) +}) + +const dialogVisiblePages = computed(() => { + const pages = [] + const total = dialogTotalPages.value + const current = dialogPage.value + + if (total <= 7) { + for (let i = 1; i <= total; i++) pages.push(i) + } else { + pages.push(1) + if (current > 3) pages.push('...') + const start = Math.max(2, current - 1) + const end = Math.min(total - 1, current + 1) + for (let i = start; i <= end; i++) pages.push(i) + if (current < total - 2) pages.push('...') + pages.push(total) + } + + return pages +}) + +// Reset dialog page when opening a new app +watch(() => selectedApp.value?.appName, () => { + dialogPage.value = 1 +}) + const topApps = computed(() => { return appPayments.value .sort((a, b) => b.totalPaid - a.totalPaid) @@ -1454,24 +1592,48 @@ watch(appFilterMenuOpen, (isOpen) => { function viewTransactions(app) { // Dynamically build transactions array for this specific app - const appTransactions = transactions.value - .filter(tx => { - const appName = tx.appSpecifications?.name || 'Unknown' - return appName === app.appName - }) - .map(tx => { - const amountFlux = (tx.valueSat || 0) / 100000000 - const isRegister = tx.type === 'zelappregister' || tx.type === 'fluxappregister' - const type = isRegister ? 'register' : 'update' - - return { - txid: tx.txid, - amount: amountFlux, - height: tx.height, - timestamp: tx.timestamp, - type: type, + const rawTxs = transactions.value + .filter(tx => (tx.appSpecifications?.name || 'Unknown') === app.appName) + .sort((a, b) => a.timestamp - b.timestamp) + + const appTransactions = rawTxs.map((tx, i) => { + const amountFlux = (tx.valueSat || 0) / 100000000 + const isRegister = tx.type === 'zelappregister' || tx.type === 'fluxappregister' + + let type = 'update' + if (isRegister) { + type = 'register' + } else { + const currSpec = tx.appSpecifications || {} + const specVersion = currSpec.version || 0 + + // Spec version < 6 had no expire field — every update was a renewal + if (specVersion < 6) { + type = 'renewal' + } else { + // Spec version >= 6 has expire field — compare with previous tx + const prevTx = rawTxs[i - 1] + if (prevTx) { + const prevSpec = prevTx.appSpecifications || {} + const expireChanged = prevSpec.expire !== currSpec.expire + const onlyExpireOrNoChange = Object.keys(currSpec).every(k => + k === 'expire' || JSON.stringify(prevSpec[k]) === JSON.stringify(currSpec[k]) + ) + if (expireChanged || onlyExpireOrNoChange) { + type = 'renewal' + } + } } - }) + } + + return { + txid: tx.txid, + amount: amountFlux, + height: tx.height, + timestamp: tx.timestamp, + type: type, + } + }) selectedApp.value = { ...app, @@ -2075,7 +2237,7 @@ function exportCSV() { // Check if there are any transactions to export (only headers means no data) if (rows.length === 1) { - exportValidationError.value = 'No transactions found for the selected period' + exportValidationError.value = t('pages.billing.noTransactionsFound') return } @@ -2362,11 +2524,34 @@ useSEO({ /* Category Pills - FluxTracker style */ .billing-category-pills { display: flex; - gap: 0.5rem; + column-gap: 0.5rem; + row-gap: 0.25rem; margin-bottom: 1rem; flex-wrap: wrap; } +@media (max-width: 599px) { + .billing-category-pills { + flex-wrap: nowrap; + } + + .billing-category-pill { + flex: 1 1 0; + min-width: 0; + justify-content: center; + padding: 0.25rem 0.25rem; + } + + .billing-category-label { + font-size: 0.6rem !important; + letter-spacing: 0 !important; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + +} + .billing-category-pill { display: flex; align-items: center; @@ -2442,6 +2627,12 @@ useSEO({ line-height: 1.4 !important; } +@media (max-width: 599px) { + :deep(.bordered-table .v-data-table-footer__items-per-page > span) { + display: none; + } +} + @media (max-width: 768px) { .billing-chart-header { flex-direction: column; @@ -2471,6 +2662,12 @@ useSEO({ } /* Sticky table header */ +:deep(.bordered-table .v-data-table-footer__items-per-page) { + order: -1; + margin-right: auto; + margin-left: 1rem; +} + :deep(.bordered-table thead) { position: sticky; top: 0; @@ -2523,6 +2720,32 @@ useSEO({ box-shadow: 0 0 0 2px rgba(var(--v-theme-primary), 0.1); } +/* Stat cards in transaction dialog */ +.stat-card { + border-radius: 8px; + border: 1px solid; +} + +.stat-card--secondary { + background-color: rgba(var(--v-theme-secondary), 0.06); + border-color: rgba(var(--v-theme-secondary), 0.2); +} + +.stat-card--info { + background-color: rgba(var(--v-theme-info), 0.06); + border-color: rgba(var(--v-theme-info), 0.2); +} + +.stat-card--success { + background-color: rgba(var(--v-theme-success), 0.06); + border-color: rgba(var(--v-theme-success), 0.2); +} + +.stat-card--warning { + background-color: rgba(var(--v-theme-warning), 0.06); + border-color: rgba(var(--v-theme-warning), 0.2); +} + /* Export dialog radio button styling */ .export-radio-group :deep(.v-label) { font-size: 0.8125rem !important; @@ -2530,12 +2753,12 @@ useSEO({