OpportunityDetail page - view individual opportunity
cgen-fc9e4569b2a04ca0a476d425c6d55404
This commit is contained in:
parent
b322efa514
commit
86d507cbef
1 changed files with 293 additions and 0 deletions
293
client/pages/opportunities/OpportunityDetail.tsx
Normal file
293
client/pages/opportunities/OpportunityDetail.tsx
Normal file
|
|
@ -0,0 +1,293 @@
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
import Layout from "@/components/Layout";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
ArrowLeft,
|
||||||
|
DollarSign,
|
||||||
|
Briefcase,
|
||||||
|
Clock,
|
||||||
|
Send,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { getOpportunityById, submitApplication } from "@/api/opportunities";
|
||||||
|
import { ArmBadge } from "@/components/creator-network/ArmBadge";
|
||||||
|
import { useAethexToast } from "@/hooks/use-aethex-toast";
|
||||||
|
import { useAuthContext } from "@/contexts/AuthContext";
|
||||||
|
import type { Opportunity } from "@/api/opportunities";
|
||||||
|
|
||||||
|
export default function OpportunityDetail() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuthContext();
|
||||||
|
const { toast } = useAethexToast();
|
||||||
|
const [opportunity, setOpportunity] = useState<Opportunity | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isApplying, setIsApplying] = useState(false);
|
||||||
|
const [showApplyDialog, setShowApplyDialog] = useState(false);
|
||||||
|
const [coverLetter, setCoverLetter] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchOpportunity = async () => {
|
||||||
|
if (!id) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await getOpportunityById(id);
|
||||||
|
setOpportunity(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch opportunity:", error);
|
||||||
|
setOpportunity(null);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchOpportunity();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const handleApply = async () => {
|
||||||
|
if (!opportunity || !user) return;
|
||||||
|
|
||||||
|
if (!coverLetter.trim()) {
|
||||||
|
toast("Please write a cover letter", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsApplying(true);
|
||||||
|
try {
|
||||||
|
await submitApplication({
|
||||||
|
opportunity_id: opportunity.id,
|
||||||
|
cover_letter: coverLetter.trim(),
|
||||||
|
});
|
||||||
|
toast("Application submitted successfully!", "success");
|
||||||
|
setCoverLetter("");
|
||||||
|
setShowApplyDialog(false);
|
||||||
|
navigate("/profile/applications");
|
||||||
|
} catch (error) {
|
||||||
|
toast(
|
||||||
|
error instanceof Error ? error.message : "Failed to submit application",
|
||||||
|
"error"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsApplying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatSalary = (min?: number, max?: number) => {
|
||||||
|
if (!min && !max) return "Not specified";
|
||||||
|
if (min && max) return `$${min.toLocaleString()} - $${max.toLocaleString()}`;
|
||||||
|
if (min) return `$${min.toLocaleString()}+`;
|
||||||
|
if (max) return `Up to $${max.toLocaleString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-cyan-400" />
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!opportunity) {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="min-h-screen bg-black text-white flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-3xl font-bold mb-4">Opportunity Not Found</h1>
|
||||||
|
<p className="text-gray-400 mb-6">
|
||||||
|
The opportunity you're looking for doesn't exist.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => navigate("/opportunities")}>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
|
Back to Opportunities
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const poster = opportunity.aethex_creators;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="relative min-h-screen bg-black text-white overflow-hidden">
|
||||||
|
{/* Background */}
|
||||||
|
<div className="pointer-events-none absolute inset-0 opacity-[0.12] [background-image:radial-gradient(circle_at_top,#06b6d4_0,rgba(0,0,0,0.45)_55%,rgba(0,0,0,0.9)_100%)]" />
|
||||||
|
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(transparent_0,transparent_calc(100%-1px),rgba(6,182,212,0.05)_calc(100%-1px))] bg-[length:100%_32px]" />
|
||||||
|
|
||||||
|
<main className="relative z-10">
|
||||||
|
<div className="container mx-auto max-w-4xl px-4 py-12">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate("/opportunities")}
|
||||||
|
variant="ghost"
|
||||||
|
className="mb-4"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
|
Back to Opportunities
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Opportunity Card */}
|
||||||
|
<Card className="bg-slate-800/50 border-slate-700 mb-8">
|
||||||
|
<CardContent className="p-8">
|
||||||
|
<div className="mb-6">
|
||||||
|
<ArmBadge arm={opportunity.arm_affiliation} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-4xl font-bold mb-4">
|
||||||
|
{opportunity.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8 py-6 border-t border-b border-slate-700">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-400 mb-1">Job Type</p>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Briefcase className="h-4 w-4 text-yellow-400" />
|
||||||
|
<p className="font-semibold">{opportunity.job_type}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-400 mb-1">Salary</p>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<DollarSign className="h-4 w-4 text-green-400" />
|
||||||
|
<p className="font-semibold text-sm">
|
||||||
|
{formatSalary(
|
||||||
|
opportunity.salary_min,
|
||||||
|
opportunity.salary_max
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-400 mb-1">Experience</p>
|
||||||
|
<p className="font-semibold text-sm">
|
||||||
|
{opportunity.experience_level || "Any"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-400 mb-1">Posted</p>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Clock className="h-4 w-4 text-blue-400" />
|
||||||
|
<p className="font-semibold text-sm">
|
||||||
|
{new Date(opportunity.created_at).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Posted By */}
|
||||||
|
<div className="flex items-center gap-4 mb-8 p-4 bg-slate-700/30 rounded-lg">
|
||||||
|
<Avatar className="h-12 w-12">
|
||||||
|
<AvatarImage src={poster.avatar_url} alt={poster.username} />
|
||||||
|
<AvatarFallback>
|
||||||
|
{poster.username.charAt(0).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-xs text-gray-400">Posted by</p>
|
||||||
|
<p className="font-semibold">@{poster.username}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/creators/${poster.username}`)
|
||||||
|
}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
View Profile
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-xl font-bold mb-4">Description</h2>
|
||||||
|
<p className="text-gray-300 whitespace-pre-wrap leading-relaxed">
|
||||||
|
{opportunity.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Apply Button */}
|
||||||
|
{user ? (
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowApplyDialog(true)}
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4 mr-2" />
|
||||||
|
Apply for This Opportunity
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate("/signup")}
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
Sign in to Apply
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Apply Dialog */}
|
||||||
|
<AlertDialog open={showApplyDialog} onOpenChange={setShowApplyDialog}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Apply for {opportunity.title}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Submit your application with a cover letter explaining why you're
|
||||||
|
interested in this opportunity.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Cover Letter</label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Tell the poster why you're interested in this opportunity..."
|
||||||
|
value={coverLetter}
|
||||||
|
onChange={(e) => setCoverLetter(e.target.value)}
|
||||||
|
disabled={isApplying}
|
||||||
|
className="min-h-32 bg-slate-800/50 border-slate-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<AlertDialogCancel disabled={isApplying}>
|
||||||
|
Cancel
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleApply}
|
||||||
|
disabled={isApplying || !coverLetter.trim()}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{isApplying && (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Submit Application
|
||||||
|
</AlertDialogAction>
|
||||||
|
</div>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue