diff --git a/frontend/src/components/account/AppProfileAuth.vue b/frontend/src/components/account/AppProfileAuth.vue index fe7e387..aff1a0c 100644 --- a/frontend/src/components/account/AppProfileAuth.vue +++ b/frontend/src/components/account/AppProfileAuth.vue @@ -8,13 +8,13 @@ import { UserRound, } from 'lucide-vue-next' import { - SkyButton as kButton, - SkyField as kListInput, - SkyGlass as kGlass, - SkyList as kList, - SkySegmented as kSegmented, - SkySegmentedButton as kSegmentedButton, - SkySpinner as kPreloader, + SkyButton, + SkyField, + SkyGlass, + SkyList, + SkySegmented, + SkySegmentedButton, + SkySpinner, } from '@/ui' import { computed } from 'vue' @@ -24,6 +24,7 @@ const props = withDefaults( body: string cameraLabel: string email: string + emailAsField?: boolean emailLabel: string error: string eyebrow: string @@ -38,19 +39,38 @@ const props = withDefaults( pending: boolean registerLabel: string requirePassword?: boolean + submitEnabled?: boolean title: string username: string + usernameAutocomplete?: string + usernameHelp?: string + usernameInputType?: 'password' | 'text' usernameLabel: string usernamePlaceholder?: string variant?: 'default' | 'centered' + confirmPassword?: string + confirmPasswordError?: boolean | string + confirmPasswordLabel?: string + confirmPasswordPlaceholder?: string + showConfirmPassword?: boolean }>(), { + confirmPassword: '', + confirmPasswordError: false, + confirmPasswordLabel: '', + confirmPasswordPlaceholder: '', + emailAsField: false, maxUsernameLength: 40, minUsernameLength: 2, password: '', passwordLabel: 'Password', passwordPlaceholder: '', requirePassword: false, + showConfirmPassword: false, + submitEnabled: undefined, + usernameAutocomplete: 'username', + usernameHelp: '', + usernameInputType: 'text', usernamePlaceholder: '', variant: 'default', }, @@ -59,6 +79,7 @@ const emit = defineEmits<{ camera: [] gallery: [] submit: [] + 'update:confirmPassword': [value: string] 'update:mode': [value: 'login' | 'register'] 'update:password': [value: string] 'update:username': [value: string] @@ -68,13 +89,16 @@ const canSubmit = computed(() => { const length = props.username.trim().length const validUsername = length >= props.minUsernameLength && length <= props.maxUsernameLength - return Boolean( - props.email && - (props.mode === 'login' && props.requirePassword - ? true - : validUsername) && - (!props.requirePassword || - (props.password.length >= 8 && props.password.length <= 72)), + return ( + props.submitEnabled ?? + Boolean( + props.email && + (props.mode === 'login' && props.requirePassword + ? true + : validUsername) && + (!props.requirePassword || + (props.password.length >= 8 && props.password.length <= 72)), + ) ) }) @@ -93,41 +117,9 @@ const canSubmit = computed(() => {

{{ body }}

- -
- - -
- - - + + - + {{ registerLabel }} - - + +
@@ -156,16 +148,16 @@ const canSubmit = computed(() => {
- + {{ galleryLabel }} - - + + {{ cameraLabel }} - +
-
+
{{ emailLabel }} @@ -174,71 +166,34 @@ const canSubmit = computed(() => {
- - - - - - + + { emit('update:username', ($event.target as HTMLInputElement).value) " @keydown.enter="emit('submit')" - /> - + > + + + + + + + + + - - + - - + + diff --git a/frontend/src/components/citymarkt/CityMarktAuth.vue b/frontend/src/components/citymarkt/CityMarktAuth.vue index abd7caa..7dd0412 100644 --- a/frontend/src/components/citymarkt/CityMarktAuth.vue +++ b/frontend/src/components/citymarkt/CityMarktAuth.vue @@ -4,18 +4,20 @@ import { usePhoneStore } from '@/stores/phone' defineProps<{ avatarUrl: string | null + confirmPassword: string email: string error: string mode: 'login' | 'register' + password: string pending: boolean - username: string }>() const emit = defineEmits<{ camera: [] gallery: [] submit: [] + 'update:confirmPassword': [value: string] 'update:mode': [value: 'login' | 'register'] - 'update:username': [value: string] + 'update:password': [value: string] }>() const phone = usePhoneStore() @@ -23,25 +25,186 @@ const phone = usePhoneStore() + + diff --git a/frontend/src/stores/marketplace.test.ts b/frontend/src/stores/marketplace.test.ts index 431096b..a418ac7 100644 --- a/frontend/src/stores/marketplace.test.ts +++ b/frontend/src/stores/marketplace.test.ts @@ -47,4 +47,28 @@ describe('marketplace store offers', () => { inquiryId: 'inquiry-id', }) }) + + it('authenticates CityMarkt with the iFruit password and stores the profile', async () => { + const profile = { + avatar_media_id: null, + avatar_url: null, + bio: '', + display_name: 'demo', + email: 'demo@ifruit.com', + exists: true, + listing_count: 0, + } + mockNuiCall.mockResolvedValueOnce({ data: profile, success: true }) + + const marketplace = useMarketplaceStore() + const response = await marketplace.authenticate('register', 'secret12', 7) + + expect(response).toEqual({ data: profile, success: true }) + expect(mockNuiCall).toHaveBeenCalledWith('marketplace:auth', { + avatarMediaId: 7, + mode: 'register', + password: 'secret12', + }) + expect(marketplace.profile).toEqual(profile) + }) }) diff --git a/frontend/src/stores/marketplace.ts b/frontend/src/stores/marketplace.ts index f347730..c95563a 100644 --- a/frontend/src/stores/marketplace.ts +++ b/frontend/src/stores/marketplace.ts @@ -28,6 +28,19 @@ export const useMarketplaceStore = defineStore('marketplace', { profile: null as MarketplaceProfile | null, }), actions: { + async authenticate( + mode: 'login' | 'register', + password: string, + avatarMediaId = 0, + ): Promise> { + const response = await nuiCall('marketplace:auth', { + avatarMediaId, + mode, + password, + }) + if (response.success && response.data) this.profile = response.data + return response + }, async loadProfile(): Promise { const response = await nuiCall('marketplace:profile') if (response.success && response.data) this.profile = response.data diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index c6fa122..6cab93c 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -3159,18 +3159,29 @@ const defaultLocales: LocaleTree = { signInTitle: 'Sign in to Sky Cloud', signInBody: 'Log in to your CityMarkt profile to sell, save or message.', authEyebrow: 'CityMarkt account', - authTitle: 'Welcome to CityMarkt', - authBody: - 'Your iFruit email is linked automatically. Use your CityMarkt username to continue.', - login: 'Login', - register: 'Register', - authUsername: 'Username', + authLoginTitle: 'Welcome back', + authRegisterTitle: 'Create your profile', + authLoginBody: 'Sign in with the iFruit account linked to this phone.', + authRegisterBody: + 'Confirm your linked iFruit account to create a CityMarkt profile.', + login: 'Sign in', + register: 'Create profile', + authPassword: 'iFruit password', + authPasswordPlaceholder: 'Enter your iFruit password', + authPasswordHelp: 'Use 6–64 characters.', + authConfirmPassword: 'Repeat password', + authConfirmPlaceholder: 'Repeat your iFruit password', + passwordsMismatch: 'The passwords do not match.', authErrors: { no_ifruit_account: 'Connect a Sky Cloud account in Settings first.', - invalid_username: 'Enter the username of your CityMarkt profile.', + invalid_password: 'Password must be 6–64 characters.', + invalid_credentials: 'The iFruit email or password is incorrect.', + invalid_profile_image: 'Choose a valid photo from this phone.', profile_not_found: 'No CityMarkt profile exists for this iFruit email.', profile_exists: - 'A CityMarkt profile already exists. Use Login instead.', + 'A CityMarkt profile already exists. Use Sign in instead.', + rate_limited: 'Too many attempts. Try again in a minute.', + default: 'CityMarkt authentication failed.', }, noMessages: 'No conversations', noMessagesBody: 'Messages about offers will appear here.', diff --git a/frontend/src/views/apps/CityMarktApp.vue b/frontend/src/views/apps/CityMarktApp.vue index 46af0a7..6357919 100644 --- a/frontend/src/views/apps/CityMarktApp.vue +++ b/frontend/src/views/apps/CityMarktApp.vue @@ -23,6 +23,7 @@ import { MessageCircle, MoreHorizontal, Pencil, + Phone as PhoneIcon, Rows3, Search, Send, @@ -38,6 +39,7 @@ import { SkyButton, SkyGlass, SkyIcon, + SkyLink, SkyNotification, SkyNavbar, SkyAppPage, @@ -46,7 +48,7 @@ import { SkyTabButton, SkyToolbarPane, } from '@/ui' -import { computed, onMounted, ref } from 'vue' +import { computed, nextTick, onMounted, ref } from 'vue' import { useRoute, useRouter } from 'vue-router' import CityMarktSelect from '@/components/citymarkt/CityMarktSelect.vue' @@ -63,6 +65,7 @@ import { useMessageMediaStore } from '@/stores/messageMedia' import { usePagesStore } from '@/stores/pages' import { usePhoneStore } from '@/stores/phone' import { parseDatabaseDate, type DatabaseDateValue } from '@/utils/date' +import { formatPhoneNumber } from '@/utils/phone' import type { MarketplaceCategory, MarketplaceChat, @@ -128,10 +131,13 @@ const pages = usePagesStore() const logoutDialogOpen = ref(false) const authMode = ref<'login' | 'register'>('login') const authError = ref('') +const authPassword = ref('') +const authConfirmPassword = ref('') const tab = ref('discover') const screen = ref('main') const selectedListing = ref(null) const selectedChat = ref(null) +const chatMessages = ref(null) const search = ref('') const listingLayout = ref<'compact' | 'wide'>('compact') const category = ref('all') @@ -558,61 +564,57 @@ async function finishAuthentication(): Promise { function switchAuthMode(mode: 'login' | 'register'): void { authMode.value = mode authError.value = '' - profileDraft.value.displayName = - mode === 'login' - ? (marketplace.profile?.display_name ?? '') - : (account.email.split('@')[0] ?? '') + authPassword.value = '' + authConfirmPassword.value = '' if (mode === 'login') selectedProfilePhoto.value = null } async function submitCityMarktAuth(): Promise { - const username = profileDraft.value.displayName.trim() authError.value = '' if (!account.email) { authError.value = phone.t('Apps.citymarkt.authErrors.no_ifruit_account') return } - if (username.length < 2 || username.length > 40) { - authError.value = phone.t('Apps.citymarkt.authErrors.invalid_username') + if (authPassword.value.length < 6 || authPassword.value.length > 64) { + authError.value = phone.t('Apps.citymarkt.authErrors.invalid_password') + return + } + if ( + authMode.value === 'register' && + authConfirmPassword.value !== authPassword.value + ) { + authError.value = phone.t('Apps.citymarkt.passwordsMismatch') return } profilePending.value = true - await marketplace.loadProfile() - if (authMode.value === 'login') { - profilePending.value = false - if (!marketplace.profile?.exists) { - authError.value = phone.t('Apps.citymarkt.authErrors.profile_not_found') - return - } - if ( - marketplace.profile.display_name.trim().toLocaleLowerCase(phone.lang) !== - username.toLocaleLowerCase(phone.lang) - ) { - authError.value = phone.t('Apps.citymarkt.authErrors.invalid_username') - return - } - } else { - if (marketplace.profile?.exists) { - profilePending.value = false - authError.value = phone.t('Apps.citymarkt.authErrors.profile_exists') - return - } - const response = await marketplace.saveProfile({ - avatarMediaId: - selectedProfilePhoto.value?.id ?? profileDraft.value.avatarMediaId, - bio: '', - displayName: username, - }) - profilePending.value = false - if (!response.success) { - authError.value = phone.t( - `Apps.citymarkt.errors.${response.error ?? 'default'}`, - ) - return - } + const response = await marketplace.authenticate( + authMode.value, + authPassword.value, + selectedProfilePhoto.value?.id ?? profileDraft.value.avatarMediaId, + ) + profilePending.value = false + if (!response.success) { + const knownErrors = [ + 'invalid_credentials', + 'invalid_password', + 'invalid_profile_image', + 'profile_exists', + 'profile_not_found', + 'rate_limited', + ] + authError.value = phone.t( + `Apps.citymarkt.authErrors.${ + response.error && knownErrors.includes(response.error) + ? response.error + : 'default' + }`, + ) + return } + authPassword.value = '' + authConfirmPassword.value = '' appAuth.signIn('citymarkt', account.email) await finishAuthentication() } @@ -839,9 +841,16 @@ async function sendFirstMessage(): Promise { if (chatResponse.success && chatResponse.data) { selectedChat.value = chatResponse.data screen.value = 'chat' + await scrollChatToBottom() } } +async function scrollChatToBottom(): Promise { + await nextTick() + if (!chatMessages.value) return + chatMessages.value.scrollTop = chatMessages.value.scrollHeight +} + async function openChat(id: string): Promise { const response = await marketplace.getInquiry(id) if (response.success && response.data) { @@ -849,6 +858,7 @@ async function openChat(id: string): Promise { message.value = '' offerPanelOpen.value = false screen.value = 'chat' + await scrollChatToBottom() await Promise.all([marketplace.loadInquiries(), marketplace.loadCounts()]) } } @@ -933,7 +943,10 @@ async function sendChatMessage(): Promise { const refreshed = await marketplace.getInquiry( selectedChat.value.inquiry.id, ) - if (refreshed.data) selectedChat.value = refreshed.data + if (refreshed.data) { + selectedChat.value = refreshed.data + await scrollChatToBottom() + } } else setFeedback(`Apps.citymarkt.errors.${response.error ?? 'default'}`) } @@ -1076,7 +1089,20 @@ onMounted(async () => { : `Apps.citymarkt.tabs.${tab}`, ) " - /> + > + +
{{ phone.t('Common.loading') }} @@ -1281,7 +1307,8 @@ onMounted(async () => {
{ >
- - - {{ phone.t('Common.signOut') }} - @@ -1489,14 +1506,16 @@ onMounted(async () => { class="citymarkt__detail" >
- + />
{ :next-label="phone.t('Apps.citymarkt.nextPhoto')" :photo-label="phone.t('Apps.citymarkt.photo')" /> -
+

{{ formatPrice(selectedListing) }}

@@ -1575,10 +1600,18 @@ onMounted(async () => { >
-

- {{ phone.t('Apps.citymarkt.phone') }}: - {{ selectedListing.phone_number }} -

+ + + + {{ phone.t('Apps.citymarkt.phone') }} + {{ + formatPhoneNumber(selectedListing.phone_number) + }} + + + />
{{ phone.t('Apps.citymarkt.signInToMessage') }}
+
@@ -1905,6 +1942,15 @@ onMounted(async () => { {{ label('districts', draft.district) }}
+ + + {{ phone.t('Apps.citymarkt.phone') }} + {{ formatPhoneNumber(phone.device.sim.number) }} +
@@ -1925,7 +1971,14 @@ onMounted(async () => { class="citymarkt__chat" >
-
@@ -1944,7 +1997,7 @@ onMounted(async () => {
{{ label('status', selectedChat.inquiry.status) }} -
+