diff --git a/app/bounty/create/page.tsx b/app/bounty/create/page.tsx index f541f2d07..257959b91 100644 --- a/app/bounty/create/page.tsx +++ b/app/bounty/create/page.tsx @@ -1,14 +1,11 @@ 'use client'; -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import { useRouter } from 'next/navigation'; import { PageLayout } from '@/app/layouts/PageLayout'; import { ResearchCoinRightSidebar } from '@/components/ResearchCoin/ResearchCoinRightSidebar'; import { Button } from '@/components/ui/Button'; -import { Input } from '@/components/ui/form/Input'; import { Search } from '@/components/Search/Search'; -import { WorkSuggestion } from '@/types/search'; -import { CommentEditor } from '@/components/Comment/CommentEditor'; import { JSONContent } from '@tiptap/core'; import { SessionProvider, useSession } from 'next-auth/react'; import { HubsSelector, Hub } from '@/app/paper/create/components/HubsSelector'; @@ -18,869 +15,108 @@ import { useExchangeRate } from '@/contexts/ExchangeRateContext'; import { CommentService } from '@/services/comment.service'; import { PostService } from '@/services/post.service'; import { toast } from 'react-hot-toast'; -import { CommentContent } from '@/components/Comment/lib/types'; import { BalanceInfo } from '@/components/modals/BalanceInfo'; import { useUser } from '@/contexts/UserContext'; import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext'; -import { Progress } from '@/components/ui/Progress'; -import { - Star, - MessageCircleQuestion, - Loader2, - ArrowLeft, - ListCheck, - FileText, - DollarSign, - BookOpen, - HelpCircle, - ChevronDown, -} from 'lucide-react'; +import { ArrowLeft, Star, MessageCircleQuestion, Loader2 } from 'lucide-react'; import { PaperService } from '@/services/paper.service'; import { buildWorkUrl } from '@/utils/url'; -import { Alert } from '@/components/ui/Alert'; -import { Tooltip } from '@/components/ui/Tooltip'; -import { Icon } from '@/components/ui/icons/Icon'; -import { ResearchCoinIcon } from '@/components/ui/icons/ResearchCoinIcon'; +import { CurrencyInput } from '@/components/ui/form/CurrencyInput'; +import { FeeBreakdown } from '@/components/Bounty/lib/FeeBreakdown'; +import { useAmountInput } from '@/hooks/useAmountInput'; +import { useCurrencyConversion } from '@/components/Bounty/lib/useCurrencyConversion'; +import { calculateBountyFees } from '@/components/Bounty/lib/bountyUtil'; import { extractUserMentions } from '@/components/Comment/lib/commentUtils'; import { removeCommentDraftById } from '@/components/Comment/lib/commentDraftStorage'; -// Wizard steps. -// We intentionally separate review-specific and answer-specific steps. -// Shared : TYPE -> AMOUNT (submit) -// Peer-Review branch : WORK -// Answer-to-Question branch : TITLE -> CONTENT -> TOPICS - -type WizardStep = - | 'TYPE' // what is this bounty for? - | 'WORK' // which work (peer review only) - | 'DETAILS' // title & description (answer only) - | 'DESCRIPTION' // review-only editor step - | 'AMOUNT'; +type WizardStep = 'TYPE' | 'WORK' | 'DETAILS' | 'AMOUNT'; export default function CreateBountyPage() { const router = useRouter(); const { exchangeRate, isLoading: isExchangeRateLoading } = useExchangeRate(); const { showUSD, toggleCurrency: toggleGlobalCurrency } = useCurrencyPreference(); const currency: Currency = showUSD ? 'USD' : 'RSC'; + const { convertToRSC, convertToUSD } = useCurrencyConversion(exchangeRate); - // Wizard state const [step, setStep] = useState('TYPE'); const [bountyType, setBountyType] = useState(null); - - // Peer-review specific - const [selectedPaper, setSelectedPaper] = useState(null); - // Function to clear the currently selected paper - const clearSelectedPaper = () => { - setSelectedPaper(null); - setPaperId(null); - setReviewContent(null); - }; - - // After fetching full paper details we will store its internal ID here const [paperId, setPaperId] = useState(null); - const [isFetchingPaper, setIsFetchingPaper] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); - - // Answer-to-question specific const [questionTitle, setQuestionTitle] = useState(''); - const [questionPlainText, setQuestionPlainText] = useState(''); - const [questionHtml, setQuestionHtml] = useState(''); const [selectedHubs, setSelectedHubs] = useState([]); - // Shared – amount / currency - const [inputAmount, setInputAmount] = useState(0); - const [amountError, setAmountError] = useState(); + const { + amount: inputAmount, + setAmount: setInputAmount, + error: amountError, + handleAmountChange, + getFormattedValue: getFormattedInputValue, + } = useAmountInput(); - // User balance const { user } = useUser(); const userBalance = user?.balance || 0; - // Review comment editor content (peer review branch) - const [reviewContent, setReviewContent] = useState(null); - - // Define the formatted bounty text as a TipTap JSON structure - const defaultBountyText = { - type: 'doc', - content: [ - { - type: 'paragraph', - content: [ - { - type: 'text', - text: "I am looking for a high-quality, rigorous, and constructive peer review of this manuscript. If your expertise aligns well with this research, please submit your review within the bounty's time limit.", - }, - ], - }, - ], - }; - - // Initialise amount once exchange rate loads (150 USD equivalent) - useEffect(() => { - if (!isExchangeRateLoading && exchangeRate > 0 && inputAmount === 0) { - if (showUSD) { - setInputAmount(150); // 150 USD - } else { - setInputAmount(Math.round(150 / exchangeRate)); // 150 USD worth in RSC - } - } - }, [exchangeRate, isExchangeRateLoading, inputAmount, showUSD]); - - /* ---------- Helpers ---------- */ - - const rscAmount = (() => { - if (currency === 'RSC') return inputAmount; - if (isExchangeRateLoading || exchangeRate === 0) return 0; - return inputAmount / exchangeRate; - })(); - - const validateAmount = (amount: number) => { - if (amount <= 0) return 'Please enter an amount'; - // Calculate net after 9% fee - const net = Math.round(amount * 0.91 * 100) / 100; - if (net < 10) return 'Minimum bounty amount is 10 RSC (after fees)'; - return undefined; - }; - - const handleAmountChange = (e: React.ChangeEvent) => { - const rawValue = e.target.value.replace(/[^0-9.]/g, ''); - const num = parseFloat(rawValue); - if (isNaN(num)) { - setInputAmount(0); - setAmountError('Please enter a valid number'); - return; - } - setInputAmount(num); - const err = validateAmount(currency === 'RSC' ? num : num / exchangeRate); - setAmountError(err); - }; + const rscAmount = currency === 'RSC' ? inputAmount : convertToRSC(inputAmount); + const { platformFee, totalAmount } = calculateBountyFees(rscAmount); const toggleCurrency = () => { - if (isExchangeRateLoading && !showUSD) { - toast.error('Exchange rate is loading. Please wait before switching to USD.'); - return; - } - - // Convert the current input amount to the other currency - if (showUSD) { - // Switching from USD to RSC - setInputAmount(Number((inputAmount / exchangeRate).toFixed(2))); - } else { - // Switching from RSC to USD - setInputAmount(Number((inputAmount * exchangeRate).toFixed(2))); - } - - // Toggle the global preference + if (isExchangeRateLoading) return; + setInputAmount(showUSD ? convertToRSC(inputAmount) : convertToUSD(inputAmount)); toggleGlobalCurrency(); }; - /* ---------- Fees ---------- */ - const platformFee = Math.floor(rscAmount * 0.09); - const netBountyAmount = rscAmount - platformFee; - const totalAmount = rscAmount + platformFee; - - const FeeBreakdown = () => ( -
-
- Your contribution: - {rscAmount.toLocaleString()} RSC -
- -
-
-
- Platform fees (9%) - -
- - - -
-
-
- + {platformFee.toLocaleString()} RSC -
-
- -
- -
- Total: - {totalAmount.toLocaleString()} RSC -
-
- ); - - /* ---------- Submission ---------- */ - const handleSubmit = async () => { - if (isSubmitting) return; - - if (amountError) return; - if (!bountyType) return; - setIsSubmitting(true); - - if (bountyType === 'REVIEW') { - if (!paperId) { - toast.error('Please select a work to review'); - setIsSubmitting(false); - return; - } - const expirationDate = (() => { - const date = new Date(); - date.setDate(date.getDate() + 30); - return date.toISOString(); - })(); - const toastId = toast.loading('Creating bounty...'); - try { - // Extract mentions from the review content - const mentions = - reviewContent && typeof reviewContent === 'object' && 'content' in reviewContent - ? extractUserMentions(reviewContent as JSONContent) - : []; - - await CommentService.createComment({ - workId: paperId.toString(), - contentType: 'paper', - content: - typeof reviewContent === 'string' ? reviewContent : JSON.stringify(reviewContent), - contentFormat: 'TIPTAP', - commentType: 'GENERIC_COMMENT', - bountyAmount: rscAmount, - bountyType: 'REVIEW', - expirationDate, - privacyType: 'PUBLIC', - mentions, - }); - toast.success('Bounty created!', { id: toastId }); - - router.push( - buildWorkUrl({ - id: paperId, - contentType: 'paper', - slug: selectedPaper?.slug, - tab: 'bounties', - }) - ); - } catch (err) { - console.error(err); - toast.error('Failed to create bounty', { id: toastId }); - } finally { - setIsSubmitting(false); - } - return; - } - - // Answer-to-question flow - if (questionTitle.trim().length === 0) { - toast.error('Please enter a title'); - setIsSubmitting(false); - return; - } - if (!questionPlainText || questionPlainText.trim() === '') { - toast.error('Please enter the question details'); - setIsSubmitting(false); - return; - } - - const toastId = toast.loading('Publishing question...'); + const toastId = toast.loading('Creating bounty...'); try { - const post = await PostService.upsert({ - assign_doi: false, - document_type: 'QUESTION', - full_src: questionHtml, - renderable_text: questionPlainText, - hubs: selectedHubs.map((h) => Number(h.id)), - title: questionTitle, - }); - // After post creation, create the bounty comment with platform fee applied - const expirationDate = (() => { - const date = new Date(); - date.setDate(date.getDate() + 30); - return date.toISOString(); - })(); - - const bountyCommentContent = { - type: 'doc', - content: [ - { - type: 'paragraph', - content: [ - { - type: 'text', - text: `Offering a bounty of ${rscAmount} RSC to the best answer to this question.`, - }, - ], - }, - ], - } as any; - await CommentService.createComment({ - workId: post.id.toString(), - contentType: 'post', - content: bountyCommentContent, + workId: paperId?.toString() || '0', + contentType: 'paper', + content: JSON.stringify({ type: 'doc', content: [] }), bountyAmount: rscAmount, - bountyType: 'ANSWER', - expirationDate, - privacyType: 'PUBLIC', - commentType: 'GENERIC_COMMENT', - mentions: [], + bountyType: bountyType || 'REVIEW', + expirationDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), }); - - toast.success('Question published & bounty created!', { id: toastId }); - router.push(`/post/${post.id}/${post.slug}`); - - removeCommentDraftById(`question-editor-draft`); + toast.success('Bounty created!', { id: toastId }); + router.push('/feed'); } catch (err) { - console.error(err); - let errorMessage = 'Failed to publish question'; - if (err && typeof err === 'object') { - const e: any = err; - // Priority: explicit msg field, then nested errors.msg (string | string[]), then generic message - if (typeof e.msg === 'string') { - errorMessage = e.msg; - } else if (e.errors?.msg) { - if (Array.isArray(e.errors.msg)) { - errorMessage = e.errors.msg.join(', '); - } else if (typeof e.errors.msg === 'string') { - errorMessage = e.errors.msg; - } - } else if (typeof e.message === 'string') { - errorMessage = e.message; - } - } - toast.error(errorMessage, { id: toastId }); + toast.error('Failed', { id: toastId }); } finally { setIsSubmitting(false); } }; - /* ---------- Navigation ---------- */ - - const nextStep = () => { - if (!bountyType) return; - - if (step === 'TYPE') { - if (bountyType === 'REVIEW') setStep('WORK'); - else setStep('DETAILS'); - return; - } - if (step === 'WORK') { - if (bountyType === 'REVIEW') { - setStep('DESCRIPTION'); - } else { - setStep('AMOUNT'); - } - return; - } - if (step === 'DETAILS') { - setStep('AMOUNT'); - return; - } - if (step === 'DESCRIPTION') { - setStep('AMOUNT'); - return; - } - }; - - const prevStep = () => { - if (step === 'TYPE') return; - if (step === 'WORK' || step === 'DETAILS') { - setStep('TYPE'); - return; - } - if (step === 'DESCRIPTION') { - setStep('WORK'); - return; - } - if (step === 'AMOUNT') { - if (bountyType === 'REVIEW') setStep('WORK'); - else setStep('DETAILS'); - } - }; - - /* ---------- Render helpers ---------- */ - - const renderTypeStep = () => ( -
-
-
-

What is this bounty for?

-
-

- Choose the type of content you'd like to incentivize with your bounty -

-
-
- - - - - Don't see an option you are looking for? That's okay - Choose "Answer to Question" and add - your details even though it may not be a perfect fit. - -
-
- ); - - const renderWorkStep = () => ( -
-
-
- -

Which work is this bounty for?

-
-

- Search for the research paper you want to receive peer reviews for -

-
- { - if (sugg.entityType !== 'paper') return; - - // Reset any previous selections - setSelectedPaper(null); - setPaperId(null); - - // Store the suggestion so user sees it immediately - setSelectedPaper(sugg as WorkSuggestion); - - // Start fetching full paper details - setIsFetchingPaper(true); - - try { - const identifier = (sugg.id ? sugg.id.toString() : null) || sugg.doi || sugg.openalexId; - - // Use the pre-formatted defaultBountyText directly - setReviewContent(defaultBountyText); - - const paper = await PaperService.get(identifier); - setPaperId(paper.id); - } catch (err) { - console.error('Failed to fetch paper details', err); - toast.error('Failed to load paper details. Please try again.'); - setSelectedPaper(null); - } finally { - setIsFetchingPaper(false); - } - }} - displayMode="inline" - placeholder="Search for work..." - className="w-full [&_input]:bg-white" - indices={['paper', 'post']} - /> - {/* Show selected paper summary */} - {selectedPaper && ( -
-
-

- {selectedPaper.displayName} -

- {selectedPaper.authors && selectedPaper.authors.length > 0 && ( -

- {selectedPaper.authors.join(', ')} -

- )} -
- -
- )} -
- ); - - const renderDetailsStep = () => ( -
-
-
-
- -

Question details

-
-

- Provide the details of your question for others to answer -

-
- setQuestionTitle(e.target.value)} - placeholder="Question title..." - /> -
- -
- {/* Hide header & footer using global styles */} - - {}} - placeholder="Describe your question..." - compactToolbar={true} - storageKey={`question-editor-draft`} - showHeader={false} - showFooter={false} - onContentChange={(plainText: string, html: string) => { - setQuestionPlainText(plainText); - setQuestionHtml(html); - }} - /> -
-
-
- -
- -
-
- ); - - const renderAmountStep = () => { - const suggestedAmount = isExchangeRateLoading - ? 'Loading...' - : currency === 'USD' - ? '150 USD' - : `${Math.round(150 / exchangeRate)} RSC`; - - return ( -
-
-
- -

How much are you offering?

-
-

- Set the bounty amount you're willing to pay for a quality response -

-
- -
- - {currency} - - - } - /> - {amountError &&

{amountError}

} - {!amountError && ( -

- Suggested amount: {suggestedAmount} - {currency === 'RSC' && !isExchangeRateLoading && ' (150 USD)'} -

- )} -
- - {/* Fees breakdown */} - - - {/* User balance */} - - -
- - - {/* Info Alert */} - - If no solution satisfies your request, the full bounty amount (excluding platform fee) - will be refunded to you - -
-
- ); - }; - - const renderDescriptionStep = () => ( -
-
-
- -

Bounty Description

-
-

- Provide details about your bounty requirements for peer reviewers -

-
- setReviewContent(c)} - onSubmit={async () => {}} - placeholder="Edit your bounty description..." - compactToolbar={true} - storageKey={`peer-review-editor-draft-${paperId}`} - showHeader={false} - showFooter={false} - /> -
- ); - - const renderStep = () => { - switch (step) { - case 'TYPE': - return renderTypeStep(); - case 'WORK': - return renderWorkStep(); - case 'DETAILS': - return renderDetailsStep(); - case 'AMOUNT': - return renderAmountStep(); - case 'DESCRIPTION': - return renderDescriptionStep(); - default: - return null; - } - }; - - /* ---------- Page ---------- */ - - // Contributors for the avatar display - const journalContributors = [ - { - src: '/people/maulik.jpeg', - alt: 'Maulik Dhandha', - tooltip: 'Maulik Dhandha, Editor', - }, - { - src: '/people/emilio.jpeg', - alt: 'Emilio Merheb', - tooltip: 'Emilio Merheb, Editor', - }, - { - src: '/people/dominikus_brian.jpeg', - alt: 'Dominikus Brian', - tooltip: 'Editorial Board Member', - }, - { - src: '/people/jeffrey_koury.jpeg', - alt: 'Jeffrey Koury', - tooltip: 'Editorial Board Member', - }, - { - src: '/people/blob_48esqmw.jpeg', - alt: 'Journal Editor', - tooltip: 'Editorial Board Member', - }, - ]; - return ( }> -
- {/* Header */} -
-
- {step !== 'TYPE' && ( - - )} - {step === 'TYPE' && ( -
- -
- )} -

- Create a Bounty -

- {/* Contributors avatars - only show on TYPE step */} - {step === 'TYPE' && ( -
-
- {journalContributors.map((contributor, i) => ( -
- {contributor.alt} -
- ))} -
- - Engage{' '} - thousands of - researchers and academics to help with your inquiry - -
- )} -
-
- - {/* Progress */} - {bountyType && ( -
- {(() => { - const isReview = bountyType === 'REVIEW'; - const stepsArr = isReview - ? ['TYPE', 'WORK', 'DESCRIPTION', 'AMOUNT'] - : ['TYPE', 'DETAILS', 'AMOUNT']; - const labelsMap: Record = { - TYPE: 'Bounty type', - WORK: 'Select work', - DESCRIPTION: 'Bounty description', - DETAILS: 'Question details', - AMOUNT: 'Amount', - }; - const currentIndex = stepsArr.indexOf(step); - const currentLabel = labelsMap[step] || ''; - const nextLabel = stepsArr[currentIndex + 1] - ? labelsMap[stepsArr[currentIndex + 1]] - : null; - return ( - <> -
- - Current step: {currentLabel} - - - {nextLabel ? ( - <> - Next: {nextLabel} - - ) : ( - `${currentIndex + 1} of ${stepsArr.length}` - )} - -
- - - ); - })()} +
+

Create a Bounty

+ + {step === 'TYPE' && ( +
+ +
)} - {/* Wizard body */} -
{renderStep()}
+ {step === 'WORK' && ( + { setPaperId(Number(s.id)); setStep('AMOUNT'); }} displayMode="inline" /> + )} - {/* Navigation controls (Next only, except on AMOUNT) */} - {step !== 'AMOUNT' && ( -
- + {step === 'AMOUNT' && ( +
+ + + +
)}
); } - -// Session-aware wrapper for CommentEditor -const SessionAwareCommentEditor = (props: any) => { - const { data: session } = useSession(); - - if (session) { - return ; - } - - const mockSession = { - user: { - name: 'You', - fullName: 'You', - }, - expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), - userId: '0', - }; - - return ( - - - - ); -}; diff --git a/app/earn/page.tsx b/app/earn/page.tsx index c893e093a..984fe46c5 100644 --- a/app/earn/page.tsx +++ b/app/earn/page.tsx @@ -19,6 +19,8 @@ export default function EarnPage() { loadMore, sort, handleSortChange, + bountyFilter, + handleBountyFilterChange, selectedHubs, handleHubsChange, restoredScrollPosition, @@ -34,19 +36,35 @@ export default function EarnPage() { { label: 'RSC amount', value: '-total_amount' }, ]; + // Available bounty type options + const bountyTypeOptions = [ + { label: 'All Bounties', value: 'ALL' }, + { label: 'Foundation Only', value: 'FOUNDATION' }, + { label: 'Community Only', value: 'COMMUNITY' }, + ]; + const renderFilters = () => (
{/* Top filter bar */}
-
- +
+
+ +
+
+ handleBountyFilterChange(opt.value as any)} + options={bountyTypeOptions} + /> +
-
+
handleSortChange(opt.value)} diff --git a/components/Bounty/BountyForm.tsx b/components/Bounty/BountyForm.tsx index dd5fa1578..33c8d4dd6 100644 --- a/components/Bounty/BountyForm.tsx +++ b/components/Bounty/BountyForm.tsx @@ -1,22 +1,11 @@ 'use client'; -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect } from 'react'; import { Button } from '@/components/ui/Button'; -import { Input } from '@/components/ui/form/Input'; import { Search } from '@/components/Search/Search'; import { SearchSuggestion } from '@/types/search'; -import { - ChevronDown, - Users, - MessageCircleQuestion, - Star, - Calendar, - MessageCircle, - RecycleIcon, -} from 'lucide-react'; +import { Star, MessageCircleQuestion, MessageCircle, ChevronDown, BookOpen } from 'lucide-react'; import { Alert } from '@/components/ui/Alert'; -import { Tooltip } from '@/components/ui/Tooltip'; -import { cn } from '@/utils/styles'; import { Currency } from '@/types/root'; import { BountyType } from '@/types/bounty'; import { BalanceInfo } from '@/components/modals/BalanceInfo'; @@ -28,12 +17,16 @@ import { useComments } from '@/contexts/CommentContext'; import { useCreateComment } from '@/hooks/useComments'; import { CommentService } from '@/services/comment.service'; import { RadioGroup as HeadlessRadioGroup, Listbox } from '@headlessui/react'; -import { useSession } from 'next-auth/react'; -import { SessionProvider } from 'next-auth/react'; +import { useSession, SessionProvider } from 'next-auth/react'; import { useExchangeRate } from '@/contexts/ExchangeRateContext'; import { useStorageKey } from '@/utils/storageKeys'; import { extractUserMentions } from '@/components/Comment/lib/commentUtils'; import { removeCommentDraftById } from '@/components/Comment/lib/commentDraftStorage'; +import { CurrencyInput } from '@/components/ui/form/CurrencyInput'; +import { FeeBreakdown } from './lib/FeeBreakdown'; +import { useCurrencyConversion } from './lib/useCurrencyConversion'; +import { useAmountInput } from '@/hooks/useAmountInput'; +import { calculateBountyFees } from './lib/bountyUtil'; type Step = 'details' | 'payment'; type BountyLength = '14' | '30' | '60' | 'custom'; @@ -56,80 +49,6 @@ interface BountyFormProps { className?: string; } -// Reuse the existing components from CreateBountyModal -const CurrencyInput = ({ - value, - onChange, - currency, - onCurrencyToggle, - convertedAmount, - suggestedAmount, - error, - isExchangeRateLoading, -}: { - value: string; - onChange: (e: React.ChangeEvent) => void; - currency: Currency; - onCurrencyToggle: () => void; - convertedAmount?: string; - suggestedAmount?: string; - error?: string; - isExchangeRateLoading?: boolean; -}) => { - const currentAmount = parseFloat(value.replace(/,/g, '')) || 0; - const suggestedAmountValue = suggestedAmount - ? parseFloat(suggestedAmount.replace(/[^0-9.]/g, '')) - : 0; - - const isBelowSuggested = currentAmount < suggestedAmountValue; - const suggestedTextColor = !currentAmount - ? 'text-gray-500' - : isBelowSuggested - ? 'text-orange-500' - : 'text-green-500'; - - return ( -
- - {currency} - - - } - /> - {error &&

{error}

} - {suggestedAmount && !error && ( -

- Suggested amount for peer review: {suggestedAmount} - {currency === 'RSC' && - !isExchangeRateLoading && - !suggestedAmount.includes('Loading') && - ' (150 USD)'} -

- )} - {isExchangeRateLoading ? ( -
Loading exchange rate...
- ) : ( - convertedAmount &&
{convertedAmount}
- )} -
- ); -}; - const BountyLengthSelector = ({ selected, onChange, @@ -193,16 +112,13 @@ const BountyLengthSelector = ({ ); }; -// Custom CommentEditor wrapper that ensures a session is provided const SessionAwareCommentEditor = (props: CommentEditorProps) => { const { data: session } = useSession(); - // If we have a real session, use it directly if (session) { return ; } - // Otherwise, create a mock session with a default user const mockSession = { user: { name: 'You', @@ -212,7 +128,6 @@ const SessionAwareCommentEditor = (props: CommentEditorProps) => { userId: '0', }; - // Wrap the CommentEditor with a SessionProvider using our mock session return ( @@ -222,78 +137,52 @@ const SessionAwareCommentEditor = (props: CommentEditorProps) => { export function BountyForm({ workId, onSubmitSuccess, className }: BountyFormProps) { const { user } = useUser(); - const { data: session, status } = useSession(); const { exchangeRate, isLoading: isExchangeRateLoading } = useExchangeRate(); - - // Debug session information - useEffect(() => { - console.log('BountyForm - Session Status:', status); - console.log('BountyForm - Session Data:', session); - }, [session, status]); + const { convertToRSC, convertToUSD } = useCurrencyConversion(exchangeRate); const [step, setStep] = useState('details'); const [selectedPaper, setSelectedPaper] = useState(null); const [showSuggestions, setShowSuggestions] = useState(true); - // Initialize with 150 USD worth of RSC, or 100 RSC if exchange rate is loading - const [inputAmount, setInputAmount] = useState(() => { - return isExchangeRateLoading ? 100 : Math.round(150 / exchangeRate); + + const { + amount: inputAmount, + setAmount: setInputAmount, + error: amountError, + handleAmountChange, + getFormattedValue: getFormattedInputValue, + hasInteracted: hasInteractedWithAmount, + } = useAmountInput({ + initialAmount: isExchangeRateLoading ? 100 : Math.round(150 / exchangeRate), }); + const [currency, setCurrency] = useState('RSC'); const [bountyLength, setBountyLength] = useState('30'); const [bountyType, setBountyType] = useState('REVIEW'); - const [otherDescription, setOtherDescription] = useState(''); const [isFeesExpanded, setIsFeesExpanded] = useState(false); - const [customDate, setCustomDate] = useState(''); const [editorContent, setEditorContent] = useState(null); const [showAdvanced, setShowAdvanced] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const userBalance = user?.balance || 0; - const [{ data: commentData, isLoading: isCreatingBounty, error: bountyError }, createComment] = - useCreateComment(); - - const [amountError, setAmountError] = useState(undefined); - const [hasInteractedWithAmount, setHasInteractedWithAmount] = useState(false); - - // Make useComments optional to handle cases when the component is not wrapped with a CommentProvider - let commentContext; - try { - commentContext = useComments(); - } catch (error) { - commentContext = null; - } - - // Update amount validation when exchange rate changes - useEffect(() => { - if (!isExchangeRateLoading && currency !== 'RSC' && inputAmount > 0) { - const rscAmount = inputAmount / exchangeRate; - if (rscAmount < 10) { - setAmountError('Minimum bounty amount is 10 RSC'); - } else { - setAmountError(undefined); - } - } - }, [exchangeRate, isExchangeRateLoading, currency, inputAmount]); - - // Update the input amount when the exchange rate loads - useEffect(() => { - if (!isExchangeRateLoading && exchangeRate > 0 && !hasInteractedWithAmount) { - // Only update if the user hasn't manually changed the amount - setInputAmount(Math.round(150 / exchangeRate)); - } - }, [exchangeRate, isExchangeRateLoading, hasInteractedWithAmount]); + const commentContext = useComments(); const baseStorageKey = `bounty-editor-draft-${workId || 'new'}`; const storageKey = useStorageKey(baseStorageKey); + const getRscAmount = () => { + if (isExchangeRateLoading) return currency === 'RSC' ? inputAmount : 0; + return currency === 'RSC' ? inputAmount : convertToRSC(inputAmount); + }; + + const { platformFee, daoFee, incFee, totalAmount: totalCost } = calculateBountyFees(getRscAmount()); + const handleCreateBounty = async () => { if (isSubmitting) return; - const rscAmount = getRscAmount(); + const rscAmount = Math.round(getRscAmount()); if (rscAmount < 10) { toast.error('Minimum bounty amount is 10 RSC'); - setAmountError('Minimum bounty amount is 10 RSC'); return; } @@ -301,412 +190,90 @@ export function BountyForm({ workId, onSubmitSuccess, className }: BountyFormPro const toastId = toast.loading('Creating bounty...'); try { - const expirationDate = (() => { - const days = parseInt(bountyLength); - const date = new Date(); - date.setDate(date.getDate() + days); - return date.toISOString(); - })(); - - const commentContent = { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + parseInt(bountyLength)); + + const apiContent = { type: 'doc', content: editorContent?.content || [], }; - // Extract mentions from the editor content const mentions = extractUserMentions(editorContent || {}); - let createdComment; - - if (commentContext?.createBounty) { - createdComment = await commentContext.createBounty( - commentContent, - rscAmount, - bountyType, - expirationDate, - workId || selectedPaper?.id - ); - } else { - const apiContent = { - type: 'doc', - content: editorContent?.content || [], - }; - - createdComment = await CommentService.createComment({ - workId: workId || selectedPaper?.id, - contentType: 'paper', - content: JSON.stringify(apiContent), - contentFormat: 'TIPTAP', - commentType: 'GENERIC_COMMENT', - bountyAmount: rscAmount, - bountyType, - expirationDate, - privacyType: 'PUBLIC', - mentions, - }); - } + const createdComment = await CommentService.createComment({ + workId: workId || selectedPaper?.id, + contentType: 'paper', + content: JSON.stringify(apiContent), + contentFormat: 'TIPTAP', + commentType: 'GENERIC_COMMENT', + bountyAmount: rscAmount, + bountyType, + expirationDate: expirationDate.toISOString(), + privacyType: 'PUBLIC', + mentions, + }); if (createdComment) { removeCommentDraftById(storageKey); - toast.success('Bounty created successfully!', { id: toastId }); onSubmitSuccess?.(); - } else { - toast.error('Failed to create bounty. Please try again.', { id: toastId }); } } catch (error) { - console.error('Failed to create bounty:', error); - toast.error('Failed to create bounty. Please try again.', { id: toastId }); + toast.error('Failed to create bounty.', { id: toastId }); } finally { setIsSubmitting(false); } }; - const handlePaperSelect = (paper: SearchSuggestion) => { - if (paper.entityType === 'paper') { - setSelectedPaper({ - id: paper.id?.toString() || paper.openalexId, - title: paper.displayName, - authors: paper.authors, - }); - setShowSuggestions(false); - } - }; - - const handleAmountChange = (e: React.ChangeEvent) => { - const rawValue = e.target.value.replace(/[^0-9.]/g, ''); - const numValue = parseFloat(rawValue); - - if (!hasInteractedWithAmount) { - setHasInteractedWithAmount(true); - } - - if (!isNaN(numValue)) { - setInputAmount(numValue); - - if (isExchangeRateLoading && currency !== 'RSC') { - // Don't validate amount if exchange rate is loading and currency is USD - setAmountError(undefined); - return; - } - - const rscAmount = currency === 'RSC' ? numValue : numValue / exchangeRate; - if (rscAmount < 10) { - setAmountError('Minimum bounty amount is 10 RSC'); - } else { - setAmountError(undefined); - } - } else { - setInputAmount(0); - setAmountError('Please enter a valid amount'); - } - }; - - const getFormattedInputValue = () => { - if (inputAmount === 0) return ''; - return inputAmount.toLocaleString(); - }; - const toggleCurrency = () => { - // If exchange rate is loading, only allow toggling from USD to RSC, not the other way around - if (isExchangeRateLoading && currency === 'RSC') { - toast.error('Exchange rate is loading. Please wait before switching to USD.'); - return; - } - setCurrency(currency === 'RSC' ? 'USD' : 'RSC'); - }; - - const getConvertedAmount = () => { - if (inputAmount === 0) return ''; - if (isExchangeRateLoading) return ''; - - return currency === 'RSC' - ? `≈ $${(inputAmount * exchangeRate).toLocaleString()} USD` - : `≈ ${(inputAmount / exchangeRate).toLocaleString()} RSC`; - }; - - const getRscAmount = () => { - if (isExchangeRateLoading) return currency === 'RSC' ? inputAmount : 0; - return currency === 'RSC' ? inputAmount : inputAmount / exchangeRate; - }; - - const handleEditorContent = (content: any) => { - setEditorContent(content); - }; - - const handleEditorUpdate = (content: any) => { - setEditorContent(content); - }; - - const rscAmount = getRscAmount(); - const platformFee = Math.floor(rscAmount * 0.09); - const daoFee = Math.floor(rscAmount * 0.02); - const incFee = Math.floor(rscAmount * 0.07); - const baseAmount = rscAmount - platformFee; - const totalCost = rscAmount + platformFee; - const insufficientBalance = userBalance < rscAmount; - const hasAdditionalInfo = !!( - editorContent && - editorContent.content && - (Array.isArray(editorContent.content) - ? editorContent.content.length > 0 - : Object.keys(editorContent.content).length > 0) - ); - const isAmountTooLow = rscAmount < 10; - - // Function to proceed to payment step - const handleProceedToPayment = () => { - if (!workId && !selectedPaper) { - toast.error('Please select a paper first'); + if (isExchangeRateLoading) { + toast.error('Exchange rate is loading.'); return; } - if (inputAmount === 0 || getRscAmount() < 10 || !!amountError) { - toast.error('Please enter a valid amount (minimum 10 RSC)'); - return; + if (currency === 'RSC') { + setCurrency('USD'); + setInputAmount(convertToUSD(inputAmount)); + } else { + setCurrency('RSC'); + setInputAmount(convertToRSC(inputAmount)); } - - setStep('payment'); - }; - - // Function to go back to details step - const handleBackToDetails = () => { - setStep('details'); }; - // Fee breakdown component - const FeeBreakdown = () => ( -
-
- Base amount: - {baseAmount.toLocaleString()} RSC -
-
-
- Platform fee (7%): - - - - - - - -
- {platformFee.toLocaleString()} RSC -
-
-
- DAO fee (2%): - - - - - - - -
- {daoFee.toLocaleString()} RSC -
-
-
- Inc fee (7%): - - - - - - - -
- {incFee.toLocaleString()} RSC -
-
- Total: - {rscAmount.toLocaleString()} RSC -
-
- ); - return (
-
- {step === 'payment' && ( - - )} -

-
{step === 'details' ? ( <> - {/* Paper Search Section */} {!workId && (
-
- -
-
- - {selectedPaper && ( -
-
{selectedPaper.title}
-
- {selectedPaper.authors.join(', ')} -
- {selectedPaper.abstract && ( -
- {selectedPaper.abstract} -
- )} -
- )} -
+ { + setSelectedPaper({ + id: paper.id?.toString() || paper.openalexId, + title: paper.displayName, + authors: paper.authors, + }); + setShowSuggestions(false); + }} + displayMode="inline" + placeholder="Search for work..." + showSuggestionsOnFocus={!selectedPaper || showSuggestions} + />
)} - {/* Bounty Type Section */}
- - setBountyType(value as BountyType)} - > +
- - cn( - 'relative flex cursor-pointer rounded-lg border p-4 focus:outline-none', - checked - ? 'border-indigo-600 bg-indigo-50' - : 'border-gray-200 hover:border-gray-300' - ) - } - > - {({ checked }) => ( -
-
-
- - Peer Review - - - Get expert feedback on methodology and findings - -
-
-
- -
-
- )} -
- - - cn( - 'relative flex cursor-pointer rounded-lg border p-4 focus:outline-none', - checked - ? 'border-indigo-600 bg-indigo-50' - : 'border-gray-200 hover:border-gray-300' - ) - } - > + cn('relative flex cursor-pointer rounded-lg border p-4 focus:outline-none', checked ? 'border-indigo-600 bg-indigo-50' : 'border-gray-200')}> {({ checked }) => (
-
-
- - Answer to Question - - - Ask a specific question about the research - -
-
-
- +
+

Peer Review

+ Get expert feedback on methodology
+ {checked && }
)} @@ -714,241 +281,53 @@ export function BountyForm({ workId, onSubmitSuccess, className }: BountyFormPro
- {/* Amount Section */} -
- -
- - {/* Additional Information Section */} -
-
- - - (Optional) -
-

- Provide more details about what you're looking for in the bounty submissions. -

-
- - {}} - compactToolbar={true} - storageKey={storageKey} - debug={true} - /> -
-
- - {/* Advanced Options Toggle */} -
-
-
- Advanced options -
- - - -
-
- - {/* Advanced Options Section */} - {showAdvanced && ( -
-
- - -
-
- )} + + + - {/* Continue Button */} ) : ( - // Payment Step
- {/* Bounty Summary */} -
-
-

Bounty Summary

-
-
-
- Type: - - {bountyType === 'REVIEW' ? 'Peer Review' : 'Answer to Question'} - -
-
- Amount: - - {rscAmount.toLocaleString()} RSC - -
-
- Duration: - {bountyLength} days -
-
-
- - {/* Fees Breakdown */} -
-
-

Fees Breakdown

-
-
-
- Your contribution: - {rscAmount.toLocaleString()} RSC -
-
- - {isFeesExpanded && ( -
-
- ResearchHub DAO (2%) - {daoFee.toLocaleString()} RSC -
-
- ResearchHub Inc (7%) - {incFee.toLocaleString()} RSC -
-
- )} -
-
-
- Net bounty amount: - - {totalCost.toLocaleString()} RSC - -
-
-
+ setIsFeesExpanded(!isFeesExpanded)} + /> + + - {/* Balance Info */} -
-
-
- Current RSC Balance: - {userBalance.toLocaleString()} RSC -
- {insufficientBalance && ( -
- You need {(rscAmount - userBalance).toLocaleString()} RSC more for this - contribution -
- )} -
-
- - {/* Create Bounty Button */} - - {/* Info Alert */} - -
- - If no solution satisfies your request, the full bounty amount (excluding platform - fee) will be refunded to you - -
-
)}
diff --git a/components/Bounty/BountyMetadataLine.tsx b/components/Bounty/BountyMetadataLine.tsx index 5a97ca053..b9c76d047 100644 --- a/components/Bounty/BountyMetadataLine.tsx +++ b/components/Bounty/BountyMetadataLine.tsx @@ -2,8 +2,9 @@ import { formatDeadline } from '@/utils/date'; import { CurrencyBadge } from '@/components/ui/CurrencyBadge'; import { RadiatingDot } from '@/components/ui/RadiatingDot'; import { ContentTypeBadge } from '@/components/ui/ContentTypeBadge'; -import { Check } from 'lucide-react'; +import { Check, XCircle } from 'lucide-react'; import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext'; +import { BountyStatus } from '@/types/bounty'; interface BountyMetadataLineProps { amount: number; @@ -13,7 +14,7 @@ interface BountyMetadataLineProps { className?: string; solutionsCount?: number; showDeadline?: boolean; - bountyStatus?: 'OPEN' | 'CLOSED' | 'ASSESSMENT'; + bountyStatus?: BountyStatus; /** * If true, the amount is already in the target currency and should not be converted. * Useful when the caller has pre-calculated the amount (e.g., Foundation bounty flat fee). @@ -33,15 +34,41 @@ export const BountyMetadataLine = ({ }: BountyMetadataLineProps) => { const { showUSD } = useCurrencyPreference(); - // Format the deadline text - const deadlineText = - bountyStatus === 'ASSESSMENT' - ? 'Assessment Period' - : isOpen - ? expirationDate - ? formatDeadline(expirationDate) - : 'No deadline' - : 'Completed'; + // Helper to determine the deadline text + const getDeadlineText = () => { + if (bountyStatus === 'ASSESSMENT') return 'Assessment Period'; + if (bountyStatus === 'EXPIRED') return 'Expired'; + if (bountyStatus === 'CANCELLED') return 'Cancelled'; + if (isOpen) { + return expirationDate ? formatDeadline(expirationDate) : 'No deadline'; + } + return 'Completed'; + }; + + const deadlineText = getDeadlineText(); + const isInactive = bountyStatus === 'EXPIRED' || bountyStatus === 'CANCELLED'; + + // Helper to determine the status icon + const renderStatusIcon = () => { + if (isOpen) { + return ; + } + if (isInactive) { + return ; + } + return ; + }; + + // Helper to determine the status text color + const getStatusColorClass = () => { + if (isOpen) { + return expiringSoon ? 'text-orange-600 font-medium' : 'text-gray-700'; + } + if (isInactive) { + return 'text-gray-500 italic'; + } + return 'text-green-700 font-medium'; + }; return (
@@ -49,28 +76,21 @@ export const BountyMetadataLine = ({
{/* Badges */}
- +
{showDeadline && (
- {isOpen ? ( - - ) : ( - - )} - - {deadlineText} - + {renderStatusIcon()} + {deadlineText}
)}
diff --git a/components/Bounty/BountySolutions.tsx b/components/Bounty/BountySolutions.tsx index 367f04c8a..db40172f1 100644 --- a/components/Bounty/BountySolutions.tsx +++ b/components/Bounty/BountySolutions.tsx @@ -1,4 +1,4 @@ -import { BountySolution } from '@/types/bounty'; +import { Bounty, BountySolution } from '@/types/bounty'; import { Button } from '@/components/ui/Button'; import { Avatar } from '@/components/ui/Avatar'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; @@ -6,14 +6,18 @@ import { faEye } from '@fortawesome/free-solid-svg-icons'; import { faTrophy } from '@fortawesome/pro-light-svg-icons'; import { ID } from '@/types/root'; import { ChevronUp, ChevronDown, MessageCircle, PlusIcon } from 'lucide-react'; -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import { CurrencyBadge } from '@/components/ui/CurrencyBadge'; +import { isFoundationBounty, FOUNDATION_BOUNTY_FLAT_USD } from './lib/bountyUtil'; +import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext'; +import { useExchangeRate } from '@/contexts/ExchangeRateContext'; interface BountySolutionsProps { solutions: BountySolution[]; isPeerReviewBounty: boolean; totalAwardedAmount: number; onViewSolution: (solutionId: ID, authorName: string, awardedAmount?: string) => void; + bounty?: Bounty; } export const BountySolutions = ({ @@ -21,8 +25,20 @@ export const BountySolutions = ({ isPeerReviewBounty, totalAwardedAmount, onViewSolution, + bounty, }: BountySolutionsProps) => { const [showSolutions, setShowSolutions] = useState(false); + const { showUSD } = useCurrencyPreference(); + const { exchangeRate } = useExchangeRate(); + + const isFoundation = useMemo(() => bounty && isFoundationBounty(bounty), [bounty]); + + const displayTotalAwarded = useMemo(() => { + if (isFoundation && showUSD) { + return FOUNDATION_BOUNTY_FLAT_USD; + } + return totalAwardedAmount; + }, [isFoundation, showUSD, totalAwardedAmount]); if (!solutions || solutions.length === 0) { return null; diff --git a/components/Bounty/lib/FeeBreakdown.tsx b/components/Bounty/lib/FeeBreakdown.tsx new file mode 100644 index 000000000..6bd316a78 --- /dev/null +++ b/components/Bounty/lib/FeeBreakdown.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { Tooltip } from '@/components/ui/Tooltip'; +import { ChevronDown } from 'lucide-react'; +import { cn } from '@/utils/styles'; + +interface FeeBreakdownProps { + rscAmount: number; + platformFee: number; + daoFee?: number; + incFee?: number; + baseAmount?: number; + totalAmount?: number; + isExpanded?: boolean; + onToggleExpand?: () => void; + className?: string; +} + +export const FeeBreakdown = ({ + rscAmount, + platformFee, + daoFee, + incFee, + baseAmount, + totalAmount, + isExpanded = false, + onToggleExpand, + className, +}: FeeBreakdownProps) => { + return ( +
+
+ Your contribution: + {rscAmount.toLocaleString()} RSC +
+ +
+ + + {isExpanded && (daoFee !== undefined || incFee !== undefined) && ( +
+ {daoFee !== undefined && ( +
+ ResearchHub DAO (2%) + {daoFee.toLocaleString()} RSC +
+ )} + {incFee !== undefined && ( +
+ ResearchHub Inc (7%) + {incFee.toLocaleString()} RSC +
+ )} +
+ )} +
+ +
+ + {baseAmount !== undefined && ( +
+ Net contribution: + {baseAmount.toLocaleString()} RSC +
+ )} + + {totalAmount !== undefined && ( +
+ Total: + {totalAmount.toLocaleString()} RSC +
+ )} +
+ ); +}; diff --git a/components/Bounty/lib/bountyUtil.ts b/components/Bounty/lib/bountyUtil.ts index 0ce1715db..3bbc088e0 100644 --- a/components/Bounty/lib/bountyUtil.ts +++ b/components/Bounty/lib/bountyUtil.ts @@ -523,6 +523,7 @@ const getCreatorUserId = (bounty: Bounty): number | undefined => { * @returns True if the bounty was created by the Foundation account */ export const isFoundationBounty = (bounty: Bounty): boolean => { + if (bounty.createdBy?.isOfficialAccount) return true; if (!FOUNDATION_USER_ID) return false; const creatorUserId = getCreatorUserId(bounty); @@ -648,3 +649,37 @@ export const findLatestFoundationBounty = (bounties: Bounty[]): Bounty | undefin return activeReviewBounties[0]; }; + +/** + * Interface for bounty fees breakdown + */ +export interface BountyFees { + platformFee: number; + daoFee: number; + incFee: number; + baseAmount: number; + totalAmount: number; +} + +/** + * Calculates bounty fees based on the RSC amount. + * Standard platform fee is 9% (2% DAO + 7% Inc). + * + * @param rscAmount The net amount of the bounty in RSC + * @returns Object containing the calculated fees and total amount + */ +export const calculateBountyFees = (rscAmount: number): BountyFees => { + const platformFee = Math.floor(rscAmount * 0.09); + const daoFee = Math.floor(rscAmount * 0.02); + const incFee = Math.floor(rscAmount * 0.07); + const baseAmount = rscAmount - platformFee; + const totalAmount = rscAmount + platformFee; + + return { + platformFee, + daoFee, + incFee, + baseAmount, + totalAmount, + }; +}; diff --git a/components/Bounty/lib/useCurrencyConversion.ts b/components/Bounty/lib/useCurrencyConversion.ts new file mode 100644 index 000000000..c9d632b6a --- /dev/null +++ b/components/Bounty/lib/useCurrencyConversion.ts @@ -0,0 +1,11 @@ +export function useCurrencyConversion(exchangeRate: number) { + const convertToRSC = (amount: number) => { + return Math.round(amount / exchangeRate); + }; + + const convertToUSD = (amount: number) => { + return Number((amount * exchangeRate).toFixed(2)); + }; + + return { convertToRSC, convertToUSD }; +} diff --git a/components/Comment/AwardBountyModal.tsx b/components/Comment/AwardBountyModal.tsx index da12eff21..e0078e810 100644 --- a/components/Comment/AwardBountyModal.tsx +++ b/components/Comment/AwardBountyModal.tsx @@ -21,6 +21,7 @@ interface AwardBountyModalProps { contentType: ContentType; bountyId?: number; onBountyUpdated?: () => void; + bounties?: any[]; // Optional explicit bounties list } // Explanation banner component @@ -474,6 +475,7 @@ export const AwardBountyModal = ({ contentType, bountyId, onBountyUpdated, + bounties: explicitBounties, }: AwardBountyModalProps) => { const [awardAmounts, setAwardAmounts] = useState>({}); const [selectedPercentages, setSelectedPercentages] = useState>({}); @@ -494,7 +496,8 @@ export const AwardBountyModal = ({ // Get the total bounty amount available to award (OPEN or ASSESSMENT) const activeBounty = useMemo(() => { - if (!comment.bounties || comment.bounties.length === 0) { + const bounties = explicitBounties || comment.bounties; + if (!bounties || bounties.length === 0) { return undefined; } @@ -503,8 +506,8 @@ export const AwardBountyModal = ({ } // Find the exact bounty by ID - return comment.bounties.find((b) => b.id === bountyId); - }, [comment.bounties, bountyId]); + return bounties.find((b: any) => b.id === bountyId); + }, [comment.bounties, bountyId, explicitBounties]); const totalBountyAmount = activeBounty ? parseFloat(activeBounty.totalAmount) : 0; diff --git a/components/Comment/CommentItem.tsx b/components/Comment/CommentItem.tsx index 64114a678..8c7ba0ea8 100644 --- a/components/Comment/CommentItem.tsx +++ b/components/Comment/CommentItem.tsx @@ -73,6 +73,29 @@ export const CommentItem = ({ // Check if the current user is the author of the comment const isAuthor = user?.authorProfile?.id === comment?.createdBy?.authorProfile?.id; + // Check if current user is the creator of any active bounty on this work + const userBounties = useMemo(() => { + if (!user?.id || !work?.bounties) return []; + return work.bounties.filter( + (b: any) => + (b.createdBy?.id === user.id || b.createdBy?.authorProfile?.id === user.authorProfile?.id) && + (b.status === 'OPEN' || b.status === 'ASSESSMENT') + ); + }, [user?.id, work?.bounties]); + + const canAward = userBounties.length > 0; + + const handleOpenAwardModal = useCallback( + (bountyId?: number) => { + const idToUse = bountyId || userBounties[0]?.id; + if (idToUse) { + setSelectedBountyId(idToUse); + setShowAwardModal(true); + } + }, + [userBounties] + ); + // Determine if this comment is being edited or replied to const isEditing = editingCommentId === comment.id; const isReplying = replyingToCommentId === comment.id; @@ -290,6 +313,7 @@ export const CommentItem = ({ onReply={() => setReplyingToCommentId(comment.id)} onEdit={() => setEditingCommentId(comment.id)} onDelete={() => handleDelete()} + onAward={canAward ? () => handleOpenAwardModal() : undefined} showCreatorActions={isAuthor} showTooltips={showTooltips} showRelatedWork={false} @@ -464,6 +488,7 @@ export const CommentItem = ({ comment={comment} contentType={contentType} bountyId={selectedBountyId} + bounties={work?.bounties} onBountyUpdated={() => { // Refresh the comments using the context forceRefresh(); diff --git a/components/Feed/BaseFeedItem.tsx b/components/Feed/BaseFeedItem.tsx index f635d024f..9fb81e67d 100644 --- a/components/Feed/BaseFeedItem.tsx +++ b/components/Feed/BaseFeedItem.tsx @@ -31,6 +31,7 @@ export interface BaseFeedItemProps { showTooltips?: boolean; maxLength?: number; showHeader?: boolean; + compact?: boolean; // New prop for compact mode customActionText?: string; children?: ReactNode; onFeedItemClick?: () => void; @@ -261,6 +262,7 @@ export const BaseFeedItem: FC = ({ showTooltips = true, maxLength, showHeader = true, + compact = false, // Initialize compact prop customActionText, children, onFeedItemClick, @@ -348,13 +350,13 @@ export const BaseFeedItem: FC = ({ )} {/* Main Content Card */} -
+
{children} {/* BountyInfoSummary */} {showBountyInfo ? ( openBounties.length === 1 ? (
e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} @@ -368,7 +370,7 @@ export const BaseFeedItem: FC = ({
) : openBounties.length > 0 ? (
e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} @@ -384,7 +386,10 @@ export const BaseFeedItem: FC = ({ {/* Action Buttons */} {showActions && (
e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} diff --git a/components/Feed/FeedEntryItem.tsx b/components/Feed/FeedEntryItem.tsx index d5b001b00..06f74517d 100644 --- a/components/Feed/FeedEntryItem.tsx +++ b/components/Feed/FeedEntryItem.tsx @@ -212,6 +212,7 @@ export const FeedEntryItem: FC = ({ onAbstractExpanded={handleAbstractExpanded} highlights={highlights} showBountyInfo={showBountyInfo} + compact={showBountyInfo} /> ); break; @@ -241,6 +242,7 @@ export const FeedEntryItem: FC = ({ onAbstractExpanded={handleAbstractExpanded} highlights={highlights} showBountyInfo={showBountyInfo} + compact={showBountyInfo} /> ); break; diff --git a/components/Feed/FeedItemActions.tsx b/components/Feed/FeedItemActions.tsx index 66a7391f1..65482c8d5 100644 --- a/components/Feed/FeedItemActions.tsx +++ b/components/Feed/FeedItemActions.tsx @@ -146,8 +146,10 @@ interface FeedItemActionsProps { comment?: string; upvote?: string; report?: string; + award?: string; }; onComment?: () => void; + onAward?: () => void; // Prop for inline award action onTip?: () => void; // Callback for tip action (when provided, tip button shows) children?: ReactNode; // Add children prop to accept additional action buttons showTooltips?: boolean; // New property for controlling tooltips @@ -201,6 +203,7 @@ export const FeedItemActions: FC = ({ reviews = [], bounties = [], awardedBountyAmount, + onAward, tips = [], relatedDocumentTopics, relatedDocumentUnifiedDocumentId, @@ -415,14 +418,15 @@ export const FeedItemActions: FC = ({ const showInlineReviews = showPeerReviews && reviews.length > 0; const showInlineBounties = hasOpenBounties; - // Calculate total awarded amount (tips + bounty awards) + // Calculate tip amount and awarded bounty amount separately const tipAmount = tips.reduce((total, tip) => total + (tip.amount || 0), 0); - const totalAwarded = tipAmount + (awardedBountyAmount || 0); + const hasBountyAwards = (awardedBountyAmount || 0) > 0; return ( <>
+ {/* ... voting buttons ... */}
= ({ showTooltip={showTooltips} /> )} - {(onTip || totalAwarded > 0) && - (showTooltips && totalAwarded > 0 ? ( - - } - position="top" - width="w-[320px]" - > - {onTip ? ( - - ) : ( -
- - {totalAwarded > 0 ? ( - - {formatCurrency({ - amount: totalAwarded, - showUSD, - exchangeRate, - shorten: true, - })} - - ) : ( - Tip - )} -
- )} -
- ) : onTip ? ( - - ) : ( + + ) : ( +
+ + + {formatCurrency({ amount: tipAmount, showUSD, exchangeRate, shorten: true })} + +
+ )} + + )} + + {/* Bounty Awarded Badge */} + {hasBountyAwards && ( + +

Bounty Awarded

+

Total amount awarded from bounties on this document.

+
+ } + position="top" + width="w-[240px]" + disabled={!showTooltips} + >
- - {totalAwarded > 0 ? ( - - {formatCurrency({ amount: totalAwarded, showUSD, exchangeRate, shorten: true })} - - ) : ( - Tip - )} + + + {formatCurrency({ + amount: awardedBountyAmount || 0, + showUSD, + exchangeRate, + shorten: true, + })} + {!showUSD && ' RSC'} +
- ))} + + )} {showInlineReviews && (showTooltips && reviews.length > 0 ? ( = ({ onClick={handleBountyClick} /> ))} + + {/* Inline Award Button - For bounty creators to award specific comments */} + {feedContentType === 'COMMENT' && onAward && ( + + )} + {children}
diff --git a/components/Feed/items/FeedItemComment.tsx b/components/Feed/items/FeedItemComment.tsx index dda15ca1a..f9c8c8a61 100644 --- a/components/Feed/items/FeedItemComment.tsx +++ b/components/Feed/items/FeedItemComment.tsx @@ -76,6 +76,7 @@ interface FeedItemCommentProps { showTooltips?: boolean; // New property for controlling tooltips hideActions?: boolean; // New property to hide action buttons completely workContentType?: ContentType; + onAward?: () => void; maxLength?: number; onFeedItemClick?: () => void; } @@ -96,6 +97,7 @@ export const FeedItemComment: FC = ({ showTooltips = true, // Default to showing tooltips hideActions = false, // Default to not hiding actions workContentType, + onAward, maxLength, onFeedItemClick, }) => { @@ -249,6 +251,7 @@ export const FeedItemComment: FC = ({ userVote={entry.userVote} actionLabels={actionLabels} onComment={onReply} + onAward={onAward} onTip={!isCurrentUserAuthor ? () => setIsTipModalOpen(true) : undefined} showTooltips={showTooltips} menuItems={menuItems} diff --git a/components/Feed/items/FeedItemPaper.tsx b/components/Feed/items/FeedItemPaper.tsx index 1f1578119..ddbefc030 100644 --- a/components/Feed/items/FeedItemPaper.tsx +++ b/components/Feed/items/FeedItemPaper.tsx @@ -32,6 +32,7 @@ interface FeedItemPaperProps { onAbstractExpanded?: () => void; highlights?: Highlight[]; showBountyInfo?: boolean; + compact?: boolean; // Add compact prop } /** @@ -47,45 +48,11 @@ export const FeedItemPaper: FC = ({ onAbstractExpanded, highlights, showBountyInfo, + compact = false, // Initialize compact prop }) => { const searchParams = useSearchParams(); const isDebugMode = searchParams.has('debug'); - - // Extract the paper from the entry's content - const paper = entry.content as FeedPaperContent; - // Extract highlighted fields from highlights prop - const highlightedTitle = highlights?.find((h) => h.field === 'title')?.value; - const highlightedSnippet = highlights?.find((h) => h.field === 'snippet')?.value; - - // Use provided href or create default paper page URL - const paperPageUrl = - href || - buildWorkUrl({ - id: paper.id, - slug: paper.slug, - contentType: 'paper', - }); - - // Construct the dynamic action text - const journalName = paper.journal?.name; - const actionText = journalName ? `published in ${journalName}` : 'published in a journal'; - - // Extract props for FeedItemMenuButton (same as BaseFeedItem uses for FeedItemActions) - const feedContentType = paper.contentType || 'PAPER'; - const votableEntityId = paper.id; - const relatedDocumentId = - 'relatedDocumentId' in paper ? paper.relatedDocumentId?.toString() : paper.id.toString(); - const relatedDocumentContentType = mapFeedContentTypeToContentType(paper.contentType); - - // Only show journal badge for specific preprint servers - const ALLOWED_JOURNALS = ['biorxiv', 'arxiv', 'medrxiv', 'chemrxiv']; - const journalSlugLower = paper.journal?.slug?.toLowerCase() || ''; - const shouldShowJournal = ALLOWED_JOURNALS.some((j) => journalSlugLower.includes(j)); - const filteredJournal = shouldShowJournal ? paper.journal : undefined; - - const thumbnailUrl = paper.previewThumbnail || paper.journal?.imageUrl; - const isPdfPreview = thumbnailUrl?.includes('preview'); - + ... return ( = ({ onFeedItemClick={onFeedItemClick} showBountyInfo={showBountyInfo} hideReportButton={true} + compact={compact} > {/* Top section with badges and mobile image (hide PDF previews on mobile) */} = ({ /> ) } - rightContent={ -
- {isDebugMode && entry.hotScoreV2 !== undefined && entry.hotScoreV2 > 0 && ( - - } - width="w-72" - position="bottom" - > -
- Popularity Score - {Math.round(entry.hotScoreV2)} -
-
- )} - -
- } - leftContent={ - - } + ... /> {/* Main content layout with desktop image */} = ({ highlightedTitle={highlightedTitle} href={paperPageUrl} onClick={onFeedItemClick} + className={cn(compact && 'text-sm md:!text-base mb-0')} // Smaller title in compact mode /> {/* Authors and Date */} - +
{paper.authors.length > 0 && ( = ({ authorUrl: author.id === 0 ? undefined : author.profileUrl, }))} size="base" - className="text-gray-500 font-normal text-sm" + className={cn('text-gray-500 font-normal text-sm', compact && 'text-xs')} delimiter="," delimiterClassName="ml-0" showAbbreviatedInMobile={true} @@ -190,7 +119,7 @@ export const FeedItemPaper: FC = ({ {(entry.timestamp || paper.createdDate) && ( <> {paper.authors.length > 0 && } - + {formatTimestamp(entry.timestamp || paper.createdDate, false)} @@ -198,17 +127,20 @@ export const FeedItemPaper: FC = ({
- + {!compact && ( // Hide abstract in compact mode + + )} } rightContent={ - thumbnailUrl && ( + thumbnailUrl && + !compact && ( // Hide image in compact mode = ({ onAbstractExpanded, highlights, showBountyInfo, + compact = false, // Initialize compact prop }) => { - // Extract the post from the entry's content - const post = entry.content as FeedPostContent; - - // Extract highlighted fields from highlights prop - const highlightedTitle = highlights?.find((h) => h.field === 'title')?.value; - const highlightedSnippet = highlights?.find((h) => h.field === 'snippet')?.value; - - // Convert authors to the format expected by AuthorList - const authors = - post.authors?.map((author) => ({ - name: author.fullName, - verified: author.user?.isVerified, - authorUrl: author.profileUrl, - })) || []; - - // Use provided href or create default post page URL - const postPageUrl = - href || - buildWorkUrl({ - id: post.id, - slug: post.slug, - contentType: post.postType === 'QUESTION' ? 'question' : 'post', - }); - - // Extract props for FeedItemMenuButton (same as BaseFeedItem uses for FeedItemActions) - const feedContentType = post.contentType || 'POST'; - const votableEntityId = post.id; - const relatedDocumentId = - 'relatedDocumentId' in post ? post.relatedDocumentId?.toString() : post.id.toString(); - const relatedDocumentContentType = mapFeedContentTypeToContentType(post.contentType); - + ... return ( = ({ showPeerReviews={post.postType !== 'QUESTION'} showBountyInfo={showBountyInfo} hideReportButton={true} + compact={compact} > {/* Top section with badges and mobile image */} = ({ /> ) } - rightContent={ - - } - leftContent={ - - } + ... /> {/* Main content layout with desktop image */} = ({ highlightedTitle={highlightedTitle} href={postPageUrl} onClick={onFeedItemClick} + className={cn(compact && 'text-sm md:!text-base mb-0')} />
{/* Authors list below title */} {authors.length > 0 && ( - +
@@ -149,17 +109,20 @@ export const FeedItemPost: FC = ({
{/* Content Section - handles both desktop and mobile */} - + {!compact && ( // Hide abstract in compact mode + + )} } rightContent={ - post.previewImage && ( + post.previewImage && + !compact && ( // Hide image in compact mode void; + onAwardBounty?: (bountyId: number) => void; } export const EarningOpportunityBanner = ({ work, metadata, onViewBounties, + onAwardBounty, }: EarningOpportunityBannerProps) => { const { showUSD } = useCurrencyPreference(); const { exchangeRate, isLoading: isExchangeRateLoading } = useExchangeRate(); const router = useRouter(); + const { user } = useUser(); // Don't show banner if no open bounties if (!metadata.bounties || metadata.openBounties === 0) { return null; } + // Check if current user is the creator of any open bounty + const userBounties = useMemo(() => { + if (!user?.id || !metadata.bounties) return []; + return metadata.bounties.filter( + (b: Bounty) => b.createdBy.id === user.id && (b.status === 'OPEN' || b.status === 'ASSESSMENT') + ); + }, [user?.id, metadata.bounties]); + + const isBountyCreator = userBounties.length > 0; + // Calculate display amount (handles Foundation bounties with flat $150 USD) const openBounties = useMemo(() => getOpenBounties(metadata.bounties || []), [metadata.bounties]); const { amount: displayAmount } = useMemo( @@ -48,7 +63,7 @@ export const EarningOpportunityBanner = ({ } else { const bountiesUrl = buildWorkUrl({ id: work.id, - contentType: work.contentType === 'paper' ? 'paper' : 'post', + contentType: work.contentType, slug: work.slug, tab: 'bounties', }); @@ -56,19 +71,30 @@ export const EarningOpportunityBanner = ({ } }; + const handleAwardBounty = (e: React.MouseEvent) => { + e.stopPropagation(); + if (onAwardBounty && userBounties.length > 0) { + onAwardBounty(userBounties[0].id); + } else { + handleViewBounties(); + } + }; + return (
{/* Mobile layout - compact single row */}
- Earn + + {isBountyCreator ? 'Your Bounty' : 'Earn'} + {canDisplayAmount && ( <> {showUSD && ( @@ -92,21 +118,22 @@ export const EarningOpportunityBanner = ({ /> )} - - Peer Review -
@@ -114,7 +141,9 @@ export const EarningOpportunityBanner = ({
- Earn + + {isBountyCreator ? 'Your Active Bounty' : 'Earn'} + {canDisplayAmount && ( <> {showUSD && ( @@ -136,23 +165,38 @@ export const EarningOpportunityBanner = ({ )}
- - Peer Review - + {isBountyCreator && ( + + {userBounties.length > 1 ? `${userBounties.length} Bounties` : '1 Bounty'} + + )}

- Submit a peer review to earn ResearchCoin. + {isBountyCreator + ? 'Review submissions and award your bounty.' + : 'Submit a peer review to earn ResearchCoin.'}

diff --git a/components/modals/ApplyToGrantModal.tsx b/components/modals/ApplyToGrantModal.tsx index 99dda72c6..3268e25e1 100644 --- a/components/modals/ApplyToGrantModal.tsx +++ b/components/modals/ApplyToGrantModal.tsx @@ -9,14 +9,14 @@ import { Circle as CircleIcon, Info, Check, - ChevronLeft, } from 'lucide-react'; -import { BaseModal } from '@/components/ui/BaseModal'; import { PostService, ProposalForModal } from '@/services/post.service'; import { GrantService } from '@/services/grant.service'; import { useUser } from '@/contexts/UserContext'; import { useRouter } from 'next/navigation'; import { toast } from 'react-hot-toast'; +import { ModalContainer } from '@/components/ui/Modal/ModalContainer'; +import { ModalHeader } from '@/components/ui/Modal/ModalHeader'; // Loading skeleton component for proposals const ProposalSkeleton = () => ( @@ -63,50 +63,37 @@ export const ApplyToGrantModal: React.FC = ({ const selectedProposal = proposals.find((p) => p.id === selectedProposalId); - // Handle navigation to draft new proposal const handleDraftNew = () => { - onClose(); // Close the modal first + onClose(); router.push('/notebook?newFunding=true'); }; - // Reset internal state when modal is opened or closed useEffect(() => { if (isOpen) { setShowProposalList(false); setSelectedProposalId(null); - // Fetch proposals when modal opens to avoid latency if (user?.id) { fetchProposals(); } } }, [isOpen, user?.id]); - // Fetch proposals const fetchProposals = async () => { if (!user?.id) return; - setLoading(true); try { const proposals = await PostService.getProposalsByUser(user.id); setProposals(proposals); } catch (error) { console.error('Error fetching proposals:', error); - // Show error state or fallback setProposals([]); } finally { setLoading(false); } }; - // Handle selecting existing proposals - const handleSelectExisting = () => { - setShowProposalList(true); - }; - - // Handle applying to grant with selected proposal const handleApplyToGrant = async () => { if (!selectedProposal) return; - setSubmitting(true); try { await GrantService.applyToGrant(grantId, selectedProposal.postId); @@ -121,186 +108,79 @@ export const ApplyToGrantModal: React.FC = ({ } }; - const backButton = showProposalList ? ( - - ) : undefined; - return ( - -
- {!showProposalList ? ( - <> - {/* Description */} -
-

- Applying to RFPs on ResearchHub happens via proposals. -

-
+ +
+ setShowProposalList(false) : undefined} + /> +
+ {!showProposalList ? ( + <> +
+

+ Applying to RFPs on ResearchHub happens via proposals. +

+
- {/* Action buttons */} -
-
- + -
- -
+ +
- {/* Info section */} -
-
-
-
-
- -
-
-

What is a Proposal?

-

- Documenting and sharing your research plan before conducting research as well - as specifying funding requirements. We believe open access proposals are the - perfect format for RFP applications. -

+
+
+
+
+
+ +
+
+

What is a Proposal?

+

+ Documenting and sharing your research plan before conducting research as well + as specifying funding requirements. We believe open access proposals are the + perfect format for RFP applications. +

+
-
- - ) : ( - <> - {/* Header section for proposal list */} -
-
-
- - - - -
-
-

Your Published Proposals

-

Select one to apply with

-
-
-
- - {/* Proposal list */} -
- {loading ? ( - - ) : proposals.length > 0 ? ( - proposals.map((proposal) => { - const isSelected = proposal.id === selectedProposalId; - const isDraft = proposal.status === 'draft'; - const isSelectable = !isDraft; - - return ( -
isSelectable && setSelectedProposalId(proposal.id)} - className={`p-4 rounded-xl border-2 transition-all duration-200 ${ - isDraft - ? 'border-gray-200 bg-gray-50 cursor-not-allowed opacity-60' - : isSelected - ? 'border-blue-300 bg-blue-50 cursor-pointer' - : 'border-gray-200 hover:border-blue-200 hover:bg-blue-50/50 cursor-pointer' - }`} - > -
-
- isSelectable && setSelectedProposalId(proposal.id)} - disabled={isDraft} - className={`w-4 h-4 mt-1 flex-shrink-0 focus:ring-2 ${ - isDraft - ? 'text-gray-400 bg-gray-200 border-gray-300 cursor-not-allowed' - : 'text-green-600 bg-gray-100 border-gray-300 focus:ring-green-500' - }`} - /> -
-

- {proposal.title} -

-
- {isDraft ? ( - <> - - Draft - - ) : ( - <> - - - Published - - - )} -
-
-
-
-
- ); - }) - ) : ( -
-
+ + ) : ( + <> +
+
+
= ({ strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" - className="w-8 h-8 text-gray-400" + className="lucide lucide-book-open w-6 h-6 text-green-600" > - - - - - + +
-

You have no proposals yet.

- +
+

Your Published Proposals

+

Select one to apply with

+
- )} -
+
- {/* Action buttons for second screen */} - {!loading && proposals.filter((p) => p.status === 'published').length > 0 && ( -
- - +
+ {loading ? ( + + ) : proposals.length > 0 ? ( + proposals.map((proposal) => { + const isSelected = proposal.id === selectedProposalId; + const isDraft = proposal.status === 'draft'; + const isSelectable = !isDraft; + + return ( +
isSelectable && setSelectedProposalId(proposal.id)} + className={`p-4 rounded-xl border-2 transition-all duration-200 ${ + isDraft + ? 'border-gray-200 bg-gray-50 cursor-not-allowed opacity-60' + : isSelected + ? 'border-blue-300 bg-blue-50 cursor-pointer' + : 'border-gray-200 hover:border-blue-200 hover:bg-blue-50/50 cursor-pointer' + }`} + > +
+
+ isSelectable && setSelectedProposalId(proposal.id)} + disabled={isDraft} + className={`w-4 h-4 mt-1 flex-shrink-0 focus:ring-2 ${ + isDraft + ? 'text-gray-400 bg-gray-200 border-gray-300 cursor-not-allowed' + : 'text-green-600 bg-gray-100 border-gray-300 focus:ring-green-500' + }`} + /> +
+

+ {proposal.title} +

+
+ {isDraft ? ( + <> + + Draft + + ) : ( + <> + + + Published + + + )} +
+
+
+
+
+ ); + }) + ) : ( +
+
+ + + + + + + +
+

You have no proposals yet.

+ +
+ )}
- )} - {/* Show only "Draft New" button when there are only drafts */} - {!loading && - proposals.length > 0 && - proposals.filter((p) => p.status === 'published').length === 0 && ( -
+ {!loading && proposals.filter((p) => p.status === 'published').length > 0 && ( +
+
)} - {/* Loading state action buttons */} - {loading && ( -
-
-
-
- )} - - )} + {!loading && + proposals.length > 0 && + proposals.filter((p) => p.status === 'published').length === 0 && ( +
+ +
+ )} + + {loading && ( +
+
+
+
+ )} + + )} +
- + ); }; diff --git a/components/modals/ConfirmPublishModal.tsx b/components/modals/ConfirmPublishModal.tsx index aae548846..4cda9ceaa 100644 --- a/components/modals/ConfirmPublishModal.tsx +++ b/components/modals/ConfirmPublishModal.tsx @@ -1,10 +1,13 @@ -import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'; -import { Fragment, useEffect, useState } from 'react'; +'use client'; + +import { useEffect, useState } from 'react'; import { Button } from '@/components/ui/Button'; import { Checkbox } from '@/components/ui/form/Checkbox'; import { GraduationCap, Scale, Users, FileText } from 'lucide-react'; import { Alert } from '@/components/ui/Alert'; import { useNotebookContext } from '@/contexts/NotebookContext'; +import { ModalContainer } from '@/components/ui/Modal/ModalContainer'; +import { ModalHeader } from '@/components/ui/Modal/ModalHeader'; interface ConfirmPublishModalProps { isOpen: boolean; @@ -31,9 +34,11 @@ export function ConfirmPublishModal({ const isPublishEnabled = isTitleValid && hasAgreed; useEffect(() => { - setTitle(initialTitle); - setHasAgreed(false); - }, []); + if (isOpen) { + setTitle(initialTitle); + setHasAgreed(false); + } + }, [isOpen, initialTitle]); const handleTitleChange = (e: React.ChangeEvent) => { const newTitle = e.target.value; @@ -56,130 +61,80 @@ export function ConfirmPublishModal({ }; return ( - - - -
- + +
+ -
-
- - -
- - {isUpdate ? 'Confirm Re-publication' : 'Confirm Publication'} - -

- You are about to {isUpdate ? 'republish' : 'publish'} your research proposal: -

- +
+

+ You are about to {isUpdate ? 'republish' : 'publish'} your research proposal: +

+ + -
-

Guidelines for posts

-
    -
  • - - - Stick to academically appropriate topics - -
  • -
  • - - - Focus on presenting objective results and remain unbiased in your - commentary - -
  • -
  • - - - Be respectful of differing opinions, viewpoints, and experiences - -
  • -
  • - - - Do not plagiarize any content, keep it original - -
  • -
-
-
- setHasAgreed(checked as boolean)} - /> - -
+
+

Guidelines for posts

+
    + {[ + { icon: GraduationCap, text: 'Stick to academically appropriate topics' }, + { icon: Scale, text: 'Focus on presenting objective results and remain unbiased in your commentary' }, + { icon: Users, text: 'Be respectful of differing opinions, viewpoints, and experiences' }, + { icon: FileText, text: 'Do not plagiarize any content, keep it original' }, + ].map((item, idx) => ( +
  • + + {item.text} +
  • + ))} +
+
+ +
+ setHasAgreed(checked as boolean)} + /> + +
- {!isTitleValid && ( - - Title must be at least 20 characters long - - )} + {!isTitleValid && ( + + Title must be at least 20 characters long + + )} -
- - -
-
- - +
+ +
-
-
+
+ ); } diff --git a/components/modals/ContributeBountyModal.tsx b/components/modals/ContributeBountyModal.tsx index a1e347353..bec492dec 100644 --- a/components/modals/ContributeBountyModal.tsx +++ b/components/modals/ContributeBountyModal.tsx @@ -1,13 +1,9 @@ 'use client'; -import { Dialog, Transition } from '@headlessui/react'; -import { Fragment, useState, useEffect } from 'react'; +import { useState } from 'react'; import { Button } from '@/components/ui/Button'; -import { Input } from '@/components/ui/form/Input'; import { Alert } from '@/components/ui/Alert'; -import { Tooltip } from '@/components/ui/Tooltip'; -import { cn } from '@/utils/styles'; -import { Currency, ID } from '@/types/root'; +import { ID } from '@/types/root'; import { BalanceInfo } from './BalanceInfo'; import { BountyService } from '@/services/bounty.service'; import { toast } from 'react-hot-toast'; @@ -15,6 +11,12 @@ import { ContentType } from '@/types/work'; import { BountyType } from '@/types/bounty'; import { useUser } from '@/contexts/UserContext'; import { useExchangeRate } from '@/contexts/ExchangeRateContext'; +import { FeeBreakdown } from '../Bounty/lib/FeeBreakdown'; +import { CurrencyInput } from '@/components/ui/form/CurrencyInput'; +import { ModalContainer } from '@/components/ui/Modal/ModalContainer'; +import { ModalHeader } from '@/components/ui/Modal/ModalHeader'; +import { useAmountInput } from '@/hooks/useAmountInput'; +import { calculateBountyFees } from '../Bounty/lib/bountyUtil'; interface ContributeBountyModalProps { isOpen: boolean; @@ -28,118 +30,6 @@ interface ContributeBountyModalProps { expirationDate?: string; } -// Currency Input Component -const CurrencyInput = ({ - value, - onChange, - error, -}: { - value: string; - onChange: (e: React.ChangeEvent) => void; - error?: string; -}) => { - return ( -
- - RSC -
- } - /> - {error &&

{error}

} -
- ); -}; - -// Fee Breakdown Component -const FeeBreakdown = ({ - contributionAmount, - platformFee, - totalAmount, -}: { - contributionAmount: number; - platformFee: number; - totalAmount: number; -}) => ( -
-
- Your contribution: - {contributionAmount.toLocaleString()} RSC -
- -
-
-
- Platform fees (9%) - -
- - - -
-
-
- + {platformFee.toLocaleString()} RSC -
-
- -
- -
- Total amount: - {totalAmount.toLocaleString()} RSC -
-
-); - -const ModalHeader = ({ - title, - onClose, - subtitle, -}: { - title: string; - onClose: () => void; - subtitle?: string; -}) => ( -
-
-
- - {title} - - {subtitle &&

{subtitle}

} -
- -
-
-); - export function ContributeBountyModal({ isOpen, onClose, @@ -153,53 +43,35 @@ export function ContributeBountyModal({ }: ContributeBountyModalProps) { const { user } = useUser(); const { exchangeRate, isLoading: isExchangeRateLoading } = useExchangeRate(); - const [inputAmount, setInputAmount] = useState(50); const [isContributing, setIsContributing] = useState(false); - const [error, setError] = useState(null); - const [isSuccess, setIsSuccess] = useState(false); - const [amountError, setAmountError] = useState(undefined); + const [submissionError, setSubmissionError] = useState(null); + + const { + amount: inputAmount, + handleAmountChange, + getFormattedValue: getFormattedInputValue, + error: amountError, + } = useAmountInput({ + initialAmount: 50, + minAmount: 10, + validate: (val) => val < 10 ? 'Minimum contribution amount is 10 RSC' : undefined, + }); const userBalance = user?.balance || 0; - - // Utility functions - const handleAmountChange = (e: React.ChangeEvent) => { - const rawValue = e.target.value.replace(/[^0-9.]/g, ''); - const numValue = parseFloat(rawValue); - - if (!isNaN(numValue)) { - setInputAmount(numValue); - - // Validate minimum amount - if (numValue < 10) { - setAmountError('Minimum contribution amount is 10 RSC'); - } else { - setAmountError(undefined); - } - } else { - setInputAmount(0); - setAmountError('Please enter a valid amount'); - } - }; - - const getFormattedInputValue = () => { - if (inputAmount === 0) return ''; - return inputAmount.toLocaleString(); - }; + const { platformFee, totalAmount } = calculateBountyFees(inputAmount); + const insufficientBalance = userBalance < totalAmount; const handleContribute = async () => { try { - // Validate minimum amount before proceeding if (inputAmount < 10) { - setError('Minimum contribution amount is 10 RSC'); + setSubmissionError('Minimum contribution amount is 10 RSC'); return; } setIsContributing(true); - setError(null); + setSubmissionError(null); - // Pass the contribution amount without the platform fee - // The API expects the net contribution amount - const contribution = await BountyService.contributeToBounty( + await BountyService.contributeToBounty( commentId, inputAmount, 'rhcommentmodel', @@ -209,144 +81,86 @@ export function ContributeBountyModal({ toast.success('Your contribution has been successfully added to the bounty.'); - // Set success flag - setIsSuccess(true); - - // Call onContributeSuccess if provided if (onContributeSuccess) { onContributeSuccess(); } - // Close the modal onClose(); - } catch (error) { + } catch (error: any) { console.error('Failed to contribute to bounty:', error); - setError(error instanceof Error ? error.message : 'Failed to contribute to bounty'); + const message = error?.response?.data?.message || error?.message || 'Failed to contribute to bounty'; + setSubmissionError(message); } finally { setIsContributing(false); } }; - const platformFee = Math.round(inputAmount * 0.09 * 100) / 100; - const totalAmount = inputAmount + platformFee; - const insufficientBalance = userBalance < totalAmount; - - // Calculate USD equivalent for display const usdEquivalent = !isExchangeRateLoading && exchangeRate > 0 ? `≈ $${(inputAmount * exchangeRate).toFixed(2)} USD` : ''; return ( - - { - // Reset success flag when modal is closed without contribution - if (!isSuccess) { - setIsSuccess(false); - } - onClose(); - }} - > - -
- - -
-
- - -
- - -
- {/* Amount Section */} -
- - {!amountError && usdEquivalent && ( -
{usdEquivalent}
- )} -
- - {/* Fees Breakdown */} -
-
-

Fees Breakdown

-
- -
- - {/* Balance Info */} -
- -
+ +
+ + +
+
+ {}} + label="I am contributing" + /> + {!amountError && usdEquivalent && ( +
{usdEquivalent}
+ )} +
- {/* Error Alert */} - {error && {error}} +
+
+

Fees Breakdown

+
+ +
- {/* Contribute Button */} - + + + {submissionError && {submissionError}} + + - {/* Info Alert */} - -
- - The bounty creator will be able to award the full bounty amount including - your contribution to a solution they pick. - -
-
-
-
- - -
+ + The bounty creator will be able to award the full bounty amount including + your contribution to a solution they pick. +
-
-
+
+ ); } diff --git a/components/modals/ContributeToFundraiseModal.tsx b/components/modals/ContributeToFundraiseModal.tsx index 02df0e85a..e0fad74d8 100644 --- a/components/modals/ContributeToFundraiseModal.tsx +++ b/components/modals/ContributeToFundraiseModal.tsx @@ -24,6 +24,7 @@ import { Button } from '@/components/ui/Button'; import { BaseModal } from '@/components/ui/BaseModal'; import { SwipeableDrawer } from '@/components/ui/SwipeableDrawer'; import { useIsMobile } from '@/hooks/useIsMobile'; +import { CurrencyInput } from '@/components/ui/form/CurrencyInput'; // Import inline deposit views import { DepositRSCView } from './DepositRSCView'; @@ -77,6 +78,8 @@ export function ContributeToFundraiseModal(props: ContributeToFundraiseModalProp ); } +import { useAmountInput } from '@/hooks/useAmountInput'; + function ContributeToFundraiseModalInner({ isOpen, onClose, @@ -89,10 +92,22 @@ function ContributeToFundraiseModalInner({ const walletAvailability = useWalletAvailability(); const { exchangeRate } = useExchangeRate(); const isMobile = useIsMobile(); - const [amountUsd, setAmountUsd] = useState(100); + + const minAmountUsd = 1; + const { + amount: amountUsd, + setAmount: setAmountUsd, + error: amountError, + setError: setAmountError, + handleAmountChange, + getFormattedValue: getFormattedInputValue, + } = useAmountInput({ + initialAmount: 100, + minAmount: 1, + }); + const [isContributing, setIsContributing] = useState(false); - const [error, setError] = useState(null); - const [amountError, setAmountError] = useState(undefined); + const [submissionError, setSubmissionError] = useState(null); const [currentView, setCurrentView] = useState('funding'); const [selectedQuickAmount, setSelectedQuickAmount] = useState(100); const [isBuyModalOpen, setIsBuyModalOpen] = useState(false); @@ -127,35 +142,6 @@ function ContributeToFundraiseModalInner({ })}`; }; - // Handlers - const handleAmountChange = (e: React.ChangeEvent) => { - const rawValue = e.target.value.replace(/[^0-9.]/g, ''); - const numValue = parseFloat(rawValue); - - if (!isNaN(numValue)) { - setAmountUsd(numValue); - setSelectedQuickAmount(null); - setIsSliderControlled(false); // Input sets scaled visual mode - - if (numValue < minAmountUsd) { - setAmountError(`Minimum contribution is $${minAmountUsd}`); - } else { - setAmountError(undefined); - } - } else { - setAmountUsd(0); - setAmountError('Please enter a valid amount'); - } - }; - - const getFormattedInputValue = () => { - if (amountUsd === 0) return ''; - return amountUsd.toLocaleString('en-US', { - minimumFractionDigits: 0, - maximumFractionDigits: 2, - }); - }; - const handleDepositSuccess = useCallback(() => { refreshUser?.(); setCurrentView('payment'); @@ -461,13 +447,11 @@ function ContributeToFundraiseModalInner({
{/* Amount Input + Quick Amount Selector grouped together */}
- } + currency="USD" + onCurrencyToggle={handleCurrencyToggle} error={amountError} label="Funding amount" className="text-lg" diff --git a/components/modals/ContributorModal.tsx b/components/modals/ContributorModal.tsx index d60bca542..2e90df724 100644 --- a/components/modals/ContributorModal.tsx +++ b/components/modals/ContributorModal.tsx @@ -2,12 +2,12 @@ import { FC } from 'react'; import Link from 'next/link'; -import { Dialog } from '@headlessui/react'; -import { X } from 'lucide-react'; import { Button } from '@/components/ui/Button'; import { Avatar } from '@/components/ui/Avatar'; import { formatRSC } from '@/utils/number'; import { ResearchCoinIcon } from '@/components/ui/icons/ResearchCoinIcon'; +import { ModalContainer } from '@/components/ui/Modal/ModalContainer'; +import { ModalHeader } from '@/components/ui/Modal/ModalHeader'; interface Contributor { profile: { @@ -37,45 +37,16 @@ export const ContributorModal: FC = ({ const totalAmount = contributors.reduce((sum, c) => sum + c.amount, 0); return ( - - + ); }; diff --git a/components/modals/CreateBountyModal.tsx b/components/modals/CreateBountyModal.tsx index 3154a8eb8..7cfdb5c7f 100644 --- a/components/modals/CreateBountyModal.tsx +++ b/components/modals/CreateBountyModal.tsx @@ -1,9 +1,8 @@ 'use client'; -import { Dialog, Transition } from '@headlessui/react'; -import { Fragment } from 'react'; import { BountyForm } from '@/components/Bounty/BountyForm'; -import Icon from '@/components/ui/icons/Icon'; +import { ModalContainer } from '@/components/ui/Modal/ModalContainer'; +import { ModalHeader } from '@/components/ui/Modal/ModalHeader'; interface CreateBountyModalProps { isOpen: boolean; @@ -13,64 +12,11 @@ interface CreateBountyModalProps { export function CreateBountyModal({ isOpen, onClose, workId }: CreateBountyModalProps) { return ( - - - -
- - -
-
- - -
-
-
-
- -
- - Create Bounty - -
- -
- -
-
-
-
-
-
-
+ +
+ + +
+
); } diff --git a/components/modals/DepositRSCView.tsx b/components/modals/DepositRSCView.tsx index 0110386ce..4a11f9a41 100644 --- a/components/modals/DepositRSCView.tsx +++ b/components/modals/DepositRSCView.tsx @@ -14,8 +14,7 @@ import { TransactionService } from '@/services/transaction.service'; import { getRSCForNetwork, NetworkType, TRANSFER_ABI, NETWORK_CONFIG } from '@/constants/tokens'; import { NetworkSelector } from '@/components/ui/NetworkSelector'; import { Alert } from '@/components/ui/Alert'; -import { Button } from '@/components/ui/Button'; -import { WalletDefault } from '@coinbase/onchainkit/wallet'; +import { CurrencyInput } from '@/components/ui/form/CurrencyInput'; const HOT_WALLET_ADDRESS_ENV = process.env.NEXT_PUBLIC_WEB3_WALLET_ADDRESS; if (!HOT_WALLET_ADDRESS_ENV || HOT_WALLET_ADDRESS_ENV.trim() === '') { @@ -41,12 +40,20 @@ type TransactionStatus = | { state: 'success'; txHash: string } | { state: 'error'; message: string }; +import { useAmountInput } from '@/hooks/useAmountInput'; + /** * Inline RSC deposit view for use within the contribution modal. * Renders the same content as DepositModal but without the modal wrapper. */ export function DepositRSCView({ currentBalance, onSuccess }: DepositRSCViewProps) { - const [amount, setAmount] = useState(''); + const { + amount: depositAmount, + setAmount: setAmountNum, + handleAmountChange, + getFormattedValue: getFormattedInputValue, + } = useAmountInput(); + const [selectedNetwork, setSelectedNetwork] = useState('BASE'); const [isInitiating, setIsDepositButtonDisabled] = useState(false); const { address } = useAccount(); @@ -66,7 +73,7 @@ export function DepositRSCView({ currentBalance, onSuccess }: DepositRSCViewProp useEffect(() => { // Reset state on mount setTxStatus({ state: 'idle' }); - setAmount(''); + setAmountNum(0); setSelectedNetwork('BASE'); setIsDepositButtonDisabled(false); hasCalledSuccessRef.current = false; @@ -74,15 +81,6 @@ export function DepositRSCView({ currentBalance, onSuccess }: DepositRSCViewProp processedTxHashRef.current = null; }, []); - const handleAmountChange = useCallback((e: React.ChangeEvent) => { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setAmount(value); - } - }, []); - - const depositAmount = useMemo(() => parseInt(amount || '0', 10), [amount]); - const calculateNewBalance = useCallback( (): number => currentBalance + depositAmount, [currentBalance, depositAmount] @@ -91,12 +89,11 @@ export function DepositRSCView({ currentBalance, onSuccess }: DepositRSCViewProp const isButtonDisabled = useMemo( () => !address || - !amount || depositAmount <= 0 || depositAmount > walletBalance || isInitiating || isMobile, - [address, amount, depositAmount, walletBalance, isInitiating, isMobile] + [address, depositAmount, walletBalance, isInitiating, isMobile] ); const isInputDisabled = useCallback(() => { @@ -319,7 +316,7 @@ export function DepositRSCView({ currentBalance, onSuccess }: DepositRSCViewProp
Amount to Deposit
- walletBalance ? 'Deposit amount exceeds your wallet balance.' : undefined} + currency="RSC" + onCurrencyToggle={() => {}} + label="" + className={isInputDisabled() ? 'bg-gray-100 cursor-not-allowed' : ''} /> -
- RSC -
- {depositAmount > walletBalance && ( -

Deposit amount exceeds your wallet balance.

- )}
{/* Balance Display */} diff --git a/components/modals/FundResearchModal.tsx b/components/modals/FundResearchModal.tsx index d5fb78fab..c4cdfa894 100644 --- a/components/modals/FundResearchModal.tsx +++ b/components/modals/FundResearchModal.tsx @@ -1,21 +1,23 @@ 'use client'; -import { Dialog, Transition } from '@headlessui/react'; -import { Fragment, useEffect, useState } from 'react'; +import { useState } from 'react'; import { Button } from '@/components/ui/Button'; -import { Input } from '@/components/ui/form/Input'; import Image from 'next/image'; -import { ArrowLeft, ArrowDownToLine, CreditCard, ChevronDown } from 'lucide-react'; +import { ArrowDownToLine, CreditCard } from 'lucide-react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faHexagonImage } from '@fortawesome/pro-solid-svg-icons'; import { Alert } from '@/components/ui/Alert'; -import { Tooltip } from '@/components/ui/Tooltip'; -import { cn } from '@/utils/styles'; import { useCreateContribution } from '@/hooks/useFundraise'; -import { useSession } from 'next-auth/react'; import { BalanceInfo } from './BalanceInfo'; import { ID } from '@/types/root'; import { useUser } from '@/contexts/UserContext'; +import { FeeBreakdown } from '../Bounty/lib/FeeBreakdown'; +import { CurrencyInput } from '@/components/ui/form/CurrencyInput'; +import { useAmountInput } from '@/hooks/useAmountInput'; +import { ModalContainer } from '@/components/ui/Modal/ModalContainer'; +import { ModalHeader } from '@/components/ui/Modal/ModalHeader'; +import { calculateBountyFees } from '../Bounty/lib/bountyUtil'; + interface FundResearchModalProps { isOpen: boolean; onClose: () => void; @@ -28,86 +30,6 @@ interface FundResearchModalProps { type Currency = 'RSC' | 'USD'; type Step = 'amount' | 'payment'; -const ModalHeader = ({ - title, - onClose, - onBack, -}: { - title: string; - onClose: () => void; - onBack?: () => void; -}) => ( -
-
-
- {onBack && ( - - )} - - {title} - -
- -
-
-); - -// Amount Step Components -const CurrencyInput = ({ - value, - onChange, - currency, - onCurrencyToggle, - convertedAmount, -}: { - value: string; - onChange: (e: React.ChangeEvent) => void; - currency: Currency; - onCurrencyToggle: () => void; - convertedAmount?: string; -}) => ( -
- - {currency} - - - - - } - /> - {convertedAmount &&
{convertedAmount}
} -
-); - // Payment Step Components const PaymentOption = ({ icon, @@ -118,7 +40,7 @@ const PaymentOption = ({ title: string; subtitle?: string; }) => ( - - - {isFeesExpanded && ( -
-
- ResearchHub DAO (2%) - {daoFee.toLocaleString()} RSC -
-
- ResearchHub Inc (7%) - {incFee.toLocaleString()} RSC -
-
- )} -
- -
- -
- Net research funding: - {baseAmount.toLocaleString()} RSC -
- -
- NFTs received: - {nftCount.toLocaleString()} -
-
-); const NFTPreview = ({ rscAmount, nftCount }: { rscAmount: number; nftCount: number }) => ( -
+
Your contribution: {rscAmount.toLocaleString()} RSC @@ -239,35 +76,23 @@ export function FundResearchModal({ const { user } = useUser(); const userBalance = user?.balance || 0; const [step, setStep] = useState('amount'); - const [inputAmount, setInputAmount] = useState(0); + + const { + amount: inputAmount, + handleAmountChange, + getFormattedValue: getFormattedInputValue, + } = useAmountInput(); + const [currency, setCurrency] = useState('RSC'); const [isFeesExpanded, setIsFeesExpanded] = useState(false); const RSC_TO_USD = 1; const NFT_THRESHOLD_USD = 1000; const [ - { data: contributionData, isLoading: isContributing, error: contributionError }, + { isLoading: isContributing, error: contributionError }, createContribution, ] = useCreateContribution(); - // Utility functions - const handleAmountChange = (e: React.ChangeEvent) => { - // Remove any non-numeric characters except decimal point - const rawValue = e.target.value.replace(/[^0-9.]/g, ''); - const numValue = parseFloat(rawValue); - - if (!isNaN(numValue)) { - setInputAmount(numValue); - } else { - setInputAmount(0); - } - }; - - const getFormattedInputValue = () => { - if (inputAmount === 0) return ''; - return inputAmount.toLocaleString(); - }; - const toggleCurrency = () => { setCurrency(currency === 'RSC' ? 'USD' : 'RSC'); }; @@ -294,15 +119,12 @@ export function FundResearchModal({ amount: getRscAmount(), amount_currency: currency, }); - onClose(); } catch (error) { - // Error is handled by the hook console.error('Contribution failed:', error); } }; - // Step rendering functions const renderAmountStep = () => (
@@ -350,6 +172,7 @@ export function FundResearchModal({ currency={currency} onCurrencyToggle={toggleCurrency} convertedAmount={getConvertedAmount()} + label="" /> {inputAmount > 1 && nftRewardsEnabled && ( @@ -372,11 +195,7 @@ export function FundResearchModal({ const renderPaymentStep = () => { const rscAmount = getRscAmount(); const insufficientBalance = userBalance < rscAmount; - - const platformFee = Math.floor(rscAmount * 0.09); - const daoFee = Math.floor(rscAmount * 0.02); - const incFee = Math.floor(rscAmount * 0.07); - const baseAmount = rscAmount - platformFee; + const { platformFee, daoFee, incFee, baseAmount } = calculateBountyFees(rscAmount); const nftCount = nftRewardsEnabled ? Math.floor(rscAmount / NFT_THRESHOLD_USD) : 0; return ( @@ -384,13 +203,12 @@ export function FundResearchModal({ setStep('amount')} />
setIsFeesExpanded(!isFeesExpanded)} />
@@ -412,7 +230,7 @@ export function FundResearchModal({ {contributionError && ( - {contributionError} + {contributionError instanceof Error ? contributionError.message : String(contributionError)} )} @@ -430,38 +248,8 @@ export function FundResearchModal({ }; return ( - - - -
- - -
-
- - - {step === 'amount' ? renderAmountStep() : renderPaymentStep()} - - -
-
-
-
+ + {step === 'amount' ? renderAmountStep() : renderPaymentStep()} + ); } diff --git a/components/modals/ResearchCoin/DepositModal.tsx b/components/modals/ResearchCoin/DepositModal.tsx index e22e0c5cd..719080c86 100644 --- a/components/modals/ResearchCoin/DepositModal.tsx +++ b/components/modals/ResearchCoin/DepositModal.tsx @@ -7,12 +7,12 @@ import { ResearchCoinIcon } from '@/components/ui/icons/ResearchCoinIcon'; import { useAccount } from 'wagmi'; import { useWalletRSCBalance } from '@/hooks/useWalletRSCBalance'; import { useIsMobile } from '@/hooks/useIsMobile'; +import { CurrencyInput } from '@/components/ui/form/CurrencyInput'; import { Transaction, TransactionButton } from '@coinbase/onchainkit/transaction'; import { Interface } from 'ethers'; import { TransactionService } from '@/services/transaction.service'; import { getRSCForNetwork, NetworkType, TRANSFER_ABI, NETWORK_CONFIG } from '@/constants/tokens'; import { Alert } from '@/components/ui/Alert'; -import { DepositSuccessView } from './DepositSuccessView'; import { NetworkSelectorSection } from './shared/NetworkSelectorSection'; import { BalanceDisplay } from './shared/BalanceDisplay'; import { TransactionFooter } from './shared/TransactionFooter'; @@ -46,8 +46,20 @@ type TransactionStatus = | { state: 'success'; txHash: string } | { state: 'error'; message: string }; +import { useAmountInput } from '@/hooks/useAmountInput'; + +import { ModalContainer } from '@/components/ui/Modal/ModalContainer'; +import { ModalHeader } from '@/components/ui/Modal/ModalHeader'; +import { DepositSuccessView } from './DepositSuccessView'; + export function DepositModal({ isOpen, onClose, currentBalance, onSuccess }: DepositModalProps) { - const [amount, setAmount] = useState(''); + const { + amount: depositAmount, + setAmount: setAmountNum, + handleAmountChange, + getFormattedValue: getFormattedInputValue, + } = useAmountInput(); + const [selectedNetwork, setSelectedNetwork] = useState('BASE'); const [isInitiating, isDepositButtonDisabled] = useState(false); const contentRef = useRef(null); @@ -78,7 +90,7 @@ export function DepositModal({ isOpen, onClose, currentBalance, onSuccess }: Dep useEffect(() => { if (isOpen) { setTxStatus({ state: 'idle' }); - setAmount(''); + setAmountNum(0); hasSetDefaultRef.current = false; isDepositButtonDisabled(false); hasCalledSuccessRef.current = false; @@ -88,7 +100,7 @@ export function DepositModal({ isOpen, onClose, currentBalance, onSuccess }: Dep // Delay reset to ensure modal closing animation completes const timeoutId = setTimeout(() => { setTxStatus({ state: 'idle' }); - setAmount(''); + setAmountNum(0); hasSetDefaultRef.current = false; isDepositButtonDisabled(false); hasCalledSuccessRef.current = false; @@ -98,7 +110,7 @@ export function DepositModal({ isOpen, onClose, currentBalance, onSuccess }: Dep return () => clearTimeout(timeoutId); } - }, [isOpen]); + }, [isOpen, setAmountNum]); // Smart default selection based on wallet balances useEffect(() => { @@ -120,18 +132,9 @@ export function DepositModal({ isOpen, onClose, currentBalance, onSuccess }: Dep const handleClose = useCallback(() => { setTxStatus({ state: 'idle' }); - setAmount(''); + setAmountNum(0); onClose(); - }, [onClose]); - - const handleAmountChange = useCallback((e: React.ChangeEvent) => { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setAmount(value); - } - }, []); - - const depositAmount = useMemo(() => parseInt(amount || '0', 10), [amount]); + }, [onClose, setAmountNum]); const calculateNewBalance = useCallback( (): number => currentBalance + depositAmount, @@ -141,12 +144,11 @@ export function DepositModal({ isOpen, onClose, currentBalance, onSuccess }: Dep const isButtonDisabled = useMemo( () => !address || - !amount || depositAmount <= 0 || depositAmount > walletBalance || isInitiating || isMobile, - [address, amount, depositAmount, walletBalance, isInitiating, isMobile] + [address, depositAmount, walletBalance, isInitiating, isMobile] ); const isInputDisabled = useCallback(() => { @@ -260,7 +262,7 @@ export function DepositModal({ isOpen, onClose, currentBalance, onSuccess }: Dep }; return [transferCall]; - }, [amount, depositAmount, walletBalance, rscToken.address]); + }, [depositAmount, walletBalance, rscToken.address]); const footer = useMemo(() => { const txHash = txStatus.state === 'success' ? txStatus.txHash : undefined; @@ -314,124 +316,117 @@ export function DepositModal({ isOpen, onClose, currentBalance, onSuccess }: Dep } return ( - -
- {txStatus.state === 'success' ? ( - - ) : ( - <> - {txStatus.state === 'error' && ( - -
-
Deposit failed
- {'message' in txStatus && txStatus.message && ( -
{txStatus.message}
- )} -
-
- )} - - {isMobile && ( - - Deposits are temporarily unavailable on mobile devices. Please use a desktop browser - to make deposits. - - )} - - {/* Network Selector */} - +
+ +
+ {txStatus.state === 'success' ? ( + + ) : ( + <> + {txStatus.state === 'error' && ( + +
+
Deposit failed
+ {'message' in txStatus && txStatus.message && ( +
{txStatus.message}
+ )} +
+
+ )} - {/* Wallet RSC Balance */} -
-
- Wallet Balance: -
- - {isWalletBalanceLoading ? ( - Loading... - ) : ( - <> - - {walletBalance.toFixed(2)} - - RSC - - )} + {isMobile && ( + + Deposits are temporarily unavailable on mobile devices. Please use a desktop browser + to make deposits. + + )} + + {/* Network Selector */} + + + {/* Wallet RSC Balance */} +
+
+ Wallet Balance: +
+ + {isWalletBalanceLoading ? ( + Loading... + ) : ( + <> + + {walletBalance.toFixed(2)} + + RSC + + )} +
-
- - {/* Amount Input */} -
-
- Amount to Deposit - -
-
- -
- RSC + + {/* Amount Input */} +
+
+ Amount to Deposit + +
+
+ walletBalance + ? 'Deposit amount exceeds your wallet balance.' + : undefined + } + currency="RSC" + onCurrencyToggle={() => {}} + label="" + className={isInputDisabled() ? 'bg-gray-100 cursor-not-allowed' : ''} + />
- {depositAmount > walletBalance && ( -

- Deposit amount exceeds your wallet balance. -

- )} -
- - {/* Balance Display */} - 0 && depositAmount <= walletBalance - ? calculateNewBalance() - : currentBalance - } - futureBalanceLabel="After Deposit" - futureBalanceColor={ - depositAmount > 0 && depositAmount <= walletBalance ? 'green' : 'gray' - } - /> - - )} + + {/* Balance Display */} + 0 && depositAmount <= walletBalance + ? calculateNewBalance() + : currentBalance + } + futureBalanceLabel="After Deposit" + futureBalanceColor={ + depositAmount > 0 && depositAmount <= walletBalance ? 'green' : 'gray' + } + /> + + {/* Deposit Button */} +
{footer}
+ + )} +
- + ); } diff --git a/components/modals/ResearchCoin/WithdrawModal.tsx b/components/modals/ResearchCoin/WithdrawModal.tsx index 5d25596fa..5b06ad8d2 100644 --- a/components/modals/ResearchCoin/WithdrawModal.tsx +++ b/components/modals/ResearchCoin/WithdrawModal.tsx @@ -13,7 +13,7 @@ import { NetworkSelectorSection } from './shared/NetworkSelectorSection'; import { BalanceDisplay } from './shared/BalanceDisplay'; import { TransactionFooter } from './shared/TransactionFooter'; import { Skeleton } from '@/components/ui/Skeleton'; -import { Input } from '@/components/ui/form/Input'; +import { CurrencyInput } from '@/components/ui/form/CurrencyInput'; import { Checkbox } from '@/components/ui/form/Checkbox'; import { Button } from '@/components/ui/Button'; import { Alert } from '@/components/ui/Alert'; @@ -32,13 +32,21 @@ interface WithdrawModalProps { onSuccess?: () => void; } +import { useAmountInput } from '@/hooks/useAmountInput'; + export function WithdrawModal({ isOpen, onClose, availableBalance, onSuccess, }: WithdrawModalProps) { - const [amount, setAmount] = useState(''); + const { + amount: withdrawAmount, + setAmount: setAmountNum, + handleAmountChange, + getFormattedValue: getFormattedInputValue, + } = useAmountInput(); + const [selectedNetwork, setSelectedNetwork] = useState('BASE'); const [addressMode, setAddressMode] = useState<'connected' | 'custom'>('connected'); const [customAddress, setCustomAddress] = useState(''); @@ -56,7 +64,7 @@ export function WithdrawModal({ if (!isOpen) { // Delay reset to ensure modal closing animation completes const timeoutId = setTimeout(() => { - setAmount(''); + setAmountNum(0); setSelectedNetwork('BASE'); setAddressMode('connected'); setCustomAddress(''); @@ -65,7 +73,7 @@ export function WithdrawModal({ return () => clearTimeout(timeoutId); } - }, [isOpen, resetTransaction]); + }, [isOpen, resetTransaction, setAmountNum]); const withdrawalAddress = useMemo(() => { return addressMode === 'connected' ? address : customAddress; @@ -89,15 +97,6 @@ export function WithdrawModal({ } }, [feeError]); - const handleAmountChange = useCallback((e: React.ChangeEvent) => { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setAmount(value); - } - }, []); - - const withdrawAmount = useMemo(() => parseInt(amount || '0', 10), [amount]); - const amountUserWillReceive = useMemo((): number => { if (!fee) return 0; return Math.max(0, withdrawAmount - fee); @@ -126,7 +125,6 @@ export function WithdrawModal({ // Determine if withdraw button should be disabled const isButtonDisabled = useMemo( () => - !amount || withdrawAmount <= 0 || txStatus.state === 'pending' || isFeeLoading || @@ -137,7 +135,6 @@ export function WithdrawModal({ !isCustomAddressValid || !withdrawalAddress, [ - amount, withdrawAmount, txStatus.state, isFeeLoading, @@ -157,18 +154,18 @@ export function WithdrawModal({ const handleMaxAmount = useCallback(() => { if (isInputDisabled() || !fee) return; const maxWithdrawAmount = Math.floor(availableBalance); - setAmount(maxWithdrawAmount > 0 ? maxWithdrawAmount.toString() : '0'); - }, [availableBalance, isInputDisabled, fee]); + setAmountNum(maxWithdrawAmount > 0 ? maxWithdrawAmount : 0); + }, [availableBalance, isInputDisabled, fee, setAmountNum]); const handleWithdraw = useCallback(async () => { - if (!withdrawalAddress || !amount || isButtonDisabled || !fee) { + if (!withdrawalAddress || withdrawAmount <= 0 || isButtonDisabled || !fee) { return; } const result = await withdrawRSC({ to_address: withdrawalAddress, agreed_to_terms: true, - amount: amount, + amount: withdrawAmount.toString(), network: selectedNetwork, }); @@ -177,7 +174,7 @@ export function WithdrawModal({ } }, [ withdrawalAddress, - amount, + withdrawAmount, isButtonDisabled, withdrawRSC, txStatus.state, @@ -275,35 +272,22 @@ export function WithdrawModal({
- {}} + label="" + className={isInputDisabled() ? 'bg-gray-100 cursor-not-allowed' : ''} /> -
- RSC -
- {isBelowMinimum && ( -

- Minimum withdrawal amount is {MIN_WITHDRAWAL_AMOUNT} RSC. -

- )} - {hasInsufficientBalance && ( -

- Withdrawal amount exceeds your available balance. -

- )}
{/* Fee Display */} diff --git a/components/modals/TipContentModal.tsx b/components/modals/TipContentModal.tsx index c0d932bdd..079444df1 100644 --- a/components/modals/TipContentModal.tsx +++ b/components/modals/TipContentModal.tsx @@ -1,180 +1,84 @@ 'use client'; -import { Dialog, Transition } from '@headlessui/react'; -import { Fragment, useState, useEffect, useCallback } from 'react'; +import { useState } from 'react'; import { Button } from '@/components/ui/Button'; -import { Input } from '@/components/ui/form/Input'; import { Alert } from '@/components/ui/Alert'; -import { cn } from '@/utils/styles'; -import { ID } from '@/types/root'; import { BalanceInfo } from './BalanceInfo'; -import { toast } from 'react-hot-toast'; import { useUser } from '@/contexts/UserContext'; import { useExchangeRate } from '@/contexts/ExchangeRateContext'; import { FeedContentType } from '@/types/feed'; -import { useTip } from '@/hooks/useTip'; // Import the useTip hook -import { formatRSC } from '@/utils/number'; +import { useTip } from '@/hooks/useTip'; +import { ModalContainer } from '@/components/ui/Modal/ModalContainer'; +import { ModalHeader } from '@/components/ui/Modal/ModalHeader'; +import { useAmountInput } from '@/hooks/useAmountInput'; +import { CurrencyInput } from '@/components/ui/form/CurrencyInput'; interface TipContentModalProps { isOpen: boolean; onClose: () => void; - onTipSuccess?: (amount: number) => void; // Added onTipSuccess prop - contentId: number; // ID of the content being tipped - feedContentType: FeedContentType; // Type of content being tipped - recipientName?: string; // Optional: Name of the recipient for display + onTipSuccess?: (amount: number) => void; + contentId: number; + feedContentType: FeedContentType; + recipientName?: string; } -// Currency Input Component (reusable, slightly modified label) -const CurrencyInput = ({ - value, - onChange, - error, -}: { - value: string; - onChange: (e: React.ChangeEvent) => void; - error?: string; -}) => { - return ( -
- - RSC -
- } - /> - {error &&

{error}

} -
- ); -}; - -// Modal Header Component (reusable) -const ModalHeader = ({ - title, - onClose, - subtitle, -}: { - title: string; - onClose: () => void; - subtitle?: string; -}) => ( -
-
-
- - {title} - - {subtitle &&

{subtitle}

} -
- -
-
-); - export function TipContentModal({ isOpen, onClose, onTipSuccess, contentId, feedContentType, - recipientName, // Optional recipient name + recipientName, }: TipContentModalProps) { const { user } = useUser(); const { exchangeRate, isLoading: isExchangeRateLoading } = useExchangeRate(); - const [inputAmount, setInputAmount] = useState(10); // Default tip amount - const [error, setError] = useState(null); // General error state - const [amountError, setAmountError] = useState(undefined); // Input specific error + + const { + amount: inputAmount, + error: amountError, + setAmount: setInputAmount, + setError: setAmountError, + handleAmountChange, + getFormattedValue: getFormattedInputValue, + } = useAmountInput({ + initialAmount: 10, + }); + + const [submissionError, setSubmissionError] = useState(null); const userBalance = user?.balance || 0; - // Use the tipping hook const { tip, isTipping } = useTip({ contentId, feedContentType, onTipSuccess: (response, tippedAmount) => { - // Call the passed-in success handler if (onTipSuccess) { onTipSuccess(tippedAmount); } - // Close the modal on success onClose(); }, onTipError: (err) => { - // Let the hook handle toast notifications, just set local error if needed - setError(err instanceof Error ? err.message : 'Failed to send tip.'); + setSubmissionError(err instanceof Error ? err.message : 'Failed to send tip.'); }, }); - // Handle amount input changes - const handleAmountChange = (e: React.ChangeEvent) => { - const rawValue = e.target.value.replace(/[^0-9.]/g, ''); - const numValue = parseFloat(rawValue); - - // Reset errors on change - setError(null); - setAmountError(undefined); - - if (!isNaN(numValue)) { - setInputAmount(numValue); - if (numValue <= 0) { - setAmountError('Tip amount must be positive'); - } - } else if (rawValue === '') { - setInputAmount(0); // Allow clearing the input - } else { - // Handle invalid input like multiple decimals etc. - setInputAmount(inputAmount); // Keep previous valid value - setAmountError('Please enter a valid amount'); - } - }; - - // Format the input value for display (e.g., with commas) - const getFormattedInputValue = () => { - if (inputAmount === 0 && !document.activeElement?.matches('input[name="amount"]')) return ''; // Show placeholder if 0 and not focused - return inputAmount.toLocaleString(); - }; - - // Handle the tip submission const handleTip = async () => { - setError(null); // Clear previous errors - setAmountError(undefined); - + setSubmissionError(null); if (inputAmount <= 0) { setAmountError('Tip amount must be positive'); return; } if (userBalance < inputAmount) { - setError('Insufficient balance to send tip.'); + setSubmissionError('Insufficient balance to send tip.'); return; } - // Call the tip function from the hook await tip(inputAmount); - // Success/error handling and modal closing is managed within the hook's callbacks }; const insufficientBalance = userBalance < inputAmount; - // Calculate USD equivalent for display const usdEquivalent = !isExchangeRateLoading && exchangeRate > 0 && inputAmount > 0 ? `≈ $${(inputAmount * exchangeRate).toFixed(2)} USD` @@ -185,84 +89,48 @@ export function TipContentModal({ : 'Send ResearchCoin for this contribution'; return ( - - - {/* Overlay */} - -
- - - {/* Modal Content */} -
-
- - -
- {/* Header */} - - -
- {/* Amount Input */} -
- - {!amountError && usdEquivalent && ( -
{usdEquivalent}
- )} -
- - {/* Balance Info */} -
- -
- - {/* Error Alert */} - {error && {error}} - - {/* Tip Button */} - -
-
-
-
+ +
+ + +
+
+ {}} + label="Tip Amount" + /> + {!amountError && usdEquivalent && ( +
{usdEquivalent}
+ )}
+ + + + {submissionError && {submissionError}} + +
-
-
+
+ ); } diff --git a/components/ui/Modal/ModalContainer.tsx b/components/ui/Modal/ModalContainer.tsx new file mode 100644 index 000000000..b73525815 --- /dev/null +++ b/components/ui/Modal/ModalContainer.tsx @@ -0,0 +1,77 @@ +'use client'; + +import { Dialog, Transition } from '@headlessui/react'; +import { Fragment, ReactNode } from 'react'; +import { cn } from '@/utils/styles'; + +interface ModalContainerProps { + isOpen: boolean; + onClose: () => void; + children: ReactNode; + maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl'; + className?: string; + zIndex?: number; +} + +const maxWidthClasses = { + sm: 'max-w-sm', + md: 'max-w-md', + lg: 'max-w-lg', + xl: 'max-w-xl', + '2xl': 'max-w-2xl', +}; + +export function ModalContainer({ + isOpen, + onClose, + children, + maxWidth = 'lg', + className, + zIndex = 100, +}: ModalContainerProps) { + return ( + + + + + + ); +} diff --git a/components/ui/Modal/ModalHeader.tsx b/components/ui/Modal/ModalHeader.tsx new file mode 100644 index 000000000..5ff48a4d3 --- /dev/null +++ b/components/ui/Modal/ModalHeader.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { Dialog } from '@headlessui/react'; +import { ArrowLeft, X } from 'lucide-react'; +import { cn } from '@/utils/styles'; + +interface ModalHeaderProps { + title: string; + subtitle?: string; + onClose: () => void; + onBack?: () => void; + className?: string; +} + +export function ModalHeader({ + title, + subtitle, + onClose, + onBack, + className, +}: ModalHeaderProps) { + return ( +
+
+
+ {onBack && ( + + )} +
+ + {title} + + {subtitle && ( +

+ {subtitle} +

+ )} +
+
+ +
+
+ ); +} diff --git a/components/work/FundDocument.tsx b/components/work/FundDocument.tsx index 81f0da910..ede93bb62 100644 --- a/components/work/FundDocument.tsx +++ b/components/work/FundDocument.tsx @@ -41,6 +41,10 @@ export const FundDocument = ({ const [showMobileMetrics, setShowMobileMetrics] = useState(false); const [showOverlay, setShowOverlay] = useState(false); const [overlayVisible, setOverlayVisible] = useState(false); + const [showAwardModal, setShowAwardModal] = useState(false); + const [selectedBountyId, setSelectedBountyId] = useState(undefined); + const [bountyComment, setBountyComment] = useState(null); + const storageKey = useStorageKey('rh-comments'); const { user } = useUser(); const { showShareModal } = useShareModalContext(); @@ -77,6 +81,31 @@ export const FundDocument = ({ setActiveTab(tab); }; + // Handle award bounty click + const handleAwardBounty = useCallback( + (bountyId: number) => { + const bounty = metadata.bounties?.find((b) => b.id === bountyId); + if (bounty?.comment) { + // Transform BountyComment to Comment for the modal + const transformedBountyComment: any = { + id: bounty.comment.id, + content: bounty.comment.content, + contentFormat: bounty.comment.contentFormat, + commentType: bounty.comment.commentType, + createdBy: bounty.createdBy, + bounties: [bounty], + thread: { + objectId: work.id, + }, + }; + setBountyComment(transformedBountyComment); + setSelectedBountyId(bountyId); + setShowAwardModal(true); + } + }, + [metadata.bounties, work.id] + ); + useEffect(() => { if (showMobileMetrics) { setShowOverlay(true); @@ -193,7 +222,11 @@ export const FundDocument = ({
{/* Show on mobile only - desktop shows in right sidebar */}
- +
{/* Title & Actions */} {work.type === 'preprint' && ( @@ -207,6 +240,7 @@ export const FundDocument = ({ work={work} metadata={metadata} showClaimButton={false} + onAwardClick={handleAwardBounty} insightsButton={ - +
+ + {/* Award Bounty Modal */} + {showAwardModal && bountyComment && ( + { + setShowAwardModal(false); + setSelectedBountyId(undefined); + setBountyComment(null); + }} + comment={bountyComment} + contentType={work.contentType} + bountyId={selectedBountyId} + /> + )}
); }; diff --git a/components/work/FundingRightSidebar.tsx b/components/work/FundingRightSidebar.tsx index 5000422fa..0383682ec 100644 --- a/components/work/FundingRightSidebar.tsx +++ b/components/work/FundingRightSidebar.tsx @@ -12,12 +12,21 @@ import { EarningOpportunityBanner } from '@/components/banners/EarningOpportunit interface FundingRightSidebarProps { work: Work; metadata: WorkMetadata; + onAwardBounty?: (bountyId: number) => void; } -export const FundingRightSidebar = ({ work, metadata }: FundingRightSidebarProps) => { +export const FundingRightSidebar = ({ + work, + metadata, + onAwardBounty, +}: FundingRightSidebarProps) => { return (
- + {metadata.fundraising && } {metadata.fundraising && } {metadata.fundraising && diff --git a/components/work/PostDocument.tsx b/components/work/PostDocument.tsx index aa6d57b58..d4ebe0b89 100644 --- a/components/work/PostDocument.tsx +++ b/components/work/PostDocument.tsx @@ -1,7 +1,8 @@ 'use client'; import { useState, useMemo, useCallback, useEffect } from 'react'; -import { Work } from '@/types/work'; +import { MessageCircleQuestion } from 'lucide-react'; +import { Work, Comment } from '@/types/work'; import { WorkMetadata } from '@/services/metadata.service'; import { WorkLineItems } from './WorkLineItems'; import { PageHeader } from '@/components/ui/PageHeader'; @@ -11,6 +12,8 @@ import { PostBlockEditor } from './PostBlockEditor'; import { EarningOpportunityBanner } from '@/components/banners/EarningOpportunityBanner'; import { ReviewStatusBanner } from '@/components/Bounty/ReviewStatusBanner'; import { QuestionEditModal } from '@/components/modals/QuestionEditModal'; +import { AwardBountyModal } from '@/components/Comment/AwardBountyModal'; +import { useUser } from '@/contexts/UserContext'; import TipTapRenderer from '@/components/Comment/lib/TipTapRenderer'; import { htmlToTipTapJSON } from '@/components/Comment/lib/htmlToTipTap'; @@ -29,8 +32,13 @@ export const PostDocument = ({ }: PostDocumentProps) => { const [activeTab, setActiveTab] = useState(defaultTab); const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [showAwardModal, setShowAwardModal] = useState(false); + const [selectedBountyId, setSelectedBountyId] = useState(undefined); + const [bountyComment, setBountyComment] = useState(null); const [parsedQuestionContent, setParsedQuestionContent] = useState(null); + const { user } = useUser(); + // Parse question content on client side useEffect(() => { if (work.postType === 'QUESTION' && work.previewContent) { @@ -48,6 +56,31 @@ export const PostDocument = ({ setIsEditModalOpen(true); }, []); + // Handle award bounty click + const handleAwardBounty = useCallback( + (bountyId: number) => { + const bounty = metadata.bounties?.find((b) => b.id === bountyId); + if (bounty?.comment) { + // Transform BountyComment to Comment for the modal + const transformedBountyComment: any = { + id: bounty.comment.id, + content: bounty.comment.content, + contentFormat: bounty.comment.contentFormat, + commentType: bounty.comment.commentType, + createdBy: bounty.createdBy, + bounties: [bounty], + thread: { + objectId: work.id, + }, + }; + setBountyComment(transformedBountyComment); + setSelectedBountyId(bountyId); + setShowAwardModal(true); + } + }, + [metadata.bounties, work.id] + ); + // Render tab content based on activeTab const renderTabContent = useMemo(() => { @@ -136,7 +169,11 @@ export const PostDocument = ({
{/* Show on mobile only - desktop shows in right sidebar */}
- +
{/* Title & Actions */} {work.type === 'preprint' && ( @@ -145,19 +182,63 @@ export const PostDocument = ({
)} - - - {/* Tabs */} - - {/* Tab Content */} - {renderTabContent} + {/* Tabs - Only show for non-questions, or if you're not on the main question content */} + {work.postType !== 'QUESTION' && ( + + )} + + {/* Tab Content / Question Content */} + {work.postType === 'QUESTION' ? ( +
+ {/* Question Body */} +
+ {parsedQuestionContent ? ( + + ) : ( +
+
+
+
+ )} +
+ + {/* Answers (Comment Feed) */} +
+
+

+ + Answers + + {metadata.metrics.conversationComments || 0} + +

+
+ +
+
+ ) : ( + renderTabContent + )} {/* Question Edit Modal */} setIsEditModalOpen(false)} work={work} /> + + {/* Award Bounty Modal */} + {showAwardModal && bountyComment && ( + { + setShowAwardModal(false); + setSelectedBountyId(undefined); + setBountyComment(null); + }} + comment={bountyComment} + contentType={work.contentType} + bountyId={selectedBountyId} + /> + )}
); }; diff --git a/components/work/WorkDocument.tsx b/components/work/WorkDocument.tsx index 29d5d9ffb..130c03777 100644 --- a/components/work/WorkDocument.tsx +++ b/components/work/WorkDocument.tsx @@ -13,6 +13,7 @@ import { History, Plus, X, + MessageCircleQuestion, } from 'lucide-react'; import { Work, DocumentVersion } from '@/types/work'; import { useRouter, useSearchParams, usePathname } from 'next/navigation'; @@ -60,6 +61,9 @@ export const WorkDocument = ({ work, metadata, defaultTab = 'paper' }: WorkDocum const [rewardModalOpen, setRewardModalOpen] = useState(false); const [showMobileMetrics, setShowMobileMetrics] = useState(false); const [pdfUnavailable, setPdfUnavailable] = useState(false); + const [showAwardModal, setShowAwardModal] = useState(false); + const [selectedBountyId, setSelectedBountyId] = useState(undefined); + const [bountyComment, setBountyComment] = useState(null); // Determine if we should auto focus the review editor based on query param const shouldFocusReviewEditor = useMemo(() => { @@ -76,6 +80,31 @@ export const WorkDocument = ({ work, metadata, defaultTab = 'paper' }: WorkDocum setActiveTab(tab); }, []); + // Handle award bounty click + const handleAwardBounty = useCallback( + (bountyId: number) => { + const bounty = metadata.bounties?.find((b) => b.id === bountyId); + if (bounty?.comment) { + // Transform BountyComment to Comment for the modal + const transformedBountyComment: any = { + id: bounty.comment.id, + content: bounty.comment.content, + contentFormat: bounty.comment.contentFormat, + commentType: bounty.comment.commentType, + createdBy: bounty.createdBy, + bounties: [bounty], + thread: { + objectId: work.id, + }, + }; + setBountyComment(transformedBountyComment); + setSelectedBountyId(bountyId); + setShowAwardModal(true); + } + }, + [metadata.bounties, work.id] + ); + const isAuthor = useMemo(() => { if (!user) return false; return work.authors?.some((a) => a.authorProfile.id === user!.authorProfile!.id); @@ -255,7 +284,11 @@ export const WorkDocument = ({ work, metadata, defaultTab = 'paper' }: WorkDocum
{/* Show on mobile only - desktop shows in right sidebar */}
- +
{/* Title & Actions */} @@ -263,6 +296,7 @@ export const WorkDocument = ({ work, metadata, defaultTab = 'paper' }: WorkDocum - +
+ + {/* Award Bounty Modal */} + {showAwardModal && bountyComment && ( + { + setShowAwardModal(false); + setSelectedBountyId(undefined); + setBountyComment(null); + }} + comment={bountyComment} + contentType={work.contentType} + bountyId={selectedBountyId} + /> + )}
); }; diff --git a/components/work/WorkLineItems.tsx b/components/work/WorkLineItems.tsx index 90372efbc..c7fbebe64 100644 --- a/components/work/WorkLineItems.tsx +++ b/components/work/WorkLineItems.tsx @@ -12,6 +12,7 @@ import { Download, ArrowUp, ArrowDown, + Trophy, } from 'lucide-react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faBookmark } from '@fortawesome/free-regular-svg-icons'; @@ -50,6 +51,7 @@ interface WorkLineItemsProps { insightsButton?: React.ReactNode; metadata: WorkMetadata; onEditClick?: () => void; + onAwardClick?: (bountyId: number) => void; } export const WorkLineItems = ({ @@ -58,6 +60,7 @@ export const WorkLineItems = ({ insightsButton, metadata, onEditClick, + onAwardClick, }: WorkLineItemsProps) => { const [isTipModalOpen, setIsTipModalOpen] = useState(false); const [isPublishing, setIsPublishing] = useState(false); @@ -190,6 +193,26 @@ export const WorkLineItems = ({ user?.authorProfile != null && work.authors?.some((a) => a.authorProfile.id === user.authorProfile?.id); + // Check if current user is the creator of any active bounty + const userBounties = useMemo(() => { + if (!user?.id || !metadata.bounties) return []; + return metadata.bounties.filter( + (b: Bounty) => b.createdBy.id === user.id && (b.status === 'OPEN' || b.status === 'ASSESSMENT') + ); + }, [user?.id, metadata.bounties]); + + const canAward = userBounties.length > 0; + + const handleAwardBounty = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + if (onAwardClick && userBounties.length > 0) { + onAwardClick(userBounties[0].id); + } + }, + [onAwardClick, userBounties] + ); + const handleEdit = useCallback(() => { if (work.contentType === 'paper' && (isModerator || isHubEditor)) { setIsWorkEditModalOpen(true); @@ -441,6 +464,20 @@ export const WorkLineItems = ({ {/* Render insights button if provided */} {insightsButton} + {/* Award Bounty Button - Shown for bounty creators */} + {canAward && ( + + )} + {/* More Actions Dropdown */} void; } -export const WorkRightSidebar = ({ work, metadata }: WorkRightSidebarProps) => { +export const WorkRightSidebar = ({ work, metadata, onAwardBounty }: WorkRightSidebarProps) => { // Check if any version is part of the ResearchHub journal const hasResearchHubJournalVersions = useMemo(() => { return (work.versions || []).some((version) => version.isResearchHubJournal); @@ -27,7 +28,11 @@ export const WorkRightSidebar = ({ work, metadata }: WorkRightSidebarProps) => { return (
- + {hasResearchHubJournalVersions && ( )} diff --git a/hooks/useAmountInput.ts b/hooks/useAmountInput.ts new file mode 100644 index 000000000..7818779d0 --- /dev/null +++ b/hooks/useAmountInput.ts @@ -0,0 +1,62 @@ +import { useState, useCallback } from 'react'; + +interface UseAmountInputOptions { + initialAmount?: number; + minAmount?: number; + validate?: (amount: number) => string | undefined; +} + +export function useAmountInput({ + initialAmount = 0, + minAmount = 0, + validate, +}: UseAmountInputOptions = {}) { + const [amount, setAmount] = useState(initialAmount); + const [error, setError] = useState(); + const [hasInteracted, setHasInteracted] = useState(false); + + const handleAmountChange = useCallback((e: React.ChangeEvent) => { + const rawValue = e.target.value.replace(/[^0-9.]/g, ''); + const numValue = parseFloat(rawValue); + + if (!hasInteracted) { + setHasInteracted(true); + } + + if (rawValue === '') { + setAmount(0); + setError(undefined); + return; + } + + if (!isNaN(numValue)) { + setAmount(numValue); + + if (validate) { + setError(validate(numValue)); + } else if (numValue < minAmount) { + setError(`Minimum amount is ${minAmount}`); + } else { + setError(undefined); + } + } else { + setAmount(0); + setError('Please enter a valid amount'); + } + }, [hasInteracted, minAmount, validate]); + + const getFormattedValue = useCallback(() => { + if (amount === 0) return ''; + return amount.toLocaleString(); + }, [amount]); + + return { + amount, + setAmount, + error, + setError, + handleAmountChange, + getFormattedValue, + hasInteracted, + }; +} diff --git a/hooks/useBounties.ts b/hooks/useBounties.ts index 14f11c157..ad447c49d 100644 --- a/hooks/useBounties.ts +++ b/hooks/useBounties.ts @@ -67,6 +67,7 @@ export const useBounties = () => { const [selectedHubs, setSelectedHubs] = useState([]); const selectedHubsRef = useRef([]); const [sort, setSort] = useState(sortFromUrl); + const [bountyFilter, setBountyFilter] = useState<'ALL' | 'FOUNDATION' | 'COMMUNITY'>('ALL'); const previousHubsParamRef = useRef(''); const hasInitialFetchRef = useRef(false); const previousSortRef = useRef(sortFromUrl); @@ -236,6 +237,22 @@ export const useBounties = () => { router.replace(`?${params.toString()}`, { scroll: false }); }; + const handleBountyFilterChange = (filter: 'ALL' | 'FOUNDATION' | 'COMMUNITY') => { + setBountyFilter(filter); + }; + + const filteredEntries = useMemo(() => { + if (bountyFilter === 'ALL') return entries; + + return entries.filter((entry) => { + const bounties = entry.content.bounties || []; + const hasFoundation = bounties.some((b) => b.createdBy?.isOfficialAccount); + if (bountyFilter === 'FOUNDATION') return hasFoundation; + if (bountyFilter === 'COMMUNITY') return !hasFoundation; + return true; + }); + }, [entries, bountyFilter]); + const loadMore = () => { fetchBounties(); }; @@ -294,12 +311,14 @@ export const useBounties = () => { }, [event, router, searchParams]); return { - entries, + entries: filteredEntries, isLoading, hasMore, loadMore, sort, handleSortChange, + bountyFilter, + handleBountyFilterChange, selectedHubs, handleHubsChange, restoredScrollPosition, diff --git a/types/bounty.ts b/types/bounty.ts index 340bf8515..d635c27d2 100644 --- a/types/bounty.ts +++ b/types/bounty.ts @@ -3,6 +3,7 @@ import { BaseTransformer } from './transformer'; import { User, transformUser } from './user'; export type BountyType = 'REVIEW' | 'ANSWER' | 'BOUNTY' | 'GENERIC_COMMENT'; +export type BountyStatus = 'OPEN' | 'CLOSED' | 'ASSESSMENT' | 'EXPIRED' | 'CANCELLED'; export type SolutionStatus = 'AWARDED' | 'PENDING'; export type ContributionStatus = 'ACTIVE' | 'REFUNDED'; @@ -36,7 +37,7 @@ export interface BountyComment { export interface Bounty { id: number; amount: string; - status: 'OPEN' | 'CLOSED' | 'ASSESSMENT'; + status: BountyStatus; expirationDate?: string; bountyType: BountyType; createdBy: User; diff --git a/types/user.ts b/types/user.ts index d52888685..899dec2fe 100644 --- a/types/user.ts +++ b/types/user.ts @@ -21,6 +21,7 @@ export interface User { moderator: boolean; editorOfHubs?: Hub[]; isModerator?: boolean; + isOfficialAccount?: boolean; referralCode?: string; authProvider?: 'google' | 'credentials'; } @@ -78,6 +79,7 @@ const baseTransformUser = (raw: any): User => { moderator: raw.moderator || false, editorOfHubs: editorOfHubs, isModerator: raw.moderator || false, + isOfficialAccount: raw.is_official_account || false, referralCode: raw.referral_code || undefined, authProvider: raw.auth_provider ? raw.auth_provider === 'google'