Compare commits

...

5 Commits

Author SHA1 Message Date
6f80e72c1d style: align PDF Page component with KLZ brand Design System
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 12s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Failing after 1m15s
Build & Deploy / 🏗️ Build (push) Has been skipped
Nightly QA / 🔗 Links & Deps (push) Successful in 3m32s
Nightly QA / 🎭 Lighthouse (push) Successful in 5m5s
Nightly QA / ♿ Accessibility (push) Successful in 5m28s
Nightly QA / 🔍 Static Analysis (push) Failing after 6m0s
Nightly QA / 🔔 Notify (push) Successful in 2s
2026-03-05 22:57:16 +01:00
d9334f558d fix(cms): add missing featured_image_id column to products via migration 2026-03-05 22:04:14 +01:00
cb436d31d0 fix(cms): disable migrationDir in production to prevent runtime TS import crashes 2026-03-05 21:51:55 +01:00
4b3ef49522 feat: register PDF download block and fix gotify notifications
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 2m24s
Build & Deploy / 🏗️ Build (push) Successful in 4m3s
Build & Deploy / 🚀 Deploy (push) Successful in 17s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 9m3s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-05 16:56:09 +01:00
301e112488 fix(workflow): remove push trigger from qa.yml to prevent race conditions with deploy 2026-03-05 16:56:09 +01:00
10 changed files with 253 additions and 72 deletions

View File

@@ -576,6 +576,11 @@ jobs:
Deploy: $DEPLOY | Smoke: $SMOKE | Perf: $PERF Deploy: $DEPLOY | Smoke: $SMOKE | Perf: $PERF
$URL" $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 }}" \ curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=$TITLE" \ -F "title=$TITLE" \
-F "message=$MESSAGE" \ -F "message=$MESSAGE" \

View File

@@ -1,8 +1,6 @@
name: Nightly QA name: Nightly QA
on: on:
push:
branches: [main]
schedule: schedule:
- cron: '0 3 * * *' - cron: '0 3 * * *'
workflow_dispatch: workflow_dispatch:
@@ -227,6 +225,11 @@ jobs:
MESSAGE="Static: $STATIC | A11y: $A11Y | Lighthouse: $LIGHTHOUSE | Links: $LINKS MESSAGE="Static: $STATIC | A11y: $A11Y | Lighthouse: $LIGHTHOUSE | Links: $LINKS
${{ env.TARGET_URL }}" ${{ 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 }}" \ curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=$TITLE" \ -F "title=$TITLE" \
-F "message=$MESSAGE" \ -F "message=$MESSAGE" \

View 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>
);
};

View File

