const { div, h2, p, section, button, form, a, input, label, span, textarea, br, table, tr, td } = require("../server/node_modules/hyperaxe"); const { renderCommentsSection: renderSharedCommentsSection } = require("./comments_view"); const { template, i18n, userLink, renderOpinionsVoting, renderEngagement, renderSpreadButton, renderContentActions, renderStateChip, renderLifespanChip, renderSpreadEditWarning, renderModuleStatsBy, moduleIsEmpty, contentDeleteAction, paged, renderCreatedRows } = require("./main_views"); const moment = require("../server/node_modules/moment"); const { config } = require("../server/SSB_server.js"); const { MAX_OPTIONS, MIN_OPTIONS, MAX_OPTION_LENGTH } = require("../models/polls_model_limits"); const userId = config.keys.id; const FILTERS = [ { key: "ALL", i18n: "pollFilterAll" }, { key: "RECENT", i18n: "pollFilterRecent" }, { key: "MINE", i18n: "pollFilterMine" }, { key: "TOP", i18n: "pollFilterTop" }, { key: "VOTED", i18n: "pollFilterVoted" }, { key: "OPEN", i18n: "pollFilterOpen" }, { key: "CLOSED", i18n: "pollFilterClosed" } ]; const safeArr = (v) => (Array.isArray(v) ? v : []); const pct = (n, total) => (total > 0 ? Math.round((Number(n) || 0) / total * 100) : 0); const letterOf = (i) => { let n = Number(i) || 0; let out = ""; do { out = String.fromCharCode(65 + (n % 26)) + out; n = Math.floor(n / 26) - 1; } while (n >= 0); return out; }; const pctStep = (n, total) => Math.round(pct(n, total) / 5) * 5; const outcomeOf = (poll) => { if (!poll.totalVoters) return i18n.pollNoVotesYet; const max = Math.max(...poll.options.map(o => poll.counts[o] || 0)); if (!max) return i18n.pollNoVotesYet; const winners = poll.options .map((o, i) => ({ o, letter: letterOf(i) })) .filter(({ o }) => (poll.counts[o] || 0) === max); if (winners.length > 1) return `${i18n.pollTie}: ${winners.map(w => `${w.letter})`).join(", ")}`; return `${winners[0].letter}) ${pct(max, poll.totalVoters)}%`; }; const renderStatusChip = (poll) => renderStateChip(poll.status === "OPEN" ? "whole" : "mutuals", "◆", poll.status === "OPEN" ? i18n.pollStatusOpen : i18n.pollStatusClosed); const renderModeChips = (poll) => [ poll.anonymous ? renderStateChip("mutuals", "◈", i18n.pollAnonymous) : null, poll.multiple ? renderStateChip("whole", "☰", i18n.pollMultiple) : null ].filter(Boolean); const renderResults = (poll) => table({ class: "poll-results" }, ...poll.options.map((opt, i) => tr({ class: poll.myChoices.includes(opt) ? "poll-result-row poll-result-mine" : "poll-result-row" }, td({ class: "poll-result-option" }, `${letterOf(i)})`), td({ class: "poll-result-bar" }, div({ class: "poll-bar" }, div({ class: `poll-bar-fill poll-bar-fill-${pctStep(poll.counts[opt], poll.totalVoters)} poll-hue-${i % 8}` }) ) ), td({ class: "poll-result-count" }, `${poll.counts[opt] || 0} (${pct(poll.counts[opt], poll.totalVoters)}%)`) ) ) ); const renderBallot = (poll, returnTo, basePath, extra = null) => { if (poll.status === "CLOSED" || poll.hasVoted) return null; const inputType = poll.multiple ? "checkbox" : "radio"; return form({ method: "POST", action: `${basePath}/vote/${encodeURIComponent(poll.id)}`, class: "poll-ballot" }, input({ type: "hidden", name: "returnTo", value: returnTo }), ...poll.options.map((opt, i) => div({ class: "poll-option-row" }, label({ for: `opt-${i}-${poll.id}` }, input({ type: inputType, name: "choices", value: opt, id: `opt-${i}-${poll.id}`, ...(poll.myChoices.includes(opt) ? { checked: true } : {}) }), " ", `${letterOf(i)}) ${opt}` ) ) ), div({ class: "poll-ballot-actions" }, button({ type: "submit", class: "filter-btn" }, i18n.pollVoteButton), ...(Array.isArray(extra) ? extra.filter(Boolean) : []) ) ); }; const renderPollCard = (poll, filter, spreadInfo, basePath = "/polls") => { const href = `${basePath}/${encodeURIComponent(poll.id)}`; const isOwn = String(poll.author) === String(userId); const chips = [ renderStatusChip(poll), ...renderModeChips(poll), poll.deadline ? renderStateChip(poll.status === "OPEN" ? "whole" : "mutuals", "◷", `${i18n.pollDeadline}: ${moment(poll.deadline).format("YYYY/MM/DD HH:mm")}`) : null, renderLifespanChip(poll.lifetime, i18n) ].filter(Boolean); return div({ class: "tribe-card poll-card" + (isOwn ? " own-content" : "") }, div({ class: "card-header activity-card-header" }, span(), renderContentActions(poll.id, href, { spread: spreadInfo || null, author: poll.author, favKind: 'polls', isFavorite: poll.isFavorite, reportTitle: poll.question, deleteAction: isOwn ? contentDeleteAction('poll', poll.id) : undefined }) ), div({ class: "tribe-card-body" }, div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, a({ href }, poll.question)) ), chips.length ? div({ class: "card-chips-row" }, ...chips) : null, div({ class: "tribe-card-members" }, span({ class: "tribe-members-count" }, `${i18n.pollVoters}: ${poll.totalVoters}`) ) ) ); }; const renderCreateForm = (poll = null, params = {}) => { const isEdit = !!poll; const action = isEdit ? `/polls/update/${encodeURIComponent(poll.id)}` : "/polls/create"; return section( params.spreadWarning || null, div({ class: "div-center audio-form" }, h2(isEdit ? i18n.pollEditTitle : i18n.pollCreateTitle), form({ method: "POST", action }, label(i18n.pollQuestion), br(), input({ type: "text", name: "question", required: true, maxlength: "300", value: isEdit ? poll.question : "", placeholder: i18n.pollQuestionPlaceholder }), br(), br(), label(i18n.pollOptions), br(), textarea({ name: "options", rows: String(MAX_OPTIONS), required: true, maxlength: String(MAX_OPTIONS * (MAX_OPTION_LENGTH + 1)) }, isEdit ? poll.options.join("\n") : ""), br(), div({ class: "poll-switch" }, input({ type: "hidden", name: "anonymous", value: "0" }), label( input({ type: "checkbox", name: "anonymous", value: "1", ...(isEdit && poll.anonymous ? { checked: true } : {}) }), " ", i18n.pollAnonymousLabel ) ), div({ class: "poll-switch" }, input({ type: "hidden", name: "multiple", value: "0" }), label( input({ type: "checkbox", name: "multiple", value: "1", ...(isEdit && poll.multiple ? { checked: true } : {}) }), " ", i18n.pollMultipleLabel ) ), br(), label(i18n.pollDeadline), br(), input({ type: "datetime-local", name: "deadline", min: moment().format("YYYY-MM-DDTHH:mm"), value: isEdit && poll.deadline ? moment(poll.deadline).format("YYYY-MM-DDTHH:mm") : "" }), br(), br(), label(i18n.pollTags), br(), input({ type: "text", name: "tags", value: isEdit ? poll.tags.join(", ") : "" }), br(), br(), button({ type: "submit", class: "create-button" }, isEdit ? i18n.pollUpdateButton : i18n.pollPublishButton) ) ) ); }; const renderComments = (poll, comments, basePath) => { const href = `${basePath}/${encodeURIComponent(poll.id)}`; return renderSharedCommentsSection({ action: `${href}/comments`, comments: comments, returnTo: href }); }; const renderFilterBar = (filter, q, showSearch = true, items = null, censusList = null) => { const emptyMod = items !== null && moduleIsEmpty(items, filter, "ALL", q); const censusPl = Array.isArray(censusList) ? censusList : (Array.isArray(items) ? items : []); const pollChip = (x) => { const m = x.key; if (m === filter) return true; if (m === "TOP") return censusPl.length > 0; if (m === "MINE") return censusPl.some(pl => String(pl.author || pl.createdBy) === String(userId)); if (m === "RECENT") return censusPl.length > 0; if (m === "VOTED") return censusPl.some(pl => pl.hasVoted || safeArr(pl.myChoices).length > 0); if (m === "OPEN" || m === "CLOSED") return censusPl.some(pl => String(pl.status || "OPEN").toUpperCase() === m); return true; }; return section( div({ class: "filters" }, form({ method: "GET", action: "/polls", class: "ui-toolbar ui-toolbar--filters" }, ...(emptyMod ? [] : FILTERS.filter(pollChip).map(f => button({ type: "submit", name: "filter", value: f.key, class: filter === f.key ? "filter-btn active" : "filter-btn" }, String(i18n[f.i18n]).toUpperCase()) )), button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.pollCreateButton) ) ), showSearch && !emptyMod ? div({ class: "filters activity-filter-chips activity-toolbar-row" }, items ? renderModuleStatsBy(items, pl => String(pl.status || 'OPEN').toUpperCase(), [{ value: 'OPEN', label: i18n.pollStatusOpen }, { value: 'CLOSED', label: i18n.pollStatusClosed }]) : null, form({ method: "GET", action: "/polls", class: "filter-box" }, input({ type: "hidden", name: "filter", value: filter }), input({ type: "text", name: "q", value: q || "", placeholder: i18n.pollSearchPlaceholder, class: "filter-box__input" }), div({ class: "filter-box__controls" }, button({ type: "submit", class: "filter-box__button" }, i18n.searchButton) ) ) ) : null ); }; exports.pollsView = async (polls = [], filter = "ALL", params = {}) => { const mode = String(filter).toUpperCase(); const spreadMap = params.spreadMap instanceof Map ? params.spreadMap : new Map(); if (mode === "CREATE" || mode === "EDIT") { const editing = params.poll || null; if (editing) params = { ...params, spreadWarning: await renderSpreadEditWarning(editing.id) }; return template( i18n.pollsTitle, section(div({ class: "tags-header module-header-line" }, h2(i18n.pollsTitle), p(i18n.pollsDescription))), renderFilterBar(mode, params.q, false), renderCreateForm(editing, params) ); } return template( i18n.pollsTitle, section(div({ class: "tags-header module-header-line" }, h2(i18n.pollsTitle), p(i18n.pollsDescription))), renderFilterBar(mode, params.q, true, polls, params.censusList), section( polls.length ? div({ class: "jobs-grid" }, ...paged(polls).map(pl => renderPollCard(pl, mode, spreadMap.get(pl.id)))) : div({ class: "no-content-box" }, p({ class: "no-content" }, i18n.pollsNoItems)) ) ); }; exports.singlePollView = async (poll, comments = [], params = {}) => { const basePath = "/polls"; const href = `${basePath}/${encodeURIComponent(poll.id)}`; const isOwn = String(poll.author) === String(userId); const chips = [renderStatusChip(poll), ...renderModeChips(poll), renderLifespanChip(poll.lifetime, i18n)].filter(Boolean); const infoRows = []; const pushRow = (labelText, valueNode) => infoRows.push(tr( td({ class: "tribe-info-label" }, labelText), td({ class: "tribe-info-value" }, valueNode) )); if (poll.deadline) pushRow(i18n.pollDeadline, moment(poll.deadline).format("YYYY/MM/DD HH:mm")); pushRow(i18n.pollOutcome, outcomeOf(poll)); const tagsNode = poll.tags.length ? div({ class: "card-tags" }, poll.tags.map(tag => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))) : null; const ownerActions = isOwn && poll.totalVoters === 0 && poll.status === "OPEN" ? div({ class: "tribe-side-actions" }, form({ method: "GET", action: "/polls" }, input({ type: "hidden", name: "filter", value: "EDIT" }), input({ type: "hidden", name: "id", value: poll.id }), button({ type: "submit", class: "update-btn" }, i18n.pollUpdateButton) ) ) : null; const pollSide = div({ class: "tribe-side" }, div({ class: "card-header activity-card-header" }, renderContentActions(poll.id, href, { spread: params.spreads || null, author: poll.author, favKind: 'polls', isFavorite: poll.isFavorite, returnTo: href, reportTitle: poll.question, deleteAction: isOwn ? contentDeleteAction('poll', poll.id) : undefined }) ), div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, poll.question)), chips.length ? div({ class: "card-chips-row" }, ...chips) : null, table({ class: "tribe-info-table jobs-info-table" }, ...infoRows, ...renderCreatedRows(poll.createdAt, poll.author)), tagsNode, div({ class: "tribe-card-members" }, span({ class: "tribe-members-count" }, `${i18n.pollVoters}: ${poll.totalVoters}`) ), isOwn ? div({ class: "tribe-side-actions housing-status-row" }, span({ class: "card-label" }, `${i18n.pollStatusLabel}: `), renderStatusChip(poll), poll.status === "OPEN" ? form({ method: "POST", action: `/polls/close/${encodeURIComponent(poll.id)}` }, button({ type: "submit", class: "status-btn project-control-btn" }, i18n.pollCloseButton) ) : null ) : null, ownerActions ); const pollMain = div({ class: "tribe-main" }, div({ class: "job-section" }, h2({ class: "job-section-title" }, i18n.pollOutcome), renderBallot(poll, href, basePath), renderResults(poll) ), poll.votersHidden ? null : (poll.voters.length ? div({ class: "card-assigned-list" }, ...poll.voters.map(v => userLink(v))) : null), poll.tribeId ? null : renderEngagement(poll.id, renderOpinionsVoting('/polls/opinions', poll.id, poll.opinions, href, poll.opinions_inhabitants), renderComments(poll, comments, basePath) ) ); return template( poll.question, section(div({ class: "tags-header module-header-line" }, h2(i18n.pollsTitle), p(i18n.pollsDescription))), renderFilterBar(String(params.filter || "ALL").toUpperCase(), params.q, false), section(div({ class: "tribe-details" }, pollSide, pollMain)) ); }; exports.renderPollCard = renderPollCard; exports.renderResults = renderResults; exports.renderBallot = renderBallot; exports.outcomeOf = outcomeOf; exports.letterOf = letterOf;