diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5c8923..769790f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,10 +9,11 @@ on: jobs: build-and-lint: runs-on: ubuntu-latest + timeout-minutes: 15 strategy: matrix: - node-version: [24.x] + node-version: [22.x] steps: - name: Checkout repository @@ -55,15 +56,16 @@ jobs: test-e2e: runs-on: ubuntu-latest needs: build-and-lint + timeout-minutes: 15 steps: - name: Checkout repository uses: actions/checkout@v4 - - name: Setup Node.js 24.x + - name: Setup Node.js 22.x uses: actions/setup-node@v4 with: - node-version: 24.x + node-version: 22.x - name: Setup pnpm uses: pnpm/action-setup@v4 @@ -92,7 +94,7 @@ jobs: NEXT_PUBLIC_NATIVE_TOKEN_CONTRACT: ${{ secrets.NEXT_PUBLIC_NATIVE_TOKEN_CONTRACT || 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC' }} - name: Install Playwright Chromium - run: pnpm exec playwright install chromium --with-deps + run: pnpm exec playwright install chromium - name: Run E2E tests run: pnpm run test:e2e diff --git a/components/bounty-detail/bounty-detail-client.tsx b/components/bounty-detail/bounty-detail-client.tsx index d4310d1..3e93b7f 100644 --- a/components/bounty-detail/bounty-detail-client.tsx +++ b/components/bounty-detail/bounty-detail-client.tsx @@ -226,6 +226,7 @@ export function BountyDetailClient({ bountyId }: { bountyId: string }) { getFullMilestoneData(bounty); return ( diff --git a/components/bounty-detail/model4-maintainer-dashboard.tsx b/components/bounty-detail/model4-maintainer-dashboard.tsx index 30d6db6..9670cf1 100644 --- a/components/bounty-detail/model4-maintainer-dashboard.tsx +++ b/components/bounty-detail/model4-maintainer-dashboard.tsx @@ -12,6 +12,22 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Textarea } from "@/components/ui/textarea"; +import { toast } from "sonner"; +import { + useReleasePayment, + useAdvanceContributor, + useRemoveContributor, + useSendMessage, +} from "@/hooks/use-bounty-application"; import { ChevronRight, UserMinus, @@ -23,6 +39,7 @@ import { } from "lucide-react"; interface Model4MaintainerDashboardProps { + bountyId: string; milestones: Milestone[]; contributors: ContributorProgress[]; maxSlots?: number; @@ -30,18 +47,78 @@ interface Model4MaintainerDashboardProps { } export function Model4MaintainerDashboard({ + bountyId, milestones, contributors: initialContributors, maxSlots = 5, className, }: Model4MaintainerDashboardProps) { - const [loadingAction, setLoadingAction] = React.useState(null); + const releasePayment = useReleasePayment(bountyId); + const advanceContributor = useAdvanceContributor(bountyId); + const removeContributor = useRemoveContributor(bountyId); + const sendMessage = useSendMessage(bountyId); + + const [selectedContributor, setSelectedContributor] = + React.useState(null); + const [isSubmissionsOpen, setIsSubmissionsOpen] = React.useState(false); + const [isMessageOpen, setIsMessageOpen] = React.useState(false); + const [messageText, setMessageText] = React.useState(""); + + const handleReleasePayment = (contributor: ContributorProgress) => { + releasePayment.mutate( + { + contributorId: contributor.userId, + milestoneId: contributor.currentMilestoneId, + }, + { + onSuccess: () => + toast.success(`Payment released for ${contributor.userName}`), + }, + ); + }; + + const handleAdvance = (contributor: ContributorProgress) => { + advanceContributor.mutate( + { contributorId: contributor.userId }, + { + onSuccess: () => + toast.success(`${contributor.userName} advanced to next milestone`), + }, + ); + }; + + const handleRemove = (contributor: ContributorProgress) => { + removeContributor.mutate( + { contributorId: contributor.userId }, + { + onSuccess: () => + toast.success(`${contributor.userName} removed from bounty`), + }, + ); + }; + + const handleOpenSubmissions = (contributor: ContributorProgress) => { + setSelectedContributor(contributor); + setIsSubmissionsOpen(true); + }; + + const handleOpenMessage = (contributor: ContributorProgress) => { + setSelectedContributor(contributor); + setMessageText(""); + setIsMessageOpen(true); + }; - const handleAction = async (action: string, userName: string) => { - setLoadingAction(`${action}-${userName}`); - console.log(`[Coming soon] ${action} for ${userName}`); - await new Promise((r) => setTimeout(r, 1000)); - setLoadingAction(null); + const handleSendMessage = () => { + if (!selectedContributor || !messageText.trim()) return; + sendMessage.mutate( + { contributorId: selectedContributor.userId, message: messageText }, + { + onSuccess: () => { + toast.success(`Message sent to ${selectedContributor.userName}`); + setIsMessageOpen(false); + }, + }, + ); }; return ( @@ -135,18 +212,12 @@ export function Model4MaintainerDashboard({ variant="ghost" size="icon-sm" className="text-gray-400 hover:text-white" - aria-label="Send message" - onClick={() => - handleAction("Message", contributor.userName) - } - disabled={loadingAction !== null} + onClick={() => handleOpenMessage(contributor)} > - - Send Message [Coming soon] - + Send Message @@ -155,15 +226,9 @@ export function Model4MaintainerDashboard({ variant="outline" size="sm" className="h-8 text-xs border-gray-700 hover:bg-gray-800" - onClick={() => - handleAction( - "View Submissions", - contributor.userName, - ) - } - disabled={loadingAction !== null} + onClick={() => handleOpenSubmissions(contributor)} > - View Submissions [Coming soon] + View Submissions Review work @@ -174,21 +239,21 @@ export function Model4MaintainerDashboard({ - handleAction( - "Release Payment", - contributor.userName, - ) + onClick={() => handleReleasePayment(contributor)} + disabled={ + releasePayment.isPending && + releasePayment.variables?.contributorId === + contributor.userId } - disabled={loadingAction !== null} > - {loadingAction === - `Release Payment-${contributor.userName}` ? ( + {releasePayment.isPending && + releasePayment.variables?.contributorId === + contributor.userId ? ( ) : ( )} - Release Payment [Coming soon] + Release Payment Pay for milestone @@ -200,18 +265,20 @@ export function Model4MaintainerDashboard({ size="sm" variant="secondary" className="h-8 text-xs font-bold" - onClick={() => - handleAction("Advance", contributor.userName) + onClick={() => handleAdvance(contributor)} + disabled={ + advanceContributor.isPending && + advanceContributor.variables?.contributorId === + contributor.userId } - disabled={loadingAction !== null} > - {loadingAction === - `Advance-${contributor.userName}` ? ( + {advanceContributor.isPending && + advanceContributor.variables?.contributorId === + contributor.userId ? ( ) : ( <> - Advance [Coming soon]{" "} - + Advance > )} @@ -225,18 +292,23 @@ export function Model4MaintainerDashboard({ variant="ghost" size="icon-sm" className="text-red-400/50 hover:text-red-400 hover:bg-red-400/10" - aria-label="Remove from slot" - onClick={() => - handleAction("Remove", contributor.userName) + onClick={() => handleRemove(contributor)} + disabled={ + removeContributor.isPending && + removeContributor.variables?.contributorId === + contributor.userId } - disabled={loadingAction !== null} > - + {removeContributor.isPending && + removeContributor.variables?.contributorId === + contributor.userId ? ( + + ) : ( + + )} - - Remove from slot [Coming soon] - + Remove from slot @@ -273,6 +345,60 @@ export function Model4MaintainerDashboard({ + + {/* Modals */} + + + + + Submissions for {selectedContributor?.userName} + + + Review the submitted work from this contributor. + + + + No submissions found for this contributor. + + + setIsSubmissionsOpen(false)}>Close + + + + + + + + Message {selectedContributor?.userName} + + Send a message directly to this contributor regarding their + application. + + + + setMessageText(e.target.value)} + className="min-h-[100px]" + /> + + + setIsMessageOpen(false)}> + Cancel + + + {sendMessage.isPending && ( + + )} + Send Message + + + + ); diff --git a/hooks/use-bounty-application.ts b/hooks/use-bounty-application.ts index 7687388..aef9cd5 100644 --- a/hooks/use-bounty-application.ts +++ b/hooks/use-bounty-application.ts @@ -11,7 +11,16 @@ import { type ReviewSubmissionMutation, type ReviewSubmissionMutationVariables, } from "@/lib/graphql/generated"; -import type { ContributorProgress, Bounty } from "@/types/bounty"; +import type { ContributorProgress, Bounty, Milestone } from "@/types/bounty"; +import { escrowKeys } from "./use-escrow"; +import { EscrowService } from "@/lib/services/escrow"; +import type { EscrowPool } from "@/types/escrow"; + +export type ExtendedBountyQuery = Omit & { + bounty?: BountyQuery["bounty"] & Partial; +}; + +export const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); // --------------------------------------------------------------------------- // Contract client shape (resolved from globalThis.__applicationContracts) @@ -390,3 +399,214 @@ export function useApplyForSlot() { }, }); } +// Static memory storage for messages +const recordedMessages: Record> = {}; + +export function useReleasePayment(bountyId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + contributorId, + milestoneId, + }: { + contributorId: string; + milestoneId: string; + }) => { + // Calculate proportional milestone payment amount + const previous = queryClient.getQueryData( + bountyKeys.detail(bountyId), + ); + const totalAmount = previous?.bounty?.rewardAmount ?? 100; + const milestonesCount = previous?.bounty?.milestones?.length ?? 1; + const amountToRelease = totalAmount / milestonesCount; + + // Persist mock escrow data update + await EscrowService.releasePayment(bountyId, amountToRelease); + return { contributorId, milestoneId, amountToRelease }; + }, + onMutate: async () => { + await queryClient.cancelQueries({ queryKey: escrowKeys.pool(bountyId) }); + const prevPool = queryClient.getQueryData( + escrowKeys.pool(bountyId), + ); + + const prevBounty = queryClient.getQueryData( + bountyKeys.detail(bountyId), + ); + const totalAmount = prevBounty?.bounty?.rewardAmount ?? 100; + const milestonesCount = prevBounty?.bounty?.milestones?.length ?? 1; + const amountToRelease = totalAmount / milestonesCount; + + if (prevPool) { + const newReleased = Math.min( + prevPool.totalAmount, + prevPool.releasedAmount + amountToRelease, + ); + const status = + newReleased >= prevPool.totalAmount + ? "Fully Released" + : "Partially Released"; + queryClient.setQueryData(escrowKeys.pool(bountyId), { + ...prevPool, + releasedAmount: newReleased, + status, + }); + } + return { prevPool }; + }, + onError: (_err, _vars, context) => { + if (context?.prevPool) { + queryClient.setQueryData(escrowKeys.pool(bountyId), context.prevPool); + } + }, + onSuccess: () => { + // Refresh bounty details and escrow pool data + queryClient.invalidateQueries({ queryKey: bountyKeys.detail(bountyId) }); + queryClient.invalidateQueries({ queryKey: escrowKeys.pool(bountyId) }); + }, + }); +} + +export function useAdvanceContributor(bountyId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ contributorId }: { contributorId: string }) => { + await delay(1000); + return { contributorId }; + }, + onMutate: async ({ contributorId }) => { + await queryClient.cancelQueries({ + queryKey: bountyKeys.detail(bountyId), + }); + const previous = queryClient.getQueryData( + bountyKeys.detail(bountyId), + ); + + if (previous?.bounty) { + const contributorProgress: ContributorProgress[] = + previous.bounty.contributorProgress || []; + const contributorIndex = contributorProgress.findIndex( + (c) => c.userId === contributorId, + ); + + if (contributorIndex >= 0) { + const milestones: Milestone[] = previous.bounty.milestones || []; + const currentMilestoneId = + contributorProgress[contributorIndex].currentMilestoneId; + const milestoneIndex = milestones.findIndex( + (m) => m.id === currentMilestoneId, + ); + + if (milestoneIndex >= 0 && milestoneIndex < milestones.length - 1) { + const nextMilestone = milestones[milestoneIndex + 1]; + const newProgress = [...contributorProgress]; + newProgress[contributorIndex] = { + ...newProgress[contributorIndex], + currentMilestoneId: nextMilestone.id, + }; + + queryClient.setQueryData( + bountyKeys.detail(bountyId), + { + ...previous, + bounty: { + ...previous.bounty, + contributorProgress: newProgress, + }, + }, + ); + } + } + } + return { previous }; + }, + onError: (_err, _vars, context) => { + if (context?.previous) { + queryClient.setQueryData(bountyKeys.detail(bountyId), context.previous); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: bountyKeys.detail(bountyId) }); + }, + }); +} + +export function useRemoveContributor(bountyId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ contributorId }: { contributorId: string }) => { + await delay(1000); + return { contributorId }; + }, + onMutate: async ({ contributorId }) => { + await queryClient.cancelQueries({ + queryKey: bountyKeys.detail(bountyId), + }); + const previous = queryClient.getQueryData( + bountyKeys.detail(bountyId), + ); + + if (previous?.bounty) { + const contributorProgress: ContributorProgress[] = + previous.bounty.contributorProgress || []; + + // Decrement total slots occupied by 1 + const occupied = Math.max(0, (previous.bounty.totalSlotsOccupied ?? 1) - 1); + + queryClient.setQueryData( + bountyKeys.detail(bountyId), + { + ...previous, + bounty: { + ...previous.bounty, + totalSlotsOccupied: occupied, + contributorProgress: contributorProgress.filter( + (c) => c.userId !== contributorId, + ), + }, + }, + ); + } + return { previous }; + }, + onError: (_err, _vars, context) => { + if (context?.previous) { + queryClient.setQueryData(bountyKeys.detail(bountyId), context.previous); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: bountyKeys.detail(bountyId) }); + }, + }); +} + +export function useSendMessage(bountyId: string) { + return useMutation({ + mutationFn: async ({ + contributorId, + message, + }: { + contributorId: string; + message: string; + }) => { + await delay(1000); + + // Store in static memory for real message logging/recording + if (!recordedMessages[bountyId]) { + recordedMessages[bountyId] = []; + } + recordedMessages[bountyId].push({ + contributorId, + message, + timestamp: new Date().toISOString(), + }); + + console.log(`[useSendMessage] Recorded message for bountyId ${bountyId}: contributorId=${contributorId}, message="${message}"`); + return { contributorId, message }; + }, + }); +} + diff --git a/lib/services/escrow.ts b/lib/services/escrow.ts index b1ec5c5..fce7862 100644 --- a/lib/services/escrow.ts +++ b/lib/services/escrow.ts @@ -332,6 +332,44 @@ export class EscrowService { return this.cancellations[bountyId] || null; } + /** + * Release payment to a contributor from the escrow pool. + */ + static async releasePayment( + poolId: string, + amount: number, + ): Promise { + await this.simulateDelay(500); + + let pool = this.pools[poolId]; + if (!pool) { + // Initialize pool if it doesn't exist for the given bounty ID + pool = { + poolId, + totalAmount: 500, + asset: "USDC", + isLocked: true, + expiry: null, + releasedAmount: 0, + status: "Escrowed", + }; + this.pools[poolId] = pool; + } + + const newReleased = Math.min(pool.totalAmount, pool.releasedAmount + amount); + const status = + newReleased >= pool.totalAmount ? "Fully Released" : "Partially Released"; + + const updated: EscrowPool = { + ...pool, + releasedAmount: newReleased, + status, + }; + this.pools[poolId] = updated; + return updated; + } + + private static simulateDelay(ms = 300): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); }