Marketing Site + Trial Update Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Rewrite the public marketing site for v2 fact engine, switch trial to 14-day with CC collection via Stripe, and add subscription card injection in the app feed.

Architecture: Next.js rewrite proxy for feed data (public site → app API), Stripe Checkout for trial signup with payment method collection, lightweight card components in public app, modal dialog for card click CTA.

Tech Stack: Next.js 16 (App Router), Tailwind v4, Stripe Checkout, Drizzle ORM, Zod


Task 1: Trial Duration Migration (5.1)

Files:

  • Create: supabase/migrations/0114_trial_14_days.sql

Step 1: Write the migration

-- ============================================================================
-- MIGRATION 0114: Update Trial Duration to 14 Days
--
-- Switches Eko+ trial from 30 days to 14 days. The subscribe page and Stripe
-- checkout will be updated in subsequent tasks to use this value.
--
-- Changes:
--   - Update plan_definitions.trial_days from 30 to 14 for 'plus' plan
-- ============================================================================

UPDATE plan_definitions SET trial_days = 14 WHERE plan_key = 'plus';

Step 2: Regenerate migrations index

Run: bun run migrations:index Expected: supabase/migrations-index.md updated with 0114 entry

Step 3: Verify migrations check passes

Run: bun run migrations:check Expected: PASS

Step 4: Commit

git add supabase/migrations/0114_trial_14_days.sql supabase/migrations-index.md
git commit -m "feat(db): add migration 0114 to update trial to 14 days"

Task 2: Add getPlanTrialDays Query (5.1 continued)

Files:

  • Modify: packages/db/src/drizzle/fact-engine-queries.ts
  • Modify: packages/db/src/index.ts
  • Modify: apps/web/app/actions/subscription.ts

Step 1: Add query function to fact-engine-queries.ts

Add after the createTrialSubscription function (around line 1054):

/**
 * Get trial days for a plan from plan_definitions.
 * Returns 0 if plan not found.
 */
export async function getPlanTrialDays(planKey: string): Promise<number> {
  const db = getDrizzleClient()

  const plan = await db.query.planDefinitions.findFirst({
    where: eq(planDefinitions.planKey, planKey),
    columns: { trialDays: true },
  })

  return plan?.trialDays ?? 0
}

Step 2: Export from packages/db/src/index.ts

Add getPlanTrialDays to the direct exports from ./drizzle/fact-engine-queries (alphabetical order in the existing export block).

Step 3: Update subscription action to use dynamic trial days

In apps/web/app/actions/subscription.ts, change:

// Before
import { createTrialSubscription, drizzleQueries } from '@eko/db'

const subscription = await createTrialSubscription(user.id, 'plus', 30)

To:

// After
import { createTrialSubscription, drizzleQueries, getPlanTrialDays } from '@eko/db'

const trialDays = await getPlanTrialDays('plus')
const subscription = await createTrialSubscription(user.id, 'plus', trialDays || 14)

Step 4: Run typecheck

Run: bun run typecheck Expected: No errors

Step 5: Commit

git add packages/db/src/drizzle/fact-engine-queries.ts packages/db/src/index.ts apps/web/app/actions/subscription.ts
git commit -m "feat(db): add getPlanTrialDays query, use dynamic trial days in action"

Task 3: Stripe Checkout — Allow Plus Plan + Payment Method Collection (5.2)

Files:

  • Modify: apps/web/app/api/stripe/checkout/route.ts
  • Modify: packages/stripe/src/checkout.ts

Step 1: Update checkout route to accept 'plus' plan and billingPeriod

In apps/web/app/api/stripe/checkout/route.ts, change the Zod schema and pass trial days:

const CheckoutRequestSchema = z.object({
  planKey: z.enum(['base', 'pro', 'team', 'plus']),
  billingPeriod: z.enum(['monthly', 'annual']).optional(),
})

In the POST handler, after parsing, pass billingPeriod and trialDays:

const { planKey, billingPeriod } = parsed.data

const { sessionId, url } = await createCheckoutSession(supabase, {
  userId: user.id,
  userEmail: user.email!,
  planKey: planKey as PlanKey,
  billingPeriod,
  trialDays: planKey === 'plus' ? 14 : undefined,
  successUrl,
  cancelUrl,
})

Step 2: Add payment_method_collection to Stripe session

In packages/stripe/src/checkout.ts, inside createCheckoutSession, add payment_method_collection to the session create call:

