Add Telegram moderator application mini app
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 1m41s
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 1m41s
This commit is contained in:
62
app/api/moderator-apply/route.ts
Normal file
62
app/api/moderator-apply/route.ts
Normal 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 });
|
||||
}
|
||||
277
app/moderator-apply/apply.tsx
Normal file
277
app/moderator-apply/apply.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
app/moderator-apply/moderator-apply.css
Normal file
118
app/moderator-apply/moderator-apply.css
Normal 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;
|
||||
}
|
||||
23
app/moderator-apply/page.tsx
Normal file
23
app/moderator-apply/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user