diff --git a/app/action/[userId]/page.tsx b/app/action/[userId]/page.tsx index 81d0794..489169b 100644 --- a/app/action/[userId]/page.tsx +++ b/app/action/[userId]/page.tsx @@ -164,25 +164,31 @@ function ContributeFlow({ contribution, onSuccess }: { contribution: MyContribut ); if (step === 'enter_amount') return ( -
+
setAmount(e.target.value)} className="h-8 text-xs w-36" autoFocus /> - - + value={amount} onChange={(e) => setAmount(e.target.value)} className="h-8 text-xs w-full sm:w-36 min-w-0" autoFocus /> +
+ + +
); if (step === 'enter_pin') return ( -
-
PIN
- setPin(e.target.value.replace(/\D/g, '').slice(0, 4))} - className="h-8 text-xs w-20 tracking-widest" autoFocus /> - - +
+
+
PIN
+ setPin(e.target.value.replace(/\D/g, '').slice(0, 4))} + className="h-8 text-xs w-full sm:w-20 min-w-0 tracking-widest" autoFocus /> +
+
+ + +
); @@ -322,7 +328,7 @@ function ContributionCard({ contribution: initial, currentUserId }: { contributi )} {c.isAdmin && (isActive || c.status === 'expired') && ( -
+
{isActive && (
{extendOpen && ( -
+
setNewDeadline(e.target.value)} className="h-8 text-xs w-44" /> - - + onChange={(e) => setNewDeadline(e.target.value)} className="h-8 text-xs w-full sm:w-44 min-w-0" /> +
+ + +
)} @@ -1414,10 +1422,10 @@ const ActionsByAccountPage = () => {
{/* Tab switcher — only show when not viewing another user */} {!isViewingAnotherUser && ( -
+
- +
+ + +
-
-
+
), }} - className="border border-gray-100 rounded-2xl" + className="rounded-2xl" /> @@ -445,6 +449,8 @@ const ActionPageLayout: React.FC = () => { okText="Transfer" confirmLoading={transferSubmitting} okButtonProps={{ className: 'bg-[#00B512] border-none hover:bg-[#009e10]' }} + width="min(480px, calc(100vw - 32px))" + style={{ top: 'auto' }} destroyOnClose >
diff --git a/components/RecentActions.tsx b/components/RecentActions.tsx index 8f4ee97..15ed88f 100644 --- a/components/RecentActions.tsx +++ b/components/RecentActions.tsx @@ -1,12 +1,20 @@ "use client" import React, { useEffect, useState } from 'react'; -import { Plus, Ticket, Target, ArrowRight } from 'lucide-react'; +import { Plus, Ticket, Target, ArrowRight, Users, BarChart3, ChevronDown } from 'lucide-react'; import { useAuthToken } from '@/hooks/use-auth-token'; import { useRouter } from 'next/navigation'; import axios from 'axios'; import baseUrl from '@/helpers/baseUrl'; import { getMyGroupContributions } from '@/helpers/api'; import { getCurrentUserInfo } from '@/utils/tokenUtils'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import CreateGroupModal from '@/components/chat/create-group-modal'; +import { toast } from '@/hooks/use-toast'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -109,14 +117,23 @@ function TicketRow({ action, onClick }: { action: RecentAction; onClick: () => v ); } -function ContributionRow({ c, userId, onClick }: { c: PendingContribution; userId?: string; onClick: () => void }) { +function ContributionRow({ c, onClick }: { c: any; onClick: () => void }) { const progress = c.goalAmount > 0 ? Math.min((c.collectedAmount / c.goalAmount) * 100, 100) : 0; + const isPaid = !!c.myPayment; + const isActive = c.status === 'active'; + const dueLabel = c.type === 'fixed' && c.amountPerMember ? fmtRwf(Number(c.amountPerMember), c.currency) : c.minimumAmount ? `min ${fmtRwf(Number(c.minimumAmount), c.currency)}` : 'Flexible'; + const statusBadge: Record = { + active: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400', + expired: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400', + closed: 'bg-gray-100 dark:bg-darkBg-interactive text-gray-500 dark:text-gray-400', + }; + return ( ); @@ -220,6 +244,7 @@ export const RecentActions = ({ userId, onCreateAction }: RecentActionsProps) => const [orgActions, setOrgActions] = useState([]); const [isOrganization, setIsOrganization] = useState(false); const [loading, setLoading] = useState(true); + const [isCreateGroupOpen, setIsCreateGroupOpen] = useState(false); const { getToken } = useAuthToken(); const router = useRouter(); @@ -278,16 +303,20 @@ export const RecentActions = ({ userId, onCreateAction }: RecentActionsProps) => setTickets(recent); } - // Contributions: show ALL active campaigns (paid and unpaid) — up to 2, - // unpaid first so they get attention, paid second + // Contributions: active ones first (unpaid before paid), capped at 2. + // Fall back to expired/closed only when there are no active ones at all. if (contribRes.status === 'fulfilled') { const all: any[] = contribRes.value?.data?.data || contribRes.value?.data || []; - const active = Array.isArray(all) ? all.filter((c: any) => c.status === 'active') : []; - const sorted = [ - ...active.filter((c: any) => !c.myPayment), // unpaid first - ...active.filter((c: any) => c.myPayment), // paid second - ].slice(0, 2); - setPending(sorted); + if (Array.isArray(all)) { + const active = [ + ...all.filter((c: any) => c.status === 'active' && !c.myPayment), + ...all.filter((c: any) => c.status === 'active' && c.myPayment), + ]; + const toShow = active.length > 0 + ? active.slice(0, 2) + : all.filter((c: any) => c.status !== 'active').slice(0, 2); + setPending(toShow); + } } } catch { // silently fail — card shows empty state @@ -313,16 +342,59 @@ export const RecentActions = ({ userId, onCreateAction }: RecentActionsProps) => {/* Header */}

Recent Actions

- {isOrganization && ( - - )} + + + + + + + {/* Group */} + setIsCreateGroupOpen(true)} + > +
+ +
+
+

Group

+

Create a new group

+
+
+ + {/* Contribution */} + router.push('/chat')} + > +
+ +
+
+

Contribution

+

View & start fundraisers

+
+
+ + {/* Vote */} + toast({ description: "Open any group chat to start a vote." })} + > +
+ +
+
+

Vote

+

Create a group poll

+
+
+
+
{/* Body */} @@ -365,18 +437,14 @@ export const RecentActions = ({ userId, onCreateAction }: RecentActionsProps) =>

- Pending Contributions + Contributions

- - {pendingContributions.length} -
- {pendingContributions.map((c) => ( + {pendingContributions.map((c: any) => ( goToContribs(c.groupId)} /> ))} @@ -397,6 +465,12 @@ export const RecentActions = ({ userId, onCreateAction }: RecentActionsProps) => )}
+ + setIsCreateGroupOpen(false)} + token={getToken()} + />
); }; diff --git a/components/chat/create-contribution-modal.tsx b/components/chat/create-contribution-modal.tsx index 2a2dde2..ae63b78 100644 --- a/components/chat/create-contribution-modal.tsx +++ b/components/chat/create-contribution-modal.tsx @@ -1,6 +1,6 @@ "use client" -import { useState, useEffect } from "react" +import { useState, useEffect, useMemo } from "react" import { Dialog, DialogContent, @@ -10,11 +10,12 @@ import { DialogDescription, } from "@/components/ui/dialog" import { Button } from "@/components/ui/button" -import { Target, Loader2, Users, Eye, EyeOff } from "lucide-react" +import { Target, Loader2, Users, Eye, EyeOff, Wallet } from "lucide-react" import type { Conversation } from "@/types/chat.types" import { toast } from "@/hooks/use-toast" -import { getCurrentUserId } from "@/utils/tokenUtils" +import { getCurrentUserId, getTokenFromStorage } from "@/utils/tokenUtils" import { useSendMessageMutation } from "@/states/chatSlice" +import { useGetGroupMembersQuery } from "@/states/groupSlice" import { createGroupContribution } from "@/helpers/api" import Input from "../ui/Input-ant" @@ -35,8 +36,22 @@ export default function CreateContributionModal({ creatorName = "Admin", }: Props) { const currentUserId = getCurrentUserId() + const token = useMemo(() => getTokenFromStorage(), []) const [sendMessageHttp] = useSendMessageMutation() + const groupId = conversation?.groupId ?? null + const { data: membersData } = useGetGroupMembersQuery( + { groupId: groupId!, token: token! }, + { skip: !groupId || !token || !isOpen } + ) + const adminMembers = useMemo( + () => + (membersData?.data?.members ?? []).filter( + (m: any) => m.status === "active" && ["owner", "admin"].includes(m.role) + ), + [membersData] + ) + const [title, setTitle] = useState("") const [note, setNote] = useState("") const [goalAmount, setGoalAmount] = useState("") @@ -45,6 +60,8 @@ export default function CreateContributionModal({ const [minimumAmount, setMinimumAmount] = useState("") const [deadline, setDeadline] = useState("") const [visibilityMode, setVisibilityMode] = useState("all") + const [disbursementPolicy, setDisbursementPolicy] = useState<"hold" | "auto">("hold") + const [disbursementRecipientId, setDisbursementRecipientId] = useState("") const [isSubmitting, setIsSubmitting] = useState(false) // Reset on open @@ -58,6 +75,8 @@ export default function CreateContributionModal({ setMinimumAmount("") setDeadline("") setVisibilityMode("all") + setDisbursementPolicy("hold") + setDisbursementRecipientId("") } }, [isOpen]) @@ -66,15 +85,15 @@ export default function CreateContributionModal({ toast({ variant: "destructive", description: "Campaign title is required" }) return false } - if (!goalAmount || Number(goalAmount) <= 0) { - toast({ variant: "destructive", description: "Enter a valid goal amount" }) - return false - } if (contributionType === "fixed" && (!amountPerMember || Number(amountPerMember) <= 0)) { toast({ variant: "destructive", description: "Enter the amount per member" }) return false } - if (Number(amountPerMember) > Number(goalAmount)) { + if (disbursementPolicy === "auto" && !disbursementRecipientId) { + toast({ variant: "destructive", description: "Select a recipient for auto-transfer" }) + return false + } + if (goalAmount && Number(amountPerMember) > Number(goalAmount)) { toast({ variant: "destructive", description: "Amount per member cannot exceed goal" }) return false } @@ -94,7 +113,7 @@ export default function CreateContributionModal({ const payload: Parameters[1] = { title: title.trim(), note: note.trim() || undefined, - goalAmount: Number(goalAmount), + ...(goalAmount ? { goalAmount: Number(goalAmount) } : {}), type: contributionType, visibilityMode, ...(contributionType === "fixed" && amountPerMember @@ -104,6 +123,8 @@ export default function CreateContributionModal({ ? { minimumAmount: Number(minimumAmount) } : {}), ...(deadline ? { deadline: new Date(deadline).toISOString() } : {}), + disbursementPolicy, + ...(disbursementPolicy === "auto" ? { disbursementRecipientId } : {}), } const response = await createGroupContribution(conversation.groupId, payload) @@ -113,13 +134,18 @@ export default function CreateContributionModal({ if (!contributionId) throw new Error("No contribution ID returned") // Step 2: Send as a chat message so the card appears in the thread + const recipientMember = adminMembers.find((m: any) => m.userId === disbursementRecipientId) + const disbursementRecipientName = recipientMember + ? `${recipientMember.userName ?? recipientMember.userEmail ?? ""}`.trim() + : undefined + const chatPayload = { type: "group_contribution", contributionId, groupId: conversation.groupId, title: title.trim(), note: note.trim() || "", - goalAmount: Number(goalAmount), + goalAmount: goalAmount ? Number(goalAmount) : undefined, collectedAmount: 0, contributorCount: 0, contributionType, @@ -127,6 +153,10 @@ export default function CreateContributionModal({ minimumAmount: contributionType === "flexible" && minimumAmount ? Number(minimumAmount) : undefined, deadline: deadline ? new Date(deadline).toISOString() : undefined, visibilityMode, + disbursementPolicy, + ...(disbursementPolicy === "auto" && disbursementRecipientName + ? { disbursementRecipientName } + : {}), status: "active", currency: "RWF", createdBy: currentUserId, @@ -204,7 +234,7 @@ export default function CreateContributionModal({ {/* Goal amount */}
@@ -369,6 +399,57 @@ export default function CreateContributionModal({
+ {/* Disbursement policy */} +
+ +
+ + +
+ {disbursementPolicy === "auto" && ( +
+ + +
+ )} +
+ {/* Members info */}
@@ -376,19 +457,19 @@ export default function CreateContributionModal({
- +
{/* progress */} -
-
- - {fmt(localCollected, data.currency)} - - - {fmt(goal, data.currency)} goal - + {goal > 0 ? ( +
+
+ + {fmt(localCollected, data.currency)} + + + {fmt(goal, data.currency)} goal + +
+ +
+ + {Math.round(progress)}% collected + + + + {localCount}{" "} + {localCount === 1 ? "contributor" : "contributors"} + +
- -
- - {Math.round(progress)}% collected + ) : ( +
+ + {fmt(localCollected, data.currency)} collected - + {localCount}{" "} {localCount === 1 ? "contributor" : "contributors"}
-
+ )} {/* details row */}
@@ -399,6 +414,12 @@ export function GroupContributionCard({ data, isMe, chatId }: Props) { {fmtDeadline(data.deadline)} )} + {data.disbursementPolicy === "auto" && data.disbursementRecipientName && ( + + + {data.disbursementRecipientName} on completion + + )}
{/* ── contribute flow ── */} diff --git a/components/chat/group-settings-modal.tsx b/components/chat/group-settings-modal.tsx index d23d0dd..6946ebb 100644 --- a/components/chat/group-settings-modal.tsx +++ b/components/chat/group-settings-modal.tsx @@ -10,8 +10,9 @@ import { Switch } from '@/components/ui/switch'; import { useGetGroupByIdQuery, useUpdateGroupMutation } from '@/states/groupSlice'; import { useAuthToken } from '@/hooks/use-auth-token'; import { toast } from '@/hooks/use-toast'; -import { Loader2, Settings, Users, Lock, Target, Calendar, Info } from 'lucide-react'; +import { Loader2, Settings, Lock, Target, Info, Wallet, ArrowDownLeft, ArrowUpRight, RefreshCw } from 'lucide-react'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { getGroupWallet } from '@/helpers/api'; interface GroupSettingsModalProps { isOpen: boolean; @@ -44,6 +45,22 @@ export default function GroupSettingsModal({ isOpen, onClose, groupId }: GroupSe const group = groupData?.data; + const [walletData, setWalletData] = useState<{ balance: number; currency: string; transactions: any[] } | null>(null); + const [walletLoading, setWalletLoading] = useState(false); + + const fetchWallet = async () => { + if (!groupId) return; + setWalletLoading(true); + try { + const res = await getGroupWallet(groupId); + setWalletData(res?.data?.data ?? null); + } catch { + toast({ variant: 'destructive', description: 'Failed to load wallet data' }); + } finally { + setWalletLoading(false); + } + }; + useEffect(() => { if (group) { setFormData({ @@ -142,18 +159,22 @@ export default function GroupSettingsModal({ isOpen, onClose, groupId }: GroupSe ) : (
- + - - General + + General - - Privacy + + Privacy - - Fundraising + + Fundraising + + + + Wallet @@ -306,6 +327,81 @@ export default function GroupSettingsModal({ isOpen, onClose, groupId }: GroupSe
)} + {/* Wallet */} + +
+

Group Wallet

+ +
+ + {walletLoading && !walletData ? ( +
+ +
+ ) : walletData ? ( + <> + {/* Balance card */} +
+

Available Balance

+

+ {new Intl.NumberFormat('en-RW', { style: 'currency', currency: walletData.currency || 'RWF', minimumFractionDigits: 0 }).format(walletData.balance)} +

+
+ + {/* Transaction list */} +
+

Recent Transactions

+ {walletData.transactions.length === 0 ? ( +

No transactions yet

+ ) : ( +
    + {walletData.transactions.map((tx: any) => { + const isIn = tx.direction === 'in'; + const senderName = tx.senderWallet?.user + ? `${tx.senderWallet.user.firstName} ${tx.senderWallet.user.lastName}` + : tx.senderWallet?.groupId ? 'Group wallet' : 'External'; + const receiverName = tx.receiverWallet?.user + ? `${tx.receiverWallet.user.firstName} ${tx.receiverWallet.user.lastName}` + : tx.receiverWallet?.groupId ? 'Group wallet' : 'External'; + return ( +
  • +
    +
    + {isIn + ? + : } +
    +
    +

    + {tx.description || (isIn ? `From ${senderName}` : `To ${receiverName}`)} +

    +

    + {new Date(tx.createdAt).toLocaleDateString('en-RW', { month: 'short', day: 'numeric', year: 'numeric' })} +

    +
    +
    + + {isIn ? '+' : '-'}{new Intl.NumberFormat('en-RW', { minimumFractionDigits: 0 }).format(Number(tx.amount))} RWF + +
  • + ); + })} +
+ )} +
+ + ) : ( +

Click Refresh to load wallet data

+ )} +
diff --git a/helpers/api.ts b/helpers/api.ts index e007ca8..335fd10 100644 --- a/helpers/api.ts +++ b/helpers/api.ts @@ -397,18 +397,23 @@ export const getSubActions = (actionId: string) => apiGet(`/actions/${actionId}/ export const getGroupById = (groupId: string) => axios.get(`${baseUrl}/groups/${groupId}`, { headers: getAuthHeaders() }); +export const getGroupWallet = (groupId: string) => + apiGet(`/groups/${groupId}/wallet`); + // Group contributions export const createGroupContribution = ( groupId: string, payload: { title: string; note?: string; - goalAmount: number; + goalAmount?: number; type: "fixed" | "flexible"; amountPerMember?: number; minimumAmount?: number; deadline?: string; visibilityMode: "all" | "admin_only"; + disbursementPolicy?: "hold" | "auto"; + disbursementRecipientId?: string; } ) => axios.post(`${baseUrl}/groups/${groupId}/contributions`, payload, { diff --git a/utils/tokenUtils.ts b/utils/tokenUtils.ts index abda417..57566fb 100644 --- a/utils/tokenUtils.ts +++ b/utils/tokenUtils.ts @@ -59,6 +59,7 @@ export const validateToken = (token: string | null, expires?: number): string | }; export const getTokenFromStorage = (): string | null => { + if (typeof window === 'undefined') return null; for (const storage of [sessionStorage, localStorage]) { try { const stored = storage.getItem(ACCESS_TOKEN_KEY);