ADD - support CityMarkt offer negotiation

This commit is contained in:
smx.pusha
2026-08-06 13:16:49 +02:00
parent bfe09814d8
commit e4e94e51a7
13 changed files with 742 additions and 16 deletions
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { BadgeDollarSign, Check, RefreshCw, X } from 'lucide-vue-next'
import { computed } from 'vue'
import { usePhoneStore } from '@/stores/phone'
import type { MarketplaceOffer } from '@/types/marketplace'
const props = defineProps<{
accountId: number
actionable: boolean
isCounter: boolean
offer: MarketplaceOffer
}>()
defineEmits<{
accept: []
counter: []
reject: []
}>()
const phone = usePhoneStore()
const isOwn = computed(() => props.offer.proposer_account_id === props.accountId)
const statusKey = computed(() =>
props.offer.status === 'rejected' ? 'declined' : props.offer.status,
)
const formattedAmount = computed(() =>
phone.t('Apps.citymarkt.money', {
price: new Intl.NumberFormat(phone.lang, { maximumFractionDigits: 0 }).format(
Number(props.offer.amount),
),
}),
)
</script>
<template>
<article
class="citymarkt-offer"
:class="[`citymarkt-offer--${offer.status}`, { 'citymarkt-offer--own': isOwn }]"
>
<header>
<span><BadgeDollarSign :size="16" /></span>
<div>
<small>{{ phone.t(isCounter ? 'Apps.citymarkt.counterOffer' : 'Apps.citymarkt.offer') }}</small>
<strong>{{ formattedAmount }}</strong>
</div>
<i>{{ phone.t(`Apps.citymarkt.offerStatus.${statusKey}`) }}</i>
</header>
<p>
{{ phone.t(isOwn ? 'Apps.citymarkt.offeredByYou' : 'Apps.citymarkt.offeredToYou') }}
</p>
<div v-if="actionable" class="citymarkt-offer__actions">
<button type="button" class="accept" @click="$emit('accept')">
<Check :size="14" />{{ phone.t('Apps.citymarkt.acceptOffer') }}
</button>
<button type="button" @click="$emit('counter')">
<RefreshCw :size="13" />{{ phone.t('Apps.citymarkt.negotiateOffer') }}
</button>
<button type="button" class="reject" @click="$emit('reject')">
<X :size="14" />{{ phone.t('Apps.citymarkt.declineOffer') }}
</button>
</div>
</article>
</template>
<style scoped>
.citymarkt-offer{width:92%;padding:10px;border:1px solid #ffc92842;border-radius:14px;align-self:flex-start;background:linear-gradient(145deg,#332d19,var(--panel));box-shadow:0 7px 18px #0003}.citymarkt-offer--own{align-self:flex-end}.citymarkt-offer header{display:flex;align-items:center;gap:8px}.citymarkt-offer header>span{width:30px;height:30px;flex:none;border-radius:10px;display:grid;place-items:center;background:var(--yellow);color:#171816}.citymarkt-offer header>div{min-width:0;flex:1}.citymarkt-offer header small,.citymarkt-offer header strong{display:block}.citymarkt-offer header small{color:var(--muted);font-size:8px;font-weight:800;text-transform:uppercase}.citymarkt-offer header strong{margin-top:1px;font-size:17px}.citymarkt-offer header i{padding:4px 6px;border-radius:7px;background:#ffc92817;color:var(--yellow);font-size:7px;font-style:normal;font-weight:900;text-transform:uppercase}.citymarkt-offer>p{margin:6px 0 0;color:var(--muted);font-size:8px}.citymarkt-offer--accepted{border-color:#54d68173;background:linear-gradient(145deg,#193526,var(--panel))}.citymarkt-offer--accepted header>span{background:#54d681}.citymarkt-offer--accepted header i{background:#54d6811c;color:#67e494}.citymarkt-offer--rejected,.citymarkt-offer--countered{border-color:#ffffff14;filter:saturate(.65)}.citymarkt-offer--rejected header>span,.citymarkt-offer--countered header>span{background:#555750;color:#ddd}.citymarkt-offer--rejected header i,.citymarkt-offer--countered header i{background:#ffffff0c;color:var(--muted)}.citymarkt-offer__actions{margin-top:9px;display:grid;grid-template-columns:1fr 1fr;gap:5px}.citymarkt-offer__actions button{min-height:30px;padding:5px;border:1px solid #ffffff12;border-radius:9px;display:flex;align-items:center;justify-content:center;gap:3px;background:#ffffff09;color:inherit;font-size:8px;font-weight:800}.citymarkt-offer__actions button.accept{border:0;background:#54d681;color:#102319}.citymarkt-offer__actions button.reject{grid-column:1/-1;color:#ff8078}:global(.citymarkt--light) .citymarkt-offer{background:linear-gradient(145deg,#fff8d9,#fff);box-shadow:0 7px 18px #0001}:global(.citymarkt--light) .citymarkt-offer--accepted{background:linear-gradient(145deg,#e6faed,#fff)}
</style>
+50
View File
@@ -0,0 +1,50 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useMarketplaceStore } from '@/stores/marketplace'
import { nuiCall } from '@/utils/nui'
vi.mock('@/utils/nui', () => ({
nuiCall: vi.fn(),
}))
const mockNuiCall = vi.mocked(nuiCall)
describe('marketplace store offers', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockNuiCall.mockReset()
})
it('sends an offer and refreshes conversations and counts', async () => {
mockNuiCall
.mockResolvedValueOnce({ data: { id: 7 }, success: true })
.mockResolvedValueOnce({ data: [], success: true })
.mockResolvedValueOnce({ data: { active: 1, unread: 0 }, success: true })
const marketplace = useMarketplaceStore()
const response = await marketplace.makeOffer('inquiry-id', 175000)
expect(response).toEqual({ data: { id: 7 }, success: true })
expect(mockNuiCall).toHaveBeenNthCalledWith(1, 'marketplace:make-offer', {
amount: 175000,
inquiryId: 'inquiry-id',
})
expect(mockNuiCall).toHaveBeenCalledWith('marketplace:list-inquiries')
expect(mockNuiCall).toHaveBeenCalledWith('marketplace:counts')
})
it('does not refresh state when an offer response is rejected by the server', async () => {
mockNuiCall.mockResolvedValueOnce({ error: 'offer_conflict', success: false })
const marketplace = useMarketplaceStore()
const response = await marketplace.respondOffer('inquiry-id', 'accepted')
expect(response).toEqual({ error: 'offer_conflict', success: false })
expect(mockNuiCall).toHaveBeenCalledTimes(1)
expect(mockNuiCall).toHaveBeenCalledWith('marketplace:respond-offer', {
action: 'accepted',
inquiryId: 'inquiry-id',
})
})
})
+19
View File
@@ -100,6 +100,25 @@ export const useMarketplaceStore = defineStore('marketplace', {
if (response.success) await Promise.all([this.loadInquiries(), this.loadCounts()])
return response
},
async makeOffer(
inquiryId: string,
amount: number,
): Promise<NuiResponse<{ id: number }>> {
const response = await nuiCall<{ id: number }>('marketplace:make-offer', {
amount,
inquiryId,
})
if (response.success) await Promise.all([this.loadInquiries(), this.loadCounts()])
return response
},
async respondOffer(
inquiryId: string,
action: 'accepted' | 'rejected',
): Promise<NuiResponse> {
const response = await nuiCall('marketplace:respond-offer', { action, inquiryId })
if (response.success) await Promise.all([this.loadInquiries(), this.loadCounts()])
return response
},
report(id: string, reason: string, details = ''): Promise<NuiResponse> {
return nuiCall('marketplace:report', { details, id, reason })
},
+19
View File
@@ -347,6 +347,12 @@ const defaultLocales: LocaleTree = {
},
conditions: { new: 'New', very_good: 'Very good', used: 'Used', defective: 'Defective' },
priceTypes: { fixed: 'Fixed price', negotiable: 'Negotiable', free: 'Free' },
offerStatus: {
pending: 'Open',
accepted: 'Accepted',
declined: 'Declined',
countered: 'Negotiated',
},
districts: {
los_santos: 'Los Santos', vinewood: 'Vinewood', vespucci: 'Vespucci',
south_los_santos: 'South Los Santos', sandy_shores: 'Sandy Shores',
@@ -372,14 +378,27 @@ const defaultLocales: LocaleTree = {
priceAndPlace: 'Price and location', priceType: 'Price type', price: 'Price', district: 'District',
showPhone: 'Show my current phone number', preview: 'Preview', published: 'Your listing is live.',
writeMessage: 'Write a message', reserveForBuyer: 'Reserve for this buyer', statusChanged: 'Listing updated.',
offer: 'Offer', counterOffer: 'Counteroffer', makeOffer: 'Make an offer',
offeredByYou: 'You sent this offer.', offeredToYou: 'You received this offer.',
acceptOffer: 'Accept', declineOffer: 'Decline', negotiateOffer: 'Counter',
offerFor: 'Your offer for', offerAmount: 'Offer amount', sendOffer: 'Send offer',
offerSent: 'Your offer was sent.', offerAccepted: 'Offer accepted.', offerDeclined: 'Offer declined.',
reportListing: 'Report listing', reportWhy: 'Why are you reporting this?', reportDetails: 'Add details (optional)',
sendReport: 'Send report', blockSeller: 'Block seller', reported: 'Report sent.', blocked: 'Seller blocked.',
newMessage: 'New CityMarkt message from {sender}',
newOffer: '{sender} offered ${price}.',
offerAcceptedNotification: '{sender} accepted your ${price} offer.',
offerRejectedNotification: '{sender} declined your ${price} offer.',
errors: {
invalid_listing: 'Check the listing details.', invalid_price: 'Enter a valid price.',
invalid_images: 'Choose valid photos from this phone.', phone_unavailable: 'Insert a SIM or hide your number.',
listing_limit: 'You have reached the active listing limit.', listing_not_found: 'This listing is no longer available.',
invalid_message: 'Enter a valid message.', inquiry_not_found: 'This conversation is no longer available.',
invalid_offer: 'Enter a valid whole-number offer.', invalid_offer_response: 'This offer action is invalid.',
offer_listing_unavailable: 'This listing is no longer available for offers.',
offer_closed: 'This negotiation is already complete.', offer_waiting: 'Wait for the other person to respond.',
offer_not_allowed: 'You cannot make an offer right now.', offer_not_actionable: 'This offer can no longer be changed.',
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.',
conflict: 'The listing changed. Open it again.', request_failed: 'CityMarkt is temporarily unavailable.',
+18
View File
@@ -13,6 +13,7 @@ export type MarketplaceCategory =
export type MarketplaceCondition = 'new' | 'very_good' | 'used' | 'defective'
export type MarketplacePriceType = 'fixed' | 'negotiable' | 'free'
export type MarketplaceStatus = 'active' | 'reserved' | 'sold' | 'expired' | 'removed'
export type MarketplaceOfferStatus = 'pending' | 'accepted' | 'rejected' | 'countered'
export type MarketplaceImage = {
gradient: string
@@ -85,11 +86,27 @@ export type MarketplaceMessage = {
sender_account_id: number
}
export type MarketplaceOffer = {
amount: number | string
created_at: string
id: number
proposer_account_id: number
read_at: string | null
response_read_at: string | null
status: MarketplaceOfferStatus
updated_at: string
}
export type MarketplaceInquiry = {
buyer_account_id: number
buyer_name: string
id: string
listing_id: string
offer_amount: number | string | null
offer_id: number | null
offer_proposer_account_id: number | null
offer_revision: number
offer_status: Exclude<MarketplaceOfferStatus, 'countered'> | null
price: number | string | null
price_type: MarketplacePriceType
reserved_account_id: number | null
@@ -103,6 +120,7 @@ export type MarketplaceChat = {
accountId: number
inquiry: MarketplaceInquiry
messages: MarketplaceMessage[]
offers: MarketplaceOffer[]
}
export type MarketplaceCounts = { active: number; unread: number }
+162 -2
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import {
ArrowLeft,
BadgeDollarSign,
Bell,
BriefcaseBusiness,
CarFront,
@@ -27,6 +28,7 @@ import {
import { computed, onMounted, ref } from 'vue'
import CityMarktSelect from '@/components/citymarkt/CityMarktSelect.vue'
import CityMarktOfferCard from '@/components/citymarkt/CityMarktOfferCard.vue'
import { useAccountStore } from '@/stores/account'
import { useMarketplaceStore } from '@/stores/marketplace'
import { useMediaStore } from '@/stores/media'
@@ -38,11 +40,16 @@ import type {
MarketplaceListing,
MarketplaceListingDraft,
MarketplaceListingSummary,
MarketplaceMessage,
MarketplaceOffer,
MarketplacePriceType,
} from '@/types/marketplace'
type Tab = 'discover' | 'search' | 'sell' | 'inbox' | 'profile'
type Screen = 'main' | 'detail' | 'sell' | 'chat' | 'report'
type ChatTimelineItem =
| { createdAt: string; key: string; kind: 'message'; value: MarketplaceMessage }
| { createdAt: string; isCounter: boolean; key: string; kind: 'offer'; value: MarketplaceOffer }
const phone = usePhoneStore()
const account = useAccountStore()
@@ -59,6 +66,9 @@ const sort = ref('newest')
const profileMode = ref<'own' | 'favorites'>('own')
const message = ref('')
const firstMessage = ref('')
const offerAmount = ref('')
const offerPanelOpen = ref(false)
const offerSubmitting = ref(false)
const feedback = ref('')
const sellStep = ref(1)
const submitting = ref(false)
@@ -130,6 +140,43 @@ const displayItems = computed(() => {
}
return marketplace.items
})
const chatTimeline = computed<ChatTimelineItem[]>(() => {
if (!selectedChat.value) return []
const messages: ChatTimelineItem[] = selectedChat.value.messages.map((item) => ({
createdAt: item.created_at,
key: `message-${item.id}`,
kind: 'message',
value: item,
}))
const offers: ChatTimelineItem[] = (selectedChat.value.offers ?? []).map((item, index) => ({
createdAt: item.created_at,
isCounter: index > 0,
key: `offer-${item.id}`,
kind: 'offer',
value: item,
}))
return [...messages, ...offers].sort(
(left, right) =>
new Date(left.createdAt.replace(' ', 'T')).getTime() -
new Date(right.createdAt.replace(' ', 'T')).getTime(),
)
})
const canMakeOffer = computed(() => {
if (!selectedChat.value || !['active', 'reserved'].includes(selectedChat.value.inquiry.status)) {
return false
}
const inquiry = selectedChat.value.inquiry
if (inquiry.offer_status === 'accepted') return false
if (inquiry.offer_status === 'pending') {
return Number(inquiry.offer_proposer_account_id) !== selectedChat.value.accountId
}
return Number(inquiry.buyer_account_id) === selectedChat.value.accountId
})
const offerButtonLabel = computed(() =>
selectedChat.value?.inquiry.offer_status === 'pending'
? phone.t('Apps.citymarkt.negotiateOffer')
: phone.t('Apps.citymarkt.makeOffer'),
)
const canContinueSell = computed(() => {
if (sellStep.value === 1) return selectedPhotoIds.value.length > 0
if (sellStep.value === 2)
@@ -159,6 +206,12 @@ function relativeDate(value: string): string {
return phone.t('Apps.citymarkt.daysAgo', { count: String(Math.floor(hours / 24)) })
}
function messageTime(value: string): string {
return new Intl.DateTimeFormat(phone.lang, { hour: '2-digit', minute: '2-digit' }).format(
new Date(value.replace(' ', 'T')),
)
}
function setFeedback(key: string): void {
feedback.value = phone.t(key)
window.setTimeout(() => {
@@ -312,11 +365,70 @@ async function openChat(id: string): Promise<void> {
if (response.success && response.data) {
selectedChat.value = response.data
message.value = ''
offerPanelOpen.value = false
screen.value = 'chat'
await Promise.all([marketplace.loadInquiries(), marketplace.loadCounts()])
}
}
function isOfferActionable(offer: MarketplaceOffer): boolean {
if (!selectedChat.value) return false
return (
offer.status === 'pending' &&
Number(selectedChat.value.inquiry.offer_id) === Number(offer.id) &&
Number(offer.proposer_account_id) !== selectedChat.value.accountId
)
}
function openOfferPanel(): void {
if (!selectedChat.value || !canMakeOffer.value) return
const suggestedAmount = Number(
selectedChat.value.inquiry.offer_amount ?? selectedChat.value.inquiry.price ?? 0,
)
offerAmount.value = suggestedAmount > 0 ? String(suggestedAmount) : ''
offerPanelOpen.value = true
}
async function refreshChat(): Promise<void> {
if (!selectedChat.value) return
const response = await marketplace.getInquiry(selectedChat.value.inquiry.id)
if (response.success && response.data) selectedChat.value = response.data
}
async function submitOffer(): Promise<void> {
if (!selectedChat.value || offerSubmitting.value) return
const amount = Number(offerAmount.value)
if (!Number.isInteger(amount) || amount < 1) {
setFeedback('Apps.citymarkt.errors.invalid_offer')
return
}
offerSubmitting.value = true
const response = await marketplace.makeOffer(selectedChat.value.inquiry.id, amount)
offerSubmitting.value = false
if (!response.success) {
setFeedback(`Apps.citymarkt.errors.${response.error ?? 'default'}`)
return
}
offerPanelOpen.value = false
await refreshChat()
setFeedback('Apps.citymarkt.offerSent')
}
async function respondOffer(action: 'accepted' | 'rejected'): Promise<void> {
if (!selectedChat.value || offerSubmitting.value) return
offerSubmitting.value = true
const response = await marketplace.respondOffer(selectedChat.value.inquiry.id, action)
offerSubmitting.value = false
if (!response.success) {
setFeedback(`Apps.citymarkt.errors.${response.error ?? 'default'}`)
return
}
await refreshChat()
setFeedback(
action === 'accepted' ? 'Apps.citymarkt.offerAccepted' : 'Apps.citymarkt.offerDeclined',
)
}
async function sendChatMessage(): Promise<void> {
if (!selectedChat.value || !message.value.trim()) return
const body = message.value
@@ -526,8 +638,49 @@ onMounted(async () => {
<section v-else-if="screen === 'chat' && selectedChat" class="citymarkt__chat">
<header><button @click="screen = 'main'; tab = 'inbox'"><ArrowLeft :size="19" /></button><div><strong>{{ selectedChat.inquiry.seller_account_id === selectedChat.accountId ? selectedChat.inquiry.buyer_name : selectedChat.inquiry.seller_name }}</strong><small>{{ selectedChat.inquiry.title }}</small></div></header>
<div class="citymarkt__chat-listing"><div><strong>{{ formatPrice(selectedChat.inquiry) }}</strong><span>{{ selectedChat.inquiry.title }}</span></div><i>{{ label('status', selectedChat.inquiry.status) }}</i></div>
<div class="citymarkt__messages"><article v-for="item in selectedChat.messages" :key="item.id" :class="{ own: item.sender_account_id === selectedChat.accountId }"><p>{{ item.body }}</p><small>{{ new Intl.DateTimeFormat(phone.lang, { hour: '2-digit', minute: '2-digit' }).format(new Date(item.created_at.replace(' ', 'T'))) }}</small></article></div>
<button v-if="selectedChat.inquiry.seller_account_id === selectedChat.accountId && selectedChat.inquiry.status === 'active'" class="citymarkt__reserve" @click="setListingStatus('reserved', selectedChat.inquiry.id)">{{ phone.t('Apps.citymarkt.reserveForBuyer') }}</button>
<div class="citymarkt__messages">
<template v-for="item in chatTimeline" :key="item.key">
<article
v-if="item.kind === 'message'"
:class="{ own: item.value.sender_account_id === selectedChat.accountId }"
>
<p>{{ item.value.body }}</p>
<small>{{ messageTime(item.value.created_at) }}</small>
</article>
<CityMarktOfferCard
v-else
:account-id="selectedChat.accountId"
:actionable="isOfferActionable(item.value)"
:is-counter="item.isCounter"
:offer="item.value"
@accept="respondOffer('accepted')"
@counter="openOfferPanel"
@reject="respondOffer('rejected')"
/>
</template>
</div>
<form v-if="offerPanelOpen" class="citymarkt__offer-panel" @submit.prevent="submitOffer">
<header>
<div>
<small>{{ phone.t('Apps.citymarkt.offerFor') }}</small>
<strong>{{ selectedChat.inquiry.title }}</strong>
</div>
<button type="button" @click="offerPanelOpen = false"><X :size="16" /></button>
</header>
<label>
{{ phone.t('Apps.citymarkt.offerAmount') }}
<span><b>$</b><input v-model="offerAmount" inputmode="numeric" min="1" type="number" /></span>
</label>
<button type="submit" :disabled="offerSubmitting || !offerAmount">
<BadgeDollarSign :size="16" />{{ phone.t('Apps.citymarkt.sendOffer') }}
</button>
</form>
<div v-if="canMakeOffer || (selectedChat.inquiry.seller_account_id === selectedChat.accountId && selectedChat.inquiry.status === 'active')" class="citymarkt__chat-actions">
<button v-if="canMakeOffer" type="button" @click="openOfferPanel">
<BadgeDollarSign :size="14" />{{ offerButtonLabel }}
</button>
<button v-if="selectedChat.inquiry.seller_account_id === selectedChat.accountId && selectedChat.inquiry.status === 'active'" type="button" @click="setListingStatus('reserved', selectedChat.inquiry.id)">{{ phone.t('Apps.citymarkt.reserveForBuyer') }}</button>
</div>
<form class="citymarkt__chat-composer" @submit.prevent="sendChatMessage"><input v-model="message" maxlength="1000" :placeholder="phone.t('Apps.citymarkt.writeMessage')" /><button :disabled="!message.trim()"><Send :size="17" /></button></form>
</section>
@@ -556,4 +709,11 @@ onMounted(async () => {
.citymarkt__segmented button.active span{background:#17181626}
:global(.citymarkt--light) .citymarkt__segmented{border-color:#0000000b}
:global(.citymarkt--light) .citymarkt__segmented button span{background:#0000000b}
.citymarkt__messages{position:absolute;top:158px;right:0;bottom:116px;left:0;height:auto}
.citymarkt__messages .citymarkt-offer{width:92%;max-width:92%}
.citymarkt__chat-actions{position:absolute;right:9px;bottom:75px;left:9px;display:flex;gap:5px}
.citymarkt__chat-actions button{min-height:34px;flex:1;padding:6px 8px;border:1px solid #ffc92831;border-radius:11px;display:flex;align-items:center;justify-content:center;gap:4px;background:#3d3621;color:var(--yellow);font-size:8px;font-weight:900}
.citymarkt__offer-panel{position:absolute;z-index:6;right:9px;bottom:75px;left:9px;padding:11px;border:1px solid #ffc92840;border-radius:15px;background:#292a27;box-shadow:0 14px 35px #000b}
.citymarkt__offer-panel header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px}.citymarkt__offer-panel header div{min-width:0}.citymarkt__offer-panel header small,.citymarkt__offer-panel header strong{display:block}.citymarkt__offer-panel header small{color:var(--yellow);font-size:8px;font-weight:900;text-transform:uppercase}.citymarkt__offer-panel header strong{overflow:hidden;font-size:12px;white-space:nowrap;text-overflow:ellipsis}.citymarkt__offer-panel header button{width:27px;height:27px;flex:none;padding:0;border:0;border-radius:9px;display:grid;place-items:center;background:#ffffff0b}.citymarkt__offer-panel label{margin-top:9px;display:block;color:var(--muted);font-size:8px;font-weight:800}.citymarkt__offer-panel label>span{height:39px;margin-top:4px;padding:0 10px;border:1px solid #ffffff16;border-radius:11px;display:flex;align-items:center;gap:5px;background:#151613}.citymarkt__offer-panel label b{color:var(--yellow);font-size:16px}.citymarkt__offer-panel input{min-width:0;flex:1;border:0;outline:0;background:none;color:inherit;font-size:16px;font-weight:900}.citymarkt__offer-panel>button{width:100%;min-height:36px;margin-top:8px;border:0;border-radius:11px;display:flex;align-items:center;justify-content:center;gap:5px;background:var(--yellow);color:#171816;font-size:9px;font-weight:900}.citymarkt__offer-panel>button:disabled{opacity:.45}
:global(.citymarkt--light) .citymarkt__offer-panel{background:#fff;box-shadow:0 14px 35px #0003}:global(.citymarkt--light) .citymarkt__offer-panel label>span{border-color:#00000012;background:#f4f4ef}
</style>
+120 -5
View File
@@ -146,7 +146,12 @@ const marketplaceInquiries = [
image: 'linear-gradient(145deg, #ff6b6b, #845ec2 52%, #0f2027)',
other_name: 'morgan',
last_message: 'Sure, come by the Vinewood garage around 8 PM.',
unread: 1,
offer_id: 2,
offer_amount: 178000,
offer_proposer_account_id: 2,
offer_status: 'pending',
offer_revision: 2,
unread: 2,
updated_at: '2026-08-06 11:42:00',
},
{
@@ -163,7 +168,12 @@ const marketplaceInquiries = [
image: 'linear-gradient(135deg, #ffc75f, #f96d80 48%, #4b4453)',
other_name: 'jamie',
last_message: 'I can collect it today and pay the full price.',
unread: 2,
offer_id: 3,
offer_amount: 7000,
offer_proposer_account_id: 3,
offer_status: 'pending',
offer_revision: 1,
unread: 3,
updated_at: '2026-08-06 11:55:00',
},
]
@@ -209,6 +219,41 @@ const marketplaceMessages = [
read_at: null,
},
]
const marketplaceOffers = [
{
id: 1,
inquiry_id: '4903b923-409a-437e-971f-b7a2b10e9e31',
proposer_account_id: 1,
amount: 170000,
status: 'countered',
read_at: '2026-08-06 11:43:00',
response_read_at: null,
created_at: '2026-08-06 11:40:00',
updated_at: '2026-08-06 11:44:00',
},
{
id: 2,
inquiry_id: '4903b923-409a-437e-971f-b7a2b10e9e31',
proposer_account_id: 2,
amount: 178000,
status: 'pending',
read_at: null,
response_read_at: null,
created_at: '2026-08-06 11:44:00',
updated_at: '2026-08-06 11:44:00',
},
{
id: 3,
inquiry_id: '4903b923-409a-437e-971f-b7a2b10e9e32',
proposer_account_id: 3,
amount: 7000,
status: 'pending',
read_at: null,
response_read_at: null,
created_at: '2026-08-06 11:56:00',
updated_at: '2026-08-06 11:56:00',
},
]
function counts() {
return {
@@ -370,7 +415,7 @@ app.post('/api/:endpoint', (request, response) => {
let inquiry = marketplaceInquiries.find((item) => item.id === request.body.inquiryId || item.listing_id === request.body.listingId)
if (!inquiry) {
const listing = marketplaceListings.find((item) => item.id === request.body.listingId)
inquiry = { id: '4903b923-409a-437e-971f-b7a2b10e9e31', listing_id: listing.id, seller_account_id: listing.seller_account_id, buyer_account_id: 1, title: listing.title, price: listing.price, price_type: listing.price_type, status: listing.status, image: listing.image, other_name: listing.seller_name, last_message: request.body.body, unread: 0, updated_at: '2026-08-06 11:30:00' }
inquiry = { id: '4903b923-409a-437e-971f-b7a2b10e9e31', listing_id: listing.id, seller_account_id: listing.seller_account_id, buyer_account_id: 1, title: listing.title, price: listing.price, price_type: listing.price_type, status: listing.status, image: listing.image, other_name: listing.seller_name, last_message: request.body.body, offer_id: null, offer_amount: null, offer_proposer_account_id: null, offer_status: null, offer_revision: 0, unread: 0, updated_at: '2026-08-06 11:30:00' }
marketplaceInquiries.push(inquiry)
}
marketplaceMessages.push({ id: marketplaceMessages.length + 1, inquiry_id: inquiry.id, sender_account_id: 1, body: request.body.body, created_at: '2026-08-06 12:00:00', read_at: null })
@@ -382,8 +427,78 @@ app.post('/api/:endpoint', (request, response) => {
}
if (endpoint === 'marketplace:get-inquiry') {
const inquiry = marketplaceInquiries.find((item) => item.id === request.body.id)
if (inquiry) inquiry.unread = 0
response.json(inquiry ? { success: true, data: { accountId: 1, inquiry: { ...inquiry, reserved_account_id: null }, messages: marketplaceMessages.filter((message) => message.inquiry_id === inquiry.id) } } : { success: false, error: 'inquiry_not_found' })
if (inquiry) {
inquiry.unread = 0
for (const offer of marketplaceOffers) {
if (offer.inquiry_id === inquiry.id && offer.proposer_account_id !== 1) offer.read_at = '2026-08-06 12:01:00'
if (offer.inquiry_id === inquiry.id && offer.proposer_account_id === 1 && ['accepted', 'rejected'].includes(offer.status)) offer.response_read_at = '2026-08-06 12:01:00'
}
}
const listing = inquiry && marketplaceListings.find((item) => item.id === inquiry.listing_id)
response.json(inquiry ? { success: true, data: { accountId: 1, inquiry: { ...inquiry, reserved_account_id: listing.reserved_account_id ?? null, status: listing.status }, messages: marketplaceMessages.filter((message) => message.inquiry_id === inquiry.id), offers: marketplaceOffers.filter((offer) => offer.inquiry_id === inquiry.id) } } : { success: false, error: 'inquiry_not_found' })
return
}
if (endpoint === 'marketplace:make-offer') {
const inquiry = marketplaceInquiries.find((item) => item.id === request.body.inquiryId)
const amount = Number(request.body.amount)
if (!inquiry || !Number.isInteger(amount) || amount < 1) {
response.json({ success: false, error: 'invalid_offer' })
return
}
if (inquiry.offer_status === 'accepted') {
response.json({ success: false, error: 'offer_closed' })
return
}
if (inquiry.offer_status === 'pending' && inquiry.offer_proposer_account_id === 1) {
response.json({ success: false, error: 'offer_waiting' })
return
}
const previous = marketplaceOffers.find((offer) => offer.id === inquiry.offer_id)
if (previous?.status === 'pending') previous.status = 'countered'
const offer = {
id: marketplaceOffers.length + 1,
inquiry_id: inquiry.id,
proposer_account_id: 1,
amount,
status: 'pending',
read_at: null,
response_read_at: null,
created_at: '2026-08-06 12:02:00',
updated_at: '2026-08-06 12:02:00',
}
marketplaceOffers.push(offer)
inquiry.offer_id = offer.id
inquiry.offer_amount = amount
inquiry.offer_proposer_account_id = 1
inquiry.offer_status = 'pending'
inquiry.offer_revision += 1
inquiry.updated_at = offer.updated_at
response.json({ success: true, data: { id: offer.id } })
return
}
if (endpoint === 'marketplace:respond-offer') {
const inquiry = marketplaceInquiries.find((item) => item.id === request.body.inquiryId)
const offer = inquiry && marketplaceOffers.find((item) => item.id === inquiry.offer_id)
if (!inquiry || !offer || offer.status !== 'pending' || offer.proposer_account_id === 1) {
response.json({ success: false, error: 'offer_not_actionable' })
return
}
if (!['accepted', 'rejected'].includes(request.body.action)) {
response.json({ success: false, error: 'invalid_offer_response' })
return
}
offer.status = request.body.action
offer.updated_at = '2026-08-06 12:03:00'
inquiry.offer_status = request.body.action
inquiry.offer_revision += 1
inquiry.updated_at = offer.updated_at
if (request.body.action === 'accepted') {
const listing = marketplaceListings.find((item) => item.id === inquiry.listing_id)
listing.status = 'reserved'
listing.reserved_account_id = inquiry.buyer_account_id
inquiry.status = 'reserved'
}
response.json({ success: true })
return
}
if (endpoint === 'marketplace:report' || endpoint === 'marketplace:block') {
+1
View File
@@ -50,6 +50,7 @@ Config.Mail = {
Config.Marketplace = {
PageSize = 20,
MessagePageSize = 50,
OfferHistorySize = 50,
MaxActiveListings = 15,
MaxImages = 6,
TitleMinLength = 5,
+14
View File
@@ -210,6 +210,7 @@ Locales["en"] = {
},
conditions = { new = "New", very_good = "Very good", used = "Used", defective = "Defective" },
priceTypes = { fixed = "Fixed price", negotiable = "Negotiable", free = "Free" },
offerStatus = { pending = "Open", accepted = "Accepted", declined = "Declined", countered = "Negotiated" },
districts = {
los_santos = "Los Santos", vinewood = "Vinewood", vespucci = "Vespucci",
south_los_santos = "South Los Santos", sandy_shores = "Sandy Shores",
@@ -235,14 +236,27 @@ Locales["en"] = {
priceAndPlace = "Price and location", priceType = "Price type", price = "Price", district = "District",
showPhone = "Show my current phone number", preview = "Preview", published = "Your listing is live.",
writeMessage = "Write a message", reserveForBuyer = "Reserve for this buyer", statusChanged = "Listing updated.",
offer = "Offer", counterOffer = "Counteroffer", makeOffer = "Make an offer",
offeredByYou = "You sent this offer.", offeredToYou = "You received this offer.",
acceptOffer = "Accept", declineOffer = "Decline", negotiateOffer = "Counter",
offerFor = "Your offer for", offerAmount = "Offer amount", sendOffer = "Send offer",
offerSent = "Your offer was sent.", offerAccepted = "Offer accepted.", offerDeclined = "Offer declined.",
reportListing = "Report listing", reportWhy = "Why are you reporting this?", reportDetails = "Add details (optional)",
sendReport = "Send report", blockSeller = "Block seller", reported = "Report sent.", blocked = "Seller blocked.",
newMessage = "New CityMarkt message from {sender}",
newOffer = "{sender} offered ${price}.",
offerAcceptedNotification = "{sender} accepted your ${price} offer.",
offerRejectedNotification = "{sender} declined your ${price} offer.",
errors = {
invalid_listing = "Check the listing details.", invalid_price = "Enter a valid price.",
invalid_images = "Choose valid photos from this phone.", phone_unavailable = "Insert a SIM or hide your number.",
listing_limit = "You have reached the active listing limit.", listing_not_found = "This listing is no longer available.",
invalid_message = "Enter a valid message.", inquiry_not_found = "This conversation is no longer available.",
invalid_offer = "Enter a valid whole-number offer.", invalid_offer_response = "This offer action is invalid.",
offer_listing_unavailable = "This listing is no longer available for offers.",
offer_closed = "This negotiation is already complete.", offer_waiting = "Wait for the other person to respond.",
offer_not_allowed = "You cannot make an offer right now.", offer_not_actionable = "This offer can no longer be changed.",
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.",
conflict = "The listing changed. Open it again.", request_failed = "CityMarkt is temporarily unavailable.",
+17 -1
View File
@@ -44,6 +44,8 @@ local server_callbacks = {
"marketplace:list-inquiries",
"marketplace:get-inquiry",
"marketplace:send-message",
"marketplace:make-offer",
"marketplace:respond-offer",
"marketplace:report",
"marketplace:block",
"sim:insert",
@@ -283,7 +285,21 @@ end)
RegisterNetEvent("sky_phone:marketplace:new-message", function(data)
local marketplace_locale = get_locale().Nui.Apps.citymarkt
data.title = marketplace_locale.name
data.text = marketplace_locale.newMessage:gsub("{sender}", tostring(data.sender))
if data.kind == "offer" then
data.text = marketplace_locale.newOffer
:gsub("{sender}", tostring(data.sender))
:gsub("{price}", tostring(data.amount))
elseif data.kind == "offer-response" and data.action == "accepted" then
data.text = marketplace_locale.offerAcceptedNotification
:gsub("{sender}", tostring(data.sender))
:gsub("{price}", tostring(data.amount))
elseif data.kind == "offer-response" and data.action == "rejected" then
data.text = marketplace_locale.offerRejectedNotification
:gsub("{sender}", tostring(data.sender))
:gsub("{price}", tostring(data.amount))
else
data.text = marketplace_locale.newMessage:gsub("{sender}", tostring(data.sender))
end
SendNUIMessage({ type = "marketplace:new-message", data = data })
end)
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sky Phone</title>
<script type="module" crossorigin src="./assets/sky-index-CgQnpDWL.js"></script>
<script type="module" crossorigin src="./assets/sky-index-D7Ll7isC.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-D9eWeoNZ.css">
</head>
<body>
+30
View File
@@ -394,6 +394,11 @@ local schema = {
{ name = "listing_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "seller_account_id", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "buyer_account_id", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "offer_id", type = "BIGINT UNSIGNED NULL" },
{ name = "offer_amount", type = "BIGINT UNSIGNED NULL" },
{ name = "offer_proposer_account_id", type = "BIGINT UNSIGNED NULL" },
{ name = "offer_status", type = "ENUM('pending', 'accepted', 'rejected') NULL" },
{ name = "offer_revision", type = "INT UNSIGNED NOT NULL DEFAULT 0" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
{ name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
},
@@ -433,6 +438,31 @@ local schema = {
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_marketplace_offers",
columns = {
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
{ name = "inquiry_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "proposer_account_id", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "amount", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "status", type = "ENUM('pending', 'accepted', 'rejected', 'countered') NOT NULL DEFAULT 'pending'" },
{ name = "read_at", type = "DATETIME NULL" },
{ name = "response_read_at", type = "DATETIME 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 = "id",
indexes = {
{ name = "idx_sky_phone_marketplace_offers", columns = "(`inquiry_id`, `id`)" },
{ name = "idx_sky_phone_marketplace_offer_unread", columns = "(`inquiry_id`, `read_at`, `proposer_account_id`)" },
{ name = "idx_sky_phone_marketplace_offer_response_unread", columns = "(`inquiry_id`, `response_read_at`, `proposer_account_id`)" },
},
foreignKeys = {
{ column = "inquiry_id", references = "`sky_phone_marketplace_inquiries` (`id`) ON DELETE CASCADE" },
{ column = "proposer_account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_marketplace_blocks",
columns = {
+224 -7
View File
@@ -5,6 +5,7 @@ local photo_gradients = {}
local item_conditions = { new = true, very_good = true, used = true, defective = true }
local price_types = { fixed = true, negotiable = true, free = true }
local report_reasons = { prohibited = true, fraud = true, spam = true, offensive = true, other = true }
local offer_responses = { accepted = true, rejected = true }
local public_statuses = { active = true, reserved = true }
local seller_statuses = { active = true, reserved = true, sold = true, removed = true }
@@ -37,6 +38,13 @@ local function affected_rows(result)
return type(result) == "table" and tonumber(result.affectedRows) or 0
end
local function insert_id(result)
if type(result) == "number" then
return result
end
return type(result) == "table" and tonumber(result.insertId) or nil
end
local function new_id()
local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {})
if not rows[1] or type(rows[1].id) ~= "string" then
@@ -201,13 +209,23 @@ end
local function marketplace_counts(account_id)
local rows = Bridge.Database.Query([[
SELECT
(SELECT COUNT(*) FROM `sky_phone_marketplace_messages` m
((SELECT COUNT(*) FROM `sky_phone_marketplace_messages` m
JOIN `sky_phone_marketplace_inquiries` q ON q.`id` = m.`inquiry_id`
WHERE (q.`seller_account_id` = ? OR q.`buyer_account_id` = ?)
AND m.`sender_account_id` <> ? AND m.`read_at` IS NULL) AS `unread`,
AND m.`sender_account_id` <> ? AND m.`read_at` IS NULL)
+ (SELECT COUNT(*) FROM `sky_phone_marketplace_offers` o
JOIN `sky_phone_marketplace_inquiries` q ON q.`id` = o.`inquiry_id`
WHERE (q.`seller_account_id` = ? OR q.`buyer_account_id` = ?)
AND ((o.`proposer_account_id` <> ? AND o.`read_at` IS NULL)
OR (o.`proposer_account_id` = ? AND o.`status` IN ('accepted', 'rejected')
AND o.`response_read_at` IS NULL)))) AS `unread`,
(SELECT COUNT(*) FROM `sky_phone_marketplace_listings`
WHERE `seller_account_id` = ? AND `status` IN ('active', 'reserved')) AS `active`
]], { account_id, account_id, account_id, account_id })
]], {
account_id, account_id, account_id,
account_id, account_id, account_id, account_id,
account_id,
})
return {
unread = tonumber(rows[1] and rows[1].unread) or 0,
active = tonumber(rows[1] and rows[1].active) or 0,
@@ -558,9 +576,15 @@ Bridge.Callbacks.Register("sky_phone:marketplace:list-inquiries", function(sourc
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
((SELECT COUNT(*) FROM `sky_phone_marketplace_messages` unread
WHERE unread.`inquiry_id` = q.`id` AND unread.`sender_account_id` <> ?
AND unread.`read_at` IS NULL) AS `unread`
AND unread.`read_at` IS NULL)
+ (SELECT COUNT(*) FROM `sky_phone_marketplace_offers` unread_offer
WHERE unread_offer.`inquiry_id` = q.`id`
AND ((unread_offer.`proposer_account_id` <> ? AND unread_offer.`read_at` IS NULL)
OR (unread_offer.`proposer_account_id` = ?
AND unread_offer.`status` IN ('accepted', 'rejected')
AND unread_offer.`response_read_at` IS NULL)))) AS `unread`
FROM `sky_phone_marketplace_inquiries` q
JOIN `sky_phone_marketplace_listings` l ON l.`id` = q.`listing_id`
JOIN `sky_phone_accounts` other_account ON other_account.`id` =
@@ -568,7 +592,7 @@ Bridge.Callbacks.Register("sky_phone:marketplace:list-inquiries", function(sourc
WHERE q.`seller_account_id` = ? OR q.`buyer_account_id` = ?
ORDER BY q.`updated_at` DESC
LIMIT 100
]], { account.id, account.id, account.id, account.id })
]], { account.id, account.id, account.id, account.id, account.id, account.id })
return { success = true, data = rows }
end)
@@ -597,6 +621,17 @@ Bridge.Callbacks.Register("sky_phone:marketplace:get-inquiry", function(source,
SET `read_at` = CURRENT_TIMESTAMP
WHERE `inquiry_id` = ? AND `sender_account_id` <> ? AND `read_at` IS NULL
]], { data.id, account.id })
Bridge.Database.Query([[
UPDATE `sky_phone_marketplace_offers`
SET `read_at` = CURRENT_TIMESTAMP
WHERE `inquiry_id` = ? AND `proposer_account_id` <> ? AND `read_at` IS NULL
]], { data.id, account.id })
Bridge.Database.Query([[
UPDATE `sky_phone_marketplace_offers`
SET `response_read_at` = CURRENT_TIMESTAMP
WHERE `inquiry_id` = ? AND `proposer_account_id` = ?
AND `status` IN ('accepted', 'rejected') AND `response_read_at` IS NULL
]], { data.id, account.id })
local messages = Bridge.Database.Query([[
SELECT `id`, `sender_account_id`, `body`, `created_at`, `read_at`
FROM `sky_phone_marketplace_messages`
@@ -604,8 +639,19 @@ Bridge.Callbacks.Register("sky_phone:marketplace:get-inquiry", function(source,
ORDER BY `id` ASC
LIMIT ?
]], { data.id, Config.Marketplace.MessagePageSize })
local offers = Bridge.Database.Query([[
SELECT `id`, `proposer_account_id`, `amount`, `status`, `read_at`, `response_read_at`,
`created_at`, `updated_at`
FROM `sky_phone_marketplace_offers`
WHERE `inquiry_id` = ?
ORDER BY `id` ASC
LIMIT ?
]], { data.id, Config.Marketplace.OfferHistorySize })
notify_changed(account.id)
return { success = true, data = { inquiry = inquiries[1], messages = messages, accountId = account.id } }
return {
success = true,
data = { inquiry = inquiries[1], messages = messages, offers = offers, accountId = account.id },
}
end)
Bridge.Callbacks.Register("sky_phone:marketplace:send-message", function(source, data)
@@ -695,6 +741,177 @@ Bridge.Callbacks.Register("sky_phone:marketplace:send-message", function(source,
return { success = true, data = { id = inquiry.id } }
end)
Bridge.Callbacks.Register("sky_phone:marketplace:make-offer", function(source, data)
local account, error_response = require_account(source)
if not account then return error_response end
if not SkyPhone.AllowOperation(source, "marketplace:offer", 10, 60) then
return { success = false, error = "rate_limited" }
end
data = require_payload(source, "make-offer", data)
local amount = data and tonumber(data.amount) or nil
if not data or type(data.inquiryId) ~= "string" or #data.inquiryId ~= 36
or not amount or amount ~= math.floor(amount) or amount < 1
or amount > Config.Marketplace.MaximumPrice
then
return { success = false, error = "invalid_offer" }
end
local rows = Bridge.Database.Query([[
SELECT q.*, l.`status` AS `listing_status`, l.`reserved_account_id`
FROM `sky_phone_marketplace_inquiries` q
JOIN `sky_phone_marketplace_listings` l ON l.`id` = q.`listing_id`
WHERE q.`id` = ? AND (q.`seller_account_id` = ? OR q.`buyer_account_id` = ?)
LIMIT 1
]], { data.inquiryId, account.id, account.id })
local inquiry = rows[1]
if not inquiry then return { success = false, error = "inquiry_not_found" } end
local buyer_account_id = tonumber(inquiry.buyer_account_id)
local seller_account_id = tonumber(inquiry.seller_account_id)
local reserved_account_id = tonumber(inquiry.reserved_account_id)
if inquiry.listing_status ~= "active"
and (inquiry.listing_status ~= "reserved" or reserved_account_id ~= buyer_account_id)
then
return { success = false, error = "offer_listing_unavailable" }
end
if inquiry.offer_status == "accepted" then
return { success = false, error = "offer_closed" }
end
local current_proposer_account_id = tonumber(inquiry.offer_proposer_account_id)
if inquiry.offer_status == "pending" and current_proposer_account_id == account.id then
return { success = false, error = "offer_waiting" }
end
if inquiry.offer_status ~= "pending" and account.id ~= buyer_account_id then
return { success = false, error = "offer_not_allowed" }
end
local other_account_id = account.id == seller_account_id and buyer_account_id or seller_account_id
local blocked = Bridge.Database.Query([[
SELECT 1 FROM `sky_phone_marketplace_blocks`
WHERE (`blocker_account_id` = ? AND `blocked_account_id` = ?)
OR (`blocker_account_id` = ? AND `blocked_account_id` = ?)
LIMIT 1
]], { account.id, other_account_id, other_account_id, account.id })
if blocked[1] then return { success = false, error = "blocked" } end
local offer_result = Bridge.Database.Query([[
INSERT INTO `sky_phone_marketplace_offers` (`inquiry_id`, `proposer_account_id`, `amount`)
VALUES (?, ?, ?)
]], { inquiry.id, account.id, amount })
local offer_id = insert_id(offer_result)
if not offer_id then
error("[sky_phone] Database did not return a marketplace offer id.")
end
local revision = tonumber(inquiry.offer_revision) or 0
local update_result = Bridge.Database.Query([[
UPDATE `sky_phone_marketplace_inquiries`
SET `offer_id` = ?, `offer_amount` = ?, `offer_proposer_account_id` = ?,
`offer_status` = 'pending', `offer_revision` = `offer_revision` + 1,
`updated_at` = CURRENT_TIMESTAMP
WHERE `id` = ? AND `offer_revision` = ?
]], { offer_id, amount, account.id, inquiry.id, revision })
if affected_rows(update_result) == 0 then
Bridge.Database.Query(
"UPDATE `sky_phone_marketplace_offers` SET `status` = 'countered' WHERE `id` = ?",
{ offer_id }
)
return { success = false, error = "offer_conflict" }
end
local previous_offer_id = tonumber(inquiry.offer_id)
if previous_offer_id then
Bridge.Database.Query([[
UPDATE `sky_phone_marketplace_offers`
SET `status` = 'countered'
WHERE `id` = ? AND `status` = 'pending'
]], { previous_offer_id })
end
notify_changed(account.id)
SkyPhone.NotifyAccountDevices(other_account_id, "sky_phone:marketplace:new-message", {
amount = amount,
inquiryId = inquiry.id,
kind = "offer",
listingId = inquiry.listing_id,
sender = account.email:match("^([^@]+)") or account.email,
})
notify_changed(other_account_id)
return { success = true, data = { id = offer_id } }
end)
Bridge.Callbacks.Register("sky_phone:marketplace:respond-offer", function(source, data)
local account, error_response = require_account(source)
if not account then return error_response end
if not SkyPhone.AllowOperation(source, "marketplace:offer-response", 10, 60) then
return { success = false, error = "rate_limited" }
end
data = require_payload(source, "respond-offer", data)
if not data or type(data.inquiryId) ~= "string" or #data.inquiryId ~= 36
or not offer_responses[data.action]
then
return { success = false, error = "invalid_offer_response" }
end
local rows = Bridge.Database.Query([[
SELECT q.*, l.`status` AS `listing_status`, l.`reserved_account_id`
FROM `sky_phone_marketplace_inquiries` q
JOIN `sky_phone_marketplace_listings` l ON l.`id` = q.`listing_id`
WHERE q.`id` = ? AND (q.`seller_account_id` = ? OR q.`buyer_account_id` = ?)
LIMIT 1
]], { data.inquiryId, account.id, account.id })
local inquiry = rows[1]
if not inquiry then return { success = false, error = "inquiry_not_found" } end
local offer_id = tonumber(inquiry.offer_id)
local proposer_account_id = tonumber(inquiry.offer_proposer_account_id)
if inquiry.offer_status ~= "pending" or not offer_id or proposer_account_id == account.id then
return { success = false, error = "offer_not_actionable" }
end
local buyer_account_id = tonumber(inquiry.buyer_account_id)
if data.action == "accepted" then
local reservation_result = Bridge.Database.Query([[
UPDATE `sky_phone_marketplace_listings`
SET `status` = 'reserved', `reserved_account_id` = ?, `revision` = `revision` + 1
WHERE `id` = ? AND (`status` = 'active'
OR (`status` = 'reserved' AND `reserved_account_id` = ?))
]], { buyer_account_id, inquiry.listing_id, buyer_account_id })
if affected_rows(reservation_result) == 0 then
return { success = false, error = "offer_listing_unavailable" }
end
end
local revision = tonumber(inquiry.offer_revision) or 0
local update_result = Bridge.Database.Query([[
UPDATE `sky_phone_marketplace_inquiries`
SET `offer_status` = ?, `offer_revision` = `offer_revision` + 1,
`updated_at` = CURRENT_TIMESTAMP
WHERE `id` = ? AND `offer_id` = ? AND `offer_revision` = ? AND `offer_status` = 'pending'
]], { data.action, inquiry.id, offer_id, revision })
if affected_rows(update_result) == 0 then
return { success = false, error = "offer_conflict" }
end
Bridge.Database.Query([[
UPDATE `sky_phone_marketplace_offers`
SET `status` = ?
WHERE `id` = ? AND `status` = 'pending'
]], { data.action, offer_id })
notify_changed(account.id)
SkyPhone.NotifyAccountDevices(proposer_account_id, "sky_phone:marketplace:new-message", {
action = data.action,
amount = tonumber(inquiry.offer_amount),
inquiryId = inquiry.id,
kind = "offer-response",
listingId = inquiry.listing_id,
sender = account.email:match("^([^@]+)") or account.email,
})
notify_changed(proposer_account_id)
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:marketplace:report", function(source, data)
local account, error_response = require_account(source)
if not account then return error_response end