Newsletter with back issues
Three recent issues shown as real links beside the form.
Live preview
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.
Related
All sectionsNewsletter with topics
Topic selection, because choice reduces unsubscribes more than frequency does.
intermediate3 variantsRelease timeline
Versioned releases with tone-coded markers and machine-readable dates.
intermediate3 variantsNewsletter footer
A validated subscribe form as the footer’s primary content, with an honest success message.
intermediate3 variants