initial migration

This commit is contained in:
2025-12-28 23:28:31 +01:00
parent 1f99781458
commit 292975299d
284 changed files with 119466 additions and 0 deletions

282
lib/data.ts Normal file
View File

@@ -0,0 +1,282 @@
/**
* Data utilities for Next.js WordPress migration
*/
import wordpressData from '../data/processed/wordpress-data.json';
export interface SiteInfo {
title: string;
description: string;
baseUrl: string;
defaultLocale: string;
locales: string[];
}
export interface TranslationReference {
locale: string;
id: number;
}
export interface Page {
id: number;
translationKey: string;
locale: string;
slug: string;
path: string;
title: string;
titleHtml: string;
contentHtml: string;
excerptHtml: string;
featuredImage: number | null;
updatedAt: string;
translation: TranslationReference | null;
}
export interface Post {
id: number;
translationKey: string;
locale: string;
slug: string;
path: string;
title: string;
titleHtml: string;
contentHtml: string;
excerptHtml: string;
featuredImage: number | null;
datePublished: string;
updatedAt: string;
translation: TranslationReference | null;
}
export interface Product {
id: number;
translationKey: string;
locale: string;
slug: string;
path: string;
name: string;
shortDescriptionHtml: string;
descriptionHtml: string;
images: string[];
featuredImage: string | null;
sku: string;
regularPrice: string;
salePrice: string;
currency: string;
stockStatus: string;
categories: Array<{ id: number; name: string; slug: string }>;
attributes: any[];
variations: any[];
updatedAt: string;
translation: TranslationReference | null;
}
export interface ProductCategory {
id: number;
translationKey: string;
locale: string;
slug: string;
name: string;
path: string;
description: string;
count: number;
translation: TranslationReference | null;
}
export interface Media {
id: number;
filename: string;
url: string;
localPath: string;
alt: string;
width: number | null;
height: number | null;
mimeType: string | null;
}
export interface Redirect {
source: string;
destination: string;
permanent: boolean;
locale: string;
}
export interface WordPressData {
site: SiteInfo;
content: {
pages: Page[];
posts: Post[];
products: Product[];
categories: ProductCategory[];
};
assets: {
media: Media[];
map: Record<string, string>;
};
redirects: Redirect[];
exportDate: string;
}
// Load data
// Use type assertion to handle the JSON import properly
const data = wordpressData as unknown as WordPressData;
// Data access functions
export const getSiteInfo = (): SiteInfo => data.site;
export const getAllPages = (): Page[] => data.content.pages;
export const getAllPosts = (): Post[] => data.content.posts;
export const getAllProducts = (): Product[] => data.content.products;
export const getAllCategories = (): ProductCategory[] => data.content.categories;
export const getMediaById = (id: number): Media | undefined => {
return data.assets.media.find(m => m.id === id);
};
export const getMediaByUrl = (url: string): Media | undefined => {
const localPath = data.assets.map[url];
if (!localPath) return undefined;
return data.assets.media.find(m => m.localPath === localPath);
};
export const getAssetMap = (): Record<string, string> => {
return data.assets?.map || {};
};
export const getRedirects = (): Redirect[] => data.redirects;
// Locale-specific queries
export const getPagesByLocale = (locale: string): Page[] => {
return data.content.pages.filter(p => p.locale === locale);
};
export const getPostsByLocale = (locale: string): Post[] => {
return data.content.posts.filter(p => p.locale === locale);
};
export const getProductsByLocale = (locale: string): Product[] => {
return data.content.products.filter(p => p.locale === locale);
};
export const getCategoriesByLocale = (locale: string): ProductCategory[] => {
return data.content.categories.filter(c => c.locale === locale);
};
// Single item queries
export const getPageBySlug = (slug: string, locale: string): Page | undefined => {
return data.content.pages.find(p => p.slug === slug && p.locale === locale);
};
export const getPostBySlug = (slug: string, locale: string): Post | undefined => {
return data.content.posts.find(p => p.slug === slug && p.locale === locale);
};
export const getProductBySlug = (slug: string, locale: string): Product | undefined => {
return data.content.products.find(p => p.slug === slug && p.locale === locale);
};
export const getCategoryBySlug = (slug: string, locale: string): ProductCategory | undefined => {
return data.content.categories.find(c => c.slug === slug && c.locale === locale);
};
// Translation helpers
export const getTranslation = <T extends { translationKey: string; locale: string }>(
item: T,
targetLocale: string
): T | undefined => {
const collection = [
...getAllPages(),
...getAllPosts(),
...getAllProducts(),
...getAllCategories()
];
const result = collection.find(
(i: any) => i.translationKey === item.translationKey && i.locale === targetLocale
);
return result as unknown as T | undefined;
};
// Asset URL replacement
export const replaceAssetUrls = (html: string): string => {
let result = html;
Object.entries(data.assets.map).forEach(([wpUrl, localPath]) => {
result = result.replace(new RegExp(wpUrl, 'g'), localPath);
});
return result;
};
// Additional functions for product categories
export const getProductCategory = (slug: string, locale: string): ProductCategory | undefined => {
return data.content.categories.find(c => c.slug === slug && c.locale === locale);
};
export const getProductsByCategory = (categoryId: number, locale: string): Product[] => {
return data.content.products.filter(p =>
p.locale === locale && p.categories.some(c => c.id === categoryId)
);
};
// Get products by category slug
export const getProductsByCategorySlug = (categorySlug: string, locale: string): Product[] => {
const category = getCategoryBySlug(categorySlug, locale);
if (!category) return [];
return getProductsByCategory(category.id, locale);
};
// Get related products (same category, excluding current product)
export const getRelatedProducts = (product: Product, locale: string, limit: number = 4): Product[] => {
if (product.categories.length === 0) return [];
// Get first category
const firstCategory = product.categories[0];
const categoryProducts = getProductsByCategory(firstCategory.id, locale);
// Filter out current product and limit results
return categoryProducts
.filter(p => p.id !== product.id)
.slice(0, limit);
};
// Get categories by slugs
export const getCategoriesBySlugs = (slugs: string[], locale: string): ProductCategory[] => {
return data.content.categories.filter(c =>
slugs.includes(c.slug) && c.locale === locale
);
};
// Locale-specific queries for static generation
export const getAllCategorySlugsForLocale = (locale: string): string[] => {
return [...new Set(data.content.categories.filter(c => c.locale === locale).map(c => c.slug))];
};
export const getAllPageSlugsForLocale = (locale: string): string[] => {
return [...new Set(data.content.pages.filter(p => p.locale === locale).map(p => p.slug))];
};
export const getAllPostSlugsForLocale = (locale: string): string[] => {
return [...new Set(data.content.posts.filter(p => p.locale === locale).map(p => p.slug))];
};
export const getAllProductSlugsForLocale = (locale: string): string[] => {
return [...new Set(data.content.products.filter(p => p.locale === locale).map(p => p.slug))];
};
// Get items for locale
export const getCategoriesForLocale = (locale: string): ProductCategory[] => {
return data.content.categories.filter(c => c.locale === locale);
};
export const getPagesForLocale = (locale: string): Page[] => {
return data.content.pages.filter(p => p.locale === locale);
};
export const getPostsForLocale = (locale: string): Post[] => {
return data.content.posts.filter(p => p.locale === locale);
};
export const getProductsForLocale = (locale: string): Product[] => {
return data.content.products.filter(p => p.locale === locale);
};

