diff --git a/src/app/browse/page.tsx b/src/app/browse/page.tsx index 6f8016f..0977a2d 100644 --- a/src/app/browse/page.tsx +++ b/src/app/browse/page.tsx @@ -1,12 +1,499 @@ -export default function BrowsePage() { +"use client"; + +import Link from "next/link"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { FormEvent, useEffect, useMemo, useState } from "react"; +import { slugify } from "@/lib/slug"; + +type ListingType = + | "team_blueprint" + | "agent_blueprint" + | "skill" + | "governance_template"; + +interface ListingItem { + id: string; + slug: string; + type: ListingType; + title: string; + tagline: string | null; + price: number; + creatorName: string | null; + categories: string[]; + agentCount: number | null; + installCount: number; + rating: number | string | null; + reviewCount: number; +} + +interface ListingsResponse { + data: ListingItem[]; + pagination: { + page: number; + limit: number; + total: number; + totalPages: number; + }; +} + +const TYPE_OPTIONS: Array<{ label: string; value: string }> = [ + { label: "All types", value: "" }, + { label: "Team blueprints", value: "team_blueprint" }, + { label: "Agent blueprints", value: "agent_blueprint" }, + { label: "Skills", value: "skill" }, + { label: "Governance templates", value: "governance_template" }, +]; + +const CATEGORY_OPTIONS: Array<{ label: string; value: string }> = [ + { label: "All categories", value: "" }, + { label: "SaaS", value: "saas" }, + { label: "Engineering", value: "engineering" }, + { label: "Marketing", value: "marketing" }, + { label: "Operations", value: "ops" }, + { label: "Content", value: "content" }, + { label: "Finance", value: "finance" }, +]; + +const SORT_OPTIONS: Array<{ label: string; value: string }> = [ + { label: "Most popular", value: "popular" }, + { label: "Newest", value: "newest" }, + { label: "Price: low to high", value: "price_asc" }, + { label: "Price: high to low", value: "price_desc" }, + { label: "Highest rated", value: "rating" }, +]; + +const moneyFormatter = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", +}); + +function listingTypeLabel(type: ListingType): string { + return type.replaceAll("_", " ").replace(/\b\w/g, (char) => char.toUpperCase()); +} + +function parseRating(rating: number | string | null): number | null { + if (typeof rating === "number" && Number.isFinite(rating)) { + return rating; + } + + if (typeof rating === "string") { + const parsed = Number.parseFloat(rating); + if (Number.isFinite(parsed)) { + return parsed; + } + } + + return null; +} + +function formatRating(rating: number | null): string { + if (rating === null) { + return "No ratings"; + } + + return `${rating.toFixed(1)} / 5`; +} + +function centsToDollarInput(centsValue: string | null): string { + if (!centsValue) { + return ""; + } + + const cents = Number.parseInt(centsValue, 10); + if (!Number.isFinite(cents)) { + return ""; + } + + const dollars = cents / 100; + return Number.isInteger(dollars) ? String(dollars) : dollars.toFixed(2); +} + +function OrgChartPreview({ agentCount }: { agentCount: number | null }) { + const count = Math.max(2, Math.min(agentCount ?? 3, 5)); + const leaves = Math.max(2, count - 1); + return ( -
-
-

Marketplace browse

-

- Listing browse UI is being built in a dedicated issue. Creator APIs and wizard flows are available now. -

+
+
+
+ Lead +
+
+
+
+
+ {Array.from({ length: leaves }).map((_, index) => ( + + A{index + 1} + + ))} +
+
+
+ ); +} + +function BrowseSkeleton() { + return ( +
+ {Array.from({ length: 6 }).map((_, index) => ( +
+
+
+
+
+
+
+ ))} +
+ ); +} + +export default function BrowsePage() { + const pathname = usePathname(); + const router = useRouter(); + const searchParams = useSearchParams(); + const [result, setResult] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const queryString = searchParams.toString(); + + const filters = useMemo( + () => ({ + search: searchParams.get("search") ?? "", + type: searchParams.get("type") ?? "", + category: searchParams.get("category") ?? "", + minPrice: centsToDollarInput(searchParams.get("minPrice")), + maxPrice: centsToDollarInput(searchParams.get("maxPrice")), + sort: searchParams.get("sort") ?? "popular", + page: Math.max(1, Number.parseInt(searchParams.get("page") ?? "1", 10)), + limit: Math.max(1, Math.min(48, Number.parseInt(searchParams.get("limit") ?? "12", 10))), + }), + [searchParams] + ); + + useEffect(() => { + let ignore = false; + + async function loadListings() { + setIsLoading(true); + setError(null); + + const params = new URLSearchParams(queryString); + if (!params.get("sort")) { + params.set("sort", "popular"); + } + if (!params.get("page")) { + params.set("page", "1"); + } + if (!params.get("limit")) { + params.set("limit", "12"); + } + + try { + const response = await fetch(`/api/listings?${params.toString()}`, { + cache: "no-store", + }); + + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`); + } + + const payload = (await response.json()) as ListingsResponse; + if (!ignore) { + setResult(payload); + } + } catch (loadError) { + if (!ignore) { + const message = + loadError instanceof Error + ? loadError.message + : "Failed to load listings."; + setError(message); + } + } finally { + if (!ignore) { + setIsLoading(false); + } + } + } + + void loadListings(); + + return () => { + ignore = true; + }; + }, [queryString]); + + function submitFilters(event: FormEvent) { + event.preventDefault(); + + const data = new FormData(event.currentTarget); + const next = new URLSearchParams(); + next.set("sort", String(data.get("sort") || "popular")); + next.set("limit", String(filters.limit)); + next.set("page", "1"); + + for (const key of ["search", "type", "category"]) { + const rawValue = String(data.get(key) || "").trim(); + if (rawValue.length > 0) { + next.set(key, rawValue); + } + } + + for (const key of ["minPrice", "maxPrice"]) { + const rawValue = String(data.get(key) || "").trim(); + if (!rawValue) { + continue; + } + + const dollars = Number.parseFloat(rawValue); + if (Number.isFinite(dollars) && dollars >= 0) { + next.set(key, String(Math.round(dollars * 100))); + } + } + + router.push(`${pathname}?${next.toString()}`); + } + + function resetFilters() { + router.push(pathname); + } + + function gotoPage(page: number) { + const next = new URLSearchParams(searchParams.toString()); + next.set("page", String(page)); + if (!next.get("sort")) { + next.set("sort", "popular"); + } + if (!next.get("limit")) { + next.set("limit", "12"); + } + router.push(`${pathname}?${next.toString()}`); + } + + const listings = result?.data ?? []; + const pagination = result?.pagination ?? { + page: filters.page, + limit: filters.limit, + total: 0, + totalPages: 1, + }; + + return ( +
+
+

ClipMart Marketplace

+

Browse listings

+

+ Filter team blueprints, agent packs, skills, and governance templates for your Paperclip company. +

+
+ +
+
+ + + + + + + + +
+ + +
+ + + + +
+
+ + {isLoading ? ( + + ) : error ? ( +
+

Unable to load listings

+

{error}

+
+ ) : listings.length === 0 ? ( +
+

No listings match these filters

+

+ Try widening your category or price range to discover more blueprints. +

+ +
+ ) : ( +
+
+

+ Showing {(pagination.page - 1) * pagination.limit + 1}- + {Math.min(pagination.page * pagination.limit, pagination.total)} of {pagination.total} +

+
+ +
+ {listings.map((listing) => { + const rating = parseRating(listing.rating); + const creatorName = listing.creatorName || "Unknown creator"; + + return ( +
+ + +

+ {listingTypeLabel(listing.type)} +

+

+ + {listing.title} + +

+

+ {listing.tagline || "No tagline provided."} +

+ +
+ + 👥 {listing.agentCount ?? "?"} agents + + + ⬇ {listing.installCount} installs + + + ★ {formatRating(rating)} ({listing.reviewCount}) + +
+ +
+ + By {creatorName} + +

+ {listing.price > 0 + ? moneyFormatter.format(listing.price / 100) + : "Free"} +

+
+ + + {listing.price > 0 ? "Buy / Install" : "Install"} + +
+ ); + })} +
+ +
+ +

+ Page {pagination.page} of {Math.max(1, pagination.totalPages)} +

+ +
+
+ )}
); }