Skip to content
Merged
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
9 changes: 5 additions & 4 deletions src/app/affiliates/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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`;
Comment on lines +64 to +67
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unicode characters corrupted across all seven pages

Every non-ASCII decorative character in the PR's changed files has been replaced with a mojibake equivalent: ≈, â€", â†', ✕, âš¡. These are the raw UTF-8 byte sequences of the originals re-encoded as Latin-1 then stored back as UTF-8 — a classic double-encoding error. A browser serving the UTF-8 page will render these literally (e.g. showing "â€" 50 âš¡" instead of "— 50 ⚡"), breaking visible content for all affected listing pages. The same corruption appears in src/app/directory/page.tsx, src/app/for-hire/[[...tags]]/page.tsx, src/app/gigs/[[...tags]]/page.tsx, src/app/mcp/page.tsx, src/app/prompts/page.tsx, and src/app/skills/page.tsx — the pattern is present throughout the PR.

}
return null;
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -240,7 +241,7 @@ async function AffiliatesList({ searchParams }: { searchParams: AffiliatesPagePr
</div>
)}

{/* product_url domain hint removed URL is hidden from public listing (#20) */}
{/* product_url domain hint removed — URL is hidden from public listing (#20) */}

<div className="flex flex-wrap items-center justify-between gap-y-1 text-xs text-muted-foreground mt-auto pt-2 border-t border-border/50">
<div className="flex items-center gap-1.5 min-w-0 truncate">
Expand Down
11 changes: 6 additions & 5 deletions src/app/directory/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -84,7 +85,7 @@ async function DirectoryList({
<Link href="/directory/new">
<Button size="sm">
<Zap className="h-4 w-4 mr-1" />
List Your Project 50
List Your Project — 50 ⚡
</Button>
</Link>
</div>
Expand Down Expand Up @@ -293,12 +294,12 @@ export default async function DirectoryPage({
<Link href="/directory/new">
<Button size="sm">
<Zap className="h-4 w-4 mr-1" />
List Your Project 50
List Your Project — 50 ⚡
</Button>
</Link>
</div>
<p className="text-muted-foreground mb-8">
Discover projects built by the community. List yours for 50
Discover projects built by the community. List yours for 50 âš¡
sats.
</p>

Expand Down Expand Up @@ -337,7 +338,7 @@ export default async function DirectoryPage({
})}`}
className="ml-1 hover:text-destructive"
>
✕
</Link>
</Badge>
</div>
Expand Down
9 changes: 5 additions & 4 deletions src/app/for-hire/[[...tags]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -38,7 +39,7 @@ export async function generateMetadata({ params }: GigsPageProps): Promise<Metad
};
}

const title = "I will... Find People Ready to Work | ugig.net";
const title = "I will... — Find People Ready to Work | ugig.net";
const description = "Browse professionals and AI agents offering their services. Find someone who will do exactly what you need.";
return {
title,
Expand Down Expand Up @@ -113,7 +114,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]);
Expand All @@ -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);
Expand Down Expand Up @@ -250,7 +251,7 @@ export default async function ForHirePage({ params, searchParams }: GigsPageProp
<h1 className="text-3xl font-bold mb-2">I will...</h1>
<p className="text-muted-foreground mb-8">
People and agents offering their services. Want to hire instead?{" "}
<a href="/gigs" className="text-primary hover:underline">Post a gig </a>
<a href="/gigs" className="text-primary hover:underline">Post a gig →</a>
</p>

<Suspense fallback={<div className="h-48" />}>
Expand Down
7 changes: 4 additions & 3 deletions src/app/gigs/[[...tags]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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]);
Expand All @@ -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);
Expand Down Expand Up @@ -257,7 +258,7 @@ export default async function GigsPage({ params, searchParams }: GigsPageProps)
<h1 className="text-3xl font-bold mb-2">Gigs (Hiring)</h1>
<p className="text-muted-foreground mb-8">
Clients posting work they need done. Looking for work instead?{" "}
<a href="/for-hire" className="text-primary hover:underline">Browse &quot;I will...&quot; listings </a>
<a href="/for-hire" className="text-primary hover:underline">Browse &quot;I will...&quot; listings →</a>
</p>

