wedding invitation with RSVP form

This commit is contained in:
Kaziyeva.S 2026-09-23 10:22:14 +00:00
parent aefeac1880
commit 658969224c
5 changed files with 189 additions and 198 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
data.json
.vibe42-run.log
.vibe42-run.pid

View File

@ -4,8 +4,8 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Где поступать — навигатор специальностей</title>
<meta name="description" content="Выбирай специальность, сравнивай вузы, баллы и стипендии — и знай, куда подать документы.">
<title>Приглашение на свадьбу — 1 ноября</title>
<meta name="description" content="Приглашение на свадьбу: 1 ноября, ресторан «Хан Шатır». Подтвердите, пожалуйста, ваше присутствие.">
<link rel="stylesheet" href="design-system/kt-ai-fonts.css">
<link rel="stylesheet" href="design-system/kt-ai-tokens.css">
<link rel="stylesheet" href="design-system/kt-ai-components.css">
@ -14,189 +14,131 @@
<script src="design-system/icons/kt-ai-icons.js"></script>
<style>
a.kt-ai-btn { text-decoration: none; }
.kt-ai-hero-mark { display: inline; padding: 2px 10px; margin: 0 2px; background: var(--vibe-tint); border-radius: 8px; }
.kt-ai-searchrow { display: flex; gap: var(--kt-ai-space-3); margin-top: var(--kt-ai-space-7); max-width: 560px; margin-left: auto; margin-right: auto; flex-wrap: wrap; }
.kt-ai-searchrow .kt-ai-field { flex: 1 1 240px; margin: 0; }
.kt-ai-searchrow .kt-ai-input { width: 100%; }
.kt-ai-searchrow .kt-ai-btn { align-self: flex-end; }
.kt-ai-prog { height: 6px; border-radius: 99px; background: var(--kt-ai-border); overflow: hidden; margin-top: var(--kt-ai-space-4); }
.kt-ai-prog-in { height: 100%; background: var(--vibe-accent); border-radius: 99px; }
.kt-ai-prog-l { display: flex; justify-content: space-between; font-size: 12px; color: var(--kt-ai-fg-muted); margin-bottom: 6px; }
.kt-ai-table { width: 100%; border-collapse: collapse; font-size: 14px; }
.kt-ai-table th { text-align: left; font-weight: 500; color: var(--kt-ai-fg-muted); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; padding: 10px 12px; border-bottom: 1px solid var(--kt-ai-border); }
.kt-ai-table td { padding: 14px 12px; border-bottom: 1px solid var(--kt-ai-border); vertical-align: middle; }
.kt-ai-table tr:last-child td { border-bottom: none; }
.kt-ai-table .num { font-variant-numeric: tabular-nums; white-space: nowrap; }
.kt-ai-scroll { overflow-x: auto; border: 1px solid var(--kt-ai-border); border-radius: 16px; background: var(--kt-ai-card-bg); }
.kt-ai-faq { border: 1px solid var(--kt-ai-border); border-radius: 10px; background: var(--kt-ai-card-bg); padding: 16px 18px; }
.kt-ai-faq + .kt-ai-faq { margin-top: var(--kt-ai-space-4); }
.kt-ai-faq h3 { margin: 0 0 6px; font-size: 15px; }
.kt-ai-faq p { margin: 0; color: var(--kt-ai-fg-muted); font-size: 14px; }
.kt-ai-note { color: var(--kt-ai-fg-muted); font-size: 13px; margin-top: var(--kt-ai-space-4); }
.kt-ai-tile-top { display: flex; justify-content: space-between; align-items: center; gap: var(--kt-ai-space-2); }
.kt-ai-invite-date { font-size: 13px; letter-spacing: .08em; text-transform: uppercase; color: var(--kt-ai-fg-muted); margin-bottom: var(--kt-ai-space-4); display: flex; gap: 10px; align-items: center; justify-content: center; flex-wrap: wrap; }
.kt-ai-invite-date b { font-size: 15px; color: var(--kt-ai-fg); text-transform: none; letter-spacing: 0; }
.kt-ai-invite-names { display: flex; align-items: center; justify-content: center; gap: var(--kt-ai-space-4); margin-top: var(--kt-ai-space-4); }
.kt-ai-invite-am { font-size: 120px; line-height: 1; color: var(--vibe-accent); font-weight: 300; }
.kt-ai-hero-name { font-size: clamp(40px, 7vw, 72px); font-weight: 400; letter-spacing: -.035em; }
.kt-ai-countdown { display: flex; gap: var(--kt-ai-space-3); justify-content: center; margin-top: var(--kt-ai-space-8); flex-wrap: wrap; }
.kt-ai-cd-cell { min-width: 72px; text-align: center; border: 1px solid var(--kt-ai-border); border-radius: 10px; background: var(--kt-ai-card-bg); padding: 12px 10px; }
.kt-ai-cd-num { font-size: 26px; font-variant-numeric: tabular-nums; color: var(--kt-ai-fg); }
.kt-ai-cd-lab { font-size: 11px; color: var(--kt-ai-fg-muted); margin-top: 4px; }
.kt-ai-form-card { max-width: 460px; margin: 0 auto; padding: var(--kt-ai-space-8); }
.kt-ai-form-card .kt-ai-field { margin-bottom: var(--kt-ai-space-4); }
.kt-ai-form-card .kt-ai-btn { width: 100%; }
.kt-ai-form-msg { margin-top: var(--kt-ai-space-4); text-align: center; font-size: 14px; color: var(--kt-ai-fg-muted); min-height: 20px; }
.kt-ai-detail-icon span { display: inline-flex; }
</style>
</head>
<body>
<header class="kt-ai-pagehead">
<div class="kt-ai-wrap kt-ai-pagehead-in">
<strong>Где поступать</strong>
<strong>Мы женимся!</strong>
<span class="kt-ai-spacer"></span>
<a class="kt-ai-btn" data-variant="ghost" href="#specialties">Специальности</a>
<a class="kt-ai-btn" data-variant="primary" href="#universities">Список вузов</a>
<a class="kt-ai-btn" data-variant="primary" href="#rsvp">Подтвердить</a>
</div>
</header>
<section class="kt-ai-hero" data-align="center">
<div class="kt-ai-wrap">
<h1>Знай, <span class="kt-ai-hero-mark">куда</span> подать документы</h1>
<p class="kt-ai-hero-sub">Сравни специальность, баллы, бюджетные места и стипендии — без путеводителя «спроси у старших».</p>
<div class="kt-ai-searchrow">
<div class="kt-ai-field">
<label for="f-specialty">Специальность</label>
<input class="kt-ai-input" id="f-specialty" type="text" placeholder="IT, медицина, юриспруденция…">
</div>
<div class="kt-ai-field">
<label for="f-score">Твой балл</label>
<input class="kt-ai-input" id="f-score" type="number" min="0" max="200" placeholder="напр. 150">
</div>
<button class="kt-ai-btn" data-variant="primary" id="btn-search" type="button">Найти вариант</button>
<div class="kt-ai-invite-date">
<span class="kt-ai-chip">Приглашение</span>
<span>суббота</span>
<b>1 ноября 2026</b>
</div>
<p class="kt-ai-note" id="search-note" hidden>Ищем подходящие варианты…</p>
</div>
</section>
<section id="specialties" class="kt-ai-section" data-divider="true">
<div class="kt-ai-wrap">
<div class="kt-ai-section-head">
<h2>Как выбрать специальность</h2>
<p>Пройди по шагам, и список вариантов сузится с сотен до трёх-пяти.</p>
</div>
<div class="kt-ai-grid" style="margin-top:var(--kt-ai-space-9)">
<article class="kt-ai-tile">
<div class="kt-ai-tile-top">
<span class="kt-ai-tile-icon"><svg class="kt-icon" aria-hidden="true"><use href="#search"></use></svg></span>
<span class="kt-ai-chip">Шаг 1</span>
</div>
<h3>Что не надоест через полгода</h3>
<p>Список интересов и предметов-любимцев. Честнее любой анкеты «кем ты хочешь стать».</p>
</article>
<article class="kt-ai-tile">
<div class="kt-ai-tile-top">
<span class="kt-ai-tile-icon"><svg class="kt-icon" aria-hidden="true"><use href="#chart"></use></svg></span>
<span class="kt-ai-chip">Шаг 2</span>
</div>
<h3>Проверь баллы</h3>
<p>Смотри проходные баллы 2025 года, а не «примерно сто с хвостиком». Здесь всё по цифрам.</p>
<div class="kt-ai-prog"><div class="kt-ai-prog-in" id="prog-avg" style="width:75%"></div></div>
<div class="kt-ai-prog-l"><span>средний балл по таблице</span><span id="prog-avg-num">75</span></div>
</article>
<article class="kt-ai-tile">
<div class="kt-ai-tile-top">
<span class="kt-ai-tile-icon"><svg class="kt-icon" aria-hidden="true"><use href="#building"></use></svg></span>
<span class="kt-ai-chip">Шаг 3</span>
</div>
<h3>Выбери город и формат</h3>
<p>Столица, родной город, бюджет или платно, стипендия, общежитие — всё в одном месте.</p>
</article>
<article class="kt-ai-tile">
<div class="kt-ai-tile-top">
<span class="kt-ai-tile-icon"><svg class="kt-icon" aria-hidden="true"><use href="#checklist"></use></svg></span>
<span class="kt-ai-chip">Шаг 4</span>
</div>
<h3>Собери документы</h3>
<p>Чек-лист: результат экзамена, направления, приоритеты. Без дедлайна в последний день.</p>
</article>
<h1 class="kt-ai-invite-names">
<span class="kt-ai-hero-name">Айсулу</span>
<span class="kt-ai-invite-am">&nbsp;и&nbsp;</span>
<span class="kt-ai-hero-name">Ержан</span>
</h1>
<p class="kt-ai-hero-sub">просят вас разделить с ними этот день — банкет начнётся в 16:00.</p>
<div class="kt-ai-countdown" id="countdown"></div>
<div class="kt-ai-hero-actions" style="margin-top:var(--kt-ai-space-8)">
<a class="kt-ai-btn" data-variant="primary" data-size="lg" href="#rsvp">Подтвердить визит</a>
<a class="kt-ai-btn" data-variant="ghost" data-size="lg" href="#details">Подробнее</a>
</div>
</div>
</section>
<section id="universities" class="kt-ai-section" data-divider="true">
<section id="details" class="kt-ai-section" data-divider="true">
<div class="kt-ai-wrap">
<div class="kt-ai-section-head">
<h2>Вузы и проходные баллы</h2>
<p>Примерные цифры 2025 года: минимальный проходной балл, бюджетные места и стипендия.</p>
</div>
<div class="kt-ai-scroll" style="margin-top:var(--kt-ai-space-7)">
<table class="kt-ai-table" id="uni-table">
<thead>
<tr>
<th>Вуз</th>
<th>Специальность</th>
<th>Город</th>
<th class="num">Мин. балл</th>
<th class="num">Бюджет</th>
<th class="num">Стипендия</th>
</tr>
</thead>
<tbody id="uni-body"></tbody>
</table>
</div>
<p class="kt-ai-note" id="uni-note">Показаны все варианты.</p>
</div>
</section>
<section class="kt-ai-section" data-divider="true">
<div class="kt-ai-wrap">
<div class="kt-ai-section-head">
<h2>Что дальше</h2>
<p>Короткий маршрут на лето — что и когда сделать.</p>
<h2>Где и когда</h2>
<p>Всё в одном месте — адрес, время и что взять с собой.</p>
</div>
<div class="kt-ai-grid" data-cols="3" style="margin-top:var(--kt-ai-space-9)">
<article class="kt-ai-tile">
<h3>Май — выбор</h3>
<p>Три варианта по приоритету. Не «все 10 вузов сразу», а список, с которым спокойно.</p>
<div class="kt-ai-detail-icon kt-ai-tile-icon"><svg class="kt-icon" aria-hidden="true"><use href="#pin"></use></svg></div>
<h3>Ресторан «Хан Шатır»</h3>
<p>Укажите полный адрес и карту, как только уточните — например, «г. Алматы, ул. ТОО «Хан», 5», с парковкой у входа.</p>
</article>
<article class="kt-ai-tile">
<h3>Июнь — документы</h3>
<p>Подача в приёмную комиссию, подтверждение документов и направление приоритетов.</p>
<div class="kt-ai-detail-icon kt-ai-tile-icon"><svg class="kt-icon" aria-hidden="true"><use href="#clock"></use></svg></div>
<h3>16:00 — банкет</h3>
<p>Прошу не опаздывать: торжественная часть проходит до 18:00, банкет до 23:00.</p>
</article>
<article class="kt-ai-tile">
<h3>Июль — ответ</h3>
<p>Дедлайн подачи заявления на зачисление обычно в конце июля. Проверь дважды дату.</p>
<div class="kt-ai-detail-icon kt-ai-tile-icon"><svg style="width:18px;height:18px"><svg class="kt-icon" aria-hidden="true"><use href="#users"></use></svg></div>
<h3>Дресс-код</h3>
<p>Смарт-кэжуал / вечерний. Приходите в удобной обуви — будет танцевать.</p>
</article>
</div>
</div>
</section>
<section class="kt-ai-section" data-divider="true">
<div class="kt-ai-wrap">
<section id="rsvp" class="kt-ai-section" data-divider="true">
<div class="kt-ai-wrap" data-width="narrow">
<div class="kt-ai-section-head">
<h2>Частые вопросы</h2>
<h2>Подтвердите визит</h2>
<p>До 25 октября, пожалуйста — ресторану нужно знать точное количество гостей.</p>
</div>
<div style="margin-top:var(--kt-ai-space-7)">
<div class="kt-ai-faq">
<h3>Можно ли поступить по 2–3 специальностям?</h3>
<p>Да, направление можно заявить в нескольких вузах одновременно. Приоритет отдаётся первому варианту, по которому пойдёшь на зачисление.</p>
</div>
<div class="kt-ai-faq">
<h3>Балл ниже проходного — ничего?</h3>
<p>Посмотри платное направление, смежные специальности и вузы из других городов: там проходной часто ниже на 10–20 баллов.</p>
</div>
<div class="kt-ai-faq">
<h3>Сколько будет стипендия?</h3>
<p>Социальная стипендия плюс академическая. Размер зависит от вуза и курса — в таблице указана базовая цифра на начало обучения.</p>
</div>
<div class="kt-ai-faq">
<h3>А если не попаду никуда?</h3>
<p>Останутся допнабор и платные направления. Это нормальная часть сезона, а не конец истории.</p>
</div>
<div class="kt-ai-card kt-ai-form-card" style="margin-top:var(--kt-ai-space-7)">
<form id="rsvp-form">
<div class="kt-ai-field">
<label for="r-name">Имя (и фамилия, если удобно)</label>
<input class="kt-ai-input" id="r-name" name="name" type="text" required placeholder="Айгерим Сериккызы">
</div>
<div class="kt-ai-field">
<label for="r-guests">Вы и ещё сколько гостей?</label>
<select class="kt-ai-select" id="r-guests" name="guests">
<option value="1">1 — я</option>
<option value="2">Мы вдвоём (2)</option>
<option value="3">Мы и ещё кто-то (3)</option>
<option value="4">4 человека</option>
<option value="5">5 человек</option>
<option value="6">6 человек</option>
</select>
</div>
<div class="kt-ai-field">
<label for="r-attendance">Сможете ли вы прийти?</label>
<select class="kt-ai-select" id="r-attendance" name="attendance">
<option value="yes">Да, с радостью приду</option>
<option value="no">К сожалению, не смогу</option>
</select>
</div>
<div class="kt-ai-field">
<label for="r-note">Комментарий (необязательно)</label>
<textarea class="kt-ai-textarea" id="r-note" name="note" rows="2" placeholder="Аллергии, пожелания по столу, любимая песня на танцах…"></textarea>
</div>
<button class="kt-ai-btn" data-variant="primary" data-size="lg" type="submit">Отправить</button>
<p class="kt-ai-form-msg" id="rsvp-msg"></p>
</form>
</div>
</div>
</section>
<section id="contacts" class="kt-ai-cta">
<section class="kt-ai-cta">
<div class="kt-ai-wrap">
<h2>Остался вопрос по конкретной ситуации?</h2>
<p>Напиши — разберём твой балл, город и интересы, и подскажем 2–3 реальных варианта.</p>
<a class="kt-ai-btn" data-variant="primary" data-size="lg" href="https://telegram.me">Написать в Telegram</a>
<h2>Ждём вас 1 ноября!</h2>
<p>Если появятся вопросы — напишите: +7 700 000 00 00 (организатор). До встречи!</p>
</div>
</section>
<footer class="kt-ai-wrap kt-ai-pagefoot">
<span>© 2026 Где поступать</span>
<span>1 ноября 2026 · ресторан «Хан Шатır»</span>
<span class="kt-ai-spacer"></span>
<span>Данные примерные — сверься с официальным сайтом вуза</span>
<span>С любовью</span>
</footer>
<script src="script.js"></script>

