feat: use new invite status endpoint to display different invite type counts
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { getInvitesApi, createInviteApi, deleteInviteApi } from '@/api/invites'
|
||||
import type { Invite } from '@/types/invite'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { getInvitesApi, getInviteStatusApi, createInviteApi, deleteInviteApi } from '@/api/invites'
|
||||
import type { Invite, InviteStatusCounts } from '@/types/invite'
|
||||
|
||||
type InviteStatus = 'active' | 'used' | 'expired'
|
||||
|
||||
@@ -17,16 +17,24 @@ const error = ref('')
|
||||
const pendingDeleteId = ref<string | null>(null)
|
||||
const deletingId = 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 sharedTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const activeCount = computed(
|
||||
() => invites.value.filter((invite) => inviteStatus(invite) === 'active').length,
|
||||
const totalInvites = computed(() =>
|
||||
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 showPagination = computed(() => totalPages.value > 1)
|
||||
|
||||
onMounted(() => {
|
||||
void loadStatusCounts()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
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) {
|
||||
if (target < 1 || target > totalPages.value || target === page.value || isLoading.value) {
|
||||
return
|
||||
@@ -82,6 +100,7 @@ async function handleCreate() {
|
||||
const invite = await createInviteApi()
|
||||
page.value = 1
|
||||
await loadInvites()
|
||||
await loadStatusCounts()
|
||||
// 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)
|
||||
@@ -118,6 +137,7 @@ async function confirmDelete(id: string) {
|
||||
page.value -= 1
|
||||
}
|
||||
await loadInvites()
|
||||
await loadStatusCounts()
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to delete invite'
|
||||
} finally {
|
||||
@@ -222,7 +242,12 @@ function inviteDetail(invite: Invite): string {
|
||||
>
|
||||
<span class="invites-toggle-label">
|
||||
<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 class="chevron" :class="{ 'is-open': expanded }" aria-hidden="true">⌄</span>
|
||||
</button>
|
||||
@@ -345,6 +370,7 @@ function inviteDetail(invite: Invite): string {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.invites-toggle-label h4 {
|
||||
@@ -352,6 +378,13 @@ function inviteDetail(invite: Invite): string {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.invites-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.invites-count-badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
@@ -361,6 +394,21 @@ function inviteDetail(invite: Invite): string {
|
||||
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 {
|
||||
color: var(--c-text-soft);
|
||||
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.
|
||||
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', () => {
|
||||
@@ -30,6 +37,115 @@ describe('InvitesPanel', () => {
|
||||
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 () => {
|
||||
const mockInvites: Invite[] = [
|
||||
{ id: 'invite-1', code: 'ABC123', expires_at: '2099-01-01T00:00:00.000Z' },
|
||||
|
||||
Reference in New Issue
Block a user