Compare commits

..
Author SHA1 Message Date
mmintel 494f69db85 docs: incident ledger with the analytics outage entry
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🏗️ Build (push) Successful in 6m27s
Build & Deploy / 🚀 Deploy (push) Successful in 19s
Build & Deploy / 🩺 Smoke Test (push) Successful in 5s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-10-04 13:10:48 +02:00
mmintel ed35413a44 fix(analytics): identify page views via fetch metadata, not router headers
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🏗️ Build (push) Successful in 6m26s
Build & Deploy / 🚀 Deploy (push) Successful in 19s
Build & Deploy / 🩺 Smoke Test (push) Successful in 5s
Build & Deploy / 🔔 Notify (push) Successful in 3s
Next.js strips the router-internal headers (RSC, next-router-prefetch)
before the request proxy runs, so prefetch detection based on them never
fired: link prefetches were counted as page views. A page view is now a
browser navigation, identified by the standard sec-fetch-dest: document
header, which survives into the proxy. Prefetch payloads, subresource
fetches and metadata-less clients are skipped as non-navigation
requests.
2026-10-04 12:51:54 +02:00
mmintel a54df524c7 feat(analytics): record pageviews server-side only
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🏗️ Build (push) Successful in 6m31s
Build & Deploy / 🚀 Deploy (push) Successful in 19s
Build & Deploy / 🩺 Smoke Test (push) Successful in 5s
Build & Deploy / 🔔 Notify (push) Successful in 2s
Umami never received data: the client-side path (browser beacon to
/stats/api/send) has been dead in production. Pageviews are now
classified and recorded entirely on the server in the request proxy;
no tracking script ships to the browser anymore.

- Pure domain decision (decidePageview) filters real page views from
  assets, infrastructure paths, prefetches and bot user agents
- Pure payload builder maps facts onto the Umami send contract as an
  unnamed event, with the client identity travelling in headers
- proxy.ts wires request facts into the domain and fires the send
  without delaying the response; configured via UMAMI_WEBSITE_ID and
  UMAMI_API_ENDPOINT
- Removes the dead client analytics stack (components, adapters,
  /stats proxy route) and its layout mount
