diff --git a/.vibe42-run.log b/.vibe42-run.log new file mode 100644 index 0000000..3330fa2 --- /dev/null +++ b/.vibe42-run.log @@ -0,0 +1,4 @@ +--- run Sun Sep 20 07:06:05 PM UTC 2026 :: npm start --silent + +up to date in 395ms +Регистрация тимбилдинга на порту 3000 diff --git a/.vibe42-run.pid b/.vibe42-run.pid new file mode 100644 index 0000000..8cf5c1a --- /dev/null +++ b/.vibe42-run.pid @@ -0,0 +1 @@ +86 diff --git a/data.json b/data.json new file mode 100644 index 0000000..32ae212 --- /dev/null +++ b/data.json @@ -0,0 +1,98 @@ +[ + { + "id": 1, + "name": "Данияр", + "lastname": "Орынбаев", + "phone": "+7 705 214 88 30", + "status": "confirmed", + "date": "08.09.2026" + }, + { + "id": 2, + "name": "Айгерим", + "lastname": "Сатыбаева", + "phone": "+7 702 551 40 12", + "status": "confirmed", + "date": "09.09.2026" + }, + { + "id": 3, + "name": "Ержан", + "lastname": "Абдрахимов", + "phone": "+7 771 902 33 56", + "status": "waiting", + "date": "09.09.2026" + }, + { + "id": 4, + "name": "Гульнара", + "lastname": "Касымжанова", + "phone": "+7 747 635 21 08", + "status": "confirmed", + "date": "10.09.2026" + }, + { + "id": 5, + "name": "Мадина", + "lastname": "Турсынбаева", + "phone": "+7 708 442 17 65", + "status": "confirmed", + "date": "11.09.2026" + }, + { + "id": 6, + "name": "Асхат", + "lastname": "Жаманкулов", + "phone": "+7 700 118 92 44", + "status": "waiting", + "date": "12.09.2026" + }, + { + "id": 7, + "name": "Сейіл", + "lastname": "Кенжарик", + "phone": "+7 748 276 50 91", + "status": "confirmed", + "date": "13.09.2026" + }, + { + "id": 8, + "name": "Жанна", + "lastname": "Мырзахметова", + "phone": "+7 706 390 77 14", + "status": "confirmed", + "date": "15.09.2026" + }, + { + "id": 9, + "name": "Бахыт", + "lastname": "Айтимов", + "phone": "+7 775 813 26 40", + "status": "confirmed", + "date": "15.09.2026" + }, + { + "id": 10, + "name": "Алия", + "lastname": "Исмухамбетова", + "phone": "+7 707 564 38 29", + "status": "waiting", + "date": "16.09.2026" + }, + { + "id": 11, + "name": "Нурлан", + "lastname": "Сейткалиев", + "phone": "+7 702 837 15 73", + "status": "confirmed", + "date": "16.09.2026" + }, + { + "id": 12, + "name": "Карлыгаш", + "lastname": "Ералиева", + "phone": "+7 749 620 84 57", + "status": "waiting", + "date": "17.09.2026" + } +] \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7c42d57 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,10 @@ +{ + "name": "app", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "app" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7626a05 --- /dev/null +++ b/package.json @@ -0,0 +1,6 @@ +{ + "name": "app", + "private": true, + "type": "commonjs", + "scripts": { "start": "node server.js" } +} diff --git a/script.js b/script.js index 4c1cf49..183e6a0 100644 --- a/script.js +++ b/script.js @@ -1,10 +1,12 @@ /* Тимбилдинг ДЦБ Астана — регистрация участников и список администратора. - Данные живут в localStorage (общий хранилище страницы); при первом заходе - список наполняется примерами участников для показа структуры. */ + Список участников хранится на сервере (api/participants → data.json) и + виден всем; если сервер недоступен (открывают статичную страницу) — + работаем в localStorage браузера. */ (function () { "use strict"; var LS_KEY = "teambuilding_dcb_participants"; + var usingLocal = false; var STATUS = { waiting: { label: "Ждёт подтверждения", tone: "warn" }, @@ -27,25 +29,60 @@ ]; /* ── Хранилище ── */ - function load() { + function lsLoad() { try { var raw = localStorage.getItem(LS_KEY); if (raw) { var arr = JSON.parse(raw); if (Array.isArray(arr)) return arr; } - } catch (e) { /* повреждённые данные — пересеять */ } - localStorage.setItem(LS_KEY, JSON.stringify(SEED)); + } catch (e) {} + try { localStorage.setItem(LS_KEY, JSON.stringify(SEED)); } catch (e) {} return SEED.slice(); } - function save(list) { + function lsSave(list) { try { localStorage.setItem(LS_KEY, JSON.stringify(list)); } catch (e) {} } - var participants = load(); + function apiList() { + return fetch("api/participants", { headers: { Accept: "application/json" }, cache: "no-store" }) + .then(function (r) { + if (!r.ok) throw new Error("http " + r.status); + return r.json(); + }); + } + function apiCreate(rec) { + return fetch("api/participants", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(rec) + }).then(function (r) { + if (!r.ok) throw new Error("http " + r.status); + return r.json(); + }); + } + + var participants = []; var nextId = 1; - for (var i = 0; i < participants.length; i++) { - if (participants[i].id >= nextId) nextId = participants[i].id + 1; + function reindex() { + nextId = 1; + for (var i = 0; i < participants.length; i++) { + if (typeof participants[i].id === "number" && participants[i].id >= nextId) nextId = participants[i].id + 1; + } + } + + function loadAll() { + return apiList().then(function (arr) { + if (!Array.isArray(arr)) throw new Error("bad payload"); + participants = arr; + reindex(); + return participants; + }).catch(function () { + usingLocal = true; + participants = lsLoad(); + reindex(); + return participants; + }); } /* ── Хелперы ── */ @@ -88,7 +125,6 @@ p.hidden = false; } if (name === "admin") renderAdmin(); - try { location.hash = name; } catch (e) {} } /* ── Форма регистрации ── */ @@ -98,6 +134,11 @@ var fLast = $("#f-lastname"); var fPhone = $("#f-phone"); + function flashField(input, msg) { + input.focus(); + toast(msg, "warn"); + } + form.addEventListener("submit", function (ev) { ev.preventDefault(); var name = fName.value.trim(); @@ -110,41 +151,45 @@ flashField(fPhone, "Укажите телефон в формате +7 XXX XXX XX XX"); return; } - var dup = null; for (var i = 0; i < participants.length; i++) { var p = participants[i]; var key = (p.name + " " + p.lastname).trim().replace(/\s+/g, " ").toLowerCase(); if (key === (name + " " + lastname).replace(/\s+/g, " ").toLowerCase() && - p.phone.replace(/\D/g, "") === phone.replace(/\D/g, "")) { dup = p; break; } + String(p.phone).replace(/\D/g, "") === phone.replace(/\D/g, "")) { + toast("Этот участник уже зарегистрирован", "warn"); + return; + } } - if (dup) { - toast("Этот участник уже зарегистрирован", "warn"); + + var rec = { name: name, lastname: lastname, phone: phone, status: "waiting", date: todayStr() }; + + function finished(savedRec) { + rec = savedRec || rec; + participants.unshift(rec); + reindex(); + form.hidden = true; + done.hidden = false; + $("#regdone-text").textContent = + name + ", вы в списке участников. Администратор подтвердит участие до 18 сентября."; + toast("Регистрация принята", "ok"); + renderAdmin(); + } + + if (usingLocal) { + finished({ id: nextId, name: name, lastname: lastname, phone: phone, status: "waiting", date: todayStr() }); + lsSave(participants); return; } - var rec = { - id: nextId++, - name: name, - lastname: lastname, - phone: phone, - status: "waiting", - date: todayStr() - }; - participants.unshift(rec); - save(participants); - - form.hidden = true; - done.hidden = false; - $("#regdone-text").textContent = - name + ", вы в списке участников. Администратор подтвердит участие до 18 сентября."; - toast("Регистрация принята", "ok"); + apiCreate(rec).then(function (json) { + finished(json && json.record ? json.record : { id: nextId, name: name, lastname: lastname, phone: phone, status: "waiting", date: todayStr() }); + }).catch(function () { + usingLocal = true; + finished({ id: nextId, name: name, lastname: lastname, phone: phone, status: "waiting", date: todayStr() }); + lsSave(participants); + }); }); - function flashField(input, msg) { - input.focus(); - toast(msg, "warn"); - } - $("#regdone-again").addEventListener("click", function () { form.reset(); done.hidden = true; @@ -174,10 +219,8 @@ } function renderAdmin() { - /* навигация-счётчик */ $("#nav-count").textContent = participants.length; - /* KPI */ var total = participants.length; var confirmed = 0, waiting = 0; for (var i = 0; i < participants.length; i++) { @@ -187,19 +230,16 @@ $("#kpi-confirmed").textContent = confirmed; $("#kpi-waiting").textContent = waiting; - /* фильтр-чипы */ var chips = document.querySelectorAll(".kt-ai-filter-chip"); for (var j = 0; j < chips.length; j++) { chips[j].setAttribute("data-active", chips[j].getAttribute("data-status") === statusFilter ? "true" : "false"); } - /* таблица */ var list = filtered(); - var tbody = $("#adm-tbody"); var html = ""; for (var k = 0; k < list.length; k++) { var p = list[k]; - html += "" + + html += '" + '' + esc(p.lastname) + " " + esc(p.name) + "" + 'ДЦБ Астана' + '' + esc(p.phone) + "" + @@ -208,9 +248,8 @@ '' + ""; } - tbody.innerHTML = html; + $("#adm-tbody").innerHTML = html; - /* пустое состояние */ var empty = $("#adm-empty"); if (list.length === 0) { empty.hidden = false; @@ -224,14 +263,12 @@ } } - /* клики по строке и по «открыть» */ $("#adm-tbody").addEventListener("click", function (ev) { var tr = ev.target.closest("tr[data-id]"); if (!tr) return; openDrawer(parseInt(tr.getAttribute("data-id"), 10)); }); - /* поиск + фильтры */ $("#adm-search").addEventListener("input", function () { searchQuery = this.value.trim().toLowerCase(); renderAdmin(); @@ -246,9 +283,13 @@ /* ── Drawer ── */ var drawer = $("#drawer"); + function findRec(id) { + for (var i = 0; i < participants.length; i++) if (participants[i].id === id) return participants[i]; + return null; + } + function openDrawer(id) { - var p = null; - for (var i = 0; i < participants.length; i++) if (participants[i].id === id) { p = participants[i]; break; } + var p = findRec(id); if (!p) return; selectedId = id; @@ -259,7 +300,6 @@ $("#d-date").textContent = p.date; $("#d-status").innerHTML = statusPill(p.status); $("#d-confirm").hidden = p.status === "confirmed"; - $("#d-confirm").setAttribute("data-tone", p.status === "confirmed" ? "ok" : ""); drawer.hidden = false; document.documentElement.classList.add("kt-ai-scroll-locked"); @@ -277,23 +317,23 @@ if (e.key === "Escape" && !drawer.hidden) closeDrawer(); }); + /* Статусы (подтверждение, удаление) — только для статической версии: + на серверной стороне запись не теряется, статус «ждёт» сохранится. */ $("#d-confirm").addEventListener("click", function () { - var p = null; - for (var i = 0; i < participants.length; i++) if (participants[i].id === selectedId) { p = participants[i]; break; } + var p = findRec(selectedId); if (!p) return; p.status = "confirmed"; - save(participants); + if (usingLocal) lsSave(participants); toast(p.lastname + " " + p.name + ": участие подтверждено", "ok"); openDrawer(p.id); }); $("#d-unlink").addEventListener("click", function () { - var p = null; - for (var i = 0; i < participants.length; i++) if (participants[i].id === selectedId) { p = participants[i]; break; } + var p = findRec(selectedId); if (!p) return; var idx = participants.indexOf(p); - participants.splice(idx, 1); - save(participants); + if (idx !== -1) participants.splice(idx, 1); + if (usingLocal) lsSave(participants); closeDrawer(); toast(p.lastname + " " + p.name + " убран из списка"); }); @@ -317,7 +357,10 @@ var adminLink = document.querySelector(".tb-admin-link-strong"); if (adminLink) adminLink.addEventListener("click", function () { showView("admin"); }); - /* стартовый вид по location.hash */ - if (location.hash === "#admin" || location.hash === "#view-admin") showView("admin"); - else showView("participant"); + /* ── Старт: загрузить список (с сервера; при недоступности — локальный) ── */ + loadAll().then(function () { + renderAdmin(); + if (location.hash === "#admin" || location.hash === "#view-admin") showView("admin"); + else showView("participant"); + }); })(); diff --git a/server.js b/server.js new file mode 100644 index 0000000..5eb96b8 --- /dev/null +++ b/server.js @@ -0,0 +1,61 @@ +// Реестр участников тимбилдинга: GET/POST /api/participants + статика проекта. +const http = require("http"); +const fs = require("fs"); +const path = require("path"); + +const PORT = process.env.PORT || 3000; +const DATA = path.join(__dirname, "data.json"); + +function readItems() { + try { return JSON.parse(fs.readFileSync(DATA, "utf8")); } catch (_) { return []; } +} +function writeItems(items) { + fs.writeFileSync(DATA, JSON.stringify(items, null, 2)); +} +function body(req) { + return new Promise((resolve) => { + let s = ""; + req.on("data", (c) => (s += c)); + req.on("end", () => { try { resolve(JSON.parse(s || "{}")); } catch (_) { resolve({}); } }); + }); +} + +const MIME = { + ".html": "text/html", ".css": "text/css", ".js": "application/javascript", + ".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png" +}; + +http.createServer(async (req, res) => { + const url = req.url.split("?")[0]; + + if (req.method === "GET" && url === "/api/participants") { + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end(JSON.stringify(readItems())); + } + if (req.method === "POST" && url === "/api/participants") { + const it = await body(req); + const items = readItems(); + const rec = { + id: Date.now(), + name: String(it.name || "").trim(), + lastname: String(it.lastname || "").trim(), + phone: String(it.phone || "").trim(), + status: "waiting", + date: it.date || new Date().toISOString().slice(0, 10) + }; + items.push(rec); + writeItems(items); + res.writeHead(201, { "Content-Type": "application/json" }); + return res.end(JSON.stringify({ ok: true, record: rec })); + } + + let file = url === "/" ? "/index.html" : url; + const full = path.join(__dirname, decodeURIComponent(file)); + if (full.startsWith(__dirname) && fs.existsSync(full) && fs.statSync(full).isFile()) { + const ext = path.extname(full); + res.writeHead(200, { "Content-Type": (MIME[ext] || "application/octet-stream") + "; charset=utf-8" }); + return res.end(fs.readFileSync(full)); + } + res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("Not found"); +}).listen(PORT, () => console.log("Регистрация тимбилдинга на порту " + PORT));