diff --git a/frontend/src/assets/img/app-icons/local-pages.webp b/frontend/src/assets/img/app-icons/local-pages.webp new file mode 100644 index 0000000..5037723 Binary files /dev/null and b/frontend/src/assets/img/app-icons/local-pages.webp differ diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index 6369dc0..83f3b25 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -18,6 +18,7 @@ import { CloudSun, Wind, Tag, + MapPinHouse, } from 'lucide-vue-next' import { defineAsyncComponent, markRaw } from 'vue' @@ -40,9 +41,23 @@ import skyFlappyIcon from '@/assets/img/app-icons/sky-flappy.webp' import neonDropIcon from '@/assets/img/app-icons/neon-drop.webp' import weatherIcon from '@/assets/img/app-icons/weather.webp' import citymarktIcon from '@/assets/img/app-icons/citymarkt.webp' +import localPagesIcon from '@/assets/img/app-icons/local-pages.webp' import type { PhoneAppDefinition, PhoneAppId } from '@/types/apps' export const PHONE_APPS: PhoneAppDefinition[] = [ + { + component: markRaw( + defineAsyncComponent(() => import('@/views/apps/LocalPagesApp.vue')), + ), + dockOrder: null, + gridOrder: 19, + icon: markRaw(MapPinHouse), + iconClass: 'app-icon--local-pages', + iconImage: localPagesIcon, + id: 'local-pages', + labelKey: 'Apps.localPages.name', + route: '/apps/local-pages', + }, { component: markRaw( defineAsyncComponent(() => import('@/views/apps/PhoneApp.vue')), diff --git a/frontend/src/stores/pages.test.ts b/frontend/src/stores/pages.test.ts new file mode 100644 index 0000000..e2d027c --- /dev/null +++ b/frontend/src/stores/pages.test.ts @@ -0,0 +1,33 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { usePagesStore } from '@/stores/pages' +import { nuiCall } from '@/utils/nui' + +vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() })) +const mockNuiCall = vi.mocked(nuiCall) + +describe('Local Pages store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + mockNuiCall.mockReset() + }) + + it('shares only the CityMarkt listing id with the server', async () => { + mockNuiCall.mockResolvedValueOnce({ data: { id: 'post-id' }, success: true }) + const response = await usePagesStore().shareCityMarkt('listing-id') + expect(response.success).toBe(true) + expect(mockNuiCall).toHaveBeenCalledWith('pages:share-citymarkt', { + listingId: 'listing-id', + }) + }) + + it('updates a successful like locally', async () => { + mockNuiCall.mockResolvedValueOnce({ success: true }) + const pages = usePagesStore() + pages.items = [{ id: 'post-id', is_liked: false, like_count: 2 } as never] + await pages.react('post-id', 'like', true) + expect(pages.items[0]?.is_liked).toBe(true) + expect(pages.items[0]?.like_count).toBe(3) + }) +}) diff --git a/frontend/src/stores/pages.ts b/frontend/src/stores/pages.ts new file mode 100644 index 0000000..a5e492d --- /dev/null +++ b/frontend/src/stores/pages.ts @@ -0,0 +1,63 @@ +import { defineStore } from 'pinia' + +import type { PagesPage, PagesPost, PagesPostDraft } from '@/types/pages' +import { nuiCall, type NuiResponse } from '@/utils/nui' + +export const usePagesStore = defineStore('pages', { + state: () => ({ + items: [] as PagesPost[], + ownItems: [] as PagesPost[], + savedItems: [] as PagesPost[], + isLoading: false, + }), + actions: { + async load(filters: Record = {}): Promise { + this.isLoading = true + const response = await nuiCall('pages:list', filters) + this.isLoading = false + if (response.success && response.data) this.items = response.data.items + return response.success + }, + async loadProfile(): Promise { + const [own, saved] = await Promise.all([ + nuiCall('pages:list-own'), + nuiCall('pages:list', { saved: true }), + ]) + if (own.success && own.data) this.ownItems = own.data.items + if (saved.success && saved.data) this.savedItems = saved.data.items + return own.success && saved.success + }, + get(id: string): Promise> { + return nuiCall('pages:get', { id }) + }, + async create(draft: PagesPostDraft): Promise> { + const response = await nuiCall<{ id: string }>('pages:create', draft) + if (response.success) await Promise.all([this.load(), this.loadProfile()]) + return response + }, + async shareCityMarkt(listingId: string): Promise> { + return nuiCall<{ id: string }>('pages:share-citymarkt', { listingId }) + }, + async react(id: string, kind: 'like' | 'save', active: boolean): Promise { + const response = await nuiCall('pages:react', { active, id, kind }) + if (response.success) { + for (const item of [...this.items, ...this.ownItems, ...this.savedItems]) { + if (item.id !== id) continue + if (kind === 'like') { + item.like_count = Math.max(0, item.like_count + (active ? 1 : -1)) + item.is_liked = active + } else item.is_saved = active + } + } + return response.success + }, + async remove(id: string): Promise { + const response = await nuiCall('pages:delete', { id }) + if (response.success) { + this.items = this.items.filter((item) => item.id !== id) + this.ownItems = this.ownItems.filter((item) => item.id !== id) + } + return response.success + }, + }, +}) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 4d56526..a438d2d 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -414,6 +414,23 @@ const defaultLocales: LocaleTree = { default: 'The CityMarkt request failed.', }, }, + localPages: { + name: 'Local Pages', eyebrow: 'Your city. Your stories.', cityPulse: 'Live from Los Santos', + heroTitle: 'What is happening nearby?', heroBody: 'Places, people, tips and local discoveries from the community.', + searchPlaceholder: 'Search posts and places', search: 'Search', allCategories: 'All categories', + allLosSantos: 'Los Santos', hoursAgo: '{count}h ago', daysAgo: '{count}d ago', + categories: { recommendation: 'Recommended', wanted: 'Wanted', service: 'Service', event: 'Event', place: 'Place', community: 'Community', citymarkt: 'CityMarkt' }, + discover: 'Discover', create: 'Post', profile: 'Profile', post: 'Post', posts: 'posts', + noPosts: 'Nothing here yet', noPostsBody: 'Be the first to share something with the city.', noPhoto: 'No photo attached', + signInTitle: 'Your Local Pages profile', signInBody: 'Sign in to iFruit in Settings to publish and save posts.', + localCreator: 'Local creator', myPosts: 'My posts', saved: 'Saved', save: 'Save', likes: 'likes', + location: 'Location', sharedFrom: 'Shared from CityMarkt', openCityMarkt: 'Open CityMarkt listing', + newPost: 'New local post', shareWithCity: 'Share with the city', publish: 'Publish', published: 'Your post is live.', deleted: 'Post deleted.', + title: 'Title', body: 'Your story', category: 'Category', titlePlaceholder: 'What should people know?', bodyPlaceholder: 'Add details, a recommendation or directions...', + photos: 'Photos', optional: 'optional', camera: 'Camera', gallery: 'Gallery', photoLimit: 'You can add up to six photos.', + cityMarktShare: 'Share to Local Pages', cityMarktShared: 'Shared to Local Pages.', cityMarktShareHint: 'One CityMarkt share per day', + errors: { invalid_post: 'Add a title and a little more detail.', invalid_images: 'Choose valid photos from this phone.', invalid_request: 'This action is not valid.', post_not_found: 'This post is no longer available.', citymarkt_not_found: 'This CityMarkt listing is unavailable.', citymarkt_daily_limit: 'You already shared a CityMarkt listing today.', citymarkt_already_shared: 'This listing was already shared.', not_authenticated: 'Sign in to iFruit first.', rate_limited: 'Too many requests. Try again shortly.', request_failed: 'The post could not be saved.', default: 'Local Pages is temporarily unavailable.' }, + }, map: { name: 'Map', controls: 'Map controls', diff --git a/frontend/src/types/apps.ts b/frontend/src/types/apps.ts index 56b2874..0c692a6 100644 --- a/frontend/src/types/apps.ts +++ b/frontend/src/types/apps.ts @@ -20,6 +20,7 @@ export type PhoneAppId = | 'sky-flappy' | 'neon-drop' | 'citymarkt' + | 'local-pages' export type AppLaunchOrigin = { borderRadius: number diff --git a/frontend/src/types/pages.ts b/frontend/src/types/pages.ts new file mode 100644 index 0000000..40f82c2 --- /dev/null +++ b/frontend/src/types/pages.ts @@ -0,0 +1,47 @@ +export type PagesCategory = + | 'recommendation' + | 'wanted' + | 'service' + | 'event' + | 'place' + | 'community' + | 'citymarkt' + +export type PagesImage = { + gradient: string + media_id: string + sort_order: number +} + +export type PagesPost = { + author_name: string + body: string + category: PagesCategory + citymarkt_listing_id: string | null + citymarkt_price: number | string | null + created_at: string + district: string | null + id: string + image: string | null + images: PagesImage[] + is_liked: boolean | number + is_owner: boolean | number + is_saved: boolean | number + like_count: number + source_type: 'personal' | 'citymarkt' + title: string +} + +export type PagesPostDraft = { + body: string + category: Exclude + district: string + images: Array<{ id: string }> + title: string +} + +export type PagesPage = { + hasMore: boolean + items: PagesPost[] + offset: number +} diff --git a/frontend/src/utils/preferences.ts b/frontend/src/utils/preferences.ts index 7127855..3f7424b 100644 --- a/frontend/src/utils/preferences.ts +++ b/frontend/src/utils/preferences.ts @@ -58,6 +58,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record< 'sky-flappy': { enabled: true, sounds: true }, 'neon-drop': { enabled: true, sounds: true }, citymarkt: { enabled: true, sounds: true }, + 'local-pages': { enabled: true, sounds: true }, camera: { enabled: true, sounds: true }, clock: { enabled: true, sounds: true }, weather: { enabled: true, sounds: true }, diff --git a/frontend/src/views/apps/CityMarktApp.vue b/frontend/src/views/apps/CityMarktApp.vue index ff0b436..6eb346d 100644 --- a/frontend/src/views/apps/CityMarktApp.vue +++ b/frontend/src/views/apps/CityMarktApp.vue @@ -22,6 +22,7 @@ import { MoreHorizontal, Search, Send, + Share2, Shirt, Tag, UserRound, @@ -36,6 +37,7 @@ import CityMarktOfferCard from '@/components/citymarkt/CityMarktOfferCard.vue' import { useAccountStore } from '@/stores/account' import { useMarketplaceStore } from '@/stores/marketplace' import { useMediaStore } from '@/stores/media' +import { usePagesStore } from '@/stores/pages' import { usePhoneStore } from '@/stores/phone' import type { MarketplaceCategory, @@ -59,6 +61,7 @@ const phone = usePhoneStore() const account = useAccountStore() const marketplace = useMarketplaceStore() const media = useMediaStore() +const pages = usePagesStore() const tab = ref('discover') const screen = ref('main') const selectedListing = ref(null) @@ -258,6 +261,14 @@ function setFeedback(key: string): void { }, 2600) } +async function shareToLocalPages(): Promise { + if (!selectedListing.value) return + const response = await pages.shareCityMarkt(selectedListing.value.id) + setFeedback(response.success + ? 'Apps.localPages.cityMarktShared' + : `Apps.localPages.errors.${response.error ?? 'default'}`) +} + async function loadFeed(): Promise { await marketplace.load({ category: category.value, @@ -695,6 +706,12 @@ onMounted(async () => {
{{ selectedListing.seller_name.charAt(0).toUpperCase() }}
{{ selectedListing.seller_name }}{{ selectedListing.seller_active }} {{ phone.t('Apps.citymarkt.activeListings') }}

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