Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f80e72c1d | |||
| d9334f558d | |||
| cb436d31d0 | |||
| 4b3ef49522 | |||
| 301e112488 | |||
| 2d4919cc1f | |||
| 6a748a3ac8 | |||
| d69e0eebe6 | |||
| 1577bfd2ec | |||
| 6440d893f0 | |||
| d8e3c7d9a3 | |||
| aa14f39dba | |||
| 1cfc0523f3 | |||
| 3ff20fd2c9 | |||
| 549ee34490 | |||
| 8a8e30400c | |||
| 4faed38f47 | |||
| 1e0886144f | |||
| c933d9b886 | |||
| 5c56d8babf | |||
| c4c6fb3b07 | |||
| ff685b9933 | |||
| 980258af5c |
@@ -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" \
|
||||||
|
|||||||
@@ -5,13 +5,232 @@ on:
|
|||||||
- cron: '0 3 * * *'
|
- cron: '0 3 * * *'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
TARGET_URL: 'https://testing.klz-cables.com'
|
||||||
|
PROJECT_NAME: 'klz-2026'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
call-qa-workflow:
|
# ────────────────────────────────────────────────────
|
||||||
uses: mmintel/at-mintel/.gitea/workflows/quality-assurance-template.yml@main
|
# 1. Static Checks (HTML, Assets, HTTP)
|
||||||
with:
|
# ────────────────────────────────────────────────────
|
||||||
TARGET_URL: 'https://testing.klz-cables.com'
|
static:
|
||||||
PROJECT_NAME: 'klz-2026'
|
name: 🔍 Static Analysis
|
||||||
secrets:
|
runs-on: docker
|
||||||
GOTIFY_URL: ${{ secrets.GOTIFY_URL }}
|
container:
|
||||||
GOTIFY_TOKEN: ${{ secrets.GOTIFY_TOKEN }}
|
image: catthehacker/ubuntu:act-latest
|
||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'lassmichrein' }}
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- name: 🔐 Registry Auth
|
||||||
|
run: |
|
||||||
|
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||||
|
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||||
|
- name: 📦 Cache node_modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
id: cache-deps
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
- name: Install
|
||||||
|
if: steps.cache-deps.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
pnpm store prune
|
||||||
|
pnpm install --no-frozen-lockfile
|
||||||
|
- name: 🌐 Install Chrome & Dependencies
|
||||||
|
run: |
|
||||||
|
apt-get update && apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libgbm1 libasound2t64 libpango-1.0-0 libcairo2
|
||||||
|
npx puppeteer browsers install chrome
|
||||||
|
- name: 🌐 HTML Validation
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||||
|
run: pnpm run check:html
|
||||||
|
- name: 🖼️ Broken Assets
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||||
|
ASSET_CHECK_LIMIT: 10
|
||||||
|
run: pnpm run check:assets
|
||||||
|
- name: 🔒 HTTP Headers
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||||
|
run: pnpm run check:http
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
# 2. Accessibility (WCAG)
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
a11y:
|
||||||
|
name: ♿ Accessibility
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- name: 🔐 Registry Auth
|
||||||
|
run: |
|
||||||
|
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||||
|
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||||
|
- name: 📦 Cache node_modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
id: cache-deps
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
- name: Install
|
||||||
|
if: steps.cache-deps.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
pnpm store prune
|
||||||
|
pnpm install --no-frozen-lockfile
|
||||||
|
- name: 🌐 Install Chrome & Dependencies
|
||||||
|
run: |
|
||||||
|
apt-get update && apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libgbm1 libasound2t64 libpango-1.0-0 libcairo2
|
||||||
|
npx puppeteer browsers install chrome
|
||||||
|
- name: ♿ WCAG Scan
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||||
|
run: pnpm run check:wcag
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
# 3. Performance (Lighthouse)
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
lighthouse:
|
||||||
|
name: 🎭 Lighthouse
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- name: 🔐 Registry Auth
|
||||||
|
run: |
|
||||||
|
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||||
|
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||||
|
- name: 📦 Cache node_modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
id: cache-deps
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
- name: Install
|
||||||
|
if: steps.cache-deps.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
pnpm store prune
|
||||||
|
pnpm install --no-frozen-lockfile
|
||||||
|
- name: 🌐 Install Chrome & Dependencies
|
||||||
|
run: |
|
||||||
|
apt-get update && apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libgbm1 libasound2t64 libpango-1.0-0 libcairo2
|
||||||
|
npx puppeteer browsers install chrome
|
||||||
|
- name: 🎭 Desktop
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||||
|
PAGESPEED_LIMIT: 5
|
||||||
|
run: pnpm run pagespeed:test -- --collect.settings.preset=desktop
|
||||||
|
- name: 📱 Mobile
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||||
|
PAGESPEED_LIMIT: 5
|
||||||
|
run: pnpm run pagespeed:test -- --collect.settings.preset=mobile
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
# 4. Link Check & Dependency Audit
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
links:
|
||||||
|
name: 🔗 Links & Deps
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- name: 🔐 Registry Auth
|
||||||
|
run: |
|
||||||
|
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||||
|
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||||
|
- name: 📦 Cache node_modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
id: cache-deps
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
- name: Install
|
||||||
|
if: steps.cache-deps.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
pnpm store prune
|
||||||
|
pnpm install --no-frozen-lockfile
|
||||||
|
- name: 📦 Depcheck
|
||||||
|
continue-on-error: true
|
||||||
|
run: pnpm dlx depcheck --ignores="*eslint*,*typescript*,*tailwindcss*,*postcss*,*prettier*,*@types/*,*husky*,*lint-staged*,*@next/*,*@lhci/*,*commitlint*,*cspell*,*rimraf*,*@payloadcms/*,*start-server-and-test*,*html-validate*,*critters*,*dotenv*,*turbo*" || true
|
||||||
|
- name: 🔗 Lychee Link Check
|
||||||
|
uses: lycheeverse/lychee-action@v2
|
||||||
|
with:
|
||||||
|
args: --accept 200,204,429 --timeout 15 --insecure --exclude "file://*" --exclude "https://logs.infra.***.me/*" --exclude "https://git.infra.***.me/*" --exclude "https://umami.is/docs/best-practices" --exclude "https://***/*" .
|
||||||
|
fail: true
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
# 5. Notification
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
notify:
|
||||||
|
name: 🔔 Notify
|
||||||
|
needs: [static, a11y, lighthouse, links]
|
||||||
|
if: always()
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- name: 🔔 Gotify
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
STATIC="${{ needs.static.result }}"
|
||||||
|
A11Y="${{ needs.a11y.result }}"
|
||||||
|
LIGHTHOUSE="${{ needs.lighthouse.result }}"
|
||||||
|
LINKS="${{ needs.links.result }}"
|
||||||
|
|
||||||
|
if [[ "$STATIC" != "success" || "$LIGHTHOUSE" != "success" ]]; then
|
||||||
|
PRIORITY=8
|
||||||
|
EMOJI="🚨"
|
||||||
|
STATUS="Failed"
|
||||||
|
else
|
||||||
|
PRIORITY=2
|
||||||
|
EMOJI="✅"
|
||||||
|
STATUS="Passed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
TITLE="$EMOJI ${{ env.PROJECT_NAME }} QA $STATUS"
|
||||||
|
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" \
|
||||||
|
-F "priority=$PRIORITY" || true
|
||||||
|
|||||||
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 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;
|
||||||
@@ -1111,14 +1118,6 @@ const jsxConverters: JSXConverters = {
|
|||||||
<CTA data={node?.fields} />
|
<CTA data={node?.fields} />
|
||||||
</Reveal>
|
</Reveal>
|
||||||
),
|
),
|
||||||
'block-email': ({ node }: any) => {
|
|
||||||
const { email, label } = node.fields;
|
|
||||||
return <ObfuscatedEmail email={email}>{label || email}</ObfuscatedEmail>;
|
|
||||||
},
|
|
||||||
'block-phone': ({ node }: any) => {
|
|
||||||
const { phone, label } = node.fields;
|
|
||||||
return <ObfuscatedPhone phone={phone}>{label || phone}</ObfuscatedPhone>;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
// Custom converter for the Payload "upload" Lexical node (Media collection)
|
// Custom converter for the Payload "upload" Lexical node (Media collection)
|
||||||
// This natively reconstructs Next.js <Image /> tags pointing to the focal-point cropped sizes
|
// This natively reconstructs Next.js <Image /> tags pointing to the focal-point cropped sizes
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
{
|
{
|
||||||
"ci": {
|
"ci": {
|
||||||
"collect": {
|
"collect": {
|
||||||
"numberOfRuns": 3,
|
"numberOfRuns": 1,
|
||||||
"settings": {
|
"settings": {
|
||||||
"preset": "desktop",
|
"preset": "desktop",
|
||||||
"onlyCategories": [
|
"onlyCategories": ["performance", "accessibility", "best-practices", "seo"],
|
||||||
"performance",
|
|
||||||
"accessibility",
|
|
||||||
"best-practices",
|
|
||||||
"seo"
|
|
||||||
],
|
|
||||||
"chromeFlags": "--no-sandbox --disable-setuid-sandbox"
|
"chromeFlags": "--no-sandbox --disable-setuid-sandbox"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -18,7 +13,7 @@
|
|||||||
"categories:performance": [
|
"categories:performance": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
"minScore": 0.9
|
"minScore": 0.7
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"categories:accessibility": [
|
"categories:accessibility": [
|
||||||
@@ -54,4 +49,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
329
lib/pdf-page.tsx
Normal file
329
lib/pdf-page.tsx
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Document, Page, View, Text, 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 (matching datasheet) ──────────────────────────────────
|
||||||
|
const C = {
|
||||||
|
navy: '#001a4d',
|
||||||
|
navyDeep: '#000d26',
|
||||||
|
green: '#4da612',
|
||||||
|
greenLight: '#e8f5d8',
|
||||||
|
white: '#FFFFFF',
|
||||||
|
offWhite: '#f8f9fa',
|
||||||
|
gray100: '#f3f4f6',
|
||||||
|
gray200: '#e5e7eb',
|
||||||
|
gray300: '#d1d5db',
|
||||||
|
gray400: '#9ca3af',
|
||||||
|
gray600: '#4b5563',
|
||||||
|
gray900: '#111827',
|
||||||
|
};
|
||||||
|
|
||||||
|
const MARGIN = 56;
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
page: {
|
||||||
|
color: C.gray900,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
backgroundColor: C.white,
|
||||||
|
paddingTop: 0,
|
||||||
|
paddingBottom: 80,
|
||||||
|
fontFamily: 'Helvetica',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Hero-style header
|
||||||
|
hero: {
|
||||||
|
backgroundColor: C.white,
|
||||||
|
paddingTop: 24,
|
||||||
|
paddingBottom: 0,
|
||||||
|
paddingHorizontal: MARGIN,
|
||||||
|
marginBottom: 20,
|
||||||
|
position: 'relative',
|
||||||
|
borderBottomWidth: 0,
|
||||||
|
},
|
||||||
|
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
|
||||||
|
logoText: {
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: C.navyDeep,
|
||||||
|
letterSpacing: 2,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
},
|
||||||
|
|
||||||
|
docTitle: {
|
||||||
|
fontSize: 8,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: C.green,
|
||||||
|
letterSpacing: 2,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Content Area
|
||||||
|
content: {
|
||||||
|
paddingHorizontal: MARGIN,
|
||||||
|
},
|
||||||
|
|
||||||
|
pageTitle: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: C.navyDeep,
|
||||||
|
marginBottom: 8,
|
||||||
|
marginTop: 10,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
},
|
||||||
|
|
||||||
|
accentBar: {
|
||||||
|
width: 30,
|
||||||
|
height: 2,
|
||||||
|
backgroundColor: C.green,
|
||||||
|
marginBottom: 20,
|
||||||
|
borderRadius: 1,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Lexical Elements
|
||||||
|
paragraph: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: C.gray600,
|
||||||
|
lineHeight: 1.7,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
heading1: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: C.navyDeep,
|
||||||
|
marginTop: 20,
|
||||||
|
marginBottom: 10,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
heading2: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: C.navyDeep,
|
||||||
|
marginTop: 16,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
heading3: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: C.navyDeep,
|
||||||
|
marginTop: 12,
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
marginBottom: 12,
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
|
listItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
listItemBullet: {
|
||||||
|
width: 12,
|
||||||
|
fontSize: 10,
|
||||||
|
color: C.green,
|
||||||
|
fontWeight: 700,
|
||||||
|
},
|
||||||
|
listItemContent: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 10,
|
||||||
|
color: C.gray600,
|
||||||
|
lineHeight: 1.7,
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
color: C.green,
|
||||||
|
textDecoration: 'none',
|
||||||
|
},
|
||||||
|
textBold: {
|
||||||
|
fontWeight: 700,
|
||||||
|
fontFamily: 'Helvetica-Bold',
|
||||||
|
color: C.navyDeep,
|
||||||
|
},
|
||||||
|
textItalic: {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 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': {
|
||||||
|
return <Text key={idx}>{'\n'}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ignore payload blocks recursively to avoid crashing
|
||||||
|
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}>
|
||||||
|
{/* Hero Header */}
|
||||||
|
<View style={styles.hero} fixed>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.logoText}>KLZ</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.docTitle}>{locale === 'en' ? 'Document' : 'Dokument'}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.content}>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Minimal footer */}
|
||||||
|
<View style={styles.footer} fixed>
|
||||||
|
<Text style={styles.footerBrand}>KLZ CABLES</Text>
|
||||||
|
<Text style={styles.footerText}>{dateStr}</Text>
|
||||||
|
</View>
|
||||||
|
</Page>
|
||||||
|
</Document>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -21,8 +21,6 @@ import { Posts } from './src/payload/collections/Posts';
|
|||||||
import { FormSubmissions } from './src/payload/collections/FormSubmissions';
|
import { FormSubmissions } from './src/payload/collections/FormSubmissions';
|
||||||
import { Products } from './src/payload/collections/Products';
|
import { Products } from './src/payload/collections/Products';
|
||||||
import { Pages } from './src/payload/collections/Pages';
|
import { Pages } from './src/payload/collections/Pages';
|
||||||
import { Email } from './src/payload/blocks/Email';
|
|
||||||
import { Phone } from './src/payload/blocks/Phone';
|
|
||||||
import { seedDatabase } from './src/payload/seed';
|
import { seedDatabase } from './src/payload/seed';
|
||||||
|
|
||||||
const filename = fileURLToPath(import.meta.url);
|
const filename = fileURLToPath(import.meta.url);
|
||||||
@@ -64,7 +62,6 @@ export default buildConfig({
|
|||||||
...defaultFeatures,
|
...defaultFeatures,
|
||||||
BlocksFeature({
|
BlocksFeature({
|
||||||
blocks: payloadBlocks,
|
blocks: payloadBlocks,
|
||||||
inlineBlocks: [Email, Phone],
|
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
@@ -74,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 ||
|
||||||
|
|||||||
@@ -2,11 +2,16 @@ import puppeteer, { HTTPResponse } from 'puppeteer';
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
|
|
||||||
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
const targetUrl =
|
||||||
|
process.argv.find((arg) => !arg.startsWith('--') && arg.startsWith('http')) ||
|
||||||
|
process.env.NEXT_PUBLIC_BASE_URL ||
|
||||||
|
'http://localhost:3000';
|
||||||
|
const limit = process.env.ASSET_CHECK_LIMIT ? parseInt(process.env.ASSET_CHECK_LIMIT) : 20;
|
||||||
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
|
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log(`\n🚀 Starting Strict Asset Integrity Check for: ${targetUrl}`);
|
console.log(`\n🚀 Starting Strict Asset Integrity Check for: ${targetUrl}`);
|
||||||
|
console.log(`📊 Limit: ${limit} pages\n`);
|
||||||
|
|
||||||
// 1. Fetch Sitemap to discover all routes
|
// 1. Fetch Sitemap to discover all routes
|
||||||
const sitemapUrl = `${targetUrl.replace(/\/$/, '')}/sitemap.xml`;
|
const sitemapUrl = `${targetUrl.replace(/\/$/, '')}/sitemap.xml`;
|
||||||
@@ -31,6 +36,17 @@ async function main() {
|
|||||||
.sort();
|
.sort();
|
||||||
|
|
||||||
console.log(`✅ Found ${urls.length} target URLs.`);
|
console.log(`✅ Found ${urls.length} target URLs.`);
|
||||||
|
|
||||||
|
if (urls.length > limit) {
|
||||||
|
console.log(
|
||||||
|
`⚠️ Too many pages (${urls.length}). Limiting to ${limit} representative pages.`,
|
||||||
|
);
|
||||||
|
// Simplify selection: home pages + a slice of the rest
|
||||||
|
const homeEN = urls.filter((u) => u.endsWith('/en') || u === targetUrl);
|
||||||
|
const homeDE = urls.filter((u) => u.endsWith('/de'));
|
||||||
|
const others = urls.filter((u) => !homeEN.includes(u) && !homeDE.includes(u));
|
||||||
|
urls = [...homeEN, ...homeDE, ...others.slice(0, limit - (homeEN.length + homeDE.length))];
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(`❌ Failed to fetch sitemap: ${err.message}`);
|
console.error(`❌ Failed to fetch sitemap: ${err.message}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
24
scripts/lhci-puppeteer-setup.js
Normal file
24
scripts/lhci-puppeteer-setup.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* LHCI Puppeteer Setup Script
|
||||||
|
* Sets the gatekeeper session cookie before auditing
|
||||||
|
*/
|
||||||
|
module.exports = async (browser, context) => {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
// Using LHCI_URL or TARGET_URL if available
|
||||||
|
const targetUrl =
|
||||||
|
process.env.LHCI_URL || process.env.TARGET_URL || 'https://testing.klz-cables.com';
|
||||||
|
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
|
||||||
|
|
||||||
|
console.log(`🔑 LHCI Auth: Setting gatekeeper cookie for ${new URL(targetUrl).hostname}...`);
|
||||||
|
|
||||||
|
await page.setCookie({
|
||||||
|
name: 'klz_gatekeeper_session',
|
||||||
|
value: gatekeeperPassword,
|
||||||
|
domain: new URL(targetUrl).hostname,
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
secure: targetUrl.startsWith('https://'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.close();
|
||||||
|
};
|
||||||
@@ -12,7 +12,11 @@ import * as path from 'path';
|
|||||||
* 3. Runs Lighthouse CI on those URLs
|
* 3. Runs Lighthouse CI on those URLs
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
const targetUrl =
|
||||||
|
process.argv.find((arg) => !arg.startsWith('--') && arg.startsWith('http')) ||
|
||||||
|
process.env.NEXT_PUBLIC_BASE_URL ||
|
||||||
|
process.env.LHCI_URL ||
|
||||||
|
'http://localhost:3000';
|
||||||
const limit = process.env.PAGESPEED_LIMIT ? parseInt(process.env.PAGESPEED_LIMIT) : 20; // Default limit to avoid infinite runs
|
const limit = process.env.PAGESPEED_LIMIT ? parseInt(process.env.PAGESPEED_LIMIT) : 20; // Default limit to avoid infinite runs
|
||||||
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
|
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
|
||||||
|
|
||||||
@@ -76,7 +80,56 @@ async function main() {
|
|||||||
Cookie: `klz_gatekeeper_session=${gatekeeperPassword}`,
|
Cookie: `klz_gatekeeper_session=${gatekeeperPassword}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const chromePath = process.env.CHROME_PATH || process.env.PUPPETEER_EXECUTABLE_PATH;
|
// Detect Chrome path from Puppeteer installation if not provided
|
||||||
|
let chromePath = process.env.CHROME_PATH || process.env.PUPPETEER_EXECUTABLE_PATH;
|
||||||
|
if (!chromePath) {
|
||||||
|
try {
|
||||||
|
console.log('🔍 Attempting to detect Puppeteer Chrome path...');
|
||||||
|
const puppeteerInfo = execSync('npx puppeteer browsers latest chrome', {
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
console.log(`📦 Puppeteer info: ${puppeteerInfo}`);
|
||||||
|
const match = puppeteerInfo.match(/executablePath: (.*)/);
|
||||||
|
if (match && match[1]) {
|
||||||
|
chromePath = match[1].trim();
|
||||||
|
console.log(`✅ Detected Puppeteer Chrome at: ${chromePath}`);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.warn(`⚠️ Could not detect Puppeteer Chrome path via command: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to known paths if still not found
|
||||||
|
if (!chromePath) {
|
||||||
|
const fallbacks = [
|
||||||
|
'/root/.cache/puppeteer/chrome/linux-145.0.7632.77/chrome-linux64/chrome',
|
||||||
|
'/home/runner/.cache/puppeteer/chrome/linux-145.0.7632.77/chrome-linux64/chrome',
|
||||||
|
path.join(
|
||||||
|
process.cwd(),
|
||||||
|
'node_modules',
|
||||||
|
'.puppeteer',
|
||||||
|
'chrome',
|
||||||
|
'linux-145.0.7632.77',
|
||||||
|
'chrome-linux64',
|
||||||
|
'chrome',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const fallback of fallbacks) {
|
||||||
|
if (fs.existsSync(fallback)) {
|
||||||
|
chromePath = fallback;
|
||||||
|
console.log(`✅ Found Puppeteer Chrome at fallback: ${chromePath}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`ℹ️ Using existing Chrome path: ${chromePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!chromePath) {
|
||||||
|
console.warn('❌ CHROME_PATH is still undefined. Lighthouse might fail.');
|
||||||
|
}
|
||||||
|
|
||||||
const chromePathArg = chromePath ? `--collect.chromePath="${chromePath}"` : '';
|
const chromePathArg = chromePath ? `--collect.chromePath="${chromePath}"` : '';
|
||||||
|
|
||||||
// Clean up old reports
|
// Clean up old reports
|
||||||
@@ -85,15 +138,16 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Using a more robust way to execute and capture output
|
// Using a more robust way to execute and capture output
|
||||||
// We remove 'npx lhci upload' to keep everything local and avoid Google-hosted reports
|
// We use a puppeteer script to set cookies which is more reliable than extraHeaders for LHCI
|
||||||
const lhciCommand = `npx lhci collect ${urlArgs} ${chromePathArg} --config=config/lighthouserc.json --collect.settings.extraHeaders='${extraHeaders}' --collect.settings.chromeFlags="--no-sandbox --disable-setuid-sandbox --disable-dev-shm-usage" && npx lhci assert --config=config/lighthouserc.json`;
|
const lhciCommand = `npx lhci collect ${urlArgs} ${chromePathArg} --config=config/lighthouserc.json --collect.puppeteerScript="scripts/lhci-puppeteer-setup.js" --collect.settings.chromeFlags="--no-sandbox --disable-setuid-sandbox --disable-dev-shm-usage" && npx lhci assert --config=config/lighthouserc.json`;
|
||||||
|
|
||||||
console.log(`💻 Executing LHCI...`);
|
console.log(`💻 Executing LHCI with CHROME_PATH="${chromePath}" and Puppeteer Auth...`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execSync(lhciCommand, {
|
execSync(lhciCommand, {
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
|
env: { ...process.env, CHROME_PATH: chromePath },
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.warn('⚠️ LHCI assertion finished with warnings or errors.');
|
console.warn('⚠️ LHCI assertion finished with warnings or errors.');
|
||||||
|
|||||||
52
src/migrations/20260305_215000_products_featured_image.ts
Normal file
52
src/migrations/20260305_215000_products_featured_image.ts
Normal 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";
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import { Block } from 'payload';
|
|
||||||
|
|
||||||
export const Email: Block = {
|
|
||||||
slug: 'email',
|
|
||||||
interfaceName: 'EmailBlock',
|
|
||||||
labels: {
|
|
||||||
singular: 'Email (Inline)',
|
|
||||||
plural: 'Emails (Inline)',
|
|
||||||
},
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: 'email',
|
|
||||||
type: 'text',
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'label',
|
|
||||||
type: 'text',
|
|
||||||
required: false,
|
|
||||||
admin: {
|
|
||||||
placeholder: 'Optional: Custom link text',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
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' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { Block } from 'payload';
|
|
||||||
|
|
||||||
export const Phone: Block = {
|
|
||||||
slug: 'phone',
|
|
||||||
interfaceName: 'PhoneBlock',
|
|
||||||
labels: {
|
|
||||||
singular: 'Phone (Inline)',
|
|
||||||
plural: 'Phones (Inline)',
|
|
||||||
},
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: 'phone',
|
|
||||||
type: 'text',
|
|
||||||
required: true,
|
|
||||||
admin: {
|
|
||||||
placeholder: '+49 123 456 789',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'label',
|
|
||||||
type: 'text',
|
|
||||||
required: false,
|
|
||||||
admin: {
|
|
||||||
placeholder: 'Optional: Custom link text',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import { AnimatedImage } from './AnimatedImage';
|
import { AnimatedImage } from './AnimatedImage';
|
||||||
import { Email } from './Email';
|
|
||||||
import { Phone } from './Phone';
|
|
||||||
import { Callout } from './Callout';
|
import { Callout } from './Callout';
|
||||||
import { CategoryGrid } from './CategoryGrid';
|
import { CategoryGrid } from './CategoryGrid';
|
||||||
import { ChatBubble } from './ChatBubble';
|
import { ChatBubble } from './ChatBubble';
|
||||||
@@ -18,13 +16,12 @@ 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 = [
|
||||||
...homeBlocksArray,
|
...homeBlocksArray,
|
||||||
AnimatedImage,
|
AnimatedImage,
|
||||||
Email,
|
|
||||||
Phone,
|
|
||||||
Callout,
|
Callout,
|
||||||
CategoryGrid,
|
CategoryGrid,
|
||||||
ChatBubble,
|
ChatBubble,
|
||||||
@@ -42,4 +39,5 @@ export const payloadBlocks = [
|
|||||||
TeamProfile,
|
TeamProfile,
|
||||||
TechnicalGrid,
|
TechnicalGrid,
|
||||||
VisualLinkPreview,
|
VisualLinkPreview,
|
||||||
|
PDFDownload,
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user