Files
visit.waterschurch.org/src/lib/api.ts
T
Waters ChurchandClaude Fable 5.1 78cd0cc550 Plan Your Visit landing page for visit.waterschurch.org
Next.js 16 static export + PHP 8 API for cPanel/Apache hosting.

- Location-aware campus finder (IP estimate, optional precise location, ZIP search)
- Three-step Plan Your Visit form with separate email/SMS consent
- PHP API: validation, anti-spam, rate limiting, SQLite lead store,
  Mailchimp and Sinch Engage adapters, idempotent retries via cron
- Attribution capture (utm_*, gclid/gbraid/wbraid, fbclid, ttclid, twclid) and
  consent-aware analytics hooks
- Vitest and PHP test suites, cPanel deployment script and docs

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 13:22:26 -04:00

80 lines
2.5 KiB
TypeScript

import { site } from "@/config/site";
import type { PlanVisitData } from "@/lib/schema";
export interface LocateResponse {
ok: boolean;
approximate?: { lat: number; lng: number; city?: string; region?: string; confidence: "low" | "medium" };
reason?: string;
}
export interface GeocodeResponse {
ok: boolean;
location?: { lat: number; lng: number; city?: string; state?: string };
error?: string;
}
export type IntegrationStatus = "queued" | "sent" | "skipped" | "failed" | "mock";
export interface SubmitResponse {
ok: boolean;
submissionId?: string;
duplicate?: boolean;
email?: { status: IntegrationStatus };
sms?: { status: IntegrationStatus };
error?: string;
fieldErrors?: Record<string, string>;
retryAfter?: number;
}
const DEFAULT_TIMEOUT = 10_000;
export class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.status = status;
}
}
async function request<T>(path: string, init: RequestInit & { timeoutMs?: number } = {}): Promise<T> {
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), init.timeoutMs ?? DEFAULT_TIMEOUT);
try {
const res = await fetch(`${site.apiBase}${path}`, {
...init,
signal: controller.signal,
headers: { Accept: "application/json", "X-Requested-With": "visit-page", ...(init.headers ?? {}) },
credentials: "same-origin",
});
const text = await res.text();
let data: unknown = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
throw new ApiError(`Bad response (${res.status})`, res.status);
}
if (!res.ok && (!data || typeof data !== "object")) throw new ApiError(`Request failed (${res.status})`, res.status);
return data as T;
} finally {
clearTimeout(t);
}
}
/** Approximate, server-side IP geolocation. Never blocks the page. */
export function locateApprox(): Promise<LocateResponse> {
return request<LocateResponse>("/locate.php", { method: "GET", timeoutMs: 4000 });
}
export function geocodeZip(zip: string): Promise<GeocodeResponse> {
return request<GeocodeResponse>(`/geocode.php?zip=${encodeURIComponent(zip)}`, { method: "GET", timeoutMs: 8000 });
}
export function submitPlanVisit(data: PlanVisitData): Promise<SubmitResponse> {
return request<SubmitResponse>("/plan-visit.php", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
timeoutMs: 20_000,
});
}