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 {
|
import {
|
||||||
requests,
|
requests,
|
||||||
donors,
|
donors,
|
||||||
@@ -12,8 +12,8 @@ import { v4 as uuidv4 } from "uuid";
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// ─── GET /requests ───────────────────────────────────────────────────────────
|
// ─── GET /requests ────────────────────────────────────────────────────────────
|
||||||
router.get("/requests", (req, res) => {
|
router.get("/requests", (req: Request, res: Response): void => {
|
||||||
let result = [...requests];
|
let result = [...requests];
|
||||||
const { status, needType } = req.query;
|
const { status, needType } = req.query;
|
||||||
if (status) result = result.filter((r) => r.status === status);
|
if (status) result = result.filter((r) => r.status === status);
|
||||||
@@ -21,18 +21,18 @@ router.get("/requests", (req, res) => {
|
|||||||
res.json(result);
|
res.json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── GET /requests/new ──────────────────────────────────────────────────────
|
// ─── GET /requests/new ───────────────────────────────────────────────────────
|
||||||
router.get("/requests/new", (_req, res) => {
|
router.get("/requests/new", (_req: Request, res: Response): void => {
|
||||||
res.json(requests.filter((r) => r.status === "new"));
|
res.json(requests.filter((r) => r.status === "new"));
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── GET /requests/published ────────────────────────────────────────────────
|
// ─── GET /requests/published ─────────────────────────────────────────────────
|
||||||
router.get("/requests/published", (_req, res) => {
|
router.get("/requests/published", (_req: Request, res: Response): void => {
|
||||||
res.json(requests.filter((r) => r.status === "published"));
|
res.json(requests.filter((r) => r.status === "published"));
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── POST /requests ──────────────────────────────────────────────────────────
|
// ─── POST /requests ───────────────────────────────────────────────────────────
|
||||||
router.post("/requests", (req, res) => {
|
router.post("/requests", (req: Request, res: Response): void => {
|
||||||
const {
|
const {
|
||||||
beneficiaryName,
|
beneficiaryName,
|
||||||
nationalId,
|
nationalId,
|
||||||
@@ -54,7 +54,8 @@ router.post("/requests", (req, res) => {
|
|||||||
!requestedAmount ||
|
!requestedAmount ||
|
||||||
!description
|
!description
|
||||||
) {
|
) {
|
||||||
return res.status(400).json({ error: "Missing required fields" });
|
res.status(400).json({ error: "Missing required fields" });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { eligible } = checkEligibility(nationalId);
|
const { eligible } = checkEligibility(nationalId);
|
||||||
@@ -98,31 +99,39 @@ router.post("/requests", (req, res) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
requests.push(newReq);
|
requests.push(newReq);
|
||||||
return res.status(201).json(newReq);
|
res.status(201).json(newReq);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── GET /requests/:id ───────────────────────────────────────────────────────
|
// ─── GET /requests/:id ────────────────────────────────────────────────────────
|
||||||
router.get("/requests/:id", (req, res) => {
|
router.get("/requests/:id", (req: Request, res: Response): void => {
|
||||||
const item = requests.find((r) => r.id === req.params.id || r.caseId === req.params.id);
|
const item = requests.find(
|
||||||
if (!item) return res.status(404).json({ error: "Not found" });
|
(r) => r.id === req.params.id || r.caseId === req.params.id
|
||||||
|
);
|
||||||
|
if (!item) {
|
||||||
|
res.status(404).json({ error: "Not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
res.json(item);
|
res.json(item);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Helper: find & update ───────────────────────────────────────────────────
|
// ─── Helper: find & update ────────────────────────────────────────────────────
|
||||||
function findAndUpdate(
|
function findAndUpdate(
|
||||||
id: string,
|
id: string,
|
||||||
updater: (r: DonationRequest) => void,
|
updater: (r: DonationRequest) => void,
|
||||||
res: any
|
res: Response
|
||||||
) {
|
): void {
|
||||||
const item = requests.find((r) => r.id === id || r.caseId === id);
|
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);
|
updater(item);
|
||||||
item.updatedAt = new Date().toISOString();
|
item.updatedAt = new Date().toISOString();
|
||||||
res.json(item);
|
res.json(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── POST /requests/:id/verify ───────────────────────────────────────────────
|
// ─── POST /requests/:id/verify ────────────────────────────────────────────────
|
||||||
router.post("/requests/:id/verify", (req, res) => {
|
router.post("/requests/:id/verify", (req: Request, res: Response): void => {
|
||||||
findAndUpdate(
|
findAndUpdate(
|
||||||
req.params.id,
|
req.params.id,
|
||||||
(r) => {
|
(r) => {
|
||||||
@@ -133,8 +142,8 @@ router.post("/requests/:id/verify", (req, res) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── POST /requests/:id/publish ──────────────────────────────────────────────
|
// ─── POST /requests/:id/publish ───────────────────────────────────────────────
|
||||||
router.post("/requests/:id/publish", (req, res) => {
|
router.post("/requests/:id/publish", (req: Request, res: Response): void => {
|
||||||
findAndUpdate(
|
findAndUpdate(
|
||||||
req.params.id,
|
req.params.id,
|
||||||
(r) => {
|
(r) => {
|
||||||
@@ -145,20 +154,23 @@ router.post("/requests/:id/publish", (req, res) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── POST /requests/:id/donate ───────────────────────────────────────────────
|
// ─── POST /requests/:id/donate ────────────────────────────────────────────────
|
||||||
router.post("/requests/:id/donate", (req, res) => {
|
router.post("/requests/:id/donate", (req: Request, res: Response): void => {
|
||||||
const item = requests.find(
|
const item = requests.find(
|
||||||
(r) => r.id === req.params.id || r.caseId === req.params.id
|
(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;
|
const { donorName, donorPhone, donorEmail, amount } = req.body;
|
||||||
if (!donorName || !donorPhone || !amount) {
|
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();
|
const donorId = uuidv4();
|
||||||
// add/update donor record
|
|
||||||
const existingDonor = donors.find((d) => d.phone === donorPhone);
|
const existingDonor = donors.find((d) => d.phone === donorPhone);
|
||||||
if (existingDonor) {
|
if (existingDonor) {
|
||||||
existingDonor.totalDonated += Number(amount);
|
existingDonor.totalDonated += Number(amount);
|
||||||
@@ -185,8 +197,8 @@ router.post("/requests/:id/donate", (req, res) => {
|
|||||||
res.json(item);
|
res.json(item);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── POST /requests/:id/deliver ──────────────────────────────────────────────
|
// ─── POST /requests/:id/deliver ───────────────────────────────────────────────
|
||||||
router.post("/requests/:id/deliver", (req, res) => {
|
router.post("/requests/:id/deliver", (req: Request, res: Response): void => {
|
||||||
findAndUpdate(
|
findAndUpdate(
|
||||||
req.params.id,
|
req.params.id,
|
||||||
(r) => {
|
(r) => {
|
||||||
@@ -197,27 +209,41 @@ router.post("/requests/:id/deliver", (req, res) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── POST /requests/:id/confirm-receipt ──────────────────────────────────────
|
// ─── POST /requests/:id/confirm-receipt ───────────────────────────────────────
|
||||||
router.post("/requests/:id/confirm-receipt", (req, res) => {
|
router.post(
|
||||||
findAndUpdate(
|
"/requests/:id/confirm-receipt",
|
||||||
req.params.id,
|
(req: Request, res: Response): void => {
|
||||||
(r) => {
|
findAndUpdate(
|
||||||
r.status = "receipt_confirmed";
|
req.params.id,
|
||||||
r.currentStep = STATUS_STEP["receipt_confirmed"];
|
(r) => {
|
||||||
},
|
r.status = "receipt_confirmed";
|
||||||
res
|
r.currentStep = STATUS_STEP["receipt_confirmed"];
|
||||||
);
|
},
|
||||||
});
|
res
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// ─── POST /requests/:id/thank-you ────────────────────────────────────────────
|
// ─── POST /requests/:id/thank-you ─────────────────────────────────────────────
|
||||||
router.post("/requests/:id/thank-you", (req, res) => {
|
router.post("/requests/:id/thank-you", (req: Request, res: Response): void => {
|
||||||
const item = requests.find(
|
const item = requests.find(
|
||||||
(r) => r.id === req.params.id || r.caseId === req.params.id
|
(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;
|
const { message, beneficiaryName } = req.body;
|
||||||
if (!message) return res.status(400).json({ error: "Message is required" });
|
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.thankYouMessage = message;
|
||||||
item.status = "thank_you_submitted";
|
item.status = "thank_you_submitted";
|
||||||
@@ -242,96 +268,95 @@ router.post("/requests/:id/thank-you", (req, res) => {
|
|||||||
res.json(item);
|
res.json(item);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── POST /requests/:id/send-whatsapp ────────────────────────────────────────
|
// ─── POST /requests/:id/send-whatsapp ─────────────────────────────────────────
|
||||||
router.post("/requests/:id/send-whatsapp", async (req, res) => {
|
router.post(
|
||||||
const item = requests.find(
|
"/requests/:id/send-whatsapp",
|
||||||
(r) => r.id === req.params.id || r.caseId === req.params.id
|
async (req: Request, res: Response): Promise<void> => {
|
||||||
);
|
const item = requests.find(
|
||||||
if (!item) return res.status(404).json({ error: "Not found" });
|
(r) => r.id === req.params.id || r.caseId === req.params.id
|
||||||
|
);
|
||||||
const simulate = process.env.OPENCLAW_SIMULATE !== "false";
|
if (!item) {
|
||||||
const openclawUrl =
|
res.status(404).json({ error: "Not found" });
|
||||||
process.env.OPENCLAW_BASE_URL || "http://localhost:3100";
|
return;
|
||||||
|
|
||||||
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({
|
const simulate = process.env.OPENCLAW_SIMULATE !== "false";
|
||||||
success: true,
|
const openclawUrl = process.env.OPENCLAW_BASE_URL || "http://localhost:3100";
|
||||||
message: "WhatsApp sent (simulated)",
|
const logEntry = whatsappLog.find((w) => w.caseId === item.caseId);
|
||||||
simulated: true,
|
const now = new Date().toISOString();
|
||||||
sentAt: now,
|
|
||||||
});
|
if (simulate) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "WhatsApp sent (simulated)",
|
||||||
|
simulated: true,
|
||||||
|
sentAt: now,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live mode — call OpenClaw
|
||||||
|
try {
|
||||||
|
const donor = donors.find((d) => d.id === item.donorId);
|
||||||
|
const payload = {
|
||||||
|
to: donor?.phone || "",
|
||||||
|
message: logEntry?.whatsappMessage || "",
|
||||||
|
caseId: item.caseId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`${openclawUrl}/api/whatsapp/send`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`OpenClaw returned ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "WhatsApp sent via OpenClaw",
|
||||||
|
simulated: false,
|
||||||
|
sentAt: now,
|
||||||
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
item.whatsappStatus = "failed";
|
||||||
|
item.updatedAt = now;
|
||||||
|
if (logEntry) {
|
||||||
|
logEntry.status = "failed";
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
success: false,
|
||||||
|
message: `Failed to send via OpenClaw: ${message}`,
|
||||||
|
simulated: false,
|
||||||
|
sentAt: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Live mode — call OpenClaw
|
// ─── POST /requests/:id/close ─────────────────────────────────────────────────
|
||||||
try {
|
router.post("/requests/:id/close", (req: Request, res: Response): void => {
|
||||||
const donor = donors.find((d) => d.id === item.donorId);
|
|
||||||
const payload = {
|
|
||||||
to: donor?.phone || "",
|
|
||||||
message: logEntry?.whatsappMessage || "",
|
|
||||||
caseId: item.caseId,
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await fetch(`${openclawUrl}/api/whatsapp/send`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`OpenClaw returned ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
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({
|
|
||||||
success: true,
|
|
||||||
message: "WhatsApp sent via OpenClaw",
|
|
||||||
simulated: false,
|
|
||||||
sentAt: now,
|
|
||||||
});
|
|
||||||
} catch (err: any) {
|
|
||||||
item.whatsappStatus = "failed";
|
|
||||||
item.updatedAt = now;
|
|
||||||
|
|
||||||
if (logEntry) {
|
|
||||||
logEntry.status = "failed";
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
success: false,
|
|
||||||
message: `Failed to send via OpenClaw: ${err.message}`,
|
|
||||||
simulated: false,
|
|
||||||
sentAt: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── POST /requests/:id/close ────────────────────────────────────────────────
|
|
||||||
router.post("/requests/:id/close", (req, res) => {
|
|
||||||
findAndUpdate(
|
findAndUpdate(
|
||||||
req.params.id,
|
req.params.id,
|
||||||
(r) => {
|
(r) => {
|
||||||
@@ -342,12 +367,15 @@ router.post("/requests/:id/close", (req, res) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── POST /requests/:id/reject ───────────────────────────────────────────────
|
// ─── POST /requests/:id/reject ────────────────────────────────────────────────
|
||||||
router.post("/requests/:id/reject", (req, res) => {
|
router.post("/requests/:id/reject", (req: Request, res: Response): void => {
|
||||||
const item = requests.find(
|
const item = requests.find(
|
||||||
(r) => r.id === req.params.id || r.caseId === req.params.id
|
(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.status = "rejected";
|
||||||
item.currentStep = STATUS_STEP["rejected"];
|
item.currentStep = STATUS_STEP["rejected"];
|
||||||
|
|||||||
@@ -15,6 +15,16 @@ export const en = {
|
|||||||
back: "Back",
|
back: "Back",
|
||||||
confirm: "Confirm",
|
confirm: "Confirm",
|
||||||
language: "العربية",
|
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: {
|
home: {
|
||||||
heroTitle: "Closed Donation Loop POC",
|
heroTitle: "Closed Donation Loop POC",
|
||||||
@@ -22,8 +32,13 @@ export const en = {
|
|||||||
totalRequests: "Total Requests",
|
totalRequests: "Total Requests",
|
||||||
totalCollected: "Total Collected",
|
totalCollected: "Total Collected",
|
||||||
totalClosed: "Closed Cases",
|
totalClosed: "Closed Cases",
|
||||||
viewOpportunities: "View Opportunities",
|
viewOpportunities: "View All Opportunities",
|
||||||
workflowTitle: "Closed Donation Loop Workflow",
|
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: {
|
workflow: {
|
||||||
step1: "Request Submitted",
|
step1: "Request Submitted",
|
||||||
@@ -85,22 +100,26 @@ export const en = {
|
|||||||
collected: "Collected",
|
collected: "Collected",
|
||||||
remaining: "Remaining",
|
remaining: "Remaining",
|
||||||
target: "Target",
|
target: "Target",
|
||||||
|
noOpportunities: "No opportunities are available right now.",
|
||||||
|
verified: "Verified",
|
||||||
},
|
},
|
||||||
donate: {
|
donate: {
|
||||||
title: "Complete Donation",
|
title: "Complete Donation",
|
||||||
|
caseSummary: "Case Summary",
|
||||||
donorName: "Donor Name",
|
donorName: "Donor Name",
|
||||||
donorPhone: "Phone Number",
|
donorPhone: "Phone Number",
|
||||||
donorEmail: "Email (Optional)",
|
donorEmail: "Email (Optional)",
|
||||||
amount: "Donation Amount",
|
amount: "Donation Amount",
|
||||||
confirmDonation: "Confirm Donation",
|
confirmDonation: "Confirm Donation",
|
||||||
successMessage: "Thank you for your donation. May Allah reward you.",
|
successMessage: "Thank you for your donation. May Allah reward you.",
|
||||||
|
caseNotFound: "Case not found or no longer available.",
|
||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
title: "Admin Dashboard",
|
title: "Admin Dashboard",
|
||||||
caseId: "Case ID",
|
caseId: "Case ID",
|
||||||
beneficiary: "Beneficiary",
|
beneficiary: "Beneficiary",
|
||||||
status: "Status",
|
status: "Status",
|
||||||
currentStep: "Current Step",
|
currentStep: "Step",
|
||||||
actions: "Actions",
|
actions: "Actions",
|
||||||
verify: "Verify",
|
verify: "Verify",
|
||||||
publish: "Publish",
|
publish: "Publish",
|
||||||
@@ -109,27 +128,41 @@ export const en = {
|
|||||||
close: "Close Case",
|
close: "Close Case",
|
||||||
reject: "Reject",
|
reject: "Reject",
|
||||||
rejectionReason: "Rejection Reason",
|
rejectionReason: "Rejection Reason",
|
||||||
|
track: "Track",
|
||||||
|
whatsapp: "WhatsApp",
|
||||||
|
noRequests: "No requests found.",
|
||||||
|
needType: "Need Type",
|
||||||
|
amount: "Amount",
|
||||||
},
|
},
|
||||||
track: {
|
track: {
|
||||||
title: "Track Case",
|
title: "Track Case",
|
||||||
caseTimeline: "Case Timeline",
|
caseTimeline: "Case Timeline",
|
||||||
|
caseInfo: "Case Information",
|
||||||
|
rejected: "Case Rejected",
|
||||||
|
currentStepLabel: "Current",
|
||||||
|
submitThankYou: "Submit Thank-You Message",
|
||||||
},
|
},
|
||||||
thankYou: {
|
thankYou: {
|
||||||
title: "Submit Thank You Message",
|
title: "Submit Thank You Message",
|
||||||
message: "Thank You Message",
|
message: "Thank You Message",
|
||||||
submitLabel: "Send Message to Donor",
|
submitLabel: "Send Message to Donor",
|
||||||
|
successNote: "Your thank-you message will be sent to the donor via WhatsApp through OpenClaw.",
|
||||||
|
beneficiaryMessageLabel: "Beneficiary Message",
|
||||||
},
|
},
|
||||||
whatsapp: {
|
whatsapp: {
|
||||||
title: "WhatsApp Log",
|
title: "WhatsApp Log",
|
||||||
donor: "Donor",
|
donor: "Donor",
|
||||||
message: "Message",
|
donorPhone: "Phone",
|
||||||
|
message: "WhatsApp Message",
|
||||||
|
beneficiaryMessage: "Beneficiary Message",
|
||||||
status: "Status",
|
status: "Status",
|
||||||
sentAt: "Sent At",
|
sentAt: "Sent At",
|
||||||
sendViaOpenClaw: "Send via OpenClaw",
|
sendViaOpenClaw: "Send via OpenClaw",
|
||||||
pending: "Pending",
|
pending: "Pending",
|
||||||
sent: "Sent",
|
sent: "Sent",
|
||||||
failed: "Failed",
|
failed: "Failed",
|
||||||
}
|
noEntries: "No WhatsApp log entries yet.",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ar = {
|
export const ar = {
|
||||||
@@ -149,6 +182,16 @@ export const ar = {
|
|||||||
back: "رجوع",
|
back: "رجوع",
|
||||||
confirm: "تأكيد",
|
confirm: "تأكيد",
|
||||||
language: "English",
|
language: "English",
|
||||||
|
trackCase: "تتبع الحالة",
|
||||||
|
notFound: "الحالة غير موجودة.",
|
||||||
|
noData: "لا توجد بيانات.",
|
||||||
|
currentStep: "الخطوة الحالية",
|
||||||
|
pleaseWait: "يرجى الانتظار...",
|
||||||
|
search: "بحث",
|
||||||
|
searchPlaceholder: "ابحث بالاسم أو رقم الحالة أو الوصف...",
|
||||||
|
allOpportunities: "جميع الفرص",
|
||||||
|
featuredCases: "الحالات المميزة",
|
||||||
|
donate: "تبرع",
|
||||||
},
|
},
|
||||||
home: {
|
home: {
|
||||||
heroTitle: "إقفال دورة التبرع",
|
heroTitle: "إقفال دورة التبرع",
|
||||||
@@ -156,8 +199,13 @@ export const ar = {
|
|||||||
totalRequests: "إجمالي الطلبات",
|
totalRequests: "إجمالي الطلبات",
|
||||||
totalCollected: "إجمالي التبرعات (ريال)",
|
totalCollected: "إجمالي التبرعات (ريال)",
|
||||||
totalClosed: "الحالات المغلقة",
|
totalClosed: "الحالات المغلقة",
|
||||||
viewOpportunities: "استعراض الفرص",
|
viewOpportunities: "عرض جميع الفرص",
|
||||||
workflowTitle: "خطوات إقفال دورة التبرع",
|
workflowTitle: "خطوات إقفال دورة التبرع",
|
||||||
|
searchOpportunities: "ابحث في فرص التبرع",
|
||||||
|
searchLabel: "ابحث عن قضية لدعمها",
|
||||||
|
searchButton: "بحث",
|
||||||
|
featuredTitle: "الفرص المميزة",
|
||||||
|
noResults: "لا توجد فرص تطابق بحثك.",
|
||||||
},
|
},
|
||||||
workflow: {
|
workflow: {
|
||||||
step1: "مقدم الطلب",
|
step1: "مقدم الطلب",
|
||||||
@@ -219,22 +267,26 @@ export const ar = {
|
|||||||
collected: "المجموع",
|
collected: "المجموع",
|
||||||
remaining: "المتبقي",
|
remaining: "المتبقي",
|
||||||
target: "الهدف",
|
target: "الهدف",
|
||||||
|
noOpportunities: "لا توجد فرص متاحة حالياً.",
|
||||||
|
verified: "موثق",
|
||||||
},
|
},
|
||||||
donate: {
|
donate: {
|
||||||
title: "إتمام التبرع",
|
title: "إتمام التبرع",
|
||||||
|
caseSummary: "ملخص الحالة",
|
||||||
donorName: "اسم المتبرع",
|
donorName: "اسم المتبرع",
|
||||||
donorPhone: "رقم الجوال",
|
donorPhone: "رقم الجوال",
|
||||||
donorEmail: "البريد الإلكتروني (اختياري)",
|
donorEmail: "البريد الإلكتروني (اختياري)",
|
||||||
amount: "مبلغ التبرع",
|
amount: "مبلغ التبرع",
|
||||||
confirmDonation: "تأكيد التبرع",
|
confirmDonation: "تأكيد التبرع",
|
||||||
successMessage: "شكراً لتبرعك. جزاك الله خيراً.",
|
successMessage: "شكراً لتبرعك. جزاك الله خيراً.",
|
||||||
|
caseNotFound: "الحالة غير موجودة أو لم تعد متاحة.",
|
||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
title: "لوحة الإدارة",
|
title: "لوحة الإدارة",
|
||||||
caseId: "رقم الحالة",
|
caseId: "رقم الحالة",
|
||||||
beneficiary: "المستفيد",
|
beneficiary: "المستفيد",
|
||||||
status: "الحالة",
|
status: "الحالة",
|
||||||
currentStep: "الخطوة الحالية",
|
currentStep: "الخطوة",
|
||||||
actions: "الإجراءات",
|
actions: "الإجراءات",
|
||||||
verify: "توثيق",
|
verify: "توثيق",
|
||||||
publish: "نشر",
|
publish: "نشر",
|
||||||
@@ -243,25 +295,39 @@ export const ar = {
|
|||||||
close: "إغلاق الحالة",
|
close: "إغلاق الحالة",
|
||||||
reject: "رفض",
|
reject: "رفض",
|
||||||
rejectionReason: "سبب الرفض",
|
rejectionReason: "سبب الرفض",
|
||||||
|
track: "تتبع",
|
||||||
|
whatsapp: "واتساب",
|
||||||
|
noRequests: "لا توجد طلبات.",
|
||||||
|
needType: "نوع الاحتياج",
|
||||||
|
amount: "المبلغ",
|
||||||
},
|
},
|
||||||
track: {
|
track: {
|
||||||
title: "تتبع الحالة",
|
title: "تتبع الحالة",
|
||||||
caseTimeline: "مسار الحالة",
|
caseTimeline: "مسار الحالة",
|
||||||
|
caseInfo: "معلومات الحالة",
|
||||||
|
rejected: "تم رفض الحالة",
|
||||||
|
currentStepLabel: "الحالية",
|
||||||
|
submitThankYou: "تقديم رسالة الشكر",
|
||||||
},
|
},
|
||||||
thankYou: {
|
thankYou: {
|
||||||
title: "تقديم رسالة الشكر",
|
title: "تقديم رسالة الشكر",
|
||||||
message: "رسالة الشكر",
|
message: "رسالة الشكر",
|
||||||
submitLabel: "إرسال الرسالة للمتبرع",
|
submitLabel: "إرسال الرسالة للمتبرع",
|
||||||
|
successNote: "سيتم إرسال رسالة شكرك إلى المتبرع عبر واتساب من خلال OpenClaw.",
|
||||||
|
beneficiaryMessageLabel: "رسالة المستفيد",
|
||||||
},
|
},
|
||||||
whatsapp: {
|
whatsapp: {
|
||||||
title: "سجل رسائل الواتساب",
|
title: "سجل رسائل الواتساب",
|
||||||
donor: "المتبرع",
|
donor: "المتبرع",
|
||||||
message: "الرسالة",
|
donorPhone: "الجوال",
|
||||||
|
message: "رسالة الواتساب",
|
||||||
|
beneficiaryMessage: "رسالة المستفيد",
|
||||||
status: "الحالة",
|
status: "الحالة",
|
||||||
sentAt: "وقت الإرسال",
|
sentAt: "وقت الإرسال",
|
||||||
sendViaOpenClaw: "إرسال عبر OpenClaw",
|
sendViaOpenClaw: "إرسال عبر OpenClaw",
|
||||||
pending: "قيد الانتظار",
|
pending: "قيد الانتظار",
|
||||||
sent: "مرسل",
|
sent: "مرسل",
|
||||||
failed: "فشل",
|
failed: "فشل",
|
||||||
}
|
noEntries: "لا توجد سجلات واتساب بعد.",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
import { useLanguage } from "../contexts/LanguageContext";
|
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 { 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 { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
@@ -10,7 +22,7 @@ export default function Admin() {
|
|||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data: requests, isLoading } = useListRequests();
|
const { data: requests, isLoading } = useListRequests();
|
||||||
|
|
||||||
const verifyRequest = useVerifyRequest();
|
const verifyRequest = useVerifyRequest();
|
||||||
const publishRequest = usePublishRequest();
|
const publishRequest = usePublishRequest();
|
||||||
const deliverSupport = useDeliverSupport();
|
const deliverSupport = useDeliverSupport();
|
||||||
@@ -30,15 +42,15 @@ export default function Admin() {
|
|||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-12">
|
<div className="container mx-auto px-4 py-12">
|
||||||
<h1 className="text-3xl font-bold text-foreground mb-8">{t.admin.title}</h1>
|
<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">
|
<div className="bg-card rounded-xl border overflow-hidden shadow-sm">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader className="bg-muted/50">
|
<TableHeader className="bg-muted/50">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>{t.admin.caseId}</TableHead>
|
<TableHead>{t.admin.caseId}</TableHead>
|
||||||
<TableHead>{t.admin.beneficiary}</TableHead>
|
<TableHead>{t.admin.beneficiary}</TableHead>
|
||||||
<TableHead>{t.request.needType}</TableHead>
|
<TableHead>{t.admin.needType}</TableHead>
|
||||||
<TableHead>{t.request.amount}</TableHead>
|
<TableHead>{t.admin.amount}</TableHead>
|
||||||
<TableHead>{t.admin.status}</TableHead>
|
<TableHead>{t.admin.status}</TableHead>
|
||||||
<TableHead>{t.admin.currentStep}</TableHead>
|
<TableHead>{t.admin.currentStep}</TableHead>
|
||||||
<TableHead className="text-right">{t.admin.actions}</TableHead>
|
<TableHead className="text-right">{t.admin.actions}</TableHead>
|
||||||
@@ -55,8 +67,10 @@ export default function Admin() {
|
|||||||
<TableRow key={req.id}>
|
<TableRow key={req.id}>
|
||||||
<TableCell className="font-mono text-xs">{req.caseId}</TableCell>
|
<TableCell className="font-mono text-xs">{req.caseId}</TableCell>
|
||||||
<TableCell>{req.beneficiaryName}</TableCell>
|
<TableCell>{req.beneficiaryName}</TableCell>
|
||||||
<TableCell>{t.needTypes[req.needType as keyof typeof t.needTypes] || req.needType}</TableCell>
|
<TableCell>
|
||||||
<TableCell>{req.requestedAmount} ﷼</TableCell>
|
{t.needTypes[req.needType as keyof typeof t.needTypes] || req.needType}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{req.requestedAmount.toLocaleString()} ﷼</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="secondary" className="font-normal">
|
<Badge variant="secondary" className="font-normal">
|
||||||
{t.statuses[req.status as keyof typeof t.statuses] || req.status}
|
{t.statuses[req.status as keyof typeof t.statuses] || req.status}
|
||||||
@@ -64,33 +78,61 @@ export default function Admin() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{req.currentStep}/10</TableCell>
|
<TableCell>{req.currentStep}/10</TableCell>
|
||||||
<TableCell className="text-right">
|
<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}`}>
|
<Link href={`/track/${req.id}`}>
|
||||||
<Button variant="outline" size="sm">Track</Button>
|
<Button variant="outline" size="sm">{t.admin.track}</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
{req.status === "new" && (
|
||||||
{req.status === 'new' && (
|
|
||||||
<>
|
<>
|
||||||
<Button size="sm" onClick={() => handleAction(verifyRequest, req.id)}>Verify</Button>
|
<Button size="sm" onClick={() => handleAction(verifyRequest, req.id)}>
|
||||||
<Button size="sm" variant="destructive" onClick={() => handleAction(rejectRequest, req.id)}>Reject</Button>
|
{t.admin.verify}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="destructive" onClick={() => handleAction(rejectRequest, req.id)}>
|
||||||
|
{t.admin.reject}
|
||||||
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{req.status === 'verified' && (
|
{req.status === "pending_review" && (
|
||||||
<Button size="sm" onClick={() => handleAction(publishRequest, req.id)}>Publish</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 === 'donated' && (
|
{req.status === "verified" && (
|
||||||
<Button size="sm" onClick={() => handleAction(deliverSupport, req.id)}>Deliver</Button>
|
<Button size="sm" onClick={() => handleAction(publishRequest, req.id)}>
|
||||||
|
{t.admin.publish}
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
{req.status === 'delivered' && (
|
{req.status === "donated" && (
|
||||||
<Button size="sm" onClick={() => handleAction(confirmReceipt, req.id)}>Confirm Receipt</Button>
|
<Button size="sm" onClick={() => handleAction(deliverSupport, req.id)}>
|
||||||
|
{t.admin.deliver}
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
{req.status === 'thank_you_submitted' && (
|
{req.status === "delivered" && (
|
||||||
<Link href={`/whatsapp-log`}>
|
<Button size="sm" onClick={() => handleAction(confirmReceipt, req.id)}>
|
||||||
<Button size="sm" variant="outline">WhatsApp</Button>
|
{t.admin.confirmReceipt}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{req.status === "receipt_confirmed" && (
|
||||||
|
<Link href={`/thank-you/${req.id}`}>
|
||||||
|
<Button size="sm" variant="outline">
|
||||||
|
{t.track.submitThankYou}
|
||||||
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{req.status === 'whatsapp_sent' && (
|
{req.status === "thank_you_submitted" && (
|
||||||
<Button size="sm" onClick={() => handleAction(closeRequest, req.id)}>Close Case</Button>
|
<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>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -99,7 +141,7 @@ export default function Admin() {
|
|||||||
{(!requests || requests.length === 0) && !isLoading && (
|
{(!requests || requests.length === 0) && !isLoading && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
||||||
No requests found
|
{t.admin.noRequests}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
@@ -108,4 +150,4 @@ export default function Admin() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { useParams, useLocation } from "wouter";
|
import { useParams, useLocation } from "wouter";
|
||||||
import { useLanguage } from "../contexts/LanguageContext";
|
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 { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -14,6 +17,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { CheckCircle, Heart } from "lucide-react";
|
import { CheckCircle, Heart } from "lucide-react";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Link } from "wouter";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
donorName: z.string().min(2),
|
donorName: z.string().min(2),
|
||||||
@@ -43,7 +47,7 @@ export default function Donate() {
|
|||||||
donorName: "",
|
donorName: "",
|
||||||
donorPhone: "",
|
donorPhone: "",
|
||||||
donorEmail: "",
|
donorEmail: "",
|
||||||
amount: request?.requestedAmount || 0,
|
amount: 0,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -80,7 +84,7 @@ export default function Donate() {
|
|||||||
if (!request) {
|
if (!request) {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-12 text-center text-muted-foreground">
|
<div className="container mx-auto px-4 py-12 text-center text-muted-foreground">
|
||||||
Case not found.
|
{t.donate.caseNotFound}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -94,10 +98,16 @@ export default function Donate() {
|
|||||||
<CheckCircle className="w-10 h-10 text-green-500 mx-auto mb-4" />
|
<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>
|
<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-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">
|
<div className="mt-8 flex gap-3 justify-center">
|
||||||
<Button variant="outline" onClick={() => setLocation("/opportunities")}>{t.common.opportunities}</Button>
|
<Button variant="outline" onClick={() => setLocation("/opportunities")}>
|
||||||
<Button onClick={() => setLocation(`/track/${request.id}`)}>Track Case</Button>
|
{t.common.opportunities}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setLocation(`/track/${request.id}`)}>
|
||||||
|
{t.common.trackCase}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 (
|
return (
|
||||||
<div className="container mx-auto px-4 py-12 max-w-2xl">
|
<div className="container mx-auto px-4 py-12 max-w-2xl">
|
||||||
@@ -115,7 +130,12 @@ export default function Donate() {
|
|||||||
|
|
||||||
{/* Case Summary */}
|
{/* Case Summary */}
|
||||||
<Card className="mb-6 bg-primary/5 border-primary/20">
|
<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 className="flex justify-between items-start mb-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-foreground">{request.description}</p>
|
<p className="font-semibold text-foreground">{request.description}</p>
|
||||||
@@ -126,8 +146,14 @@ export default function Donate() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-sm mb-2">
|
<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">
|
||||||
<span className="text-muted-foreground">{t.opportunities.target}: <strong>{request.requestedAmount} ﷼</strong></span>
|
{t.opportunities.collected}:{" "}
|
||||||
|
<strong>{request.collectedAmount.toLocaleString()} ﷼</strong>
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t.opportunities.target}:{" "}
|
||||||
|
<strong>{request.requestedAmount.toLocaleString()} ﷼</strong>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Progress value={progress} className="h-2" />
|
<Progress value={progress} className="h-2" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -205,6 +231,12 @@ export default function Donate() {
|
|||||||
</Form>
|
</Form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<div className="mt-4 text-center">
|
||||||
|
<Link href="/opportunities">
|
||||||
|
<Button variant="ghost" size="sm">{t.common.back}</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,65 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { useLanguage } from "../contexts/LanguageContext";
|
import { useLanguage } from "../contexts/LanguageContext";
|
||||||
import { useGetStats } from "@workspace/api-client-react";
|
import { useGetStats, useListPublishedRequests } from "@workspace/api-client-react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Link } from "wouter";
|
||||||
|
import { Search } from "lucide-react";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const { t } = useLanguage();
|
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 (
|
return (
|
||||||
<div className="container mx-auto px-4 py-12">
|
<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">
|
<h1 className="text-4xl md:text-5xl font-bold text-primary mb-6">
|
||||||
{t.home.heroTitle}
|
{t.home.heroTitle}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto mb-8">
|
<p className="text-xl text-muted-foreground max-w-2xl mx-auto mb-8">
|
||||||
{t.home.heroSubtitle}
|
{t.home.heroSubtitle}
|
||||||
</p>
|
</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}
|
{/* Search Bar */}
|
||||||
</Link>
|
<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>
|
</section>
|
||||||
|
|
||||||
{isLoading ? (
|
{/* Stats */}
|
||||||
|
{statsLoading ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-16">
|
<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" />
|
<Skeleton className="h-32 w-full" />
|
||||||
@@ -38,7 +74,9 @@ export default function Home() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
@@ -48,7 +86,9 @@ export default function Home() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
@@ -58,19 +98,95 @@ export default function Home() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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">
|
<section className="mb-12">
|
||||||
<h2 className="text-2xl font-bold mb-8 text-center">{t.home.workflowTitle}</h2>
|
<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">
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||||
{Array.from({ length: 10 }).map((_, i) => (
|
{Array.from({ length: 10 }).map((_, i) => (
|
||||||
<Card key={i} className="bg-muted/50 border-none shadow-none">
|
<Card key={i} className="bg-muted/50 border-none shadow-none">
|
||||||
<CardContent className="p-4 text-center">
|
<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}
|
{i + 1}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-medium">
|
<div className="text-sm font-medium">
|
||||||
@@ -83,4 +199,4 @@ export default function Home() {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,88 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { useLanguage } from "../contexts/LanguageContext";
|
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 { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Link } from "wouter";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
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() {
|
export default function Opportunities() {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { data: requests, isLoading } = useListPublishedRequests();
|
const { data: requests, isLoading } = useListPublishedRequests();
|
||||||
|
const [activeFilter, setActiveFilter] = useState<NeedTypeKey | "all">("all");
|
||||||
|
|
||||||
|
const filtered = (requests || []).filter((r) =>
|
||||||
|
activeFilter === "all" ? true : r.needType === activeFilter
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-12">
|
<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 ? (
|
{isLoading ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{[1, 2, 3].map((i) => (
|
{[1, 2, 3].map((i) => (
|
||||||
<Skeleton key={i} className="h-64 w-full" />
|
<Skeleton key={i} className="h-64 w-full" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</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">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{requests?.map((request) => {
|
{filtered.map((request) => {
|
||||||
const progress = Math.min(100, Math.round((request.collectedAmount / request.requestedAmount) * 100));
|
const progress = Math.min(
|
||||||
const remaining = request.requestedAmount - request.collectedAmount;
|
100,
|
||||||
|
request.requestedAmount > 0
|
||||||
|
? Math.round((request.collectedAmount / request.requestedAmount) * 100)
|
||||||
|
: 0
|
||||||
|
);
|
||||||
|
const remaining = Math.max(0, request.requestedAmount - request.collectedAmount);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={request.id} className="overflow-hidden flex flex-col">
|
<Card key={request.id} className="overflow-hidden flex flex-col">
|
||||||
<CardHeader className="bg-primary/5 pb-4 border-b">
|
<CardHeader className="bg-primary/5 pb-4 border-b">
|
||||||
@@ -34,24 +90,29 @@ export default function Opportunities() {
|
|||||||
<Badge variant="outline" className="bg-white">
|
<Badge variant="outline" className="bg-white">
|
||||||
{t.needTypes[request.needType as keyof typeof t.needTypes] || request.needType}
|
{t.needTypes[request.needType as keyof typeof t.needTypes] || request.needType}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge className="bg-green-600">
|
<Badge className="bg-green-600 text-white">
|
||||||
{t.statuses.verified}
|
{t.opportunities.verified}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</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>
|
</CardHeader>
|
||||||
<CardContent className="pt-6 flex-1">
|
<CardContent className="pt-5 flex-1">
|
||||||
<div className="flex justify-between text-sm mb-2">
|
<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">
|
||||||
<span className="text-muted-foreground">{t.opportunities.target}: <strong className="text-foreground">{request.requestedAmount} ﷼</strong></span>
|
{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>
|
</div>
|
||||||
<Progress value={progress} className="h-2 mb-2" />
|
<Progress value={progress} className="h-2 mb-2" />
|
||||||
<div className="text-sm text-right text-primary font-medium">
|
<div className="text-sm font-medium text-primary">{progress}%</div>
|
||||||
{progress}%
|
<div className="mt-3 text-center">
|
||||||
</div>
|
|
||||||
<div className="mt-4 text-center">
|
|
||||||
<span className="text-sm text-muted-foreground">{t.opportunities.remaining}: </span>
|
<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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="pt-0">
|
<CardFooter className="pt-0">
|
||||||
@@ -64,14 +125,8 @@ export default function Opportunities() {
|
|||||||
</Card>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { useParams, useLocation } from "wouter";
|
import { useParams, useLocation } from "wouter";
|
||||||
import { useLanguage } from "../contexts/LanguageContext";
|
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 { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { CheckCircle, Heart } from "lucide-react";
|
import { CheckCircle, Heart } from "lucide-react";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Link } from "wouter";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
beneficiaryName: z.string().min(2),
|
beneficiaryName: z.string().min(2),
|
||||||
@@ -38,11 +41,14 @@ export default function ThankYou() {
|
|||||||
const form = useForm<FormData>({
|
const form = useForm<FormData>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
beneficiaryName: request?.beneficiaryName || "",
|
beneficiaryName: "",
|
||||||
message: "",
|
message: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Pre-fill name once loaded
|
||||||
|
const beneficiaryName = request?.beneficiaryName || "";
|
||||||
|
|
||||||
const onSubmit = (data: FormData) => {
|
const onSubmit = (data: FormData) => {
|
||||||
submitThankYou.mutate(
|
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) {
|
if (submitted) {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-12 max-w-xl">
|
<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" />
|
<CheckCircle className="w-12 h-12 text-green-500" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-bold text-green-700 mb-2">{t.common.success}</h2>
|
<h2 className="text-2xl font-bold text-green-700 mb-2">{t.common.success}</h2>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">{t.thankYou.title}</p>
|
||||||
{t.thankYou.title}
|
<p className="mt-4 text-sm text-muted-foreground">{t.thankYou.successNote}</p>
|
||||||
</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">
|
<div className="mt-8 flex gap-3 justify-center">
|
||||||
<Button onClick={() => setLocation(`/track/${params.id}`)}>Track Case</Button>
|
<Button onClick={() => setLocation(`/track/${params.id}`)}>
|
||||||
<Button variant="outline" onClick={() => setLocation("/")}>Home</Button>
|
{t.common.trackCase}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => setLocation("/")}>
|
||||||
|
{t.common.home}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -98,9 +112,9 @@ export default function ThankYou() {
|
|||||||
<div className="container mx-auto px-4 py-12 max-w-xl">
|
<div className="container mx-auto px-4 py-12 max-w-xl">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-3xl font-bold text-foreground">{t.thankYou.title}</h1>
|
<h1 className="text-3xl font-bold text-foreground">{t.thankYou.title}</h1>
|
||||||
{request && (
|
<p className="text-muted-foreground mt-1">
|
||||||
<p className="text-muted-foreground mt-1">{request.caseId} — {request.beneficiaryName}</p>
|
{request.caseId} — {request.beneficiaryName}
|
||||||
)}
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
@@ -119,8 +133,9 @@ export default function ThankYou() {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
data-testid="input-beneficiaryName"
|
data-testid="input-beneficiaryName"
|
||||||
defaultValue={request?.beneficiaryName || ""}
|
placeholder={beneficiaryName}
|
||||||
{...field}
|
{...field}
|
||||||
|
defaultValue={beneficiaryName}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -137,7 +152,6 @@ export default function ThankYou() {
|
|||||||
<Textarea
|
<Textarea
|
||||||
data-testid="input-thankYouMessage"
|
data-testid="input-thankYouMessage"
|
||||||
rows={5}
|
rows={5}
|
||||||
placeholder="جزاكم الله خيراً، وصلني الدعم وكان له أثر كبير عليّ."
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -157,6 +171,12 @@ export default function ThankYou() {
|
|||||||
</Form>
|
</Form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<div className="mt-4 text-center">
|
||||||
|
<Link href={`/track/${params.id}`}>
|
||||||
|
<Button variant="ghost" size="sm">{t.common.back}</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Check, Clock, Circle, ArrowRight } from "lucide-react";
|
import { Check, Clock } from "lucide-react";
|
||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
|
|
||||||
const STEPS = [
|
const STEPS = [
|
||||||
@@ -48,7 +48,7 @@ export default function Track() {
|
|||||||
if (!request) {
|
if (!request) {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-12 text-center text-muted-foreground">
|
<div className="container mx-auto px-4 py-12 text-center text-muted-foreground">
|
||||||
Case not found.
|
{t.common.notFound}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -61,7 +61,9 @@ export default function Track() {
|
|||||||
<div className="mb-8 flex items-center justify-between flex-wrap gap-4">
|
<div className="mb-8 flex items-center justify-between flex-wrap gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-foreground">{t.track.title}</h1>
|
<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>
|
</div>
|
||||||
<Badge
|
<Badge
|
||||||
className={`text-sm px-3 py-1 ${
|
className={`text-sm px-3 py-1 ${
|
||||||
@@ -79,23 +81,38 @@ export default function Track() {
|
|||||||
|
|
||||||
{/* Case Info */}
|
{/* Case Info */}
|
||||||
<Card className="mb-8 bg-muted/30">
|
<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 className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground text-xs uppercase tracking-wide mb-1">{t.request.needType}</p>
|
<p className="text-muted-foreground text-xs uppercase tracking-wide mb-1">
|
||||||
<p className="font-semibold">{t.needTypes[request.needType as keyof typeof t.needTypes]}</p>
|
{t.request.needType}
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold">
|
||||||
|
{t.needTypes[request.needType as keyof typeof t.needTypes]}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<p className="font-semibold">{request.requestedAmount.toLocaleString()} ﷼</p>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<p className="font-semibold text-primary">{request.collectedAmount.toLocaleString()} ﷼</p>
|
||||||
</div>
|
</div>
|
||||||
{request.donorName && (
|
{request.donorName && (
|
||||||
<div>
|
<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>
|
<p className="font-semibold">{request.donorName}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -111,7 +128,7 @@ export default function Track() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
{isRejected ? (
|
{isRejected ? (
|
||||||
<div className="p-6 text-center bg-red-50 rounded-lg border border-red-200">
|
<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 && (
|
{request.rejectionReason && (
|
||||||
<p className="text-red-600 text-sm">{request.rejectionReason}</p>
|
<p className="text-red-600 text-sm">{request.rejectionReason}</p>
|
||||||
)}
|
)}
|
||||||
@@ -122,11 +139,9 @@ export default function Track() {
|
|||||||
const stepNum = index + 1;
|
const stepNum = index + 1;
|
||||||
const isDone = stepNum < currentStep;
|
const isDone = stepNum < currentStep;
|
||||||
const isCurrent = stepNum === currentStep;
|
const isCurrent = stepNum === currentStep;
|
||||||
const isPending = stepNum > currentStep;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={stepKey} className="flex items-start gap-4 mb-6 last:mb-0">
|
<div key={stepKey} className="flex items-start gap-4 mb-6 last:mb-0">
|
||||||
{/* Icon */}
|
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div
|
<div
|
||||||
className={`w-9 h-9 rounded-full flex items-center justify-center flex-shrink-0 border-2 transition-all ${
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Label */}
|
|
||||||
<div className="pt-1.5">
|
<div className="pt-1.5">
|
||||||
<p
|
<p
|
||||||
className={`font-medium text-sm ${
|
className={`font-medium text-sm ${
|
||||||
@@ -167,7 +180,9 @@ export default function Track() {
|
|||||||
{t.workflow[stepKey as keyof typeof t.workflow]}
|
{t.workflow[stepKey as keyof typeof t.workflow]}
|
||||||
</p>
|
</p>
|
||||||
{isCurrent && (
|
{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>
|
||||||
</div>
|
</div>
|
||||||
@@ -182,11 +197,17 @@ export default function Track() {
|
|||||||
<div className="mt-6 flex gap-3 flex-wrap">
|
<div className="mt-6 flex gap-3 flex-wrap">
|
||||||
{request.status === "receipt_confirmed" && (
|
{request.status === "receipt_confirmed" && (
|
||||||
<Link href={`/thank-you/${request.id}`}>
|
<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>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<Button variant="outline" onClick={() => setLocation("/admin")}>{t.common.adminDashboard}</Button>
|
<Button variant="outline" onClick={() => setLocation("/admin")}>
|
||||||
<Button variant="ghost" onClick={() => setLocation("/opportunities")}>{t.common.back}</Button>
|
{t.common.adminDashboard}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={() => setLocation("/opportunities")}>
|
||||||
|
{t.common.back}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { useLanguage } from "../contexts/LanguageContext";
|
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 { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -7,7 +10,6 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { MessageSquare, Phone, Send, CheckCircle, Clock, XCircle } from "lucide-react";
|
import { MessageSquare, Phone, Send, CheckCircle, Clock, XCircle } from "lucide-react";
|
||||||
import { useListRequests } from "@workspace/api-client-react";
|
|
||||||
|
|
||||||
export default function WhatsappLog() {
|
export default function WhatsappLog() {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
@@ -67,7 +69,7 @@ export default function WhatsappLog() {
|
|||||||
) : !logs || logs.length === 0 ? (
|
) : !logs || logs.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="py-16 text-center text-muted-foreground">
|
<CardContent className="py-16 text-center text-muted-foreground">
|
||||||
No WhatsApp log entries yet.
|
{t.whatsapp.noEntries}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
@@ -118,7 +120,7 @@ export default function WhatsappLog() {
|
|||||||
{/* Beneficiary Message */}
|
{/* Beneficiary Message */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase text-muted-foreground tracking-wide mb-2">
|
<p className="text-xs font-semibold uppercase text-muted-foreground tracking-wide mb-2">
|
||||||
Beneficiary Message
|
{t.thankYou.beneficiaryMessageLabel}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-foreground italic">"{log.beneficiaryMessage}"</p>
|
<p className="text-sm text-foreground italic">"{log.beneficiaryMessage}"</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user