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 (
+
+ );
+ }
+
+ 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