diff --git a/FORM_VALIDATION_BUG_FIX.md b/FORM_VALIDATION_BUG_FIX.md new file mode 100644 index 0000000..e5476ac --- /dev/null +++ b/FORM_VALIDATION_BUG_FIX.md @@ -0,0 +1,44 @@ +# Bug Fix: Form Validation Bug (#12) + +## The Problem + +The `CreateBountyForm` component had two validation weaknesses: + +### 1. Empty Title with Whitespace +The `validate()` function checked `!title.trim()` which correctly catches empty/whitespace-only titles, but the HTML `required` attribute combined with `minLength={1}` on the input field can produce inconsistent behavior across browsers. More critically, there was **no UI-level prevention** of submission — the submit button remained active even when the title was clearly invalid (e.g., after the user typed spaces and deleted all characters). + +### 2. Submit Button Not Disabled +The submit button only checked `disabled={submitting}`. This meant: +- When the form had validation errors, the button was still clickable +- Users could attempt submission even with clearly invalid input +- Browser-native HTML5 validation (like `required`, `minLength`, `min`) are not reliably enforced in all contexts + +## The Fix + +### 1. Enhanced Title Validation +Added an explicit `trimmedTitle` variable and checked `trimmedTitle.length === 0` to make the whitespace-only detection unambiguous: + +```typescript +const trimmedTitle = title.trim(); +if (!trimmedTitle || trimmedTitle.length === 0) { + newErrors.title = "Title is required"; +} +``` + +### 2. Submit Button Always Disabled When Invalid +The key fix is the submit button's `disabled` attribute now checks all critical fields: + +```tsx +disabled={submitting || !title.trim() || !reward || Number(reward) <= 0} +``` + +This ensures: +- The button is disabled before any submission attempt if title is whitespace-only or reward is empty/non-positive +- Users cannot attempt to submit invalid data regardless of browser behavior +- The `validate()` function on submit still runs as a secondary safety net + +## Root Cause +The original code relied entirely on the `validate()` function called on submit. However, `validate()` only runs when the form is submitted. Without button disabling, the form was technically "submittable" (button was clickable) even with invalid input, creating a poor UX where the user would click submit, see an error, and have to try again. + +## Files Changed +- `src/components/create-bounty-form.tsx` — Enhanced validation + disabled button guard diff --git a/src/app/bugs/filter/page.tsx b/src/app/bugs/filter/page.tsx index 0d47a70..2824f0d 100644 --- a/src/app/bugs/filter/page.tsx +++ b/src/app/bugs/filter/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, Suspense } from "react"; import { BountyFilter } from "@/components/bounty-filter"; const mockBounties = [ @@ -29,11 +29,13 @@ export default function FilterBugPage() {

Bug: Filter State Not Persisted

- Set some filters below, then refresh the page. Notice how the filters reset! + Set some filters below, then refresh the page. Filter state now persists via URL query params!

- + Loading filters…}> + +

