ADD - introduce SkyRide taxi platform

This commit is contained in:
Leon.Schmidt
2026-08-09 19:10:36 +02:00
parent 550982f847
commit e3de5cd747
18 changed files with 6730 additions and 0 deletions
+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 = ''
},
},
})