mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +00:00
FIX - polish banking interactions and transaction details
This commit is contained in:
+32
-1
@@ -90,6 +90,7 @@ type AppMessage = {
|
|||||||
| PicstagramVerificationData
|
| PicstagramVerificationData
|
||||||
| PicstagramNotificationData
|
| PicstagramNotificationData
|
||||||
| FeatherNotificationData
|
| FeatherNotificationData
|
||||||
|
| BankingChangedData
|
||||||
| BillingNotificationData
|
| BillingNotificationData
|
||||||
| EasyShareEvent
|
| EasyShareEvent
|
||||||
| PhoneCall
|
| PhoneCall
|
||||||
@@ -238,6 +239,13 @@ type BillingNotificationData = {
|
|||||||
title?: string
|
title?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BankingChangedData = {
|
||||||
|
amount?: number
|
||||||
|
currency?: string
|
||||||
|
kind?: 'transfer_in'
|
||||||
|
sender?: string
|
||||||
|
}
|
||||||
|
|
||||||
type CrewLinkNotificationData = {
|
type CrewLinkNotificationData = {
|
||||||
actor?: string
|
actor?: string
|
||||||
device?: PhoneNotificationDevicePayload
|
device?: PhoneNotificationDevicePayload
|
||||||
@@ -973,7 +981,30 @@ function onMessage(event: MessageEvent<AppMessage>): void {
|
|||||||
} else if (event.data?.type === 'calls:changed') {
|
} else if (event.data?.type === 'calls:changed') {
|
||||||
void calls.loadRecents()
|
void calls.loadRecents()
|
||||||
} else if (event.data?.type === 'banking:changed') {
|
} 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') {
|
} else if (event.data?.type === 'billing:changed') {
|
||||||
void billing.loadOverview()
|
void billing.loadOverview()
|
||||||
} else if (event.data?.type === 'billing:new' && event.data.data) {
|
} else if (event.data?.type === 'billing:new' && event.data.data) {
|
||||||
|
|||||||
@@ -3720,6 +3720,9 @@ button {
|
|||||||
--bank-blue: #2d76ff;
|
--bank-blue: #2d76ff;
|
||||||
--bank-cyan: #57d5ff;
|
--bank-cyan: #57d5ff;
|
||||||
--bank-green: #4ee6a4;
|
--bank-green: #4ee6a4;
|
||||||
|
--banking-card-radius: 22px;
|
||||||
|
--banking-page-gutter: 16px;
|
||||||
|
--banking-section-gap: 16px;
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -3772,8 +3775,16 @@ button {
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 108px 17px 112px;
|
overscroll-behavior-y: contain;
|
||||||
|
padding: 108px var(--banking-page-gutter) 112px;
|
||||||
scrollbar-width: none;
|
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 {
|
.banking-scroll::-webkit-scrollbar {
|
||||||
@@ -3828,8 +3839,8 @@ button {
|
|||||||
.banking-balance {
|
.banking-balance {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 22px 20px 20px;
|
padding: 20px;
|
||||||
border-radius: 27px;
|
border-radius: var(--banking-card-radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
.banking-balance__label {
|
.banking-balance__label {
|
||||||
@@ -3871,8 +3882,8 @@ button {
|
|||||||
.banking-actions {
|
.banking-actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
gap: 9px;
|
gap: 10px;
|
||||||
margin: 13px 0 18px;
|
margin: 12px 0 var(--banking-section-gap);
|
||||||
}
|
}
|
||||||
|
|
||||||
.banking-action {
|
.banking-action {
|
||||||
@@ -3884,7 +3895,7 @@ button {
|
|||||||
min-height: 64px;
|
min-height: 64px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 11px 12px;
|
padding: 11px 12px;
|
||||||
border-radius: 19px;
|
border-radius: var(--banking-card-radius);
|
||||||
color: rgb(237 243 255 / 82%);
|
color: rgb(237 243 255 / 82%);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
@@ -3933,7 +3944,8 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.banking-card {
|
.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 {
|
.banking-transaction-card {
|
||||||
@@ -3944,7 +3956,7 @@ button {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 11px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.banking-section-title h2 {
|
.banking-section-title h2 {
|
||||||
@@ -3969,10 +3981,10 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.banking-transactions {
|
.banking-transactions {
|
||||||
padding: 0 3px;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.banking-transactions--all {
|
.banking-transactions--all {
|
||||||
margin-top: 18px;
|
margin-top: var(--banking-section-gap);
|
||||||
}
|
}
|
||||||
|
|
||||||
.banking-transaction-list b {
|
.banking-transaction-list b {
|
||||||
@@ -3989,7 +4001,7 @@ button {
|
|||||||
.banking-no-transactions {
|
.banking-no-transactions {
|
||||||
padding: 24px 12px;
|
padding: 24px 12px;
|
||||||
border: 1px solid rgb(255 255 255 / 8%);
|
border: 1px solid rgb(255 255 255 / 8%);
|
||||||
border-radius: 22px;
|
border-radius: var(--banking-card-radius);
|
||||||
background: rgb(255 255 255 / 3%);
|
background: rgb(255 255 255 / 3%);
|
||||||
color: rgb(213 225 248 / 47%);
|
color: rgb(213 225 248 / 47%);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -4000,7 +4012,7 @@ button {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 16px 0 20px;
|
padding: 12px 0 var(--banking-section-gap);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4098,20 +4110,7 @@ button {
|
|||||||
.banking-sheet__content {
|
.banking-sheet__content {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 22px 18px 18px;
|
padding: 8px 18px calc(var(--sky-safe-area-bottom) + 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%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.banking-modal__icon {
|
.banking-modal__icon {
|
||||||
@@ -4140,11 +4139,11 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.banking-form-list {
|
.banking-form-list {
|
||||||
margin: 12px 0 0 !important;
|
margin: var(--banking-section-gap) 0 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.banking-contact-picker {
|
.banking-contact-picker {
|
||||||
margin-top: 12px;
|
margin-top: var(--banking-section-gap);
|
||||||
}
|
}
|
||||||
|
|
||||||
.banking-contact-picker > span {
|
.banking-contact-picker > span {
|
||||||
@@ -4174,7 +4173,58 @@ button {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
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 {
|
.phone-app--light .banking-app {
|
||||||
@@ -4209,7 +4259,6 @@ button {
|
|||||||
.phone-app--light .banking-activity-hero small,
|
.phone-app--light .banking-activity-hero small,
|
||||||
.phone-app--light .banking-chart-legend,
|
.phone-app--light .banking-chart-legend,
|
||||||
.phone-app--light .banking-chart__day span,
|
.phone-app--light .banking-chart__day span,
|
||||||
.phone-app--light .banking-modal__close,
|
|
||||||
.phone-app--light .banking-sheet__content > p,
|
.phone-app--light .banking-sheet__content > p,
|
||||||
.phone-app--light .banking-contact-picker > span,
|
.phone-app--light .banking-contact-picker > span,
|
||||||
.phone-app--light .banking-contact-picker > p {
|
.phone-app--light .banking-contact-picker > p {
|
||||||
@@ -4217,8 +4266,7 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.phone-app--light .banking-balance__label small,
|
.phone-app--light .banking-balance__label small,
|
||||||
.phone-app--light .banking-no-transactions,
|
.phone-app--light .banking-no-transactions {
|
||||||
.phone-app--light .banking-modal__close {
|
|
||||||
border-color: var(--sky-hairline);
|
border-color: var(--sky-hairline);
|
||||||
background: rgb(255 255 255 / 52%);
|
background: rgb(255 255 255 / 52%);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ describe('banking store', () => {
|
|||||||
expect(banking.error).toBe('insufficient_funds')
|
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
|
let resolveOlder!: (response: NuiResponse<BankingOverview>) => void
|
||||||
const olderResponse = new Promise<NuiResponse<BankingOverview>>(
|
const olderResponse = new Promise<NuiResponse<BankingOverview>>(
|
||||||
(resolve) => {
|
(resolve) => {
|
||||||
@@ -75,7 +75,7 @@ describe('banking store', () => {
|
|||||||
const banking = useBankingStore()
|
const banking = useBankingStore()
|
||||||
|
|
||||||
const olderRequest = banking.load()
|
const olderRequest = banking.load()
|
||||||
await banking.load()
|
await banking.perform('transfer', 1787, '5551234567')
|
||||||
resolveOlder({ data: { ...overview, bank: 1 }, success: true })
|
resolveOlder({ data: { ...overview, bank: 1 }, success: true })
|
||||||
await olderRequest
|
await olderRequest
|
||||||
|
|
||||||
@@ -83,15 +83,62 @@ describe('banking store', () => {
|
|||||||
expect(banking.isLoading).toBe(false)
|
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()
|
const banking = useBankingStore()
|
||||||
banking.cooldownUntil = Date.now() + 10_000
|
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.load(true)).toBe(false)
|
||||||
expect(await banking.perform('transfer', 100, '5551234567')).toEqual({
|
expect(await banking.load()).toBe(true)
|
||||||
error: 'reload_cooldown',
|
expect((await banking.perform('transfer', 100, '5551234567')).success).toBe(
|
||||||
success: false,
|
true,
|
||||||
})
|
)
|
||||||
expect(mockNuiCall).not.toHaveBeenCalled()
|
expect(mockNuiCall).toHaveBeenCalledTimes(2)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import {
|
|||||||
RELOAD_COOLDOWN_ERROR,
|
RELOAD_COOLDOWN_ERROR,
|
||||||
} from '@/utils/reload-cooldown'
|
} from '@/utils/reload-cooldown'
|
||||||
|
|
||||||
|
let activeOverviewLoad: Promise<boolean> | null = null
|
||||||
|
let queuedOverviewLoad: Promise<boolean> | null = null
|
||||||
|
|
||||||
export const useBankingStore = defineStore('banking', {
|
export const useBankingStore = defineStore('banking', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
error: '',
|
error: '',
|
||||||
@@ -19,41 +22,59 @@ export const useBankingStore = defineStore('banking', {
|
|||||||
reloadAttempts: [] as number[],
|
reloadAttempts: [] as number[],
|
||||||
}),
|
}),
|
||||||
actions: {
|
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 (
|
if (
|
||||||
isReloadCooldownActive(this) ||
|
manualReload &&
|
||||||
(manualReload && !allowManualReload(this))
|
(isReloadCooldownActive(this) || !allowManualReload(this))
|
||||||
) {
|
) {
|
||||||
this.error = RELOAD_COOLDOWN_ERROR
|
this.error = RELOAD_COOLDOWN_ERROR
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
const generation = ++this.requestGeneration
|
const request = (async () => {
|
||||||
this.pendingRequests += 1
|
const generation = ++this.requestGeneration
|
||||||
this.isLoading = true
|
this.pendingRequests += 1
|
||||||
const response = await nuiCall<BankingOverview>('banking:overview').finally(
|
this.isLoading = true
|
||||||
() => {
|
const response = await nuiCall<BankingOverview>(
|
||||||
|
'banking:overview',
|
||||||
|
).finally(() => {
|
||||||
this.pendingRequests = Math.max(0, this.pendingRequests - 1)
|
this.pendingRequests = Math.max(0, this.pendingRequests - 1)
|
||||||
this.isLoading = this.pendingRequests > 0
|
this.isLoading = this.pendingRequests > 0
|
||||||
},
|
})
|
||||||
)
|
if (generation !== this.requestGeneration) return response.success
|
||||||
if (generation !== this.requestGeneration) return response.success
|
if (response.success && response.data) {
|
||||||
if (response.success && response.data) {
|
this.overview = response.data
|
||||||
this.overview = response.data
|
this.error = ''
|
||||||
this.error = ''
|
return true
|
||||||
return true
|
}
|
||||||
}
|
this.error = response.error ?? 'request_failed'
|
||||||
this.error = response.error ?? 'request_failed'
|
return false
|
||||||
return false
|
})()
|
||||||
|
const trackedRequest = request.finally(() => {
|
||||||
|
activeOverviewLoad = null
|
||||||
|
})
|
||||||
|
activeOverviewLoad = trackedRequest
|
||||||
|
return trackedRequest
|
||||||
},
|
},
|
||||||
async perform(
|
async perform(
|
||||||
action: BankingAction,
|
action: BankingAction,
|
||||||
amount: number,
|
amount: number,
|
||||||
phoneNumber?: string,
|
phoneNumber?: string,
|
||||||
): Promise<NuiResponse<BankingOverview>> {
|
): Promise<NuiResponse<BankingOverview>> {
|
||||||
if (isReloadCooldownActive(this)) {
|
|
||||||
this.error = RELOAD_COOLDOWN_ERROR
|
|
||||||
return { error: RELOAD_COOLDOWN_ERROR, success: false }
|
|
||||||
}
|
|
||||||
const generation = ++this.requestGeneration
|
const generation = ++this.requestGeneration
|
||||||
this.pendingRequests += 1
|
this.pendingRequests += 1
|
||||||
this.isLoading = true
|
this.isLoading = true
|
||||||
|
|||||||
@@ -2284,6 +2284,14 @@ const defaultLocales: LocaleTree = {
|
|||||||
noContacts: 'No contacts saved yet.',
|
noContacts: 'No contacts saved yet.',
|
||||||
amount: 'Amount',
|
amount: 'Amount',
|
||||||
amountPlaceholder: 'Enter an 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: {
|
transactions: {
|
||||||
deposit: 'Cash deposit',
|
deposit: 'Cash deposit',
|
||||||
withdrawal: 'Cash withdrawal',
|
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),
|
new URL('./BankingApp.vue', import.meta.url),
|
||||||
'utf8',
|
'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', () => {
|
describe('Banking app Sky UI migration', () => {
|
||||||
it('uses Sky UI instead of Konsta components', () => {
|
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('handleSheetKeydown')
|
||||||
expect(source).not.toContain('handleWindowKeydown')
|
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,
|
Landmark,
|
||||||
Send,
|
Send,
|
||||||
WalletCards,
|
WalletCards,
|
||||||
X,
|
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
@@ -23,6 +22,10 @@ import type {
|
|||||||
BankingTransactionKind,
|
BankingTransactionKind,
|
||||||
} from '@/types/banking'
|
} from '@/types/banking'
|
||||||
import type { PhoneContact } from '@/types/phone'
|
import type { PhoneContact } from '@/types/phone'
|
||||||
|
import {
|
||||||
|
normalizeBankingAmountInput,
|
||||||
|
parseBankingAmount,
|
||||||
|
} from '@/utils/bankingAmount'
|
||||||
import { handleEnterAction } from '@/utils/keyboard'
|
import { handleEnterAction } from '@/utils/keyboard'
|
||||||
import { formatPhoneNumber, normalizePhoneNumber } from '@/utils/phone'
|
import { formatPhoneNumber, normalizePhoneNumber } from '@/utils/phone'
|
||||||
import {
|
import {
|
||||||
@@ -50,6 +53,7 @@ const banking = useBankingStore()
|
|||||||
const calls = useCallsStore()
|
const calls = useCallsStore()
|
||||||
const activeTab = ref<BankingTab>('home')
|
const activeTab = ref<BankingTab>('home')
|
||||||
const action = ref<BankingAction | null>(null)
|
const action = ref<BankingAction | null>(null)
|
||||||
|
const selectedTransaction = ref<BankingTransaction | null>(null)
|
||||||
const amount = ref('')
|
const amount = ref('')
|
||||||
const target = ref('')
|
const target = ref('')
|
||||||
const formError = ref('')
|
const formError = ref('')
|
||||||
@@ -57,6 +61,9 @@ const bankingScroll = ref<HTMLElement | null>(null)
|
|||||||
const isRefreshing = ref(false)
|
const isRefreshing = ref(false)
|
||||||
const pullDistance = ref(0)
|
const pullDistance = ref(0)
|
||||||
const cooldownToastOpened = ref(false)
|
const cooldownToastOpened = ref(false)
|
||||||
|
const overlayOpened = computed(() =>
|
||||||
|
Boolean(action.value || selectedTransaction.value),
|
||||||
|
)
|
||||||
|
|
||||||
const pullThreshold = 56
|
const pullThreshold = 56
|
||||||
let pullStartY = 0
|
let pullStartY = 0
|
||||||
@@ -145,11 +152,15 @@ function transactionTitle(transaction: BankingTransaction): string {
|
|||||||
return phone.t(`Apps.banking.transactions.${transaction.kind}`)
|
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 {
|
function openAction(nextAction: BankingAction): void {
|
||||||
if (banking.cooldownUntil > Date.now()) {
|
selectedTransaction.value = null
|
||||||
banking.error = 'reload_cooldown'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
action.value = nextAction
|
action.value = nextAction
|
||||||
amount.value = ''
|
amount.value = ''
|
||||||
target.value = ''
|
target.value = ''
|
||||||
@@ -158,8 +169,14 @@ function openAction(nextAction: BankingAction): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeAction(): void {
|
function closeAction(): void {
|
||||||
if (banking.isLoading) return
|
if (action.value && banking.isLoading) return
|
||||||
action.value = null
|
action.value = null
|
||||||
|
selectedTransaction.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTransaction(transaction: BankingTransaction): void {
|
||||||
|
action.value = null
|
||||||
|
selectedTransaction.value = transaction
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateTarget(event: Event): void {
|
function updateTarget(event: Event): void {
|
||||||
@@ -186,7 +203,11 @@ function updateAmount(event: Event): void {
|
|||||||
console.error('[banking] Amount input emitted without an input target.')
|
console.error('[banking] Amount input emitted without an input target.')
|
||||||
return
|
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 = ''
|
formError.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,14 +269,10 @@ function errorMessage(code: string): string {
|
|||||||
|
|
||||||
async function submitAction(): Promise<void> {
|
async function submitAction(): Promise<void> {
|
||||||
if (!action.value) return
|
if (!action.value) return
|
||||||
const parsedAmount = Number(amount.value)
|
const parsedAmount = parseBankingAmount(amount.value)
|
||||||
const phoneNumber =
|
const phoneNumber =
|
||||||
action.value === 'transfer' ? normalizePhoneNumber(target.value) : undefined
|
action.value === 'transfer' ? normalizePhoneNumber(target.value) : undefined
|
||||||
if (
|
if (parsedAmount === null || (action.value === 'transfer' && !phoneNumber)) {
|
||||||
!Number.isSafeInteger(parsedAmount) ||
|
|
||||||
parsedAmount <= 0 ||
|
|
||||||
(action.value === 'transfer' && !phoneNumber)
|
|
||||||
) {
|
|
||||||
formError.value = phone.t('Apps.banking.errors.invalid_request')
|
formError.value = phone.t('Apps.banking.errors.invalid_request')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -310,8 +327,8 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<SkyNavbar
|
<SkyNavbar
|
||||||
class="banking-navbar"
|
class="banking-navbar"
|
||||||
:aria-hidden="Boolean(action)"
|
:aria-hidden="overlayOpened"
|
||||||
:inert="Boolean(action)"
|
:inert="overlayOpened"
|
||||||
:subtitle="phone.t('Apps.banking.welcome')"
|
:subtitle="phone.t('Apps.banking.welcome')"
|
||||||
:title="banking.overview?.playerName ?? phone.t('Common.loading')"
|
:title="banking.overview?.playerName ?? phone.t('Common.loading')"
|
||||||
/>
|
/>
|
||||||
@@ -319,8 +336,8 @@ onBeforeUnmount(() => {
|
|||||||
<div
|
<div
|
||||||
v-if="!banking.overview && banking.isLoading"
|
v-if="!banking.overview && banking.isLoading"
|
||||||
class="banking-loading"
|
class="banking-loading"
|
||||||
:aria-hidden="Boolean(action)"
|
:aria-hidden="overlayOpened"
|
||||||
:inert="Boolean(action)"
|
:inert="overlayOpened"
|
||||||
>
|
>
|
||||||
<SkySpinner :label="phone.t('Common.loading')" />
|
<SkySpinner :label="phone.t('Common.loading')" />
|
||||||
<span>{{ phone.t('Common.loading') }}</span>
|
<span>{{ phone.t('Common.loading') }}</span>
|
||||||
@@ -329,9 +346,9 @@ onBeforeUnmount(() => {
|
|||||||
<SkyEmptyState
|
<SkyEmptyState
|
||||||
v-else-if="!banking.overview"
|
v-else-if="!banking.overview"
|
||||||
class="banking-empty"
|
class="banking-empty"
|
||||||
:aria-hidden="Boolean(action)"
|
:aria-hidden="overlayOpened"
|
||||||
:body="errorMessage(banking.error)"
|
:body="errorMessage(banking.error)"
|
||||||
:inert="Boolean(action)"
|
:inert="overlayOpened"
|
||||||
:title="phone.t('Apps.banking.unavailable')"
|
:title="phone.t('Apps.banking.unavailable')"
|
||||||
>
|
>
|
||||||
<template #icon><Landmark :size="34" /></template>
|
<template #icon><Landmark :size="34" /></template>
|
||||||
@@ -346,8 +363,9 @@ onBeforeUnmount(() => {
|
|||||||
v-else
|
v-else
|
||||||
ref="bankingScroll"
|
ref="bankingScroll"
|
||||||
class="banking-scroll"
|
class="banking-scroll"
|
||||||
:aria-hidden="Boolean(action)"
|
:class="{ 'is-locked': overlayOpened }"
|
||||||
:inert="Boolean(action)"
|
:aria-hidden="overlayOpened"
|
||||||
|
:inert="overlayOpened"
|
||||||
@touchend="finishPull"
|
@touchend="finishPull"
|
||||||
@touchmove.passive="movePull"
|
@touchmove.passive="movePull"
|
||||||
@touchstart.passive="startPull"
|
@touchstart.passive="startPull"
|
||||||
@@ -418,7 +436,7 @@ onBeforeUnmount(() => {
|
|||||||
<SkyLink
|
<SkyLink
|
||||||
component="button"
|
component="button"
|
||||||
type="button"
|
type="button"
|
||||||
@click="activeTab = 'activity'"
|
@click="selectTab('activity')"
|
||||||
>
|
>
|
||||||
{{ phone.t('Apps.banking.viewAll') }}
|
{{ phone.t('Apps.banking.viewAll') }}
|
||||||
</SkyLink>
|
</SkyLink>
|
||||||
@@ -434,6 +452,9 @@ onBeforeUnmount(() => {
|
|||||||
:key="transaction.id"
|
:key="transaction.id"
|
||||||
:subtitle="formatDate(transaction.createdAt)"
|
:subtitle="formatDate(transaction.createdAt)"
|
||||||
:title="transactionTitle(transaction)"
|
:title="transactionTitle(transaction)"
|
||||||
|
link
|
||||||
|
link-component="button"
|
||||||
|
@click="openTransaction(transaction)"
|
||||||
>
|
>
|
||||||
<template #media>
|
<template #media>
|
||||||
<component
|
<component
|
||||||
@@ -519,6 +540,9 @@ onBeforeUnmount(() => {
|
|||||||
:key="transaction.id"
|
:key="transaction.id"
|
||||||
:subtitle="formatDate(transaction.createdAt)"
|
:subtitle="formatDate(transaction.createdAt)"
|
||||||
:title="transactionTitle(transaction)"
|
:title="transactionTitle(transaction)"
|
||||||
|
link
|
||||||
|
link-component="button"
|
||||||
|
@click="openTransaction(transaction)"
|
||||||
>
|
>
|
||||||
<template #media>
|
<template #media>
|
||||||
<component
|
<component
|
||||||
@@ -550,43 +574,44 @@ onBeforeUnmount(() => {
|
|||||||
icons
|
icons
|
||||||
labels
|
labels
|
||||||
class="banking-tabbar"
|
class="banking-tabbar"
|
||||||
:aria-hidden="Boolean(action)"
|
:aria-hidden="overlayOpened"
|
||||||
:inert="Boolean(action)"
|
:inert="overlayOpened"
|
||||||
:label="phone.t('Apps.banking.navigation')"
|
:label="phone.t('Apps.banking.navigation')"
|
||||||
>
|
>
|
||||||
<SkyTabButton
|
<SkyTabButton
|
||||||
:active="activeTab === 'home'"
|
:active="activeTab === 'home'"
|
||||||
:label="phone.t('Apps.banking.home')"
|
:label="phone.t('Apps.banking.home')"
|
||||||
@click="activeTab = 'home'"
|
@click="selectTab('home')"
|
||||||
>
|
>
|
||||||
<template #icon><House :size="25" /></template>
|
<template #icon><House :size="25" /></template>
|
||||||
</SkyTabButton>
|
</SkyTabButton>
|
||||||
<SkyTabButton
|
<SkyTabButton
|
||||||
:active="activeTab === 'activity'"
|
:active="activeTab === 'activity'"
|
||||||
:label="phone.t('Apps.banking.activity')"
|
:label="phone.t('Apps.banking.activity')"
|
||||||
@click="activeTab = 'activity'"
|
@click="selectTab('activity')"
|
||||||
>
|
>
|
||||||
<template #icon><BarChart3 :size="25" /></template>
|
<template #icon><BarChart3 :size="25" /></template>
|
||||||
</SkyTabButton>
|
</SkyTabButton>
|
||||||
</SkyTabBar>
|
</SkyTabBar>
|
||||||
|
|
||||||
<SkySheet
|
<SkySheet
|
||||||
:opened="Boolean(action)"
|
:opened="overlayOpened"
|
||||||
:ariaLabelledby="action ? `banking-${action}-title` : undefined"
|
:ariaLabelledby="
|
||||||
|
action
|
||||||
|
? `banking-${action}-title`
|
||||||
|
: selectedTransaction
|
||||||
|
? 'banking-transaction-detail-title'
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
swipe-to-close
|
||||||
|
grabber-clickable
|
||||||
|
:grabber-label="phone.t('Common.close')"
|
||||||
@backdropclick="closeAction"
|
@backdropclick="closeAction"
|
||||||
@escape="closeAction"
|
@escape="closeAction"
|
||||||
|
@grabberclick="closeAction"
|
||||||
|
@swipeclose="closeAction"
|
||||||
>
|
>
|
||||||
<section v-if="action" class="banking-sheet__content">
|
<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">
|
<span class="banking-modal__icon">
|
||||||
<Send :size="23" />
|
<Send :size="23" />
|
||||||
</span>
|
</span>
|
||||||
@@ -606,14 +631,16 @@ onBeforeUnmount(() => {
|
|||||||
@input="updateTarget"
|
@input="updateTarget"
|
||||||
/>
|
/>
|
||||||
<SkyField
|
<SkyField
|
||||||
|
autocomplete="off"
|
||||||
|
class="banking-amount-field"
|
||||||
:label="phone.t('Apps.banking.amount')"
|
:label="phone.t('Apps.banking.amount')"
|
||||||
:error="formError || false"
|
:error="formError || false"
|
||||||
input-id="banking-transfer-amount"
|
input-id="banking-transfer-amount"
|
||||||
inputmode="numeric"
|
inputmode="decimal"
|
||||||
min="1"
|
|
||||||
outline
|
outline
|
||||||
|
pattern="[0-9]+([,][0-9]{0,2})?"
|
||||||
:placeholder="phone.t('Apps.banking.amountPlaceholder')"
|
:placeholder="phone.t('Apps.banking.amountPlaceholder')"
|
||||||
type="number"
|
type="text"
|
||||||
:value="amount"
|
:value="amount"
|
||||||
@input="updateAmount"
|
@input="updateAmount"
|
||||||
@keydown.enter="handleEnterAction($event, submitAction)"
|
@keydown.enter="handleEnterAction($event, submitAction)"
|
||||||
@@ -652,6 +679,64 @@ onBeforeUnmount(() => {
|
|||||||
</template>
|
</template>
|
||||||
</SkyButton>
|
</SkyButton>
|
||||||
</section>
|
</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>
|
</SkySheet>
|
||||||
|
|
||||||
<SkyToast
|
<SkyToast
|
||||||
|
|||||||
@@ -1040,6 +1040,12 @@ Locales["en"] = {
|
|||||||
recipientPhone = "Phone number", recipientPhonePlaceholder = "Enter the recipient's number",
|
recipientPhone = "Phone number", recipientPhonePlaceholder = "Enter the recipient's number",
|
||||||
chooseContact = "Choose from contacts", noContacts = "No contacts saved yet.",
|
chooseContact = "Choose from contacts", noContacts = "No contacts saved yet.",
|
||||||
amount = "Amount", amountPlaceholder = "Enter an amount",
|
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 = {
|
transactions = {
|
||||||
deposit = "Cash deposit", withdrawal = "Cash withdrawal",
|
deposit = "Cash deposit", withdrawal = "Cash withdrawal",
|
||||||
transfer_in = "Incoming transfer", transfer_out = "Outgoing transfer",
|
transfer_in = "Incoming transfer", transfer_out = "Outgoing transfer",
|
||||||
|
|||||||
@@ -894,8 +894,8 @@ RegisterNetEvent("sky_phone:calls:changed", function()
|
|||||||
SendNUIMessage({ type = "calls:changed" })
|
SendNUIMessage({ type = "calls:changed" })
|
||||||
end)
|
end)
|
||||||
|
|
||||||
RegisterNetEvent("sky_phone:banking:changed", function()
|
RegisterNetEvent("sky_phone:banking:changed", function(data)
|
||||||
SendNUIMessage({ type = "banking:changed" })
|
SendNUIMessage({ type = "banking:changed", data = data })
|
||||||
end)
|
end)
|
||||||
|
|
||||||
RegisterNetEvent("sky_phone:billing:changed", function()
|
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 "" })
|
]], { identifier, kind, amount, label or "", reference or "" })
|
||||||
end
|
end
|
||||||
|
|
||||||
local function notify_changed(source)
|
local function notify_changed(source, data)
|
||||||
TriggerClientEvent("sky_phone:banking:changed", source)
|
TriggerClientEvent("sky_phone:banking:changed", source, data)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function online_source_for_phone(number)
|
local function online_source_for_phone(number)
|
||||||
@@ -149,9 +149,15 @@ Bridge.Callbacks.Register("sky_phone:banking:transfer", function(source, data)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local reference = ("phone-transfer-%s-%s"):format(os.time(), source)
|
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(identifier, "transfer_out", amount, player_name(target), reference)
|
||||||
record_transaction(target_identifier, "transfer_in", amount, player_name(source), reference)
|
record_transaction(target_identifier, "transfer_in", amount, sender_name, reference)
|
||||||
notify_changed(target)
|
notify_changed(target, {
|
||||||
|
kind = "transfer_in",
|
||||||
|
amount = amount,
|
||||||
|
currency = Config.Banking.Currency,
|
||||||
|
sender = sender_name,
|
||||||
|
})
|
||||||
return { success = true, data = overview(source, identifier) }
|
return { success = true, data = overview(source, identifier) }
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user