115
lib/html-compat.ts Normal file
View File

@@ -0,0 +1,115 @@
/**
* HTML Compatibility Layer
* Handles HTML entities and formatting from WordPress exports
*/
export function processHTML(html: string | null | undefined): string {
if (!html) return '';
// Replace common HTML entities
let processed = html;
const entities: Record<string, string> = {
'\u00A0': ' ', // Non-breaking space
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": "'",
'¢': '¢',
'£': '£',
'¥': '¥',
'€': '€',
'©': '©',
'®': '®',
'™': '™',
'°': '°',
'±': '±',
'×': '×',
'÷': '÷',
'µ': 'µ',
'¶': '¶',
'§': '§',
'á': 'á',
'é': 'é',
'í': 'í',
'ó': 'ó',
'ú': 'ú',
'Á': 'Á',
'É': 'É',
'Í': 'Í',
'Ó': 'Ó',
'Ú': 'Ú',
'ñ': 'ñ',
'Ñ': 'Ñ',
'ü': 'ü',
'Ü': 'Ü',
'ö': 'ö',
'Ö': 'Ö',
'ä': 'ä',
'Ä': 'Ä',
'ß': 'ß',
'—': '—',
'': '',
'…': '…',
'«': '«',
'»': '»',
'': "'",
'': "'",
'“': '"',
'”': '"',
'•': '•',
'·': '·'
};
// Replace entities
for (const [entity, char] of Object.entries(entities)) {
processed = processed.replace(new RegExp(entity.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), char);
}
// Remove script tags
processed = processed.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
// Remove style tags
processed = processed.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, '');
// Remove inline event handlers
processed = processed.replace(/\s+on\w+\s*=\s*["'][^"']*["']/gi, '');
// Remove dangerous attributes
processed = processed.replace(/\s+(href|src)\s*=\s*["']\s*javascript:/gi, '');
// Remove any remaining WordPress shortcode-like content (e.g., [vc_row...])
processed = processed.replace(/\[[^\]]*\]/g, '');
// Keep HTML structure from processed data - allow divs with our classes
// Allow: <p>, <br>, <h1-6>, <strong>, <b>, <em>, <i>, <ul>, <ol>, <li>, <a>, <div>, <span>, <img>
// Also allow our vc-row/vc-column classes
processed = processed.replace(/<\/?(?!\/?(p|br|h[1-6]|strong|b|em|i|ul|ol|li|a|div|span|img|small)(\s|>))[^>]*>/gi, '');
// Clean up empty paragraphs and extra spaces
processed = processed.replace(/<p>\s*<\/p>/g, '');
processed = processed.replace(/\s+/g, ' ').trim();
return processed;
}
export function stripHTML(html: string | null | undefined): string {
if (!html) return '';
return html.replace(/<[^>]*>/g, '');
}
export function extractTextFromHTML(html: string | null | undefined): string {
if (!html) return '';
return processHTML(html);
}
/**
* Get dictionary for translations
* This is a compatibility function for the i18n system
*/
export function getDictionary(locale: string): Record<string, string> {
// For now, return empty dictionary
// In a real implementation, this would load translation files
return {};
}

