diff --git a/package.json b/package.json index ef3bbf9..de6b41f 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@internationalized/date": "^3.5.4", "@nextui-org/accordion": "^2.0.28", "@nextui-org/avatar": "^2.0.21", + "@nextui-org/badge": "^2.2.5", "@nextui-org/button": "^2.0.21", "@nextui-org/calendar": "^2.0.7", "@nextui-org/card": "^2.0.21", @@ -30,6 +31,7 @@ "@nextui-org/progress": "^2.0.24", "@nextui-org/radio": "^2.0.22", "@nextui-org/scroll-shadow": "^2.1.12", + "@nextui-org/select": "^2.4.9", "@nextui-org/skeleton": "^2.0.22", "@nextui-org/spinner": "^2.0.19", "@nextui-org/switch": "^2.0.33", @@ -54,6 +56,7 @@ "firebase-admin": "^11.11.0", "framer-motion": "^11.3.2", "lodash.debounce": "^4.0.8", + "lucide-react": "^0.545.0", "next": "^14.0.3", "next-themes": "^0.2.1", "next-usequerystate": "^1.10.2", diff --git a/public/icons/user-group.svg b/public/icons/user-group.svg new file mode 100644 index 0000000..64d2d2c --- /dev/null +++ b/public/icons/user-group.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/app/(site)/activity/ActivityClientWrapper.tsx b/src/app/(site)/activity/ActivityClientWrapper.tsx new file mode 100644 index 0000000..62c52b4 --- /dev/null +++ b/src/app/(site)/activity/ActivityClientWrapper.tsx @@ -0,0 +1,26 @@ +// Copyright 2019-2025 @polkassembly/fellowship authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +'use client'; + +import React from 'react'; +import ActivityFeed from '@/components/Home/ActivityFeed'; +import ActivitySelectorCard from '@/components/Home/ActivitySelectorCard'; +import { EActivityFeed, ActivityFeedItem, Network } from '@/global/types'; + +interface Props { + feedItems: ActivityFeedItem[]; + feed: EActivityFeed; + network: Network; + originUrl: string; +} + +export default function ActivityClientWrapper({ feedItems, feed, network, originUrl }: Props) { + return ( +
+ + +
+ ); +} diff --git a/src/app/(site)/activity/page.tsx b/src/app/(site)/activity/page.tsx new file mode 100644 index 0000000..d9b8f96 --- /dev/null +++ b/src/app/(site)/activity/page.tsx @@ -0,0 +1,50 @@ +// Copyright 2019-2025 @polkassembly/fellowship authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { API_ERROR_CODE } from '@/global/constants/errorCodes'; +import { ClientError } from '@/global/exceptions'; +import MESSAGES from '@/global/messages'; +import { EActivityFeed, Network, ServerComponentProps, ActivityFeedItem } from '@/global/types'; +import { headers } from 'next/headers'; +import { Metadata } from 'next'; +import getOriginUrl from '@/utils/getOriginUrl'; +import getActivityFeed from '@/app/api/v1/feed/getActivityFeed'; +import ActivityClientWrapper from './ActivityClientWrapper'; + +type SearchParamProps = { + feed: string; + network?: string; +}; + +export const metadata: Metadata = { + title: 'Activity Feed', + description: 'View fellowship activity feed and recent proposals.' +}; + +export default async function ActivityPage({ searchParams }: Readonly>) { + const { feed = EActivityFeed.ALL, network } = searchParams ?? {}; + + // validate feed search param + if (feed && !Object.values(EActivityFeed).includes(feed as EActivityFeed)) { + throw new ClientError(MESSAGES.INVALID_SEARCH_PARAMS_ERROR, API_ERROR_CODE.INVALID_SEARCH_PARAMS_ERROR); + } + + const headersList = headers(); + const originUrl = getOriginUrl(headersList); + + const feedItems = await getActivityFeed({ + feedType: feed as EActivityFeed, + originUrl, + network: network as Network + }); + + return ( + + ); +} diff --git a/src/app/(site)/create-proposal/page.tsx b/src/app/(site)/create-proposal/page.tsx new file mode 100644 index 0000000..98273e4 --- /dev/null +++ b/src/app/(site)/create-proposal/page.tsx @@ -0,0 +1,68 @@ +// Copyright 2019-2025 @polkassembly/fellowship authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +'use client'; + +import CreateProposalForm from '@/components/CreateProposal/CreateProposalForm'; +import { Button } from '@nextui-org/button'; +import { useRef, useState } from 'react'; +import { useUserDetailsContext } from '@/contexts'; +import LinkWithNetwork from '@/components/Misc/LinkWithNetwork'; + +export default function CreateProposal() { + const { id } = useUserDetailsContext(); + + const formRef = useRef(null); + const [isFormValid, setIsFormValid] = useState(false); + const [isFormLoading, setIsFormLoading] = useState(false); + + const handleSubmit = () => { + if (formRef.current) { + formRef?.current?.requestSubmit(); + } + }; + + return ( +
+

