market_view.js 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716
  1. const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option, img, table, tr, th, td, progress } = require("../server/node_modules/hyperaxe")
  2. const { template, i18n } = require("./main_views")
  3. const moment = require("../server/node_modules/moment")
  4. const { config } = require("../server/SSB_server.js")
  5. const { renderUrl } = require("../backend/renderUrl")
  6. const userId = config.keys.id
  7. const parseBidEntry = (raw) => {
  8. const s = String(raw || "").trim()
  9. if (!s) return null
  10. if (s.includes("|")) {
  11. const parts = s.split("|")
  12. if (parts.length < 3) return null
  13. const bidder = parts[0] || ""
  14. const amount = parseFloat(String(parts[1] || "").replace(",", "."))
  15. const time = parts.slice(2).join("|")
  16. if (!bidder || !Number.isFinite(amount) || !time) return null
  17. return { bidder, amount, time }
  18. }
  19. const first = s.indexOf(":")
  20. const second = s.indexOf(":", first + 1)
  21. if (first === -1 || second === -1) return null
  22. const bidder = s.slice(0, first)
  23. const amountStr = s.slice(first + 1, second)
  24. const time = s.slice(second + 1)
  25. const amount = parseFloat(String(amountStr || "").replace(",", "."))
  26. if (!bidder || !Number.isFinite(amount) || !time) return null
  27. return { bidder, amount, time }
  28. }
  29. const toNum = (v) => {
  30. if (v === null || v === undefined) return NaN
  31. const n = parseFloat(String(v).replace(",", "."))
  32. return Number.isFinite(n) ? n : NaN
  33. }
  34. const normalizeQ = (q) => String(q || "").trim().toLowerCase()
  35. const matchesQuery = (item, q) => {
  36. const qq = normalizeQ(q)
  37. if (!qq) return true
  38. const title = String(item.title || "").toLowerCase()
  39. const tags = Array.isArray(item.tags) ? item.tags : []
  40. const tagStr = tags.map((t) => String(t || "").toLowerCase()).join(" ")
  41. return title.includes(qq) || tagStr.includes(qq)
  42. }
  43. const withinPrice = (item, minP, maxP) => {
  44. const p = toNum(item.price)
  45. if (!Number.isFinite(p)) return false
  46. if (Number.isFinite(minP) && p < minP) return false
  47. if (Number.isFinite(maxP) && p > maxP) return false
  48. return true
  49. }
  50. const sortItems = (items, sort) => {
  51. const s = String(sort || "recent")
  52. if (s === "price") return items.slice().sort((a, b) => toNum(a.price) - toNum(b.price))
  53. if (s === "deadline") {
  54. return items.slice().sort((a, b) => {
  55. const ad = a.deadline ? new Date(a.deadline).getTime() : Number.POSITIVE_INFINITY
  56. const bd = b.deadline ? new Date(b.deadline).getTime() : Number.POSITIVE_INFINITY
  57. return ad - bd
  58. })
  59. }
  60. return items.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
  61. }
  62. const buildReturnTo = (filter, q, minPrice, maxPrice, sort) => {
  63. const params = []
  64. if (filter) params.push(`filter=${encodeURIComponent(filter)}`)
  65. if (q) params.push(`q=${encodeURIComponent(q)}`)
  66. if (minPrice !== undefined && minPrice !== null && String(minPrice) !== "") params.push(`minPrice=${encodeURIComponent(String(minPrice))}`)
  67. if (maxPrice !== undefined && maxPrice !== null && String(maxPrice) !== "") params.push(`maxPrice=${encodeURIComponent(String(maxPrice))}`)
  68. if (sort) params.push(`sort=${encodeURIComponent(sort)}`)
  69. return `/market${params.length ? `?${params.join("&")}` : ""}`
  70. }
  71. const renderCardField = (labelText, value = "") =>
  72. div({ class: "card-field" }, span({ class: "card-label" }, labelText), span({ class: "card-value" }, ...renderUrl(String(value))))
  73. const renderCardFieldRich = (labelText, parts) =>
  74. div({ class: "card-field" }, span({ class: "card-label" }, labelText), span({ class: "card-value" }, ...(Array.isArray(parts) ? parts : [parts])))
  75. const renderPmButton = (recipientId) =>
  76. recipientId && String(recipientId) !== String(userId)
  77. ? form({ method: "GET", action: "/pm" }, input({ type: "hidden", name: "recipients", value: recipientId }), button({ type: "submit", class: "filter-btn" }, i18n.privateMessage))
  78. : null
  79. const renderStockBar = (stockValue, maxValue) => {
  80. const s = Math.max(0, Number(stockValue || 0))
  81. const m = Math.max(1, Number(maxValue || s || 1))
  82. return div(
  83. { class: "confirmations-block stock-block" },
  84. div(
  85. { class: "card-field" },
  86. span({ class: "card-label" }, `${i18n.marketItemStock}: `),
  87. span({ class: "card-value" }, s > 0 ? `${s}/${m}` : i18n.marketOutOfStock)
  88. ),
  89. progress({ class: "confirmations-progress stock-progress", value: Math.min(s, m), max: m })
  90. )
  91. }
  92. const renderMarketCommentsSection = (itemId, returnTo, comments = []) => {
  93. const commentsCount = Array.isArray(comments) ? comments.length : 0
  94. return div(
  95. { class: "vote-comments-section market-comments" },
  96. div({ class: "comments-count" }, span({ class: "card-label" }, i18n.voteCommentsLabel + ": "), span({ class: "card-value" }, String(commentsCount))),
  97. div(
  98. { class: "comment-form-wrapper" },
  99. h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
  100. form(
  101. { method: "POST", action: `/market/${encodeURIComponent(itemId)}/comments`, class: "comment-form" },
  102. input({ type: "hidden", name: "returnTo", value: returnTo }),
  103. textarea({ id: "comment-text", name: "text", required: true, rows: 4, class: "comment-textarea", placeholder: i18n.voteNewCommentPlaceholder }),
  104. br(),
  105. button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
  106. )
  107. ),
  108. comments && comments.length
  109. ? div(
  110. { class: "comments-list" },
  111. comments.map((c) => {
  112. const author = c.value && c.value.author ? c.value.author : ""
  113. const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp
  114. const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : ""
  115. const relDate = ts ? moment(ts).fromNow() : ""
  116. const userName = author && author.includes("@") ? author.split("@")[1] : author
  117. const rootId = c.value && c.value.content ? c.value.content.fork || c.value.content.root : null
  118. const text = c.value && c.value.content && c.value.content.text ? c.value.content.text : ""
  119. return div(
  120. { class: "votations-comment-card" },
  121. span(
  122. { class: "created-at" },
  123. span(i18n.createdBy),
  124. author ? a({ href: `/author/${encodeURIComponent(author)}` }, `@${userName}`) : span("(unknown)"),
  125. absDate ? span(" | ") : "",
  126. absDate ? span({ class: "votations-comment-date" }, absDate) : "",
  127. relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
  128. relDate && rootId ? a({ href: `/thread/${encodeURIComponent(rootId)}#${encodeURIComponent(c.key)}` }, relDate) : ""
  129. ),
  130. p({ class: "votations-comment-text" }, ...renderUrl(text))
  131. )
  132. })
  133. )
  134. : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
  135. )
  136. }
  137. const isMyBidItem = (item) => {
  138. const polls = Array.isArray(item.auctions_poll) ? item.auctions_poll : []
  139. for (const x of polls) {
  140. const b = parseBidEntry(x)
  141. if (b && b.bidder === userId) return true
  142. }
  143. return false
  144. }
  145. const auctionCountdownParts = (deadline) => {
  146. if (!deadline) return null
  147. const dl = moment(deadline)
  148. if (!dl.isValid()) return null
  149. const now = moment()
  150. const rel = dl.fromNow()
  151. if (dl.isAfter(now)) return { label: i18n.marketAuctionEndsIn, rel }
  152. return { label: i18n.marketAuctionEnded, rel }
  153. }
  154. const renderCountdownField = (item) => {
  155. const cd = item && (item.item_type === "auction" || item.item_type === "exchange") ? auctionCountdownParts(item.deadline) : null
  156. if (!cd) return null
  157. return renderCardFieldRich(`${cd.label}:`, [span({ class: "countdown-strong" }, cd.rel)])
  158. }
  159. const normStatus = (s) => String(s || "").toUpperCase().replace(/_/g, " ").replace(/\s+/g, " ").trim()
  160. const renderMarketOwnerActions = (item, returnTo) => {
  161. const polls = Array.isArray(item.auctions_poll) ? item.auctions_poll : []
  162. const canUpdate = item.status !== "SOLD" && item.status !== "DISCARDED" && polls.length === 0
  163. const cur = normStatus(item.status || "FOR SALE")
  164. const canChange = cur !== "SOLD" && cur !== "DISCARDED"
  165. const out = []
  166. if (canUpdate) {
  167. out.push(
  168. form(
  169. { method: "GET", action: `/market/edit/${encodeURIComponent(item.id)}` },
  170. input({ type: "hidden", name: "returnTo", value: returnTo }),
  171. button({ class: "update-btn", type: "submit" }, i18n.marketActionsUpdate)
  172. )
  173. )
  174. }
  175. out.push(
  176. form(
  177. { method: "POST", action: `/market/delete/${encodeURIComponent(item.id)}` },
  178. input({ type: "hidden", name: "returnTo", value: returnTo }),
  179. button({ class: "delete-btn", type: "submit" }, i18n.marketActionsDelete)
  180. )
  181. )
  182. if (canChange) {
  183. out.push(
  184. form(
  185. { method: "POST", action: `/market/status/${encodeURIComponent(item.id)}`, class: "project-control-form project-control-form--status" },
  186. input({ type: "hidden", name: "returnTo", value: returnTo }),
  187. select(
  188. { name: "status", class: "project-control-select" },
  189. option({ value: "FOR SALE", selected: cur === "FOR SALE" }, i18n.marketFilterForSale),
  190. option({ value: "SOLD", selected: cur === "SOLD" }, i18n.marketFilterSold),
  191. option({ value: "DISCARDED", selected: cur === "DISCARDED" }, i18n.marketFilterDiscarded)
  192. ),
  193. button({ class: "status-btn project-control-btn", type: "submit" }, i18n.marketActionsChangeStatus)
  194. )
  195. )
  196. }
  197. return out
  198. }
  199. const renderMarketTopbar = (item, returnTo) => {
  200. const left = [renderPmButton(item && item.seller)].filter(Boolean)
  201. const right = item && String(item.seller) === String(userId) ? renderMarketOwnerActions(item, returnTo) : []
  202. const leftNode = left.length ? div({ class: "bookmark-topbar-left transfer-topbar-left" }, ...left) : null
  203. const rightNode = right.length ? div({ class: "bookmark-actions transfer-actions" }, ...right) : null
  204. const children = []
  205. if (leftNode) children.push(leftNode)
  206. if (rightNode) children.push(rightNode)
  207. return children.length ? div({ class: "bookmark-topbar transfer-topbar-single" }, ...children) : null
  208. }
  209. exports.marketView = async (items, filter, itemToEdit = null, params = {}) => {
  210. const list = Array.isArray(items) ? items : []
  211. const q = params.q || ""
  212. const minPrice = params.minPrice
  213. const maxPrice = params.maxPrice
  214. const sort = params.sort || "recent"
  215. let title = i18n.marketAllSectionTitle
  216. switch (filter) {
  217. case "mine":
  218. title = i18n.marketMineSectionTitle
  219. break
  220. case "create":
  221. title = i18n.marketCreateSectionTitle
  222. break
  223. case "edit":
  224. title = i18n.marketUpdateSectionTitle
  225. break
  226. case "mybids":
  227. title = i18n.marketFilterMyBids
  228. break
  229. }
  230. let filtered = []
  231. switch (filter) {
  232. case "all":
  233. filtered = list
  234. break
  235. case "mine":
  236. filtered = list.filter((e) => e.seller === userId)
  237. break
  238. case "exchange":
  239. filtered = list.filter((e) => e.item_type === "exchange" && e.status === "FOR SALE")
  240. break
  241. case "auctions":
  242. filtered = list.filter((e) => e.item_type === "auction" && e.status === "FOR SALE")
  243. break
  244. case "new":
  245. filtered = list.filter((e) => e.item_status === "NEW" && e.status === "FOR SALE")
  246. break
  247. case "used":
  248. filtered = list.filter((e) => e.item_status === "USED" && e.status === "FOR SALE")
  249. break
  250. case "broken":
  251. filtered = list.filter((e) => e.item_status === "BROKEN" && e.status === "FOR SALE")
  252. break
  253. case "for sale":
  254. filtered = list.filter((e) => e.status === "FOR SALE")
  255. break
  256. case "sold":
  257. filtered = list.filter((e) => e.status === "SOLD")
  258. break
  259. case "discarded":
  260. filtered = list.filter((e) => e.status === "DISCARDED")
  261. break
  262. case "recent": {
  263. const oneDayAgo = moment().subtract(1, "days").toISOString()
  264. filtered = list.filter((e) => e.status === "FOR SALE" && String(e.createdAt || "") >= oneDayAgo)
  265. break
  266. }
  267. case "mybids":
  268. filtered = list.filter((e) => String(e.item_type || "").toLowerCase() === "auction").filter(isMyBidItem)
  269. break
  270. default:
  271. filtered = list
  272. break
  273. }
  274. const minP = toNum(minPrice)
  275. const maxP = toNum(maxPrice)
  276. filtered = filtered.filter((it) => matchesQuery(it, q)).filter((it) => withinPrice(it, minP, maxP))
  277. filtered = sortItems(filtered, sort)
  278. const returnTo = buildReturnTo(filter, q, minPrice, maxPrice, sort)
  279. const itemEdit = itemToEdit || {}
  280. const hiddenCtx = [
  281. input({ type: "hidden", name: "q", value: q }),
  282. input({ type: "hidden", name: "minPrice", value: minPrice ?? "" }),
  283. input({ type: "hidden", name: "maxPrice", value: maxPrice ?? "" }),
  284. input({ type: "hidden", name: "sort", value: sort })
  285. ]
  286. const isFormMode = filter === "create" || filter === "edit"
  287. return template(
  288. title,
  289. section(
  290. div({ class: "tags-header" }, h2(i18n.marketTitle), p(i18n.marketDescription)),
  291. div(
  292. { class: "filters" },
  293. form(
  294. { method: "GET", action: "/market", class: "ui-toolbar ui-toolbar--filters" },
  295. ...hiddenCtx,
  296. button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterAll),
  297. button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterMine),
  298. button({ type: "submit", name: "filter", value: "exchange", class: filter === "exchange" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterItems),
  299. button({ type: "submit", name: "filter", value: "auctions", class: filter === "auctions" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterAuctions),
  300. button({ type: "submit", name: "filter", value: "mybids", class: filter === "mybids" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterMyBids),
  301. button({ type: "submit", name: "filter", value: "new", class: filter === "new" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterNew),
  302. button({ type: "submit", name: "filter", value: "used", class: filter === "used" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterUsed),
  303. button({ type: "submit", name: "filter", value: "broken", class: filter === "broken" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterBroken),
  304. button({ type: "submit", name: "filter", value: "for sale", class: filter === "for sale" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterForSale),
  305. button({ type: "submit", name: "filter", value: "sold", class: filter === "sold" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterSold),
  306. button({ type: "submit", name: "filter", value: "discarded", class: filter === "discarded" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterDiscarded),
  307. button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterRecent),
  308. button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.marketCreateButton)
  309. )
  310. ),
  311. !isFormMode
  312. ? div(
  313. { class: "market-search" },
  314. form(
  315. { method: "GET", action: "/market", class: "filter-box" },
  316. input({ type: "hidden", name: "filter", value: filter || "all" }),
  317. input({ type: "text", name: "q", value: q, placeholder: i18n.marketSearchPlaceholder, class: "filter-box__input" }),
  318. div(
  319. { class: "filter-box__controls" },
  320. div(
  321. { class: "transfer-range" },
  322. input({
  323. type: "number",
  324. name: "minPrice",
  325. step: "0.000001",
  326. min: "0",
  327. value: String(minPrice ?? ""),
  328. placeholder: i18n.marketMinPriceLabel,
  329. class: "filter-box__number transfer-amount-input"
  330. }),
  331. input({
  332. type: "number",
  333. name: "maxPrice",
  334. step: "0.000001",
  335. min: "0",
  336. value: String(maxPrice ?? ""),
  337. placeholder: i18n.marketMaxPriceLabel,
  338. class: "filter-box__number transfer-amount-input"
  339. })
  340. ),
  341. select(
  342. { name: "sort", class: "filter-box__select" },
  343. option({ value: "recent", selected: sort === "recent" }, i18n.marketSortRecent),
  344. option({ value: "price", selected: sort === "price" }, i18n.marketSortPrice),
  345. option({ value: "deadline", selected: sort === "deadline" }, i18n.marketSortDeadline)
  346. ),
  347. button({ type: "submit", class: "filter-box__button" }, i18n.marketSearchButton)
  348. )
  349. )
  350. )
  351. : null
  352. ),
  353. section(
  354. isFormMode
  355. ? div(
  356. { class: "market-form" },
  357. form(
  358. { action: filter === "edit" ? `/market/update/${encodeURIComponent(itemEdit.id)}` : "/market/create", method: "POST", enctype: "multipart/form-data" },
  359. input({ type: "hidden", name: "returnTo", value: "/market?filter=mine" }),
  360. label(i18n.marketItemType),
  361. br(),
  362. select(
  363. { name: "item_type", id: "item_type", required: true },
  364. option({ value: "auction", selected: itemEdit && itemEdit.item_type === "auction" }, "Auction"),
  365. option({ value: "exchange", selected: itemEdit && itemEdit.item_type === "exchange" }, "Exchange")
  366. ),
  367. br(),
  368. br(),
  369. label(i18n.marketItemTitle),
  370. br(),
  371. input({ type: "text", name: "title", id: "title", value: (itemEdit && itemEdit.title) || "", required: true }),
  372. br(),
  373. br(),
  374. label(i18n.marketItemDescription),
  375. br(),
  376. textarea({ name: "description", id: "description", placeholder: i18n.marketItemDescriptionPlaceholder, rows: "6", required: true }, (itemEdit && itemEdit.description) || ""),
  377. br(),
  378. br(),
  379. label(i18n.marketCreateFormImageLabel),
  380. br(),
  381. input({ type: "file", name: "image", id: "image", accept: "image/*" }),
  382. br(),
  383. br(),
  384. label(i18n.marketItemStatus),
  385. br(),
  386. select(
  387. { name: "item_status", id: "item_status" },
  388. option({ value: "BROKEN", selected: itemEdit && itemEdit.item_status === "BROKEN" }, "BROKEN"),
  389. option({ value: "USED", selected: itemEdit && itemEdit.item_status === "USED" }, "USED"),
  390. option({ value: "NEW", selected: itemEdit && itemEdit.item_status === "NEW" }, "NEW")
  391. ),
  392. br(),
  393. br(),
  394. label(i18n.marketItemStock),
  395. br(),
  396. input({ type: "number", name: "stock", id: "stock", value: (itemEdit && itemEdit.stock) || 1, required: true, min: "1", step: "1" }),
  397. br(),
  398. br(),
  399. label(i18n.marketItemPrice),
  400. br(),
  401. input({ type: "number", name: "price", id: "price", value: (itemEdit && itemEdit.price) || "", required: true, step: "0.000001", min: "0.000001" }),
  402. br(),
  403. br(),
  404. label(i18n.marketItemTags),
  405. br(),
  406. input({ type: "text", name: "tags", id: "tags", placeholder: i18n.marketItemTagsPlaceholder, value: (itemEdit && itemEdit.tags && itemEdit.tags.join(", ")) || "" }),
  407. br(),
  408. br(),
  409. label(i18n.marketItemDeadline),
  410. br(),
  411. input({
  412. type: "datetime-local",
  413. name: "deadline",
  414. id: "deadline",
  415. required: true,
  416. min: moment().format("YYYY-MM-DDTHH:mm"),
  417. value: itemEdit && itemEdit.deadline ? moment(itemEdit.deadline).format("YYYY-MM-DDTHH:mm") : ""
  418. }),
  419. br(),
  420. br(),
  421. input({ type: "hidden", name: "includesShipping", value: "0" }),
  422. label(i18n.marketItemIncludesShipping),
  423. br(),
  424. input({
  425. id: "includesShipping-checkbox",
  426. type: "checkbox",
  427. name: "includesShipping",
  428. value: "1",
  429. class: "meme-checkbox",
  430. ...(itemEdit && itemEdit.includesShipping ? { checked: true } : {})
  431. }),
  432. br(),
  433. br(),
  434. button({ type: "submit" }, filter === "edit" ? i18n.marketUpdateButton : i18n.marketCreateButton)
  435. )
  436. )
  437. : div(
  438. { class: "market-grid" },
  439. filtered.length > 0
  440. ? filtered.map((item) => {
  441. const polls = Array.isArray(item.auctions_poll) ? item.auctions_poll : []
  442. const parsedBids = polls.map(parseBidEntry).filter(Boolean).sort((a, b) => new Date(b.time) - new Date(a.time))
  443. const myBid = item.item_type === "auction" ? parsedBids.some((b) => b.bidder === userId) : false
  444. const maxStock = item.initialStock || item.stockMax || item.stock || 1
  445. const stockLeft = Number(item.stock || 0)
  446. const isOwner = String(item.seller) === String(userId)
  447. const actionNodesRaw = isOwner
  448. ? renderMarketOwnerActions(item, "/market?filter=mine")
  449. : [
  450. item.status !== "SOLD" && item.status !== "DISCARDED" && item.item_type === "auction"
  451. ? form(
  452. { method: "POST", action: `/market/bid/${encodeURIComponent(item.id)}` },
  453. input({ type: "hidden", name: "returnTo", value: returnTo }),
  454. input({ type: "number", name: "bidAmount", step: "0.000001", min: "0.000001", placeholder: i18n.marketYourBid, required: true }),
  455. br(),
  456. button({ class: "buy-btn", type: "submit" }, i18n.marketPlaceBidButton)
  457. )
  458. : null,
  459. item.status === "FOR SALE" && item.item_type !== "auction" && !isOwner && stockLeft > 0
  460. ? form(
  461. { method: "POST", action: `/market/buy/${encodeURIComponent(item.id)}` },
  462. input({ type: "hidden", name: "returnTo", value: "/inbox?filter=sent" }),
  463. input({ type: "hidden", name: "buyerId", value: userId }),
  464. button({ class: "buy-btn", type: "submit" }, i18n.marketActionsBuy)
  465. )
  466. : null
  467. ].filter(Boolean)
  468. const actionNodes = Array.isArray(actionNodesRaw) ? actionNodesRaw.filter(Boolean) : []
  469. const buttonsBlock =
  470. actionNodes.length > 0
  471. ? div(
  472. { class: "market-card buttons" },
  473. div({ style: "display:flex;gap:8px;flex-wrap:wrap;align-items:center;" }, ...actionNodes)
  474. )
  475. : stockLeft <= 0
  476. ? div(
  477. { class: "market-card buttons" },
  478. div({ class: "card-field" }, span({ class: "card-value" }, i18n.marketOutOfStock))
  479. )
  480. : null
  481. return div(
  482. { class: "market-item" },
  483. div(
  484. { class: "market-card left-col" },
  485. div(
  486. { style: "display:flex;gap:8px;flex-wrap:wrap;align-items:center;" },
  487. form(
  488. { method: "GET", action: `/market/${encodeURIComponent(item.id)}` },
  489. input({ type: "hidden", name: "returnTo", value: returnTo }),
  490. input({ type: "hidden", name: "filter", value: filter || "all" }),
  491. input({ type: "hidden", name: "q", value: q }),
  492. input({ type: "hidden", name: "minPrice", value: String(minPrice ?? "") }),
  493. input({ type: "hidden", name: "maxPrice", value: String(maxPrice ?? "") }),
  494. input({ type: "hidden", name: "sort", value: sort }),
  495. button({ class: "filter-btn", type: "submit" }, i18n.viewDetails)
  496. ),
  497. renderPmButton(item.seller),
  498. myBid ? span({ class: "chip chip-you" }, i18n.marketMyBidBadge) : null
  499. ),
  500. h2({ class: "market-card type" }, `${i18n.marketItemType}: ${String(item.item_type || "").toUpperCase()}`),
  501. h2(item.title),
  502. renderCardField(`${i18n.marketItemStatus}:`, item.status),
  503. renderCountdownField(item),
  504. item.deadline ? renderCardField(`${i18n.marketItemAvailable}:`, moment(item.deadline).format("YYYY/MM/DD HH:mm:ss")) : null,
  505. br(),
  506. br(),
  507. div(
  508. { class: "market-card image" },
  509. item.image ? img({ src: `/blob/${encodeURIComponent(item.image)}` }) : img({ src: "/assets/images/default-market.png", alt: item.title })
  510. ),
  511. p(...renderUrl(item.description)),
  512. item.tags && item.tags.filter(Boolean).length
  513. ? div(
  514. { class: "card-tags" },
  515. item.tags
  516. .filter(Boolean)
  517. .map((tag) => a({ class: "tag-link", href: `/search?query=%23${encodeURIComponent(tag)}` }, `#${tag}`))
  518. )
  519. : null
  520. ),
  521. div(
  522. { class: "market-card right-col" },
  523. div({ class: "market-card price" }, renderCardField(`${i18n.marketItemPrice}:`, `${item.price} ECO`)),
  524. renderCardField(`${i18n.marketItemCondition}:`, item.item_status),
  525. renderCardField(`${i18n.marketItemIncludesShipping}:`, item.includesShipping ? i18n.YESLabel : i18n.NOLabel),
  526. br(),
  527. renderStockBar(item.stock, maxStock),
  528. item.item_type === "auction" && parsedBids.length > 0
  529. ? div(
  530. { class: "auction-info" },
  531. p({ class: "auction-bid-text" }, i18n.marketAuctionBids),
  532. table(
  533. { class: "auction-bid-table" },
  534. tr(th(i18n.marketAuctionBidTime), th(i18n.marketAuctionUser), th(i18n.marketAuctionBidAmount)),
  535. parsedBids.map((bid) =>
  536. tr(
  537. td(moment(bid.time).format("YYYY-MM-DD HH:mm:ss")),
  538. td(a({ href: `/author/${encodeURIComponent(bid.bidder)}` }, bid.bidder)),
  539. td(`${parseFloat(bid.amount).toFixed(6)} ECO`)
  540. )
  541. )
  542. )
  543. )
  544. : null,
  545. br(),
  546. br(),
  547. div(
  548. { class: "card-comments-summary" },
  549. span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
  550. span({ class: "card-value" }, String(item.commentCount || 0)),
  551. br(),
  552. br(),
  553. form(
  554. { method: "GET", action: `/market/${encodeURIComponent(item.id)}` },
  555. input({ type: "hidden", name: "returnTo", value: returnTo }),
  556. input({ type: "hidden", name: "filter", value: filter || "all" }),
  557. input({ type: "hidden", name: "q", value: q }),
  558. input({ type: "hidden", name: "minPrice", value: String(minPrice ?? "") }),
  559. input({ type: "hidden", name: "maxPrice", value: String(maxPrice ?? "") }),
  560. input({ type: "hidden", name: "sort", value: sort }),
  561. button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton)
  562. )
  563. ),
  564. buttonsBlock
  565. )
  566. )
  567. })
  568. : p(i18n.marketNoItemsMatch || i18n.marketNoItems)
  569. )
  570. )
  571. )
  572. }
  573. exports.singleMarketView = async (item, filter, comments = [], params = {}) => {
  574. const polls = Array.isArray(item.auctions_poll) ? item.auctions_poll : []
  575. const parsedBids = polls.map(parseBidEntry).filter(Boolean).sort((a, b) => new Date(b.time) - new Date(a.time))
  576. const q = params.q || ""
  577. const minPrice = params.minPrice
  578. const maxPrice = params.maxPrice
  579. const sort = params.sort || "recent"
  580. const returnTo = params.returnTo || buildReturnTo(filter, q, minPrice, maxPrice, sort)
  581. const topbar = renderMarketTopbar(item, returnTo)
  582. const stockLeft = Number(item.stock || 0)
  583. const showBuy = item.status === "FOR SALE" && item.item_type !== "auction" && String(item.seller) !== String(userId) && stockLeft > 0
  584. const maxStock = item.initialStock || item.stockMax || item.stock || 1
  585. return template(
  586. item.title,
  587. section(
  588. div(
  589. { class: "filters" },
  590. form(
  591. { method: "GET", action: "/market", class: "ui-toolbar ui-toolbar--filters" },
  592. input({ type: "hidden", name: "q", value: q }),
  593. input({ type: "hidden", name: "minPrice", value: minPrice ?? "" }),
  594. input({ type: "hidden", name: "maxPrice", value: maxPrice ?? "" }),
  595. input({ type: "hidden", name: "sort", value: sort }),
  596. button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterAll),
  597. button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterMine),
  598. button({ type: "submit", name: "filter", value: "exchange", class: filter === "exchange" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterItems),
  599. button({ type: "submit", name: "filter", value: "auctions", class: filter === "auctions" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterAuctions),
  600. button({ type: "submit", name: "filter", value: "mybids", class: filter === "mybids" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterMyBids),
  601. button({ type: "submit", name: "filter", value: "new", class: filter === "new" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterNew),
  602. button({ type: "submit", name: "filter", value: "used", class: filter === "used" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterUsed),
  603. button({ type: "submit", name: "filter", value: "broken", class: filter === "broken" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterBroken),
  604. button({ type: "submit", name: "filter", value: "for sale", class: filter === "for sale" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterForSale),
  605. button({ type: "submit", name: "filter", value: "sold", class: filter === "sold" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterSold),
  606. button({ type: "submit", name: "filter", value: "discarded", class: filter === "discarded" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterDiscarded),
  607. button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterRecent),
  608. button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.marketCreateButton)
  609. )
  610. ),
  611. div(
  612. { class: "tags-header" },
  613. topbar ? topbar : null,
  614. h2(item.title),
  615. renderCardField(`${i18n.marketItemType}:`, `${String(item.item_type || "").toUpperCase()}`),
  616. renderCardField(`${i18n.marketItemStatus}:`, item.status),
  617. renderCountdownField(item),
  618. renderCardField(`${i18n.marketItemCondition}:`, item.item_status),
  619. br(),
  620. div(
  621. { class: "market-item image" },
  622. item.image ? img({ src: `/blob/${encodeURIComponent(item.image)}` }) : img({ src: "/assets/images/default-market.png", alt: item.title })
  623. ),
  624. renderCardField(`${i18n.marketItemDescription}:`, ""),
  625. p(...renderUrl(item.description)),
  626. item.tags && item.tags.length
  627. ? div({ class: "card-tags" }, item.tags.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)))
  628. : null,
  629. br(),
  630. renderCardField(`${i18n.marketItemPrice}:`, ""),
  631. br(),
  632. div({ class: "card-label" }, h2(`${item.price} ECO`)),
  633. br(),
  634. renderStockBar(item.stock, maxStock),
  635. br(),
  636. renderCardField(`${i18n.marketItemIncludesShipping}:`, `${item.includesShipping ? i18n.YESLabel : i18n.NOLabel}`),
  637. item.deadline ? renderCardField(`${i18n.marketItemAvailable}:`, `${moment(item.deadline).format("YYYY/MM/DD HH:mm:ss")}`) : null,
  638. renderCardFieldRich(`${i18n.marketItemSeller}:`, [a({ class: "user-link", href: `/author/${encodeURIComponent(item.seller)}` }, item.seller)])
  639. ),
  640. item.item_type === "auction"
  641. ? div(
  642. { class: "auction-info" },
  643. p({ class: "auction-bid-text" }, i18n.marketAuctionBids),
  644. parsedBids.length
  645. ? table(
  646. { class: "auction-bid-table" },
  647. tr(th(i18n.marketAuctionBidTime), th(i18n.marketAuctionUser), th(i18n.marketAuctionBidAmount)),
  648. parsedBids.map((bid) =>
  649. 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`))
  650. )
  651. )
  652. : null,
  653. item.status !== "SOLD" && item.status !== "DISCARDED"
  654. ? form(
  655. { method: "POST", action: `/market/bid/${encodeURIComponent(item.id)}` },
  656. input({ type: "hidden", name: "returnTo", value: returnTo }),
  657. input({ type: "number", name: "bidAmount", step: "0.000001", min: "0.000001", placeholder: i18n.marketYourBid, required: true }),
  658. br(),
  659. button({ class: "buy-btn", type: "submit" }, i18n.marketPlaceBidButton)
  660. )
  661. : null
  662. )
  663. : null,
  664. showBuy
  665. ? div(
  666. { class: "market-item actions" },
  667. form(
  668. { method: "POST", action: `/market/buy/${encodeURIComponent(item.id)}` },
  669. input({ type: "hidden", name: "returnTo", value: "/inbox?filter=sent" }),
  670. button({ class: "buy-btn", type: "submit" }, i18n.marketActionsBuy)
  671. )
  672. )
  673. : null,
  674. renderMarketCommentsSection(item.id, returnTo, comments)
  675. )
  676. )
  677. }