const session = await stripe.client.checkout.sessions.create({
  customer: customerId,
  mode: 'subscription',
  payment_method_collection: 'always',
  line_items: [{ price: priceId, quantity: 1 }],
  // ... rest unchanged
})

Step 3: Run typecheck

Run: bun run typecheck Expected: No errors

Step 4: Commit

git add apps/web/app/api/stripe/checkout/route.ts packages/stripe/src/checkout.ts
git commit -m "feat(stripe): allow plus plan checkout with payment method collection"

Task 4: Subscribe Page — Stripe Redirect + Copy Updates (5.2 continued)

Files:

  • Modify: apps/web/app/subscribe/_components/subscribe-page.tsx

Step 1: Replace local trial with Stripe checkout redirect

Replace the handleStartTrial function and update copy:

'use client'

import { Button } from '@eko/ui'
import Link from 'next/link'
import { useState } from 'react'

interface SubscribePageProps {
  isAuthenticated: boolean
  hasActiveSubscription: boolean
}

const FREE_FEATURES = ['Browse the daily feed', 'See headlines and topics', 'View category filters']

const PLUS_FEATURES = [
  'Full card details and insights',
  'Multiple choice trivia quizzes',
  'Open recall practice',
  'Bookmark up to 10 cards',
  'Validation sources',
  '14-day free trial',
]

export function SubscribePage({ isAuthenticated, hasActiveSubscription }: SubscribePageProps) {
  const [error, setError] = useState<string | null>(null)
  const [isLoading, setIsLoading] = useState(false)

  const handleStartTrial = async (billingPeriod: 'monthly' | 'annual' = 'monthly') => {
    setError(null)
    setIsLoading(true)
    try {
      const res = await fetch('/api/stripe/checkout', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ planKey: 'plus', billingPeriod }),
      })
      const data = await res.json()
      if (!res.ok) {
        setError(data.error || 'Something went wrong')
        return
      }
      // Redirect to Stripe Checkout
      window.location.href = data.url
    } catch {
      setError('Something went wrong. Please try again.')
    } finally {
      setIsLoading(false)
    }
  }

  // ... rest of component unchanged except:
  // - CTA button text: 'Start 14-day free trial' (was 'Start 30-day free trial')
  // - CTA onClick: handleStartTrial() (no change in signature)
  // - Footer text: 'No charge for 14 days. Cancel anytime.' (was 'No credit card required...')
}

Key changes to the JSX:

  • Line with 'Start 30-day free trial''Start 14-day free trial'
  • Footer <p>'No charge for 14 days. Cancel anytime.'

Step 2: Run typecheck and lint

Run: bun run typecheck && bun run lint Expected: No errors

Step 3: Commit

git add apps/web/app/subscribe/_components/subscribe-page.tsx
git commit -m "feat(subscribe): redirect to Stripe checkout for trial with CC collection"

Task 5: Feed Proxy Rewrite (5.8)

Files:

  • Modify: apps/public/next.config.ts

Step 1: Add rewrite rule

import { withSentryConfig } from '@sentry/nextjs'
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  transpilePackages: ['@eko/config', '@eko/shared', '@eko/ui-public'],
  async rewrites() {
    return [
      {
        source: '/api/feed',
        destination: process.env.FEED_API_URL || 'https://app.eko.day/api/feed',
      },
    ]
  },
}

export default withSentryConfig(nextConfig, {
  org: 'lab90-llc',
  project: 'eko-public',
  silent: !process.env.CI,
  widenClientFileUpload: true,
  webpack: {
    treeshake: {
      removeDebugLogging: true,
    },
  },
})

Step 2: Run typecheck

Run: bun run typecheck Expected: No errors

Step 3: Commit

git add apps/public/next.config.ts
git commit -m "feat(public): add feed API rewrite proxy"

Files:

  • Create: apps/public/app/_components/site-nav.tsx
  • Create: apps/public/app/_components/site-footer.tsx
  • Modify: apps/public/app/layout.tsx

Step 1: Create site nav component

apps/public/app/_components/site-nav.tsx:

import Link from 'next/link'

const APP_URL = process.env.NEXT_PUBLIC_APP_URL || 'https://app.eko.day'

const NAV_LINKS = [
  { href: '/features', label: 'Features' },
  { href: '/pricing', label: 'Pricing' },
  { href: '/about', label: 'About' },
]

