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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user