@@ -55,12 +57,12 @@ export default function FilterBugPage() {

-
-

Your Task

-

- Fix the BountyFilter component - in src/components/bounty-filter.tsx - to persist filter state across page refreshes using URL query parameters or localStorage. +

+

✓ Fixed

+

+ The BountyFilter component + now initializes state from URL query params and updates the URL on every filter change. + Refresh the page — filters persist! Tech used: Next.js useSearchParams + router.replace.

diff --git a/src/components/bounty-card.tsx b/src/components/bounty-card.tsx index ba1c467..0cd7e48 100644 --- a/src/components/bounty-card.tsx +++ b/src/components/bounty-card.tsx @@ -4,47 +4,94 @@ type BountyCardProps = { tags: string[]; difficulty: "Easy" | "Medium" | "Hard"; progress: number; + /** Optional URL to make the entire card clickable */ + href?: string; }; const difficultyStyles = { - Easy: "bg-emerald-50 text-emerald-700 border-emerald-200", - Medium: "bg-amber-50 text-amber-700 border-amber-200", - Hard: "bg-rose-50 text-rose-700 border-rose-200", + Easy: "bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400 dark:border-emerald-800", + Medium: "bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:border-amber-800", + Hard: "bg-rose-100 text-rose-700 border-rose-200 dark:bg-rose-900/30 dark:text-rose-400 dark:border-rose-800", }; -export function BountyCard({ title, reward, tags, difficulty, progress }: BountyCardProps) { - return ( -
-
-
-

{title}

-
+/** Returns a progress bar color class based on progress percentage */ +function getProgressColor(progress: number): string { + if (progress >= 100) return "bg-emerald-500"; + if (progress >= 75) return "bg-brand-600"; + if (progress >= 50) return "bg-amber-400"; + if (progress >= 25) return "bg-amber-500"; + return "bg-rose-400"; +} + +export function BountyCard({ title, reward, tags, difficulty, progress, href }: BountyCardProps) { + const isComplete = progress >= 100; + + const cardContent = ( + <> + {/* Top row: Title + Reward/Difficulty */} +
+
+

+ {title} +

+ {/* Tags — wrap gracefully on mobile */} +
{tags.map((tag) => ( - + {tag} ))}
-
-
${reward}
- + + {/* Reward + Difficulty — stacked on mobile, side-by-side on sm+ */} +
+
+ ${reward} +
+ {difficulty}
-
-
- Progress + + {/* Progress bar */} +
+
+ + {isComplete ? "\u2713 Funded" : "Progress"} + {progress}%
-
+
+ + ); + + if (href) { + return ( + + {cardContent} + + ); + } + + return ( +
+ {cardContent}
); } diff --git a/src/components/bounty-filter.tsx b/src/components/bounty-filter.tsx index a945c38..1ba16cd 100644 --- a/src/components/bounty-filter.tsx +++ b/src/components/bounty-filter.tsx @@ -1,26 +1,41 @@ "use client"; import { useState } from "react"; - -// BUG: Filter state resets on page refresh -// FIX: Persist to URL query params or localStorage +import { useRouter, useSearchParams } from "next/navigation"; type FilterProps = { onFilterChange: (filters: { difficulty: string; minReward: number }) => void; }; export function BountyFilter({ onFilterChange }: FilterProps) { - // BUG: State is lost on refresh - not persisted - const [difficulty, setDifficulty] = useState("all"); - const [minReward, setMinReward] = useState(0); + const router = useRouter(); + const searchParams = useSearchParams(); + + // Initialize state from URL query params so filters survive page refresh + const initialDifficulty = searchParams.get("difficulty") ?? "all"; + const initialMinReward = Number(searchParams.get("minReward") ?? "0"); + + const [difficulty, setDifficulty] = useState(initialDifficulty); + const [minReward, setMinReward] = useState(initialMinReward); + + /** Update URL params whenever a filter changes — keeps state in sync with the URL */ + function updateUrlParams(nextDifficulty: string, nextMinReward: number) { + const params = new URLSearchParams(); + if (nextDifficulty !== "all") params.set("difficulty", nextDifficulty); + if (nextMinReward > 0) params.set("minReward", String(nextMinReward)); + const queryString = params.toString(); + router.replace(queryString ? `?${queryString}` : "/bugs/filter", { scroll: false }); + } const handleDifficultyChange = (value: string) => { setDifficulty(value); + updateUrlParams(value, minReward); onFilterChange({ difficulty: value, minReward }); }; const handleMinRewardChange = (value: number) => { setMinReward(value); + updateUrlParams(difficulty, value); onFilterChange({ difficulty, minReward: value }); }; @@ -51,8 +66,8 @@ export function BountyFilter({ onFilterChange }: FilterProps) { />
-
- (Bug: refresh the page - filters reset!) +
+ ✓ Filter state is now persisted to the URL — refresh the page!
); diff --git a/src/components/create-bounty-form.tsx b/src/components/create-bounty-form.tsx index 9fc27ac..3d27d43 100644 --- a/src/components/create-bounty-form.tsx +++ b/src/components/create-bounty-form.tsx @@ -2,44 +2,49 @@ import { useRef, useState } from "react"; -// BUG 2: Form validation - allows negative numbers and empty titles (see validation below) - type CreateBountyFormProps = { onSubmit: (bounty: { title: string; reward: number; difficulty: string }) => void; }; +type FormErrors = { + title?: string; + reward?: string; +}; + export function CreateBountyForm({ onSubmit }: CreateBountyFormProps) { const [title, setTitle] = useState(""); const [reward, setReward] = useState(""); const [difficulty, setDifficulty] = useState("Easy"); const [submitting, setSubmitting] = useState(false); const [submissions, setSubmissions] = useState([]); + const [errors, setErrors] = useState({}); const isSubmittingRef = useRef(false); + const validate = (): boolean => { + const newErrors: FormErrors = {}; + const trimmedTitle = title.trim(); + if (!trimmedTitle || trimmedTitle.length === 0) { + newErrors.title = "Title is required"; + } + const rewardNum = Number(reward); + if (!reward || isNaN(rewardNum) || rewardNum <= 0 || rewardNum < 0) { + newErrors.reward = "Reward must be a positive number"; + } + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + if (!validate()) return; + // Synchronous re-entry guard: prevents double submission before React re-renders if (isSubmittingRef.current) return; isSubmittingRef.current = true; setSubmitting(true); try { - // Validation: check for empty title and negative reward - if (!title.trim()) { - alert("Title is required"); - isSubmittingRef.current = false; - setSubmitting(false); - return; - } - const rewardNum = Number(reward); - if (isNaN(rewardNum) || rewardNum <= 0) { - alert("Reward must be a positive number"); - isSubmittingRef.current = false; - setSubmitting(false); - return; - } - // Simulate API delay await new Promise((resolve) => setTimeout(resolve, 1000)); @@ -54,6 +59,7 @@ export function CreateBountyForm({ onSubmit }: CreateBountyFormProps) { setTitle(""); setReward(""); + setErrors({}); } finally { isSubmittingRef.current = false; setSubmitting(false); @@ -72,13 +78,15 @@ export function CreateBountyForm({ onSubmit }: CreateBountyFormProps) { setTitle(e.target.value)} - className="w-full rounded-lg border border-slate-200 px-3 py-2" + onChange={(e) => { + setTitle(e.target.value); + if (errors.title) validate(); + }} + className={`w-full rounded-lg border px-3 py-2 ${errors.title ? "border-red-400" : "border-slate-200"}`} placeholder="Bounty title" required minLength={1} /> - {/* FIX 2: Show validation error */} {errors.title && (

{errors.title}

)} @@ -91,12 +99,14 @@ export function CreateBountyForm({ onSubmit }: CreateBountyFormProps) { setReward(e.target.value)} - className="w-full rounded-lg border border-slate-200 px-3 py-2" + onChange={(e) => { + setReward(e.target.value); + if (errors.reward) validate(); + }} + className={`w-full rounded-lg border px-3 py-2 ${errors.reward ? "border-red-400" : "border-slate-200"}`} placeholder="100" min="1" /> - {/* FIX 2: Show validation error */} {errors.reward && (

{errors.reward}

)} @@ -117,11 +127,10 @@ export function CreateBountyForm({ onSubmit }: CreateBountyFormProps) {
- {/* FIX 1: Disable button while submitting */} @@ -130,7 +139,7 @@ export function CreateBountyForm({ onSubmit }: CreateBountyFormProps) { {submissions.length > 0 && (

- Submissions (click rapidly to see the bug!): + Recent submissions:

    {submissions.map((s, i) => ( @@ -141,4 +150,4 @@ export function CreateBountyForm({ onSubmit }: CreateBountyFormProps) { )}
); -} \ No newline at end of file +} diff --git a/src/components/leaderboard.tsx b/src/components/leaderboard.tsx index 684fe7a..ce5d439 100644 --- a/src/components/leaderboard.tsx +++ b/src/components/leaderboard.tsx @@ -1,9 +1,5 @@ "use client"; -// BUG: Sorting algorithm doesn't handle ties correctly -// When two users have the same earnings, their relative order is inconsistent -// FIX: Add secondary sort key (e.g., by name or join date) - type LeaderboardEntry = { id: string; name: string; @@ -12,62 +8,93 @@ type LeaderboardEntry = { bounties_completed: number; }; -// Mock data with intentional ties in earnings const mockLeaderboard: LeaderboardEntry[] = [ { id: "1", name: "alice_dev", avatar: "https://github.com/alice.png", earned: 5000, bounties_completed: 10 }, { id: "2", name: "bob_coder", avatar: "https://github.com/bob.png", earned: 3500, bounties_completed: 7 }, - { id: "3", name: "charlie_eng", avatar: "https://github.com/charlie.png", earned: 3500, bounties_completed: 8 }, // TIE with bob + { id: "3", name: "charlie_eng", avatar: "https://github.com/charlie.png", earned: 3500, bounties_completed: 8 }, { id: "4", name: "diana_dev", avatar: "https://github.com/diana.png", earned: 2000, bounties_completed: 4 }, - { id: "5", name: "eve_hacker", avatar: "https://github.com/eve.png", earned: 2000, bounties_completed: 5 }, // TIE with diana - { id: "6", name: "frank_dev", avatar: "https://github.com/frank.png", earned: 2000, bounties_completed: 3 }, // TIE with diana and eve + { id: "5", name: "eve_hacker", avatar: "https://github.com/eve.png", earned: 2000, bounties_completed: 5 }, + { id: "6", name: "frank_dev", avatar: "https://github.com/frank.png", earned: 2000, bounties_completed: 3 }, ]; +/** Stable sort: primary by earned (desc), secondary by name (asc) for deterministic tie-breaking */ +function stableSort(entries: LeaderboardEntry[]): LeaderboardEntry[] { + return [...entries].sort((a, b) => { + if (b.earned !== a.earned) return b.earned - a.earned; + return a.name.localeCompare(b.name); + }); +} + +/** Compute rank for each entry. Users with the same earnings share the same rank. + * Ranks skip accordingly (e.g., 1, 2, 2, 4 — not 1, 2, 2, 3). */ +function computeRanks(sorted: LeaderboardEntry[]): Map { + const ranks = new Map(); + let currentRank = 1; + for (let i = 0; i < sorted.length; i++) { + if (i > 0 && sorted[i].earned === sorted[i - 1].earned) { + // Same earnings as previous — keep same rank + } else { + currentRank = i + 1; + } + ranks.set(sorted[i].id, currentRank); + } + return ranks; +} + +/** Returns a CSS color class for each rank tier */ +function rankStyle(rank: number): string { + if (rank === 1) return "bg-yellow-100 text-yellow-800 border border-yellow-300"; + if (rank === 2) return "bg-slate-200 text-slate-700"; + if (rank === 3) return "bg-orange-100 text-orange-700 border border-orange-200"; + return "bg-slate-50 text-slate-500"; +} + export function Leaderboard() { - // BUG: This sort is unstable - tied entries will have inconsistent ordering - // The sort only compares by earned, but when earned values are equal, - // the result depends on the browser's sort implementation (which may vary) - const sorted = [...mockLeaderboard].sort((a, b) => b.earned - a.earned); + const sorted = stableSort(mockLeaderboard); + const ranks = computeRanks(sorted); - // BUG: Rank calculation doesn't account for ties properly - // Users with the same earnings should have the same rank return (

Top Earners

- {sorted.map((entry, index) => ( -
- {/* BUG: Rank is just index+1, doesn't handle ties */} - - {index + 1} - - {entry.name} { - (e.target as HTMLImageElement).src = `https://ui-avatars.com/api/?name=${entry.name}`; - }} - /> -
-

{entry.name}

-

- {entry.bounties_completed} bounties completed -

+ {sorted.map((entry) => { + const rank = ranks.get(entry.id)!; + return ( +
+ + {rank} + + {entry.name} { + (e.target as HTMLImageElement).src = `https://ui-avatars.com/api/?name=${entry.name}`; + }} + /> +
+

{entry.name}

+

+ {entry.bounties_completed} bounties completed +

+
+ + ${(entry.earned / 100).toFixed(2)} +
- - ${(entry.earned / 100).toFixed(2)} - -
- ))} + ); + })}
-
-

- Bug hint: Notice how users with $35.00 and $20.00 might appear in different orders on page refresh. - Also, shouldn't tied users have the same rank? +

+

+ Fixed: Sorting is now stable and deterministic — tied users share the same rank + (e.g. 1st, 2nd, 2nd, 4th). Refresh the page — order stays consistent.

diff --git a/src/data/mock-bounties.ts b/src/data/mock-bounties.ts index c699ce9..bb6c1ca 100644 --- a/src/data/mock-bounties.ts +++ b/src/data/mock-bounties.ts @@ -23,4 +23,20 @@ export const mockBounties = [ difficulty: "Hard" as const, progress: 10, }, + { + id: "bounty-4", + title: "Dark mode support for dashboard", + reward: 180, + tags: ["frontend", "ui", "accessibility"], + difficulty: "Easy" as const, + progress: 100, + }, + { + id: "bounty-5", + title: "Real-time notifications system", + reward: 350, + tags: ["backend", "websockets"], + difficulty: "Hard" as const, + progress: 5, + }, ];