Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions FORM_VALIDATION_BUG_FIX.md
Original file line number Diff line number Diff line change
@@ -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
20 changes: 11 additions & 9 deletions src/app/bugs/filter/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useState } from "react";
import { useState, Suspense } from "react";
import { BountyFilter } from "@/components/bounty-filter";

const mockBounties = [
Expand Down Expand Up @@ -29,11 +29,13 @@ export default function FilterBugPage() {
<div className="card p-6">
<h1 className="text-2xl font-bold">Bug: Filter State Not Persisted</h1>
<p className="mt-2 text-slate-600">
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!
</p>
</div>

<BountyFilter onFilterChange={setFilters} />
<Suspense fallback={<div className="card p-4 text-sm text-slate-400">Loading filters…</div>}>
<BountyFilter onFilterChange={setFilters} />
</Suspense>

<div className="card p-6">
<h2 className="text-lg font-semibold mb-4">
Expand All @@ -55,12 +57,12 @@ export default function FilterBugPage() {
</div>
</div>

<div className="card p-6 bg-blue-50 border-blue-200">
<h3 className="font-semibold text-blue-800">Your Task</h3>
<p className="mt-2 text-sm text-blue-700">
Fix the <code className="bg-blue-100 px-1 rounded">BountyFilter</code> component
in <code className="bg-blue-100 px-1 rounded">src/components/bounty-filter.tsx</code>
to persist filter state across page refreshes using URL query parameters or localStorage.
<div className="card p-6 bg-green-50 border-green-200">
<h3 className="font-semibold text-green-800">✓ Fixed</h3>
<p className="mt-2 text-sm text-green-700">
The <code className="bg-green-100 px-1 rounded">BountyFilter</code> 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 <code>useSearchParams</code> + <code>router.replace</code>.
</p>
</div>
</div>
Expand Down
87 changes: 67 additions & 20 deletions src/components/bounty-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="card p-4 sm:p-5 hover:shadow-md transition">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<h3 className="text-base sm:text-lg font-semibold leading-snug break-words">{title}</h3>
<div className="mt-1.5 flex flex-wrap gap-1.5">
/** 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 */}
<div className="flex flex-col sm:flex-row sm:items-start gap-3">
<div className="flex-1 min-w-0">
<h3 className="text-base sm:text-lg font-semibold leading-snug text-slate-900 dark:text-slate-100">
{title}
</h3>
{/* Tags — wrap gracefully on mobile */}
<div className="mt-2 flex flex-wrap gap-1.5">
{tags.map((tag) => (
<span key={tag} className="pill text-[11px] sm:text-xs px-2 py-0.5 sm:px-3 sm:py-1">
<span
key={tag}
className="pill text-[11px] sm:text-xs px-2 py-0.5 sm:px-3 sm:py-1 transition-colors hover:border-brand-300 hover:text-brand-700 dark:hover:border-brand-400 dark:hover:text-brand-300"
>
{tag}
</span>
))}
</div>
</div>
<div className="text-right shrink-0">
<div className="text-xl sm:text-xl font-bold">${reward}</div>
<span className={`mt-1 inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] sm:text-xs font-semibold whitespace-nowrap ${difficultyStyles[difficulty]}`}>

{/* Reward + Difficulty — stacked on mobile, side-by-side on sm+ */}
<div className="flex sm:flex-col sm:items-end sm:text-right gap-2 sm:gap-1 shrink-0">
<div className="text-xl font-bold text-brand-600 dark:text-brand-400">
${reward}
</div>
<span
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] sm:text-xs font-semibold whitespace-nowrap ${difficultyStyles[difficulty]}`}
>
{difficulty}
</span>
</div>
</div>
<div className="mt-3">
<div className="flex items-center justify-between text-xs text-slate-500">
<span>Progress</span>

{/* Progress bar */}
<div className="mt-4">
<div className="flex items-center justify-between text-xs text-slate-500 dark:text-slate-400">
<span className={isComplete ? "text-emerald-600 dark:text-emerald-400 font-medium" : ""}>
{isComplete ? "\u2713 Funded" : "Progress"}
</span>
<span>{progress}%</span>
</div>
<div className="mt-1.5 h-2 w-full rounded-full bg-slate-100">
<div className="mt-1.5 h-2 w-full overflow-hidden rounded-full bg-slate-100 dark:bg-slate-700">
<div
className="h-2 rounded-full bg-brand-600"
style={{ width: `${progress}%` }}
className={`h-2 rounded-full transition-all duration-500 ${getProgressColor(progress)}`}
style={{ width: `${Math.min(progress, 100)}%` }}
/>
</div>
</div>
</>
);

if (href) {
return (
<a
href={href}
className="card p-4 sm:p-5 hover:shadow-lg hover:border-brand-200 dark:hover:border-brand-600 hover:-translate-y-0.5 transition-all duration-200 block cursor-pointer no-underline"
>
{cardContent}
</a>
);
}

return (
<div className="card p-4 sm:p-5 hover:shadow-lg hover:border-brand-200 dark:hover:border-brand-600 hover:-translate-y-0.5 transition-all duration-200">
{cardContent}
</div>
);
}
31 changes: 23 additions & 8 deletions src/components/bounty-filter.tsx
Original file line number Diff line number Diff line change
@@ -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 });
};

Expand Down Expand Up @@ -51,8 +66,8 @@ export function BountyFilter({ onFilterChange }: FilterProps) {
/>
</div>

<div className="text-xs text-slate-400">
(Bug: refresh the page - filters reset!)
<div className="text-xs text-green-600">
✓ Filter state is now persisted to the URL — refresh the page!
</div>
</div>
);
Expand Down
Loading