Skip to content
On this page

Developer Blog

Building the Leap Week Registration and Referral System

Published June 10th, 2024

Written By
Bryant Gillespie
Bryant Gillespie
Developer Advocate

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.

Customize Your Rabbitar page with traits and avatar options

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.

The confirmation page with the URL field for referring others

When a unique ticket link is shared, we use that avatar to create a personalized social sharing image.

A personalized social sharing image generated from an attendee's avatar

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.

4 frames of the Directus Data Model, Page Content, landing page and launch countdown

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.

jsx
<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.

jsx
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.

jsx
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.

The swag-o-meter count bar on the landing 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.

jsx
‹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.

jsx
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.

Dashboard tracking sign ups and views with a panel showing total number of Rabbitars generated

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.

Tell us what is missing

How helpful was this article?