Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 1m41s
278 lines
8.5 KiB
TypeScript
278 lines
8.5 KiB
TypeScript
"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>
|
||
);
|
||
}
|