Compare commits
12 Commits
f1e44084f3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 94ccbf6fe4 | |||
| c089f41b68 | |||
| cb47f9bd2b | |||
| 9e602d53fa | |||
| 838dde0d95 | |||
| 8fb75a51a9 | |||
| 4d83c14297 | |||
| 7f12421d8a | |||
| d6f7f953dd | |||
| ea4134f94e | |||
| e7f0995f1d | |||
| 8aecc02cbe |
@@ -12,3 +12,12 @@ re-seed clean demo data.
|
||||
|
||||
**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.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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
|
||||
@@ -31,6 +31,10 @@ externalPort = 8081
|
||||
localPort = 8082
|
||||
externalPort = 3003
|
||||
|
||||
[[ports]]
|
||||
localPort = 9099
|
||||
externalPort = 3002
|
||||
|
||||
[[ports]]
|
||||
localPort = 18312
|
||||
externalPort = 3000
|
||||
|
||||
@@ -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).
|
||||
@@ -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@9 --activate
|
||||
|
||||
WORKDIR /repo
|
||||
COPY . .
|
||||
|
||||
RUN pnpm install --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"]
|
||||
@@ -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@9 --activate
|
||||
|
||||
WORKDIR /repo
|
||||
COPY . .
|
||||
|
||||
RUN pnpm install --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;"]
|
||||
@@ -116,7 +116,7 @@ router.get("/requests/:id", (req: Request, res: Response): void => {
|
||||
|
||||
// ─── Helper: find & update ────────────────────────────────────────────────────
|
||||
function findAndUpdate(
|
||||
id: string,
|
||||
id: string | string[],
|
||||
updater: (r: DonationRequest) => void,
|
||||
res: Response
|
||||
): void {
|
||||
|
||||
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 59 KiB |
@@ -357,6 +357,20 @@ export const en = {
|
||||
backToDetails: "Back to Details",
|
||||
paymentTitle: "Payment Details",
|
||||
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: {
|
||||
title: "Your Donation Cart",
|
||||
@@ -806,6 +820,20 @@ export const ar = {
|
||||
backToDetails: "رجوع للتفاصيل",
|
||||
paymentTitle: "بيانات الدفع",
|
||||
selectAmountError: "الرجاء اختيار أو إدخال مبلغ صحيح.",
|
||||
successTitle: "شكرا على تبرعك الكريم",
|
||||
successSubtitle: "لقد تم إتمام عملية تبرعك بنجاح!",
|
||||
receiptNumber: "رقم الإيصال",
|
||||
referenceNumber: "الرقم المرجعي للعملية",
|
||||
refundNote: "لتتم عملية الإسترداد بسهولة، نأمل حفظ الرقم المرجعي للعملية",
|
||||
copied: "تم النسخ",
|
||||
statsVisits: "الزيارات",
|
||||
statsVisitsUnit: "زيارة",
|
||||
statsLastDonation: "آخر عملية تبرع قبل",
|
||||
statsSecondUnit: "ثانية",
|
||||
statsBeneficiaries: "عدد المستفيدين",
|
||||
statsOutOf: "من أصل",
|
||||
statsDonations: "عدد عمليات التبرع",
|
||||
statsDonationsUnit: "عملية",
|
||||
},
|
||||
cart: {
|
||||
title: "سلة تبرعاتك",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { useParams, useLocation, useSearch, Link } from "wouter";
|
||||
import { useParams, useSearch, Link } from "wouter";
|
||||
import { useLanguage } from "../contexts/LanguageContext";
|
||||
import { useCart } from "../contexts/CartContext";
|
||||
import {
|
||||
@@ -15,13 +15,45 @@ import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
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 { getNeedImage } from "../lib/needImages";
|
||||
import { Riyal } from "@/components/Riyal";
|
||||
|
||||
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({
|
||||
donorName: z.string().min(2),
|
||||
donorPhone: z.string().min(10),
|
||||
@@ -34,7 +66,6 @@ export default function Donate() {
|
||||
const { t } = useLanguage();
|
||||
const params = useParams<{ id: string }>();
|
||||
const search = useSearch();
|
||||
const [, setLocation] = useLocation();
|
||||
const queryClient = useQueryClient();
|
||||
const { removeItem: removeFromCart } = useCart();
|
||||
|
||||
@@ -49,6 +80,31 @@ export default function Donate() {
|
||||
const [onBehalf, setOnBehalf] = useState(false);
|
||||
const [onBehalfName, setOnBehalfName] = useState("");
|
||||
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 || "", {
|
||||
query: { enabled: !!params.id, queryKey: getGetRequestQueryKey(params.id || "") },
|
||||
@@ -61,6 +117,19 @@ export default function Donate() {
|
||||
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) {
|
||||
return (
|
||||
<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) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-12 max-w-2xl">
|
||||
<Card className="border-2 border-primary/20">
|
||||
<CardContent className="pt-10 pb-10 text-center">
|
||||
<Heart className="w-16 h-16 text-primary mx-auto mb-4 fill-primary/10" />
|
||||
<CheckCircle className="w-10 h-10 text-primary mx-auto mb-4" />
|
||||
<h2 className="text-2xl font-bold text-primary mb-2">{t.common.success}</h2>
|
||||
<p className="text-muted-foreground text-lg mb-6">{t.donate.successMessage}</p>
|
||||
<p className="text-sm font-mono text-muted-foreground bg-muted/30 px-4 py-2 rounded-lg inline-block">
|
||||
{request.caseId}
|
||||
</p>
|
||||
<div className="mt-8 flex gap-3 justify-center">
|
||||
<Button variant="outline" onClick={() => setLocation("/opportunities")}>
|
||||
{t.common.opportunities}
|
||||
</Button>
|
||||
<Button onClick={() => setLocation(`/track/${request.id}`)}>
|
||||
{t.common.trackCase}
|
||||
</Button>
|
||||
<div className="relative min-h-[70vh] overflow-hidden">
|
||||
{/* Faint EHSAN geometric pattern */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-72 opacity-[0.06]"
|
||||
style={{ backgroundImage: PATTERN_BG, backgroundSize: "120px 120px" }}
|
||||
/>
|
||||
|
||||
<div className="container relative mx-auto px-4 py-16 max-w-xl text-center">
|
||||
{/* Checkmark badge */}
|
||||
<div className="mx-auto mb-8 flex h-20 w-20 items-center justify-center rounded-full bg-[#E9F5EF]">
|
||||
<Check className="h-9 w-9 text-[#176B43]" strokeWidth={2} />
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold text-foreground mb-3">{t.donate.successTitle}</h2>
|
||||
<p className="text-muted-foreground mb-7">{t.donate.successSubtitle}</p>
|
||||
|
||||
{/* Amount */}
|
||||
<div className="mb-9 flex items-center justify-center gap-2 text-4xl font-bold text-[#176B43]">
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -143,6 +261,9 @@ export default function Donate() {
|
||||
queryClient.invalidateQueries({ queryKey: getListPublishedRequestsQueryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: getGetRequestQueryKey(params.id || "") });
|
||||
removeFromCart(params.id || "");
|
||||
setDonatedAmount(Number(amount));
|
||||
setReceiptNo(generateReceiptNo());
|
||||
setReferenceNo(generateReferenceNo());
|
||||
setDonated(true);
|
||||
},
|
||||
}
|
||||
@@ -384,6 +505,52 @@ export default function Donate() {
|
||||
)}
|
||||
</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">
|
||||
<Link href="/opportunities">
|
||||
<Button variant="ghost" size="sm">{t.common.back}</Button>
|
||||
|
||||
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 34 KiB |
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||