ENH - refine banking experience

This commit is contained in:
Type
2026-08-07 13:03:48 +02:00
parent 082e16f465
commit 0bde681655
10 changed files with 104 additions and 157 deletions
+17 -26
View File
@@ -1724,32 +1724,6 @@ button {
color: #fff;
}
.banking-profile {
position: relative;
display: grid;
width: 36px;
height: 36px;
flex: 0 0 auto;
place-items: center;
border-radius: 50%;
background: transparent;
color: #dce9ff;
}
.banking-profile .banking-spin {
position: absolute;
right: -2px;
bottom: -2px;
padding: 3px;
border-radius: 50%;
background: var(--bank-blue);
box-sizing: content-box;
}
.banking-spin { animation: banking-spin 0.75s linear infinite; }
@keyframes banking-spin { to { transform: rotate(360deg); } }
.banking-scroll {
position: absolute;
inset: 0;
@@ -1761,6 +1735,23 @@ button {
.banking-scroll::-webkit-scrollbar { display: none; }
.banking-pull-refresh {
position: absolute;
z-index: 4;
top: 78px;
right: 0;
left: 0;
display: flex;
justify-content: center;
opacity: 0;
pointer-events: none;
transition:
opacity 0.16s ease,
transform 0.16s ease;
}
.banking-pull-refresh.is-visible { opacity: 1; }
.banking-loading,
.banking-empty {
position: absolute;
+1 -1
View File
@@ -55,7 +55,7 @@ describe('banking store', () => {
const banking = useBankingStore()
banking.overview = overview
await banking.perform('withdraw', 50000)
await banking.perform('transfer', 50000, 17)
expect(banking.overview).toEqual(overview)
expect(banking.error).toBe('insufficient_funds')
-12
View File
@@ -216,8 +216,6 @@ const defaultLocales: LocaleTree = {
recentPeriod: 'in recent activity',
actions: 'Banking actions',
send: 'Send',
deposit: 'Deposit',
withdraw: 'Withdraw',
accounts: 'Accounts',
bankAccount: 'Bank account',
cash: 'Cash',
@@ -249,16 +247,6 @@ const defaultLocales: LocaleTree = {
body: 'Transfer money from your bank account to an online player.',
submit: 'Send transfer',
},
deposit: {
title: 'Deposit cash',
body: 'Move cash from your wallet into your bank account.',
submit: 'Deposit cash',
},
withdraw: {
title: 'Withdraw cash',
body: 'Move money from your bank account into your wallet.',
submit: 'Withdraw cash',
},
},
errors: {
invalid_request: 'Enter a valid whole amount and player ID.',
+1 -1
View File
@@ -22,4 +22,4 @@ export type BankingOverview = {
transactions: BankingTransaction[]
}
export type BankingAction = 'deposit' | 'withdraw' | 'transfer'
export type BankingAction = 'transfer'
+76 -38
View File
@@ -24,7 +24,6 @@ import {
CircleDollarSign,
House,
Landmark,
RefreshCw,
Send,
WalletCards,
X,
@@ -48,6 +47,14 @@ const action = ref<BankingAction | null>(null)
const amount = ref('')
const target = ref('')
const formError = ref('')
const bankingScroll = ref<HTMLElement | null>(null)
const isRefreshing = ref(false)
const pullDistance = ref(0)
const pullThreshold = 56
let pullStartY = 0
let isPulling = false
let wheelRefreshTimeout: ReturnType<typeof setTimeout> | undefined
const transactionIcons: Record<BankingTransactionKind, typeof Send> = {
deposit: ArrowDownLeft,
@@ -136,6 +143,55 @@ function closeAction(): void {
action.value = null
}
async function refresh(): Promise<void> {
if (isRefreshing.value) return
isRefreshing.value = true
pullDistance.value = pullThreshold
await banking.load()
isRefreshing.value = false
pullDistance.value = 0
}
function atTop(): boolean {
return (bankingScroll.value?.scrollTop ?? 0) <= 0
}
function startPull(event: TouchEvent): void {
if (!atTop() || isRefreshing.value) return
pullStartY = event.touches[0]?.clientY ?? 0
isPulling = true
}
function movePull(event: TouchEvent): void {
if (!isPulling || isRefreshing.value) return
const distance = (event.touches[0]?.clientY ?? pullStartY) - pullStartY
if (distance <= 0) {
pullDistance.value = 0
return
}
pullDistance.value = Math.min(pullThreshold + 20, distance * 0.45)
}
function finishPull(): void {
if (!isPulling && pullDistance.value === 0) return
isPulling = false
if (pullDistance.value >= pullThreshold) {
void refresh()
return
}
pullDistance.value = 0
}
function pullWithWheel(event: WheelEvent): void {
if (!atTop() || isRefreshing.value || event.deltaY >= 0) return
pullDistance.value = Math.min(
pullThreshold + 20,
pullDistance.value + Math.abs(event.deltaY) * 0.18,
)
if (wheelRefreshTimeout) clearTimeout(wheelRefreshTimeout)
wheelRefreshTimeout = setTimeout(finishPull, 130)
}
function errorMessage(code: string): string {
return phone.t(`Apps.banking.errors.${code}`) ===
`Apps.banking.errors.${code}`
@@ -179,20 +235,7 @@ onMounted(() => void banking.load())
class="banking-navbar"
:subtitle="phone.t('Apps.banking.welcome')"
:title="banking.overview?.playerName ?? phone.t('Common.loading')"
>
<template #right>
<button
type="button"
class="banking-profile"
:aria-label="phone.t('Apps.banking.refresh')"
:disabled="banking.isLoading"
@click="banking.load()"
>
<Landmark :size="20" />
<RefreshCw v-if="banking.isLoading" class="banking-spin" :size="10" />
</button>
</template>
</k-navbar>
/>
<div v-if="!banking.overview && banking.isLoading" class="banking-loading">
<k-preloader />
@@ -208,7 +251,23 @@ onMounted(() => void banking.load())
</k-button>
</div>
<div v-else class="banking-scroll">
<div
v-else
ref="bankingScroll"
class="banking-scroll"
@touchend="finishPull"
@touchmove.passive="movePull"
@touchstart.passive="startPull"
@wheel="pullWithWheel"
>
<div
class="banking-pull-refresh"
:class="{ 'is-visible': pullDistance > 0 }"
:style="{ transform: `translateY(${pullDistance - pullThreshold}px)` }"
aria-live="polite"
>
<k-preloader />
</div>
<template v-if="activeTab === 'home'">
<k-glass class="banking-balance">
<div class="banking-balance__label">
@@ -233,24 +292,6 @@ onMounted(() => void banking.load())
<b>{{ phone.t('Apps.banking.send') }}</b>
<ChevronRight :size="16" aria-hidden="true" />
</k-glass>
<k-glass
component="button"
type="button"
class="banking-action banking-action--secondary"
@click="openAction('deposit')"
>
<span class="banking-action__icon"><ArrowDownLeft :size="20" /></span>
<b>{{ phone.t('Apps.banking.deposit') }}</b>
</k-glass>
<k-glass
component="button"
type="button"
class="banking-action banking-action--secondary"
@click="openAction('withdraw')"
>
<span class="banking-action__icon"><ArrowUpRight :size="20" /></span>
<b>{{ phone.t('Apps.banking.withdraw') }}</b>
</k-glass>
</section>
<k-card class="banking-card banking-accounts">
@@ -404,15 +445,12 @@ onMounted(() => void banking.load())
<X :size="17" />
</button>
<span class="banking-modal__icon">
<Send v-if="action === 'transfer'" :size="23" />
<ArrowDownLeft v-else-if="action === 'deposit'" :size="23" />
<ArrowUpRight v-else :size="23" />
<Send :size="23" />
</span>
<h2>{{ phone.t(`Apps.banking.forms.${action}.title`) }}</h2>
<p>{{ phone.t(`Apps.banking.forms.${action}.body`) }}</p>
<k-list inset strong class="banking-form-list">
<k-list-input
v-if="action === 'transfer'"
:label="phone.t('Apps.banking.playerId')"
inputmode="numeric"
min="1"
+6 -26
View File
@@ -904,38 +904,18 @@ app.post('/api/:endpoint', (request, response) => {
response.json({ success: true, data: bankingOverview() })
return
}
if (
endpoint === 'banking:deposit' ||
endpoint === 'banking:withdraw' ||
endpoint === 'banking:transfer'
) {
if (endpoint === 'banking:transfer') {
const amount = Number(request.body.amount)
if (!Number.isSafeInteger(amount) || amount <= 0) {
response.json({ success: false, error: 'invalid_request' })
return
}
if (endpoint === 'banking:deposit' && mockCashBalance < amount) {
response.json({ success: false, error: 'insufficient_funds' })
return
}
if (endpoint !== 'banking:deposit' && mockBankBalance < amount) {
response.json({ success: false, error: 'insufficient_funds' })
return
}
const kind = endpoint === 'banking:deposit'
? 'deposit'
: endpoint === 'banking:withdraw'
? 'withdrawal'
: 'transfer_out'
if (kind === 'deposit') {
mockCashBalance -= amount
mockBankBalance += amount
} else if (kind === 'withdrawal') {
if (mockBankBalance < amount) {
response.json({ success: false, error: 'insufficient_funds' })
return
}
const kind = 'transfer_out'
mockBankBalance -= amount
mockCashBalance += amount
} else {
mockBankBalance -= amount
}
mockBankTransactions.unshift({
amount,
createdAt: Date.now(),
+1 -3
View File
@@ -119,7 +119,7 @@ Locales["en"] = {
},
banking = {
name = "Banking", welcome = "Welcome back", totalBalance = "Total Balance", recentPeriod = "in recent activity",
actions = "Banking actions", send = "Send", deposit = "Deposit", withdraw = "Withdraw",
actions = "Banking actions", send = "Send",
accounts = "Accounts", bankAccount = "Bank account", cash = "Cash",
latestTransactions = "Latest Transactions", allTransactions = "All Transactions", viewAll = "View all",
noTransactions = "Your banking activity will appear here.", home = "Home", activity = "Activity",
@@ -133,8 +133,6 @@ Locales["en"] = {
},
forms = {
transfer = { title = "Send money", body = "Transfer money from your bank account to an online player.", submit = "Send transfer" },
deposit = { title = "Deposit cash", body = "Move cash from your wallet into your bank account.", submit = "Deposit cash" },
withdraw = { title = "Withdraw cash", body = "Move money from your bank account into your wallet.", submit = "Withdraw cash" },
},
errors = {
invalid_request = "Enter a valid whole amount and player ID.", insufficient_funds = "There is not enough money in this account.",
-2
View File
@@ -71,8 +71,6 @@ local server_callbacks = {
"calls:decline",
"calls:hangup",
"banking:overview",
"banking:deposit",
"banking:withdraw",
"banking:transfer",
"messages:conversations",
"messages:thread",
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sky Phone</title>
<script type="module" crossorigin src="./assets/sky-index-8Bzj9L4a.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-C6M6KClq.css">
<script type="module" crossorigin src="./assets/sky-index-2HtRTJch.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-Bhv3YOTT.css">
</head>
<body>
<div id="app"></div>
-46
View File
@@ -91,52 +91,6 @@ Bridge.Callbacks.Register("sky_phone:banking:overview", function(source)
return { success = true, data = overview(source, identifier) }
end)
Bridge.Callbacks.Register("sky_phone:banking:deposit", function(source, data)
if not SkyPhone.AllowOperation(source, "banking_transaction", Config.Banking.ActionsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local identifier, error_response = require_banking_session(source)
if not identifier then
return error_response
end
local amount = valid_amount(data and data.amount)
if not amount then
return { success = false, error = "invalid_request" }
end
if not Bridge.Framework.RemoveMoney(source, "cash", amount) then
return { success = false, error = "insufficient_funds" }
end
if not Bridge.Framework.AddMoney(source, "bank", amount) then
Bridge.Framework.AddMoney(source, "cash", amount)
return { success = false, error = "transfer_failed" }
end
record_transaction(identifier, "deposit", amount, "", "cash-deposit")
return { success = true, data = overview(source, identifier) }
end)
Bridge.Callbacks.Register("sky_phone:banking:withdraw", function(source, data)
if not SkyPhone.AllowOperation(source, "banking_transaction", Config.Banking.ActionsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local identifier, error_response = require_banking_session(source)
if not identifier then
return error_response
end
local amount = valid_amount(data and data.amount)
if not amount then
return { success = false, error = "invalid_request" }
end
if not Bridge.Framework.RemoveMoney(source, "bank", amount) then
return { success = false, error = "insufficient_funds" }
end
if not Bridge.Framework.AddMoney(source, "cash", amount) then
Bridge.Framework.AddMoney(source, "bank", amount)
return { success = false, error = "transfer_failed" }
end
record_transaction(identifier, "withdrawal", amount, "", "cash-withdrawal")
return { success = true, data = overview(source, identifier) }
end)
Bridge.Callbacks.Register("sky_phone:banking:transfer", function(source, data)
if not SkyPhone.AllowOperation(source, "banking_transaction", Config.Banking.ActionsPerMinute, 60) then
return { success = false, error = "rate_limited" }