Complete EHSAN POC: fix all code review findings

- 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.
This commit is contained in:
Replit Agent
2026-06-05 17:12:44 +00:00
parent 12111a9562
commit 1dcfa0bfa5
9 changed files with 640 additions and 258 deletions
+69 -27
View File
@@ -1,7 +1,19 @@
import { useLanguage } from "../contexts/LanguageContext";
import { useListRequests, getListRequestsQueryKey, useVerifyRequest, usePublishRequest, useDeliverSupport, useConfirmReceipt, useCloseRequest, useRejectRequest } from "@workspace/api-client-react";
import {
useListRequests,
getListRequestsQueryKey,
useVerifyRequest,
usePublishRequest,
useDeliverSupport,
useConfirmReceipt,
useCloseRequest,
useRejectRequest,
} from "@workspace/api-client-react";
import { useQueryClient } from "@tanstack/react-query";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
Table, TableBody, TableCell, TableHead,
TableHeader, TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Link } from "wouter";
@@ -10,7 +22,7 @@ export default function Admin() {
const { t } = useLanguage();
const queryClient = useQueryClient();
const { data: requests, isLoading } = useListRequests();
const verifyRequest = useVerifyRequest();
const publishRequest = usePublishRequest();
const deliverSupport = useDeliverSupport();
@@ -30,15 +42,15 @@ export default function Admin() {
return (
<div className="container mx-auto px-4 py-12">
<h1 className="text-3xl font-bold text-foreground mb-8">{t.admin.title}</h1>
<div className="bg-card rounded-xl border overflow-hidden shadow-sm">
<Table>
<TableHeader className="bg-muted/50">
<TableRow>
<TableHead>{t.admin.caseId}</TableHead>
<TableHead>{t.admin.beneficiary}</TableHead>
<TableHead>{t.request.needType}</TableHead>
<TableHead>{t.request.amount}</TableHead>
<TableHead>{t.admin.needType}</TableHead>
<TableHead>{t.admin.amount}</TableHead>
<TableHead>{t.admin.status}</TableHead>
<TableHead>{t.admin.currentStep}</TableHead>
<TableHead className="text-right">{t.admin.actions}</TableHead>
@@ -55,8 +67,10 @@ export default function Admin() {
<TableRow key={req.id}>
<TableCell className="font-mono text-xs">{req.caseId}</TableCell>
<TableCell>{req.beneficiaryName}</TableCell>
<TableCell>{t.needTypes[req.needType as keyof typeof t.needTypes] || req.needType}</TableCell>
<TableCell>{req.requestedAmount} </TableCell>
<TableCell>
{t.needTypes[req.needType as keyof typeof t.needTypes] || req.needType}
</TableCell>
<TableCell>{req.requestedAmount.toLocaleString()} </TableCell>
<TableCell>
<Badge variant="secondary" className="font-normal">
{t.statuses[req.status as keyof typeof t.statuses] || req.status}
@@ -64,33 +78,61 @@ export default function Admin() {
</TableCell>
<TableCell>{req.currentStep}/10</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<div className="flex items-center justify-end gap-2 flex-wrap">
<Link href={`/track/${req.id}`}>
<Button variant="outline" size="sm">Track</Button>
<Button variant="outline" size="sm">{t.admin.track}</Button>
</Link>
{req.status === 'new' && (
{req.status === "new" && (
<>
<Button size="sm" onClick={() => handleAction(verifyRequest, req.id)}>Verify</Button>
<Button size="sm" variant="destructive" onClick={() => handleAction(rejectRequest, req.id)}>Reject</Button>
<Button size="sm" onClick={() => handleAction(verifyRequest, req.id)}>
{t.admin.verify}
</Button>
<Button size="sm" variant="destructive" onClick={() => handleAction(rejectRequest, req.id)}>
{t.admin.reject}
</Button>
</>
)}
{req.status === 'verified' && (
<Button size="sm" onClick={() => handleAction(publishRequest, req.id)}>Publish</Button>
{req.status === "pending_review" && (
<>
<Button size="sm" onClick={() => handleAction(verifyRequest, req.id)}>
{t.admin.verify}
</Button>
<Button size="sm" variant="destructive" onClick={() => handleAction(rejectRequest, req.id)}>
{t.admin.reject}
</Button>
</>
)}
{req.status === 'donated' && (
<Button size="sm" onClick={() => handleAction(deliverSupport, req.id)}>Deliver</Button>
{req.status === "verified" && (
<Button size="sm" onClick={() => handleAction(publishRequest, req.id)}>
{t.admin.publish}
</Button>
)}
{req.status === 'delivered' && (
<Button size="sm" onClick={() => handleAction(confirmReceipt, req.id)}>Confirm Receipt</Button>
{req.status === "donated" && (
<Button size="sm" onClick={() => handleAction(deliverSupport, req.id)}>
{t.admin.deliver}
</Button>
)}
{req.status === 'thank_you_submitted' && (
<Link href={`/whatsapp-log`}>
<Button size="sm" variant="outline">WhatsApp</Button>
{req.status === "delivered" && (
<Button size="sm" onClick={() => handleAction(confirmReceipt, req.id)}>
{t.admin.confirmReceipt}
</Button>
)}
{req.status === "receipt_confirmed" && (
<Link href={`/thank-you/${req.id}`}>
<Button size="sm" variant="outline">
{t.track.submitThankYou}
</Button>
</Link>
)}
{req.status === 'whatsapp_sent' && (
<Button size="sm" onClick={() => handleAction(closeRequest, req.id)}>Close Case</Button>
{req.status === "thank_you_submitted" && (
<Link href="/whatsapp-log">
<Button size="sm" variant="outline">{t.admin.whatsapp}</Button>
</Link>
)}
{req.status === "whatsapp_sent" && (
<Button size="sm" onClick={() => handleAction(closeRequest, req.id)}>
{t.admin.close}
</Button>
)}
</div>
</TableCell>
@@ -99,7 +141,7 @@ export default function Admin() {
{(!requests || requests.length === 0) && !isLoading && (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
No requests found
{t.admin.noRequests}
</TableCell>
</TableRow>
)}
@@ -108,4 +150,4 @@ export default function Admin() {
</div>
</div>
);
}
}
+42 -10
View File
@@ -4,7 +4,10 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useParams, useLocation } from "wouter";
import { useLanguage } from "../contexts/LanguageContext";
import { useGetRequest, useDonateToRequest, getListRequestsQueryKey, getListPublishedRequestsQueryKey, getGetRequestQueryKey } from "@workspace/api-client-react";
import {
useGetRequest, useDonateToRequest,
getListRequestsQueryKey, getListPublishedRequestsQueryKey, getGetRequestQueryKey,
} from "@workspace/api-client-react";
import { useQueryClient } from "@tanstack/react-query";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
@@ -14,6 +17,7 @@ import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { CheckCircle, Heart } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { Link } from "wouter";
const schema = z.object({
donorName: z.string().min(2),
@@ -43,7 +47,7 @@ export default function Donate() {
donorName: "",
donorPhone: "",
donorEmail: "",
amount: request?.requestedAmount || 0,
amount: 0,
},
});
@@ -80,7 +84,7 @@ export default function Donate() {
if (!request) {
return (
<div className="container mx-auto px-4 py-12 text-center text-muted-foreground">
Case not found.
{t.donate.caseNotFound}
</div>
);
}
@@ -94,10 +98,16 @@ export default function Donate() {
<CheckCircle className="w-10 h-10 text-green-500 mx-auto mb-4" />
<h2 className="text-2xl font-bold text-green-700 mb-2">{t.common.success}</h2>
<p className="text-muted-foreground text-lg mb-6">{t.donate.successMessage}</p>
<p className="text-sm font-mono text-muted-foreground bg-muted/30 px-4 py-2 rounded-lg inline-block">{request.caseId}</p>
<p className="text-sm font-mono text-muted-foreground bg-muted/30 px-4 py-2 rounded-lg inline-block">
{request.caseId}
</p>
<div className="mt-8 flex gap-3 justify-center">
<Button variant="outline" onClick={() => setLocation("/opportunities")}>{t.common.opportunities}</Button>
<Button onClick={() => setLocation(`/track/${request.id}`)}>Track Case</Button>
<Button variant="outline" onClick={() => setLocation("/opportunities")}>
{t.common.opportunities}
</Button>
<Button onClick={() => setLocation(`/track/${request.id}`)}>
{t.common.trackCase}
</Button>
</div>
</CardContent>
</Card>
@@ -105,7 +115,12 @@ export default function Donate() {
);
}
const progress = Math.min(100, Math.round((request.collectedAmount / request.requestedAmount) * 100));
const progress = Math.min(
100,
request.requestedAmount > 0
? Math.round((request.collectedAmount / request.requestedAmount) * 100)
: 0
);
return (
<div className="container mx-auto px-4 py-12 max-w-2xl">
@@ -115,7 +130,12 @@ export default function Donate() {
{/* Case Summary */}
<Card className="mb-6 bg-primary/5 border-primary/20">
<CardContent className="pt-5 pb-5">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
{t.donate.caseSummary}
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="flex justify-between items-start mb-3">
<div>
<p className="font-semibold text-foreground">{request.description}</p>
@@ -126,8 +146,14 @@ export default function Donate() {
</Badge>
</div>
<div className="flex justify-between text-sm mb-2">
<span className="text-muted-foreground">{t.opportunities.collected}: <strong>{request.collectedAmount} </strong></span>
<span className="text-muted-foreground">{t.opportunities.target}: <strong>{request.requestedAmount} </strong></span>
<span className="text-muted-foreground">
{t.opportunities.collected}:{" "}
<strong>{request.collectedAmount.toLocaleString()} </strong>
</span>
<span className="text-muted-foreground">
{t.opportunities.target}:{" "}
<strong>{request.requestedAmount.toLocaleString()} </strong>
</span>
</div>
<Progress value={progress} className="h-2" />
</CardContent>
@@ -205,6 +231,12 @@ export default function Donate() {
</Form>
</CardContent>
</Card>
<div className="mt-4 text-center">
<Link href="/opportunities">
<Button variant="ghost" size="sm">{t.common.back}</Button>
</Link>
</div>
</div>
);
}
+130 -14
View File
@@ -1,29 +1,65 @@
import { useState } from "react";
import { useLanguage } from "../contexts/LanguageContext";
import { useGetStats } from "@workspace/api-client-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useGetStats, useListPublishedRequests } from "@workspace/api-client-react";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Link } from "wouter";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import { Link } from "wouter";
import { Search } from "lucide-react";
export default function Home() {
const { t } = useLanguage();
const { data: stats, isLoading } = useGetStats();
const { data: stats, isLoading: statsLoading } = useGetStats();
const { data: published, isLoading: pubLoading } = useListPublishedRequests();
const [query, setQuery] = useState("");
const filtered = (published || []).filter((r) => {
if (!query.trim()) return true;
const q = query.toLowerCase();
return (
r.caseId.toLowerCase().includes(q) ||
r.description.toLowerCase().includes(q) ||
r.beneficiaryName.toLowerCase().includes(q)
);
});
return (
<div className="container mx-auto px-4 py-12">
<section className="text-center py-20 bg-primary/5 rounded-3xl mb-12 border border-primary/10">
{/* Hero */}
<section className="text-center py-16 bg-primary/5 rounded-3xl mb-12 border border-primary/10 px-6">
<h1 className="text-4xl md:text-5xl font-bold text-primary mb-6">
{t.home.heroTitle}
</h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto mb-8">
{t.home.heroSubtitle}
</p>
<Link href="/opportunities" className="inline-flex items-center justify-center whitespace-nowrap rounded-md text-base font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-12 px-8 py-2">
{t.home.viewOpportunities}
</Link>
{/* Search Bar */}
<div className="max-w-xl mx-auto">
<label className="block text-sm font-medium text-foreground mb-2">
{t.home.searchLabel}
</label>
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
className="ps-9"
placeholder={t.common.searchPlaceholder}
value={query}
onChange={(e) => setQuery(e.target.value)}
data-testid="input-search"
/>
</div>
<Button data-testid="button-search">{t.home.searchButton}</Button>
</div>
</div>
</section>
{isLoading ? (
{/* Stats */}
{statsLoading ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-16">
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
@@ -38,7 +74,9 @@ export default function Home() {
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-4xl font-bold text-foreground">{stats?.totalRequests || 0}</div>
<div className="text-4xl font-bold text-foreground">
{stats?.totalRequests || 0}
</div>
</CardContent>
</Card>
<Card>
@@ -48,7 +86,9 @@ export default function Home() {
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-4xl font-bold text-primary">{stats?.totalCollected?.toLocaleString() || 0} </div>
<div className="text-4xl font-bold text-primary">
{stats?.totalCollected?.toLocaleString() || 0}
</div>
</CardContent>
</Card>
<Card>
@@ -58,19 +98,95 @@ export default function Home() {
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-4xl font-bold text-foreground">{stats?.totalClosed || 0}</div>
<div className="text-4xl font-bold text-foreground">
{stats?.totalClosed || 0}
</div>
</CardContent>
</Card>
</div>
)}
{/* Featured Opportunities */}
<section className="mb-16">
<div className="flex items-center justify-between mb-6 flex-wrap gap-3">
<h2 className="text-2xl font-bold">{t.home.featuredTitle}</h2>
<Link href="/opportunities">
<Button variant="outline" size="sm">{t.home.viewOpportunities}</Button>
</Link>
</div>
{pubLoading ? (
<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="col-span-full text-center py-12 text-muted-foreground bg-muted/20 rounded-xl border border-dashed">
{query.trim() ? t.home.noResults : t.opportunities.noOpportunities}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filtered.slice(0, 6).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">{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-lg">{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>
)}
</section>
{/* Workflow Steps */}
<section className="mb-12">
<h2 className="text-2xl font-bold mb-8 text-center">{t.home.workflowTitle}</h2>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
{Array.from({ length: 10 }).map((_, i) => (
<Card key={i} className="bg-muted/50 border-none shadow-none">
<CardContent className="p-4 text-center">
<div className="w-8 h-8 rounded-full bg-primary/20 text-primary mx-auto flex items-center justify-center font-bold mb-3">
<div className="w-8 h-8 rounded-full bg-primary/20 text-primary mx-auto flex items-center justify-center font-bold mb-3 text-sm">
{i + 1}
</div>
<div className="text-sm font-medium">
@@ -83,4 +199,4 @@ export default function Home() {
</section>
</div>
);
}
}
+81 -26
View File
@@ -1,32 +1,88 @@
import { useState } from "react";
import { useLanguage } from "../contexts/LanguageContext";
import { useListPublishedRequests, getListPublishedRequestsQueryKey } from "@workspace/api-client-react";
import { useListPublishedRequests } from "@workspace/api-client-react";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Link } from "wouter";
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-8">{t.opportunities.title}</h1>
<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">
{requests?.map((request) => {
const progress = Math.min(100, Math.round((request.collectedAmount / request.requestedAmount) * 100));
const remaining = request.requestedAmount - request.collectedAmount;
{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">
@@ -34,24 +90,29 @@ export default function Opportunities() {
<Badge variant="outline" className="bg-white">
{t.needTypes[request.needType as keyof typeof t.needTypes] || request.needType}
</Badge>
<Badge className="bg-green-600">
{t.statuses.verified}
<Badge className="bg-green-600 text-white">
{t.opportunities.verified}
</Badge>
</div>
<CardTitle className="text-lg line-clamp-1">{request.description}</CardTitle>
<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-6 flex-1">
<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} </strong></span>
<span className="text-muted-foreground">{t.opportunities.target}: <strong className="text-foreground">{request.requestedAmount} </strong></span>
<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 text-right text-primary font-medium">
{progress}%
</div>
<div className="mt-4 text-center">
<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 > 0 ? remaining : 0} </span>
<span className="font-bold text-xl">{remaining.toLocaleString()} </span>
</div>
</CardContent>
<CardFooter className="pt-0">
@@ -64,14 +125,8 @@ export default function Opportunities() {
</Card>
);
})}
{(!requests || requests.length === 0) && (
<div className="col-span-full text-center py-12 text-muted-foreground bg-muted/20 rounded-xl border border-dashed">
No opportunities available right now.
</div>
)}
</div>
)}
</div>
);
}
}
+36 -16
View File
@@ -4,8 +4,10 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useParams, useLocation } from "wouter";
import { useLanguage } from "../contexts/LanguageContext";
import { useGetRequest, useSubmitThankYou, getListRequestsQueryKey, getGetRequestQueryKey } from "@workspace/api-client-react";
import {
useGetRequest, useSubmitThankYou,
getListRequestsQueryKey, getGetRequestQueryKey,
} from "@workspace/api-client-react";
import { useQueryClient } from "@tanstack/react-query";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
@@ -14,6 +16,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { CheckCircle, Heart } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { Link } from "wouter";
const schema = z.object({
beneficiaryName: z.string().min(2),
@@ -38,11 +41,14 @@ export default function ThankYou() {
const form = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
beneficiaryName: request?.beneficiaryName || "",
beneficiaryName: "",
message: "",
},
});
// Pre-fill name once loaded
const beneficiaryName = request?.beneficiaryName || "";
const onSubmit = (data: FormData) => {
submitThankYou.mutate(
{
@@ -68,6 +74,14 @@ export default function ThankYou() {
);
}
if (!request) {
return (
<div className="container mx-auto px-4 py-12 text-center text-muted-foreground">
{t.common.notFound}
</div>
);
}
if (submitted) {
return (
<div className="container mx-auto px-4 py-12 max-w-xl">
@@ -78,15 +92,15 @@ export default function ThankYou() {
<CheckCircle className="w-12 h-12 text-green-500" />
</div>
<h2 className="text-2xl font-bold text-green-700 mb-2">{t.common.success}</h2>
<p className="text-muted-foreground">
{t.thankYou.title}
</p>
<p className="mt-4 text-sm text-muted-foreground">
Your thank-you message will be sent to the donor via WhatsApp through OpenClaw.
</p>
<p className="text-muted-foreground">{t.thankYou.title}</p>
<p className="mt-4 text-sm text-muted-foreground">{t.thankYou.successNote}</p>
<div className="mt-8 flex gap-3 justify-center">
<Button onClick={() => setLocation(`/track/${params.id}`)}>Track Case</Button>
<Button variant="outline" onClick={() => setLocation("/")}>Home</Button>
<Button onClick={() => setLocation(`/track/${params.id}`)}>
{t.common.trackCase}
</Button>
<Button variant="outline" onClick={() => setLocation("/")}>
{t.common.home}
</Button>
</div>
</CardContent>
</Card>
@@ -98,9 +112,9 @@ export default function ThankYou() {
<div className="container mx-auto px-4 py-12 max-w-xl">
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground">{t.thankYou.title}</h1>
{request && (
<p className="text-muted-foreground mt-1">{request.caseId} {request.beneficiaryName}</p>
)}
<p className="text-muted-foreground mt-1">
{request.caseId} {request.beneficiaryName}
</p>
</div>
<Card>
@@ -119,8 +133,9 @@ export default function ThankYou() {
<FormControl>
<Input
data-testid="input-beneficiaryName"
defaultValue={request?.beneficiaryName || ""}
placeholder={beneficiaryName}
{...field}
defaultValue={beneficiaryName}
/>
</FormControl>
<FormMessage />
@@ -137,7 +152,6 @@ export default function ThankYou() {
<Textarea
data-testid="input-thankYouMessage"
rows={5}
placeholder="جزاكم الله خيراً، وصلني الدعم وكان له أثر كبير عليّ."
{...field}
/>
</FormControl>
@@ -157,6 +171,12 @@ export default function ThankYou() {
</Form>
</CardContent>
</Card>
<div className="mt-4 text-center">
<Link href={`/track/${params.id}`}>
<Button variant="ghost" size="sm">{t.common.back}</Button>
</Link>
</div>
</div>
);
}
+39 -18
View File
@@ -5,7 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Check, Clock, Circle, ArrowRight } from "lucide-react";
import { Check, Clock } from "lucide-react";
import { Link } from "wouter";
const STEPS = [
@@ -48,7 +48,7 @@ export default function Track() {
if (!request) {
return (
<div className="container mx-auto px-4 py-12 text-center text-muted-foreground">
Case not found.
{t.common.notFound}
</div>
);
}
@@ -61,7 +61,9 @@ export default function Track() {
<div className="mb-8 flex items-center justify-between flex-wrap gap-4">
<div>
<h1 className="text-3xl font-bold text-foreground">{t.track.title}</h1>
<p className="text-muted-foreground mt-1">{request.caseId} {request.beneficiaryName}</p>
<p className="text-muted-foreground mt-1">
{request.caseId} {request.beneficiaryName}
</p>
</div>
<Badge
className={`text-sm px-3 py-1 ${
@@ -79,23 +81,38 @@ export default function Track() {
{/* Case Info */}
<Card className="mb-8 bg-muted/30">
<CardContent className="pt-5 pb-5">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
{t.track.caseInfo}
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<p className="text-muted-foreground text-xs uppercase tracking-wide mb-1">{t.request.needType}</p>
<p className="font-semibold">{t.needTypes[request.needType as keyof typeof t.needTypes]}</p>
<p className="text-muted-foreground text-xs uppercase tracking-wide mb-1">
{t.request.needType}
</p>
<p className="font-semibold">
{t.needTypes[request.needType as keyof typeof t.needTypes]}
</p>
</div>
<div>
<p className="text-muted-foreground text-xs uppercase tracking-wide mb-1">{t.request.amount}</p>
<p className="text-muted-foreground text-xs uppercase tracking-wide mb-1">
{t.request.amount}
</p>
<p className="font-semibold">{request.requestedAmount.toLocaleString()} </p>
</div>
<div>
<p className="text-muted-foreground text-xs uppercase tracking-wide mb-1">{t.opportunities.collected}</p>
<p className="text-muted-foreground text-xs uppercase tracking-wide mb-1">
{t.opportunities.collected}
</p>
<p className="font-semibold text-primary">{request.collectedAmount.toLocaleString()} </p>
</div>
{request.donorName && (
<div>
<p className="text-muted-foreground text-xs uppercase tracking-wide mb-1">{t.whatsapp.donor}</p>
<p className="text-muted-foreground text-xs uppercase tracking-wide mb-1">
{t.whatsapp.donor}
</p>
<p className="font-semibold">{request.donorName}</p>
</div>
)}
@@ -111,7 +128,7 @@ export default function Track() {
<CardContent>
{isRejected ? (
<div className="p-6 text-center bg-red-50 rounded-lg border border-red-200">
<p className="text-red-700 font-semibold mb-2">{t.statuses.rejected}</p>
<p className="text-red-700 font-semibold mb-2">{t.track.rejected}</p>
{request.rejectionReason && (
<p className="text-red-600 text-sm">{request.rejectionReason}</p>
)}
@@ -122,11 +139,9 @@ export default function Track() {
const stepNum = index + 1;
const isDone = stepNum < currentStep;
const isCurrent = stepNum === currentStep;
const isPending = stepNum > currentStep;
return (
<div key={stepKey} className="flex items-start gap-4 mb-6 last:mb-0">
{/* Icon */}
<div className="flex flex-col items-center">
<div
className={`w-9 h-9 rounded-full flex items-center justify-center flex-shrink-0 border-2 transition-all ${
@@ -152,8 +167,6 @@ export default function Track() {
/>
)}
</div>
{/* Label */}
<div className="pt-1.5">
<p
className={`font-medium text-sm ${
@@ -167,7 +180,9 @@ export default function Track() {
{t.workflow[stepKey as keyof typeof t.workflow]}
</p>
{isCurrent && (
<p className="text-xs text-primary mt-0.5 font-medium"> Current Step</p>
<p className="text-xs text-primary mt-0.5 font-medium">
{t.track.currentStepLabel}
</p>
)}
</div>
</div>
@@ -182,11 +197,17 @@ export default function Track() {
<div className="mt-6 flex gap-3 flex-wrap">
{request.status === "receipt_confirmed" && (
<Link href={`/thank-you/${request.id}`}>
<Button data-testid="button-submitThankYou">{t.thankYou.title}</Button>
<Button data-testid="button-submitThankYou">
{t.track.submitThankYou}
</Button>
</Link>
)}
<Button variant="outline" onClick={() => setLocation("/admin")}>{t.common.adminDashboard}</Button>
<Button variant="ghost" onClick={() => setLocation("/opportunities")}>{t.common.back}</Button>
<Button variant="outline" onClick={() => setLocation("/admin")}>
{t.common.adminDashboard}
</Button>
<Button variant="ghost" onClick={() => setLocation("/opportunities")}>
{t.common.back}
</Button>
</div>
</div>
);
@@ -1,5 +1,8 @@
import { useLanguage } from "../contexts/LanguageContext";
import { useListWhatsappLog, useSendWhatsapp, getListWhatsappLogQueryKey, getListRequestsQueryKey } from "@workspace/api-client-react";
import {
useListWhatsappLog, useSendWhatsapp,
getListWhatsappLogQueryKey, getListRequestsQueryKey, useListRequests,
} from "@workspace/api-client-react";
import { useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
@@ -7,7 +10,6 @@ import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator";
import { MessageSquare, Phone, Send, CheckCircle, Clock, XCircle } from "lucide-react";
import { useListRequests } from "@workspace/api-client-react";
export default function WhatsappLog() {
const { t } = useLanguage();
@@ -67,7 +69,7 @@ export default function WhatsappLog() {
) : !logs || logs.length === 0 ? (
<Card>
<CardContent className="py-16 text-center text-muted-foreground">
No WhatsApp log entries yet.
{t.whatsapp.noEntries}
</CardContent>
</Card>
) : (
@@ -118,7 +120,7 @@ export default function WhatsappLog() {
{/* Beneficiary Message */}
<div>
<p className="text-xs font-semibold uppercase text-muted-foreground tracking-wide mb-2">
Beneficiary Message
{t.thankYou.beneficiaryMessageLabel}
</p>
<p className="text-sm text-foreground italic">"{log.beneficiaryMessage}"</p>
</div>