This commit is contained in:
2026-01-29 19:47:55 +01:00
parent 506c8682fe
commit be9f9cf483
42 changed files with 756 additions and 2 deletions

10
.env
View File

@@ -19,3 +19,13 @@ MAIL_USERNAME=postmaster@mg.mintel.me
MAIL_PASSWORD=4592fcb94599ee1a45b4ac2386fd0a64-102c75d8-ca2870e6
MAIL_FROM="KLZ Cables <postmaster@mg.mintel.me>"
MAIL_RECIPIENTS=marc@cablecreations.de,info@klz-cables.com
# Strapi
STRAPI_DATABASE_NAME=strapi
STRAPI_DATABASE_USERNAME=strapi
STRAPI_DATABASE_PASSWORD=strapi_password_change_me
APP_KEYS=toBeModified1,toBeModified2,toBeModified3,toBeModified4
API_TOKEN_SALT=tobemodified
ADMIN_JWT_SECRET=tobemodified
TRANSFER_TOKEN_SALT=tobemodified
JWT_SECRET=tobemodified

View File

@@ -45,6 +45,19 @@ LOG_LEVEL=info
# ────────────────────────────────────────────────────────────────────────────
VARNISH_CACHE_SIZE=256m
# ────────────────────────────────────────────────────────────────────────────
# Strapi CMS
# ────────────────────────────────────────────────────────────────────────────
STRAPI_DATABASE_NAME=strapi
STRAPI_DATABASE_USERNAME=strapi
STRAPI_DATABASE_PASSWORD=strapi
STRAPI_URL=http://localhost:1337
APP_KEYS=toBeModified1,toBeModified2
API_TOKEN_SALT=tobemodified
ADMIN_JWT_SECRET=tobemodified
TRANSFER_TOKEN_SALT=tobemodified
JWT_SECRET=tobemodified
# ============================================================================
# IMPORTANT NOTES
# ============================================================================

View File

@@ -26,6 +26,15 @@ MAIL_PASSWORD=
MAIL_FROM=KLZ Cables <noreply@klz-cables.com>
MAIL_RECIPIENTS=info@klz-cables.com
# Strapi
STRAPI_DATABASE_NAME=strapi
STRAPI_DATABASE_USERNAME=strapi
STRAPI_DATABASE_PASSWORD=
APP_KEYS=
API_TOKEN_SALT=
ADMIN_JWT_SECRET=
TRANSFER_TOKEN_SALT=
JWT_SECRET=
# Varnish Cache Size (optional)
VARNISH_CACHE_SIZE=256m

View File

@@ -29,6 +29,35 @@ npm run export
# Or run development server
npm run dev
### 🏗️ CMS (Strapi)
The CMS runs in Docker. Use the following npm scripts for local development:
```bash
# Start Strapi and its database
npm run cms:dev
# View logs
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
# Export local data
npm run cms:export -- my-data.tar.gz
# Import data
npm run cms:import -- my-data.tar.gz
# Migrate existing MDX data to Strapi
npm run cms:migrate
```
### Environment Variables
@@ -73,7 +102,8 @@ NEXT_PUBLIC_SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
- **Framework**: Next.js 14 (App Router)
- **Language**: TypeScript
- **Styling**: SCSS
- **Data**: Static JSON (WordPress export)
- **CMS**: Strapi (Source of Truth)
- **Data**: Static JSON (WordPress export) & Strapi API
- **Email**: Resend
- **Analytics**: Vercel (consent-based)
- **CAPTCHA**: Cloudflare Turnstile

16
cms/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM node:20-alpine
# Installing libvips-dev for sharp Compatibility
RUN apk update && apk add --no-cache build-base gcc autoconf automake zlib-dev libpng-dev vips-dev git > /dev/null 2>&1
WORKDIR /opt/
COPY package.json ./
RUN npm install -g node-gyp
RUN npm config set fetch-retry-maxtimeout 600000 -g && npm install
ENV PATH /opt/node_modules/.bin:$PATH
WORKDIR /opt/app
COPY . .
RUN NODE_ENV=production npm run build
EXPOSE 1337
CMD ["npm", "run", "develop"]

