928 lines
37 KiB
TypeScript
928 lines
37 KiB
TypeScript
"use client"
|
|
|
|
import type React from "react"
|
|
import { useState, useEffect, useRef } from "react"
|
|
import { useSearchParams } from "next/navigation"
|
|
import Link from "next/link"
|
|
import { FileText, Upload, CheckCircle, Loader2, ArrowLeft, ShieldAlert, ShieldCheck, AlertTriangle, Info, RefreshCw } from "lucide-react"
|
|
import { CoinsStacked } from "@/icons"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Label } from "@/components/ui/label"
|
|
import { Textarea } from "@/components/ui/textarea"
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
|
import { useWalletStore } from "@/lib/wallet-store"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip"
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog"
|
|
import { ethers } from "ethers"
|
|
import { usePrivadoId } from "@/lib/privado-id"
|
|
import { PERSPECTIVE_CONTRACT_ABI, PERSPECTIVE_CONTRACT_ADDRESS, FEE_CONTRACT_ADDRESS, FEE_CONTRACT_ABI } from "@/lib/constants"
|
|
import { useTrustData } from "@/hooks/use-trust-data"
|
|
|
|
// Mock issues data for the dropdown
|
|
const mockIssues = [
|
|
{
|
|
id: "climate-action",
|
|
title: "Climate Action Policies",
|
|
category: "Environmental Policy"
|
|
},
|
|
{
|
|
id: "education-reform",
|
|
title: "Education System Reform",
|
|
category: "Education"
|
|
},
|
|
{
|
|
id: "healthcare-access",
|
|
title: "Healthcare Accessibility",
|
|
category: "Healthcare"
|
|
},
|
|
{
|
|
id: "housing-affordability",
|
|
title: "Housing Affordability Crisis",
|
|
category: "Infrastructure"
|
|
},
|
|
{
|
|
id: "digital-privacy",
|
|
title: "Digital Privacy Regulations",
|
|
category: "Technology"
|
|
}
|
|
]
|
|
|
|
// Generate a simple CAPTCHA
|
|
const generateCaptcha = () => {
|
|
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
|
|
let captcha = '';
|
|
for (let i = 0; i < 6; i++) {
|
|
captcha += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
}
|
|
return captcha;
|
|
};
|
|
|
|
export default function SubmitPage() {
|
|
const {
|
|
walletConnected,
|
|
walletAddress,
|
|
isVerified,
|
|
citizenshipVerified,
|
|
eligibilityVerified,
|
|
verificationStatus,
|
|
perspectivesSubmittedToday: perspectivesSubmitted,
|
|
incrementPerspectiveCount
|
|
} = useWalletStore()
|
|
const { trustScore, dailyLimit, isLoading, updateUserTrust } = useTrustData()
|
|
const searchParams = useSearchParams()
|
|
const issueParam = searchParams.get('issue')
|
|
|
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
const [isSubmitted, setIsSubmitted] = useState(false)
|
|
const [ipfsHash, setIpfsHash] = useState("")
|
|
const [selectedIssue, setSelectedIssue] = useState<string | null>(null)
|
|
const [title, setTitle] = useState("")
|
|
const [content, setContent] = useState("")
|
|
const [captchaInput, setCaptchaInput] = useState("")
|
|
const [captchaValue, setCaptchaValue] = useState("")
|
|
const [captchaVerified, setCaptchaVerified] = useState(false)
|
|
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
|
const [fileError, setFileError] = useState<string>("")
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
const [submissionError, setSubmissionError] = useState<string | null>(null)
|
|
const [uploadingFile, setUploadingFile] = useState(false)
|
|
const { verifyIdentity } = usePrivadoId()
|
|
|
|
// Economic barrier states
|
|
const [showFeeModal, setShowFeeModal] = useState(false)
|
|
const [transactionState, setTransactionState] = useState<'idle' | 'pending' | 'confirmed' | 'error'>('idle')
|
|
const [transactionHash, setTransactionHash] = useState<string | null>(null)
|
|
const [contentToSubmit, setContentToSubmit] = useState<{
|
|
issueId: string;
|
|
text: string;
|
|
fileUrl?: string;
|
|
} | null>(null)
|
|
|
|
// Fee amount in POL
|
|
const FEE_AMOUNT = "0.01"
|
|
|
|
// Check rate limit for unverified users
|
|
const isRateLimited = !isVerified && perspectivesSubmitted >= 5
|
|
|
|
// Set the selected issue from URL parameter if available
|
|
useEffect(() => {
|
|
if (issueParam) {
|
|
setSelectedIssue(issueParam)
|
|
}
|
|
}, [issueParam])
|
|
|
|
// Generate CAPTCHA on load
|
|
useEffect(() => {
|
|
if (!isVerified) {
|
|
refreshCaptcha();
|
|
}
|
|
}, [isVerified]);
|
|
|
|
// Get the selected issue details
|
|
const selectedIssueDetails = selectedIssue ?
|
|
mockIssues.find(issue => issue.id === selectedIssue) : null
|
|
|
|
const refreshCaptcha = () => {
|
|
setCaptchaValue(generateCaptcha());
|
|
setCaptchaInput("");
|
|
setCaptchaVerified(false);
|
|
};
|
|
|
|
const validateCaptcha = () => {
|
|
const isValid = captchaInput.trim() === captchaValue;
|
|
setCaptchaVerified(isValid);
|
|
return isValid;
|
|
};
|
|
|
|
// Add file validation function
|
|
const validateFile = (file: File): boolean => {
|
|
// Reset error message
|
|
setFileError("");
|
|
|
|
// Check file type
|
|
const validTypes = ['application/pdf', 'image/jpeg', 'image/png'];
|
|
if (!validTypes.includes(file.type)) {
|
|
setFileError("Invalid file type. Please upload a PDF, JPEG, or PNG file.");
|
|
return false;
|
|
}
|
|
|
|
// Check file size (5MB limit)
|
|
const maxSize = 5 * 1024 * 1024; // 5MB in bytes
|
|
if (file.size > maxSize) {
|
|
setFileError("File is too large. Maximum size is 5MB.");
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// Add file change handler
|
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0]
|
|
if (file) {
|
|
if (validateFile(file)) {
|
|
setSelectedFile(file)
|
|
} else {
|
|
e.target.value = '' // Reset input
|
|
setSelectedFile(null)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add smart contract submission function
|
|
const submitToBlockchain = async (
|
|
issueId: string,
|
|
text: string,
|
|
fileUrl?: string
|
|
) => {
|
|
try {
|
|
const provider = new ethers.providers.Web3Provider(
|
|
window.ethereum as ethers.providers.ExternalProvider || undefined
|
|
);
|
|
const signer = provider.getSigner()
|
|
const contract = new ethers.Contract(
|
|
PERSPECTIVE_CONTRACT_ADDRESS,
|
|
PERSPECTIVE_CONTRACT_ABI,
|
|
signer
|
|
)
|
|
|
|
// Prepare metadata including verification status
|
|
const metadata = {
|
|
isVerified,
|
|
timestamp: Date.now(),
|
|
fileUrl,
|
|
submitterAddress: await signer.getAddress()
|
|
}
|
|
|
|
// Submit perspective to smart contract
|
|
const tx = await contract.submitPerspective(
|
|
issueId,
|
|
text,
|
|
JSON.stringify(metadata),
|
|
{ gasLimit: 500000 }
|
|
)
|
|
|
|
// Wait for transaction confirmation
|
|
await tx.wait()
|
|
return tx.hash
|
|
} catch (error) {
|
|
console.error("Blockchain submission error:", error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
// Update file upload handler with additional CAPTCHA check
|
|
const handleFileUpload = async (file: File): Promise<string> => {
|
|
if (!isVerified && !captchaVerified) {
|
|
setFileError("Please complete CAPTCHA verification first");
|
|
document.getElementById('captcha-section')?.scrollIntoView({ behavior: 'smooth' });
|
|
return "";
|
|
}
|
|
|
|
setUploadingFile(true);
|
|
try {
|
|
// TODO: Replace with actual IPFS upload
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
const mockIpfsUrl = `ipfs://Qm...${file.name}`;
|
|
return mockIpfsUrl;
|
|
} catch (error) {
|
|
console.error("File upload error:", error);
|
|
setFileError("Failed to upload file. Please try again.");
|
|
return "";
|
|
} finally {
|
|
setUploadingFile(false);
|
|
}
|
|
};
|
|
|
|
// Handle fee payment and submission
|
|
const handleFeePayment = async () => {
|
|
if (!walletConnected || !contentToSubmit) return;
|
|
|
|
setTransactionState('pending');
|
|
|
|
try {
|
|
const provider = new ethers.providers.Web3Provider(
|
|
window.ethereum as ethers.providers.ExternalProvider || undefined
|
|
);
|
|
const signer = provider.getSigner();
|
|
const contract = new ethers.Contract(
|
|
FEE_CONTRACT_ADDRESS,
|
|
FEE_CONTRACT_ABI,
|
|
signer
|
|
);
|
|
|
|
// Convert the fee amount to wei
|
|
const feeInWei = ethers.utils.parseEther(FEE_AMOUNT);
|
|
|
|
// Call the payFeeAndSubmit function from our smart contract
|
|
const tx = await contract.payFeeAndSubmit(
|
|
contentToSubmit.issueId,
|
|
contentToSubmit.text,
|
|
contentToSubmit.fileUrl || "",
|
|
isVerified,
|
|
{
|
|
value: feeInWei,
|
|
gasLimit: 500000
|
|
}
|
|
);
|
|
|
|
// Wait for transaction confirmation
|
|
await tx.wait();
|
|
|
|
setTransactionHash(tx.hash);
|
|
setTransactionState('confirmed');
|
|
|
|
// Update user trust score if unverified
|
|
if (!isVerified) {
|
|
try {
|
|
await updateUserTrust('submit_perspective');
|
|
} catch (trustError) {
|
|
console.error("Failed to update trust score:", trustError);
|
|
}
|
|
}
|
|
|
|
setIpfsHash(tx.hash);
|
|
setIsSubmitted(true);
|
|
incrementPerspectiveCount();
|
|
setShowFeeModal(false);
|
|
|
|
} catch (error: any) {
|
|
console.error("Fee payment failed:", error);
|
|
setTransactionState('error');
|
|
setSubmissionError(error.message || "Failed to process fee payment. Please try again.");
|
|
}
|
|
};
|
|
|
|
// Update submit handler to check for verification
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setSubmissionError(null);
|
|
|
|
if (!walletConnected || !selectedIssue) return;
|
|
|
|
// Verify identity status if needed
|
|
if (!isVerified) {
|
|
if (!validateCaptcha()) {
|
|
setSubmissionError("CAPTCHA validation failed. Please try again.");
|
|
refreshCaptcha();
|
|
return;
|
|
}
|
|
|
|
if (isRateLimited) {
|
|
setSubmissionError("Daily submission limit reached. Please verify your identity for unlimited access.");
|
|
return;
|
|
}
|
|
}
|
|
|
|
setIsSubmitting(true);
|
|
try {
|
|
// Handle file upload if present
|
|
let fileUrl = "";
|
|
if (selectedFile) {
|
|
fileUrl = await handleFileUpload(selectedFile);
|
|
if (!fileUrl) {
|
|
setIsSubmitting(false);
|
|
return; // Upload failed
|
|
}
|
|
}
|
|
|
|
// For unverified users, prompt for fee payment
|
|
if (!isVerified) {
|
|
setContentToSubmit({
|
|
issueId: selectedIssue,
|
|
text: content,
|
|
fileUrl: fileUrl || undefined
|
|
});
|
|
setShowFeeModal(true);
|
|
setIsSubmitting(false);
|
|
return;
|
|
}
|
|
|
|
// For verified users, proceed with normal submission
|
|
const txHash = await submitToBlockchain(selectedIssue, content, fileUrl || undefined);
|
|
setIpfsHash(txHash);
|
|
setIsSubmitted(true);
|
|
incrementPerspectiveCount();
|
|
} catch (error: any) {
|
|
console.error("Submission failed:", error);
|
|
setSubmissionError(error.message || "Failed to submit perspective. Please try again.");
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
// Render verification banner based on status
|
|
const renderVerificationBanner = () => {
|
|
if (!isVerified) {
|
|
return (
|
|
<Alert variant="default" className="mb-6 border-amber-300 bg-amber-50 dark:bg-amber-950/30 dark:border-amber-800">
|
|
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
|
<AlertTitle className="text-amber-800 dark:text-amber-400">Limited Functionality</AlertTitle>
|
|
<AlertDescription className="text-amber-700 dark:text-amber-500">
|
|
As an unverified user, you're in read-only mode with limited actions.
|
|
You can submit up to {dailyLimit} perspectives per day.
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="mt-2 bg-amber-600 text-white hover:bg-amber-700 border-amber-700"
|
|
asChild
|
|
>
|
|
<Link href="/profile/verify">Verify Identity</Link>
|
|
</Button>
|
|
</AlertDescription>
|
|
</Alert>
|
|
);
|
|
} else if (citizenshipVerified && !eligibilityVerified) {
|
|
return (
|
|
<Alert variant="default" className="mb-6 border-amber-300 bg-amber-50 dark:bg-amber-950/30 dark:border-amber-800">
|
|
<ShieldCheck className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
|
<AlertTitle className="text-amber-800 dark:text-amber-400">Citizenship Verified</AlertTitle>
|
|
<AlertDescription className="text-amber-700 dark:text-amber-500">
|
|
Your citizenship is confirmed. You can submit up to 20 perspectives per day.
|
|
Complete your eligibility attestation to unlock unlimited submissions and full voting power.
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="mt-2 bg-amber-600 text-white hover:bg-amber-700 border-amber-700"
|
|
asChild
|
|
>
|
|
<Link href="/profile/verify">Complete Verification</Link>
|
|
</Button>
|
|
</AlertDescription>
|
|
</Alert>
|
|
);
|
|
} else if (citizenshipVerified && eligibilityVerified) {
|
|
return (
|
|
<Alert variant="default" className="mb-6 border-green-300 bg-green-50 dark:bg-green-950/30 dark:border-green-800">
|
|
<ShieldCheck className="h-4 w-4 text-green-600 dark:text-green-400" />
|
|
<AlertTitle className="text-green-800 dark:text-green-400">Fully Verified</AlertTitle>
|
|
<AlertDescription className="text-green-700 dark:text-green-500">
|
|
You're fully verified with unlimited perspective submissions and enhanced influence on outcomes.
|
|
</AlertDescription>
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
if (!walletConnected) {
|
|
return (
|
|
<div className="container mx-auto px-4 py-8">
|
|
<div className="mx-auto max-w-3xl text-center">
|
|
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl">Connect Your Wallet</h1>
|
|
<p className="mt-4 text-lg text-muted-foreground">
|
|
Please connect your wallet to submit a perspective.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (isSubmitted) {
|
|
return (
|
|
<div className="container mx-auto px-4 py-8">
|
|
<div className="mx-auto max-w-3xl text-center">
|
|
<div className="mb-4 flex justify-center">
|
|
<CheckCircle className="h-12 w-12 text-green-500 dark:text-green-400" />
|
|
</div>
|
|
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl">Perspective Submitted!</h1>
|
|
<p className="mt-4 text-lg text-muted-foreground">
|
|
Your perspective has been successfully submitted and stored on IPFS.
|
|
</p>
|
|
<p className="mt-2 text-sm text-muted-foreground">
|
|
IPFS Hash: {ipfsHash}
|
|
</p>
|
|
{!isVerified && (
|
|
<p className="mt-4 text-sm bg-blue-50 dark:bg-blue-950 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800 rounded-md p-3">
|
|
<Info className="h-4 w-4 inline-block mr-2" />
|
|
Your fee of {FEE_AMOUNT} POL is refundable if your submission remains unflagged for 24 hours.
|
|
</p>
|
|
)}
|
|
<div className="mt-8 flex flex-col sm:flex-row justify-center gap-4">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
setIsSubmitted(false)
|
|
setTitle("")
|
|
setContent("")
|
|
if (!isVerified) {
|
|
refreshCaptcha();
|
|
}
|
|
}}
|
|
>
|
|
Submit Another Perspective
|
|
</Button>
|
|
{selectedIssue && (
|
|
<Button asChild>
|
|
<Link href={`/issues/${selectedIssue}`}>
|
|
View Issue
|
|
</Link>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="container mx-auto px-4 py-10 max-w-4xl">
|
|
<div className="mx-auto max-w-3xl">
|
|
<Button variant="ghost" size="sm" asChild className="mb-4">
|
|
<Link href="/issues">
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
Back to Issues
|
|
</Link>
|
|
</Button>
|
|
|
|
{renderVerificationBanner()}
|
|
|
|
{/* Fee payment modal */}
|
|
<Dialog open={showFeeModal} onOpenChange={setShowFeeModal}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center">
|
|
<CoinsStacked className="h-5 w-5 mr-2 text-blue-500" />
|
|
Fee Required for Unverified Users
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
This action requires a {FEE_AMOUNT} POL fee, which is refundable if your submission is not flagged within 24 hours.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="my-6">
|
|
<div className="rounded-md p-4 bg-blue-50 dark:bg-blue-950 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800">
|
|
<p className="text-sm">
|
|
<Info className="h-4 w-4 inline-block mr-2" />
|
|
This small fee helps prevent spam and abuse. The fee will be automatically refunded after 24 hours if your submission isn't flagged for moderation.
|
|
</p>
|
|
</div>
|
|
|
|
{transactionState === 'pending' && (
|
|
<div className="flex flex-col items-center justify-center mt-4 p-4 bg-muted/20 rounded-md">
|
|
<Loader2 className="h-8 w-8 animate-spin text-primary mb-2" />
|
|
<p className="text-sm font-medium">Processing Transaction...</p>
|
|
<p className="text-xs text-muted-foreground mt-1">Please confirm in your wallet and wait for confirmation</p>
|
|
</div>
|
|
)}
|
|
|
|
{transactionState === 'error' && (
|
|
<div className="mt-4 p-4 bg-red-50 dark:bg-red-950/50 text-red-700 dark:text-red-400 rounded-md border border-red-200 dark:border-red-900">
|
|
<AlertTriangle className="h-4 w-4 inline-block mr-2" />
|
|
<span className="font-medium">Transaction Error</span>
|
|
<p className="text-sm mt-1">{submissionError}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<DialogFooter className="gap-2 sm:justify-between sm:gap-0">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setShowFeeModal(false)}
|
|
disabled={transactionState === 'pending'}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleFeePayment}
|
|
className="bg-blue-600 hover:bg-blue-700 dark:bg-blue-700 dark:hover:bg-blue-800"
|
|
disabled={transactionState === 'pending' || !walletConnected}
|
|
>
|
|
Pay {FEE_AMOUNT} POL & Submit
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<h1 className="text-3xl font-bold mb-2">Submit Your Perspective</h1>
|
|
<p className="text-muted-foreground mb-6">
|
|
Share your thoughts on important topics and contribute to the discourse
|
|
</p>
|
|
|
|
{/* Daily submission limit indicator - only for unverified users */}
|
|
{!isVerified && (
|
|
<div className="mb-6 p-4 bg-primary/10 dark:bg-primary/20 rounded-lg border border-primary/20 dark:border-primary/30">
|
|
<div className="flex items-start">
|
|
<div className="flex-1">
|
|
<h3 className="text-sm font-medium text-foreground dark:text-primary-foreground mb-1">Daily Submission Limit</h3>
|
|
<div className="flex items-center">
|
|
<div className="flex-1 mr-4">
|
|
<div className="h-2 w-full bg-muted dark:bg-muted/50 rounded-full overflow-hidden border border-primary/10">
|
|
<div
|
|
className="h-full bg-primary rounded-full"
|
|
style={{ width: `${(perspectivesSubmitted / dailyLimit) * 100}%` }}
|
|
></div>
|
|
</div>
|
|
</div>
|
|
<div className="text-sm font-medium text-foreground dark:text-primary-foreground">
|
|
{perspectivesSubmitted} / {dailyLimit}
|
|
</div>
|
|
</div>
|
|
<p className="mt-2 text-xs text-muted-foreground dark:text-primary-foreground">
|
|
{trustScore < 100 ? "Increase your trust score to get more daily submissions." : "You've reached the maximum trust score for an unverified user."}
|
|
{" "}
|
|
<a href="/profile/verify" className="font-medium underline">
|
|
Verify your identity
|
|
</a>{" "}
|
|
for unlimited submissions.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Fee notice for unverified users */}
|
|
{!isVerified && (
|
|
<Alert className="mb-6 bg-blue-50 dark:bg-blue-950 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800">
|
|
<CoinsStacked className="h-4 w-4" />
|
|
<AlertTitle>Fee Required for Unverified Users</AlertTitle>
|
|
<AlertDescription>
|
|
Submitting a perspective requires a {FEE_AMOUNT} POL fee, which will be refunded if your submission remains unflagged for 24 hours.
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{perspectivesSubmitted >= dailyLimit && !isVerified ? (
|
|
<div className="bg-yellow-50 border border-yellow-200 text-yellow-800 rounded-lg p-4 mb-6 dark:bg-yellow-900/50 dark:border-yellow-800 dark:text-yellow-300">
|
|
<div className="flex">
|
|
<div className="flex-shrink-0">
|
|
<svg className="h-5 w-5 text-yellow-400 dark:text-yellow-300" viewBox="0 0 20 20" fill="currentColor">
|
|
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
|
</svg>
|
|
</div>
|
|
<div className="ml-3">
|
|
<h3 className="text-sm font-medium">Daily submission limit reached</h3>
|
|
<div className="mt-2 text-sm">
|
|
<p>
|
|
You've reached your daily submission limit. Come back tomorrow or{" "}
|
|
<a href="/profile/verify" className="font-medium underline">
|
|
verify your identity
|
|
</a>{" "}
|
|
for unlimited submissions.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<Card className="dark:border-border">
|
|
<CardHeader>
|
|
<CardTitle>New Perspective</CardTitle>
|
|
<CardDescription>
|
|
Fill out the form below to submit your perspective. Be specific and constructive in your feedback.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
{/* Issue Selection */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="issue" className="flex items-center">
|
|
Select Issue <span className="text-red-500 ml-1">*</span>
|
|
<span className="text-xs text-muted-foreground ml-2">(Required)</span>
|
|
</Label>
|
|
|
|
{selectedIssueDetails ? (
|
|
<div className="mb-4">
|
|
<div className="flex items-center justify-between p-3 border rounded-md">
|
|
<div>
|
|
<p className="font-medium">{selectedIssueDetails.title}</p>
|
|
<Badge variant="outline" className="mt-1">{selectedIssueDetails.category}</Badge>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setSelectedIssue(null)}
|
|
>
|
|
Change
|
|
</Button>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground mt-2">
|
|
Your perspective will be linked to this issue
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<Select
|
|
value={selectedIssue || ""}
|
|
onValueChange={(value) => setSelectedIssue(value)}
|
|
required
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Choose an issue to respond to" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{mockIssues.map((issue) => (
|
|
<SelectItem key={issue.id} value={issue.id}>
|
|
{issue.title} ({issue.category})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<div className="mt-2 flex justify-between items-center">
|
|
<p className="text-xs text-muted-foreground">
|
|
Can't find the issue you're looking for?
|
|
</p>
|
|
<Button type="button" variant="link" size="sm" asChild className="p-0 h-auto">
|
|
<Link href="/issues/propose">Propose a new issue</Link>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{!selectedIssue && (
|
|
<Alert className="mb-4">
|
|
<AlertTitle>Note</AlertTitle>
|
|
<AlertDescription>
|
|
All perspectives must be linked to a specific issue. This helps organize discussions and generate meaningful insights.
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{/* Title */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="title">Title</Label>
|
|
<Input
|
|
id="title"
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
placeholder="Give your perspective a clear, concise title"
|
|
className="w-full"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="content">Your Perspective</Label>
|
|
<Textarea
|
|
id="content"
|
|
value={content}
|
|
onChange={(e) => setContent(e.target.value)}
|
|
placeholder="Share your thoughts in detail. Consider including specific examples or suggestions."
|
|
className={`min-h-[200px] ${content.length > 1000 ? 'border-red-500 focus-visible:ring-red-500' : ''}`}
|
|
required
|
|
/>
|
|
<div className="flex justify-between items-center mt-1">
|
|
<p className={`text-sm ${content.length > 1000 ? 'text-red-500 dark:text-red-400 font-medium' : 'text-muted-foreground'}`}>
|
|
{content.length > 1000 ? 'Perspective exceeds 1000 characters' : ''}
|
|
</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{content.length}/1000 characters
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Attachments */}
|
|
<div className="space-y-2">
|
|
<Label className="flex items-center">
|
|
Attachments
|
|
<span className="text-xs text-muted-foreground ml-2">(Optional)</span>
|
|
</Label>
|
|
<div className="space-y-4">
|
|
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
|
<input
|
|
type="file"
|
|
ref={fileInputRef}
|
|
onChange={handleFileChange}
|
|
accept=".pdf,.jpg,.jpeg,.png"
|
|
className="hidden"
|
|
disabled={!isVerified && !captchaVerified}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="gap-2 bg-blue-50 dark:bg-blue-950 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800 hover:bg-blue-100 dark:hover:bg-blue-900 hover:text-blue-800 dark:hover:text-blue-200"
|
|
onClick={() => {
|
|
if (!isVerified && !captchaVerified) {
|
|
// Scroll to CAPTCHA section when trying to upload without verification
|
|
document.getElementById('captcha-section')?.scrollIntoView({ behavior: 'smooth' });
|
|
return;
|
|
}
|
|
fileInputRef.current?.click();
|
|
}}
|
|
disabled={!isVerified && !captchaVerified}
|
|
>
|
|
<Upload className="h-4 w-4" />
|
|
{!isVerified && !captchaVerified ? 'Verify to Upload' : 'Attach File'}
|
|
</Button>
|
|
{selectedFile && (
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-muted-foreground">{selectedFile.name}</span>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-auto p-1 text-red-500 hover:text-red-700"
|
|
onClick={() => {
|
|
setSelectedFile(null)
|
|
if (fileInputRef.current) fileInputRef.current.value = ''
|
|
}}
|
|
>
|
|
Remove
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{fileError && (
|
|
<p className="text-sm text-red-500 dark:text-red-400">{fileError}</p>
|
|
)}
|
|
<div className="text-sm text-muted-foreground space-y-1">
|
|
<p>Files must be PDF, JPEG, or PNG, and under 5MB.</p>
|
|
{!isVerified && !captchaVerified && (
|
|
<Alert className="mt-2 bg-blue-50 dark:bg-blue-950 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800">
|
|
<Info className="h-4 w-4" />
|
|
<AlertTitle>Verification Required</AlertTitle>
|
|
<AlertDescription>
|
|
Please complete the CAPTCHA verification below to upload files.
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="mt-2 w-full bg-blue-100 dark:bg-blue-900 hover:bg-blue-200 dark:hover:bg-blue-800 border-blue-300 dark:border-blue-700 dark:text-blue-300"
|
|
onClick={() => document.getElementById('captcha-section')?.scrollIntoView({ behavior: 'smooth' })}
|
|
>
|
|
Go to Verification
|
|
</Button>
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* CAPTCHA for Unverified Users */}
|
|
{!isVerified && (
|
|
<div id="captcha-section" className="space-y-2">
|
|
<Label htmlFor="captcha" className="flex items-center">
|
|
Human Verification
|
|
<span className="text-red-500 ml-1">*</span>
|
|
{!captchaVerified && (
|
|
<span className="text-xs text-blue-600 ml-2">(Required for unverified users)</span>
|
|
)}
|
|
</Label>
|
|
<div className="border rounded-md p-4 bg-muted/30">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="flex-1 text-center">
|
|
<div
|
|
className="inline-block px-4 py-2 bg-primary/10 dark:bg-primary/20 rounded font-mono text-lg tracking-widest text-foreground dark:text-primary-foreground"
|
|
style={{ letterSpacing: '0.5em', userSelect: 'none' }}
|
|
>
|
|
{captchaValue}
|
|
</div>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={refreshCaptcha}
|
|
className="ml-2"
|
|
>
|
|
<RefreshCw className="h-4 w-4" />
|
|
<span className="sr-only">Refresh CAPTCHA</span>
|
|
</Button>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
id="captcha"
|
|
value={captchaInput}
|
|
onChange={(e) => setCaptchaInput(e.target.value)}
|
|
placeholder="Enter the code shown above"
|
|
className="flex-1"
|
|
required={!isVerified}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={validateCaptcha}
|
|
className="bg-blue-50 dark:bg-blue-950 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800 hover:bg-blue-100 dark:hover:bg-blue-900 hover:text-blue-800 dark:hover:text-blue-200"
|
|
>
|
|
Verify
|
|
</Button>
|
|
</div>
|
|
{captchaVerified && (
|
|
<p className="text-green-600 dark:text-green-400 text-sm mt-1 flex items-center">
|
|
<CheckCircle className="h-3 w-3 mr-1" /> Verification successful - You can now upload files
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error Message */}
|
|
{submissionError && (
|
|
<Alert variant="destructive" className="mt-4">
|
|
<AlertTriangle className="h-4 w-4" />
|
|
<AlertTitle>Error</AlertTitle>
|
|
<AlertDescription>{submissionError}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{/* Submit Button */}
|
|
<div className="mt-6">
|
|
<button
|
|
type="submit"
|
|
disabled={
|
|
!walletConnected ||
|
|
(!isVerified && !captchaVerified) ||
|
|
(perspectivesSubmitted >= dailyLimit && !isVerified) ||
|
|
isSubmitting
|
|
}
|
|
className={`
|
|
w-full py-3 px-4 rounded-md font-medium text-white
|
|
${isSubmitting ? 'bg-muted-foreground cursor-not-allowed' : 'bg-primary hover:bg-primary/90'}
|
|
${(!walletConnected || (!isVerified && !captchaVerified) || (perspectivesSubmitted >= dailyLimit && !isVerified))
|
|
? 'opacity-50 cursor-not-allowed'
|
|
: ''}
|
|
transition duration-200
|
|
`}
|
|
>
|
|
{isSubmitting ? 'Submitting...' : 'Submit Perspective'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Guidelines */}
|
|
<Card className="mt-8 dark:border-border">
|
|
<CardHeader>
|
|
<CardTitle>Submission Guidelines</CardTitle>
|
|
<CardDescription>Tips for submitting an effective perspective</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<ul className="list-disc space-y-2 pl-4 text-muted-foreground">
|
|
<li>Be specific and provide concrete examples</li>
|
|
<li>Focus on constructive solutions rather than just problems</li>
|
|
<li>Consider the impact on different stakeholders</li>
|
|
<li>Support your perspective with data when possible</li>
|
|
<li>Keep your tone professional and respectful</li>
|
|
<li>Review your submission for clarity before posting</li>
|
|
</ul>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|