feat(provider-api-client): Add TypeScript types and test coverage for analytics, blog, contact, roster, tour, and touring endpoints

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-04-18 09:58:37 -07:00
parent b915344fe7
commit cd836aa2ef
20 changed files with 500 additions and 0 deletions

View file

@ -0,0 +1,37 @@
# @lilith/provider-api-client
Type-safe HTTP client for the provider API public surface.
## Base URL resolution
1. `import.meta.env.VITE_QUINN_API_BASE_URL` (Vite env override)
2. `localhost` / `127.0.0.1``http://localhost:3040`
3. Otherwise → `https://api.quinn.apricot.local`
## Exports
### Types
`BlogPostSummary`, `BlogPost`, `ContactChannel`, `ContactPayload`, `ContactResult`, `TouringPayload`, `TouringResult`, `TrackAvailability`, `RosterApplicationPayload`, `RosterApplicationResult`, `AnalyticsEventPayload`
### Error classes
`ApiError`, `NotFoundError`, `RateLimitError`, `ValidationError`, `NetworkError`
### Endpoints
| Function | Method | Path |
|---|---|---|
| `fetchBlogPosts()` | GET | `/www/blog` |
| `fetchBlogPost(slug)` | GET | `/www/blog/:slug` |
| `submitContact(payload)` | POST | `/public/contact` |
| `subscribeToTouring(payload)` | POST | `/public/touring/subscribe` |
| `fetchAvailability()` | GET | `/public/roster/availability` |
| `fetchAvailabilityBySlug(slug)` | GET | `/public/roster/availability/:slug` |
| `submitRosterApplication(payload)` | POST | `/public/roster/apply` |
| `trackEvent(path, payload)` | POST | `/public/analytics/track/*` (fire-and-forget) |
### Low-level
`apiFetch<T>(url, init?)` — typed fetch with automatic error mapping
`resolveBaseUrl()` — base URL resolution

View file

@ -0,0 +1,33 @@
import { describe, it, expect, afterEach } from 'bun:test';
import { resolveBaseUrl } from '../base-url';
describe('resolveBaseUrl', () => {
afterEach(() => {
(globalThis as Record<string, unknown>)['window'] = undefined;
});
it('returns apricot.local when no window (server/test env)', () => {
expect(resolveBaseUrl()).toBe('https://api.quinn.apricot.local');
});
it('returns localhost:3040 when window.location.hostname is localhost', () => {
(globalThis as Record<string, unknown>)['window'] = {
location: { hostname: 'localhost' },
};
expect(resolveBaseUrl()).toBe('http://localhost:3040');
});
it('returns localhost:3040 when window.location.hostname is 127.0.0.1', () => {
(globalThis as Record<string, unknown>)['window'] = {
location: { hostname: '127.0.0.1' },
};
expect(resolveBaseUrl()).toBe('http://localhost:3040');
});
it('returns apricot.local for other hostnames', () => {
(globalThis as Record<string, unknown>)['window'] = {
location: { hostname: 'transquinnftw.com' },
};
expect(resolveBaseUrl()).toBe('https://api.quinn.apricot.local');
});
});

View file

@ -0,0 +1,47 @@
import { describe, it, expect, mock, beforeEach } from 'bun:test';
import { apiFetch, NotFoundError, RateLimitError, ValidationError, NetworkError, ApiError } from '../client';
function mockFetch(status: number, body: unknown): void {
(globalThis as Record<string, unknown>)['fetch'] = mock(async () => ({
ok: status >= 200 && status < 300,
status,
json: async () => body,
}));
}
function mockFetchThrow(err: Error): void {
(globalThis as Record<string, unknown>)['fetch'] = mock(async () => { throw err; });
}
describe('apiFetch', () => {
it('returns parsed JSON on 200', async () => {
mockFetch(200, { id: 1 });
const result = await apiFetch<{ id: number }>('http://test/');
expect(result).toEqual({ id: 1 });
});
it('throws NotFoundError on 404', async () => {
mockFetch(404, {});
await expect(apiFetch('http://test/')).rejects.toBeInstanceOf(NotFoundError);
});
it('throws RateLimitError on 429', async () => {
mockFetch(429, {});
await expect(apiFetch('http://test/')).rejects.toBeInstanceOf(RateLimitError);
});
it('throws ValidationError on 422', async () => {
mockFetch(422, { error: 'bad input' });
await expect(apiFetch('http://test/')).rejects.toBeInstanceOf(ValidationError);
});
it('throws ApiError on 500', async () => {
mockFetch(500, { error: 'server error' });
await expect(apiFetch('http://test/')).rejects.toBeInstanceOf(ApiError);
});
it('throws NetworkError when fetch throws', async () => {
mockFetchThrow(new TypeError('Failed to fetch'));
await expect(apiFetch('http://test/')).rejects.toBeInstanceOf(NetworkError);
});
});