13
cms/config/admin.ts Normal file
View File

@@ -0,0 +1,13 @@
export default ({ env }) => ({
auth: {
secret: env('ADMIN_JWT_SECRET'),
},
apiToken: {
salt: env('API_TOKEN_SALT'),
},
transfer: {
token: {
salt: env('TRANSFER_TOKEN_SALT'),
},
},
});

7
cms/config/api.ts Normal file
View File

@@ -0,0 +1,7 @@
export default {
rest: {
defaultLimit: 25,
maxLimit: 100,
withCount: true,
},
};

14
cms/config/database.ts Normal file
View File

@@ -0,0 +1,14 @@
export default ({ env }) => ({
connection: {
client: 'postgres',
connection: {
host: env('DATABASE_HOST', '127.0.0.1'),
port: env.int('DATABASE_PORT', 5432),
database: env('DATABASE_NAME', 'strapi'),
user: env('DATABASE_USERNAME', 'strapi'),
password: env('DATABASE_PASSWORD', 'strapi'),
ssl: env.bool('DATABASE_SSL', false),
},
pool: { min: env.int('DATABASE_POOL_MIN', 2), max: env.int('DATABASE_POOL_MAX', 10) },
},
});

10
cms/config/server.ts Normal file
View File

@@ -0,0 +1,10 @@
export default ({ env }) => ({
host: env('HOST', '0.0.0.0'),
port: env.int('PORT', 1337),
app: {
keys: env.array('APP_KEYS'),
},
webhooks: {
populateRelations: env.bool('WEBHOOKS_POPULATE_RELATIONS', false),
},
});

39
cms/package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "klz-cms",
"private": true,
"version": "0.1.0",
"description": "Strapi CMS for KLZ Cables",
"scripts": {
"develop": "strapi develop",
"start": "strapi start",
"build": "strapi build",
"strapi": "strapi"
},
"dependencies": {
"@strapi/strapi": "4.25.11",
"@strapi/plugin-users-permissions": "4.25.11",
"@strapi/plugin-i18n": "4.25.11",
"pg": "8.11.3",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-router-dom": "^5.2.0",
"styled-components": "^5.2.1"
},
"devDependencies": {
"@types/node": "^18.0.0",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"typescript": "^5.0.0"
},
"author": {
"name": "A Strapi developer"
},
"strapi": {
"uuid": "klz-cms"
},
"engines": {
"node": ">=18.0.0 <=20.x.x",
"npm": ">=6.0.0"
},
"license": "MIT"
}

View File

@@ -0,0 +1,38 @@
{
"kind": "collectionType",
"collectionName": "applications",
"info": {
"singularName": "application",
"pluralName": "applications",
"displayName": "Application"
},
"options": {
"draftAndPublish": false
},
"attributes": {
"job": {
"type": "relation",
"relation": "manyToOne",
"target": "api::job.job"
},
"name": {
"type": "string",
"required": true
},
"email": {
"type": "email",
"required": true
},
"resume": {
"type": "media",
"multiple": false,
"required": true,
"allowedTypes": [
"files"
]
},
"message": {
"type": "text"
}
}
}

View File

@@ -0,0 +1,2 @@
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::application.application');

View File

@@ -0,0 +1,2 @@
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::application.application');

View File

@@ -0,0 +1,2 @@
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::application.application');

View File

