diff --git a/frontend/src/stores/marketplace.ts b/frontend/src/stores/marketplace.ts index 6999c62..f347730 100644 --- a/frontend/src/stores/marketplace.ts +++ b/frontend/src/stores/marketplace.ts @@ -7,6 +7,8 @@ import type { MarketplaceListing, MarketplaceListingDraft, MarketplaceListingSummary, + MarketplaceProfile, + MarketplaceProfileDraft, } from '@/types/marketplace' import { nuiCall, type NuiResponse } from '@/utils/nui' @@ -23,8 +25,19 @@ export const useMarketplaceStore = defineStore('marketplace', { isLoading: false, items: [] as MarketplaceListingSummary[], ownItems: [] as MarketplaceListingSummary[], + profile: null as MarketplaceProfile | null, }), actions: { + async loadProfile(): Promise { + const response = await nuiCall('marketplace:profile') + if (response.success && response.data) this.profile = response.data + return response.success + }, + async saveProfile(draft: MarketplaceProfileDraft): Promise> { + const response = await nuiCall('marketplace:profile-save', draft) + if (response.success && response.data) this.profile = response.data + return response + }, async load(filters: Record = {}): Promise { this.isLoading = true const response = await nuiCall('marketplace:list', filters) diff --git a/frontend/src/types/marketplace.ts b/frontend/src/types/marketplace.ts index eecc668..2e3ca87 100644 --- a/frontend/src/types/marketplace.ts +++ b/frontend/src/types/marketplace.ts @@ -15,6 +15,22 @@ export type MarketplacePriceType = 'fixed' | 'negotiable' | 'free' export type MarketplaceStatus = 'active' | 'reserved' | 'sold' | 'expired' | 'removed' export type MarketplaceOfferStatus = 'pending' | 'accepted' | 'rejected' | 'countered' +export type MarketplaceProfile = { + avatar_media_id: number | null + avatar_url: string | null + bio: string + display_name: string + email: string + exists: boolean + listing_count: number +} + +export type MarketplaceProfileDraft = { + avatarMediaId: number + bio: string + displayName: string +} + export type MarketplaceImage = { gradient: string media_id: string @@ -46,6 +62,7 @@ export type MarketplaceListing = MarketplaceListingSummary & { reserved_account_id: number | null revision: number seller_active: number + seller_avatar: string | null seller_since: DatabaseDateValue show_phone: boolean | number } diff --git a/frontend/src/views/apps/CityMarktApp.vue b/frontend/src/views/apps/CityMarktApp.vue index c2fac3d..3cfa38d 100644 --- a/frontend/src/views/apps/CityMarktApp.vue +++ b/frontend/src/views/apps/CityMarktApp.vue @@ -20,6 +20,7 @@ import { MapPin, MessageCircle, MoreHorizontal, + Pencil, Rows3, Search, Send, @@ -66,17 +67,15 @@ import type { MarketplaceMessage, MarketplaceOffer, MarketplacePriceType, + MarketplaceProfileDraft, } from '@/types/marketplace' +import type { PhoneMedia } from '@/types/media' const glassActionColors = { bgIos: 'bg-ios-light-glass/75 dark:bg-ios-dark-glass/75', activeBgIos: 'active:bg-white/90 dark:active:bg-white/20', textIos: 'text-black/80 dark:text-white/80', } -const yellowGlassActionColors = { - ...glassActionColors, - textIos: 'text-primary', -} type Tab = 'discover' | 'search' | 'sell' | 'inbox' | 'profile' type Screen = 'main' | 'detail' | 'sell' | 'chat' | 'report' @@ -111,6 +110,7 @@ type MediaContext = { photos: SelectedPhoto[] sellStep: number } +type ProfileMediaContext = { draft: MarketplaceProfileDraft } const phone = usePhoneStore() const route = useRoute() @@ -130,6 +130,11 @@ const category = ref('all') const district = ref('all') const sort = ref('newest') const profileMode = ref<'own' | 'favorites'>('own') +const onboardingReady = ref(false) +const profileEditing = ref(false) +const profilePending = ref(false) +const profileDraft = ref({ avatarMediaId: 0, bio: '', displayName: '' }) +const selectedProfilePhoto = ref(null) const message = ref('') const firstMessage = ref('') const offerAmount = ref('') @@ -236,6 +241,10 @@ const tabs = [ ] as const const isAuthenticated = computed(() => account.email !== '') +const canSaveProfile = computed(() => { + const nameLength = profileDraft.value.displayName.trim().length + return nameLength >= 2 && nameLength <= 40 && profileDraft.value.bio.trim().length <= 160 +}) const displayItems = computed(() => { if (tab.value === 'profile') { return profileMode.value === 'own' @@ -366,11 +375,17 @@ async function shareToLocalPages(listingId?: string): Promise { pagesSharePendingId.value = id const response = await pages.shareCityMarkt(id) pagesSharePendingId.value = null - setFeedback( - response.success - ? 'Apps.localPages.cityMarktShared' - : `Apps.localPages.errors.${response.error ?? 'default'}`, - ) + if (response.success && response.data?.id) { + await router.push({ + path: '/apps/local-pages', + query: { + easyShareId: response.data.id, + easyShareKind: 'post', + }, + }) + return + } + setFeedback(`Apps.localPages.errors.${response.error ?? 'default'}`) } function shareListing(): void { @@ -456,6 +471,9 @@ async function selectTab(next: Tab): Promise { if (next === 'inbox' && isAuthenticated.value) await marketplace.loadInquiries() if (next === 'profile' && isAuthenticated.value) { + await marketplace.loadProfile() + syncProfileDraft() + profileEditing.value = !marketplace.profile?.exists await Promise.all([ marketplace.loadOwn(), marketplace.load({ favorites: true }), @@ -476,14 +494,76 @@ async function openListing( screen.value = 'detail' } -async function toggleFavorite(): Promise { - if (!selectedListing.value || !isAuthenticated.value) return - const next = !Boolean(selectedListing.value.is_favorite) - if (await marketplace.favorite(selectedListing.value.id, next)) { - selectedListing.value.is_favorite = next +async function toggleListingFavorite( + item: MarketplaceListingSummary, +): Promise { + if (!isAuthenticated.value) return + const next = !Boolean(item.is_favorite) + if (await marketplace.favorite(item.id, next)) { + item.is_favorite = next } } +function syncProfileDraft(): void { + profileDraft.value = { + avatarMediaId: marketplace.profile?.avatar_media_id ?? 0, + bio: marketplace.profile?.bio ?? '', + displayName: marketplace.profile?.display_name ?? account.email.split('@')[0] ?? '', + } + selectedProfilePhoto.value = null +} + +function editProfile(): void { + syncProfileDraft() + profileEditing.value = true +} + +function cancelProfileEdit(): void { + if (!marketplace.profile?.exists) return + syncProfileDraft() + profileEditing.value = false +} + +function openProfileMedia(app: 'camera' | 'photos'): void { + messageMedia.begin( + 'citymarkt:profile-avatar', + 'photo', + '/apps/citymarkt?profileEdit=1', + 1, + { draft: { ...profileDraft.value } } satisfies ProfileMediaContext, + ) + void router.push(app === 'photos' ? '/apps/photos?picker=1' : '/apps/camera?picker=1') +} + +function removeProfilePhoto(): void { + selectedProfilePhoto.value = null + profileDraft.value.avatarMediaId = 0 +} + +async function saveProfile(): Promise { + if (!canSaveProfile.value || profilePending.value) { + setFeedback('Apps.citymarkt.errors.invalid_profile') + return + } + profilePending.value = true + const response = await marketplace.saveProfile({ + avatarMediaId: selectedProfilePhoto.value?.id ?? profileDraft.value.avatarMediaId, + bio: profileDraft.value.bio.trim(), + displayName: profileDraft.value.displayName.trim(), + }) + profilePending.value = false + if (!response.success) { + setFeedback(`Apps.citymarkt.errors.${response.error ?? 'default'}`) + return + } + syncProfileDraft() + profileEditing.value = false + tab.value = 'profile' + screen.value = 'main' + await Promise.all([marketplace.loadOwn(), marketplace.loadCounts()]) + setFeedback('Apps.citymarkt.profileSaved') +} + function togglePhoto(id: string): void { const index = selectedPhotoIds.value.indexOf(id) if (index >= 0) { @@ -752,6 +832,7 @@ async function blockSeller(): Promise { onMounted(async () => { const selection = messageMedia.consumeMany('citymarkt:sell') + const profileSelection = messageMedia.consumeMany('citymarkt:profile-avatar') if (selection) { if (selection.context) { draft.value = selection.context.draft @@ -774,13 +855,37 @@ onMounted(async () => { }) } } + if (profileSelection?.context) { + profileDraft.value = profileSelection.context.draft + if (profileSelection.media[0]) { + selectedProfilePhoto.value = profileSelection.media[0] + profileDraft.value.avatarMediaId = profileSelection.media[0].id + } + profileEditing.value = true + tab.value = 'profile' + screen.value = 'main' + } if (route.query.sell === '1') { tab.value = 'sell' screen.value = 'sell' } - await loadFeed() - if (isAuthenticated.value) await marketplace.loadCounts() - if (typeof route.query.listingId === 'string') { + if (isAuthenticated.value) { + await marketplace.loadProfile() + if (!marketplace.profile?.exists) { + if (!profileSelection) syncProfileDraft() + profileEditing.value = true + tab.value = 'profile' + screen.value = 'main' + } else if (!profileSelection) { + await loadFeed() + } + await marketplace.loadCounts() + } else { + tab.value = 'profile' + screen.value = 'main' + } + onboardingReady.value = true + if (marketplace.profile?.exists && typeof route.query.listingId === 'string') { await openListing({ id: route.query.listingId }) } }) @@ -794,7 +899,7 @@ onMounted(async () => { :colors="{ bgIos: 'bg-transparent' }" > -
+
{{ phone.t('Common.loading') }}
+
@@ -983,13 +1110,46 @@ onMounted(async () => {

{{ phone.t('Apps.citymarkt.signInBody') }}