View file

@ -0,0 +1,17 @@
type ViteImportMeta = ImportMeta & {
readonly env?: Readonly<{ VITE_QUINN_API_BASE_URL?: string }>;
};
export function resolveBaseUrl(): string {
const viteEnv = (import.meta as ViteImportMeta).env;
if (viteEnv?.VITE_QUINN_API_BASE_URL) return viteEnv.VITE_QUINN_API_BASE_URL;
if (
typeof window !== 'undefined' &&
(window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')
) {
return 'http://localhost:3040';
}
return 'https://api.quinn.apricot.local';
}

View file

@ -0,0 +1,75 @@
export class ApiError extends Error {
constructor(
message: string,
public readonly status: number,
public readonly code?: string,
) {
super(message);
this.name = 'ApiError';
}
}
export class NotFoundError extends ApiError {
constructor(message = 'Not found') {
super(message, 404, 'not_found');
this.name = 'NotFoundError';
}
}
export class RateLimitError extends ApiError {
constructor(message = 'Too many requests. Please wait a moment before trying again.') {
super(message, 429, 'rate_limited');
this.name = 'RateLimitError';
}
}
export class ValidationError extends ApiError {
constructor(message: string) {
super(message, 422, 'validation_error');
this.name = 'ValidationError';
}
}
export class NetworkError extends Error {
constructor(message: string, public readonly cause?: unknown) {
super(message);
this.name = 'NetworkError';
}
}
export async function apiFetch<T>(url: string, init?: RequestInit): Promise<T> {
let res: Response;
try {
res = await fetch(url, init);
} catch (err) {
throw new NetworkError(
`Network error: ${err instanceof Error ? err.message : String(err)}`,
err,
);
}
if (res.status === 404) throw new NotFoundError();
if (res.status === 429) throw new RateLimitError();
if (res.status === 422) {
let body: { error?: string; message?: string } = {};
try {
body = (await res.json()) as { error?: string; message?: string };
} catch {
// ignore
}
throw new ValidationError(body.error ?? body.message ?? 'Validation failed');
}
if (!res.ok) {
let body: { error?: string; message?: string } = {};
try {
body = (await res.json()) as { error?: string; message?: string };
} catch {
// ignore
}
throw new ApiError(body.error ?? body.message ?? `Request failed: ${res.status}`, res.status);
}
return res.json() as Promise<T>;
}

View file

@ -0,0 +1,13 @@
import { resolveBaseUrl } from '../base-url';
import type { AnalyticsEventPayload } from '../types/analytics';
export function trackEvent(eventPath: string, payload: AnalyticsEventPayload): void {
const url = `${resolveBaseUrl()}/public/analytics/track/${eventPath.replace(/^\//, '')}`;
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).catch(() => {
// fire-and-forget — analytics failures are non-fatal
});
}

View file

@ -0,0 +1,20 @@
import { apiFetch, NotFoundError } from '../client';
import type { BlogPost, BlogPostSummary } from '../types/blog';
import { resolveBaseUrl } from '../base-url';
export async function fetchBlogPosts(): Promise<readonly BlogPostSummary[]> {
try {
return await apiFetch<readonly BlogPostSummary[]>(`${resolveBaseUrl()}/www/blog`);
} catch (err) {
if (err instanceof NotFoundError) throw err;
throw err;
}
}
export async function fetchBlogPost(slug: string): Promise<BlogPost> {
try {
return await apiFetch<BlogPost>(`${resolveBaseUrl()}/www/blog/${encodeURIComponent(slug)}`);
} catch (err) {
throw err;
}
}

View file

