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,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>
);
}