diff --git a/app/action/[userId]/page.tsx b/app/action/[userId]/page.tsx index 489169b..f768373 100644 --- a/app/action/[userId]/page.tsx +++ b/app/action/[userId]/page.tsx @@ -22,6 +22,7 @@ import { ChevronRight, Lock, ArrowRight, + Plus, } from 'lucide-react'; import { useParams, useRouter } from 'next/navigation'; import Navigation from '@/components/Navigation'; @@ -128,6 +129,12 @@ interface MyContribution { const fmtRwf = (n: number, cur = 'RWF') => new Intl.NumberFormat('en-RW', { style: 'currency', currency: cur, minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(n); +const toOrdinal = (n: number) => { + const s = ['th', 'st', 'nd', 'rd']; + const v = n % 100; + return n + (s[(v - 20) % 10] || s[v] || s[0]); +}; + function ContributeFlow({ contribution, onSuccess }: { contribution: MyContribution; onSuccess: (amount: number) => void }) { type Step = 'idle' | 'enter_amount' | 'enter_pin' | 'loading'; const [step, setStep] = useState('idle'); @@ -423,6 +430,7 @@ const ActionsByAccountPage = () => { const [isSubActionsModalOpen, setIsSubActionsModalOpen] = useState(false); const [wizardOpen, setWizardOpen] = useState(false); const [editingActionId, setEditingActionId] = useState(null); + const [preSelectedType, setPreSelectedType] = useState(null); const [creatingSubAction, setCreatingSubAction] = useState(false); const [qrValidatorOpen, setQrValidatorOpen] = useState(false); const [subActionError, setSubActionError] = useState(null); @@ -437,6 +445,8 @@ const ActionsByAccountPage = () => { const [purchasedActionsFilter, setPurchasedActionsFilter] = useState<'all' | 'archive'>('all'); const [editingSubActionId, setEditingSubActionId] = useState(null); const [markingAsUsed, setMarkingAsUsed] = useState>({}); + const [resolvedTypeMap, setResolvedTypeMap] = useState>({}); + const [voteStandingsMap, setVoteStandingsMap] = useState>({}); // Group contributions tab const [individualTab, setIndividualTab] = useState<'actions' | 'contributions'>('actions'); @@ -582,6 +592,37 @@ const ActionsByAccountPage = () => { } }, [accountMode, individualTab, fetchMyContributions]); + useEffect(() => { + if (!purchasedActions.length) return; + const token = getToken(); + if (!token) return; + const headers = { Authorization: `Bearer ${token}` }; + const uniqueIds = Array.from( + new Set( + purchasedActions.map(i => i.metadata?.actionId || i.actionId).filter(Boolean) as string[] + ) + ); + Promise.allSettled( + uniqueIds.map(async (actionId) => { + const res = await axios.get(`${baseUrl}/actions/${actionId}`, { headers }).catch(() => null); + const action = res?.data?.data || res?.data; + if (!action) return; + setResolvedTypeMap(prev => ({ ...prev, [actionId]: action.type })); + if (action.type === 'vote') { + const subRes = await axios.get(`${baseUrl}/actions/${actionId}/sub-actions`, { headers }).catch(() => null); + const subs: any[] = subRes?.data?.data || subRes?.data || []; + if (Array.isArray(subs)) { + const standings = subs + .filter((s: any) => s.isActive !== false) + .sort((a: any, b: any) => Number(b.metadata?.votes ?? 0) - Number(a.metadata?.votes ?? 0)) + .map((s: any, idx: number) => ({ id: s.id, name: s.name, votes: Number(s.metadata?.votes ?? 0), rank: idx + 1 })); + setVoteStandingsMap(prev => ({ ...prev, [actionId]: standings })); + } + } + }) + ); + }, [purchasedActions, getToken]); + const pageTitle = useMemo(() => { if (isLoggedInAsOrganization && isViewingAnotherUser && accountMode === 'individual') { return 'User QR Objects'; @@ -826,6 +867,7 @@ const ActionsByAccountPage = () => { const handleWizardCompleted = () => { setWizardOpen(false); setEditingActionId(null); + setPreSelectedType(null); if (effectiveUserId) { fetchData(effectiveUserId); } @@ -834,6 +876,7 @@ const ActionsByAccountPage = () => { const handleWizardClose = () => { setWizardOpen(false); setEditingActionId(null); + setPreSelectedType(null); }; // Handler for organizations to mark a QR object as used @@ -1034,120 +1077,223 @@ const ActionsByAccountPage = () => {

) : ( -
- {filteredPurchasedActions.map((item) => ( -
-
-
-

- {item.type || 'QR Object'} -

-

- {item.metadata?.actionName || 'Unnamed Action'} -

- {item.metadata?.subActionName && ( -

- {item.metadata.subActionName} -

- )} -
- - {isQRObjectExpired(item) ? 'Expired' : item.status || 'unknown'} +
+ {filteredPurchasedActions.map((item) => { + const actionId = item.metadata?.actionId || item.actionId || ''; + const orgId = item.metadata?.organizationId || item.organizationId || ''; + const resolvedType = actionId ? (resolvedTypeMap[actionId] ?? item.type ?? 'ticket') : (item.type ?? 'ticket'); + const isExpiredItem = isQRObjectExpired(item); + const cover = item.coverImage || item.metadata?.coverImage; + + const TYPE_CONFIG: Record = { + ticket: { icon: '🎟️', label: 'Ticket', accent: 'text-emerald-600 dark:text-emerald-400', accentBg: 'bg-emerald-50 dark:bg-emerald-900/20', border: 'border-emerald-100 dark:border-emerald-800/40', btnBg: 'bg-[#00B512] hover:bg-[#00a010]' }, + vote: { icon: '🗳️', label: 'Vote Receipt', accent: 'text-orange-600 dark:text-orange-400', accentBg: 'bg-orange-50 dark:bg-orange-900/10', border: 'border-orange-100 dark:border-orange-800/40', btnBg: 'bg-orange-500 hover:bg-orange-600' }, + transport: { icon: '🚌', label: 'Transport Pass', accent: 'text-blue-600 dark:text-blue-400', accentBg: 'bg-blue-50 dark:bg-blue-900/10', border: 'border-blue-100 dark:border-blue-800/40', btnBg: 'bg-blue-500 hover:bg-blue-600' }, + service: { icon: '🛠️', label: 'Service', accent: 'text-violet-600 dark:text-violet-400', accentBg: 'bg-violet-50 dark:bg-violet-900/10', border: 'border-violet-100 dark:border-violet-800/40', btnBg: 'bg-violet-500 hover:bg-violet-600' }, + booking: { icon: '📅', label: 'Booking', accent: 'text-cyan-600 dark:text-cyan-400', accentBg: 'bg-cyan-50 dark:bg-cyan-900/10', border: 'border-cyan-100 dark:border-cyan-800/40', btnBg: 'bg-cyan-500 hover:bg-cyan-600' }, + membership: { icon: '🏅', label: 'Membership', accent: 'text-amber-600 dark:text-amber-400', accentBg: 'bg-amber-50 dark:bg-amber-900/10', border: 'border-amber-100 dark:border-amber-800/40', btnBg: 'bg-amber-500 hover:bg-amber-600' }, + }; + const cfg = TYPE_CONFIG[resolvedType] ?? TYPE_CONFIG.ticket; + + const statusBadge = ( + + {isExpiredItem ? 'Expired' : item.status || 'unknown'} -
+ ); -
-
- Issued - {formatDate(item.issuedAt || item.createdAt)} -
-
- - Valid Until - - {formatDate(item.validUntil)} -
-
- Quantity - {item.metadata?.quantity ?? 1} -
- {item.metadata?.seatType && ( -
- Seat - {item.metadata.seatType} -
- )} -
+ // ── VOTE RECEIPT CARD ──────────────────────────────── + if (resolvedType === 'vote') { + const standings = voteStandingsMap[actionId] ?? []; + const myCandidate = item.metadata?.subActionName || ''; + const myFromStandings = standings.find(s => s.name.toLowerCase() === myCandidate.toLowerCase()); + const myRank = myFromStandings?.rank ?? item.metadata?.rank ?? item.metadata?.candidateRank ?? null; + const myRankLabel = myRank != null ? toOrdinal(myRank) : null; + const placeEmoji = (r: number) => r === 1 ? '🥇' : r === 2 ? '🥈' : r === 3 ? '🥉' : `${r}.`; + const myRankColor = myRank === 1 ? 'text-yellow-600 dark:text-yellow-400' : myRank === 2 ? 'text-gray-500 dark:text-gray-300' : myRank === 3 ? 'text-amber-600 dark:text-amber-500' : 'text-blue-600 dark:text-blue-400'; + const actionName = item.metadata?.actionName || 'Vote'; + + return ( +
+ {/* Header strip */} +
+
+ {cover + ? {actionName} + : {cfg.icon} + } +
+
+

{cfg.label}

+

{actionName}

+
+ {statusBadge} +
- {Array.isArray(item.metadata?.benefits) && item.metadata.benefits.length > 0 ? ( -
-

Benefits

-
    - {item.metadata.benefits.map((benefit) => ( -
  • - - {benefit} -
  • - ))} -
-
- ) : null} - - {item.qrCodeData && ( -
-
- {`${item.metadata?.actionName -
-
-

Show this QR code to redeem your action.

-

- {item.usedAt - ? `Used ${formatDate(item.usedAt)}` - : 'Not used yet. Keep it safe for event day.'} -

-
- - {/* Show Mark as Used button for organizations viewing another user's QR objects */} - {isLoggedInAsOrganization && isViewingAnotherUser && item.status?.toLowerCase() !== 'used' && ( +
+ {/* My choice */} + {myCandidate && ( +
+

Your Vote

+
+

{myCandidate}

+ {myRankLabel && ( + + {myRankLabel} place + + )} +
+
+ )} + + {/* Standings */} + {standings.length > 0 && ( +
+

Standings

+
+ {standings.slice(0, 3).map((s, i) => { + const isMe = myCandidate && s.name.toLowerCase() === myCandidate.toLowerCase(); + return ( +
+ {placeEmoji(i + 1)} + {s.name} + {s.votes > 0 && {s.votes} votes} +
+ ); + })} +
+
+ )} + + {/* View voting page */} + {actionId && orgId && ( )}
+ ); + } + + // ── TICKET / TRANSPORT / SERVICE / BOOKING / MEMBERSHIP ── + const actionName = item.metadata?.actionName || 'Unnamed Action'; + const tier = item.metadata?.subActionName; + + return ( +
+ {/* Cover image */} + {cover && ( +
+ {actionName} +
+
+
+

{cfg.icon} {cfg.label}

+

{actionName}

+ {tier &&

{tier}

} +
+ {statusBadge} +
+
+ )} + + {/* Header (no cover) */} + {!cover && ( +
+
+ {cfg.icon} +
+
+

{cfg.label}

+

{actionName}

+ {tier &&

{tier}

} +
+ {statusBadge} +
+ )} + +
+ {/* Details grid */} +
+
+

Issued

+

{formatDate(item.issuedAt || item.createdAt)}

+
+
+

Valid Until

+

{formatDate(item.validUntil)}

+
+ {(item.metadata?.quantity ?? 1) > 1 && ( +
+

Qty

+

{item.metadata?.quantity}

+
+ )} + {item.metadata?.seatType && ( +
+

Seat

+

{item.metadata.seatType}

+
+ )} +
+ + {/* Benefits */} + {Array.isArray(item.metadata?.benefits) && item.metadata.benefits.length > 0 && ( +
+

Benefits

+
    + {item.metadata.benefits.map((b: string) => ( +
  • + {b} +
  • + ))} +
+
+ )} + + {/* QR + actions */} + {item.qrCodeData && ( +
+
+ QR +
+
+

+ {item.usedAt ? `Used ${formatDate(item.usedAt)}` : 'Show at entry to redeem'} +

+
+ + {isLoggedInAsOrganization && isViewingAnotherUser && item.status?.toLowerCase() !== 'used' && ( + + )} +
+
+
+ )} +
- )} -
- ))} + ); + })}
)}
@@ -1179,65 +1325,134 @@ const ActionsByAccountPage = () => { const filteredActions = getFilteredOrganizationActions(); return ( -
- {/* Filter and Action Buttons - Always Visible */} -
-
-
- -
- {[ - { value: 'all', label: 'All' }, - { value: 'published', label: 'Published' }, - { value: 'draft', label: 'Draft' }, - { value: 'archived', label: 'Archived' } - ].map(filter => { - let count = 0; - if (filter.value === 'all') { - count = organizationActions.length; - } else if (filter.value === 'archived') { - count = organizationActions.filter(action => action.status === 'archived' || isActionExpired(action)).length; - } else { - count = organizationActions.filter(action => action.status === filter.value && !isActionExpired(action)).length; - } - return ( - - ); - })} -
-
-
-
+
+ {/* Create New Action */} +
+

+ + Create new action +

+
+ {/* Ticket */} + + {/* Transport */} + + {/* Service */} + + + {/* Vote */} + + + {/* Booking */} + + + {/* Membership */} +
+ {/* Status filter + Scan QR */} +
+
+ {[ + { value: 'all', label: 'All' }, + { value: 'published', label: 'Published' }, + { value: 'draft', label: 'Draft' }, + { value: 'archived', label: 'Archived' }, + ].map(filter => { + let count = 0; + if (filter.value === 'all') count = organizationActions.length; + else if (filter.value === 'archived') count = organizationActions.filter(a => a.status === 'archived' || isActionExpired(a)).length; + else count = organizationActions.filter(a => a.status === filter.value && !isActionExpired(a)).length; + return ( + + ); + })} +
+ +
+ {/* Content Section - Empty or Actions Grid */} {!organizationActions.length ? (
@@ -1245,20 +1460,9 @@ const ActionsByAccountPage = () => { No actions published yet
-

- Create your first action to start accepting payments or issuing tickets. They will appear here in the same - layout visitors see on your welcome page. +

+ Select an action type above to get started. Your published actions will appear here in the same layout visitors see on your welcome page.

-
) : filteredActions.length === 0 ? (
@@ -1720,6 +1924,7 @@ const ActionsByAccountPage = () => { organizationId={effectiveUserId} onCompleted={handleWizardCompleted} editingActionId={editingActionId} + preSelectedType={preSelectedType} /> - {/* ── Sticky nav ─────────────────────────────────────────────────── */} -
+ {/* ── Sticky nav ── */} +
-

{parentAction.name}

-

{subAction.name}

+

{parentAction.name}

+

{subAction.name}

{isOwner && !isEditing && (
- -
)} {isOwner && isEditing && (
- - +
)}
-
+
- {/* ── Hero ───────────────────────────────────────────────────── */} -
+ {/* ── Hero ── */} +
{(subAction.coverImage || parentAction.coverImage) ? ( - {subAction.name} + {subAction.name} ) : ( -
+
)} -
-
-
- - {parentAction.status} +
+ + {/* Badges top-left */} +
+ {parentAction.status} + {parentAction.type && ( + + {parentAction.type} - {parentAction.type && ( - - {parentAction.type} - - )} - {stock != null && ( - - {stock} left - - )} + )} +
+ + {/* Stock badge top-right */} + {stock != null && ( +
+ {stock} available
+ )} + + {/* Title bottom */} +
{isEditing && editFormData ? ( setEditFormData({ ...editFormData, name: e.target.value })} - className="text-2xl font-bold bg-transparent border-b-2 border-[#3b82f6] text-[#f0f4f8] outline-none w-full" + className="text-2xl font-bold bg-transparent border-b-2 border-[#3b82f6] text-white outline-none w-full" /> ) : ( -

{subAction.name}

+

{subAction.name}

)} - {(subAction.description || parentAction.shortDescription) && !isEditing && ( -

+ {!isEditing && (subAction.description || parentAction.shortDescription) && ( +

{subAction.description || parentAction.shortDescription}

)}
- {/* ── Main grid ──────────────────────────────────────────────── */} -
- - {/* Left column */} -
- - {/* Price card */} -
-

- {isPWYW ? 'Pay what you want' : 'Price'} + {/* ── Price + stats strip ── */} +

+
+

+ {isPWYW ? 'Pay what you want' : 'Price'} +

+ {isEditing && editFormData && !isPWYW ? ( + setEditFormData({ ...editFormData, price: parseFloat(e.target.value) })} + className="text-xl font-bold bg-transparent border-b border-[#3b82f6] text-white outline-none w-full" + /> + ) : ( +

+ {isPWYW ? 'Open price' : `${parentAction.currency || ''} ${Number(subAction.price).toLocaleString('en-US', { minimumFractionDigits: 2 })}`}

- {isEditing && editFormData && !isPWYW ? ( - setEditFormData({ ...editFormData, price: parseFloat(e.target.value) })} - className="text-3xl font-bold bg-transparent border-b-2 border-[#3b82f6] text-[#f0f4f8] outline-none w-full" - /> - ) : ( -

- {isPWYW ? 'Open price' : `${parentAction.currency || ''} ${Number(subAction.price).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`} -

- )} - {stock != null && ( -

{stock} units available

- )} + )} +
+ {subAction.metadata?.votes !== undefined && ( +
+

Votes

+

{Number(subAction.metadata.votes).toLocaleString()}

+ )} + {subAction.metadata?.rank !== undefined && ( +
+

Rank

+

#{subAction.metadata.rank}

+
+ )} + {isOwner && subAction.wallet && ( +
+

Balance

+

{subAction.wallet.balance.toLocaleString()}

+
+ )} +
- {/* Wallet balance (owner only) */} - {isOwner && subAction.wallet && ( -
-
- -
-
-

Wallet balance

-

- {subAction.wallet.currency} {subAction.wallet.balance.toLocaleString()} -

-
-
- )} + {/* ── Content sections ── */} +
- {/* QR code */} - {subAction.dedicatedQrCodeData && ( -
-
- -

Your QR Code

-
-
-
- QR Code -
-
- - - Open link - -
+ {/* Description */} +
+

About

+ {isEditing && editFormData ? ( +