Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec227d614f | |||
| cb07b739b8 | |||
| 55e9531698 | |||
| 089ce13c59 |
@@ -113,7 +113,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
</Heading>
|
</Heading>
|
||||||
<div className="flex flex-wrap items-center gap-6 text-white/80 text-sm md:text-base font-medium">
|
<div className="flex flex-wrap items-center gap-6 text-white/80 text-sm md:text-base font-medium">
|
||||||
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
|
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
|
||||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
{new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
@@ -150,7 +150,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
</Heading>
|
</Heading>
|
||||||
<div className="flex items-center gap-6 text-text-primary/80 font-medium">
|
<div className="flex items-center gap-6 text-text-primary/80 font-medium">
|
||||||
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
|
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
|
||||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
{new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
|||||||
|
|
||||||
<div className="flex items-center gap-3 text-xs md:text-sm font-bold text-white/80 mb-3 tracking-widest uppercase">
|
<div className="flex items-center gap-3 text-xs md:text-sm font-bold text-white/80 mb-3 tracking-widest uppercase">
|
||||||
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
|
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
|
||||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
{new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export async function sendContactFormAction(formData: FormData) {
|
|||||||
? `Product Inquiry: ${productName}`
|
? `Product Inquiry: ${productName}`
|
||||||
: 'New Contact Form Submission';
|
: 'New Contact Form Submission';
|
||||||
const confirmationSubject = 'Thank you for your inquiry';
|
const confirmationSubject = 'Thank you for your inquiry';
|
||||||
|
const isTestSubmission = email === 'testing@mintel.me';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 2a. Send notification to Mintel/Client
|
// 2a. Send notification to Mintel/Client
|
||||||
@@ -84,26 +85,30 @@ export async function sendContactFormAction(formData: FormData) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const notificationResult = await sendEmail({
|
if (!isTestSubmission) {
|
||||||
replyTo: email,
|
const notificationResult = await sendEmail({
|
||||||
subject: notificationSubject,
|
replyTo: email,
|
||||||
html: notificationHtml,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (notificationResult.success) {
|
|
||||||
logger.info('Notification email sent successfully', {
|
|
||||||
messageId: notificationResult.messageId,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
logger.error('Notification email FAILED', {
|
|
||||||
error: notificationResult.error,
|
|
||||||
subject: notificationSubject,
|
subject: notificationSubject,
|
||||||
email,
|
html: notificationHtml,
|
||||||
});
|
});
|
||||||
services.errors.captureException(
|
|
||||||
new Error(`Notification email failed: ${notificationResult.error}`),
|
if (notificationResult.success) {
|
||||||
{ action: 'sendContactFormAction_notification', email },
|
logger.info('Notification email sent successfully', {
|
||||||
);
|
messageId: notificationResult.messageId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.error('Notification email FAILED', {
|
||||||
|
error: notificationResult.error,
|
||||||
|
subject: notificationSubject,
|
||||||
|
email,
|
||||||
|
});
|
||||||
|
services.errors.captureException(
|
||||||
|
new Error(`Notification email failed: ${notificationResult.error}`),
|
||||||
|
{ action: 'sendContactFormAction_notification', email },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info('Skipping notification email for test submission', { email });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2b. Send confirmation to Customer (branded as KLZ Cables)
|
// 2b. Send confirmation to Customer (branded as KLZ Cables)
|
||||||
@@ -115,26 +120,30 @@ export async function sendContactFormAction(formData: FormData) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const confirmationResult = await sendEmail({
|
if (!isTestSubmission) {
|
||||||
to: email,
|
const confirmationResult = await sendEmail({
|
||||||
subject: confirmationSubject,
|
|
||||||
html: confirmationHtml,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (confirmationResult.success) {
|
|
||||||
logger.info('Confirmation email sent successfully', {
|
|
||||||
messageId: confirmationResult.messageId,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
logger.error('Confirmation email FAILED', {
|
|
||||||
error: confirmationResult.error,
|
|
||||||
subject: confirmationSubject,
|
|
||||||
to: email,
|
to: email,
|
||||||
|
subject: confirmationSubject,
|
||||||
|
html: confirmationHtml,
|
||||||
});
|
});
|
||||||
services.errors.captureException(
|
|
||||||
new Error(`Confirmation email failed: ${confirmationResult.error}`),
|
if (confirmationResult.success) {
|
||||||
{ action: 'sendContactFormAction_confirmation', email },
|
logger.info('Confirmation email sent successfully', {
|
||||||
);
|
messageId: confirmationResult.messageId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.error('Confirmation email FAILED', {
|
||||||
|
error: confirmationResult.error,
|
||||||
|
subject: confirmationSubject,
|
||||||
|
to: email,
|
||||||
|
});
|
||||||
|
services.errors.captureException(
|
||||||
|
new Error(`Confirmation email failed: ${confirmationResult.error}`),
|
||||||
|
{ action: 'sendContactFormAction_confirmation', email },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info('Skipping confirmation email for test submission', { email });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify via Gotify (Internal)
|
// Notify via Gotify (Internal)
|
||||||
|
|||||||
@@ -352,6 +352,29 @@ export default function Header() {
|
|||||||
ref={mobileMenuRef}
|
ref={mobileMenuRef}
|
||||||
inert={isMobileMenuOpen ? undefined : true}
|
inert={isMobileMenuOpen ? undefined : true}
|
||||||
>
|
>
|
||||||
|
{/* Close Button inside overlay */}
|
||||||
|
<div className="flex justify-end p-6 pt-8">
|
||||||
|
<button
|
||||||
|
className="touch-target p-2 rounded-xl bg-white/10 border border-white/20 text-white hover:bg-white/20 transition-all duration-300"
|
||||||
|
aria-label={t('toggleMenu')}
|
||||||
|
onClick={() => {
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
|
type: 'mobile_menu',
|
||||||
|
action: 'close',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<nav className="flex-grow flex flex-col justify-center items-center p-8 space-y-8">
|
<nav className="flex-grow flex flex-col justify-center items-center p-8 space-y-8">
|
||||||
{menuItems.map((item, idx) => (
|
{menuItems.map((item, idx) => (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ const DynamicAnalyticsProvider = dynamic(() => import('./AnalyticsProvider'), {
|
|||||||
const DynamicScrollDepthTracker = dynamic(() => import('./ScrollDepthTracker'), {
|
const DynamicScrollDepthTracker = dynamic(() => import('./ScrollDepthTracker'), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
});
|
});
|
||||||
|
const DynamicWebVitalsTracker = dynamic(() => import('./WebVitalsTracker'), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
|
|
||||||
export default function AnalyticsShell() {
|
export default function AnalyticsShell() {
|
||||||
const [shouldLoad, setShouldLoad] = useState(false);
|
const [shouldLoad, setShouldLoad] = useState(false);
|
||||||
@@ -34,6 +37,7 @@ export default function AnalyticsShell() {
|
|||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<DynamicAnalyticsProvider />
|
<DynamicAnalyticsProvider />
|
||||||
<DynamicScrollDepthTracker />
|
<DynamicScrollDepthTracker />
|
||||||
|
<DynamicWebVitalsTracker />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
54
components/analytics/WebVitalsTracker.tsx
Normal file
54
components/analytics/WebVitalsTracker.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useReportWebVitals } from 'next/web-vitals';
|
||||||
|
import { useAnalytics } from './useAnalytics';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebVitalsTracker component.
|
||||||
|
*
|
||||||
|
* Captures Next.js Web Vitals and reports them to Umami as custom events.
|
||||||
|
* This provides "meaningful" page speed tracking by measuring real user
|
||||||
|
* experiences (LCP, CLS, INP, etc.).
|
||||||
|
*/
|
||||||
|
export default function WebVitalsTracker() {
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
useReportWebVitals((metric) => {
|
||||||
|
const { name, value, id, label } = metric;
|
||||||
|
|
||||||
|
// Determine rating (simplified version of web-vitals standards)
|
||||||
|
let rating: 'good' | 'needs-improvement' | 'poor' = 'good';
|
||||||
|
|
||||||
|
if (name === 'LCP') {
|
||||||
|
if (value > 4000) rating = 'poor';
|
||||||
|
else if (value > 2500) rating = 'needs-improvement';
|
||||||
|
} else if (name === 'CLS') {
|
||||||
|
if (value > 0.25) rating = 'poor';
|
||||||
|
else if (value > 0.1) rating = 'needs-improvement';
|
||||||
|
} else if (name === 'FID') {
|
||||||
|
if (value > 300) rating = 'poor';
|
||||||
|
else if (value > 100) rating = 'needs-improvement';
|
||||||
|
} else if (name === 'FCP') {
|
||||||
|
if (value > 3000) rating = 'poor';
|
||||||
|
else if (value > 1800) rating = 'needs-improvement';
|
||||||
|
} else if (name === 'TTFB') {
|
||||||
|
if (value > 1500) rating = 'poor';
|
||||||
|
else if (value > 800) rating = 'needs-improvement';
|
||||||
|
} else if (name === 'INP') {
|
||||||
|
if (value > 500) rating = 'poor';
|
||||||
|
else if (value > 200) rating = 'needs-improvement';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report to Umami
|
||||||
|
trackEvent('web-vital', {
|
||||||
|
metric: name,
|
||||||
|
value: Math.round(name === 'CLS' ? value * 1000 : value), // CLS is a score, multiply by 1000 to keep as integer if preferred
|
||||||
|
rating,
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
path: typeof window !== 'undefined' ? window.location.pathname : undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -74,7 +74,7 @@ export default async function RecentPosts({ locale, data }: RecentPostsProps) {
|
|||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
className="px-3 py-1 text-white/80 text-[10px] md:text-xs font-bold uppercase tracking-widest border border-white/20 rounded-full bg-white/10 backdrop-blur-md"
|
className="px-3 py-1 text-white/80 text-[10px] md:text-xs font-bold uppercase tracking-widest border border-white/20 rounded-full bg-white/10 backdrop-blur-md"
|
||||||
>
|
>
|
||||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
{new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
|||||||
@@ -65,7 +65,15 @@ export function getServerAppServices(): AppServices {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const errors = config.errors.glitchtip.enabled
|
const errors = config.errors.glitchtip.enabled
|
||||||
? new GlitchtipErrorReportingService({ enabled: true }, logger, notifications)
|
? new GlitchtipErrorReportingService(
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
dsn: config.errors.glitchtip.dsn,
|
||||||
|
tracesSampleRate: 1.0, // Server-side we usually want higher visibility
|
||||||
|
},
|
||||||
|
logger,
|
||||||
|
notifications,
|
||||||
|
)
|
||||||
: new NoopErrorReportingService();
|
: new NoopErrorReportingService();
|
||||||
|
|
||||||
if (config.errors.glitchtip.enabled) {
|
if (config.errors.glitchtip.enabled) {
|
||||||
|
|||||||
@@ -69,7 +69,15 @@ export function getAppServices(): AppServices {
|
|||||||
|
|
||||||
// Create error reporting service (GlitchTip/Sentry or no-op)
|
// Create error reporting service (GlitchTip/Sentry or no-op)
|
||||||
const errors = sentryEnabled
|
const errors = sentryEnabled
|
||||||
? new GlitchtipErrorReportingService({ enabled: true }, logger, notifications)
|
? new GlitchtipErrorReportingService(
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
dsn: config.errors.glitchtip.dsn,
|
||||||
|
tracesSampleRate: 0.1, // Default to 10% sampling
|
||||||
|
},
|
||||||
|
logger,
|
||||||
|
notifications,
|
||||||
|
)
|
||||||
: new NoopErrorReportingService();
|
: new NoopErrorReportingService();
|
||||||
|
|
||||||
if (sentryEnabled) {
|
if (sentryEnabled) {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import type { LoggerService } from '../logging/logger-service';
|
|||||||
|
|
||||||
export type GlitchtipErrorReportingServiceOptions = {
|
export type GlitchtipErrorReportingServiceOptions = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
dsn?: string;
|
||||||
|
tracesSampleRate?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// GlitchTip speaks the Sentry protocol; @sentry/nextjs can send to GlitchTip via DSN.
|
// GlitchTip speaks the Sentry protocol; @sentry/nextjs can send to GlitchTip via DSN.
|
||||||
@@ -46,12 +48,12 @@ export class GlitchtipErrorReportingService implements ErrorReportingService {
|
|||||||
if (!this.sentryPromise) {
|
if (!this.sentryPromise) {
|
||||||
this.sentryPromise = import('@sentry/nextjs').then((Sentry) => {
|
this.sentryPromise = import('@sentry/nextjs').then((Sentry) => {
|
||||||
// Client-side initialization must happen here since sentry.client.config.ts is empty
|
// Client-side initialization must happen here since sentry.client.config.ts is empty
|
||||||
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'production') {
|
if (typeof window !== 'undefined' && this.options.enabled) {
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn: 'https://public@errors.infra.mintel.me/1',
|
dsn: this.options.dsn || 'https://public@errors.infra.mintel.me/1',
|
||||||
tunnel: '/errors/api/relay',
|
tunnel: '/errors/api/relay',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
tracesSampleRate: 0,
|
tracesSampleRate: this.options.tracesSampleRate ?? 0.1,
|
||||||
replaysOnErrorSampleRate: 1.0,
|
replaysOnErrorSampleRate: 1.0,
|
||||||
replaysSessionSampleRate: 0.1,
|
replaysSessionSampleRate: 0.1,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -66,16 +66,29 @@ async function main() {
|
|||||||
|
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
|
|
||||||
// 3. Inject Gatekeeper session bypassing auth screens
|
// 3. Authenticate through Gatekeeper login form
|
||||||
console.log(`\n🛡️ Injecting Gatekeeper Session...`);
|
console.log(`\n🛡️ Authenticating through Gatekeeper...`);
|
||||||
await page.setCookie({
|
try {
|
||||||
name: 'klz_gatekeeper_session',
|
// Navigate to a protected page so Gatekeeper redirects us to the login screen
|
||||||
value: gatekeeperPassword,
|
await page.goto(contactUrl, { waitUntil: 'networkidle0', timeout: 30000 });
|
||||||
domain: new URL(targetUrl).hostname,
|
|
||||||
path: '/',
|
// Check if we landed on the Gatekeeper login page
|
||||||
httpOnly: true,
|
const isGatekeeperPage = await page.$('input[name="password"]');
|
||||||
secure: targetUrl.startsWith('https://'),
|
if (isGatekeeperPage) {
|
||||||
});
|
await page.type('input[name="password"]', gatekeeperPassword);
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForNavigation({ waitUntil: 'networkidle0', timeout: 30000 }),
|
||||||
|
page.click('button[type="submit"]'),
|
||||||
|
]);
|
||||||
|
console.log(`✅ Gatekeeper authentication successful!`);
|
||||||
|
} else {
|
||||||
|
console.log(`✅ Already authenticated (no Gatekeeper gate detected).`);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`❌ Gatekeeper authentication failed: ${err.message}`);
|
||||||
|
await browser.close();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
let hasErrors = false;
|
let hasErrors = false;
|
||||||
|
|
||||||
@@ -155,9 +168,38 @@ async function main() {
|
|||||||
hasErrors = true;
|
hasErrors = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5. Cleanup: Delete test submissions from Payload CMS
|
||||||
|
console.log(`\n🧹 Starting cleanup of test submissions...`);
|
||||||
|
try {
|
||||||
|
const apiUrl = `${targetUrl.replace(/\/$/, '')}/api/form-submissions`;
|
||||||
|
const searchUrl = `${apiUrl}?where[email][equals]=testing@mintel.me`;
|
||||||
|
|
||||||
|
// Fetch test submissions
|
||||||
|
const searchResponse = await axios.get(searchUrl, {
|
||||||
|
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
const testSubmissions = searchResponse.data.docs || [];
|
||||||
|
console.log(` Found ${testSubmissions.length} test submissions to clean up.`);
|
||||||
|
|
||||||
|
for (const doc of testSubmissions) {
|
||||||
|
try {
|
||||||
|
await axios.delete(`${apiUrl}/${doc.id}`, {
|
||||||
|
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
|
||||||
|
});
|
||||||
|
console.log(` ✅ Deleted submission: ${doc.id}`);
|
||||||
|
} catch (delErr: any) {
|
||||||
|
console.error(` ❌ Failed to delete submission ${doc.id}: ${delErr.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`❌ Cleanup failed: ${err.message}`);
|
||||||
|
// Don't mark the whole test as failed just because cleanup failed
|
||||||
|
}
|
||||||
|
|
||||||
await browser.close();
|
await browser.close();
|
||||||
|
|
||||||
// 5. Evaluation
|
// 6. Evaluation
|
||||||
if (hasErrors) {
|
if (hasErrors) {
|
||||||
console.error(`\n🚨 IMPORTANT: Form E2E checks failed. The CI build is failing.`);
|
console.error(`\n🚨 IMPORTANT: Form E2E checks failed. The CI build is failing.`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
Reference in New Issue
Block a user