diff --git a/.replit b/.replit index df803ae..ade20b3 100644 --- a/.replit +++ b/.replit @@ -26,3 +26,7 @@ externalPort = 80 [[ports]] localPort = 8081 externalPort = 8081 + +[[ports]] +localPort = 18312 +externalPort = 3000 diff --git a/artifacts/api-server/package.json b/artifacts/api-server/package.json index 6916f27..d12a3b2 100644 --- a/artifacts/api-server/package.json +++ b/artifacts/api-server/package.json @@ -17,13 +17,15 @@ "drizzle-orm": "catalog:", "express": "^5.2.1", "pino": "^9.14.0", - "pino-http": "^10.5.0" + "pino-http": "^10.5.0", + "uuid": "^14.0.0" }, "devDependencies": { "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/node": "catalog:", + "@types/uuid": "^11.0.0", "esbuild": "0.27.3", "esbuild-plugin-pino": "^2.3.3", "pino-pretty": "^13.1.3", diff --git a/artifacts/api-server/src/lib/mockDb.ts b/artifacts/api-server/src/lib/mockDb.ts new file mode 100644 index 0000000..5ab357e --- /dev/null +++ b/artifacts/api-server/src/lib/mockDb.ts @@ -0,0 +1,442 @@ +import { v4 as uuidv4 } from "uuid"; + +export type NeedType = + | "electricity" + | "water" + | "food" + | "health" + | "housing" + | "refrigerator" + | "air_conditioner" + | "court_order"; + +export type RequestSource = "beneficiary" | "charity" | "official"; + +export type RequestStatus = + | "new" + | "pending_review" + | "verified" + | "published" + | "donated" + | "delivered" + | "receipt_confirmed" + | "thank_you_submitted" + | "whatsapp_sent" + | "closed" + | "rejected"; + +export type WhatsappStatus = "pending" | "sent" | "failed"; + +export interface DonationRequest { + id: string; + caseId: string; + beneficiaryName: string; + nationalId: string; + phone: string; + source: RequestSource; + sourceName: string; + needType: NeedType; + requestedAmount: number; + collectedAmount: number; + description: string; + status: RequestStatus; + currentStep: number; + donorId: string | null; + donorName: string | null; + thankYouMessage: string | null; + whatsappStatus: WhatsappStatus | null; + whatsappSentAt: string | null; + rejectionReason: string | null; + createdAt: string; + updatedAt: string; +} + +export interface Donor { + id: string; + name: string; + phone: string; + email: string | null; + totalDonated: number; + donationCount: number; +} + +export interface EligibilityRecord { + nationalId: string; + eligible: boolean; +} + +export interface WhatsappLogEntry { + id: string; + caseId: string; + donorName: string; + donorPhone: string; + beneficiaryMessage: string; + whatsappMessage: string; + status: WhatsappStatus; + sentAt: string | null; + createdAt: string; +} + +// ─── Eligibility Database ─────────────────────────────────────────────────── +export const eligibilityDb: EligibilityRecord[] = [ + { nationalId: "1090512345", eligible: true }, + { nationalId: "1023456789", eligible: true }, + { nationalId: "2098765432", eligible: true }, + { nationalId: "1056789012", eligible: true }, + { nationalId: "2034567890", eligible: true }, + { nationalId: "1099999999", eligible: false }, +]; + +// ─── Donors ───────────────────────────────────────────────────────────────── +export const donors: Donor[] = [ + { + id: "donor-001", + name: "عبدالله المنصور", + phone: "0501234567", + email: "abdullah@example.com", + totalDonated: 5000, + donationCount: 2, + }, + { + id: "donor-002", + name: "سارة الأحمد", + phone: "0556789012", + email: "sara@example.com", + totalDonated: 3000, + donationCount: 1, + }, + { + id: "donor-003", + name: "محمد الشمري", + phone: "0589012345", + email: null, + totalDonated: 2500, + donationCount: 1, + }, +]; + +// ─── Mock Requests ─────────────────────────────────────────────────────────── +const now = new Date(); +const d = (daysAgo: number) => + new Date(now.getTime() - daysAgo * 86400000).toISOString(); + +export const requests: DonationRequest[] = [ + { + id: "req-001", + caseId: "CASE-001", + beneficiaryName: "أحمد إبراهيم الحربي", + nationalId: "1090512345", + phone: "0501111111", + source: "beneficiary", + sourceName: "مستفيد مباشر", + needType: "electricity", + requestedAmount: 2400, + collectedAmount: 2400, + description: "فاتورة كهرباء متراكمة لمدة 6 أشهر، أب لأربعة أطفال", + status: "closed", + currentStep: 10, + donorId: "donor-001", + donorName: "عبدالله المنصور", + thankYouMessage: "جزاكم الله خيراً، وصلني الدعم وكان له أثر كبير عليّ وعلى أسرتي.", + whatsappStatus: "sent", + whatsappSentAt: d(1), + rejectionReason: null, + createdAt: d(30), + updatedAt: d(1), + }, + { + id: "req-002", + caseId: "CASE-002", + beneficiaryName: "فاطمة علي السلمي", + nationalId: "1023456789", + phone: "0502222222", + source: "charity", + sourceName: "جمعية البر الخيرية", + needType: "water", + requestedAmount: 1800, + collectedAmount: 1800, + description: "فاتورة مياه متأخرة، أرملة تعيل ثلاثة أطفال", + status: "whatsapp_sent", + currentStep: 9, + donorId: "donor-002", + donorName: "سارة الأحمد", + thankYouMessage: "بارك الله فيكم، وصل الدعم في الوقت المناسب جداً.", + whatsappStatus: "sent", + whatsappSentAt: d(2), + rejectionReason: null, + createdAt: d(25), + updatedAt: d(2), + }, + { + id: "req-003", + caseId: "CASE-003", + beneficiaryName: "خالد محمد الغامدي", + nationalId: "2098765432", + phone: "0503333333", + source: "official", + sourceName: "وزارة العدل", + needType: "food", + requestedAmount: 1200, + collectedAmount: 1200, + description: "سلة غذائية شهرية لعائلة من 6 أفراد", + status: "thank_you_submitted", + currentStep: 8, + donorId: "donor-003", + donorName: "محمد الشمري", + thankYouMessage: "شكراً جزيلاً، السلة الغذائية أفادتنا كثيراً.", + whatsappStatus: "pending", + whatsappSentAt: null, + rejectionReason: null, + createdAt: d(20), + updatedAt: d(3), + }, + { + id: "req-004", + caseId: "CASE-004", + beneficiaryName: "مريم سالم العتيبي", + nationalId: "1056789012", + phone: "0504444444", + source: "charity", + sourceName: "الهلال الأحمر السعودي", + needType: "health", + requestedAmount: 5000, + collectedAmount: 5000, + description: "مصاريف علاج ومستلزمات طبية لمريضة مزمنة", + status: "receipt_confirmed", + currentStep: 7, + donorId: "donor-001", + donorName: "عبدالله المنصور", + thankYouMessage: null, + whatsappStatus: null, + whatsappSentAt: null, + rejectionReason: null, + createdAt: d(18), + updatedAt: d(4), + }, + { + id: "req-005", + caseId: "CASE-005", + beneficiaryName: "عمر عبدالرحمن الدوسري", + nationalId: "2034567890", + phone: "0505555555", + source: "official", + sourceName: "شركة المياه الوطنية", + needType: "housing", + requestedAmount: 8000, + collectedAmount: 8000, + description: "إصلاحات طارئة في المسكن بعد تسرب المياه", + status: "delivered", + currentStep: 6, + donorId: "donor-002", + donorName: "سارة الأحمد", + thankYouMessage: null, + whatsappStatus: null, + whatsappSentAt: null, + rejectionReason: null, + createdAt: d(15), + updatedAt: d(5), + }, + { + id: "req-006", + caseId: "CASE-006", + beneficiaryName: "نورة سعد القحطاني", + nationalId: "1090512345", + phone: "0506666666", + source: "beneficiary", + sourceName: "مستفيد مباشر", + needType: "refrigerator", + requestedAmount: 1500, + collectedAmount: 1500, + description: "ثلاجة منزلية لعائلة تفتقر لوسيلة حفظ الغذاء", + status: "donated", + currentStep: 5, + donorId: "donor-003", + donorName: "محمد الشمري", + thankYouMessage: null, + whatsappStatus: null, + whatsappSentAt: null, + rejectionReason: null, + createdAt: d(10), + updatedAt: d(6), + }, + { + id: "req-007", + caseId: "CASE-007", + beneficiaryName: "سليمان ناصر الزهراني", + nationalId: "1023456789", + phone: "0507777777", + source: "charity", + sourceName: "جمعية التنمية الأسرية", + needType: "air_conditioner", + requestedAmount: 2000, + collectedAmount: 0, + description: "مكيف لمنزل في منطقة حارة، وجود أطفال ومسنين", + status: "published", + currentStep: 4, + donorId: null, + donorName: null, + thankYouMessage: null, + whatsappStatus: null, + whatsappSentAt: null, + rejectionReason: null, + createdAt: d(8), + updatedAt: d(7), + }, + { + id: "req-008", + caseId: "CASE-008", + beneficiaryName: "حسن عبدالله الرشيدي", + nationalId: "2056789012", + phone: "0508888888", + source: "official", + sourceName: "وزارة العدل", + needType: "court_order", + requestedAmount: 3500, + collectedAmount: 0, + description: "سداد غرامة قضائية لتجنب الحجز على الممتلكات", + status: "verified", + currentStep: 3, + donorId: null, + donorName: null, + thankYouMessage: null, + whatsappStatus: null, + whatsappSentAt: null, + rejectionReason: null, + createdAt: d(6), + updatedAt: d(5), + }, + { + id: "req-009", + caseId: "CASE-009", + beneficiaryName: "رنا طارق المالكي", + nationalId: "9999999999", + phone: "0509999999", + source: "beneficiary", + sourceName: "مستفيد مباشر", + needType: "food", + requestedAmount: 900, + collectedAmount: 0, + description: "سلة غذائية لأسرة محتاجة", + status: "pending_review", + currentStep: 2, + donorId: null, + donorName: null, + thankYouMessage: null, + whatsappStatus: null, + whatsappSentAt: null, + rejectionReason: null, + createdAt: d(2), + updatedAt: d(1), + }, + { + id: "req-010", + caseId: "CASE-010", + beneficiaryName: "بدر محمد الجهني", + nationalId: "1099999999", + phone: "0511111111", + source: "charity", + sourceName: "الجمعية الخيرية", + needType: "electricity", + requestedAmount: 1800, + collectedAmount: 0, + description: "فاتورة كهرباء متراكمة", + status: "rejected", + currentStep: 2, + donorId: null, + donorName: null, + thankYouMessage: null, + whatsappStatus: null, + whatsappSentAt: null, + rejectionReason: "المستفيد غير مؤهل وفق قاعدة بيانات الاستحقاق", + createdAt: d(5), + updatedAt: d(4), + }, + { + id: "req-011", + caseId: "CASE-011", + beneficiaryName: "أميرة خالد السبيعي", + nationalId: "1056789012", + phone: "0512222222", + source: "official", + sourceName: "شركة الكهرباء السعودية", + needType: "electricity", + requestedAmount: 3200, + collectedAmount: 0, + description: "فاتورة كهرباء لمنزل أرملة ذات أطفال صغار", + status: "new", + currentStep: 1, + donorId: null, + donorName: null, + thankYouMessage: null, + whatsappStatus: null, + whatsappSentAt: null, + rejectionReason: null, + createdAt: d(1), + updatedAt: d(0), + }, +]; + +// ─── WhatsApp Log ──────────────────────────────────────────────────────────── +export const whatsappLog: WhatsappLogEntry[] = [ + { + id: "wa-001", + caseId: "CASE-001", + donorName: "عبدالله المنصور", + donorPhone: "0501234567", + beneficiaryMessage: + "جزاكم الله خيراً، وصلني الدعم وكان له أثر كبير عليّ وعلى أسرتي.", + whatsappMessage: + "السلام عليكم، نشكركم على تبرعكم عبر منصة إحسان.\nتم إيصال الدعم للمستفيد، وهذه رسالة الشكر من المستفيد:\n\"جزاكم الله خيراً، وصلني الدعم وكان له أثر كبير عليّ وعلى أسرتي.\"\nرقم الحالة: CASE-001", + status: "sent", + sentAt: d(1), + createdAt: d(2), + }, + { + id: "wa-002", + caseId: "CASE-002", + donorName: "سارة الأحمد", + donorPhone: "0556789012", + beneficiaryMessage: "بارك الله فيكم، وصل الدعم في الوقت المناسب جداً.", + whatsappMessage: + "السلام عليكم، نشكركم على تبرعكم عبر منصة إحسان.\nتم إيصال الدعم للمستفيد، وهذه رسالة الشكر من المستفيد:\n\"بارك الله فيكم، وصل الدعم في الوقت المناسب جداً.\"\nرقم الحالة: CASE-002", + status: "sent", + sentAt: d(2), + createdAt: d(3), + }, + { + id: "wa-003", + caseId: "CASE-003", + donorName: "محمد الشمري", + donorPhone: "0589012345", + beneficiaryMessage: "شكراً جزيلاً، السلة الغذائية أفادتنا كثيراً.", + whatsappMessage: + "السلام عليكم، نشكركم على تبرعكم عبر منصة إحسان.\nتم إيصال الدعم للمستفيد، وهذه رسالة الشكر من المستفيد:\n\"شكراً جزيلاً، السلة الغذائية أفادتنا كثيراً.\"\nرقم الحالة: CASE-003", + status: "pending", + sentAt: null, + createdAt: d(3), + }, +]; + +// ─── Helper: Determine status after eligibility check ─────────────────────── +export function checkEligibility(nationalId: string): { + eligible: boolean | null; +} { + const record = eligibilityDb.find((r) => r.nationalId === nationalId); + if (!record) return { eligible: null }; + return { eligible: record.eligible }; +} + +// ─── Helper: Status → Step mapping ────────────────────────────────────────── +export const STATUS_STEP: Record = { + new: 1, + pending_review: 2, + verified: 3, + published: 4, + donated: 5, + delivered: 6, + receipt_confirmed: 7, + thank_you_submitted: 8, + whatsapp_sent: 9, + closed: 10, + rejected: 2, +}; diff --git a/artifacts/api-server/src/routes/donors.ts b/artifacts/api-server/src/routes/donors.ts new file mode 100644 index 0000000..6f9e60a --- /dev/null +++ b/artifacts/api-server/src/routes/donors.ts @@ -0,0 +1,10 @@ +import { Router } from "express"; +import { donors } from "../lib/mockDb.js"; + +const router = Router(); + +router.get("/donors", (_req, res) => { + res.json(donors); +}); + +export default router; diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts index 5a1f77a..42f716f 100644 --- a/artifacts/api-server/src/routes/index.ts +++ b/artifacts/api-server/src/routes/index.ts @@ -1,8 +1,16 @@ import { Router, type IRouter } from "express"; -import healthRouter from "./health"; +import healthRouter from "./health.js"; +import requestsRouter from "./requests.js"; +import donorsRouter from "./donors.js"; +import statsRouter from "./stats.js"; +import whatsappLogRouter from "./whatsappLog.js"; const router: IRouter = Router(); router.use(healthRouter); +router.use(requestsRouter); +router.use(donorsRouter); +router.use(statsRouter); +router.use(whatsappLogRouter); export default router; diff --git a/artifacts/api-server/src/routes/requests.ts b/artifacts/api-server/src/routes/requests.ts new file mode 100644 index 0000000..a21650c --- /dev/null +++ b/artifacts/api-server/src/routes/requests.ts @@ -0,0 +1,359 @@ +import { Router } from "express"; +import { + requests, + donors, + whatsappLog, + checkEligibility, + STATUS_STEP, + DonationRequest, + WhatsappLogEntry, +} from "../lib/mockDb.js"; +import { v4 as uuidv4 } from "uuid"; + +const router = Router(); + +// ─── GET /requests ─────────────────────────────────────────────────────────── +router.get("/requests", (req, res) => { + let result = [...requests]; + const { status, needType } = req.query; + if (status) result = result.filter((r) => r.status === status); + if (needType) result = result.filter((r) => r.needType === needType); + res.json(result); +}); + +// ─── GET /requests/new ────────────────────────────────────────────────────── +router.get("/requests/new", (_req, res) => { + res.json(requests.filter((r) => r.status === "new")); +}); + +// ─── GET /requests/published ──────────────────────────────────────────────── +router.get("/requests/published", (_req, res) => { + res.json(requests.filter((r) => r.status === "published")); +}); + +// ─── POST /requests ────────────────────────────────────────────────────────── +router.post("/requests", (req, res) => { + const { + beneficiaryName, + nationalId, + phone, + source, + sourceName, + needType, + requestedAmount, + description, + } = req.body; + + if ( + !beneficiaryName || + !nationalId || + !phone || + !source || + !sourceName || + !needType || + !requestedAmount || + !description + ) { + return res.status(400).json({ error: "Missing required fields" }); + } + + const { eligible } = checkEligibility(nationalId); + + let status: DonationRequest["status"]; + if (eligible === true) { + status = "verified"; + } else if (eligible === false) { + status = "rejected"; + } else { + status = "pending_review"; + } + + const now = new Date().toISOString(); + const caseNum = String(requests.length + 1).padStart(3, "0"); + const newReq: DonationRequest = { + id: uuidv4(), + caseId: `CASE-${caseNum}`, + beneficiaryName, + nationalId, + phone, + source, + sourceName, + needType, + requestedAmount: Number(requestedAmount), + collectedAmount: 0, + description, + status, + currentStep: STATUS_STEP[status], + donorId: null, + donorName: null, + thankYouMessage: null, + whatsappStatus: null, + whatsappSentAt: null, + rejectionReason: + status === "rejected" + ? "المستفيد غير مؤهل وفق قاعدة بيانات الاستحقاق" + : null, + createdAt: now, + updatedAt: now, + }; + + requests.push(newReq); + return 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" }); + res.json(item); +}); + +// ─── Helper: find & update ─────────────────────────────────────────────────── +function findAndUpdate( + id: string, + updater: (r: DonationRequest) => void, + res: any +) { + const item = requests.find((r) => r.id === id || r.caseId === id); + if (!item) return res.status(404).json({ error: "Not found" }); + updater(item); + item.updatedAt = new Date().toISOString(); + res.json(item); +} + +// ─── POST /requests/:id/verify ─────────────────────────────────────────────── +router.post("/requests/:id/verify", (req, res) => { + findAndUpdate( + req.params.id, + (r) => { + r.status = "verified"; + r.currentStep = STATUS_STEP["verified"]; + }, + res + ); +}); + +// ─── POST /requests/:id/publish ────────────────────────────────────────────── +router.post("/requests/:id/publish", (req, res) => { + findAndUpdate( + req.params.id, + (r) => { + r.status = "published"; + r.currentStep = STATUS_STEP["published"]; + }, + res + ); +}); + +// ─── POST /requests/:id/donate ─────────────────────────────────────────────── +router.post("/requests/:id/donate", (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" }); + + const { donorName, donorPhone, donorEmail, amount } = req.body; + if (!donorName || !donorPhone || !amount) { + return res.status(400).json({ error: "Missing donor details" }); + } + + const donorId = uuidv4(); + // add/update donor record + const existingDonor = donors.find((d) => d.phone === donorPhone); + if (existingDonor) { + existingDonor.totalDonated += Number(amount); + existingDonor.donationCount += 1; + item.donorId = existingDonor.id; + } else { + const newDonor = { + id: donorId, + name: donorName, + phone: donorPhone, + email: donorEmail || null, + totalDonated: Number(amount), + donationCount: 1, + }; + donors.push(newDonor); + item.donorId = donorId; + } + + item.donorName = donorName; + item.collectedAmount = Number(amount); + item.status = "donated"; + item.currentStep = STATUS_STEP["donated"]; + item.updatedAt = new Date().toISOString(); + res.json(item); +}); + +// ─── POST /requests/:id/deliver ────────────────────────────────────────────── +router.post("/requests/:id/deliver", (req, res) => { + findAndUpdate( + req.params.id, + (r) => { + r.status = "delivered"; + r.currentStep = STATUS_STEP["delivered"]; + }, + res + ); +}); + +// ─── POST /requests/:id/confirm-receipt ────────────────────────────────────── +router.post("/requests/:id/confirm-receipt", (req, res) => { + findAndUpdate( + req.params.id, + (r) => { + r.status = "receipt_confirmed"; + r.currentStep = STATUS_STEP["receipt_confirmed"]; + }, + res + ); +}); + +// ─── POST /requests/:id/thank-you ──────────────────────────────────────────── +router.post("/requests/:id/thank-you", (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" }); + + const { message } = req.body; + if (!message) return res.status(400).json({ error: "Message is required" }); + + item.thankYouMessage = message; + item.status = "thank_you_submitted"; + item.currentStep = STATUS_STEP["thank_you_submitted"]; + item.whatsappStatus = "pending"; + item.updatedAt = new Date().toISOString(); + + // Create whatsapp log entry + const logEntry: WhatsappLogEntry = { + id: uuidv4(), + caseId: item.caseId, + donorName: item.donorName || "متبرع", + donorPhone: donors.find((d) => d.id === item.donorId)?.phone || "", + beneficiaryMessage: message, + whatsappMessage: `السلام عليكم، نشكركم على تبرعكم عبر منصة إحسان.\nتم إيصال الدعم للمستفيد، وهذه رسالة الشكر من المستفيد:\n"${message}"\nرقم الحالة: ${item.caseId}`, + status: "pending", + sentAt: null, + createdAt: new Date().toISOString(), + }; + whatsappLog.push(logEntry); + + res.json(item); +}); + +// ─── POST /requests/:id/send-whatsapp ──────────────────────────────────────── +router.post("/requests/:id/send-whatsapp", async (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" }); + + const simulate = process.env.OPENCLAW_SIMULATE !== "false"; + 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({ + success: true, + message: "WhatsApp sent (simulated)", + simulated: true, + sentAt: now, + }); + } + + // 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; + } + + 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( + req.params.id, + (r) => { + r.status = "closed"; + r.currentStep = STATUS_STEP["closed"]; + }, + res + ); +}); + +// ─── POST /requests/:id/reject ─────────────────────────────────────────────── +router.post("/requests/:id/reject", (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" }); + + item.status = "rejected"; + item.currentStep = STATUS_STEP["rejected"]; + item.rejectionReason = req.body?.reason || "تم الرفض من قبل المشرف"; + item.updatedAt = new Date().toISOString(); + res.json(item); +}); + +export default router; diff --git a/artifacts/api-server/src/routes/stats.ts b/artifacts/api-server/src/routes/stats.ts new file mode 100644 index 0000000..5583468 --- /dev/null +++ b/artifacts/api-server/src/routes/stats.ts @@ -0,0 +1,53 @@ +import { Router } from "express"; +import { requests } from "../lib/mockDb.js"; + +const router = Router(); + +router.get("/stats", (_req, res) => { + const totalRequests = requests.length; + const totalDonated = requests.filter((r) => + ["donated", "delivered", "receipt_confirmed", "thank_you_submitted", "whatsapp_sent", "closed"].includes(r.status) + ).length; + const totalCollected = requests.reduce((sum, r) => sum + r.collectedAmount, 0); + const totalClosed = requests.filter((r) => r.status === "closed").length; + const pendingVerification = requests.filter((r) => + ["new", "pending_review"].includes(r.status) + ).length; + const activeOpportunities = requests.filter((r) => r.status === "published").length; + + const statusCounts: Record = {}; + for (const r of requests) { + statusCounts[r.status] = (statusCounts[r.status] || 0) + 1; + } + const byStatus = Object.entries(statusCounts).map(([status, count]) => ({ + status, + count, + })); + + const needTypeCounts: Record = {}; + for (const r of requests) { + if (!needTypeCounts[r.needType]) { + needTypeCounts[r.needType] = { count: 0, totalAmount: 0 }; + } + needTypeCounts[r.needType].count += 1; + needTypeCounts[r.needType].totalAmount += r.requestedAmount; + } + const byNeedType = Object.entries(needTypeCounts).map(([needType, v]) => ({ + needType, + count: v.count, + totalAmount: v.totalAmount, + })); + + res.json({ + totalRequests, + totalDonated, + totalCollected, + totalClosed, + pendingVerification, + activeOpportunities, + byStatus, + byNeedType, + }); +}); + +export default router; diff --git a/artifacts/api-server/src/routes/whatsappLog.ts b/artifacts/api-server/src/routes/whatsappLog.ts new file mode 100644 index 0000000..7032fc0 --- /dev/null +++ b/artifacts/api-server/src/routes/whatsappLog.ts @@ -0,0 +1,10 @@ +import { Router } from "express"; +import { whatsappLog } from "../lib/mockDb.js"; + +const router = Router(); + +router.get("/whatsapp-log", (_req, res) => { + res.json(whatsappLog); +}); + +export default router; diff --git a/artifacts/ehsan-poc/.replit-artifact/artifact.toml b/artifacts/ehsan-poc/.replit-artifact/artifact.toml new file mode 100644 index 0000000..9ad1756 --- /dev/null +++ b/artifacts/ehsan-poc/.replit-artifact/artifact.toml @@ -0,0 +1,31 @@ +kind = "web" +previewPath = "/" +title = "EHSAN Closed Donation Loop" +version = "1.0.0" +id = "artifacts/ehsan-poc" +router = "path" + +[[integratedSkills]] +name = "react-vite" +version = "1.0.0" + +[[services]] +name = "web" +paths = [ "/" ] +localPort = 18312 + +[services.development] +run = "pnpm --filter @workspace/ehsan-poc run dev" + +[services.production] +build = [ "pnpm", "--filter", "@workspace/ehsan-poc", "run", "build" ] +publicDir = "artifacts/ehsan-poc/dist/public" +serve = "static" + +[[services.production.rewrites]] +from = "/*" +to = "/index.html" + +[services.env] +PORT = "18312" +BASE_PATH = "/" diff --git a/artifacts/ehsan-poc/components.json b/artifacts/ehsan-poc/components.json new file mode 100644 index 0000000..3ff62cf --- /dev/null +++ b/artifacts/ehsan-poc/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} \ No newline at end of file diff --git a/artifacts/ehsan-poc/index.html b/artifacts/ehsan-poc/index.html new file mode 100644 index 0000000..22123dd --- /dev/null +++ b/artifacts/ehsan-poc/index.html @@ -0,0 +1,24 @@ + + + + + + EHSAN Closed Donation Loop + + + + + + + + + + + + + + +
+ + + diff --git a/artifacts/ehsan-poc/package.json b/artifacts/ehsan-poc/package.json new file mode 100644 index 0000000..63cc7ec --- /dev/null +++ b/artifacts/ehsan-poc/package.json @@ -0,0 +1,77 @@ +{ + "name": "@workspace/ehsan-poc", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --config vite.config.ts --host 0.0.0.0", + "build": "vite build --config vite.config.ts", + "serve": "vite preview --config vite.config.ts --host 0.0.0.0", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "devDependencies": { + "@hookform/resolvers": "^3.10.0", + "@radix-ui/react-accordion": "^1.2.4", + "@radix-ui/react-alert-dialog": "^1.1.7", + "@radix-ui/react-aspect-ratio": "^1.1.3", + "@radix-ui/react-avatar": "^1.1.4", + "@radix-ui/react-checkbox": "^1.1.5", + "@radix-ui/react-collapsible": "^1.1.4", + "@radix-ui/react-context-menu": "^2.2.7", + "@radix-ui/react-dialog": "^1.1.7", + "@radix-ui/react-dropdown-menu": "^2.1.7", + "@radix-ui/react-hover-card": "^1.1.7", + "@radix-ui/react-label": "^2.1.3", + "@radix-ui/react-menubar": "^1.1.7", + "@radix-ui/react-navigation-menu": "^1.2.6", + "@radix-ui/react-popover": "^1.1.7", + "@radix-ui/react-progress": "^1.1.3", + "@radix-ui/react-radio-group": "^1.2.4", + "@radix-ui/react-scroll-area": "^1.2.4", + "@radix-ui/react-select": "^2.1.7", + "@radix-ui/react-separator": "^1.1.3", + "@radix-ui/react-slider": "^1.2.4", + "@radix-ui/react-slot": "^1.2.0", + "@radix-ui/react-switch": "^1.1.4", + "@radix-ui/react-tabs": "^1.1.4", + "@radix-ui/react-toast": "^1.2.7", + "@radix-ui/react-toggle": "^1.1.3", + "@radix-ui/react-toggle-group": "^1.1.3", + "@radix-ui/react-tooltip": "^1.2.0", + "@replit/vite-plugin-cartographer": "catalog:", + "@replit/vite-plugin-dev-banner": "catalog:", + "@replit/vite-plugin-runtime-error-modal": "catalog:", + "@tailwindcss/typography": "^0.5.15", + "@tailwindcss/vite": "catalog:", + "@tanstack/react-query": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "@workspace/api-client-react": "workspace:*", + "class-variance-authority": "catalog:", + "clsx": "catalog:", + "cmdk": "^1.1.1", + "date-fns": "^3.6.0", + "embla-carousel-react": "^8.6.0", + "framer-motion": "catalog:", + "input-otp": "^1.4.2", + "lucide-react": "catalog:", + "next-themes": "^0.4.6", + "react": "catalog:", + "react-day-picker": "^9.11.1", + "react-dom": "catalog:", + "react-hook-form": "^7.55.0", + "react-icons": "^5.4.0", + "react-resizable-panels": "^2.1.7", + "recharts": "^2.15.2", + "sonner": "^2.0.7", + "tailwind-merge": "catalog:", + "tailwindcss": "catalog:", + "tw-animate-css": "^1.4.0", + "vaul": "^1.1.2", + "vite": "catalog:", + "wouter": "^3.3.5", + "zod": "catalog:" + } +} diff --git a/artifacts/ehsan-poc/public/favicon.svg b/artifacts/ehsan-poc/public/favicon.svg new file mode 100644 index 0000000..4373d3c --- /dev/null +++ b/artifacts/ehsan-poc/public/favicon.svg @@ -0,0 +1,3 @@ + + + diff --git a/artifacts/ehsan-poc/public/opengraph.jpg b/artifacts/ehsan-poc/public/opengraph.jpg new file mode 100644 index 0000000..558299c Binary files /dev/null and b/artifacts/ehsan-poc/public/opengraph.jpg differ diff --git a/artifacts/ehsan-poc/public/robots.txt b/artifacts/ehsan-poc/public/robots.txt new file mode 100644 index 0000000..c2a49f4 --- /dev/null +++ b/artifacts/ehsan-poc/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Allow: / diff --git a/artifacts/ehsan-poc/src/App.tsx b/artifacts/ehsan-poc/src/App.tsx new file mode 100644 index 0000000..15fec76 --- /dev/null +++ b/artifacts/ehsan-poc/src/App.tsx @@ -0,0 +1,54 @@ +import { Switch, Route, Router as WouterRouter } from "wouter"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Toaster } from "@/components/ui/toaster"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { LanguageProvider } from "./contexts/LanguageContext"; +import { AppLayout } from "./components/layout/AppLayout"; +import NotFound from "@/pages/not-found"; + +// Page imports +import Home from "./pages/home"; +import RequestSupport from "./pages/request"; +import Opportunities from "./pages/opportunities"; +import Donate from "./pages/donate"; +import Admin from "./pages/admin"; +import Track from "./pages/track"; +import ThankYou from "./pages/thank-you"; +import WhatsappLog from "./pages/whatsapp-log"; + +const queryClient = new QueryClient(); + +function Router() { + return ( + + + + + + + + + + + + + + ); +} + +function App() { + return ( + + + + + + + + + + + ); +} + +export default App; diff --git a/artifacts/ehsan-poc/src/components/layout/AppLayout.tsx b/artifacts/ehsan-poc/src/components/layout/AppLayout.tsx new file mode 100644 index 0000000..99ea593 --- /dev/null +++ b/artifacts/ehsan-poc/src/components/layout/AppLayout.tsx @@ -0,0 +1,18 @@ +import { ReactNode } from "react"; +import { Header } from "./Header"; + +export function AppLayout({ children }: { children: ReactNode }) { + return ( +
+
+
+ {children} +
+
+
+ EHSAN POC © {new Date().getFullYear()} +
+
+
+ ); +} diff --git a/artifacts/ehsan-poc/src/components/layout/Header.tsx b/artifacts/ehsan-poc/src/components/layout/Header.tsx new file mode 100644 index 0000000..60bb752 --- /dev/null +++ b/artifacts/ehsan-poc/src/components/layout/Header.tsx @@ -0,0 +1,57 @@ +import { Link, useLocation } from "wouter"; +import { useLanguage } from "../../contexts/LanguageContext"; +import { Button } from "../ui/button"; + +export function Header() { + const { language, setLanguage, t } = useLanguage(); + const [location] = useLocation(); + + const toggleLanguage = () => { + setLanguage(language === "ar" ? "en" : "ar"); + }; + + const navItems = [ + { path: "/", label: t.common.home }, + { path: "/opportunities", label: t.common.opportunities }, + { path: "/request", label: t.common.requestSupport }, + { path: "/admin", label: t.common.adminDashboard }, + { path: "/whatsapp-log", label: t.common.whatsappLog }, + ]; + + return ( +
+
+
+ +
+ إ +
+ {t.common.ehsan} + + + +
+ +
+ +
+
+
+ ); +} diff --git a/artifacts/ehsan-poc/src/components/ui/accordion.tsx b/artifacts/ehsan-poc/src/components/ui/accordion.tsx new file mode 100644 index 0000000..e1797c9 --- /dev/null +++ b/artifacts/ehsan-poc/src/components/ui/accordion.tsx @@ -0,0 +1,55 @@ +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDown } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Accordion = AccordionPrimitive.Root + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/artifacts/ehsan-poc/src/components/ui/alert-dialog.tsx b/artifacts/ehsan-poc/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..fa2b442 --- /dev/null +++ b/artifacts/ehsan-poc/src/components/ui/alert-dialog.tsx @@ -0,0 +1,139 @@ +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/artifacts/ehsan-poc/src/components/ui/alert.tsx b/artifacts/ehsan-poc/src/components/ui/alert.tsx new file mode 100644 index 0000000..5afd41d --- /dev/null +++ b/artifacts/ehsan-poc/src/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/artifacts/ehsan-poc/src/components/ui/aspect-ratio.tsx b/artifacts/ehsan-poc/src/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..c4abbf3 --- /dev/null +++ b/artifacts/ehsan-poc/src/components/ui/aspect-ratio.tsx @@ -0,0 +1,5 @@ +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" + +const AspectRatio = AspectRatioPrimitive.Root + +export { AspectRatio } diff --git a/artifacts/ehsan-poc/src/components/ui/avatar.tsx b/artifacts/ehsan-poc/src/components/ui/avatar.tsx new file mode 100644 index 0000000..51e507b --- /dev/null +++ b/artifacts/ehsan-poc/src/components/ui/avatar.tsx @@ -0,0 +1,50 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/artifacts/ehsan-poc/src/components/ui/badge.tsx b/artifacts/ehsan-poc/src/components/ui/badge.tsx new file mode 100644 index 0000000..3f03665 --- /dev/null +++ b/artifacts/ehsan-poc/src/components/ui/badge.tsx @@ -0,0 +1,43 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + // @replit + // Whitespace-nowrap: Badges should never wrap. + "whitespace-nowrap inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2" + + " hover-elevate ", + { + variants: { + variant: { + default: + // @replit shadow-xs instead of shadow, no hover because we use hover-elevate + "border-transparent bg-primary text-primary-foreground shadow-xs", + secondary: + // @replit no hover because we use hover-elevate + "border-transparent bg-secondary text-secondary-foreground", + destructive: + // @replit shadow-xs instead of shadow, no hover because we use hover-elevate + "border-transparent bg-destructive text-destructive-foreground shadow-xs", + // @replit shadow-xs" - use badge outline variable + outline: "text-foreground border [border-color:var(--badge-outline)]", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/artifacts/ehsan-poc/src/components/ui/breadcrumb.tsx b/artifacts/ehsan-poc/src/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..60e6c96 --- /dev/null +++ b/artifacts/ehsan-poc/src/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode + } +>(({ ...props }, ref) =>