Create Proposal

+ +
+ {!id ? ( +
+ Please{' '} + + login + {' '} + to create a proposal. +
+ ) : ( +
+ { + // Optionally redirect or show success message + }} + onFormStateChange={(isValid, isLoading) => { + setIsFormValid(isValid); + setIsFormLoading(isLoading); + }} + /> + + +
+ )} +
+
+ ); +} diff --git a/src/app/(site)/submit-evidence/page.tsx b/src/app/(site)/submit-evidence/page.tsx new file mode 100644 index 0000000..1c37290 --- /dev/null +++ b/src/app/(site)/submit-evidence/page.tsx @@ -0,0 +1,68 @@ +// Copyright 2019-2025 @polkassembly/fellowship authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +'use client'; + +import SubmitEvidenceForm from '@/components/SubmitEvidence/SubmitEvidenceForm'; +import { Button } from '@nextui-org/button'; +import { useRef, useState } from 'react'; +import { useUserDetailsContext } from '@/contexts'; +import LinkWithNetwork from '@/components/Misc/LinkWithNetwork'; + +export default function SubmitEvidence() { + const { id } = useUserDetailsContext(); + + const formRef = useRef(null); + const [isFormValid, setIsFormValid] = useState(false); + const [isFormLoading, setIsFormLoading] = useState(false); + + const handleSubmit = () => { + if (formRef.current) { + formRef?.current?.requestSubmit(); + } + }; + + return ( +
+

Submit Evidence

+ +
+ {!id ? ( +
+ Please{' '} + + login + {' '} + to submit evidence. +
+ ) : ( +
+ { + // Optionally redirect or show success message + }} + onFormStateChange={(isValid, isLoading) => { + setIsFormValid(isValid); + setIsFormLoading(isLoading); + }} + /> + + +
+ )} +
+
+ ); +} diff --git a/src/app/@modal/(site)/(.)create-proposal/page.tsx b/src/app/@modal/(site)/(.)create-proposal/page.tsx new file mode 100644 index 0000000..ad7c3eb --- /dev/null +++ b/src/app/@modal/(site)/(.)create-proposal/page.tsx @@ -0,0 +1,97 @@ +// Copyright 2019-2025 @polkassembly/fellowship authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +'use client'; + +import CreateProposalForm from '@/components/CreateProposal/CreateProposalForm'; +import { Button } from '@nextui-org/button'; +import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from '@nextui-org/modal'; +import { useRouter } from 'next/navigation'; +import React, { useRef, useState } from 'react'; +import { Divider } from '@nextui-org/divider'; +import { useUserDetailsContext } from '@/contexts'; +import LinkWithNetwork from '@/components/Misc/LinkWithNetwork'; +import { FileText } from 'lucide-react'; + +function CreateProposalModal() { + const router = useRouter(); + const { id } = useUserDetailsContext(); + + const formRef = useRef(null); + const [isModalOpen, setIsModalOpen] = useState(true); + const [isFormValid, setIsFormValid] = useState(false); + const [isFormLoading, setIsFormLoading] = useState(false); + + const handleOnClose = () => { + router.back(); + }; + + const handleSubmit = () => { + if (formRef.current) { + formRef?.current?.requestSubmit(); + } + }; + + return ( + + + {() => + id ? ( + <> + + +

Create Proposal

