diff --git a/app/pages/admin/index.vue b/app/pages/admin/index.vue
index f350cc5..19cb824 100644
--- a/app/pages/admin/index.vue
+++ b/app/pages/admin/index.vue
@@ -597,6 +597,7 @@
{{ entry.notes }}
Source: {{ entry.source || 'landing' }}
+ Referrals: {{ entry.referralJoins }}
@@ -1246,6 +1247,8 @@ interface WaitlistEntryItem {
wantsProductUpdates: boolean
updatesOptedInAt?: string
invitationSentAt?: string
+ referralJoins: number
+ referredBy?: string
invitation?: WaitlistInvitationInfo
}
diff --git a/app/pages/index.vue b/app/pages/index.vue
index 4e680d2..26a0909 100644
--- a/app/pages/index.vue
+++ b/app/pages/index.vue
@@ -589,6 +589,11 @@
({{ formatRelativeFromNow(waitlistStats?.lastJoinedAt) }})
+
+ Friend referrals:
+ {{ waitlistReferralJoinsDisplay }}
+ joined via shared waitlist links.
+
Product and roadmap deep-dives now live on
@@ -623,6 +628,13 @@
/>
+
+ You are joining through a shared OpenSquawk link.
+
+
Thank you! You are on the waitlist and we will reach out as soon as slots open up.
- {{ waitlistError }}
+
+ Share your personal waitlist link
+
+ {{ waitlistReferralUrl }}
+
+
+
+
+
+ {{ waitlistShareNotice }}
+
+ {{ waitlistError }}
@@ -1098,10 +1135,22 @@ interface WaitlistStats {
syntheticBoost: number
recent7Days: number
recent30Days: number
+ referralJoins: number
+ referralShareClicks: number
lastJoinedAt: string | null
generatedAt: string
}
+interface WaitlistJoinResponse {
+ success: boolean
+ alreadyJoined: boolean
+ joinedAt: string
+ referralToken?: string
+ referralUrl?: string
+ referralAttributed?: boolean
+ referralJoins?: number
+}
+
interface CaptchaChallenge {
id: string
prompt: string
@@ -1176,6 +1225,12 @@ const normaliseCaptchaValue = (value: string) =>
.replace(/[^a-z0-9]+/g, ' ')
.trim()
+const normalizeReferralToken = (value: unknown) => {
+ if (typeof value !== 'string') return ''
+ const token = value.trim().toUpperCase()
+ return /^[A-F0-9]{8}$/.test(token) ? token : ''
+}
+
const isCaptchaAnswerValid = (state: CaptchaState) => {
const response = normaliseCaptchaValue(state.answer)
if (!response) return false
@@ -1219,10 +1274,20 @@ const waitlistCaptchaReminder = ref('')
const waitlistSubmitting = ref(false)
const waitlistSuccess = ref(false)
const waitlistError = ref('')
+const waitlistShareNotice = ref('')
+const waitlistReferralToken = ref('')
+const waitlistReferralUrl = ref('')
const waitlistStats = ref(null)
const waitlistLoading = ref(false)
+const activeReferralToken = computed(() => {
+ const queryValue = route.query.ref
+ const firstValue = Array.isArray(queryValue) ? queryValue[0] : queryValue
+ return normalizeReferralToken(firstValue)
+})
+const supportsNativeShare = computed(() => import.meta.client && typeof navigator !== 'undefined' && typeof navigator.share === 'function')
const waitlistCountDisplay = computed(() => formatNumber(waitlistStats.value?.displayCount ?? 0))
+const waitlistReferralJoinsDisplay = computed(() => formatNumber(waitlistStats.value?.referralJoins ?? 0))
const waitlistLastJoinedFormatted = computed(() => {
const iso = waitlistStats.value?.lastJoinedAt
if (!iso) return '–'
@@ -1270,6 +1335,9 @@ async function submitWaitlist() {
waitlistError.value = ''
waitlistSuccess.value = false
+ waitlistShareNotice.value = ''
+ waitlistReferralToken.value = ''
+ waitlistReferralUrl.value = ''
waitlistCaptchaReminder.value = ''
const email = waitlistForm.email.trim()
@@ -1298,7 +1366,7 @@ async function submitWaitlist() {
waitlistSubmitting.value = true
try {
- await api.post(
+ const response = await api.post(
'/api/service/waitlist',
{
name: waitlistForm.name,
@@ -1308,11 +1376,14 @@ async function submitWaitlist() {
consentTerms: waitlistForm.consentTerms,
wantsProductUpdates: waitlistForm.subscribeUpdates,
source: 'landing-phase1-cta',
+ referralToken: activeReferralToken.value || undefined,
},
{ auth: false },
)
waitlistSuccess.value = true
+ waitlistReferralToken.value = response.referralToken || ''
+ waitlistReferralUrl.value = response.referralUrl || ''
await loadWaitlistStats()
waitlistForm.name = ''
@@ -1333,6 +1404,55 @@ async function submitWaitlist() {
}
}
+async function trackWaitlistShare(method: 'copy' | 'native-share') {
+ if (!waitlistReferralToken.value) return
+
+ try {
+ await api.post(
+ '/api/service/waitlist/referral-event',
+ {
+ event: 'share_clicked',
+ referralToken: waitlistReferralToken.value,
+ method,
+ },
+ { auth: false },
+ )
+ await loadWaitlistStats()
+ } catch (error) {
+ console.warn('Could not track referral share event', error)
+ }
+}
+
+async function copyWaitlistReferralLink() {
+ if (!waitlistReferralUrl.value || !import.meta.client || !navigator.clipboard?.writeText) return
+
+ try {
+ await navigator.clipboard.writeText(waitlistReferralUrl.value)
+ waitlistShareNotice.value = 'Share link copied.'
+ await trackWaitlistShare('copy')
+ } catch (error) {
+ waitlistShareNotice.value = 'Copy failed. You can copy the link manually.'
+ }
+}
+
+async function shareWaitlistReferralLink() {
+ if (!waitlistReferralUrl.value || !supportsNativeShare.value || !import.meta.client) return
+
+ try {
+ await navigator.share({
+ title: 'OpenSquawk waitlist',
+ text: 'Train ATC with me before flying VATSIM.',
+ url: waitlistReferralUrl.value,
+ })
+ waitlistShareNotice.value = 'Thanks for sharing.'
+ await trackWaitlistShare('native-share')
+ } catch (error: any) {
+ if (error?.name !== 'AbortError') {
+ waitlistShareNotice.value = 'Share failed. You can still copy the link.'
+ }
+ }
+}
+
const formatWaitlistDate = (iso: string) => {
if (!iso) return 'unknown'
const parsed = new Date(iso)
diff --git a/server/api/admin/waitlist/index.get.ts b/server/api/admin/waitlist/index.get.ts
index 2b64820..852f1f6 100644
--- a/server/api/admin/waitlist/index.get.ts
+++ b/server/api/admin/waitlist/index.get.ts
@@ -15,6 +15,8 @@ type WaitlistListItem = {
wantsProductUpdates: boolean
updatesOptedInAt?: string
invitationSentAt?: string
+ referralJoins: number
+ referredBy?: string
invitation?: {
id: string
code: string
@@ -53,6 +55,8 @@ function mapWaitlistEntry(doc: any): WaitlistListItem {
wantsProductUpdates: Boolean(doc.wantsProductUpdates),
updatesOptedInAt: normalizeDate(doc.updatesOptedInAt),
invitationSentAt: sentAt,
+ referralJoins: Number(doc.referralJoins || 0),
+ referredBy: doc.referredBy ? String(doc.referredBy) : undefined,
invitation:
invitationDoc && invitationDoc.code
? {
@@ -150,4 +154,3 @@ export default defineEventHandler(async (event) => {
},
}
})
-
diff --git a/server/api/service/cron/waitlist-drip.get.ts b/server/api/service/cron/waitlist-drip.get.ts
index 8059b80..b02f171 100644
--- a/server/api/service/cron/waitlist-drip.get.ts
+++ b/server/api/service/cron/waitlist-drip.get.ts
@@ -43,7 +43,6 @@ export default defineEventHandler(async () => {
}
const sentAt = new Date()
-
if (!invitation) {
const code = generateInvitationCode()
invitation = await InvitationCode.create({
diff --git a/server/api/service/waitlist.get.ts b/server/api/service/waitlist.get.ts
index 0176373..f8e76a7 100644
--- a/server/api/service/waitlist.get.ts
+++ b/server/api/service/waitlist.get.ts
@@ -24,15 +24,18 @@ export default defineEventHandler(async () => {
const weekAgo = new Date(now.getTime() - 7 * DAY_MS)
const monthAgo = new Date(now.getTime() - 30 * DAY_MS)
- const [count, latestEntry, recent7Days, recent30Days] = await Promise.all([
+ const [count, latestEntry, recent7Days, recent30Days, referralJoins, referralShareClicksResult] = await Promise.all([
WaitlistEntry.countDocuments(),
WaitlistEntry.findOne().sort({ joinedAt: -1 }).select({ joinedAt: 1 }).lean(),
WaitlistEntry.countDocuments({ joinedAt: { $gte: weekAgo } }),
WaitlistEntry.countDocuments({ joinedAt: { $gte: monthAgo } }),
+ WaitlistEntry.countDocuments({ referredBy: { $exists: true, $ne: null } }),
+ WaitlistEntry.aggregate([{ $group: { _id: null, total: { $sum: '$referralShareClicks' } } }]),
])
const displayCount = computeDisplayCount(count, now)
const boost = Math.max(0, displayCount - count)
+ const referralShareClicks = Number(referralShareClicksResult?.[0]?.total || 0)
return {
count,
@@ -40,8 +43,9 @@ export default defineEventHandler(async () => {
syntheticBoost: boost,
recent7Days,
recent30Days,
+ referralJoins,
+ referralShareClicks,
lastJoinedAt: latestEntry?.joinedAt ? latestEntry.joinedAt.toISOString() : null,
generatedAt: now.toISOString(),
}
})
-
diff --git a/server/api/service/waitlist.post.ts b/server/api/service/waitlist.post.ts
index a19fb56..fb3475f 100644
--- a/server/api/service/waitlist.post.ts
+++ b/server/api/service/waitlist.post.ts
@@ -1,7 +1,11 @@
-import { readBody, createError } from 'h3'
-import { WaitlistEntry } from '../../models/WaitlistEntry'
+import { readBody, createError, getRequestURL, type H3Event } from 'h3'
+import { WaitlistEntry, type WaitlistEntryDocument } from '../../models/WaitlistEntry'
import { sendAdminNotification } from '../../utils/notifications'
import { registerUpdateSubscriber } from '../../utils/subscribers'
+import {
+ generateWaitlistReferralToken,
+ normalizeWaitlistReferralToken,
+} from '../../utils/waitlistReferrals'
interface WaitlistRequestBody {
email?: string
@@ -11,9 +15,46 @@ interface WaitlistRequestBody {
consentTerms?: boolean
source?: string
wantsProductUpdates?: boolean
+ referralToken?: string
}
type NotificationDataEntry = [string, ...unknown[]]
+const MAX_REFERRAL_TOKEN_ATTEMPTS = 8
+
+function isSameWaitlistEntry(a?: WaitlistEntryDocument | null, b?: WaitlistEntryDocument | null) {
+ if (!a || !b) return false
+ return String((a as any)._id) === String((b as any)._id)
+}
+
+async function createUniqueReferralToken() {
+ for (let attempt = 0; attempt < MAX_REFERRAL_TOKEN_ATTEMPTS; attempt += 1) {
+ const token = generateWaitlistReferralToken()
+ const exists = await WaitlistEntry.exists({ referralToken: token })
+ if (!exists) {
+ return token
+ }
+ }
+
+ throw createError({
+ statusCode: 503,
+ statusMessage: 'Could not issue referral token. Please try again.',
+ })
+}
+
+function buildReferralUrl(event: H3Event, token: string) {
+ const requestUrl = getRequestURL(event)
+ const params = new URLSearchParams({ ref: token })
+ return `${requestUrl.origin}/?${params.toString()}#cta`
+}
+
+async function applyReferralAttribution(referrer?: WaitlistEntryDocument | null) {
+ if (!referrer) {
+ return
+ }
+
+ referrer.referralJoins = Math.max(0, Number(referrer.referralJoins || 0)) + 1
+ await referrer.save()
+}
export default defineEventHandler(async (event) => {
const body = await readBody(event)
@@ -22,6 +63,7 @@ export default defineEventHandler(async (event) => {
const notes = body.notes?.trim()
const source = body.source?.trim() || 'landing'
const wantsProductUpdates = Boolean(body.wantsProductUpdates)
+ const normalizedReferralToken = normalizeWaitlistReferralToken(body.referralToken)
const fromAddress = email ? (name ? `${name} <${email}>` : email) : undefined
if (!email) {
@@ -33,14 +75,33 @@ export default defineEventHandler(async (event) => {
}
const now = new Date()
+ const referrer = normalizedReferralToken
+ ? await WaitlistEntry.findOne({ referralToken: normalizedReferralToken })
+ : null
+ const referralSource = referrer ? `${source}-referral` : source
const existing = await WaitlistEntry.findOne({ email })
if (existing) {
const previouslyWantedUpdates = Boolean(existing.wantsProductUpdates)
+ const canAttributeReferral =
+ !existing.referredBy &&
+ Boolean(referrer) &&
+ !isSameWaitlistEntry(existing, referrer) &&
+ referrer?.email !== email
+
+ if (!existing.referralToken) {
+ existing.referralToken = await createUniqueReferralToken()
+ }
+ const existingReferralToken = existing.referralToken as string
+
+ if (canAttributeReferral && referrer) {
+ existing.referredBy = referrer._id as any
+ }
+
existing.name = name || existing.name
existing.notes = notes || existing.notes
- existing.source = source
+ existing.source = referralSource
existing.consentPrivacy = true
existing.consentTerms = true
if (wantsProductUpdates && !previouslyWantedUpdates) {
@@ -49,11 +110,15 @@ export default defineEventHandler(async (event) => {
}
await existing.save()
+ if (canAttributeReferral) {
+ await applyReferralAttribution(referrer)
+ }
+
if (wantsProductUpdates) {
const updateResult = await registerUpdateSubscriber({
email,
name,
- source: `${source}-waitlist`,
+ source: `${referralSource}-waitlist`,
consentPrivacy: true,
consentMarketing: true,
})
@@ -68,7 +133,7 @@ export default defineEventHandler(async (event) => {
if (notes) {
dataEntries.push(['Notes', notes])
}
- dataEntries.push(['Source', source])
+ dataEntries.push(['Source', referralSource])
dataEntries.push(['Opt-in', 'Product updates'])
await sendAdminNotification({
@@ -84,26 +149,39 @@ export default defineEventHandler(async (event) => {
success: true,
alreadyJoined: true,
joinedAt: existing.joinedAt,
+ referralToken: existingReferralToken,
+ referralUrl: buildReferralUrl(event, existingReferralToken),
+ referralAttributed: canAttributeReferral,
+ referralJoins: Number(existing.referralJoins || 0),
}
}
+ const referralToken = await createUniqueReferralToken()
+ const canAttributeReferral = Boolean(referrer) && referrer?.email !== email
+
const entry = await WaitlistEntry.create({
email,
name,
notes,
- source,
+ source: referralSource,
consentPrivacy: true,
consentTerms: true,
joinedAt: now,
wantsProductUpdates,
updatesOptedInAt: wantsProductUpdates ? now : undefined,
+ referralToken,
+ referredBy: canAttributeReferral && referrer ? (referrer._id as any) : undefined,
})
+ if (canAttributeReferral) {
+ await applyReferralAttribution(referrer)
+ }
+
if (wantsProductUpdates) {
await registerUpdateSubscriber({
email,
name,
- source: `${source}-waitlist`,
+ source: `${referralSource}-waitlist`,
consentPrivacy: true,
consentMarketing: true,
})
@@ -118,8 +196,11 @@ export default defineEventHandler(async (event) => {
if (notes) {
dataEntries.push(['Notes', notes])
}
- dataEntries.push(['Source', source])
+ dataEntries.push(['Source', referralSource])
dataEntries.push(['Opt-in', wantsProductUpdates ? 'Product updates' : 'Waitlist only'])
+ if (canAttributeReferral && referrer) {
+ dataEntries.push(['Referral', normalizedReferralToken as string])
+ }
await sendAdminNotification({
event: 'New waitlist signup',
@@ -132,5 +213,9 @@ export default defineEventHandler(async (event) => {
success: true,
alreadyJoined: false,
joinedAt: entry.joinedAt,
+ referralToken,
+ referralUrl: buildReferralUrl(event, referralToken),
+ referralAttributed: canAttributeReferral,
+ referralJoins: Number(entry.referralJoins || 0),
}
})
diff --git a/server/api/service/waitlist/referral-event.post.ts b/server/api/service/waitlist/referral-event.post.ts
new file mode 100644
index 0000000..bde1102
--- /dev/null
+++ b/server/api/service/waitlist/referral-event.post.ts
@@ -0,0 +1,40 @@
+import { createError, readBody } from 'h3'
+import { WaitlistEntry } from '../../../models/WaitlistEntry'
+import { normalizeWaitlistReferralToken } from '../../../utils/waitlistReferrals'
+
+interface ReferralEventRequestBody {
+ event?: string
+ referralToken?: string
+ method?: string
+}
+
+export default defineEventHandler(async (event) => {
+ const body = await readBody(event)
+ const eventName = body.event?.trim()
+
+ if (eventName !== 'share_clicked') {
+ throw createError({ statusCode: 400, statusMessage: 'Unsupported referral event' })
+ }
+
+ const referralToken = normalizeWaitlistReferralToken(body.referralToken)
+ if (!referralToken) {
+ throw createError({ statusCode: 400, statusMessage: 'Valid referral token is required' })
+ }
+
+ const entry = await WaitlistEntry.findOne({ referralToken })
+ if (!entry) {
+ throw createError({ statusCode: 404, statusMessage: 'Referral owner not found' })
+ }
+
+ entry.referralShareClicks = Math.max(0, Number(entry.referralShareClicks || 0)) + 1
+ entry.lastReferralShareAt = new Date()
+ entry.markModified('referralShareClicks')
+
+ await entry.save()
+
+ return {
+ success: true,
+ event: eventName,
+ method: body.method?.trim() || 'unknown',
+ }
+})
diff --git a/server/models/WaitlistEntry.ts b/server/models/WaitlistEntry.ts
index 2758fc4..27d19de 100644
--- a/server/models/WaitlistEntry.ts
+++ b/server/models/WaitlistEntry.ts
@@ -16,6 +16,11 @@ export interface WaitlistEntryDocument extends mongoose.Document {
invitationCode?: mongoose.Types.ObjectId
invitationSentAt?: Date
feedbackRequestedAt?: Date
+ referralToken?: string
+ referredBy?: mongoose.Types.ObjectId
+ referralJoins: number
+ referralShareClicks: number
+ lastReferralShareAt?: Date
}
const waitlistSchema = new mongoose.Schema({
@@ -32,9 +37,13 @@ const waitlistSchema = new mongoose.Schema({
invitationCode: { type: Schema.Types.ObjectId, ref: 'InvitationCode' },
invitationSentAt: { type: Date },
feedbackRequestedAt: { type: Date },
+ referralToken: { type: String, uppercase: true, trim: true, unique: true, sparse: true, index: true },
+ referredBy: { type: Schema.Types.ObjectId, ref: 'WaitlistEntry' },
+ referralJoins: { type: Number, default: 0 },
+ referralShareClicks: { type: Number, default: 0 },
+ lastReferralShareAt: { type: Date },
})
export const WaitlistEntry =
(mongoose.models.WaitlistEntry as mongoose.Model | undefined) ||
mongoose.model('WaitlistEntry', waitlistSchema)
-
diff --git a/server/utils/waitlistReferrals.ts b/server/utils/waitlistReferrals.ts
new file mode 100644
index 0000000..51abf99
--- /dev/null
+++ b/server/utils/waitlistReferrals.ts
@@ -0,0 +1,20 @@
+import { randomBytes } from 'node:crypto'
+
+export const WAITLIST_REFERRAL_TOKEN_PATTERN = /^[A-F0-9]{8}$/
+
+export function generateWaitlistReferralToken() {
+ return randomBytes(4).toString('hex').toUpperCase()
+}
+
+export function normalizeWaitlistReferralToken(value?: unknown) {
+ if (typeof value !== 'string') {
+ return null
+ }
+
+ const token = value.trim().toUpperCase()
+ if (!WAITLIST_REFERRAL_TOKEN_PATTERN.test(token)) {
+ return null
+ }
+
+ return token
+}
diff --git a/tests/server/waitlistReferrals.test.ts b/tests/server/waitlistReferrals.test.ts
new file mode 100644
index 0000000..6dc59aa
--- /dev/null
+++ b/tests/server/waitlistReferrals.test.ts
@@ -0,0 +1,28 @@
+import { describe, it } from 'node:test'
+import assert from 'node:assert/strict'
+
+import {
+ generateWaitlistReferralToken,
+ normalizeWaitlistReferralToken,
+} from '~~/server/utils/waitlistReferrals'
+
+describe('waitlist referral utilities', () => {
+ it('generates uppercase 8-char hex referral tokens', () => {
+ const token = generateWaitlistReferralToken()
+ assert.match(token, /^[A-F0-9]{8}$/)
+ })
+
+ it('normalizes valid referral tokens', () => {
+ assert.equal(normalizeWaitlistReferralToken('ab12cd34'), 'AB12CD34')
+ assert.equal(normalizeWaitlistReferralToken(' ff00aa11 '), 'FF00AA11')
+ })
+
+ it('rejects invalid referral tokens', () => {
+ assert.equal(normalizeWaitlistReferralToken(''), null)
+ assert.equal(normalizeWaitlistReferralToken('abc123'), null)
+ assert.equal(normalizeWaitlistReferralToken('AB12-CD34'), null)
+ assert.equal(normalizeWaitlistReferralToken('GZ12CD34'), null)
+ assert.equal(normalizeWaitlistReferralToken(undefined), null)
+ assert.equal(normalizeWaitlistReferralToken(1234), null)
+ })
+})
|