Compare commits
6 Commits
0b81d1a4cb
...
v1.0.0-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
| 506c8682fe | |||
| a909de30f3 | |||
| a2f94f15bc | |||
| 13e56a88bc | |||
| bb7d17001b | |||
| 920efa0083 |
@@ -1,10 +1,11 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||
import { Container, Badge } from '@/components/ui';
|
||||
import { Container, Badge, Heading } from '@/components/ui';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { Metadata } from 'next';
|
||||
import { getPageBySlug, getAllPages } from '@/lib/pages';
|
||||
import { mdxComponents } from '@/components/blog/MDXComponents';
|
||||
import { getOGImageMetadata } from '@/lib/metadata';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
@@ -47,6 +48,7 @@ export async function generateMetadata({ params: { locale, slug } }: PageProps):
|
||||
title: `${pageData.frontmatter.title} | KLZ Cables`,
|
||||
description: pageData.frontmatter.excerpt || '',
|
||||
url: `https://klz-cables.com/${locale}/${slug}`,
|
||||
images: getOGImageMetadata(slug, pageData.frontmatter.title, locale),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
@@ -74,9 +76,9 @@ export default async function StandardPage({ params: { locale, slug } }: PagePro
|
||||
<Container className="relative z-10">
|
||||
<div className="max-w-4xl animate-slide-up">
|
||||
<Badge variant="accent" className="mb-4 md:mb-6">{t('badge')}</Badge>
|
||||
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold text-white mb-0 leading-tight">
|
||||
<Heading level={1} className="text-white mb-0">
|
||||
{pageData.frontmatter.title}
|
||||
</h1>
|
||||
</Heading>
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
|
||||
@@ -2,18 +2,29 @@ import { ImageResponse } from 'next/og';
|
||||
import { getProductBySlug } from '@/lib/mdx';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export default async function Image({ params: { locale, slug } }: { params: { locale: string, slug: string[] } }) {
|
||||
const t = await getTranslations('Products');
|
||||
const productSlug = slug[slug.length - 1];
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { locale: string } }
|
||||
) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const slug = searchParams.get('slug');
|
||||
const locale = params.locale || 'en';
|
||||
|
||||
if (!slug) {
|
||||
return new Response('Missing slug', { status: 400 });
|
||||
}
|
||||
|
||||
const t = await getTranslations({ locale, namespace: 'Products' });
|
||||
|
||||
// Check if it's a category page
|
||||
const categories = ['low-voltage-cables', 'medium-voltage-cables', 'high-voltage-cables', 'solar-cables'];
|
||||
if (categories.includes(productSlug)) {
|
||||
const categoryKey = productSlug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||
const categoryTitle = t.has(`categories.${categoryKey}.title`) ? t(`categories.${categoryKey}.title`) : productSlug;
|
||||
if (categories.includes(slug)) {
|
||||
const categoryKey = slug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||
const categoryTitle = t.has(`categories.${categoryKey}.title`) ? t(`categories.${categoryKey}.title`) : slug;
|
||||
const categoryDesc = t.has(`categories.${categoryKey}.description`) ? t(`categories.${categoryKey}.description`) : '';
|
||||
|
||||
return new ImageResponse(
|
||||
@@ -31,7 +42,7 @@ export default async function Image({ params: { locale, slug } }: { params: { lo
|
||||
);
|
||||
}
|
||||
|
||||
const product = await getProductBySlug(productSlug, locale);
|
||||
const product = await getProductBySlug(slug, locale);
|
||||
|
||||
if (!product) {
|
||||
return new ImageResponse(
|
||||
@@ -39,10 +50,11 @@ export default async function Image({ params: { locale, slug } }: { params: { lo
|
||||
);
|
||||
}
|
||||
|
||||
const { origin } = new URL(request.url);
|
||||
const featuredImage = product.frontmatter.images?.[0]
|
||||
? (product.frontmatter.images[0].startsWith('http')
|
||||
? product.frontmatter.images[0]
|
||||
: `https://klz-cables.com${product.frontmatter.images[0]}`)
|
||||
: `${origin}${product.frontmatter.images[0]}`)
|
||||
: undefined;
|
||||
|
||||
return new ImageResponse(
|
||||
@@ -10,6 +10,8 @@ import PostNavigation from '@/components/blog/PostNavigation';
|
||||
import PowerCTA from '@/components/blog/PowerCTA';
|
||||
import TableOfContents from '@/components/blog/TableOfContents';
|
||||
import { mdxComponents } from '@/components/blog/MDXComponents';
|
||||
import { Heading } from '@/components/ui';
|
||||
import { getOGImageMetadata } from '@/lib/metadata';
|
||||
|
||||
interface BlogPostProps {
|
||||
params: {
|
||||
@@ -42,6 +44,7 @@ export async function generateMetadata({ params: { locale, slug } }: BlogPostPro
|
||||
publishedTime: post.frontmatter.date,
|
||||
authors: ['KLZ Cables'],
|
||||
url: `https://klz-cables.com/${locale}/blog/${slug}`,
|
||||
images: getOGImageMetadata(`blog/${slug}`, post.frontmatter.title, locale),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
@@ -84,9 +87,9 @@ export default async function BlogPost({ params: { locale, slug } }: BlogPostPro
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold text-white mb-8 leading-[1.1] drop-shadow-2xl animate-slight-fade-in-from-bottom [animation-delay:200ms]">
|
||||
<Heading level={1} className="text-white mb-8 drop-shadow-2xl animate-slight-fade-in-from-bottom [animation-delay:200ms]">
|
||||
{post.frontmatter.title}
|
||||
</h1>
|
||||
</Heading>
|
||||
<div className="flex flex-wrap items-center gap-6 text-white/80 text-sm md:text-base font-medium animate-slight-fade-in-from-bottom [animation-delay:400ms]">
|
||||
<time dateTime={post.frontmatter.date}>
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||
@@ -112,9 +115,9 @@ export default async function BlogPost({ params: { locale, slug } }: BlogPostPro
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-text-primary mb-8 leading-tight">
|
||||
<Heading level={1} className="mb-8">
|
||||
{post.frontmatter.title}
|
||||
</h1>
|
||||
</Heading>
|
||||
<div className="flex items-center gap-6 text-text-secondary font-medium">
|
||||
<time dateTime={post.frontmatter.date}>
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||
|
||||
25
app/[locale]/blog/opengraph-image.tsx
Normal file
25
app/[locale]/blog/opengraph-image.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
||||
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
|
||||
const title = t('title');
|
||||
const description = t('description');
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<OGImageTemplate
|
||||
title={title}
|
||||
description={description}
|
||||
label="Blog"
|
||||
/>
|
||||
),
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { getAllPosts } from '@/lib/blog';
|
||||
import { Section, Container, Heading, Card, Badge, Button } from '@/components/ui';
|
||||
import Reveal from '@/components/Reveal';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { getOGImageMetadata } from '@/lib/metadata';
|
||||
|
||||
interface BlogIndexProps {
|
||||
params: {
|
||||
@@ -27,6 +28,7 @@ export async function generateMetadata({ params: { locale } }: BlogIndexProps) {
|
||||
title: `${t('title')} | KLZ Cables`,
|
||||
description: t('description'),
|
||||
url: `https://klz-cables.com/${locale}/blog`,
|
||||
images: getOGImageMetadata('blog', t('title'), locale),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
@@ -69,9 +71,9 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
|
||||
<Badge variant="saturated" className="mb-4 md:mb-6">{t('featuredPost')}</Badge>
|
||||
{featuredPost && (
|
||||
<>
|
||||
<h1 className="text-3xl md:text-6xl font-extrabold text-white mb-4 md:mb-8 leading-[1.1] line-clamp-3 md:line-clamp-none">
|
||||
<Heading level={1} className="text-white mb-4 md:mb-8">
|
||||
{featuredPost.frontmatter.title}
|
||||
</h1>
|
||||
</Heading>
|
||||
<p className="text-base md:text-xl text-white/80 mb-6 md:mb-10 line-clamp-2 md:line-clamp-2 max-w-2xl">
|
||||
{featuredPost.frontmatter.excerpt}
|
||||
</p>
|
||||
|
||||
@@ -4,6 +4,8 @@ import Reveal from '@/components/Reveal';
|
||||
import { Container, Heading, Section } from '@/components/ui';
|
||||
import { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import { getOGImageMetadata } from '@/lib/metadata';
|
||||
import { Suspense } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
const LeafletMap = dynamic(() => import('@/components/LeafletMap'), {
|
||||
@@ -40,14 +42,7 @@ export async function generateMetadata({ params: { locale } }: ContactPageProps)
|
||||
description,
|
||||
url: `https://klz-cables.com/${locale}/contact`,
|
||||
siteName: 'KLZ Cables',
|
||||
images: [
|
||||
{
|
||||
url: 'https://klz-cables.com/logo.png',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'KLZ Cables Contact',
|
||||
},
|
||||
],
|
||||
images: getOGImageMetadata('contact', title, locale),
|
||||
locale: `${locale.toUpperCase()}_DE`,
|
||||
type: 'website',
|
||||
},
|
||||
@@ -55,7 +50,7 @@ export async function generateMetadata({ params: { locale } }: ContactPageProps)
|
||||
card: 'summary_large_image',
|
||||
title: `${title} | KLZ Cables`,
|
||||
description,
|
||||
images: ['https://klz-cables.com/logo.png'],
|
||||
images: [`${SITE_URL}/${locale}/contact/opengraph-image`],
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ImageResponse } from 'next/og';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
||||
const t = await getTranslations({ locale, namespace: 'Index.meta' });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Hero from '@/components/home/Hero';
|
||||
import JsonLd from '@/components/JsonLd';
|
||||
import { getBreadcrumbSchema } from '@/lib/schema';
|
||||
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
|
||||
import ProductCategories from '@/components/home/ProductCategories';
|
||||
import WhatWeDo from '@/components/home/WhatWeDo';
|
||||
import RecentPosts from '@/components/home/RecentPosts';
|
||||
@@ -13,6 +13,7 @@ import CTA from '@/components/home/CTA';
|
||||
import Reveal from '@/components/Reveal';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { Metadata } from 'next';
|
||||
import { getOGImageMetadata } from '@/lib/metadata';
|
||||
|
||||
export default function HomePage({ params: { locale } }: { params: { locale: string } }) {
|
||||
return (
|
||||
@@ -70,6 +71,7 @@ export async function generateMetadata({ params: { locale } }: { params: { local
|
||||
title: `${title} | KLZ Cables`,
|
||||
description,
|
||||
url: `https://klz-cables.com/${locale}`,
|
||||
images: getOGImageMetadata('', title, locale),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
|
||||
@@ -5,12 +5,13 @@ import ProductSidebar from '@/components/ProductSidebar';
|
||||
import ProductTabs from '@/components/ProductTabs';
|
||||
import ProductTechnicalData from '@/components/ProductTechnicalData';
|
||||
import RelatedProducts from '@/components/RelatedProducts';
|
||||
import { Badge, Container, Section } from '@/components/ui';
|
||||
import { Badge, Container, Heading, Section } from '@/components/ui';
|
||||
import { getDatasheetPath } from '@/lib/datasheets';
|
||||
import { getAllProducts, getProductBySlug } from '@/lib/mdx';
|
||||
import { mapFileSlugToTranslated, mapSlugToFileSlug } from '@/lib/slugs';
|
||||
import { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { getProductOGImageMetadata } from '@/lib/metadata';
|
||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
@@ -51,6 +52,7 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
||||
title: `${categoryTitle} | KLZ Cables`,
|
||||
description: categoryDesc,
|
||||
url: `https://klz-cables.com/${locale}/products/${productSlug}`,
|
||||
images: getProductOGImageMetadata(fileSlug, categoryTitle, locale),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
@@ -79,6 +81,7 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
||||
description: product.frontmatter.description,
|
||||
type: 'website',
|
||||
url: `https://klz-cables.com/${locale}/products/${slug.join('/')}`,
|
||||
images: getProductOGImageMetadata(productSlug, product.frontmatter.title, locale),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
@@ -164,9 +167,9 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
<span className="mx-3 opacity-30">/</span>
|
||||
<span className="text-white/90">{categoryTitle}</span>
|
||||
</nav>
|
||||
<h1 className="text-5xl md:text-7xl lg:text-8xl font-extrabold text-white mb-8 tracking-tight leading-[1.05]">
|
||||
<Heading level={1} className="text-white mb-8">
|
||||
{categoryTitle}
|
||||
</h1>
|
||||
</Heading>
|
||||
<div className="h-1.5 w-24 bg-accent rounded-full" />
|
||||
</div>
|
||||
</Container>
|
||||
@@ -309,9 +312,9 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<h1 className="text-6xl md:text-8xl lg:text-9xl font-black text-white mb-8 tracking-tighter leading-[0.9] uppercase">
|
||||
<Heading level={1} className="text-white mb-8 uppercase">
|
||||
{product.frontmatter.title}
|
||||
</h1>
|
||||
</Heading>
|
||||
<p className="text-xl md:text-2xl text-white/60 max-w-2xl leading-relaxed font-medium">
|
||||
{product.frontmatter.description}
|
||||
</p>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import Reveal from '@/components/Reveal';
|
||||
import Scribble from '@/components/Scribble';
|
||||
import { Badge, Button, Card, Container, Section } from '@/components/ui';
|
||||
import { Badge, Button, Card, Container, Heading, Section } from '@/components/ui';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { Metadata } from 'next';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { mapFileSlugToTranslated } from '@/lib/slugs';
|
||||
import { getOGImageMetadata } from '@/lib/metadata';
|
||||
|
||||
interface ProductsPageProps {
|
||||
params: {
|
||||
@@ -32,6 +33,7 @@ export async function generateMetadata({ params: { locale } }: ProductsPageProps
|
||||
title: `${title} | KLZ Cables`,
|
||||
description,
|
||||
url: `https://klz-cables.com/${locale}/products`,
|
||||
images: getOGImageMetadata('products', title, locale),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
@@ -90,7 +92,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||
<Badge variant="saturated" className="mb-4 md:mb-8 shadow-lg px-3 py-1 md:px-4 md:py-1.5">
|
||||
{t('heroSubtitle')}
|
||||
</Badge>
|
||||
<h1 className="text-4xl md:text-6xl lg:text-7xl font-extrabold text-white mb-4 md:mb-8 tracking-tight leading-[1.05]">
|
||||
<Heading level={1} className="text-white mb-4 md:mb-8">
|
||||
{t.rich('title', {
|
||||
green: (chunks) => (
|
||||
<span className="relative inline-block">
|
||||
@@ -99,7 +101,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</h1>
|
||||
</Heading>
|
||||
<p className="text-lg md:text-xl text-white/70 leading-relaxed max-w-2xl mb-8 md:mb-12 line-clamp-2 md:line-clamp-none">
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Metadata } from 'next';
|
||||
import JsonLd from '@/components/JsonLd';
|
||||
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
|
||||
import { Section, Container, Heading, Badge, Button } from '@/components/ui';
|
||||
import { getOGImageMetadata } from '@/lib/metadata';
|
||||
import Image from 'next/image';
|
||||
import Reveal from '@/components/Reveal';
|
||||
import Gallery from '@/components/team/Gallery';
|
||||
@@ -32,6 +33,7 @@ export async function generateMetadata({ params: { locale } }: TeamPageProps): P
|
||||
title: `${title} | KLZ Cables`,
|
||||
description,
|
||||
url: `https://klz-cables.com/${locale}/team`,
|
||||
images: getOGImageMetadata('team', title, locale),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
@@ -102,9 +104,9 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
||||
|
||||
<Container className="relative z-10 text-center text-white max-w-5xl">
|
||||
<Badge variant="saturated" className="mb-4 md:mb-8 shadow-lg">{t('hero.badge')}</Badge>
|
||||
<h1 className="text-3xl md:text-6xl lg:text-7xl font-extrabold tracking-tight leading-[1.1] mb-4 md:mb-8">
|
||||
<Heading level={1} className="text-white mb-4 md:mb-8">
|
||||
{t('hero.subtitle')}
|
||||
</h1>
|
||||
</Heading>
|
||||
<p className="text-lg md:text-2xl text-white/70 font-medium italic">
|
||||
{t('hero.title')}
|
||||
</p>
|
||||
|
||||
@@ -46,6 +46,8 @@ export function OGImageTemplate({
|
||||
<img
|
||||
src={image}
|
||||
alt=""
|
||||
width="1200"
|
||||
height="630"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
|
||||
@@ -49,6 +49,7 @@ export default function AnalyticsProvider() {
|
||||
id="umami-analytics"
|
||||
src="/stats/script.js"
|
||||
data-website-id={websiteId}
|
||||
data-host-url="/stats"
|
||||
strategy="afterInteractive"
|
||||
data-domains="klz-cables.com"
|
||||
defer
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function Hero() {
|
||||
variants={containerVariants}
|
||||
>
|
||||
<motion.div variants={headingVariants}>
|
||||
<Heading level={1} 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 [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]">
|
||||
<Heading level={1} 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]">
|
||||
{t.rich('title', {
|
||||
green: (chunks) => (
|
||||
<span className="relative inline-block">
|
||||
|
||||
@@ -17,9 +17,9 @@ export function Heading({
|
||||
const Tag = `h${level}` as any;
|
||||
|
||||
const sizes = {
|
||||
1: 'text-3xl md:text-5xl lg:text-6xl font-extrabold leading-[1.1] tracking-tight',
|
||||
2: 'text-2xl md:text-4xl lg:text-5xl font-bold leading-[1.2] tracking-tight',
|
||||
3: 'text-xl md:text-2xl lg:text-3xl font-bold leading-[1.3] tracking-tight',
|
||||
1: 'text-2xl md:text-4xl lg:text-5xl font-bold leading-[1.1] tracking-tight',
|
||||
2: 'text-xl md:text-3xl lg:text-4xl font-bold leading-[1.2] tracking-tight',
|
||||
3: 'text-lg md:text-2xl lg:text-3xl font-bold leading-[1.3] tracking-tight',
|
||||
4: 'text-lg md:text-xl lg:text-2xl font-bold leading-[1.4]',
|
||||
5: 'text-base md:text-lg font-bold leading-[1.5]',
|
||||
6: 'text-base md:text-lg font-semibold leading-[1.6]',
|
||||
|
||||
26
lib/mdx.ts
26
lib/mdx.ts
@@ -31,6 +31,8 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
|
||||
filePath = path.join(productsDir, `${fileSlug}-2.mdx`);
|
||||
}
|
||||
|
||||
let product: ProductMdx | null = null;
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
// Fallback to English if locale is not 'en'
|
||||
if (locale !== 'en') {
|
||||
@@ -43,7 +45,7 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
|
||||
if (fs.existsSync(enFilePath)) {
|
||||
const fileContent = fs.readFileSync(enFilePath, 'utf8');
|
||||
const { data, content } = matter(fileContent);
|
||||
return {
|
||||
product = {
|
||||
slug: fileSlug,
|
||||
frontmatter: {
|
||||
...data,
|
||||
@@ -53,17 +55,23 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const { data, content } = matter(fileContent);
|
||||
|
||||
product = {
|
||||
slug: fileSlug,
|
||||
frontmatter: data as ProductFrontmatter,
|
||||
content,
|
||||
};
|
||||
}
|
||||
|
||||
// Filter out products without images
|
||||
if (product && (!product.frontmatter.images || product.frontmatter.images.length === 0 || !product.frontmatter.images[0])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const { data, content } = matter(fileContent);
|
||||
|
||||
return {
|
||||
slug: fileSlug,
|
||||
frontmatter: data as ProductFrontmatter,
|
||||
content,
|
||||
};
|
||||
return product;
|
||||
}
|
||||
|
||||
export async function getAllProductSlugs(locale: string): Promise<string[]> {
|
||||
|
||||
24
lib/metadata.ts
Normal file
24
lib/metadata.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Metadata } from 'next';
|
||||
import { SITE_URL } from './schema';
|
||||
|
||||
export function getOGImageMetadata(path: string, title: string, locale: string): NonNullable<Metadata['openGraph']>['images'] {
|
||||
return [
|
||||
{
|
||||
url: `${SITE_URL}/${locale}/${path}/opengraph-image`,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: title,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function getProductOGImageMetadata(slug: string, title: string, locale: string): NonNullable<Metadata['openGraph']>['images'] {
|
||||
return [
|
||||
{
|
||||
url: `${SITE_URL}/${locale}/api/og/product?slug=${slug}`,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: title,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -63,6 +63,7 @@
|
||||
"lint": "next lint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:og": "vitest run tests/og-image.test.ts",
|
||||
"pdf:datasheets": "tsx ./scripts/generate-pdf-datasheets.ts",
|
||||
"pdf:datasheets:legacy": "tsx ./scripts/generate-pdf-datasheets-pdf-lib.ts"
|
||||
},
|
||||
|
||||
@@ -206,6 +206,7 @@ function technicalFullLabel(args: { key: string; excelKey: string; locale: 'en'
|
||||
.replace(/\bconductor material\b/gi, 'Conductor material')
|
||||
.replace(/\bconductor class\b/gi, 'Conductor class')
|
||||
.replace(/\bcore insulation\b/gi, 'Core insulation')
|
||||
.replace(/\binsulation\b/gi, 'Core insulation')
|
||||
.replace(/\bfield control\b/gi, 'Field control')
|
||||
.replace(/\bscreen\b/gi, 'Screen')
|
||||
.replace(/\blongitudinal water tightness\b/gi, 'Longitudinal water tightness')
|
||||
@@ -221,7 +222,49 @@ function technicalFullLabel(args: { key: string; excelKey: string; locale: 'en'
|
||||
.replace(/\bmin\.? bending radius, fixed\b/gi, 'Min. bending radius, fixed')
|
||||
.replace(/\bminimum laying temperature\b/gi, 'Minimum laying temperature')
|
||||
.replace(/\bmeter marking\b/gi, 'Meter marking')
|
||||
.replace(/\bpartial discharge\b/gi, 'Partial discharge');
|
||||
.replace(/\bpartial discharge\b/gi, 'Partial discharge')
|
||||
.replace(/\bcapacitance\b/gi, 'Capacitance')
|
||||
.replace(/\binductance\b/gi, 'Inductance')
|
||||
.replace(/\breactance\b/gi, 'Reactance')
|
||||
.replace(/\btest voltage\b/gi, 'Test voltage')
|
||||
.replace(/\brated voltage\b/gi, 'Rated voltage')
|
||||
.replace(/\boperating temperature range\b/gi, 'Operating temperature range')
|
||||
.replace(/\bminimum sheath thickness\b/gi, 'Sheath thickness (min.)')
|
||||
.replace(/\bsheath thickness\b/gi, 'Sheath thickness')
|
||||
.replace(/\bnominal insulation thickness\b/gi, 'Insulation thickness (nom.)')
|
||||
.replace(/\binsulation thickness\b/gi, 'Insulation thickness')
|
||||
.replace(/\bdc resistance at 20\s*°?c\b/gi, 'DC resistance (20 °C)')
|
||||
.replace(/\bouter diameter(?: of cable)?\b/gi, 'Outer diameter')
|
||||
.replace(/\bbending radius\b/gi, 'Bending radius')
|
||||
.replace(/\bpackaging\b/gi, 'Packaging')
|
||||
.replace(/\bce\s*-?conformity\b/gi, 'CE conformity');
|
||||
}
|
||||
|
||||
function technicalValueTranslation(args: { label: string; value: string; locale: 'en' | 'de' }): string {
|
||||
const v = normalizeValue(args.value);
|
||||
if (!v) return '';
|
||||
|
||||
if (args.locale === 'de') {
|
||||
if (/^yes$/i.test(v)) return 'ja';
|
||||
if (/^no$/i.test(v)) return 'nein';
|
||||
if (/^copper$/i.test(v)) return 'Kupfer';
|
||||
if (/^aluminum$/i.test(v)) return 'Aluminium';
|
||||
if (/^black$/i.test(v)) return 'schwarz';
|
||||
if (/^stranded$/i.test(v)) return 'mehrdrähtig';
|
||||
if (/^(\d+)xD$/i.test(v)) return v.replace(/^(\d+)xD$/i, '$1 facher Durchmesser');
|
||||
if (/^XLPE/i.test(v)) return v.replace(/^XLPE/i, 'VPE');
|
||||
return v;
|
||||
}
|
||||
|
||||
if (/^ja$/i.test(v)) return 'yes';
|
||||
if (/^nein$/i.test(v)) return 'no';
|
||||
if (/^kupfer$/i.test(v)) return 'Copper';
|
||||
if (/^aluminium$/i.test(v)) return 'Aluminum';
|
||||
if (/^schwarz$/i.test(v)) return 'black';
|
||||
if (/^mehrdrähtig$/i.test(v)) return 'stranded';
|
||||
if (/^(\d+)xD$/i.test(v)) return v.replace(/^(\d+)xD$/i, '$1 times diameter');
|
||||
if (/^VPE/i.test(v)) return v.replace(/^VPE/i, 'XLPE');
|
||||
return v;
|
||||
}
|
||||
|
||||
function metaFullLabel(args: { key: string; excelKey: string; locale: 'en' | 'de' }): string {
|
||||
@@ -642,11 +685,66 @@ function buildExcelModel(args: { product: ProductData; locale: 'en' | 'de' }): B
|
||||
const unit = normalizeUnit(units[excelKey] || mapping.unit || '');
|
||||
const labelBase = technicalFullLabel({ key: mapping.key, excelKey, locale: args.locale });
|
||||
const label = formatExcelHeaderLabel(labelBase, unit);
|
||||
const value = compactCellForDenseTable(values[0], unit, args.locale);
|
||||
const rawValue = compactCellForDenseTable(values[0], unit, args.locale);
|
||||
const value = technicalValueTranslation({ label: labelBase, value: rawValue, locale: args.locale });
|
||||
if (!technicalItems.find(t => t.label === label)) technicalItems.push({ label, value, unit });
|
||||
}
|
||||
}
|
||||
technicalItems.sort((a, b) => a.label.localeCompare(b.label));
|
||||
const TECHNICAL_DATA_ORDER_DE = [
|
||||
'Leitermaterial',
|
||||
'Leiterklasse',
|
||||
'Aderisolation',
|
||||
'Feldsteuerung',
|
||||
'Schirm',
|
||||
'Längswasserdichtigkeit',
|
||||
'Querwasserdichtigkeit',
|
||||
'Mantelmaterial',
|
||||
'Mantelfarbe',
|
||||
'Flammwidrigkeit',
|
||||
'UV-bestandig',
|
||||
'Max. zulässige Leitertemperatur',
|
||||
'Zul. Kabelaußentemperatur, fest verlegt',
|
||||
'Zul. Kabelaußentemperatur, in Bewegung',
|
||||
'Maximale Kurzschlußtemperatur',
|
||||
'Min. Biegeradius, fest verlegt',
|
||||
'Mindesttemperatur Verlegung',
|
||||
'Metermarkierung',
|
||||
'Teilentladung',
|
||||
];
|
||||
|
||||
const TECHNICAL_DATA_ORDER_EN = [
|
||||
'Conductor material',
|
||||
'Conductor class',
|
||||
'Core insulation',
|
||||
'Field control',
|
||||
'Screen',
|
||||
'Longitudinal water tightness',
|
||||
'Transverse water tightness',
|
||||
'Sheath material',
|
||||
'Sheath color',
|
||||
'Flame retardancy',
|
||||
'UV resistant',
|
||||
'Max. permissible conductor temperature',
|
||||
'Permissible cable outer temperature, fixed',
|
||||
'Permissible cable outer temperature, in motion',
|
||||
'Maximum short-circuit temperature',
|
||||
'Min. bending radius, fixed',
|
||||
'Minimum laying temperature',
|
||||
'Meter marking',
|
||||
'Partial discharge',
|
||||
];
|
||||
|
||||
const order = args.locale === 'de' ? TECHNICAL_DATA_ORDER_DE : TECHNICAL_DATA_ORDER_EN;
|
||||
|
||||
technicalItems.sort((a, b) => {
|
||||
const indexA = order.findIndex(label => a.label.startsWith(label));
|
||||
const indexB = order.findIndex(label => b.label.startsWith(label));
|
||||
|
||||
if (indexA !== -1 && indexB !== -1) return indexA - indexB;
|
||||
if (indexA !== -1) return -1;
|
||||
if (indexB !== -1) return 1;
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
|
||||
const voltageTables: VoltageTableModel[] = [];
|
||||
for (const vKey of voltageKeysSorted) {
|
||||
@@ -957,17 +1055,19 @@ export function buildDatasheetModel(args: { product: ProductData; locale: 'en' |
|
||||
labels,
|
||||
technicalItems: [
|
||||
...(excelModel.ok ? excelModel.technicalItems : []),
|
||||
...(args.locale === 'de'
|
||||
? [
|
||||
{ label: 'Prüfspannung 6/10 kV', value: '21 kV' },
|
||||
{ label: 'Prüfspannung 12/20 kV', value: '42 kV' },
|
||||
{ label: 'Prüfspannung 18/30 kV', value: '63 kV' },
|
||||
]
|
||||
: [
|
||||
{ label: 'Test voltage 6/10 kV', value: '21 kV' },
|
||||
{ label: 'Test voltage 12/20 kV', value: '42 kV' },
|
||||
{ label: 'Test voltage 18/30 kV', value: '63 kV' },
|
||||
]),
|
||||
...(isMediumVoltageProduct(args.product)
|
||||
? args.locale === 'de'
|
||||
? [
|
||||
{ label: 'Prüfspannung 6/10 kV', value: '21 kV' },
|
||||
{ label: 'Prüfspannung 12/20 kV', value: '42 kV' },
|
||||
{ label: 'Prüfspannung 18/30 kV', value: '63 kV' },
|
||||
]
|
||||
: [
|
||||
{ label: 'Test voltage 6/10 kV', value: '21 kV' },
|
||||
{ label: 'Test voltage 12/20 kV', value: '42 kV' },
|
||||
{ label: 'Test voltage 18/30 kV', value: '63 kV' },
|
||||
]
|
||||
: []),
|
||||
],
|
||||
voltageTables,
|
||||
legendItems: crossSectionModel.legendItems || [],
|
||||
|
||||
@@ -6,8 +6,6 @@ Sentry.init({
|
||||
dsn,
|
||||
enabled: Boolean(dsn),
|
||||
tracesSampleRate: 0,
|
||||
// Ensure 500 errors are always captured
|
||||
debug: process.env.NODE_ENV === 'development',
|
||||
// AdjustingreplaysOnErrorSampleRate to 1.0 to capture 100% of errors with replays if enabled
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
replaysSessionSampleRate: 0.1,
|
||||
|
||||
@@ -6,7 +6,5 @@ Sentry.init({
|
||||
dsn,
|
||||
enabled: Boolean(dsn),
|
||||
tracesSampleRate: 0,
|
||||
// Ensure 500 errors are always captured
|
||||
debug: process.env.NODE_ENV === 'development',
|
||||
});
|
||||
|
||||
|
||||
@@ -6,9 +6,5 @@ Sentry.init({
|
||||
dsn,
|
||||
enabled: Boolean(dsn),
|
||||
tracesSampleRate: 0,
|
||||
// Ensure 500 errors are always captured
|
||||
// Next.js 14+ with App Router handles many errors automatically,
|
||||
// but we want to be explicit about capturing all unhandled exceptions.
|
||||
debug: process.env.NODE_ENV === 'development',
|
||||
});
|
||||
|
||||
|
||||
52
tests/og-image.test.ts
Normal file
52
tests/og-image.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
const BASE_URL = process.env.TEST_URL || 'http://localhost:3000';
|
||||
|
||||
describe('OG Image Generation', () => {
|
||||
const locales = ['de', 'en'];
|
||||
const productSlugs = ['nay2y']; // Based on data/products/de/nay2y.mdx
|
||||
|
||||
async function verifyImageResponse(response: Response) {
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toContain('image/png');
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
|
||||
// Check for PNG signature: 89 50 4E 47 0D 0A 1A 0A
|
||||
expect(bytes[0]).toBe(0x89);
|
||||
expect(bytes[1]).toBe(0x50);
|
||||
expect(bytes[2]).toBe(0x4E);
|
||||
expect(bytes[3]).toBe(0x47);
|
||||
|
||||
// Check that the image is not empty and has a reasonable size
|
||||
// A 1200x630 OG image should be at least 4KB
|
||||
expect(bytes.length).toBeGreaterThan(4000);
|
||||
}
|
||||
|
||||
locales.forEach((locale) => {
|
||||
it(`should generate main OG image for ${locale}`, async () => {
|
||||
const url = `${BASE_URL}/${locale}/opengraph-image`;
|
||||
const response = await fetch(url);
|
||||
await verifyImageResponse(response);
|
||||
}, 30000);
|
||||
|
||||
it(`should generate product OG image for ${locale} with slug ${productSlugs[0]}`, async () => {
|
||||
const url = `${BASE_URL}/${locale}/api/og/product?slug=${productSlugs[0]}`;
|
||||
const response = await fetch(url);
|
||||
await verifyImageResponse(response);
|
||||
}, 30000);
|
||||
|
||||
it(`should return 400 for product OG image without slug in ${locale}`, async () => {
|
||||
const url = `${BASE_URL}/${locale}/api/og/product`;
|
||||
const response = await fetch(url);
|
||||
expect(response.status).toBe(400);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
it('should generate blog OG image', async () => {
|
||||
const url = `${BASE_URL}/de/blog/opengraph-image`;
|
||||
const response = await fetch(url);
|
||||
await verifyImageResponse(response);
|
||||
}, 30000);
|
||||
});
|
||||
Reference in New Issue
Block a user