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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user