feat: complete MDX migration, stabilize environment & verify via E2E tests

Former-commit-id: ec3e64156a2e182535cbdcf0d975cd54603a517d
This commit is contained in:
2026-05-03 18:46:41 +02:00
parent e57661a586
commit b13f628b55
131 changed files with 1388 additions and 25288 deletions

View File

@@ -1,22 +0,0 @@
import Redis from 'ioredis';
const isDockerContainer =
process.env.IS_DOCKER === 'true' || process.env.HOSTNAME?.includes('klz-app');
const redisUrl =
process.env.REDIS_URL ||
(isDockerContainer ? 'redis://klz-redis:6379' : 'redis://localhost:6379');
// Only create a single instance in Node.js
const globalForRedis = global as unknown as { redis: Redis };
export const redis =
globalForRedis.redis ||
new Redis(redisUrl, {
maxRetriesPerRequest: 3,
});
if (process.env.NODE_ENV !== 'production') {
globalForRedis.redis = redis;
}
export default redis;

File diff suppressed because it is too large Load Diff

View File

@@ -1,356 +0,0 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres';
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_posts_locale" AS ENUM('en', 'de');
CREATE TYPE "public"."enum_posts_status" AS ENUM('draft', 'published');
CREATE TYPE "public"."enum__posts_v_version_locale" AS ENUM('en', 'de');
CREATE TYPE "public"."enum__posts_v_version_status" AS ENUM('draft', 'published');
CREATE TYPE "public"."enum_form_submissions_type" AS ENUM('contact', 'product_quote');
CREATE TYPE "public"."enum_products_locale" AS ENUM('en', 'de');
CREATE TYPE "public"."enum_products_status" AS ENUM('draft', 'published');
CREATE TYPE "public"."enum__products_v_version_locale" AS ENUM('en', 'de');
CREATE TYPE "public"."enum__products_v_version_status" AS ENUM('draft', 'published');
CREATE TABLE "users_sessions" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"created_at" timestamp(3) with time zone,
"expires_at" timestamp(3) with time zone NOT NULL
);
CREATE TABLE "users" (
"id" serial PRIMARY KEY NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"email" varchar NOT NULL,
"reset_password_token" varchar,
"reset_password_expiration" timestamp(3) with time zone,
"salt" varchar,
"hash" varchar,
"login_attempts" numeric DEFAULT 0,
"lock_until" timestamp(3) with time zone
);
CREATE TABLE "media" (
"id" serial PRIMARY KEY NOT NULL,
"alt" varchar NOT NULL,
"caption" varchar,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"url" varchar,
"thumbnail_u_r_l" varchar,
"filename" varchar,
"mime_type" varchar,
"filesize" numeric,
"width" numeric,
"height" numeric,
"focal_x" numeric,
"focal_y" numeric,
"sizes_thumbnail_url" varchar,
"sizes_thumbnail_width" numeric,
"sizes_thumbnail_height" numeric,
"sizes_thumbnail_mime_type" varchar,
"sizes_thumbnail_filesize" numeric,
"sizes_thumbnail_filename" varchar,
"sizes_card_url" varchar,
"sizes_card_width" numeric,
"sizes_card_height" numeric,
"sizes_card_mime_type" varchar,
"sizes_card_filesize" numeric,
"sizes_card_filename" varchar,
"sizes_hero_url" varchar,
"sizes_hero_width" numeric,
"sizes_hero_height" numeric,
"sizes_hero_mime_type" varchar,
"sizes_hero_filesize" numeric,
"sizes_hero_filename" varchar,
"sizes_hero_mobile_url" varchar,
"sizes_hero_mobile_width" numeric,
"sizes_hero_mobile_height" numeric,
"sizes_hero_mobile_mime_type" varchar,
"sizes_hero_mobile_filesize" numeric,
"sizes_hero_mobile_filename" varchar
);
CREATE TABLE "posts" (
"id" serial PRIMARY KEY NOT NULL,
"title" varchar,
"slug" varchar,
"excerpt" varchar,
"date" timestamp(3) with time zone,
"featured_image_id" integer,
"locale" "enum_posts_locale" DEFAULT 'en',
"category" varchar,
"content" jsonb,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"_status" "enum_posts_status" DEFAULT 'draft'
);
CREATE TABLE "_posts_v" (
"id" serial PRIMARY KEY NOT NULL,
"parent_id" integer,
"version_title" varchar,
"version_slug" varchar,
"version_excerpt" varchar,
"version_date" timestamp(3) with time zone,
"version_featured_image_id" integer,
"version_locale" "enum__posts_v_version_locale" DEFAULT 'en',
"version_category" varchar,
"version_content" jsonb,
"version_updated_at" timestamp(3) with time zone,
"version_created_at" timestamp(3) with time zone,
"version__status" "enum__posts_v_version_status" DEFAULT 'draft',
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"latest" boolean
);
CREATE TABLE "form_submissions" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar NOT NULL,
"email" varchar NOT NULL,
"type" "enum_form_submissions_type" NOT NULL,
"product_name" varchar,
"message" varchar NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "products_categories" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"category" varchar
);
CREATE TABLE "products" (
"id" serial PRIMARY KEY NOT NULL,
"title" varchar,
"sku" varchar,
"slug" varchar,
"description" varchar,
"locale" "enum_products_locale" DEFAULT 'de',
"application" jsonb,
"content" jsonb,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"_status" "enum_products_status" DEFAULT 'draft'
);
CREATE TABLE "products_rels" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"media_id" integer
);
CREATE TABLE "_products_v_version_categories" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" serial PRIMARY KEY NOT NULL,
"category" varchar,
"_uuid" varchar
);
CREATE TABLE "_products_v" (
"id" serial PRIMARY KEY NOT NULL,
"parent_id" integer,
"version_title" varchar,
"version_sku" varchar,
"version_slug" varchar,
"version_description" varchar,
"version_locale" "enum__products_v_version_locale" DEFAULT 'de',
"version_application" jsonb,
"version_content" jsonb,
"version_updated_at" timestamp(3) with time zone,
"version_created_at" timestamp(3) with time zone,
"version__status" "enum__products_v_version_status" DEFAULT 'draft',
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"latest" boolean
);
CREATE TABLE "_products_v_rels" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"media_id" integer
);
CREATE TABLE "payload_kv" (
"id" serial PRIMARY KEY NOT NULL,
"key" varchar NOT NULL,
"data" jsonb NOT NULL
);
CREATE TABLE "payload_locked_documents" (
"id" serial PRIMARY KEY NOT NULL,
"global_slug" varchar,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "payload_locked_documents_rels" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"users_id" integer,
"media_id" integer,
"posts_id" integer,
"form_submissions_id" integer,
"products_id" integer
);
CREATE TABLE "payload_preferences" (
"id" serial PRIMARY KEY NOT NULL,
"key" varchar,
"value" jsonb,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "payload_preferences_rels" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"users_id" integer
);
CREATE TABLE "payload_migrations" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar,
"batch" numeric,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
ALTER TABLE "users_sessions" ADD CONSTRAINT "users_sessions_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "posts" ADD CONSTRAINT "posts_featured_image_id_media_id_fk" FOREIGN KEY ("featured_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "_posts_v" ADD CONSTRAINT "_posts_v_parent_id_posts_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."posts"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "_posts_v" ADD CONSTRAINT "_posts_v_version_featured_image_id_media_id_fk" FOREIGN KEY ("version_featured_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "products_categories" ADD CONSTRAINT "products_categories_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."products"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "products_rels" ADD CONSTRAINT "products_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."products"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "products_rels" ADD CONSTRAINT "products_rels_media_fk" FOREIGN KEY ("media_id") REFERENCES "public"."media"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "_products_v_version_categories" ADD CONSTRAINT "_products_v_version_categories_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."_products_v"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "_products_v" ADD CONSTRAINT "_products_v_parent_id_products_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."products"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "_products_v_rels" ADD CONSTRAINT "_products_v_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."_products_v"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "_products_v_rels" ADD CONSTRAINT "_products_v_rels_media_fk" FOREIGN KEY ("media_id") REFERENCES "public"."media"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."payload_locked_documents"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_users_fk" FOREIGN KEY ("users_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_media_fk" FOREIGN KEY ("media_id") REFERENCES "public"."media"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_posts_fk" FOREIGN KEY ("posts_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_form_submissions_fk" FOREIGN KEY ("form_submissions_id") REFERENCES "public"."form_submissions"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_products_fk" FOREIGN KEY ("products_id") REFERENCES "public"."products"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."payload_preferences"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_users_fk" FOREIGN KEY ("users_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "users_sessions_order_idx" ON "users_sessions" USING btree ("_order");
CREATE INDEX "users_sessions_parent_id_idx" ON "users_sessions" USING btree ("_parent_id");
CREATE INDEX "users_updated_at_idx" ON "users" USING btree ("updated_at");
CREATE INDEX "users_created_at_idx" ON "users" USING btree ("created_at");
CREATE UNIQUE INDEX "users_email_idx" ON "users" USING btree ("email");
CREATE INDEX "media_updated_at_idx" ON "media" USING btree ("updated_at");
CREATE INDEX "media_created_at_idx" ON "media" USING btree ("created_at");
CREATE UNIQUE INDEX "media_filename_idx" ON "media" USING btree ("filename");
CREATE INDEX "media_sizes_thumbnail_sizes_thumbnail_filename_idx" ON "media" USING btree ("sizes_thumbnail_filename");
CREATE INDEX "media_sizes_card_sizes_card_filename_idx" ON "media" USING btree ("sizes_card_filename");
CREATE INDEX "media_sizes_hero_sizes_hero_filename_idx" ON "media" USING btree ("sizes_hero_filename");
CREATE INDEX "media_sizes_hero_mobile_sizes_hero_mobile_filename_idx" ON "media" USING btree ("sizes_hero_mobile_filename");
CREATE UNIQUE INDEX "posts_slug_idx" ON "posts" USING btree ("slug");
CREATE INDEX "posts_featured_image_idx" ON "posts" USING btree ("featured_image_id");
CREATE INDEX "posts_updated_at_idx" ON "posts" USING btree ("updated_at");
CREATE INDEX "posts_created_at_idx" ON "posts" USING btree ("created_at");
CREATE INDEX "posts__status_idx" ON "posts" USING btree ("_status");
CREATE INDEX "_posts_v_parent_idx" ON "_posts_v" USING btree ("parent_id");
CREATE INDEX "_posts_v_version_version_slug_idx" ON "_posts_v" USING btree ("version_slug");
CREATE INDEX "_posts_v_version_version_featured_image_idx" ON "_posts_v" USING btree ("version_featured_image_id");
CREATE INDEX "_posts_v_version_version_updated_at_idx" ON "_posts_v" USING btree ("version_updated_at");
CREATE INDEX "_posts_v_version_version_created_at_idx" ON "_posts_v" USING btree ("version_created_at");
CREATE INDEX "_posts_v_version_version__status_idx" ON "_posts_v" USING btree ("version__status");
CREATE INDEX "_posts_v_created_at_idx" ON "_posts_v" USING btree ("created_at");
CREATE INDEX "_posts_v_updated_at_idx" ON "_posts_v" USING btree ("updated_at");
CREATE INDEX "_posts_v_latest_idx" ON "_posts_v" USING btree ("latest");
CREATE INDEX "form_submissions_updated_at_idx" ON "form_submissions" USING btree ("updated_at");
CREATE INDEX "form_submissions_created_at_idx" ON "form_submissions" USING btree ("created_at");
CREATE INDEX "products_categories_order_idx" ON "products_categories" USING btree ("_order");
CREATE INDEX "products_categories_parent_id_idx" ON "products_categories" USING btree ("_parent_id");
CREATE UNIQUE INDEX "products_sku_idx" ON "products" USING btree ("sku");
CREATE INDEX "products_updated_at_idx" ON "products" USING btree ("updated_at");
CREATE INDEX "products_created_at_idx" ON "products" USING btree ("created_at");
CREATE INDEX "products__status_idx" ON "products" USING btree ("_status");
CREATE INDEX "products_rels_order_idx" ON "products_rels" USING btree ("order");
CREATE INDEX "products_rels_parent_idx" ON "products_rels" USING btree ("parent_id");
CREATE INDEX "products_rels_path_idx" ON "products_rels" USING btree ("path");
CREATE INDEX "products_rels_media_id_idx" ON "products_rels" USING btree ("media_id");
CREATE INDEX "_products_v_version_categories_order_idx" ON "_products_v_version_categories" USING btree ("_order");
CREATE INDEX "_products_v_version_categories_parent_id_idx" ON "_products_v_version_categories" USING btree ("_parent_id");
CREATE INDEX "_products_v_parent_idx" ON "_products_v" USING btree ("parent_id");
CREATE INDEX "_products_v_version_version_sku_idx" ON "_products_v" USING btree ("version_sku");
CREATE INDEX "_products_v_version_version_updated_at_idx" ON "_products_v" USING btree ("version_updated_at");
CREATE INDEX "_products_v_version_version_created_at_idx" ON "_products_v" USING btree ("version_created_at");
CREATE INDEX "_products_v_version_version__status_idx" ON "_products_v" USING btree ("version__status");
CREATE INDEX "_products_v_created_at_idx" ON "_products_v" USING btree ("created_at");
CREATE INDEX "_products_v_updated_at_idx" ON "_products_v" USING btree ("updated_at");
CREATE INDEX "_products_v_latest_idx" ON "_products_v" USING btree ("latest");
CREATE INDEX "_products_v_rels_order_idx" ON "_products_v_rels" USING btree ("order");
CREATE INDEX "_products_v_rels_parent_idx" ON "_products_v_rels" USING btree ("parent_id");
CREATE INDEX "_products_v_rels_path_idx" ON "_products_v_rels" USING btree ("path");
CREATE INDEX "_products_v_rels_media_id_idx" ON "_products_v_rels" USING btree ("media_id");
CREATE UNIQUE INDEX "payload_kv_key_idx" ON "payload_kv" USING btree ("key");
CREATE INDEX "payload_locked_documents_global_slug_idx" ON "payload_locked_documents" USING btree ("global_slug");
CREATE INDEX "payload_locked_documents_updated_at_idx" ON "payload_locked_documents" USING btree ("updated_at");
CREATE INDEX "payload_locked_documents_created_at_idx" ON "payload_locked_documents" USING btree ("created_at");
CREATE INDEX "payload_locked_documents_rels_order_idx" ON "payload_locked_documents_rels" USING btree ("order");
CREATE INDEX "payload_locked_documents_rels_parent_idx" ON "payload_locked_documents_rels" USING btree ("parent_id");
CREATE INDEX "payload_locked_documents_rels_path_idx" ON "payload_locked_documents_rels" USING btree ("path");
CREATE INDEX "payload_locked_documents_rels_users_id_idx" ON "payload_locked_documents_rels" USING btree ("users_id");
CREATE INDEX "payload_locked_documents_rels_media_id_idx" ON "payload_locked_documents_rels" USING btree ("media_id");
CREATE INDEX "payload_locked_documents_rels_posts_id_idx" ON "payload_locked_documents_rels" USING btree ("posts_id");
CREATE INDEX "payload_locked_documents_rels_form_submissions_id_idx" ON "payload_locked_documents_rels" USING btree ("form_submissions_id");
CREATE INDEX "payload_locked_documents_rels_products_id_idx" ON "payload_locked_documents_rels" USING btree ("products_id");
CREATE INDEX "payload_preferences_key_idx" ON "payload_preferences" USING btree ("key");
CREATE INDEX "payload_preferences_updated_at_idx" ON "payload_preferences" USING btree ("updated_at");
CREATE INDEX "payload_preferences_created_at_idx" ON "payload_preferences" USING btree ("created_at");
CREATE INDEX "payload_preferences_rels_order_idx" ON "payload_preferences_rels" USING btree ("order");
CREATE INDEX "payload_preferences_rels_parent_idx" ON "payload_preferences_rels" USING btree ("parent_id");
CREATE INDEX "payload_preferences_rels_path_idx" ON "payload_preferences_rels" USING btree ("path");
CREATE INDEX "payload_preferences_rels_users_id_idx" ON "payload_preferences_rels" USING btree ("users_id");
CREATE INDEX "payload_migrations_updated_at_idx" ON "payload_migrations" USING btree ("updated_at");
CREATE INDEX "payload_migrations_created_at_idx" ON "payload_migrations" USING btree ("created_at");`);
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
DROP TABLE "users_sessions" CASCADE;
DROP TABLE "users" CASCADE;
DROP TABLE "media" CASCADE;
DROP TABLE "posts" CASCADE;
DROP TABLE "_posts_v" CASCADE;
DROP TABLE "form_submissions" CASCADE;
DROP TABLE "products_categories" CASCADE;
DROP TABLE "products" CASCADE;
DROP TABLE "products_rels" CASCADE;
DROP TABLE "_products_v_version_categories" CASCADE;
DROP TABLE "_products_v" CASCADE;
DROP TABLE "_products_v_rels" CASCADE;
DROP TABLE "payload_kv" CASCADE;
DROP TABLE "payload_locked_documents" CASCADE;
DROP TABLE "payload_locked_documents_rels" CASCADE;
DROP TABLE "payload_preferences" CASCADE;
DROP TABLE "payload_preferences_rels" CASCADE;
DROP TABLE "payload_migrations" CASCADE;
DROP TYPE "public"."enum_posts_locale";
DROP TYPE "public"."enum_posts_status";
DROP TYPE "public"."enum__posts_v_version_locale";
DROP TYPE "public"."enum__posts_v_version_status";
DROP TYPE "public"."enum_form_submissions_type";
DROP TYPE "public"."enum_products_locale";
DROP TYPE "public"."enum_products_status";
DROP TYPE "public"."enum__products_v_version_locale";
DROP TYPE "public"."enum__products_v_version_status";`);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +0,0 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres';
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
DROP INDEX "products_sku_idx";
DROP INDEX "_products_v_version_version_sku_idx";`);
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
CREATE UNIQUE INDEX "products_sku_idx" ON "products" USING btree ("sku");
CREATE INDEX "_products_v_version_version_sku_idx" ON "_products_v" USING btree ("version_sku");`);
}

