import {z} from 'zod' export type Metric = 'visitors' | 'pageviews' | 'visits' | 'visit_duration' | 'bounce_rate' | 'views_per_visit' | 'engagement_time' | 'events' | 'conversion_rate' | 'time_on_page' | 'scroll_depth' | 'exit_rate'; export type DateRange = string | Array; export type Dimension = 'time' | 'time:minute' | 'time:hour' | 'time:day' | 'time:week' | 'time:month' | 'visit:source' | 'visit:referrer' | 'visit:channel' | 'visit:utm_source' | 'visit:utm_medium' | 'visit:utm_campaign' | 'visit:utm_content' | 'visit:utm_term' | 'visit:country' | 'visit:region' | 'visit:city' | 'visit:browser' | 'visit:browser_version' | 'visit:os' | 'visit:os_version' | 'visit:device' | 'visit:entry_page' | 'visit:exit_page' | 'event:page' | 'event:folder' | 'event:hostname' | 'event:name' | 'event:goal' | 'event:status_code'; export type Filter = {field: 'browser' | 'browser_version' | 'os' | 'os_version' | 'device' | 'country' | 'city' | 'region' | 'hostname' | 'utm_source' | 'utm_medium' | 'utm_campaign' | 'utm_content' | 'utm_term' | 'entry_page' | 'exit_page' | 'source' | 'channel' | 'referrer' | 'page' | 'code' | 'event', operator: 'is' | 'is_not' | 'contains' | 'does_not_contain', values: Array}; export type QueryRequest = {site_id?: number | undefined, metrics: Array, date_range: DateRange, dimensions?: Array | undefined, filters?: Array | undefined, limit?: number | undefined, offset?: number | undefined, compare?: ('previous_period' | Array) | undefined}; export type CreateApiQueryArgs = {body: QueryRequest}; export type CreateApiQueryOptions = {baseUrl?: string | undefined, bearerAuth?: string | undefined, fetch?: typeof fetch | undefined}; export const resultRow = z.object({dimensions: z.record(z.string(), z.string()).optional(), labels: z.record(z.string(), z.string()).optional(), metrics: z.record(z.string(), z.number()).optional(), compare: z.record(z.string(), z.object({value: z.number().optional(), change: z.number().optional()})).optional()}); export const meta = z.object({total: z.number().int().optional(), limit: z.number().int().optional(), offset: z.number().int().optional(), has_more: z.boolean().optional()}); export const metric = z.enum(["visitors", "pageviews", "visits", "visit_duration", "bounce_rate", "views_per_visit", "engagement_time", "events", "conversion_rate", "time_on_page", "scroll_depth", "exit_rate"]); export const dimension = z.enum(["time", "time:minute", "time:hour", "time:day", "time:week", "time:month", "visit:source", "visit:referrer", "visit:channel", "visit:utm_source", "visit:utm_medium", "visit:utm_campaign", "visit:utm_content", "visit:utm_term", "visit:country", "visit:region", "visit:city", "visit:browser", "visit:browser_version", "visit:os", "visit:os_version", "visit:device", "visit:entry_page", "visit:exit_page", "event:page", "event:folder", "event:hostname", "event:name", "event:goal", "event:status_code"]); export const filter = z.object({field: z.enum(["browser", "browser_version", "os", "os_version", "device", "country", "city", "region", "hostname", "utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term", "entry_page", "exit_page", "source", "channel", "referrer", "page", "code", "event"]), operator: z.enum(["is", "is_not", "contains", "does_not_contain"]), values: z.array(z.string())}); export const queryEcho = z.object({site_id: z.number().int().optional(), metrics: z.array(metric).optional(), date_range: z.array(z.string()).optional(), compare_date_range: z.array(z.string()).optional(), dimensions: z.array(dimension).optional(), filters: z.array(filter).optional()}); export const queryResponse = z.object({results: z.array(resultRow), meta: meta.optional(), query: queryEcho, has_import: z.boolean().optional()}); export const validateResponse = async ( schema: { readonly '~standard': { readonly validate: (value: unknown) => ({ readonly value: Output; readonly issues?: undefined } | { readonly issues: ReadonlyArray<{ readonly message: string; readonly path?: ReadonlyArray | undefined }> }) | Promise<{ readonly value: Output; readonly issues?: undefined } | { readonly issues: ReadonlyArray<{ readonly message: string; readonly path?: ReadonlyArray | undefined }> }> } }, value: unknown ): Promise => { const result = await schema['~standard'].validate(value) if (result.issues !== undefined) { const detail = result.issues .map(({ message, path }) => path?.length ? `${path.map((segment) => String(typeof segment === 'object' ? segment.key : segment)).join('.')}: ${message}` : message ) .join('; ') throw new Error(`Response does not match the schema: ${detail}`, { cause: { issues: result.issues } }) } return result.value }; /** * Run one analytics query * * POST /query */ export const createApiQuery = async (args: CreateApiQueryArgs, options?: CreateApiQueryOptions) => { const {baseUrl = "https://statable.com/api/v1", fetch: fetchFn = fetch, bearerAuth} = options ?? {} const query = new URLSearchParams() const headers = new Headers() headers.set('content-type', "application/json") headers.set('accept', "application/json") if (bearerAuth !== undefined) headers.set('authorization', `Bearer ${bearerAuth}`) const url = `${baseUrl.replace(/\/+$/, '')}/query${query.size > 0 ? `?${query}` : ''}` const res = await fetchFn(url, {method: "POST", headers, body: JSON.stringify(args.body)}) if (!res.ok) throw new Error(`Request failed with status ${res.status}`, {cause: {status: res.status, body: await res.text()}}) return validateResponse(queryResponse, await res.json()) };