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
This commit is contained in:
Replit Agent
2026-06-06 09:20:10 +00:00
parent f1e44084f3
commit 8aecc02cbe
4 changed files with 154 additions and 21 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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

@@ -357,6 +357,12 @@ 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",
}, },
cart: { cart: {
title: "Your Donation Cart", title: "Your Donation Cart",
@@ -806,6 +812,12 @@ export const ar = {
backToDetails: "رجوع للتفاصيل", backToDetails: "رجوع للتفاصيل",
paymentTitle: "بيانات الدفع", paymentTitle: "بيانات الدفع",
selectAmountError: "الرجاء اختيار أو إدخال مبلغ صحيح.", selectAmountError: "الرجاء اختيار أو إدخال مبلغ صحيح.",
successTitle: "شكرا على تبرعك الكريم",
successSubtitle: "لقد تم إتمام عملية تبرعك بنجاح!",
receiptNumber: "رقم الإيصال",
referenceNumber: "الرقم المرجعي للعملية",
refundNote: "لتتم عملية الإسترداد بسهولة، نأمل حفظ الرقم المرجعي للعملية",
copied: "تم النسخ",
}, },
cart: { cart: {
title: "سلة تبرعاتك", title: "سلة تبرعاتك",
+126 -14
View File
@@ -15,13 +15,38 @@ 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 } 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}")`;
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),
@@ -49,6 +74,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 || "") },
@@ -88,17 +138,77 @@ 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]">
<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>
{/* Navigation */}
<div className="mt-10 flex gap-3 justify-center">
<Button variant="outline" onClick={() => setLocation("/opportunities")}> <Button variant="outline" onClick={() => setLocation("/opportunities")}>
{t.common.opportunities} {t.common.opportunities}
</Button> </Button>
@@ -106,8 +216,7 @@ export default function Donate() {
{t.common.trackCase} {t.common.trackCase}
</Button> </Button>
</div> </div>
</CardContent> </div>
</Card>
</div> </div>
); );
} }
@@ -143,6 +252,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);
}, },
} }