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: {
+
+