mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
[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:
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user