Merge remote-tracking branch 'origin/dev' into benja/app-overhaul

This commit is contained in:
smx.pusha
2026-08-07 16:00:29 +02:00
27 changed files with 1572 additions and 6 deletions
+1
View File
@@ -1 +1,2 @@
/sky_phone/source/html
.pnpm-store/
+2
View File
@@ -1,4 +1,6 @@
node_modules/
dist/
.vite/
*.local
dev-server*.log
+1 -1
View File
@@ -5,7 +5,7 @@ import pluginVue from 'eslint-plugin-vue'
export default defineConfigWithVueTs(
{ files: ['**/*.{ts,mts,tsx,vue}'], name: 'app/files-to-lint' },
globalIgnores(['**/dist/**', 'testserver/**', 'build.cjs']),
globalIgnores(['**/.vite/**', '**/dist/**', 'testserver/**', 'build.cjs']),
pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
skipFormatting,
+4
View File
@@ -23,6 +23,7 @@ import { PHONE_FRAME_IMAGES } from '@/config/appearance'
import { useClockStore } from '@/stores/clock'
import { useGamesStore } from '@/features/games/store'
import { useCallsStore } from '@/stores/calls'
import { useBankingStore } from '@/stores/banking'
import { useAccountStore } from '@/stores/account'
import { useMailStore } from '@/stores/mail'
import { useMessagesStore } from '@/stores/messages'
@@ -120,6 +121,7 @@ const account = useAccountStore()
const clock = useClockStore()
const games = useGamesStore()
const calls = useCallsStore()
const banking = useBankingStore()
const mail = useMailStore()
const messages = useMessagesStore()
const darkchat = useDarkChatStore()
@@ -357,6 +359,8 @@ 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()
} else if (
(event.data?.type === 'call:incoming' ||
event.data?.type === 'call:state') &&
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<defs>
<linearGradient id="bg" x1="16" y1="8" x2="112" y2="120" gradientUnits="userSpaceOnUse">
<stop stop-color="#3b82f6"/>
<stop offset="0.52" stop-color="#1857d9"/>
<stop offset="1" stop-color="#081b55"/>
</linearGradient>
<linearGradient id="shine" x1="26" y1="18" x2="99" y2="106" gradientUnits="userSpaceOnUse">
<stop stop-color="#fff" stop-opacity=".95"/>
<stop offset="1" stop-color="#cfe1ff" stop-opacity=".82"/>
</linearGradient>
</defs>
<rect width="128" height="128" rx="29" fill="url(#bg)"/>
<circle cx="103" cy="22" r="35" fill="#6aa4ff" opacity=".2"/>
<path d="M26 52 64 29l38 23v8H26v-8Zm7 15h10v25H33V67Zm21 0h10v25H54V67Zm21 0h10v25H75V67Zm21 0h-1v25h-9V67h10ZM25 98h78v10H25V98Z" fill="url(#shine)"/>
</svg>

After

Width:  |  Height:  |  Size: 846 B

+402 -1
View File
@@ -1663,15 +1663,416 @@ button {
linear-gradient(165deg, #7897b5 0%, #536d8b 48%, #283c58 100%);
}
.weather-navbar {
--k-safe-area-top: 46px;
--tw-bg-opacity: 0;
position: absolute;
z-index: 3;
top: 46px;
top: 0;
right: 0;
left: 0;
background: transparent !important;
color: #fff;
}
/* Banking */
.banking-app {
--bank-blue: #2d76ff;
--bank-cyan: #57d5ff;
--bank-green: #4ee6a4;
position: relative;
height: 100%;
overflow: hidden;
background:
radial-gradient(circle at 92% 4%, rgb(45 118 255 / 28%), transparent 32%),
linear-gradient(160deg, #0c1834 0%, #080d1d 44%, #050710 100%) !important;
color: #fff;
isolation: isolate;
}
.banking-app::before {
position: absolute;
z-index: -1;
inset: 0;
background-image:
linear-gradient(rgb(255 255 255 / 2.4%) 1px, transparent 1px),
linear-gradient(90deg, rgb(255 255 255 / 2.4%) 1px, transparent 1px);
background-size: 42px 42px;
content: '';
mask-image: linear-gradient(to bottom, #000, transparent 66%);
}
.banking-app__aurora {
position: absolute;
z-index: -1;
width: 240px;
height: 180px;
top: 160px;
left: -145px;
border-radius: 50%;
background: #215dea;
filter: blur(78px);
opacity: 0.2;
}
.banking-navbar {
--k-safe-area-top: 46px;
position: absolute;
z-index: 5;
top: 0;
right: 0;
left: 0;
color: #fff;
}
.banking-scroll {
position: absolute;
inset: 0;
overflow-x: hidden;
overflow-y: auto;
padding: 108px 17px 112px;
scrollbar-width: none;
}
.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;
inset: 100px 24px 82px;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 10px;
color: rgb(226 235 255 / 72%);
text-align: center;
}
.banking-empty p {
margin: 0 0 8px;
color: rgb(208 220 248 / 58%);
font-size: 12px;
}
.banking-balance {
padding: 22px 20px 20px;
border-radius: 27px;
}
.banking-balance__label {
display: flex;
align-items: center;
justify-content: space-between;
color: rgb(226 235 255 / 67%);
font-size: 12px;
}
.banking-balance__label small {
padding: 4px 8px;
border: 1px solid rgb(255 255 255 / 10%);
border-radius: 99px;
background: rgb(0 0 0 / 12%);
font-size: 9px;
}
.banking-balance > strong {
display: block;
margin-top: 6px;
font-size: 36px;
font-weight: 600;
letter-spacing: -0.055em;
}
.banking-balance__trend {
margin-top: 8px;
color: rgb(208 220 248 / 57%);
font-size: 10px;
}
.banking-balance__trend span {
margin-right: 4px;
color: var(--bank-green);
font-weight: 650;
}
.banking-actions {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 9px;
margin: 13px 0 18px;
}
.banking-action {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 64px;
padding: 11px 12px;
border-radius: 19px;
color: rgb(237 243 255 / 82%);
font-size: 10px;
}
.banking-action__icon {
display: grid;
width: 34px;
height: 34px;
flex: 0 0 auto;
place-items: center;
border-radius: 11px;
background: linear-gradient(145deg, rgb(60 129 255 / 34%), rgb(25 54 115 / 25%));
color: #8fb8ff;
}
.banking-action b {
font-size: 11px;
font-weight: 610;
}
.banking-action--primary {
grid-column: 1 / -1;
justify-content: flex-start;
min-height: 58px;
padding: 10px 13px;
color: #fff;
}
.banking-action--primary .banking-action__icon {
background: rgb(255 255 255 / 15%);
color: #fff;
}
.banking-action--primary svg:last-child {
margin-left: auto;
opacity: 0.78;
}
.banking-action--secondary {
justify-content: flex-start;
}
.banking-card {
margin: 0 0 18px !important;
}
.banking-transaction-card {
overflow: hidden;
}
.banking-section-title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 11px;
}
.banking-section-title h2 {
margin: 0;
font-size: 15px;
font-weight: 630;
letter-spacing: -0.025em;
}
.banking-section-title > svg { color: rgb(220 232 255 / 43%); }
.banking-section-title button {
color: #7ca8ff;
font-size: 10px;
}
.banking-account-list,
.banking-transaction-list {
margin: 0 !important;
}
.banking-transactions { padding: 0 3px; }
.banking-transactions--all { margin-top: 18px; }
.banking-transaction-list b {
flex: 0 0 auto;
color: #f2a0a8;
font-size: 11px;
font-weight: 620;
}
.banking-transaction-list b.is-incoming { color: var(--bank-green); }
.banking-no-transactions {
padding: 24px 12px;
border: 1px solid rgb(255 255 255 / 8%);
border-radius: 22px;
background: rgb(255 255 255 / 3%);
color: rgb(213 225 248 / 47%);
font-size: 11px;
text-align: center;
}
.banking-activity-hero {
display: flex;
align-items: center;
flex-direction: column;
padding: 16px 0 20px;
text-align: center;
}
.banking-activity-hero span {
color: rgb(214 225 250 / 55%);
font-size: 11px;
}
.banking-activity-hero strong {
margin: 5px 0;
color: var(--bank-green);
font-size: 31px;
font-weight: 590;
letter-spacing: -0.045em;
}
.banking-activity-hero small {
color: rgb(214 225 250 / 38%);
font-size: 9px;
}
.banking-chart-card { padding: 18px 14px 15px !important; }
.banking-chart-legend {
display: flex;
justify-content: flex-end;
gap: 12px;
color: rgb(218 229 251 / 50%);
font-size: 8px;
}
.banking-chart-legend span { display: flex; align-items: center; gap: 4px; }
.banking-chart-legend i {
width: 6px;
height: 6px;
border-radius: 50%;
background: #58647f;
}
.banking-chart-legend i.is-incoming { background: var(--bank-blue); }
.banking-chart {
display: grid;
height: 145px;
grid-template-columns: repeat(7, 1fr);
gap: 5px;
margin-top: 14px;
}
.banking-chart__day {
display: flex;
align-items: center;
justify-content: flex-end;
flex-direction: column;
gap: 7px;
}
.banking-chart__day > div {
display: flex;
align-items: flex-end;
justify-content: center;
width: 100%;
height: 116px;
gap: 3px;
}
.banking-chart__day i {
width: 7px;
min-height: 5px;
border-radius: 6px;
background: linear-gradient(to top, #343e57, #65718d);
transition: height 0.35s ease;
}
.banking-chart__day i.is-incoming {
background: linear-gradient(to top, #1d55d8, #3d87ff);
box-shadow: 0 0 14px rgb(45 118 255 / 33%);
}
.banking-chart__day span {
color: rgb(217 227 249 / 45%);
font-size: 8px;
}
.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%);
}
.banking-modal__icon {
display: grid;
width: 45px;
height: 45px;
place-items: center;
border-radius: 15px;
background: linear-gradient(145deg, #3c83ff, #1849b6);
box-shadow: 0 12px 28px rgb(22 76 196 / 36%), inset 0 1px rgb(255 255 255 / 28%);
}
.banking-sheet__content h2 {
margin: 14px 0 4px;
font-size: 20px;
letter-spacing: -0.035em;
}
.banking-sheet__content > p {
margin: 0 0 15px;
color: rgb(213 225 248 / 52%);
font-size: 10px;
line-height: 1.45;
}
.banking-form-list { margin: 12px 0 0 !important; }
.banking-sheet__content .banking-form-error {
margin: 10px 3px 0;
color: #ff8b98;
font-size: 9px;
}
.banking-sheet__content > button:last-child { margin-top: 15px; }
.banking-sheet__content > button:last-child > span {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.weather-navbar::after {
opacity: 0;
}
+5
View File
@@ -30,6 +30,11 @@ describe('app registry', () => {
labelKey: 'Apps.weather.name',
route: '/apps/weather',
})
expect(PHONE_APPS.find((app) => app.id === 'banking')).toMatchObject({
gridOrder: 5,
labelKey: 'Apps.banking.name',
route: '/apps/banking',
})
expect(PHONE_APPS.find((app) => app.id === 'calendar')).toMatchObject({
gridOrder: 21,
labelKey: 'Apps.calendar.name',
+15
View File
@@ -19,6 +19,7 @@ import {
Settings,
ShoppingBag,
CloudSun,
Landmark,
Wind,
Tag,
MapPinHouse,
@@ -46,6 +47,7 @@ import towerStackIcon from '@/assets/img/app-icons/tower-stack.webp'
import skyFlappyIcon from '@/assets/img/app-icons/sky-flappy.webp'
import neonDropIcon from '@/assets/img/app-icons/neon-drop.webp'
import weatherIcon from '@/assets/img/app-icons/weather.webp'
import bankingIcon from '@/assets/img/app-icons/banking.svg'
import citymarktIcon from '@/assets/img/app-icons/citymarkt.webp'
import localPagesIcon from '@/assets/img/app-icons/local-pages.webp'
import type {
@@ -141,6 +143,19 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
},
{
category: 'social',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/BankingApp.vue')),
),
dockOrder: null,
gridOrder: 5,
icon: markRaw(Landmark),
iconClass: 'app-icon--banking',
iconImage: bankingIcon,
id: 'banking',
labelKey: 'Apps.banking.name',
route: '/apps/banking',
},
{
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/MailApp.vue')),
),
+63
View File
@@ -0,0 +1,63 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useBankingStore } from '@/stores/banking'
import type { BankingOverview } from '@/types/banking'
import { nuiCall } from '@/utils/nui'
vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
const mockNuiCall = vi.mocked(nuiCall)
const overview: BankingOverview = {
bank: 24787,
cash: 2350,
currency: '$',
playerId: 42,
playerName: 'Alex Morgan',
transactions: [],
}
describe('banking store', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockNuiCall.mockReset()
})
it('loads the server-authoritative banking overview', async () => {
mockNuiCall.mockResolvedValueOnce({ data: overview, success: true })
const banking = useBankingStore()
expect(await banking.load()).toBe(true)
expect(banking.overview).toEqual(overview)
expect(mockNuiCall).toHaveBeenCalledWith('banking:overview')
})
it('updates balances after a successful transfer', async () => {
const updated = { ...overview, bank: 23787 }
mockNuiCall.mockResolvedValueOnce({ data: updated, success: true })
const banking = useBankingStore()
const response = await banking.perform('transfer', 1000, 17)
expect(response.success).toBe(true)
expect(banking.overview?.bank).toBe(23787)
expect(mockNuiCall).toHaveBeenCalledWith('banking:transfer', {
amount: 1000,
target: 17,
})
})
it('keeps the previous overview and exposes server errors', async () => {
mockNuiCall.mockResolvedValueOnce({
error: 'insufficient_funds',
success: false,
})
const banking = useBankingStore()
banking.overview = overview
await banking.perform('transfer', 50000, 17)
expect(banking.overview).toEqual(overview)
expect(banking.error).toBe('insufficient_funds')
})
})
+45
View File
@@ -0,0 +1,45 @@
import { defineStore } from 'pinia'
import type { BankingAction, BankingOverview } from '@/types/banking'
import { nuiCall, type NuiResponse } from '@/utils/nui'
export const useBankingStore = defineStore('banking', {
state: () => ({
error: '',
isLoading: false,
overview: null as BankingOverview | null,
}),
actions: {
async load(): Promise<boolean> {
this.isLoading = true
const response = await nuiCall<BankingOverview>('banking:overview')
this.isLoading = false
if (response.success && response.data) {
this.overview = response.data
this.error = ''
return true
}
this.error = response.error ?? 'request_failed'
return false
},
async perform(
action: BankingAction,
amount: number,
target?: number,
): Promise<NuiResponse<BankingOverview>> {
this.isLoading = true
const response = await nuiCall<BankingOverview>(`banking:${action}`, {
amount,
...(target === undefined ? {} : { target }),
})
this.isLoading = false
if (response.success && response.data) {
this.overview = response.data
this.error = ''
} else {
this.error = response.error ?? 'request_failed'
}
return response
},
},
})
+52
View File
@@ -209,6 +209,58 @@ const defaultLocales: LocaleTree = {
default: 'The phone request failed.',
},
},
banking: {
name: 'Banking',
welcome: 'Welcome back',
totalBalance: 'Total Balance',
recentPeriod: 'in recent activity',
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',
chartDaySummary: '{day}: {incoming} incoming, {outgoing} outgoing',
navigation: 'Banking navigation',
incoming: 'Incoming',
outgoing: 'Outgoing',
refresh: 'Refresh banking data',
unavailable: 'Banking unavailable',
tryAgain: 'Try Again',
playerId: 'Player ID',
playerIdPlaceholder: 'Enter the recipient ID',
amount: 'Amount',
amountPlaceholder: 'Enter an amount',
transactions: {
deposit: 'Cash deposit',
withdrawal: 'Cash withdrawal',
transfer_in: 'Incoming transfer',
transfer_out: 'Outgoing transfer',
},
forms: {
transfer: {
title: 'Send money',
body: 'Transfer money from your bank account to an online player.',
submit: 'Send transfer',
},
},
errors: {
invalid_request: 'Enter a valid whole amount and player ID.',
insufficient_funds: 'There is not enough money in this account.',
target_not_found: 'The recipient is not online.',
self_transfer: 'You cannot send money to yourself.',
rate_limited: 'Please wait before making another transaction.',
transfer_failed: 'The transfer could not be completed.',
banking_unavailable: 'Banking is currently unavailable.',
request_failed: 'The banking request failed.',
default: 'The banking request failed.',
},
},
calculator: { name: 'Calculator' },
snake: {
name: 'Snake',
+1
View File
@@ -9,6 +9,7 @@ export type PhoneAppId =
| 'clock'
| 'calendar'
| 'weather'
| 'banking'
| 'mail'
| 'map'
| 'notes'
+25
View File
@@ -0,0 +1,25 @@
export type BankingTransactionKind =
| 'deposit'
| 'withdrawal'
| 'transfer_in'
| 'transfer_out'
export type BankingTransaction = {
amount: number
createdAt: number
id: number
kind: BankingTransactionKind
label: string
reference: string
}
export type BankingOverview = {
bank: number
cash: number
currency: string
playerId: number
playerName: string
transactions: BankingTransaction[]
}
export type BankingAction = 'transfer'
+1
View File
@@ -66,6 +66,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
clock: { enabled: true, sounds: true },
calendar: { enabled: true, sounds: true },
weather: { enabled: true, sounds: true },
banking: { enabled: true, sounds: true },
mail: { enabled: true, sounds: true },
map: { enabled: true, sounds: true },
notes: { enabled: true, sounds: true },
+608
View File
@@ -0,0 +1,608 @@
<script setup lang="ts">
import {
kButton,
kCard,
kGlass,
kIcon,
kLink,
kList,
kListInput,
kListItem,
kNavbar,
kPage,
kPreloader,
kSheet,
kTabbar,
kTabbarLink,
kToolbarPane,
} from 'konsta/vue'
import {
ArrowDownLeft,
ArrowRight,
ArrowUpRight,
BarChart3,
ChevronRight,
CircleDollarSign,
House,
Landmark,
Send,
WalletCards,
X,
} from 'lucide-vue-next'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useBankingStore } from '@/stores/banking'
import { usePhoneStore } from '@/stores/phone'
import type {
BankingAction,
BankingTransaction,
BankingTransactionKind,
} from '@/types/banking'
type BankingTab = 'home' | 'activity'
const phone = usePhoneStore()
const banking = useBankingStore()
const activeTab = ref<BankingTab>('home')
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
let previousFocus: HTMLElement | null = null
const transactionIcons: Record<BankingTransactionKind, typeof Send> = {
deposit: ArrowDownLeft,
withdrawal: ArrowUpRight,
transfer_in: ArrowDownLeft,
transfer_out: ArrowUpRight,
}
const isIncoming = (kind: BankingTransactionKind): boolean =>
kind === 'deposit' || kind === 'transfer_in'
const chart = computed(() => {
const days = Array.from({ length: 7 }, (_, offset) => {
const date = new Date()
date.setHours(0, 0, 0, 0)
date.setDate(date.getDate() - (6 - offset))
return { date, incoming: 0, outgoing: 0 }
})
for (const transaction of banking.overview?.transactions ?? []) {
const transactionDate = new Date(transaction.createdAt)
transactionDate.setHours(0, 0, 0, 0)
const day = days.find(
(candidate) => candidate.date.getTime() === transactionDate.getTime(),
)
if (!day) continue
if (isIncoming(transaction.kind)) day.incoming += transaction.amount
else day.outgoing += transaction.amount
}
const maximum = Math.max(
1,
...days.flatMap((day) => [day.incoming, day.outgoing]),
)
return days.map((day) => ({
...day,
incomingHeight: Math.max(5, (day.incoming / maximum) * 100),
label: new Intl.DateTimeFormat(phone.lang, { weekday: 'narrow' }).format(
day.date,
),
outgoingHeight: Math.max(5, (day.outgoing / maximum) * 100),
}))
})
const totals = computed(() =>
(banking.overview?.transactions ?? []).reduce(
(result, transaction) => {
if (isIncoming(transaction.kind)) result.incoming += transaction.amount
else result.outgoing += transaction.amount
return result
},
{ incoming: 0, outgoing: 0 },
),
)
function formatMoney(value: number, signed = false): string {
const formatted = new Intl.NumberFormat(phone.lang, {
maximumFractionDigits: 0,
minimumFractionDigits: 0,
}).format(Math.abs(value))
const prefix = signed ? (value >= 0 ? '+' : '') : ''
return `${prefix}${banking.overview?.currency ?? '$'}${formatted}`
}
function formatDate(timestamp: number): string {
return new Intl.DateTimeFormat(phone.lang, {
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
month: 'short',
}).format(timestamp)
}
function transactionTitle(transaction: BankingTransaction): string {
if (transaction.label) return transaction.label
return phone.t(`Apps.banking.transactions.${transaction.kind}`)
}
function openAction(nextAction: BankingAction): void {
action.value = nextAction
amount.value = ''
target.value = ''
formError.value = ''
}
function closeAction(): void {
if (banking.isLoading) return
action.value = null
}
function updateTarget(event: Event): void {
if (!(event.target instanceof HTMLInputElement)) {
console.error('[banking] Player ID input emitted without an input target.')
return
}
target.value = event.target.value
formError.value = ''
}
function updateAmount(event: Event): void {
if (!(event.target instanceof HTMLInputElement)) {
console.error('[banking] Amount input emitted without an input target.')
return
}
amount.value = event.target.value
formError.value = ''
}
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 focusableSheetElements(): HTMLElement[] {
const sheet = document.querySelector<HTMLElement>('.banking-sheet__content')
if (!sheet) return []
return Array.from(
sheet.querySelectorAll<HTMLElement>(
'button:not(:disabled), input:not(:disabled), [href], [tabindex]:not([tabindex="-1"])',
),
)
}
function handleSheetKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
event.preventDefault()
closeAction()
return
}
if (event.key !== 'Tab') return
const focusable = focusableSheetElements()
if (!focusable.length) {
event.preventDefault()
return
}
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
}
}
function errorMessage(code: string): string {
return phone.t(`Apps.banking.errors.${code}`) ===
`Apps.banking.errors.${code}`
? phone.t('Apps.banking.errors.default')
: phone.t(`Apps.banking.errors.${code}`)
}
async function submitAction(): Promise<void> {
if (!action.value) return
const parsedAmount = Number(amount.value)
const parsedTarget = action.value === 'transfer' ? Number(target.value) : undefined
if (
!Number.isSafeInteger(parsedAmount) ||
parsedAmount <= 0 ||
(action.value === 'transfer' &&
(!Number.isSafeInteger(parsedTarget) || (parsedTarget ?? 0) <= 0))
) {
formError.value = phone.t('Apps.banking.errors.invalid_request')
return
}
const response = await banking.perform(action.value, parsedAmount, parsedTarget)
if (!response.success) {
formError.value = errorMessage(response.error ?? 'default')
return
}
action.value = null
}
onMounted(() => void banking.load())
watch(action, async (currentAction) => {
if (currentAction) {
previousFocus = document.activeElement instanceof HTMLElement
? document.activeElement
: null
await nextTick()
document.getElementById('banking-transfer-target')?.focus()
return
}
previousFocus?.focus()
previousFocus = null
})
onBeforeUnmount(() => {
if (wheelRefreshTimeout) clearTimeout(wheelRefreshTimeout)
previousFocus?.focus()
})
</script>
<template>
<k-page
component="main"
class="banking-app pb-safe-24"
:colors="{ bgIos: 'bg-transparent' }"
>
<div class="banking-app__aurora" aria-hidden="true"></div>
<k-navbar
class="banking-navbar"
:aria-hidden="Boolean(action)"
:inert="Boolean(action)"
:subtitle="phone.t('Apps.banking.welcome')"
:title="banking.overview?.playerName ?? phone.t('Common.loading')"
/>
<div
v-if="!banking.overview && banking.isLoading"
class="banking-loading"
:aria-hidden="Boolean(action)"
:inert="Boolean(action)"
>
<k-preloader />
<span>{{ phone.t('Common.loading') }}</span>
</div>
<div
v-else-if="!banking.overview"
class="banking-empty"
:aria-hidden="Boolean(action)"
:inert="Boolean(action)"
>
<Landmark :size="34" />
<strong>{{ phone.t('Apps.banking.unavailable') }}</strong>
<p>{{ errorMessage(banking.error) }}</p>
<k-button rounded @click="banking.load()">
{{ phone.t('Apps.banking.tryAgain') }}
</k-button>
</div>
<div
v-else
ref="bankingScroll"
class="banking-scroll"
:aria-hidden="Boolean(action)"
:inert="Boolean(action)"
@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">
<span>{{ phone.t('Apps.banking.totalBalance') }}</span>
<small>#{{ banking.overview.playerId }}</small>
</div>
<strong>{{ formatMoney(banking.overview.bank) }}</strong>
<div class="banking-balance__trend">
<span>{{ formatMoney(totals.incoming - totals.outgoing, true) }}</span>
{{ phone.t('Apps.banking.recentPeriod') }}
</div>
</k-glass>
<section class="banking-actions" :aria-label="phone.t('Apps.banking.actions')">
<k-glass
component="button"
type="button"
class="banking-action banking-action--primary"
@click="openAction('transfer')"
>
<span class="banking-action__icon"><Send :size="20" /></span>
<b>{{ phone.t('Apps.banking.send') }}</b>
<ChevronRight :size="16" aria-hidden="true" />
</k-glass>
</section>
<k-card class="banking-card banking-accounts">
<div class="banking-section-title">
<h2>{{ phone.t('Apps.banking.accounts') }}</h2>
</div>
<k-list inset strong class="banking-account-list">
<k-list-item
:title="phone.t('Apps.banking.bankAccount')"
:after="formatMoney(banking.overview.bank)"
>
<template #media><WalletCards :size="18" /></template>
</k-list-item>
<k-list-item
:title="phone.t('Apps.banking.cash')"
:after="formatMoney(banking.overview.cash)"
>
<template #media><CircleDollarSign :size="18" /></template>
</k-list-item>
</k-list>
</k-card>
<section class="banking-transactions">
<div class="banking-section-title">
<h2>{{ phone.t('Apps.banking.latestTransactions') }}</h2>
<k-link
component="button"
:link-props="{ type: 'button' }"
@click="activeTab = 'activity'"
>
{{ phone.t('Apps.banking.viewAll') }}
</k-link>
</div>
<k-card
v-if="banking.overview.transactions.length"
:content-wrap="false"
class="banking-card banking-transaction-card"
>
<k-list inset strong class="banking-transaction-list">
<k-list-item
v-for="transaction in banking.overview.transactions.slice(0, 5)"
:key="transaction.id"
:subtitle="formatDate(transaction.createdAt)"
:title="transactionTitle(transaction)"
>
<template #media>
<component :is="transactionIcons[transaction.kind]" :size="17" />
</template>
<template #after>
<b :class="{ 'is-incoming': isIncoming(transaction.kind) }">
{{ formatMoney(isIncoming(transaction.kind) ? transaction.amount : -transaction.amount, true) }}
</b>
</template>
</k-list-item>
</k-list>
</k-card>
<p v-else class="banking-no-transactions">
{{ phone.t('Apps.banking.noTransactions') }}
</p>
</section>
</template>
<template v-else>
<section class="banking-activity-hero">
<span>{{ phone.t('Apps.banking.activity') }}</span>
<strong>{{ formatMoney(totals.incoming - totals.outgoing, true) }}</strong>
<small>{{ phone.t('Apps.banking.recentPeriod') }}</small>
</section>
<k-card :content-wrap="false" class="banking-card banking-chart-card">
<div class="banking-chart-legend">
<span><i class="is-incoming"></i>{{ phone.t('Apps.banking.incoming') }}</span>
<span><i></i>{{ phone.t('Apps.banking.outgoing') }}</span>
</div>
<div class="banking-chart">
<div
v-for="day in chart"
:key="day.date.getTime()"
class="banking-chart__day"
role="img"
:aria-label="phone.t('Apps.banking.chartDaySummary', {
day: day.label,
incoming: formatMoney(day.incoming),
outgoing: formatMoney(day.outgoing),
})"
>
<div>
<i class="is-incoming" :style="{ height: `${day.incomingHeight}%` }"></i>
<i :style="{ height: `${day.outgoingHeight}%` }"></i>
</div>
<span>{{ day.label }}</span>
</div>
</div>
</k-card>
<section class="banking-transactions banking-transactions--all">
<div class="banking-section-title">
<h2>{{ phone.t('Apps.banking.allTransactions') }}</h2>
</div>
<k-card :content-wrap="false" class="banking-card banking-transaction-card">
<k-list inset strong class="banking-transaction-list">
<k-list-item
v-for="transaction in banking.overview.transactions"
:key="transaction.id"
:subtitle="formatDate(transaction.createdAt)"
:title="transactionTitle(transaction)"
>
<template #media>
<component :is="transactionIcons[transaction.kind]" :size="17" />
</template>
<template #after>
<b :class="{ 'is-incoming': isIncoming(transaction.kind) }">
{{ formatMoney(isIncoming(transaction.kind) ? transaction.amount : -transaction.amount, true) }}
</b>
</template>
</k-list-item>
</k-list>
</k-card>
</section>
</template>
</div>
<k-tabbar
v-if="banking.overview"
component="nav"
icons
labels
class="bottom-0 left-0 fixed"
:aria-hidden="Boolean(action)"
:aria-label="phone.t('Apps.banking.navigation')"
:inert="Boolean(action)"
>
<k-toolbar-pane>
<k-tabbar-link
component="button"
:active="activeTab === 'home'"
:link-props="{ type: 'button' }"
@click="activeTab = 'home'"
>
<template #label>{{ phone.t('Apps.banking.home') }}</template>
<template #icon>
<k-icon><House class="w-7 h-7" /></k-icon>
</template>
</k-tabbar-link>
<k-tabbar-link
component="button"
:active="activeTab === 'activity'"
:link-props="{ type: 'button' }"
@click="activeTab = 'activity'"
>
<template #label>{{ phone.t('Apps.banking.activity') }}</template>
<template #icon>
<k-icon><BarChart3 class="w-7 h-7" /></k-icon>
</template>
</k-tabbar-link>
</k-toolbar-pane>
</k-tabbar>
<k-sheet :opened="Boolean(action)" class="banking-sheet" @backdropclick="closeAction">
<section
v-if="action"
class="banking-sheet__content"
role="dialog"
aria-modal="true"
:aria-labelledby="`banking-${action}-title`"
@keydown="handleSheetKeydown"
>
<k-link
component="button"
class="banking-modal__close"
:aria-label="phone.t('Common.close')"
:link-props="{ type: 'button' }"
@click="closeAction"
>
<X :size="17" />
</k-link>
<span class="banking-modal__icon">
<Send :size="23" />
</span>
<h2 :id="`banking-${action}-title`">
{{ 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
:label="phone.t('Apps.banking.playerId')"
input-id="banking-transfer-target"
inputmode="numeric"
min="1"
outline
:placeholder="phone.t('Apps.banking.playerIdPlaceholder')"
type="number"
:value="target"
@input="updateTarget"
/>
<k-list-input
:label="phone.t('Apps.banking.amount')"
:error="formError || undefined"
input-id="banking-transfer-amount"
inputmode="numeric"
min="1"
outline
:placeholder="phone.t('Apps.banking.amountPlaceholder')"
type="number"
:value="amount"
@input="updateAmount"
@keydown.enter="submitAction"
/>
</k-list>
<p v-if="formError" class="banking-form-error" role="alert">
{{ formError }}
</p>
<k-button
large
rounded
:disabled="banking.isLoading"
@click="submitAction"
>
<k-preloader v-if="banking.isLoading" />
<template v-else>
{{ phone.t(`Apps.banking.forms.${action}.submit`) }}
<ArrowRight :size="17" />
</template>
</k-button>
</section>
</k-sheet>
</k-page>
</template>
+47
View File
@@ -20,6 +20,18 @@ function isoTime(offsetMilliseconds) {
let authenticated = true
let draft = null
let mockBankBalance = 24787
let mockCashBalance = 2350
let nextBankTransactionId = 7
const mockBankTransactions = [
{ id: 1, kind: 'transfer_in', amount: 3200, label: 'Sofia Turner', reference: 'mock-1', createdAt: Date.now() - 45 * 60 * 1000 },
{ id: 2, kind: 'transfer_out', amount: 680, label: 'Vincent Cole', reference: 'mock-2', createdAt: Date.now() - 5 * 60 * 60 * 1000 },
{ id: 3, kind: 'deposit', amount: 1250, label: '', reference: 'mock-3', createdAt: Date.now() - 25 * 60 * 60 * 1000 },
{ id: 4, kind: 'transfer_out', amount: 420, label: 'Maya Brooks', reference: 'mock-4', createdAt: Date.now() - 50 * 60 * 60 * 1000 },
{ id: 5, kind: 'withdrawal', amount: 300, label: '', reference: 'mock-5', createdAt: Date.now() - 76 * 60 * 60 * 1000 },
{ id: 6, kind: 'transfer_in', amount: 950, label: 'Noah Bennett', reference: 'mock-6', createdAt: Date.now() - 120 * 60 * 60 * 1000 },
]
let contactSequence = 2
const contacts = [
{
@@ -880,6 +892,41 @@ function counts() {
app.post('/api/:endpoint', (request, response) => {
console.log(`[NUI] ${request.params.endpoint}`, request.body)
const endpoint = request.params.endpoint
const bankingOverview = () => ({
bank: mockBankBalance,
cash: mockCashBalance,
currency: '$',
playerId: 42,
playerName: 'Alex Morgan',
transactions: mockBankTransactions,
})
if (endpoint === 'banking:overview') {
response.json({ success: true, data: bankingOverview() })
return
}
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 (mockBankBalance < amount) {
response.json({ success: false, error: 'insufficient_funds' })
return
}
const kind = 'transfer_out'
mockBankBalance -= amount
mockBankTransactions.unshift({
amount,
createdAt: Date.now(),
id: nextBankTransactionId++,
kind,
label: kind === 'transfer_out' ? `Player #${request.body.target}` : '',
reference: `mock-${Date.now()}`,
})
response.json({ success: true, data: bankingOverview() })
return
}
if (endpoint === 'weather:get') {
response.json({
success: true,
+10 -2
View File
@@ -14,8 +14,8 @@ Config.Bridge = {
Config.Command = "phone"
Config.Phone = {
Item = "sky_phone",
DevelopmentCommand = false,
Item = "phone",
DevelopmentCommand = true,
DeviceName = "iFruit Phone",
}
@@ -82,6 +82,14 @@ Config.Mail = {
AuthAttemptsPerMinute = 5,
}
Config.Banking = {
Currency = "$",
MinimumAmount = 1,
MaximumAmount = 1000000,
ActionsPerMinute = 12,
HistoryLimit = 50,
}
Config.Marketplace = {
PageSize = 20,
MessagePageSize = 50,
+26
View File
@@ -117,6 +117,32 @@ Locales["en"] = {
default = "The phone request failed.",
},
},
banking = {
name = "Banking", welcome = "Welcome back", totalBalance = "Total Balance", recentPeriod = "in recent activity",
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",
chartDaySummary = "{day}: {incoming} incoming, {outgoing} outgoing",
navigation = "Banking navigation", incoming = "Incoming", outgoing = "Outgoing",
refresh = "Refresh banking data", unavailable = "Banking unavailable", tryAgain = "Try Again",
playerId = "Player ID", playerIdPlaceholder = "Enter the recipient ID",
amount = "Amount", amountPlaceholder = "Enter an amount",
transactions = {
deposit = "Cash deposit", withdrawal = "Cash withdrawal",
transfer_in = "Incoming transfer", transfer_out = "Outgoing transfer",
},
forms = {
transfer = { title = "Send money", body = "Transfer money from your bank account to an online player.", submit = "Send transfer" },
},
errors = {
invalid_request = "Enter a valid whole amount and player ID.", insufficient_funds = "There is not enough money in this account.",
target_not_found = "The recipient is not online.", self_transfer = "You cannot send money to yourself.",
rate_limited = "Please wait before making another transaction.", transfer_failed = "The transfer could not be completed.",
banking_unavailable = "Banking is currently unavailable.", request_failed = "The banking request failed.",
default = "The banking request failed.",
},
},
calculator = { name = "Calculator" },
snake = {
name = "Snake", backToMenu = "Back to game menu", board = "Snake game board",
+1
View File
@@ -48,6 +48,7 @@ server_scripts {
'source/server/darkchat.lua',
'source/server/notes.lua',
'source/server/mail.lua',
'source/server/banking.lua',
'source/server/marketplace.lua',
'source/server/pages.lua',
'source/server/calendar.lua',
@@ -21,6 +21,46 @@ function Bridge.Framework.GetIdentifier(source)
return player and player.identifier or nil
end
function Bridge.Framework.GetMoney(source, account)
local player = get_player(source)
if not player then
return nil
end
if account == "cash" then
account = "money"
end
local account_data = player.getAccount(account)
return account_data and account_data.money or nil
end
function Bridge.Framework.AddMoney(source, account, amount)
local player = get_player(source)
if not player then
return false
end
if account == "cash" then
account = "money"
end
player.addAccountMoney(account, amount)
return true
end
function Bridge.Framework.RemoveMoney(source, account, amount)
local player = get_player(source)
if not player then
return false
end
if account == "cash" then
account = "money"
end
local account_data = player.getAccount(account)
if not account_data or account_data.money < amount then
return false
end
player.removeAccountMoney(account, amount)
return true
end
function Bridge.Framework.GetFirstname(source)
local player = get_player(source)
return player and player.get("firstName") or nil
@@ -21,6 +21,27 @@ function Bridge.Framework.GetIdentifier(source)
return player and player.PlayerData and tostring(player.PlayerData.citizenid) or nil
end
function Bridge.Framework.GetMoney(source, account)
local player = get_player(source)
return player and player.PlayerData and player.PlayerData.money[account] or nil
end
function Bridge.Framework.AddMoney(source, account, amount)
local player = get_player(source)
if not player then
return false
end
return player.Functions.AddMoney(account, amount)
end
function Bridge.Framework.RemoveMoney(source, account, amount)
local player = get_player(source)
if not player or not player.PlayerData.money[account] or player.PlayerData.money[account] < amount then
return false
end
return player.Functions.RemoveMoney(account, amount)
end
local function get_character_info(source)
local player = get_player(source)
return player and player.PlayerData and player.PlayerData.charinfo or nil
@@ -19,6 +19,18 @@ function Bridge.Framework.GetIdentifier(source)
return player and player.PlayerData and tostring(player.PlayerData.citizenid) or nil
end
function Bridge.Framework.GetMoney(source, account)
return exports.qbx_core:GetMoney(tonumber(source), account)
end
function Bridge.Framework.AddMoney(source, account, amount)
return exports.qbx_core:AddMoney(tonumber(source), account, amount)
end
function Bridge.Framework.RemoveMoney(source, account, amount)
return exports.qbx_core:RemoveMoney(tonumber(source), account, amount)
end
local function get_character_info(source)
local player = get_player(source)
return player and player.PlayerData and player.PlayerData.charinfo or nil
+6
View File
@@ -70,6 +70,8 @@ local server_callbacks = {
"calls:answer",
"calls:decline",
"calls:hangup",
"banking:overview",
"banking:transfer",
"messages:conversations",
"messages:thread",
"messages:send",
@@ -383,6 +385,10 @@ RegisterNetEvent("sky_phone:calls:changed", function()
SendNUIMessage({ type = "calls:changed" })
end)
RegisterNetEvent("sky_phone:banking:changed", function()
SendNUIMessage({ type = "banking:changed" })
end)
RegisterNetEvent("sky_phone:messages:changed", function(data)
SendNUIMessage({ type = "messages:changed", data = data })
end)
+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-C7Rwfofz.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-HW5wL36V.css">
<script type="module" crossorigin src="./assets/sky-index-CGTRpPrH.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-Bhv3YOTT.css">
</head>
<body>
<div id="app"></div>
+135
View File
@@ -0,0 +1,135 @@
Bridge.Database.AfterMigration("sky_phone", function()
local function valid_amount(value)
local amount = tonumber(value)
if not amount or amount ~= math.floor(amount) then
return nil
end
if amount < Config.Banking.MinimumAmount or amount > Config.Banking.MaximumAmount then
return nil
end
return amount
end
local function player_name(source)
local firstname = Bridge.Framework.GetFirstname(source)
local lastname = Bridge.Framework.GetLastname(source)
local name = ((firstname or "") .. " " .. (lastname or "")):match("^%s*(.-)%s*$")
if name == "" then
name = GetPlayerName(source) or ("Player %s"):format(source)
end
return name
end
local function require_banking_session(source)
local session, error_response = SkyPhone.RequireSession(source)
if not session then
return nil, error_response
end
local identifier = Bridge.Framework.GetIdentifier(source)
if type(identifier) ~= "string" or identifier == "" then
return nil, { success = false, error = "banking_unavailable" }
end
return identifier
end
local function transaction_dto(row)
return {
id = tonumber(row.id),
kind = row.kind,
amount = tonumber(row.amount) or 0,
label = row.label or "",
reference = row.reference or "",
createdAt = (tonumber(row.created_at_unix) or 0) * 1000,
}
end
local function transactions(identifier)
local rows = Bridge.Database.Query([[
SELECT `id`, `kind`, `amount`, `label`, `reference`,
UNIX_TIMESTAMP(`created_at`) AS `created_at_unix`
FROM `sky_phone_bank_transactions`
WHERE `owner_identifier` = ?
ORDER BY `id` DESC
LIMIT ?
]], { identifier, Config.Banking.HistoryLimit })
local result = {}
for _, row in ipairs(rows) do
result[#result + 1] = transaction_dto(row)
end
return result
end
local function overview(source, identifier)
return {
bank = math.max(0, tonumber(Bridge.Framework.GetMoney(source, "bank")) or 0),
cash = math.max(0, tonumber(Bridge.Framework.GetMoney(source, "cash")) or 0),
currency = Config.Banking.Currency,
playerId = source,
playerName = player_name(source),
transactions = transactions(identifier),
}
end
local function record_transaction(identifier, kind, amount, label, reference)
Bridge.Database.Query([[
INSERT INTO `sky_phone_bank_transactions`
(`owner_identifier`, `kind`, `amount`, `label`, `reference`)
VALUES (?, ?, ?, ?, ?)
]], { identifier, kind, amount, label or "", reference or "" })
end
local function notify_changed(source)
TriggerClientEvent("sky_phone:banking:changed", source)
end
Bridge.Callbacks.Register("sky_phone:banking:overview", function(source)
local identifier, error_response = require_banking_session(source)
if not identifier then
return error_response
end
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" }
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)
local target_value = tonumber(data and data.target)
if not amount
or not target_value
or target_value ~= math.floor(target_value)
or target_value <= 0
or target_value > 65535
then
return { success = false, error = "invalid_request" }
end
local target = math.floor(target_value)
if target == source then
return { success = false, error = "self_transfer" }
end
local target_identifier = Bridge.Framework.GetIdentifier(target)
if type(target_identifier) ~= "string" or target_identifier == "" then
return { success = false, error = "target_not_found" }
end
if not Bridge.Framework.RemoveMoney(source, "bank", amount) then
return { success = false, error = "insufficient_funds" }
end
if not Bridge.Framework.AddMoney(target, "bank", amount) then
Bridge.Framework.AddMoney(source, "bank", amount)
return { success = false, error = "transfer_failed" }
end
local reference = ("phone-transfer-%s-%s"):format(os.time(), 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)
return { success = true, data = overview(source, identifier) }
end)
end)
+18
View File
@@ -351,6 +351,24 @@ local schema = {
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_bank_transactions",
columns = {
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
{ name = "owner_identifier", type = "VARCHAR(80) NOT NULL" },
{ name = "kind", type = "ENUM('deposit', 'withdrawal', 'transfer_in', 'transfer_out') NOT NULL" },
{ name = "amount", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "label", type = "VARCHAR(160) NOT NULL DEFAULT ''" },
{ name = "reference", type = "VARCHAR(96) NOT NULL DEFAULT ''", characterSet = "ascii", collation = "ascii_bin" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
},
primaryKey = "id",
indexes = {
{ name = "idx_sky_phone_bank_owner", columns = "(`owner_identifier`, `id`)" },
{ name = "idx_sky_phone_bank_reference", columns = "(`reference`)" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_sms_messages",
columns = {
+13
View File
@@ -177,6 +177,19 @@ CREATE TABLE IF NOT EXISTS `sky_phone_call_entries` (
FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_bank_transactions` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`owner_identifier` VARCHAR(80) NOT NULL,
`kind` ENUM('deposit', 'withdrawal', 'transfer_in', 'transfer_out') NOT NULL,
`amount` BIGINT UNSIGNED NOT NULL,
`label` VARCHAR(160) NOT NULL DEFAULT '',
`reference` VARCHAR(96) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT '',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_sky_phone_bank_owner` (`owner_identifier`, `id`),
KEY `idx_sky_phone_bank_reference` (`reference`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_sms_messages` (
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`sender_sim_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,