Skip to content

Commit 34ecc70

Browse files
committed
Website: respect dismissed/subscribed newsletter
- Add unified localStorage state: `newsletter-subscribed` and `newsletter-dismissed` (+ legacy `newsletter-cta-dismissed` read support) - Replace X dismiss button with "Not interested" text link to prevent accidental dismissal - Show inline confirmation: "Hidden across the site. Changed your mind? There's a signup in the footer." - Hide header nav button (desktop + mobile) when dismissed/subscribed - Show "You're on the list" on download/roadmap/changelog when subscribed - Footer always stays visible as a fallback - New `NewsletterInlineWrapper.astro` reusable component - E2E tests for all states and cross-page propagation
1 parent 3368963 commit 34ecc70

12 files changed

Lines changed: 645 additions & 57 deletions

apps/website/e2e/blog.spec.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,4 +105,51 @@ test.describe('Blog', () => {
105105
const blogLink = page.getByRole('navigation').getByRole('link', { name: 'Blog' })
106106
await expect(blogLink).toBeVisible()
107107
})
108+
109+
test('individual post shows newsletter CTA with signup form', async ({ page }) => {
110+
await page.goto(`/blog/${slug}`)
111+
const cta = page.locator('[data-newsletter-cta]')
112+
await expect(cta).toBeVisible()
113+
await expect(cta.locator('form[data-newsletter-form]')).toBeVisible()
114+
await expect(cta.locator('input[type="email"]')).toBeVisible()
115+
await expect(cta.locator('button[type="submit"]')).toBeVisible()
116+
})
117+
118+
test('newsletter CTA "Not interested" hides it and persists across reload', async ({ page }) => {
119+
await page.goto(`/blog/${slug}`)
120+
const cta = page.locator('[data-newsletter-cta]')
121+
await expect(cta).toBeVisible()
122+
123+
// Click "Not interested"
124+
await page.locator('[data-newsletter-cta-dismiss]').click()
125+
126+
// Content should be hidden, confirmation should show
127+
await expect(page.locator('[data-newsletter-cta-content]')).toBeHidden()
128+
await expect(page.locator('[data-newsletter-cta-confirmation]')).toBeVisible()
129+
130+
// Verify localStorage was set with new key
131+
const dismissed = await page.evaluate(() => localStorage.getItem('newsletter-dismissed'))
132+
expect(dismissed).toBe('true')
133+
134+
// Reload and verify CTA is completely hidden
135+
await page.reload()
136+
await expect(page.locator('[data-newsletter-cta]')).toBeHidden()
137+
})
138+
139+
test('newsletter CTA is hidden when newsletter-subscribed is set', async ({ page }) => {
140+
// Set state before navigating
141+
await page.goto(`/blog/${slug}`)
142+
await page.evaluate(() => localStorage.setItem('newsletter-subscribed', 'true'))
143+
await page.reload()
144+
await page.waitForLoadState('domcontentloaded')
145+
await expect(page.locator('[data-newsletter-cta]')).toBeHidden()
146+
})
147+
148+
test('newsletter CTA respects legacy newsletter-cta-dismissed key', async ({ page }) => {
149+
await page.goto(`/blog/${slug}`)
150+
await page.evaluate(() => localStorage.setItem('newsletter-cta-dismissed', 'true'))
151+
await page.reload()
152+
await page.waitForLoadState('domcontentloaded')
153+
await expect(page.locator('[data-newsletter-cta]')).toBeHidden()
154+
})
108155
})

apps/website/e2e/newsletter.spec.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,33 @@ test.describe('Newsletter signup', () => {
4545
await expect(panel.locator('input[type="email"]')).toBeVisible()
4646
await expect(panel.getByRole('button', { name: 'Sign up' })).toBeVisible()
4747
})
48+
49+
test('Newsletter button is hidden when dismissed', async ({ page }) => {
50+
await page.goto('/')
51+
await page.evaluate(() => localStorage.setItem('newsletter-dismissed', 'true'))
52+
await page.reload()
53+
54+
const toggle = page.locator('[data-newsletter-toggle]')
55+
await expect(toggle).toBeHidden()
56+
})
57+
58+
test('Newsletter button is hidden when subscribed', async ({ page }) => {
59+
await page.goto('/')
60+
await page.evaluate(() => localStorage.setItem('newsletter-subscribed', 'true'))
61+
await page.reload()
62+
63+
const toggle = page.locator('[data-newsletter-toggle]')
64+
await expect(toggle).toBeHidden()
65+
})
66+
67+
test('Newsletter button is hidden with legacy dismissed key', async ({ page }) => {
68+
await page.goto('/')
69+
await page.evaluate(() => localStorage.setItem('newsletter-cta-dismissed', 'true'))
70+
await page.reload()
71+
72+
const toggle = page.locator('[data-newsletter-toggle]')
73+
await expect(toggle).toBeHidden()
74+
})
4875
})
4976

