diff --git a/app/(landing)/hackathons/[slug]/HackathonPageClient.tsx b/app/(landing)/hackathons/[slug]/HackathonPageClient.tsx index 3f38dce0..d457f207 100644 --- a/app/(landing)/hackathons/[slug]/HackathonPageClient.tsx +++ b/app/(landing)/hackathons/[slug]/HackathonPageClient.tsx @@ -403,6 +403,7 @@ export default function HackathonPageClient() { // Shared props for banner and sticky card const sharedActionProps = { deadline: currentHackathon.submissionDeadline, + submissionDeadlineExtendedAt: currentHackathon.submissionDeadlineExtendedAt, startDate: currentHackathon.startDate, totalPrizePool: currentHackathon.prizeTiers .reduce((acc, prize) => acc + Number(prize.prizeAmount || 0), 0) diff --git a/app/(landing)/hackathons/[slug]/hackathon-detail-design.md b/app/(landing)/hackathons/[slug]/hackathon-detail-design.md new file mode 100644 index 00000000..03830250 --- /dev/null +++ b/app/(landing)/hackathons/[slug]/hackathon-detail-design.md @@ -0,0 +1,32 @@ +Hackathon Detail Page Redesign +Issue: #414 + +Figma Design +https://www.figma.com/design/EMNGAQl1SGObXcsoa24krt/Boundless_Project-Details?node-id=0-1&t=A1ywRcn60Xyw0X6h-1 + +This design proposes a cleaner and more professional UI/UX for the hackathon detail page. + +Included in the Figma file: + +- Desktop layout +- Mobile layout +- Banner / hero placement proposal +- Redesigned hero section +- Sticky sidebar card +- Tab navigation +- All tab layouts (overview, participants, resources, announcements, submissions, discussions, find team, winners) +- Loading state +- Hackathon not found state + +Banner Placement Proposal +The hackathon banner is placed as a full-width hero image at the top of the page, allowing it to visually represent the hackathon and improve page identity. + +The sidebar becomes a compact summary card with key information and actions. + +Design Goals + +- Simpler UI and improved visual hierarchy +- Clear primary actions (Join, Submit, View Submission) +- Consistent spacing and typography +- Better mobile usability +- Professional and product-quality look diff --git a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx index 832add8f..b5a17ef1 100644 --- a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx +++ b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx @@ -53,6 +53,7 @@ const ParticipantsPage: React.FC = () => { const hackathonId = params.hackathonId as string; const [view, setView] = useState<'table' | 'grid'>('table'); + const [pageSize, setPageSize] = useState(PAGE_SIZE); const [filters, setFilters] = useState({ search: '', status: 'all', @@ -63,9 +64,9 @@ const ParticipantsPage: React.FC = () => { () => ({ organizationId, autoFetch: false, - pageSize: PAGE_SIZE, // Grid looks better with multiples of 3/4 + pageSize, // Use dynamic page size }), - [organizationId] + [organizationId, pageSize] ); const { @@ -110,7 +111,7 @@ const ParticipantsPage: React.FC = () => { fetchParticipants( actualHackathonId, 1, - PAGE_SIZE, + pageSize, mapFiltersToParams(filters, debouncedSearch) ); } @@ -120,6 +121,7 @@ const ParticipantsPage: React.FC = () => { debouncedSearch, filters.status, filters.type, + pageSize, ]); // Statistics @@ -161,12 +163,12 @@ const ParticipantsPage: React.FC = () => { }, [organizationId, actualHackathonId]); // Handlers - const handlePageChange = (page: number) => { + const handlePageChange = (page: number, limit?: number) => { if (actualHackathonId) { fetchParticipants( actualHackathonId, page, - PAGE_SIZE, + limit ?? pageSize, mapFiltersToParams(filters, debouncedSearch) ); } @@ -220,7 +222,7 @@ const ParticipantsPage: React.FC = () => { fetchParticipants( actualHackathonId, participantsPagination.currentPage, - PAGE_SIZE, + pageSize, mapFiltersToParams(filters, debouncedSearch) ); } @@ -241,16 +243,17 @@ const ParticipantsPage: React.FC = () => { }, onPaginationChange: updater => { if (typeof updater === 'function') { - const newState = ( - updater as (old: { pageIndex: number; pageSize: number }) => { - pageIndex: number; - pageSize: number; - } - )({ + const newState = updater({ pageIndex: participantsPagination.currentPage - 1, pageSize: participantsPagination.itemsPerPage, }); - handlePageChange(newState.pageIndex + 1); + + if (newState.pageSize !== participantsPagination.itemsPerPage) { + setPageSize(newState.pageSize); + handlePageChange(1, newState.pageSize); + } else { + handlePageChange(newState.pageIndex + 1); + } } }, }); diff --git a/app/dashboard/page copy.tsx b/app/dashboard/page copy.tsx deleted file mode 100644 index 7546289b..00000000 --- a/app/dashboard/page copy.tsx +++ /dev/null @@ -1,225 +0,0 @@ -'use client'; - -import { useRouter } from 'next/navigation'; -import { useEffect } from 'react'; -import { Button } from '@/components/ui/button'; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/components/ui/card'; -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; -import { - Loader2, - LogOut, - User, - Mail, - Shield, - CheckCircle2, -} from 'lucide-react'; -import { authClient } from '@/lib/auth-client'; -import { useAuthActions } from '@/hooks/use-auth'; - -export default function DashboardPage() { - const { data: session, isPending: sessionPending } = authClient.useSession(); - const router = useRouter(); - const { logout } = useAuthActions(); - - useEffect(() => { - if (!sessionPending && !session?.user) { - router.push('/auth?mode=signin'); - } - }, [session, sessionPending, router]); - - const handleSignOut = async () => { - await logout(); - router.push('/'); - }; - - if (sessionPending) { - return ( -
-
- - Loading... -
-
- ); - } - - if (!session?.user) { - return null; - } - - return ( -
-
- {/* Header */} -
-
-

Dashboard

-

- Welcome back, {session.user.name || session.user.email} -

-
- -
- - {/* Stats Cards Grid */} -
- {/* Profile Card */} - -
- - -
- -
- Profile -
-
- -
- - - - {session.user.name?.charAt(0) || - session.user.email.charAt(0)} - - -
-

- {session.user.name || 'No name'} -

-

- {session.user.email} -

-
-
-
-
- - {/* Account Details Card */} - -
- - -
- -
- Account Details -
-
- -
- User ID - - {session.user.id} - -
-
- Email - - {session.user.email} - -
-
-
- - {/* Status Card */} - -
- - -
- -
- Status & Verification -
-
- -
- Email Status -
- {session.user.emailVerified ? ( - <> - - - Verified - - - ) : ( - - Unverified - - )} -
-
-
- Account Status -
-
- - Active - -
-
- {(session.user as { lastLoginMethod?: string | null }) - ?.lastLoginMethod && ( -
- - Last Login Method - - - {(() => { - const method = ( - session.user as { lastLoginMethod?: string | null } - ).lastLoginMethod; - return method === 'google' - ? 'Google' - : method === 'email' - ? 'Email' - : method || 'N/A'; - })()} - -
- )} -
-
-
- - {/* Welcome Card */} -
- -
-
- - - Welcome to Boundless - - - Your platform for crowdfunding and grants - - - -

- This is your dashboard where you can manage your projects, view - contributions, and access all the features of the platform. The - authentication system is now working properly! -

-
-
-
-
-
- ); -} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx deleted file mode 100644 index 69a6c55d..00000000 --- a/app/dashboard/page.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { DashboardContent } from '@/components/dashboard-content'; -import React from 'react'; - -export default function Page() { - return ; -} diff --git a/app/me/layout.tsx b/app/me/layout.tsx index 0560658f..85dabcb1 100644 --- a/app/me/layout.tsx +++ b/app/me/layout.tsx @@ -23,9 +23,14 @@ interface MeLayoutProfile { image?: string; joinedHackathons?: ProfileItemWithId[]; hackathonSubmissionsAsParticipant?: ProfileItemWithId[]; + projects?: ProfileItemWithId[]; }; image?: string; hackathonSubmissionsAsParticipant?: ProfileItemWithId[]; + projects?: ProfileItemWithId[]; + stats?: { + projectsCreated?: number; + }; } const getId = (item: ProfileItemWithId): string | undefined => @@ -69,6 +74,25 @@ const MeLayout = ({ children }: MeLayoutProps): React.ReactElement => { }).length; }, [typedProfile]); + const projectsCount = useMemo(() => { + if (!typedProfile) return 0; + // stats.projectsCreated is often the most direct count + if (typeof typedProfile.stats?.projectsCreated === 'number') { + return typedProfile.stats.projectsCreated; + } + // Fallback to array lengths + const fromUser = typedProfile.user?.projects ?? []; + const fromProfile = typedProfile.projects ?? []; + const merged = [...fromUser, ...fromProfile]; + const seen = new Set(); + return merged.filter(p => { + const id = getId(p); + if (!id || seen.has(id)) return false; + seen.add(id); + return true; + }).length; + }, [typedProfile]); + // Only show full-screen spinner on first load, not on background refetches if (isLoading && !hasLoadedOnce.current) { return ( @@ -92,6 +116,7 @@ const MeLayout = ({ children }: MeLayoutProps): React.ReactElement => { counts={{ participating: hackathonsCount, submissions: submissionsCount, + projects: projectsCount, }} variant='inset' /> diff --git a/app/me/notifications/page.tsx b/app/me/notifications/page.tsx index 8b7e58fa..59a71019 100644 --- a/app/me/notifications/page.tsx +++ b/app/me/notifications/page.tsx @@ -104,10 +104,6 @@ export default function NotificationsPage() { const { setUnreadCount, clearUnreadCount } = useNotificationStore(); - useEffect(() => { - setUnreadCount(unreadCount); - }, [unreadCount, setUnreadCount]); - useNotificationPolling(notificationsHook, { interval: 30000, enabled: true, diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index ec78d978..d4b3ac94 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -27,13 +27,16 @@ import { SidebarMenuItem, } from '@/components/ui/sidebar'; import Image from 'next/image'; -import Link from 'next/link'; import { useNotificationStore } from '@/lib/stores/notification-store'; +import { Logo } from './landing-page/navbar'; +import { useNotifications } from '@/hooks/useNotifications'; +import { authClient } from '@/lib/auth-client'; const getNavigationData = (counts?: { participating?: number; unreadNotifications?: number; submissions?: number; + projects?: number; }) => ({ main: [ { @@ -57,7 +60,7 @@ const getNavigationData = (counts?: { title: 'My Projects', url: '/me/projects', icon: IconFolder, - badge: '3', + badge: (counts?.projects ?? 0) > 0 ? String(counts?.projects) : undefined, }, { title: 'Create Project', @@ -113,8 +116,8 @@ const getNavigationData = (counts?: { url: '/me/notifications', icon: IconBell, badge: - counts?.unreadNotifications && counts.unreadNotifications > 0 - ? counts.unreadNotifications.toString() + (counts?.unreadNotifications ?? 0) > 0 + ? String(counts?.unreadNotifications) : undefined, }, ], @@ -132,8 +135,14 @@ export function AppSidebar({ ...props }: { user: UserData; - counts?: { participating?: number; submissions?: number }; + counts?: { participating?: number; submissions?: number; projects?: number }; } & React.ComponentProps) { + const { data: session } = authClient.useSession(); + const userId = session?.user?.id; + + // Initialize notifications hook to ensure it fetches globally and syncs with store + useNotifications({ enabled: !!userId }); + const unreadNotifications = useNotificationStore(state => state.unreadCount); const navigationData = React.useMemo( @@ -164,17 +173,7 @@ export function AppSidebar({ size='lg' className='group hover:bg-sidebar-accent/0 transition-all duration-200' > - -
- Boundless Logo -
- + diff --git a/components/dashboard-content.tsx b/components/dashboard-content.tsx index a8dcd2c5..2aba3177 100644 --- a/components/dashboard-content.tsx +++ b/components/dashboard-content.tsx @@ -7,7 +7,7 @@ import { SectionCards } from '@/components/section-cards'; import { SiteHeader } from '@/components/site-header'; import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; import { useAuthStatus } from '@/hooks/use-auth'; -import data from '../app/dashboard/data.json'; +import data from '../data/data.json'; import React, { useState } from 'react'; import { FamilyWalletButton } from '@/components/wallet/FamilyWalletButton'; import { diff --git a/components/escrow/FundEscrowButton.tsx b/components/escrow/FundEscrowButton.tsx index 54652343..1ea7f8d1 100644 --- a/components/escrow/FundEscrowButton.tsx +++ b/components/escrow/FundEscrowButton.tsx @@ -15,6 +15,10 @@ import { } from '@trustless-work/escrow'; import { toast } from 'sonner'; import { reportError } from '@/lib/error-reporting'; +import { useWalletProtection } from '@/hooks/use-wallet-protection'; +import WalletRequiredModal from '@/components/wallet/WalletRequiredModal'; +import WalletNotReadyModal from '@/components/wallet/WalletNotReadyModal'; +import { WalletSheet } from '@/components/wallet/WalletSheet'; // Extended type to include balance property that may exist at runtime // Using intersection type to avoid type conflicts with required balance property @@ -34,7 +38,6 @@ import { * Component to fund an existing multi-release escrow using Trustless Work */ export const FundEscrowButton = () => { - const { walletAddress } = useWalletContext(); const { contractId, escrow, updateEscrow } = useEscrowContext(); const { fundEscrow } = useFundEscrow(); const { sendTransaction } = useSendTransaction(); @@ -45,6 +48,22 @@ export const FundEscrowButton = () => { transactionHash?: string; } | null>(null); + // Wallet protection hook + const { + requireWallet, + showWalletModal, + showNotReadyModal, + notReadyReasons, + handleWalletConnected, + closeWalletModal, + closeNotReadyModal, + publicKey: walletAddress, + } = useWalletProtection({ + actionName: 'fund escrow', + }); + + const [isWalletDrawerOpen, setIsWalletDrawerOpen] = useState(false); + // Calculate total amount from all milestones const calculateTotalAmount = (): number => { if (!escrow || !escrow.milestones) { @@ -94,79 +113,86 @@ export const FundEscrowButton = () => { ); } - // Step 1: Prepare the payload according to FundEscrowPayload type - const payload: FundEscrowPayload = { - contractId: contractId, - signer: walletAddress, - amount: totalAmount, - }; - - // Log payload for debugging - // Step 2: Execute function from Trustless Work - const fundResponse: EscrowRequestResponse = await fundEscrow( - payload, - 'multi-release' as EscrowType - ); + const walletReady = await requireWallet(async () => { + // Step 1: Prepare the payload according to FundEscrowPayload type + const payload: FundEscrowPayload = { + contractId: contractId, + signer: walletAddress!, + amount: totalAmount, + }; - // Type guard: Check if response is successful - if ( - fundResponse.status !== ('SUCCESS' as Status) || - !fundResponse.unsignedTransaction - ) { - const errorMessage = - 'message' in fundResponse && typeof fundResponse.message === 'string' - ? fundResponse.message - : 'Failed to fund escrow'; - throw new Error(errorMessage); - } + // Step 2: Execute function from Trustless Work + const fundResponse: EscrowRequestResponse = await fundEscrow( + payload, + 'multi-release' as EscrowType + ); - const { unsignedTransaction } = fundResponse; + // Type guard: Check if response is successful + if ( + fundResponse.status !== ('SUCCESS' as Status) || + !fundResponse.unsignedTransaction + ) { + const errorMessage = + 'message' in fundResponse && + typeof fundResponse.message === 'string' + ? fundResponse.message + : 'Failed to fund escrow'; + throw new Error(errorMessage); + } - // Step 3: Sign transaction with wallet - const signedXdr = await signTransaction({ - unsignedTransaction, - address: walletAddress, - }); + const { unsignedTransaction } = fundResponse; + + // Step 3: Sign transaction with wallet + const signedXdr = await signTransaction({ + unsignedTransaction, + address: walletAddress!, + }); + + // Step 4: Send transaction + const sendResponse = await sendTransaction(signedXdr); + + // Type guard: Check if response is successful + if ( + 'status' in sendResponse && + sendResponse.status !== ('SUCCESS' as Status) + ) { + const errorMessage = + 'message' in sendResponse && + typeof sendResponse.message === 'string' + ? sendResponse.message + : 'Failed to send transaction'; + throw new Error(errorMessage); + } - // Step 4: Send transaction - const sendResponse = await sendTransaction(signedXdr); + // Update escrow balance in context + if (escrow) { + const escrowWithBalance = escrow as MultiReleaseEscrowWithBalance; + const currentBalance = escrowWithBalance.balance || 0; + const updatedEscrow: MultiReleaseEscrowWithBalance = { + ...escrow, + balance: currentBalance + totalAmount, + }; + updateEscrow(updatedEscrow as MultiReleaseEscrow); + } - // Type guard: Check if response is successful - if ( - 'status' in sendResponse && - sendResponse.status !== ('SUCCESS' as Status) - ) { - const errorMessage = + // Display success status + const successMessage = 'message' in sendResponse && typeof sendResponse.message === 'string' ? sendResponse.message - : 'Failed to send transaction'; - throw new Error(errorMessage); - } + : 'Escrow funded successfully!'; - // Update escrow balance in context - if (escrow) { - // Balance may not be in the type, so we use type assertion - const escrowWithBalance = escrow as MultiReleaseEscrowWithBalance; - const currentBalance = escrowWithBalance.balance || 0; - const updatedEscrow: MultiReleaseEscrowWithBalance = { - ...escrow, - balance: currentBalance + totalAmount, - }; - updateEscrow(updatedEscrow as MultiReleaseEscrow); - } + setFundingStatus({ + success: true, + message: successMessage, + }); - // Display success status - const successMessage = - 'message' in sendResponse && typeof sendResponse.message === 'string' - ? sendResponse.message - : 'Escrow funded successfully!'; + toast.success('Escrow funded successfully!'); + }, totalAmount); - setFundingStatus({ - success: true, - message: successMessage, - }); - - toast.success('Escrow funded successfully!'); + if (!walletReady) { + setIsLoading(false); + return; + } } catch (err) { reportError(err, { context: 'escrow-fund' }); setFundingStatus({ @@ -187,147 +213,159 @@ export const FundEscrowButton = () => { const totalAmount = calculateTotalAmount(); - if (fundingStatus) { - return ( - - -
- {fundingStatus.success ? ( - - ) : ( - - )} - + {fundingStatus ? ( + + +
+ {fundingStatus.success ? ( + + ) : ( + + )} + + {fundingStatus.success + ? 'Escrow Funded Successfully!' + : 'Funding Failed'} + +
+ - {fundingStatus.success - ? 'Escrow Funded Successfully!' - : 'Funding Failed'} -
-
- - {fundingStatus.message} - -
- {fundingStatus.success && escrow && ( - -
-
- - Previous Balance: - - - {formatAmount( - ((escrow as MultiReleaseEscrowWithBalance).balance || 0) - - totalAmount - )} - -
-
- - Funded Amount: - - - +{formatAmount(totalAmount)} - -
-
- - New Balance: - - - {formatAmount( - (escrow as MultiReleaseEscrowWithBalance).balance || 0 - )} - + {fundingStatus.message} + + + {fundingStatus.success && escrow && ( + +
+
+ + Previous Balance: + + + {formatAmount( + ((escrow as MultiReleaseEscrowWithBalance).balance || 0) - + totalAmount + )} + +
+
+ + Funded Amount: + + + +{formatAmount(totalAmount)} + +
+
+ + New Balance: + + + {formatAmount( + (escrow as MultiReleaseEscrowWithBalance).balance || 0 + )} + +
+ +
+ )} + + ) : !contractId || !escrow ? ( +
+

+ Please initialize an escrow first before funding. +

+
+ ) : !escrow.milestones || escrow.milestones.length === 0 ? ( +
+

+ Error: Escrow initialized without milestones +

+

+ This escrow was initialized without milestones. Please initialize a + new escrow with milestones. +

+
+ ) : ( + <> +
+
+ + Total Funding Amount: + + + {formatAmount(totalAmount)} +
- - - )} - - ); - } - - if (!contractId || !escrow) { - return ( -
-

- Please initialize an escrow first before funding. -

-
- ); - } - - // Check if escrow has milestones - if (!escrow.milestones || escrow.milestones.length === 0) { - return ( -
-

- Error: Escrow initialized without milestones -

-

- This escrow was initialized without milestones. Please initialize a - new escrow with milestones. -

-
- ); - } +

+ This amount is the sum of all milestone amounts ( + {escrow.milestones.length} milestones) +

+
- return ( -
-
-
- - Total Funding Amount: - - - {formatAmount(totalAmount)} - -
-

- This amount is the sum of all milestone amounts ( - {escrow.milestones.length} milestones) -

-
- - + + + )} + + {/* Wallet Modals */} + + + setIsWalletDrawerOpen(true)} + actionName='fund escrow' + /> + +
); }; diff --git a/components/hackathons/hackathonBanner.tsx b/components/hackathons/hackathonBanner.tsx index f08d27b0..d18a9212 100644 --- a/components/hackathons/hackathonBanner.tsx +++ b/components/hackathons/hackathonBanner.tsx @@ -30,6 +30,7 @@ interface HackathonBannerProps { onLeaveClick?: () => void; isLeaving?: boolean; participantType?: 'INDIVIDUAL' | 'TEAM' | 'TEAM_OR_INDIVIDUAL'; + submissionDeadlineExtendedAt?: string | null; } export function HackathonBanner({ @@ -46,6 +47,7 @@ export function HackathonBanner({ registrationDeadline, isLeaving, participantType, + submissionDeadlineExtendedAt, onJoinClick, onSubmitClick, onViewSubmissionClick, @@ -252,6 +254,11 @@ export function HackathonBanner({ {status === 'ongoing' ? 'Ends In' : 'Starts In'} + {status === 'ongoing' && submissionDeadlineExtendedAt && ( + + Extended + + )}
void; onLeaveClick?: () => void; participantType?: 'INDIVIDUAL' | 'TEAM' | 'TEAM_OR_INDIVIDUAL'; + submissionDeadlineExtendedAt?: string | null; } export function HackathonStickyCard(props: HackathonStickyCardProps) { @@ -54,6 +55,7 @@ export function HackathonStickyCard(props: HackathonStickyCardProps) { onLeaveClick, isLeaving, participantType, + submissionDeadlineExtendedAt, } = props; const { status } = useHackathonStatus(startDate, deadline); @@ -162,6 +164,11 @@ export function HackathonStickyCard(props: HackathonStickyCardProps) { Deadline {formatDateWithFallback(deadline)} + {submissionDeadlineExtendedAt && ( + + Extended + + )}
)} diff --git a/components/landing-page/navbar.tsx b/components/landing-page/navbar.tsx index 01fe37b4..10cddc32 100644 --- a/components/landing-page/navbar.tsx +++ b/components/landing-page/navbar.tsx @@ -33,6 +33,8 @@ import WalletRequiredModal from '@/components/wallet/WalletRequiredModal'; import { WalletTrigger } from '../wallet/WalletTrigger'; import { NotificationBell } from '../notifications/NotificationBell'; import CreateProjectModal from '@/features/projects/components/CreateProjectModal'; +import WalletNotReadyModal from '@/components/wallet/WalletNotReadyModal'; +import { useWalletContext } from '../providers/wallet-provider'; const BRAND_COLOR = '#a7f950'; const ACTIONS = { @@ -122,7 +124,7 @@ export function Navbar() { ); } -function Logo() { +export function Logo() { return ( setCreateProjectModalOpen(true), }); + const { onOpenWallet } = useWalletContext(); return ( <> @@ -294,17 +300,31 @@ function AuthenticatedActions() { actionName={ACTIONS.CREATE_PROJECT} onWalletConnected={handleWalletConnected} /> + ); } function UnauthenticatedActions() { const [createProjectModalOpen, setCreateProjectModalOpen] = useState(false); - const { showWalletModal, closeWalletModal, handleWalletConnected } = - useProtectedAction({ - actionName: ACTIONS.CREATE_PROJECT, - onSuccess: () => setCreateProjectModalOpen(true), - }); + const { + showWalletModal, + showNotReadyModal, + notReadyReasons, + closeWalletModal, + closeNotReadyModal, + handleWalletConnected, + } = useProtectedAction({ + actionName: ACTIONS.CREATE_PROJECT, + onSuccess: () => setCreateProjectModalOpen(true), + }); + const { onOpenWallet } = useWalletContext(); return ( <> @@ -334,6 +354,13 @@ function UnauthenticatedActions() { actionName={ACTIONS.CREATE_PROJECT} onWalletConnected={handleWalletConnected} /> + ); } diff --git a/components/modals/fund-project/index.tsx b/components/modals/fund-project/index.tsx index e74f2b7b..763645c1 100644 --- a/components/modals/fund-project/index.tsx +++ b/components/modals/fund-project/index.tsx @@ -12,6 +12,9 @@ import { fundCrowdfundingProject } from '@/features/projects/api'; import { useWalletContext } from '@/components/providers/wallet-provider'; import { useWalletProtection } from '@/hooks/use-wallet-protection'; import { signTransaction } from '@/lib/config/wallet-kit'; +import WalletRequiredModal from '@/components/wallet/WalletRequiredModal'; +import WalletNotReadyModal from '@/components/wallet/WalletNotReadyModal'; +import { WalletSheet } from '@/components/wallet/WalletSheet'; import { useFundEscrow, useSendTransaction, @@ -82,9 +85,19 @@ const FundProject = ({ open, setOpen, project }: FundProjectProps) => { // Wallet hooks const { walletAddress } = useWalletContext(); - const { requireWallet } = useWalletProtection({ + const { + requireWallet, + showWalletModal, + showNotReadyModal, + notReadyReasons, + handleWalletConnected, + closeWalletModal, + closeNotReadyModal, + } = useWalletProtection({ actionName: 'fund project', }); + + const [isWalletDrawerOpen, setIsWalletDrawerOpen] = useState(false); // Form data state const [formData, setFormData] = useState({ amount: {}, @@ -292,29 +305,11 @@ const FundProject = ({ open, setOpen, project }: FundProjectProps) => { setError(null); const walletValid = await requireWallet(async () => { - if (!walletAddress) { - setError('Wallet address is required'); - setFlowStep('form'); - setIsLoading(false); - setIsSubmitting(false); - return; - } - - if (!project.contractId) { - setError( - 'This project does not have an escrow contract set up. Please contact the project creator or support if you believe this is an error.' - ); - setFlowStep('form'); - setIsLoading(false); - setIsSubmitting(false); - return; - } - try { // Step 1: Prepare the payload according to FundEscrowPayload type const payload: FundEscrowPayload = { - contractId: project.contractId, - signer: walletAddress, + contractId: project.contractId!, + signer: walletAddress!, amount: fundingAmount, }; @@ -342,7 +337,7 @@ const FundProject = ({ open, setOpen, project }: FundProjectProps) => { // Step 3: Sign transaction with wallet const signedXdr = await signTransaction({ unsignedTransaction, - address: walletAddress, + address: walletAddress!, }); // Extract transaction hash from signed XDR @@ -405,7 +400,7 @@ const FundProject = ({ open, setOpen, project }: FundProjectProps) => { setIsLoading(false); setIsSubmitting(false); } - }); + }, fundingAmount); if (!walletValid) { setFlowStep('form'); @@ -593,6 +588,27 @@ const FundProject = ({ open, setOpen, project }: FundProjectProps) => { isSubmitting={isSubmitting} /> )} + + {/* Wallet Modals */} + + + setIsWalletDrawerOpen(true)} + actionName='fund project' + /> + + ); }; diff --git a/components/nav-user.tsx b/components/nav-user.tsx index edcd65d0..df269f80 100644 --- a/components/nav-user.tsx +++ b/components/nav-user.tsx @@ -26,6 +26,9 @@ import { useSidebar, } from '@/components/ui/sidebar'; import { Badge } from './ui/badge'; +import Link from 'next/link'; +import { useAuthActions } from '@/hooks/use-auth'; +import { useNotificationStore } from '@/lib/stores/notification-store'; export interface NavUserProps { user: { @@ -37,6 +40,14 @@ export interface NavUserProps { export const NavUser = ({ user }: NavUserProps): React.ReactElement => { const { isMobile } = useSidebar(); + const { logout } = useAuthActions(); + const { unreadCount: unreadNotifications, clearUnreadCount } = + useNotificationStore(); + + const handleLogout = () => { + logout(); + clearUnreadCount(); + }; return ( @@ -68,7 +79,7 @@ export const NavUser = ({ user }: NavUserProps): React.ReactElement => { {
- + {user.name} - + {user.email}
@@ -96,22 +107,44 @@ export const NavUser = ({ user }: NavUserProps): React.ReactElement => { - - - Account Settings + + + + Account Settings + - - - Billing + + + + Billing + - - - Notifications - 3 + + + + Notifications + {unreadNotifications > 0 && ( + + {unreadNotifications} + + )} + - - + + Log out diff --git a/components/providers/wallet-provider.tsx b/components/providers/wallet-provider.tsx index 46586a6a..fa1b918d 100644 --- a/components/providers/wallet-provider.tsx +++ b/components/providers/wallet-provider.tsx @@ -26,6 +26,9 @@ type WalletContextType = { syncWallet: () => Promise; getSupportedTrustlineAssets: () => Promise; addTrustline: (assetCode: string) => Promise; + isWalletOpen: boolean; + onOpenWallet: () => void; + onCloseWallet: () => void; }; const WalletContext = createContext(undefined); @@ -123,6 +126,16 @@ export const WalletProvider = ({ children }: { children: ReactNode }) => { const isLoading = isSessionLoading || walletLoading; const hasWalletFromSession = !!session?.user?.wallet?.address; + const [isWalletOpen, setIsWalletOpen] = useState(false); + + const onOpenWallet = useCallback(() => { + setIsWalletOpen(true); + }, []); + + const onCloseWallet = useCallback(() => { + setIsWalletOpen(false); + }, []); + return ( { syncWallet, getSupportedTrustlineAssets, addTrustline, + isWalletOpen, + onOpenWallet, + onCloseWallet, }} > {children} diff --git a/components/wallet/FamilyWalletDrawer.tsx b/components/wallet/FamilyWalletDrawer.tsx index e9e7cb67..9f49efac 100644 --- a/components/wallet/FamilyWalletDrawer.tsx +++ b/components/wallet/FamilyWalletDrawer.tsx @@ -212,6 +212,10 @@ export function FamilyWalletDrawer({ setValidateResult('idle'); try { const result = await validateSendDestination(dest, currency); + + // Protect against stale responses if the user has changed the destination + if (sendDestination.trim() !== dest) return; + if (result.valid) { setValidateResult('valid'); setValidateError(''); @@ -222,15 +226,45 @@ export function FamilyWalletDrawer({ ); } } catch (err: unknown) { + // Protect against stale responses + if (sendDestination.trim() !== dest) return; + const { message, details } = getErrorDisplay(err); setValidateResult('invalid'); setValidateError(message); setValidateErrorDetails(details); } finally { - setValidateLoading(false); + // We still want to clear loading if it's the latest call + if (sendDestination.trim() === dest) { + setValidateLoading(false); + } } }, [sendDestination, sendCurrency, getErrorDisplay]); + // Auto-validate destination address + useEffect(() => { + const trimmedDest = sendDestination.trim(); + + // Reset state if empty + if (!trimmedDest) { + setValidateResult('idle'); + setValidateError(''); + return; + } + + // Immediate trigger if 56 chars + if (trimmedDest.length === 56) { + handleValidateDestination(); + return; + } + + const timer = setTimeout(() => { + handleValidateDestination(); + }, 500); + + return () => clearTimeout(timer); + }, [sendDestination, sendCurrency, handleValidateDestination]); + const handleSendSubmit = useCallback(async () => { const dest = sendDestination.trim(); const currency = sendCurrency || 'XLM'; @@ -833,37 +867,26 @@ export function FamilyWalletDrawer({ -
+
{ setSendDestination(e.target.value); - setValidateResult('idle'); - setValidateError(''); }} - className='font-mono text-sm' + className='pr-10 font-mono text-sm' /> - + ) : validateResult === 'invalid' && + sendDestination.trim().length >= 56 ? ( + + ) : null} +
{validateResult === 'invalid' && validateError && ( diff --git a/components/wallet/LandingWalletWrapper.tsx b/components/wallet/LandingWalletWrapper.tsx index 4b827829..8e37ecd6 100644 --- a/components/wallet/LandingWalletWrapper.tsx +++ b/components/wallet/LandingWalletWrapper.tsx @@ -4,11 +4,12 @@ import { useState } from 'react'; import { FamilyWalletButton } from './FamilyWalletButton'; import { FamilyWalletDrawer, DrawerView } from './FamilyWalletDrawer'; import { useAuthStatus } from '@/hooks/use-auth'; +import { useWalletContext } from '@/components/providers/wallet-provider'; export function LandingWalletWrapper() { - const [open, setOpen] = useState(false); const [drawerView, setDrawerView] = useState('main'); const { isAuthenticated, isLoading } = useAuthStatus(); + const { isWalletOpen, onOpenWallet, onCloseWallet } = useWalletContext(); if (isLoading || !isAuthenticated) { return null; @@ -19,13 +20,13 @@ export function LandingWalletWrapper() { { if (view) setDrawerView(view); - setOpen(true); + onOpenWallet(); }} /> ); diff --git a/components/wallet/WalletNotReadyModal.tsx b/components/wallet/WalletNotReadyModal.tsx new file mode 100644 index 00000000..ec1a3976 --- /dev/null +++ b/components/wallet/WalletNotReadyModal.tsx @@ -0,0 +1,163 @@ +import React from 'react'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '../ui/dialog'; +import { Button } from '../ui/button'; +import { + XIcon, + Wallet, + ExternalLink, + Activity, + Coins, + ShieldCheck, + AlertCircle, +} from 'lucide-react'; +import { WalletNotReadyReason } from '@/hooks/use-wallet-readiness'; + +interface WalletNotReadyModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + reasons: WalletNotReadyReason[]; + onOpenWallet: () => void; + actionName: string; +} + +const WalletNotReadyModal: React.FC = ({ + open, + onOpenChange, + reasons, + onOpenWallet, + actionName, +}) => { + const handleOpenWallet = () => { + onOpenChange(false); + onOpenWallet(); + }; + + return ( + + + + + + + + +
+ {/* Header Icon with Glow */} +
+
+
+ +
+
+ + {/* Title & Description */} +
+ + Wallet Not Ready + + + A few quick steps are needed before you can{' '} + {actionName}. + +
+ + {/* Steps Section */} +
+ {reasons.includes('not_activated') && ( + } + title='Activate Stellar Account' + description="Your account isn't on-chain. Send at least 2 XLM to this address to activate it." + /> + )} + {reasons.includes('no_usdc_trustline') && ( + } + title='Add USDC Trustline' + description='Required to hold USDC on Stellar. Open your wallet to add the trustline.' + /> + )} + {reasons.includes('insufficient_xlm') && + !reasons.includes('not_activated') && ( + } + title='Low XLM Balance' + description='Transaction fees and reserve requirements need a small amount of XLM.' + /> + )} + {reasons.includes('insufficient_usdc') && ( + } + title='Insufficient USDC Balance' + description='You need more USDC in your wallet to complete this transaction.' + /> + )} +
+ + {/* Actions */} +
+ + +
+ + + Why is this required? + + +
+ +
+ ); +}; + +const StepItem = ({ + icon, + title, + description, +}: { + icon: React.ReactNode; + title: string; + description: string; +}) => ( +
+
+ {icon} +
+
+

{title}

+

{description}

+
+
+); + +export default WalletNotReadyModal; diff --git a/components/wallet/WalletTrigger.tsx b/components/wallet/WalletTrigger.tsx index db0eff67..31f39591 100644 --- a/components/wallet/WalletTrigger.tsx +++ b/components/wallet/WalletTrigger.tsx @@ -21,7 +21,8 @@ export function WalletTrigger({ className, drawerType = 'sheet', }: WalletTriggerProps) { - const { walletAddress, hasWalletFromSession, isLoading } = useWalletContext(); + const { walletAddress, hasWalletFromSession, isLoading, onOpenWallet } = + useWalletContext(); const [open, setOpen] = useState(false); // Wallet is managed by backend; no "Connect Wallet" flow. Show trigger only when diff --git a/app/dashboard/data.json b/data/data.json similarity index 100% rename from app/dashboard/data.json rename to data/data.json diff --git a/features/projects/components/Initialize.tsx b/features/projects/components/Initialize.tsx index 5460ab3b..18a9c74b 100644 --- a/features/projects/components/Initialize.tsx +++ b/features/projects/components/Initialize.tsx @@ -13,6 +13,8 @@ import ProjectSubmissionSuccess from './ProjectSubmissionSuccess'; import Loading from '@/components/loading/Loading'; import { useWalletProtection } from '@/hooks/use-wallet-protection'; import WalletRequiredModal from '@/components/wallet/WalletRequiredModal'; +import WalletNotReadyModal from '@/components/wallet/WalletNotReadyModal'; +import { WalletSheet } from '@/components/wallet/WalletSheet'; type StepState = 'pending' | 'active' | 'completed'; @@ -47,12 +49,17 @@ const Initialize: React.FC = ({ onSuccess }) => { const { requireWallet, showWalletModal, + showNotReadyModal, + notReadyReasons, handleWalletConnected, closeWalletModal, + closeNotReadyModal, } = useWalletProtection({ actionName: 'initialize project', }); + const [isWalletDrawerOpen, setIsWalletDrawerOpen] = useState(false); + const localSteps: Step[] = [ { title: 'Submit your project Details', @@ -97,7 +104,7 @@ const Initialize: React.FC = ({ onSuccess }) => { const submitInit = async () => { if (!formData) return; - requireWallet(async () => { + await requireWallet(async () => { try { setIsSubmitting(true); toast.loading('Initializing project...'); @@ -203,6 +210,21 @@ const Initialize: React.FC = ({ onSuccess }) => { actionName='initialize project' onWalletConnected={handleWalletConnected} /> + + {/* Wallet Not Ready Modal */} + setIsWalletDrawerOpen(true)} + actionName='initialize project' + /> + + {/* Wallet Sheet */} + ); }; @@ -237,7 +259,7 @@ const MilestonesPhase = ({