View File

@@ -1,5 +0,0 @@
{
"id": "20260225_003500_add_pages_collection",
"name": "20260225_003500_add_pages_collection",
"batch": 3
}

View File

@@ -1,48 +0,0 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres';
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "public"."enum_pages_locale" AS ENUM('en', 'de');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
CREATE TABLE IF NOT EXISTS "pages" (
"id" serial PRIMARY KEY NOT NULL,
"title" varchar NOT NULL,
"slug" varchar NOT NULL,
"locale" "enum_pages_locale" NOT NULL,
"excerpt" varchar,
"featured_image_id" integer,
"content" jsonb NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
ALTER TABLE "pages" ADD CONSTRAINT "pages_featured_image_id_media_id_fk"
FOREIGN KEY ("featured_image_id") REFERENCES "public"."media"("id")
ON DELETE set null ON UPDATE no action;
CREATE INDEX IF NOT EXISTS "pages_featured_image_idx" ON "pages" USING btree ("featured_image_id");
CREATE INDEX IF NOT EXISTS "pages_updated_at_idx" ON "pages" USING btree ("updated_at");
CREATE INDEX IF NOT EXISTS "pages_created_at_idx" ON "pages" USING btree ("created_at");
-- Add pages_id to payload_locked_documents_rels if not already present
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN IF NOT EXISTS "pages_id" integer;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_pages_fk"
FOREIGN KEY ("pages_id") REFERENCES "public"."pages"("id")
ON DELETE cascade ON UPDATE no action;
CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_pages_id_idx"
ON "payload_locked_documents_rels" USING btree ("pages_id");
`);
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT IF EXISTS "payload_locked_documents_rels_pages_fk";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN IF EXISTS "pages_id";
DROP TABLE IF EXISTS "pages" CASCADE;
DROP TYPE IF EXISTS "public"."enum_pages_locale";
`);
}

View File

@@ -1,358 +0,0 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres';
/**
* Migration: native_localization
*
* Transforms the existing schema (manual `locale` select column on each row)
* into Payload's native localization join-table structure.
*
* Each statement is a separate db.execute call to avoid Drizzle multi-statement issues.
*/
export async function up({ db }: MigrateUpArgs): Promise<void> {
// ── 1. Global locale enum ────────────────────────────────────────────────────
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "public"."enum__locales" AS ENUM('de', 'en');
EXCEPTION WHEN duplicate_object THEN null; END $$
`);
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "public"."enum__posts_v_published_locale" AS ENUM('de', 'en');
EXCEPTION WHEN duplicate_object THEN null; END $$
`);
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "public"."enum__products_v_published_locale" AS ENUM('de', 'en');
EXCEPTION WHEN duplicate_object THEN null; END $$
`);
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "public"."enum__pages_v_published_locale" AS ENUM('de', 'en');
EXCEPTION WHEN duplicate_object THEN null; END $$
`);
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "public"."enum_pages_layout" AS ENUM('default', 'fullBleed');
EXCEPTION WHEN duplicate_object THEN null; END $$
`);
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "public"."enum__pages_v_version_layout" AS ENUM('default', 'fullBleed');
EXCEPTION WHEN duplicate_object THEN null; END $$
`);
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "public"."enum__pages_v_version_status" AS ENUM('draft', 'published');
EXCEPTION WHEN duplicate_object THEN null; END $$
`);
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "public"."enum__posts_v_version_status" AS ENUM('draft', 'published');
EXCEPTION WHEN duplicate_object THEN null; END $$
`);
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "public"."enum__products_v_version_status" AS ENUM('draft', 'published');
EXCEPTION WHEN duplicate_object THEN null; END $$
`);
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "public"."enum_pages_status" AS ENUM('draft', 'published');
EXCEPTION WHEN duplicate_object THEN null; END $$
`);
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "public"."enum_posts_status" AS ENUM('draft', 'published');
EXCEPTION WHEN duplicate_object THEN null; END $$
`);
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "public"."enum_products_status" AS ENUM('draft', 'published');
EXCEPTION WHEN duplicate_object THEN null; END $$
`);
// ── 2. Alter pages table ─────────────────────────────────────────────────────
await db.execute(
sql`ALTER TABLE "pages" ADD COLUMN IF NOT EXISTS "layout" "enum_pages_layout" DEFAULT 'default'`,
);
await db.execute(
sql`ALTER TABLE "pages" ADD COLUMN IF NOT EXISTS "_status" "enum_pages_status" DEFAULT 'draft'`,
);
// ── 3. Create pages_locales join table ───────────────────────────────────────
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "pages_locales" (
"title" varchar,
"slug" varchar,
"excerpt" varchar,
"content" jsonb,
"id" serial PRIMARY KEY NOT NULL,
"_locale" "enum__locales" NOT NULL,
"_parent_id" integer NOT NULL
)
`);
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "pages_locales" ADD CONSTRAINT "pages_locales_locale_parent_id_unique" UNIQUE("_locale", "_parent_id");
EXCEPTION WHEN duplicate_object THEN null; END $$
`);
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "pages_locales" ADD CONSTRAINT "pages_locales_parent_id_fk"
FOREIGN KEY ("_parent_id") REFERENCES "pages"("id") ON DELETE cascade;
EXCEPTION WHEN duplicate_object THEN null; END $$
`);
// ── 4. Backfill pages_locales from old pages rows ────────────────────────────
await db.execute(sql`
INSERT INTO "pages_locales" ("title", "slug", "excerpt", "content", "_locale", "_parent_id")
SELECT
p.title, p.slug, p.excerpt, p.content,
CASE WHEN p.locale::text = 'de' THEN 'de'::"enum__locales" ELSE 'en'::"enum__locales" END,
p.id
FROM "pages" p
WHERE p.locale IS NOT NULL
ON CONFLICT ("_locale", "_parent_id") DO UPDATE
SET "title" = EXCLUDED."title",
"slug" = EXCLUDED."slug",
"excerpt" = EXCLUDED."excerpt",
"content" = EXCLUDED."content"
`);
// ── 5. Drop old columns from pages ───────────────────────────────────────────
await db.execute(sql`ALTER TABLE "pages" DROP COLUMN IF EXISTS "title"`);
await db.execute(sql`ALTER TABLE "pages" DROP COLUMN IF EXISTS "slug"`);
await db.execute(sql`ALTER TABLE "pages" DROP COLUMN IF EXISTS "excerpt"`);
await db.execute(sql`ALTER TABLE "pages" DROP COLUMN IF EXISTS "content"`);
await db.execute(sql`ALTER TABLE "pages" DROP COLUMN IF EXISTS "locale"`);
// ── 6. Create posts_locales join table ───────────────────────────────────────
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "posts_locales" (
"title" varchar,
"slug" varchar,
"excerpt" varchar,
"category" varchar,
"content" jsonb,
"id" serial PRIMARY KEY NOT NULL,
"_locale" "enum__locales" NOT NULL,
"_parent_id" integer NOT NULL
)
`);
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "posts_locales" ADD CONSTRAINT "posts_locales_locale_parent_id_unique" UNIQUE("_locale", "_parent_id");
EXCEPTION WHEN duplicate_object THEN null; END $$
`);
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "posts_locales" ADD CONSTRAINT "posts_locales_parent_id_fk"
FOREIGN KEY ("_parent_id") REFERENCES "posts"("id") ON DELETE cascade;
EXCEPTION WHEN duplicate_object THEN null; END $$
`);
// ── 7. Backfill posts_locales ────────────────────────────────────────────────
await db.execute(sql`
INSERT INTO "posts_locales" ("title", "slug", "excerpt", "category", "content", "_locale", "_parent_id")
SELECT
p.title, p.slug, p.excerpt, p.category, p.content,
CASE WHEN p.locale::text = 'de' THEN 'de'::"enum__locales" ELSE 'en'::"enum__locales" END,
p.id
FROM "posts" p
WHERE p.locale IS NOT NULL
ON CONFLICT ("_locale", "_parent_id") DO UPDATE
SET "title" = EXCLUDED."title",
"slug" = EXCLUDED."slug",
"excerpt" = EXCLUDED."excerpt",
"category" = EXCLUDED."category",
"content" = EXCLUDED."content"
`);
// ── 8. Drop old columns from posts ───────────────────────────────────────────
await db.execute(sql`ALTER TABLE "posts" DROP COLUMN IF EXISTS "title"`);
await db.execute(sql`ALTER TABLE "posts" DROP COLUMN IF EXISTS "slug"`);
await db.execute(sql`ALTER TABLE "posts" DROP COLUMN IF EXISTS "excerpt"`);
await db.execute(sql`ALTER TABLE "posts" DROP COLUMN IF EXISTS "category"`);
await db.execute(sql`ALTER TABLE "posts" DROP COLUMN IF EXISTS "content"`);
await db.execute(sql`ALTER TABLE "posts" DROP COLUMN IF EXISTS "locale"`);
// ── 9. Create products_locales join table ────────────────────────────────────
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "products_locales" (
"title" varchar,
"description" varchar,
"application" jsonb,
"content" jsonb,
"id" serial PRIMARY KEY NOT NULL,
"_locale" "enum__locales" NOT NULL,
"_parent_id" integer NOT NULL
)
`);
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "products_locales" ADD CONSTRAINT "products_locales_locale_parent_id_unique" UNIQUE("_locale", "_parent_id");
EXCEPTION WHEN duplicate_object THEN null; END $$
`);
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "products_locales" ADD CONSTRAINT "products_locales_parent_id_fk"
FOREIGN KEY ("_parent_id") REFERENCES "products"("id") ON DELETE cascade;
EXCEPTION WHEN duplicate_object THEN null; END $$
`);
// ── 10. Backfill products_locales ────────────────────────────────────────────
// Products were separate DE/EN rows — each becomes a locale entry on its own id
await db.execute(sql`
INSERT INTO "products_locales" ("title", "description", "application", "content", "_locale", "_parent_id")
SELECT
p.title, p.description, p.application, p.content,
CASE WHEN p.locale::text = 'de' THEN 'de'::"enum__locales" ELSE 'en'::"enum__locales" END,
p.id
FROM "products" p
WHERE p.locale IS NOT NULL
ON CONFLICT ("_locale", "_parent_id") DO NOTHING
`);
// ── 11. Drop old columns from products ───────────────────────────────────────
await db.execute(sql`ALTER TABLE "products" DROP COLUMN IF EXISTS "title"`);
await db.execute(sql`ALTER TABLE "products" DROP COLUMN IF EXISTS "description"`);
await db.execute(sql`ALTER TABLE "products" DROP COLUMN IF EXISTS "application"`);
await db.execute(sql`ALTER TABLE "products" DROP COLUMN IF EXISTS "content"`);
await db.execute(sql`ALTER TABLE "products" DROP COLUMN IF EXISTS "locale"`);
// ── 12. Version tables (_posts_v, _products_v, _pages_v) locale columns ──────
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "_posts_v" (
"id" serial PRIMARY KEY NOT NULL,
"parent_id" integer,
"version_title" varchar,
"version_slug" varchar,
"version_excerpt" varchar,
"version_content" jsonb,
"version_updated_at" timestamp(3) with time zone,
"version_created_at" timestamp(3) with time zone,
"version__status" "enum__posts_v_version_status" DEFAULT 'draft',
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"latest" boolean
)
`);
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "_products_v" (
"id" serial PRIMARY KEY NOT NULL,
"parent_id" integer,
"version_title" varchar,
"version_description" varchar,
"version_content" jsonb,
"version_updated_at" timestamp(3) with time zone,
"version_created_at" timestamp(3) with time zone,
"version__status" "enum__products_v_version_status" DEFAULT 'draft',
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"latest" boolean
)
`);
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "_pages_v" (
"id" serial PRIMARY KEY NOT NULL,
"parent_id" integer,
"version_title" varchar,
"version_slug" varchar,
"version_excerpt" varchar,
"version_content" jsonb,
"version_updated_at" timestamp(3) with time zone,
"version_created_at" timestamp(3) with time zone,
"version__status" "enum__pages_v_version_status" DEFAULT 'draft',
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"latest" boolean
)
`);
await db.execute(
sql`ALTER TABLE "_posts_v" ADD COLUMN IF NOT EXISTS "published_locale" "enum__posts_v_published_locale"`,
);
await db.execute(
sql`ALTER TABLE "_products_v" ADD COLUMN IF NOT EXISTS "published_locale" "enum__products_v_published_locale"`,
);
await db.execute(
sql`ALTER TABLE "_pages_v" ADD COLUMN IF NOT EXISTS "published_locale" "enum__pages_v_published_locale"`,
);
// ── 13. Create _posts_v_locales ──────────────────────────────────────────────
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "_posts_v_locales" (
"version_title" varchar, "version_slug" varchar, "version_excerpt" varchar,
"version_category" varchar, "version_content" jsonb,
"id" serial PRIMARY KEY NOT NULL,
"_locale" "enum__locales" NOT NULL, "_parent_id" integer NOT NULL
)
`);
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "_posts_v_locales" ADD CONSTRAINT "_posts_v_locales_locale_parent_id_unique" UNIQUE("_locale", "_parent_id");
EXCEPTION WHEN duplicate_object THEN null; END $$
`);
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "_posts_v_locales" ADD CONSTRAINT "_posts_v_locales_parent_id_fk"
FOREIGN KEY ("_parent_id") REFERENCES "_posts_v"("id") ON DELETE cascade;
EXCEPTION WHEN duplicate_object THEN null; END $$
`);
// ── 14. Create _products_v_locales ───────────────────────────────────────────
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "_products_v_locales" (
"version_title" varchar, "version_description" varchar,
"version_application" jsonb, "version_content" jsonb,
"id" serial PRIMARY KEY NOT NULL,
"_locale" "enum__locales" NOT NULL, "_parent_id" integer NOT NULL
)
`);
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "_products_v_locales" ADD CONSTRAINT "_products_v_locales_locale_parent_id_unique" UNIQUE("_locale", "_parent_id");
EXCEPTION WHEN duplicate_object THEN null; END $$
`);
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "_products_v_locales" ADD CONSTRAINT "_products_v_locales_parent_id_fk"
FOREIGN KEY ("_parent_id") REFERENCES "_products_v"("id") ON DELETE cascade;
EXCEPTION WHEN duplicate_object THEN null; END $$
`);
// ── 15. Create _pages_v_locales ──────────────────────────────────────────────
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "_pages_v_locales" (
"version_title" varchar, "version_slug" varchar,
"version_excerpt" varchar, "version_content" jsonb,
"id" serial PRIMARY KEY NOT NULL,
"_locale" "enum__locales" NOT NULL, "_parent_id" integer NOT NULL
)
`);
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "_pages_v_locales" ADD CONSTRAINT "_pages_v_locales_locale_parent_id_unique" UNIQUE("_locale", "_parent_id");
EXCEPTION WHEN duplicate_object THEN null; END $$
`);
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "_pages_v_locales" ADD CONSTRAINT "_pages_v_locales_parent_id_fk"
FOREIGN KEY ("_parent_id") REFERENCES "_pages_v"("id") ON DELETE cascade;
EXCEPTION WHEN duplicate_object THEN null; END $$
`);
// ── 16. Drop the now-redundant old locale enum ───────────────────────────────
await db.execute(sql`DROP TYPE IF EXISTS "public"."enum_pages_locale"`);
await db.execute(sql`DROP TYPE IF EXISTS "public"."enum_posts_locale"`);
await db.execute(sql`DROP TYPE IF EXISTS "public"."enum_products_locale"`);
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`DROP TABLE IF EXISTS "pages_locales" CASCADE`);
await db.execute(sql`DROP TABLE IF EXISTS "_pages_v_locales" CASCADE`);
await db.execute(sql`DROP TABLE IF EXISTS "posts_locales" CASCADE`);
await db.execute(sql`DROP TABLE IF EXISTS "_posts_v_locales" CASCADE`);
await db.execute(sql`DROP TABLE IF EXISTS "products_locales" CASCADE`);
await db.execute(sql`DROP TABLE IF EXISTS "_products_v_locales" CASCADE`);
}

View File

@@ -1,52 +0,0 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres';
export async function up({ db }: MigrateUpArgs): Promise<void> {
// Add featured_image_id to products and _products_v
await db.execute(sql`
ALTER TABLE "products" ADD COLUMN IF NOT EXISTS "featured_image_id" integer;
`);
await db.execute(sql`
ALTER TABLE "_products_v" ADD COLUMN IF NOT EXISTS "version_featured_image_id" integer;
`);
// Add foreign key constraints
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "products" ADD CONSTRAINT "products_featured_image_id_media_id_fk" FOREIGN KEY ("featured_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN null; END $$;
`);
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "_products_v" ADD CONSTRAINT "_products_v_version_featured_image_id_media_id_fk" FOREIGN KEY ("version_featured_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN null; END $$;
`);
// Add indexes
await db.execute(sql`
CREATE INDEX IF NOT EXISTS "products_featured_image_idx" ON "products" USING btree ("featured_image_id");
`);
await db.execute(sql`
CREATE INDEX IF NOT EXISTS "_products_v_version_version_featured_image_idx" ON "_products_v" USING btree ("version_featured_image_id");
`);
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "products" DROP CONSTRAINT IF EXISTS "products_featured_image_id_media_id_fk";
`);
await db.execute(sql`
ALTER TABLE "_products_v" DROP CONSTRAINT IF EXISTS "_products_v_version_featured_image_id_media_id_fk";
`);
await db.execute(sql`
DROP INDEX IF EXISTS "products_featured_image_idx";
`);
await db.execute(sql`
DROP INDEX IF EXISTS "_products_v_version_version_featured_image_idx";
`);
await db.execute(sql`
ALTER TABLE "products" DROP COLUMN IF EXISTS "featured_image_id";
`);
await db.execute(sql`
ALTER TABLE "_products_v" DROP COLUMN IF EXISTS "version_featured_image_id";
`);
}

View File

@@ -1,31 +0,0 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres';
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "media" ADD COLUMN IF NOT EXISTS "sizes_tablet_url" varchar;
ALTER TABLE "media" ADD COLUMN IF NOT EXISTS "sizes_tablet_width" numeric;
ALTER TABLE "media" ADD COLUMN IF NOT EXISTS "sizes_tablet_height" numeric;
ALTER TABLE "media" ADD COLUMN IF NOT EXISTS "sizes_tablet_mime_type" varchar;
ALTER TABLE "media" ADD COLUMN IF NOT EXISTS "sizes_tablet_filesize" numeric;
ALTER TABLE "media" ADD COLUMN IF NOT EXISTS "sizes_tablet_filename" varchar;
`);
await db.execute(sql`
CREATE INDEX IF NOT EXISTS "media_sizes_tablet_sizes_tablet_filename_idx" ON "media" USING btree ("sizes_tablet_filename");
`);
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
DROP INDEX IF EXISTS "media_sizes_tablet_sizes_tablet_filename_idx";
`);
await db.execute(sql`
ALTER TABLE "media" DROP COLUMN IF EXISTS "sizes_tablet_filename";
ALTER TABLE "media" DROP COLUMN IF EXISTS "sizes_tablet_filesize";
ALTER TABLE "media" DROP COLUMN IF EXISTS "sizes_tablet_mime_type";
ALTER TABLE "media" DROP COLUMN IF EXISTS "sizes_tablet_height";
ALTER TABLE "media" DROP COLUMN IF EXISTS "sizes_tablet_width";
ALTER TABLE "media" DROP COLUMN IF EXISTS "sizes_tablet_url";
`);
}

View File

@@ -1,39 +0,0 @@
import * as migration_20260223_195005_products_collection from './20260223_195005_products_collection';
import * as migration_20260223_195151_remove_sku_unique from './20260223_195151_remove_sku_unique';
import * as migration_20260225_003500_add_pages_collection from './20260225_003500_add_pages_collection';
import * as migration_20260225_175000_native_localization from './20260225_175000_native_localization';
import * as migration_20260305_215000_products_featured_image from './20260305_215000_products_featured_image';
import * as migration_20260305_222000_media_sizes_tablet from './20260305_222000_media_sizes_tablet';
export const migrations = [
{
up: migration_20260223_195005_products_collection.up,
down: migration_20260223_195005_products_collection.down,
name: '20260223_195005_products_collection',
},
{
up: migration_20260223_195151_remove_sku_unique.up,
down: migration_20260223_195151_remove_sku_unique.down,
name: '20260223_195151_remove_sku_unique',
},
{
up: migration_20260225_003500_add_pages_collection.up,
down: migration_20260225_003500_add_pages_collection.down,
name: '20260225_003500_add_pages_collection',
},
{
up: migration_20260225_175000_native_localization.up,
down: migration_20260225_175000_native_localization.down,
name: '20260225_175000_native_localization',
},
{
up: migration_20260305_215000_products_featured_image.up,
down: migration_20260305_215000_products_featured_image.down,
name: '20260305_215000_products_featured_image',
},
{
up: migration_20260305_222000_media_sizes_tablet.up,
down: migration_20260305_222000_media_sizes_tablet.down,
name: '20260305_222000_media_sizes_tablet',
},
];

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +0,0 @@
import type { Access } from 'payload';
export const anyone: Access = () => true;

View File

@@ -1,5 +0,0 @@
import type { Access } from 'payload';
export const authenticated: Access = ({ req: { user } }) => {
return Boolean(user);
};

View File

@@ -1,27 +0,0 @@
import { EtibBlock } from './types';
import AnimatedImageComponent from '@/components/blog/AnimatedImage';
export const AnimatedImage: EtibBlock = {
slug: 'animatedImage',
render: AnimatedImageComponent,
fields: [
{
name: 'src',
type: 'text',
required: true,
},
{
name: 'alt',
type: 'text',
required: true,
},
{
name: 'width',
type: 'number',
},
{
name: 'height',
type: 'number',
},
],
};

View File

@@ -1,22 +0,0 @@
import { EtibBlock } from './types';
import { Callout as CalloutComponent } from '@/components/ui/Callout';
import { lexicalEditor } from '@payloadcms/richtext-lexical';
export const Callout: EtibBlock = {
slug: 'callout',
render: CalloutComponent,
fields: [
{
name: 'type',
type: 'select',
options: ['info', 'warning', 'important', 'tip', 'caution'],
defaultValue: 'info',
},
{
name: 'content',
type: 'richText',
editor: lexicalEditor({}),
required: true,
},
],
};

View File

@@ -1,48 +0,0 @@
import { Block } from 'payload';
export const CategoryGrid: Block = {
slug: 'categoryGrid',
interfaceName: 'CategoryGridBlock',
fields: [
{
name: 'categories',
type: 'array',
required: true,
minRows: 1,
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'description',
type: 'textarea',
required: false,
},
{
name: 'image',
type: 'upload',
relationTo: 'media',
required: false,
},
{
name: 'icon',
type: 'upload',
relationTo: 'media',
required: false,
},
{
name: 'href',
type: 'text',
required: true,
},
{
name: 'ctaLabel',
type: 'text',
required: false,
},
],
},
],
};

View File

@@ -1,36 +0,0 @@
import { EtibBlock } from './types';
import { lexicalEditor } from '@payloadcms/richtext-lexical';
import ChatBubbleComponent from '@/components/blog/ChatBubble';
export const ChatBubble: EtibBlock = {
slug: 'chatBubble',
render: ChatBubbleComponent,
fields: [
{
name: 'author',
type: 'text',
defaultValue: 'KLZ Team',
},
{
name: 'avatar',
type: 'text',
},
{
name: 'role',
type: 'text',
defaultValue: 'Assistant',
},
{
name: 'align',
type: 'select',
options: ['left', 'right'],
defaultValue: 'left',
},
{
name: 'content',
type: 'richText',
editor: lexicalEditor({}),
required: true,
},
],
};

View File

@@ -1,54 +0,0 @@
import { Block } from 'payload';
export const TeamLegacySection: Block = {
slug: 'teamLegacySection',
interfaceName: 'TeamLegacySectionBlock',
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'subtitle',
type: 'text',
required: true,
},
{
name: 'paragraph1',
type: 'textarea',
required: true,
},
{
name: 'paragraph2',
type: 'textarea',
required: true,
},
{
name: 'expertiseTitle',
type: 'text',
required: true,
},
{
name: 'expertiseDesc',
type: 'text',
required: true,
},
{
name: 'networkTitle',
type: 'text',
required: true,
},
{
name: 'networkDesc',
type: 'text',
required: true,
},
{
name: 'backgroundImage',
type: 'upload',
relationTo: 'media',
required: false,
},
],
};

View File

