Initial commit: Analytics frontend implementation
This commit is contained in:
164
lib/api/client.ts
Normal file
164
lib/api/client.ts
Normal 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
48
lib/api/sites.ts
Normal 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
74
lib/api/stats.ts
Normal 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)
|
||||
}
|
||||
114
lib/auth/context.tsx
Normal file
114
lib/auth/context.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
'use client'
|
||||
|
||||
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import apiRequest from '@/lib/api/client'
|
||||
import LoadingOverlay from '@/components/LoadingOverlay'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
email: string
|
||||
totp_enabled: boolean
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null
|
||||
loading: boolean
|
||||
login: (token: string, refreshToken: string, user: User) => void
|
||||
logout: () => void
|
||||
refresh: () => Promise<void>
|
||||
refreshSession: () => Promise<void>
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType>({
|
||||
user: null,
|
||||
loading: true,
|
||||
login: () => {},
|
||||
logout: () => {},
|
||||
refresh: async () => {},
|
||||
refreshSession: async () => {},
|
||||
})
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
const login = (token: string, refreshToken: string, userData: User) => {
|
||||
localStorage.setItem('token', token)
|
||||
localStorage.setItem('refreshToken', refreshToken)
|
||||
localStorage.setItem('user', JSON.stringify(userData))
|
||||
setUser(userData)
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
const logout = useCallback(() => {
|
||||
setIsLoggingOut(true)
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('refreshToken')
|
||||
localStorage.removeItem('user')
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.href = '/'
|
||||
}, 500)
|
||||
}, [])
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const userData = await apiRequest<User>('/auth/user/me')
|
||||
setUser(userData)
|
||||
localStorage.setItem('user', JSON.stringify(userData))
|
||||
} catch (e) {
|
||||
console.error('Failed to refresh user data', e)
|
||||
const savedUser = localStorage.getItem('user')
|
||||
if (savedUser && !user) {
|
||||
try { setUser(JSON.parse(savedUser)) } catch {}
|
||||
}
|
||||
}
|
||||
router.refresh()
|
||||
}, [router, user])
|
||||
|
||||
const refreshSession = useCallback(async () => {
|
||||
await refresh()
|
||||
}, [refresh])
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
const token = localStorage.getItem('token')
|
||||
const savedUser = localStorage.getItem('user')
|
||||
|
||||
if (token) {
|
||||
// Optimistically set from local storage first
|
||||
if (savedUser) {
|
||||
try {
|
||||
setUser(JSON.parse(savedUser))
|
||||
} catch (e) {
|
||||
localStorage.removeItem('user')
|
||||
}
|
||||
}
|
||||
|
||||
// Then fetch fresh data
|
||||
try {
|
||||
const userData = await apiRequest<User>('/auth/user/me')
|
||||
setUser(userData)
|
||||
localStorage.setItem('user', JSON.stringify(userData))
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch initial user data', e)
|
||||
}
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
init()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, logout, refresh, refreshSession }}>
|
||||
{isLoggingOut && <LoadingOverlay logoSrc="/ciphera_icon_no_margins.png" title="Ciphera Analytics" />}
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useAuth = () => useContext(AuthContext)
|
||||
44
lib/utils/format.ts
Normal file
44
lib/utils/format.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Format numbers with commas
|
||||
*/
|
||||
export function formatNumber(num: number): string {
|
||||
return new Intl.NumberFormat('en-US').format(num)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date to YYYY-MM-DD
|
||||
*/
|
||||
export function formatDate(date: Date): string {
|
||||
return date.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get date range for last N days
|
||||
*/
|
||||
export function getDateRange(days: number): { start: string; end: string } {
|
||||
const end = new Date()
|
||||
const start = new Date()
|
||||
start.setDate(start.getDate() - days)
|
||||
return {
|
||||
start: formatDate(start),
|
||||
end: formatDate(end),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative time (e.g., "2 hours ago")
|
||||
*/
|
||||
export function formatRelativeTime(date: string | Date): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - d.getTime()
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago`
|
||||
if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago`
|
||||
if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`
|
||||
return 'Just now'
|
||||
}
|
||||
Reference in New Issue
Block a user