feat: registration with invite codes

This commit is contained in:
2026-08-31 21:35:37 +02:00
parent 68991a3722
commit 2c45478478
9 changed files with 645 additions and 11 deletions
+35 -11
View File
@@ -12,17 +12,17 @@ const isCreating = ref(false)
const error = ref('')
const pendingDeleteId = ref<string | null>(null)
const deletingId = ref<string | null>(null)
const copiedId = ref<string | null>(null)
const sharedId = ref<string | null>(null)
let hasLoaded = false
let copiedTimeout: ReturnType<typeof setTimeout> | undefined
let sharedTimeout: ReturnType<typeof setTimeout> | undefined
const activeCount = computed(
() => invites.value.filter((invite) => inviteStatus(invite) === 'active').length,
)
onBeforeUnmount(() => {
clearTimeout(copiedTimeout)
clearTimeout(sharedTimeout)
})
function isOffline(): boolean {
@@ -66,6 +66,9 @@ async function handleCreate() {
const invite = await createInviteApi()
invites.value = [invite, ...invites.value]
hasLoaded = true
// Sharing is the whole point of an invite, so offer it immediately
// instead of making the user hunt for the Share button afterwards.
await shareInvite(invite)
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to create invite'
} finally {
@@ -102,13 +105,34 @@ async function confirmDelete(id: string) {
}
}
async function copyCode(invite: Invite) {
// The link that lands someone on the (otherwise unlinked) register page —
// see router.beforeEach, which redirects any URL carrying ?invite=... there.
function getInviteUrl(code: string): string {
const base = `${window.location.origin}${import.meta.env.BASE_URL}`
return `${base}?invite=${encodeURIComponent(code)}`
}
async function shareInvite(invite: Invite) {
const url = getInviteUrl(invite.code)
if (typeof navigator.share === 'function') {
try {
await navigator.share({ title: 'Join dttmr', text: 'Use this link to create your account', url })
return
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
return
}
// Web Share unsupported in this context; fall through to clipboard copy.
}
}
try {
await navigator.clipboard.writeText(invite.code)
copiedId.value = invite.id
clearTimeout(copiedTimeout)
copiedTimeout = setTimeout(() => {
copiedId.value = null
await navigator.clipboard.writeText(url)
sharedId.value = invite.id
clearTimeout(sharedTimeout)
sharedTimeout = setTimeout(() => {
sharedId.value = null
}, 1500)
} catch {
// Clipboard access denied or unavailable; nothing sensible to do.
@@ -203,8 +227,8 @@ function inviteDetail(invite: Invite): string {
<span class="invite-detail">{{ inviteDetail(invite) }}</span>
<div v-if="pendingDeleteId !== invite.id" class="invite-actions">
<button type="button" class="ticket-btn" @click="copyCode(invite)">
{{ copiedId === invite.id ? 'Copied!' : 'Copy' }}
<button type="button" class="ticket-btn" @click="shareInvite(invite)">
{{ sharedId === invite.id ? 'Copied!' : 'Share' }}
</button>
<button
type="button"