Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aa299b285d | |||
| 2be0d1ea92 | |||
| 0554460153 | |||
| 4ae45b1aa7 | |||
| 76cbdaa37e | |||
| 5b1a2eff7f | |||
| 60422da644 | |||
| fc6da4e9aa | |||
| f7411d3dc4 | |||
| 7be2ec605c | |||
| bd00175912 | |||
| 9c9663eb88 | |||
| 402b5c30c6 | |||
| 2bb0381de4 | |||
| 614b0a5ffd | |||
| b1569a1ce2 | |||
| 2c35c6b8aa | |||
| bff150b1a7 | |||
| 8ffb1e5f6a | |||
| 42ff5b6226 | |||
| 539c503d0d | |||
| 4c473a2ffd | |||
| 4f64a94a15 | |||
| 5a82243965 | |||
| ebb14ee302 | |||
| 80f5e46d88 | |||
| b826b5b741 | |||
| 41228aca53 | |||
| cd3b7c1e3b | |||
| ecd525d782 | |||
| 52439cc5aa | |||
| 61bf8517d8 | |||
| 85d9e3a930 | |||
| 2f7d6befe4 | |||
| a6ba484faa | |||
| d7178a9100 | |||
| 1e39dc8567 | |||
| 3c2aa8b71e | |||
| 6e3befdfd9 | |||
| 5377b3c81a | |||
| 941625a4e0 |
@@ -256,6 +256,10 @@ jobs:
|
|||||||
# Analytics
|
# Analytics
|
||||||
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
||||||
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||||
|
|
||||||
|
# Notifications
|
||||||
|
GOTIFY_URL: ${{ secrets.GOTIFY_URL }}
|
||||||
|
GOTIFY_TOKEN: ${{ secrets.GOTIFY_TOKEN }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -309,6 +313,10 @@ jobs:
|
|||||||
echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID"
|
echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID"
|
||||||
echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT"
|
echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT"
|
||||||
echo ""
|
echo ""
|
||||||
|
echo "# Notifications"
|
||||||
|
echo "GOTIFY_URL=$GOTIFY_URL"
|
||||||
|
echo "GOTIFY_TOKEN=$GOTIFY_TOKEN"
|
||||||
|
echo ""
|
||||||
echo "TARGET=$TARGET"
|
echo "TARGET=$TARGET"
|
||||||
echo "SENTRY_ENVIRONMENT=$TARGET"
|
echo "SENTRY_ENVIRONMENT=$TARGET"
|
||||||
echo "PROJECT_NAME=$PROJECT_NAME"
|
echo "PROJECT_NAME=$PROJECT_NAME"
|
||||||
|
|||||||
1
.gitignore
vendored
@@ -28,3 +28,4 @@ backups/
|
|||||||
|
|
||||||
# Local data / backups
|
# Local data / backups
|
||||||
.data/
|
.data/
|
||||||
|
scratch/
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ImageResponse } from 'next/og';
|
import { ImageResponse } from 'next/og';
|
||||||
import { getPageBySlug } from '@/lib/pages';
|
import { getPageBySlug } from '@/lib/pages';
|
||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
import { getOgFonts, OG_IMAGE_SIZE, getOgAssets } from '@/lib/og-helper';
|
||||||
|
|
||||||
export const size = OG_IMAGE_SIZE;
|
export const size = OG_IMAGE_SIZE;
|
||||||
export const contentType = 'image/png';
|
export const contentType = 'image/png';
|
||||||
@@ -20,9 +20,12 @@ export default async function Image({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fonts = await getOgFonts();
|
const fonts = await getOgFonts();
|
||||||
|
const { logo, bgImage } = getOgAssets();
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
<OGImageTemplate
|
<OGImageTemplate
|
||||||
|
image={bgImage}
|
||||||
|
logo={logo}
|
||||||
title={pageData.frontmatter.title}
|
title={pageData.frontmatter.title}
|
||||||
description={pageData.frontmatter.excerpt}
|
description={pageData.frontmatter.excerpt}
|
||||||
label="Information"
|
label="Information"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ImageResponse } from 'next/og';
|
import { ImageResponse } from 'next/og';
|
||||||
import { getPostBySlug } from '@/lib/blog';
|
import { getPostBySlug } from '@/lib/blog';
|
||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
import { getOgFonts, OG_IMAGE_SIZE, getOgAssets } from '@/lib/og-helper';
|
||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
|
|
||||||
export const size = OG_IMAGE_SIZE;
|
export const size = OG_IMAGE_SIZE;
|
||||||
@@ -35,6 +35,7 @@ export default async function Image({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fonts = await getOgFonts();
|
const fonts = await getOgFonts();
|
||||||
|
const { logo, bgImage } = getOgAssets();
|
||||||
|
|
||||||
// We don't have request.url here, but we can assume the domain from SITE_URL or config
|
// We don't have request.url here, but we can assume the domain from SITE_URL or config
|
||||||
// For local images during dev, relative paths in <img> might not work in Satori
|
// For local images during dev, relative paths in <img> might not work in Satori
|
||||||
@@ -55,10 +56,11 @@ export default async function Image({
|
|||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
<OGImageTemplate
|
<OGImageTemplate
|
||||||
|
logo={logo}
|
||||||
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={base64Image || featuredImage}
|
image={base64Image || featuredImage || bgImage}
|
||||||
/>,
|
/>,
|
||||||
{
|
{
|
||||||
...OG_IMAGE_SIZE,
|
...OG_IMAGE_SIZE,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ImageResponse } from 'next/og';
|
import { ImageResponse } from 'next/og';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
import { getOgFonts, OG_IMAGE_SIZE, getOgAssets } from '@/lib/og-helper';
|
||||||
|
|
||||||
export const size = OG_IMAGE_SIZE;
|
export const size = OG_IMAGE_SIZE;
|
||||||
export const contentType = 'image/png';
|
export const contentType = 'image/png';
|
||||||
@@ -11,9 +11,16 @@ export default async function Image({ params }: { params: Promise<{ locale: stri
|
|||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
|
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
|
||||||
const fonts = await getOgFonts();
|
const fonts = await getOgFonts();
|
||||||
|
const { logo, bgImage } = getOgAssets();
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
<OGImageTemplate title={t('title')} description={t('description')} label="Blog" />,
|
<OGImageTemplate
|
||||||
|
image={bgImage}
|
||||||
|
logo={logo}
|
||||||
|
title={t('title')}
|
||||||
|
description={t('description')}
|
||||||
|
label="Blog"
|
||||||
|
/>,
|
||||||
{
|
{
|
||||||
...OG_IMAGE_SIZE,
|
...OG_IMAGE_SIZE,
|
||||||
fonts,
|
fonts,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Image from 'next/image';
|
|||||||
import { getAllPosts } from '@/lib/blog';
|
import { getAllPosts } from '@/lib/blog';
|
||||||
import { Section, Container, Heading, Card, Badge, Button } from '@/components/ui';
|
import { Section, Container, Heading, Card, Badge, Button } from '@/components/ui';
|
||||||
import Reveal from '@/components/Reveal';
|
import Reveal from '@/components/Reveal';
|
||||||
import { Metadata } from 'next';
|
|
||||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
import { BlogPaginationKeyboardObserver } from '@/components/blog/BlogPaginationKeyboardObserver';
|
import { BlogPaginationKeyboardObserver } from '@/components/blog/BlogPaginationKeyboardObserver';
|
||||||
@@ -12,6 +12,7 @@ interface BlogIndexProps {
|
|||||||
params: Promise<{
|
params: Promise<{
|
||||||
locale: string;
|
locale: string;
|
||||||
}>;
|
}>;
|
||||||
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params }: BlogIndexProps) {
|
export async function generateMetadata({ params }: BlogIndexProps) {
|
||||||
@@ -41,8 +42,10 @@ export async function generateMetadata({ params }: BlogIndexProps) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function BlogIndex({ params }: BlogIndexProps) {
|
export default async function BlogIndex({ params, searchParams }: BlogIndexProps) {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
|
const resolvedSearchParams = await searchParams;
|
||||||
|
const categoryParam = resolvedSearchParams.category as string | undefined;
|
||||||
setRequestLocale(locale);
|
setRequestLocale(locale);
|
||||||
const t = await getTranslations('Blog');
|
const t = await getTranslations('Blog');
|
||||||
const posts = await getAllPosts(locale);
|
const posts = await getAllPosts(locale);
|
||||||
@@ -52,8 +55,14 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
|||||||
(a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime(),
|
(a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const featuredPost = sortedPosts[0];
|
const filteredPosts = categoryParam
|
||||||
const remainingPosts = sortedPosts.slice(1);
|
? sortedPosts.filter(
|
||||||
|
(post) => post.frontmatter.category?.toLowerCase() === categoryParam.toLowerCase(),
|
||||||
|
)
|
||||||
|
: sortedPosts;
|
||||||
|
|
||||||
|
const featuredPost = filteredPosts[0];
|
||||||
|
const remainingPosts = filteredPosts.slice(1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-neutral-light min-h-screen">
|
<div className="bg-neutral-light min-h-screen">
|
||||||
@@ -123,31 +132,38 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
|||||||
{t('allArticles')}
|
{t('allArticles')}
|
||||||
</Heading>
|
</Heading>
|
||||||
<div className="flex flex-wrap gap-2 md:gap-4">
|
<div className="flex flex-wrap gap-2 md:gap-4">
|
||||||
{/* Category filters could go here */}
|
<Link href={`/${locale}/blog`}>
|
||||||
<Badge
|
<Badge
|
||||||
variant="primary"
|
variant={!categoryParam ? 'primary' : 'neutral'}
|
||||||
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||||
>
|
>
|
||||||
{t('categories.all')}
|
{t('categories.all')}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
</Link>
|
||||||
|
<Link href={`/${locale}/blog?category=industry`}>
|
||||||
<Badge
|
<Badge
|
||||||
variant="neutral"
|
variant={categoryParam === 'industry' ? 'primary' : 'neutral'}
|
||||||
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||||
>
|
>
|
||||||
{t('categories.industry')}
|
{t('categories.industry')}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
</Link>
|
||||||
|
<Link href={`/${locale}/blog?category=technical`}>
|
||||||
<Badge
|
<Badge
|
||||||
variant="neutral"
|
variant={categoryParam === 'technical' ? 'primary' : 'neutral'}
|
||||||
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||||
>
|
>
|
||||||
{t('categories.technical')}
|
{t('categories.technical')}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
</Link>
|
||||||
|
<Link href={`/${locale}/blog?category=sustainability`}>
|
||||||
<Badge
|
<Badge
|
||||||
variant="neutral"
|
variant={categoryParam === 'sustainability' ? 'primary' : 'neutral'}
|
||||||
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||||
>
|
>
|
||||||
{t('categories.sustainability')}
|
{t('categories.sustainability')}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
@@ -260,7 +276,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
|||||||
{t('prev')}
|
{t('prev')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
href={`/${locale}/blog?page=1`}
|
href={`/${locale}/blog?page=1${categoryParam ? `&category=${categoryParam}` : ''}`}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="md:h-11 md:px-6 md:text-base"
|
className="md:h-11 md:px-6 md:text-base"
|
||||||
@@ -269,7 +285,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
|||||||
1
|
1
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
href={`/${locale}/blog?page=2`}
|
href={`/${locale}/blog?page=2${categoryParam ? `&category=${categoryParam}` : ''}`}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="md:h-11 md:px-6 md:text-base"
|
className="md:h-11 md:px-6 md:text-base"
|
||||||
@@ -277,7 +293,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
|||||||
2
|
2
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
href={`/${locale}/blog?page=2`}
|
href={`/${locale}/blog?page=2${categoryParam ? `&category=${categoryParam}` : ''}`}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="md:h-11 md:px-6 md:text-base"
|
className="md:h-11 md:px-6 md:text-base"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ImageResponse } from 'next/og';
|
import { ImageResponse } from 'next/og';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
import { getOgFonts, OG_IMAGE_SIZE, getOgAssets } from '@/lib/og-helper';
|
||||||
|
|
||||||
export const size = OG_IMAGE_SIZE;
|
export const size = OG_IMAGE_SIZE;
|
||||||
export const contentType = 'image/png';
|
export const contentType = 'image/png';
|
||||||
@@ -11,12 +11,19 @@ export default async function Image({ params }: { params: Promise<{ locale: stri
|
|||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: 'Contact' });
|
const t = await getTranslations({ locale, namespace: 'Contact' });
|
||||||
const fonts = await getOgFonts();
|
const fonts = await getOgFonts();
|
||||||
|
const { logo, bgImage } = getOgAssets();
|
||||||
|
|
||||||
const title = t('meta.title') || t('title');
|
const title = t('meta.title') || t('title');
|
||||||
const description = t('meta.description') || t('subtitle');
|
const description = t('meta.description') || t('subtitle');
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
<OGImageTemplate title={title} description={description} label="Contact" />,
|
<OGImageTemplate
|
||||||
|
image={bgImage}
|
||||||
|
logo={logo}
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
label="Contact"
|
||||||
|
/>,
|
||||||
{
|
{
|
||||||
...OG_IMAGE_SIZE,
|
...OG_IMAGE_SIZE,
|
||||||
fonts,
|
fonts,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ImageResponse } from 'next/og';
|
import { ImageResponse } from 'next/og';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
import { getOgFonts, OG_IMAGE_SIZE, getOgAssets } from '@/lib/og-helper';
|
||||||
|
|
||||||
export const size = OG_IMAGE_SIZE;
|
export const size = OG_IMAGE_SIZE;
|
||||||
export const contentType = 'image/png';
|
export const contentType = 'image/png';
|
||||||
@@ -11,9 +11,12 @@ export default async function Image({ params }: { params: Promise<{ locale: stri
|
|||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: 'Index.meta' });
|
const t = await getTranslations({ locale, namespace: 'Index.meta' });
|
||||||
const fonts = await getOgFonts();
|
const fonts = await getOgFonts();
|
||||||
|
const { logo, bgImage } = getOgAssets();
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
<OGImageTemplate
|
<OGImageTemplate
|
||||||
|
image={bgImage}
|
||||||
|
logo={logo}
|
||||||
title={t('title')}
|
title={t('title')}
|
||||||
description={t('description')}
|
description={t('description')}
|
||||||
label="Reliable Energy Infrastructure"
|
label="Reliable Energy Infrastructure"
|
||||||
|
|||||||
@@ -266,18 +266,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
let technicalItems = [];
|
let technicalItems: any[] = [];
|
||||||
try {
|
|
||||||
const tabMatch =
|
|
||||||
typeof product.content === 'string' &&
|
|
||||||
product.content.match(/<Block type="productTabs" data={({.*?})}\s*\/>/s);
|
|
||||||
if (tabMatch && tabMatch[1]) {
|
|
||||||
const data = JSON.parse(tabMatch[1]);
|
|
||||||
technicalItems = data.technicalItems || [];
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore JSON parse errors for AST
|
|
||||||
}
|
|
||||||
|
|
||||||
const datasheetPath = getDatasheetPath(productSlug, locale);
|
const datasheetPath = getDatasheetPath(productSlug, locale);
|
||||||
const isFallback = (product.frontmatter as any).isFallback;
|
const isFallback = (product.frontmatter as any).isFallback;
|
||||||
@@ -296,8 +285,20 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
if (typeof product.content === 'string') {
|
if (typeof product.content === 'string') {
|
||||||
const tabBlockMatch = product.content.match(/<Block type="productTabs".*?\/>/s);
|
const tabBlockMatch = product.content.match(/<Block type="productTabs".*?\/>/s);
|
||||||
if (tabBlockMatch) {
|
if (tabBlockMatch) {
|
||||||
descriptionContent = product.content.replace(tabBlockMatch[0], '');
|
|
||||||
technicalContent = tabBlockMatch[0];
|
technicalContent = tabBlockMatch[0];
|
||||||
|
descriptionContent = product.content.replace(tabBlockMatch[0], '');
|
||||||
|
|
||||||
|
const dataMatch =
|
||||||
|
technicalContent.match(/data="({.*?})"/s) || technicalContent.match(/data={({.*?})}/s);
|
||||||
|
if (dataMatch && dataMatch[1]) {
|
||||||
|
try {
|
||||||
|
const rawJsonStr = dataMatch[1].replace(/"/g, '"');
|
||||||
|
const data = JSON.parse(rawJsonStr);
|
||||||
|
technicalItems = data.technicalItems || [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse productTabs data for page:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ImageResponse } from 'next/og';
|
import { ImageResponse } from 'next/og';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
import { getOgFonts, OG_IMAGE_SIZE, getOgAssets } from '@/lib/og-helper';
|
||||||
|
|
||||||
export const size = OG_IMAGE_SIZE;
|
export const size = OG_IMAGE_SIZE;
|
||||||
export const contentType = 'image/png';
|
export const contentType = 'image/png';
|
||||||
@@ -11,6 +11,7 @@ export default async function Image({ params }: { params: Promise<{ locale: stri
|
|||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: 'Products' });
|
const t = await getTranslations({ locale, namespace: 'Products' });
|
||||||
const fonts = await getOgFonts();
|
const fonts = await getOgFonts();
|
||||||
|
const { logo, bgImage } = getOgAssets();
|
||||||
|
|
||||||
const title = t.has('meta.title')
|
const title = t.has('meta.title')
|
||||||
? t('meta.title')
|
? t('meta.title')
|
||||||
@@ -20,7 +21,13 @@ export default async function Image({ params }: { params: Promise<{ locale: stri
|
|||||||
const description = t('meta.description') || t('subtitle');
|
const description = t('meta.description') || t('subtitle');
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
<OGImageTemplate title={title} description={description} label="Products" />,
|
<OGImageTemplate
|
||||||
|
image={bgImage}
|
||||||
|
logo={logo}
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
label="Products"
|
||||||
|
/>,
|
||||||
{
|
{
|
||||||
...OG_IMAGE_SIZE,
|
...OG_IMAGE_SIZE,
|
||||||
fonts,
|
fonts,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ImageResponse } from 'next/og';
|
import { ImageResponse } from 'next/og';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
import { getOgFonts, OG_IMAGE_SIZE, getOgAssets } from '@/lib/og-helper';
|
||||||
|
|
||||||
export const size = OG_IMAGE_SIZE;
|
export const size = OG_IMAGE_SIZE;
|
||||||
export const contentType = 'image/png';
|
export const contentType = 'image/png';
|
||||||
@@ -11,12 +11,19 @@ export default async function Image({ params }: { params: Promise<{ locale: stri
|
|||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: 'Team' });
|
const t = await getTranslations({ locale, namespace: 'Team' });
|
||||||
const fonts = await getOgFonts();
|
const fonts = await getOgFonts();
|
||||||
|
const { logo, bgImage } = getOgAssets();
|
||||||
|
|
||||||
const title = t('meta.title') || t('hero.subtitle');
|
const title = t('meta.title') || t('hero.subtitle');
|
||||||
const description = t('meta.description') || t('hero.title');
|
const description = t('meta.description') || t('hero.title');
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
<OGImageTemplate title={title} description={description} label="Our Team" />,
|
<OGImageTemplate
|
||||||
|
image={bgImage}
|
||||||
|
logo={logo}
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
label="Our Team"
|
||||||
|
/>,
|
||||||
{
|
{
|
||||||
...OG_IMAGE_SIZE,
|
...OG_IMAGE_SIZE,
|
||||||
fonts,
|
fonts,
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export async function sendContactFormAction(formData: FormData) {
|
|||||||
productName,
|
productName,
|
||||||
message,
|
message,
|
||||||
};
|
};
|
||||||
fs.appendFileSync(backupFile, JSON.stringify(leadData) + '\\n');
|
fs.appendFileSync(backupFile, JSON.stringify(leadData) + '\n');
|
||||||
logger.info('Successfully saved lead to local backup file', { backupFile });
|
logger.info('Successfully saved lead to local backup file', { backupFile });
|
||||||
} catch (backupError) {
|
} catch (backupError) {
|
||||||
logger.error('Failed to write to local leads backup', { error: String(backupError) });
|
logger.error('Failed to write to local leads backup', { error: String(backupError) });
|
||||||
@@ -156,7 +156,7 @@ export async function sendContactFormAction(formData: FormData) {
|
|||||||
|
|
||||||
// Notify via Gotify (Internal)
|
// Notify via Gotify (Internal)
|
||||||
await services.notifications.notify({
|
await services.notifications.notify({
|
||||||
title: `📩 ${notificationSubject}`,
|
title: `📩 [KLZ] ${notificationSubject}`,
|
||||||
message: `New message from ${name} (${email}):\n\n${message}`,
|
message: `New message from ${name} (${email}):\n\n${message}`,
|
||||||
priority: 5,
|
priority: 5,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import VideoSection from './home/VideoSection';
|
|||||||
import CTA from './home/CTA';
|
import CTA from './home/CTA';
|
||||||
import { AgbHistoryBlock } from './AgbHistoryBlock';
|
import { AgbHistoryBlock } from './AgbHistoryBlock';
|
||||||
import { PDFDownloadBlock } from './PDFDownloadBlock';
|
import { PDFDownloadBlock } from './PDFDownloadBlock';
|
||||||
|
import ProductTechnicalData from './ProductTechnicalData';
|
||||||
function ContactSection(props: any) {
|
function ContactSection(props: any) {
|
||||||
return (
|
return (
|
||||||
<div className="p-8 border-2 border-dashed border-primary my-8 text-center text-primary font-bold">
|
<div className="p-8 border-2 border-dashed border-primary my-8 text-center text-primary font-bold">
|
||||||
@@ -100,6 +101,8 @@ async function Block(props: any) {
|
|||||||
return <AgbHistoryBlock {...data} />;
|
return <AgbHistoryBlock {...data} />;
|
||||||
case 'pdfDownload':
|
case 'pdfDownload':
|
||||||
return <PDFDownloadBlock {...data} />;
|
return <PDFDownloadBlock {...data} />;
|
||||||
|
case 'productTabs':
|
||||||
|
return <ProductTechnicalData data={data} />;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ interface OGImageTemplateProps {
|
|||||||
description?: string;
|
description?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
image?: string;
|
image?: string;
|
||||||
|
logo?: string;
|
||||||
mode?: 'dark' | 'light' | 'image';
|
mode?: 'dark' | 'light' | 'image';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ export function OGImageTemplate({
|
|||||||
description,
|
description,
|
||||||
label,
|
label,
|
||||||
image,
|
image,
|
||||||
|
logo,
|
||||||
mode = 'dark',
|
mode = 'dark',
|
||||||
}: OGImageTemplateProps) {
|
}: OGImageTemplateProps) {
|
||||||
const primaryBlue = '#001a4d';
|
const primaryBlue = '#001a4d';
|
||||||
@@ -152,9 +154,13 @@ export function OGImageTemplate({
|
|||||||
height: '6px',
|
height: '6px',
|
||||||
backgroundColor: accentGreen,
|
backgroundColor: accentGreen,
|
||||||
borderRadius: '3px',
|
borderRadius: '3px',
|
||||||
marginRight: '24px',
|
marginRight: '32px',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{logo ? (
|
||||||
|
/* eslint-disable-next-line @next/next/no-img-element */
|
||||||
|
<img src={logo} alt="KLZ Cables" height="40" style={{ objectFit: 'contain' }} />
|
||||||
|
) : (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: '24px',
|
fontSize: '24px',
|
||||||
@@ -167,6 +173,7 @@ export function OGImageTemplate({
|
|||||||
>
|
>
|
||||||
KLZ Cables
|
KLZ Cables
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Saturated Blue Brand Strip */}
|
{/* Saturated Blue Brand Strip */}
|
||||||
|
|||||||
@@ -3,7 +3,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
export const PDFDownloadBlock: React.FC<{ label: string; style: string }> = ({ label, style }) => {
|
export const PDFDownloadBlock: React.FC<{ label: string; style: string; url?: string }> = ({
|
||||||
|
label,
|
||||||
|
style,
|
||||||
|
url,
|
||||||
|
}) => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
// Extract slug from pathname
|
// Extract slug from pathname
|
||||||
@@ -12,7 +16,7 @@ export const PDFDownloadBlock: React.FC<{ label: string; style: string }> = ({ l
|
|||||||
// We want the page slug.
|
// We want the page slug.
|
||||||
const slug = segments[segments.length - 1] || 'home';
|
const slug = segments[segments.length - 1] || 'home';
|
||||||
|
|
||||||
const href = `/api/pages/${slug}/pdf`;
|
const href = url || `/api/pages/${slug}/pdf`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="my-8">
|
<div className="my-8">
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ _status: published
|
|||||||
|
|
||||||
Stand: April 2026
|
Stand: April 2026
|
||||||
|
|
||||||
<Block type="pdfDownload" data={{"id":"69a9b09ea220fecfb9d54bb8","label":"Als PDF herunterladen","style":"primary","blockName":"","blockType":"pdfDownload"}} />
|
<Block type="pdfDownload" data={{"id":"69a9b09ea220fecfb9d54bb8","label":"Als PDF herunterladen","style":"primary","url":"/AVB-KLZ-4-2026.pdf","blockName":"","blockType":"pdfDownload"}} />
|
||||||
|
|
||||||
<Block type="agbHistory" data={{"id":"69a9b09ea220fecfb9d54bb9","blockName":"AGB History","blockType":"agbHistory","title":"Vorherige Versionen"}} />
|
<Block type="agbHistory" data={{"id":"69a9b09ea220fecfb9d54bb9","blockName":"AGB History","blockType":"agbHistory","title":"Vorherige Versionen"}} />
|
||||||
|
|
||||||
|
|||||||
@@ -65,11 +65,8 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostD
|
|||||||
const fileContent = await fs.readFile(filePath, 'utf-8');
|
const fileContent = await fs.readFile(filePath, 'utf-8');
|
||||||
const { data, content } = matter(fileContent);
|
const { data, content } = matter(fileContent);
|
||||||
|
|
||||||
// Fix MDX data props dropped by next-mdx-remote
|
const { fixMdxDataProps } = await import('./mdx-utils');
|
||||||
// Payload serializes as data={{"items":...}} which Acorn treats as a Block statement.
|
const fixedContent = fixMdxDataProps(content);
|
||||||
const fixedContent = content.replace(/data=\{\{([\s\S]*?)\}\}/g, (match, p1) => {
|
|
||||||
return 'data="{' + p1.replace(/"/g, '"') + '}"';
|
|
||||||
});
|
|
||||||
|
|
||||||
let parsedContent = fixedContent;
|
let parsedContent = fixedContent;
|
||||||
try {
|
try {
|
||||||
|
|||||||
146
lib/mdx-utils.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
export function fixMdxDataProps(content: string): string {
|
||||||
|
let fixedContent = content;
|
||||||
|
let dataIndex = fixedContent.indexOf('data={{');
|
||||||
|
|
||||||
|
while (dataIndex !== -1) {
|
||||||
|
let openCount = 0;
|
||||||
|
let endIndex = -1;
|
||||||
|
const startObj = dataIndex + 6; // index of the first '{' in 'data={{'
|
||||||
|
|
||||||
|
let inString = false;
|
||||||
|
let isEscaped = false;
|
||||||
|
|
||||||
|
for (let i = startObj; i < fixedContent.length; i++) {
|
||||||
|
const char = fixedContent[i];
|
||||||
|
|
||||||
|
if (isEscaped) {
|
||||||
|
isEscaped = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === '\\') {
|
||||||
|
isEscaped = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === '"') {
|
||||||
|
inString = !inString;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inString) {
|
||||||
|
if (char === '{') {
|
||||||
|
openCount++;
|
||||||
|
} else if (char === '}') {
|
||||||
|
openCount--;
|
||||||
|
if (openCount === 0) {
|
||||||
|
endIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endIndex !== -1) {
|
||||||
|
// jsonStr is the content INSIDE the outer curly braces
|
||||||
|
const jsonStr = fixedContent.substring(startObj + 1, endIndex);
|
||||||
|
const safeJsonStr = jsonStr.replace(/"/g, '"');
|
||||||
|
const replacement = `data="{${safeJsonStr}}"`;
|
||||||
|
|
||||||
|
// We also need to consume the closing `}` of the `data={{`
|
||||||
|
const nextCharIndex = endIndex + 1;
|
||||||
|
const skipChars =
|
||||||
|
nextCharIndex < fixedContent.length && fixedContent[nextCharIndex] === '}' ? 2 : 1;
|
||||||
|
|
||||||
|
fixedContent =
|
||||||
|
fixedContent.substring(0, dataIndex) +
|
||||||
|
replacement +
|
||||||
|
fixedContent.substring(endIndex + skipChars);
|
||||||
|
|
||||||
|
dataIndex = fixedContent.indexOf('data={{', dataIndex + replacement.length);
|
||||||
|
} else {
|
||||||
|
// Malformed braces, prevent infinite loop
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fixedContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lexicalToMarkdown(node: any, depth = 0): string {
|
||||||
|
if (!node) return '';
|
||||||
|
|
||||||
|
if (typeof node === 'string') return node;
|
||||||
|
|
||||||
|
if (node.type === 'text') {
|
||||||
|
let text = node.text || '';
|
||||||
|
if (node.format === 1) text = `**${text}**`; // Bold
|
||||||
|
if (node.format === 2) text = `*${text}*`; // Italic
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'heading') {
|
||||||
|
const level = parseInt((node.tag || 'h1').replace('h', '')) || 1;
|
||||||
|
const prefix = '#'.repeat(level);
|
||||||
|
const text = (node.children || []).map((c: any) => lexicalToMarkdown(c, depth)).join('');
|
||||||
|
return `${prefix} ${text}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'paragraph') {
|
||||||
|
const text = (node.children || []).map((c: any) => lexicalToMarkdown(c, depth)).join('');
|
||||||
|
return text.trim() ? `${text}\n\n` : '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'list') {
|
||||||
|
const isOrdered = node.tag === 'ol';
|
||||||
|
const items = (node.children || [])
|
||||||
|
.map((c: any, index: number) => {
|
||||||
|
const prefix = isOrdered ? `${index + 1}.` : '-';
|
||||||
|
return `${prefix} ${lexicalToMarkdown(c, depth + 1).trim()}`;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
return `${items}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'listitem') {
|
||||||
|
return (node.children || []).map((c: any) => lexicalToMarkdown(c, depth)).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'quote') {
|
||||||
|
const text = (node.children || []).map((c: any) => lexicalToMarkdown(c, depth)).join('');
|
||||||
|
return `> ${text}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'link') {
|
||||||
|
const url = node.fields?.url || node.url || '';
|
||||||
|
const text = (node.children || []).map((c: any) => lexicalToMarkdown(c, depth)).join('');
|
||||||
|
return `[${text}](${url})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'upload') {
|
||||||
|
const url = node.value?.url || '';
|
||||||
|
const alt = node.value?.alt || node.value?.filename || '';
|
||||||
|
return `\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'block') {
|
||||||
|
const blockType = node.fields?.blockType;
|
||||||
|
if (blockType === 'contactSection') {
|
||||||
|
return `<ContactSection showMap={${!!node.fields?.showMap}} showForm={${!!node.fields?.showForm}} showHours={${!!node.fields?.showHours}} />\n\n`;
|
||||||
|
}
|
||||||
|
if (blockType === 'heroSection') {
|
||||||
|
return `<HeroSection badge="${node.fields?.badge || ''}" title="${node.fields?.title || ''}" subtitle="${node.fields?.subtitle || ''}" alignment="${node.fields?.alignment || 'left'}" />\n\n`;
|
||||||
|
}
|
||||||
|
if (blockType === 'features') {
|
||||||
|
return `<FeaturesSection layout="${node.fields?.layout || ''}" />\n\n`;
|
||||||
|
}
|
||||||
|
// Generic MDX block wrapper if unknown
|
||||||
|
return `<Block type="${blockType}" data={${JSON.stringify(node.fields)}} />\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.children && Array.isArray(node.children)) {
|
||||||
|
return node.children.map((c: any) => lexicalToMarkdown(c, depth)).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
@@ -55,3 +55,36 @@ export const OG_IMAGE_SIZE = {
|
|||||||
width: 1200,
|
width: 1200,
|
||||||
height: 630,
|
height: 630,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads a local image and converts it to a base64 data URI for Satori
|
||||||
|
*/
|
||||||
|
export function getLocalImageBase64(filename: string): string | undefined {
|
||||||
|
try {
|
||||||
|
const filePath = join(process.cwd(), 'public', filename);
|
||||||
|
const buffer = readFileSync(filePath);
|
||||||
|
const ext = filename.split('.').pop()?.toLowerCase();
|
||||||
|
const mimeType =
|
||||||
|
ext === 'png'
|
||||||
|
? 'image/png'
|
||||||
|
: ext === 'jpg' || ext === 'jpeg'
|
||||||
|
? 'image/jpeg'
|
||||||
|
: 'image/svg+xml';
|
||||||
|
return `data:${mimeType};base64,${buffer.toString('base64')}`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[OG] Failed to load local image ${filename}:`, error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns standard assets for OG images
|
||||||
|
*/
|
||||||
|
export function getOgAssets() {
|
||||||
|
return {
|
||||||
|
logo: getLocalImageBase64('logo-white.png'),
|
||||||
|
bgImage: getLocalImageBase64(
|
||||||
|
'media/aerial-view-of-electricity-station-surrounded-with-2023-11-27-05-33-40-utc-scaled.jpg',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,11 +53,8 @@ export async function getPageBySlug(slug: string, locale: string): Promise<PageD
|
|||||||
const fileContent = await fs.readFile(filePath, 'utf-8');
|
const fileContent = await fs.readFile(filePath, 'utf-8');
|
||||||
const { data, content } = matter(fileContent);
|
const { data, content } = matter(fileContent);
|
||||||
|
|
||||||
// Fix MDX data props dropped by next-mdx-remote
|
const { fixMdxDataProps } = await import('./mdx-utils');
|
||||||
// Payload serializes as data={{"items":...}} which Acorn treats as a Block statement.
|
const fixedContent = fixMdxDataProps(content);
|
||||||
const fixedContent = content.replace(/data=\{\{([\s\S]*?)\}\}/g, (match, p1) => {
|
|
||||||
return 'data="{' + p1.replace(/"/g, '"') + '}"';
|
|
||||||
});
|
|
||||||
|
|
||||||
let parsedContent = fixedContent;
|
let parsedContent = fixedContent;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -215,8 +215,8 @@ const styles = StyleSheet.create({
|
|||||||
bottom: 0,
|
bottom: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
height: '20mm', // Generous area for the footer
|
height: '22mm', // Increased height to accommodate more bottom padding
|
||||||
paddingBottom: '7mm', // 3mm bleed + 4mm safe margin
|
paddingBottom: '10mm', // Increased bottom padding to move icons further from edge
|
||||||
zIndex: 50,
|
zIndex: 50,
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
@@ -253,7 +253,7 @@ const styles = StyleSheet.create({
|
|||||||
// -- WHITE PAGE (Vorderseite) --
|
// -- WHITE PAGE (Vorderseite) --
|
||||||
backContainer: {
|
backContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
paddingTop: '3mm', // Reduced significantly to pull content up
|
paddingTop: '6mm', // Pushed down as requested
|
||||||
paddingLeft: '6mm',
|
paddingLeft: '6mm',
|
||||||
paddingRight: '6mm',
|
paddingRight: '6mm',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
@@ -302,12 +302,12 @@ const styles = StyleSheet.create({
|
|||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
},
|
},
|
||||||
nameSection: {
|
nameSection: {
|
||||||
marginBottom: '2.5mm', // Clear separation between name block and contact block
|
marginBottom: '2.5mm', // Reduced to fix the 'Lücke' under the role
|
||||||
},
|
},
|
||||||
nameText: {
|
nameText: {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
color: COLORS.neutralDark,
|
color: COLORS.primary,
|
||||||
marginBottom: 1.5,
|
marginBottom: 1.5,
|
||||||
letterSpacing: -0.15,
|
letterSpacing: -0.15,
|
||||||
},
|
},
|
||||||
@@ -334,7 +334,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
contactText: {
|
contactText: {
|
||||||
fontSize: 7.5,
|
fontSize: 7.5,
|
||||||
color: COLORS.neutralDark,
|
color: COLORS.primary,
|
||||||
fontWeight: 'normal',
|
fontWeight: 'normal',
|
||||||
paddingTop: 0.5,
|
paddingTop: 0.5,
|
||||||
letterSpacing: 0.1,
|
letterSpacing: 0.1,
|
||||||
@@ -351,10 +351,10 @@ const styles = StyleSheet.create({
|
|||||||
gap: 3,
|
gap: 3,
|
||||||
},
|
},
|
||||||
qrCodeWrapper: {
|
qrCodeWrapper: {
|
||||||
width: '12mm',
|
width: '10mm',
|
||||||
height: '12mm',
|
height: '10mm',
|
||||||
border: `0.75pt solid ${COLORS.darkGreen}`,
|
border: `0.75pt solid ${COLORS.darkGreen}`,
|
||||||
padding: 2.5,
|
padding: 2,
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
},
|
},
|
||||||
qrCode: {
|
qrCode: {
|
||||||
@@ -372,7 +372,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
addressFooterText: {
|
addressFooterText: {
|
||||||
fontSize: 6.5,
|
fontSize: 6.5,
|
||||||
color: COLORS.grayText,
|
color: COLORS.primary,
|
||||||
letterSpacing: 0.4,
|
letterSpacing: 0.4,
|
||||||
fontWeight: 'normal',
|
fontWeight: 'normal',
|
||||||
},
|
},
|
||||||
@@ -383,7 +383,7 @@ const PhoneIcon = () => (
|
|||||||
<Svg viewBox="0 0 24 24" width={9} height={9}>
|
<Svg viewBox="0 0 24 24" width={9} height={9}>
|
||||||
<Path
|
<Path
|
||||||
d="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z"
|
d="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z"
|
||||||
fill="#000000"
|
fill={COLORS.primary}
|
||||||
/>
|
/>
|
||||||
</Svg>
|
</Svg>
|
||||||
);
|
);
|
||||||
@@ -392,7 +392,7 @@ const EmailIcon = () => (
|
|||||||
<Svg viewBox="0 0 24 24" width={9} height={9}>
|
<Svg viewBox="0 0 24 24" width={9} height={9}>
|
||||||
<Path
|
<Path
|
||||||
d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"
|
d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"
|
||||||
fill="#000000"
|
fill={COLORS.primary}
|
||||||
/>
|
/>
|
||||||
</Svg>
|
</Svg>
|
||||||
);
|
);
|
||||||
@@ -401,7 +401,7 @@ const GlobeIcon = () => (
|
|||||||
<Svg viewBox="0 0 24 24" width={9} height={9}>
|
<Svg viewBox="0 0 24 24" width={9} height={9}>
|
||||||
<Path
|
<Path
|
||||||
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"
|
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"
|
||||||
fill="#000000"
|
fill={COLORS.primary}
|
||||||
/>
|
/>
|
||||||
</Svg>
|
</Svg>
|
||||||
);
|
);
|
||||||
@@ -410,7 +410,7 @@ const LocationIcon = () => (
|
|||||||
<Svg viewBox="0 0 24 24" width={9} height={9}>
|
<Svg viewBox="0 0 24 24" width={9} height={9}>
|
||||||
<Path
|
<Path
|
||||||
d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"
|
d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"
|
||||||
fill="#000000"
|
fill={COLORS.primary}
|
||||||
/>
|
/>
|
||||||
</Svg>
|
</Svg>
|
||||||
);
|
);
|
||||||
@@ -419,7 +419,7 @@ const ScanIcon = () => (
|
|||||||
<Svg viewBox="0 0 24 24" width={9} height={9}>
|
<Svg viewBox="0 0 24 24" width={9} height={9}>
|
||||||
<Path
|
<Path
|
||||||
d="M3 3h6v2H5v4H3V3zm18 0h-6v2h4v4h2V3zM3 21h6v-2H5v-4H3v6zm18 0h-6v-2h4v-4h2v6zM7 7h10v10H7V7z"
|
d="M3 3h6v2H5v4H3V3zm18 0h-6v2h4v4h2V3zM3 21h6v-2H5v-4H3v6zm18 0h-6v-2h4v-4h2v6zM7 7h10v10H7V7z"
|
||||||
fill="#000000"
|
fill={COLORS.primary}
|
||||||
/>
|
/>
|
||||||
</Svg>
|
</Svg>
|
||||||
);
|
);
|
||||||
@@ -455,6 +455,7 @@ const VectorLogo = ({
|
|||||||
style,
|
style,
|
||||||
overrideColor,
|
overrideColor,
|
||||||
overrideWhiteColor,
|
overrideWhiteColor,
|
||||||
|
overrideBlackColor,
|
||||||
pathOverrides,
|
pathOverrides,
|
||||||
solidPaths,
|
solidPaths,
|
||||||
solidBackgroundColor = '#000a66',
|
solidBackgroundColor = '#000a66',
|
||||||
@@ -463,6 +464,7 @@ const VectorLogo = ({
|
|||||||
style: any;
|
style: any;
|
||||||
overrideColor?: string;
|
overrideColor?: string;
|
||||||
overrideWhiteColor?: string;
|
overrideWhiteColor?: string;
|
||||||
|
overrideBlackColor?: string;
|
||||||
pathOverrides?: Record<number, string>;
|
pathOverrides?: Record<number, string>;
|
||||||
solidPaths?: number[];
|
solidPaths?: number[];
|
||||||
solidBackgroundColor?: string;
|
solidBackgroundColor?: string;
|
||||||
@@ -492,6 +494,11 @@ const VectorLogo = ({
|
|||||||
(p.fill === 'white' || p.fill === '#ffffff' || p.fill === '#fff')
|
(p.fill === 'white' || p.fill === '#ffffff' || p.fill === '#fff')
|
||||||
) {
|
) {
|
||||||
fill = overrideWhiteColor;
|
fill = overrideWhiteColor;
|
||||||
|
} else if (
|
||||||
|
overrideBlackColor &&
|
||||||
|
(p.fill === 'black' || p.fill === '#000000' || p.fill === '#0a0a0a' || p.fill === '#000')
|
||||||
|
) {
|
||||||
|
fill = overrideBlackColor;
|
||||||
}
|
}
|
||||||
return <Path key={i} d={d} fill={fill} />;
|
return <Path key={i} d={d} fill={fill} />;
|
||||||
})}
|
})}
|
||||||
@@ -815,6 +822,8 @@ export const PDFBusinessCard: React.FC<PDFBusinessCardProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getBackLogoPaths = () => {
|
const getBackLogoPaths = () => {
|
||||||
|
// We use the original black logo to preserve the exact colors of the Truck and KLZ.
|
||||||
|
// The tagline will be hidden via pathOverrides in the JSX.
|
||||||
return logoBlackWithTaglinePaths;
|
return logoBlackWithTaglinePaths;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -822,6 +831,8 @@ export const PDFBusinessCard: React.FC<PDFBusinessCardProps> = ({
|
|||||||
? path.join(process.cwd(), 'public', 'media', 'cable_drums_truck.png')
|
? path.join(process.cwd(), 'public', 'media', 'cable_drums_truck.png')
|
||||||
: path.join(process.cwd(), 'public', 'cable_drums_bg.png');
|
: path.join(process.cwd(), 'public', 'cable_drums_bg.png');
|
||||||
|
|
||||||
|
const mmToPt = (mm: number) => (mm * 72) / 25.4;
|
||||||
|
|
||||||
const renderPageWrapper = (children: React.ReactNode, bleedStyle: any) => {
|
const renderPageWrapper = (children: React.ReactNode, bleedStyle: any) => {
|
||||||
if (withCropMarks) {
|
if (withCropMarks) {
|
||||||
return (
|
return (
|
||||||
@@ -836,7 +847,7 @@ export const PDFBusinessCard: React.FC<PDFBusinessCardProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Page size={[258, 173]} style={bleedStyle}>
|
<Page size={[mmToPt(91), mmToPt(61)]} style={bleedStyle}>
|
||||||
{children}
|
{children}
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
@@ -903,16 +914,6 @@ export const PDFBusinessCard: React.FC<PDFBusinessCardProps> = ({
|
|||||||
solidPaths={KLZ_PATHS}
|
solidPaths={KLZ_PATHS}
|
||||||
solidBackgroundColor={isWhiteVariant ? '#ffffff' : COLORS.primary}
|
solidBackgroundColor={isWhiteVariant ? '#ffffff' : COLORS.primary}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Letterpress Mask Layer: We use #ff00fe as a marker color. Only the "KLZ" letters! */}
|
|
||||||
{isViaprinto && (
|
|
||||||
<VectorLogo
|
|
||||||
paths={getFrontLogoPaths().filter((_, i) => KLZ_PATHS.includes(i))}
|
|
||||||
style={[styles.frontLogo, { position: 'absolute' }]}
|
|
||||||
overrideColor="#ff00fe"
|
|
||||||
solidPaths={KLZ_PATHS.map((_, i) => i)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</>,
|
</>,
|
||||||
[styles.pageBleed, isWhiteVariant && { backgroundColor: '#ffffff' }],
|
[styles.pageBleed, isWhiteVariant && { backgroundColor: '#ffffff' }],
|
||||||
@@ -924,8 +925,18 @@ export const PDFBusinessCard: React.FC<PDFBusinessCardProps> = ({
|
|||||||
<VectorLogo
|
<VectorLogo
|
||||||
paths={getBackLogoPaths()}
|
paths={getBackLogoPaths()}
|
||||||
style={styles.backLogo}
|
style={styles.backLogo}
|
||||||
overrideWhiteColor={COLORS.black}
|
overrideBlackColor={COLORS.primary}
|
||||||
pathOverrides={KLZ_PATHS.reduce((acc, idx) => ({ ...acc, [idx]: 'none' }), {})}
|
pathOverrides={{
|
||||||
|
...(KLZ_PATHS.reduce(
|
||||||
|
(acc: Record<number, string>, idx) => ({ ...acc, [idx]: 'none' }),
|
||||||
|
{} as Record<number, string>,
|
||||||
|
) as any),
|
||||||
|
// Paths 0-24 are the "Cables for a greener tomorrow" tagline in the 40-path logo.
|
||||||
|
...(Array.from({ length: 25 }).reduce(
|
||||||
|
(acc: Record<number, string>, _, i) => ({ ...acc, [i]: 'none' }),
|
||||||
|
{} as Record<number, string>,
|
||||||
|
) as any),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<View style={styles.accentLineHorizontal} />
|
<View style={styles.accentLineHorizontal} />
|
||||||
<View style={styles.qrContainer}>
|
<View style={styles.qrContainer}>
|
||||||
|
|||||||
@@ -329,6 +329,42 @@ const renderLexicalNode = (node: any, idx: number): React.ReactNode => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderMarkdown = (markdown: string) => {
|
||||||
|
const blocks = markdown.split('\n\n').filter((b) => b.trim());
|
||||||
|
return blocks.map((block, idx) => {
|
||||||
|
if (block.startsWith('### ')) {
|
||||||
|
return (
|
||||||
|
<Text key={idx} style={styles.heading3} wrap={false} minPresenceAhead={100}>
|
||||||
|
{block.replace('### ', '').trim()}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (block.startsWith('## ')) {
|
||||||
|
return (
|
||||||
|
<Text key={idx} style={styles.heading2} wrap={false} minPresenceAhead={100}>
|
||||||
|
{block.replace('## ', '').trim()}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (block.startsWith('# ')) {
|
||||||
|
return (
|
||||||
|
<View key={idx} style={styles.heading1Wrapper} wrap={false} minPresenceAhead={100}>
|
||||||
|
<Text style={styles.heading1}>{block.replace('# ', '').trim()}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ignore block components like <Block ... />
|
||||||
|
if (block.startsWith('<Block')) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text key={idx} style={styles.paragraph} minPresenceAhead={15}>
|
||||||
|
{block.trim()}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
interface PDFPageProps {
|
interface PDFPageProps {
|
||||||
page: any;
|
page: any;
|
||||||
locale?: string;
|
locale?: string;
|
||||||
@@ -360,7 +396,9 @@ export const PDFPage: React.FC<PDFPageProps> = ({ page, locale = 'de' }) => {
|
|||||||
|
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
<View>
|
<View>
|
||||||
{page.content?.root?.children?.map((node: any, i: number) =>
|
{typeof page.content === 'string'
|
||||||
|
? renderMarkdown(page.content)
|
||||||
|
: page.content?.root?.children?.map((node: any, i: number) =>
|
||||||
renderLexicalNode(node, i),
|
renderLexicalNode(node, i),
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -39,11 +39,8 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
|
|||||||
const fileContent = await fs.readFile(filePath, 'utf-8');
|
const fileContent = await fs.readFile(filePath, 'utf-8');
|
||||||
const { data, content } = matter(fileContent);
|
const { data, content } = matter(fileContent);
|
||||||
|
|
||||||
// Fix MDX data props dropped by next-mdx-remote
|
const { fixMdxDataProps } = await import('./mdx-utils');
|
||||||
// Payload serializes as data={{"items":...}} which Acorn treats as a Block statement.
|
const fixedContent = fixMdxDataProps(content);
|
||||||
const fixedContent = content.replace(/data=\{\{([\s\S]*?)\}\}/g, (match, p1) => {
|
|
||||||
return 'data="{' + p1.replace(/"/g, '"') + '}"';
|
|
||||||
});
|
|
||||||
|
|
||||||
let parsedContent = fixedContent;
|
let parsedContent = fixedContent;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ export class GlitchtipErrorReportingService implements ErrorReportingService {
|
|||||||
tracesSampleRate: this.options.tracesSampleRate ?? 0.1,
|
tracesSampleRate: this.options.tracesSampleRate ?? 0.1,
|
||||||
replaysOnErrorSampleRate: 1.0,
|
replaysOnErrorSampleRate: 1.0,
|
||||||
replaysSessionSampleRate: 0.1,
|
replaysSessionSampleRate: 0.1,
|
||||||
|
ignoreErrors: [
|
||||||
|
'ChunkLoadError',
|
||||||
|
'Failed to fetch dynamically imported module',
|
||||||
|
'Failed to find Server Action',
|
||||||
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return Sentry;
|
return Sentry;
|
||||||
|
|||||||
@@ -116,7 +116,7 @@
|
|||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"preinstall": "npx only-allow pnpm"
|
"preinstall": "npx only-allow pnpm"
|
||||||
},
|
},
|
||||||
"version": "2.3.24",
|
"version": "2.3.34",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"@parcel/watcher",
|
"@parcel/watcher",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 7.4 KiB |
10
public/v/johannesgleich.vcf
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
BEGIN:VCARD
|
||||||
|
VERSION:3.0
|
||||||
|
N:Gleich;Johannes;;;
|
||||||
|
FN:Johannes Gleich
|
||||||
|
ORG:KLZ Vertriebs GmbH
|
||||||
|
TITLE:Senior Key Account Manager
|
||||||
|
TEL;TYPE=WORK,VOICE:+49 172 739 1109
|
||||||
|
EMAIL;TYPE=PREF,INTERNET:johannes.gleich@klz-cables.com
|
||||||
|
URL:https://klz-cables.com
|
||||||
|
END:VCARD
|
||||||
10
public/v/klausmintel.vcf
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
BEGIN:VCARD
|
||||||
|
VERSION:3.0
|
||||||
|
N:Mintel;Klaus;;;
|
||||||
|
FN:Klaus Mintel
|
||||||
|
ORG:KLZ Vertriebs GmbH
|
||||||
|
TITLE:Managing Director
|
||||||
|
TEL;TYPE=WORK,VOICE:+49 151 1775 2873
|
||||||
|
EMAIL;TYPE=PREF,INTERNET:klaus.mintel@klz-cables.com
|
||||||
|
URL:https://klz-cables.com
|
||||||
|
END:VCARD
|
||||||
10
public/v/michaelbodemer.vcf
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
BEGIN:VCARD
|
||||||
|
VERSION:3.0
|
||||||
|
N:Bodemer;Michael;;;
|
||||||
|
FN:Michael Bodemer
|
||||||
|
ORG:KLZ Vertriebs GmbH
|
||||||
|
TITLE:Owner & Managing Director
|
||||||
|
TEL;TYPE=WORK,VOICE:+49 151 462 467 98
|
||||||
|
EMAIL;TYPE=PREF,INTERNET:michael.bodemer@klz-cables.com
|
||||||
|
URL:https://klz-cables.com
|
||||||
|
END:VCARD
|
||||||
@@ -52,24 +52,26 @@ async function generateBusinessCards() {
|
|||||||
for (const person of PEOPLE) {
|
for (const person of PEOPLE) {
|
||||||
console.log(`\nGenerating Business Card for: ${person.name}`);
|
console.log(`\nGenerating Business Card for: ${person.name}`);
|
||||||
|
|
||||||
// Generate VCard QR Code
|
|
||||||
const lastName = person.name.split(' ').slice(1).join(' ');
|
const lastName = person.name.split(' ').slice(1).join(' ');
|
||||||
const firstName = person.name.split(' ')[0];
|
const firstName = person.name.split(' ')[0];
|
||||||
const vcard = `BEGIN:VCARD
|
|
||||||
VERSION:3.0
|
|
||||||
N:${lastName};${firstName};;;
|
|
||||||
FN:${person.name}
|
|
||||||
ORG:KLZ Vertriebs GmbH
|
|
||||||
TITLE:${person.role}
|
|
||||||
TEL;TYPE=WORK,VOICE:${person.phone}
|
|
||||||
EMAIL;TYPE=PREF,INTERNET:${person.email}
|
|
||||||
URL:https://klz-cables.com
|
|
||||||
END:VCARD`;
|
|
||||||
|
|
||||||
const qrCodeDataUrl = await QRCode.toDataURL(vcard, {
|
// To make the QR code extremely coarse (minimalist), we cannot encode all contact details into it directly.
|
||||||
|
// Instead, we create a physical .vcf file and encode ONLY its URL into the QR Code (e.g. 35 chars vs 140 chars).
|
||||||
|
const shortId = person.name.toLowerCase().replace(/[^a-z0-9]/g, ''); // "michaelbodemer"
|
||||||
|
const vcfUrl = `https://klz-cables.com/v/${shortId}.vcf`;
|
||||||
|
|
||||||
|
const vcardFull = `BEGIN:VCARD\r\nVERSION:3.0\r\nN:${lastName};${firstName};;;\r\nFN:${person.name}\r\nORG:KLZ Vertriebs GmbH\r\nTITLE:${person.role}\r\nTEL;TYPE=WORK,VOICE:${person.phone}\r\nEMAIL;TYPE=PREF,INTERNET:${person.email}\r\nURL:https://klz-cables.com\r\nEND:VCARD`;
|
||||||
|
|
||||||
|
// Save the physical .vcf file so it can be served by Next.js from /public/v/
|
||||||
|
const vcardDir = path.join(process.cwd(), 'public/v');
|
||||||
|
if (!fs.existsSync(vcardDir)) fs.mkdirSync(vcardDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(vcardDir, `${shortId}.vcf`), vcardFull);
|
||||||
|
|
||||||
|
const qrCodeDataUrl = await QRCode.toDataURL(vcfUrl, {
|
||||||
width: 400,
|
width: 400,
|
||||||
margin: 0,
|
margin: 0,
|
||||||
color: { dark: '#000000', light: '#ffffff' },
|
errorCorrectionLevel: 'L', // Low error correction = less dots
|
||||||
|
color: { dark: '#000a66', light: '#ffffff' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const safeName = person.name.replace(/\s+/g, '_');
|
const safeName = person.name.replace(/\s+/g, '_');
|
||||||
@@ -119,7 +121,36 @@ END:VCARD`;
|
|||||||
try {
|
try {
|
||||||
execSync(cmykCommand, { stdio: 'inherit' });
|
execSync(cmykCommand, { stdio: 'inherit' });
|
||||||
fs.unlinkSync(tempViaprintoPath);
|
fs.unlinkSync(tempViaprintoPath);
|
||||||
console.log(`✓ CMYK Print File generated: ${finalFileNameCMYK}`);
|
console.log(`[INFO] Injecting PANTONE 289 C Spot Color...`);
|
||||||
|
const injectPantoneCommand = `npx tsx scripts/inject-pantone.ts "${finalPathCMYK}"`;
|
||||||
|
execSync(injectPantoneCommand, { stdio: 'inherit' });
|
||||||
|
|
||||||
|
console.log(`✓ 5-Color Print File generated: ${finalFileNameCMYK}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`❌ CMYK conversion failed! Is Ghostscript installed?`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.5 SAXOPRINT PDF (Pure CMYK, no mask, no Pantone)
|
||||||
|
console.log(`- Rendering Saxoprint PDF (Pure CMYK)...`);
|
||||||
|
const tempSaxoprintPath = path.join(CONFIG.outputDir, `temp_saxoprint_${safeName}.pdf`);
|
||||||
|
buffer = await renderToBuffer(
|
||||||
|
React.createElement(PDFBusinessCard, {
|
||||||
|
person,
|
||||||
|
qrCodeDataUrl,
|
||||||
|
variant: 'blue',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
fs.writeFileSync(tempSaxoprintPath, buffer);
|
||||||
|
|
||||||
|
const finalFileNameSaxoCMYK = `KLZ_Visitenkarte_${safeName}_Saxoprint_CMYK_91x61mm_3mmBleed.pdf`;
|
||||||
|
const finalPathSaxoCMYK = path.join(CONFIG.outputDir, finalFileNameSaxoCMYK);
|
||||||
|
|
||||||
|
console.log(`- Converting to CMYK for Saxoprint...`);
|
||||||
|
const cmykSaxoCommand = `bash "${CONFIG.ghostscriptScript}" "${tempSaxoprintPath}" "${finalPathSaxoCMYK}"`;
|
||||||
|
try {
|
||||||
|
execSync(cmykSaxoCommand, { stdio: 'inherit' });
|
||||||
|
fs.unlinkSync(tempSaxoprintPath);
|
||||||
|
console.log(`✓ 4-Color CMYK Print File generated: ${finalFileNameSaxoCMYK}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`❌ CMYK conversion failed! Is Ghostscript installed?`);
|
console.error(`❌ CMYK conversion failed! Is Ghostscript installed?`);
|
||||||
}
|
}
|
||||||
|
|||||||
105
scripts/inject-pantone.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { PDFDocument, PDFName, decodePDFRawStream, PDFRawStream } from 'pdf-lib';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const inputPath = process.argv[2];
|
||||||
|
const outputPath = process.argv[3] || inputPath;
|
||||||
|
|
||||||
|
if (!inputPath || !fs.existsSync(inputPath)) {
|
||||||
|
console.error(`[ERROR] Input file not found: ${inputPath}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[INFO] Injecting PANTONE 289 C into: ${path.basename(inputPath)}`);
|
||||||
|
const pdfBytes = fs.readFileSync(inputPath);
|
||||||
|
const pdfDoc = await PDFDocument.load(pdfBytes);
|
||||||
|
|
||||||
|
// CMYK tint transform for PANTONE 289 C.
|
||||||
|
// Approximation: 100C, 64M, 0Y, 60K (1.0 0.64 0.0 0.60)
|
||||||
|
// The function maps an input x (0 to 1) to CMYK: x*1.0, x*0.64, x*0.0, x*0.60
|
||||||
|
const tintTransform = pdfDoc.context.obj({
|
||||||
|
FunctionType: 2,
|
||||||
|
Domain: [0, 1],
|
||||||
|
C0: [0, 0, 0, 0],
|
||||||
|
C1: [1.0, 0.64, 0.0, 0.6],
|
||||||
|
N: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create the /Separation color space array
|
||||||
|
const pantoneCS = pdfDoc.context.obj([
|
||||||
|
PDFName.of('Separation'),
|
||||||
|
PDFName.of('PANTONE#20289#20C'),
|
||||||
|
PDFName.of('DeviceCMYK'),
|
||||||
|
tintTransform,
|
||||||
|
]);
|
||||||
|
const csRef = pdfDoc.context.register(pantoneCS);
|
||||||
|
|
||||||
|
const pages = pdfDoc.getPages();
|
||||||
|
for (const page of pages) {
|
||||||
|
let resources = page.node.Resources();
|
||||||
|
if (!resources) {
|
||||||
|
resources = pdfDoc.context.obj({});
|
||||||
|
page.node.set(PDFName.of('Resources'), resources);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure ColorSpace dict exists
|
||||||
|
let colorSpaces = resources.get(PDFName.of('ColorSpace')) as any;
|
||||||
|
if (!colorSpaces) {
|
||||||
|
colorSpaces = pdfDoc.context.obj({});
|
||||||
|
resources.set(PDFName.of('ColorSpace'), colorSpaces);
|
||||||
|
}
|
||||||
|
// Register the PANTONE color space as /CSPantone in the page's resources
|
||||||
|
colorSpaces.set(PDFName.of('CSPantone'), csRef);
|
||||||
|
|
||||||
|
// Read all content streams
|
||||||
|
const contents = page.node.Contents();
|
||||||
|
let streams: any[] = [];
|
||||||
|
if (contents) {
|
||||||
|
const resolvedContents = pdfDoc.context.lookup(contents);
|
||||||
|
if (resolvedContents instanceof PDFRawStream) {
|
||||||
|
streams = [resolvedContents];
|
||||||
|
} else if (resolvedContents.constructor.name === 'PDFArray') {
|
||||||
|
const arr = resolvedContents as any;
|
||||||
|
for (let j = 0; j < arr.size(); j++) {
|
||||||
|
streams.push(pdfDoc.context.lookup(arr.get(j)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let newStr = '';
|
||||||
|
for (let i = 0; i < streams.length; i++) {
|
||||||
|
const stream = streams[i];
|
||||||
|
if (stream instanceof PDFRawStream) {
|
||||||
|
const decoded = decodePDFRawStream(stream).decode();
|
||||||
|
const str = Buffer.from(decoded).toString('utf-8');
|
||||||
|
newStr += str + '\n';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The CMYK values for the KLZ Blue created by Ghostscript from #000a66
|
||||||
|
// is '1 0.98 0.224 0.298'. We use a slightly dynamic regex to catch small precision diffs.
|
||||||
|
const dynamicRegexK = /1 0\.98[0-9]* 0\.224[0-9]* 0\.298[0-9]* k/g;
|
||||||
|
const dynamicRegexStrokeK = /1 0\.98[0-9]* 0\.224[0-9]* 0\.298[0-9]* K/g;
|
||||||
|
|
||||||
|
let modified = false;
|
||||||
|
if (dynamicRegexK.test(newStr) || dynamicRegexStrokeK.test(newStr)) {
|
||||||
|
newStr = newStr.replace(dynamicRegexK, '/CSPantone cs 1 scn');
|
||||||
|
newStr = newStr.replace(dynamicRegexStrokeK, '/CSPantone CS 1 SCN');
|
||||||
|
modified = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modified) {
|
||||||
|
const newStream = pdfDoc.context.flateStream(newStr);
|
||||||
|
const newRef = pdfDoc.context.register(newStream);
|
||||||
|
page.node.set(PDFName.of('Contents'), newRef);
|
||||||
|
console.log(`[INFO] Spot color injected into page ${pages.indexOf(page) + 1}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalPdfBytes = await pdfDoc.save();
|
||||||
|
fs.writeFileSync(outputPath, finalPdfBytes);
|
||||||
|
console.log(`[SUCCESS] Saved 5-color PDF to ${outputPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch(console.error);
|
||||||
@@ -12,4 +12,5 @@ Sentry.init({
|
|||||||
|
|
||||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||||
debug: false,
|
debug: false,
|
||||||
|
ignoreErrors: ['failed to pipe response'],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,4 +12,5 @@ Sentry.init({
|
|||||||
|
|
||||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||||
debug: false,
|
debug: false,
|
||||||
|
ignoreErrors: ['failed to pipe response'],
|
||||||
});
|
});
|
||||||
|
|||||||
47
tests/glitchtip-error-reporting.test.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { GlitchtipErrorReportingService } from '../lib/services/errors/glitchtip-error-reporting-service';
|
||||||
|
|
||||||
|
// Mock the LoggerService
|
||||||
|
const mockLogger = {
|
||||||
|
child: vi.fn().mockReturnThis(),
|
||||||
|
info: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const mockSentryInit = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@sentry/nextjs', () => ({
|
||||||
|
init: (...args: any[]) => mockSentryInit(...args),
|
||||||
|
captureException: vi.fn(),
|
||||||
|
captureMessage: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('GlitchtipErrorReportingService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore version drift errors (ChunkLoadError and Server Action) in Sentry config', async () => {
|
||||||
|
// Simulate window to force client-side init behavior instantly
|
||||||
|
const originalWindow = global.window;
|
||||||
|
global.window = {
|
||||||
|
requestIdleCallback: (cb: Function) => cb(),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const service = new GlitchtipErrorReportingService({ enabled: true }, mockLogger);
|
||||||
|
|
||||||
|
// Give the dynamic import a moment to resolve
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
expect(mockSentryInit).toHaveBeenCalledOnce();
|
||||||
|
const initCallArgs = mockSentryInit.mock.calls[0][0];
|
||||||
|
|
||||||
|
expect(initCallArgs.ignoreErrors).toBeDefined();
|
||||||
|
expect(initCallArgs.ignoreErrors).toContain('ChunkLoadError');
|
||||||
|
expect(initCallArgs.ignoreErrors).toContain('Failed to find Server Action');
|
||||||
|
|
||||||
|
global.window = originalWindow;
|
||||||
|
});
|
||||||
|
});
|
||||||
27
tests/lexical.test.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { lexicalToMarkdown } from '../lib/mdx-utils';
|
||||||
|
|
||||||
|
describe('lexicalToMarkdown', () => {
|
||||||
|
it('should convert lexical AST to markdown', () => {
|
||||||
|
const ast = {
|
||||||
|
type: 'root',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
children: [
|
||||||
|
{ type: 'text', text: 'Hello ' },
|
||||||
|
{ type: 'text', text: 'World', format: 1 }, // bold
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'heading',
|
||||||
|
tag: 'h2',
|
||||||
|
children: [{ type: 'text', text: 'Title' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = lexicalToMarkdown(ast);
|
||||||
|
expect(result).toBe('Hello **World**\n\n## Title\n\n');
|
||||||
|
});
|
||||||
|
});
|
||||||
14
tests/mdx-content-mapping.test.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
describe('MDXContent Mapping', () => {
|
||||||
|
it('should have productTabs in the switch statement to prevent Unknown Block Type errors', () => {
|
||||||
|
const filePath = path.join(process.cwd(), 'components/MDXContent.tsx');
|
||||||
|
const fileContent = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
|
||||||
|
// Check if the switch case for productTabs exists
|
||||||
|
expect(fileContent).toContain("case 'productTabs':");
|
||||||
|
expect(fileContent).toContain('<ProductTechnicalData');
|
||||||
|
});
|
||||||
|
});
|
||||||
22
tests/mdx-regex.test.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { fixMdxDataProps } from '../lib/mdx-utils';
|
||||||
|
|
||||||
|
describe('MDX Data Props Fixer', () => {
|
||||||
|
it('should correctly parse MDX with nested JSON containing }}', () => {
|
||||||
|
// This string simulates the exact structure that causes the bug in n2x2y.mdx
|
||||||
|
const mdxInput = `<Block type="productTabs" data={{"content":{"root":{"children":[]}},"id":"123"}} />`;
|
||||||
|
|
||||||
|
const result = fixMdxDataProps(mdxInput);
|
||||||
|
|
||||||
|
const expected = `<Block type="productTabs" data="{"content":{"root":{"children":[]}},"id":"123"}" />`;
|
||||||
|
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not prematurely truncate if } appears inside a string literal', () => {
|
||||||
|
const mdxInput = `<Block type="productTabs" data={{"content":{"text":"}>"}}} />`;
|
||||||
|
const result = fixMdxDataProps(mdxInput);
|
||||||
|
const expected = `<Block type="productTabs" data="{"content":{"text":"}>"}}" />`;
|
||||||
|
expect(result).toBe(expected.replace('>', '>'));
|
||||||
|
});
|
||||||
|
});
|
||||||