Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions src/lib/components/IntroAndEmailSignupBlock.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<script lang="ts">
import NewsletterForm from '#lib/components/NewsletterForm.svelte';
import {
APPLE_APP_STORE_LINK,
GOOGLE_PLAY_STORE_LINK,
} from '#lib/config.ts';
import type { components } from '#lib/umbraco/deliveryApiSchema.d.ts';

interface Props {
block?: components['schemas']['IntroAndEmailSignupBlockElementModel'];
params: {
conference: string;
};
}

let { params }: Props = $props();
</script>

<section class="flex flex-col lg:flex-row">
<div class="bg-gray-100 py-16 lg:w-1/2 lg:px-5">
<div class="content-container lg:max-w-[500px]">
<div class="mb-8">
<h2 class="font-bold text-xl lg:text-4xl">
Invest In Yourself and Hone Your Craft
</h2>
<p>
Method Conference Springfield, MO is an immersive one-day
conference with three tracks, two one-hour session tracks, a
workshop track, and a keynote. All with an intense focus on
digital development, design, UX, content, code, and more.
</p>
</div>
<a
href={`/${params.conference}/register/`}
class="button inline-block mb-8"
>
Register Now
</a>
<h3 class="font-bold text-xl lg:text-2xl mb-4">
Download Our Mobile App!
</h3>
<div class="flex space-x-4">
<a
class="block"
href={APPLE_APP_STORE_LINK}
title="Download iOS app"
>
<img
src="/app-store.svg"
alt="Download on the App Store"
width="165"
height="55"
/>
</a>
<a
class="block"
href={GOOGLE_PLAY_STORE_LINK}
title="Download android app"
>
<img
src="/play-store.svg"
alt="Get it on Google Play"
width="186"
height="55"
/>
</a>
</div>
</div>
</div>
<div class="lg:w-1/2 lg:px-5">
<div
class="content-container max-w-[650px] lg:max-w-[400px] mx-auto py-8 lg:pt-16"
>
<p class="font-bold text-xl mb-4">
Stay in-the-know about event updates and new speaker
announcements.
</p>
<NewsletterForm />
</div>
</div>
</section>
75 changes: 75 additions & 0 deletions src/lib/components/NewsletterForm.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<script lang="ts">
import TextInput from '#lib/components/TextInput.svelte';
import Swal from 'sweetalert2';

let name = $state('');
let email = $state('');
let nullCheck = $state('');
let pending = $state(false);

const id = $props.id();

async function onSubmit(event: { preventDefault: () => void }) {
event.preventDefault();

if (pending) {
return;
}

pending = true;
let success = false;

try {
const response = await globalThis.fetch('/api/newsletter', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, nullCheck }),
});
const result = await response.json();

success = result.success === true;
} catch {
// Keep the default failure state.
} finally {
pending = false;
}

if (!success) {
await Swal.fire({
text: 'Something went wrong!',
icon: 'error',
});

return;
}

await Swal.fire({
title: 'Check your email',
text: "You'll be sent an email to confirm your subscriptions to our newsletter",
icon: 'success',
});
}
</script>

<form action="" onsubmit={onSubmit}>
<TextInput class="mb-4" label="Name" name="name" bind:value={name} />
<TextInput
class="mb-4"
label="Email"
name="email"
type="email"
bind:value={email}
required
/>
<label for={id} class="sr-only">Leave this field empty</label>
<input
type="text"
{id}
name="company"
class="sr-only"
tabindex="-1"
autocomplete="off"
bind:value={nullCheck}
/>
<button class="button" type="submit">Email Signup</button>
</form>
113 changes: 113 additions & 0 deletions src/lib/server/newsletter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
export interface NewsletterConfig {
endpoint?: URL;
listId?: string;
}

export interface NewsletterResult {
body: { success: boolean };
status: number;
}

export type NewsletterFetch = (
input: string,
init: RequestInit,
) => Promise<Response>;

const DEFAULT_TIMEOUT_MS = 5_000;

function failure(status: number): NewsletterResult {
return { body: { success: false }, status };
}

function success(): NewsletterResult {
return { body: { success: true }, status: 200 };
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function trimString(value: unknown): string | undefined {
return typeof value === 'string' ? value.trim() : undefined;
}

function isValidEmail(value: string): boolean {
return /^[^\s@]+@[^\s@]+$/.test(value);
}

export async function handleNewsletterRequest(
request: Request,
config: NewsletterConfig,
fetcher: NewsletterFetch = fetch,
timeoutMs = DEFAULT_TIMEOUT_MS,
): Promise<NewsletterResult> {
const { endpoint, listId } = config;

if (!endpoint || !listId) {
return failure(500);
}

let payload: unknown;

try {
payload = await request.json();
} catch {
return failure(400);
}

return submitNewsletter(payload, { endpoint, listId }, fetcher, timeoutMs);
}

export async function submitNewsletter(
payload: unknown,
config: Required<NewsletterConfig>,
fetcher: NewsletterFetch = fetch,
timeoutMs = DEFAULT_TIMEOUT_MS,
): Promise<NewsletterResult> {
if (!isRecord(payload)) {
return failure(400);
}

if (payload.nullCheck) {
return success();
}

const email = trimString(payload.email);

if (!email || !isValidEmail(email)) {
return failure(400);
}

const name = trimString(payload.name) ?? '';
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);

try {
const response = await fetcher(config.endpoint.toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
email,
list_uuids: [config.listId],
}),
signal: controller.signal,
});

