diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 7a0c7ee..f02f098 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -565,7 +565,8 @@ const defaultLocales: LocaleTree = { camera: 'Camera', backToLogin: 'Back to CrewLink login', authErrors: { - no_ifruit_account: 'Sign in to your Sky Cloud account in Settings first.', + no_ifruit_account: + 'Sign in to your Sky Cloud account in Settings first.', invalid_username: 'Use 3–20 letters, numbers, dots, or underscores.', profile_not_found: 'No CrewLink profile exists for this iFruit email.', profile_exists: 'This iFruit email already has a CrewLink profile.', @@ -1190,7 +1191,8 @@ const defaultLocales: LocaleTree = { profileStep: 'Step 2 of 2', accountConnected: 'Sky Cloud account connected', authErrors: { - no_ifruit_account: 'Sign in to your Sky Cloud account in Settings first.', + no_ifruit_account: + 'Sign in to your Sky Cloud account in Settings first.', invalid_handle: 'Use 3–30 letters, numbers or underscores.', invalid_username: 'That username does not match your Feather profile.', profile_not_found: 'No Feather profile exists for this iFruit email.', @@ -2269,6 +2271,18 @@ const defaultLocales: LocaleTree = { acceptance: 'Acceptance', rating: 'Rating', notAvailable: 'N/A', + profile: 'Your profile', + editProfile: 'Edit Profile', + editProfileBody: 'Change your public name and profile photo', + profilePhoto: 'Profile photo', + gallery: 'Gallery', + camera: 'Camera', + profileDetails: 'Profile details', + profileName: 'Name', + profileNamePlaceholder: 'Your SkyRide name', + removeProfilePhoto: 'Remove profile photo', + saveProfile: 'Save Profile', + profileSaved: 'Your SkyRide profile was updated.', memberSince: 'SkyRide member since {date}', rideComplete: 'Ride Complete', rateRide: 'Rate your ride', @@ -2309,6 +2323,10 @@ const defaultLocales: LocaleTree = { 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.', + invalid_profile: 'Choose a valid profile name and photo.', + invalid_profile_name: + 'Your profile name must contain 2 to 50 characters.', + invalid_profile_image: 'Choose a profile photo from your own gallery.', tip_failed: 'The tip could not be processed.', insufficient_funds: 'Your selected payment method has insufficient funds.', @@ -2957,7 +2975,8 @@ const defaultLocales: LocaleTree = { 'Review the listing and publish it when you are ready.', cityMarktPhotosHint: 'The CityMarkt listing photos will be included.', authErrors: { - no_ifruit_account: 'Sign in to your Sky Cloud account in Settings first.', + no_ifruit_account: + 'Sign in to your Sky Cloud account in Settings first.', invalid_username: 'Use 3–24 lowercase letters, numbers, dots or underscores.', profile_not_found: diff --git a/frontend/src/stores/skyride.test.ts b/frontend/src/stores/skyride.test.ts index 5a691a1..bb09bc5 100644 --- a/frontend/src/stores/skyride.test.ts +++ b/frontend/src/stores/skyride.test.ts @@ -42,6 +42,7 @@ const bootstrap: SkyRideBootstrap = { pendingRating: null, profile: { acceptanceRate: 94, + avatarMediaId: null, avatarUrl: null, cancelledRides: 2, completedRides: 128, @@ -112,6 +113,33 @@ describe('SkyRide store', () => { }) }) + it('updates the editable profile with an owned media id', async () => { + const updatedProfile = { + ...bootstrap.profile, + avatarMediaId: 17, + avatarUrl: 'https://example.test/avatar.webp', + name: 'Jordan Sky', + } + mockNuiCall.mockResolvedValueOnce({ + data: { profile: updatedProfile }, + success: true, + }) + const skyride = useSkyRideStore() + skyride.profile = bootstrap.profile + + const response = await skyride.updateProfile({ + avatarMediaId: 17, + name: 'Jordan Sky', + }) + + expect(response.success).toBe(true) + expect(skyride.profile).toEqual(updatedProfile) + expect(mockNuiCall).toHaveBeenCalledWith('skyride:update-profile', { + avatarMediaId: 17, + name: 'Jordan Sky', + }) + }) + it('books with the opaque quote id and never sends a client price', async () => { mockNuiCall.mockResolvedValueOnce({ data: { activeRide: ride }, diff --git a/frontend/src/stores/skyride.ts b/frontend/src/stores/skyride.ts index 12cf15a..b12b33f 100644 --- a/frontend/src/stores/skyride.ts +++ b/frontend/src/stores/skyride.ts @@ -9,6 +9,7 @@ import type { SkyRideQuote, SkyRideQuoteOption, SkyRideRide, + SkyRideProfileInput, SkyRideStateUpdate, } from '@/types/skyride' import { nuiCall, type NuiResponse } from '@/utils/nui' @@ -73,6 +74,23 @@ export const useSkyRideStore = defineStore('skyride', { this.error = '' return true }, + async updateProfile( + profile: SkyRideProfileInput, + ): Promise> { + this.isActionPending = true + const response = await nuiCall( + 'skyride:update-profile', + profile, + ) + 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 createQuote( pickup: SkyRideLocation, destination: SkyRideLocation, diff --git a/frontend/src/types/skyride.ts b/frontend/src/types/skyride.ts index 53f8c40..b83a60a 100644 --- a/frontend/src/types/skyride.ts +++ b/frontend/src/types/skyride.ts @@ -100,6 +100,7 @@ export type SkyRideRide = { export type SkyRideProfile = { acceptanceRate: number | null + avatarMediaId: number | null avatarUrl: string | null cancelledRides: number completedRides: number @@ -112,6 +113,11 @@ export type SkyRideProfile = { rating: number } +export type SkyRideProfileInput = { + avatarMediaId: number + name: string +} + export type SkyRideBootstrap = { activeRide: SkyRideRide | null availableRequests: SkyRideRide[] diff --git a/frontend/src/ui/SkyNavbar.vue b/frontend/src/ui/SkyNavbar.vue index c811aa8..096cf1e 100644 --- a/frontend/src/ui/SkyNavbar.vue +++ b/frontend/src/ui/SkyNavbar.vue @@ -10,6 +10,7 @@ const props = withDefaults( backLabel?: string showBack?: boolean showBackText?: boolean + subtitle?: string title: string variant?: 'compact' | 'large' }>(), @@ -18,6 +19,7 @@ const props = withDefaults( backLabel: '', showBack: false, showBackText: false, + subtitle: '', variant: 'compact', }, ) @@ -49,7 +51,10 @@ const accessibleBackLabel = computed(() => props.backLabel || props.title) -

{{ title }}

+
+

{{ title }}

+ {{ subtitle }} +
diff --git a/frontend/src/ui/SkyTabBar.test.ts b/frontend/src/ui/SkyTabBar.test.ts new file mode 100644 index 0000000..ed1e4f4 --- /dev/null +++ b/frontend/src/ui/SkyTabBar.test.ts @@ -0,0 +1,24 @@ +import { createSSRApp } from 'vue' +import { renderToString } from 'vue/server-renderer' +import { describe, expect, it } from 'vitest' + +import SkyTabBar from '@/ui/SkyTabBar.vue' + +describe('SkyTabBar', () => { + it('keeps the docked tab bar as the default', async () => { + const html = await renderToString( + createSSRApp(SkyTabBar, { label: 'Navigation' }), + ) + + expect(html).toContain('class="sky-tabbar"') + expect(html).not.toContain('sky-tabbar--floating') + }) + + it('exposes the shared floating capsule variant', async () => { + const html = await renderToString( + createSSRApp(SkyTabBar, { floating: true, label: 'Navigation' }), + ) + + expect(html).toContain('sky-tabbar sky-tabbar--floating') + }) +}) diff --git a/frontend/src/ui/SkyTabBar.vue b/frontend/src/ui/SkyTabBar.vue index b4497b6..a2e3dc1 100644 --- a/frontend/src/ui/SkyTabBar.vue +++ b/frontend/src/ui/SkyTabBar.vue @@ -1,13 +1,24 @@