Testimonials Management
TinyKit Pro includes a flexible testimonials system that supports both Twitter/X tweet embeds and custom testimonials, perfect for showcasing social proof on...
TinyKit Pro includes a flexible testimonials system that supports both Twitter/X tweet embeds and custom testimonials, perfect for showcasing social proof on your landing pages.
🎯 Features
Dual Testimonial Types
- Tweet Testimonials: Embed real tweets from Twitter/X with one click
- Custom Testimonials: Full control with text, author info, ratings, and avatars
- Unified Management: Single interface for both types
- Rich Metadata: Author names, handles, companies, images, and ratings
Display Control
- Active/Inactive Toggle: Quickly publish or hide testimonials
- Custom Ordering: Control display order with drag-and-drop or manual positioning
- Featured Testimonials: Highlight special testimonials prominently
- Auto-Normalization: Automatic gap elimination in display order
Asset Management
- Avatar Storage: Upload avatars to Convex storage or use external URLs
- Tweet Integration: Automatic tweet rendering via
react-tweet - Flexible Display: Choose between grid, carousel, or masonry layouts
🏗️ Architecture
Database Schema
testimonials: defineTable({
// Type control
type: v.union(v.literal("tweet"), v.literal("custom")),
// Tweet-specific fields
tweetId: v.optional(v.string()),
tweetUrl: v.optional(v.string()),
// Custom testimonial fields
authorName: v.optional(v.string()),
authorHandle: v.optional(v.string()), // @username
authorImage: v.optional(v.string()), // External URL
authorImageId: v.optional(v.id("_storage")), // Convex storage
authorCompany: v.optional(v.string()),
content: v.optional(v.string()),
rating: v.optional(v.number()), // 1-5 stars
// Display control
isActive: v.boolean(),
displayOrder: v.number(),
featured: v.optional(v.boolean()),
// Metadata
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_active", ["isActive"])
.index("by_display_order", ["displayOrder"])
.index("by_featured", ["featured"])
.index("by_type", ["type"]);🚀 Usage
Display Testimonials (Tweet Type)
import { useQuery } from "convex/react";
import { api } from "@/convex/_generated/api";
import { Tweet } from "react-tweet";
function TestimonialsSection() {
const testimonials = useQuery(api.testimonials.public.queries.listActive);
if (!testimonials || testimonials.length === 0) {
return null;
}
return (
<section className="container mx-auto py-20">
<h2 className="text-3xl font-bold text-center mb-12">
What People Are Saying
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{testimonials.map((testimonial) => (
<div key={testimonial._id}>
{testimonial.type === "tweet" && testimonial.tweetId ? (
<Tweet id={testimonial.tweetId} />
) : (
<CustomTestimonialCard testimonial={testimonial} />
)}
</div>
))}
</div>
</section>
);
}Custom Testimonial Card
import { Star } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
function CustomTestimonialCard({ testimonial }) {
return (
<Card>
<CardContent className="pt-6">
{/* Rating */}
{testimonial.rating && (
<div className="flex gap-1 mb-4">
{Array.from({ length: 5 }).map((_, i) => (
<Star
key={i}
className={`h-4 w-4 ${
i < testimonial.rating
? "fill-primary text-primary"
: "text-muted"
}`}
/>
))}
</div>
)}
{/* Content */}
<p className="text-muted-foreground mb-4">{testimonial.content}</p>
{/* Author */}
<div className="flex items-center gap-3">
{testimonial.authorImageId ? (
<img
src={getImageUrl(testimonial.authorImageId)}
alt={testimonial.authorName || "User"}
className="w-10 h-10 rounded-full"
/>
) : testimonial.authorImage ? (
<img
src={testimonial.authorImage}
alt={testimonial.authorName || "User"}
className="w-10 h-10 rounded-full"
/>
) : (
<div className="w-10 h-10 rounded-full bg-muted" />
)}
<div>
<p className="font-semibold">{testimonial.authorName}</p>
{testimonial.authorHandle && (
<p className="text-sm text-muted-foreground">
@{testimonial.authorHandle}
</p>
)}
{testimonial.authorCompany && (
<p className="text-sm text-muted-foreground">
{testimonial.authorCompany}
</p>
)}
</div>
</div>
</CardContent>
</Card>
);
}Create Tweet Testimonial
const createTestimonial = useMutation(
api.testimonials.private.mutations.create,
);
await createTestimonial({
type: "tweet",
tweetId: "1234567890", // Extract from tweet URL
tweetUrl: "https://twitter.com/user/status/1234567890",
isActive: true,
});Create Custom Testimonial
const createTestimonial = useMutation(
api.testimonials.private.mutations.create,
);
await createTestimonial({
type: "custom",
authorName: "Jane Doe",
authorHandle: "janedoe",
authorCompany: "Acme Corp",
content: "This product changed my workflow completely!",
rating: 5,
featured: true,
isActive: true,
});⚙️ Admin Functions
Create Testimonial
const createTestimonial = useMutation(
api.testimonials.private.mutations.create,
);
// Tweet testimonial
await createTestimonial({
type: "tweet",
tweetId: "1234567890",
tweetUrl: "https://twitter.com/user/status/1234567890",
isActive: true,
});
// Custom testimonial with all fields
await createTestimonial({
type: "custom",
authorName: "John Smith",
authorHandle: "johnsmith",
authorCompany: "Tech Startup Inc",
authorImage: "https://example.com/avatar.jpg", // External URL
content: "Amazing product! Highly recommended.",
rating: 5,
featured: true,
isActive: true,
});Update Testimonial
const updateTestimonial = useMutation(
api.testimonials.private.mutations.update,
);
await updateTestimonial({
id: testimonialId,
content: "Updated testimonial text",
rating: 4,
isActive: true,
});Upload Avatar to Convex Storage
const generateUploadUrl = useMutation(
api.testimonials.private.mutations.generateUploadUrl,
);
const updateWithAvatar = useMutation(
api.testimonials.private.mutations.updateWithAvatar,
);
// 1. Generate upload URL
const uploadUrl = await generateUploadUrl();
// 2. Upload file
const response = await fetch(uploadUrl, {
method: "POST",
body: file,
});
const { storageId } = await response.json();
// 3. Update testimonial with avatar
await updateWithAvatar({
id: testimonialId,
authorImageId: storageId,
});Delete Testimonial
const removeTestimonial = useMutation(
api.testimonials.private.mutations.remove,
);
await removeTestimonial({ id: testimonialId });
// Automatically normalizes display orderToggle Active Status
const toggleActive = useMutation(
api.testimonials.private.mutations.toggleActive,
);
await toggleActive({ id: testimonialId });Reorder Testimonials
const reorderTestimonial = useMutation(
api.testimonials.private.mutations.reorder,
);
await reorderTestimonial({
id: testimonialId,
newDisplayOrder: 2,
});🎨 Implementation Patterns
Featured Testimonials Section
function FeaturedTestimonials() {
const featured = useQuery(api.testimonials.public.queries.listFeatured);
if (!featured || featured.length === 0) {
return null;
}
return (
<section className="bg-primary/5 py-20">
<div className="container mx-auto">
<h2 className="text-4xl font-bold text-center mb-12">
Featured Reviews
</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 max-w-5xl mx-auto">
{featured.map((testimonial) => (
<TestimonialCard
key={testimonial._id}
testimonial={testimonial}
variant="featured"
/>
))}
</div>
</div>
</section>
);
}Tweet Wall
import { Tweet } from "react-tweet";
function TweetWall() {
const tweets = useQuery(api.testimonials.public.queries.listByType, {
type: "tweet",
});
if (!tweets || tweets.length === 0) {
return null;
}
return (
<section className="container mx-auto py-20">
<h2 className="text-3xl font-bold text-center mb-12">
What They're Saying on X
</h2>
<div className="columns-1 md:columns-2 lg:columns-3 gap-6 space-y-6">
{tweets.map((tweet) => (
<div key={tweet._id} className="break-inside-avoid">
<Tweet id={tweet.tweetId!} />
</div>
))}
</div>
</section>
);
}📊 Display Patterns
Display Order
- Lower Numbers First:
displayOrder: 1beforedisplayOrder: 2 - Auto-Assignment: New testimonials get next available order
- Auto-Normalization: Gaps automatically filled when deleting
- Featured Priority: Featured testimonials can be shown separately
Filtering Options
// All active testimonials
const all = useQuery(api.testimonials.public.queries.listActive);
// Featured only
const featured = useQuery(api.testimonials.public.queries.listFeatured);
// By type
const tweets = useQuery(api.testimonials.public.queries.listByType, {
type: "tweet",
});
const custom = useQuery(api.testimonials.public.queries.listByType, {
type: "custom",
});🔒 Access Control
All admin functions require proper authorization:
await requireAccess(ctx, {
userRole: ["admin"],
});Required Roles:
- Admin: Full testimonial management
- User: Read-only access to active testimonials (public)
📚 API Reference
Public Queries
listActive - Get all active testimonials sorted by display order
listFeatured - Get featured testimonials only
listByType - Get testimonials filtered by type (tweet/custom)
Private Mutations
create - Create new testimonial (tweet or custom)
update - Update existing testimonial
remove - Delete testimonial (auto-normalizes order)
toggleActive - Toggle active status
reorder - Change display order
generateUploadUrl - Generate URL for avatar upload
updateWithAvatar - Update testimonial with uploaded avatar
🧪 Testing Checklist
- Create tweet testimonial works
- Create custom testimonial works
- Update preserves type and display order
- Delete normalizes remaining orders
- Toggle active status works
- Reorder updates surrounding items
- Avatar upload to Convex storage works
- Public queries return active only
- Featured filter works correctly
- Type filter works correctly
🎯 Best Practices
- Mix Types: Combine tweets and custom testimonials for variety
- Feature Strategically: Highlight testimonials from notable customers
- Update Regularly: Keep testimonials fresh and current
- Verify Permissions: Always get permission before using customer testimonials
- Use High-Quality Images: Upload clear, professional-looking avatars
- Order Thoughtfully: Put strongest testimonials first
📚 Related Documentation
- Site Branding - Customize testimonial section appearance
- FAQs - Combine with FAQs for complete social proof
- Admin Dashboard - Admin interface overview
FAQs Management
TinyKit Pro includes a comprehensive Frequently Asked Questions (FAQ) management system with admin controls for creating, updating, ordering, and publishing ...
Site Branding & Visual Identity
Comprehensive site branding management including logo uploads, OG image customization, and visual identity configuration.