export function SiteNav() {
  return (
    <header className="border-b">
      <nav className="mx-auto flex max-w-6xl items-center justify-between px-4 py-3 sm:px-6">
        <Link href="/" className="text-xl font-bold tracking-tight">
          Eko
        </Link>
        <div className="flex items-center gap-6">
          <div className="hidden items-center gap-4 sm:flex">
            {NAV_LINKS.map((link) => (
              <Link
                key={link.href}
                href={link.href}
                className="text-sm text-muted-foreground transition-colors hover:text-foreground"
              >
                {link.label}
              </Link>
            ))}
          </div>
          <div className="flex items-center gap-2">
            <a
              href={`${APP_URL}/login`}
              className="rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
            >
              Sign in
            </a>
            <a
              href={`${APP_URL}/subscribe`}
              className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
            >
              Start free trial
            </a>
          </div>
        </div>
      </nav>
    </header>
  )
}

Step 2: Create site footer component

apps/public/app/_components/site-footer.tsx:

import Link from 'next/link'

const APP_URL = process.env.NEXT_PUBLIC_APP_URL || 'https://app.eko.day'

export function SiteFooter() {
  return (
    <footer className="border-t">
      <div className="mx-auto max-w-6xl px-4 py-8 sm:px-6">
        <div className="flex flex-col items-center justify-between gap-4 sm:flex-row">
          <p className="text-sm text-muted-foreground">
            &copy; {new Date().getFullYear()} Eko. All rights reserved.
          </p>
          <div className="flex gap-4">
            <Link href="/features" className="text-sm text-muted-foreground hover:text-foreground">
              Features
            </Link>
            <Link href="/pricing" className="text-sm text-muted-foreground hover:text-foreground">
              Pricing
            </Link>
            <Link href="/about" className="text-sm text-muted-foreground hover:text-foreground">
              About
            </Link>
            <a
              href={`${APP_URL}/login`}
              className="text-sm text-muted-foreground hover:text-foreground"
            >
              Sign in
            </a>
          </div>
        </div>
      </div>
    </footer>
  )
}

Step 3: Update layout with nav, footer, and v2 metadata

Replace apps/public/app/layout.tsx:

import type { Metadata } from 'next'
import { Red_Hat_Text } from 'next/font/google'
import { SiteFooter } from './_components/site-footer'
import { SiteNav } from './_components/site-nav'
import './globals.css'

const redHatText = Red_Hat_Text({
  subsets: ['latin'],
  variable: '--font-sans',
  display: 'swap',
})

export const metadata: Metadata = {
  title: 'Eko — Challenge what you know. Learn what you don\'t.',
  description:
    'Eko delivers daily knowledge challenges powered by verified facts from real news. Test yourself across 36+ categories with spaced repetition.',
  icons: {
    icon: [
      { url: '/brand/raster/favicon-16.png', sizes: '16x16', type: 'image/png' },
      { url: '/brand/raster/favicon-32.png', sizes: '32x32', type: 'image/png' },
    ],
    apple: [{ url: '/brand/raster/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }],
  },
  openGraph: {
    title: 'Eko — Challenge what you know. Learn what you don\'t.',
    description:
      'Daily knowledge challenges powered by verified facts from real news. 36+ categories. Spaced repetition.',
    type: 'website',
    siteName: 'Eko',
  },
  twitter: {
    card: 'summary_large_image',
    title: 'Eko — Challenge what you know. Learn what you don\'t.',
    description:
      'Daily knowledge challenges powered by verified facts from real news. 36+ categories. Spaced repetition.',
  },
}

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode
}>) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body className={`${redHatText.variable} flex min-h-screen flex-col font-sans antialiased`}>
        <SiteNav />
        <main className="flex-1">{children}</main>
        <SiteFooter />
      </body>
    </html>
  )
}

Step 4: Run typecheck

Run: bun run typecheck Expected: No errors

Step 5: Commit

git add apps/public/app/_components/site-nav.tsx apps/public/app/_components/site-footer.tsx apps/public/app/layout.tsx
git commit -m "feat(public): add site nav, footer, and v2 metadata"

Task 7: Public Home Page with Feed + Card Modal (5.3)

Files:

  • Create: apps/public/app/_components/public-fact-card.tsx
  • Create: apps/public/app/_components/card-modal.tsx
  • Modify: apps/public/app/page.tsx

Step 1: Create public fact card component

apps/public/app/_components/public-fact-card.tsx:

A lightweight card component that renders title, topic, and published date. No auth, no status badges, no interactions. Clicking opens the modal.

