Add ability for users to donate to multiple items in their cart at once
Implement multi-item donation checkout flow, including form validation, error handling for partial failures, and success messaging. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 1fa9329f-0cec-4a2f-80e8-e26dbae3142e Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 2339a9db-a182-40b6-a165-d906737c84f7 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/4d696b13-86f2-4c9d-be0d-95b293430047/1fa9329f-0cec-4a2f-80e8-e26dbae3142e/ZkqO90V Replit-Helium-Checkpoint-Created: true
This commit is contained in:
@@ -4,7 +4,7 @@ import { useLanguage } from "../contexts/LanguageContext";
|
|||||||
import { useCart } from "../contexts/CartContext";
|
import { useCart } from "../contexts/CartContext";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Share2, ShoppingCart, Check, Trash2 } from "lucide-react";
|
import { Share2, ShoppingCart } from "lucide-react";
|
||||||
import { getNeedImage } from "../lib/needImages";
|
import { getNeedImage } from "../lib/needImages";
|
||||||
import { Riyal } from "@/components/Riyal";
|
import { Riyal } from "@/components/Riyal";
|
||||||
|
|
||||||
@@ -110,20 +110,18 @@ export function OpportunityCard({ request }: OpportunityCardProps) {
|
|||||||
{/* Donate / cart row */}
|
{/* Donate / cart row */}
|
||||||
{inCart ? (
|
{inCart ? (
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-between gap-2 rounded-lg border border-primary/30 bg-primary/5 px-4 py-3"
|
className="flex items-center justify-between gap-3 rounded-lg bg-muted/70 px-4 py-3"
|
||||||
data-testid={`added-state-${request.id}`}
|
data-testid={`added-state-${request.id}`}
|
||||||
>
|
>
|
||||||
<span className="inline-flex items-center gap-2 text-sm font-medium text-primary">
|
<span className="text-sm font-medium text-primary">
|
||||||
<Check className="w-4 h-4 shrink-0" />
|
|
||||||
{t.cart.added}
|
{t.cart.added}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeItem(request.id)}
|
onClick={() => removeItem(request.id)}
|
||||||
className="inline-flex items-center gap-1 text-sm font-medium text-destructive hover:underline"
|
className="text-sm font-medium text-foreground hover:text-destructive transition-colors"
|
||||||
data-testid={`button-removeFromCart-${request.id}`}
|
data-testid={`button-removeFromCart-${request.id}`}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
{t.cart.remove}
|
{t.cart.remove}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -344,6 +344,9 @@ export const en = {
|
|||||||
empty: "Your donation cart is empty",
|
empty: "Your donation cart is empty",
|
||||||
emptyHint: "Browse the available opportunities and add the causes you wish to support.",
|
emptyHint: "Browse the available opportunities and add the causes you wish to support.",
|
||||||
browse: "Browse Opportunities",
|
browse: "Browse Opportunities",
|
||||||
|
amountRequired: "Please enter an amount for each item in the cart.",
|
||||||
|
itemsCount: "Items",
|
||||||
|
successMessage: "Thank you. Your donations have been completed successfully. May Allah reward you.",
|
||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
title: "Admin Dashboard",
|
title: "Admin Dashboard",
|
||||||
@@ -742,6 +745,9 @@ export const ar = {
|
|||||||
empty: "سلة تبرعاتك فارغة",
|
empty: "سلة تبرعاتك فارغة",
|
||||||
emptyHint: "تصفّح الفرص المتاحة وأضف القضايا التي ترغب بدعمها.",
|
emptyHint: "تصفّح الفرص المتاحة وأضف القضايا التي ترغب بدعمها.",
|
||||||
browse: "تصفّح الفرص",
|
browse: "تصفّح الفرص",
|
||||||
|
amountRequired: "الرجاء إدخال قيمة المبلغ لكل عنصر في السلة.",
|
||||||
|
itemsCount: "عدد العناصر",
|
||||||
|
successMessage: "شكراً لك. تمت تبرعاتك بنجاح. جزاك الله خيراً.",
|
||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
title: "لوحة الإدارة",
|
title: "لوحة الإدارة",
|
||||||
|
|||||||
@@ -1,28 +1,123 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { useLocation, Link } from "wouter";
|
import { useLocation, Link } from "wouter";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
import { useLanguage } from "../contexts/LanguageContext";
|
import { useLanguage } from "../contexts/LanguageContext";
|
||||||
import { useCart } from "../contexts/CartContext";
|
import { useCart } from "../contexts/CartContext";
|
||||||
|
import {
|
||||||
|
useDonateToRequest,
|
||||||
|
getListRequestsQueryKey,
|
||||||
|
getListPublishedRequestsQueryKey,
|
||||||
|
} from "@workspace/api-client-react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { ShoppingCart, Trash2, ChevronLeft, ChevronRight } from "lucide-react";
|
import { ShoppingCart, Trash2, ChevronLeft, ChevronRight, CheckCircle, Heart } from "lucide-react";
|
||||||
import { getNeedImage } from "../lib/needImages";
|
import { getNeedImage } from "../lib/needImages";
|
||||||
import { Riyal } from "@/components/Riyal";
|
import { Riyal } from "@/components/Riyal";
|
||||||
import { Reveal } from "../components/Reveal";
|
import { Reveal } from "../components/Reveal";
|
||||||
import leafPattern from "@assets/right-snapel-2_1780688632733.svg";
|
import leafPattern from "@assets/right-snapel-2_1780688632733.svg";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
donorName: z.string().min(2),
|
||||||
|
donorPhone: z.string().min(10),
|
||||||
|
donorEmail: z.string().email().optional().or(z.literal("")),
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormData = z.infer<typeof schema>;
|
||||||
|
|
||||||
export default function Cart() {
|
export default function Cart() {
|
||||||
const { t, dir } = useLanguage();
|
const { t, dir } = useLanguage();
|
||||||
const [, setLocation] = useLocation();
|
const [, setLocation] = useLocation();
|
||||||
const { items, removeItem, updateAmount, total } = useCart();
|
const queryClient = useQueryClient();
|
||||||
|
const { items, removeItem, updateAmount, total, clear } = useCart();
|
||||||
|
const donateMutation = useDonateToRequest();
|
||||||
|
|
||||||
|
const [step, setStep] = useState<"cart" | "payment">("cart");
|
||||||
|
const [amountError, setAmountError] = useState(false);
|
||||||
|
const [submitError, setSubmitError] = useState(false);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<FormData>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: { donorName: "", donorPhone: "", donorEmail: "" },
|
||||||
|
});
|
||||||
|
|
||||||
const Chevron = dir === "rtl" ? ChevronLeft : ChevronRight;
|
const Chevron = dir === "rtl" ? ChevronLeft : ChevronRight;
|
||||||
|
|
||||||
const proceed = () => {
|
const goToPayment = () => {
|
||||||
if (items.length === 0) return;
|
if (items.length === 0) return;
|
||||||
const first = items[0];
|
if (!items.every((i) => i.amount > 0)) {
|
||||||
const suffix = first.amount > 0 ? `?amount=${first.amount}` : "";
|
setAmountError(true);
|
||||||
setLocation(`/donate/${first.id}${suffix}`);
|
return;
|
||||||
|
}
|
||||||
|
setAmountError(false);
|
||||||
|
setStep("payment");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (data: FormData) => {
|
||||||
|
// Re-validate amounts at submit time in case the user edited a value
|
||||||
|
// back to zero after entering the payment step.
|
||||||
|
if (!items.every((i) => i.amount > 0)) {
|
||||||
|
setSubmitError(false);
|
||||||
|
setStep("cart");
|
||||||
|
setAmountError(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitError(false);
|
||||||
|
let failed = false;
|
||||||
|
try {
|
||||||
|
for (const item of items) {
|
||||||
|
await donateMutation.mutateAsync({
|
||||||
|
id: item.id,
|
||||||
|
data: {
|
||||||
|
donorName: data.donorName,
|
||||||
|
donorPhone: data.donorPhone,
|
||||||
|
donorEmail: data.donorEmail || null,
|
||||||
|
amount: item.amount,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
removeItem(item.id);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
failed = true;
|
||||||
|
setSubmitError(true);
|
||||||
|
} finally {
|
||||||
|
// Always refresh request data: even on partial failure, the donations
|
||||||
|
// that did succeed have already changed server state.
|
||||||
|
queryClient.invalidateQueries({ queryKey: getListRequestsQueryKey() });
|
||||||
|
queryClient.invalidateQueries({ queryKey: getListPublishedRequestsQueryKey() });
|
||||||
|
}
|
||||||
|
if (!failed) {
|
||||||
|
clear();
|
||||||
|
setDone(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Success screen after a completed multi-item donation
|
||||||
|
if (done) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-16 max-w-2xl">
|
||||||
|
<div className="bg-card rounded-2xl border-2 border-primary/20 text-center px-6 py-12">
|
||||||
|
<Heart className="w-16 h-16 text-primary mx-auto mb-4 fill-primary/10" />
|
||||||
|
<CheckCircle className="w-10 h-10 text-primary mx-auto mb-4" />
|
||||||
|
<h2 className="text-2xl font-bold text-primary mb-2">{t.common.success}</h2>
|
||||||
|
<p className="text-muted-foreground text-lg mb-8">{t.cart.successMessage}</p>
|
||||||
|
<div className="flex gap-3 justify-center">
|
||||||
|
<Button variant="outline" onClick={() => setLocation("/")} data-testid="button-home">
|
||||||
|
{t.common.home}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setLocation("/opportunities")} data-testid="button-opportunities">
|
||||||
|
{t.common.opportunities}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative overflow-hidden">
|
<div className="relative overflow-hidden">
|
||||||
{/* Decorative leaf background */}
|
{/* Decorative leaf background */}
|
||||||
@@ -124,7 +219,10 @@ export default function Cart() {
|
|||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
value={item.amount > 0 ? item.amount : ""}
|
value={item.amount > 0 ? item.amount : ""}
|
||||||
onChange={(e) => updateAmount(item.id, Number(e.target.value))}
|
onChange={(e) => {
|
||||||
|
updateAmount(item.id, Number(e.target.value));
|
||||||
|
setAmountError(false);
|
||||||
|
}}
|
||||||
placeholder={t.cart.amountLabel}
|
placeholder={t.cart.amountLabel}
|
||||||
className="ps-8"
|
className="ps-8"
|
||||||
data-testid={`input-amount-${item.id}`}
|
data-testid={`input-amount-${item.id}`}
|
||||||
@@ -137,22 +235,110 @@ export default function Cart() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary panel */}
|
{/* Summary / checkout panel */}
|
||||||
<div className="lg:sticky lg:top-24">
|
<div className="lg:sticky lg:top-24">
|
||||||
<div className="bg-card rounded-2xl border border-card-border shadow-sm p-6">
|
<div className="bg-card rounded-2xl border border-card-border shadow-sm p-6">
|
||||||
|
<div className="flex items-center justify-between text-sm text-muted-foreground mb-3">
|
||||||
|
<span>{t.cart.itemsCount}</span>
|
||||||
|
<span className="font-medium text-foreground" data-testid="text-cart-count">
|
||||||
|
{items.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div className="flex items-center justify-between pb-4 border-b border-border">
|
<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-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">
|
<span
|
||||||
|
className="text-xl font-bold text-primary inline-flex items-center gap-1"
|
||||||
|
data-testid="text-cart-total"
|
||||||
|
>
|
||||||
{total.toLocaleString()} <Riyal />
|
{total.toLocaleString()} <Riyal />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{step === "cart" ? (
|
||||||
|
<>
|
||||||
|
{amountError && (
|
||||||
|
<p className="text-sm text-destructive mt-4" data-testid="text-amount-error">
|
||||||
|
{t.cart.amountRequired}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
className="w-full mt-5 h-12 text-base"
|
className="w-full mt-5 h-12 text-base"
|
||||||
onClick={proceed}
|
onClick={goToPayment}
|
||||||
data-testid="button-proceed-to-payment"
|
data-testid="button-proceed-to-payment"
|
||||||
>
|
>
|
||||||
{t.cart.proceedToPayment}
|
{t.cart.proceedToPayment}
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 mt-5">
|
||||||
|
<h3 className="font-bold text-foreground">{t.donate.paymentTitle}</h3>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="donorName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t.donate.donorName}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input data-testid="input-donorName" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="donorPhone"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t.donate.donorPhone}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input data-testid="input-donorPhone" type="tel" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="donorEmail"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t.donate.donorEmail}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input data-testid="input-donorEmail" type="email" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{submitError && (
|
||||||
|
<p className="text-sm text-destructive" data-testid="text-submit-error">
|
||||||
|
{t.common.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-3 pt-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => setStep("cart")}
|
||||||
|
data-testid="button-back-to-cart"
|
||||||
|
>
|
||||||
|
{t.donate.backToDetails}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1"
|
||||||
|
disabled={donateMutation.isPending}
|
||||||
|
data-testid="button-confirm-donation"
|
||||||
|
>
|
||||||
|
{donateMutation.isPending ? t.common.loading : t.donate.confirmDonation}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 5.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
Reference in New Issue
Block a user