Skip to content
Merged
92 changes: 90 additions & 2 deletions components/bounty/bounty-sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client"

import { useMemo, useState } from "react"
import { RatingModal } from "../rating/rating-modal"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import type { Bounty } from "@/types/bounty"
Expand All @@ -20,8 +21,22 @@ export function BountySidebar({ bounty }: BountySidebarProps) {
const [loading, setLoading] = useState(false)
// const router = useRouter()

// Mock user ID for now - in real app this comes from auth context
const CURRENT_USER_ID = "mock-user-123"
// Mock user ID and maintainer check for now - in real app this comes from auth context
// DEV-MOCK: Allow maintainer to test rating flow locally.
// WARNING: This is a client-side dev-only bypass and MUST NOT be enabled in production.
// TODO: Replace with real auth context and enforce authorization server-side.

// Opt-in via environment (local/.env.local):
// NEXT_PUBLIC_MOCK_MAINTAINER=true
// NEXT_PUBLIC_MOCK_USER_ID=mock-user-123
const CURRENT_USER_ID = process.env.NEXT_PUBLIC_MOCK_USER_ID ?? "mock-user-123"
const IS_MAINTAINER = process.env.NEXT_PUBLIC_MOCK_MAINTAINER === "true"

if (typeof window !== "undefined" && process.env.NEXT_PUBLIC_MOCK_MAINTAINER === "true") {
console.warn(
"DEV: Mock maintainer enabled in components/bounty/bounty-sidebar.tsx — do NOT enable in production"
)
}

// const isClaimable = bounty.status === "open"

Expand Down Expand Up @@ -75,7 +90,63 @@ export function BountySidebar({ bounty }: BountySidebarProps) {
}
}

// Rating modal state
const [showRating, setShowRating] = useState(false)
const [completed, setCompleted] = useState(false)
const [lastRating, setLastRating] = useState<number | null>(null)
const [reputationGain, setReputationGain] = useState<number | null>(null)
const [hasRated, setHasRated] = useState(false)

const handleMarkCompleted = async () => {
if (!IS_MAINTAINER) {
alert('Only maintainers can mark as completed.');
return;
}
setLoading(true)
// Simulate completion API call
setTimeout(() => {
setLoading(false)
setCompleted(true)
setShowRating(true)
}, 1000)
}

const handleSubmitRating = async (rating: number, feedback: string) => {
if (hasRated) {
alert('You have already rated this contributor.');
return;
}
if (!IS_MAINTAINER) {
alert('Only maintainers can rate contributors.');
return;
}
if (!completed) {
alert('Bounty must be marked as completed before rating.');
return;
}
// Simulate API call to reputation endpoint and calculate points
await new Promise((res) => setTimeout(res, 1000))
// Use feedback variable to avoid unused variable lint warnings
void feedback
setLastRating(rating)
setReputationGain(rating * 10)
setHasRated(true)
setShowRating(false)
// Notify contributor (mock)
toast.success(`You have been rated ${rating} star${rating > 1 ? 's' : ''} and gained +${rating * 10} reputation!`, {
description: 'Congratulations on your contribution!'
})
}

const renderActionButton = () => {
if (bounty.status === 'claimed' && IS_MAINTAINER && !completed) {
return (
<Button onClick={handleMarkCompleted} disabled={loading} className="w-full gap-2 bg-green-600 text-white hover:bg-green-700">
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Check className="mr-2 h-4 w-4" />}
Mark as Completed
</Button>
)
}
Comment on lines 141 to +149
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Allow reopening the rating flow after completion.

Once the modal is dismissed, there’s no way to rate later because the “Mark as Completed” button disappears and no “Rate Contributor” action is shown. This blocks a core flow if the maintainer closes the modal accidentally.

