Build EHSAN Closed Donation Loop POC — full bilingual Arabic/English app

- Backend (api-server): Complete in-memory mock DB with 11 seed cases, 5 eligible
  beneficiaries, 3 donors, and WhatsApp log. All 14 API routes implemented across
  requests, donors, stats, and whatsapp-log. OpenClaw integration with OPENCLAW_SIMULATE
  toggle. UUID-based IDs. Full status machine (new → closed, 10 steps).

- Frontend (ehsan-poc): 8 pages fully implemented using all generated API hooks:
  Home (stats counters, 10-step workflow diagram), Request (form with eligibility
  result), Opportunities (card grid with progress bars), Donate (case summary +
  donor form), Admin (full data table with contextual action buttons), Track
  (10-step visual timeline in green), ThankYou (message form), WhatsApp Log
  (WhatsApp bubble preview + OpenClaw send button).

- Bilingual LanguageContext (AR/EN) with RTL/LTR toggle, localStorage persistence.
  EHSAN green palette (HSL 143), Tajawal font, fully responsive.
  TypeScript clean — zero errors.
This commit is contained in:
Replit Agent
2026-06-05 17:05:27 +00:00
parent 2da838bb66
commit 12111a9562
117 changed files with 12366 additions and 81 deletions
+111
View File
@@ -0,0 +1,111 @@
import { useLanguage } from "../contexts/LanguageContext";
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 { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Link } from "wouter";
export default function Admin() {
const { t } = useLanguage();
const queryClient = useQueryClient();
const { data: requests, isLoading } = useListRequests();
const verifyRequest = useVerifyRequest();
const publishRequest = usePublishRequest();
const deliverSupport = useDeliverSupport();
const confirmReceipt = useConfirmReceipt();
const closeRequest = useCloseRequest();
const rejectRequest = useRejectRequest();
const handleAction = async (action: any, id: string) => {
try {
await action.mutateAsync({ id });
queryClient.invalidateQueries({ queryKey: getListRequestsQueryKey() });
} catch (e) {
console.error(e);
}
};
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.status}</TableHead>
<TableHead>{t.admin.currentStep}</TableHead>
<TableHead className="text-right">{t.admin.actions}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
{t.common.loading}
</TableCell>
</TableRow>
) : requests?.map((req) => (
<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>
<Badge variant="secondary" className="font-normal">
{t.statuses[req.status as keyof typeof t.statuses] || req.status}
</Badge>
</TableCell>
<TableCell>{req.currentStep}/10</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Link href={`/track/${req.id}`}>
<Button variant="outline" size="sm">Track</Button>
</Link>
{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>
</>
)}
{req.status === 'verified' && (
<Button size="sm" onClick={() => handleAction(publishRequest, req.id)}>Publish</Button>
)}
{req.status === 'donated' && (
<Button size="sm" onClick={() => handleAction(deliverSupport, req.id)}>Deliver</Button>
)}
{req.status === 'delivered' && (
<Button size="sm" onClick={() => handleAction(confirmReceipt, req.id)}>Confirm Receipt</Button>
)}
{req.status === 'thank_you_submitted' && (
<Link href={`/whatsapp-log`}>
<Button size="sm" variant="outline">WhatsApp</Button>
</Link>
)}
{req.status === 'whatsapp_sent' && (
<Button size="sm" onClick={() => handleAction(closeRequest, req.id)}>Close Case</Button>
)}
</div>
</TableCell>
</TableRow>
))}
{(!requests || requests.length === 0) && !isLoading && (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
No requests found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
);
}
+210
View File
@@ -0,0 +1,210 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
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 { useQueryClient } from "@tanstack/react-query";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { CheckCircle, Heart } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
const schema = z.object({
donorName: z.string().min(2),
donorPhone: z.string().min(10),
donorEmail: z.string().email().optional().or(z.literal("")),
amount: z.coerce.number().positive(),
});
type FormData = z.infer<typeof schema>;
export default function Donate() {
const { t } = useLanguage();
const params = useParams<{ id: string }>();
const [, setLocation] = useLocation();
const queryClient = useQueryClient();
const [donated, setDonated] = useState(false);
const { data: request, isLoading } = useGetRequest(params.id || "", {
query: { enabled: !!params.id, queryKey: getGetRequestQueryKey(params.id || "") },
});
const donateMutation = useDonateToRequest();
const form = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
donorName: "",
donorPhone: "",
donorEmail: "",
amount: request?.requestedAmount || 0,
},
});
const onSubmit = (data: FormData) => {
donateMutation.mutate(
{
id: params.id || "",
data: {
donorName: data.donorName,
donorPhone: data.donorPhone,
donorEmail: data.donorEmail || null,
amount: data.amount,
},
},
{
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: getListRequestsQueryKey() });
queryClient.invalidateQueries({ queryKey: getListPublishedRequestsQueryKey() });
setDonated(true);
},
}
);
};
if (isLoading) {
return (
<div className="container mx-auto px-4 py-12 max-w-2xl space-y-4">
<Skeleton className="h-10 w-48" />
<Skeleton className="h-64 w-full" />
</div>
);
}
if (!request) {
return (
<div className="container mx-auto px-4 py-12 text-center text-muted-foreground">
Case not found.
</div>
);
}
if (donated) {
return (
<div className="container mx-auto px-4 py-12 max-w-2xl">
<Card className="border-2 border-green-200">
<CardContent className="pt-10 pb-10 text-center">
<Heart className="w-16 h-16 text-green-600 mx-auto mb-4 fill-green-100" />
<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>
<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>
</div>
</CardContent>
</Card>
</div>
);
}
const progress = Math.min(100, Math.round((request.collectedAmount / request.requestedAmount) * 100));
return (
<div className="container mx-auto px-4 py-12 max-w-2xl">
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground">{t.donate.title}</h1>
</div>
{/* Case Summary */}
<Card className="mb-6 bg-primary/5 border-primary/20">
<CardContent className="pt-5 pb-5">
<div className="flex justify-between items-start mb-3">
<div>
<p className="font-semibold text-foreground">{request.description}</p>
<p className="text-sm text-muted-foreground mt-1">{request.caseId}</p>
</div>
<Badge className="bg-primary/10 text-primary border-primary/20">
{t.needTypes[request.needType as keyof typeof t.needTypes]}
</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>
</div>
<Progress value={progress} className="h-2" />
</CardContent>
</Card>
{/* Donation Form */}
<Card>
<CardHeader>
<CardTitle className="text-lg font-semibold text-primary">{t.donate.title}</CardTitle>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-5">
<FormField
control={form.control}
name="donorName"
render={({ field }) => (
<FormItem>
<FormLabel>{t.donate.donorName}</FormLabel>
<FormControl>
<Input data-testid="input-donorName" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="donorPhone"
render={({ field }) => (
<FormItem>
<FormLabel>{t.donate.donorPhone}</FormLabel>
<FormControl>
<Input data-testid="input-donorPhone" type="tel" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="donorEmail"
render={({ field }) => (
<FormItem>
<FormLabel>{t.donate.donorEmail}</FormLabel>
<FormControl>
<Input data-testid="input-donorEmail" type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="amount"
render={({ field }) => (
<FormItem>
<FormLabel>{t.donate.amount} ()</FormLabel>
<FormControl>
<Input data-testid="input-donationAmount" type="number" min={1} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full"
disabled={donateMutation.isPending}
data-testid="button-confirmDonation"
>
{donateMutation.isPending ? t.common.loading : t.donate.confirmDonation}
</Button>
</form>
</Form>
</CardContent>
</Card>
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
import { useLanguage } from "../contexts/LanguageContext";
import { useGetStats } from "@workspace/api-client-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Link } from "wouter";
import { Skeleton } from "@/components/ui/skeleton";
export default function Home() {
const { t } = useLanguage();
const { data: stats, isLoading } = useGetStats();
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">
<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>
</section>
{isLoading ? (
<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" />
<Skeleton className="h-32 w-full" />
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-16">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t.home.totalRequests}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-4xl font-bold text-foreground">{stats?.totalRequests || 0}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t.home.totalCollected}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-4xl font-bold text-primary">{stats?.totalCollected?.toLocaleString() || 0} </div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t.home.totalClosed}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-4xl font-bold text-foreground">{stats?.totalClosed || 0}</div>
</CardContent>
</Card>
</div>
)}
<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">
{i + 1}
</div>
<div className="text-sm font-medium">
{t.workflow[`step${i + 1}` as keyof typeof t.workflow]}
</div>
</CardContent>
</Card>
))}
</div>
</section>
</div>
);
}
@@ -0,0 +1,21 @@
import { Card, CardContent } from "@/components/ui/card";
import { AlertCircle } from "lucide-react";
export default function NotFound() {
return (
<div className="min-h-screen w-full flex items-center justify-center bg-gray-50">
<Card className="w-full max-w-md mx-4">
<CardContent className="pt-6">
<div className="flex mb-4 gap-2">
<AlertCircle className="h-8 w-8 text-red-500" />
<h1 className="text-2xl font-bold text-gray-900">404 Page Not Found</h1>
</div>
<p className="mt-4 text-sm text-gray-600">
Did you forget to add the page to the router?
</p>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,77 @@
import { useLanguage } from "../contexts/LanguageContext";
import { useListPublishedRequests, getListPublishedRequestsQueryKey } 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";
export default function Opportunities() {
const { t } = useLanguage();
const { data: requests, isLoading } = useListPublishedRequests();
return (
<div className="container mx-auto px-4 py-12">
<h1 className="text-3xl font-bold text-foreground mb-8">{t.opportunities.title}</h1>
{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>
) : (
<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;
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">
{t.statuses.verified}
</Badge>
</div>
<CardTitle className="text-lg line-clamp-1">{request.description}</CardTitle>
</CardHeader>
<CardContent className="pt-6 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>
</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">
<span className="text-sm text-muted-foreground">{t.opportunities.remaining}: </span>
<span className="font-bold text-xl">{remaining > 0 ? remaining : 0} </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>
);
})}
{(!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>
);
}
+268
View File
@@ -0,0 +1,268 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useLanguage } from "../contexts/LanguageContext";
import { useCreateRequest } from "@workspace/api-client-react";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { CheckCircle, XCircle, Clock } from "lucide-react";
const schema = z.object({
beneficiaryName: z.string().min(2),
nationalId: z.string().min(10).max(10),
phone: z.string().min(10),
source: z.enum(["beneficiary", "charity", "official"]),
sourceName: z.string().min(2),
needType: z.enum(["electricity", "water", "food", "health", "housing", "refrigerator", "air_conditioner", "court_order"]),
requestedAmount: z.coerce.number().positive(),
description: z.string().min(20),
});
type FormData = z.infer<typeof schema>;
export default function RequestSupport() {
const { t } = useLanguage();
const [submitted, setSubmitted] = useState<any>(null);
const createRequest = useCreateRequest();
const form = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
beneficiaryName: "",
nationalId: "",
phone: "",
source: "beneficiary",
sourceName: "",
needType: "electricity",
requestedAmount: 0,
description: "",
},
});
const onSubmit = (data: FormData) => {
createRequest.mutate(
{ data },
{
onSuccess: (result) => {
setSubmitted(result);
},
}
);
};
if (submitted) {
const isVerified = submitted.status === "verified";
const isRejected = submitted.status === "rejected";
const isPending = submitted.status === "pending_review";
return (
<div className="container mx-auto px-4 py-12 max-w-2xl">
<Card className="border-2">
<CardContent className="pt-8 pb-8 text-center">
{isVerified && (
<div className="flex flex-col items-center gap-4">
<CheckCircle className="w-16 h-16 text-green-600" />
<h2 className="text-2xl font-bold text-green-700">{isVerified ? (t as any).statuses.verified : ""}</h2>
<p className="text-muted-foreground">{t.request.submitSuccess}</p>
<Badge className="bg-green-600 text-white px-4 py-1 text-sm">{t.statuses.verified}</Badge>
</div>
)}
{isPending && (
<div className="flex flex-col items-center gap-4">
<Clock className="w-16 h-16 text-amber-500" />
<h2 className="text-2xl font-bold text-amber-600">{t.statuses.pending_review}</h2>
<p className="text-muted-foreground">{t.request.submitSuccess}</p>
<Badge variant="secondary" className="px-4 py-1 text-sm">{t.statuses.pending_review}</Badge>
</div>
)}
{isRejected && (
<div className="flex flex-col items-center gap-4">
<XCircle className="w-16 h-16 text-red-500" />
<h2 className="text-2xl font-bold text-red-600">{t.statuses.rejected}</h2>
<p className="text-muted-foreground">{submitted.rejectionReason}</p>
<Badge variant="destructive" className="px-4 py-1 text-sm">{t.statuses.rejected}</Badge>
</div>
)}
<div className="mt-6 p-4 bg-muted/30 rounded-lg text-sm text-start">
<p className="font-medium mb-1">{submitted.caseId}</p>
<p className="text-muted-foreground">{submitted.beneficiaryName}</p>
</div>
<Button className="mt-6" onClick={() => { setSubmitted(null); form.reset(); }}>
{t.common.back}
</Button>
</CardContent>
</Card>
</div>
);
}
return (
<div className="container mx-auto px-4 py-12 max-w-2xl">
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground">{t.request.title}</h1>
<p className="text-muted-foreground mt-2">
{t.home.heroSubtitle}
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="text-lg font-semibold text-primary">{t.request.title}</CardTitle>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-5">
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<FormField
control={form.control}
name="beneficiaryName"
render={({ field }) => (
<FormItem>
<FormLabel>{t.request.beneficiaryName}</FormLabel>
<FormControl>
<Input data-testid="input-beneficiaryName" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="nationalId"
render={({ field }) => (
<FormItem>
<FormLabel>{t.request.nationalId}</FormLabel>
<FormControl>
<Input data-testid="input-nationalId" maxLength={10} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel>{t.request.phone}</FormLabel>
<FormControl>
<Input data-testid="input-phone" type="tel" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<FormField
control={form.control}
name="source"
render={({ field }) => (
<FormItem>
<FormLabel>{t.request.source}</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger data-testid="select-source">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="beneficiary">{t.sources.beneficiary}</SelectItem>
<SelectItem value="charity">{t.sources.charity}</SelectItem>
<SelectItem value="official">{t.sources.official}</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="sourceName"
render={({ field }) => (
<FormItem>
<FormLabel>{t.request.sourceName}</FormLabel>
<FormControl>
<Input data-testid="input-sourceName" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<FormField
control={form.control}
name="needType"
render={({ field }) => (
<FormItem>
<FormLabel>{t.request.needType}</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger data-testid="select-needType">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.entries(t.needTypes).map(([key, label]) => (
<SelectItem key={key} value={key}>{label}</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="requestedAmount"
render={({ field }) => (
<FormItem>
<FormLabel>{t.request.amount} ()</FormLabel>
<FormControl>
<Input data-testid="input-amount" type="number" min={1} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>{t.request.description}</FormLabel>
<FormControl>
<Textarea data-testid="input-description" rows={4} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full"
disabled={createRequest.isPending}
data-testid="button-submit"
>
{createRequest.isPending ? t.common.loading : t.common.submit}
</Button>
</form>
</Form>
</CardContent>
</Card>
</div>
);
}
+162
View File
@@ -0,0 +1,162 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
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 { useQueryClient } from "@tanstack/react-query";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
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";
const schema = z.object({
beneficiaryName: z.string().min(2),
message: z.string().min(10),
});
type FormData = z.infer<typeof schema>;
export default function ThankYou() {
const { t } = useLanguage();
const params = useParams<{ id: string }>();
const [, setLocation] = useLocation();
const queryClient = useQueryClient();
const [submitted, setSubmitted] = useState(false);
const { data: request, isLoading } = useGetRequest(params.id || "", {
query: { enabled: !!params.id, queryKey: getGetRequestQueryKey(params.id || "") },
});
const submitThankYou = useSubmitThankYou();
const form = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
beneficiaryName: request?.beneficiaryName || "",
message: "",
},
});
const onSubmit = (data: FormData) => {
submitThankYou.mutate(
{
id: params.id || "",
data: { beneficiaryName: data.beneficiaryName, message: data.message },
},
{
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: getListRequestsQueryKey() });
queryClient.invalidateQueries({ queryKey: getGetRequestQueryKey(params.id || "") });
setSubmitted(true);
},
}
);
};
if (isLoading) {
return (
<div className="container mx-auto px-4 py-12 max-w-xl space-y-4">
<Skeleton className="h-10 w-48" />
<Skeleton className="h-64 w-full" />
</div>
);
}
if (submitted) {
return (
<div className="container mx-auto px-4 py-12 max-w-xl">
<Card className="border-2 border-green-200">
<CardContent className="pt-10 pb-10 text-center">
<div className="flex justify-center gap-2 mb-4">
<Heart className="w-12 h-12 text-green-600 fill-green-100" />
<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>
<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>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<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>
)}
</div>
<Card>
<CardHeader>
<CardTitle className="text-base text-primary">{t.thankYou.title}</CardTitle>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-5">
<FormField
control={form.control}
name="beneficiaryName"
render={({ field }) => (
<FormItem>
<FormLabel>{t.request.beneficiaryName}</FormLabel>
<FormControl>
<Input
data-testid="input-beneficiaryName"
defaultValue={request?.beneficiaryName || ""}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="message"
render={({ field }) => (
<FormItem>
<FormLabel>{t.thankYou.message}</FormLabel>
<FormControl>
<Textarea
data-testid="input-thankYouMessage"
rows={5}
placeholder="جزاكم الله خيراً، وصلني الدعم وكان له أثر كبير عليّ."
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full"
disabled={submitThankYou.isPending}
data-testid="button-submitThankYou"
>
{submitThankYou.isPending ? t.common.loading : t.thankYou.submitLabel}
</Button>
</form>
</Form>
</CardContent>
</Card>
</div>
);
}
+193
View File
@@ -0,0 +1,193 @@
import { useParams, useLocation } from "wouter";
import { useLanguage } from "../contexts/LanguageContext";
import { useGetRequest, getGetRequestQueryKey } from "@workspace/api-client-react";
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 { Link } from "wouter";
const STEPS = [
"step1", "step2", "step3", "step4", "step5",
"step6", "step7", "step8", "step9", "step10",
] as const;
const STATUS_TO_STEP: Record<string, number> = {
new: 1,
pending_review: 2,
verified: 3,
published: 4,
donated: 5,
delivered: 6,
receipt_confirmed: 7,
thank_you_submitted: 8,
whatsapp_sent: 9,
closed: 10,
rejected: 2,
};
export default function Track() {
const { t } = useLanguage();
const params = useParams<{ id: string }>();
const [, setLocation] = useLocation();
const { data: request, isLoading } = useGetRequest(params.id || "", {
query: { enabled: !!params.id, queryKey: getGetRequestQueryKey(params.id || "") },
});
if (isLoading) {
return (
<div className="container mx-auto px-4 py-12 max-w-3xl space-y-4">
<Skeleton className="h-10 w-48" />
<Skeleton className="h-96 w-full" />
</div>
);
}
if (!request) {
return (
<div className="container mx-auto px-4 py-12 text-center text-muted-foreground">
Case not found.
</div>
);
}
const currentStep = STATUS_TO_STEP[request.status] || 1;
const isRejected = request.status === "rejected";
return (
<div className="container mx-auto px-4 py-12 max-w-3xl">
<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>
</div>
<Badge
className={`text-sm px-3 py-1 ${
isRejected
? "bg-red-100 text-red-700 border-red-200"
: request.status === "closed"
? "bg-green-100 text-green-700 border-green-200"
: "bg-amber-100 text-amber-700 border-amber-200"
}`}
variant="outline"
>
{t.statuses[request.status as keyof typeof t.statuses] || request.status}
</Badge>
</div>
{/* Case Info */}
<Card className="mb-8 bg-muted/30">
<CardContent className="pt-5 pb-5">
<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>
</div>
<div>
<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="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="font-semibold">{request.donorName}</p>
</div>
)}
</div>
</CardContent>
</Card>
{/* Timeline */}
<Card>
<CardHeader>
<CardTitle className="text-base font-semibold">{t.track.caseTimeline}</CardTitle>
</CardHeader>
<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>
{request.rejectionReason && (
<p className="text-red-600 text-sm">{request.rejectionReason}</p>
)}
</div>
) : (
<div className="relative">
{STEPS.map((stepKey, index) => {
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 ${
isDone
? "bg-primary border-primary text-white"
: isCurrent
? "bg-primary/10 border-primary text-primary"
: "bg-muted border-border text-muted-foreground"
}`}
data-testid={`step-icon-${stepNum}`}
>
{isDone ? (
<Check className="w-4 h-4" />
) : isCurrent ? (
<Clock className="w-4 h-4" />
) : (
<span className="text-xs font-semibold">{stepNum}</span>
)}
</div>
{index < STEPS.length - 1 && (
<div
className={`w-0.5 h-6 mt-1 ${isDone ? "bg-primary" : "bg-border"}`}
/>
)}
</div>
{/* Label */}
<div className="pt-1.5">
<p
className={`font-medium text-sm ${
isDone
? "text-primary"
: isCurrent
? "text-foreground"
: "text-muted-foreground"
}`}
>
{t.workflow[stepKey as keyof typeof t.workflow]}
</p>
{isCurrent && (
<p className="text-xs text-primary mt-0.5 font-medium"> Current Step</p>
)}
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
{/* Actions */}
<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>
</Link>
)}
<Button variant="outline" onClick={() => setLocation("/admin")}>{t.common.adminDashboard}</Button>
<Button variant="ghost" onClick={() => setLocation("/opportunities")}>{t.common.back}</Button>
</div>
</div>
);
}
@@ -0,0 +1,152 @@
import { useLanguage } from "../contexts/LanguageContext";
import { useListWhatsappLog, useSendWhatsapp, getListWhatsappLogQueryKey, getListRequestsQueryKey } 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";
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();
const queryClient = useQueryClient();
const { data: logs, isLoading } = useListWhatsappLog();
const { data: allRequests } = useListRequests();
const sendWhatsapp = useSendWhatsapp();
const getRequestByCase = (caseId: string) =>
allRequests?.find((r) => r.caseId === caseId);
const handleSend = (caseId: string) => {
const req = getRequestByCase(caseId);
if (!req) return;
sendWhatsapp.mutate(
{ id: req.id },
{
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: getListWhatsappLogQueryKey() });
queryClient.invalidateQueries({ queryKey: getListRequestsQueryKey() });
},
}
);
};
const statusConfig = {
pending: {
icon: Clock,
className: "bg-amber-100 text-amber-700 border-amber-200",
label: t.whatsapp.pending,
},
sent: {
icon: CheckCircle,
className: "bg-green-100 text-green-700 border-green-200",
label: t.whatsapp.sent,
},
failed: {
icon: XCircle,
className: "bg-red-100 text-red-700 border-red-200",
label: t.whatsapp.failed,
},
};
return (
<div className="container mx-auto px-4 py-12">
<div className="mb-8 flex items-center gap-3">
<MessageSquare className="w-7 h-7 text-primary" />
<h1 className="text-3xl font-bold text-foreground">{t.whatsapp.title}</h1>
</div>
{isLoading ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-48 w-full" />
))}
</div>
) : !logs || logs.length === 0 ? (
<Card>
<CardContent className="py-16 text-center text-muted-foreground">
No WhatsApp log entries yet.
</CardContent>
</Card>
) : (
<div className="space-y-4">
{logs.map((log) => {
const status = statusConfig[log.status as keyof typeof statusConfig] || statusConfig.pending;
const StatusIcon = status.icon;
return (
<Card key={log.id} className="overflow-hidden" data-testid={`card-whatsapp-${log.id}`}>
<CardHeader className="pb-3 bg-muted/20">
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3">
<CardTitle className="text-base font-mono">{log.caseId}</CardTitle>
<Badge variant="outline" className={status.className}>
<StatusIcon className="w-3 h-3 me-1" />
{status.label}
</Badge>
</div>
{log.status === "pending" && (
<Button
size="sm"
onClick={() => handleSend(log.caseId)}
disabled={sendWhatsapp.isPending}
className="gap-2"
data-testid={`button-sendWhatsapp-${log.id}`}
>
<Send className="w-3.5 h-3.5" />
{t.whatsapp.sendViaOpenClaw}
</Button>
)}
</div>
</CardHeader>
<CardContent className="pt-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Donor Info */}
<div>
<p className="text-xs font-semibold uppercase text-muted-foreground tracking-wide mb-2">
{t.whatsapp.donor}
</p>
<p className="font-medium">{log.donorName}</p>
<div className="flex items-center gap-1.5 text-sm text-muted-foreground mt-1">
<Phone className="w-3.5 h-3.5" />
<span>{log.donorPhone}</span>
</div>
</div>
{/* Beneficiary Message */}
<div>
<p className="text-xs font-semibold uppercase text-muted-foreground tracking-wide mb-2">
Beneficiary Message
</p>
<p className="text-sm text-foreground italic">"{log.beneficiaryMessage}"</p>
</div>
</div>
<Separator className="my-4" />
{/* WhatsApp Message Preview */}
<div>
<p className="text-xs font-semibold uppercase text-muted-foreground tracking-wide mb-2">
{t.whatsapp.message}
</p>
<div className="bg-[#dcf8c6] dark:bg-green-900/30 rounded-xl rounded-tl-sm p-4 max-w-lg text-sm whitespace-pre-line text-gray-800 dark:text-gray-200 border border-green-200 dark:border-green-700">
{log.whatsappMessage}
</div>
</div>
{log.sentAt && (
<p className="text-xs text-muted-foreground mt-3">
{t.whatsapp.sentAt}: {new Date(log.sentAt).toLocaleString()}
</p>
)}
</CardContent>
</Card>
);
})}
</div>
)}
</div>
);
}