Kyiv Electricity Availability Survey – Multi-Step Form with Telegram Integration

A civic-focused, full-stack Next.js application that collects real-time electricity availability data across Kyiv and delivers submissions instantly to organizers via Telegram Bot API. Built with Next.js 16, TypeScript, Zod validation, and a multi-step carousel interface, the project demonstrates modern architecture, client-side validation strategy, secure API handling, and production deployment.
The Kyiv Electricity Availability Survey is a single-page web application designed to collect structured, real-time data about electricity availability across Kyiv and surrounding districts. The goal of the project is to demonstrate how modern web technologies can be used to build small but impactful civic tools. The application combines a guided multi-step user experience with backend API routes and Telegram Bot integration to deliver submissions instantly for review. The project emphasizes structured validation, clean architecture, user-focused design, and secure third-party API integration.
The application is a single-page survey platform designed to collect structured data about electricity access across Kyiv districts.
Users are guided through a seven-step carousel-style form where they provide:
- Their district
- Average hours of electricity per day
- Whether the situation has improved, stayed the same, or worsened
- Whether outage schedules are predictable
- Optional comments
- Contact information for follow-up
Once submitted, responses are processed through a secure server-side API route and optionally delivered to a configured Telegram channel for immediate review. The system was designed to be lightweight, mobile-friendly, and fast to deploy during an ongoing crisis.

- Next.js 16 (App Router)
- React 19
- TypeScript
- Tailwind CSS 4
- Google Fonts (Playfair Display, Inter)
- Zod
- Telegram Bot API
- Multi-step carousel form – 7 slides with transitions
- District selection – 23 Kyiv districts (incl. Irpin, Hostomel, Boiarka, etc.)
- Survey questions – Hours per day, situation change (Improved / Same / Worse), schedule predictability
- Optional comments – Up to 2,000 characters
- Contact capture – First name, last name, email, phone (for follow-up)
- Client-side validation – Zod-based validation per slide before advancing
- Real-time Telegram notifications – Submissions sent to a configured chat when enabled
- Thank you screen – Confirmation plus option to submit another response
- Ukraine-themed design – Blue (#0057B7) and gold (#FFD700) palette, Ukrainian flag accent
- Responsive layout – Works across mobile and desktop
app/
├── page.tsx → Home (renders ElectricitySurvey)
├── layout.tsx → Root layout, fonts, metadata
├── globals.css → Tailwind, CSS variables
└── api/
├── submit-survey/ → POST: receives form data, sends to Telegram
└── telegram/chat-id/ → GET: helper to discover chat ID for setup
components/
├── ElectricitySurvey.tsx → Main form orchestrator (state, validation, flow)
├── SlideContent.tsx → Renders slide-specific form fields
├── SurveyCard.tsx → Card shell + CarouselNavigation
├── CarouselNavigation.tsx→ Prev/Next + dot indicators
├── ThankYouView.tsx → Success state
├── BackgroundImage.tsx → Hero background
└── slides.tsx → Slide config + district list
lib/
├── schemas.ts → Zod survey + contact schemas
├── carouselUtils.ts → Initial state, Zod error flattening
└── telegram.ts → Telegram API helpers, HTML escaping, notifications
types/
└── index.ts → SurveyFormData, ContactFormData, Slide, FormErrorsconst TELEGRAM_API = "https://api.telegram.org";
export type TelegramConfig = {
botToken: string;
chatId: string;
enabled: boolean;
};
export type SendMessageOptions = {
parse_mode?: "HTML" | "Markdown";
};
export type SendResult = { ok: boolean; error?: string };
/** Escape HTML to prevent injection in Telegram messages */
export function escapeHtml(text: string): string {
return text
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
export function getTelegramConfig(): TelegramConfig {
const botToken = process.env.TELEGRAM_BOT_TOKEN ?? "";
const chatId = process.env.TELEGRAM_CHAT_ID ?? "";
const enabled =
process.env.TELEGRAM_ENABLED === "true" ||
process.env.TELEGRAM_ENABLED === "1";
return { botToken, chatId, enabled };
}
/** Check if Telegram is properly configured and enabled */
export function isTelegramEnabled(): boolean {
const { botToken, chatId, enabled } = getTelegramConfig();
return !!enabled && !!botToken && !!chatId;
}
export async function sendTelegramMessage(
text: string,
config?: TelegramConfig,
options?: SendMessageOptions,
): Promise<SendResult> {
const cfg = config ?? getTelegramConfig();
const { botToken, chatId, enabled } = cfg;
if (!enabled || !botToken || !chatId) {
return { ok: false, error: "Telegram is not configured or disabled" };
}
const parseMode = options?.parse_mode ?? "HTML";
try {
const res = await fetch(`${TELEGRAM_API}/bot${botToken}/sendMessage`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
chat_id: chatId,
text,
parse_mode: parseMode,
}),
});
const data = (await res.json()) as { ok: boolean; description?: string };
if (!data.ok) {
return {
ok: false,
error: data.description ?? "Unknown Telegram API error",
};
}
return { ok: true };
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to send Telegram message";
return { ok: false, error: message };
}
}
export type SurveyNotificationPayload = {
surveyData: {
district: string;
hoursPerDay: string;
situationChange: string;
predictableSchedule: string;
comments: string;
};
contactData: {
firstName: string;
lastName: string;
email: string;
phone: string;
};
};
/** Format and send the electricity survey notification */
export async function sendSurveyNotification(
payload: SurveyNotificationPayload,
config?: TelegramConfig,
): Promise<SendResult> {
const { surveyData, contactData } = payload;
const lines = [
"🔌 <b>Kyiv Electricity Availability Survey</b>",
"<b>Contact Information</b>",
"<b>Name:</b> " +
escapeHtml(
[contactData.firstName, contactData.lastName]
.filter(Boolean)
.join(" ") || "—",
),
"<b>Email:</b> " + escapeHtml(contactData.email || "—"),
"<b>Phone:</b> " + escapeHtml(contactData.phone || "—"),
"",
"<b>District:</b> " + escapeHtml(surveyData.district),
"<b>Hours per day:</b> " + escapeHtml(surveyData.hoursPerDay),
"<b>Situation change:</b> " + escapeHtml(surveyData.situationChange || "—"),
"<b>Timetable schedule:</b> " +
escapeHtml(surveyData.predictableSchedule || "—"),
surveyData.comments
? "<b>Comments:</b> " + escapeHtml(surveyData.comments)
: "",
"",
].filter(Boolean);
const text = lines.join("\n");
return sendTelegramMessage(text, config, { parse_mode: "HTML" });
}
export async function getTelegramChatId(): Promise<{
ok: boolean;
chatId?: string;
error?: string;
}> {
const { botToken, enabled } = getTelegramConfig();
if (!enabled || !botToken) {
return {
ok: false,
error: "TELEGRAM_BOT_TOKEN or TELEGRAM_ENABLED not set",
};
}
try {
const res = await fetch(`${TELEGRAM_API}/bot${botToken}/getUpdates`);
const data = (await res.json()) as {
ok: boolean;
result?: Array<{
message?: { chat?: { id: number; type: string; username?: string } };
}>;
description?: string;
};
if (!data.ok) {
return {
ok: false,
error: data.description ?? "Failed to fetch updates",
};
}
const updates = data.result ?? [];
const lastWithChat = [...updates]
.reverse()
.find((u) => u.message?.chat?.id);
if (!lastWithChat?.message?.chat?.id) {
return {
ok: false,
error:
"No messages found. Send a message to @kyiv_Electricity_Survay_bot first, then try again.",
};
}
return { ok: true, chatId: String(lastWithChat.message.chat.id) };
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to fetch chat ID";
return { ok: false, error: message };
}
}
One of the main challenges was building a multi-step form that validates progressively without overwhelming users. This was solved using Zod schemas with per-slide validation and controlled navigation logic. Another challenge involved securely integrating the Telegram Bot API. All requests are handled server-side via Next.js API routes, and sensitive credentials are stored in environment variables. User input is sanitized before being transmitted externally. Because infrastructure conditions were unpredictable, the system was designed to remain fully functional even if the Telegram integration is disabled. In that case, submissions still complete successfully, ensuring resilience.

