Compare commits

..

10 Commits

Author SHA1 Message Date
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
20 changed files with 633 additions and 24 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@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"]
+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@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;"]
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;
}
}
+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