From 1f85d9b3fcfcea06469d64244c49c8ada8a7b030 Mon Sep 17 00:00:00 2001 From: "smx.pusha" <139338836+smxpusha@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:33:59 +0200 Subject: [PATCH] ENH - expand CityMarkt photo workflow --- .../components/citymarkt/CityMarktGallery.vue | 79 ++++++++++++ frontend/src/stores/media.test.ts | 39 ++++++ frontend/src/stores/media.ts | 10 +- frontend/src/stores/phone.ts | 10 +- frontend/src/views/apps/CityMarktApp.vue | 116 ++++++++++++++++-- frontend/testserver/index.cjs | 14 ++- sky_phone/source/html/index.html | 2 +- sky_phone/source/server/marketplace.lua | 5 +- 8 files changed, 256 insertions(+), 19 deletions(-) create mode 100644 frontend/src/components/citymarkt/CityMarktGallery.vue create mode 100644 frontend/src/stores/media.test.ts diff --git a/frontend/src/components/citymarkt/CityMarktGallery.vue b/frontend/src/components/citymarkt/CityMarktGallery.vue new file mode 100644 index 0000000..c4afe65 --- /dev/null +++ b/frontend/src/components/citymarkt/CityMarktGallery.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/frontend/src/stores/media.test.ts b/frontend/src/stores/media.test.ts new file mode 100644 index 0000000..97849a5 --- /dev/null +++ b/frontend/src/stores/media.test.ts @@ -0,0 +1,39 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useMediaStore } from '@/stores/media' + +const mocks = vi.hoisted(() => ({ + saveDeviceNamespace: vi.fn(), +})) + +vi.mock('@/utils/nui', () => ({ + nuiCall: vi.fn(), +})) +vi.mock('@/stores/phone', () => ({ + usePhoneStore: () => ({ saveDeviceNamespace: mocks.saveDeviceNamespace }), +})) + +describe('media store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.restoreAllMocks() + mocks.saveDeviceNamespace.mockReset() + }) + + it('returns each captured photo and persists it in the shared gallery', () => { + vi.spyOn(Date, 'now').mockReturnValue(123456789) + const media = useMediaStore() + + const first = media.capture() + const second = media.capture() + + expect(first.id).toBe('capture-123456789-1') + expect(second.id).toBe('capture-123456789-2') + expect(media.photos.slice(0, 2)).toEqual([second, first]) + expect(mocks.saveDeviceNamespace).toHaveBeenLastCalledWith('media', { + captures: [second, first], + claimedApps: [], + }) + }) +}) diff --git a/frontend/src/stores/media.ts b/frontend/src/stores/media.ts index a3512d4..f99c52f 100644 --- a/frontend/src/stores/media.ts +++ b/frontend/src/stores/media.ts @@ -48,20 +48,22 @@ export const useMediaStore = defineStore('media', { photos: (state): PhonePhoto[] => [...state.captures, ...samplePhotos], }, actions: { - capture(): void { + capture(): PhonePhoto { const gradients = [ 'linear-gradient(145deg, #ff6b6b, #845ec2 52%, #0f2027)', 'linear-gradient(150deg, #00c9a7, #4d8076 46%, #1f3a5f)', 'linear-gradient(135deg, #ffc75f, #f96d80 48%, #4b4453)', ] const captureNumber = this.captures.length - this.captures.unshift({ + const photo: PhonePhoto = { capturedAt: Date.now(), gradient: gradients[captureNumber % gradients.length], - id: `capture-${Date.now()}`, + id: `capture-${Date.now()}-${captureNumber + 1}`, titleKey: 'Apps.photos.samples.capture', - }) + } + this.captures.unshift(photo) this.persist() + return photo }, claimApp(id: string): void { if (!this.claimedApps.includes(id)) { diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 7b76303..e7eabf9 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -373,7 +373,15 @@ const defaultLocales: LocaleTree = { signInToMessage: 'Sign in to your iFruit account to send a message.', edit: 'Edit', makeActive: 'Make active', markSold: 'Mark sold', remove: 'Remove', createListing: 'Create listing', editListing: 'Edit listing', save: 'Save', step: 'Step {current} of {total}', next: 'Next', previous: 'Back', publish: 'Publish', - addPhotos: 'Add photos', addPhotosBody: 'Choose up to six photos. The first becomes the cover.', + addPhotos: 'Add photos', addPhotosBody: 'Choose up to six photos from your gallery or take new ones. Photos are optional and the first becomes the cover.', + chooseGallery: 'Choose from gallery', chooseGalleryBody: 'Use photos saved on this phone.', + takePhotos: 'Take photos', takePhotosBody: 'Take several new photos for this listing.', + selectedPhotos: 'Selected photos', gallery: 'Gallery', camera: 'Camera', takePhoto: 'Take photo', + cameraHint: 'Take as many photos as you need. Each shot is added to the listing automatically.', + noPhoto: 'No photo available', noPhotoBody: 'The seller did not add a photo to this listing.', + noPhotoOptional: 'You can publish without a photo or add one from the gallery or camera.', + previousPhoto: 'Previous photo', nextPhoto: 'Next photo', photo: 'Photo', + removePhoto: 'Remove photo {number}', photoLimit: 'You can add up to six photos.', describeOffer: 'Describe your offer', title: 'Title', description: 'Description', category: 'Category', condition: 'Condition', priceAndPlace: 'Price and location', priceType: 'Price type', price: 'Price', district: 'District', showPhone: 'Show my current phone number', preview: 'Preview', published: 'Your listing is live.', diff --git a/frontend/src/views/apps/CityMarktApp.vue b/frontend/src/views/apps/CityMarktApp.vue index e80319b..b674aa6 100644 --- a/frontend/src/views/apps/CityMarktApp.vue +++ b/frontend/src/views/apps/CityMarktApp.vue @@ -4,6 +4,7 @@ import { BadgeDollarSign, Bell, BriefcaseBusiness, + Camera, CarFront, ChevronRight, CirclePlus, @@ -11,7 +12,9 @@ import { Hammer, Heart, House, + ImageOff, ImagePlus, + Images, Inbox, Laptop, MapPin, @@ -28,6 +31,7 @@ import { import { computed, onMounted, ref } from 'vue' import CityMarktSelect from '@/components/citymarkt/CityMarktSelect.vue' +import CityMarktGallery from '@/components/citymarkt/CityMarktGallery.vue' import CityMarktOfferCard from '@/components/citymarkt/CityMarktOfferCard.vue' import { useAccountStore } from '@/stores/account' import { useMarketplaceStore } from '@/stores/marketplace' @@ -73,6 +77,8 @@ const feedback = ref('') const sellStep = ref(1) const submitting = ref(false) const selectedPhotoIds = ref([]) +const photoSource = ref<'camera' | 'gallery' | null>(null) +const cameraFlash = ref(false) const reportReason = ref('spam') const reportDetails = ref('') const editing = ref<{ id: string; revision: number } | null>(null) @@ -158,6 +164,14 @@ const displayItems = computed(() => { } return marketplace.items }) +const draftImages = computed(() => + selectedPhotoIds.value.flatMap((id, index) => { + const photo = media.photos.find((item) => item.id === id) + return photo + ? [{ gradient: photo.gradient, media_id: photo.id, sort_order: index + 1 }] + : [] + }), +) const chatTimeline = computed(() => { if (!selectedChat.value) return [] const messages: ChatTimelineItem[] = selectedChat.value.messages.map((item) => ({ @@ -196,7 +210,7 @@ const offerButtonLabel = computed(() => : phone.t('Apps.citymarkt.makeOffer'), ) const canContinueSell = computed(() => { - if (sellStep.value === 1) return selectedPhotoIds.value.length > 0 + if (sellStep.value === 1) return true if (sellStep.value === 2) return draft.value.title.trim().length >= 5 && draft.value.description.trim().length >= 20 if (sellStep.value === 3) @@ -319,12 +333,25 @@ function togglePhoto(id: string): void { const index = selectedPhotoIds.value.indexOf(id) if (index >= 0) selectedPhotoIds.value.splice(index, 1) else if (selectedPhotoIds.value.length < 6) selectedPhotoIds.value.push(id) + else setFeedback('Apps.citymarkt.photoLimit') +} + +function captureMarketplacePhoto(): void { + if (selectedPhotoIds.value.length >= 6) { + setFeedback('Apps.citymarkt.photoLimit') + return + } + cameraFlash.value = true + const photo = media.capture() + selectedPhotoIds.value.push(photo.id) + window.setTimeout(() => (cameraFlash.value = false), 120) } function resetSell(): void { editing.value = null sellStep.value = 1 selectedPhotoIds.value = [] + photoSource.value = null draft.value = { category: 'vehicles', condition: 'used', @@ -586,7 +613,8 @@ onMounted(async () => {
@@ -635,7 +663,7 @@ onMounted(async () => {
{{ phone.t('Apps.citymarkt.noProfileListings') }}
- +
@@ -643,7 +671,15 @@ onMounted(async () => {
-
{{ selectedListing.images.length }} {{ phone.t('Apps.citymarkt.photos') }}
+

{{ formatPrice(selectedListing) }}

{{ label('status', selectedListing.status) }}
{{ relativeDate(selectedListing.created_at) }}

{{ selectedListing.title }}

@@ -665,12 +701,74 @@ onMounted(async () => {
{{ phone.t(editing ? 'Apps.citymarkt.editListing' : 'Apps.citymarkt.createListing') }}{{ phone.t('Apps.citymarkt.step', { current: String(sellStep), total: '4' }) }}
- +