[codex] fix newsletter subscription UI (#3760)

* fix: polish newsletter subscription UI

* fix(web): lock onboarding navigation during newsletter submit

Generated-By: looper 0.9.4 (runner=fixer, agent=codex)
This commit is contained in:
Ray Xi
2026-06-05 16:04:45 +00:00
committed by GitHub
parent 817e7de387
commit 885ac06043
3 changed files with 159 additions and 41 deletions
+83 -25
View File
@@ -131,6 +131,26 @@ body::before {
}
.side-rail.right { right: 0; border-left: 1px solid var(--line-faint); }
.side-rail.left { left: 0; border-right: 1px solid var(--line-faint); }
body.newsletter-popup-visible .side-rail.right {
-webkit-mask-image: linear-gradient(
to bottom,
#000 0,
#000 var(--newsletter-popup-mask-top, 0px),
transparent var(--newsletter-popup-mask-top, 0px),
transparent var(--newsletter-popup-mask-bottom, 0px),
#000 var(--newsletter-popup-mask-bottom, 0px),
#000 100%
);
mask-image: linear-gradient(
to bottom,
#000 0,
#000 var(--newsletter-popup-mask-top, 0px),
transparent var(--newsletter-popup-mask-top, 0px),
transparent var(--newsletter-popup-mask-bottom, 0px),
#000 var(--newsletter-popup-mask-bottom, 0px),
#000 100%
);
}
.side-rail .rail-text {
font-family: var(--sans);
font-size: 10px;
@@ -397,17 +417,16 @@ body::before {
.nav-inner {
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-start;
gap: 24px;
}
/*
* Desktop three-zone header: brand pinned left, the primary nav grows to
* fill the middle and centers its links, the side actions sit right. The
* brand and side columns keep their natural width so the centered nav is
* centered against the full bar, not squeezed between them. Scoped to the
* desktop breakpoint only — at ≤1080px the nav becomes an absolutely
* positioned hamburger panel (rules further below), which must not inherit
* this flex layout.
* Desktop header: keep the logo, primary nav, and side actions in one row
* without forcing the nav onto the page center. The primary nav should follow
* the logo; the side actions consume the remaining room by pinning themselves
* to the right edge. Scoped to desktop only — at ≤1080px the nav becomes an
* absolutely positioned hamburger panel (rules further below), which must not
* inherit this flex layout.
*/
@media (min-width: 1081px) {
/* Header spans the full viewport width (overriding the shared .container
@@ -417,29 +436,27 @@ body::before {
max-width: none;
/* Clear the fixed 46px decorative side-rails (plus breathing room) so
* the brand and side actions never collide with the edge lines. */
padding-left: 60px;
padding-right: 60px;
padding-left: clamp(32px, 3.8vw, 60px);
padding-right: clamp(32px, 3.8vw, 60px);
}
.nav-inner {
gap: clamp(28px, 3vw, 56px);
}
/* Three-zone header: the brand and side clusters take equal flexible
* width on either side, so the centered nav sits on (or very near) the
* bar's midline regardless of how wide the action cluster is — without the
* overlap an absolutely-centered nav causes at mid widths. The brand stays
* left-aligned in its column and the side actions stay right-aligned in
* theirs. */
.nav-inner > .brand {
flex: 1 1 0;
min-width: 0;
flex: 0 0 auto;
justify-content: flex-start;
}
.nav-inner > .nav-side {
flex: 1 1 0;
flex: 0 1 auto;
min-width: 0;
margin-left: auto;
justify-content: flex-end;
}
.nav-inner > nav[data-nav-primary] {
flex: 0 0 auto;
flex: 0 1 auto;
min-width: 0;
display: flex;
justify-content: center;
justify-content: flex-start;
}
/* Scale the header chrome up a step so the bar reads as substantial across
@@ -448,9 +465,9 @@ body::before {
* are deliberately one level more specific than the base rules below
* (which appear later in source) so these win without !important. */
.nav .nav-inner .brand-logo { height: 44px; }
.nav-inner .nav-links { gap: 44px; }
.nav-inner .nav-links a { font-size: 16px; }
.nav-inner .nav-side { gap: 14px; }
.nav-inner .nav-links { gap: clamp(24px, 2.55vw, 44px); }
.nav-inner .nav-links a { font-size: clamp(14px, 0.95vw, 16px); }
.nav-inner .nav-side { gap: clamp(8px, 0.85vw, 14px); }
.nav-inner .nav-side .nav-cta.ghost { font-size: 13px; }
.nav-inner .nav-side .locale-trigger { font-size: 13px; }
.nav-inner .nav-side .nav-amr img { width: 68px; height: auto; }
@@ -525,6 +542,7 @@ body::before {
font-family: var(--sans);
font-size: 14px;
font-weight: 500;
white-space: nowrap;
transition: color 0.18s ease;
position: relative;
/*
@@ -3778,6 +3796,22 @@ footer {
.newsletter-submit {
flex: 0 0 auto;
}
.newsletter-submit.is-loading {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.newsletter-submit.is-loading::before {
content: '';
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.52);
border-top-color: var(--paper);
border-radius: 50%;
animation: newsletter-spin 760ms linear infinite;
}
.newsletter-submit:disabled {
opacity: 0.55;
cursor: progress;
@@ -3806,11 +3840,13 @@ footer {
z-index: 90;
width: min(360px, calc(100vw - 48px));
padding: 22px 22px 20px;
background: var(--paper);
background: color-mix(in srgb, var(--paper) 96%, #fff 4%);
background-clip: padding-box;
border: 1px solid var(--ink);
border-radius: 4px;
box-shadow: 0 18px 48px rgba(21, 20, 15, 0.22);
opacity: 0;
isolation: isolate;
transform: translateY(12px);
transition: opacity 0.28s ease, transform 0.28s ease;
}
@@ -3865,3 +3901,25 @@ footer {
gap: 24px;
}
}
@media (min-width: 1081px) and (max-width: 1360px) {
.nav-inner .nav-side .nav-cta.ghost {
padding-inline: 13px;
}
.nav-inner .nav-side .nav-amr {
padding-inline: 12px;
}
}
@media (min-width: 1081px) and (max-width: 1240px) {
.nav-inner .nav-side .nav-icon {
display: none;
}
.nav-inner .nav-side .nav-amr img {
width: 64px;
}
}
@keyframes newsletter-spin {
to { transform: rotate(360deg); }
}
+36 -2
View File
@@ -553,13 +553,19 @@ const pageHtml = renderToStaticMarkup(
const input = form.querySelector('[data-newsletter-email]');
const msg = form.querySelector('[data-newsletter-msg]');
const submit = form.querySelector('.newsletter-submit');
const submitIdleText = submit ? (submit.textContent || '').trim() : '';
const successText = form.getAttribute('data-success') || '';
const errorText = form.getAttribute('data-error') || '';
form.addEventListener('submit', (ev) => {
ev.preventDefault();
const email = (input && input.value ? input.value : '').trim();
if (!email) return;
if (submit) submit.disabled = true;
if (submit) {
submit.disabled = true;
submit.classList.add('is-loading');
submit.setAttribute('aria-busy', 'true');
submit.textContent = submitIdleText ? `${submitIdleText}...` : 'Loading...';
}
if (msg) msg.textContent = '';
fetch('/subscribe', {
method: 'POST',
@@ -570,6 +576,11 @@ const pageHtml = renderToStaticMarkup(
.then((data) => {
if (!data || data.ok !== true) throw new Error('rejected');
form.classList.add('is-success');
if (submit) {
submit.classList.remove('is-loading');
submit.removeAttribute('aria-busy');
submit.textContent = submitIdleText;
}
if (msg) msg.textContent = successText;
if (input) input.value = '';
setDismissed();
@@ -577,7 +588,12 @@ const pageHtml = renderToStaticMarkup(
})
.catch(() => {
if (msg) msg.textContent = errorText;
if (submit) submit.disabled = false;
if (submit) {
submit.disabled = false;
submit.classList.remove('is-loading');
submit.removeAttribute('aria-busy');
submit.textContent = submitIdleText;
}
track(area, 'error');
});
});
@@ -590,8 +606,23 @@ const pageHtml = renderToStaticMarkup(
const popup = document.querySelector('[data-newsletter-popup]');
if (popup && !dismissed()) {
const syncPopupRailMask = () => {
if (popup.hidden || !popup.classList.contains('is-visible')) return;
const rect = popup.getBoundingClientRect();
document.body.style.setProperty('--newsletter-popup-mask-top', `${Math.max(0, rect.top)}px`);
document.body.style.setProperty(
'--newsletter-popup-mask-bottom',
`${Math.min(window.innerHeight, rect.bottom)}px`,
);
};
const clearPopupRailMask = () => {
document.body.classList.remove('newsletter-popup-visible');
document.body.style.removeProperty('--newsletter-popup-mask-top');
document.body.style.removeProperty('--newsletter-popup-mask-bottom');
};
const close = () => {
popup.hidden = true;
clearPopupRailMask();
setDismissed();
};
popup.querySelectorAll('[data-newsletter-dismiss]').forEach((btn) =>
@@ -604,8 +635,11 @@ const pageHtml = renderToStaticMarkup(
if (dismissed()) return;
popup.hidden = false;
popup.classList.add('is-visible');
document.body.classList.add('newsletter-popup-visible');
requestAnimationFrame(syncPopupRailMask);
track('popup', 'view');
};
window.addEventListener('resize', syncPopupRailMask, { passive: true });
// Show after the visitor has scrolled past the hero, or a delay
// fallback — whichever comes first. Avoids interrupting the LCP.
let shown = false;
+40 -14
View File
@@ -886,6 +886,7 @@ function OnboardingView({
const [cliScanStatus, setCliScanStatus] = useState<'idle' | 'scanning' | 'done'>('idle');
const [amrStatus, setAmrStatus] = useState<VelaLoginStatus | null>(null);
const [amrLoginPending, setAmrLoginPending] = useState(false);
const [newsletterSubmitting, setNewsletterSubmitting] = useState(false);
const [amrLoginError, setAmrLoginError] = useState<string | null>(null);
const [visibleAgentIds, setVisibleAgentIds] = useState<string[]>([]);
const [providerTestState, setProviderTestState] = useState<
@@ -1338,6 +1339,7 @@ function OnboardingView({
onFinish();
}
function handleBackWithTracking(): void {
if (newsletterSubmitting) return;
if (step === 0) {
// Step 0 "Back" semantically maps to Skip — there's nowhere
// earlier to go. Match the Skip telemetry shape rather than
@@ -1348,7 +1350,8 @@ function OnboardingView({
emitOnboardingClick('back', 'back');
setStep((current) => current - 1);
}
function handlePrimaryAction() {
async function handlePrimaryAction() {
if (newsletterSubmitting) return;
if (step === 0 && amrSelectedAndSignedOut) {
const attribution = recordAmrEntry(
analytics.track,
@@ -1371,7 +1374,13 @@ function OnboardingView({
// `onboarding_complete_result` give the funnel two independent
// paths for the same data.
emitAboutYouSubmit();
submitNewsletterEmail(profileRef.current.email);
const newsletterEmail = profileRef.current.email;
const shouldSubmitNewsletter =
NEWSLETTER_EMAIL_RE.test(newsletterEmail.trim().toLowerCase());
if (shouldSubmitNewsletter) {
setNewsletterSubmitting(true);
await submitNewsletterEmail(newsletterEmail);
}
emitOnboardingClick('continue', 'continue');
// Last-step Continue without a DS generation = "completed
// without design system". The Generate path inside the
@@ -1449,21 +1458,29 @@ function OnboardingView({
});
}
// Optional newsletter signup captured on the About-you step. Fire-and-forget
// so a slow or failing request never blocks finishing onboarding; a blank or
// Optional newsletter signup captured on the About-you step. The last-step
// button shows loading while this settles; failures are swallowed so
// onboarding completion never depends on the marketing site. A blank or
// malformed email is simply skipped. Only a boolean opt-in is tracked — the
// address itself is never sent to analytics.
function submitNewsletterEmail(rawEmail: string): void {
async function submitNewsletterEmail(rawEmail: string): Promise<void> {
const email = rawEmail.trim().toLowerCase();
if (!email || !NEWSLETTER_EMAIL_RE.test(email)) return;
emitOnboardingClick('newsletter_email', 'subscribe', { newsletter_opt_in: true });
void fetch(NEWSLETTER_SUBSCRIBE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, source: 'client' }),
}).catch(() => {
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), 5000);
try {
await fetch(NEWSLETTER_SUBSCRIBE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, source: 'client' }),
signal: controller.signal,
});
} catch {
// Swallow — onboarding completion must not depend on the marketing site.
});
} finally {
window.clearTimeout(timeout);
}
}
async function scanCliAgents() {
@@ -1636,7 +1653,10 @@ function OnboardingView({
}
}
const primaryActionLabel = step === 0 && amrLoginPending
const onboardingNavigationLocked = newsletterSubmitting;
const primaryActionLabel = isLastStep && newsletterSubmitting
? t('common.loading')
: step === 0 && amrLoginPending
? t('settings.amrSigningIn')
: step === 0 && amrSelectedAndSignedOut
? t('settings.amrSignInToContinue')
@@ -1659,7 +1679,11 @@ function OnboardingView({
{steps.map((label, index) => (
<li key={label} className={index === step ? 'is-active' : index < step ? 'is-done' : ''}>
<span>{index + 1}</span>
<button type="button" onClick={() => setStep(index)}>
<button
type="button"
onClick={() => setStep(index)}
disabled={onboardingNavigationLocked}
>
{label}
</button>
</li>
@@ -1907,6 +1931,7 @@ function OnboardingView({
type="button"
className="onboarding-view__secondary"
onClick={handleBackWithTracking}
disabled={onboardingNavigationLocked}
>
{step === 0 ? t('settings.onboardingSkip') : t('settings.onboardingBack')}
</button>
@@ -1914,7 +1939,8 @@ function OnboardingView({
type="button"
className="onboarding-view__primary"
onClick={handlePrimaryAction}
disabled={amrLoginPending}
disabled={amrLoginPending || newsletterSubmitting}
aria-busy={newsletterSubmitting ? true : undefined}
>
<span>{primaryActionLabel}</span>
</button>