Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
**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.
|
||||||
|
|||||||
@@ -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
|
localPort = 8082
|
||||||
externalPort = 3003
|
externalPort = 3003
|
||||||
|
|
||||||
|
[[ports]]
|
||||||
|
localPort = 9099
|
||||||
|
externalPort = 3002
|
||||||
|
|
||||||
[[ports]]
|
[[ports]]
|
||||||
localPort = 18312
|
localPort = 18312
|
||||||
externalPort = 3000
|
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;"]
|
||||||
|
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: "سلة تبرعاتك",
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
{/* 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>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</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>
|
||||||
|
|||||||
|
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,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
|
||||||