TinyKit Pro Docs

FAQs Management

TinyKit Pro includes a comprehensive Frequently Asked Questions (FAQ) management system with admin controls for creating, updating, ordering, and publishing ...

TinyKit Pro includes a comprehensive Frequently Asked Questions (FAQ) management system with admin controls for creating, updating, ordering, and publishing FAQs on your public pages.

🎯 Features

Public FAQ Display

  • Active FAQs Only: Only published FAQs visible to public users
  • Custom Ordering: Display FAQs in administrator-defined order
  • Clean Interface: Professional accordion-style FAQ display
  • SEO Friendly: Properly structured content for search engines

Admin Management

  • Full CRUD Operations: Create, read, update, and delete FAQ entries
  • Display Order Control: Drag-and-drop or manual ordering
  • Active/Inactive Toggle: Quickly publish or hide FAQs
  • Auto-Normalization: Automatic gap elimination in display order
  • Timestamp Tracking: Created and updated timestamps for all entries

🏗️ Architecture

Database Schema

faqs: defineTable({
  question: v.string(),
  answer: v.string(),
  displayOrder: v.number(), // Lower number = higher priority
  isActive: v.boolean(),
  createdAt: v.number(),
  updatedAt: v.number(),
})
  .index("by_active", ["isActive"])
  .index("by_display_order", ["displayOrder"])
  .index("by_created_at", ["createdAt"]);

Key Design Decisions

  • Display Order: Lower numbers appear first (1, 2, 3, ...)
  • Active Status: Inactive FAQs hidden from public but retained in database
  • Auto-Normalization: Gaps in display order automatically filled when FAQs are deleted
  • Simple Structure: No categories or tags for maximum flexibility

🚀 Usage

Display FAQs on Public Pages

import { useQuery } from "convex/react";
import { api } from "@/convex/_generated/api";
import {
  Accordion,
  AccordionContent,
  AccordionItem,
  AccordionTrigger,
} from "@/components/ui/accordion";

function FAQSection() {
  const faqs = useQuery(api.faqs.public.queries.listActive);

  if (!faqs || faqs.length === 0) {
    return null; // No FAQs to display
  }

  return (
    <section className="container mx-auto py-20">
      <h2 className="text-3xl font-bold text-center mb-12">
        Frequently Asked Questions
      </h2>

      <Accordion type="single" collapsible className="max-w-3xl mx-auto">
        {faqs.map((faq, index) => (
          <AccordionItem key={faq._id} value={`item-${index}`}>
            <AccordionTrigger className="text-left">
              {faq.question}
            </AccordionTrigger>
            <AccordionContent className="text-muted-foreground">
              {faq.answer}
            </AccordionContent>
          </AccordionItem>
        ))}
      </Accordion>
    </section>
  );
}

Admin FAQ Management

import { useMutation, useQuery } from "convex/react";
import { api } from "@/convex/_generated/api";
import { toast } from "sonner";

function FAQAdmin() {
  const faqs = useQuery(api.faqs.private.queries.listAll);
  const createFAQ = useMutation(api.faqs.private.mutations.create);
  const updateFAQ = useMutation(api.faqs.private.mutations.update);
  const removeFAQ = useMutation(api.faqs.private.mutations.remove);
  const toggleActive = useMutation(api.faqs.private.mutations.toggleActive);

  const handleCreate = async (question: string, answer: string) => {
    const promise = createFAQ({ question, answer });
    toast.promise(promise, {
      loading: "Creating FAQ...",
      success: "FAQ created successfully!",
      error: "Failed to create FAQ",
    });
  };

  const handleToggle = async (id: Id<"faqs">) => {
    const promise = toggleActive({ id });
    toast.promise(promise, {
      loading: "Updating status...",
      success: "FAQ status updated!",
      error: "Failed to update status",
    });
  };

  const handleDelete = async (id: Id<"faqs">) => {
    if (!confirm("Are you sure you want to delete this FAQ?")) return;

    const promise = removeFAQ({ id });
    toast.promise(promise, {
      loading: "Deleting FAQ...",
      success: "FAQ deleted successfully!",
      error: "Failed to delete FAQ",
    });
  };

  // ... render UI
}

