feat: implement TASK-WP1-03 registration notice

This commit is contained in:
suyx
2026-07-28 16:48:49 +08:00
parent 4d81f9c723
commit aec4c83eca
10 changed files with 1049 additions and 33 deletions
+192
View File
@@ -30,6 +30,10 @@ input {
outline-offset: 3px;
}
button {
letter-spacing: 0;
}
.auth-page {
min-height: 100vh;
display: grid;
@@ -157,6 +161,10 @@ input {
line-height: 1.25;
}
.auth-registration-form {
min-height: 520px;
}
.auth-form label {
margin: 0 0 7px;
font-size: 13px;
@@ -231,6 +239,169 @@ input {
color: #3c5b32;
}
.auth-registration-status {
align-self: flex-end;
margin: -52px 0 14px;
padding: 6px 10px;
color: #f2f500;
background: #111111;
font-size: 12px;
font-weight: 700;
}
.auth-verified-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 8px;
}
.auth-verified-grid > div {
display: flex;
min-width: 0;
flex-direction: column;
gap: 7px;
min-height: 64px;
border: 1px solid #b4b4af;
padding: 10px;
background: #ffffff;
}
.auth-verified-grid strong {
color: #2f6b45;
font-size: 11px;
}
.auth-verified-grid span {
overflow: hidden;
font-family: Consolas, monospace;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.auth-text-action {
align-self: flex-end;
border: 0;
padding: 4px 0;
color: #075f8f;
background: transparent;
font-size: 12px;
font-weight: 700;
text-decoration: underline;
text-underline-offset: 3px;
cursor: pointer;
}
.auth-registration-form > .auth-text-action {
margin-bottom: 12px;
}
.auth-consent {
border: 1px solid #b4b4af;
padding: 10px;
background: #ffffff;
}
.auth-consent-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 9px;
font-size: 13px;
}
.auth-checkbox {
display: grid;
grid-template-columns: 20px 1fr;
align-items: start;
gap: 8px;
margin: 0;
font-weight: 600;
line-height: 1.5;
}
.auth-form .auth-checkbox input {
width: 18px;
height: 18px;
margin: 1px 0 0;
accent-color: #111111;
}
.auth-consent p {
margin: 8px 0 0 28px;
color: #8d281b;
font-size: 11px;
}
.auth-dialog-backdrop {
position: fixed;
z-index: 20;
inset: 0;
display: grid;
place-items: center;
padding: 28px;
background: rgba(17, 17, 17, 0.82);
}
.auth-dialog {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
width: min(720px, 100%);
max-height: min(760px, calc(100vh - 56px));
border: 2px solid #111111;
background: #f6f6f4;
box-shadow: 12px 12px 0 #f2f500;
}
.auth-dialog > header {
border-bottom: 1px solid #111111;
padding: 22px 24px 18px;
}
.auth-dialog header p {
margin: 0 0 6px;
font-family: Consolas, monospace;
font-size: 11px;
font-weight: 700;
}
.auth-dialog h2 {
margin: 0;
font-size: 24px;
}
.auth-dialog-content {
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: 24px;
overflow-y: auto;
padding: 6px 24px 18px;
}
.auth-dialog-content section {
border-bottom: 1px solid #ccccb9;
padding: 14px 0;
}
.auth-dialog-content h3 {
margin: 0 0 6px;
font-size: 15px;
}
.auth-dialog-content p {
margin: 0;
font-size: 13px;
line-height: 1.7;
}
.auth-dialog > footer {
border-top: 1px solid #111111;
padding: 0 24px 18px;
background: #f6f6f4;
}
.auth-error {
border-left: 4px solid #c7432f;
padding: 8px 10px;
@@ -304,6 +475,27 @@ input {
margin-left: 0;
}
.auth-registration-status {
margin-top: 0;
}
.auth-verified-grid {
grid-template-columns: 1fr;
}
.auth-dialog-backdrop {
padding: 16px;
}
.auth-dialog {
max-height: calc(100vh - 32px);
box-shadow: 6px 6px 0 #f2f500;
}
.auth-dialog-content {
display: block;
}
.auth-code-row {
grid-template-columns: minmax(0, 1fr) 126px;
}
+266 -31
View File
@@ -1,4 +1,5 @@
import { useEffect, useId, useRef, useState, type FormEvent } from "react";
import { registrationNotice } from "@dada/shared-contracts";
import { useEffect, useId, useRef, useState, type FormEvent, type KeyboardEvent } from "react";
import "./user-auth.css";
@@ -20,6 +21,16 @@ const messageByKey: Record<string, string> = {
"auth.challenge.too_many_attempts": "尝试次数过多,请稍后再试。",
"auth.login.admin_required": "此邮箱需从管理员登录入口进入。",
"auth.login.registration_required": "该邮箱尚未注册,请切换到注册。",
"auth.email.already_registered": "该邮箱已注册,请切换到登录。",
"auth.invite.disabled": "邀请码已停用,请更换邀请码。",
"auth.invite.exhausted": "邀请码使用次数已耗尽,请更换邀请码。",
"auth.invite.expired": "邀请码已过期,请更换邀请码。",
"auth.invite.not_found": "邀请码无效,请检查后重试。",
"auth.privacy.consent_required": "请阅读并同意《内测使用与隐私告知》。",
"auth.privacy.notice_version_invalid": "告知版本已更新,请重新阅读后同意。",
"auth.profile.invalid": "请检查创作署名和社交 ID。",
"auth.registration.login_required": "该邮箱已注册,请切换到登录。",
"auth.registration.stage_limit_reached": "本轮内测名额已满,请返回登录。",
"auth.service.unavailable": "邮件服务暂时不可用,请稍后重试。",
};
@@ -34,22 +45,47 @@ function maskedEmail(email: string) {
return `${visible}${"*".repeat(Math.max(3, local.length - visible.length))}@${domain}`;
}
function maskedRegistrationEmail(email: string) {
const [local = "", domain = ""] = email.split("@", 2);
return `${local.slice(0, 1)}***@${domain.slice(0, 1)}***`;
}
function maskedInvite(inviteCode: string) {
return `••••${inviteCode.slice(-3)}`;
}
export function UserAuthPage() {
const emailId = useId();
const codeId = useId();
const inviteId = useId();
const creatorNameId = useId();
const socialId = useId();
const loginTab = useRef<HTMLButtonElement>(null);
const registerTab = useRef<HTMLButtonElement>(null);
const noticeButton = useRef<HTMLButtonElement>(null);
const noticeCloseButton = useRef<HTMLButtonElement>(null);
const noticeDialog = useRef<HTMLElement>(null);
const [mode, setMode] = useState<AuthMode>("login");
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
const [inviteCode, setInviteCode] = useState("");
const [creatorName, setCreatorName] = useState("");
const [socialHandle, setSocialHandle] = useState("");
const [privacyConsentAccepted, setPrivacyConsentAccepted] = useState(false);
const [noticeOpen, setNoticeOpen] = useState(false);
const [registrationId, setRegistrationId] = useState<string>();
const [sendState, setSendState] = useState<SendState>("idle");
const [countdown, setCountdown] = useState(0);
const [error, setError] = useState<string>();
const [submitting, setSubmitting] = useState(false);
const emailValid = /^[^@\s]+@[^@\s]+$/.test(email);
const registrationReady = Boolean(
registrationId
&& /^[0-9]{6}$/.test(code)
&& creatorName.trim()
&& socialHandle.trim()
&& privacyConsentAccepted,
);
useEffect(() => {
if (countdown <= 0) return;
@@ -57,10 +93,28 @@ export function UserAuthPage() {
return () => window.clearInterval(timer);
}, [countdown]);
useEffect(() => {
if (!noticeOpen) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
noticeCloseButton.current?.focus();
return () => {
document.body.style.overflow = previousOverflow;
};
}, [noticeOpen]);
function resetRegistrationDetails() {
setCode("");
setCreatorName("");
setSocialHandle("");
setPrivacyConsentAccepted(false);
setNoticeOpen(false);
}
function switchMode(nextMode: AuthMode) {
if (nextMode === mode) return;
setMode(nextMode);
setCode("");
resetRegistrationDetails();
setInviteCode("");
setRegistrationId(undefined);
setSendState("idle");
@@ -68,6 +122,43 @@ export function UserAuthPage() {
setError(undefined);
}
function modifyRegistrationEntry() {
resetRegistrationDetails();
setRegistrationId(undefined);
setSendState("idle");
setCountdown(0);
setError(undefined);
window.requestAnimationFrame(() => document.getElementById(inviteId)?.focus());
}
function closeNotice() {
setNoticeOpen(false);
window.requestAnimationFrame(() => noticeButton.current?.focus());
}
function handleNoticeKeyDown(event: KeyboardEvent<HTMLElement>) {
if (event.key === "Escape") {
event.preventDefault();
closeNotice();
return;
}
if (event.key === "Tab") {
const focusable = Array.from(
noticeDialog.current?.querySelectorAll<HTMLElement>("button, [href], input, [tabindex]:not([tabindex='-1'])") ?? [],
).filter((element) => !element.hasAttribute("disabled"));
const first = focusable[0];
const last = focusable.at(-1);
if (!first || !last) return;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
}
function switchTab(nextMode: AuthMode) {
switchMode(nextMode);
window.requestAnimationFrame(() => (nextMode === "login" ? loginTab : registerTab).current?.focus());
@@ -129,8 +220,44 @@ export function UserAuthPage() {
}
}
async function completeRegistration(event: FormEvent) {
event.preventDefault();
if (!registrationReady || !registrationId || submitting) return;
setSubmitting(true);
setError(undefined);
try {
const response = await fetch("/api/v1/auth/register/complete", {
body: JSON.stringify({
creator_name: creatorName,
privacy_consent_accepted: privacyConsentAccepted,
privacy_notice_version: registrationNotice.version,
registration_id: registrationId,
social_id: socialHandle,
verification_code: code,
}),
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", ""),
},
method: "POST",
});
const body = await response.json() as ErrorEnvelopeBody;
if (!response.ok) {
setError(errorMessage(body));
return;
}
window.location.assign("/app");
} catch {
setError("注册请求未完成,请重试。");
} finally {
setSubmitting(false);
}
}
return (
<main className="auth-page">
<>
<main className="auth-page">
<section className="auth-art" aria-label="Dada 创作艺术带">
<div className="auth-wordmark">DADA</div>
<div className="auth-art-copy">
@@ -227,34 +354,109 @@ export function UserAuthPage() {
</button>
</form>
) : (
<section className="auth-form" aria-labelledby="registration-title">
<h1 id="registration-title"></h1>
<label htmlFor={inviteId}></label>
<input
autoComplete="off"
id={inviteId}
onChange={(event) => setInviteCode(event.target.value)}
value={inviteCode}
/>
<label htmlFor={emailId}></label>
<input
autoComplete="email"
id={emailId}
inputMode="email"
onChange={(event) => setEmail(event.target.value)}
type="email"
value={email}
/>
<button
className="auth-primary"
disabled={!emailValid || inviteCode.trim().length < 8 || sendState === "sending" || countdown > 0}
onClick={sendCode}
type="button"
>
{sendState === "sending" ? "发送中" : countdown > 0 ? `${countdown}s` : "获取验证码"}
</button>
<form className="auth-form auth-registration-form" onSubmit={completeRegistration}>
<h1>{registrationId ? "完善注册资料" : "邀请码注册"}</h1>
{!registrationId ? (
<>
<label htmlFor={inviteId}></label>
<input
autoComplete="off"
id={inviteId}
onChange={(event) => setInviteCode(event.target.value)}
value={inviteCode}
/>
<label htmlFor={emailId}></label>
<input
autoComplete="email"
id={emailId}
inputMode="email"
onChange={(event) => setEmail(event.target.value)}
placeholder="请输入邮箱"
type="email"
value={email}
/>
<button
className="auth-primary"
disabled={!emailValid || inviteCode.trim().length < 8 || sendState === "sending" || countdown > 0}
onClick={sendCode}
type="button"
>
{sendState === "sending" ? "发送中" : countdown > 0 ? `${countdown}s` : "获取验证码"}
</button>
</>
) : (
<>
<div className="auth-registration-status"></div>
<div className="auth-verified-grid">
<div>
<strong> · </strong>
<span>{maskedInvite(inviteCode)}</span>
</div>
<div>
<strong> · </strong>
<span>{maskedRegistrationEmail(email)}</span>
</div>
</div>
<button className="auth-text-action" onClick={modifyRegistrationEntry} type="button">
</button>
<label htmlFor={codeId}></label>
<input
autoComplete="one-time-code"
id={codeId}
inputMode="numeric"
maxLength={6}
onChange={(event) => setCode(event.target.value.replace(/\D/g, ""))}
placeholder="输入 6 位验证码"
value={code}
/>
<label htmlFor={creatorNameId}></label>
<input
autoComplete="nickname"
id={creatorNameId}
maxLength={80}
onChange={(event) => setCreatorName(event.target.value)}
placeholder="成品中显示的名称"
value={creatorName}
/>
<label htmlFor={socialId}> ID</label>
<input
autoComplete="off"
id={socialId}
maxLength={80}
onChange={(event) => setSocialHandle(event.target.value)}
placeholder="成品中显示的账号文本"
value={socialHandle}
/>
<div className="auth-consent">
<div className="auth-consent-title">
<strong>使</strong>
<button
className="auth-text-action"
onClick={() => setNoticeOpen(true)}
ref={noticeButton}
type="button"
>
</button>
</div>
<label className="auth-checkbox">
<input
checked={privacyConsentAccepted}
onChange={(event) => setPrivacyConsentAccepted(event.target.checked)}
type="checkbox"
/>
使
</label>
{!privacyConsentAccepted ? <p></p> : null}
</div>
<button className="auth-primary" disabled={!registrationReady || submitting} type="submit">
{submitting ? "注册中" : "注册并进入 Dada"}
</button>
</>
)}
{error ? <p className="auth-error" role="alert">{error}</p> : null}
</section>
</form>
)}
</div>
</section>
@@ -262,6 +464,39 @@ export function UserAuthPage() {
<footer className="auth-local-notice">
</footer>
</main>
</main>
{noticeOpen ? (
<div className="auth-dialog-backdrop">
<section
aria-labelledby="registration-notice-title"
aria-modal="true"
className="auth-dialog"
onKeyDown={handleNoticeKeyDown}
ref={noticeDialog}
role="dialog"
>
<header>
<div>
<p> {registrationNotice.version} · {registrationNotice.effectiveAt}</p>
<h2 id="registration-notice-title">{registrationNotice.title}</h2>
</div>
</header>
<div className="auth-dialog-content">
{registrationNotice.sections.map((section) => (
<section key={section.title}>
<h3>{section.title}</h3>
<p>{section.body}</p>
</section>
))}
</div>
<footer>
<button className="auth-primary" onClick={closeNotice} ref={noticeCloseButton} type="button">
</button>
</footer>
</section>
</div>
) : null}
</>
);
}