@@ -0,0 +1,39 @@
{
"kind": "collectionType",
"collectionName": "categories",
"info": {
"singularName": "category",
"pluralName": "categories",
"displayName": "Category"
},
"options": {
"draftAndPublish": false
},
"pluginOptions": {
"i18n": {
"localized": true
}
},
"attributes": {
"name": {
"type": "string",
"required": true,
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"slug": {
"type": "uid",
"targetField": "name",
"required": true
},
"products": {
"type": "relation",
"relation": "manyToMany",
"target": "api::product.product",
"mappedBy": "categories"
}
}
}

View File

@@ -0,0 +1,2 @@
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::category.category');

View File

@@ -0,0 +1,2 @@
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::category.category');

View File

@@ -0,0 +1,2 @@
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::category.category');

View File

@@ -0,0 +1,29 @@
{
"kind": "collectionType",
"collectionName": "contact_messages",
"info": {
"singularName": "contact-message",
"pluralName": "contact-messages",
"displayName": "Contact Message"
},
"options": {
"draftAndPublish": false
},
"attributes": {
"name": {
"type": "string",
"required": true
},
"email": {
"type": "email",
"required": true
},
"subject": {
"type": "string"
},
"message": {
"type": "text",
"required": true
}
}
}

View File

@@ -0,0 +1,2 @@
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::contact-message.contact-message');

View File

@@ -0,0 +1,2 @@
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::contact-message.contact-message');

View File

@@ -0,0 +1,2 @@
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::contact-message.contact-message');

View File

@@ -0,0 +1,44 @@
{
"kind": "collectionType",
"collectionName": "jobs",
"info": {
"singularName": "job",
"pluralName": "jobs",
"displayName": "Job"
},
"options": {
"draftAndPublish": true
},
"pluginOptions": {
"i18n": {
"localized": true
}
},
"attributes": {
"title": {
"type": "string",
"required": true,
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"description": {
"type": "richtext",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"location": {
"type": "string",
"pluginOptions": {
"i18n": {
"localized": true
}
}
}
}
}

View File

@@ -0,0 +1,2 @@
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::job.job');

View File

@@ -0,0 +1,2 @@
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::job.job');

View File

@@ -0,0 +1,2 @@
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::job.job');

View File

@@ -0,0 +1,80 @@
{
"kind": "collectionType",
"collectionName": "products",
"info": {
"singularName": "product",
"pluralName": "products",
"displayName": "Product",
"description": ""
},
"options": {
"draftAndPublish": true
},
"pluginOptions": {
"i18n": {
"localized": true
}
},
"attributes": {
"title": {
"type": "string",
"required": true,
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"sku": {
"type": "uid",
"targetField": "title",
"required": true
},
"description": {
"type": "text",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"application": {
"type": "text",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"content": {
"type": "richtext",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"images": {
"type": "media",
"multiple": true,
"required": false,
"allowedTypes": [
"images"
]
},
"categories": {
"type": "relation",
"relation": "manyToMany",
"target": "api::category.category",
"inversedBy": "products"
},
"technicalData": {
"type": "json",
"pluginOptions": {
"i18n": {
"localized": true
}
}
}
}
}

View File

@@ -0,0 +1,2 @@
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::product.product');

View File

@@ -0,0 +1,2 @@
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::product.product');

View File

@@ -0,0 +1,2 @@
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::product.product');

View File

@@ -0,0 +1,42 @@
{
"kind": "singleType",
"collectionName": "settings",
"info": {
"singularName": "setting",
"pluralName": "settings",
"displayName": "Setting"
},
"options": {
"draftAndPublish": true
},
"pluginOptions": {
"i18n": {
"localized": true
}
},
"attributes": {
"siteName": {
"type": "string",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"siteDescription": {
"type": "text",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"logo": {
"type": "media",
"multiple": false,
"allowedTypes": [
"images"
]
}
}
}

View File

@@ -0,0 +1,2 @@
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::setting.setting');

View File

@@ -0,0 +1,2 @@
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::setting.setting');

View File

@@ -0,0 +1,2 @@
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::setting.setting');

18
cms/src/index.ts Normal file
View File

@@ -0,0 +1,18 @@
export default {
/**
* An asynchronous register function that runs before
* your application is initialized.
*
* This gives you an opportunity to extend code.
*/
register(/*{ strapi }*/) {},
/**
* An asynchronous bootstrap function that runs before
* your application gets started.
*
* This gives you an opportunity to set up your data model,
* run jobs, or perform some special logic.
*/
bootstrap(/*{ strapi }*/) {},
};

22
cms/tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"extends": "@strapi/typescript-utils/tsconfigs/server",
"compilerOptions": {
"outDir": "dist",
"rootDir": "."
},
"include": [
"src/**/*.ts",
"config/**/*.ts",
"src/**/*.tsx"
],
"exclude": [
"node_modules/",
"build/",
"dist/",
".cache/",
".tmp/",
"src/admin/",
"**/*.test.ts",
"src/plugins/**"
]
}

View File

@@ -28,6 +28,54 @@ services:
# Middlewares
- "traefik.http.routers.klz-cables.middlewares=klz-forward,compress"
cms:
build:
context: ./cms
dockerfile: Dockerfile
restart: always
networks:
- infra
env_file:
- .env
environment:
DATABASE_CLIENT: postgres
DATABASE_HOST: cms-db
DATABASE_PORT: 5432
DATABASE_NAME: ${STRAPI_DATABASE_NAME:-strapi}
DATABASE_USERNAME: ${STRAPI_DATABASE_USERNAME:-strapi}
DATABASE_PASSWORD: ${STRAPI_DATABASE_PASSWORD:-strapi}
NODE_ENV: ${NODE_ENV:-development}
STRAPI_URL: ${STRAPI_URL:-https://cms.klz-cables.com}
volumes:
- ./cms/config:/opt/app/config
- ./cms/src:/opt/app/src
- ./cms/package.json:/opt/app/package.json
- ./cms/public/uploads:/opt/app/public/uploads
labels:
- "traefik.enable=true"
- "traefik.http.routers.klz-cms.rule=Host(`cms.klz-cables.com`) || Host(`cms-staging.klz-cables.com`)"
- "traefik.http.routers.klz-cms.entrypoints=websecure"
- "traefik.http.routers.klz-cms.tls.certresolver=le"
- "traefik.http.routers.klz-cms.tls=true"
- "traefik.http.services.klz-cms.loadbalancer.server.port=1337"
cms-db:
image: postgres:16-alpine
restart: always
networks:
- infra
env_file:
- .env
environment:
POSTGRES_DB: ${STRAPI_DATABASE_NAME:-strapi}
POSTGRES_USER: ${STRAPI_DATABASE_USERNAME:-strapi}
POSTGRES_PASSWORD: ${STRAPI_DATABASE_PASSWORD:-strapi}
volumes:
- cms-db-data:/var/lib/postgresql/data
networks:
infra:
external: true
volumes:
cms-db-data:

View File

@@ -70,6 +70,15 @@ These are loaded from the `.env` file at runtime and are only available on the s
| `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

85
lib/strapi.ts Normal file
View File

@@ -0,0 +1,85 @@
import axios from 'axios';
const STRAPI_URL = process.env.STRAPI_URL || 'http://localhost:1337';
const STRAPI_TOKEN = process.env.STRAPI_API_TOKEN;
const strapi = axios.create({
baseURL: `${STRAPI_URL}/api`,
headers: {
Authorization: `Bearer ${STRAPI_TOKEN}`,
},
});
export interface StrapiResponse<T> {
data: {
id: number;
attributes: T;
}[];
meta: {
pagination: {
page: number;
pageSize: number;
pageCount: number;
total: number;
};
};
}
export interface Product {
title: string;
sku: string;
description: string;
application: string;
content: string;
technicalData: any;
locale: string;
images?: {
data: {
attributes: {
url: string;
alternativeText: string;
};
}[];
};
}
export async function getProducts(locale: string = 'de') {
try {
const response = await strapi.get<StrapiResponse<Product>>('/products', {
params: {
locale,
populate: '*',
},
});
return response.data.data.map(item => ({
id: item.id,
...item.attributes,
}));
} catch (error) {
console.error('Error fetching products from Strapi:', error);
return [];
}
}
export async function getProductBySku(sku: string, locale: string = 'de') {
try {
const response = await strapi.get<StrapiResponse<Product>>('/products', {
params: {
filters: { sku: { $eq: sku } },
locale,
populate: '*',
},
});
if (response.data.data.length === 0) return null;
const item = response.data.data[0];
return {
id: item.id,
...item.attributes,
};
} catch (error) {
console.error(`Error fetching product ${sku} from Strapi:`, error);
return null;
}
}
export default strapi;

View File

@@ -65,7 +65,13 @@
"test": "vitest run --passWithNoTests",
"test:og": "vitest run tests/og-image.test.ts",
"pdf:datasheets": "tsx ./scripts/generate-pdf-datasheets.ts",
"pdf:datasheets:legacy": "tsx ./scripts/generate-pdf-datasheets-pdf-lib.ts"
"pdf:datasheets:legacy": "tsx ./scripts/generate-pdf-datasheets-pdf-lib.ts",
"cms:dev": "docker-compose up -d cms cms-db",
"cms:stop": "docker-compose stop cms cms-db",
"cms:logs": "docker-compose logs -f cms",
"cms:export": "./scripts/strapi-sync.sh export",
"cms:import": "./scripts/strapi-sync.sh import",
"cms:migrate": "tsx ./scripts/migrate-to-strapi.ts"
},
"version": "1.0.0"
}

View File

@@ -0,0 +1,64 @@
import * as fs from 'fs';
import * as path from 'path';
import matter from 'gray-matter';
import axios from 'axios';
const STRAPI_URL = process.env.STRAPI_URL || 'http://localhost:1337';
const STRAPI_TOKEN = process.env.STRAPI_ADMIN_TOKEN; // You'll need to generate this
async function migrateProducts() {
const productsDir = path.join(process.cwd(), 'data/products');
const locales = ['de', 'en'];
for (const locale of locales) {
const localeDir = path.join(productsDir, locale);
if (!fs.existsSync(localeDir)) continue;
const files = fs.readdirSync(localeDir).filter(f => f.endsWith('.mdx'));
for (const file of files) {
const filePath = path.join(localeDir, file);
const fileContent = fs.readFileSync(filePath, 'utf8');
const { data, content } = matter(fileContent);
console.log(`Migrating ${data.title} (${locale})...`);
try {
// 1. Check if product exists (by SKU)
const existing = await axios.get(`${STRAPI_URL}/api/products?filters[sku][$eq]=${data.sku}&locale=${locale}`, {
headers: { Authorization: `Bearer ${STRAPI_TOKEN}` }
});
const productData = {
title: data.title,
sku: data.sku,
description: data.description,
application: data.application,
content: content,
technicalData: data.technicalData || {}, // This might need adjustment based on how it's stored in MDX
locale: locale,
};
if (existing.data.data.length > 0) {
// Update
const id = existing.data.data[0].id;
await axios.put(`${STRAPI_URL}/api/products/${id}`, { data: productData }, {
headers: { Authorization: `Bearer ${STRAPI_TOKEN}` }
});
console.log(`Updated ${data.title}`);
} else {
// Create
await axios.post(`${STRAPI_URL}/api/products`, { data: productData }, {
headers: { Authorization: `Bearer ${STRAPI_TOKEN}` }
});
console.log(`Created ${data.title}`);
}
} catch (error) {
console.error(`Error migrating ${data.title}:`, error.response?.data || error.message);
}
}
}
}
// Note: This script requires a running Strapi instance and an admin token.
// migrateProducts();

33
scripts/strapi-sync.sh Executable file
View File

@@ -0,0 +1,33 @@
#!/bin/bash
# Script to sync Strapi data between environments
# Usage: ./scripts/strapi-sync.sh [export|import] [filename]
COMMAND=$1
FILENAME=$2
if [ -z "$COMMAND" ]; then
echo "Usage: $0 [export|import] [filename]"
exit 1
fi
if [ "$COMMAND" == "export" ]; then
if [ -z "$FILENAME" ]; then
FILENAME="strapi-export-$(date +%Y%m%d%H%M%S).tar.gz"
fi
echo "Exporting Strapi data to $FILENAME..."
docker-compose exec cms npm run strapi export -- --no-encrypt -f "$FILENAME"
docker cp $(docker-compose ps -q cms):/opt/app/$FILENAME .
echo "Export complete: $FILENAME"
fi
if [ "$COMMAND" == "import" ]; then
if [ -z "$FILENAME" ]; then
echo "Please specify a filename to import"
exit 1
fi
echo "Importing Strapi data from $FILENAME..."
docker cp $FILENAME $(docker-compose ps -q cms):/opt/app/$FILENAME
docker-compose exec cms npm run strapi import -- -f "$FILENAME" --force
echo "Import complete"
fi