⚙️ Admin Functions

Create FAQ

const createFAQ = useMutation(api.faqs.private.mutations.create);

await createFAQ({
  question: "What is your refund policy?",
  answer: "We offer a 30-day money-back guarantee...",
  isActive: true, // Optional, defaults to true
});

Update FAQ

const updateFAQ = useMutation(api.faqs.private.mutations.update);

await updateFAQ({
  id: faqId,
  question: "Updated question text",
  answer: "Updated answer text",
  isActive: true, // Optional
});

Delete FAQ

const removeFAQ = useMutation(api.faqs.private.mutations.remove);

await removeFAQ({ id: faqId });
// Automatically normalizes display order of remaining FAQs

Toggle Active Status

const toggleActive = useMutation(api.faqs.private.mutations.toggleActive);

await toggleActive({ id: faqId });
// Flips isActive from true to false or vice versa

Reorder FAQs

const reorderFAQ = useMutation(api.faqs.private.mutations.reorder);

await reorderFAQ({
  id: faqId,
  newDisplayOrder: 2, // Move to position 2
});
// Automatically adjusts surrounding FAQs

📊 Display Order Management

How Display Order Works

  1. Lower Numbers First: FAQs with displayOrder: 1 appear before displayOrder: 2
  2. Auto-Assignment: New FAQs get the next available order (highest + 1)
  3. Auto-Normalization: Deleting FAQ #2 renumbers others to eliminate gaps
  4. Reordering Logic: Moving FAQs automatically shifts surrounding items

Display Order Examples

// Initial state
FAQ 1: displayOrder = 1
FAQ 2: displayOrder = 2
FAQ 3: displayOrder = 3

// Delete FAQ 2
FAQ 1: displayOrder = 1
FAQ 3: displayOrder = 2  // Automatically renumbered

// Move FAQ 3 to position 1
FAQ 3: displayOrder = 1  // Moved up
FAQ 1: displayOrder = 2  // Shifted down

🎨 Implementation Patterns

Landing Page FAQ Section

function LandingPageFAQs() {
  const faqs = useQuery(api.faqs.public.queries.listActive);

  if (!faqs || faqs.length === 0) {
    return null;
  }

  return (
    <section className="bg-muted/50 py-20">
      <div className="container mx-auto">
        <div className="text-center mb-12">
          <h2 className="text-4xl font-bold mb-4">Common Questions</h2>
          <p className="text-muted-foreground text-lg">
            Everything you need to know about our product
          </p>
        </div>

        <Accordion type="single" collapsible className="max-w-3xl mx-auto">
          {faqs.map((faq, index) => (
            <AccordionItem
              key={faq._id}
              value={`faq-${index}`}
              className="bg-background border rounded-lg mb-4 px-6"
            >
              <AccordionTrigger className="text-left hover:no-underline">
                <span className="font-semibold">{faq.question}</span>
              </AccordionTrigger>
              <AccordionContent className="text-muted-foreground">
                <div className="pt-2 pb-4">{faq.answer}</div>
              </AccordionContent>
            </AccordionItem>
          ))}
        </Accordion>
      </div>
    </section>
  );
}

Admin FAQ Management Interface