import { NextResponse } from "next/server";
import {
sendSurveyNotification,
getTelegramConfig,
} from "@/lib/telegram";
type SurveyPayload = {
surveyData: {
district: string;
hoursPerDay: string;
situationChange: string;
predictableSchedule: string;
comments: string;
};
contactData: {
firstName: string;
lastName: string;
email: string;
phone: string;
};
};
export async function POST(request: Request) {
const config = getTelegramConfig();
if (!config.enabled) {
return NextResponse.json(
{ success: true, message: "Survey recorded (Telegram disabled)" },
{ status: 200 }
);
}
let payload: SurveyPayload;
try {
payload = await request.json();
} catch {
return NextResponse.json(
{ success: false, error: "Invalid JSON body" },
{ status: 400 }
);
}
const { surveyData, contactData } = payload;
if (!surveyData || !contactData) {
return NextResponse.json(
{ success: false, error: "Missing surveyData or contactData" },
{ status: 400 }
);
}
const result = await sendSurveyNotification(payload, config);
if (!result.ok) {
return NextResponse.json(
{ success: false, error: result.error ?? "Failed to send to Telegram" },
{ status: 500 }
);
}
return NextResponse.json({ success: true });
}

- Ukraine identity – Blue and gold, Ukrainian flag in the card, Kyiv-focused copy
- Low cognitive load – One question per slide, clear labels and required markers
- Smooth transitions – 800ms fade between slides
- Mobile-friendly – Responsive grid, touch targets, scrollable contact step
- Clear feedback – Inline validation, red borders on invalid fields, success state after submit
- Hosting: Netlify
- Build: next build (Next.js production build)
- Server-side API routes to protect sensitive credentials
- Lightweight validation without heavy form libraries
The Kyiv Electricity Availability Survey is a civic-tech application built in response to real infrastructure disruption during wartime conditions in Ukraine. It combines modern full-stack web development practices with practical problem-solving, demonstrating the ability to design, architect, and deploy a production-ready application under meaningful real-world constraints. Beyond technical implementation, the project reflects product thinking, resilience-focused design, and the application of software engineering to urgent societal needs.
This project is live at https://electricysurverybot.netlify.app/