feat: use new invite status endpoint to display different invite type counts

This commit is contained in:
2026-09-01 16:34:13 +02:00
parent 6a1624c616
commit 16405ec5f7
5 changed files with 213 additions and 8 deletions
+28 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia' import { setActivePinia, createPinia } from 'pinia'
import { getInvitesApi, createInviteApi, deleteInviteApi } from '../invites' import { getInvitesApi, getInviteStatusApi, createInviteApi, deleteInviteApi } from '../invites'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { API_BASE_URL } from '@/api/http' import { API_BASE_URL } from '@/api/http'
@@ -65,6 +65,33 @@ describe('invites API', () => {
await expect(getInvitesApi()).rejects.toThrow('Failed') await expect(getInvitesApi()).rejects.toThrow('Failed')
}) })
it('getInviteStatusApi sends GET to /user/invites/status and returns the counts', async () => {
const mockCounts = { active: 12, expired: 2, used: 8 }
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => mockCounts,
} as unknown as Response)
global.fetch = fetchMock
const result = await getInviteStatusApi()
expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/user/invites/status`,
expect.objectContaining({ method: 'GET' }),
)
expect(result).toEqual(mockCounts)
})
it('getInviteStatusApi throws on failure', async () => {
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: false,
json: async () => ({ message: 'Failed to load invite counts' }),
} as unknown as Response)
await expect(getInviteStatusApi()).rejects.toThrow('Failed to load invite counts')
})
it('createInviteApi sends POST to /user/invites and returns the created invite', async () => { it('createInviteApi sends POST to /user/invites and returns the created invite', async () => {
const mockInvite = { id: 'invite-1', code: 'ABC123' } const mockInvite = { id: 'invite-1', code: 'ABC123' }
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({ const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
+9 -1
View File
@@ -1,6 +1,6 @@
import { apiClient } from '@/api/client' import { apiClient } from '@/api/client'
import { extractErrorMessage } from '@/api/http' import { extractErrorMessage } from '@/api/http'
import type { Invite, PaginatedInvites } from '@/types/invite' import type { Invite, InviteStatusCounts, PaginatedInvites } from '@/types/invite'
export interface GetInvitesParams { export interface GetInvitesParams {
page?: number page?: number
@@ -20,6 +20,14 @@ export async function getInvitesApi(params: GetInvitesParams = {}): Promise<Pagi
return response.json() return response.json()
} }
export async function getInviteStatusApi(): Promise<InviteStatusCounts> {
const response = await apiClient.get('/user/invites/status')
if (!response.ok) {
throw new Error(await extractErrorMessage(response, 'Failed to load invite counts'))
}
return response.json()
}
export async function createInviteApi(): Promise<Invite> { export async function createInviteApi(): Promise<Invite> {
const response = await apiClient.post('/user/invites') const response = await apiClient.post('/user/invites')
if (!response.ok) { if (!response.ok) {
+54 -6
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { getInvitesApi, createInviteApi, deleteInviteApi } from '@/api/invites' import { getInvitesApi, getInviteStatusApi, createInviteApi, deleteInviteApi } from '@/api/invites'
import type { Invite } from '@/types/invite' import type { Invite, InviteStatusCounts } from '@/types/invite'
type InviteStatus = 'active' | 'used' | 'expired' type InviteStatus = 'active' | 'used' | 'expired'
@@ -17,16 +17,24 @@ const error = ref('')
const pendingDeleteId = ref<string | null>(null) const pendingDeleteId = ref<string | null>(null)
const deletingId = ref<string | null>(null) const deletingId = ref<string | null>(null)
const sharedId = ref<string | null>(null) const sharedId = ref<string | null>(null)
// Counts across ALL invites (not just the current page)
const statusCounts = ref<InviteStatusCounts | null>(null)
let hasLoaded = false let hasLoaded = false
let sharedTimeout: ReturnType<typeof setTimeout> | undefined let sharedTimeout: ReturnType<typeof setTimeout> | undefined
const activeCount = computed( const totalInvites = computed(() =>
() => invites.value.filter((invite) => inviteStatus(invite) === 'active').length, statusCounts.value
? statusCounts.value.active + statusCounts.value.expired + statusCounts.value.used
: null,
) )
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE))) const totalPages = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))
const showPagination = computed(() => totalPages.value > 1) const showPagination = computed(() => totalPages.value > 1)
onMounted(() => {
void loadStatusCounts()
})
onBeforeUnmount(() => { onBeforeUnmount(() => {
clearTimeout(sharedTimeout) clearTimeout(sharedTimeout)
}) })
@@ -62,6 +70,16 @@ async function loadInvites() {
} }
} }
async function loadStatusCounts() {
if (isOffline()) return
try {
statusCounts.value = await getInviteStatusApi()
} catch {
// Non-critical: the header badges just stay hidden until the next
// successful fetch instead of blocking the rest of the panel.
}
}
async function goToPage(target: number) { async function goToPage(target: number) {
if (target < 1 || target > totalPages.value || target === page.value || isLoading.value) { if (target < 1 || target > totalPages.value || target === page.value || isLoading.value) {
return return
@@ -82,6 +100,7 @@ async function handleCreate() {
const invite = await createInviteApi() const invite = await createInviteApi()
page.value = 1 page.value = 1
await loadInvites() await loadInvites()
await loadStatusCounts()
// Sharing is the whole point of an invite, so offer it immediately // Sharing is the whole point of an invite, so offer it immediately
// instead of making the user hunt for the Share button afterwards. // instead of making the user hunt for the Share button afterwards.
await shareInvite(invite) await shareInvite(invite)
@@ -118,6 +137,7 @@ async function confirmDelete(id: string) {
page.value -= 1 page.value -= 1
} }
await loadInvites() await loadInvites()
await loadStatusCounts()
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to delete invite' error.value = err instanceof Error ? err.message : 'Failed to delete invite'
} finally { } finally {
@@ -222,7 +242,12 @@ function inviteDetail(invite: Invite): string {
> >
<span class="invites-toggle-label"> <span class="invites-toggle-label">
<h4>Invites</h4> <h4>Invites</h4>
<span v-if="activeCount > 0" class="invites-count-badge">{{ activeCount }} active</span> <span v-if="statusCounts" class="invites-stats">
<span class="invites-count-badge">{{ statusCounts.active }} active</span>
<span class="invites-count-badge badge-expired">{{ statusCounts.expired }} expired</span>
<span class="invites-count-badge badge-used">{{ statusCounts.used }} used</span>
<span class="invites-total-label">{{ totalInvites }} total</span>
</span>
</span> </span>
<span class="chevron" :class="{ 'is-open': expanded }" aria-hidden="true"></span> <span class="chevron" :class="{ 'is-open': expanded }" aria-hidden="true"></span>
</button> </button>
@@ -345,6 +370,7 @@ function inviteDetail(invite: Invite): string {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
flex-wrap: wrap;
} }
.invites-toggle-label h4 { .invites-toggle-label h4 {
@@ -352,6 +378,13 @@ function inviteDetail(invite: Invite): string {
margin: 0; margin: 0;
} }
.invites-stats {
display: flex;
align-items: center;
gap: 0.35rem;
flex-wrap: wrap;
}
.invites-count-badge { .invites-count-badge {
font-size: 0.65rem; font-size: 0.65rem;
font-weight: 600; font-weight: 600;
@@ -361,6 +394,21 @@ function inviteDetail(invite: Invite): string {
color: var(--c-accent-strong); color: var(--c-accent-strong);
} }
.invites-count-badge.badge-expired {
background-color: var(--c-danger-bg);
color: var(--c-danger);
}
.invites-count-badge.badge-used {
background-color: var(--c-bg-elevated);
color: var(--c-text-soft);
}
.invites-total-label {
font-size: 0.65rem;
color: var(--c-text-soft);
}
.chevron { .chevron {
color: var(--c-text-soft); color: var(--c-text-soft);
transition: transform 0.15s ease-in-out; transition: transform 0.15s ease-in-out;
@@ -18,6 +18,13 @@ describe('InvitesPanel', () => {
}) })
// jsdom has no Web Share API; tests that want it define it explicitly. // jsdom has no Web Share API; tests that want it define it explicitly.
Reflect.deleteProperty(navigator, 'share') Reflect.deleteProperty(navigator, 'share')
// Every mount fetches status counts once on mount; give it a harmless
// default so tests that don't care about counts don't hit real fetch.
vi.spyOn(invitesApi, 'getInviteStatusApi').mockResolvedValue({
active: 0,
expired: 0,
used: 0,
})
}) })
it('renders collapsed by default without loading invites', () => { it('renders collapsed by default without loading invites', () => {
@@ -30,6 +37,115 @@ describe('InvitesPanel', () => {
expect(getSpy).not.toHaveBeenCalled() expect(getSpy).not.toHaveBeenCalled()
}) })
it('fetches invite status counts on mount and shows them in the header while collapsed', async () => {
const statusSpy = vi.spyOn(invitesApi, 'getInviteStatusApi').mockResolvedValueOnce({
active: 12,
expired: 2,
used: 8,
})
const wrapper = mount(InvitesPanel)
await flushPromises()
expect(statusSpy).toHaveBeenCalledTimes(1)
expect(wrapper.find('#invites-panel').exists()).toBe(false)
expect(wrapper.text()).toContain('12 active')
expect(wrapper.text()).toContain('2 expired')
expect(wrapper.text()).toContain('8 used')
expect(wrapper.text()).toContain('22 total')
})
it('does not fetch status counts on mount when offline', () => {
Object.defineProperty(navigator, 'onLine', { value: false, configurable: true })
const statusSpy = vi.spyOn(invitesApi, 'getInviteStatusApi')
mount(InvitesPanel)
expect(statusSpy).not.toHaveBeenCalled()
})
it('hides status badges without an error banner when the status endpoint fails', async () => {
vi.spyOn(invitesApi, 'getInviteStatusApi').mockRejectedValueOnce(new Error('boom'))
const wrapper = mount(InvitesPanel)
await flushPromises()
expect(wrapper.find('.invites-stats').exists()).toBe(false)
expect(wrapper.find('.banner-error').exists()).toBe(false)
})
it('does not refetch status counts when paginating', async () => {
const statusSpy = vi.spyOn(invitesApi, 'getInviteStatusApi').mockResolvedValue({
active: 11,
expired: 0,
used: 0,
})
const page1 = Array.from({ length: 10 }, (_, i) => ({ id: `invite-${i}`, code: `CODE${i}` }))
const page2 = [{ id: 'invite-10', code: 'CODE10' }]
vi.spyOn(invitesApi, 'getInvitesApi')
.mockResolvedValueOnce(paginated(page1, 11))
.mockResolvedValueOnce(paginated(page2, 11))
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
await flushPromises()
expect(statusSpy).toHaveBeenCalledTimes(1)
const [, nextBtn] = wrapper.findAll('.page-btn')
await nextBtn?.trigger('click')
await flushPromises()
expect(statusSpy).toHaveBeenCalledTimes(1)
})
it('refetches status counts after creating an invite', async () => {
const statusSpy = vi.spyOn(invitesApi, 'getInviteStatusApi').mockResolvedValue({
active: 1,
expired: 0,
used: 0,
})
const newInvite: Invite = { id: 'invite-new', code: 'NEWCODE1' }
vi.spyOn(invitesApi, 'getInvitesApi')
.mockResolvedValueOnce(paginated([]))
.mockResolvedValueOnce(paginated([newInvite]))
vi.spyOn(invitesApi, 'createInviteApi').mockResolvedValueOnce(newInvite)
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
await flushPromises()
expect(statusSpy).toHaveBeenCalledTimes(1)
await wrapper.find('.generate-btn').trigger('click')
await flushPromises()
expect(statusSpy).toHaveBeenCalledTimes(2)
})
it('refetches status counts after deleting an invite', async () => {
const statusSpy = vi.spyOn(invitesApi, 'getInviteStatusApi').mockResolvedValue({
active: 0,
expired: 0,
used: 0,
})
const mockInvite: Invite = { id: 'invite-1', code: 'ABC123' }
vi.spyOn(invitesApi, 'getInvitesApi')
.mockResolvedValueOnce(paginated([mockInvite]))
.mockResolvedValueOnce(paginated([]))
vi.spyOn(invitesApi, 'deleteInviteApi').mockResolvedValueOnce()
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
await flushPromises()
expect(statusSpy).toHaveBeenCalledTimes(1)
await wrapper.find('.ticket-btn-danger').trigger('click')
const confirmButtons = wrapper.findAll('.ticket-btn-danger')
await confirmButtons[confirmButtons.length - 1]?.trigger('click')
await flushPromises()
expect(statusSpy).toHaveBeenCalledTimes(2)
})
it('loads and displays invites on expand', async () => { it('loads and displays invites on expand', async () => {
const mockInvites: Invite[] = [ const mockInvites: Invite[] = [
{ id: 'invite-1', code: 'ABC123', expires_at: '2099-01-01T00:00:00.000Z' }, { id: 'invite-1', code: 'ABC123', expires_at: '2099-01-01T00:00:00.000Z' },
+6
View File
@@ -10,3 +10,9 @@ export interface PaginatedInvites {
total: number total: number
count: number count: number
} }
export interface InviteStatusCounts {
active: number
expired: number
used: number
}