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:
@@ -1,4 +1,4 @@
|
||||
import { Router } from "express";
|
||||
import { Router, Request, Response } from "express";
|
||||
import {
|
||||
requests,
|
||||
donors,
|
||||
@@ -12,8 +12,8 @@ import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ─── GET /requests ───────────────────────────────────────────────────────────
|
||||
router.get("/requests", (req, res) => {
|
||||
// ─── GET /requests ────────────────────────────────────────────────────────────
|
||||
router.get("/requests", (req: Request, res: Response): void => {
|
||||
let result = [...requests];
|
||||
const { status, needType } = req.query;
|
||||
if (status) result = result.filter((r) => r.status === status);
|
||||
@@ -21,18 +21,18 @@ router.get("/requests", (req, res) => {
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// ─── GET /requests/new ──────────────────────────────────────────────────────
|
||||
router.get("/requests/new", (_req, res) => {
|
||||
// ─── GET /requests/new ───────────────────────────────────────────────────────
|
||||
router.get("/requests/new", (_req: Request, res: Response): void => {
|
||||
res.json(requests.filter((r) => r.status === "new"));
|
||||
});
|
||||
|
||||
// ─── GET /requests/published ────────────────────────────────────────────────
|
||||
router.get("/requests/published", (_req, res) => {
|
||||
// ─── GET /requests/published ─────────────────────────────────────────────────
|
||||
router.get("/requests/published", (_req: Request, res: Response): void => {
|
||||
res.json(requests.filter((r) => r.status === "published"));
|
||||
});
|
||||
|
||||
// ─── POST /requests ──────────────────────────────────────────────────────────
|
||||
router.post("/requests", (req, res) => {
|
||||
// ─── POST /requests ───────────────────────────────────────────────────────────
|
||||
router.post("/requests", (req: Request, res: Response): void => {
|
||||
const {
|
||||
beneficiaryName,
|
||||
nationalId,
|
||||
@@ -54,7 +54,8 @@ router.post("/requests", (req, res) => {
|
||||
!requestedAmount ||
|
||||
!description
|
||||
) {
|
||||
return res.status(400).json({ error: "Missing required fields" });
|
||||
res.status(400).json({ error: "Missing required fields" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { eligible } = checkEligibility(nationalId);
|
||||
@@ -98,31 +99,39 @@ router.post("/requests", (req, res) => {
|
||||
};
|
||||
|
||||
requests.push(newReq);
|
||||
return res.status(201).json(newReq);
|
||||
res.status(201).json(newReq);
|
||||
});
|
||||
|
||||
// ─── GET /requests/:id ───────────────────────────────────────────────────────
|
||||
router.get("/requests/:id", (req, res) => {
|
||||
const item = requests.find((r) => r.id === req.params.id || r.caseId === req.params.id);
|
||||
if (!item) return res.status(404).json({ error: "Not found" });
|
||||
// ─── GET /requests/:id ────────────────────────────────────────────────────────
|
||||
router.get("/requests/:id", (req: Request, res: Response): void => {
|
||||
const item = requests.find(
|
||||
(r) => r.id === req.params.id || r.caseId === req.params.id
|
||||
);
|
||||
if (!item) {
|
||||
res.status(404).json({ error: "Not found" });
|
||||
return;
|
||||
}
|
||||
res.json(item);
|
||||
});
|
||||
|
||||
// ─── Helper: find & update ───────────────────────────────────────────────────
|
||||
// ─── Helper: find & update ────────────────────────────────────────────────────
|
||||
function findAndUpdate(
|
||||
id: string,
|
||||
updater: (r: DonationRequest) => void,
|
||||
res: any
|
||||
) {
|
||||
res: Response
|
||||
): void {
|
||||
const item = requests.find((r) => r.id === id || r.caseId === id);
|
||||
if (!item) return res.status(404).json({ error: "Not found" });
|
||||
if (!item) {
|
||||
res.status(404).json({ error: "Not found" });
|
||||
return;
|
||||
}
|
||||
updater(item);
|
||||
item.updatedAt = new Date().toISOString();
|
||||
res.json(item);
|
||||
}
|
||||
|
||||
// ─── POST /requests/:id/verify ───────────────────────────────────────────────
|
||||
router.post("/requests/:id/verify", (req, res) => {
|
||||
// ─── POST /requests/:id/verify ────────────────────────────────────────────────
|
||||
router.post("/requests/:id/verify", (req: Request, res: Response): void => {
|
||||
findAndUpdate(
|
||||
req.params.id,
|
||||
(r) => {
|
||||
@@ -133,8 +142,8 @@ router.post("/requests/:id/verify", (req, res) => {
|
||||
);
|
||||
});
|
||||
|
||||
// ─── POST /requests/:id/publish ──────────────────────────────────────────────
|
||||
router.post("/requests/:id/publish", (req, res) => {
|
||||
// ─── POST /requests/:id/publish ───────────────────────────────────────────────
|
||||
router.post("/requests/:id/publish", (req: Request, res: Response): void => {
|
||||
findAndUpdate(
|
||||
req.params.id,
|
||||
(r) => {
|
||||
@@ -145,20 +154,23 @@ router.post("/requests/:id/publish", (req, res) => {
|
||||
);
|
||||
});
|
||||
|
||||
// ─── POST /requests/:id/donate ───────────────────────────────────────────────
|
||||
router.post("/requests/:id/donate", (req, res) => {
|
||||
// ─── POST /requests/:id/donate ────────────────────────────────────────────────
|
||||
router.post("/requests/:id/donate", (req: Request, res: Response): void => {
|
||||
const item = requests.find(
|
||||
(r) => r.id === req.params.id || r.caseId === req.params.id
|
||||
);
|
||||
if (!item) return res.status(404).json({ error: "Not found" });
|
||||
if (!item) {
|
||||
res.status(404).json({ error: "Not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { donorName, donorPhone, donorEmail, amount } = req.body;
|
||||
if (!donorName || !donorPhone || !amount) {
|
||||
return res.status(400).json({ error: "Missing donor details" });
|
||||
res.status(400).json({ error: "Missing donor details" });
|
||||
return;
|
||||
}
|
||||
|
||||
const donorId = uuidv4();
|
||||
// add/update donor record
|
||||
const existingDonor = donors.find((d) => d.phone === donorPhone);
|
||||
if (existingDonor) {
|
||||
existingDonor.totalDonated += Number(amount);
|
||||
@@ -185,8 +197,8 @@ router.post("/requests/:id/donate", (req, res) => {
|
||||
res.json(item);
|
||||
});
|
||||
|
||||
// ─── POST /requests/:id/deliver ──────────────────────────────────────────────
|
||||
router.post("/requests/:id/deliver", (req, res) => {
|
||||
// ─── POST /requests/:id/deliver ───────────────────────────────────────────────
|
||||
router.post("/requests/:id/deliver", (req: Request, res: Response): void => {
|
||||
findAndUpdate(
|
||||
req.params.id,
|
||||
(r) => {
|
||||
@@ -197,8 +209,10 @@ router.post("/requests/:id/deliver", (req, res) => {
|
||||
);
|
||||
});
|
||||
|
||||
// ─── POST /requests/:id/confirm-receipt ──────────────────────────────────────
|
||||
router.post("/requests/:id/confirm-receipt", (req, res) => {
|
||||
// ─── POST /requests/:id/confirm-receipt ───────────────────────────────────────
|
||||
router.post(
|
||||
"/requests/:id/confirm-receipt",
|
||||
(req: Request, res: Response): void => {
|
||||
findAndUpdate(
|
||||
req.params.id,
|
||||
(r) => {
|
||||
@@ -207,17 +221,29 @@ router.post("/requests/:id/confirm-receipt", (req, res) => {
|
||||
},
|
||||
res
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// ─── POST /requests/:id/thank-you ────────────────────────────────────────────
|
||||
router.post("/requests/:id/thank-you", (req, res) => {
|
||||
// ─── POST /requests/:id/thank-you ─────────────────────────────────────────────
|
||||
router.post("/requests/:id/thank-you", (req: Request, res: Response): void => {
|
||||
const item = requests.find(
|
||||
(r) => r.id === req.params.id || r.caseId === req.params.id
|
||||
);
|
||||
if (!item) return res.status(404).json({ error: "Not found" });
|
||||
if (!item) {
|
||||
res.status(404).json({ error: "Not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { message } = req.body;
|
||||
if (!message) return res.status(400).json({ error: "Message is required" });
|
||||
const { message, beneficiaryName } = req.body;
|
||||
if (!message) {
|
||||
res.status(400).json({ error: "Message is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Update beneficiary name if provided
|
||||
if (beneficiaryName) {
|
||||
item.beneficiaryName = beneficiaryName;
|
||||
}
|
||||
|
||||
item.thankYouMessage = message;
|
||||
item.status = "thank_you_submitted";
|
||||
@@ -242,39 +268,40 @@ router.post("/requests/:id/thank-you", (req, res) => {
|
||||
res.json(item);
|
||||
});
|
||||
|
||||
// ─── POST /requests/:id/send-whatsapp ────────────────────────────────────────
|
||||
router.post("/requests/:id/send-whatsapp", async (req, res) => {
|
||||
// ─── POST /requests/:id/send-whatsapp ─────────────────────────────────────────
|
||||
router.post(
|
||||
"/requests/:id/send-whatsapp",
|
||||
async (req: Request, res: Response): Promise<void> => {
|
||||
const item = requests.find(
|
||||
(r) => r.id === req.params.id || r.caseId === req.params.id
|
||||
);
|
||||
if (!item) return res.status(404).json({ error: "Not found" });
|
||||
if (!item) {
|
||||
res.status(404).json({ error: "Not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const simulate = process.env.OPENCLAW_SIMULATE !== "false";
|
||||
const openclawUrl =
|
||||
process.env.OPENCLAW_BASE_URL || "http://localhost:3100";
|
||||
|
||||
const openclawUrl = process.env.OPENCLAW_BASE_URL || "http://localhost:3100";
|
||||
const logEntry = whatsappLog.find((w) => w.caseId === item.caseId);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (simulate) {
|
||||
// Simulate mode — mark as sent without calling OpenClaw
|
||||
item.whatsappStatus = "sent";
|
||||
item.whatsappSentAt = now;
|
||||
item.status = "whatsapp_sent";
|
||||
item.currentStep = STATUS_STEP["whatsapp_sent"];
|
||||
item.updatedAt = now;
|
||||
|
||||
if (logEntry) {
|
||||
logEntry.status = "sent";
|
||||
logEntry.sentAt = now;
|
||||
}
|
||||
|
||||
return res.json({
|
||||
res.json({
|
||||
success: true,
|
||||
message: "WhatsApp sent (simulated)",
|
||||
simulated: true,
|
||||
sentAt: now,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Live mode — call OpenClaw
|
||||
@@ -301,37 +328,35 @@ router.post("/requests/:id/send-whatsapp", async (req, res) => {
|
||||
item.status = "whatsapp_sent";
|
||||
item.currentStep = STATUS_STEP["whatsapp_sent"];
|
||||
item.updatedAt = now;
|
||||
|
||||
if (logEntry) {
|
||||
logEntry.status = "sent";
|
||||
logEntry.sentAt = now;
|
||||
}
|
||||
|
||||
return res.json({
|
||||
res.json({
|
||||
success: true,
|
||||
message: "WhatsApp sent via OpenClaw",
|
||||
simulated: false,
|
||||
sentAt: now,
|
||||
});
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
item.whatsappStatus = "failed";
|
||||
item.updatedAt = now;
|
||||
|
||||
if (logEntry) {
|
||||
logEntry.status = "failed";
|
||||
}
|
||||
|
||||
return res.json({
|
||||
res.json({
|
||||
success: false,
|
||||
message: `Failed to send via OpenClaw: ${err.message}`,
|
||||
message: `Failed to send via OpenClaw: ${message}`,
|
||||
simulated: false,
|
||||
sentAt: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// ─── POST /requests/:id/close ────────────────────────────────────────────────
|
||||
router.post("/requests/:id/close", (req, res) => {
|
||||
// ─── POST /requests/:id/close ─────────────────────────────────────────────────
|
||||
router.post("/requests/:id/close", (req: Request, res: Response): void => {
|
||||
findAndUpdate(
|
||||
req.params.id,
|
||||
(r) => {
|
||||
@@ -342,12 +367,15 @@ router.post("/requests/:id/close", (req, res) => {
|
||||
);
|
||||
});
|
||||
|
||||
// ─── POST /requests/:id/reject ───────────────────────────────────────────────
|
||||
router.post("/requests/:id/reject", (req, res) => {
|
||||
// ─── POST /requests/:id/reject ────────────────────────────────────────────────
|
||||
router.post("/requests/:id/reject", (req: Request, res: Response): void => {
|
||||
const item = requests.find(
|
||||
(r) => r.id === req.params.id || r.caseId === req.params.id
|
||||
);
|
||||
if (!item) return res.status(404).json({ error: "Not found" });
|
||||
if (!item) {
|
||||
res.status(404).json({ error: "Not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
item.status = "rejected";
|
||||
item.currentStep = STATUS_STEP["rejected"];
|
||||
|
||||
@@ -15,6 +15,16 @@ export const en = {
|
||||
back: "Back",
|
||||
confirm: "Confirm",
|
||||
language: "العربية",
|
||||
trackCase: "Track Case",
|
||||
notFound: "Case not found.",
|
||||
noData: "No data available.",
|
||||
currentStep: "Current Step",
|
||||
pleaseWait: "Please wait...",
|
||||
search: "Search",
|
||||
searchPlaceholder: "Search by name, case ID, or description...",
|
||||
allOpportunities: "All Opportunities",
|
||||
featuredCases: "Featured Cases",
|
||||
donate: "Donate",
|
||||
},
|
||||
home: {
|
||||
heroTitle: "Closed Donation Loop POC",
|
||||
@@ -22,8 +32,13 @@ export const en = {
|
||||
totalRequests: "Total Requests",
|
||||
totalCollected: "Total Collected",
|
||||
totalClosed: "Closed Cases",
|
||||
viewOpportunities: "View Opportunities",
|
||||
viewOpportunities: "View All Opportunities",
|
||||
workflowTitle: "Closed Donation Loop Workflow",
|
||||
searchOpportunities: "Search Donation Opportunities",
|
||||
searchLabel: "Find a cause to support",
|
||||
searchButton: "Search",
|
||||
featuredTitle: "Featured Opportunities",
|
||||
noResults: "No opportunities match your search.",
|
||||
},
|
||||
workflow: {
|
||||
step1: "Request Submitted",
|
||||
@@ -85,22 +100,26 @@ export const en = {
|
||||
collected: "Collected",
|
||||
remaining: "Remaining",
|
||||
target: "Target",
|
||||
noOpportunities: "No opportunities are available right now.",
|
||||
verified: "Verified",
|
||||
},
|
||||
donate: {
|
||||
title: "Complete Donation",
|
||||
caseSummary: "Case Summary",
|
||||
donorName: "Donor Name",
|
||||
donorPhone: "Phone Number",
|
||||
donorEmail: "Email (Optional)",
|
||||
amount: "Donation Amount",
|
||||
confirmDonation: "Confirm Donation",
|
||||
successMessage: "Thank you for your donation. May Allah reward you.",
|
||||
caseNotFound: "Case not found or no longer available.",
|
||||
},
|
||||
admin: {
|
||||
title: "Admin Dashboard",
|
||||
caseId: "Case ID",
|
||||
beneficiary: "Beneficiary",
|
||||
status: "Status",
|
||||
currentStep: "Current Step",
|
||||
currentStep: "Step",
|
||||
actions: "Actions",
|
||||
verify: "Verify",
|
||||
publish: "Publish",
|
||||
@@ -109,27 +128,41 @@ export const en = {
|
||||
close: "Close Case",
|
||||
reject: "Reject",
|
||||
rejectionReason: "Rejection Reason",
|
||||
track: "Track",
|
||||
whatsapp: "WhatsApp",
|
||||
noRequests: "No requests found.",
|
||||
needType: "Need Type",
|
||||
amount: "Amount",
|
||||
},
|
||||
track: {
|
||||
title: "Track Case",
|
||||
caseTimeline: "Case Timeline",
|
||||
caseInfo: "Case Information",
|
||||
rejected: "Case Rejected",
|
||||
currentStepLabel: "Current",
|
||||
submitThankYou: "Submit Thank-You Message",
|
||||
},
|
||||
thankYou: {
|
||||
title: "Submit Thank You Message",
|
||||
message: "Thank You Message",
|
||||
submitLabel: "Send Message to Donor",
|
||||
successNote: "Your thank-you message will be sent to the donor via WhatsApp through OpenClaw.",
|
||||
beneficiaryMessageLabel: "Beneficiary Message",
|
||||
},
|
||||
whatsapp: {
|
||||
title: "WhatsApp Log",
|
||||
donor: "Donor",
|
||||
message: "Message",
|
||||
donorPhone: "Phone",
|
||||
message: "WhatsApp Message",
|
||||
beneficiaryMessage: "Beneficiary Message",
|
||||
status: "Status",
|
||||
sentAt: "Sent At",
|
||||
sendViaOpenClaw: "Send via OpenClaw",
|
||||
pending: "Pending",
|
||||
sent: "Sent",
|
||||
failed: "Failed",
|
||||
}
|
||||
noEntries: "No WhatsApp log entries yet.",
|
||||
},
|
||||
};
|
||||
|
||||
export const ar = {
|
||||
@@ -149,6 +182,16 @@ export const ar = {
|
||||
back: "رجوع",
|
||||
confirm: "تأكيد",
|
||||
language: "English",
|
||||
trackCase: "تتبع الحالة",
|
||||
notFound: "الحالة غير موجودة.",
|
||||
noData: "لا توجد بيانات.",
|
||||
currentStep: "الخطوة الحالية",
|
||||
pleaseWait: "يرجى الانتظار...",
|
||||
search: "بحث",
|
||||
searchPlaceholder: "ابحث بالاسم أو رقم الحالة أو الوصف...",
|
||||
allOpportunities: "جميع الفرص",
|
||||
featuredCases: "الحالات المميزة",
|
||||
donate: "تبرع",
|
||||
},
|
||||
home: {
|
||||
heroTitle: "إقفال دورة التبرع",
|
||||
@@ -156,8 +199,13 @@ export const ar = {
|
||||
totalRequests: "إجمالي الطلبات",
|
||||
totalCollected: "إجمالي التبرعات (ريال)",
|
||||
totalClosed: "الحالات المغلقة",
|
||||
viewOpportunities: "استعراض الفرص",
|
||||
viewOpportunities: "عرض جميع الفرص",
|
||||
workflowTitle: "خطوات إقفال دورة التبرع",
|
||||
searchOpportunities: "ابحث في فرص التبرع",
|
||||
searchLabel: "ابحث عن قضية لدعمها",
|
||||
searchButton: "بحث",
|
||||
featuredTitle: "الفرص المميزة",
|
||||
noResults: "لا توجد فرص تطابق بحثك.",
|
||||
},
|
||||
workflow: {
|
||||
step1: "مقدم الطلب",
|
||||
@@ -219,22 +267,26 @@ export const ar = {
|
||||
collected: "المجموع",
|
||||
remaining: "المتبقي",
|
||||
target: "الهدف",
|
||||
noOpportunities: "لا توجد فرص متاحة حالياً.",
|
||||
verified: "موثق",
|
||||
},
|
||||
donate: {
|
||||
title: "إتمام التبرع",
|
||||
caseSummary: "ملخص الحالة",
|
||||
donorName: "اسم المتبرع",
|
||||
donorPhone: "رقم الجوال",
|
||||
donorEmail: "البريد الإلكتروني (اختياري)",
|
||||
amount: "مبلغ التبرع",
|
||||
confirmDonation: "تأكيد التبرع",
|
||||
successMessage: "شكراً لتبرعك. جزاك الله خيراً.",
|
||||
caseNotFound: "الحالة غير موجودة أو لم تعد متاحة.",
|
||||
},
|
||||
admin: {
|
||||
title: "لوحة الإدارة",
|
||||
caseId: "رقم الحالة",
|
||||
beneficiary: "المستفيد",
|
||||
status: "الحالة",
|
||||
currentStep: "الخطوة الحالية",
|
||||
currentStep: "الخطوة",
|
||||
actions: "الإجراءات",
|
||||
verify: "توثيق",
|
||||
publish: "نشر",
|
||||
@@ -243,25 +295,39 @@ export const ar = {
|
||||
close: "إغلاق الحالة",
|
||||
reject: "رفض",
|
||||
rejectionReason: "سبب الرفض",
|
||||
track: "تتبع",
|
||||
whatsapp: "واتساب",
|
||||
noRequests: "لا توجد طلبات.",
|
||||
needType: "نوع الاحتياج",
|
||||
amount: "المبلغ",
|
||||
},
|
||||
track: {
|
||||
title: "تتبع الحالة",
|
||||
caseTimeline: "مسار الحالة",
|
||||
caseInfo: "معلومات الحالة",
|
||||
rejected: "تم رفض الحالة",
|
||||
currentStepLabel: "الحالية",
|
||||
submitThankYou: "تقديم رسالة الشكر",
|
||||
},
|
||||
thankYou: {
|
||||
title: "تقديم رسالة الشكر",
|
||||
message: "رسالة الشكر",
|
||||
submitLabel: "إرسال الرسالة للمتبرع",
|
||||
successNote: "سيتم إرسال رسالة شكرك إلى المتبرع عبر واتساب من خلال OpenClaw.",
|
||||
beneficiaryMessageLabel: "رسالة المستفيد",
|
||||
},
|
||||
whatsapp: {
|
||||
title: "سجل رسائل الواتساب",
|
||||
donor: "المتبرع",
|
||||
message: "الرسالة",
|
||||
donorPhone: "الجوال",
|
||||
message: "رسالة الواتساب",
|
||||
beneficiaryMessage: "رسالة المستفيد",
|
||||
status: "الحالة",
|
||||
sentAt: "وقت الإرسال",
|
||||
sendViaOpenClaw: "إرسال عبر OpenClaw",
|
||||
pending: "قيد الانتظار",
|
||||
sent: "مرسل",
|
||||
failed: "فشل",
|
||||
}
|
||||
noEntries: "لا توجد سجلات واتساب بعد.",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
@@ -37,8 +49,8 @@ export default function Admin() {
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -1,19 +1,66 @@
|
||||
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">
|
||||
@@ -21,11 +68,20 @@ export default function Opportunities() {
|
||||
<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">
|
||||
@@ -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,12 +125,6 @@ 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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user