diff --git a/.eslintrc.json b/.eslintrc.json index bb8037a..0e2ba45 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,6 +1,6 @@ { "parser": "@typescript-eslint/parser", - "extends": [], + "extends": ["next/core-web-vitals"], "rules": { "no-console": ["error", { "allow": ["error","warn"] }] }, diff --git a/app/action/[userId]/page.tsx b/app/action/[userId]/page.tsx index f768373..9e51dff 100644 --- a/app/action/[userId]/page.tsx +++ b/app/action/[userId]/page.tsx @@ -23,6 +23,7 @@ import { Lock, ArrowRight, Plus, + Globe2, } from 'lucide-react'; import { useParams, useRouter } from 'next/navigation'; import Navigation from '@/components/Navigation'; @@ -39,7 +40,9 @@ import { Button } from '@/components/ui/button'; import jsPDF from 'jspdf'; import ActionWizardModal from '@/components/ActionPage/ActionWizardModal'; import QRObjectValidator from '@/components/ActionPage/QRObjectValidator'; -import { createSubAction, updateSubAction, getMyGroupContributions, contributeToGroup, closeGroupContribution, extendGroupContributionDeadline } from '@/helpers/api'; +import { createSubAction, updateSubAction, getMyGroupContributions, contributeToGroup, closeGroupContribution, extendGroupContributionDeadline, getMyPublicContributions } from '@/helpers/api'; +import CreatePublicContributionModal from '@/components/contributions/CreatePublicContributionModal'; +import { PublicContributionCard, PublicContributionData } from '@/components/contributions/PublicContributionCard'; import socketService from '@/services/socketService'; import { getCurrentUserId } from '@/utils/tokenUtils'; import { formatDistanceToNow } from 'date-fns'; @@ -449,16 +452,22 @@ const ActionsByAccountPage = () => { const [voteStandingsMap, setVoteStandingsMap] = useState>({}); // Group contributions tab - const [individualTab, setIndividualTab] = useState<'actions' | 'contributions'>('actions'); + const [individualTab, setIndividualTab] = useState<'actions' | 'contributions' | 'campaigns'>('actions'); const [filterGroupId, setFilterGroupId] = useState(null); const [myContributions, setMyContributions] = useState([]); const [contributionsLoading, setContributionsLoading] = useState(false); const currentUserId = React.useMemo(() => getCurrentUserId(), []); + // Public campaigns tab + const [myPublicContributions, setMyPublicContributions] = useState([]); + const [publicContributionsLoading, setPublicContributionsLoading] = useState(false); + const [createCampaignOpen, setCreateCampaignOpen] = useState(false); + useEffect(() => { if (typeof window === 'undefined') return; const params = new URLSearchParams(window.location.search); if (params.get('tab') === 'contributions') setIndividualTab('contributions'); + if (params.get('tab') === 'campaigns') setIndividualTab('campaigns'); setFilterGroupId(params.get('group')); }, []); @@ -586,12 +595,30 @@ const ActionsByAccountPage = () => { } }, []); + const fetchMyPublicContributions = useCallback(async () => { + setPublicContributionsLoading(true); + try { + const res = await getMyPublicContributions(); + setMyPublicContributions(res?.data?.data || res?.data || []); + } catch { + // silently fail + } finally { + setPublicContributionsLoading(false); + } + }, []); + useEffect(() => { if (accountMode === 'individual' && individualTab === 'contributions') { fetchMyContributions(); } }, [accountMode, individualTab, fetchMyContributions]); + useEffect(() => { + if (accountMode === 'individual' && individualTab === 'campaigns') { + fetchMyPublicContributions(); + } + }, [accountMode, individualTab, fetchMyPublicContributions]); + useEffect(() => { if (!purchasedActions.length) return; const token = getToken(); @@ -1653,12 +1680,29 @@ const ActionsByAccountPage = () => { )} + )} - {individualTab === 'actions' || isViewingAnotherUser - ? renderPurchasedActions() - : contributionsLoading + {(individualTab === 'actions' || isViewingAnotherUser) && renderPurchasedActions()} + + {individualTab === 'contributions' && !isViewingAnotherUser && ( + contributionsLoading ? (
@@ -1701,7 +1745,69 @@ const ActionsByAccountPage = () => { )}
) - } + )} + + {individualTab === 'campaigns' && !isViewingAnotherUser && ( + publicContributionsLoading + ? ( +
+ +

Loading campaigns…

+
+ ) + : ( + <> +
+

+ {myPublicContributions.length} campaign{myPublicContributions.length !== 1 ? 's' : ''} +

+ +
+ {myPublicContributions.length === 0 + ? ( +
+
+
+ +
+
+

No campaigns yet

+

+ Create a public contribution campaign and share it with anyone via link or QR code. +

+ +
+ ) + : ( +
+ {myPublicContributions.map((c) => ( + + setMyPublicContributions((prev) => + prev.map((item) => item.id === c.id ? { ...item, ...patch } : item) + ) + } + /> + ))} +
+ )} + + ) + )} ); }; @@ -1948,6 +2054,12 @@ const ActionsByAccountPage = () => { organizationId={tokenUserId} /> )} + + setCreateCampaignOpen(false)} + onCreated={fetchMyPublicContributions} + /> ); }; diff --git a/app/contribute/[contributionId]/page.tsx b/app/contribute/[contributionId]/page.tsx new file mode 100644 index 0000000..6768c7d --- /dev/null +++ b/app/contribute/[contributionId]/page.tsx @@ -0,0 +1,9 @@ +import PublicContributionPage from "@/components/contributions/PublicContributionPage"; + +interface Props { + params: { contributionId: string }; +} + +export default function ContributePage({ params }: Props) { + return ; +} diff --git a/components/contributions/CreatePublicContributionModal.tsx b/components/contributions/CreatePublicContributionModal.tsx new file mode 100644 index 0000000..69c1aa2 --- /dev/null +++ b/components/contributions/CreatePublicContributionModal.tsx @@ -0,0 +1,497 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { + Target, + Loader2, + Eye, + EyeOff, + Copy, + Check, + Share2, + QrCode, +} from "lucide-react"; +import { toast } from "@/hooks/use-toast"; +import { createPublicContribution } from "@/helpers/api"; +import Input from "../ui/Input-ant"; + +interface Props { + isOpen: boolean; + onClose: () => void; + onCreated?: () => void; +} + +type Step = "form" | "share"; +type ContributionType = "fixed" | "flexible"; +type VisibilityMode = "all" | "creator_only"; + +export default function CreatePublicContributionModal({ isOpen, onClose, onCreated }: Props) { + const [step, setStep] = useState("form"); + + // form state + const [title, setTitle] = useState(""); + const [note, setNote] = useState(""); + const [goalAmount, setGoalAmount] = useState(""); + const [contributionType, setContributionType] = useState("fixed"); + const [amountPerMember, setAmountPerMember] = useState(""); + const [minimumAmount, setMinimumAmount] = useState(""); + const [deadline, setDeadline] = useState(""); + const [visibilityMode, setVisibilityMode] = useState("all"); + const [disbursementPolicy, setDisbursementPolicy] = useState<"hold" | "auto">("hold"); + const [isSubmitting, setIsSubmitting] = useState(false); + + // share state + const [shareLink, setShareLink] = useState(""); + const [copied, setCopied] = useState(false); + const [showQR, setShowQR] = useState(false); + + useEffect(() => { + if (isOpen) { + setStep("form"); + setTitle(""); + setNote(""); + setGoalAmount(""); + setContributionType("fixed"); + setAmountPerMember(""); + setMinimumAmount(""); + setDeadline(""); + setVisibilityMode("all"); + setDisbursementPolicy("hold"); + setShareLink(""); + setCopied(false); + setShowQR(false); + } + }, [isOpen]); + + const validate = () => { + if (!title.trim()) { + toast({ variant: "destructive", description: "Campaign title is required" }); + return false; + } + if (contributionType === "fixed" && (!amountPerMember || Number(amountPerMember) <= 0)) { + toast({ variant: "destructive", description: "Enter the amount per person" }); + return false; + } + return true; + }; + + const handleSubmit = async () => { + if (!validate()) return; + setIsSubmitting(true); + try { + const payload = { + title: title.trim(), + note: note.trim() || undefined, + ...(goalAmount ? { goalAmount: Number(goalAmount) } : {}), + type: contributionType, + visibilityMode, + disbursementPolicy, + ...(contributionType === "fixed" && amountPerMember + ? { amountPerMember: Number(amountPerMember) } + : {}), + ...(contributionType === "flexible" && minimumAmount + ? { minimumAmount: Number(minimumAmount) } + : {}), + ...(deadline ? { deadline: new Date(deadline).toISOString() } : {}), + }; + + const res = await createPublicContribution(payload); + const contribution = res?.data?.data; + if (!contribution?.id) throw new Error("No contribution ID returned"); + + const link = `${window.location.origin}/contribute/${contribution.id}`; + setShareLink(link); + setStep("share"); + toast({ description: `Campaign "${title}" created!` }); + onCreated?.(); + } catch (error: any) { + toast({ + variant: "destructive", + description: + error?.response?.data?.message || "Failed to create campaign. Try again.", + }); + } finally { + setIsSubmitting(false); + } + }; + + const copyLink = () => { + navigator.clipboard.writeText(shareLink); + setCopied(true); + toast({ description: "Link copied to clipboard" }); + setTimeout(() => setCopied(false), 2000); + }; + + const handleNativeShare = async () => { + if (navigator.share) { + try { + await navigator.share({ title, text: `Contribute to "${title}"`, url: shareLink }); + } catch { + // user cancelled + } + } else { + copyLink(); + } + }; + + const qrApiUrl = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(shareLink)}`; + + return ( + !open && !isSubmitting && onClose()}> + + +
+
+ +
+ + {step === "form" ? "Create Contribution Campaign" : "Share Your Campaign"} + +
+ + {step === "form" + ? "Create a campaign and share the link with anyone — no group needed." + : "Send the link or QR code to anyone you want to contribute."} + +
+ + {/* ─── FORM STEP ─── */} + {step === "form" && ( +
+ {/* Title */} +
+ + setTitle(e.target.value)} + disabled={isSubmitting} + className="dark:bg-darkBg-interactive dark:border-darkBorder-light dark:text-white" + /> +
+ + {/* Note */} +
+ + setNote(e.target.value)} + disabled={isSubmitting} + className="dark:bg-darkBg-interactive dark:border-darkBorder-light dark:text-white" + /> +
+ + {/* Goal amount */} +
+ +
+
+ RWF +
+ setGoalAmount(e.target.value)} + disabled={isSubmitting} + /> +
+
+ + {/* Contribution type */} +
+ +
+ + +
+
+ + {contributionType === "fixed" && ( +
+ +
+
+ RWF +
+ setAmountPerMember(e.target.value)} + disabled={isSubmitting} + /> +
+
+ )} + + {contributionType === "flexible" && ( +
+ +
+
+ RWF +
+ setMinimumAmount(e.target.value)} + disabled={isSubmitting} + /> +
+
+ )} + + {/* Deadline */} +
+ + setDeadline(e.target.value)} + disabled={isSubmitting} + className="dark:bg-darkBg-interactive dark:border-darkBorder-light dark:text-white" + /> +
+ + {/* Visibility toggle */} + + + {/* Disbursement policy */} +
+ +
+ + +
+

