Building the Leap Week Registration and Referral System
Published June 10th, 2024
I recently shipped the ticketing system for Leap Week 3 - our week long launch celebration for developers at Directus. There are product announcements, workshops, giveaways, and more.
While the live events are broadcast via Directus TV, the registration has typically been powered by existing ticketing platforms. This time, we decided to build our own, powered by Directus.
This project also used Nuxt and Vue on the frontend, along with Nuxt UI and Tailwind CSS for a lot of the basic components like buttons and form inputs. It is hosted on Netlify with image generation through OpenAI’s Dall•E 3 model.
Project Overview
Each event gets a landing page and allows users to register. Once registered, they are prompted to make a ‘Rabbitar’ by selecting a set of characteristics. The image patches on the rabbit’s arms are dynamic based on the company website URL and company selected.
Once registration is complete, each ticket has a separate URL that can be shared. If new people register from the ticket page, another giveaway entry is added for that user.
When a unique ticket link is shared, we use that avatar to create a personalized social sharing image.
Landing Page Builder
Directus runs the whole backend of this project from ticketing and registration to serving data for the landing page.
The concept of blocks
is one we use a lot at Directus, utilizing the Many-to-Any (M2A) relation type to create flexible page builders. We also utilized it in the project for building event landing pages - blocks include elements like schedules, call-to-actions (CTAs), speakers, and cards. They can be added in any order or quantity when creating a page.
It all comes together on the Nuxt side. The data is fetched from the Directus backend, and then passed to a PageBuilder
component that is responsible for looping through an array of blocks and rendering the components dynamically.
<script setup lang="ts">
import type { BlockType, EventLandingPageBlocks } from '~/types';
const componentMap: Record<BlockType, any> = {
block_hero: resolveComponent('BlocksHero'),
block_faqs: resolveComponent('BlocksFaqs'),
block_cta: resolveComponent('BlocksCta'),
block_speakers: resolveComponent('BlocksSpeakers'),
block_schedule: resolveComponent('BlocksSchedule'),
block_cardgroup: resolveComponent('BlocksCardgroup'),
block_countdown: resolveComponent('BlocksCountdown'),
block_button: resolveComponent('BlocksButton'),
block_button_group: resolveComponent('BlocksButtonGroup'),
block_card: resolveComponent('BlocksCard'),
};
const props = defineProps<{
blocks: EventLandingPageBlocks[];
}>();
</script>
<template>
<div id="content" class="mx-auto">
<template v-for="block in blocks" :key="block.id">
<component
:is="componentMap[block.collection as BlockType]"
v-if="block && block.collection"
:data="block.item"
/>
</template>
</div>
</template>
<script setup lang="ts">
import type { BlockType, EventLandingPageBlocks } from '~/types';
const componentMap: Record<BlockType, any> = {
block_hero: resolveComponent('BlocksHero'),
block_faqs: resolveComponent('BlocksFaqs'),
block_cta: resolveComponent('BlocksCta'),
block_speakers: resolveComponent('BlocksSpeakers'),
block_schedule: resolveComponent('BlocksSchedule'),
block_cardgroup: resolveComponent('BlocksCardgroup'),
block_countdown: resolveComponent('BlocksCountdown'),
block_button: resolveComponent('BlocksButton'),
block_button_group: resolveComponent('BlocksButtonGroup'),
block_card: resolveComponent('BlocksCard'),
};
const props = defineProps<{
blocks: EventLandingPageBlocks[];
}>();
</script>
<template>
<div id="content" class="mx-auto">
<template v-for="block in blocks" :key="block.id">
<component
:is="componentMap[block.collection as BlockType]"
v-if="block && block.collection"
:data="block.item"
/>
</template>
</div>
</template>
Generating AI Rabbitars
The actual rabbitar images are generated using OpenAI’s Dall•E 3 model. Currently, the average user generates ~1.52 avatars costing us roughly $0.06 per registrant. We have set a hard limit of 3 generations to prevent any unexpected bills.
There is a Nuxt server route that calls the OpenAI API, saves the generated image to the Directus instance, and updates the avatars generated by the user.
import { importFile, readItem } from '@directus/sdk';
import { directusServer } from '~/server/utils/directus-server';
import jwt from 'jsonwebtoken';
import type { Token, People } from '~/types';
const openAiApiKey = process.env. OPENAI_API_KEY;
const jwtSecret = process.env. JWT_SECRET;
const avatarLimit = 3;
export default defineEventHandler(async (event) => {
try {
// Get the body and the cookies from the event
const body = await readBody(event);
const cookies = parseCookies(event);
// Check the number of avatars generated for the ticket id
const token = (await jwt.verify(cookies.leapweek_token, jwtSecret as string)) as Token;
if (!token) {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
}
const ticket = await directusServer.request(readItem('event_tickets', token.ticket_id));
if (!ticket) {
throw createError({ statusCode: 500, statusMessage: 'Ticket not found' });
}
// If they have genearated 3 avatars, throw an error
if (ticket.avatars && ticket.avatars.length >= avatarLimit) {
throw createError({ statusCode: 500, statusMessage: `Maximum number of ${avatarLimit} avatars generated.` });
}
let { attributes } = body;
attributes = attributes.join(', ');
const prompt = `A photorealistic head-on headshot of a single rabbit, set against a black background, with detailed fur texture and realistic lighting. Keep the rabbits face inside the frame. The rabbit should have the following characteristics: ${attributes}.`;
const image: any = await $fetch('https://api.openai.com/v1/images/generations', {
method: 'POST',
headers: { Authorization: `Bearer ${openAiApiKey}`, 'Content-Type': 'application/json' },
body: { prompt, model: 'dall-e-3', n: 1, size: '1024x1024' },
});
const imageResponse = await directusServer.request(
importFile(image.data[0].url, {
description: image.data[0].revised_prompt,
}),
);
// Update ticket with the new avatar
await directusServer.request(
updateItem('event_tickets', token.ticket_id, {
avatars: {
create: [
{
directus_files_id: imageResponse.id,
event_tickets_id: token.ticket_id,
},
],
},
people_id: {
id: token.people_id,
// If the avatar is the first one, set it as the main avatar
avatar: ticket.avatars?.length === 0 ? imageResponse.id : (ticket.people_id as People)?.avatar,
},
}),
);
return { directus_files_id: imageResponse.id };
} catch (error) {
return error;
}
});
import { importFile, readItem } from '@directus/sdk';
import { directusServer } from '~/server/utils/directus-server';
import jwt from 'jsonwebtoken';
import type { Token, People } from '~/types';
const openAiApiKey = process.env. OPENAI_API_KEY;
const jwtSecret = process.env. JWT_SECRET;
const avatarLimit = 3;
export default defineEventHandler(async (event) => {
try {
// Get the body and the cookies from the event
const body = await readBody(event);
const cookies = parseCookies(event);
// Check the number of avatars generated for the ticket id
const token = (await jwt.verify(cookies.leapweek_token, jwtSecret as string)) as Token;
if (!token) {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
}
const ticket = await directusServer.request(readItem('event_tickets', token.ticket_id));
if (!ticket) {
throw createError({ statusCode: 500, statusMessage: 'Ticket not found' });
}
// If they have genearated 3 avatars, throw an error
if (ticket.avatars && ticket.avatars.length >= avatarLimit) {
throw createError({ statusCode: 500, statusMessage: `Maximum number of ${avatarLimit} avatars generated.` });
}
let { attributes } = body;
attributes = attributes.join(', ');
const prompt = `A photorealistic head-on headshot of a single rabbit, set against a black background, with detailed fur texture and realistic lighting. Keep the rabbits face inside the frame. The rabbit should have the following characteristics: ${attributes}.`;
const image: any = await $fetch('https://api.openai.com/v1/images/generations', {
method: 'POST',
headers: { Authorization: `Bearer ${openAiApiKey}`, 'Content-Type': 'application/json' },
body: { prompt, model: 'dall-e-3', n: 1, size: '1024x1024' },
});
const imageResponse = await directusServer.request(
importFile(image.data[0].url, {
description: image.data[0].revised_prompt,
}),
);
// Update ticket with the new avatar
await directusServer.request(
updateItem('event_tickets', token.ticket_id, {
avatars: {
create: [
{
directus_files_id: imageResponse.id,
event_tickets_id: token.ticket_id,
},
],
},
people_id: {
id: token.people_id,
// If the avatar is the first one, set it as the main avatar
avatar: ticket.avatars?.length === 0 ? imageResponse.id : (ticket.people_id as People)?.avatar,
},
}),
);
return { directus_files_id: imageResponse.id };
} catch (error) {
return error;
}
});
Nuxt Route Rules
Nuxt Route Rules keep the site speedy by allowing different rendering modes based on specific routes – an uncommon feature for other frameworks.
export default defineNuxtConfig({
routeRules: {
'/': { swr: true },
'/auth/**': { swr: true },
'/api/logo/**': {
proxy: 'https://logo.clearbit.com/**',
swr: true,
cache: {
maxAge: 60 * 60 * 24, // 24 hours
},
},
},
})
export default defineNuxtConfig({
routeRules: {
'/': { swr: true },
'/auth/**': { swr: true },
'/api/logo/**': {
proxy: 'https://logo.clearbit.com/**',
swr: true,
cache: {
maxAge: 60 * 60 * 24, // 24 hours
},
},
},
})
Referral Tracking
We wanted to offer more chances in the giveaway for referrals so we needed to build a mechanism to control that.
Once you generate your personalized rabbitar - you can share it to increase your odds of winning. Each person your refer earns you another entry in the giveaway.
To track this, we tag the visitor with a referral_ticket_id
cookie whenever they visit a registrant personal url. Whenever a visitor registers for the event, we check for the cookie, and update a referred_by
field inside our Directus backend.
This is surfaced to the registrant as a “Swag-O-Meter” on their personalized ticket page.
Function Timeouts
leapweek.dev is hosted on Netlify. We’ve got a number of our other projects hosted there and I’m pretty familiar with the workflow. With Nuxt, there’s not really much configuration to be done, aside from connecting your repo and adding your ENV variables.
But Dall•E 3 currently takes roughly between ~15-21 seconds to generate a rabbitar for the site. In local development this wasn’t a problem, but once deployed to Netlify, we were getting timeouts on the serverless functions because the default timeout is 10 secs.
The Netlify support team was right there to help us out. They increased our limit to 26 secs and we’ve not had anymore issues.
Shortening URLs
Originally we wanted to run this off a subdomain of the site. But https://leapweek.directus.io/tickets/bryant-gillespie
eats up a lot of characters and shorter urls are better for sharing. We’re really digging Dub.co for sharing our content on socials, but it just wasn’t a fit here for generating links.
So we chose the leapweek.dev
domain over leapweek.directus.io
.
But we could do better.
Nuxt Alias
The alias property within Nuxt’s definePageMeta makes it super easy to generate aliases for a specific route. So the page at /tickets/bryant-gillespie
can also be rendered at /t/bryant-gillespie
.
‹script setup lang="ts">
// pages/tickets/[ticket].vue
definePageMeta({
alias: '/t/:ticket',
});
// Rest of script setup
</script>
‹script setup lang="ts">
// pages/tickets/[ticket].vue
definePageMeta({
alias: '/t/:ticket',
});
// Rest of script setup
</script>
Which gives us a final url like https://leapweek.dev/t/bryant-gillespie
Dynamic Social Images and Caching
Dynamically generated OG images are cool, but it’s hard to ensure they render perfectly on different social media platforms. Each platform tends to have it’s own cache for OG images, making it harder to figure out than the Water Temple in Ocarina of Time.
For actually generating the dynamic social share images and caching them, we use the Nuxt OG-Image module by Harlan Wilton. It abstracts away a lot of the complexities of serving up dynamic social images.
Under the hood, it uses Satori by Vercel to render the images from a Vue component. But because of that there are some caveats about component structure and how you can style your images.
When someone updates their avatar, we also need to purge the cached image so we don’t show the previous one. That’s handled inside a Nuxt server route as well.
import { updateItem } from '@directus/sdk';
import { directusServer } from '~/server/utils/directus-server';
import jwt from 'jsonwebtoken';
import type { Token } from '~/types';
const ogPattern = '__og-image__/image';
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig();
const { public: { siteUrl } } = config;
try {
// Get the body and the cookies from the event
const body = await readBody(event);
const cookies = parseCookies(event);
// Check the number of avatars generated for the ticket id
const token = await jwt.verify(cookies.leapweek_token, process.env. JWT_SECRET as string);
if (!token) {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
}
const { people_id, ticket_id } = token as Token;
if (!token || !people_id || !ticket_id) {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
}
delete body.loading;
const person = await directusServer.request(
updateItem('people', people_id, body, {
fields: [
'id',
'first_name',
'last_name',
'email',
'job_title',
'country',
'website',
'avatar',
{ tickets: ['id', 'slug'] },
],
}),
);
// Purge the cache for the OG image
if (person.tickets?.length) {
$fetch(`${siteUrl}/${ogPattern}/t/${person.tickets[0].slug}/og.png?purge`);
$fetch(`${siteUrl}/${ogPattern}/tickets/${person.tickets[0].id}/og.png?purge`);
}
return person;
} catch (error) {
return error;
}
});
import { updateItem } from '@directus/sdk';
import { directusServer } from '~/server/utils/directus-server';
import jwt from 'jsonwebtoken';
import type { Token } from '~/types';
const ogPattern = '__og-image__/image';
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig();
const { public: { siteUrl } } = config;
try {
// Get the body and the cookies from the event
const body = await readBody(event);
const cookies = parseCookies(event);
// Check the number of avatars generated for the ticket id
const token = await jwt.verify(cookies.leapweek_token, process.env. JWT_SECRET as string);
if (!token) {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
}
const { people_id, ticket_id } = token as Token;
if (!token || !people_id || !ticket_id) {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
}
delete body.loading;
const person = await directusServer.request(
updateItem('people', people_id, body, {
fields: [
'id',
'first_name',
'last_name',
'email',
'job_title',
'country',
'website',
'avatar',
{ tickets: ['id', 'slug'] },
],
}),
);
// Purge the cache for the OG image
if (person.tickets?.length) {
$fetch(`${siteUrl}/${ogPattern}/t/${person.tickets[0].slug}/og.png?purge`);
$fetch(`${siteUrl}/${ogPattern}/tickets/${person.tickets[0].id}/og.png?purge`);
}
return person;
} catch (error) {
return error;
}
});
Dashboarding
I also put together a nice dashboard for the team to track sign ups and view all the different countries users were from. This is baked into Directus and took me all of like 5 minutes.
In Summary
This was a really fun project which brings together a lot of powerful Directus features and usage patterns into an application that solves our team’s needs. There were a lot of interesting small edge cases that we have shared here so you can build your own.
Leap Week 3 starts June 17 2024 and it’s not too late to build your rabbitar and enter our prize raffle.