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
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { describe, it, expect, afterEach } from "vitest"
import { cleanup, render, screen } from "@testing-library/react"
import { TierProgress } from "./tier-progress"

afterEach(cleanup)

describe("TierProgress", () => {
describe("zero volume (Bronze, no progress toward Silver)", () => {
it("renders current tier label", () => {
render(<TierProgress tier={1} volumeUsd={0} />)
expect(screen.getAllByText("Bronze").length).toBeGreaterThan(0)
})

it("renders next tier label", () => {
render(<TierProgress tier={1} volumeUsd={0} />)
expect(screen.getAllByText("Silver").length).toBeGreaterThan(0)
})

it("renders progress bar at 0%", () => {
render(<TierProgress tier={1} volumeUsd={0} />)
const bar = screen.getByRole("progressbar")
expect(bar).toHaveAttribute("aria-valuenow", "0")
})

it("renders remaining threshold copy", () => {
render(<TierProgress tier={1} volumeUsd={0} />)
expect(screen.getByLabelText("remaining volume")).toHaveTextContent("more needed")
})
})

describe("partial progress (Bronze → Silver at 50%)", () => {
it("renders both tier labels", () => {
render(<TierProgress tier={1} volumeUsd={1250} />)
expect(screen.getAllByText("Bronze").length).toBeGreaterThan(0)
expect(screen.getAllByText("Silver").length).toBeGreaterThan(0)
})

it("renders progressbar at 50", () => {
render(<TierProgress tier={1} volumeUsd={1250} />)
const bar = screen.getByRole("progressbar")
expect(bar).toHaveAttribute("aria-valuenow", "50")
})

it("shows next-tier threshold copy", () => {
render(<TierProgress tier={1} volumeUsd={1250} />)
expect(screen.getByLabelText("remaining volume")).toHaveTextContent("more needed")
})
})

describe("maxed-out tier (Gold, level 3)", () => {
it("renders current tier label", () => {
render(<TierProgress tier={3} volumeUsd={50000} />)
expect(screen.getAllByText("Gold").length).toBeGreaterThan(0)
})

it("renders maximum tier message", () => {
render(<TierProgress tier={3} volumeUsd={50000} />)
expect(screen.getByText("Maximum tier reached!")).toBeInTheDocument()
})

it("does not render a progress bar", () => {
render(<TierProgress tier={3} volumeUsd={50000} />)
expect(screen.queryByRole("progressbar")).not.toBeInTheDocument()
})

it("does not render 'more needed' copy", () => {
render(<TierProgress tier={3} volumeUsd={50000} />)
expect(screen.queryByLabelText("remaining volume")).not.toBeInTheDocument()
})
})

describe("tier label correctness", () => {
it("Silver → Gold shows correct labels", () => {
render(<TierProgress tier={2} volumeUsd={5000} />)
expect(screen.getAllByText("Silver").length).toBeGreaterThan(0)
expect(screen.getAllByText("Gold").length).toBeGreaterThan(0)
})
})

describe("progressbar aria attributes", () => {
it("has aria-valuemin=0 and aria-valuemax=100", () => {
render(<TierProgress tier={1} volumeUsd={500} />)
const bar = screen.getByRole("progressbar")
expect(bar).toHaveAttribute("aria-valuemin", "0")
expect(bar).toHaveAttribute("aria-valuemax", "100")
})

it("caps aria-valuenow at 100 when volume exceeds threshold", () => {
render(<TierProgress tier={1} volumeUsd={999999} />)
const bar = screen.getByRole("progressbar")
expect(Number(bar.getAttribute("aria-valuenow"))).toBeLessThanOrEqual(100)
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { cn } from "@workspace/ui/lib/utils"
import { getTierByLevel, getNextTier } from "../../data/tiers"
import { formatUsd } from "@/shared/lib/format"

type Props = {
tier: 1 | 2 | 3
volumeUsd: number
}

export function TierProgress({ tier, volumeUsd }: Props) {
const current = getTierByLevel(tier)
const next = getNextTier(tier)

if (!next) {
return (
<div
role="status"
aria-label="tier progress"
className="flex items-center gap-2 rounded-lg border border-yellow-500/20 bg-yellow-500/[0.06] px-4 py-2.5"
>
<span
className={cn(
"inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1",
current.colorClass,
current.ringClass,
)}
>
{current.label}
</span>
<span className="text-[12px] font-medium text-yellow-400">Maximum tier reached!</span>
</div>
)
}

const progress = Math.min((volumeUsd / next.minVolumeUsd) * 100, 100)
const remaining = Math.max(next.minVolumeUsd - volumeUsd, 0)

return (
<div
role="status"
aria-label="tier progress"
className="rounded-lg border border-border bg-card px-4 py-3"
>
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<span
className={cn(
"inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1",
current.colorClass,
current.ringClass,
)}
>
{current.label}
</span>
<span className="text-[11px] text-muted-foreground">→</span>
<span
className={cn(
"inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1",
next.colorClass,
next.ringClass,
)}
>
{next.label}
</span>
</div>
<span className="text-[11px] text-muted-foreground" aria-label="remaining volume">
{formatUsd(remaining, { compact: true })} more needed
</span>
</div>
<div
role="progressbar"
aria-valuenow={Math.round(progress)}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`progress to ${next.label}`}
className="h-1.5 w-full overflow-hidden rounded-full bg-muted"
>
<div
className="h-full rounded-full bg-gradient-to-r from-violet-500 to-blue-400 transition-all"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)
}
102 changes: 102 additions & 0 deletions apps/web/src/shared/components/ErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"
import { cleanup, render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { ErrorBoundary } from "./ErrorBoundary"

afterEach(cleanup)

function Thrower({ message = "boom" }: { message?: string }) {
throw new Error(message)
}

function Stable({ text }: { text: string }) {
return <p>{text}</p>
}

describe("ErrorBoundary", () => {
beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => {})
})

afterEach(() => {
vi.restoreAllMocks()
})

it("renders children when no error is thrown", () => {
render(
<ErrorBoundary>
<Stable text="all good" />
</ErrorBoundary>,
)
expect(screen.getByText("all good")).toBeInTheDocument()
})

it("renders fallback message when a child throws", () => {
render(
<ErrorBoundary>
<Thrower />
</ErrorBoundary>,
)
expect(screen.getByRole("alert")).toBeInTheDocument()
expect(screen.getByText("Something went wrong")).toBeInTheDocument()
})

it("renders the error message in the fallback", () => {
render(
<ErrorBoundary>
<Thrower message="network failure" />
</ErrorBoundary>,
)
expect(screen.getByText("network failure")).toBeInTheDocument()
})

it("renders a retry button in the fallback", () => {
render(
<ErrorBoundary>
<Thrower />
</ErrorBoundary>,
)
expect(screen.getByRole("button", { name: /try again/i })).toBeInTheDocument()
})

it("re-renders children after clicking retry", async () => {
const user = userEvent.setup()
let shouldThrow = true

function MaybeThrow() {
if (shouldThrow) throw new Error("oops")
return <p>recovered</p>
}

render(
<ErrorBoundary>
<MaybeThrow />
</ErrorBoundary>,
)

expect(screen.getByRole("alert")).toBeInTheDocument()
shouldThrow = false
await user.click(screen.getByRole("button", { name: /try again/i }))
expect(screen.getByText("recovered")).toBeInTheDocument()
expect(screen.queryByRole("alert")).not.toBeInTheDocument()
})

it("renders a custom fallback prop when provided", () => {
render(
<ErrorBoundary fallback={<div>custom error UI</div>}>
<Thrower />
</ErrorBoundary>,
)
expect(screen.getByText("custom error UI")).toBeInTheDocument()
expect(screen.queryByText("Something went wrong")).not.toBeInTheDocument()
})

it("does not leak console.error noise across tests", () => {
render(
<ErrorBoundary>
<Thrower />
</ErrorBoundary>,
)
expect(console.error).toHaveBeenCalled()
})
})
47 changes: 47 additions & 0 deletions apps/web/src/shared/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Component, type ReactNode, type ErrorInfo } from "react"

type Props = {
children: ReactNode
fallback?: ReactNode
}

type State = {
error: Error | null
}

export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null }

static getDerivedStateFromError(error: Error): State {
return { error }
}

componentDidCatch(error: Error, info: ErrorInfo) {
console.error("[ErrorBoundary]", error, info.componentStack)
}

reset = () => {
this.setState({ error: null })
}

render() {
if (this.state.error) {
if (this.props.fallback) return this.props.fallback

return (
<div role="alert" className="flex flex-col items-center gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm font-medium text-destructive">Something went wrong</p>
<p className="text-xs text-muted-foreground">{this.state.error.message}</p>
<button
onClick={this.reset}
className="rounded-md bg-destructive/10 px-3 py-1.5 text-xs font-medium text-destructive hover:bg-destructive/20"
>
Try again
</button>
</div>
)
}

return this.props.children
}
}
Loading