diff --git a/app/providers.tsx b/app/providers.tsx index bf32b7125..3d2a6e254 100644 --- a/app/providers.tsx +++ b/app/providers.tsx @@ -3,6 +3,7 @@ import { SessionProvider } from 'next-auth/react'; import { ReactNode } from 'react'; import { AuthProvider } from '@/components/providers/auth-provider'; +import { WalletProvider } from '@/components/providers/wallet-provider'; import { NotificationProvider } from 'react-notification-core'; import { mockFetchNotifications, @@ -19,19 +20,21 @@ export function Providers({ children }: ProvidersProps) { return ( - - {children} - + + + {children} + + ); diff --git a/components/landing-page/navbar.tsx b/components/landing-page/navbar.tsx index 4dcf318e4..dbd4f383c 100644 --- a/components/landing-page/navbar.tsx +++ b/components/landing-page/navbar.tsx @@ -29,7 +29,9 @@ import { User, LogOut, Settings } from 'lucide-react'; import { cn } from '@/lib/utils'; import WalletConnectButton from '../wallet/WalletConnectButton'; import CreateProjectModal from './project/CreateProjectModal'; +import { useProtectedAction } from '@/hooks/use-protected-action'; import { useAuthStore } from '@/lib/stores/auth-store'; +import WalletRequiredModal from '@/components/wallet/WalletRequiredModal'; gsap.registerPlugin(useGSAP); @@ -229,6 +231,16 @@ function AuthenticatedNav({ const { logout } = useAuthActions(); const { isLoading } = useAuthStore(); const [createProjectModalOpen, setCreateProjectModalOpen] = useState(false); + + const { + executeProtectedAction, + showWalletModal, + closeWalletModal, + handleWalletConnected, + } = useProtectedAction({ + actionName: 'create project', + onSuccess: () => setCreateProjectModalOpen(true), + }); return (
@@ -243,7 +255,11 @@ function AuthenticatedNav({ className='bg-background w-[300px] rounded-[8px] border border-[#2B2B2B] pt-3 pb-6 text-white shadow-[0_4px_4px_0_rgba(26,26,26,0.25)]' > setCreateProjectModalOpen(true)} + onClick={async () => { + await executeProtectedAction(() => + setCreateProjectModalOpen(true) + ); + }} className='group hover:text-primary px-6 py-3.5 text-white hover:!bg-transparent' > @@ -366,6 +382,14 @@ function AuthenticatedNav({ open={createProjectModalOpen} setOpen={setCreateProjectModalOpen} /> + + {/* Wallet Required Modal */} +
); } @@ -375,6 +399,16 @@ function AuthenticatedNav({ function UnauthenticatedNav() { const [createProjectModalOpen, setCreateProjectModalOpen] = useState(false); + const { + executeProtectedAction, + showWalletModal, + closeWalletModal, + handleWalletConnected, + } = useProtectedAction({ + actionName: 'create project', + onSuccess: () => setCreateProjectModalOpen(true), + }); + const showDevAddProject = process.env.NODE_ENV !== 'production' && (process.env.NEXT_PUBLIC_SHOW_ADD_PROJECT_FOR_GUESTS === 'true' || true); @@ -391,7 +425,9 @@ function UnauthenticatedNav() {
setCreateProjectModalOpen(true)} + onClick={async () => { + await executeProtectedAction(() => setCreateProjectModalOpen(true)); + }} className='border-white/20 text-white hover:bg-white/10' > @@ -404,6 +440,14 @@ function UnauthenticatedNav() { open={createProjectModalOpen} setOpen={setCreateProjectModalOpen} /> + + {/* Wallet Required Modal */} +
); } diff --git a/components/landing-page/project/CreateProjectModal/Basic.tsx b/components/landing-page/project/CreateProjectModal/Basic.tsx index 2fbadc0b8..4d07e6734 100644 --- a/components/landing-page/project/CreateProjectModal/Basic.tsx +++ b/components/landing-page/project/CreateProjectModal/Basic.tsx @@ -20,7 +20,7 @@ interface BasicProps { export interface BasicFormData { projectName: string; logo: File | string | null; - logoUrl?: string; // URL of uploaded logo + logoUrl?: string; vision: string; category: string; githubUrl: string; @@ -29,12 +29,35 @@ export interface BasicFormData { socialLinks: string[]; } -// File validation is handled in the upload handlers +const normalizeUrl = (url: string): string => { + if (!url || url.trim() === '') return ''; + const trimmed = url.trim(); + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + return trimmed; + } + return `https://${trimmed}`; +}; + +const urlValidation = z.string().refine( + val => { + if (!val || val.trim() === '') return true; + const normalized = normalizeUrl(val); + try { + new URL(normalized); + return true; + } catch { + return false; + } + }, + { + message: 'Please enter a valid URL', + } +); const basicSchema = z .object({ projectName: z.string().trim().min(1, 'Project name is required'), - logo: z.any().optional(), // Allow any type for logo (File or null) + logo: z.any().optional(), logoUrl: z.string().optional(), vision: z .string() @@ -42,9 +65,9 @@ const basicSchema = z .min(1, 'Vision is required') .max(300, 'Vision must be 300 characters or less'), category: z.string().trim().min(1, 'Category is required'), - githubUrl: z.string().url().optional().or(z.literal('')), - websiteUrl: z.string().url().optional().or(z.literal('')), - demoVideoUrl: z.string().url().optional().or(z.literal('')), + githubUrl: urlValidation, + websiteUrl: urlValidation, + demoVideoUrl: urlValidation, socialLinks: z .array(z.string().trim()) .refine( @@ -81,7 +104,6 @@ const Basic = React.forwardRef<{ validate: () => boolean }, BasicProps>( socialLinks: initialData?.socialLinks || ['', '', ''], }); - // Update form data when initialData changes React.useEffect(() => { if (initialData) { setFormData({ @@ -98,11 +120,6 @@ const Basic = React.forwardRef<{ validate: () => boolean }, BasicProps>( } }, [initialData]); - // Debug form data changes (removed for production) - // React.useEffect(() => { - // console.log('Form data updated:', formData); - // }, [formData]); - const [errors, setErrors] = useState< Partial> >({}); @@ -118,10 +135,26 @@ const Basic = React.forwardRef<{ validate: () => boolean }, BasicProps>( field: keyof BasicFormData, value: string | File | string[] ) => { - const newData = { ...formData, [field]: value }; + let processedValue = value; + + if ( + typeof value === 'string' && + (field === 'githubUrl' || + field === 'websiteUrl' || + field === 'demoVideoUrl') + ) { + if ( + value.trim() && + !value.startsWith('http://') && + !value.startsWith('https://') + ) { + processedValue = `https://${value}`; + } + } + + const newData = { ...formData, [field]: processedValue }; setFormData(newData); - // Clear error when user starts typing if (errors[field]) { setErrors(prev => ({ ...prev, [field]: undefined })); } @@ -143,7 +176,6 @@ const Basic = React.forwardRef<{ validate: () => boolean }, BasicProps>( setTouched(prev => ({ ...prev, logo: true })); setUploadError(null); - // Validate file type if (!file.type.match(/^image\/(jpeg|png)$/)) { setErrors(prev => ({ ...prev, @@ -152,7 +184,6 @@ const Basic = React.forwardRef<{ validate: () => boolean }, BasicProps>( return; } - // Validate file size (2MB = 2 * 1024 * 1024 bytes) if (file.size > 2 * 1024 * 1024) { setErrors(prev => ({ ...prev, @@ -180,9 +211,7 @@ const Basic = React.forwardRef<{ validate: () => boolean }, BasicProps>( }); if (uploadResult.success) { - // Upload successful - set logoUrl and clear logo file handleInputChange('logoUrl', uploadResult.data.secure_url); - // Clear the file object since we have URL now setFormData(prev => { const newData = { ...prev, logo: null }; onDataChange?.(newData); @@ -190,11 +219,9 @@ const Basic = React.forwardRef<{ validate: () => boolean }, BasicProps>( }); setErrors(prev => ({ ...prev, logo: undefined })); } else { - // Upload failed throw new Error(uploadResult.message || 'Upload failed'); } } catch (error) { - // Upload error setUploadError( error instanceof Error ? error.message : 'Upload failed' ); @@ -231,7 +258,6 @@ const Basic = React.forwardRef<{ validate: () => boolean }, BasicProps>( setTouched(prev => ({ ...prev, logo: true })); setUploadError(null); - // Validate file type if (!file.type.match(/^image\/(jpeg|png)$/)) { setErrors(prev => ({ ...prev, @@ -240,7 +266,6 @@ const Basic = React.forwardRef<{ validate: () => boolean }, BasicProps>( return; } - // Validate file size (2MB = 2 * 1024 * 1024 bytes) if (file.size > 2 * 1024 * 1024) { setErrors(prev => ({ ...prev, @@ -249,10 +274,8 @@ const Basic = React.forwardRef<{ validate: () => boolean }, BasicProps>( return; } - // Set the file immediately for preview handleInputChange('logo', file); - // Upload the file setIsUploading(true); try { const uploadResult = await uploadService.uploadSingle(file, { @@ -268,9 +291,7 @@ const Basic = React.forwardRef<{ validate: () => boolean }, BasicProps>( }); if (uploadResult.success) { - // Upload successful - set logoUrl and clear logo file handleInputChange('logoUrl', uploadResult.data.secure_url); - // Clear the file object since we have URL now setFormData(prev => { const newData = { ...prev, logo: null }; onDataChange?.(newData); @@ -278,11 +299,9 @@ const Basic = React.forwardRef<{ validate: () => boolean }, BasicProps>( }); setErrors(prev => ({ ...prev, logo: undefined })); } else { - // Upload failed throw new Error(uploadResult.message || 'Upload failed'); } } catch (error) { - // Upload error setUploadError( error instanceof Error ? error.message : 'Upload failed' ); @@ -297,12 +316,10 @@ const Basic = React.forwardRef<{ validate: () => boolean }, BasicProps>( }; const validateForm = (): boolean => { - // Don't validate logo if we're currently uploading if (isUploading) { return false; } - // Normalize social links (trim empties but keep array length for UI) const parsed = basicSchema.safeParse({ ...formData, socialLinks: formData.socialLinks.map(s => s.trim()), @@ -325,7 +342,6 @@ const Basic = React.forwardRef<{ validate: () => boolean }, BasicProps>( return false; }; - // Field-level validation on blur const validateField = (field: keyof BasicFormData | 'socialLinks') => { setTouched(prev => ({ ...prev, [field]: true })); try { @@ -370,11 +386,9 @@ const Basic = React.forwardRef<{ validate: () => boolean }, BasicProps>( setErrors(prev => ({ ...prev, demoVideoUrl: undefined })); break; case 'logo': - // Don't validate logo if we're currently uploading if (isUploading) { break; } - // Validate logo field - either file or URL should exist if (!formData.logo && !formData.logoUrl) { setErrors(prev => ({ ...prev, @@ -397,7 +411,6 @@ const Basic = React.forwardRef<{ validate: () => boolean }, BasicProps>( break; } } catch (e: unknown) { - // Zod throws on parse; map first error if (e && typeof e === 'object' && 'issues' in e) { const zodError = e as { issues: Array<{ message: string }> }; const issue = zodError.issues?.[0]; @@ -408,7 +421,6 @@ const Basic = React.forwardRef<{ validate: () => boolean }, BasicProps>( } }; - // Expose validation function to parent React.useImperativeHandle(ref, () => ({ validate: validateForm, markSubmitted: () => setSubmitted(true), @@ -416,7 +428,6 @@ const Basic = React.forwardRef<{ validate: () => boolean }, BasicProps>( return (
- {/* Project Name */}
- {/* Logo/Image */}
- {/* Vision */}
- {/* Category */}
- {/* GitHub/GitLab/Bitbucket */}
- {/* Project Website */}
boolean }, BasicProps>( value={formData.websiteUrl} onChange={e => handleInputChange('websiteUrl', e.target.value)} onBlur={() => validateField('websiteUrl')} - className='focus-visible:border-primary border-[#484848] bg-[#1A1A1A] p-4 text-white placeholder:text-[#919191]' + className={cn( + 'focus-visible:border-primary border-[#484848] bg-[#1A1A1A] p-4 text-white placeholder:text-[#919191]', + (submitted || touched.websiteUrl) && + errors.websiteUrl && + 'border-red-500' + )} /> + {(submitted || touched.websiteUrl) && errors.websiteUrl && ( +

{errors.websiteUrl}

+ )}
- {/* Demo Video */}
- {/* Social Links */}
+ {errors[`${milestone.id}-startDate`] && ( +

+ {errors[`${milestone.id}-startDate`]} +

+ )}
@@ -203,10 +292,18 @@ const SortableMilestoneItem = ({ onChange={e => onMilestoneChange(milestone.id, 'endDate', e.target.value) } - className='focus-visible:border-primary border-[#2B2B2B] bg-[#101010] p-4 pr-10 text-white' + className={cn( + 'focus-visible:border-primary border-[#2B2B2B] bg-[#101010] p-4 pr-10 text-white', + errors[`${milestone.id}-endDate`] && 'border-red-500' + )} />
+ {errors[`${milestone.id}-endDate`] && ( +

+ {errors[`${milestone.id}-endDate`]} +

+ )}
@@ -267,6 +364,7 @@ const Milestones = React.forwardRef< const [errors, setErrors] = useState<{ fundingAmount?: string; milestones?: string; + [key: string]: string | undefined; }>({}); const sensors = useSensors( @@ -291,6 +389,48 @@ const Milestones = React.forwardRef< onDataChange?.(newData); }; + const validateMilestoneField = ( + milestone: Milestone, + field: keyof Milestone, + value: string + ) => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + if (field === 'startDate' && value) { + const startDate = new Date(value); + if (startDate <= today) { + return 'Start date must be at least tomorrow'; + } + + // Check if not too far in future (2 years) + const maxFutureDate = new Date(); + maxFutureDate.setFullYear(maxFutureDate.getFullYear() + 2); + if (startDate > maxFutureDate) { + return 'Start date cannot be more than 2 years in the future'; + } + } + + if (field === 'endDate' && value && milestone.startDate) { + const startDate = new Date(milestone.startDate); + const endDate = new Date(value); + + if (endDate <= startDate) { + return 'End date must be after start date'; + } + + // Check minimum duration (1 week) + const durationInDays = Math.ceil( + (endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24) + ); + if (durationInDays < 7) { + return 'Milestone duration must be at least 1 week'; + } + } + + return undefined; + }; + const handleMilestoneChange = ( id: string, field: keyof Milestone, @@ -299,6 +439,22 @@ const Milestones = React.forwardRef< const updatedMilestones = formData.milestones.map(milestone => milestone.id === id ? { ...milestone, [field]: value } : milestone ); + + // Validate the specific field + const milestone = updatedMilestones.find(m => m.id === id); + if (milestone) { + const fieldError = validateMilestoneField(milestone, field, value); + if (fieldError) { + setErrors(prev => ({ ...prev, [`${id}-${field}`]: fieldError })); + } else { + setErrors(prev => { + const newErrors = { ...prev }; + delete newErrors[`${id}-${field}`]; + return newErrors; + }); + } + } + handleInputChange('milestones', updatedMilestones); }; @@ -421,6 +577,7 @@ const Milestones = React.forwardRef< onMilestoneChange={handleMilestoneChange} onRemoveMilestone={removeMilestone} canRemove={formData.milestones.length > 1} + errors={errors} /> ))} diff --git a/components/landing-page/project/CreateProjectModal/Team.tsx b/components/landing-page/project/CreateProjectModal/Team.tsx index ae1ae1683..271094fec 100644 --- a/components/landing-page/project/CreateProjectModal/Team.tsx +++ b/components/landing-page/project/CreateProjectModal/Team.tsx @@ -159,9 +159,15 @@ const Team = React.forwardRef<{ validate: () => boolean }, TeamProps>(
- +
+ +

+ Team members will receive invite links via email to join your + project. +

+
diff --git a/components/landing-page/project/CreateProjectModal/index.tsx b/components/landing-page/project/CreateProjectModal/index.tsx index dcdff5926..15dac7afc 100644 --- a/components/landing-page/project/CreateProjectModal/index.tsx +++ b/components/landing-page/project/CreateProjectModal/index.tsx @@ -254,7 +254,7 @@ const CreateProjectModal = ({ open, setOpen }: CreateProjectModalProps) => { return; } - requireWallet(async () => { + const walletValid = await requireWallet(async () => { setIsSigningTransaction(true); setFlowStep('confirming'); @@ -308,6 +308,10 @@ const CreateProjectModal = ({ open, setOpen }: CreateProjectModalProps) => { setFlowStep('signing'); } }); + + if (!walletValid) { + return; + } }; const handleSubmit = async () => { @@ -325,13 +329,52 @@ const CreateProjectModal = ({ open, setOpen }: CreateProjectModalProps) => { endDate: z.string().min(1), }) .superRefine((val, ctx) => { - if (new Date(val.startDate) >= new Date(val.endDate)) { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const startDate = new Date(val.startDate); + const endDate = new Date(val.endDate); + + // Check if start date is in the future (at least tomorrow) + if (startDate <= today) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['startDate'], + message: 'Start date must be at least tomorrow', + }); + } + + // Check if end date is after start date + if (endDate <= startDate) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['endDate'], message: 'End date must be after start date', }); } + + // Check if milestone has reasonable duration (at least 1 week) + const durationInDays = Math.ceil( + (endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24) + ); + if (durationInDays < 7) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['endDate'], + message: 'Milestone duration must be at least 1 week', + }); + } + + // Check if milestone is not too far in the future (max 2 years) + const maxFutureDate = new Date(); + maxFutureDate.setFullYear(maxFutureDate.getFullYear() + 2); + if (startDate > maxFutureDate) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['startDate'], + message: 'Start date cannot be more than 2 years in the future', + }); + } }); const projectSchema = z.object({ @@ -340,13 +383,53 @@ const CreateProjectModal = ({ open, setOpen }: CreateProjectModalProps) => { logo: z.any().optional(), vision: z.string().trim().min(1).max(300), category: z.string().trim().min(1), - githubUrl: z.string().url().optional().or(z.literal('')).optional(), - websiteUrl: z.string().url().optional().or(z.literal('')).optional(), + githubUrl: z + .string() + .trim() + .optional() + .or(z.literal('')) + .refine( + v => + !v || + /^https?:\/\/.+/i.test(v) || + /^[\w.-]+\.[a-z]{2,}$/i.test(v), + { + message: + 'Please enter a valid URL (with or without https), e.g., https://github.com or github.com', + } + ) + .optional(), + websiteUrl: z + .string() + .trim() + .optional() + .or(z.literal('')) + .refine( + v => + !v || + /^https?:\/\/.+/i.test(v) || + /^[\w.-]+\.[a-z]{2,}$/i.test(v), + { + message: + 'Please enter a valid URL (with or without https), e.g., https://boundlessfi.xyz or boundlessfi.xyz', + } + ) + .optional(), demoVideoUrl: z .string() - .url() + .trim() .optional() .or(z.literal('')) + .refine( + v => + !v || + /^https?:\/\/.+/i.test(v) || + /^[\w.-]+\.[a-z]{2,}$/i.test(v), + { + message: + 'Please enter a valid URL (with or without https), e.g., https://demo.com or demo.com', + } + ) .optional(), socialLinks: z.array(z.string()).min(1), }), @@ -357,7 +440,51 @@ const CreateProjectModal = ({ open, setOpen }: CreateProjectModalProps) => { fundingAmount: z .string() .refine(v => !isNaN(parseFloat(v)) && parseFloat(v) > 0), - milestones: z.array(milestoneSchema).min(1), + milestones: z + .array(milestoneSchema) + .min(1) + .superRefine((milestones, ctx) => { + // Check that milestones are in chronological order + for (let i = 0; i < milestones.length - 1; i++) { + const currentEndDate = new Date(milestones[i].endDate); + const nextStartDate = new Date(milestones[i + 1].startDate); + + // Allow some overlap (up to 1 day) but not significant overlap + const daysBetween = Math.ceil( + (nextStartDate.getTime() - currentEndDate.getTime()) / + (1000 * 60 * 60 * 24) + ); + + if (daysBetween < -1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [i + 1, 'startDate'], + message: `Milestone ${i + 2} start date should be after milestone ${i + 1} end date`, + }); + } + } + + // Check that total project timeline is reasonable (max 3 years) + if (milestones.length > 0) { + const firstStartDate = new Date(milestones[0].startDate); + const lastEndDate = new Date( + milestones[milestones.length - 1].endDate + ); + const totalDurationInDays = Math.ceil( + (lastEndDate.getTime() - firstStartDate.getTime()) / + (1000 * 60 * 60 * 24) + ); + + if (totalDurationInDays > 1095) { + // 3 years + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['milestones'], + message: 'Total project timeline cannot exceed 3 years', + }); + } + } + }), }), team: z .object({ diff --git a/components/layout/header.tsx b/components/layout/header.tsx index 1f7af7fec..4c4480e0f 100644 --- a/components/layout/header.tsx +++ b/components/layout/header.tsx @@ -11,11 +11,26 @@ import { fadeInUp, slideInFromLeft, slideInFromRight } from '@/lib/motion'; import WalletConnectButton from '../wallet/WalletConnectButton'; import { ProjectSheetFlow } from '../project'; import { useProjectSheetStore } from '@/lib/stores/project-sheet-store'; +import { useProtectedAction } from '@/hooks/use-protected-action'; +import WalletRequiredModal from '../wallet/WalletRequiredModal'; const Header = () => { const [open, setOpen] = useState(false); const sheet = useProjectSheetStore(); + const { + executeProtectedAction, + showWalletModal, + closeWalletModal, + handleWalletConnected, + } = useProtectedAction({ + actionName: 'create project', + onSuccess: () => { + sheet.openInitialize(); + setOpen(true); + }, + }); + return ( { size='default' icon={} iconPosition='right' - onClick={() => { - sheet.openInitialize(); - setOpen(true); + onClick={async () => { + await executeProtectedAction(() => { + sheet.openInitialize(); + setOpen(true); + }); }} > New Project @@ -107,6 +124,13 @@ const Header = () => { sheet.setOpen(o); }} /> + + ); }; diff --git a/components/modals/fund-project/index.tsx b/components/modals/fund-project/index.tsx index a360dd2dc..348d00046 100644 --- a/components/modals/fund-project/index.tsx +++ b/components/modals/fund-project/index.tsx @@ -13,6 +13,7 @@ import { confirmProjectFunding, } from '@/lib/api/project'; import { useWalletInfo, useWalletSigning } from '@/hooks/use-wallet'; +import { useWalletProtection } from '@/hooks/use-wallet-protection'; interface FundProjectProps { open: boolean; @@ -62,6 +63,9 @@ const FundProject = ({ open, setOpen, project }: FundProjectProps) => { // Wallet hooks const { signTransaction } = useWalletSigning(); const { address } = useWalletInfo() || { address: '' }; + const { requireWallet } = useWalletProtection({ + actionName: 'fund project', + }); // Form data state const [formData, setFormData] = useState({ amount: {}, @@ -207,7 +211,17 @@ const FundProject = ({ open, setOpen, project }: FundProjectProps) => { throw new Error(prepareResponse.message || 'Failed to prepare funding'); } - // Step 2: Sign transaction + // Step 2: Sign transaction with wallet protection + const walletValid = await requireWallet(); + + if (!walletValid) { + setError('Wallet connection required to fund project'); + setFlowStep('form'); + setIsLoading(false); + setIsSubmitting(false); + return; + } + const signedXdr = await signTransaction(prepareResponse.data.unsignedXdr); // Step 3: Confirm funding diff --git a/components/project-details/project-sidebar/ProjectSidebarActions.tsx b/components/project-details/project-sidebar/ProjectSidebarActions.tsx index 4414ab5ee..723cf64f1 100644 --- a/components/project-details/project-sidebar/ProjectSidebarActions.tsx +++ b/components/project-details/project-sidebar/ProjectSidebarActions.tsx @@ -14,6 +14,8 @@ import { ProjectSidebarActionsProps } from './types'; import { BoundlessButton } from '@/components/buttons'; import { SharePopup } from './SharePopup'; import FundProject from '@/components/modals/fund-project'; +import { useProtectedAction } from '@/hooks/use-protected-action'; +import WalletRequiredModal from '@/components/wallet/WalletRequiredModal'; export function ProjectSidebarActions({ project, @@ -25,6 +27,16 @@ export function ProjectSidebarActions({ const [isSharePopupOpen, setIsSharePopupOpen] = useState(false); const [isFundModalOpen, setIsFundModalOpen] = useState(false); + const { + executeProtectedAction, + showWalletModal, + closeWalletModal, + handleWalletConnected, + } = useProtectedAction({ + actionName: 'fund project', + onSuccess: () => setIsFundModalOpen(true), + }); + const handleShareClick = () => { setIsSharePopupOpen(true); }; @@ -33,8 +45,8 @@ export function ProjectSidebarActions({ setIsSharePopupOpen(false); }; - const handleFundClick = () => { - setIsFundModalOpen(true); + const handleFundClick = async () => { + await executeProtectedAction(() => setIsFundModalOpen(true)); }; return ( @@ -142,6 +154,13 @@ export function ProjectSidebarActions({ : undefined, }} /> + +
); } diff --git a/components/providers/wallet-provider.tsx b/components/providers/wallet-provider.tsx new file mode 100644 index 000000000..26229d46b --- /dev/null +++ b/components/providers/wallet-provider.tsx @@ -0,0 +1,53 @@ +import React, { useEffect, useState } from 'react'; +import { useWalletStore } from '@/hooks/use-wallet'; +import { useAutoReconnect } from '@/hooks/use-wallet'; + +interface WalletProviderProps { + children: React.ReactNode; +} + +export function WalletProvider({ children }: WalletProviderProps) { + const [isInitialized, setIsInitialized] = useState(false); + const { initializeWalletKit, network } = useWalletStore(); + + useEffect(() => { + const initialize = async () => { + try { + await initializeWalletKit(network); + setIsInitialized(true); + } catch { + setIsInitialized(true); + } + }; + + initialize(); + }, [initializeWalletKit, network]); + + useAutoReconnect(); + + if (!isInitialized) { + return ( +
+
+
+

Initializing wallet connection...

+
+
+ ); + } + + return <>{children}; +} + +export function useWalletProvider() { + const { isConnected, publicKey, selectedWallet, network, error } = + useWalletStore(); + + return { + isConnected, + publicKey, + selectedWallet, + network, + error, + }; +} diff --git a/hooks/use-protected-action.ts b/hooks/use-protected-action.ts new file mode 100644 index 000000000..9ac880d06 --- /dev/null +++ b/hooks/use-protected-action.ts @@ -0,0 +1,98 @@ +import { useState, useCallback, useEffect } from 'react'; +import { useWalletStore } from './use-wallet'; +import { useWalletProtection } from './use-wallet-protection'; + +interface UseProtectedActionOptions { + actionName: string; + onSuccess?: () => void; + redirectTo?: string; +} + +export function useProtectedAction({ + actionName, + onSuccess, + redirectTo, +}: UseProtectedActionOptions) { + const { isConnected, publicKey } = useWalletStore(); + const { + requireWallet, + showWalletModal, + handleWalletConnected, + closeWalletModal, + } = useWalletProtection({ + actionName, + showModal: true, + }); + const [pendingAction, setPendingAction] = useState<(() => void) | null>(null); + const [isHydrated, setIsHydrated] = useState(false); + + useEffect(() => { + const checkHydration = () => { + if (useWalletStore.persist.hasHydrated()) { + setIsHydrated(true); + } else { + const unsubscribe = useWalletStore.persist.onFinishHydration(() => { + setIsHydrated(true); + }); + return unsubscribe; + } + }; + + const cleanup = checkHydration(); + return cleanup; + }, []); + + const executeProtectedAction = useCallback( + async (action: () => void | Promise) => { + if (!isHydrated) { + return false; + } + + if (!isConnected || !publicKey) { + setPendingAction(() => action); + requireWallet(); + return false; + } + + const isValid = await requireWallet(); + if (!isValid) { + setPendingAction(() => action); + return false; + } + + try { + await action(); + onSuccess?.(); + return true; + } catch { + return false; + } + }, + [isHydrated, isConnected, publicKey, requireWallet, actionName, onSuccess] + ); + + const handleWalletConnectedWithRedirect = useCallback(() => { + handleWalletConnected(); + if (pendingAction) { + pendingAction(); + setPendingAction(null); + } + + if (redirectTo) { + window.location.href = redirectTo; + } + }, [handleWalletConnected, pendingAction, redirectTo]); + + const clearPendingAction = useCallback(() => { + setPendingAction(null); + }, []); + + return { + executeProtectedAction, + showWalletModal, + closeWalletModal, + handleWalletConnected: handleWalletConnectedWithRedirect, + clearPendingAction, + hasPendingAction: !!pendingAction, + }; +} diff --git a/hooks/use-wallet-protection.ts b/hooks/use-wallet-protection.ts index 50235537c..1a2111850 100644 --- a/hooks/use-wallet-protection.ts +++ b/hooks/use-wallet-protection.ts @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useWalletStore } from './use-wallet'; import { toast } from 'sonner'; @@ -9,11 +9,33 @@ interface UseWalletProtectionOptions { export function useWalletProtection(options: UseWalletProtectionOptions = {}) { const { actionName = 'perform this action', showModal = true } = options; - const { isConnected, publicKey } = useWalletStore(); + const { isConnected, publicKey, validateConnection } = useWalletStore(); const [showWalletModal, setShowWalletModal] = useState(false); + const [isValidating, setIsValidating] = useState(false); - const requireWallet = (callback?: () => void) => { - if (!isConnected) { + useEffect(() => { + const validateAndUpdateConnection = async () => { + if (isConnected && publicKey) { + setIsValidating(true); + try { + const isValid = await validateConnection(); + if (!isValid && isConnected) { + toast.error( + 'Wallet connection lost. Please reconnect your wallet.' + ); + } + } catch { + } finally { + setIsValidating(false); + } + } + }; + + validateAndUpdateConnection(); + }, [isConnected, publicKey, validateConnection]); + + const requireWallet = async (callback?: () => void) => { + if (!isConnected || !publicKey) { if (showModal) { setShowWalletModal(true); } else { @@ -22,12 +44,37 @@ export function useWalletProtection(options: UseWalletProtectionOptions = {}) { return false; } - // If callback provided and wallet is connected, execute it - if (callback) { - callback(); - } + setIsValidating(true); + try { + const isValid = await validateConnection(); + if (!isValid) { + if (showModal) { + setShowWalletModal(true); + } else { + toast.error( + `Wallet connection lost. Please reconnect to ${actionName}` + ); + } + return false; + } + + if (callback) { + callback(); + } - return true; + return true; + } catch { + if (showModal) { + setShowWalletModal(true); + } else { + toast.error( + `Wallet connection validation failed. Please reconnect to ${actionName}` + ); + } + return false; + } finally { + setIsValidating(false); + } }; const handleWalletConnected = () => { @@ -46,5 +93,6 @@ export function useWalletProtection(options: UseWalletProtectionOptions = {}) { showWalletModal, handleWalletConnected, closeWalletModal, + isValidating, }; } diff --git a/hooks/use-wallet.ts b/hooks/use-wallet.ts index b4ec9ea34..37e8c0b4f 100644 --- a/hooks/use-wallet.ts +++ b/hooks/use-wallet.ts @@ -58,6 +58,7 @@ export interface WalletState { setError: (error: string | null) => void; clearError: () => void; getWalletInfo: () => { address: string; network: string } | null; + validateConnection: () => Promise; } let walletKitInstance: StellarWalletsKit | null = null; @@ -546,6 +547,55 @@ export const useWalletStore = create()( network: network === 'testnet' ? 'TESTNET' : 'PUBLIC', }; }, + + validateConnection: async () => { + const { isConnected, publicKey, selectedWallet, walletKit } = get(); + + if (!isConnected || !publicKey || !selectedWallet) { + return false; + } + + try { + // For Freighter with direct API + if (usingFreighterAPI && selectedWallet === 'freighter') { + const isStillConnected = await freighterIsConnected(); + if (!isStillConnected) { + // Clear the connection state + get().disconnectWallet(); + return false; + } + + // Verify the address is still the same + const currentAddress = await freighterGetAddress(); + if (currentAddress.address !== publicKey) { + // Address changed, update it + set({ publicKey: currentAddress.address }); + } + return true; + } + + // For other wallets using wallet kit + if (walletKit) { + try { + const addressResult = await walletKit.getAddress(); + if (addressResult.address !== publicKey) { + // Address changed, update it + set({ publicKey: addressResult.address }); + } + return true; + } catch { + // Connection lost + get().disconnectWallet(); + return false; + } + } + + return false; + } catch { + get().disconnectWallet(); + return false; + } + }, }), { name: 'stellar-wallets-kit-storage', @@ -578,14 +628,38 @@ export function useWalletInfo() { } export function useAutoReconnect() { - const { isConnected, publicKey, selectedWallet, connectWallet } = - useWalletStore(); + const { + isConnected, + publicKey, + selectedWallet, + connectWallet, + initializeWalletKit, + network, + } = useWalletStore(); useEffect(() => { - if (publicKey && selectedWallet && !isConnected) { - connectWallet(selectedWallet).catch(() => {}); - } - }, [publicKey, selectedWallet, isConnected, connectWallet]); + const attemptReconnection = async () => { + if (publicKey && selectedWallet && !isConnected) { + try { + await initializeWalletKit(network); + await connectWallet(selectedWallet); + } catch { + useWalletStore.getState().disconnectWallet(); + } + } + }; + + const timeoutId = setTimeout(attemptReconnection, 1000); + + return () => clearTimeout(timeoutId); + }, [ + publicKey, + selectedWallet, + isConnected, + connectWallet, + initializeWalletKit, + network, + ]); } export function useWalletConnection() { diff --git a/lib/api/api.ts b/lib/api/api.ts index 1af831c4d..edeb5c309 100644 --- a/lib/api/api.ts +++ b/lib/api/api.ts @@ -2,8 +2,8 @@ import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; import Cookies from 'js-cookie'; import { useAuthStore } from '@/lib/stores/auth-store'; -const API_BASE_URL = 'https://staging-api.boundlessfi.xyz/api'; -// const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL; +// const API_BASE_URL = 'https://staging-api.boundlessfi.xyz/api'; +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL; if (!API_BASE_URL) { throw new Error('NEXT_PUBLIC_API_URL environment variable is not defined'); }