interface PublicFactCardProps {
  title: string
  topicCategory: {
    name: string
    slug: string
    icon: string | null
    color: string | null
  } | null
  publishedAt: string | null
  imageUrl?: string | null
  onClick: () => void
}

export function PublicFactCard({
  title,
  topicCategory,
  publishedAt,
  imageUrl,
  onClick,
}: PublicFactCardProps) {
  const timeAgo = publishedAt ? getTimeAgo(new Date(publishedAt)) : null

  return (
    <button
      type="button"
      onClick={onClick}
      className="group flex flex-col rounded-xl border bg-card p-4 text-left transition-shadow hover:shadow-md"
    >
      {imageUrl && (
        <div className="mb-3 aspect-video overflow-hidden rounded-lg bg-muted">
          <img
            src={imageUrl}
            alt=""
            className="h-full w-full object-cover transition-transform group-hover:scale-105"
          />
        </div>
      )}
      {topicCategory && (
        <span
          className="mb-2 w-fit rounded-full px-2 py-0.5 text-xs font-medium"
          style={{
            backgroundColor: topicCategory.color ? `${topicCategory.color}20` : undefined,
            color: topicCategory.color ?? undefined,
          }}
        >
          {topicCategory.icon} {topicCategory.name}
        </span>
      )}
      <h3 className="mb-2 line-clamp-3 text-sm font-semibold leading-snug">{title}</h3>
      {timeAgo && (
        <span className="mt-auto text-xs text-muted-foreground">{timeAgo}</span>
      )}
    </button>
  )
}

function getTimeAgo(date: Date): string {
  const seconds = Math.floor((Date.now() - date.getTime()) / 1000)
  if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`
  if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`
  return `${Math.floor(seconds / 86400)}d ago`
}

Step 2: Create card modal component

apps/public/app/_components/card-modal.tsx:

'use client'

import { useEffect, useRef } from 'react'

const APP_URL = process.env.NEXT_PUBLIC_APP_URL || 'https://app.eko.day'

interface CardModalProps {
  isOpen: boolean
  onClose: () => void
  title: string
  topicName: string | null
  topicIcon: string | null
}

export function CardModal({ isOpen, onClose, title, topicName, topicIcon }: CardModalProps) {
  const dialogRef = useRef<HTMLDialogElement>(null)

  useEffect(() => {
    const dialog = dialogRef.current
    if (!dialog) return
    if (isOpen && !dialog.open) dialog.showModal()
    if (!isOpen && dialog.open) dialog.close()
  }, [isOpen])

  return (
    <dialog
      ref={dialogRef}
      onClose={onClose}
      className="w-full max-w-md rounded-xl border bg-card p-0 shadow-lg backdrop:bg-black/50"
    >
      <div className="p-6">
        {topicName && (
          <span className="mb-3 inline-block text-xs font-medium text-muted-foreground">
            {topicIcon} {topicName}
          </span>
        )}
        <h2 className="mb-4 text-lg font-bold leading-snug">{title}</h2>
        <p className="mb-6 text-sm text-muted-foreground">
          Sign up to unlock the full insight, take the challenge, and track what you know.
        </p>
        <div className="flex flex-col gap-2">
          <a
            href={`${APP_URL}/subscribe`}
            className="rounded-md bg-primary px-4 py-2.5 text-center text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
          >
            Start 14-day free trial
          </a>
          <a
            href={`${APP_URL}/login`}
            className="rounded-md border px-4 py-2.5 text-center text-sm text-muted-foreground transition-colors hover:bg-muted"
          >
            Already have an account? Sign in
          </a>
        </div>
      </div>
      <button
        type="button"
        onClick={onClose}
        className="absolute right-3 top-3 rounded-md p-1 text-muted-foreground hover:text-foreground"
        aria-label="Close"
      >
        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
          <path d="M18 6L6 18M6 6l12 12" />
        </svg>
      </button>
    </dialog>
  )
}

Step 3: Update home page

Replace apps/public/app/page.tsx:

import { PublicFeed } from './_components/public-feed'

const FEED_URL = process.env.FEED_API_URL || 'https://app.eko.day/api/feed'

export const metadata = {
  title: 'Eko — Challenge what you know',
}

interface FeedResponse {
  facts: Array<{
    id: string
    title: string
    topicCategory: {
      name: string
      slug: string
      icon: string | null
      color: string | null
    } | null
    publishedAt: string | null
    imageUrl?: string | null
  }>
}

