Files
sky_phone-sky-systems/frontend/testserver/index.cjs
T
smx.pusha 18fd8cb950 ENH - integrate camera and gallery updates
# Conflicts:
#	frontend/src/config/apps.ts
#	sky_phone/fxmanifest.lua
#	sky_phone/source/html/index.html
2026-08-06 15:07:55 +02:00

812 lines
33 KiB
JavaScript

const cors = require('cors')
const express = require('express')
const app = express()
const port = Number(process.argv[2]) || 3001
app.use(cors())
app.use(express.json())
let authenticated = true
let draft = null
const accountDevices = [
{
created_at: '2026-08-04 12:00:00',
current: true,
device_name: 'iFruit Phone',
imei: '356938035643809',
updated_at: '2026-08-04 12:00:00',
},
]
const messages = [
{
body: 'Welcome to iFruit Mail. Your shared mailbox is ready to use.',
created_at: '2026-08-04 11:30:00',
folder: 'inbox',
id: 1,
is_read: false,
message_id: 'b73f3872-54cc-4d74-a058-6caa24f0dfff',
preview: 'Welcome to iFruit Mail. Your shared mailbox is ready to use.',
recipients: ['demo@ifruit.com'],
sender: 'support@ifruit.com',
subject: 'Welcome to iFruit Mail',
trashed_at: null,
},
{
body: 'The meet is behind the casino at nine. Bring the blue car.',
created_at: '2026-08-04 10:15:00',
folder: 'inbox',
id: 2,
is_read: true,
message_id: '79042d13-f86d-4418-a490-8b558539c20b',
preview: 'The meet is behind the casino at nine. Bring the blue car.',
recipients: ['demo@ifruit.com', 'jamie@ifruit.com'],
sender: 'morgan@ifruit.com',
subject: 'Tonight',
trashed_at: null,
},
{
body: 'I will be there.',
created_at: '2026-08-03 18:05:00',
folder: 'sent',
id: 3,
is_read: true,
message_id: 'c4414c1e-5fe3-46ea-bc0b-b5b9785c91bd',
preview: 'I will be there.',
recipients: ['morgan@ifruit.com'],
sender: 'demo@ifruit.com',
subject: 'Re: Tonight',
trashed_at: null,
},
{
body: 'This is an old message.',
created_at: '2026-08-02 09:20:00',
folder: 'inbox',
id: 4,
is_read: true,
message_id: 'd99669ab-c1ac-42d4-8ed4-3c4fe7fa37fb',
preview: 'This is an old message.',
recipients: ['demo@ifruit.com'],
sender: 'news@ifruit.com',
subject: 'Old mail',
trashed_at: '2026-08-04 08:00:00',
},
]
const marketplaceListings = [
{
id: '81bc9d37-20e1-4d8a-82f8-f4b85f77cf01', seller_account_id: 2, seller_name: 'morgan',
seller_since: '2026-07-02 10:00:00', seller_active: 2, title: 'Sultan RS in excellent condition',
description: 'Clean Sultan RS with fresh service, tidy interior and no known damage. Viewing and test drive available in Vinewood.',
category: 'vehicles', item_condition: 'very_good', price_type: 'negotiable', price: 185000,
district: 'vinewood', status: 'active', revision: 1, show_phone: 0, phone_number: null,
created_at: '2026-08-06 09:20:00', updated_at: '2026-08-06 09:20:00', expires_at: '2026-08-13 09:20:00',
image: 'linear-gradient(145deg, #ff6b6b, #845ec2 52%, #0f2027)', is_favorite: false,
images: [
{ media_id: 'capture-car', gradient: 'linear-gradient(145deg, #ff6b6b, #845ec2 52%, #0f2027)', sort_order: 1 },
{ media_id: 'city-lights', gradient: 'linear-gradient(135deg, #fbc2eb, #a6c1ee 48%, #302b63)', sort_order: 2 },
{ media_id: 'ocean-air', gradient: 'linear-gradient(160deg, #67d5b5, #26648e 55%, #0b132b)', sort_order: 3 },
],
},
{
id: '81bc9d37-20e1-4d8a-82f8-f4b85f77cf02', seller_account_id: 3, seller_name: 'jamie',
seller_since: '2026-06-14 10:00:00', seller_active: 1, title: 'Modern apartment near Vespucci',
description: 'Bright apartment with a city view, underground parking and modern furniture. Available immediately after viewing.',
category: 'property', item_condition: 'very_good', price_type: 'fixed', price: 420000,
district: 'vespucci', status: 'active', revision: 1, show_phone: 0, phone_number: null,
created_at: '2026-08-05 17:10:00', updated_at: '2026-08-05 17:10:00', expires_at: '2026-08-12 17:10:00',
image: null, is_favorite: true,
images: [],
},
{
id: '81bc9d37-20e1-4d8a-82f8-f4b85f77cf03', seller_account_id: 4, seller_name: 'citytech',
seller_since: '2026-05-22 10:00:00', seller_active: 3, title: 'Nearly new gaming laptop',
description: 'Fast gaming laptop including charger and carrying bag. Runs quietly and can be tested before purchase.',
category: 'electronics', item_condition: 'very_good', price_type: 'fixed', price: 3500,
district: 'los_santos', status: 'reserved', revision: 2, show_phone: 0, phone_number: null,
created_at: '2026-08-05 12:30:00', updated_at: '2026-08-06 08:00:00', expires_at: '2026-08-12 12:30:00',
image: 'linear-gradient(160deg, #67d5b5, #26648e 55%, #0b132b)', is_favorite: false,
images: [{ media_id: 'ocean-air', gradient: 'linear-gradient(160deg, #67d5b5, #26648e 55%, #0b132b)', sort_order: 1 }],
},
{
id: '81bc9d37-20e1-4d8a-82f8-f4b85f77cf04', seller_account_id: 1, seller_name: 'demo',
seller_since: '2026-08-04 12:00:00', seller_active: 1, title: 'Complete mechanic tool set',
description: 'Complete mechanic tool set with trolley, sockets and diagnostic equipment. Everything is clean and ready for work.',
category: 'tools', item_condition: 'very_good', price_type: 'fixed', price: 7800,
district: 'south_los_santos', status: 'active', revision: 1, show_phone: 1, phone_number: '5551234567',
created_at: '2026-08-06 10:45:00', updated_at: '2026-08-06 10:45:00', expires_at: '2026-08-13 10:45:00',
image: 'linear-gradient(135deg, #ffc75f, #f96d80 48%, #4b4453)', is_favorite: false,
images: [{ media_id: 'capture-tools', gradient: 'linear-gradient(135deg, #ffc75f, #f96d80 48%, #4b4453)', sort_order: 1 }],
},
]
let linkedAccount = {
devices: accountDevices,
email: 'demo@ifruit.com',
id: 1,
}
let mockNotes = [
{
body: 'Meet Morgan in Vinewood and inspect the Sultan RS.',
createdAt: Date.now() - 3_600_000,
id: 'demo-note-marketplace',
pinned: true,
revision: 1,
title: 'CityMarkt viewing',
updatedAt: Date.now() - 3_600_000,
},
]
const deviceData = {}
let mockMedia = [
{
createdAt: Date.now() - 60_000,
id: 1,
mediaType: 'photo',
url: 'https://picsum.photos/seed/sky-phone-1/600/800',
},
{
createdAt: Date.now() - 120_000,
id: 2,
mediaType: 'video',
url: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4',
},
]
const marketplaceInquiries = [
{
id: '4903b923-409a-437e-971f-b7a2b10e9e31',
listing_id: '81bc9d37-20e1-4d8a-82f8-f4b85f77cf01',
seller_account_id: 2,
buyer_account_id: 1,
seller_name: 'morgan',
buyer_name: 'demo',
title: 'Sultan RS in excellent condition',
price: 185000,
price_type: 'negotiable',
status: 'active',
image: 'linear-gradient(145deg, #ff6b6b, #845ec2 52%, #0f2027)',
other_name: 'morgan',
last_message: 'Sure, come by the Vinewood garage around 8 PM.',
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',
},
{
id: '4903b923-409a-437e-971f-b7a2b10e9e32',
listing_id: '81bc9d37-20e1-4d8a-82f8-f4b85f77cf04',
seller_account_id: 1,
buyer_account_id: 3,
seller_name: 'demo',
buyer_name: 'jamie',
title: 'Complete mechanic tool set',
price: 7800,
price_type: 'fixed',
status: 'active',
image: 'linear-gradient(135deg, #ffc75f, #f96d80 48%, #4b4453)',
other_name: 'jamie',
last_message: 'I can collect it today and pay the full price.',
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',
},
]
const marketplaceMessages = [
{
id: 1,
inquiry_id: '4903b923-409a-437e-971f-b7a2b10e9e31',
sender_account_id: 1,
body: 'Hi, is the Sultan still available?',
created_at: '2026-08-06 11:35:00',
read_at: '2026-08-06 11:36:00',
},
{
id: 2,
inquiry_id: '4903b923-409a-437e-971f-b7a2b10e9e31',
sender_account_id: 2,
body: 'Yes, it is. You can also take it for a short test drive.',
created_at: '2026-08-06 11:38:00',
read_at: '2026-08-06 11:39:00',
},
{
id: 3,
inquiry_id: '4903b923-409a-437e-971f-b7a2b10e9e31',
sender_account_id: 2,
body: 'Sure, come by the Vinewood garage around 8 PM.',
created_at: '2026-08-06 11:42:00',
read_at: null,
},
{
id: 4,
inquiry_id: '4903b923-409a-437e-971f-b7a2b10e9e32',
sender_account_id: 3,
body: 'Hello, does the diagnostic equipment work with every vehicle?',
created_at: '2026-08-06 11:51:00',
read_at: null,
},
{
id: 5,
inquiry_id: '4903b923-409a-437e-971f-b7a2b10e9e32',
sender_account_id: 3,
body: 'I can collect it today and pay the full price.',
created_at: '2026-08-06 11:55:00',
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',
},
]
const pagesPosts = [
{
id: 'pages-citymarkt-1', account_id: 2, author_name: 'morgan', source_type: 'citymarkt',
citymarkt_listing_id: '81bc9d37-20e1-4d8a-82f8-f4b85f77cf01',
title: 'Sultan RS in excellent condition',
body: 'Clean Sultan RS with fresh service, tidy interior and no known damage. Viewing and test drive available in Vinewood.',
category: 'citymarkt', district: 'vinewood', created_at: '2026-08-06 12:50:00', like_count: 24,
images: [{ media_id: 'capture-car', gradient: 'linear-gradient(145deg, #ff6b6b, #845ec2 52%, #0f2027)', sort_order: 1 }],
},
{
id: 'pages-1', account_id: 2, author_name: 'morgan', source_type: 'personal', citymarkt_listing_id: null,
title: 'Best sunset view above Vinewood', body: 'Take the small trail behind the observatory shortly before sunset. The view across Los Santos is incredible and there is enough space to park two cars.',
category: 'recommendation', district: 'vinewood', created_at: '2026-08-06 12:20:00', like_count: 18,
images: [{ media_id: 'sunset-drive', gradient: 'linear-gradient(145deg, #ff9a62, #5f2c82 58%, #141e30)', sort_order: 1 }],
},
{
id: 'pages-2', account_id: 1, author_name: 'demo', source_type: 'personal', citymarkt_listing_id: null,
title: 'Looking for people for a beach cruise', body: 'Meeting at Vespucci Pier tonight. Bring a clean car and a good playlist. Everyone is welcome.',
category: 'community', district: 'vespucci', created_at: '2026-08-06 11:10:00', like_count: 7,
images: [{ media_id: 'ocean-air', gradient: 'linear-gradient(160deg, #67d5b5, #26648e 55%, #0b132b)', sort_order: 1 }],
},
{
id: 'pages-3', account_id: 3, author_name: 'jamie', source_type: 'personal', citymarkt_listing_id: null,
title: 'Mobile repair help around Sandy Shores', body: 'I can help with small repairs and jump starts around Sandy Shores this afternoon. Send me a message when you see me nearby.',
category: 'service', district: 'sandy_shores', created_at: '2026-08-05 19:40:00', like_count: 12, images: [],
},
]
const pagesReactions = [{ post_id: 'pages-1', account_id: 1, kind: 'like' }, { post_id: 'pages-3', account_id: 1, kind: 'save' }]
function pageView(post) {
const listing = marketplaceListings.find((item) => item.id === post.citymarkt_listing_id)
return {
...post,
citymarkt_price: listing?.price ?? null,
image: post.images[0]?.gradient ?? null,
is_liked: pagesReactions.some((item) => item.post_id === post.id && item.account_id === 1 && item.kind === 'like'),
is_owner: authenticated && post.account_id === 1,
is_saved: pagesReactions.some((item) => item.post_id === post.id && item.account_id === 1 && item.kind === 'save'),
}
}
function counts() {
return {
drafts: draft ? 1 : 0,
inbox: messages.filter(
(item) => item.folder === 'inbox' && !item.trashed_at,
).length,
sent: messages.filter((item) => item.folder === 'sent' && !item.trashed_at)
.length,
trash: messages.filter((item) => item.trashed_at).length,
unread: messages.filter(
(item) => item.folder === 'inbox' && !item.trashed_at && !item.is_read,
).length,
}
}
app.post('/api/:endpoint', (request, response) => {
console.log(`[NUI] ${request.params.endpoint}`, request.body)
const endpoint = request.params.endpoint
if (endpoint === 'weather:get') {
response.json({
success: true,
data: {
clock: { year: 2026, month: 8, day: 5, hour: 17, minute: 20 },
condition: 'partly_cloudy',
rainLevel: 0.08,
region: 'los_santos',
windSpeed: 3.2,
},
})
return
}
if (endpoint === 'map:getPlayerCoords') {
response.json({
success: true,
data: { coords: { x: -75.2, y: -818.9, z: 326.2 } },
})
return
}
if (endpoint === 'media:config') {
response.json({ success: true, data: { videoBitrateKbps: 1500 } })
return
}
if (endpoint === 'gallery:list') {
if (request.body.mockState === 'error') {
response.json({ success: false, error: 'service_unavailable' })
return
}
if (request.body.mockState === 'empty') {
response.json({ success: true, data: [] })
return
}
const filtered = request.body.mediaType
? mockMedia.filter((item) => item.mediaType === request.body.mediaType)
: mockMedia
const offset = Number(request.body.offset) || 0
const limit = Number(request.body.limit) || 36
response.json({
success: true,
data: filtered.slice(offset, offset + limit),
})
return
}
if (
endpoint === 'media:requestUpload' ||
endpoint === 'media:completeUpload' ||
endpoint === 'media:failUpload' ||
endpoint === 'media:cancelUpload'
) {
response.json({ success: true })
return
}
if (endpoint === 'gallery:delete') {
mockMedia = mockMedia.filter((item) => item.id !== Number(request.body.id))
response.json({ success: true })
return
}
if (endpoint === 'account:login' || endpoint === 'account:register') {
authenticated = true
linkedAccount = {
devices: accountDevices,
email: request.body.email.includes('@')
? request.body.email
: `${request.body.email}@ifruit.com`,
id: 1,
}
response.json({ success: true, data: linkedAccount })
return
}
if (endpoint === 'account:logout') {
authenticated = false
linkedAccount = null
response.json({ success: true })
return
}
if (endpoint === 'account:devices') {
response.json({ success: true, data: accountDevices })
return
}
if (endpoint === 'account:remove-device') {
response.json({ success: true, data: accountDevices })
return
}
if (endpoint === 'device:save') {
const current = deviceData[request.body.namespace]
const revision = (current?.revision ?? 0) + 1
deviceData[request.body.namespace] = {
payload: request.body.payload,
revision,
}
response.json({ success: true, data: { revision } })
return
}
if (endpoint === 'device:factory-reset') {
authenticated = false
linkedAccount = null
mockNotes = []
mockMedia = []
for (const key of Object.keys(deviceData)) delete deviceData[key]
response.json({ success: true })
return
}
if (endpoint === 'pages:list') {
const query = String(request.body.search ?? '').toLowerCase()
let items = pagesPosts
if (request.body.category && request.body.category !== 'all') items = items.filter((item) => item.category === request.body.category)
if (query) items = items.filter((item) => `${item.title} ${item.body}`.toLowerCase().includes(query))
if (request.body.saved) items = items.filter((post) => pagesReactions.some((reaction) => reaction.post_id === post.id && reaction.account_id === 1 && reaction.kind === 'save'))
response.json({ success: true, data: { hasMore: false, items: items.map(pageView), offset: 0 } })
return
}
if (endpoint === 'pages:get') {
const post = pagesPosts.find((item) => item.id === request.body.id)
response.json(post ? { success: true, data: pageView(post) } : { success: false, error: 'post_not_found' })
return
}
if (endpoint.startsWith('pages:') && !authenticated) {
response.json({ success: false, error: 'not_authenticated' })
return
}
if (endpoint === 'pages:list-own') {
response.json({ success: true, data: { hasMore: false, items: pagesPosts.filter((item) => item.account_id === 1).map(pageView), offset: 0 } })
return
}
if (endpoint === 'pages:create') {
const gradients = { 'sunset-drive': 'linear-gradient(145deg, #ff9a62, #5f2c82 58%, #141e30)', 'ocean-air': 'linear-gradient(160deg, #67d5b5, #26648e 55%, #0b132b)', 'city-lights': 'linear-gradient(135deg, #fbc2eb, #a6c1ee 48%, #302b63)', 'desert-road': 'linear-gradient(150deg, #f6d365, #fda085 45%, #512b58)' }
const id = `pages-${Date.now()}`
const images = request.body.images.map((image, index) => ({ media_id: image.id, gradient: gradients[image.id] ?? 'linear-gradient(145deg, #ff6b6b, #845ec2 52%, #0f2027)', sort_order: index + 1 }))
pagesPosts.unshift({ ...request.body, id, account_id: 1, author_name: 'demo', source_type: 'personal', citymarkt_listing_id: null, created_at: new Date().toISOString(), like_count: 0, images })
response.json({ success: true, data: { id } })
return
}
if (endpoint === 'pages:share-citymarkt') {
const listing = marketplaceListings.find((item) => item.id === request.body.listingId && item.seller_account_id === 1 && ['active', 'reserved'].includes(item.status))
if (!listing) { response.json({ success: false, error: 'citymarkt_not_found' }); return }
if (pagesPosts.some((item) => item.citymarkt_listing_id === listing.id)) { response.json({ success: false, error: 'citymarkt_already_shared' }); return }
if (pagesPosts.some((item) => item.account_id === 1 && item.source_type === 'citymarkt' && item.created_at.slice(0, 10) === '2026-08-06')) { response.json({ success: false, error: 'citymarkt_daily_limit' }); return }
const id = `pages-${Date.now()}`
pagesPosts.unshift({ id, account_id: 1, author_name: 'demo', source_type: 'citymarkt', citymarkt_listing_id: listing.id, title: listing.title, body: listing.description, category: 'citymarkt', district: listing.district, created_at: '2026-08-06 13:30:00', like_count: 0, images: listing.images })
response.json({ success: true, data: { id } })
return
}
if (endpoint === 'pages:react') {
const index = pagesReactions.findIndex((item) => item.post_id === request.body.id && item.account_id === 1 && item.kind === request.body.kind)
if (request.body.active && index < 0) pagesReactions.push({ post_id: request.body.id, account_id: 1, kind: request.body.kind })
if (!request.body.active && index >= 0) pagesReactions.splice(index, 1)
response.json({ success: true })
return
}
if (endpoint === 'pages:delete') {
const index = pagesPosts.findIndex((item) => item.id === request.body.id && item.account_id === 1)
if (index < 0) { response.json({ success: false, error: 'post_not_found' }); return }
pagesPosts.splice(index, 1)
response.json({ success: true })
return
}
if (endpoint === 'marketplace:list') {
const query = String(request.body.search ?? '').toLowerCase()
let items = marketplaceListings.filter((item) => ['active', 'reserved'].includes(item.status))
if (request.body.category && request.body.category !== 'all') items = items.filter((item) => item.category === request.body.category)
if (request.body.district && request.body.district !== 'all') items = items.filter((item) => item.district === request.body.district)
if (query) items = items.filter((item) => `${item.title} ${item.description}`.toLowerCase().includes(query))
if (request.body.favorites) items = items.filter((item) => item.is_favorite)
response.json({ success: true, data: { hasMore: false, items, offset: 0 } })
return
}
if (endpoint === 'marketplace:get') {
const item = marketplaceListings.find((listing) => listing.id === request.body.id)
response.json(item ? { success: true, data: { ...item, is_owner: authenticated && item.seller_account_id === 1 } } : { success: false, error: 'listing_not_found' })
return
}
if (endpoint.startsWith('marketplace:') && !authenticated) {
response.json({ success: false, error: 'not_authenticated' })
return
}
if (endpoint === 'marketplace:counts') {
response.json({ success: true, data: { active: marketplaceListings.filter((item) => item.seller_account_id === 1 && ['active', 'reserved'].includes(item.status)).length, unread: marketplaceInquiries.reduce((total, item) => total + item.unread, 0) } })
return
}
if (endpoint === 'marketplace:list-own') {
response.json({ success: true, data: { hasMore: false, items: marketplaceListings.filter((item) => item.seller_account_id === 1), offset: 0 } })
return
}
if (endpoint === 'marketplace:create') {
const id = `81bc9d37-20e1-4d8a-82f8-${String(Date.now()).slice(-12)}`
const selected = request.body.images.map((image) => {
const photo = { 'sunset-drive': 'linear-gradient(145deg, #ff9a62, #5f2c82 58%, #141e30)', 'ocean-air': 'linear-gradient(160deg, #67d5b5, #26648e 55%, #0b132b)', 'city-lights': 'linear-gradient(135deg, #fbc2eb, #a6c1ee 48%, #302b63)', 'desert-road': 'linear-gradient(150deg, #f6d365, #fda085 45%, #512b58)' }[image.id] ?? 'linear-gradient(145deg, #ff6b6b, #845ec2 52%, #0f2027)'
return { media_id: image.id, gradient: photo, sort_order: 1 }
})
marketplaceListings.unshift({ ...request.body, id, seller_account_id: 1, seller_name: 'demo', seller_since: '2026-08-04 12:00:00', seller_active: 1, item_condition: request.body.condition, price_type: request.body.priceType, show_phone: request.body.showPhone ? 1 : 0, phone_number: null, status: 'active', revision: 1, created_at: '2026-08-06 11:00:00', updated_at: '2026-08-06 11:00:00', expires_at: '2026-08-13 11:00:00', images: selected, image: selected[0]?.gradient ?? null, is_favorite: false })
response.json({ success: true, data: { id } })
return
}
if (endpoint === 'marketplace:update') {
const item = marketplaceListings.find((listing) => listing.id === request.body.id)
if (!item) {
response.json({ success: false, error: 'listing_not_found' })
return
}
const selected = request.body.images.map((image, index) => ({
media_id: image.id,
gradient: { 'sunset-drive': 'linear-gradient(145deg, #ff9a62, #5f2c82 58%, #141e30)', 'ocean-air': 'linear-gradient(160deg, #67d5b5, #26648e 55%, #0b132b)', 'city-lights': 'linear-gradient(135deg, #fbc2eb, #a6c1ee 48%, #302b63)', 'desert-road': 'linear-gradient(150deg, #f6d365, #fda085 45%, #512b58)' }[image.id] ?? item.image,
sort_order: index + 1,
}))
Object.assign(item, request.body, {
image: selected[0]?.gradient ?? null,
images: selected,
item_condition: request.body.condition,
price_type: request.body.priceType,
revision: item.revision + 1,
})
response.json({ success: true, data: { revision: item.revision } })
return
}
if (endpoint === 'marketplace:favorite') {
const item = marketplaceListings.find((listing) => listing.id === request.body.id)
if (item) item.is_favorite = request.body.favorite
response.json({ success: true })
return
}
if (endpoint === 'marketplace:set-status') {
const item = marketplaceListings.find((listing) => listing.id === request.body.id)
if (item) item.status = request.body.status
response.json({ success: true })
return
}
if (endpoint === 'marketplace:list-inquiries') {
response.json({ success: true, data: marketplaceInquiries })
return
}
if (endpoint === 'marketplace:send-message') {
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, 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 })
inquiry.last_message = request.body.body
inquiry.unread = 0
inquiry.updated_at = '2026-08-06 12:00:00'
response.json({ success: true, data: { id: inquiry.id } })
return
}
if (endpoint === 'marketplace:get-inquiry') {
const inquiry = marketplaceInquiries.find((item) => item.id === request.body.id)
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') {
response.json({ success: true })
return
}
if (
endpoint === 'sim:insert' &&
request.body.imei === '356938035643810' &&
!request.body.confirmed
) {
response.json({ success: false, error: 'confirmation_required' })
return
}
if (endpoint === 'notes:list') {
response.json({ success: true, data: mockNotes })
return
}
if (endpoint === 'notes:create') {
mockNotes.unshift({ ...request.body, revision: 1 })
response.json({ success: true, data: mockNotes })
return
}
if (endpoint === 'notes:update') {
const index = mockNotes.findIndex((note) => note.id === request.body.id)
if (index >= 0) {
mockNotes[index] = {
...request.body,
revision: mockNotes[index].revision + 1,
updatedAt: Date.now(),
}
}
response.json({ success: true, data: mockNotes })
return
}
if (endpoint === 'notes:delete') {
mockNotes = mockNotes.filter((note) => note.id !== request.body.id)
response.json({ success: true, data: mockNotes })
return
}
if (endpoint === 'mail:login' || endpoint === 'mail:register') {
authenticated = true
linkedAccount = {
devices: accountDevices,
email: 'demo@ifruit.com',
id: 1,
}
response.json({ success: true, data: linkedAccount })
return
}
if (endpoint === 'mail:logout') {
authenticated = false
response.json({ success: true })
return
}
if (endpoint.startsWith('mail:') && !authenticated) {
response.json({ success: false, error: 'not_authenticated' })
return
}
if (endpoint === 'mail:counts') {
response.json({ success: true, data: counts() })
return
}
if (endpoint === 'mail:list') {
const { folder, search = '' } = request.body
let items =
folder === 'drafts'
? draft
? [{ ...draft, created_at: draft.updated_at, preview: draft.body }]
: []
: messages.filter((item) =>
folder === 'trash'
? item.trashed_at
: item.folder === folder && !item.trashed_at,
)
const query = String(search).toLowerCase()
if (query) {
items = items.filter((item) =>
`${item.sender ?? ''} ${item.subject} ${item.preview}`
.toLowerCase()
.includes(query),
)
}
response.json({ success: true, data: { hasMore: false, items, offset: 0 } })
return
}
if (endpoint === 'mail:get') {
const message = messages.find((item) => item.id === Number(request.body.id))
if (message) message.is_read = true
response.json(
message
? { success: true, data: message }
: { success: false, error: 'message_not_found' },
)
return
}
if (endpoint === 'mail:get-draft') {
response.json(
draft
? { success: true, data: draft }
: { success: false, error: 'draft_not_found' },
)
return
}
if (endpoint === 'mail:save-draft') {
draft = {
...request.body,
id: request.body.id ?? '748836d2-2308-4c21-a4d8-3af6df6dd7eb',
created_at: '2026-08-04 11:40:00',
updated_at: '2026-08-04 11:40:00',
}
response.json({ success: true, data: { id: draft.id } })
return
}
if (endpoint === 'mail:delete-draft') {
draft = null
response.json({ success: true })
return
}
if (endpoint === 'mail:send') {
draft = null
response.json({ success: true, data: { id: 'mock-sent-message' } })
return
}
if (endpoint === 'mail:trash' || endpoint === 'mail:restore') {
const message = messages.find((item) => item.id === Number(request.body.id))
if (message)
message.trashed_at =
endpoint === 'mail:trash' ? '2026-08-04 12:00:00' : null
response.json({ success: true })
return
}
if (endpoint === 'mail:delete-forever') {
const index = messages.findIndex(
(item) => item.id === Number(request.body.id),
)
if (index >= 0) messages.splice(index, 1)
response.json({ success: true })
return
}
if (endpoint === 'mail:empty-trash') {
for (let index = messages.length - 1; index >= 0; index -= 1) {
if (messages[index].trashed_at) messages.splice(index, 1)
}
response.json({ success: true })
return
}
if (endpoint === 'mail:set-read') {
const message = messages.find((item) => item.id === Number(request.body.id))
if (message) message.is_read = request.body.read
response.json({ success: true })
return
}
response.json({ success: true })
})
app.listen(port, () => {
console.log(`Mock NUI server listening on http://localhost:${port}`)
})