mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 00:46:00 +08:00
personal waitlist link
This commit is contained in:
@@ -597,6 +597,7 @@
|
||||
<div v-if="entry.notes" class="text-xs text-white/50">{{ entry.notes }}</div>
|
||||
<div class="flex flex-wrap gap-2 text-[11px] text-white/40">
|
||||
<span>Source: {{ entry.source || 'landing' }}</span>
|
||||
<span>Referrals: {{ entry.referralJoins }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@@ -1246,6 +1247,8 @@ interface WaitlistEntryItem {
|
||||
wantsProductUpdates: boolean
|
||||
updatesOptedInAt?: string
|
||||
invitationSentAt?: string
|
||||
referralJoins: number
|
||||
referredBy?: string
|
||||
invitation?: WaitlistInvitationInfo
|
||||
}
|
||||
|
||||
|
||||
@@ -589,6 +589,11 @@
|
||||
({{ formatRelativeFromNow(waitlistStats?.lastJoinedAt) }})
|
||||
</span>
|
||||
</p>
|
||||
<p class="text-xs text-white/60">
|
||||
Friend referrals:
|
||||
<span class="font-medium text-white">{{ waitlistReferralJoinsDisplay }}</span>
|
||||
joined via shared waitlist links.
|
||||
</p>
|
||||
</template>
|
||||
<p class="text-xs text-white/60">
|
||||
Product and roadmap deep-dives now live on
|
||||
@@ -623,6 +628,13 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="activeReferralToken"
|
||||
class="rounded-2xl border border-cyan-400/30 bg-cyan-400/10 px-4 py-3 text-xs text-cyan-100"
|
||||
>
|
||||
You are joining through a shared OpenSquawk link.
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="waitlistCaptchaVisible"
|
||||
class="space-y-2 rounded-2xl border border-white/10 bg-white/5 p-4 text-xs text-white/60"
|
||||
@@ -680,7 +692,32 @@
|
||||
<p v-if="waitlistSuccess" class="text-sm text-green-300">
|
||||
Thank you! You are on the waitlist and we will reach out as soon as slots open up.
|
||||
</p>
|
||||
<p v-else-if="waitlistError" class="text-sm text-red-300">{{ waitlistError }}</p>
|
||||
<div
|
||||
v-if="waitlistSuccess && waitlistReferralUrl"
|
||||
class="space-y-3 rounded-2xl border border-cyan-300/35 bg-cyan-400/10 p-4"
|
||||
>
|
||||
<p class="text-sm font-medium text-cyan-100">Share your personal waitlist link</p>
|
||||
<div class="rounded-xl border border-white/15 bg-[#0b1020]/70 px-3 py-2 text-xs text-cyan-100 break-all">
|
||||
{{ waitlistReferralUrl }}
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<button type="button" class="btn btn-ghost w-full" @click="copyWaitlistReferralLink">
|
||||
<v-icon icon="mdi-content-copy" size="18" />
|
||||
Copy link
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost w-full"
|
||||
:disabled="!supportsNativeShare"
|
||||
@click="shareWaitlistReferralLink"
|
||||
>
|
||||
<v-icon icon="mdi-share-variant" size="18" />
|
||||
Share
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="waitlistShareNotice" class="text-xs text-cyan-100/90">{{ waitlistShareNotice }}</p>
|
||||
</div>
|
||||
<p v-if="!waitlistSuccess && waitlistError" class="text-sm text-red-300">{{ waitlistError }}</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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<WaitlistStats | null>(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<WaitlistJoinResponse>(
|
||||
'/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)
|
||||
|
||||
@@ -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) => {
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -43,7 +43,6 @@ export default defineEventHandler(async () => {
|
||||
}
|
||||
|
||||
const sentAt = new Date()
|
||||
|
||||
if (!invitation) {
|
||||
const code = generateInvitationCode()
|
||||
invitation = await InvitationCode.create({
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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<WaitlistRequestBody>(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),
|
||||
}
|
||||
})
|
||||
|
||||
40
server/api/service/waitlist/referral-event.post.ts
Normal file
40
server/api/service/waitlist/referral-event.post.ts
Normal file
@@ -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<ReferralEventRequestBody>(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',
|
||||
}
|
||||
})
|
||||
@@ -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<WaitlistEntryDocument>({
|
||||
@@ -32,9 +37,13 @@ const waitlistSchema = new mongoose.Schema<WaitlistEntryDocument>({
|
||||
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<WaitlistEntryDocument> | undefined) ||
|
||||
mongoose.model<WaitlistEntryDocument>('WaitlistEntry', waitlistSchema)
|
||||
|
||||
|
||||
20
server/utils/waitlistReferrals.ts
Normal file
20
server/utils/waitlistReferrals.ts
Normal file
@@ -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
|
||||
}
|
||||
28
tests/server/waitlistReferrals.test.ts
Normal file
28
tests/server/waitlistReferrals.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user