FIX - polish banking interactions and transaction details

This commit is contained in:
Leon.Schmidt
2026-08-17 00:24:58 +02:00
parent 181206f3cf
commit 335b9a98b5
12 changed files with 482 additions and 111 deletions
+32 -1
View File
@@ -90,6 +90,7 @@ type AppMessage = {
| PicstagramVerificationData
| PicstagramNotificationData
| FeatherNotificationData
| BankingChangedData
| BillingNotificationData
| EasyShareEvent
| PhoneCall
@@ -238,6 +239,13 @@ type BillingNotificationData = {
title?: string
}
type BankingChangedData = {
amount?: number
currency?: string
kind?: 'transfer_in'
sender?: string
}
type CrewLinkNotificationData = {
actor?: string
device?: PhoneNotificationDevicePayload
@@ -973,7 +981,30 @@ function onMessage(event: MessageEvent<AppMessage>): void {
} else if (event.data?.type === 'calls:changed') {
void calls.loadRecents()
} else if (event.data?.type === 'banking:changed') {
void banking.load()
const data = event.data.data as BankingChangedData | undefined
void banking.load(false, true)
if (
data?.kind === 'transfer_in' &&
typeof data.amount === 'number' &&
data.amount > 0
) {
const formattedAmount = `${
data.currency ?? banking.overview?.currency ?? '$'
}${new Intl.NumberFormat(phone.lang, {
maximumFractionDigits: 0,
minimumFractionDigits: 0,
}).format(data.amount)}`
notifications.show({
appId: 'banking',
route: '/apps/banking',
subtitle: data.sender,
text: phone.t('Apps.banking.notifications.received', {
amount: formattedAmount,
sender: data.sender ?? '',
}),
title: phone.t('Apps.banking.notifications.receivedTitle'),
})
}
} else if (event.data?.type === 'billing:changed') {
void billing.loadOverview()
} else if (event.data?.type === 'billing:new' && event.data.data) {
+80 -32
View File
@@ -3720,6 +3720,9 @@ button {
--bank-blue: #2d76ff;
--bank-cyan: #57d5ff;
--bank-green: #4ee6a4;
--banking-card-radius: 22px;
--banking-page-gutter: 16px;
--banking-section-gap: 16px;
position: relative;
height: 100%;
overflow: hidden;
@@ -3772,8 +3775,16 @@ button {
inset: 0;
overflow-x: hidden;
overflow-y: auto;
padding: 108px 17px 112px;
overscroll-behavior-y: contain;
padding: 108px var(--banking-page-gutter) 112px;
scrollbar-width: none;
touch-action: pan-y;
-webkit-overflow-scrolling: touch;
}
.banking-scroll.is-locked {
overflow-y: hidden;
touch-action: none;
}
.banking-scroll::-webkit-scrollbar {
@@ -3828,8 +3839,8 @@ button {
.banking-balance {
position: relative;
overflow: hidden;
padding: 22px 20px 20px;
border-radius: 27px;
padding: 20px;
border-radius: var(--banking-card-radius);
}
.banking-balance__label {
@@ -3871,8 +3882,8 @@ button {
.banking-actions {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 9px;
margin: 13px 0 18px;
gap: 10px;
margin: 12px 0 var(--banking-section-gap);
}
.banking-action {
@@ -3884,7 +3895,7 @@ button {
min-height: 64px;
overflow: hidden;
padding: 11px 12px;
border-radius: 19px;
border-radius: var(--banking-card-radius);
color: rgb(237 243 255 / 82%);
font-size: 10px;
}
@@ -3933,7 +3944,8 @@ button {
}
.banking-card {
margin: 0 0 18px !important;
margin: 0 0 var(--banking-section-gap) !important;
border-radius: var(--banking-card-radius) !important;
}
.banking-transaction-card {
@@ -3944,7 +3956,7 @@ button {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 11px;
margin-bottom: 10px;
}
.banking-section-title h2 {
@@ -3969,10 +3981,10 @@ button {
}
.banking-transactions {
padding: 0 3px;
padding: 0;
}
.banking-transactions--all {
margin-top: 18px;
margin-top: var(--banking-section-gap);
}
.banking-transaction-list b {
@@ -3989,7 +4001,7 @@ button {
.banking-no-transactions {
padding: 24px 12px;
border: 1px solid rgb(255 255 255 / 8%);
border-radius: 22px;
border-radius: var(--banking-card-radius);
background: rgb(255 255 255 / 3%);
color: rgb(213 225 248 / 47%);
font-size: 11px;
@@ -4000,7 +4012,7 @@ button {
display: flex;
align-items: center;
flex-direction: column;
padding: 16px 0 20px;
padding: 12px 0 var(--banking-section-gap);
text-align: center;
}
@@ -4098,20 +4110,7 @@ button {
.banking-sheet__content {
position: relative;
width: 100%;
padding: 22px 18px 18px;
}
.banking-modal__close {
position: absolute;
top: 15px;
right: 15px;
display: grid;
width: 30px;
height: 30px;
place-items: center;
border-radius: 50%;
background: rgb(255 255 255 / 8%);
color: rgb(226 235 255 / 65%);
padding: 8px 18px calc(var(--sky-safe-area-bottom) + 18px);
}
.banking-modal__icon {
@@ -4140,11 +4139,11 @@ button {
}
.banking-form-list {
margin: 12px 0 0 !important;
margin: var(--banking-section-gap) 0 0 !important;
}
.banking-contact-picker {
margin-top: 12px;
margin-top: var(--banking-section-gap);
}
.banking-contact-picker > span {
@@ -4174,7 +4173,58 @@ button {
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 15px;
margin-top: var(--banking-section-gap);
}
.banking-transaction-detail {
text-align: center;
}
.banking-transaction-detail__icon {
margin: 0 auto;
background: linear-gradient(145deg, #d95867, #972937);
}
.banking-transaction-detail__icon.is-incoming {
background: linear-gradient(145deg, #38c58d, #147454);
box-shadow:
0 12px 28px rgb(20 116 84 / 32%),
inset 0 1px rgb(255 255 255 / 28%);
}
.banking-transaction-detail > .banking-transaction-detail__eyebrow {
margin: 12px 0 0;
color: rgb(213 225 248 / 52%);
font-size: 10px;
font-weight: 650;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.banking-transaction-detail h2 {
margin-top: 4px;
}
.banking-transaction-detail__amount {
display: block;
margin: 8px 0 18px;
color: #f2a0a8;
font-size: 30px;
font-weight: 650;
letter-spacing: -0.04em;
}
.banking-transaction-detail__amount.is-incoming {
color: var(--bank-green);
}
.banking-transaction-detail__list {
margin: 0 !important;
text-align: left;
}
.banking-transaction-detail__list .sky-list-item__subtitle {
overflow-wrap: anywhere;
}
.phone-app--light .banking-app {
@@ -4209,7 +4259,6 @@ button {
.phone-app--light .banking-activity-hero small,
.phone-app--light .banking-chart-legend,
.phone-app--light .banking-chart__day span,
.phone-app--light .banking-modal__close,
.phone-app--light .banking-sheet__content > p,
.phone-app--light .banking-contact-picker > span,
.phone-app--light .banking-contact-picker > p {
@@ -4217,8 +4266,7 @@ button {
}
.phone-app--light .banking-balance__label small,
.phone-app--light .banking-no-transactions,
.phone-app--light .banking-modal__close {
.phone-app--light .banking-no-transactions {
border-color: var(--sky-hairline);
background: rgb(255 255 255 / 52%);
}
+56 -9
View File
@@ -61,7 +61,7 @@ describe('banking store', () => {
expect(banking.error).toBe('insufficient_funds')
})
it('does not let an older response overwrite the newest overview', async () => {
it('does not let an older overview response overwrite a newer transfer', async () => {
let resolveOlder!: (response: NuiResponse<BankingOverview>) => void
const olderResponse = new Promise<NuiResponse<BankingOverview>>(
(resolve) => {
@@ -75,7 +75,7 @@ describe('banking store', () => {
const banking = useBankingStore()
const olderRequest = banking.load()
await banking.load()
await banking.perform('transfer', 1787, '5551234567')
resolveOlder({ data: { ...overview, bank: 1 }, success: true })
await olderRequest
@@ -83,15 +83,62 @@ describe('banking store', () => {
expect(banking.isLoading).toBe(false)
})
it('blocks every banking request while a reload cooldown is active', async () => {
it('coalesces concurrent overview reloads into one NUI request', async () => {
let resolveLoad!: (response: NuiResponse<BankingOverview>) => void
mockNuiCall.mockReturnValueOnce(
new Promise<NuiResponse<BankingOverview>>((resolve) => {
resolveLoad = resolve
}),
)
const banking = useBankingStore()
const firstLoad = banking.load()
const secondLoad = banking.load()
resolveLoad({ data: overview, success: true })
expect(await firstLoad).toBe(true)
expect(await secondLoad).toBe(true)
expect(mockNuiCall).toHaveBeenCalledTimes(1)
})
it('queues one fresh overview after a server-side balance change', async () => {
let resolveActive!: (response: NuiResponse<BankingOverview>) => void
mockNuiCall
.mockReturnValueOnce(
new Promise<NuiResponse<BankingOverview>>((resolve) => {
resolveActive = resolve
}),
)
.mockResolvedValueOnce({
data: { ...overview, bank: overview.bank + 500 },
success: true,
})
const banking = useBankingStore()
const activeLoad = banking.load()
const changedLoad = banking.load(false, true)
const duplicateChangedLoad = banking.load(false, true)
resolveActive({ data: overview, success: true })
await activeLoad
expect(await changedLoad).toBe(true)
expect(await duplicateChangedLoad).toBe(true)
expect(banking.overview?.bank).toBe(overview.bank + 500)
expect(mockNuiCall).toHaveBeenCalledTimes(2)
})
it('limits manual refreshes without blocking automatic loads or transfers', async () => {
const banking = useBankingStore()
banking.cooldownUntil = Date.now() + 10_000
mockNuiCall
.mockResolvedValueOnce({ data: overview, success: true })
.mockResolvedValueOnce({ data: overview, success: true })
expect(await banking.load()).toBe(false)
expect(await banking.perform('transfer', 100, '5551234567')).toEqual({
error: 'reload_cooldown',
success: false,
})
expect(mockNuiCall).not.toHaveBeenCalled()
expect(await banking.load(true)).toBe(false)
expect(await banking.load()).toBe(true)
expect((await banking.perform('transfer', 100, '5551234567')).success).toBe(
true,
)
expect(mockNuiCall).toHaveBeenCalledTimes(2)
})
})
+43 -22
View File
@@ -8,6 +8,9 @@ import {
RELOAD_COOLDOWN_ERROR,
} from '@/utils/reload-cooldown'
let activeOverviewLoad: Promise<boolean> | null = null
let queuedOverviewLoad: Promise<boolean> | null = null
export const useBankingStore = defineStore('banking', {
state: () => ({
error: '',
@@ -19,41 +22,59 @@ export const useBankingStore = defineStore('banking', {
reloadAttempts: [] as number[],
}),
actions: {
async load(manualReload = false): Promise<boolean> {
async load(manualReload = false, ensureFresh = false): Promise<boolean> {
if (activeOverviewLoad) {
if (!ensureFresh) return activeOverviewLoad
if (!queuedOverviewLoad) {
const activeRequest = activeOverviewLoad
const queuedRequest = activeRequest.then(
() => this.load(),
() => this.load(),
)
const trackedQueuedRequest = queuedRequest.finally(() => {
queuedOverviewLoad = null
})
queuedOverviewLoad = trackedQueuedRequest
}
return queuedOverviewLoad
}
if (
isReloadCooldownActive(this) ||
(manualReload && !allowManualReload(this))
manualReload &&
(isReloadCooldownActive(this) || !allowManualReload(this))
) {
this.error = RELOAD_COOLDOWN_ERROR
return false
}
const generation = ++this.requestGeneration
this.pendingRequests += 1
this.isLoading = true
const response = await nuiCall<BankingOverview>('banking:overview').finally(
() => {
const request = (async () => {
const generation = ++this.requestGeneration
this.pendingRequests += 1
this.isLoading = true
const response = await nuiCall<BankingOverview>(
'banking:overview',
).finally(() => {
this.pendingRequests = Math.max(0, this.pendingRequests - 1)
this.isLoading = this.pendingRequests > 0
},
)
if (generation !== this.requestGeneration) return response.success
if (response.success && response.data) {
this.overview = response.data
this.error = ''
return true
}
this.error = response.error ?? 'request_failed'
return false
})
if (generation !== this.requestGeneration) return response.success
if (response.success && response.data) {
this.overview = response.data
this.error = ''
return true
}
this.error = response.error ?? 'request_failed'
return false
})()
const trackedRequest = request.finally(() => {
activeOverviewLoad = null
})
activeOverviewLoad = trackedRequest
return trackedRequest
},
async perform(
action: BankingAction,
amount: number,
phoneNumber?: string,
): Promise<NuiResponse<BankingOverview>> {
if (isReloadCooldownActive(this)) {
this.error = RELOAD_COOLDOWN_ERROR
return { error: RELOAD_COOLDOWN_ERROR, success: false }
}
const generation = ++this.requestGeneration
this.pendingRequests += 1
this.isLoading = true
+8
View File
@@ -2284,6 +2284,14 @@ const defaultLocales: LocaleTree = {
noContacts: 'No contacts saved yet.',
amount: 'Amount',
amountPlaceholder: 'Enter an amount',
transactionDetails: 'Transaction details',
transactionDate: 'Date',
transactionDirection: 'Direction',
transactionReference: 'Reference',
notifications: {
receivedTitle: 'Money received',
received: 'You received {amount} from {sender}.',
},
transactions: {
deposit: 'Cash deposit',
withdrawal: 'Cash withdrawal',
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import {
normalizeBankingAmountInput,
parseBankingAmount,
} from '@/utils/bankingAmount'
describe('banking amount input', () => {
it('uses a decimal comma and strips native number-input noise', () => {
expect(normalizeBankingAmountInput('1.')).toBe('1,')
expect(normalizeBankingAmountInput('12.00')).toBe('12,00')
expect(normalizeBankingAmountInput('EUR 12,00')).toBe('12,00')
expect(normalizeBankingAmountInput('12,0,0')).toBe('12,00')
})
it('keeps the server-authoritative whole-money contract', () => {
expect(parseBankingAmount('125')).toBe(125)
expect(parseBankingAmount('125,00')).toBe(125)
expect(parseBankingAmount('125,50')).toBeNull()
expect(parseBankingAmount('0')).toBeNull()
expect(parseBankingAmount('')).toBeNull()
})
})
+24
View File
@@ -0,0 +1,24 @@
export function normalizeBankingAmountInput(value: string): string {
const normalizedSeparators = value.replace(/\./g, ',')
const digitsAndSeparators = normalizedSeparators.replace(/[^\d,]/g, '')
const separatorIndex = digitsAndSeparators.indexOf(',')
if (separatorIndex < 0) return digitsAndSeparators
const whole = digitsAndSeparators.slice(0, separatorIndex)
const decimal = digitsAndSeparators
.slice(separatorIndex + 1)
.replace(/,/g, '')
.slice(0, 2)
return `${whole},${decimal}`
}
export function parseBankingAmount(value: string): number | null {
if (!/^\d+(?:,\d{1,2})?$/.test(value)) return null
const amount = Number(value.replace(',', '.'))
if (!Number.isSafeInteger(amount) || amount <= 0) return null
return amount
}
@@ -6,6 +6,22 @@ const source = readFileSync(
new URL('./BankingApp.vue', import.meta.url),
'utf8',
)
const styles = readFileSync(
new URL('../../assets/main.css', import.meta.url),
'utf8',
)
const appSource = readFileSync(
new URL('../../App.vue', import.meta.url),
'utf8',
)
const clientSource = readFileSync(
new URL('../../../../sky_phone/source/client/main.lua', import.meta.url),
'utf8',
)
const serverSource = readFileSync(
new URL('../../../../sky_phone/source/server/banking.lua', import.meta.url),
'utf8',
)
describe('Banking app Sky UI migration', () => {
it('uses Sky UI instead of Konsta components', () => {
@@ -37,4 +53,60 @@ describe('Banking app Sky UI migration', () => {
expect(source).not.toContain('handleSheetKeydown')
expect(source).not.toContain('handleWindowKeydown')
})
it('uses a comma-first text amount field without native stepper arrows', () => {
expect(source).toContain('class="banking-amount-field"')
expect(source).toContain('inputmode="decimal"')
expect(source).toContain('type="text"')
expect(source).not.toMatch(
/input-id="banking-transfer-amount"[\s\S]*?type="number"/,
)
expect(source).toContain('normalizeBankingAmountInput')
expect(source).toContain('parseBankingAmount')
})
it('turns received phone transfers into routed Banking notifications', () => {
expect(serverSource).toContain('kind = "transfer_in"')
expect(serverSource).toContain('currency = Config.Banking.Currency')
expect(clientSource).toContain(
'SendNUIMessage({ type = "banking:changed", data = data })',
)
expect(appSource).toContain("data?.kind === 'transfer_in'")
expect(appSource).toContain("appId: 'banking'")
expect(appSource).toContain("route: '/apps/banking'")
expect(appSource).toContain("phone.t('Apps.banking.notifications.received'")
})
it('resets tab scroll and locks the background behind sheets', () => {
expect(source).toContain('async function selectTab(nextTab: BankingTab)')
expect(source).toContain('bankingScroll.value.scrollTop = 0')
expect(source).toContain(':class="{ \'is-locked\': overlayOpened }"')
expect(source).toContain('@click="selectTab(\'activity\')"')
expect(source).toContain('@click="selectTab(\'home\')"')
expect(styles).toMatch(
/\.banking-scroll\s*\{[^}]*overscroll-behavior-y:\s*contain;[^}]*touch-action:\s*pan-y;/s,
)
expect(styles).toMatch(
/\.banking-scroll\.is-locked\s*\{[^}]*overflow-y:\s*hidden;[^}]*touch-action:\s*none;/s,
)
})
it('opens every transaction in an accessible Sky detail sheet', () => {
expect(source).toContain(
'const selectedTransaction = ref<BankingTransaction | null>(null)',
)
expect(source).toContain(
'function openTransaction(transaction: BankingTransaction)',
)
expect(source).toContain('@click="openTransaction(transaction)"')
expect(source).toContain('link-component="button"')
expect(source).toContain(':opened="overlayOpened"')
expect(source).toContain('swipe-to-close')
expect(source).toContain('grabber-clickable')
expect(source).toContain('@swipeclose="closeAction"')
expect(source).not.toContain('banking-modal__close')
expect(source).toContain('id="banking-transaction-detail-title"')
expect(source).toContain('banking-transaction-detail__amount')
expect(source).toContain("phone.t('Apps.banking.transactionReference')")
})
})
+126 -41
View File
@@ -10,7 +10,6 @@ import {
Landmark,
Send,
WalletCards,
X,
} from 'lucide-vue-next'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
@@ -23,6 +22,10 @@ import type {
BankingTransactionKind,
} from '@/types/banking'
import type { PhoneContact } from '@/types/phone'
import {
normalizeBankingAmountInput,
parseBankingAmount,
} from '@/utils/bankingAmount'
import { handleEnterAction } from '@/utils/keyboard'
import { formatPhoneNumber, normalizePhoneNumber } from '@/utils/phone'
import {
@@ -50,6 +53,7 @@ const banking = useBankingStore()
const calls = useCallsStore()
const activeTab = ref<BankingTab>('home')
const action = ref<BankingAction | null>(null)
const selectedTransaction = ref<BankingTransaction | null>(null)
const amount = ref('')
const target = ref('')
const formError = ref('')
@@ -57,6 +61,9 @@ const bankingScroll = ref<HTMLElement | null>(null)
const isRefreshing = ref(false)
const pullDistance = ref(0)
const cooldownToastOpened = ref(false)
const overlayOpened = computed(() =>
Boolean(action.value || selectedTransaction.value),
)
const pullThreshold = 56
let pullStartY = 0
@@ -145,11 +152,15 @@ function transactionTitle(transaction: BankingTransaction): string {
return phone.t(`Apps.banking.transactions.${transaction.kind}`)
}
async function selectTab(nextTab: BankingTab): Promise<void> {
if (activeTab.value === nextTab) return
activeTab.value = nextTab
await nextTick()
if (bankingScroll.value) bankingScroll.value.scrollTop = 0
}
function openAction(nextAction: BankingAction): void {
if (banking.cooldownUntil > Date.now()) {
banking.error = 'reload_cooldown'
return
}
selectedTransaction.value = null
action.value = nextAction
amount.value = ''
target.value = ''
@@ -158,8 +169,14 @@ function openAction(nextAction: BankingAction): void {
}
function closeAction(): void {
if (banking.isLoading) return
if (action.value && banking.isLoading) return
action.value = null
selectedTransaction.value = null
}
function openTransaction(transaction: BankingTransaction): void {
action.value = null
selectedTransaction.value = transaction
}
function updateTarget(event: Event): void {
@@ -186,7 +203,11 @@ function updateAmount(event: Event): void {
console.error('[banking] Amount input emitted without an input target.')
return
}
amount.value = event.target.value
const normalizedAmount = normalizeBankingAmountInput(event.target.value)
if (event.target.value !== normalizedAmount) {
event.target.value = normalizedAmount
}
amount.value = normalizedAmount
formError.value = ''
}
@@ -248,14 +269,10 @@ function errorMessage(code: string): string {
async function submitAction(): Promise<void> {
if (!action.value) return
const parsedAmount = Number(amount.value)
const parsedAmount = parseBankingAmount(amount.value)
const phoneNumber =
action.value === 'transfer' ? normalizePhoneNumber(target.value) : undefined
if (
!Number.isSafeInteger(parsedAmount) ||
parsedAmount <= 0 ||
(action.value === 'transfer' && !phoneNumber)
) {
if (parsedAmount === null || (action.value === 'transfer' && !phoneNumber)) {
formError.value = phone.t('Apps.banking.errors.invalid_request')
return
}
@@ -310,8 +327,8 @@ onBeforeUnmount(() => {
<SkyNavbar
class="banking-navbar"
:aria-hidden="Boolean(action)"
:inert="Boolean(action)"
:aria-hidden="overlayOpened"
:inert="overlayOpened"
:subtitle="phone.t('Apps.banking.welcome')"
:title="banking.overview?.playerName ?? phone.t('Common.loading')"
/>
@@ -319,8 +336,8 @@ onBeforeUnmount(() => {
<div
v-if="!banking.overview && banking.isLoading"
class="banking-loading"
:aria-hidden="Boolean(action)"
:inert="Boolean(action)"
:aria-hidden="overlayOpened"
:inert="overlayOpened"
>
<SkySpinner :label="phone.t('Common.loading')" />
<span>{{ phone.t('Common.loading') }}</span>
@@ -329,9 +346,9 @@ onBeforeUnmount(() => {
<SkyEmptyState
v-else-if="!banking.overview"
class="banking-empty"
:aria-hidden="Boolean(action)"
:aria-hidden="overlayOpened"
:body="errorMessage(banking.error)"
:inert="Boolean(action)"
:inert="overlayOpened"
:title="phone.t('Apps.banking.unavailable')"
>
<template #icon><Landmark :size="34" /></template>
@@ -346,8 +363,9 @@ onBeforeUnmount(() => {
v-else
ref="bankingScroll"
class="banking-scroll"
:aria-hidden="Boolean(action)"
:inert="Boolean(action)"
:class="{ 'is-locked': overlayOpened }"
:aria-hidden="overlayOpened"
:inert="overlayOpened"
@touchend="finishPull"
@touchmove.passive="movePull"
@touchstart.passive="startPull"
@@ -418,7 +436,7 @@ onBeforeUnmount(() => {
<SkyLink
component="button"
type="button"
@click="activeTab = 'activity'"
@click="selectTab('activity')"
>
{{ phone.t('Apps.banking.viewAll') }}
</SkyLink>
@@ -434,6 +452,9 @@ onBeforeUnmount(() => {
:key="transaction.id"
:subtitle="formatDate(transaction.createdAt)"
:title="transactionTitle(transaction)"
link
link-component="button"
@click="openTransaction(transaction)"
>
<template #media>
<component
@@ -519,6 +540,9 @@ onBeforeUnmount(() => {
:key="transaction.id"
:subtitle="formatDate(transaction.createdAt)"
:title="transactionTitle(transaction)"
link
link-component="button"
@click="openTransaction(transaction)"
>
<template #media>
<component
@@ -550,43 +574,44 @@ onBeforeUnmount(() => {
icons
labels
class="banking-tabbar"
:aria-hidden="Boolean(action)"
:inert="Boolean(action)"
:aria-hidden="overlayOpened"
:inert="overlayOpened"
:label="phone.t('Apps.banking.navigation')"
>
<SkyTabButton
:active="activeTab === 'home'"
:label="phone.t('Apps.banking.home')"
@click="activeTab = 'home'"
@click="selectTab('home')"
>
<template #icon><House :size="25" /></template>
</SkyTabButton>
<SkyTabButton
:active="activeTab === 'activity'"
:label="phone.t('Apps.banking.activity')"
@click="activeTab = 'activity'"
@click="selectTab('activity')"
>
<template #icon><BarChart3 :size="25" /></template>
</SkyTabButton>
</SkyTabBar>
<SkySheet
:opened="Boolean(action)"
:ariaLabelledby="action ? `banking-${action}-title` : undefined"
:opened="overlayOpened"
:ariaLabelledby="
action
? `banking-${action}-title`
: selectedTransaction
? 'banking-transaction-detail-title'
: undefined
"
swipe-to-close
grabber-clickable
:grabber-label="phone.t('Common.close')"
@backdropclick="closeAction"
@escape="closeAction"
@grabberclick="closeAction"
@swipeclose="closeAction"
>
<section v-if="action" class="banking-sheet__content">
<SkyLink
component="button"
class="banking-modal__close"
:aria-label="phone.t('Common.close')"
icon-only
type="button"
@click="closeAction"
>
<X :size="17" />
</SkyLink>
<span class="banking-modal__icon">
<Send :size="23" />
</span>
@@ -606,14 +631,16 @@ onBeforeUnmount(() => {
@input="updateTarget"
/>
<SkyField
autocomplete="off"
class="banking-amount-field"
:label="phone.t('Apps.banking.amount')"
:error="formError || false"
input-id="banking-transfer-amount"
inputmode="numeric"
min="1"
inputmode="decimal"
outline
pattern="[0-9]+([,][0-9]{0,2})?"
:placeholder="phone.t('Apps.banking.amountPlaceholder')"
type="number"
type="text"
:value="amount"
@input="updateAmount"
@keydown.enter="handleEnterAction($event, submitAction)"
@@ -652,6 +679,64 @@ onBeforeUnmount(() => {
</template>
</SkyButton>
</section>
<section
v-else-if="selectedTransaction"
class="banking-sheet__content banking-transaction-detail"
>
<span
class="banking-modal__icon banking-transaction-detail__icon"
:class="{
'is-incoming': isIncoming(selectedTransaction.kind),
}"
>
<component
:is="transactionIcons[selectedTransaction.kind]"
:size="23"
/>
</span>
<p class="banking-transaction-detail__eyebrow">
{{ phone.t('Apps.banking.transactionDetails') }}
</p>
<h2 id="banking-transaction-detail-title">
{{ transactionTitle(selectedTransaction) }}
</h2>
<strong
class="banking-transaction-detail__amount"
:class="{
'is-incoming': isIncoming(selectedTransaction.kind),
}"
>
{{
formatMoney(
isIncoming(selectedTransaction.kind)
? selectedTransaction.amount
: -selectedTransaction.amount,
true,
)
}}
</strong>
<SkyList inset strong class="banking-transaction-detail__list">
<SkyListItem
:title="phone.t('Apps.banking.transactionDate')"
:after="formatDate(selectedTransaction.createdAt)"
/>
<SkyListItem
:title="phone.t('Apps.banking.transactionDirection')"
:after="
phone.t(
isIncoming(selectedTransaction.kind)
? 'Apps.banking.incoming'
: 'Apps.banking.outgoing',
)
"
/>
<SkyListItem
v-if="selectedTransaction.reference"
:title="phone.t('Apps.banking.transactionReference')"
:subtitle="selectedTransaction.reference"
/>
</SkyList>
</section>
</SkySheet>
<SkyToast
+6
View File
@@ -1040,6 +1040,12 @@ Locales["en"] = {
recipientPhone = "Phone number", recipientPhonePlaceholder = "Enter the recipient's number",
chooseContact = "Choose from contacts", noContacts = "No contacts saved yet.",
amount = "Amount", amountPlaceholder = "Enter an amount",
transactionDetails = "Transaction details", transactionDate = "Date",
transactionDirection = "Direction", transactionReference = "Reference",
notifications = {
receivedTitle = "Money received",
received = "You received {amount} from {sender}.",
},
transactions = {
deposit = "Cash deposit", withdrawal = "Cash withdrawal",
transfer_in = "Incoming transfer", transfer_out = "Outgoing transfer",
+2 -2
View File
@@ -894,8 +894,8 @@ RegisterNetEvent("sky_phone:calls:changed", function()
SendNUIMessage({ type = "calls:changed" })
end)
RegisterNetEvent("sky_phone:banking:changed", function()
SendNUIMessage({ type = "banking:changed" })
RegisterNetEvent("sky_phone:banking:changed", function(data)
SendNUIMessage({ type = "banking:changed", data = data })
end)
RegisterNetEvent("sky_phone:billing:changed", function()
+10 -4
View File
@@ -79,8 +79,8 @@ local function record_transaction(identifier, kind, amount, label, reference)
]], { identifier, kind, amount, label or "", reference or "" })
end
local function notify_changed(source)
TriggerClientEvent("sky_phone:banking:changed", source)
local function notify_changed(source, data)
TriggerClientEvent("sky_phone:banking:changed", source, data)
end
local function online_source_for_phone(number)
@@ -149,9 +149,15 @@ Bridge.Callbacks.Register("sky_phone:banking:transfer", function(source, data)
end
local reference = ("phone-transfer-%s-%s"):format(os.time(), source)
local sender_name = player_name(source)
record_transaction(identifier, "transfer_out", amount, player_name(target), reference)
record_transaction(target_identifier, "transfer_in", amount, player_name(source), reference)
notify_changed(target)
record_transaction(target_identifier, "transfer_in", amount, sender_name, reference)
notify_changed(target, {
kind = "transfer_in",
amount = amount,
currency = Config.Banking.Currency,
sender = sender_name,
})
return { success = true, data = overview(source, identifier) }
end)