First Init
Deploy to Production / Build & Verify (push) Failing after 5m56s
Ping Search Engines / Notify Search Engines (push) Successful in 2s
Deploy to Production / Pre-Deploy Tests (push) Has been skipped
Deploy to Production / Deploy to Railway (push) Has been skipped
Deploy to Production / Deploy to Render (push) Has been skipped
Deploy to Production / Deploy to VPS (PM2) (push) Has been skipped
Deploy to Production / Deploy to Fly.io (push) Has been skipped
Deploy to Production / Post-Deploy Verification (push) Has been skipped
Deploy to Production / Notify on Failure (push) Successful in 2s
E2E Test Suite / Critical User Journeys (push) Has been skipped
E2E Test Suite / API Integration Tests (push) Has been skipped
E2E Test Suite / Smoke Tests (P0) (push) Failing after 11m26s
E2E Test Suite / Form Interaction Tests (push) Failing after 11m42s
E2E Test Suite / Destructive & Chaos Tests (push) Failing after 12m2s
E2E Test Suite / Cross-Browser Regression (chromium) (push) Failing after 16m14s
E2E Test Suite / Cross-Browser Regression (webkit) (push) Failing after 17m45s
E2E Test Suite / Cross-Browser Regression (firefox) (push) Failing after 25m23s
E2E Test Suite / Security Header Tests (push) Failing after 7m55s
E2E Test Suite / Test Report Summary (push) Failing after 20s
E2E Test Suite / Mobile Device Tests (push) Failing after 2h49m9s
Uptime Monitor / Health & Response Time (push) Failing after 2s
Uptime Monitor / SSL Certificate (push) Successful in 2s
Uptime Monitor / Send Alerts (push) Failing after 3s
Uptime Monitor / Record Uptime Success (push) Has been skipped
Deploy to Production / Build & Verify (push) Failing after 5m56s
Ping Search Engines / Notify Search Engines (push) Successful in 2s
Deploy to Production / Pre-Deploy Tests (push) Has been skipped
Deploy to Production / Deploy to Railway (push) Has been skipped
Deploy to Production / Deploy to Render (push) Has been skipped
Deploy to Production / Deploy to VPS (PM2) (push) Has been skipped
Deploy to Production / Deploy to Fly.io (push) Has been skipped
Deploy to Production / Post-Deploy Verification (push) Has been skipped
Deploy to Production / Notify on Failure (push) Successful in 2s
E2E Test Suite / Critical User Journeys (push) Has been skipped
E2E Test Suite / API Integration Tests (push) Has been skipped
E2E Test Suite / Smoke Tests (P0) (push) Failing after 11m26s
E2E Test Suite / Form Interaction Tests (push) Failing after 11m42s
E2E Test Suite / Destructive & Chaos Tests (push) Failing after 12m2s
E2E Test Suite / Cross-Browser Regression (chromium) (push) Failing after 16m14s
E2E Test Suite / Cross-Browser Regression (webkit) (push) Failing after 17m45s
E2E Test Suite / Cross-Browser Regression (firefox) (push) Failing after 25m23s
E2E Test Suite / Security Header Tests (push) Failing after 7m55s
E2E Test Suite / Test Report Summary (push) Failing after 20s
E2E Test Suite / Mobile Device Tests (push) Failing after 2h49m9s
Uptime Monitor / Health & Response Time (push) Failing after 2s
Uptime Monitor / SSL Certificate (push) Successful in 2s
Uptime Monitor / Send Alerts (push) Failing after 3s
Uptime Monitor / Record Uptime Success (push) Has been skipped
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Analytics event tracking utility
|
||||
*
|
||||
* Supports both Google Analytics 4 (gtag) and Plausible.
|
||||
* Safe to call even when analytics is not configured — silently no-ops.
|
||||
*
|
||||
* Usage:
|
||||
* import { trackEvent } from '../utils/analytics';
|
||||
* trackEvent('form_submit', { form_name: 'contact' });
|
||||
*/
|
||||
|
||||
interface EventParams {
|
||||
[key: string]: string | number | boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a custom event in GA4 and/or Plausible
|
||||
*/
|
||||
export function trackEvent(eventName: string, params: EventParams = {}): void {
|
||||
// Guard: skip if DNT is enabled
|
||||
if (navigator.doNotTrack === '1' || (window as any).doNotTrack === '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Google Analytics 4
|
||||
if (typeof (window as any).gtag === 'function') {
|
||||
(window as any).gtag('event', eventName, params);
|
||||
}
|
||||
|
||||
// Plausible Analytics
|
||||
if (typeof (window as any).plausible === 'function') {
|
||||
(window as any).plausible(eventName, { props: params });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track external link clicks
|
||||
* Call this on anchor elements pointing to external domains.
|
||||
*/
|
||||
export function trackExternalLink(url: string, label?: string): void {
|
||||
trackEvent('external_link_click', {
|
||||
link_url: url,
|
||||
link_label: label ?? url,
|
||||
outbound: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Track form submission events
|
||||
*/
|
||||
export function trackFormSubmit(formName: string, success: boolean): void {
|
||||
trackEvent(success ? 'form_submit_success' : 'form_submit_error', {
|
||||
form_name: formName,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Track newsletter signup events
|
||||
*/
|
||||
export function trackNewsletterSignup(success: boolean): void {
|
||||
trackEvent(success ? 'newsletter_signup_success' : 'newsletter_signup_error', {
|
||||
form_name: 'newsletter',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Track portfolio filter clicks
|
||||
*/
|
||||
export function trackPortfolioFilter(filter: string): void {
|
||||
trackEvent('portfolio_filter', {
|
||||
filter_category: filter,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Track portfolio case study views
|
||||
*/
|
||||
export function trackCaseStudyView(projectId: string, projectTitle: string): void {
|
||||
trackEvent('case_study_view', {
|
||||
project_id: projectId,
|
||||
project_title: projectTitle,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-bind external link tracking to all external anchor tags on the page.
|
||||
* Call once after DOM is ready.
|
||||
*/
|
||||
export function bindExternalLinkTracking(): void {
|
||||
const currentHost = window.location.hostname;
|
||||
|
||||
document.querySelectorAll<HTMLAnchorElement>('a[href]').forEach((anchor) => {
|
||||
try {
|
||||
const url = new URL(anchor.href, window.location.href);
|
||||
const isExternal = url.hostname !== currentHost && url.hostname !== '';
|
||||
|
||||
if (isExternal) {
|
||||
anchor.addEventListener('click', () => {
|
||||
trackExternalLink(url.href, anchor.textContent?.trim() || anchor.getAttribute('aria-label') || url.href);
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Invalid URL, skip
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Scroll-reveal & micro-interaction utilities
|
||||
* Uses Intersection Observer — no external libraries required.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Initialise scroll-reveal for all [data-animate] elements.
|
||||
* Call once per page after DOM is ready.
|
||||
*/
|
||||
export function initScrollReveal(): void {
|
||||
// Skip entirely when the user prefers reduced motion
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
document.querySelectorAll<HTMLElement>('[data-animate]').forEach((el) => {
|
||||
el.classList.add('is-visible');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('is-visible');
|
||||
// Unobserve after triggering — animate once
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
threshold: 0.12,
|
||||
rootMargin: '0px 0px -40px 0px',
|
||||
}
|
||||
);
|
||||
|
||||
document.querySelectorAll('[data-animate]').forEach((el) => observer.observe(el));
|
||||
}
|
||||
|
||||
/**
|
||||
* Animates a numeric counter from 0 to `target`.
|
||||
* Attaches to elements with [data-counter] attribute.
|
||||
*/
|
||||
export function initCounters(): void {
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||
|
||||
const counterObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.isIntersecting) return;
|
||||
const el = entry.target as HTMLElement;
|
||||
const target = parseInt(el.dataset.counter ?? '0', 10);
|
||||
const duration = parseInt(el.dataset.counterDuration ?? '2000', 10);
|
||||
const suffix = el.dataset.counterSuffix ?? '';
|
||||
animateCounter(el, target, duration, suffix);
|
||||
counterObserver.unobserve(el);
|
||||
});
|
||||
},
|
||||
{ threshold: 0.5 }
|
||||
);
|
||||
|
||||
document.querySelectorAll('[data-counter]').forEach((el) =>
|
||||
counterObserver.observe(el)
|
||||
);
|
||||
}
|
||||
|
||||
function animateCounter(
|
||||
el: HTMLElement,
|
||||
target: number,
|
||||
duration: number,
|
||||
suffix: string
|
||||
): void {
|
||||
const start = performance.now();
|
||||
const step = (now: number) => {
|
||||
const elapsed = now - start;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
// Ease-out cubic
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
const current = Math.floor(eased * target);
|
||||
el.textContent = current.toLocaleString() + suffix;
|
||||
el.classList.toggle('counting', progress < 1);
|
||||
if (progress < 1) requestAnimationFrame(step);
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a radial highlight that follows the cursor on `.btn-ripple` elements.
|
||||
*/
|
||||
export function initButtonRipple(): void {
|
||||
document.querySelectorAll<HTMLElement>('.btn-ripple').forEach((btn) => {
|
||||
btn.addEventListener('mousemove', (e: MouseEvent) => {
|
||||
const rect = btn.getBoundingClientRect();
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 100;
|
||||
const y = ((e.clientY - rect.top) / rect.height) * 100;
|
||||
btn.style.setProperty('--x', `${x}%`);
|
||||
btn.style.setProperty('--y', `${y}%`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills `.progress-bar` elements to their `--progress-width` CSS var
|
||||
* once they scroll into view.
|
||||
*/
|
||||
export function initProgressBars(): void {
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
document.querySelectorAll<HTMLElement>('.progress-bar').forEach((el) => {
|
||||
el.classList.add('is-visible');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const pbObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('is-visible');
|
||||
pbObserver.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.4 }
|
||||
);
|
||||
|
||||
document.querySelectorAll('.progress-bar').forEach((el) => pbObserver.observe(el));
|
||||
}
|
||||
|
||||
/** Convenience: run all animation initialisers in one call. */
|
||||
export function initAllAnimations(): void {
|
||||
initScrollReveal();
|
||||
initCounters();
|
||||
initButtonRipple();
|
||||
initProgressBars();
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Image Optimization Utilities
|
||||
*
|
||||
* Helper functions for optimizing external image URLs
|
||||
* and generating responsive image srcsets.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Optimize Unsplash URL with WebP format and quality settings
|
||||
*/
|
||||
export function optimizeUnsplashUrl(
|
||||
url: string,
|
||||
options: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
quality?: number;
|
||||
format?: 'webp' | 'auto';
|
||||
} = {}
|
||||
): string {
|
||||
if (!url.includes('unsplash.com')) return url;
|
||||
|
||||
const { width, height, quality = 80, format = 'webp' } = options;
|
||||
|
||||
// Parse existing URL
|
||||
const urlObj = new URL(url);
|
||||
|
||||
// Set optimization parameters
|
||||
if (width) urlObj.searchParams.set('w', width.toString());
|
||||
if (height) urlObj.searchParams.set('h', height.toString());
|
||||
urlObj.searchParams.set('q', quality.toString());
|
||||
urlObj.searchParams.set('fm', format);
|
||||
urlObj.searchParams.set('fit', 'crop');
|
||||
urlObj.searchParams.set('auto', 'format,compress');
|
||||
|
||||
return urlObj.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate srcset for responsive images
|
||||
*/
|
||||
export function generateSrcset(
|
||||
url: string,
|
||||
widths: number[] = [320, 640, 960, 1280],
|
||||
aspectRatio?: number
|
||||
): string {
|
||||
if (!url.includes('unsplash.com')) return '';
|
||||
|
||||
return widths
|
||||
.map((w) => {
|
||||
const height = aspectRatio ? Math.round(w / aspectRatio) : undefined;
|
||||
const optimizedUrl = optimizeUnsplashUrl(url, { width: w, height });
|
||||
return `${optimizedUrl} ${w}w`;
|
||||
})
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Default image sizes attribute for responsive images
|
||||
*/
|
||||
export const defaultSizes = '(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw';
|
||||
|
||||
/**
|
||||
* Generate blur placeholder data URL (10x10 pixel)
|
||||
*/
|
||||
export function getBlurPlaceholder(color: string = '#1e293b'): string {
|
||||
return `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10'%3E%3Crect fill='${encodeURIComponent(color)}' width='10' height='10'/%3E%3C/svg%3E`;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Structured Logger
|
||||
*
|
||||
* Provides consistent, structured logging across the application.
|
||||
* - In development: pretty-prints to console with colors
|
||||
* - In production: outputs JSON for log aggregation (Logtail, Datadog, etc.)
|
||||
*
|
||||
* Usage:
|
||||
* import { logger } from '@/utils/logger';
|
||||
* logger.info('api.contact', 'Form submitted', { email: '...' });
|
||||
* logger.error('api.contact', 'Email failed', { error: err.message });
|
||||
*/
|
||||
|
||||
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
interface LogEntry {
|
||||
level: LogLevel;
|
||||
scope: string;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const LEVELS: Record<LogLevel, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
};
|
||||
|
||||
// Minimum log level — override via LOG_LEVEL env var
|
||||
function getMinLevel(): LogLevel {
|
||||
const env = (typeof process !== 'undefined' && process.env.LOG_LEVEL) ||
|
||||
(typeof import.meta !== 'undefined' && (import.meta as { env?: { LOG_LEVEL?: string } }).env?.LOG_LEVEL);
|
||||
if (env && env in LEVELS) return env as LogLevel;
|
||||
return (typeof import.meta !== 'undefined' && (import.meta as { env?: { PROD?: boolean } }).env?.PROD)
|
||||
? 'info'
|
||||
: 'debug';
|
||||
}
|
||||
|
||||
function shouldLog(level: LogLevel): boolean {
|
||||
return LEVELS[level] >= LEVELS[getMinLevel()];
|
||||
}
|
||||
|
||||
function formatEntry(level: LogLevel, scope: string, message: string, meta?: Record<string, unknown>): LogEntry {
|
||||
return {
|
||||
level,
|
||||
scope,
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
...meta,
|
||||
};
|
||||
}
|
||||
|
||||
const isProd = typeof import.meta !== 'undefined'
|
||||
? (import.meta as { env?: { PROD?: boolean } }).env?.PROD === true
|
||||
: process.env.NODE_ENV === 'production';
|
||||
|
||||
const COLORS: Record<LogLevel, string> = {
|
||||
debug: '\x1b[90m', // gray
|
||||
info: '\x1b[36m', // cyan
|
||||
warn: '\x1b[33m', // yellow
|
||||
error: '\x1b[31m', // red
|
||||
};
|
||||
const RESET = '\x1b[0m';
|
||||
|
||||
function output(entry: LogEntry): void {
|
||||
if (isProd) {
|
||||
// JSON output for log aggregation in production
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(JSON.stringify(entry));
|
||||
} else {
|
||||
const color = COLORS[entry.level] ?? '';
|
||||
const { level, scope, message, timestamp, ...rest } = entry;
|
||||
const meta = Object.keys(rest).length > 0 ? ' ' + JSON.stringify(rest) : '';
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`${color}[${level.toUpperCase()}]${RESET} ${timestamp} [${scope}] ${message}${meta}`);
|
||||
}
|
||||
}
|
||||
|
||||
function log(level: LogLevel, scope: string, message: string, meta?: Record<string, unknown>): void {
|
||||
if (!shouldLog(level)) return;
|
||||
output(formatEntry(level, scope, message, meta));
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
debug: (scope: string, message: string, meta?: Record<string, unknown>) => log('debug', scope, message, meta),
|
||||
info: (scope: string, message: string, meta?: Record<string, unknown>) => log('info', scope, message, meta),
|
||||
warn: (scope: string, message: string, meta?: Record<string, unknown>) => log('warn', scope, message, meta),
|
||||
error: (scope: string, message: string, meta?: Record<string, unknown>) => log('error', scope, message, meta),
|
||||
};
|
||||
|
||||
/**
|
||||
* Log an API request/response pair. Use at the end of API handlers.
|
||||
*/
|
||||
export function logApiRequest(opts: {
|
||||
scope: string;
|
||||
method: string;
|
||||
path: string;
|
||||
status: number;
|
||||
ip?: string;
|
||||
durationMs?: number;
|
||||
meta?: Record<string, unknown>;
|
||||
}): void {
|
||||
const { scope, method, path, status, ip, durationMs, meta } = opts;
|
||||
const level: LogLevel = status >= 500 ? 'error' : status >= 400 ? 'warn' : 'info';
|
||||
log(level, scope, `${method} ${path} ${status}`, {
|
||||
method,
|
||||
path,
|
||||
status,
|
||||
...(ip && { ip }),
|
||||
...(durationMs !== undefined && { durationMs }),
|
||||
...meta,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Sentry Error Tracking — Optional Integration
|
||||
*
|
||||
* Activated only when SENTRY_DSN environment variable is set.
|
||||
* Uses the Sentry REST API directly (no SDK dependency required).
|
||||
*
|
||||
* For full SDK integration, install @sentry/node and replace captureException
|
||||
* with the SDK's init() + Sentry.captureException().
|
||||
*
|
||||
* Usage:
|
||||
* import { captureException, captureMessage } from '@/utils/sentry';
|
||||
* captureException(error, { scope: 'api.contact', extra: { ip } });
|
||||
* captureMessage('Rate limit hit', 'warning', { scope: 'api.contact' });
|
||||
*/
|
||||
|
||||
import { logger } from './logger';
|
||||
|
||||
interface SentryContext {
|
||||
scope?: string; // e.g. 'api.contact'
|
||||
user?: { ip?: string; email?: string };
|
||||
extra?: Record<string, unknown>;
|
||||
tags?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface SentryEnvelope {
|
||||
event_id: string;
|
||||
timestamp: string;
|
||||
platform: string;
|
||||
level: string;
|
||||
environment: string;
|
||||
release?: string;
|
||||
transaction?: string;
|
||||
logger?: string;
|
||||
exception?: {
|
||||
values: Array<{
|
||||
type: string;
|
||||
value: string;
|
||||
stacktrace?: { frames: Array<{ filename?: string; function?: string; lineno?: number }> };
|
||||
}>;
|
||||
};
|
||||
message?: string;
|
||||
user?: { ip_address?: string; email?: string };
|
||||
extra?: Record<string, unknown>;
|
||||
tags?: Record<string, string>;
|
||||
}
|
||||
|
||||
function getSentryDsn(): string | undefined {
|
||||
try {
|
||||
return (import.meta as { env?: { SENTRY_DSN?: string } }).env?.SENTRY_DSN;
|
||||
} catch {
|
||||
return process.env.SENTRY_DSN;
|
||||
}
|
||||
}
|
||||
|
||||
function getEnvironment(): string {
|
||||
try {
|
||||
return (import.meta as { env?: { PROD?: boolean } }).env?.PROD ? 'production' : 'development';
|
||||
} catch {
|
||||
return process.env.NODE_ENV ?? 'development';
|
||||
}
|
||||
}
|
||||
|
||||
function getRelease(): string | undefined {
|
||||
try {
|
||||
return process.env.npm_package_version ?? process.env.RELEASE_VERSION;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function generateEventId(): string {
|
||||
return Array.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16)).join('');
|
||||
}
|
||||
|
||||
/** Parse DSN into project URL and auth header */
|
||||
function parseDsn(dsn: string): { url: string; auth: string } | null {
|
||||
try {
|
||||
const url = new URL(dsn);
|
||||
const key = url.username;
|
||||
const projectId = url.pathname.replace('/', '');
|
||||
const storeUrl = `${url.protocol}//${url.host}/api/${projectId}/store/`;
|
||||
return { url: storeUrl, auth: `Sentry sentry_version=7, sentry_key=${key}` };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendToSentry(payload: SentryEnvelope): Promise<void> {
|
||||
const dsn = getSentryDsn();
|
||||
if (!dsn) return;
|
||||
|
||||
const parsed = parseDsn(dsn);
|
||||
if (!parsed) {
|
||||
logger.warn('sentry', 'Invalid SENTRY_DSN format — skipping event');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(parsed.url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Sentry-Auth': parsed.auth,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
logger.warn('sentry', 'Failed to send event to Sentry', { status: res.status });
|
||||
}
|
||||
} catch (err) {
|
||||
// Never let Sentry reporting crash the application
|
||||
logger.warn('sentry', 'Network error sending to Sentry', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture an exception and send to Sentry (if configured).
|
||||
* Also logs the error via the structured logger.
|
||||
*/
|
||||
export async function captureException(
|
||||
error: unknown,
|
||||
context: SentryContext = {}
|
||||
): Promise<void> {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
const scope = context.scope ?? 'app';
|
||||
|
||||
// Always log locally
|
||||
logger.error(scope, err.message, {
|
||||
error: err.name,
|
||||
stack: err.stack?.split('\n').slice(0, 5).join(' | '),
|
||||
...context.extra,
|
||||
});
|
||||
|
||||
// Send to Sentry if DSN is configured
|
||||
const dsn = getSentryDsn();
|
||||
if (!dsn) return;
|
||||
|
||||
const frames = err.stack
|
||||
?.split('\n')
|
||||
.slice(1)
|
||||
.map((line) => {
|
||||
const match = line.trim().match(/at\s+(.+?)\s+\((.+?):(\d+):\d+\)/) ??
|
||||
line.trim().match(/at\s+(.+?):(\d+):\d+/);
|
||||
if (match) {
|
||||
return {
|
||||
function: match[1],
|
||||
filename: match[2] ?? match[1],
|
||||
lineno: parseInt(match[3] ?? match[2], 10),
|
||||
};
|
||||
}
|
||||
return { filename: line.trim() };
|
||||
}) ?? [];
|
||||
|
||||
await sendToSentry({
|
||||
event_id: generateEventId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
platform: 'node',
|
||||
level: 'error',
|
||||
environment: getEnvironment(),
|
||||
release: getRelease(),
|
||||
logger: scope,
|
||||
transaction: scope,
|
||||
exception: {
|
||||
values: [{
|
||||
type: err.name,
|
||||
value: err.message,
|
||||
stacktrace: { frames },
|
||||
}],
|
||||
},
|
||||
user: context.user
|
||||
? { ip_address: context.user.ip, email: context.user.email }
|
||||
: undefined,
|
||||
extra: context.extra,
|
||||
tags: context.tags,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a message (non-exception) and send to Sentry (if configured).
|
||||
* Also logs the message via the structured logger.
|
||||
*/
|
||||
export async function captureMessage(
|
||||
message: string,
|
||||
level: 'info' | 'warning' | 'error' = 'info',
|
||||
context: SentryContext = {}
|
||||
): Promise<void> {
|
||||
const scope = context.scope ?? 'app';
|
||||
|
||||
if (level === 'error') {
|
||||
logger.error(scope, message, context.extra);
|
||||
} else if (level === 'warning') {
|
||||
logger.warn(scope, message, context.extra);
|
||||
} else {
|
||||
logger.info(scope, message, context.extra);
|
||||
}
|
||||
|
||||
const dsn = getSentryDsn();
|
||||
if (!dsn) return;
|
||||
|
||||
await sendToSentry({
|
||||
event_id: generateEventId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
platform: 'node',
|
||||
level: level === 'warning' ? 'warning' : level,
|
||||
environment: getEnvironment(),
|
||||
release: getRelease(),
|
||||
logger: scope,
|
||||
message,
|
||||
user: context.user
|
||||
? { ip_address: context.user.ip, email: context.user.email }
|
||||
: undefined,
|
||||
extra: context.extra,
|
||||
tags: context.tags,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
// SEO Utility Functions
|
||||
// Helpers for generating meta tags, structured data, and canonical URLs
|
||||
|
||||
export interface SEOMetadata {
|
||||
title: string;
|
||||
description: string;
|
||||
keywords?: string[];
|
||||
canonicalUrl?: string;
|
||||
ogImage?: string;
|
||||
ogType?: 'website' | 'article' | 'profile';
|
||||
twitterCard?: 'summary' | 'summary_large_image' | 'player';
|
||||
publishedTime?: string;
|
||||
modifiedTime?: string;
|
||||
author?: string;
|
||||
noIndex?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate page title with site name
|
||||
*/
|
||||
export function generatePageTitle(title: string, siteName: string = 'WorkRoot IT Solutions'): string {
|
||||
if (title === 'Home' || title.includes(siteName)) {
|
||||
return siteName;
|
||||
}
|
||||
return `${title} | ${siteName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate keywords from tags and categories
|
||||
*/
|
||||
export function generateKeywords(tags: string[], additionalKeywords: string[] = []): string {
|
||||
const baseKeywords = [
|
||||
'web development',
|
||||
'mobile apps',
|
||||
'AI solutions',
|
||||
'cloud services',
|
||||
'IT consulting',
|
||||
];
|
||||
|
||||
const allKeywords = [...new Set([...baseKeywords, ...tags, ...additionalKeywords])];
|
||||
return allKeywords.join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate breadcrumb structured data
|
||||
*/
|
||||
export function generateBreadcrumbSchema(breadcrumbs: Array<{ name: string; url: string }>, siteUrl: string = 'https://workroot.in') {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: breadcrumbs.map((item, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
name: item.name,
|
||||
item: `${siteUrl}${item.url}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Article structured data for blog posts
|
||||
*/
|
||||
export function generateArticleSchema(
|
||||
title: string,
|
||||
description: string,
|
||||
author: string,
|
||||
publishedTime: string,
|
||||
modifiedTime: string,
|
||||
imageUrl: string,
|
||||
url: string,
|
||||
keywords: string[] = []
|
||||
) {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: title,
|
||||
description: description,
|
||||
image: imageUrl,
|
||||
datePublished: publishedTime,
|
||||
dateModified: modifiedTime,
|
||||
author: {
|
||||
'@type': 'Person',
|
||||
name: author,
|
||||
},
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'WorkRoot IT Solutions',
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: 'https://workroot.in/logo.png',
|
||||
},
|
||||
},
|
||||
mainEntityOfPage: {
|
||||
'@type': 'WebPage',
|
||||
'@id': url,
|
||||
},
|
||||
keywords: keywords.join(', '),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate FAQ structured data
|
||||
*/
|
||||
export function generateFAQSchema(faqs: Array<{ question: string; answer: string }>) {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: faqs.map((faq) => ({
|
||||
'@type': 'Question',
|
||||
name: faq.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: faq.answer,
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Service structured data
|
||||
*/
|
||||
export function generateServiceSchema(
|
||||
serviceName: string,
|
||||
description: string,
|
||||
features: string[] = [],
|
||||
siteUrl: string = 'https://workroot.in'
|
||||
) {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Service',
|
||||
name: serviceName,
|
||||
description: description,
|
||||
provider: {
|
||||
'@type': 'Organization',
|
||||
name: 'WorkRoot IT Solutions',
|
||||
url: siteUrl,
|
||||
},
|
||||
areaServed: {
|
||||
'@type': 'GeoShape',
|
||||
name: 'Worldwide',
|
||||
},
|
||||
serviceType: serviceName,
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
availability: 'https://schema.org/InStock',
|
||||
},
|
||||
hasOfferCatalog: {
|
||||
'@type': 'OfferCatalog',
|
||||
name: `${serviceName} Features`,
|
||||
itemListElement: features.map((feature, index) => ({
|
||||
'@type': 'Offer',
|
||||
itemOffered: {
|
||||
'@type': 'Service',
|
||||
name: feature,
|
||||
},
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate canonical URL
|
||||
*/
|
||||
export function generateCanonicalUrl(pathname: string, siteUrl: string = 'https://workroot.in'): string {
|
||||
// Remove trailing slash for consistency (except for root)
|
||||
const cleanPath = pathname === '/' ? pathname : pathname.replace(/\/$/, '');
|
||||
return `${siteUrl}${cleanPath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate description to optimal length
|
||||
*/
|
||||
export function truncateDescription(description: string, maxLength: number = 160): string {
|
||||
if (description.length <= maxLength) {
|
||||
return description;
|
||||
}
|
||||
|
||||
// Find last complete word before maxLength
|
||||
const truncated = description.substring(0, maxLength);
|
||||
const lastSpace = truncated.lastIndexOf(' ');
|
||||
|
||||
return truncated.substring(0, lastSpace) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Open Graph image URL
|
||||
*/
|
||||
export function generateOGImageUrl(imagePath: string, siteUrl: string = 'https://workroot.in'): string {
|
||||
if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) {
|
||||
return imagePath;
|
||||
}
|
||||
return `${siteUrl}${imagePath.startsWith('/') ? imagePath : `/${imagePath}`}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if URL should be indexed
|
||||
*/
|
||||
export function shouldIndexPage(pathname: string): boolean {
|
||||
const noIndexPatterns = [
|
||||
'/admin',
|
||||
'/private',
|
||||
'/api',
|
||||
'/preview',
|
||||
'/draft',
|
||||
];
|
||||
|
||||
return !noIndexPatterns.some(pattern => pathname.startsWith(pattern));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate social media share URLs
|
||||
*/
|
||||
export function generateShareUrls(url: string, title: string) {
|
||||
const encodedUrl = encodeURIComponent(url);
|
||||
const encodedTitle = encodeURIComponent(title);
|
||||
|
||||
return {
|
||||
twitter: `https://twitter.com/intent/tweet?url=${encodedUrl}&text=${encodedTitle}`,
|
||||
facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`,
|
||||
linkedin: `https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}`,
|
||||
email: `mailto:?subject=${encodedTitle}&body=${encodedUrl}`,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user