블록
뉴스레터 구독
이메일을 받아 구독을 확인해 주고 지난 호를 겹쳐 보여 주는 가입 블록이에요.
- #arc
- #block
- #newsletter
- #signup
- #form
- #marketing
1280px
설치
이 명령 한 줄이면 필요한 파일과 패키지가 함께 설치돼요.
터미널터미널터미널bash
npx shadcn@latest add https://dsui.vercel.app/r/newsletter-signup.json파일
components/arc/blocks/newsletter-signup/newsletter-signup-data.tscomponents/arc/blocks/newsletter-signup/newsletter-signup.module.csscomponents/arc/blocks/newsletter-signup/newsletter-signup.tsx
npm 패키지
motion@^14.2.0
newsletter-signup-data.tsnewsletter-signup-data.tsnewsletter-signup-data.tsts
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.cssnewsletter-signup.module.cssnewsletter-signup.module.csscss
.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.tsxnewsletter-signup.tsxnewsletter-signup.tsxtsx
"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">·</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;