mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-28 22:01:40 +00:00
ADD - add SkyPhone banking app
Includes the required banking configuration, SQL migration, and English locale entries.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
allowBuilds:
|
||||
esbuild: set this to true or false
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import SimPhonePicker, {
|
||||
import { PHONE_FRAME_IMAGES } from '@/config/appearance'
|
||||
import { useClockStore } from '@/stores/clock'
|
||||
import { useCallsStore } from '@/stores/calls'
|
||||
import { useBankingStore } from '@/stores/banking'
|
||||
import { useAccountStore } from '@/stores/account'
|
||||
import { useMailStore } from '@/stores/mail'
|
||||
import { useMediaStore } from '@/stores/media'
|
||||
@@ -67,6 +68,7 @@ const phone = usePhoneStore()
|
||||
const account = useAccountStore()
|
||||
const clock = useClockStore()
|
||||
const calls = useCallsStore()
|
||||
const banking = useBankingStore()
|
||||
const mail = useMailStore()
|
||||
const media = useMediaStore()
|
||||
const notes = useNotesStore()
|
||||
@@ -155,6 +157,8 @@ function onMessage(event: MessageEvent<AppMessage>): void {
|
||||
void calls.loadContacts()
|
||||
} else if (event.data?.type === 'calls:changed') {
|
||||
void calls.loadRecents()
|
||||
} else if (event.data?.type === 'banking:changed') {
|
||||
void banking.load()
|
||||
} else if (
|
||||
(event.data?.type === 'call:incoming' ||
|
||||
event.data?.type === 'call:state') &&
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="16" y1="8" x2="112" y2="120" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#3b82f6"/>
|
||||
<stop offset="0.52" stop-color="#1857d9"/>
|
||||
<stop offset="1" stop-color="#081b55"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="shine" x1="26" y1="18" x2="99" y2="106" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#fff" stop-opacity=".95"/>
|
||||
<stop offset="1" stop-color="#cfe1ff" stop-opacity=".82"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="128" height="128" rx="29" fill="url(#bg)"/>
|
||||
<circle cx="103" cy="22" r="35" fill="#6aa4ff" opacity=".2"/>
|
||||
<path d="M26 52 64 29l38 23v8H26v-8Zm7 15h10v25H33V67Zm21 0h10v25H54V67Zm21 0h10v25H75V67Zm21 0h-1v25h-9V67h10ZM25 98h78v10H25V98Z" fill="url(#shine)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 846 B |
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -6,6 +6,7 @@ export type PhoneAppId =
|
||||
| 'camera'
|
||||
| 'clock'
|
||||
| '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 = 'deposit' | 'withdraw' | 'transfer'
|
||||
@@ -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 },
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
kButton,
|
||||
kCard,
|
||||
kPage,
|
||||
kPreloader,
|
||||
kTabbar,
|
||||
kTabbarLink,
|
||||
} from 'konsta/vue'
|
||||
import {
|
||||
ArrowDownLeft,
|
||||
ArrowRight,
|
||||
ArrowUpRight,
|
||||
BarChart3,
|
||||
ChevronRight,
|
||||
CircleDollarSign,
|
||||
House,
|
||||
Landmark,
|
||||
RefreshCw,
|
||||
Send,
|
||||
WalletCards,
|
||||
X,
|
||||
} from 'lucide-vue-next'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import { useBankingStore } from '@/stores/banking'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type {
|
||||
BankingAction,
|
||||
BankingTransaction,
|
||||
BankingTransactionKind,
|
||||
} from '@/types/banking'
|
||||
|
||||
type BankingTab = 'home' | 'activity'
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const banking = useBankingStore()
|
||||
const activeTab = ref<BankingTab>('home')
|
||||
const action = ref<BankingAction | null>(null)
|
||||
const amount = ref('')
|
||||
const target = ref('')
|
||||
const formError = ref('')
|
||||
|
||||
const transactionIcons: Record<BankingTransactionKind, typeof Send> = {
|
||||
deposit: ArrowDownLeft,
|
||||
withdrawal: ArrowUpRight,
|
||||
transfer_in: ArrowDownLeft,
|
||||
transfer_out: ArrowUpRight,
|
||||
}
|
||||
|
||||
const isIncoming = (kind: BankingTransactionKind): boolean =>
|
||||
kind === 'deposit' || kind === 'transfer_in'
|
||||
|
||||
const chart = computed(() => {
|
||||
const days = Array.from({ length: 7 }, (_, offset) => {
|
||||
const date = new Date()
|
||||
date.setHours(0, 0, 0, 0)
|
||||
date.setDate(date.getDate() - (6 - offset))
|
||||
return { date, incoming: 0, outgoing: 0 }
|
||||
})
|
||||
for (const transaction of banking.overview?.transactions ?? []) {
|
||||
const transactionDate = new Date(transaction.createdAt)
|
||||
transactionDate.setHours(0, 0, 0, 0)
|
||||
const day = days.find(
|
||||
(candidate) => candidate.date.getTime() === transactionDate.getTime(),
|
||||
)
|
||||
if (!day) continue
|
||||
if (isIncoming(transaction.kind)) day.incoming += transaction.amount
|
||||
else day.outgoing += transaction.amount
|
||||
}
|
||||
const maximum = Math.max(
|
||||
1,
|
||||
...days.flatMap((day) => [day.incoming, day.outgoing]),
|
||||
)
|
||||
return days.map((day) => ({
|
||||
...day,
|
||||
incomingHeight: Math.max(5, (day.incoming / maximum) * 100),
|
||||
label: new Intl.DateTimeFormat(phone.lang, { weekday: 'narrow' }).format(
|
||||
day.date,
|
||||
),
|
||||
outgoingHeight: Math.max(5, (day.outgoing / maximum) * 100),
|
||||
}))
|
||||
})
|
||||
|
||||
const totals = computed(() =>
|
||||
(banking.overview?.transactions ?? []).reduce(
|
||||
(result, transaction) => {
|
||||
if (isIncoming(transaction.kind)) result.incoming += transaction.amount
|
||||
else result.outgoing += transaction.amount
|
||||
return result
|
||||
},
|
||||
{ incoming: 0, outgoing: 0 },
|
||||
),
|
||||
)
|
||||
|
||||
function formatMoney(value: number, signed = false): string {
|
||||
const formatted = new Intl.NumberFormat(phone.lang, {
|
||||
maximumFractionDigits: 0,
|
||||
minimumFractionDigits: 0,
|
||||
}).format(Math.abs(value))
|
||||
const prefix = signed ? (value >= 0 ? '+' : '−') : ''
|
||||
return `${prefix}${banking.overview?.currency ?? '$'}${formatted}`
|
||||
}
|
||||
|
||||
function formatDate(timestamp: number): string {
|
||||
return new Intl.DateTimeFormat(phone.lang, {
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
month: 'short',
|
||||
}).format(timestamp)
|
||||
}
|
||||
|
||||
function transactionTitle(transaction: BankingTransaction): string {
|
||||
if (transaction.label) return transaction.label
|
||||
return phone.t(`Apps.banking.transactions.${transaction.kind}`)
|
||||
}
|
||||
|
||||
function openAction(nextAction: BankingAction): void {
|
||||
action.value = nextAction
|
||||
amount.value = ''
|
||||
target.value = ''
|
||||
formError.value = ''
|
||||
}
|
||||
|
||||
function closeAction(): void {
|
||||
if (banking.isLoading) return
|
||||
action.value = null
|
||||
}
|
||||
|
||||
function errorMessage(code: string): string {
|
||||
return phone.t(`Apps.banking.errors.${code}`) ===
|
||||
`Apps.banking.errors.${code}`
|
||||
? phone.t('Apps.banking.errors.default')
|
||||
: phone.t(`Apps.banking.errors.${code}`)
|
||||
}
|
||||
|
||||
async function submitAction(): Promise<void> {
|
||||
if (!action.value) return
|
||||
const parsedAmount = Number(amount.value)
|
||||
const parsedTarget = action.value === 'transfer' ? Number(target.value) : undefined
|
||||
if (
|
||||
!Number.isSafeInteger(parsedAmount) ||
|
||||
parsedAmount <= 0 ||
|
||||
(action.value === 'transfer' &&
|
||||
(!Number.isSafeInteger(parsedTarget) || (parsedTarget ?? 0) <= 0))
|
||||
) {
|
||||
formError.value = phone.t('Apps.banking.errors.invalid_request')
|
||||
return
|
||||
}
|
||||
const response = await banking.perform(action.value, parsedAmount, parsedTarget)
|
||||
if (!response.success) {
|
||||
formError.value = errorMessage(response.error ?? 'default')
|
||||
return
|
||||
}
|
||||
action.value = null
|
||||
}
|
||||
|
||||
onMounted(() => void banking.load())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<k-page
|
||||
component="main"
|
||||
class="banking-app"
|
||||
:colors="{ bgIos: 'bg-transparent' }"
|
||||
>
|
||||
<div class="banking-app__aurora" aria-hidden="true"></div>
|
||||
|
||||
<header class="banking-header">
|
||||
<div>
|
||||
<span>{{ phone.t('Apps.banking.welcome') }}</span>
|
||||
<h1>{{ banking.overview?.playerName ?? phone.t('Common.loading') }}</h1>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="banking-profile"
|
||||
:aria-label="phone.t('Apps.banking.refresh')"
|
||||
:disabled="banking.isLoading"
|
||||
@click="banking.load()"
|
||||
>
|
||||
<Landmark :size="20" />
|
||||
<RefreshCw v-if="banking.isLoading" class="banking-spin" :size="10" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="!banking.overview && banking.isLoading" class="banking-loading">
|
||||
<k-preloader />
|
||||
<span>{{ phone.t('Common.loading') }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!banking.overview" class="banking-empty">
|
||||
<Landmark :size="34" />
|
||||
<strong>{{ phone.t('Apps.banking.unavailable') }}</strong>
|
||||
<p>{{ errorMessage(banking.error) }}</p>
|
||||
<k-button rounded @click="banking.load()">
|
||||
{{ phone.t('Apps.banking.tryAgain') }}
|
||||
</k-button>
|
||||
</div>
|
||||
|
||||
<div v-else class="banking-scroll">
|
||||
<template v-if="activeTab === 'home'">
|
||||
<section class="banking-balance">
|
||||
<div class="banking-balance__label">
|
||||
<span>{{ phone.t('Apps.banking.totalBalance') }}</span>
|
||||
<small>#{{ banking.overview.playerId }}</small>
|
||||
</div>
|
||||
<strong>{{ formatMoney(banking.overview.bank) }}</strong>
|
||||
<div class="banking-balance__trend">
|
||||
<span>{{ formatMoney(totals.incoming - totals.outgoing, true) }}</span>
|
||||
{{ phone.t('Apps.banking.recentPeriod') }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="banking-actions" :aria-label="phone.t('Apps.banking.actions')">
|
||||
<button type="button" @click="openAction('transfer')">
|
||||
<span><Send :size="20" /></span>
|
||||
{{ phone.t('Apps.banking.send') }}
|
||||
</button>
|
||||
<button type="button" @click="openAction('deposit')">
|
||||
<span><ArrowDownLeft :size="20" /></span>
|
||||
{{ phone.t('Apps.banking.deposit') }}
|
||||
</button>
|
||||
<button type="button" @click="openAction('withdraw')">
|
||||
<span><ArrowUpRight :size="20" /></span>
|
||||
{{ phone.t('Apps.banking.withdraw') }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<k-card :content-wrap="false" class="banking-card banking-accounts">
|
||||
<div class="banking-section-title">
|
||||
<h2>{{ phone.t('Apps.banking.accounts') }}</h2>
|
||||
<ChevronRight :size="17" />
|
||||
</div>
|
||||
<div class="banking-account-row banking-account-row--primary">
|
||||
<span><WalletCards :size="18" /></span>
|
||||
<div>
|
||||
<small>{{ phone.t('Apps.banking.bankAccount') }}</small>
|
||||
<strong>{{ formatMoney(banking.overview.bank) }}</strong>
|
||||
</div>
|
||||
<span class="banking-card-mark" aria-hidden="true"></span>
|
||||
</div>
|
||||
<div class="banking-account-row">
|
||||
<span><CircleDollarSign :size="18" /></span>
|
||||
<div>
|
||||
<small>{{ phone.t('Apps.banking.cash') }}</small>
|
||||
<strong>{{ formatMoney(banking.overview.cash) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</k-card>
|
||||
|
||||
<section class="banking-transactions">
|
||||
<div class="banking-section-title">
|
||||
<h2>{{ phone.t('Apps.banking.latestTransactions') }}</h2>
|
||||
<button type="button" @click="activeTab = 'activity'">
|
||||
{{ phone.t('Apps.banking.viewAll') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="banking.overview.transactions.length" class="banking-transaction-list">
|
||||
<article
|
||||
v-for="transaction in banking.overview.transactions.slice(0, 5)"
|
||||
:key="transaction.id"
|
||||
class="banking-transaction"
|
||||
>
|
||||
<span :class="{ 'is-incoming': isIncoming(transaction.kind) }">
|
||||
<component :is="transactionIcons[transaction.kind]" :size="17" />
|
||||
</span>
|
||||
<div>
|
||||
<strong>{{ transactionTitle(transaction) }}</strong>
|
||||
<small>{{ formatDate(transaction.createdAt) }}</small>
|
||||
</div>
|
||||
<b :class="{ 'is-incoming': isIncoming(transaction.kind) }">
|
||||
{{ formatMoney(isIncoming(transaction.kind) ? transaction.amount : -transaction.amount, true) }}
|
||||
</b>
|
||||
</article>
|
||||
</div>
|
||||
<p v-else class="banking-no-transactions">
|
||||
{{ phone.t('Apps.banking.noTransactions') }}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<section class="banking-activity-hero">
|
||||
<span>{{ phone.t('Apps.banking.activity') }}</span>
|
||||
<strong>{{ formatMoney(totals.incoming - totals.outgoing, true) }}</strong>
|
||||
<small>{{ phone.t('Apps.banking.recentPeriod') }}</small>
|
||||
</section>
|
||||
|
||||
<k-card :content-wrap="false" class="banking-card banking-chart-card">
|
||||
<div class="banking-chart-legend">
|
||||
<span><i class="is-incoming"></i>{{ phone.t('Apps.banking.incoming') }}</span>
|
||||
<span><i></i>{{ phone.t('Apps.banking.outgoing') }}</span>
|
||||
</div>
|
||||
<div class="banking-chart">
|
||||
<div v-for="day in chart" :key="day.date.getTime()" class="banking-chart__day">
|
||||
<div>
|
||||
<i class="is-incoming" :style="{ height: `${day.incomingHeight}%` }"></i>
|
||||
<i :style="{ height: `${day.outgoingHeight}%` }"></i>
|
||||
</div>
|
||||
<span>{{ day.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</k-card>
|
||||
|
||||
<section class="banking-transactions banking-transactions--all">
|
||||
<div class="banking-section-title">
|
||||
<h2>{{ phone.t('Apps.banking.allTransactions') }}</h2>
|
||||
</div>
|
||||
<div class="banking-transaction-list">
|
||||
<article
|
||||
v-for="transaction in banking.overview.transactions"
|
||||
:key="transaction.id"
|
||||
class="banking-transaction"
|
||||
>
|
||||
<span :class="{ 'is-incoming': isIncoming(transaction.kind) }">
|
||||
<component :is="transactionIcons[transaction.kind]" :size="17" />
|
||||
</span>
|
||||
<div>
|
||||
<strong>{{ transactionTitle(transaction) }}</strong>
|
||||
<small>{{ formatDate(transaction.createdAt) }}</small>
|
||||
</div>
|
||||
<b :class="{ 'is-incoming': isIncoming(transaction.kind) }">
|
||||
{{ formatMoney(isIncoming(transaction.kind) ? transaction.amount : -transaction.amount, true) }}
|
||||
</b>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<k-tabbar
|
||||
v-if="banking.overview"
|
||||
component="nav"
|
||||
icons
|
||||
labels
|
||||
class="banking-tabbar"
|
||||
bg-class="banking-tabbar__background"
|
||||
inner-class="banking-tabbar__inner"
|
||||
:aria-label="phone.t('Apps.banking.navigation')"
|
||||
>
|
||||
<k-tabbar-link
|
||||
component="button"
|
||||
:active="activeTab === 'home'"
|
||||
:label="phone.t('Apps.banking.home')"
|
||||
:link-props="{ type: 'button' }"
|
||||
@click="activeTab = 'home'"
|
||||
>
|
||||
<template #icon><House :size="19" /></template>
|
||||
</k-tabbar-link>
|
||||
<k-tabbar-link
|
||||
component="button"
|
||||
:active="activeTab === 'activity'"
|
||||
:label="phone.t('Apps.banking.activity')"
|
||||
:link-props="{ type: 'button' }"
|
||||
@click="activeTab = 'activity'"
|
||||
>
|
||||
<template #icon><BarChart3 :size="19" /></template>
|
||||
</k-tabbar-link>
|
||||
</k-tabbar>
|
||||
|
||||
<div v-if="action" class="banking-modal-backdrop" @click.self="closeAction">
|
||||
<section class="banking-modal" role="dialog" aria-modal="true">
|
||||
<button
|
||||
type="button"
|
||||
class="banking-modal__close"
|
||||
:aria-label="phone.t('Common.close')"
|
||||
@click="closeAction"
|
||||
>
|
||||
<X :size="17" />
|
||||
</button>
|
||||
<span class="banking-modal__icon">
|
||||
<Send v-if="action === 'transfer'" :size="23" />
|
||||
<ArrowDownLeft v-else-if="action === 'deposit'" :size="23" />
|
||||
<ArrowUpRight v-else :size="23" />
|
||||
</span>
|
||||
<h2>{{ phone.t(`Apps.banking.forms.${action}.title`) }}</h2>
|
||||
<p>{{ phone.t(`Apps.banking.forms.${action}.body`) }}</p>
|
||||
<label v-if="action === 'transfer'">
|
||||
<span>{{ phone.t('Apps.banking.playerId') }}</span>
|
||||
<input
|
||||
v-model="target"
|
||||
inputmode="numeric"
|
||||
min="1"
|
||||
type="number"
|
||||
:placeholder="phone.t('Apps.banking.playerIdPlaceholder')"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ phone.t('Apps.banking.amount') }}</span>
|
||||
<div class="banking-amount-input">
|
||||
<i>{{ banking.overview?.currency }}</i>
|
||||
<input
|
||||
v-model="amount"
|
||||
inputmode="numeric"
|
||||
min="1"
|
||||
type="number"
|
||||
:placeholder="phone.t('Apps.banking.amountPlaceholder')"
|
||||
@keydown.enter="submitAction"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<p v-if="formError" class="banking-form-error">{{ formError }}</p>
|
||||
<k-button
|
||||
large
|
||||
rounded
|
||||
:disabled="banking.isLoading"
|
||||
@click="submitAction"
|
||||
>
|
||||
<k-preloader v-if="banking.isLoading" />
|
||||
<template v-else>
|
||||
{{ phone.t(`Apps.banking.forms.${action}.submit`) }}
|
||||
<ArrowRight :size="17" />
|
||||
</template>
|
||||
</k-button>
|
||||
</section>
|
||||
</div>
|
||||
</k-page>
|
||||
</template>
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sky Phone</title>
|
||||
<script type="module" crossorigin src="./assets/sky-index-D2k1S7R7.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/sky-index-G8JTwQWl.css">
|
||||
<script type="module" crossorigin src="./assets/sky-index-Bf141VPP.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/sky-index-B4mea91c.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user