export default async function HomePage() {
  let facts: FeedResponse['facts'] = []

  try {
    const res = await fetch(`${FEED_URL}?limit=24`, {
      next: { revalidate: 300 },
    })
    if (res.ok) {
      const data: FeedResponse = await res.json()
      facts = data.facts
    }
  } catch {
    // Graceful degradation — show hero without feed
  }

  return (
    <div>
      {/* Hero */}
      <section className="mx-auto max-w-6xl px-4 py-16 text-center sm:px-6 sm:py-24">
        <h1 className="mb-4 text-4xl font-bold tracking-tight sm:text-5xl">
          Challenge what you know.
          <br />
          <span className="text-primary">Learn what you don&apos;t.</span>
        </h1>
        <p className="mx-auto mb-8 max-w-2xl text-lg text-muted-foreground">
          Daily knowledge challenges powered by verified facts from real news.
          36+ categories. Spaced repetition that actually works.
        </p>
        <a
          href={`${process.env.NEXT_PUBLIC_APP_URL || 'https://app.eko.day'}/subscribe`}
          className="inline-block rounded-md bg-primary px-6 py-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
        >
          Start your 14-day free trial
        </a>
      </section>

      {/* Live Feed */}
      {facts.length > 0 && (
        <section className="mx-auto max-w-6xl px-4 pb-16 sm:px-6">
          <h2 className="mb-6 text-center text-2xl font-bold">Today&apos;s challenges</h2>
          <PublicFeed facts={facts} />
        </section>
      )}
    </div>
  )
}

Step 4: Create PublicFeed client component

apps/public/app/_components/public-feed.tsx:

'use client'

import { useState } from 'react'
import { CardModal } from './card-modal'
import { PublicFactCard } from './public-fact-card'

interface FeedFact {
  id: string
  title: string
  topicCategory: {
    name: string
    slug: string
    icon: string | null
    color: string | null
  } | null
  publishedAt: string | null
  imageUrl?: string | null
}

interface PublicFeedProps {
  facts: FeedFact[]
}

export function PublicFeed({ facts }: PublicFeedProps) {
  const [selectedFact, setSelectedFact] = useState<FeedFact | null>(null)

  return (
    <>
      <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
        {facts.map((fact) => (
          <PublicFactCard
            key={fact.id}
            title={fact.title}
            topicCategory={fact.topicCategory}
            publishedAt={fact.publishedAt}
            imageUrl={fact.imageUrl}
            onClick={() => setSelectedFact(fact)}
          />
        ))}
      </div>

      <CardModal
        isOpen={selectedFact !== null}
        onClose={() => setSelectedFact(null)}
        title={selectedFact?.title ?? ''}
        topicName={selectedFact?.topicCategory?.name ?? null}
        topicIcon={selectedFact?.topicCategory?.icon ?? null}
      />
    </>
  )
}

Step 5: Run typecheck

Run: bun run typecheck Expected: No errors

Step 6: Commit

git add apps/public/app/_components/public-fact-card.tsx apps/public/app/_components/card-modal.tsx apps/public/app/_components/public-feed.tsx apps/public/app/page.tsx
git commit -m "feat(public): add home page with live feed and card modal"

Task 8: Pricing Page (5.4)

Files:

  • Create: apps/public/app/pricing/_components/pricing-toggle.tsx
  • Modify: apps/public/app/pricing/page.tsx

Step 1: Create pricing toggle client component

apps/public/app/pricing/_components/pricing-toggle.tsx:

'use client'

import { useState } from 'react'

const APP_URL = process.env.NEXT_PUBLIC_APP_URL || 'https://app.eko.day'

const FREE_FEATURES = [
  'Browse the daily feed',
  'See headlines and topics',
  'View category filters',
]

const PLUS_FEATURES = [
  'Everything in Free',
  'Full card details and insights',
  'Multiple choice trivia quizzes',
  'Open recall practice',
  'Bookmark up to 10 cards',
  'Validation sources',
  'Spaced repetition tracking',
]

