Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
494f69db85 | ||
|
|
ed35413a44 | ||
|
|
a54df524c7 | ||
|
|
390936856c | ||
|
|
dc4a01cbe4 | ||
|
|
123ab8e354 |
@@ -28,11 +28,24 @@ App lives in `apps/web`. Concept and scope: `apps/web/plans/freelance-relaunch.m
|
|||||||
- One page: everything lives on the homepage. The header has only "Get in touch", which jumps to the contact
|
- One page: everything lives on the homepage. The header has only "Get in touch", which jumps to the contact
|
||||||
section (`#contact`). Contact is one email address: no calendar integration, no form. The only other
|
section (`#contact`). Contact is one email address: no calendar integration, no form. The only other
|
||||||
pages are `/imprint` and `/privacy`. URLs of the previous site redirect to `/` (see `next.config.mjs`).
|
pages are `/imprint` and `/privacy`. URLs of the previous site redirect to `/` (see `next.config.mjs`).
|
||||||
|
- Title, description and link previews come from `SITE_TITLE` and `SITE_DESCRIPTION` in `src/content/site.ts`.
|
||||||
|
The share image is a screenshot of the real hero: after changing the hero headline or its design, run
|
||||||
|
`pnpm --filter @mintel/web share-image` and commit the two PNGs in `app/(site)/`.
|
||||||
- The email address is kept in parts (`src/content/site.ts`) and assembled in the browser by `EmailLink`.
|
- The email address is kept in parts (`src/content/site.ts`) and assembled in the browser by `EmailLink`.
|
||||||
Never write the full address into markup, metadata or copy.
|
Never write the full address into markup, metadata or copy.
|
||||||
- The hero design (binary-digit headline) is approved: do not change its design.
|
- 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.
|
- 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
|
## Architecture rules
|
||||||
|
|
||||||
- Functional core, imperative shell. Pure logic goes in `apps/web/src/domain/` (no I/O, no clock, no
|
- Functional core, imperative shell. Pure logic goes in `apps/web/src/domain/` (no I/O, no clock, no
|
||||||
|
|||||||
@@ -1004,3 +1004,87 @@ pre:has(code[class*="language-"]) {
|
|||||||
opacity: 0.4;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Inter, JetBrains_Mono, Newsreader } from "next/font/google";
|
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 { Footer } from "@/src/components/Footer";
|
||||||
import { Header } from "@/src/components/Header";
|
import { Header } from "@/src/components/Header";
|
||||||
import { InteractiveElements } from "@/src/components/InteractiveElements";
|
import { InteractiveElements } from "@/src/components/InteractiveElements";
|
||||||
|
import {
|
||||||
|
SITE_DESCRIPTION,
|
||||||
|
SITE_NAME,
|
||||||
|
SITE_TITLE,
|
||||||
|
SITE_URL,
|
||||||
|
} from "@/src/content/site";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
|
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
|
||||||
@@ -20,15 +26,32 @@ const newsreader = Newsreader({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
|
metadataBase: new URL(SITE_URL),
|
||||||
title: {
|
title: {
|
||||||
default: "Marc Mintel — Senior developer for web apps and websites",
|
default: SITE_TITLE,
|
||||||
template: "%s | Marc Mintel",
|
template: `%s | ${SITE_NAME}`,
|
||||||
},
|
},
|
||||||
description:
|
description: SITE_DESCRIPTION,
|
||||||
"Senior developer for web apps, interfaces and websites. Book weekly hours for your product, get an unreliable app back on track, or have a website built for you.",
|
applicationName: SITE_NAME,
|
||||||
metadataBase: new URL("https://mintel.me"),
|
authors: [{ name: SITE_NAME, url: SITE_URL }],
|
||||||
|
creator: SITE_NAME,
|
||||||
icons: {
|
icons: {
|
||||||
icon: "/favicon.svg",
|
icon: "/favicon.svg",
|
||||||
|
apple: "/apple-touch-icon.png",
|
||||||
|
},
|
||||||
|
// The images come from opengraph-image.png and twitter-image.png next to this file.
|
||||||
|
openGraph: {
|
||||||
|
type: "website",
|
||||||
|
url: "/",
|
||||||
|
siteName: SITE_NAME,
|
||||||
|
locale: "en_US",
|
||||||
|
title: SITE_TITLE,
|
||||||
|
description: SITE_DESCRIPTION,
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary_large_image",
|
||||||
|
title: SITE_TITLE,
|
||||||
|
description: SITE_DESCRIPTION,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -47,7 +70,7 @@ export default function RootLayout({
|
|||||||
<main>{children}</main>
|
<main>{children}</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
<InteractiveElements />
|
<InteractiveElements />
|
||||||
<Analytics />
|
<BitBurst />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Web apps and websites. Marc Mintel, freelance developer with 15 years of experience.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 190 KiB |
@@ -1,23 +0,0 @@
|
|||||||
import { ImageResponse } from "next/og";
|
|
||||||
import { OGImageTemplate } from "../../src/components/OGImageTemplate";
|
|
||||||
import { getOgFonts, OG_IMAGE_SIZE } from "../../src/lib/og-helper";
|
|
||||||
|
|
||||||
export const size = OG_IMAGE_SIZE;
|
|
||||||
export const contentType = "image/png";
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
export default async function Image() {
|
|
||||||
const fonts = await getOgFonts();
|
|
||||||
|
|
||||||
return new ImageResponse(
|
|
||||||
<OGImageTemplate
|
|
||||||
title="Marc Mintel"
|
|
||||||
description="Freelance developer. I build web apps, interfaces and websites."
|
|
||||||
label="Web apps and websites"
|
|
||||||
/>,
|
|
||||||
{
|
|
||||||
...OG_IMAGE_SIZE,
|
|
||||||
fonts: fonts as any,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { DecodeText } from "@/src/components/bits/DecodeText";
|
||||||
import { Block } from "@/src/components/home/Block";
|
import { Block } from "@/src/components/home/Block";
|
||||||
import { ClosingCta } from "@/src/components/home/ClosingCta";
|
import { ClosingCta } from "@/src/components/home/ClosingCta";
|
||||||
import { Faq } from "@/src/components/home/Faq";
|
import { Faq } from "@/src/components/home/Faq";
|
||||||
@@ -17,12 +18,12 @@ import {
|
|||||||
} from "@/src/components/illustrations/WorkShowcase";
|
} from "@/src/components/illustrations/WorkShowcase";
|
||||||
import { Reveal } from "@/src/components/Reveal";
|
import { Reveal } from "@/src/components/Reveal";
|
||||||
import { CAREER, FIT, TECH } from "@/src/content/about";
|
import { CAREER, FIT, TECH } from "@/src/content/about";
|
||||||
|
import { SITE_DESCRIPTION, SITE_TITLE } from "@/src/content/site";
|
||||||
import { AUTOMATION_NOTES } from "@/src/content/automation";
|
import { AUTOMATION_NOTES } from "@/src/content/automation";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Marc Mintel — Senior developer for web apps and websites",
|
title: { absolute: SITE_TITLE },
|
||||||
description:
|
description: SITE_DESCRIPTION,
|
||||||
"Freelance developer with 15 years of experience. I build web apps, interfaces and websites: by the hour for product teams, or a complete website for your business.",
|
|
||||||
alternates: { canonical: "/" },
|
alternates: { canonical: "/" },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -218,7 +219,7 @@ export default function LandingPage() {
|
|||||||
key={item}
|
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"
|
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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Web apps and websites. Marc Mintel, freelance developer with 15 years of experience.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 190 KiB |
@@ -2,7 +2,7 @@
|
|||||||
"name": "@mintel/web",
|
"name": "@mintel/web",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Technical problem solver's blog - practical insights and learning notes",
|
"description": "Website of Marc Mintel, freelance developer for web apps and websites",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --webpack --hostname 0.0.0.0",
|
"dev": "next dev --webpack --hostname 0.0.0.0",
|
||||||
"dev:native": "next dev --webpack",
|
"dev:native": "next dev --webpack",
|
||||||
@@ -14,7 +14,8 @@
|
|||||||
"pagespeed:test": "npx tsx ./scripts/pagespeed-sitemap.ts",
|
"pagespeed:test": "npx tsx ./scripts/pagespeed-sitemap.ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test:mutation": "stryker run",
|
"test:mutation": "stryker run",
|
||||||
"check:og": "tsx scripts/check-og-images.ts"
|
"check:og": "tsx scripts/check-og-images.ts",
|
||||||
|
"share-image": "node scripts/render-share-image.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.750.0",
|
"@aws-sdk/client-s3": "^3.750.0",
|
||||||
|
|||||||
@@ -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)$).*)",
|
||||||
|
],
|
||||||
|
};
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 3.5 MiB |
@@ -1,4 +1,4 @@
|
|||||||
User-agent: *
|
User-agent: *
|
||||||
Allow: /
|
Allow: /
|
||||||
|
|
||||||
Sitemap: https://mintel.me/sitemap.xml
|
Sitemap: https://mintel.me/sitemap.xml
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// Renders the share image (Open Graph and Twitter) from the real hero, so it always
|
||||||
|
// matches the site. Run it after the hero headline or its design changes:
|
||||||
|
//
|
||||||
|
// pnpm --filter @mintel/web share-image (uses the live site)
|
||||||
|
// pnpm --filter @mintel/web share-image http://mintel.localhost/
|
||||||
|
//
|
||||||
|
// It drives the locally installed Chrome through Playwright and writes
|
||||||
|
// app/(site)/opengraph-image.png and app/(site)/twitter-image.png.
|
||||||
|
import { copyFileSync } from "node:fs";
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
|
||||||
|
const url = process.argv[2] ?? "https://mintel.me/";
|
||||||
|
const out = "app/(site)/opengraph-image.png";
|
||||||
|
const browser = await chromium.launch({ channel: "chrome", headless: true });
|
||||||
|
const page = await browser.newPage({
|
||||||
|
viewport: { width: 1200, height: 630 },
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
});
|
||||||
|
await page.goto(url, { waitUntil: "load", timeout: 90000 });
|
||||||
|
await page.addStyleTag({
|
||||||
|
content: `
|
||||||
|
header, footer, #hero-cta, [class*="fixed"] { display: none !important; }
|
||||||
|
main > div > section:first-of-type > div.relative { min-height: 630px !important; height: 630px !important; padding: 0 72px !important; justify-content: center !important; max-width: none !important; }
|
||||||
|
main > div > section:first-of-type ul { display: none !important; }
|
||||||
|
main > div > section:first-of-type h1 { font-size: 150px !important; }
|
||||||
|
main > div > section:first-of-type p { font-size: 34px !important; max-width: 900px !important; margin-top: 36px !important; }
|
||||||
|
main > div > section:first-of-type .grid { margin-top: 0 !important; display: block !important; }
|
||||||
|
main > div > section:first-of-type h1 span:nth-of-type(2) { color: rgba(15, 23, 42, 0.2) !important; }
|
||||||
|
main > div > section:first-of-type > div.relative { padding-top: 34px !important; }
|
||||||
|
html, body { overflow: hidden !important; }
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const section = document.querySelector("main section");
|
||||||
|
const tag = document.createElement("div");
|
||||||
|
tag.textContent = "MARC MINTEL";
|
||||||
|
tag.style.cssText =
|
||||||
|
"position:absolute;left:72px;top:54px;z-index:20;font:700 15px ui-monospace,Menlo,monospace;letter-spacing:.3em;color:#0f172a";
|
||||||
|
const site = document.createElement("div");
|
||||||
|
site.textContent = "mintel.me";
|
||||||
|
site.style.cssText =
|
||||||
|
"position:absolute;right:72px;top:54px;z-index:20;font:500 15px ui-monospace,Menlo,monospace;letter-spacing:.2em;color:#64748b";
|
||||||
|
section.style.position = "relative";
|
||||||
|
section.append(tag, site);
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(4500);
|
||||||
|
await page.screenshot({
|
||||||
|
path: out,
|
||||||
|
clip: { x: 0, y: 0, width: 1200, height: 630 },
|
||||||
|
});
|
||||||
|
await browser.close();
|
||||||
|
copyFileSync(out, "app/(site)/twitter-image.png");
|
||||||
|
console.log("written:", out, "and app/(site)/twitter-image.png");
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -3,6 +3,7 @@ import Link from "next/link";
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import LogoBlack from "../assets/logo/Logo-Black-Transparent.svg";
|
import LogoBlack from "../assets/logo/Logo-Black-Transparent.svg";
|
||||||
|
import { BinaryTicker } from "./bits/BinaryTicker";
|
||||||
|
|
||||||
export const Footer: React.FC = () => {
|
export const Footer: React.FC = () => {
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
@@ -56,11 +57,10 @@ export const Footer: React.FC = () => {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
{/* Subtle binary decoration */}
|
{/* Subtle binary decoration */}
|
||||||
<span
|
<span className="flex gap-3 text-[8px] text-slate-300 tracking-[0.5em] select-none font-mono">
|
||||||
className="text-[8px] text-slate-200 tracking-[0.5em] select-none font-mono"
|
{[77, 73, 78].map((code) => (
|
||||||
aria-hidden="true"
|
<BinaryTicker key={code} value={code} alive />
|
||||||
>
|
))}
|
||||||
01001101 01001001 01001110
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import Link from "next/link";
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import IconWhite from "../assets/logo/Icon-White-Transparent.svg";
|
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. */
|
/** Header: the logo and one action. Everything else lives on the homepage. */
|
||||||
export const Header: React.FC = () => {
|
export const Header: React.FC = () => {
|
||||||
@@ -52,6 +54,8 @@ export const Header: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<ScrollBits className="hidden font-mono text-[10px] tracking-[0.3em] text-slate-400 md:block" />
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/#contact"
|
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"
|
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)",
|
transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Get in touch
|
<DecodeText text="Get in touch" trigger="hover" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,248 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
interface OGImageTemplateProps {
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
label?: string;
|
|
||||||
accentColor?: string;
|
|
||||||
keyword?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OGImageTemplate({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
label,
|
|
||||||
accentColor,
|
|
||||||
keyword,
|
|
||||||
}: OGImageTemplateProps) {
|
|
||||||
const accent = accentColor || "#3b82f6";
|
|
||||||
const slateDark = "#0f172a";
|
|
||||||
const slateLight = "#64748b";
|
|
||||||
|
|
||||||
const containerStyle: React.CSSProperties = {
|
|
||||||
height: "100%",
|
|
||||||
width: "100%",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "flex-start",
|
|
||||||
justifyContent: "center",
|
|
||||||
backgroundColor: "#ffffff",
|
|
||||||
padding: "80px",
|
|
||||||
position: "relative",
|
|
||||||
fontFamily: "Inter",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={containerStyle}>
|
|
||||||
{/* Background Grid Pattern */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundImage: `linear-gradient(rgba(148,163,184,0.06) 1px, transparent 1px), linear-gradient(90deg, rgba(148,163,184,0.06) 1px, transparent 1px)`,
|
|
||||||
backgroundSize: "40px 40px",
|
|
||||||
display: "flex",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Accent geometric block (right side) */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "60px",
|
|
||||||
right: "80px",
|
|
||||||
width: "200px",
|
|
||||||
height: "200px",
|
|
||||||
borderRadius: "24px",
|
|
||||||
border: `3px solid ${accent}`,
|
|
||||||
opacity: 0.15,
|
|
||||||
display: "flex",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "100px",
|
|
||||||
right: "120px",
|
|
||||||
width: "160px",
|
|
||||||
height: "160px",
|
|
||||||
borderRadius: "20px",
|
|
||||||
backgroundColor: accent,
|
|
||||||
opacity: 0.08,
|
|
||||||
display: "flex",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* Small accent circles */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "80px",
|
|
||||||
right: "100px",
|
|
||||||
width: "16px",
|
|
||||||
height: "16px",
|
|
||||||
borderRadius: "8px",
|
|
||||||
backgroundColor: accent,
|
|
||||||
opacity: 0.5,
|
|
||||||
display: "flex",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "240px",
|
|
||||||
right: "90px",
|
|
||||||
width: "10px",
|
|
||||||
height: "10px",
|
|
||||||
borderRadius: "5px",
|
|
||||||
backgroundColor: accent,
|
|
||||||
opacity: 0.3,
|
|
||||||
display: "flex",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
position: "relative",
|
|
||||||
zIndex: 10,
|
|
||||||
maxWidth: "850px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Label / Category */}
|
|
||||||
{label && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: "18px",
|
|
||||||
fontWeight: 700,
|
|
||||||
color: accent,
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: "0.2em",
|
|
||||||
marginBottom: "24px",
|
|
||||||
display: "flex",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: title.length > 40 ? "56px" : "72px",
|
|
||||||
fontWeight: 700,
|
|
||||||
color: slateDark,
|
|
||||||
lineHeight: "1.1",
|
|
||||||
marginBottom: "28px",
|
|
||||||
display: "flex",
|
|
||||||
letterSpacing: "-0.03em",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
{description && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: "26px",
|
|
||||||
color: slateLight,
|
|
||||||
lineHeight: "1.45",
|
|
||||||
display: "flex",
|
|
||||||
fontWeight: 400,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{description.length > 120
|
|
||||||
? description.substring(0, 117) + "..."
|
|
||||||
: description}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Brand Footer */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: "60px",
|
|
||||||
left: "80px",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: "50px",
|
|
||||||
height: "3px",
|
|
||||||
backgroundColor: slateDark,
|
|
||||||
borderRadius: "2px",
|
|
||||||
marginRight: "16px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: "20px",
|
|
||||||
fontWeight: 700,
|
|
||||||
color: slateDark,
|
|
||||||
textTransform: "lowercase",
|
|
||||||
letterSpacing: "0.05em",
|
|
||||||
display: "flex",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
mintel.me
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Keyword badge (bottom-right) */}
|
|
||||||
{keyword && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: "60px",
|
|
||||||
right: "80px",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "8px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: "8px",
|
|
||||||
height: "8px",
|
|
||||||
borderRadius: "4px",
|
|
||||||
backgroundColor: accent,
|
|
||||||
display: "flex",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: "14px",
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "#94a3b8",
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: "0.15em",
|
|
||||||
fontFamily: "ui-monospace, monospace",
|
|
||||||
display: "flex",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{keyword}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Accent Strip */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: "100%",
|
|
||||||
height: "5px",
|
|
||||||
backgroundColor: accent,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
|
};
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
import * as React from "react";
|
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";
|
import { Reveal } from "@/src/components/Reveal";
|
||||||
|
|
||||||
interface BlockProps {
|
interface BlockProps {
|
||||||
@@ -32,22 +35,22 @@ export const Block: React.FC<BlockProps> = ({
|
|||||||
{index}
|
{index}
|
||||||
</span>
|
</span>
|
||||||
<span className="h-px w-10 bg-slate-300" />
|
<span className="h-px w-10 bg-slate-300" />
|
||||||
<span className="font-mono text-[11px] uppercase tracking-[0.25em] text-slate-500">
|
<DecodeText
|
||||||
{label}
|
text={label}
|
||||||
</span>
|
className="font-mono text-[11px] uppercase tracking-[0.25em] text-slate-500"
|
||||||
<span
|
/>
|
||||||
|
<BinaryTicker
|
||||||
|
value={parseInt(index, 10)}
|
||||||
|
alive
|
||||||
className="ml-auto hidden font-mono text-[10px] tracking-[0.3em] text-slate-300 md:block"
|
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>
|
</div>
|
||||||
</Reveal>
|
</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]">
|
<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}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
</Reveal>
|
</BitWipe>
|
||||||
<div className="mt-14 md:mt-24">{children}</div>
|
<div className="mt-14 md:mt-24">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -2,34 +2,46 @@ import * as React from "react";
|
|||||||
import { Reveal } from "@/src/components/Reveal";
|
import { Reveal } from "@/src/components/Reveal";
|
||||||
import { EmailLink } from "@/src/components/EmailLink";
|
import { EmailLink } from "@/src/components/EmailLink";
|
||||||
import { BitHeadline, type BitLine } from "./BitHeadline";
|
import { BitHeadline, type BitLine } from "./BitHeadline";
|
||||||
|
import { ContactComposer } from "./ContactComposer";
|
||||||
|
|
||||||
const CLOSING: readonly BitLine[] = [
|
const CLOSING: readonly BitLine[] = [
|
||||||
{ text: "LET'S", tone: "bits" },
|
{ text: "LET'S", tone: "bits" },
|
||||||
{ text: "TALK.", tone: "solid" },
|
{ 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 = () => (
|
export const ClosingCta: React.FC = () => (
|
||||||
<section
|
<section
|
||||||
id="contact"
|
id="contact"
|
||||||
className="relative border-t border-slate-200 bg-slate-900 text-white"
|
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">
|
<div
|
||||||
<Reveal>
|
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
|
<BitHeadline
|
||||||
as="h2"
|
as="h2"
|
||||||
invert
|
invert
|
||||||
lines={CLOSING}
|
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">
|
<p className="m-0 mt-10 max-w-xl 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
|
Pick what you need and the email writes itself. Or write to me
|
||||||
call.
|
directly:
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-12">
|
<div className="mt-8">
|
||||||
<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" />
|
<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>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
<Reveal delay={0.15} className="lg:col-span-6">
|
||||||
|
<ContactComposer />
|
||||||
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,13 +1,37 @@
|
|||||||
import { SITE_NAME, SITE_URL } from "@/src/content/site";
|
import {
|
||||||
|
OWNER,
|
||||||
|
SITE_DESCRIPTION,
|
||||||
|
SITE_NAME,
|
||||||
|
SITE_URL,
|
||||||
|
} from "@/src/content/site";
|
||||||
|
|
||||||
const STRUCTURED_DATA = {
|
const STRUCTURED_DATA = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "ProfessionalService",
|
"@type": "ProfessionalService",
|
||||||
name: SITE_NAME,
|
name: SITE_NAME,
|
||||||
url: SITE_URL,
|
url: SITE_URL,
|
||||||
description:
|
description: SITE_DESCRIPTION,
|
||||||
"Senior freelance developer for web apps, interfaces and websites: weekly capacity for product teams, making unreliable apps dependable, and websites built end to end.",
|
image: `${SITE_URL}/marc-mintel.png`,
|
||||||
founder: { "@type": "Person", name: SITE_NAME },
|
address: {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
streetAddress: OWNER.street,
|
||||||
|
postalCode: OWNER.city.split(" ")[0],
|
||||||
|
addressLocality: OWNER.city.split(" ").slice(1).join(" "),
|
||||||
|
addressCountry: "DE",
|
||||||
|
},
|
||||||
|
founder: {
|
||||||
|
"@type": "Person",
|
||||||
|
name: SITE_NAME,
|
||||||
|
jobTitle: "Freelance developer",
|
||||||
|
url: SITE_URL,
|
||||||
|
},
|
||||||
|
knowsAbout: [
|
||||||
|
"Web applications",
|
||||||
|
"User interfaces",
|
||||||
|
"Websites",
|
||||||
|
"Process automation",
|
||||||
|
"Web3 frontends",
|
||||||
|
],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** Serialized server-side from constants only; "<" is escaped to keep it inert inside a script tag. */
|
/** Serialized server-side from constants only; "<" is escaped to keep it inert inside a script tag. */
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { Button } from "@/src/components/Button";
|
import { Button } from "@/src/components/Button";
|
||||||
|
import { DecodeText } from "@/src/components/bits/DecodeText";
|
||||||
import { PathScene } from "@/src/components/illustrations/PathScenes";
|
import { PathScene } from "@/src/components/illustrations/PathScenes";
|
||||||
import { SERVICE_PATHS, type ServicePath } from "@/src/content/paths";
|
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="md:order-2 md:col-span-7">
|
||||||
<div className="relative overflow-hidden rounded-3xl bg-slate-900 md:sticky md:top-28">
|
<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">
|
<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">
|
<DecodeText
|
||||||
{current?.title}
|
key={current?.title}
|
||||||
</span>
|
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">
|
<span className="font-mono text-[11px] tracking-[0.2em] text-slate-500">
|
||||||
{step + 1} / {path.steps.length}
|
{step + 1} / {path.steps.length}
|
||||||
</span>
|
</span>
|
||||||
@@ -151,7 +154,7 @@ export const PathPicker: React.FC = () => {
|
|||||||
aria-selected={selected}
|
aria-selected={selected}
|
||||||
aria-controls={`path-panel-${path.id}`}
|
aria-controls={`path-panel-${path.id}`}
|
||||||
onClick={() => setActiveId(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
|
selected
|
||||||
? "border-slate-900 bg-slate-900 text-white"
|
? "border-slate-900 bg-slate-900 text-white"
|
||||||
: "border-slate-200 bg-white text-slate-900 hover:-translate-y-1 hover:border-slate-400"
|
: "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 * as React from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { motion } from "framer-motion";
|
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
|
* 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. */}
|
{/* Portrait: right side on wide screens, top on small ones. */}
|
||||||
<motion.div
|
<motion.div
|
||||||
className="relative h-[110vw] w-full md:absolute md:inset-y-0 md:right-0 md:h-auto md:w-[62%]"
|
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 }}
|
initial={{ scale: 1.06 }}
|
||||||
whileInView={{ opacity: 1, scale: 1 }}
|
whileInView={{ scale: 1 }}
|
||||||
viewport={{ once: true, margin: "-10%" }}
|
viewport={{ once: true, margin: "-10%" }}
|
||||||
transition={{ duration: 1.4, ease: [0.16, 1, 0.3, 1] }}
|
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-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 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" />
|
<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>
|
</motion.div>
|
||||||
|
|
||||||
<div className="relative z-10 mx-auto w-full max-w-[1400px] px-5 pb-20 md:px-12 md:py-40">
|
<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}
|
{index}
|
||||||
</span>
|
</span>
|
||||||
<span className="h-px w-10 bg-slate-600" />
|
<span className="h-px w-10 bg-slate-600" />
|
||||||
<span className="font-mono text-[11px] uppercase tracking-[0.25em] text-slate-400">
|
<DecodeText
|
||||||
{label}
|
text={label}
|
||||||
</span>
|
className="font-mono text-[11px] uppercase tracking-[0.25em] text-slate-400"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="m-0 text-5xl font-bold leading-[0.95] tracking-tighter text-white md:text-7xl lg:text-[6.5rem]">
|
<BitWipe tone="light" delay={0.35}>
|
||||||
{title}
|
<h2 className="m-0 text-5xl font-bold leading-[0.95] tracking-tighter text-white md:text-7xl lg:text-[6.5rem]">
|
||||||
</h2>
|
{title}
|
||||||
|
</h2>
|
||||||
|
</BitWipe>
|
||||||
<div className="mt-10 md:mt-14">{children}</div>
|
<div className="mt-10 md:mt-14">{children}</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { motion } from "framer-motion";
|
import { motion, useMotionValue, useSpring } from "framer-motion";
|
||||||
|
|
||||||
export type WorkItem = {
|
export type WorkItem = {
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
@@ -88,10 +88,26 @@ const WorkStage: React.FC<{ item: WorkItem }> = ({ item }) => {
|
|||||||
|
|
||||||
const active = inView && !still;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
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
|
<div
|
||||||
className="pointer-events-none absolute inset-0 opacity-60"
|
className="pointer-events-none absolute inset-0 opacity-60"
|
||||||
@@ -102,7 +118,14 @@ const WorkStage: React.FC<{ item: WorkItem }> = ({ item }) => {
|
|||||||
aria-hidden="true"
|
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 */}
|
{/* Desktop */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 40 }}
|
initial={{ opacity: 0, y: 40 }}
|
||||||
@@ -150,7 +173,7 @@ const WorkStage: React.FC<{ item: WorkItem }> = ({ item }) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -3,6 +3,12 @@ import { parseEmailParts, type EmailParts } from "@/src/domain/email";
|
|||||||
export const SITE_NAME = "Marc Mintel";
|
export const SITE_NAME = "Marc Mintel";
|
||||||
export const SITE_URL = "https://mintel.me";
|
export const SITE_URL = "https://mintel.me";
|
||||||
|
|
||||||
|
/** One wording for the browser title, search results and link previews. */
|
||||||
|
export const SITE_TITLE =
|
||||||
|
"Marc Mintel — Freelance developer for web apps and websites";
|
||||||
|
export const SITE_DESCRIPTION =
|
||||||
|
"Freelance developer with 15 years of experience. I build web apps, interfaces and websites, and automate processes between your systems.";
|
||||||
|
|
||||||
// The contact address, kept in parts on purpose: the complete address must never
|
// The contact address, kept in parts on purpose: the complete address must never
|
||||||
// appear as one string in the page source (see src/components/EmailLink.tsx).
|
// appear as one string in the page source (see src/components/EmailLink.tsx).
|
||||||
const parsedEmail = parseEmailParts({
|
const parsedEmail = parseEmailParts({
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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;
|
const parts = { user: "hello", domain: "mintel", tld: "me" } as const;
|
||||||
|
|
||||||
@@ -120,3 +125,47 @@ describe("spellOutEmail", () => {
|
|||||||
).toBe("hello [at] mail [dot] site [dot] org");
|
).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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -43,3 +43,22 @@ export function spellOutEmail(parts: EmailParts): string {
|
|||||||
const domain = parts.domain.split(".").join(" [dot] ");
|
const domain = parts.domain.split(".").join(" [dot] ");
|
||||||
return `${parts.user} [at] ${domain} [dot] ${parts.tld}`;
|
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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { readFileSync } from "fs";
|
|
||||||
import { join } from "path";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads the Inter fonts for use in Satori (Next.js OG Image generation).
|
|
||||||
* Since we are using runtime = 'nodejs', we can read them from the filesystem.
|
|
||||||
*/
|
|
||||||
export async function getOgFonts() {
|
|
||||||
// Use relative path from process.cwd() which usually is the monorepo root or apps/web
|
|
||||||
// We try both to be safe during different execution contexts (dev vs build vs runtime)
|
|
||||||
const pathsToTry = [
|
|
||||||
join(process.cwd(), "apps/web/public/fonts"),
|
|
||||||
join(process.cwd(), "public/fonts"),
|
|
||||||
];
|
|
||||||
|
|
||||||
let boldFontPath = "";
|
|
||||||
let regularFontPath = "";
|
|
||||||
|
|
||||||
for (const basePath of pathsToTry) {
|
|
||||||
const b = join(basePath, "Inter-Bold.woff");
|
|
||||||
const r = join(basePath, "Inter-Regular.woff");
|
|
||||||
try {
|
|
||||||
if (readFileSync(b) && readFileSync(r)) {
|
|
||||||
boldFontPath = b;
|
|
||||||
regularFontPath = r;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch (_e) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!boldFontPath || !regularFontPath) {
|
|
||||||
console.error(
|
|
||||||
`[OG] Could not find fonts in either ${pathsToTry.join(" or ")}`,
|
|
||||||
);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log(
|
|
||||||
`[OG] Loading fonts: bold=${boldFontPath}, regular=${regularFontPath}`,
|
|
||||||
);
|
|
||||||
const boldFont = readFileSync(boldFontPath);
|
|
||||||
const regularFont = readFileSync(regularFontPath);
|
|
||||||
console.log(
|
|
||||||
`[OG] Fonts loaded successfully (${boldFont.length} and ${regularFont.length} bytes)`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
name: "Inter",
|
|
||||||
data: boldFont,
|
|
||||||
weight: 700 as const,
|
|
||||||
style: "normal" as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Inter",
|
|
||||||
data: regularFont,
|
|
||||||
weight: 400 as const,
|
|
||||||
style: "normal" as const,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[OG] Failed to load fonts:`, error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Common configuration for OG images
|
|
||||||
*/
|
|
||||||
export const OG_IMAGE_SIZE = {
|
|
||||||
width: 1200,
|
|
||||||
height: 630,
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user