@@ -1,12 +0,0 @@
import { EtibBlock } from './types';
import { CompanyTimeline } from '@/components/blocks/CompanyTimeline';
export const CompanyTimelineBlock: EtibBlock = {
slug: 'companyTimeline',
interfaceName: 'CompanyTimelineBlock',
render: CompanyTimeline,
fields: [
{ name: 'title', type: 'text', localized: true },
{ name: 'subtitle', type: 'text', localized: true },
],
};

View File

@@ -1,49 +0,0 @@
import { EtibBlock } from './types';
import ComparisonGridComponent from '@/components/blog/ComparisonGrid';
export const ComparisonGrid: EtibBlock = {
slug: 'comparisonGrid',
render: ComparisonGridComponent,
fields: [
{
name: 'title',
label: 'Main Heading',
type: 'text',
required: true,
},
{
name: 'leftLabel',
type: 'text',
required: true,
},
{
name: 'rightLabel',
type: 'text',
required: true,
},
{
name: 'items',
type: 'array',
required: true,
minRows: 1,
fields: [
{
name: 'label',
label: 'Row Label',
type: 'text',
required: true,
},
{
name: 'leftValue',
type: 'text',
required: true,
},
{
name: 'rightValue',
type: 'text',
required: true,
},
],
},
],
};

View File

@@ -1,28 +0,0 @@
import { EtibBlock } from './types';
import { ContactSection as ContactSectionComponent } from '@/components/blocks/ContactSection';
export const ContactSection: EtibBlock = {
slug: 'contactSection',
interfaceName: 'ContactSectionBlock',
render: ContactSectionComponent,
fields: [
{
name: 'showForm',
type: 'checkbox',
defaultValue: true,
label: 'Show Contact Form',
},
{
name: 'showMap',
type: 'checkbox',
defaultValue: true,
label: 'Show Map',
},
{
name: 'showHours',
type: 'checkbox',
defaultValue: true,
label: 'Show Opening Hours',
},
],
};

View File

@@ -1,50 +0,0 @@
import { EtibBlock } from './types';
import { HeroSection as HeroSectionComponent } from '@/components/blocks/HeroSection';
export const HeroSection: EtibBlock = {
slug: 'heroSection',
interfaceName: 'HeroSectionBlock',
render: HeroSectionComponent,
fields: [
{
name: 'badge',
type: 'text',
required: false,
},
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'subtitle',
type: 'textarea',
required: false,
},
{
name: 'backgroundImage',
type: 'upload',
relationTo: 'media',
required: false,
},
{
name: 'ctaLabel',
type: 'text',
required: false,
},
{
name: 'ctaHref',
type: 'text',
required: false,
},
{
name: 'alignment',
type: 'select',
defaultValue: 'left',
options: [
{ label: 'Left', value: 'left' },
{ label: 'Center', value: 'center' },
],
},
],
};

View File

@@ -1,20 +0,0 @@
import { Block } from 'payload';
import { lexicalEditor } from '@payloadcms/richtext-lexical';
export const HighlightBox: Block = {
slug: 'highlightBox',
fields: [
{
name: 'type',
type: 'select',
options: ['info', 'warning', 'success', 'error', 'neutral'],
defaultValue: 'neutral',
},
{
name: 'content',
type: 'richText',
editor: lexicalEditor({}),
required: true,
},
],
};

View File

@@ -1,240 +0,0 @@
import { EtibBlock } from './types';
import { HeroVideo } from '@/components/blocks/HeroVideo';
import { SubCompanyTiles } from '@/components/blocks/SubCompanyTiles';
import { CompetenceBentoGrid } from '@/components/blocks/CompetenceBentoGrid';
import { ReferencesSlider } from '@/components/blocks/ReferencesSlider';
import Hero from '@/components/home/Hero';
import ProductCategories from '@/components/home/ProductCategories';
import WhatWeDo from '@/components/home/WhatWeDo';
import RecentPosts from '@/components/home/RecentPosts';
import Experience from '@/components/home/Experience';
import WhyChooseUs from '@/components/home/WhyChooseUs';
import MeetTheTeam from '@/components/home/MeetTheTeam';
import GallerySection from '@/components/home/GallerySection';
import VideoSection from '@/components/home/VideoSection';
import CTA from '@/components/home/CTA';
export const HomeHero: EtibBlock = {
slug: 'homeHero',
interfaceName: 'HomeHeroBlock',
render: HeroVideo,
fields: [
{ name: 'title', type: 'text', localized: true },
{ name: 'subtitle', type: 'text', localized: true },
{ name: 'videoUrl', type: 'text', admin: { description: 'URL to the background mp4 video' } },
{ name: 'posterImage', type: 'upload', relationTo: 'media' },
{ name: 'ctaLabel', type: 'text', localized: true },
{ name: 'ctaHref', type: 'text' },
{ name: 'secondaryCtaLabel', type: 'text', localized: true },
{ name: 'secondaryCtaHref', type: 'text' },
],
};
export const HomeSubCompanyTiles: EtibBlock = {
slug: 'homeSubCompanyTiles',
interfaceName: 'HomeSubCompanyTilesBlock',
render: SubCompanyTiles,
fields: [
{ name: 'badge', type: 'text', localized: true },
{ name: 'title', type: 'text', localized: true },
{
name: 'companies',
type: 'array',
localized: true,
fields: [
{ name: 'title', type: 'text' },
{ name: 'description', type: 'textarea' },
{ name: 'icon', type: 'text', admin: { description: 'SVG path for the icon' } },
{ name: 'backgroundImage', type: 'upload', relationTo: 'media' },
],
},
],
};
export const HomeCompetenceBentoGrid: EtibBlock = {
slug: 'homeCompetenceBentoGrid',
interfaceName: 'HomeCompetenceBentoGridBlock',
render: CompetenceBentoGrid,
fields: [
{ name: 'badge', type: 'text', localized: true },
{ name: 'title', type: 'text', localized: true },
{ name: 'ctaLabel', type: 'text', localized: true },
{ name: 'ctaHref', type: 'text' },
{
name: 'items',
type: 'array',
localized: true,
fields: [
{ name: 'title', type: 'text' },
{ name: 'description', type: 'textarea' },
{ name: 'tag', type: 'text' },
{ name: 'image', type: 'upload', relationTo: 'media' },
{
name: 'size',
type: 'select',
options: [
{ label: 'Large (2x2)', value: 'large' },
{ label: 'Medium (1x2)', value: 'medium' },
{ label: 'Small (1x1)', value: 'small' },
{ label: 'Accent (Text only)', value: 'accent' },
],
defaultValue: 'small',
},
],
},
],
};
export const HomeReferencesSlider: EtibBlock = {
slug: 'homeReferencesSlider',
interfaceName: 'HomeReferencesSliderBlock',
render: ReferencesSlider,
fields: [
{ name: 'badge', type: 'text', localized: true },
{ name: 'title', type: 'text', localized: true },
{ name: 'description', type: 'textarea', localized: true },
{ name: 'ctaLabel', type: 'text', localized: true },
{ name: 'ctaHref', type: 'text' },
],
};
export const HomeProductCategories: EtibBlock = {
slug: 'homeProductCategories',
interfaceName: 'HomeProductCategoriesBlock',
render: ProductCategories,
fields: [
{ name: 'title', type: 'text', localized: true },
{ name: 'subtitle', type: 'text', localized: true },
],
};
export const HomeWhatWeDo: EtibBlock = {
slug: 'homeWhatWeDo',
interfaceName: 'HomeWhatWeDoBlock',
render: WhatWeDo,
fields: [
{ name: 'title', type: 'text', localized: true },
{ name: 'subtitle', type: 'text', localized: true },
{ name: 'expertiseLabel', type: 'text', localized: true },
{ name: 'quote', type: 'textarea', localized: true },
{
name: 'items',
type: 'array',
localized: true,
fields: [
{ name: 'title', type: 'text' },
{ name: 'description', type: 'textarea' },
],
},
],
};
export const HomeRecentPosts: EtibBlock = {
slug: 'homeRecentPosts',
interfaceName: 'HomeRecentPostsBlock',
render: RecentPosts,
fields: [
{ name: 'title', type: 'text', localized: true },
{ name: 'subtitle', type: 'text', localized: true },
],
};
export const HomeExperience: EtibBlock = {
slug: 'homeExperience',
interfaceName: 'HomeExperienceBlock',
render: Experience,
fields: [
{ name: 'title', type: 'text', localized: true },
{ name: 'subtitle', type: 'text', localized: true },
{ name: 'paragraph1', type: 'textarea', localized: true },
{ name: 'paragraph2', type: 'textarea', localized: true },
{ name: 'badge1', type: 'text', localized: true },
{ name: 'badge1Text', type: 'text', localized: true },
{ name: 'badge2', type: 'text', localized: true },
{ name: 'badge2Text', type: 'text', localized: true },
],
};
export const HomeWhyChooseUs: EtibBlock = {
slug: 'homeWhyChooseUs',
interfaceName: 'HomeWhyChooseUsBlock',
render: WhyChooseUs,
fields: [
{ name: 'title', type: 'text', localized: true },
{ name: 'subtitle', type: 'text', localized: true },
{ name: 'tagline', type: 'text', localized: true },
{
name: 'features',
type: 'array',
localized: true,
fields: [{ name: 'feature', type: 'text' }],
},
{
name: 'items',
type: 'array',
localized: true,
fields: [
{ name: 'title', type: 'text' },
{ name: 'description', type: 'textarea' },
],
},
],
};
export const HomeMeetTheTeam: EtibBlock = {
slug: 'homeMeetTheTeam',
interfaceName: 'HomeMeetTheTeamBlock',
render: MeetTheTeam,
fields: [
{ name: 'title', type: 'text', localized: true },
{ name: 'subtitle', type: 'text', localized: true },
{ name: 'description', type: 'textarea', localized: true },
{ name: 'ctaLabel', type: 'text', localized: true },
{ name: 'networkLabel', type: 'text', localized: true },
],
};
export const HomeGallery: EtibBlock = {
slug: 'homeGallery',
interfaceName: 'HomeGalleryBlock',
render: GallerySection,
fields: [
{ name: 'title', type: 'text', localized: true },
{ name: 'subtitle', type: 'text', localized: true },
],
};
export const HomeVideo: EtibBlock = {
slug: 'homeVideo',
interfaceName: 'HomeVideoBlock',
render: VideoSection,
fields: [{ name: 'title', type: 'text', localized: true }],
};
export const HomeCTA: EtibBlock = {
slug: 'homeCTA',
interfaceName: 'HomeCTABlock',
render: CTA,
fields: [
{ name: 'title', type: 'text', localized: true },
{ name: 'subtitle', type: 'text', localized: true },
{ name: 'description', type: 'textarea', localized: true },
{ name: 'buttonLabel', type: 'text', localized: true },
],
};
export const homeBlocksArray = [
HomeHero,
HomeSubCompanyTiles,
HomeCompetenceBentoGrid,
HomeReferencesSlider,
HomeProductCategories,
HomeWhatWeDo,
HomeRecentPosts,
HomeExperience,
HomeWhyChooseUs,
HomeMeetTheTeam,
HomeGallery,
HomeVideo,
HomeCTA,
];

View File

@@ -1,27 +0,0 @@
import { Block } from 'payload';
export const ImageGallery: Block = {
slug: 'imageGallery',
interfaceName: 'ImageGalleryBlock',
fields: [
{
name: 'images',
type: 'array',
required: true,
minRows: 1,
fields: [
{
name: 'image',
type: 'upload',
relationTo: 'media',
required: true,
},
{
name: 'alt',
type: 'text',
required: false,
},
],
},
],
};

View File

@@ -1,22 +0,0 @@
import { EtibBlock } from './types';
import { JobListingBlock as JobListingComponent } from '@/components/blocks/JobListingBlock';
export const JobListingBlock: EtibBlock = {
slug: 'jobListing',
interfaceName: 'JobListingBlock',
render: JobListingComponent,
fields: [
{
name: 'title',
type: 'text',
localized: true,
defaultValue: 'Aktuelle Stellenangebote',
},
{
name: 'showFairs',
type: 'checkbox',
defaultValue: true,
label: 'Nächste Messetermine anzeigen',
},
],
};

View File

@@ -1,41 +0,0 @@
import { Block } from 'payload';
export const ManifestoGrid: Block = {
slug: 'manifestoGrid',
interfaceName: 'ManifestoGridBlock',
fields: [
{
name: 'title',
type: 'text',
required: false,
},
{
name: 'subtitle',
type: 'text',
required: false,
},
{
name: 'tagline',
type: 'textarea',
required: false,
},
{
name: 'items',
type: 'array',
required: true,
minRows: 1,
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'description',
type: 'textarea',
required: true,
},
],
},
],
};

View File

@@ -1,32 +0,0 @@
import { EtibBlock } from './types';
import { PDFDownloadBlock } from '@/components/PDFDownloadBlock';
export const PDFDownload: EtibBlock = {
slug: 'pdfDownload',
render: PDFDownloadBlock,
labels: {
singular: 'PDF Download',
plural: 'PDF Downloads',
},
admin: {},
fields: [
{
name: 'label',
type: 'text',
label: 'Button Beschriftung',
required: true,
localized: true,
defaultValue: 'Als PDF herunterladen',
},
{
name: 'style',
type: 'select',
defaultValue: 'primary',
options: [
{ label: 'Primary', value: 'primary' },
{ label: 'Secondary', value: 'secondary' },
{ label: 'Outline', value: 'outline' },
],
},
],
};

View File

@@ -1,12 +0,0 @@
import { Block } from 'payload';
export const PowerCTA: Block = {
slug: 'powerCTA',
fields: [
{
name: 'locale',
type: 'text',
required: true,
},
],
};

View File

@@ -1,109 +0,0 @@
import { EtibBlock } from './types';
import { lexicalEditor } from '@payloadcms/richtext-lexical';
import ProductTabsComponent from '@/components/ProductTabs';
export const ProductTabs: EtibBlock = {
slug: 'productTabs',
interfaceName: 'ProductTabsBlock',
render: ProductTabsComponent,
fields: [
{
name: 'technicalItems',
type: 'array',
fields: [
{
name: 'label',
type: 'text',
required: true,
},
{
name: 'value',
type: 'text',
required: true,
},
{
name: 'unit',
type: 'text',
},
],
},
{
name: 'voltageTables',
type: 'array',
fields: [
{
name: 'voltageLabel',
type: 'text',
required: true,
},
{
name: 'metaItems',
type: 'array',
fields: [
{
name: 'label',
type: 'text',
required: true,
},
{
name: 'value',
type: 'text',
required: true,
},
{
name: 'unit',
type: 'text',
},
],
},
{
name: 'columns',
type: 'array',
fields: [
{
name: 'key',
type: 'text',
required: true,
},
{
name: 'label',
type: 'text',
required: true,
},
],
},
{
name: 'rows',
type: 'array',
fields: [
{
name: 'configuration',
type: 'text',
required: true,
},
{
name: 'cells',
type: 'array',
fields: [
{
name: 'value',
type: 'text',
},
],
},
],
},
],
},
{
name: 'content',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
// We don't need blocks inside ProductTabs for now to avoid complexity/recursion
],
}),
},
],
};

