Skip to content

Newsletter with back issues

Three recent issues shown as real links beside the form.

Marketingstarternewsletterarchiveissuestransparency

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

This exact file renders the preview above.

'use client'

import { useState, type FormEvent } from 'react'
import Link from 'next/link'
import { ArrowRight } from 'lucide-react'
import { Container } from '@/components/ui/layout'
import { Button } from '@/components/ui/button'
import { Field } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { formatShortDate } from '@/lib/format'
import { changelog } from '@/content/demo'

/**
 * Newsletter with a back-issue archive
 *
 * Showing what the last three issues looked like answers the only real
 * objection to subscribing. The archive is a list of real links, not an
 * illustration.
 */
export default function NewsletterWithArchive() {
  const [email, setEmail] = useState('')
  const [error, setError] = useState<string | null>(null)
  const [done, setDone] = useState(false)

  const submit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault()
    const value = email.trim()
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(value)) {
      return setError('Enter a valid email address, for example name@company.com.')
    }
    setError(null)
    setDone(true)
  }

  return (
    <section className="border-b border-line bg-canvas py-section">
      <Container>
        <div className="grid gap-10 lg:grid-cols-2 lg:gap-16">
          <div>
            <h2 className="display-type text-2xl font-semibold text-ink-strong sm:text-3xl">
              See what you would be signing up for.
            </h2>
            <p className="mt-3 max-w-md text-md text-ink-muted">
              Three recent issues, in full. Subscribe only if you like them.
            </p>

            {done ? (
              <p
                role="status"
                className="mt-7 rounded-lg border border-success-line bg-success-soft px-4 py-3 text-sm text-ink"
              >
                Subscribed. This is a template demo — no submission was sent.
              </p>
            ) : (
              <form
                onSubmit={submit}
                noValidate
                className="mt-7 flex flex-col gap-3 sm:flex-row sm:items-start"
              >
                <Field
                  name="newsletter-archive"
                  label="Email address"
                  hideLabel
                  error={error}
                  className="flex-1"
                >
                  {(field) => (
                    <Input
                      {...field}
                      type="email"
                      autoComplete="email"
                      placeholder="you@company.com"
                      value={email}
                      onChange={(event) => {
                        setEmail(event.target.value)
                        if (error) setError(null)
                      }}
                    />
                  )}
                </Field>
                <Button type="submit" className="shrink-0">
                  Subscribe
                </Button>
              </form>
            )}
          </div>

          <div>
            <h3 className="label-caps text-ink-subtle">Recent issues</h3>
            <ul className="mt-4 divide-y divide-[var(--color-border-subtle)] border-y border-line">
              {changelog.slice(0, 3).map((issue) => (
                <li key={issue.version}>
                  <Link href="/changelog" className="group flex items-center gap-4 py-4">
                    <span className="min-w-0 flex-1">
                      <span className="block text-sm font-semibold text-ink-strong group-hover:text-accent">
                        {issue.title}
                      </span>
                      <span className="mt-0.5 block text-xs text-ink-muted">{issue.summary}</span>
                    </span>
                    <time dateTime={issue.date} className="shrink-0 text-xs text-ink-subtle">
                      {formatShortDate(issue.date)}
                    </time>
                    <ArrowRight
                      className="size-4 shrink-0 text-ink-subtle transition-transform group-hover:translate-x-0.5"
                      aria-hidden="true"
                    />
                  </Link>
                </li>
              ))}
            </ul>
          </div>
        </div>
      </Container>
    </section>
  )
}

components/blocks/sections/newsletter/with-archive.tsx

'use client'

import { useState, type FormEvent } from 'react'
import Link from 'next/link'
import { ArrowRight } from 'lucide-react'
import { Container } from '@/components/ui/layout'
import { Button } from '@/components/ui/button'
import { Field } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { formatShortDate } from '@/lib/format'
import { changelog } from '@/content/demo'

/**
 * Newsletter with a back-issue archive
 *
 * Showing what the last three issues looked like answers the only real
 * objection to subscribing. The archive is a list of real links, not an
 * illustration.
 */
