Initial commit: Analytics frontend implementation

This commit is contained in:
Usman Baig
2026-01-16 13:14:19 +01:00
commit 8e10a05eb1
28 changed files with 1778 additions and 0 deletions

164
lib/api/client.ts Normal file
View File

@@ -0,0 +1,164 @@
/**
* HTTP client wrapper for API calls
*/
export const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8082'
export const AUTH_URL = process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:3000'
export const APP_URL = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3003'
export const AUTH_API_URL = process.env.NEXT_PUBLIC_AUTH_API_URL || 'http://localhost:8081'
export function getLoginUrl(redirectPath = '/auth/callback') {
const redirectUri = encodeURIComponent(`${APP_URL}${redirectPath}`)
return `${AUTH_URL}/login?client_id=analytics-app&redirect_uri=${redirectUri}&response_type=code`
}
export function getSignupUrl(redirectPath = '/auth/callback') {
const redirectUri = encodeURIComponent(`${APP_URL}${redirectPath}`)
return `${AUTH_URL}/signup?client_id=analytics-app&redirect_uri=${redirectUri}&response_type=code`
}
export class ApiError extends Error {
status: number
constructor(message: string, status: number) {
super(message)
this.status = status
}
}
// * Mutex for token refresh
let isRefreshing = false
let refreshSubscribers: ((token: string) => void)[] = []
function subscribeToTokenRefresh(cb: (token: string) => void) {
refreshSubscribers.push(cb)
}
function onRefreshed(token: string) {
refreshSubscribers.map((cb) => cb(token))
refreshSubscribers = []
}
/**
* Base API client with error handling
*/
async function apiRequest<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
// * Determine base URL
const isAuthRequest = endpoint.startsWith('/auth')
const baseUrl = isAuthRequest ? AUTH_API_URL : API_URL
const url = `${baseUrl}/api/v1${endpoint}`
const headers: HeadersInit = {
'Content-Type': 'application/json',
...options.headers,
}
// Inject Auth Token if available (Client-side only)
if (typeof window !== 'undefined') {
const token = localStorage.getItem('token')
if (token) {
(headers as any)['Authorization'] = `Bearer ${token}`
}
}
const response = await fetch(url, {
...options,
headers,
})
if (!response.ok) {
if (response.status === 401) {
// * Attempt Token Refresh if 401
if (typeof window !== 'undefined') {
const refreshToken = localStorage.getItem('refreshToken')
// * Prevent infinite loop: Don't refresh if the failed request WAS a refresh request
if (refreshToken && !endpoint.includes('/auth/refresh')) {
if (isRefreshing) {
// * If refresh is already in progress, wait for it to complete
return new Promise((resolve, reject) => {
subscribeToTokenRefresh(async (newToken) => {
// Retry original request with new token
const newHeaders = {
...headers,
'Authorization': `Bearer ${newToken}`,
}
try {
const retryResponse = await fetch(url, {
...options,
headers: newHeaders,
})
if (retryResponse.ok) {
resolve(retryResponse.json())
} else {
reject(new ApiError('Retry failed', retryResponse.status))
}
} catch (e) {
reject(e)
}
})
})
}
isRefreshing = true
try {
const refreshRes = await fetch(`${AUTH_API_URL}/api/v1/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
refresh_token: refreshToken,
}),
})
if (refreshRes.ok) {
const data = await refreshRes.json()
localStorage.setItem('token', data.access_token)
localStorage.setItem('refreshToken', data.refresh_token) // Rotation
// Notify waiting requests
onRefreshed(data.access_token)
// * Retry original request with new token
const newHeaders = {
...headers,
'Authorization': `Bearer ${data.access_token}`,
}
const retryResponse = await fetch(url, {
...options,
headers: newHeaders,
})
if (retryResponse.ok) {
return retryResponse.json()
}
} else {
// * Refresh failed, logout
localStorage.removeItem('token')
localStorage.removeItem('refreshToken')
localStorage.removeItem('user')
}
} catch (e) {
// * Network error during refresh
throw e
} finally {
isRefreshing = false
}
}
}
}
const errorBody = await response.json().catch(() => ({
error: 'Unknown error',
message: `HTTP ${response.status}: ${response.statusText}`,
}))
throw new ApiError(errorBody.message || errorBody.error || 'Request failed', response.status)
}
return response.json()
}
export const authFetch = apiRequest
export default apiRequest

48
lib/api/sites.ts Normal file
View File

@@ -0,0 +1,48 @@
import apiRequest from './client'
export interface Site {
id: string
user_id: string
domain: string
name: string
created_at: string
updated_at: string
}
export interface CreateSiteRequest {
domain: string
name: string
}
export interface UpdateSiteRequest {
name: string
}
export async function listSites(): Promise<Site[]> {
const response = await apiRequest<{ sites: Site[] }>('/sites')
return response.sites
}
export async function getSite(id: string): Promise<Site> {
return apiRequest<Site>(`/sites/${id}`)
}
export async function createSite(data: CreateSiteRequest): Promise<Site> {
return apiRequest<Site>('/sites', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateSite(id: string, data: UpdateSiteRequest): Promise<Site> {
return apiRequest<Site>(`/sites/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteSite(id: string): Promise<void> {
await apiRequest(`/sites/${id}`, {
method: 'DELETE',
})
}

74
lib/api/stats.ts Normal file
View File

@@ -0,0 +1,74 @@
import apiRequest from './client'
export interface Stats {
pageviews: number
visitors: number
}
export interface TopPage {
path: string
pageviews: number
}
export interface TopReferrer {
referrer: string
pageviews: number
}
export interface CountryStat {
country: string
pageviews: number
}
export interface DailyStat {
date: string
pageviews: number
visitors: number
}
export interface RealtimeStats {
visitors: number
}
export async function getStats(siteId: string, startDate?: string, endDate?: string): Promise<Stats> {
const params = new URLSearchParams()
if (startDate) params.append('start_date', startDate)
if (endDate) params.append('end_date', endDate)
const query = params.toString()
return apiRequest<Stats>(`/sites/${siteId}/stats${query ? `?${query}` : ''}`)
}
export async function getRealtime(siteId: string): Promise<RealtimeStats> {
return apiRequest<RealtimeStats>(`/sites/${siteId}/realtime`)
}
export async function getTopPages(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<TopPage[]> {
const params = new URLSearchParams()
if (startDate) params.append('start_date', startDate)
if (endDate) params.append('end_date', endDate)
params.append('limit', limit.toString())
return apiRequest<{ pages: TopPage[] }>(`/sites/${siteId}/pages?${params.toString()}`).then(r => r.pages)
}
export async function getTopReferrers(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<TopReferrer[]> {
const params = new URLSearchParams()
if (startDate) params.append('start_date', startDate)
if (endDate) params.append('end_date', endDate)
params.append('limit', limit.toString())
return apiRequest<{ referrers: TopReferrer[] }>(`/sites/${siteId}/referrers?${params.toString()}`).then(r => r.referrers)
}
export async function getCountries(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<CountryStat[]> {
const params = new URLSearchParams()
if (startDate) params.append('start_date', startDate)
if (endDate) params.append('end_date', endDate)
params.append('limit', limit.toString())
return apiRequest<{ countries: CountryStat[] }>(`/sites/${siteId}/countries?${params.toString()}`).then(r => r.countries)
}
export async function getDailyStats(siteId: string, startDate?: string, endDate?: string): Promise<DailyStat[]> {
const params = new URLSearchParams()
if (startDate) params.append('start_date', startDate)
if (endDate) params.append('end_date', endDate)
return apiRequest<{ stats: DailyStat[] }>(`/sites/${siteId}/daily?${params.toString()}`).then(r => r.stats)
}