diff --git a/frontend/src/stores/marketplace.ts b/frontend/src/stores/marketplace.ts
index 6999c62..f347730 100644
--- a/frontend/src/stores/marketplace.ts
+++ b/frontend/src/stores/marketplace.ts
@@ -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 {
+ const response = await nuiCall('marketplace:profile')
+ if (response.success && response.data) this.profile = response.data
+ return response.success
+ },
+ async saveProfile(draft: MarketplaceProfileDraft): Promise> {
+ const response = await nuiCall('marketplace:profile-save', draft)
+ if (response.success && response.data) this.profile = response.data
+ return response
+ },
async load(filters: Record = {}): Promise {
this.isLoading = true
const response = await nuiCall('marketplace:list', filters)
diff --git a/frontend/src/types/marketplace.ts b/frontend/src/types/marketplace.ts
index eecc668..2e3ca87 100644
--- a/frontend/src/types/marketplace.ts
+++ b/frontend/src/types/marketplace.ts
@@ -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
}
diff --git a/frontend/src/views/apps/CityMarktApp.vue b/frontend/src/views/apps/CityMarktApp.vue
index c2fac3d..3cfa38d 100644
--- a/frontend/src/views/apps/CityMarktApp.vue
+++ b/frontend/src/views/apps/CityMarktApp.vue
@@ -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('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({ avatarMediaId: 0, bio: '', displayName: '' })
+const selectedProfilePhoto = ref(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 {
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 {
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 {
- 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 {
+ 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 {
+ 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 {
onMounted(async () => {
const selection = messageMedia.consumeMany('citymarkt:sell')
+ const profileSelection = messageMedia.consumeMany('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' }"
>
-
+ {{ phone.t('Common.loading') }}
+
{
:key="item.id"
class="citymarkt-listing-card"
>
-
@@ -983,13 +1110,46 @@ onMounted(async () => {
{{ phone.t('Apps.citymarkt.signInBody') }}
- {{ account.email.charAt(0).toUpperCase() }}
+
+
+
+
+ {{ phone.t(marketplace.profile?.exists ? 'Apps.citymarkt.editProfile' : 'Apps.citymarkt.createProfile') }}
+ {{ phone.t('Apps.citymarkt.profileIntro') }}
+
+
+
+
+
+ {{ profileDraft.displayName.charAt(0).toUpperCase() || account.email.charAt(0).toUpperCase() }}
+
+
+ {{ phone.t('Apps.citymarkt.chooseGallery') }}
+ {{ phone.t('Apps.citymarkt.takePhoto') }}
+ {{ phone.t('Apps.citymarkt.removeProfilePhoto') }}
+
+
+
+
+
+
+ {{ phone.t('Apps.citymarkt.cancel') }}
+ {{ phone.t('Apps.citymarkt.saveProfile') }}
+
+
+
+
+
+
+ {{ marketplace.profile?.display_name.charAt(0).toUpperCase() }}
+
- {{ account.email.split('@')[0] }}{{ account.email }}
-
+ {{ marketplace.profile?.display_name }}
+ {{ account.email }}
+ {{ marketplace.profile.bio }}
+
+
+
{
>{{ phone.t('Apps.localPages.cityMarktShare') }}
+
@@ -1053,7 +1214,8 @@ onMounted(async () => {
{
component="button"
type="button"
:colors="glassActionColors"
- @click="toggleFavorite"
+ class="citymarkt-detail-action"
+ @click="toggleListingFavorite(selectedListing)"
> {
component="button"
type="button"
:colors="glassActionColors"
+ class="citymarkt-detail-action"
@click="screen = 'report'"
>
@@ -1113,7 +1277,7 @@ onMounted(async () => {
-
{{ selectedListing.seller_name.charAt(0).toUpperCase() }}
+
{{ selectedListing.seller_name.charAt(0).toUpperCase() }}
{{ selectedListing.seller_name }} {
{
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,
diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua
index c18345a..71fa784 100644
--- a/sky_phone/config/locales/en.lua
+++ b/sky_phone/config/locales/en.lua
@@ -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.",
},
diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua
index 84eb4d9..a0ef5fa 100644
--- a/sky_phone/source/client/main.lua
+++ b/sky_phone/source/client/main.lua
@@ -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",
diff --git a/sky_phone/source/server/db_migrate.lua b/sky_phone/source/server/db_migrate.lua
index 606b8ce..1cf8125 100644
--- a/sky_phone/source/server/db_migrate.lua
+++ b/sky_phone/source/server/db_migrate.lua
@@ -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 = {
diff --git a/sky_phone/source/server/marketplace.lua b/sky_phone/source/server/marketplace.lua
index 6783c49..24b110d 100644
--- a/sky_phone/source/server/marketplace.lua
+++ b/sky_phone/source/server/marketplace.lua
@@ -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 })