View File

@@ -1,25 +0,0 @@
import { EtibBlock } from './types';
import SplitHeadingComponent from '@/components/blog/SplitHeading';
export const SplitHeading: EtibBlock = {
slug: 'splitHeading',
interfaceName: 'SplitHeadingBlock',
render: SplitHeadingComponent,
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'id',
type: 'text',
},
{
name: 'level',
type: 'select',
options: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
defaultValue: 'h2',
},
],
};

View File

@@ -1,27 +0,0 @@
import { EtibBlock } from './types';
import StatsComponent from '@/components/blog/Stats';
export const Stats: EtibBlock = {
slug: 'stats',
interfaceName: 'StatsBlock',
render: StatsComponent,
fields: [
{
name: 'stats',
type: 'array',
required: true,
fields: [
{
name: 'value',
type: 'text',
required: true,
},
{
name: 'label',
type: 'text',
required: true,
},
],
},
],
};

View File

@@ -1,33 +0,0 @@
import { EtibBlock } from './types';
import StickyNarrativeComponent from '@/components/blog/StickyNarrative';
export const StickyNarrative: EtibBlock = {
slug: 'stickyNarrative',
render: StickyNarrativeComponent,
fields: [
{
name: 'title',
label: 'Main Heading',
type: 'text',
required: true,
},
{
name: 'items',
type: 'array',
required: true,
minRows: 1,
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'content',
type: 'textarea',
required: true,
},
],
},
],
};

View File

@@ -1,30 +0,0 @@
import { EtibBlock } from './types';
import { SupportCTA as SupportCTAComponent } from '@/components/blocks/SupportCTA';
export const SupportCTA: EtibBlock = {
slug: 'supportCTA',
interfaceName: 'SupportCTABlock',
render: SupportCTAComponent,
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'description',
type: 'textarea',
required: true,
},
{
name: 'buttonLabel',
type: 'text',
required: true,
},
{
name: 'buttonHref',
type: 'text',
required: true,
},
],
};

View File

@@ -1,33 +0,0 @@
import { EtibBlock } from './types';
import { TeamGridBlock as TeamGridComponent } from '@/components/blocks/TeamGridBlock';
export const TeamGrid: EtibBlock = {
slug: 'teamGrid',
interfaceName: 'TeamGridBlock',
render: TeamGridComponent,
fields: [
{
name: 'title',
type: 'text',
localized: true,
defaultValue: 'Ihre Ansprechpartner',
},
{
name: 'subtitle',
type: 'text',
localized: true,
defaultValue: 'Sprechen Sie direkt mit unseren Experten für Ihr regionales Projekt.',
},
{
name: 'filterBranch',
type: 'select',
options: [
{ label: 'All', value: 'all' },
{ label: 'E-TIB GmbH', value: 'e-tib' },
{ label: 'Bohrtechnik', value: 'bohr' },
{ label: 'Ingenieurgesellschaft', value: 'ing' },
],
defaultValue: 'all',
},
],
};

View File

@@ -1,64 +0,0 @@
import { EtibBlock } from './types';
import { TeamProfile as TeamProfileComponent } from '@/components/blocks/TeamProfile';
export const TeamProfile: EtibBlock = {
slug: 'teamProfile',
interfaceName: 'TeamProfileBlock',
render: TeamProfileComponent,
fields: [
{
name: 'name',
type: 'text',
required: true,
},
{
name: 'role',
type: 'text',
required: true,
},
{
name: 'quote',
type: 'textarea',
required: false,
},
{
name: 'description',
type: 'textarea',
required: false,
},
{
name: 'image',
type: 'upload',
relationTo: 'media',
required: false,
},
{
name: 'linkedinUrl',
type: 'text',
required: false,
},
{
name: 'linkedinLabel',
type: 'text',
required: false,
},
{
name: 'layout',
type: 'select',
defaultValue: 'imageRight',
options: [
{ label: 'Image Right', value: 'imageRight' },
{ label: 'Image Left', value: 'imageLeft' },
],
},
{
name: 'colorScheme',
type: 'select',
defaultValue: 'dark',
options: [
{ label: 'Dark', value: 'dark' },
{ label: 'Light', value: 'light' },
],
},
],
};

View File

@@ -1,32 +0,0 @@
import { EtibBlock } from './types';
import TechnicalGridComponent from '@/components/blog/TechnicalGrid';
export const TechnicalGrid: EtibBlock = {
slug: 'technicalGrid',
render: TechnicalGridComponent,
fields: [
{
name: 'title',
label: 'Main Heading',
type: 'text',
},
{
name: 'items',
type: 'array',
required: true,
minRows: 1,
fields: [
{
name: 'label',
type: 'text',
required: true,
},
{
name: 'value',
type: 'text',
required: true,
},
],
},
],
};

View File

@@ -1,32 +0,0 @@
import { EtibBlock } from './types';
import VisualLinkPreviewComponent from '@/components/blog/VisualLinkPreview';
export const VisualLinkPreview: EtibBlock = {
slug: 'visualLinkPreview',
render: VisualLinkPreviewComponent,
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'description',
type: 'textarea',
},
{
name: 'href',
type: 'text',
required: true,
},
{
name: 'image',
type: 'upload',
relationTo: 'media',
},
{
name: 'tag',
type: 'text',
},
],
};

View File

@@ -1,62 +0,0 @@
import { AnimatedImage } from './AnimatedImage';
import { Callout } from './Callout';
import { CategoryGrid } from './CategoryGrid';
import { ChatBubble } from './ChatBubble';
import { ComparisonGrid } from './ComparisonGrid';
import { ContactSection } from './ContactSection';
import { HeroSection } from './HeroSection';
import { HighlightBox } from './HighlightBox';
import { ImageGallery } from './ImageGallery';
import { ManifestoGrid } from './ManifestoGrid';
import { PowerCTA } from './PowerCTA';
import { ProductTabs } from './ProductTabs';
import { SplitHeading } from './SplitHeading';
import { Stats } from './Stats';
import { StickyNarrative } from './StickyNarrative';
import { TeamProfile } from './TeamProfile';
import { TechnicalGrid } from './TechnicalGrid';
import { VisualLinkPreview } from './VisualLinkPreview';
import { PDFDownload } from './PDFDownload';
import { TeamGrid } from './TeamGrid';
import { JobListingBlock } from './JobListing';
import { CompanyTimelineBlock } from './CompanyTimeline';
import { SupportCTA } from './SupportCTA';
import { homeBlocksArray } from './HomeBlocks';
import { EtibBlock } from './types';
/**
* All blocks including their React 'render' property.
* Used by the frontend renderer (PayloadRichText.tsx).
*/
export const allBlocks: EtibBlock[] = [
...homeBlocksArray,
AnimatedImage,
Callout,
CategoryGrid,
ChatBubble,
ComparisonGrid,
ContactSection,
HeroSection,
HighlightBox,
ImageGallery,
ManifestoGrid,
PowerCTA,
ProductTabs,
SplitHeading,
Stats,
StickyNarrative,
TeamProfile,
TechnicalGrid,
VisualLinkPreview,
PDFDownload,
TeamGrid,
JobListingBlock,
CompanyTimelineBlock,
SupportCTA,
];
/**
* Blocks stripped of the 'render' property.
* Used by Payload CMS configuration to avoid importing React components into the server build.
*/
export const payloadBlocks = allBlocks.map(({ render, ...block }) => block);

View File

@@ -1,6 +0,0 @@
import type { Block } from 'payload';
import type { ComponentType } from 'react';
export type EtibBlock = Block & {
render?: ComponentType<any>;
};

View File

@@ -1,46 +0,0 @@
import type { CollectionConfig } from 'payload';
import { authenticated } from '../access/authenticated';
import { anyone } from '../access/anyone';
export const Competences: CollectionConfig = {
slug: 'competences',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'slug', 'updatedAt'],
},
access: {
read: anyone,
create: authenticated,
update: authenticated,
delete: authenticated,
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'slug',
type: 'text',
required: true,
admin: {
position: 'sidebar',
},
},
{
name: 'excerpt',
type: 'textarea',
required: true,
},
{
name: 'description',
type: 'richText',
},
{
name: 'icon',
type: 'upload',
relationTo: 'media',
},
],
};

View File

@@ -1,68 +0,0 @@
import type { CollectionConfig } from 'payload';
export const FormSubmissions: CollectionConfig = {
slug: 'form-submissions',
admin: {
useAsTitle: 'name',
defaultColumns: ['name', 'email', 'type', 'createdAt'],
description: 'Captured leads from Contact and Product Quote forms.',
},
access: {
// Only Admins can view and delete leads via dashboard.
read: ({ req: { user } }) => Boolean(user) || process.env.NODE_ENV === 'development',
update: ({ req: { user } }) => Boolean(user) || process.env.NODE_ENV === 'development',
delete: ({ req: { user } }) => Boolean(user) || process.env.NODE_ENV === 'development',
// Next.js server actions handle secure inserts natively. No public client create access.
create: () => false,
},
fields: [
{
name: 'name',
type: 'text',
required: true,
admin: {
readOnly: true,
},
},
{
name: 'email',
type: 'email',
required: true,
admin: {
readOnly: true,
},
},
{
name: 'type',
type: 'select',
options: [
{ label: 'General Contact', value: 'contact' },
{ label: 'Product Quote', value: 'product_quote' },
{ label: 'Brochure Download', value: 'brochure_download' },
],
required: true,
admin: {
position: 'sidebar',
readOnly: true,
},
},
{
name: 'productName',
type: 'text',
admin: {
position: 'sidebar',
readOnly: true,
condition: (data) => data.type === 'product_quote',
description: 'The specific KLZ product the user requested a quote for.',
},
},
{
name: 'message',
type: 'textarea',
required: true,
admin: {
readOnly: true,
},
},
],
};

View File

@@ -1,59 +0,0 @@
import type { CollectionConfig } from 'payload';
import { authenticated } from '../access/authenticated';
import { anyone } from '../access/anyone';
export const Jobs: CollectionConfig = {
slug: 'jobs',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'department', 'type'],
},
access: {
read: anyone,
create: authenticated,
update: authenticated,
delete: authenticated,
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'slug',
type: 'text',
required: true,
admin: {
position: 'sidebar',
},
},
{
name: 'department',
type: 'text',
required: true,
},
{
name: 'location',
type: 'text',
defaultValue: 'Guben / Bülstedt',
},
{
name: 'type',
type: 'select',
options: [
{ label: 'Vollzeit', value: 'vollzeit' },
{ label: 'Teilzeit', value: 'teilzeit' },
{ label: 'Ausbildung', value: 'ausbildung' },
{ label: 'Praktikum', value: 'praktikum' },
],
required: true,
defaultValue: 'vollzeit',
},
{
name: 'description',
type: 'richText',
required: true,
},
],
};

View File

@@ -1,48 +0,0 @@
import type { CollectionConfig } from 'payload';
export const Media: CollectionConfig = {
slug: 'media',
access: {
read: () => true,
},
admin: {
useAsTitle: 'filename',
defaultColumns: ['filename', 'alt', 'updatedAt'],
},
upload: {
staticDir: 'public/media',
adminThumbnail: 'thumbnail',
imageSizes: [
{
name: 'thumbnail',
width: 600,
// height: undefined allows wide 5:1 aspect ratios to be preserved without cropping
height: undefined,
position: 'centre',
},
{
name: 'card',
width: 768,
height: undefined,
position: 'centre',
},
{
name: 'tablet',
width: 1024,
height: undefined,
position: 'centre',
},
],
},
fields: [
{
name: 'alt',
type: 'text',
required: true,
},
{
name: 'caption',
type: 'text',
},
],
};

View File

@@ -1,91 +0,0 @@
import { CollectionConfig } from 'payload';
import { lexicalEditor, BlocksFeature } from '@payloadcms/richtext-lexical';
import { payloadBlocks } from '../blocks/allBlocks';
export const Pages: CollectionConfig = {
slug: 'pages',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'slug', 'layout', '_status', 'updatedAt'],
},
versions: {
drafts: true,
},
access: {
read: ({ req: { user } }) => {
if (process.env.NODE_ENV === 'development' || process.env.TARGET === 'staging') {
return true;
}
if (user) {
return true;
}
return {
_status: {
equals: 'published',
},
};
},
},
fields: [
{
name: 'title',
type: 'text',
required: true,
localized: true,
},
{
name: 'slug',
type: 'text',
required: true,
localized: true,
admin: {
position: 'sidebar',
description: 'The URL slug for this locale (e.g. "impressum" for DE, "imprint" for EN).',
},
},
{
name: 'layout',
type: 'select',
defaultValue: 'default',
options: [
{ label: 'Default (Article)', value: 'default' },
{ label: 'Full Bleed (Blocks Only)', value: 'fullBleed' },
],
admin: {
position: 'sidebar',
description: 'Full Bleed pages render blocks edge-to-edge without a generic hero wrapper.',
},
},
{
name: 'excerpt',
type: 'textarea',
localized: true,
admin: {
position: 'sidebar',
},
},
{
name: 'featuredImage',
type: 'upload',
relationTo: 'media',
admin: {
position: 'sidebar',
},
},
{
name: 'content',
type: 'richText',
localized: true,
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
BlocksFeature({
blocks: payloadBlocks,
}),
],
}),
required: true,
},
],
};

View File

