diff --git a/app/test/page.tsx b/app/test/page.tsx index d66ac21a0..58af217b8 100644 --- a/app/test/page.tsx +++ b/app/test/page.tsx @@ -1,80 +1,41 @@ '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); +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 ( -
-
-

- Launch Campaign Test -

- -

- Click the button below to test the Launch Campaign feature +

+
+

Loading State Test

+

+ Test the animated loading state across different screen sizes

- + + Test Loading State + - {/* Debug info */} -
- Modal state: {showLaunchFlow ? 'Open' : 'Closed'} -
+ {showLoading && } - {/* Launch Campaign Flow Modal */} - - - - - {/* Wallet Required Modal */} - +
+

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/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..ffd3c2576 100644 --- a/components/landing-page/project/CreateProjectModal/Basic.tsx +++ b/components/landing-page/project/CreateProjectModal/Basic.tsx @@ -1,7 +1,555 @@ -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 { 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; + initialData?: Partial; +} + +export interface BasicFormData { + projectName: string; + logo: File | null; + vision: string; + category: string; + githubUrl: string; + websiteUrl: string; + demoVideoUrl: string; + 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', + '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 [touched, setTouched] = useState< + Partial> + >({}); + const [submitted, setSubmitted] = useState(false); + + 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) { + setTouched(prev => ({ ...prev, logo: true })); + // 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 => { + // Normalize social links (trim empties but keep array length for UI) + const parsed = basicSchema.safeParse({ + ...formData, + socialLinks: formData.socialLinks.map(s => s.trim()), + }); + + if (parsed.success) { + setErrors({}); + return true; + } + + 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'; + + setErrors(fieldErrors); + return false; + }; + + // 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' })); + } + } + }; + + // Expose validation function to parent + React.useImperativeHandle(ref, () => ({ + validate: validateForm, + markSubmitted: () => setSubmitted(true), + })); + + return ( +
+ {/* Project Name */} +
+ + handleInputChange('projectName', e.target.value)} + onBlur={() => validateField('projectName')} + className={cn( + 'focus-visible:border-primary border-[#484848] bg-[#1A1A1A] p-4 text-white placeholder:text-[#919191]', + (submitted || touched.projectName) && + errors.projectName && + 'border-red-500' + )} + /> + {(submitted || touched.projectName) && errors.projectName && ( +

{errors.projectName}

+ )} +
+ + {/* Logo/Image */} +
+ +
+ + +
+
+ + 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}

+ )} +
+
+ + {/* Vision */} +
+
+ + + {formData.vision.length}/300 + +
+