+ {disbursementPolicy === "hold" + ? "Funds stay in the campaign wallet until you withdraw them." + : "Funds transfer to your wallet automatically when the goal is reached or the campaign is closed."} +

+
+
+ )} + + {/* ─── SHARE STEP ─── */} + {step === "share" && ( +
+
+

+ Shareable Link +

+
+
+ {shareLink} +
+ +
+ +
+ + {/* QR code toggle */} + + + {showQR && ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + QR code for contribution link +
+

Scan to contribute

+
+ )} +
+ )} + + + {step === "form" ? ( + <> + + + + ) : ( + + )} + +
+
+ ); +} diff --git a/components/contributions/PublicContributionCard.tsx b/components/contributions/PublicContributionCard.tsx new file mode 100644 index 0000000..ba73965 --- /dev/null +++ b/components/contributions/PublicContributionCard.tsx @@ -0,0 +1,733 @@ +"use client"; + +import React from "react"; +import { + Users, + CheckCircle, + Clock, + Ban, + Lock, + ArrowRight, + CalendarClock, + Target, + ShieldAlert, + CalendarPlus, + Wallet, + Share2, + Copy, + Check, + QrCode, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Progress } from "@/components/ui/progress"; +import { + contributeToPublic, + closePublicContribution, + extendPublicContributionDeadline, + withdrawPublicContribution, +} from "@/helpers/api"; +import { toast } from "@/hooks/use-toast"; +import socketService from "@/services/socketService"; +import { useRouter } from "next/navigation"; + +// ─── types ──────────────────────────────────────────────────────────────────── + +export interface PublicContributionData { + id: string; + createdBy: string; + title: string; + note?: string | null; + goalAmount?: number | null; + collectedAmount: number; + contributorCount: number; + type: "fixed" | "flexible"; + amountPerMember?: number | null; + minimumAmount?: number | null; + deadline?: string | null; + visibilityMode: "all" | "creator_only"; + disbursementPolicy: "hold" | "auto"; + status: "active" | "completed" | "closed" | "expired"; + currency: string; + isCreator?: boolean; + myPayment?: { amount: number } | null; + payments?: Array<{ payerId: string; amount: number; payer?: { firstName: string; lastName: string } }>; + creator?: { id: string; firstName: string; lastName: string }; +} + +interface Props { + data: PublicContributionData; + onUpdated?: (patch: Partial) => void; + isAuthenticated?: boolean; +} + +// ─── helpers ───────────────────────────────────────────────────────────────── + +const fmt = (n: number, cur = "RWF") => + new Intl.NumberFormat("en-RW", { + style: "currency", + currency: cur, + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(n); + +const fmtDeadline = (date: string) => + new Intl.DateTimeFormat("en-RW", { + month: "short", + day: "numeric", + year: "numeric", + }).format(new Date(date)); + +// ─── component ─────────────────────────────────────────────────────────────── + +export function PublicContributionCard({ data, onUpdated, isAuthenticated = true }: Props) { + const [localStatus, setLocalStatus] = React.useState(data.status); + const [localCollected, setLocalCollected] = React.useState(Number(data.collectedAmount)); + const [localCount, setLocalCount] = React.useState(data.contributorCount); + const [hasPaid, setHasPaid] = React.useState(!!data.myPayment); + + type Step = + | "idle" + | "enter_amount" + | "enter_pin" + | "loading" + | "confirm_close" + | "extend_date" + | "confirm_withdraw"; + const [step, setStep] = React.useState("idle"); + const [customAmount, setCustomAmount] = React.useState(""); + const [pin, setPin] = React.useState(""); + const [newDeadline, setNewDeadline] = React.useState(""); + const [showShare, setShowShare] = React.useState(false); + const [copied, setCopied] = React.useState(false); + const [showQR, setShowQR] = React.useState(false); + const [showContributors, setShowContributors] = React.useState(false); + + const router = useRouter(); + + // ── real-time socket updates ─────────────────────────────────────────────── + React.useEffect(() => { + const handleUpdated = (ev: any) => { + if (ev.contributionId !== data.id) return; + setLocalCollected(Number(ev.collectedAmount)); + setLocalCount(ev.contributorCount); + setLocalStatus(ev.status); + onUpdated?.({ collectedAmount: Number(ev.collectedAmount), contributorCount: ev.contributorCount, status: ev.status }); + }; + const handleCompleted = (ev: any) => { + if (ev.contributionId !== data.id) return; + setLocalCollected(Number(ev.collectedAmount)); + setLocalStatus("completed"); + onUpdated?.({ collectedAmount: Number(ev.collectedAmount), status: "completed" }); + }; + const handleClosed = (ev: any) => { + if (ev.contributionId !== data.id) return; + setLocalStatus(ev.status || "closed"); + onUpdated?.({ status: ev.status || "closed" }); + }; + const handleDisbursed = (ev: any) => { + if (ev.contributionId !== data.id) return; + setLocalCollected(0); + onUpdated?.({ collectedAmount: 0 }); + }; + + socketService.onPublicContributionUpdated(handleUpdated); + socketService.onPublicContributionCompleted(handleCompleted); + socketService.onPublicContributionClosed(handleClosed); + socketService.onPublicContributionDisbursed(handleDisbursed); + return () => { + socketService.offPublicContributionUpdated(handleUpdated); + socketService.offPublicContributionCompleted(handleCompleted); + socketService.offPublicContributionClosed(handleClosed); + socketService.offPublicContributionDisbursed(handleDisbursed); + }; + }, [data.id, onUpdated]); + + const shareLink = typeof window !== "undefined" + ? `${window.location.origin}/contribute/${data.id}` + : `/contribute/${data.id}`; + + const copyLink = () => { + navigator.clipboard.writeText(shareLink); + setCopied(true); + toast({ description: "Link copied!" }); + setTimeout(() => setCopied(false), 2000); + }; + + const handleNativeShare = async () => { + if (navigator.share) { + try { + await navigator.share({ title: data.title, text: `Contribute to "${data.title}"`, url: shareLink }); + } catch { + // user cancelled + } + } else { + copyLink(); + } + }; + + const goal = Number(data.goalAmount); + const progress = goal > 0 ? Math.min((localCollected / goal) * 100, 100) : 0; + const isActive = localStatus === "active"; + const isCompleted = localStatus === "completed"; + const isClosed = localStatus === "closed" || localStatus === "expired"; + const isDeadlinePast = data.deadline && new Date(data.deadline) < new Date(); + const canContribute = isActive && !hasPaid; + + // ── status pill ─────────────────────────────────────────────────────────── + const StatusPill = () => { + if (isCompleted) + return ( + + {goal > 0 ? "Goal Reached" : "Completed"} + + ); + if (isClosed) + return ( + + + {localStatus === "expired" ? "Expired" : "Closed"} + + ); + return ( + + Active + + ); + }; + + const getAmount = () => + data.type === "fixed" ? Number(data.amountPerMember) : Number(customAmount); + + const handleProceed = () => { + if (data.type === "flexible") { + const amt = Number(customAmount); + if (!amt || amt <= 0) { + toast({ variant: "destructive", description: "Enter a valid amount" }); + return; + } + if (data.minimumAmount && amt < Number(data.minimumAmount)) { + toast({ + variant: "destructive", + description: `Minimum contribution is ${fmt(Number(data.minimumAmount), data.currency)}`, + }); + return; + } + } + setStep("enter_pin"); + }; + + const handleContribute = async () => { + if (!pin || pin.length !== 4 || !/^\d{4}$/.test(pin)) { + toast({ variant: "destructive", description: "Enter your 4-digit PIN" }); + return; + } + setStep("loading"); + try { + const amount = getAmount(); + const res = await contributeToPublic(data.id, amount, pin); + const updated = res?.data?.data?.contribution; + toast({ description: "Contribution successful!" }); + setHasPaid(true); + if (updated) { + setLocalCollected(Number(updated.collectedAmount)); + setLocalStatus(updated.status); + onUpdated?.({ collectedAmount: Number(updated.collectedAmount), status: updated.status }); + } + setStep("idle"); + setPin(""); + setCustomAmount(""); + } catch (err: any) { + const msg = err?.response?.data?.message || "Contribution failed. Try again."; + toast({ variant: "destructive", description: msg }); + setStep(data.type === "flexible" ? "enter_amount" : "enter_pin"); + } + }; + + const handleClose = async () => { + setStep("loading"); + try { + await closePublicContribution(data.id); + toast({ description: "Campaign closed." }); + setLocalStatus("closed"); + onUpdated?.({ status: "closed" }); + setStep("idle"); + } catch (err: any) { + toast({ variant: "destructive", description: err?.response?.data?.message || "Failed to close campaign" }); + setStep("idle"); + } + }; + + const handleExtend = async () => { + if (!newDeadline) { + toast({ variant: "destructive", description: "Pick a new deadline date" }); + return; + } + setStep("loading"); + try { + await extendPublicContributionDeadline(data.id, new Date(newDeadline).toISOString()); + toast({ description: "Deadline extended." }); + if (localStatus === "expired") setLocalStatus("active"); + onUpdated?.({ deadline: newDeadline, status: localStatus === "expired" ? "active" : localStatus }); + setStep("idle"); + setNewDeadline(""); + } catch (err: any) { + toast({ variant: "destructive", description: err?.response?.data?.message || "Failed to extend deadline" }); + setStep("extend_date"); + } + }; + + const handleWithdraw = async () => { + setStep("loading"); + try { + const res = await withdrawPublicContribution(data.id); + const withdrawn = res?.data?.data?.withdrawn; + toast({ description: `${fmt(withdrawn, data.currency)} withdrawn to your wallet!` }); + setStep("idle"); + } catch (err: any) { + toast({ variant: "destructive", description: err?.response?.data?.message || "Withdrawal failed" }); + setStep("idle"); + } + }; + + const handleCancel = () => { + setStep("idle"); + setPin(""); + setCustomAmount(""); + }; + + return ( +
+ {/* accent strip */} +
+ +
+ {/* header */} +
+
+
+ +
+
+