@@ -1,139 +0,0 @@
import type { CollectionConfig } from 'payload';
import { lexicalEditor, BlocksFeature } from '@payloadcms/richtext-lexical';
import { StickyNarrative } from '../blocks/StickyNarrative';
import { ComparisonGrid } from '../blocks/ComparisonGrid';
import { VisualLinkPreview } from '../blocks/VisualLinkPreview';
import { TechnicalGrid } from '../blocks/TechnicalGrid';
import { HighlightBox } from '../blocks/HighlightBox';
import { AnimatedImage } from '../blocks/AnimatedImage';
import { ChatBubble } from '../blocks/ChatBubble';
import { PowerCTA } from '../blocks/PowerCTA';
import { Callout } from '../blocks/Callout';
import { Stats } from '../blocks/Stats';
import { SplitHeading } from '../blocks/SplitHeading';
export const Posts: CollectionConfig = {
slug: 'posts',
admin: {
defaultColumns: ['featuredImage', 'title', 'date', 'updatedAt', '_status'],
},
versions: {
drafts: true,
},
access: {
read: ({ req: { user } }) => {
if (process.env.NODE_ENV === 'development' || process.env.TARGET === 'staging') {
return true;
}
if (user) {
return true;
}
return {
and: [
{
_status: {
equals: 'published',
},
},
{
date: {
less_than_equal: new Date().toISOString(),
},
},
],
};
},
},
fields: [
{
name: 'title',
type: 'text',
required: true,
localized: true,
},
{
name: 'slug',
type: 'text',
required: true,
localized: true,
admin: {
position: 'sidebar',
description: 'Unique slug per locale (e.g. same slug can exist in DE and EN).',
},
hooks: {
beforeValidate: [
({ value, data }) => {
if (value || !data?.title) return value;
return data.title
.toLowerCase()
.replace(/ /g, '-')
.replace(/[^\w-]+/g, '');
},
],
},
},
{
name: 'excerpt',
type: 'text',
localized: true,
admin: {
description: 'A short summary for blog feed cards and SEO.',
},
},
{
name: 'date',
type: 'date',
required: true,
admin: {
position: 'sidebar',
description: 'Future dates will schedule the post to publish automatically.',
},
defaultValue: () => new Date().toISOString(),
},
{
name: 'featuredImage',
type: 'upload',
relationTo: 'media',
admin: {
position: 'sidebar',
description: 'The primary Hero image used for headers and OpenGraph previews.',
},
},
{
name: 'category',
type: 'text',
localized: true,
admin: {
position: 'sidebar',
description: 'Used for tag bucketing (e.g. "Kabel Technologie").',
},
},
{
name: 'content',
type: 'richText',
localized: true,
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
BlocksFeature({
blocks: [
StickyNarrative,
ComparisonGrid,
VisualLinkPreview,
TechnicalGrid,
HighlightBox,
AnimatedImage,
ChatBubble,
PowerCTA,
Callout,
Stats,
SplitHeading,
],
}),
],
}),
},
],
};

View File

@@ -1,52 +0,0 @@
import type { CollectionConfig } from 'payload';
import { authenticated } from '../access/authenticated';
import { anyone } from '../access/anyone';
export const References: CollectionConfig = {
slug: 'references',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'category', 'updatedAt'],
},
access: {
read: anyone,
create: authenticated,
update: authenticated,
delete: authenticated,
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'slug',
type: 'text',
required: true,
admin: {
position: 'sidebar',
},
},
{
name: 'category',
type: 'select',
options: [
{ label: 'Kabelbau', value: 'kabelbau' },
{ label: 'Bohrtechnik', value: 'bohrtechnik' },
{ label: 'Vermessung', value: 'vermessung' },
{ label: 'Planung', value: 'planung' },
],
required: true,
},
{
name: 'description',
type: 'richText',
},
{
name: 'image',
type: 'upload',
relationTo: 'media',
},
],
};

View File

@@ -1,53 +0,0 @@
import type { CollectionConfig } from 'payload';
import { authenticated } from '../access/authenticated';
import { anyone } from '../access/anyone';
export const Team: CollectionConfig = {
slug: 'team',
admin: {
useAsTitle: 'name',
defaultColumns: ['name', 'position', 'email'],
},
access: {
read: anyone,
create: authenticated,
update: authenticated,
delete: authenticated,
},
fields: [
{
name: 'name',
type: 'text',
required: true,
},
{
name: 'position',
type: 'text',
required: true,
},
{
name: 'email',
type: 'email',
},
{
name: 'phone',
type: 'text',
},
{
name: 'image',
type: 'upload',
relationTo: 'media',
},
{
name: 'branch',
type: 'select',
options: [
{ label: 'E-TIB GmbH', value: 'e-tib' },
{ label: 'Ingenieurgesellschaft', value: 'ing' },
{ label: 'Bohrtechnik', value: 'bohr' },
{ label: 'Verwaltung', value: 'verw' },
],
required: true,
},
],
};

View File

@@ -1,12 +0,0 @@
import type { CollectionConfig } from 'payload';
export const Users: CollectionConfig = {
slug: 'users',
admin: {
useAsTitle: 'email',
},
auth: true,
fields: [
// Email added by default
],
};

View File

@@ -1,13 +0,0 @@
/* eslint-disable @next/next/no-img-element */
import React from 'react';
export default function Icon() {
return (
<img
src="/logo-blue.svg"
alt="KLZ"
className="klz-admin-icon"
style={{ maxWidth: '100%', height: 'auto', maxHeight: '32px', display: 'block' }}
/>
);
}

View File

@@ -1,13 +0,0 @@
/* eslint-disable @next/next/no-img-element */
import React from 'react';
export default function Logo() {
return (
<img
src="/logo-blue.svg"
alt="KLZ Cables"
className="klz-admin-logo"
style={{ maxWidth: '100%', height: 'auto', maxHeight: '40px', display: 'block' }}
/>
);
}

View File

@@ -1,60 +0,0 @@
import type { GlobalConfig } from 'payload';
import { authenticated } from '../access/authenticated';
import { anyone } from '../access/anyone';
export const Settings: GlobalConfig = {
slug: 'settings',
access: {
read: anyone,
update: authenticated,
},
fields: [
{
type: 'tabs',
tabs: [
{
label: 'Company Information',
fields: [
{
name: 'contactEmail',
type: 'email',
required: true,
defaultValue: 'info@e-tib.com',
},
{
name: 'contactPhone',
type: 'text',
defaultValue: '+49 15207230518',
},
{
name: 'address',
type: 'textarea',
defaultValue: 'Gewerbestraße 22\\n03172 Guben',
},
],
},
{
label: 'Navigation',
fields: [
{
name: 'mainNav',
type: 'array',
fields: [
{
name: 'label',
type: 'text',
required: true,
},
{
name: 'url',
type: 'text',
required: true,
},
],
},
],
},
],
},
],
};

View File