8
lib/i18n-config.ts Normal file
View File

@@ -0,0 +1,8 @@
import type { Locale } from './i18n';
export const i18n = {
defaultLocale: 'en' as Locale,
locales: ['en', 'de'] as Locale[],
} as const;
export type LocaleParam = 'en' | 'de';

299
lib/i18n.ts Normal file
View File

@@ -0,0 +1,299 @@
export type Locale = 'en' | 'de';
export const defaultLocale: Locale = 'en';
export const locales: Locale[] = ['en', 'de'];
// Simple translation dictionary
const translations = {
en: {
site: {
title: 'Kabel-Konfigurator',
description: 'Professional cable solutions - configure and order custom cables',
},
nav: {
home: 'Home',
blog: 'Blog',
products: 'Products',
contact: 'Contact',
privacy: 'Privacy Policy',
legal: 'Legal Notice',
terms: 'Terms & Conditions',
},
home: {
hero: 'Professional Cable Solutions',
heroSubtitle: 'Configure your custom cables online',
cta: 'Configure Now',
featuredPosts: 'Latest News',
featuredProducts: 'Featured Products',
},
blog: {
title: 'Blog',
readMore: 'Read more',
noPosts: 'No posts available.',
backToBlog: '← Back to Blog',
},
products: {
title: 'Products',
categories: 'Categories',
noProducts: 'No products available.',
noCategories: 'No categories available.',
inStock: 'In Stock',
outOfStock: 'Out of Stock',
price: 'Price',
sku: 'SKU',
viewAll: 'View All Products',
},
product: {
backToProducts: '← Back to Products',
description: 'Description',
specifications: 'Specifications',
price: 'Price',
sku: 'SKU',
stock: 'Stock Status',
inStock: 'In Stock',
outOfStock: 'Out of Stock',
},
productCategory: {
backToCategories: '← Back to Categories',
productsInCategory: 'Products in this category',
},
contact: {
title: 'Contact Us',
name: 'Your Name',
email: 'Your Email',
message: 'Your Message',
submit: 'Send Message',
success: 'Message sent successfully!',
error: 'Failed to send message. Please try again.',
processing: 'Sending...',
phone: 'Phone (optional)',
company: 'Company (optional)',
},
consent: {
title: 'Cookie & Analytics Consent',
description: 'We use analytics cookies to improve our website. Please accept to continue.',
accept: 'Accept',
decline: 'Decline',
analytics: 'Analytics',
analyticsDesc: 'Help us understand how visitors use our site',
},
footer: {
rights: 'All rights reserved.',
madeWith: 'Made with Next.js',
},
common: {
readMore: 'Read more',
back: 'Back',
loading: 'Loading...',
noContent: 'No content available.',
date: 'Date',
updated: 'Updated',
},
form: {
success: 'Message sent successfully!',
error: {
submit: 'Failed to send message. Please try again.',
network: 'Network error. Please try again.',
},
sending: 'Sending...',
name: 'Your Name',
email: 'Your Email',
message: 'Your Message',
submit: 'Send Message',
},
},
de: {
site: {
title: 'Kabel-Konfigurator',
description: 'Professionelle Kabel-Lösungen - konfigurieren und bestellen Sie maßgeschneiderte Kabel',
},
nav: {
home: 'Startseite',
blog: 'Blog',
products: 'Produkte',
contact: 'Kontakt',
privacy: 'Datenschutz',
legal: 'Impressum',
terms: 'AGB',
},
home: {
hero: 'Professionelle Kabel-Lösungen',
heroSubtitle: 'Konfigurieren Sie Ihre maßgeschneiderten Kabel online',
cta: 'Jetzt konfigurieren',
featuredPosts: 'Aktuelle Neuigkeiten',
featuredProducts: 'Empfohlene Produkte',
},
blog: {
title: 'Blog',
readMore: 'Weiterlesen',
noPosts: 'Keine Beiträge verfügbar.',
backToBlog: '← Zurück zum Blog',
},
products: {
title: 'Produkte',
categories: 'Kategorien',
noProducts: 'Keine Produkte verfügbar.',
noCategories: 'Keine Kategorien verfügbar.',
inStock: 'Auf Lager',
outOfStock: 'Nicht auf Lager',
price: 'Preis',
sku: 'Artikelnummer',
viewAll: 'Alle Produkte anzeigen',
},
product: {
backToProducts: '← Zurück zu Produkten',
description: 'Beschreibung',
specifications: 'Spezifikationen',
price: 'Preis',
sku: 'Artikelnummer',
stock: 'Lagerbestand',
inStock: 'Auf Lager',
outOfStock: 'Nicht auf Lager',
},
productCategory: {
backToCategories: '← Zurück zu Kategorien',
productsInCategory: 'Produkte in dieser Kategorie',
},
contact: {
title: 'Kontakt',
name: 'Ihr Name',
email: 'Ihre E-Mail',
message: 'Ihre Nachricht',
submit: 'Nachricht senden',
success: 'Nachricht erfolgreich gesendet!',
error: 'Nachricht konnte nicht gesendet werden. Bitte versuchen Sie es erneut.',
processing: 'Wird gesendet...',
phone: 'Telefon (optional)',
company: 'Firma (optional)',
},
consent: {
title: 'Cookie- & Analyse-Einwilligung',
description: 'Wir verwenden Analyse-Cookies, um unsere Website zu verbessern. Bitte akzeptieren Sie zur Fortsetzung.',
accept: 'Akzeptieren',
decline: 'Ablehnen',
analytics: 'Analyse',
analyticsDesc: 'Helfen Sie uns zu verstehen, wie Besucher unsere Seite nutzen',
},
footer: {
rights: 'Alle Rechte vorbehalten.',
madeWith: 'Erstellt mit Next.js',
},
common: {
readMore: 'Weiterlesen',
back: 'Zurück',
loading: 'Wird geladen...',
noContent: 'Kein Inhalt verfügbar.',
date: 'Datum',
updated: 'Aktualisiert',
},
form: {
success: 'Nachricht erfolgreich gesendet!',
error: {
submit: 'Nachricht konnte nicht gesendet werden. Bitte versuchen Sie es erneut.',
network: 'Netzwerkfehler. Bitte versuchen Sie es erneut.',
},
sending: 'Wird gesendet...',
name: 'Ihr Name',
email: 'Ihre E-Mail',
message: 'Ihre Nachricht',
submit: 'Nachricht senden',
},
},
};
export function t(key: string, locale: Locale = 'en'): string {
const keys = key.split('.');
let value: any = translations[locale];
for (const k of keys) {
if (value && typeof value === 'object' && k in value) {
value = value[k];
} else {
// Fallback to English
value = translations.en;
for (const k2 of keys) {
if (value && typeof value === 'object' && k2 in value) {
value = value[k2];
} else {
return key; // Return the key itself if translation not found
}
}
return value;
}
}
// Ensure we always return a string
return typeof value === 'string' ? value : key;
}
export function getLocaleFromPath(path: string): Locale {
if (path.startsWith('/de/')) {
return 'de';
}
return 'en';
}
export function getLocalizedPath(path: string, locale: Locale): string {
if (locale === 'en') {
return path.replace('/de/', '/');
}
if (locale === 'de') {
if (path === '/') return '/de';
return path.startsWith('/de/') ? path : `/de${path}`;
}
return path;
}
export function getPathWithoutLocale(path: string): string {
if (path.startsWith('/de/')) {
return path.substring(3) || '/';
}
return path;
}
export const languageNames: Record<Locale, string> = {
en: 'English',
de: 'Deutsch',
};
export function getSiteInfo(locale?: Locale) {
const loc = locale || defaultLocale;
return {
title: t('site.title', loc),
description: t('site.description', loc),
locale: loc,
baseUrl: process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com',
locales: ['en', 'de'],
};
}
// Hook for client components (simplified version)
export function useTranslation(namespace?: string) {
// This would be used in client components
// For now, return a simple t function
return {
t: (key: string) => t(namespace ? `${namespace}.${key}` : key, defaultLocale)
};
}
// Get alternate URLs for SEO
export function getAlternateUrls(path: string) {
return [
{ locale: 'en', url: path.replace('/de/', '/') },
{ locale: 'de', url: path.startsWith('/de') ? path : `/de${path}` },
];
}
// Hook for client components - returns current locale
export function useLocale(): Locale {
// This is a simplified version for build purposes
// In a real app, this would use next/navigation to get the current path
return defaultLocale;
}
// Get dictionary for client components
export function getDictionary(locale: Locale) {
return {
t: (key: string) => t(key, locale)
};
}