@@ -37,6 +37,7 @@ import MeetTheTeam from '@/components/home/MeetTheTeam';
import GallerySection from '@/components/home/GallerySection'; import GallerySection from '@/components/home/GallerySection';
import VideoSection from '@/components/home/VideoSection'; import VideoSection from '@/components/home/VideoSection';
import CTA from '@/components/home/CTA'; import CTA from '@/components/home/CTA';
import { PDFDownloadBlock } from '@/components/PDFDownloadBlock';
/** /**
* Splits a text string on \n and intersperses <br /> elements. * 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} />} {node.fields.content && <RichText data={node.fields.content} converters={jsxConverters} />}
</ProductTabs> </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 ─────────────────────────────────────────── // ─── New Page Blocks ───────────────────────────────────────────
heroSection: ({ node }: any) => { heroSection: ({ node }: any) => {
const f = node.fields; const f = node.fields;

View File

@@ -1,5 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { Document, Page, View, Text, Image, StyleSheet, Font, Link } from '@react-pdf/renderer'; import { Document, Page, View, Text, StyleSheet, Font, Link } from '@react-pdf/renderer';
// Register fonts (using system fonts for now, can be customized) // Register fonts (using system fonts for now, can be customized)
Font.register({ Font.register({
@@ -10,120 +10,120 @@ Font.register({
], ],
}); });
// ─── Brand Tokens ──────────────────────────────────────── // ─── Brand Tokens (matching datasheet) ──────────────────────────────────
const C = { const C = {
navy: '#001a4d', navy: '#001a4d',
navyDeep: '#000d26', navyDeep: '#000d26',
green: '#4da612', green: '#4da612',
greenLight: '#e8f5d8',
white: '#FFFFFF', white: '#FFFFFF',
offWhite: '#f8f9fa', offWhite: '#f8f9fa',
gray100: '#f3f4f6',
gray200: '#e5e7eb', gray200: '#e5e7eb',
gray300: '#d1d5db',
gray400: '#9ca3af', gray400: '#9ca3af',
gray600: '#4b5563', gray600: '#4b5563',
gray900: '#111827', gray900: '#111827',
}; };
const MARGIN = 56; const MARGIN = 56;
const HEADER_H = 52;
const FOOTER_H = 48;
const BODY_TOP = HEADER_H + 20;
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { page: {
color: C.gray900, color: C.gray900,
lineHeight: 1.5, lineHeight: 1.5,
backgroundColor: C.white, backgroundColor: C.white,
paddingTop: BODY_TOP, paddingTop: 0,
paddingBottom: FOOTER_H + 40, paddingBottom: 80,
paddingHorizontal: MARGIN,
fontFamily: 'Helvetica', fontFamily: 'Helvetica',
}, },
header: {
position: 'absolute', // Hero-style header
top: 0, hero: {
left: 0, backgroundColor: C.white,
right: 0, paddingTop: 24,
height: HEADER_H, paddingBottom: 0,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-end',
paddingHorizontal: MARGIN, paddingHorizontal: MARGIN,
paddingBottom: 12, marginBottom: 20,
position: 'relative',
borderBottomWidth: 0, borderBottomWidth: 0,
}, },
logoText: {
fontSize: 16, header: {
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', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
borderTopWidth: 0.5, marginBottom: 16,
borderTopColor: C.gray200,
paddingTop: 8,
}, },
footerText: {
fontSize: 7, logoText: {
color: C.gray400, fontSize: 22,
letterSpacing: 0.8, fontWeight: 700,
color: C.navyDeep,
letterSpacing: 2,
textTransform: 'uppercase', textTransform: 'uppercase',
}, },
docTitle: {
fontSize: 8,
fontWeight: 700,
color: C.green,
letterSpacing: 2,
textTransform: 'uppercase',
},
// Content Area
content: {
paddingHorizontal: MARGIN,
},
pageTitle: { pageTitle: {
fontSize: 24, fontSize: 24,
fontWeight: 700, fontWeight: 700,
color: C.navyDeep, color: C.navyDeep,
marginBottom: 16, marginBottom: 8,
marginTop: 10,
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: -0.5, letterSpacing: -0.5,
}, },
accentBar: { accentBar: {
width: 40, width: 30,
height: 3, height: 2,
backgroundColor: C.green, backgroundColor: C.green,
marginBottom: 24, marginBottom: 20,
borderRadius: 1,
}, },
// Lexical Elements // Lexical Elements
paragraph: { paragraph: {
fontSize: 10, fontSize: 10,
color: C.gray600, color: C.gray600,
lineHeight: 1.6, lineHeight: 1.7,
marginBottom: 12, marginBottom: 12,
}, },
heading1: { heading1: {
fontSize: 18, fontSize: 16,
fontWeight: 700,
color: C.navyDeep,
marginTop: 20,
marginBottom: 10,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
heading2: {
fontSize: 12,
fontWeight: 700, fontWeight: 700,
color: C.navyDeep, color: C.navyDeep,
marginTop: 16, marginTop: 16,
marginBottom: 8, marginBottom: 8,
}, },
heading2: {
fontSize: 14,
fontWeight: 700,
color: C.navyDeep,
marginTop: 14,
marginBottom: 6,
},
heading3: { heading3: {
fontSize: 12, fontSize: 10,
fontWeight: 700, fontWeight: 700,
color: C.navyDeep, color: C.navyDeep,
marginTop: 12, marginTop: 12,
marginBottom: 4, marginBottom: 6,
}, },
list: { list: {
marginBottom: 12, marginBottom: 12,
@@ -137,12 +137,13 @@ const styles = StyleSheet.create({
width: 12, width: 12,
fontSize: 10, fontSize: 10,
color: C.green, color: C.green,
fontWeight: 700,
}, },
listItemContent: { listItemContent: {
flex: 1, flex: 1,
fontSize: 10, fontSize: 10,
color: C.gray600, color: C.gray600,
lineHeight: 1.6, lineHeight: 1.7,
}, },
link: { link: {
color: C.green, color: C.green,
@@ -154,7 +155,37 @@ const styles = StyleSheet.create({
color: C.navyDeep, color: C.navyDeep,
}, },
textItalic: { textItalic: {
fontStyle: 'italic', // Not actually working without proper font set, but we will fallback fontStyle: 'italic',
},
// Footer — matches brochure style
footer: {
position: 'absolute',
bottom: 28,
left: MARGIN,
right: MARGIN,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingTop: 12,
borderTopWidth: 2,
borderTopColor: C.green,
},
footerText: {
fontSize: 7,
color: C.gray400,
fontWeight: 400,
textTransform: 'uppercase',
letterSpacing: 0.8,
},
footerBrand: {
fontSize: 9,
fontWeight: 700,
color: C.navyDeep,
textTransform: 'uppercase',
letterSpacing: 1.5,
}, },
}); });
@@ -232,11 +263,10 @@ const renderLexicalNode = (node: any, idx: number): React.ReactNode => {
} }
case 'linebreak': { case 'linebreak': {
// React-PDF doesn't handle `<br/>`, but a newline char usually works inside `<Text>`.
return <Text key={idx}>{'\n'}</Text>; return <Text key={idx}>{'\n'}</Text>;
} }
// Ignore payload blocks recursively to avoid crashing, as pages should mainly use rich text // Ignore payload blocks recursively to avoid crashing
case 'block': case 'block':
return null; return null;
@@ -267,20 +297,30 @@ export const PDFPage: React.FC<PDFPageProps> = ({ page, locale = 'de' }) => {
return ( return (
<Document> <Document>
<Page size="A4" style={styles.page}> <Page size="A4" style={styles.page}>
<View style={styles.header} fixed> {/* Hero Header */}
<Text style={styles.logoText}>KLZ</Text> <View style={styles.hero} fixed>
<Text style={styles.docTitleLabel}>{locale === 'en' ? 'Document' : 'Dokument'}</Text> <View style={styles.header}>
<View>
<Text style={styles.logoText}>KLZ</Text>
</View>
<Text style={styles.docTitle}>{locale === 'en' ? 'Document' : 'Dokument'}</Text>
</View>
</View> </View>
<Text style={styles.pageTitle}>{page.title}</Text> <View style={styles.content}>
<View style={styles.accentBar} /> <Text style={styles.pageTitle}>{page.title}</Text>
<View style={styles.accentBar} />
<View> <View>
{page.content?.root?.children?.map((node: any, i: number) => renderLexicalNode(node, i))} {page.content?.root?.children?.map((node: any, i: number) =>
renderLexicalNode(node, i),
)}
</View>
</View> </View>
{/* Minimal footer */}
<View style={styles.footer} fixed> <View style={styles.footer} fixed>
<Text style={styles.footerText}>KLZ CABLES</Text> <Text style={styles.footerBrand}>KLZ CABLES</Text>
<Text style={styles.footerText}>{dateStr}</Text> <Text style={styles.footerText}>{dateStr}</Text>
</View> </View>
</Page> </Page>