@@ -1,836 +0,0 @@
import { Payload } from 'payload';
export async function seedDatabase(payload: Payload) {
// Check if any users exist
const { totalDocs: totalUsers } = await payload.find({
collection: 'users',
limit: 1,
});
if (totalUsers === 0) {
payload.logger.info('👤 No users found. Creating default admin user...');
await payload.create({
collection: 'users',
data: {
email: 'admin@mintel.me',
password: 'klz-admin-setup',
},
});
payload.logger.info('✅ Default admin user created successfully.');
}
// Seed Team Members
const { totalDocs: totalTeam } = await payload.find({
collection: 'team',
limit: 1,
});
if (totalTeam === 0) {
payload.logger.info('👥 Seeding team members...');
await payload.create({
collection: 'team',
data: {
name: 'Danny Joseph',
position: 'Geschäftsführer',
email: 'd.joseph@e-tib.com',
phone: '+49 15207230518',
branch: 'e-tib',
},
});
await payload.create({
collection: 'team',
data: {
name: 'Technischer Leiter',
position: 'Projektierung & Planung',
email: 'planung@e-tib.com',
phone: '+49 4283 6979923',
branch: 'ing',
},
});
payload.logger.info('✅ Team members seeded.');
}
// Seed References
const { totalDocs: totalRefs } = await payload.find({
collection: 'references',
limit: 1,
});
if (totalRefs === 0) {
payload.logger.info('🏗️ Seeding references...');
await payload.create({
collection: 'references',
data: {
title: 'Breitbandausbau FTTC',
slug: 'breitbandausbau-fttc-spreewald',
category: 'kabelbau',
description: {
root: {
type: 'root',
direction: 'ltr',
format: '',
indent: 0,
version: 1,
children: [
{
type: 'paragraph',
format: '',
indent: 0,
version: 1,
children: [
{
type: 'text',
text: 'Ausbau der Breitband-Infrastruktur (FTTC) in der Planungsregion Spreewald.',
version: 1,
},
],
},
],
},
},
},
});
await payload.create({
collection: 'references',
data: {
title: 'Breitbandausbau FTTH',
slug: 'breitbandausbau-ftth-bomlitz',
category: 'kabelbau',
description: {
root: {
type: 'root',
direction: 'ltr',
format: '',
indent: 0,
version: 1,
children: [
{
type: 'paragraph',
format: '',
indent: 0,
version: 1,
children: [
{
type: 'text',
text: 'Umfangreicher FTTH-Ausbau in 29699 Bomlitz und 29690 Schwarmstedt.',
version: 1,
},
],
},
],
},
},
},
});
await payload.create({
collection: 'references',
data: {
title: 'Neubau Kabeltrasse Forst',
slug: 'neubau-kabeltrasse-forst',
category: 'kabelbau',
description: {
root: {
type: 'root',
direction: 'ltr',
format: '',
indent: 0,
version: 1,
children: [
{
type: 'paragraph',
format: '',
indent: 0,
version: 1,
children: [
{
type: 'text',
text: 'Errichtung einer neuen Kabeltrasse in 03149 Forst.',
version: 1,
},
],
},
],
},
},
},
});
await payload.create({
collection: 'references',
data: {
title: 'Kabelverbindung U30 - T10',
slug: 'kabelverbindung-eisenhuettenstadt',
category: 'kabelbau',
description: {
root: {
type: 'root',
direction: 'ltr',
format: '',
indent: 0,
version: 1,
children: [
{
type: 'paragraph',
format: '',
indent: 0,
version: 1,
children: [
{
type: 'text',
text: 'Realisierung der Kabelverbindung U30 - T10 in 15890 Eisenhüttenstadt.',
version: 1,
},
],
},
],
},
},
},
});
await payload.create({
collection: 'references',
data: {
title: 'Einspeisung PV-Anlage',
slug: 'einspeisung-pv-goerne',
category: 'kabelbau',
description: {
root: {
type: 'root',
direction: 'ltr',
format: '',
indent: 0,
version: 1,
children: [
{
type: 'paragraph',
format: '',
indent: 0,
version: 1,
children: [
{
type: 'text',
text: 'Netzanschluss und Einspeisung für eine PV-Anlage in 14728 Görne.',
version: 1,
},
],
},
],
},
},
},
});
payload.logger.info('✅ References seeded.');
}
// Seed Jobs
const { totalDocs: totalJobs } = await payload.find({
collection: 'jobs',
limit: 1,
});
if (totalJobs === 0) {
payload.logger.info('💼 Seeding jobs...');
await payload.create({
collection: 'jobs',
data: {
title: 'Initiativbewerbung (m/w/d)',
slug: 'initiativbewerbung',
department: 'Alle Bereiche',
location: 'Guben / Bundesweit',
type: 'vollzeit',
description: {
root: {
type: 'root',
direction: 'ltr',
format: '',
indent: 0,
version: 1,
children: [
{
type: 'paragraph',
format: '',
indent: 0,
version: 1,
children: [
{
type: 'text',
text: 'Sie können sich vorstellen, eine langfristige Herausforderung in unserem Unternehmen anzunehmen, finden aber kein passendes Stellenangebot? Bewerben Sie sich initiativ!',
version: 1,
},
],
},
{
type: 'paragraph',
format: '',
indent: 0,
version: 1,
children: [
{
type: 'text',
text: 'Wir bieten unseren Mitarbeitern:',
version: 1,
format: 1, // bold
},
],
},
{
type: 'list',
listType: 'bullet',
format: '',
indent: 0,
version: 1,
children: [
{ type: 'listitem', value: 1, version: 1, children: [{ type: 'text', text: 'Einsatz bei regionalen und überregionalen Bauvorhaben', version: 1 }] },
{ type: 'listitem', value: 2, version: 1, children: [{ type: 'text', text: 'Sicherer, unbefristeter Arbeitsvertrag in Vollzeit', version: 1 }] },
{ type: 'listitem', value: 3, version: 1, children: [{ type: 'text', text: 'Entsprechende Einarbeitung in einem engagierten Team', version: 1 }] },
{ type: 'listitem', value: 4, version: 1, children: [{ type: 'text', text: 'Leistungsbezogene und faire Vergütung', version: 1 }] },
{ type: 'listitem', value: 5, version: 1, children: [{ type: 'text', text: 'Auslöse bis 30,00 EUR pro Arbeitstag', version: 1 }] },
{ type: 'listitem', value: 6, version: 1, children: [{ type: 'text', text: '31 Tage Urlaub sowie Urlaubsgeld', version: 1 }] },
{ type: 'listitem', value: 7, version: 1, children: [{ type: 'text', text: 'Arbeitskleidung (Engelbert-Strauss)', version: 1 }] },
{ type: 'listitem', value: 8, version: 1, children: [{ type: 'text', text: 'Monatliche Zusatzzahlungen (persönliche MasterCard)', version: 1 }] },
{ type: 'listitem', value: 9, version: 1, children: [{ type: 'text', text: 'Zuzahlung im Rahmen der betrieblichen Altersvorsorge', version: 1 }] },
{ type: 'listitem', value: 10, version: 1, children: [{ type: 'text', text: 'Individuelle Weiterbildungs- und Entwicklungsmöglichkeiten', version: 1 }] },
{ type: 'listitem', value: 11, version: 1, children: [{ type: 'text', text: 'Stellung Fahrzeug zur betrieblichen Nutzung und Fahrten zur Baustelle', version: 1 }] },
{ type: 'listitem', value: 12, version: 1, children: [{ type: 'text', text: 'Familiäres Betriebsklima mit betrieblich organisierten Veranstaltungen', version: 1 }] },
],
},
],
},
},
},
});
payload.logger.info('✅ Jobs seeded.');
}
// 1. Seed Home Page for both locales
for (const loc of ['de', 'en']) {
const { docs } = await payload.find({
collection: 'pages',
where: { slug: { equals: 'home' } },
locale: loc as any,
limit: 1,
});
if (docs.length === 0) {
payload.logger.info(`📄 Seeding [${loc}] home page (not found for this locale)...`);
// Try to find the document by slug in ANY locale first to avoid duplicate IDs
const { docs: anyLocaleDocs } = await payload.find({
collection: 'pages',
where: { slug: { equals: 'home' } },
locale: 'all',
limit: 1,
});
if (anyLocaleDocs.length > 0) {
payload.logger.info(`📝 Updating existing Home page with [${loc}] content...`);
await payload.update({
collection: 'pages',
id: anyLocaleDocs[0].id,
locale: loc as any,
data: {
title: loc === 'de' ? 'Startseite' : 'Home',
slug: 'home',
layout: 'fullBleed',
_status: 'published',
content: {
root: {
type: 'root',
direction: 'ltr',
format: '',
indent: 0,
version: 1,
children: [
{
type: 'block',
format: '',
version: 2,
fields: {
blockType: 'homeHero',
badge: loc === 'de' ? 'Zukunftssichere Infrastruktur' : 'Future-Proof Infrastructure',
title: loc === 'de' ? 'Energie & Kommunikation vernetzen' : 'Networking Energy & Communication',
description: loc === 'de'
? 'Wir realisieren komplexe Versorgungsleitungen für eine moderne Gesellschaft. Verlässlich, innovativ und mit höchster Präzision im Kabelbau und der Bohrtechnik.'
: 'We realize complex supply lines for a modern society. Reliable, innovative and with the highest precision in cable construction and drilling technology.',
videoUrl: 'https://cdn.mintel.me/video/etib-hero.mp4',
linkText: loc === 'de' ? 'Unsere Leistungen' : 'Our Services',
linkHref: loc === 'de' ? '/kompetenzen' : '/en/competencies',
},
},
{
type: 'block',
format: '',
version: 2,
fields: {
blockType: 'homeSubCompanyTiles',
badge: loc === 'de' ? 'Unsere Unternehmen' : 'Our Companies',
title: loc === 'de' ? 'Die E-TIB Gruppe' : 'The E-TIB Group',
companies: [
{
title: 'E-TIB GmbH',
description: loc === 'de' ? 'Ausführung elektrischer Infrastrukturprojekte' : 'Execution of electrical infrastructure projects',
icon: 'M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z',
},
{
title: 'E-TIB Bohrtechnik GmbH',
description: loc === 'de' ? 'Präzise Horizontalbohrungen in allen Bodenklassen' : 'Precise horizontal drilling in all soil classes',
icon: 'M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z',
},
{
title: 'E-TIB Verwaltung GmbH',
description: loc === 'de' ? 'Zentrale Dienste, Einkauf, Finanzen' : 'Central services, purchasing, finance',
icon: 'M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z',
},
{
title: 'E-TIB Ingenieurgesellschaft mbH',
description: loc === 'de' ? 'Planung, Projektierung, Dokumentation' : 'Planning, project management, documentation',
icon: 'M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z',
}
]
}
},
{
type: 'block',
format: '',
version: 2,
fields: {
blockType: 'homeCompetenceBentoGrid',
badge: loc === 'de' ? 'Leistungsspektrum' : 'Range of Services',
title: loc === 'de' ? 'Umfassende Lösungen für komplexe Netzwerke' : 'Comprehensive solutions for complex networks',
ctaLabel: loc === 'de' ? 'Alle Kompetenzen ansehen' : 'View all competencies',
ctaHref: loc === 'de' ? '/kompetenzen' : '/en/competencies',
items: [
{
title: loc === 'de' ? 'Kabelleitungstiefbau MS/NS' : 'Cable Trenching MV/LV',
description: loc === 'de' ? 'Kabel- und Rohrgräben, Verlegung bis 110 kV, Montage bis 30 kV.' : 'Cable and pipe trenches, laying up to 110 kV, assembly up to 30 kV.',
tag: 'ENERGIE',
size: 'large',
},
{
title: loc === 'de' ? 'Breitband & Glasfaser' : 'Broadband & Fiber Optics',
description: loc === 'de' ? 'Leerrohrtrassen, Einziehen und Einblasen von LWL-Kabeln.' : 'Empty conduit routes, pulling in and blowing in fiber optic cables.',
tag: 'TELEKOMMUNIKATION',
size: 'medium',
},
{
title: loc === 'de' ? 'Grabenlose Verlegung' : 'Trenchless Laying',
description: loc === 'de' ? 'Horizontalspülbohrungen (bis 250m) und Erdraketen (bis 15m).' : 'Horizontal directional drilling (up to 250m) and soil rockets (up to 15m).',
tag: 'BOHRTECHNIK',
size: 'medium',
},
{
title: loc === 'de' ? 'Planung & Beratung' : 'Planning & Consulting',
description: loc === 'de' ? 'Struktur-, Genehmigungs- und Ausführungsplanung.' : 'Structural, approval and execution planning.',
tag: 'ENGINEERING',
size: 'small',
},
{
title: loc === 'de' ? 'Vermessung & Doku' : 'Surveying & Docu',
description: loc === 'de' ? 'Absteckung, Einmessung und 360° Erfassung.' : 'Staking out, measurement and 360° recording.',
tag: 'DOKUMENTATION',
size: 'accent',
}
]
}
},
{
type: 'block',
format: '',
version: 2,
fields: {
blockType: 'homeReferencesSlider',
badge: loc === 'de' ? 'Referenzen' : 'References',
title: loc === 'de' ? 'Erfolgreich realisierte Projekte' : 'Successfully completed projects',
description: loc === 'de' ? 'Ein Auszug unserer aktuellen Bauvorhaben aus den Bereichen Kabeltiefbau, Bohrtechnik und Montage.' : 'An excerpt of our current construction projects in the areas of cable trenching, drilling technology and assembly.',
ctaLabel: loc === 'de' ? 'Alle Projekte ansehen' : 'View all projects',
ctaHref: loc === 'de' ? '/referenzen' : '/en/references',
}
}
],
},
},
},
});
} else {
payload.logger.info(`✨ Creating brand new Home page for [${loc}]...`);
await payload.create({
collection: 'pages',
locale: loc as any,
data: {
title: loc === 'de' ? 'Startseite' : 'Home',
slug: 'home',
layout: 'fullBleed',
_status: 'published',
content: {
root: {
type: 'root',
direction: 'ltr',
format: '',
indent: 0,
version: 1,
children: [
{
type: 'block',
format: '',
version: 2,
fields: {
blockType: 'homeHero',
badge: loc === 'de' ? 'Zukunftssichere Infrastruktur' : 'Future-Proof Infrastructure',
title: loc === 'de' ? 'Energie & Kommunikation vernetzen' : 'Networking Energy & Communication',
description: loc === 'de'
? 'Wir realisieren komplexe Versorgungsleitungen für eine moderne Gesellschaft. Verlässlich, innovativ und mit höchster Präzision im Kabelbau und der Bohrtechnik.'
: 'We realize complex supply lines for a modern society. Reliable, innovative and with the highest precision in cable construction and drilling technology.',
videoUrl: 'https://cdn.mintel.me/video/etib-hero.mp4',
linkText: loc === 'de' ? 'Unsere Leistungen' : 'Our Services',
linkHref: loc === 'de' ? '/kompetenzen' : '/en/competencies',
},
},
{
type: 'block',
format: '',
version: 2,
fields: {
blockType: 'homeSubCompanyTiles',
badge: loc === 'de' ? 'Unsere Unternehmen' : 'Our Companies',
title: loc === 'de' ? 'Die E-TIB Gruppe' : 'The E-TIB Group',
companies: [
{
title: 'E-TIB GmbH',
description: loc === 'de' ? 'Ausführung elektrischer Infrastrukturprojekte' : 'Execution of electrical infrastructure projects',
icon: 'M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z',
},
{
title: 'E-TIB Bohrtechnik GmbH',
description: loc === 'de' ? 'Präzise Horizontalbohrungen in allen Bodenklassen' : 'Precise horizontal drilling in all soil classes',
icon: 'M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z',
},
{
title: 'E-TIB Verwaltung GmbH',
description: loc === 'de' ? 'Zentrale Dienste, Einkauf, Finanzen' : 'Central services, purchasing, finance',
icon: 'M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z',
},
{
title: 'E-TIB Ingenieurgesellschaft mbH',
description: loc === 'de' ? 'Planung, Projektierung, Dokumentation' : 'Planning, project management, documentation',
icon: 'M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z',
}
]
}
},
{
type: 'block',
format: '',
version: 2,
fields: {
blockType: 'homeCompetenceBentoGrid',
badge: loc === 'de' ? 'Leistungsspektrum' : 'Range of Services',
title: loc === 'de' ? 'Umfassende Lösungen für komplexe Netzwerke' : 'Comprehensive solutions for complex networks',
ctaLabel: loc === 'de' ? 'Alle Kompetenzen ansehen' : 'View all competencies',
ctaHref: loc === 'de' ? '/kompetenzen' : '/en/competencies',
items: [
{
title: loc === 'de' ? 'Kabelleitungstiefbau MS/NS' : 'Cable Trenching MV/LV',
description: loc === 'de' ? 'Kabel- und Rohrgräben, Verlegung bis 110 kV, Montage bis 30 kV.' : 'Cable and pipe trenches, laying up to 110 kV, assembly up to 30 kV.',
tag: 'ENERGIE',
size: 'large',
},
{
title: loc === 'de' ? 'Breitband & Glasfaser' : 'Broadband & Fiber Optics',
description: loc === 'de' ? 'Leerrohrtrassen, Einziehen und Einblasen von LWL-Kabeln.' : 'Empty conduit routes, pulling in and blowing in fiber optic cables.',
tag: 'TELEKOMMUNIKATION',
size: 'medium',
},
{
title: loc === 'de' ? 'Grabenlose Verlegung' : 'Trenchless Laying',
description: loc === 'de' ? 'Horizontalspülbohrungen (bis 250m) und Erdraketen (bis 15m).' : 'Horizontal directional drilling (up to 250m) and soil rockets (up to 15m).',
tag: 'BOHRTECHNIK',
size: 'medium',
},
{
title: loc === 'de' ? 'Planung & Beratung' : 'Planning & Consulting',
description: loc === 'de' ? 'Struktur-, Genehmigungs- und Ausführungsplanung.' : 'Structural, approval and execution planning.',
tag: 'ENGINEERING',
size: 'small',
},
{
title: loc === 'de' ? 'Vermessung & Doku' : 'Surveying & Docu',
description: loc === 'de' ? 'Absteckung, Einmessung und 360° Erfassung.' : 'Staking out, measurement and 360° recording.',
tag: 'DOKUMENTATION',
size: 'accent',
}
]
}
},
{
type: 'block',
format: '',
version: 2,
fields: {
blockType: 'homeReferencesSlider',
badge: loc === 'de' ? 'Referenzen' : 'References',
title: loc === 'de' ? 'Erfolgreich realisierte Projekte' : 'Successfully completed projects',
description: loc === 'de' ? 'Ein Auszug unserer aktuellen Bauvorhaben aus den Bereichen Kabeltiefbau, Bohrtechnik und Montage.' : 'An excerpt of our current construction projects in the areas of cable trenching, drilling technology and assembly.',
ctaLabel: loc === 'de' ? 'Alle Projekte ansehen' : 'View all projects',
ctaHref: loc === 'de' ? '/referenzen' : '/en/references',
}
}
],
},
},
},
});
}
payload.logger.info(`✅ [${loc}] Home page seeded.`);
}
}
// 2. Seed "Über uns"
const { totalDocs: totalUeberUns } = await payload.find({
collection: 'pages',
where: { slug: { equals: 'ueber-uns' } },
limit: 1,
});
if (totalUeberUns === 0) {
payload.logger.info('📄 Seeding Über uns page...');
await payload.create({
collection: 'pages',
data: {
title: 'Über uns',
slug: 'ueber-uns',
layout: 'fullBleed',
_status: 'published',
content: {
root: {
type: 'root',
direction: 'ltr',
format: '',
indent: 0,
version: 1,
children: [
{
type: 'block',
format: '',
version: 2,
fields: {
blockType: 'heroSection',
title: 'Über uns',
description: 'Seit unserer Gründung im Jahr 2015 wachsen wir stetig und entwickeln unsere Spezialkompetenzen weiter. Heute sind wir als E-TIB Gruppe ein ganzheitlicher Anbieter für Infrastrukturprojekte in ganz Deutschland.',
},
},
{
type: 'block',
format: '',
version: 2,
fields: {
blockType: 'companyTimeline',
},
},
],
},
},
},
});
payload.logger.info('✅ Über uns page seeded.');
}
// Seed "Kompetenzen"
const { totalDocs: totalKompetenzen } = await payload.find({
collection: 'pages',
where: { slug: { equals: 'kompetenzen' } },
limit: 1,
});
if (totalKompetenzen === 0) {
payload.logger.info('📄 Seeding Kompetenzen page...');
await payload.create({
collection: 'pages',
data: {
title: 'Unsere Kompetenzen',
slug: 'kompetenzen',
layout: 'fullBleed',
_status: 'published',
content: {
root: {
type: 'root',
direction: 'ltr',
format: '',
indent: 0,
version: 1,
children: [
{
type: 'block',
format: '',
version: 2,
fields: {
blockType: 'heroSection',
title: 'Unsere Kompetenzen',
description: 'Das Leistungsspektrum der E-TIB Gruppe umfasst alle Aspekte des modernen Infrastrukturbaus. Wir bündeln Expertenwissen und schweres Gerät, um selbst die anspruchsvollsten Projekte termingerecht zu realisieren.',
},
},
{
type: 'block',
format: '',
version: 2,
fields: {
blockType: 'homeCompetenceBentoGrid',
badge: 'Leistungsspektrum',
title: 'Umfassende Lösungen für komplexe Netzwerke',
items: [
{
title: 'Kabelleitungstiefbau MS/NS',
description: 'Kabel- und Rohrgräben, Verlegung bis 110 kV, Montage bis 30 kV, Trafostationen und Strassenbeleuchtung.',
tag: 'ENERGIE',
size: 'large',
},
{
title: 'Breitband & Glasfaser',
description: 'Leerrohrtrassen, Einziehen und Einblasen von LWL-Kabeln, Stellung von Multifunktionsgehäusen.',
tag: 'TELEKOMMUNIKATION',
size: 'medium',
},
{
title: 'Grabenlose Verlegung',
description: 'Horizontalspülbohrungen (bis 250m) und Erdraketen (bis 15m).',
tag: 'BOHRTECHNIK',
size: 'medium',
},
{
title: 'Planung & Beratung',
description: 'Struktur-, Genehmigungs- und Ausführungsplanung. Vergabe und Bauüberwachung.',
tag: 'ENGINEERING',
size: 'small',
},
{
title: 'Vermessung & Doku',
description: 'Absteckung, Einmessung und 360° Foto-/Video-Erfassung zur Projektabrechnung.',
tag: 'DOKUMENTATION',
size: 'accent',
}
]
},
},
{
type: 'block',
format: '',
version: 2,
fields: {
blockType: 'supportCTA',
title: 'Haben Sie ein konkretes Projekt?',
description: 'Unsere Experten beraten Sie gerne bei der Planung und Umsetzung Ihrer Infrastrukturvorhaben.',
buttonLabel: 'Kontakt aufnehmen',
buttonHref: '/kontakt',
},
},
],
},
},
},
});
payload.logger.info('✅ Kompetenzen page seeded.');
}
// Seed "Karriere"
const { totalDocs: totalKarriere } = await payload.find({
collection: 'pages',
where: { slug: { equals: 'karriere' } },
limit: 1,
});
if (totalKarriere === 0) {
payload.logger.info('📄 Seeding Karriere page...');
await payload.create({
collection: 'pages',
data: {
title: 'Karriere & Messen',
slug: 'karriere',
layout: 'fullBleed',
_status: 'published',
content: {
root: {
type: 'root',
direction: 'ltr',
format: '',
indent: 0,
version: 1,
children: [
{
type: 'block',
format: '',
version: 2,
fields: {
blockType: 'heroSection',
title: 'Karriere & Messen',
description: 'Werden Sie Teil eines dynamischen Teams. Entdecken Sie offene Positionen und treffen Sie uns auf kommenden Messen.',
},
},
{
type: 'block',
format: '',
version: 2,
fields: {
blockType: 'jobListing',
title: 'Aktuelle Stellenangebote',
showFairs: true,
},
},
],
},
},
},
});
payload.logger.info('✅ Karriere page seeded.');
}
// Seed "Kontakt"
const { totalDocs: totalKontakt } = await payload.find({
collection: 'pages',
where: { slug: { equals: 'kontakt' } },
limit: 1,
});
if (totalKontakt === 0) {
payload.logger.info('📄 Seeding Kontakt page...');
await payload.create({
collection: 'pages',
data: {
title: 'Kontaktieren Sie uns',
slug: 'kontakt',
layout: 'fullBleed',
_status: 'published',
content: {
root: {
type: 'root',
direction: 'ltr',
format: '',
indent: 0,
version: 1,
children: [
{
type: 'block',
format: '',
version: 2,
fields: {
blockType: 'contactSection',
showForm: true,
showMap: true,
showHours: true,
},
},
{
type: 'block',
format: '',
version: 2,
fields: {
blockType: 'teamGrid',
title: 'Ihre Ansprechpartner',
filterBranch: 'all',
},
},
],
},
},
},
});
payload.logger.info('✅ Kontakt page seeded.');
}
}

