diff --git a/frontend/.gitignore b/frontend/.gitignore
index b482045..5fd4f20 100644
--- a/frontend/.gitignore
+++ b/frontend/.gitignore
@@ -4,3 +4,6 @@ dist/
*.local
dev-server*.log
+# Image-generation source files retained locally for asset iteration.
+/src/assets/img/weather-icons/source/
+
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 97b52bb..8830222 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -116,6 +116,7 @@ type CalendarReminderData = {
const REFERENCE_VIEWPORT_WIDTH = 1920
const REFERENCE_VIEWPORT_HEIGHT = 1080
const PHONE_BASE_SCALE = 0.69
+const DEVELOPMENT_PHONE_SCALE = 1.25
const isDevelopment = import.meta.env.DEV
const phone = usePhoneStore()
@@ -145,7 +146,10 @@ const controlCenterOpened = ref(false)
const simPicker = ref(null)
const systemColorScheme = window.matchMedia('(prefers-color-scheme: dark)')
const viewportScale = ref(getViewportScale())
-const phoneBaseZoom = computed(() => viewportScale.value * PHONE_BASE_SCALE)
+const phoneBaseZoom = computed(() =>
+ viewportScale.value *
+ (isDevelopment ? DEVELOPMENT_PHONE_SCALE : PHONE_BASE_SCALE),
+)
const phoneResolutionStyle = computed(() => ({
'--phone-edge-gap': `${24 * viewportScale.value}px`,
'--phone-stack-gap': `${16 * viewportScale.value}px`,
diff --git a/frontend/src/assets/img/app-icons/garage.svg b/frontend/src/assets/img/app-icons/garage.svg
new file mode 100644
index 0000000..85e2abb
--- /dev/null
+++ b/frontend/src/assets/img/app-icons/garage.svg
@@ -0,0 +1,20 @@
+
diff --git a/frontend/src/assets/img/weather-icons/clear.webp b/frontend/src/assets/img/weather-icons/clear.webp
new file mode 100644
index 0000000..ea4240c
Binary files /dev/null and b/frontend/src/assets/img/weather-icons/clear.webp differ
diff --git a/frontend/src/assets/img/weather-icons/cloudy.webp b/frontend/src/assets/img/weather-icons/cloudy.webp
new file mode 100644
index 0000000..3f2bc55
Binary files /dev/null and b/frontend/src/assets/img/weather-icons/cloudy.webp differ
diff --git a/frontend/src/assets/img/weather-icons/fog.webp b/frontend/src/assets/img/weather-icons/fog.webp
new file mode 100644
index 0000000..c8b0c59
Binary files /dev/null and b/frontend/src/assets/img/weather-icons/fog.webp differ
diff --git a/frontend/src/assets/img/weather-icons/partly_cloudy.webp b/frontend/src/assets/img/weather-icons/partly_cloudy.webp
new file mode 100644
index 0000000..4015ca9
Binary files /dev/null and b/frontend/src/assets/img/weather-icons/partly_cloudy.webp differ
diff --git a/frontend/src/assets/img/weather-icons/rain.webp b/frontend/src/assets/img/weather-icons/rain.webp
new file mode 100644
index 0000000..93b3116
Binary files /dev/null and b/frontend/src/assets/img/weather-icons/rain.webp differ
diff --git a/frontend/src/assets/img/weather-icons/snow.webp b/frontend/src/assets/img/weather-icons/snow.webp
new file mode 100644
index 0000000..2579ced
Binary files /dev/null and b/frontend/src/assets/img/weather-icons/snow.webp differ
diff --git a/frontend/src/assets/img/weather-icons/sunny.webp b/frontend/src/assets/img/weather-icons/sunny.webp
new file mode 100644
index 0000000..d5a8ab3
Binary files /dev/null and b/frontend/src/assets/img/weather-icons/sunny.webp differ
diff --git a/frontend/src/assets/img/weather-icons/thunder.webp b/frontend/src/assets/img/weather-icons/thunder.webp
new file mode 100644
index 0000000..a61da1e
Binary files /dev/null and b/frontend/src/assets/img/weather-icons/thunder.webp differ
diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css
index 7d21444..cc0bbb3 100644
--- a/frontend/src/assets/main.css
+++ b/frontend/src/assets/main.css
@@ -1736,8 +1736,9 @@ button {
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;
+ radial-gradient(ellipse at 50% -8%, rgb(26 185 187 / 34%), transparent 42%),
+ radial-gradient(circle at 92% 18%, rgb(45 118 255 / 24%), transparent 34%),
+ linear-gradient(180deg, #071a2a 0%, #081220 40%, #060910 100%) !important;
color: #fff;
isolation: isolate;
}
@@ -1746,25 +1747,26 @@ button {
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;
+ background:
+ radial-gradient(circle at 8% 54%, rgb(28 210 183 / 10%), transparent 31%),
+ radial-gradient(circle at 78% 72%, rgb(77 75 255 / 9%), transparent 30%),
+ linear-gradient(180deg, rgb(255 255 255 / 2.5%), transparent 32%);
content: '';
- mask-image: linear-gradient(to bottom, #000, transparent 66%);
+ pointer-events: none;
}
.banking-app__aurora {
position: absolute;
z-index: -1;
- width: 240px;
- height: 180px;
- top: 160px;
- left: -145px;
+ width: 270px;
+ height: 210px;
+ top: 92px;
+ right: -178px;
border-radius: 50%;
- background: #215dea;
- filter: blur(78px);
- opacity: 0.2;
+ background: #2c8cff;
+ filter: blur(88px);
+ opacity: 0.18;
+ pointer-events: none;
}
.banking-navbar {
@@ -1913,7 +1915,8 @@ button {
color: #fff;
}
-.banking-action--primary svg:last-child {
+.banking-action--primary > .lucide-chevron-right {
+ flex: 0 0 auto;
margin-left: auto;
opacity: 0.78;
}
@@ -2112,6 +2115,29 @@ button {
.banking-form-list { margin: 12px 0 0 !important; }
+.banking-contact-picker {
+ margin-top: 12px;
+}
+
+.banking-contact-picker > span {
+ display: block;
+ margin: 0 4px 6px;
+ color: rgb(213 225 248 / 52%);
+ font-size: 9px;
+}
+
+.banking-contact-picker > div {
+ max-height: 118px;
+ margin: 0 !important;
+ overflow-y: auto;
+}
+
+.banking-contact-picker > p {
+ margin: 0 4px;
+ color: rgb(213 225 248 / 42%);
+ font-size: 9px;
+}
+
.banking-sheet__content .banking-form-error {
margin: 10px 3px 0;
color: #ff8b98;
@@ -2274,7 +2300,8 @@ button {
font-size: 11px;
font-weight: 650;
}
-.weather-hour svg {
+.weather-hour svg,
+.weather-hour img {
margin: 5px 0;
}
.weather-hour small {
@@ -2287,52 +2314,6 @@ button {
margin-top: auto;
font-size: 15px;
}
-.weather-day {
- display: grid;
- align-items: center;
- grid-template-columns: 55px 45px 28px 1fr 28px;
- min-height: 48px;
- margin: 0 12px;
- border-bottom: 1px solid #ffffff18;
- font-size: 13px;
-}
-.weather-day:last-child {
- border-bottom: 0;
-}
-.weather-day > strong {
- font-weight: 650;
-}
-.weather-day__condition {
- position: relative;
- display: grid;
- place-items: center;
-}
-.weather-day__condition small {
- position: absolute;
- top: 29px;
- color: #66d2ff;
- font-size: 8px;
- font-weight: 700;
-}
-.weather-day__low {
- color: #ffffff7d;
-}
-.weather-day__range {
- position: relative;
- height: 4px;
- margin: 0 4px;
- overflow: hidden;
- border-radius: 999px;
- background: #07122755;
-}
-.weather-day__range i {
- position: absolute;
- left: var(--range-left);
- width: var(--range-width);
- height: 100%;
- border-radius: inherit;
- background: linear-gradient(90deg, #68d8ff, #7de4b7 55%, #ffd55e);
-}
.weather-empty {
position: absolute;
inset: 90px 20px 25px;
@@ -2344,7 +2325,8 @@ button {
color: #ffffffd9;
text-align: center;
}
-.weather-empty > svg {
+.weather-empty > svg,
+.weather-empty > img {
opacity: 0.75;
}
.weather-spin {
diff --git a/frontend/src/components/PhoneHomeIndicator.vue b/frontend/src/components/PhoneHomeIndicator.vue
index 9618d40..823d2ae 100644
--- a/frontend/src/components/PhoneHomeIndicator.vue
+++ b/frontend/src/components/PhoneHomeIndicator.vue
@@ -6,17 +6,23 @@ import { usePhoneStore } from '@/stores/phone'
const route = useRoute()
const router = useRouter()
const phone = usePhoneStore()
-const isApp = computed(() => route.name === 'app')
+const HOME_PAGE = 1
+const canGoHome = computed(
+ () => route.name !== 'home' || phone.currentPage !== HOME_PAGE,
+)
function goHome(): void {
- if (isApp.value) void router.push('/')
+ if (!canGoHome.value) return
+
+ phone.setCurrentPage(HOME_PAGE)
+ if (route.name !== 'home') void router.push('/')
}
-
-
+
+
{{ phone.t('Apps.weather.feelsLike') }}
{{ forecast.feelsLike }}°
-
+
{{ phone.t('Apps.weather.wind') }}
{{ forecast.windSpeed }} km/h
-
+
{{ phone.t('Apps.weather.humidity') }}
{{ forecast.humidity }}%
-
+
{{ phone.t('Apps.weather.rain') }}
{{ forecast.rainChance }}%
@@ -160,53 +138,30 @@ function temperatureRange(day: DailyWeather): Record {
class="weather-hour"
>
{{ formatHour(hour.timestamp, index) }}
-
{{ hour.rainChance }}%
{{ hour.temperature }}°
-
-
- {{ phone.t('Apps.weather.daily') }}
-
- {{ formatDay(day, index) }}
-
-
- {{ day.rainChance }}%
-
- {{ day.low }}°
-
-
-
- {{ day.high }}°
-
-
- {{ phone.t(weather.isLoading ? 'Common.loading' : 'Apps.weather.unavailable') }}
-
+ {{
+ phone.t(
+ weather.isLoading ? 'Common.loading' : 'Apps.weather.unavailable',
+ )
+ }}
+
{{ phone.t('Apps.weather.tryAgain') }}
diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs
index 29514e4..c4f8958 100644
--- a/frontend/testserver/index.cjs
+++ b/frontend/testserver/index.cjs
@@ -48,13 +48,115 @@ 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 },
+ {
+ 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 mockGarageVehicles = [
+ {
+ id: 'vehicle-1',
+ plate: 'SKY 204',
+ vin: '1S9SKY204LS000001',
+ nickname: 'Midnight',
+ model: 'sultanrs',
+ name: 'Karin Sultan RS',
+ kind: 'car',
+ status: 'garaged',
+ location: 'Legion Square',
+ fuel: 82,
+ engine: 96,
+ body: 91,
+ },
+ {
+ id: 'vehicle-2',
+ plate: 'NOVA 77',
+ vin: '1S9NOVA77LS000002',
+ nickname: '',
+ model: 'comet6',
+ name: 'Pfister Comet S2',
+ kind: 'car',
+ status: 'out',
+ location: '',
+ fuel: 46,
+ engine: 88,
+ body: 73,
+ },
+ {
+ id: 'vehicle-3',
+ plate: 'SEA 911',
+ vin: '1S9SEA911LS000003',
+ nickname: 'Blue Current',
+ model: 'speeder',
+ name: 'Pegassi Speeder',
+ kind: 'boat',
+ status: 'garaged',
+ location: 'La Puerta Pier',
+ fuel: 67,
+ engine: 84,
+ body: 79,
+ },
+ {
+ id: 'vehicle-4',
+ plate: 'AIR 404',
+ vin: '1S9AIR404LS000004',
+ nickname: '',
+ model: 'maverick',
+ name: 'Buckingham Maverick',
+ kind: 'helicopter',
+ status: 'impounded',
+ location: 'Impound Heli',
+ fuel: 23,
+ engine: 58,
+ body: 44,
+ },
+]
+
+let mockGarageValet = null
let contactSequence = 2
const contacts = [
@@ -69,8 +171,13 @@ const contacts = [
const attachmentAssets = {
gif: new Set(['celebrate', 'hearts', 'party', 'thumbs_up', 'wow']),
image: new Set([
- 'camera-1', 'camera-2', 'camera-3', 'city-lights', 'desert-road',
- 'ocean-air', 'sunset-drive',
+ 'camera-1',
+ 'camera-2',
+ 'camera-3',
+ 'city-lights',
+ 'desert-road',
+ 'ocean-air',
+ 'sunset-drive',
]),
video: new Set(['city-loop', 'ocean-loop', 'sunset-loop']),
}
@@ -201,8 +308,26 @@ const darkChatProfile = {
createdAt: '2026-08-01 21:20:00',
}
const darkChatPeers = [
- { id: 2, darkId: 'dark:N0VA-41KQ', alias: 'Nova', originalAlias: 'Nova', avatarSeed: 142, activityVisible: true, isContact: true, blocked: false },
- { id: 3, darkId: 'dark:ECH0-77LM', alias: 'Echo', originalAlias: 'Echo', avatarSeed: 311, activityVisible: false, isContact: true, blocked: false },
+ {
+ id: 2,
+ darkId: 'dark:N0VA-41KQ',
+ alias: 'Nova',
+ originalAlias: 'Nova',
+ avatarSeed: 142,
+ activityVisible: true,
+ isContact: true,
+ blocked: false,
+ },
+ {
+ id: 3,
+ darkId: 'dark:ECH0-77LM',
+ alias: 'Echo',
+ originalAlias: 'Echo',
+ avatarSeed: 311,
+ activityVisible: false,
+ isContact: true,
+ blocked: false,
+ },
]
const darkChatConversations = [
{
@@ -217,28 +342,51 @@ const darkChatConversations = [
]
const darkChatMessages = [
{
- id: 'dc-message-00000000-0000-000000000001', conversationId: darkChatConversations[0].id,
- direction: 'received', senderProfileId: 2, messageType: 'text', body: 'The east gate is clear. Are you close?',
- reactions: {}, createdAt: '2026-08-06 22:42:00', readAt: '2026-08-06 22:43:00',
+ id: 'dc-message-00000000-0000-000000000001',
+ conversationId: darkChatConversations[0].id,
+ direction: 'received',
+ senderProfileId: 2,
+ messageType: 'text',
+ body: 'The east gate is clear. Are you close?',
+ reactions: {},
+ createdAt: '2026-08-06 22:42:00',
+ readAt: '2026-08-06 22:43:00',
},
{
- id: 'dc-message-00000000-0000-000000000002', conversationId: darkChatConversations[0].id,
- direction: 'sent', senderProfileId: 1, messageType: 'text', body: 'Two minutes. Keep this channel quiet. 🟣',
- reactions: { 2: '👍' }, createdAt: '2026-08-06 22:43:00', readAt: '2026-08-06 22:43:30',
+ id: 'dc-message-00000000-0000-000000000002',
+ conversationId: darkChatConversations[0].id,
+ direction: 'sent',
+ senderProfileId: 1,
+ messageType: 'text',
+ body: 'Two minutes. Keep this channel quiet. 🟣',
+ reactions: { 2: '👍' },
+ createdAt: '2026-08-06 22:43:00',
+ readAt: '2026-08-06 22:43:30',
},
{
- id: 'dc-message-00000000-0000-000000000003', conversationId: darkChatConversations[0].id,
- direction: 'received', senderProfileId: 2, messageType: 'gif', body: '',
- mediaPayload: 'https://media.giphy.com/media/ICOgUNjpvO0PC/giphy.gif', reactions: {}, createdAt: '2026-08-06 22:44:00', readAt: null,
+ id: 'dc-message-00000000-0000-000000000003',
+ conversationId: darkChatConversations[0].id,
+ direction: 'received',
+ senderProfileId: 2,
+ messageType: 'gif',
+ body: '',
+ mediaPayload: 'https://media.giphy.com/media/ICOgUNjpvO0PC/giphy.gif',
+ reactions: {},
+ createdAt: '2026-08-06 22:44:00',
+ readAt: null,
},
]
function darkChatBootstrap() {
return {
profile: darkChatProfile,
- contacts: darkChatPeers.filter((peer) => peer.isContact).map((peer) => ({ ...peer, createdAt: '2026-08-03 22:12:00' })),
+ contacts: darkChatPeers
+ .filter((peer) => peer.isContact)
+ .map((peer) => ({ ...peer, createdAt: '2026-08-03 22:12:00' })),
conversations: darkChatConversations.map((conversation) => {
- const thread = darkChatMessages.filter((message) => message.conversationId === conversation.id)
+ const thread = darkChatMessages.filter(
+ (message) => message.conversationId === conversation.id,
+ )
const last = thread.at(-1)
return {
id: conversation.id,
@@ -248,7 +396,9 @@ function darkChatBootstrap() {
lastMessage: last?.body ?? '',
lastMessageType: last?.messageType ?? 'system',
lastMessageAt: last?.createdAt ?? conversation.createdAt,
- unread: thread.filter((message) => message.direction === 'received' && !message.readAt).length,
+ unread: thread.filter(
+ (message) => message.direction === 'received' && !message.readAt,
+ ).length,
}
}),
}
@@ -986,18 +1136,74 @@ app.post('/api/:endpoint', (request, response) => {
response.json({ success: true, data: bankingOverview() })
return
}
- if (endpoint === 'banking:transfer') {
+ if (endpoint === 'garage:vehicles') {
+ response.json({
+ success: true,
+ data: {
+ system: 'jg',
+ valet: {
+ account: 'bank',
+ enabled: true,
+ price: 750,
+ vehicleTypes: {
+ bike: true,
+ boat: false,
+ car: true,
+ helicopter: false,
+ plane: false,
+ },
+ },
+ vehicles: mockGarageVehicles,
+ },
+ })
+ return
+ }
+ if (endpoint === 'garage:valet-state') {
+ response.json({ success: true, data: mockGarageValet })
+ return
+ }
+ if (endpoint === 'garage:valet-request') {
+ const vehicle = mockGarageVehicles.find(
+ (item) => item.plate === request.body.plate,
+ )
+ if (!vehicle || vehicle.status !== 'garaged') {
+ response.json({ success: false, error: 'vehicle_not_garaged' })
+ return
+ }
+ if (!['car', 'bike'].includes(vehicle.kind)) {
+ response.json({ success: false, error: 'valet_vehicle_type' })
+ return
+ }
+ mockGarageValet = {
+ canCancel: true,
+ cost: 750,
+ distance: 1240,
+ etaSeconds: 62,
+ orderId: 'mock-valet-1',
+ plate: vehicle.plate,
+ status: 'en_route',
+ vehicleName: vehicle.nickname || vehicle.name,
+ }
+ response.json({ success: true, data: mockGarageValet })
+ return
+ }
+ if (endpoint === 'garage:valet-cancel') {
+ mockGarageValet = null
+ response.json({ success: true, data: null })
+ 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
+ if (mockBankBalance < amount) {
+ response.json({ success: false, error: 'insufficient_funds' })
+ return
+ }
+ const kind = 'transfer_out'
+ mockBankBalance -= amount
mockBankTransactions.unshift({
amount,
createdAt: Date.now(),
@@ -1015,6 +1221,7 @@ app.post('/api/:endpoint', (request, response) => {
data: {
clock: { year: 2026, month: 8, day: 5, hour: 17, minute: 20 },
condition: 'partly_cloudy',
+ nextCondition: 'rain',
rainLevel: 0.08,
region: 'los_santos',
windSpeed: 3.2,
@@ -1034,21 +1241,27 @@ app.post('/api/:endpoint', (request, response) => {
return
}
if (endpoint === 'darkchat:update-profile') {
- darkChatProfile.alias = String(request.body.alias ?? darkChatProfile.alias).trim()
- darkChatProfile.notificationMode = request.body.notificationMode ?? 'private'
+ darkChatProfile.alias = String(
+ request.body.alias ?? darkChatProfile.alias,
+ ).trim()
+ darkChatProfile.notificationMode =
+ request.body.notificationMode ?? 'private'
darkChatProfile.activityVisible = Boolean(request.body.activityVisible)
response.json({ success: true, data: darkChatProfile })
return
}
if (endpoint === 'darkchat:start') {
const identifier = String(request.body.identifier ?? '').toUpperCase()
- const peer = darkChatPeers.find((item) => item.darkId.toUpperCase() === identifier)
- ?? (identifier === 'DC-ECH0-77LM' ? darkChatPeers[1] : null)
+ const peer =
+ darkChatPeers.find((item) => item.darkId.toUpperCase() === identifier) ??
+ (identifier === 'DC-ECH0-77LM' ? darkChatPeers[1] : null)
if (!peer) {
response.json({ success: false, error: 'profile_not_found' })
return
}
- let conversation = darkChatConversations.find((item) => item.peer.id === peer.id)
+ let conversation = darkChatConversations.find(
+ (item) => item.peer.id === peer.id,
+ )
if (!conversation) {
conversation = {
id: `dc-conversation-${Date.now()}`,
@@ -1065,22 +1278,38 @@ app.post('/api/:endpoint', (request, response) => {
return
}
if (endpoint === 'darkchat:thread') {
- const conversation = darkChatConversations.find((item) => item.id === request.body.conversationId)
+ const conversation = darkChatConversations.find(
+ (item) => item.id === request.body.conversationId,
+ )
if (!conversation) {
response.json({ success: false, error: 'conversation_not_found' })
return
}
- const thread = darkChatMessages.filter((message) => message.conversationId === conversation.id)
+ const thread = darkChatMessages.filter(
+ (message) => message.conversationId === conversation.id,
+ )
for (const message of thread) {
- if (message.direction === 'received') message.readAt = message.readAt ?? new Date().toISOString()
+ if (message.direction === 'received')
+ message.readAt = message.readAt ?? new Date().toISOString()
}
- response.json({ success: true, data: { conversation, messages: thread.map(({ mediaSecret, ...message }) => message) } })
+ response.json({
+ success: true,
+ data: {
+ conversation,
+ messages: thread.map(({ mediaSecret, ...message }) => message),
+ },
+ })
return
}
if (endpoint === 'darkchat:send') {
- const conversation = darkChatConversations.find((item) => item.id === request.body.conversationId)
+ const conversation = darkChatConversations.find(
+ (item) => item.id === request.body.conversationId,
+ )
if (!conversation || conversation.peer.blocked) {
- response.json({ success: false, error: conversation ? 'blocked' : 'conversation_not_found' })
+ response.json({
+ success: false,
+ error: conversation ? 'blocked' : 'conversation_not_found',
+ })
return
}
const messageType = request.body.messageType ?? 'text'
@@ -1089,7 +1318,9 @@ app.post('/api/:endpoint', (request, response) => {
response.json({ success: false, error: 'invalid_message' })
return
}
- const reply = darkChatMessages.find((message) => message.id === request.body.replyToId)
+ const reply = darkChatMessages.find(
+ (message) => message.id === request.body.replyToId,
+ )
const message = {
id: `dc-message-${Date.now()}`,
conversationId: conversation.id,
@@ -1097,8 +1328,10 @@ app.post('/api/:endpoint', (request, response) => {
senderProfileId: darkChatProfile.id,
messageType,
body,
- mediaPayload: messageType === 'gif' ? request.body.mediaPayload : undefined,
- mediaSecret: messageType === 'voice' ? request.body.mediaPayload : undefined,
+ mediaPayload:
+ messageType === 'gif' ? request.body.mediaPayload : undefined,
+ mediaSecret:
+ messageType === 'voice' ? request.body.mediaPayload : undefined,
mediaMime: request.body.mediaMime,
mediaDurationMs: request.body.mediaDurationMs,
mediaWaveform: request.body.mediaWaveform,
@@ -1114,25 +1347,42 @@ app.post('/api/:endpoint', (request, response) => {
return
}
if (endpoint === 'darkchat:media') {
- const message = darkChatMessages.find((item) => item.id === request.body.messageId && item.messageType === 'voice')
- response.json(message?.mediaSecret
- ? { success: true, data: { mime: message.mediaMime, payload: message.mediaSecret } }
- : { success: false, error: 'message_not_found' })
+ const message = darkChatMessages.find(
+ (item) =>
+ item.id === request.body.messageId && item.messageType === 'voice',
+ )
+ response.json(
+ message?.mediaSecret
+ ? {
+ success: true,
+ data: { mime: message.mediaMime, payload: message.mediaSecret },
+ }
+ : { success: false, error: 'message_not_found' },
+ )
return
}
if (endpoint === 'darkchat:react') {
- const message = darkChatMessages.find((item) => item.id === request.body.messageId)
+ const message = darkChatMessages.find(
+ (item) => item.id === request.body.messageId,
+ )
if (message) {
const current = message.reactions[String(darkChatProfile.id)]
- if (current === request.body.reaction) delete message.reactions[String(darkChatProfile.id)]
+ if (current === request.body.reaction)
+ delete message.reactions[String(darkChatProfile.id)]
else message.reactions[String(darkChatProfile.id)] = request.body.reaction
}
- response.json({ success: Boolean(message), error: message ? undefined : 'message_not_found' })
+ response.json({
+ success: Boolean(message),
+ error: message ? undefined : 'message_not_found',
+ })
return
}
if (endpoint === 'darkchat:message-action') {
- const index = darkChatMessages.findIndex((item) => item.id === request.body.messageId)
- if (index >= 0 && request.body.action === 'delete_me') darkChatMessages.splice(index, 1)
+ const index = darkChatMessages.findIndex(
+ (item) => item.id === request.body.messageId,
+ )
+ if (index >= 0 && request.body.action === 'delete_me')
+ darkChatMessages.splice(index, 1)
else if (index >= 0 && request.body.action === 'delete_all') {
darkChatMessages[index].messageType = 'system'
darkChatMessages[index].body = 'message_deleted'
@@ -1142,38 +1392,59 @@ app.post('/api/:endpoint', (request, response) => {
return
}
if (endpoint === 'darkchat:update-conversation') {
- const conversation = darkChatConversations.find((item) => item.id === request.body.conversationId)
+ const conversation = darkChatConversations.find(
+ (item) => item.id === request.body.conversationId,
+ )
if (conversation) {
- conversation.disappearingSeconds = Number(request.body.disappearingSeconds)
- conversation.notificationsEnabled = Boolean(request.body.notificationsEnabled)
+ conversation.disappearingSeconds = Number(
+ request.body.disappearingSeconds,
+ )
+ conversation.notificationsEnabled = Boolean(
+ request.body.notificationsEnabled,
+ )
conversation.readReceipts = Boolean(request.body.readReceipts)
darkChatMessages.push({
- id: `dc-system-${Date.now()}`, conversationId: conversation.id, direction: 'received',
- messageType: 'system', body: `timer_changed:${conversation.disappearingSeconds}`,
- reactions: {}, createdAt: new Date().toISOString().slice(0, 19).replace('T', ' '),
+ id: `dc-system-${Date.now()}`,
+ conversationId: conversation.id,
+ direction: 'received',
+ messageType: 'system',
+ body: `timer_changed:${conversation.disappearingSeconds}`,
+ reactions: {},
+ createdAt: new Date().toISOString().slice(0, 19).replace('T', ' '),
})
}
response.json({ success: Boolean(conversation) })
return
}
- if (endpoint === 'darkchat:add-contact' || endpoint === 'darkchat:remove-contact') {
- const conversation = darkChatConversations.find((item) => item.id === request.body.conversationId)
+ if (
+ endpoint === 'darkchat:add-contact' ||
+ endpoint === 'darkchat:remove-contact'
+ ) {
+ const conversation = darkChatConversations.find(
+ (item) => item.id === request.body.conversationId,
+ )
if (conversation) {
conversation.peer.isContact = endpoint === 'darkchat:add-contact'
- if (request.body.alias) conversation.peer.alias = String(request.body.alias)
+ if (request.body.alias)
+ conversation.peer.alias = String(request.body.alias)
}
response.json({ success: Boolean(conversation) })
return
}
if (endpoint === 'darkchat:block') {
- const conversation = darkChatConversations.find((item) => item.id === request.body.conversationId)
+ const conversation = darkChatConversations.find(
+ (item) => item.id === request.body.conversationId,
+ )
if (conversation) conversation.peer.blocked = Boolean(request.body.blocked)
response.json({ success: Boolean(conversation) })
return
}
if (endpoint === 'darkchat:clear') {
for (let index = darkChatMessages.length - 1; index >= 0; index -= 1) {
- if (darkChatMessages[index].conversationId === request.body.conversationId) darkChatMessages.splice(index, 1)
+ if (
+ darkChatMessages[index].conversationId === request.body.conversationId
+ )
+ darkChatMessages.splice(index, 1)
}
response.json({ success: true })
return
@@ -1209,14 +1480,16 @@ app.post('/api/:endpoint', (request, response) => {
if (endpoint === 'messages:gifs') {
const offset = Math.max(0, Number(request.body.offset ?? 0))
const pageSize = 6
- const results = gifMocks.slice(offset, offset + pageSize).map(([id, title]) => ({
- height: 200,
- id,
- previewUrl: `https://media.giphy.com/media/${id}/200w.gif`,
- title,
- url: `https://media.giphy.com/media/${id}/giphy.gif`,
- width: 200,
- }))
+ const results = gifMocks
+ .slice(offset, offset + pageSize)
+ .map(([id, title]) => ({
+ height: 200,
+ id,
+ previewUrl: `https://media.giphy.com/media/${id}/200w.gif`,
+ title,
+ url: `https://media.giphy.com/media/${id}/giphy.gif`,
+ width: 200,
+ }))
response.json({
success: true,
data: {
@@ -1256,7 +1529,8 @@ app.post('/api/:endpoint', (request, response) => {
message.sender_number === number || message.recipient_number === number,
)
for (const message of thread) {
- if (message.direction === 'received') message.read_at = message.read_at ?? '2026-08-06 13:06:00'
+ if (message.direction === 'received')
+ message.read_at = message.read_at ?? '2026-08-06 13:06:00'
}
response.json({
success: true,
@@ -1313,10 +1587,9 @@ app.post('/api/:endpoint', (request, response) => {
created_at: new Date().toISOString().slice(0, 19).replace('T', ' '),
direction: 'sent',
id: `sms-${Date.now()}`,
- media_duration_ms:
- ['voice', 'video'].includes(messageType)
- ? request.body.mediaDurationMs ?? null
- : null,
+ media_duration_ms: ['voice', 'video'].includes(messageType)
+ ? (request.body.mediaDurationMs ?? null)
+ : null,
media_mime:
messageType === 'voice'
? request.body.mediaMime
@@ -1378,7 +1651,10 @@ app.post('/api/:endpoint', (request, response) => {
if (contact) {
contact.name = name
contact.phone_number = phoneNumber
- contact.updated_at = new Date().toISOString().slice(0, 19).replace('T', ' ')
+ contact.updated_at = new Date()
+ .toISOString()
+ .slice(0, 19)
+ .replace('T', ' ')
} else {
const now = new Date().toISOString().slice(0, 19).replace('T', ' ')
contact = {
diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua
index ddd8e83..193e68d 100644
--- a/sky_phone/config/config.lua
+++ b/sky_phone/config/config.lua
@@ -140,6 +140,37 @@ Config.Banking = {
HistoryLimit = 50,
}
+Config.Garage = {
+ System = "auto", -- auto, custom, esx, qb, qbox, ak47, bp, cd, codem, ds-servercreator, hex, jg, my, okok, op, quasar, rx, vms, ws, zyke_garages
+ MaximumVehicles = 250,
+ RequestsPerMinute = 30,
+ Custom = {
+ Table = "",
+ OwnerColumn = "",
+ },
+ Valet = {
+ Enabled = true,
+ Price = 750,
+ Account = "bank", -- bank or cash
+ RequestsPerMinute = 3,
+ CooldownSeconds = 60,
+ TimeoutSeconds = 180,
+ SpawnDistance = 110.0,
+ ArrivalDistance = 14.0,
+ DriveSpeed = 20.0,
+ DrivingStyle = 786603,
+ DriverModel = "s_m_m_autoshop_01",
+ -- The current valet driver route supports road vehicles. Keep non-road types disabled.
+ VehicleTypes = {
+ car = true,
+ bike = true,
+ boat = false,
+ plane = false,
+ helicopter = false,
+ },
+ },
+}
+
Config.Marketplace = {
PageSize = 20,
MessagePageSize = 50,
diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua
index 1f0d65e..a8e38bf 100644
--- a/sky_phone/config/locales/en.lua
+++ b/sky_phone/config/locales/en.lua
@@ -160,7 +160,8 @@ Locales["en"] = {
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",
+ recipientPhone = "Phone number", recipientPhonePlaceholder = "Enter the recipient's number",
+ chooseContact = "Choose from contacts", noContacts = "No contacts saved yet.",
amount = "Amount", amountPlaceholder = "Enter an amount",
transactions = {
deposit = "Cash deposit", withdrawal = "Cash withdrawal",
@@ -170,13 +171,66 @@ Locales["en"] = {
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.",
+ invalid_request = "Enter a valid phone number and whole amount.", 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.",
},
},
+ garage = {
+ name = "Garage", subtitle = "Your vehicle collection", myVehicles = "My Vehicles",
+ searchPlaceholder = "Search name, plate or garage",
+ tryAgain = "Try Again", unavailable = "Garage unavailable",
+ noVehicles = "No Vehicles", noVehiclesBody = "Vehicles owned by this character will appear here.",
+ noResults = "No Results", noResultsBody = "Try another vehicle, plate or location.",
+ unknownVehicle = "Unknown Vehicle", unknownLocation = "Location unavailable",
+ location = "Current location", fuel = "Fuel", engine = "Engine", body = "Body",
+ vin = "Vehicle Identification Number", notAvailable = "—",
+ provider = "Vehicle data · {system}",
+ valetDelivered = "Your vehicle has arrived.",
+ valet = {
+ title = "Sky Valet", eyebrow = "Premium delivery",
+ body = "A professional driver brings this vehicle directly to your current location.",
+ deliver = "Deliver Vehicle", liveEyebrow = "Live delivery", tracked = "Live tracked",
+ onDemand = "On demand", cancel = "Cancel", confirmTitle = "Request Valet?",
+ confirmBody = "{vehicle} will be delivered to you for {price}.", confirm = "Request Valet",
+ ordering = "Requesting…", account = "The fee is charged to your {account} account.",
+ connecting = "Locating driver", arriving = "Arriving now", eta = "About {seconds}s away",
+ distance = "{distance} m away", unavailableStatus = "Only parked vehicles can be delivered.",
+ unsupported = "Valet delivery is disabled for this vehicle type.",
+ activeOrder = "A valet driver is already delivering another vehicle.",
+ status = { ordering = "Confirming order", preparing = "Preparing vehicle", en_route = "Driver en route",
+ arriving = "Arriving now", delivered = "Vehicle delivered", cancelled = "Delivery cancelled", failed = "Delivery failed" },
+ },
+ filters = { all = "All", garaged = "Parked", out = "Outside", impounded = "Impound" },
+ status = { garaged = "Parked", out = "Outside", impounded = "Impounded" },
+ kinds = { car = "Vehicle", bike = "Motorcycle", boat = "Boat", plane = "Aircraft", helicopter = "Helicopter" },
+ errors = {
+ garage_unavailable = "Vehicle data is unavailable for this character.",
+ valet_disabled = "Valet service is currently unavailable.",
+ valet_active = "A valet delivery is already active.",
+ valet_cooldown = "Please wait before requesting another vehicle.",
+ invalid_vehicle = "Select a valid vehicle.", vehicle_not_owned = "This vehicle does not belong to this character.",
+ vehicle_not_garaged = "This vehicle is not parked in a garage.",
+ valet_vehicle_type = "Valet supports cars and motorcycles only.",
+ insufficient_funds = "You do not have enough money for this delivery.",
+ valet_status_unsupported = "This garage system cannot release the vehicle.",
+ valet_not_found = "The valet order is no longer active.",
+ valet_not_cancellable = "The delivery can no longer be cancelled.",
+ invalid_vehicle_model = "The vehicle model could not be loaded.",
+ vehicle_already_out = "This vehicle is already nearby.",
+ valet_spawn_failed = "The driver could not prepare your vehicle.",
+ valet_no_road = "No suitable road was found near you.",
+ valet_interrupted = "The valet delivery was interrupted.",
+ valet_timeout = "The driver could not reach you in time. Your fee was refunded.",
+ valet_completion_failed = "The handover could not be confirmed.",
+ valet_vehicle_unverified = "The delivered vehicle could not be verified.",
+ rate_limited = "Please wait before refreshing again.",
+ device_not_open = "Open the phone again to view your vehicles.",
+ request_failed = "The garage request failed.", default = "The garage request failed.",
+ },
+ },
calculator = { name = "Calculator" },
snake = {
name = "Snake", backToMenu = "Back to game menu", board = "Snake game board",
@@ -449,7 +503,7 @@ Locales["en"] = {
weather = {
name = "Weather", now = "Now", today = "Today", refresh = "Refresh weather", details = "Weather details",
feelsLike = "Feels Like", wind = "Wind", humidity = "Humidity", rain = "Precipitation",
- hourly = "24-Hour Forecast", daily = "7-Day Forecast", unavailable = "Weather is unavailable",
+ hourly = "Next Hours", unavailable = "Weather is unavailable",
stale = "Showing the last available weather update.", tryAgain = "Try Again",
regions = { los_santos = "Los Santos", blaine_county = "Blaine County", cayo_perico = "Cayo Perico" },
conditions = {
diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua
index f54b9e8..7d798e8 100644
--- a/sky_phone/fxmanifest.lua
+++ b/sky_phone/fxmanifest.lua
@@ -25,6 +25,7 @@ client_scripts {
'source/bridge/client/framework.lua',
'source/bridge/client/callbacks.lua',
'source/client/camera.lua',
+ 'source/client/garage.lua',
'source/bridge/client/radio.lua',
'source/client/main.lua',
'source/client/radio.lua',
@@ -51,6 +52,7 @@ server_scripts {
'source/server/notes.lua',
'source/server/mail.lua',
'source/server/banking.lua',
+ 'source/server/garage.lua',
'source/server/marketplace.lua',
'source/server/pages.lua',
'source/server/calendar.lua',
diff --git a/sky_phone/source/client/garage.lua b/sky_phone/source/client/garage.lua
new file mode 100644
index 0000000..6a40adf
--- /dev/null
+++ b/sky_phone/source/client/garage.lua
@@ -0,0 +1,520 @@
+local current_valet = nil
+
+local vehicle_mods = {
+ modSpoilers = 0,
+ modFrontBumper = 1,
+ modRearBumper = 2,
+ modSideSkirt = 3,
+ modExhaust = 4,
+ modFrame = 5,
+ modGrille = 6,
+ modHood = 7,
+ modFender = 8,
+ modRightFender = 9,
+ modRoof = 10,
+ modEngine = 11,
+ modBrakes = 12,
+ modTransmission = 13,
+ modHorns = 14,
+ modSuspension = 15,
+ modArmor = 16,
+ modFrontWheels = 23,
+ modBackWheels = 24,
+ modPlateHolder = 25,
+ modVanityPlate = 26,
+ modTrimA = 27,
+ modOrnaments = 28,
+ modDashboard = 29,
+ modDial = 30,
+ modDoorSpeaker = 31,
+ modSeats = 32,
+ modSteeringWheel = 33,
+ modShifterLeavers = 34,
+ modAPlate = 35,
+ modSpeakers = 36,
+ modTrunk = 37,
+ modHydrolic = 38,
+ modEngineBlock = 39,
+ modAirFilter = 40,
+ modStruts = 41,
+ modArchCover = 42,
+ modAerials = 43,
+ modTrimB = 44,
+ modTank = 45,
+ modWindows = 46,
+ modLivery = 48,
+}
+
+local function garage_locale()
+ local locale = Locales[Config.Bridge.Locale] or Locales["en"]
+ return locale.Nui.Apps.garage
+end
+
+local function normalized_plate(value)
+ return tostring(value or ""):match("^%s*(.-)%s*$")
+end
+
+local function request_model(model)
+ RequestModel(model)
+ local timeout = GetGameTimer() + 10000
+ while not HasModelLoaded(model) and GetGameTimer() < timeout do
+ Wait(50)
+ end
+ return HasModelLoaded(model)
+end
+
+local function set_vehicle_color(vehicle, primary, secondary)
+ if type(primary) == "table" then
+ SetVehicleCustomPrimaryColour(
+ vehicle,
+ tonumber(primary[1]) or 0,
+ tonumber(primary[2]) or 0,
+ tonumber(primary[3]) or 0
+ )
+ end
+ if type(secondary) == "table" then
+ SetVehicleCustomSecondaryColour(
+ vehicle,
+ tonumber(secondary[1]) or 0,
+ tonumber(secondary[2]) or 0,
+ tonumber(secondary[3]) or 0
+ )
+ end
+ if type(primary) == "number" or type(secondary) == "number" then
+ local current_primary, current_secondary = GetVehicleColours(vehicle)
+ SetVehicleColours(
+ vehicle,
+ tonumber(primary) or current_primary,
+ tonumber(secondary) or current_secondary
+ )
+ end
+end
+
+local function apply_vehicle_properties(vehicle, properties, fallback)
+ properties = type(properties) == "table" and properties or {}
+ SetVehicleModKit(vehicle, 0)
+ set_vehicle_color(vehicle, properties.color1, properties.color2)
+ if properties.pearlescentColor or properties.wheelColor then
+ SetVehicleExtraColours(
+ vehicle,
+ tonumber(properties.pearlescentColor) or 0,
+ tonumber(properties.wheelColor) or 0
+ )
+ end
+ if properties.wheels then
+ SetVehicleWheelType(vehicle, tonumber(properties.wheels) or 0)
+ end
+ if properties.windowTint then
+ SetVehicleWindowTint(vehicle, tonumber(properties.windowTint) or 0)
+ end
+ if properties.xenonColor then
+ SetVehicleXenonLightsColor(vehicle, tonumber(properties.xenonColor) or -1)
+ end
+ for property, mod_type in pairs(vehicle_mods) do
+ local value = tonumber(properties[property])
+ if value then
+ SetVehicleMod(vehicle, mod_type, value, false)
+ end
+ end
+ ToggleVehicleMod(vehicle, 18, properties.modTurbo == true)
+ ToggleVehicleMod(vehicle, 20, properties.modSmokeEnabled == true)
+ ToggleVehicleMod(vehicle, 22, properties.modXenon == true)
+ if type(properties.neonEnabled) == "table" then
+ for index = 0, 3 do
+ SetVehicleNeonLightEnabled(vehicle, index, properties.neonEnabled[index + 1] == true)
+ end
+ end
+ if type(properties.neonColor) == "table" then
+ SetVehicleNeonLightsColour(
+ vehicle,
+ tonumber(properties.neonColor[1]) or 0,
+ tonumber(properties.neonColor[2]) or 0,
+ tonumber(properties.neonColor[3]) or 0
+ )
+ end
+ if type(properties.tyreSmokeColor) == "table" then
+ SetVehicleTyreSmokeColor(
+ vehicle,
+ tonumber(properties.tyreSmokeColor[1]) or 0,
+ tonumber(properties.tyreSmokeColor[2]) or 0,
+ tonumber(properties.tyreSmokeColor[3]) or 0
+ )
+ end
+ if type(properties.extras) == "table" then
+ for extra_id, enabled in pairs(properties.extras) do
+ local numeric_id = tonumber(extra_id)
+ if numeric_id and DoesExtraExist(vehicle, numeric_id) then
+ SetVehicleExtra(vehicle, numeric_id, not enabled)
+ end
+ end
+ end
+ local fuel = tonumber(properties.fuelLevel or properties.fuel or fallback.fuel)
+ local engine = tonumber(properties.engineHealth or properties.engine or fallback.engine and fallback.engine * 10)
+ local body = tonumber(properties.bodyHealth or properties.body or fallback.body and fallback.body * 10)
+ if fuel then
+ SetVehicleFuelLevel(vehicle, math.max(0.0, math.min(100.0, fuel)))
+ end
+ if engine then
+ SetVehicleEngineHealth(vehicle, math.max(-4000.0, math.min(1000.0, engine)))
+ end
+ if body then
+ SetVehicleBodyHealth(vehicle, math.max(0.0, math.min(1000.0, body)))
+ end
+ if properties.dirtLevel then
+ SetVehicleDirtLevel(vehicle, math.max(0.0, math.min(15.0, tonumber(properties.dirtLevel) or 0.0)))
+ end
+ SetVehicleNumberPlateText(vehicle, fallback.plate)
+end
+
+local function existing_vehicle(plate)
+ local normalized = string.upper(normalized_plate(plate))
+ for _, vehicle in ipairs(GetGamePool("CVehicle")) do
+ if string.upper(normalized_plate(GetVehicleNumberPlateText(vehicle))) == normalized then
+ return vehicle
+ end
+ end
+ return nil
+end
+
+local function public_valet_state()
+ if not current_valet then
+ return nil
+ end
+ return {
+ canCancel = current_valet.can_cancel,
+ cost = current_valet.cost,
+ distance = current_valet.distance,
+ etaSeconds = current_valet.eta_seconds,
+ orderId = current_valet.order_id,
+ plate = current_valet.plate,
+ status = current_valet.status,
+ vehicleName = current_valet.vehicle_name,
+ }
+end
+
+local function send_valet_state()
+ SendNUIMessage({
+ type = "garage:valet-status",
+ data = public_valet_state(),
+ })
+end
+
+local function remove_valet_blip()
+ if current_valet and current_valet.blip and DoesBlipExist(current_valet.blip) then
+ RemoveBlip(current_valet.blip)
+ current_valet.blip = nil
+ end
+end
+
+local function delete_valet_entities()
+ if not current_valet then
+ return
+ end
+ remove_valet_blip()
+ if current_valet.driver and DoesEntityExist(current_valet.driver) then
+ DeleteEntity(current_valet.driver)
+ end
+ if current_valet.vehicle and DoesEntityExist(current_valet.vehicle) then
+ DeleteEntity(current_valet.vehicle)
+ end
+end
+
+local function fail_valet(error_code, server_cancel)
+ if not current_valet then
+ return
+ end
+ local order_id = current_valet.order_id
+ current_valet.cancelled = true
+ delete_valet_entities()
+ if server_cancel then
+ Bridge.Callbacks.Trigger("sky_phone:garage:valet-cancel", { orderId = order_id })
+ end
+ current_valet.status = "failed"
+ current_valet.can_cancel = false
+ current_valet.error = error_code
+ send_valet_state()
+ local locale = garage_locale()
+ Bridge.Framework.Notify(locale.name, locale.errors[error_code] or locale.errors.default, "error", 5000)
+ SetTimeout(8000, function()
+ if current_valet and current_valet.order_id == order_id then
+ current_valet = nil
+ send_valet_state()
+ end
+ end)
+end
+
+local function update_drive_target(driver, vehicle)
+ local coords = GetEntityCoords(PlayerPedId())
+ TaskVehicleDriveToCoordLongrange(
+ driver,
+ vehicle,
+ coords.x,
+ coords.y,
+ coords.z,
+ Config.Garage.Valet.DriveSpeed,
+ Config.Garage.Valet.DrivingStyle,
+ Config.Garage.Valet.ArrivalDistance
+ )
+end
+
+local function run_valet_delivery(order)
+ local model = tonumber(order.vehicle.model)
+ if not model and type(order.vehicle.model) == "string" then
+ model = joaat(order.vehicle.model)
+ end
+ local driver_model = joaat(order.driverModel)
+ if not model or not IsModelInCdimage(model) or not IsModelAVehicle(model) then
+ fail_valet("invalid_vehicle_model", true)
+ return
+ end
+ if existing_vehicle(order.vehicle.plate) then
+ fail_valet("vehicle_already_out", true)
+ return
+ end
+ current_valet.status = "preparing"
+ send_valet_state()
+ if not request_model(model) or not request_model(driver_model) then
+ SetModelAsNoLongerNeeded(model)
+ SetModelAsNoLongerNeeded(driver_model)
+ fail_valet("valet_spawn_failed", true)
+ return
+ end
+
+ local player_ped = PlayerPedId()
+ local player_coords = GetEntityCoords(player_ped)
+ local target = GetOffsetFromEntityInWorldCoords(player_ped, 0.0, -Config.Garage.Valet.SpawnDistance, 0.0)
+ local found, spawn_coords, spawn_heading = GetClosestVehicleNodeWithHeading(
+ target.x,
+ target.y,
+ target.z,
+ 1,
+ 3.0,
+ 0
+ )
+ if not found then
+ SetModelAsNoLongerNeeded(model)
+ SetModelAsNoLongerNeeded(driver_model)
+ fail_valet("valet_no_road", true)
+ return
+ end
+
+ local vehicle = CreateVehicle(
+ model,
+ spawn_coords.x,
+ spawn_coords.y,
+ spawn_coords.z,
+ spawn_heading,
+ true,
+ true
+ )
+ if vehicle == 0 or not DoesEntityExist(vehicle) then
+ SetModelAsNoLongerNeeded(model)
+ SetModelAsNoLongerNeeded(driver_model)
+ fail_valet("valet_spawn_failed", true)
+ return
+ end
+ current_valet.vehicle = vehicle
+ SetEntityAsMissionEntity(vehicle, true, true)
+ SetVehicleOnGroundProperly(vehicle)
+ apply_vehicle_properties(vehicle, order.vehicle.properties, order.vehicle)
+ SetVehicleHasBeenOwnedByPlayer(vehicle, true)
+ SetVehicleDoorsLocked(vehicle, 2)
+ SetVehicleEngineOn(vehicle, true, true, false)
+ local network_id = NetworkGetNetworkIdFromEntity(vehicle)
+ SetNetworkIdCanMigrate(network_id, true)
+
+ local driver = CreatePedInsideVehicle(vehicle, 26, driver_model, -1, true, true)
+ if driver == 0 or not DoesEntityExist(driver) then
+ SetModelAsNoLongerNeeded(model)
+ SetModelAsNoLongerNeeded(driver_model)
+ fail_valet("valet_spawn_failed", true)
+ return
+ end
+ current_valet.driver = driver
+ SetEntityAsMissionEntity(driver, true, true)
+ SetBlockingOfNonTemporaryEvents(driver, true)
+ SetPedKeepTask(driver, true)
+ SetDriverAbility(driver, 1.0)
+ SetDriverAggressiveness(driver, 0.0)
+ SetModelAsNoLongerNeeded(model)
+ SetModelAsNoLongerNeeded(driver_model)
+
+ current_valet.blip = AddBlipForEntity(vehicle)
+ SetBlipSprite(current_valet.blip, 225)
+ SetBlipColour(current_valet.blip, 3)
+ SetBlipRoute(current_valet.blip, true)
+ current_valet.status = "en_route"
+ current_valet.can_cancel = true
+ update_drive_target(driver, vehicle)
+ send_valet_state()
+
+ local timeout = GetGameTimer() + (Config.Garage.Valet.TimeoutSeconds * 1000)
+ local next_retask = GetGameTimer() + 8000
+ while current_valet and not current_valet.cancelled and GetGameTimer() < timeout do
+ if not DoesEntityExist(vehicle)
+ or not DoesEntityExist(driver)
+ or IsEntityDead(driver)
+ or not IsVehicleDriveable(vehicle, false)
+ then
+ fail_valet("valet_interrupted", true)
+ return
+ end
+ player_coords = GetEntityCoords(PlayerPedId())
+ local vehicle_coords = GetEntityCoords(vehicle)
+ local distance = #(vehicle_coords - player_coords)
+ current_valet.distance = math.floor(distance + 0.5)
+ current_valet.eta_seconds = math.max(1, math.ceil(distance / math.max(1.0, Config.Garage.Valet.DriveSpeed)))
+ if distance <= Config.Garage.Valet.ArrivalDistance then
+ break
+ end
+ if GetGameTimer() >= next_retask then
+ update_drive_target(driver, vehicle)
+ next_retask = GetGameTimer() + 8000
+ end
+ send_valet_state()
+ Wait(750)
+ end
+ if not current_valet or current_valet.cancelled then
+ return
+ end
+ if GetGameTimer() >= timeout then
+ fail_valet("valet_timeout", true)
+ return
+ end
+
+ current_valet.status = "arriving"
+ current_valet.can_cancel = false
+ current_valet.distance = 0
+ current_valet.eta_seconds = 0
+ send_valet_state()
+ TaskVehicleTempAction(driver, vehicle, 27, 1800)
+ Wait(1800)
+ SetVehicleEngineOn(vehicle, false, true, true)
+ SetVehicleDoorsLocked(vehicle, 1)
+ TaskLeaveVehicle(driver, vehicle, 0)
+
+ local leave_timeout = GetGameTimer() + 5000
+ while IsPedInVehicle(driver, vehicle, false) and GetGameTimer() < leave_timeout do
+ Wait(100)
+ end
+ local driver_target = GetOffsetFromEntityInWorldCoords(vehicle, 4.0, 8.0, 0.0)
+ TaskGoStraightToCoord(
+ driver,
+ driver_target.x,
+ driver_target.y,
+ driver_target.z,
+ 1.0,
+ 10000,
+ GetEntityHeading(driver),
+ 0.5
+ )
+ SetPedKeepTask(driver, true)
+ SetEntityAsNoLongerNeeded(driver)
+ current_valet.driver = nil
+
+ local completion = Bridge.Callbacks.Trigger(
+ "sky_phone:garage:valet-complete",
+ {
+ orderId = current_valet.order_id,
+ networkId = NetworkGetNetworkIdFromEntity(vehicle),
+ }
+ )
+ if not completion or not completion.success then
+ fail_valet("valet_completion_failed", false)
+ return
+ end
+ remove_valet_blip()
+ SetEntityAsNoLongerNeeded(vehicle)
+ current_valet.vehicle = nil
+ current_valet.status = "delivered"
+ current_valet.can_cancel = false
+ send_valet_state()
+ local locale = garage_locale()
+ Bridge.Framework.Notify(locale.name, locale.valetDelivered, "success", 5000)
+ local completed_order_id = current_valet.order_id
+ SetTimeout(12000, function()
+ if current_valet and current_valet.order_id == completed_order_id then
+ current_valet = nil
+ send_valet_state()
+ end
+ end)
+end
+
+RegisterNUICallback("garage:valet-request", function(data, cb)
+ if current_valet then
+ cb({ success = false, error = "valet_active" })
+ return
+ end
+ local result = Bridge.Callbacks.Trigger("sky_phone:garage:valet-request", data)
+ if not result or not result.success or type(result.data) ~= "table" then
+ cb(result or { success = false, error = "request_failed" })
+ return
+ end
+ local vehicle = result.data.vehicle
+ local model_hash = tonumber(vehicle.model)
+ if not model_hash and type(vehicle.model) == "string" then
+ model_hash = joaat(vehicle.model)
+ end
+ local display_name = model_hash and GetDisplayNameFromVehicleModel(model_hash) or nil
+ local label = display_name and GetLabelText(display_name) or nil
+ current_valet = {
+ can_cancel = false,
+ cancelled = false,
+ cost = result.data.cost,
+ distance = nil,
+ eta_seconds = nil,
+ order_id = result.data.orderId,
+ plate = vehicle.plate,
+ status = "ordering",
+ vehicle_name = label and label ~= "NULL" and label ~= "CARNOTFOUND"
+ and label
+ or tostring(vehicle.model),
+ }
+ cb({ success = true, data = public_valet_state() })
+ send_valet_state()
+ CreateThread(function()
+ run_valet_delivery(result.data)
+ end)
+end)
+
+RegisterNUICallback("garage:valet-cancel", function(_, cb)
+ if not current_valet or not current_valet.can_cancel then
+ cb({ success = false, error = "valet_not_cancellable" })
+ return
+ end
+ local order_id = current_valet.order_id
+ local result = Bridge.Callbacks.Trigger("sky_phone:garage:valet-cancel", { orderId = order_id })
+ if not result or not result.success then
+ cb(result or { success = false, error = "request_failed" })
+ return
+ end
+ current_valet.cancelled = true
+ delete_valet_entities()
+ current_valet.status = "cancelled"
+ current_valet.can_cancel = false
+ send_valet_state()
+ cb({ success = true, data = public_valet_state() })
+ SetTimeout(5000, function()
+ if current_valet and current_valet.order_id == order_id then
+ current_valet = nil
+ send_valet_state()
+ end
+ end)
+end)
+
+RegisterNUICallback("garage:valet-state", function(_, cb)
+ cb({ success = true, data = public_valet_state() })
+end)
+
+RegisterNetEvent("sky_phone:garage:valet-aborted", function(error_code)
+ if current_valet then
+ fail_valet(error_code or "valet_timeout", false)
+ end
+end)
+
+AddEventHandler("onResourceStop", function(resource_name)
+ if resource_name ~= GetCurrentResourceName() or not current_valet then
+ return
+ end
+ delete_valet_entities()
+end)
diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua
index 7f3c245..03834d1 100644
--- a/sky_phone/source/client/main.lua
+++ b/sky_phone/source/client/main.lua
@@ -258,10 +258,12 @@ end
RegisterNUICallback("weather:get", function(_, cb)
local coords = GetEntityCoords(PlayerPedId())
local weather_hash = GetPrevWeatherTypeHashName()
+ local next_weather_hash = GetNextWeatherTypeHashName()
cb({
success = true,
data = {
condition = weather_types[weather_hash] or "clear",
+ nextCondition = weather_types[next_weather_hash] or weather_types[weather_hash] or "clear",
region = weather_region(coords),
clock = {
year = GetClockYear(),
@@ -276,6 +278,47 @@ RegisterNUICallback("weather:get", function(_, cb)
})
end)
+local function garage_vehicle_kind(model_hash, fallback)
+ if IsThisModelABoat(model_hash) then
+ return "boat"
+ end
+ if IsThisModelAPlane(model_hash) then
+ return "plane"
+ end
+ if IsThisModelAHeli(model_hash) then
+ return "helicopter"
+ end
+ if IsThisModelABike(model_hash) or IsThisModelABicycle(model_hash) then
+ return "bike"
+ end
+ return fallback
+end
+
+RegisterNUICallback("garage:vehicles", function(data, cb)
+ local result = Bridge.Callbacks.Trigger("sky_phone:garage:vehicles", data)
+ if not result or not result.success or type(result.data) ~= "table" then
+ cb(result or { success = false, error = "request_failed" })
+ return
+ end
+ for _, vehicle in ipairs(result.data.vehicles or {}) do
+ local model_hash = tonumber(vehicle.model)
+ if not model_hash and type(vehicle.model) == "string" and vehicle.model ~= "" then
+ model_hash = joaat(vehicle.model)
+ end
+ if model_hash then
+ local display_name = GetDisplayNameFromVehicleModel(model_hash)
+ local label = display_name and GetLabelText(display_name) or nil
+ if label and label ~= "NULL" and label ~= "CARNOTFOUND" then
+ vehicle.name = label
+ elseif type(vehicle.model) == "string" and vehicle.model ~= "" then
+ vehicle.name = vehicle.model
+ end
+ vehicle.kind = garage_vehicle_kind(model_hash, vehicle.kind)
+ end
+ end
+ cb(result)
+end)
+
for _, callback_name in ipairs(server_callbacks) do
RegisterNUICallback(callback_name, function(data, cb)
local result = Bridge.Callbacks.Trigger("sky_phone:" .. callback_name, data)
diff --git a/sky_phone/source/html/assets/sky-apps-CQMOrZCs.webp b/sky_phone/source/html/assets/sky-apps-CQMOrZCs.webp
deleted file mode 100644
index 14619e1..0000000
Binary files a/sky_phone/source/html/assets/sky-apps-CQMOrZCs.webp and /dev/null differ
diff --git a/sky_phone/source/html/assets/sky-calculator-NafpbqgZ.webp b/sky_phone/source/html/assets/sky-calculator-NafpbqgZ.webp
deleted file mode 100644
index ec93867..0000000
Binary files a/sky_phone/source/html/assets/sky-calculator-NafpbqgZ.webp and /dev/null differ
diff --git a/sky_phone/source/html/assets/sky-clock-kCIOP530.webp b/sky_phone/source/html/assets/sky-clock-kCIOP530.webp
deleted file mode 100644
index 378d674..0000000
Binary files a/sky_phone/source/html/assets/sky-clock-kCIOP530.webp and /dev/null differ
diff --git a/sky_phone/source/html/assets/sky-gallery-CFp9vcDB.webp b/sky_phone/source/html/assets/sky-gallery-CFp9vcDB.webp
deleted file mode 100644
index 123f373..0000000
Binary files a/sky_phone/source/html/assets/sky-gallery-CFp9vcDB.webp and /dev/null differ
diff --git a/sky_phone/source/html/assets/sky-settings-AgEjwcr1.webp b/sky_phone/source/html/assets/sky-settings-AgEjwcr1.webp
deleted file mode 100644
index 11bd0fd..0000000
Binary files a/sky_phone/source/html/assets/sky-settings-AgEjwcr1.webp and /dev/null differ
diff --git a/sky_phone/source/html/index.html b/sky_phone/source/html/index.html
deleted file mode 100644
index dd4e898..0000000
--- a/sky_phone/source/html/index.html
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
-
- Sky Phone
-
-
-
-
-
-
-
-
diff --git a/sky_phone/source/server/banking.lua b/sky_phone/source/server/banking.lua
index 6d79e8b..eb6ccbe 100644
--- a/sky_phone/source/server/banking.lua
+++ b/sky_phone/source/server/banking.lua
@@ -83,6 +83,27 @@ local function notify_changed(source)
TriggerClientEvent("sky_phone:banking:changed", source)
end
+local function online_source_for_phone(number)
+ local rows = Bridge.Database.Query([[
+ SELECT d.`imei`
+ FROM `sky_phone_sims` s
+ INNER JOIN `sky_phone_devices` d ON d.`sim_id` = s.`id`
+ WHERE s.`phone_number` = ?
+ LIMIT 1
+ ]], { number })
+ local device = rows[1]
+ if not device or type(device.imei) ~= "string" then
+ return nil
+ end
+ for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do
+ local target = tonumber(player_source) or player_source
+ if SkyPhone.FindDeviceSlots(target, device.imei)[1] then
+ return target
+ end
+ end
+ return nil
+end
+
Bridge.Callbacks.Register("sky_phone:banking:overview", function(source)
local identifier, error_response = require_banking_session(source)
if not identifier then
@@ -100,16 +121,18 @@ Bridge.Callbacks.Register("sky_phone:banking:transfer", function(source, data)
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
+ local number = SkyPhoneSimNumber.Normalize(
+ data and data.phoneNumber,
+ Config.Sim.NumberLength,
+ Config.Sim.NumberPrefix
+ )
+ if not amount or not number then
return { success = false, error = "invalid_request" }
end
- local target = math.floor(target_value)
+ local target = online_source_for_phone(number)
+ if not target then
+ return { success = false, error = "target_not_found" }
+ end
if target == source then
return { success = false, error = "self_transfer" }
end
diff --git a/sky_phone/source/server/garage.lua b/sky_phone/source/server/garage.lua
new file mode 100644
index 0000000..93a7e3c
--- /dev/null
+++ b/sky_phone/source/server/garage.lua
@@ -0,0 +1,438 @@
+Bridge.Database.AfterMigration("sky_phone", function()
+
+local supported_systems = {
+ auto = true,
+ custom = true,
+ esx = true,
+ qb = true,
+ qbox = true,
+ ak47 = true,
+ bp = true,
+ cd = true,
+ codem = true,
+ ["ds-servercreator"] = true,
+ hex = true,
+ jg = true,
+ my = true,
+ okok = true,
+ op = true,
+ quasar = true,
+ rx = true,
+ vms = true,
+ ws = true,
+ zyke_garages = true,
+}
+
+local function decode_object(value)
+ if type(value) == "table" then
+ return value
+ end
+ if type(value) ~= "string" or value == "" then
+ return {}
+ end
+ local success, decoded = pcall(json.decode, value)
+ if not success or type(decoded) ~= "table" then
+ return {}
+ end
+ return decoded
+end
+
+local function first_value(...)
+ local values = { ... }
+ for index = 1, #values do
+ local value = values[index]
+ if value ~= nil and value ~= "" then
+ return value
+ end
+ end
+ return nil
+end
+
+local function truthy_database_value(value)
+ return value == true or value == 1 or value == "1" or value == "true"
+end
+
+local function normalized_health(value, maximum)
+ local number = tonumber(value)
+ if not number then
+ return nil
+ end
+ if maximum == 1000 then
+ number = number / 10
+ end
+ return math.max(0, math.min(100, math.floor(number + 0.5)))
+end
+
+local function vehicle_status(row, location, garage_system)
+ local state = tonumber(row.state)
+ local location_key = string.lower(tostring(location or ""))
+ if truthy_database_value(row.impound)
+ or truthy_database_value(row.impounded)
+ or truthy_database_value(row.pound)
+ or location_key:find("impound", 1, true)
+ or location_key:find("pound", 1, true)
+ then
+ return "impounded"
+ end
+ if garage_system == "jg" then
+ return truthy_database_value(row.in_garage) and "garaged" or "out"
+ end
+ if state == 2 then
+ return "impounded"
+ end
+ local stored = first_value(row.stored, row.in_garage, row.parked)
+ if truthy_database_value(stored) or state == 1 then
+ return "garaged"
+ end
+ return "out"
+end
+
+local function vehicle_kind(value)
+ local kind = string.lower(tostring(value or ""))
+ if kind == "boat" then
+ return "boat"
+ end
+ if kind == "plane" or kind == "air" or kind == "airplane" then
+ return "plane"
+ end
+ if kind == "heli" or kind == "helicopter" then
+ return "helicopter"
+ end
+ if kind == "bike" or kind == "bicycle" or kind == "motorcycle" then
+ return "bike"
+ end
+ return "car"
+end
+
+local function vehicle_properties(row)
+ local properties = decode_object(first_value(row.mods, row.vehicle_data, row.properties))
+ local vehicle_object = decode_object(row.vehicle)
+ if next(vehicle_object) ~= nil then
+ for key, value in pairs(vehicle_object) do
+ if properties[key] == nil then
+ properties[key] = value
+ end
+ end
+ end
+ return properties
+end
+
+local function vehicle_dto(row, garage_system)
+ local mods = vehicle_properties(row)
+ local vehicle_value = row.vehicle
+
+ local model = first_value(row.model, row.hash, mods.model, mods.hash)
+ if type(vehicle_value) == "string" and vehicle_value:sub(1, 1) ~= "{" then
+ model = first_value(model, vehicle_value)
+ elseif type(vehicle_value) == "number" then
+ model = first_value(model, vehicle_value)
+ end
+ local numeric_model = tonumber(model)
+ if numeric_model then
+ model = numeric_model
+ end
+
+ local location = first_value(row.garage_id, row.parking, row.garage, row.parked_at)
+ local plate = tostring(first_value(row.plate, mods.plate) or ""):match("^%s*(.-)%s*$")
+ return {
+ id = tostring(first_value(row.id, row.vin, plate)),
+ plate = plate,
+ vin = tostring(row.vin or ""),
+ nickname = tostring(row.nickname or ""),
+ model = model,
+ kind = vehicle_kind(first_value(row.garage_type, row.type, mods.type)),
+ status = vehicle_status(row, location, garage_system),
+ location = tostring(location or ""),
+ fuel = normalized_health(first_value(row.fuel, mods.fuelLevel, mods.fuel), 100),
+ engine = normalized_health(first_value(row.engine, mods.engineHealth, mods.engine), 1000),
+ body = normalized_health(first_value(row.body, mods.bodyHealth, mods.body), 1000),
+ }
+end
+
+local function storage_config()
+ local system = tostring(Config.Garage.System or "auto")
+ if not supported_systems[system] then
+ error(("[sky_phone] Unsupported garage system '%s'."):format(system))
+ end
+ if system == "custom" then
+ local table_name = tostring(Config.Garage.Custom.Table or "")
+ local owner_column = tostring(Config.Garage.Custom.OwnerColumn or "")
+ if not table_name:match("^[%w_]+$") or not owner_column:match("^[%w_]+$") then
+ error("[sky_phone] Custom garage table and owner column must be configured with SQL identifiers.")
+ end
+ return table_name, owner_column, system
+ end
+ if system == "esx" then
+ return "owned_vehicles", "owner", system
+ end
+ if system == "qb" or system == "qbox" then
+ return "player_vehicles", "citizenid", system
+ end
+ if system == "auto" and GetResourceState("jg-advancedgarages") == "started" then
+ system = "jg"
+ end
+ if Bridge.Framework.GetName() == "esx" then
+ return "owned_vehicles", "owner", system == "auto" and "esx" or system
+ end
+ return "player_vehicles", "citizenid", system == "auto" and Bridge.Framework.GetName() or system
+end
+
+local active_valets = {}
+local valet_cooldowns = {}
+
+local function normalized_plate(value)
+ if type(value) ~= "string" then
+ return nil
+ end
+ local plate = value:match("^%s*(.-)%s*$")
+ if plate == "" or #plate > 16 or plate:find("[%c]") then
+ return nil
+ end
+ return plate
+end
+
+local function owned_vehicle_row(identifier, plate)
+ local table_name, owner_column, garage_system = storage_config()
+ local rows = Bridge.Database.Query(
+ ("SELECT * FROM %s WHERE %s = ? AND TRIM(plate) = ? LIMIT 1"):format(table_name, owner_column),
+ { identifier, plate }
+ )
+ return rows[1], table_name, owner_column, garage_system
+end
+
+local function status_snapshot(row)
+ local snapshot = {}
+ for _, column in ipairs({ "stored", "state", "in_garage", "parked" }) do
+ if row[column] ~= nil then
+ snapshot[column] = row[column]
+ end
+ end
+ return snapshot
+end
+
+local function write_vehicle_status(order, restore)
+ local assignments = {}
+ local parameters = {}
+ for column, original_value in pairs(order.status) do
+ assignments[#assignments + 1] = ("%s = ?"):format(column)
+ parameters[#parameters + 1] = restore and original_value or 0
+ end
+ if #assignments == 0 then
+ return false
+ end
+ parameters[#parameters + 1] = order.identifier
+ parameters[#parameters + 1] = order.plate
+ Bridge.Database.Query(
+ ("UPDATE %s SET %s WHERE %s = ? AND TRIM(plate) = ?")
+ :format(order.table_name, table.concat(assignments, ", "), order.owner_column),
+ parameters
+ )
+ return true
+end
+
+local function refund_valet(source, order)
+ if order.refunded then
+ return
+ end
+ order.refunded = true
+ if not Bridge.Framework.AddMoney(source, Config.Garage.Valet.Account, order.cost) then
+ Bridge.Debug("error", "[sky_phone] Failed to refund valet order '%s' for player %s.", tostring(order.id), tostring(source))
+ return
+ end
+ TriggerClientEvent("sky_phone:banking:changed", source)
+end
+
+local function cancel_valet(source, order)
+ write_vehicle_status(order, true)
+ refund_valet(source, order)
+ active_valets[source] = nil
+end
+
+Bridge.Callbacks.Register("sky_phone:garage:valet-request", function(source, data)
+ local valet = Config.Garage.Valet
+ if not valet.Enabled then
+ return { success = false, error = "valet_disabled" }
+ end
+ if not SkyPhone.AllowOperation(source, "garage_valet", valet.RequestsPerMinute, 60) then
+ return { success = false, error = "rate_limited" }
+ end
+ local session, error_response = SkyPhone.RequireSession(source)
+ if not session then
+ return error_response
+ end
+ if active_valets[source] then
+ return { success = false, error = "valet_active" }
+ end
+ local now = os.time()
+ if (valet_cooldowns[source] or 0) > now then
+ return {
+ success = false,
+ error = "valet_cooldown",
+ data = { retryAfter = valet_cooldowns[source] - now },
+ }
+ end
+ local plate = normalized_plate(data and data.plate)
+ if not plate then
+ return { success = false, error = "invalid_vehicle" }
+ end
+ local identifier = Bridge.Framework.GetIdentifier(source)
+ if type(identifier) ~= "string" or identifier == "" then
+ return { success = false, error = "garage_unavailable" }
+ end
+ local row, table_name, owner_column, garage_system = owned_vehicle_row(identifier, plate)
+ if not row then
+ return { success = false, error = "vehicle_not_owned" }
+ end
+ local vehicle = vehicle_dto(row, garage_system)
+ if vehicle.status ~= "garaged" then
+ return { success = false, error = "vehicle_not_garaged" }
+ end
+ if not valet.VehicleTypes[vehicle.kind] then
+ return { success = false, error = "valet_vehicle_type" }
+ end
+ local price = math.max(0, math.floor(tonumber(valet.Price) or 0))
+ local balance = tonumber(Bridge.Framework.GetMoney(source, valet.Account)) or 0
+ if balance < price then
+ return { success = false, error = "insufficient_funds" }
+ end
+ local order = {
+ cost = price,
+ expires_at = now + valet.TimeoutSeconds,
+ identifier = identifier,
+ id = ("%s:%s:%s"):format(source, now, math.random(100000, 999999)),
+ owner_column = owner_column,
+ plate = plate,
+ status = status_snapshot(row),
+ table_name = table_name,
+ }
+ if not write_vehicle_status(order, false) then
+ return { success = false, error = "valet_status_unsupported" }
+ end
+ if price > 0 and not Bridge.Framework.RemoveMoney(source, valet.Account, price) then
+ write_vehicle_status(order, true)
+ return { success = false, error = "insufficient_funds" }
+ end
+ active_valets[source] = order
+ TriggerClientEvent("sky_phone:banking:changed", source)
+ return {
+ success = true,
+ data = {
+ cost = price,
+ driverModel = valet.DriverModel,
+ orderId = order.id,
+ vehicle = {
+ body = vehicle.body,
+ engine = vehicle.engine,
+ fuel = vehicle.fuel,
+ kind = vehicle.kind,
+ model = vehicle.model,
+ plate = vehicle.plate,
+ properties = vehicle_properties(row),
+ },
+ },
+ }
+end)
+
+Bridge.Callbacks.Register("sky_phone:garage:valet-cancel", function(source, data)
+ local order = active_valets[source]
+ if not order or type(data) ~= "table" or data.orderId ~= order.id then
+ return { success = false, error = "valet_not_found" }
+ end
+ cancel_valet(source, order)
+ return { success = true, data = { refunded = order.cost } }
+end)
+
+Bridge.Callbacks.Register("sky_phone:garage:valet-complete", function(source, data)
+ local order = active_valets[source]
+ if not order or type(data) ~= "table" or data.orderId ~= order.id then
+ return { success = false, error = "valet_not_found" }
+ end
+
+ local network_id = tonumber(data.networkId)
+ if not network_id or network_id <= 0 or network_id ~= math.floor(network_id) then
+ return { success = false, error = "valet_vehicle_unverified" }
+ end
+ local entity = NetworkGetEntityFromNetworkId(network_id)
+ if entity == 0 or not DoesEntityExist(entity) or tonumber(NetworkGetEntityOwner(entity)) ~= source then
+ return { success = false, error = "valet_vehicle_unverified" }
+ end
+
+ active_valets[source] = nil
+ valet_cooldowns[source] = os.time() + Config.Garage.Valet.CooldownSeconds
+ return { success = true }
+end)
+
+AddEventHandler("playerDropped", function()
+ local source = source
+ local order = active_valets[source]
+ if order then
+ cancel_valet(source, order)
+ end
+ valet_cooldowns[source] = nil
+end)
+
+AddEventHandler("onResourceStop", function(resource_name)
+ if resource_name ~= GetCurrentResourceName() then
+ return
+ end
+ for source, order in pairs(active_valets) do
+ cancel_valet(source, order)
+ end
+end)
+
+CreateThread(function()
+ while true do
+ Wait(5000)
+ local now = os.time()
+ for source, order in pairs(active_valets) do
+ if order.expires_at <= now then
+ cancel_valet(source, order)
+ TriggerClientEvent("sky_phone:garage:valet-aborted", source, "valet_timeout")
+ end
+ end
+ end
+end)
+
+Bridge.Callbacks.Register("sky_phone:garage:vehicles", function(source)
+ if not SkyPhone.AllowOperation(source, "garage_vehicles", Config.Garage.RequestsPerMinute, 60) then
+ return { success = false, error = "rate_limited" }
+ end
+ local session, error_response = SkyPhone.RequireSession(source)
+ if not session then
+ return error_response
+ end
+ local identifier = Bridge.Framework.GetIdentifier(source)
+ if type(identifier) ~= "string" or identifier == "" then
+ return { success = false, error = "garage_unavailable" }
+ end
+ local table_name, owner_column, garage_system = storage_config()
+ local rows = Bridge.Database.Query(
+ ("SELECT * FROM `%s` WHERE `%s` = ? LIMIT ?"):format(table_name, owner_column),
+ { identifier, Config.Garage.MaximumVehicles }
+ )
+ local vehicles = {}
+ for _, row in ipairs(rows) do
+ local vehicle = vehicle_dto(row, garage_system)
+ if vehicle.plate ~= "" then
+ vehicles[#vehicles + 1] = vehicle
+ end
+ end
+ table.sort(vehicles, function(left, right)
+ return left.plate < right.plate
+ end)
+ return {
+ success = true,
+ data = {
+ system = garage_system,
+ valet = {
+ account = Config.Garage.Valet.Account,
+ enabled = Config.Garage.Valet.Enabled,
+ price = math.max(0, math.floor(tonumber(Config.Garage.Valet.Price) or 0)),
+ vehicleTypes = Config.Garage.Valet.VehicleTypes,
+ },
+ vehicles = vehicles,
+ },
+ }
+end)
+
+end)