Task #19: Donation cart (سلة تبرعاتك) for EHSAN POC

Built a real donation cart matching ehsan.sa:
- CartContext provider (localStorage-persisted, defensive parsing) wired into App.tsx
- OpportunityCard: cart icon now adds the case with its typed amount and swaps the
  action row to an added state («مضاف لسلة تبرعاتك» + «إزالة»)
- Header cart button: live count badge + navigates to /cart
- New /cart page + route: breadcrumb, item list (delete icon, category, name,
  editable «قيمة المبلغ» amount, image+progress%), summary panel «الإجمالي» +
  green «للمتابعة للدفع», decorative leaf SVG background, empty state
- translations.ts: parallel AR+EN `cart` section
- donate.tsx: removes the donated case from the cart on successful donation
  (cart reconciliation), preventing stale added-state/badge

Notes/deviations:
- Checkout handoff routes the first cart item into the existing single-case donate
  flow (the POC backend has no multi-item payment). Reconciliation keeps remaining
  items coherent. A true multi-item checkout backend was out of scope.
- Verified with passing e2e test (add → badge → cart page → remove → empty) and
  clean tsc; architect review addressed (reconciliation + defensive parsing).
This commit is contained in:
Replit Agent
2026-06-05 19:54:45 +00:00
parent b37e5fdfdb
commit d0d504bc74
8 changed files with 388 additions and 42 deletions
+11 -6
View File
@@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip";
import { LanguageProvider } from "./contexts/LanguageContext";
import { CartProvider } from "./contexts/CartContext";
import { AuthProvider, useAuth } from "./contexts/AuthContext";
import { AppLayout } from "./components/layout/AppLayout";
import NotFound from "@/pages/not-found";
@@ -19,6 +20,7 @@ import Track from "./pages/track";
import ThankYou from "./pages/thank-you";
import WhatsappLog from "./pages/whatsapp-log";
import Login from "./pages/login";
import Cart from "./pages/cart";
const queryClient = new QueryClient();
@@ -39,6 +41,7 @@ function Router() {
<Route path="/request" component={RequestSupport} />
<Route path="/opportunities" component={Opportunities} />
<Route path="/donate/:id" component={Donate} />
<Route path="/cart" component={Cart} />
<Route path="/login" component={Login} />
<Route path="/admin">
<Protected component={Admin} />
@@ -59,12 +62,14 @@ function App() {
<QueryClientProvider client={queryClient}>
<LanguageProvider>
<AuthProvider>
<TooltipProvider>
<WouterRouter base={import.meta.env.BASE_URL.replace(/\/$/, "")}>
<Router />
</WouterRouter>
<Toaster />
</TooltipProvider>
<CartProvider>
<TooltipProvider>
<WouterRouter base={import.meta.env.BASE_URL.replace(/\/$/, "")}>
<Router />
</WouterRouter>
<Toaster />
</TooltipProvider>
</CartProvider>
</AuthProvider>
</LanguageProvider>
</QueryClientProvider>
@@ -1,9 +1,10 @@
import { useState } from "react";
import { useLocation } from "wouter";
import { useLanguage } from "../contexts/LanguageContext";
import { useCart } from "../contexts/CartContext";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Share2, ShoppingCart } from "lucide-react";
import { Share2, ShoppingCart, Check, Trash2 } from "lucide-react";
import { getNeedImage } from "../lib/needImages";
import { Riyal } from "@/components/Riyal";
@@ -21,8 +22,11 @@ interface OpportunityCardProps {
export function OpportunityCard({ request }: OpportunityCardProps) {
const { t } = useLanguage();
const [, setLocation] = useLocation();
const { addItem, removeItem, isInCart } = useCart();
const [amount, setAmount] = useState("");
const inCart = isInCart(request.id);
const progress = Math.min(
100,
request.requestedAmount > 0
@@ -38,6 +42,20 @@ export function OpportunityCard({ request }: OpportunityCardProps) {
setLocation(`/donate/${request.id}${suffix}`);
};
const handleAddToCart = () => {
addItem(
{
id: request.id,
caseId: request.caseId,
description: request.description,
needType: request.needType,
requestedAmount: request.requestedAmount,
collectedAmount: request.collectedAmount,
},
Number(amount)
);
};
return (
<div className="bg-card rounded-2xl border border-card-border shadow-sm overflow-hidden flex flex-col h-full hover-elevate">
{/* Photo + progress bar */}
@@ -89,43 +107,64 @@ export function OpportunityCard({ request }: OpportunityCardProps) {
</div>
</div>
{/* Donate row */}
<div className="flex items-stretch gap-2">
<Button
variant="outline"
size="icon"
className="shrink-0"
onClick={goDonate}
disabled={isComplete}
aria-label={t.common.cart}
data-testid={`button-cart-${request.id}`}
{/* Donate / cart row */}
{inCart ? (
<div
className="flex items-center justify-between gap-2 rounded-lg border border-primary/30 bg-primary/5 px-4 py-3"
data-testid={`added-state-${request.id}`}
>
<ShoppingCart className="w-4 h-4" />
</Button>
<Button
className="shrink-0 px-5"
onClick={goDonate}
disabled={isComplete}
data-testid={`button-donate-${request.id}`}
>
{t.opportunities.donate}
</Button>
<div className="relative flex-1">
<span className="absolute start-3 top-1/2 -translate-y-1/2 text-muted-foreground text-sm pointer-events-none">
<Riyal />
<span className="inline-flex items-center gap-2 text-sm font-medium text-primary">
<Check className="w-4 h-4 shrink-0" />
{t.cart.added}
</span>
<Input
type="number"
min={1}
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder={t.opportunities.amountPlaceholder}
className="ps-8 h-full"
disabled={isComplete}
data-testid={`input-cardAmount-${request.id}`}
/>
<button
type="button"
onClick={() => removeItem(request.id)}
className="inline-flex items-center gap-1 text-sm font-medium text-destructive hover:underline"
data-testid={`button-removeFromCart-${request.id}`}
>
<Trash2 className="w-4 h-4" />
{t.cart.remove}
</button>
</div>
</div>
) : (
<div className="flex items-stretch gap-2">
<Button
variant="outline"
size="icon"
className="shrink-0"
onClick={handleAddToCart}
disabled={isComplete}
aria-label={t.cart.addToCart}
data-testid={`button-cart-${request.id}`}
>
<ShoppingCart className="w-4 h-4" />
</Button>
<Button
className="shrink-0 px-5"
onClick={goDonate}
disabled={isComplete}
data-testid={`button-donate-${request.id}`}
>
{t.opportunities.donate}
</Button>
<div className="relative flex-1">
<span className="absolute start-3 top-1/2 -translate-y-1/2 text-muted-foreground text-sm pointer-events-none">
<Riyal />
</span>
<Input
type="number"
min={1}
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder={t.opportunities.amountPlaceholder}
className="ps-8 h-full"
disabled={isComplete}
data-testid={`input-cardAmount-${request.id}`}
/>
</div>
</div>
)}
</div>
</div>
);
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { Link, useLocation } from "wouter";
import { useLanguage } from "../../contexts/LanguageContext";
import { useAuth } from "../../contexts/AuthContext";
import { useCart } from "../../contexts/CartContext";
import { Button } from "../ui/button";
import {
Search,
@@ -29,6 +30,7 @@ import ehsanLogo from "../../assets/ehsan-logo.png";
export function Header() {
const { language, setLanguage, t } = useLanguage();
const { isAuthenticated, logout } = useAuth();
const { count: cartCount } = useCart();
const [location, setLocation] = useLocation();
const [mobileOpen, setMobileOpen] = useState(false);
const [servicesOpen, setServicesOpen] = useState(false);
@@ -218,11 +220,20 @@ export function Header() {
<Button
variant="ghost"
size="icon"
className="text-foreground"
className="text-foreground relative"
aria-label={t.common.cart}
onClick={() => setLocation("/cart")}
data-testid="button-cart"
>
<ShoppingCart className="w-4 h-4" />
{cartCount > 0 && (
<span
className="absolute -top-1 -end-1 min-w-[1.1rem] h-[1.1rem] px-1 rounded-full bg-primary text-primary-foreground text-[0.65rem] font-bold flex items-center justify-center leading-none"
data-testid="badge-cart-count"
>
{cartCount}
</span>
)}
</Button>
<Button
variant="ghost"
@@ -0,0 +1,99 @@
import { createContext, useContext, useEffect, useState, ReactNode } from "react";
export interface CartItem {
id: string;
caseId: string;
description: string;
needType: string;
requestedAmount: number;
collectedAmount: number;
amount: number;
}
interface CartContextType {
items: CartItem[];
addItem: (item: Omit<CartItem, "amount">, amount: number) => void;
removeItem: (id: string) => void;
updateAmount: (id: string, amount: number) => void;
isInCart: (id: string) => boolean;
clear: () => void;
count: number;
total: number;
}
const CartContext = createContext<CartContextType | undefined>(undefined);
const STORAGE_KEY = "ehsan-cart";
export function CartProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<CartItem[]>(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (!saved) return [];
const parsed = JSON.parse(saved);
if (!Array.isArray(parsed)) return [];
return parsed
.filter((i) => i && typeof i.id === "string")
.map((i) => ({
id: String(i.id),
caseId: String(i.caseId ?? ""),
description: String(i.description ?? ""),
needType: String(i.needType ?? ""),
requestedAmount: Number(i.requestedAmount) || 0,
collectedAmount: Number(i.collectedAmount) || 0,
amount: Number(i.amount) || 0,
}));
} catch {
return [];
}
});
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
}, [items]);
const addItem = (item: Omit<CartItem, "amount">, amount: number) => {
setItems((prev) => {
if (prev.some((i) => i.id === item.id)) return prev;
return [...prev, { ...item, amount: amount > 0 ? amount : 0 }];
});
};
const removeItem = (id: string) => {
setItems((prev) => prev.filter((i) => i.id !== id));
};
const updateAmount = (id: string, amount: number) => {
setItems((prev) =>
prev.map((i) => (i.id === id ? { ...i, amount: amount > 0 ? amount : 0 } : i))
);
};
const isInCart = (id: string) => items.some((i) => i.id === id);
const clear = () => setItems([]);
const count = items.length;
const total = items.reduce((sum, i) => sum + (i.amount || 0), 0);
const value: CartContextType = {
items,
addItem,
removeItem,
updateAmount,
isInCart,
clear,
count,
total,
};
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
}
export function useCart() {
const context = useContext(CartContext);
if (context === undefined) {
throw new Error("useCart must be used within a CartProvider");
}
return context;
}
@@ -236,6 +236,19 @@ export const en = {
paymentTitle: "Payment Details",
selectAmountError: "Please select or enter a valid amount.",
},
cart: {
title: "Your Donation Cart",
breadcrumbHome: "Home",
amountLabel: "Amount value",
remove: "Remove",
added: "Added to your donation cart",
addToCart: "Add to cart",
total: "Total",
proceedToPayment: "Proceed to Payment",
empty: "Your donation cart is empty",
emptyHint: "Browse the available opportunities and add the causes you wish to support.",
browse: "Browse Opportunities",
},
admin: {
title: "Admin Dashboard",
caseId: "Case ID",
@@ -525,6 +538,19 @@ export const ar = {
paymentTitle: "بيانات الدفع",
selectAmountError: "الرجاء اختيار أو إدخال مبلغ صحيح.",
},
cart: {
title: "سلة تبرعاتك",
breadcrumbHome: "الرئيسية",
amountLabel: "قيمة المبلغ",
remove: "إزالة",
added: "مضاف لسلة تبرعاتك",
addToCart: "أضف للسلة",
total: "الإجمالي",
proceedToPayment: "للمتابعة للدفع",
empty: "سلة تبرعاتك فارغة",
emptyHint: "تصفّح الفرص المتاحة وأضف القضايا التي ترغب بدعمها.",
browse: "تصفّح الفرص",
},
admin: {
title: "لوحة الإدارة",
caseId: "رقم الحالة",
+163
View File
@@ -0,0 +1,163 @@
import { useLocation, Link } from "wouter";
import { useLanguage } from "../contexts/LanguageContext";
import { useCart } from "../contexts/CartContext";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ShoppingCart, Trash2, ChevronLeft, ChevronRight } from "lucide-react";
import { getNeedImage } from "../lib/needImages";
import { Riyal } from "@/components/Riyal";
import { Reveal } from "../components/Reveal";
import leafPattern from "@assets/right-snapel-2_1780688632733.svg";
export default function Cart() {
const { t, dir } = useLanguage();
const [, setLocation] = useLocation();
const { items, removeItem, updateAmount, total } = useCart();
const Chevron = dir === "rtl" ? ChevronLeft : ChevronRight;
const proceed = () => {
if (items.length === 0) return;
const first = items[0];
const suffix = first.amount > 0 ? `?amount=${first.amount}` : "";
setLocation(`/donate/${first.id}${suffix}`);
};
return (
<div className="relative overflow-hidden">
{/* Decorative leaf background */}
<img
src={leafPattern}
alt=""
aria-hidden="true"
className="pointer-events-none absolute top-8 start-0 w-56 opacity-60 select-none"
/>
<div className="container relative mx-auto px-4 py-10 max-w-6xl">
{/* Breadcrumb */}
<nav className="flex items-center gap-2 text-sm text-muted-foreground mb-6" aria-label="breadcrumb">
<Link href="/" className="hover:text-primary transition-colors" data-testid="link-breadcrumb-home">
{t.cart.breadcrumbHome}
</Link>
<Chevron className="w-4 h-4" />
<span className="text-foreground font-medium">{t.cart.title}</span>
</nav>
<h1 className="text-2xl md:text-3xl font-bold text-primary mb-8" data-testid="text-cart-title">
{t.cart.title}
</h1>
{items.length === 0 ? (
<Reveal>
<div className="flex flex-col items-center justify-center text-center py-20 bg-card rounded-2xl border border-card-border">
<ShoppingCart className="w-14 h-14 text-muted-foreground/40 mb-4" />
<p className="text-lg font-bold text-foreground mb-2" data-testid="text-cart-empty">
{t.cart.empty}
</p>
<p className="text-sm text-muted-foreground max-w-sm mb-6">{t.cart.emptyHint}</p>
<Button onClick={() => setLocation("/opportunities")} data-testid="button-browse-opportunities">
{t.cart.browse}
</Button>
</div>
</Reveal>
) : (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-start">
{/* Items list */}
<div className="lg:col-span-2 space-y-4">
{items.map((item) => {
const progress = Math.min(
100,
item.requestedAmount > 0
? Math.round((item.collectedAmount / item.requestedAmount) * 100)
: 0
);
return (
<Reveal key={item.id}>
<div
className="bg-card rounded-2xl border border-card-border shadow-sm p-4 flex gap-4"
data-testid={`cart-item-${item.id}`}
>
{/* Image + progress */}
<div className="relative w-28 sm:w-36 shrink-0 rounded-xl overflow-hidden">
<img
src={getNeedImage(item.needType)}
alt={item.description}
className="w-full h-full object-cover min-h-[7rem]"
/>
<div className="absolute bottom-0 inset-x-0 h-5 bg-gray-200 flex">
<div
className="h-full bg-primary flex items-center justify-end px-2 text-primary-foreground text-[0.65rem] font-bold min-w-[2.5rem]"
style={{ width: `${progress}%` }}
>
{progress}%
</div>
</div>
</div>
{/* Content */}
<div className="flex-1 min-w-0 flex flex-col">
<div className="flex items-start justify-between gap-2 mb-2">
<span className="inline-block text-xs font-medium text-muted-foreground border border-border rounded-md px-2.5 py-1">
{t.needTypes[item.needType as keyof typeof t.needTypes] ||
t.opportunities.generalProjects}
</span>
<button
type="button"
onClick={() => removeItem(item.id)}
className="text-muted-foreground hover:text-destructive transition-colors shrink-0"
aria-label={t.cart.remove}
data-testid={`button-remove-${item.id}`}
>
<Trash2 className="w-5 h-5" />
</button>
</div>
<h3 className="text-sm sm:text-base font-bold text-primary leading-snug line-clamp-2 mb-3">
{item.description}
</h3>
<div className="mt-auto relative max-w-xs">
<span className="absolute start-3 top-1/2 -translate-y-1/2 text-muted-foreground text-sm pointer-events-none">
<Riyal />
</span>
<Input
type="number"
min={1}
value={item.amount > 0 ? item.amount : ""}
onChange={(e) => updateAmount(item.id, Number(e.target.value))}
placeholder={t.cart.amountLabel}
className="ps-8"
data-testid={`input-amount-${item.id}`}
/>
</div>
</div>
</div>
</Reveal>
);
})}
</div>
{/* Summary panel */}
<div className="lg:sticky lg:top-24">
<div className="bg-card rounded-2xl border border-card-border shadow-sm p-6">
<div className="flex items-center justify-between pb-4 border-b border-border">
<span className="text-base font-bold text-foreground">{t.cart.total}</span>
<span className="text-xl font-bold text-primary inline-flex items-center gap-1" data-testid="text-cart-total">
{total.toLocaleString()} <Riyal />
</span>
</div>
<Button
className="w-full mt-5 h-12 text-base"
onClick={proceed}
data-testid="button-proceed-to-payment"
>
{t.cart.proceedToPayment}
</Button>
</div>
</div>
</div>
)}
</div>
</div>
);
}
+3
View File
@@ -4,6 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useParams, useLocation, useSearch, Link } from "wouter";
import { useLanguage } from "../contexts/LanguageContext";
import { useCart } from "../contexts/CartContext";
import {
useGetRequest, useDonateToRequest,
getListRequestsQueryKey, getListPublishedRequestsQueryKey, getGetRequestQueryKey,
@@ -35,6 +36,7 @@ export default function Donate() {
const search = useSearch();
const [, setLocation] = useLocation();
const queryClient = useQueryClient();
const { removeItem: removeFromCart } = useCart();
const initialAmount = (() => {
const a = Number(new URLSearchParams(search).get("amount"));
@@ -140,6 +142,7 @@ export default function Donate() {
queryClient.invalidateQueries({ queryKey: getListRequestsQueryKey() });
queryClient.invalidateQueries({ queryKey: getListPublishedRequestsQueryKey() });
queryClient.invalidateQueries({ queryKey: getGetRequestQueryKey(params.id || "") });
removeFromCart(params.id || "");
setDonated(true);
},
}