Compare commits
9 Commits
fb3450aa23
...
mollie
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccceb8fe78 | ||
|
|
ea5ac762b2 | ||
|
|
40ec0287fd | ||
|
|
47364d3ad8 | ||
|
|
a6d7ed3e27 | ||
|
|
f8f03b45b8 | ||
|
|
eb0d5621e6 | ||
|
|
f81b9d12df | ||
|
|
8ea3b1b6a3 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -27,6 +27,7 @@
|
|||||||
/public/index.prerender.html
|
/public/index.prerender.html
|
||||||
/public/Konfigurator
|
/public/Konfigurator
|
||||||
/public/profile
|
/public/profile
|
||||||
|
/public/404
|
||||||
|
|
||||||
/public/products.xml
|
/public/products.xml
|
||||||
/public/llms*
|
/public/llms*
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ const {
|
|||||||
generateProductsXml,
|
generateProductsXml,
|
||||||
generateLlmsTxt,
|
generateLlmsTxt,
|
||||||
generateCategoryLlmsTxt,
|
generateCategoryLlmsTxt,
|
||||||
|
generateAllCategoryLlmsPages,
|
||||||
} = require("./prerender/seo.cjs");
|
} = require("./prerender/seo.cjs");
|
||||||
const {
|
const {
|
||||||
fetchCategoryProducts,
|
fetchCategoryProducts,
|
||||||
@@ -71,6 +72,7 @@ const Batteriegesetzhinweise =
|
|||||||
require("./src/pages/Batteriegesetzhinweise.js").default;
|
require("./src/pages/Batteriegesetzhinweise.js").default;
|
||||||
const Widerrufsrecht = require("./src/pages/Widerrufsrecht.js").default;
|
const Widerrufsrecht = require("./src/pages/Widerrufsrecht.js").default;
|
||||||
const Sitemap = require("./src/pages/Sitemap.js").default;
|
const Sitemap = require("./src/pages/Sitemap.js").default;
|
||||||
|
const PrerenderSitemap = require("./src/PrerenderSitemap.js").default;
|
||||||
const AGB = require("./src/pages/AGB.js").default;
|
const AGB = require("./src/pages/AGB.js").default;
|
||||||
const NotFound404 = require("./src/pages/NotFound404.js").default;
|
const NotFound404 = require("./src/pages/NotFound404.js").default;
|
||||||
|
|
||||||
@@ -361,10 +363,11 @@ const renderApp = async (categoryData, socket) => {
|
|||||||
description: "Widerrufsrecht page",
|
description: "Widerrufsrecht page",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
component: Sitemap,
|
component: PrerenderSitemap,
|
||||||
path: "/sitemap",
|
path: "/sitemap",
|
||||||
filename: "sitemap",
|
filename: "sitemap",
|
||||||
description: "Sitemap page",
|
description: "Sitemap page",
|
||||||
|
needsCategoryData: true,
|
||||||
},
|
},
|
||||||
{ component: AGB, path: "/agb", filename: "agb", description: "AGB page" },
|
{ component: AGB, path: "/agb", filename: "agb", description: "AGB page" },
|
||||||
{ component: NotFound404, path: "/404", filename: "404", description: "404 Not Found page" },
|
{ component: NotFound404, path: "/404", filename: "404", description: "404 Not Found page" },
|
||||||
@@ -384,7 +387,9 @@ const renderApp = async (categoryData, socket) => {
|
|||||||
|
|
||||||
let staticPagesRendered = 0;
|
let staticPagesRendered = 0;
|
||||||
for (const page of staticPages) {
|
for (const page of staticPages) {
|
||||||
const pageComponent = React.createElement(page.component, null);
|
// Pass category data as props if needed
|
||||||
|
const pageProps = page.needsCategoryData ? { categoryData } : null;
|
||||||
|
const pageComponent = React.createElement(page.component, pageProps);
|
||||||
let metaTags = "";
|
let metaTags = "";
|
||||||
|
|
||||||
// Special handling for Sitemap page to include category data
|
// Special handling for Sitemap page to include category data
|
||||||
@@ -657,27 +662,39 @@ const renderApp = async (categoryData, socket) => {
|
|||||||
productsByCategory[categoryId].push(product);
|
productsByCategory[categoryId].push(product);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Generate category-specific LLM files
|
// Generate category-specific LLM files with pagination
|
||||||
let categoryFilesGenerated = 0;
|
let categoryFilesGenerated = 0;
|
||||||
let totalCategoryProducts = 0;
|
let totalCategoryProducts = 0;
|
||||||
|
let totalPaginatedFiles = 0;
|
||||||
|
|
||||||
for (const category of allCategories) {
|
for (const category of allCategories) {
|
||||||
if (category.seoName) {
|
if (category.seoName) {
|
||||||
const categoryProducts = productsByCategory[category.id] || [];
|
const categoryProducts = productsByCategory[category.id] || [];
|
||||||
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
||||||
|
|
||||||
const categoryLlmsTxt = generateCategoryLlmsTxt(category, categoryProducts, shopConfig.baseUrl, shopConfig);
|
// Generate all paginated files for this category
|
||||||
const categoryLlmsTxtPath = path.resolve(__dirname, config.outputDir, `llms-${categorySlug}.txt`);
|
const categoryPages = generateAllCategoryLlmsPages(category, categoryProducts, shopConfig.baseUrl, shopConfig);
|
||||||
|
|
||||||
fs.writeFileSync(categoryLlmsTxtPath, categoryLlmsTxt, { encoding: 'utf8' });
|
// Write each paginated file
|
||||||
|
for (const page of categoryPages) {
|
||||||
|
const pagePath = path.resolve(__dirname, config.outputDir, page.fileName);
|
||||||
|
fs.writeFileSync(pagePath, page.content, { encoding: 'utf8' });
|
||||||
|
totalPaginatedFiles++;
|
||||||
|
}
|
||||||
|
|
||||||
console.log(` ✅ llms-${categorySlug}.txt - ${categoryProducts.length} products (${Math.round(categoryLlmsTxt.length / 1024)}KB)`);
|
const pageCount = categoryPages.length;
|
||||||
|
const totalSize = categoryPages.reduce((sum, page) => sum + page.content.length, 0);
|
||||||
|
|
||||||
|
console.log(` ✅ llms-${categorySlug}-page-*.txt - ${categoryProducts.length} products across ${pageCount} pages (${Math.round(totalSize / 1024)}KB total)`);
|
||||||
|
|
||||||
categoryFilesGenerated++;
|
categoryFilesGenerated++;
|
||||||
totalCategoryProducts += categoryProducts.length;
|
totalCategoryProducts += categoryProducts.length;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(` 📄 Total paginated files generated: ${totalPaginatedFiles}`);
|
||||||
|
console.log(` 📦 Total products across all categories: ${totalCategoryProducts}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const verification = fs.readFileSync(llmsTxtPath, 'utf8');
|
const verification = fs.readFileSync(llmsTxtPath, 'utf8');
|
||||||
console.log(` - File verification: ✅ All files valid UTF-8`);
|
console.log(` - File verification: ✅ All files valid UTF-8`);
|
||||||
|
|||||||
1075
prerender/seo.cjs
1075
prerender/seo.cjs
File diff suppressed because it is too large
Load Diff
81
prerender/seo/category.cjs
Normal file
81
prerender/seo/category.cjs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
|
||||||
|
const categoryUrl = `${baseUrl}/Kategorie/${category.seoName}`;
|
||||||
|
|
||||||
|
const jsonLd = {
|
||||||
|
"@context": "https://schema.org/",
|
||||||
|
"@type": "CollectionPage",
|
||||||
|
name: category.name,
|
||||||
|
url: categoryUrl,
|
||||||
|
description: `${category.name} - Entdecken Sie unsere Auswahl an hochwertigen Produkten`,
|
||||||
|
breadcrumb: {
|
||||||
|
"@type": "BreadcrumbList",
|
||||||
|
itemListElement: [
|
||||||
|
{
|
||||||
|
"@type": "ListItem",
|
||||||
|
position: 1,
|
||||||
|
name: "Home",
|
||||||
|
item: baseUrl,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "ListItem",
|
||||||
|
position: 2,
|
||||||
|
name: category.name,
|
||||||
|
item: categoryUrl,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add product list if products are available
|
||||||
|
if (products && products.length > 0) {
|
||||||
|
jsonLd.mainEntity = {
|
||||||
|
"@type": "ItemList",
|
||||||
|
numberOfItems: products.length,
|
||||||
|
itemListElement: products.slice(0, 20).map((product, index) => ({
|
||||||
|
"@type": "ListItem",
|
||||||
|
position: index + 1,
|
||||||
|
item: {
|
||||||
|
"@type": "Product",
|
||||||
|
name: product.name,
|
||||||
|
url: `${baseUrl}/Artikel/${product.seoName}`,
|
||||||
|
image:
|
||||||
|
product.pictureList && product.pictureList.trim()
|
||||||
|
? `${baseUrl}/assets/images/prod${product.pictureList
|
||||||
|
.split(",")[0]
|
||||||
|
.trim()}.jpg`
|
||||||
|
: `${baseUrl}/assets/images/nopicture.jpg`,
|
||||||
|
description: product.description
|
||||||
|
? product.description.replace(/<[^>]*>/g, "").substring(0, 200)
|
||||||
|
: `${product.name} - Hochwertiges Growshop Produkt`,
|
||||||
|
sku: product.articleNumber || product.seoName,
|
||||||
|
brand: {
|
||||||
|
"@type": "Brand",
|
||||||
|
name: product.manufacturer || config.brandName,
|
||||||
|
},
|
||||||
|
offers: {
|
||||||
|
"@type": "Offer",
|
||||||
|
url: `${baseUrl}/Artikel/${product.seoName}`,
|
||||||
|
price: product.price && !isNaN(product.price) ? product.price.toString() : "0.00",
|
||||||
|
priceCurrency: config.currency,
|
||||||
|
availability: product.available
|
||||||
|
? "https://schema.org/InStock"
|
||||||
|
: "https://schema.org/OutOfStock",
|
||||||
|
seller: {
|
||||||
|
"@type": "Organization",
|
||||||
|
name: config.brandName,
|
||||||
|
},
|
||||||
|
itemCondition: "https://schema.org/NewCondition",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<script type="application/ld+json">${JSON.stringify(
|
||||||
|
jsonLd
|
||||||
|
)}</script>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
generateCategoryJsonLd,
|
||||||
|
};
|
||||||
344
prerender/seo/feeds.cjs
Normal file
344
prerender/seo/feeds.cjs
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
const generateRobotsTxt = (baseUrl) => {
|
||||||
|
// Ensure URLs are properly formatted
|
||||||
|
const canonicalUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
||||||
|
|
||||||
|
const robotsTxt = `User-agent: *
|
||||||
|
Allow: /
|
||||||
|
Sitemap: ${canonicalUrl}/sitemap.xml
|
||||||
|
Crawl-delay: 0
|
||||||
|
`;
|
||||||
|
|
||||||
|
return robotsTxt;
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||||
|
const currentDate = new Date().toISOString();
|
||||||
|
|
||||||
|
// Validate input
|
||||||
|
if (!Array.isArray(allProductsData) || allProductsData.length === 0) {
|
||||||
|
throw new Error("No valid product data provided");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category mapping function
|
||||||
|
const getGoogleProductCategory = (categoryId) => {
|
||||||
|
const categoryMappings = {
|
||||||
|
// Seeds & Plants
|
||||||
|
689: "Home & Garden > Plants > Seeds",
|
||||||
|
706: "Home & Garden > Plants", // Stecklinge (cuttings)
|
||||||
|
376: "Home & Garden > Plants > Plant & Herb Growing Kits", // Grow-Sets
|
||||||
|
|
||||||
|
// Headshop & Accessories
|
||||||
|
709: "Arts & Entertainment > Hobbies & Creative Arts", // Headshop
|
||||||
|
711: "Arts & Entertainment > Hobbies & Creative Arts", // Bongs
|
||||||
|
714: "Arts & Entertainment > Hobbies & Creative Arts", // Zubehör
|
||||||
|
748: "Arts & Entertainment > Hobbies & Creative Arts", // Köpfe
|
||||||
|
749: "Arts & Entertainment > Hobbies & Creative Arts", // Chillums / Diffusoren / Kupplungen
|
||||||
|
896: "Electronics > Electronics Accessories", // Vaporizer
|
||||||
|
710: "Home & Garden > Kitchen & Dining > Kitchen Tools & Utensils", // Grinder
|
||||||
|
|
||||||
|
// Measuring & Packaging
|
||||||
|
186: "Business & Industrial", // Wiegen & Verpacken
|
||||||
|
187: "Business & Industrial > Science & Laboratory > Lab Equipment", // Waagen
|
||||||
|
346: "Home & Garden > Kitchen & Dining > Food Storage", // Vakuumbeutel
|
||||||
|
355: "Home & Garden > Kitchen & Dining > Food Storage", // Boveda & Integra Boost
|
||||||
|
407: "Home & Garden > Kitchen & Dining > Food Storage", // Grove Bags
|
||||||
|
449: "Home & Garden > Kitchen & Dining > Food Storage", // Cliptütchen
|
||||||
|
539: "Home & Garden > Kitchen & Dining > Food Storage", // Gläser & Dosen
|
||||||
|
|
||||||
|
// Lighting & Equipment
|
||||||
|
694: "Home & Garden > Lighting", // Lampen
|
||||||
|
261: "Home & Garden > Lighting", // Lampenzubehör
|
||||||
|
|
||||||
|
// Plants & Growing
|
||||||
|
691: "Home & Garden > Lawn & Garden > Fertilizers", // Dünger
|
||||||
|
692: "Home & Garden > Lawn & Garden > Fertilizers", // Dünger - Zubehör
|
||||||
|
693: "Sporting Goods > Outdoor Recreation > Camping & Hiking > Tents", // Zelte
|
||||||
|
|
||||||
|
// Pots & Containers
|
||||||
|
219: "Home & Garden > Decor > Planters & Pots", // Töpfe
|
||||||
|
220: "Home & Garden > Decor > Planters & Pots", // Untersetzer
|
||||||
|
301: "Home & Garden > Decor > Planters & Pots", // Stofftöpfe
|
||||||
|
317: "Home & Garden > Decor > Planters & Pots", // Air-Pot
|
||||||
|
364: "Home & Garden > Decor > Planters & Pots", // Kunststofftöpfe
|
||||||
|
292: "Home & Garden > Decor > Planters & Pots", // Trays & Fluttische
|
||||||
|
|
||||||
|
// Ventilation & Climate
|
||||||
|
703: "Home & Garden > Outdoor Power Tools", // Abluft-Sets
|
||||||
|
247: "Home & Garden > Outdoor Power Tools", // Belüftung
|
||||||
|
214: "Home & Garden > Outdoor Power Tools", // Umluft-Ventilatoren
|
||||||
|
308: "Home & Garden > Outdoor Power Tools", // Ab- und Zuluft
|
||||||
|
609: "Home & Garden > Outdoor Power Tools", // Schalldämpfer
|
||||||
|
248: "Home & Garden > Pool & Spa > Pool & Spa Filters", // Aktivkohlefilter
|
||||||
|
392: "Home & Garden > Pool & Spa > Pool & Spa Filters", // Zuluftfilter
|
||||||
|
658: "Home & Garden > Climate Control > Dehumidifiers", // Luftbe- und entfeuchter
|
||||||
|
310: "Home & Garden > Climate Control > Heating", // Heizmatten
|
||||||
|
379: "Home & Garden > Household Supplies > Air Fresheners", // Geruchsneutralisation
|
||||||
|
|
||||||
|
// Irrigation & Watering
|
||||||
|
221: "Home & Garden > Lawn & Garden > Watering Equipment", // Bewässerung
|
||||||
|
250: "Home & Garden > Lawn & Garden > Watering Equipment", // Schläuche
|
||||||
|
297: "Home & Garden > Lawn & Garden > Watering Equipment", // Pumpen
|
||||||
|
354: "Home & Garden > Lawn & Garden > Watering Equipment", // Sprüher
|
||||||
|
372: "Home & Garden > Lawn & Garden > Watering Equipment", // AutoPot
|
||||||
|
389: "Home & Garden > Lawn & Garden > Watering Equipment", // Blumat
|
||||||
|
405: "Home & Garden > Lawn & Garden > Watering Equipment", // Schläuche
|
||||||
|
425: "Home & Garden > Lawn & Garden > Watering Equipment", // Wassertanks
|
||||||
|
480: "Home & Garden > Lawn & Garden > Watering Equipment", // Tropfer
|
||||||
|
519: "Home & Garden > Lawn & Garden > Watering Equipment", // Pumpsprüher
|
||||||
|
|
||||||
|
// Growing Media & Soils
|
||||||
|
242: "Home & Garden > Lawn & Garden > Fertilizers", // Böden
|
||||||
|
243: "Home & Garden > Lawn & Garden > Fertilizers", // Erde
|
||||||
|
269: "Home & Garden > Lawn & Garden > Fertilizers", // Kokos
|
||||||
|
580: "Home & Garden > Lawn & Garden > Fertilizers", // Perlite & Blähton
|
||||||
|
|
||||||
|
// Propagation & Starting
|
||||||
|
286: "Home & Garden > Plants", // Anzucht
|
||||||
|
298: "Home & Garden > Plants", // Steinwolltrays
|
||||||
|
421: "Home & Garden > Plants", // Vermehrungszubehör
|
||||||
|
489: "Home & Garden > Plants", // EazyPlug & Jiffy
|
||||||
|
359: "Home & Garden > Outdoor Structures > Greenhouses", // Gewächshäuser
|
||||||
|
|
||||||
|
// Tools & Equipment
|
||||||
|
373: "Home & Garden > Tools > Hand Tools", // GrowTool
|
||||||
|
403: "Home & Garden > Kitchen & Dining > Kitchen Tools & Utensils", // Messbecher & mehr
|
||||||
|
259: "Home & Garden > Tools > Hand Tools", // Pressen
|
||||||
|
280: "Home & Garden > Tools > Hand Tools", // Erntescheeren
|
||||||
|
258: "Home & Garden > Tools", // Ernte & Verarbeitung
|
||||||
|
278: "Home & Garden > Tools", // Extraktion
|
||||||
|
302: "Home & Garden > Tools", // Erntemaschinen
|
||||||
|
|
||||||
|
// Hardware & Plumbing
|
||||||
|
222: "Hardware > Plumbing", // PE-Teile
|
||||||
|
374: "Hardware > Plumbing > Plumbing Fittings", // Verbindungsteile
|
||||||
|
|
||||||
|
// Electronics & Control
|
||||||
|
314: "Electronics > Electronics Accessories", // Steuergeräte
|
||||||
|
408: "Electronics > Electronics Accessories", // GrowControl
|
||||||
|
344: "Business & Industrial > Science & Laboratory > Lab Equipment", // Messgeräte
|
||||||
|
555: "Business & Industrial > Science & Laboratory > Lab Equipment > Microscopes", // Mikroskope
|
||||||
|
|
||||||
|
// Camping & Outdoor
|
||||||
|
226: "Sporting Goods > Outdoor Recreation > Camping & Hiking", // Zeltzubehör
|
||||||
|
|
||||||
|
// Plant Care & Protection
|
||||||
|
239: "Home & Garden > Lawn & Garden > Pest Control", // Pflanzenschutz
|
||||||
|
240: "Home & Garden > Plants", // Anbauzubehör
|
||||||
|
|
||||||
|
// Office & Media
|
||||||
|
424: "Office Supplies > Labels", // Etiketten & Schilder
|
||||||
|
387: "Media > Books", // Literatur
|
||||||
|
|
||||||
|
// General categories
|
||||||
|
705: "Home & Garden", // Set-Konfigurator
|
||||||
|
686: "Home & Garden", // Zubehör
|
||||||
|
741: "Home & Garden", // Zubehör
|
||||||
|
294: "Home & Garden", // Zubehör
|
||||||
|
695: "Home & Garden", // Zubehör
|
||||||
|
293: "Home & Garden", // Trockennetze
|
||||||
|
4: "Home & Garden", // Sonstiges
|
||||||
|
450: "Home & Garden", // Restposten
|
||||||
|
};
|
||||||
|
|
||||||
|
return categoryMappings[categoryId] || "Home & Garden > Plants";
|
||||||
|
};
|
||||||
|
|
||||||
|
let productsXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss xmlns:g="http://base.google.com/ns/1.0" version="2.0">
|
||||||
|
<channel>
|
||||||
|
<title>${config.descriptions.short}</title>
|
||||||
|
<link>${baseUrl}</link>
|
||||||
|
<description>${config.descriptions.short}</description>
|
||||||
|
<lastBuildDate>${currentDate}</lastBuildDate>
|
||||||
|
<language>${config.language}</language>`;
|
||||||
|
|
||||||
|
// Helper function to clean text content of problematic characters
|
||||||
|
const cleanTextContent = (text) => {
|
||||||
|
if (!text) return "";
|
||||||
|
|
||||||
|
return text.toString()
|
||||||
|
// Remove HTML tags
|
||||||
|
.replace(/<[^>]*>/g, "")
|
||||||
|
// Remove non-printable characters and control characters
|
||||||
|
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/g, '')
|
||||||
|
// Remove BOM and other Unicode formatting characters
|
||||||
|
.replace(/[\uFEFF\u200B-\u200D\u2060]/g, '')
|
||||||
|
// Replace multiple whitespace with single space
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
// Remove leading/trailing whitespace
|
||||||
|
.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to properly escape XML content and remove invalid characters
|
||||||
|
const escapeXml = (unsafe) => {
|
||||||
|
if (!unsafe) return "";
|
||||||
|
|
||||||
|
// Convert to string and remove invalid XML characters
|
||||||
|
const cleaned = unsafe.toString()
|
||||||
|
// Remove control characters except tab (0x09), newline (0x0A), and carriage return (0x0D)
|
||||||
|
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '')
|
||||||
|
// Remove invalid Unicode characters and surrogates
|
||||||
|
.replace(/[\uD800-\uDFFF]/g, '')
|
||||||
|
// Remove other problematic characters
|
||||||
|
.replace(/[\uFFFE\uFFFF]/g, '')
|
||||||
|
// Normalize whitespace
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// Escape XML entities
|
||||||
|
return cleaned
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
};
|
||||||
|
|
||||||
|
let processedCount = 0;
|
||||||
|
let skippedCount = 0;
|
||||||
|
|
||||||
|
// Category IDs to skip (seeds, plants, headshop items)
|
||||||
|
const skipCategoryIds = [689, 706, 709, 711, 714, 748, 749, 896, 710];
|
||||||
|
|
||||||
|
// Add each product as an item
|
||||||
|
allProductsData.forEach((product, index) => {
|
||||||
|
try {
|
||||||
|
// Skip products without essential data
|
||||||
|
if (!product || !product.seoName) {
|
||||||
|
skippedCount++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip products from excluded categories
|
||||||
|
const productCategoryId = product.categoryId || product.category_id || product.category || null;
|
||||||
|
if (productCategoryId && skipCategoryIds.includes(parseInt(productCategoryId))) {
|
||||||
|
skippedCount++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip products without GTIN
|
||||||
|
if (!product.gtin || !product.gtin.toString().trim()) {
|
||||||
|
skippedCount++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip products without pictures
|
||||||
|
if (!product.pictureList || !product.pictureList.trim()) {
|
||||||
|
skippedCount++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean description for feed (remove HTML tags and limit length)
|
||||||
|
const rawDescription = product.description
|
||||||
|
? cleanTextContent(product.description).substring(0, 500)
|
||||||
|
: `${product.name || 'Product'} - Art.-Nr.: ${product.articleNumber || 'N/A'}`;
|
||||||
|
|
||||||
|
const cleanDescription = escapeXml(rawDescription) || "Produktbeschreibung nicht verfügbar";
|
||||||
|
|
||||||
|
// Clean product name
|
||||||
|
const rawName = product.name || "Unnamed Product";
|
||||||
|
const cleanName = escapeXml(cleanTextContent(rawName)) || "Unnamed Product";
|
||||||
|
|
||||||
|
// Validate essential fields
|
||||||
|
if (!cleanName || cleanName.length < 2) {
|
||||||
|
skippedCount++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate product URL
|
||||||
|
const productUrl = `${baseUrl}/Artikel/${encodeURIComponent(product.seoName)}`;
|
||||||
|
|
||||||
|
// Generate image URL
|
||||||
|
const imageUrl = product.pictureList && product.pictureList.trim()
|
||||||
|
? `${baseUrl}/assets/images/prod${product.pictureList.split(",")[0].trim()}.jpg`
|
||||||
|
: `${baseUrl}/assets/images/nopicture.jpg`;
|
||||||
|
|
||||||
|
// Generate brand (manufacturer)
|
||||||
|
const rawBrand = product.manufacturer || config.brandName;
|
||||||
|
const brand = escapeXml(cleanTextContent(rawBrand));
|
||||||
|
|
||||||
|
// Generate condition (always new for this type of shop)
|
||||||
|
const condition = "new";
|
||||||
|
|
||||||
|
// Generate availability
|
||||||
|
const availability = product.available ? "in stock" : "out of stock";
|
||||||
|
|
||||||
|
// Generate price (ensure it's a valid number)
|
||||||
|
const price = product.price && !isNaN(product.price)
|
||||||
|
? `${parseFloat(product.price).toFixed(2)} ${config.currency}`
|
||||||
|
: `0.00 ${config.currency}`;
|
||||||
|
|
||||||
|
// Skip products with price == 0
|
||||||
|
if (!product.price || parseFloat(product.price) === 0) {
|
||||||
|
skippedCount++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate GTIN/EAN if available
|
||||||
|
const gtin = product.gtin ? escapeXml(product.gtin.toString().trim()) : null;
|
||||||
|
|
||||||
|
// Generate product ID (using articleNumber or seoName)
|
||||||
|
const rawProductId = product.articleNumber || product.seoName || `product_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`;
|
||||||
|
const productId = escapeXml(rawProductId.toString().trim()) || `fallback_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`;
|
||||||
|
|
||||||
|
// Get Google product category based on product's category ID
|
||||||
|
const categoryId = product.categoryId || product.category_id || product.category || null;
|
||||||
|
const googleCategory = getGoogleProductCategory(categoryId);
|
||||||
|
const escapedGoogleCategory = escapeXml(googleCategory);
|
||||||
|
|
||||||
|
// Build item XML with proper formatting
|
||||||
|
productsXml += `
|
||||||
|
<item>
|
||||||
|
<g:id>${productId}</g:id>
|
||||||
|
<g:title>${cleanName}</g:title>
|
||||||
|
<g:description>${cleanDescription}</g:description>
|
||||||
|
<g:link>${productUrl}</g:link>
|
||||||
|
<g:image_link>${imageUrl}</g:image_link>
|
||||||
|
<g:condition>${condition}</g:condition>
|
||||||
|
<g:availability>${availability}</g:availability>
|
||||||
|
<g:price>${price}</g:price>
|
||||||
|
<g:shipping>
|
||||||
|
<g:country>${config.country}</g:country>
|
||||||
|
<g:service>${config.shipping.defaultService}</g:service>
|
||||||
|
<g:price>${config.shipping.defaultCost}</g:price>
|
||||||
|
</g:shipping>
|
||||||
|
<g:brand>${brand}</g:brand>
|
||||||
|
<g:google_product_category>${escapedGoogleCategory}</g:google_product_category>
|
||||||
|
<g:product_type>Gartenbedarf</g:product_type>`;
|
||||||
|
|
||||||
|
// Add GTIN if available
|
||||||
|
if (gtin && gtin.trim()) {
|
||||||
|
productsXml += `
|
||||||
|
<g:gtin>${gtin}</g:gtin>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add weight if available
|
||||||
|
if (product.weight && !isNaN(product.weight)) {
|
||||||
|
productsXml += `
|
||||||
|
<g:shipping_weight>${parseFloat(product.weight).toFixed(2)} g</g:shipping_weight>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
productsXml += `
|
||||||
|
</item>`;
|
||||||
|
|
||||||
|
processedCount++;
|
||||||
|
|
||||||
|
} catch (itemError) {
|
||||||
|
console.log(` ⚠️ Skipped product ${index + 1}: ${itemError.message}`);
|
||||||
|
skippedCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
productsXml += `
|
||||||
|
</channel>
|
||||||
|
</rss>`;
|
||||||
|
|
||||||
|
console.log(` 📊 Processing summary: ${processedCount} products included, ${skippedCount} skipped`);
|
||||||
|
|
||||||
|
return productsXml;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
generateRobotsTxt,
|
||||||
|
generateProductsXml,
|
||||||
|
};
|
||||||
215
prerender/seo/homepage.cjs
Normal file
215
prerender/seo/homepage.cjs
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
const generateHomepageMetaTags = (baseUrl, config) => {
|
||||||
|
const description = config.descriptions.long;
|
||||||
|
const keywords = config.keywords;
|
||||||
|
const imageUrl = `${baseUrl}${config.images.logo}`;
|
||||||
|
|
||||||
|
// Ensure URLs are properly formatted
|
||||||
|
const canonicalUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<!-- SEO Meta Tags -->
|
||||||
|
<meta name="description" content="${description}">
|
||||||
|
<meta name="keywords" content="${keywords}">
|
||||||
|
|
||||||
|
<!-- Open Graph Meta Tags -->
|
||||||
|
<meta property="og:title" content="${config.descriptions.short}">
|
||||||
|
<meta property="og:description" content="${description}">
|
||||||
|
<meta property="og:image" content="${imageUrl}">
|
||||||
|
<meta property="og:url" content="${canonicalUrl}">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:site_name" content="${config.siteName}">
|
||||||
|
|
||||||
|
<!-- Twitter Card Meta Tags -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="${config.descriptions.short}">
|
||||||
|
<meta name="twitter:description" content="${description}">
|
||||||
|
<meta name="twitter:image" content="${imageUrl}">
|
||||||
|
|
||||||
|
<!-- Additional Meta Tags -->
|
||||||
|
<meta name="robots" content="index, follow">
|
||||||
|
<link rel="canonical" href="${canonicalUrl}">
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateHomepageJsonLd = (baseUrl, config, categories = []) => {
|
||||||
|
// Ensure URLs are properly formatted
|
||||||
|
const canonicalUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
||||||
|
const logoUrl = `${canonicalUrl}${config.images.logo}`;
|
||||||
|
|
||||||
|
const websiteJsonLd = {
|
||||||
|
"@context": "https://schema.org/",
|
||||||
|
"@type": "WebSite",
|
||||||
|
name: config.brandName,
|
||||||
|
url: canonicalUrl,
|
||||||
|
description: config.descriptions.long,
|
||||||
|
publisher: {
|
||||||
|
"@type": "Organization",
|
||||||
|
name: config.brandName,
|
||||||
|
url: canonicalUrl,
|
||||||
|
logo: {
|
||||||
|
"@type": "ImageObject",
|
||||||
|
url: logoUrl,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
potentialAction: {
|
||||||
|
"@type": "SearchAction",
|
||||||
|
target: `${canonicalUrl}/search?q={search_term_string}`,
|
||||||
|
query: "required name=search_term_string"
|
||||||
|
},
|
||||||
|
mainEntity: {
|
||||||
|
"@type": "WebPage",
|
||||||
|
name: "Sitemap",
|
||||||
|
url: `${canonicalUrl}/sitemap`,
|
||||||
|
description: "Vollständige Sitemap mit allen Kategorien und Seiten",
|
||||||
|
},
|
||||||
|
sameAs: [
|
||||||
|
// Add your social media URLs here if available
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Organization/LocalBusiness Schema for rich results
|
||||||
|
const organizationJsonLd = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "LocalBusiness",
|
||||||
|
"name": config.brandName,
|
||||||
|
"alternateName": config.siteName,
|
||||||
|
"description": config.descriptions.long,
|
||||||
|
"url": canonicalUrl,
|
||||||
|
"logo": logoUrl,
|
||||||
|
"image": logoUrl,
|
||||||
|
"telephone": "015208491860",
|
||||||
|
"email": "service@growheads.de",
|
||||||
|
"address": {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
"streetAddress": "Trachenberger Strasse 14",
|
||||||
|
"addressLocality": "Dresden",
|
||||||
|
"postalCode": "01129",
|
||||||
|
"addressCountry": "DE",
|
||||||
|
"addressRegion": "Sachsen"
|
||||||
|
},
|
||||||
|
"geo": {
|
||||||
|
"@type": "GeoCoordinates",
|
||||||
|
"latitude": "51.083675",
|
||||||
|
"longitude": "13.727215"
|
||||||
|
},
|
||||||
|
"openingHours": [
|
||||||
|
"Mo-Fr 10:00:00-20:00:00",
|
||||||
|
"Sa 11:00:00-19:00:00"
|
||||||
|
],
|
||||||
|
"paymentAccepted": "Cash, Credit Card, PayPal, Bank Transfer",
|
||||||
|
"currenciesAccepted": "EUR",
|
||||||
|
"priceRange": "€€",
|
||||||
|
"areaServed": {
|
||||||
|
"@type": "Country",
|
||||||
|
"name": "Germany"
|
||||||
|
},
|
||||||
|
"contactPoint": [
|
||||||
|
{
|
||||||
|
"@type": "ContactPoint",
|
||||||
|
"telephone": "015208491860",
|
||||||
|
"contactType": "customer service",
|
||||||
|
"availableLanguage": "German",
|
||||||
|
"hoursAvailable": {
|
||||||
|
"@type": "OpeningHoursSpecification",
|
||||||
|
"dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
|
||||||
|
"opens": "10:00:00",
|
||||||
|
"closes": "20:00:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "ContactPoint",
|
||||||
|
"email": "service@growheads.de",
|
||||||
|
"contactType": "customer service",
|
||||||
|
"availableLanguage": "German"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sameAs": [
|
||||||
|
// Add social media URLs when available
|
||||||
|
// "https://www.facebook.com/growheads",
|
||||||
|
// "https://www.instagram.com/growheads"
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// FAQPage Schema for common questions
|
||||||
|
const faqJsonLd = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "FAQPage",
|
||||||
|
"mainEntity": [
|
||||||
|
{
|
||||||
|
"@type": "Question",
|
||||||
|
"name": "Welche Zahlungsmethoden akzeptiert GrowHeads?",
|
||||||
|
"acceptedAnswer": {
|
||||||
|
"@type": "Answer",
|
||||||
|
"text": "Wir akzeptieren Kreditkarten, PayPal, Banküberweisung, Nachnahme und Barzahlung bei Abholung in unserem Laden in Dresden."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Question",
|
||||||
|
"name": "Liefert GrowHeads deutschlandweit?",
|
||||||
|
"acceptedAnswer": {
|
||||||
|
"@type": "Answer",
|
||||||
|
"text": "Ja, wir liefern deutschlandweit. Zusätzlich haben wir einen Laden in Dresden (Trachenberger Strasse 14) für lokale Kunden."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Question",
|
||||||
|
"name": "Welche Produkte bietet GrowHeads?",
|
||||||
|
"acceptedAnswer": {
|
||||||
|
"@type": "Answer",
|
||||||
|
"text": "Wir bieten ein komplettes Sortiment für den Indoor-Anbau: Beleuchtung, Belüftung, Dünger, Töpfe, Zelte, Messgeräte und vieles mehr für professionelle Zuchtanlagen."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Question",
|
||||||
|
"name": "Hat GrowHeads einen physischen Laden?",
|
||||||
|
"acceptedAnswer": {
|
||||||
|
"@type": "Answer",
|
||||||
|
"text": "Ja, unser Laden befindet sich in Dresden, Trachenberger Strasse 14. Öffnungszeiten: Mo-Fr 10-20 Uhr, Sa 11-19 Uhr. Sie können auch online bestellen."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Question",
|
||||||
|
"name": "Bietet GrowHeads Beratung zum Indoor-Anbau?",
|
||||||
|
"acceptedAnswer": {
|
||||||
|
"@type": "Answer",
|
||||||
|
"text": "Ja, unser erfahrenes Team berät Sie gerne zu allen Aspekten des Indoor-Anbaus. Kontaktieren Sie uns telefonisch unter 015208491860 oder besuchen Sie unseren Laden."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate ItemList for all categories (more appropriate for homepage)
|
||||||
|
const categoriesListJsonLd = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "ItemList",
|
||||||
|
"name": "Produktkategorien",
|
||||||
|
"description": "Alle verfügbaren Produktkategorien in unserem Online-Shop",
|
||||||
|
"numberOfItems": categories.filter(category => category.seoName).length,
|
||||||
|
"itemListElement": categories
|
||||||
|
.filter(category => category.seoName) // Only include categories with seoName
|
||||||
|
.map((category, index) => ({
|
||||||
|
"@type": "ListItem",
|
||||||
|
"position": index + 1,
|
||||||
|
"item": {
|
||||||
|
"@type": "Thing",
|
||||||
|
"name": category.name,
|
||||||
|
"url": `${canonicalUrl}/Kategorie/${category.seoName}`
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
// Return all JSON-LD scripts
|
||||||
|
const websiteScript = `<script type="application/ld+json">${JSON.stringify(websiteJsonLd)}</script>`;
|
||||||
|
const organizationScript = `<script type="application/ld+json">${JSON.stringify(organizationJsonLd)}</script>`;
|
||||||
|
const faqScript = `<script type="application/ld+json">${JSON.stringify(faqJsonLd)}</script>`;
|
||||||
|
const categoriesScript = categories.length > 0
|
||||||
|
? `<script type="application/ld+json">${JSON.stringify(categoriesListJsonLd)}</script>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return websiteScript + '\n' + organizationScript + '\n' + faqScript + (categoriesScript ? '\n' + categoriesScript : '');
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
generateHomepageMetaTags,
|
||||||
|
generateHomepageJsonLd,
|
||||||
|
};
|
||||||
64
prerender/seo/index.cjs
Normal file
64
prerender/seo/index.cjs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// Import all SEO functions from their respective modules
|
||||||
|
const {
|
||||||
|
generateProductMetaTags,
|
||||||
|
generateProductJsonLd,
|
||||||
|
} = require('./product.cjs');
|
||||||
|
|
||||||
|
const {
|
||||||
|
generateCategoryJsonLd,
|
||||||
|
} = require('./category.cjs');
|
||||||
|
|
||||||
|
const {
|
||||||
|
generateHomepageMetaTags,
|
||||||
|
generateHomepageJsonLd,
|
||||||
|
} = require('./homepage.cjs');
|
||||||
|
|
||||||
|
const {
|
||||||
|
generateSitemapJsonLd,
|
||||||
|
generateXmlSitemap,
|
||||||
|
} = require('./sitemap.cjs');
|
||||||
|
|
||||||
|
const {
|
||||||
|
generateKonfiguratorMetaTags,
|
||||||
|
} = require('./konfigurator.cjs');
|
||||||
|
|
||||||
|
const {
|
||||||
|
generateRobotsTxt,
|
||||||
|
generateProductsXml,
|
||||||
|
} = require('./feeds.cjs');
|
||||||
|
|
||||||
|
const {
|
||||||
|
generateLlmsTxt,
|
||||||
|
generateCategoryLlmsTxt,
|
||||||
|
generateAllCategoryLlmsPages,
|
||||||
|
} = require('./llms.cjs');
|
||||||
|
|
||||||
|
// Export all functions for use in the main application
|
||||||
|
module.exports = {
|
||||||
|
// Product functions
|
||||||
|
generateProductMetaTags,
|
||||||
|
generateProductJsonLd,
|
||||||
|
|
||||||
|
// Category functions
|
||||||
|
generateCategoryJsonLd,
|
||||||
|
|
||||||
|
// Homepage functions
|
||||||
|
generateHomepageMetaTags,
|
||||||
|
generateHomepageJsonLd,
|
||||||
|
|
||||||
|
// Sitemap functions
|
||||||
|
generateSitemapJsonLd,
|
||||||
|
generateXmlSitemap,
|
||||||
|
|
||||||
|
// Konfigurator functions
|
||||||
|
generateKonfiguratorMetaTags,
|
||||||
|
|
||||||
|
// Feed/Export functions
|
||||||
|
generateRobotsTxt,
|
||||||
|
generateProductsXml,
|
||||||
|
|
||||||
|
// LLMs/AI functions
|
||||||
|
generateLlmsTxt,
|
||||||
|
generateCategoryLlmsTxt,
|
||||||
|
generateAllCategoryLlmsPages,
|
||||||
|
};
|
||||||
36
prerender/seo/konfigurator.cjs
Normal file
36
prerender/seo/konfigurator.cjs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
const generateKonfiguratorMetaTags = (baseUrl, config) => {
|
||||||
|
const description = "Unser interaktiver Growbox Konfigurator hilft dir dabei, das perfekte Indoor Growing Setup zusammenzustellen. Wähle aus verschiedenen Growbox-Größen, Beleuchtung, Belüftung und Extras. Bundle-Rabatte bis 36%!";
|
||||||
|
const keywords = "Growbox Konfigurator, Indoor Growing, Growzelt, Beleuchtung, Belüftung, Growbox Setup, Indoor Garden";
|
||||||
|
|
||||||
|
// Ensure URLs are properly formatted
|
||||||
|
const canonicalUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
||||||
|
const imageUrl = `${canonicalUrl}${config.images.placeholder}`; // Placeholder image
|
||||||
|
|
||||||
|
return `
|
||||||
|
<!-- SEO Meta Tags -->
|
||||||
|
<meta name="description" content="${description}">
|
||||||
|
<meta name="keywords" content="${keywords}">
|
||||||
|
|
||||||
|
<!-- Open Graph Meta Tags -->
|
||||||
|
<meta property="og:title" content="Growbox Konfigurator - Stelle dein perfektes Indoor Grow Setup zusammen">
|
||||||
|
<meta property="og:description" content="${description}">
|
||||||
|
<meta property="og:image" content="${imageUrl}">
|
||||||
|
<meta property="og:url" content="${canonicalUrl}/Konfigurator">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:site_name" content="${config.siteName}">
|
||||||
|
|
||||||
|
<!-- Twitter Card Meta Tags -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="Growbox Konfigurator - Indoor Grow Setup">
|
||||||
|
<meta name="twitter:description" content="${description}">
|
||||||
|
<meta name="twitter:image" content="${imageUrl}">
|
||||||
|
|
||||||
|
<!-- Additional Meta Tags -->
|
||||||
|
<meta name="robots" content="index, follow">
|
||||||
|
<link rel="canonical" href="${canonicalUrl}/Konfigurator">
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
generateKonfiguratorMetaTags,
|
||||||
|
};
|
||||||
277
prerender/seo/llms.cjs
Normal file
277
prerender/seo/llms.cjs
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
const generateLlmsTxt = (allCategories = [], allProductsData = [], baseUrl, config) => {
|
||||||
|
const currentDate = new Date().toISOString().split("T")[0]; // YYYY-MM-DD format
|
||||||
|
|
||||||
|
// Group products by category for statistics
|
||||||
|
const productsByCategory = {};
|
||||||
|
allProductsData.forEach((product) => {
|
||||||
|
const categoryId = product.categoryId || 'uncategorized';
|
||||||
|
if (!productsByCategory[categoryId]) {
|
||||||
|
productsByCategory[categoryId] = [];
|
||||||
|
}
|
||||||
|
productsByCategory[categoryId].push(product);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find category names for organization
|
||||||
|
const categoryMap = {};
|
||||||
|
allCategories.forEach((cat) => {
|
||||||
|
categoryMap[cat.id] = cat.name;
|
||||||
|
});
|
||||||
|
|
||||||
|
let llmsTxt = `# ${config.siteName} - Site Map for LLMs
|
||||||
|
|
||||||
|
Generated: ${currentDate}
|
||||||
|
Base URL: ${baseUrl}
|
||||||
|
|
||||||
|
## About ${config.brandName}
|
||||||
|
GrowHeads.de is a German online shop and local store in Dresden specializing in high-quality seeds, plants, and gardening supplies for cannabis cultivation.
|
||||||
|
|
||||||
|
## Site Structure
|
||||||
|
|
||||||
|
### Static Pages
|
||||||
|
- **Home** - ${baseUrl}/
|
||||||
|
- **Datenschutz (Privacy Policy)** - ${baseUrl}/datenschutz
|
||||||
|
- **Impressum (Legal Notice)** - ${baseUrl}/impressum
|
||||||
|
- **AGB (Terms & Conditions)** - ${baseUrl}/agb
|
||||||
|
- **Widerrufsrecht (Right of Withdrawal)** - ${baseUrl}/widerrufsrecht
|
||||||
|
- **Batteriegesetzhinweise (Battery Law Notice)** - ${baseUrl}/batteriegesetzhinweise
|
||||||
|
- **Sitemap** - ${baseUrl}/sitemap
|
||||||
|
- **Growbox Konfigurator** - ${baseUrl}/Konfigurator - Interactive tool to configure grow box setups with bundle discounts
|
||||||
|
- **Profile** - ${baseUrl}/profile - User account and order management
|
||||||
|
|
||||||
|
### Site Features
|
||||||
|
- **Language**: German (${config.language})
|
||||||
|
- **Currency**: ${config.currency} (Euro)
|
||||||
|
- **Shipping**: ${config.country}
|
||||||
|
- **Payment Methods**: Credit Cards, PayPal, Bank Transfer, Cash on Delivery, Cash on Pickup
|
||||||
|
|
||||||
|
### Product Categories (${allCategories.length} categories)
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add categories with links to their detailed LLM files
|
||||||
|
allCategories.forEach((category) => {
|
||||||
|
if (category.seoName) {
|
||||||
|
const productCount = productsByCategory[category.id]?.length || 0;
|
||||||
|
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
||||||
|
const productsPerPage = 50;
|
||||||
|
const totalPages = Math.ceil(productCount / productsPerPage);
|
||||||
|
|
||||||
|
llmsTxt += `#### ${category.name} (${productCount} products)`;
|
||||||
|
|
||||||
|
if (totalPages > 1) {
|
||||||
|
llmsTxt += `
|
||||||
|
- **Product Catalog**: ${totalPages} pages available
|
||||||
|
- **Page 1**: ${baseUrl}/llms-${categorySlug}-page-1.txt (Products 1-${Math.min(productsPerPage, productCount)})`;
|
||||||
|
|
||||||
|
if (totalPages > 2) {
|
||||||
|
llmsTxt += `
|
||||||
|
- **Page 2**: ${baseUrl}/llms-${categorySlug}-page-2.txt (Products ${productsPerPage + 1}-${Math.min(productsPerPage * 2, productCount)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalPages > 3) {
|
||||||
|
llmsTxt += `
|
||||||
|
- **...**: Additional pages available`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalPages > 2) {
|
||||||
|
llmsTxt += `
|
||||||
|
- **Page ${totalPages}**: ${baseUrl}/llms-${categorySlug}-page-${totalPages}.txt (Products ${((totalPages - 1) * productsPerPage) + 1}-${productCount})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
llmsTxt += `
|
||||||
|
- **Access Pattern**: Replace "page-X" with desired page number (1-${totalPages})`;
|
||||||
|
} else if (productCount > 0) {
|
||||||
|
llmsTxt += `
|
||||||
|
- **Product Catalog**: ${baseUrl}/llms-${categorySlug}-page-1.txt`;
|
||||||
|
} else {
|
||||||
|
llmsTxt += `
|
||||||
|
- **Product Catalog**: No products available`;
|
||||||
|
}
|
||||||
|
|
||||||
|
llmsTxt += `
|
||||||
|
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
llmsTxt += `
|
||||||
|
---
|
||||||
|
|
||||||
|
*This sitemap is automatically generated during the site build process and includes all publicly accessible content. For technical inquiries, please refer to our contact information in the Impressum.*
|
||||||
|
`;
|
||||||
|
|
||||||
|
return llmsTxt;
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateCategoryLlmsTxt = (category, categoryProducts = [], baseUrl, config, pageNumber = 1, productsPerPage = 50) => {
|
||||||
|
const currentDate = new Date().toISOString().split("T")[0]; // YYYY-MM-DD format
|
||||||
|
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
||||||
|
|
||||||
|
// Calculate pagination
|
||||||
|
const totalProducts = categoryProducts.length;
|
||||||
|
const totalPages = Math.ceil(totalProducts / productsPerPage);
|
||||||
|
const startIndex = (pageNumber - 1) * productsPerPage;
|
||||||
|
const endIndex = Math.min(startIndex + productsPerPage, totalProducts);
|
||||||
|
const pageProducts = categoryProducts.slice(startIndex, endIndex);
|
||||||
|
|
||||||
|
let categoryLlmsTxt = `# ${category.name} - Product Catalog (Page ${pageNumber} of ${totalPages})
|
||||||
|
|
||||||
|
Generated: ${currentDate}
|
||||||
|
Base URL: ${baseUrl}
|
||||||
|
Category: ${category.name} (ID: ${category.id})
|
||||||
|
Category URL: ${baseUrl}/Kategorie/${category.seoName}
|
||||||
|
|
||||||
|
## Category Overview
|
||||||
|
This file contains products ${startIndex + 1}-${endIndex} of ${totalProducts} in the "${category.name}" category from ${config.siteName}.
|
||||||
|
|
||||||
|
**Statistics:**
|
||||||
|
- **Total Products in Category**: ${totalProducts}
|
||||||
|
- **Products on This Page**: ${pageProducts.length}
|
||||||
|
- **Current Page**: ${pageNumber} of ${totalPages}
|
||||||
|
- **Category ID**: ${category.id}
|
||||||
|
- **Category URL**: ${baseUrl}/Kategorie/${category.seoName}
|
||||||
|
- **Back to Main Sitemap**: ${baseUrl}/llms.txt
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add navigation hints for LLMs
|
||||||
|
if (totalPages > 1) {
|
||||||
|
categoryLlmsTxt += `## Navigation for LLMs
|
||||||
|
|
||||||
|
**How to access other pages in this category:**
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (pageNumber > 1) {
|
||||||
|
categoryLlmsTxt += `- **Previous Page**: ${baseUrl}/llms-${categorySlug}-page-${pageNumber - 1}.txt
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageNumber < totalPages) {
|
||||||
|
categoryLlmsTxt += `- **Next Page**: ${baseUrl}/llms-${categorySlug}-page-${pageNumber + 1}.txt
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryLlmsTxt += `- **First Page**: ${baseUrl}/llms-${categorySlug}-page-1.txt
|
||||||
|
- **Last Page**: ${baseUrl}/llms-${categorySlug}-page-${totalPages}.txt
|
||||||
|
|
||||||
|
**All pages in this category:**
|
||||||
|
`;
|
||||||
|
|
||||||
|
for (let i = 1; i <= totalPages; i++) {
|
||||||
|
categoryLlmsTxt += `- **Page ${i}**: ${baseUrl}/llms-${categorySlug}-page-${i}.txt (Products ${((i-1) * productsPerPage) + 1}-${Math.min(i * productsPerPage, totalProducts)})
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryLlmsTxt += `
|
||||||
|
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageProducts.length > 0) {
|
||||||
|
pageProducts.forEach((product, index) => {
|
||||||
|
if (product.seoName) {
|
||||||
|
// Clean description for markdown (remove HTML tags and limit length)
|
||||||
|
const cleanDescription = product.description
|
||||||
|
? product.description
|
||||||
|
.replace(/<[^>]*>/g, "")
|
||||||
|
.replace(/\n/g, " ")
|
||||||
|
.trim()
|
||||||
|
.substring(0, 300)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const globalIndex = startIndex + index + 1;
|
||||||
|
categoryLlmsTxt += `## ${globalIndex}. ${product.name}
|
||||||
|
|
||||||
|
- **Product URL**: ${baseUrl}/Artikel/${product.seoName}
|
||||||
|
- **Article Number**: ${product.articleNumber || 'N/A'}
|
||||||
|
- **Price**: €${product.price || '0.00'}
|
||||||
|
- **Brand**: ${product.manufacturer || config.brandName}
|
||||||
|
- **Availability**: ${product.available ? 'In Stock' : 'Out of Stock'}`;
|
||||||
|
|
||||||
|
if (product.gtin) {
|
||||||
|
categoryLlmsTxt += `
|
||||||
|
- **GTIN**: ${product.gtin}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (product.weight && !isNaN(product.weight)) {
|
||||||
|
categoryLlmsTxt += `
|
||||||
|
- **Weight**: ${product.weight}g`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleanDescription) {
|
||||||
|
categoryLlmsTxt += `
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
${cleanDescription}${product.description && product.description.length > 300 ? '...' : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryLlmsTxt += `
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
categoryLlmsTxt += `## No Products Available
|
||||||
|
|
||||||
|
This category currently contains no products.
|
||||||
|
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add footer navigation for convenience
|
||||||
|
if (totalPages > 1) {
|
||||||
|
categoryLlmsTxt += `## Page Navigation
|
||||||
|
|
||||||
|
`;
|
||||||
|
if (pageNumber > 1) {
|
||||||
|
categoryLlmsTxt += `← [Previous Page](${baseUrl}/llms-${categorySlug}-page-${pageNumber - 1}.txt) | `;
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryLlmsTxt += `[Category Overview](${baseUrl}/llms-${categorySlug}-page-1.txt)`;
|
||||||
|
|
||||||
|
if (pageNumber < totalPages) {
|
||||||
|
categoryLlmsTxt += ` | [Next Page](${baseUrl}/llms-${categorySlug}-page-${pageNumber + 1}.txt) →`;
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryLlmsTxt += `
|
||||||
|
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryLlmsTxt += `---
|
||||||
|
|
||||||
|
*This category product list is automatically generated during the site build process. Product availability and pricing are updated in real-time on the main website.*
|
||||||
|
`;
|
||||||
|
|
||||||
|
return categoryLlmsTxt;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to generate all pages for a category
|
||||||
|
const generateAllCategoryLlmsPages = (category, categoryProducts = [], baseUrl, config, productsPerPage = 50) => {
|
||||||
|
const totalProducts = categoryProducts.length;
|
||||||
|
const totalPages = Math.ceil(totalProducts / productsPerPage);
|
||||||
|
const pages = [];
|
||||||
|
|
||||||
|
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber++) {
|
||||||
|
const pageContent = generateCategoryLlmsTxt(category, categoryProducts, baseUrl, config, pageNumber, productsPerPage);
|
||||||
|
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
||||||
|
const fileName = `llms-${categorySlug}-page-${pageNumber}.txt`;
|
||||||
|
|
||||||
|
pages.push({
|
||||||
|
fileName,
|
||||||
|
content: pageContent,
|
||||||
|
pageNumber,
|
||||||
|
totalPages
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
generateLlmsTxt,
|
||||||
|
generateCategoryLlmsTxt,
|
||||||
|
generateAllCategoryLlmsPages,
|
||||||
|
};
|
||||||
135
prerender/seo/product.cjs
Normal file
135
prerender/seo/product.cjs
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
const generateProductMetaTags = (product, baseUrl, config) => {
|
||||||
|
const productUrl = `${baseUrl}/Artikel/${product.seoName}`;
|
||||||
|
const imageUrl =
|
||||||
|
product.pictureList && product.pictureList.trim()
|
||||||
|
? `${baseUrl}/assets/images/prod${product.pictureList
|
||||||
|
.split(",")[0]
|
||||||
|
.trim()}.jpg`
|
||||||
|
: `${baseUrl}/assets/images/nopicture.jpg`;
|
||||||
|
|
||||||
|
// Clean description for meta (remove HTML tags and limit length)
|
||||||
|
const cleanDescription = product.description
|
||||||
|
? product.description
|
||||||
|
.replace(/<[^>]*>/g, "")
|
||||||
|
.replace(/\n/g, " ")
|
||||||
|
.substring(0, 160)
|
||||||
|
: `${product.name} - Art.-Nr.: ${product.articleNumber}`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<!-- SEO Meta Tags -->
|
||||||
|
<meta name="description" content="${cleanDescription}">
|
||||||
|
<meta name="keywords" content="${product.name}, ${
|
||||||
|
product.manufacturer || ""
|
||||||
|
}, ${product.articleNumber}">
|
||||||
|
|
||||||
|
<!-- Open Graph Meta Tags -->
|
||||||
|
<meta property="og:title" content="${product.name}">
|
||||||
|
<meta property="og:description" content="${cleanDescription}">
|
||||||
|
<meta property="og:image" content="${imageUrl}">
|
||||||
|
<meta property="og:url" content="${productUrl}">
|
||||||
|
<meta property="og:type" content="product">
|
||||||
|
<meta property="og:site_name" content="${config.siteName}">
|
||||||
|
<meta property="product:price:amount" content="${product.price}">
|
||||||
|
<meta property="product:price:currency" content="${config.currency}">
|
||||||
|
<meta property="product:availability" content="${
|
||||||
|
product.available ? "in stock" : "out of stock"
|
||||||
|
}">
|
||||||
|
${product.gtin ? `<meta property="product:gtin" content="${product.gtin}">` : ''}
|
||||||
|
${product.articleNumber ? `<meta property="product:retailer_item_id" content="${product.articleNumber}">` : ''}
|
||||||
|
${product.manufacturer ? `<meta property="product:brand" content="${product.manufacturer}">` : ''}
|
||||||
|
|
||||||
|
<!-- Twitter Card Meta Tags -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="${product.name}">
|
||||||
|
<meta name="twitter:description" content="${cleanDescription}">
|
||||||
|
<meta name="twitter:image" content="${imageUrl}">
|
||||||
|
|
||||||
|
<!-- Additional Meta Tags -->
|
||||||
|
<meta name="robots" content="index, follow">
|
||||||
|
<link rel="canonical" href="${productUrl}">
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) => {
|
||||||
|
const productUrl = `${baseUrl}/Artikel/${product.seoName}`;
|
||||||
|
const imageUrl =
|
||||||
|
product.pictureList && product.pictureList.trim()
|
||||||
|
? `${baseUrl}/assets/images/prod${product.pictureList
|
||||||
|
.split(",")[0]
|
||||||
|
.trim()}.jpg`
|
||||||
|
: `${baseUrl}/assets/images/nopicture.jpg`;
|
||||||
|
|
||||||
|
// Clean description for JSON-LD (remove HTML tags)
|
||||||
|
const cleanDescription = product.description
|
||||||
|
? product.description.replace(/<[^>]*>/g, "").replace(/\n/g, " ")
|
||||||
|
: product.name;
|
||||||
|
|
||||||
|
// Calculate price valid date (current date + 3 months)
|
||||||
|
const priceValidDate = new Date();
|
||||||
|
priceValidDate.setMonth(priceValidDate.getMonth() + 3);
|
||||||
|
|
||||||
|
const jsonLd = {
|
||||||
|
"@context": "https://schema.org/",
|
||||||
|
"@type": "Product",
|
||||||
|
name: product.name,
|
||||||
|
image: [imageUrl],
|
||||||
|
description: cleanDescription,
|
||||||
|
sku: product.articleNumber,
|
||||||
|
...(product.gtin && { gtin: product.gtin }),
|
||||||
|
brand: {
|
||||||
|
"@type": "Brand",
|
||||||
|
name: product.manufacturer || "Unknown",
|
||||||
|
},
|
||||||
|
offers: {
|
||||||
|
"@type": "Offer",
|
||||||
|
url: productUrl,
|
||||||
|
priceCurrency: config.currency,
|
||||||
|
price: product.price.toString(),
|
||||||
|
priceValidUntil: priceValidDate.toISOString().split("T")[0],
|
||||||
|
itemCondition: "https://schema.org/NewCondition",
|
||||||
|
availability: product.available
|
||||||
|
? "https://schema.org/InStock"
|
||||||
|
: "https://schema.org/OutOfStock",
|
||||||
|
seller: {
|
||||||
|
"@type": "Organization",
|
||||||
|
name: config.brandName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add breadcrumb if category information is available
|
||||||
|
if (categoryInfo && categoryInfo.name && categoryInfo.seoName) {
|
||||||
|
jsonLd.breadcrumb = {
|
||||||
|
"@type": "BreadcrumbList",
|
||||||
|
itemListElement: [
|
||||||
|
{
|
||||||
|
"@type": "ListItem",
|
||||||
|
position: 1,
|
||||||
|
name: "Home",
|
||||||
|
item: baseUrl,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "ListItem",
|
||||||
|
position: 2,
|
||||||
|
name: categoryInfo.name,
|
||||||
|
item: `${baseUrl}/Kategorie/${categoryInfo.seoName}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "ListItem",
|
||||||
|
position: 3,
|
||||||
|
name: product.name,
|
||||||
|
item: productUrl,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<script type="application/ld+json">${JSON.stringify(
|
||||||
|
jsonLd
|
||||||
|
)}</script>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
generateProductMetaTags,
|
||||||
|
generateProductJsonLd,
|
||||||
|
};
|
||||||
117
prerender/seo/sitemap.cjs
Normal file
117
prerender/seo/sitemap.cjs
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
const generateSitemapJsonLd = (allCategories = [], baseUrl, config) => {
|
||||||
|
|
||||||
|
const jsonLd = {
|
||||||
|
"@context": "https://schema.org/",
|
||||||
|
"@type": "WebPage",
|
||||||
|
name: "Sitemap",
|
||||||
|
url: `${baseUrl}/sitemap`,
|
||||||
|
description: `Sitemap - Übersicht aller Kategorien und Seiten auf ${config.siteName}`,
|
||||||
|
breadcrumb: {
|
||||||
|
"@type": "BreadcrumbList",
|
||||||
|
itemListElement: [
|
||||||
|
{
|
||||||
|
"@type": "ListItem",
|
||||||
|
position: 1,
|
||||||
|
name: "Home",
|
||||||
|
item: baseUrl,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "ListItem",
|
||||||
|
position: 2,
|
||||||
|
name: "Sitemap",
|
||||||
|
item: `${baseUrl}/sitemap`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add all categories as site navigation elements
|
||||||
|
if (allCategories && allCategories.length > 0) {
|
||||||
|
jsonLd.mainEntity = {
|
||||||
|
"@type": "SiteNavigationElement",
|
||||||
|
name: "Kategorien",
|
||||||
|
hasPart: allCategories.map((category) => ({
|
||||||
|
"@type": "SiteNavigationElement",
|
||||||
|
name: category.name,
|
||||||
|
url: `${baseUrl}/Kategorie/${category.seoName}`,
|
||||||
|
description: `${category.name} Kategorie`,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<script type="application/ld+json">${JSON.stringify(
|
||||||
|
jsonLd
|
||||||
|
)}</script>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateXmlSitemap = (allCategories = [], allProducts = [], baseUrl) => {
|
||||||
|
const currentDate = new Date().toISOString().split("T")[0]; // YYYY-MM-DD format
|
||||||
|
|
||||||
|
let sitemap = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Homepage
|
||||||
|
sitemap += ` <url>
|
||||||
|
<loc>${baseUrl}/</loc>
|
||||||
|
<lastmod>${currentDate}</lastmod>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Static pages
|
||||||
|
const staticPages = [
|
||||||
|
{ path: "/datenschutz", changefreq: "monthly", priority: "0.3" },
|
||||||
|
{ path: "/impressum", changefreq: "monthly", priority: "0.3" },
|
||||||
|
{ path: "/batteriegesetzhinweise", changefreq: "monthly", priority: "0.3" },
|
||||||
|
{ path: "/widerrufsrecht", changefreq: "monthly", priority: "0.3" },
|
||||||
|
{ path: "/sitemap", changefreq: "weekly", priority: "0.5" },
|
||||||
|
{ path: "/agb", changefreq: "monthly", priority: "0.3" },
|
||||||
|
{ path: "/404", changefreq: "monthly", priority: "0.1" },
|
||||||
|
{ path: "/Konfigurator", changefreq: "weekly", priority: "0.8" },
|
||||||
|
];
|
||||||
|
|
||||||
|
staticPages.forEach((page) => {
|
||||||
|
sitemap += ` <url>
|
||||||
|
<loc>${baseUrl}${page.path}</loc>
|
||||||
|
<lastmod>${currentDate}</lastmod>
|
||||||
|
<changefreq>${page.changefreq}</changefreq>
|
||||||
|
<priority>${page.priority}</priority>
|
||||||
|
</url>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Category pages
|
||||||
|
allCategories.forEach((category) => {
|
||||||
|
if (category.seoName) {
|
||||||
|
sitemap += ` <url>
|
||||||
|
<loc>${baseUrl}/Kategorie/${category.seoName}</loc>
|
||||||
|
<lastmod>${currentDate}</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Product pages
|
||||||
|
allProducts.forEach((productSeoName) => {
|
||||||
|
sitemap += ` <url>
|
||||||
|
<loc>${baseUrl}/Artikel/${productSeoName}</loc>
|
||||||
|
<lastmod>${currentDate}</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.6</priority>
|
||||||
|
</url>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
sitemap += `</urlset>`;
|
||||||
|
|
||||||
|
return sitemap;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
generateSitemapJsonLd,
|
||||||
|
generateXmlSitemap,
|
||||||
|
};
|
||||||
137
src/PrerenderSitemap.js
Normal file
137
src/PrerenderSitemap.js
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
const React = require('react');
|
||||||
|
const {
|
||||||
|
Box,
|
||||||
|
AppBar,
|
||||||
|
Toolbar,
|
||||||
|
Container,
|
||||||
|
Typography,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText
|
||||||
|
} = require('@mui/material');
|
||||||
|
const Footer = require('./components/Footer.js').default;
|
||||||
|
const { Logo, CategoryList } = require('./components/header/index.js');
|
||||||
|
const LegalPage = require('./pages/LegalPage.js').default;
|
||||||
|
|
||||||
|
const PrerenderSitemap = ({ categoryData }) => {
|
||||||
|
// Process category data to flatten the hierarchy
|
||||||
|
const collectAllCategories = (categoryNode, categories = [], level = 0) => {
|
||||||
|
if (!categoryNode) return categories;
|
||||||
|
|
||||||
|
// Add current category (skip root category 209)
|
||||||
|
if (categoryNode.id !== 209 && categoryNode.seoName) {
|
||||||
|
categories.push({
|
||||||
|
id: categoryNode.id,
|
||||||
|
name: categoryNode.name,
|
||||||
|
seoName: categoryNode.seoName,
|
||||||
|
level: level
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively add children
|
||||||
|
if (categoryNode.children) {
|
||||||
|
for (const child of categoryNode.children) {
|
||||||
|
collectAllCategories(child, categories, level + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return categories;
|
||||||
|
};
|
||||||
|
|
||||||
|
const categories = categoryData ? collectAllCategories(categoryData) : [];
|
||||||
|
|
||||||
|
const sitemapLinks = [
|
||||||
|
{ title: 'Startseite', url: '/' },
|
||||||
|
{ title: 'Mein Profil', url: '/profile' },
|
||||||
|
{ title: 'Datenschutz', url: '/datenschutz' },
|
||||||
|
{ title: 'AGB', url: '/agb' },
|
||||||
|
{ title: 'Impressum', url: '/impressum' },
|
||||||
|
{ title: 'Batteriegesetzhinweise', url: '/batteriegesetzhinweise' },
|
||||||
|
{ title: 'Widerrufsrecht', url: '/widerrufsrecht' },
|
||||||
|
{ title: 'Growbox Konfigurator', url: '/Konfigurator' },
|
||||||
|
{ title: 'API', url: '/api/', route: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const content = React.createElement(
|
||||||
|
React.Fragment,
|
||||||
|
null,
|
||||||
|
React.createElement(
|
||||||
|
Typography,
|
||||||
|
{ variant: 'body1', paragraph: true },
|
||||||
|
'Hier finden Sie eine Übersicht aller verfügbaren Seiten unserer Website.'
|
||||||
|
),
|
||||||
|
|
||||||
|
// Static site links
|
||||||
|
React.createElement(
|
||||||
|
Typography,
|
||||||
|
{ variant: 'h6', sx: { mt: 3, mb: 2, fontWeight: 'bold' } },
|
||||||
|
'Seiten'
|
||||||
|
),
|
||||||
|
React.createElement(
|
||||||
|
List,
|
||||||
|
null,
|
||||||
|
sitemapLinks.map((link) =>
|
||||||
|
React.createElement(
|
||||||
|
ListItem,
|
||||||
|
{
|
||||||
|
key: link.url,
|
||||||
|
button: true,
|
||||||
|
component: link.route === false ? 'a' : 'a',
|
||||||
|
href: link.url,
|
||||||
|
sx: {
|
||||||
|
py: 1,
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'divider'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
React.createElement(ListItemText, { primary: link.title })
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
// Category links
|
||||||
|
React.createElement(
|
||||||
|
Typography,
|
||||||
|
{ variant: 'h6', sx: { mt: 4, mb: 2, fontWeight: 'bold' } },
|
||||||
|
'Kategorien'
|
||||||
|
),
|
||||||
|
React.createElement(
|
||||||
|
List,
|
||||||
|
null,
|
||||||
|
categories.map((category) =>
|
||||||
|
React.createElement(
|
||||||
|
ListItem,
|
||||||
|
{
|
||||||
|
key: category.id,
|
||||||
|
button: true,
|
||||||
|
component: 'a',
|
||||||
|
href: `/Kategorie/${category.seoName}`,
|
||||||
|
sx: {
|
||||||
|
py: 1,
|
||||||
|
pl: 2 + (category.level * 2), // Indent based on category level
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'divider'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
React.createElement(
|
||||||
|
ListItemText,
|
||||||
|
{
|
||||||
|
primary: category.name,
|
||||||
|
sx: {
|
||||||
|
'& .MuiTypography-root': {
|
||||||
|
fontSize: category.level === 0 ? '1rem' : '0.9rem',
|
||||||
|
fontWeight: category.level === 0 ? 'bold' : 'normal',
|
||||||
|
color: category.level === 0 ? 'primary.main' : 'text.primary'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return React.createElement(LegalPage, { title: 'Sitemap', content: content });
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { default: PrerenderSitemap };
|
||||||
381
src/components/Mollie.js
Normal file
381
src/components/Mollie.js
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
import React, { Component, useState } from "react";
|
||||||
|
import { Button, Box, Typography, CircularProgress } from "@mui/material";
|
||||||
|
import config from "../config.js";
|
||||||
|
|
||||||
|
// Function to lazy load Mollie script
|
||||||
|
const loadMollie = () => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// Check if Mollie is already loaded
|
||||||
|
if (window.Mollie) {
|
||||||
|
resolve(window.Mollie);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create script element
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = 'https://js.mollie.com/v1/mollie.js';
|
||||||
|
script.async = true;
|
||||||
|
|
||||||
|
script.onload = () => {
|
||||||
|
if (window.Mollie) {
|
||||||
|
resolve(window.Mollie);
|
||||||
|
} else {
|
||||||
|
reject(new Error('Mollie failed to load'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
script.onerror = () => {
|
||||||
|
reject(new Error('Failed to load Mollie script'));
|
||||||
|
};
|
||||||
|
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const CheckoutForm = ({ mollie }) => {
|
||||||
|
const [errorMessage, setErrorMessage] = useState(null);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!mollie) return;
|
||||||
|
|
||||||
|
let mountedComponents = {
|
||||||
|
cardNumber: null,
|
||||||
|
cardHolder: null,
|
||||||
|
expiryDate: null,
|
||||||
|
verificationCode: null
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create Mollie components
|
||||||
|
const cardNumber = mollie.createComponent('cardNumber');
|
||||||
|
const cardHolder = mollie.createComponent('cardHolder');
|
||||||
|
const expiryDate = mollie.createComponent('expiryDate');
|
||||||
|
const verificationCode = mollie.createComponent('verificationCode');
|
||||||
|
|
||||||
|
// Store references for cleanup
|
||||||
|
mountedComponents = {
|
||||||
|
cardNumber,
|
||||||
|
cardHolder,
|
||||||
|
expiryDate,
|
||||||
|
verificationCode
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mount components
|
||||||
|
cardNumber.mount('#card-number');
|
||||||
|
cardHolder.mount('#card-holder');
|
||||||
|
expiryDate.mount('#expiry-date');
|
||||||
|
verificationCode.mount('#verification-code');
|
||||||
|
|
||||||
|
// Set up error handling
|
||||||
|
cardNumber.addEventListener('change', event => {
|
||||||
|
const errorElement = document.querySelector('#card-number-error');
|
||||||
|
if (errorElement) {
|
||||||
|
if (event.error && event.touched) {
|
||||||
|
errorElement.textContent = event.error;
|
||||||
|
} else {
|
||||||
|
errorElement.textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cardHolder.addEventListener('change', event => {
|
||||||
|
const errorElement = document.querySelector('#card-holder-error');
|
||||||
|
if (errorElement) {
|
||||||
|
if (event.error && event.touched) {
|
||||||
|
errorElement.textContent = event.error;
|
||||||
|
} else {
|
||||||
|
errorElement.textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expiryDate.addEventListener('change', event => {
|
||||||
|
const errorElement = document.querySelector('#expiry-date-error');
|
||||||
|
if (errorElement) {
|
||||||
|
if (event.error && event.touched) {
|
||||||
|
errorElement.textContent = event.error;
|
||||||
|
} else {
|
||||||
|
errorElement.textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
verificationCode.addEventListener('change', event => {
|
||||||
|
const errorElement = document.querySelector('#verification-code-error');
|
||||||
|
if (errorElement) {
|
||||||
|
if (event.error && event.touched) {
|
||||||
|
errorElement.textContent = event.error;
|
||||||
|
} else {
|
||||||
|
errorElement.textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Components are now mounted and ready
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating Mollie components:', error);
|
||||||
|
setErrorMessage('Failed to initialize payment form. Please try again.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup function
|
||||||
|
return () => {
|
||||||
|
try {
|
||||||
|
if (mountedComponents.cardNumber) mountedComponents.cardNumber.unmount();
|
||||||
|
if (mountedComponents.cardHolder) mountedComponents.cardHolder.unmount();
|
||||||
|
if (mountedComponents.expiryDate) mountedComponents.expiryDate.unmount();
|
||||||
|
if (mountedComponents.verificationCode) mountedComponents.verificationCode.unmount();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error cleaning up Mollie components:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [mollie]);
|
||||||
|
|
||||||
|
const handleSubmit = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!mollie || isSubmitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setErrorMessage(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { token, error } = await mollie.createToken();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setErrorMessage(error.message || 'Payment failed. Please try again.');
|
||||||
|
setIsSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
// Handle successful token creation
|
||||||
|
// Create a payment completion event similar to Stripe
|
||||||
|
const mollieCompletionData = {
|
||||||
|
mollieToken: token,
|
||||||
|
paymentMethod: 'mollie'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dispatch a custom event to notify the parent component
|
||||||
|
const completionEvent = new CustomEvent('molliePaymentComplete', {
|
||||||
|
detail: mollieCompletionData
|
||||||
|
});
|
||||||
|
window.dispatchEvent(completionEvent);
|
||||||
|
|
||||||
|
// For now, redirect to profile with completion data
|
||||||
|
const returnUrl = `${window.location.origin}/profile?complete&mollie_token=${token}`;
|
||||||
|
window.location.href = returnUrl;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating Mollie token:', error);
|
||||||
|
setErrorMessage('Payment failed. Please try again.');
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ maxWidth: 600, mx: 'auto', p: 3 }}>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Kreditkarte oder Sofortüberweisung
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<Typography variant="body2" gutterBottom>
|
||||||
|
Kartennummer
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
id="card-number"
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
border: '1px solid #e0e0e0',
|
||||||
|
borderRadius: 1,
|
||||||
|
minHeight: 40,
|
||||||
|
backgroundColor: '#fff'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
id="card-number-error"
|
||||||
|
variant="caption"
|
||||||
|
sx={{ color: 'error.main', minHeight: 16, display: 'block' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<Typography variant="body2" gutterBottom>
|
||||||
|
Karteninhaber
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
id="card-holder"
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
border: '1px solid #e0e0e0',
|
||||||
|
borderRadius: 1,
|
||||||
|
minHeight: 40,
|
||||||
|
backgroundColor: '#fff'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
id="card-holder-error"
|
||||||
|
variant="caption"
|
||||||
|
sx={{ color: 'error.main', minHeight: 16, display: 'block' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, mb: 3 }}>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Typography variant="body2" gutterBottom>
|
||||||
|
Ablaufdatum
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
id="expiry-date"
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
border: '1px solid #e0e0e0',
|
||||||
|
borderRadius: 1,
|
||||||
|
minHeight: 40,
|
||||||
|
backgroundColor: '#fff'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
id="expiry-date-error"
|
||||||
|
variant="caption"
|
||||||
|
sx={{ color: 'error.main', minHeight: 16, display: 'block' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Typography variant="body2" gutterBottom>
|
||||||
|
Sicherheitscode
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
id="verification-code"
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
border: '1px solid #e0e0e0',
|
||||||
|
borderRadius: 1,
|
||||||
|
minHeight: 40,
|
||||||
|
backgroundColor: '#fff'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
id="verification-code-error"
|
||||||
|
variant="caption"
|
||||||
|
sx={{ color: 'error.main', minHeight: 16, display: 'block' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
disabled={!mollie || isSubmitting}
|
||||||
|
type="submit"
|
||||||
|
fullWidth
|
||||||
|
sx={{
|
||||||
|
mt: 2,
|
||||||
|
backgroundColor: '#2e7d32',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: '#1b5e20'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<CircularProgress size={20} sx={{ mr: 1, color: 'white' }} />
|
||||||
|
Verarbeitung...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Bezahlung Abschließen'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{errorMessage && (
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{ color: 'error.main', mt: 2, textAlign: 'center' }}
|
||||||
|
>
|
||||||
|
{errorMessage}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
class Mollie extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
mollie: null,
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
this.molliePromise = loadMollie();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.molliePromise
|
||||||
|
.then((MollieClass) => {
|
||||||
|
try {
|
||||||
|
// Initialize Mollie with profile key
|
||||||
|
const mollie = MollieClass(config.mollieProfileKey, {
|
||||||
|
locale: 'de_DE',
|
||||||
|
testmode: true // Set to false for production
|
||||||
|
});
|
||||||
|
this.setState({ mollie, loading: false });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error initializing Mollie:', error);
|
||||||
|
this.setState({
|
||||||
|
error: 'Failed to initialize payment system. Please try again.',
|
||||||
|
loading: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Error loading Mollie:', error);
|
||||||
|
this.setState({
|
||||||
|
error: 'Failed to load payment system. Please try again.',
|
||||||
|
loading: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { mollie, loading, error } = this.state;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||||
|
<CircularProgress sx={{ color: '#2e7d32' }} />
|
||||||
|
<Typography variant="body1" sx={{ mt: 2 }}>
|
||||||
|
Zahlungskomponente wird geladen...
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||||
|
<Typography variant="body1" sx={{ color: 'error.main' }}>
|
||||||
|
{error}
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
sx={{ mt: 2 }}
|
||||||
|
>
|
||||||
|
Seite neu laden
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <CheckoutForm mollie={mollie} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Mollie;
|
||||||
@@ -122,13 +122,17 @@ class ProductList extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderPagination = (pages, page) => {
|
renderPagination = (pages, page) => {
|
||||||
|
// Make pagination invisible when there are zero products to avoid layout shifts
|
||||||
|
const hasProducts = this.props.products.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
height: 64,
|
height: 64,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'left',
|
justifyContent: 'left',
|
||||||
width: '100%'
|
width: '100%',
|
||||||
|
visibility: hasProducts ? 'visible' : 'hidden'
|
||||||
}}>
|
}}>
|
||||||
{(this.state.itemsPerPage==='all')?null:
|
{(this.state.itemsPerPage==='all')?null:
|
||||||
<Pagination
|
<Pagination
|
||||||
@@ -156,6 +160,57 @@ class ProductList extends Component {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if filters are active
|
||||||
|
hasActiveFilters = () => {
|
||||||
|
return (
|
||||||
|
(this.props.activeAttributeFilters && this.props.activeAttributeFilters.length > 0) ||
|
||||||
|
(this.props.activeManufacturerFilters && this.props.activeManufacturerFilters.length > 0) ||
|
||||||
|
(this.props.activeAvailabilityFilters && this.props.activeAvailabilityFilters.length > 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render message when no products found but filters are active
|
||||||
|
renderNoProductsMessage = () => {
|
||||||
|
const hasFiltersActive = this.hasActiveFilters();
|
||||||
|
const hasUnfilteredProducts = this.props.totalProductCount > 0;
|
||||||
|
|
||||||
|
if (this.props.products.length === 0 && hasUnfilteredProducts && hasFiltersActive) {
|
||||||
|
return (
|
||||||
|
<Box sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
py: 4,
|
||||||
|
px: 2
|
||||||
|
}}>
|
||||||
|
<Typography variant="h6" color="text.secondary" sx={{ textAlign: 'center' }}>
|
||||||
|
Entferne Filter um Produkte zu sehen
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function for correct pluralization
|
||||||
|
getProductCountText = () => {
|
||||||
|
const filteredCount = this.props.products.length;
|
||||||
|
const totalCount = this.props.totalProductCount;
|
||||||
|
const isFiltered = totalCount !== filteredCount;
|
||||||
|
|
||||||
|
if (!isFiltered) {
|
||||||
|
// No filters applied
|
||||||
|
if (filteredCount === 0) return "0 Produkte";
|
||||||
|
if (filteredCount === 1) return "1 Produkt";
|
||||||
|
return `${filteredCount} Produkte`;
|
||||||
|
} else {
|
||||||
|
// Filters applied
|
||||||
|
if (totalCount === 0) return "0 Produkte";
|
||||||
|
if (totalCount === 1) return `${filteredCount} von 1 Produkt`;
|
||||||
|
return `${filteredCount} von ${totalCount} Produkten`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
//console.log('products',this.props.activeAttributeFilters,this.props.activeManufacturerFilters,window.currentSearchQuery,this.state.sortBy);
|
//console.log('products',this.props.activeAttributeFilters,this.props.activeManufacturerFilters,window.currentSearchQuery,this.state.sortBy);
|
||||||
|
|
||||||
@@ -352,13 +407,8 @@ class ProductList extends Component {
|
|||||||
display: { xs: 'block', sm: 'none' },
|
display: { xs: 'block', sm: 'none' },
|
||||||
ml: 1
|
ml: 1
|
||||||
}}>
|
}}>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '0.75rem' }}>
|
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '0.75rem', whiteSpace: 'nowrap' }}>
|
||||||
{
|
{this.getProductCountText()}
|
||||||
this.props.totalProductCount==this.props.products.length && this.props.totalProductCount>0 ?
|
|
||||||
`${this.props.totalProductCount} Produkte`
|
|
||||||
:
|
|
||||||
`${this.props.products.length} von ${this.props.totalProductCount} Produkte`
|
|
||||||
}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -381,18 +431,14 @@ class ProductList extends Component {
|
|||||||
{/*this.props.dataType == 'category' && (<>Kategorie: {this.props.dataParam}</>)}*/}
|
{/*this.props.dataType == 'category' && (<>Kategorie: {this.props.dataParam}</>)}*/}
|
||||||
{this.props.dataType == 'search' && (<>Suchergebnisse für: "{this.props.dataParam}"</>)}
|
{this.props.dataType == 'search' && (<>Suchergebnisse für: "{this.props.dataParam}"</>)}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>
|
||||||
{
|
{this.getProductCountText()}
|
||||||
this.props.totalProductCount==this.props.products.length && this.props.totalProductCount>0 ?
|
|
||||||
`${this.props.totalProductCount} Produkte`
|
|
||||||
:
|
|
||||||
`${this.props.products.length} von ${this.props.totalProductCount} Produkte`
|
|
||||||
}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Grid container spacing={{ xs: 0, sm: 2 }}>
|
<Grid container spacing={{ xs: 0, sm: 2 }}>
|
||||||
|
{this.renderNoProductsMessage()}
|
||||||
{products.map((product, index) => (
|
{products.map((product, index) => (
|
||||||
<Grid
|
<Grid
|
||||||
key={product.id}
|
key={product.id}
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ class CartTab extends Component {
|
|||||||
showStripePayment: false,
|
showStripePayment: false,
|
||||||
StripeComponent: null,
|
StripeComponent: null,
|
||||||
isLoadingStripe: false,
|
isLoadingStripe: false,
|
||||||
|
showMolliePayment: false,
|
||||||
|
MollieComponent: null,
|
||||||
|
isLoadingMollie: false,
|
||||||
showPaymentConfirmation: false,
|
showPaymentConfirmation: false,
|
||||||
orderCompleted: false,
|
orderCompleted: false,
|
||||||
originalCartItems: []
|
originalCartItems: []
|
||||||
@@ -116,7 +119,7 @@ class CartTab extends Component {
|
|||||||
// Determine payment method - respect constraints
|
// Determine payment method - respect constraints
|
||||||
let prefillPaymentMethod = template.payment_method || "wire";
|
let prefillPaymentMethod = template.payment_method || "wire";
|
||||||
const paymentMethodMap = {
|
const paymentMethodMap = {
|
||||||
"credit_card": "stripe",
|
"credit_card": "mollie",//stripe
|
||||||
"bank_transfer": "wire",
|
"bank_transfer": "wire",
|
||||||
"cash_on_delivery": "onDelivery",
|
"cash_on_delivery": "onDelivery",
|
||||||
"cash": "cash"
|
"cash": "cash"
|
||||||
@@ -319,6 +322,27 @@ class CartTab extends Component {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
loadMollieComponent = async () => {
|
||||||
|
this.setState({ isLoadingMollie: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { default: Mollie } = await import("../Mollie.js");
|
||||||
|
this.setState({
|
||||||
|
MollieComponent: Mollie,
|
||||||
|
showMolliePayment: true,
|
||||||
|
isCompletingOrder: false,
|
||||||
|
isLoadingMollie: false,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load Mollie component:", error);
|
||||||
|
this.setState({
|
||||||
|
isCompletingOrder: false,
|
||||||
|
isLoadingMollie: false,
|
||||||
|
completionError: "Failed to load payment component. Please try again.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
handleCompleteOrder = () => {
|
handleCompleteOrder = () => {
|
||||||
this.setState({ completionError: null }); // Clear previous errors
|
this.setState({ completionError: null }); // Clear previous errors
|
||||||
|
|
||||||
@@ -363,6 +387,25 @@ class CartTab extends Component {
|
|||||||
this.orderService.createStripeIntent(totalAmount, this.loadStripeComponent);
|
this.orderService.createStripeIntent(totalAmount, this.loadStripeComponent);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Handle Mollie payment differently
|
||||||
|
if (paymentMethod === "mollie") {
|
||||||
|
// Store the cart items used for Mollie payment in sessionStorage for later reference
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem('molliePaymentCart', JSON.stringify(cartItems));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to store Mollie payment cart:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total amount for Mollie
|
||||||
|
const subtotal = cartItems.reduce(
|
||||||
|
(total, item) => total + item.price * item.quantity,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const totalAmount = Math.round((subtotal + deliveryCost) * 100); // Convert to cents
|
||||||
|
|
||||||
|
this.orderService.createMollieIntent(totalAmount, this.loadMollieComponent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle regular orders
|
// Handle regular orders
|
||||||
const orderData = {
|
const orderData = {
|
||||||
@@ -398,6 +441,9 @@ class CartTab extends Component {
|
|||||||
showStripePayment,
|
showStripePayment,
|
||||||
StripeComponent,
|
StripeComponent,
|
||||||
isLoadingStripe,
|
isLoadingStripe,
|
||||||
|
showMolliePayment,
|
||||||
|
MollieComponent,
|
||||||
|
isLoadingMollie,
|
||||||
showPaymentConfirmation,
|
showPaymentConfirmation,
|
||||||
orderCompleted,
|
orderCompleted,
|
||||||
} = this.state;
|
} = this.state;
|
||||||
@@ -434,7 +480,7 @@ class CartTab extends Component {
|
|||||||
<CartDropdown
|
<CartDropdown
|
||||||
cartItems={cartItems}
|
cartItems={cartItems}
|
||||||
socket={this.context.socket}
|
socket={this.context.socket}
|
||||||
showDetailedSummary={showStripePayment}
|
showDetailedSummary={showStripePayment || showMolliePayment}
|
||||||
deliveryMethod={deliveryMethod}
|
deliveryMethod={deliveryMethod}
|
||||||
deliveryCost={deliveryCost}
|
deliveryCost={deliveryCost}
|
||||||
/>
|
/>
|
||||||
@@ -442,7 +488,7 @@ class CartTab extends Component {
|
|||||||
|
|
||||||
{cartItems.length > 0 && (
|
{cartItems.length > 0 && (
|
||||||
<Box sx={{ mt: 3 }}>
|
<Box sx={{ mt: 3 }}>
|
||||||
{isLoadingStripe ? (
|
{isLoadingStripe || isLoadingMollie ? (
|
||||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||||
<Typography variant="body1">
|
<Typography variant="body1">
|
||||||
Zahlungskomponente wird geladen...
|
Zahlungskomponente wird geladen...
|
||||||
@@ -468,9 +514,29 @@ class CartTab extends Component {
|
|||||||
</Box>
|
</Box>
|
||||||
<StripeComponent clientSecret={stripeClientSecret} />
|
<StripeComponent clientSecret={stripeClientSecret} />
|
||||||
</>
|
</>
|
||||||
|
) : showMolliePayment && MollieComponent ? (
|
||||||
|
<>
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => this.setState({ showMolliePayment: false })}
|
||||||
|
sx={{
|
||||||
|
color: '#2e7d32',
|
||||||
|
borderColor: '#2e7d32',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'rgba(46, 125, 50, 0.04)',
|
||||||
|
borderColor: '#1b5e20'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
← Zurück zur Bestellung
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
<MollieComponent />
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<CheckoutForm
|
<CheckoutForm
|
||||||
paymentMethod={paymentMethod}
|
paymentMethod={paymentMethod}
|
||||||
invoiceAddress={invoiceAddress}
|
invoiceAddress={invoiceAddress}
|
||||||
deliveryAddress={deliveryAddress}
|
deliveryAddress={deliveryAddress}
|
||||||
useSameAddress={useSameAddress}
|
useSameAddress={useSameAddress}
|
||||||
@@ -478,7 +544,7 @@ class CartTab extends Component {
|
|||||||
addressFormErrors={addressFormErrors}
|
addressFormErrors={addressFormErrors}
|
||||||
termsAccepted={termsAccepted}
|
termsAccepted={termsAccepted}
|
||||||
note={note}
|
note={note}
|
||||||
deliveryMethod={deliveryMethod}
|
deliveryMethod={deliveryMethod}
|
||||||
hasStecklinge={hasStecklinge}
|
hasStecklinge={hasStecklinge}
|
||||||
isPickupOnly={isPickupOnly}
|
isPickupOnly={isPickupOnly}
|
||||||
deliveryCost={deliveryCost}
|
deliveryCost={deliveryCost}
|
||||||
|
|||||||
@@ -270,6 +270,10 @@ class OrderProcessingService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Create Mollie payment intent
|
||||||
|
createMollieIntent(totalAmount, loadMollieComponent) {
|
||||||
|
loadMollieComponent();
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate delivery cost
|
// Calculate delivery cost
|
||||||
getDeliveryCost() {
|
getDeliveryCost() {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeli
|
|||||||
// Handle delivery method changes - auto-switch to stripe when DHL/DPD is selected
|
// Handle delivery method changes - auto-switch to stripe when DHL/DPD is selected
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if ((deliveryMethod === "DHL" || deliveryMethod === "DPD") && paymentMethod === "cash") {
|
if ((deliveryMethod === "DHL" || deliveryMethod === "DPD") && paymentMethod === "cash") {
|
||||||
handlePaymentMethodChange({ target: { value: "stripe" } });
|
handlePaymentMethodChange({ target: { value: "mollie" } });
|
||||||
}
|
}
|
||||||
}, [deliveryMethod, paymentMethod, handlePaymentMethodChange]);
|
}, [deliveryMethod, paymentMethod, handlePaymentMethodChange]);
|
||||||
|
|
||||||
@@ -42,8 +42,22 @@ const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeli
|
|||||||
description: "Bezahlen Sie per Banküberweisung",
|
description: "Bezahlen Sie per Banküberweisung",
|
||||||
disabled: totalAmount === 0,
|
disabled: totalAmount === 0,
|
||||||
},
|
},
|
||||||
{
|
/*{
|
||||||
id: "stripe",
|
id: "stripe",
|
||||||
|
name: "Karte oder Sofortüberweisung (Stripe)",
|
||||||
|
description: totalAmount < 0.50 && totalAmount > 0
|
||||||
|
? "Bezahlen Sie per Karte oder Sofortüberweisung (Mindestbetrag: 0,50 €)"
|
||||||
|
: "Bezahlen Sie per Karte oder Sofortüberweisung",
|
||||||
|
disabled: totalAmount < 0.50 || (deliveryMethod !== "DHL" && deliveryMethod !== "DPD" && deliveryMethod !== "Abholung"),
|
||||||
|
icons: [
|
||||||
|
"/assets/images/giropay.png",
|
||||||
|
"/assets/images/maestro.png",
|
||||||
|
"/assets/images/mastercard.png",
|
||||||
|
"/assets/images/visa_electron.png",
|
||||||
|
],
|
||||||
|
},*/
|
||||||
|
{
|
||||||
|
id: "mollie",
|
||||||
name: "Karte oder Sofortüberweisung",
|
name: "Karte oder Sofortüberweisung",
|
||||||
description: totalAmount < 0.50 && totalAmount > 0
|
description: totalAmount < 0.50 && totalAmount > 0
|
||||||
? "Bezahlen Sie per Karte oder Sofortüberweisung (Mindestbetrag: 0,50 €)"
|
? "Bezahlen Sie per Karte oder Sofortüberweisung (Mindestbetrag: 0,50 €)"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const config = {
|
|||||||
apiBaseUrl: "",
|
apiBaseUrl: "",
|
||||||
googleClientId: "928121624463-jbgfdlgem22scs1k9c87ucg4ffvaik6o.apps.googleusercontent.com",
|
googleClientId: "928121624463-jbgfdlgem22scs1k9c87ucg4ffvaik6o.apps.googleusercontent.com",
|
||||||
stripePublishableKey: "pk_test_51R7lltRtpe3h1vwJzIrDb5bcEigTLBHrtqj9SiPX7FOEATSuD6oJmKc8xpNp49ShpGJZb2GShHIUqj4zlSIz4olj00ipOuOAnu",
|
stripePublishableKey: "pk_test_51R7lltRtpe3h1vwJzIrDb5bcEigTLBHrtqj9SiPX7FOEATSuD6oJmKc8xpNp49ShpGJZb2GShHIUqj4zlSIz4olj00ipOuOAnu",
|
||||||
|
mollieProfileKey: "pfl_AtcRTimCff",
|
||||||
|
|
||||||
// SEO and Business Information
|
// SEO and Business Information
|
||||||
siteName: "Growheads.de",
|
siteName: "Growheads.de",
|
||||||
|
|||||||
@@ -33,10 +33,42 @@ const collectAllCategories = (categoryNode, categories = [], level = 0) => {
|
|||||||
return categories;
|
return categories;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check for cached data - handle both browser and prerender environments
|
||||||
|
const getProductCache = () => {
|
||||||
|
if (typeof window !== "undefined" && window.productCache) {
|
||||||
|
return window.productCache;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof global !== "undefined" &&
|
||||||
|
global.window &&
|
||||||
|
global.window.productCache
|
||||||
|
) {
|
||||||
|
return global.window.productCache;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize categories from cache if available (for prerendering)
|
||||||
|
const initializeCategories = () => {
|
||||||
|
const productCache = getProductCache();
|
||||||
|
|
||||||
|
if (productCache && productCache["categoryTree_209"]) {
|
||||||
|
const cached = productCache["categoryTree_209"];
|
||||||
|
const cacheAge = Date.now() - cached.timestamp;
|
||||||
|
const tenMinutes = 10 * 60 * 1000;
|
||||||
|
if (cacheAge < tenMinutes && cached.categoryTree) {
|
||||||
|
return collectAllCategories(cached.categoryTree);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
const Sitemap = () => {
|
const Sitemap = () => {
|
||||||
const [categories, setCategories] = useState([]);
|
// Initialize categories and loading state together
|
||||||
const [loading, setLoading] = useState(true);
|
const initialCategories = initializeCategories();
|
||||||
const {socket} = useContext(SocketContext);
|
const [categories, setCategories] = useState(initialCategories);
|
||||||
|
const [loading, setLoading] = useState(initialCategories.length === 0);
|
||||||
|
const context = useContext(SocketContext);
|
||||||
|
|
||||||
|
|
||||||
const sitemapLinks = [
|
const sitemapLinks = [
|
||||||
@@ -53,8 +85,14 @@ const Sitemap = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchCategories = () => {
|
const fetchCategories = () => {
|
||||||
// Try cache first
|
// If we already have categories from prerendering, we're done
|
||||||
if (window.productCache && window.productCache['categoryTree_209']) {
|
if (categories.length > 0) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try cache first (for browser environment)
|
||||||
|
if (typeof window !== "undefined" && window.productCache && window.productCache['categoryTree_209']) {
|
||||||
const cached = window.productCache['categoryTree_209'];
|
const cached = window.productCache['categoryTree_209'];
|
||||||
const cacheAge = Date.now() - cached.timestamp;
|
const cacheAge = Date.now() - cached.timestamp;
|
||||||
const tenMinutes = 10 * 60 * 1000;
|
const tenMinutes = 10 * 60 * 1000;
|
||||||
@@ -66,9 +104,9 @@ const Sitemap = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, fetch from socket if available
|
// Otherwise, fetch from socket if available (only in browser)
|
||||||
if (socket) {
|
if (context && context.socket && context.socket.connected && typeof window !== "undefined") {
|
||||||
socket.emit('categoryList', { categoryId: 209 }, (response) => {
|
context.socket.emit('categoryList', { categoryId: 209 }, (response) => {
|
||||||
if (response && response.categoryTree) {
|
if (response && response.categoryTree) {
|
||||||
// Store in cache
|
// Store in cache
|
||||||
try {
|
try {
|
||||||
@@ -95,7 +133,7 @@ const Sitemap = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchCategories();
|
fetchCategories();
|
||||||
}, [socket]);
|
}, [context, categories.length]);
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
|
|||||||
Reference in New Issue
Block a user