<Suspense fallback={<div className="h-48" />}>
Expand Down
15 changes: 8 additions & 7 deletions src/app/mcp/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,27 @@ 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",
},
twitter: {
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.",
},
};

Expand All @@ -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;

Expand Down Expand Up @@ -155,7 +156,7 @@ async function McpList({ searchParams }: { searchParams: McpPageProps["searchPar
</Badge>
{btcUsd && (
<p className="text-[10px] text-muted-foreground mt-0.5">
${((listing.price_sats / 1e8) * btcUsd).toFixed(2)}
≈ ${((listing.price_sats / 1e8) * btcUsd).toFixed(2)}
</p>
)}
</div>
Expand Down Expand Up @@ -314,7 +315,7 @@ export default async function McpPage({ searchParams }: McpPageProps) {
</Link>
</div>
<p className="text-muted-foreground mb-8">
Browse MCP servers tools, integrations, and APIs for AI agents.
Browse MCP servers — tools, integrations, and APIs for AI agents.
</p>

{/* Filters */}
Expand Down Expand Up @@ -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">
✕
</Link>
</Badge>
</div>
Expand Down
15 changes: 8 additions & 7 deletions src/app/prompts/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,27 @@ 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",
},
twitter: {
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.",
},
};

Expand All @@ -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;

Expand Down Expand Up @@ -155,7 +156,7 @@ async function PromptList({ searchParams }: { searchParams: PromptsPageProps["se
</Badge>
{btcUsd && (
<p className="text-[10px] text-muted-foreground mt-0.5">
${((listing.price_sats / 1e8) * btcUsd).toFixed(2)}
≈ ${((listing.price_sats / 1e8) * btcUsd).toFixed(2)}
</p>
)}
</div>
Expand Down Expand Up @@ -324,7 +325,7 @@ export default async function PromptsPage({ searchParams }: PromptsPageProps) {
</Link>
</div>
<p className="text-muted-foreground mb-8">
Browse AI prompts expertly crafted for coding, writing, analysis, and more.
Browse AI prompts — expertly crafted for coding, writing, analysis, and more.
</p>

{/* Filters */}
Expand Down Expand Up @@ -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">
✕
</Link>
</Badge>
</div>
Expand Down
11 changes: 6 additions & 5 deletions src/app/skills/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -154,7 +155,7 @@ async function SkillsList({ searchParams }: { searchParams: SkillsPageProps["sea
</Badge>
{btcUsd && (
<p className="text-[10px] text-muted-foreground mt-0.5">
${((listing.price_sats / 1e8) * btcUsd).toFixed(2)}
≈ ${((listing.price_sats / 1e8) * btcUsd).toFixed(2)}
</p>
)}
</div>
Expand Down Expand Up @@ -312,7 +313,7 @@ export default async function SkillsPage({ searchParams }: SkillsPageProps) {
</Link>
</div>
<p className="text-muted-foreground mb-8">
Browse agent skills install tools, automations, and workflows.
Browse agent skills — install tools, automations, and workflows.
</p>

{/* Filters */}
Expand Down Expand Up @@ -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
</Link>
)}
</div>
Expand Down Expand Up @@ -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">
✕
</Link>
</Badge>
</div>
Expand Down
24 changes: 24 additions & 0 deletions src/lib/pagination.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
11 changes: 11 additions & 0 deletions src/lib/pagination.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Comment on lines +1 to +11
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Max page cap of 100,000 permits database offsets up to ~2 million rows

DEFAULT_MAX_PAGE = 100_000 with a typical limit of 20 allows offset = (100_000 - 1) * 20 = 1_999_980. PostgreSQL evaluates large offsets by scanning and discarding all preceding rows, so a request near the cap could cause significant query latency even against an otherwise small table. A tighter ceiling (e.g. 10,000) would still be far beyond any realistic catalog depth while avoiding full-table scans on malicious or accidental edge requests.

Loading