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