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"
@@ -8,6 +8,12 @@ describe('InvitesPanel', () => {
beforeEach(() => {
vi.restoreAllMocks()
Object.defineProperty(navigator, 'onLine', { value: true, configurable: true })
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: vi.fn<(text: string) => Promise<void>>().mockResolvedValue(undefined) },
configurable: true,
})
// jsdom has no Web Share API; tests that want it define it explicitly.
Reflect.deleteProperty(navigator, 'share')
})
it('renders collapsed by default without loading invites', () => {
@@ -78,6 +84,60 @@ describe('InvitesPanel', () => {
expect(wrapper.text()).toContain('NEWCODE1')
})
it('shares the newly created invite automatically', async () => {
vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce([])
const newInvite: Invite = { id: 'invite-new', code: 'NEWCODE1' }
vi.spyOn(invitesApi, 'createInviteApi').mockResolvedValueOnce(newInvite)
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
await flushPromises()
await wrapper.find('.generate-btn').trigger('click')
await flushPromises()
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
expect.stringContaining('?invite=NEWCODE1'),
)
})
it('shares an existing invite link via the clipboard when Web Share is unavailable', async () => {
const mockInvite: Invite = { id: 'invite-1', code: 'ABC123' }
vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce([mockInvite])
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
await flushPromises()
await wrapper.find('.ticket-btn').trigger('click')
await flushPromises()
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
expect.stringContaining('?invite=ABC123'),
)
expect(wrapper.text()).toContain('Copied!')
})
it('uses the Web Share API instead of the clipboard when available', async () => {
const shareMock = vi.fn<(data: ShareData) => Promise<void>>().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'share', { value: shareMock, configurable: true })
const mockInvite: Invite = { id: 'invite-1', code: 'ABC123' }
vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce([mockInvite])
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
await flushPromises()
await wrapper.find('.ticket-btn').trigger('click')
await flushPromises()
expect(shareMock).toHaveBeenCalledWith(
expect.objectContaining({ url: expect.stringContaining('?invite=ABC123') }),
)
expect(navigator.clipboard.writeText).not.toHaveBeenCalled()
})
it('deletes an invite after confirming', async () => {
const mockInvite: Invite = { id: 'invite-1', code: 'ABC123' }
vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce([mockInvite])