diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 8ad26ae..8fdfddb 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -2755,6 +2755,17 @@ const defaultLocales: LocaleTree = { cityMarktShare: 'Share to Local Pages', cityMarktShared: 'Shared to Local Pages.', cityMarktShareHint: 'One CityMarkt share per day', + cityMarktAlreadyShared: 'Already published', + cityMarktOpenSharedHint: 'Open the Local Pages post', + cityMarktAppMissing: 'Local Pages is not installed', + cityMarktInstallHint: 'Install Local Pages to share this listing', + cityMarktAccountMissing: 'Local Pages profile required', + cityMarktAccountHint: 'Create a Local Pages profile before sharing', + cityMarktComposeTitle: 'Share CityMarkt listing', + cityMarktComposeNavTitle: 'Share listing', + cityMarktComposeHint: + 'Review the listing and publish it when you are ready.', + cityMarktPhotosHint: 'The CityMarkt listing photos will be included.', authErrors: { invalid_email: 'Enter a valid 3–32 character iFruit address.', invalid_password: 'Use a password between 6 and 64 characters.', diff --git a/frontend/src/views/apps/CityMarktApp.vue b/frontend/src/views/apps/CityMarktApp.vue index 3cfa38d..6a4c321 100644 --- a/frontend/src/views/apps/CityMarktApp.vue +++ b/frontend/src/views/apps/CityMarktApp.vue @@ -6,6 +6,7 @@ import { Camera, CarFront, ChevronRight, + CircleCheck, CirclePlus, Gift, Hammer, @@ -34,7 +35,6 @@ import { import { kBadge, kButton, - kFab, kGlass, kIcon, kNavbar, @@ -51,6 +51,7 @@ 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 { useAppStoreStore } from '@/stores/app-store' import { useEasyShareStore } from '@/stores/easyshare' import { useMarketplaceStore } from '@/stores/marketplace' import { useMessageMediaStore } from '@/stores/messageMedia' @@ -71,12 +72,6 @@ import type { } 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', -} - type Tab = 'discover' | 'search' | 'sell' | 'inbox' | 'profile' type Screen = 'main' | 'detail' | 'sell' | 'chat' | 'report' type ChatTimelineItem = @@ -113,9 +108,14 @@ type MediaContext = { type ProfileMediaContext = { draft: MarketplaceProfileDraft } const phone = usePhoneStore() +const detailActionColors = computed(() => ({ + bgIos: phone.isDarkMode ? 'bg-ios-dark-glass' : 'bg-ios-light-glass', + shadowIos: phone.isDarkMode ? 'shadow-ios-dark-glass' : 'shadow-ios-light-glass', +})) const route = useRoute() const router = useRouter() const account = useAccountStore() +const appStore = useAppStoreStore() const easyShare = useEasyShareStore() const marketplace = useMarketplaceStore() const messageMedia = useMessageMediaStore() @@ -141,7 +141,6 @@ const offerAmount = ref('') const offerPanelOpen = ref(false) const offerSubmitting = ref(false) const feedback = ref('') -const pagesSharePendingId = ref(null) const sellStep = ref(1) const submitting = ref(false) const selectedPhotoIds = ref([]) @@ -241,6 +240,11 @@ const tabs = [ ] as const const isAuthenticated = computed(() => account.email !== '') +const localPagesInstalled = computed( + () => + appStore.isInstalled('local-pages') && + !appStore.homeLayout.hidden.includes('local-pages'), +) const canSaveProfile = computed(() => { const nameLength = profileDraft.value.displayName.trim().length return nameLength >= 2 && nameLength <= 40 && profileDraft.value.bio.trim().length <= 160 @@ -371,21 +375,65 @@ function setFeedback(key: string): void { async function shareToLocalPages(listingId?: string): Promise { const id = listingId ?? selectedListing.value?.id - if (!id || pagesSharePendingId.value) return - pagesSharePendingId.value = id - const response = await pages.shareCityMarkt(id) - pagesSharePendingId.value = null - if (response.success && response.data?.id) { + if (!id) return + if (!localPagesInstalled.value) { + setFeedback('Apps.localPages.cityMarktAppMissing') + return + } + if (!pages.profile?.exists) { + setFeedback('Apps.localPages.cityMarktAccountMissing') + return + } + const existingPost = pages.ownItems.find( + (item) => item.citymarkt_listing_id === id, + ) + if (existingPost) { await router.push({ path: '/apps/local-pages', query: { - easyShareId: response.data.id, + easyShareId: existingPost.id, easyShareKind: 'post', }, }) return } - setFeedback(`Apps.localPages.errors.${response.error ?? 'default'}`) + await router.push({ + path: '/apps/local-pages', + query: { + cityMarktListingId: id, + compose: '1', + }, + }) +} + +function localPagesShareLabel(listingId: string): string { + if (!localPagesInstalled.value) + return phone.t('Apps.localPages.cityMarktAppMissing') + if (!pages.profile?.exists) + return phone.t('Apps.localPages.cityMarktAccountMissing') + return phone.t( + hasLocalPagesPost(listingId) + ? 'Apps.localPages.cityMarktAlreadyShared' + : 'Apps.localPages.cityMarktShare', + ) +} + +function localPagesShareHint(listingId: string): string { + if (!localPagesInstalled.value) + return phone.t('Apps.localPages.cityMarktInstallHint') + if (!pages.profile?.exists) + return phone.t('Apps.localPages.cityMarktAccountHint') + return phone.t( + hasLocalPagesPost(listingId) + ? 'Apps.localPages.cityMarktOpenSharedHint' + : 'Apps.localPages.cityMarktShareHint', + ) +} + +function hasLocalPagesPost(listingId: string): boolean { + return pages.ownItems.some( + (item) => item.citymarkt_listing_id === listingId, + ) } function shareListing(): void { @@ -870,7 +918,10 @@ onMounted(async () => { screen.value = 'sell' } if (isAuthenticated.value) { - await marketplace.loadProfile() + await Promise.all([ + marketplace.loadProfile(), + localPagesInstalled.value ? pages.loadProfile() : Promise.resolve(false), + ]) if (!marketplace.profile?.exists) { if (!profileSelection) syncProfileDraft() profileEditing.value = true @@ -1196,9 +1247,11 @@ onMounted(async () => { v-if="profileMode === 'own' && (item.status === 'active' || item.status === 'reserved')" class="citymarkt-profile-listing__share" type="button" - :disabled="pagesSharePendingId !== null" @click="shareToLocalPages(item.id)" - >{{ phone.t('Apps.localPages.cityMarktShare') }}{{ localPagesShareLabel(item.id) }} @@ -1211,39 +1264,40 @@ onMounted(async () => { class="citymarkt__detail" >
- + >
- - + + >
{ " class="citymarkt__pages-share" type="button" - :disabled="pagesSharePendingId !== null" @click="shareToLocalPages()" > - {{ phone.t('Apps.localPages.cityMarktShare') }}{{ - phone.t('Apps.localPages.cityMarktShareHint') - }}{{ localPagesShareLabel(selectedListing.id) }}{{ localPagesShareHint(selectedListing.id) }}
@@ -1922,10 +1976,17 @@ onMounted(async () => { gap: 6px; } .citymarkt-detail-action { - box-shadow: - inset 0 0 0 0.5px #ffffff26, - inset 0 1px 0 #ffffff1a, - 0 6px 16px #0007 !important; + width: 44px; + height: 44px; + padding: 0; + border: 0; + border-radius: 50%; + overflow: hidden; + display: grid; + place-items: center; +} +.citymarkt-detail-action.active { + color: var(--yellow); } .citymarkt__glass-list > .k-glass { width: 100%; @@ -1975,8 +2036,10 @@ onMounted(async () => { color: inherit; } .citymarkt-listing-card { + isolation: isolate; min-width: 0; border-radius: 16px; + display: grid !important; overflow: hidden; transition: filter 0.18s ease, @@ -1987,6 +2050,7 @@ onMounted(async () => { transform: translateY(-1px); } .citymarkt-listing-card > .citymarkt-listing-card__open { + grid-area: 1 / 1; width: 100%; min-width: 0; padding: 0; @@ -1997,25 +2061,35 @@ onMounted(async () => { text-align: left; } .citymarkt-listing-card__favorite { - position: absolute; + position: relative; z-index: 2; - top: 7px; - right: 7px; + grid-area: 1 / 1; + align-self: start; + justify-self: end; width: 30px; height: 30px; + margin: 7px; padding: 0; border: 1px solid #ffffff12; border-radius: 50%; - display: grid; + display: grid !important; place-items: center; background: #161714d9; - color: #d0d1cb; + color: #f8f8f4; box-shadow: 0 3px 10px #0005; + opacity: 1 !important; + visibility: visible !important; + pointer-events: auto !important; transition: color 0.16s ease, background 0.16s ease, transform 0.16s ease; } +.citymarkt-listing-card__favorite > svg { + display: block !important; + opacity: 1 !important; + visibility: visible !important; +} .citymarkt-listing-card__favorite:hover { background: #242520e6; color: #f5f5ef; @@ -2081,6 +2155,10 @@ onMounted(async () => { width: 116px; height: 116px; } +.citymarkt__grid--wide .citymarkt-listing-card__favorite { + justify-self: start; + margin-left: 79px; +} .citymarkt__grid--wide .citymarkt-listing-card__body { min-height: 116px; padding: 12px; diff --git a/frontend/src/views/apps/LocalPagesApp.vue b/frontend/src/views/apps/LocalPagesApp.vue index 56d2a19..3557f48 100644 --- a/frontend/src/views/apps/LocalPagesApp.vue +++ b/frontend/src/views/apps/LocalPagesApp.vue @@ -40,8 +40,10 @@ import CityMarktGallery from '@/components/citymarkt/CityMarktGallery.vue' import { useAccountStore } from '@/stores/account' import { useMessageMediaStore } from '@/stores/messageMedia' import { useEasyShareStore } from '@/stores/easyshare' +import { useMarketplaceStore } from '@/stores/marketplace' import { usePagesStore } from '@/stores/pages' import { usePhoneStore } from '@/stores/phone' +import type { MarketplaceListing } from '@/types/marketplace' import type { PagesCategory, PagesPost, PagesProfileDraft } from '@/types/pages' import type { PhoneMedia } from '@/types/media' import { @@ -68,6 +70,7 @@ const phone = usePhoneStore() const account = useAccountStore() const messageMedia = useMessageMediaStore() const easyShare = useEasyShareStore() +const marketplace = useMarketplaceStore() const pages = usePagesStore() const route = useRoute() const router = useRouter() @@ -91,6 +94,8 @@ const profilePending = ref(false) const profileDraft = ref({ avatarMediaId: 0, bio: '', handle: '' }) const selectedProfilePhoto = ref(null) const pickedPhotos = ref([]) +const cityMarktListing = ref(null) +const cityMarktListingId = ref(null) const draft = ref({ body: '', category: 'recommendation' as Exclude, @@ -274,7 +279,14 @@ function syncProfileDraft(): void { function ensureBrowserProfile(): void { const onboardingScenario = new URLSearchParams(window.location.search).get('testScenario') - if (!import.meta.env.DEV || onboardingScenario === 'local-pages-onboarding' || pages.profile) return + if ( + !import.meta.env.DEV || + ['local-pages-onboarding', 'citymarkt-local-pages-account-missing'].includes( + onboardingScenario ?? '', + ) || + pages.profile + ) + return const handle = account.email.split('@')[0].toLowerCase() pages.profile = { avatar_media_id: null, @@ -387,24 +399,45 @@ async function publish(): Promise { showFeedback('Apps.localPages.errors.invalid_post') return } - const response = await pages.create({ - body: draft.value.body.trim(), - category: draft.value.category, - district: draft.value.district, - images: draft.value.images.map((id) => ({ id })), - title: draft.value.title.trim(), - }) + const response = cityMarktListingId.value + ? await pages.shareCityMarkt(cityMarktListingId.value) + : await pages.create({ + body: draft.value.body.trim(), + category: draft.value.category, + district: draft.value.district, + images: draft.value.images.map((id) => ({ id })), + title: draft.value.title.trim(), + }) if (!response.success) { showFeedback(`Apps.localPages.errors.${response.error ?? 'default'}`) return } draft.value = { body: '', category: 'recommendation', district: 'los_santos', images: [], title: '' } pickedPhotos.value = [] + cityMarktListing.value = null + cityMarktListingId.value = null tab.value = 'feed' screen.value = 'main' + await router.replace('/apps/local-pages') + await Promise.all([pages.load(), pages.loadProfile()]) showFeedback('Apps.localPages.published') } +function closeCompose(): void { + draft.value = { + body: '', + category: 'recommendation', + district: 'los_santos', + images: [], + title: '', + } + pickedPhotos.value = [] + cityMarktListing.value = null + cityMarktListingId.value = null + screen.value = 'main' + void router.replace('/apps/local-pages') +} + async function react(kind: 'like' | 'save'): Promise { if (!selected.value || !isAuthenticated.value) { showFeedback('Apps.localPages.errors.not_authenticated') @@ -511,7 +544,34 @@ onMounted(async () => { screen.value = 'main' } onboardingReady.value = true - if (route.query.compose === '1' && pages.profile?.exists) screen.value = 'compose' + if (route.query.compose === '1' && pages.profile?.exists) { + const listingId = String(route.query.cityMarktListingId ?? '') + if (listingId) { + const response = await marketplace.get(listingId) + if ( + response.success && + response.data?.is_owner && + ['active', 'reserved'].includes(response.data.status) + ) { + cityMarktListing.value = response.data + cityMarktListingId.value = response.data.id + draft.value = { + body: response.data.description, + category: 'recommendation', + district: response.data.district ?? 'los_santos', + images: response.data.images.map((image) => image.media_id), + title: response.data.title, + } + pickedPhotos.value = response.data.images.map((image) => ({ + background: image.gradient, + id: image.media_id, + })) + } else { + showFeedback('Apps.localPages.errors.citymarkt_not_found') + } + } + if (!listingId || cityMarktListingId.value) screen.value = 'compose' + } const easyShareId = String(route.query.easyShareId ?? '') if (pages.profile?.exists && easyShareId && route.query.easyShareKind === 'post') { const response = await pages.get(easyShareId) @@ -752,18 +812,22 @@ onMounted(async () => { -
+
+ + + {{ phone.t('Apps.localPages.cityMarktComposeTitle') }}{{ phone.t('Apps.localPages.cityMarktComposeHint') }} +