Compare commits
3 Commits
9cfe7ee9e5
...
2038b8fe47
| Author | SHA1 | Date | |
|---|---|---|---|
| 2038b8fe47 | |||
| 7fd0c447bc | |||
| 24f8772a31 |
@@ -12,6 +12,7 @@ import {
|
|||||||
ParticleNetwork,
|
ParticleNetwork,
|
||||||
GridLines,
|
GridLines,
|
||||||
} from "../../src/components/Landing";
|
} from "../../src/components/Landing";
|
||||||
|
import { Signature } from "../../src/components/Signature";
|
||||||
import { Check } from "lucide-react";
|
import { Check } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
H1,
|
H1,
|
||||||
@@ -115,10 +116,8 @@ export default function AboutPage() {
|
|||||||
Agenturen, Konzerne, Startups – ich habe die Branche von allen
|
Agenturen, Konzerne, Startups – ich habe die Branche von allen
|
||||||
Seiten kennengelernt. Was hängen geblieben ist:{" "}
|
Seiten kennengelernt. Was hängen geblieben ist:{" "}
|
||||||
<span className="text-slate-900">
|
<span className="text-slate-900">
|
||||||
<Marker delay={0.2} color="rgba(148,163,184,0.15)">
|
<Marker delay={0.2}>Ergebnisse</Marker> zählen. Nicht der
|
||||||
Ergebnisse zählen.
|
Weg dorthin.
|
||||||
</Marker>{" "}
|
|
||||||
Nicht der Weg dorthin.
|
|
||||||
</span>
|
</span>
|
||||||
</LeadText>
|
</LeadText>
|
||||||
<IconList className="space-y-4">
|
<IconList className="space-y-4">
|
||||||
@@ -228,53 +227,68 @@ export default function AboutPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Section 03: Philosophie – what drives me */}
|
{/* Section 03: Garantie – The Pledge */}
|
||||||
<Section number="03" title="Philosophie" borderTop>
|
<Section number="03" title="Garantie" borderTop>
|
||||||
<div className="space-y-12 md:space-y-16">
|
<div className="relative">
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<H3 className="text-2xl md:text-5xl leading-tight max-w-3xl">
|
<div className="max-w-4xl text-left space-y-12 md:space-y-16 py-8 md:py-16">
|
||||||
Ich stehe für <br />
|
<H3 className="text-3xl md:text-6xl leading-tight">
|
||||||
<span className="text-slate-400">meine Arbeit gerade.</span>
|
Ich stehe für <br />
|
||||||
</H3>
|
<span className="text-slate-400">meine Arbeit gerade.</span>
|
||||||
</Reveal>
|
</H3>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-12 items-start">
|
<div className="prose prose-lg md:prose-2xl text-slate-500 leading-relaxed">
|
||||||
<div className="space-y-8 min-w-0">
|
<p>
|
||||||
<Reveal delay={0.1}>
|
Keine Hierarchien. Keine Ausreden. Wenn etwas nicht passt,
|
||||||
<LeadText className="text-lg md:text-xl text-slate-400">
|
liegt die Verantwortung bei mir.
|
||||||
Keine Hierarchien, keine Ausreden. Wenn etwas nicht passt,
|
</p>
|
||||||
liegt die Verantwortung bei mir – und ich{" "}
|
<p>
|
||||||
<span className="text-slate-900">
|
Ich liefere nicht nur Code, sondern{" "}
|
||||||
<Marker color="rgba(255,235,59,0.5)">löse es.</Marker>
|
<span className="text-slate-900 font-medium relative inline-block">
|
||||||
|
Ergebnisse
|
||||||
|
<svg
|
||||||
|
className="absolute -bottom-2 left-0 w-full h-3 text-blue-500/30"
|
||||||
|
viewBox="0 0 100 10"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M0 5 Q 50 10 100 5"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
</LeadText>
|
, auf die Sie bauen können.
|
||||||
</Reveal>
|
</p>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
</div>
|
||||||
{[
|
|
||||||
"Vollständige Transparenz",
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-2xl text-left">
|
||||||
"Ein Ansprechpartner",
|
<div className="p-6 bg-slate-50 rounded-2xl border border-slate-100">
|
||||||
"Messbare Qualität",
|
<h4 className="font-bold text-slate-900 mb-2">
|
||||||
"Langfristige Partnerschaft",
|
Fixpreis-Garantie
|
||||||
].map((item, i) => (
|
</h4>
|
||||||
<Reveal key={i} delay={0.2 + i * 0.05}>
|
<p className="text-slate-500 text-sm">
|
||||||
<div className="flex items-center gap-3 group">
|
Keine versteckten Kosten. Der vereinbarte Preis ist final.
|
||||||
<div className="w-6 h-6 rounded-full bg-white border border-slate-200 flex items-center justify-center shrink-0 group-hover:bg-slate-900 group-hover:border-slate-900 group-hover:shadow-lg group-hover:shadow-blue-500/10 transition-all duration-300">
|
</p>
|
||||||
<Check className="w-3 h-3 text-slate-400 group-hover:text-white transition-colors duration-300" />
|
</div>
|
||||||
</div>
|
<div className="p-6 bg-slate-50 rounded-2xl border border-slate-100">
|
||||||
<Label className="text-slate-900 text-xs md:text-sm">
|
<h4 className="font-bold text-slate-900 mb-2">
|
||||||
{item}
|
Satisfaction Guarantee
|
||||||
</Label>
|
</h4>
|
||||||
</div>
|
<p className="text-slate-500 text-sm">
|
||||||
</Reveal>
|
Wir gehen erst live, wenn Sie zu 100% zufrieden sind.
|
||||||
))}
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-8 md:pt-12 flex flex-col items-start">
|
||||||
|
<div className="w-64 md:w-80">
|
||||||
|
<Signature delay={0.5} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Reveal>
|
||||||
{/* Decorative terminal */}
|
|
||||||
<Reveal delay={0.3} className="min-w-0">
|
|
||||||
<CodeSnippet variant="terminal" className="opacity-70" />
|
|
||||||
</Reveal>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
@@ -306,9 +320,7 @@ export default function AboutPage() {
|
|||||||
<LeadText className="text-lg md:text-4xl leading-tight max-w-2xl text-slate-400">
|
<LeadText className="text-lg md:text-4xl leading-tight max-w-2xl text-slate-400">
|
||||||
Lassen Sie uns gemeinsam etwas bauen, das{" "}
|
Lassen Sie uns gemeinsam etwas bauen, das{" "}
|
||||||
<span className="text-slate-900">
|
<span className="text-slate-900">
|
||||||
<Marker delay={0.3} color="rgba(148,163,184,0.15)">
|
wirklich <Marker delay={0.3}>funktioniert.</Marker>
|
||||||
wirklich funktioniert.
|
|
||||||
</Marker>
|
|
||||||
</span>
|
</span>
|
||||||
</LeadText>
|
</LeadText>
|
||||||
|
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export default function KLZCablesCaseStudy() {
|
|||||||
<div className="max-w-3xl border-l-[3px] border-slate-900 pl-6 md:pl-12">
|
<div className="max-w-3xl border-l-[3px] border-slate-900 pl-6 md:pl-12">
|
||||||
<LeadText className="text-lg md:text-4xl leading-tight text-slate-900 font-medium">
|
<LeadText className="text-lg md:text-4xl leading-tight text-slate-900 font-medium">
|
||||||
Engineering eines <br className="hidden md:block" />
|
Engineering eines <br className="hidden md:block" />
|
||||||
<Marker delay={0.2}>B2B Commerce Systems.</Marker>
|
<Marker delay={0.2}>Systems.</Marker>
|
||||||
</LeadText>
|
</LeadText>
|
||||||
<BodyText className="mt-4 md:mt-6 text-base md:text-xl text-slate-500 max-w-xl leading-relaxed font-serif italic">
|
<BodyText className="mt-4 md:mt-6 text-base md:text-xl text-slate-500 max-w-xl leading-relaxed font-serif italic">
|
||||||
Vom statischen Altsystem zum industriellen Standard. Ich
|
Vom statischen Altsystem zum industriellen Standard. Ich
|
||||||
@@ -172,8 +172,8 @@ export default function KLZCablesCaseStudy() {
|
|||||||
Attribute in einer zentralen relationalen Instanz. Durch die
|
Attribute in einer zentralen relationalen Instanz. Durch die
|
||||||
Implementierung nativer PHP-Services und den Verzicht auf
|
Implementierung nativer PHP-Services und den Verzicht auf
|
||||||
volatile Drittanbieter-Plugins wurde ein System geschaffen,
|
volatile Drittanbieter-Plugins wurde ein System geschaffen,
|
||||||
das keine technologischen Überraschungen zulässt.{" "}
|
das keine technologischen Überraschungen zulässt. Stability by{" "}
|
||||||
<Marker delay={0.5}>Stability by Design.</Marker>
|
<Marker delay={0.5}>Design.</Marker>
|
||||||
</BodyText>
|
</BodyText>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { IconList, IconListItem } from "../src/components/IconList";
|
|||||||
import { HeroSection } from "../src/components/HeroSection";
|
import { HeroSection } from "../src/components/HeroSection";
|
||||||
import { GlitchText } from "../src/components/GlitchText";
|
import { GlitchText } from "../src/components/GlitchText";
|
||||||
import { Marker } from "../src/components/Marker";
|
import { Marker } from "../src/components/Marker";
|
||||||
|
import { PenCircle } from "../src/components/PenCircle";
|
||||||
|
|
||||||
export default function LandingPage() {
|
export default function LandingPage() {
|
||||||
return (
|
return (
|
||||||
@@ -45,12 +46,7 @@ export default function LandingPage() {
|
|||||||
<Reveal>
|
<Reveal>
|
||||||
<H3 className="max-w-3xl">
|
<H3 className="max-w-3xl">
|
||||||
Kein Agentur-Zirkus. <br />
|
Kein Agentur-Zirkus. <br />
|
||||||
<span className="text-slate-400">
|
<Marker delay={0.3}>Ergebnisse.</Marker>
|
||||||
Nur{" "}
|
|
||||||
<Marker delay={0.3} color="rgba(148,163,184,0.15)">
|
|
||||||
Ergebnisse.
|
|
||||||
</Marker>
|
|
||||||
</span>
|
|
||||||
</H3>
|
</H3>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
@@ -104,12 +100,7 @@ export default function LandingPage() {
|
|||||||
<Reveal>
|
<Reveal>
|
||||||
<H3 className="max-w-3xl">
|
<H3 className="max-w-3xl">
|
||||||
Ich arbeite für das Ergebnis, <br />
|
Ich arbeite für das Ergebnis, <br />
|
||||||
<span className="text-slate-400">
|
nicht gegen die <Marker delay={0.4}>Uhr.</Marker>
|
||||||
nicht gegen die{" "}
|
|
||||||
<Marker delay={0.4} color="rgba(148,163,184,0.1)">
|
|
||||||
Uhr.
|
|
||||||
</Marker>
|
|
||||||
</span>
|
|
||||||
</H3>
|
</H3>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
@@ -118,14 +109,24 @@ export default function LandingPage() {
|
|||||||
negativeLabel="Klassisch"
|
negativeLabel="Klassisch"
|
||||||
negativeText="Wochen in Planung, bevor eine einzige Zeile Code geschrieben wird."
|
negativeText="Wochen in Planung, bevor eine einzige Zeile Code geschrieben wird."
|
||||||
positiveLabel="Mein Weg"
|
positiveLabel="Mein Weg"
|
||||||
positiveText="Schnelle Prototypen. Ergebnisse in Tagen, nicht Monaten."
|
positiveText={
|
||||||
|
<>
|
||||||
|
Schnelle Prototypen. Ergebnisse in{" "}
|
||||||
|
<PenCircle delay={0.5}>Tagen</PenCircle>, nicht Monaten.
|
||||||
|
</>
|
||||||
|
}
|
||||||
delay={0.1}
|
delay={0.1}
|
||||||
/>
|
/>
|
||||||
<ComparisonRow
|
<ComparisonRow
|
||||||
negativeLabel="Klassisch"
|
negativeLabel="Klassisch"
|
||||||
negativeText="Unvorhersehbare Kosten durch Stundenabrechnungen."
|
negativeText="Unvorhersehbare Kosten durch Stundenabrechnungen."
|
||||||
positiveLabel="Mein Weg"
|
positiveLabel="Mein Weg"
|
||||||
positiveText="Fixpreise. Sie wissen von Anfang an, was es kostet."
|
positiveText={
|
||||||
|
<>
|
||||||
|
<PenCircle delay={0.5}>Fixpreise.</PenCircle> Sie wissen von
|
||||||
|
Anfang an, was es kostet.
|
||||||
|
</>
|
||||||
|
}
|
||||||
reverse
|
reverse
|
||||||
delay={0.2}
|
delay={0.2}
|
||||||
/>
|
/>
|
||||||
@@ -290,7 +291,7 @@ export default function LandingPage() {
|
|||||||
<LeadText className="text-lg md:text-3xl text-slate-400">
|
<LeadText className="text-lg md:text-3xl text-slate-400">
|
||||||
Beschreiben Sie kurz Ihr Vorhaben. Ich melde mich{" "}
|
Beschreiben Sie kurz Ihr Vorhaben. Ich melde mich{" "}
|
||||||
<span className="text-slate-900 border-b-2 border-slate-900/10">
|
<span className="text-slate-900 border-b-2 border-slate-900/10">
|
||||||
<Marker color="rgba(255,235,59,0.5)">zeitnah</Marker>
|
<Marker>zeitnah</Marker>
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
bei Ihnen.
|
bei Ihnen.
|
||||||
</LeadText>
|
</LeadText>
|
||||||
|
|||||||
77
apps/web/app/stats/api/send/route.ts
Normal file
77
apps/web/app/stats/api/send/route.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,11 +42,9 @@ export default function WebsitesPage() {
|
|||||||
SYSTEM ENGINEERING
|
SYSTEM ENGINEERING
|
||||||
</MonoLabel>
|
</MonoLabel>
|
||||||
<H3 className="text-4xl md:text-8xl leading-[1.0] tracking-tighter">
|
<H3 className="text-4xl md:text-8xl leading-[1.0] tracking-tighter">
|
||||||
Websites, die <br />
|
Websites, die einfach <br />
|
||||||
<span className="text-slate-400">
|
<span className="text-slate-400">
|
||||||
<Marker color="rgba(255,235,59,0.5)">
|
<Marker>funktionieren.</Marker>
|
||||||
einfach funktionieren.
|
|
||||||
</Marker>
|
|
||||||
</span>
|
</span>
|
||||||
</H3>
|
</H3>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,9 +119,7 @@ export default function WebsitesPage() {
|
|||||||
<H3 className="text-2xl md:text-5xl leading-tight max-w-3xl">
|
<H3 className="text-2xl md:text-5xl leading-tight max-w-3xl">
|
||||||
Geschwindigkeit ist <br />
|
Geschwindigkeit ist <br />
|
||||||
<span className="text-slate-400">
|
<span className="text-slate-400">
|
||||||
<Marker delay={0.3} color="rgba(148,163,184,0.15)">
|
kein Extra. Sie ist <Marker delay={0.3}>Standard.</Marker>
|
||||||
kein Extra. Sie ist Standard.
|
|
||||||
</Marker>
|
|
||||||
</span>
|
</span>
|
||||||
</H3>
|
</H3>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|||||||
@@ -19,10 +19,7 @@ const nextConfig = {
|
|||||||
: "https://errors.infra.mintel.me";
|
: "https://errors.infra.mintel.me";
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
// Umami proxy rewrite removed in favor of app/stats/api/send/route.ts
|
||||||
source: "/stats/:path*",
|
|
||||||
destination: `${umamiUrl}/:path*`,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
source: "/errors/:path*",
|
source: "/errors/:path*",
|
||||||
destination: `${glitchtipUrl}/:path*`,
|
destination: `${glitchtipUrl}/:path*`,
|
||||||
|
|||||||
@@ -1,34 +1,59 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import React, { useEffect } from "react";
|
||||||
import { getDefaultAnalytics } from '../utils/analytics';
|
import { usePathname, useSearchParams } from "next/navigation";
|
||||||
import { getDefaultErrorTracking } from '../utils/error-tracking';
|
import { ScrollDepthTracker } from "./analytics/ScrollDepthTracker";
|
||||||
|
import { getDefaultAnalytics } from "../utils/analytics";
|
||||||
|
import { getDefaultErrorTracking } from "../utils/error-tracking";
|
||||||
|
|
||||||
export const Analytics: React.FC = () => {
|
export const Analytics: React.FC = () => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
// 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(() => {
|
useEffect(() => {
|
||||||
const analytics = getDefaultAnalytics();
|
const analytics = getDefaultAnalytics();
|
||||||
const errorTracking = getDefaultErrorTracking();
|
const errorTracking = getDefaultErrorTracking();
|
||||||
|
|
||||||
// Track page load performance
|
// Track page load performance
|
||||||
const trackPageLoad = () => {
|
const trackPageLoad = () => {
|
||||||
const perfData = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
|
// ... existing implementation ...
|
||||||
if (perfData && typeof perfData.loadEventEnd === 'number' && typeof perfData.startTime === 'number') {
|
const perfData = performance.getEntriesByType(
|
||||||
|
"navigation",
|
||||||
|
)[0] as PerformanceNavigationTiming;
|
||||||
|
if (
|
||||||
|
perfData &&
|
||||||
|
typeof perfData.loadEventEnd === "number" &&
|
||||||
|
typeof perfData.startTime === "number"
|
||||||
|
) {
|
||||||
const loadTime = perfData.loadEventEnd - perfData.startTime;
|
const loadTime = perfData.loadEventEnd - perfData.startTime;
|
||||||
analytics.trackPageLoad(
|
analytics.trackPageLoad(
|
||||||
loadTime,
|
loadTime,
|
||||||
window.location.pathname,
|
window.location.pathname,
|
||||||
navigator.userAgent
|
navigator.userAgent,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Track outbound links
|
// Track outbound links
|
||||||
const trackOutboundLinks = () => {
|
const trackOutboundLinks = () => {
|
||||||
document.querySelectorAll('a[href^="http"]').forEach(link => {
|
document.querySelectorAll('a[href^="http"]').forEach((link) => {
|
||||||
const anchor = link as HTMLAnchorElement;
|
const anchor = link as HTMLAnchorElement;
|
||||||
if (!anchor.href.includes(window.location.hostname)) {
|
if (!anchor.href.includes(window.location.hostname)) {
|
||||||
anchor.addEventListener('click', () => {
|
anchor.addEventListener("click", () => {
|
||||||
analytics.trackOutboundLink(anchor.href, anchor.textContent?.trim() || 'unknown');
|
analytics.trackOutboundLink(
|
||||||
|
anchor.href,
|
||||||
|
anchor.textContent?.trim() || "unknown",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -36,7 +61,9 @@ export const Analytics: React.FC = () => {
|
|||||||
|
|
||||||
// Track search
|
// Track search
|
||||||
const trackSearch = () => {
|
const trackSearch = () => {
|
||||||
const searchInput = document.querySelector('input[type="search"]') as HTMLInputElement;
|
const searchInput = document.querySelector(
|
||||||
|
'input[type="search"]',
|
||||||
|
) as HTMLInputElement;
|
||||||
if (searchInput) {
|
if (searchInput) {
|
||||||
const handleSearch = (e: Event) => {
|
const handleSearch = (e: Event) => {
|
||||||
const target = e.target as HTMLInputElement;
|
const target = e.target as HTMLInputElement;
|
||||||
@@ -44,8 +71,8 @@ export const Analytics: React.FC = () => {
|
|||||||
analytics.trackSearch(target.value, window.location.pathname);
|
analytics.trackSearch(target.value, window.location.pathname);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
searchInput.addEventListener('search', handleSearch);
|
searchInput.addEventListener("search", handleSearch);
|
||||||
return () => searchInput.removeEventListener('search', handleSearch);
|
return () => searchInput.removeEventListener("search", handleSearch);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -58,18 +85,37 @@ export const Analytics: React.FC = () => {
|
|||||||
errorTracking.captureException(event.reason);
|
errorTracking.captureException(event.reason);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('error', handleGlobalError);
|
window.addEventListener("error", handleGlobalError);
|
||||||
window.addEventListener('unhandledrejection', handleUnhandledRejection);
|
window.addEventListener("unhandledrejection", handleUnhandledRejection);
|
||||||
|
|
||||||
trackPageLoad();
|
// Initial load tracking
|
||||||
trackOutboundLinks();
|
if (document.readyState === "complete") {
|
||||||
const cleanupSearch = trackSearch();
|
trackPageLoad();
|
||||||
|
trackOutboundLinks();
|
||||||
return () => {
|
const cleanupSearch = trackSearch();
|
||||||
if (cleanupSearch) cleanupSearch();
|
return () => {
|
||||||
window.removeEventListener('error', handleGlobalError);
|
if (cleanupSearch) cleanupSearch();
|
||||||
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
|
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 analytics = getDefaultAnalytics();
|
||||||
@@ -81,9 +127,12 @@ export const Analytics: React.FC = () => {
|
|||||||
// We use dangerouslySetInnerHTML to inject the script tag from the adapter
|
// We use dangerouslySetInnerHTML to inject the script tag from the adapter
|
||||||
// This is safe here because the script URLs and IDs come from our own config/env
|
// This is safe here because the script URLs and IDs come from our own config/env
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
dangerouslySetInnerHTML={{ __html: scriptTag }}
|
<ScrollDepthTracker />
|
||||||
style={{ display: 'none' }}
|
<div
|
||||||
/>
|
dangerouslySetInnerHTML={{ __html: scriptTag }}
|
||||||
|
style={{ display: "none" }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ interface ButtonProps {
|
|||||||
size?: "normal" | "large";
|
size?: "normal" | "large";
|
||||||
className?: string;
|
className?: string;
|
||||||
showArrow?: boolean;
|
showArrow?: boolean;
|
||||||
|
onClick?: (e: React.MouseEvent<any>) => void;
|
||||||
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,6 +32,8 @@ export const Button: React.FC<ButtonProps> = ({
|
|||||||
size = "normal",
|
size = "normal",
|
||||||
className = "",
|
className = "",
|
||||||
showArrow = true,
|
showArrow = true,
|
||||||
|
onClick,
|
||||||
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const [hovered, setHovered] = React.useState(false);
|
const [hovered, setHovered] = React.useState(false);
|
||||||
const [displayText, setDisplayText] = React.useState<string | null>(null);
|
const [displayText, setDisplayText] = React.useState<string | null>(null);
|
||||||
@@ -153,14 +157,20 @@ export const Button: React.FC<ButtonProps> = ({
|
|||||||
<a
|
<a
|
||||||
href={href}
|
href={href}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
if (onClick) onClick(e);
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
document.querySelector(href)?.scrollIntoView({ behavior: "smooth" });
|
document.querySelector(href)?.scrollIntoView({ behavior: "smooth" });
|
||||||
}}
|
}}
|
||||||
|
{...props}
|
||||||
>
|
>
|
||||||
{inner}
|
{inner}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Link href={href}>{inner}</Link>;
|
return (
|
||||||
|
<Link href={href} onClick={onClick} {...props}>
|
||||||
|
{inner}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { useAnalytics } from "./analytics/useAnalytics";
|
||||||
|
import { AnalyticsEvents } from "./analytics/analytics-events";
|
||||||
|
|
||||||
import { FormState } from "./ContactForm/types";
|
import { FormState } from "./ContactForm/types";
|
||||||
import {
|
import {
|
||||||
PRICING,
|
PRICING,
|
||||||
@@ -69,6 +72,8 @@ export function ContactForm({
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Scroll to top on flow or step change
|
// Scroll to top on flow or step change
|
||||||
@@ -131,6 +136,10 @@ export function ContactForm({
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setIsSubmitted(true);
|
setIsSubmitted(true);
|
||||||
|
trackEvent(AnalyticsEvents.CONTACT_FORM_SUBMIT, {
|
||||||
|
flow,
|
||||||
|
config: flow === "configurator" ? state : undefined,
|
||||||
|
});
|
||||||
// Celebration
|
// Celebration
|
||||||
const duration = 3 * 1000;
|
const duration = 3 * 1000;
|
||||||
const animationEnd = Date.now() + duration;
|
const animationEnd = Date.now() + duration;
|
||||||
|
|||||||
@@ -59,19 +59,24 @@ const Node: React.FC<{
|
|||||||
);
|
);
|
||||||
|
|
||||||
const Connector: React.FC<{ active?: boolean }> = ({ active }) => (
|
const Connector: React.FC<{ active?: boolean }> = ({ active }) => (
|
||||||
<div className="flex-1 w-px md:w-auto h-8 md:h-[1px] bg-slate-100 relative min-h-[20px] md:min-w-[40px] shrink-0">
|
<div className="flex-1 w-px md:w-auto h-8 md:h-[1px] bg-slate-50 relative min-h-[24px] md:min-h-0 md:min-w-[40px] shrink-0">
|
||||||
{active && (
|
{active && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ scaleX: 0, scaleY: 0 }}
|
initial={{ scaleX: 0, scaleY: 0 }}
|
||||||
animate={{ scaleX: 1, scaleY: 1 }}
|
animate={{ scaleX: 1, scaleY: 1 }}
|
||||||
className="absolute inset-0 bg-blue-300 origin-top md:origin-left"
|
className="absolute inset-0 bg-blue-300/50 origin-top md:origin-left mix-blend-multiply"
|
||||||
|
style={{
|
||||||
|
// Visual correction to prevent sub-pixel rendering thickening
|
||||||
|
height: "100%",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-1 h-1 rounded-full",
|
"w-1 h-1 rounded-full border border-white", // Added border white to make it look smaller/cleaner
|
||||||
active ? "bg-blue-300 animate-pulse" : "bg-slate-100",
|
active ? "bg-blue-400 animate-pulse scale-75" : "bg-slate-100", // Reduced scale and deeper color
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import * as React from "react";
|
|||||||
import { ArrowRight } from "lucide-react";
|
import { ArrowRight } from "lucide-react";
|
||||||
import { Reveal } from "../Reveal";
|
import { Reveal } from "../Reveal";
|
||||||
import { Label, H3, LeadText } from "../Typography";
|
import { Label, H3, LeadText } from "../Typography";
|
||||||
|
import { Strikethrough } from "../Strikethrough";
|
||||||
import { cn } from "../../utils/cn";
|
import { cn } from "../../utils/cn";
|
||||||
|
|
||||||
interface ComparisonRowProps {
|
interface ComparisonRowProps {
|
||||||
@@ -38,11 +39,11 @@ export const ComparisonRow: React.FC<ComparisonRowProps> = ({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex-1 p-8 md:p-10 bg-slate-50/50 rounded-2xl text-slate-400 border border-transparent w-full">
|
<div className="flex-1 p-8 md:p-10 bg-slate-50/50 rounded-2xl text-slate-400 border border-transparent w-full">
|
||||||
<Label className="mb-4 line-through decoration-red-500">
|
<Label className="mb-4">
|
||||||
{negativeLabel}
|
<Strikethrough delay={delay + 0.2}>{negativeLabel}</Strikethrough>
|
||||||
</Label>
|
</Label>
|
||||||
<LeadText className="line-through decoration-red-500 leading-snug">
|
<LeadText className="leading-snug">
|
||||||
{negativeText}
|
<Strikethrough delay={delay + 0.3}>{negativeText}</Strikethrough>
|
||||||
</LeadText>
|
</LeadText>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React, { useId, useMemo } from "react";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { cn } from "../utils/cn";
|
import { cn } from "../utils/cn";
|
||||||
|
|
||||||
/**
|
|
||||||
* TECHNICAL MARKER COMPONENT
|
|
||||||
* Implements the "hand-drawn marker" effect.
|
|
||||||
* Animates in when entering the viewport.
|
|
||||||
*/
|
|
||||||
interface MarkerProps {
|
interface MarkerProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
delay?: number;
|
delay?: number;
|
||||||
@@ -16,74 +11,171 @@ interface MarkerProps {
|
|||||||
color?: string;
|
color?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copic Marker Component
|
||||||
|
*
|
||||||
|
* Uses hand-drawn filled polygon paths (NOT rects or strokes) for an
|
||||||
|
* organic, human feel. Each highlight band is a wobbly shape with
|
||||||
|
* bezier curves, not a perfect rectangle. Second strokes are offset
|
||||||
|
* to simulate imperfect re-highlighting.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Seeded PRNG for SSR-safe randomness
|
||||||
|
function createRng(seed: string) {
|
||||||
|
let s = 0;
|
||||||
|
for (let i = 0; i < seed.length; i++) {
|
||||||
|
s = ((s << 5) - s + seed.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
s = (s * 16807 + 0) % 2147483647;
|
||||||
|
return (s & 0x7fffffff) / 0x7fffffff;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a hand-drawn highlight band path.
|
||||||
|
* Instead of a rectangle, we trace an organic polygon with wobbly edges.
|
||||||
|
*
|
||||||
|
* The shape goes: left edge → top edge (left to right) → right edge → bottom edge (right to left)
|
||||||
|
* Each edge has 1-2 control points for subtle curves.
|
||||||
|
*
|
||||||
|
* ViewBox: 0 0 100 100 with preserveAspectRatio="none"
|
||||||
|
* So x=0..100 maps to text width, y=0..100 maps to text height.
|
||||||
|
*/
|
||||||
|
function generateBandPath(rng: () => number, bandIndex: number) {
|
||||||
|
// Vertical position: center band in the text height
|
||||||
|
// First band centered around y=50, second shifted up or down
|
||||||
|
const yOffset = bandIndex === 0 ? 0 : (rng() - 0.5) * 20; // -10 to +10 shift
|
||||||
|
const yCenter = 50 + yOffset;
|
||||||
|
|
||||||
|
// Band thickness: covers ~40-50% of text height
|
||||||
|
const halfHeight = 20 + rng() * 8; // 20-28 units (40-56% of viewBox)
|
||||||
|
const yTop = yCenter - halfHeight;
|
||||||
|
const yBottom = yCenter + halfHeight;
|
||||||
|
|
||||||
|
// Horizontal extent: slight random overshoot/undershoot
|
||||||
|
const xStart = -2 + rng() * 3; // -2 to 1
|
||||||
|
const xEnd = 99 + rng() * 3; // 99 to 102
|
||||||
|
|
||||||
|
// Generate wobbly top edge points (left to right)
|
||||||
|
const topWobble1 = yTop + (rng() - 0.5) * 6;
|
||||||
|
const topWobble2 = yTop + (rng() - 0.5) * 6;
|
||||||
|
const topWobble3 = yTop + (rng() - 0.5) * 6;
|
||||||
|
|
||||||
|
// Generate wobbly bottom edge points (right to left)
|
||||||
|
const botWobble1 = yBottom + (rng() - 0.5) * 6;
|
||||||
|
const botWobble2 = yBottom + (rng() - 0.5) * 6;
|
||||||
|
const botWobble3 = yBottom + (rng() - 0.5) * 6;
|
||||||
|
|
||||||
|
// Slight angle on left/right edges
|
||||||
|
const leftTopY = yTop + (rng() - 0.5) * 4;
|
||||||
|
const leftBotY = yBottom + (rng() - 0.5) * 4;
|
||||||
|
const rightTopY = yTop + (rng() - 0.5) * 4;
|
||||||
|
const rightBotY = yBottom + (rng() - 0.5) * 4;
|
||||||
|
|
||||||
|
// Build the path:
|
||||||
|
// Start at top-left, trace top edge with curves, down right edge,
|
||||||
|
// trace bottom edge with curves back left, close.
|
||||||
|
const path = [
|
||||||
|
// Start top-left
|
||||||
|
`M ${xStart},${leftTopY}`,
|
||||||
|
// Top edge: 3 curve segments left→right
|
||||||
|
`C ${xStart + 15},${topWobble1} ${xStart + 30},${topWobble1} ${33},${topWobble2}`,
|
||||||
|
`C ${45},${topWobble2} ${55},${topWobble3} ${67},${topWobble3}`,
|
||||||
|
`C ${78},${topWobble3} ${xEnd - 10},${rightTopY} ${xEnd},${rightTopY}`,
|
||||||
|
// Right edge down
|
||||||
|
`L ${xEnd + (rng() - 0.5) * 2},${rightBotY}`,
|
||||||
|
// Bottom edge: 3 curve segments right→left
|
||||||
|
`C ${xEnd - 10},${botWobble1} ${78},${botWobble1} ${67},${botWobble2}`,
|
||||||
|
`C ${55},${botWobble2} ${45},${botWobble3} ${33},${botWobble3}`,
|
||||||
|
`C ${xStart + 30},${botWobble3} ${xStart + 15},${leftBotY} ${xStart},${leftBotY}`,
|
||||||
|
// Close
|
||||||
|
`Z`,
|
||||||
|
].join(" ");
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
export const Marker: React.FC<MarkerProps> = ({
|
export const Marker: React.FC<MarkerProps> = ({
|
||||||
children,
|
children,
|
||||||
delay = 0,
|
delay = 0,
|
||||||
className = "",
|
className = "",
|
||||||
color = "rgba(255,235,59,0.7)",
|
color = "rgba(250, 204, 21, 0.5)",
|
||||||
}) => {
|
}) => {
|
||||||
|
const id = useId();
|
||||||
|
const filterId = `marker-rough-${id.replace(/:/g, "")}`;
|
||||||
|
|
||||||
|
const bands = useMemo(() => {
|
||||||
|
const rng = createRng(id);
|
||||||
|
const numBands = rng() > 0.5 ? 2 : 1;
|
||||||
|
const result = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < numBands; i++) {
|
||||||
|
result.push({
|
||||||
|
d: generateBandPath(rng, i),
|
||||||
|
delay: delay + i * 0.15,
|
||||||
|
duration: 0.4 + rng() * 0.15,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [id, delay]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={cn("relative inline px-1", className)}>
|
<span className={cn("relative inline px-1", className)}>
|
||||||
<svg
|
<svg
|
||||||
className="absolute inset-x-0 bottom-0 top-0 h-full w-full pointer-events-none z-[-1]"
|
className="absolute inset-0 h-full w-full pointer-events-none z-[-1] overflow-visible mix-blend-multiply"
|
||||||
preserveAspectRatio="none"
|
preserveAspectRatio="none"
|
||||||
viewBox="0 0 100 100"
|
viewBox="0 0 100 100"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{/* Organic Stroke 1: Main body */}
|
<defs>
|
||||||
<motion.path
|
<filter id={filterId} x="-5%" y="-5%" width="110%" height="110%">
|
||||||
d="M 0,85 C 10,87 25,82 40,84 C 55,86 75,81 90,83 C 95,84 100,85 100,85"
|
{/* Very subtle edge wobble — alcohol ink bleed, not brush fraying */}
|
||||||
vectorEffect="non-scaling-stroke"
|
<feTurbulence
|
||||||
initial={{ pathLength: 0, opacity: 0 }}
|
type="fractalNoise"
|
||||||
whileInView={{ pathLength: 1, opacity: 1 }}
|
baseFrequency="0.04 0.3"
|
||||||
viewport={{ once: true, margin: "-5%" }}
|
numOctaves="2"
|
||||||
transition={{
|
seed={Math.abs(id.charCodeAt(1) || 42)}
|
||||||
duration: 1.5,
|
result="noise"
|
||||||
delay: delay + 0.1,
|
/>
|
||||||
ease: [0.23, 1, 0.32, 1],
|
<feDisplacementMap
|
||||||
}}
|
in="SourceGraphic"
|
||||||
stroke={color}
|
in2="noise"
|
||||||
strokeWidth="60"
|
scale="1.5"
|
||||||
strokeLinecap="round"
|
xChannelSelector="R"
|
||||||
fill="none"
|
yChannelSelector="G"
|
||||||
/>
|
/>
|
||||||
{/* Organic Stroke 2: Variation for overlap */}
|
</filter>
|
||||||
<motion.path
|
</defs>
|
||||||
d="M 5,82 C 20,80 40,85 60,82 C 80,79 95,84 100,83"
|
|
||||||
vectorEffect="non-scaling-stroke"
|
{bands.map((band, i) => (
|
||||||
initial={{ pathLength: 0, opacity: 0 }}
|
<motion.path
|
||||||
whileInView={{ pathLength: 1, opacity: 0.6 }}
|
key={i}
|
||||||
viewport={{ once: true, margin: "-5%" }}
|
d={band.d}
|
||||||
transition={{
|
fill={color}
|
||||||
duration: 1.8,
|
filter={`url(#${filterId})`}
|
||||||
delay: delay + 0.3,
|
initial={{ scaleX: 0, opacity: 0 }}
|
||||||
ease: [0.23, 1, 0.32, 1],
|
whileInView={{ scaleX: 1, opacity: 1 }}
|
||||||
}}
|
viewport={{ once: true, margin: "-10%" }}
|
||||||
stroke={color}
|
transition={{
|
||||||
strokeWidth="35"
|
scaleX: {
|
||||||
strokeLinecap="round"
|
duration: band.duration,
|
||||||
fill="none"
|
delay: band.delay,
|
||||||
/>
|
ease: [0.4, 0, 0.2, 1],
|
||||||
{/* Organic Stroke 3: Rough edge details */}
|
},
|
||||||
<motion.path
|
opacity: {
|
||||||
d="M 0,88 C 15,90 35,85 55,87 C 75,89 90,84 100,86"
|
duration: 0.1,
|
||||||
vectorEffect="non-scaling-stroke"
|
delay: band.delay,
|
||||||
initial={{ pathLength: 0, opacity: 0 }}
|
},
|
||||||
whileInView={{ pathLength: 1, opacity: 0.4 }}
|
}}
|
||||||
viewport={{ once: true, margin: "-5%" }}
|
style={{ transformOrigin: "left center" }}
|
||||||
transition={{
|
/>
|
||||||
duration: 1.2,
|
))}
|
||||||
delay: delay + 0.2,
|
|
||||||
ease: [0.23, 1, 0.32, 1],
|
|
||||||
}}
|
|
||||||
stroke={color}
|
|
||||||
strokeWidth="15"
|
|
||||||
strokeLinecap="round"
|
|
||||||
fill="none"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
<span className="relative z-10 text-inherit">{children}</span>
|
<span className="relative z-10 font-semibold" style={{ color: "#000" }}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
124
apps/web/src/components/PenCircle.tsx
Normal file
124
apps/web/src/components/PenCircle.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useId, useMemo } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { cn } from "../utils/cn";
|
||||||
|
|
||||||
|
interface PenCircleProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
delay?: number;
|
||||||
|
className?: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ballpoint Pen Circle Component
|
||||||
|
*
|
||||||
|
* Draws a hand-drawn ellipse around children using SVG.
|
||||||
|
* Key: uses preserveAspectRatio="none" so the ellipse stretches
|
||||||
|
* to match the element dimensions regardless of text length.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function createRng(seed: string) {
|
||||||
|
let s = 0;
|
||||||
|
for (let i = 0; i < seed.length; i++) {
|
||||||
|
s = ((s << 5) - s + seed.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
s = (s * 16807 + 0) % 2147483647;
|
||||||
|
return (s & 0x7fffffff) / 0x7fffffff;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateCirclePath(rng: () => number) {
|
||||||
|
// We draw an ellipse in a 0-100 x 0-100 viewBox.
|
||||||
|
// preserveAspectRatio="none" will stretch it to fit the element.
|
||||||
|
const cx = 50;
|
||||||
|
const cy = 50;
|
||||||
|
|
||||||
|
// Radii in viewBox units — will be stretched by the element
|
||||||
|
const rx = 50;
|
||||||
|
const ry = 50;
|
||||||
|
|
||||||
|
// Wobble for organic feel
|
||||||
|
const w = () => (rng() - 0.5) * 6;
|
||||||
|
|
||||||
|
const k = 0.5522847;
|
||||||
|
const kx = rx * k;
|
||||||
|
const ky = ry * k;
|
||||||
|
|
||||||
|
// 4 cardinal points with wobble
|
||||||
|
const top = { x: cx + w(), y: cy - ry + w() * 0.5 };
|
||||||
|
const right = { x: cx + rx + w() * 0.3, y: cy + w() };
|
||||||
|
const bottom = { x: cx + w(), y: cy + ry + w() * 0.5 };
|
||||||
|
const left = { x: cx - rx + w() * 0.3, y: cy + w() };
|
||||||
|
|
||||||
|
// End slightly offset from start for imperfect closure
|
||||||
|
const endOffX = (rng() - 0.5) * 8;
|
||||||
|
const endOffY = (rng() - 0.5) * 4;
|
||||||
|
|
||||||
|
return [
|
||||||
|
`M ${top.x},${top.y}`,
|
||||||
|
`C ${top.x + kx + w()},${top.y + w()} ${right.x + w()},${right.y - ky + w()} ${right.x},${right.y}`,
|
||||||
|
`C ${right.x + w()},${right.y + ky + w()} ${bottom.x + kx + w()},${bottom.y + w()} ${bottom.x},${bottom.y}`,
|
||||||
|
`C ${bottom.x - kx + w()},${bottom.y + w()} ${left.x + w()},${left.y + ky + w()} ${left.x},${left.y}`,
|
||||||
|
`C ${left.x + w()},${left.y - ky + w()} ${top.x - kx + w()},${top.y + w()} ${top.x + endOffX},${top.y + endOffY}`,
|
||||||
|
].join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PenCircle: React.FC<PenCircleProps> = ({
|
||||||
|
children,
|
||||||
|
delay = 0,
|
||||||
|
className = "",
|
||||||
|
color = "rgba(37, 99, 235, 0.65)", // Blue ballpoint pen
|
||||||
|
}) => {
|
||||||
|
const id = useId();
|
||||||
|
|
||||||
|
const path = useMemo(() => {
|
||||||
|
const rng = createRng(id);
|
||||||
|
return generateCirclePath(rng);
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={cn("relative inline-block px-2 py-1", className)}>
|
||||||
|
<svg
|
||||||
|
className="absolute pointer-events-none overflow-visible"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
viewBox="-5 -5 110 110"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
top: "-6px",
|
||||||
|
left: "-8px",
|
||||||
|
width: "calc(100% + 16px)",
|
||||||
|
height: "calc(100% + 12px)",
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<motion.path
|
||||||
|
d={path}
|
||||||
|
initial={{ pathLength: 0, opacity: 0 }}
|
||||||
|
whileInView={{ pathLength: 1, opacity: 1 }}
|
||||||
|
viewport={{ once: true, margin: "-5%" }}
|
||||||
|
transition={{
|
||||||
|
pathLength: {
|
||||||
|
duration: 0.7,
|
||||||
|
delay: delay,
|
||||||
|
ease: [0.4, 0, 0.2, 1],
|
||||||
|
},
|
||||||
|
opacity: {
|
||||||
|
duration: 0.1,
|
||||||
|
delay: delay,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth="2.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
fill="none"
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
36
apps/web/src/components/Signature.tsx
Normal file
36
apps/web/src/components/Signature.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { cn } from "../utils/cn";
|
||||||
|
import Image from "next/image";
|
||||||
|
import LogoBlack from "../assets/logo/Logo Black Transparent.svg";
|
||||||
|
|
||||||
|
interface SignatureProps {
|
||||||
|
className?: string;
|
||||||
|
delay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Signature: React.FC<SignatureProps> = ({
|
||||||
|
className,
|
||||||
|
delay = 0,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className={cn("relative", className)}>
|
||||||
|
<motion.div
|
||||||
|
initial={{ clipPath: "polygon(0 0, 0 0, 0 100%, 0% 100%)" }}
|
||||||
|
whileInView={{ clipPath: "polygon(0 0, 100% 0, 100% 100%, 0% 100%)" }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 2.5, delay: delay, ease: "easeInOut" }}
|
||||||
|
className="relative"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={LogoBlack}
|
||||||
|
alt="Marc Mintel Signature"
|
||||||
|
className="w-full h-auto opacity-90"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
140
apps/web/src/components/Strikethrough.tsx
Normal file
140
apps/web/src/components/Strikethrough.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useId, useMemo } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { cn } from "../utils/cn";
|
||||||
|
|
||||||
|
interface StrikethroughProps {
|
||||||
|
children: string; // Enforce string children for splitting
|
||||||
|
delay?: number;
|
||||||
|
className?: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand-drawn Strikethrough Component
|
||||||
|
*
|
||||||
|
* NOW SUPPORTS MULTI-LINE TEXT!
|
||||||
|
*
|
||||||
|
* Strategy: Splits the text into individual words. Each word is wrapped
|
||||||
|
* in a span with its own SVG strikethrough. This allows the text to
|
||||||
|
* wrap naturally across lines, and each word carries its strikethrough
|
||||||
|
* with it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function createRng(seed: string) {
|
||||||
|
let s = 0;
|
||||||
|
for (let i = 0; i < seed.length; i++) {
|
||||||
|
s = ((s << 5) - s + seed.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
s = (s * 16807 + 0) % 2147483647;
|
||||||
|
return (s & 0x7fffffff) / 0x7fffffff;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateStrikePath(rng: () => number) {
|
||||||
|
// Simple approach: just a slightly wobbly line from left to right
|
||||||
|
// through vertical center (y=50).
|
||||||
|
const points: { x: number; y: number }[] = [];
|
||||||
|
const numPoints = 4; // Fewer points for shorter word segments
|
||||||
|
|
||||||
|
for (let i = 0; i <= numPoints; i++) {
|
||||||
|
const x = (i / numPoints) * 100;
|
||||||
|
const y = 55 + (rng() - 0.5) * 15; // ±7.5 units wobble
|
||||||
|
points.push({ x, y });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a smooth path through all points
|
||||||
|
let d = `M ${points[0].x},${points[0].y}`;
|
||||||
|
for (let i = 1; i < points.length - 1; i++) {
|
||||||
|
const midX = (points[i].x + points[i + 1].x) / 2;
|
||||||
|
const midY = (points[i].y + points[i + 1].y) / 2;
|
||||||
|
d += ` Q ${points[i].x},${points[i].y} ${midX},${midY}`;
|
||||||
|
}
|
||||||
|
const last = points[points.length - 1];
|
||||||
|
d += ` L ${last.x},${last.y}`;
|
||||||
|
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WordStrike: React.FC<{
|
||||||
|
word: string;
|
||||||
|
index: number;
|
||||||
|
baseDelay: number;
|
||||||
|
color: string;
|
||||||
|
seed: string;
|
||||||
|
}> = ({ word, index, baseDelay, color, seed }) => {
|
||||||
|
// Unique seed per word so they look different
|
||||||
|
const uniqueSeed = `${seed}-${index}-${word}`;
|
||||||
|
|
||||||
|
const path = useMemo(() => {
|
||||||
|
const rng = createRng(uniqueSeed);
|
||||||
|
return generateStrikePath(rng);
|
||||||
|
}, [uniqueSeed]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="relative inline-block whitespace-nowrap">
|
||||||
|
<svg
|
||||||
|
className="absolute inset-0 w-full h-full pointer-events-none overflow-visible"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ zIndex: 10 }}
|
||||||
|
>
|
||||||
|
<motion.path
|
||||||
|
d={path}
|
||||||
|
initial={{ pathLength: 0, opacity: 0 }}
|
||||||
|
whileInView={{ pathLength: 1, opacity: 1 }}
|
||||||
|
viewport={{ once: true, margin: "-5%" }}
|
||||||
|
transition={{
|
||||||
|
pathLength: {
|
||||||
|
duration: 0.4,
|
||||||
|
// Stagger words slightly
|
||||||
|
delay: baseDelay + index * 0.05,
|
||||||
|
ease: "easeOut",
|
||||||
|
},
|
||||||
|
opacity: { duration: 0.1, delay: baseDelay + index * 0.05 },
|
||||||
|
}}
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth="2.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
fill="none"
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{word}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Strikethrough: React.FC<StrikethroughProps> = ({
|
||||||
|
children,
|
||||||
|
delay = 0,
|
||||||
|
className = "",
|
||||||
|
color = "rgba(220, 50, 50, 0.8)",
|
||||||
|
}) => {
|
||||||
|
const id = useId();
|
||||||
|
|
||||||
|
// Split by spaces but preserve them as separate elements for spacing
|
||||||
|
// Actually, we can just split by space and rejoin with non-struck spaces
|
||||||
|
const words = children.split(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={cn("relative inline", className)}>
|
||||||
|
{words.map((word, i) => (
|
||||||
|
<React.Fragment key={i}>
|
||||||
|
<WordStrike
|
||||||
|
word={word}
|
||||||
|
index={i}
|
||||||
|
baseDelay={delay}
|
||||||
|
color={color}
|
||||||
|
seed={id}
|
||||||
|
/>
|
||||||
|
{i < words.length - 1 && " "}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
70
apps/web/src/components/analytics/ScrollDepthTracker.tsx
Normal file
70
apps/web/src/components/analytics/ScrollDepthTracker.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
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 = usePathname();
|
||||||
|
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;
|
||||||
|
}
|
||||||
48
apps/web/src/components/analytics/TrackedButton.tsx
Normal file
48
apps/web/src/components/analytics/TrackedButton.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Button } from "../Button";
|
||||||
|
import { useAnalytics } from "./useAnalytics";
|
||||||
|
import { AnalyticsEvents } from "./analytics-events";
|
||||||
|
|
||||||
|
// Since ButtonProps is not exported from Button.tsx, we define a compatible interface
|
||||||
|
interface ButtonProps {
|
||||||
|
href: string; // Correctly matching Button.tsx which has href as required
|
||||||
|
children: React.ReactNode;
|
||||||
|
variant?: "primary" | "outline" | "ghost";
|
||||||
|
size?: "normal" | "large";
|
||||||
|
className?: string;
|
||||||
|
showArrow?: boolean;
|
||||||
|
[key: string]: any; // Allow other props
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrackedButtonProps extends ButtonProps {
|
||||||
|
eventName?: string;
|
||||||
|
eventProperties?: Record<string, any>;
|
||||||
|
onClick?: (e: React.MouseEvent<any>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A wrapper around the project's Button component that tracks click events.
|
||||||
|
*/
|
||||||
|
export function TrackedButton({
|
||||||
|
eventName = AnalyticsEvents.BUTTON_CLICK,
|
||||||
|
eventProperties = {},
|
||||||
|
onClick,
|
||||||
|
...props
|
||||||
|
}: TrackedButtonProps) {
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
const handleClick = (e: React.MouseEvent<any>) => {
|
||||||
|
trackEvent(eventName, {
|
||||||
|
...eventProperties,
|
||||||
|
label:
|
||||||
|
typeof props.children === "string"
|
||||||
|
? props.children
|
||||||
|
: eventProperties.label,
|
||||||
|
});
|
||||||
|
if (onClick) onClick(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Button {...props} onClick={handleClick} />;
|
||||||
|
}
|
||||||
45
apps/web/src/components/analytics/TrackedLink.tsx
Normal file
45
apps/web/src/components/analytics/TrackedLink.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import Link, { LinkProps } from "next/link";
|
||||||
|
import { useAnalytics } from "./useAnalytics";
|
||||||
|
import { AnalyticsEvents } from "./analytics-events";
|
||||||
|
|
||||||
|
interface TrackedLinkProps extends LinkProps {
|
||||||
|
eventName?: string;
|
||||||
|
eventProperties?: Record<string, any>;
|
||||||
|
className?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
onClick?: (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => void;
|
||||||
|
// allow passing other props
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A wrapper around next/link that tracks the click event.
|
||||||
|
*/
|
||||||
|
export function TrackedLink({
|
||||||
|
href,
|
||||||
|
eventName = AnalyticsEvents.LINK_CLICK,
|
||||||
|
eventProperties = {},
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
...props
|
||||||
|
}: TrackedLinkProps) {
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
const handleClick = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
|
||||||
|
trackEvent(eventName, {
|
||||||
|
href: href.toString(),
|
||||||
|
...eventProperties,
|
||||||
|
});
|
||||||
|
if (onClick) onClick(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={href} className={className} onClick={handleClick} {...props}>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
apps/web/src/components/analytics/analytics-events.ts
Normal file
44
apps/web/src/components/analytics/analytics-events.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* 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];
|
||||||
40
apps/web/src/components/analytics/useAnalytics.ts
Normal file
40
apps/web/src/components/analytics/useAnalytics.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -12,6 +12,14 @@ const envExtension = {
|
|||||||
MAIL_PASS: z.string().optional(),
|
MAIL_PASS: z.string().optional(),
|
||||||
MAIL_FROM: z.string().optional().default("marc@mintel.me"),
|
MAIL_FROM: z.string().optional().default("marc@mintel.me"),
|
||||||
MAIL_RECIPIENTS: z.string().optional().default("marc@mintel.me"),
|
MAIL_RECIPIENTS: z.string().optional().default("marc@mintel.me"),
|
||||||
|
|
||||||
|
// Analytics (Server-side)
|
||||||
|
UMAMI_WEBSITE_ID: z.string().optional(),
|
||||||
|
UMAMI_API_ENDPOINT: z
|
||||||
|
.string()
|
||||||
|
.url()
|
||||||
|
.optional()
|
||||||
|
.default("https://analytics.infra.mintel.me"),
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -96,7 +96,6 @@ export function getDefaultAnalytics(): AnalyticsService {
|
|||||||
|
|
||||||
if (provider === "umami") {
|
if (provider === "umami") {
|
||||||
defaultAnalytics = createUmamiAnalytics({
|
defaultAnalytics = createUmamiAnalytics({
|
||||||
websiteId: env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || "",
|
|
||||||
hostUrl: env.UMAMI_API_ENDPOINT,
|
hostUrl: env.UMAMI_API_ENDPOINT,
|
||||||
});
|
});
|
||||||
} else if (provider === "plausible") {
|
} else if (provider === "plausible") {
|
||||||
|
|||||||
@@ -3,52 +3,76 @@
|
|||||||
* Decoupled implementation
|
* Decoupled implementation
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AnalyticsAdapter, AnalyticsEvent, AnalyticsConfig } from './interfaces';
|
import type {
|
||||||
|
AnalyticsAdapter,
|
||||||
|
AnalyticsEvent,
|
||||||
|
AnalyticsConfig,
|
||||||
|
} from "./interfaces";
|
||||||
|
|
||||||
export interface UmamiConfig extends AnalyticsConfig {
|
export interface UmamiConfig extends AnalyticsConfig {
|
||||||
websiteId: string;
|
hostUrl?: string; // Optional, defaults to env var on server
|
||||||
hostUrl?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UmamiAdapter implements AnalyticsAdapter {
|
export class UmamiAdapter implements AnalyticsAdapter {
|
||||||
private websiteId: string;
|
|
||||||
private hostUrl: string;
|
private hostUrl: string;
|
||||||
|
|
||||||
constructor(config: UmamiConfig) {
|
constructor(config: UmamiConfig) {
|
||||||
this.websiteId = config.websiteId;
|
this.hostUrl = config.hostUrl || "https://analytics.infra.mintel.me";
|
||||||
this.hostUrl = config.hostUrl || 'https://cloud.umami.is';
|
}
|
||||||
|
|
||||||
|
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> {
|
async track(event: AnalyticsEvent): Promise<void> {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
const w = window as any;
|
await this.sendPayload("event", {
|
||||||
if (w.umami) {
|
name: event.name,
|
||||||
w.umami.track(event.name, event.props);
|
data: event.props,
|
||||||
}
|
url: window.location.pathname + window.location.search,
|
||||||
}
|
|
||||||
|
|
||||||
async page(path: string, props?: Record<string, any>): Promise<void> {
|
|
||||||
// Umami tracks pageviews automatically by default,
|
|
||||||
// but we can manually trigger it if needed.
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
|
|
||||||
const w = window as any;
|
|
||||||
if (w.umami) {
|
|
||||||
w.umami.track(props?.name || 'pageview', { url: path, ...props });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async identify(userId: string, traits?: Record<string, any>): Promise<void> {
|
|
||||||
// Umami doesn't have a direct 'identify' like Segment,
|
|
||||||
// but we can track it as an event or session property if supported by the instance.
|
|
||||||
await this.track({
|
|
||||||
name: 'identify',
|
|
||||||
props: { userId, ...traits }
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
getScriptTag(): string {
|
||||||
return `<script async src="${this.hostUrl}/script.js" data-website-id="${this.websiteId}"></script>`;
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user