From 3803cea5a2eeb843190dfcfba3fa1e5166b0e7a9 Mon Sep 17 00:00:00 2001 From: Ekezie Uchechukwu Date: Fri, 24 Apr 2026 10:08:41 +0100 Subject: [PATCH] feat: Connect Wallet button component and Bid List for clients Closes #102 Closes #132 --- apps/web/app/jobs/[id]/page.tsx | 41 ++- apps/web/components/jobs/bid-list.tsx | 237 ++++++++++++++++++ apps/web/components/navigation/top-nav.tsx | 2 + .../wallet/connect-wallet-button.tsx | 133 ++++++++++ 4 files changed, 388 insertions(+), 25 deletions(-) create mode 100644 apps/web/components/jobs/bid-list.tsx create mode 100644 apps/web/components/wallet/connect-wallet-button.tsx diff --git a/apps/web/app/jobs/[id]/page.tsx b/apps/web/app/jobs/[id]/page.tsx index 378cda89..b91fdc2a 100644 --- a/apps/web/app/jobs/[id]/page.tsx +++ b/apps/web/app/jobs/[id]/page.tsx @@ -12,6 +12,7 @@ import { ShieldAlert, Wallet, } from "lucide-react"; +import { BidList } from "@/components/jobs/bid-list"; import { SiteShell } from "@/components/site-shell"; import { Stars } from "@/components/stars"; import { JobDetailsSkeleton } from "@/components/ui/skeleton"; @@ -319,7 +320,7 @@ export default function JobDetailsPage() {
-
+

Bids ({workspace.bids.length})

@@ -327,30 +328,20 @@ export default function JobDetailsPage() { Client shortlist
-
- {workspace.bids.map((bid) => ( -
-

- {shortenAddress(bid.freelancer_address)} -

-

- {bid.proposal} -

- -
- ))} -
+
) : null} diff --git a/apps/web/components/jobs/bid-list.tsx b/apps/web/components/jobs/bid-list.tsx new file mode 100644 index 00000000..2287eb46 --- /dev/null +++ b/apps/web/components/jobs/bid-list.tsx @@ -0,0 +1,237 @@ +"use client"; + +import { useState } from "react"; +import { CheckCircle2, Clock3, Loader2, UserCircle2 } from "lucide-react"; +import { type Bid } from "@/lib/api"; +import { shortenAddress, formatDate } from "@/lib/format"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; + +// ── Status helpers ────────────────────────────────────────────────────────── + +const STATUS_CONFIG: Record< + string, + { label: string; className: string } +> = { + pending: { + label: "Pending", + className: "bg-amber-500/10 text-amber-400 border-amber-500/20", + }, + accepted: { + label: "Accepted", + className: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", + }, + rejected: { + label: "Rejected", + className: "bg-red-500/10 text-red-400 border-red-500/20", + }, +}; + +function StatusBadge({ status }: { status: string }) { + const config = STATUS_CONFIG[status] ?? { + label: status, + className: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20", + }; + return ( + + {config.label} + + ); +} + +// ── Empty / loading states ────────────────────────────────────────────────── + +function BidListSkeleton() { + return ( + + ); +} + +function EmptyBids() { + return ( +
+
+ ); +} + +// ── Main component ────────────────────────────────────────────────────────── + +interface BidListProps { + bids: Bid[]; + loading?: boolean; + error?: string | null; + isClientOwner?: boolean; + jobStatus?: string; + acceptingBidId?: string | null; + onAccept?: (bidId: string) => void; +} + +/** + * BidList — Issue #132 + * + * Renders the list of bids on a job from the client's perspective. + * - Shows loading skeletons while bids are being fetched + * - Empty state when no bids have been submitted + * - Error boundary fallback for fetch failures + * - Per-bid "Accept" action for the client owner on open jobs + * - Status badges with semantic colour coding (Amber = pending, Emerald = accepted) + * - Fully responsive with keyboard-accessible accept buttons + */ +export function BidList({ + bids, + loading = false, + error = null, + isClientOwner = false, + jobStatus = "open", + acceptingBidId = null, + onAccept, +}: BidListProps) { + const [expandedId, setExpandedId] = useState(null); + + if (loading) return ; + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (bids.length === 0) return ; + + const canAccept = isClientOwner && jobStatus === "open"; + + return ( +
    + {bids.map((bid) => { + const isExpanded = expandedId === bid.id; + const isAccepting = acceptingBidId === bid.id; + const isAccepted = bid.status === "accepted"; + + return ( +
  • + {/* Header row */} +
    +
    +
    + +
    + + +
    +
    + + {/* Proposal — collapsed to 2 lines, expandable */} +
    + {bid.proposal} +
    + + {bid.proposal.length > 120 && ( + + )} + + {/* Accept action */} + {canAccept && !isAccepted && ( +
    + +
    + )} + + {isAccepted && ( +

    +

    + )} +
  • + ); + })} +
+ ); +} diff --git a/apps/web/components/navigation/top-nav.tsx b/apps/web/components/navigation/top-nav.tsx index e8d17589..0dd60572 100644 --- a/apps/web/components/navigation/top-nav.tsx +++ b/apps/web/components/navigation/top-nav.tsx @@ -8,6 +8,7 @@ import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Input } from "@/components/ui/input"; import { SessionSwitcher } from "@/components/auth/session-switcher"; import { ThemeToggle } from "@/components/theme/theme-toggle"; +import { ConnectWalletButton } from "@/components/wallet/connect-wallet-button"; export function TopNav({ onOpenSidebar }: { onOpenSidebar?: () => void }) { const { isLoggedIn, logout, login, role, user } = useAuthStore(); @@ -94,6 +95,7 @@ export function TopNav({ onOpenSidebar }: { onOpenSidebar?: () => void }) { ) : (
+ + ); + } + + if (isConnected && address) { + const truncated = `${address.slice(0, 4)}…${address.slice(-4)}`; + + return ( +
+ {networkMismatch && ( +

+

+ )} + +
+ + +
+
+ ); + } + + return ( +
+ {error && ( +

+

+ )} + + +
+ ); +}