FIX - polish banking interactions and transaction details

This commit is contained in:
Leon.Schmidt
2026-08-17 00:25:14 +02:00
parent 181206f3cf
commit 335b9a98b5
12 changed files with 482 additions and 111 deletions
@@ -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