diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml index c6f4a1e..d508289 100644 --- a/frontend/pnpm-workspace.yaml +++ b/frontend/pnpm-workspace.yaml @@ -1,3 +1,4 @@ +allowBuilds: + esbuild: set this to true or false onlyBuiltDependencies: - esbuild - diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 3648726..65ffab7 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -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): 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') && diff --git a/frontend/src/assets/img/app-icons/banking.svg b/frontend/src/assets/img/app-icons/banking.svg new file mode 100644 index 0000000..78924fa --- /dev/null +++ b/frontend/src/assets/img/app-icons/banking.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 26f0f99..84f759c 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -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; } diff --git a/frontend/src/config/apps.test.ts b/frontend/src/config/apps.test.ts index 0140978..b9b7d26 100644 --- a/frontend/src/config/apps.test.ts +++ b/frontend/src/config/apps.test.ts @@ -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)) diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index 07fd30c..4f19210 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -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, diff --git a/frontend/src/stores/banking.test.ts b/frontend/src/stores/banking.test.ts new file mode 100644 index 0000000..9029ddf --- /dev/null +++ b/frontend/src/stores/banking.test.ts @@ -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') + }) +}) diff --git a/frontend/src/stores/banking.ts b/frontend/src/stores/banking.ts new file mode 100644 index 0000000..84fc79e --- /dev/null +++ b/frontend/src/stores/banking.ts @@ -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 { + this.isLoading = true + const response = await nuiCall('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> { + this.isLoading = true + const response = await nuiCall(`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 + }, + }, +}) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 0d0f8b5..9b36a1d 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -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', diff --git a/frontend/src/types/apps.ts b/frontend/src/types/apps.ts index f41235b..7a002f0 100644 --- a/frontend/src/types/apps.ts +++ b/frontend/src/types/apps.ts @@ -6,6 +6,7 @@ export type PhoneAppId = | 'camera' | 'clock' | 'weather' + | 'banking' | 'mail' | 'map' | 'notes' diff --git a/frontend/src/types/banking.ts b/frontend/src/types/banking.ts new file mode 100644 index 0000000..6e7fa3c --- /dev/null +++ b/frontend/src/types/banking.ts @@ -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' diff --git a/frontend/src/utils/preferences.ts b/frontend/src/utils/preferences.ts index 5acd580..2e2d322 100644 --- a/frontend/src/utils/preferences.ts +++ b/frontend/src/utils/preferences.ts @@ -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 }, diff --git a/frontend/src/views/apps/BankingApp.vue b/frontend/src/views/apps/BankingApp.vue new file mode 100644 index 0000000..14d020f --- /dev/null +++ b/frontend/src/views/apps/BankingApp.vue @@ -0,0 +1,419 @@ + + + diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs index e237df0..ba0006e 100644 --- a/frontend/testserver/index.cjs +++ b/frontend/testserver/index.cjs @@ -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, diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index 5f0b0b2..231a3bb 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -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, +} diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index 55f86e2..6fd1593 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -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", diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua index a414025..7045436 100644 --- a/sky_phone/fxmanifest.lua +++ b/sky_phone/fxmanifest.lua @@ -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', +} diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua index 801757a..61a12b3 100644 --- a/sky_phone/source/client/main.lua +++ b/sky_phone/source/client/main.lua @@ -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) diff --git a/sky_phone/source/html/index.html b/sky_phone/source/html/index.html index 2d66aff..f265ac7 100644 --- a/sky_phone/source/html/index.html +++ b/sky_phone/source/html/index.html @@ -4,8 +4,8 @@ Sky Phone - - + +
diff --git a/sky_phone/source/server/banking.lua b/sky_phone/source/server/banking.lua new file mode 100644 index 0000000..f44f893 --- /dev/null +++ b/sky_phone/source/server/banking.lua @@ -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) diff --git a/sky_phone/source/server/db_migrate.lua b/sky_phone/source/server/db_migrate.lua index 536fb54..f9aafb1 100644 --- a/sky_phone/source/server/db_migrate.lua +++ b/sky_phone/source/server/db_migrate.lua @@ -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) diff --git a/sky_phone/sql/install.sql b/sky_phone/sql/install.sql index 773ca24..f6645d6 100644 --- a/sky_phone/sql/install.sql +++ b/sky_phone/sql/install.sql @@ -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;