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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user