From f52a2ab14329196307c6202a07810d5e9acc5be3 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Fri, 6 Mar 2026 13:27:12 +0100 Subject: [PATCH 1/8] merg to prod (#450) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: improve timeline input , ui improvement and fixes for participation tab (#442) * feat: integrate Sentry for error tracking and reporting - Added Sentry configuration for both server and edge environments to capture errors and performance metrics. - Updated error handling across various components to report errors to Sentry, enhancing monitoring capabilities. - Introduced a new MessagesProvider for managing in-app messaging and notifications. - Refactored the .env.example file to include Sentry DSN and related configurations for production environments. - Removed the deprecated connect-wallet component to streamline the codebase. * UI fixes (#444) * fix: improve timeline input , ui improvement and fixes for participation tab * fix: implement 2fa for email and password login * fix: fix conflict * UI fixes (#445) * fix: improve timeline input , ui improvement and fixes for participation tab * fix: implement 2fa for email and password login * fix: fix conflict * fix: handle potential null values in notification data - Updated notification handling to safely access properties using optional chaining. - Ensured that the notification data is checked for null values before accessing its properties in various components, improving robustness and preventing runtime errors. * feat: Enforce Submission Requirements (requireGithub, requireDemoVideo, requireOtherLinks) (#443) * feat: enforce submission requirements (requireGithub, requireDemoVideo, requireOtherLinks) - Add dynamic schema validation based on currentHackathon flags - Enforce requireDemoVideo at schema level with Zod refine - Add Step 2 validation for requireGithub, requireDemoVideo, requireOtherLinks - Add final validation pass in onSubmit before API call - Add dynamic asterisk (*) to required field labels - Add legend and helper text for required fields - Wrap links section in FormField for proper error display * UI fixes (#444) * fix: improve timeline input , ui improvement and fixes for participation tab * fix: implement 2fa for email and password login * fix: fix conflict * feat: prevent removal of required links and style required asterisks in red Made-with: Cursor --------- Co-authored-by: Nnaji Benjamin <60315147+Benjtalkshow@users.noreply.github.com> Co-authored-by: felipevega2x * feat(submissions): enforce team size limits (teamMin, teamMax) (#446) * feat(submissions): enforce team size limits (teamMin, teamMax) - Add validation in handleNext (Step 0) to block progression when team size is below teamMin, showing specific message with members needed - Guard handleAddInvitee function to prevent adding members when team reaches teamMax capacity - Add capacity indicator Badge showing "X / Y members" with visual states: orange when below minimum, yellow when at capacity - Disable "Add Member" button and inputs when team is full - Display helper text when team is below minimum requirement Closes #405 * fix(submissions): remove hardcoded team size fallbacks - Use undefined instead of hardcoded defaults (1, 10) - Add hasTeamLimits guard to only enforce when hackathon defines limits - Conditionally render Badge and helper text when limits are defined * refactor: enhance error handling and submission response structure - Introduced a utility function to standardize API error messages across submission operations. - Updated the create and update submission functions to handle optional chaining for response checks. - Improved the response structure to include messages for successful submissions. - Adjusted the CreateSubmissionRequest interface to remove the hackathonId field and made organizationId optional in the update submission request. --------- Co-authored-by: Nnaji Benjamin <60315147+Benjtalkshow@users.noreply.github.com> Co-authored-by: Matias Aguilar Co-authored-by: felipevega2x Co-authored-by: Josué Araya Marín <104031367+Josue19-08@users.noreply.github.com> --- .cursor/rules/trustless-rule.mdc | 4 +- .env.example | 12 +- README.md | 5 +- .../hackathons/[slug]/HackathonPageClient.tsx | 13 +- .../announcements/[announcementId]/page.tsx | 7 +- .../preview/[orgId]/[draftId]/page.tsx | 6 +- .../[hackathonId]/announcement/page.tsx | 9 +- .../hackathons/[hackathonId]/judging/page.tsx | 53 +- .../[hackathonId]/participants/page.tsx | 13 +- .../[hackathonId]/submissions/page.tsx | 8 +- app/(landing)/projects/[slug]/page.tsx | 11 +- app/api/auth/test/route.ts | 10 + app/api/proxy/[...path]/route.ts | 5 +- app/error.tsx | 16 +- app/global-error.tsx | 68 + app/me/earnings/page.tsx | 3 +- app/me/layout.tsx | 12 +- app/me/messages/page.tsx | 25 + .../components/NotificationDetailSheet.tsx | 2 +- .../components/NotificationFeedItem.tsx | 4 +- app/me/notifications/page.tsx | 18 +- app/me/projects/page.tsx | 5 +- app/me/settings/SettingsContent.tsx | 58 +- app/not-found.tsx | 10 +- app/providers.tsx | 9 +- components/app-sidebar.tsx | 6 + components/auth/LoginWrapper.tsx | 119 +- components/auth/TwoFactorVerify.tsx | 180 + components/auth/login-form.tsx | 14 +- components/connect-wallet/index.tsx | 257 - components/connect-wallet/wallet-card.tsx | 34 - .../crowdfunding/campaign-comments-tab.tsx | 17 +- components/escrow/ApproveMilestone.tsx | 4 +- components/escrow/ChangeMilestoneStatus.tsx | 4 +- components/escrow/FundEscrowButton.tsx | 4 +- components/escrow/InitializeEscrowButton.tsx | 4 +- .../hackathons/participants/profileCard.tsx | 78 +- .../hackathons/submissions/SubmissionForm.tsx | 417 +- .../hackathons/submissions/submissionCard.tsx | 13 +- .../hackathons/submissions/submissionTab.tsx | 6 +- components/landing-page/footer.tsx | 20 +- components/messages/MessagesProvider.tsx | 58 + components/messages/MessagesSheet.tsx | 446 + components/messages/MessagesTrigger.tsx | 25 + .../notifications/NotificationDropdown.tsx | 48 +- components/notifications/NotificationIcon.tsx | 3 + components/notifications/NotificationItem.tsx | 47 +- .../organization/OrganizationHeader.tsx | 19 +- .../organization/OrganizationHeaderSearch.tsx | 186 + .../useSubmissionScores.ts | 6 +- .../organization/cards/JudgingParticipant.tsx | 6 +- .../IndividualScoresBreakdown.tsx | 6 +- .../SubmissionModalHeader.tsx | 3 +- .../hackathons/new/tabs/ParticipantTab.tsx | 3 +- .../components/timeline/DateTimeInput.tsx | 8 +- .../new/tabs/schemas/participantSchema.ts | 13 + .../submissions/SubmissionsList.tsx | 3 +- components/profile/PublicEarningsTab.tsx | 3 +- .../profile/update/SecuritySettingsTab.tsx | 212 + components/profile/update/SecurityTab.tsx | 166 + components/profile/update/Settings.tsx | 1149 +- components/profile/update/TwoFactorTab.tsx | 514 + .../comment-section/project-comments.tsx | 11 +- components/providers/auth-provider.tsx | 7 +- components/providers/wallet-provider.tsx | 161 +- components/site-header.tsx | 2 + components/wallet/AssetIcon.tsx | 61 + components/wallet/FamilyWalletDrawer.tsx | 576 +- components/wallet/WalletSheet.tsx | 171 +- components/wallet/WalletTrigger.tsx | 77 +- hooks/hackathon/use-hackathons-list.ts | 6 +- hooks/hackathon/use-participants.ts | 6 +- hooks/hackathon/use-submission-vote.ts | 5 +- hooks/hackathon/use-submission.ts | 66 +- hooks/hackathon/use-team-invite.ts | 8 +- hooks/hackathon/use-team-posts.ts | 7 +- hooks/use-comment-realtime.ts | 23 +- hooks/use-file-upload.ts | 11 +- hooks/use-hackathon-rewards.ts | 15 +- hooks/use-message-realtime.ts | 99 + hooks/use-vote-realtime.ts | 23 +- hooks/use-wallet.ts | 64 +- hooks/useNotifications.ts | 5 +- hooks/useSocket.ts | 3 +- instrumentation-client.ts | 25 + instrumentation.ts | 13 + lib/api/api.ts | 13 +- lib/api/auth.ts | 174 +- lib/api/hackathons.ts | 42 +- lib/api/hackathons/participants.ts | 14 +- lib/api/messages.ts | 150 + lib/api/wallet.ts | 134 + lib/auth-client.ts | 2 +- lib/auth/logger.ts | 17 +- lib/config/wallet-kit.ts | 59 +- lib/country-utils.ts | 6 +- lib/error-reporting.ts | 95 + lib/logger.ts | 5 +- lib/providers/OrganizationProvider.tsx | 9 +- lib/providers/hackathonProvider.tsx | 9 +- lib/utils/hackathon-escrow.ts | 7 +- lib/utils/stellar-address-validation.ts | 11 +- next.config.ts | 37 +- package-lock.json | 9212 ++++++----------- package.json | 2 +- public/assets/eurc.png | Bin 0 -> 1348 bytes public/assets/usdc.svg | 5 + public/assets/xlm.svg | 9 + sentry.edge.config.ts | 12 + sentry.server.config.ts | 12 + types/messages.ts | 65 + types/notifications.ts | 9 +- 112 files changed, 8295 insertions(+), 7747 deletions(-) create mode 100644 app/global-error.tsx create mode 100644 app/me/messages/page.tsx create mode 100644 components/auth/TwoFactorVerify.tsx delete mode 100644 components/connect-wallet/index.tsx delete mode 100644 components/connect-wallet/wallet-card.tsx create mode 100644 components/messages/MessagesProvider.tsx create mode 100644 components/messages/MessagesSheet.tsx create mode 100644 components/messages/MessagesTrigger.tsx create mode 100644 components/organization/OrganizationHeaderSearch.tsx create mode 100644 components/profile/update/SecuritySettingsTab.tsx create mode 100644 components/profile/update/SecurityTab.tsx create mode 100644 components/profile/update/TwoFactorTab.tsx create mode 100644 components/wallet/AssetIcon.tsx create mode 100644 hooks/use-message-realtime.ts create mode 100644 instrumentation-client.ts create mode 100644 instrumentation.ts create mode 100644 lib/api/messages.ts create mode 100644 lib/api/wallet.ts create mode 100644 lib/error-reporting.ts create mode 100644 public/assets/eurc.png create mode 100644 public/assets/usdc.svg create mode 100644 public/assets/xlm.svg create mode 100644 sentry.edge.config.ts create mode 100644 sentry.server.config.ts create mode 100644 types/messages.ts diff --git a/.cursor/rules/trustless-rule.mdc b/.cursor/rules/trustless-rule.mdc index a9d529ad..cb846ed3 100644 --- a/.cursor/rules/trustless-rule.mdc +++ b/.cursor/rules/trustless-rule.mdc @@ -30,13 +30,15 @@ You are methodical, precise, and a master at reasoning through complex requireme When working with Trustless Work: - Documentation (I'll provide you the docs in the cursor docs management): - React Library → - - Wallet Kit → - Types → - Ensure proper installation and configuration before usage. - Use provided Types from the documentation when applicable. - Follow the API and component usage exactly as described in the docs. - Do not use any, instead always you must search for the Trustless Work entities. +## Wallet (backend-managed) +- Wallet data is provided by the backend wallet API (see `lib/api/wallet.ts`). The wallet provider holds the API response in state and exposes `refreshWallet()`, `syncWallet()`, and trustline helpers. Use the in-app wallet UI (FamilyWalletDrawer, WalletSheet) for sync and add-trustline; there is no external "connect wallet" flow or Stellar Wallet Kit. + ## Code Implementation Guidelines - Use **TailwindCSS classes** for styling; avoid plain CSS. - For conditional classes, prefer `clsx` or similar helper functions over ternary operators in JSX. diff --git a/.env.example b/.env.example index 8db3c900..50e04a86 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,18 @@ # Created by Vercel CLI GOOGLE_CLIENT_ID="" GOOGLE_CLIENT_SECRET="your_google_client_secret" -NEXTAUTH_SECRET="PiRlq+7IyU5QJuCiVwjiuwZUGCqc/3qw1QPgLOt2AFg=" +# Set a strong secret per environment (e.g. openssl rand -base64 32). Do not commit real values. +NEXTAUTH_SECRET="" NEXTAUTH_URL="http://localhost:3000" +# In production set to your production API base (e.g. https://api.boundlessfi.xyz). Do not rely on staging fallback. NEXT_PUBLIC_API_URL="https://stage-api.boundlessfi.xyz/api" NEXT_PUBLIC_APP_DESCRIPTION="Stellar-based application" NEXT_PUBLIC_APP_ICON="/logo.svg" NEXT_PUBLIC_APP_NAME="Boundless" NEXT_PUBLIC_APP_URL="http://localhost:3000" +# Required in production for realtime/socket. No localhost fallback in production. NEXT_PUBLIC_BETTER_AUTH_URL="https://stage-api.boundlessfi.xyz" +# Required for escrow in production. Set to your platform wallet address. NEXT_PUBLIC_BOUNDLESS_PLATFORM_ADDRESS="" NEXT_PUBLIC_DEBUG_MODE="false" NEXT_PUBLIC_ENABLE_MULTI_WALLET="true" @@ -20,4 +24,10 @@ NEXT_PUBLIC_HORIZON_TESTNET_URL="https://horizon-testnet.stellar.org" NEXT_PUBLIC_STELLAR_NETWORK="testnet" NEXT_PUBLIC_TRUSTLESS_WORK_API_KEY="" NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID="your_wallet_connect_project_id" +# Error reporting (optional). When set, errors are sent to Sentry. +NEXT_PUBLIC_SENTRY_DSN="" +SENTRY_DSN="" +SENTRY_ORG="" +SENTRY_PROJECT="boundless-next" +SENTRY_AUTH_TOKEN="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" NODE_ENV="dev" \ No newline at end of file diff --git a/README.md b/README.md index e76dd425..71f95f55 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Boundless is a decentralized crowdfunding and grants platform built on the Stell - **Secure and transparent community voting and feedback systems** - **Flexible user roles** including campaign creators, grant applicants, managers, and admins - **Integrated authentication** with email, social login, and KYC verification -- **Multi-wallet support** for Stellar ecosystem (Freighter, Albedo, Rabet, xBull, Lobstr, Hana, HOT Wallet) +- **Backend-managed wallet** with sync and trustline support in-app (no external connect-wallet flow) - **Comprehensive backend support** with RESTful API endpoints and robust security measures - **Automated contract deployment and upgrade processes** using CI/CD pipelines @@ -81,7 +81,6 @@ boundless/ │ │ └── LaunchCampaignFlow.tsx │ ├── comment/ # Comment components │ ├── comments/ # Comments system -│ ├── connect-wallet/ # Wallet connection UI │ ├── landing-page/ # Landing page components │ ├── layout/ # Layout components │ ├── loading/ # Loading components @@ -93,7 +92,7 @@ boundless/ │ ├── stepper/ # Stepper components │ ├── testimonials/ # Testimonial components │ ├── ui/ # Reusable UI components (48 components) -│ ├── wallet/ # Wallet-related components +│ ├── wallet/ # In-app wallet UI (drawer, sheet, sync, trustlines) │ └── waitlist/ # Waitlist components ├── hooks/ # Custom React hooks │ ├── use-account.ts # Account management hook diff --git a/app/(landing)/hackathons/[slug]/HackathonPageClient.tsx b/app/(landing)/hackathons/[slug]/HackathonPageClient.tsx index 91518cd5..0c4d1adf 100644 --- a/app/(landing)/hackathons/[slug]/HackathonPageClient.tsx +++ b/app/(landing)/hackathons/[slug]/HackathonPageClient.tsx @@ -29,6 +29,7 @@ import { } from '@/lib/api/hackathons/index'; import { Megaphone } from 'lucide-react'; import { AnnouncementsTab } from '@/components/hackathons/announcements/AnnouncementsTab'; +import { reportError, reportMessage } from '@/lib/error-reporting'; export default function HackathonPageClient() { const router = useRouter(); @@ -84,8 +85,10 @@ export default function HackathonPageClient() { // Only show published announcements for public view setAnnouncements(data.filter(a => !a.isDraft)); } catch (error) { - // eslint-disable-next-line no-console - console.error('Failed to fetch announcements:', error); + reportError(error, { + context: 'hackathon-fetchAnnouncements', + hackathonId, + }); } finally { setAnnouncementsLoading(false); } @@ -197,8 +200,10 @@ export default function HackathonPageClient() { process.env.NODE_ENV === 'development' && currentHackathon?.enabledTabs ) { - console.warn( - `[HackathonPageClient] Tab "${tab.id}" (enabled key: ${key}) is not in currentHackathon.enabledTabs and will be hidden. Add the tab id to tabIdToEnabledKey and ensure the backend includes the key in enabledTabs when the tab should be visible.` + reportMessage( + `Tab "${tab.id}" (enabled key: ${key}) is not in currentHackathon.enabledTabs and will be hidden`, + 'warning', + { tabId: tab.id, key } ); } return isVisible; diff --git a/app/(landing)/hackathons/[slug]/announcements/[announcementId]/page.tsx b/app/(landing)/hackathons/[slug]/announcements/[announcementId]/page.tsx index 759781a5..b31da2d7 100644 --- a/app/(landing)/hackathons/[slug]/announcements/[announcementId]/page.tsx +++ b/app/(landing)/hackathons/[slug]/announcements/[announcementId]/page.tsx @@ -9,6 +9,7 @@ import { type HackathonAnnouncement, } from '@/lib/api/hackathons/index'; import { useMarkdown } from '@/hooks/use-markdown'; +import { reportError } from '@/lib/error-reporting'; import { BoundlessButton } from '@/components/buttons'; import Loading from '@/components/Loading'; import { Badge } from '@/components/ui/badge'; @@ -37,7 +38,11 @@ export default function AnnouncementDetailPage() { setAnnouncement(announcementData); setHackathonName(hackathonData.data.name); } catch (err) { - console.error('Failed to fetch details:', err); + reportError(err, { + context: 'announcement-details', + announcementId, + slug, + }); setError( 'Failed to load announcement. It may have been deleted or moved.' ); diff --git a/app/(landing)/hackathons/preview/[orgId]/[draftId]/page.tsx b/app/(landing)/hackathons/preview/[orgId]/[draftId]/page.tsx index 558a1637..2bfa90b0 100644 --- a/app/(landing)/hackathons/preview/[orgId]/[draftId]/page.tsx +++ b/app/(landing)/hackathons/preview/[orgId]/[draftId]/page.tsx @@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation'; import { ArrowLeft } from 'lucide-react'; import { getDraft, PrizeTier, VenueType } from '@/lib/api/hackathons'; import { getOrganization } from '@/lib/api/organization'; +import { reportError } from '@/lib/error-reporting'; import { HackathonBanner } from '@/components/hackathons/hackathonBanner'; import { HackathonNavTabs } from '@/components/hackathons/hackathonNavTabs'; import { HackathonOverview } from '@/components/hackathons/overview/hackathonOverview'; @@ -104,7 +105,10 @@ export default function DraftPreviewPage({ params }: PreviewPageProps) { }; } } catch (orgErr) { - console.error('Failed to fetch organization for preview:', orgErr); + reportError(orgErr, { + context: 'hackathon-preview-fetchOrg', + orgId: resolvedParams.orgId, + }); } if (response.success && response.data) { diff --git a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/announcement/page.tsx b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/announcement/page.tsx index d73e7042..60116acb 100644 --- a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/announcement/page.tsx +++ b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/announcement/page.tsx @@ -27,6 +27,7 @@ import { DialogFooter, } from '@/components/ui/dialog'; import { Switch } from '@/components/ui/switch'; +import { reportError } from '@/lib/error-reporting'; export default function AnnouncementPage() { const params = useParams(); @@ -57,7 +58,7 @@ export default function AnnouncementPage() { const data = await listAnnouncements(hackathonId); setAnnouncements(data); } catch (error) { - console.error('Failed to fetch announcements:', error); + reportError(error, { context: 'announcement-fetch', hackathonId }); toast.error('Failed to load announcements'); } finally { setIsLoading(false); @@ -100,7 +101,7 @@ export default function AnnouncementPage() { resetForm(); fetchAnnouncements(); } catch (error) { - console.error('Failed to save announcement:', error); + reportError(error, { context: 'announcement-save', hackathonId }); toast.error('Failed to save announcement'); } finally { setIsSubmitting(false); @@ -119,7 +120,7 @@ export default function AnnouncementPage() { toast.success('Announcement deleted'); fetchAnnouncements(); } catch (error) { - console.error('Failed to delete announcement:', error); + reportError(error, { context: 'announcement-delete', hackathonId }); toast.error('Failed to delete announcement'); } finally { setIsDeleteDialogOpen(false); @@ -133,7 +134,7 @@ export default function AnnouncementPage() { toast.success('Announcement published'); fetchAnnouncements(); } catch (error) { - console.error('Failed to publish announcement:', error); + reportError(error, { context: 'announcement-publish', hackathonId }); toast.error('Failed to publish announcement'); } }; diff --git a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/judging/page.tsx b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/judging/page.tsx index ceb280bb..21cfba35 100644 --- a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/judging/page.tsx +++ b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/judging/page.tsx @@ -29,6 +29,7 @@ import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { AuthGuard } from '@/components/auth/AuthGuard'; import Loading from '@/components/Loading'; +import { reportError, reportMessage } from '@/lib/error-reporting'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { JudgingCriteriaList } from '@/components/organization/hackathons/judging/JudgingCriteriaList'; import JudgingResultsTable from '@/components/organization/hackathons/judging/JudgingResultsTable'; @@ -87,9 +88,10 @@ export default function JudgingPage() { finalMembers = membersRes.data; } } catch (err) { - console.warn( - 'Legacy member fetch failed, trying Better Auth fallback:', - err + reportMessage( + 'Legacy member fetch failed, trying Better Auth fallback', + 'warning', + { error: err instanceof Error ? err.message : String(err) } ); } @@ -111,7 +113,10 @@ export default function JudgingPage() { } } } catch (err) { - console.error('All member fetching attempts failed:', err); + reportError(err, { + context: 'judging-fetchMembers', + organizationId: targetOrgId, + }); } // 2. Fetch judges @@ -121,7 +126,7 @@ export default function JudgingPage() { judges = judgesRes.data || []; } } catch (err) { - console.error('Failed to fetch judges:', err); + reportError(err, { context: 'judging-fetchJudges', hackathonId }); } setOrgMembers(finalMembers); @@ -168,7 +173,11 @@ export default function JudgingPage() { } } } catch (error: any) { - console.error('Error fetching results:', error); + reportError(error, { + context: 'judging-fetchResults', + organizationId, + hackathonId, + }); setJudgingResults([]); setJudgingSummary(null); toast.error( @@ -190,7 +199,11 @@ export default function JudgingPage() { setWinners(Array.isArray(res.data) ? res.data : []); } } catch (error) { - console.error('Error fetching winners:', error); + reportError(error, { + context: 'judging-fetchWinners', + organizationId, + hackathonId, + }); } finally { setIsFetchingWinners(false); } @@ -284,10 +297,10 @@ export default function JudgingPage() { } return sub; } catch (err) { - console.error( - `Failed to fetch details for submission ${sub.id}`, - err - ); + reportError(err, { + context: 'judging-fetchSubmissionDetails', + submissionId: sub.id, + }); return sub; } }); @@ -301,7 +314,11 @@ export default function JudgingPage() { // Handle criteria response safely setCriteria(Array.isArray(criteriaRes) ? criteriaRes : []); } catch (error) { - console.error('Judging data fetch error:', error); + reportError(error, { + context: 'judging-fetchData', + organizationId, + hackathonId, + }); toast.error('Failed to load judging data'); } finally { setIsLoading(false); @@ -331,7 +348,11 @@ export default function JudgingPage() { toast.error(res.message || 'Failed to assign judge'); } } catch (error: any) { - console.error('Error adding judge:', error); + reportError(error, { + context: 'judging-addJudge', + organizationId, + hackathonId, + }); toast.error( error.response?.data?.message || error.message || @@ -352,7 +373,11 @@ export default function JudgingPage() { toast.error(res.message || 'Failed to remove judge'); } } catch (error: any) { - console.error('Error removing judge:', error); + reportError(error, { + context: 'judging-removeJudge', + organizationId, + hackathonId, + }); toast.error( error.response?.data?.message || error.message || diff --git a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx index 77c4ee38..832add8f 100644 --- a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx +++ b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx @@ -23,6 +23,7 @@ import { import { Participant } from '@/lib/api/hackathons'; import { useDebounce } from '@/hooks/use-debounce'; import { toast } from 'sonner'; +import { reportError } from '@/lib/error-reporting'; const PAGE_SIZE = 12; @@ -146,7 +147,11 @@ const ParticipantsPage: React.FC = () => { submissionsCount: stats.totalSubmissions ?? 0, }); } catch (err) { - console.error('Failed to load statistics', err); + reportError(err, { + context: 'participants-statistics', + organizationId, + hackathonId: actualHackathonId, + }); } finally { setStatisticsLoading(false); } @@ -198,7 +203,11 @@ const ParticipantsPage: React.FC = () => { toast.error('Failed to load judging criteria'); } } catch (err) { - console.error('Failed to load criteria', err); + reportError(err, { + context: 'participants-loadCriteria', + organizationId, + hackathonId, + }); setCriteria([]); toast.error('An error occurred while loading judging criteria'); } finally { diff --git a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/submissions/page.tsx b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/submissions/page.tsx index 46f22f9d..041847e3 100644 --- a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/submissions/page.tsx +++ b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/submissions/page.tsx @@ -10,6 +10,7 @@ import Loading from '@/components/Loading'; import { SubmissionsManagement } from '@/components/organization/hackathons/submissions/SubmissionsManagement'; import { authClient } from '@/lib/auth-client'; import { getHackathon, type Hackathon } from '@/lib/api/hackathons'; +import { reportError } from '@/lib/error-reporting'; export default function SubmissionsPage() { const params = useParams(); @@ -41,7 +42,10 @@ export default function SubmissionsPage() { setHackathon(res.data); } } catch (err) { - console.error('Failed to fetch hackathon details:', err); + reportError(err, { + context: 'org-submissions-fetchHackathon', + hackathonId, + }); } }; fetchHackathonDetails(); @@ -56,7 +60,7 @@ export default function SubmissionsPage() { setCurrentUserId(session.user.id); } } catch (err) { - console.error('Failed to fetch session:', err); + reportError(err, { context: 'org-submissions-fetchSession' }); } }; fetchSession(); diff --git a/app/(landing)/projects/[slug]/page.tsx b/app/(landing)/projects/[slug]/page.tsx index aa7271a4..e79be2de 100644 --- a/app/(landing)/projects/[slug]/page.tsx +++ b/app/(landing)/projects/[slug]/page.tsx @@ -1,5 +1,6 @@ 'use client'; import { ProjectLayout } from '@/components/project-details/project-layout'; +import { reportError } from '@/lib/error-reporting'; import { ProjectLoading } from '@/components/project-details/project-loading'; import { getCrowdfundingProject } from '@/features/projects/api'; import type { Crowdfunding } from '@/features/projects/types'; @@ -51,7 +52,10 @@ function ProjectContent({ hackathon = hackathonRes.data; } } catch (err) { - console.error('Failed to fetch hackathon details', err); + reportError(err, { + context: 'project-fetchHackathonDetails', + submissionId: id, + }); } if (hackathon) { @@ -69,7 +73,7 @@ function ProjectContent({ } throw new Error('Submission not found'); } catch (e) { - console.error('Failed to fetch submission:', e); + reportError(e, { context: 'project-fetchSubmission', id }); throw e; } }; @@ -93,7 +97,8 @@ function ProjectContent({ } catch (e) { await fetchSubmission(id); } - } catch { + } catch (err) { + reportError(err, { context: 'project-fetch', id }); setError('Failed to fetch project data'); } finally { setLoading(false); diff --git a/app/api/auth/test/route.ts b/app/api/auth/test/route.ts index 26f8ba60..6b510af2 100644 --- a/app/api/auth/test/route.ts +++ b/app/api/auth/test/route.ts @@ -1,6 +1,12 @@ import { NextResponse } from 'next/server'; +const isProduction = process.env.NODE_ENV === 'production'; + export async function GET() { + if (isProduction) { + return NextResponse.json({ message: 'Not found' }, { status: 404 }); + } + // Normalize API URL: remove trailing slash and /api if present // The env var should be base URL without /api (e.g., https://api.boundlessfi.xyz) let apiUrl = @@ -37,6 +43,10 @@ export async function GET() { } export async function POST(req: Request) { + if (isProduction) { + return NextResponse.json({ message: 'Not found' }, { status: 404 }); + } + try { const body = await req.json(); const { email, password } = body; diff --git a/app/api/proxy/[...path]/route.ts b/app/api/proxy/[...path]/route.ts index a96b839d..655dfe6d 100644 --- a/app/api/proxy/[...path]/route.ts +++ b/app/api/proxy/[...path]/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; +import { reportError } from '@/lib/error-reporting'; const getBackendUrl = () => { // Normalize API URL: remove trailing slash and /api if present @@ -97,7 +98,9 @@ async function handleRequest( body = await request.text(); } } - } catch {} + } catch (err) { + reportError(err, { context: 'proxy-parseRequestBody' }); + } } // Forward headers diff --git a/app/error.tsx b/app/error.tsx index 0e3e6826..aba38ad1 100644 --- a/app/error.tsx +++ b/app/error.tsx @@ -1,7 +1,9 @@ 'use client'; -import React from 'react'; + +import React, { useEffect } from 'react'; import { BoundlessButton } from '@/components/buttons'; import { AlertTriangle, RefreshCw, Home, ArrowLeft } from 'lucide-react'; +import { reportError } from '@/lib/error-reporting'; interface ErrorProps { error: Error & { digest?: string }; @@ -9,6 +11,13 @@ interface ErrorProps { } const Error: React.FC = ({ error, reset }) => { + useEffect(() => { + reportError(error, { + digest: error.digest, + message: error.message, + }); + }, [error]); + const handleReset = () => { reset(); }; @@ -38,8 +47,9 @@ const Error: React.FC = ({ error, reset }) => { {/* Error Message */}

- We encountered an unexpected error. Don't worry, our team has - been notified and is working to fix it. + We encountered an unexpected error. If you have error reporting + enabled, our team has been notified. Otherwise, try again or contact + support.

{/* Error Details (Development only) */} diff --git a/app/global-error.tsx b/app/global-error.tsx new file mode 100644 index 00000000..6123e337 --- /dev/null +++ b/app/global-error.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { useEffect } from 'react'; +import { reportError } from '@/lib/error-reporting'; + +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + reportError(error, { + digest: error.digest, + message: error.message, + source: 'global-error', + }); + }, [error]); + + return ( + + +
+

+ Something went wrong +

+

+ We've recorded this error. Please try again or contact support if it + continues. +

+ +

+ + Go to home + +

+
+ + + ); +} diff --git a/app/me/earnings/page.tsx b/app/me/earnings/page.tsx index 3c0c7f7c..d2ed4d15 100644 --- a/app/me/earnings/page.tsx +++ b/app/me/earnings/page.tsx @@ -19,6 +19,7 @@ import { CardTitle, } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; +import { reportError } from '@/lib/error-reporting'; import { getUserEarnings, EarningsData, @@ -154,7 +155,7 @@ const EarningsPage: React.FC = () => { toast.error(res.error || 'Failed to load earnings data'); } } catch (error) { - console.error('Failed to fetch earnings:', error); + reportError(error, { context: 'me-earnings-fetch' }); toast.error('Failed to load earnings data'); } finally { setLoading(false); diff --git a/app/me/layout.tsx b/app/me/layout.tsx index a2ad6e87..0560658f 100644 --- a/app/me/layout.tsx +++ b/app/me/layout.tsx @@ -4,7 +4,7 @@ import { AppSidebar } from '@/components/app-sidebar'; import { SiteHeader } from '@/components/site-header'; import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; import { useAuthStatus } from '@/hooks/use-auth'; -import React, { useMemo } from 'react'; +import React, { useMemo, useRef, useEffect } from 'react'; import LoadingSpinner from '@/components/LoadingSpinner'; interface MeLayoutProps { @@ -33,6 +33,13 @@ const getId = (item: ProfileItemWithId): string | undefined => const MeLayout = ({ children }: MeLayoutProps): React.ReactElement => { const { user, isLoading } = useAuthStatus(); + // Track whether we've completed the very first load. + // This prevents children from unmounting during background session refetches + // (e.g. on window focus), which would destroy component state like 2FA steps. + const hasLoadedOnce = useRef(false); + useEffect(() => { + if (!isLoading) hasLoadedOnce.current = true; + }, [isLoading]); const { name = '', email = '', profile, image: userImage = '' } = user || {}; const typedProfile = profile as MeLayoutProfile | null | undefined; @@ -62,7 +69,8 @@ const MeLayout = ({ children }: MeLayoutProps): React.ReactElement => { }).length; }, [typedProfile]); - if (isLoading) { + // Only show full-screen spinner on first load, not on background refetches + if (isLoading && !hasLoadedOnce.current) { return (
diff --git a/app/me/messages/page.tsx b/app/me/messages/page.tsx new file mode 100644 index 00000000..e308e9cd --- /dev/null +++ b/app/me/messages/page.tsx @@ -0,0 +1,25 @@ +'use client'; + +import { useEffect } from 'react'; +import { useMessages } from '@/components/messages/MessagesProvider'; +import { AuthGuard } from '@/components/auth'; +import Loading from '@/components/Loading'; + +export default function MessagesPage() { + const { openMessages } = useMessages(); + + useEffect(() => { + openMessages(); + }, [openMessages]); + + return ( + }> +
+

+ Use the messages icon in the header to open your conversations, or + select a conversation above. +

+
+
+ ); +} diff --git a/app/me/notifications/components/NotificationDetailSheet.tsx b/app/me/notifications/components/NotificationDetailSheet.tsx index d9934028..51fe360e 100644 --- a/app/me/notifications/components/NotificationDetailSheet.tsx +++ b/app/me/notifications/components/NotificationDetailSheet.tsx @@ -39,7 +39,7 @@ export const NotificationDetailSheet = ({ const Icon = getNotificationIcon(notification.type); const createdAt = new Date(notification.createdAt); - const metadata = Object.entries(notification.data).filter( + const metadata = Object.entries(notification.data ?? {}).filter( ([key, value]) => value !== undefined && value !== null && diff --git a/app/me/notifications/components/NotificationFeedItem.tsx b/app/me/notifications/components/NotificationFeedItem.tsx index 42620eba..cd2546ed 100644 --- a/app/me/notifications/components/NotificationFeedItem.tsx +++ b/app/me/notifications/components/NotificationFeedItem.tsx @@ -70,10 +70,10 @@ export const NotificationFeedItem = ({ {notification.message}

- {notification.data.amount && ( + {notification.data?.amount != null && (
$ - {notification.data.amount.toLocaleString()} + {notification.data?.amount.toLocaleString()}
)}
diff --git a/app/me/notifications/page.tsx b/app/me/notifications/page.tsx index cb82075a..8b7e58fa 100644 --- a/app/me/notifications/page.tsx +++ b/app/me/notifications/page.tsx @@ -4,7 +4,8 @@ import { useState, useCallback, useEffect, useMemo } from 'react'; import { useNotifications } from '@/hooks/useNotifications'; import { useNotificationPolling } from '@/hooks/use-notification-polling'; import { useNotificationStore } from '@/lib/stores/notification-store'; -import { Notification } from '@/types/notifications'; +import { Notification, NotificationType } from '@/types/notifications'; +import { useMessages } from '@/components/messages/MessagesProvider'; import { NotificationDetailSheet } from './components/NotificationDetailSheet'; import { NotificationSection } from './components/NotificationSection'; import { Button } from '@/components/ui/button'; @@ -87,6 +88,7 @@ export default function NotificationsPage() { const [isMarkingAll, setIsMarkingAll] = useState(false); const limit = 20; + const { openMessages } = useMessages(); const notificationsHook = useNotifications({ page, limit, autoFetch: true }); const { @@ -120,6 +122,18 @@ export default function NotificationsPage() { const handleNotificationClick = useCallback( (notification: Notification) => { + if ( + notification.type === NotificationType.DIRECT_MESSAGE && + notification.data?.conversationId + ) { + openMessages(notification.data?.conversationId as string); + setSheetOpen(false); + if (!notification.read) { + markNotificationAsRead([notification.id]).catch(() => {}); + } + return; + } + setSelectedNotification(notification); setSheetOpen(true); @@ -127,7 +141,7 @@ export default function NotificationsPage() { markNotificationAsRead([notification.id]).catch(() => {}); } }, - [markNotificationAsRead] + [markNotificationAsRead, openMessages] ); const handleMarkAllAsRead = useCallback(async () => { diff --git a/app/me/projects/page.tsx b/app/me/projects/page.tsx index 4abc7580..4c61e8f1 100644 --- a/app/me/projects/page.tsx +++ b/app/me/projects/page.tsx @@ -8,6 +8,7 @@ import { Card, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Package, ArrowLeft } from 'lucide-react'; import { useAuthStatus } from '@/hooks/use-auth'; +import { reportError } from '@/lib/error-reporting'; import ProjectCard from '@/features/projects/components/ProjectCard'; // ... (existing imports) @@ -22,8 +23,8 @@ export default function MyProjectsPage() { try { const data = await getMe(); setMeData(data); - } catch { - // console.error('Failed to fetch user data:', error); + } catch (err) { + reportError(err, { context: 'me-projects-fetchUser' }); } finally { setLoading(false); } diff --git a/app/me/settings/SettingsContent.tsx b/app/me/settings/SettingsContent.tsx index 2f0ad701..0f24738d 100644 --- a/app/me/settings/SettingsContent.tsx +++ b/app/me/settings/SettingsContent.tsx @@ -8,14 +8,20 @@ import { getMe } from '@/lib/api/auth'; import { GetMeResponse } from '@/lib/api/types'; import { Skeleton } from '@/components/ui/skeleton'; import Settings from '@/components/profile/update/Settings'; +import TwoFactorTab from '@/components/profile/update/TwoFactorTab'; +import SecurityTab from '@/components/profile/update/SecurityTab'; import { IdentityVerificationSection } from '@/components/didit/IdentityVerificationSection'; import { invalidateAuthProfileCache } from '@/hooks/use-auth'; +import { useRef } from 'react'; +import { Loader2 } from 'lucide-react'; const SettingsContent = () => { const searchParams = useSearchParams(); const fromVerification = searchParams.get('verification') === 'complete'; const [userData, setUserData] = useState(null); const [isLoading, setIsLoading] = useState(true); + // Prevent unmounting tabs on background refetches (e.g. after 2FA enable) + const hasLoadedOnce = useRef(false); const fetchUserData = useCallback(async () => { try { @@ -25,11 +31,15 @@ const SettingsContent = () => { setUserData(null); } finally { setIsLoading(false); + hasLoadedOnce.current = true; } }, []); useEffect(() => { - setIsLoading(true); + // Only set isLoading true on the very first fetch + if (!hasLoadedOnce.current) { + setIsLoading(true); + } fetchUserData(); }, [fetchUserData]); @@ -38,7 +48,8 @@ const SettingsContent = () => { invalidateAuthProfileCache(); }, [fetchUserData]); - if (isLoading) { + // Only show skeleton on first load — not on background refetches + if (isLoading && !hasLoadedOnce.current) { return (
@@ -112,11 +123,52 @@ const SettingsContent = () => { - + {userData?.user ? ( + + ) : ( +
+ + Loading profile... +
+ )}
+ + + + + + + + + + + {userData?.user ? ( + + ) : ( +
+ + + Loading security settings... + +
+ )} +
+ + {userData?.user ? ( + + ) : ( +
+ + Loading 2FA settings... +
+ )} +
{ return ( -
+
@@ -22,9 +23,12 @@ const NotFound = () => {
diff --git a/app/providers.tsx b/app/providers.tsx index b1630424..942454cd 100644 --- a/app/providers.tsx +++ b/app/providers.tsx @@ -4,6 +4,7 @@ import { ReactNode } from 'react'; import { AuthProvider } from '@/components/providers/auth-provider'; import { SocketProvider } from '@/components/providers/socket-provider'; import { WalletProvider } from '@/components/providers/wallet-provider'; +import { MessagesProvider } from '@/components/messages/MessagesProvider'; import { TrustlessWorkProvider } from '@/lib/providers/TrustlessWorkProvider'; import { EscrowProvider } from '@/lib/providers/EscrowProvider'; interface ProvidersProps { @@ -15,9 +16,11 @@ export function Providers({ children }: ProvidersProps) { - - {children} - + + + {children} + + diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index eea9e5cc..ec78d978 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -8,6 +8,7 @@ import { IconDashboard, IconFileText, IconFolder, + IconMessageCircle, IconSettings, IconShieldCheck, IconUserCircle, @@ -97,6 +98,11 @@ const getNavigationData = (counts?: { url: '/me/profile', icon: IconUserCircle, }, + { + title: 'Messages', + url: '/me/messages', + icon: IconMessageCircle, + }, { title: 'Settings', url: '/me/settings', diff --git a/components/auth/LoginWrapper.tsx b/components/auth/LoginWrapper.tsx index 3c141bb3..b000c40c 100644 --- a/components/auth/LoginWrapper.tsx +++ b/components/auth/LoginWrapper.tsx @@ -7,6 +7,7 @@ import { useForm } from 'react-hook-form'; import { toast } from 'sonner'; import z from 'zod'; import LoginForm from './LoginForm'; +import TwoFactorVerify from './TwoFactorVerify'; import { authClient } from '@/lib/auth-client'; import { useAuthStore } from '@/lib/stores/auth-store'; @@ -32,6 +33,7 @@ const LoginWrapper = ({ setLoadingState }: LoginWrapperProps) => { const [showPassword, setShowPassword] = useState(false); const [isLoading, setIsLoading] = useState(false); const [lastMethod, setLastMethod] = useState(null); + const [twoFactorRequired, setTwoFactorRequired] = useState(false); const callbackUrl = searchParams.get('callbackUrl') ? decodeURIComponent(searchParams.get('callbackUrl')!) @@ -42,16 +44,6 @@ const LoginWrapper = ({ setLoadingState }: LoginWrapperProps) => { setLastMethod(method); }, []); - useEffect(() => { - const method = authClient.getLastUsedLoginMethod(); - setLastMethod(method); - }, []); - - useEffect(() => { - const method = authClient.getLastUsedLoginMethod(); - setLastMethod(method); - }, []); - const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { @@ -89,6 +81,17 @@ const LoginWrapper = ({ setLoadingState }: LoginWrapperProps) => { const errorStatus = error.status; const errorCode = error.code; + // Check for 2FA requirement + if ( + errorStatus === 403 && + (errorMessage === 'two_factor_required' || + errorMessage?.includes('two_factor') || + errorCode === 'TWO_FACTOR_REQUIRED') + ) { + setTwoFactorRequired(true); + return; + } + if (errorStatus === 403 || errorCode === 'FORBIDDEN') { const message = 'Please verify your email before signing in. Check your inbox for a verification link.'; @@ -171,8 +174,36 @@ const LoginWrapper = ({ setLoadingState }: LoginWrapperProps) => { setIsLoading(true); setLoadingState(true); + const syncSession = async () => { + const session = await authClient.getSession(); + if (session && typeof session === 'object' && 'user' in session) { + const sessionUser = session.user as + | { + id: string; + email: string; + name?: string | null; + image?: string | null; + } + | null + | undefined; + + if (sessionUser && sessionUser.id && sessionUser.email) { + const authStore = useAuthStore.getState(); + await authStore.syncWithSession({ + id: sessionUser.id, + email: sessionUser.email, + name: sessionUser.name || undefined, + image: sessionUser.image || undefined, + role: 'USER', + username: undefined, + accessToken: undefined, + }); + } + } + }; + try { - const { error } = await authClient.signIn.email( + const { data, error } = await authClient.signIn.email( { email: values.email, password: values.password, @@ -184,42 +215,26 @@ const LoginWrapper = ({ setLoadingState }: LoginWrapperProps) => { setIsLoading(true); setLoadingState(true); }, - onSuccess: async () => { - await new Promise(resolve => setTimeout(resolve, 200)); + onSuccess: async ctx => { + const resData = ctx?.data as + | { + twoFactorRequired?: boolean; + twoFactorRedirect?: boolean; + } + | undefined; - const session = await authClient.getSession(); - - if (session && typeof session === 'object' && 'user' in session) { - const sessionUser = session.user as - | { - id: string; - email: string; - name?: string | null; - image?: string | null; - } - | null - | undefined; - - if (sessionUser && sessionUser.id && sessionUser.email) { - const authStore = useAuthStore.getState(); - await authStore.syncWithSession({ - id: sessionUser.id, - email: sessionUser.email, - name: sessionUser.name || undefined, - image: sessionUser.image || undefined, - role: 'USER', - username: undefined, - accessToken: undefined, - }); - } + if (resData?.twoFactorRequired || resData?.twoFactorRedirect) { + setTwoFactorRequired(true); + setIsLoading(false); + setLoadingState(false); + return; } - // Keep loading state active during redirect - // The page will unmount when redirecting, so no need to set false + await new Promise(resolve => setTimeout(resolve, 200)); + await syncSession(); window.location.href = callbackUrl; }, onError: ctx => { - // Handle error from Better Auth callback const errorObj = ctx.error || ctx; handleAuthError( typeof errorObj === 'object' @@ -233,14 +248,21 @@ const LoginWrapper = ({ setLoadingState }: LoginWrapperProps) => { } ); - // Handle error from return value if (error) { handleAuthError(error, values); setIsLoading(false); setLoadingState(false); + } else if ( + (data as { twoFactorRequired?: boolean; twoFactorRedirect?: boolean }) + ?.twoFactorRequired || + (data as { twoFactorRequired?: boolean; twoFactorRedirect?: boolean }) + ?.twoFactorRedirect + ) { + setTwoFactorRequired(true); + setIsLoading(false); + setLoadingState(false); } } catch (error) { - // Handle unexpected errors const errorObj = error instanceof Error ? { message: error.message, status: undefined, code: undefined } @@ -254,6 +276,17 @@ const LoginWrapper = ({ setLoadingState }: LoginWrapperProps) => { [handleAuthError, setLoadingState, callbackUrl] ); + if (twoFactorRequired) { + return ( + { + window.location.href = callbackUrl; + }} + onCancel={() => setTwoFactorRequired(false)} + /> + ); + } + return ( Promise; + onCancel: () => void; +} + +const TwoFactorVerify = ({ onSuccess, onCancel }: TwoFactorVerifyProps) => { + const [code, setCode] = useState(''); + const [backupCode, setBackupCode] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isBackupMode, setIsBackupMode] = useState(false); + + const handleVerify = async (codeValue: string) => { + if (codeValue.length < 6) return; + + setIsLoading(true); + try { + const { data, error } = await authClient.twoFactor.verifyTotp({ + code: codeValue, + }); + + if (error) { + toast.error(error.message || 'Verification failed'); + setCode(''); // Clear on error to let user try again + return; + } + + if (data) { + toast.success('Verification successful'); + await onSuccess(); + } + } catch (err) { + toast.error('An unexpected error occurred during verification'); + } finally { + setIsLoading(false); + } + }; + + const handleVerifyBackupCode = async (e?: React.FormEvent) => { + e?.preventDefault(); + if (!backupCode) { + toast.error('Please enter a backup code'); + return; + } + + setIsLoading(true); + try { + const { data, error } = await authClient.twoFactor.verifyBackupCode({ + code: backupCode.trim(), + }); + + if (error) { + toast.error(error.message || 'Verification failed'); + setBackupCode(''); + return; + } + + if (data) { + toast.success('Recovery successful'); + setBackupCode(''); + await onSuccess(); + } + } catch (err) { + toast.error('An unexpected error occurred during verification'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+
+ +
+
+

+ {isBackupMode ? 'Account Recovery' : 'Two-Factor Authentication'} +

+

+ {isBackupMode + ? 'Enter a one-time backup code to access your account.' + : 'Enter the 6-digit verification code from your authenticator app.'} +

+
+ +
+ {!isBackupMode ? ( +
+
+ { + setCode(val); + if (val.length === 6) { + handleVerify(val); + } + }} + disabled={isLoading} + autoFocus + > + + {[...Array(6)].map((_, i) => ( + + ))} + + +
+ + +
+ ) : ( +
+
+
+ setBackupCode(e.target.value)} + className='h-14 w-full rounded-lg border border-zinc-800 bg-zinc-900/50 text-center text-xl tracking-widest text-white focus:border-[#a7f950]/50 focus:outline-none' + autoFocus + /> +
+ +
+ + +
+ )} + +
+ +
+
+
+ ); +}; + +export default TwoFactorVerify; diff --git a/components/auth/login-form.tsx b/components/auth/login-form.tsx index 2a27932e..6cd6b3f1 100644 --- a/components/auth/login-form.tsx +++ b/components/auth/login-form.tsx @@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { authClient } from '@/lib/auth-client'; import { toast } from 'sonner'; +import { reportError } from '@/lib/error-reporting'; const loginSchema = z.object({ email: z.string().email('Please enter a valid email'), @@ -55,7 +56,12 @@ export function LoginForm({ onSuccess, onError }: LoginFormProps) { ? errorObj.message : 'Login failed. Please try again.'; - console.error('Login error:', errorObj); + reportError( + errorObj instanceof Error + ? errorObj + : new Error(String(errorObj)), + { context: 'login-callback' } + ); onError?.(errorMessage); toast.error(errorMessage); }, @@ -64,7 +70,7 @@ export function LoginForm({ onSuccess, onError }: LoginFormProps) { if (error) { const errorMessage = error.message || 'Login failed. Please try again.'; - console.error('Login error:', error); + reportError(error, { context: 'login-error' }); onError?.(errorMessage); toast.error(errorMessage); } @@ -73,7 +79,9 @@ export function LoginForm({ onSuccess, onError }: LoginFormProps) { error instanceof Error ? error.message : 'Login failed. Please try again.'; - console.error('Login error:', error); + reportError(error instanceof Error ? error : new Error(String(error)), { + context: 'login-catch', + }); onError?.(errorMessage); toast.error(errorMessage); } finally { diff --git a/components/connect-wallet/index.tsx b/components/connect-wallet/index.tsx deleted file mode 100644 index f14b298b..00000000 --- a/components/connect-wallet/index.tsx +++ /dev/null @@ -1,257 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogTitle, -} from '../ui/dialog'; -import WalletCard from './wallet-card'; - -import { ScrollArea } from '../ui/scroll-area'; -import { Button } from '../ui/button'; -import { useWalletStore } from '@/hooks/use-wallet'; -import { toast } from 'sonner'; -import { AlertCircle, Loader2, X } from 'lucide-react'; - -import { getCurrentNetwork } from '@/lib/wallet-utils'; - -const ConnectWallet = ({ - open, - onOpenChange, - onConnect, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; - onConnect?: () => void; -}) => { - const [selectedNetwork, setSelectedNetwork] = useState(getCurrentNetwork()); - const [isConnecting, setIsConnecting] = useState(false); - const [connectingWallet, setConnectingWallet] = useState(null); - - const { - network, - availableWallets, - isConnected, - isLoading, - error, - initializeWalletKit, - connectWallet, - clearError, - } = useWalletStore(); - - // Initialize wallet kit when modal opens - useEffect(() => { - if (open && !isConnected) { - initializeWalletKit(selectedNetwork as 'testnet' | 'public').catch(() => { - // Silently handle initialization errors - // These are expected when wallet is not available - }); - } - }, [open, isConnected, initializeWalletKit, selectedNetwork]); - - // Handle network selection - useEffect(() => { - setSelectedNetwork(network); - }, [network]); - - const handleWalletSelect = async (walletId: string) => { - setIsConnecting(true); - setConnectingWallet(walletId); - clearError(); - - try { - // Show specific instructions for different wallets - const walletInstructions = { - freighter: 'Please unlock Freighter and approve the connection', - albedo: - 'Albedo will open in a new window. Please approve the connection', - rabet: 'Please unlock Rabet and approve the connection', - xbull: 'Please unlock xBull and approve the connection', - lobstr: 'Please unlock Lobstr and approve the connection', - hana: 'Please unlock Hana and approve the connection', - 'hot-wallet': - 'Please unlock your hardware wallet and approve the connection', - }; - - const instruction = - walletInstructions[walletId as keyof typeof walletInstructions] || - 'Please approve the connection'; - - toast.info(instruction, { - duration: 5000, - }); - - await connectWallet(walletId); - - toast.success('Wallet connected successfully!', { - description: `Connected to ${network === 'testnet' ? 'Testnet' : 'Public'} network`, - }); - onOpenChange(false); - onConnect?.(); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : 'Failed to connect wallet'; - - // Provide specific error messages for different wallets - let specificError = errorMessage; - if (errorMessage.includes('not available')) { - specificError = `${walletId} is not installed or not available. Please install it first.`; - } else if (errorMessage.includes('permission')) { - specificError = `Permission denied. Please unlock ${walletId} and try again.`; - } else if (errorMessage.includes('network')) { - specificError = `Network mismatch. Please switch ${walletId} to ${selectedNetwork} network.`; - } - - toast.error('Connection failed', { - description: specificError, - duration: 8000, - }); - } finally { - setIsConnecting(false); - setConnectingWallet(null); - } - }; - - // Filter available wallets and map them to our UI format - const walletOptions = availableWallets - .filter((wallet: { isAvailable: boolean }) => wallet.isAvailable) - .map((wallet: { id: string; name: string; icon: string }) => ({ - id: wallet.id, - name: wallet.name, - icon: wallet.icon, - disabled: false, - })); - - // Fallback wallets if none are available - const fallbackWallets = [ - { - id: 'freighter', - name: 'Freighter', - icon: '/wallets/freighter.svg', - disabled: false, - }, - { - id: 'albedo', - name: 'Albedo', - icon: '/wallets/albedo.svg', - disabled: false, - }, - { - id: 'rabet', - name: 'Rabet', - icon: '/wallets/rabet.svg', - disabled: false, - }, - { - id: 'xbull', - name: 'xBull', - icon: '/wallets/xbull.svg', - disabled: false, - }, - { - id: 'lobstr', - name: 'Lobstr', - icon: '/wallets/lobstr.svg', - disabled: false, - }, - { - id: 'hana', - name: 'Hana', - icon: '/wallets/hana.svg', - disabled: false, - }, - { - id: 'hot-wallet', - name: 'HOT Wallet', - icon: '/wallets/hot-wallet.svg', - disabled: false, - }, - ]; - - const wallets = walletOptions.length > 0 ? walletOptions : fallbackWallets; - - return ( - - - {/* Header */} -
-
- - Connect Wallet - -
- -
- - - -
-
-

Select Wallet

- {isLoading && ( -
- - Loading wallets... -
- )} -
- - {/* Connection Status */} - {isConnecting && connectingWallet && ( -
- - - Connecting to {connectingWallet}... - -
- )} - - -
- {wallets.map( - (wallet: { - id: string; - name: string; - icon: string; - disabled?: boolean; - }) => ( - handleWalletSelect(wallet.id)} - icon={wallet.icon} - label={wallet.name} - /> - ) - )} -
-
-
- - {/* Error Display */} - {error && ( -
-
- - - Connection Error - -
-

{error}

-
- )} -
-
- ); -}; - -export default ConnectWallet; diff --git a/components/connect-wallet/wallet-card.tsx b/components/connect-wallet/wallet-card.tsx deleted file mode 100644 index c12deab6..00000000 --- a/components/connect-wallet/wallet-card.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import Image from 'next/image'; -import React from 'react'; - -interface WalletCardProps { - disabled: boolean; - onClick: () => void; - icon: string; - label: string; -} - -const WalletCard = ({ disabled, onClick, icon, label }: WalletCardProps) => { - return ( - - ); -}; - -export default WalletCard; diff --git a/components/crowdfunding/campaign-comments-tab.tsx b/components/crowdfunding/campaign-comments-tab.tsx index f61d6f7a..80779a9f 100644 --- a/components/crowdfunding/campaign-comments-tab.tsx +++ b/components/crowdfunding/campaign-comments-tab.tsx @@ -13,6 +13,7 @@ import { ReportReason, } from '@/types/comment'; import { useAuth } from '@/hooks/use-auth'; +import { reportError } from '@/lib/error-reporting'; import { Loader2, MessageCircle } from 'lucide-react'; interface CampaignCommentsTabProps { @@ -90,7 +91,7 @@ export function CampaignCommentsTab({ campaignId }: CampaignCommentsTabProps) { }); commentsHook.refetch(); } catch (error) { - console.error('Failed to add comment:', error); + reportError(error, { context: 'campaign-addComment', campaignId }); } }; @@ -104,7 +105,7 @@ export function CampaignCommentsTab({ campaignId }: CampaignCommentsTabProps) { }); commentsHook.refetch(); } catch (error) { - console.error('Failed to add reply:', error); + reportError(error, { context: 'campaign-addReply', campaignId }); } }; @@ -121,7 +122,7 @@ export function CampaignCommentsTab({ campaignId }: CampaignCommentsTabProps) { description, }); } catch (error) { - console.error('Failed to report comment:', error); + reportError(error, { context: 'campaign-reportComment', campaignId }); } }; @@ -186,7 +187,10 @@ export function CampaignCommentsTab({ campaignId }: CampaignCommentsTabProps) { }); commentsHook.refetch(); } catch (error) { - console.error('Failed to update comment:', error); + reportError(error, { + context: 'campaign-updateComment', + campaignId, + }); } }} onDelete={async commentId => { @@ -194,7 +198,10 @@ export function CampaignCommentsTab({ campaignId }: CampaignCommentsTabProps) { await deleteCommentHook.deleteComment(commentId); commentsHook.refetch(); } catch (error) { - console.error('Failed to delete comment:', error); + reportError(error, { + context: 'campaign-deleteComment', + campaignId, + }); } }} onReport={handleReportComment} diff --git a/components/escrow/ApproveMilestone.tsx b/components/escrow/ApproveMilestone.tsx index 9f597cbf..f4b2abd4 100644 --- a/components/escrow/ApproveMilestone.tsx +++ b/components/escrow/ApproveMilestone.tsx @@ -27,6 +27,7 @@ import { } from '@trustless-work/escrow'; import { toast } from 'sonner'; import { Loader2, CheckCircle2, XCircle } from 'lucide-react'; +import { reportError } from '@/lib/error-reporting'; /** * Component to approve milestones in a multi-release escrow @@ -176,7 +177,8 @@ export const ApproveMilestone = () => { duration: 5000, } ); - } catch { + } catch (err) { + reportError(err, { context: 'escrow-approveMilestone' }); toast.error('Failed to approve milestone'); } finally { setIsLoading(null); diff --git a/components/escrow/ChangeMilestoneStatus.tsx b/components/escrow/ChangeMilestoneStatus.tsx index dd2a7e7c..4c347cb0 100644 --- a/components/escrow/ChangeMilestoneStatus.tsx +++ b/components/escrow/ChangeMilestoneStatus.tsx @@ -25,6 +25,7 @@ import { MultiReleaseMilestone, } from '@trustless-work/escrow'; import { toast } from 'sonner'; +import { reportError } from '@/lib/error-reporting'; import { Loader2, CheckCircle2, @@ -237,7 +238,8 @@ export const ChangeMilestoneStatus = () => { `Milestone ${milestoneIndex + 1} status changed to ${newStatus}` ); setSelectedMilestone(null); - } catch { + } catch (err) { + reportError(err, { context: 'escrow-changeMilestoneStatus' }); toast.error('Failed to change milestone status'); } finally { setIsLoading(null); diff --git a/components/escrow/FundEscrowButton.tsx b/components/escrow/FundEscrowButton.tsx index 192f14ef..54652343 100644 --- a/components/escrow/FundEscrowButton.tsx +++ b/components/escrow/FundEscrowButton.tsx @@ -14,6 +14,7 @@ import { MultiReleaseEscrow, } from '@trustless-work/escrow'; import { toast } from 'sonner'; +import { reportError } from '@/lib/error-reporting'; // Extended type to include balance property that may exist at runtime // Using intersection type to avoid type conflicts with required balance property @@ -166,7 +167,8 @@ export const FundEscrowButton = () => { }); toast.success('Escrow funded successfully!'); - } catch { + } catch (err) { + reportError(err, { context: 'escrow-fund' }); setFundingStatus({ success: false, message: 'Failed to fund escrow', diff --git a/components/escrow/InitializeEscrowButton.tsx b/components/escrow/InitializeEscrowButton.tsx index 4036a4db..bd281840 100644 --- a/components/escrow/InitializeEscrowButton.tsx +++ b/components/escrow/InitializeEscrowButton.tsx @@ -19,6 +19,7 @@ import { } from '@trustless-work/escrow'; import { toast } from 'sonner'; import { Loader2 } from 'lucide-react'; +import { reportError } from '@/lib/error-reporting'; /** * Component to initialize a multi-release escrow using Trustless Work @@ -149,7 +150,8 @@ export const InitializeEscrowButton = () => { } else { throw new Error('Missing contractId or escrow in response'); } - } catch { + } catch (err) { + reportError(err, { context: 'escrow-initialize' }); toast.error('Failed to initialize escrow'); } finally { setIsLoading(false); diff --git a/components/hackathons/participants/profileCard.tsx b/components/hackathons/participants/profileCard.tsx index 2b53e588..080c554c 100644 --- a/components/hackathons/participants/profileCard.tsx +++ b/components/hackathons/participants/profileCard.tsx @@ -1,5 +1,5 @@ 'use client'; -import { useState, useMemo } from 'react'; +import { useState, useMemo, useEffect } from 'react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent } from '@/components/ui/card'; @@ -13,23 +13,21 @@ import { CheckCircle2, UserPlus, Info, + Loader2, } from 'lucide-react'; import { useParticipants } from '@/hooks/hackathon/use-participants'; import Link from 'next/link'; import { useAuthStatus } from '@/hooks/use-auth'; +import { useMessages } from '@/components/messages/MessagesProvider'; +import { createConversation } from '@/lib/api/messages'; import { useHackathonData } from '@/lib/providers/hackathonProvider'; import { InviteUserModal } from '../team-formation/InviteUserModal'; import { useFollow } from '@/hooks/use-follow'; import { useFollowStats } from '@/hooks/use-follow-stats'; import { getUserProfileByUsername } from '@/lib/api/auth'; +import { reportError } from '@/lib/error-reporting'; import type { PublicUserProfile } from '@/features/projects/types'; -import { useEffect } from 'react'; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@/components/ui/tooltip'; +import { toast } from 'sonner'; const BRAND_COLOR = '#a7f950'; @@ -62,6 +60,9 @@ export function ProfileCard({ participant, onInviteClick }: ProfileCardProps) { const [profileData, setProfileData] = useState( null ); + const [messageLoading, setMessageLoading] = useState(false); + const { user } = useAuthStatus(); + const { openMessages } = useMessages(); const { toggleFollow, isFollowing, @@ -72,6 +73,27 @@ export function ProfileCard({ participant, onInviteClick }: ProfileCardProps) { ); const { participants, allParticipants, teams } = useParticipants(); + const isOwnProfile = user?.id === participant.userId; + + const handleMessageClick = async (e: React.MouseEvent) => { + e.stopPropagation(); + if (isOwnProfile || messageLoading) return; + setMessageLoading(true); + try { + const { conversation } = await createConversation(participant.userId); + openMessages(conversation.id); + } catch (err) { + reportError(err, { + context: 'profile-card-message', + userId: participant.userId, + }); + toast.error( + err instanceof Error ? err.message : 'Failed to start conversation' + ); + } finally { + setMessageLoading(false); + } + }; useEffect(() => { const fetchProfile = async () => { @@ -81,7 +103,10 @@ export function ProfileCard({ participant, onInviteClick }: ProfileCardProps) { setProfileData(data); } } catch (error) { - console.error('Error fetching profile data:', error); + reportError(error, { + context: 'participant-profile', + username: participant.username, + }); } }; fetchProfile(); @@ -98,7 +123,6 @@ export function ProfileCard({ participant, onInviteClick }: ProfileCardProps) { const isTeamLeader = participant.role === 'leader' && participant.teamId; - const { user } = useAuthStatus(); const { currentHackathon } = useHackathonData(); const currentUserParticipant = useMemo(() => { @@ -254,24 +278,22 @@ export function ProfileCard({ participant, onInviteClick }: ProfileCardProps) { > {isFollowLoading ? '...' : isFollowing ? 'Following' : 'Follow'} - - - -
- -
-
- - Messaging coming soon - -
-
+ {!isOwnProfile && ( + + )} {canInvite && (
@@ -868,7 +1096,9 @@ const SubmissionFormContent: React.FC = ({ name='projectName' render={({ field }) => ( - Project Name * + + Project Name * + = ({ name='category' render={({ field }) => ( - Category * + + Category * +