Compare commits

..

14 Commits

Author SHA1 Message Date
Replit Agent a0b07262f8 fix(docker): pin pnpm@10.32.1 to match lockfile (resolves LOCKFILE_CONFIG_MISMATCH) 2026-06-06 22:38:25 +03:00
Replit Agent a69da41f98 feat: queue donor WhatsApp confirmations 2026-06-06 22:27:47 +03:00
Replit Agent 94ccbf6fe4 docs: add project brief and fix request route types 2026-06-06 17:01:12 +03:00
Replit Agent c089f41b68 Add project documentation file detailing technical specifications and development setup
Create `hammam-dev.md` at the repository root, containing a comprehensive English-language project brief for the EHSAN "Closed Donation Loop" POC, including tech stack, repo structure, and development/production run instructions.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 1fa9329f-0cec-4a2f-80e8-e26dbae3142e
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 365a3c6c-f69d-49c2-b185-bef6f758fdf7
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/4d696b13-86f2-4c9d-be0d-95b293430047/1fa9329f-0cec-4a2f-80e8-e26dbae3142e/Q5tQff9
Replit-Helium-Checkpoint-Created: true
2026-06-06 12:56:09 +00:00
Replit Agent cb47f9bd2b Add screenshot of successful Tailscale funnel configuration
Add screenshot of successful Tailscale funnel configuration to attached assets.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 1fa9329f-0cec-4a2f-80e8-e26dbae3142e
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: a97180af-9dfe-4037-861b-3dc9d9ffb4fc
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/4d696b13-86f2-4c9d-be0d-95b293430047/1fa9329f-0cec-4a2f-80e8-e26dbae3142e/ODGOKcj
Replit-Helium-Checkpoint-Created: true
2026-06-06 11:27:30 +00:00
Replit Agent 9e602d53fa Add deployment workflow to push code to Gitea and redeploy on Mac Mini
Configure Replit project for deployment to a self-hosted Gitea repository, including a `deploy.sh` script on a Mac Mini to pull changes, stop, rebuild, and restart Docker containers.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 1fa9329f-0cec-4a2f-80e8-e26dbae3142e
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 29017a07-e519-4b14-bdf7-b913b959d38f
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/4d696b13-86f2-4c9d-be0d-95b293430047/1fa9329f-0cec-4a2f-80e8-e26dbae3142e/ODGOKcj
Replit-Helium-Checkpoint-Created: true
2026-06-06 10:11:36 +00:00
Replit Agent 838dde0d95 Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 1fa9329f-0cec-4a2f-80e8-e26dbae3142e
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: d239bc61-265e-49fa-9226-f578c7dc34e5
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/4d696b13-86f2-4c9d-be0d-95b293430047/1fa9329f-0cec-4a2f-80e8-e26dbae3142e/ODGOKcj
Replit-Helium-Checkpoint-Created: true
2026-06-06 10:03:18 +00:00
Replit Agent 8fb75a51a9 Make donation statistic cards shorter and position them below the donate button
Adjusted card padding, text size, and icon dimensions in donate.tsx to visually shrink the statistic cards and reposition them beneath the donate button.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 1fa9329f-0cec-4a2f-80e8-e26dbae3142e
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 0e0b7508-7573-4571-a4cb-0b42c5ee33d0
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/4d696b13-86f2-4c9d-be0d-95b293430047/1fa9329f-0cec-4a2f-80e8-e26dbae3142e/ODGOKcj
Replit-Helium-Checkpoint-Created: true
2026-06-06 09:56:36 +00:00
Replit Agent 4d83c14297 Update donation screen design to match reference
Modify donation page UI to align with provided design, including adjusting card dimensions, border colors, text alignment, and beneficiary count.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 1fa9329f-0cec-4a2f-80e8-e26dbae3142e
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 4689e9bb-3c96-48c6-9a74-9cfbcd5dd8d8
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/4d696b13-86f2-4c9d-be0d-95b293430047/1fa9329f-0cec-4a2f-80e8-e26dbae3142e/gmzM42I
Replit-Helium-Checkpoint-Created: true
2026-06-06 09:51:02 +00:00
Replit Agent 7f12421d8a Remove unused navigation and location tracking from donation page
Remove the `useLocation` hook and associated navigation buttons from the donate page component.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 1fa9329f-0cec-4a2f-80e8-e26dbae3142e
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 63734cdd-842b-41e8-9e46-27ee7cb9c87a
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/4d696b13-86f2-4c9d-be0d-95b293430047/1fa9329f-0cec-4a2f-80e8-e26dbae3142e/BUvsOPe
Replit-Helium-Checkpoint-Created: true
2026-06-06 09:43:27 +00:00
Replit Agent d6f7f953dd Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 1fa9329f-0cec-4a2f-80e8-e26dbae3142e
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: c7b23589-5fde-42ae-8e24-9e43573cfaaf
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/4d696b13-86f2-4c9d-be0d-95b293430047/1fa9329f-0cec-4a2f-80e8-e26dbae3142e/3JkYdFP
Replit-Helium-Checkpoint-Created: true
2026-06-06 09:42:03 +00:00
Replit Agent ea4134f94e Display dynamic donation statistics and update translations
Implement dynamic, hash-derived statistics for visits, last donation, beneficiaries, and donations on the donate page. Update English and Arabic translations to support these new statistics.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 1fa9329f-0cec-4a2f-80e8-e26dbae3142e
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: d09ce5e5-3522-4026-98f7-5e4e673f3a38
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/4d696b13-86f2-4c9d-be0d-95b293430047/1fa9329f-0cec-4a2f-80e8-e26dbae3142e/3JkYdFP
Replit-Helium-Checkpoint-Created: true
2026-06-06 09:37:53 +00:00
Replit Agent e7f0995f1d Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 1fa9329f-0cec-4a2f-80e8-e26dbae3142e
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: b8d02de4-c23f-474e-bd57-f6981c342211
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/4d696b13-86f2-4c9d-be0d-95b293430047/1fa9329f-0cec-4a2f-80e8-e26dbae3142e/4KPAtBh
Replit-Helium-Checkpoint-Created: true
2026-06-06 09:33:38 +00:00
Replit Agent 8aecc02cbe Update donation success screen with translations and improved functionality
Add Arabic and English translations for the donation success screen, including receipt and reference numbers. Implement client-side generation of these numbers with copy-to-clipboard functionality. Update memory data with testing notes regarding donation cases.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 1fa9329f-0cec-4a2f-80e8-e26dbae3142e
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: a89849bc-f826-44f3-8055-c4618b5fd918
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/4d696b13-86f2-4c9d-be0d-95b293430047/1fa9329f-0cec-4a2f-80e8-e26dbae3142e/4KPAtBh
Replit-Helium-Checkpoint-Created: true
2026-06-06 09:20:10 +00:00
25 changed files with 891 additions and 25 deletions
+9
View File
@@ -12,3 +12,12 @@ re-seed clean demo data.
**How to apply:** after running curl-based API tests that mutate state, restart the **How to apply:** after running curl-based API tests that mutate state, restart the
api-server workflow before screenshots/handoff so the user sees a clean seeded demo. api-server workflow before screenshots/handoff so the user sees a clean seeded demo.
## Donate e2e: use OPEN cases only
Seed cases req-001..req-006 are already in later pipeline stages (fully funded,
remaining 0). The donate page clamps any donation to the case's remaining target, so
on those cases the amount silently becomes 0 and the donate POST returns 400 — you
never reach the success screen. For donation/success-screen e2e, use a `published`
case with remaining > 0 (e.g. req-007, req-012..req-017).
**Why:** cost 3 failed test runs chasing a non-bug. The clamp + funded-seed
interaction is not obvious from the UI alone.
+22
View File
@@ -0,0 +1,22 @@
# Keep the Docker build context lean, but DO NOT exclude:
# - attached_assets/ (the web app imports images via the @assets alias)
# - any workspace package.json (pnpm --frozen-lockfile needs them all)
**/node_modules
**/dist
**/.turbo
**/.vite
.git
.cache
.config
.local
.canvas
.agents
*.log
.DS_Store
Thumbs.db
# Docs / meta not needed inside images
.replitignore
+4
View File
@@ -31,6 +31,10 @@ externalPort = 8081
localPort = 8082 localPort = 8082
externalPort = 3003 externalPort = 3003
[[ports]]
localPort = 9099
externalPort = 3002
[[ports]] [[ports]]
localPort = 18312 localPort = 18312
externalPort = 3000 externalPort = 3000
+113
View File
@@ -0,0 +1,113 @@
# Deployment: Replit → Gitea → Mac Mini (Docker)
This project is developed on **Replit** and self-hosted on a **Mac Mini** using
Docker. **Gitea** is the central Git repository. Nothing deploys directly from
Replit, and GitHub is not used.
```
Replit (dev) ──push──▶ Gitea (central repo) ──pull──▶ Mac Mini ──▶ Docker redeploy
```
- **Default branch:** `main`
- **Git remote name (everywhere):** `gitea`
---
## What's in this repo
| File | Runs on | Purpose |
|------|---------|---------|
| `scripts/push-to-gitea.sh` | Replit | Push `main` to the `gitea` remote |
| `deploy.sh` | Mac Mini | Pull `main`, then rebuild & restart Docker |
| `docker-compose.yml` | Mac Mini | Defines the `web` + `api` services |
| `Dockerfile.web` | Mac Mini | Builds the SPA, serves it via nginx (+ `/api` proxy) |
| `Dockerfile.api` | Mac Mini | Builds & runs the Express API server |
| `docker/nginx.conf` | Mac Mini | Static serving + reverse proxy to the API |
---
## One-time setup
### 1. On Replit — add the `gitea` remote
The Gitea repo URL is provided later. Add it once (HTTPS with a token, or SSH):
```bash
# HTTPS (token embedded) — simplest for a headless push
git remote add gitea https://<user>:<token>@<gitea-host>/<owner>/<repo>.git
# …or SSH
git remote add gitea git@<gitea-host>:<owner>/<repo>.git
```
Verify:
```bash
git remote -v # should list 'gitea'
```
### 2. On the Mac Mini — clone the repo from Gitea
```bash
git clone -b main https://<gitea-host>/<owner>/<repo>.git ehsan
cd ehsan
# Make sure the remote is named 'gitea' (clone names it 'origin' by default):
git remote rename origin gitea # only if needed
chmod +x deploy.sh
```
Requirements on the Mac Mini: **Docker Desktop** (or Docker Engine) with the
`docker compose` plugin. On Apple Silicon, the images build for `linux/amd64`
and run under Rosetta/emulation automatically.
---
## Everyday workflow
### A. Push from Replit
Commit your changes (via the Replit Git pane), then:
```bash
./scripts/push-to-gitea.sh
```
(Or directly: `git push gitea main`.)
### B. Redeploy on the Mac Mini
```bash
./deploy.sh
```
`deploy.sh` will, in order:
1. `git pull gitea main`
2. `docker compose down` (stop current containers)
3. `docker compose build` (rebuild images)
4. `docker compose up -d` (start again)
It prints a clear **SUCCESS** message and the running containers, or an
**ERROR** and a non-zero exit code if any step fails.
After a successful deploy the app is available on the Mac Mini at
`http://localhost:8080` (override the host port with `WEB_PORT`, e.g.
`WEB_PORT=3000 ./deploy.sh`).
---
## Notes & constraints
- **Replit stays the development environment.** Its workflows/preview are
unchanged by this setup.
- **amd64 / glibc only.** The pnpm workspace strips every native binary that is
not `linux-x64-gnu`, so the Dockerfiles use `node:24-bookworm-slim` (not
alpine) and pin `platform: linux/amd64`. Do not switch the build base to
alpine or arm64 — the web build (rollup / tailwind oxide / lightningcss) will
fail to find its native binaries.
- **Web ↔ API.** The browser calls same-origin `/api/...`; nginx proxies that to
the `api` container, so no API URL needs to be configured in the frontend.
- **Data.** The API currently uses in-memory demo data, so no database service
is included. Restarting the `api` container resets it.
- **Secrets.** Do not commit the Gitea URL/token. Keep it in the local `gitea`
remote (or pass `GITEA_REMOTE_URL` at push time).
+34
View File
@@ -0,0 +1,34 @@
# syntax=docker/dockerfile:1
#
# API server image (Express, esbuild bundle).
#
# NOTE: the pnpm-workspace `overrides` strip every native binary that is not
# linux-x64-gnu (no musl, no arm64, no darwin). The image therefore MUST be a
# glibc/amd64 image — use node:*-bookworm-slim (NOT alpine) and build for
# linux/amd64 (Docker on Apple Silicon runs this under emulation/Rosetta).
# ---- Build stage -----------------------------------------------------------
FROM --platform=linux/amd64 node:24-bookworm-slim AS build
ENV PNPM_HOME=/pnpm
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable && corepack prepare pnpm@10.32.1 --activate
WORKDIR /repo
COPY . .
RUN pnpm install --no-frozen-lockfile
RUN NODE_ENV=production pnpm --filter @workspace/api-server run build
# ---- Runtime stage ---------------------------------------------------------
FROM --platform=linux/amd64 node:24-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
# esbuild produces a self-contained bundle (index.mjs + pino transport files).
COPY --from=build /repo/artifacts/api-server/dist ./dist
# PORT is supplied by docker-compose (defaults there to 8080).
EXPOSE 8080
CMD ["node", "--enable-source-maps", "dist/index.mjs"]
+32
View File
@@ -0,0 +1,32 @@
# syntax=docker/dockerfile:1
#
# Web image: builds the Vite static bundle and serves it with nginx, which also
# reverse-proxies /api to the api service.
#
# NOTE: build MUST be glibc/amd64 (see Dockerfile.api for the reason). The
# vite.config.ts requires PORT and BASE_PATH to be set even for `build`.
# ---- Build stage -----------------------------------------------------------
FROM --platform=linux/amd64 node:24-bookworm-slim AS build
ENV PNPM_HOME=/pnpm
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable && corepack prepare pnpm@10.32.1 --activate
WORKDIR /repo
COPY . .
RUN pnpm install --no-frozen-lockfile
# PORT + BASE_PATH are required by vite.config.ts at config load time.
RUN PORT=8080 BASE_PATH=/ NODE_ENV=production pnpm --filter @workspace/ehsan-poc run build
# ---- Runtime stage ---------------------------------------------------------
# The glibc/amd64 constraint applies only to the BUILD stage (node native deps).
# The runtime just serves static files, so the lightweight nginx:alpine is fine.
FROM --platform=linux/amd64 nginx:1.27-alpine AS runtime
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /repo/artifacts/ehsan-poc/dist/public /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+39
View File
@@ -77,6 +77,45 @@ export interface WhatsappLogEntry {
createdAt: string; createdAt: string;
} }
// Outbound WhatsApp "thank the donor" notifications, fired right after a
// successful donation. They are queued here (in-memory) and picked up by an
// external poller (OpenClaw) that actually sends the WhatsApp message and then
// marks the row as sent. Resets on container restart like all mock data.
export interface DonorNotification {
id: string;
caseId: string;
donorName: string;
donorPhone: string; // normalized to international form, e.g. 9665XXXXXXXX
message: string;
status: WhatsappStatus; // pending | sent | failed
sentAt: string | null;
createdAt: string;
}
// Fixed Arabic confirmation message sent to the donor after a successful
// donation. This is an outbound WhatsApp message (not in-app UI text), so it is
// intentionally a single fixed string and does not go through LanguageContext.
export const DONOR_THANK_YOU_MESSAGE =
"إحسانك يُثمر وعطاؤك يعين ..\n\nتمت عملية تبرعك عبر منصة إحسان بنجاح\n\n(والله يحب المحسنين)";
export const donorNotifications: DonorNotification[] = [];
// Normalize a Saudi phone number to WhatsApp international form.
// 05XXXXXXXX -> 9665XXXXXXXX
// 5XXXXXXXX -> 9665XXXXXXXX
// 9665XXXXXXXX -> 9665XXXXXXXX (unchanged)
// +966 5XXXXXXXX -> 9665XXXXXXXX
// Returns digits only (no '+'). Falls back to the cleaned digits if the shape
// is unexpected, so we never throw inside the donation flow.
export function normalizeSaudiPhone(raw: string): string {
const digits = String(raw || "").replace(/\D/g, "");
if (!digits) return "";
if (digits.startsWith("966")) return digits;
if (digits.startsWith("0")) return "966" + digits.slice(1);
if (digits.startsWith("5") && digits.length === 9) return "966" + digits;
return digits;
}
// ─── Eligibility Database ─────────────────────────────────────────────────── // ─── Eligibility Database ───────────────────────────────────────────────────
export const eligibilityDb: EligibilityRecord[] = [ export const eligibilityDb: EligibilityRecord[] = [
{ nationalId: "1090512345", eligible: true }, { nationalId: "1090512345", eligible: true },
+2
View File
@@ -4,6 +4,7 @@ import requestsRouter from "./requests.js";
import donorsRouter from "./donors.js"; import donorsRouter from "./donors.js";
import statsRouter from "./stats.js"; import statsRouter from "./stats.js";
import whatsappLogRouter from "./whatsappLog.js"; import whatsappLogRouter from "./whatsappLog.js";
import notificationsRouter from "./notifications.js";
const router: IRouter = Router(); const router: IRouter = Router();
@@ -12,5 +13,6 @@ router.use(requestsRouter);
router.use(donorsRouter); router.use(donorsRouter);
router.use(statsRouter); router.use(statsRouter);
router.use(whatsappLogRouter); router.use(whatsappLogRouter);
router.use(notificationsRouter);
export default router; export default router;
@@ -0,0 +1,36 @@
import { Router, Request, Response } from "express";
import { donorNotifications } from "../lib/mockDb.js";
const router = Router();
// ─── GET /notifications ───────────────────────────────────────────────────────
// Full queue (most recent first). Useful for debugging/inspection.
router.get("/notifications", (_req: Request, res: Response): void => {
res.json([...donorNotifications].reverse());
});
// ─── GET /notifications/pending ───────────────────────────────────────────────
// Rows the external poller (OpenClaw) still needs to send via WhatsApp.
router.get("/notifications/pending", (_req: Request, res: Response): void => {
res.json(donorNotifications.filter((n) => n.status === "pending"));
});
// ─── POST /notifications/:id/sent ─────────────────────────────────────────────
// Marks a queued notification as delivered (or failed). Called by the poller
// after it actually sends the WhatsApp message from the platform number.
router.post(
"/notifications/:id/sent",
(req: Request, res: Response): void => {
const item = donorNotifications.find((n) => n.id === req.params.id);
if (!item) {
res.status(404).json({ error: "Not found" });
return;
}
const failed = req.body?.status === "failed";
item.status = failed ? "failed" : "sent";
item.sentAt = failed ? null : new Date().toISOString();
res.json(item);
}
);
export default router;
+25 -1
View File
@@ -3,10 +3,14 @@ import {
requests, requests,
donors, donors,
whatsappLog, whatsappLog,
donorNotifications,
DONOR_THANK_YOU_MESSAGE,
normalizeSaudiPhone,
checkEligibility, checkEligibility,
STATUS_STEP, STATUS_STEP,
DonationRequest, DonationRequest,
WhatsappLogEntry, WhatsappLogEntry,
DonorNotification,
} from "../lib/mockDb.js"; } from "../lib/mockDb.js";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
@@ -116,7 +120,7 @@ router.get("/requests/:id", (req: Request, res: Response): void => {
// ─── Helper: find & update ──────────────────────────────────────────────────── // ─── Helper: find & update ────────────────────────────────────────────────────
function findAndUpdate( function findAndUpdate(
id: string, id: string | string[],
updater: (r: DonationRequest) => void, updater: (r: DonationRequest) => void,
res: Response res: Response
): void { ): void {
@@ -213,6 +217,26 @@ router.post("/requests/:id/donate", (req: Request, res: Response): void => {
item.currentStep = STATUS_STEP["donated"]; item.currentStep = STATUS_STEP["donated"];
} }
item.updatedAt = new Date().toISOString(); item.updatedAt = new Date().toISOString();
// Queue a WhatsApp "thank the donor" confirmation for every successful
// donation. An external poller (OpenClaw) picks pending rows up and sends the
// message from the platform's WhatsApp number, then marks it sent. This does
// not touch the closed-loop funding logic above.
const normalizedPhone = normalizeSaudiPhone(donorPhone);
if (normalizedPhone) {
const notification: DonorNotification = {
id: uuidv4(),
caseId: item.caseId,
donorName,
donorPhone: normalizedPhone,
message: DONOR_THANK_YOU_MESSAGE,
status: "pending",
sentAt: null,
createdAt: new Date().toISOString(),
};
donorNotifications.push(notification);
}
res.json(item); res.json(item);
}); });
Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

@@ -357,6 +357,20 @@ export const en = {
backToDetails: "Back to Details", backToDetails: "Back to Details",
paymentTitle: "Payment Details", paymentTitle: "Payment Details",
selectAmountError: "Please select or enter a valid amount.", selectAmountError: "Please select or enter a valid amount.",
successTitle: "Thank you for your generous donation",
successSubtitle: "Your donation has been completed successfully!",
receiptNumber: "Receipt Number",
referenceNumber: "Transaction Reference Number",
refundNote: "To make refunds easy, please keep the transaction reference number.",
copied: "Copied",
statsVisits: "Visits",
statsVisitsUnit: "visits",
statsLastDonation: "Last donation",
statsSecondUnit: "seconds ago",
statsBeneficiaries: "Beneficiaries",
statsOutOf: "of",
statsDonations: "Donations",
statsDonationsUnit: "donations",
}, },
cart: { cart: {
title: "Your Donation Cart", title: "Your Donation Cart",
@@ -806,6 +820,20 @@ export const ar = {
backToDetails: "رجوع للتفاصيل", backToDetails: "رجوع للتفاصيل",
paymentTitle: "بيانات الدفع", paymentTitle: "بيانات الدفع",
selectAmountError: "الرجاء اختيار أو إدخال مبلغ صحيح.", selectAmountError: "الرجاء اختيار أو إدخال مبلغ صحيح.",
successTitle: "شكرا على تبرعك الكريم",
successSubtitle: "لقد تم إتمام عملية تبرعك بنجاح!",
receiptNumber: "رقم الإيصال",
referenceNumber: "الرقم المرجعي للعملية",
refundNote: "لتتم عملية الإسترداد بسهولة، نأمل حفظ الرقم المرجعي للعملية",
copied: "تم النسخ",
statsVisits: "الزيارات",
statsVisitsUnit: "زيارة",
statsLastDonation: "آخر عملية تبرع قبل",
statsSecondUnit: "ثانية",
statsBeneficiaries: "عدد المستفيدين",
statsOutOf: "من أصل",
statsDonations: "عدد عمليات التبرع",
statsDonationsUnit: "عملية",
}, },
cart: { cart: {
title: "سلة تبرعاتك", title: "سلة تبرعاتك",
+191 -24
View File
@@ -1,8 +1,8 @@
import { useState } from "react"; import { useState, useMemo } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { useParams, useLocation, useSearch, Link } from "wouter"; import { useParams, useSearch, Link } from "wouter";
import { useLanguage } from "../contexts/LanguageContext"; import { useLanguage } from "../contexts/LanguageContext";
import { useCart } from "../contexts/CartContext"; import { useCart } from "../contexts/CartContext";
import { import {
@@ -15,13 +15,45 @@ import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { CheckCircle, Heart, Gift, Check } from "lucide-react"; import { Gift, Check, Copy, Info, Eye, Clock, Users, Radio } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { getNeedImage } from "../lib/needImages"; import { getNeedImage } from "../lib/needImages";
import { Riyal } from "@/components/Riyal"; import { Riyal } from "@/components/Riyal";
const PRESETS = [100, 50, 10]; const PRESETS = [100, 50, 10];
// POC: receipt/reference numbers are not returned by the API, so we synthesize
// plausible values on the client at the moment the donation succeeds.
function generateReceiptNo(): string {
let s = "";
for (let i = 0; i < 15; i++) s += Math.floor(Math.random() * 10);
return s;
}
function generateReferenceNo(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
// Subtle EHSAN-style overlapping-circles geometric pattern.
const PATTERN_SVG = encodeURIComponent(
`<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120' viewBox='0 0 120 120'><g fill='none' stroke='#17a85a' stroke-width='1'><circle cx='0' cy='0' r='60'/><circle cx='120' cy='0' r='60'/><circle cx='0' cy='120' r='60'/><circle cx='120' cy='120' r='60'/><circle cx='60' cy='60' r='60'/></g></svg>`
);
const PATTERN_BG = `url("data:image/svg+xml,${PATTERN_SVG}")`;
// Stable per-case pseudo-random seed so POC stat values don't flicker on re-render.
function hashStr(s: string): number {
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
return h;
}
const schema = z.object({ const schema = z.object({
donorName: z.string().min(2), donorName: z.string().min(2),
donorPhone: z.string().min(10), donorPhone: z.string().min(10),
@@ -34,7 +66,6 @@ export default function Donate() {
const { t } = useLanguage(); const { t } = useLanguage();
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const search = useSearch(); const search = useSearch();
const [, setLocation] = useLocation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { removeItem: removeFromCart } = useCart(); const { removeItem: removeFromCart } = useCart();
@@ -49,6 +80,31 @@ export default function Donate() {
const [onBehalf, setOnBehalf] = useState(false); const [onBehalf, setOnBehalf] = useState(false);
const [onBehalfName, setOnBehalfName] = useState(""); const [onBehalfName, setOnBehalfName] = useState("");
const [donated, setDonated] = useState(false); const [donated, setDonated] = useState(false);
const [donatedAmount, setDonatedAmount] = useState(0);
const [receiptNo, setReceiptNo] = useState("");
const [referenceNo, setReferenceNo] = useState("");
const [copiedField, setCopiedField] = useState<"receipt" | "reference" | null>(null);
const copyToClipboard = async (value: string, field: "receipt" | "reference") => {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
} else {
const ta = document.createElement("textarea");
ta.value = value;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
}
setCopiedField(field);
setTimeout(() => setCopiedField((c) => (c === field ? null : c)), 1500);
} catch {
// Clipboard unavailable or permission denied; silently ignore.
}
};
const { data: request, isLoading } = useGetRequest(params.id || "", { const { data: request, isLoading } = useGetRequest(params.id || "", {
query: { enabled: !!params.id, queryKey: getGetRequestQueryKey(params.id || "") }, query: { enabled: !!params.id, queryKey: getGetRequestQueryKey(params.id || "") },
@@ -61,6 +117,19 @@ export default function Donate() {
defaultValues: { donorName: "", donorPhone: "", donorEmail: "" }, defaultValues: { donorName: "", donorPhone: "", donorEmail: "" },
}); });
// POC demo stats — stable per case (visits / last-donation time / beneficiaries
// are not stored by the API, so derive plausible values from the case id).
const stats = useMemo(() => {
const h = hashStr(params.id || "case");
return {
visits: 8000 + (h % 15000),
donations: 1500 + ((h >> 3) % 22000),
beneficiaries: 5 + (h % 30),
totalBeneficiaries: 60,
lastDonationSeconds: 11 + (h % 49),
};
}, [params.id]);
if (isLoading) { if (isLoading) {
return ( return (
<div className="container mx-auto px-4 py-12 max-w-5xl space-y-4"> <div className="container mx-auto px-4 py-12 max-w-5xl space-y-4">
@@ -88,26 +157,75 @@ export default function Donate() {
if (donated) { if (donated) {
return ( return (
<div className="container mx-auto px-4 py-12 max-w-2xl"> <div className="relative min-h-[70vh] overflow-hidden">
<Card className="border-2 border-primary/20"> {/* Faint EHSAN geometric pattern */}
<CardContent className="pt-10 pb-10 text-center"> <div
<Heart className="w-16 h-16 text-primary mx-auto mb-4 fill-primary/10" /> aria-hidden="true"
<CheckCircle className="w-10 h-10 text-primary mx-auto mb-4" /> className="pointer-events-none absolute inset-x-0 top-0 h-72 opacity-[0.06]"
<h2 className="text-2xl font-bold text-primary mb-2">{t.common.success}</h2> style={{ backgroundImage: PATTERN_BG, backgroundSize: "120px 120px" }}
<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} <div className="container relative mx-auto px-4 py-16 max-w-xl text-center">
</p> {/* Checkmark badge */}
<div className="mt-8 flex gap-3 justify-center"> <div className="mx-auto mb-8 flex h-20 w-20 items-center justify-center rounded-full bg-[#E9F5EF]">
<Button variant="outline" onClick={() => setLocation("/opportunities")}> <Check className="h-9 w-9 text-[#176B43]" strokeWidth={2} />
{t.common.opportunities} </div>
</Button>
<Button onClick={() => setLocation(`/track/${request.id}`)}> <h2 className="text-2xl font-bold text-foreground mb-3">{t.donate.successTitle}</h2>
{t.common.trackCase} <p className="text-muted-foreground mb-7">{t.donate.successSubtitle}</p>
</Button>
</div> {/* Amount */}
</CardContent> <div className="mb-9 flex items-center justify-center gap-2 text-4xl font-bold text-[#176B43]">
</Card> <Riyal size="1em" />
<span>
{donatedAmount.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</span>
</div>
{/* Receipt + reference chips */}
<div className="space-y-4">
<button
type="button"
onClick={() => copyToClipboard(receiptNo, "receipt")}
data-testid="button-copy-receipt"
className="group flex w-full items-center justify-between gap-3 rounded-full border border-[#CDE7DA] bg-white px-5 py-3.5 text-start transition-colors hover:bg-[#F4FAF7]"
>
<Copy className="h-5 w-5 shrink-0 text-[#176B43]" />
<span className="flex-1 text-sm text-foreground">
<span className="text-muted-foreground">{t.donate.receiptNumber}: </span>
<span className="font-medium">{receiptNo}</span>
</span>
{copiedField === "receipt" && (
<span className="shrink-0 text-xs font-medium text-[#176B43]">{t.donate.copied}</span>
)}
</button>
<button
type="button"
onClick={() => copyToClipboard(referenceNo, "reference")}
data-testid="button-copy-reference"
className="group flex w-full items-center justify-between gap-3 rounded-2xl border border-[#CDE7DA] bg-white px-5 py-3.5 text-start transition-colors hover:bg-[#F4FAF7]"
>
<Copy className="h-5 w-5 shrink-0 text-[#176B43]" />
<span className="flex-1 text-sm text-foreground">
<span className="text-muted-foreground">{t.donate.referenceNumber}: </span>
<span className="font-medium break-all">{referenceNo}</span>
</span>
{copiedField === "reference" && (
<span className="shrink-0 text-xs font-medium text-[#176B43]">{t.donate.copied}</span>
)}
</button>
</div>
{/* Refund note */}
<div className="mt-6 flex items-center justify-center gap-2 rounded-xl bg-muted/40 px-5 py-4 text-sm text-muted-foreground">
<Info className="h-4 w-4 shrink-0" />
<span>{t.donate.refundNote}</span>
</div>
</div>
</div> </div>
); );
} }
@@ -143,6 +261,9 @@ export default function Donate() {
queryClient.invalidateQueries({ queryKey: getListPublishedRequestsQueryKey() }); queryClient.invalidateQueries({ queryKey: getListPublishedRequestsQueryKey() });
queryClient.invalidateQueries({ queryKey: getGetRequestQueryKey(params.id || "") }); queryClient.invalidateQueries({ queryKey: getGetRequestQueryKey(params.id || "") });
removeFromCart(params.id || ""); removeFromCart(params.id || "");
setDonatedAmount(Number(amount));
setReceiptNo(generateReceiptNo());
setReferenceNo(generateReferenceNo());
setDonated(true); setDonated(true);
}, },
} }
@@ -384,6 +505,52 @@ export default function Donate() {
)} )}
</div> </div>
{/* Case stat cards (POC demo values) */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-8">
{[
{
icon: Eye,
label: t.donate.statsVisits,
value: stats.visits.toLocaleString("en-US"),
unit: t.donate.statsVisitsUnit,
},
{
icon: Clock,
label: t.donate.statsLastDonation,
value: stats.lastDonationSeconds.toLocaleString("en-US"),
unit: t.donate.statsSecondUnit,
},
{
icon: Users,
label: t.donate.statsBeneficiaries,
value: stats.beneficiaries.toLocaleString("en-US"),
unit: `${t.donate.statsOutOf} ${stats.totalBeneficiaries.toLocaleString("en-US")}`,
},
{
icon: Radio,
label: t.donate.statsDonations,
value: stats.donations.toLocaleString("en-US"),
unit: t.donate.statsDonationsUnit,
},
].map(({ icon: Icon, label, value, unit }) => (
<div
key={label}
className="flex items-center gap-4 rounded-2xl border border-gray-200 bg-white px-5 py-4"
>
<div className="flex-1 min-w-0 text-center">
<p className="text-sm text-[#1B8354] mb-1.5">{label}</p>
<p className="flex items-baseline justify-center gap-1.5 flex-wrap font-bold text-foreground text-xl">
<span>{value}</span>
<span className="text-xs font-normal text-[#1B8354]">{unit}</span>
</p>
</div>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#EAF5EF] text-[#1B8354]">
<Icon className="h-[18px] w-[18px]" />
</div>
</div>
))}
</div>
<div className="mt-6 text-center"> <div className="mt-6 text-center">
<Link href="/opportunities"> <Link href="/opportunities">
<Button variant="ghost" size="sm">{t.common.back}</Button> <Button variant="ghost" size="sm">{t.common.back}</Button>
Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Executable
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
#
# EHSAN — Mac Mini deployment script.
#
# Pulls the latest code from the Gitea repository, then rebuilds and restarts
# the Docker containers. Run this ON THE MAC MINI, from the repo checkout.
#
# Flow: Replit -> push to Gitea -> (this script) Mac Mini pulls -> Docker redeploys
#
# Usage:
# ./deploy.sh
#
# Optional overrides (environment variables):
# GIT_REMOTE Git remote to pull from (default: gitea)
# GIT_BRANCH Branch to deploy (default: main)
# WEB_PORT Host port for the web app (default: 8080)
set -euo pipefail
GIT_REMOTE="${GIT_REMOTE:-gitea}"
GIT_BRANCH="${GIT_BRANCH:-main}"
# Always operate from the directory this script lives in.
cd "$(dirname "$0")"
# --- Resolve the docker compose command (v2 plugin or legacy binary) --------
if docker compose version >/dev/null 2>&1; then
COMPOSE="docker compose"
elif command -v docker-compose >/dev/null 2>&1; then
COMPOSE="docker-compose"
else
echo "ERROR: Docker Compose was not found. Install Docker Desktop (includes the" >&2
echo " 'docker compose' plugin) or the standalone 'docker-compose' binary." >&2
exit 1
fi
echo "=================================================="
echo " EHSAN deploy | remote=${GIT_REMOTE} branch=${GIT_BRANCH}"
echo "=================================================="
echo ""
echo "==> [1/4] Pulling latest code from ${GIT_REMOTE}/${GIT_BRANCH}..."
if ! git pull "${GIT_REMOTE}" "${GIT_BRANCH}"; then
echo "ERROR: 'git pull ${GIT_REMOTE} ${GIT_BRANCH}' failed. Is the '${GIT_REMOTE}' remote" >&2
echo " configured and reachable? (git remote add ${GIT_REMOTE} <GITEA_URL>)" >&2
exit 1
fi
echo ""
echo "==> [2/4] Stopping current containers..."
if ! ${COMPOSE} down --remove-orphans; then
echo "ERROR: Failed to stop the existing containers." >&2
exit 1
fi
echo ""
echo "==> [3/4] Rebuilding containers (this can take a few minutes)..."
if ! ${COMPOSE} build --pull; then
echo "ERROR: Docker image build failed. See the build output above." >&2
exit 1
fi
echo ""
echo "==> [4/4] Starting containers..."
if ! ${COMPOSE} up -d; then
echo "ERROR: Failed to start the containers." >&2
exit 1
fi
echo ""
echo "=================================================="
echo " SUCCESS: Deployment complete."
echo "=================================================="
${COMPOSE} ps
echo ""
echo "The app should now be available on this machine at: http://localhost:${WEB_PORT:-8080}"
+45
View File
@@ -0,0 +1,45 @@
# Docker Compose for the EHSAN app on the Mac Mini.
#
# Two services:
# - api : Express API server (internal only, reached via the web proxy)
# - web : nginx serving the built SPA + reverse-proxying /api -> api
#
# Platform is pinned to linux/amd64 because the workspace strips all native
# binaries that are not linux-x64-gnu. On Apple Silicon this runs under
# emulation/Rosetta automatically.
services:
api:
build:
context: .
dockerfile: Dockerfile.api
platform: linux/amd64
environment:
NODE_ENV: production
PORT: "8080"
expose:
- "8080"
restart: unless-stopped
healthcheck:
test:
- CMD
- node
- -e
- "fetch('http://localhost:8080/api/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
interval: 30s
timeout: 5s
retries: 5
start_period: 20s
web:
build:
context: .
dockerfile: Dockerfile.web
platform: linux/amd64
ports:
# Host port is configurable: WEB_PORT (default 8080) -> container :80
- "${WEB_PORT:-8080}:80"
depends_on:
api:
condition: service_healthy
restart: unless-stopped
+28
View File
@@ -0,0 +1,28 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Gzip for static assets
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
# Forward API calls to the api service (same Docker network).
# The prefix /api is preserved, which is what the API server expects.
location /api/ {
proxy_pass http://api:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# SPA fallback — let the client router handle unknown paths.
location / {
try_files $uri $uri/ /index.html;
}
}
+156
View File
@@ -0,0 +1,156 @@
# Hammam Dev — EHSAN "Closed Donation Loop" (POC)
> Onboarding brief for any developer or AI coding agent (e.g. OpenClaw) taking over
> this project. Read it fully before editing. The repo is hosted on a self-hosted
> **Gitea** server and runs on a **Mac Mini** via Docker.
>
> **تنويه:** مشروع **تجريبي** مستوحى من فكرة منصة «إحسان»، وليس منصة إحسان الرسمية
> ولا تابعاً لها.
## 1. What this product is
A bilingual (Arabic / English, full RTL + LTR) **proof-of-concept charity donation web
app** inspired by the Saudi "EHSAN" (إحسان) platform. Its core idea is a **Closed
Donation Loop**:
- Beneficiaries submit support requests (housing, food, electricity, water, health,
court-ordered debt, appliances like A/C & refrigerator, etc.).
- Each request/opportunity has a **funding target**. Donations from multiple donors
**accumulate and are clamped to the target** — a case can never be over-funded.
- A case only **enters the "closed loop" fulfillment pipeline once it is fully funded**.
This is the central business rule. Do not break it.
- After funding, the flow continues to confirmation / tracking, and a simulated
WhatsApp notification log records donor/beneficiary messaging.
This is a POC/demo: data is **in-memory mock data** (no real database, no real payments).
## 2. Tech stack
- Monorepo: **pnpm workspaces**, Node.js 24, TypeScript 5.9.
- Web app (`artifacts/ehsan-poc`): React + Vite + **wouter** (routing) +
**TanStack Query** (data) + **Tailwind CSS** + shadcn/ui components.
- API (`artifacts/api-server`): **Express 5**, all routes mounted under `/api`,
health at `/api/healthz`. Data lives in `src/lib/mockDb.ts` (in-memory; resets on restart).
- Shared libs under `lib/`: `api-spec` (OpenAPI source of truth), `api-client-react`
(generated React Query hooks), `api-zod` (Zod schemas), `db` (Drizzle schema, not
active at runtime since data is in-memory).
- All workspace libs export TypeScript source directly (`./src/index.ts`) — no lib
pre-build step; Vite/esbuild consume the source.
## 3. Repo map
- `artifacts/ehsan-poc/src/pages/` — screens: home, about, waqf, baraem, request,
opportunities, donate/:id, cart, login, admin, track/:id, thank-you/:id,
whatsapp-log, not-found.
- `artifacts/ehsan-poc/src/contexts/``LanguageContext` (ar/en + RTL), `CartContext`
(multi-case donation cart), `AuthContext` (mock admin login).
- `artifacts/ehsan-poc/src/components/``Riyal.tsx` (renders the NEW Saudi Riyal
symbol via an image mask), `layout/` (Header, AppLayout), `ui/` (shadcn components).
- `artifacts/ehsan-poc/src/App.tsx` — UI root: routing (wouter) + all context providers.
- `artifacts/api-server/src/routes/``health`, `requests`, `donors`, `stats`,
`whatsappLog`. Mounted in `routes/index.ts`.
- `artifacts/api-server/src/routes/requests.ts` — **the closed-loop logic and every
status transition** live here.
- `artifacts/api-server/src/lib/mockDb.ts` — types, in-memory seed data, the
`STATUS_STEP` map, and `checkEligibility`.
- Root deploy files: `Dockerfile.web`, `Dockerfile.api`, `docker/nginx.conf`,
`docker-compose.yml`, `deploy.sh`, `scripts/push-to-gitea.sh`, `DEPLOYMENT.md`.
## 4. How to run locally (development)
```bash
pnpm install
pnpm --filter @workspace/api-server run dev # API (port 5000 in dev)
pnpm --filter @workspace/ehsan-poc run dev # Web (needs PORT and BASE_PATH env)
pnpm run typecheck # full typecheck
pnpm run build # typecheck + build everything
```
Note: `vite.config.ts` REQUIRES `PORT` and `BASE_PATH` env vars even for `build`
(it throws otherwise), e.g.
`PORT=8080 BASE_PATH=/ pnpm --filter @workspace/ehsan-poc run build`.
> **Apple Silicon caveat:** a full `pnpm run build` typechecks all packages but the
> `vite build` step fails locally on darwin-arm64 with
> `Cannot find module @rollup/rollup-darwin-arm64`. That is **by design** — the
> workspace `overrides` in `pnpm-workspace.yaml` strip every non-`linux-x64-gnu`
> native binary so the Docker (`linux/amd64`) build stays clean. The real build runs
> inside Docker; locally the `typecheck` result is the meaningful gate.
## 5. How it runs in production (Mac Mini, Docker)
Two services in `docker-compose.yml`:
- `api` — Express, internal only, listens on `PORT=8080`, healthcheck `/api/healthz`.
- `web` — nginx that serves the built Vite SPA **and reverse-proxies `/api/` to
`api:8080`**. The browser always calls **same-origin `/api/...`**, so there is NO
frontend API URL to configure.
Deploy / redeploy on the Mac Mini:
```bash
./deploy.sh # git pull gitea main → docker compose down → build → up -d
```
App is then served on the Mac Mini at `http://localhost:8080` (override with `WEB_PORT`).
## 6. Deployment flow (how code travels)
```
Edit code → commit → push to Gitea (branch: main)
→ on Mac Mini run ./deploy.sh → Docker rebuilds & restarts
```
- Central repo is **Gitea** (no GitHub), branch `main`. The documented remote name is
`gitea`; on this Mac Mini checkout the remote may be named `origin` but still points
at the Gitea host — verify with `git remote -v`.
- `scripts/push-to-gitea.sh` pushes from a dev machine; `deploy.sh` redeploys on the Mac Mini.
- An AI agent working directly on the Mac Mini clone should use this loop:
edit → test → `pnpm run build``git commit``git push <gitea-remote> main``./deploy.sh`.
## 7. Status lifecycle (create → close)
The case `status` (`RequestStatus`) and its step number (`STATUS_STEP` in `mockDb.ts`):
```
new (1)
→ pending_review (2)
→ verified (3)
→ published (4)
→ donated (5) ← reached ONLY when fully funded
→ delivered (6)
→ receipt_confirmed (7)
→ thank_you_submitted (8)
→ whatsapp_sent (9)
→ closed (10)
rejected (side path, step 2)
```
- **Eligibility on creation:** `POST /requests` checks `nationalId` via
`checkEligibility` → eligible ⇒ `verified`, not eligible ⇒ `rejected`, unknown ⇒
`pending_review`.
- **API transition endpoints:** `verify`, `publish`, `donate`, `deliver`,
`confirm-receipt`, `thank-you`, `send-whatsapp`, `close`, `reject`
(`POST /requests/:id/<action>`).
- **`donate` is the heart of the closed loop:** it only accepts donations while the
case is `published`, clamps each donation to the remaining amount
(`applied = min(amount, requestedAmount collectedAmount)`), and advances the case
to `donated` only once `collectedAmount >= requestedAmount`.
## 8. Hard rules / gotchas — do NOT break these
- **amd64 / glibc ONLY.** The pnpm workspace strips every native binary that is not
`linux-x64-gnu`. Docker build stages MUST use `node:24-bookworm-slim` (glibc, not
alpine) and `platform: linux/amd64` (runs under Rosetta on Apple Silicon). Do not
switch the build base to alpine or arm64 — rollup / tailwind-oxide / lightningcss
will fail to find native binaries.
- **Funding rule (Closed Donation Loop).** Donations accumulate and clamp to the
target; a case can never be over-funded and enters the fulfillment pipeline
(`donated` and beyond) only when fully funded. Preserve this.
- **Bilingual + RTL.** Every user-facing string must exist in both Arabic and English
via `LanguageContext`. Don't hardcode single-language text. Keep RTL layout working.
- **Same-origin API.** The browser calls `/api/...` on the same domain (nginx proxies
it to the `api` service). Never add a separate frontend API URL.
- **Currency.** Saudi Riyal uses the new official symbol rendered by the `<Riyal/>`
component (image mask), not the old "ر.س"/"SAR" text. Reuse `<Riyal/>`.
- **Routing base.** The app is mounted under a base path via `import.meta.env.BASE_URL`.
Use it for routes/links; never hardcode root-relative `/api` in a way that escapes
the base — call same-origin `/api/...` through the proxy.
- **Data is ephemeral.** mockDb is in-memory; restarting the `api` container resets it.
If you add persistence, add a database service to `docker-compose.yml` accordingly.
- **HMR quirk (dev):** if you see "useLanguage must be used within a LanguageProvider"
while the code is correct, it's stale Fast Refresh state — restart the web dev server.
- Always run `pnpm run build` (typecheck + build) before pushing to catch type errors.
## 9. Suggested first task for a new agent
Read `DEPLOYMENT.md`, `artifacts/ehsan-poc/src/App.tsx`, and
`artifacts/api-server/src/routes/index.ts` to confirm the routes and data model, then
summarize your understanding of the funding / closed-loop flow before making any change.
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
#
# Push this project to the central Gitea repository.
#
# Run this FROM REPLIT (the development environment). It pushes the `main`
# branch to the `gitea` remote so the Mac Mini can pull and redeploy.
#
# Flow: (this script) Replit -> push to Gitea -> Mac Mini pulls -> Docker redeploys
#
# One-time setup — add the gitea remote with your Gitea repo URL:
# git remote add gitea https://<user>:<token>@<gitea-host>/<owner>/<repo>.git
# # or via SSH:
# git remote add gitea git@<gitea-host>:<owner>/<repo>.git
#
# After that, just run:
# ./scripts/push-to-gitea.sh
#
# Optional overrides (environment variables):
# GIT_REMOTE Remote name (default: gitea)
# GIT_BRANCH Branch to push (default: main)
# GITEA_REMOTE_URL If set and the remote does not exist, it is added first.
set -euo pipefail
GIT_REMOTE="${GIT_REMOTE:-gitea}"
GIT_BRANCH="${GIT_BRANCH:-main}"
# Add the remote automatically if a URL was provided and it is missing.
if ! git remote get-url "${GIT_REMOTE}" >/dev/null 2>&1; then
if [ -n "${GITEA_REMOTE_URL:-}" ]; then
echo "==> Adding '${GIT_REMOTE}' remote -> ${GITEA_REMOTE_URL}"
git remote add "${GIT_REMOTE}" "${GITEA_REMOTE_URL}"
else
echo "ERROR: Git remote '${GIT_REMOTE}' is not configured." >&2
echo " Add it once with:" >&2
echo " git remote add ${GIT_REMOTE} <GITEA_REPO_URL>" >&2
echo " Or re-run with the URL inline:" >&2
echo " GITEA_REMOTE_URL=<GITEA_REPO_URL> ./scripts/push-to-gitea.sh" >&2
exit 1
fi
fi
# NOTE: never echo the remote URL — it may contain an embedded token.
echo "==> Pushing '${GIT_BRANCH}' to '${GIT_REMOTE}'..."
if git push "${GIT_REMOTE}" "${GIT_BRANCH}"; then
echo "SUCCESS: Pushed ${GIT_BRANCH} to ${GIT_REMOTE}."
echo "Next: on the Mac Mini run ./deploy.sh to pull and redeploy."
else
echo "ERROR: Push to ${GIT_REMOTE}/${GIT_BRANCH} failed." >&2
exit 1
fi