diff --git a/src/api/__tests__/invites.spec.ts b/src/api/__tests__/invites.spec.ts index 35cb970..c351cbd 100644 --- a/src/api/__tests__/invites.spec.ts +++ b/src/api/__tests__/invites.spec.ts @@ -21,12 +21,12 @@ describe('invites API', () => { localStorage.clear() }) - it('getInvitesApi sends GET to /user/invites and returns the invites', async () => { - const mockInvites = [{ id: 'invite-1', code: 'ABC123' }] + it('getInvitesApi sends GET to /user/invites and returns the paginated result', async () => { + const mockResponse = { data: [{ id: 'invite-1', code: 'ABC123' }], total: 1, count: 1 } const fetchMock = vi.fn().mockResolvedValueOnce({ ok: true, status: 200, - json: async () => mockInvites, + json: async () => mockResponse, } as unknown as Response) global.fetch = fetchMock @@ -36,7 +36,24 @@ describe('invites API', () => { `${API_BASE_URL}/user/invites`, expect.objectContaining({ method: 'GET' }), ) - expect(result).toEqual(mockInvites) + expect(result).toEqual(mockResponse) + }) + + it('getInvitesApi sends page and count as query params when given', async () => { + const mockResponse = { data: [], total: 0, count: 10 } + const fetchMock = vi.fn().mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => mockResponse, + } as unknown as Response) + global.fetch = fetchMock + + await getInvitesApi({ page: 2, count: 10 }) + + expect(fetchMock).toHaveBeenCalledWith( + `${API_BASE_URL}/user/invites?page=2&count=10`, + expect.objectContaining({ method: 'GET' }), + ) }) it('getInvitesApi throws on failure', async () => { diff --git a/src/api/invites.ts b/src/api/invites.ts index eb529de..891521b 100644 --- a/src/api/invites.ts +++ b/src/api/invites.ts @@ -1,9 +1,19 @@ import { apiClient } from '@/api/client' import { extractErrorMessage } from '@/api/http' -import type { Invite } from '@/types/invite' +import type { Invite, PaginatedInvites } from '@/types/invite' -export async function getInvitesApi(): Promise { - const response = await apiClient.get('/user/invites') +export interface GetInvitesParams { + page?: number + count?: number +} + +export async function getInvitesApi(params: GetInvitesParams = {}): Promise { + const query = new URLSearchParams() + if (params.page !== undefined) query.set('page', String(params.page)) + if (params.count !== undefined) query.set('count', String(params.count)) + const queryString = query.toString() + + const response = await apiClient.get(`/user/invites${queryString ? `?${queryString}` : ''}`) if (!response.ok) { throw new Error(await extractErrorMessage(response, 'Failed to load invites')) } diff --git a/src/assets/main.css b/src/assets/main.css index bd7fa66..a9142bb 100644 --- a/src/assets/main.css +++ b/src/assets/main.css @@ -130,6 +130,7 @@ } .page { + width: 100%; min-height: 100vh; padding: 1rem 1rem calc(var(--nav-height) + var(--safe-bottom) + 1.5rem); padding-top: calc(1rem + var(--safe-top)); diff --git a/src/components/InvitesPanel.vue b/src/components/InvitesPanel.vue index 5c9d42c..c899ed6 100644 --- a/src/components/InvitesPanel.vue +++ b/src/components/InvitesPanel.vue @@ -5,8 +5,12 @@ import type { Invite } from '@/types/invite' type InviteStatus = 'active' | 'used' | 'expired' +const PAGE_SIZE = 10 + const expanded = ref(false) const invites = ref([]) +const page = ref(1) +const total = ref(0) const isLoading = ref(false) const isCreating = ref(false) const error = ref('') @@ -20,6 +24,8 @@ let sharedTimeout: ReturnType | undefined const activeCount = computed( () => invites.value.filter((invite) => inviteStatus(invite) === 'active').length, ) +const totalPages = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE))) +const showPagination = computed(() => totalPages.value > 1) onBeforeUnmount(() => { clearTimeout(sharedTimeout) @@ -45,7 +51,9 @@ async function loadInvites() { isLoading.value = true try { - invites.value = await getInvitesApi() + const response = await getInvitesApi({ page: page.value, count: PAGE_SIZE }) + invites.value = response.data + total.value = response.total hasLoaded = true } catch (err) { error.value = err instanceof Error ? err.message : 'Failed to load invites' @@ -54,6 +62,14 @@ async function loadInvites() { } } +async function goToPage(target: number) { + if (target < 1 || target > totalPages.value || target === page.value || isLoading.value) { + return + } + page.value = target + await loadInvites() +} + async function handleCreate() { error.value = '' if (isOffline()) { @@ -64,8 +80,8 @@ async function handleCreate() { isCreating.value = true try { const invite = await createInviteApi() - invites.value = [invite, ...invites.value] - hasLoaded = true + page.value = 1 + await loadInvites() // 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) @@ -96,7 +112,12 @@ async function confirmDelete(id: string) { deletingId.value = id try { await deleteInviteApi(id) - invites.value = invites.value.filter((invite) => invite.id !== id) + // Deleted the only item on a page past the first: step back a page + // instead of reloading into a stranded, empty page. + if (invites.value.length === 1 && page.value > 1) { + page.value -= 1 + } + await loadInvites() } catch (err) { error.value = err instanceof Error ? err.message : 'Failed to delete invite' } finally { @@ -258,6 +279,28 @@ function inviteDetail(invite: Invite): string {

No invites yet. Generate one to invite someone.

+
+ + {{ total }} invites total + +
+