diff --git a/__tests__/faq-page.test.tsx b/__tests__/faq-page.test.tsx index b4c52e4a..dc40915a 100644 --- a/__tests__/faq-page.test.tsx +++ b/__tests__/faq-page.test.tsx @@ -1,7 +1,7 @@ /** @jest-environment jsdom */ import React from "react"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import FAQPage from "../src/app/faq/page"; type LinkProps = { @@ -19,7 +19,38 @@ jest.mock("next/link", () => { return LinkMock; }); +const mockFaqResponse = { + faqs: [ + { + id: "faq-getting-started-1", + category: "getting-started", + question: "What is forAgents.dev in one sentence?", + answer: + "forAgents.dev is an agent-first directory of reusable skills, MCP servers, templates, and guides.", + }, + { + id: "faq-skills-1", + category: "skills", + question: "What is a skill on forAgents.dev?", + answer: "A skill is a focused capability package with docs and install instructions.", + }, + ], + total: 15, + categories: ["getting-started", "skills", "mcp", "pricing", "agents"], +}; + describe("FAQ Page", () => { + beforeEach(() => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => mockFaqResponse, + }) as jest.Mock; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + it("renders the FAQ page", () => { const { container } = render(); expect(container).toBeInTheDocument(); @@ -32,84 +63,60 @@ describe("FAQ Page", () => { it("displays the search bar", () => { render(); - const searchInput = screen.getByPlaceholderText("Search questions..."); - expect(searchInput).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Search questions...")).toBeInTheDocument(); }); - it("displays all category sections", () => { + it("shows category tabs", async () => { render(); - expect(screen.getByText("General")).toBeInTheDocument(); - expect(screen.getByText("Getting Started")).toBeInTheDocument(); - expect(screen.getByText("Skills & Kits")).toBeInTheDocument(); - expect(screen.getByText("Billing & Pricing")).toBeInTheDocument(); - expect(screen.getByText("Security")).toBeInTheDocument(); - expect(screen.getByText("API")).toBeInTheDocument(); - expect(screen.getByText("Enterprise")).toBeInTheDocument(); - }); - it("displays question count for each category", () => { - render(); - // Each category should have (4) next to it - const categoryHeaders = screen.getAllByText(/\(4\)/); - expect(categoryHeaders.length).toBeGreaterThan(0); + expect(await screen.findByRole("button", { name: "All" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Getting Started" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Skills" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "MCP" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Pricing" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Agents" })).toBeInTheDocument(); }); - it("filters questions based on search input", () => { + it("displays result count after searching", async () => { render(); const searchInput = screen.getByPlaceholderText("Search questions..."); - - fireEvent.change(searchInput, { target: { value: "API" } }); - - // Should show result count - expect(screen.getByText(/result\(s\) found/)).toBeInTheDocument(); + + fireEvent.change(searchInput, { target: { value: "skill" } }); + + await waitFor(() => { + expect(screen.getByText(/result\(s\) found/)).toBeInTheDocument(); + }); }); - it("displays 'Still need help?' CTA section", () => { + it("includes CTA links", () => { render(); expect(screen.getByText("Still need help?")).toBeInTheDocument(); - expect(screen.getByText("Join Community")).toBeInTheDocument(); - expect(screen.getByText("Contact Support")).toBeInTheDocument(); - }); - it("includes link to community page", () => { - render(); const communityLink = screen.getByText("Join Community").closest("a"); expect(communityLink).toHaveAttribute("href", "/community"); - }); - it("includes link to support email", () => { - render(); const supportLink = screen.getByText("Contact Support").closest("a"); expect(supportLink).toHaveAttribute("href", "mailto:support@foragents.dev"); }); - it("displays thumbs up/down feedback buttons when accordion is opened", () => { + it("displays thumbs up/down feedback buttons when accordion is opened", async () => { render(); - - // Find and click on the first accordion trigger to open it - const firstQuestion = screen.getByText("What is forAgents.dev?"); + + const firstQuestion = await screen.findByText("What is forAgents.dev in one sentence?"); fireEvent.click(firstQuestion); - - // Now the feedback buttons should be visible - const thumbsUpButtons = screen.getAllByLabelText("Thumbs up"); - const thumbsDownButtons = screen.getAllByLabelText("Thumbs down"); - - // Should have at least one of each button - expect(thumbsUpButtons.length).toBeGreaterThan(0); - expect(thumbsDownButtons.length).toBeGreaterThan(0); - }); - it("shows no results message when search returns empty", () => { - render(); - const searchInput = screen.getByPlaceholderText("Search questions..."); - - fireEvent.change(searchInput, { target: { value: "xyznonexistentquery123" } }); - - expect(screen.getByText(/No questions found matching/)).toBeInTheDocument(); + expect(screen.getAllByLabelText("Thumbs up").length).toBeGreaterThan(0); + expect(screen.getAllByLabelText("Thumbs down").length).toBeGreaterThan(0); }); - it("displays total question count in hero section", () => { + it("calls faq api", async () => { render(); - expect(screen.getByText(/28 questions across/)).toBeInTheDocument(); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + "/api/faq", + expect.objectContaining({ method: "GET", cache: "no-store" }) + ); + }); }); }); diff --git a/data/faq.json b/data/faq.json new file mode 100644 index 00000000..cd1f466e --- /dev/null +++ b/data/faq.json @@ -0,0 +1,94 @@ +{ + "faqs": [ + { + "id": "faq-getting-started-1", + "category": "getting-started", + "question": "What is forAgents.dev in one sentence?", + "answer": "forAgents.dev is an agent-first directory of reusable skills, MCP servers, templates, and guides that help AI agents become useful faster." + }, + { + "id": "faq-getting-started-2", + "category": "getting-started", + "question": "How do I bootstrap a new agent with forAgents.dev?", + "answer": "Start with /bootstrap, copy the bootstrap prompt or JSON, and load it into your agent so it understands project structure, skills, and best practices." + }, + { + "id": "faq-getting-started-3", + "category": "getting-started", + "question": "Do I need an account to use forAgents.dev?", + "answer": "No account is required for public browsing and basic usage. Accounts are useful for submissions, personalization, and premium features." + }, + { + "id": "faq-skills-1", + "category": "skills", + "question": "What is a skill on forAgents.dev?", + "answer": "A skill is a focused capability package with docs and install instructions that an agent can adopt, such as web scraping, summarization, or workflow orchestration." + }, + { + "id": "faq-skills-2", + "category": "skills", + "question": "How do I know if a skill is safe to install?", + "answer": "Review verification signals, source links, docs quality, and community feedback before installing. Prefer transparent, well-maintained skills with clear permissions." + }, + { + "id": "faq-skills-3", + "category": "skills", + "question": "Can I publish my own skill?", + "answer": "Yes. Use the submission flow, include clear documentation, and provide reproducible install steps so agents and developers can evaluate your skill quickly." + }, + { + "id": "faq-mcp-1", + "category": "mcp", + "question": "Does forAgents.dev support MCP servers?", + "answer": "Yes. MCP is a first-class concept on forAgents.dev, including server discovery, integration details, and health metadata where available." + }, + { + "id": "faq-mcp-2", + "category": "mcp", + "question": "What MCP details are shown on listings?", + "answer": "Listings can include install commands, transport type, required environment variables, and operational notes so agents can integrate with fewer failures." + }, + { + "id": "faq-mcp-3", + "category": "mcp", + "question": "How can I troubleshoot MCP integration issues?", + "answer": "Check server logs, validate required env vars, verify transport configuration, and test with minimal prompts before scaling to production workflows." + }, + { + "id": "faq-pricing-1", + "category": "pricing", + "question": "Is forAgents.dev free to use?", + "answer": "Core discovery and many resources are available for free. Some advanced features and premium workflows are part of paid plans." + }, + { + "id": "faq-pricing-2", + "category": "pricing", + "question": "What does premium unlock?", + "answer": "Premium can include expanded discovery tools, deeper analytics, higher limits, and priority experiences for active builders and teams." + }, + { + "id": "faq-pricing-3", + "category": "pricing", + "question": "Do listed tools have separate costs?", + "answer": "Often yes. forAgents.dev indexes tools, skills, and MCP servers that may have their own pricing, so always review each listing's licensing details." + }, + { + "id": "faq-agents-1", + "category": "agents", + "question": "Which agent frameworks work with forAgents.dev?", + "answer": "forAgents.dev is framework-agnostic and works with many agent stacks as long as they can consume structured docs, APIs, or MCP integrations." + }, + { + "id": "faq-agents-2", + "category": "agents", + "question": "Can teams standardize agent behavior using forAgents.dev?", + "answer": "Yes. Teams can use shared templates, preferred skill sets, and onboarding docs to make agents more consistent across projects and environments." + }, + { + "id": "faq-agents-3", + "category": "agents", + "question": "How often is content updated?", + "answer": "The catalog evolves continuously as new skills, MCP servers, and guides are submitted and curated by the community and maintainers." + } + ] +} diff --git a/src/app/api/faq/route.ts b/src/app/api/faq/route.ts new file mode 100644 index 00000000..99639f34 --- /dev/null +++ b/src/app/api/faq/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from "next/server"; +import { promises as fs } from "node:fs"; +import path from "node:path"; + +const FAQ_CATEGORIES = ["getting-started", "skills", "mcp", "pricing", "agents"] as const; +type FaqCategory = (typeof FAQ_CATEGORIES)[number]; + +type FaqItem = { + id: string; + category: FaqCategory; + question: string; + answer: string; +}; + +type FaqFile = { + faqs: FaqItem[]; +}; + +async function readFaqFile(): Promise { + const faqPath = path.join(process.cwd(), "data", "faq.json"); + const raw = await fs.readFile(faqPath, "utf8"); + const parsed = JSON.parse(raw) as FaqFile; + + if (!parsed || !Array.isArray(parsed.faqs)) { + return []; + } + + return parsed.faqs.filter( + (faq): faq is FaqItem => + typeof faq?.id === "string" && + typeof faq?.question === "string" && + typeof faq?.answer === "string" && + typeof faq?.category === "string" && + FAQ_CATEGORIES.includes(faq.category as FaqCategory) + ); +} + +export async function GET(request: NextRequest) { + try { + const allFaqs = await readFaqFile(); + + const search = request.nextUrl.searchParams.get("search")?.trim().toLowerCase() ?? ""; + const categoryParam = request.nextUrl.searchParams.get("category")?.trim().toLowerCase() ?? ""; + + if (categoryParam && !FAQ_CATEGORIES.includes(categoryParam as FaqCategory)) { + return NextResponse.json( + { + error: "Invalid category", + allowed: FAQ_CATEGORIES, + }, + { status: 400 } + ); + } + + let filtered = allFaqs; + + if (categoryParam) { + filtered = filtered.filter((faq) => faq.category === categoryParam); + } + + if (search) { + filtered = filtered.filter((faq) => { + const haystack = `${faq.question} ${faq.answer}`.toLowerCase(); + return haystack.includes(search); + }); + } + + return NextResponse.json( + { + faqs: filtered, + total: filtered.length, + categories: FAQ_CATEGORIES, + }, + { + headers: { + "Cache-Control": "no-store", + }, + } + ); + } catch { + return NextResponse.json( + { + faqs: [], + total: 0, + categories: FAQ_CATEGORIES, + }, + { status: 200 } + ); + } +} diff --git a/src/app/faq/page.tsx b/src/app/faq/page.tsx index e94d8cf5..d35e0485 100644 --- a/src/app/faq/page.tsx +++ b/src/app/faq/page.tsx @@ -1,10 +1,11 @@ "use client"; -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Accordion, AccordionContent, @@ -12,54 +13,148 @@ import { AccordionTrigger, } from "@/components/ui/accordion"; import { Search, ThumbsUp, ThumbsDown, MessageCircle, Mail } from "lucide-react"; -import faqData from "@/data/faq.json"; type FeedbackState = Record; +type FaqCategory = "getting-started" | "skills" | "mcp" | "pricing" | "agents"; +type TabValue = "all" | FaqCategory; + +type FaqItem = { + id: string; + category: FaqCategory; + question: string; + answer: string; +}; + +type FaqApiResponse = { + faqs: FaqItem[]; + total: number; + categories: FaqCategory[]; +}; + +const FALLBACK_CATEGORIES: FaqCategory[] = [ + "getting-started", + "skills", + "mcp", + "pricing", + "agents", +]; + +const CATEGORY_LABELS: Record = { + "getting-started": "Getting Started", + skills: "Skills", + mcp: "MCP", + pricing: "Pricing", + agents: "Agents", +}; + export default function FAQPage() { - const [searchQuery, setSearchQuery] = useState(""); + const [searchInput, setSearchInput] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [activeCategory, setActiveCategory] = useState("all"); + const [faqData, setFaqData] = useState({ + faqs: [], + total: 0, + categories: FALLBACK_CATEGORIES, + }); const [feedback, setFeedback] = useState({}); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + const timeout = setTimeout(() => { + setDebouncedSearch(searchInput.trim()); + }, 250); + + return () => clearTimeout(timeout); + }, [searchInput]); + + useEffect(() => { + const controller = new AbortController(); + + const fetchFaqs = async () => { + setIsLoading(true); + + try { + const params = new URLSearchParams(); + if (debouncedSearch) { + params.set("search", debouncedSearch); + } + if (activeCategory !== "all") { + params.set("category", activeCategory); + } + + const query = params.toString(); + const url = query ? `/api/faq?${query}` : "/api/faq"; + + const response = await fetch(url, { + method: "GET", + signal: controller.signal, + cache: "no-store", + }); + + if (!response.ok) { + throw new Error(`Failed to load FAQs (${response.status})`); + } + + const data = (await response.json()) as FaqApiResponse; + setFaqData({ + faqs: Array.isArray(data.faqs) ? data.faqs : [], + total: typeof data.total === "number" ? data.total : 0, + categories: + Array.isArray(data.categories) && data.categories.length > 0 + ? data.categories + : FALLBACK_CATEGORIES, + }); + } catch (error) { + if ((error as Error).name !== "AbortError") { + setFaqData({ + faqs: [], + total: 0, + categories: FALLBACK_CATEGORIES, + }); + } + } finally { + if (!controller.signal.aborted) { + setIsLoading(false); + } + } + }; + + void fetchFaqs(); + + return () => controller.abort(); + }, [debouncedSearch, activeCategory]); + + const groupedFaqs = useMemo(() => { + const groups = new Map(); + + faqData.faqs.forEach((faq) => { + const existing = groups.get(faq.category) ?? []; + existing.push(faq); + groups.set(faq.category, existing); + }); + + return groups; + }, [faqData.faqs]); + + const orderedCategories = faqData.categories.length > 0 ? faqData.categories : FALLBACK_CATEGORIES; - // Generate JSON-LD schema for FAQ const faqJsonLd = { "@context": "https://schema.org", "@type": "FAQPage", name: "FAQ — forAgents.dev", - description: "Frequently asked questions about forAgents.dev, the central hub for AI agent development.", + description: "Frequently asked questions about forAgents.dev.", url: "https://foragents.dev/faq", - mainEntity: faqData.categories.flatMap((category) => - category.questions.map((q) => ({ - "@type": "Question", - name: q.question, - acceptedAnswer: { - "@type": "Answer", - text: q.answer, - }, - })) - ), + mainEntity: faqData.faqs.map((faq) => ({ + "@type": "Question", + name: faq.question, + acceptedAnswer: { + "@type": "Answer", + text: faq.answer, + }, + })), }; - // Filter questions based on search query - const getFilteredCategories = () => { - if (!searchQuery.trim()) { - return faqData.categories; - } - - const query = searchQuery.toLowerCase(); - return faqData.categories - .map((category) => ({ - ...category, - questions: category.questions.filter( - (q) => - q.question.toLowerCase().includes(query) || - q.answer.toLowerCase().includes(query) - ), - })) - .filter((category) => category.questions.length > 0); - }; - - const filteredCategories = getFilteredCategories(); - const handleFeedback = (questionId: string, type: "up" | "down") => { setFeedback((prev) => ({ ...prev, @@ -67,9 +162,58 @@ export default function FAQPage() { })); }; - const totalQuestions = faqData.categories.reduce( - (sum, cat) => sum + cat.questions.length, - 0 + const renderFaqSection = (category: FaqCategory, questions: FaqItem[]) => ( +
+

+ {CATEGORY_LABELS[category]} + ({questions.length}) +

+ + + + + {questions.map((question) => ( + + + {question.question} + + +
{question.answer}
+ +
+ Was this helpful? +
+ + +
+
+
+
+ ))} +
+
+
+
); return ( @@ -79,7 +223,6 @@ export default function FAQPage() { dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }} /> - {/* Hero Section */}
@@ -89,132 +232,81 @@ export default function FAQPage() {

Frequently Asked Questions

-

- Everything you need to know about forAgents.dev -

+

Everything you need to know about forAgents.dev

- {totalQuestions} questions across {faqData.categories.length} categories + {faqData.total} questions across {orderedCategories.length} categories

- {/* Search Section */} -
+
setSearchQuery(e.target.value)} + value={searchInput} + onChange={(e) => setSearchInput(e.target.value)} className="w-full pl-12 pr-4 py-6 text-lg bg-[#0f0f0f] border-white/10 focus:border-[#06D6A0] rounded-lg" />
- {searchQuery && ( -

- {filteredCategories.reduce((sum, cat) => sum + cat.questions.length, 0)}{" "} - result(s) found -

+ + {(debouncedSearch || activeCategory !== "all") && ( +

{faqData.total} result(s) found

)}
- {/* FAQ Content */} +
+ setActiveCategory(value as TabValue)}> + + All + {orderedCategories.map((category) => ( + + {CATEGORY_LABELS[category]} + + ))} + + +
+
- {filteredCategories.length === 0 ? ( + {isLoading ? ( + +

Loading FAQs...

+
+ ) : faqData.faqs.length === 0 ? (

- No questions found matching "{searchQuery}" + No questions found matching "{debouncedSearch || activeCategory}"

) : (
- {filteredCategories.map((category) => ( -
-

- {category.name} - - ({category.questions.length}) - -

- - - - - {category.questions.map((question) => ( - - - - {question.question} - - - -
- {question.answer} -
- - {/* Feedback Section */} -
- - Was this helpful? - -
- - -
-
-
-
- ))} -
-
-
-
- ))} + {activeCategory === "all" + ? orderedCategories + .filter((category) => groupedFaqs.has(category)) + .map((category) => renderFaqSection(category, groupedFaqs.get(category) ?? [])) + : renderFaqSection(activeCategory, groupedFaqs.get(activeCategory) ?? [])}
)}
- {/* CTA Section */}
-

- Still need help? -

+

Still need help?

- Can't find what you're looking for? Our community and support team - are here to help you get the most out of forAgents.dev. + Can't find what you're looking for? Our community and support team are here to help you get the most out of forAgents.dev.

@@ -225,10 +317,7 @@ export default function FAQPage() { - @@ -238,10 +327,7 @@ export default function FAQPage() {

Looking for detailed documentation?{" "} - + Check out our API docs