Compare commits
3 Commits
feature/ex
...
4b3ef49522
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b3ef49522 | |||
| 301e112488 | |||
| 2d4919cc1f |
@@ -576,6 +576,11 @@ jobs:
|
||||
Deploy: $DEPLOY | Smoke: $SMOKE | Perf: $PERF
|
||||
$URL"
|
||||
|
||||
if [[ -z "${{ secrets.GOTIFY_URL }}" || -z "${{ secrets.GOTIFY_TOKEN }}" ]]; then
|
||||
echo "⚠️ Gotify credentials missing, skipping notification."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||
-F "title=$TITLE" \
|
||||
-F "message=$MESSAGE" \
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
name: Nightly QA
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '0 3 * * *'
|
||||
workflow_dispatch:
|
||||
@@ -227,6 +225,11 @@ jobs:
|
||||
MESSAGE="Static: $STATIC | A11y: $A11Y | Lighthouse: $LIGHTHOUSE | Links: $LINKS
|
||||
${{ env.TARGET_URL }}"
|
||||
|
||||
if [[ -z "${{ secrets.GOTIFY_URL }}" || -z "${{ secrets.GOTIFY_TOKEN }}" ]]; then
|
||||
echo "⚠️ Gotify credentials missing, skipping notification."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||
-F "title=$TITLE" \
|
||||
-F "message=$MESSAGE" \
|
||||
|
||||
64
app/api/pages/[slug]/pdf/route.tsx
Normal file
64
app/api/pages/[slug]/pdf/route.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPayload } from 'payload';
|
||||
import configPromise from '@payload-config';
|
||||
import { renderToStream } from '@react-pdf/renderer';
|
||||
import React from 'react';
|
||||
import { PDFPage } from '@/lib/pdf-page';
|
||||
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
|
||||
try {
|
||||
const { slug } = await params;
|
||||
|
||||
// Get Payload App
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
// Fetch the page
|
||||
const pages = await payload.find({
|
||||
collection: 'pages',
|
||||
where: {
|
||||
slug: { equals: slug },
|
||||
_status: { equals: 'published' },
|
||||
},
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (pages.totalDocs === 0) {
|
||||
return new NextResponse('Page not found', { status: 404 });
|
||||
}
|
||||
|
||||
const page = pages.docs[0];
|
||||
|
||||
// Determine locale from searchParams or default to 'de'
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
const locale = (searchParams.get('locale') as 'en' | 'de') || 'de';
|
||||
|
||||
// Render the React-PDF document into a stream
|
||||
const stream = await renderToStream(<PDFPage page={page} locale={locale} />);
|
||||
|
||||
// Pipe the Node.js Readable stream into a valid fetch/Web Response stream
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
stream.on('data', (chunk) => controller.enqueue(chunk));
|
||||
stream.on('end', () => controller.close());
|
||||
stream.on('error', (err) => controller.error(err));
|
||||
},
|
||||
cancel() {
|
||||
(stream as any).destroy?.();
|
||||
},
|
||||
});
|
||||
|
||||
const filename = `${slug}.pdf`;
|
||||
|
||||
return new NextResponse(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
// Cache control if needed, skip for now.
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error generating PDF:', error);
|
||||
return new NextResponse('Internal Server Error', { status: 500 });
|
||||
}
|
||||
}
|
||||
34
components/PDFDownloadBlock.tsx
Normal file
34
components/PDFDownloadBlock.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
export const PDFDownloadBlock: React.FC<{ label: string; style: string }> = ({ label, style }) => {
|
||||
const pathname = usePathname();
|
||||
|
||||
// Extract slug from pathname
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
// Pathname is usually /[locale]/[slug] or /[locale]/products/[slug]
|
||||
// We want the page slug.
|
||||
const slug = segments[segments.length - 1] || 'home';
|
||||
|
||||
const href = `/api/pages/${slug}/pdf`;
|
||||
|
||||
return (
|
||||
<div className="my-8">
|
||||
<a
|
||||
href={href}
|
||||
className={`inline-flex items-center px-8 py-3.5 font-bold rounded-full transition-all duration-300 shadow-lg hover:shadow-xl group ${
|
||||
style === 'primary'
|
||||
? 'bg-primary text-white hover:bg-primary-dark'
|
||||
: style === 'secondary'
|
||||
? 'bg-accent text-primary-dark hover:bg-neutral-light'
|
||||
: 'border-2 border-primary text-primary hover:bg-primary hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-3 transition-transform group-hover:scale-12 bit-bounce">📄</span>
|
||||
{label}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -37,6 +37,7 @@ import MeetTheTeam from '@/components/home/MeetTheTeam';
|
||||
import GallerySection from '@/components/home/GallerySection';
|
||||
import VideoSection from '@/components/home/VideoSection';
|
||||
import CTA from '@/components/home/CTA';
|
||||
import { PDFDownloadBlock } from '@/components/PDFDownloadBlock';
|
||||
|
||||
/**
|
||||
* Splits a text string on \n and intersperses <br /> elements.
|
||||
@@ -429,6 +430,12 @@ const jsxConverters: JSXConverters = {
|
||||
{node.fields.content && <RichText data={node.fields.content} converters={jsxConverters} />}
|
||||
</ProductTabs>
|
||||
),
|
||||
pdfDownload: ({ node }: any) => (
|
||||
<PDFDownloadBlock label={node.fields.label} style={node.fields.style} />
|
||||
),
|
||||
'block-pdfDownload': ({ node }: any) => (
|
||||
<PDFDownloadBlock label={node.fields.label} style={node.fields.style} />
|
||||
),
|
||||
// ─── New Page Blocks ───────────────────────────────────────────
|
||||
heroSection: ({ node }: any) => {
|
||||
const f = node.fields;
|
||||
|
||||
289
lib/pdf-page.tsx
Normal file
289
lib/pdf-page.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
import * as React from 'react';
|
||||
import { Document, Page, View, Text, Image, StyleSheet, Font, Link } from '@react-pdf/renderer';
|
||||
|
||||
// Register fonts (using system fonts for now, can be customized)
|
||||
Font.register({
|
||||
family: 'Helvetica',
|
||||
fonts: [
|
||||
{ src: '/fonts/Helvetica.ttf', fontWeight: 400 },
|
||||
{ src: '/fonts/Helvetica-Bold.ttf', fontWeight: 700 },
|
||||
],
|
||||
});
|
||||
|
||||
// ─── Brand Tokens ────────────────────────────────────────
|
||||
|
||||
const C = {
|
||||
navy: '#001a4d',
|
||||
navyDeep: '#000d26',
|
||||
green: '#4da612',
|
||||
white: '#FFFFFF',
|
||||
offWhite: '#f8f9fa',
|
||||
gray200: '#e5e7eb',
|
||||
gray400: '#9ca3af',
|
||||
gray600: '#4b5563',
|
||||
gray900: '#111827',
|
||||
};
|
||||
|
||||
const MARGIN = 56;
|
||||
const HEADER_H = 52;
|
||||
const FOOTER_H = 48;
|
||||
const BODY_TOP = HEADER_H + 20;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
color: C.gray900,
|
||||
lineHeight: 1.5,
|
||||
backgroundColor: C.white,
|
||||
paddingTop: BODY_TOP,
|
||||
paddingBottom: FOOTER_H + 40,
|
||||
paddingHorizontal: MARGIN,
|
||||
fontFamily: 'Helvetica',
|
||||
},
|
||||
header: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: HEADER_H,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-end',
|
||||
paddingHorizontal: MARGIN,
|
||||
paddingBottom: 12,
|
||||
borderBottomWidth: 0,
|
||||
},
|
||||
logoText: {
|
||||
fontSize: 16,
|
||||
fontWeight: 700,
|
||||
color: C.navy,
|
||||
},
|
||||
docTitleLabel: {
|
||||
fontSize: 7,
|
||||
fontWeight: 700,
|
||||
color: C.gray400,
|
||||
letterSpacing: 1.2,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 20,
|
||||
left: MARGIN,
|
||||
right: MARGIN,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
borderTopWidth: 0.5,
|
||||
borderTopColor: C.gray200,
|
||||
paddingTop: 8,
|
||||
},
|
||||
footerText: {
|
||||
fontSize: 7,
|
||||
color: C.gray400,
|
||||
letterSpacing: 0.8,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
pageTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: 700,
|
||||
color: C.navyDeep,
|
||||
marginBottom: 16,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
accentBar: {
|
||||
width: 40,
|
||||
height: 3,
|
||||
backgroundColor: C.green,
|
||||
marginBottom: 24,
|
||||
},
|
||||
|
||||
// Lexical Elements
|
||||
paragraph: {
|
||||
fontSize: 10,
|
||||
color: C.gray600,
|
||||
lineHeight: 1.6,
|
||||
marginBottom: 12,
|
||||
},
|
||||
heading1: {
|
||||
fontSize: 18,
|
||||
fontWeight: 700,
|
||||
color: C.navyDeep,
|
||||
marginTop: 16,
|
||||
marginBottom: 8,
|
||||
},
|
||||
heading2: {
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
color: C.navyDeep,
|
||||
marginTop: 14,
|
||||
marginBottom: 6,
|
||||
},
|
||||
heading3: {
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: C.navyDeep,
|
||||
marginTop: 12,
|
||||
marginBottom: 4,
|
||||
},
|
||||
list: {
|
||||
marginBottom: 12,
|
||||
marginLeft: 8,
|
||||
},
|
||||
listItem: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 4,
|
||||
},
|
||||
listItemBullet: {
|
||||
width: 12,
|
||||
fontSize: 10,
|
||||
color: C.green,
|
||||
},
|
||||
listItemContent: {
|
||||
flex: 1,
|
||||
fontSize: 10,
|
||||
color: C.gray600,
|
||||
lineHeight: 1.6,
|
||||
},
|
||||
link: {
|
||||
color: C.green,
|
||||
textDecoration: 'none',
|
||||
},
|
||||
textBold: {
|
||||
fontWeight: 700,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
color: C.navyDeep,
|
||||
},
|
||||
textItalic: {
|
||||
fontStyle: 'italic', // Not actually working without proper font set, but we will fallback
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Lexical to React-PDF Renderer ────────────────────────────────
|
||||
|
||||
const renderLexicalNode = (node: any, idx: number): React.ReactNode => {
|
||||
if (!node) return null;
|
||||
|
||||
switch (node.type) {
|
||||
case 'text': {
|
||||
const format = node.format || 0;
|
||||
const isBold = (format & 1) !== 0;
|
||||
const isItalic = (format & 2) !== 0;
|
||||
|
||||
let elementStyle: any = {};
|
||||
if (isBold) elementStyle = { ...elementStyle, ...styles.textBold };
|
||||
if (isItalic) elementStyle = { ...elementStyle, ...styles.textItalic };
|
||||
|
||||
return (
|
||||
<Text key={idx} style={elementStyle}>
|
||||
{node.text}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
case 'paragraph': {
|
||||
return (
|
||||
<Text key={idx} style={styles.paragraph}>
|
||||
{node.children?.map((child: any, i: number) => renderLexicalNode(child, i))}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
case 'heading': {
|
||||
let hStyle = styles.heading3;
|
||||
if (node.tag === 'h1') hStyle = styles.heading1;
|
||||
if (node.tag === 'h2') hStyle = styles.heading2;
|
||||
|
||||
return (
|
||||
<Text key={idx} style={hStyle}>
|
||||
{node.children?.map((child: any, i: number) => renderLexicalNode(child, i))}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
case 'list': {
|
||||
return (
|
||||
<View key={idx} style={styles.list}>
|
||||
{node.children?.map((child: any, i: number) => {
|
||||
if (child.type === 'listitem') {
|
||||
return (
|
||||
<View key={i} style={styles.listItem}>
|
||||
<Text style={styles.listItemBullet}>
|
||||
{node.listType === 'number' ? `${i + 1}.` : '•'}
|
||||
</Text>
|
||||
<Text style={styles.listItemContent}>
|
||||
{child.children?.map((c: any, ci: number) => renderLexicalNode(c, ci))}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return renderLexicalNode(child, i);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
case 'link': {
|
||||
const href = node.fields?.url || node.url || '#';
|
||||
return (
|
||||
<Link key={idx} src={href} style={styles.link}>
|
||||
{node.children?.map((child: any, i: number) => renderLexicalNode(child, i))}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
case 'linebreak': {
|
||||
// React-PDF doesn't handle `<br/>`, but a newline char usually works inside `<Text>`.
|
||||
return <Text key={idx}>{'\n'}</Text>;
|
||||
}
|
||||
|
||||
// Ignore payload blocks recursively to avoid crashing, as pages should mainly use rich text
|
||||
case 'block':
|
||||
return null;
|
||||
|
||||
default:
|
||||
if (node.children) {
|
||||
return (
|
||||
<Text key={idx}>
|
||||
{node.children.map((child: any, i: number) => renderLexicalNode(child, i))}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
interface PDFPageProps {
|
||||
page: any;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export const PDFPage: React.FC<PDFPageProps> = ({ page, locale = 'de' }) => {
|
||||
const dateStr = new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<Page size="A4" style={styles.page}>
|
||||
<View style={styles.header} fixed>
|
||||
<Text style={styles.logoText}>KLZ</Text>
|
||||
<Text style={styles.docTitleLabel}>{locale === 'en' ? 'Document' : 'Dokument'}</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.pageTitle}>{page.title}</Text>
|
||||
<View style={styles.accentBar} />
|
||||
|
||||
<View>
|
||||
{page.content?.root?.children?.map((node: any, i: number) => renderLexicalNode(node, i))}
|
||||
</View>
|
||||
|
||||
<View style={styles.footer} fixed>
|
||||
<Text style={styles.footerText}>KLZ CABLES</Text>
|
||||
<Text style={styles.footerText}>{dateStr}</Text>
|
||||
</View>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
};
|
||||
30
src/payload/blocks/PDFDownload.ts
Normal file
30
src/payload/blocks/PDFDownload.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Block } from 'payload';
|
||||
|
||||
export const PDFDownload: Block = {
|
||||
slug: 'pdfDownload',
|
||||
labels: {
|
||||
singular: 'PDF Download',
|
||||
plural: 'PDF Downloads',
|
||||
},
|
||||
admin: {},
|
||||
fields: [
|
||||
{
|
||||
name: 'label',
|
||||
type: 'text',
|
||||
label: 'Button Beschriftung',
|
||||
required: true,
|
||||
localized: true,
|
||||
defaultValue: 'Als PDF herunterladen',
|
||||
},
|
||||
{
|
||||
name: 'style',
|
||||
type: 'select',
|
||||
defaultValue: 'primary',
|
||||
options: [
|
||||
{ label: 'Primary', value: 'primary' },
|
||||
{ label: 'Secondary', value: 'secondary' },
|
||||
{ label: 'Outline', value: 'outline' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -16,6 +16,7 @@ import { StickyNarrative } from './StickyNarrative';
|
||||
import { TeamProfile } from './TeamProfile';
|
||||
import { TechnicalGrid } from './TechnicalGrid';
|
||||
import { VisualLinkPreview } from './VisualLinkPreview';
|
||||
import { PDFDownload } from './PDFDownload';
|
||||
import { homeBlocksArray } from './HomeBlocks';
|
||||
|
||||
export const payloadBlocks = [
|
||||
@@ -38,4 +39,5 @@ export const payloadBlocks = [
|
||||
TeamProfile,
|
||||
TechnicalGrid,
|
||||
VisualLinkPreview,
|
||||
PDFDownload,
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user