From dfb15f2676f9fea5ef9357606f31cea690977d1e Mon Sep 17 00:00:00 2001
From: wheval
Date: Mon, 29 Sep 2025 10:53:34 +0100
Subject: [PATCH 1/2] feat: implement create project modal
---
app/test/page.tsx | 83 +---
components/landing-page/navbar.tsx | 42 +-
.../project/CreateProjectModal/Basic.tsx | 365 +++++++++++++++-
.../project/CreateProjectModal/Contact.tsx | 395 +++++++++++++++++-
.../project/CreateProjectModal/Details.tsx | 269 +++++++++++-
.../project/CreateProjectModal/Footer.tsx | 10 +-
.../project/CreateProjectModal/Milestones.tsx | 337 ++++++++++++++-
.../project/CreateProjectModal/Team.tsx | 188 ++++++++-
.../project/CreateProjectModal/index.tsx | 146 ++++++-
9 files changed, 1707 insertions(+), 128 deletions(-)
diff --git a/app/test/page.tsx b/app/test/page.tsx
index d66ac21a0..14e721bec 100644
--- a/app/test/page.tsx
+++ b/app/test/page.tsx
@@ -1,81 +1,4 @@
-'use client';
-
-import { useState } from 'react';
-import LaunchCampaignFlow from '@/components/project/LaunchCampaignFlow';
-import BoundlessSheet from '@/components/sheet/boundless-sheet';
-import { Button } from '@/components/ui/button';
-import { Rocket } from 'lucide-react';
-import { useWalletProtection } from '@/hooks/use-wallet-protection';
-import WalletRequiredModal from '@/components/wallet/WalletRequiredModal';
-
-export default function TestPage() {
- const [showLaunchFlow, setShowLaunchFlow] = useState(false);
-
- // Wallet protection hook
- const {
- requireWallet,
- showWalletModal,
- handleWalletConnected,
- closeWalletModal,
- } = useWalletProtection({
- actionName: 'test launch campaign',
- });
-
- const handleOpenModal = () => {
- requireWallet(() => setShowLaunchFlow(true));
- };
-
- const handleCloseModal = () => {
- setShowLaunchFlow(false);
- };
-
- return (
-
-
-
- Launch Campaign Test
-
-
-
- Click the button below to test the Launch Campaign feature
-
-
-
-
- Test Launch Campaign
-
-
- {/* Debug info */}
-
- Modal state: {showLaunchFlow ? 'Open' : 'Closed'}
-
-
- {/* Launch Campaign Flow Modal */}
-
-
-
-
- {/* Wallet Required Modal */}
-
-
-
- );
+// Removed temporary test page. This file is intentionally empty.
+export default function Page() {
+ return null;
}
diff --git a/components/landing-page/navbar.tsx b/components/landing-page/navbar.tsx
index e67324ec2..2bfda6afa 100644
--- a/components/landing-page/navbar.tsx
+++ b/components/landing-page/navbar.tsx
@@ -199,9 +199,7 @@ export function Navbar() {
) : isAuthenticated ? (
) : (
-
- Get Started
-
+
)}
+ Get Started
+
+ );
+ }
+
+ return (
+
+
setCreateProjectModalOpen(true)}
+ className='border-white/20 text-white hover:bg-white/10'
+ >
+
+ Add Project
+
+
+ Sign in
+
+
+
+ );
+}
+
function MobileMenu({
isAuthenticated,
isLoading,
diff --git a/components/landing-page/project/CreateProjectModal/Basic.tsx b/components/landing-page/project/CreateProjectModal/Basic.tsx
index 2dd154c24..18a82ea1c 100644
--- a/components/landing-page/project/CreateProjectModal/Basic.tsx
+++ b/components/landing-page/project/CreateProjectModal/Basic.tsx
@@ -1,7 +1,364 @@
-import React from 'react';
+'use client';
-const Basic = () => {
- return Basic
;
-};
+import React, { useState } from 'react';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
+import { Label } from '@/components/ui/label';
+import { Camera, Info, Lightbulb } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+interface BasicProps {
+ onDataChange?: (data: BasicFormData) => void;
+ initialData?: Partial;
+}
+
+export interface BasicFormData {
+ projectName: string;
+ logo: File | null;
+ vision: string;
+ category: string;
+ githubUrl: string;
+ websiteUrl: string;
+ demoVideoUrl: string;
+ socialLinks: string[];
+}
+
+const categories = [
+ 'DeFi & Finance',
+ 'Gaming & Metaverse',
+ 'Social & Community',
+ 'Infrastructure & Tooling',
+ 'AI & Machine Learning',
+ 'Sustainability & Impact',
+ 'Other',
+];
+
+const Basic = React.forwardRef<{ validate: () => boolean }, BasicProps>(
+ ({ onDataChange, initialData }, ref) => {
+ const [formData, setFormData] = useState({
+ projectName: initialData?.projectName || '',
+ logo: initialData?.logo || null,
+ vision: initialData?.vision || '',
+ category: initialData?.category || '',
+ githubUrl: initialData?.githubUrl || '',
+ websiteUrl: initialData?.websiteUrl || '',
+ demoVideoUrl: initialData?.demoVideoUrl || '',
+ socialLinks: initialData?.socialLinks || ['', '', ''],
+ });
+
+ const [errors, setErrors] = useState<
+ Partial>
+ >({});
+
+ const handleInputChange = (
+ field: keyof BasicFormData,
+ value: string | File | string[]
+ ) => {
+ const newData = { ...formData, [field]: value };
+ setFormData(newData);
+
+ // Clear error when user starts typing
+ if (errors[field]) {
+ setErrors(prev => ({ ...prev, [field]: undefined }));
+ }
+
+ onDataChange?.(newData);
+ };
+
+ const handleSocialLinkChange = (index: number, value: string) => {
+ const newSocialLinks = [...formData.socialLinks];
+ newSocialLinks[index] = value;
+ handleInputChange('socialLinks', newSocialLinks);
+ };
+
+ const handleFileUpload = (event: React.ChangeEvent) => {
+ const file = event.target.files?.[0];
+ if (file) {
+ // Validate file type
+ if (!file.type.match(/^image\/(jpeg|png)$/)) {
+ setErrors(prev => ({
+ ...prev,
+ logo: 'Only JPEG and PNG files are allowed',
+ }));
+ return;
+ }
+
+ // Validate file size (2MB = 2 * 1024 * 1024 bytes)
+ if (file.size > 2 * 1024 * 1024) {
+ setErrors(prev => ({
+ ...prev,
+ logo: 'File size must be less than 2MB',
+ }));
+ return;
+ }
+
+ handleInputChange('logo', file);
+ }
+ };
+
+ const validateForm = (): boolean => {
+ const newErrors: Partial> = {};
+
+ if (!formData.projectName.trim()) {
+ newErrors.projectName = 'Project name is required';
+ }
+
+ if (!formData.logo) {
+ newErrors.logo = 'Logo is required';
+ }
+
+ if (!formData.vision.trim()) {
+ newErrors.vision = 'Vision is required';
+ } else if (formData.vision.length > 300) {
+ newErrors.vision = 'Vision must be 300 characters or less';
+ }
+
+ if (!formData.category) {
+ newErrors.category = 'Category is required';
+ }
+
+ const validSocialLinks = formData.socialLinks.filter(link => link.trim());
+ if (validSocialLinks.length === 0) {
+ newErrors.socialLinks = 'At least one social link is required';
+ }
+
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ };
+
+ // Expose validation function to parent
+ React.useImperativeHandle(ref, () => ({
+ validate: validateForm,
+ }));
+
+ return (
+
+ {/* Project Name */}
+
+
+ Project Name *
+
+
handleInputChange('projectName', e.target.value)}
+ className={cn(
+ 'focus:border-primary border-[#484848] bg-[#1A1A1A] text-white placeholder:text-[#919191]',
+ errors.projectName && 'border-red-500'
+ )}
+ />
+ {errors.projectName && (
+
{errors.projectName}
+ )}
+
+
+ {/* Logo/Image */}
+
+
+ Logo/Image *
+
+
+
+
+ {formData.logo ? (
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
Change
+
+ ) : (
+
+
+ Upload
+
+ )}
+
+
+
+
+ Accepted file type:{' '}
+ JPEG or{' '}
+ PNG , and less
+ than 2 MB .
+
+
+ A size of{' '}
+ 480 x 480 px is
+ recommended.
+
+
+ {errors.logo &&
{errors.logo}
}
+
+
+ {/* Vision */}
+
+
+
+ Vision *
+
+
+ {formData.vision.length}/300
+
+
+
+
+ {/* Category */}
+
+
+ Category *
+
+
handleInputChange('category', value)}
+ className='grid grid-cols-1 gap-3 md:grid-cols-2'
+ >
+ {categories.map(category => (
+
+
+
+ {category}
+
+
+ ))}
+
+ {errors.category && (
+
{errors.category}
+ )}
+
+
+ {/* GitHub/GitLab/Bitbucket */}
+
+
+
+ GitHub/Gitlab/Bitbucket (optional unless hackathons required)
+
+
+
+
handleInputChange('githubUrl', e.target.value)}
+ className='focus:border-primary border-[#484848] bg-[#1A1A1A] text-white placeholder:text-[#919191]'
+ />
+
+ E.g., "https://github.com/org" or "https://github.com/org/repo"
+
+
+
+ {/* Project Website */}
+
+ Project Website (optional)
+ handleInputChange('websiteUrl', e.target.value)}
+ className='focus:border-primary border-[#484848] bg-[#1A1A1A] text-white placeholder:text-[#919191]'
+ />
+
+
+ {/* Demo Video */}
+
+
+
+ Demo Video (optional unless hackathons required)
+
+
+
+
handleInputChange('demoVideoUrl', e.target.value)}
+ className='focus:border-primary border-[#484848] bg-[#1A1A1A] text-white placeholder:text-[#919191]'
+ />
+
+
+
+ Tip: A YouTube video link will be displayed as an embedded player.
+
+
+
+
+ {/* Social Links */}
+
+
+ Social links (at least one link){' '}
+ *
+
+ {formData.socialLinks.map((link, index) => (
+
handleSocialLinkChange(index, e.target.value)}
+ className='focus:border-primary border-[#484848] bg-[#1A1A1A] text-white placeholder:text-[#919191]'
+ />
+ ))}
+
+ You can add up to 3 social links on your BUIDL profile, e.g.,
+ Facebook Page, Farcaster, Instagram, Substack, X/Twitter, YakiHonne,
+ etc.
+
+ {errors.socialLinks && (
+
{errors.socialLinks}
+ )}
+
+
+ );
+ }
+);
+
+Basic.displayName = 'Basic';
export default Basic;
diff --git a/components/landing-page/project/CreateProjectModal/Contact.tsx b/components/landing-page/project/CreateProjectModal/Contact.tsx
index d1ac84631..42eae2857 100644
--- a/components/landing-page/project/CreateProjectModal/Contact.tsx
+++ b/components/landing-page/project/CreateProjectModal/Contact.tsx
@@ -1,7 +1,394 @@
-import React from 'react';
+'use client';
-const Contact = () => {
- return Contact
;
-};
+import React, { useState } from 'react';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { Label } from '@/components/ui/label';
+import { Checkbox } from '@/components/ui/checkbox';
+import { cn } from '@/lib/utils';
+import { Mail, Phone, MapPin, Info } from 'lucide-react';
+
+interface ContactProps {
+ onDataChange?: (data: ContactFormData) => void;
+ initialData?: Partial;
+}
+
+export interface ContactFormData {
+ email: string;
+ phone: string;
+ address: string;
+ city: string;
+ country: string;
+ postalCode: string;
+ additionalInfo: string;
+ agreeToTerms: boolean;
+ agreeToPrivacy: boolean;
+ agreeToMarketing: boolean;
+}
+
+const Contact = React.forwardRef<{ validate: () => boolean }, ContactProps>(
+ ({ onDataChange, initialData }, ref) => {
+ const [formData, setFormData] = useState({
+ email: initialData?.email || '',
+ phone: initialData?.phone || '',
+ address: initialData?.address || '',
+ city: initialData?.city || '',
+ country: initialData?.country || '',
+ postalCode: initialData?.postalCode || '',
+ additionalInfo: initialData?.additionalInfo || '',
+ agreeToTerms: initialData?.agreeToTerms || false,
+ agreeToPrivacy: initialData?.agreeToPrivacy || false,
+ agreeToMarketing: initialData?.agreeToMarketing || false,
+ });
+
+ const [errors, setErrors] = useState<
+ Partial>
+ >({});
+
+ const handleInputChange = (
+ field: keyof ContactFormData,
+ value: string | boolean
+ ) => {
+ const newData = { ...formData, [field]: value };
+ setFormData(newData);
+
+ // Clear error when user starts typing
+ if (errors[field]) {
+ setErrors(prev => ({ ...prev, [field]: undefined }));
+ }
+
+ onDataChange?.(newData);
+ };
+
+ const validateForm = (): boolean => {
+ const newErrors: Partial> = {};
+
+ if (!formData.email.trim()) {
+ newErrors.email = 'Email is required';
+ } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
+ newErrors.email = 'Please enter a valid email address';
+ }
+
+ if (!formData.phone.trim()) {
+ newErrors.phone = 'Phone number is required';
+ }
+
+ if (!formData.address.trim()) {
+ newErrors.address = 'Address is required';
+ }
+
+ if (!formData.city.trim()) {
+ newErrors.city = 'City is required';
+ }
+
+ if (!formData.country.trim()) {
+ newErrors.country = 'Country is required';
+ }
+
+ if (!formData.postalCode.trim()) {
+ newErrors.postalCode = 'Postal code is required';
+ }
+
+ if (!formData.agreeToTerms) {
+ newErrors.agreeToTerms = 'You must agree to the Terms of Service';
+ }
+
+ if (!formData.agreeToPrivacy) {
+ newErrors.agreeToPrivacy = 'You must agree to the Privacy Policy';
+ }
+
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ };
+
+ // Expose validation function to parent
+ React.useImperativeHandle(ref, () => ({
+ validate: validateForm,
+ }));
+
+ return (
+
+ {/* Contact Information */}
+
+
+
+ Contact Information
+
+
+ Please provide your contact details for project communication and
+ verification.
+
+
+
+ {/* Email */}
+
+
+ Email Address *
+
+
+
+ handleInputChange('email', e.target.value)}
+ className={cn(
+ 'focus:border-primary border-[#484848] bg-[#1A1A1A] pl-10 text-white placeholder:text-[#919191]',
+ errors.email && 'border-red-500'
+ )}
+ />
+
+ {errors.email && (
+
{errors.email}
+ )}
+
+
+ {/* Phone */}
+
+
+ Phone Number *
+
+
+
+
handleInputChange('phone', e.target.value)}
+ className={cn(
+ 'focus:border-primary border-[#484848] bg-[#1A1A1A] pl-10 text-white placeholder:text-[#919191]',
+ errors.phone && 'border-red-500'
+ )}
+ />
+
+ {errors.phone && (
+
{errors.phone}
+ )}
+
+
+
+ {/* Address Information */}
+
+
+
+ Address Information
+
+
+ This information is required for legal and verification purposes.
+
+
+
+ {/* Address */}
+
+
+ Street Address *
+
+
+
+ handleInputChange('address', e.target.value)}
+ className={cn(
+ 'focus:border-primary border-[#484848] bg-[#1A1A1A] pl-10 text-white placeholder:text-[#919191]',
+ errors.address && 'border-red-500'
+ )}
+ />
+
+ {errors.address && (
+
{errors.address}
+ )}
+
+
+ {/* City, Country, Postal Code */}
+
+
+
+ City *
+
+
handleInputChange('city', e.target.value)}
+ className={cn(
+ 'focus:border-primary border-[#484848] bg-[#1A1A1A] text-white placeholder:text-[#919191]',
+ errors.city && 'border-red-500'
+ )}
+ />
+ {errors.city && (
+
{errors.city}
+ )}
+
+
+
+
+ Country *
+
+
handleInputChange('country', e.target.value)}
+ className={cn(
+ 'focus:border-primary border-[#484848] bg-[#1A1A1A] text-white placeholder:text-[#919191]',
+ errors.country && 'border-red-500'
+ )}
+ />
+ {errors.country && (
+
{errors.country}
+ )}
+
+
+
+
+ Postal Code *
+
+
handleInputChange('postalCode', e.target.value)}
+ className={cn(
+ 'focus:border-primary border-[#484848] bg-[#1A1A1A] text-white placeholder:text-[#919191]',
+ errors.postalCode && 'border-red-500'
+ )}
+ />
+ {errors.postalCode && (
+
{errors.postalCode}
+ )}
+
+
+
+
+ {/* Additional Information */}
+
+
+ Additional Information (Optional)
+
+
+
+ {/* Terms and Agreements */}
+
+
+
+ Terms and Agreements
+
+
+ Please review and accept the following terms to continue.
+
+
+
+
+
+
+ handleInputChange('agreeToTerms', checked as boolean)
+ }
+ className='mt-1'
+ />
+
+
+ I agree to the{' '}
+
+ Terms of Service
+ {' '}
+ *
+
+ {errors.agreeToTerms && (
+
{errors.agreeToTerms}
+ )}
+
+
+
+
+
+ handleInputChange('agreeToPrivacy', checked as boolean)
+ }
+ className='mt-1'
+ />
+
+
+ I agree to the{' '}
+
+ Privacy Policy
+ {' '}
+ *
+
+ {errors.agreeToPrivacy && (
+
+ {errors.agreeToPrivacy}
+
+ )}
+
+
+
+
+
+ handleInputChange('agreeToMarketing', checked as boolean)
+ }
+ className='mt-1'
+ />
+
+ I would like to receive updates about new features and
+ opportunities (optional)
+
+
+
+
+
+ {/* Information Box */}
+
+
+
+
+
+ What happens next?
+
+
+ After submitting your project, our team will review your
+ application within 3-5 business days. You'll receive an email
+ notification once the review is complete. If approved, your
+ project will be published and available for funding.
+
+
+
+
+
+ );
+ }
+);
+
+Contact.displayName = 'Contact';
export default Contact;
diff --git a/components/landing-page/project/CreateProjectModal/Details.tsx b/components/landing-page/project/CreateProjectModal/Details.tsx
index e4bd461bc..fb74c5653 100644
--- a/components/landing-page/project/CreateProjectModal/Details.tsx
+++ b/components/landing-page/project/CreateProjectModal/Details.tsx
@@ -1,7 +1,268 @@
-import React from 'react';
+'use client';
-const Details = () => {
- return Details
;
-};
+import React, { useState, useRef } from 'react';
+import { Label } from '@/components/ui/label';
+import { Button } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+import {
+ Undo2,
+ Redo2,
+ Bold,
+ Italic,
+ Strikethrough,
+ Code,
+ Link,
+ Image,
+ Calendar,
+} from 'lucide-react';
+
+interface DetailsProps {
+ onDataChange?: (data: DetailsFormData) => void;
+ initialData?: Partial;
+}
+
+export interface DetailsFormData {
+ vision: string;
+}
+
+const Details = React.forwardRef<{ validate: () => boolean }, DetailsProps>(
+ ({ onDataChange, initialData }, ref) => {
+ const [vision, setVision] = useState(initialData?.vision || '');
+ const [errors, setErrors] = useState<{ vision?: string }>({});
+ const editorRef = useRef(null);
+
+ const handleVisionChange = (value: string) => {
+ setVision(value);
+ onDataChange?.({ vision: value });
+
+ // Clear error when user starts typing
+ if (errors.vision) {
+ setErrors(prev => ({ ...prev, vision: undefined }));
+ }
+ };
+
+ const validateForm = (): boolean => {
+ const newErrors: { vision?: string } = {};
+
+ if (!vision.trim()) {
+ newErrors.vision = 'Vision is required';
+ }
+
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ };
+
+ // Expose validation function to parent
+ React.useImperativeHandle(ref, () => ({
+ validate: validateForm,
+ }));
+
+ const executeCommand = (command: string, value?: string) => {
+ document.execCommand(command, false, value);
+ editorRef.current?.focus();
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.ctrlKey || e.metaKey) {
+ switch (e.key) {
+ case 'b':
+ e.preventDefault();
+ executeCommand('bold');
+ break;
+ case 'i':
+ e.preventDefault();
+ executeCommand('italic');
+ break;
+ case 'u':
+ e.preventDefault();
+ executeCommand('underline');
+ break;
+ case 'k':
+ e.preventDefault();
+ const url = prompt('Enter URL:');
+ if (url) executeCommand('createLink', url);
+ break;
+ }
+ }
+ };
+
+ const handleInput = () => {
+ if (editorRef.current) {
+ const content = editorRef.current.innerHTML;
+ handleVisionChange(content);
+ }
+ };
+
+ const handlePaste = (e: React.ClipboardEvent) => {
+ e.preventDefault();
+ const text = e.clipboardData.getData('text/plain');
+ document.execCommand('insertText', false, text);
+ };
+
+ return (
+
+ {/* Vision */}
+
+
+ Vision *
+
+
+ {/* Rich Text Editor */}
+
+ {/* Toolbar */}
+
+
executeCommand('undo')}
+ className='h-8 w-8 p-0 text-[#B5B5B5] hover:bg-[#2A2A2A] hover:text-white'
+ >
+
+
+
+
executeCommand('redo')}
+ className='h-8 w-8 p-0 text-[#B5B5B5] hover:bg-[#2A2A2A] hover:text-white'
+ >
+
+
+
+
+
+
executeCommand('formatBlock', e.target.value)}
+ className='bg-transparent text-sm text-[#B5B5B5] focus:outline-none'
+ defaultValue='normal'
+ >
+ Normal
+ Heading 1
+ Heading 2
+ Heading 3
+ Paragraph
+
+
+
+
+
executeCommand('bold')}
+ className='h-8 w-8 p-0 text-[#B5B5B5] hover:bg-[#2A2A2A] hover:text-white'
+ >
+
+
+
+
executeCommand('italic')}
+ className='h-8 w-8 p-0 text-[#B5B5B5] hover:bg-[#2A2A2A] hover:text-white'
+ >
+
+
+
+
executeCommand('strikeThrough')}
+ className='h-8 w-8 p-0 text-[#B5B5B5] hover:bg-[#2A2A2A] hover:text-white'
+ >
+
+
+
+
executeCommand('formatCode')}
+ className='h-8 w-8 p-0 text-[#B5B5B5] hover:bg-[#2A2A2A] hover:text-white'
+ >
+
+
+
+
+
+
{
+ const url = prompt('Enter URL:');
+ if (url) executeCommand('createLink', url);
+ }}
+ className='h-8 w-8 p-0 text-[#B5B5B5] hover:bg-[#2A2A2A] hover:text-white'
+ >
+
+
+
+
{
+ const url = prompt('Enter image URL:');
+ if (url) executeCommand('insertImage', url);
+ }}
+ className='h-8 w-8 p-0 text-[#B5B5B5] hover:bg-[#2A2A2A] hover:text-white'
+ >
+
+
+
+
executeCommand('insertUnorderedList')}
+ className='h-8 w-8 p-0 text-[#B5B5B5] hover:bg-[#2A2A2A] hover:text-white'
+ >
+
+
+
+
+ {/* Editor */}
+
+
+ {/* Placeholder text */}
+ {!vision && (
+
+
Tell your project's full story...
+
+ Use text, images, links, or videos to bring your vision to
+ life. Format freely with headings, lists, and more.
+
+
+ )}
+
+
+ {errors.vision && (
+
{errors.vision}
+ )}
+
+
+ );
+ }
+);
+
+Details.displayName = 'Details';
export default Details;
diff --git a/components/landing-page/project/CreateProjectModal/Footer.tsx b/components/landing-page/project/CreateProjectModal/Footer.tsx
index e15a96c43..ce642adfa 100644
--- a/components/landing-page/project/CreateProjectModal/Footer.tsx
+++ b/components/landing-page/project/CreateProjectModal/Footer.tsx
@@ -5,12 +5,14 @@ interface FooterProps {
currentStep: number;
onContinue: () => void;
isStepValid?: boolean;
+ isSubmitting?: boolean;
}
const Footer = ({
currentStep,
onContinue,
isStepValid = true,
+ isSubmitting = false,
}: FooterProps) => {
const isLastStep = currentStep === 5;
const buttonText = isLastStep ? 'Submit for review' : 'Continue';
@@ -36,8 +38,12 @@ const Footer = ({
)}
-
- {buttonText}
+
+ {isSubmitting ? 'Submitting...' : buttonText}
);
diff --git a/components/landing-page/project/CreateProjectModal/Milestones.tsx b/components/landing-page/project/CreateProjectModal/Milestones.tsx
index 9cc71851c..4c356e850 100644
--- a/components/landing-page/project/CreateProjectModal/Milestones.tsx
+++ b/components/landing-page/project/CreateProjectModal/Milestones.tsx
@@ -1,7 +1,336 @@
-import React from 'react';
+'use client';
-const Milestones = () => {
- return Milestones
;
-};
+import React, { useState } from 'react';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { Label } from '@/components/ui/label';
+import { Button } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+import { GripVertical, Plus, X, Info, Calendar } from 'lucide-react';
+
+interface MilestonesProps {
+ onDataChange?: (data: MilestonesFormData) => void;
+ initialData?: Partial;
+}
+
+export interface Milestone {
+ id: string;
+ title: string;
+ description: string;
+ startDate: string;
+ endDate: string;
+}
+
+export interface MilestonesFormData {
+ fundingAmount: string;
+ milestones: Milestone[];
+}
+
+const Milestones = React.forwardRef<
+ { validate: () => boolean },
+ MilestonesProps
+>(({ onDataChange, initialData }, ref) => {
+ const [formData, setFormData] = useState({
+ fundingAmount: initialData?.fundingAmount || '0',
+ milestones: initialData?.milestones || [
+ {
+ id: '1',
+ title: '',
+ description: '',
+ startDate: '',
+ endDate: '',
+ },
+ ],
+ });
+
+ const [errors, setErrors] = useState<{
+ fundingAmount?: string;
+ milestones?: string;
+ }>({});
+
+ const handleInputChange = (
+ field: keyof MilestonesFormData,
+ value: string | Milestone[]
+ ) => {
+ const newData = { ...formData, [field]: value };
+ setFormData(newData);
+
+ // Clear error when user starts typing
+ if (errors[field]) {
+ setErrors(prev => ({ ...prev, [field]: undefined }));
+ }
+
+ onDataChange?.(newData);
+ };
+
+ const handleMilestoneChange = (
+ id: string,
+ field: keyof Milestone,
+ value: string
+ ) => {
+ const updatedMilestones = formData.milestones.map(milestone =>
+ milestone.id === id ? { ...milestone, [field]: value } : milestone
+ );
+ handleInputChange('milestones', updatedMilestones);
+ };
+
+ const addMilestone = () => {
+ const newMilestone: Milestone = {
+ id: Date.now().toString(),
+ title: '',
+ description: '',
+ startDate: '',
+ endDate: '',
+ };
+ handleInputChange('milestones', [...formData.milestones, newMilestone]);
+ };
+
+ const removeMilestone = (id: string) => {
+ if (formData.milestones.length > 1) {
+ const updatedMilestones = formData.milestones.filter(
+ milestone => milestone.id !== id
+ );
+ handleInputChange('milestones', updatedMilestones);
+ }
+ };
+
+ const validateForm = (): boolean => {
+ const newErrors: { fundingAmount?: string; milestones?: string } = {};
+
+ // Validate funding amount
+ const fundingValue = parseFloat(formData.fundingAmount);
+ if (!formData.fundingAmount || isNaN(fundingValue) || fundingValue <= 0) {
+ newErrors.fundingAmount = 'Please enter a valid funding amount';
+ }
+
+ // Validate milestones
+ const validMilestones = formData.milestones.filter(
+ milestone =>
+ milestone.title.trim() &&
+ milestone.description.trim() &&
+ milestone.startDate &&
+ milestone.endDate
+ );
+
+ if (validMilestones.length === 0) {
+ newErrors.milestones = 'At least one complete milestone is required';
+ }
+
+ // Validate individual milestones
+ for (const milestone of formData.milestones) {
+ if (
+ milestone.title.trim() ||
+ milestone.description.trim() ||
+ milestone.startDate ||
+ milestone.endDate
+ ) {
+ if (!milestone.title.trim()) {
+ newErrors.milestones = 'All milestone titles are required';
+ break;
+ }
+ if (!milestone.description.trim()) {
+ newErrors.milestones = 'All milestone descriptions are required';
+ break;
+ }
+ if (!milestone.startDate) {
+ newErrors.milestones = 'All milestone start dates are required';
+ break;
+ }
+ if (!milestone.endDate) {
+ newErrors.milestones = 'All milestone end dates are required';
+ break;
+ }
+ if (
+ milestone.startDate &&
+ milestone.endDate &&
+ new Date(milestone.startDate) >= new Date(milestone.endDate)
+ ) {
+ newErrors.milestones = 'End date must be after start date';
+ break;
+ }
+ }
+ }
+
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ };
+
+ // Expose validation function to parent
+ React.useImperativeHandle(ref, () => ({
+ validate: validateForm,
+ }));
+
+ return (
+
+ {/* Funding Amount */}
+
+
+ How much funding does your project need?{' '}
+ *
+
+
handleInputChange('fundingAmount', e.target.value)}
+ className={cn(
+ 'focus:border-primary border-[#484848] bg-[#1A1A1A] text-lg text-white placeholder:text-[#919191]',
+ errors.fundingAmount && 'border-red-500'
+ )}
+ />
+
+
+
+ This amount will serve as your project's total funding goal. It will
+ be allocated across milestones during admin review.
+
+
+ {errors.fundingAmount && (
+
{errors.fundingAmount}
+ )}
+
+
+ {/* Milestones */}
+
+
+
+ Define milestones for your project{' '}
+ *
+
+
+ Add at least one milestone to outline your project's progress. Fund
+ allocation for each milestone will be finalized during admin review.
+
+
+
+
+ {formData.milestones.map(milestone => (
+
+
+ {/* Drag Handle */}
+
+
+
+
+ {/* Milestone Content */}
+
+ {/* Title */}
+
+
+ handleMilestoneChange(
+ milestone.id,
+ 'title',
+ e.target.value
+ )
+ }
+ className='focus:border-primary border-[#484848] bg-[#2A2A2A] text-white placeholder:text-[#919191]'
+ />
+
+
+ {/* Description */}
+
+
+
+ {/* Date Inputs */}
+
+
+
+ Start Date
+
+
+
+ handleMilestoneChange(
+ milestone.id,
+ 'startDate',
+ e.target.value
+ )
+ }
+ className='focus:border-primary border-[#484848] bg-[#2A2A2A] pr-10 text-white'
+ />
+
+
+
+
+
+
End Date
+
+
+ handleMilestoneChange(
+ milestone.id,
+ 'endDate',
+ e.target.value
+ )
+ }
+ className='focus:border-primary border-[#484848] bg-[#2A2A2A] pr-10 text-white'
+ />
+
+
+
+
+
+
+ {/* Remove Button */}
+ {formData.milestones.length > 1 && (
+
removeMilestone(milestone.id)}
+ className='h-8 w-8 p-0 text-[#B5B5B5] hover:bg-[#2A2A2A] hover:text-white'
+ >
+
+
+ )}
+
+
+ ))}
+
+
+ {/* Add Milestone Button */}
+
+
+ {errors.milestones && (
+
{errors.milestones}
+ )}
+
+
+ );
+});
+
+Milestones.displayName = 'Milestones';
export default Milestones;
diff --git a/components/landing-page/project/CreateProjectModal/Team.tsx b/components/landing-page/project/CreateProjectModal/Team.tsx
index aaa1cafd1..4b60d05b1 100644
--- a/components/landing-page/project/CreateProjectModal/Team.tsx
+++ b/components/landing-page/project/CreateProjectModal/Team.tsx
@@ -1,7 +1,187 @@
-import React from 'react';
+'use client';
-const Team = () => {
- return Team
;
-};
+import React, { useState } from 'react';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Button } from '@/components/ui/button';
+import { User, X, Search } from 'lucide-react';
+
+interface TeamProps {
+ onDataChange?: (data: TeamFormData) => void;
+ initialData?: Partial;
+}
+
+export interface TeamMember {
+ id: string;
+ username: string;
+ role?: string;
+}
+
+export interface TeamFormData {
+ members: TeamMember[];
+}
+
+const Team = React.forwardRef<{ validate: () => boolean }, TeamProps>(
+ ({ onDataChange, initialData }, ref) => {
+ const [formData, setFormData] = useState({
+ members: initialData?.members || [],
+ });
+
+ const [searchQuery, setSearchQuery] = useState('');
+ const [errors, setErrors] = useState<{ members?: string }>({});
+
+ const handleInputChange = (
+ field: keyof TeamFormData,
+ value: TeamMember[]
+ ) => {
+ const newData = { ...formData, [field]: value };
+ setFormData(newData);
+
+ // Clear error when user makes changes
+ if (errors[field]) {
+ setErrors(prev => ({ ...prev, [field]: undefined }));
+ }
+
+ onDataChange?.(newData);
+ };
+
+ const addMember = (username: string) => {
+ if (
+ username.trim() &&
+ !formData.members.some(member => member.username === username.trim())
+ ) {
+ const newMember: TeamMember = {
+ id: Date.now().toString(),
+ username: username.trim(),
+ role: 'MEMBER',
+ };
+ handleInputChange('members', [...formData.members, newMember]);
+ setSearchQuery('');
+ }
+ };
+
+ const removeMember = (id: string) => {
+ const updatedMembers = formData.members.filter(
+ member => member.id !== id
+ );
+ handleInputChange('members', updatedMembers);
+ };
+
+ const handleKeyPress = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ addMember(searchQuery);
+ }
+ };
+
+ const validateForm = (): boolean => {
+ const newErrors: { members?: string } = {};
+
+ // Team step is optional, so no validation required
+ // But we can add validation if needed in the future
+
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ };
+
+ // Expose validation function to parent
+ React.useImperativeHandle(ref, () => ({
+ validate: validateForm,
+ }));
+
+ return (
+
+ {/* Creator Information */}
+
+
+
+
+
+
+
Creator Name
+ OWNER
+
+
+
+
+ {/* Invite Members */}
+
+
+ Invite members to your team (optional)
+
+
+
+ {/* Search Input */}
+
+
+ setSearchQuery(e.target.value)}
+ onKeyPress={handleKeyPress}
+ className='focus:border-primary border-[#484848] bg-[#1A1A1A] pl-10 text-white placeholder:text-[#919191]'
+ />
+
+
+ {/* Member Tags */}
+ {formData.members.length > 0 && (
+
+ {formData.members.map(member => (
+
+
+ {member.username}
+
+ removeMember(member.id)}
+ className='h-5 w-5 rounded-full p-0 text-[#B5B5B5] hover:bg-[#3A3A3A] hover:text-white'
+ >
+
+
+
+ ))}
+
+ )}
+
+ {/* Add Member Button */}
+ {searchQuery.trim() && (
+
addMember(searchQuery)}
+ className='hover:border-primary border-[#484848] bg-[#1A1A1A] text-white hover:bg-[#2A2A2A]'
+ >
+ Add "{searchQuery}"
+
+ )}
+
+
+ {errors.members && (
+
{errors.members}
+ )}
+
+
+ {/* Team Information */}
+
+
+ Team Information
+
+
+ You can invite team members to collaborate on your project. They
+ will have access to project details and can help manage milestones
+ and updates.
+
+
+
+ );
+ }
+);
+
+Team.displayName = 'Team';
export default Team;
diff --git a/components/landing-page/project/CreateProjectModal/index.tsx b/components/landing-page/project/CreateProjectModal/index.tsx
index 121049728..921d6c68f 100644
--- a/components/landing-page/project/CreateProjectModal/index.tsx
+++ b/components/landing-page/project/CreateProjectModal/index.tsx
@@ -1,21 +1,47 @@
import BoundlessSheet from '@/components/sheet/boundless-sheet';
-import React, { useState } from 'react';
+import React, { useState, useRef, useCallback } from 'react';
import Header from './Header';
import Footer from './Footer';
-import Basic from './Basic';
-import Details from './Details';
-import Milestones from './Milestones';
-import Team from './Team';
-import Contact from './Contact';
-
-const CreateProjectModal = ({
- open,
- setOpen,
-}: {
+import Basic, { BasicFormData } from './Basic';
+import Details, { DetailsFormData } from './Details';
+import Milestones, { MilestonesFormData } from './Milestones';
+import Team, { TeamFormData } from './Team';
+import Contact, { ContactFormData } from './Contact';
+
+interface CreateProjectModalProps {
open: boolean;
setOpen: (open: boolean) => void;
-}) => {
+}
+
+export interface ProjectFormData {
+ basic: Partial;
+ details: Partial;
+ milestones: Partial;
+ team: Partial;
+ contact: Partial;
+}
+
+const CreateProjectModal = ({ open, setOpen }: CreateProjectModalProps) => {
const [currentStep, setCurrentStep] = useState(1);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ // Form data state
+ const [formData, setFormData] = useState({
+ basic: {},
+ details: {},
+ milestones: {},
+ team: {},
+ contact: {},
+ });
+
+ // Refs for step components to access validation methods
+ const stepRefs = {
+ basic: useRef<{ validate: () => boolean }>(null),
+ details: useRef<{ validate: () => boolean }>(null),
+ milestones: useRef<{ validate: () => boolean }>(null),
+ team: useRef<{ validate: () => boolean }>(null),
+ contact: useRef<{ validate: () => boolean }>(null),
+ };
const handleBack = () => {
if (currentStep > 1) {
@@ -23,33 +49,106 @@ const CreateProjectModal = ({
}
};
- const handleContinue = () => {
+ const validateCurrentStep = (): boolean => {
+ const stepRef = stepRefs[getStepKey(currentStep)];
+ return stepRef?.current?.validate() ?? true;
+ };
+
+ const getStepKey = (step: number): keyof typeof stepRefs => {
+ switch (step) {
+ case 1:
+ return 'basic';
+ case 2:
+ return 'details';
+ case 3:
+ return 'milestones';
+ case 4:
+ return 'team';
+ case 5:
+ return 'contact';
+ default:
+ return 'basic';
+ }
+ };
+
+ const handleContinue = async () => {
+ // Validate current step before proceeding
+ if (!validateCurrentStep()) {
+ return;
+ }
+
if (currentStep < 5) {
setCurrentStep(currentStep + 1);
} else {
// Handle final submission
- // TODO: Implement project submission logic
- // This could include API calls, form validation, etc.
+ await handleSubmit();
+ }
+ };
+
+ const handleSubmit = async () => {
+ setIsSubmitting(true);
+
+ try {
+ // TODO: Implement actual API call to submit project
+ // console.log('Submitting project data:', formData);
+
+ // Simulate API call
+ await new Promise(resolve => setTimeout(resolve, 2000));
+
+ // Reset form and close modal on success
+ setFormData({
+ basic: {},
+ details: {},
+ milestones: {},
+ team: {},
+ contact: {},
+ });
+ setCurrentStep(1);
+ setOpen(false);
+
+ // TODO: Show success notification
+ alert('Project submitted successfully!');
+ } catch {
+ // console.error('Error submitting project:', error);
+ // TODO: Show error notification
+ alert('Error submitting project. Please try again.');
+ } finally {
+ setIsSubmitting(false);
}
};
- // Placeholder for step validation - you can implement actual validation logic here
- const isStepValid = true;
+ const handleDataChange = useCallback(
+ (step: keyof ProjectFormData, data: unknown) => {
+ setFormData(prev => ({
+ ...prev,
+ [step]: { ...prev[step], ...data },
+ }));
+ },
+ []
+ );
+
+ const isStepValid = validateCurrentStep();
const renderStepContent = () => {
+ const commonProps = {
+ onDataChange: (data: unknown) =>
+ handleDataChange(getStepKey(currentStep), data),
+ initialData: formData[getStepKey(currentStep)],
+ };
+
switch (currentStep) {
case 1:
- return ;
+ return ;
case 2:
- return ;
+ return ;
case 3:
- return ;
+ return ;
case 4:
- return ;
+ return ;
case 5:
- return ;
+ return ;
default:
- return ;
+ return ;
}
};
@@ -67,6 +166,7 @@ const CreateProjectModal = ({
currentStep={currentStep}
onContinue={handleContinue}
isStepValid={isStepValid}
+ isSubmitting={isSubmitting}
/>
);
From 2e2d92a7d19c6b71ecb797d11f608feaf37647a9 Mon Sep 17 00:00:00 2001
From: wheval
Date: Thu, 2 Oct 2025 12:33:33 +0100
Subject: [PATCH 2/2] feat: implement steps
---
app/test/page.tsx | 44 +-
components/form/FormHint.tsx | 49 ++
.../project/CreateProjectModal/Basic.tsx | 331 +++++++++---
.../project/CreateProjectModal/Contact.tsx | 493 +++++++-----------
.../project/CreateProjectModal/Details.tsx | 26 +-
.../project/CreateProjectModal/Footer.tsx | 2 +-
.../project/CreateProjectModal/Header.tsx | 47 +-
.../project/CreateProjectModal/Milestones.tsx | 318 +++++------
.../project/CreateProjectModal/Team.tsx | 100 ++--
.../project/CreateProjectModal/index.tsx | 372 +++++++++++--
public/avatar.png | Bin 0 -> 3855 bytes
11 files changed, 1137 insertions(+), 645 deletions(-)
create mode 100644 components/form/FormHint.tsx
create mode 100644 public/avatar.png
diff --git a/app/test/page.tsx b/app/test/page.tsx
index 14e721bec..58af217b8 100644
--- a/app/test/page.tsx
+++ b/app/test/page.tsx
@@ -1,4 +1,42 @@
-// Removed temporary test page. This file is intentionally empty.
-export default function Page() {
- return null;
+'use client';
+import { useState } from 'react';
+import AuthLoadingState from '@/components/auth/AuthLoadingState';
+import { BoundlessButton } from '@/components/buttons';
+
+export default function TestLoadingPage() {
+ const [showLoading, setShowLoading] = useState(false);
+
+ const handleShowLoading = () => {
+ setShowLoading(true);
+ // Simulate loading for 3 seconds
+ setTimeout(() => {
+ setShowLoading(false);
+ }, 3000);
+ };
+
+ return (
+
+
+
Loading State Test
+
+ Test the animated loading state across different screen sizes
+
+
+
+ Test Loading State
+
+
+ {showLoading &&
}
+
+
+
Test on different screen sizes:
+
+ Mobile (320px - 768px)
+ Tablet (768px - 1024px)
+ Desktop (1024px+)
+
+
+
+
+ );
}
diff --git a/components/form/FormHint.tsx b/components/form/FormHint.tsx
new file mode 100644
index 000000000..1d182f0f8
--- /dev/null
+++ b/components/form/FormHint.tsx
@@ -0,0 +1,49 @@
+'use client';
+
+import { Info } from 'lucide-react';
+import {
+ Tooltip,
+ TooltipTrigger,
+ TooltipContent,
+} from '@/components/ui/tooltip';
+import { cn } from '@/lib/utils';
+
+type FormHintProps = {
+ hint: React.ReactNode;
+ className?: string;
+ side?: 'top' | 'right' | 'bottom' | 'left';
+ sideOffset?: number;
+ iconClassName?: string;
+};
+
+export default function FormHint({
+ hint,
+ className,
+ side = 'top',
+ sideOffset = 6,
+ iconClassName,
+}: FormHintProps) {
+ return (
+
+
+
+
+
+
+
+ {hint}
+
+
+ );
+}
diff --git a/components/landing-page/project/CreateProjectModal/Basic.tsx b/components/landing-page/project/CreateProjectModal/Basic.tsx
index 18a82ea1c..ffd3c2576 100644
--- a/components/landing-page/project/CreateProjectModal/Basic.tsx
+++ b/components/landing-page/project/CreateProjectModal/Basic.tsx
@@ -5,8 +5,10 @@ import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Label } from '@/components/ui/label';
-import { Camera, Info, Lightbulb } from 'lucide-react';
+import { Info } from 'lucide-react';
import { cn } from '@/lib/utils';
+import { z } from 'zod';
+import FormHint from '@/components/form/FormHint';
interface BasicProps {
onDataChange?: (data: BasicFormData) => void;
@@ -24,6 +26,38 @@ export interface BasicFormData {
socialLinks: string[];
}
+// Zod schema for validation
+const imageFileSchema = z
+ .custom(
+ (val): val is File => typeof File !== 'undefined' && val instanceof File
+ )
+ .refine(file => ['image/jpeg', 'image/png'].includes(file.type), {
+ message: 'Only JPEG and PNG files are allowed',
+ })
+ .refine(file => file.size <= 2 * 1024 * 1024, {
+ message: 'File size must be less than 2MB',
+ });
+
+const basicSchema = z.object({
+ projectName: z.string().trim().min(1, 'Project name is required'),
+ logo: imageFileSchema,
+ vision: z
+ .string()
+ .trim()
+ .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('')),
+ socialLinks: z
+ .array(z.string().trim())
+ .refine(
+ arr => arr.some(link => link.length > 0),
+ 'At least one social link is required'
+ ),
+});
+
const categories = [
'DeFi & Finance',
'Gaming & Metaverse',
@@ -50,6 +84,10 @@ const Basic = React.forwardRef<{ validate: () => boolean }, BasicProps>(
const [errors, setErrors] = useState<
Partial>
>({});
+ const [touched, setTouched] = useState<
+ Partial>
+ >({});
+ const [submitted, setSubmitted] = useState(false);
const handleInputChange = (
field: keyof BasicFormData,
@@ -75,6 +113,7 @@ const Basic = React.forwardRef<{ validate: () => boolean }, BasicProps>(
const handleFileUpload = (event: React.ChangeEvent) => {
const file = event.target.files?.[0];
if (file) {
+ setTouched(prev => ({ ...prev, logo: true }));
// Validate file type
if (!file.type.match(/^image\/(jpeg|png)$/)) {
setErrors(prev => ({
@@ -98,38 +137,106 @@ const Basic = React.forwardRef<{ validate: () => boolean }, BasicProps>(
};
const validateForm = (): boolean => {
- const newErrors: Partial> = {};
-
- if (!formData.projectName.trim()) {
- newErrors.projectName = 'Project name is required';
- }
+ // Normalize social links (trim empties but keep array length for UI)
+ const parsed = basicSchema.safeParse({
+ ...formData,
+ socialLinks: formData.socialLinks.map(s => s.trim()),
+ });
- if (!formData.logo) {
- newErrors.logo = 'Logo is required';
+ if (parsed.success) {
+ setErrors({});
+ return true;
}
- if (!formData.vision.trim()) {
- newErrors.vision = 'Vision is required';
- } else if (formData.vision.length > 300) {
- newErrors.vision = 'Vision must be 300 characters or less';
+ const fieldErrors: Partial> = {};
+ for (const issue of parsed.error.issues) {
+ const path = issue.path[0] as keyof BasicFormData | undefined;
+ if (path) {
+ fieldErrors[path] = issue.message;
+ }
}
+ // If logo is null, zod will also error; add friendly message
+ if (!formData.logo && !fieldErrors.logo)
+ fieldErrors.logo = 'Logo is required';
- if (!formData.category) {
- newErrors.category = 'Category is required';
- }
+ setErrors(fieldErrors);
+ return false;
+ };
- const validSocialLinks = formData.socialLinks.filter(link => link.trim());
- if (validSocialLinks.length === 0) {
- newErrors.socialLinks = 'At least one social link is required';
+ // Field-level validation on blur
+ const validateField = (field: keyof BasicFormData | 'socialLinks') => {
+ setTouched(prev => ({ ...prev, [field]: true }));
+ try {
+ switch (field) {
+ case 'projectName':
+ basicSchema
+ .pick({ projectName: true })
+ .parse({ projectName: formData.projectName });
+ setErrors(prev => ({ ...prev, projectName: undefined }));
+ break;
+ case 'vision':
+ basicSchema
+ .pick({ vision: true })
+ .parse({ vision: formData.vision });
+ setErrors(prev => ({ ...prev, vision: undefined }));
+ break;
+ case 'category':
+ basicSchema
+ .pick({ category: true })
+ .parse({ category: formData.category });
+ setErrors(prev => ({ ...prev, category: undefined }));
+ break;
+ case 'githubUrl':
+ if (formData.githubUrl)
+ basicSchema
+ .pick({ githubUrl: true })
+ .parse({ githubUrl: formData.githubUrl });
+ setErrors(prev => ({ ...prev, githubUrl: undefined }));
+ break;
+ case 'websiteUrl':
+ if (formData.websiteUrl)
+ basicSchema
+ .pick({ websiteUrl: true })
+ .parse({ websiteUrl: formData.websiteUrl });
+ setErrors(prev => ({ ...prev, websiteUrl: undefined }));
+ break;
+ case 'demoVideoUrl':
+ if (formData.demoVideoUrl)
+ basicSchema
+ .pick({ demoVideoUrl: true })
+ .parse({ demoVideoUrl: formData.demoVideoUrl });
+ setErrors(prev => ({ ...prev, demoVideoUrl: undefined }));
+ break;
+ case 'logo':
+ // handled on change
+ break;
+ case 'socialLinks':
+ {
+ const ok = formData.socialLinks.some(s => s.trim().length > 0);
+ setErrors(prev => ({
+ ...prev,
+ socialLinks: ok
+ ? undefined
+ : 'At least one social link is required',
+ }));
+ }
+ break;
+ }
+ } catch (e: unknown) {
+ // Zod throws on parse; map first error
+ if (e && typeof e === 'object' && 'issues' in (e as any)) {
+ const issue = (e as any).issues?.[0];
+ const msg = issue?.message as string | undefined;
+ const k = field as keyof BasicFormData;
+ setErrors(prev => ({ ...prev, [k]: msg || 'Invalid value' }));
+ }
}
-
- setErrors(newErrors);
- return Object.keys(newErrors).length === 0;
};
// Expose validation function to parent
React.useImperativeHandle(ref, () => ({
validate: validateForm,
+ markSubmitted: () => setSubmitted(true),
}));
return (
@@ -143,12 +250,15 @@ const Basic = React.forwardRef<{ validate: () => boolean }, BasicProps>(
placeholder='Enter project name/title'
value={formData.projectName}
onChange={e => handleInputChange('projectName', e.target.value)}
+ onBlur={() => validateField('projectName')}
className={cn(
- 'focus:border-primary border-[#484848] bg-[#1A1A1A] text-white placeholder:text-[#919191]',
- errors.projectName && 'border-red-500'
+ 'focus-visible:border-primary border-[#484848] bg-[#1A1A1A] p-4 text-white placeholder:text-[#919191]',
+ (submitted || touched.projectName) &&
+ errors.projectName &&
+ 'border-red-500'
)}
/>
- {errors.projectName && (
+ {(submitted || touched.projectName) && errors.projectName && (
{errors.projectName}
)}
@@ -169,11 +279,11 @@ const Basic = React.forwardRef<{ validate: () => boolean }, BasicProps>(
{formData.logo ? (
@@ -188,26 +298,51 @@ const Basic = React.forwardRef<{ validate: () => boolean }, BasicProps>(
) : (
)}
-
-
- Accepted file type:{' '}
- JPEG or{' '}
- PNG , and less
- than 2 MB .
-
-
- A size of{' '}
- 480 x 480 px is
- recommended.
-
+
+
+ Accepted files should be JPEG or PNG, and less than 2 MB.
+
+ }
+ side='top'
+ />
+
+
+ Accepted file type:{' '}
+ JPEG or{' '}
+ PNG , and less
+ than 2 MB .
+
+
+ A size of{' '}
+ 480 x 480 px is
+ recommended.
+
+
+ {(submitted || touched.logo) && errors.logo && (
+ {errors.logo}
+ )}
- {errors.logo &&
{errors.logo}
}
{/* Vision */}
@@ -224,17 +359,29 @@ const Basic = React.forwardRef<{ validate: () => boolean }, BasicProps>(
placeholder='Share the future your project is building'
value={formData.vision}
onChange={e => handleInputChange('vision', e.target.value)}
+ onBlur={() => validateField('vision')}
className={cn(
- 'focus:border-primary min-h-24 resize-none border-[#484848] bg-[#1A1A1A] text-white placeholder:text-[#919191]',
- errors.vision && 'border-red-500'
+ 'focus-visible:border-primary min-h-24 resize-none border-[#484848] bg-[#1A1A1A] text-white placeholder:text-[#919191] xl:min-h-[172px]',
+ (submitted || touched.vision) && errors.vision && 'border-red-500'
)}
maxLength={300}
/>
-
- Describe your project's long-term goal or the positive change it
- will bring to people, communities, or industries.
-
- {errors.vision && (
+
+
+ Describe your project's long-term goal or the positive change
+ it will bring to people, communities, or industries.
+
+ }
+ side='top'
+ />
+
+ Describe your project's long-term goal or the positive change it
+ will bring to people, communities, or industries.
+
+
+ {(submitted || touched.vision) && errors.vision && (
{errors.vision}
)}
@@ -247,24 +394,30 @@ const Basic = React.forwardRef<{ validate: () => boolean }, BasicProps>(
handleInputChange('category', value)}
- className='grid grid-cols-1 gap-3 md:grid-cols-2'
+ className='flex flex-wrap gap-3'
>
{categories.map(category => (
-
+
@@ -273,7 +426,7 @@ const Basic = React.forwardRef<{ validate: () => boolean }, BasicProps>(
))}
- {errors.category && (
+ {(submitted || touched.category) && errors.category && (
{errors.category}
)}
@@ -284,17 +437,25 @@ const Basic = React.forwardRef<{ validate: () => boolean }, BasicProps>(
GitHub/Gitlab/Bitbucket (optional unless hackathons required)
-
+
handleInputChange('githubUrl', e.target.value)}
- className='focus:border-primary border-[#484848] bg-[#1A1A1A] text-white placeholder:text-[#919191]'
+ onBlur={() => validateField('githubUrl')}
+ className='focus-visible:border-primary border-[#484848] bg-[#1A1A1A] p-4 text-white placeholder:text-[#919191]'
/>
-
- E.g., "https://github.com/org" or "https://github.com/org/repo"
-
+
+
+
+ E.g., "https://github.com/org" or "https://github.com/org/repo"
+
+
{/* Project Website */}
@@ -304,7 +465,8 @@ const Basic = React.forwardRef<{ validate: () => boolean }, BasicProps>(
placeholder='Link to your project website/landing page'
value={formData.websiteUrl}
onChange={e => handleInputChange('websiteUrl', e.target.value)}
- className='focus:border-primary border-[#484848] bg-[#1A1A1A] text-white placeholder:text-[#919191]'
+ onBlur={() => validateField('websiteUrl')}
+ className='focus-visible:border-primary border-[#484848] bg-[#1A1A1A] p-4 text-white placeholder:text-[#919191]'
/>
@@ -320,11 +482,33 @@ const Basic = React.forwardRef<{ validate: () => boolean }, BasicProps>(
placeholder='Link to demo video (YouTube video recommended)'
value={formData.demoVideoUrl}
onChange={e => handleInputChange('demoVideoUrl', e.target.value)}
- className='focus:border-primary border-[#484848] bg-[#1A1A1A] text-white placeholder:text-[#919191]'
+ onBlur={() => validateField('demoVideoUrl')}
+ className='focus-visible:border-primary border-[#484848] bg-[#1A1A1A] p-4 text-white placeholder:text-[#919191]'
/>
-
-
+
+
+
+
+
+
+
+
+
+
+
Tip: A YouTube video link will be displayed as an embedded player.
@@ -342,15 +526,22 @@ const Basic = React.forwardRef<{ validate: () => boolean }, BasicProps>(
placeholder='Link URL (newsletters/social account)'
value={link}
onChange={e => handleSocialLinkChange(index, e.target.value)}
- className='focus:border-primary border-[#484848] bg-[#1A1A1A] text-white placeholder:text-[#919191]'
+ onBlur={() => validateField('socialLinks')}
+ className='focus-visible:border-primary border-[#484848] bg-[#1A1A1A] p-4 text-white placeholder:text-[#919191]'
/>
))}
-
- You can add up to 3 social links on your BUIDL profile, e.g.,
- Facebook Page, Farcaster, Instagram, Substack, X/Twitter, YakiHonne,
- etc.
-
- {errors.socialLinks && (
+
+
+
+ You can add up to 3 social links on your BUIDL profile, e.g.,
+ Facebook Page, Farcaster, Instagram, Substack, X/Twitter,
+ YakiHonne, etc.
+
+
+ {(submitted || touched.socialLinks) && errors.socialLinks && (
{errors.socialLinks}
)}
diff --git a/components/landing-page/project/CreateProjectModal/Contact.tsx b/components/landing-page/project/CreateProjectModal/Contact.tsx
index 42eae2857..1808a6bb5 100644
--- a/components/landing-page/project/CreateProjectModal/Contact.tsx
+++ b/components/landing-page/project/CreateProjectModal/Contact.tsx
@@ -2,11 +2,10 @@
import React, { useState } from 'react';
import { Input } from '@/components/ui/input';
-import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
-import { Checkbox } from '@/components/ui/checkbox';
+import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { cn } from '@/lib/utils';
-import { Mail, Phone, MapPin, Info } from 'lucide-react';
+import { z } from 'zod';
interface ContactProps {
onDataChange?: (data: ContactFormData) => void;
@@ -14,31 +13,46 @@ interface ContactProps {
}
export interface ContactFormData {
- email: string;
- phone: string;
- address: string;
- city: string;
- country: string;
- postalCode: string;
- additionalInfo: string;
+ telegram: string;
+ backupType: 'discord' | 'whatsapp';
+ backupContact: string;
agreeToTerms: boolean;
agreeToPrivacy: boolean;
- agreeToMarketing: boolean;
}
+const contactSchema = z
+ .object({
+ telegram: z
+ .string()
+ .trim()
+ .min(1, 'Telegram username is required')
+ .regex(/^@?[a-zA-Z0-9_]+$/, 'Please enter a valid Telegram username'),
+ backupType: z.union([z.literal('discord'), z.literal('whatsapp')]),
+ backupContact: z.string().trim().min(1, 'Backup contact is required'),
+ // Terms/Privacy are validated at final submission in the parent schema
+ agreeToTerms: z.boolean().optional(),
+ agreeToPrivacy: z.boolean().optional(),
+ })
+ .refine(
+ data => {
+ // Ensure backup type is selected and backup contact is provided
+ return data.backupType && data.backupContact.trim().length > 0;
+ },
+ {
+ message:
+ 'Please select a backup contact method and provide contact information',
+ path: ['backupType'],
+ }
+ );
+
const Contact = React.forwardRef<{ validate: () => boolean }, ContactProps>(
({ onDataChange, initialData }, ref) => {
const [formData, setFormData] = useState({
- email: initialData?.email || '',
- phone: initialData?.phone || '',
- address: initialData?.address || '',
- city: initialData?.city || '',
- country: initialData?.country || '',
- postalCode: initialData?.postalCode || '',
- additionalInfo: initialData?.additionalInfo || '',
+ telegram: initialData?.telegram || '',
+ backupType: initialData?.backupType || 'whatsapp',
+ backupContact: initialData?.backupContact || '',
agreeToTerms: initialData?.agreeToTerms || false,
agreeToPrivacy: initialData?.agreeToPrivacy || false,
- agreeToMarketing: initialData?.agreeToMarketing || false,
});
const [errors, setErrors] = useState<
@@ -61,44 +75,18 @@ const Contact = React.forwardRef<{ validate: () => boolean }, ContactProps>(
};
const validateForm = (): boolean => {
- const newErrors: Partial> = {};
-
- if (!formData.email.trim()) {
- newErrors.email = 'Email is required';
- } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
- newErrors.email = 'Please enter a valid email address';
- }
-
- if (!formData.phone.trim()) {
- newErrors.phone = 'Phone number is required';
- }
-
- if (!formData.address.trim()) {
- newErrors.address = 'Address is required';
- }
-
- if (!formData.city.trim()) {
- newErrors.city = 'City is required';
- }
-
- if (!formData.country.trim()) {
- newErrors.country = 'Country is required';
- }
-
- if (!formData.postalCode.trim()) {
- newErrors.postalCode = 'Postal code is required';
+ const parsed = contactSchema.safeParse(formData);
+ if (parsed.success) {
+ setErrors({});
+ return true;
}
-
- if (!formData.agreeToTerms) {
- newErrors.agreeToTerms = 'You must agree to the Terms of Service';
- }
-
- if (!formData.agreeToPrivacy) {
- newErrors.agreeToPrivacy = 'You must agree to the Privacy Policy';
+ const fieldErrors: Partial> = {};
+ for (const issue of parsed.error.issues) {
+ const field = issue.path[0] as keyof ContactFormData | undefined;
+ if (field) fieldErrors[field] = issue.message;
}
-
- setErrors(newErrors);
- return Object.keys(newErrors).length === 0;
+ setErrors(fieldErrors);
+ return false;
};
// Expose validation function to parent
@@ -108,280 +96,187 @@ const Contact = React.forwardRef<{ validate: () => boolean }, ContactProps>(
return (
- {/* Contact Information */}
-
-
-
- Contact Information
-
-
- Please provide your contact details for project communication and
- verification.
-
-
-
- {/* Email */}
-
-
- Email Address *
-
-
-
-
handleInputChange('email', e.target.value)}
- className={cn(
- 'focus:border-primary border-[#484848] bg-[#1A1A1A] pl-10 text-white placeholder:text-[#919191]',
- errors.email && 'border-red-500'
- )}
+ {/* Information Box */}
+
+
- {errors.email && (
-
{errors.email}
- )}
-
+
- {/* Phone */}
-
-
- Phone Number *
-
-
-
-
handleInputChange('phone', e.target.value)}
- className={cn(
- 'focus:border-primary border-[#484848] bg-[#1A1A1A] pl-10 text-white placeholder:text-[#919191]',
- errors.phone && 'border-red-500'
- )}
- />
+
+
+ Your Project contact information will be used for Project
+ verification and for Boundless staff to contact you. The contact
+ information can only be accessed by Boundless staff.
+
- {errors.phone && (
-
{errors.phone}
- )}
- {/* Address Information */}
+ {/* Contact Information */}
-
-
- Address Information
-
-
- This information is required for legal and verification purposes.
-
-
-
- {/* Address */}
+ {/* Telegram */}
- Street Address *
+ Telegram (primary contact) *
-
-
+
+ @
handleInputChange('address', e.target.value)}
+ placeholder='Telegram username'
+ value={formData.telegram}
+ onChange={e => handleInputChange('telegram', e.target.value)}
className={cn(
- 'focus:border-primary border-[#484848] bg-[#1A1A1A] pl-10 text-white placeholder:text-[#919191]',
- errors.address && 'border-red-500'
+ 'focus:border-primary border-[#484848] bg-[#1A1A1A] pl-8 text-white placeholder:text-[#919191]',
+ errors.telegram && 'border-red-500'
)}
/>
- {errors.address && (
-
{errors.address}
+ {errors.telegram && (
+
{errors.telegram}
)}
- {/* City, Country, Postal Code */}
-
-
-
- City *
-
-
handleInputChange('city', e.target.value)}
- className={cn(
- 'focus:border-primary border-[#484848] bg-[#1A1A1A] text-white placeholder:text-[#919191]',
- errors.city && 'border-red-500'
- )}
- />
- {errors.city && (
-
{errors.city}
- )}
-
-
-
-
- Country *
-
-
handleInputChange('country', e.target.value)}
- className={cn(
- 'focus:border-primary border-[#484848] bg-[#1A1A1A] text-white placeholder:text-[#919191]',
- errors.country && 'border-red-500'
- )}
- />
- {errors.country && (
-
{errors.country}
- )}
-
+ {/* Backup Contact */}
+
+
+ Backup *
+
-
-
- Postal Code *
-
-
handleInputChange('postalCode', e.target.value)}
+
+ handleInputChange('backupType', value as 'discord' | 'whatsapp')
+ }
+ className='space-y-1'
+ >
+ {/* Discord Option */}
+
- {errors.postalCode && (
- {errors.postalCode}
- )}
-
-
-
-
- {/* Additional Information */}
-
-
- Additional Information (Optional)
-
-
-
- {/* Terms and Agreements */}
-
-
-
- Terms and Agreements
-
-
- Please review and accept the following terms to continue.
-
-
-
-
-
-
- handleInputChange('agreeToTerms', checked as boolean)
- }
- className='mt-1'
- />
-
-
+
- I agree to the{' '}
-
+
- Terms of Service
- {' '}
- *
-
- {errors.agreeToTerms && (
-
{errors.agreeToTerms}
- )}
+ Discord
+
+
+
+ handleInputChange('backupContact', e.target.value)
+ }
+ className={cn(
+ 'rounded-none border-0 bg-transparent py-0 pr-4 pl-4 text-white placeholder:text-[#919191] focus-visible:border-0 focus-visible:ring-0 focus-visible:ring-offset-0',
+ errors.backupContact && 'text-red-500'
+ )}
+ />
-
-
-
- handleInputChange('agreeToPrivacy', checked as boolean)
- }
- className='mt-1'
- />
-
-
+
- I agree to the{' '}
-
+
- Privacy Policy
- {' '}
- *
-
- {errors.agreeToPrivacy && (
-
- {errors.agreeToPrivacy}
-
- )}
+ WhatsApp
+
+
+
+ handleInputChange('backupContact', e.target.value)
+ }
+ className={cn(
+ 'rounded-none border-0 bg-transparent py-0 pr-4 pl-4 text-white placeholder:text-[#919191] focus-visible:border-0 focus-visible:ring-0 focus-visible:ring-offset-0',
+ errors.backupContact && 'text-red-500'
+ )}
+ />
-
+
-
-
- handleInputChange('agreeToMarketing', checked as boolean)
- }
- className='mt-1'
- />
-
- I would like to receive updates about new features and
- opportunities (optional)
-
-
-
-
-
- {/* Information Box */}
-
-
-
-
-
- What happens next?
-
-
- After submitting your project, our team will review your
- application within 3-5 business days. You'll receive an email
- notification once the review is complete. If approved, your
- project will be published and available for funding.
+ {(errors.backupContact || errors.backupType) && (
+
+ {errors.backupContact || errors.backupType}
-
+ )}
diff --git a/components/landing-page/project/CreateProjectModal/Details.tsx b/components/landing-page/project/CreateProjectModal/Details.tsx
index fb74c5653..d32db77c1 100644
--- a/components/landing-page/project/CreateProjectModal/Details.tsx
+++ b/components/landing-page/project/CreateProjectModal/Details.tsx
@@ -4,6 +4,7 @@ import React, { useState, useRef } from 'react';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
+import { z } from 'zod';
import {
Undo2,
Redo2,
@@ -25,10 +26,15 @@ export interface DetailsFormData {
vision: string;
}
+const detailsSchema = z.object({
+ vision: z.string().trim().min(1, 'Vision is required'),
+});
+
const Details = React.forwardRef<{ validate: () => boolean }, DetailsProps>(
({ onDataChange, initialData }, ref) => {
const [vision, setVision] = useState(initialData?.vision || '');
const [errors, setErrors] = useState<{ vision?: string }>({});
+ const [submitted, setSubmitted] = useState(false);
const editorRef = useRef
(null);
const handleVisionChange = (value: string) => {
@@ -42,14 +48,16 @@ const Details = React.forwardRef<{ validate: () => boolean }, DetailsProps>(
};
const validateForm = (): boolean => {
- const newErrors: { vision?: string } = {};
-
- if (!vision.trim()) {
- newErrors.vision = 'Vision is required';
+ setSubmitted(true);
+ const parsed = detailsSchema.safeParse({ vision });
+ if (parsed.success) {
+ setErrors({});
+ return true;
}
-
- setErrors(newErrors);
- return Object.keys(newErrors).length === 0;
+ setErrors({
+ vision: parsed.error.issues[0]?.message || 'Vision is required',
+ });
+ return false;
};
// Expose validation function to parent
@@ -235,7 +243,7 @@ const Details = React.forwardRef<{ validate: () => boolean }, DetailsProps>(
onPaste={handlePaste}
className={cn(
'focus:border-primary min-h-48 w-full rounded-lg border border-[#484848] bg-[#1A1A1A] p-4 text-white placeholder:text-[#919191] focus:outline-none',
- errors.vision && 'border-red-500'
+ submitted && errors.vision && 'border-red-500'
)}
style={{ minHeight: '192px' }}
data-placeholder="Tell your project's full story...\n\nUse text, images, links, or videos to bring your vision to life. Format freely with headings, lists, and more."
@@ -254,7 +262,7 @@ const Details = React.forwardRef<{ validate: () => boolean }, DetailsProps>(
)}
- {errors.vision && (
+ {submitted && errors.vision && (
{errors.vision}
)}
diff --git a/components/landing-page/project/CreateProjectModal/Footer.tsx b/components/landing-page/project/CreateProjectModal/Footer.tsx
index ce642adfa..0e551a88c 100644
--- a/components/landing-page/project/CreateProjectModal/Footer.tsx
+++ b/components/landing-page/project/CreateProjectModal/Footer.tsx
@@ -18,7 +18,7 @@ const Footer = ({
const buttonText = isLastStep ? 'Submit for review' : 'Continue';
return (
-
+
{isLastStep && (
diff --git a/components/landing-page/project/CreateProjectModal/Header.tsx b/components/landing-page/project/CreateProjectModal/Header.tsx
index 3744a3a02..002d5f67f 100644
--- a/components/landing-page/project/CreateProjectModal/Header.tsx
+++ b/components/landing-page/project/CreateProjectModal/Header.tsx
@@ -16,7 +16,7 @@ const Header = ({ currentStep = 1, onBack }: HeaderProps) => {
];
return (
-
+
{currentStep > 1 && onBack && (
{
>
{step.name}
-
+
{isActive && (
-
+
+
+
+
)}
{isCompleted && (
+ void;
@@ -26,6 +28,36 @@ export interface MilestonesFormData {
milestones: Milestone[];
}
+const milestoneSchema = z
+ .object({
+ id: z.string(),
+ title: z.string().trim().min(1, 'Title is required'),
+ description: z.string().trim().min(1, 'Description is required'),
+ startDate: z.string().min(1, 'Start date is required'),
+ endDate: z.string().min(1, 'End date is required'),
+ })
+ .superRefine((val, ctx) => {
+ if (new Date(val.startDate) >= new Date(val.endDate)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['endDate'],
+ message: 'End date must be after start date',
+ });
+ }
+ });
+
+const milestonesSchema = z.object({
+ fundingAmount: z
+ .string()
+ .refine(
+ v => !isNaN(parseFloat(v)) && parseFloat(v) > 0,
+ 'Please enter a valid funding amount'
+ ),
+ milestones: z
+ .array(milestoneSchema)
+ .min(1, 'At least one complete milestone is required'),
+});
+
const Milestones = React.forwardRef<
{ validate: () => boolean },
MilestonesProps
@@ -95,64 +127,19 @@ const Milestones = React.forwardRef<
};
const validateForm = (): boolean => {
- const newErrors: { fundingAmount?: string; milestones?: string } = {};
-
- // Validate funding amount
- const fundingValue = parseFloat(formData.fundingAmount);
- if (!formData.fundingAmount || isNaN(fundingValue) || fundingValue <= 0) {
- newErrors.fundingAmount = 'Please enter a valid funding amount';
+ const parsed = milestonesSchema.safeParse(formData);
+ if (parsed.success) {
+ setErrors({});
+ return true;
}
-
- // Validate milestones
- const validMilestones = formData.milestones.filter(
- milestone =>
- milestone.title.trim() &&
- milestone.description.trim() &&
- milestone.startDate &&
- milestone.endDate
- );
-
- if (validMilestones.length === 0) {
- newErrors.milestones = 'At least one complete milestone is required';
- }
-
- // Validate individual milestones
- for (const milestone of formData.milestones) {
- if (
- milestone.title.trim() ||
- milestone.description.trim() ||
- milestone.startDate ||
- milestone.endDate
- ) {
- if (!milestone.title.trim()) {
- newErrors.milestones = 'All milestone titles are required';
- break;
- }
- if (!milestone.description.trim()) {
- newErrors.milestones = 'All milestone descriptions are required';
- break;
- }
- if (!milestone.startDate) {
- newErrors.milestones = 'All milestone start dates are required';
- break;
- }
- if (!milestone.endDate) {
- newErrors.milestones = 'All milestone end dates are required';
- break;
- }
- if (
- milestone.startDate &&
- milestone.endDate &&
- new Date(milestone.startDate) >= new Date(milestone.endDate)
- ) {
- newErrors.milestones = 'End date must be after start date';
- break;
- }
- }
+ const newErrors: { fundingAmount?: string; milestones?: string } = {};
+ for (const issue of parsed.error.issues) {
+ const key = issue.path[0];
+ if (key === 'fundingAmount') newErrors.fundingAmount = issue.message;
+ if (key === 'milestones') newErrors.milestones = issue.message;
}
-
setErrors(newErrors);
- return Object.keys(newErrors).length === 0;
+ return false;
};
// Expose validation function to parent
@@ -170,16 +157,19 @@ const Milestones = React.forwardRef<
handleInputChange('fundingAmount', e.target.value)}
className={cn(
- 'focus:border-primary border-[#484848] bg-[#1A1A1A] text-lg text-white placeholder:text-[#919191]',
+ 'focus-visible:border-primary border-[#2B2B2B] bg-[#101010] p-4 text-lg text-white placeholder:text-[#FFFFFF99]',
errors.fundingAmount && 'border-red-500'
)}
/>
-
+
This amount will serve as your project's total funding goal. It will
be allocated across milestones during admin review.
@@ -192,7 +182,7 @@ const Milestones = React.forwardRef<
{/* Milestones */}
-
+
Define milestones for your project{' '}
*
@@ -203,110 +193,128 @@ const Milestones = React.forwardRef<
-
- {formData.milestones.map(milestone => (
-
-
- {/* Drag Handle */}
-
-
-
-
- {/* Milestone Content */}
-
- {/* Title */}
-
-
- handleMilestoneChange(
- milestone.id,
- 'title',
- e.target.value
- )
- }
- className='focus:border-primary border-[#484848] bg-[#2A2A2A] text-white placeholder:text-[#919191]'
- />
+
+ {formData.milestones.map((milestone, index) => (
+
+ {index > 0 && (
+
+ )}
+
+
+ {/* Drag Handle */}
+
- {/* Description */}
-
-
-
- {/* Date Inputs */}
-
+ {/* Milestone Content */}
+
+ {/* Title */}
+ {/* Description */}
-
End Date
-
-
- handleMilestoneChange(
- milestone.id,
- 'endDate',
- e.target.value
- )
- }
- className='focus:border-primary border-[#484848] bg-[#2A2A2A] pr-10 text-white'
- />
-
+
+
+ {/* Date Inputs */}
+
+
+
+ Start Date
+
+
+
+ handleMilestoneChange(
+ milestone.id,
+ 'startDate',
+ e.target.value
+ )
+ }
+ className='focus-visible:border-primary border-[#2B2B2B] bg-[#101010] p-4 pr-10 text-white'
+ />
+
+
+
+
+
+
+ End Date
+
+
+
+ handleMilestoneChange(
+ milestone.id,
+ 'endDate',
+ e.target.value
+ )
+ }
+ className='focus-visible:border-primary border-[#2B2B2B] bg-[#101010] p-4 pr-10 text-white'
+ />
+
+
-
- {/* Remove Button */}
- {formData.milestones.length > 1 && (
-
removeMilestone(milestone.id)}
- className='h-8 w-8 p-0 text-[#B5B5B5] hover:bg-[#2A2A2A] hover:text-white'
- >
-
-
- )}
+ {/* Remove Button */}
+ {formData.milestones.length > 1 && (
+
removeMilestone(milestone.id)}
+ className='text-primary/32 bg-primary/8 hover:bg-primary/8 hover:text-primary h-6 w-6 rounded-full p-0'
+ >
+
+
+ )}
+
-
+
))}
@@ -316,10 +324,10 @@ const Milestones = React.forwardRef<
type='button'
variant='outline'
onClick={addMilestone}
- className='hover:border-primary border-[#484848] bg-[#1A1A1A] text-white hover:bg-[#2A2A2A]'
+ className='border-primary hover:text-primary hover:bg-primary/5 bg-transparent font-normal text-[#99FF2D] hover:bg-[#101010]'
>
-
Add Milestone
+
diff --git a/components/landing-page/project/CreateProjectModal/Team.tsx b/components/landing-page/project/CreateProjectModal/Team.tsx
index 4b60d05b1..c403abb50 100644
--- a/components/landing-page/project/CreateProjectModal/Team.tsx
+++ b/components/landing-page/project/CreateProjectModal/Team.tsx
@@ -1,10 +1,9 @@
'use client';
import React, { useState } from 'react';
-import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
-import { User, X, Search } from 'lucide-react';
+import Image from 'next/image';
interface TeamProps {
onDataChange?: (data: TeamFormData) => void;
@@ -46,13 +45,23 @@ const Team = React.forwardRef<{ validate: () => boolean }, TeamProps>(
};
const addMember = (username: string) => {
+ // Enforce max of 4 members
+ if (formData.members.length >= 4) {
+ setErrors(prev => ({
+ ...prev,
+ members: 'You can add up to 4 members max.',
+ }));
+ return;
+ }
+
+ const trimmed = username.trim();
if (
- username.trim() &&
- !formData.members.some(member => member.username === username.trim())
+ trimmed &&
+ !formData.members.some(member => member.username === trimmed)
) {
const newMember: TeamMember = {
id: Date.now().toString(),
- username: username.trim(),
+ username: trimmed,
role: 'MEMBER',
};
handleInputChange('members', [...formData.members, newMember]);
@@ -78,7 +87,6 @@ const Team = React.forwardRef<{ validate: () => boolean }, TeamProps>(
const newErrors: { members?: string } = {};
// Team step is optional, so no validation required
- // But we can add validation if needed in the future
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
@@ -94,8 +102,14 @@ const Team = React.forwardRef<{ validate: () => boolean }, TeamProps>(
{/* Creator Information */}
-
-
+
+
Creator Name
@@ -111,50 +125,64 @@ const Team = React.forwardRef<{ validate: () => boolean }, TeamProps>(
- {/* Search Input */}
+ {/* Search Input with Tags Inside */}
-
- setSearchQuery(e.target.value)}
- onKeyPress={handleKeyPress}
- className='focus:border-primary border-[#484848] bg-[#1A1A1A] pl-10 text-white placeholder:text-[#919191]'
- />
-
-
- {/* Member Tags */}
- {formData.members.length > 0 && (
-
+
+ {/* Member Tags Inside Input */}
{formData.members.map(member => (
-
+
{member.username}
removeMember(member.id)}
- className='h-5 w-5 rounded-full p-0 text-[#B5B5B5] hover:bg-[#3A3A3A] hover:text-white'
+ className='ml-1 h-[14px] w-[14px]'
>
-
+
+
+
+
))}
+
+ {/* Search Input */}
+
setSearchQuery(e.target.value)}
+ onKeyPress={handleKeyPress}
+ className='min-w-[200px] flex-1 bg-transparent text-sm text-white placeholder:text-[#919191] focus:outline-none'
+ />
- )}
+
{/* Add Member Button */}
- {searchQuery.trim() && (
+ {searchQuery.trim() && formData.members.length < 4 && (
addMember(searchQuery)}
- className='hover:border-primary border-[#484848] bg-[#1A1A1A] text-white hover:bg-[#2A2A2A]'
+ className='hover:border-primary border-primary/10 text-primary hover:text-primary hover:bg-primary/10 bg-transparent text-sm'
>
Add "{searchQuery}"
@@ -165,18 +193,6 @@ const Team = React.forwardRef<{ validate: () => boolean }, TeamProps>(
{errors.members}
)}
-
- {/* Team Information */}
-
-
- Team Information
-
-
- You can invite team members to collaborate on your project. They
- will have access to project details and can help manage milestones
- and updates.
-
-
);
}
diff --git a/components/landing-page/project/CreateProjectModal/index.tsx b/components/landing-page/project/CreateProjectModal/index.tsx
index 921d6c68f..8b53bb460 100644
--- a/components/landing-page/project/CreateProjectModal/index.tsx
+++ b/components/landing-page/project/CreateProjectModal/index.tsx
@@ -1,5 +1,6 @@
import BoundlessSheet from '@/components/sheet/boundless-sheet';
-import React, { useState, useRef, useCallback } from 'react';
+import React, { useState, useRef, useCallback, useEffect } from 'react';
+import Link from 'next/link';
import Header from './Header';
import Footer from './Footer';
import Basic, { BasicFormData } from './Basic';
@@ -7,6 +8,9 @@ import Details, { DetailsFormData } from './Details';
import Milestones, { MilestonesFormData } from './Milestones';
import Team, { TeamFormData } from './Team';
import Contact, { ContactFormData } from './Contact';
+import { z } from 'zod';
+
+type StepHandle = { validate: () => boolean; markSubmitted?: () => void };
interface CreateProjectModalProps {
open: boolean;
@@ -24,6 +28,8 @@ export interface ProjectFormData {
const CreateProjectModal = ({ open, setOpen }: CreateProjectModalProps) => {
const [currentStep, setCurrentStep] = useState(1);
const [isSubmitting, setIsSubmitting] = useState(false);
+ const [submitErrors, setSubmitErrors] = useState
([]);
+ const [showSuccess, setShowSuccess] = useState(false);
// Form data state
const [formData, setFormData] = useState({
@@ -36,13 +42,56 @@ const CreateProjectModal = ({ open, setOpen }: CreateProjectModalProps) => {
// Refs for step components to access validation methods
const stepRefs = {
- basic: useRef<{ validate: () => boolean }>(null),
- details: useRef<{ validate: () => boolean }>(null),
- milestones: useRef<{ validate: () => boolean }>(null),
- team: useRef<{ validate: () => boolean }>(null),
- contact: useRef<{ validate: () => boolean }>(null),
+ basic: useRef(null),
+ details: useRef(null),
+ milestones: useRef(null),
+ team: useRef(null),
+ contact: useRef(null),
};
+ // Ref for the scrollable content container
+ const contentRef = useRef(null);
+
+ // Reset scroll position when step changes with smooth transition
+ useEffect(() => {
+ if (currentStep === 1) return; // Skip for initial load
+
+ // Reset scroll position immediately (while content is hidden)
+ const resetScroll = () => {
+ // Reset window scroll first
+ window.scrollTo(0, 0);
+
+ if (contentRef.current) {
+ // Try to find the scrollable parent container
+ const scrollableParent =
+ contentRef.current.closest('[data-radix-scroll-area-viewport]') ||
+ contentRef.current.closest('.overflow-y-auto') ||
+ contentRef.current.parentElement?.querySelector('.overflow-y-auto');
+
+ if (scrollableParent) {
+ scrollableParent.scrollTop = 0;
+ } else {
+ // Fallback to scrolling the content ref itself
+ contentRef.current.scrollTop = 0;
+ }
+
+ // Also try to find any element with overflow-y-auto in the document
+ const allScrollableElements =
+ document.querySelectorAll('.overflow-y-auto');
+ allScrollableElements.forEach(element => {
+ if (element.contains(contentRef.current)) {
+ element.scrollTop = 0;
+ }
+ });
+ }
+ };
+
+ // Reset scroll immediately
+ resetScroll();
+
+ return () => {};
+ }, [currentStep]);
+
const handleBack = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1);
@@ -72,6 +121,9 @@ const CreateProjectModal = ({ open, setOpen }: CreateProjectModalProps) => {
};
const handleContinue = async () => {
+ // Mark the step as submitted so untouched fields can show errors
+ const key = getStepKey(currentStep);
+ stepRefs[key].current?.markSubmitted?.();
// Validate current step before proceeding
if (!validateCurrentStep()) {
return;
@@ -79,35 +131,112 @@ const CreateProjectModal = ({ open, setOpen }: CreateProjectModalProps) => {
if (currentStep < 5) {
setCurrentStep(currentStep + 1);
- } else {
- // Handle final submission
- await handleSubmit();
+ return;
}
+
+ // Handle final submission (show loader then success)
+ await handleSubmit();
};
const handleSubmit = async () => {
setIsSubmitting(true);
try {
- // TODO: Implement actual API call to submit project
- // console.log('Submitting project data:', formData);
-
- // Simulate API call
- await new Promise(resolve => setTimeout(resolve, 2000));
+ // Validate full payload with master schema before submitting
+ const milestoneSchema = z
+ .object({
+ id: z.string().optional(),
+ title: z.string().trim().min(1),
+ description: z.string().trim().min(1),
+ startDate: z.string().min(1),
+ endDate: z.string().min(1),
+ })
+ .superRefine((val, ctx) => {
+ if (new Date(val.startDate) >= new Date(val.endDate)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['endDate'],
+ message: 'End date must be after start date',
+ });
+ }
+ });
- // Reset form and close modal on success
- setFormData({
- basic: {},
- details: {},
- milestones: {},
- team: {},
- contact: {},
+ const projectSchema = z.object({
+ basic: z.object({
+ projectName: z.string().trim().min(1),
+ 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(),
+ demoVideoUrl: z
+ .string()
+ .url()
+ .optional()
+ .or(z.literal(''))
+ .optional(),
+ socialLinks: z.array(z.string()).min(1),
+ }),
+ details: z.object({
+ vision: z.string().trim().min(1),
+ }),
+ milestones: z.object({
+ fundingAmount: z
+ .string()
+ .refine(v => !isNaN(parseFloat(v)) && parseFloat(v) > 0),
+ milestones: z.array(milestoneSchema).min(1),
+ }),
+ team: z
+ .object({
+ members: z
+ .array(
+ z.object({
+ id: z.string(),
+ username: z.string().trim().min(1),
+ role: z.string().optional(),
+ })
+ )
+ .optional()
+ .default([]),
+ })
+ .optional()
+ .default({ members: [] }),
+ contact: z.object({
+ telegram: z.string().trim().min(1),
+ backupType: z.enum(['discord', 'whatsapp']),
+ backupContact: z.string().trim().min(1),
+ agreeToTerms: z.literal(true),
+ agreeToPrivacy: z.literal(true),
+ }),
});
- setCurrentStep(1);
- setOpen(false);
- // TODO: Show success notification
- alert('Project submitted successfully!');
+ // Clicking submit implies agreement to Terms and Privacy
+ const payload: ProjectFormData = {
+ ...formData,
+ contact: {
+ ...(formData.contact || {}),
+ agreeToTerms: true,
+ agreeToPrivacy: true,
+ },
+ } as ProjectFormData;
+
+ const parsed = projectSchema.safeParse(payload);
+ if (!parsed.success) {
+ setSubmitErrors(
+ parsed.error.issues.map(i => `${i.path.join('.')} - ${i.message}`)
+ );
+ setIsSubmitting(false);
+ return;
+ }
+
+ setSubmitErrors([]);
+
+ // Simulate API call with loading
+ await new Promise(resolve => setTimeout(resolve, 2000));
+
+ // Show success modal
+ setShowSuccess(true);
+ setIsSubmitting(false);
} catch {
// console.error('Error submitting project:', error);
// TODO: Show error notification
@@ -118,37 +247,157 @@ const CreateProjectModal = ({ open, setOpen }: CreateProjectModalProps) => {
};
const handleDataChange = useCallback(
- (step: keyof ProjectFormData, data: unknown) => {
+ (step: K, data: ProjectFormData[K]) => {
setFormData(prev => ({
...prev,
- [step]: { ...prev[step], ...data },
+ [step]: {
+ ...(prev[step] as Record),
+ ...(data as Record),
+ },
}));
},
[]
);
- const isStepValid = validateCurrentStep();
+ // Lightweight enable/disable for Continue button without firing validation side-effects
+ const isStepValid = (() => {
+ if (currentStep === 2) {
+ // Details step: require non-empty vision to enable Continue
+ const v = (formData.details?.vision || '').trim();
+ return v.length > 0;
+ }
+ if (currentStep === 5) {
+ // Contact step: enable when core fields are filled
+ // Terms/Privacy will be enforced on submit via schema
+ const contact = formData.contact || {};
+ return !!(
+ contact.telegram?.trim() &&
+ contact.backupType &&
+ contact.backupContact?.trim()
+ );
+ }
+ // For other steps, allow Continue (validation will run on click)
+ return true;
+ })();
+
+ const handleSuccessContinue = () => {
+ // Reset form and close modal
+ setFormData({
+ basic: {},
+ details: {},
+ milestones: {},
+ team: {},
+ contact: {},
+ });
+ setCurrentStep(1);
+ setShowSuccess(false);
+ setOpen(false);
+ };
const renderStepContent = () => {
- const commonProps = {
- onDataChange: (data: unknown) =>
- handleDataChange(getStepKey(currentStep), data),
- initialData: formData[getStepKey(currentStep)],
- };
+ if (showSuccess) {
+ return (
+
+ {/* Success Icon */}
+
+
+ {/* Success Message */}
+
+
+ Submission Successful!
+
+
+ Your project has been sent for admin review and will be processed
+ within 72 hours. You can track its status anytime on the{' '}
+
+ Projects Page
+
+ .
+
+
+
+ {/* Continue Button */}
+
+ Continue
+
+
+ );
+ }
switch (currentStep) {
case 1:
- return ;
+ return (
+ handleDataChange('basic', data)}
+ initialData={formData.basic}
+ />
+ );
case 2:
- return ;
+ return (
+ handleDataChange('details', data)}
+ initialData={formData.details}
+ />
+ );
case 3:
- return ;
+ return (
+ handleDataChange('milestones', data)}
+ initialData={formData.milestones}
+ />
+ );
case 4:
- return ;
+ return (
+ handleDataChange('team', data)}
+ initialData={formData.team}
+ />
+ );
case 5:
- return ;
+ return (
+ handleDataChange('contact', data)}
+ initialData={formData.contact}
+ />
+ );
default:
- return ;
+ return (
+ handleDataChange('basic', data)}
+ initialData={formData.basic}
+ />
+ );
}
};
@@ -158,16 +407,43 @@ const CreateProjectModal = ({ open, setOpen }: CreateProjectModalProps) => {
open={open}
setOpen={setOpen}
>
-
-
- {renderStepContent()}
+ {!showSuccess &&
}
+
+ {showSuccess ? (
+
+ {renderStepContent()}
+
+ ) : (
+ <>
+ {submitErrors.length > 0 && (
+
+
+ Please fix the following errors before submitting:
+
+
+ {submitErrors.map((e, idx) => (
+
+ {e}
+
+ ))}
+
+
+ )}
+
{renderStepContent()}
+ >
+ )}
-
+ {!showSuccess && (
+
+ )}
);
};
diff --git a/public/avatar.png b/public/avatar.png
new file mode 100644
index 0000000000000000000000000000000000000000..6adb541d3f35304cc78ca136347746ac65c25eed
GIT binary patch
literal 3855
zcmV+q5Ag7bP)
7GPu738^8Ijv)l>Fm%%X(LzaGGSiIJq)pRlW(>5M
zNy?CjP77p8nzR`Tolb)dEo~-EEY|^xT3{LD!4Jt^TbA|KKJ?h#`#SxdbNBAc3gDz?
z=kDIS_wK#l`F^kOcb4H-7`>17cQH%ZYjJE1scN6DBEcB%0vI^G;INd%d1~2lGMeoi
zZqYF~_}oAWzvN)J!Lhr)KapBe4mA3-y+MXPC=Qsd$a9?W$}Pn{C(sIqVkz`#L!sBA
z%~Ltke*9+L89a1@z&|ut0rDb&?DQ-B3Rr@BCkT)P=G~5aK*g~;2{9pJS$;20F?!KA
zxMElXw`$JQ9~QLwVJ_PE$XE6TRqaSpRrheN1SF`^gWY{`SOP7k&)s8%_Otms3WXwU
z&f!;eIwgQId09eLb}mBMt8Lkt&73?w@ms!E)l_l+9hoAKeCIpL$#Nr}yn7b$0
zm3MPZPnJjCE}$V4$JZbH1NvS?W+{hn{OEZ(5miw=&fTBUI67=8Y)|M-^zyOV+^
z&wVe)H1v4{Iu1vADN79bQLqY#M`G})WE{tRbkQ!#YsABE>FB`|zq=n7C#E4OrkiY7
zvj%_h!m~IxIqrQWCno1hTh8GlA!hCy`UP&WD%jlwGza}iNTtXoUlR|{t}NrzH{Xsw
zy7%+Y6peoA;Dw+4Cw_SB-=U}qws!Vn*QU*gguDs!6-O%^sVIy)SIZi!g8kV3KRcP!cppl$E?Nd2xcx5d-FX)l$efBy
zO+Yvh#;MUWxTX6h*fb-{v7}AK%d$;amW8FI6-1*U@&vK~x8c`9`1}8Q5hLScU<%!d
zmqS&;7D1G?`A9Nk-Z?lhkg3L{)C1kL|Eu3g`kX$ICmFFPreoU{K7YsE1awh8R6N8a
z)>7jsY-n4LC8GetF@-bAb0S?ajeJ+t5Q&74&SkK?v<#mfkmq8-h-8vWu7#U3*X_l#
zXp5<=Z(>C|SdXm+Z0GNP`JuHM`nfQ@$dRSM>27MnQZY|Jm}J5*Oia(rARLaA4P-kG
z9NQ+Jb)-#IdTBn5HXq{A1Z>O1{QMlkdJrc@PeYNcvQntJ?7GRsg|t>v`nxC#tqOKe
ze}7kp-*=GnYTt09z_cjY(Ch_(fIo~27tTwdoI5aW16<|MH61>m4xIq{C;(MrRYbkm
ze6$}9N1-tv#>U2wvGOPpDOpu`CF?Qn`Y^>Q+WpG+MMk;~?AaCVfCf=2>jvFrY;FQ%
zXK1>P)a8o^Q4n#OmG1W==nn}F>7kDN&Y?~2GhQhcG#TL@A`qF*@K^Wj$3kJrO?_29
zTb_Xjjx$j$`@LgX6!tv&wJyJkm%Qt2x`|T$6(cqS`0<-ZDI09VnX~VqwRH^wfq-ON
zq0GPp7^u=s6!K}GU{?!3Q+?0_G!>0lR!d-oq3ODr^c)uQi;`J*TNyMf1u78NSYN}(
zZ~yMp&`?2>tQyAdPQO;ZR`rrRO0}M5ZxJJpe)k*r%Ll$FtC3CqsZvFvsnLCP1PQzd
z#PKUB96k0rrY0xIM;EZPVoFYYI+v4fXk3GQzJNPFu^l6mV~FaZ+GoM_VK4vEY!aEA
zWcM+Nu)(ih7kTv~O)Px%lMf=ZoGbY&gHAF_(Nr8ib__om9K_7rA}(GWL*FgiWww}@
zxP(wRj(99atcFlD7|xx)fZ5rrV1;G0Y)nvEq@XnH8o>4Wv{_T{PZ3xqDwVG2muy~f
zU>lxjkO@p$C8-8+-%Yy^NyHG3$EB(N@t=N(<3lIV(6SDb=YNXMt@ok0v_KibMq674
z(hGAGfJMwsO%m%SWduX!-Ksh8_>vSk-B(>GKE8_%}0R#I&$QvNR#Q_dFy9rZe54$#4zsKu?@47+CKI5f5jiZcovhhIW!w%m_#f7
z{5QMt?y2K=eQXvnN;ln68=Dh(EN5rn&_uNjQR(v{g>iVvwv=aCEoD9^eVSikUA3mK
zW!U4^WHv@e$1ypT!rFB`7=7=5@jJVE(3*_mvHN?`8VO*TbZe1ykdLd_O9CbsPvTGZ
z2n3uDc8#eC7FYeLh`p``>p{W
zW2*wK{E{J?n!!|Rmg@W*6@sA5n_t{>E4tbnarSBkZ_ipVD24r6(l4u3vV9$vrmvwr
z8N$n_XYlT|9EL|{5DN!jQb}&6BH@4FPN0yN60=g#DxXq9maI&m6Iyk|c|c_XDeVYz
zD0Fi(3TIg5o2eRjF76kSF$H!5~($i?B^wsxDFWIBDG0
zSQtu67_xRrdkZn+0u+Vba|Mq%K_sQ!lv1>LhIwpOFE0ERn!Z8-N2xtmtHloLYFE{+
za1nz$DXr-6Q)Ga?>hlh&su?T!5$)&5EU(QjVN*1K_MnRSxp_om2}(aCSj4rKt1nD)
zi<55;BQv*v9iO=eD)POG9+8t@W7*AA|_2toO=&r
z7srvH)TU9T%cs+rFL78UTgpHszXY^Y=*!oU&OM|s%Q^JE{dnDIrd5O2GD$phY@C=e
zkjZ3_OJ`)@SY`olo;r_z{d`s6H!hszq)HR%2u`n@{L6%ILraYR>EXdT>bJGTlUK*tW
z5QawDwLp`}k>A?s1-vwJg|x3O(PVl`?!Hr8q7R$N
z+(lw8o6AyP7C?ydXjf-978S~=>)Qzg$Iv^cF+yFg=D1>|?2}%ptjV8lJrfqbRqQ+WH0H!x0(
zWkWoQ?A#oN29F{h3Y45)q$LH_(7e`(d0XkZq;gX|etV$ms>{z-<+|k?hEjwmmvJoiuLgL
z55D~a?^KN>$ebKHt8d+vjA(3Ubt0*rQN=CXvS`rFw^J=hG$iDejJjQgm<~{I1%n}4
zsWXT`)qS!6sFX2OihRXK%#ya<;wuqHH{qpCDJ>((-itP>
zDOjMjuCebu)nAY8Iqywh6(89ck~Z-Au2R_*M`9VjNip|fm9gr|_toK{%)0G&9A0v;
zJD{*u`~ra?q98}iZfl64Arg^H>a><6#CJFvmJ1<`jg4p|=BXpWQ*_pX=^Mb0kRmK$ALw
z1R+ujY5d2P2@FmxpoLt+lKHgav+|Dgb~SH5y(MlxcBpQd^M=5R(fQDqAL7KMTnM>6t?=qEr(~WS%+sgK&JPKR|LL$49np
z`;Eirix!J&*vYPQb(d|CXM?n^;3q#0&}WD?oqU$90+bd=GgpfITTKz`w@04+&IkVa
z;XdUC#~xb2Icq4plbU)kq@l|-sZ$a=ZopLcb7Ht{lZ<&RJ8UFlI)}Sr*1+KVnEd|*
zZ1oWTJxqxe9pHYQ{u}erzl$1EyS!3L;8I1-GkMz?(HTEwYtEswzrsI^{|6#IYF?>P
Rw%Pyy002ovPDHLkV1kgsQx^aL
literal 0
HcmV?d00001