Add Telegram moderator application mini app
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 1m41s

This commit is contained in:
2026-11-01 10:21:05 +00:00
parent 05854818a0
commit 3afad02686
11 changed files with 753 additions and 17 deletions

View File

@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { createModeratorApplication } from "@/lib/moderator-applications";
import { verifyTelegramInitData } from "@/lib/telegram-webapp";
import { jsonBody } from "@/lib/json-body";
import { ServiceError } from "@/lib/service-error";
export const runtime = "nodejs";
const payload = z.object({
initData: z.string().min(1).max(4096),
username: z
.string()
.min(3)
.max(32)
.regex(/^[A-Za-z0-9_]+$/),
});
export async function POST(request: NextRequest) {
const botToken = process.env.TELEGRAM_BOT_TOKEN;
if (!botToken)
return NextResponse.json(
{ error: "The Telegram bot is not configured" },
{ status: 503 },
);
let decoded: unknown;
try {
decoded = await jsonBody(request);
} catch (error) {
if (error instanceof ServiceError)
return NextResponse.json({ error: error.message }, { status: error.status });
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
}
const input = payload.safeParse(decoded);
if (!input.success)
return NextResponse.json(
{
error:
"Use your MineGeo username (3–32 letters, numbers or underscores).",
},
{ status: 400 },
);
const telegramUser = verifyTelegramInitData(input.data.initData, botToken);
if (!telegramUser)
return NextResponse.json(
{
error:
"Telegram signature could not be verified. Open the form again from the MineGeo bot.",
},
{ status: 401 },
);
const result = await createModeratorApplication({
platform: "telegram",
platformUserId: telegramUser.id,
platformUsername: telegramUser.username,
requestedUsername: input.data.username,
origin: process.env.MINEGEO_URL ?? request.nextUrl.origin,
});
if (!result.ok)
return NextResponse.json({ error: result.message }, { status: 409 });
return NextResponse.json({ url: result.url });
}

View File