6
package.json Normal file
View File

@ -0,0 +1,6 @@
{
"name": "wedding-invite",
"private": true,
"type": "commonjs",
"scripts": { "start": "node server.js" }
}

100
script.js
View File

@ -1,61 +1,49 @@
// Данные примерные: их легко заменить на реальные (массив UNIVERSITIES).
const UNIVERSITIES = [
{ name: "КазНУ им. аль-Фараби", specialty: "Прикладная математика", city: "Алматы", min: 162, budget: 85, scholarship: 14500 },
{ name: "КазНУ им. аль-Фараби", specialty: "Высшая математика в финансах", city: "Алматы", min: 159, budget: 40, scholarship: 14500 },
{ name: "КазНУ им. аль-Фараби", specialty: "Математика и анализ данных", city: "Алматы", min: 156, budget: 55, scholarship: 14000 },
{ name: "Eurasian National University", specialty: "Высшая математика", city: "Астана", min: 152, budget: 35, scholarship: 13500 },
{ name: "Kazakh-British Technical University", specialty: "Высшая математика и моделирование", city: "Астана", min: 155, budget: 30, scholarship: 13500 },
{ name: "КазНТУ им. Т. Рысқұлова", specialty: "Высшая математика", city: "Алматы", min: 148, budget: 68, scholarship: 12500 },
{ name: "АУ «Сарыарка»", specialty: "Высшая математика", city: "Караганда", min: 138, budget: 25, scholarship: 10000 },
{ name: "ЮЗГУ", specialty: "Математика и статистика", city: "Костанай", min: 132, budget: 30, scholarship: 9500 },
];
// Пригласительный: обратный отсчёт + форма RSVP (отправляет в /api/rsvp)
const WEDDING_DATE = new Date("2026-11-01T16:00:00");
const body = document.getElementById("uni-body");
const note = document.getElementById("uni-note");
function renderTable(rows) {
if (!body) return;
body.innerHTML = "";
for (const r of rows) {
const tr = document.createElement("tr");
tr.innerHTML =
'<td>' + r.name + "</td>" +
'<td>' + r.specialty + "</td>" +
'<td>' + r.city + "</td>" +
'<td class="num">' + r.min + "</td>" +
'<td class="num">' + r.budget + "</td>" +
'<td class="num">' + r.scholarship.toLocaleString("ru-RU") + " тг</td>";
body.appendChild(tr);
const cd = document.getElementById("countdown");
if (cd) {
function tick() {
const diff = WEDDING_DATE - new Date();
if (diff <= 0) {
cd.innerHTML = '<span class="kt-ai-chip">Сегодня наш день!</span>';
return;
}
const d = Math.floor(diff / 86400000);
const h = Math.floor(diff / 3600000) % 24;
const m = Math.floor(diff / 60000) % 60;
const s = Math.floor(diff / 1000) % 60;
cd.innerHTML =
cell(d, "дней") + cell(h, "часов") + cell(m, "минут") + cell(s, "секунд");
}
note.textContent = rows.length
? "Показано вариантов: " + rows.length + "."
: "Ничего не нашлось — попробуй другой запрос или сбрось балл.";
function cell(n, lab) {
return '<div class="kt-ai-cd-cell"><div class="kt-ai-cd-num">' + n + "</div><div class=\"kt-ai-cd-lab\">" + lab + "</div></div>";
}
tick();
setInterval(tick, 1000);
}
function fmt(v) {
return v == null ? "" : String(v).trim().toLowerCase();
}
document.getElementById("btn-search")?.addEventListener("click", function () {
const q = fmt(document.getElementById("f-specialty").value);
const score = parseInt(document.getElementById("f-score").value, 10);
let rows = UNIVERSITIES.filter(function (u) {
const okQ = !q || u.specialty.toLowerCase().includes(q) || u.name.toLowerCase().includes(q);
const okScore = isNaN(score) || u.min <= score;
return okQ && okScore;
const form = document.getElementById("rsvp-form");
const msg = document.getElementById("rsvp-msg");
if (form) {
form.addEventListener("submit", async function (e) {
e.preventDefault();
msg.textContent = "Отправляю…";
try {
const body = Object.fromEntries(new FormData(form).entries());
const res = await fetch("api/rsvp", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error("server " + res.status);
const attendance = form.attendance.value;
msg.textContent = attendance === "yes"
? "Спасибо! До встречи 1 ноября."
: "Спасибо, что сообщили. Мы вас поздравим на расстоянии.";
form.reset();
} catch (err) {
msg.textContent = "Не получилось отправить. Попробуйте ещё раз или напишите организатору напрямую.";
}
});
if (!isNaN(score)) rows = rows.slice().sort(function (a, b) { return a.min - b.min; });
renderTable(rows);
document.getElementById("search-note").hidden = true;
document.getElementById("universities").scrollIntoView({ behavior: "smooth" });
});
(function () {
const avg = Math.round(UNIVERSITIES.reduce(function (s, u) { return s + u.min; }, 0) / UNIVERSITIES.length);
const el = document.getElementById("prog-avg");
const num = document.getElementById("prog-avg-num");
if (el) el.style.width = Math.min(100, Math.round(avg / 2)) + "%";
if (num) num.textContent = avg;
})();
renderTable(UNIVERSITIES);
}

51
server.js Normal file
View File

@ -0,0 +1,51 @@
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 readAll() {
try { return JSON.parse(fs.readFileSync(DATA, "utf8")); } catch (_) { return { rsvps: [] }; }
}
function writeAll(d) {
fs.writeFileSync(DATA, JSON.stringify(d, 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({}); } });
});
}
http.createServer(async (req, res) => {
if (req.method === "POST" && req.url.split("?")[0] === "/api/rsvp") {
const item = await body(req);
const d = readAll();
d.rsvps.push({
id: Date.now(),
name: String(item.name || "").slice(0, 80),
guests: parseInt(item.guests, 10) || 1,
attendance: item.attendance === "no" ? "no" : "yes",
note: String(item.note || "").slice(0, 300),
createdAt: new Date().toISOString(),
});
writeAll(d);
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
return res.end(JSON.stringify({ ok: true }));
}
if (req.method === "GET" && req.url.split("?")[0] === "/api/rsvp") {
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
return res.end(JSON.stringify(readAll().rsvps));
}
let file = req.url === "/" ? "/index.html" : req.url.split("?")[0];
const full = path.join(__dirname, file);
if (full.startsWith(__dirname) && fs.existsSync(full) && fs.statSync(full).isFile()) {
const ext = path.extname(full);
const type = ext === ".css" ? "text/css" : ext === ".js" ? "application/javascript" : ext === ".svg" ? "image/svg+xml" : "text/html";
res.writeHead(200, { "Content-Type": type + "; charset=utf-8" });
return res.end(fs.readFileSync(full));
}
res.writeHead(404); res.end("Not found");
}).listen(PORT, () => console.log("Server on port " + PORT));