Merge branch 'dev' into weather-app

This commit is contained in:
Type
2026-08-09 21:57:17 +02:00
19 changed files with 6735 additions and 5 deletions
@@ -0,0 +1,34 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="skyride-bg" x1="62" y1="26" x2="446" y2="488" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFE66A"/>
<stop offset="0.48" stop-color="#FFC62E"/>
<stop offset="1" stop-color="#FF9500"/>
</linearGradient>
<linearGradient id="skyride-car" x1="154" y1="192" x2="352" y2="388" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFFFFF"/>
<stop offset="1" stop-color="#ECEEF2"/>
</linearGradient>
<filter id="skyride-shadow" x="88" y="102" width="346" height="340" filterUnits="userSpaceOnUse">
<feDropShadow dx="0" dy="18" stdDeviation="18" flood-color="#402400" flood-opacity="0.3"/>
</filter>
</defs>
<rect width="512" height="512" rx="112" fill="url(#skyride-bg)"/>
<path d="M63 128C146 88 218 112 251 169C285 226 362 224 452 163" fill="none" stroke="#171717" stroke-opacity="0.15" stroke-width="20" stroke-linecap="round" stroke-dasharray="2 40"/>
<path d="M75 405C145 347 191 354 242 389C292 424 357 435 441 389" fill="none" stroke="#171717" stroke-opacity="0.12" stroke-width="18" stroke-linecap="round" stroke-dasharray="2 38"/>
<g filter="url(#skyride-shadow)">
<path d="M370 104C329 104 296 137 296 178C296 236 370 293 370 293C370 293 444 236 444 178C444 137 411 104 370 104Z" fill="#171717"/>
<circle cx="370" cy="178" r="28" fill="#FFD137"/>
<path d="M183 199C190 181 207 169 226 169H286C305 169 322 181 329 199L350 251C371 260 385 281 385 305V371C385 384 375 394 362 394H342C329 394 319 384 319 371V360H193V371C193 384 183 394 170 394H150C137 394 127 384 127 371V305C127 281 141 260 162 251L183 199Z" fill="url(#skyride-car)"/>
<path d="M207 205C211 196 220 190 230 190H282C292 190 301 196 305 205L320 244H192L207 205Z" fill="#20232A"/>
<path d="M238 151H274C282 151 289 157 289 166V179H223V166C223 157 230 151 238 151Z" fill="#171717"/>
<rect x="235" y="158" width="42" height="13" rx="6.5" fill="#FFD137"/>
<path d="M155 279H357C364 279 370 285 370 292V335C370 342 364 348 357 348H155C148 348 142 342 142 335V292C142 285 148 279 155 279Z" fill="#FFFFFF"/>
<path d="M158 291L205 300L188 325H156C150 325 146 321 146 315V301C146 294 151 290 158 291Z" fill="#171717"/>
<path d="M354 291L307 300L324 325H356C362 325 366 321 366 315V301C366 294 361 290 354 291Z" fill="#171717"/>
<rect x="224" y="312" width="64" height="12" rx="6" fill="#171717"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

