ENH - refine CityMarkt listings and profiles

This commit is contained in:
smx.pusha
2026-08-12 15:03:52 +02:00
parent 6a99f7027c
commit 1df5202360
7 changed files with 563 additions and 47 deletions
+13
View File
@@ -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)
+17
View File
@@ -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
}
+407 -41
View File
@@ -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,
+9 -1
View File
@@ -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.",
},
+2
View File
@@ -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",
+23
View File
@@ -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 = {
+92 -5
View File
@@ -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 })