View File

@@ -1,375 +0,0 @@
/**
* Converts a Markdown+JSX string into a Lexical AST node array.
* Specifically adapted for e-tib.com custom Component Blocks.
*/
function propValue(chunk: string, prop: string): string {
// Match prop="value" or prop='value' or prop={value}
// and also multiline props like prop={\n [\n {...}\n ]\n}
// For arrays or complex objects passed as props, basic regex might fail,
// but the Markdown in e-tib usually uses simpler props or children.
const match =
chunk.match(new RegExp(`${prop}=["']([^"']+)["']`)) ||
chunk.match(new RegExp(`${prop}=\\{([^}]+)\\}`));
return match ? match[1] : '';
}
function extractItemsProp(chunk: string, startTag: string): any[] {
// Match items={ [ ... ] } robustly without stopping at inner object braces
const itemsMatch = chunk.match(/items=\{\s*(\[[\s\S]*?\])\s*\}/);
if (itemsMatch) {
try {
const arrayString = itemsMatch[1].trim();
// Since e-tib Markdown passes pure JS object arrays like `items={[{title: 'A', content: 'B'}]}`,
// parsing it via Regex to JSON is extremely brittle due to unquoted keys and trailing commas.
// Using `new Function` safely evaluates the array AST directly in this Node script environment.
const fn = new Function(`return ${arrayString};`);
return fn();
} catch (_e: any) {
console.warn(`Could not parse items array for block ${startTag}:`, _e.message);
return [];
}
}
return [];
}
function blockNode(blockType: string, fields: Record<string, any>) {
return { type: 'block', format: '', version: 2, fields: { blockType, ...fields } };
}
function ensureChildren(parsedNodes: any[]): any[] {
// Lexical root nodes require at least one child node, or validation fails
if (parsedNodes.length === 0) {
return [
{
type: 'paragraph',
format: '',
indent: 0,
version: 1,
children: [{ mode: 'normal', type: 'text', text: ' ', version: 1 }],
},
];
}
return parsedNodes;
}
function parseInlineMarkdown(text: string): any[] {
// Simple regex-based inline parser for bold and italic
// Matches **bold**, __bold__, *italic*, _italic_
const nodes: any[] = [];
let lastIndex = 0;
const createTextNode = (content: string, format = 0) => ({
detail: 0,
format,
mode: 'normal',
style: '',
text: content,
type: 'text',
version: 1,
});
const rawMatch = text.matchAll(
/(\*\*(.*?)\*\*|__(.*?)__|(?<!\*)\*(?!\*)(.*?)\*|(?<!_)_(?!_)(.*?)_)/g,
);
for (const m of rawMatch) {
const offset = m.index!;
// Leading plain text
if (offset > lastIndex) {
nodes.push(createTextNode(text.slice(lastIndex, offset)));
}
const boldContent = m[2] || m[3];
const italicContent = m[4] || m[5];
if (boldContent) {
nodes.push(createTextNode(boldContent, 1)); // 1 = Bold
} else if (italicContent) {
nodes.push(createTextNode(italicContent, 2)); // 2 = Italic
}
lastIndex = offset + m[0].length;
}
// Trailing plain text
if (lastIndex < text.length) {
nodes.push(createTextNode(text.slice(lastIndex)));
}
return nodes.length > 0 ? nodes : [createTextNode(text)];
}
export function parseMarkdownToLexical(markdown: string): any[] {
const paragraphNode = (text: string) => ({
type: 'paragraph',
format: '',
indent: 0,
version: 1,
direction: 'ltr',
children: parseInlineMarkdown(text),
});
const nodes: any[] = [];
let content = markdown;
// Strip frontmatter
const fm = content.match(/^---\s*\n[\s\S]*?\n---/);
if (fm) content = content.replace(fm[0], '').trim();
// 1. EXTRACT MULTILINE WRAPPERS BEFORE CHUNKING
const extractBlocks = [
{
tag: 'HighlightBox',
regex: /<HighlightBox([^>]*)>([\s\S]*?)<\/HighlightBox>/g,
build: (props: string, inner: string) =>
blockNode('highlightBox', {
title: propValue(`<Tag ${props}>`, 'title'),
color: propValue(`<Tag ${props}>`, 'color') || 'primary',
content: {
root: {
type: 'root',
format: '',
indent: 0,
version: 1,
direction: 'ltr',
children: ensureChildren(parseMarkdownToLexical(inner.trim())),
},
},
}),
},
{
tag: 'ChatBubble',
regex: /<ChatBubble([^>]*)>([\s\S]*?)<\/ChatBubble>/g,
build: (props: string, inner: string) =>
blockNode('chatBubble', {
author: propValue(`<Tag ${props}>`, 'author') || 'E-TIB Team',
avatar: propValue(`<Tag ${props}>`, 'avatar'),
role: propValue(`<Tag ${props}>`, 'role') || 'Assistant',
align: propValue(`<Tag ${props}>`, 'align') || 'left',
content: {
root: {
type: 'root',
format: '',
indent: 0,
version: 1,
direction: 'ltr',
children: ensureChildren(parseMarkdownToLexical(inner.trim())),
},
},
}),
},
{
tag: 'Callout',
regex: /<Callout([^>]*)>([\s\S]*?)<\/Callout>/g,
build: (props: string, inner: string) =>
blockNode('callout', {
type: propValue(`<Tag ${props}>`, 'type') || 'info',
title: propValue(`<Tag ${props}>`, 'title'),
content: {
root: {
type: 'root',
format: '',
indent: 0,
version: 1,
direction: 'ltr',
children: ensureChildren(parseMarkdownToLexical(inner.trim())),
},
},
}),
},
{
tag: 'ProductTabs',
regex: /<ProductTabs([^>]*)>([\s\S]*?)<\/ProductTabs>/g,
build: (props: string, inner: string) => {
const fullTag = `<ProductTabs ${props}>`;
const dataMatch = fullTag.match(/data=\{({[\s\S]*?})\}\s*\/>/);
let technicalItems = [];
let voltageTables = [];
if (dataMatch) {
try {
const parsedData = JSON.parse(dataMatch[1]);
technicalItems = parsedData.technicalItems || [];
voltageTables = parsedData.voltageTables || [];
voltageTables.forEach((vt: any) => {
vt.rows?.forEach((row: any) => {
if (row.cells) {
row.cells = row.cells.map((c: any) =>
typeof c !== 'object' ? { value: String(c) } : c,
);
}
});
});
} catch (e) {
console.warn('Failed to parse ProductTabs JSON data:', e);
}
}
return blockNode('productTabs', {
technicalItems,
voltageTables,
content: {
root: {
type: 'root',
format: '',
indent: 0,
version: 1,
direction: 'ltr',
children: ensureChildren(parseMarkdownToLexical(inner.trim())),
},
},
});
},
},
];
function cleanMarkdownContent(text: string): string {
return text
.replace(/<section[^>]*>/g, '')
.replace(/<\/section>/g, '')
.replace(/<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/g, '### $1\n\n')
.replace(/<p[^>]*>([\s\S]*?)<\/p>/g, '$1\n\n')
.replace(/<strong[^>]*>([\s\S]*?)<\/strong>/g, '**$1**')
.replace(/<em[^>]*>([\s\S]*?)<\/em>/g, '_$1_')
.replace(/&nbsp;/g, ' ')
.replace(/^(#{1,6}\s+.*)$/gm, '\n\n$1\n\n') // MAKE HEADINGS THEIR OWN CHUNK
.trim();
}
content = cleanMarkdownContent(content);
const placeholders = new Map<string, any>();
let placeholderIdx = 0;
for (const block of extractBlocks) {
content = content.replace(block.regex, (match, propsMatch, innerMatch) => {
const id = `__BLOCK_PLACEHOLDER_${placeholderIdx++}__`;
placeholders.set(id, block.build(propsMatch, innerMatch));
return `\n\n${id}\n\n`;
});
}
// 2. CHUNK THE REST
const rawChunks = content.split(/\n\s*\n/);
for (let chunk of rawChunks) {
chunk = chunk.trim();
if (!chunk) continue;
if (chunk.startsWith('__BLOCK_PLACEHOLDER_')) {
nodes.push(placeholders.get(chunk));
continue;
}
if (chunk.includes('<StickyNarrative')) {
nodes.push(
blockNode('stickyNarrative', {
title: propValue(chunk, 'title'),
items: extractItemsProp(chunk, 'StickyNarrative'),
}),
);
continue;
}
if (chunk.includes('<ComparisonGrid')) {
nodes.push(
blockNode('comparisonGrid', {
title: propValue(chunk, 'title'),
leftLabel: propValue(chunk, 'leftLabel'),
rightLabel: propValue(chunk, 'rightLabel'),
items: extractItemsProp(chunk, 'ComparisonGrid'),
}),
);
continue;
}
if (chunk.includes('<VisualLinkPreview')) {
nodes.push(
blockNode('visualLinkPreview', {
url: propValue(chunk, 'url'),
title: propValue(chunk, 'title'),
summary: propValue(chunk, 'summary'),
image: propValue(chunk, 'image'),
}),
);
continue;
}
if (chunk.includes('<TechnicalGrid')) {
nodes.push(
blockNode('technicalGrid', {
title: propValue(chunk, 'title'),
items: extractItemsProp(chunk, 'TechnicalGrid'),
}),
);
continue;
}
if (chunk.includes('<AnimatedImage')) {
const widthMatch = chunk.match(/width=\{?(\d+)\}?/);
const heightMatch = chunk.match(/height=\{?(\d+)\}?/);
nodes.push(
blockNode('animatedImage', {
src: propValue(chunk, 'src'),
alt: propValue(chunk, 'alt'),
width: widthMatch ? parseInt(widthMatch[1], 10) : undefined,
height: heightMatch ? parseInt(heightMatch[1], 10) : undefined,
}),
);
continue;
}
if (chunk.includes('<PowerCTA')) {
nodes.push(
blockNode('powerCTA', {
locale: propValue(chunk, 'locale') || 'de',
}),
);
continue;
}
// Skip horizontal rules (---)
if (/^-{3,}$/.test(chunk)) {
continue;
}
const headingMatch = chunk.match(/^(#{1,6})\s+(.*)/);
if (headingMatch) {
const level = Math.min(headingMatch[1].length + 1, 6);
nodes.push({
type: 'heading',
tag: `h${level}`,
format: '',
indent: 0,
version: 1,
direction: 'ltr',
children: parseInlineMarkdown(headingMatch[2]),
});
// If there's more text after the heading line in this chunk, emit as paragraph(s)
const rest = chunk.slice(chunk.indexOf('\n') + 1).trim();
if (rest && chunk.includes('\n')) {
// Split remaining lines by single newlines for multi-paragraph support
const subParagraphs = rest.split(/\n\s*\n/);
for (const sub of subParagraphs) {
const trimmed = sub.trim();
if (trimmed && !/^-{3,}$/.test(trimmed)) {
nodes.push(paragraphNode(trimmed));
}
}
}
continue;
}
const imageMatch = chunk.match(/^!\[([^\]]*)\]\(([^)]+)\)$/);
if (imageMatch) {
nodes.push(paragraphNode(chunk));
continue;
}
nodes.push(paragraphNode(chunk));
}
return nodes;
}