# 뉴스레터 구독

이메일을 받아 구독을 확인해 주고 지난 호를 겹쳐 보여 주는 가입 블록이에요.

- Type: block
- Theme: base
- Tags: arc, block, newsletter, signup, email, form, marketing
- English title: Newsletter signup
- URL: https://dsui.vercel.app/items/newsletter-signup

## Used items

- [숫자 카운터](https://dsui.vercel.app/items/animated-counter): 화면에 보이면 숫자가 부드럽게 올라가는 카운터예요.
- [세그먼트 컨트롤](https://dsui.vercel.app/items/segmented-control): 한 번에 하나만 고르는 짧은 선택지를 나란히 보여 주는 컨트롤이에요.
- [스위치](https://dsui.vercel.app/items/switch): 켜고 끄는 값을 바꾸는 스위치로, 누르는 동안 손잡이가 늘어나요.

## Controls

| Key | Type | Label | Default |
| --- | --- | --- | --- |
| variant | select | 레이아웃 | inline |
| title | text | 제목 |  |
| description | text | 설명 |  |
| buttonLabel | text | 버튼 문구 | 구독하기 |
| placeholder | text | 입력 안내 | name&#64;example.com |

## Source

### newsletter-signup-data.ts

```ts
import { avatar, photo, type PersonId, type PhotoId } from "../../lib/media";

/** One story teased in an issue. `image` is a small square-cropped thumbnail. */
export interface NewsletterStory {
  title: string;
  image: string;
  minutes: number;
}

/** One issue of the newsletter, as it appears on the stack. */
export interface NewsletterIssue {
  number: number;
  /** Send date as it should read, such as "10월 2일 금요일". */
  date: string;
  subject: string;
  /** Up to three stories. */
  stories: NewsletterStory[];
}

/** The publication shown beside the form: the next issue lands on the stack of recent ones when someone subscribes. */
export interface NewsletterPublication {
  name: string;
  /** The next issue. It is addressed to the new reader and joins the front of the stack on success. */
  upcoming: NewsletterIssue;
  /** Recent issues, newest first. The first three make the stack. */
  recent: NewsletterIssue[];
}

const story = (title: string, image: PhotoId, minutes: number): NewsletterStory => ({ title, image: photo(image).src, minutes });

export const newsletterCopy = {
  inline: {
    title: "인터페이스 장인정신을 매주 금요일 5분에",
    description: "읽을 가치가 있는 링크 세 개와, 소프트웨어를 제대로 느끼게 하는 디테일에 관한 짧은 에세이를 보내 드려요.",
  },
  card: {
    title: "다음 호를 금요일에 받아 보세요",
    description: "링크 세 개와 인터페이스 장인정신에 관한 짧은 에세이예요. 일주일에 한 번, 5분이면 충분해요.",
  },
  privacy: "추적 픽셀은 쓰지 않아요. 한 번의 클릭으로 구독을 취소할 수 있어요.",
  privacyLink: { label: "개인정보 처리방침", href: "#privacy" },
};

/** Sample publication. Dates follow a Friday send; images come from `public/media/photos`. */
export const newsletterPublication: NewsletterPublication = {
  name: "여백 노트",
  upcoming: {
    number: 149,
    date: "10월 2일 금요일",
    subject: "첫 방문보다 두 번째 방문을 위한 디자인",
    stories: [
      story("첫걸음을 건네는 빈 화면", "reading-chair", 5),
      story("도자기 공방에서 배운, 소음 없는 질감", "stacked-bowls", 4),
      story("콘서트홀은 2천 명을 어떻게 앉힐까", "concert-hall", 7),
    ],
  },
  recent: [
    {
      number: 148,
      date: "9월 25일 금요일",
      subject: "좋은 설정 화면은 왜 조용할까",
      stories: [
        story("볕이 드는 방이 알려 주는 대비", "sunroom", 4),
        story("강조색은 하나면 충분해요", "ceramic-lamp", 3),
        story("리스본 트램 표지판과 길 찾기 설계", "lisbon-tram", 6),
      ],
    },
    {
      number: 147,
      date: "9월 18일 금요일",
      subject: "튀지 않고 가라앉는 스프링",
      stories: [
        story("애니메이션 곡선의 능선 읽기", "mountain-ridges", 5),
        story("탁상 조명과 다크 모드의 온기", "table-lamp", 3),
        story("건축의 곡선과 화면 모서리의 곡선", "curved-facade", 4),
      ],
    },
    {
      number: 146,
      date: "9월 11일 금요일",
      subject: "사람처럼 말하는 문구",
      stories: [
        story("탓을 대신 짊어지는 오류 메시지", "glass-carafe", 4),
        story("버튼은 동사로 써요", "home-office", 3),
        story("저녁 식탁에서 배우는 말투", "salmon-dinner", 5),
      ],
    },
  ],
};

/** Readers shown under the stack and in the card. */
export const newsletterReaders = {
  count: 12480,
  faces: (["jasmine-brooks", "daniel-kim", "ava-mitchell"] as PersonId[]).map(id => avatar(id)),
};

```

### newsletter-signup.module.css

```css
.newsletter {
  --paper: var(--surface-raised);
  --paper-back: color-mix(in oklab, var(--surface-raised), var(--surface-muted) 55%);
  --paper-far: var(--surface-muted);
  --paper-shadow: 0 1px 1px oklch(0% 0 0 / .03), 0 10px 28px -6px oklch(0% 0 0 / .09);
  container: newsletter / inline-size;
  background: var(--background);
  color: var(--foreground);
  font-family: var(--font-body);
  letter-spacing: var(--tracking-body);
  text-align: start;
}
:global(:root[data-theme="dark"]) .newsletter {
  --paper: oklch(24.5% 0 0);
  --paper-back: oklch(22.5% 0 0);
  --paper-far: oklch(21% 0 0);
  --paper-shadow: 0 1px 1px oklch(0% 0 0 / .2), 0 14px 32px -6px oklch(0% 0 0 / .45);
}
.newsletter *, .newsletter *::before, .newsletter *::after { box-sizing: border-box; }
.srOnly { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

/* Inline: copy and form on the left, the issue stack on the right. */
.inline { display: grid; max-width: 1120px; grid-template-columns: minmax(0, 1fr); gap: var(--space-12) var(--space-16); margin: 0 auto; padding: var(--space-24) var(--space-8); }
.newsletter[data-aside] .inline { grid-template-columns: minmax(0, 1fr) minmax(0, 440px); align-items: center; }
/* Without the stack or readers the section is the copy and form alone. */
.newsletter[data-variant="inline"]:not([data-aside]) .inline { max-width: 720px; }
.intro { display: grid; max-width: 540px; }
.title { margin: 0; font-family: var(--font-display); font-size: clamp(2rem, 1.1rem + 3.2cqi, var(--text-4xl)); font-weight: 500; letter-spacing: var(--tracking-display); line-height: var(--leading-display); text-wrap: balance; }
.description { margin: var(--space-4) 0 0; color: var(--text-secondary); font-size: var(--text-lg); line-height: var(--leading-body); text-wrap: pretty; }
.intro .form { margin-top: var(--space-10); }
.aside { display: grid; justify-items: center; gap: var(--space-6); min-width: 0; }

/* The stack: every issue shares one grid cell, so the stack is as tall as one issue and nothing shifts when they trade places. */
.stack { display: grid; width: 100%; max-width: 440px; padding-top: calc(12px * 2); }
.issue {
  display: grid;
  grid-area: 1 / 1;
  align-content: start;
  min-width: 0;
  border: 1px solid var(--border);
  border-radius: 22px;
  padding: 22px 22px 18px;
  background: var(--paper);
  box-shadow: var(--paper-shadow);
  transform-origin: 50% 0;
  transition: background-color var(--duration-considered) var(--ease-standard);
}
.issueSizer { visibility: hidden; box-shadow: none; }
.issue[data-depth="1"] { background: var(--paper-back); box-shadow: none; }
.issue[data-depth="2"] { background: var(--paper-far); box-shadow: none; }
.issueHead { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-3); }
.masthead { font-family: var(--font-display); font-size: var(--text-xl); font-weight: 500; letter-spacing: var(--tracking-display); line-height: var(--leading-display); }
.issueNumber, .issueMeta { color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); font-variant-numeric: tabular-nums; }
.issueMeta { display: flex; min-width: 0; gap: 6px; margin: 2px 0 0; white-space: nowrap; }
.issueMeta > span:last-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.recipient { color: var(--text-secondary); }
.recipientEmail { color: var(--foreground); }
.subject { min-height: calc(2em * var(--leading-display)); margin: var(--space-5) 0 var(--space-4); border-top: 1px solid var(--border); padding-top: var(--space-5); font-family: var(--font-display); font-size: var(--text-2xl); font-weight: 500; letter-spacing: var(--tracking-display); line-height: var(--leading-display); text-wrap: balance; box-sizing: content-box; }
.stack[data-compact] .subject { font-size: var(--text-xl); }
.stories { display: grid; gap: 10px; margin: 0; padding: 0; list-style: none; }
.story { display: grid; grid-template-columns: 44px minmax(0, 1fr) auto; align-items: center; gap: 12px; min-height: 44px; }
.thumb { display: block; width: 44px; height: 44px; border-radius: 10px; object-fit: cover; background: var(--surface-muted); }
.storyTitle { display: -webkit-box; overflow: hidden; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); -webkit-box-orient: vertical; -webkit-line-clamp: 2; text-wrap: pretty; }
.minutes { color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; white-space: nowrap; }

/* Readers: faces and a count that ticks up by one. */
.readers { display: flex; align-items: center; gap: var(--space-3); color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); }
.faces { display: flex; flex: none; padding-left: 6px; }
.faces img { display: block; width: 28px; height: 28px; margin-left: -6px; border: 2px solid var(--background); border-radius: 50%; object-fit: cover; }
.card .faces img { border-color: var(--surface); }
.readerLine { min-width: 0; font-variant-numeric: tabular-nums; white-space: nowrap; }
.count { display: inline-block; color: var(--foreground); }
.count > span { color: inherit; font-family: inherit; font-size: inherit; font-weight: 500; letter-spacing: inherit; line-height: inherit; vertical-align: bottom; }
.you { display: inline-block; overflow: hidden; color: var(--text-secondary); vertical-align: bottom; white-space: nowrap; }

/* Card: a self-contained card whose top is a tray holding the stack, cropped where the card body begins. */
.newsletter[data-variant="card"] { padding: var(--space-20) var(--space-6); }
.cardShell { display: grid; width: 100%; place-items: center; }
.card { display: grid; width: min(100%, 460px); overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-surface); background: var(--surface); box-shadow: var(--shadow-resting); }
.tray { display: grid; height: 248px; grid-template-rows: minmax(0, 1fr); overflow: hidden; justify-items: center; border-bottom: 1px solid var(--border); padding: 20px 32px 0; background: var(--surface-muted); }
.tray .stack { height: 100%; -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 64px), transparent); mask-image: linear-gradient(to bottom, #000 calc(100% - 64px), transparent); }
.tray .issue { box-shadow: none; }
.cardBody { display: grid; padding: var(--space-8); }
.cardTitle { margin: 0; font-family: var(--font-display); font-size: var(--text-2xl); font-weight: 500; letter-spacing: var(--tracking-display); line-height: var(--leading-display); text-wrap: balance; }
.card .description { margin-top: var(--space-2); font-size: var(--text-base); }
.card .form { margin-top: var(--space-6); }
.card .readers { margin-top: var(--space-5); border-top: 1px solid var(--border-subtle); padding-top: var(--space-5); }
:global(:root[data-theme="dark"]) .tray { background: var(--background); }

/* The form row: a field and a button at the same height, each drawn like the system's Input and Button. */
.form { container: signup / inline-size; display: grid; gap: var(--space-3); min-width: 0; }
.pill { position: relative; display: flex; align-items: stretch; gap: var(--space-2); }
.input { width: 100%; min-width: 0; flex: 1; min-height: var(--control-height-lg); border: 1px solid var(--border-strong); border-radius: var(--radius-control); padding: 0 var(--space-4); background: var(--surface); color: var(--foreground); font: inherit; font-size: var(--text-base); line-height: var(--leading-body); letter-spacing: inherit; outline: none; transition: border-color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); }
.input::placeholder { color: var(--text-muted); }
@media (hover: hover) and (pointer: fine) { .pill:not([data-done], [data-invalid]) .input:hover:not(:focus-visible) { border-color: var(--foreground); } }
/* Focus fills the field with the accent and a problem fills it with danger; the field never changes size. */
.input:focus-visible { border-color: var(--accent); background: var(--accent); color: var(--accent-foreground); caret-color: var(--accent-foreground); }
.input:focus-visible::placeholder { color: var(--accent-foreground-muted); }
.pill.pill[data-invalid] .input { border-color: var(--danger); background: var(--danger); color: var(--accent-foreground); caret-color: var(--accent-foreground); }
.pill.pill[data-invalid] .input::placeholder { color: var(--accent-foreground-muted); }
.pill[data-done] .input { border-color: color-mix(in oklab, var(--success) 45%, var(--border)); color: var(--text-secondary); }

/* The button follows the system's large Button: control radius, label-lg, bold label, same height as the field. */
.submit { position: relative; display: inline-flex; min-height: var(--control-height-lg); flex: none; align-items: center; justify-content: center; border: 1px solid var(--accent); border-radius: var(--radius-control); padding: 0 var(--space-5); background: var(--accent); color: var(--accent-foreground); font: inherit; font-size: var(--label-lg); font-weight: var(--label-weight); line-height: var(--leading-body); cursor: pointer; white-space: nowrap; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard); }
@media (hover: hover) and (pointer: fine) { .submit[data-state="idle"]:hover, .submit[data-state="retry"]:hover { opacity: .91; box-shadow: var(--shadow-resting); } }
.submit[data-state="idle"]:active, .submit[data-state="retry"]:active { background: var(--accent-strong); border-color: var(--accent-strong); }
.submit[data-state="sending"] { cursor: progress; }
.submit[data-state="done"] { border-color: transparent; background: color-mix(in oklab, var(--success) 13%, var(--surface)); color: var(--success); cursor: default; }
.labels { display: inline-grid; place-items: center; }
.sizer, .label { display: inline-flex; grid-area: 1 / 1; align-items: center; gap: var(--space-2); }
.sizer { visibility: hidden; }
.note.sizer { display: block; }
.spinner { width: 14px; height: 14px; border: 2px solid color-mix(in oklab, currentColor 30%, transparent); border-top-color: currentColor; border-radius: 50%; animation: spin 700ms linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.check { display: inline-flex; width: 16px; height: 16px; }
.check svg { width: 16px; height: 16px; }

/* Two lines are reserved so the note, an error, and the confirmation never move what sits below. */
.message { display: grid; }
.message > * { grid-area: 1 / 1; min-width: 0; }
.messageLive { position: relative; }
.note { margin: 0; padding-left: var(--space-1); color: var(--text-muted); font-size: var(--text-sm); line-height: var(--leading-body); text-wrap: pretty; }
.note[data-tone="error"] { color: var(--danger); }
.note[data-tone="success"] { color: var(--text-secondary); }
.link, .textButton { color: var(--foreground); text-decoration: underline; text-decoration-color: var(--border-strong); text-underline-offset: 3px; transition: text-decoration-color var(--duration-fast) var(--ease-standard); }
.textButton { border: 0; padding: 0; background: none; font: inherit; cursor: pointer; }
@media (hover: hover) and (pointer: fine) { .link:hover, .textButton:hover { text-decoration-color: currentColor; } }

@container newsletter (max-width: 860px) {
  .newsletter[data-variant="inline"] .inline { grid-template-columns: minmax(0, 1fr); gap: var(--space-12); padding: var(--space-16) var(--space-8); }
  .intro { max-width: 600px; }
}
@container newsletter (max-width: 520px) {
  .newsletter[data-variant="inline"] .inline { padding: var(--space-12) var(--space-4); gap: var(--space-10); }
  .description { font-size: var(--text-base); }
  .intro .form { margin-top: var(--space-8); }
  .issue { border-radius: 20px; padding: 18px 18px 16px; }
  .subject, .stack[data-compact] .subject { font-size: var(--text-xl); }
  .newsletter[data-variant="card"] { padding: var(--space-10) var(--space-4); }
  .card { border-radius: 28px; }
  .tray { height: 232px; padding: 16px 20px 0; }
  .cardBody { padding: var(--space-6); }
  .note { padding-left: 4px; }
}
@container newsletter (max-width: 400px) {
  .minutes { display: none; }
  /* The recipient matters more than the date once the issue is addressed. */
  .issueMeta:has(.recipient) > :not(.recipient) { display: none; }
  .story { grid-template-columns: 44px minmax(0, 1fr); }
  .faces img { width: 24px; height: 24px; }
  .readers { gap: var(--space-2); }
}
@container newsletter (max-width: 360px) {
  .readers { align-items: flex-start; }
  .readerLine { min-height: calc(2 * 1.4em); white-space: normal; }
}
/* On narrow phones the field and the button stack, both full width. */
@container signup (max-width: 380px) {
  .pill { flex-direction: column; }
  .input { flex: none; }
  .note { padding-left: 4px; }
}

/* Preview chrome. */
.preview { display: grid; width: 100%; justify-items: center; gap: var(--space-4); }
.controls { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: var(--space-3) var(--space-6); }
.simulate { display: inline-flex; align-items: center; gap: 10px; color: var(--text-secondary); font-size: var(--text-sm); }
.simulate label { cursor: pointer; }
.frame { width: 100%; overflow: hidden; border: 1px solid var(--border); border-radius: 20px; background: var(--background); }
.caption { margin: 0; color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); text-align: center; }

@media (prefers-reduced-motion: reduce) {
  .pill, .submit, .input, .issue { transition: none; }
  .spinner { animation-duration: 1.6s; }
}

```

### newsletter-signup.tsx

```tsx
"use client";

import { forwardRef, useEffect, useId, useRef, useState } from "react";
import type { FormEvent, ReactNode } from "react";
import Image from "next/image";
import { AnimatePresence, motion, useAnimate } from "motion/react";
import { useReducedMotion } from "../../lib/use-reduced-motion";
import type { Variants } from "motion/react";
import AnimatedCounter from "../../animated-counter/animated-counter";
import SegmentedControl from "../../segmented-control/segmented-control";
import { Switch } from "../../switch/switch";
import { motionTokens } from "../../lib/motion-tokens";
import { newsletterCopy, newsletterPublication, newsletterReaders } from "./newsletter-signup-data";
import type { NewsletterIssue, NewsletterPublication } from "./newsletter-signup-data";
import styles from "./newsletter-signup.module.css";
import { UiIcon } from "../../theme/ui-icon";
import { playUiSound } from "../../sound/play";

export type { NewsletterIssue, NewsletterPublication, NewsletterStory } from "./newsletter-signup-data";
export type NewsletterVariant = "inline" | "card";

export interface NewsletterSignupProps {
  /** `inline` puts the copy and form beside the issue stack; `card` is a self-contained card with the stack in a tray on top. */
  variant?: NewsletterVariant;
  title?: string;
  description?: string;
  placeholder?: string;
  buttonLabel?: string;
  /** Short line under the form about frequency and privacy. */
  privacyNote?: ReactNode;
  /** Link after the privacy note. Pass null to hide it. */
  privacyLink?: { label: string; href: string } | null;
  /** Reader count and up to three faces. The count ticks up by one when someone subscribes. Pass null to hide it. */
  readers?: { count: number; faces: string[] } | null;
  /** The issue stack. On success the upcoming issue, addressed to the new reader, lands on top. Pass null for a form without the stack. */
  publication?: NewsletterPublication | null;
  /** Called with a valid, trimmed email. Reject to show an error and keep the email; resolve to show the success state. */
  onSubscribe?: (email: string) => void | Promise<void>;
  className?: string;
}

type Phase = "idle" | "sending" | "done";
type Problem = { kind: "invalid" | "failed"; text: string };
type Bezier = [number, number, number, number];
const enter = [...motionTokens.ease.enter] as Bezier;
const standard = [...motionTokens.ease.standard] as Bezier;
const MESSAGES = {
  empty: "이메일 주소를 입력해 주세요",
  format: "name@example.com 같은 형식으로 입력해 주세요",
  failed: "전송하지 못했어요. 입력한 이메일은 그대로 두었으니 다시 해 볼까요?",
};
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
/** How far each issue behind the front one peeks out above it. */
const STEP = 12;

function validate(value: string): Problem | null {
  const email = value.trim();
  if (!email) return { kind: "invalid", text: MESSAGES.empty };
  if (!EMAIL.test(email)) return { kind: "invalid", text: MESSAGES.format };
  return null;
}

const swapIn = { opacity: 0, y: 6, filter: `blur(${motionTokens.blur.subtle}px)` };
const swapShown = { opacity: 1, y: 0, filter: "blur(0px)" };
const swapOut = { opacity: 0, y: -6, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: standard } };
const fadeIn = { opacity: 0 };
const fadeOut = { opacity: 0, transition: { duration: motionTokens.duration.instant } };

/** A line that swaps in place: the old text lifts away and the new one rises out of a soft blur. */
function Swap({ id, children, reduced, className, live }: { id: string; children: ReactNode; reduced: boolean; className?: string; live?: "polite" }) {
  return <div className={className} aria-live={live}>
    <AnimatePresence initial={false} mode="popLayout">
      <motion.div key={id} initial={reduced ? fadeIn : swapIn} animate={swapShown} exit={reduced ? fadeOut : swapOut} transition={{ duration: reduced ? motionTokens.duration.instant : motionTokens.duration.standard, ease: enter }}>{children}</motion.div>
    </AnimatePresence>
  </div>;
}

/** One issue: masthead, recipient line, subject, and three stories with thumbnails. */
function IssueCard({ issue, name, to }: { issue: NewsletterIssue; name: string; to?: string }) {
  return <>
    <header className={styles.issueHead}>
      <span className={styles.masthead}>{name}</span>
      <span className={styles.issueNumber}>제{issue.number}호</span>
    </header>
    <p className={styles.issueMeta}>
      <span className={styles.issueDate}>{issue.date}</span>
      {to && <><span aria-hidden="true">&middot;</span><span className={styles.recipient}>받는 사람 <span className={styles.recipientEmail}>{to}</span></span></>}
    </p>
    <h3 className={styles.subject}>{issue.subject}</h3>
    <ul className={styles.stories}>
      {issue.stories.slice(0, 3).map(story => <li key={story.title} className={styles.story}>
        <Image className={styles.thumb} src={story.image} alt="" width={88} height={88} sizes="44px" />
        <span className={styles.storyTitle}>{story.title}</span>
        <span className={styles.minutes}>{story.minutes}분</span>
      </li>)}
    </ul>
  </>;
}

type Placement = { depth: number; upcoming: boolean };
/** The front issue sits flat; each one behind steps up and shrinks a little so its top edge shows. */
const stackVariants: Variants = {
  placed: ({ depth }: Placement) => ({ opacity: 1, y: -depth * STEP, scale: 1 - depth * .045 }),
  // The upcoming issue arrives from below, where the form is; older ones slide in from behind.
  away: ({ upcoming }: Placement) => upcoming ? { opacity: 0, y: 56, scale: 1 } : { opacity: 0, y: -3 * STEP, scale: 1 - 3 * .045 },
};
/** Position rides the no-overshoot spring; opacity resolves fast so two issues never read through each other. */
const settle = { ...motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.instant, ease: standard } };
/** Reduced motion: issues take their places at once and only fade. */
const still = { duration: 0, opacity: { duration: motionTokens.duration.fast } };

/**
 * A stack of recent issues. When someone subscribes, the upcoming issue, addressed to them, lands on the front
 * and the older ones step back; the oldest drops away.
 */
function IssueStack({ publication, delivered, to, reduced, compact }: { publication: NewsletterPublication; delivered: boolean; to: string; reduced: boolean; compact?: boolean }) {
  const issues = (delivered ? [publication.upcoming, ...publication.recent] : publication.recent).slice(0, 3);
  return <div className={styles.stack} data-compact={compact ? "" : undefined}>
    {/* An unseen copy of the upcoming issue keeps the stack as tall as the tallest issue it will ever hold. */}
    <div className={`${styles.issue} ${styles.issueSizer}`} aria-hidden="true"><IssueCard issue={publication.upcoming} name={publication.name} to={to || " "} /></div>
    <AnimatePresence initial={false}>
      {issues.map((issue, depth) => {
        const upcoming = issue === publication.upcoming;
        const placement: Placement = { depth, upcoming };
        return <motion.article
          key={issue.number}
          className={styles.issue}
          data-depth={depth}
          aria-hidden={depth > 0 ? true : undefined}
          aria-label={depth === 0 ? `${publication.name} 제${issue.number}호` : undefined}
          custom={placement}
          variants={stackVariants}
          initial="away"
          animate="placed"
          exit="away"
          transition={reduced ? still : settle}
          style={{ zIndex: 3 - depth }}
        >
          <IssueCard issue={issue} name={publication.name} to={upcoming ? to : undefined} />
        </motion.article>;
      })}
    </AnimatePresence>
  </div>;
}

/** Faces and a reader count that ticks up by one when you join. */
function Readers({ readers, done, reduced }: { readers: { count: number; faces: string[] }; done: boolean; reduced: boolean }) {
  return <div className={styles.readers}>
    <span className={styles.faces} aria-hidden="true">
      {readers.faces.slice(0, 3).map(src => <Image key={src} src={src} alt="" width={56} height={56} sizes="28px" />)}
    </span>
    <span className={styles.readerLine}>
      <span className={styles.count}><AnimatedCounter value={readers.count + (done ? 1 : 0)} /></span>
      명의 독자
      <AnimatePresence initial={false}>
        {/* The phrase opens its own width on the no-overshoot spring, so the centred line glides instead of jumping sideways. */}
        {done && <motion.span key="you" className={styles.you}
          initial={reduced ? { opacity: 0 } : { opacity: 0, width: 0 }}
          animate={reduced ? { opacity: 1 } : { opacity: 1, width: "auto" }}
          exit={reduced ? { opacity: 0 } : { opacity: 0, width: 0 }}
          transition={reduced ? { duration: motionTokens.duration.instant } : { width: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.standard, ease: enter, delay: motionTokens.duration.instant } }}>, 나도 포함이에요</motion.span>}
      </AnimatePresence>
    </span>
  </div>;
}

/**
 * A newsletter signup framed by the newsletter itself: a stack of recent issues. Subscribing drops the next issue,
 * addressed to the new reader, onto the front of the stack and ticks the reader count up by one.
 * Comes as an inline page section or a self-contained card.
 */
export const NewsletterSignup = forwardRef<HTMLElement, NewsletterSignupProps>(function NewsletterSignup({
  variant = "inline",
  title,
  description,
  placeholder = "name@example.com",
  buttonLabel = "구독하기",
  privacyNote = newsletterCopy.privacy,
  privacyLink = newsletterCopy.privacyLink,
  readers = newsletterReaders,
  publication = newsletterPublication,
  onSubscribe,
  className,
}, ref) {
  const id = useId();
  const reduced = !!useReducedMotion();
  const [email, setEmail] = useState("");
  const [problem, setProblem] = useState<Problem | null>(null);
  const [tried, setTried] = useState(false);
  const [phase, setPhase] = useState<Phase>("idle");
  const [sentTo, setSentTo] = useState("");
  const input = useRef<HTMLInputElement>(null);
  const again = useRef<HTMLButtonElement>(null);
  const busy = useRef(false);
  const refocus = useRef(false);
  const [pill, animatePill] = useAnimate<HTMLDivElement>();
  const copy = newsletterCopy[variant];

  useEffect(() => {
    if (phase === "idle" && refocus.current) { refocus.current = false; input.current?.focus(); }
    if (phase === "done") again.current?.focus();
  }, [phase]);

  function shake() {
    if (reduced || !pill.current) return;
    animatePill(pill.current, { x: [0, -6, 5, -3, 1, 0] }, { duration: motionTokens.duration.moderate, ease: "easeOut" });
  }

  async function submit(event: FormEvent) {
    event.preventDefault();
    if (busy.current || phase !== "idle") return;
    setTried(true);
    const found = validate(email);
    setProblem(found);
    if (found) { shake(); input.current?.focus(); return; }
    const value = email.trim();
    busy.current = true;
    setPhase("sending");
    try {
      await (onSubscribe ? onSubscribe(value) : new Promise(resolve => setTimeout(resolve, 1100)));
      setSentTo(value);
      setPhase("done");
      playUiSound("success");
    } catch {
      setPhase("idle");
      playUiSound("error");
      setProblem({ kind: "failed", text: MESSAGES.failed });
      shake();
    } finally {
      busy.current = false;
    }
  }

  function reset() {
    setEmail("");
    setProblem(null);
    setTried(false);
    refocus.current = true;
    setPhase("idle");
  }

  const done = phase === "done";
  const sending = phase === "sending";
  const messageId = `${id}-message`;
  const labelKey = done ? "done" : sending ? "sending" : problem?.kind === "failed" ? "retry" : "idle";
  const labels: Record<string, ReactNode> = {
    idle: <>{buttonLabel}<UiIcon iconRole="arrow-right" size={16} strokeWidth={1.75} aria-hidden="true" /></>,
    retry: <>다시 하기<UiIcon iconRole="arrow-right" size={16} strokeWidth={1.75} aria-hidden="true" /></>,
    sending: <><span className={styles.spinner} aria-hidden="true" />구독하는 중이에요</>,
    done: <><span className={styles.check} aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round"><motion.path d="M5 12.5l4.5 4.5L19 7.5" initial={reduced ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: reduced ? 0 : motionTokens.duration.moderate, ease: enter, delay: motionTokens.duration.instant }} /></svg></span>구독했어요</>,
  };

  const doneText = "받은 편지함에서 구독을 확인해 주세요.";
  const note = <>{privacyNote}{privacyLink && <> <a className={styles.link} href={privacyLink.href} onClick={() => playUiSound("tick")}>{privacyLink.label}</a></>}</>;
  // Plain copies of the note and the confirmation, used only to reserve the line's height.
  const measures = [<>{privacyNote}{privacyLink && ` ${privacyLink.label}`}</>, `${doneText} 다른 이메일 쓰기`, MESSAGES.empty, MESSAGES.format, MESSAGES.failed];
  const form = <form className={styles.form} onSubmit={submit} noValidate aria-label={title ?? copy.title}>
    <motion.div ref={pill} className={styles.pill} data-invalid={problem ? "" : undefined} data-done={done ? "" : undefined}>
      <label htmlFor={`${id}-email`} className={styles.srOnly}>이메일 주소</label>
      <input
        ref={input}
        id={`${id}-email`}
        className={styles.input}
        type="email"
        name="email"
        inputMode="email"
        autoComplete="email"
        enterKeyHint="send"
        autoCapitalize="off"
        spellCheck={false}
        placeholder={placeholder}
        value={email}
        readOnly={phase !== "idle"}
        aria-invalid={problem?.kind === "invalid" ? true : undefined}
        aria-describedby={messageId}
        onBlur={() => { if (email.trim() && !tried && phase === "idle") { setTried(true); setProblem(validate(email)); } }}
        onChange={event => { setEmail(event.target.value); if (tried) setProblem(validate(event.target.value)); else if (problem) setProblem(null); }}
      />
      <motion.button
        type={done ? "button" : "submit"}
        className={styles.submit}
        data-state={labelKey}
        aria-busy={sending || undefined}
        aria-disabled={sending || done || undefined}
        tabIndex={done ? -1 : undefined}
        onClick={() => { if (!sending && !done) playUiSound("click"); }}
        whileTap={{ scale: reduced || sending || done ? 1 : .97 }}
        transition={motionTokens.spring.snappy}
      >
        <span className={styles.labels}>
          {Object.keys(labels).map(key => <span key={key} className={styles.sizer} aria-hidden="true">{key === "done" ? <><span className={styles.check} />구독했어요</> : labels[key]}</span>)}
          <AnimatePresence initial={false} mode="popLayout">
            <motion.span key={labelKey} className={styles.label} initial={reduced ? fadeIn : swapIn} animate={swapShown} exit={reduced ? fadeOut : swapOut} transition={{ duration: reduced ? motionTokens.duration.instant : motionTokens.duration.standard, ease: enter }}>
              {labels[labelKey]}
            </motion.span>
          </AnimatePresence>
        </span>
      </motion.button>
    </motion.div>
    <div className={styles.message}>
      {/* Every message the line can show sits here unseen, so the line is always as tall as the longest one and nothing below it moves. */}
      {measures.map((text, index) => <p key={index} className={`${styles.note} ${styles.sizer}`} aria-hidden="true">{text}</p>)}
      <Swap id={problem ? `problem-${problem.text}` : done ? "done" : "note"} reduced={reduced} className={styles.messageLive} live="polite">
        <p id={messageId} className={styles.note} data-tone={problem ? "error" : done ? "success" : undefined} role={problem ? "alert" : undefined}>
          {problem?.text ?? (done
            ? <>{doneText}<span className={styles.srOnly}> 확인 링크를 {sentTo}(으)로 보냈어요.</span> <button ref={again} type="button" className={styles.textButton} onClick={() => { playUiSound("click"); reset(); }}>다른 이메일 쓰기</button></>
            : note)}
        </p>
      </Swap>
    </div>
  </form>;

  const root = [styles.newsletter, className].filter(Boolean).join(" ");

  if (variant === "card") {
    return <section ref={ref} className={root} data-variant="card" aria-labelledby={`${id}-title`}>
      {/* The shell spans the section so a host layout can set its gutter without reaching into the card. */}
      <div className={styles.cardShell}>
      <div className={styles.card}>
        {publication && <div className={styles.tray}>
          <IssueStack publication={publication} delivered={done} to={sentTo} reduced={reduced} compact />
        </div>}
        <div className={styles.cardBody}>
          <h2 id={`${id}-title`} className={styles.cardTitle}>{title ?? copy.title}</h2>
          <p className={styles.description}>{description ?? copy.description}</p>
          {form}
          {readers && <Readers readers={readers} done={done} reduced={reduced} />}
        </div>
      </div>
      </div>
    </section>;
  }

  return <section ref={ref} className={root} data-variant="inline" data-aside={publication || readers ? "" : undefined} aria-labelledby={`${id}-title`}>
    <div className={styles.inline}>
      <div className={styles.intro}>
        <h2 id={`${id}-title`} className={styles.title}>{title ?? copy.title}</h2>
        <p className={styles.description}>{description ?? copy.description}</p>
        {form}
      </div>
      {(publication || readers) && <div className={styles.aside}>
        {publication && <IssueStack publication={publication} delivered={done} to={sentTo} reduced={reduced} />}
        {readers && <Readers readers={readers} done={done} reduced={reduced} />}
      </div>}
    </div>
  </section>;
});

NewsletterSignup.displayName = "NewsletterSignup";

const variantOptions = [{ value: "inline", label: "인라인" }, { value: "card", label: "카드" }];

/** Preview: both layouts. Subscribing is simulated and nothing is sent; the switch makes the next send fail. */
export function NewsletterSignupBlock() {
  const [variant, setVariant] = useState<NewsletterVariant>("inline");
  const [fail, setFail] = useState(false);
  const switchId = useId();
  const simulate = () => new Promise<void>((resolve, reject) => setTimeout(() => fail ? reject(new Error("가상 실패")) : resolve(), 1100));
  return <div className={styles.preview}>
    <div className={styles.controls}>
      <SegmentedControl label="뉴스레터 레이아웃" options={variantOptions} value={variant} onValueChange={next => setVariant(next as NewsletterVariant)} />
      <div className={styles.simulate}>
        <Switch id={switchId} checked={fail} onCheckedChange={setFail} />
        <label htmlFor={switchId}>다음 전송 실패시키기</label>
      </div>
    </div>
    <div className={styles.frame}>
      <NewsletterSignup key={variant} variant={variant} onSubscribe={simulate} />
    </div>
    <p className={styles.caption}>이 미리보기에서는 구독을 가상으로 처리해요. 실제로 전송되지 않아요.</p>
  </div>;
}

export default NewsletterSignupBlock;

```
