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) + }) +})