mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 02:01:40 +00:00
ENH - refine CityMarkt listings and profiles
This commit is contained in:
@@ -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<boolean> {
|
||||
const response = await nuiCall<MarketplaceProfile>('marketplace:profile')
|
||||
if (response.success && response.data) this.profile = response.data
|
||||
return response.success
|
||||
},
|
||||
async saveProfile(draft: MarketplaceProfileDraft): Promise<NuiResponse<MarketplaceProfile>> {
|
||||
const response = await nuiCall<MarketplaceProfile>('marketplace:profile-save', draft)
|
||||
if (response.success && response.data) this.profile = response.data
|
||||
return response
|
||||
},
|
||||
async load(filters: Record<string, unknown> = {}): Promise<boolean> {
|
||||
this.isLoading = true
|
||||
const response = await nuiCall<ListingPage>('marketplace:list', filters)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<MarketplaceCategory | 'all'>('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<MarketplaceProfileDraft>({ avatarMediaId: 0, bio: '', displayName: '' })
|
||||
const selectedProfilePhoto = ref<PhoneMedia | null>(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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
|
||||
onMounted(async () => {
|
||||
const selection = messageMedia.consumeMany<MediaContext>('citymarkt:sell')
|
||||
const profileSelection = messageMedia.consumeMany<ProfileMediaContext>('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' }"
|
||||
>
|
||||
<k-navbar
|
||||
v-if="screen === 'main'"
|
||||
v-if="screen === 'main' && onboardingReady"
|
||||
class="citymarkt-navbar"
|
||||
:subtitle="
|
||||
phone.t(
|
||||
@@ -810,7 +915,8 @@ onMounted(async () => {
|
||||
"
|
||||
/>
|
||||
|
||||
<section v-if="screen === 'main'" class="citymarkt__content">
|
||||
<div v-if="!onboardingReady" class="citymarkt__gate-loading">{{ phone.t('Common.loading') }}</div>
|
||||
<section v-if="screen === 'main' && onboardingReady" class="citymarkt__content">
|
||||
<template v-if="tab === 'discover' || tab === 'search'">
|
||||
<k-searchbar
|
||||
component="form"
|
||||
@@ -910,7 +1016,11 @@ onMounted(async () => {
|
||||
:key="item.id"
|
||||
class="citymarkt-listing-card"
|
||||
>
|
||||
<button type="button" @click="openListing(item)">
|
||||
<button
|
||||
type="button"
|
||||
class="citymarkt-listing-card__open"
|
||||
@click="openListing(item)"
|
||||
>
|
||||
<span
|
||||
class="citymarkt__card-image"
|
||||
:class="{ 'citymarkt__card-image--empty': !item.image }"
|
||||
@@ -924,7 +1034,6 @@ onMounted(async () => {
|
||||
<i v-if="item.status === 'reserved'">{{
|
||||
phone.t('Apps.citymarkt.status.reserved')
|
||||
}}</i>
|
||||
<Heart v-if="item.is_favorite" :size="15" fill="currentColor" />
|
||||
</span>
|
||||
<span class="citymarkt-listing-card__body">
|
||||
<strong>{{ formatPrice(item) }}</strong>
|
||||
@@ -940,6 +1049,24 @@ onMounted(async () => {
|
||||
>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="citymarkt-listing-card__favorite"
|
||||
:class="{ active: Boolean(item.is_favorite) }"
|
||||
:aria-label="
|
||||
phone.t(
|
||||
item.is_favorite
|
||||
? 'Apps.citymarkt.removeFavorite'
|
||||
: 'Apps.citymarkt.addFavorite',
|
||||
)
|
||||
"
|
||||
@click.stop="toggleListingFavorite(item)"
|
||||
>
|
||||
<Heart
|
||||
:size="15"
|
||||
:fill="item.is_favorite ? 'currentColor' : 'none'"
|
||||
/>
|
||||
</button>
|
||||
</k-glass>
|
||||
</div>
|
||||
</template>
|
||||
@@ -983,13 +1110,46 @@ onMounted(async () => {
|
||||
<p>{{ phone.t('Apps.citymarkt.signInBody') }}</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<k-glass class="citymarkt__glass-profile"
|
||||
><span>{{ account.email.charAt(0).toUpperCase() }}</span>
|
||||
<section v-if="profileEditing || !marketplace.profile?.exists" class="citymarkt__profile-editor">
|
||||
<header>
|
||||
<div><UserRound :size="21" /></div>
|
||||
<span>
|
||||
<strong>{{ phone.t(marketplace.profile?.exists ? 'Apps.citymarkt.editProfile' : 'Apps.citymarkt.createProfile') }}</strong>
|
||||
<small>{{ phone.t('Apps.citymarkt.profileIntro') }}</small>
|
||||
</span>
|
||||
</header>
|
||||
<div class="citymarkt__profile-photo">
|
||||
<span>
|
||||
<img v-if="selectedProfilePhoto?.url || marketplace.profile?.avatar_url" :src="selectedProfilePhoto?.url ?? marketplace.profile?.avatar_url ?? ''" alt="" />
|
||||
<template v-else>{{ profileDraft.displayName.charAt(0).toUpperCase() || account.email.charAt(0).toUpperCase() }}</template>
|
||||
</span>
|
||||
<div>
|
||||
<button type="button" @click="openProfileMedia('photos')"><Images :size="15" />{{ phone.t('Apps.citymarkt.chooseGallery') }}</button>
|
||||
<button type="button" @click="openProfileMedia('camera')"><Camera :size="15" />{{ phone.t('Apps.citymarkt.takePhoto') }}</button>
|
||||
<button v-if="profileDraft.avatarMediaId" type="button" class="danger" @click="removeProfilePhoto">{{ phone.t('Apps.citymarkt.removeProfilePhoto') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<label>{{ phone.t('Apps.citymarkt.profileEmail') }}<k-glass><input :value="account.email" readonly /></k-glass></label>
|
||||
<label>{{ phone.t('Apps.citymarkt.displayName') }}<k-glass><input v-model="profileDraft.displayName" maxlength="40" /></k-glass></label>
|
||||
<label>{{ phone.t('Apps.citymarkt.profileBio') }}<k-glass><textarea v-model="profileDraft.bio" maxlength="160" /></k-glass></label>
|
||||
<div class="citymarkt__profile-actions">
|
||||
<button v-if="marketplace.profile?.exists" type="button" @click="cancelProfileEdit">{{ phone.t('Apps.citymarkt.cancel') }}</button>
|
||||
<button type="button" :disabled="!canSaveProfile || profilePending" @click="saveProfile">{{ phone.t('Apps.citymarkt.saveProfile') }}</button>
|
||||
</div>
|
||||
</section>
|
||||
<template v-else>
|
||||
<k-glass class="citymarkt__glass-profile">
|
||||
<span>
|
||||
<img v-if="marketplace.profile?.avatar_url" :src="marketplace.profile.avatar_url" alt="" />
|
||||
<template v-else>{{ marketplace.profile?.display_name.charAt(0).toUpperCase() }}</template>
|
||||
</span>
|
||||
<div>
|
||||
<strong>{{ account.email.split('@')[0] }}</strong
|
||||
><small>{{ account.email }}</small>
|
||||
</div></k-glass
|
||||
>
|
||||
<strong>{{ marketplace.profile?.display_name }}</strong>
|
||||
<small>{{ account.email }}</small>
|
||||
<p v-if="marketplace.profile?.bio">{{ marketplace.profile.bio }}</p>
|
||||
</div>
|
||||
<button type="button" :aria-label="phone.t('Apps.citymarkt.editProfile')" @click="editProfile"><Pencil :size="16" /></button>
|
||||
</k-glass>
|
||||
<k-glass class="citymarkt__glass-segmented">
|
||||
<button
|
||||
type="button"
|
||||
@@ -1041,6 +1201,7 @@ onMounted(async () => {
|
||||
><Share2 :size="15" />{{ phone.t('Apps.localPages.cityMarktShare') }}</button
|
||||
></k-glass>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</section>
|
||||
@@ -1053,7 +1214,8 @@ onMounted(async () => {
|
||||
<k-fab
|
||||
component="button"
|
||||
type="button"
|
||||
:colors="yellowGlassActionColors"
|
||||
:colors="glassActionColors"
|
||||
class="citymarkt-detail-action"
|
||||
:aria-label="phone.t('Common.back')"
|
||||
@click="screen = 'main'"
|
||||
><template #icon><ArrowLeft :size="19" /></template
|
||||
@@ -1064,7 +1226,8 @@ onMounted(async () => {
|
||||
component="button"
|
||||
type="button"
|
||||
:colors="glassActionColors"
|
||||
@click="toggleFavorite"
|
||||
class="citymarkt-detail-action"
|
||||
@click="toggleListingFavorite(selectedListing)"
|
||||
><template #icon
|
||||
><Heart
|
||||
:size="19"
|
||||
@@ -1077,6 +1240,7 @@ onMounted(async () => {
|
||||
component="button"
|
||||
type="button"
|
||||
:colors="glassActionColors"
|
||||
class="citymarkt-detail-action"
|
||||
@click="screen = 'report'"
|
||||
><template #icon><MoreHorizontal :size="20" /></template
|
||||
></k-fab>
|
||||
@@ -1113,7 +1277,7 @@ onMounted(async () => {
|
||||
</p>
|
||||
<p class="citymarkt__description">{{ selectedListing.description }}</p>
|
||||
<div class="citymarkt__seller">
|
||||
<span>{{ selectedListing.seller_name.charAt(0).toUpperCase() }}</span>
|
||||
<span><img v-if="selectedListing.seller_avatar" :src="selectedListing.seller_avatar" alt="" /><template v-else>{{ selectedListing.seller_name.charAt(0).toUpperCase() }}</template></span>
|
||||
<div>
|
||||
<strong>{{ selectedListing.seller_name }}</strong
|
||||
><small
|
||||
@@ -1593,7 +1757,7 @@ onMounted(async () => {
|
||||
</section>
|
||||
|
||||
<k-tabbar
|
||||
v-if="screen === 'main'"
|
||||
v-if="screen === 'main' && onboardingReady && isAuthenticated && marketplace.profile?.exists && !profileEditing"
|
||||
component="nav"
|
||||
icons
|
||||
labels
|
||||
@@ -1730,8 +1894,8 @@ onMounted(async () => {
|
||||
transform 0.18s ease;
|
||||
}
|
||||
.citymarkt__categories button:hover .citymarkt__category-icon {
|
||||
filter: brightness(1.15);
|
||||
transform: translateY(-2px);
|
||||
filter: brightness(1.04);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.citymarkt__categories button.active {
|
||||
color: #fff;
|
||||
@@ -1757,6 +1921,12 @@ onMounted(async () => {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.citymarkt-detail-action {
|
||||
box-shadow:
|
||||
inset 0 0 0 0.5px #ffffff26,
|
||||
inset 0 1px 0 #ffffff1a,
|
||||
0 6px 16px #0007 !important;
|
||||
}
|
||||
.citymarkt__glass-list > .k-glass {
|
||||
width: 100%;
|
||||
flex: none;
|
||||
@@ -1813,10 +1983,10 @@ onMounted(async () => {
|
||||
transform 0.18s ease;
|
||||
}
|
||||
.citymarkt-listing-card:hover {
|
||||
filter: brightness(1.08);
|
||||
transform: translateY(-2px);
|
||||
filter: brightness(1.025);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.citymarkt-listing-card > button {
|
||||
.citymarkt-listing-card > .citymarkt-listing-card__open {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
@@ -1826,6 +1996,36 @@ onMounted(async () => {
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
}
|
||||
.citymarkt-listing-card__favorite {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 7px;
|
||||
right: 7px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border: 1px solid #ffffff12;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #161714d9;
|
||||
color: #d0d1cb;
|
||||
box-shadow: 0 3px 10px #0005;
|
||||
transition:
|
||||
color 0.16s ease,
|
||||
background 0.16s ease,
|
||||
transform 0.16s ease;
|
||||
}
|
||||
.citymarkt-listing-card__favorite:hover {
|
||||
background: #242520e6;
|
||||
color: #f5f5ef;
|
||||
}
|
||||
.citymarkt-listing-card__favorite:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
.citymarkt-listing-card__favorite.active {
|
||||
color: var(--yellow);
|
||||
}
|
||||
.citymarkt-listing-card .citymarkt__card-image {
|
||||
height: 108px;
|
||||
margin: 0;
|
||||
@@ -1871,7 +2071,9 @@ onMounted(async () => {
|
||||
.citymarkt__grid--wide {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.citymarkt__grid--wide .citymarkt-listing-card > button {
|
||||
.citymarkt__grid--wide
|
||||
.citymarkt-listing-card
|
||||
> .citymarkt-listing-card__open {
|
||||
display: grid;
|
||||
grid-template-columns: 116px minmax(0, 1fr);
|
||||
}
|
||||
@@ -1957,6 +2159,36 @@ onMounted(async () => {
|
||||
color: #171816;
|
||||
font-size: 19px;
|
||||
font-weight: 900;
|
||||
overflow: hidden;
|
||||
}
|
||||
.citymarkt__glass-profile > span img,
|
||||
.citymarkt__seller > span img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.citymarkt__seller > span {
|
||||
overflow: hidden;
|
||||
}
|
||||
.citymarkt__glass-profile > div {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.citymarkt__glass-profile > div p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.citymarkt__glass-profile > button {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #ffffff0d;
|
||||
color: var(--yellow);
|
||||
}
|
||||
.citymarkt__glass-profile strong,
|
||||
.citymarkt__glass-profile small {
|
||||
@@ -2060,14 +2292,148 @@ onMounted(async () => {
|
||||
right: 0;
|
||||
left: 0;
|
||||
}
|
||||
.citymarkt-create-action {
|
||||
.citymarkt-create-navbar :deep(.citymarkt-create-action) {
|
||||
height: 44px;
|
||||
flex: none;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
.citymarkt-create-action--close {
|
||||
width: 44px;
|
||||
.citymarkt__gate-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.citymarkt-create-action--next {
|
||||
.citymarkt__profile-editor {
|
||||
padding: 14px;
|
||||
border: 1px solid #ffffff14;
|
||||
border-radius: 18px;
|
||||
background: var(--panel);
|
||||
}
|
||||
.citymarkt__profile-editor > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.citymarkt__profile-editor > header > div {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 13px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--yellow);
|
||||
color: #171816;
|
||||
}
|
||||
.citymarkt__profile-editor > header strong,
|
||||
.citymarkt__profile-editor > header small {
|
||||
display: block;
|
||||
}
|
||||
.citymarkt__profile-editor > header small {
|
||||
margin-top: 2px;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
.citymarkt__profile-photo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.citymarkt__profile-photo > span {
|
||||
width: 68px;
|
||||
height: 68px;
|
||||
flex: none;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
background: var(--yellow);
|
||||
color: #171816;
|
||||
font-size: 25px;
|
||||
font-weight: 900;
|
||||
}
|
||||
.citymarkt__profile-photo > span img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.citymarkt__profile-photo > div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.citymarkt__profile-photo button {
|
||||
padding: 7px 9px;
|
||||
border: 1px solid #ffffff14;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background: #ffffff0a;
|
||||
font-size: 10px;
|
||||
}
|
||||
.citymarkt__profile-photo button.danger {
|
||||
color: #ff796f;
|
||||
}
|
||||
.citymarkt__profile-editor > label {
|
||||
margin-top: 12px;
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.citymarkt__profile-editor > label > .k-glass {
|
||||
margin-top: 5px;
|
||||
border-radius: 11px;
|
||||
}
|
||||
.citymarkt__profile-editor input,
|
||||
.citymarkt__profile-editor textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
resize: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
.citymarkt__profile-editor input[readonly] {
|
||||
color: var(--muted);
|
||||
}
|
||||
.citymarkt__profile-editor textarea {
|
||||
min-height: 72px;
|
||||
}
|
||||
.citymarkt__profile-actions {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.citymarkt__profile-actions button {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
border: 0;
|
||||
border-radius: 11px;
|
||||
background: #ffffff0d;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.citymarkt__profile-actions button:last-child {
|
||||
background: var(--yellow);
|
||||
color: #171816;
|
||||
}
|
||||
.citymarkt__profile-actions button:disabled {
|
||||
opacity: .4;
|
||||
}
|
||||
:global(.citymarkt--light) .citymarkt__profile-editor {
|
||||
border-color: #00000012;
|
||||
}
|
||||
.citymarkt-create-navbar :deep(.citymarkt-create-action--close) {
|
||||
width: 44px;
|
||||
min-width: 44px;
|
||||
max-width: 44px;
|
||||
}
|
||||
.citymarkt-create-navbar :deep(.citymarkt-create-action--next) {
|
||||
min-width: 58px;
|
||||
}
|
||||
.citymarkt-create-close,
|
||||
|
||||
@@ -1199,7 +1199,12 @@ Locales["en"] = {
|
||||
hoursAgo = "{count}h ago", daysAgo = "{count}d ago", photos = "photos", activeListings = "active listings",
|
||||
signInTitle = "Sign in to iFruit", signInBody = "Use Settings to sign in before selling, saving or messaging.",
|
||||
noMessages = "No conversations", noMessagesBody = "Messages about offers will appear here.",
|
||||
myListings = "My listings", favorites = "Favorites", noProfileListings = "Nothing here yet",
|
||||
myListings = "My listings", favorites = "Favorites", addFavorite = "Add to favorites", removeFavorite = "Remove from favorites", noProfileListings = "Nothing here yet",
|
||||
createProfile = "Create your CityMarkt profile", editProfile = "Edit profile",
|
||||
profileIntro = "Your iFruit email stays linked to this profile.", profileEmail = "iFruit email",
|
||||
displayName = "Display name", profileBio = "About you", saveProfile = "Save profile",
|
||||
cancel = "Cancel", profileSaved = "Your profile was saved.", chooseGallery = "Gallery",
|
||||
takePhoto = "Camera", removeProfilePhoto = "Remove photo",
|
||||
phone = "Phone", contactSeller = "Contact seller", messagePlaceholder = "Hi, is this still available?",
|
||||
signInToMessage = "Sign in to your iFruit account to send a message.",
|
||||
edit = "Edit", makeActive = "Make active", markSold = "Mark sold", remove = "Remove",
|
||||
@@ -1232,6 +1237,9 @@ Locales["en"] = {
|
||||
offer_conflict = "The offer changed. Please try again.",
|
||||
blocked = "Messages are blocked between these accounts.", already_reported = "You already reported this listing.",
|
||||
rate_limited = "Too many requests. Try again shortly.", not_authenticated = "Sign in to your iFruit account first.",
|
||||
invalid_profile = "Enter a display name between 2 and 40 characters.",
|
||||
invalid_profile_image = "Choose a valid photo from this phone.",
|
||||
profile_required = "Create your CityMarkt profile first.",
|
||||
conflict = "The listing changed. Open it again.", request_failed = "CityMarkt is temporarily unavailable.",
|
||||
default = "The CityMarkt request failed.",
|
||||
},
|
||||
|
||||
@@ -43,6 +43,8 @@ local server_callbacks = {
|
||||
"mail:delete-forever",
|
||||
"mail:empty-trash",
|
||||
"marketplace:list",
|
||||
"marketplace:profile",
|
||||
"marketplace:profile-save",
|
||||
"marketplace:get",
|
||||
"marketplace:list-own",
|
||||
"marketplace:create",
|
||||
|
||||
@@ -591,6 +591,29 @@ local schema = {
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_marketplace_profiles",
|
||||
columns = {
|
||||
{ name = "account_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "display_name", type = "VARCHAR(40) NOT NULL" },
|
||||
{ name = "bio", type = "VARCHAR(160) NOT NULL DEFAULT ''" },
|
||||
{ name = "avatar_media_id", type = "BIGINT UNSIGNED NULL" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
{
|
||||
name = "updated_at",
|
||||
type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP",
|
||||
},
|
||||
},
|
||||
primaryKey = "account_id",
|
||||
indexes = {
|
||||
{ name = "idx_sky_phone_marketplace_profile_avatar", columns = "(`avatar_media_id`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "avatar_media_id", references = "`sky_phone_media` (`id`) ON DELETE SET NULL" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_marketplace_listings",
|
||||
columns = {
|
||||
|
||||
@@ -75,6 +75,84 @@ local function require_account(source)
|
||||
return SkyPhone.RequireAccount(source)
|
||||
end
|
||||
|
||||
local function default_display_name(account)
|
||||
return account.email:match("^([^@]+)") or account.email
|
||||
end
|
||||
|
||||
local function profile_dto(account)
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT profile.`display_name`, profile.`bio`, profile.`avatar_media_id`,
|
||||
avatar.`url` AS `avatar_url`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_marketplace_listings` listing
|
||||
WHERE listing.`seller_account_id` = ?) AS `listing_count`
|
||||
FROM `sky_phone_accounts` account
|
||||
LEFT JOIN `sky_phone_marketplace_profiles` profile ON profile.`account_id` = account.`id`
|
||||
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = profile.`avatar_media_id`
|
||||
WHERE account.`id` = ?
|
||||
LIMIT 1
|
||||
]], { account.id, account.id })
|
||||
local row = rows[1]
|
||||
if not row then
|
||||
error(("[sky_phone] Could not load CityMarkt profile account %s."):format(account.id))
|
||||
end
|
||||
return {
|
||||
avatar_media_id = tonumber(row.avatar_media_id),
|
||||
avatar_url = row.avatar_url,
|
||||
bio = row.bio or "",
|
||||
display_name = row.display_name or default_display_name(account),
|
||||
email = account.email,
|
||||
exists = row.display_name ~= nil,
|
||||
listing_count = tonumber(row.listing_count) or 0,
|
||||
}
|
||||
end
|
||||
|
||||
local function require_profile(account_id)
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT `account_id` FROM `sky_phone_marketplace_profiles`
|
||||
WHERE `account_id` = ? LIMIT 1
|
||||
]], { account_id })
|
||||
if not rows[1] then
|
||||
return { success = false, error = "profile_required" }
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:marketplace:profile", function(source)
|
||||
local account, error_response = require_account(source)
|
||||
if not account then return error_response end
|
||||
return { success = true, data = profile_dto(account) }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:marketplace:profile-save", function(source, data)
|
||||
local account, error_response = require_account(source)
|
||||
if not account then return error_response end
|
||||
if not SkyPhone.AllowOperation(source, "marketplace:profile-save", 12, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
if type(data) ~= "table" or type(data.displayName) ~= "string" or type(data.bio) ~= "string" then
|
||||
return { success = false, error = "invalid_profile" }
|
||||
end
|
||||
local display_name = trim(data.displayName)
|
||||
local bio = trim(data.bio)
|
||||
local avatar_media_id = tonumber(data.avatarMediaId)
|
||||
if not valid_text(display_name, 2, 40) or not valid_text(bio, 0, 160)
|
||||
or not avatar_media_id or avatar_media_id < 0 or avatar_media_id ~= math.floor(avatar_media_id)
|
||||
then
|
||||
return { success = false, error = "invalid_profile" }
|
||||
end
|
||||
if avatar_media_id > 0 and not SkyPhoneMedia.ResolveOwnedMedia(source, tostring(avatar_media_id), "photo") then
|
||||
Bridge.Debug("warn", "[sky_phone] Rejected unowned CityMarkt profile image from source %s.", tostring(source))
|
||||
return { success = false, error = "invalid_profile_image" }
|
||||
end
|
||||
Bridge.Database.Query([[
|
||||
INSERT INTO `sky_phone_marketplace_profiles` (`account_id`, `display_name`, `bio`, `avatar_media_id`)
|
||||
VALUES (?, ?, ?, NULLIF(?, 0))
|
||||
ON DUPLICATE KEY UPDATE `display_name` = VALUES(`display_name`), `bio` = VALUES(`bio`),
|
||||
`avatar_media_id` = VALUES(`avatar_media_id`)
|
||||
]], { account.id, display_name, bio, avatar_media_id })
|
||||
return { success = true, data = profile_dto(account) }
|
||||
end)
|
||||
|
||||
local function expire_listings()
|
||||
Bridge.Database.Query([[
|
||||
UPDATE `sky_phone_marketplace_listings`
|
||||
@@ -186,13 +264,14 @@ local function listing_summary_query(account_id, where_clause, order_clause, val
|
||||
return Bridge.Database.Query(([[
|
||||
SELECT l.`id`, l.`title`, l.`category`, l.`item_condition`, l.`price_type`, l.`price`,
|
||||
l.`district`, l.`status`, l.`created_at`, l.`updated_at`, l.`expires_at`,
|
||||
SUBSTRING_INDEX(a.`email`, '@', 1) AS `seller_name`,
|
||||
COALESCE(profile.`display_name`, SUBSTRING_INDEX(a.`email`, '@', 1)) AS `seller_name`,
|
||||
(SELECT i.`gradient` FROM `sky_phone_marketplace_images` i
|
||||
WHERE i.`listing_id` = l.`id` ORDER BY i.`sort_order` LIMIT 1) AS `image`,
|
||||
EXISTS(SELECT 1 FROM `sky_phone_marketplace_favorites` f
|
||||
WHERE f.`listing_id` = l.`id` AND f.`account_id` = ?) AS `is_favorite`
|
||||
FROM `sky_phone_marketplace_listings` l
|
||||
JOIN `sky_phone_accounts` a ON a.`id` = l.`seller_account_id`
|
||||
LEFT JOIN `sky_phone_marketplace_profiles` profile ON profile.`account_id` = l.`seller_account_id`
|
||||
WHERE %s
|
||||
ORDER BY %s
|
||||
LIMIT ? OFFSET ?
|
||||
@@ -319,13 +398,16 @@ Bridge.Callbacks.Register("sky_phone:marketplace:get", function(source, data)
|
||||
expire_listings()
|
||||
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT l.*, SUBSTRING_INDEX(a.`email`, '@', 1) AS `seller_name`, a.`created_at` AS `seller_since`,
|
||||
SELECT l.*, COALESCE(profile.`display_name`, SUBSTRING_INDEX(a.`email`, '@', 1)) AS `seller_name`,
|
||||
profile_avatar.`url` AS `seller_avatar`, a.`created_at` AS `seller_since`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_marketplace_listings` own
|
||||
WHERE own.`seller_account_id` = l.`seller_account_id` AND own.`status` IN ('active', 'reserved')) AS `seller_active`,
|
||||
EXISTS(SELECT 1 FROM `sky_phone_marketplace_favorites` f
|
||||
WHERE f.`listing_id` = l.`id` AND f.`account_id` = ?) AS `is_favorite`
|
||||
FROM `sky_phone_marketplace_listings` l
|
||||
JOIN `sky_phone_accounts` a ON a.`id` = l.`seller_account_id`
|
||||
LEFT JOIN `sky_phone_marketplace_profiles` profile ON profile.`account_id` = l.`seller_account_id`
|
||||
LEFT JOIN `sky_phone_media` profile_avatar ON profile_avatar.`id` = profile.`avatar_media_id`
|
||||
WHERE l.`id` = ?
|
||||
LIMIT 1
|
||||
]], { account_id or 0, id })
|
||||
@@ -375,6 +457,8 @@ end)
|
||||
Bridge.Callbacks.Register("sky_phone:marketplace:create", function(source, data)
|
||||
local account, error_response = require_account(source)
|
||||
if not account then return error_response end
|
||||
local profile_error = require_profile(account.id)
|
||||
if profile_error then return profile_error end
|
||||
if not SkyPhone.AllowOperation(source, "marketplace:create", 5, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
@@ -566,7 +650,7 @@ Bridge.Callbacks.Register("sky_phone:marketplace:list-inquiries", function(sourc
|
||||
l.`title`, l.`price`, l.`price_type`, l.`status`,
|
||||
(SELECT image.`gradient` FROM `sky_phone_marketplace_images` image
|
||||
WHERE image.`listing_id` = l.`id` ORDER BY image.`sort_order` LIMIT 1) AS `image`,
|
||||
SUBSTRING_INDEX(other_account.`email`, '@', 1) AS `other_name`,
|
||||
COALESCE(other_profile.`display_name`, SUBSTRING_INDEX(other_account.`email`, '@', 1)) AS `other_name`,
|
||||
(SELECT message.`body` FROM `sky_phone_marketplace_messages` message
|
||||
WHERE message.`inquiry_id` = q.`id` ORDER BY message.`id` DESC LIMIT 1) AS `last_message`,
|
||||
((SELECT COUNT(*) FROM `sky_phone_marketplace_messages` unread
|
||||
@@ -582,6 +666,7 @@ Bridge.Callbacks.Register("sky_phone:marketplace:list-inquiries", function(sourc
|
||||
JOIN `sky_phone_marketplace_listings` l ON l.`id` = q.`listing_id`
|
||||
JOIN `sky_phone_accounts` other_account ON other_account.`id` =
|
||||
CASE WHEN q.`seller_account_id` = ? THEN q.`buyer_account_id` ELSE q.`seller_account_id` END
|
||||
LEFT JOIN `sky_phone_marketplace_profiles` other_profile ON other_profile.`account_id` = other_account.`id`
|
||||
WHERE q.`seller_account_id` = ? OR q.`buyer_account_id` = ?
|
||||
ORDER BY q.`updated_at` DESC
|
||||
LIMIT 100
|
||||
@@ -598,12 +683,14 @@ Bridge.Callbacks.Register("sky_phone:marketplace:get-inquiry", function(source,
|
||||
end
|
||||
local inquiries = Bridge.Database.Query([[
|
||||
SELECT q.*, l.`title`, l.`price`, l.`price_type`, l.`status`, l.`reserved_account_id`,
|
||||
SUBSTRING_INDEX(seller.`email`, '@', 1) AS `seller_name`,
|
||||
SUBSTRING_INDEX(buyer.`email`, '@', 1) AS `buyer_name`
|
||||
COALESCE(seller_profile.`display_name`, SUBSTRING_INDEX(seller.`email`, '@', 1)) AS `seller_name`,
|
||||
COALESCE(buyer_profile.`display_name`, SUBSTRING_INDEX(buyer.`email`, '@', 1)) AS `buyer_name`
|
||||
FROM `sky_phone_marketplace_inquiries` q
|
||||
JOIN `sky_phone_marketplace_listings` l ON l.`id` = q.`listing_id`
|
||||
JOIN `sky_phone_accounts` seller ON seller.`id` = q.`seller_account_id`
|
||||
JOIN `sky_phone_accounts` buyer ON buyer.`id` = q.`buyer_account_id`
|
||||
LEFT JOIN `sky_phone_marketplace_profiles` seller_profile ON seller_profile.`account_id` = seller.`id`
|
||||
LEFT JOIN `sky_phone_marketplace_profiles` buyer_profile ON buyer_profile.`account_id` = buyer.`id`
|
||||
WHERE q.`id` = ? AND (q.`seller_account_id` = ? OR q.`buyer_account_id` = ?)
|
||||
LIMIT 1
|
||||
]], { data.id, account.id, account.id })
|
||||
|
||||
Reference in New Issue
Block a user