Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| acf642d7e6 | |||
| d5da2a91c8 | |||
| ebe664f984 | |||
| 9c7324ee92 | |||
| 0c8d9ea669 | |||
| 1bb0efc85b | |||
| 4adf547265 | |||
| ec227d614f | |||
| cb07b739b8 | |||
| 55e9531698 | |||
| 089ce13c59 |
@@ -14,4 +14,4 @@ jobs:
|
|||||||
secrets:
|
secrets:
|
||||||
GOTIFY_URL: ${{ secrets.GOTIFY_URL }}
|
GOTIFY_URL: ${{ secrets.GOTIFY_URL }}
|
||||||
GOTIFY_TOKEN: ${{ secrets.GOTIFY_TOKEN }}
|
GOTIFY_TOKEN: ${{ secrets.GOTIFY_TOKEN }}
|
||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'lassmichrein' }}
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
"valid-id": "off",
|
"valid-id": "off",
|
||||||
"element-required-attributes": "off",
|
"element-required-attributes": "off",
|
||||||
"attribute-empty-style": "off",
|
"attribute-empty-style": "off",
|
||||||
"element-permitted-content": "off"
|
"element-permitted-content": "off",
|
||||||
|
"element-required-content": "off",
|
||||||
|
"element-permitted-parent": "off",
|
||||||
|
"no-implicit-close": "off",
|
||||||
|
"close-order": "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,20 @@ export const size = OG_IMAGE_SIZE;
|
|||||||
export const contentType = 'image/png';
|
export const contentType = 'image/png';
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
async function fetchImageAsBase64(url: string) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) return undefined;
|
||||||
|
const arrayBuffer = await res.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
const contentType = res.headers.get('content-type') || 'image/jpeg';
|
||||||
|
return `data:${contentType};base64,${buffer.toString('base64')}`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch OG image:', url, error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default async function Image({
|
export default async function Image({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
@@ -32,12 +46,19 @@ export default async function Image({
|
|||||||
: `${SITE_URL}${post.frontmatter.featuredImage}`
|
: `${SITE_URL}${post.frontmatter.featuredImage}`
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
// Fetch image explicitly and convert to base64 because Satori sometimes struggles
|
||||||
|
// fetching remote URLs directly inside ImageResponse correctly in various environments.
|
||||||
|
let base64Image: string | undefined = undefined;
|
||||||
|
if (featuredImage) {
|
||||||
|
base64Image = await fetchImageAsBase64(featuredImage);
|
||||||
|
}
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
<OGImageTemplate
|
<OGImageTemplate
|
||||||
title={post.frontmatter.title}
|
title={post.frontmatter.title}
|
||||||
description={post.frontmatter.excerpt}
|
description={post.frontmatter.excerpt}
|
||||||
label={post.frontmatter.category || 'Blog'}
|
label={post.frontmatter.category || 'Blog'}
|
||||||
image={featuredImage}
|
image={base64Image || featuredImage}
|
||||||
/>,
|
/>,
|
||||||
{
|
{
|
||||||
...OG_IMAGE_SIZE,
|
...OG_IMAGE_SIZE,
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { notFound, redirect } from 'next/navigation';
|
import { notFound, redirect } from 'next/navigation';
|
||||||
import JsonLd from '@/components/JsonLd';
|
import JsonLd from '@/components/JsonLd';
|
||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
import { getPostBySlug, getAdjacentPosts, getReadingTime } from '@/lib/blog';
|
import {
|
||||||
|
getPostBySlug,
|
||||||
|
getAdjacentPosts,
|
||||||
|
getReadingTime,
|
||||||
|
extractLexicalHeadings,
|
||||||
|
} from '@/lib/blog';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import PostNavigation from '@/components/blog/PostNavigation';
|
import PostNavigation from '@/components/blog/PostNavigation';
|
||||||
import PowerCTA from '@/components/blog/PowerCTA';
|
import PowerCTA from '@/components/blog/PowerCTA';
|
||||||
|
import TableOfContents from '@/components/blog/TableOfContents';
|
||||||
import { Heading } from '@/components/ui';
|
import { Heading } from '@/components/ui';
|
||||||
import { setRequestLocale } from 'next-intl/server';
|
import { setRequestLocale } from 'next-intl/server';
|
||||||
import BlogEngagementTracker from '@/components/analytics/BlogEngagementTracker';
|
import BlogEngagementTracker from '@/components/analytics/BlogEngagementTracker';
|
||||||
@@ -67,6 +73,10 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
|
|
||||||
const { prev, next, isPrevRandom, isNextRandom } = await getAdjacentPosts(post.slug, locale);
|
const { prev, next, isPrevRandom, isNextRandom } = await getAdjacentPosts(post.slug, locale);
|
||||||
|
|
||||||
|
// Convert Lexical content into a plain string to estimate reading time roughly
|
||||||
|
// Extract headings for TOC
|
||||||
|
const headings = extractLexicalHeadings(post.content?.root || post.content);
|
||||||
|
|
||||||
// Convert Lexical content into a plain string to estimate reading time roughly
|
// Convert Lexical content into a plain string to estimate reading time roughly
|
||||||
const rawTextContent = JSON.stringify(post.content);
|
const rawTextContent = JSON.stringify(post.content);
|
||||||
|
|
||||||
@@ -88,6 +98,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
alt={post.frontmatter.title}
|
alt={post.frontmatter.title}
|
||||||
fill
|
fill
|
||||||
priority
|
priority
|
||||||
|
quality={100}
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
style={{
|
style={{
|
||||||
@@ -113,7 +124,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',
|
||||||
@@ -123,13 +134,13 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
<span>{getReadingTime(rawTextContent)} min read</span>
|
<span>{getReadingTime(rawTextContent)} min read</span>
|
||||||
{(new Date(post.frontmatter.date) > new Date() ||
|
{(new Date(post.frontmatter.date) > new Date() ||
|
||||||
post.frontmatter.public === false) && (
|
post.frontmatter.public === false) && (
|
||||||
<>
|
<>
|
||||||
<span className="w-1 h-1 bg-white/30 rounded-full" />
|
<span className="w-1 h-1 bg-white/30 rounded-full" />
|
||||||
<span className="px-2 py-0.5 border border-white/40 text-white/80 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold">
|
<span className="px-2 py-0.5 border border-white/40 text-white/80 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold">
|
||||||
Draft Preview
|
Draft Preview
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -150,7 +161,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',
|
||||||
@@ -160,13 +171,13 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
<span>{getReadingTime(rawTextContent)} min read</span>
|
<span>{getReadingTime(rawTextContent)} min read</span>
|
||||||
{(new Date(post.frontmatter.date) > new Date() ||
|
{(new Date(post.frontmatter.date) > new Date() ||
|
||||||
post.frontmatter.public === false) && (
|
post.frontmatter.public === false) && (
|
||||||
<>
|
<>
|
||||||
<span className="w-1 h-1 bg-neutral-300 rounded-full" />
|
<span className="w-1 h-1 bg-neutral-300 rounded-full" />
|
||||||
<span className="px-2 py-0.5 border border-orange-500/50 text-orange-600 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold">
|
<span className="px-2 py-0.5 border border-orange-500/50 text-orange-600 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold">
|
||||||
Draft Preview
|
Draft Preview
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -231,10 +242,10 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Column: Sticky Sidebar - Temporarily Hidden without ToC */}
|
{/* Right Column: Sticky Sidebar - TOC */}
|
||||||
<aside className="sticky-narrative-sidebar hidden lg:block">
|
<aside className="sticky-narrative-sidebar hidden lg:block">
|
||||||
<div className="space-y-12">
|
<div className="space-y-12 lg:sticky lg:top-32">
|
||||||
{/* Future Payload Table of Contents Implementation */}
|
<TableOfContents headings={headings} locale={locale} />
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -51,27 +51,74 @@ const jsxConverters: JSXConverters = {
|
|||||||
heading: ({ node, nodesToJSX }: any) => {
|
heading: ({ node, nodesToJSX }: any) => {
|
||||||
const children = nodesToJSX({ nodes: node.children });
|
const children = nodesToJSX({ nodes: node.children });
|
||||||
const tag = node?.tag;
|
const tag = node?.tag;
|
||||||
|
|
||||||
|
// Extract text to generate an ID for the TOC
|
||||||
|
// Lexical children might contain various nodes; we need a plain text representation
|
||||||
|
const textContent = node.children ? node.children.map((c: any) => c.text || '').join('') : '';
|
||||||
|
const id = textContent
|
||||||
|
? textContent
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/ä/g, 'ae')
|
||||||
|
.replace(/ö/g, 'oe')
|
||||||
|
.replace(/ü/g, 'ue')
|
||||||
|
.replace(/ß/g, 'ss')
|
||||||
|
.replace(/[*_`]/g, '')
|
||||||
|
.replace(/[^\w\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
: undefined;
|
||||||
|
|
||||||
if (tag === 'h1')
|
if (tag === 'h1')
|
||||||
return (
|
return (
|
||||||
<h2 className="text-3xl md:text-4xl font-bold mt-12 mb-6 text-text-primary">{children}</h2>
|
<h2
|
||||||
|
id={id}
|
||||||
|
className="text-3xl md:text-4xl font-bold mt-12 mb-6 text-text-primary scroll-mt-24"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</h2>
|
||||||
);
|
);
|
||||||
if (tag === 'h2')
|
if (tag === 'h2')
|
||||||
return (
|
return (
|
||||||
<h3 className="text-2xl md:text-3xl font-bold mt-10 mb-5 text-text-primary">{children}</h3>
|
<h3
|
||||||
|
id={id}
|
||||||
|
className="text-2xl md:text-3xl font-bold mt-10 mb-5 text-text-primary scroll-mt-24"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</h3>
|
||||||
);
|
);
|
||||||
if (tag === 'h3')
|
if (tag === 'h3')
|
||||||
return (
|
return (
|
||||||
<h4 className="text-xl md:text-2xl font-bold mt-8 mb-4 text-text-primary">{children}</h4>
|
<h4
|
||||||
|
id={id}
|
||||||
|
className="text-xl md:text-2xl font-bold mt-8 mb-4 text-text-primary scroll-mt-24"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</h4>
|
||||||
);
|
);
|
||||||
if (tag === 'h4')
|
if (tag === 'h4')
|
||||||
return (
|
return (
|
||||||
<h5 className="text-lg md:text-xl font-bold mt-6 mb-4 text-text-primary">{children}</h5>
|
<h5
|
||||||
|
id={id}
|
||||||
|
className="text-lg md:text-xl font-bold mt-6 mb-4 text-text-primary scroll-mt-24"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</h5>
|
||||||
);
|
);
|
||||||
if (tag === 'h5')
|
if (tag === 'h5')
|
||||||
return (
|
return (
|
||||||
<h6 className="text-base md:text-lg font-bold mt-6 mb-4 text-text-primary">{children}</h6>
|
<h6
|
||||||
|
id={id}
|
||||||
|
className="text-base md:text-lg font-bold mt-6 mb-4 text-text-primary scroll-mt-24"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</h6>
|
||||||
);
|
);
|
||||||
return <h6 className="text-base font-bold mt-6 mb-4 text-text-primary">{children}</h6>;
|
return (
|
||||||
|
<h6 id={id} className="text-base font-bold mt-6 mb-4 text-text-primary scroll-mt-24">
|
||||||
|
{children}
|
||||||
|
</h6>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
list: ({ node, nodesToJSX }: any) => {
|
list: ({ node, nodesToJSX }: any) => {
|
||||||
const children = nodesToJSX({ nodes: node.children });
|
const children = nodesToJSX({ nodes: node.children });
|
||||||
@@ -95,18 +142,18 @@ const jsxConverters: JSXConverters = {
|
|||||||
const children = nodesToJSX({ nodes: node.children });
|
const children = nodesToJSX({ nodes: node.children });
|
||||||
if (node?.checked != null) {
|
if (node?.checked != null) {
|
||||||
return (
|
return (
|
||||||
<li className="flex items-center gap-3 mb-2 leading-relaxed">
|
<li className="flex items-start gap-3 mb-2 leading-relaxed">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={node.checked}
|
checked={node.checked}
|
||||||
readOnly
|
readOnly
|
||||||
className="mt-1 w-4 h-4 text-primary focus:ring-primary border-gray-300 rounded"
|
className="mt-1.5 w-4 h-4 text-primary focus:ring-primary border-gray-300 rounded shrink-0"
|
||||||
/>
|
/>
|
||||||
<span>{children}</span>
|
<div className="flex-1">{children}</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <li className="mb-2 leading-relaxed">{children}</li>;
|
return <li className="mb-2 leading-relaxed block">{children}</li>;
|
||||||
},
|
},
|
||||||
quote: ({ node, nodesToJSX }: any) => {
|
quote: ({ node, nodesToJSX }: any) => {
|
||||||
const children = nodesToJSX({ nodes: node.children });
|
const children = nodesToJSX({ nodes: node.children });
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -23,19 +23,27 @@ export default function Hero({ data }: { data?: any }) {
|
|||||||
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-4xl sm:text-5xl md:text-7xl font-extrabold [text-shadow:_-2px_-2px_0_#002b49,_2px_-2px_0_#002b49,_-2px_2px_0_#002b49,_2px_2px_0_#002b49,_-2px_0_0_#002b49,_2px_0_0_#002b49,_0_-2px_0_#002b49,_0_2px_0_#002b49]"
|
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-4xl sm:text-5xl md:text-7xl font-extrabold [text-shadow:_-2px_-2px_0_#002b49,_2px_-2px_0_#002b49,_-2px_2px_0_#002b49,_2px_2px_0_#002b49,_-2px_0_0_#002b49,_2px_0_0_#002b49,_0_-2px_0_#002b49,_0_2px_0_#002b49]"
|
||||||
>
|
>
|
||||||
{data?.title ? (
|
{data?.title ? (
|
||||||
<span
|
<>
|
||||||
dangerouslySetInnerHTML={{
|
{data.title.split(/(<green>.*?<\/green>)/g).map((part: string, i: number) => {
|
||||||
__html: data.title
|
if (part.startsWith('<green>') && part.endsWith('</green>')) {
|
||||||
.replace(
|
const content = part.replace(/<\/?green>/g, '');
|
||||||
/<green>/g,
|
return (
|
||||||
'<span class="relative inline-block"><span class="relative z-10 text-accent italic inline-block">',
|
<span key={i} className="relative inline-block">
|
||||||
)
|
<span className="relative z-10 text-accent italic inline-block">
|
||||||
.replace(
|
{content}
|
||||||
/<\/green>/g,
|
</span>
|
||||||
'</span><div class="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both" style="animation-delay: 500ms;"><Scribble variant="circle" /></div></span>',
|
<div
|
||||||
),
|
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both"
|
||||||
}}
|
style={{ animationDelay: '500ms' }}
|
||||||
/>
|
>
|
||||||
|
<Scribble variant="circle" />
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span key={i}>{part}</span>;
|
||||||
|
})}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
t.rich('title', {
|
t.rich('title', {
|
||||||
green: (chunks) => (
|
green: (chunks) => (
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
39
lib/blog.ts
39
lib/blog.ts
@@ -116,7 +116,7 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostD
|
|||||||
category: doc.category || '',
|
category: doc.category || '',
|
||||||
featuredImage:
|
featuredImage:
|
||||||
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
||||||
? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url
|
? doc.featuredImage.url || doc.featuredImage.sizes?.card?.url
|
||||||
: null,
|
: null,
|
||||||
focalX:
|
focalX:
|
||||||
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
||||||
@@ -162,7 +162,7 @@ export async function getAllPosts(locale: string): Promise<PostData[]> {
|
|||||||
category: doc.category || '',
|
category: doc.category || '',
|
||||||
featuredImage:
|
featuredImage:
|
||||||
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
||||||
? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url
|
? doc.featuredImage.url || doc.featuredImage.sizes?.card?.url
|
||||||
: null,
|
: null,
|
||||||
focalX:
|
focalX:
|
||||||
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
||||||
@@ -286,3 +286,38 @@ export function getHeadings(content: string): { id: string; text: string; level:
|
|||||||
return { id, text: cleanText, level };
|
return { id, text: cleanText, level };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function extractLexicalHeadings(
|
||||||
|
node: any,
|
||||||
|
headings: { id: string; text: string; level: number }[] = [],
|
||||||
|
): { id: string; text: string; level: number }[] {
|
||||||
|
if (!node) return headings;
|
||||||
|
|
||||||
|
if (node.type === 'heading' && node.tag) {
|
||||||
|
const level = parseInt(node.tag.replace('h', ''));
|
||||||
|
const text = getTextContentFromLexical(node);
|
||||||
|
if (text) {
|
||||||
|
headings.push({
|
||||||
|
id: generateHeadingId(text),
|
||||||
|
text,
|
||||||
|
level,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.children && Array.isArray(node.children)) {
|
||||||
|
node.children.forEach((child: any) => extractLexicalHeadings(child, headings));
|
||||||
|
}
|
||||||
|
|
||||||
|
return headings;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTextContentFromLexical(node: any): string {
|
||||||
|
if (node.type === 'text') {
|
||||||
|
return node.text || '';
|
||||||
|
}
|
||||||
|
if (node.children && Array.isArray(node.children)) {
|
||||||
|
return node.children.map(getTextContentFromLexical).join('');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -139,7 +139,7 @@
|
|||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"preinstall": "npx only-allow pnpm"
|
"preinstall": "npx only-allow pnpm"
|
||||||
},
|
},
|
||||||
"version": "2.0.2",
|
"version": "2.2.9",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"@parcel/watcher",
|
"@parcel/watcher",
|
||||||
|
|||||||
@@ -66,17 +66,36 @@ async function main() {
|
|||||||
|
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
|
|
||||||
// 3. Inject Gatekeeper session bypassing auth screens
|
page.on('console', (msg) => console.log('💻 BROWSER CONSOLE:', msg.text()));
|
||||||
console.log(`\n🛡️ Injecting Gatekeeper Session...`);
|
page.on('pageerror', (error) => console.error('💻 BROWSER ERROR:', error.message));
|
||||||
await page.setCookie({
|
page.on('requestfailed', (request) => {
|
||||||
name: 'klz_gatekeeper_session',
|
console.error('💻 BROWSER REQUEST FAILED:', request.url(), request.failure()?.errorText);
|
||||||
value: gatekeeperPassword,
|
|
||||||
domain: new URL(targetUrl).hostname,
|
|
||||||
path: '/',
|
|
||||||
httpOnly: true,
|
|
||||||
secure: targetUrl.startsWith('https://'),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 3. Authenticate through Gatekeeper login form
|
||||||
|
console.log(`\n🛡️ Authenticating through Gatekeeper...`);
|
||||||
|
try {
|
||||||
|
// Navigate to a protected page so Gatekeeper redirects us to the login screen
|
||||||
|
await page.goto(contactUrl, { waitUntil: 'networkidle0', timeout: 30000 });
|
||||||
|
|
||||||
|
// Check if we landed on the Gatekeeper login page
|
||||||
|
const isGatekeeperPage = await page.$('input[name="password"]');
|
||||||
|
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;
|
||||||
|
|
||||||
// 4. Test Contact Form
|
// 4. Test Contact Form
|
||||||
@@ -96,6 +115,9 @@ async function main() {
|
|||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wait specifically for hydration logic to initialize the onSubmit handler
|
||||||
|
await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 2000)));
|
||||||
|
|
||||||
// Fill form fields
|
// Fill form fields
|
||||||
await page.type('input[name="name"]', 'Automated E2E Test');
|
await page.type('input[name="name"]', 'Automated E2E Test');
|
||||||
await page.type('input[name="email"]', 'testing@mintel.me');
|
await page.type('input[name="email"]', 'testing@mintel.me');
|
||||||
@@ -104,14 +126,24 @@ async function main() {
|
|||||||
'This is an automated test verifying the contact form submission.',
|
'This is an automated test verifying the contact form submission.',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Give state a moment to settle
|
||||||
|
await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 500)));
|
||||||
|
|
||||||
console.log(` Submitting Contact Form...`);
|
console.log(` Submitting Contact Form...`);
|
||||||
|
|
||||||
// Explicitly click submit and wait for navigation/state-change
|
// Explicitly click submit and wait for navigation/state-change
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
page.waitForSelector('[role="alert"][aria-live="polite"]', { timeout: 15000 }),
|
page.waitForSelector('[role="alert"]', { timeout: 15000 }),
|
||||||
page.click('button[type="submit"]'),
|
page.click('button[type="submit"]'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const alertText = await page.$eval('[role="alert"]', (el) => el.textContent);
|
||||||
|
console.log(` Alert text: ${alertText}`);
|
||||||
|
|
||||||
|
if (alertText?.includes('Failed') || alertText?.includes('went wrong')) {
|
||||||
|
throw new Error(`Form submitted but showed error: ${alertText}`);
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`✅ Contact Form submitted successfully! (Success state verified)`);
|
console.log(`✅ Contact Form submitted successfully! (Success state verified)`);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(`❌ Contact Form Test Failed: ${err.message}`);
|
console.error(`❌ Contact Form Test Failed: ${err.message}`);
|
||||||
@@ -134,6 +166,9 @@ async function main() {
|
|||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wait specifically for hydration logic to initialize the onSubmit handler
|
||||||
|
await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 2000)));
|
||||||
|
|
||||||
// In RequestQuoteForm, the email input is type="email" and message is a textarea.
|
// In RequestQuoteForm, the email input is type="email" and message is a textarea.
|
||||||
await page.type('form input[type="email"]', 'testing@mintel.me');
|
await page.type('form input[type="email"]', 'testing@mintel.me');
|
||||||
await page.type(
|
await page.type(
|
||||||
@@ -141,23 +176,71 @@ async function main() {
|
|||||||
'Automated request for product quote via E2E testing framework.',
|
'Automated request for product quote via E2E testing framework.',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Give state a moment to settle
|
||||||
|
await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 500)));
|
||||||
|
|
||||||
console.log(` Submitting Product Quote Form...`);
|
console.log(` Submitting Product Quote Form...`);
|
||||||
|
|
||||||
// Submit and wait for success state
|
// Submit and wait for success state
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
page.waitForSelector('[role="alert"][aria-live="polite"]', { timeout: 15000 }),
|
page.waitForSelector('[role="alert"]', { timeout: 15000 }),
|
||||||
page.click('form button[type="submit"]'),
|
page.click('form button[type="submit"]'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const alertText = await page.$eval('[role="alert"]', (el) => el.textContent);
|
||||||
|
console.log(` Alert text: ${alertText}`);
|
||||||
|
|
||||||
|
if (alertText?.includes('Failed') || alertText?.includes('went wrong')) {
|
||||||
|
throw new Error(`Form submitted but showed error: ${alertText}`);
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`✅ Product Quote Form submitted successfully! (Success state verified)`);
|
console.log(`✅ Product Quote Form submitted successfully! (Success state verified)`);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(`❌ Product Quote Form Test Failed: ${err.message}`);
|
console.error(`❌ Product Quote Form Test Failed: ${err.message}`);
|
||||||
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) {
|
||||||
|
// Log but don't fail, 403s on Directus / Payload APIs for guest Gatekeeper sessions are normal
|
||||||
|
console.warn(
|
||||||
|
` ⚠️ Cleanup attempt on ${doc.id} returned an error, typically due to API Auth separation: ${delErr.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.response?.status === 403) {
|
||||||
|
console.warn(
|
||||||
|
` ⚠️ Cleanup fetch failed with 403 Forbidden. This is expected if the runner lacks admin API credentials. Test submissions remain in the database.`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error(` ❌ Cleanup fetch 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);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, beforeAll } from 'vitest';
|
import { describe, it, expect, beforeAll } from 'vitest';
|
||||||
|
|
||||||
const BASE_URL = process.env.TEST_URL || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
const BASE_URL =
|
||||||
|
process.env.TEST_URL || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
describe('OG Image Generation', () => {
|
describe('OG Image Generation', () => {
|
||||||
const locales = ['de', 'en'];
|
const locales = ['de', 'en'];
|
||||||
@@ -18,7 +19,9 @@ describe('OG Image Generation', () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log(`\n⚠️ KLZ Application not detected at ${BASE_URL}. Skipping integration tests.\n`);
|
console.log(
|
||||||
|
`\n⚠️ KLZ Application not detected at ${BASE_URL}. Skipping integration tests.\n`,
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
isServerUp = false;
|
isServerUp = false;
|
||||||
}
|
}
|
||||||
@@ -34,7 +37,7 @@ describe('OG Image Generation', () => {
|
|||||||
// Check for PNG signature: 89 50 4E 47 0D 0A 1A 0A
|
// Check for PNG signature: 89 50 4E 47 0D 0A 1A 0A
|
||||||
expect(bytes[0]).toBe(0x89);
|
expect(bytes[0]).toBe(0x89);
|
||||||
expect(bytes[1]).toBe(0x50);
|
expect(bytes[1]).toBe(0x50);
|
||||||
expect(bytes[2]).toBe(0x4E);
|
expect(bytes[2]).toBe(0x4e);
|
||||||
expect(bytes[3]).toBe(0x47);
|
expect(bytes[3]).toBe(0x47);
|
||||||
|
|
||||||
// Check that the image is not empty and has a reasonable size
|
// Check that the image is not empty and has a reasonable size
|
||||||
@@ -49,7 +52,9 @@ describe('OG Image Generation', () => {
|
|||||||
await verifyImageResponse(response);
|
await verifyImageResponse(response);
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
it(`should generate product OG image for ${locale} with slug ${productSlugs[0]}`, async ({ skip }) => {
|
it(`should generate product OG image for ${locale} with slug ${productSlugs[0]}`, async ({
|
||||||
|
skip,
|
||||||
|
}) => {
|
||||||
if (!isServerUp) skip();
|
if (!isServerUp) skip();
|
||||||
const url = `${BASE_URL}/${locale}/api/og/product?slug=${productSlugs[0]}`;
|
const url = `${BASE_URL}/${locale}/api/og/product?slug=${productSlugs[0]}`;
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
@@ -64,11 +69,26 @@ describe('OG Image Generation', () => {
|
|||||||
}, 30000);
|
}, 30000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should generate blog OG image', async ({ skip }) => {
|
it('should generate static blog overview OG image', async ({ skip }) => {
|
||||||
if (!isServerUp) skip();
|
if (!isServerUp) skip();
|
||||||
const url = `${BASE_URL}/de/blog/opengraph-image`;
|
const url = `${BASE_URL}/de/blog/opengraph-image`;
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
await verifyImageResponse(response);
|
await verifyImageResponse(response);
|
||||||
}, 30000);
|
}, 30000);
|
||||||
});
|
|
||||||
|
|
||||||
|
it('should generate dynamic blog post OG image', async ({ skip }) => {
|
||||||
|
if (!isServerUp) skip();
|
||||||
|
// Assuming 'hello-world' or a newly created post slug.
|
||||||
|
// If it 404s, it still tests the routing, though 200 is expected for an actual post.
|
||||||
|
const url = `${BASE_URL}/de/blog/hello-world/opengraph-image`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
// Even if the post "hello-world" doesn't exist and returns 404 in some environments,
|
||||||
|
// we should at least check it doesn't 500. We'll accept 200 or 404 as valid "working" states
|
||||||
|
// vs a 500 compilation/satori error.
|
||||||
|
expect([200, 404]).toContain(response.status);
|
||||||
|
|
||||||
|
if (response.status === 200) {
|
||||||
|
await verifyImageResponse(response);
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user