Compare commits

..

7 Commits

Author SHA1 Message Date
5b43349205 fix: prevent backtick expansion in env generation and fix traefik rules
All checks were successful
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m34s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 4m53s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 32s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Successful in 4m13s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-07 00:49:36 +01:00
96b296da12 fix: traefik routing rules and define missing compress middleware
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 10s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m37s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 4m36s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 32s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Failing after 1m44s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-06 23:57:37 +01:00
d5eb20a341 fix(analytics): bypass gatekeeper and middleware for tracking endpoints
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m37s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 9m13s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 28s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Failing after 2m44s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-06 23:16:52 +01:00
333111f03b chore: cms branding scripts 2026-02-06 23:11:57 +01:00
698141f70b fix: eslint and tests
All checks were successful
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m31s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 3m53s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 31s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Successful in 5m24s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-06 22:46:53 +01:00
e179e8162c refactor: Standardize Umami analytics environment variables to non-public names with fallbacks to NEXT_PUBLIC_ prefixed versions.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Failing after 1m31s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 3m51s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-06 22:35:49 +01:00
259d712105 fix: Update Gitea workflow to use environment-specific mail and Directus secrets, and refactor Directus branding script for improved CSS management and button styling.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 8s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Has been cancelled
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been cancelled
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been cancelled
Build & Deploy KLZ Cables / 🔔 Notifications (push) Has been cancelled
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Has been cancelled
2026-02-06 21:43:53 +01:00
21 changed files with 473 additions and 404 deletions

2
.env
View File

@@ -1,7 +1,7 @@
# Application
NODE_ENV=production
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
LOG_LEVEL=info

View File

@@ -21,7 +21,7 @@ TARGET=development
# ────────────────────────────────────────────────────────────────────────────
# Optional: Leave empty to disable analytics
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
# ────────────────────────────────────────────────────────────────────────────
# Error Tracking (GlitchTip/Sentry)

View File

@@ -13,7 +13,7 @@ NEXT_PUBLIC_BASE_URL=https://klz-cables.com
# Analytics (Umami)
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
# Error Tracking (GlitchTip/Sentry)
SENTRY_DSN=

View File

@@ -29,6 +29,8 @@ jobs:
image_tag: ${{ steps.determine.outputs.image_tag }}
env_file: ${{ steps.determine.outputs.env_file }}
traefik_host: ${{ steps.determine.outputs.traefik_host }}
traefik_host_rule: ${{ steps.determine.outputs.traefik_host_rule }}
primary_host: ${{ steps.determine.outputs.primary_host }}
next_public_base_url: ${{ steps.determine.outputs.next_public_base_url }}
directus_url: ${{ steps.determine.outputs.directus_url }}
directus_host: ${{ steps.determine.outputs.directus_host }}
@@ -115,11 +117,23 @@ jobs:
TARGET="skip"
fi
if [[ "$TRAEFIK_HOST" == *","* ]]; then
# Multi-domain: Host(`a.com`) || Host(`b.com`)
TRAEFIK_HOST_RULE=$(echo "$TRAEFIK_HOST" | sed 's/,/ /g' | awk '{for(i=1;i<=NF;i++) printf "Host(`%s`)%s", $i, (i==NF?"":" || ")}')
PRIMARY_HOST=$(echo "$TRAEFIK_HOST" | cut -d',' -f1 | sed 's/ //g')
else
# Single domain: Host(`domain.com`)
TRAEFIK_HOST_RULE="Host(\`$TRAEFIK_HOST\`)"
PRIMARY_HOST="$TRAEFIK_HOST"
fi
{
echo "target=$TARGET"
echo "image_tag=$IMAGE_TAG"
echo "env_file=$ENV_FILE"
echo "traefik_host=$TRAEFIK_HOST"
echo "traefik_host_rule=$TRAEFIK_HOST_RULE"
echo "primary_host=$PRIMARY_HOST"
echo "next_public_base_url=$NEXT_PUBLIC_BASE_URL"
echo "directus_url=$DIRECTUS_URL"
echo "directus_host=$DIRECTUS_HOST"
@@ -199,7 +213,7 @@ jobs:
TARGET: ${{ needs.prepare.outputs.target }}
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }}
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.TESTING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID) }}
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.TESTING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL) }}
UMAMI_API_ENDPOINT: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.TESTING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL) }}
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
run: |
docker buildx build \
@@ -207,7 +221,7 @@ jobs:
--platform linux/arm64 \
--build-arg NEXT_PUBLIC_BASE_URL="$NEXT_PUBLIC_BASE_URL" \
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID="$NEXT_PUBLIC_UMAMI_WEBSITE_ID" \
--build-arg NEXT_PUBLIC_UMAMI_SCRIPT_URL="$NEXT_PUBLIC_UMAMI_SCRIPT_URL" \
--build-arg UMAMI_API_ENDPOINT="$UMAMI_API_ENDPOINT" \
--build-arg NEXT_PUBLIC_TARGET="$TARGET" \
--build-arg DIRECTUS_URL="$DIRECTUS_URL" \
-t registry.infra.mintel.me/mintel/klz-cables.com:$IMAGE_TAG \
@@ -229,28 +243,28 @@ jobs:
TARGET: ${{ needs.prepare.outputs.target }}
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
TRAEFIK_HOST: ${{ needs.prepare.outputs.primary_host }}
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }}
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.TESTING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID) }}
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.TESTING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL) }}
UMAMI_API_ENDPOINT: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.TESTING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL) }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN || (needs.prepare.outputs.target == 'production' && secrets.SENTRY_DSN || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_SENTRY_DSN || secrets.TESTING_SENTRY_DSN || secrets.SENTRY_DSN)) }}
MAIL_HOST: ${{ secrets.MAIL_HOST || vars.MAIL_HOST }}
MAIL_PORT: ${{ secrets.MAIL_PORT || vars.MAIL_PORT }}
MAIL_USERNAME: ${{ secrets.MAIL_USERNAME || vars.MAIL_USERNAME }}
MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD }}
MAIL_FROM: ${{ secrets.MAIL_FROM || vars.MAIL_FROM }}
MAIL_RECIPIENTS: ${{ secrets.MAIL_RECIPIENTS || vars.MAIL_RECIPIENTS }}
MAIL_HOST: ${{ secrets.MAIL_HOST || vars.MAIL_HOST || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_HOST || vars.MAIL_HOST) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_HOST || vars.STAGING_MAIL_HOST) || (secrets.TESTING_MAIL_HOST || vars.TESTING_MAIL_HOST) || (secrets.MAIL_HOST || vars.MAIL_HOST))) }}
MAIL_PORT: ${{ secrets.MAIL_PORT || vars.MAIL_PORT || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_PORT || vars.MAIL_PORT) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_PORT || vars.STAGING_MAIL_PORT) || (secrets.TESTING_MAIL_PORT || vars.TESTING_MAIL_PORT) || (secrets.MAIL_PORT || vars.MAIL_PORT))) }}
MAIL_USERNAME: ${{ secrets.MAIL_USERNAME || vars.MAIL_USERNAME || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_USERNAME || vars.MAIL_USERNAME) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_USERNAME || vars.STAGING_MAIL_USERNAME) || (secrets.TESTING_MAIL_USERNAME || vars.TESTING_MAIL_USERNAME) || (secrets.MAIL_USERNAME || vars.MAIL_USERNAME))) }}
MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD || (needs.prepare.outputs.target == 'production' && secrets.MAIL_PASSWORD || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_MAIL_PASSWORD || secrets.TESTING_MAIL_PASSWORD || secrets.MAIL_PASSWORD)) }}
MAIL_FROM: ${{ secrets.MAIL_FROM || vars.MAIL_FROM || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_FROM || vars.MAIL_FROM) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_FROM || vars.STAGING_MAIL_FROM) || (secrets.TESTING_MAIL_FROM || vars.TESTING_MAIL_FROM) || (secrets.MAIL_FROM || vars.MAIL_FROM))) }}
MAIL_RECIPIENTS: ${{ secrets.MAIL_RECIPIENTS || vars.MAIL_RECIPIENTS || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_RECIPIENTS || vars.MAIL_RECIPIENTS) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_RECIPIENTS || vars.STAGING_MAIL_RECIPIENTS) || (secrets.TESTING_MAIL_RECIPIENTS || vars.TESTING_MAIL_RECIPIENTS) || (secrets.MAIL_RECIPIENTS || vars.MAIL_RECIPIENTS))) }}
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
DIRECTUS_HOST: ${{ needs.prepare.outputs.directus_host }}
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
DIRECTUS_KEY: ${{ secrets.DIRECTUS_KEY }}
DIRECTUS_SECRET: ${{ secrets.DIRECTUS_SECRET }}
DIRECTUS_ADMIN_EMAIL: ${{ secrets.DIRECTUS_ADMIN_EMAIL }}
DIRECTUS_ADMIN_PASSWORD: ${{ secrets.DIRECTUS_ADMIN_PASSWORD }}
DIRECTUS_KEY: ${{ secrets.DIRECTUS_KEY || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_KEY || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_KEY || secrets.TESTING_DIRECTUS_KEY || secrets.DIRECTUS_KEY)) }}
DIRECTUS_SECRET: ${{ secrets.DIRECTUS_SECRET || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_SECRET || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_SECRET || secrets.TESTING_DIRECTUS_SECRET || secrets.DIRECTUS_SECRET)) }}
DIRECTUS_ADMIN_EMAIL: ${{ secrets.DIRECTUS_ADMIN_EMAIL || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_ADMIN_EMAIL || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_ADMIN_EMAIL || secrets.TESTING_DIRECTUS_ADMIN_EMAIL || secrets.DIRECTUS_ADMIN_EMAIL)) }}
DIRECTUS_ADMIN_PASSWORD: ${{ secrets.DIRECTUS_ADMIN_PASSWORD || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_ADMIN_PASSWORD || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_ADMIN_PASSWORD || secrets.TESTING_DIRECTUS_ADMIN_PASSWORD || secrets.DIRECTUS_ADMIN_PASSWORD)) }}
DIRECTUS_DB_NAME: ${{ secrets.DIRECTUS_DB_NAME || 'directus' }}
DIRECTUS_DB_USER: ${{ secrets.DIRECTUS_DB_USER || 'directus' }}
DIRECTUS_DB_PASSWORD: ${{ secrets.DIRECTUS_DB_PASSWORD }}
DIRECTUS_API_TOKEN: ${{ secrets.DIRECTUS_API_TOKEN }}
DIRECTUS_DB_PASSWORD: ${{ secrets.DIRECTUS_DB_PASSWORD || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_DB_PASSWORD || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_DB_PASSWORD || secrets.TESTING_DIRECTUS_DB_PASSWORD || secrets.DIRECTUS_DB_PASSWORD)) }}
DIRECTUS_API_TOKEN: ${{ secrets.DIRECTUS_API_TOKEN || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_API_TOKEN || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_API_TOKEN || secrets.TESTING_DIRECTUS_API_TOKEN || secrets.DIRECTUS_API_TOKEN)) }}
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
steps:
- name: Checkout repository
@@ -268,15 +282,19 @@ jobs:
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
# Generated by CI - $TARGET - $(date -u)
# Determine dynamic values before writing the file
LOG_LEVEL=$( [[ "$TARGET" == "testing" || "$TARGET" == "development" ]] && echo "debug" || echo "info" )
COOKIE_DOMAIN=.$(echo $NEXT_PUBLIC_BASE_URL | sed 's|https://||')
cat > /tmp/klz-cables.env << EOF
# Generated by CI - $TARGET - $(date -u)
NODE_ENV=production
IMAGE_TAG=$IMAGE_TAG
NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
NEXT_PUBLIC_TARGET=$TARGET
NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
SENTRY_DSN=$SENTRY_DSN
LOG_LEVEL=$( [[ "$TARGET" == "testing" || "$TARGET" == "development" ]] && echo "debug" || echo "info" )
LOG_LEVEL=$LOG_LEVEL
MAIL_HOST=$MAIL_HOST
MAIL_PORT=$MAIL_PORT
MAIL_USERNAME=$MAIL_USERNAME
@@ -300,14 +318,15 @@ jobs:
TARGET=$TARGET
SENTRY_ENVIRONMENT=$TARGET
IMAGE_TAG=$IMAGE_TAG
TRAEFIK_HOST=$TRAEFIK_HOST
ENV_FILE=$ENV_FILE
AUTH_MIDDLEWARE=$( [[ "$TARGET" == "production" ]] && echo "compress" || echo "${PROJECT_NAME}-auth,compress" )
PROJECT_NAME=$PROJECT_NAME
COOKIE_DOMAIN=.$(echo $NEXT_PUBLIC_BASE_URL | sed 's|https://||')
COOKIE_DOMAIN=$COOKIE_DOMAIN
TRAEFIK_HOST=$TRAEFIK_HOST
EOF
# Append complex variables that contain backticks using printf to avoid shell expansion hits
printf "AUTH_MIDDLEWARE=%s\n" "$( [[ "$TARGET" == "production" ]] && echo "${PROJECT_NAME}-compress" || echo "${PROJECT_NAME}-auth,${PROJECT_NAME}-compress" )" >> /tmp/klz-cables.env
printf "TRAEFIK_HOST_RULE='%s'\n" '${{ needs.prepare.outputs.traefik_host_rule }}' >> /tmp/klz-cables.env
# 1. Cleanup and Create Directories on server BEFORE SCP
ssh -o StrictHostKeyChecking=accept-new root@alpha.mintel.me bash << 'EOF'
set -e

