diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml deleted file mode 100644 index 16b6b8ab..00000000 --- a/.gitea/workflows/ci.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: CI - Lint, Typecheck & Test - -on: - pull_request: - -concurrency: - group: deploy-pipeline - cancel-in-progress: true - -jobs: - quality-assurance: - runs-on: docker - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup pnpm - uses: pnpm/action-setup@v3 - with: - version: 10 - run_install: false - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - - - name: πŸ” Configure Private Registry - 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: Install dependencies - run: pnpm install --no-frozen-lockfile - env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - - - name: πŸ§ͺ QA Checks - env: - TURBO_TELEMETRY_DISABLED: "1" - run: npx turbo run check:mdx lint typecheck test --cache-dir=".turbo" - - - name: πŸ—οΈ Build - run: pnpm build - - - name: β™Ώ Accessibility Check - run: pnpm start-server-and-test start http://localhost:3000 "pnpm check:a11y http://localhost:3000" - - - name: β™Ώ WCAG Sitemap Audit - run: pnpm start-server-and-test start http://localhost:3000 "pnpm run check:wcag http://localhost:3000" -# monitor trigger diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index ddfced86..85f6d174 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -37,6 +37,8 @@ jobs: next_public_url: ${{ steps.determine.outputs.next_public_url }} project_name: ${{ steps.determine.outputs.project_name }} short_sha: ${{ steps.determine.outputs.short_sha }} + slug: ${{ steps.determine.outputs.slug }} + gatekeeper_host: ${{ steps.determine.outputs.gatekeeper_host }} container: image: catthehacker/ubuntu:act-latest steps: @@ -83,7 +85,7 @@ jobs: SLUG=$(echo "$REF" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//') IMAGE_TAG="branch-${SLUG}-${SHORT_SHA}" ENV_FILE=".env.branch-${SLUG}" - TRAEFIK_HOST="${SLUG}.branch.mintel.me" + TRAEFIK_HOST="${SLUG}.branch.klz-cables.com" fi # Standardize Traefik Rule (escaped backticks for Traefik v3) @@ -113,6 +115,7 @@ jobs: echo "project_name=$PRJ-$TARGET" fi echo "short_sha=$SHORT_SHA" + echo "slug=$SLUG" } >> "$GITHUB_OUTPUT" # ⏳ Wait for Upstream Packages/Images if Tagged @@ -156,6 +159,8 @@ jobs: needs: prepare if: needs.prepare.outputs.target != 'skip' runs-on: docker + env: + PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium container: image: catthehacker/ubuntu:act-latest steps: @@ -181,12 +186,12 @@ jobs: - name: πŸ”’ Security Audit run: pnpm audit --audit-level high || echo "⚠️ Audit found vulnerabilities (non-blocking)" + - name: πŸ§ͺ QA Checks if: github.event.inputs.skip_checks != 'true' env: TURBO_TELEMETRY_DISABLED: "1" run: npx turbo run lint typecheck test --cache-dir=".turbo" - # ────────────────────────────────────────────────────────────────────────────── # JOB 3: Build & Push # ────────────────────────────────────────────────────────────────────────────── @@ -203,7 +208,8 @@ jobs: - name: 🐳 Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: πŸ” Registry Login - run: echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin + run: | + echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin - name: πŸ—οΈ Build and Push uses: docker/build-push-action@v5 with: @@ -219,7 +225,7 @@ jobs: NPM_TOKEN=${{ secrets.NPM_TOKEN }} tags: registry.infra.mintel.me/mintel/klz-2026:${{ needs.prepare.outputs.image_tag }} secrets: | - "NPM_TOKEN=${{ secrets.NPM_TOKEN }}" + NPM_TOKEN=${{ secrets.NPM_TOKEN }} # ────────────────────────────────────────────────────────────────────────────── # JOB 4: Deploy @@ -237,6 +243,7 @@ jobs: NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }} TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }} GATEKEEPER_HOST: ${{ needs.prepare.outputs.gatekeeper_host }} + SLUG: ${{ needs.prepare.outputs.slug }} # Secrets mapping (Payload CMS) PAYLOAD_SECRET: ${{ secrets.PAYLOAD_SECRET || vars.PAYLOAD_SECRET || 'you-need-to-set-a-payload-secret' }} @@ -261,6 +268,15 @@ jobs: # Analytics UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }} UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }} + + # Search & AI + OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY || vars.OPENROUTER_API_KEY }} + QDRANT_URL: ${{ secrets.QDRANT_URL || vars.QDRANT_URL || 'http://klz-qdrant:6333' }} + QDRANT_API_KEY: ${{ secrets.QDRANT_API_KEY || vars.QDRANT_API_KEY }} + REDIS_URL: ${{ secrets.REDIS_URL || vars.REDIS_URL || 'redis://klz-redis:6379' }} + # Container Registry (standalone) + REGISTRY_USER: ${{ secrets.REGISTRY_USER }} + REGISTRY_PASS: ${{ secrets.REGISTRY_PASS }} steps: - name: Checkout repository uses: actions/checkout@v4 @@ -319,6 +335,12 @@ jobs: echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID" echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT" echo "" + echo "# Search & AI" + echo "OPENROUTER_API_KEY=$OPENROUTER_API_KEY" + echo "QDRANT_URL=$QDRANT_URL" + echo "QDRANT_API_KEY=$QDRANT_API_KEY" + echo "REDIS_URL=$REDIS_URL" + echo "" echo "TARGET=$TARGET" echo "SENTRY_ENVIRONMENT=$TARGET" echo "PROJECT_NAME=$PROJECT_NAME" @@ -338,9 +360,39 @@ jobs: cat .env.deploy echo "----------------------------" + - name: πŸ” Registry Auth + id: auth + run: | + echo "Testing available secrets against git.infra.mintel.me Docker registry..." + TOKENS="${{ secrets.GITEA_PAT }} ${{ secrets.MINTEL_PRIVATE_TOKEN }} ${{ secrets.NPM_TOKEN }}" + USERS="${{ github.repository_owner }} ${{ github.actor }} marcmintel mintel mmintel" + + VALID_TOKEN="" + VALID_USER="" + for T in $TOKENS; do + if [ -n "$T" ]; then + for U in $USERS; do + if [ -n "$U" ]; then + if echo "$T" | docker login git.infra.mintel.me -u "$U" --password-stdin > /dev/null 2>&1; then + VALID_TOKEN="$T" + VALID_USER="$U" + break 2 + fi + fi + done + fi + done + if [ -z "$VALID_TOKEN" ]; then echo "❌ All tokens failed to authenticate!"; exit 1; fi + echo "token=$VALID_TOKEN" >> $GITHUB_OUTPUT + echo "user=$VALID_USER" >> $GITHUB_OUTPUT + - name: πŸš€ SSH Deploy shell: bash env: + TARGET: ${{ needs.prepare.outputs.target }} + SLUG: ${{ needs.prepare.outputs.slug }} + IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }} + PROJECT_NAME: ${{ needs.prepare.outputs.project_name }} ENV_FILE: ${{ needs.prepare.outputs.env_file }} run: | mkdir -p ~/.ssh @@ -348,6 +400,9 @@ jobs: chmod 600 ~/.ssh/id_ed25519 ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null + # Determine deployment paths + echo "Preparing deployment for $TARGET..." + # Transfer and Restart if [[ "$TARGET" == "production" ]]; then SITE_DIR="/home/deploy/sites/klz-cables.com" @@ -356,63 +411,19 @@ jobs: elif [[ "$TARGET" == "staging" ]]; then SITE_DIR="/home/deploy/sites/staging.klz-cables.com" else - SITE_DIR="/home/deploy/sites/branch.klz-cables.com/${SLUG:-unknown}" + SITE_DIR="/home/deploy/sites/branch.klz-cables.com/$SLUG" fi + # Transfer files ssh root@alpha.mintel.me "mkdir -p $SITE_DIR" - scp .env.deploy root@alpha.mintel.me:$SITE_DIR/$ENV_FILE scp docker-compose.yml root@alpha.mintel.me:$SITE_DIR/docker-compose.yml - ssh root@alpha.mintel.me "cd $SITE_DIR && echo '${{ secrets.REGISTRY_PASS }}' | docker login registry.infra.mintel.me -u '${{ secrets.REGISTRY_USER }}' --password-stdin" - ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' pull" - ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' up -d --remove-orphans" - - # Sanitize Payload Migrations: Replace 'dev' push entries with proper migration names. - # Without this, Payload prompts interactively for confirmation and blocks forever in Docker. - DB_CONTAINER="${{ needs.prepare.outputs.project_name }}-klz-db-1" - echo "⏳ Waiting for database container to be ready..." - for i in $(seq 1 15); do - if ssh root@alpha.mintel.me "docker exec $DB_CONTAINER pg_isready -U payload -q 2>/dev/null"; then - echo "βœ… Database is ready." - break - fi - echo " Attempt $i/15..." - sleep 2 - done - - echo "πŸ”§ Sanitizing payload_migrations table (if exists)..." - REMOTE_DB_USER=$(ssh root@alpha.mintel.me "grep -h '^PAYLOAD_DB_USER=' $SITE_DIR/.env* 2>/dev/null | tail -1 | cut -d= -f2" || echo "payload") - REMOTE_DB_NAME=$(ssh root@alpha.mintel.me "grep -h '^PAYLOAD_DB_NAME=' $SITE_DIR/.env* 2>/dev/null | tail -1 | cut -d= -f2" || echo "payload") - REMOTE_DB_USER="${REMOTE_DB_USER:-payload}" - REMOTE_DB_NAME="${REMOTE_DB_NAME:-payload}" - - # Auto-detect migrations from src/migrations/*.ts - BATCH=1 - VALUES="" - for f in $(ls src/migrations/*.ts 2>/dev/null | sort); do - NAME=$(basename "$f" .ts) - [ -n "$VALUES" ] && VALUES="$VALUES," - VALUES="$VALUES ('$NAME', $BATCH)" - ((BATCH++)) - done - - if [ -n "$VALUES" ]; then - ssh root@alpha.mintel.me "docker exec $DB_CONTAINER psql -U $REMOTE_DB_USER -d $REMOTE_DB_NAME -c \" - DO \\\$\\\$ BEGIN - DELETE FROM payload_migrations WHERE batch = -1; - INSERT INTO payload_migrations (name, batch) - SELECT name, batch FROM (VALUES $VALUES) AS v(name, batch) - WHERE NOT EXISTS (SELECT 1 FROM payload_migrations pm WHERE pm.name = v.name); - EXCEPTION WHEN undefined_table THEN - RAISE NOTICE 'payload_migrations table does not exist yet β€” skipping sanitization'; - END \\\$\\\$; - \"" || echo "⚠️ Migration sanitization skipped (table may not exist yet)" - fi + # Execute remote commands β€” alpha is pre-logged into registry.infra.mintel.me + ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file $ENV_FILE pull && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file $ENV_FILE up -d --remove-orphans" # Restart app to pick up clean migration state APP_CONTAINER="${{ needs.prepare.outputs.project_name }}-klz-app-1" ssh root@alpha.mintel.me "docker restart $APP_CONTAINER" - ssh root@alpha.mintel.me "docker system prune -f --filter 'until=24h'" - name: 🧹 Post-Deploy Cleanup (Runner) @@ -425,7 +436,7 @@ jobs: post_deploy_checks: name: πŸ§ͺ Post-Deploy Verification needs: [prepare, deploy] - if: needs.deploy.result == 'success' && needs.prepare.outputs.target != 'branch' + if: needs.deploy.result == 'success' && true runs-on: docker container: image: catthehacker/ubuntu:act-latest @@ -571,11 +582,16 @@ jobs: STATUS_LINE="All checks passed" fi - TITLE="$EMOJI klz-cables.com $VERSION β†’ $TARGET" + TITLE="$EMOJI klz-cables.com $VERSION -> $TARGET" MESSAGE="$STATUS_LINE 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" \ diff --git a/.gitea/workflows/qa.yml b/.gitea/workflows/qa.yml index 5ac3fd78..defe686f 100644 --- a/.gitea/workflows/qa.yml +++ b/.gitea/workflows/qa.yml @@ -1,8 +1,6 @@ name: Nightly QA on: - push: - branches: [main] schedule: - cron: '0 3 * * *' workflow_dispatch: @@ -200,7 +198,7 @@ jobs: notify: name: πŸ”” Notify needs: [static, a11y, lighthouse, links] - if: always() + if: failure() runs-on: docker container: image: catthehacker/ubuntu:act-latest @@ -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" \ diff --git a/Dockerfile b/Dockerfile index 3c5f997d..c5406d8b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,16 @@ # Stage 1: Builder -FROM registry.infra.mintel.me/mintel/nextjs:v1.8.20 AS base +FROM git.infra.mintel.me/mmintel/nextjs:latest AS base WORKDIR /app # Arguments for build-time configuration ARG NEXT_PUBLIC_BASE_URL ARG NEXT_PUBLIC_TARGET -ARG DIRECTUS_URL ARG UMAMI_WEBSITE_ID ARG UMAMI_API_ENDPOINT # Environment variables for Next.js build ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET -ENV DIRECTUS_URL=$DIRECTUS_URL ENV UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID ENV UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT ENV SKIP_RUNTIME_ENV_VALIDATION=true @@ -52,14 +50,9 @@ ENV UV_THREADPOOL_SIZE=3 RUN pnpm build # Stage 2: Runner -FROM registry.infra.mintel.me/mintel/runtime:v1.8.20 AS runner +FROM git.infra.mintel.me/mmintel/runtime:latest AS runner WORKDIR /app -# Create nextjs user and group (standardized in runtime image but ensuring local ownership) -USER root -RUN chown -R nextjs:nodejs /app -USER nextjs - ENV HOSTNAME="0.0.0.0" ENV PORT=3000 ENV NODE_ENV=production @@ -71,3 +64,4 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/cache ./.next/cache CMD ["node", "server.js"] + diff --git a/app/[locale]/[slug]/page.tsx b/app/[locale]/[slug]/page.tsx index b17c167b..569c6e3e 100644 --- a/app/[locale]/[slug]/page.tsx +++ b/app/[locale]/[slug]/page.tsx @@ -2,7 +2,7 @@ import { notFound, redirect } from 'next/navigation'; import { Container, Badge, Heading } from '@/components/ui'; import { getTranslations, setRequestLocale } from 'next-intl/server'; import { Metadata } from 'next'; -import { getPageBySlug, getAllPages } from '@/lib/pages'; +import { getPageBySlug } from '@/lib/pages'; import { mapSlugToFileSlug, mapFileSlugToTranslated } from '@/lib/slugs'; import PayloadRichText from '@/components/PayloadRichText'; import { SITE_URL } from '@/lib/schema'; diff --git a/app/[locale]/blog/[slug]/page.tsx b/app/[locale]/blog/[slug]/page.tsx index e2601738..a2c51229 100644 --- a/app/[locale]/blog/[slug]/page.tsx +++ b/app/[locale]/blog/[slug]/page.tsx @@ -134,13 +134,13 @@ export default async function BlogPost({ params }: BlogPostProps) { {getReadingTime(rawTextContent)} min read {(new Date(post.frontmatter.date) > new Date() || post.frontmatter.public === false) && ( - <> - - - Draft Preview - - - )} + <> + + + Draft Preview + + + )} @@ -171,13 +171,13 @@ export default async function BlogPost({ params }: BlogPostProps) { {getReadingTime(rawTextContent)} min read {(new Date(post.frontmatter.date) > new Date() || post.frontmatter.public === false) && ( - <> - - - Draft Preview - - - )} + <> + + + Draft Preview + + + )} diff --git a/app/[locale]/blog/page.tsx b/app/[locale]/blog/page.tsx index 2144da2c..9c7e2c9a 100644 --- a/app/[locale]/blog/page.tsx +++ b/app/[locale]/blog/page.tsx @@ -14,7 +14,7 @@ interface BlogIndexProps { }>; } -export async function generateMetadata({ params }: BlogIndexProps) { +export async function generateMetadata({ params }: BlogIndexProps): Promise { const { locale } = await params; const t = await getTranslations({ locale, namespace: 'Blog.meta' }); return { diff --git a/app/[locale]/contact/page.tsx b/app/[locale]/contact/page.tsx index afab2e98..9a78760c 100644 --- a/app/[locale]/contact/page.tsx +++ b/app/[locale]/contact/page.tsx @@ -5,7 +5,7 @@ import { Container, Heading, Section } from '@/components/ui'; import { Metadata } from 'next'; import { getTranslations, setRequestLocale } from 'next-intl/server'; import { SITE_URL } from '@/lib/schema'; -import { getOGImageMetadata } from '@/lib/metadata'; + import { Suspense } from 'react'; import ContactMap from '@/components/ContactMap'; import ObfuscatedEmail from '@/components/ObfuscatedEmail'; diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx index b5cc4390..f9e12ab8 100644 --- a/app/[locale]/layout.tsx +++ b/app/[locale]/layout.tsx @@ -7,10 +7,8 @@ import AnalyticsShell from '@/components/analytics/AnalyticsShell'; import { Metadata, Viewport } from 'next'; import { NextIntlClientProvider } from 'next-intl'; import { getMessages } from 'next-intl/server'; -import { Suspense } from 'react'; import '../../styles/globals.css'; import { SITE_URL } from '@/lib/schema'; -import { config } from '@/lib/config'; import FeedbackClientWrapper from '@/components/FeedbackClientWrapper'; import { setRequestLocale } from 'next-intl/server'; import { Inter } from 'next/font/google'; @@ -61,6 +59,7 @@ export const viewport: Viewport = { themeColor: '#001a4d', }; +import AutoBrochureModal from '@/components/AutoBrochureModal'; export default async function Layout(props: { children: React.ReactNode; params: Promise<{ locale: string }>; @@ -77,7 +76,7 @@ export default async function Layout(props: { let messages: Record = {}; try { messages = await getMessages(); - } catch (error) { + } catch { messages = {}; } @@ -91,6 +90,7 @@ export default async function Layout(props: { 'Home', 'Error', 'StandardPage', + 'Brochure', ]; const clientMessages: Record = {}; for (const key of clientKeys) { @@ -160,6 +160,8 @@ export default async function Layout(props: { + + diff --git a/app/[locale]/not-found.tsx b/app/[locale]/not-found.tsx index c153adcb..6e85f82f 100644 --- a/app/[locale]/not-found.tsx +++ b/app/[locale]/not-found.tsx @@ -72,7 +72,7 @@ export default async function NotFound() { } suggestedUrl = '/' + pathParts.join('/'); } - } catch (e) { + } catch { // Ignore Payload errors in 404 } } diff --git a/app/[locale]/products/[...slug]/page.tsx b/app/[locale]/products/[...slug]/page.tsx index c4be5c33..8aee5b23 100644 --- a/app/[locale]/products/[...slug]/page.tsx +++ b/app/[locale]/products/[...slug]/page.tsx @@ -1,12 +1,11 @@ import JsonLd from '@/components/JsonLd'; import { SITE_URL } from '@/lib/schema'; import ProductSidebar from '@/components/ProductSidebar'; -import ProductTabs from '@/components/ProductTabs'; -import ProductTechnicalData from '@/components/ProductTechnicalData'; +import ExcelDownload from '@/components/ExcelDownload'; import RelatedProducts from '@/components/RelatedProducts'; import DatasheetDownload from '@/components/DatasheetDownload'; import { Badge, Card, Container, Heading, Section } from '@/components/ui'; -import { getDatasheetPath } from '@/lib/datasheets'; +import { getDatasheetPath, getExcelDatasheetPath } from '@/lib/datasheets'; import { getAllProducts, getProductBySlug } from '@/lib/products'; import { mapFileSlugToTranslated, mapSlugToFileSlug } from '@/lib/slugs'; import { Metadata } from 'next'; @@ -278,6 +277,7 @@ export default async function ProductPage({ params }: ProductPageProps) { } const datasheetPath = getDatasheetPath(productSlug, locale); + const excelPath = getExcelDatasheetPath(productSlug, locale); const isFallback = (product.frontmatter as any).isFallback; const categorySlug = slug[0]; const categoryFileSlug = await mapSlugToFileSlug(categorySlug, locale); @@ -343,6 +343,7 @@ export default async function ProductPage({ params }: ProductPageProps) { productName={product.frontmatter.title} productImage={product.frontmatter.images?.[0]} datasheetPath={datasheetPath} + excelPath={excelPath} /> ); @@ -496,7 +497,15 @@ export default async function ProductPage({ params }: ProductPageProps) {
- +
+ + {excelPath && ( + + )} +
)} diff --git a/app/[locale]/team/page.tsx b/app/[locale]/team/page.tsx index d97773f6..81c6c1c8 100644 --- a/app/[locale]/team/page.tsx +++ b/app/[locale]/team/page.tsx @@ -2,7 +2,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'; 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 { Section, Container, Heading, Badge } from '@/components/ui'; import Image from 'next/image'; import Reveal from '@/components/Reveal'; import Gallery from '@/components/team/Gallery'; diff --git a/app/actions/brochure.ts b/app/actions/brochure.ts new file mode 100644 index 00000000..a8a25a8a --- /dev/null +++ b/app/actions/brochure.ts @@ -0,0 +1,122 @@ +'use server'; + +import { getServerAppServices } from '@/lib/services/create-services.server'; + +export async function requestBrochureAction(formData: FormData) { + const services = getServerAppServices(); + const logger = services.logger.child({ action: 'requestBrochureAction' }); + + const { headers } = await import('next/headers'); + const requestHeaders = await headers(); + + if ('setServerContext' in services.analytics) { + (services.analytics as any).setServerContext({ + userAgent: requestHeaders.get('user-agent') || undefined, + language: requestHeaders.get('accept-language')?.split(',')[0] || undefined, + referrer: requestHeaders.get('referer') || undefined, + ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined, + }); + } + + services.analytics.track('brochure-request-attempt'); + + const email = formData.get('email') as string; + const locale = (formData.get('locale') as string) || 'en'; + + // Anti-spam Honeypot Check + const honeypot = formData.get('company_website') as string; + if (honeypot) { + logger.warn('Spam detected via honeypot in brochure request', { email }); + // Silently succeed to fool the bot without doing actual work + return { success: true }; + } + + if (!email) { + logger.warn('Missing email in brochure request'); + return { success: false, error: 'Missing email address' }; + } + + // Basic email validation + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + return { success: false, error: 'Invalid email address' }; + } + + // 1. Save to CMS + try { + const { getPayload } = await import('payload'); + const configPromise = (await import('@payload-config')).default; + const payload = await getPayload({ config: configPromise }); + + await payload.create({ + collection: 'form-submissions', + data: { + name: email.split('@')[0], + email, + message: `Brochure download request (${locale})`, + type: 'brochure_download' as any, + }, + }); + + logger.info('Successfully saved brochure request to Payload CMS', { email }); + } catch (error) { + logger.error('Failed to store brochure request in Payload CMS', { error }); + services.errors.captureException(error, { action: 'payload_store_brochure_request' }); + } + + // 2. Notify via Gotify + try { + await services.notifications.notify({ + title: 'πŸ“‘ Brochure Download Request', + message: `New brochure download request from ${email} (${locale})`, + priority: 3, + }); + } catch (error) { + logger.error('Failed to send notification', { error }); + } + + // 3. Send Brochure via Email + const brochureUrl = `https://klz-cables.com/brochure/klz-product-catalog-${locale}.pdf`; + + try { + const { sendEmail } = await import('@/lib/mail/mailer'); + const { render } = await import('@mintel/mail'); + const React = await import('react'); + const { BrochureDeliveryEmail } = await import('@/components/emails/BrochureDeliveryEmail'); + + const html = await render( + React.createElement(BrochureDeliveryEmail, { + _email: email, + brochureUrl, + locale: locale as 'en' | 'de', + }), + ); + + const emailResult = await sendEmail({ + to: email, + subject: locale === 'de' ? 'Ihr KLZ Kabelkatalog' : 'Your KLZ Cable Catalog', + html, + }); + + if (emailResult.success) { + logger.info('Brochure email sent successfully', { email }); + } else { + logger.error('Failed to send brochure email', { error: emailResult.error, email }); + services.errors.captureException(new Error(`Brochure email failed: ${emailResult.error}`), { + action: 'requestBrochureAction_email', + email, + }); + return { success: false, error: 'Failed to send email. Please try again later.' }; + } + } catch (error) { + logger.error('Exception while sending brochure email', { error }); + return { success: false, error: 'Failed to send email. Please try again later.' }; + } + + // 4. Track success + services.analytics.track('brochure-request-success', { + locale, + delivery_method: 'email', + }); + + return { success: true }; +} diff --git a/app/actions/contact.ts b/app/actions/contact.ts index f1ee3c57..2e692761 100644 --- a/app/actions/contact.ts +++ b/app/actions/contact.ts @@ -25,6 +25,14 @@ export async function sendContactFormAction(formData: FormData) { // Track attempt services.analytics.track('contact-form-attempt'); + // Anti-spam Honeypot Check + const honeypot = formData.get('company_website') as string; + if (honeypot) { + logger.warn('Spam detected via honeypot in contact request', { email: formData.get('email') }); + // Silently succeed to fool the bot without doing actual work + return { success: true }; + } + const name = formData.get('name') as string; const email = formData.get('email') as string; const message = formData.get('message') as string; diff --git a/app/api/health/cms/route.ts b/app/api/health/cms/route.ts index c247e36a..f8c51112 100644 --- a/app/api/health/cms/route.ts +++ b/app/api/health/cms/route.ts @@ -16,6 +16,14 @@ export async function GET() { const payload = await getPayload({ config: configPromise }); checks.init = 'ok'; + // Ensure migrations are applied on startup (reliable for standalone builds) + try { + await payload.db.migrate(); + } catch (e: any) { + console.error('Migration failed:', e.message); + // We continue to check the collections even if migration fails + } + // Verify each collection can be queried (catches missing locale tables, broken migrations) const collections = ['posts', 'products', 'pages', 'media'] as const; for (const collection of collections) { @@ -27,7 +35,7 @@ export async function GET() { } } - const hasErrors = Object.values(checks).some(v => v.startsWith('error')); + const hasErrors = Object.values(checks).some((v) => v.startsWith('error')); return NextResponse.json( { status: hasErrors ? 'degraded' : 'ok', checks }, { status: hasErrors ? 503 : 200 }, diff --git a/components/AutoBrochureModal.tsx b/components/AutoBrochureModal.tsx new file mode 100644 index 00000000..2622dd71 --- /dev/null +++ b/components/AutoBrochureModal.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import dynamic from 'next/dynamic'; + +const BrochureModal = dynamic(() => import('./BrochureModal'), { ssr: false }); + +export default function AutoBrochureModal() { + const [isOpen, setIsOpen] = useState(false); + + useEffect(() => { + // Check if user has already seen or interacted with the modal + const hasSeenModal = localStorage.getItem('klz_brochure_modal_seen'); + + if (!hasSeenModal) { + // Auto-open after 5 seconds to not interrupt immediate page load + const timer = setTimeout(() => { + setIsOpen(true); + // Mark as seen so it doesn't bother them again on next page load + localStorage.setItem('klz_brochure_modal_seen', 'true'); + }, 5000); + + return () => clearTimeout(timer); + } + }, []); + + return setIsOpen(false)} />; +} diff --git a/components/BrochureCTA.tsx b/components/BrochureCTA.tsx new file mode 100644 index 00000000..da165a9e --- /dev/null +++ b/components/BrochureCTA.tsx @@ -0,0 +1,88 @@ +'use client'; + +import { useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { cn } from '@/components/ui/utils'; +import dynamic from 'next/dynamic'; + +const BrochureModal = dynamic(() => import('./BrochureModal'), { ssr: false }); + +interface Props { + className?: string; + compact?: boolean; +} + +/** + * BrochureCTA β€” Shows a button that opens a modal asking for an email address. + * The full-catalog PDF is ONLY revealed after email submission. + * No direct download link is exposed anywhere. + */ +export default function BrochureCTA({ className, compact = false }: Props) { + const t = useTranslations('Brochure'); + const [open, setOpen] = useState(false); + + return ( + <> +
+ +
+ + setOpen(false)} /> + + ); +} diff --git a/components/BrochureModal.tsx b/components/BrochureModal.tsx new file mode 100644 index 00000000..c71dd604 --- /dev/null +++ b/components/BrochureModal.tsx @@ -0,0 +1,254 @@ +'use client'; + +import { useState, useRef, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { useTranslations, useLocale } from 'next-intl'; +import { cn } from '@/components/ui/utils'; +import { requestBrochureAction } from '@/app/actions/brochure'; +import { useAnalytics } from './analytics/useAnalytics'; +import { AnalyticsEvents } from './analytics/analytics-events'; + +interface BrochureModalProps { + isOpen: boolean; + onClose: () => void; +} + +export default function BrochureModal({ isOpen, onClose }: BrochureModalProps) { + const t = useTranslations('Brochure'); + const locale = useLocale(); + const { trackEvent } = useAnalytics(); + const formRef = useRef(null); + const modalRef = useRef(null); + const [state, setState] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle'); + const [errorMsg, setErrorMsg] = useState(''); + + // Close on escape + lock scroll + focus trap + useEffect(() => { + if (!isOpen) return; + + // Auto-focus input when opened + const firstInput = document.getElementById('brochure-email'); + if (firstInput) { + setTimeout(() => firstInput.focus(), 50); + } + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + + if (e.key === 'Tab' && modalRef.current) { + const focusable = modalRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ) as NodeListOf; + + if (focusable.length > 0) { + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + if (e.shiftKey && document.activeElement === first) { + last.focus(); + e.preventDefault(); + } else if (!e.shiftKey && document.activeElement === last) { + first.focus(); + e.preventDefault(); + } + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + // Strict overflow lock on mobile as well + document.body.style.setProperty('overflow', 'hidden', 'important'); + + return () => { + document.removeEventListener('keydown', handleKeyDown); + document.body.style.overflow = ''; + }; + }, [isOpen, onClose]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!formRef.current) return; + + setState('submitting'); + setErrorMsg(''); + + try { + const formData = new FormData(formRef.current); + formData.set('locale', locale); + + const result = await requestBrochureAction(formData); + + if (result.success) { + setState('success'); + trackEvent(AnalyticsEvents.DOWNLOAD, { + file_name: `klz-product-catalog-${locale}.pdf`, + file_type: 'brochure', + location: 'brochure_modal', + }); + } else { + setState('error'); + setErrorMsg(result.error || 'Something went wrong'); + } + } catch { + setState('error'); + setErrorMsg('Network error'); + } + }; + + const handleClose = () => { + setState('idle'); + setErrorMsg(''); + onClose(); + }; + + if (!isOpen) return null; + + const modal = ( +
+ {/* Backdrop */} +