← back to CelebritySignatures

apps/mobile/api.ts

130 lines

// Thin typed client over the existing celebsignatures.com Express JSON APIs.
// Native app keeps the backend on Kamatera (no rewrite) — see plan. RN has its
// own cookie jar, so the cs_sess auth cookie set by /api/auth/* is retained
// across fetches automatically (no CORS constraints apply to a native client).
import Constants from 'expo-constants';
import * as WebBrowser from 'expo-web-browser';

export const WEB_BASE: string =
  (Constants.expoConfig?.extra as any)?.webBase ?? 'https://celebsignatures.com';

/**
 * Sign in with Apple (native). Apple/Google reject OAuth inside an embedded
 * WebView, so we run the web sign-in flow in ASWebAuthenticationSession, then
 * replay the one-time handoff token from THIS native fetch — so the cs_sess
 * cookie lands in RN's shared cookie jar (the same jar every api call + the
 * checkout WebView use). Returns {ok:false, error:'canceled'} on user cancel.
 */
export async function appleSignIn(): Promise<{ ok: boolean; error?: string }> {
  try {
    const result = await WebBrowser.openAuthSessionAsync(
      `${WEB_BASE}/auth/apple/login?app=1`,
      'celebsignatures://auth',
    );
    if (result.type === 'cancel' || result.type === 'dismiss') return { ok: false, error: 'canceled' };
    if (result.type !== 'success' || !result.url) return { ok: false, error: 'Sign in was not completed.' };
    const token = new URL(result.url).searchParams.get('token');
    if (!token) return { ok: false, error: 'No sign-in token returned.' };
    // Replay the handoff (302 + Set-Cookie); RN's fetch stores the cookie from
    // the redirect chain into the shared native jar.
    await fetch(`${WEB_BASE}/auth/handoff?token=${encodeURIComponent(token)}`);
    return { ok: true };
  } catch (e) {
    return { ok: false, error: e instanceof Error ? e.message : String(e) };
  }
}

export type Signature = {
  category: string;
  rank?: number;
  full_name: string;
  wikidata?: string;
  reason_for_ranking?: string;
  signature_image_url?: string;
  image_license?: string;
  image_author?: string;
  deceased?: string;
  death_date?: string;
  museums?: string[];
};

export type EvolutionEntry = { file: string; year: number | null; source: string; url: string; license: string };
export type Evolution = Record<string, { name: string; sigs: EvolutionEntry[] }>;
export type Portraits = Record<string, string>;

export type Mural = {
  code: string; slug: string; title: string; blurb?: string; sigs?: number;
  roster?: string[]; met_accent?: string;
};
export type MuralsCatalog = {
  pricePerSqFt: number;
  size: { widthFt: number; heightFt: number; dpi: number; pxW: number; pxH: number; sqft: number };
  murals: Mural[];
};

export type User = { email: string; name?: string } | null;
export type LeaderRow = { id?: string; name: string; score: number; at: string };

/** Wikidata QID from a `.../Q12345` url — the key portraits + evolution use. */
export function qidOf(sig: Signature): string | null {
  const m = (sig.wikidata || '').match(/Q\d+/);
  return m ? m[0] : null;
}

/**
 * RN's <Image> cannot decode SVG (62 of the 1,554 signatures, incl. Jefferson),
 * so route Commons SVG originals through Special:FilePath?width=N, which 302s
 * to a rasterized PNG thumb (the same mechanism the evolution data already
 * uses; hand-building …/thumb/… paths 400s on some shards). Raster originals
 * pass through untouched — they decode natively and the thumb service errors
 * when asked to upscale a bitmap past its native width.
 */
export function sigImg(url?: string, width = 640): string | undefined {
  if (!url) return undefined;
  const m = url.match(
    /^https?:\/\/upload\.wikimedia\.org\/wikipedia\/commons\/[0-9a-f]\/[0-9a-f]{2}\/([^/?#]+\.svg)$/i,
  );
  if (!m) return url;
  return `https://commons.wikimedia.org/wiki/Special:FilePath/${m[1]}?width=${width}`;
}

async function getJSON<T>(path: string): Promise<T> {
  const r = await fetch(`${WEB_BASE}${path}`);
  if (!r.ok) throw new Error(`${path} → ${r.status}`);
  return r.json() as Promise<T>;
}
async function postJSON<T>(path: string, body: unknown): Promise<T> {
  const r = await fetch(`${WEB_BASE}${path}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });
  return r.json() as Promise<T>;
}

export const api = {
  signatures: () => getJSON<Signature[]>('/api/signatures'),
  portraits: () => getJSON<Portraits>('/api/portraits'),
  evolution: () => getJSON<Evolution>('/api/signature-evolution'),
  murals: () => getJSON<MuralsCatalog>('/api/murals-catalog'),

  leaderboardTop: (game: string, diff: string) =>
    getJSON<{ ok: boolean; top: LeaderRow[] }>(`/api/leaderboard?game=${game}&diff=${diff}`),
  leaderboardSubmit: (game: string, diff: string, score: number, name: string) =>
    postJSON<{ ok: boolean; rank?: number; top?: LeaderRow[] }>('/api/leaderboard', { game, diff, score, name }),

  // Which sign-in methods are live server-side (Apple button hides until configured).
  authConfig: () => getJSON<{ ok: boolean; apple: boolean }>('/api/auth-config'),
  me: () => getJSON<{ ok: boolean; user: User }>('/api/auth/me'),
  login: (email: string, password: string) =>
    postJSON<{ ok: boolean; user?: User; error?: string }>('/api/auth/login', { email, password }),
  signup: (email: string, password: string, name?: string) =>
    postJSON<{ ok: boolean; user?: User; error?: string }>('/api/auth/signup', { email, password, name }),
  logout: () => postJSON<{ ok: boolean }>('/api/auth/logout', {}),
  // account deletion — Apple 5.1.1(v). Backend endpoint added in the same effort.
  deleteAccount: () => postJSON<{ ok: boolean; error?: string }>('/api/auth/delete', {}),

  muralOrder: (body: { name: string; email: string; mural?: string; note?: string }) =>
    postJSON<{ ok: boolean; id?: string }>('/api/mural-order', body),
};