+ Contribution Campaign +

+ {data.creator && ( +

+ by {data.creator.firstName} {data.creator.lastName} +

+ )} +
+
+ +
+ + {/* title + note */} +
+

+ {data.title} +

+ {data.note && ( +

+ {data.note} +

+ )} +
+ + {/* progress */} + {goal > 0 ? ( +
+
+ + {fmt(localCollected, data.currency)} + + + {fmt(goal, data.currency)} goal + +
+ +
+ + {Math.round(progress)}% collected + + + + {localCount} {localCount === 1 ? "contributor" : "contributors"} + +
+
+ ) : ( +
+ + {fmt(localCollected, data.currency)} collected + + + + {localCount} {localCount === 1 ? "contributor" : "contributors"} + +
+ )} + + {/* details */} +
+ {data.type === "fixed" && data.amountPerMember ? ( + + Fixed:{" "} + + {fmt(Number(data.amountPerMember), data.currency)} / person + + + ) : ( + + Flexible + {data.minimumAmount + ? ` (min ${fmt(Number(data.minimumAmount), data.currency)})` + : ""} + + )} + {data.deadline && ( + + + {isDeadlinePast ? "Deadline passed " : "Due "} + {fmtDeadline(data.deadline)} + + )} +
+ + {/* ── contribute flow ── */} + {step === "idle" && ( + <> + {hasPaid && ( +
+ + You have contributed +
+ )} + {!isAuthenticated && isActive && ( + + )} + {isAuthenticated && canContribute && ( + + )} + + )} + + {step === "enter_amount" && ( +
+

+ Enter amount{" "} + {data.minimumAmount + ? `(min ${fmt(Number(data.minimumAmount), data.currency)})` + : ""} +

+ setCustomAmount(e.target.value)} + className="h-9 text-sm" + autoFocus + /> +
+ + +
+
+ )} + + {step === "enter_pin" && ( +
+

+ + Confirm with your PIN + {data.type === "flexible" && customAmount + ? ` — ${fmt(Number(customAmount), data.currency)}` + : data.type === "fixed" && data.amountPerMember + ? ` — ${fmt(Number(data.amountPerMember), data.currency)}` + : ""} +

+ setPin(e.target.value.replace(/\D/g, "").slice(0, 4))} + className="h-9 text-sm tracking-widest" + autoFocus + /> +
+ + +
+
+ )} + + {step === "loading" && ( +
+
+ Processing… +
+ )} + + {/* ── creator controls ── */} + {data.isCreator && (isActive || localStatus === "expired") && step === "idle" && ( +
+

+ Campaign Controls +

+
+ {isActive && ( + + )} + +
+ {/* Withdraw button (hold policy only, some funds collected) */} + {data.disbursementPolicy === "hold" && localCollected > 0 && ( + + )} + + {/* Share row */} + + + {showShare && ( +
+
+ {shareLink} + +
+
+ + +
+ {showQR && ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + QR Code +
+ )} +
+ )} +
+ )} + + {/* confirm close */} + {step === "confirm_close" && ( +
+

+ Close this campaign? People won't be able to contribute after this. +

+
+ + +
+
+ )} + + {/* extend deadline */} + {step === "extend_date" && ( +
+

New deadline date:

+ setNewDeadline(e.target.value)} + className="h-8 text-sm" + autoFocus + /> +
+ + +
+
+ )} + + {/* confirm withdraw */} + {step === "confirm_withdraw" && ( +
+

+ Withdraw {fmt(localCollected, data.currency)} to your personal wallet? +

+
+ + +
+
+ )} + + {/* ── contributor list ── */} + {data.payments && data.payments.length > 0 && data.isCreator && step === "idle" && ( +
+ + {showContributors && ( +
+ {data.payments.map((p, i) => ( +
+ + {p.payer ? `${p.payer.firstName} ${p.payer.lastName}` : "Anonymous"} + + + {fmt(Number(p.amount), data.currency)} + +
+ ))} +
+ )} +
+ )} +
+
+ ); +} diff --git a/components/contributions/PublicContributionPage.tsx b/components/contributions/PublicContributionPage.tsx new file mode 100644 index 0000000..4c88d97 --- /dev/null +++ b/components/contributions/PublicContributionPage.tsx @@ -0,0 +1,97 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { getPublicContribution } from "@/helpers/api"; +import { getValidToken } from "@/utils/tokenUtils"; +import { PublicContributionCard, PublicContributionData } from "./PublicContributionCard"; +import { Loader2, AlertCircle, LogIn } from "lucide-react"; + +interface Props { + contributionId: string; +} + +export default function PublicContributionPage({ contributionId }: Props) { + const router = useRouter(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const isAuthenticated = typeof window !== "undefined" ? !!getValidToken() : false; + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await getPublicContribution(contributionId); + if (!cancelled) setData(res?.data?.data ?? null); + } catch (err: any) { + if (cancelled) return; + const status = err?.response?.status; + if (status === 401 || status === 403) { + // Shouldn't happen now that the endpoint is public, but guard anyway + router.push(`/auth/login?returnUrl=${encodeURIComponent(`/contribute/${contributionId}`)}`); + } else { + setError(err?.response?.data?.message || "Could not load this contribution campaign."); + } + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { cancelled = true; }; + }, [contributionId, router]); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error || !data) { + return ( +
+ +

+ {error ?? "This contribution campaign could not be found."} +

+
+ ); + } + + return ( +
+ {/* branding strip */} +
+

que

+

Contribution Campaign

+
+ + {/* login nudge for unauthenticated visitors */} + {!isAuthenticated && ( +
+

+ Log in to contribute to this campaign. +

+ +
+ )} + + setData((prev) => (prev ? { ...prev, ...patch } : prev))} + /> + +

+ Payments are secured by the Que platform. +

+
+ ); +} diff --git a/components/notifications/NotificationCenter.tsx b/components/notifications/NotificationCenter.tsx index 1a58287..c2c24ae 100644 --- a/components/notifications/NotificationCenter.tsx +++ b/components/notifications/NotificationCenter.tsx @@ -169,6 +169,18 @@ const NotificationCenter: React.FC = ({ isOpen, onClose case 'CHAT_USER_ADDED': case 'chat_user_added': return + + // Public contribution notifications + case 'PUBLIC_CONTRIBUTION_RECEIVED': + case 'public_contribution_received': + return + case 'PUBLIC_CONTRIBUTION_COMPLETED': + case 'public_contribution_completed': + return + case 'PUBLIC_CONTRIBUTION_CLOSED': + case 'public_contribution_closed': + return + default: return } diff --git a/helpers/api.ts b/helpers/api.ts index 335fd10..beb3498 100644 --- a/helpers/api.ts +++ b/helpers/api.ts @@ -436,3 +436,51 @@ export const closeGroupContribution = (groupId: string, contributionId: string) export const extendGroupContributionDeadline = (groupId: string, contributionId: string, deadline: string) => axios.patch(`${baseUrl}/groups/${groupId}/contributions/${contributionId}/extend`, { deadline }, { headers: getAuthHeaders() }); + +// Public (standalone) contributions +export const createPublicContribution = (payload: { + title: string; + note?: string; + goalAmount?: number; + type: "fixed" | "flexible"; + amountPerMember?: number; + minimumAmount?: number; + deadline?: string; + visibilityMode?: "all" | "creator_only"; + disbursementPolicy?: "hold" | "auto"; +}) => + apiPost("/public-contributions", payload); + +export const getPublicContribution = (contributionId: string) => + apiGet(`/public-contributions/${contributionId}`); + +export const getMyPublicContributions = () => + apiGet("/public-contributions/"); + +export const contributeToPublic = (contributionId: string, amount: number, pin: string) => + axios.post( + `${baseUrl}/public-contributions/${contributionId}/pay`, + { amount, pin }, + { headers: getAuthHeaders() } + ); + +export const closePublicContribution = (contributionId: string) => + axios.patch( + `${baseUrl}/public-contributions/${contributionId}/close`, + {}, + { headers: getAuthHeaders() } + ); + +export const extendPublicContributionDeadline = (contributionId: string, deadline: string) => + axios.patch( + `${baseUrl}/public-contributions/${contributionId}/extend`, + { deadline }, + { headers: getAuthHeaders() } + ); + +export const withdrawPublicContribution = (contributionId: string) => + axios.post( + `${baseUrl}/public-contributions/${contributionId}/withdraw`, + {}, + { headers: getAuthHeaders() } + ); diff --git a/services/socketService.ts b/services/socketService.ts index e588dc7..a475d6c 100644 --- a/services/socketService.ts +++ b/services/socketService.ts @@ -699,6 +699,67 @@ class SocketService { } } + // ── Public contribution real-time events ────────────────────────────────── + onPublicContributionUpdated(callback: (data: { + contributionId: string; + collectedAmount: number; + contributorCount: number; + status: string; + payerId: string; + payerName: string; + amount: number; + }) => void) { + if (this.socket) this.socket.on("public_contribution_updated", callback) + } + + offPublicContributionUpdated(callback?: (data: any) => void) { + if (this.socket) { + callback ? this.socket.off("public_contribution_updated", callback) : this.socket.off("public_contribution_updated") + } + } + + onPublicContributionCompleted(callback: (data: { + contributionId: string; + title: string; + goalAmount: number; + collectedAmount: number; + }) => void) { + if (this.socket) this.socket.on("public_contribution_completed", callback) + } + + offPublicContributionCompleted(callback?: (data: any) => void) { + if (this.socket) { + callback ? this.socket.off("public_contribution_completed", callback) : this.socket.off("public_contribution_completed") + } + } + + onPublicContributionClosed(callback: (data: { + contributionId: string; + status: string; + }) => void) { + if (this.socket) this.socket.on("public_contribution_closed", callback) + } + + offPublicContributionClosed(callback?: (data: any) => void) { + if (this.socket) { + callback ? this.socket.off("public_contribution_closed", callback) : this.socket.off("public_contribution_closed") + } + } + + onPublicContributionDisbursed(callback: (data: { + contributionId: string; + amount: number; + creatorId: string; + }) => void) { + if (this.socket) this.socket.on("public_contribution_disbursed", callback) + } + + offPublicContributionDisbursed(callback?: (data: any) => void) { + if (this.socket) { + callback ? this.socket.off("public_contribution_disbursed", callback) : this.socket.off("public_contribution_disbursed") + } + } + getSocket() { return this.socket }