if (!response.ok) {
return failure(502);
}

const upstream = await response.json().catch(() => undefined);

if (!isRecord(upstream) || upstream.data !== true) {
return failure(502);
}
} catch {
return failure(controller.signal.aborted ? 504 : 502);
} finally {
clearTimeout(timeout);
}

return success();
}
18 changes: 18 additions & 0 deletions src/routes/api/newsletter/+server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { getNewsletterConfig } from '#lib/server/config.ts';
import { handleNewsletterRequest } from '#lib/server/newsletter.ts';
import type { RequestHandler } from './$types';

export const trailingSlash = 'ignore';

export const GET: RequestHandler = () =>
Response.json({ success: false }, { status: 405 });

export const POST: RequestHandler = async ({ request, fetch }) => {
const result = await handleNewsletterRequest(
request,
getNewsletterConfig(),
fetch,
);

return Response.json(result.body, { status: result.status });
};
66 changes: 66 additions & 0 deletions tests/route-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const GOOGLE_PLAY_STORE_LINK =
'https://play.google.com/store/apps/details?id=com.sgfdevs.methodConfApp';
const APPLE_APP_STORE_LINK =
'https://apps.apple.com/us/app/method-conf/id1498359521';
const NEWSLETTER_LIST_ID = 'fake-newsletter-list';

const conference = (slug, date, callForSpeakersUrl) => ({
contentType: 'conference',
Expand Down Expand Up @@ -44,6 +45,7 @@ const state = {
),
],
mediaRequests: [],
newsletterRequests: [],
cmsRequests: 0,
};

Expand Down Expand Up @@ -110,6 +112,23 @@ async function startUpstream() {
return;
}

if (url.pathname === '/api/public/subscription') {
let body = '';
request.setEncoding('utf8');
request.on('data', (chunk) => {
body += chunk;
});
request.on('end', () => {
state.newsletterRequests.push({
method: request.method,
contentType: request.headers['content-type'],
body,
});
writeJson(response, 200, { data: true });
});
return;
}

if (url.pathname === '/media/image.jpg') {
state.mediaRequests.push({
method: request.method,
Expand Down Expand Up @@ -244,6 +263,8 @@ async function startApp({ upstreamUrl, searchIndexingEnabled }) {
CMS_PUBLIC_URL: 'https://cms.example.test/',
SITE_URL: 'https://www.example.test/',
SEARCH_INDEXING_ENABLED: searchIndexingEnabled ? 'true' : 'false',
NEWSLETTER_ENDPOINT: `${upstreamUrl}api/public/subscription`,
NEWSLETTER_LIST_ID,
},
stdio: ['ignore', 'pipe', 'pipe'],
});
Expand Down Expand Up @@ -472,6 +493,50 @@ async function runRouteChecks(origin) {
];
}

async function runNewsletterChecks(origin) {
state.newsletterRequests = [];

const get = await request(origin, '/api/newsletter/');
assert.equal(get.status, 405);
assert.deepEqual(await get.json(), { success: false });

const success = await request(origin, '/api/newsletter/', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: ' Ada Lovelace ',
email: ' ada@example.com ',
nullCheck: '',
}),
});
assert.equal(success.status, 200);
assert.deepEqual(await success.json(), { success: true });
assert.equal(state.newsletterRequests.length, 1);
assert.deepEqual(JSON.parse(state.newsletterRequests.at(-1).body), {
name: 'Ada Lovelace',
email: 'ada@example.com',
list_uuids: [NEWSLETTER_LIST_ID],
});

const slashless = await request(origin, '/api/newsletter', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: 'a@b', nullCheck: '' }),
});
assert.equal(slashless.status, 200);
assert.deepEqual(await slashless.json(), { success: true });
assert.equal(state.newsletterRequests.length, 2);

const honeypot = await request(origin, '/api/newsletter/', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: '', nullCheck: 'bot' }),
});
assert.equal(honeypot.status, 200);
assert.deepEqual(await honeypot.json(), { success: true });
assert.equal(state.newsletterRequests.length, 2);
}

async function runMobileChecks(origin) {
await expectRedirect(
origin,
Expand Down Expand Up @@ -818,6 +883,7 @@ try {
try {
await runRouteChecks(app.origin);
await runMobileChecks(app.origin);
await runNewsletterChecks(app.origin);
await runHeadChecks(app.origin, { searchIndexingEnabled: true });
await runMediaChecks(app.origin);
await runImageOptimizerChecks(app.origin);
Expand Down
Loading
Loading