diff --git a/client/package.json b/client/package.json index 9c10a57d..50c66fde 100644 --- a/client/package.json +++ b/client/package.json @@ -54,7 +54,7 @@ "react-copy-to-clipboard": "^5.1.0", "react-device-detect": "^2.2.3", "react-dom": "18.2.0", - "react-hook-form": "7.43.1", + "react-hook-form": "^7.43.1", "react-markdown": "^9.0.1", "react-number-format": "5.1.4", "react-router-dom": "6.8.1", diff --git a/client/public/fairai_logo_whitebg.png b/client/public/fairai_logo_whitebg.png new file mode 100644 index 00000000..9a5e3855 Binary files /dev/null and b/client/public/fairai_logo_whitebg.png differ diff --git a/client/public/logo_non_capitalized_black_transp.svg b/client/public/logo_non_capitalized_black_transp.svg new file mode 100644 index 00000000..241b81ed --- /dev/null +++ b/client/public/logo_non_capitalized_black_transp.svg @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/logo_non_capitalized_white_transp.svg b/client/public/logo_non_capitalized_white_transp.svg new file mode 100644 index 00000000..02bfcd56 --- /dev/null +++ b/client/public/logo_non_capitalized_white_transp.svg @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/src/components/layout.tsx b/client/src/components/layout.tsx index 3f079897..0e987c22 100644 --- a/client/src/components/layout.tsx +++ b/client/src/components/layout.tsx @@ -63,7 +63,7 @@ export default function Layout({ children }: { children: ReactElement }) { disableGutters sx={{ width: '100%', - height: !userScrolledDown && !isSmallScreen ? `calc(100% - ${headerHeight})` : '100%', + height: `calc(100% - ${headerHeight})`, top: userScrolledDown && isSmallScreen ? 0 : headerHeight, position: 'fixed', transition: 'all 0.2s', diff --git a/client/src/components/logo.tsx b/client/src/components/logo.tsx index 38076ccb..d2744082 100644 --- a/client/src/components/logo.tsx +++ b/client/src/components/logo.tsx @@ -21,7 +21,7 @@ import { Icon } from '@mui/material'; const Logo = () => { return ( - FairAI Logo + FairAI Logo ); }; diff --git a/client/src/components/make-request-banner.tsx b/client/src/components/make-request-banner.tsx new file mode 100644 index 00000000..b4b8b37f --- /dev/null +++ b/client/src/components/make-request-banner.tsx @@ -0,0 +1,55 @@ +import { StyledMuiButton } from '@/styles/components'; +import { useCallback } from 'react'; +import { useNavigate } from 'react-router'; +import InfoRounded from '@mui/icons-material/InfoRounded'; +import LibraryAddRoundedIcon from '@mui/icons-material/LibraryAddRounded'; + +const MakeRequestBanner = ({ smallScreen }: { smallScreen: boolean }) => { + const navigate = useNavigate(); + const openRequestsRoute = useCallback(() => navigate('/request'), [navigate]); + + return ( +
+
+ + + Are you looking for custom made, tailored solutions for your own projects? +
+ Create your request listing, define your budget and quickly get amazing solutions tailored + for you by the trusted FairAI community members. +
+ + + + Create a request + +
+
+ ); +}; + +export default MakeRequestBanner; \ No newline at end of file diff --git a/client/src/components/navbar.tsx b/client/src/components/navbar.tsx index e8f938c7..88b61e91 100644 --- a/client/src/components/navbar.tsx +++ b/client/src/components/navbar.tsx @@ -351,7 +351,7 @@ const Navbar = ({ userScrolledDown }: { userScrolledDown: boolean }) => {
FairAI Logo { `https://arweave.net/${throwawayData.data.transactions.edges[0].node.id}`, ); const encData = await result.text(); - const decData = await decrypt(encData as `0x${string}`); + const buf = Buffer.from( + encData, + 'utf8' + ); + const encryptedValue = '0x' + buf.toString('hex'); + const decData = await decrypt(encryptedValue as `0x${string}`); setPrivateKey(decData); await setIrys(decData as `0x${string}`); setThrowawayProvider(decData as `0x${string}`); diff --git a/client/src/hooks/useModels.tsx b/client/src/hooks/useModels.tsx deleted file mode 100644 index 060bf8c1..00000000 --- a/client/src/hooks/useModels.tsx +++ /dev/null @@ -1,136 +0,0 @@ -/* - * Fair Protocol, open source decentralised inference marketplace for artificial intelligence. - * Copyright (C) 2023 Fair Protocol - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ - -import { TAG_NAMES } from '@/constants'; -import FilterContext from '@/context/filter'; -import { IContractEdge } from '@/interfaces/arweave'; -import { commonUpdateQuery, findTagsWithKeyword, findTag } from '@/utils/common'; -import { useQuery, NetworkStatus } from '@apollo/client'; -import { RefObject, useState, useContext, useEffect, useMemo } from 'react'; -import useOnScreen from './useOnScreen'; -import FairSDKWeb from '@fair-protocol/sdk/web'; -import _ from 'lodash'; -import Stamps, { CountResult } from '@permaweb/stampjs'; -import { WarpFactory } from 'warp-contracts'; -import Arweave from 'arweave'; - -const useModels = (target?: RefObject, featuredElements?: number) => { - const [hasNextPage, setHasNextPage] = useState(false); - const [txs, setTxs] = useState([]); - const [featuredTxs, setFeaturedTxs] = useState([]); - const [validTxs, setValidTxs] = useState([]); - const [filtering, setFiltering] = useState(false); - const [txsCountsMap, setTxsCountsMap] = useState>(new Map()); - - const filterValue = useContext(FilterContext); - const isOnScreen = useOnScreen(target); - - const elementsPerPage = 5; - const defaultFeaturedElements = 3; - const queryObject = FairSDKWeb.utils.getModelsQuery(elementsPerPage); - const { data, loading, error, fetchMore, networkStatus } = useQuery(queryObject.query, { - variables: queryObject.variables, - }); - - const loadingOrFiltering = useMemo(() => filtering || loading, [filtering, loading]); - - const transformCountsToObjectMap = (counts: CountResult[]): Map => - new Map(Object.entries(counts)); - - const totalStamps = async (targetTxs: (string | undefined)[]) => { - try { - const filteredTxsIds = targetTxs.filter((txId) => txId !== undefined) as string[]; - const stampsInstance = Stamps.init({ - warp: WarpFactory.forMainnet(), - arweave: Arweave.init({}), - wallet: window.arweaveWallet, - dre: 'https://dre-u.warp.cc/contract', - graphql: 'https://arweave.net/graphql', - }); - const counts = await stampsInstance.counts(filteredTxsIds); - - return transformCountsToObjectMap(counts); - } catch (errorObj) { - return new Map(); - } - }; - - useEffect(() => { - if (data && networkStatus === NetworkStatus.ready) { - (async () => { - setFiltering(true); - const filtered = await FairSDKWeb.utils.modelsFilter(data.transactions.edges); - const targetTxs = filtered.map((el) => findTag(el, 'modelTransaction')); - const mapTxsCountStamps = await totalStamps(targetTxs); - setTxsCountsMap(mapTxsCountStamps); - setHasNextPage(data.transactions.pageInfo.hasNextPage); - setTxs(filtered); - setValidTxs(filtered); - const newFilteredTxs = filtered.slice(0, featuredElements ?? defaultFeaturedElements); - if (!_.isEqual(newFilteredTxs, featuredTxs)) { - setFeaturedTxs(newFilteredTxs); - } - setFiltering(false); - })(); - } - }, [data]); - - useEffect(() => { - if (data) { - setFiltering(true); - const filtered: IContractEdge[] = validTxs.filter( - (el: IContractEdge) => - filterValue.trim() === '' || - findTagsWithKeyword( - el, - [TAG_NAMES.modelName, TAG_NAMES.description, TAG_NAMES.modelCategory], - filterValue, - ), - ); - setTxs(filtered); - setFiltering(false); - } - }, [filterValue]); - - useEffect(() => { - if (isOnScreen && hasNextPage) { - (async () => { - await fetchMore({ - variables: { - after: - data.transactions.edges.length > 0 - ? data.transactions.edges[data.transactions.edges.length - 1].cursor - : undefined, - }, - updateQuery: commonUpdateQuery, - }); - })(); - } - }, [isOnScreen, data, hasNextPage]); - - return { - loading: loadingOrFiltering, - txs, - txsCountsMap, - isOnScreen, - featuredTxs, - error, - }; -}; - -export default useModels; diff --git a/client/src/interfaces/common.ts b/client/src/interfaces/common.ts index 2752a6d2..03e152cf 100644 --- a/client/src/interfaces/common.ts +++ b/client/src/interfaces/common.ts @@ -98,3 +98,14 @@ export interface OperatorData { operatorFee: number; solutionId: string; } + +export interface IRequestSolution { + title: string; + description: string; + keywords: string[]; + needsDb: string; + needsApp: string; + budget: string; + paymentPlan: string; + targetUnixTimestamp: number; +} diff --git a/client/src/pages/browse-requests.tsx b/client/src/pages/browse-requests.tsx index a7e559a0..2977ebc3 100644 --- a/client/src/pages/browse-requests.tsx +++ b/client/src/pages/browse-requests.tsx @@ -16,7 +16,12 @@ * along with this program. If not, see http://www.gnu.org/licenses/. */ -import DebounceIconButton from '@/components/debounce-icon-button'; +import { + databaseConfigType, + needsAppConfig, + paymentPlanBrowseTag, + paymentPlanType, +} from '@/utils/requestsPipeFunctions'; import { PROTOCOL_NAME, PROTOCOL_VERSION, TAG_NAMES } from '@/constants'; import { gql, useQuery } from '@apollo/client'; import Close from '@mui/icons-material/Close'; @@ -34,16 +39,38 @@ import { TextField, Fab, Tooltip, + useTheme, + FormControl, + MenuItem, + CircularProgress, + Switch, } from '@mui/material'; import Grid from '@mui/material/Unstable_Grid2'; import SendIcon from '@mui/icons-material/Send'; -import { ChangeEvent, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { EVMWalletContext } from '@/context/evm-wallet'; import { postOnArweave } from '@fairai/evm-sdk'; import { motion } from 'framer-motion'; import { StyledMuiButton } from '@/styles/components'; import ArrowBackIosNewRoundedIcon from '@mui/icons-material/ArrowBackIosNewRounded'; import useScroll from '@/hooks/useScroll'; +import useWindowDimensions from '@/hooks/useWindowDimensions'; +import { + ChatBubbleRounded, + CloseRounded, + InfoRounded, + ReplyRounded, + StarRounded, +} from '@mui/icons-material'; +import { NumericFormat } from 'react-number-format'; +import { DateField, LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterMoment } from '@mui/x-date-pickers/AdapterMoment'; +import { IRequestSolution } from '@/interfaces/common'; +import dayjs from 'dayjs'; +import MakeRequestBanner from '@/components/make-request-banner'; +import { ITag } from '@/interfaces/arweave'; +import { Controller, useForm } from 'react-hook-form'; +import moment from 'moment'; interface IrysTx { id: string; @@ -54,13 +81,32 @@ interface IrysTx { address: string; } -interface RequestData { - title: string; - description: string; - keywords: string[]; +interface RequestData extends IRequestSolution { + id: string; + owner: string; + timestamp: string; +} + +interface FormCommentInputs { + budget: string; + paymentPlan: string; + dateTarget: string; + websiteUrl: string; + twitterHandle: string; + linkedinHandle: string; + commentText: string; +} + +interface Comment { id: string; owner: string; timestamp: string; + content: string; + commentType: 'text' | 'suggestion'; + showReplyInput?: boolean; + replies?: Comment[]; + tags: ITag[]; + suggestionFields?: FormCommentInputs; } const irysQuery = gql` @@ -84,41 +130,402 @@ const irysQuery = gql` } `; -interface Comment { - owner: string; - timestamp: string; - content: string; -} - -const CommentElement = ({ comment, request }: { comment: Comment; request: RequestData }) => { +const CommentElement = ({ + comment, + request, + refetchComments, + isReply, + replyToUserAddress, + commentType, + replyChainMainParentId, +}: { + comment: Comment; + request: RequestData; + isReply: boolean; + replyToUserAddress: string; + replyChainMainParentId: string; + commentType: 'text' | 'suggestion'; + refetchComments: () => void; +}) => { const handleAddressClick = useCallback(() => { window.open(`https://arbiscan.io/address/${comment.owner}`, '_blank'); }, [comment]); + const { currentAddress } = useContext(EVMWalletContext); + const [changedComment, setChangedComment] = useState(comment); + const [replyingToCommentId, setReplyingToCommentId] = useState(''); + const [replyingToUserAddress, setReplyingToUserAddress] = useState(''); + const [isSuggestion, setIsSuggestion] = useState(false); + + // declare comment form + const { + register, + handleSubmit, + reset: resetForm, + formState, + getValues, + control, + trigger, + } = useForm(); + + const handleShowSuggestionInputsReply = () => { + setIsSuggestion(!isSuggestion); + }; + + const checkIfAtLeastOneFieldIsFilled = (): boolean => { + const fieldValues = Object.values(getValues()).filter( + (value) => value && value !== getValues().commentText, + ); + if (fieldValues.length === 0) { + return false; + } else return true; + }; + + useEffect(() => { + trigger(); + }, [isSuggestion]); + + const handleSetShowAddReply = ( + commentToChange: Comment, + replyToCommentId: string, + replyToCommentOwnerAddress: string, + ) => { + if (commentToChange.showReplyInput) { + // reply is open, we are closing it now + resetForm(); // clear the reply input + setReplyingToCommentId(''); + setReplyingToUserAddress(''); + } else { + // reply is closed, initiate new reply + setReplyingToCommentId(replyToCommentId); + setReplyingToUserAddress(replyToCommentOwnerAddress); + } + + commentToChange.showReplyInput = !commentToChange.showReplyInput; + setChangedComment((prev) => ({ ...prev, ...commentToChange })); + }; + + const handlePostReplyToComment = async (commentToChange: Comment) => { + if (formState.isValid) { + if (isSuggestion && !checkIfAtLeastOneFieldIsFilled()) { + setIsSuggestion(false); + } + + let tags = [ + { name: TAG_NAMES.protocolName, value: PROTOCOL_NAME }, + { name: TAG_NAMES.protocolVersion, value: PROTOCOL_VERSION }, + { name: TAG_NAMES.operationName, value: 'Comment' }, + { name: 'Comment-For', value: request.id }, + { name: 'Replying-To-Comment-Id', value: replyingToCommentId }, + { name: 'Replying-To-User-Address', value: replyingToUserAddress }, + { name: 'Reply-Chain-Main-Parent-Id', value: replyChainMainParentId }, + { name: 'Comment-Type', value: isSuggestion ? 'suggestion' : 'text' }, + { name: TAG_NAMES.unixTime, value: (Date.now() / 1000).toString() }, + ]; + + if (isSuggestion) { + // declare tags of extra suggestion fields + tags = tags.concat([ + { + name: 'Suggestion-Budget', + value: getValues().budget, + }, + { name: 'Suggestion-PaymentPlan', value: getValues().paymentPlan }, + { + name: 'Suggestion-DateTargetISO', + value: getValues().dateTarget ? moment(getValues().dateTarget)?.toISOString() : '', + }, + { name: 'Suggestion-Website', value: getValues().websiteUrl }, + { name: 'Suggestion-TwitterXHandle', value: getValues().twitterHandle }, + { name: 'Suggestion-LinkedInHandle', value: getValues().linkedinHandle }, + ]); + } + + // submit data to blockchain + await postOnArweave(getValues().commentText, tags); + + resetForm(); + handleSetShowAddReply(commentToChange, '', ''); + + refetchComments(); + } + }; + return ( -
- -
- {' '} - - {'Commented by '} - - - {comment.owner.slice(0, 6)}...{comment.owner.slice(-4)} - - - {` on ${new Date(Number(comment.timestamp) * 1000).toLocaleString()}`} - - {comment.owner === request.owner && ( - -
- Request Creator +
+ {isReply &&
} + +
+
+
+ {commentType === 'text' && ( + + )} + + {commentType === 'suggestion' && } + + + {!isReply ? 'Comment by ' : 'Reply by '} + + + {changedComment.owner.slice(0, 6)}...{changedComment.owner.slice(-4)} + + + {isReply && replyToUserAddress && ( + <> + {' to '} + + + {replyToUserAddress.slice(0, 6)}...{replyToUserAddress.slice(-4)} + + + + )} + {` on ${new Date(Number(changedComment.timestamp) * 1000).toLocaleString()}`} + + {changedComment.owner === request.owner && ( + +
+ Request Creator +
+
+ )} + {commentType === 'suggestion' && ( +
+ Suggestion +
+ )} +
+ + {commentType === 'suggestion' && ( +
+
+
+ Budget suggestion: {' '} + {changedComment.suggestionFields?.budget ?? '(Not Provided)'} +
+
+ Date target: {' '} + {changedComment.suggestionFields?.dateTarget ?? '(Not Provided)'} +
+
+ Payment / Deliveries: {' '} + {paymentPlanBrowseTag(changedComment.suggestionFields?.paymentPlan ?? '') ?? + '(Not Provided)'} +
+
+
+ {changedComment.suggestionFields?.twitterHandle && ( + + )} + {changedComment.suggestionFields?.linkedinHandle && ( + + )} + {changedComment.suggestionFields?.websiteUrl && ( + + )} +
+
+ )} + +
{changedComment.content}
+ + {currentAddress && !changedComment?.showReplyInput && ( +
+ + handleSetShowAddReply(changedComment, changedComment.id, changedComment.owner) + } + > + Reply + +
+ )} + + {currentAddress && changedComment?.showReplyInput && ( + <> +
+
+ Replying to + + {replyingToUserAddress.slice(0, 6)}...{replyingToUserAddress.slice(-4)} + + : +
+ +
+ handleShowSuggestionInputsReply()} + /> + Suggest budgets +
+
+ + {isSuggestion && ( + <> +
+ + Suggesting new budgets and/or + targets + +

+ Fill any field as needed. All fields are optional.
+ These will be added to your comment and will be publicly visible. +

+
+
+ Budgets and date targets suggestion + +
+ ( + field.onChange(budgetText)} + > + )} + /> + + + + Daily deliveries and payments + Weekly deliveries and payments + Monthly deliveries and payments + Yearly deliveries and payments + + All at once, right at the start + + All at once, when project ends + + + + ( + + field.onChange(date)} + /> + + )} + /> +
+ + Ways to contact you + +
+
+ + @ }} + {...register('twitterHandle', { maxLength: 50 })} + /> + @ }} + {...register('linkedinHandle', { maxLength: 50 })} + /> +
+
+ + Your comment +
+ + )} + +
+ + handleSetShowAddReply(changedComment, changedComment.id, changedComment.owner) + } + > + + + + handlePostReplyToComment(changedComment))} + className='primary plausible-event-name=Request+Reply+Post+Click' + disabled={!formState.isValid} + > + Send +
- + )}
-
{comment.content}
- +
); }; @@ -126,13 +533,59 @@ const CommentElement = ({ comment, request }: { comment: Comment; request: Reque const RequestElement = ({ request }: { request: RequestData }) => { const [open, setOpen] = useState(false); const [comments, setComments] = useState([]); - const [newComment, setNewComment] = useState(''); + const [commentsLoadingAnim, setCommentsLoadingAnim] = useState(false); + const [commentsAmountTotal, setCommentsAmountTotal] = useState(0); + const [showAddComment, setShowAddComment] = useState(false); const { currentAddress } = useContext(EVMWalletContext); const handleOpen = useCallback(() => setOpen(true), [setOpen]); const handleClose = useCallback(() => setOpen(false), [setOpen]); - const { data: commentsData } = useQuery(irysQuery, { + const handleShowNewComment = () => { + setShowAddComment(!showAddComment); + + // reset this if not showing new comment + if (!showAddComment) { + setIsSuggestion(false); + resetForm(); + } + }; + + const [isSuggestion, setIsSuggestion] = useState(false); + + // declare comment form + const { + register, + handleSubmit, + reset: resetForm, + formState, + getValues, + control, + trigger, + } = useForm(); + + const handleShowSuggestionInputsReply = () => { + setIsSuggestion(!isSuggestion); + }; + + const checkIfAtLeastOneFieldIsFilled = (): boolean => { + const fieldValues = Object.values(getValues()).filter( + (value) => value && value !== getValues().commentText, + ); + if (fieldValues.length === 0) { + return false; + } else return true; + }; + + useEffect(() => { + trigger(); + }, [isSuggestion]); + + const { + data: commentsData, + refetch, + loading: loadingQuery, + } = useQuery(irysQuery, { variables: { tags: [ { name: TAG_NAMES.protocolName, values: [PROTOCOL_NAME] }, @@ -145,53 +598,162 @@ const RequestElement = ({ request }: { request: RequestData }) => { context: { clientName: 'irys', }, + notifyOnNetworkStatusChange: true, }); - const handleCommentChange = useCallback( - (e: ChangeEvent) => setNewComment(e.target.value), - [setNewComment], - ); + const handlePostNewComment = async () => { + if (formState.isValid) { + if (isSuggestion && !checkIfAtLeastOneFieldIsFilled()) { + setIsSuggestion(false); + } - const handleNewComment = useCallback(async () => { - const comment = { - owner: currentAddress, - timestamp: (Date.now() / 1000).toString(), - content: newComment, - }; + let tags = [ + { name: TAG_NAMES.protocolName, value: PROTOCOL_NAME }, + { name: TAG_NAMES.protocolVersion, value: PROTOCOL_VERSION }, + { name: TAG_NAMES.operationName, value: 'Comment' }, + { name: 'Comment-For', value: request.id }, + { name: 'Comment-Type', value: isSuggestion ? 'suggestion' : 'text' }, + { name: TAG_NAMES.unixTime, value: (Date.now() / 1000).toString() }, + ]; + + if (isSuggestion) { + // declare tags of extra suggestion fields + tags = tags.concat([ + { + name: 'Suggestion-Budget', + value: getValues().budget, + }, + { name: 'Suggestion-PaymentPlan', value: getValues().paymentPlan }, + { + name: 'Suggestion-DateTargetISO', + value: getValues().dateTarget ? moment(getValues().dateTarget)?.toISOString() : '', + }, + { name: 'Suggestion-Website', value: getValues().websiteUrl }, + { name: 'Suggestion-TwitterXHandle', value: getValues().twitterHandle }, + { name: 'Suggestion-LinkedInHandle', value: getValues().linkedinHandle }, + ]); + } - const tags = [ - { name: TAG_NAMES.protocolName, value: PROTOCOL_NAME }, - { name: TAG_NAMES.protocolVersion, value: PROTOCOL_VERSION }, - { name: TAG_NAMES.operationName, value: 'Comment' }, - { name: 'Comment-For', value: request.id }, - { name: TAG_NAMES.unixTime, value: (Date.now() / 1000).toString() }, - ]; + // submit data to blockchain + await postOnArweave(getValues().commentText, tags); - await postOnArweave(newComment, tags); + resetForm(); + handleShowNewComment(); - setComments((prev) => [...prev, comment]); - setNewComment(''); - }, [request, newComment, currentAddress, setComments, setNewComment]); + refetch(); + } + }; useEffect(() => { if (commentsData && commentsData.transactions.edges) { (async () => { - const allComments = []; + const allComments: Comment[] = []; + setCommentsLoadingAnim(true); for (const tx of commentsData.transactions.edges) { const res = await fetch(`https://arweave.net/${tx.node.id}`); const data = await res.text(); allComments.push({ + id: tx.node.id, owner: tx.node.address, + commentType: + tx.node.tags.find( + (tag: { name: string; value: string }) => tag.name === 'Comment-Type', + )?.value ?? 'text', timestamp: tx.node.tags.find( (tag: { name: string; value: string }) => tag.name === TAG_NAMES.unixTime, )?.value ?? '', content: data, + tags: tx.node.tags, }); } - setComments(allComments); + const allCommentsWithReplies: Comment[] = []; + + // check and organize comment replies + allComments.forEach((comment: Comment) => { + if (allCommentsWithReplies.find((parent) => parent.id === comment.id)) { + // if this comment is already added, skip it + return; + } + // lets use the main parent Id for now ... (only 1 level of replies) + const foundId = comment.tags.find( + (tag) => tag.name === 'Reply-Chain-Main-Parent-Id', + )?.value; + + // set the comment type by tag + comment.commentType = + comment.tags.find((tag) => tag.name === 'Comment-Type')?.value === 'suggestion' + ? 'suggestion' + : 'text'; + + if (comment.commentType === 'suggestion') { + // set additional tag info + comment.suggestionFields = { + budget: comment.tags.find((tag) => tag.name === 'Suggestion-Budget')?.value ?? '', + paymentPlan: + comment.tags.find((tag) => tag.name === 'Suggestion-PaymentPlan')?.value ?? '', + dateTarget: + comment.tags.find((tag) => tag.name === 'Suggestion-DateTargetISO')?.value ?? '', + websiteUrl: + comment.tags.find((tag) => tag.name === 'Suggestion-Website')?.value ?? '', + twitterHandle: + comment.tags.find((tag) => tag.name === 'Suggestion-TwitterXHandle')?.value ?? '', + linkedinHandle: + comment.tags.find((tag) => tag.name === 'Suggestion-LinkedInHandle')?.value ?? '', + commentText: '', // not used here + }; + + if (comment.suggestionFields.dateTarget) { + comment.suggestionFields.dateTarget = moment(comment.suggestionFields.dateTarget) + .toDate() + .toDateString(); + } + } + + if (foundId) { + // its a reply + const foundParentIndex = allCommentsWithReplies.findIndex( + (parent) => parent.id === foundId, + ); + if (foundParentIndex >= 0) { + if (!allCommentsWithReplies[foundParentIndex]?.replies) { + allCommentsWithReplies[foundParentIndex].replies = []; + } + + allCommentsWithReplies[foundParentIndex].replies.push(comment); + } else { + const foundParentInAllComments = allComments.find( + (allComment) => allComment.id === foundId, + ); + if (foundParentInAllComments) { + allCommentsWithReplies.push({ + ...foundParentInAllComments, + ...{ + replies: [comment], + }, + }); + } + } + } else { + // if its reply and no parent was found, show it as a parent anyway + allCommentsWithReplies.push(comment); + } + }); + + const totalComments = allCommentsWithReplies.reduce((acc: number, comment: Comment) => { + if (comment.replies) { + acc += comment.replies.length + 1; + } else { + acc += 1; + } + return acc; + }, 0); + + setCommentsAmountTotal(totalComments); + setComments(allCommentsWithReplies); + setCommentsLoadingAnim(false); })(); } }, [commentsData, setComments]); @@ -233,7 +795,7 @@ const RequestElement = ({ request }: { request: RequestData }) => { animate={{ opacity: 1, y: 0 }} className='w-full' > - + @@ -263,8 +825,14 @@ const RequestElement = ({ request }: { request: RequestData }) => {
- - + + {request.description} { label={keyword} variant='filled' color='primary' - className='font-bold saturate-50' + className='font-bold saturate-50 brightness-105' /> ))} +
+ + Request details + +
+
+ Planned budget: + {request.budget} +
+
+ Payment / Deliveries: + {paymentPlanType(request.paymentPlan)} +
+
+ Date target: + {dayjs(request.targetUnixTimestamp * 1000).format('MM/YYYY')} +
+
+ Application Development: + {needsAppConfig(request.needsApp)} +
+
+ Database: + {databaseConfigType(request.needsDb)} +
+
+
+
- {comments.length} {comments.length === 1 ? ' comment' : 'comments'} + {commentsAmountTotal} {commentsAmountTotal === 1 ? ' comment' : 'comments'}
- {comments.length === 0 && ( + {commentsAmountTotal === 0 && ( {'No comments yet.'} )} - {comments.map((comment) => ( - - ))} + + {(loadingQuery || commentsLoadingAnim) && ( +
+ + Loading comments ... +
+ )} + + {!loadingQuery && !commentsLoadingAnim && ( + <> + {comments.map((comment) => ( +
+ + + {comment?.replies?.length && ( + <> + {comment.replies.map((reply) => ( +
+ tag.name === 'Replying-To-User-Address', + )?.value ?? '' + } + /> +
+ ))} + + )} +
+ ))} + + )}
@@ -315,38 +952,167 @@ const RequestElement = ({ request }: { request: RequestData }) => { animate={{ opacity: 1, y: 0, transition: { delay: 0.1 } }} >
- + + + + Add a comment + + +
+ )} + + {showAddComment && ( + <> +
+
+ {isSuggestion ? '' : 'Type your comment'} +
+ +
+ handleShowSuggestionInputsReply()} + /> + Suggest budgets +
+
+ + {isSuggestion && ( + <> +
+ + Suggesting new + budgets and/or targets + +

+ Fill any field as needed. All fields are optional.
+ These will be added to your comment and will be publicly visible. +

+
+
+ Budgets and date targets suggestion + +
+ ( + field.onChange(budgetText)} + > + )} + /> + + + + + Daily deliveries and payments + + + Weekly deliveries and payments + + + Monthly deliveries and payments + + + Yearly deliveries and payments + + + All at once, right at the start + + + All at once, when project ends + + + + + ( + + field.onChange(date)} + /> + + )} + /> +
+ + Ways to contact you + +
+
+ + @ }} + {...register('twitterHandle', { maxLength: 50 })} + /> + @ }} + {...register('linkedinHandle', { maxLength: 50 })} + /> +
+
+ + Your comment +
+ + )} + +
+ - - - ), - }} - /> + + + + handlePostNewComment())} + className='primary plausible-event-name=Request+Comment+Click' + disabled={!formState.isValid} + > + Send + +
+ + )}
)} @@ -363,7 +1129,7 @@ const RequestElement = ({ request }: { request: RequestData }) => { className='flex flex-col gap-1' style={{ fontWeight: 600, padding: '10px 15px', fontSize: '18px', marginTop: '5px' }} > - {request.title} + {request.title.substring(0, 200) + (request.title.length > 200 ? ' (...)' : '')} {`${new Date(Number(request.timestamp) * 1000).toLocaleString()} by `} @@ -379,10 +1145,13 @@ const RequestElement = ({ request }: { request: RequestData }) => { sx={{ WebkitLineClamp: 3, paddingBottom: '20px', + textWrap: 'wrap', + wordBreak: 'break-word', fontWeight: 400, }} > - {request.description} + {request.description.substring(0, 500) + + (request.description.length > 500 ? ' (...)' : '')} { gap={'5px'} flexWrap={'wrap'} > - - {comments.length} {comments.length === 1 ? ' comment' : ' comments'} - +
+ + {commentsLoadingAnim && } + + } + label={`${commentsAmountTotal} ${ + commentsAmountTotal === 1 ? ' comment' : ' comments' + }`} + color='secondary' + /> + +
{request.keywords.map((keyword) => ( ))}
@@ -418,6 +1206,14 @@ const BrowseRequests = () => { const ref = useRef(null); const bottomRef = useRef(null); const { isAtBottom, isScrolled } = useScroll(ref); + const theme = useTheme(); + const { width } = useWindowDimensions(); + const [isSmallScreen, setIsSmallScreen] = useState(false); + + useEffect(() => { + const md = theme.breakpoints.values.md; + setIsSmallScreen(width < md); + }, [width, theme, setIsSmallScreen]); const { data, loading } = useQuery(irysQuery, { variables: { @@ -431,6 +1227,7 @@ const BrowseRequests = () => { context: { clientName: 'irys', }, + notifyOnNetworkStatusChange: true, }); const isLoading = useMemo(() => loading || filtering, [loading, filtering]); @@ -479,17 +1276,19 @@ const BrowseRequests = () => { }, []); // run only on first load return ( - <> -
- - +
-
+
+ + + + Back + +
{ /> Current Feature Requests
- - - - Go Back - - +
@@ -514,6 +1308,8 @@ const BrowseRequests = () => { )} + {!isLoading && } + {requests.length === 0 && !isLoading && ( @@ -541,7 +1337,7 @@ const BrowseRequests = () => { )} - +
{ - +
); }; diff --git a/client/src/pages/home.tsx b/client/src/pages/home.tsx index d02e5e0e..fb910a71 100644 --- a/client/src/pages/home.tsx +++ b/client/src/pages/home.tsx @@ -20,6 +20,7 @@ import { Box, Container, Grid, InputBase, Typography, useTheme } from '@mui/mate import '@/styles/ui.css'; import useSolutions from '@/hooks/useSolutions'; import LoadingCard from '@/components/loading-card'; +import MakeRequestBanner from '@/components/make-request-banner'; import { ChangeEvent, RefObject, @@ -260,10 +261,10 @@ export default function Home() { maxWidth: '100%', }, mt: '40px', - paddingBottom: '86px', + paddingBottom: '20px', }} > -
+
Choose a solution and start creating @@ -343,6 +344,12 @@ export default function Home() { )} + {!error && !loading && ( +
+ +
+ )} + {!error && !loading && ( (); const Keyword = ({ currentKeyword, @@ -35,11 +86,7 @@ const Keyword = ({ }: { currentKeyword: string; keywords: string[]; - setValue: UseFormSetValue<{ - title: string; - description: string; - keywords: string[]; - }>; + setValue: UseFormSetValue; }) => { const onDelete = useCallback(() => { setValue( @@ -48,25 +95,36 @@ const Keyword = ({ ); }, [currentKeyword, keywords, setValue]); - return ; + return ( + + ); }; const RequestSolution = () => { const [requestSuccessful, setRequestSuccessfull] = useState(false); - const [newKeyword, setNewKeyword] = useState(''); + const [dateTarge, setDateTarget] = useState(null); + const [autocompleteInputValue, setAutomcompleteInputValue] = useState(''); const navigate = useNavigate(); const { enqueueSnackbar } = useSnackbar(); const handleBack = useCallback(() => navigate(-1), [navigate]); - const { control, formState, setValue, handleSubmit } = useForm<{ - title: string; - description: string; - keywords: string[]; - }>({ + const { control, formState, setValue, handleSubmit } = useForm({ defaultValues: { title: '', description: '', keywords: [], + needsDb: '', + needsApp: '', + budget: '', + paymentPlan: '', + targetUnixTimestamp: 0, }, }); const { field: title } = useController({ control, name: 'title', rules: { required: true } }); @@ -75,15 +133,36 @@ const RequestSolution = () => { name: 'description', rules: { required: true }, }); + const { field: needsDb } = useController({ control, name: 'needsDb', rules: { required: true } }); + const { field: needsApp } = useController({ + control, + name: 'needsApp', + rules: { required: true }, + }); + const { field: budget } = useController({ control, name: 'budget', rules: { required: true } }); + const { field: paymentPlan } = useController({ + control, + name: 'paymentPlan', + rules: { required: true }, + }); const keywords = useWatch({ control, name: 'keywords' }); - const handleClick = async (data: { title: string; description: string; keywords: string[] }) => { + const handleClick = async (data: IRequestSolution) => { try { const tags = [ - { name: TAG_NAMES.protocolName, value: PROTOCOL_NAME }, - { name: TAG_NAMES.protocolVersion, value: PROTOCOL_VERSION }, + /* { name: TAG_NAMES.protocolName, value: PROTOCOL_NAME }, + { name: TAG_NAMES.protocolVersion, value: PROTOCOL_VERSION }, */ + { name: TAG_NAMES.protocolName, values: ['FairAI-test'] }, + { name: TAG_NAMES.protocolVersion, values: ['test'] }, { name: TAG_NAMES.operationName, value: 'Request-Solution' }, + { name: 'Request-Title', value: data.title }, + { name: 'Request-Description', value: data.description }, + { name: 'needsDb', value: data.needsDb }, + { name: 'needsApp', value: data.needsApp }, + { name: 'budget', value: data.budget.toString() }, + { name: 'paymentPlan', value: data.paymentPlan }, + { name: 'targetUnixTimestamp', value: data.targetUnixTimestamp.toString() }, { name: TAG_NAMES.unixTime, value: (Date.now() / 1000).toString() }, ]; @@ -102,22 +181,51 @@ const RequestSolution = () => { } }; - const keyDownHandler = useCallback( - (event: React.KeyboardEvent) => { - if (event.code === 'Enter') { - event.preventDefault(); - setValue('keywords', [...keywords, newKeyword]); - setNewKeyword(''); + const handleNewKeywordAutoCompleteChanged = useCallback( + (_: SyntheticEvent, newValue: string | null) => { + if (newValue && newValue.includes('Add')) { + setValue('keywords', [...keywords, newValue.split('Add')[1].trim()]); + setAutomcompleteInputValue(''); + } else if (newValue) { + setValue('keywords', [...keywords, newValue]); + setAutomcompleteInputValue(''); } }, - [keywords, newKeyword, setValue, setNewKeyword], + [keywords, setValue], ); - const handleNewKeywordChanged = useCallback( - (event: React.ChangeEvent) => { - setNewKeyword(event.target.value); + const handleAutocompleteInputChanged = useCallback( + (_: SyntheticEvent, newValue: string | null) => { + if (newValue && newValue.includes('Add')) { + setAutomcompleteInputValue(''); + } else { + setAutomcompleteInputValue(newValue || ''); + } + }, + [setAutomcompleteInputValue], + ); + + const handleTargetDataChange = useCallback( + (newValue: Dayjs | null) => { + if (newValue) { + setValue('targetUnixTimestamp', newValue.unix()); + setDateTarget(newValue); + } + }, + [setValue, setDateTarget], + ); + + const autocompleteFilterOptions = useCallback( + (options: string[], state: FilterOptionsState) => { + const filtered = filter(options, state); + + if (state.inputValue !== '' && !keywords.includes(state.inputValue)) { + filtered.push(`Add "${state.inputValue}"`); + } + + return filtered.filter((option) => !keywords.includes(option)); }, - [setNewKeyword], + [filter, keywords], ); if (requestSuccessful) { @@ -131,7 +239,7 @@ const RequestSolution = () => { alignItems={'center'} overflow={'auto'} > - Your Request Has been registered. + Your Request Has Been Registered. Thank you for your collaboration.