export function PricingToggle() {
  const [annual, setAnnual] = useState(false)

  return (
    <div>
      {/* Toggle */}
      <div className="mb-10 flex items-center justify-center gap-3">
        <span className={`text-sm ${!annual ? 'font-medium text-foreground' : 'text-muted-foreground'}`}>
          Monthly
        </span>
        <button
          type="button"
          onClick={() => setAnnual(!annual)}
          className={`relative h-6 w-11 rounded-full transition-colors ${annual ? 'bg-primary' : 'bg-muted-foreground/30'}`}
          role="switch"
          aria-checked={annual}
        >
          <span
            className={`absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition-transform ${annual ? 'translate-x-5' : ''}`}
          />
        </button>
        <span className={`text-sm ${annual ? 'font-medium text-foreground' : 'text-muted-foreground'}`}>
          Annual <span className="text-xs text-primary">(save 27%)</span>
        </span>
      </div>

      {/* Plans */}
      <div className="mx-auto grid max-w-3xl gap-6 md:grid-cols-2">
        {/* Free */}
        <div className="rounded-xl border p-6">
          <h3 className="mb-1 text-xl font-semibold">Free</h3>
          <div className="mb-4">
            <span className="text-3xl font-bold">$0</span>
            <span className="text-muted-foreground">/forever</span>
          </div>
          <ul className="mb-6 space-y-3">
            {FREE_FEATURES.map((f) => (
              <li key={f} className="flex items-start gap-2 text-sm">
                <CheckIcon className="text-muted-foreground" />
                <span>{f}</span>
              </li>
            ))}
          </ul>
          <a
            href={`${APP_URL}/signup`}
            className="block rounded-md border px-4 py-2.5 text-center text-sm font-medium transition-colors hover:bg-muted"
          >
            Sign up free
          </a>
        </div>

        {/* Eko+ */}
        <div className="relative rounded-xl border-2 border-primary p-6">
          <div className="absolute -top-3 left-1/2 -translate-x-1/2">
            <span className="rounded-full bg-primary px-3 py-1 text-xs font-medium text-primary-foreground">
              Recommended
            </span>
          </div>
          <h3 className="mb-1 text-xl font-semibold">Eko+</h3>
          <div className="mb-1">
            <span className="text-3xl font-bold">{annual ? '$34.99' : '$3.99'}</span>
            <span className="text-muted-foreground">{annual ? '/year' : '/month'}</span>
          </div>
          {!annual && (
            <p className="mb-4 text-xs text-muted-foreground">or $34.99/year (save 27%)</p>
          )}
          {annual && (
            <p className="mb-4 text-xs text-muted-foreground">$2.92/month, billed annually</p>
          )}
          <ul className="mb-6 space-y-3">
            {PLUS_FEATURES.map((f) => (
              <li key={f} className="flex items-start gap-2 text-sm">
                <CheckIcon className="text-primary" />
                <span>{f}</span>
              </li>
            ))}
          </ul>
          <a
            href={`${APP_URL}/subscribe`}
            className="block rounded-md bg-primary px-4 py-2.5 text-center text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
          >
            Start 14-day free trial
          </a>
          <p className="mt-3 text-center text-xs text-muted-foreground">
            No charge for 14 days. Cancel anytime.
          </p>
        </div>
      </div>
    </div>
  )
}

function CheckIcon({ className }: { className?: string }) {
  return (
    <svg
      className={`mt-0.5 h-4 w-4 shrink-0 ${className}`}
      fill="none"
      viewBox="0 0 24 24"
      stroke="currentColor"
      strokeWidth={2}
    >
      <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
    </svg>
  )
}

Step 2: Update pricing page

Replace apps/public/app/pricing/page.tsx:

import { PricingToggle } from './_components/pricing-toggle'

export const metadata = { title: 'Pricing | Eko' }

export default function PricingPage() {
  return (
    <div className="mx-auto max-w-6xl px-4 py-16 sm:px-6">
      <div className="mb-10 text-center">
        <h1 className="mb-3 text-3xl font-bold sm:text-4xl">Simple, transparent pricing</h1>
        <p className="text-muted-foreground">
          Browse for free. Unlock everything with Eko+ across 36+ topic categories.
        </p>
      </div>
      <PricingToggle />
    </div>
  )
}

Step 3: Run typecheck

Run: bun run typecheck Expected: No errors

Step 4: Commit

git add apps/public/app/pricing/page.tsx apps/public/app/pricing/_components/pricing-toggle.tsx
git commit -m "feat(public): add pricing page with plan comparison and annual toggle"

Task 9: Features Page (5.5)

Files:

  • Modify: apps/public/app/features/page.tsx

Step 1: Replace stub with features content

export const metadata = { title: 'Features | Eko' }

const APP_URL = process.env.NEXT_PUBLIC_APP_URL || 'https://app.eko.day'

