Success Stories
Real stories from real people who discovered benefits they qualified for, building trust and reducing stigma.
Why Success Stories Matter
Many people don’t apply for benefits because:- They assume “those programs aren’t for people like me”
- They feel ashamed about needing help
- They don’t believe the programs actually work
- Diverse people benefiting (not just stereotypes)
- Concrete impact (dollar amounts, life changes)
- That the process is manageable
Data Model
Copy
interface SuccessStory {
id: string;
// Author
authorId: string | null; // null for truly anonymous
authorName: string; // Display name or "Anonymous"
isAnonymous: boolean;
// Content
title: string; // "How SNAP helped my family..."
story: string; // Full narrative (min 100 chars)
quote: string | null; // Pull quote for cards
// Association
programId: string | null;
programName: string | null;
// Impact metrics
benefitReceivedCents: number | null;
timeToApprovalDays: number | null;
// Moderation
status: "pending" | "approved" | "rejected" | "featured";
moderatedBy: string | null;
moderatedAt: Date | null;
rejectionReason: string | null;
// Engagement
viewCount: number;
helpfulCount: number;
// Media
imageUrl: string | null;
videoUrl: string | null;
// Timestamps
createdAt: Date;
updatedAt: Date;
}
Database Schema
Copy
CREATE TABLE success_stories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Author (can be anonymous)
author_id TEXT REFERENCES profiles(id) ON DELETE SET NULL,
author_name TEXT NOT NULL,
is_anonymous BOOLEAN DEFAULT false,
-- Story content
title TEXT NOT NULL,
story TEXT NOT NULL,
quote TEXT,
-- Related program
program_id UUID REFERENCES programs(id) ON DELETE SET NULL,
-- Impact metrics
benefit_received_cents INTEGER,
time_to_approval_days INTEGER,
-- Moderation
status TEXT DEFAULT 'pending' CHECK (status IN (
'pending', -- Awaiting review
'approved', -- Published
'rejected', -- Not suitable
'featured' -- Highlighted on homepage
)),
moderated_by TEXT REFERENCES profiles(id) ON DELETE SET NULL,
moderated_at TIMESTAMPTZ,
rejection_reason TEXT,
-- Engagement
view_count INTEGER DEFAULT 0,
helpful_count INTEGER DEFAULT 0,
-- Media
image_url TEXT,
video_url TEXT,
-- Timestamps
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indexes
CREATE INDEX idx_success_stories_status
ON success_stories(status, created_at DESC)
WHERE status IN ('approved', 'featured');
CREATE INDEX idx_success_stories_program
ON success_stories(program_id, status)
WHERE status IN ('approved', 'featured');
-- Deduplicated voting
CREATE TABLE story_helpful_votes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
story_id UUID NOT NULL REFERENCES success_stories(id) ON DELETE CASCADE,
user_id TEXT REFERENCES profiles(id) ON DELETE SET NULL,
session_id TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(story_id, user_id),
UNIQUE(story_id, session_id)
);
Story Submission Flow
Three-step wizard for submitting stories.Copy
// app/(dashboard)/share-story/page.tsx
export default function ShareStoryPage() {
const [step, setStep] = useState<"form" | "review" | "success">("form");
const [formData, setFormData] = useState<StoryFormData>({
title: "",
story: "",
quote: "",
programId: "",
benefitAmount: "",
isAnonymous: false,
displayName: "",
});
const isFormValid =
formData.title.trim() &&
formData.story.trim() &&
formData.story.length >= 100 &&
(formData.isAnonymous || formData.displayName.trim());
if (step === "success") {
return <SuccessScreen />;
}
if (step === "review") {
return (
<ReviewScreen
formData={formData}
onEdit={() => setStep("form")}
onSubmit={async () => {
await submitStory(formData);
setStep("success");
}}
/>
);
}
return (
<div className="max-w-2xl mx-auto">
{/* Hero */}
<div className="text-center mb-8">
<div className="w-16 h-16 rounded-full bg-amber-100 mx-auto mb-4 flex items-center justify-center">
<Sparkles className="h-8 w-8 text-amber-600" />
</div>
<h1 className="text-2xl font-bold">Share Your Success Story</h1>
<p className="text-stone-600 mt-2">
Your experience could help someone else discover benefits they qualify for
</p>
</div>
{/* Form */}
<Card>
<CardContent className="space-y-6 p-6">
{/* Title */}
<div>
<Label>Story Title *</Label>
<Input
placeholder="e.g., How SNAP helped my family during a difficult time"
value={formData.title}
onChange={e => setFormData(f => ({ ...f, title: e.target.value }))}
/>
</div>
{/* Story */}
<div>
<Label>Your Story *</Label>
<p className="text-xs text-stone-500 mb-1.5">
Minimum 100 characters. Share what happened, how you found the
program, and how it helped.
</p>
<Textarea
rows={8}
value={formData.story}
onChange={e => setFormData(f => ({ ...f, story: e.target.value }))}
/>
<p className="text-xs text-stone-500 mt-1">
{formData.story.length} / 100 characters minimum
</p>
</div>
{/* Pull quote */}
<div>
<Label>Pull Quote (Optional)</Label>
<p className="text-xs text-stone-500 mb-1.5">
A short, impactful quote we can feature in cards
</p>
<Textarea
rows={2}
placeholder="e.g., This program changed my family's life..."
value={formData.quote}
onChange={e => setFormData(f => ({ ...f, quote: e.target.value }))}
/>
</div>
{/* Program selection */}
<div>
<Label>Related Program (Optional)</Label>
<Select
value={formData.programId}
onValueChange={v => setFormData(f => ({ ...f, programId: v }))}
>
<SelectTrigger>
<SelectValue placeholder="Select a program..." />
</SelectTrigger>
<SelectContent>
{PROGRAMS.map(p => (
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Benefit amount */}
<div>
<Label>Benefit Amount Received (Optional)</Label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-500">
$
</span>
<Input
type="number"
className="pl-7"
value={formData.benefitAmount}
onChange={e => setFormData(f => ({ ...f, benefitAmount: e.target.value }))}
/>
</div>
<p className="text-xs text-stone-500 mt-1">
Monthly benefit or one-time assistance received
</p>
</div>
{/* Anonymous toggle */}
<div className="flex items-center justify-between p-4 bg-stone-50 rounded-lg">
<div>
<p className="font-medium">Share Anonymously</p>
<p className="text-sm text-stone-500">
Your name won't be displayed
</p>
</div>
<Switch
checked={formData.isAnonymous}
onCheckedChange={v => setFormData(f => ({ ...f, isAnonymous: v }))}
/>
</div>
{/* Display name */}
{!formData.isAnonymous && (
<div>
<Label>Display Name *</Label>
<Input
placeholder="e.g., Sarah J."
value={formData.displayName}
onChange={e => setFormData(f => ({ ...f, displayName: e.target.value }))}
/>
<p className="text-xs text-stone-500 mt-1">
You can use first name and last initial
</p>
</div>
)}
{/* Guidelines */}
<div className="bg-blue-50 rounded-lg p-4">
<h4 className="font-medium text-blue-900 mb-2 flex items-center gap-2">
<Heart className="h-4 w-4" />
Story Guidelines
</h4>
<ul className="text-sm text-blue-800 space-y-1">
<li>Be honest and share your real experience</li>
<li>Avoid sensitive info (SSN, account numbers)</li>
<li>Keep it respectful and appropriate</li>
<li>Stories are reviewed before publishing</li>
</ul>
</div>
{/* Submit */}
<Button
className="w-full"
size="lg"
disabled={!isFormValid}
onClick={() => setStep("review")}
>
Preview & Submit
</Button>
</CardContent>
</Card>
</div>
);
}
Story Card Component
Three variants for different display contexts.Copy
// components/success-stories/story-card.tsx
interface StoryCardProps {
story: SuccessStory;
variant?: "default" | "compact" | "featured";
onMarkHelpful?: (storyId: string) => void;
hasVoted?: boolean;
}
export function StoryCard({
story,
variant = "default",
onMarkHelpful,
hasVoted = false,
}: StoryCardProps) {
// Compact variant for carousels
if (variant === "compact") {
return (
<Card className="h-full hover:shadow-md transition-shadow">
<CardContent className="p-5">
{story.quote && (
<div className="relative mb-4">
<Quote className="absolute -top-1 -left-1 h-6 w-6 text-stone-200" />
<p className="text-stone-700 italic pl-6 line-clamp-3">
“{story.quote}”
</p>
</div>
)}
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-stone-200 flex items-center justify-center">
{getInitials(story)}
</div>
<div>
<p className="text-sm font-medium">{getDisplayName(story)}</p>
{story.programName && (
<p className="text-xs text-stone-500">{story.programName}</p>
)}
</div>
</div>
{story.benefitReceivedCents && (
<Badge className="mt-3 bg-emerald-100 text-emerald-700">
<DollarSign className="h-3 w-3 mr-1" />
{formatBenefit(story.benefitReceivedCents)} received
</Badge>
)}
</CardContent>
</Card>
);
}
// Featured variant for homepage
if (variant === "featured") {
return (
<Card className="overflow-hidden bg-gradient-to-br from-stone-50 to-white">
<CardContent className="p-6 md:p-8">
<div className="flex flex-col md:flex-row gap-6">
{story.imageUrl && (
<div className="w-full md:w-48 h-48 rounded-xl overflow-hidden shrink-0">
<img
src={story.imageUrl}
alt={`${getDisplayName(story)}'s story`}
className="w-full h-full object-cover"
/>
</div>
)}
<div className="flex-1">
<div className="relative mb-4">
<Quote className="absolute -top-2 -left-2 h-8 w-8 text-emerald-200" />
<p className="text-lg md:text-xl italic pl-8">
“{story.quote || story.story.slice(0, 150)}...”
</p>
</div>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-emerald-100 flex items-center justify-center text-emerald-700 font-medium">
{getInitials(story)}
</div>
<div>
<p className="font-semibold">{getDisplayName(story)}</p>
{story.programName && (
<p className="text-sm text-stone-500">{story.programName}</p>
)}
</div>
</div>
<div className="flex flex-wrap gap-4 mb-4">
{story.benefitReceivedCents && (
<Badge className="bg-emerald-100 text-emerald-700">
{formatBenefit(story.benefitReceivedCents)} in benefits
</Badge>
)}
<Badge variant="secondary">
<ThumbsUp className="h-3 w-3 mr-1" />
{story.helpfulCount} found helpful
</Badge>
</div>
<Link
href={`/stories/${story.id}`}
className="inline-flex items-center gap-1 text-sm font-medium text-emerald-600"
>
Read full story
<ArrowRight className="h-4 w-4" />
</Link>
</div>
</div>
</CardContent>
</Card>
);
}
// Default variant
return (
<Card className="h-full hover:shadow-md transition-shadow">
<CardContent className="p-6">
{/* Header */}
<div className="flex items-start justify-between gap-4 mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-stone-200 flex items-center justify-center">
{story.isAnonymous ? (
<User className="h-5 w-5" />
) : (
getInitials(story)
)}
</div>
<div>
<p className="font-medium">{getDisplayName(story)}</p>
<p className="text-xs text-stone-500">
{formatDate(story.createdAt)}
</p>
</div>
</div>
{story.programName && (
<Badge variant="secondary">{story.programName}</Badge>
)}
</div>
{/* Title & Content */}
<h3 className="font-semibold mb-2">{story.title}</h3>
{story.quote ? (
<div className="relative mb-4">
<Quote className="absolute -top-1 -left-1 h-5 w-5 text-stone-200" />
<p className="text-stone-600 italic pl-5 line-clamp-3">
“{story.quote}”
</p>
</div>
) : (
<p className="text-stone-600 line-clamp-3 mb-4">{story.story}</p>
)}
{/* Benefit */}
{story.benefitReceivedCents && (
<div className="flex items-center gap-2 mb-4 text-emerald-700 bg-emerald-50 rounded-lg px-3 py-2">
<DollarSign className="h-4 w-4" />
<span className="text-sm font-medium">
Received {formatBenefit(story.benefitReceivedCents)} in benefits
</span>
</div>
)}
{/* Footer */}
<div className="flex items-center justify-between pt-4 border-t">
<Button
variant={hasVoted ? "secondary" : "outline"}
size="sm"
onClick={() => onMarkHelpful?.(story.id)}
disabled={hasVoted}
>
<ThumbsUp className={cn("h-4 w-4 mr-1.5", hasVoted && "fill-current")} />
{hasVoted ? "Thanks!" : "Helpful"} ({story.helpfulCount})
</Button>
<Link
href={`/stories/${story.id}`}
className="text-sm font-medium text-stone-600 hover:text-stone-900"
>
Read more
</Link>
</div>
</CardContent>
</Card>
);
}
Stories Carousel
Horizontal scrolling carousel for program pages.Copy
// components/success-stories/program-stories.tsx
export function ProgramStories({
stories,
programName,
title,
showViewAll = true,
viewAllHref = "/stories",
onMarkHelpful,
}: ProgramStoriesProps) {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const checkScroll = useCallback(() => {
const container = scrollContainerRef.current;
if (!container) return;
setCanScrollLeft(container.scrollLeft > 0);
setCanScrollRight(
container.scrollLeft < container.scrollWidth - container.clientWidth - 10
);
}, []);
const scroll = (direction: "left" | "right") => {
const container = scrollContainerRef.current;
if (!container) return;
container.scrollBy({
left: direction === "left" ? -300 : 300,
behavior: "smooth",
});
};
if (stories.length === 0) return null;
return (
<section className="py-8">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-bold flex items-center gap-2">
<Sparkles className="h-5 w-5 text-amber-500" />
{title || `Success Stories from ${programName}`}
</h2>
<p className="text-stone-500 mt-1">
Real experiences from people who received benefits
</p>
</div>
{showViewAll && (
<Link href={viewAllHref}>
<Button variant="outline">View All Stories</Button>
</Link>
)}
</div>
<div className="relative">
{/* Scroll buttons */}
{canScrollLeft && (
<button
onClick={() => scroll("left")}
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 w-10 h-10 bg-white border rounded-full shadow-md flex items-center justify-center -ml-5"
>
<ChevronLeft className="h-5 w-5" />
</button>
)}
{canScrollRight && (
<button
onClick={() => scroll("right")}
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 w-10 h-10 bg-white border rounded-full shadow-md flex items-center justify-center -mr-5"
>
<ChevronRight className="h-5 w-5" />
</button>
)}
{/* Stories container */}
<div
ref={scrollContainerRef}
onScroll={checkScroll}
className="flex gap-4 overflow-x-auto scrollbar-hide pb-4 -mx-4 px-4"
>
{stories.map(story => (
<div key={story.id} className="flex-shrink-0 w-72 md:w-80">
<StoryCard
story={story}
variant="compact"
onMarkHelpful={onMarkHelpful}
/>
</div>
))}
</div>
</div>
</section>
);
}
Moderation Workflow
Stories go through moderation before publishing.Moderation Functions
Copy
// lib/dal/repositories/success-stories.repository.ts
export async function approveStory(
storyId: string,
moderatorId: string
): Promise<void> {
await supabaseAdmin
.from("success_stories")
.update({
status: "approved",
moderated_by: moderatorId,
moderated_at: new Date().toISOString(),
})
.eq("id", storyId);
}
export async function rejectStory(
storyId: string,
moderatorId: string,
reason: string
): Promise<void> {
await supabaseAdmin
.from("success_stories")
.update({
status: "rejected",
moderated_by: moderatorId,
moderated_at: new Date().toISOString(),
rejection_reason: reason,
})
.eq("id", storyId);
}
export async function featureStory(
storyId: string,
moderatorId: string
): Promise<void> {
await supabaseAdmin
.from("success_stories")
.update({
status: "featured",
moderated_by: moderatorId,
moderated_at: new Date().toISOString(),
})
.eq("id", storyId);
}
Helpful Voting
Deduplicated voting with database function.Copy
CREATE OR REPLACE FUNCTION mark_story_helpful(
p_story_id UUID,
p_user_id TEXT DEFAULT NULL,
p_session_id TEXT DEFAULT NULL
)
RETURNS BOOLEAN AS $$
DECLARE
v_inserted BOOLEAN;
BEGIN
-- Try to insert vote (will fail silently if duplicate)
INSERT INTO story_helpful_votes (story_id, user_id, session_id)
VALUES (p_story_id, p_user_id, p_session_id)
ON CONFLICT DO NOTHING;
GET DIAGNOSTICS v_inserted = ROW_COUNT;
IF v_inserted THEN
-- Increment helpful count
UPDATE success_stories
SET helpful_count = helpful_count + 1
WHERE id = p_story_id
AND status IN ('approved', 'featured');
END IF;
RETURN v_inserted > 0;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;