2026-10-04 12:24:13 +02:00
mmintelandClaude Opus 5.5 390936856c feat: message starter in the contact section
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🏗️ Build (push) Successful in 6m26s
Build & Deploy / 🚀 Deploy (push) Successful in 18s
Build & Deploy / 🩺 Smoke Test (push) Successful in 5s
Build & Deploy / 🔔 Notify (push) Successful in 2s
The visitor picks what they need and a prepared email types itself out; one
press opens it in their own mail app with subject and body filled in. The
mailto link is built by a tested pure function (src/domain/email.ts, spec
first) and only in the browser, so the address still never appears in the
page source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:01:03 +02:00
mmintelandClaude Opus 5.5 dc4a01cbe4 feat: binary micro-interactions across the page
Section headlines are written out behind a column of digits, mono labels
resolve from binary, the portrait assembles itself from bits, section indexes
rattle and keep one bit alive, cards get corner marks, the header shows the
scroll position as an eight-bit number, pressed links and buttons shed a few
digits, and the work stage leans towards the pointer. All effects respect
reduced motion and leave the content readable without JavaScript.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:14:22 +02:00
42 changed files with 1303 additions and 864 deletions
+10
View File
@@ -36,6 +36,16 @@ App lives in `apps/web`. Concept and scope: `apps/web/plans/freelance-relaunch.m
- The hero design (binary-digit headline) is approved: do not change its design.
- Look at the result before reporting: take screenshots (desktop and mobile), do not ship blind.
## Motion (binary micro-interactions)
- Building blocks live in `apps/web/src/components/bits/`: `DecodeText` (mono labels resolving from digits),
`BitWipe` (headline revealed behind a column of digits), `BitReveal` (picture assembling from bits),
`BinaryTicker`, `ScrollBits`, `BitBurst`. Reuse them instead of inventing new effects.
- `DecodeText` is for monospaced text only; in proportional type the changing glyph widths make text jump.
- Never put an in-view trigger on an element that clips itself away (clip-path): a fully clipped element
never counts as visible. Observe an unclipped wrapper (see `BitWipe`).
- Every effect must respect `prefers-reduced-motion` and leave content readable without JavaScript.
## Architecture rules
- Functional core, imperative shell. Pure logic goes in `apps/web/src/domain/` (no I/O, no clock, no
+84
View File
@@ -1004,3 +1004,87 @@ pre:has(code[class*="language-"]) {
opacity: 0.4;
}
}
/* ── Binary micro-interactions ─────────────────────────────────────────────── */
/* Digits that fly out from a pressed link or button (see BitBurst). */
.bit-burst {
position: fixed;
z-index: 200;
pointer-events: none;
/* On the layer, not the digits: a fixed layer blends as a whole with the page. */
mix-blend-mode: difference;
}
.bit-burst span {
position: absolute;
left: 0;
top: 0;
font-family: var(--font-jetbrains-mono), ui-monospace, monospace;
font-size: 13px;
font-weight: 700;
color: #ffffff;
opacity: 0;
animation: bit-burst-fly 620ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes bit-burst-fly {
0% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
100% {
opacity: 0;
transform: translate(calc(-50% + var(--dx)), calc(-50% + var(--dy)))
scale(0.6);
}
}
/* Corner marks that snap onto a card on hover or focus. */
.bit-card {
position: relative;
}
.bit-card::before,
.bit-card::after {
content: "";
position: absolute;
width: 14px;
height: 14px;
border: 1.5px solid currentColor;
opacity: 0;
transition:
opacity 250ms ease,
transform 350ms cubic-bezier(0.16, 1, 0.3, 1);
pointer-events: none;
}
.bit-card::before {
top: 12px;
right: 12px;
border-left: 0;
border-bottom: 0;
transform: translate(-6px, 6px);
}
.bit-card::after {
bottom: 12px;
left: 12px;
border-right: 0;
border-top: 0;
transform: translate(6px, -6px);
}
.bit-card:hover::before,
.bit-card:hover::after,
.bit-card:focus-visible::before,
.bit-card:focus-visible::after,
.bit-card[aria-selected="true"]::before,
.bit-card[aria-selected="true"]::after {
opacity: 0.55;
transform: translate(0, 0);
}
@media (prefers-reduced-motion: reduce) {
.bit-burst {
display: none;
}
.bit-card::before,
.bit-card::after {
transition: none;
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
import type { Metadata } from "next";
import { Inter, JetBrains_Mono, Newsreader } from "next/font/google";
import { Analytics } from "@/src/components/Analytics";
import { BitBurst } from "@/src/components/bits/BitBurst";
import { Footer } from "@/src/components/Footer";
import { Header } from "@/src/components/Header";
import { InteractiveElements } from "@/src/components/InteractiveElements";
@@ -70,7 +70,7 @@ export default function RootLayout({
<main>{children}</main>
<Footer />
<InteractiveElements />
<Analytics />
<BitBurst />
</body>
</html>
);
+2 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { DecodeText } from "@/src/components/bits/DecodeText";
import { Block } from "@/src/components/home/Block";
import { ClosingCta } from "@/src/components/home/ClosingCta";
import { Faq } from "@/src/components/home/Faq";
@@ -218,7 +219,7 @@ export default function LandingPage() {
key={item}
className="rounded-full border border-slate-200 bg-white px-4 py-2 font-mono text-[11px] uppercase tracking-[0.12em] text-slate-700"
>
{item}
<DecodeText text={item} trigger="both" />
</li>
))}
</ul>
@@ -1,77 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { env } from "@/lib/env";
/**
* Smart Proxy for Umami Analytics.
*
* This Route Handler receives tracking events from the browser,
* injects the secret UMAMI_WEBSITE_ID, and forwards them to the
* internal Umami API endpoint.
*
* This ensures:
* 1. The Website ID is NOT leaked to the client bundle.
* 2. The Umami API endpoint is hidden behind our domain.
* 3. We have full control over the tracking data.
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { type, payload } = body;
// Inject the secret websiteId from server config
const websiteId = env.UMAMI_WEBSITE_ID || env.NEXT_PUBLIC_UMAMI_WEBSITE_ID;
if (!websiteId) {
console.warn(
"Umami tracking received but no Website ID configured on server",
);
return NextResponse.json({ status: "ignored" }, { status: 200 });
}
// Prepare the enhanced payload with the secret ID
const enhancedPayload = {
...payload,
website: websiteId,
};
const umamiEndpoint = env.UMAMI_API_ENDPOINT;
// Log the event (debug only)
if (process.env.NODE_ENV === "development") {
console.debug("Forwarding analytics event", {
type,
url: payload.url,
website: websiteId.slice(0, 8) + "...",
});
}
const response = await fetch(`${umamiEndpoint}/api/send`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": request.headers.get("user-agent") || "Mintel-Smart-Proxy",
"X-Forwarded-For": request.headers.get("x-forwarded-for") || "",
},
body: JSON.stringify({ type, payload: enhancedPayload }),
});
if (!response.ok) {
const errorText = await response.text();
console.error("Umami API responded with error", {
status: response.status,
error: errorText.slice(0, 100),
});
return new NextResponse(errorText, { status: response.status });
}
return NextResponse.json({ status: "ok" });
} catch (error) {
console.error("Failed to proxy analytics request", {
error: (error as Error).message,
});
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 },
);
}
}
+53
View File
@@ -0,0 +1,53 @@
/**
* Server request shell. Reads the incoming request once, classifies it via
* the pure domain decision, and records a page view without touching the
* response. All analytics happen server-side; no tracking script ships to
* the browser.
*
* Page views are browser navigations (`sec-fetch-dest: document`).
* Router-internal headers (RSC, next-router-prefetch) are stripped by the
* server before this shell runs and cannot be used here.
*/
import { NextResponse, type NextRequest } from "next/server";
import { recordPageviewToUmami } from "@/src/analytics/recordPageviewToUmami";
import { decidePageview } from "@/src/domain/pageview-decision";
const primaryLanguage = (acceptLanguage: string | null): string =>
acceptLanguage?.split(",")[0]?.split(";")[0]?.trim() ?? "";
const firstForwardedIp = (forwardedFor: string | null): string =>
forwardedFor?.split(",")[0]?.trim() ?? "";
const hostnameWithoutPort = (host: string): string => host.replace(/:\d+$/, "");
export default function proxy(request: NextRequest): NextResponse {
const decision = decidePageview({
method: request.method,
secFetchDest: request.headers.get("sec-fetch-dest") ?? "",
pathname: request.nextUrl.pathname,
search: request.nextUrl.search,
hostname: hostnameWithoutPort(
request.headers.get("x-forwarded-host") ??
request.headers.get("host") ??
"",
),
referrer: request.headers.get("referer") ?? "",
language: primaryLanguage(request.headers.get("accept-language")),
userAgent: request.headers.get("user-agent") ?? "",
clientIp: firstForwardedIp(request.headers.get("x-forwarded-for")),
});
if (decision.track) {
recordPageviewToUmami(decision.facts);
}
return NextResponse.next();
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|.*\\.(?:png|jpe?g|gif|svg|webp|avif|ico|css|mjs|cjs|js|map|txt|xml|json|webmanifest|woff2?|ttf|otf|mp4|webm|mov|pdf|zip)$).*)",
],
};
@@ -0,0 +1,36 @@
/**
* Imperative shell for page view analytics: sends one page view to the
* Umami collection API, identifying the visitor via request headers.
* Fire-and-forget by design — analytics must never delay a response.
*/
import type { PageviewFacts } from "@/src/domain/pageview-decision";
import { buildUmamiPageviewBody } from "@/src/domain/umami-payload";
const SEND_TIMEOUT_MS = 3_000;
const websiteId = process.env.UMAMI_WEBSITE_ID ?? "";
const apiEndpoint = (process.env.UMAMI_API_ENDPOINT ?? "").replace(/\/+$/, "");
export function recordPageviewToUmami(facts: PageviewFacts): void {
if (!websiteId || !apiEndpoint) return;
const body = buildUmamiPageviewBody(facts, websiteId);
void fetch(`${apiEndpoint}/api/send`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": facts.userAgent,
"X-Forwarded-For": facts.clientIp,
"Accept-Language": facts.language,
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
}).catch((error: unknown) => {
console.error("umami pageview send failed", {
url: facts.url,
error: error instanceof Error ? error.message : String(error),
});
});
}
-147
View File
@@ -1,147 +0,0 @@
"use client";
import React, { useEffect, Suspense } from "react";
import {
useSafePathname,
useSafeSearchParams,
} from "./analytics/useSafePathname";
import { ScrollDepthTracker } from "./analytics/ScrollDepthTracker";
import { getDefaultAnalytics } from "../utils/analytics";
import { getDefaultErrorTracking } from "../utils/error-tracking";
const AnalyticsInner: React.FC = () => {
const pathname = useSafePathname();
const searchParams = useSafeSearchParams();
// Track pageviews on route change
useEffect(() => {
if (!pathname) return;
const analytics = getDefaultAnalytics();
const url = `${pathname}${searchParams?.size ? `?${searchParams.toString()}` : ""}`;
analytics.page(url);
}, [pathname, searchParams]);
useEffect(() => {
const analytics = getDefaultAnalytics();
const errorTracking = getDefaultErrorTracking();
// Track page load performance
const trackPageLoad = () => {
// ... existing implementation ...
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) {
const handleSearch = (e: Event) => {
const target = e.target as HTMLInputElement;
if (target.value) {
analytics.trackSearch(target.value, window.location.pathname);
}
};
searchInput.addEventListener("search", handleSearch);
return () => searchInput.removeEventListener("search", handleSearch);
}
};
// Global error handler for error tracking
const handleGlobalError = (event: ErrorEvent) => {
errorTracking.captureException(event.error || event.message);
};
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
errorTracking.captureException(event.reason);
};
window.addEventListener("error", handleGlobalError);
window.addEventListener("unhandledrejection", handleUnhandledRejection);
// Initial load tracking
if (document.readyState === "complete") {
trackPageLoad();
trackOutboundLinks();
const cleanupSearch = trackSearch();
return () => {
if (cleanupSearch) cleanupSearch();
window.removeEventListener("error", handleGlobalError);
window.removeEventListener(
"unhandledrejection",
handleUnhandledRejection,
);
};
} else {
window.addEventListener("load", () => {
trackPageLoad();
trackOutboundLinks();
// search tracking might need to wait for hydration/render
});
// Fallback/standard cleanup
return () => {
window.removeEventListener("error", handleGlobalError);
window.removeEventListener(
"unhandledrejection",
handleUnhandledRejection,
);
};
}
}, []);
const analytics = getDefaultAnalytics();
const adapter = analytics.getAdapter();
const scriptTag = adapter.getScriptTag ? adapter.getScriptTag() : null;
return (
<>
<ScrollDepthTracker />
{scriptTag && (
<div
dangerouslySetInnerHTML={{ __html: scriptTag }}
style={{ display: "none" }}
/>
)}
</>
);
};
export const Analytics: React.FC = () => {
return (
<Suspense fallback={null}>
<AnalyticsInner />
</Suspense>
);
};
+5 -5
View File
@@ -3,6 +3,7 @@ import Link from "next/link";
import * as React from "react";
import LogoBlack from "../assets/logo/Logo-Black-Transparent.svg";
import { BinaryTicker } from "./bits/BinaryTicker";
export const Footer: React.FC = () => {
const currentYear = new Date().getFullYear();
@@ -56,11 +57,10 @@ export const Footer: React.FC = () => {
</Link>
</div>
{/* Subtle binary decoration */}
<span
className="text-[8px] text-slate-200 tracking-[0.5em] select-none font-mono"
aria-hidden="true"
>
01001101 01001001 01001110
<span className="flex gap-3 text-[8px] text-slate-300 tracking-[0.5em] select-none font-mono">
{[77, 73, 78].map((code) => (
<BinaryTicker key={code} value={code} alive />
))}
</span>
</div>
</div>
+5 -1
View File
@@ -4,6 +4,8 @@ import Link from "next/link";
import * as React from "react";
import IconWhite from "../assets/logo/Icon-White-Transparent.svg";
import { DecodeText } from "./bits/DecodeText";
import { ScrollBits } from "./bits/ScrollBits";
/** Header: the logo and one action. Everything else lives on the homepage. */
export const Header: React.FC = () => {
@@ -52,6 +54,8 @@ export const Header: React.FC = () => {
</div>
</Link>
<ScrollBits className="hidden font-mono text-[10px] tracking-[0.3em] text-slate-400 md:block" />
<Link
href="/#contact"
className="font-sans text-[10px] font-bold uppercase tracking-[0.2em] text-slate-900 border border-slate-200 px-5 py-2.5 rounded-full no-underline hover:border-slate-400 hover:bg-slate-50 transition-all duration-500 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-slate-100"
@@ -59,7 +63,7 @@ export const Header: React.FC = () => {
transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
}}
>
Get in touch
<DecodeText text="Get in touch" trigger="hover" />
</Link>
</div>
</header>
@@ -1,70 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
import { useSafePathname } from "./useSafePathname";
import { useAnalytics } from "./useAnalytics";
import { AnalyticsEvents } from "./analytics-events";
/**
* ScrollDepthTracker
* Tracks user scroll progress across pages.
* Fires events at 25%, 50%, 75%, and 100% depth.
*/
export function ScrollDepthTracker() {
const pathname = useSafePathname();
const { trackEvent } = useAnalytics();
const trackedDepths = useRef<Set<number>>(new Set());
// Reset tracking when path changes
useEffect(() => {
trackedDepths.current.clear();
}, [pathname]);
useEffect(() => {
const handleScroll = () => {
const scrollY = window.scrollY;
const windowHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
// Calculate how far the user has scrolled in percentage
// documentHeight - windowHeight is the total scrollable distance
const totalScrollable = documentHeight - windowHeight;
if (totalScrollable <= 0) return; // Not scrollable
const scrollPercentage = Math.round((scrollY / totalScrollable) * 100);
// We only care about specific milestones
const milestones = [25, 50, 75, 100];
milestones.forEach((milestone) => {
if (
scrollPercentage >= milestone &&
!trackedDepths.current.has(milestone)
) {
trackedDepths.current.add(milestone);
trackEvent(AnalyticsEvents.SCROLL_DEPTH, {
depth: milestone,
path: pathname,
});
}
});
};
// Use passive listener for better performance
window.addEventListener("scroll", handleScroll, { passive: true });
// Initial check (in case page is short or already scrolled)
if (document.readyState === "complete") {
handleScroll();
} else {
window.addEventListener("load", handleScroll);
}
return () => {
window.removeEventListener("scroll", handleScroll);
window.removeEventListener("load", handleScroll);
};
}, [pathname, trackEvent]);
return null;
}
@@ -1,44 +0,0 @@
/**
* Analytics Events Utility
*
* Centralized definitions for common analytics events and their properties.
*/
export const AnalyticsEvents = {
// Page & Navigation Events
PAGE_VIEW: "pageview",
PAGE_SCROLL: "page_scroll",
PAGE_EXIT: "page_exit",
SCROLL_DEPTH: "scroll_depth",
// User Interaction Events
BUTTON_CLICK: "button_click",
LINK_CLICK: "link_click",
FORM_SUBMIT: "form_submit",
FORM_START: "form_start",
FORM_ERROR: "form_error",
FORM_FIELD_FOCUS: "form_field_focus",
// UI Interaction Events
MODAL_OPEN: "modal_open",
MODAL_CLOSE: "modal_close",
TOGGLE_SWITCH: "toggle_switch",
ACCORDION_TOGGLE: "accordion_toggle",
TAB_SWITCH: "tab_switch",
// Error & Performance Events
ERROR: "error",
PERFORMANCE: "performance",
API_ERROR: "api_error",
API_SUCCESS: "api_success",
// Custom Business Events
CONTACT_FORM_SUBMIT: "contact_form_submit",
NEWSLETTER_SUBSCRIBE: "newsletter_subscribe",
} as const;
/**
* Type-safe event properties for common events
*/
export type AnalyticsEventName =
(typeof AnalyticsEvents)[keyof typeof AnalyticsEvents];
@@ -1,40 +0,0 @@
"use client";
import { useCallback } from "react";
import { getDefaultAnalytics } from "../../utils/analytics";
import type { AnalyticsEventName } from "./analytics-events";
/**
* Custom hook for tracking analytics events.
* Wraps the analytics service for easy use in components.
*/
export function useAnalytics() {
const trackEvent = useCallback(
(
eventName: string | AnalyticsEventName,
properties?: Record<string, any>,
) => {
const analytics = getDefaultAnalytics();
analytics.trackEvent(eventName, properties);
if (process.env.NODE_ENV === "development") {
console.debug("[Analytics] Tracked event:", eventName, properties);
}
},
[],
);
const trackPageview = useCallback((url?: string) => {
const analytics = getDefaultAnalytics();
analytics.page(url || window.location.pathname);
if (process.env.NODE_ENV === "development") {
console.debug("[Analytics] Tracked pageview:", url ?? "current location");
}
}, []);
return {
trackEvent,
trackPageview,
};
}
@@ -1,24 +0,0 @@
"use client";
import { usePathname, useSearchParams } from "next/navigation";
export function useSafePathname(): string | null {
try {
return usePathname();
} catch (error) {
if (process.env.NODE_ENV === "development") {
console.warn("Caught usePathname exception (likely Next.js static prerender bug):", error);
}
return null;
}
}
export function useSafeSearchParams() {
try {
return useSearchParams();
} catch (error) {
if (process.env.NODE_ENV === "development") {
console.warn("Caught useSearchParams exception (likely Next.js static prerender bug):", error);
}
return null;
}
}
@@ -0,0 +1,63 @@
"use client";
import * as React from "react";
import { useInViewOnce } from "./useInViewOnce";
const toBits = (value: number, width: number): string =>
value.toString(2).padStart(width, "0").slice(-width);
/**
* BinaryTicker: a number shown in binary. The bits rattle when it scrolls into
* view and then settle on the value; with `alive` a single bit keeps flipping.
*/
export const BinaryTicker: React.FC<{
value: number;
width?: number;
className?: string;
alive?: boolean;
}> = ({ value, width = 8, className = "", alive = false }) => {
const settled = toBits(value, width);
const { ref, seen, still } = useInViewOnce<HTMLSpanElement>(0.6);
const [shown, setShown] = React.useState(settled);
React.useEffect(() => {
if (!seen || still) return;
let tick = 0;
const timer = window.setInterval(() => {
tick += 1;
if (tick > 14) {
window.clearInterval(timer);
setShown(settled);
return;
}
setShown(
Array.from(settled)
.map((bit, i) =>
i < tick - 6 ? bit : Math.random() < 0.5 ? "0" : "1",
)
.join(""),
);
}, 45);
return () => window.clearInterval(timer);
}, [seen, still, settled]);
React.useEffect(() => {
if (!alive || !seen || still) return;
const timer = window.setInterval(() => {
const index = Math.floor(Math.random() * width);
setShown((current) =>
Array.from(current)
.map((bit, i) => (i === index ? (bit === "0" ? "1" : "0") : bit))
.join(""),
);
window.setTimeout(() => setShown(settled), 260);
}, 2400);
return () => window.clearInterval(timer);
}, [alive, seen, still, width, settled]);
return (
<span ref={ref} className={className} aria-hidden="true">
{shown}
</span>
);
};
+44
View File
@@ -0,0 +1,44 @@
"use client";
import * as React from "react";
const COUNT = 10;
const LIFE_MS = 620;
/**
* BitBurst: a handful of binary digits fly out from the point where a link or
* button is pressed. Purely decorative, and off when reduced motion is requested.
*/
export const BitBurst: React.FC = () => {
React.useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const onPointerDown = (event: PointerEvent) => {
const target = event.target as Element | null;
if (!target?.closest("a, button")) return;
const layer = document.createElement("div");
layer.setAttribute("aria-hidden", "true");
layer.className = "bit-burst";
layer.style.left = `${event.clientX}px`;
layer.style.top = `${event.clientY}px`;
for (let i = 0; i < COUNT; i++) {
const angle = (i / COUNT) * Math.PI * 2 + Math.random() * 0.5;
const distance = 26 + Math.random() * 34;
const digit = document.createElement("span");
digit.textContent = Math.random() < 0.5 ? "0" : "1";
digit.style.setProperty("--dx", `${Math.cos(angle) * distance}px`);
digit.style.setProperty("--dy", `${Math.sin(angle) * distance}px`);
digit.style.animationDelay = `${Math.random() * 60}ms`;
layer.appendChild(digit);
}
document.body.appendChild(layer);
window.setTimeout(() => layer.remove(), LIFE_MS + 120);
};
window.addEventListener("pointerdown", onPointerDown);
return () => window.removeEventListener("pointerdown", onPointerDown);
}, []);
return null;
};
@@ -0,0 +1,89 @@
"use client";
import * as React from "react";
import { useInViewOnce } from "./useInViewOnce";
const CELL = 16;
const DURATION_MS = 1700;
/**
* BitReveal: covers whatever lies beneath it with a field of binary digits that
* dissolves cell by cell once it scrolls into view, so the picture underneath
* appears to assemble itself out of bits. Without JavaScript nothing is covered.
*/
export const BitReveal: React.FC<{
/** Colour of the covering cells; should match the surface behind the picture. */
surface?: string;
}> = ({ surface = "#0f172a" }) => {
const { ref, seen, still } = useInViewOnce<HTMLCanvasElement>(0.25);
const [done, setDone] = React.useState(false);
// Cover the picture as soon as the script runs, before it is scrolled into view.
React.useEffect(() => {
const canvas = ref.current;
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
setDone(true);
return;
}
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
ctx.fillStyle = surface;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}, [ref, surface]);
React.useEffect(() => {
const canvas = ref.current;
const ctx = canvas?.getContext("2d");
if (!seen || still || !canvas || !ctx) return;
const width = (canvas.width = canvas.offsetWidth);
const height = (canvas.height = canvas.offsetHeight);
const cols = Math.ceil(width / CELL);
const rows = Math.ceil(height / CELL);
// Each cell disappears at its own moment; cells further right go a little later.
const when = new Float32Array(cols * rows);
const bit = new Uint8Array(cols * rows);
for (let i = 0; i < when.length; i++) {
const col = i % cols;
when[i] = Math.random() * 0.7 + (col / cols) * 0.3;
bit[i] = Math.random() < 0.5 ? 0 : 1;
}
ctx.font = `11px ui-monospace, Menlo, monospace`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
let raf = 0;
const start = performance.now();
const frame = (now: number) => {
const progress = Math.min(1, (now - start) / DURATION_MS);
ctx.clearRect(0, 0, width, height);
for (let i = 0; i < when.length; i++) {
const left = (when[i] ?? 0) - progress;
if (left <= 0) continue;
const x = (i % cols) * CELL;
const y = Math.floor(i / cols) * CELL;
ctx.fillStyle = surface;
ctx.fillRect(x, y, CELL, CELL);
// Cells about to vanish light up first.
ctx.fillStyle = left < 0.08 ? "#f8fafc" : "#475569";
if (Math.random() < 0.02) bit[i] = bit[i] === 1 ? 0 : 1;
ctx.fillText(String(bit[i] ?? 0), x + CELL / 2, y + CELL / 2);
}
if (progress < 1) raf = requestAnimationFrame(frame);
else setDone(true);
};
raf = requestAnimationFrame(frame);
return () => cancelAnimationFrame(raf);
}, [ref, seen, still, surface]);
if (done) return null;
return (
<canvas
ref={ref}
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full"
/>
);
};
+66
View File
@@ -0,0 +1,66 @@
"use client";
import * as React from "react";
import { motion } from "framer-motion";
import { useInViewOnce } from "./useInViewOnce";
const DIGITS = "0110100110010110011010011001011001101001";
const DURATION = 0.95;
const EASE = [0.65, 0, 0.35, 1] as const;
/**
* BitWipe: reveals its content from left to right behind a narrow column of
* binary digits, as if the text were being written out bit by bit. The content
* keeps its place the whole time, so nothing reflows.
*
* Visibility is observed on the outer wrapper: the inner element is clipped away
* completely at first, and a fully clipped element never counts as "in view".
*/
export const BitWipe: React.FC<{
children: React.ReactNode;
className?: string;
delay?: number;
/** Colour of the digit column: dark on light surfaces, light on dark ones. */
tone?: "dark" | "light";
}> = ({ children, className = "", delay = 0, tone = "dark" }) => {
const { ref, seen, still } = useInViewOnce<HTMLDivElement>(0.35);
const open = seen || still;
return (
<div ref={ref} className={`relative ${className}`}>
<motion.div
initial={false}
animate={{
clipPath: open
? "inset(-12% -2% -12% -2%)"
: "inset(-12% 100% -12% -2%)",
}}
transition={
still ? { duration: 0 } : { duration: DURATION, delay, ease: EASE }
}
// Server render and no-JS: content is simply visible.
style={{ clipPath: "none" }}
>
{children}
</motion.div>
{seen && !still && (
<motion.span
aria-hidden="true"
className={`pointer-events-none absolute inset-y-0 w-[1.1em] overflow-hidden break-all font-mono text-[11px] leading-[1.05] tracking-normal [writing-mode:vertical-rl] md:text-[13px] ${
tone === "light" ? "text-white" : "text-slate-900"
}`}
initial={{ left: "0%", opacity: 0 }}
animate={{ left: ["0%", "100%"], opacity: [0, 1, 1, 0] }}
transition={{
duration: DURATION,
delay,
ease: EASE,
opacity: { duration: DURATION, delay, times: [0, 0.08, 0.86, 1] },
}}
>
{DIGITS}
</motion.span>
)}
</div>
);
};
@@ -0,0 +1,63 @@
"use client";
import * as React from "react";
import { useInViewOnce } from "./useInViewOnce";
const FRAME_MS = 32;
const scramble = (text: string, revealed: number): string =>
Array.from(text)
.map((char, index) => {
if (index < revealed || !/[A-Za-z0-9]/.test(char)) return char;
return Math.random() < 0.5 ? "0" : "1";
})
.join("");
/**
* DecodeText: text that resolves from binary digits, left to right. Meant for
* monospaced labels, where digits and letters have the same width and nothing shifts.
* Screen readers and no-JS visitors get the plain text.
*/
export const DecodeText: React.FC<{
text: string;
className?: string;
/** Run when scrolled into view, on hover, or both. */
trigger?: "view" | "hover" | "both";
}> = ({ text, className = "", trigger = "view" }) => {
const { ref, seen, still } = useInViewOnce<HTMLSpanElement>(0.6);
const [shown, setShown] = React.useState(text);
const timer = React.useRef(0);
const run = React.useCallback(() => {
if (still) return;
window.clearInterval(timer.current);
const length = Array.from(text).length;
const frames = Math.min(26, Math.max(12, length));
let frame = 0;
timer.current = window.setInterval(() => {
frame += 1;
if (frame >= frames) {
window.clearInterval(timer.current);
setShown(text);
return;
}
setShown(scramble(text, Math.floor((frame / frames) * length)));
}, FRAME_MS);
}, [still, text]);
React.useEffect(() => {
if (seen && trigger !== "hover") run();
return () => window.clearInterval(timer.current);
}, [seen, trigger, run]);
return (
<span
ref={ref}
className={className}
onPointerEnter={trigger === "view" ? undefined : run}
>
<span className="sr-only">{text}</span>
<span aria-hidden="true">{shown}</span>
</span>
);
};
@@ -0,0 +1,29 @@
"use client";
import * as React from "react";
/** ScrollBits: how far the page is scrolled, as an eight-bit binary number. */
export const ScrollBits: React.FC<{ className?: string }> = ({
className = "",
}) => {
const [percent, setPercent] = React.useState(0);
React.useEffect(() => {
const update = () => {
const max = document.documentElement.scrollHeight - window.innerHeight;
setPercent(max > 0 ? Math.round((window.scrollY / max) * 100) : 0);
};
update();
window.addEventListener("scroll", update, { passive: true });
return () => window.removeEventListener("scroll", update);
}, []);
return (
<span className={className} aria-hidden="true">
{percent.toString(2).padStart(8, "0")}
<span className="ml-3 text-slate-300">
{String(percent).padStart(3, "0")}%
</span>
</span>
);
};
@@ -0,0 +1,31 @@
"use client";
import * as React from "react";
/** True once the element has entered the viewport. Also reports reduced-motion preference. */
export const useInViewOnce = <T extends Element>(
threshold = 0.4,
): { ref: React.RefObject<T | null>; seen: boolean; still: boolean } => {
const ref = React.useRef<T | null>(null);
const [seen, setSeen] = React.useState(false);
const [still, setStill] = React.useState(false);
React.useEffect(() => {
setStill(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
const node = ref.current;
if (!node) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
setSeen(true);
observer.disconnect();
}
},
{ threshold },
);
observer.observe(node);
return () => observer.disconnect();
}, [threshold]);
return { ref, seen, still };
};
+13 -10
View File
@@ -1,4 +1,7 @@
import * as React from "react";
import { BinaryTicker } from "@/src/components/bits/BinaryTicker";
import { BitWipe } from "@/src/components/bits/BitWipe";
import { DecodeText } from "@/src/components/bits/DecodeText";
import { Reveal } from "@/src/components/Reveal";
interface BlockProps {
@@ -32,22 +35,22 @@ export const Block: React.FC<BlockProps> = ({
{index}
</span>
<span className="h-px w-10 bg-slate-300" />
<span className="font-mono text-[11px] uppercase tracking-[0.25em] text-slate-500">
{label}
</span>
<span
<DecodeText
text={label}
className="font-mono text-[11px] uppercase tracking-[0.25em] text-slate-500"
/>
<BinaryTicker
value={parseInt(index, 10)}
alive
className="ml-auto hidden font-mono text-[10px] tracking-[0.3em] text-slate-300 md:block"
aria-hidden="true"
>
{parseInt(index, 10).toString(2).padStart(8, "0")}
</span>
/>
</div>
</Reveal>
<Reveal delay={0.1}>
<BitWipe delay={0.1}>
<h2 className="m-0 max-w-6xl text-4xl font-bold leading-[0.95] tracking-tighter text-slate-900 md:text-7xl lg:text-[6.5rem]">
{title}
</h2>
</Reveal>
</BitWipe>
<div className="mt-14 md:mt-24">{children}</div>
</div>
</section>
+21 -9
View File
@@ -2,34 +2,46 @@ import * as React from "react";
import { Reveal } from "@/src/components/Reveal";
import { EmailLink } from "@/src/components/EmailLink";
import { BitHeadline, type BitLine } from "./BitHeadline";
import { ContactComposer } from "./ContactComposer";
const CLOSING: readonly BitLine[] = [
{ text: "LET'S", tone: "bits" },
{ text: "TALK.", tone: "solid" },
];
/** ClosingCta: the contact section. One address, no forms, no calendar. */
/** ClosingCta: the contact section. One address and a message starter, no form to submit. */
export const ClosingCta: React.FC = () => (
<section
id="contact"
className="relative border-t border-slate-200 bg-slate-900 text-white"
>
<div className="mx-auto w-full max-w-[1400px] px-5 py-24 md:px-12 md:py-44">
<Reveal>
<div
className="pointer-events-none absolute inset-0 opacity-70"
style={{
backgroundImage: "radial-gradient(#1e293b 1px, transparent 1px)",
backgroundSize: "20px 20px",
}}
aria-hidden="true"
/>
<div className="relative mx-auto grid w-full max-w-[1400px] grid-cols-1 gap-14 px-5 py-24 md:px-12 md:py-40 lg:grid-cols-12 lg:items-center lg:gap-12">
<Reveal className="lg:col-span-6">
<BitHeadline
as="h2"
invert
lines={CLOSING}
className="font-sans text-[19vw] font-black leading-[0.86] tracking-tighter md:text-[13vw] xl:text-[12rem]"
className="font-sans text-[19vw] font-black leading-[0.86] tracking-tighter md:text-[13vw] lg:text-[8.5vw] xl:text-[9.5rem]"
/>
<p className="m-0 mt-10 max-w-2xl font-sans text-xl leading-snug text-slate-300 md:text-3xl">
Write me a few lines about what you need. We then arrange a short
call.
<p className="m-0 mt-10 max-w-xl font-sans text-xl leading-snug text-slate-300 md:text-3xl">
Pick what you need and the email writes itself. Or write to me
directly:
</p>
<div className="mt-12">
<EmailLink className="inline-block border-b-2 border-white pb-2 font-sans text-3xl font-bold tracking-tight text-white no-underline transition-opacity hover:opacity-70 md:text-6xl" />
<div className="mt-8">
<EmailLink className="inline-block border-b-2 border-white pb-2 font-sans text-2xl font-bold tracking-tight text-white no-underline transition-opacity hover:opacity-70 md:text-4xl" />
</div>
</Reveal>
<Reveal delay={0.15} className="lg:col-span-6">
<ContactComposer />
</Reveal>
</div>
</section>
);
@@ -0,0 +1,171 @@
"use client";
import * as React from "react";
import { CONTACT_TOPICS } from "@/src/content/contact";
import { CONTACT_EMAIL_PARTS } from "@/src/content/site";
import { assembleEmail, mailtoHref, spellOutEmail } from "@/src/domain/email";
const TYPE_MS = 9;
const MONO = "font-mono text-[11px] uppercase tracking-[0.2em]";
/** The text typed out so far. Jumps to the full text when motion is reduced. */
const useTyped = (text: string): string => {
const [length, setLength] = React.useState(text.length);
React.useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
setLength(text.length);
return;
}
setLength(0);
const timer = window.setInterval(() => {
setLength((current) => {
if (current >= text.length) {
window.clearInterval(timer);
return current;
}
return Math.min(text.length, current + 3);
});
}, TYPE_MS);
return () => window.clearInterval(timer);
}, [text]);
return text.slice(0, length);
};
/** Blanks the visitor fills in are set apart from the prepared wording. */
const withBlanks = (text: string): React.ReactNode =>
text.split(/(\[[^\]]*\]?)/).map((part, index) =>
part.startsWith("[") ? (
<span key={index} className="rounded-sm bg-amber-200/15 text-amber-200">
{part}
</span>
) : (
part
),
);
/**
* ContactComposer: pick what you need and the email writes itself. One press
* opens it in the visitor's own mail app, ready to finish and send. The address
* is assembled in the browser, so it never appears in the page source.
*/
export const ContactComposer: React.FC = () => {
const [topicId, setTopicId] = React.useState(CONTACT_TOPICS[0]?.id ?? "");
const [address, setAddress] = React.useState<string | null>(null);
const [copied, setCopied] = React.useState(false);
React.useEffect(() => {
setAddress(assembleEmail(CONTACT_EMAIL_PARTS));
}, []);
const topic =
CONTACT_TOPICS.find((item) => item.id === topicId) ?? CONTACT_TOPICS[0];
const typed = useTyped(topic?.draft.body ?? "");
if (!topic) return null;
const done = typed.length === topic.draft.body.length;
const copy = async () => {
if (!address) return;
try {
await navigator.clipboard.writeText(address);
setCopied(true);
window.setTimeout(() => setCopied(false), 1800);
} catch {
// Clipboard access can be refused; the address stays visible to copy by hand.
}
};
return (
<div className="overflow-hidden rounded-3xl border border-slate-700 bg-slate-950/60 backdrop-blur-sm">
<div className="flex items-center justify-between border-b border-slate-800 px-5 py-4 md:px-7">
<div className="flex items-center gap-3">
<span className="flex gap-1.5" aria-hidden="true">
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
</span>
<span className={`${MONO} text-slate-400`}>New message</span>
</div>
<span className={`${MONO} flex items-center gap-2 text-slate-500`}>
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-400" />
draft
</span>
</div>
<div className="border-b border-slate-800 px-5 py-5 md:px-7">
<p className={`${MONO} m-0 text-slate-500`}>I need</p>
<div className="mt-3 flex flex-wrap gap-2">
{CONTACT_TOPICS.map((item) => {
const selected = item.id === topic.id;
return (
<button
key={item.id}
type="button"
aria-pressed={selected}
onClick={() => setTopicId(item.id)}
className={`rounded-full border px-4 py-2 font-mono text-[11px] uppercase tracking-[0.15em] transition-colors duration-200 ${
selected
? "border-white bg-white text-slate-900"
: "border-slate-700 text-slate-300 hover:border-slate-400 hover:text-white"
}`}
>
{item.label}
</button>
);
})}
</div>
</div>
<dl className="m-0 border-b border-slate-800 px-5 py-4 font-mono text-[12px] leading-relaxed md:px-7 md:text-[13px]">
<div className="flex gap-4">
<dt className="w-16 shrink-0 text-slate-500">to</dt>
<dd className="m-0 text-slate-200">
{address ?? spellOutEmail(CONTACT_EMAIL_PARTS)}
</dd>
</div>
<div className="mt-1 flex gap-4">
<dt className="w-16 shrink-0 text-slate-500">subject</dt>
<dd className="m-0 text-white">{topic.draft.subject}</dd>
</div>
</dl>
<div
className="min-h-[15.5rem] whitespace-pre-wrap px-5 py-5 font-mono text-[12px] leading-relaxed text-slate-300 md:min-h-[14rem] md:px-7 md:text-[13px]"
aria-live="off"
>
{withBlanks(typed)}
<span
aria-hidden="true"
className={`ml-0.5 inline-block h-[1.05em] w-[0.55em] translate-y-[0.18em] bg-amber-200 ${done ? "animate-pulse" : ""}`}
/>
</div>
<div className="flex flex-col gap-3 border-t border-slate-800 p-5 sm:flex-row sm:items-center md:px-7">
<a
href={
address ? mailtoHref(CONTACT_EMAIL_PARTS, topic.draft) : "#contact"
}
className="group inline-flex flex-1 items-center justify-between gap-4 rounded-full bg-white px-7 py-4 font-sans text-[12px] font-bold uppercase tracking-[0.18em] text-slate-900 no-underline transition-transform duration-300 hover:-translate-y-0.5"
>
Open in my mail app
<span
aria-hidden="true"
className="transition-transform duration-300 group-hover:translate-x-1"
>
→
</span>
</a>
<button
type="button"
onClick={copy}
className="rounded-full border border-slate-700 px-6 py-4 font-sans text-[12px] font-bold uppercase tracking-[0.18em] text-slate-300 transition-colors duration-200 hover:border-slate-400 hover:text-white"
>
{copied ? "Copied" : "Copy address"}
</button>
</div>
</div>
);
};
+7 -4
View File
@@ -3,6 +3,7 @@
import * as React from "react";
import { motion } from "framer-motion";
import { Button } from "@/src/components/Button";
import { DecodeText } from "@/src/components/bits/DecodeText";
import { PathScene } from "@/src/components/illustrations/PathScenes";
import { SERVICE_PATHS, type ServicePath } from "@/src/content/paths";
@@ -50,9 +51,11 @@ const PathStory: React.FC<{ path: ServicePath; active: boolean }> = ({
<div className="md:order-2 md:col-span-7">
<div className="relative overflow-hidden rounded-3xl bg-slate-900 md:sticky md:top-28">
<div className="flex items-center justify-between border-b border-slate-800 px-5 py-4 md:px-7">
<span className="font-mono text-[11px] uppercase tracking-[0.2em] text-slate-300">
{current?.title}
</span>
<DecodeText
key={current?.title}
text={current?.title ?? ""}
className="font-mono text-[11px] uppercase tracking-[0.2em] text-slate-300"
/>
<span className="font-mono text-[11px] tracking-[0.2em] text-slate-500">
{step + 1} / {path.steps.length}
</span>
@@ -151,7 +154,7 @@ export const PathPicker: React.FC = () => {
aria-selected={selected}
aria-controls={`path-panel-${path.id}`}
onClick={() => setActiveId(path.id)}
className={`group flex h-full flex-col justify-between rounded-3xl border p-5 text-left transition-all duration-300 md:p-7 ${
className={`bit-card group flex h-full flex-col justify-between rounded-3xl border p-5 text-left transition-all duration-300 md:p-7 ${
selected
? "border-slate-900 bg-slate-900 text-white"
: "border-slate-200 bg-white text-slate-900 hover:-translate-y-1 hover:border-slate-400"
@@ -3,6 +3,9 @@
import * as React from "react";
import Image from "next/image";
import { motion } from "framer-motion";
import { BitReveal } from "@/src/components/bits/BitReveal";
import { BitWipe } from "@/src/components/bits/BitWipe";
import { DecodeText } from "@/src/components/bits/DecodeText";
/**
* PortraitSection: the introduction, with the portrait fading out of a dark surface
@@ -22,8 +25,8 @@ export const PortraitSection: React.FC<{
{/* Portrait: right side on wide screens, top on small ones. */}
<motion.div
className="relative h-[110vw] w-full md:absolute md:inset-y-0 md:right-0 md:h-auto md:w-[62%]"
initial={{ opacity: 0, scale: 1.06 }}
whileInView={{ opacity: 1, scale: 1 }}
initial={{ scale: 1.06 }}
whileInView={{ scale: 1 }}
viewport={{ once: true, margin: "-10%" }}
transition={{ duration: 1.4, ease: [0.16, 1, 0.3, 1] }}
>
@@ -39,6 +42,8 @@ export const PortraitSection: React.FC<{
<div className="absolute inset-0 hidden bg-gradient-to-r from-slate-900 via-slate-900/75 to-slate-900/10 md:block" />
<div className="absolute inset-x-0 bottom-0 h-2/5 bg-gradient-to-t from-slate-900 to-transparent" />
<div className="absolute inset-x-0 top-0 h-24 bg-gradient-to-b from-slate-900 to-transparent" />
{/* The portrait assembles itself out of binary digits. */}
<BitReveal />
</motion.div>
<div className="relative z-10 mx-auto w-full max-w-[1400px] px-5 pb-20 md:px-12 md:py-40">
@@ -54,13 +59,16 @@ export const PortraitSection: React.FC<{
{index}
</span>
<span className="h-px w-10 bg-slate-600" />
<span className="font-mono text-[11px] uppercase tracking-[0.25em] text-slate-400">
{label}
</span>
<DecodeText
text={label}
className="font-mono text-[11px] uppercase tracking-[0.25em] text-slate-400"
/>
</div>
<h2 className="m-0 text-5xl font-bold leading-[0.95] tracking-tighter text-white md:text-7xl lg:text-[6.5rem]">
{title}
</h2>
<BitWipe tone="light" delay={0.35}>
<h2 className="m-0 text-5xl font-bold leading-[0.95] tracking-tighter text-white md:text-7xl lg:text-[6.5rem]">
{title}
</h2>
</BitWipe>
<div className="mt-10 md:mt-14">{children}</div>
</motion.div>
</div>
@@ -2,7 +2,7 @@
import * as React from "react";
import Image from "next/image";
import { motion } from "framer-motion";
import { motion, useMotionValue, useSpring } from "framer-motion";
export type WorkItem = {
readonly name: string;
@@ -88,10 +88,26 @@ const WorkStage: React.FC<{ item: WorkItem }> = ({ item }) => {
const active = inView && !still;
// The frames lean a few degrees towards the pointer.
const tiltX = useSpring(useMotionValue(0), { stiffness: 120, damping: 18 });
const tiltY = useSpring(useMotionValue(0), { stiffness: 120, damping: 18 });
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
if (still || event.pointerType !== "mouse") return;
const box = event.currentTarget.getBoundingClientRect();
tiltY.set(((event.clientX - box.left) / box.width - 0.5) * 7);
tiltX.set(-((event.clientY - box.top) / box.height - 0.5) * 5);
};
const onPointerLeave = () => {
tiltX.set(0);
tiltY.set(0);
};
return (
<div
ref={ref}
className="relative overflow-hidden rounded-3xl bg-slate-900 px-5 pb-0 pt-8 md:px-12 md:pt-14"
onPointerMove={onPointerMove}
onPointerLeave={onPointerLeave}
className="relative overflow-hidden rounded-3xl bg-slate-900 px-5 pb-0 pt-8 [perspective:1400px] md:px-12 md:pt-14"
>
<div
className="pointer-events-none absolute inset-0 opacity-60"
@@ -102,7 +118,14 @@ const WorkStage: React.FC<{ item: WorkItem }> = ({ item }) => {
aria-hidden="true"
/>
<div className="relative mx-auto max-w-4xl">
<motion.div
className="relative mx-auto max-w-4xl"
style={{
rotateX: tiltX,
rotateY: tiltY,
transformStyle: "preserve-3d",
}}
>
{/* Desktop */}
<motion.div
initial={{ opacity: 0, y: 40 }}
@@ -150,7 +173,7 @@ const WorkStage: React.FC<{ item: WorkItem }> = ({ item }) => {
/>
</div>
</motion.div>
</div>
</motion.div>
</div>
);
};
+57
View File
@@ -0,0 +1,57 @@
import type { MailDraft } from "@/src/domain/email";
/**
* Message starters for the contact section. The visitor picks what they need and
* gets a prepared email to finish in their own mail app. The square brackets are
* blanks for the visitor to fill in.
*/
export type ContactTopic = {
readonly id: string;
readonly label: string;
readonly draft: MailDraft;
};
const SIGN_OFF = "Best regards";
export const CONTACT_TOPICS: readonly ContactTopic[] = [
{
id: "developer",
label: "A developer",
draft: {
subject: "A developer for our product",
body: `Hi Marc,\n\nwe are working on [what you are building] and need help with [what is next on your list].\n\nA first call would suit us around [date or week].\n\n${SIGN_OFF}`,
},
},
{
id: "rescue",
label: "A reliable app",
draft: {
subject: "Our app needs to become reliable",
body: `Hi Marc,\n\nour app [name or short description] works, but [what keeps going wrong].\n\nCould you take a look?\n\n${SIGN_OFF}`,
},
},
{
id: "automation",
label: "Automation",
draft: {
subject: "We want to automate a process",
body: `Hi Marc,\n\nwe would like to automate [the process]. Today it is done by hand in [the tools involved].\n\nCan we talk about it?\n\n${SIGN_OFF}`,
},
},
{
id: "website",
label: "A website",
draft: {
subject: "A website for my business",
body: `Hi Marc,\n\nI need a website for [your business]. It should [what it is for].\n\nWhen could we talk?\n\n${SIGN_OFF}`,
},
},
{
id: "other",
label: "Something else",
draft: {
subject: "A question",
body: `Hi Marc,\n\n[what you have in mind]\n\n${SIGN_OFF}`,
},
},
];
+50 -1
View File
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { assembleEmail, parseEmailParts, spellOutEmail } from "./email";
import {
assembleEmail,
mailtoHref,
parseEmailParts,
spellOutEmail,
} from "./email";
const parts = { user: "hello", domain: "mintel", tld: "me" } as const;
@@ -120,3 +125,47 @@ describe("spellOutEmail", () => {
).toBe("hello [at] mail [dot] site [dot] org");
});
});
describe("mailtoHref", () => {
it("opens a message to the address with subject and body", () => {
expect(mailtoHref(parts, { subject: "Hello", body: "Hi" })).toBe(
"mailto:hello@mintel.me?subject=Hello&body=Hi",
);
});
it("encodes spaces as %20, not as plus signs", () => {
expect(mailtoHref(parts, { subject: "A website", body: "Hi Marc" })).toBe(
"mailto:hello@mintel.me?subject=A%20website&body=Hi%20Marc",
);
});
it("encodes line breaks so the mail app keeps the paragraphs", () => {
expect(mailtoHref(parts, { subject: "S", body: "one\n\ntwo" })).toBe(
"mailto:hello@mintel.me?subject=S&body=one%0D%0A%0D%0Atwo",
);
});
it("encodes characters that would break the link", () => {
expect(mailtoHref(parts, { subject: "Q&A: 50%?", body: "a=b #1" })).toBe(
"mailto:hello@mintel.me?subject=Q%26A%3A%2050%25%3F&body=a%3Db%20%231",
);
});
it("keeps umlauts readable for the mail app", () => {
expect(mailtoHref(parts, { subject: "Grüße", body: "ä" })).toBe(
"mailto:hello@mintel.me?subject=Gr%C3%BC%C3%9Fe&body=%C3%A4",
);
});
it("leaves out an empty subject or body instead of sending empty fields", () => {
expect(mailtoHref(parts, { subject: "", body: "" })).toBe(
"mailto:hello@mintel.me",
);
expect(mailtoHref(parts, { subject: "Only subject", body: "" })).toBe(
"mailto:hello@mintel.me?subject=Only%20subject",
);
expect(mailtoHref(parts, { subject: "", body: "Only body" })).toBe(
"mailto:hello@mintel.me?body=Only%20body",
);
});
});
+19
View File
@@ -43,3 +43,22 @@ export function spellOutEmail(parts: EmailParts): string {
const domain = parts.domain.split(".").join(" [dot] ");
return `${parts.user} [at] ${domain} [dot] ${parts.tld}`;
}
export type MailDraft = {
readonly subject: string;
readonly body: string;
};
/** Mail apps expect CRLF line breaks in a mailto body. */
const encodeField = (value: string): string =>
encodeURIComponent(value.replace(/\r?\n/g, "\r\n"));
/** A mailto link that opens a prepared message. For use in the browser only. */
export function mailtoHref(parts: EmailParts, draft: MailDraft): string {
const query = [
draft.subject === "" ? "" : `subject=${encodeField(draft.subject)}`,
draft.body === "" ? "" : `body=${encodeField(draft.body)}`,
].filter((field) => field !== "");
const address = `mailto:${assembleEmail(parts)}`;
return query.length === 0 ? address : `${address}?${query.join("&")}`;
}
@@ -0,0 +1,140 @@
import { describe, expect, it } from "vitest";
import {
decidePageview,
type IncomingPageviewRequest,
} from "./pageview-decision";
const humanRequest = (
overrides: Partial<IncomingPageviewRequest> = {},
): IncomingPageviewRequest => ({
method: "GET",
secFetchDest: "document",
pathname: "/",
search: "",
hostname: "mintel.me",
referrer: "",
language: "de-DE",
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/131.0.0.0",
clientIp: "84.117.2.55",
...overrides,
});
describe("decidePageview", () => {
it("tracks a browser navigation for the homepage", () => {
const decision = decidePageview(humanRequest());
expect(decision).toEqual({
track: true,
facts: {
url: "/",
hostname: "mintel.me",
referrer: "",
language: "de-DE",
userAgent: humanRequest().userAgent,
clientIp: "84.117.2.55",
},
});
});
it("keeps the query string in the tracked url", () => {
const decision = decidePageview(
humanRequest({ pathname: "/imprint", search: "?from=footer" }),
);
expect(decision).toMatchObject({
track: true,
facts: { url: "/imprint?from=footer" },
});
});
it("tracks a blog-style slug that merely contains a file-like segment", () => {
const decision = decidePageview(
humanRequest({ pathname: "/blog/next.js-vs-remix" }),
);
expect(decision).toMatchObject({ track: true });
});
it("tracks a slug that merely starts like a metadata image route", () => {
const decision = decidePageview(
humanRequest({ pathname: "/notes/opengraph-image-deep-dive" }),
);
expect(decision).toMatchObject({ track: true });
});
it("accepts the method in lower case", () => {
const decision = decidePageview(humanRequest({ method: "get" }));
expect(decision).toMatchObject({ track: true });
});
it.each([
["a form submission", { method: "POST" }],
["a head request", { method: "HEAD" }],
["an options probe", { method: "OPTIONS" }],
])("skips %s", (_label, overrides) => {
const decision = decidePageview(humanRequest(overrides));
expect(decision).toEqual({ track: false, reason: "non-get-method" });
});
it.each([
["a router prefetch payload", "empty"],
["a script subresource", "script"],
["an image subresource", "image"],
["a request without fetch metadata", ""],
])("skips %s", (_label, secFetchDest) => {
const decision = decidePageview(humanRequest({ secFetchDest }));
expect(decision).toEqual({
track: false,
reason: "non-navigation-request",
});
});
it.each([
["a next build chunk", "/_next/static/chunks/main-abc123.js"],
["a next image request", "/_next/image"],
["a public image", "/images/portrait.webp"],
["a jpeg photo", "/images/photo.jpg"],
["a woff font", "/fonts/inter.woff"],
["a font", "/fonts/inter.woff2"],
["a feed document", "/rss.xml"],
["the sitemap", "/sitemap.xml"],
["the robots file", "/robots.txt"],
["the web manifest", "/manifest.webmanifest"],
["an open graph image route", "/opengraph-image"],
["a twitter image route", "/twitter-image"],
["a favicon", "/favicon.ico"],
])("skips %s at %s", (_label, pathname) => {
const decision = decidePageview(humanRequest({ pathname }));
expect(decision).toEqual({ track: false, reason: "static-asset" });
});
it.each([
["the analytics proxy", "/stats/api/send"],
["the error relay", "/errors/api/relay"],
["an api route", "/api/contact"],
["the api health check", "/api/health"],
["the health check", "/health"],
["the gatekeeper", "/gatekeeper/api/verify"],
["a well-known challenge", "/.well-known/acme-challenge/token"],
])("skips %s at %s", (_label, pathname) => {
const decision = decidePageview(humanRequest({ pathname }));
expect(decision).toEqual({ track: false, reason: "infrastructure-path" });
});
it.each([
["curl", "curl/8.1.2"],
["wget", "Wget/1.21"],
["python requests", "python-requests/2.31"],
["go http", "Go-http-client/2.0"],
["uptime kuma", "Uptime-Kuma/1.23.11"],
["a crawler", "Googlebot/2.1 (+http://www.google.com/bot.html)"],
["a link preview", "WhatsApp/2.23.20.0"],
])("skips the %s user agent", (_label, userAgent) => {
const decision = decidePageview(humanRequest({ userAgent }));
expect(decision).toEqual({ track: false, reason: "bot-user-agent" });
});
it("skips a bot even when its request looks like a navigation", () => {
const decision = decidePageview(
humanRequest({ userAgent: "curl/8.1.2", secFetchDest: "document" }),
);
expect(decision).toEqual({ track: false, reason: "bot-user-agent" });
});
});
+111
View File
@@ -0,0 +1,111 @@
/**
* Decides whether an incoming HTTP request represents a human page view,
* and distils the request into the facts a page view consists of.
* Pure classification for use in the request shell (proxy.ts).
*
* A page view is a browser navigation, identified by the Fetch Metadata
* header `sec-fetch-dest: document`. Router-internal headers (RSC,
* next-router-prefetch) are stripped by the server before the request
* shell runs and can therefore not be used.
*/
export type IncomingPageviewRequest = {
readonly method: string;
/** Value of the sec-fetch-dest request header; "" when absent. */
readonly secFetchDest: string;
readonly pathname: string;
readonly search: string;
readonly hostname: string;
readonly referrer: string;
readonly language: string;
readonly userAgent: string;
readonly clientIp: string;
};
export type PageviewFacts = {
readonly url: string;
readonly hostname: string;
readonly referrer: string;
readonly language: string;
readonly userAgent: string;
readonly clientIp: string;
};
export type PageviewSkipReason =
| "non-get-method"
| "non-navigation-request"
| "bot-user-agent"
| "static-asset"
| "infrastructure-path";
export type PageviewDecision =
| { readonly track: true; readonly facts: PageviewFacts }
| { readonly track: false; readonly reason: PageviewSkipReason };
const NAVIGATION_DESTINATION = "document";
const NEXT_INTERNAL_PREFIX = "/_next/";
const STATIC_FILE_PATTERN =
/\.(?:png|jpe?g|gif|svg|webp|avif|ico|css|mjs|cjs|js|map|txt|xml|json|webmanifest|woff2?|ttf|otf|eot|mp4|webm|mov|avi|pdf|zip|gz|csv)$/i;
const METADATA_IMAGE_ROUTES =
/\/(?:opengraph-image|twitter-image|apple-icon|icon)$/;
const INFRASTRUCTURE_PATH_PREFIXES = [
"/api/",
"/stats/",
"/errors/",
"/gatekeeper/",
"/directus/",
"/assets/",
"/showcase/",
"/.well-known/",
] as const;
// The only infrastructure path that is neither prefixed nor a static
// file extension.
const INFRASTRUCTURE_HEALTH_PATH = "/health";
const BOT_USER_AGENT_PATTERN =
/bot|crawl|spider|slurp|curl|wget|python-requests|go-http-client|uptime|whatsapp|facebook|slack|headless/i;
export function decidePageview(
request: IncomingPageviewRequest,
): PageviewDecision {
if (request.method.toUpperCase() !== "GET") {
return { track: false, reason: "non-get-method" };
}
if (request.secFetchDest !== NAVIGATION_DESTINATION) {
return { track: false, reason: "non-navigation-request" };
}
if (BOT_USER_AGENT_PATTERN.test(request.userAgent)) {
return { track: false, reason: "bot-user-agent" };
}
if (
request.pathname.startsWith(NEXT_INTERNAL_PREFIX) ||
STATIC_FILE_PATTERN.test(request.pathname) ||
METADATA_IMAGE_ROUTES.test(request.pathname)
) {
return { track: false, reason: "static-asset" };
}
if (
request.pathname === INFRASTRUCTURE_HEALTH_PATH ||
INFRASTRUCTURE_PATH_PREFIXES.some((prefix) =>
request.pathname.startsWith(prefix),
)
) {
return { track: false, reason: "infrastructure-path" };
}
return {
track: true,
facts: {
url: `${request.pathname}${request.search}`,
hostname: request.hostname,
referrer: request.referrer,
language: request.language,
userAgent: request.userAgent,
clientIp: request.clientIp,
},
};
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { buildUmamiPageviewBody } from "./umami-payload";
import type { PageviewFacts } from "./pageview-decision";
const facts = (): PageviewFacts => ({
url: "/work?ref=linkedin",
hostname: "mintel.me",
referrer: "https://www.google.com/",
language: "de-DE",
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/131.0.0.0",
clientIp: "84.117.2.55",
});
describe("buildUmamiPageviewBody", () => {
it("maps the facts onto the umami send contract", () => {
expect(buildUmamiPageviewBody(facts(), "b903-website")).toEqual({
type: "event",
payload: {
website: "b903-website",
hostname: "mintel.me",
language: "de-DE",
referrer: "https://www.google.com/",
url: "/work?ref=linkedin",
},
});
});
it("sends the pageview as an unnamed event, never as a named custom event", () => {
const body = buildUmamiPageviewBody(facts(), "b903-website");
expect(body.payload).not.toHaveProperty("name");
expect(body.payload).not.toHaveProperty("data");
});
it("preserves an empty referrer as an empty string", () => {
const body = buildUmamiPageviewBody(
{ ...facts(), referrer: "" },
"b903-website",
);
expect(body.payload.referrer).toBe("");
});
it("carries the client identity fields for the sender, outside the payload", () => {
const body = buildUmamiPageviewBody(facts(), "b903-website");
expect(body).not.toHaveProperty("userAgent");
expect(body).not.toHaveProperty("clientIp");
expect(body.payload).not.toHaveProperty("userAgent");
expect(body.payload).not.toHaveProperty("clientIp");
});
});
+35
View File
@@ -0,0 +1,35 @@
/**
* Maps page view facts onto the Umami /api/send contract.
* A page view is an unnamed event: no `name`, no `data`.
* The client identity (user agent, IP) travels as request headers, not in
* the payload — see src/analytics/recordPageviewToUmami.ts.
*/
import type { PageviewFacts } from "./pageview-decision";
export type UmamiSendBody = {
readonly type: "event";
readonly payload: {
readonly website: string;
readonly hostname: string;
readonly language: string;
readonly referrer: string;
readonly url: string;
};
};
export function buildUmamiPageviewBody(
facts: PageviewFacts,
websiteId: string,
): UmamiSendBody {
return {
type: "event",
payload: {
website: websiteId,
hostname: facts.hostname,
language: facts.language,
referrer: facts.referrer,
url: facts.url,
},
};
}
-137
View File
@@ -1,137 +0,0 @@
/**
* Analytics Service - Main entry point with DI
* Clean constructor-based dependency injection
*/
import type {
AnalyticsAdapter,
AnalyticsEvent,
AnalyticsConfig,
} from "./interfaces";
import { PlausibleAdapter } from "./plausible-adapter";
import { UmamiAdapter, type UmamiConfig } from "./umami-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 functions
export function createPlausibleAnalytics(
config: AnalyticsConfig,
): AnalyticsService {
return new AnalyticsService(new PlausibleAdapter(config));
}
export function createUmamiAnalytics(config: UmamiConfig): AnalyticsService {
return new AnalyticsService(new UmamiAdapter(config));
}
// Default singleton
let defaultAnalytics: AnalyticsService | null = null;
import { env } from "@/lib/env";
export function getDefaultAnalytics(): AnalyticsService {
if (!defaultAnalytics) {
const provider = env.NEXT_PUBLIC_ANALYTICS_PROVIDER;
if (provider === "umami") {
defaultAnalytics = createUmamiAnalytics({
hostUrl: env.UMAMI_API_ENDPOINT,
});
} else if (provider === "plausible") {
defaultAnalytics = createPlausibleAnalytics({
domain: env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN,
scriptUrl: env.NEXT_PUBLIC_PLAUSIBLE_SCRIPT_URL,
});
} else {
// No analytics provider configured
return {
getAdapter: () => ({
track: async () => {},
page: async () => {},
getScriptTag: () => null,
}),
track: async () => {},
page: async () => {},
identify: async () => {},
trackEvent: async () => {},
trackOutboundLink: async () => {},
trackSearch: async () => {},
trackPageLoad: async () => {},
} as any;
}
}
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 };
@@ -1,22 +0,0 @@
/**
* 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;
}
@@ -1,42 +0,0 @@
/**
* 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 || "";
this.scriptUrl = config.scriptUrl || "";
}
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>`;
}
}
@@ -1,78 +0,0 @@
/**
* Umami Analytics Adapter
* Decoupled implementation
*/
import type {
AnalyticsAdapter,
AnalyticsEvent,
AnalyticsConfig,
} from "./interfaces";
export interface UmamiConfig extends AnalyticsConfig {
hostUrl?: string; // Optional, defaults to env var on server
}
export class UmamiAdapter implements AnalyticsAdapter {
private hostUrl: string;
constructor(config: UmamiConfig) {
this.hostUrl = config.hostUrl || "https://analytics.infra.mintel.me";
}
private async sendPayload(type: "event", data: Record<string, any>) {
try {
if (typeof window === "undefined") return;
const payload = {
hostname: window.location.hostname,
screen: `${window.screen.width}x${window.screen.height}`,
language: navigator.language,
referrer: document.referrer,
...data,
};
await fetch("/stats/api/send", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ type, payload }),
keepalive: true,
});
} catch (e) {
console.error("Failed to send analytics", e);
}
}
async track(event: AnalyticsEvent): Promise<void> {
if (typeof window === "undefined") return;
await this.sendPayload("event", {
name: event.name,
data: event.props,
url: window.location.pathname + window.location.search,
});
}
async page(path: string, props?: Record<string, any>): Promise<void> {
if (typeof window === "undefined") return;
await this.sendPayload("event", {
url: path,
...props,
});
}
async identify(
_userId: string,
_traits?: Record<string, any>,
): Promise<void> {
// Not implemented in this version
}
// No script tag needed for proxy mode
getScriptTag(): string {
return "";
}
}
@@ -1,48 +0,0 @@
/**
* GlitchTip Error Tracking Adapter
* GlitchTip is Sentry-compatible.
* This version uses the official @sentry/nextjs SDK.
*/
import * as Sentry from "@sentry/nextjs";
import type {
ErrorTrackingAdapter,
ErrorContext,
ErrorTrackingConfig,
} from "./interfaces";
export class GlitchTipAdapter implements ErrorTrackingAdapter {
constructor(_config: ErrorTrackingConfig) {
// Sentry is initialized via sentry.*.config.ts files
}
captureException(error: any, context?: ErrorContext): void {
Sentry.captureException(error, {
extra: context?.extra,
tags: context?.tags,
user: context?.user as any,
level: context?.level as any,
});
}
captureMessage(message: string, context?: ErrorContext): void {
Sentry.captureMessage(message, {
extra: context?.extra,
tags: context?.tags,
user: context?.user as any,
level: context?.level as any,
});
}
setUser(user: ErrorContext["user"]): void {
Sentry.setUser(user as any);
}
setTag(key: string, value: string): void {
Sentry.setTag(key, value);
}
setExtra(key: string, value: any): void {
Sentry.setExtra(key, value);
}
}
@@ -1,61 +0,0 @@
/**
* Error Tracking Service - Main entry point with DI
*/
import type { ErrorTrackingAdapter, ErrorContext, ErrorTrackingConfig } from './interfaces';
import { GlitchTipAdapter } from './glitchtip-adapter';
export class ErrorTrackingService {
private adapter: ErrorTrackingAdapter;
constructor(adapter: ErrorTrackingAdapter) {
this.adapter = adapter;
}
captureException(error: any, context?: ErrorContext): void {
this.adapter.captureException(error, context);
}
captureMessage(message: string, context?: ErrorContext): void {
this.adapter.captureMessage(message, context);
}
setUser(user: ErrorContext['user']): void {
this.adapter.setUser(user);
}
setTag(key: string, value: string): void {
this.adapter.setTag(key, value);
}
setExtra(key: string, value: any): void {
this.adapter.setExtra(key, value);
}
}
// Factory function
export function createGlitchTipErrorTracking(config: ErrorTrackingConfig): ErrorTrackingService {
return new ErrorTrackingService(new GlitchTipAdapter(config));
}
// Default singleton
let defaultErrorTracking: ErrorTrackingService | null = null;
export function getDefaultErrorTracking(): ErrorTrackingService {
if (!defaultErrorTracking) {
defaultErrorTracking = createGlitchTipErrorTracking({
dsn: process.env.NEXT_PUBLIC_GLITCHTIP_DSN || '',
environment: process.env.NODE_ENV,
});
}
return defaultErrorTracking;
}
// Convenience functions
export function captureException(error: any, context?: ErrorContext): void {
getDefaultErrorTracking().captureException(error, context);
}
export function captureMessage(message: string, context?: ErrorContext): void {
getDefaultErrorTracking().captureMessage(message, context);
}
@@ -1,29 +0,0 @@
/**
* Error Tracking interfaces - decoupled contracts
*/
export interface ErrorContext {
extra?: Record<string, any>;
tags?: Record<string, string>;
user?: {
id?: string;
email?: string;
username?: string;
};
level?: 'fatal' | 'error' | 'warning' | 'info' | 'debug';
}
export interface ErrorTrackingAdapter {
captureException(_error: any, _context?: ErrorContext): void;
captureMessage(_message: string, _context?: ErrorContext): void;
setUser(_user: ErrorContext['user']): void;
setTag(_key: string, _value: string): void;
setExtra(_key: string, _value: any): void;
}
export interface ErrorTrackingConfig {
dsn: string;
environment?: string;
release?: string;
debug?: boolean;
}
+5
View File
@@ -0,0 +1,5 @@
# Incident Ledger
One line per real incident: date, mechanism, root cause, remedy. Append-only.
- 2026-10-04 — Analytics outage (since launch of the analytics feature) — page views never reached Umami (0 events ever recorded for mintel.me) — root cause: tracking depended on a browser beacon path that was never functional in production (the `/stats/api/send` proxy chain dropped the website identifier, and router-internal headers the client relied on are stripped server-side) — remedy: page views are now recorded entirely server-side in the request proxy (`apps/web/proxy.ts` + `src/domain/pageview-decision.ts`), classified as browser navigations via `sec-fetch-dest: document`; deployed as v2.1.0.