Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions src/app/events/page.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import('@/lib/api')>()),
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>): 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<EventRecord> => ({
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(<EventsPage />);
});
}

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');
});
});
96 changes: 96 additions & 0 deletions src/app/events/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Paginated<EventRecord> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
let cancelled = false;
apiFetch<Paginated<EventRecord>>(`/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 (
<div className="mx-auto max-w-3xl px-6 py-16">
<h1 className="font-heading text-3xl font-bold">Events</h1>
<p className="mt-2 text-muted">Upcoming published events with tickets on StellarTickets.</p>

{error && (
<div className="mt-6">
<FormError message={error} />
</div>
)}

{loading && !data ? (
<p className="mt-8 text-muted">Loading…</p>
) : !error && data && data.items.length === 0 ? (
<p className="mt-8 text-muted">No events are published right now.</p>
) : data ? (
<ul className="mt-8 flex flex-col gap-3" aria-busy={loading}>
{data.items.map((event) => (
<li key={event.id} className="rounded-lg border border-border p-4">
<div className="flex items-center gap-2">
<p className="break-words font-medium">{event.name}</p>
<span className="rounded bg-muted px-2 py-0.5 text-xs font-medium text-foreground">
{event.category}
</span>
</div>
<p className="mt-1 text-sm text-muted">
<time dateTime={event.startsAt}>{formatEventDate(event.startsAt, event.endsAt)}</time>
</p>
<p className="break-words text-sm text-muted">
{event.venue}
{event.organization && <> · by {event.organization.name}</>}
</p>
</li>
))}
</ul>
) : null}

{data && totalPages > 1 && (
<nav className="mt-8 flex items-center justify-between" aria-label="Events pages">
<Button onClick={() => goToPage(page - 1)} disabled={page <= 1 || loading}>
Previous
</Button>
<span className="text-sm text-muted">
Page {page} of {totalPages}
</span>
<Button onClick={() => goToPage(page + 1)} disabled={page >= totalPages || loading}>
Next
</Button>
</nav>
)}
</div>
);
}
2 changes: 1 addition & 1 deletion src/app/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down
1 change: 1 addition & 0 deletions src/components/navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
9 changes: 9 additions & 0 deletions src/lib/events.ts
Original file line number Diff line number Diff line change
@@ -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;
}
8 changes: 8 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> {
items: T[];
total: number;
page: number;
limit: number;
}
Loading