+
+ + + + setIsModalOpen(false)} + onFormStateChange={(isValid, isLoading) => { + setIsFormValid(isValid); + setIsFormLoading(isLoading); + }} + /> + + + + + + + + + ) : ( +
+ Please{' '} + + login + {' '} + to create a proposal. +
+ ) + } +
+
+ ); +} + +export default CreateProposalModal; diff --git a/src/app/@modal/(site)/(.)submit-evidence/page.tsx b/src/app/@modal/(site)/(.)submit-evidence/page.tsx new file mode 100644 index 0000000..0a2b3c7 --- /dev/null +++ b/src/app/@modal/(site)/(.)submit-evidence/page.tsx @@ -0,0 +1,97 @@ +// Copyright 2019-2025 @polkassembly/fellowship authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +'use client'; + +import SubmitEvidenceForm from '@/components/SubmitEvidence/SubmitEvidenceForm'; +import { Button } from '@nextui-org/button'; +import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from '@nextui-org/modal'; +import { useRouter } from 'next/navigation'; +import React, { useRef, useState } from 'react'; +import { Divider } from '@nextui-org/divider'; +import { useUserDetailsContext } from '@/contexts'; +import LinkWithNetwork from '@/components/Misc/LinkWithNetwork'; +import { FileText } from 'lucide-react'; + +function SubmitEvidenceModal() { + const router = useRouter(); + const { id } = useUserDetailsContext(); + + const formRef = useRef(null); + const [isModalOpen, setIsModalOpen] = useState(true); + const [isFormValid, setIsFormValid] = useState(false); + const [isFormLoading, setIsFormLoading] = useState(false); + + const handleOnClose = () => { + router.back(); + }; + + const handleSubmit = () => { + if (formRef.current) { + formRef?.current?.requestSubmit(); + } + }; + + return ( + + + {() => + id ? ( + <> + + +

Submit Evidence

