Skip to main content

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
Success stories counter these barriers by showing:
  • Diverse people benefiting (not just stereotypes)
  • Concrete impact (dollar amounts, life changes)
  • That the process is manageable

Data Model

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

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.
// 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&apos;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.
// 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">
                &ldquo;{story.quote}&rdquo;
              </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">
                  &ldquo;{story.quote || story.story.slice(0, 150)}...&rdquo;
                </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">
              &ldquo;{story.quote}&rdquo;
            </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>
  );
}

Horizontal scrolling carousel for program pages.
// 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

// 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.
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;

Next Steps