WaitlistForm.astro
1---
2interface Props {
3 id: string;
4 placeholder?: string;
5}
6
7const { id, placeholder = '[email protected]' } = Astro.props;
8---
9
10<!-- sovrnd serves this page and handles POST /waitlist (internal/waitlist).
11 The script below posts JSON; without JavaScript the form posts urlencoded
12 and sovrnd redirects back to the page. -->
13<form class="waitlist" action="/waitlist" method="post" novalidate>
14 <label class="visually-hidden" for={`${id}-email`}>Your email address</label>
15 <div class="row">
16 <span class="field">
17 <input
18 class="cells"
19 id={`${id}-email`}
20 name="email"
21 type="email"
22 inputmode="email"
23 autocomplete="email"
24 autocapitalize="off"
25 spellcheck="false"
26 required
27 placeholder={placeholder}
28 aria-describedby={`${id}-status`}
29 />
30 </span>
31 <button class="send" type="submit">
32 <span class="send-label">Join the waitlist</span>
33 <svg class="arrow" viewBox="0 0 28 16" aria-hidden="true" focusable="false">
34 <path d="M0 8h25M18 1l7 7-7 7" fill="none" stroke="currentColor" stroke-width="1.75" />
35 </svg>
36 </button>
37 </div>
38 <p class="status" id={`${id}-status`} role="status" aria-live="polite"></p>
39 <p class="alt">
40 Already on Bluesky or another Atmosphere app?{' '}
41 <a href="/login?action=waitlist">Join with your Atmosphere account</a>
42 </p>
43</form>
44
45<script>
46 const messages = {
47 invalid: 'Enter a full email address, like [email protected].',
48 sending: 'Sending…',
49 done: 'You’re on the list. We’ll write when your invite is ready.',
50 failed: 'That didn’t go through. Check your connection and try again.',
51 } as const;
52
53 for (const form of document.querySelectorAll<HTMLFormElement>('form.waitlist')) {
54 const input = form.querySelector<HTMLInputElement>('input[name="email"]')!;
55 const status = form.querySelector<HTMLElement>('.status')!;
56 const button = form.querySelector<HTMLButtonElement>('button')!;
57
58 const set = (state: keyof typeof messages | 'idle') => {
59 form.dataset.state = state;
60 status.textContent = state === 'idle' ? '' : messages[state];
61 input.setAttribute('aria-invalid', String(state === 'invalid'));
62 button.disabled = state === 'sending' || state === 'done';
63 };
64
65 input.addEventListener('input', () => {
66 if (form.dataset.state === 'invalid' || form.dataset.state === 'failed') set('idle');
67 });
68
69 form.addEventListener('submit', async (event) => {
70 event.preventDefault();
71 if (!input.checkValidity() || !input.value.includes('.')) {
72 set('invalid');
73 input.focus();
74 return;
75 }
76 set('sending');
77 try {
78 const res = await fetch(form.action, {
79 method: 'POST',
80 headers: { 'Content-Type': 'application/json' },
81 body: JSON.stringify({ email: input.value.trim() }),
82 });
83 set(res.ok ? 'done' : res.status === 400 ? 'invalid' : 'failed');
84 } catch {
85 set('failed');
86 }
87 });
88 }
89</script>
90
91<style>
92 .waitlist {
93 --cell: 2.4rem;
94 --cells: 20;
95 --ink: var(--ptt);
96 display: grid;
97 gap: 0.75rem;
98 }
99
100 .row {
101 display: grid;
102 grid-template-columns: minmax(0, calc(var(--cell) * var(--cells) + 4px)) auto;
103 gap: 1.25rem;
104 align-items: stretch;
105 justify-content: start;
106 }
107
108 /* Each character lands in its own form cell: a mono advance of 1ch plus
109 letter-spacing equals one cell, and the ruled background draws the cells. */
110 .cells {
111 width: 100%;
112 min-width: 0;
113 height: 4.625rem;
114 margin: 0.125rem 0;
115 padding: 0 0 0 calc((var(--cell) - 1ch) / 2);
116 border: 2px solid var(--ink);
117 border-radius: 0;
118 background: transparent;
119 color: var(--ink);
120 font-family: var(--mono);
121 font-size: 1.625rem;
122 font-weight: 700;
123 letter-spacing: calc(var(--cell) - 1ch);
124 appearance: none;
125 transition: background-color 0.2s var(--ease-out);
126 }
127
128 /* The field closes on a whole cell at any width. Cell dividers are soft
129 dotted ticks inset from the frame: they suggest the form without
130 competing with what's typed. */
131 .field {
132 position: relative;
133 display: block;
134 width: calc(round(down, 100% - 4px, var(--cell)) + 4px);
135 min-width: 0;
136 }
137
138 .field::after {
139 content: '';
140 position: absolute;
141 inset: 0.9rem 2px;
142 pointer-events: none;
143 background: linear-gradient(to bottom, var(--ink) 0 2px, transparent 2px 5px) 0 0 / 100% 5px;
144 opacity: 0.4;
145 -webkit-mask: repeating-linear-gradient(
146 to right,
147 transparent 0 calc(var(--cell) - 1px),
148 #000 calc(var(--cell) - 1px) var(--cell)
149 );
150 mask: repeating-linear-gradient(
151 to right,
152 transparent 0 calc(var(--cell) - 1px),
153 #000 calc(var(--cell) - 1px) var(--cell)
154 );
155 }
156
157 .cells::placeholder {
158 color: var(--ink);
159 opacity: 0.85;
160 }
161
162 .cells:focus-visible {
163 outline: 3px solid var(--ink);
164 outline-offset: 3px;
165 background-color: color-mix(in srgb, var(--gold-wash) 45%, transparent);
166 }
167
168 .cells[aria-invalid='true'] {
169 border-color: var(--error);
170 outline-color: var(--error);
171 }
172
173 .send {
174 flex: 0 0 auto;
175 display: inline-flex;
176 align-items: center;
177 justify-content: space-between;
178 gap: 1.75rem;
179 min-width: 17.875rem;
180 height: 4.875rem;
181 padding: 0 1.5rem 0 1.625rem;
182 border: 0;
183 border-radius: 0;
184 background: var(--ptt);
185 color: var(--paper);
186 font: 700 1.5rem/1 var(--sans);
187 letter-spacing: -0.02em;
188 cursor: pointer;
189 transition:
190 background-color 0.25s var(--ease-out),
191 transform 0.1s ease-out;
192 }
193
194 .send:hover {
195 background: var(--ptt-deep);
196 }
197
198 .send:active {
199 transform: translateY(1px);
200 }
201
202 .send:disabled {
203 cursor: default;
204 background: var(--ptt-deep);
205 }
206
207 .arrow {
208 width: 1.75rem;
209 height: 1rem;
210 transition: transform 0.35s var(--ease-out);
211 }
212
213 .send:hover .arrow {
214 transform: translateX(0.35rem);
215 }
216
217 .status {
218 min-height: 1.5em;
219 color: var(--ink);
220 font-weight: 600;
221 font-size: 1.0625rem;
222 }
223
224 .alt {
225 margin-top: -0.25rem;
226 color: var(--ink);
227 font-size: 1rem;
228 line-height: 1.4;
229 }
230
231 .alt a {
232 color: var(--ink);
233 font-weight: 700;
234 text-decoration: underline;
235 text-decoration-thickness: 2px;
236 text-underline-offset: 0.2em;
237 white-space: nowrap;
238 }
239
240 .alt a:hover {
241 text-decoration-color: var(--signal);
242 }
243
244 .waitlist[data-state='invalid'] .status,
245 .waitlist[data-state='failed'] .status {
246 color: var(--error);
247 }
248
249 @media (max-width: 1400px) {
250 .waitlist {
251 --cell: 2rem;
252 }
253 .cells {
254 font-size: 1.25rem;
255 height: 4rem;
256 }
257 .send {
258 height: 4rem;
259 min-width: 0;
260 font-size: 1.25rem;
261 }
262 }
263
264 @media (max-width: 640px) {
265 .waitlist {
266 --cell: 1.25rem;
267 }
268 .row {
269 grid-template-columns: minmax(0, 1fr);
270 gap: 0.75rem;
271 }
272 .cells {
273 font-size: 0.9375rem;
274 height: 3.5rem;
275 }
276 .send {
277 height: 3.5rem;
278 }
279 }
280</style>