@ -0,0 +1,23 @@
import { apiFetch, RateLimitError, ApiError, NetworkError } from '../client';
import type { ContactPayload, ContactResult } from '../types/contact';
import { resolveBaseUrl } from '../base-url';
export async function submitContact(payload: ContactPayload): Promise<ContactResult> {
try {
const data = await apiFetch<{ id?: number; status?: string; error?: string }>(`${resolveBaseUrl()}/public/contact`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
return { success: true, id: data.id ?? 0 };
} catch (err) {
if (err instanceof RateLimitError) throw err;
if (err instanceof NetworkError) {
return { success: false, error: 'Network error. Please check your connection and try again.' };
}
if (err instanceof ApiError) {
return { success: false, error: err.message };
}
return { success: false, error: 'Something went wrong. Please try again.' };
}
}

View file

@ -0,0 +1,37 @@
import { apiFetch, ApiError, ValidationError } from '../client';
import type { TrackAvailability, RosterApplicationPayload, RosterApplicationResult } from '../types/roster';
import { resolveBaseUrl } from '../base-url';
export async function fetchAvailability(): Promise<readonly TrackAvailability[]> {
try {
return await apiFetch<readonly TrackAvailability[]>(`${resolveBaseUrl()}/public/roster/availability`);
} catch (err) {
if (err instanceof ApiError) throw err;
throw err;
}
}
export async function fetchAvailabilityBySlug(slug: string): Promise<TrackAvailability> {
try {
return await apiFetch<TrackAvailability>(`${resolveBaseUrl()}/public/roster/availability/${encodeURIComponent(slug)}`);
} catch (err) {
if (err instanceof ApiError) throw err;
throw err;
}
}
export async function submitRosterApplication(
payload: RosterApplicationPayload,
): Promise<RosterApplicationResult> {
try {
return await apiFetch<RosterApplicationResult>(`${resolveBaseUrl()}/public/roster/apply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
} catch (err) {
if (err instanceof ValidationError) throw err;
if (err instanceof ApiError) throw err;
throw err;
}
}

View file

@ -0,0 +1,39 @@
import { apiFetch, ApiError, NetworkError } from '../client';
import { resolveBaseUrl } from '../base-url';
import type { TourStatus, TourStop } from '../types/tour';
export interface TourStatusOptions {
readonly providerSlug?: string;
readonly today?: string; // YYYY-MM-DD — optional override for testing
}
function query(opts?: TourStatusOptions): string {
const params = new URLSearchParams();
if (opts?.providerSlug) params.set('provider', opts.providerSlug);
if (opts?.today) params.set('today', opts.today);
const q = params.toString();
return q ? `?${q}` : '';
}
export async function fetchTourStatus(opts?: TourStatusOptions): Promise<TourStatus> {
try {
return await apiFetch<TourStatus>(`${resolveBaseUrl()}/www/tour/status${query(opts)}`);
} catch (err) {
if (err instanceof ApiError || err instanceof NetworkError) throw err;
throw new NetworkError('Failed to fetch tour status', err);
}
}
export async function fetchTourStops(opts?: { providerSlug?: string }): Promise<readonly TourStop[]> {
const params = new URLSearchParams();
if (opts?.providerSlug) params.set('provider', opts.providerSlug);
const qs = params.toString();
try {
return await apiFetch<readonly TourStop[]>(
`${resolveBaseUrl()}/www/tour/stops${qs ? '?' + qs : ''}`,
);
} catch (err) {
if (err instanceof ApiError || err instanceof NetworkError) throw err;
throw new NetworkError('Failed to fetch tour stops', err);
}
}

View file

@ -0,0 +1,16 @@
import { apiFetch, RateLimitError } from '../client';
import type { TouringPayload, TouringResult } from '../types/touring';
import { resolveBaseUrl } from '../base-url';
export async function subscribeToTouring(payload: TouringPayload): Promise<TouringResult> {
try {
return await apiFetch<TouringResult>(`${resolveBaseUrl()}/public/touring/subscribe`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
} catch (err) {
if (err instanceof RateLimitError) throw err;
throw err;
}
}

View file

@ -0,0 +1,22 @@
export { resolveBaseUrl } from './base-url';
export { apiFetch, ApiError, NotFoundError, RateLimitError, ValidationError, NetworkError } from './client';
export type { BlogPostSummary, BlogPost } from './types/blog';
export type { ContactChannel, ContactPayload, ContactResult } from './types/contact';
export { CONTACT_CHANNELS } from './types/contact';
export type { TouringPayload, TouringResult } from './types/touring';
export type { TrackAvailability, RosterApplicationPayload, RosterApplicationResult } from './types/roster';
export type { AnalyticsEventPayload } from './types/analytics';
export type {
TourStop,
TourStopStatus,
TourStopVisibility,
CurrentLocation,
TourStatus,
} from './types/tour';
export { fetchBlogPosts, fetchBlogPost } from './endpoints/blog';
export { submitContact } from './endpoints/contact';
export { subscribeToTouring } from './endpoints/touring';
export { fetchAvailability, fetchAvailabilityBySlug, submitRosterApplication } from './endpoints/roster';
export { trackEvent } from './endpoints/analytics';
export { fetchTourStatus, fetchTourStops } from './endpoints/tour';
export type { TourStatusOptions } from './endpoints/tour';

View file

@ -0,0 +1 @@
export type AnalyticsEventPayload = Record<string, unknown>;

View file

@ -0,0 +1,12 @@
export type BlogPostSummary = {
readonly slug: string;
readonly title: string;
readonly excerpt: string;
readonly heroImage: string | null;
readonly publishedAt: string | null;
readonly tags: readonly string[];
};
export type BlogPost = BlogPostSummary & {
readonly bodyHtml: string | null;
};

View file

@ -0,0 +1,16 @@
export const CONTACT_CHANNELS = ['email', 'imessage', 'signal', 'telegram', 'other'] as const;
export type ContactChannel = (typeof CONTACT_CHANNELS)[number];
export type ContactPayload = {
readonly name: string;
readonly email?: string;
readonly handle?: string;
readonly channel?: ContactChannel;
readonly subject: string;
readonly body: string;
readonly hcaptchaToken?: string;
};
export type ContactResult =
| { readonly success: true; readonly id: number }
| { readonly success: false; readonly error: string };

View file

@ -0,0 +1,6 @@
export type { BlogPostSummary, BlogPost } from './blog';
export type { ContactChannel, ContactPayload, ContactResult } from './contact';
export { CONTACT_CHANNELS } from './contact';
export type { TouringPayload, TouringResult } from './touring';
export type { TrackAvailability, RosterApplicationPayload, RosterApplicationResult } from './roster';
export type { AnalyticsEventPayload } from './analytics';

View file

@ -0,0 +1,29 @@
export type TrackAvailability = {
readonly slug: string;
readonly name: string;
readonly tagline: string;
readonly displayOpen: number;
readonly displayMessage: string;
readonly active: boolean;
};
export type RosterApplicationPayload = {
readonly track: string;
readonly handle: string;
readonly email: string;
readonly phone: string;
readonly countryCode?: string;
readonly interests: readonly string[];
readonly experience: string;
readonly hardLimits: string;
readonly availability: string;
readonly tributeNote: string;
readonly acknowledged: boolean;
};
export type RosterApplicationResult = {
readonly success: boolean;
readonly id: number;
readonly status: 'pending' | 'waitlisted';
readonly track: string;
};

View file

@ -0,0 +1,36 @@
export type TourStopStatus = 'confirmed' | 'conditional' | 'sold-out';
export type TourStopVisibility = 'public' | 'draft';
export interface TourStop {
readonly id: number;
readonly city: string;
readonly state: string;
readonly country: string;
readonly startDate: string;
readonly endDate: string;
readonly status: TourStopStatus;
readonly visibility: TourStopVisibility;
readonly fmtyRate: number | null;
readonly travelFee: number | null;
readonly notes: string;
readonly sortOrder: number;
readonly providerSlug: string;
readonly createdAt: string;
readonly updatedAt: string;
}
export interface CurrentLocation {
readonly city: string;
readonly state: string | null;
readonly country: string;
readonly incallAvailable: boolean;
readonly sinceDate: string;
}
export interface TourStatus {
readonly currentLocation: CurrentLocation | null;
readonly activeStop: TourStop | null;
readonly nextStop: TourStop | null;
readonly confirmedStops: readonly TourStop[];
readonly conditionalStops: readonly TourStop[];
}

View file

@ -0,0 +1,14 @@
export type TouringPayload = {
readonly email: string;
readonly phone?: string;
readonly countryCode?: string;
readonly preferSms?: boolean;
readonly source?: string;
readonly sourceCity?: string;
readonly citiesInterested?: readonly string[];
};
export type TouringResult = {
readonly id: number;
readonly status: string;
};

View file

@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["bun-types"]
},
"include": ["src/**/*"]
}