const FEATURES = [
  {
    icon: '🎯',
    title: 'Challenge System',
    description:
      'Every fact becomes a challenge. Multiple choice, open recall, true/false — formats that make you think, not just read.',
  },
  {
    icon: '🔄',
    title: 'Spaced Repetition',
    description:
      'Facts you struggle with come back more often. Facts you master fade into review. Your feed adapts to what you actually know.',
  },
  {
    icon: '⚖️',
    title: 'Score Disputes & Rewards',
    description:
      'Think a question was unfair? Dispute it. Earn milestone badges as your knowledge grows across categories.',
  },
  {
    icon: '🌍',
    title: '36+ Topic Categories',
    description:
      'Science, history, geography, economics, technology, health, culture, and more. New verified facts every day from real news sources.',
  },
]

export default function FeaturesPage() {
  return (
    <div className="mx-auto max-w-6xl px-4 py-16 sm:px-6">
      <div className="mb-16 text-center">
        <h1 className="mb-4 text-3xl font-bold sm:text-4xl">
          Knowledge that sticks
        </h1>
        <p className="mx-auto max-w-2xl text-lg text-muted-foreground">
          Eko turns verified news facts into daily challenges. Not flashcards. Not quizzes.
          Challenges that make you curious.
        </p>
      </div>

      <div className="grid gap-8 sm:grid-cols-2">
        {FEATURES.map((feature) => (
          <div key={feature.title} className="rounded-xl border p-6">
            <span className="mb-3 block text-3xl">{feature.icon}</span>
            <h3 className="mb-2 text-lg font-semibold">{feature.title}</h3>
            <p className="text-sm leading-relaxed text-muted-foreground">
              {feature.description}
            </p>
          </div>
        ))}
      </div>

      <div className="mt-16 text-center">
        <a
          href={`${APP_URL}/subscribe`}
          className="inline-block rounded-md bg-primary px-6 py-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
        >
          Start your 14-day free trial
        </a>
      </div>
    </div>
  )
}

Step 2: Run typecheck

Run: bun run typecheck Expected: No errors

Step 3: Commit

git add apps/public/app/features/page.tsx
git commit -m "feat(public): add features page with challenge system descriptions"

Task 10: About Page (5.6)

Files:

  • Modify: apps/public/app/about/page.tsx

Step 1: Replace stub with about content

export const metadata = { title: 'About | Eko' }

const STEPS = [
  {
    step: '1',
    title: 'News ingestion',
    description: 'We monitor hundreds of news sources around the clock, clustering stories into coherent topics.',
  },
  {
    step: '2',
    title: 'AI fact extraction',
    description: 'Our fact engine extracts structured, verifiable facts and scores them for notability and learning value.',
  },
  {
    step: '3',
    title: 'Multi-tier verification',
    description: 'Every fact passes through validation tiers — from authoritative API checks to AI cross-referencing.',
  },
  {
    step: '4',
    title: 'Challenge cards',
    description: 'Verified facts become challenge cards delivered through spaced repetition, tuned to what you know.',
  },
]

export default function AboutPage() {
  return (
    <div className="mx-auto max-w-4xl px-4 py-16 sm:px-6">
      <div className="mb-16">
        <h1 className="mb-4 text-3xl font-bold sm:text-4xl">
          Knowledge should be earned, not scrolled past.
        </h1>
        <p className="text-lg leading-relaxed text-muted-foreground">
          Eko is a knowledge platform that turns the daily news into something you actually
          remember. We believe the best way to learn isn&apos;t reading — it&apos;s being
          challenged. Every fact in your feed is verified, structured, and delivered at the
          right time for your brain to hold onto it.
        </p>
      </div>

      <div className="mb-16">
        <h2 className="mb-8 text-2xl font-bold">How the fact engine works</h2>
        <div className="grid gap-6 sm:grid-cols-2">
          {STEPS.map((step) => (
            <div key={step.step} className="flex gap-4">
              <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary text-sm font-bold text-primary-foreground">
                {step.step}
              </span>
              <div>
                <h3 className="mb-1 font-semibold">{step.title}</h3>
                <p className="text-sm leading-relaxed text-muted-foreground">
                  {step.description}
                </p>
              </div>
            </div>
          ))}
        </div>
      </div>
    </div>
  )
}

Step 2: Run typecheck

Run: bun run typecheck Expected: No errors

Step 3: Commit

git add apps/public/app/about/page.tsx
git commit -m "feat(public): add about page with mission and fact engine overview"

Task 11: Subscription Card Injection (5.7)

Files:

  • Create: apps/web/app/feed/_components/subscription-card.tsx
  • Modify: apps/web/app/feed/_components/card-feed.tsx