5077
test.describe('Footer form', () => {
@@ -56,6 +83,16 @@ test.describe('Newsletter signup', () => {
5683
await expect(footer.locator('input[type="email"]')).toBeVisible()
5784
await expect(footer.getByRole('button', { name: 'Sign up' })).toBeVisible()
5885
})
86+
87+
test('footer newsletter is visible even when dismissed', async ({ page }) => {
88+
await page.goto('/')
89+
await page.evaluate(() => localStorage.setItem('newsletter-dismissed', 'true'))
90+
await page.reload()
91+
92+
const footer = page.locator('footer')
93+
await expect(footer.getByText('Stay in the loop')).toBeVisible()
94+
await expect(footer.locator('input[type="email"]')).toBeVisible()
95+
})
5996
})
6097

6198
test.describe('Download section form', () => {
@@ -67,6 +104,35 @@ test.describe('Newsletter signup', () => {
67104
await expect(download.locator('input[type="email"]')).toBeVisible()
68105
await expect(download.getByRole('button', { name: 'Sign up' })).toBeVisible()
69106
})
107+
108+
test('download section has "Not interested" dismiss link', async ({ page }) => {
109+
await page.goto('/')
110+
111+
const download = page.locator('#download')
112+
const dismissBtn = download.locator('[data-newsletter-inline-dismiss]')
113+
await expect(dismissBtn).toBeVisible()
114+
await expect(dismissBtn).toHaveText('Not interested')
115+
})
116+
117+
test('download section hides when dismissed', async ({ page }) => {
118+
await page.goto('/')
119+
await page.evaluate(() => localStorage.setItem('newsletter-dismissed', 'true'))
120+
await page.reload()
121+
122+
const download = page.locator('#download')
123+
const content = download.locator('[data-newsletter-inline-content]')
124+
await expect(content).toBeHidden()
125+
})
126+
127+
test('download section shows "You\'re on the list" when subscribed', async ({ page }) => {
128+
await page.goto('/')
129+
await page.evaluate(() => localStorage.setItem('newsletter-subscribed', 'true'))
130+
await page.reload()
131+
132+
const download = page.locator('#download')
133+
await expect(download.locator('[data-newsletter-inline-subscribed]')).toBeVisible()
134+
await expect(download.locator('[data-newsletter-inline-content]')).toBeHidden()
135+
})
70136
})
71137