130
lib/seo.ts Normal file
View File

@@ -0,0 +1,130 @@
import { Metadata } from 'next';
import { getSiteInfo } from './i18n';
import type { Locale } from './i18n';
export interface SEOParams {
title?: string;
description?: string;
locale?: Locale;
canonical?: string;
ogType?: 'website' | 'article' | 'product';
ogImages?: string[];
publishedTime?: string;
updatedTime?: string;
author?: string;
}
export function generateSEOMetadata({
title,
description,
locale = 'en',
canonical,
ogType = 'website',
ogImages = [],
publishedTime,
updatedTime,
author,
}: SEOParams): Metadata {
const site = getSiteInfo(locale);
const pageTitle = title ? `${title} | ${site.title}` : site.title;
const pageDescription = description || site.description;
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || site.baseUrl;
const path = canonical || '/';
const fullUrl = `${baseUrl}${path}`;
// Generate alternate URLs for both locales
const alternates = {
canonical: fullUrl,
languages: {
'en': `${baseUrl}${path.replace('/de/', '/')}`,
'de': `${baseUrl}${path.startsWith('/de') ? path : `/de${path}`}`,
},
};
const openGraph = {
title: pageTitle,
description: pageDescription,
url: fullUrl,
siteName: site.title,
locale: locale === 'de' ? 'de_DE' : 'en_US',
type: ogType,
...(ogImages.length > 0 && { images: ogImages }),
...(publishedTime && { publishedTime }),
...(updatedTime && { updatedTime }),
...(author && { authors: [author] }),
};
const twitter = {
card: 'summary_large_image',
title: pageTitle,
description: pageDescription,
...(ogImages.length > 0 && { images: ogImages }),
};
return {
title: pageTitle,
description: pageDescription,
alternates,
openGraph,
twitter,
authors: author ? [{ name: author }] : undefined,
metadataBase: new URL(baseUrl),
};
}
// Helper for blog posts
export function getPostSEO(post: any, locale: Locale): Metadata {
return generateSEOMetadata({
title: post.title,
description: post.excerptHtml?.replace(/<[^>]*>/g, '') || '',
canonical: post.path,
locale: locale,
ogType: 'article',
ogImages: post.featuredImage ? [post.featuredImage] : [],
publishedTime: post.datePublished,
updatedTime: post.updatedAt,
author: 'KLZ Cables Team',
});
}
// Helper for products
export function getProductSEO(product: any, locale: Locale): Metadata {
return generateSEOMetadata({
title: product.name,
description: product.shortDescriptionHtml?.replace(/<[^>]*>/g, '') || '',
canonical: product.path,
locale: locale,
ogType: 'product',
ogImages: product.images || [],
});
}
// Helper for categories
export function getCategorySEO(category: any, locale: Locale): Metadata {
return generateSEOMetadata({
title: category.name,
description: category.description || `Products in ${category.name}`,
canonical: category.path,
locale: locale,
ogType: 'website',
});
}
export function generateSitemapItem({
path,
lastmod,
priority = 0.7,
}: {
path: string;
lastmod?: string;
priority?: number;
}) {
return {
url: path,
lastmod: lastmod || new Date().toISOString().split('T')[0],
changefreq: 'weekly',
priority,
};
}