+
+ + + + setIsModalOpen(false)} + onFormStateChange={(isValid, isLoading) => { + setIsFormValid(isValid); + setIsFormLoading(isLoading); + }} + /> + + + + + + + + + ) : ( +
+ Please{' '} + + login + {' '} + to submit evidence. +
+ ) + } +
+
+ ); +} + +export default SubmitEvidenceModal; diff --git a/src/app/HomeClientWrapper.tsx b/src/app/HomeClientWrapper.tsx new file mode 100644 index 0000000..50751b8 --- /dev/null +++ b/src/app/HomeClientWrapper.tsx @@ -0,0 +1,97 @@ +// Copyright 2019-2025 @polkassembly/fellowship authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +'use client'; + +import React, { useEffect, useState, useMemo } from 'react'; +import ActivityFeed from '@/components/Home/ActivityFeed'; +import ActivitySelectorCard from '@/components/Home/ActivitySelectorCard'; +import Carousel from '@/components/Home/Carousel'; +import Stats from '@/components/Home/Stats'; +import PostFeed from '@/components/Home/PostFeed'; +import Profile from '@/components/Profile'; +import { useApiContext, useUserDetailsContext } from '@/contexts'; +import { ActivityFeedItem, EActivityFeed, IProfile, Network } from '@/global/types'; +import getSubstrateAddress from '@/utils/getSubstrateAddress'; +import getProfile from './api/v1/address/[address]/getProfile'; +import LoadingSpinner from '@/components/Misc/LoadingSpinner'; + +interface Props { + feedItems: ActivityFeedItem[] | any[]; + feed: EActivityFeed; + network: Network; + originUrl: string; +} + +export default function HomeClientWrapper({ feedItems, feed, network, originUrl }: Props) { + const { fellows } = useApiContext(); + const { loginAddress } = useUserDetailsContext(); + + const [userProfile, setUserProfile] = useState(null); + const [loading, setLoading] = useState(false); + + // Check if current user is a fellow + const isFellow = useMemo(() => { + if (!loginAddress || !fellows?.length) return false; + const substrateAddress = getSubstrateAddress(loginAddress); + return fellows.find((f: any) => f.address === substrateAddress) !== undefined; + }, [loginAddress, fellows]); + + // Fetch user profile data (for fellows) + useEffect(() => { + if (isFellow && loginAddress) { + const fetchUserProfile = async () => { + try { + setLoading(true); + const profile = await getProfile({ + address: loginAddress, + originUrl, + network + }); + setUserProfile(profile); + } catch (error) { + console.error('Error fetching user profile:', error); + setUserProfile(null); + } finally { + setLoading(false); + } + }; + + fetchUserProfile(); + } + }, [isFellow, loginAddress, network, originUrl]); + + console.log('userProfile', userProfile); + + if (loading) { + return ( +
+ +
+ +
+
+ ); + } + + // If user is a fellow, show their profile + if (isFellow && userProfile) { + return ; + } + + // If user is not a fellow, show activity feed + return ( +
+ + +
+
+ + + {feed === EActivityFeed.ALL ? : } +
+
+
+ ); +} diff --git a/src/app/api/v1/address/[address]/activity/utils.ts b/src/app/api/v1/address/[address]/activity/utils.ts index 7a5b37c..06a946c 100644 --- a/src/app/api/v1/address/[address]/activity/utils.ts +++ b/src/app/api/v1/address/[address]/activity/utils.ts @@ -117,7 +117,15 @@ export const getUserActivityFeedServer = async (address: string, page: number): const proposalIndexes = getProposalIndexes(activities); const indexes = Array.from(proposalIndexes); - const querySnapshot = await postsCollRef(network, ProposalType.FELLOWSHIP_REFERENDUMS).where('index', 'in', indexes).get(); + + // Skip Firestore query if no indexes to avoid 'IN requires non-empty ArrayValue' error + let querySnapshot; + if (indexes.length > 0) { + querySnapshot = await postsCollRef(network, ProposalType.FELLOWSHIP_REFERENDUMS).where('index', 'in', indexes).get(); + } else { + // Create empty query snapshot when no indexes + querySnapshot = { docs: [] }; + } const titleContentMap = new Map(); let metadatas = querySnapshot.docs diff --git a/src/app/api/v1/treasury/route.ts b/src/app/api/v1/treasury/route.ts new file mode 100644 index 0000000..10d2ad7 --- /dev/null +++ b/src/app/api/v1/treasury/route.ts @@ -0,0 +1,142 @@ +// Copyright 2019-2025 @polkassembly/fellowship authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextRequest, NextResponse } from 'next/server'; +import { headers } from 'next/headers'; +import getNetworkFromHeaders from '@/app/api/api-utils/getNetworkFromHeaders'; +import withErrorHandling from '@/app/api/api-utils/withErrorHandling'; +import { API_ERROR_CODE } from '@/global/constants/errorCodes'; +import { APIError } from '@/global/exceptions'; +import MESSAGES from '@/global/messages'; +import { ApiPromise, WsProvider } from '@polkadot/api'; +import networkConstants from '@/global/networkConstants'; + +interface TreasuryData { + cycleIndex: number; + cycleProgress: number; + totalCycleDays: number; + daysRemaining: number; + lastPayoutDaysAgo: number; + nextPayoutDays: number; + treasurySpendPeriod: number; + currentSpendingPeriod: number; + spendingProgress: number; +} + +async function getCurrentBlock(api: ApiPromise) { + return await api.rpc.chain.getHeader(); +} + +async function getTreasuryData(api: ApiPromise): Promise { + try { + // Check if treasury module exists + if (!api.consts.treasury) { + // Check for fellowship salary cycle instead + if (api.consts.fellowshipSalary) { + const registrationPeriod = api.consts.fellowshipSalary.registrationPeriod as any; + const registrationPeriodValue = registrationPeriod?.toJSON() || 0; + + // Get current block + const currentBlock = await getCurrentBlock(api); + + // Use registration period as cycle length (this is an approximation) + const cycleLength = registrationPeriodValue * 10; // Multiply by 10 for longer cycles + const currentCycle = Math.floor(currentBlock.number.toNumber() / cycleLength); + const cycleProgress = ((currentBlock.number.toNumber() % cycleLength) / cycleLength) * 100; + + // Convert blocks to days (assuming 6 second block time for collectives) + const blocksPerDay = (24 * 60 * 60) / 6; // 14400 blocks per day + const daysRemaining = Math.ceil((cycleLength - (currentBlock.number.toNumber() % cycleLength)) / blocksPerDay); + const totalCycleDays = Math.ceil(cycleLength / blocksPerDay); + + return { + cycleIndex: currentCycle, + cycleProgress: Math.round(cycleProgress), + totalCycleDays, + daysRemaining, + lastPayoutDaysAgo: 0, + nextPayoutDays: daysRemaining, + treasurySpendPeriod: cycleLength, + currentSpendingPeriod: currentCycle, + spendingProgress: Math.round(cycleProgress) + }; + } + + // Return default values if no modules found + return { + cycleIndex: 0, + cycleProgress: 0, + totalCycleDays: 0, + daysRemaining: 0, + lastPayoutDaysAgo: 0, + nextPayoutDays: 0, + treasurySpendPeriod: 0, + currentSpendingPeriod: 0, + spendingProgress: 0 + }; + } + + // Get treasury spend period information + const spendPeriod = api.consts.treasury.spendPeriod as any; + const treasurySpendPeriod = (spendPeriod?.toJSON() as number) || 0; + + // Get current block + const currentBlock = await getCurrentBlock(api); + + // Calculate current spending period + const currentSpendingPeriod = Math.floor(currentBlock.number.toNumber() / treasurySpendPeriod); + const spendingProgress = ((currentBlock.number.toNumber() % treasurySpendPeriod) / treasurySpendPeriod) * 100; + + // Convert blocks to days (assuming 6 second block time) + const blocksPerDay = (24 * 60 * 60) / 6; // 14400 blocks per day + const daysRemaining = Math.ceil((treasurySpendPeriod - (currentBlock.number.toNumber() % treasurySpendPeriod)) / blocksPerDay); + const totalCycleDays = Math.ceil(treasurySpendPeriod / blocksPerDay); + + return { + cycleIndex: currentSpendingPeriod, + cycleProgress: Math.round(spendingProgress), + totalCycleDays, + daysRemaining, + lastPayoutDaysAgo: 0, // This would need to be fetched from historical data + nextPayoutDays: daysRemaining, + treasurySpendPeriod, + currentSpendingPeriod, + spendingProgress: Math.round(spendingProgress) + }; + } catch (error) { + console.error('Error fetching treasury data:', error); + throw error; + } +} + +export const GET = withErrorHandling(async (req: NextRequest) => { + const headersList = headers(); + const network = getNetworkFromHeaders(headersList); + + if (!network) { + throw new APIError(`${MESSAGES.INVALID_PARAMS_ERROR}`, 500, API_ERROR_CODE.INVALID_PARAMS_ERROR); + } + + try { + // Create API connection + const wsProvider = networkConstants[String(network)]?.rpcEndpoints?.[0]?.key; + if (!wsProvider) { + throw new APIError('No RPC endpoint available for network', 500, 'RPC_ERROR'); + } + + const provider = new WsProvider(wsProvider); + const api = new ApiPromise({ provider }); + + await api.isReady; + + const treasuryData = await getTreasuryData(api); + + await api.disconnect(); + + return NextResponse.json(treasuryData); + } catch (error) { + console.error('Error in treasury API:', error); + throw new APIError('Failed to fetch treasury data', 500, 'TREASURY_FETCH_ERROR'); + } +}); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 8654cb4..ebe9a8d 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -7,6 +7,7 @@ import 'react-toastify/dist/ReactToastify.css'; import type { Metadata } from 'next'; import { ReactNode } from 'react'; import AppSidebar from '@/components/Header/AppSidebar'; +import RightSidebar from '@/components/Header/RightSidebar/RightSidebar'; import AppNavbar from '@/components/Header/AppNavbar'; import NotificationsContainer from '@/components/Misc/NotificationsContainer'; import Footer from '@/components/Footer/Footer'; @@ -24,13 +25,16 @@ export default function RootLayout({ children, modal }: { children: ReactNode; m
-
+
{children}
+
+ +
diff --git a/src/app/page.tsx b/src/app/page.tsx index 12a82de..f20e507 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,23 +2,15 @@ // This software may be modified and distributed under the terms // of the Apache-2.0 license. See the LICENSE file for details. -import ActivityFeed from '@/components/Home/ActivityFeed'; -import ActivitySelectorCard from '@/components/Home/ActivitySelectorCard'; -import Carousel from '@/components/Home/Carousel'; -import JoinFellowshipCard from '@/components/Home/JoinFellowshipCard'; -import Stats from '@/components/Home/Stats'; -import TrendingProposals from '@/components/Home/TrendingProposals'; import { API_ERROR_CODE } from '@/global/constants/errorCodes'; import { ClientError } from '@/global/exceptions'; import MESSAGES from '@/global/messages'; -import { ActivityFeedItem, EActivityFeed, Network, PostFeedListingItem, ServerComponentProps } from '@/global/types'; +import { EActivityFeed, Network, ServerComponentProps } from '@/global/types'; import { headers } from 'next/headers'; import { Metadata } from 'next'; import getOriginUrl from '@/utils/getOriginUrl'; -import PostFeed from '@/components/Home/PostFeed'; -import PendingTasks from '@/components/Home/PendingTasks'; import getActivityFeed from './api/v1/feed/getActivityFeed'; -import getTrendingProposals from './api/v1/feed/trending/getTrendingProposals'; +import HomeClientWrapper from '@/app/HomeClientWrapper'; type SearchParamProps = { feed: string; @@ -30,8 +22,7 @@ export const metadata: Metadata = { description: 'Fellowship never felt so good before. - Home' }; -export default async function Home({ searchParams }: ServerComponentProps) { - // TODO: default should be pending if user is logged in and is a fellow +export default async function Home({ searchParams }: Readonly>) { const { feed = EActivityFeed.ALL, network } = searchParams ?? {}; // validate feed search param @@ -44,26 +35,12 @@ export default async function Home({ searchParams }: ServerComponentProps - - -
-
- - - - {feed === EActivityFeed.ALL ? : } -
-
- - - - -
-
- + ); } diff --git a/src/components/CreateProposal/CreateProposalForm.tsx b/src/components/CreateProposal/CreateProposalForm.tsx new file mode 100644 index 0000000..b67ee2f --- /dev/null +++ b/src/components/CreateProposal/CreateProposalForm.tsx @@ -0,0 +1,449 @@ +// Copyright 2019-2025 @polkassembly/fellowship authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +'use client'; + +import React, { useState } from 'react'; +import { useForm, Controller } from 'react-hook-form'; +import { Select, SelectItem } from '@nextui-org/select'; +import { Input, Textarea } from '@nextui-org/input'; +import { useApiContext, useUserDetailsContext } from '@/contexts'; +import { Wallet } from '@/global/types'; +import { InjectedAccount } from '@polkadot/extension-inject/types'; +import { useSearchParams } from 'next/navigation'; +import queueNotification from '@/utils/queueNotification'; +import LoadingSpinner from '@/components/Misc/LoadingSpinner'; +import AlertCard from '@/components/Misc/AlertCard'; +import AddressSwitch from '@/components/Misc/AddressSwitch'; +import EvidenceSelector from '@/components/Misc/EvidenceSelector'; +import getSubstrateAddress from '@/utils/getSubstrateAddress'; + +interface Props { + readonly formRef: React.RefObject; + readonly onSuccess?: () => void; + readonly onFormStateChange?: (isValid: boolean, isLoading: boolean) => void; +} + +export enum ProposalType { + PROMOTION = 'Promotion', + RETENTION = 'Retention' +} + +interface FormData { + title: string; + proposalType: ProposalType; + motivation: string; + interest: string; + evidenceId: string; +} + +function CreateProposalForm({ formRef, onSuccess, onFormStateChange }: Props) { + const { api, apiReady, fellows } = useApiContext(); + const { id, addresses } = useUserDetailsContext(); + const searchParams = useSearchParams(); + + const { + formState: { errors }, + control, + handleSubmit + } = useForm({ + defaultValues: { + title: '', + proposalType: ProposalType.PROMOTION, + motivation: '', + interest: '', + evidenceId: searchParams?.get('evidenceId') || '' + } + }); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [selectedWallet, setSelectedWallet] = useState(null); + const [selectedAddress, setSelectedAddress] = useState(null); + const [txStatus, setTxStatus] = useState(''); + + // Form validation state + const isFormValid = Boolean(selectedWallet && selectedAddress && api && apiReady && !loading); + + // Notify parent component of form state changes + React.useEffect(() => { + onFormStateChange?.(isFormValid, loading); + }, [isFormValid, loading, onFormStateChange]); + + const currentFellow = fellows?.find((fellow) => fellow.address === getSubstrateAddress(addresses?.[0] || '')); + + // Validation function to check if form can be submitted + const validateFormData = (data: FormData): string | null => { + if (!data.title || data.title.trim().length === 0) { + return 'Title is required.'; + } + + if (data.title.trim().length < 5) { + return 'Title must be at least 5 characters long.'; + } + + if (data.title.trim().length > 200) { + return 'Title must be less than 200 characters.'; + } + + if (!data.proposalType || !Object.values(ProposalType).includes(data.proposalType)) { + return 'Please select a valid proposal type.'; + } + + if (!data.motivation || data.motivation.trim().length === 0) { + return 'Motivation is required.'; + } + + if (data.motivation.trim().length < 20) { + return 'Motivation must be at least 20 characters long.'; + } + + if (data.motivation.trim().length > 5000) { + return 'Motivation must be less than 5,000 characters.'; + } + + if (!data.interest || data.interest.trim().length === 0) { + return 'Interest is required.'; + } + + if (data.interest.trim().length < 10) { + return 'Interest must be at least 10 characters long.'; + } + + if (data.interest.trim().length > 2000) { + return 'Interest must be less than 2,000 characters.'; + } + + if (!data.evidenceId || data.evidenceId.trim().length === 0) { + return 'Evidence is required.'; + } + + return null; + }; + + const submitForm = async (data: FormData) => { + // Early return if basic conditions are not met + if (!id) { + setError('Please login to create a proposal.'); + return; + } + + if (loading) { + setError('Transaction is already in progress.'); + return; + } + + if (!api || !apiReady) { + setError('API is not ready. Please try again.'); + return; + } + + if (!selectedWallet) { + setError('Please select a wallet.'); + return; + } + + if (!selectedAddress) { + setError('Please select an address.'); + return; + } + + // Validate selected address + if (!selectedAddress.address) { + setError('Selected address is invalid.'); + return; + } + + const fellowAddress = getSubstrateAddress(selectedAddress.address); + if (!fellowAddress) { + setError('Invalid address format. Please select a valid address.'); + return; + } + + // Validate fellow status + if (!fellows || fellows.length === 0) { + setError('Fellows data is not available. Please try again.'); + return; + } + + const fellow = fellows.find((f) => f.address === fellowAddress); + if (!fellow) { + setError('Selected address is not a fellow. Only fellows can create proposals.'); + return; + } + + // Validate form data using validation function + const formValidationError = validateFormData(data); + if (formValidationError) { + setError(formValidationError); + return; + } + + // Clear any previous errors + setError(''); + + setLoading(true); + + try { + // TODO: Implement actual proposal creation logic + // This would typically involve: + // 1. Creating a preimage + // 2. Submitting the proposal + // 3. Handling the transaction + + // For now, simulate a successful transaction + await new Promise((resolve) => setTimeout(resolve, 2000)); + + queueNotification({ + header: 'Proposal Created Successfully!', + message: `Your ${data.proposalType.toLowerCase()} proposal has been created.`, + status: 'success' + }); + + setLoading(false); + onSuccess?.(); + } catch (err) { + console.error('Error creating proposal:', err); + const errorMessage = err instanceof Error ? err.message : 'Failed to create proposal. Please try again.'; + setError(`Transaction failed: ${errorMessage}`); + setLoading(false); + setTxStatus(''); + } + }; + + return ( +
{ + e.preventDefault(); + if (!isFormValid) { + setError('Please ensure all required fields are filled and a valid wallet/address is selected.'); + return; + } + handleSubmit(submitForm)(e); + }} + className='flex flex-col gap-4' + > + + + {currentFellow && ( +
+
+ Current Rank: {currentFellow.rank} +
+
+ )} + +
+
+ Title* +
+ ( + + )} + /> + {errors.title?.message && ( + + {errors.title.message} + + )} +
+ +
+
+ Wish Type* +
+ ( + + )} + /> + {errors.proposalType?.message && ( + + {errors.proposalType.message} + + )} +
+ +
+
+ Motivation* +
+ ( +