+7
View File
@@ -41,6 +41,12 @@ describe('app registry', () => {
labelKey: 'Apps.garage.name',
route: '/apps/garage',
})
expect(PHONE_APPS.find((app) => app.id === 'skyride')).toMatchObject({
category: 'utilities',
gridOrder: 25,
labelKey: 'Apps.skyride.name',
route: '/apps/skyride',
})
expect(PHONE_APPS.find((app) => app.id === 'calendar')).toMatchObject({
gridOrder: 21,
labelKey: 'Apps.calendar.name',
@@ -109,6 +115,7 @@ describe('app registry', () => {
expect(isPhoneAppId('camera')).toBe(true)
expect(isPhoneAppId('photos')).toBe(true)
expect(isPhoneAppId('clock')).toBe(true)
expect(isPhoneAppId('skyride')).toBe(true)
expect(
PHONE_APPS.filter((app) => app.category === 'games').map((app) => app.id),
).toEqual([
+16
View File
@@ -4,6 +4,7 @@ import {
Blocks,
Camera,
CarFront,
CarTaxiFront,
CalendarDays,
Clock3,
Gamepad2,
@@ -58,6 +59,7 @@ import localPagesIcon from '@/assets/img/app-icons/local-pages.webp'
import flareIcon from '@/assets/img/app-icons/flare.svg'
import flipTokIcon from '@/assets/img/app-icons/fliptok.webp'
import picstagramIcon from '@/assets/img/app-icons/picstagram.webp'
import skyRideIcon from '@/assets/img/app-icons/skyride.svg'
import type {
LaunchablePhoneAppDefinition,
LaunchablePhoneAppId,
@@ -219,6 +221,20 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
labelKey: 'Apps.map.name',
route: '/apps/map',
},
{
category: 'utilities',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/SkyRideApp.vue')),
),
dockOrder: null,
gridOrder: 25,
icon: markRaw(CarTaxiFront),
iconClass: 'app-icon--skyride',
iconImage: skyRideIcon,
id: 'skyride',
labelKey: 'Apps.skyride.name',
route: '/apps/skyride',
},
{
category: 'social',
component: markRaw(
+211
View File
@@ -1022,6 +1022,217 @@ const defaultLocales: LocaleTree = {
default: 'The garage request failed.',
},
},
skyride: {
name: 'SkyRide',
loading: 'Loading SkyRide',
unavailable: 'SkyRide unavailable',
tryAgain: 'Try Again',
mode: {
rider: 'Ride',
driver: 'Drive',
},
riderEyebrow: 'Ride anywhere',
whereTo: 'Where to?',
pickup: 'Pickup',
destination: 'Destination',
currentLocation: 'Current location',
notSelected: 'Not selected',
location: 'Choose a location',
chooseLocation: {
pickup: 'Set pickup',
destination: 'Choose destination',
},
chooseOnMap: 'Choose on map',
chooseOnMapBody: 'Tap a precise point on the Los Santos map.',
pickOnMap: {
pickup: 'Tap your pickup point',
destination: 'Tap your destination',
},
tapMapHint: 'Drag and zoom the map, then tap to place the pin.',
mapPoint: 'Map pin',
map: 'Map',
useGps: 'Use your live position',
zoomIn: 'Zoom in',
zoomOut: 'Zoom out',
resetMap: 'Reset map view',
savedPlaces: 'Saved places',
savedPlace: 'Saved place',
quickDestinations: 'Quick destinations',
quickLocations: {
'legion-square': 'Legion Square',
'diamond-casino': 'Diamond Casino',
airport: 'Los Santos Airport',
vinewood: 'Vinewood',
},
viewRides: 'View Ride Options',
change: 'Change',
requestRide: 'Request SkyRide',
serviceMeta: '{eta} min away · {seats} seats',
distanceMeters: '{distance} m',
distanceKilometers: '{distance} km',
distanceMiles: '{distance} mi',
durationMinutes: '{minutes} min',
fare: 'Trip fare',
calculatedFare: 'Calculated',
customFare: 'Own price',
calculatedFareBody: 'Distance, time, and selected service included.',
customFareRange: 'Allowed range: {minimum} to {maximum}',
applyCustomFare: 'Apply Own Price',
perKilometer: '{price} / km',
perMile: '{price} / mi',
rideStatus: 'Ride status',
services: {
taxi: { name: 'Taxi' },
comfort: { name: 'Comfort' },
xl: { name: 'XL' },
premium: { name: 'Premium' },
},
ride: 'SkyRide',
driver: {
online: 'You are online',
offline: 'You are offline',
onlineBody: 'New ride requests can reach you now.',
offlineBody: 'Go online when you are ready to drive.',
toggleStatus: 'Toggle driver status',
today: 'Today',
requests: 'Nearby requests',
accept: 'Accept Ride',
noRequests: 'No nearby requests',
noRequestsBody: 'New requests will appear here automatically.',
goOnline: 'Go online to receive rides',
goOnlineBody: 'Switch your driver status on to see nearby requests.',
statusBody: {
searching: 'A passenger is waiting for a driver.',
accepted: 'Navigate to the passenger pickup.',
driver_arriving: 'You are heading to the passenger.',
arrived: 'Confirm when the passenger is in the vehicle.',
in_progress: 'Follow the route to the destination.',
completed: 'The ride has been completed.',
cancelled: 'This ride was cancelled.',
},
actions: {
arrive: 'I Have Arrived',
start: 'Start Ride',
complete: 'Complete Ride',
},
},
passenger: 'Passenger',
vehicle: 'Vehicle',
navigate: 'Navigate',
call: 'Call',
message: 'Message',
cancelRide: 'Cancel Ride',
cancelTitle: 'Cancel this ride?',
cancelBody:
'Your driver or passenger will be notified. A cancellation fee may apply.',
safetyNote: 'Trip details are shared only while this ride is active.',
waypointSet: 'Route added to your GPS.',
status: {
searching: 'Finding a driver',
accepted: 'Driver found',
driver_arriving: 'Driver en route',
arrived: 'Driver arrived',
in_progress: 'On the way',
completed: 'Completed',
cancelled: 'Cancelled',
},
statusBody: {
searching: 'We are matching you with a nearby driver.',
accepted: 'Your driver is preparing to pick you up.',
driver_arriving: 'Your driver is on the way to your pickup.',
arrived: 'Your driver is waiting at the pickup point.',
in_progress: 'You are on the way to your destination.',
completed: 'You have arrived at your destination.',
cancelled: 'This ride is no longer active.',
},
navigation: 'SkyRide navigation',
tabs: {
home: 'Home',
rides: 'Rides',
activity: 'Activity',
messages: 'Messages',
profile: 'Profile',
},
rides: 'Your Rides',
ridesBody: 'Review completed and cancelled rides.',
noRides: 'No rides yet',
noRidesBody: 'Completed and cancelled rides will appear here.',
activity: 'Activity',
activityBody: 'Recent trip updates and milestones.',
noActivity: 'No recent activity',
noActivityBody: 'Ride updates will appear here.',
messages: 'Messages',
messagesBody: 'Contact your active driver or passenger.',
noMessages: 'No active contact',
noMessagesBody: 'Book or accept a ride to contact the other person.',
openMessages: 'Open Messages',
account: 'Account',
paymentMethod: 'Payment Method',
paymentMethods: {
bank: 'Bank',
cash: 'Cash',
default: 'Payment method',
},
safety: 'Safety Center',
safetyBody: 'Help, trip sharing, and emergency information',
driverMode: 'Driver Mode',
driverModeBody: 'Accept ride requests and track your earnings',
completedRides: 'Completed',
cancelledRides: 'Cancelled',
acceptance: 'Acceptance',
rating: 'Rating',
notAvailable: 'N/A',
memberSince: 'SkyRide member since {date}',
rideComplete: 'Ride Complete',
rateRide: 'Rate your ride',
rateRideBody: 'How was your trip? Your feedback helps every rider.',
ratingValue: '{rating} stars',
tip: 'Add a tip',
noTip: 'No tip',
comment: 'Comment',
commentPlaceholder: 'Share an optional note...',
submitRating: 'Submit Rating',
notNow: 'Not Now',
ratingSaved: 'Thanks for your feedback.',
errors: {
skyride_unavailable: 'SkyRide is currently unavailable.',
invalid_location: 'Choose a valid pickup and destination.',
pickup_too_far: 'Move closer to the selected pickup point.',
invalid_route: 'No suitable route was found for this ride.',
invalid_custom_fare: 'Choose an own price inside the allowed range.',
quote_not_found: 'These ride options are no longer available.',
quote_expired: 'These ride options expired. Request a new quote.',
active_ride_exists: 'Finish or cancel your active ride first.',
invalid_driver_status: 'That driver status is not available.',
driver_not_eligible: 'This account is not eligible for Driver Mode.',
driver_offline: 'Go online before accepting a ride.',
driver_unavailable: 'That driver is no longer available.',
driver_vehicle_required:
"Sit in the driver's seat of a vehicle before accepting a ride.",
ride_not_found: 'This ride is no longer available.',
invalid_ride_status: 'This action is not available for the ride.',
not_ride_participant: 'You are not part of this ride.',
driver_too_far: 'Move closer to the required ride location.',
passenger_too_far: 'The passenger is not close enough to start.',
destination_too_far:
'Move closer to the destination to complete the ride.',
payout_failed: 'The driver payout could not be completed.',
refund_failed: 'The ride refund could not be completed.',
invalid_cancel_reason: 'Choose a valid cancellation reason.',
invalid_rating: 'Choose a rating from one to five stars.',
invalid_tip: 'Choose a valid tip amount.',
invalid_comment: 'The rating comment is not valid.',
tip_failed: 'The tip could not be processed.',
insufficient_funds:
'Your selected payment method has insufficient funds.',
rate_limited: 'Please wait a moment before trying again.',
device_not_open: 'Open the phone again to use SkyRide.',
device_not_owned: 'This phone does not belong to this character.',
device_locked: 'Unlock this phone to use SkyRide.',
request_failed: 'SkyRide could not complete the request.',
default: 'SkyRide could not complete the request.',
},
},
calculator: { name: 'Calculator' },
snake: {
name: 'Snake',
+203
View File
@@ -0,0 +1,203 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSkyRideStore } from '@/stores/skyride'
import type {
SkyRideBootstrap,
SkyRideLocation,
SkyRideQuote,
SkyRideRide,
} from '@/types/skyride'
import { nuiCall } from '@/utils/nui'
vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
const mockNuiCall = vi.mocked(nuiCall)
const pickup: SkyRideLocation = {
coords: { x: -1037.7, y: -2737.8, z: 20.1 },
label: 'Los Santos International',
}
const destination: SkyRideLocation = {
coords: { x: 215.8, y: -810.1, z: 30.7 },
label: 'Pillbox Hill',
}
const ride: SkyRideRide = {
createdAt: 1_755_000_000,
currency: '$',
destination,
driver: null,
id: 'ride-1',
pickup,
price: 48,
serviceClass: 'comfort',
status: 'searching',
updatedAt: 1_755_000_000,
}
const bootstrap: SkyRideBootstrap = {
activeRide: null,
availableRequests: [ride],
driverEligible: true,
driverOnline: false,
history: [],
pendingRating: null,
profile: {
acceptanceRate: 94,
avatarUrl: null,
cancelledRides: 2,
completedRides: 128,
currency: '$',
defaultPaymentMethod: 'Bank',
earningsToday: 320,
id: 'profile-1',
memberSince: 1_700_000_000,
name: 'Alex Morgan',
rating: 4.9,
},
quickLocations: [destination],
}
const quote: SkyRideQuote = {
destination,
distance: 5.2,
distanceMeters: 5200,
distanceUnit: 'kilometer',
durationSeconds: 640,
expiresAt: 1_755_000_120,
options: [
{
available: true,
calculatedPrice: 48,
currency: '$',
etaMinutes: 4,
fareMode: 'calculated',
maximumCustomPrice: 144,
minimumCustomPrice: 24,
price: 48,
pricePerDistanceUnit: 24,
quoteId: 'quote-comfort',
seats: 4,
serviceClass: 'comfort',
},
],
pickup,
}
describe('SkyRide store', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockNuiCall.mockReset()
})
it('hydrates the complete server-authoritative snapshot', async () => {
mockNuiCall.mockResolvedValueOnce({ data: bootstrap, success: true })
const skyride = useSkyRideStore()
skyride.quote = quote
expect(await skyride.bootstrap()).toBe(true)
expect(skyride.profile).toEqual(bootstrap.profile)
expect(skyride.driverEligible).toBe(true)
expect(skyride.availableRequests).toEqual([ride])
expect(skyride.quote).toBeNull()
expect(mockNuiCall).toHaveBeenCalledWith('skyride:bootstrap')
})
it('requests a quote with pickup and destination coordinates', async () => {
mockNuiCall.mockResolvedValueOnce({ data: quote, success: true })
const skyride = useSkyRideStore()
expect((await skyride.createQuote(pickup, destination)).success).toBe(true)
expect(skyride.quote).toEqual(quote)
expect(mockNuiCall).toHaveBeenCalledWith('skyride:quote', {
destination,
pickup,
})
})
it('books with the opaque quote id and never sends a client price', async () => {
mockNuiCall.mockResolvedValueOnce({
data: { activeRide: ride },
success: true,
})
const skyride = useSkyRideStore()
skyride.quote = quote
const response = await skyride.requestRide(quote.options[0])
expect(response.success).toBe(true)
expect(skyride.activeRide).toEqual(ride)
expect(skyride.quote).toBeNull()
expect(mockNuiCall).toHaveBeenCalledWith('skyride:request', {
quoteId: 'quote-comfort',
})
})
it('asks the server to bind a validated own price to a new quote', async () => {
const customQuote = {
...quote,
options: [
{
...quote.options[0],
fareMode: 'custom' as const,
price: 60,
quoteId: 'quote-comfort-custom',
},
],
}
mockNuiCall.mockResolvedValueOnce({ data: customQuote, success: true })
const skyride = useSkyRideStore()
await skyride.createQuote(pickup, destination, {
price: 60,
serviceClass: 'comfort',
})
expect(mockNuiCall).toHaveBeenCalledWith('skyride:quote', {
customFare: { price: 60, serviceClass: 'comfort' },
destination,
pickup,
})
})
it('preserves the active ride when a lifecycle action is rejected', async () => {
mockNuiCall.mockResolvedValueOnce({
error: 'ride_state_changed',
success: false,
})
const skyride = useSkyRideStore()
skyride.activeRide = ride
await skyride.performRideAction('start', ride.id)
expect(skyride.activeRide).toEqual(ride)
expect(skyride.error).toBe('ride_state_changed')
})
it('applies live snapshots including an explicit cleared ride', () => {
const skyride = useSkyRideStore()
skyride.activeRide = ride
skyride.driverOnline = false
skyride.applyUpdate({ activeRide: null, driverOnline: true })
expect(skyride.activeRide).toBeNull()
expect(skyride.driverOnline).toBe(true)
})
it('submits rating details and clears the pending rating on success', async () => {
mockNuiCall.mockResolvedValueOnce({
data: { history: [{ ...ride, status: 'completed' }] },
success: true,
})
const skyride = useSkyRideStore()
skyride.pendingRating = { ...ride, status: 'completed' }
await skyride.rateRide(ride.id, 5, 10, 'Excellent')
expect(skyride.pendingRating).toBeNull()
expect(mockNuiCall).toHaveBeenCalledWith('skyride:rate', {
comment: 'Excellent',
rating: 5,
rideId: ride.id,
tip: 10,
})
})
})
+204
View File
@@ -0,0 +1,204 @@
import { defineStore } from 'pinia'
import type {
SkyRideBootstrap,
SkyRideCoordinates,
SkyRideCustomFareInput,
SkyRideHistoryResponse,
SkyRideLocation,
SkyRideQuote,
SkyRideQuoteOption,
SkyRideRide,
SkyRideStateUpdate,
} from '@/types/skyride'
import { nuiCall, type NuiResponse } from '@/utils/nui'
type RideAction = 'accept' | 'arrive' | 'start' | 'complete'
export const useSkyRideStore = defineStore('skyride', {
state: () => ({
activeRide: null as SkyRideRide | null,
availableRequests: [] as SkyRideRide[],
driverEligible: false,
driverOnline: false,
error: '',
history: [] as SkyRideRide[],
isActionPending: false,
isLoading: false,
pendingRating: null as SkyRideRide | null,
profile: null as SkyRideBootstrap['profile'] | null,
quickLocations: [] as SkyRideLocation[],
quote: null as SkyRideQuote | null,
}),
actions: {
applyUpdate(update: SkyRideStateUpdate): void {
if (update.activeRide !== undefined) this.activeRide = update.activeRide
if (update.availableRequests !== undefined)
this.availableRequests = update.availableRequests
if (update.driverOnline !== undefined)
this.driverOnline = update.driverOnline
if (update.history !== undefined) this.history = update.history
if (update.pendingRating !== undefined)
this.pendingRating = update.pendingRating
if (update.profile !== undefined) this.profile = update.profile
},
async bootstrap(): Promise<boolean> {
this.isLoading = true
const response = await nuiCall<SkyRideBootstrap>('skyride:bootstrap')
this.isLoading = false
if (!response.success || !response.data) {
this.error = response.error ?? 'request_failed'
return false
}
this.activeRide = response.data.activeRide
this.availableRequests = response.data.availableRequests
this.driverEligible = response.data.driverEligible
this.driverOnline = response.data.driverOnline
this.history = response.data.history
this.pendingRating = response.data.pendingRating
this.profile = response.data.profile
this.quickLocations = response.data.quickLocations
this.quote = null
this.error = ''
return true
},
async loadHistory(): Promise<boolean> {
const response = await nuiCall<SkyRideHistoryResponse>('skyride:history')
if (!response.success || !response.data) {
this.error = response.error ?? 'request_failed'
return false
}
this.history = response.data.items
this.error = ''
return true
},
async createQuote(
pickup: SkyRideLocation,
destination: SkyRideLocation,
customFare?: SkyRideCustomFareInput,
): Promise<NuiResponse<SkyRideQuote>> {
this.isActionPending = true
const response = await nuiCall<SkyRideQuote>('skyride:quote', {
...(customFare ? { customFare } : {}),
destination,
pickup,
})
this.isActionPending = false
if (response.success && response.data) {
this.quote = response.data
this.error = ''
} else {
this.error = response.error ?? 'request_failed'
}
return response
},
async requestRide(
option: Pick<SkyRideQuoteOption, 'quoteId'>,
): Promise<NuiResponse<SkyRideStateUpdate>> {
this.isActionPending = true
const response = await nuiCall<SkyRideStateUpdate>('skyride:request', {
quoteId: option.quoteId,
})
this.isActionPending = false
if (response.success) {
if (response.data) this.applyUpdate(response.data)
this.quote = null
this.error = ''
} else {
this.error = response.error ?? 'request_failed'
}
return response
},
async setDriverStatus(
online: boolean,
): Promise<NuiResponse<SkyRideStateUpdate>> {
this.isActionPending = true
const response = await nuiCall<SkyRideStateUpdate>(
'skyride:set-driver-status',
{ online },
)
this.isActionPending = false
if (response.success) {
this.driverOnline = response.data?.driverOnline ?? online
if (response.data) this.applyUpdate(response.data)
this.error = ''
} else {
this.error = response.error ?? 'request_failed'
}
return response
},
async performRideAction(
action: RideAction,
rideId: string,
): Promise<NuiResponse<SkyRideStateUpdate>> {
this.isActionPending = true
const response = await nuiCall<SkyRideStateUpdate>(`skyride:${action}`, {
rideId,
})
this.isActionPending = false
if (response.success) {
if (response.data) this.applyUpdate(response.data)
this.error = ''
} else {
this.error = response.error ?? 'request_failed'
}
return response
},
async cancelRide(
rideId: string,
reason?: string,
): Promise<NuiResponse<SkyRideStateUpdate>> {
this.isActionPending = true
const response = await nuiCall<SkyRideStateUpdate>('skyride:cancel', {
rideId,
...(reason ? { reason } : {}),
})
this.isActionPending = false
if (response.success) {
if (response.data) this.applyUpdate(response.data)
this.error = ''
} else {
this.error = response.error ?? 'request_failed'
}
return response
},
async rateRide(
rideId: string,
rating: number,
tip: number,
comment: string,
): Promise<NuiResponse<SkyRideStateUpdate>> {
this.isActionPending = true
const response = await nuiCall<SkyRideStateUpdate>('skyride:rate', {
comment,
rating,
rideId,
tip,
})
this.isActionPending = false
if (response.success) {
if (response.data) this.applyUpdate(response.data)
this.pendingRating = null
this.error = ''
} else {
this.error = response.error ?? 'request_failed'
}
return response
},
async getPlayerCoordinates(): Promise<
NuiResponse<{ coords: SkyRideCoordinates }>
> {
return nuiCall<{ coords: SkyRideCoordinates }>(
'skyride:get-player-coords',
)
},
async setWaypoint(coords: SkyRideCoordinates): Promise<NuiResponse> {
return nuiCall('map:setWaypoint', { coords })
},
clearQuote(): void {
this.quote = null
this.error = ''
},
},
})
+1
View File
@@ -30,6 +30,7 @@ export type PhoneAppId =
| 'flare'
| 'fliptok'
| 'picstagram'
| 'skyride'
export type LaunchablePhoneAppId = PhoneAppId
+145
View File
@@ -0,0 +1,145 @@
export type SkyRideMode = 'rider' | 'driver'
export type SkyRideServiceClass = 'taxi' | 'comfort' | 'xl' | 'premium'
export type SkyRideDistanceUnit = 'kilometer' | 'mile'
export type SkyRideFareMode = 'calculated' | 'custom'
export type SkyRideRideStatus =
| 'searching'
| 'accepted'
| 'driver_arriving'
| 'arrived'
| 'in_progress'
| 'completed'
| 'cancelled'
export type SkyRideCoordinates = {
x: number
y: number
z: number
}
export type SkyRideLocation = {
coords: SkyRideCoordinates
id?: string
label: string
}
export type SkyRidePerson = {
avatarUrl: string | null
id: string
name: string
phoneNumber?: string
rating: number
trips: number
}
export type SkyRideVehicle = {
color: string
model: string
plate: string
}
export type SkyRideDriver = SkyRidePerson & {
location?: SkyRideCoordinates
vehicle: SkyRideVehicle
}
export type SkyRidePassenger = Pick<
SkyRidePerson,
'avatarUrl' | 'id' | 'name' | 'phoneNumber' | 'rating' | 'trips'
>
export type SkyRideQuoteOption = {
available: boolean
calculatedPrice: number
currency: string
etaMinutes: number
fareMode: SkyRideFareMode
maximumCustomPrice: number
minimumCustomPrice: number
price: number
pricePerDistanceUnit: number
quoteId: string
seats: number
serviceClass: SkyRideServiceClass
}
export type SkyRideCustomFareInput = {
price: number
serviceClass: SkyRideServiceClass
}
export type SkyRideQuote = {
destination: SkyRideLocation
distance: number
distanceMeters: number
distanceUnit: SkyRideDistanceUnit
durationSeconds: number
expiresAt: number
options: SkyRideQuoteOption[]
pickup: SkyRideLocation
}
export type SkyRideRide = {
createdAt: number
currency: string
destination: SkyRideLocation
driver: SkyRideDriver | null
finalPrice?: number
id: string
passenger?: SkyRidePassenger
pickup: SkyRideLocation
price: number
serviceClass: SkyRideServiceClass
status: SkyRideRideStatus
updatedAt: number
}
export type SkyRideProfile = {
acceptanceRate: number | null
avatarUrl: string | null
cancelledRides: number
completedRides: number
currency: string
defaultPaymentMethod: string
earningsToday: number | null
id: string
memberSince: number
name: string
rating: number
}
export type SkyRideBootstrap = {
activeRide: SkyRideRide | null
availableRequests: SkyRideRide[]
driverEligible: boolean
driverOnline: boolean
history: SkyRideRide[]
pendingRating: SkyRideRide | null
profile: SkyRideProfile
quickLocations: SkyRideLocation[]
}
export type SkyRideStateUpdate = Partial<
Pick<
SkyRideBootstrap,
| 'activeRide'
| 'availableRequests'
| 'driverOnline'
| 'history'
| 'pendingRating'
| 'profile'
>
>
export type SkyRideHistoryResponse = {
items: SkyRideRide[]
}
export type SkyRideChangedMessage = {
data: SkyRideStateUpdate
type: 'skyride:changed'
}
+1
View File
@@ -89,6 +89,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
weather: { enabled: true, sounds: true },
banking: { enabled: true, sounds: true },
garage: { enabled: true, sounds: true },
skyride: { enabled: true, sounds: true },
mail: { enabled: true, sounds: true },
map: { enabled: true, sounds: true },
notes: { enabled: true, sounds: true },
+1 -1
View File
@@ -89,7 +89,7 @@ const darkMessagebarColors = {
toolbarIconIos: 'fill-[#0a84ff]',
}
const darkSearchbarColors = {
inputBgIos: 'bg-[#1c1c1e]',
inputBgIos: 'bg-transparent',
placeholderIos: 'placeholder-[#8e8e93]',
}
const darkSheetColors = {
File diff suppressed because it is too large Load Diff
+524
View File
@@ -18,6 +18,10 @@ function isoTime(offsetMilliseconds) {
return new Date(Date.now() + offsetMilliseconds).toISOString()
}
function unixTime(offsetSeconds = 0) {
return Math.floor(Date.now() / 1000) + offsetSeconds
}
let authenticated = true
let draft = null
const radioData = {
@@ -464,6 +468,155 @@ const mockGarageVehicles = [
let mockGarageValet = null
const skyRideProfile = {
acceptanceRate: 96,
avatarUrl:
'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=240&q=80',
cancelledRides: 3,
completedRides: 146,
currency: '$',
defaultPaymentMethod: 'bank',
earningsToday: 284,
id: 'skyride-profile-demo',
memberSince: unixTime(-214 * 24 * 60 * 60),
name: 'Alex Morgan',
rating: 4.92,
}
const skyRideDriver = {
avatarUrl:
'https://images.unsplash.com/photo-1531123897727-8f129e1688ce?auto=format&fit=crop&w=240&q=80',
id: 'skyride-driver-nova',
location: { x: -121.8, y: -904.6, z: 29.3 },
name: 'Nova Hayes',
phoneNumber: '5550199',
rating: 4.97,
trips: 842,
vehicle: {
color: 'Obsidian Black',
model: 'Enus Deity',
plate: 'RIDE 01',
},
}
const skyRidePassenger = {
avatarUrl: skyRideProfile.avatarUrl,
id: skyRideProfile.id,
name: skyRideProfile.name,
phoneNumber: '5550142',
rating: skyRideProfile.rating,
trips: skyRideProfile.completedRides,
}
function skyRideWithoutLiveContact(ride) {
const passenger = { ...ride.passenger }
delete passenger.phoneNumber
let driver = null
if (ride.driver) {
driver = { ...ride.driver }
delete driver.location
delete driver.phoneNumber
}
return { ...ride, driver, passenger }
}
const skyRideQuickLocations = [
{
coords: { x: -265.1, y: -960.2, z: 31.2 },
id: 'legion-square',
label: 'Legion Square',
},
{
coords: { x: 925.2, y: 46.4, z: 81.1 },
id: 'diamond-casino',
label: 'Diamond Casino',
},
{
coords: { x: -1037.7, y: -2737.8, z: 20.2 },
id: 'airport',
label: 'Los Santos Airport',
},
{
coords: { x: -594.4, y: -929.9, z: 23.9 },
id: 'vinewood',
label: 'Vinewood',
},
]
let skyRideSequence = 4
let skyRideDriverOnline = false
let skyRideActiveRide = null
let skyRidePendingRating = null
let skyRideAvailableRequests = [
{
createdAt: unixTime(-90),
currency: '$',
destination: skyRideQuickLocations[1],
driver: null,
id: 'skyride-request-demo',
passenger: {
avatarUrl:
'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=240&q=80',
id: 'skyride-passenger-jordan',
name: 'Jordan Lee',
rating: 4.84,
trips: 38,
},
pickup: skyRideQuickLocations[0],
price: 42,
serviceClass: 'comfort',
status: 'searching',
updatedAt: unixTime(-90),
},
]
let skyRideHistory = [
skyRideWithoutLiveContact({
createdAt: unixTime(-22 * 60 * 60),
currency: '$',
destination: skyRideQuickLocations[3],
driver: skyRideDriver,
finalPrice: 31,
id: 'skyride-history-1',
passenger: skyRidePassenger,
pickup: skyRideQuickLocations[0],
price: 31,
serviceClass: 'taxi',
status: 'completed',
updatedAt: unixTime(-21 * 60 * 60),
}),
skyRideWithoutLiveContact({
createdAt: unixTime(-3 * 24 * 60 * 60),
currency: '$',
destination: skyRideQuickLocations[2],
driver: skyRideDriver,
finalPrice: 68,
id: 'skyride-history-2',
passenger: skyRidePassenger,
pickup: skyRideQuickLocations[1],
price: 68,
serviceClass: 'premium',
status: 'completed',
updatedAt: unixTime(-3 * 24 * 60 * 60 + 18 * 60),
}),
]
const skyRideQuotes = new Map()
function skyRideBootstrap() {
return {
activeRide: skyRideActiveRide,
availableRequests: skyRideAvailableRequests,
driverEligible: true,
driverOnline: skyRideDriverOnline,
history: skyRideHistory,
pendingRating: skyRidePendingRating,
profile: skyRideProfile,
quickLocations: skyRideQuickLocations,
}
}
function skyRideUpdate(fields) {
return Object.fromEntries(
fields.map((field) => [field, skyRideBootstrap()[field]]),
)
}
let contactSequence = 2
const contacts = [
{
@@ -2309,6 +2462,377 @@ app.post('/api/:endpoint', (request, response) => {
response.json({ success: true })
return
}
if (endpoint === 'skyride:get-player-coords') {
response.json({
success: true,
data: { coords: { x: -265.1, y: -960.2, z: 31.2 } },
})
return
}
if (endpoint === 'skyride:bootstrap') {
response.json({ success: true, data: skyRideBootstrap() })
return
}
if (endpoint === 'skyride:history') {
response.json({ success: true, data: { items: skyRideHistory } })
return
}
if (endpoint === 'skyride:quote') {
const pickup = request.body.pickup
const destination = request.body.destination
if (!pickup?.coords || !destination?.coords) {
response.json({ success: false, error: 'invalid_location' })
return
}
const distanceMeters = Math.max(
750,
Math.round(
Math.hypot(
Number(destination.coords.x) - Number(pickup.coords.x),
Number(destination.coords.y) - Number(pickup.coords.y),
) * 1.25,
),
)
const durationSeconds = Math.max(60, Math.round(distanceMeters / 12))
const distanceUnit = 'kilometer'
const distance = distanceMeters / 1000
const expiresAt = unixTime(2 * 60)
const definitions = [
{
baseFare: 12,
etaMinutes: 3,
minimumFare: 15,
pricePerKilometer: 18,
pricePerMile: 29,
pricePerMinute: 1,
seats: 4,
serviceClass: 'taxi',
},
{
baseFare: 16,
etaMinutes: 5,
minimumFare: 22,
pricePerKilometer: 24,
pricePerMile: 39,
pricePerMinute: 2,
seats: 4,
serviceClass: 'comfort',
},
{
baseFare: 20,
etaMinutes: 7,
minimumFare: 28,
pricePerKilometer: 30,
pricePerMile: 48,
pricePerMinute: 2,
seats: 6,
serviceClass: 'xl',
},
{
baseFare: 28,
etaMinutes: 8,
minimumFare: 38,
pricePerKilometer: 40,
pricePerMile: 64,
pricePerMinute: 3,
seats: 4,
serviceClass: 'premium',
},
]
const requestedCustomFare = request.body.customFare
if (
requestedCustomFare !== undefined &&
(!definitions.some(
(definition) =>
definition.serviceClass === requestedCustomFare?.serviceClass,
) ||
!Number.isInteger(requestedCustomFare?.price))
) {
response.json({ success: false, error: 'invalid_custom_fare' })
return
}
const options = definitions.map((definition) => {
const pricePerDistanceUnit =
distanceUnit === 'mile'
? definition.pricePerMile
: definition.pricePerKilometer
const calculatedPrice = Math.round(
Math.max(
definition.minimumFare,
definition.baseFare +
distance * pricePerDistanceUnit +
(durationSeconds / 60) * definition.pricePerMinute,
),
)
const minimumCustomPrice = Math.round(
Math.max(5, definition.minimumFare, calculatedPrice * 0.5),
)
const maximumCustomPrice = Math.round(
Math.min(100000, calculatedPrice * 3),
)
const usesCustomFare =
requestedCustomFare?.serviceClass === definition.serviceClass
const requestedPrice = Number(requestedCustomFare?.price)
if (
usesCustomFare &&
(!Number.isInteger(requestedPrice) ||
requestedPrice < minimumCustomPrice ||
requestedPrice > maximumCustomPrice)
) {
return null
}
const option = {
available: true,
calculatedPrice,
currency: '$',
etaMinutes: definition.etaMinutes,
fareMode: usesCustomFare ? 'custom' : 'calculated',
maximumCustomPrice,
minimumCustomPrice,
price: usesCustomFare ? requestedPrice : calculatedPrice,
pricePerDistanceUnit,
quoteId: 'skyride-quote-' + Date.now() + '-' + definition.serviceClass,
seats: definition.seats,
serviceClass: definition.serviceClass,
}
skyRideQuotes.set(option.quoteId, {
destination,
expiresAt,
option,
pickup,
})
return option
})
if (options.some((option) => option === null)) {
response.json({ success: false, error: 'invalid_custom_fare' })
return
}
response.json({
success: true,
data: {
destination,
distance,
distanceMeters,
distanceUnit,
durationSeconds,
expiresAt,
options,
pickup,
},
})
return
}
if (endpoint === 'skyride:request') {
const quote = skyRideQuotes.get(String(request.body.quoteId ?? ''))
if (!quote) {
response.json({ success: false, error: 'quote_not_found' })
return
}
if (quote.expiresAt <= unixTime()) {
response.json({ success: false, error: 'quote_expired' })
return
}
if (skyRideActiveRide) {
response.json({ success: false, error: 'active_ride_exists' })
return
}
const now = unixTime()
const ride = {
createdAt: now,
currency: quote.option.currency,
destination: quote.destination,
driver: null,
id: `skyride-ride-${skyRideSequence++}`,
passenger: skyRidePassenger,
pickup: quote.pickup,
price: quote.option.price,
serviceClass: quote.option.serviceClass,
status: 'searching',
updatedAt: now,
}
skyRideActiveRide = ride
skyRideAvailableRequests = [
skyRideWithoutLiveContact(ride),
...skyRideAvailableRequests,
]
response.json({
success: true,
data: skyRideUpdate(['activeRide']),
})
return
}
if (endpoint === 'skyride:set-driver-status') {
if (typeof request.body.online !== 'boolean') {
response.json({ success: false, error: 'invalid_driver_status' })
return
}
skyRideDriverOnline = request.body.online
response.json({
success: true,
data: skyRideUpdate(['driverOnline', 'availableRequests']),
})
return
}
if (endpoint === 'skyride:accept') {
if (!skyRideDriverOnline) {
response.json({ success: false, error: 'driver_offline' })
return
}
const ride = skyRideAvailableRequests.find(
(item) => item.id === request.body.rideId,
)
if (!ride) {
response.json({ success: false, error: 'ride_not_found' })
return
}
if (skyRideActiveRide && skyRideActiveRide.id !== ride.id) {
response.json({ success: false, error: 'active_ride_exists' })
return
}
skyRideActiveRide = {
...ride,
driver: skyRideDriver,
status: 'accepted',
updatedAt: unixTime(),
}
skyRideAvailableRequests = skyRideAvailableRequests.filter(
(item) => item.id !== ride.id,
)
response.json({
success: true,
data: skyRideUpdate(['activeRide', 'availableRequests']),
})
return
}
if (
endpoint === 'skyride:arrive' ||
endpoint === 'skyride:start' ||
endpoint === 'skyride:complete'
) {
if (!skyRideActiveRide || skyRideActiveRide.id !== request.body.rideId) {
response.json({ success: false, error: 'ride_not_found' })
return
}
const transition = {
'skyride:arrive': {
from: ['accepted', 'driver_arriving'],
to: 'arrived',
},
'skyride:start': { from: ['arrived'], to: 'in_progress' },
'skyride:complete': { from: ['in_progress'], to: 'completed' },
}[endpoint]
if (!transition.from.includes(skyRideActiveRide.status)) {
response.json({ success: false, error: 'invalid_ride_status' })
return
}
const updatedRide = {
...skyRideActiveRide,
...(transition.to === 'completed'
? { finalPrice: skyRideActiveRide.price }
: {}),
status: transition.to,
updatedAt: unixTime(),
}
if (transition.to === 'completed') {
const passengerRide = updatedRide.passenger?.id === skyRideProfile.id
skyRideProfile.completedRides += 1
if (!passengerRide) {
skyRideProfile.earningsToday += Math.round(
updatedRide.finalPrice * 0.85,
)
}
const historyRide = skyRideWithoutLiveContact(updatedRide)
skyRideHistory = [historyRide, ...skyRideHistory]
skyRidePendingRating = passengerRide ? historyRide : null
skyRideActiveRide = null
response.json({
success: true,
data: skyRideUpdate([
'activeRide',
'history',
'pendingRating',
'profile',
]),
})
return
}
skyRideActiveRide = updatedRide
response.json({
success: true,
data: skyRideUpdate(['activeRide']),
})
return
}
if (endpoint === 'skyride:cancel') {
const ride =
skyRideActiveRide?.id === request.body.rideId
? skyRideActiveRide
: skyRideAvailableRequests.find(
(item) => item.id === request.body.rideId,
)
if (!ride) {
response.json({ success: false, error: 'ride_not_found' })
return
}
const cancelledRide = {
...ride,
status: 'cancelled',
updatedAt: unixTime(),
}
skyRideAvailableRequests = skyRideAvailableRequests.filter(
(item) => item.id !== ride.id,
)
if (skyRideActiveRide?.id === ride.id) skyRideActiveRide = null
skyRidePendingRating = null
skyRideHistory = [
skyRideWithoutLiveContact(cancelledRide),
...skyRideHistory,
]
response.json({
success: true,
data: skyRideUpdate([
'activeRide',
'availableRequests',
'history',
'pendingRating',
]),
})
return
}
if (endpoint === 'skyride:rate') {
const rating = Number(request.body.rating)
const tip = Number(request.body.tip)
if (
!skyRidePendingRating ||
skyRidePendingRating.id !== request.body.rideId
) {
response.json({ success: false, error: 'ride_not_found' })
return
}
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
response.json({ success: false, error: 'invalid_rating' })
return
}
if (!Number.isFinite(tip) || tip < 0 || tip > 10000) {
response.json({ success: false, error: 'invalid_tip' })
return
}
skyRidePendingRating = null
response.json({
success: true,
data: skyRideUpdate(['history', 'pendingRating']),
})
return
}
if (endpoint === 'banking:overview') {
response.json({ success: true, data: bankingOverview() })
return
+101 -4
View File
@@ -115,12 +115,12 @@ Config.Animations = {
},
Transforms = {
Portrait = {
position = { x = 0.0, y = 0.0, z = 0.0 },
rotation = { x = 0.0, y = 0.0, z = 0.0 },
position = vector3(0.0, 0.0, 0.0),
rotation = vector3(0.0, 0.0, 0.0),
},
Landscape = {
position = { x = 0.0, y = 0.0, z = 0.0 },
rotation = { x = 0.0, y = 0.0, z = 90.0 },
position = vector3(0.0, 0.0, 0.0),
rotation = vector3(0.0, 0.0, 90.0),
},
},
}
@@ -320,3 +320,100 @@ Config.Calendar = {
FutureSeconds = 5 * 365 * 24 * 60 * 60,
ReminderPollSeconds = 15,
}
Config.SkyRide = {
PaymentAccount = "bank",
Currency = "$",
DistanceUnit = "kilometer", -- kilometer or mile
QuoteLifetimeSeconds = 120,
HistoryLimit = 50,
ReadsPerMinute = 120,
ActionsPerMinute = 30,
QuotesPerMinute = 20,
RecoveryIntervalSeconds = 15,
PaymentPendingRecoverySeconds = 30,
PickupSelectionRadius = 150.0,
ArrivalRadius = 40.0,
CompletionRadius = 80.0,
MinimumDistanceMeters = 250,
MaximumDistanceMeters = 40000,
RouteDistanceMultiplier = 1.25,
AverageSpeedMetersPerSecond = 12.0,
DriverPayoutPercent = 85,
RatingCommentMaxLength = 300,
MaximumTip = 10000,
CustomFare = {
Enabled = true,
MinimumPrice = 5,
MaximumPrice = 100000,
MinimumCalculatedMultiplier = 0.5,
MaximumCalculatedMultiplier = 3.0,
},
DriverJobs = {
taxi = 0,
},
Services = {
{
Id = "taxi",
Seats = 4,
EtaMinutes = 3,
BaseFare = 12,
PricePerKilometer = 18,
PricePerMile = 29,
PricePerMinute = 1,
MinimumFare = 15,
},
{
Id = "comfort",
Seats = 4,
EtaMinutes = 5,
BaseFare = 16,
PricePerKilometer = 24,
PricePerMile = 39,
PricePerMinute = 2,
MinimumFare = 22,
},
{
Id = "xl",
Seats = 6,
EtaMinutes = 7,
BaseFare = 20,
PricePerKilometer = 30,
PricePerMile = 48,
PricePerMinute = 2,
MinimumFare = 28,
},
{
Id = "premium",
Seats = 4,
EtaMinutes = 8,
BaseFare = 28,
PricePerKilometer = 40,
PricePerMile = 64,
PricePerMinute = 3,
MinimumFare = 38,
},
},
QuickLocations = {
{
Id = "legion-square",
Label = "Legion Square",
Coords = vector3(-265.1, -960.2, 31.2),
},
{
Id = "diamond-casino",
Label = "Diamond Casino",
Coords = vector3(925.2, 46.4, 81.1),
},
{
Id = "airport",
Label = "Los Santos Airport",
Coords = vector3(-1037.7, -2737.8, 20.2),
},
{
Id = "vinewood",
Label = "Vinewood",
Coords = vector3(-594.4, -929.9, 23.9),
},
},
}
+89
View File
@@ -396,6 +396,95 @@ Locales["en"] = {
request_failed = "The garage request failed.", default = "The garage request failed.",
},
},
skyride = {
name = "SkyRide", loading = "Loading SkyRide", unavailable = "SkyRide unavailable", tryAgain = "Try Again",
mode = { rider = "Ride", driver = "Drive" },
riderEyebrow = "Ride anywhere", whereTo = "Where to?", pickup = "Pickup", destination = "Destination",
currentLocation = "Current location", notSelected = "Not selected", location = "Choose a location",
chooseLocation = { pickup = "Set pickup", destination = "Choose destination" },
chooseOnMap = "Choose on map", chooseOnMapBody = "Tap a precise point on the Los Santos map.",
pickOnMap = { pickup = "Tap your pickup point", destination = "Tap your destination" },
tapMapHint = "Drag and zoom the map, then tap to place the pin.", mapPoint = "Map pin", map = "Map", useGps = "Use your live position",
zoomIn = "Zoom in", zoomOut = "Zoom out", resetMap = "Reset map view",
savedPlaces = "Saved places", savedPlace = "Saved place", quickDestinations = "Quick destinations",
quickLocations = { ["legion-square"] = "Legion Square", ["diamond-casino"] = "Diamond Casino", airport = "Los Santos Airport", vinewood = "Vinewood" },
viewRides = "View Ride Options", change = "Change", requestRide = "Request SkyRide",
serviceMeta = "{eta} min away · {seats} seats", distanceMeters = "{distance} m",
distanceKilometers = "{distance} km", distanceMiles = "{distance} mi", durationMinutes = "{minutes} min",
fare = "Trip fare", calculatedFare = "Calculated", customFare = "Own price",
calculatedFareBody = "Distance, time, and selected service included.",
customFareRange = "Allowed range: {minimum} to {maximum}", applyCustomFare = "Apply Own Price",
perKilometer = "{price} / km", perMile = "{price} / mi", rideStatus = "Ride status",
services = {
taxi = { name = "Taxi" }, comfort = { name = "Comfort" }, xl = { name = "XL" }, premium = { name = "Premium" },
},
ride = "SkyRide",
driver = {
online = "You are online", offline = "You are offline",
onlineBody = "New ride requests can reach you now.", offlineBody = "Go online when you are ready to drive.",
toggleStatus = "Toggle driver status", today = "Today", requests = "Nearby requests", accept = "Accept Ride",
noRequests = "No nearby requests", noRequestsBody = "New requests will appear here automatically.",
goOnline = "Go online to receive rides", goOnlineBody = "Switch your driver status on to see nearby requests.",
statusBody = {
searching = "A passenger is waiting for a driver.", accepted = "Navigate to the passenger pickup.",
driver_arriving = "You are heading to the passenger.", arrived = "Confirm when the passenger is in the vehicle.",
in_progress = "Follow the route to the destination.", completed = "The ride has been completed.",
cancelled = "This ride was cancelled.",
},
actions = { arrive = "I Have Arrived", start = "Start Ride", complete = "Complete Ride" },
},
passenger = "Passenger", vehicle = "Vehicle", navigate = "Navigate", call = "Call", message = "Message",
cancelRide = "Cancel Ride", cancelTitle = "Cancel this ride?",
cancelBody = "Your driver or passenger will be notified. A cancellation fee may apply.",
safetyNote = "Trip details are shared only while this ride is active.", waypointSet = "Route added to your GPS.",
status = {
searching = "Finding a driver", accepted = "Driver found", driver_arriving = "Driver en route",
arrived = "Driver arrived", in_progress = "On the way", completed = "Completed", cancelled = "Cancelled",
},
statusBody = {
searching = "We are matching you with a nearby driver.", accepted = "Your driver is preparing to pick you up.",
driver_arriving = "Your driver is on the way to your pickup.", arrived = "Your driver is waiting at the pickup point.",
in_progress = "You are on the way to your destination.", completed = "You have arrived at your destination.",
cancelled = "This ride is no longer active.",
},
navigation = "SkyRide navigation",
tabs = { home = "Home", rides = "Rides", activity = "Activity", messages = "Messages", profile = "Profile" },
rides = "Your Rides", ridesBody = "Review completed and cancelled rides.", noRides = "No rides yet",
noRidesBody = "Completed and cancelled rides will appear here.", activity = "Activity",
activityBody = "Recent trip updates and milestones.", noActivity = "No recent activity",
noActivityBody = "Ride updates will appear here.", messages = "Messages",
messagesBody = "Contact your active driver or passenger.", noMessages = "No active contact",
noMessagesBody = "Book or accept a ride to contact the other person.", openMessages = "Open Messages",
account = "Account", paymentMethod = "Payment Method", paymentMethods = { bank = "Bank", cash = "Cash", default = "Payment method" }, safety = "Safety Center",
safetyBody = "Help, trip sharing, and emergency information", driverMode = "Driver Mode",
driverModeBody = "Accept ride requests and track your earnings", completedRides = "Completed",
cancelledRides = "Cancelled", acceptance = "Acceptance", rating = "Rating", notAvailable = "N/A",
memberSince = "SkyRide member since {date}", rideComplete = "Ride Complete", rateRide = "Rate your ride",
rateRideBody = "How was your trip? Your feedback helps every rider.", ratingValue = "{rating} stars",
tip = "Add a tip", noTip = "No tip", comment = "Comment", commentPlaceholder = "Share an optional note...",
submitRating = "Submit Rating", notNow = "Not Now", ratingSaved = "Thanks for your feedback.",
errors = {
skyride_unavailable = "SkyRide is currently unavailable.",
invalid_location = "Choose a valid pickup and destination.", quote_not_found = "These ride options are no longer available.",
pickup_too_far = "Move closer to the selected pickup point.", invalid_route = "No suitable route was found for this ride.",
invalid_custom_fare = "Choose an own price inside the allowed range.",
quote_expired = "These ride options expired. Request a new quote.", active_ride_exists = "Finish or cancel your active ride first.",
invalid_driver_status = "That driver status is not available.", driver_not_eligible = "This account is not eligible for Driver Mode.",
driver_offline = "Go online before accepting a ride.", driver_unavailable = "That driver is no longer available.",
driver_vehicle_required = "Sit in the driver's seat of a vehicle before accepting a ride.",
ride_not_found = "This ride is no longer available.", not_ride_participant = "You are not part of this ride.",
invalid_ride_status = "This action is not available for the ride.", invalid_rating = "Choose a rating from one to five stars.",
driver_too_far = "Move closer to the required ride location.", passenger_too_far = "The passenger is not close enough to start.",
destination_too_far = "Move closer to the destination to complete the ride.", payout_failed = "The driver payout could not be completed.",
refund_failed = "The ride refund could not be completed.", invalid_cancel_reason = "Choose a valid cancellation reason.",
invalid_tip = "Choose a valid tip amount.", invalid_comment = "The rating comment is not valid.",
tip_failed = "The tip could not be processed.", insufficient_funds = "Your selected payment method has insufficient funds.",
rate_limited = "Please wait a moment before trying again.", request_failed = "SkyRide could not complete the request.",
device_not_open = "Open the phone again to use SkyRide.", device_not_owned = "This phone does not belong to this character.",
device_locked = "Unlock this phone to use SkyRide.",
default = "SkyRide could not complete the request.",
},
},
calculator = { name = "Calculator" },
snake = {
name = "Snake", backToMenu = "Back to game menu", board = "Snake game board",
+2
View File
@@ -27,6 +27,7 @@ client_scripts {
'source/client/animations.lua',
'source/client/camera.lua',
'source/client/garage.lua',
'source/client/skyride.lua',
'source/bridge/client/radio.lua',
'source/client/main.lua',
'source/client/radio.lua',
@@ -61,6 +62,7 @@ server_scripts {
'source/server/fliptok.lua',
'source/server/picstagram.lua',
'source/server/map.lua',
'source/server/skyride.lua',
'source/server/calendar.lua',
'source/server/radio.lua',
}
+120
View File
@@ -0,0 +1,120 @@
local server_callbacks = {
"skyride:bootstrap",
"skyride:quote",
"skyride:request",
"skyride:set-driver-status",
"skyride:accept",
"skyride:arrive",
"skyride:start",
"skyride:complete",
"skyride:cancel",
"skyride:rate",
"skyride:history",
}
local function normalize_person(person)
if type(person) ~= "table" then
return
end
if person.avatarUrl == false then
person.avatarUrl = json.null
end
end
local function normalize_ride(ride)
if type(ride) ~= "table" then
return
end
normalize_person(ride.passenger)
if ride.driver == false then
ride.driver = json.null
return
end
if type(ride.driver) ~= "table" then
return
end
normalize_person(ride.driver)
local vehicle = ride.driver.vehicle
local model_hash = type(vehicle) == "table" and tonumber(vehicle.model) or nil
if not model_hash then
return
end
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.model = label
end
end
local function normalize_rides(rides)
if type(rides) ~= "table" then
return
end
for index = 1, #rides do
normalize_ride(rides[index])
end
end
local function normalize_state(data)
if type(data) ~= "table" then
return
end
if data.activeRide == false then
data.activeRide = json.null
else
normalize_ride(data.activeRide)
end
if data.pendingRating == false then
data.pendingRating = json.null
else
normalize_ride(data.pendingRating)
end
normalize_rides(data.availableRequests)
normalize_rides(data.history)
normalize_rides(data.items)
if type(data.profile) == "table" then
if data.profile.acceptanceRate == false then
data.profile.acceptanceRate = json.null
end
if data.profile.avatarUrl == false then
data.profile.avatarUrl = json.null
end
if data.profile.earningsToday == false then
data.profile.earningsToday = json.null
end
end
end
for index = 1, #server_callbacks do
local callback_name = server_callbacks[index]
RegisterNUICallback(callback_name, function(data, cb)
local result = Bridge.Callbacks.Trigger("sky_phone:" .. callback_name, data or {})
if not result then
cb({ success = false, error = "request_failed" })
return
end
if result.success and type(result.data) == "table" then
normalize_state(result.data)
end
cb(result)
end)
end
RegisterNUICallback("skyride:get-player-coords", function(_, cb)
local coords = GetEntityCoords(PlayerPedId())
cb({
success = true,
data = {
coords = {
x = coords.x,
y = coords.y,
z = coords.z,
},
},
})
end)
RegisterNetEvent("sky_phone:skyride:changed", function(data)
normalize_state(data)
SendNUIMessage({ type = "skyride:changed", data = data })
end)
+119
View File
@@ -1517,6 +1517,125 @@ local schema = {
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_skyride_profiles",
columns = {
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{
name = "owner_identifier",
type = "VARCHAR(80) NOT NULL",
characterSet = "ascii",
collation = "ascii_bin",
},
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
},
primaryKey = "id",
uniqueKeys = {
{ name = "uniq_sky_phone_skyride_owner", columns = "(`owner_identifier`)" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_skyride_rides",
columns = {
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{
name = "passenger_profile_id",
type = "CHAR(36) NOT NULL",
characterSet = "ascii",
collation = "ascii_bin",
},
{
name = "driver_profile_id",
type = "CHAR(36) NULL",
characterSet = "ascii",
collation = "ascii_bin",
},
{ name = "passenger_name", type = "VARCHAR(80) NOT NULL" },
{ name = "driver_name", type = "VARCHAR(80) NULL" },
{
name = "status",
type = "ENUM('payment_pending','searching','accepted','arrived','in_progress','completing','completed','cancelled') NOT NULL",
},
{
name = "service_class",
type = "ENUM('taxi','comfort','xl','premium') NOT NULL",
},
{ name = "pickup_label", type = "VARCHAR(80) NOT NULL" },
{ name = "pickup_x", type = "DECIMAL(10,3) NOT NULL" },
{ name = "pickup_y", type = "DECIMAL(10,3) NOT NULL" },
{ name = "pickup_z", type = "DECIMAL(10,3) NOT NULL" },
{ name = "destination_label", type = "VARCHAR(80) NOT NULL" },
{ name = "destination_x", type = "DECIMAL(10,3) NOT NULL" },
{ name = "destination_y", type = "DECIMAL(10,3) NOT NULL" },
{ name = "destination_z", type = "DECIMAL(10,3) NOT NULL" },
{ name = "distance_meters", type = "INT UNSIGNED NOT NULL" },
{ name = "duration_seconds", type = "INT UNSIGNED NOT NULL" },
{ name = "price", type = "INT UNSIGNED NOT NULL" },
{ name = "payout_amount", type = "INT UNSIGNED NOT NULL" },
{
name = "currency",
type = "VARCHAR(8) NOT NULL",
characterSet = "ascii",
collation = "ascii_general_ci",
},
{ name = "driver_vehicle_model", type = "VARCHAR(64) NULL" },
{ name = "driver_vehicle_color", type = "VARCHAR(64) NULL" },
{ name = "driver_vehicle_plate", type = "VARCHAR(16) NULL" },
{ name = "cancelled_by", type = "ENUM('passenger','driver','system') NULL" },
{ name = "cancel_reason", type = "VARCHAR(32) NULL" },
{ name = "passenger_rating", type = "TINYINT UNSIGNED NULL" },
{ name = "rating_comment", type = "VARCHAR(300) NULL" },
{ name = "tip_amount", type = "INT UNSIGNED NOT NULL DEFAULT 0" },
{
name = "refund_status",
type = "ENUM('none','pending','processing','completed') NOT NULL DEFAULT 'none'",
},
{
name = "tip_status",
type = "ENUM('none','processing','completed','failed') NOT NULL DEFAULT 'none'",
},
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
{
name = "updated_at",
type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP",
},
{ name = "accepted_at", type = "DATETIME NULL" },
{ name = "arrived_at", type = "DATETIME NULL" },
{ name = "started_at", type = "DATETIME NULL" },
{ name = "completed_at", type = "DATETIME NULL" },
{ name = "cancelled_at", type = "DATETIME NULL" },
{ name = "refunded_at", type = "DATETIME NULL" },
{ name = "paid_out_at", type = "DATETIME NULL" },
},
primaryKey = "id",
indexes = {
{
name = "idx_sky_phone_skyride_passenger",
columns = "(`passenger_profile_id`, `status`, `updated_at`)",
},
{
name = "idx_sky_phone_skyride_driver",
columns = "(`driver_profile_id`, `status`, `updated_at`)",
},
{ name = "idx_sky_phone_skyride_requests", columns = "(`status`, `created_at`)" },
{
name = "idx_sky_phone_skyride_refunds",
columns = "(`refund_status`, `status`, `updated_at`)",
},
},
foreignKeys = {
{
column = "passenger_profile_id",
references = "`sky_phone_skyride_profiles` (`id`) ON DELETE CASCADE",
},
{
column = "driver_profile_id",
references = "`sky_phone_skyride_profiles` (`id`) ON DELETE SET NULL",
},
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
}
Bridge.Database.Migrate("sky_phone", schema)
File diff suppressed because it is too large Load Diff
+57
View File
@@ -614,3 +614,60 @@ CREATE TABLE IF NOT EXISTS `sky_phone_picstagram_moderation_audit` (
KEY `idx_sky_phone_picstagram_audit_target` (`target_type`,`target_id`,`created_at`),
FOREIGN KEY (`report_id`) REFERENCES `sky_phone_picstagram_reports` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_skyride_profiles` (
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`owner_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_sky_phone_skyride_owner` (`owner_identifier`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_skyride_rides` (
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`passenger_profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`driver_profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
`passenger_name` VARCHAR(80) NOT NULL,
`driver_name` VARCHAR(80) NULL,
`status` ENUM('payment_pending','searching','accepted','arrived','in_progress','completing','completed','cancelled') NOT NULL,
`service_class` ENUM('taxi','comfort','xl','premium') NOT NULL,
`pickup_label` VARCHAR(80) NOT NULL,
`pickup_x` DECIMAL(10,3) NOT NULL,
`pickup_y` DECIMAL(10,3) NOT NULL,
`pickup_z` DECIMAL(10,3) NOT NULL,
`destination_label` VARCHAR(80) NOT NULL,
`destination_x` DECIMAL(10,3) NOT NULL,
`destination_y` DECIMAL(10,3) NOT NULL,
`destination_z` DECIMAL(10,3) NOT NULL,
`distance_meters` INT UNSIGNED NOT NULL,
`duration_seconds` INT UNSIGNED NOT NULL,
`price` INT UNSIGNED NOT NULL,
`payout_amount` INT UNSIGNED NOT NULL,
`currency` VARCHAR(8) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
`driver_vehicle_model` VARCHAR(64) NULL,
`driver_vehicle_color` VARCHAR(64) NULL,
`driver_vehicle_plate` VARCHAR(16) NULL,
`cancelled_by` ENUM('passenger','driver','system') NULL,
`cancel_reason` VARCHAR(32) NULL,
`passenger_rating` TINYINT UNSIGNED NULL,
`rating_comment` VARCHAR(300) NULL,
`tip_amount` INT UNSIGNED NOT NULL DEFAULT 0,
`refund_status` ENUM('none','pending','processing','completed') NOT NULL DEFAULT 'none',
`tip_status` ENUM('none','processing','completed','failed') NOT NULL DEFAULT 'none',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`accepted_at` DATETIME NULL,
`arrived_at` DATETIME NULL,
`started_at` DATETIME NULL,
`completed_at` DATETIME NULL,
`cancelled_at` DATETIME NULL,
`refunded_at` DATETIME NULL,
`paid_out_at` DATETIME NULL,
PRIMARY KEY (`id`),
KEY `idx_sky_phone_skyride_passenger` (`passenger_profile_id`,`status`,`updated_at`),
KEY `idx_sky_phone_skyride_driver` (`driver_profile_id`,`status`,`updated_at`),
KEY `idx_sky_phone_skyride_requests` (`status`,`created_at`),
KEY `idx_sky_phone_skyride_refunds` (`refund_status`,`status`,`updated_at`),
FOREIGN KEY (`passenger_profile_id`) REFERENCES `sky_phone_skyride_profiles` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`driver_profile_id`) REFERENCES `sky_phone_skyride_profiles` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;