wip
This commit is contained in:
71
src/components/Analytics.astro
Normal file
71
src/components/Analytics.astro
Normal file
@@ -0,0 +1,71 @@
|
||||
---
|
||||
// Analytics Component
|
||||
// Uses clean service pattern with dependency injection
|
||||
import { createPlausibleAnalytics } from '../utils/analytics';
|
||||
|
||||
const { domain = 'mintel.me', scriptUrl = 'https://plausible.yourdomain.com/js/script.js' } = Astro.props;
|
||||
|
||||
// Create service instance
|
||||
const analytics = createPlausibleAnalytics({ domain, scriptUrl });
|
||||
const adapter = analytics.getAdapter();
|
||||
const scriptTag = (adapter as any).getScriptTag?.() || '';
|
||||
---
|
||||
|
||||
<!-- Analytics Script -->
|
||||
{scriptTag && <Fragment set:html={scriptTag} />}
|
||||
|
||||
<!-- Analytics Event Tracking -->
|
||||
<script>
|
||||
// Initialize analytics service in browser
|
||||
import { createPlausibleAnalytics } from '../utils/analytics';
|
||||
|
||||
const analytics = createPlausibleAnalytics({
|
||||
domain: document.documentElement.lang || 'mintel.me',
|
||||
scriptUrl: 'https://plausible.yourdomain.com/js/script.js'
|
||||
});
|
||||
|
||||
// Track page load performance
|
||||
const trackPageLoad = () => {
|
||||
const perfData = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
|
||||
if (perfData && typeof perfData.loadEventEnd === 'number' && typeof perfData.startTime === 'number') {
|
||||
const loadTime = perfData.loadEventEnd - perfData.startTime;
|
||||
analytics.trackPageLoad(
|
||||
loadTime,
|
||||
window.location.pathname,
|
||||
navigator.userAgent
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Track outbound links
|
||||
const trackOutboundLinks = () => {
|
||||
document.querySelectorAll('a[href^="http"]').forEach(link => {
|
||||
const anchor = link as HTMLAnchorElement;
|
||||
if (!anchor.href.includes(window.location.hostname)) {
|
||||
anchor.addEventListener('click', () => {
|
||||
analytics.trackOutboundLink(anchor.href, anchor.textContent?.trim() || 'unknown');
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Track search
|
||||
const trackSearch = () => {
|
||||
const searchInput = document.querySelector('input[type="search"]') as HTMLInputElement;
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('search', (e) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target.value) {
|
||||
analytics.trackSearch(target.value, window.location.pathname);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize all tracking
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
trackPageLoad();
|
||||
trackOutboundLinks();
|
||||
trackSearch();
|
||||
});
|
||||
</script>
|
||||
@@ -2,6 +2,7 @@
|
||||
import '../styles/global.css';
|
||||
import { Footer } from '../components/Footer';
|
||||
import { Hero } from '../components/Hero';
|
||||
import Analytics from '../components/Analytics.astro';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
@@ -55,6 +56,9 @@ const { title, description = "Technical problem solver's blog - practical insigh
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Analytics Component -->
|
||||
<Analytics />
|
||||
|
||||
<!-- Global JavaScript for interactive elements -->
|
||||
<script>
|
||||
// Global interactive elements manager
|
||||
|
||||
93
src/utils/analytics/index.ts
Normal file
93
src/utils/analytics/index.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Analytics Service - Main entry point with DI
|
||||
* Clean constructor-based dependency injection
|
||||
*/
|
||||
|
||||
import type { AnalyticsAdapter, AnalyticsEvent, AnalyticsConfig } from './interfaces';
|
||||
import { PlausibleAdapter } from './plausible-adapter';
|
||||
|
||||
export class AnalyticsService {
|
||||
private adapter: AnalyticsAdapter;
|
||||
|
||||
/**
|
||||
* Create analytics service with dependency injection
|
||||
* @param adapter - Analytics adapter implementation
|
||||
*/
|
||||
constructor(adapter: AnalyticsAdapter) {
|
||||
this.adapter = adapter;
|
||||
}
|
||||
|
||||
getAdapter(): AnalyticsAdapter {
|
||||
return this.adapter;
|
||||
}
|
||||
|
||||
async track(event: AnalyticsEvent): Promise<void> {
|
||||
return this.adapter.track(event);
|
||||
}
|
||||
|
||||
async page(path: string, props?: Record<string, any>): Promise<void> {
|
||||
if (this.adapter.page) {
|
||||
return this.adapter.page(path, props);
|
||||
}
|
||||
return this.track({ name: 'Pageview', props: { path, ...props } });
|
||||
}
|
||||
|
||||
async identify(userId: string, traits?: Record<string, any>): Promise<void> {
|
||||
if (this.adapter.identify) {
|
||||
return this.adapter.identify(userId, traits);
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience methods
|
||||
async trackEvent(name: string, props?: Record<string, any>): Promise<void> {
|
||||
return this.track({ name, props });
|
||||
}
|
||||
|
||||
async trackOutboundLink(url: string, text: string): Promise<void> {
|
||||
return this.track({
|
||||
name: 'Outbound Link',
|
||||
props: { url, text }
|
||||
});
|
||||
}
|
||||
|
||||
async trackSearch(query: string, path: string): Promise<void> {
|
||||
return this.track({
|
||||
name: 'Search',
|
||||
props: { query, path }
|
||||
});
|
||||
}
|
||||
|
||||
async trackPageLoad(loadTime: number, path: string, userAgent: string): Promise<void> {
|
||||
return this.track({
|
||||
name: 'Page Load',
|
||||
props: { loadTime: Math.round(loadTime), path, userAgent }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Factory function
|
||||
export function createPlausibleAnalytics(config: AnalyticsConfig): AnalyticsService {
|
||||
return new AnalyticsService(new PlausibleAdapter(config));
|
||||
}
|
||||
|
||||
// Default singleton
|
||||
let defaultAnalytics: AnalyticsService | null = null;
|
||||
|
||||
export function getDefaultAnalytics(): AnalyticsService {
|
||||
if (!defaultAnalytics) {
|
||||
defaultAnalytics = createPlausibleAnalytics({
|
||||
domain: 'mintel.me',
|
||||
scriptUrl: 'https://plausible.yourdomain.com/js/script.js'
|
||||
});
|
||||
}
|
||||
return defaultAnalytics;
|
||||
}
|
||||
|
||||
// Convenience function
|
||||
export async function track(name: string, props?: Record<string, any>): Promise<void> {
|
||||
return getDefaultAnalytics().trackEvent(name, props);
|
||||
}
|
||||
|
||||
// Re-export for advanced usage
|
||||
export type { AnalyticsAdapter, AnalyticsEvent, AnalyticsConfig };
|
||||
export { PlausibleAdapter };
|
||||
20
src/utils/analytics/interfaces.ts
Normal file
20
src/utils/analytics/interfaces.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Analytics interfaces - decoupled contracts
|
||||
*/
|
||||
|
||||
export interface AnalyticsEvent {
|
||||
name: string;
|
||||
props?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface AnalyticsAdapter {
|
||||
track(event: AnalyticsEvent): Promise<void>;
|
||||
identify?(userId: string, traits?: Record<string, any>): Promise<void>;
|
||||
page?(path: string, props?: Record<string, any>): Promise<void>;
|
||||
getScriptTag?(): string;
|
||||
}
|
||||
|
||||
export interface AnalyticsConfig {
|
||||
domain?: string;
|
||||
scriptUrl?: string;
|
||||
}
|
||||
38
src/utils/analytics/plausible-adapter.ts
Normal file
38
src/utils/analytics/plausible-adapter.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Plausible Analytics Adapter
|
||||
* Decoupled implementation
|
||||
*/
|
||||
|
||||
import type { AnalyticsAdapter, AnalyticsEvent, AnalyticsConfig } from './interfaces';
|
||||
|
||||
export class PlausibleAdapter implements AnalyticsAdapter {
|
||||
private domain: string;
|
||||
private scriptUrl: string;
|
||||
|
||||
constructor(config: AnalyticsConfig) {
|
||||
this.domain = config.domain || 'mintel.me';
|
||||
this.scriptUrl = config.scriptUrl || 'https://plausible.yourdomain.com/js/script.js';
|
||||
}
|
||||
|
||||
async track(event: AnalyticsEvent): Promise<void> {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const w = window as any;
|
||||
if (w.plausible) {
|
||||
w.plausible(event.name, {
|
||||
props: event.props
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async page(path: string, props?: Record<string, any>): Promise<void> {
|
||||
await this.track({
|
||||
name: 'Pageview',
|
||||
props: { path, ...props }
|
||||
});
|
||||
}
|
||||
|
||||
getScriptTag(): string {
|
||||
return `<script defer data-domain="${this.domain}" src="${this.scriptUrl}"></script>`;
|
||||
}
|
||||
}
|
||||
101
src/utils/cache/index.ts
vendored
Normal file
101
src/utils/cache/index.ts
vendored
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Cache Service - Main entry point with DI
|
||||
* Simple constructor-based dependency injection
|
||||
*/
|
||||
|
||||
import type { CacheAdapter, CacheConfig } from './interfaces';
|
||||
import { MemoryCacheAdapter } from './memory-adapter';
|
||||
import { RedisCacheAdapter } from './redis-adapter';
|
||||
|
||||
export class CacheService {
|
||||
private adapter: CacheAdapter;
|
||||
|
||||
/**
|
||||
* Create cache service with dependency injection
|
||||
* @param adapter - Cache adapter implementation
|
||||
*/
|
||||
constructor(adapter: CacheAdapter) {
|
||||
this.adapter = adapter;
|
||||
}
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
return this.adapter.get<T>(key);
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
|
||||
return this.adapter.set(key, value, ttl);
|
||||
}
|
||||
|
||||
async del(key: string): Promise<void> {
|
||||
return this.adapter.del(key);
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
return this.adapter.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache wrapper for functions
|
||||
* Returns cached result or executes function and caches result
|
||||
*/
|
||||
async wrap<T>(key: string, fn: () => Promise<T>, ttl?: number): Promise<T> {
|
||||
const cached = await this.get<T>(key);
|
||||
if (cached !== null) return cached;
|
||||
|
||||
const result = await fn();
|
||||
await this.set(key, result, ttl);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Factory functions for easy instantiation
|
||||
export function createMemoryCache(config?: CacheConfig): CacheService {
|
||||
return new CacheService(new MemoryCacheAdapter(config));
|
||||
}
|
||||
|
||||
export function createRedisCache(config?: CacheConfig & { redisUrl?: string }): CacheService {
|
||||
return new CacheService(new RedisCacheAdapter(config));
|
||||
}
|
||||
|
||||
// Default singleton instance - uses Redis if available, otherwise Memory
|
||||
let defaultCache: CacheService | null = null;
|
||||
|
||||
export async function getDefaultCache(): Promise<CacheService> {
|
||||
if (!defaultCache) {
|
||||
// Try Redis first
|
||||
const redisAdapter = new RedisCacheAdapter({
|
||||
prefix: 'mintel:blog:',
|
||||
defaultTTL: 3600
|
||||
});
|
||||
|
||||
const redisAvailable = await redisAdapter.get('__test__')
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (redisAvailable) {
|
||||
console.log('✅ Using Redis cache');
|
||||
defaultCache = new CacheService(redisAdapter);
|
||||
} else {
|
||||
console.log('⚠️ Redis unavailable, using Memory cache');
|
||||
defaultCache = createMemoryCache({
|
||||
prefix: 'mintel:blog:',
|
||||
defaultTTL: 3600
|
||||
});
|
||||
}
|
||||
}
|
||||
return defaultCache;
|
||||
}
|
||||
|
||||
// Convenience function using default cache
|
||||
export async function withCache<T>(
|
||||
key: string,
|
||||
fn: () => Promise<T>,
|
||||
ttl?: number
|
||||
): Promise<T> {
|
||||
const cache = await getDefaultCache();
|
||||
return cache.wrap(key, fn, ttl);
|
||||
}
|
||||
|
||||
// Re-export interfaces and adapters for advanced usage
|
||||
export type { CacheAdapter, CacheConfig };
|
||||
export { MemoryCacheAdapter, RedisCacheAdapter };
|
||||
15
src/utils/cache/interfaces.ts
vendored
Normal file
15
src/utils/cache/interfaces.ts
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Cache interfaces - decoupled contracts
|
||||
*/
|
||||
|
||||
export interface CacheAdapter {
|
||||
get<T>(key: string): Promise<T | null>;
|
||||
set<T>(key: string, value: T, ttl?: number): Promise<void>;
|
||||
del(key: string): Promise<void>;
|
||||
clear(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface CacheConfig {
|
||||
prefix?: string;
|
||||
defaultTTL?: number;
|
||||
}
|
||||
43
src/utils/cache/memory-adapter.ts
vendored
Normal file
43
src/utils/cache/memory-adapter.ts
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Memory Cache Adapter
|
||||
* Simple in-memory implementation
|
||||
*/
|
||||
|
||||
import type { CacheAdapter, CacheConfig } from './interfaces';
|
||||
|
||||
export class MemoryCacheAdapter implements CacheAdapter {
|
||||
private cache = new Map<string, { value: any; expiry: number }>();
|
||||
private defaultTTL: number;
|
||||
|
||||
constructor(config: CacheConfig = {}) {
|
||||
this.defaultTTL = config.defaultTTL || 3600;
|
||||
}
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
const item = this.cache.get(key);
|
||||
if (!item) return null;
|
||||
|
||||
if (Date.now() > item.expiry) {
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return item.value as T;
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
|
||||
const finalTTL = ttl || this.defaultTTL;
|
||||
this.cache.set(key, {
|
||||
value,
|
||||
expiry: Date.now() + (finalTTL * 1000)
|
||||
});
|
||||
}
|
||||
|
||||
async del(key: string): Promise<void> {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
95
src/utils/cache/redis-adapter.ts
vendored
Normal file
95
src/utils/cache/redis-adapter.ts
vendored
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Redis Cache Adapter
|
||||
* Decoupled Redis implementation
|
||||
*/
|
||||
|
||||
import type { CacheAdapter, CacheConfig } from './interfaces';
|
||||
|
||||
export class RedisCacheAdapter implements CacheAdapter {
|
||||
private client: any = null;
|
||||
private prefix: string;
|
||||
private defaultTTL: number;
|
||||
private redisUrl: string;
|
||||
|
||||
constructor(config: CacheConfig & { redisUrl?: string } = {}) {
|
||||
this.prefix = config.prefix || 'mintel:';
|
||||
this.defaultTTL = config.defaultTTL || 3600;
|
||||
this.redisUrl = config.redisUrl || process.env.REDIS_URL || 'redis://localhost:6379';
|
||||
}
|
||||
|
||||
private async init(): Promise<boolean> {
|
||||
if (this.client !== null) return true;
|
||||
|
||||
try {
|
||||
const Redis = await import('ioredis');
|
||||
this.client = new Redis.default(this.redisUrl);
|
||||
|
||||
this.client.on('error', (err: Error) => {
|
||||
console.warn('Redis connection error:', err.message);
|
||||
this.client = null;
|
||||
});
|
||||
|
||||
this.client.on('connect', () => {
|
||||
console.log('✅ Redis connected');
|
||||
});
|
||||
|
||||
// Test connection
|
||||
await this.client.set(this.prefix + '__test__', 'ok', 'EX', 1);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Redis unavailable:', error);
|
||||
this.client = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
const available = await this.init();
|
||||
if (!available || !this.client) return null;
|
||||
|
||||
try {
|
||||
const data = await this.client.get(this.prefix + key);
|
||||
return data ? JSON.parse(data) : null;
|
||||
} catch (error) {
|
||||
console.warn('Redis get error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
|
||||
const available = await this.init();
|
||||
if (!available || !this.client) return;
|
||||
|
||||
try {
|
||||
const finalTTL = ttl || this.defaultTTL;
|
||||
await this.client.setex(this.prefix + key, finalTTL, JSON.stringify(value));
|
||||
} catch (error) {
|
||||
console.warn('Redis set error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async del(key: string): Promise<void> {
|
||||
const available = await this.init();
|
||||
if (!available || !this.client) return;
|
||||
|
||||
try {
|
||||
await this.client.del(this.prefix + key);
|
||||
} catch (error) {
|
||||
console.warn('Redis del error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
const available = await this.init();
|
||||
if (!available || !this.client) return;
|
||||
|
||||
try {
|
||||
const keys = await this.client.keys(this.prefix + '*');
|
||||
if (keys.length > 0) {
|
||||
await this.client.del(...keys);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Redis clear error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user