diff --git a/src/app/events/page.test.tsx b/src/app/events/page.test.tsx new file mode 100644 index 0000000..d2438b6 --- /dev/null +++ b/src/app/events/page.test.tsx @@ -0,0 +1,88 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import type { EventRecord, Paginated } from '@/lib/types'; + +const apiFetch = vi.fn(); + +vi.mock('@/lib/api', async (importOriginal) => ({ + ...(await importOriginal()), + apiFetch: (...args: unknown[]) => apiFetch(...args), +})); + +import EventsPage from './page'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +function event(overrides: Partial): EventRecord { + return { + id: 'e1', + organizationId: 'o1', + name: 'Lagos Jazz Night', + category: 'CONCERT', + venue: 'Eko Hall', + startsAt: '2030-01-01T20:00:00Z', + endsAt: null, + chainEventId: '1', + maxResaleMultiplierBps: 15000, + royaltyBps: 500, + status: 'PUBLISHED', + organization: { name: 'Afrobeats Co', slug: 'afrobeats' }, + ...overrides, + } as EventRecord; +} + +const page = (items: EventRecord[], total = items.length, n = 1): Paginated => ({ + items, + total, + page: n, + limit: 20, +}); + +let container: HTMLDivElement; +let root: Root; + +async function render() { + container = document.createElement('div'); + document.body.appendChild(container); + await act(async () => { + root = createRoot(container); + root.render(); + }); +} + +beforeEach(() => apiFetch.mockReset()); +afterEach(() => { + act(() => root.unmount()); + container.remove(); +}); + +describe('EventsPage (#282)', () => { + it('lists published events with venue and organizer from GET /events', async () => { + apiFetch.mockResolvedValue(page([event({}), event({ id: 'e2', name: 'Abuja Tech Summit', venue: 'ICC' })])); + await render(); + + expect(apiFetch).toHaveBeenCalledWith('/events?page=1&limit=20'); + expect(container.textContent).toContain('Lagos Jazz Night'); + expect(container.textContent).toContain('Eko Hall'); + expect(container.textContent).toContain('by Afrobeats Co'); + expect(container.textContent).toContain('Abuja Tech Summit'); + expect(container.querySelector('time')?.getAttribute('dateTime')).toBe('2030-01-01T20:00:00Z'); + }); + + it('shows an empty state when nothing is published', async () => { + apiFetch.mockResolvedValue(page([])); + await render(); + expect(container.textContent).toContain('No events are published right now.'); + }); + + it('pages forward with the Next button', async () => { + apiFetch.mockResolvedValueOnce(page([event({})], 25, 1)).mockResolvedValueOnce(page([event({ id: 'e9', name: 'Page Two Gig' })], 25, 2)); + await render(); + const next = Array.from(container.querySelectorAll('button')).find((b) => b.textContent === 'Next')!; + await act(async () => next.click()); + expect(apiFetch).toHaveBeenLastCalledWith('/events?page=2&limit=20'); + expect(container.textContent).toContain('Page Two Gig'); + expect(container.textContent).toContain('Page 2 of 2'); + }); +}); diff --git a/src/app/events/page.tsx b/src/app/events/page.tsx new file mode 100644 index 0000000..2760742 --- /dev/null +++ b/src/app/events/page.tsx @@ -0,0 +1,96 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { apiFetch } from '@/lib/api'; +import { actionErrorMessage } from '@/lib/action-error'; +import { EVENTS_PAGE_SIZE, formatEventDate } from '@/lib/events'; +import type { EventRecord, Paginated } from '@/lib/types'; +import { FormError } from '@/components/form-error'; +import { Button } from '@/components/button'; + +/** Public listing of published events (#282). No sign-in required. */ +export default function EventsPage() { + const [page, setPage] = useState(1); + const [data, setData] = useState | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + apiFetch>(`/events?page=${page}&limit=${EVENTS_PAGE_SIZE}`) + .then((res) => { + if (!cancelled) setData(res); + }) + .catch((err) => { + if (!cancelled) setError(actionErrorMessage(err, 'Could not load events.')); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [page]); + + // Loading/error are reset here rather than synchronously inside the effect. + function goToPage(next: number) { + setLoading(true); + setError(null); + setPage(next); + } + + const totalPages = data ? Math.max(1, Math.ceil(data.total / data.limit)) : 1; + + return ( +
+

Events

+

Upcoming published events with tickets on StellarTickets.

+ + {error && ( +
+ +
+ )} + + {loading && !data ? ( +

Loading…

+ ) : !error && data && data.items.length === 0 ? ( +

No events are published right now.

+ ) : data ? ( +
    + {data.items.map((event) => ( +
  • +
    +

    {event.name}

    + + {event.category} + +
    +

    + +

    +

    + {event.venue} + {event.organization && <> · by {event.organization.name}} +

    +
  • + ))} +
+ ) : null} + + {data && totalPages > 1 && ( + + )} +
+ ); +} diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 595689f..31caa65 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -3,7 +3,7 @@ import type { MetadataRoute } from 'next'; const BASE_URL = process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3001'; export default function sitemap(): MetadataRoute.Sitemap { - const routes = ['', '/marketplace', '/login', '/register']; + const routes = ['', '/events', '/marketplace', '/login', '/register']; return routes.map((route) => ({ url: `${BASE_URL}${route}`, diff --git a/src/components/navbar.tsx b/src/components/navbar.tsx index 0e207ac..1945196 100644 --- a/src/components/navbar.tsx +++ b/src/components/navbar.tsx @@ -17,6 +17,7 @@ interface NavLink { const LINKS: NavLink[] = [ { href: '/about', label: 'About' }, { href: '/security', label: 'Security' }, + { href: '/events', label: 'Events' }, { href: '/marketplace', label: 'Marketplace' }, { href: '/dashboard', label: 'Dashboard', authOnly: true }, { href: '/my-tickets', label: 'My tickets', authOnly: true }, diff --git a/src/lib/events.ts b/src/lib/events.ts new file mode 100644 index 0000000..db8cd72 --- /dev/null +++ b/src/lib/events.ts @@ -0,0 +1,9 @@ +/** Page size for the public events listing (#282). */ +export const EVENTS_PAGE_SIZE = 20; + +/** "Oct 16, 2026, 7:00 PM" or a start – end range when the event has an end time. */ +export function formatEventDate(startsAt: string, endsAt: string | null): string { + const opts: Intl.DateTimeFormatOptions = { dateStyle: 'medium', timeStyle: 'short' }; + const start = new Date(startsAt).toLocaleString(undefined, opts); + return endsAt ? `${start} – ${new Date(endsAt).toLocaleString(undefined, opts)}` : start; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index cb6c684..970540b 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -108,3 +108,11 @@ export interface ResaleListing { }; seller: { name: string }; } + +/** Page-number paginated list, as returned by backend list endpoints (e.g. `GET /events`). */ +export interface Paginated { + items: T[]; + total: number; + page: number; + limit: number; +}