accesscodes/src/app/access/[method]/[step]/page.tsx
2025-12-19 15:29:38 +00:00

695 lines
24 KiB
TypeScript

"use client";
import { useState, useTransition, useEffect, use } from "react";
import { Asterisk, CheckCircle2, Camera, CameraOff, PartyPopper, ExternalLink, ArrowLeft, Copy } from "lucide-react";
import { Button } from "@/components/ui/button";
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Scanner } from "@yudiel/react-qr-scanner";
import { toast } from "sonner";
import { motion } from "motion/react";
import { Item, ItemContent, ItemTitle, ItemMedia, ItemActions } from "@/components/ui/item";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { cfg } from "@/lib/cfg";
import { getAvatarProfile } from "@/lib/avatar";
interface PageProps {
params: Promise<{
method: string;
step: string;
}>;
}
export default function AccessPage({ params }: PageProps) {
const router = useRouter();
const resolvedParams = use(params);
const method = resolvedParams.method; // 'code' or 'qr'
const step = resolvedParams.step; // 'verify', 'details', 'success'
// Access Code State
const [length, setLength] = useState(6);
const [code, setCode] = useState("");
const [verified, setVerified] = useState(false);
const [whitelistSuccess, setWhitelistSuccess] = useState(false);
const [status, setStatus] = useState<{ ok: boolean; message?: string }>({ ok: false });
const [isPending, startTransition] = useTransition();
// Details State
const [preference, setPreference] = useState<"java" | "bedrock" | "both">("java");
const [javaName, setJavaName] = useState("");
const [bedrockName, setBedrockName] = useState("");
// QR State
const [showScanner, setShowScanner] = useState(false);
const [hasPermission, setHasPermission] = useState<boolean | null>(null);
const [scannerError, setScannerError] = useState<string | null>(null);
// Success State
const [copied, setCopied] = useState(false);
const [profileData, setProfileData] = useState<{ avatarUrl: string; gamertag: string } | null>(null);
// Loading state to prevent premature redirects
const [isLoading, setIsLoading] = useState(true);
// Handle redirects based on verification state
useEffect(() => {
if (!isLoading) {
if (step === 'details' && !verified) {
router.push(`/access/${method}/verify`);
} else if (step === 'success' && !whitelistSuccess) {
router.push(`/access/${method}/verify`);
}
}
}, [step, verified, whitelistSuccess, method, router, isLoading]);
useEffect(() => {
if (method === 'qr' && step === 'verify' && hasPermission === null) {
const requestCameraPermission = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
stream.getTracks().forEach(track => track.stop()); // Stop immediately after getting permission
setHasPermission(true);
} catch (error) {
console.error('Camera permission denied:', error);
setHasPermission(false);
setScannerError("Camera permission denied. Please enable camera access to scan QR codes.");
}
};
requestCameraPermission();
}
}, [method, step, hasPermission]);
useEffect(() => {
const prefix = method === 'qr' ? 'frg-qr-' : 'frg-';
const savedVerified = localStorage.getItem(`${prefix}verified`);
const savedWhitelistSuccess = localStorage.getItem(`${prefix}whitelist-success`);
const savedCode = localStorage.getItem(`${prefix}auth-code`);
const savedJavaName = localStorage.getItem(`${prefix}java-name`);
const savedBedrockName = localStorage.getItem(`${prefix}bedrock-name`);
const savedPreference = localStorage.getItem(`${prefix}preference`);
if (savedVerified === 'true') {
setVerified(true);
}
if (savedWhitelistSuccess === 'true') {
setWhitelistSuccess(true);
}
if (savedCode) {
setCode(savedCode);
}
if (savedJavaName) {
setJavaName(savedJavaName);
}
if (savedBedrockName) {
setBedrockName(savedBedrockName);
}
if (savedPreference) {
setPreference(savedPreference as "java" | "bedrock" | "both");
}
// Fetch profile data for success step
if (step === 'success') {
fetchProfileData();
}
// Set loading to false after state is loaded
setIsLoading(false);
}, [method, step]);
// Save state to localStorage
useEffect(() => {
const prefix = method === 'qr' ? 'frg-qr-' : 'frg-';
localStorage.setItem(`${prefix}verified`, verified.toString());
}, [verified, method]);
useEffect(() => {
const prefix = method === 'qr' ? 'frg-qr-' : 'frg-';
localStorage.setItem(`${prefix}whitelist-success`, whitelistSuccess.toString());
}, [whitelistSuccess, method]);
useEffect(() => {
const prefix = method === 'qr' ? 'frg-qr-' : 'frg-';
localStorage.setItem(`${prefix}auth-code`, code);
}, [code, method]);
useEffect(() => {
const prefix = method === 'qr' ? 'frg-qr-' : 'frg-';
localStorage.setItem(`${prefix}java-name`, javaName);
}, [javaName, method]);
useEffect(() => {
const prefix = method === 'qr' ? 'frg-qr-' : 'frg-';
localStorage.setItem(`${prefix}bedrock-name`, bedrockName);
}, [bedrockName, method]);
useEffect(() => {
const prefix = method === 'qr' ? 'frg-qr-' : 'frg-';
localStorage.setItem(`${prefix}preference`, preference);
}, [preference, method]);
useEffect(() => {
const fetchConfig = async () => {
try {
const response = await fetch("/api/access-code-config");
const config = await response.json();
setLength(config.length || 6);
} catch (error) {
console.error("Failed to fetch access code config:", error);
}
};
fetchConfig();
}, []);
const fetchProfileData = async () => {
try {
const res = await fetch(
`/api/xbox/profile/get?gamertag=${encodeURIComponent(cfg.server.bedrockFriend)}`
);
const data = await res.json();
setProfileData(data);
} catch (e) {
console.error("Failed to fetch profile", e);
}
};
const needsJavaName = preference === "java" || preference === "both";
const needsBedrockName = preference === "bedrock" || preference === "both";
const handleVerifyCode = async () => {
startTransition(async () => {
try {
const response = await fetch("/api/access-code", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ code }),
});
const result = await response.json();
if (!result.success) {
setVerified(false);
setStatus({ ok: false, message: result.message });
return;
}
setVerified(true);
setStatus({
ok: true,
message: "Access code verified!",
});
// Navigate to details step
router.push(`/access/${method}/details`);
} catch {
setVerified(false);
setStatus({
ok: false,
message: "We couldn't reach the server. Double-check your connection and try again.",
});
}
});
};
const handleQRScan = (detectedCodes: any[]) => {
if (detectedCodes.length > 0) {
const result = detectedCodes[0].rawValue;
const sanitizedResult = result.replace(/[^0-9A-Za-z]/g, "");
startTransition(async () => {
try {
const response = await fetch("/api/qr-verify", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
code: sanitizedResult,
}),
});
const verifyResult = await response.json();
if (verifyResult.success) {
setVerified(true);
setStatus({
ok: true,
message: verifyResult.message || "QR code verified successfully!",
});
// Navigate to details step
router.push(`/access/${method}/details`);
} else {
setVerified(false);
setStatus({
ok: false,
message: verifyResult.message || "QR code verification failed",
});
}
} catch {
setVerified(false);
setStatus({
ok: false,
message: "We couldn't reach the server. Try again soon.",
});
}
});
}
};
const handleWhitelistSubmit = async () => {
if (
(needsJavaName && !javaName.trim()) ||
(needsBedrockName && !bedrockName.trim())
) {
setStatus({
ok: false,
message: "Please fill out every username we need to whitelist.",
});
return;
}
startTransition(async () => {
try {
const response = await fetch("/api/whitelist", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
javaName: needsJavaName ? javaName.trim() : undefined,
bedrockName: needsBedrockName ? bedrockName.trim() : undefined,
preference,
accessCode: method === 'qr' ? "qr_verified" : code,
}),
});
const result = await response.json();
if (result.success) {
setStatus({
ok: true,
message: result.results.join(" ") || "Successfully added to whitelist!",
});
setWhitelistSuccess(true);
// Navigate to success step
router.push(`/access/${method}/success`);
} else {
setStatus({
ok: false,
message: result.errors.join(" ") || "Failed to add to whitelist.",
});
}
} catch {
setStatus({
ok: false,
message: "We couldn't reach the server. Double-check your connection and try again.",
});
}
});
};
const handleScannerError = (error: any) => {
console.error("QR Scanner error:", error);
setScannerError("Unable to access camera. Please check permissions.");
setHasPermission(false);
};
const handleCopyIp = async () => {
const serverIp = cfg.server.javaIp;
if (serverIp) {
await navigator.clipboard.writeText(serverIp);
toast.success("Copied to clipboard");
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
// Get server information based on user's preference
const getServerInfo = () => {
switch (preference) {
case 'java':
return {
address: cfg.server.javaIp,
label: 'Java Server Address',
showProfile: false
};
case 'bedrock':
return {
address: cfg.server.bedrockIp + (cfg.server.bedrockPort ? `:${cfg.server.bedrockPort}` : ''),
label: 'Bedrock Server Address',
showProfile: true
};
case 'both':
return {
javaAddress: cfg.server.javaIp,
bedrockAddress: cfg.server.bedrockIp + (cfg.server.bedrockPort ? `:${cfg.server.bedrockPort}` : ''),
label: 'Server Addresses',
showProfile: true
};
default:
return {
address: cfg.server.javaIp,
label: 'Server Address',
showProfile: false
};
}
};
const serverInfo = getServerInfo();
const handleReset = () => {
const prefix = method === 'qr' ? 'frg-qr-' : 'frg-';
localStorage.removeItem(`${prefix}verified`);
localStorage.removeItem(`${prefix}whitelist-success`);
localStorage.removeItem(`${prefix}auth-code`);
localStorage.removeItem(`${prefix}java-name`);
localStorage.removeItem(`${prefix}bedrock-name`);
localStorage.removeItem(`${prefix}preference`);
setVerified(false);
setWhitelistSuccess(false);
setCode("");
setJavaName("");
setBedrockName("");
setStatus({ ok: false });
router.push(`/access/${method}/verify`);
};
// Render different steps
if (step === 'verify') {
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="w-full max-w-md space-y-6">
{/* Header */}
<div className="text-center space-y-2">
<Link href="/" className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft className="w-4 h-4 mr-2" />
Back to home
</Link>
<h1 className="text-2xl font-semibold">
{method === 'code' ? 'Access Code Verification' : 'QR Code Verification'}
</h1>
<p className="text-muted-foreground">
{method === 'code'
? `Use the ${length}-character invite your friend gave you so we can safely whitelist you.`
: 'Scan a QR code to verify your access.'
}
</p>
</div>
{/* Verification Content */}
<div className="space-y-4">
{method === 'code' ? (
<div className="space-y-2">
<Label className="text-xs uppercase tracking-wide text-muted-foreground">
Access code
</Label>
<InputOTP
maxLength={length}
className="w-full font-mono"
value={code}
onChange={setCode}
disabled={verified}
>
<InputOTPGroup className="flex h-16 w-full">
{Array.from({ length }).map((_, i) => (
<InputOTPSlot
key={i}
index={i}
className="flex-1 h-16 font-mono text-3xl"
/>
))}
</InputOTPGroup>
</InputOTP>
</div>
) : (
<div className="w-full max-w-sm mx-auto space-y-4">
{scannerError ? (
<div className="text-center space-y-4">
<CameraOff className="w-12 h-12 text-muted-foreground mx-auto" />
<p className="text-sm text-muted-foreground">{scannerError}</p>
<Button onClick={() => router.push('/')} variant="outline">
Back to home
</Button>
</div>
) : (
<>
<div className="relative w-full h-64 rounded-md overflow-hidden">
<Scanner
onScan={handleQRScan}
onError={handleScannerError}
styles={{
container: {
width: "100%",
height: "100%",
borderRadius: "0.5rem",
overflow: "hidden",
},
video: {
width: "100%",
height: "100%",
objectFit: "cover",
},
}}
/>
{hasPermission === null && (
<div className="absolute inset-0 flex items-center justify-center bg-muted/50 rounded-md">
<div className="text-center space-y-2">
<Camera className="w-8 h-8 text-muted-foreground mx-auto animate-pulse" />
<p className="text-sm text-muted-foreground">
Requesting camera permission...
</p>
</div>
</div>
)}
</div>
</>
)}
</div>
)}
{/* Status */}
<div className="text-center">
{status.message && (
<p className={status.ok ? "text-emerald-500" : "text-destructive"}>
{status.ok && <CheckCircle2 className="inline size-4 mr-1" />}
{status.message}
</p>
)}
</div>
{/* Actions */}
{method === 'code' && (
<Button
onClick={handleVerifyCode}
disabled={isPending || code.length < length}
className="w-full"
>
{isPending ? "Checking..." : "Verify access code"}
</Button>
)}
</div>
</div>
</div>
);
}
if (step === 'details') {
if (!verified) {
return null; // Redirect handled by useEffect
}
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="w-full max-w-md space-y-6">
{/* Header */}
<div className="text-center space-y-2">
<Link href={`/access/${method}/verify`} className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft className="w-4 h-4 mr-2" />
Back to verification
</Link>
<h1 className="text-2xl font-semibold">Server Details</h1>
<p className="text-muted-foreground">
Tell us which Minecraft edition you play and your username(s).
</p>
</div>
{/* Form */}
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-xs uppercase tracking-wide text-muted-foreground">
Which edition?
</Label>
<ToggleGroup
type="single"
value={preference}
onValueChange={(value) =>
value && setPreference(value as typeof preference)
}
className="w-full"
>
<ToggleGroupItem value="java" className="flex-1">
Java
</ToggleGroupItem>
<ToggleGroupItem value="bedrock" className="flex-1">
Bedrock
</ToggleGroupItem>
<ToggleGroupItem value="both" className="flex-1">
Both
</ToggleGroupItem>
</ToggleGroup>
</div>
{needsJavaName && (
<div className="space-y-2">
<Label htmlFor="java-username">Java username</Label>
<Input
id="java-username"
placeholder="Your Java username"
value={javaName}
className="font-mono"
onChange={(event) => setJavaName(event.target.value)}
/>
</div>
)}
{needsBedrockName && (
<div className="space-y-2">
<Label htmlFor="bedrock-username">Bedrock Gamertag</Label>
<Input
id="bedrock-username"
placeholder="Your Xbox name"
value={bedrockName}
className="font-mono"
onChange={(event) => setBedrockName(event.target.value)}
/>
</div>
)}
{/* Status */}
<div className="text-center">
{status.message && (
<p className={status.ok ? "text-emerald-500" : "text-destructive"}>
{status.ok && <CheckCircle2 className="inline size-4 mr-1" />}
{status.message}
</p>
)}
</div>
{/* Actions */}
<Button
onClick={handleWhitelistSubmit}
disabled={isPending || (needsJavaName && !javaName.trim()) || (needsBedrockName && !bedrockName.trim())}
className="w-full"
>
{isPending ? "Submitting..." : "Continue"}
</Button>
</div>
</div>
</div>
);
}
if (step === 'success') {
if (!whitelistSuccess) {
return null; // Redirect handled by useEffect
}
const serverIp = cfg.server.javaIp;
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="w-full max-w-md space-y-6">
{/* Header */}
<div className="text-center space-y-4">
<PartyPopper className="w-16 h-16 text-primary mx-auto" />
<h1 className="text-2xl font-semibold">You're whitelisted!</h1>
<p className="text-muted-foreground">
Welcome to FRG Network! You can now join the server.
As soon as you join, come back and click "Claim my reward" to get a gift on behalf of the team here.
</p>
</div>
{/* Server IP */}
<div className="space-y-2 w-full justify-start">
<p className="text-sm text-muted-foreground">Server Address</p>
<div className="flex w-full items-center">
<Input
value={serverIp || "Loading..."}
readOnly
className="font-mono w-full"
/>
<Button
className="border-l-0 border-input"
variant="outline"
onClick={handleCopyIp}
disabled={!serverIp}
>
<motion.div
className="flex flex-row items-center pl-1 justify-center w-16 gap-1.5"
key={copied ? "check" : "copy"}
initial={{ scale: 0.6, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.6, opacity: 0 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
>
{copied ? <><CheckCircle2 className="text-muted-foreground w-4 h-4" /> <span className="text-muted-foreground">Copied</span></> : <>
<Copy className="w-4 h-4" /> Copy
</>}
</motion.div>
</Button>
</div>
</div>
{/* Profile */}
<div className="flex flex-col gap-2">
<p className="text-sm text-muted-foreground">Add on Bedrock</p>
<Item variant="outline">
<ItemMedia>
<Avatar size="lg">
<AvatarImage src={profileData?.avatarUrl || ""} />
<AvatarFallback>?</AvatarFallback>
</Avatar>
</ItemMedia>
<ItemContent>
<ItemTitle>{cfg.server.bedrockFriend}</ItemTitle>
</ItemContent>
<ItemActions>
<Button asChild>
<Link href={`https://www.xbox.com/en-GB/play/user/${profileData?.gamertag}`} target="_blank">
<ExternalLink className="w-4 h-4 mr-2" />
View
</Link>
</Button>
</ItemActions>
</Item>
</div>
{/* Actions */}
<div className="space-y-3">
<Button asChild className="w-full">
<Link href="/survey" target="_blank" rel="noopener noreferrer">
<ExternalLink className="w-4 h-4 mr-2" />
Claim my reward
</Link>
</Button>
<Button variant="outline" onClick={handleReset} className="w-full">
Start over
</Button>
</div>
</div>
</div>
);
}
// Invalid step - redirect handled by useEffect
return null;
}