1dcfa0bfa5
- API routes: explicit return types on all Express handlers (fixes TS7030), `beneficiaryName` now accepted and stored in /thank-you route per OpenAPI spec. - Home page: added search bar (filters by case ID, description, name) + Featured Opportunities section with live cards, progress bars, and Donate buttons. - Opportunities page: added need-type filter pill bar (all 8 types + "All Types") with active state highlighting; empty state respects selected filter. - i18n: expanded translations with all previously hardcoded strings (trackCase, notFound, noData, currentStep, search, searchPlaceholder, featuredTitle, noResults, donate.caseSummary, donate.caseNotFound, admin.noRequests, admin.needType, admin.amount, admin.track, admin.whatsapp, track.caseInfo, track.rejected, track.currentStepLabel, track.submitThankYou, thankYou.successNote, thankYou.beneficiaryMessageLabel, whatsapp.donorPhone, whatsapp.beneficiaryMessage, whatsapp.noEntries, opportunities.noOpportunities, opportunities.verified). All pages now use t.* — zero hardcoded English UI strings. - TypeScript: both frontend (tsc --noEmit) and API server build are clean.
133 lines
5.7 KiB
TypeScript
133 lines
5.7 KiB
TypeScript
import { useState } from "react";
|
|
import { useLanguage } from "../contexts/LanguageContext";
|
|
import { useListPublishedRequests } from "@workspace/api-client-react";
|
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Progress } from "@/components/ui/progress";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { Link } from "wouter";
|
|
|
|
type NeedTypeKey =
|
|
| "electricity" | "water" | "food" | "health"
|
|
| "housing" | "refrigerator" | "air_conditioner" | "court_order";
|
|
|
|
const NEED_TYPES: NeedTypeKey[] = [
|
|
"electricity", "water", "food", "health",
|
|
"housing", "refrigerator", "air_conditioner", "court_order",
|
|
];
|
|
|
|
export default function Opportunities() {
|
|
const { t } = useLanguage();
|
|
const { data: requests, isLoading } = useListPublishedRequests();
|
|
const [activeFilter, setActiveFilter] = useState<NeedTypeKey | "all">("all");
|
|
|
|
const filtered = (requests || []).filter((r) =>
|
|
activeFilter === "all" ? true : r.needType === activeFilter
|
|
);
|
|
|
|
return (
|
|
<div className="container mx-auto px-4 py-12">
|
|
<h1 className="text-3xl font-bold text-foreground mb-6">{t.opportunities.title}</h1>
|
|
|
|
{/* Filter Bar */}
|
|
<div className="mb-8">
|
|
<p className="text-sm text-muted-foreground mb-3">{t.opportunities.filterByType}</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
<button
|
|
onClick={() => setActiveFilter("all")}
|
|
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-colors border ${
|
|
activeFilter === "all"
|
|
? "bg-primary text-primary-foreground border-primary"
|
|
: "bg-background border-border text-muted-foreground hover:border-primary hover:text-primary"
|
|
}`}
|
|
data-testid="filter-all"
|
|
>
|
|
{t.opportunities.all}
|
|
</button>
|
|
{NEED_TYPES.map((type) => (
|
|
<button
|
|
key={type}
|
|
onClick={() => setActiveFilter(type)}
|
|
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-colors border ${
|
|
activeFilter === type
|
|
? "bg-primary text-primary-foreground border-primary"
|
|
: "bg-background border-border text-muted-foreground hover:border-primary hover:text-primary"
|
|
}`}
|
|
data-testid={`filter-${type}`}
|
|
>
|
|
{t.needTypes[type]}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
{[1, 2, 3].map((i) => (
|
|
<Skeleton key={i} className="h-64 w-full" />
|
|
))}
|
|
</div>
|
|
) : filtered.length === 0 ? (
|
|
<div className="text-center py-16 text-muted-foreground bg-muted/20 rounded-xl border border-dashed">
|
|
{t.opportunities.noOpportunities}
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
{filtered.map((request) => {
|
|
const progress = Math.min(
|
|
100,
|
|
request.requestedAmount > 0
|
|
? Math.round((request.collectedAmount / request.requestedAmount) * 100)
|
|
: 0
|
|
);
|
|
const remaining = Math.max(0, request.requestedAmount - request.collectedAmount);
|
|
|
|
return (
|
|
<Card key={request.id} className="overflow-hidden flex flex-col">
|
|
<CardHeader className="bg-primary/5 pb-4 border-b">
|
|
<div className="flex justify-between items-start mb-2">
|
|
<Badge variant="outline" className="bg-white">
|
|
{t.needTypes[request.needType as keyof typeof t.needTypes] || request.needType}
|
|
</Badge>
|
|
<Badge className="bg-green-600 text-white">
|
|
{t.opportunities.verified}
|
|
</Badge>
|
|
</div>
|
|
<CardTitle className="text-base line-clamp-2">{request.description}</CardTitle>
|
|
<p className="text-xs font-mono text-muted-foreground mt-1">{request.caseId}</p>
|
|
</CardHeader>
|
|
<CardContent className="pt-5 flex-1">
|
|
<div className="flex justify-between text-sm mb-2">
|
|
<span className="text-muted-foreground">
|
|
{t.opportunities.collected}:{" "}
|
|
<strong className="text-foreground">{request.collectedAmount.toLocaleString()} ﷼</strong>
|
|
</span>
|
|
<span className="text-muted-foreground">
|
|
{t.opportunities.target}:{" "}
|
|
<strong className="text-foreground">{request.requestedAmount.toLocaleString()} ﷼</strong>
|
|
</span>
|
|
</div>
|
|
<Progress value={progress} className="h-2 mb-2" />
|
|
<div className="text-sm font-medium text-primary">{progress}%</div>
|
|
<div className="mt-3 text-center">
|
|
<span className="text-sm text-muted-foreground">{t.opportunities.remaining}: </span>
|
|
<span className="font-bold text-xl">{remaining.toLocaleString()} ﷼</span>
|
|
</div>
|
|
</CardContent>
|
|
<CardFooter className="pt-0">
|
|
<Link href={`/donate/${request.id}`} className="w-full">
|
|
<Button className="w-full" disabled={remaining <= 0}>
|
|
{t.opportunities.donate}
|
|
</Button>
|
|
</Link>
|
|
</CardFooter>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|