ADD - add SkyPhone banking app

Includes the required banking configuration, SQL migration, and English locale entries.
This commit is contained in:
Type
2026-08-06 17:30:38 +02:00
parent 5471040dd8
commit f79400a691
22 changed files with 1663 additions and 16 deletions
+2 -1
View File
@@ -1,3 +1,4 @@
allowBuilds:
esbuild: set this to true or false
onlyBuiltDependencies:
- esbuild
+4
View File
@@ -21,6 +21,7 @@ import SimPhonePicker, {
import { PHONE_FRAME_IMAGES } from '@/config/appearance'
import { useClockStore } from '@/stores/clock'
import { useCallsStore } from '@/stores/calls'
import { useBankingStore } from '@/stores/banking'
import { useAccountStore } from '@/stores/account'
import { useMailStore } from '@/stores/mail'
import { useMediaStore } from '@/stores/media'
@@ -67,6 +68,7 @@ const phone = usePhoneStore()
const account = useAccountStore()
const clock = useClockStore()
const calls = useCallsStore()
const banking = useBankingStore()
const mail = useMailStore()
const media = useMediaStore()
const notes = useNotesStore()
@@ -155,6 +157,8 @@ function onMessage(event: MessageEvent<AppMessage>): void {
void calls.loadContacts()
} 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

+657 -1
View File
@@ -1096,15 +1096,671 @@ 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-header {
position: absolute;
z-index: 5;
top: 0;
right: 0;
left: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 54px 22px 13px;
background: linear-gradient(to bottom, rgb(7 13 29 / 85%), transparent);
}
.banking-header > div {
min-width: 0;
}
.banking-header span {
display: block;
color: rgb(208 220 248 / 64%);
font-size: 11px;
letter-spacing: 0.03em;
}
.banking-header h1 {
overflow: hidden;
margin: 2px 0 0;
font-size: 17px;
font-weight: 650;
letter-spacing: -0.02em;
text-overflow: ellipsis;
white-space: nowrap;
}
.banking-profile {
position: relative;
display: grid;
width: 42px;
height: 42px;
flex: 0 0 auto;
place-items: center;
border: 1px solid rgb(255 255 255 / 16%);
border-radius: 15px;
background: linear-gradient(145deg, rgb(65 130 255 / 42%), rgb(19 35 78 / 48%));
box-shadow: inset 0 1px rgb(255 255 255 / 18%), 0 9px 28px rgb(0 0 0 / 22%);
color: #dce9ff;
backdrop-filter: blur(18px) saturate(145%);
-webkit-backdrop-filter: blur(18px) saturate(145%);
}
.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;
overflow-x: hidden;
overflow-y: auto;
padding: 108px 17px 112px;
scrollbar-width: none;
}
.banking-scroll::-webkit-scrollbar { display: none; }
.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 {
position: relative;
overflow: hidden;
padding: 22px 20px 20px;
border: 1px solid rgb(135 178 255 / 22%);
border-radius: 27px;
background:
linear-gradient(145deg, rgb(50 106 218 / 38%), rgb(15 29 67 / 36%)),
rgb(255 255 255 / 3%);
box-shadow: inset 0 1px rgb(255 255 255 / 16%), 0 22px 52px rgb(0 0 0 / 26%);
backdrop-filter: blur(26px) saturate(150%);
-webkit-backdrop-filter: blur(26px) saturate(150%);
}
.banking-balance::after {
position: absolute;
width: 150px;
height: 150px;
top: -92px;
right: -24px;
border-radius: 50%;
background: var(--bank-blue);
box-shadow: 0 0 68px 25px rgb(45 118 255 / 48%);
content: '';
opacity: 0.25;
}
.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(3, 1fr);
gap: 9px;
margin: 13px 0 18px;
}
.banking-actions button {
display: flex;
align-items: center;
flex-direction: column;
gap: 7px;
padding: 12px 5px 10px;
border: 1px solid rgb(255 255 255 / 10%);
border-radius: 19px;
background: rgb(255 255 255 / 5.5%);
box-shadow: inset 0 1px rgb(255 255 255 / 9%);
color: rgb(237 243 255 / 82%);
font-size: 10px;
backdrop-filter: blur(17px);
-webkit-backdrop-filter: blur(17px);
}
.banking-actions button span {
display: grid;
width: 38px;
height: 38px;
place-items: center;
border-radius: 13px;
background: linear-gradient(145deg, rgb(60 129 255 / 34%), rgb(25 54 115 / 25%));
color: #8fb8ff;
}
.banking-card {
margin: 0 0 18px !important;
padding: 16px !important;
border: 1px solid rgb(255 255 255 / 10%) !important;
border-radius: 24px !important;
background: linear-gradient(145deg, rgb(255 255 255 / 8%), rgb(255 255 255 / 3%)) !important;
box-shadow: inset 0 1px rgb(255 255 255 / 9%), 0 18px 38px rgb(0 0 0 / 18%) !important;
color: #fff !important;
backdrop-filter: blur(24px) saturate(140%);
-webkit-backdrop-filter: blur(24px) saturate(140%);
}
.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-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 17px;
}
.banking-account-row + .banking-account-row { margin-top: 7px; }
.banking-account-row--primary {
position: relative;
overflow: hidden;
border: 1px solid rgb(125 174 255 / 17%);
background: linear-gradient(120deg, rgb(37 103 230 / 38%), rgb(36 72 143 / 15%));
}
.banking-account-row > span:first-child {
display: grid;
width: 36px;
height: 36px;
flex: 0 0 auto;
place-items: center;
border-radius: 12px;
background: rgb(255 255 255 / 9%);
color: #9fc0ff;
}
.banking-account-row div {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
}
.banking-account-row small {
color: rgb(216 227 250 / 54%);
font-size: 9px;
}
.banking-account-row strong {
margin-top: 2px;
font-size: 16px;
font-weight: 620;
}
.banking-card-mark {
position: relative;
width: 32px !important;
background: transparent !important;
}
.banking-card-mark::before,
.banking-card-mark::after {
position: absolute;
width: 20px;
height: 20px;
top: 8px;
border-radius: 50%;
background: rgb(255 255 255 / 64%);
content: '';
}
.banking-card-mark::before { left: 0; }
.banking-card-mark::after { right: 0; background: rgb(120 164 255 / 65%); }
.banking-transactions { padding: 0 3px; }
.banking-transactions--all { margin-top: 18px; }
.banking-transaction-list {
overflow: hidden;
border: 1px solid rgb(255 255 255 / 8%);
border-radius: 22px;
background: rgb(255 255 255 / 3.5%);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
}
.banking-transaction {
display: flex;
align-items: center;
gap: 11px;
padding: 12px;
}
.banking-transaction + .banking-transaction {
border-top: 1px solid rgb(255 255 255 / 6%);
}
.banking-transaction > span {
display: grid;
width: 36px;
height: 36px;
flex: 0 0 auto;
place-items: center;
border-radius: 12px;
background: rgb(255 106 120 / 10%);
color: #ff8793;
}
.banking-transaction > span.is-incoming {
background: rgb(78 230 164 / 10%);
color: var(--bank-green);
}
.banking-transaction > div {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
}
.banking-transaction strong {
overflow: hidden;
font-size: 11px;
font-weight: 570;
text-overflow: ellipsis;
white-space: nowrap;
}
.banking-transaction small {
margin-top: 2px;
color: rgb(210 221 245 / 43%);
font-size: 8px;
}
.banking-transaction b {
flex: 0 0 auto;
color: #f2a0a8;
font-size: 11px;
font-weight: 620;
}
.banking-transaction 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-tabbar {
position: absolute;
z-index: 10;
right: 16px;
bottom: 22px;
left: 16px;
width: auto;
padding: 0;
}
.banking-tabbar__background {
border: 1px solid rgb(255 255 255 / 13%);
border-radius: 24px;
background: rgb(14 22 45 / 72%);
box-shadow: inset 0 1px rgb(255 255 255 / 12%), 0 16px 48px rgb(0 0 0 / 48%);
backdrop-filter: blur(30px) saturate(165%);
-webkit-backdrop-filter: blur(30px) saturate(165%);
}
.banking-tabbar__inner {
gap: 4px;
padding: 7px;
}
.banking-tabbar .k-link {
color: rgb(210 222 247 / 48%);
font-size: 10px;
}
.banking-tabbar .k-tabbar-link-active {
color: #93b9ff;
}
.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-modal-backdrop {
position: absolute;
z-index: 30;
inset: 0;
display: flex;
align-items: flex-end;
padding: 14px 14px 20px;
background: rgb(0 3 12 / 58%);
backdrop-filter: blur(9px);
-webkit-backdrop-filter: blur(9px);
}
.banking-modal {
position: relative;
width: 100%;
padding: 22px 18px 18px;
border: 1px solid rgb(255 255 255 / 14%);
border-radius: 29px;
background:
radial-gradient(circle at 16% 0%, rgb(47 117 255 / 24%), transparent 42%),
rgb(13 22 45 / 92%);
box-shadow: inset 0 1px rgb(255 255 255 / 14%), 0 24px 70px rgb(0 0 0 / 55%);
backdrop-filter: blur(34px) saturate(165%);
-webkit-backdrop-filter: blur(34px) saturate(165%);
}
.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-modal h2 {
margin: 14px 0 4px;
font-size: 20px;
letter-spacing: -0.035em;
}
.banking-modal > p {
margin: 0 0 15px;
color: rgb(213 225 248 / 52%);
font-size: 10px;
line-height: 1.45;
}
.banking-modal label {
display: block;
margin-top: 11px;
}
.banking-modal label > span {
display: block;
margin: 0 0 6px 4px;
color: rgb(218 229 251 / 62%);
font-size: 9px;
}
.banking-modal input {
width: 100%;
height: 48px;
padding: 0 14px;
border: 1px solid rgb(255 255 255 / 10%);
border-radius: 15px;
outline: none;
background: rgb(255 255 255 / 5%);
color: #fff;
font-size: 14px;
}
.banking-modal input:focus {
border-color: rgb(72 137 255 / 62%);
box-shadow: 0 0 0 3px rgb(45 118 255 / 11%);
}
.banking-amount-input { position: relative; }
.banking-amount-input i {
position: absolute;
top: 50%;
left: 14px;
color: #7faaff;
font-size: 15px;
font-style: normal;
transform: translateY(-50%);
}
.banking-amount-input input { padding-left: 31px; }
.banking-modal .banking-form-error {
margin: 10px 3px 0;
color: #ff8b98;
font-size: 9px;
}
.banking-modal > button:last-child {
margin-top: 15px;
background: linear-gradient(135deg, #397fff, #1a55d1) !important;
box-shadow: 0 12px 28px rgb(25 78 190 / 30%);
}
.banking-modal > button:last-child > span {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.weather-navbar::after {
opacity: 0;
}
+13 -4
View File
@@ -8,16 +8,20 @@ describe('app registry', () => {
expect(PHONE_APPS.every((app) => app.route === `/apps/${app.id}`)).toBe(
true,
)
expect(PHONE_APPS.every((app) => app.iconImage.endsWith('.webp'))).toBe(
true,
)
expect(
PHONE_APPS.filter(
(app) =>
!app.iconImage.startsWith('data:image/svg+xml') &&
!app.iconImage.endsWith('.webp'),
).map((app) => ({ id: app.id, image: app.iconImage })),
).toEqual([])
expect(PHONE_APPS.find((app) => app.id === 'phone')).toMatchObject({
dockOrder: 0,
labelKey: 'Apps.phone.name',
route: '/apps/phone',
})
expect(PHONE_APPS.find((app) => app.id === 'mail')).toMatchObject({
gridOrder: 5,
gridOrder: 6,
labelKey: 'Apps.mail.name',
route: '/apps/mail',
})
@@ -26,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.filter((app) => app.dockOrder !== null)
.sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0))
+20 -5
View File
@@ -10,6 +10,7 @@ import {
Settings,
ShoppingBag,
CloudSun,
Landmark,
} from 'lucide-vue-next'
import { defineAsyncComponent, markRaw } from 'vue'
@@ -24,6 +25,7 @@ import photosIcon from '@/assets/img/app-icons/gallery.webp'
import phoneIcon from '@/assets/img/app-icons/phone.webp'
import settingsIcon from '@/assets/img/app-icons/settings.webp'
import weatherIcon from '@/assets/img/app-icons/weather.webp'
import bankingIcon from '@/assets/img/app-icons/banking.svg'
import type { PhoneAppDefinition, PhoneAppId } from '@/types/apps'
export const PHONE_APPS: PhoneAppDefinition[] = [
@@ -55,10 +57,23 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
},
{
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/MailApp.vue')),
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')),
),
dockOrder: null,
gridOrder: 6,
icon: markRaw(Mail),
iconClass: '',
iconImage: mailIcon,
@@ -71,7 +86,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
defineAsyncComponent(() => import('@/views/apps/NotesApp.vue')),
),
dockOrder: null,
gridOrder: 6,
gridOrder: 7,
icon: markRaw(NotebookPen),
iconClass: '',
iconImage: notesIcon,
@@ -136,7 +151,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
defineAsyncComponent(() => import('@/views/apps/PhotosApp.vue')),
),
dockOrder: null,
gridOrder: 7,
gridOrder: 8,
icon: markRaw(Images),
iconClass: 'app-icon--photos',
iconImage: photosIcon,
@@ -149,7 +164,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
defineAsyncComponent(() => import('@/views/apps/AppStoreApp.vue')),
),
dockOrder: null,
gridOrder: 8,
gridOrder: 9,
icon: markRaw(ShoppingBag),
iconClass: 'app-icon--store',
iconImage: appStoreIcon,
@@ -162,7 +177,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
defineAsyncComponent(() => import('@/views/apps/SettingsApp.vue')),
),
dockOrder: null,
gridOrder: 9,
gridOrder: 11,
icon: markRaw(Settings),
iconClass: 'app-icon--settings',
iconImage: settingsIcon,
+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('withdraw', 50000)
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
},
},
})
+63
View File
@@ -127,6 +127,69 @@ 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',
deposit: 'Deposit',
withdraw: 'Withdraw',
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',
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',
},
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.',
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: 'SkyBase banking is currently unavailable.',
request_failed: 'The banking request failed.',
default: 'The banking request failed.',
},
},
calculator: { name: 'Calculator' },
map: {
name: 'Map',
+1
View File
@@ -6,6 +6,7 @@ export type PhoneAppId =
| 'camera'
| 'clock'
| '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 = 'deposit' | 'withdraw' | 'transfer'
+1
View File
@@ -53,6 +53,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
camera: { enabled: true, sounds: true },
clock: { 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 },
+419
View File
@@ -0,0 +1,419 @@
<script setup lang="ts">
import {
kButton,
kCard,
kPage,
kPreloader,
kTabbar,
kTabbarLink,
} from 'konsta/vue'
import {
ArrowDownLeft,
ArrowRight,
ArrowUpRight,
BarChart3,
ChevronRight,
CircleDollarSign,
House,
Landmark,
RefreshCw,
Send,
WalletCards,
X,
} from 'lucide-vue-next'
import { computed, onMounted, ref } 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 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 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())
</script>
<template>
<k-page
component="main"
class="banking-app"
:colors="{ bgIos: 'bg-transparent' }"
>
<div class="banking-app__aurora" aria-hidden="true"></div>
<header class="banking-header">
<div>
<span>{{ phone.t('Apps.banking.welcome') }}</span>
<h1>{{ banking.overview?.playerName ?? phone.t('Common.loading') }}</h1>
</div>
<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>
</header>
<div v-if="!banking.overview && banking.isLoading" class="banking-loading">
<k-preloader />
<span>{{ phone.t('Common.loading') }}</span>
</div>
<div v-else-if="!banking.overview" class="banking-empty">
<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 class="banking-scroll">
<template v-if="activeTab === 'home'">
<section 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>
</section>
<section class="banking-actions" :aria-label="phone.t('Apps.banking.actions')">
<button type="button" @click="openAction('transfer')">
<span><Send :size="20" /></span>
{{ phone.t('Apps.banking.send') }}
</button>
<button type="button" @click="openAction('deposit')">
<span><ArrowDownLeft :size="20" /></span>
{{ phone.t('Apps.banking.deposit') }}
</button>
<button type="button" @click="openAction('withdraw')">
<span><ArrowUpRight :size="20" /></span>
{{ phone.t('Apps.banking.withdraw') }}
</button>
</section>
<k-card :content-wrap="false" class="banking-card banking-accounts">
<div class="banking-section-title">
<h2>{{ phone.t('Apps.banking.accounts') }}</h2>
<ChevronRight :size="17" />
</div>
<div class="banking-account-row banking-account-row--primary">
<span><WalletCards :size="18" /></span>
<div>
<small>{{ phone.t('Apps.banking.bankAccount') }}</small>
<strong>{{ formatMoney(banking.overview.bank) }}</strong>
</div>
<span class="banking-card-mark" aria-hidden="true"></span>
</div>
<div class="banking-account-row">
<span><CircleDollarSign :size="18" /></span>
<div>
<small>{{ phone.t('Apps.banking.cash') }}</small>
<strong>{{ formatMoney(banking.overview.cash) }}</strong>
</div>
</div>
</k-card>
<section class="banking-transactions">
<div class="banking-section-title">
<h2>{{ phone.t('Apps.banking.latestTransactions') }}</h2>
<button type="button" @click="activeTab = 'activity'">
{{ phone.t('Apps.banking.viewAll') }}
</button>
</div>
<div v-if="banking.overview.transactions.length" class="banking-transaction-list">
<article
v-for="transaction in banking.overview.transactions.slice(0, 5)"
:key="transaction.id"
class="banking-transaction"
>
<span :class="{ 'is-incoming': isIncoming(transaction.kind) }">
<component :is="transactionIcons[transaction.kind]" :size="17" />
</span>
<div>
<strong>{{ transactionTitle(transaction) }}</strong>
<small>{{ formatDate(transaction.createdAt) }}</small>
</div>
<b :class="{ 'is-incoming': isIncoming(transaction.kind) }">
{{ formatMoney(isIncoming(transaction.kind) ? transaction.amount : -transaction.amount, true) }}
</b>
</article>
</div>
<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">
<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>
<div class="banking-transaction-list">
<article
v-for="transaction in banking.overview.transactions"
:key="transaction.id"
class="banking-transaction"
>
<span :class="{ 'is-incoming': isIncoming(transaction.kind) }">
<component :is="transactionIcons[transaction.kind]" :size="17" />
</span>
<div>
<strong>{{ transactionTitle(transaction) }}</strong>
<small>{{ formatDate(transaction.createdAt) }}</small>
</div>
<b :class="{ 'is-incoming': isIncoming(transaction.kind) }">
{{ formatMoney(isIncoming(transaction.kind) ? transaction.amount : -transaction.amount, true) }}
</b>
</article>
</div>
</section>
</template>
</div>
<k-tabbar
v-if="banking.overview"
component="nav"
icons
labels
class="banking-tabbar"
bg-class="banking-tabbar__background"
inner-class="banking-tabbar__inner"
:aria-label="phone.t('Apps.banking.navigation')"
>
<k-tabbar-link
component="button"
:active="activeTab === 'home'"
:label="phone.t('Apps.banking.home')"
:link-props="{ type: 'button' }"
@click="activeTab = 'home'"
>
<template #icon><House :size="19" /></template>
</k-tabbar-link>
<k-tabbar-link
component="button"
:active="activeTab === 'activity'"
:label="phone.t('Apps.banking.activity')"
:link-props="{ type: 'button' }"
@click="activeTab = 'activity'"
>
<template #icon><BarChart3 :size="19" /></template>
</k-tabbar-link>
</k-tabbar>
<div v-if="action" class="banking-modal-backdrop" @click.self="closeAction">
<section class="banking-modal" role="dialog" aria-modal="true">
<button
type="button"
class="banking-modal__close"
:aria-label="phone.t('Common.close')"
@click="closeAction"
>
<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" />
</span>
<h2>{{ phone.t(`Apps.banking.forms.${action}.title`) }}</h2>
<p>{{ phone.t(`Apps.banking.forms.${action}.body`) }}</p>
<label v-if="action === 'transfer'">
<span>{{ phone.t('Apps.banking.playerId') }}</span>
<input
v-model="target"
inputmode="numeric"
min="1"
type="number"
:placeholder="phone.t('Apps.banking.playerIdPlaceholder')"
/>
</label>
<label>
<span>{{ phone.t('Apps.banking.amount') }}</span>
<div class="banking-amount-input">
<i>{{ banking.overview?.currency }}</i>
<input
v-model="amount"
inputmode="numeric"
min="1"
type="number"
:placeholder="phone.t('Apps.banking.amountPlaceholder')"
@keydown.enter="submitAction"
/>
</div>
</label>
<p v-if="formError" class="banking-form-error">{{ 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>
</div>
</k-page>
</template>
+66
View File
@@ -11,6 +11,17 @@ let authenticated = false
let draft = null
let linkedAccount = null
let mockNotes = []
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 },
]
const deviceData = {}
const accountDevices = [
{
@@ -94,6 +105,61 @@ 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:deposit' ||
endpoint === 'banking:withdraw' ||
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') {
mockBankBalance -= amount
mockCashBalance += amount
} else {
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",
}
@@ -46,3 +46,11 @@ Config.Mail = {
PageSize = 50,
AuthAttemptsPerMinute = 5,
}
Config.Banking = {
Currency = "$",
MinimumAmount = 1,
MaximumAmount = 1000000,
ActionsPerMinute = 12,
HistoryLimit = 50,
}
+27
View File
@@ -60,6 +60,33 @@ 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", deposit = "Deposit", withdraw = "Withdraw",
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",
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" },
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.",
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 = "SkyBase banking is currently unavailable.", request_failed = "The banking request failed.",
default = "The banking request failed.",
},
},
calculator = { name = "Calculator" },
camera = {
name = "Camera", shutter = "Take photo", flip = "Flip camera", flash = "Toggle flash", controls = "Camera controls",
+6 -1
View File
@@ -13,6 +13,7 @@ escrow_ignore {
}
shared_scripts {
'@sky_base/source/import.lua',
'config/init.lua',
'source/bridge/shared.lua',
'source/shared/imei.lua',
@@ -43,6 +44,7 @@ server_scripts {
'source/server/calls.lua',
'source/server/notes.lua',
'source/server/mail.lua',
'source/server/banking.lua',
}
files {
@@ -53,4 +55,7 @@ files {
ui_page 'source/html/index.html'
dependency 'oxmysql'
dependencies {
'oxmysql',
'sky_base',
}
+8
View File
@@ -43,6 +43,10 @@ local server_callbacks = {
"calls:answer",
"calls:decline",
"calls:hangup",
"banking:overview",
"banking:deposit",
"banking:withdraw",
"banking:transfer",
}
local function get_locale()
@@ -283,6 +287,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:call:incoming", function(data)
notification_focus = true
SetNuiFocus(true, true)
+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-D2k1S7R7.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-G8JTwQWl.css">
<script type="module" crossorigin src="./assets/sky-index-Bf141VPP.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-B4mea91c.css">
</head>
<body>
<div id="app"></div>
+184
View File
@@ -0,0 +1,184 @@
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 = Sky.FW.GetFirstname(source)
local lastname = Sky.FW.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 = Sky.FW.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(Sky.FW.GetAccountMoney(source, "bank")) or 0),
cash = math.max(0, tonumber(Sky.FW.GetAccountMoney(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: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 Sky.FW.RemoveAccountMoney(source, "cash", amount) then
return { success = false, error = "insufficient_funds" }
end
local added = pcall(Sky.FW.AddAccountMoney, source, "bank", amount)
if not added then
Sky.FW.AddAccountMoney(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 Sky.FW.RemoveAccountMoney(source, "bank", amount) then
return { success = false, error = "insufficient_funds" }
end
local added = pcall(Sky.FW.AddAccountMoney, source, "cash", amount)
if not added then
Sky.FW.AddAccountMoney(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" }
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 = Sky.FW.GetIdentifier(target)
if type(target_identifier) ~= "string" or target_identifier == "" then
return { success = false, error = "target_not_found" }
end
if not Sky.FW.RemoveAccountMoney(source, "bank", amount) then
return { success = false, error = "insufficient_funds" }
end
local added = pcall(Sky.FW.AddAccountMoney, target, "bank", amount)
if not added then
Sky.FW.AddAccountMoney(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
@@ -318,6 +318,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",
},
}
Bridge.Database.Migrate("sky_phone", schema)
+13
View File
@@ -161,3 +161,16 @@ CREATE TABLE IF NOT EXISTS `sky_phone_call_entries` (
FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE,
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;