🛠️ Suggested fix (add a “Rate Contributor” action)
   const renderActionButton = () => {
     if (bounty.status === 'claimed' && IS_MAINTAINER && !completed) {
       return (
         <Button onClick={handleMarkCompleted} disabled={loading} className="w-full gap-2 bg-green-600 text-white hover:bg-green-700">
           {loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Check className="mr-2 h-4 w-4" />}
           Mark as Completed
         </Button>
       )
     }
+    if (bounty.status === 'claimed' && IS_MAINTAINER && completed && !hasRated) {
+      return (
+        <Button onClick={() => setShowRating(true)} className="w-full gap-2 bg-primary text-primary-foreground hover:bg-primary/90">
+          Rate Contributor
+        </Button>
+      )
+    }
🤖 Prompt for AI Agents
In `@components/bounty/bounty-sidebar.tsx` around lines 126 - 134, The current
renderActionButton hides the maintainer rating flow after completion; update
renderActionButton to show a persistent "Rate Contributor" action when the
bounty is completed and the viewer is a maintainer by adding a new button
rendered for the case bounty.status === 'claimed' && IS_MAINTAINER && completed;
wire its onClick to open the rating modal (create or reuse a handler like
handleOpenRating or openRatingModal) and ensure the rating modal state/handler
(e.g., isRatingOpen, setIsRatingOpen, handleSubmitRating) exists and is used so
the maintainer can re-open the rating UI after dismissing it; keep the existing
handleMarkCompleted behavior unchanged for marking completion.

if (bounty.status !== 'open') {
const labels: Record<string, string> = {
claimed: 'Already Claimed',
Expand Down Expand Up @@ -155,6 +226,23 @@ export function BountySidebar({ bounty }: BountySidebarProps) {

return (
<div className="sticky top-4 rounded-xl border border-gray-800 bg-background-card p-6 space-y-4">
{/* Sidebar UI */}
{showRating && !hasRated && (
<RatingModal
contributor={{ id: bounty.claimedBy || '', name: 'Contributor', reputation: 100 + (reputationGain || 0) }}
bounty={{ id: bounty.id, title: bounty.issueTitle }}
onSubmit={handleSubmitRating}
onClose={() => setShowRating(false)}
/>
)}

{/* Show rating and reputation gain after rating, visible to all users if available */}
{lastRating && reputationGain && (
<div className="p-4 mb-4 rounded bg-green-900/60 text-green-200 border border-green-700">
<div className="mb-1">{IS_MAINTAINER ? 'You rated the contributor:' : 'Contributor was rated:'} <b>{lastRating} / 5</b> stars</div>
<div>Reputation gained: <b>+{reputationGain}</b></div>
</div>
)}
<Button asChild className="w-full gap-2 bg-primary text-primary-foreground hover:bg-primary/90">
<a href={bounty.githubIssueUrl} target="_blank" rel="noopener noreferrer">
<Github className="size-4" />
Expand Down
82 changes: 82 additions & 0 deletions components/rating/rating-modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import React, { useState } from 'react';
import { RatingStars } from './rating-stars';

interface RatingModalProps {
contributor: {
id: string;
name: string;
reputation: number;
};
bounty: {
id: string;
title: string;
};
onSubmit: (rating: number, feedback: string) => Promise<void>;
onClose: () => void;
}

export const RatingModal: React.FC<RatingModalProps> = ({ contributor, bounty, onSubmit, onClose }) => {
const [rating, setRating] = useState(0);
const [feedback, setFeedback] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);

const handleSubmit = async () => {
if (rating < 1 || rating > 5) {
setError('Please select a rating between 1 and 5.');
return;
}
setLoading(true);
setError(null);
try {
await onSubmit(rating, feedback);
setSuccess(true);
} catch (err) {
console.error(err)
setError('Failed to submit rating. Please try again.');
} finally {
setLoading(false);
}
};

if (success) {
return (
<div className="modal">
<h2>Success!</h2>
<p>Rating submitted. Contributor reputation updated.</p>
<button onClick={onClose}>Close</button>
</div>
);
}

return (
<div className="modal">
<h2>Rate Contributor</h2>
<div>
<strong>Bounty:</strong> {bounty.title}
</div>
<div>
<strong>Contributor:</strong> {contributor.name}
</div>
<div>
<strong>Current Reputation:</strong> {contributor.reputation}
</div>
<div style={{ margin: '16px 0' }}>
<RatingStars value={rating} onChange={setRating} />
</div>
<textarea
placeholder="Optional feedback"
value={feedback}
onChange={e => setFeedback(e.target.value)}
rows={3}
style={{ width: '100%', marginBottom: 8 }}
/>
{error && <div style={{ color: 'red', marginBottom: 8 }}>{error}</div>}
<button onClick={handleSubmit} disabled={loading}>
{loading ? 'Submitting...' : 'Submit'}
</button>
<button onClick={onClose} style={{ marginLeft: 8 }}>Cancel</button>
</div>
);
};
49 changes: 49 additions & 0 deletions components/rating/rating-stars.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import React, { useState } from 'react';

interface RatingStarsProps {
value: number;
onChange?: (value: number) => void;
disabled?: boolean;
displayOnly?: boolean;
}

export const RatingStars: React.FC<RatingStarsProps> = ({ value, onChange, disabled, displayOnly }) => {
const [hovered, setHovered] = useState<number | null>(null);

const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (!onChange || disabled || displayOnly) return;
if (e.key === 'ArrowLeft' && value > 1) onChange(value - 1);
if (e.key === 'ArrowRight' && value < 5) onChange(value + 1);
};

return (
<div
tabIndex={displayOnly ? -1 : 0}
role={displayOnly ? 'img' : 'slider'}
aria-valuenow={value}
aria-valuemin={1}
aria-valuemax={5}
onKeyDown={handleKeyDown}
style={{ display: 'flex', gap: 4, outline: 'none', cursor: displayOnly ? 'default' : 'pointer' }}
>
{[1, 2, 3, 4, 5].map((star) => (
<span
key={star}
onMouseEnter={() => !displayOnly && setHovered(star)}
onMouseLeave={() => !displayOnly && setHovered(null)}
onClick={() => onChange && !disabled && !displayOnly && onChange(star)}
style={{
color: (hovered ?? value) >= star ? '#FFD700' : '#CCC',
fontSize: 28,
transition: 'color 0.2s',
pointerEvents: displayOnly ? 'none' : 'auto',
userSelect: 'none',
}}
Comment on lines +10 to +41
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "rating-stars.tsx" | head -20

Repository: boundlessfi/bounties

Length of output: 100


🏁 Script executed:

cat -n ./components/rating/rating-stars.tsx

Repository: boundlessfi/bounties

Length of output: 1975


Ensure disabled state prevents all interactions and align ARIA props with role.

When disabled=true but displayOnly=false, hover effects still fire because the handlers check !displayOnly only (lines 32-33, 39). Additionally, ARIA slider attributes (aria-valuenow, aria-valuemin, aria-valuemax) are always present even when role="img", which is semantically incorrect. Consolidate interaction state logic and conditionally apply ARIA slider attributes only when appropriate.

Suggested fix
 export const RatingStars: React.FC<RatingStarsProps> = ({ value, onChange, disabled, displayOnly }) => {
   const [hovered, setHovered] = useState<number | null>(null);
+  const isDisplayOnly = displayOnly || !onChange;
+  const isInteractive = !isDisplayOnly && !disabled;
 
   const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
-    if (!onChange || disabled || displayOnly) return;
+    if (!isInteractive) return;
     if (e.key === 'ArrowLeft' && value > 1) onChange(value - 1);
     if (e.key === 'ArrowRight' && value < 5) onChange(value + 1);
   };
 
   return (
     <div
-      tabIndex={displayOnly ? -1 : 0}
-      role={displayOnly ? 'img' : 'slider'}
-      aria-valuenow={value}
-      aria-valuemin={1}
-      aria-valuemax={5}
+      tabIndex={isInteractive ? 0 : -1}
+      role={isDisplayOnly ? 'img' : 'slider'}
+      aria-label={isDisplayOnly ? `${value} out of 5 stars` : 'Rating'}
+      aria-valuenow={isDisplayOnly ? undefined : value}
+      aria-valuemin={isDisplayOnly ? undefined : 0}
+      aria-valuemax={isDisplayOnly ? undefined : 5}
+      aria-disabled={isDisplayOnly ? undefined : disabled}
       onKeyDown={handleKeyDown}
-      style={{ display: 'flex', gap: 4, outline: 'none', cursor: displayOnly ? 'default' : 'pointer' }}
+      style={{ display: 'flex', gap: 4, outline: 'none', cursor: isInteractive ? 'pointer' : 'default' }}
     >
       {[1, 2, 3, 4, 5].map((star) => (
         <span
           key={star}
-          onMouseEnter={() => !displayOnly && setHovered(star)}
-          onMouseLeave={() => !displayOnly && setHovered(null)}
-          onClick={() => onChange && !disabled && !displayOnly && onChange(star)}
+          onMouseEnter={() => isInteractive && setHovered(star)}
+          onMouseLeave={() => isInteractive && setHovered(null)}
+          onClick={() => isInteractive && onChange?.(star)}
           style={{
             color: (hovered ?? value) >= star ? '#FFD700' : '#CCC',
             fontSize: 28,
             transition: 'color 0.2s',
-            pointerEvents: displayOnly ? 'none' : 'auto',
+            pointerEvents: isInteractive ? 'auto' : 'none',
             userSelect: 'none',
           }}
           aria-label={star + ' star'}
🤖 Prompt for AI Agents
In `@components/rating/rating-stars.tsx` around lines 10 - 41, The component
allows interactions when disabled because mouse handlers only check displayOnly;
update all interaction checks to consider disabled as well (e.g.,
onMouseEnter/onMouseLeave/onClick and handleKeyDown should early-return if
disabled || displayOnly || !onChange) and use the disabled flag to set
pointerEvents and cursor; also only apply slider ARIA attributes (aria-valuenow,
aria-valuemin, aria-valuemax and role="slider") when not displayOnly (i.e., when
role is slider) and when not disabled ensure tabIndex is 0 otherwise -1 so the
DOM reflects the true interactive state; touch up references in RatingStars,
handleKeyDown, hovered/setHovered, and the span handlers to implement these
conditional checks and ARIA changes.

aria-label={star + ' star'}
>
</span>
))}
</div>
);
};
5 changes: 3 additions & 2 deletions components/ui/resizable-navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,13 @@ interface MobileNavMenuProps {
export const Navbar = ({ children, className }: NavbarProps) => {
const ref = useRef<HTMLDivElement>(null);
const { scrollY } = useScroll({
target: ref,
// Cast ref to a nullable Element ref to satisfy motion's typings at build time
target: ref as unknown as React.RefObject<Element | null>,
offset: ["start start", "end start"],
});
const [visible, setVisible] = useState<boolean>(false);

useMotionValueEvent(scrollY, "change", (latest) => {
useMotionValueEvent(scrollY, "change", (latest: number) => {
if (latest > 100) {
setVisible(true);
} else {
Expand Down
24 changes: 11 additions & 13 deletions hooks/use-local-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,24 +36,22 @@ export function useLocalStorage<T>(
// ... persists the new value to localStorage.
const setValue = React.useCallback(
(value: T | ((val: T) => T)) => {
try {
// Allow value to be a function so we have same API as useState
// Use functional update to get the latest value
setStoredValue((currentValue) => {
const valueToStore =
value instanceof Function ? value(currentValue) : value
// Use functional update to get the latest value
setStoredValue((currentValue) => {
const valueToStore =
value instanceof Function ? value(currentValue) : value

// Save to local storage
// Save to local storage (catch errors here so they don't escape React internals)
try {
if (typeof window !== "undefined") {
window.localStorage.setItem(key, JSON.stringify(valueToStore))
}
} catch (error) {
console.warn(`Error setting localStorage key "${key}":`, error)
}

return valueToStore
})
} catch (error) {
// A more advanced implementation would handle the error case
console.warn(`Error setting localStorage key "${key}":`, error)
}
return valueToStore
})
},
[key]
)
Expand Down
12 changes: 10 additions & 2 deletions hooks/use-media-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,16 @@ export function useMediaQuery(query: string): boolean {
// Update the state with the current value
setMatches(media.matches)

// Listener callback
const listener = (e: MediaQueryListEvent) => setMatches(e.matches)
// Listener callback (supports event or direct call fallback)
const listener = (e?: MediaQueryListEvent) => {
if (e && typeof e.matches === 'boolean') {
setMatches(e.matches)
} else {
// Fallback: read current value from matchMedia in case tests call the
// handler directly and replaced the underlying matchMedia implementation.
setMatches(window.matchMedia(query).matches)
}
}

// Register listener with fallback for older browsers
if (media.addEventListener) {
Expand Down
27 changes: 18 additions & 9 deletions hooks/use-throttle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,24 @@ export function useThrottle<T>(value: T, limit: number): T {


React.useEffect(() => {
const handler = setTimeout(
() => {
if (Date.now() - lastRan.current >= limit) {
setThrottledValue(value)
lastRan.current = Date.now()
}
},
limit - (Date.now() - lastRan.current)
)
const now = Date.now()

if (lastRan.current === 0) {
lastRan.current = now
}

if (now - lastRan.current >= limit) {
setThrottledValue(value)
lastRan.current = now
return
}

const remaining = Math.max(0, limit - (now - lastRan.current))

const handler = setTimeout(() => {
setThrottledValue(value)
lastRan.current = Date.now()
}, remaining)

return () => {
clearTimeout(handler)
Expand Down
1 change: 1 addition & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const createJestConfig = nextJest({
const customJestConfig = {
testEnvironment: 'jest-environment-jsdom',
testMatch: ['**/__tests__/**/*.test.ts'],
setupFiles: ['<rootDir>/jest.setup.js'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/$1',
},
Expand Down
16 changes: 16 additions & 0 deletions jest.setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Ensure localStorage methods are configurable and writable so tests can spyOn/set mocks
const createLocalStorageMock = () => {
let store = {}
return {
getItem: (key) => (Object.prototype.hasOwnProperty.call(store, key) ? store[key] : null),
setItem: (key, value) => { store[String(key)] = String(value) },
removeItem: (key) => { delete store[String(key)] },
clear: () => { store = {} },
}
}

Object.defineProperty(window, 'localStorage', {
configurable: true,
writable: true,
value: createLocalStorageMock(),
})
4 changes: 2 additions & 2 deletions lib/mock-bounty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ Add a dark mode toggle to the application settings page that persists user prefe
tags: ["ui", "theme", "settings", "dark-mode"],
status: "claimed",
claimedBy: "dev_user_123",
claimedAt: "2024-01-01T00:00:00Z",
claimExpiresAt: "2024-01-15T00:00:00Z", // Expired
claimedAt: "2026-01-01T00:00:00Z",
claimExpiresAt: "2026-01-15T00:00:00Z",
createdAt: "2025-01-10T08:00:00Z",
updatedAt: "2025-01-17T11:00:00Z",
},
Expand Down
Loading
Loading