mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 09:13:24 +00:00
ADD - support CityMarkt offer negotiation
This commit is contained in:
@@ -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>
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
},
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -50,6 +50,7 @@ Config.Mail = {
|
||||
Config.Marketplace = {
|
||||
PageSize = 20,
|
||||
MessagePageSize = 50,
|
||||
OfferHistorySize = 50,
|
||||
MaxActiveListings = 15,
|
||||
MaxImages = 6,
|
||||
TitleMinLength = 5,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user