mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 09:18:57 +00:00
FIX - polish banking interactions and transaction details
This commit is contained in:
+32
-1
@@ -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) {
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user