diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 79deadc..6773f18 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -14,6 +14,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Setup pnpm uses: pnpm/action-setup@v2 @@ -30,7 +32,17 @@ jobs: run: pnpm install --frozen-lockfile - name: Run ESLint - run: pnpm run lint + run: | + CHANGED_FILES=$(git diff --name-only --diff-filter=ACMR ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} | grep -E '\.(js|jsx|ts|tsx)$' || true) + if [ -z "$CHANGED_FILES" ]; then + echo "No JS/TS files changed; skipping ESLint." + exit 0 + fi + LINT_ARGS=() + while IFS= read -r file; do + [ -n "$file" ] && LINT_ARGS+=(--file "$file") + done <<< "$CHANGED_FILES" + pnpm exec next lint "${LINT_ARGS[@]}" - name: Run TypeScript type check run: pnpm run type-check diff --git a/INTEGRATION_AUDIT.md b/INTEGRATION_AUDIT.md new file mode 100644 index 0000000..d41a07b --- /dev/null +++ b/INTEGRATION_AUDIT.md @@ -0,0 +1,169 @@ +# Integration Audit — Poeta (que_code_fn ↔ que_code_bn) + +Date: 2026-06-24 +Branch: `fx-act` + +This document tracks two integration gaps: +1. **Frontend features that are NOT wired to the backend.** +2. **Backend features that have NO frontend consumer.** + +Items are listed by priority within each section. + +--- + +## Part 1 — Frontend Features Missing Backend Integration + +### High Priority + +1. **Privacy Settings** + - File: [components/settings/PrivacyTab.tsx](components/settings/PrivacyTab.tsx#L33) + - Issue: No-op save (simulated 1000 ms delay only). Handlers exist but no API call. + - Missing: `PATCH /users/privacy-settings` (or equivalent) integration. + - Impact: User privacy preferences are not persisted. + +2. **Payment Methods & Bank Accounts** + - File: [components/settings/PaymentTab.tsx](components/settings/PaymentTab.tsx#L50) + - Issue: Hardcoded payment methods/bank accounts; handlers are non-functional. + - Missing: `setDefaultPaymentMethod()`, `setDefaultBankAccount()`, `increaseLimits()` API calls. + - Impact: Entire payment-management UI is dead. + +3. **Invite Page** + - File: [app/invite/page.tsx](app/invite/page.tsx#L8-L13) + - Issue: Uses hardcoded sample invitation data (Miss Rwanda, John Doe, dummy email/dates). + - Missing: Dynamic loading of actual invitation from URL params or API. + - Impact: Cannot accept/decline real invitations. + +4. **Organization Wallet Demo** + - File: [app/organization-wallet-demo/page.tsx](app/organization-wallet-demo/page.tsx#L12) + - Issue: Mock organizations array with `// in real app, this would come from API`. + - Missing: `getOrganizations()` API call. + - Impact: Demo page does not reflect the user's actual organizations. + +### Medium Priority + +5. **Account Deletion Workflow** + - File: [components/settings/PrivacyTab.tsx](components/settings/PrivacyTab.tsx#L52) + - Issue: `TODO` comment; shows a toast instead of executing the workflow. + - Missing: Account deletion API endpoint and confirmation flow. + - Impact: Users cannot delete their accounts. + +6. **Notification Preferences** + - File: [components/settings/NotificationsTab.tsx](components/settings/NotificationsTab.tsx#L91) + - Issue: Sound/vibration saved to localStorage; transaction/group toggles never sync. + - Missing: `PATCH /users/notification-preferences` call. + - Impact: Email/SMS/push preferences not saved server-side. + +7. **Profile Verification (KYC)** + - File: [components/settings/ProfileTab.tsx](components/settings/ProfileTab.tsx) + - Issue: ID and address verification buttons carry `TODO: Implement`. + - Missing: KYC API endpoints and verification flow. + - Impact: KYC/verification features non-functional. + +### Low Priority + +8. **Category Transaction Drill-down** + - File: [components/analytics/CategoryTransactionPopover.tsx](components/analytics/CategoryTransactionPopover.tsx) + - Issue: `TODO` on click handler; no route/API for per-category transaction list. + - Missing: Detailed transaction analytics by category. + - Impact: Clicking category breakdown items is a no-op. + +> **Already integrated (no action needed):** auth, transactions, chat, actions, analytics, and groups — all wired through ~62 helpers in [helpers/api.ts](helpers/api.ts). The currently opened [app/action/[userId]/page.tsx](app/action/[userId]/page.tsx) is part of that integrated set. + +--- + +## Part 2 — Backend Features Missing Frontend Consumer + +### High Priority + +1. **Admin Notifications Broadcast** + - Endpoint: `POST /api/v1/admin/notifications/broadcast` + - File: [../que_code_bn/src/routes/admin.notifications.routes.ts](../que_code_bn/src/routes/admin.notifications.routes.ts) + - What: Send broadcast notifications to all/selected users. + - Why: Critical admin capability for platform-wide announcements. + +2. **Contact Invitations by Public ID (QR)** + - Endpoint: `POST /api/v1/contact-invitations/by-public-id` + - File: [../que_code_bn/src/routes/contactInvitation.routes.ts](../que_code_bn/src/routes/contactInvitation.routes.ts) + - What: Send contact invitations via QR-code scans. + - Why: Core social feature; pairs directly with the Invite page already flagged in Part 1. + +3. **Support Chat Admin Panel** + - Endpoints: `GET/POST /api/v1/admin/support/chats`, `GET/POST /api/v1/admin/support/chats/:chatId/messages` + - File: [../que_code_bn/src/routes/admin.support.routes.ts](../que_code_bn/src/routes/admin.support.routes.ts) + - What: Operator UI to manage user support chats. + - Why: Operators cannot reply to user support without this. + +4. **Admin Dashboard Statistics** + - Endpoint: `GET /api/v1/admin/dashboard/statistics` + - File: [../que_code_bn/src/routes/admin.dashboard.routes.ts](../que_code_bn/src/routes/admin.dashboard.routes.ts) + - What: Platform-wide statistics for the admin dashboard. + - Why: Essential admin landing data. + +### Medium Priority + +5. **Roles & Permissions Management** + - Endpoints: `GET/POST/PUT/DELETE /api/v1/roles` and `/api/v1/permissions` + - Files: [../que_code_bn/src/routes/role.routes.ts](../que_code_bn/src/routes/role.routes.ts), [permission.routes.ts](../que_code_bn/src/routes/permission.routes.ts) + - What: Manage user roles and access-control permissions. + - Why: Admin RBAC management. + +6. **Audit Logs** + - Endpoints: `GET /api/v1/admin/audit-logs[/:id|/user/:userId]` + - File: [../que_code_bn/src/routes/auditLog.routes.ts](../que_code_bn/src/routes/auditLog.routes.ts) + - What: Track admin/system actions. + - Why: Compliance and security review. + +7. **Admin Action Moderation** + - Endpoints: `PUT /api/v1/admin/actions/:id/suspend`, `…/status` + - File: [../que_code_bn/src/routes/admin.action.routes.ts](../que_code_bn/src/routes/admin.action.routes.ts) + - What: Suspend/manage actions for policy violations. + - Why: Moderation capability. + +8. **Admin Group Management** + - Endpoints: `DELETE /api/v1/admin/groups/:id`, `…/members/:userId` + - File: [../que_code_bn/src/routes/admin.groups.routes.ts](../que_code_bn/src/routes/admin.groups.routes.ts) + - What: Admin-level group deletion and member removal. + - Why: Compliance/moderation. + +9. **Platform Analytics** + - Endpoint: `GET /api/v1/admin/analytics/platform` + - File: [../que_code_bn/src/routes/admin.analytics.routes.ts](../que_code_bn/src/routes/admin.analytics.routes.ts) + - What: Aggregated platform metrics. + - Why: Business intelligence. + +### Low Priority + +10. **Wallet Admin Controls** + - Endpoint: `PUT /api/v1/admin/wallets/:id/status` + - File: [../que_code_bn/src/routes/admin.wallets.routes.ts](../que_code_bn/src/routes/admin.wallets.routes.ts) + - What: Enable/disable wallets for compliance. + - Why: Edge-case admin operation. + +11. **Push Subscriptions** + - Endpoints: `POST/DELETE /api/v1/push-subscriptions` + - File: [../que_code_bn/src/routes/pushSubscription.routes.ts](../que_code_bn/src/routes/pushSubscription.routes.ts) + - What: Manage PWA push-notification subscriptions. + - Why: Required only if PWA push is on the roadmap. + +12. **Link Preview** + - Endpoint: `GET /api/v1/link-preview?url=...` + - File: [../que_code_bn/src/routes/link-preview.routes.ts](../que_code_bn/src/routes/link-preview.routes.ts) + - What: Generate OG metadata previews for chat links. + - Why: Nice-to-have chat enhancement. + +13. **Outside Messages** + - Endpoints: `POST /api/v1/outside-messages`, `GET /api/v1/outside-messages/inbox` + - File: [../que_code_bn/src/routes/outsideMessage.routes.ts](../que_code_bn/src/routes/outsideMessage.routes.ts) + - What: Messages from non-registered users. + - Why: Niche feature. + +--- + +## Summary + +- **Frontend gaps (8):** mostly in Settings (privacy, payments, notifications, KYC, deletion) plus the Invite and Organization-Wallet pages. +- **Backend gaps (13):** overwhelmingly the **admin/operator surface** — no admin UI exists to consume dashboard stats, support chat, broadcasts, RBAC, audit logs, moderation, or platform analytics. Only the QR contact-invitation endpoint sits on the user-facing side and ties into the Invite page already flagged in Part 1. + +**Recommended next steps:** +1. Wire the Part-1 High-priority items (Privacy → Payments → Invite → Org Wallet). +2. Scaffold an `/admin` section starting with Dashboard Statistics + Support Chat, then layer in Broadcasts and RBAC. diff --git a/app/action/[userId]/page.tsx b/app/action/[userId]/page.tsx index ab5f180..0e96607 100644 --- a/app/action/[userId]/page.tsx +++ b/app/action/[userId]/page.tsx @@ -25,6 +25,10 @@ import { ArrowRight, Plus, Globe2, + Eye, + Pencil, + Send, + ArrowLeftRight, } from 'lucide-react'; import { useParams, useRouter } from 'next/navigation'; import Navigation from '@/components/Navigation'; @@ -41,6 +45,7 @@ import { Button } from '@/components/ui/button'; import jsPDF from 'jspdf'; import ActionWizardModal from '@/components/ActionPage/ActionWizardModal'; import QRObjectValidator from '@/components/ActionPage/QRObjectValidator'; +import TransferTicketModal from '@/components/ActionPage/TransferTicketModal'; import { createSubAction, updateSubAction, getMyGroupContributions, contributeToGroup, closeGroupContribution, extendGroupContributionDeadline, getMyPublicContributions } from '@/helpers/api'; import CreatePublicContributionModal from '@/components/contributions/CreatePublicContributionModal'; import { PublicContributionCard, PublicContributionData } from '@/components/contributions/PublicContributionCard'; @@ -48,6 +53,14 @@ import socketService from '@/services/socketService'; import { getCurrentUserId } from '@/utils/tokenUtils'; import { formatDistanceToNow } from 'date-fns'; +interface TransferRecord { + fromId: string; + fromName: string; + toId: string; + toName: string; + at: string; +} + interface QrObject { id: string; type: string; @@ -60,6 +73,7 @@ interface QrObject { coverImage?: string; actionId?: string; organizationId?: string; + transferHistory?: TransferRecord[]; [key: string]: any; }; status: string; @@ -71,6 +85,7 @@ interface QrObject { createdAt?: string; updatedAt?: string; actionId?: string; + actionPurchaseId?: string; organizationId?: string; } @@ -504,6 +519,7 @@ const ActionsByAccountPage = () => { const [purchasedActionsFilter, setPurchasedActionsFilter] = useState<'all' | 'archive'>('all'); const [editingSubActionId, setEditingSubActionId] = useState(null); const [markingAsUsed, setMarkingAsUsed] = useState>({}); + const [transferTarget, setTransferTarget] = useState(null); const [resolvedTypeMap, setResolvedTypeMap] = useState>({}); const [voteStandingsMap, setVoteStandingsMap] = useState>({}); @@ -1236,6 +1252,7 @@ const ActionsByAccountPage = () => { // ── TICKET / TRANSPORT / SERVICE / BOOKING / MEMBERSHIP ── const actionName = item.metadata?.actionName || 'Unnamed Action'; const tier = item.metadata?.subActionName; + const lastTransfer = item.metadata?.transferHistory?.[item.metadata.transferHistory.length - 1]; return (
@@ -1271,6 +1288,18 @@ const ActionsByAccountPage = () => { )}
+ {/* Transfer trail — this ticket changed hands */} + {lastTransfer && ( +
+ + + {!isViewingAnotherUser + ? `Received from ${lastTransfer.fromName}` + : `Transferred from ${lastTransfer.fromName} to ${lastTransfer.toName}`} + +
+ )} + {/* Details grid */}
@@ -1326,6 +1355,18 @@ const ActionsByAccountPage = () => { > Download PDF + {/* Owner can hand a still-valid ticket to a contact */} + {!isViewingAnotherUser + && item.actionPurchaseId + && !isExpiredItem + && item.status?.toLowerCase() === 'valid' && ( + + )} {isLoggedInAsOrganization && isViewingAnotherUser && item.status?.toLowerCase() !== 'used' && (
)}
- {action.status === 'draft' && ( -
+
+ {(action.status === 'draft' || action.status === 'published') && ( { }} className="inline-flex items-center gap-2 px-3 py-2 rounded-full border border-[#00B512] dark:border-brand-green text-[#00B512] dark:text-brand-green text-xs font-semibold hover:bg-[#00B512] dark:hover:bg-brand-green hover:text-white transition-colors cursor-pointer" > - - Continue Setup + {action.status === 'draft' ? ( + <> + + Continue Setup + + ) : ( + <> + + Edit action + + )} -
- )} + )} + {effectiveUserId && ( + { + e.stopPropagation(); + router.push(`/welcome/${effectiveUserId}/action/${action.id}`); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + router.push(`/welcome/${effectiveUserId}/action/${action.id}`); + } + }} + className="inline-flex items-center gap-2 px-3 py-2 rounded-full border border-[#00B512] dark:border-brand-green text-[#00B512] dark:text-brand-green text-xs font-semibold hover:bg-[#00B512] dark:hover:bg-brand-green hover:text-white transition-colors cursor-pointer" + > + + Preview action + + )} +
))}
@@ -1752,7 +1823,7 @@ const ActionsByAccountPage = () => {
🎯

No group campaigns

- When a group admin starts a contribution campaign in your chat, it'll show up here. + When a group admin starts a contribution campaign in your chat, it'll show up here.

) : visibleContributions.length === 0 ? ( @@ -1865,13 +1936,25 @@ const ActionsByAccountPage = () => {
-
-

- - Actions Center -

-

{pageTitle}

-

{pageDescription}

+
+
+

+ + Actions Center +

+

{pageTitle}

+

{pageDescription}

+
+ {accountMode === 'organization' && effectiveUserId && ( + + )}
@@ -2097,6 +2180,21 @@ const ActionsByAccountPage = () => { onClose={() => setCreateCampaignOpen(false)} onCreated={fetchMyPublicContributions} /> + + {/* Transfer a purchased ticket to a contact */} + {transferTarget && tokenUserId && ( + setTransferTarget(null)} + purchaseId={transferTarget.actionPurchaseId as string} + ticketName={transferTarget.metadata?.actionName || 'this ticket'} + senderId={tokenUserId} + onTransferred={() => { + setTransferTarget(null); + if (effectiveUserId) fetchData(effectiveUserId); + }} + /> + )}
); }; diff --git a/app/action/[userId]/subactions/[subactionId]/page.tsx b/app/action/[userId]/subactions/[subactionId]/page.tsx index 94526c1..d72f644 100644 --- a/app/action/[userId]/subactions/[subactionId]/page.tsx +++ b/app/action/[userId]/subactions/[subactionId]/page.tsx @@ -18,10 +18,13 @@ import { Clock, Edit2, ExternalLink, + Link as LinkIcon, Loader2, + Maximize2, Package, QrCode, Save, + Share2, ShoppingCart, Trash2, Users, @@ -29,6 +32,12 @@ import { X, } from 'lucide-react'; import { Dialog, DialogContent } from '@/components/ui/dialog'; +import ImageCarousel from '@/components/ui/image-carousel'; +import ImageLightbox from '@/components/ui/image-lightbox'; +import ShareQrDialog from '@/components/ui/share-qr-dialog'; +import SocialLinksRow from '@/components/ui/social-links'; +import type { SocialLinks } from '@/types/action.types'; +import { parseMetadata } from '@/utils/subActionMetadata'; interface SubAction { id: string; @@ -39,10 +48,11 @@ interface SubAction { stock?: number; stockReserved?: number; variants?: Record; - metadata?: Record; + metadata?: Record & { socialLinks?: SocialLinks }; isActive?: boolean; sortOrder?: number; coverImage?: string; + images?: string[]; dedicatedQrCodeData?: string; createdAt?: string; updatedAt?: string; @@ -71,6 +81,22 @@ const formatDate = (date?: string) => { return new Date(date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); }; +// Metadata is free-form JSON, so arrays and objects have to be rendered readably +const formatMetaValue = (value: unknown): string => { + if (value === null || value === undefined || value === '') return '—'; + if (typeof value === 'boolean') return value ? 'Yes' : 'No'; + if (Array.isArray(value)) return value.map(formatMetaValue).join(', '); + if (typeof value === 'object') { + return Object.entries(value as Record) + .map(([k, v]) => `${k}: ${formatMetaValue(v)}`) + .join(' · '); + } + return String(value); +}; + +const editFieldClass = + 'w-full bg-[#0d1525] border border-[#1e2d40] focus:border-[#3b82f6] rounded-xl px-4 py-2.5 text-sm text-[#f0f4f8] placeholder:text-[#4a6278] outline-none transition-colors'; + const ctaLabel = (type?: string) => { switch (type) { case 'vote': return 'Vote now'; @@ -119,6 +145,19 @@ export default function SubActionDetailPage() { const [isPurchaseSuccessOpen, setIsPurchaseSuccessOpen] = useState(false); const [showPurchaseForm, setShowPurchaseForm] = useState(false); const [detailsExpanded, setDetailsExpanded] = useState(false); + const [heroScrolledPast, setHeroScrolledPast] = useState(false); + const [lightboxIndex, setLightboxIndex] = useState(null); + const [siblings, setSiblings] = useState([]); + const [copied, setCopied] = useState(false); + const [showShareQr, setShowShareQr] = useState(false); + + // The sticky bar only shows the title once the hero title has scrolled out of view + useEffect(() => { + const onScroll = () => setHeroScrolledPast(window.scrollY > 220); + onScroll(); + window.addEventListener('scroll', onScroll, { passive: true }); + return () => window.removeEventListener('scroll', onScroll); + }, []); const isOwner = actionId === tokenUserId && accountType === 'organization'; @@ -132,14 +171,25 @@ export default function SubActionDetailPage() { const subRes = await axios.get(`${baseUrl}/sub-actions/${subactionId}`, { headers }); const found: SubAction = subRes.data?.data || subRes.data; if (!found) { setError('Not found'); setLoading(false); return; } - if (found.metadata && typeof found.metadata === 'string') { - try { found.metadata = JSON.parse(found.metadata); } catch { found.metadata = {}; } - } + found.metadata = parseMetadata(found.metadata); const actionRes = await axios.get(`${baseUrl}/actions/${found.actionId}`, { headers }); const foundAction: ParentAction = actionRes.data?.data || actionRes.data; setSubAction(found); setParentAction(foundAction); setEditFormData(found); + + // Siblings power the share-of-vote bar and the leader gap; a failure here is not fatal + try { + const siblingRes = await axios.get(`${baseUrl}/actions/${found.actionId}/sub-actions`, { headers }); + const list: SubAction[] = siblingRes.data?.data ?? []; + setSiblings( + Array.isArray(list) + ? list.map(item => ({ ...item, metadata: parseMetadata(item.metadata) })) + : [] + ); + } catch { + setSiblings([]); + } } catch (err: any) { setError(err?.response?.data?.message || err?.message || 'Failed to load'); } finally { @@ -258,7 +308,7 @@ export default function SubActionDetailPage() { const cfg = configs[field] || { label: field, type: 'text', placeholder: `Enter ${field}` }; return (
-