function FAQManagement() {
  const faqs = useQuery(api.faqs.private.queries.listAll);
  const createFAQ = useMutation(api.faqs.private.mutations.create);
  const [isCreating, setIsCreating] = useState(false);

  if (!faqs) {
    return <LoadingSpinner />;
  }

  return (
    <div className="container mx-auto py-8">
      <div className="flex justify-between items-center mb-6">
        <h1 className="text-3xl font-bold">FAQ Management</h1>
        <Button onClick={() => setIsCreating(true)}>
          <Plus className="h-4 w-4 mr-2" />
          Add FAQ
        </Button>
      </div>

      <div className="space-y-4">
        {faqs.map((faq, index) => (
          <Card key={faq._id}>
            <CardHeader>
              <div className="flex items-center justify-between">
                <div className="flex items-center gap-4">
                  <span className="text-muted-foreground">
                    #{faq.displayOrder}
                  </span>
                  <Badge variant={faq.isActive ? "default" : "secondary"}>
                    {faq.isActive ? "Active" : "Inactive"}
                  </Badge>
                </div>
                <div className="flex gap-2">
                  <Button
                    variant="ghost"
                    size="sm"
                    onClick={() => handleToggle(faq._id)}
                  >
                    {faq.isActive ? "Hide" : "Show"}
                  </Button>
                  <Button
                    variant="ghost"
                    size="sm"
                    onClick={() => handleEdit(faq)}
                  >
                    Edit
                  </Button>
                  <Button
                    variant="ghost"
                    size="sm"
                    onClick={() => handleDelete(faq._id)}
                  >
                    Delete
                  </Button>
                </div>
              </div>
            </CardHeader>
            <CardContent>
              <h3 className="font-semibold mb-2">{faq.question}</h3>
              <p className="text-muted-foreground">{faq.answer}</p>
            </CardContent>
          </Card>
        ))}
      </div>

      {isCreating && (
        <CreateFAQDialog
          onClose={() => setIsCreating(false)}
          onCreate={createFAQ}
        />
      )}
    </div>
  );
}

🔒 Access Control

All admin FAQ functions require proper authorization:

// Only admin can manage FAQs
await requireAccess(ctx, {
  userRole: ["admin"],
});

Required Roles:

  • Admin: Full FAQ management access
  • User: Read-only access to active FAQs (public)

📚 API Reference

Public Queries

listActive

// Get all active FAQs sorted by display order
const faqs = await ctx.runQuery(api.faqs.public.queries.listActive);

// Returns: Array<{
//   _id: Id<"faqs">;
//   question: string;
//   answer: string;
//   displayOrder: number;
//   isActive: boolean;
//   createdAt: number;
//   updatedAt: number;
// }>

Private Mutations

create

await ctx.runMutation(api.faqs.private.mutations.create, {
  question: "Your question here?",
  answer: "Your answer here.",
  isActive: true, // Optional, defaults to true
});

update

await ctx.runMutation(api.faqs.private.mutations.update, {
  id: faqId,
  question: "Updated question",
  answer: "Updated answer",
  isActive: true,
});

remove

await ctx.runMutation(api.faqs.private.mutations.remove, {
  id: faqId,
});
// Auto-normalizes display order

toggleActive

await ctx.runMutation(api.faqs.private.mutations.toggleActive, {
  id: faqId,
});

reorder

await ctx.runMutation(api.faqs.private.mutations.reorder, {
  id: faqId,
  newDisplayOrder: 3, // Move to position 3
});

🧪 Testing

Manual Testing Checklist

  • Create FAQ works and assigns correct displayOrder
  • Update FAQ preserves displayOrder
  • Delete FAQ normalizes remaining displayOrders
  • Toggle active status works correctly
  • Reorder FAQ updates surrounding items correctly
  • Public query only returns active FAQs
  • FAQs display in correct order
  • Admin requires proper permissions

Edge Cases to Test

// Empty FAQ list
faqs = [];

// Single FAQ
faqs = [{ question: "Q1", displayOrder: 1 }];

// Reorder to same position (no-op)
reorder({ id, newDisplayOrder: currentOrder });

// Delete last FAQ
remove({ id: lastFaqId });

// Toggle all FAQs inactive (public should show none)
toggleActive for all FAQs;

🎯 Best Practices

Content Guidelines

  1. Clear Questions: Write questions from user perspective
  2. Concise Answers: Keep answers brief but complete
  3. Common First: Most frequently asked questions at top
  4. Update Regularly: Keep FAQs current with product changes

Display Order Strategy

  1. Start Simple: Use increments of 1 (1, 2, 3, ...)
  2. Let System Normalize: Rely on auto-normalization after deletions
  3. Logical Grouping: Order related questions together
  4. Most Important First: Critical information at the top

Admin Workflow

  1. Draft Mode: Create FAQs as inactive first
  2. Review Content: Ensure accuracy before activating
  3. Gradual Rollout: Activate FAQs one at a time
  4. Monitor Feedback: Update based on user questions

← Previous: Testimonials | Next: Site Banners →

On this page

Ship your startup faster. In minutes.

Get TinyKit Pro