View File

@@ -25,14 +25,17 @@ ENV NEXT_TELEMETRY_DISABLED=1
# Build-time environment variables for Next.js
# These are baked into the client bundle during build
ARG NEXT_PUBLIC_BASE_URL
ARG NEXT_PUBLIC_BASE_URL
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
ARG UMAMI_API_ENDPOINT
ARG UMAMI_SCRIPT_URL
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
ARG NEXT_PUBLIC_TARGET
ARG DIRECTUS_URL
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
ENV NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
ENV UMAMI_API_ENDPOINT=${UMAMI_API_ENDPOINT:-${UMAMI_SCRIPT_URL:-$NEXT_PUBLIC_UMAMI_SCRIPT_URL}}
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
ENV DIRECTUS_URL=$DIRECTUS_URL

View File

@@ -5,11 +5,13 @@ A complete WordPress to Next.js static site migration for KLZ Cables, transformi
## 🚀 Quick Start
### Prerequisites
- Node.js 18+
- Node.js 18+
- npm or yarn
### Installation
```bash
````bash
# Install dependencies
npm install --legacy-peer-deps
@@ -42,11 +44,12 @@ npm run cms:logs
# Stop the CMS
npm run cms:stop
```
````
Once running, you can access the Strapi admin panel at `http://localhost:1337/admin`.
### 🔄 Data & Migration
To sync data or migrate existing content:
```bash
@@ -61,6 +64,7 @@ npm run cms:migrate
```
### Environment Variables
```bash
# .env
SITE_URL=https://klz-cables.com
@@ -69,8 +73,8 @@ TURNSTILE_SITE_KEY=your_turnstile_key
TURNSTILE_SECRET_KEY=your_turnstile_secret
# Umami
NEXT_PUBLIC_UMAMI_WEBSITE_ID=your_umami_website_id
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
UMAMI_WEBSITE_ID=your_umami_website_id
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
# GlitchTip (Sentry compatible)
SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
@@ -81,6 +85,7 @@ NEXT_PUBLIC_SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
## 📊 Project Overview
### Migration Statistics
- **Content Exported**: 141 items
- 18 pages (9 EN + 9 DE)
- 59 posts (29 EN + 30 DE)
@@ -91,6 +96,7 @@ NEXT_PUBLIC_SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
- **Translation Pairs**: 16
### Performance Benefits
- **Before**: Dynamic WordPress with database queries
- **After**: Static HTML with CDN delivery
- **Load Time**: <100ms (vs 500ms+)
@@ -99,6 +105,7 @@ NEXT_PUBLIC_SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
## 🏗️ Architecture
### Tech Stack
- **Framework**: Next.js 14 (App Router)
- **Language**: TypeScript
- **Styling**: SCSS
@@ -109,6 +116,7 @@ NEXT_PUBLIC_SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
- **CAPTCHA**: Cloudflare Turnstile
### Project Structure
```
app/
├── layout.tsx # Root layout
@@ -163,6 +171,7 @@ scripts/
## 🎯 Features
### ✅ Implemented
- **Multi-language**: EN/DE with `/de/` prefix routing
- **Contact Forms**: Resend integration with validation
- **GDPR Compliance**: Cookie consent banner
@@ -175,12 +184,14 @@ scripts/
- **Asset Management**: WordPress → local path mapping
### 🔄 In Progress
- Analytics integration (consent-based)
- Turnstile CAPTCHA
- Build testing
- Deployment configuration
### 📝 Remaining
- Performance optimization
- Final QA testing
- Documentation updates
@@ -188,6 +199,7 @@ scripts/
## 📝 Content Management
### Data Export
```bash
# Export from WordPress
npm run data:export
@@ -203,6 +215,7 @@ npm run data:improve-mapping
```
### Adding New Content
1. Export new content from WordPress
2. Process the data
3. Rebuild the site
@@ -210,17 +223,20 @@ npm run data:improve-mapping
## 🎨 Design System
### Colors
- Primary: `#0066cc` (KLZ Blue)
- Secondary: `#00a896` (Teal)
- Text: `#1a1a1a`
- Background: `#f8f9fa`
### Typography
- Font: Inter
- Base: 16px
- Scale: 1.25 (Major Third)
### Layout
- Max width: 1200px
- Responsive grid
- Mobile-first
@@ -228,6 +244,7 @@ npm run data:improve-mapping
## 🔧 API Endpoints
### Contact Form
```
POST /api/contact
{
@@ -239,11 +256,13 @@ POST /api/contact
```
### Sitemap
```
GET /sitemap.xml
```
### Robots
```
GET /robots.txt
```
@@ -261,6 +280,7 @@ The project uses **Gitea Actions** for CI/CD. Every push to `main` or `staging`
**Workflow**: `.gitea/workflows/deploy.yml`
**Branch Deployments**:
- `main` branch: Deploys to production using `.env.prod`
- `staging` branch: Deploys to staging using `.env.staging`
@@ -268,12 +288,13 @@ The project uses **Gitea Actions** for CI/CD. Every push to `main` or `staging`
The CI/CD workflow supports `STAGING_`-prefixed secrets (e.g., `STAGING_NEXT_PUBLIC_BASE_URL`) to override default secrets when deploying the `staging` branch.
**Required Secrets** (configure in Gitea repository settings):
- `REGISTRY_USER` - Docker registry username
- `REGISTRY_PASS` - Docker registry password
- `ALPHA_SSH_KEY` - SSH private key for deployment
- `NEXT_PUBLIC_BASE_URL` - Application base URL (e.g., `https://klz-cables.com`)
- `NEXT_PUBLIC_UMAMI_WEBSITE_ID` - Analytics ID
- `NEXT_PUBLIC_UMAMI_SCRIPT_URL` - Analytics script URL
- `UMAMI_WEBSITE_ID` - Analytics ID
- `UMAMI_API_ENDPOINT` - Analytics API endpoint (formerly NEXT_PUBLIC_UMAMI_SCRIPT_URL)
- `SENTRY_DSN` - Error tracking DSN
- `MAIL_HOST`, `MAIL_PORT`, `MAIL_USERNAME`, `MAIL_PASSWORD`, `MAIL_FROM`, `MAIL_RECIPIENTS` - Email configuration
@@ -293,6 +314,7 @@ docker image prune -f
```
Or use the convenience script:
```bash
bash scripts/deploy-webhook.sh
```
@@ -304,11 +326,13 @@ Client → Traefik (TLS) → Next.js App
```
**Domains**:
- `klz-cables.com` - Production
- `www.klz-cables.com` - Production (www)
- `staging.klz-cables.com` - Staging
**Services**:
- `app`: Next.js application (port 3000)
- `traefik`: Reverse proxy (external)
@@ -317,25 +341,30 @@ For detailed deployment documentation, see [`docs/DEPLOYMENT.md`](docs/DEPLOYMEN
## 📈 Performance
### Build Time
- **Target**: < 2 minutes
- **Current**: ~1-2 minutes
### Page Load
- **Target**: < 100ms
- **Current**: Static HTML from CDN
### Bundle Size
- **Target**: < 100KB gzipped
- **Current**: Optimized with code splitting
## 🔒 Security
### Environment Variables
- Never commit `.env` file
- Rotate keys regularly
- Use secrets in deployment platform
### Form Security
- Email validation
- Rate limiting (recommended)
- Turnstile CAPTCHA (pending)
@@ -343,6 +372,7 @@ For detailed deployment documentation, see [`docs/DEPLOYMENT.md`](docs/DEPLOYMEN
## 🎓 WordPress Specifics
### WPBakery Shortcodes Removed
- `[vc_row]`, `[vc_column]`, `[vc_column_text]`
- `[nectar_*]` (Salient theme)
- `[image_with_animation]`
@@ -350,13 +380,16 @@ For detailed deployment documentation, see [`docs/DEPLOYMENT.md`](docs/DEPLOYMEN
- `[divider]`
### HTML Sanitization
- Removes inline event handlers
- Strips scripts
- Normalizes classes
- Preserves structure
### Asset Mapping
WordPress URLs → Local paths:
```
https://klz-cables.com/wp-content/uploads/... → /media/...
```
@@ -364,11 +397,13 @@ https://klz-cables.com/wp-content/uploads/... → /media/...
## 📚 Documentation
### Internal
- `PROJECT_STRUCTURE.md` - Detailed structure
- `IMPLEMENTATION_SUMMARY.md` - Progress tracking
- `FINAL_SUMMARY.md` - Complete overview
### External
- [Next.js Docs](https://nextjs.org/docs)
- [WordPress REST API](https://developer.wordpress.org/rest-api/)
- [Resend Docs](https://resend.com/docs)
@@ -379,17 +414,20 @@ https://klz-cables.com/wp-content/uploads/... → /media/...
### Common Issues
**TypeScript Errors**
- The TypeScript errors shown in the editor are expected
- They occur because modules reference each other
- The build process resolves these correctly
- Run `npm run build` to verify
**Build Failures**
- Check environment variables
- Verify data files exist
- Clear `.next` cache: `rm -rf .next`
**Missing Modules**
- Run `npm install --legacy-peer-deps`
- Check `package.json` dependencies
@@ -404,11 +442,12 @@ https://klz-cables.com/wp-content/uploads/... → /media/...
**i18n**: Multi-language support
**SEO**: Metadata and sitemaps
**Compatibility**: WPBakery content handled
**Media**: All images downloaded
**Media**: All images downloaded
## 📞 Support
For issues or questions:
1. Check the documentation
2. Review the troubleshooting section
3. Check environment variables

View File

@@ -8,6 +8,7 @@ import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
import '../../styles/globals.css';
import { SITE_URL } from '@/lib/schema';
import { config } from '@/lib/config';
export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
@@ -51,7 +52,7 @@ export default async function LocaleLayout({
<CMSConnectivityNotice />
{/* Sends pageviews for client-side navigations */}
<AnalyticsProvider />
<AnalyticsProvider websiteId={config.analytics.umami.websiteId} />
</NextIntlClientProvider>
</body>
</html>

View File

@@ -3,7 +3,6 @@
import { useEffect } from 'react';
import { usePathname, useSearchParams } from 'next/navigation';
import { getAppServices } from '@/lib/services/create-services';
import Script from 'next/script';
/**
* AnalyticsProvider Component
@@ -11,49 +10,35 @@ import Script from 'next/script';
* Automatically tracks pageviews on client-side route changes.
* This component should be placed inside your layout to handle navigation events.
*
* @param {Object} props - Component props
* @param {string} [props.websiteId] - The Umami website ID (passed from server config)
*
* @example
* ```tsx
* // In your layout.tsx
* <NextIntlClientProvider messages={messages} locale={locale}>
* <UmamiScript />
* <Header />
* <main>{children}</main>
* <Footer />
* <AnalyticsProvider />
* </NextIntlClientProvider>
* const { websiteId } = config.analytics.umami;
* <AnalyticsProvider websiteId={websiteId} />
* ```
*/
export default function AnalyticsProvider() {
export default function AnalyticsProvider({ websiteId }: { websiteId?: string }) {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
if (!pathname) return;
const services = getAppServices();
const url = `${pathname}${searchParams?.size ? `?${searchParams.toString()}` : ''}`;
// Track pageview with the full URL
services.analytics.trackPageview(url);
if (process.env.NODE_ENV === 'development') {
console.log('[Umami] Tracked pageview:', url);
}
}, [pathname, searchParams]);
const websiteId = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID;
if (!websiteId) return null;
return (
<Script
id="umami-analytics"
src="/stats/script.js"
data-website-id={websiteId}
data-host-url="/stats"
strategy="afterInteractive"
data-domains="klz-cables.com"
defer
/>
);
return null;
}

View File

@@ -25,31 +25,43 @@ services:
labels:
- "traefik.enable=true"
# HTTP ⇒ HTTPS redirect
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.rule=Host(`${TRAEFIK_HOST}`) && !PathPrefix(`/.well-known/acme-challenge/`)"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.rule=${TRAEFIK_HOST_RULE:-Host(`klz-cables.com`)}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.entrypoints=web"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.middlewares=redirect-https"
# HTTPS router
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.rule=Host(`${TRAEFIK_HOST}`)"
# HTTPS router (Protected)
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.rule=${TRAEFIK_HOST_RULE:-Host(`klz-cables.com`)}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.entrypoints=websecure"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls.certresolver=le"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls=true"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.service=${PROJECT_NAME:-klz-cables}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.middlewares=${PROJECT_NAME:-klz-cables}-ratelimit,${PROJECT_NAME:-klz-cables}-forward,${AUTH_MIDDLEWARE:-${PROJECT_NAME:-klz-cables}-compress}"
# HTTPS router (Unprotected - for Analytics & Errors)
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.rule=${TRAEFIK_HOST_RULE:-Host(`klz-cables.com`)} && PathPrefix(`/stats`, `/errors`)"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.entrypoints=websecure"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.tls.certresolver=le"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.tls=true"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.service=${PROJECT_NAME:-klz-cables}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.middlewares=${PROJECT_NAME:-klz-cables}-ratelimit,${PROJECT_NAME:-klz-cables}-forward,${PROJECT_NAME:-klz-cables}-compress"
- "traefik.http.services.${PROJECT_NAME:-klz-cables}.loadbalancer.server.port=80"
- "traefik.http.services.${PROJECT_NAME:-klz-cables}.loadbalancer.server.scheme=http"
# Forwarded Headers
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
# Middlewares
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.middlewares=${PROJECT_NAME:-klz-cables}-ratelimit,${PROJECT_NAME:-klz-cables}-forward,${AUTH_MIDDLEWARE:-compress}"
- "traefik.docker.network=infra"
# Middleware Definitions
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-compress.compress=true"
# Gatekeeper Router (to show the login page)
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.rule=Host(`gatekeeper.${TRAEFIK_HOST}`)"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.rule=Host(`gatekeeper.${TRAEFIK_HOST:-klz-cables.com}`)"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.entrypoints=websecure"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.tls.certresolver=le"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.tls=true"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.service=${PROJECT_NAME:-klz-cables}-gatekeeper"
# Forwarded Headers
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
# Middleware Definitions
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-ratelimit.ratelimit.average=100"
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-ratelimit.ratelimit.burst=50"
@@ -70,7 +82,7 @@ services:
PORT: 3000
COOKIE_DOMAIN: ${COOKIE_DOMAIN}
AUTH_COOKIE_NAME: klz_gatekeeper_session
NEXT_PUBLIC_BASE_URL: https://gatekeeper.${TRAEFIK_HOST}
NEXT_PUBLIC_BASE_URL: https://gatekeeper.${TRAEFIK_HOST:-klz-cables.com}
GATEKEEPER_PASSWORD: ${GATEKEEPER_PASSWORD:-klz2026}
labels:
- "traefik.enable=true"

View File

@@ -42,15 +42,15 @@ The application uses a clean, robust, **fully automated** environment variable s
## Environment Variables
### Build-Time Variables (NEXT_PUBLIC_*)
### Build-Time Variables (NEXT*PUBLIC*\*)
These are embedded into the JavaScript bundle during build and are visible to the client:
| Variable | Required | Description |
|----------|----------|-------------|
| `NEXT_PUBLIC_BASE_URL` | ✅ Yes | Base URL of the application (e.g., `https://klz-cables.com`) |
| `NEXT_PUBLIC_UMAMI_WEBSITE_ID` | ❌ No | Umami analytics website ID |
| `NEXT_PUBLIC_UMAMI_SCRIPT_URL` | ❌ No | Umami analytics script URL (default: `https://analytics.infra.mintel.me/script.js`) |
| Variable | Required | Description |
| ---------------------- | -------- | ------------------------------------------------------------ |
| `NEXT_PUBLIC_BASE_URL` | ✅ Yes | Base URL of the application (e.g., `https://klz-cables.com`) |
| `UMAMI_WEBSITE_ID` | ❌ No | Umami analytics website ID (passed as prop) |
| `UMAMI_API_ENDPOINT` | ❌ No | Backend-only Umami analytics API target (internal) |
**Important**: These must be provided as `--build-arg` when building the Docker image.
@@ -58,38 +58,40 @@ These are embedded into the JavaScript bundle during build and are visible to th
These are loaded from the `.env` file at runtime and are only available on the server:
| Variable | Required | Description |
|----------|----------|-------------|
| `NODE_ENV` | ✅ Yes | Environment mode (`production`, `development`, `test`) |
| `SENTRY_DSN` | ❌ No | GlitchTip/Sentry error tracking DSN |
| `MAIL_HOST` | ❌ No | SMTP server hostname |
| `MAIL_PORT` | ❌ No | SMTP server port (default: `587`) |
| `MAIL_USERNAME` | ❌ No | SMTP authentication username |
| `MAIL_PASSWORD` | ❌ No | SMTP authentication password |
| `MAIL_FROM` | ❌ No | Email sender address |
| `MAIL_RECIPIENTS` | ❌ No | Comma-separated list of recipient emails |
| `REDIS_URL` | ❌ No | Redis connection URL (e.g., `redis://redis:6379/2`) |
| `REDIS_KEY_PREFIX` | ❌ No | Redis key prefix (default: `klz:`) |
| `STRAPI_DATABASE_NAME` | ✅ Yes | Strapi database name |
| `STRAPI_DATABASE_USERNAME` | ✅ Yes | Strapi database username |
| `STRAPI_DATABASE_PASSWORD` | ✅ Yes | Strapi database password |
| `STRAPI_URL` | ✅ Yes | URL of the Strapi CMS |
| `APP_KEYS` | ✅ Yes | Strapi application keys (comma-separated) |
| `API_TOKEN_SALT` | ✅ Yes | Strapi API token salt |
| `ADMIN_JWT_SECRET` | ✅ Yes | Strapi admin JWT secret |
| `TRANSFER_TOKEN_SALT` | ✅ Yes | Strapi transfer token salt |
| `JWT_SECRET` | ✅ Yes | Strapi JWT secret |
| Variable | Required | Description |
| -------------------------- | -------- | ------------------------------------------------------ |
| `NODE_ENV` | ✅ Yes | Environment mode (`production`, `development`, `test`) |
| `SENTRY_DSN` | ❌ No | GlitchTip/Sentry error tracking DSN |
| `MAIL_HOST` | ❌ No | SMTP server hostname |
| `MAIL_PORT` | ❌ No | SMTP server port (default: `587`) |
| `MAIL_USERNAME` | ❌ No | SMTP authentication username |
| `MAIL_PASSWORD` | ❌ No | SMTP authentication password |
| `MAIL_FROM` | ❌ No | Email sender address |
| `MAIL_RECIPIENTS` | ❌ No | Comma-separated list of recipient emails |
| `REDIS_URL` | ❌ No | Redis connection URL (e.g., `redis://redis:6379/2`) |
| `REDIS_KEY_PREFIX` | ❌ No | Redis key prefix (default: `klz:`) |
| `STRAPI_DATABASE_NAME` | ✅ Yes | Strapi database name |
| `STRAPI_DATABASE_USERNAME` | ✅ Yes | Strapi database username |
| `STRAPI_DATABASE_PASSWORD` | ✅ Yes | Strapi database password |
| `STRAPI_URL` | ✅ Yes | URL of the Strapi CMS |
| `APP_KEYS` | ✅ Yes | Strapi application keys (comma-separated) |
| `API_TOKEN_SALT` | ✅ Yes | Strapi API token salt |
| `ADMIN_JWT_SECRET` | ✅ Yes | Strapi admin JWT secret |
| `TRANSFER_TOKEN_SALT` | ✅ Yes | Strapi transfer token salt |
| `JWT_SECRET` | ✅ Yes | Strapi JWT secret |
## Local Development
### Setup
1. Copy the example environment file:
```bash
cp .env.example .env
```
2. Edit `.env` and fill in your local configuration:
```bash
NODE_ENV=development
NEXT_PUBLIC_BASE_URL=http://localhost:3000
@@ -97,6 +99,7 @@ These are loaded from the `.env` file at runtime and are only available on the s
```
3. Install dependencies:
```bash
npm install
```
@@ -112,8 +115,8 @@ These are loaded from the `.env` file at runtime and are only available on the s
# Build with build-time arguments
docker build \
--build-arg NEXT_PUBLIC_BASE_URL=http://localhost:3000 \
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID=your-id \
--build-arg NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js \
--build-arg UMAMI_WEBSITE_ID=your-id \
--build-arg UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me \
-t klz-cables:local .
# Run with runtime environment file
@@ -138,8 +141,8 @@ docker run --env-file .env -p 3000:3000 klz-cables:local
**Build-Time Variables:**
- `NEXT_PUBLIC_BASE_URL` - Production URL (e.g., `https://klz-cables.com`)
- `NEXT_PUBLIC_UMAMI_WEBSITE_ID` - Umami analytics ID
- `NEXT_PUBLIC_UMAMI_SCRIPT_URL` - Umami script URL
- `UMAMI_WEBSITE_ID` - Umami analytics ID
- `UMAMI_API_ENDPOINT` - Umami API endpoint
**Runtime Variables:**
- `SENTRY_DSN` - Error tracking DSN
@@ -209,11 +212,12 @@ docker-compose logs -f app
**Problem**: Build fails with "Environment validation failed"
**Solution**: Ensure all required `NEXT_PUBLIC_*` variables are provided as build arguments:
```bash
docker build \
--build-arg NEXT_PUBLIC_BASE_URL=https://klz-cables.com \
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID=your-id \
--build-arg NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js \
--build-arg UMAMI_WEBSITE_ID=your-id \
--build-arg UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me \
-t klz-cables .
```
@@ -222,6 +226,7 @@ docker build \
**Problem**: Container starts but application crashes
**Solution**: Check that the `.env` file exists and contains all required runtime variables:
```bash
# On the server
cat /home/deploy/sites/klz-cables.com/.env
@@ -235,9 +240,11 @@ docker-compose logs app
**Problem**: Features not working (email, analytics, etc.)
**Solution**:
1. Check that the secret is configured in Gitea
2. Verify the workflow includes it in the `.env` generation (see `.gitea/workflows/deploy.yml`)
3. Redeploy to regenerate the `.env` file:
```bash
git commit --allow-empty -m "Trigger redeploy"
git push origin main
@@ -255,6 +262,7 @@ docker-compose logs app
**Problem**: `docker-compose up` fails with "env file not found"
**Solution**: The `.env` file should be automatically created by the workflow. If it's missing:
1. Check the workflow logs for errors in the "📝 Preparing environment configuration" step
2. Manually trigger a deployment by pushing to main
3. If still missing, check server permissions and disk space
@@ -264,6 +272,7 @@ docker-compose logs app
**Problem**: Container can't connect to Traefik
**Solution**: Verify the `infra` network exists:
```bash
docker network ls | grep infra
docker network inspect infra

View File

@@ -7,29 +7,31 @@ This guide helps you migrate from the old fragile environment variable setup to
### Before (Fragile & Overkill)
**Problems:**
- Environment variables passed individually via SSH (12+ vars)
- Duplicate definitions in Dockerfile, docker-compose.yml, and deploy.yml
- Build args included runtime-only variables (SENTRY_DSN, MAIL_*, REDIS_*)
- Build args included runtime-only variables (SENTRY*DSN, MAIL*_, REDIS\__)
- No single source of truth
- Difficult to maintain and error-prone
```yaml
# Old deploy.yml - FRAGILE!
ssh root@alpha.mintel.me \
"MAIL_FROM='${{ secrets.MAIL_FROM }}' \
MAIL_HOST='${{ secrets.MAIL_HOST }}' \
MAIL_PASSWORD='${{ secrets.MAIL_PASSWORD }}' \
MAIL_PORT='${{ secrets.MAIL_PORT }}' \
MAIL_RECIPIENTS='${{ secrets.MAIL_RECIPIENTS }}' \
MAIL_USERNAME='${{ secrets.MAIL_USERNAME }}' \
NEXT_PUBLIC_BASE_URL='${{ secrets.NEXT_PUBLIC_BASE_URL }}' \
... (12+ variables) \
/home/deploy/deploy.sh"
"MAIL_FROM='${{ secrets.MAIL_FROM }}' \
MAIL_HOST='${{ secrets.MAIL_HOST }}' \
MAIL_PASSWORD='${{ secrets.MAIL_PASSWORD }}' \
MAIL_PORT='${{ secrets.MAIL_PORT }}' \
MAIL_RECIPIENTS='${{ secrets.MAIL_RECIPIENTS }}' \
MAIL_USERNAME='${{ secrets.MAIL_USERNAME }}' \
NEXT_PUBLIC_BASE_URL='${{ secrets.NEXT_PUBLIC_BASE_URL }}' \
... (12+ variables) \
/home/deploy/deploy.sh"
```
### After (Clean & Robust)
**Benefits:**
- Single `.env` file on server contains all runtime variables
- Only `NEXT_PUBLIC_*` variables passed as build args (3 vars)
- Clear separation: build-time vs runtime
@@ -46,6 +48,7 @@ ssh root@alpha.mintel.me "/home/deploy/deploy.sh"
### Step 1: Update Gitea Secrets
**Remove these secrets** (no longer needed in CI/CD):
-`MAIL_FROM`
-`MAIL_HOST`
-`MAIL_PASSWORD`
@@ -58,9 +61,11 @@ ssh root@alpha.mintel.me "/home/deploy/deploy.sh"
-`SENTRY_DSN` (from build args)
**Keep these secrets** (still needed for build):
-`NEXT_PUBLIC_BASE_URL`
-`NEXT_PUBLIC_UMAMI_WEBSITE_ID`
-`NEXT_PUBLIC_UMAMI_SCRIPT_URL`
-`NEXT_PUBLIC_BASE_URL`
-`UMAMI_WEBSITE_ID`
-`UMAMI_API_ENDPOINT`
-`REGISTRY_USER`
-`REGISTRY_PASS`
-`ALPHA_SSH_KEY`
@@ -81,8 +86,8 @@ NODE_ENV=production
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
# Analytics
NEXT_PUBLIC_UMAMI_WEBSITE_ID=your-actual-id
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
UMAMI_WEBSITE_ID=your-actual-id
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
# Error Tracking
SENTRY_DSN=your-actual-dsn
@@ -168,6 +173,7 @@ git push origin main
```
The CI/CD workflow will:
1. Build with only `NEXT_PUBLIC_*` build args
2. Push to registry
3. SSH to server and run deploy.sh
@@ -197,21 +203,22 @@ curl -I https://klz-cables.com
## Comparison Table
| Aspect | Before | After |
|--------|--------|-------|
| **Gitea Secrets** | 15+ secrets | 8 secrets |
| **Build Args** | 4 vars (including runtime-only) | 3 vars (NEXT_PUBLIC_* only) |
| **Runtime Vars** | Passed via SSH command | Loaded from .env file |
| **Maintenance** | Update in 3 places | Update in 1 place |
| **Security** | Secrets in CI logs | Secrets only on server |
| **Clarity** | Confusing duplication | Clear separation |
| **Robustness** | Fragile SSH command | Robust file-based config |
| Aspect | Before | After |
| ----------------- | ------------------------------- | ---------------------------- |
| **Gitea Secrets** | 15+ secrets | 8 secrets |
| **Build Args** | 4 vars (including runtime-only) | 3 vars (NEXT*PUBLIC*\* only) |
| **Runtime Vars** | Passed via SSH command | Loaded from .env file |
| **Maintenance** | Update in 3 places | Update in 1 place |
| **Security** | Secrets in CI logs | Secrets only on server |
| **Clarity** | Confusing duplication | Clear separation |
| **Robustness** | Fragile SSH command | Robust file-based config |
## Rollback Plan
If you need to rollback to the old system:
1. Revert the changes in git:
```bash
git revert HEAD
git push origin main
@@ -229,7 +236,8 @@ A: `NEXT_PUBLIC_*` variables are special in Next.js - they're embedded into the
**Q: Can I update environment variables without rebuilding?**
A: Yes, for runtime-only variables (MAIL_*, REDIS_*, SENTRY_DSN, etc.). Just edit the `.env` file on the server and restart containers:
A: Yes, for runtime-only variables (MAIL*\*, REDIS*\*, SENTRY_DSN, etc.). Just edit the `.env` file on the server and restart containers:
```bash
nano /home/deploy/sites/klz-cables.com/.env
docker-compose down && docker-compose up -d
@@ -240,6 +248,7 @@ For `NEXT_PUBLIC_*` variables, you need to rebuild the Docker image since they'r
**Q: Where should I store the .env file backup?**
A: Keep a secure backup outside the server:
```bash
# Download from server
scp root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/.env \
@@ -250,7 +259,8 @@ scp root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/.env \
**Q: What if I accidentally commit .env to git?**
A:
A:
1. Remove it immediately: `git rm .env && git commit -m "Remove .env"`
2. Rotate all credentials in the file
3. Update the `.gitignore` to ensure it doesn't happen again (already done)
@@ -267,6 +277,7 @@ If you encounter issues during migration:
## Summary
The new system is:
-**Simpler**: One .env file instead of scattered variables
-**Cleaner**: Clear separation of build vs runtime
-**Robust**: File-based config instead of fragile SSH commands

View File

@@ -28,9 +28,7 @@ function createConfig() {
analytics: {
umami: {
websiteId: env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
scriptUrl: env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
// The proxied path used in the frontend
proxyPath: '/stats/script.js',
apiEndpoint: env.UMAMI_API_ENDPOINT,
enabled: Boolean(env.NEXT_PUBLIC_UMAMI_WEBSITE_ID),
},
},
@@ -152,7 +150,7 @@ export function getMaskedConfig() {
analytics: {
umami: {
websiteId: mask(c.analytics.umami.websiteId),
scriptUrl: c.analytics.umami.scriptUrl,
apiEndpoint: c.analytics.umami.apiEndpoint,
enabled: c.analytics.umami.enabled,
},
},

View File

@@ -16,9 +16,9 @@ export const envSchema = z
// Analytics
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.preprocess(preprocessEmptyString, z.string().optional()),
NEXT_PUBLIC_UMAMI_SCRIPT_URL: z.preprocess(
UMAMI_API_ENDPOINT: z.preprocess(
preprocessEmptyString,
z.string().url().default('https://analytics.infra.mintel.me/script.js'),
z.string().url().default('https://analytics.infra.mintel.me'),
),
// Error Tracking
@@ -82,8 +82,12 @@ export function getRawEnv() {
NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
NEXT_PUBLIC_TARGET: process.env.NEXT_PUBLIC_TARGET,
NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
NEXT_PUBLIC_UMAMI_SCRIPT_URL: process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
NEXT_PUBLIC_UMAMI_WEBSITE_ID:
process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || process.env.UMAMI_WEBSITE_ID,
UMAMI_API_ENDPOINT:
process.env.UMAMI_API_ENDPOINT ||
process.env.UMAMI_SCRIPT_URL ||
process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
SENTRY_DSN: process.env.SENTRY_DSN,
LOG_LEVEL: process.env.LOG_LEVEL,
MAIL_HOST: process.env.MAIL_HOST,

View File

@@ -47,7 +47,7 @@ describe('mailer', () => {
});
expect(result.success).toBe(false);
expect((result.error as Error).message).toContain('MAIL_HOST is not configured');
expect(result.error).toContain('MAIL_HOST is not configured');
// Restore host
(config.mail as any).host = originalHost;

View File

@@ -1,14 +1,5 @@
import type { AnalyticsEventProperties, AnalyticsService } from './analytics-service';
/**
* Type definition for the Umami global object.
*
* This represents the `window.umami` object that the Umami script exposes.
* The `track` function can accept either an event name or a URL.
*/
type UmamiGlobal = {
track?: (eventOrUrl: string, props?: AnalyticsEventProperties) => void;
};
import { config } from '../../config';
/**
* Configuration options for UmamiAnalyticsService.
@@ -20,133 +11,90 @@ export type UmamiAnalyticsServiceOptions = {
};
/**
* Umami Analytics Service Implementation.
* Umami Analytics Service Implementation (Script-less/Proxy edition).
*
* This service implements the AnalyticsService interface for Umami analytics.
* It provides type-safe event tracking and pageview tracking.
* This version implements the Umami tracking protocol directly via fetch,
* eliminating the need to load an external script.js file.
*
* @example
* ```typescript
* // Service creation (usually done by create-services.ts)
* const service = new UmamiAnalyticsService({ enabled: true });
*
* // Track events
* service.track('button_click', { button_id: 'cta' });
* service.trackPageview('/products/123');
* ```
*
* @example
* ```typescript
* // Using through the service layer (recommended)
* import { getAppServices } from '@/lib/services/create-services';
*
* const services = getAppServices();
* services.analytics.track('product_add_to_cart', {
* product_id: '123',
* price: 99.99,
* });
* ```
* In the browser, it gathers standard metadata (screen, language, referrer)
* and sends it to the proxied '/stats/api/send' endpoint.
*/
export class UmamiAnalyticsService implements AnalyticsService {
constructor(private readonly options: UmamiAnalyticsServiceOptions) {}
private websiteId?: string;
private endpoint: string;
constructor(private readonly options: UmamiAnalyticsServiceOptions) {
this.websiteId = config.analytics.umami.websiteId;
// On server, use the full internal URL; on client, use the proxied path
this.endpoint = typeof window === 'undefined' ? config.analytics.umami.apiEndpoint : '/stats';
}
/**
* Track a custom event with optional properties.
*
* This method checks if analytics are enabled and if we're in a browser environment
* before attempting to track the event.
*
* @param eventName - The name of the event to track
* @param props - Optional event properties
*
* @example
* ```typescript
* service.track('product_add_to_cart', {
* product_id: '123',
* product_name: 'Cable',
* price: 99.99,
* quantity: 1,
* });
* ```
* Internal method to send the payload to Umami API.
*/
private async sendPayload(type: 'event', data: Record<string, any>) {
if (!this.options.enabled || !this.websiteId) return;
try {
const payload = {
website: this.websiteId,
hostname: typeof window !== 'undefined' ? window.location.hostname : 'server',
screen:
typeof window !== 'undefined'
? `${window.screen.width}x${window.screen.height}`
: undefined,
language: typeof window !== 'undefined' ? navigator.language : undefined,
referrer: typeof window !== 'undefined' ? document.referrer : undefined,
...data,
};
const response = await fetch(`${this.endpoint}/api/send`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': typeof window === 'undefined' ? 'KLZ-Server' : navigator.userAgent,
},
body: JSON.stringify({ type, payload }),
// Use keepalive for page navigation events to ensure they complete
keepalive: true,
} as any);
if (!response.ok && process.env.NODE_ENV === 'development') {
const errorText = await response.text();
console.warn(`[Umami] API responded with ${response.status}: ${errorText}`);
}
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.error('[Umami] Failed to send analytics:', error);
}
}
}
/**
* Track a custom event.
*/
track(eventName: string, props?: AnalyticsEventProperties) {
if (!this.options.enabled) return;
// Server-side tracking via proxy
if (typeof window === 'undefined') {
const { getServerAppServices } = require('../create-services.server');
const { config } = require('../../config');
const websiteId = config.analytics.umami.websiteId;
const umamiUrl = config.analytics.umami.scriptUrl.replace('/script.js', '');
if (!websiteId) return;
const logger = getServerAppServices().logger.child({ component: 'analytics' });
logger.info('Sending analytics event', { eventName, props });
fetch(`${umamiUrl}/api/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'User-Agent': 'KLZ-Server' },
body: JSON.stringify({ type: 'event', payload: { website: websiteId, name: eventName, data: props } }),
}).catch((error) => {
logger.error('Failed to send analytics event', { eventName, props, error });
});
return;
}
const umami = (window as unknown as { umami?: UmamiGlobal }).umami;
umami?.track?.(eventName, props);
this.sendPayload('event', {
name: eventName,
data: props,
url:
typeof window !== 'undefined'
? window.location.pathname + window.location.search
: undefined,
});
}
/**
* Track a pageview.
*
* This method checks if analytics are enabled and if we're in a browser environment
* before attempting to track the pageview.
*
* Umami treats `track(url)` as a pageview override, so we can use the same
* `track` function for both events and pageviews.
*
* @param url - The URL to track (defaults to current location)
*
* @example
* ```typescript
* // Track current page
* service.trackPageview();
*
* // Track custom URL
* service.trackPageview('/products/123?category=cables');
* ```
*/
trackPageview(url?: string) {
if (!this.options.enabled) return;
// Server-side tracking via proxy
if (typeof window === 'undefined') {
const { getServerAppServices } = require('../create-services.server');
const { config } = require('../../config');
const websiteId = config.analytics.umami.websiteId;
const umamiUrl = config.analytics.umami.scriptUrl.replace('/script.js', '');
if (!websiteId || !url) return;
const logger = getServerAppServices().logger.child({ component: 'analytics' });
logger.info('Sending analytics pageview', { url });
fetch(`${umamiUrl}/api/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'User-Agent': 'KLZ-Server' },
body: JSON.stringify({ type: 'event', payload: { website: websiteId, url } }),
}).catch((error) => {
logger.error('Failed to send analytics pageview', { url, error });
});
return;
}
const umami = (window as unknown as { umami?: UmamiGlobal }).umami;
// Umami treats `track(url)` as a pageview override.
if (url) umami?.track?.(url);
else umami?.track?.(window.location.pathname + window.location.search);
this.sendPayload('event', {
url:
url ||
(typeof window !== 'undefined'
? window.location.pathname + window.location.search
: undefined),
});
}
}

View File

@@ -28,7 +28,7 @@ let singleton: AppServices | undefined;
* - Cache service (in-memory)
*
* The services are configured based on environment variables:
* - `NEXT_PUBLIC_UMAMI_WEBSITE_ID` - Enables Umami analytics
* - `UMAMI_WEBSITE_ID` - Enables Umami analytics
* - `NEXT_PUBLIC_SENTRY_DSN` - Enables client-side error reporting
* - `SENTRY_DSN` - Enables server-side error reporting
*

View File

@@ -62,5 +62,5 @@ export default function middleware(request: NextRequest) {
export const config = {
// Match only internationalized pathnames
matcher: ['/((?!api|_next|_vercel|health|.*\\..*).*)', '/', '/(de|en)/:path*'],
matcher: ['/((?!api|_next|_vercel|stats|errors|health|.*\\..*).*)', '/', '/(de|en)/:path*'],
};

View File

@@ -322,7 +322,7 @@ const nextConfig = {
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
},
async rewrites() {
const umamiUrl = (process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL || 'https://analytics.infra.mintel.me').replace('/script.js', '');
const umamiUrl = (process.env.UMAMI_API_ENDPOINT || process.env.UMAMI_SCRIPT_URL || process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL || 'https://analytics.infra.mintel.me');
const glitchtipUrl = process.env.SENTRY_DSN
? new URL(process.env.SENTRY_DSN).origin
: 'https://errors.infra.mintel.me';

View File

@@ -74,7 +74,11 @@
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests",
"test:og": "vitest run tests/og-image.test.ts",
"cms:bootstrap": "DIRECTUS_URL=http://localhost:8055 npx tsx --env-file=.env scripts/setup-directus-branding.ts",
"cms:branding:local": "DIRECTUS_URL=http://localhost:8055 npx tsx --env-file=.env scripts/setup-directus-branding.ts",
"cms:branding:testing": "DIRECTUS_URL=https://cms.testing.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts",
"cms:branding:staging": "DIRECTUS_URL=https://cms.staging.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts",
"cms:branding:prod": "DIRECTUS_URL=https://cms.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts",
"cms:bootstrap": "npm run cms:branding:local",
"pdf:datasheets": "tsx ./scripts/generate-pdf-datasheets.ts",
"pdf:datasheets:legacy": "tsx ./scripts/generate-pdf-datasheets-pdf-lib.ts",
"cms:push:staging": "./scripts/sync-directus.sh push staging",

View File

@@ -9,49 +9,65 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
async function setupBranding() {
console.log('🎨 Refining Directus Branding for Premium Website Look...');
console.log('🎨 Refining Directus Branding for Premium Website Look...');
// 1. Authenticate
await ensureAuthenticated();
// 1. Authenticate
await ensureAuthenticated();
try {
// 2. Upload Assets (MIME FIXED)
console.log('📤 Re-uploading assets for clean IDs...');
try {
// 2. Upload Assets (MIME FIXED)
console.log('📤 Re-uploading assets for clean IDs...');
const getMimeType = (filePath: string) => {
const ext = path.extname(filePath).toLowerCase();
switch (ext) {
case '.svg': return 'image/svg+xml';
case '.png': return 'image/png';
case '.jpg':
case '.jpeg': return 'image/jpeg';
case '.ico': return 'image/x-icon';
default: return 'application/octet-stream';
}
};
const getMimeType = (filePath: string) => {
const ext = path.extname(filePath).toLowerCase();
switch (ext) {
case '.svg':
return 'image/svg+xml';
case '.png':
return 'image/png';
case '.jpg':
case '.jpeg':
return 'image/jpeg';
case '.ico':
return 'image/x-icon';
default:
return 'application/octet-stream';
}
};
const uploadAsset = async (filePath: string, title: string) => {
if (!fs.existsSync(filePath)) {
console.warn(`⚠️ File not found: ${filePath}`);
return null;
}
const mimeType = getMimeType(filePath);
const form = new FormData();
const fileBuffer = fs.readFileSync(filePath);
const blob = new Blob([fileBuffer], { type: mimeType });
form.append('file', blob, path.basename(filePath));
form.append('title', title);
const res = await client.request(uploadFiles(form));
return res.id;
};
const uploadAsset = async (filePath: string, title: string) => {
if (!fs.existsSync(filePath)) {
console.warn(`⚠️ File not found: ${filePath}`);
return null;
}
const mimeType = getMimeType(filePath);
const form = new FormData();
const fileBuffer = fs.readFileSync(filePath);
const blob = new Blob([fileBuffer], { type: mimeType });
form.append('file', blob, path.basename(filePath));
form.append('title', title);
const res = await client.request(uploadFiles(form));
return res.id;
};
const logoWhiteId = await uploadAsset(path.resolve(__dirname, '../public/logo-white.svg'), 'Logo White');
const logoBlueId = await uploadAsset(path.resolve(__dirname, '../public/logo-blue.svg'), 'Logo Blue');
const faviconId = await uploadAsset(path.resolve(__dirname, '../public/favicon.ico'), 'Favicon');
const logoWhiteId = await uploadAsset(
path.resolve(__dirname, '../public/logo-white.svg'),
'Logo White',
);
const logoBlueId = await uploadAsset(
path.resolve(__dirname, '../public/logo-blue.svg'),
'Logo Blue',
);
const faviconId = await uploadAsset(
path.resolve(__dirname, '../public/favicon.ico'),
'Favicon',
);
// Smoother Background SVG
const bgSvgPath = path.resolve(__dirname, '../public/login-bg.svg');
fs.writeFileSync(bgSvgPath, `<svg width="1920" height="1080" viewBox="0 0 1920 1080" fill="none" xmlns="http://www.w3.org/2000/svg">
// Smoother Background SVG
const bgSvgPath = path.resolve(__dirname, '../public/login-bg.svg');
fs.writeFileSync(
bgSvgPath,
`<svg width="1920" height="1080" viewBox="0 0 1920 1080" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="1920" height="1080" fill="#001a4d"/>
<ellipse cx="960" cy="540" rx="800" ry="600" fill="url(#paint0_radial_premium)"/>
<defs>
@@ -60,122 +76,142 @@ async function setupBranding() {
<stop offset="1" stop-color="#001a4d" stop-opacity="0"/>
</radialGradient>
</defs>
</svg>`);
const backgroundId = await uploadAsset(bgSvgPath, 'Login Bg');
if (fs.existsSync(bgSvgPath)) fs.unlinkSync(bgSvgPath);
</svg>`,
);
const backgroundId = await uploadAsset(bgSvgPath, 'Login Bg');
if (fs.existsSync(bgSvgPath)) fs.unlinkSync(bgSvgPath);
// 3. Update Settings with "Premium Web" Theme
console.log('⚙️ Updating Directus settings...');
// 3. Update Settings with "Premium Web" Theme
console.log('⚙️ Updating Directus settings...');
const COLOR_PRIMARY = '#001a4d'; // Deep Blue
const COLOR_ACCENT = '#82ed20'; // Sustainability Green
const COLOR_SECONDARY = '#003d82';
const COLOR_PRIMARY = '#001a4d'; // Deep Blue
const COLOR_ACCENT = '#82ed20'; // Sustainability Green
const COLOR_SECONDARY = '#003d82';
const cssInjection = `
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
/* Global Login Styles */
body, .v-app {
font-family: 'Inter', sans-serif !important;
-webkit-font-smoothing: antialiased;
}
/* Glassmorphism Effect for Login Card */
.public-view .v-card {
background: rgba(255, 255, 255, 0.95) !important;
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.3) !important;
border-radius: 32px !important;
box-shadow: 0 50px 100px -20px rgba(0, 0, 0, 0.4) !important;
padding: 40px !important;
}
const customCss = `
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
/* Global Login Styles */
body, .v-app {
font-family: 'Inter', sans-serif !important;
-webkit-font-smoothing: antialiased;
}
/* Glassmorphism Effect for Login Card */
.public-view .v-card {
background: rgba(255, 255, 255, 0.95) !important;
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.3) !important;
border-radius: 32px !important;
box-shadow: 0 50px 100px -20px rgba(0, 0, 0, 0.4) !important;
padding: 40px !important;
}
.public-view .v-button {
border-radius: full !important;
height: 56px !important;
font-weight: 600 !important;
letter-spacing: -0.01em !important;
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1) !important;
}
.public-view .v-button {
border-radius: 9999px !important;
height: 56px !important;
font-weight: 600 !important;
letter-spacing: -0.01em !important;
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1) !important;
}
.public-view .v-button:hover {
transform: translateY(-2px);
box-shadow: 0 15px 30px rgba(130, 237, 32, 0.2) !important;
}
.public-view .v-button:hover {
transform: translateY(-2px);
box-shadow: 0 15px 30px rgba(130, 237, 32, 0.2) !important;
}
.public-view .v-input {
--v-input-border-radius: 12px !important;
--v-input-background-color: #f8f9fa !important;
}
</style>
<div style="font-family: 'Inter', sans-serif; text-align: center; margin-top: 24px;">
<p style="color: rgba(255,255,255,0.7); font-size: 14px; margin-bottom: 4px; font-weight: 500;">KLZ INFRASTRUCTURE ENGINE</p>
<h1 style="color: #ffffff; font-size: 18px; font-weight: 700; margin: 0;">Sustainable Energy. <span style="color: #82ed20;">Industrial Reliability.</span></h1>
</div>
.public-view .v-input {
--v-input-border-radius: 12px !important;
--v-input-background-color: #f8f9fa !important;
}
/* Inject Headline via CSS to avoid raw HTML display in public_note */
.public-view .form::before {
content: 'Sustainable Energy. Industrial Reliability.';
display: block;
text-align: center;
font-size: 18px;
font-weight: 700;
color: #ffffff;
margin-bottom: 8px;
}
.public-view .form::after {
content: 'KLZ INFRASTRUCTURE ENGINE';
display: block;
text-align: center;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.1em;
color: rgba(255, 255, 255, 0.5);
margin-top: 24px;
}
`;
await client.request(updateSettings({
project_name: 'KLZ Cables',
project_url: 'https://klz-cables.com',
project_color: COLOR_ACCENT,
project_descriptor: 'Sustainable Energy Infrastructure',
project_owner: 'KLZ Cables',
const publicNote = '';
// FIXED: Use WHITE logo for the Blue Sidebar
project_logo: logoWhiteId as any,
await client.request(
updateSettings({
project_name: 'KLZ Cables',
project_url: 'https://klz-cables.com',
project_color: COLOR_ACCENT,
project_descriptor: 'Sustainable Energy Infrastructure',
public_foreground: logoWhiteId as any,
public_background: backgroundId as any,
public_note: cssInjection,
public_favicon: faviconId as any,
// FIXED: Use WHITE logo for the Blue Sidebar
project_logo: logoWhiteId as any,
// DEEP PREMIUM THEME
theme_light_overrides: {
// Brands
"primary": COLOR_ACCENT, // Buttons/Actions are GREEN like the website
"secondary": COLOR_SECONDARY,
public_foreground: logoWhiteId as any,
public_background: backgroundId as any,
public_note: publicNote,
public_favicon: faviconId as any,
custom_css: customCss,
// Content Area
"background": "#f1f3f7",
"backgroundNormal": "#ffffff",
"backgroundAccent": "#eef2ff",
// DEEP PREMIUM THEME
theme_light_overrides: {
// Brands
primary: COLOR_ACCENT, // Buttons/Actions are GREEN like the website
secondary: COLOR_SECONDARY,
// Sidebar Branding
"navigationBackground": COLOR_PRIMARY,
"navigationForeground": "#ffffff",
"navigationBackgroundHover": "rgba(255,255,255,0.05)",
"navigationForegroundHover": "#ffffff",
"navigationBackgroundActive": "rgba(130, 237, 32, 0.15)", // Subtle Green highlight
"navigationForegroundActive": COLOR_ACCENT, // Active item is GREEN
// Content Area
background: '#f1f3f7',
backgroundNormal: '#ffffff',
backgroundAccent: '#eef2ff',
// Module Bar (Thin far left)
"moduleBarBackground": "#000d26",
"moduleBarForeground": "#ffffff",
"moduleBarForegroundActive": COLOR_ACCENT,
// Sidebar Branding
navigationBackground: COLOR_PRIMARY,
navigationForeground: '#ffffff',
navigationBackgroundHover: 'rgba(255,255,255,0.05)',
navigationForegroundHover: '#ffffff',
navigationBackgroundActive: 'rgba(130, 237, 32, 0.15)', // Subtle Green highlight
navigationForegroundActive: COLOR_ACCENT, // Active item is GREEN
// UI Standards
"borderRadius": "16px", // Larger radius for modern feel
"borderWidth": "1px",
"borderColor": "#e2e8f0",
"formFieldHeight": "48px" // Touch-target height
} as any,
// Module Bar (Thin far left)
moduleBarBackground: '#000d26',
moduleBarForeground: '#ffffff',
moduleBarForegroundActive: COLOR_ACCENT,
theme_dark_overrides: {
"primary": COLOR_ACCENT,
"background": "#0a0a0a",
"navigationBackground": "#000000",
"moduleBarBackground": COLOR_PRIMARY,
"borderRadius": "16px",
"formFieldHeight": "48px"
} as any
}));
// UI Standards
borderRadius: '16px', // Larger radius for modern feel
borderWidth: '1px',
borderColor: '#e2e8f0',
formFieldHeight: '48px', // Touch-target height
} as any,
console.log('✨ Premium Theme applied successfully!');
theme_dark_overrides: {
primary: COLOR_ACCENT,
background: '#0a0a0a',
navigationBackground: '#000000',
moduleBarBackground: COLOR_PRIMARY,
borderRadius: '16px',
formFieldHeight: '48px',
} as any,
}),
);
} catch (error: any) {
console.error('❌ Error:', JSON.stringify(error, null, 2));
}
console.log('✨ Premium Theme applied successfully!');
} catch (error: any) {
console.error('❌ Error:', JSON.stringify(error, null, 2));
}
}
setupBranding();

File diff suppressed because one or more lines are too long