@@ -0,0 +1,277 @@
"use client";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type FormEvent,
} from "react";
interface TelegramUser {
id: number;
username?: string;
first_name?: string;
last_name?: string;
}
interface ThemeParams {
bg_color?: string;
text_color?: string;
hint_color?: string;
link_color?: string;
button_color?: string;
button_text_color?: string;
secondary_bg_color?: string;
}
interface TelegramWebApp {
initData: string;
initDataUnsafe: { user?: TelegramUser };
colorScheme?: string;
themeParams?: ThemeParams;
ready: () => void;
expand: () => void;
close: () => void;
openLink: (url: string) => void;
setHeaderColor?: (color: string) => void;
setBackgroundColor?: (color: string) => void;
MainButton?: {
text: string;
color: string;
textColor: string;
isVisible: boolean;
setText: (text: string) => void;
show: () => void;
hide: () => void;
enable: () => void;
disable: () => void;
showProgress: (leaveActive?: boolean) => void;
hideProgress: () => void;
onClick: (handler: () => void) => void;
offClick: (handler: () => void) => void;
};
BackButton?: { show: () => void; hide: () => void; onClick: (handler: () => void) => void };
}
declare global {
interface Window {
Telegram?: { WebApp?: TelegramWebApp };
}
}
type Status = "loading" | "form" | "success" | "outside";
const TELEGRAM_SCRIPT = "https://telegram.org/js/telegram-web-app.js";
const BOT_LINK = "https://t.me/MineGeoModeratorBot";
function loadTelegramScript(): Promise<void> {
if (window.Telegram?.WebApp) return Promise.resolve();
return new Promise((resolve, reject) => {
const existing = document.querySelector<HTMLScriptElement>(
`script[src="${TELEGRAM_SCRIPT}"]`,
);
if (existing) {
existing.addEventListener("load", () => resolve(), { once: true });
existing.addEventListener("error", () => reject(new Error("load failed")), {
once: true,
});
return;
}
const script = document.createElement("script");
script.src = TELEGRAM_SCRIPT;
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error("load failed"));
document.head.appendChild(script);
});
}
export function ModeratorApplyApp() {
const [status, setStatus] = useState<Status>("loading");
const [username, setUsername] = useState("");
const [error, setError] = useState<string | null>(null);
const [confirmationUrl, setConfirmationUrl] = useState<string | null>(null);
const [theme, setTheme] = useState<ThemeParams>({});
const tgRef = useRef<TelegramWebApp | null>(null);
const submitRef = useRef<() => void>(() => {});
const submittingRef = useRef(false);
useEffect(() => {
let cancelled = false;
loadTelegramScript()
.then(() => {
if (cancelled) return;
const tg = window.Telegram?.WebApp;
if (!tg) {
setStatus("outside");
return;
}
tgRef.current = tg;
tg.ready();
tg.expand();
setTheme(tg.themeParams ?? {});
const background = tg.themeParams?.bg_color ?? "#0e1621";
tg.setBackgroundColor?.(background);
tg.setHeaderColor?.(tg.themeParams?.secondary_bg_color ?? background);
if (tg.initData) setStatus("form");
else setStatus("outside");
})
.catch(() => !cancelled && setStatus("outside"));
return () => {
cancelled = true;
};
}, []);
const submit = useCallback(async () => {
const tg = tgRef.current;
if (!tg?.initData || !/^[A-Za-z0-9_]{3,32}$/.test(username.trim()) || submittingRef.current) return;
submittingRef.current = true;
const mainButton = tg.MainButton;
setError(null);
mainButton?.disable();
mainButton?.showProgress(true);
try {
const response = await fetch("/api/moderator-apply", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ initData: tg.initData, username: username.trim() }),
});
const data = (await response.json().catch(() => null)) as {
url?: string;
error?: string;
} | null;
if (!response.ok || !data?.url) {
setError(data?.error ?? "Something went wrong. Please try again.");
return;
}
setConfirmationUrl(data.url);
setStatus("success");
mainButton?.hide();
} catch {
setError("Network error. Please try again.");
} finally {
submittingRef.current = false;
mainButton?.hideProgress();
mainButton?.enable();
}
}, [username]);
submitRef.current = submit;
useEffect(() => {
if (status !== "form") return;
const tg = tgRef.current;
const mainButton = tg?.MainButton;
if (!tg || !mainButton) return;
mainButton.text = "Submit application";
if (tg.themeParams?.button_color) mainButton.color = tg.themeParams.button_color;
if (tg.themeParams?.button_text_color)
mainButton.textColor = tg.themeParams.button_text_color;
const handler = () => submitRef.current();
mainButton.onClick(handler);
mainButton.show();
return () => {
mainButton.offClick(handler);
mainButton.hide();
};
}, [status]);
const onSubmit = (event: FormEvent) => {
event.preventDefault();
void submit();
};
const style = useMemo(
() =>
({
"--tg-bg": theme.bg_color ?? (theme.secondary_bg_color ? "#0e1621" : "#ffffff"),
"--tg-secondary-bg": theme.secondary_bg_color ?? "#f4f4f5",
"--tg-text": theme.text_color ?? "#000000",
"--tg-hint": theme.hint_color ?? "#707579",
"--tg-link": theme.link_color ?? "#3390ec",
"--tg-button": theme.button_color ?? "#3390ec",
"--tg-button-text": theme.button_text_color ?? "#ffffff",
}) as CSSProperties,
[theme],
);
const displayName = useMemo(() => {
const user = tgRef.current?.initDataUnsafe?.user;
if (!user) return null;
const name = [user.first_name, user.last_name].filter(Boolean).join(" ").trim();
return name || user.username || null;
}, [status]);
return (
<div className="tg-apply-card" style={style}>
{status === "loading" && <p className="tg-apply-hint">Loading…</p>}
{status === "outside" && (
<div className="tg-apply-body">
<h1 className="tg-apply-title">Moderator application</h1>
<p className="tg-apply-hint">
This form opens inside Telegram. Use the MineGeo bot to apply.
</p>
<a className="tg-apply-button" href={BOT_LINK} target="_blank" rel="noreferrer">
Open @MineGeoModeratorBot
</a>
</div>
)}
{status === "form" && (
<form className="tg-apply-body" onSubmit={onSubmit}>
<h1 className="tg-apply-title">Apply for moderator</h1>
<p className="tg-apply-hint">
{displayName ? `Signed in as ${displayName}. ` : ""}
Enter your MineGeo username. You will confirm the application from that
account.
</p>
<label className="tg-apply-label" htmlFor="minegeo-username">
MineGeo username
</label>
<input
id="minegeo-username"
className="tg-apply-input"
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="your_username"
autoCapitalize="none"
autoCorrect="off"
autoComplete="username"
minLength={3}
maxLength={32}
pattern="[A-Za-z0-9_]{3,32}"
required
/>
<p className="tg-apply-hint">3–32 letters, numbers or underscores.</p>
{error && (
<p className="tg-apply-error" role="alert">
{error}
</p>
)}
<button className="tg-apply-button" type="submit">
Submit application
</button>
</form>
)}
{status === "success" && confirmationUrl && (
<div className="tg-apply-body">
<h1 className="tg-apply-title">Application created</h1>
<p className="tg-apply-hint">
Confirm it from your MineGeo account within 30 minutes. The staff will
review it after confirmation.
</p>
<button
className="tg-apply-button"
type="button"
onClick={() => tgRef.current?.openLink(confirmationUrl)}
>
Confirm application
</button>
<p className="tg-apply-url">{confirmationUrl}</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,118 @@
.tg-apply {
min-height: 100vh;
min-height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: var(--tg-bg, #0e1621);
color: var(--tg-text, #fff);
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
Arial, sans-serif;
}
.tg-apply *,
.tg-apply *::before,
.tg-apply *::after {
box-sizing: border-box;
}
.tg-apply-card {
width: 100%;
max-width: 420px;
}
.tg-apply-body {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
.tg-apply-title {
margin: 0 0 4px;
font-size: 22px;
font-weight: 700;
line-height: 1.25;
color: var(--tg-text, inherit);
}
.tg-apply-hint {
margin: 0;
font-size: 14px;
line-height: 1.45;
color: var(--tg-hint, #707579);
}
.tg-apply-label {
margin-top: 8px;
font-size: 14px;
font-weight: 600;
color: var(--tg-text, inherit);
}
.tg-apply-input {
width: 100%;
padding: 12px 14px;
border: 1px solid transparent;
border-radius: 12px;
background: var(--tg-secondary-bg, #f4f4f5);
color: var(--tg-text, #000);
font-size: 16px;
line-height: 1.3;
outline: none;
transition: border-color 0.15s ease;
}
.tg-apply-input:focus {
border-color: var(--tg-link, #3390ec);
}
.tg-apply-input::placeholder {
color: var(--tg-hint, #707579);
}
.tg-apply-error {
margin: 0;
padding: 10px 12px;
border-radius: 12px;
background: color-mix(in srgb, #e53935 14%, transparent);
color: #e53935;
font-size: 14px;
line-height: 1.4;
}
.tg-apply-button {
margin-top: 10px;
width: 100%;
padding: 13px 16px;
border: none;
border-radius: 12px;
background: var(--tg-button, #3390ec);
color: var(--tg-button-text, #fff);
font-size: 16px;
font-weight: 600;
line-height: 1.2;
text-align: center;
text-decoration: none;
cursor: pointer;
transition: filter 0.15s ease;
}
.tg-apply-button:hover {
filter: brightness(1.06);
}
.tg-apply-button:active {
filter: brightness(0.94);
}
.tg-apply-url {
margin: 6px 0 0;
font-size: 12px;
line-height: 1.5;
word-break: break-all;
color: var(--tg-hint, #707579);
user-select: all;
}

View File

@@ -0,0 +1,23 @@
import type { Metadata, Viewport } from "next";
import { ModeratorApplyApp } from "./apply";
import "./moderator-apply.css";
export const metadata: Metadata = {
title: "Moderator application",
robots: { index: false, follow: false },
referrer: "no-referrer",
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
};
export default function ModeratorApplyPage() {
return (
<main className="tg-apply">
<ModeratorApplyApp />
</main>
);
}