72138
test.describe('Client-side validation', () => {
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
---
2+
import NewsletterForm from './NewsletterForm.astro'
3+
---
4+
5+
<aside class="newsletter-cta" data-newsletter-cta aria-label="Newsletter signup">
6+
<div data-newsletter-cta-content>
7+
<p class="newsletter-cta__headline">Enjoying this? Get Cmdr updates in your inbox.</p>
8+
<p class="newsletter-cta__subline">Product news, tips, and behind-the-scenes stories. No spam, ever.</p>
9+
10+
<div class="newsletter-cta__form">
11+
<NewsletterForm variant="inline" />
12+
</div>
13+
14+
<div class="newsletter-cta__dismiss-row">
15+
<button type="button" class="newsletter-cta__not-interested" data-newsletter-cta-dismiss>
16+
Not interested
17+
</button>
18+
</div>
19+
</div>
20+
21+
<p class="newsletter-cta__confirmation" data-newsletter-cta-confirmation>
22+
Hidden across the site. Changed your mind? There's a signup in the footer.
23+
</p>
24+
</aside>
25+
26+
<style>
27+
.newsletter-cta {
28+
position: relative;
29+
margin: 2rem 0;
30+
padding: 1.5rem;
31+
border-radius: 0.75rem;
32+
border: 1px solid var(--color-border);
33+
border-top: 2px solid var(--color-accent);
34+
background: var(--color-surface);
35+
}
36+
37+
.newsletter-cta[data-dismissed] {
38+
display: none;
39+
}
40+
41+
.newsletter-cta__headline {
42+
margin: 0 0 0.25rem;
43+
font-size: 1rem;
44+
font-weight: 600;
45+
color: var(--color-text-primary);
46+
}
47+
48+
.newsletter-cta__subline {
49+
margin: 0 0 1rem;
50+
font-size: 0.875rem;
51+
color: var(--color-text-secondary);
52+
}
53+
54+
.newsletter-cta__form {
55+
max-width: 28rem;
56+
}
57+
58+
.newsletter-cta__dismiss-row {
59+
margin-top: 0.75rem;
60+
text-align: right;
61+
}
62+
63+
.newsletter-cta__not-interested {
64+
background: none;
65+
border: none;
66+
color: var(--color-text-tertiary);
67+
font-size: 0.8125rem;
68+
cursor: pointer;
69+
padding: 0.25rem 0.5rem;
70+
text-decoration: none;
71+
transition: text-decoration var(--duration-normal);
72+
}
73+
74+
.newsletter-cta__not-interested:hover {
75+
text-decoration: underline;
76+
text-underline-offset: 2px;
77+
}
78+
79+
.newsletter-cta__not-interested:focus-visible {
80+
outline: 2px solid var(--color-accent);
81+
outline-offset: 2px;
82+
}
83+
84+
.newsletter-cta__confirmation {
85+
display: none;
86+
font-size: 0.8125rem;
87+
color: var(--color-text-secondary);
88+
margin: 0;
89+
transition: opacity var(--duration-slow);
90+
}
91+
92+
.newsletter-cta__confirmation[data-visible] {
93+
display: block;
94+
}
95+
96+
.newsletter-cta__confirmation[data-fading] {
97+
opacity: 0;
98+
}
99+
100+
[data-newsletter-cta-content][data-hidden] {
101+
display: none;
102+
}
103+
104+
@media (prefers-reduced-motion: reduce) {
105+
.newsletter-cta,
106+
.newsletter-cta__confirmation {
107+
transition: none !important;
108+
}
109+
}
110+
</style>
111+
112+
<script>
113+
function isNewsletterHidden(): boolean {
114+
return (
115+
localStorage.getItem('newsletter-subscribed') === 'true' ||
116+
localStorage.getItem('newsletter-dismissed') === 'true' ||
117+
localStorage.getItem('newsletter-cta-dismissed') === 'true'
118+
)
119+
}
120+
121+
function hideConfirmationAfterDelay(confirmation: HTMLElement) {
122+
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
123+
if (prefersReducedMotion) {
124+
confirmation.removeAttribute('data-visible')
125+
return
126+
}
127+
128+
setTimeout(() => {
129+
confirmation.setAttribute('data-fading', '')
130+
setTimeout(() => {
131+
confirmation.removeAttribute('data-visible')
132+
confirmation.removeAttribute('data-fading')
133+
}, 500)
134+
}, 3000)
135+
}
136+
137+
document.addEventListener('astro:page-load', () => {
138+
const ctas = document.querySelectorAll<HTMLElement>('[data-newsletter-cta]')
139+
140+
if (isNewsletterHidden()) {
141+
ctas.forEach((cta) => {
142+
cta.dataset.dismissed = ''
143+
})
144+
return
145+
}
146+
147+
ctas.forEach((cta) => {
148+
const dismissButton = cta.querySelector<HTMLButtonElement>('[data-newsletter-cta-dismiss]')
149+
if (!dismissButton || dismissButton.dataset.bound) return
150+
dismissButton.dataset.bound = 'true'
151+
152+
dismissButton.addEventListener('click', () => {
153+
localStorage.setItem('newsletter-dismissed', 'true')
154+
155+
// Hide content, show confirmation in this CTA
156+
const content = cta.querySelector<HTMLElement>('[data-newsletter-cta-content]')
157+
const confirmation = cta.querySelector<HTMLElement>('[data-newsletter-cta-confirmation]')
158+
if (content) content.setAttribute('data-hidden', '')
159+
if (confirmation) {
160+
confirmation.setAttribute('data-visible', '')
161+
hideConfirmationAfterDelay(confirmation)
162+
}
163+
164+
// Hide all other CTAs on the page immediately
165+
const allCtas = document.querySelectorAll<HTMLElement>('[data-newsletter-cta]')
166+
allCtas.forEach((el) => {
167+
if (el !== cta) {
168+
el.dataset.dismissed = ''
169+
}
170+
})
171+
172+
// Also hide any inline newsletter wrappers and header nav items
173+
document.querySelectorAll<HTMLElement>('[data-newsletter-inline]').forEach((el) => {
174+
const otherContent = el.querySelector<HTMLElement>('[data-newsletter-inline-content]')
175+
if (otherContent) otherContent.setAttribute('data-hidden', '')
176+
})
177+
document.querySelectorAll<HTMLElement>('[data-newsletter-nav]').forEach((el) => {
178+
el.style.display = 'none'
179+
})
180+
})
181+
})
182+
})
183+
</script>

apps/website/src/components/Download.astro

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
import { version, dmgUrl } from '../lib/release'
3-
import NewsletterForm from './NewsletterForm.astro'
3+
import NewsletterInlineWrapper from './NewsletterInlineWrapper.astro'
44
---
55

66
<section id="download" class="relative px-6 py-32">
@@ -67,13 +67,8 @@ import NewsletterForm from './NewsletterForm.astro'
6767
</div>
6868

6969
<!-- Other platforms -->
70-
<div class="mt-8 text-center">
71-
<p class="mb-3 text-[var(--color-text-tertiary)]">
72-
Windows and Linux coming soon. Get notified when they're ready:
73-
</p>
74-
<div class="mx-auto max-w-sm">
75-
<NewsletterForm variant="inline" />
76-
</div>
70+
<div class="mt-8">
71+
<NewsletterInlineWrapper label="Windows and Linux coming soon. Get notified when they're ready:" />
7772
</div>
7873
</div>
7974
</section>

apps/website/src/components/Header.astro

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const navLinks = [
3636
aria-expanded="false"
3737
aria-controls="newsletter-panel"
3838
data-newsletter-toggle
39+
data-newsletter-nav
3940
>
4041
Newsletter
4142
</button>
@@ -139,7 +140,7 @@ const navLinks = [
139140
</a>
140141

141142
<!-- Newsletter section (mobile) -->
142-
<div class="mobile-menu__newsletter">
143+
<div class="mobile-menu__newsletter" data-newsletter-nav>
143144
<p class="mobile-menu__newsletter-label">Newsletter</p>
144145
<p class="mobile-menu__newsletter-desc">Get Cmdr news in your inbox. No spam, we promise.</p>
145146
<NewsletterForm variant="inline" />
@@ -336,11 +337,29 @@ const navLinks = [
336337

337338
<script>
338339
document.addEventListener('astro:page-load', () => {
340+
// Check if newsletter should be hidden site-wide
341+
const newsletterHidden =
342+
localStorage.getItem('newsletter-subscribed') === 'true' ||
343+
localStorage.getItem('newsletter-dismissed') === 'true' ||
344+
localStorage.getItem('newsletter-cta-dismissed') === 'true'
345+
339346
// Desktop newsletter toggle
340347
const desktopToggle = document.querySelector<HTMLButtonElement>('[data-newsletter-toggle]')
341348
const newsletterPanel = document.getElementById('newsletter-panel')
342349

343-
if (desktopToggle && newsletterPanel) {
350+
if (newsletterHidden) {
351+
// Hide all newsletter nav elements
352+
document.querySelectorAll<HTMLElement>('[data-newsletter-nav]').forEach((el) => {
353+
el.style.display = 'none'
354+
})
355+
// Close the panel if it's open
356+
if (newsletterPanel && newsletterPanel.getAttribute('aria-hidden') === 'false') {
357+
newsletterPanel.setAttribute('aria-hidden', 'true')
358+
if (desktopToggle) desktopToggle.setAttribute('aria-expanded', 'false')
359+
}
360+
}
361+
362+
if (desktopToggle && newsletterPanel && !newsletterHidden) {
344363
if (!desktopToggle.dataset.bound) {
345364
desktopToggle.dataset.bound = 'true'
346365

0 commit comments

Comments
 (0)