diff --git a/src/app/affiliates/page.tsx b/src/app/affiliates/page.tsx index 03761121..8346591a 100644 --- a/src/app/affiliates/page.tsx +++ b/src/app/affiliates/page.tsx @@ -8,6 +8,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; import { Megaphone, Users, TrendingUp, Zap } from "lucide-react"; import { SKILL_CATEGORIES } from "@/lib/constants"; +import { parsePageParam } from "@/lib/pagination"; export const metadata: Metadata = { title: "Affiliate Marketplace | ugig.net", @@ -60,10 +61,10 @@ function commissionUsdHint(offer: { price_sats: number; }, btcUsd: number | null): string | null { if (offer.commission_type === "percentage" && offer.price_sats > 0) { - return `≈ $${(offer.price_sats * offer.commission_rate).toFixed(2)} USD`; + return `≈ $${(offer.price_sats * offer.commission_rate).toFixed(2)} USD`; } if (offer.commission_type === "flat" && offer.commission_flat_sats > 0 && btcUsd) { - return `≈ $${((offer.commission_flat_sats / 1e8) * btcUsd).toFixed(2)} USD`; + return `≈ $${((offer.commission_flat_sats / 1e8) * btcUsd).toFixed(2)} USD`; } return null; } @@ -141,7 +142,7 @@ async function AffiliatesList({ searchParams }: { searchParams: AffiliatesPagePr query = query.order("created_at", { ascending: false }); } - const page = parseInt(queryParams.page || "1"); + const page = parsePageParam(queryParams.page); const limit = 20; const offset = (page - 1) * limit; query = query.range(offset, offset + limit - 1); @@ -240,7 +241,7 @@ async function AffiliatesList({ searchParams }: { searchParams: AffiliatesPagePr )} - {/* product_url domain hint removed — URL is hidden from public listing (#20) */} + {/* product_url domain hint removed — URL is hidden from public listing (#20) */}
diff --git a/src/app/directory/page.tsx b/src/app/directory/page.tsx index 0ab9c8d0..419a9edc 100644 --- a/src/app/directory/page.tsx +++ b/src/app/directory/page.tsx @@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; import { FolderOpen, ExternalLink, Zap, ThumbsUp, MessageSquare } from "lucide-react"; +import { parsePageParam } from "@/lib/pagination"; export const metadata: Metadata = { title: "Project Directory | ugig.net", @@ -39,7 +40,7 @@ async function DirectoryList({ const queryParams = await searchParams; const supabase = await createClient(); - const page = parseInt(queryParams.page || "1"); + const page = parsePageParam(queryParams.page); const limit = 21; const offset = (page - 1) * limit; @@ -84,7 +85,7 @@ async function DirectoryList({
@@ -293,12 +294,12 @@ export default async function DirectoryPage({

- Discover projects built by the community. List yours for 50 ⚡ + Discover projects built by the community. List yours for 50 âš¡ sats.

@@ -337,7 +338,7 @@ export default async function DirectoryPage({ })}`} className="ml-1 hover:text-destructive" > - ✕ + ✕ diff --git a/src/app/for-hire/[[...tags]]/page.tsx b/src/app/for-hire/[[...tags]]/page.tsx index e316b94e..d5bc9fb0 100644 --- a/src/app/for-hire/[[...tags]]/page.tsx +++ b/src/app/for-hire/[[...tags]]/page.tsx @@ -7,6 +7,7 @@ import { GigFiltersWithTags } from "@/components/gigs/GigFiltersWithTags"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { Header } from "@/components/layout/Header"; +import { parsePageParam } from "@/lib/pagination"; import { Briefcase } from "lucide-react"; interface GigsPageProps { @@ -38,7 +39,7 @@ export async function generateMetadata({ params }: GigsPageProps): Promise c.toUpperCase())); } query = query.overlaps("skills_required", [...expandedTags]); @@ -135,7 +136,7 @@ async function GigsList({ } // Pagination - const page = parseInt(queryParams.page || "1"); + const page = parsePageParam(queryParams.page); const limit = 20; const offset = (page - 1) * limit; query = query.range(offset, offset + limit - 1); @@ -250,7 +251,7 @@ export default async function ForHirePage({ params, searchParams }: GigsPageProp

I will...

People and agents offering their services. Want to hire instead?{" "} - Post a gig → + Post a gig →

}> diff --git a/src/app/gigs/[[...tags]]/page.tsx b/src/app/gigs/[[...tags]]/page.tsx index 93351dcb..cda50f1f 100644 --- a/src/app/gigs/[[...tags]]/page.tsx +++ b/src/app/gigs/[[...tags]]/page.tsx @@ -7,6 +7,7 @@ import { GigFiltersWithTags } from "@/components/gigs/GigFiltersWithTags"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { Header } from "@/components/layout/Header"; +import { parsePageParam } from "@/lib/pagination"; import { Briefcase } from "lucide-react"; interface GigsPageProps { @@ -119,7 +120,7 @@ async function GigsList({ expandedTags.add(tag.toLowerCase()); expandedTags.add(tag.charAt(0).toUpperCase() + tag.slice(1)); // Title case expandedTags.add(tag.toUpperCase()); - // Handle multi-word: "node.js" → "Node.js", "next.js" → "Next.js" + // Handle multi-word: "node.js" → "Node.js", "next.js" → "Next.js" expandedTags.add(tag.replace(/\b\w/g, c => c.toUpperCase())); } query = query.overlaps("skills_required", [...expandedTags]); @@ -141,7 +142,7 @@ async function GigsList({ } // Pagination - const page = parseInt(queryParams.page || "1"); + const page = parsePageParam(queryParams.page); const limit = 20; const offset = (page - 1) * limit; query = query.range(offset, offset + limit - 1); @@ -257,7 +258,7 @@ export default async function GigsPage({ params, searchParams }: GigsPageProps)

Gigs (Hiring)

Clients posting work they need done. Looking for work instead?{" "} - Browse "I will..." listings → + Browse "I will..." listings →

}> diff --git a/src/app/mcp/page.tsx b/src/app/mcp/page.tsx index 7bb6d135..b9414845 100644 --- a/src/app/mcp/page.tsx +++ b/src/app/mcp/page.tsx @@ -10,18 +10,19 @@ import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; import { Server, Star, Download, Zap } from "lucide-react"; import { MCP_CATEGORIES } from "@/lib/constants"; import { CopyLinkButton } from "@/components/ui/CopyLinkButton"; +import { parsePageParam } from "@/lib/pagination"; export const metadata: Metadata = { title: "MCP Server Marketplace | ugig.net", description: - "Browse MCP servers — tools, integrations, and APIs that AI agents can connect to via the Model Context Protocol.", + "Browse MCP servers — tools, integrations, and APIs that AI agents can connect to via the Model Context Protocol.", alternates: { canonical: "/mcp", }, openGraph: { title: "MCP Server Marketplace | ugig.net", description: - "Browse MCP servers — tools, integrations, and APIs that AI agents can connect to via the Model Context Protocol.", + "Browse MCP servers — tools, integrations, and APIs that AI agents can connect to via the Model Context Protocol.", url: "/mcp", type: "website", }, @@ -29,7 +30,7 @@ export const metadata: Metadata = { card: "summary_large_image", title: "MCP Server Marketplace | ugig.net", description: - "Browse MCP servers — tools, integrations, and APIs that AI agents can connect to via the Model Context Protocol.", + "Browse MCP servers — tools, integrations, and APIs that AI agents can connect to via the Model Context Protocol.", }, }; @@ -55,7 +56,7 @@ async function McpList({ searchParams }: { searchParams: McpPageProps["searchPar const queryParams = await searchParams; const [supabase, btcUsd] = await Promise.all([createClient(), fetchBtcRate()]); - const page = parseInt(queryParams.page || "1"); + const page = parsePageParam(queryParams.page); const limit = 21; const offset = (page - 1) * limit; @@ -155,7 +156,7 @@ async function McpList({ searchParams }: { searchParams: McpPageProps["searchPar {btcUsd && (

- ≈ ${((listing.price_sats / 1e8) * btcUsd).toFixed(2)} + ≈ ${((listing.price_sats / 1e8) * btcUsd).toFixed(2)}

)} @@ -314,7 +315,7 @@ export default async function McpPage({ searchParams }: McpPageProps) {

- Browse MCP servers — tools, integrations, and APIs for AI agents. + Browse MCP servers — tools, integrations, and APIs for AI agents.

{/* Filters */} @@ -401,7 +402,7 @@ export default async function McpPage({ searchParams }: McpPageProps) { ...(queryParams.category ? { category: queryParams.category } : {}), ...(queryParams.sort ? { sort: queryParams.sort } : {}), })}`} className="ml-1 hover:text-destructive"> - ✕ + ✕ diff --git a/src/app/prompts/page.tsx b/src/app/prompts/page.tsx index 36d8a6ad..76fedb95 100644 --- a/src/app/prompts/page.tsx +++ b/src/app/prompts/page.tsx @@ -10,18 +10,19 @@ import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; import { FileText, Star, Download, Zap } from "lucide-react"; import { PROMPT_CATEGORIES } from "@/lib/constants"; import { CopyLinkButton } from "@/components/ui/CopyLinkButton"; +import { parsePageParam } from "@/lib/pagination"; export const metadata: Metadata = { title: "Prompt Marketplace | ugig.net", description: - "Browse AI prompts — expertly crafted prompts for coding, writing, analysis, creative work, and more.", + "Browse AI prompts — expertly crafted prompts for coding, writing, analysis, creative work, and more.", alternates: { canonical: "/prompts", }, openGraph: { title: "Prompt Marketplace | ugig.net", description: - "Browse AI prompts — expertly crafted prompts for coding, writing, analysis, creative work, and more.", + "Browse AI prompts — expertly crafted prompts for coding, writing, analysis, creative work, and more.", url: "/prompts", type: "website", }, @@ -29,7 +30,7 @@ export const metadata: Metadata = { card: "summary_large_image", title: "Prompt Marketplace | ugig.net", description: - "Browse AI prompts — expertly crafted prompts for coding, writing, analysis, creative work, and more.", + "Browse AI prompts — expertly crafted prompts for coding, writing, analysis, creative work, and more.", }, }; @@ -55,7 +56,7 @@ async function PromptList({ searchParams }: { searchParams: PromptsPageProps["se const queryParams = await searchParams; const [supabase, btcUsd] = await Promise.all([createClient(), fetchBtcRate()]); - const page = parseInt(queryParams.page || "1"); + const page = parsePageParam(queryParams.page); const limit = 21; const offset = (page - 1) * limit; @@ -155,7 +156,7 @@ async function PromptList({ searchParams }: { searchParams: PromptsPageProps["se {btcUsd && (

- ≈ ${((listing.price_sats / 1e8) * btcUsd).toFixed(2)} + ≈ ${((listing.price_sats / 1e8) * btcUsd).toFixed(2)}

)} @@ -324,7 +325,7 @@ export default async function PromptsPage({ searchParams }: PromptsPageProps) {

- Browse AI prompts — expertly crafted for coding, writing, analysis, and more. + Browse AI prompts — expertly crafted for coding, writing, analysis, and more.

{/* Filters */} @@ -411,7 +412,7 @@ export default async function PromptsPage({ searchParams }: PromptsPageProps) { ...(queryParams.category ? { category: queryParams.category } : {}), ...(queryParams.sort ? { sort: queryParams.sort } : {}), })}`} className="ml-1 hover:text-destructive"> - ✕ + ✕ diff --git a/src/app/skills/page.tsx b/src/app/skills/page.tsx index 34a3bfff..cb9c984d 100644 --- a/src/app/skills/page.tsx +++ b/src/app/skills/page.tsx @@ -9,6 +9,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; import { Package, Star, Download, Zap, ShieldCheck, ShieldAlert, ShieldX, Shield } from "lucide-react"; import { SKILL_CATEGORIES, SUPPORTED_AGENT_OPTIONS } from "@/lib/constants"; +import { parsePageParam } from "@/lib/pagination"; export const metadata: Metadata = { title: "AI Agent Skills Marketplace | ugig.net", @@ -54,7 +55,7 @@ async function SkillsList({ searchParams }: { searchParams: SkillsPageProps["sea const queryParams = await searchParams; const [supabase, btcUsd] = await Promise.all([createClient(), fetchBtcRate()]); - const page = parseInt(queryParams.page || "1"); + const page = parsePageParam(queryParams.page); const limit = 21; const offset = (page - 1) * limit; @@ -154,7 +155,7 @@ async function SkillsList({ searchParams }: { searchParams: SkillsPageProps["sea {btcUsd && (

- ≈ ${((listing.price_sats / 1e8) * btcUsd).toFixed(2)} + ≈ ${((listing.price_sats / 1e8) * btcUsd).toFixed(2)}

)} @@ -312,7 +313,7 @@ export default async function SkillsPage({ searchParams }: SkillsPageProps) {

- Browse agent skills — install tools, automations, and workflows. + Browse agent skills — install tools, automations, and workflows.

{/* Filters */} @@ -390,7 +391,7 @@ export default async function SkillsPage({ searchParams }: SkillsPageProps) { })}`} className="text-xs text-muted-foreground hover:text-destructive flex items-center ml-1" > - ✕ clear + ✕ clear )} @@ -429,7 +430,7 @@ export default async function SkillsPage({ searchParams }: SkillsPageProps) { ...(queryParams.category ? { category: queryParams.category } : {}), ...(queryParams.sort ? { sort: queryParams.sort } : {}), })}`} className="ml-1 hover:text-destructive"> - ✕ + ✕ diff --git a/src/lib/pagination.test.ts b/src/lib/pagination.test.ts new file mode 100644 index 00000000..92fbb8c0 --- /dev/null +++ b/src/lib/pagination.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { parsePageParam } from "./pagination"; + +describe("parsePageParam", () => { + it("defaults missing values to page 1", () => { + expect(parsePageParam(undefined)).toBe(1); + expect(parsePageParam(null)).toBe(1); + expect(parsePageParam("")).toBe(1); + }); + + it("clamps invalid low values to page 1", () => { + expect(parsePageParam("-1")).toBe(1); + expect(parsePageParam("0")).toBe(1); + expect(parsePageParam("abc")).toBe(1); + }); + + it("truncates fractional page values", () => { + expect(parsePageParam("2.9")).toBe(2); + }); + + it("caps very large page values", () => { + expect(parsePageParam("999999999")).toBe(100_000); + }); +}); diff --git a/src/lib/pagination.ts b/src/lib/pagination.ts new file mode 100644 index 00000000..3b162272 --- /dev/null +++ b/src/lib/pagination.ts @@ -0,0 +1,11 @@ +const DEFAULT_MAX_PAGE = 100_000; + +export function parsePageParam( + value: string | null | undefined, + maxPage = DEFAULT_MAX_PAGE +) { + const parsed = parseInt(value || "1", 10); + return Number.isFinite(parsed) + ? Math.min(Math.max(parsed, 1), maxPage) + : 1; +}