View File

@@ -71,6 +71,8 @@ export default buildConfig({
}, },
db: postgresAdapter({ db: postgresAdapter({
prodMigrations: migrations, prodMigrations: migrations,
migrationDir:
process.env.NODE_ENV === 'production' ? undefined : path.resolve(dirname, 'src/migrations'),
pool: { pool: {
connectionString: connectionString:
process.env.DATABASE_URI || process.env.DATABASE_URI ||

View File

@@ -0,0 +1,52 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres';
export async function up({ db }: MigrateUpArgs): Promise<void> {
// Add featured_image_id to products and _products_v
await db.execute(sql`
ALTER TABLE "products" ADD COLUMN IF NOT EXISTS "featured_image_id" integer;
`);
await db.execute(sql`
ALTER TABLE "_products_v" ADD COLUMN IF NOT EXISTS "version_featured_image_id" integer;
`);
// Add foreign key constraints
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "products" ADD CONSTRAINT "products_featured_image_id_media_id_fk" FOREIGN KEY ("featured_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN null; END $$;
`);
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "_products_v" ADD CONSTRAINT "_products_v_version_featured_image_id_media_id_fk" FOREIGN KEY ("version_featured_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN null; END $$;
`);
// Add indexes
await db.execute(sql`
CREATE INDEX IF NOT EXISTS "products_featured_image_idx" ON "products" USING btree ("featured_image_id");
`);
await db.execute(sql`
CREATE INDEX IF NOT EXISTS "_products_v_version_version_featured_image_idx" ON "_products_v" USING btree ("version_featured_image_id");
`);
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "products" DROP CONSTRAINT IF EXISTS "products_featured_image_id_media_id_fk";
`);
await db.execute(sql`
ALTER TABLE "_products_v" DROP CONSTRAINT IF EXISTS "_products_v_version_featured_image_id_media_id_fk";
`);
await db.execute(sql`
DROP INDEX IF EXISTS "products_featured_image_idx";
`);
await db.execute(sql`
DROP INDEX IF EXISTS "_products_v_version_version_featured_image_idx";
`);
await db.execute(sql`
ALTER TABLE "products" DROP COLUMN IF EXISTS "featured_image_id";
`);
await db.execute(sql`
ALTER TABLE "_products_v" DROP COLUMN IF EXISTS "version_featured_image_id";
`);
}

View File

@@ -2,6 +2,7 @@ import * as migration_20260223_195005_products_collection from './20260223_19500
import * as migration_20260223_195151_remove_sku_unique from './20260223_195151_remove_sku_unique'; import * as migration_20260223_195151_remove_sku_unique from './20260223_195151_remove_sku_unique';
import * as migration_20260225_003500_add_pages_collection from './20260225_003500_add_pages_collection'; import * as migration_20260225_003500_add_pages_collection from './20260225_003500_add_pages_collection';
import * as migration_20260225_175000_native_localization from './20260225_175000_native_localization'; import * as migration_20260225_175000_native_localization from './20260225_175000_native_localization';
import * as migration_20260305_215000_products_featured_image from './20260305_215000_products_featured_image';
export const migrations = [ export const migrations = [
{ {
@@ -24,4 +25,9 @@ export const migrations = [
down: migration_20260225_175000_native_localization.down, down: migration_20260225_175000_native_localization.down,
name: '20260225_175000_native_localization', name: '20260225_175000_native_localization',
}, },
{
up: migration_20260305_215000_products_featured_image.up,
down: migration_20260305_215000_products_featured_image.down,
name: '20260305_215000_products_featured_image',
},
]; ];

View 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' },
],
},
],
};

View File

@@ -16,6 +16,7 @@ import { StickyNarrative } from './StickyNarrative';
import { TeamProfile } from './TeamProfile'; import { TeamProfile } from './TeamProfile';
import { TechnicalGrid } from './TechnicalGrid'; import { TechnicalGrid } from './TechnicalGrid';
import { VisualLinkPreview } from './VisualLinkPreview'; import { VisualLinkPreview } from './VisualLinkPreview';
import { PDFDownload } from './PDFDownload';
import { homeBlocksArray } from './HomeBlocks'; import { homeBlocksArray } from './HomeBlocks';
export const payloadBlocks = [ export const payloadBlocks = [
@@ -38,4 +39,5 @@ export const payloadBlocks = [
TeamProfile, TeamProfile,
TechnicalGrid, TechnicalGrid,
VisualLinkPreview, VisualLinkPreview,
PDFDownload,
]; ];