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>