From 7087ba7959d0ecaf5467acc978d7530cbddc4b83 Mon Sep 17 00:00:00 2001 From: Kai Date: Tue, 10 Feb 2026 00:58:06 -0800 Subject: [PATCH] feat: wire partners page to persistent data --- data/partners.json | 72 +++++++---- src/app/api/partners/route.ts | 199 ++++++++++-------------------- src/app/partners/[slug]/page.tsx | 64 +++++----- src/app/partners/page.tsx | 108 +++++++++++++---- src/lib/partners.ts | 201 +++++++++++++++++++++++++++++++ 5 files changed, 426 insertions(+), 218 deletions(-) create mode 100644 src/lib/partners.ts diff --git a/data/partners.json b/data/partners.json index b67d3087..2647e6cd 100644 --- a/data/partners.json +++ b/data/partners.json @@ -1,13 +1,16 @@ [ { + "id": "partner_openclaw", "name": "OpenClaw", - "slug": "openclaw", - "website": "https://openclaw.dev", "description": "Open-source agent runtime and orchestration platform for tool-using autonomous systems.", + "url": "https://openclaw.dev", "logo": "https://placehold.co/120x120/0b1324/06D6A0.png?text=OC", "tier": "founding", + "category": "runtime", + "tags": ["orchestration", "agent-runtime", "tools"], + "updatedAt": "2026-02-10T08:45:00.000Z", + "slug": "openclaw", "featured": true, - "joinedAt": "2025-10-12", "fullDescription": "OpenClaw powers production AI agents with robust tool routing, memory, and workflow automation.", "features": [ "Multi-agent orchestration", @@ -19,14 +22,17 @@ "contactEmail": "partners@openclaw.dev" }, { + "id": "partner_anthropic", "name": "Anthropic", - "slug": "anthropic", - "website": "https://anthropic.com", "description": "Build reliable long-context workflows with Claude models and advanced reasoning capabilities.", + "url": "https://anthropic.com", "logo": "https://placehold.co/120x120/1a1024/c084fc.png?text=AN", "tier": "founding", + "category": "model", + "tags": ["llm", "safety", "reasoning"], + "updatedAt": "2026-02-10T08:45:00.000Z", + "slug": "anthropic", "featured": true, - "joinedAt": "2025-11-01", "fullDescription": "Anthropic helps teams deliver safe, high-performance AI experiences with Claude APIs.", "features": [ "Long context windows", @@ -38,14 +44,17 @@ "contactEmail": "partnerships@anthropic.com" }, { + "id": "partner_supabase", "name": "Supabase", - "slug": "supabase", - "website": "https://supabase.com", "description": "Postgres, auth, and realtime primitives for persistent agent state and app backends.", + "url": "https://supabase.com", "logo": "https://placehold.co/120x120/0f2a18/22c55e.png?text=SB", "tier": "gold", + "category": "backend", + "tags": ["postgres", "auth", "realtime"], + "updatedAt": "2026-02-10T08:45:00.000Z", + "slug": "supabase", "featured": true, - "joinedAt": "2025-12-03", "fullDescription": "Supabase gives teams a developer-friendly backend for fast shipping and strong data ownership.", "features": [ "Managed Postgres", @@ -57,14 +66,17 @@ "contactEmail": "partners@supabase.com" }, { + "id": "partner_vercel", "name": "Vercel", - "slug": "vercel", - "website": "https://vercel.com", "description": "Global edge hosting and deployment workflows for agent dashboards and APIs.", + "url": "https://vercel.com", "logo": "https://placehold.co/120x120/111111/ffffff.png?text=VE", "tier": "gold", + "category": "hosting", + "tags": ["hosting", "deployment", "edge"], + "updatedAt": "2026-02-10T08:45:00.000Z", + "slug": "vercel", "featured": false, - "joinedAt": "2025-12-15", "fullDescription": "Vercel enables rapid iteration and low-latency experiences for modern web and AI apps.", "features": [ "Instant rollbacks", @@ -76,14 +88,17 @@ "contactEmail": "partners@vercel.com" }, { + "id": "partner_openai", "name": "OpenAI", - "slug": "openai", - "website": "https://openai.com", "description": "State-of-the-art multimodal model APIs for assistants, automation, and analysis.", + "url": "https://openai.com", "logo": "https://placehold.co/120x120/06201b/2dd4bf.png?text=OA", "tier": "silver", + "category": "model", + "tags": ["multimodal", "api", "agents"], + "updatedAt": "2026-02-10T08:45:00.000Z", + "slug": "openai", "featured": true, - "joinedAt": "2026-01-07", "fullDescription": "OpenAI provides flexible APIs for chat, tools, images, and advanced automation use cases.", "features": [ "Multimodal model access", @@ -95,14 +110,17 @@ "contactEmail": "partnerships@openai.com" }, { + "id": "partner_cloudflare", "name": "Cloudflare", - "slug": "cloudflare", - "website": "https://cloudflare.com", "description": "Edge networking, security, and serverless infrastructure for resilient AI products.", + "url": "https://cloudflare.com", "logo": "https://placehold.co/120x120/2c1603/f59e0b.png?text=CF", "tier": "silver", + "category": "infrastructure", + "tags": ["security", "workers", "network"], + "updatedAt": "2026-02-10T08:45:00.000Z", + "slug": "cloudflare", "featured": false, - "joinedAt": "2026-01-15", "fullDescription": "Cloudflare helps agent apps scale globally with secure APIs and edge compute primitives.", "features": [ "DDoS and API protection", @@ -114,14 +132,17 @@ "contactEmail": "partners@cloudflare.com" }, { + "id": "partner_langchain", "name": "LangChain", - "slug": "langchain", - "website": "https://langchain.com", "description": "Composable framework for building LLM applications with memory, retrieval, and tools.", + "url": "https://langchain.com", "logo": "https://placehold.co/120x120/0d1f2e/38bdf8.png?text=LC", "tier": "community", + "category": "framework", + "tags": ["framework", "retrieval", "developer-tools"], + "updatedAt": "2026-02-10T08:45:00.000Z", + "slug": "langchain", "featured": false, - "joinedAt": "2026-01-20", "fullDescription": "LangChain accelerates experimentation with reusable components for model-powered workflows.", "features": [ "Chains and agents", @@ -133,14 +154,17 @@ "contactEmail": "partners@langchain.dev" }, { + "id": "partner_mastra", "name": "Mastra", - "slug": "mastra", - "website": "https://mastra.ai", "description": "Agent-first framework for workflows, tools, and production-ready orchestration.", + "url": "https://mastra.ai", "logo": "https://placehold.co/120x120/1f1022/f472b6.png?text=MA", "tier": "community", + "category": "framework", + "tags": ["workflow", "orchestration", "typescript"], + "updatedAt": "2026-02-10T08:45:00.000Z", + "slug": "mastra", "featured": false, - "joinedAt": "2026-01-28", "fullDescription": "Mastra provides a practical developer toolkit for shipping robust AI workflows faster.", "features": [ "Workflow composition", diff --git a/src/app/api/partners/route.ts b/src/app/api/partners/route.ts index acb7c753..a72d557a 100644 --- a/src/app/api/partners/route.ts +++ b/src/app/api/partners/route.ts @@ -1,87 +1,43 @@ import { NextRequest, NextResponse } from "next/server"; -import { promises as fs } from "fs"; -import path from "path"; import { readJsonWithLimit } from "@/lib/requestLimits"; +import { createPartner, getPartners, type PartnerCategory, type PartnerTier } from "@/lib/partners"; export const runtime = "nodejs"; -export type PartnerTier = "founding" | "gold" | "silver" | "community"; - -export type Partner = { - name: string; - slug: string; - website: string; - description: string; - logo: string; - tier: PartnerTier; - featured: boolean; - joinedAt: string; - fullDescription?: string; - features?: string[]; - integrationGuide?: string; - docsUrl?: string; - contactEmail?: string; -}; - -type PartnerApplicationPayload = { +const MAX_BODY_BYTES = 16_000; +const TIERS: PartnerTier[] = ["founding", "gold", "silver", "community"]; +const CATEGORIES: PartnerCategory[] = [ + "runtime", + "model", + "backend", + "hosting", + "infrastructure", + "framework", +]; + +type CreatePartnerPayload = { + id?: string; name?: string; - website?: string; description?: string; - tierInterest?: string; - contactEmail?: string; + url?: string; + logo?: string; + tier?: string; + category?: string; + tags?: string[]; }; -type PartnerApplication = { - id: string; - name: string; - website: string; - description: string; - tierInterest: PartnerTier; - contactEmail: string; - submittedAt: string; -}; - -const PARTNERS_PATH = path.join(process.cwd(), "data", "partners.json"); -const APPLICATIONS_PATH = path.join(process.cwd(), "data", "partner-applications.json"); -const MAX_BODY_BYTES = 16_000; -const TIERS: PartnerTier[] = ["founding", "gold", "silver", "community"]; - -async function readPartners(): Promise { - try { - const raw = await fs.readFile(PARTNERS_PATH, "utf-8"); - const parsed = JSON.parse(raw) as unknown; - - if (!Array.isArray(parsed)) { - return []; - } - - return parsed as Partner[]; - } catch { - return []; - } -} - -async function readApplications(): Promise { - try { - const raw = await fs.readFile(APPLICATIONS_PATH, "utf-8"); - const parsed = JSON.parse(raw) as unknown; - return Array.isArray(parsed) ? (parsed as PartnerApplication[]) : []; - } catch { - return []; - } -} - -async function writeApplications(applications: PartnerApplication[]) { - await fs.writeFile(APPLICATIONS_PATH, `${JSON.stringify(applications, null, 2)}\n`, "utf-8"); -} - -function normalizeApplication(payload: PartnerApplicationPayload) { +function normalizeCreatePayload(payload: CreatePartnerPayload) { return { + id: payload.id?.trim() ?? "", name: payload.name?.trim() ?? "", - website: payload.website?.trim() ?? "", description: payload.description?.trim() ?? "", - tierInterest: payload.tierInterest?.trim().toLowerCase() ?? "", - contactEmail: payload.contactEmail?.trim().toLowerCase() ?? "", + url: payload.url?.trim() ?? "", + logo: payload.logo?.trim() ?? "", + tier: payload.tier?.trim().toLowerCase() ?? "", + category: payload.category?.trim().toLowerCase() ?? "", + tags: Array.isArray(payload.tags) + ? payload.tags.map((tag) => String(tag).trim()).filter(Boolean) + : [], }; } @@ -94,61 +50,48 @@ function isValidUrl(value: string) { } } -function validateApplication(payload: ReturnType) { +function validateCreatePayload(payload: ReturnType) { const errors: string[] = []; if (!payload.name) errors.push("name is required"); + if (!payload.description) errors.push("description is required"); - if (!payload.website) { - errors.push("website is required"); - } else if (!isValidUrl(payload.website)) { - errors.push("website must be a valid http or https URL"); + if (!payload.url) { + errors.push("url is required"); + } else if (!isValidUrl(payload.url)) { + errors.push("url must be a valid http or https URL"); } - if (!payload.description) errors.push("description is required"); + if (!payload.logo) errors.push("logo is required"); - if (!TIERS.includes(payload.tierInterest as PartnerTier)) { - errors.push('tierInterest must be one of: founding, gold, silver, community'); + if (!TIERS.includes(payload.tier as PartnerTier)) { + errors.push(`tier must be one of: ${TIERS.join(", ")}`); } - if (!payload.contactEmail) { - errors.push("contactEmail is required"); - } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(payload.contactEmail)) { - errors.push("contactEmail must be valid"); + if (!CATEGORIES.includes(payload.category as PartnerCategory)) { + errors.push(`category must be one of: ${CATEGORIES.join(", ")}`); } return errors; } export async function GET(request: NextRequest) { - const partners = await readPartners(); const { searchParams } = new URL(request.url); - const tierFilter = searchParams.get("tier")?.trim().toLowerCase() ?? ""; - const searchFilter = searchParams.get("search")?.trim().toLowerCase() ?? ""; + const tier = searchParams.get("tier") ?? undefined; + const category = searchParams.get("category") ?? undefined; + const search = searchParams.get("search") ?? undefined; - const filtered = partners.filter((partner) => { - const matchesTier = !tierFilter || partner.tier === tierFilter; - - if (!matchesTier) { - return false; - } - - if (!searchFilter) { - return true; - } - - const haystack = `${partner.name} ${partner.description} ${partner.slug}`.toLowerCase(); - return haystack.includes(searchFilter); - }); + const partners = await getPartners({ tier, category, search }); return NextResponse.json( { - partners: filtered, - total: filtered.length, + partners, + total: partners.length, filters: { - tier: tierFilter || null, - search: searchFilter || null, + tier: tier?.trim() || null, + category: category?.trim() || null, + search: search?.trim() || null, }, }, { @@ -161,41 +104,26 @@ export async function GET(request: NextRequest) { export async function POST(request: NextRequest) { try { - const body = await readJsonWithLimit(request, MAX_BODY_BYTES); - const payload = normalizeApplication(body); - const errors = validateApplication(payload); + const body = await readJsonWithLimit(request, MAX_BODY_BYTES); + const payload = normalizeCreatePayload(body); + const errors = validateCreatePayload(payload); if (errors.length > 0) { return NextResponse.json({ error: "Validation failed", details: errors }, { status: 400 }); } - const applications = await readApplications(); - - const application: PartnerApplication = { - id: `pa_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + const created = await createPartner({ + ...(payload.id ? { id: payload.id } : {}), name: payload.name, - website: payload.website, description: payload.description, - tierInterest: payload.tierInterest as PartnerTier, - contactEmail: payload.contactEmail, - submittedAt: new Date().toISOString(), - }; - - applications.push(application); - await writeApplications(applications); - - return NextResponse.json( - { - success: true, - message: "Application received. Our team will review and follow up soon.", - application: { - id: application.id, - tierInterest: application.tierInterest, - submittedAt: application.submittedAt, - }, - }, - { status: 201 } - ); + url: payload.url, + logo: payload.logo, + tier: payload.tier as PartnerTier, + category: payload.category as PartnerCategory, + tags: payload.tags, + }); + + return NextResponse.json({ success: true, partner: created }, { status: 201 }); } catch (err) { const status = typeof err === "object" && err && "status" in err @@ -206,6 +134,9 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Payload too large" }, { status: 413 }); } - return NextResponse.json({ error: "Invalid request body. Expected JSON." }, { status: 400 }); + const message = err instanceof Error ? err.message : "Invalid request body. Expected JSON."; + const responseStatus = message.includes("already exists") || message.includes("Invalid partner payload") ? 400 : 500; + + return NextResponse.json({ error: message }, { status: responseStatus }); } } diff --git a/src/app/partners/[slug]/page.tsx b/src/app/partners/[slug]/page.tsx index 8ba42e60..a43714b4 100644 --- a/src/app/partners/[slug]/page.tsx +++ b/src/app/partners/[slug]/page.tsx @@ -4,27 +4,9 @@ import Link from "next/link"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; -import partnersData from "@/data/partners.json"; - -type PartnerTier = "founding" | "gold" | "silver" | "community"; - -interface Partner { - name: string; - slug: string; - website: string; - description: string; - logo: string; - tier: PartnerTier; - featured: boolean; - joinedAt: string; - fullDescription?: string; - features?: string[]; - integrationGuide?: string; - docsUrl?: string; - contactEmail?: string; -} +import { getPartnerBySlug, getPartners, type Partner, type PartnerTier } from "@/lib/partners"; -const partners: Partner[] = partnersData as Partner[]; +export const dynamic = "force-dynamic"; interface Props { params: Promise<{ slug: string }>; @@ -53,14 +35,13 @@ function getTierBadgeColor(tier: PartnerTier) { } export async function generateStaticParams() { - return partners.map((partner) => ({ - slug: partner.slug, - })); + const partners = await getPartners(); + return partners.map((partner) => ({ slug: partner.slug })); } export async function generateMetadata({ params }: Props) { const { slug } = await params; - const partner = partners.find((p) => p.slug === slug); + const partner = await getPartnerBySlug(slug); if (!partner) { return { @@ -81,24 +62,30 @@ export async function generateMetadata({ params }: Props) { }; } +function getSafePartnerFields(partner: Partner) { + return { + docsUrl: partner.docsUrl ?? partner.url, + contactEmail: partner.contactEmail ?? "partners@foragents.dev", + features: partner.features ?? [ + "Dedicated onboarding support", + "Partner directory listing", + "Cross-promotion opportunities", + ], + integrationGuide: + partner.integrationGuide ?? + `# ${partner.name} integration\nVisit docs and follow the quickstart for your stack.`, + }; +} + export default async function PartnerDetailPage({ params }: Props) { const { slug } = await params; - const partner = partners.find((p) => p.slug === slug); + const partner = await getPartnerBySlug(slug); if (!partner) { notFound(); } - const docsUrl = partner.docsUrl ?? partner.website; - const contactEmail = partner.contactEmail ?? "partners@foragents.dev"; - const features = partner.features ?? [ - "Dedicated onboarding support", - "Partner directory listing", - "Cross-promotion opportunities", - ]; - const integrationGuide = - partner.integrationGuide ?? - `# ${partner.name} integration\nVisit docs and follow the quickstart for your stack.`; + const { docsUrl, contactEmail, features, integrationGuide } = getSafePartnerFields(partner); return (
@@ -143,6 +130,9 @@ export default async function PartnerDetailPage({ params }: Props) { Featured )} + + Updated {new Date(partner.updatedAt).toLocaleDateString()} +

{partner.description}

{partner.fullDescription ?? partner.description}

@@ -213,14 +203,14 @@ export default async function PartnerDetailPage({ params }: Props) {

Partner Website

-

{partner.website}

+

{partner.url}

diff --git a/src/app/partners/page.tsx b/src/app/partners/page.tsx index eee71e22..a69a69ef 100644 --- a/src/app/partners/page.tsx +++ b/src/app/partners/page.tsx @@ -9,16 +9,20 @@ import { Input } from "@/components/ui/input"; import { Separator } from "@/components/ui/separator"; type PartnerTier = "founding" | "gold" | "silver" | "community"; +type PartnerCategory = "runtime" | "model" | "backend" | "hosting" | "infrastructure" | "framework"; type Partner = { + id: string; name: string; - slug: string; - website: string; description: string; + url: string; logo: string; tier: PartnerTier; - featured: boolean; - joinedAt: string; + category: PartnerCategory; + tags: string[]; + updatedAt: string; + slug: string; + featured?: boolean; }; type PartnersResponse = { @@ -43,6 +47,7 @@ const initialForm: PartnerApplicationForm = { }; const tierOrder: PartnerTier[] = ["founding", "gold", "silver", "community"]; +const categoryOrder: PartnerCategory[] = ["runtime", "model", "backend", "hosting", "infrastructure", "framework"]; const tierLabels: Record = { founding: "Founding", @@ -51,6 +56,15 @@ const tierLabels: Record = { community: "Community", }; +const categoryLabels: Record = { + runtime: "Runtime", + model: "Model", + backend: "Backend", + hosting: "Hosting", + infrastructure: "Infrastructure", + framework: "Framework", +}; + function getTierBadgeColor(tier: PartnerTier) { switch (tier) { case "founding": @@ -72,6 +86,7 @@ export default function PartnersPage() { const [error, setError] = useState(null); const [tierFilter, setTierFilter] = useState("all"); + const [categoryFilter, setCategoryFilter] = useState("all"); const [searchQuery, setSearchQuery] = useState(""); const [form, setForm] = useState(initialForm); @@ -86,10 +101,15 @@ export default function PartnersPage() { setLoading(true); const params = new URLSearchParams(); + if (tierFilter !== "all") { params.set("tier", tierFilter); } + if (categoryFilter !== "all") { + params.set("category", categoryFilter); + } + if (searchQuery.trim()) { params.set("search", searchQuery.trim()); } @@ -125,17 +145,11 @@ export default function PartnersPage() { cancelled = true; window.clearTimeout(timeoutId); }; - }, [searchQuery, tierFilter]); + }, [categoryFilter, searchQuery, tierFilter]); - const featuredPartners = useMemo( - () => partners.filter((partner) => partner.featured), - [partners] - ); + const featuredPartners = useMemo(() => partners.filter((partner) => partner.featured), [partners]); - const directoryPartners = useMemo( - () => partners.filter((partner) => !partner.featured), - [partners] - ); + const directoryPartners = useMemo(() => partners.filter((partner) => !partner.featured), [partners]); async function handleApply(event: FormEvent) { event.preventDefault(); @@ -144,12 +158,18 @@ export default function PartnersPage() { setSubmitError(null); try { - const response = await fetch("/api/partners", { + const response = await fetch("/api/partners/apply", { method: "POST", headers: { "Content-Type": "application/json", }, - body: JSON.stringify(form), + body: JSON.stringify({ + name: form.name, + company: form.name, + email: form.contactEmail, + type: "partner", + message: `Tier interest: ${form.tierInterest}\nWebsite: ${form.website}\n\n${form.description}`, + }), }); const payload = (await response.json()) as { @@ -195,14 +215,15 @@ export default function PartnersPage() {
-
+
setSearchQuery(e.target.value)} className="max-w-xl bg-card/30 border-white/10 text-foreground placeholder:text-muted-foreground" /> +
+ +
+ + {categoryOrder.map((category) => ( + + ))} +
{loading ? ( @@ -246,7 +295,7 @@ export default function PartnersPage() {
{featuredPartners.map((partner) => ( @@ -266,7 +315,10 @@ export default function PartnersPage() { {tierLabels[partner.tier]} - Joined {new Date(partner.joinedAt).toLocaleDateString()} + {categoryLabels[partner.category]} + + + Updated {new Date(partner.updatedAt).toLocaleDateString()}
@@ -277,7 +329,7 @@ export default function PartnersPage() { View details → {directoryPartners.map((partner) => ( @@ -315,18 +367,28 @@ export default function PartnersPage() { {tierLabels[partner.tier]} - Joined {new Date(partner.joinedAt).toLocaleDateString()} + {categoryLabels[partner.category]} + + + Updated {new Date(partner.updatedAt).toLocaleDateString()}

{partner.description}

+
+ {partner.tags.slice(0, 3).map((tag) => ( + + #{tag} + + ))} +
View details → ): Partner | null { + const name = input.name?.trim() ?? ""; + const description = input.description?.trim() ?? ""; + const url = input.url?.trim() ?? ""; + const logo = input.logo?.trim() ?? ""; + const tier = input.tier?.trim().toLowerCase() ?? ""; + const category = input.category?.trim().toLowerCase() ?? ""; + + if (!name || !description || !isValidUrl(url) || !logo) { + return null; + } + + if (!isPartnerTier(tier) || !isPartnerCategory(category)) { + return null; + } + + const tags = Array.isArray(input.tags) + ? input.tags + .map((tag) => String(tag).trim().toLowerCase()) + .filter(Boolean) + .slice(0, 12) + : []; + + const slug = input.slug?.trim() || toSlug(name); + + return { + id: input.id?.trim() || `partner_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + name, + description, + url, + logo, + tier, + category, + tags, + updatedAt: input.updatedAt?.trim() || new Date().toISOString(), + slug, + ...(typeof input.featured === "boolean" ? { featured: input.featured } : {}), + ...(input.fullDescription ? { fullDescription: input.fullDescription } : {}), + ...(Array.isArray(input.features) ? { features: input.features } : {}), + ...(input.integrationGuide ? { integrationGuide: input.integrationGuide } : {}), + ...(input.docsUrl ? { docsUrl: input.docsUrl } : {}), + ...(input.contactEmail ? { contactEmail: input.contactEmail } : {}), + }; +} + +export async function readPartners(): Promise { + try { + const raw = await fs.readFile(PARTNERS_PATH, "utf-8"); + const parsed = JSON.parse(raw) as unknown; + + if (!Array.isArray(parsed)) { + return []; + } + + return parsed + .map((entry) => normalizePartner(entry as Partial)) + .filter((entry): entry is Partner => Boolean(entry)); + } catch { + return []; + } +} + +export async function writePartners(partners: Partner[]) { + await fs.writeFile(PARTNERS_PATH, `${JSON.stringify(partners, null, 2)}\n`, "utf-8"); +} + +export async function getPartners(filters: PartnerFilters = {}): Promise { + const partners = await readPartners(); + + const tierFilter = filters.tier?.trim().toLowerCase(); + const categoryFilter = filters.category?.trim().toLowerCase(); + const searchFilter = filters.search?.trim().toLowerCase(); + + return partners.filter((partner) => { + if (tierFilter && partner.tier !== tierFilter) { + return false; + } + + if (categoryFilter && partner.category !== categoryFilter) { + return false; + } + + if (!searchFilter) { + return true; + } + + const haystack = `${partner.name} ${partner.description} ${partner.slug} ${partner.category} ${partner.tags.join(" ")}`.toLowerCase(); + return haystack.includes(searchFilter); + }); +} + +export async function getPartnerBySlug(slug: string): Promise { + const partners = await readPartners(); + return partners.find((partner) => partner.slug === slug); +} + +export async function createPartner(payload: Partial): Promise { + const normalized = normalizePartner(payload); + + if (!normalized) { + throw new Error("Invalid partner payload"); + } + + const partners = await readPartners(); + + const slugExists = partners.some((partner) => partner.slug === normalized.slug); + if (slugExists) { + throw new Error("Partner slug already exists"); + } + + const idExists = partners.some((partner) => partner.id === normalized.id); + if (idExists) { + normalized.id = `partner_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + } + + const created = { + ...normalized, + updatedAt: new Date().toISOString(), + }; + + partners.push(created); + await writePartners(partners); + + return created; +}