diff --git a/playwright-report/index.html b/playwright-report/index.html index 74977ee..49893aa 100644 --- a/playwright-report/index.html +++ b/playwright-report/index.html @@ -82,4 +82,4 @@ Error generating stack: `+n.message+`
- \ No newline at end of file + \ No newline at end of file diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index 948829f..ccc2744 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -2553,6 +2553,8 @@ "title": "QR-Code & Druck-Layouts", "heroTitle": "Einlass-QR-Code", "description": "Scannen, um zur Gäste-App zu gelangen.", + "expiresAt": "Gültig bis {{date}}", + "noExpiry": "Gültig ohne Ablaufdatum", "qrAlt": "QR-Code", "previewAlt": "QR-Layout Vorschau", "bottomNote": "Unterer Hinweistext", diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index f477bb1..b43eac2 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -2557,6 +2557,8 @@ "title": "QR Code & Print Layouts", "heroTitle": "Entrance QR Code", "description": "Scan to access the event guest app.", + "expiresAt": "Valid until {{date}}", + "noExpiry": "No expiry configured", "qrAlt": "QR code", "previewAlt": "QR layout preview", "bottomNote": "Bottom note text", diff --git a/resources/js/admin/mobile/QrPrintPage.tsx b/resources/js/admin/mobile/QrPrintPage.tsx index 635962e..4aaa4a3 100644 --- a/resources/js/admin/mobile/QrPrintPage.tsx +++ b/resources/js/admin/mobile/QrPrintPage.tsx @@ -132,6 +132,13 @@ export default function MobileQrPrintPage() { {qrUrl} ) : null} + + {selectedInvite?.expires_at + ? t('events.qr.expiresAt', 'Valid until {{date}}', { + date: formatExpiry(selectedInvite.expires_at), + }) + : t('events.qr.noExpiry', 'No expiry configured')} + {t('events.qr.description', 'Scan to access the event guest app.')} @@ -238,6 +245,12 @@ export default function MobileQrPrintPage() { ); } +function formatExpiry(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString(undefined, { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' }); +} + function FormatSelection({ layouts, selectedFormat, diff --git a/resources/js/admin/mobile/__tests__/QrPrintPage.test.tsx b/resources/js/admin/mobile/__tests__/QrPrintPage.test.tsx new file mode 100644 index 0000000..80c5442 --- /dev/null +++ b/resources/js/admin/mobile/__tests__/QrPrintPage.test.tsx @@ -0,0 +1,124 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +const backMock = vi.fn(); +const navigateMock = vi.fn(); + +vi.mock('react-router-dom', () => ({ + useNavigate: () => navigateMock, + useParams: () => ({ slug: 'demo-event' }), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock('../hooks/useBackNavigation', () => ({ + useBackNavigation: () => backMock, +})); + +vi.mock('../../api', () => ({ + getEvent: vi.fn().mockResolvedValue({ slug: 'demo-event', name: 'Demo' }), + getEventQrInvites: vi.fn().mockResolvedValue([ + { + id: 1, + token: 'demo-token', + url: 'https://example.test/g/demo-token', + label: null, + qr_code_data_url: 'data:image/png;base64,abc', + usage_limit: null, + usage_count: 0, + expires_at: '2026-01-12T12:00:00Z', + revoked_at: null, + is_active: true, + created_at: null, + metadata: {}, + layouts_url: null, + layouts: [ + { + id: 'layout-1', + name: 'Poster', + description: '', + subtitle: '', + formats: [], + preview: { + background: null, + background_gradient: null, + accent: null, + text: null, + }, + paper: 'a4', + orientation: 'portrait', + panel_mode: 'single', + }, + ], + }, + ]), + createQrInvite: vi.fn(), +})); + +vi.mock('react-hot-toast', () => ({ + default: { + error: vi.fn(), + success: vi.fn(), + }, +})); + +vi.mock('@tamagui/react-native-web-lite', () => ({ + Pressable: ({ children, onPress }: { children: React.ReactNode; onPress?: () => void }) => ( + + ), +})); + +vi.mock('../components/MobileShell', () => ({ + MobileShell: ({ children }: { children: React.ReactNode }) =>
{children}
, + HeaderActionButton: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock('../components/Primitives', () => ({ + MobileCard: ({ children }: { children: React.ReactNode }) =>
{children}
, + CTAButton: ({ label, onPress }: { label: string; onPress?: () => void }) => ( + + ), + PillBadge: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock('@tamagui/stacks', () => ({ + YStack: ({ children }: { children: React.ReactNode }) =>
{children}
, + XStack: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock('@tamagui/text', () => ({ + SizableText: ({ children }: { children: React.ReactNode }) => {children}, +})); + +vi.mock('../theme', () => ({ + useAdminTheme: () => ({ + textStrong: '#111827', + text: '#111827', + muted: '#6b7280', + subtle: '#94a3b8', + border: '#e5e7eb', + surfaceMuted: '#fffdfb', + primary: '#ff5a5f', + danger: '#b91c1c', + accentSoft: '#ffe5ec', + }), +})); + +import MobileQrPrintPage from '../QrPrintPage'; + +describe('MobileQrPrintPage', () => { + it('shows token expiry info when the invite has expires_at', async () => { + render(); + + expect(await screen.findByText('events.qr.expiresAt')).toBeInTheDocument(); + }); +});