Oasis 0.8.7 mobile (nodejs-project baseline)
This commit is contained in:
commit
e563b86ede
226 changed files with 141968 additions and 0 deletions
737
src/views/market_view.js
Normal file
737
src/views/market_view.js
Normal file
|
|
@ -0,0 +1,737 @@
|
|||
const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option, img, table, tr, th, td, progress, video, audio } = require("../server/node_modules/hyperaxe")
|
||||
const { template, i18n, userLink, renderStateChip, renderVisibilityChip, renderLifespanChip, renderEcoTax, renderSpreadButton } = require("./main_views")
|
||||
const moment = require("../server/node_modules/moment")
|
||||
const { config } = require("../server/SSB_server.js")
|
||||
const { renderUrl } = require("../backend/renderUrl")
|
||||
const { renderMapLocationUrl, renderMapEmbed, renderMapLocationVisitLabel, renderMapEmbedWithZoom } = require("./maps_view")
|
||||
|
||||
const renderMediaBlob = (value, fallbackSrc = null, attrs = {}) => {
|
||||
if (!value) return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null
|
||||
const s = String(value).trim()
|
||||
if (!s) return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null
|
||||
if (s.startsWith('&')) return img({ src: `/blob/${encodeURIComponent(s)}`, ...attrs })
|
||||
const mVideo = s.match(/\[video:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
|
||||
if (mVideo) return video({ controls: true, class: attrs.class || 'post-video', src: `/blob/${encodeURIComponent(mVideo[1])}` })
|
||||
const mAudio = s.match(/\[audio:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
|
||||
if (mAudio) return audio({ controls: true, class: attrs.class || 'post-audio', src: `/blob/${encodeURIComponent(mAudio[1])}` })
|
||||
const mImg = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
|
||||
if (mImg) return img({ src: `/blob/${encodeURIComponent(mImg[1])}`, class: attrs.class || 'post-image' })
|
||||
return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null
|
||||
}
|
||||
|
||||
const userId = config.keys.id
|
||||
|
||||
const parseBidEntry = (raw) => {
|
||||
const s = String(raw || "").trim()
|
||||
if (!s) return null
|
||||
|
||||
if (s.includes("|")) {
|
||||
const parts = s.split("|")
|
||||
if (parts.length < 3) return null
|
||||
const bidder = parts[0] || ""
|
||||
const amount = parseFloat(String(parts[1] || "").replace(",", "."))
|
||||
const time = parts.slice(2).join("|")
|
||||
if (!bidder || !Number.isFinite(amount) || !time) return null
|
||||
return { bidder, amount, time }
|
||||
}
|
||||
|
||||
const first = s.indexOf(":")
|
||||
const second = s.indexOf(":", first + 1)
|
||||
if (first === -1 || second === -1) return null
|
||||
|
||||
const bidder = s.slice(0, first)
|
||||
const amountStr = s.slice(first + 1, second)
|
||||
const time = s.slice(second + 1)
|
||||
const amount = parseFloat(String(amountStr || "").replace(",", "."))
|
||||
if (!bidder || !Number.isFinite(amount) || !time) return null
|
||||
return { bidder, amount, time }
|
||||
}
|
||||
|
||||
const toNum = (v) => {
|
||||
if (v === null || v === undefined) return NaN
|
||||
const n = parseFloat(String(v).replace(",", "."))
|
||||
return Number.isFinite(n) ? n : NaN
|
||||
}
|
||||
|
||||
const normalizeQ = (q) => String(q || "").trim().toLowerCase()
|
||||
|
||||
const matchesQuery = (item, q) => {
|
||||
const qq = normalizeQ(q)
|
||||
if (!qq) return true
|
||||
const title = String(item.title || "").toLowerCase()
|
||||
const tags = Array.isArray(item.tags) ? item.tags : []
|
||||
const tagStr = tags.map((t) => String(t || "").toLowerCase()).join(" ")
|
||||
return title.includes(qq) || tagStr.includes(qq)
|
||||
}
|
||||
|
||||
const withinPrice = (item, minP, maxP) => {
|
||||
const p = toNum(item.price)
|
||||
if (!Number.isFinite(p)) return false
|
||||
if (Number.isFinite(minP) && p < minP) return false
|
||||
if (Number.isFinite(maxP) && p > maxP) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const sortItems = (items, sort) => {
|
||||
const s = String(sort || "recent")
|
||||
if (s === "price") return items.slice().sort((a, b) => toNum(a.price) - toNum(b.price))
|
||||
if (s === "deadline") {
|
||||
return items.slice().sort((a, b) => {
|
||||
const ad = a.deadline ? new Date(a.deadline).getTime() : Number.POSITIVE_INFINITY
|
||||
const bd = b.deadline ? new Date(b.deadline).getTime() : Number.POSITIVE_INFINITY
|
||||
return ad - bd
|
||||
})
|
||||
}
|
||||
return items.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||
}
|
||||
|
||||
const buildReturnTo = (filter, q, minPrice, maxPrice, sort) => {
|
||||
const params = []
|
||||
if (filter) params.push(`filter=${encodeURIComponent(filter)}`)
|
||||
if (q) params.push(`q=${encodeURIComponent(q)}`)
|
||||
if (minPrice !== undefined && minPrice !== null && String(minPrice) !== "") params.push(`minPrice=${encodeURIComponent(String(minPrice))}`)
|
||||
if (maxPrice !== undefined && maxPrice !== null && String(maxPrice) !== "") params.push(`maxPrice=${encodeURIComponent(String(maxPrice))}`)
|
||||
if (sort) params.push(`sort=${encodeURIComponent(sort)}`)
|
||||
return `/market${params.length ? `?${params.join("&")}` : ""}`
|
||||
}
|
||||
|
||||
const renderCardField = (labelText, value = "") =>
|
||||
div({ class: "card-field" }, span({ class: "card-label" }, labelText), span({ class: "card-value" }, ...renderUrl(String(value))))
|
||||
|
||||
const renderCardFieldRich = (labelText, parts) =>
|
||||
div({ class: "card-field" }, span({ class: "card-label" }, labelText), span({ class: "card-value" }, ...(Array.isArray(parts) ? parts : [parts])))
|
||||
|
||||
const renderPmButton = (recipientId) =>
|
||||
recipientId && String(recipientId) !== String(userId)
|
||||
? form({ method: "GET", action: "/pm" }, input({ type: "hidden", name: "recipients", value: recipientId }), button({ type: "submit", class: "filter-btn" }, i18n.privateMessage))
|
||||
: null
|
||||
|
||||
const renderStockBar = (stockValue, maxValue) => {
|
||||
const s = Math.max(0, Number(stockValue || 0))
|
||||
const m = Math.max(1, Number(maxValue || s || 1))
|
||||
return div(
|
||||
{ class: "confirmations-block stock-block" },
|
||||
div(
|
||||
{ class: "card-field" },
|
||||
span({ class: "card-label" }, `${i18n.marketItemStock}: `),
|
||||
span({ class: "card-value" }, s > 0 ? `${s}/${m}` : i18n.marketOutOfStock)
|
||||
),
|
||||
progress({ class: "confirmations-progress stock-progress", value: Math.min(s, m), max: m })
|
||||
)
|
||||
}
|
||||
|
||||
const renderMarketCommentsSection = (itemId, returnTo, comments = []) => {
|
||||
const commentsCount = Array.isArray(comments) ? comments.length : 0
|
||||
|
||||
return div(
|
||||
{ class: "vote-comments-section market-comments" },
|
||||
div({ class: "comments-count" }, span({ class: "card-label" }, i18n.voteCommentsLabel + ": "), span({ class: "card-value" }, String(commentsCount))),
|
||||
div(
|
||||
{ class: "comment-form-wrapper" },
|
||||
h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
|
||||
form(
|
||||
{ method: "POST", action: `/market/${encodeURIComponent(itemId)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
textarea({ id: "comment-text", name: "text", rows: 4, class: "comment-textarea", placeholder: i18n.voteNewCommentPlaceholder }),
|
||||
div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
|
||||
br(),
|
||||
button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
|
||||
)
|
||||
),
|
||||
(() => {
|
||||
const visibleComments = (comments || []).filter(c => {
|
||||
const t = c && c.value && c.value.content && c.value.content.text
|
||||
return t && String(t).trim()
|
||||
})
|
||||
return visibleComments.length
|
||||
? div(
|
||||
{ class: "comments-list" },
|
||||
visibleComments.map((c) => {
|
||||
const author = c.value && c.value.author ? c.value.author : ""
|
||||
const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp
|
||||
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : ""
|
||||
const relDate = ts ? moment(ts).fromNow() : ""
|
||||
const userName = author && author.includes("@") ? author.split("@")[1] : author
|
||||
const rootId = c.value && c.value.content ? c.value.content.fork || c.value.content.root : null
|
||||
const text = c.value && c.value.content && c.value.content.text ? c.value.content.text : ""
|
||||
|
||||
return div(
|
||||
{ class: "votations-comment-card" },
|
||||
span(
|
||||
{ class: "created-at" },
|
||||
span(i18n.createdBy),
|
||||
author ? userLink(author) : span("(unknown)"),
|
||||
absDate ? span(" | ") : "",
|
||||
absDate ? span({ class: "votations-comment-date" }, absDate) : "",
|
||||
relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
|
||||
relDate && rootId ? a({ href: `/thread/${encodeURIComponent(rootId)}#${encodeURIComponent(c.key)}` }, relDate) : ""
|
||||
),
|
||||
p({ class: "votations-comment-text" }, ...renderUrl(text))
|
||||
)
|
||||
})
|
||||
)
|
||||
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
|
||||
})()
|
||||
)
|
||||
}
|
||||
|
||||
const isMyBidItem = (item) => {
|
||||
const polls = Array.isArray(item.auctions_poll) ? item.auctions_poll : []
|
||||
for (const x of polls) {
|
||||
const b = parseBidEntry(x)
|
||||
if (b && b.bidder === userId) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const auctionCountdownParts = (deadline) => {
|
||||
if (!deadline) return null
|
||||
const dl = moment(deadline)
|
||||
if (!dl.isValid()) return null
|
||||
const now = moment()
|
||||
const rel = dl.fromNow()
|
||||
if (dl.isAfter(now)) return { label: i18n.marketAuctionEndsIn, rel }
|
||||
return { label: i18n.marketAuctionEnded, rel }
|
||||
}
|
||||
|
||||
const renderCountdownField = (item) => {
|
||||
const cd = item && (item.item_type === "auction" || item.item_type === "exchange") ? auctionCountdownParts(item.deadline) : null
|
||||
if (!cd) return null
|
||||
return renderCardFieldRich(`${cd.label}:`, [span({ class: "countdown-strong" }, cd.rel)])
|
||||
}
|
||||
|
||||
const normStatus = (s) => String(s || "").toUpperCase().replace(/_/g, " ").replace(/\s+/g, " ").trim()
|
||||
|
||||
const renderMarketOwnerActions = (item, returnTo) => {
|
||||
const polls = Array.isArray(item.auctions_poll) ? item.auctions_poll : []
|
||||
const canUpdate = item.status !== "SOLD" && item.status !== "DISCARDED" && polls.length === 0
|
||||
const cur = normStatus(item.status || "FOR SALE")
|
||||
const canChange = cur !== "SOLD" && cur !== "DISCARDED"
|
||||
const out = []
|
||||
if (canUpdate) {
|
||||
out.push(
|
||||
form(
|
||||
{ method: "GET", action: `/market/edit/${encodeURIComponent(item.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ class: "update-btn", type: "submit" }, i18n.marketActionsUpdate)
|
||||
)
|
||||
)
|
||||
}
|
||||
out.push(
|
||||
form(
|
||||
{ method: "POST", action: `/market/delete/${encodeURIComponent(item.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
button({ class: "delete-btn", type: "submit" }, i18n.marketActionsDelete)
|
||||
)
|
||||
)
|
||||
if (canChange) {
|
||||
out.push(
|
||||
form(
|
||||
{ method: "POST", action: `/market/status/${encodeURIComponent(item.id)}`, class: "project-control-form project-control-form--status" },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
select(
|
||||
{ name: "status", class: "project-control-select" },
|
||||
option({ value: "FOR SALE", selected: cur === "FOR SALE" }, i18n.marketFilterForSale),
|
||||
option({ value: "SOLD", selected: cur === "SOLD" }, i18n.marketFilterSold),
|
||||
option({ value: "DISCARDED", selected: cur === "DISCARDED" }, i18n.marketFilterDiscarded)
|
||||
),
|
||||
button({ class: "status-btn project-control-btn", type: "submit" }, i18n.marketActionsChangeStatus)
|
||||
)
|
||||
)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const renderMarketTopbar = (item, returnTo) => {
|
||||
const left = [renderPmButton(item && item.seller)].filter(Boolean)
|
||||
const right = item && String(item.seller) === String(userId) ? renderMarketOwnerActions(item, returnTo) : []
|
||||
const leftNode = left.length ? div({ class: "bookmark-topbar-left transfer-topbar-left" }, ...left) : null
|
||||
const rightNode = right.length ? div({ class: "bookmark-actions transfer-actions" }, ...right) : null
|
||||
const children = []
|
||||
if (leftNode) children.push(leftNode)
|
||||
if (rightNode) children.push(rightNode)
|
||||
return children.length ? div({ class: "bookmark-topbar transfer-topbar-single" }, ...children) : null
|
||||
}
|
||||
|
||||
exports.marketView = async (items, filter, itemToEdit = null, params = {}) => {
|
||||
const list = Array.isArray(items) ? items : []
|
||||
const q = params.q || ""
|
||||
const minPrice = params.minPrice
|
||||
const maxPrice = params.maxPrice
|
||||
const sort = params.sort || "recent"
|
||||
|
||||
let title = i18n.marketAllSectionTitle
|
||||
switch (filter) {
|
||||
case "mine":
|
||||
title = i18n.marketMineSectionTitle
|
||||
break
|
||||
case "create":
|
||||
title = i18n.marketCreateSectionTitle
|
||||
break
|
||||
case "edit":
|
||||
title = i18n.marketUpdateSectionTitle
|
||||
break
|
||||
case "mybids":
|
||||
title = i18n.marketFilterMyBids
|
||||
break
|
||||
}
|
||||
|
||||
let filtered = []
|
||||
switch (filter) {
|
||||
case "all":
|
||||
filtered = list
|
||||
break
|
||||
case "mine":
|
||||
filtered = list.filter((e) => e.seller === userId)
|
||||
break
|
||||
case "exchange":
|
||||
filtered = list.filter((e) => e.item_type === "exchange" && e.status === "FOR SALE")
|
||||
break
|
||||
case "auctions":
|
||||
filtered = list.filter((e) => e.item_type === "auction" && e.status === "FOR SALE")
|
||||
break
|
||||
case "new":
|
||||
filtered = list.filter((e) => e.item_status === "NEW" && e.status === "FOR SALE")
|
||||
break
|
||||
case "used":
|
||||
filtered = list.filter((e) => e.item_status === "USED" && e.status === "FOR SALE")
|
||||
break
|
||||
case "broken":
|
||||
filtered = list.filter((e) => e.item_status === "BROKEN" && e.status === "FOR SALE")
|
||||
break
|
||||
case "for sale":
|
||||
filtered = list.filter((e) => e.status === "FOR SALE")
|
||||
break
|
||||
case "sold":
|
||||
filtered = list.filter((e) => e.status === "SOLD")
|
||||
break
|
||||
case "discarded":
|
||||
filtered = list.filter((e) => e.status === "DISCARDED")
|
||||
break
|
||||
case "recent": {
|
||||
const oneDayAgo = moment().subtract(1, "days").toISOString()
|
||||
filtered = list.filter((e) => e.status === "FOR SALE" && String(e.createdAt || "") >= oneDayAgo)
|
||||
break
|
||||
}
|
||||
case "mybids":
|
||||
filtered = list.filter((e) => String(e.item_type || "").toLowerCase() === "auction").filter(isMyBidItem)
|
||||
break
|
||||
default:
|
||||
filtered = list
|
||||
break
|
||||
}
|
||||
|
||||
const minP = toNum(minPrice)
|
||||
const maxP = toNum(maxPrice)
|
||||
filtered = filtered.filter((it) => matchesQuery(it, q)).filter((it) => withinPrice(it, minP, maxP))
|
||||
filtered = sortItems(filtered, sort)
|
||||
|
||||
const returnTo = buildReturnTo(filter, q, minPrice, maxPrice, sort)
|
||||
const itemEdit = itemToEdit || {}
|
||||
|
||||
const hiddenCtx = [
|
||||
input({ type: "hidden", name: "q", value: q }),
|
||||
input({ type: "hidden", name: "minPrice", value: minPrice ?? "" }),
|
||||
input({ type: "hidden", name: "maxPrice", value: maxPrice ?? "" }),
|
||||
input({ type: "hidden", name: "sort", value: sort })
|
||||
]
|
||||
|
||||
const isFormMode = filter === "create" || filter === "edit"
|
||||
|
||||
return template(
|
||||
title,
|
||||
section(
|
||||
div({ class: "tags-header" }, h2(i18n.marketTitle), p(i18n.marketDescription)),
|
||||
div(
|
||||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/market", class: "ui-toolbar ui-toolbar--filters" },
|
||||
...hiddenCtx,
|
||||
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterAll),
|
||||
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterMine),
|
||||
button({ type: "submit", name: "filter", value: "exchange", class: filter === "exchange" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterItems),
|
||||
button({ type: "submit", name: "filter", value: "auctions", class: filter === "auctions" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterAuctions),
|
||||
button({ type: "submit", name: "filter", value: "mybids", class: filter === "mybids" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterMyBids),
|
||||
button({ type: "submit", name: "filter", value: "new", class: filter === "new" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterNew),
|
||||
button({ type: "submit", name: "filter", value: "used", class: filter === "used" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterUsed),
|
||||
button({ type: "submit", name: "filter", value: "broken", class: filter === "broken" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterBroken),
|
||||
button({ type: "submit", name: "filter", value: "for sale", class: filter === "for sale" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterForSale),
|
||||
button({ type: "submit", name: "filter", value: "sold", class: filter === "sold" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterSold),
|
||||
button({ type: "submit", name: "filter", value: "discarded", class: filter === "discarded" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterDiscarded),
|
||||
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterRecent),
|
||||
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.marketCreateButton)
|
||||
)
|
||||
),
|
||||
!isFormMode
|
||||
? div(
|
||||
{ class: "market-search" },
|
||||
form(
|
||||
{ method: "GET", action: "/market", class: "filter-box" },
|
||||
input({ type: "hidden", name: "filter", value: filter || "all" }),
|
||||
input({ type: "text", name: "q", value: q, placeholder: i18n.marketSearchPlaceholder, class: "filter-box__input" }),
|
||||
div(
|
||||
{ class: "filter-box__controls" },
|
||||
div(
|
||||
{ class: "transfer-range" },
|
||||
input({
|
||||
type: "number",
|
||||
name: "minPrice",
|
||||
step: "0.000001",
|
||||
min: "0",
|
||||
value: String(minPrice ?? ""),
|
||||
placeholder: i18n.marketMinPriceLabel,
|
||||
class: "filter-box__number transfer-amount-input"
|
||||
}),
|
||||
input({
|
||||
type: "number",
|
||||
name: "maxPrice",
|
||||
step: "0.000001",
|
||||
min: "0",
|
||||
value: String(maxPrice ?? ""),
|
||||
placeholder: i18n.marketMaxPriceLabel,
|
||||
class: "filter-box__number transfer-amount-input"
|
||||
})
|
||||
),
|
||||
select(
|
||||
{ name: "sort", class: "filter-box__select" },
|
||||
option({ value: "recent", selected: sort === "recent" }, i18n.marketSortRecent),
|
||||
option({ value: "price", selected: sort === "price" }, i18n.marketSortPrice),
|
||||
option({ value: "deadline", selected: sort === "deadline" }, i18n.marketSortDeadline)
|
||||
),
|
||||
button({ type: "submit", class: "filter-box__button" }, i18n.marketSearchButton)
|
||||
)
|
||||
)
|
||||
)
|
||||
: null
|
||||
),
|
||||
section(
|
||||
isFormMode
|
||||
? div(
|
||||
{ class: "market-form" },
|
||||
form(
|
||||
{ action: filter === "edit" ? `/market/update/${encodeURIComponent(itemEdit.id)}` : "/market/create", method: "POST", enctype: "multipart/form-data" },
|
||||
input({ type: "hidden", name: "returnTo", value: "/market?filter=mine" }),
|
||||
label(i18n.marketItemType),
|
||||
br(),
|
||||
select(
|
||||
{ name: "item_type", id: "item_type", required: true },
|
||||
option({ value: "auction", selected: itemEdit && itemEdit.item_type === "auction" }, "Auction"),
|
||||
option({ value: "exchange", selected: itemEdit && itemEdit.item_type === "exchange" }, "Exchange")
|
||||
),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.marketItemTitle),
|
||||
br(),
|
||||
input({ type: "text", name: "title", id: "title", value: (itemEdit && itemEdit.title) || "", required: true }),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.marketItemDescription),
|
||||
br(),
|
||||
textarea({ name: "description", id: "description", placeholder: i18n.marketItemDescriptionPlaceholder, rows: "6", required: true }, (itemEdit && itemEdit.description) || ""),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.marketCreateFormImageLabel),
|
||||
br(),
|
||||
input({ type: "file", name: "image", id: "image" }),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.marketItemStatus),
|
||||
br(),
|
||||
select(
|
||||
{ name: "item_status", id: "item_status" },
|
||||
option({ value: "BROKEN", selected: itemEdit && itemEdit.item_status === "BROKEN" }, "BROKEN"),
|
||||
option({ value: "USED", selected: itemEdit && itemEdit.item_status === "USED" }, "USED"),
|
||||
option({ value: "NEW", selected: itemEdit && itemEdit.item_status === "NEW" }, "NEW")
|
||||
),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.marketItemStock),
|
||||
br(),
|
||||
input({ type: "number", name: "stock", id: "stock", value: (itemEdit && itemEdit.stock) || 1, required: true, min: "1", step: "1" }),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.mapLocationTitle || "Map Location"),
|
||||
br(),
|
||||
input({ type: "text", name: "mapUrl", placeholder: i18n.mapUrlPlaceholder || "/maps/MAP_ID", value: itemEdit?.mapUrl || "" }),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.visibilityLabel || "Visibility"),
|
||||
br(),
|
||||
select(
|
||||
{ name: "visibility" },
|
||||
option({ value: "PUBLIC", selected: (itemEdit?.visibility || "PUBLIC") === "PUBLIC" }, i18n.visibilityPublic || "Public"),
|
||||
option({ value: "HIDDEN", selected: itemEdit?.visibility === "HIDDEN" }, i18n.visibilityHidden || "Hidden")
|
||||
),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.marketItemPrice),
|
||||
br(),
|
||||
input({ type: "number", name: "price", id: "price", value: (itemEdit && itemEdit.price) || "", required: true, step: "0.000001", min: "0.000001" }),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.marketItemTags),
|
||||
br(),
|
||||
input({ type: "text", name: "tags", id: "tags", placeholder: i18n.marketItemTagsPlaceholder, value: (itemEdit && itemEdit.tags && itemEdit.tags.join(", ")) || "" }),
|
||||
br(),
|
||||
br(),
|
||||
label(i18n.marketItemDeadline),
|
||||
br(),
|
||||
input({
|
||||
type: "datetime-local",
|
||||
name: "deadline",
|
||||
id: "deadline",
|
||||
required: true,
|
||||
min: moment().format("YYYY-MM-DDTHH:mm"),
|
||||
value: itemEdit && itemEdit.deadline ? moment(itemEdit.deadline).format("YYYY-MM-DDTHH:mm") : ""
|
||||
}),
|
||||
br(),
|
||||
br(),
|
||||
input({ type: "hidden", name: "includesShipping", value: "0" }),
|
||||
label(i18n.marketItemIncludesShipping),
|
||||
br(),
|
||||
input({
|
||||
id: "includesShipping-checkbox",
|
||||
type: "checkbox",
|
||||
name: "includesShipping",
|
||||
value: "1",
|
||||
class: "meme-checkbox",
|
||||
...(itemEdit && itemEdit.includesShipping ? { checked: true } : {})
|
||||
}),
|
||||
br(),br(),
|
||||
button({ type: "submit" }, filter === "edit" ? i18n.marketUpdateButton : i18n.marketCreateButton)
|
||||
)
|
||||
)
|
||||
: div(
|
||||
{ class: "market-grid" },
|
||||
filtered.length > 0
|
||||
? filtered.map((item) => {
|
||||
const polls = Array.isArray(item.auctions_poll) ? item.auctions_poll : []
|
||||
const parsedBids = polls.map(parseBidEntry).filter(Boolean).sort((a, b) => new Date(b.time) - new Date(a.time))
|
||||
const myBid = item.item_type === "auction" ? parsedBids.some((b) => b.bidder === userId) : false
|
||||
const maxStock = item.initialStock || item.stockMax || item.stock || 1
|
||||
const stockLeft = Number(item.stock || 0)
|
||||
const isOwner = String(item.seller) === String(userId)
|
||||
|
||||
const actionNodesRaw = isOwner
|
||||
? renderMarketOwnerActions(item, "/market?filter=mine")
|
||||
: [
|
||||
item.status !== "SOLD" && item.status !== "DISCARDED" && item.item_type === "auction"
|
||||
? form(
|
||||
{ method: "POST", action: `/market/bid/${encodeURIComponent(item.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
input({ type: "number", name: "bidAmount", step: "0.000001", min: "0.000001", placeholder: i18n.marketYourBid, required: true }),
|
||||
br(),
|
||||
button({ class: "buy-btn", type: "submit" }, i18n.marketPlaceBidButton)
|
||||
)
|
||||
: null,
|
||||
item.status === "FOR SALE" && item.item_type !== "auction" && !isOwner && stockLeft > 0
|
||||
? form(
|
||||
{ method: "POST", action: `/market/buy/${encodeURIComponent(item.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: "/inbox?filter=sent" }),
|
||||
input({ type: "hidden", name: "buyerId", value: userId }),
|
||||
button({ class: "buy-btn", type: "submit" }, i18n.marketActionsBuy)
|
||||
)
|
||||
: null
|
||||
].filter(Boolean)
|
||||
|
||||
return div({ class: "tribe-card market-tribe-card" },
|
||||
div({ class: "tribe-card-image-wrapper" },
|
||||
a({ href: `/market/${encodeURIComponent(item.id)}` },
|
||||
renderMediaBlob(item.image, '/assets/images/default-market.png', { class: 'tribe-card-hero-image' })
|
||||
)
|
||||
),
|
||||
div({ class: "tribe-card-body" },
|
||||
div({ class: "shop-title-row" },
|
||||
h2({ class: "tribe-card-title" }, a({ href: `/market/${encodeURIComponent(item.id)}` }, item.title))
|
||||
),
|
||||
div({ class: "card-chips-row" },
|
||||
renderStateChip("encrypted", "", String(item.item_type || "").toUpperCase()),
|
||||
item.item_status ? renderStateChip("whole", "", String(item.item_status).toUpperCase()) : null,
|
||||
String(item.visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? renderVisibilityChip("HIDDEN", i18n) : null,
|
||||
item.includesShipping ? renderStateChip("mutuals", "📦", String(i18n.marketItemIncludesShipping || "Shipping").replace(/\?$/, "").toUpperCase()) : null,
|
||||
renderLifespanChip(item.lifetime, i18n)
|
||||
),
|
||||
div({ class: "market-card-price card-date-highlight" }, `${item.price} ECO`),
|
||||
div({ class: "card-visit-btn-centered" },
|
||||
form({ method: 'GET', action: `/market/${encodeURIComponent(item.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
input({ type: "hidden", name: "filter", value: filter || "all" }),
|
||||
input({ type: "hidden", name: "q", value: q }),
|
||||
input({ type: "hidden", name: "minPrice", value: String(minPrice ?? "") }),
|
||||
input({ type: "hidden", name: "maxPrice", value: String(maxPrice ?? "") }),
|
||||
input({ type: "hidden", name: "sort", value: sort }),
|
||||
button({ type: 'submit', class: 'filter-btn' }, i18n.viewItem || i18n.marketVisitItem || 'View Item')
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
})
|
||||
: p(i18n.marketNoItemsMatch || i18n.marketNoItems)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
exports.singleMarketView = async (item, filter, comments = [], params = {}) => {
|
||||
const polls = Array.isArray(item.auctions_poll) ? item.auctions_poll : []
|
||||
const parsedBids = polls.map(parseBidEntry).filter(Boolean).sort((a, b) => new Date(b.time) - new Date(a.time))
|
||||
const q = params.q || ""
|
||||
const minPrice = params.minPrice
|
||||
const maxPrice = params.maxPrice
|
||||
const sort = params.sort || "recent"
|
||||
const returnTo = params.returnTo || buildReturnTo(filter, q, minPrice, maxPrice, sort)
|
||||
const topbar = renderMarketTopbar(item, returnTo)
|
||||
const stockLeft = Number(item.stock || 0)
|
||||
const showBuy = item.status === "FOR SALE" && item.item_type !== "auction" && String(item.seller) !== String(userId) && stockLeft > 0
|
||||
const maxStock = item.initialStock || item.stockMax || item.stock || 1
|
||||
|
||||
return template(
|
||||
item.title,
|
||||
section(
|
||||
div(
|
||||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/market", class: "ui-toolbar ui-toolbar--filters" },
|
||||
input({ type: "hidden", name: "q", value: q }),
|
||||
input({ type: "hidden", name: "minPrice", value: minPrice ?? "" }),
|
||||
input({ type: "hidden", name: "maxPrice", value: maxPrice ?? "" }),
|
||||
input({ type: "hidden", name: "sort", value: sort }),
|
||||
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterAll),
|
||||
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterMine),
|
||||
button({ type: "submit", name: "filter", value: "exchange", class: filter === "exchange" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterItems),
|
||||
button({ type: "submit", name: "filter", value: "auctions", class: filter === "auctions" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterAuctions),
|
||||
button({ type: "submit", name: "filter", value: "mybids", class: filter === "mybids" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterMyBids),
|
||||
button({ type: "submit", name: "filter", value: "new", class: filter === "new" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterNew),
|
||||
button({ type: "submit", name: "filter", value: "used", class: filter === "used" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterUsed),
|
||||
button({ type: "submit", name: "filter", value: "broken", class: filter === "broken" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterBroken),
|
||||
button({ type: "submit", name: "filter", value: "for sale", class: filter === "for sale" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterForSale),
|
||||
button({ type: "submit", name: "filter", value: "sold", class: filter === "sold" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterSold),
|
||||
button({ type: "submit", name: "filter", value: "discarded", class: filter === "discarded" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterDiscarded),
|
||||
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterRecent),
|
||||
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.marketCreateButton)
|
||||
)
|
||||
),
|
||||
(() => {
|
||||
const isHidden = String(item.visibility || 'PUBLIC').toUpperCase() === 'HIDDEN'
|
||||
const chips = [
|
||||
renderStateChip("encrypted", "", String(item.item_type || "").toUpperCase()),
|
||||
item.item_status ? renderStateChip("whole", "", String(item.item_status).toUpperCase()) : null,
|
||||
item.includesShipping ? renderStateChip("mutuals", "📦", String(i18n.marketItemIncludesShipping || "Shipping").replace(/\?$/, "").toUpperCase()) : null,
|
||||
isHidden ? renderVisibilityChip("HIDDEN", i18n) : null,
|
||||
renderLifespanChip(item.lifetime, i18n),
|
||||
renderEcoTax(item.msgSize, item.id)
|
||||
].filter(Boolean)
|
||||
|
||||
const tagsNode = item.tags && item.tags.length
|
||||
? div({ class: "card-tags" }, item.tags.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)))
|
||||
: null
|
||||
|
||||
const infoRows = []
|
||||
const pushRow = (labelText, valueNode) =>
|
||||
infoRows.push(tr(
|
||||
td({ class: "tribe-info-label" }, labelText),
|
||||
td({ class: "tribe-info-value" }, valueNode)
|
||||
))
|
||||
pushRow(i18n.marketItemSeller, userLink(item.seller))
|
||||
if (item.shopId && item.shopTitle) pushRow(i18n.marketShopLabel || "Shop", a({ href: `/shops/${encodeURIComponent(item.shopId)}`, class: "user-link" }, item.shopTitle))
|
||||
if (item.deadline) pushRow(i18n.marketItemAvailable, moment(item.deadline).format("YYYY/MM/DD HH:mm:ss"))
|
||||
|
||||
const itemSide = div({ class: "tribe-side" },
|
||||
div({ class: "shop-title-row" },
|
||||
h2({ class: "tribe-card-title" }, item.title)
|
||||
),
|
||||
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
|
||||
div({ class: "card-spread-centered" }, renderSpreadButton(item.id, params.spreads)),
|
||||
renderMediaBlob(item.image, "/assets/images/default-market.png"),
|
||||
div({ class: "card-date-highlight" }, `${item.price} ECO`),
|
||||
renderStockBar(item.stock, maxStock),
|
||||
table({ class: "tribe-info-table jobs-info-table" }, ...infoRows),
|
||||
tagsNode
|
||||
)
|
||||
|
||||
const visibilityRow = String(item.seller) === String(userId)
|
||||
? (() => {
|
||||
const vis = isHidden ? 'HIDDEN' : 'PUBLIC'
|
||||
const next = vis === 'PUBLIC' ? 'HIDDEN' : 'PUBLIC'
|
||||
return div({ class: "tribe-side-actions" },
|
||||
span({ class: "card-label" }, `${i18n.visibilityLabel || 'Visibility'}: `),
|
||||
renderVisibilityChip(vis, i18n),
|
||||
form({ method: "POST", action: `/market/visibility/${encodeURIComponent(item.id)}`, class: "inline-form" },
|
||||
input({ type: "hidden", name: "visibility", value: next }),
|
||||
button({ type: "submit", class: "filter-btn" },
|
||||
next === 'PUBLIC' ? (i18n.visibilityMakePublic || 'Make public') : (i18n.visibilityMakeHidden || 'Make hidden')
|
||||
)
|
||||
)
|
||||
)
|
||||
})()
|
||||
: null
|
||||
|
||||
const marketActions = []
|
||||
if (item.seller && String(item.seller) !== String(userId)) {
|
||||
marketActions.push(form({ method: "GET", action: "/pm" },
|
||||
input({ type: "hidden", name: "recipients", value: item.seller }),
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.privateMessage)
|
||||
))
|
||||
}
|
||||
if (String(item.seller) === String(userId)) {
|
||||
marketActions.push(form({ method: "GET", action: `/market/edit/${encodeURIComponent(item.id)}` },
|
||||
button({ type: "submit", class: "update-btn" }, i18n.marketUpdateButton || "Update")
|
||||
))
|
||||
marketActions.push(form({ method: "POST", action: `/market/delete/${encodeURIComponent(item.id)}` },
|
||||
button({ type: "submit", class: "delete-btn" }, i18n.marketDeleteButton || "Delete")
|
||||
))
|
||||
}
|
||||
const itemMain = div({ class: "tribe-main" },
|
||||
marketActions.length ? div({ class: "tribe-side-actions" }, ...marketActions) : null,
|
||||
visibilityRow,
|
||||
item.description
|
||||
? div({ class: "job-section" },
|
||||
h2({ class: "job-section-title" }, i18n.marketItemDescription),
|
||||
p({ class: "tribe-side-description" }, ...renderUrl(item.description))
|
||||
)
|
||||
: null,
|
||||
renderCountdownField(item),
|
||||
item.mapUrl ? div({ class: "job-section" }, renderMapEmbedWithZoom(params.mapData, item.mapUrl, `/market/${encodeURIComponent(item.id)}`, params.zoom)) : null,
|
||||
item.item_type === "auction"
|
||||
? div(
|
||||
{ class: "auction-info job-section" },
|
||||
h2({ class: "job-section-title" }, i18n.marketAuctionBids),
|
||||
parsedBids.length
|
||||
? table(
|
||||
{ class: "auction-bid-table" },
|
||||
tr(th(i18n.marketAuctionBidTime), th(i18n.marketAuctionUser), th(i18n.marketAuctionBidAmount)),
|
||||
parsedBids.map((bid) =>
|
||||
tr(td(moment(bid.time).format("YYYY-MM-DD HH:mm:ss")), td(a({ href: `/author/${encodeURIComponent(bid.bidder)}` }, bid.bidder)), td(`${parseFloat(bid.amount).toFixed(6)} ECO`))
|
||||
)
|
||||
)
|
||||
: p({ class: "tribe-side-description" }, i18n.marketNoBids || "No bids yet"),
|
||||
item.status !== "SOLD" && item.status !== "DISCARDED" && String(item.seller) !== String(userId)
|
||||
? form(
|
||||
{ method: "POST", action: `/market/bid/${encodeURIComponent(item.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
||||
input({ type: "number", name: "bidAmount", step: "0.000001", min: "0.000001", placeholder: i18n.marketYourBid, required: true }),
|
||||
br(),
|
||||
button({ class: "buy-btn", type: "submit" }, i18n.marketPlaceBidButton)
|
||||
)
|
||||
: null
|
||||
)
|
||||
: null,
|
||||
showBuy
|
||||
? div(
|
||||
{ class: "market-item actions" },
|
||||
form(
|
||||
{ method: "POST", action: `/market/buy/${encodeURIComponent(item.id)}` },
|
||||
input({ type: "hidden", name: "returnTo", value: "/inbox?filter=sent" }),
|
||||
button({ class: "buy-btn", type: "submit" }, i18n.marketActionsBuy)
|
||||
)
|
||||
)
|
||||
: null,
|
||||
renderMarketCommentsSection(item.id, returnTo, comments)
|
||||
)
|
||||
|
||||
return div({ class: "tribe-details" }, itemSide, itemMain)
|
||||
})()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue