upstream: integrar Oasis 1.3.1

Base del merge a tres bandas: git 0.9.5 de epsylon, esta rama y el src de la
APK oficial 1.3.1 (la variante movil: OASIS_MOBILE, regex sin \p{}, audio y
video de Android). Corre en el mismo nodejs-mobile 18.20.4 que ya usabamos.

Se conserva de la rama: Karvan, identidades y multicuenta, topbar y barra
inferior, borrado de EXIF sin sharp, filtro hot del foro, RTC sin STUN y los
modulos apagados en movil (wallet, banking, torrents).

Se toma de upstream lo que ya hace de serie: respuesta citada en chats, ssb-lan
en movil, configuracion en ~/.ssb/oasis (sobrevive a reinstalar el backend).
El asistente de bienvenida deja elegir workflow tambien en movil.
This commit is contained in:
oasis-bot 2026-10-18 02:52:17 +02:00
parent 8d838dd8b1
commit c332afd6ea
265 changed files with 80970 additions and 29167 deletions

View file

@ -1,27 +1,31 @@
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 { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option, img, table, tr, th, td, progress, video, audio, hr } = require("../server/node_modules/hyperaxe")
const { renderZoomableImage } = require("./gallery_view")
const { renderCommentsSection: renderSharedCommentsSection } = require("./comments_view");
const { template, i18n, userLink, renderStateChip, renderVisibilityChip, renderLifespanChip, renderEcoTax, renderSpreadButton, renderSpreadEditWarning, renderOpinionsVoting, renderEngagement , renderContentActions } = require("./main_views")
const { clearnetItemHref, template, i18n, userLink, renderStateChip, renderVisibilityChip, renderLifespanChip, renderEcoTax, renderSpreadButton, renderSpreadEditWarning, renderOpinionsVoting, renderEngagement , renderContentActions, renderModuleStatsBy, moduleIsEmpty, renderEcoValueChip, renderWalletChip, contentDeleteAction, paged } = require("./main_views")
const { renderReachChip, renderClearnetSelector, renderClearnetSwitch } = require("./clearnet_view")
const opinionCategories = require("../backend/opinion_categories")
const moment = require("../server/node_modules/moment")
const { config } = require("../server/SSB_server.js")
const { renderUrl } = require("../backend/renderUrl")
const { renderStyledText } = require("../backend/renderStyledText")
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 })
if (s.startsWith('&')) return renderZoomableImage(`/blob/${encodeURIComponent(s)}`, { imgClass: attrs.class || 'post-image', alt: attrs.alt || '' })
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])}` })
if (mVideo) return video({ controls: true, class: attrs.class || 'post-video', src: `/blob/${encodeURIComponent(mVideo[1])}`, preload: 'metadata' })
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' })
if (mImg) return renderZoomableImage(`/blob/${encodeURIComponent(mImg[1])}`, { imgClass: attrs.class || 'post-image', alt: attrs.alt || '' })
return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null
}
const userId = config.keys.id
const MARKET_REACH = ["PUBLIC", "HIDDEN"]
const marketReachLabel = (v) => v === "HIDDEN" ? (i18n.visibilityHidden || "Hidden") : (i18n.visibilityPublic || "Public")
const parseBidEntry = (raw) => {
const s = String(raw || "").trim()
@ -109,16 +113,11 @@ const renderStarRating = (opinions, voterCount) => {
}
const renderCardField = (labelText, value = "") =>
div({ class: "card-field" }, span({ class: "card-label" }, labelText), span({ class: "card-value" }, ...renderUrl(String(value))))
div({ class: "card-field" }, span({ class: "card-label" }, labelText), span({ class: "card-value" }, ...renderStyledText(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))
@ -163,7 +162,7 @@ const auctionCountdownParts = (deadline) => {
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)])
return renderCardFieldRich(`${cd.label}:`, [span({ class: "time-chip" }, cd.rel)])
}
const normStatus = (s) => String(s || "").toUpperCase().replace(/_/g, " ").replace(/\s+/g, " ").trim()
@ -183,13 +182,6 @@ const renderMarketOwnerActions = (item, returnTo) => {
)
)
}
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(
@ -208,16 +200,22 @@ const renderMarketOwnerActions = (item, returnTo) => {
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
}
const marketChipFor = (filter, list) => (mode) => {
if (mode === filter) return true;
if (!Array.isArray(list)) return true;
if (mode === "recent") return list.length > 0;
if (mode === "mine") return list.some((e) => String(e.seller) === String(userId));
if (mode === "exchange") return list.some((e) => e.item_type === "exchange" && e.status === "FOR SALE");
if (mode === "auctions") return list.some((e) => e.item_type === "auction" && e.status === "FOR SALE");
if (mode === "mybids") return list.some(isMyBidItem);
if (mode === "new") return list.some((e) => e.item_status === "NEW" && e.status === "FOR SALE");
if (mode === "used") return list.some((e) => e.item_status === "USED" && e.status === "FOR SALE");
if (mode === "broken") return list.some((e) => e.item_status === "BROKEN" && e.status === "FOR SALE");
if (mode === "for sale") return list.some((e) => normStatus(e.status || "FOR SALE") === "FOR SALE");
if (mode === "sold") return list.some((e) => normStatus(e.status || "") === "SOLD");
if (mode === "discarded") return list.some((e) => normStatus(e.status || "") === "DISCARDED");
return true;
};
exports.marketView = async (items, filter, itemToEdit = null, params = {}) => {
const list = Array.isArray(items) ? items : []
@ -278,6 +276,8 @@ exports.marketView = async (items, filter, itemToEdit = null, params = {}) => {
filtered = filtered.filter((it) => matchesQuery(it, q)).filter((it) => withinPrice(it, minP, maxP))
filtered = sortItems(filtered, sort)
const emptyMod = moduleIsEmpty(list, filter || "all", "all", q || minPrice || maxPrice);
const marketChipVisible = marketChipFor(filter, list);
const returnTo = buildReturnTo(filter, q, minPrice, maxPrice, sort)
const itemEdit = itemToEdit || {}
@ -290,43 +290,46 @@ exports.marketView = async (items, filter, itemToEdit = null, params = {}) => {
const isFormMode = filter === "create" || filter === "edit"
const spreadWarning = filter === "edit" ? await renderSpreadEditWarning(itemEdit && (itemEdit.id || itemEdit.key)) : null
const reachRaw = String(params.reach || "").toUpperCase()
const ownReach = String(itemEdit.visibility || "").toUpperCase()
const reach = MARKET_REACH.includes(reachRaw) ? reachRaw : (MARKET_REACH.includes(ownReach) ? ownReach : "PUBLIC")
const prefillKeys = ["industry", "title", "description", "price", "tags", "stock"]
return template(
title,
section(
div({ class: "tags-header" }, h2(i18n.marketTitle), p(i18n.marketDescription)),
div({ class: "tags-header module-header-line" }, h2(i18n.marketTitle), p(i18n.marketDescription), renderWalletChip()),
div(
{ class: "filters" },
form(
{ method: "GET", action: "/market", class: "ui-toolbar ui-toolbar--filters" },
...hiddenCtx,
...(emptyMod ? [] : [
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterAll).toUpperCase()),
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterMine).toUpperCase()),
button({ type: "submit", name: "filter", value: "exchange", class: filter === "exchange" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterItems).toUpperCase()),
button({ type: "submit", name: "filter", value: "auctions", class: filter === "auctions" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterAuctions).toUpperCase()),
button({ type: "submit", name: "filter", value: "mybids", class: filter === "mybids" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterMyBids).toUpperCase()),
button({ type: "submit", name: "filter", value: "new", class: filter === "new" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterNew).toUpperCase()),
button({ type: "submit", name: "filter", value: "used", class: filter === "used" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterUsed).toUpperCase()),
button({ type: "submit", name: "filter", value: "broken", class: filter === "broken" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterBroken).toUpperCase()),
button({ type: "submit", name: "filter", value: "for sale", class: filter === "for sale" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterForSale).toUpperCase()),
button({ type: "submit", name: "filter", value: "sold", class: filter === "sold" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterSold).toUpperCase()),
button({ type: "submit", name: "filter", value: "discarded", class: filter === "discarded" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterDiscarded).toUpperCase()),
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterRecent).toUpperCase()),
...(marketChipVisible("recent") ? [button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterRecent).toUpperCase())] : []),
...(marketChipVisible("mine") ? [button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterMine).toUpperCase())] : []),
...(marketChipVisible("exchange") ? [button({ type: "submit", name: "filter", value: "exchange", class: filter === "exchange" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterItems).toUpperCase())] : []),
...(marketChipVisible("auctions") ? [button({ type: "submit", name: "filter", value: "auctions", class: filter === "auctions" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterAuctions).toUpperCase())] : []),
...(marketChipVisible("mybids") ? [button({ type: "submit", name: "filter", value: "mybids", class: filter === "mybids" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterMyBids).toUpperCase())] : []),
...(marketChipVisible("new") ? [button({ type: "submit", name: "filter", value: "new", class: filter === "new" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterNew).toUpperCase())] : []),
...(marketChipVisible("used") ? [button({ type: "submit", name: "filter", value: "used", class: filter === "used" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterUsed).toUpperCase())] : []),
...(marketChipVisible("broken") ? [button({ type: "submit", name: "filter", value: "broken", class: filter === "broken" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterBroken).toUpperCase())] : []),
...(marketChipVisible("for sale") ? [button({ type: "submit", name: "filter", value: "for sale", class: filter === "for sale" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterForSale).toUpperCase())] : []),
...(marketChipVisible("sold") ? [button({ type: "submit", name: "filter", value: "sold", class: filter === "sold" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterSold).toUpperCase())] : []),
...(marketChipVisible("discarded") ? [button({ type: "submit", name: "filter", value: "discarded", class: filter === "discarded" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterDiscarded).toUpperCase())] : []),
]),
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.marketCreateButton)
)
),
!isFormMode
!isFormMode && !emptyMod
? div(
{ class: "market-search" },
{ class: "market-search activity-filter-chips activity-toolbar-row" },
renderModuleStatsBy(filtered, e => normStatus(e.status || 'FOR SALE'), [{ value: 'FOR SALE', label: i18n.marketFilterForSale }, { value: 'SOLD', label: i18n.marketFilterSold }, { value: 'DISCARDED', label: i18n.marketFilterDiscarded }]),
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({
input({
type: "number",
name: "minPrice",
step: "0.000001",
@ -343,8 +346,8 @@ exports.marketView = async (items, filter, itemToEdit = null, params = {}) => {
value: String(maxPrice ?? ""),
placeholder: i18n.marketMaxPriceLabel,
class: "filter-box__number transfer-amount-input"
})
),
}),
select(
{ name: "sort", class: "filter-box__select" },
option({ value: "recent", ...(sort === "recent" ? { selected: true } : {})}, i18n.marketSortRecent),
@ -352,7 +355,7 @@ exports.marketView = async (items, filter, itemToEdit = null, params = {}) => {
option({ value: "deadline", ...(sort === "deadline" ? { selected: true } : {})}, i18n.marketSortDeadline)
),
button({ type: "submit", class: "filter-box__button" }, i18n.marketSearchButton)
)
)
)
: null
@ -362,9 +365,27 @@ exports.marketView = async (items, filter, itemToEdit = null, params = {}) => {
? div(
{ class: "market-form" },
spreadWarning,
form(
{ method: "GET", action: filter === "edit" ? `/market/edit/${encodeURIComponent(itemEdit.id)}` : "/market" },
filter === "edit" ? null : input({ type: "hidden", name: "filter", value: "create" }),
...(filter === "edit" ? [] : prefillKeys.filter((k) => params[k]).map((k) => input({ type: "hidden", name: k, value: params[k] }))),
label(i18n.visibilityLabel || "Visibility"),
br(),
div({ class: "apply-row" },
select(
{ name: "visibility", class: "report-category-select" },
option({ value: "PUBLIC", ...(reach === "PUBLIC" ? { selected: true } : {})}, i18n.visibilityPublic || "Public"),
option({ value: "HIDDEN", ...(reach === "HIDDEN" ? { selected: true } : {})}, i18n.visibilityHidden || "Hidden")
),
button({ type: "submit", class: "create-button" }, i18n.apply || "Apply")
)
),
hr({ class: "form-sep" }),
h2({ class: "report-category-fixed" }, marketReachLabel(reach)),
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" }),
input({ type: "hidden", name: "visibility", value: reach }),
((itemEdit && itemEdit.industry) || params.industry) ? input({ type: "hidden", name: "industry", value: (itemEdit && itemEdit.industry) || params.industry }) : null,
label(i18n.marketItemType),
br(),
@ -382,7 +403,7 @@ exports.marketView = async (items, filter, itemToEdit = null, params = {}) => {
br(),
label(i18n.marketItemDescription),
br(),
textarea({ name: "description", id: "description", placeholder: i18n.marketItemDescriptionPlaceholder, rows: "6", required: true }, (itemEdit && itemEdit.description) || params.description || ""),
textarea({ maxlength: "5000", name: "description", id: "description", placeholder: i18n.marketItemDescriptionPlaceholder, rows: "6", required: true }, (itemEdit && itemEdit.description) || params.description || ""),
br(),
br(),
label(i18n.marketCreateFormImageLabel),
@ -410,18 +431,11 @@ exports.marketView = async (items, filter, itemToEdit = null, params = {}) => {
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", ...((itemEdit?.visibility || "PUBLIC") === "PUBLIC" ? { selected: true } : {})}, i18n.visibilityPublic || "Public"),
option({ value: "HIDDEN", ...(itemEdit?.visibility === "HIDDEN" ? { selected: true } : {})}, i18n.visibilityHidden || "Hidden")
),
br(),
br(),
...(reach === "PUBLIC" ? [renderClearnetSelector(itemEdit.clearnet === true || String(itemEdit.clearnet || "") === "1", i18n), br()] : []),
label(i18n.marketItemPrice),
br(),
input({ type: "number", name: "price", id: "price", value: (itemEdit && itemEdit.price) || params.price || "", required: true, step: "0.000001", min: "0.000001" }),
renderEcoValueChip(),
br(),
br(),
label(i18n.marketItemTags),
@ -450,7 +464,7 @@ exports.marketView = async (items, filter, itemToEdit = null, params = {}) => {
name: "includesShipping",
value: "1",
class: "meme-checkbox",
...(itemEdit && itemEdit.includesShipping ? { checked: true } : {})
...(itemEdit && [true, "1", "on", "true"].includes([].concat(itemEdit.includesShipping).pop()) ? { checked: true } : {})
}),
br(),br(),
button({ type: "submit" }, filter === "edit" ? i18n.marketUpdateButton : i18n.marketCreateButton)
@ -459,36 +473,9 @@ exports.marketView = async (items, filter, itemToEdit = null, params = {}) => {
: 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)
? paged(filtered).map((item) => {
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)}` },
@ -497,7 +484,7 @@ exports.marketView = async (items, filter, itemToEdit = null, params = {}) => {
),
div({ class: "card-header activity-card-header" },
span(),
renderContentActions(item.id, `/market/${encodeURIComponent(item.id)}`, { spread: params.spreads || null, author: item.seller, favKind: 'market', isFavorite: item.isFavorite, reportTitle: item.title })
renderContentActions(item.id, `/market/${encodeURIComponent(item.id)}`, { spread: (params.spreadMap && params.spreadMap.get(item.id)) || null, author: item.seller, favKind: 'market', isFavorite: item.isFavorite, reportTitle: item.title, returnTo, deleteAction: isOwner ? contentDeleteAction('market', item.id) : undefined })
),
div({ class: "tribe-card-body" },
div({ class: "shop-title-row" },
@ -505,24 +492,26 @@ exports.marketView = async (items, filter, itemToEdit = null, params = {}) => {
),
renderStarRating(item.opinions, Array.isArray(item.opinions_inhabitants) ? item.opinions_inhabitants.length : 0),
div({ class: "card-chips-row" },
String(item.visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? renderVisibilityChip("HIDDEN", i18n) : null,
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,
item.industry ? a({ href: `/industry/${encodeURIComponent(item.industry)}` }, renderStateChip("whole", "🏭", String(i18n.industryTitle || "Industry").toUpperCase())) : null,
renderLifespanChip(item.lifetime, i18n)
renderLifespanChip(item.lifetime, i18n),
item.clearnet === true ? renderReachChip(true, i18n, clearnetItemHref("market", item.title, item.id)) : null
),
div({ class: "market-card-price card-date-highlight" }, `${item.price} ECO`)
div({ class: "price-chip" }, `${item.price} ECO`)
)
)
})
: p(i18n.marketNoItemsMatch || i18n.marketNoItems)
: p(q || String(minPrice ?? "") || String(maxPrice ?? "") ? (i18n.marketNoItemsMatch || i18n.marketNoItems) : (i18n.marketNoItems || i18n.marketNoItemsMatch))
)
)
)
}
exports.singleMarketView = async (item, filter, comments = [], params = {}) => {
const marketChipVisible = (mode) => marketChipFor(filter, Array.isArray(params.censusList) ? params.censusList : null)(mode);
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 || ""
@ -530,14 +519,13 @@ exports.singleMarketView = async (item, filter, comments = [], params = {}) => {
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: "tags-header" }, h2(i18n.marketTitle), p(i18n.marketDescription))),
section(div({ class: "tags-header module-header-line" }, h2(i18n.marketTitle), p(i18n.marketDescription), renderWalletChip())),
section(
div(
{ class: "filters" },
@ -548,27 +536,29 @@ exports.singleMarketView = async (item, filter, comments = [], params = {}) => {
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" }, String(i18n.marketFilterAll).toUpperCase()),
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterMine).toUpperCase()),
button({ type: "submit", name: "filter", value: "exchange", class: filter === "exchange" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterItems).toUpperCase()),
button({ type: "submit", name: "filter", value: "auctions", class: filter === "auctions" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterAuctions).toUpperCase()),
button({ type: "submit", name: "filter", value: "mybids", class: filter === "mybids" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterMyBids).toUpperCase()),
button({ type: "submit", name: "filter", value: "new", class: filter === "new" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterNew).toUpperCase()),
button({ type: "submit", name: "filter", value: "used", class: filter === "used" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterUsed).toUpperCase()),
button({ type: "submit", name: "filter", value: "broken", class: filter === "broken" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterBroken).toUpperCase()),
button({ type: "submit", name: "filter", value: "for sale", class: filter === "for sale" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterForSale).toUpperCase()),
button({ type: "submit", name: "filter", value: "sold", class: filter === "sold" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterSold).toUpperCase()),
button({ type: "submit", name: "filter", value: "discarded", class: filter === "discarded" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterDiscarded).toUpperCase()),
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterRecent).toUpperCase()),
...(marketChipVisible("recent") ? [button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterRecent).toUpperCase())] : []),
...(marketChipVisible("mine") ? [button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterMine).toUpperCase())] : []),
...(marketChipVisible("exchange") ? [button({ type: "submit", name: "filter", value: "exchange", class: filter === "exchange" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterItems).toUpperCase())] : []),
...(marketChipVisible("auctions") ? [button({ type: "submit", name: "filter", value: "auctions", class: filter === "auctions" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterAuctions).toUpperCase())] : []),
...(marketChipVisible("mybids") ? [button({ type: "submit", name: "filter", value: "mybids", class: filter === "mybids" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterMyBids).toUpperCase())] : []),
...(marketChipVisible("new") ? [button({ type: "submit", name: "filter", value: "new", class: filter === "new" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterNew).toUpperCase())] : []),
...(marketChipVisible("used") ? [button({ type: "submit", name: "filter", value: "used", class: filter === "used" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterUsed).toUpperCase())] : []),
...(marketChipVisible("broken") ? [button({ type: "submit", name: "filter", value: "broken", class: filter === "broken" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterBroken).toUpperCase())] : []),
...(marketChipVisible("for sale") ? [button({ type: "submit", name: "filter", value: "for sale", class: filter === "for sale" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterForSale).toUpperCase())] : []),
...(marketChipVisible("sold") ? [button({ type: "submit", name: "filter", value: "sold", class: filter === "sold" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterSold).toUpperCase())] : []),
...(marketChipVisible("discarded") ? [button({ type: "submit", name: "filter", value: "discarded", class: filter === "discarded" ? "filter-btn active" : "filter-btn" }, String(i18n.marketFilterDiscarded).toUpperCase())] : []),
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.marketCreateButton)
)
),
(() => {
const isHidden = String(item.visibility || 'PUBLIC').toUpperCase() === 'HIDDEN'
const isClearnet = !!item.clearnet
const chips = [
renderReachChip(isClearnet, i18n, isClearnet ? clearnetItemHref("market", item.title, item.id) : null),
isHidden ? renderVisibilityChip("HIDDEN", i18n) : null,
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,
item.industry ? a({ href: `/industry/${encodeURIComponent(item.industry)}` }, renderStateChip("whole", "🏭", String(i18n.industryTitle || "Industry").toUpperCase())) : null,
renderLifespanChip(item.lifetime, i18n),
renderEcoTax(item.msgSize, item.id)
@ -605,27 +595,20 @@ exports.singleMarketView = async (item, filter, comments = [], params = {}) => {
})()
: null
const marketActions = []
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 marketActions = String(item.seller) === String(userId) ? renderMarketOwnerActions(item, `/market/${encodeURIComponent(item.id)}`) : []
const itemSide = div({ class: "tribe-side" },
div({ class: "card-header activity-card-header" },
renderContentActions(item.id, null, { spread: params.spreads || null, author: item.seller, favKind: 'market', isFavorite: item.isFavorite, reportTitle: item.title })
renderContentActions(item.id, `/market/${encodeURIComponent(item.id)}`, { spread: params.spreads || null, author: item.seller, favKind: 'market', isFavorite: item.isFavorite, reportTitle: item.title, returnTo: `/market/${encodeURIComponent(item.id)}`, deleteAction: String(item.seller) === String(userId) ? contentDeleteAction('market', item.id) : undefined })
),
div({ class: "shop-title-row" },
h2({ class: "tribe-card-title" }, item.title)
),
renderStarRating(item.opinions, Array.isArray(item.opinions_inhabitants) ? item.opinions_inhabitants.length : 0),
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
String(item.seller) === String(userId) ? renderClearnetSwitch("market", item.rootId || item.id, isClearnet) : null,
renderMediaBlob(item.image, "/assets/images/default-market.png"),
div({ class: "card-date-highlight" }, `${item.price} ECO`),
div({ class: "price-chip" }, `${item.price} ECO`),
renderStockBar(item.stock, maxStock),
table({ class: "tribe-info-table jobs-info-table" }, ...infoRows),
tagsNode,
@ -637,7 +620,7 @@ exports.singleMarketView = async (item, filter, comments = [], params = {}) => {
item.description
? div({ class: "job-section" },
h2({ class: "job-section-title" }, i18n.marketItemDescription),
p({ class: "tribe-side-description" }, ...renderUrl(item.description))
p({ class: "tribe-side-description" }, ...renderStyledText(item.description))
)
: null,
renderCountdownField(item),
@ -657,7 +640,7 @@ exports.singleMarketView = async (item, filter, comments = [], params = {}) => {
: 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)}` },
{ method: "POST", action: `/market/bid/${encodeURIComponent(item.id)}`, class: "market-bid-form" },
input({ type: "hidden", name: "returnTo", value: returnTo }),
input({ type: "number", name: "bidAmount", step: "0.000001", min: "0.000001", placeholder: i18n.marketYourBid, required: true }),
br(),
@ -670,7 +653,7 @@ exports.singleMarketView = async (item, filter, comments = [], params = {}) => {
? div(
{ class: "market-item actions" },
form(
{ method: "POST", action: `/market/buy/${encodeURIComponent(item.id)}` },
{ method: "POST", action: `/market/buy/${encodeURIComponent(item.id)}`, class: "market-buy-form" },
input({ type: "hidden", name: "returnTo", value: "/inbox?filter=sent" }),
button({ class: "buy-btn", type: "submit" }, i18n.marketActionsBuy)
)