695 lines
24 KiB
TypeScript
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;
|
|
}
|