export default function NewsletterWithArchive() {
  const [email, setEmail] = useState('')
  const [error, setError] = useState<string | null>(null)
  const [done, setDone] = useState(false)

  const submit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault()
    const value = email.trim()
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(value)) {
      return setError('Enter a valid email address, for example name@company.com.')
    }
    setError(null)
    setDone(true)
  }

  return (
    <section className="border-b border-line bg-canvas py-section">
      <Container>
        <div className="grid gap-10 lg:grid-cols-2 lg:gap-16">
          <div>
            <h2 className="display-type text-2xl font-semibold text-ink-strong sm:text-3xl">
              See what you would be signing up for.
            </h2>
            <p className="mt-3 max-w-md text-md text-ink-muted">
              Three recent issues, in full. Subscribe only if you like them.
            </p>

            {done ? (
              <p
                role="status"
                className="mt-7 rounded-lg border border-success-line bg-success-soft px-4 py-3 text-sm text-ink"
              >
                Subscribed. This is a template demo — no submission was sent.
              </p>
            ) : (
              <form
                onSubmit={submit}
                noValidate
                className="mt-7 flex flex-col gap-3 sm:flex-row sm:items-start"
              >
                <Field
                  name="newsletter-archive"
                  label="Email address"
                  hideLabel
                  error={error}
                  className="flex-1"
                >
                  {(field) => (
                    <Input
                      {...field}
                      type="email"
                      autoComplete="email"
                      placeholder="you@company.com"
                      value={email}
                      onChange={(event) => {
                        setEmail(event.target.value)
                        if (error) setError(null)
                      }}
                    />
                  )}
                </Field>
                <Button type="submit" className="shrink-0">
                  Subscribe
                </Button>
              </form>
            )}
          </div>

          <div>
            <h3 className="label-caps text-ink-subtle">Recent issues</h3>
            <ul className="mt-4 divide-y divide-[var(--color-border-subtle)] border-y border-line">
              {changelog.slice(0, 3).map((issue) => (
                <li key={issue.version}>
                  <Link href="/changelog" className="group flex items-center gap-4 py-4">
                    <span className="min-w-0 flex-1">
                      <span className="block text-sm font-semibold text-ink-strong group-hover:text-accent">
                        {issue.title}
                      </span>
                      <span className="mt-0.5 block text-xs text-ink-muted">{issue.summary}</span>
                    </span>
                    <time dateTime={issue.date} className="shrink-0 text-xs text-ink-subtle">
                      {formatShortDate(issue.date)}
                    </time>
                    <ArrowRight
                      className="size-4 shrink-0 text-ink-subtle transition-transform group-hover:translate-x-0.5"
                      aria-hidden="true"
                    />
                  </Link>
                </li>
              ))}
            </ul>
          </div>
        </div>
      </Container>
    </section>
  )
}

lib/format.ts

/** Small formatting helpers shared across catalogue and starter surfaces. */

export function titleCase(value: string): string {
  return value
    .split(/[-_\s]+/)
    .filter(Boolean)
    .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
    .join(' ')
}

export function pluralise(count: number, singular: string, plural = `${singular}s`): string {
  return `${count} ${count === 1 ? singular : plural}`
}

const currencyFormatter = new Intl.NumberFormat('en-US', {
  style: 'currency',
  currency: 'USD',
  minimumFractionDigits: 2,
})

export function formatCurrency(cents: number): string {
  return currencyFormatter.format(cents / 100)
}

const compactFormatter = new Intl.NumberFormat('en-US', {
  notation: 'compact',
  maximumFractionDigits: 1,
})

export function formatCompact(value: number): string {
  return compactFormatter.format(value)
}

/**
 * Dates in Foundry are authored as ISO date strings so that server and client
 * renders agree byte-for-byte. Formatting is pinned to `en-US` + UTC for the
 * same reason — no hydration drift from the visitor's locale or timezone.
 */
export function formatDate(iso: string): string {
  const date = new Date(`${iso}T00:00:00Z`)
  if (Number.isNaN(date.getTime())) return iso
  return new Intl.DateTimeFormat('en-US', {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
    timeZone: 'UTC',
  }).format(date)
}

export function formatShortDate(iso: string): string {
  const date = new Date(`${iso}T00:00:00Z`)
  if (Number.isNaN(date.getTime())) return iso
  return new Intl.DateTimeFormat('en-US', {
    month: 'short',
    day: 'numeric',
    year: 'numeric',
    timeZone: 'UTC',
  }).format(date)
}

export function initials(name: string): string {
  const parts = name.trim().split(/\s+/).filter(Boolean)
  if (parts.length === 0) return '?'
  if (parts.length === 1) return (parts[0] ?? '?').slice(0, 2).toUpperCase()
  return `${(parts[0] ?? '')[0] ?? ''}${(parts[parts.length - 1] ?? '')[0] ?? ''}`.toUpperCase()
}

export function slugify(value: string): string {
  return value
    .toLowerCase()
    .normalize('NFKD')
    .replace(/[^\w\s-]/g, '')
    .trim()
    .replace(/[\s_]+/g, '-')
    .replace(/-+/g, '-')
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

Showing what the last three issues looked like answers the only real objection to subscribing. The archive is real links, not an illustration.

  • Link to genuine back issues; a fake archive is worse than none.
  • Show the date on every issue so the cadence is visible.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • Two-column layout
  • Three back issues
  • Validation

Accessibility

Whole-row links
Each issue is one focus stop.
Time elements
Issue dates are machine-readable.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.