Skip to content

Commit 46829c2

Browse files
committed
Invite users to the community Discord once, right after a download starts
Shows a one-time, centered modal the first time a visitor starts any download (gated by a localStorage flag so it never nags), with a gold Discord mark, the invite, and X / Maybe later / backdrop / Escape dismissal. Post-download is high-intent and low-friction: the download proceeds untouched (the open is deferred a tick) while the invite rides along. Fully accessible (dialog role, focus trap, focus restore) and dark/light + reduced-motion aware. Mounted globally in Layout; triggers off any [data-umami-event="download"] anchor.
1 parent f65050b commit 46829c2

2 files changed

Lines changed: 324 additions & 0 deletions

File tree

Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
---
2+
// One-time, post-download invite to the community Discord. Shows the first time a visitor
3+
// starts a download (any architecture, any download button), then never again (localStorage flag).
4+
// Mounted globally in Layout.astro; it listens for clicks on any [data-umami-event="download"] anchor.
5+
const DISCORD_INVITE_URL = 'https://discord.gg/4BVafBneKJ'
6+
---
7+
8+
<div class="discord-invite" data-discord-invite hidden>
9+
<div class="discord-invite__backdrop" data-discord-invite-backdrop></div>
10+
<div
11+
class="discord-invite__dialog"
12+
role="dialog"
13+
aria-modal="true"
14+
aria-labelledby="discord-invite-title"
15+
aria-describedby="discord-invite-body"
16+
>
17+
<button type="button" class="discord-invite__close" aria-label="Close" data-discord-invite-close>
18+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
19+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 6l12 12M18 6L6 18"></path>
20+
</svg>
21+
</button>
22+
23+
<svg class="discord-invite__logo" viewBox="0 0 127.14 96.36" aria-hidden="true">
24+
<path
25+
fill="var(--color-accent)"
26+
d="M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z"
27+
></path>
28+
</svg>
29+
30+
<h2 id="discord-invite-title" class="discord-invite__title">Download started</h2>
31+
<div id="discord-invite-body" class="discord-invite__body">
32+
<p>Thanks for your interest in Cmdr!</p>
33+
<p>
34+
If you are someone who joins Discord servers, please consider joining ours, which is a convenient place
35+
to drop your feedback on the app and talk to others who also use Cmdr.
36+
</p>
37+
</div>
38+
39+
<div class="discord-invite__actions">
40+
<a
41+
class="discord-invite__join"
42+
href={DISCORD_INVITE_URL}
43+
target="_blank"
44+
rel="noopener noreferrer"
45+
data-discord-invite-join
46+
>
47+
Join our Discord
48+
</a>
49+
<button type="button" class="discord-invite__later" data-discord-invite-close> Maybe later </button>
50+
</div>
51+
</div>
52+
</div>
53+
54+
<style>
55+
.discord-invite[hidden] {
56+
display: none;
57+
}
58+
59+
.discord-invite {
60+
position: fixed;
61+
inset: 0;
62+
z-index: 100;
63+
display: flex;
64+
align-items: center;
65+
justify-content: center;
66+
padding: 1.5rem;
67+
}
68+
69+
.discord-invite__backdrop {
70+
position: absolute;
71+
inset: 0;
72+
background: rgba(0, 0, 0, 0.6);
73+
animation: discord-invite-fade 200ms ease-out;
74+
}
75+
76+
.discord-invite__dialog {
77+
position: relative;
78+
width: 100%;
79+
max-width: 26rem;
80+
padding: 2.25rem 1.75rem 1.75rem;
81+
text-align: center;
82+
background: var(--color-surface-elevated);
83+
border: 1px solid var(--color-border);
84+
border-radius: 1rem;
85+
box-shadow:
86+
0 24px 60px rgba(0, 0, 0, 0.45),
87+
0 0 60px var(--color-accent-glow);
88+
animation: discord-invite-pop 200ms ease-out;
89+
}
90+
91+
.discord-invite__close {
92+
position: absolute;
93+
top: 0.75rem;
94+
right: 0.75rem;
95+
display: flex;
96+
align-items: center;
97+
justify-content: center;
98+
width: 2rem;
99+
height: 2rem;
100+
padding: 0;
101+
color: var(--color-text-tertiary);
102+
background: transparent;
103+
border: none;
104+
border-radius: 0.5rem;
105+
cursor: pointer;
106+
transition:
107+
color 150ms,
108+
background-color 150ms;
109+
}
110+
111+
.discord-invite__close svg {
112+
width: 1.125rem;
113+
height: 1.125rem;
114+
}
115+
116+
.discord-invite__close:hover {
117+
color: var(--color-text-primary);
118+
background: var(--color-surface);
119+
}
120+
121+
.discord-invite__close:focus-visible {
122+
outline: 2px solid var(--color-accent);
123+
outline-offset: 2px;
124+
}
125+
126+
.discord-invite__logo {
127+
width: 4rem;
128+
height: auto;
129+
margin: 0 auto 1rem;
130+
}
131+
132+
.discord-invite__title {
133+
margin: 0 0 0.75rem;
134+
font-size: 1.375rem;
135+
font-weight: 600;
136+
color: var(--color-text-primary);
137+
}
138+
139+
.discord-invite__body {
140+
margin: 0 0 1.5rem;
141+
color: var(--color-text-secondary);
142+
font-size: 0.9375rem;
143+
line-height: 1.55;
144+
}
145+
146+
.discord-invite__body p {
147+
margin: 0;
148+
}
149+
150+
.discord-invite__body p + p {
151+
margin-top: 0.75rem;
152+
}
153+
154+
.discord-invite__actions {
155+
display: flex;
156+
flex-direction: column;
157+
gap: 0.5rem;
158+
}
159+
160+
.discord-invite__join {
161+
display: inline-flex;
162+
align-items: center;
163+
justify-content: center;
164+
padding: 0.75rem 1.5rem;
165+
font-size: 0.9375rem;
166+
font-weight: 600;
167+
text-decoration: none;
168+
color: var(--color-accent-contrast);
169+
background: var(--color-accent);
170+
border-radius: 0.625rem;
171+
transition: background-color var(--duration-normal);
172+
}
173+
174+
.discord-invite__join:hover {
175+
background: var(--color-accent-hover);
176+
}
177+
178+
.discord-invite__join:focus-visible {
179+
outline: 2px solid var(--color-text-primary);
180+
outline-offset: 2px;
181+
}
182+
183+
.discord-invite__later {
184+
padding: 0.5rem 1rem;
185+
font-size: 0.875rem;
186+
color: var(--color-text-secondary);
187+
background: transparent;
188+
border: none;
189+
border-radius: 0.5rem;
190+
cursor: pointer;
191+
transition: color 150ms;
192+
}
193+
194+
.discord-invite__later:hover {
195+
color: var(--color-text-primary);
196+
}
197+
198+
.discord-invite__later:focus-visible {
199+
outline: 2px solid var(--color-accent);
200+
outline-offset: 2px;
201+
}
202+
203+
@keyframes discord-invite-fade {
204+
from {
205+
opacity: 0;
206+
}
207+
to {
208+
opacity: 1;
209+
}
210+
}
211+
212+
@keyframes discord-invite-pop {
213+
from {
214+
opacity: 0;
215+
transform: translateY(8px) scale(0.98);
216+
}
217+
to {
218+
opacity: 1;
219+
transform: translateY(0) scale(1);
220+
}
221+
}
222+
223+
@media (prefers-reduced-motion: reduce) {
224+
.discord-invite__backdrop,
225+
.discord-invite__dialog {
226+
animation: none;
227+
}
228+
}
229+
</style>
230+
231+
<script>
232+
const STORAGE_KEY = 'cmdr-discord-invite-shown'
233+
234+
// Module-scoped: the bundled script runs once; `astro:page-load` re-points these at the
235+
// current page's freshly-swapped overlay, while the document listeners bind only once.
236+
let documentBound = false
237+
let currentOpen: (() => void) | null = null
238+
let currentKeydown: ((e: KeyboardEvent) => void) | null = null
239+
240+
const hasShown = () => {
241+
try {
242+
return localStorage.getItem(STORAGE_KEY) === 'true'
243+
} catch {
244+
return false
245+
}
246+
}
247+
248+
const markShown = () => {
249+
try {
250+
localStorage.setItem(STORAGE_KEY, 'true')
251+
} catch {
252+
/* localStorage unavailable: it reappears next visit, acceptable */
253+
}
254+
}
255+
256+
document.addEventListener('astro:page-load', () => {
257+
const overlay = document.querySelector<HTMLElement>('[data-discord-invite]')
258+
if (!overlay) return
259+
260+
const dialog = overlay.querySelector<HTMLElement>('.discord-invite__dialog')
261+
const closers = overlay.querySelectorAll<HTMLElement>('[data-discord-invite-close]')
262+
const backdrop = overlay.querySelector<HTMLElement>('[data-discord-invite-backdrop]')
263+
264+
let lastFocused: HTMLElement | null = null
265+
266+
const open = () => {
267+
lastFocused = document.activeElement as HTMLElement
268+
overlay.hidden = false
269+
overlay.querySelector<HTMLElement>('.discord-invite__close')?.focus()
270+
}
271+
272+
const close = () => {
273+
overlay.hidden = true
274+
lastFocused?.focus()
275+
}
276+
277+
// Trap Tab focus within the dialog while open, close on Escape.
278+
const onKeydown = (e: KeyboardEvent) => {
279+
if (overlay.hidden) return
280+
if (e.key === 'Escape') {
281+
e.preventDefault()
282+
close()
283+
return
284+
}
285+
if (e.key !== 'Tab' || !dialog) return
286+
const focusables = dialog.querySelectorAll<HTMLElement>(
287+
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])',
288+
)
289+
if (focusables.length === 0) return
290+
const first = focusables[0]
291+
const last = focusables[focusables.length - 1]
292+
if (e.shiftKey && document.activeElement === first) {
293+
e.preventDefault()
294+
last.focus()
295+
} else if (!e.shiftKey && document.activeElement === last) {
296+
e.preventDefault()
297+
first.focus()
298+
}
299+
}
300+
301+
currentOpen = open
302+
currentKeydown = onKeydown
303+
304+
closers.forEach((el) => el.addEventListener('click', close))
305+
backdrop?.addEventListener('click', close)
306+
307+
if (!documentBound) {
308+
documentBound = true
309+
310+
document.addEventListener('click', (e) => {
311+
const trigger = (e.target as HTMLElement)?.closest('[data-umami-event="download"]')
312+
if (!trigger || hasShown()) return
313+
markShown()
314+
// Defer so the browser's own download starts first, unimpeded.
315+
setTimeout(() => currentOpen?.(), 0)
316+
})
317+
318+
document.addEventListener('keydown', (e) => currentKeydown?.(e))
319+
}
320+
})
321+
</script>

apps/website/src/layouts/Layout.astro

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
22
import { ClientRouter } from 'astro:transitions'
3+
import DiscordInvitePopup from '../components/DiscordInvitePopup.astro'
34
45
interface Props {
56
title: string
@@ -122,6 +123,8 @@ const {
122123
<body class="min-h-screen">
123124
<slot />
124125

126+
<DiscordInvitePopup />
127+
125128
{
126129
/* PostHog: session replay, heatmaps, click tracking.
127130
Loaded from PostHog's CDN to avoid bundling dompurify (has an unpatched XSS advisory).

0 commit comments

Comments
 (0)