From 9abd66fdbaa1e7fa98c3d0b7e1d4d6e1c7d39f2e Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Tue, 1 Sep 2026 08:44:39 +0200 Subject: [PATCH 1/3] fix: viewport will no longer overflow in width --- src/assets/main.css | 1 + 1 file changed, 1 insertion(+) 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)); From a30101f8416664865d9d04f10f6a1f5d9d3ed865 Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Tue, 1 Sep 2026 08:58:38 +0200 Subject: [PATCH 2/3] fix: styling for AccountsView on desktop and mobile --- src/components/InvitesPanel.vue | 6 ++++++ src/views/AccountView.vue | 12 +++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/components/InvitesPanel.vue b/src/components/InvitesPanel.vue index 5c9d42c..41e9554 100644 --- a/src/components/InvitesPanel.vue +++ b/src/components/InvitesPanel.vue @@ -384,6 +384,12 @@ function inviteDetail(invite: Invite): string { text-overflow: ellipsis; } +@media (min-width: 768px) { + .invite-code { + max-width: 80ch; + } +} + .invite-status-pill { flex-shrink: 0; font-size: 0.65rem; diff --git a/src/views/AccountView.vue b/src/views/AccountView.vue index ac13f91..53df3d2 100644 --- a/src/views/AccountView.vue +++ b/src/views/AccountView.vue @@ -37,7 +37,7 @@ onMounted(() => { -

Pending changes

+

Pending changes

Sync status

@@ -56,11 +56,21 @@ onMounted(() => { diff --git a/src/components/__tests__/InvitesPanel.spec.ts b/src/components/__tests__/InvitesPanel.spec.ts index 22dcc89..fe50ae3 100644 --- a/src/components/__tests__/InvitesPanel.spec.ts +++ b/src/components/__tests__/InvitesPanel.spec.ts @@ -2,7 +2,11 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import { mount } from '@vue/test-utils' import InvitesPanel from '../InvitesPanel.vue' import * as invitesApi from '@/api/invites' -import type { Invite } from '@/types/invite' +import type { Invite, PaginatedInvites } from '@/types/invite' + +function paginated(data: Invite[], total = data.length): PaginatedInvites { + return { data, total, count: data.length } +} describe('InvitesPanel', () => { beforeEach(() => { @@ -36,12 +40,15 @@ describe('InvitesPanel', () => { consumed_at: '2026-01-01T00:00:00.000Z', }, ] - vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce(mockInvites) + const getSpy = vi + .spyOn(invitesApi, 'getInvitesApi') + .mockResolvedValueOnce(paginated(mockInvites)) const wrapper = mount(InvitesPanel) await wrapper.find('.invites-toggle').trigger('click') await flushPromises() + expect(getSpy).toHaveBeenCalledWith({ page: 1, count: 10 }) expect(wrapper.text()).toContain('ABC123') expect(wrapper.text()).toContain('USEDCODE') expect(wrapper.text()).toContain('Active') @@ -60,7 +67,7 @@ describe('InvitesPanel', () => { }) it('shows empty state when there are no invites', async () => { - vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce([]) + vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce(paginated([])) const wrapper = mount(InvitesPanel) await wrapper.find('.invites-toggle').trigger('click') @@ -69,9 +76,60 @@ describe('InvitesPanel', () => { expect(wrapper.text()).toContain('No invites yet') }) - it('generates a new invite and prepends it to the list', async () => { - vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce([]) + it('does not show pagination controls when everything fits on one page', async () => { + vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce( + paginated([{ id: 'invite-1', code: 'ABC123' }], 1), + ) + + const wrapper = mount(InvitesPanel) + await wrapper.find('.invites-toggle').trigger('click') + await flushPromises() + + expect(wrapper.find('.invites-pagination').exists()).toBe(false) + }) + + it('shows pagination controls and total count when there is more than one page', async () => { + const page1 = Array.from({ length: 10 }, (_, i) => ({ id: `invite-${i}`, code: `CODE${i}` })) + vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce(paginated(page1, 15)) + + const wrapper = mount(InvitesPanel) + await wrapper.find('.invites-toggle').trigger('click') + await flushPromises() + + expect(wrapper.find('.invites-pagination').text()).toContain('15 invites total') + const [prevBtn, nextBtn] = wrapper.findAll('.page-btn') + expect((prevBtn!.element as HTMLButtonElement).disabled).toBe(true) + expect((nextBtn!.element as HTMLButtonElement).disabled).toBe(false) + }) + + it('navigates to the next page when the forward button is clicked', async () => { + const page1 = Array.from({ length: 10 }, (_, i) => ({ id: `invite-${i}`, code: `CODE${i}` })) + const page2 = [{ id: 'invite-10', code: 'CODE10' }] + const getSpy = 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() + + const [, nextBtn] = wrapper.findAll('.page-btn') + await nextBtn?.trigger('click') + await flushPromises() + + expect(getSpy).toHaveBeenLastCalledWith({ page: 2, count: 10 }) + expect(wrapper.text()).toContain('CODE10') + const [prevBtn, nextBtnAfter] = wrapper.findAll('.page-btn') + expect((prevBtn!.element as HTMLButtonElement).disabled).toBe(false) + expect((nextBtnAfter!.element as HTMLButtonElement).disabled).toBe(true) + }) + + it('generates a new invite, reloads page one, and prepends it to the list', async () => { 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) @@ -85,8 +143,10 @@ describe('InvitesPanel', () => { }) it('shares the newly created invite automatically', async () => { - vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce([]) 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) @@ -103,7 +163,7 @@ describe('InvitesPanel', () => { 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]) + vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce(paginated([mockInvite])) const wrapper = mount(InvitesPanel) await wrapper.find('.invites-toggle').trigger('click') @@ -123,7 +183,7 @@ describe('InvitesPanel', () => { Object.defineProperty(navigator, 'share', { value: shareMock, configurable: true }) const mockInvite: Invite = { id: 'invite-1', code: 'ABC123' } - vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce([mockInvite]) + vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce(paginated([mockInvite])) const wrapper = mount(InvitesPanel) await wrapper.find('.invites-toggle').trigger('click') @@ -140,7 +200,9 @@ describe('InvitesPanel', () => { it('deletes an invite after confirming', async () => { const mockInvite: Invite = { id: 'invite-1', code: 'ABC123' } - vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce([mockInvite]) + vi.spyOn(invitesApi, 'getInvitesApi') + .mockResolvedValueOnce(paginated([mockInvite])) + .mockResolvedValueOnce(paginated([])) const deleteSpy = vi.spyOn(invitesApi, 'deleteInviteApi').mockResolvedValueOnce() const wrapper = mount(InvitesPanel) @@ -158,13 +220,39 @@ describe('InvitesPanel', () => { expect(wrapper.text()).toContain('No invites yet') }) + it('steps back a page when deleting the last item on a page past the first', async () => { + const page1 = Array.from({ length: 10 }, (_, i) => ({ id: `invite-${i}`, code: `CODE${i}` })) + const page2 = [{ id: 'invite-10', code: 'CODE10' }] + const getSpy = vi + .spyOn(invitesApi, 'getInvitesApi') + .mockResolvedValueOnce(paginated(page1, 11)) + .mockResolvedValueOnce(paginated(page2, 11)) + .mockResolvedValueOnce(paginated(page1, 10)) + vi.spyOn(invitesApi, 'deleteInviteApi').mockResolvedValueOnce() + + const wrapper = mount(InvitesPanel) + await wrapper.find('.invites-toggle').trigger('click') + await flushPromises() + + const [, nextBtn] = wrapper.findAll('.page-btn') + await nextBtn?.trigger('click') + await flushPromises() + + 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(getSpy).toHaveBeenLastCalledWith({ page: 1, count: 10 }) + }) + it('disables delete for already-used invites', async () => { const usedInvite: Invite = { id: 'invite-1', code: 'USEDCODE', consumed_at: '2026-01-01T00:00:00.000Z', } - vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce([usedInvite]) + vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce(paginated([usedInvite])) const wrapper = mount(InvitesPanel) await wrapper.find('.invites-toggle').trigger('click') diff --git a/src/types/invite.ts b/src/types/invite.ts index cbf046f..2ff39cc 100644 --- a/src/types/invite.ts +++ b/src/types/invite.ts @@ -4,3 +4,9 @@ export interface Invite { expires_at?: string consumed_at?: string } + +export interface PaginatedInvites { + data: Invite[] + total: number + count: number +}