Step 1: Create subscription card component

apps/web/app/feed/_components/subscription-card.tsx:

import Link from 'next/link'

const BENEFITS = [
  'Full card details and challenge quizzes',
  'Spaced repetition that adapts to you',
  'Track progress across 36+ categories',
]

export function SubscriptionCard() {
  return (
    <div className="flex flex-col rounded-xl border-2 border-primary/30 bg-gradient-to-br from-primary/5 to-primary/10 p-5">
      <h3 className="mb-2 text-base font-bold">Unlock the full Eko experience</h3>
      <ul className="mb-4 space-y-2">
        {BENEFITS.map((b) => (
          <li key={b} className="flex items-start gap-2 text-sm text-muted-foreground">
            <svg
              className="mt-0.5 h-4 w-4 shrink-0 text-primary"
              fill="none"
              viewBox="0 0 24 24"
              stroke="currentColor"
              strokeWidth={2}
            >
              <title>Included</title>
              <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
            </svg>
            <span>{b}</span>
          </li>
        ))}
      </ul>
      <Link
        href="/subscribe"
        className="mt-auto rounded-md bg-primary px-4 py-2.5 text-center text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
      >
        Start 14-day free trial
      </Link>
      <p className="mt-2 text-center text-xs text-muted-foreground">
        No charge for 14 days. Cancel anytime.
      </p>
    </div>
  )
}

Step 2: Modify CardFeed to inject subscription cards

In apps/web/app/feed/_components/card-feed.tsx, add the isAuthenticated prop and inject cards:

Add to imports:

import { SubscriptionCard } from './subscription-card'

Update the interface:

interface CardFeedProps {
  initialFacts: FactRecord[]
  topicSlug?: string
  isAuthenticated?: boolean
}

Update the component signature:

export function CardFeed({ initialFacts, topicSlug, isAuthenticated = true }: CardFeedProps) {

Replace the <CardGrid> rendering block (the facts.map) with injection logic:

<CardGrid>
  {facts.map((fact, index) => {
    const items = []

    // Inject subscription card at position 4, then every 24
    if (!isAuthenticated && (index === 4 || (index > 4 && (index - 4) % 24 === 0))) {
      items.push(<SubscriptionCard key={`sub-${index}`} />)
    }

    items.push(
      <FactCard
        key={fact.id}
        id={fact.id}
        title={fact.title}
        facts={fact.facts}
        notabilityScore={fact.notabilityScore}
        topicCategory={fact.topicCategory}
        publishedAt={fact.publishedAt}
        userStatus={fact.userStatus}
        imageUrl={fact.imageUrl}
        difficulty={fact.difficulty}
      />,
    )

    return items
  })}
  {loading && ['a', 'b', 'c', 'd'].map((id) => <CardSkeleton key={id} />)}
</CardGrid>

Step 3: Pass isAuthenticated from parent page

Check the feed page that renders <CardFeed> and ensure it passes isAuthenticated based on the user's auth state. The feed page server component should check auth and pass it down.

Step 4: Run typecheck and lint

Run: bun run typecheck && bun run lint Expected: No errors

Step 5: Commit

git add apps/web/app/feed/_components/subscription-card.tsx apps/web/app/feed/_components/card-feed.tsx
git commit -m "feat(feed): add subscription card injection for logged-out users"

Task 12: Quality Verification + Changelog (5.Q)

Files:

  • Modify: docs/changelog/unreleased.md

Step 1: Run full build

Run: bun run build Expected: All apps build successfully

Step 2: Run typecheck

Run: bun run typecheck Expected: No errors

Step 3: Run lint

Run: bun run lint Expected: No errors

Step 4: Run tests

Run: bun run test Expected: All pass

Step 5: Update changelog

Add to docs/changelog/unreleased.md under today's date:

## February 16, 2026

### Features

- **Subscribe**: Switch to 14-day trial with Stripe Checkout and CC collection
- **Public Site**: Add home page with live feed and card modal CTA
- **Public Site**: Add pricing page with plan comparison and annual toggle
- **Public Site**: Add features page with challenge system descriptions
- **Public Site**: Add about page with mission and fact engine overview
- **Feed**: Add subscription card injection for logged-out users

### Database

- **Migration 0114**: Update trial_days from 30 to 14 for Eko+ plan

Step 6: Commit

git add docs/changelog/unreleased.md
git commit -m "docs: update changelog for marketing site and trial update"

Step 7: Run full CI check

Run: bun run ci Expected: All checks pass