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>
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user