Compare commits

...

44 Commits

Author SHA1 Message Date
sebseb7
dbd5df28f8 feat: Wrap product carousel title in a React Router Link and add a ChevronRight icon. 2025-12-14 10:01:37 +01:00
sebseb7
57515bfb85 feat: Refine i18n content across multiple locales and improve LLM SEO data processing for catalog generation. 2025-12-14 09:47:51 +01:00
sebseb7
9df5642a6e refactor: reimplement category page display with a recursive tree structure and add configurator image. 2025-12-13 02:14:20 +01:00
sebseb7
a50dd086c3 feat: Include 'Kategorien' in Nginx location regex for HTML content. 2025-12-06 21:02:22 +01:00
sebseb7
e88370ff3e feat: add Categories page with refined layout and translation support 2025-12-06 14:29:33 +01:00
sebseb7
5d3e0832fe doc 2025-12-01 13:11:24 +01:00
sebseb7
3347ba2754 Add missing auth translations and update components to use i18n keys
- Added new translation keys to de/auth.js:
  - resetPassword section (title, button, success, invalidToken, error, emailSent, emailError)
  - errors section (fillAllFields, invalidEmail, passwordsNotMatch, passwordsNotMatchShort, enterEmail, loginFailed, registerFailed, googleLoginFailed, emailExists)
  - success section (registerComplete)
  - newPassword, backToHome keys

- Updated ResetPassword.js to use translation keys instead of hardcoded German strings
- Updated LoginComponent.js to use translation keys instead of hardcoded German strings
- translate-i18n.js generated translations for other languages
2025-12-01 13:02:03 +01:00
sebseb7
013a38ca98 fix: update caniuse-lite version and enhance SPA routing for resetPassword
- Updated caniuse-lite to version 1.0.30001757 in package-lock.json for improved compatibility.
- Added functionality to copy index.html to the resetPassword directory for better SPA routing in production environments.
2025-12-01 12:50:41 +01:00
sebseb7
2d6c8ff25f feat(Orders): add tracking shipment link and update translations
- Implemented a tracking shipment link in the OrdersTab component for DHL deliveries, enhancing user experience by allowing direct access to shipment tracking.
- Added 'trackShipment' translation key across multiple languages to support the new feature.
- Updated existing translations for consistency and improved localization in the orders module.
2025-11-29 14:05:59 +01:00
sebseb7
d2ac8d3fc1 feat(Orders): update order status translations and colors
- Refactored order status translations to use English keys for 'new', 'shipped', and 'delivered'.
- Updated corresponding status colors to maintain consistency in the UI.
- Adjusted the display logic to reflect the new status keys in the OrdersTab component.
2025-11-29 13:45:39 +01:00
sebseb7
8928b3f283 feat(Orders): add 'paid' status and update translations across multiple languages
- Introduced 'paid' status to the orders system, enhancing order tracking capabilities.
- Updated translations for 'paid' status in various languages including German, Spanish, French, and more.
- Adjusted related UI components to reflect the new status and ensure consistent user experience across the application.
2025-11-29 13:21:35 +01:00
sebseb7
87db7ba3ea feat(Content, ProductDetail, SearchBar): enhance product handling with translation support
- Updated Content component to process products using translated attributes, improving localization.
- Modified ProductDetailPage to utilize translatedProduct for similar products.
- Adjusted SearchBar to provide suggestions based on translated products, enhancing user experience across components.
2025-11-23 07:53:37 +01:00
sebseb7
766fef2796 feat(ProductDetail): enhance attribute handling with translation support
- Updated ProductDetailPage to utilize translated attributes if available, improving localization.
- Cached both product and attribute data for better performance.
- Adjusted state management to reflect the use of translated attributes in the component.
2025-11-22 12:48:40 +01:00
sebseb7
a08c90a521 fix 2025-11-22 10:12:41 +01:00
sebseb7
10d60d5827 fix 2025-11-22 10:02:59 +01:00
sebseb7
905eee57d5 feat(Translation): add kitConfig.js for improved localization in GrowTentKonfigurator
- Updated the translation model by adding kitConfig.js to the list of translation files.
- Enhanced the GrowTentKonfigurator component to utilize translation functions for various UI texts, improving localization support throughout the configuration process.
2025-11-22 09:59:47 +01:00
sebseb7
3389a9b66c feat(Translation): enhance product dialogs and update translation model
- Added new translation files for product dialogs to support additional languages.
- Refactored various components to utilize translation functions for error messages, labels, and placeholders, enhancing localization support.
2025-11-22 09:43:51 +01:00
sebseb7
d63c385a97 feat(Content): pass categoryName prop to ProductFilters for improved filtering
- Added categoryName prop to the ProductFilters component to have it translated
2025-11-22 08:49:58 +01:00
sebseb7
1b51da69a9 feat(Images): update image URLs to AVIF format in SEO components
- Changed image file extensions from JPG to AVIF in category, feeds, and product SEO components to enhance performance and reduce file sizes.
- Ensured consistent image handling across the application by updating relevant image paths.
2025-11-21 13:21:58 +01:00
sebseb7
da81479d9b refactor(App): update Box component to use 'main' role for improved semantics
- Changed Box component in App.js and PrerenderAppContent.js to use 'main' as the component role, enhancing accessibility and semantic structure of the application.
2025-11-21 11:57:39 +01:00
sebseb7
d8678e261d feat(Images): update image handling to AVIF format across components
- Changed image file extensions from JPG to AVIF in data-fetching, product, category, and image components for improved performance and reduced file sizes.
- Updated image blob creation to reflect the new AVIF format in various components, ensuring consistency in image handling throughout the application.
2025-11-21 11:10:50 +01:00
sebseb7
ef91e50aa5 feat(Images): convert images to AVIF format for improved performance
- Updated image references in various components and configuration files to use AVIF format instead of PNG and JPG.
- Modified the build process to include a script for converting images to AVIF, enhancing loading times and reducing file sizes.
- Ensured consistency across the application by updating image paths in the footer, main layout, and content components.
2025-11-20 11:43:07 +01:00
sebseb7
061bf5ff17 feat(SEO): add price validity date to category JSON-LD
- Introduced a priceValidUntil field in the category JSON-LD to indicate the validity period of product prices, set to three months from the current date.
- This enhancement improves the structured data for SEO, providing clearer information about pricing timelines.
2025-11-20 11:13:48 +01:00
sebseb7
0b915db9eb refactor(ProductDetailPage): improve layout and button functionality
- Adjusted layout to ensure a minimum height for attribute images and action buttons.
- Enhanced button functionality by updating the availability request button and ensuring proper alignment of action buttons.
- Cleaned up conditional rendering for attribute images to streamline the component's structure.
2025-11-20 07:31:11 +01:00
sebseb7
43e67ee4c4 feat(Context): integrate Product and Category context providers into App
- Wrapped AppContent with ProductContextProvider and CategoryContextProvider to manage product and category states.
- Added TitleUpdater component for dynamic title management.
- Enhanced Content and ProductDetailPage components to utilize the new context for setting and clearing current product and category states.
- Updated ProductDetailWithSocket to pass setCurrentProduct function from context.
2025-11-19 09:25:21 +01:00
sebseb7
b599e6424b refactor(CategoryList): simplify layout styles for mobile and desktop views
- Removed conditional styles for flexWrap and overflowX, setting them to "wrap" and "visible" respectively for consistency across devices.
- Cleaned up unused scrollbar styles to streamline the component's CSS.
2025-11-19 08:27:58 +01:00
sebseb7
1ddbafaa51 u 2025-11-19 07:00:33 +01:00
sebseb7
e6faa63219 fix(SEO): update skipCategoryIds to include additional categories
- Expanded the skipCategoryIds array to include new category IDs: 924, 923, 922, 921, 916, 278, 259, and 258.
- This change ensures that the specified categories are excluded from product XML generation.
2025-11-18 08:35:19 +01:00
sebseb7
277edea15e feat(SEO): enhance product and category JSON-LD with shipping and return policy details
- Added merchant return policy and shipping details to the JSON-LD for both product and category schemas.
- Updated delivery method price to reflect the new shipping rate of 5.90 €.
- Improved localization files to include loading messages for products and updated various translations for consistency.
2025-11-18 08:18:51 +01:00
sebseb7
b267b9132a feat(ProductDetailPage): implement embedded product rendering and loading
- Add functionality to render embedded products from <product> tags in the product description.
- Introduce state management for embedded products and their images, including loading and error handling.
- Update localization files to include loading messages for embedded products.
2025-11-17 10:16:16 +01:00
sebseb7
c82a6a8f62 u 2025-11-17 09:19:57 +01:00
sebseb7
6b0ab27a3a u 2025-11-17 09:07:07 +01:00
sebseb7
289baec8cf u 2025-11-17 09:06:47 +01:00
sebseb7
11ba2db893 u 2025-11-17 08:43:57 +01:00
sebseb7
521cc307a3 u 2025-11-17 07:53:04 +01:00
sebseb7
d397930f2c u 2025-11-17 07:49:06 +01:00
sebseb7
8e43eaaede u 2025-11-17 07:38:16 +01:00
sebseb7
13c63db643 u 2025-11-17 07:26:46 +01:00
sebseb7
5b12dad435 u 2025-11-17 07:21:23 +01:00
sebseb7
f20628f71c feat(ProductDetailPage): add manufacturer button for search navigation
- Enhance the ProductDetailPage by introducing a button for the manufacturer that allows users to navigate to search results for that manufacturer.
- Improve user interaction with styling adjustments for the button, ensuring a seamless experience when accessing related products.
2025-11-16 08:19:08 +01:00
sebseb7
f9437a79e6 typo 2025-11-16 08:15:23 +01:00
sebseb7
f665e7c5f8 feat(i18n): add 'searchResultsFor' translation key across multiple languages
- Introduce a new 'searchResultsFor' translation key in various language files to enhance search functionality.
- Update language files for Arabic, Bulgarian, Czech, German, Greek, English, Spanish, French, Croatian, Hungarian, Italian, Polish, Romanian, Russian, Slovak, Slovenian, Albanian, Serbian, Swedish, Turkish, Ukrainian, and Chinese to include this key.
- Ensure consistency in language context for search results across the application.
2025-11-16 08:08:33 +01:00
sebseb7
4f5a44dc7d feat(i18n): add 'more' translation key across multiple languages and enhance SearchBar
- Introduce a new 'more' translation key in various language files to improve internationalization support.
- Update SearchBar component to include an IconButton for additional actions, enhancing user interaction.
- Ensure consistency in language context by adding the 'more' key in Arabic, Bulgarian, Czech, German, Greek, English, Spanish, French, Croatian, Hungarian, Italian, Polish, Romanian, Russian, Slovak, Slovenian, Albanian, Serbian, Swedish, Turkish, Ukrainian, and Chinese.
2025-11-16 07:58:08 +01:00
sebseb7
bf2e5f56ce fix(i18n): update SearchBar placeholder for translation support
- Change SearchBar placeholder text to utilize translation function for improved internationalization.
- Ensure consistency in language context across the application by integrating translation keys.
2025-11-16 07:44:39 +01:00
308 changed files with 8764 additions and 1661 deletions

View File

@@ -0,0 +1,5 @@
---
alwaysApply: false
---
never run your own dev sever, it can be restarted with ```pm2 restart dev_seedheads_fron```
get logoutput lioke this ```pm2 log dev_seedheads_fron --lines 20 --nostream```

63
.gitignore vendored
View File

@@ -1,64 +1,3 @@
# dependencies
/node_modules
/.pnp
.pnp.js
.cursor/
# testing
/coverage
# production
/build
/dist
/public/index.prerender.html
/public/assets/images/prod*.jpg
/public/assets/images/cat*.jpg
/public/prerender.css
/public/Artikel/*
/public/Kategorie/*
/public/agb
/public/batteriegesetzhinweise
/public/datenschutz
/public/impressum
/public/sitemap
/public/widerrufsrecht
/public/robots.txt
/public/sitemap.xml
/public/index.prerender.html
/public/Konfigurator
/public/profile
/public/404
/public/products.xml
/public/llms*
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
.hintrc
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor directories and files
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Local configuration
src/config.local.js
taxonomy-with-ids.de-DE*
# Local development notes
dev-notes.md
dev-notes.local.md
logs/
/logs

154
docs/nginx.conf Normal file
View File

@@ -0,0 +1,154 @@
server {
client_max_body_size 64M;
listen 443 ssl;
http2 on;
server_name example.de;
ssl_certificate /etc/letsencrypt/live/example.de/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.de/privkey.pem;
gzip on;
gzip_comp_level 6;
gzip_min_length 256;
gzip_vary on;
gzip_proxied any;
gzip_types
text/css
text/javascript
application/javascript
application/x-javascript
application/json
application/xml
image/svg+xml;
index index.html;
root /example/dist;
error_log logs/error.log info;
access_log logs/access.log combined;
location /socket.io/ {
proxy_pass http://localhost:9303/socket.io/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_connect_timeout 3600s;
proxy_send_timeout 3600s;
proxy_read_timeout 3600s;
send_timeout 3600s;
proxy_buffering off;
proxy_cache off;
keepalive_timeout 65;
keepalive_requests 100;
}
location /api/ {
proxy_pass http://localhost:9303/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header User-Agent $http_user_agent;
proxy_set_header Content-Type $content_type;
proxy_set_header Content-Length $content_length;
proxy_set_header X-API-Key $http_x_api_key;
proxy_connect_timeout 30s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
proxy_buffering off;
client_max_body_size 10M;
}
location ^~ /Kategorie/ {
types {}
default_type text/html;
}
location ^~ /Artikel/ {
types {}
default_type text/html;
}
location = /sitemap.xml {
types {}
default_type application/xml;
}
location ~ ^/(datenschutz|impressum|batteriegesetzhinweise|widerrufsrecht|sitemap|agb|Kategorien|Konfigurator|404|profile|resetPassword|thc-test|filiale|aktionen|presseverleih|payment/success)(/|$) {
types {}
default_type text/html;
}
location = /404 {
error_page 404 =404 /404-big.html;
return 404;
}
location = /404-big.html {
internal;
alias /home/seb/src/growheads_de/dist/404;
default_type text/html;
}
error_page 404 /404.html;
location = /404.html {
internal;
default_type text/html;
return 404 '<!doctype html><html><body>
<script>
if (!navigator.userAgent.includes("bot")) { location.href="/404"; }
</script>
</body></html>';
}
location ~* \.(js|css)\?.*$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Vary Accept-Encoding;
}
location ~* \.(js|css)$ {
if ($uri ~ "\.[a-f0-9]{7,}\.(js|css)$") {
expires 1y;
add_header Cache-Control "public, immutable";
break;
}
expires 1d;
add_header Cache-Control "public";
add_header Vary Accept-Encoding;
}
location ~* \.(ttf|otf|woff|woff2|eot)$ {
expires 1y;
add_header Cache-Control "public";
add_header Access-Control-Allow-Origin "*";
}
location ~* \.(jpg|jpeg|png|gif|ico|svg|webp)$ {
expires 1y;
add_header Cache-Control "public";
add_header Vary Accept-Encoding;
}
location = /prerender.css {
expires 1w;
add_header Cache-Control "public";
add_header Vary Accept-Encoding;
}
location /assets/ {
expires 1y;
add_header Cache-Control "public";
add_header Vary Accept-Encoding;
}
}

View File

@@ -0,0 +1,380 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
import OpenAI from 'openai';
// Configuration
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
const DIST_DIR = './dist';
const OUTPUT_CSV = './category-descriptions.csv';
// Model configuration
const MODEL = 'gpt-5.1';
// Initialize OpenAI client
const openai = new OpenAI({
apiKey: OPENAI_API_KEY,
});
// System prompt for generating SEO descriptions
const SEO_DESCRIPTION_PROMPT = `You are given a list of products from a specific category. Create a SEO-friendly description for that category that would be suitable for a product catalog page.
Requirements:
- Write in German
- Make it SEO-optimized with relevant keywords
The product list format is:
First line: categoryName,categoryId
Subsequent lines: articleNumber,price,productName,shortDescription
Generate a compelling category description based on this product data.`;
// Function to find all *-list.txt files in dist directory
function findListFiles() {
try {
const files = fs.readdirSync(DIST_DIR);
return files.filter(file => file.endsWith('-list.txt'));
} catch (error) {
console.error('Error reading dist directory:', error.message);
return [];
}
}
// Function to read a list file and extract category info
function readListFile(filePath) {
try {
const content = fs.readFileSync(filePath, 'utf8');
const lines = content.trim().split('\n');
if (lines.length < 1) {
throw new Error('File is empty');
}
// Parse first line: categoryName,categoryId,[subcategoryIds]
const firstLine = lines[0];
const parts = firstLine.split(',');
if (parts.length < 2) {
throw new Error('Invalid first line format');
}
const categoryName = parts[0].replace(/^"|"$/g, '');
const categoryId = parts[1].replace(/^"|"$/g, '');
// Parse subcategory IDs from array notation [id1,id2,...]
let subcategoryIds = [];
if (parts.length >= 3) {
const subcatString = parts.slice(2).join(','); // Handle case where array spans multiple comma-separated values
const match = subcatString.match(/\[(.*?)\]/);
if (match && match[1]) {
subcategoryIds = match[1].split(',').map(id => id.trim()).filter(id => id);
}
}
if (!categoryName || !categoryId) {
throw new Error('Invalid first line format');
}
return {
categoryName: categoryName,
categoryId: categoryId,
subcategoryIds: subcategoryIds,
content: content
};
} catch (error) {
console.error(`Error reading ${filePath}:`, error.message);
return null;
}
}
// Function to build processing order based on dependencies
function buildProcessingOrder(categories) {
const categoryMap = new Map();
const processed = new Set();
const processingOrder = [];
// Create a map of categoryId -> category data
categories.forEach(cat => {
categoryMap.set(cat.categoryId, cat);
});
// Function to check if all subcategories are processed
function canProcess(category) {
return category.subcategoryIds.every(subId => processed.has(subId));
}
// Keep processing until all categories are done
while (processingOrder.length < categories.length) {
const beforeLength = processingOrder.length;
// Find categories that can be processed now
for (const category of categories) {
if (!processed.has(category.categoryId) && canProcess(category)) {
processingOrder.push(category);
processed.add(category.categoryId);
}
}
// If no progress was made, there might be a circular dependency or missing category
if (processingOrder.length === beforeLength) {
console.error('⚠️ Unable to resolve all category dependencies');
// Add remaining categories anyway
for (const category of categories) {
if (!processed.has(category.categoryId)) {
console.warn(` Adding ${category.categoryName} (${category.categoryId}) despite unresolved dependencies`);
processingOrder.push(category);
processed.add(category.categoryId);
}
}
break;
}
}
return processingOrder;
}
// Function to generate SEO description using OpenAI
async function generateSEODescription(productListContent, categoryName, categoryId, subcategoryDescriptions = []) {
try {
console.log(`🔄 Generating SEO description for category: ${categoryName} (ID: ${categoryId})`);
// Prepend subcategory information if present
let fullContent = productListContent;
if (subcategoryDescriptions.length > 0) {
const subcatInfo = 'This category has the following subcategories:\n' +
subcategoryDescriptions.map(sub => `- "${sub.name}": ${sub.description}`).join('\n') +
'\n\n';
fullContent = subcatInfo + productListContent;
}
const response = await openai.responses.create({
model: "gpt-5.1",
input: [
{
"role": "developer",
"content": [
{
"type": "input_text",
"text": SEO_DESCRIPTION_PROMPT
}
]
},
{
"role": "user",
"content": [
{
"type": "input_text",
"text": fullContent
}
]
}
],
text: {
"format": {
"type": "json_schema",
"name": "descriptions",
"strict": true,
"schema": {
"type": "object",
"properties": {
"seo_description": {
"type": "string",
"description": "A concise description intended for SEO purposes. 155 characters"
},
"long_description": {
"type": "string",
"description": "A comprehensive description, 2-5 Sentences"
}
},
"required": [
"seo_description",
"long_description"
],
"additionalProperties": false
}
},
"verbosity": "medium"
},
reasoning: {
"effort": "none"
}
});
const description = response.output_text;
console.log(`✅ Generated description for ${categoryName}`);
return description;
} catch (error) {
console.error(`❌ Error generating description for ${categoryName}:`, error.message);
return `Error generating description: ${error.message}`;
}
}
// Function to write CSV file
function writeCSV(results) {
try {
const csvHeader = 'categoryId,listFileName,seoDescription\n';
const csvRows = results.map(result =>
`"${result.categoryId}","${result.listFileName}","${result.description.replace(/"/g, '""')}"`
).join('\n');
const csvContent = csvHeader + csvRows;
fs.writeFileSync(OUTPUT_CSV, csvContent, 'utf8');
console.log(`✅ CSV file written: ${OUTPUT_CSV}`);
console.log(`📊 Processed ${results.length} categories`);
} catch (error) {
console.error('Error writing CSV file:', error.message);
}
}
// Main execution function
async function main() {
console.log('🚀 Starting category description generation...');
// Check if OpenAI API key is set
if (!OPENAI_API_KEY) {
console.error('❌ OPENAI_API_KEY environment variable is not set');
console.log('Please set your OpenAI API key: export OPENAI_API_KEY="your-api-key-here"');
process.exit(1);
}
// Check if dist directory exists
if (!fs.existsSync(DIST_DIR)) {
console.error(`❌ Dist directory not found: ${DIST_DIR}`);
process.exit(1);
}
// Find all list files
const listFiles = findListFiles();
if (listFiles.length === 0) {
console.log('⚠️ No *-list.txt files found in dist directory');
console.log('💡 Make sure to run the prerender script first to generate the list files');
process.exit(1);
}
console.log(`📂 Found ${listFiles.length} list files to process`);
// Step 1: Read all list files and extract category information
console.log('📖 Reading all category files...');
const categories = [];
const fileDataMap = new Map(); // Map categoryId -> fileData
for (const listFile of listFiles) {
const filePath = path.join(DIST_DIR, listFile);
const fileData = readListFile(filePath);
if (!fileData) {
console.log(`⚠️ Skipping ${listFile} due to read error`);
continue;
}
categories.push({
categoryId: fileData.categoryId,
categoryName: fileData.categoryName,
subcategoryIds: fileData.subcategoryIds,
listFileName: listFile
});
fileDataMap.set(fileData.categoryId, {
...fileData,
listFileName: listFile
});
}
console.log(`✅ Read ${categories.length} categories`);
// Step 2: Build processing order based on dependencies
console.log('🔨 Building processing order based on category hierarchy...');
const processingOrder = buildProcessingOrder(categories);
const leafCategories = processingOrder.filter(cat => cat.subcategoryIds.length === 0);
const parentCategories = processingOrder.filter(cat => cat.subcategoryIds.length > 0);
console.log(` 📄 ${leafCategories.length} leaf categories (no subcategories)`);
console.log(` 📁 ${parentCategories.length} parent categories (with subcategories)`);
// Step 3: Process categories in order
const results = [];
const generatedDescriptions = new Map(); // Map categoryId -> {seo_description, long_description}
for (const category of processingOrder) {
const fileData = fileDataMap.get(category.categoryId);
if (!fileData) {
console.log(`⚠️ Skipping ${category.categoryName} - no file data found`);
continue;
}
// Gather subcategory descriptions
const subcategoryDescriptions = [];
for (const subId of category.subcategoryIds) {
const subDesc = generatedDescriptions.get(subId);
const subCategory = categories.find(cat => cat.categoryId === subId);
if (subDesc && subCategory) {
subcategoryDescriptions.push({
name: subCategory.categoryName,
description: subDesc.long_description || subDesc.seo_description
});
} else if (subCategory) {
console.warn(` ⚠️ Subcategory ${subCategory.categoryName} (${subId}) not yet processed`);
}
}
// Generate SEO description
const descriptionJSON = await generateSEODescription(
fileData.content,
fileData.categoryName,
fileData.categoryId,
subcategoryDescriptions
);
// Parse the JSON response
let parsedDescription;
try {
parsedDescription = JSON.parse(descriptionJSON);
generatedDescriptions.set(category.categoryId, parsedDescription);
} catch (error) {
console.error(` ❌ Failed to parse JSON for ${category.categoryName}:`, error.message);
parsedDescription = { seo_description: descriptionJSON, long_description: descriptionJSON };
generatedDescriptions.set(category.categoryId, parsedDescription);
}
// Store result
results.push({
categoryId: category.categoryId,
listFileName: fileData.listFileName,
description: parsedDescription.seo_description || descriptionJSON
});
// Add delay to avoid rate limiting
await new Promise(resolve => setTimeout(resolve, 1000));
}
// Write CSV output
if (results.length > 0) {
writeCSV(results);
console.log('🎉 Category description generation completed successfully!');
} else {
console.error('❌ No results to write - all files failed processing');
process.exit(1);
}
}
// Run the script
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch(error => {
console.error('❌ Script failed:', error.message);
process.exit(1);
});
}
export {
findListFiles,
readListFile,
buildProcessingOrder,
generateSEODescription,
writeCSV
};

1949
out Normal file

File diff suppressed because it is too large Load Diff

6
package-lock.json generated
View File

@@ -4554,9 +4554,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001727",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz",
"integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==",
"version": "1.0.30001757",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz",
"integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==",
"dev": true,
"funding": [
{

View File

@@ -7,7 +7,7 @@
"start": "cross-env NODE_OPTIONS=\"--no-deprecation\" webpack serve --progress --mode development --no-open",
"start:seedheads": "cross-env PROXY_TARGET=https://seedheads.de NODE_OPTIONS=\"--no-deprecation\" webpack serve --progress --mode development --no-open",
"prod": "webpack serve --progress --mode production --no-client-overlay --no-client --no-web-socket-server --no-open --no-live-reload --no-hot --compress --no-devtool",
"build:client": "cross-env NODE_ENV=production webpack --progress --mode production && shx cp dist/index.html dist/index_template.html",
"build:client": "node scripts/convert-images-to-avif.cjs && cross-env NODE_ENV=production webpack --progress --mode production && shx cp dist/index.html dist/index_template.html",
"build": "npm run build:client",
"analyze": "cross-env ANALYZE=true NODE_ENV=production webpack --progress --mode production",
"lint": "eslint src/**/*.{js,jsx}",

View File

@@ -28,7 +28,7 @@ class CategoryService {
const cacheKey = `${categoryId}_${language}`;
return null;
}
async get(categoryId, language = "de") {
const cacheKey = `${categoryId}_${language}`;
return null;
@@ -136,6 +136,7 @@ const {
generateLlmsTxt,
generateCategoryLlmsTxt,
generateAllCategoryLlmsPages,
generateCategoryProductList,
} = require("./prerender/seo.cjs");
const {
fetchCategoryProducts,
@@ -158,6 +159,7 @@ const Batteriegesetzhinweise =
const Widerrufsrecht = require("./src/pages/Widerrufsrecht.js").default;
const Sitemap = require("./src/pages/Sitemap.js").default;
const PrerenderSitemap = require("./src/PrerenderSitemap.js").default;
const PrerenderCategoriesPage = require("./src/PrerenderCategoriesPage.js").default;
const AGB = require("./src/pages/AGB.js").default;
const NotFound404 = require("./src/pages/NotFound404.js").default;
@@ -188,7 +190,7 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback,
try {
const productDetails = await fetchProductDetails(workerSocket, productSeoName);
const actualSeoName = productDetails.product.seoName || productSeoName;
const productComponent = React.createElement(PrerenderProduct, {
productData: productDetails,
@@ -204,7 +206,7 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback,
}, shopConfig.baseUrl, shopConfig);
// Get category info from categoryMap if available
const categoryInfo = productDetails.product.categoryId ? categoryMap[productDetails.product.categoryId] : null;
const jsonLdScript = generateProductJsonLd({
...productDetails.product,
seoName: actualSeoName,
@@ -233,9 +235,9 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback,
success,
workerId
};
results.push(result);
// Call progress callback if provided
if (progressCallback) {
progressCallback(result);
@@ -251,14 +253,14 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback,
error: error.message,
workerId
};
results.push(result);
// Call progress callback if provided
if (progressCallback) {
progressCallback(result);
}
setTimeout(processNextProduct, 25);
}
};
@@ -290,16 +292,16 @@ const renderProductsInParallel = async (allProductsArray, maxWorkers, totalProdu
const barLength = 30;
const filledLength = Math.round((barLength * current) / total);
const bar = '█'.repeat(filledLength) + '░'.repeat(barLength - filledLength);
// @note Single line progress update to prevent flickering
const truncatedName = productName ? ` - ${productName.substring(0, 25)}${productName.length > 25 ? '...' : ''}` : '';
// Build worker stats on one line
let workerStats = '';
for (let i = 0; i < Math.min(maxWorkers, 8); i++) { // Limit to 8 workers to fit on screen
workerStats += `W${i + 1}:${workerCounts[i]}/${workerSuccess[i]} `;
}
// Single line update without complex cursor movements
process.stdout.write(`\r [${bar}] ${percentage}% (${current}/${total})${truncatedName}\n ${workerStats}${current < total ? '\x1b[1A' : '\n'}`);
};
@@ -307,26 +309,26 @@ const renderProductsInParallel = async (allProductsArray, maxWorkers, totalProdu
// Split products among workers
const productsPerWorker = Math.ceil(allProductsArray.length / maxWorkers);
const workerPromises = [];
// Initial progress bar
updateProgressBar(0, totalProducts);
for (let i = 0; i < maxWorkers; i++) {
const start = i * productsPerWorker;
const end = Math.min(start + productsPerWorker, allProductsArray.length);
const productsForWorker = allProductsArray.slice(start, end);
if (productsForWorker.length > 0) {
const promise = renderProductWorker(productsForWorker, i + 1, (result) => {
// Progress callback - called each time a product is completed
completedProducts++;
progressResults.push(result);
lastProductName = result.productName;
// Update per-worker counters
const workerIndex = result.workerId - 1; // Convert to 0-based index
workerCounts[workerIndex]++;
if (result.success) {
totalSuccessCount++;
workerSuccess[workerIndex]++;
@@ -334,11 +336,11 @@ const renderProductsInParallel = async (allProductsArray, maxWorkers, totalProdu
// Don't log errors immediately to avoid interfering with progress bar
// Errors will be shown after completion
}
// Update progress bar with worker stats
updateProgressBar(completedProducts, totalProducts, lastProductName);
}, categoryMap);
workerPromises.push(promise);
}
}
@@ -346,10 +348,10 @@ const renderProductsInParallel = async (allProductsArray, maxWorkers, totalProdu
try {
// Wait for all workers to complete
await Promise.all(workerPromises);
// Ensure final progress update
updateProgressBar(totalProducts, totalProducts, lastProductName);
// Show any errors that occurred
const errorResults = progressResults.filter(r => !r.success && r.error);
if (errorResults.length > 0) {
@@ -358,7 +360,7 @@ const renderProductsInParallel = async (allProductsArray, maxWorkers, totalProdu
console.log(` - ${result.productSeoName}: ${result.error}`);
});
}
return totalSuccessCount;
} catch (error) {
console.error('Error in parallel rendering:', error);
@@ -421,6 +423,14 @@ const renderApp = async (categoryData, socket) => {
process.exit(1);
}
// Copy index.html to resetPassword (no file extension) for SPA routing
if (config.isProduction) {
const indexPath = path.resolve(__dirname, config.outputDir, "index.html");
const resetPasswordPath = path.resolve(__dirname, config.outputDir, "resetPassword");
fs.copyFileSync(indexPath, resetPasswordPath);
console.log(`✅ Copied index.html to ${resetPasswordPath}`);
}
// Render static pages
console.log("\n📄 Rendering static pages...");
@@ -456,6 +466,13 @@ const renderApp = async (categoryData, socket) => {
description: "Sitemap page",
needsCategoryData: true,
},
{
component: PrerenderCategoriesPage,
path: "/Kategorien",
filename: "Kategorien",
description: "Categories page",
needsCategoryData: true,
},
{ component: AGB, path: "/agb", filename: "agb", description: "AGB page" },
{ component: NotFound404, path: "/404", filename: "404", description: "404 Not Found page" },
{
@@ -550,8 +567,7 @@ const renderApp = async (categoryData, socket) => {
try {
productData = await fetchCategoryProducts(socket, category.id);
console.log(
` ✅ Found ${
productData.products ? productData.products.length : 0
` ✅ Found ${productData.products ? productData.products.length : 0
} products`
);
@@ -635,7 +651,7 @@ const renderApp = async (categoryData, socket) => {
const totalProducts = allProducts.size;
const numCPUs = os.cpus().length;
const maxWorkers = Math.min(numCPUs, totalProducts, 8); // Cap at 8 workers to avoid overwhelming the server
// Create category map for breadcrumbs
const categoryMap = {};
allCategories.forEach(category => {
@@ -644,11 +660,11 @@ const renderApp = async (categoryData, socket) => {
seoName: category.seoName
};
});
console.log(
`\n📦 Rendering ${totalProducts} individual product pages using ${maxWorkers} parallel workers...`
);
const productPagesRendered = await renderProductsInParallel(
Array.from(allProducts),
maxWorkers,
@@ -700,21 +716,21 @@ const renderApp = async (categoryData, socket) => {
// Generate products.xml (Google Shopping feed) in parallel to sitemap.xml
if (allProductsData.length > 0) {
console.log("\n🛒 Generating products.xml (Google Shopping feed)...");
try {
const productsXml = generateProductsXml(allProductsData, shopConfig.baseUrl, shopConfig);
const productsXmlPath = path.resolve(__dirname, config.outputDir, "products.xml");
// Write with explicit UTF-8 encoding
fs.writeFileSync(productsXmlPath, productsXml, { encoding: 'utf8' });
console.log(`✅ products.xml generated: ${productsXmlPath}`);
console.log(` - Products included: ${allProductsData.length}`);
console.log(` - Format: Google Shopping RSS 2.0 feed`);
console.log(` - Encoding: UTF-8`);
console.log(` - Includes: title, description, price, availability, images`);
// Verify the file is valid UTF-8
try {
const verification = fs.readFileSync(productsXmlPath, 'utf8');
@@ -722,18 +738,18 @@ const renderApp = async (categoryData, socket) => {
} catch (verifyError) {
console.log(` - File verification: ⚠️ ${verifyError.message}`);
}
// Validate XML against Google Shopping schema
try {
const ProductsXmlValidator = require('./scripts/validate-products-xml.cjs');
const validator = new ProductsXmlValidator(productsXmlPath);
const validationResults = await validator.validate();
if (validationResults.valid) {
console.log(` - Schema validation: ✅ Valid Google Shopping RSS 2.0`);
} else {
console.log(` - Schema validation: ⚠️ ${validationResults.summary.errorCount} errors, ${validationResults.summary.warningCount} warnings`);
// Show first few errors for quick debugging
if (validationResults.errors.length > 0) {
console.log(` - First error: ${validationResults.errors[0].message}`);
@@ -742,7 +758,7 @@ const renderApp = async (categoryData, socket) => {
} catch (validationError) {
console.log(` - Schema validation: ⚠️ Validation failed: ${validationError.message}`);
}
} catch (error) {
console.error(`❌ Error generating products.xml: ${error.message}`);
console.log("\n⚠ Skipping products.xml generation due to errors");
@@ -753,18 +769,18 @@ const renderApp = async (categoryData, socket) => {
// Generate llms.txt (LLM-friendly markdown sitemap) and category-specific files
console.log("\n🤖 Generating LLM sitemap files...");
try {
// Generate main llms.txt overview file
const llmsTxt = generateLlmsTxt(allCategories, allProductsData, shopConfig.baseUrl, shopConfig);
const llmsTxtPath = path.resolve(__dirname, config.outputDir, "llms.txt");
fs.writeFileSync(llmsTxtPath, llmsTxt, { encoding: 'utf8' });
console.log(`✅ Main llms.txt generated: ${llmsTxtPath}`);
console.log(` - Static pages: 8 pages`);
console.log(` - Categories: ${allCategories.length} with links to detailed files`);
console.log(` - File size: ${Math.round(llmsTxt.length / 1024)}KB`);
// Group products by category for category-specific files
const productsByCategory = {};
allProductsData.forEach((product) => {
@@ -774,47 +790,53 @@ const renderApp = async (categoryData, socket) => {
}
productsByCategory[categoryId].push(product);
});
// Generate category-specific LLM files with pagination
let categoryFilesGenerated = 0;
let totalCategoryProducts = 0;
let totalPaginatedFiles = 0;
for (const category of allCategories) {
if (category.seoName) {
const categoryProducts = productsByCategory[category.id] || [];
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
// Generate all paginated files for this category
const categoryPages = generateAllCategoryLlmsPages(category, categoryProducts, shopConfig.baseUrl, shopConfig);
// 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++;
}
// Generate and write the product list file for this category
const productList = generateCategoryProductList(category, categoryProducts);
const listPath = path.resolve(__dirname, config.outputDir, productList.fileName);
fs.writeFileSync(listPath, productList.content, { encoding: 'utf8' });
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)`);
console.log(` 📋 ${productList.fileName} - ${productList.productCount} products (${Math.round(productList.content.length / 1024)}KB)`);
categoryFilesGenerated++;
totalCategoryProducts += categoryProducts.length;
}
}
console.log(` 📄 Total paginated files generated: ${totalPaginatedFiles}`);
console.log(` 📦 Total products across all categories: ${totalCategoryProducts}`);
try {
const verification = fs.readFileSync(llmsTxtPath, 'utf8');
console.log(` - File verification: ✅ All files valid UTF-8`);
} catch (verifyError) {
console.log(` - File verification: ⚠️ ${verifyError.message}`);
}
} catch (error) {
console.error(`❌ Error generating LLM sitemap files: ${error.message}`);
console.log("\n⚠ Skipping LLM sitemap generation due to errors");
@@ -834,7 +856,7 @@ const fetchCategoryDataAndRender = () => {
const socket = io(socketUrl, {
path: "/socket.io/",
transports: [ "websocket"],
transports: ["websocket"],
reconnection: false,
timeout: 10000,
});

View File

@@ -152,7 +152,7 @@ const saveProductImages = async (socket, products, categoryName, outputDir) => {
"public",
"assets",
"images",
"sh.png"
"sh.avif"
);
// Ensure assets/images directory exists
@@ -185,7 +185,7 @@ const saveProductImages = async (socket, products, categoryName, outputDir) => {
if (imageIds.length > 0) {
// Process first image for each product
const bildId = parseInt(imageIds[0]);
const estimatedFilename = `prod${bildId}.jpg`; // We'll generate a filename based on the ID
const estimatedFilename = `prod${bildId}.avif`; // We'll generate a filename based on the ID
const imagePath = path.join(assetsPath, estimatedFilename);
@@ -231,12 +231,12 @@ const saveProductImages = async (socket, products, categoryName, outputDir) => {
opacity: 0.3,
},
])
.jpeg() // Ensure output is JPEG
.avif() // Ensure output is AVIF
.toBuffer();
fs.writeFileSync(imagePath, processedImageBuffer);
console.log(
` ✅ Applied centered inverted sh.png overlay to ${estimatedFilename}`
` ✅ Applied centered inverted sh.avif overlay to ${estimatedFilename}`
);
} catch (overlayError) {
console.log(
@@ -281,7 +281,7 @@ const saveCategoryImages = async (socket, categories, outputDir) => {
// Debug: Log categories that will be processed
console.log(" 🔍 Categories to process:");
categories.forEach((cat, index) => {
console.log(` ${index + 1}. "${cat.name}" (ID: ${cat.id}) -> cat${cat.id}.jpg`);
console.log(` ${index + 1}. "${cat.name}" (ID: ${cat.id}) -> cat${cat.id}.avif`);
});
const assetsPath = path.resolve(
@@ -308,7 +308,7 @@ const saveCategoryImages = async (socket, categories, outputDir) => {
for (const category of categories) {
categoriesProcessed++;
const estimatedFilename = `cat${category.id}.jpg`; // Use 'cat' prefix with category ID
const estimatedFilename = `cat${category.id}.avif`; // Use 'cat' prefix with category ID
const imagePath = path.join(assetsPath, estimatedFilename);
// Skip if image already exists

View File

@@ -247,10 +247,6 @@ const renderPage = (
if (!suppressLogs) {
console.log(`${description} prerendered to ${outputPath}`);
console.log(` - Markup length: ${renderedMarkup.length} characters`);
console.log(` - CSS rules: ${Object.keys(cache.inserted).length}`);
console.log(` - Total inlined CSS: ${Math.round(combinedCss.length / 1024)}KB`);
console.log(` - Render-blocking CSS eliminated: ${inlinedCss ? 'YES' : 'NO'}`);
console.log(` - Fallback content saved to window.__PRERENDER_FALLBACK__`);
if (productDetailCacheScript) {
console.log(` - Product detail cache populated for SPA hydration`);
}

View File

@@ -1,6 +1,19 @@
const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
// Category IDs to skip (seeds, plants, headshop items)
const skipCategoryIds = [689, 706, 709, 711, 714, 748, 749, 896, 710, 924, 923, 922, 921, 916, 278, 259, 258];
// Check if category ID is in skip list
if (category.id && skipCategoryIds.includes(parseInt(category.id))) {
return '';
}
const categoryUrl = `${baseUrl}/Kategorie/${category.seoName}`;
// Calculate price valid date (current date + 3 months)
const priceValidDate = new Date();
priceValidDate.setMonth(priceValidDate.getMonth() + 3);
const priceValidUntil = priceValidDate.toISOString().split("T")[0];
const jsonLd = {
"@context": "https://schema.org/",
"@type": "CollectionPage",
@@ -42,7 +55,7 @@ const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
product.pictureList && product.pictureList.trim()
? `${baseUrl}/assets/images/prod${product.pictureList
.split(",")[0]
.trim()}.jpg`
.trim()}.avif`
: `${baseUrl}/assets/images/nopicture.jpg`,
description: product.description
? product.description.replace(/<[^>]*>/g, "").substring(0, 200)
@@ -57,6 +70,7 @@ const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
url: `${baseUrl}/Artikel/${product.seoName}`,
price: product.price && !isNaN(product.price) ? product.price.toString() : "0.00",
priceCurrency: config.currency,
priceValidUntil: priceValidUntil,
availability: product.available
? "https://schema.org/InStock"
: "https://schema.org/OutOfStock",
@@ -65,6 +79,41 @@ const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
name: config.brandName,
},
itemCondition: "https://schema.org/NewCondition",
hasMerchantReturnPolicy: {
"@type": "MerchantReturnPolicy",
applicableCountry: "DE",
returnPolicyCategory: "https://schema.org/MerchantReturnFiniteReturnWindow",
merchantReturnDays: 14,
returnMethod: "https://schema.org/ReturnByMail",
returnFees: "https://schema.org/FreeReturn",
},
shippingDetails: {
"@type": "OfferShippingDetails",
shippingRate: {
"@type": "MonetaryAmount",
value: 5.90,
currency: "EUR",
},
shippingDestination: {
"@type": "DefinedRegion",
addressCountry: "DE",
},
deliveryTime: {
"@type": "ShippingDeliveryTime",
handlingTime: {
"@type": "QuantitativeValue",
minValue: 0,
maxValue: 1,
unitCode: "DAY",
},
transitTime: {
"@type": "QuantitativeValue",
minValue: 2,
maxValue: 3,
unitCode: "DAY",
},
},
},
},
},
})),

View File

@@ -122,6 +122,7 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
689: "543561", // Seeds (Saatgut)
706: "543561", // Stecklinge (cuttings) ebenfalls Pflanzen/Saatgut
376: "2802", // Grow-Sets Pflanzen- & Kräuteranbausets
915: "2802", // Grow-Sets > Set-Zubehör Pflanzen- & Kräuteranbausets
// Headshop & Accessories
709: "4082", // Headshop Rauchzubehör
@@ -129,8 +130,13 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
714: "4082", // Headshop > Bongs > Zubehör Rauchzubehör
748: "4082", // Headshop > Bongs > Köpfe Rauchzubehör
749: "4082", // Headshop > Bongs > Chillums/Diffusoren/Kupplungen Rauchzubehör
921: "4082", // Headshop > Pfeifen Rauchzubehör
924: "4082", // Headshop > Dabbing Rauchzubehör
896: "3151", // Headshop > Vaporizer Vaporizer
923: "4082", // Headshop > Papes & Blunts Rauchzubehör
710: "5109", // Headshop > Grinder Gewürzmühlen (Küchenhelfer)
922: "4082", // Headshop > Aktivkohlefilter & Tips Rauchzubehör
916: "4082", // Headshop > Rollen & Bauen Rauchzubehör
// Measuring & Packaging
186: "5631", // Headshop > Wiegen & Verpacken Aufbewahrung/Zubehör
@@ -140,6 +146,7 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
407: "3561", // Headshop > Grove Bags Aufbewahrungsbehälter
449: "1496", // Headshop > Cliptütchen Lebensmittelverpackungsmaterial
539: "3110", // Headshop > Gläser & Dosen Lebensmittelbehälter
920: "581", // Headshop > Räucherstäbchen Raumdüfte (Home Fragrances)
// Lighting & Equipment
694: "3006", // Lampen Lampen (Beleuchtung)
@@ -248,9 +255,9 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
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>
<title>${config.descriptions.de.short}</title>
<link>${baseUrl}</link>
<description>${config.descriptions.short}</description>
<description>${config.descriptions.de.short}</description>
<lastBuildDate>${currentDate}</lastBuildDate>
<language>de-DE</language>`;
@@ -299,19 +306,43 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
let processedCount = 0;
let skippedCount = 0;
// Track products with missing data for logging
// Track skip reasons with counts and product lists
const skipReasons = {
noProductOrSeoName: { count: 0, products: [] },
excludedCategory: { count: 0, products: [] },
excludedTermsTitle: { count: 0, products: [] },
excludedTermsDescription: { count: 0, products: [] },
missingGTIN: { count: 0, products: [] },
invalidGTINChecksum: { count: 0, products: [] },
missingPicture: { count: 0, products: [] },
missingWeight: { count: 0, products: [] },
insufficientDescription: { count: 0, products: [] },
nameTooShort: { count: 0, products: [] },
outOfStock: { count: 0, products: [] },
zeroPriceOrInvalid: { count: 0, products: [] },
processingError: { count: 0, products: [] }
};
// Legacy arrays for backward compatibility
const productsNeedingWeight = [];
const productsNeedingDescription = [];
// Category IDs to skip (seeds, plants, headshop items)
const skipCategoryIds = [689, 706, 709, 711, 714, 748, 749, 896, 710];
const skipCategoryIds = [689, 706, 709, 711, 714, 748, 749, 896, 710, 924, 923, 922, 921, 916, 278, 259, 258];
// Add each product as an item
allProductsData.forEach((product, index) => {
try {
// Skip products without essential data
if (!product || !product.seoName) {
skippedCount++;
skipReasons.noProductOrSeoName.count++;
skipReasons.noProductOrSeoName.products.push({
id: product?.articleNumber || 'N/A',
name: product?.name || 'N/A',
url: product?.seoName ? `/Artikel/${product.seoName}` : 'N/A'
});
return;
}
@@ -319,12 +350,21 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
const productCategoryId = product.categoryId || product.category_id || product.category || null;
if (productCategoryId && skipCategoryIds.includes(parseInt(productCategoryId))) {
skippedCount++;
skipReasons.excludedCategory.count++;
skipReasons.excludedCategory.products.push({
id: product.articleNumber || product.seoName,
name: product.name || 'N/A',
categoryId: productCategoryId,
url: `/Artikel/${product.seoName}`
});
return;
}
// Skip products with excluded terms in title or description
const productTitle = (product.name || "").toLowerCase();
const productDescription = (product.description || "").toLowerCase();
// Get description early so we can check it for excluded terms
const productDescription = product.kurzBeschreibung || product.description || '';
const excludedTerms = {
title: ['canna', 'hash', 'marijuana', 'marihuana'],
@@ -332,20 +372,42 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
};
// Check title for excluded terms
if (excludedTerms.title.some(term => productTitle.includes(term))) {
const excludedTitleTerm = excludedTerms.title.find(term => productTitle.includes(term));
if (excludedTitleTerm) {
skippedCount++;
skipReasons.excludedTermsTitle.count++;
skipReasons.excludedTermsTitle.products.push({
id: product.articleNumber || product.seoName,
name: product.name || 'N/A',
term: excludedTitleTerm,
url: `/Artikel/${product.seoName}`
});
return;
}
// Check description for excluded terms
if (excludedTerms.description.some(term => productDescription.includes(term))) {
const excludedDescTerm = excludedTerms.description.find(term => productDescription.toLowerCase().includes(term));
if (excludedDescTerm) {
skippedCount++;
skipReasons.excludedTermsDescription.count++;
skipReasons.excludedTermsDescription.products.push({
id: product.articleNumber || product.seoName,
name: product.name || 'N/A',
term: excludedDescTerm,
url: `/Artikel/${product.seoName}`
});
return;
}
// Skip products without GTIN or with invalid GTIN
if (!product.gtin || !product.gtin.toString().trim()) {
skippedCount++;
skipReasons.missingGTIN.count++;
skipReasons.missingGTIN.products.push({
id: product.articleNumber || product.seoName,
name: product.name || 'N/A',
url: `/Artikel/${product.seoName}`
});
return;
}
@@ -360,15 +422,33 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
const length = digits.length;
let sum = 0;
for (let i = 0; i < length - 1; i++) {
// Even/odd multiplier depends on GTIN length
let multiplier = 1;
if (length === 8) {
multiplier = (i % 2 === 0) ? 3 : 1;
} else {
multiplier = ((length - i) % 2 === 0) ? 3 : 1;
if (length === 8) {
// EAN-8: positions 0-6, check digit at 7
// Multipliers: 3,1,3,1,3,1,3 for positions 0-6
for (let i = 0; i < 7; i++) {
const multiplier = (i % 2 === 0) ? 3 : 1;
sum += digits[i] * multiplier;
}
} else if (length === 12) {
// UPC-A: positions 0-10, check digit at 11
// Multipliers: 3,1,3,1,3,1,3,1,3,1,3 for positions 0-10
for (let i = 0; i < 11; i++) {
const multiplier = (i % 2 === 0) ? 3 : 1;
sum += digits[i] * multiplier;
}
} else if (length === 13) {
// EAN-13: positions 0-11, check digit at 12
// Multipliers: 1,3,1,3,1,3,1,3,1,3,1,3 for positions 0-11
for (let i = 0; i < 12; i++) {
const multiplier = (i % 2 === 0) ? 1 : 3;
sum += digits[i] * multiplier;
}
} else if (length === 14) {
// EAN-14: similar to EAN-13 but 14 digits
for (let i = 0; i < 13; i++) {
const multiplier = (i % 2 === 0) ? 1 : 3;
sum += digits[i] * multiplier;
}
sum += digits[i] * multiplier;
}
const checkDigit = (10 - (sum % 10)) % 10;
return checkDigit === digits[length - 1];
@@ -376,43 +456,62 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
if (!isValidGTIN(gtinString)) {
skippedCount++;
skipReasons.invalidGTINChecksum.count++;
skipReasons.invalidGTINChecksum.products.push({
id: product.articleNumber || product.seoName,
name: product.name || 'N/A',
gtin: gtinString,
url: `/Artikel/${product.seoName}`
});
return;
}
// Skip products without pictures
if (!product.pictureList || !product.pictureList.trim()) {
skippedCount++;
skipReasons.missingPicture.count++;
skipReasons.missingPicture.products.push({
id: product.articleNumber || product.seoName,
name: product.name || 'N/A',
url: `/Artikel/${product.seoName}`
});
return;
}
// Check if product has weight data - validate BEFORE building XML
if (!product.weight || isNaN(product.weight)) {
// Track products without weight
productsNeedingWeight.push({
const productInfo = {
id: product.articleNumber || product.seoName,
name: product.name || 'Unnamed',
url: `/Artikel/${product.seoName}`
});
};
productsNeedingWeight.push(productInfo);
skipReasons.missingWeight.count++;
skipReasons.missingWeight.products.push(productInfo);
skippedCount++;
return;
}
// Check if description is missing or too short (less than 20 characters) - skip if insufficient
const originalDescription = product.description ? cleanTextContent(product.description) : '';
const originalDescription = productDescription ? cleanTextContent(productDescription) : '';
if (!originalDescription || originalDescription.length < 20) {
productsNeedingDescription.push({
const productInfo = {
id: product.articleNumber || product.seoName,
name: product.name || 'Unnamed',
currentDescription: originalDescription || 'NONE',
url: `/Artikel/${product.seoName}`
});
};
productsNeedingDescription.push(productInfo);
skipReasons.insufficientDescription.count++;
skipReasons.insufficientDescription.products.push(productInfo);
skippedCount++;
return;
}
// Clean description for feed (remove HTML tags and limit length)
const rawDescription = cleanTextContent(product.description).substring(0, 500);
const cleanDescription = escapeXml(rawDescription) || "Produktbeschreibung nicht verfügbar";
const feedDescription = cleanTextContent(productDescription).substring(0, 500);
const cleanDescription = escapeXml(feedDescription) || "Produktbeschreibung nicht verfügbar";
// Clean product name
const rawName = product.name || "Unnamed Product";
@@ -421,6 +520,13 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
// Validate essential fields
if (!cleanName || cleanName.length < 2) {
skippedCount++;
skipReasons.nameTooShort.count++;
skipReasons.nameTooShort.products.push({
id: product.articleNumber || product.seoName,
name: rawName,
cleanedName: cleanName,
url: `/Artikel/${product.seoName}`
});
return;
}
@@ -429,7 +535,7 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
// Generate image URL
const imageUrl = product.pictureList && product.pictureList.trim()
? `${baseUrl}/assets/images/prod${product.pictureList.split(",")[0].trim()}.jpg`
? `${baseUrl}/assets/images/prod${product.pictureList.split(",")[0].trim()}.avif`
: `${baseUrl}/assets/images/nopicture.jpg`;
// Generate brand (manufacturer)
@@ -445,6 +551,12 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
// Skip products that are out of stock
if (!product.available) {
skippedCount++;
skipReasons.outOfStock.count++;
skipReasons.outOfStock.products.push({
id: product.articleNumber || product.seoName,
name: product.name || 'N/A',
url: `/Artikel/${product.seoName}`
});
return;
}
@@ -456,6 +568,13 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
// Skip products with price == 0
if (!product.price || parseFloat(product.price) === 0) {
skippedCount++;
skipReasons.zeroPriceOrInvalid.count++;
skipReasons.zeroPriceOrInvalid.products.push({
id: product.articleNumber || product.seoName,
name: product.name || 'N/A',
price: product.price,
url: `/Artikel/${product.seoName}`
});
return;
}
@@ -522,6 +641,13 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
} catch (itemError) {
console.log(` ⚠️ Skipped product ${index + 1}: ${itemError.message}`);
skippedCount++;
skipReasons.processingError.count++;
skipReasons.processingError.products.push({
id: product?.articleNumber || product?.seoName || 'N/A',
name: product?.name || 'N/A',
error: itemError.message,
url: product?.seoName ? `/Artikel/${product.seoName}` : 'N/A'
});
}
});
@@ -529,7 +655,43 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
</channel>
</rss>`;
console.log(` 📊 Processing summary: ${processedCount} products included, ${skippedCount} skipped`);
console.log(`\n 📊 Processing summary: ${processedCount} products included, ${skippedCount} skipped`);
// Display skip reason totals
console.log(`\n 📋 Skip Reasons Breakdown:`);
console.log(` ────────────────────────────────────────────────────────────`);
const skipReasonLabels = {
noProductOrSeoName: 'No Product or SEO Name',
excludedCategory: 'Excluded Category',
excludedTermsTitle: 'Excluded Terms in Title',
excludedTermsDescription: 'Excluded Terms in Description',
missingGTIN: 'Missing GTIN',
invalidGTINChecksum: 'Invalid GTIN Checksum',
missingPicture: 'Missing Picture',
missingWeight: 'Missing Weight',
insufficientDescription: 'Insufficient Description',
nameTooShort: 'Name Too Short',
outOfStock: 'Out of Stock',
zeroPriceOrInvalid: 'Zero or Invalid Price',
processingError: 'Processing Error'
};
let hasAnySkips = false;
Object.entries(skipReasons).forEach(([key, data]) => {
if (data.count > 0) {
hasAnySkips = true;
const label = skipReasonLabels[key] || key;
console.log(`${label}: ${data.count}`);
}
});
if (!hasAnySkips) {
console.log(` ✅ No products were skipped`);
}
console.log(` ────────────────────────────────────────────────────────────`);
console.log(` Total: ${skippedCount} products skipped\n`);
// Write log files for products needing attention
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
@@ -540,7 +702,56 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
fs.mkdirSync(logsDir, { recursive: true });
}
// Write missing weight log
// Write comprehensive skip reasons log
const skipLogPath = path.join(logsDir, `skip-reasons-${timestamp}.log`);
let skipLogContent = `# Product Skip Reasons Report
# Generated: ${new Date().toISOString()}
# Total products processed: ${processedCount}
# Total products skipped: ${skippedCount}
# Base URL: ${baseUrl}
`;
Object.entries(skipReasons).forEach(([key, data]) => {
if (data.count > 0) {
const label = skipReasonLabels[key] || key;
skipLogContent += `\n## ${label} (${data.count} products)\n`;
skipLogContent += `${'='.repeat(80)}\n`;
data.products.forEach(product => {
skipLogContent += `ID: ${product.id}\n`;
skipLogContent += `Name: ${product.name}\n`;
if (product.categoryId !== undefined) {
skipLogContent += `Category ID: ${product.categoryId}\n`;
}
if (product.term !== undefined) {
skipLogContent += `Excluded Term: ${product.term}\n`;
}
if (product.gtin !== undefined) {
skipLogContent += `GTIN: ${product.gtin}\n`;
}
if (product.currentDescription !== undefined) {
skipLogContent += `Current Description: "${product.currentDescription}"\n`;
}
if (product.cleanedName !== undefined) {
skipLogContent += `Cleaned Name: "${product.cleanedName}"\n`;
}
if (product.price !== undefined) {
skipLogContent += `Price: ${product.price}\n`;
}
if (product.error !== undefined) {
skipLogContent += `Error: ${product.error}\n`;
}
skipLogContent += `URL: ${baseUrl}${product.url}\n`;
skipLogContent += `${'-'.repeat(80)}\n`;
});
}
});
fs.writeFileSync(skipLogPath, skipLogContent, 'utf8');
console.log(` 📄 Detailed skip reasons report saved to: ${skipLogPath}`);
// Write missing weight log (for backward compatibility)
if (productsNeedingWeight.length > 0) {
const weightLogContent = `# Products Missing Weight Data
# Generated: ${new Date().toISOString()}
@@ -551,10 +762,10 @@ ${productsNeedingWeight.map(product => `${product.id}\t${product.name}\t${baseUr
const weightLogPath = path.join(logsDir, `missing-weight-${timestamp}.log`);
fs.writeFileSync(weightLogPath, weightLogContent, 'utf8');
console.log(`\n ⚠️ Products missing weight (${productsNeedingWeight.length}) - saved to: ${weightLogPath}`);
console.log(` ⚠️ Products missing weight (${productsNeedingWeight.length}) - saved to: ${weightLogPath}`);
}
// Write missing description log
// Write missing description log (for backward compatibility)
if (productsNeedingDescription.length > 0) {
const descLogContent = `# Products With Insufficient Description Data
# Generated: ${new Date().toISOString()}
@@ -565,7 +776,7 @@ ${productsNeedingDescription.map(product => `${product.id}\t${product.name}\t"${
const descLogPath = path.join(logsDir, `missing-description-${timestamp}.log`);
fs.writeFileSync(descLogPath, descLogContent, 'utf8');
console.log(`\n ⚠️ Products with insufficient description (${productsNeedingDescription.length}) - saved to: ${descLogPath}`);
console.log(` ⚠️ Products with insufficient description (${productsNeedingDescription.length}) - saved to: ${descLogPath}`);
}
if (productsNeedingWeight.length === 0 && productsNeedingDescription.length === 0) {

View File

@@ -1,6 +1,6 @@
const generateHomepageMetaTags = (baseUrl, config) => {
const description = config.descriptions.long;
const keywords = config.keywords;
const description = config.descriptions.de.long;
const keywords = config.keywords.de;
const imageUrl = `${baseUrl}${config.images.logo}`;
// Ensure URLs are properly formatted
@@ -12,7 +12,7 @@ const generateHomepageMetaTags = (baseUrl, config) => {
<meta name="keywords" content="${keywords}">
<!-- Open Graph Meta Tags -->
<meta property="og:title" content="${config.descriptions.short}">
<meta property="og:title" content="${config.descriptions.de.short}">
<meta property="og:description" content="${description}">
<meta property="og:image" content="${imageUrl}">
<meta property="og:url" content="${canonicalUrl}">
@@ -21,7 +21,7 @@ const generateHomepageMetaTags = (baseUrl, config) => {
<!-- Twitter Card Meta Tags -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${config.descriptions.short}">
<meta name="twitter:title" content="${config.descriptions.de.short}">
<meta name="twitter:description" content="${description}">
<meta name="twitter:image" content="${imageUrl}">
@@ -41,7 +41,7 @@ const generateHomepageJsonLd = (baseUrl, config, categories = []) => {
"@type": "WebSite",
name: config.brandName,
url: canonicalUrl,
description: config.descriptions.long,
description: config.descriptions.de.long,
publisher: {
"@type": "Organization",
name: config.brandName,
@@ -73,7 +73,7 @@ const generateHomepageJsonLd = (baseUrl, config, categories = []) => {
"@type": "LocalBusiness",
"name": config.brandName,
"alternateName": config.siteName,
"description": config.descriptions.long,
"description": config.descriptions.de.long,
"url": canonicalUrl,
"logo": logoUrl,
"image": logoUrl,

View File

@@ -31,6 +31,7 @@ const {
generateLlmsTxt,
generateCategoryLlmsTxt,
generateAllCategoryLlmsPages,
generateCategoryProductList,
} = require('./llms.cjs');
// Export all functions for use in the main application
@@ -61,4 +62,5 @@ module.exports = {
generateLlmsTxt,
generateCategoryLlmsTxt,
generateAllCategoryLlmsPages,
generateCategoryProductList,
};

View File

@@ -55,29 +55,29 @@ GrowHeads.de is a German online shop and local store in Dresden specializing in
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) {
@@ -87,7 +87,7 @@ GrowHeads.de is a German online shop and local store in Dresden specializing in
llmsTxt += `
- **Product Catalog**: No products available`;
}
llmsTxt += `
`;
@@ -106,7 +106,7 @@ GrowHeads.de is a German online shop and local store in Dresden specializing in
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);
@@ -140,28 +140,28 @@ This file contains products ${startIndex + 1}-${endIndex} of ${totalProducts} in
**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 += `- **Page ${i}**: ${baseUrl}/llms-${categorySlug}-page-${i}.txt (Products ${((i - 1) * productsPerPage) + 1}-${Math.min(i * productsPerPage, totalProducts)})
`;
}
categoryLlmsTxt += `
`;
@@ -173,10 +173,10 @@ This file contains products ${startIndex + 1}-${endIndex} of ${totalProducts} in
// 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)
.replace(/<[^>]*>/g, "")
.replace(/\n/g, " ")
.trim()
.substring(0, 300)
: "";
const globalIndex = startIndex + index + 1;
@@ -234,13 +234,13 @@ This category currently contains no products.
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 += `
`;
@@ -254,17 +254,52 @@ This category currently contains no products.
return categoryLlmsTxt;
};
// Helper function to generate a simple product list for a category
const generateCategoryProductList = (category, categoryProducts = []) => {
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
const fileName = `llms-${categorySlug}-list.txt`;
const subcategoryIds = (category.subcategories || []).join(',');
let content = '';//`${String(category.name)},${String(category.id)},[${subcategoryIds}]\n`;
categoryProducts.forEach((product) => {
const artnr = String(product.articleNumber || '');
const price = String(product.price || '0.00');
const name = String(product.name || '');
const kurzBeschreibung = String(product.kurzBeschreibung || '');
// Escape commas in fields by wrapping in quotes if they contain commas
const escapeField = (field) => {
const fieldStr = String(field || '');
if (fieldStr.includes(',')) {
return `"${fieldStr.replace(/"/g, '""')}"`;
}
return fieldStr;
};
content += `${escapeField(artnr)},${escapeField(price)},${escapeField(name)},${escapeField(kurzBeschreibung)}\n`;
});
return {
fileName,
content,
categoryName: category.name,
categoryId: category.id,
productCount: categoryProducts.length
};
};
// 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,
@@ -272,7 +307,7 @@ const generateAllCategoryLlmsPages = (category, categoryProducts = [], baseUrl,
totalPages
});
}
return pages;
};
@@ -280,4 +315,5 @@ module.exports = {
generateLlmsTxt,
generateCategoryLlmsTxt,
generateAllCategoryLlmsPages,
generateCategoryProductList,
};

View File

@@ -5,7 +5,7 @@ const generateProductMetaTags = (product, baseUrl, config) => {
product.pictureList && product.pictureList.trim()
? `${baseUrl}/assets/images/prod${product.pictureList
.split(",")[0]
.trim()}.jpg`
.trim()}.avif`
: `${baseUrl}/assets/images/nopicture.jpg`;
@@ -68,7 +68,7 @@ const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) =>
product.pictureList && product.pictureList.trim()
? `${baseUrl}/assets/images/prod${product.pictureList
.split(",")[0]
.trim()}.jpg`
.trim()}.avif`
: `${baseUrl}/assets/images/nopicture.jpg`;
// Clean description for JSON-LD (remove HTML tags)
@@ -106,6 +106,41 @@ const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) =>
"@type": "Organization",
name: config.brandName,
},
hasMerchantReturnPolicy: {
"@type": "MerchantReturnPolicy",
applicableCountry: "DE",
returnPolicyCategory: "https://schema.org/MerchantReturnFiniteReturnWindow",
merchantReturnDays: 14,
returnMethod: "https://schema.org/ReturnByMail",
returnFees: "https://schema.org/FreeReturn",
},
shippingDetails: {
"@type": "OfferShippingDetails",
shippingRate: {
"@type": "MonetaryAmount",
value: 5.90,
currency: "EUR",
},
shippingDestination: {
"@type": "DefinedRegion",
addressCountry: "DE",
},
deliveryTime: {
"@type": "ShippingDeliveryTime",
handlingTime: {
"@type": "QuantitativeValue",
minValue: 0,
maxValue: 1,
unitCode: "DAY",
},
transitTime: {
"@type": "QuantitativeValue",
minValue: 2,
maxValue: 3,
unitCode: "DAY",
},
},
},
},
};

View File

@@ -7,11 +7,17 @@ const collectAllCategories = (categoryNode, categories = []) => {
// Add current category (skip root category 209)
if (categoryNode.id !== 209) {
// Extract subcategory IDs from children
const subcategoryIds = categoryNode.children
? categoryNode.children.map(child => child.id)
: [];
categories.push({
id: categoryNode.id,
name: categoryNode.name,
seoName: categoryNode.seoName,
parentId: categoryNode.parentId
parentId: categoryNode.parentId,
subcategories: subcategoryIds
});
}

105
process_llms_cat.cjs Normal file
View File

@@ -0,0 +1,105 @@
const fs = require('fs');
const path = require('path');
// Read the input file from public
const inputFile = path.join(__dirname, 'public', 'llms-cat.txt');
// Write the output file to dist
const outputFile = path.join(__dirname, 'dist', 'llms-cat.txt');
// Function to parse a CSV line with escaped quotes
function parseCSVLine(line) {
const fields = [];
let current = '';
let inQuotes = false;
let i = 0;
while (i < line.length) {
const char = line[i];
if (char === '"') {
// Check if this is an escaped quote
if (i + 1 < line.length && line[i + 1] === '"') {
current += '"'; // Add single quote (unescaped)
i += 2; // Skip both quotes
continue;
} else {
inQuotes = !inQuotes; // Toggle quote state
}
} else if (char === ',' && !inQuotes) {
fields.push(current);
current = '';
} else {
current += char;
}
i++;
}
fields.push(current); // Add the last field
return fields;
}
try {
if (!fs.existsSync(inputFile)) {
throw new Error(`Input file not found: ${inputFile}`);
}
const data = fs.readFileSync(inputFile, 'utf8');
const lines = data.trim().split('\n');
// Keep the header as intended: URL and Description
const outputLines = ['URL of product list for article numbers,SEO Description'];
let skippedLines = 0;
let processedLines = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.trim() === '') continue;
// Skip comment lines or lines not starting with a number/quote (simple heuristic for header/comments)
// The file starts with text "this file has..." and then header "categoryId..."
// Actual data lines start with "
if (!line.trim().startsWith('"')) {
continue;
}
// Parse the CSV line properly handling escaped quotes
const fields = parseCSVLine(line);
if (fields.length !== 3) {
console.warn(`Skipping malformed line ${i + 1} (got ${fields.length} fields): ${line.substring(0, 50)}...`);
skippedLines++;
continue;
}
// Input: categoryId, listFileName, seoDescription
// Output: URL, SEO Description
const [categoryId, listFileName, seoDescription] = fields;
// Use listFileName as URL
const url = listFileName;
// Use seoDescription as description directly (it's already a string)
const description = seoDescription;
// Escape quotes for CSV output
const escapedDescription = '"' + description.replace(/"/g, '""') + '"';
outputLines.push(`${url},${escapedDescription}`);
processedLines++;
}
// Ensure dist directory exists
const distDir = path.dirname(outputFile);
if (!fs.existsSync(distDir)) {
fs.mkdirSync(distDir, { recursive: true });
}
// Write the output CSV
fs.writeFileSync(outputFile, outputLines.join('\n'), 'utf8');
console.log(`Processed ${processedLines} lines (skipped ${skippedLines}) and created ${outputFile}`);
} catch (error) {
console.error('Error processing file:', error.message);
process.exit(1);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

92
public/llms-cat.txt Normal file
View File

@@ -0,0 +1,92 @@
this file has the list of category overview lists, where you can find article numbers
categoryId,listFileName,seoDescription
"703","https://growheads.de/llms-abluft-sets-list.txt","Abluft-Sets für Growbox & Indoor-Grow: leise, energiesparend & mit Aktivkohlefilter zur Geruchsneutralisation. Ideal für Zelte von 60 cm bis 1 m²."
"317","https://growheads.de/llms-air-pot-list.txt","Air-Pot Pflanztöpfe für maximales Wurzelwachstum: Air-Pruning, optimale Belüftung & Drainage. Ideal für Indoor, Outdoor, Hydroponik & Anzucht."
"922","https://growheads.de/llms-aktivkohlefilter-tips-list.txt","Aktivkohlefilter & Tips für Zigaretten und Selbstgedrehte milderer Geschmack, weniger Schadstoffe, optimale Rauchfilterung und hoher Genuss."
"372","https://growheads.de/llms-autopot-list.txt","AutoPot Bewässerungssysteme & Zubehör: Stromlose, automatische Pflanzenbewässerung mit Tanks, Schläuchen, FlexiPots & AQUAvalve5 für Hydroponik & Garten."
"389","https://growheads.de/llms-blumat-list.txt","Blumat Bewässerungssysteme & Zubehör: Tropf-Bewässerung, Erdfeuchte-Sensoren und Ventile für automatische, bedarfsgerechte Pflanzenbewässerung."
"355","https://growheads.de/llms-boveda-integra-boost-list.txt","Boveda & Integra Boost Hygro-Packs für perfekte Feuchtigkeitskontrolle Deiner Kräuter. Verhindern Schimmel, Austrocknung und Aroma-Verlust bei der Lagerung."
"749","https://growheads.de/llms-chillums-diffusoren-kupplungen-list.txt","Chillums, Diffusoren & Kupplungen für Bongs große Auswahl an 14,5mm, 18,8mm & 29,2mm Adaptern aus Glas für bessere Kühlung & sanfteren Rauchgenuss."
"449","https://growheads.de/llms-cliptuetchen-list.txt","Cliptütchen & Mylarbeutel: hochwertige Zip Bags und Schnellverschlussbeutel in vielen Größen, starken Folienstärken und Farben ideal zur sicheren Lagerung."
"924","https://growheads.de/llms-dabbing-list.txt","Entdecken Sie hochwertiges Dabbing-Zubehör für konzentrierte Aromagenuss Dab Rigs, Tools und mehr für ein intensives, sauberes Dab-Erlebnis."
"691","https://growheads.de/llms-duenger-list.txt","Dünger & Pflanzennährstoffe für Erde, Coco & Hydro: Bio- und Mineraldünger, Booster, Wurzelstimulatoren, PK-Additive & pH-Regulatoren für maximale Erträge."
"692","https://growheads.de/llms-duenger-zubehoer-list.txt","Dünger-Zubehör: Abfüllhähne, Wurmhumus, pH-Eichlösungen & Desinfektionsmittel für präzise Dosierung, Hygiene und optimale Nährstoffversorgung der Pflanzen."
"489","https://growheads.de/llms-eazyplug-jiffy-list.txt","EazyPlug & Jiffy Anzuchtmedien: nachhaltige Anzuchtwürfel, Torftöpfe & Trays für Stecklinge und Sämlinge mit optimalem Wasser-Luft-Verhältnis."
"243","https://growheads.de/llms-erde-list.txt","Hochwertige Blumenerde & Bio-Substrate: torffrei, organisch, vorgedüngt ideal für Indoor-Grow, Urban Gardening, Kräuter, Gemüse & Cannabis-Anbau."
"302","https://growheads.de/llms-erntemaschinen-list.txt","Erntemaschinen & Leaf Trimmer für professionelle Ernteverarbeitung vom manuellen Trimmer bis zur automatisierten Profigerät, inkl. Zubehör & Ersatzteile."
"280","https://growheads.de/llms-erntescheeren-list.txt","Hochwertige Erntescheren & Gartenscheren für präzise Pflanzenschnitte. Entdecken Sie Profi-Erntescheren aus Edelstahl & Japanstahl für Garten & Indoor-Grow."
"424","https://growheads.de/llms-etiketten-schilder-list.txt","Etiketten & Schilder für Garten & Gewächshaus wasserfeste Stecketiketten in vielen Farben für Pflanzenbeschriftung, Sortierung und Kennzeichnung."
"278","https://growheads.de/llms-extraktion-list.txt","Hochwertige Extraktionszubehör & -geräte: Pollenmaschinen, Extraktorbeutel, DME-Gas, Rosin Bags & Infuser für saubere Pflanzen- und Öl-Extraktionen."
"379","https://growheads.de/llms-geruchsneutralisation-list.txt","Effektive Geruchsneutralisation für Haushalt, Grow-Räume & Gewerbe ONA & BIODOR Gel, Spray, Filter und Ozongeneratoren gegen Tabak-, Cannabis- & Tiergerüche."
"359","https://growheads.de/llms-gewaechshaeuser-list.txt","Gewächshäuser & Anzuchtgewächshäuser für drinnen: beheizt, mit LED & Lüftung. Ideal für Kräuter, Gemüse & Stecklinge für erfolgreiche Pflanzenanzucht."
"539","https://growheads.de/llms-glaeser-dosen-list.txt","Gläser & Dosen für luftdichte, lichtgeschützte und geruchsneutrale Aufbewahrung von Kräutern, Lebensmitteln & Wertsachen. Vakuum-, Stash- & Miron-Glas."
"710","https://growheads.de/llms-grinder-list.txt","Hochwertige Grinder & Kräutermühlen aus Aluminium, Holz & Edelstahl 2-, 3- & 4-teilig, Pollinator, Non-Sticky & Design-Grinder für Tabak & Kräuter."
"407","https://growheads.de/llms-grove-bags-list.txt","Grove Bags TerpLoc professionelle Lagerung für Cannabis & Kräuter. Schimmelschutz, Feuchtigkeitskontrolle, Terpene & Aroma optimal bewahren."
"408","https://growheads.de/llms-growcontrol-list.txt","GrowControl Steuerungen & Sensoren für präzises Klima-, CO₂- und Lichtmanagement im Indoor-Grow. Made in Germany, kompatibel mit EC-Lüftern & LED-Systemen."
"373","https://growheads.de/llms-growtool-list.txt","GrowTool Zubehör für professionelle Bewässerung & GrowRacks: stabile Unterbauten, aeroponische Systeme, Adapter & Wasserkühler für optimales Indoor-Growing."
"310","https://growheads.de/llms-heizmatten-list.txt","Heizmatten für Gewächshaus, Growbox & Terrarium: Effiziente Wurzelwärme, schnellere Keimung & gesundes Pflanzenwachstum mit Thermostat-Steuerung."
"748","https://growheads.de/llms-koepfe-list.txt","Hochwertige Bong-Köpfe & Glasbowls: Entdecke Trichterköpfe, Flutschköpfe und Zenit Premium-Köpfe in 14,5 & 18,8 mm in vielen Farben online."
"269","https://growheads.de/llms-kokos-list.txt","Entdecken Sie hochwertige Kokossubstrate & Kokosmatten für Hydroponik, Indoor-Grow & Topfkulturen torffreie, nachhaltige Coco-Erden für starkes Wurzelwachstum."
"364","https://growheads.de/llms-kunststofftoepfe-list.txt","Kunststofftöpfe für Pflanzen, Anzucht und Umtopfen: Viereckige Pflanztöpfe, Airpots & Mini-Pots in vielen Größen für gesundes Wurzelwachstum."
"694","https://growheads.de/llms-lampen-list.txt","Entdecken Sie hochwertige LED Grow Lampen & Pflanzenlampen mit Vollspektrum für Indoor-Grow, Wachstum & Blüte. Effizient, dimmbar & langlebig."
"261","https://growheads.de/llms-lampenzubehoer-list.txt","Hochwertiges Lampenzubehör für Growbox & Gewächshaus: Aufhängungen, Dimmer, Reflektoren, Netzteile & SANlight-Zubehör für optimales Pflanzenlicht."
"387","https://growheads.de/llms-literatur-list.txt","Entdecke Fachliteratur zu Cannabis-Anbau, Bio-Grow, LED, Hydrokultur & Extraktion praxisnahe Bücher für Indoor- und Outdoor-Gärtner, Anfänger & Profis."
"658","https://growheads.de/llms-luftbe-und-entfeuchter-list.txt","Effektive Luftbefeuchter & Luftentfeuchter für Growroom & Indoor-Anbau. Optimale Luftfeuchtigkeit, Schimmelvorbeugung & gesundes Pflanzenwachstum."
"403","https://growheads.de/llms-messbecher-mehr-list.txt","Messbecher, Pipetten & Einwegspritzen zum präzisen Abmessen von Flüssigkeiten ideal für Dünger, Zusätze, Labor und Garten. Verschiedene Größen."
"344","https://growheads.de/llms-messgeraete-list.txt","Präzise pH-, EC-, Temperatur- und Klimamessgeräte für Garten, Hydroponik & Labor. Entdecke Profi-Messinstrumente, Sonden und Kalibrierlösungen."
"555","https://growheads.de/llms-mikroskope-list.txt","Mikroskope & Lupen für Hobby, Schule & Elektronik: Entdecken Sie 5x100x Vergrößerung, USB-Mikroskope, LED-Modelle und mobile Zoom-Mikroskope."
"923","https://growheads.de/llms-papes-blunts-list.txt","Entdecke hochwertige Papers & Blunts für perfekten Rauchgenuss von klassischen Blättchen bis aromatisierten Blunt Wraps in vielen Größen und Stärken."
"222","https://growheads.de/llms-pe-teile-list.txt","PE-Teile für Bewässerung: Absperrhähne, Kupplungen, T-Stücke & Endkappen für PE-Schläuche ideal für Gartenbewässerung und Tropfbewässerung."
"580","https://growheads.de/llms-perlite-blaehton-list.txt","Perlite & Blähton für Hydroponik, Hydrokultur & Gartenbau. Optimale Drainage, Belüftung und Wasserspeicherung für gesundes Wurzelwachstum."
"921","https://growheads.de/llms-pfeifen-list.txt","Entdecken Sie Pfeifen für Aktivkohlefilter: langlebige Holzpfeifen und hochwertige Aluminium-Pfeifen mit Royal Filter Adapter für ein reines Raucherlebnis."
"239","https://growheads.de/llms-pflanzenschutz-list.txt","Pflanzenschutz biologisch & chemiefrei: Nützlinge, Neemöl, Gelbtafeln & Raubmilben gegen Trauermücken, Thripse, Spinnmilben, Blattläuse & Weiße Fliege."
"259","https://growheads.de/llms-pressen-list.txt","Hydraulische Rosin Pressen, Pollenpressen & Rosin Bags für professionelle, lösungsmittelfreie Extraktion und Harzpressung. Große Auswahl & Top-Marken."
"297","https://growheads.de/llms-pumpen-list.txt","Entdecken Sie leistungsstarke Pumpen für Bewässerung, Hydroponik & Aquaristik: Tauchpumpen, Umwälzpumpen, Belüftungs- und Luftpumpen für jeden Bedarf."
"519","https://growheads.de/llms-pumpsprueher-list.txt","Pumpsprüher & Drucksprüher für Garten, Haushalt & Industrie. Hochwertige 18L Sprüher für Pflanzenpflege, Reinigung und Pflanzenschutz online kaufen."
"920","https://growheads.de/llms-raeucherstaebchen-list.txt","Entdecken Sie hochwertige Räucherstäbchen wie Goloka Nag Champa und Satya für Meditation, Ayurveda, Chakra-Harmonisierung und entspannende Duftmomente."
"450","https://growheads.de/llms-restposten-list.txt","Günstige Restposten: stark reduzierte Markenartikel, Sonderposten und Einzelstücke für cleveres Sparen. Jetzt Restbestände kaufen und Schnäppchen sichern."
"916","https://growheads.de/llms-rollen-bauen-list.txt","Entdecke Rolling Trays, Tin Boxen & Rolling Sets für perfektes Drehen praktische Aufbewahrung, integriertes Zubehör & stylische Designs."
"609","https://growheads.de/llms-schalldaempfer-list.txt","Schalldämpfer für Lüftungsanlagen: hochwertige Rohr- & Telefonieschalldämpfer zur effektiven Geräuschreduzierung in Wohnraum, Gewerbe & Technikräumen."
"405","https://growheads.de/llms-schlaeuche-1-list.txt","Schläuche für Bewässerung & Garten: Tropfschläuche, Mikroschläuche und flexible Gartenschläuche in verschiedenen Durchmessern für präzise Wasserversorgung."
"250","https://growheads.de/llms-schlaeuche-list.txt","Hochwertige Lüftungs- und Abluftschläuche: Aluflex-, Combi-, Phonic Trap & Sonodec für leise, effiziente Belüftung in Growroom, Werkstatt & Haus."
"689","https://growheads.de/llms-seeds-list.txt","Entdecke hochwertige Samen: Cannabis-, Gemüse- und Kräutersamen für Indoor & Outdoor, inklusive Autoflower, CBD, Fast Version & Bio-Gartensaatgut."
"915","https://growheads.de/llms-set-zubehoer-list.txt","Set-Zubehör für Grow & Indoor-Garten: Erde, Dünger-Starterkit, Ernteschere, Thermo-Hygrometer & WLAN Zeitschaltuhr für optimale Pflanzenpflege."
"4","https://growheads.de/llms-sonstiges-list.txt","Sonstiges Garten- & Grow-Zubehör: LST Pflanzenbieger, Kabel, CBD-Aromaöle, Adventskalender, Schutzbrillen & Bambusstäbe günstig online kaufen."
"354","https://growheads.de/llms-sprueher-list.txt","Sprüher & Sprühflaschen fürs Pflanzenwässern: Drucksprüher, Handsprüher, Gießstäbe & Hozelock-Spritzdüsen für Gewächshaus, Garten & Indoor-Grow."
"706","https://growheads.de/llms-stecklinge-list.txt","Entdecke hochwertige Cannabis-Stecklinge: Top-Genetiken, feminisierte & Autoflower Sorten, hohe THC- und CBD-Werte, ideal für Indoor- & Outdoor-Grower."
"298","https://growheads.de/llms-steinwolltrays-list.txt","Steinwolltrays & Anzuchtsysteme für Stecklinge & Samen Grodan, Speedgrow & Joplug. Optimale Bewurzelung, Hydroponik-tauglich, pH-neutral & effizient."
"314","https://growheads.de/llms-steuergeraete-list.txt","Steuergeräte für Indoor-Grow & Gewächshaus: Klimacontroller, Lüftersteuerungen, CO₂-Regler & Thermostate für optimales Grow-Klima online kaufen."
"301","https://growheads.de/llms-stofftoepfe-list.txt","Stofftöpfe & Pflanzsäcke für gesundes Wurzelwachstum atmungsaktive, nachhaltige Fabric Pots aus recyceltem Material für Indoor & Outdoor Anbau."
"292","https://growheads.de/llms-trays-fluttische-list.txt","Trays & Fluttische für Growbox & Gewächshaus: stabile Pflanzschalen, Fluttischböden, Water Trays und Eisenracks für effiziente Bewässerung & Trocknung."
"293","https://growheads.de/llms-trockennetze-list.txt","Trockennetze & Dry Bags für Kräuter, Blüten & Samen: platzsparend, geruchsarm & schimmelfrei trocknen ideal für Growbox, Indoor & Balkon."
"480","https://growheads.de/llms-tropfer-list.txt","Tropfer & Mikroschläuche für professionelle Tropfbewässerung Zubehör, Verbinder und Systeme für Garten, Gewächshaus & Containerpflanzen."
"214","https://growheads.de/llms-umluft-ventilatoren-list.txt","Umluft-Ventilatoren für Growbox, Growraum & Haushalt: leise Clip, Box und Wandventilatoren mit Oszillation, EC-Motor, energieeffizient & langlebig."
"220","https://growheads.de/llms-untersetzer-list.txt","Untersetzer & Auffangschalen für Pflanztöpfe: eckig & rund, verschiedene Größen, robust, wasserdicht ideal für Indoor-Grow, Balkon & Zimmerpflanzen."
"346","https://growheads.de/llms-vakuumbeutel-list.txt","Vakuumbeutel & Alu-Bügelbeutel für Lebensmittel, Fermentation & Lagerung luftdicht, robust, BPA-frei. Passend zu allen gängigen Vakuumierern."
"896","https://growheads.de/llms-vaporizer-list.txt","Vaporizer & E-Rigs für Kräuter & Konzentrate: Entdecke Premium-Verdampfer, Dab Tools & Zubehör von Puffco, Storz & Bickel, Wolkenkraft u.v.m."
"374","https://growheads.de/llms-verbindungsteile-list.txt","Verbindungsteile für Lüftungsanlagen: Außen- & Innenverbinder, Reduzierstücke, Gummimuffen, Dichtbänder, T- und Y-Stücke für luftdichte Rohrverbindungen."
"421","https://growheads.de/llms-vermehrungszubehoer-list.txt","Vermehrungszubehör für Stecklinge & Jungpflanzen: Bewurzelungsgel, Clonex Mist, Jiffy Quelltöpfe, Skalpelle & Substrate für erfolgreiche Pflanzenzucht."
"187","https://growheads.de/llms-waagen-list.txt","Präzisionswaagen, Taschenwaagen & Paketwaagen: Entdecken Sie digitale Waagen, Juwelierwaagen und Eichgewichte für Labor, Versand, Haushalt & Hobby."
"425","https://growheads.de/llms-wassertanks-list.txt","Wassertanks & Nährstofftanks für Bewässerung & Hydroponik robuste Tanks, flexible Flex-Tanks, Tankdurchführungen & Zubehör für Growbox und Garten."
"186","https://growheads.de/llms-wiegen-verpacken-list.txt","Wiegen & Verpacken: Präzisionswaagen, Vakuumbeutel, Grove Bags, Boveda, Integra Boost, Cliptütchen sowie Gläser & Dosen für sichere Lagerung."
"693","https://growheads.de/llms-zelte-list.txt","Entdecke hochwertige Growzelte für Indoor-Growing von kompakten Mini-Growboxen bis zu Profi-Zelten mit Mylar, PAR+ & stabilen Stahlrahmen."
"226","https://growheads.de/llms-zeltzubehoer-list.txt","Zeltzubehör für Growbox & Growzelt: Scrog-Netze, Stütznetze, Space Booster, Stoffböden, Verbinder & Zubehör für stabile, effiziente Indoor-Grows."
"686","https://growheads.de/llms-zubehoer-1-list.txt","Zubehör für Aktivkohlefilter, Vorfilter und Flansche: hochwertiges Lüftungs- & Filterzubehör für Prima Klima und Can Filters in Profi-Qualität."
"741","https://growheads.de/llms-zubehoer-2-list.txt","Zubehör für Lüfter & Klima: EC-Controller, Temperaturregler, Netzstecker & Gewebeband für leisen, effizienten und sicheren Betrieb Ihrer Lüftungsanlage."
"294","https://growheads.de/llms-zubehoer-3-list.txt","Praktisches Zubehör für Bewässerung, Hydroponik & Garten: Schläuche, Filter, Heizstäbe, Verbinder und mehr für effiziente Grow- & Bewässerungssysteme."
"714","https://growheads.de/llms-zubehoer-list.txt","Zubehör für Bongs & Wasserpfeifen: Aktivkohle, Adapter, Filter, Köpfe, Vorkühler, Reinigungsmittel & Tools von Zenit, Smokebuddy, Black Leaf u.v.m."
"392","https://growheads.de/llms-zuluftfilter-list.txt","Zuluftfilter für Growroom & Gewächshaus: saubere Frischluft, Schutz vor Pollen, Staub & Insekten, optimales Klima und gesundes Pflanzenwachstum."
"308","https://growheads.de/llms-ab-und-zuluft-list.txt","Ab- und Zuluft für Growbox & Raumklima: leise EC-Lüfter, Rohrventilatoren, Iso-Boxen, Schläuche, Filter, Schalldämpfer & Zubehör für Profi-Lüftung."
"248","https://growheads.de/llms-aktivkohlefilter-list.txt","Aktivkohlefilter für Growbox, Industrie & Lüftung: hochwertige Geruchsneutralisation, Luftreinigung und Zubehör von Can Filters, Prima Klima, Rhino u.v.m."
"240","https://growheads.de/llms-anbauzubehoer-list.txt","Anbauzubehör für Indoor & Outdoor Grow: Kabel, Zeitschaltuhren, Pflanzentraining, Befestigung, Gewächshausheizung & mehr für Hobby- und Profigärtner."
"286","https://growheads.de/llms-anzucht-list.txt","Anzucht-Zubehör für erfolgreiches Vorziehen: Steinwolltrays, Heizmatten, Gewächshäuser, Vermehrungszubehör sowie EazyPlug & Jiffy Substrate online kaufen."
"247","https://growheads.de/llms-belueftung-list.txt","Belüftung für Growbox & Indoor-Grow: Umluft-Ventilatoren, Aktivkohlefilter, Ab- und Zuluft, Steuergeräte, Luftbefeuchter & Entfeuchter, Geruchsneutralisation."
"221","https://growheads.de/llms-bewaesserung-list.txt","Bewässerungssysteme für Garten, Gewächshaus & Indoor-Grow: Pumpen, Schläuche, Tropfer, AutoPot, Blumat, Trays, Wassertanks & Zubehör günstig kaufen."
"242","https://growheads.de/llms-boeden-list.txt","Böden & Substrate für Profi- und Hobby-Grower: Erde, Kokos, Perlite & Blähton für Indoor-Grow, Hydroponik und Gartenbau. Optimale Drainage & Nährstoffversorgung."
"711","https://growheads.de/llms-bongs-list.txt","Bongs online kaufen: Glasbongs, Acrylbongs & Ölbongs von Black Leaf, Jelly Joker, Grace Glass, Boost, Zenit u.v.m. Für Kräuter, Öl & Dabs große Auswahl."
"258","https://growheads.de/llms-ernte-verarbeitung-list.txt","Ernte & Verarbeitung: Pressen, Extraktion, Erntescheren, Trockennetze & Erntemaschinen für effiziente, schonende Blüten- und Kräuterverarbeitung."
"376","https://growheads.de/llms-grow-sets-list.txt","Entdecken Sie hochwertige Grow-Sets für Indoor-Growing: Komplettsets mit LED-Beleuchtung, Growbox, Abluftsystem & Zubehör für Anfänger und Profis."
"709","https://growheads.de/llms-headshop-list.txt","Headshop mit Bongs, Vaporizern, Pfeifen, DabbingZubehör, Papes, Grinder, Filtern, Waagen, Rolling Trays & Räucherstäbchen alles für dein RauchSetup."
"219","https://growheads.de/llms-toepfe-list.txt","Töpfe für Indoor- und Outdoorgrowing: Stofftöpfe, Air-Pots, Kunststofftöpfe & Untersetzer für optimales Wurzelwachstum und professionelle Pflanzenzucht."
"695","https://growheads.de/llms-zubehoer-4-list.txt","Zubehör für Growbox & Indoor-Grow: Zeltzubehör, Anbauzubehör, Lampenzubehör, Messgeräte, Ernte & Verarbeitung sowie Dünger-Zubehör online kaufen."

View File

@@ -0,0 +1,61 @@
const sharp = require('sharp');
const path = require('path');
const fs = require('fs');
const imagesToConvert = [
{ src: 'sh.png', dest: 'sh.avif' },
{ src: 'seeds.jpg', dest: 'seeds.avif' },
{ src: 'cutlings.jpg', dest: 'cutlings.avif' },
{ src: 'gg.png', dest: 'gg.avif' },
{ src: 'konfigurator.png', dest: 'konfigurator.avif' },
{ src: 'maps.png', dest: 'maps.avif' }
];
const run = async () => {
const imagesDir = path.join(__dirname, '../public/assets/images');
let hasError = false;
for (const image of imagesToConvert) {
const inputPath = path.join(imagesDir, image.src);
const outputPath = path.join(imagesDir, image.dest);
console.log('d');
if (!fs.existsSync(inputPath)) {
console.warn(`⚠️ Input file not found: ${inputPath}`);
continue;
}
// Check if output file exists and compare modification times
// Only convert if source is newer or destination doesn't exist
let shouldConvert = true;
if (fs.existsSync(outputPath)) {
const inputStat = fs.statSync(inputPath);
const outputStat = fs.statSync(outputPath);
if (inputStat.mtime <= outputStat.mtime) {
shouldConvert = false;
}
}
if (shouldConvert) {
try {
await sharp(inputPath)
.toFormat('avif')
.toFile(outputPath);
console.log(`✅ Converted ${image.src} to ${image.dest}`);
} catch (error) {
console.error(`❌ Error converting ${image.src}:`, error.message);
hasError = true;
}
} else {
// Silent skip if already up to date to keep logs clean, or use verbose flag
// console.log(`Skipping ${image.src} (already up to date)`);
}
}
if (hasError) {
process.exit(1);
}
};
console.log('dsfs');
run();

View File

@@ -0,0 +1,26 @@
const sharp = require('sharp');
const path = require('path');
const fs = require('fs');
const run = async () => {
const inputPath = path.join(__dirname, '../public/assets/images/sh.png');
const outputPath = path.join(__dirname, '../public/assets/images/sh.avif');
if (!fs.existsSync(inputPath)) {
console.error('Input file not found:', inputPath);
process.exit(1);
}
try {
await sharp(inputPath)
.toFormat('avif')
.toFile(outputPath);
console.log(`Successfully converted ${inputPath} to ${outputPath}`);
} catch (error) {
console.error('Error converting image:', error);
process.exit(1);
}
};
run();

View File

@@ -18,6 +18,9 @@ import PaletteIcon from "@mui/icons-material/Palette";
import ScienceIcon from "@mui/icons-material/Science";
import { CarouselProvider } from "./contexts/CarouselContext.js";
import { ProductContextProvider } from "./context/ProductContext.js";
import { CategoryContextProvider } from "./context/CategoryContext.js";
import TitleUpdater from "./components/TitleUpdater.js";
import config from "./config.js";
import ScrollToTop from "./components/ScrollToTop.js";
@@ -47,6 +50,7 @@ const Datenschutz = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/D
const AGB = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/AGB.js"));
//const NotFound404 = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/NotFound404.js")); <Route path="/404" element={<NotFound404 />} />
const Sitemap = lazy(() => import(/* webpackChunkName: "sitemap" */ "./pages/Sitemap.js"));
const CategoriesPage = lazy(() => import(/* webpackChunkName: "categories" */ "./pages/CategoriesPage.js"));
const Impressum = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Impressum.js"));
const Batteriegesetzhinweise = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Batteriegesetzhinweise.js"));
const Widerrufsrecht = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Widerrufsrecht.js"));
@@ -222,9 +226,10 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
bgcolor: "background.default",
}}
>
<TitleUpdater />
<ScrollToTop />
<Header active categoryId={categoryId} key={authVersion} />
<Box sx={{ flexGrow: 1 }}>
<Box component="main" sx={{ flexGrow: 1 }}>
<Suspense fallback={
// Use prerender fallback if available, otherwise show loading spinner
typeof window !== "undefined" && window.__PRERENDER_FALLBACK__ ? (
@@ -256,19 +261,19 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
{/* Category page - Render Content in parallel */}
<Route
path="/Kategorie/:categoryId"
element={<Content/>}
element={<Content />}
/>
{/* Single product page */}
<Route
path="/Artikel/:seoName"
element={<ProductDetail/>}
element={<ProductDetail />}
/>
{/* Search page - Render Content in parallel */}
<Route path="/search" element={<Content/>} />
<Route path="/search" element={<Content />} />
{/* Profile page */}
<Route path="/profile" element={<ProfilePage/>} />
<Route path="/profile" element={<ProfilePage />} />
{/* Payment success page for Mollie redirects */}
<Route path="/payment/success" element={<PaymentSuccess />} />
@@ -276,22 +281,23 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
{/* Reset password page */}
<Route
path="/resetPassword"
element={<ResetPassword/>}
element={<ResetPassword />}
/>
{/* Admin page */}
<Route path="/admin" element={<AdminPage/>} />
<Route path="/admin" element={<AdminPage />} />
{/* Admin Users page */}
<Route path="/admin/users" element={<UsersPage/>} />
<Route path="/admin/users" element={<UsersPage />} />
{/* Admin Server Logs page */}
<Route path="/admin/logs" element={<ServerLogsPage/>} />
<Route path="/admin/logs" element={<ServerLogsPage />} />
{/* Legal pages */}
<Route path="/datenschutz" element={<Datenschutz />} />
<Route path="/agb" element={<AGB />} />
<Route path="/sitemap" element={<Sitemap />} />
<Route path="/Kategorien" element={<CategoriesPage />} />
<Route path="/impressum" element={<Impressum />} />
<Route
path="/batteriegesetzhinweise"
@@ -300,7 +306,7 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
<Route path="/widerrufsrecht" element={<Widerrufsrecht />} />
{/* Grow Tent Configurator */}
<Route path="/Konfigurator" element={<GrowTentKonfigurator/>} />
<Route path="/Konfigurator" element={<GrowTentKonfigurator />} />
{/* Separate pages that are truly different */}
<Route path="/presseverleih" element={<PresseverleihPage />} />
@@ -450,12 +456,16 @@ const App = () => {
return (
<LanguageProvider i18n={i18n}>
<ThemeProvider theme={dynamicTheme}>
<CssBaseline />
<AppContent
currentTheme={currentTheme}
dynamicTheme={dynamicTheme}
onThemeChange={handleThemeChange}
/>
<ProductContextProvider>
<CategoryContextProvider>
<CssBaseline />
<AppContent
currentTheme={currentTheme}
dynamicTheme={dynamicTheme}
onThemeChange={handleThemeChange}
/>
</CategoryContextProvider>
</ProductContextProvider>
</ThemeProvider>
</LanguageProvider>
);

View File

@@ -44,7 +44,7 @@ const PrerenderAppContent = (socket) => (
<CategoryList categoryId={209} activeCategoryId={null} socket={socket}/>
</AppBar>
<Box sx={{ flexGrow: 1 }}>
<Box component="main" sx={{ flexGrow: 1 }}>
<CarouselProvider>
<Routes>
<Route path="/" element={<MainPageLayout />} />

View File

@@ -0,0 +1,118 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Paper from '@mui/material/Paper';
import LegalPage from './pages/LegalPage.js';
import CategoryBox from './components/CategoryBox.js';
const PrerenderCategoriesPage = ({ categoryData }) => {
// Helper function to recursively collect all categories from the tree
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;
};
// The categoryData passed prop is the root tree (id: 209)
const rootTree = categoryData;
const renderLevel1Section = (l1Node) => {
// Collect all descendants (excluding the L1 node itself, which collectAllCategories would include first)
const descendants = collectAllCategories(l1Node).slice(1);
return (
<Paper
key={l1Node.id}
elevation={1}
sx={{
p: 2,
mb: 3,
display: 'flex',
flexDirection: { xs: 'column', md: 'row' },
alignItems: { xs: 'flex-start', md: 'flex-start' },
gap: 3
}}
>
{/* Level 1 Header/Box */}
<Box sx={{
minWidth: '150px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1
}}>
<CategoryBox
id={l1Node.id}
name={l1Node.name}
seoName={l1Node.seoName}
sx={{
boxShadow: 4,
width: '150px',
height: '150px'
}}
/>
</Box>
{/* Descendants area */}
<Box sx={{ flex: 1 }}>
<Box sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 2
}}>
{descendants.map((cat) => (
<CategoryBox
key={cat.id}
id={cat.id}
name={cat.name}
seoName={cat.seoName}
sx={{
width: '100px',
height: '100px',
minWidth: '100px',
minHeight: '100px',
boxShadow: 1,
fontSize: '0.9rem'
}}
/>
))}
</Box>
</Box>
</Paper>
);
};
const content = (
<Box>
<Box>
{rootTree && rootTree.children && rootTree.children.map((child) => (
renderLevel1Section(child)
))}
{(!rootTree || !rootTree.children || rootTree.children.length === 0) && (
<Typography>Keine Kategorien gefunden.</Typography>
)}
</Box>
</Box>
);
return <LegalPage title="Kategorien" content={content} />;
};
export default PrerenderCategoriesPage;

View File

@@ -111,7 +111,7 @@ const PrerenderCategory = ({ categoryId, categoryName, categorySeoName: _categor
component="img"
height="200"
image={product.pictureList && product.pictureList.trim()
? `/assets/images/prod${product.pictureList.split(',')[0].trim()}.jpg`
? `/assets/images/prod${product.pictureList.split(',')[0].trim()}.avif`
: '/assets/images/nopicture.jpg'
}
alt={product.name}

View File

@@ -79,7 +79,7 @@ class ArticleAvailabilityForm extends Component {
} else {
this.setState({
loading: false,
error: response.error || 'Ein Fehler ist aufgetreten'
error: response.error || this.props.t("productDialogs.errorGeneric")
});
}
@@ -114,20 +114,21 @@ class ArticleAvailabilityForm extends Component {
render() {
const { name, email, telegramId, notificationMethod, message, loading, success, error } = this.state;
const { t } = this.props;
return (
<Paper id="availability-form" sx={{ p: 3, mt: 4, borderRadius: 2, boxShadow: "0 2px 8px rgba(0,0,0,0.08)" }}>
<Typography variant="h5" gutterBottom sx={{ fontWeight: 600, color: '#333' }}>
Verfügbarkeit anfragen
{t("productDialogs.availabilityTitle")}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Dieser Artikel ist derzeit nicht verfügbar. Gerne informieren wir Sie, sobald er wieder lieferbar ist.
{t("productDialogs.availabilitySubtitle")}
</Typography>
{success && (
<Alert severity="success" sx={{ mb: 3 }}>
Vielen Dank für Ihre Anfrage! Wir werden Sie {notificationMethod === 'email' ? 'per E-Mail' : 'über Telegram'} informieren, sobald der Artikel wieder verfügbar ist.
{notificationMethod === 'email' ? t("productDialogs.availabilitySuccessEmail") : t("productDialogs.availabilitySuccessTelegram")}
</Alert>
)}
@@ -139,18 +140,18 @@ class ArticleAvailabilityForm extends Component {
<Box component="form" onSubmit={this.handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Name"
label={t("productDialogs.nameLabel")}
value={name}
onChange={this.handleInputChange('name')}
required
fullWidth
disabled={loading}
placeholder="Ihr Name"
placeholder={t("productDialogs.namePlaceholder")}
/>
<FormControl component="fieldset" disabled={loading}>
<FormLabel component="legend" sx={{ mb: 1 }}>
Wie möchten Sie benachrichtigt werden?
{t("productDialogs.notificationMethodLabel")}
</FormLabel>
<RadioGroup
value={notificationMethod}
@@ -160,51 +161,51 @@ class ArticleAvailabilityForm extends Component {
<FormControlLabel
value="email"
control={<Radio />}
label="E-Mail"
label={t("productDialogs.emailLabel")}
/>
<FormControlLabel
value="telegram"
control={<Radio />}
label="Telegram Bot"
label={t("productDialogs.telegramBotLabel")}
/>
</RadioGroup>
</FormControl>
{notificationMethod === 'email' && (
<TextField
label="E-Mail"
label={t("productDialogs.emailLabel")}
type="email"
value={email}
onChange={this.handleInputChange('email')}
required
fullWidth
disabled={loading}
placeholder="ihre.email@example.com"
placeholder={t("productDialogs.emailPlaceholder")}
/>
)}
{notificationMethod === 'telegram' && (
<TextField
label="Telegram ID"
label={t("productDialogs.telegramIdLabel")}
value={telegramId}
onChange={this.handleInputChange('telegramId')}
required
fullWidth
disabled={loading}
placeholder="@IhrTelegramName oder Telegram ID"
helperText="Geben Sie Ihren Telegram-Benutzernamen (mit @) oder Ihre Telegram-ID ein"
placeholder={t("productDialogs.telegramPlaceholder")}
helperText={t("productDialogs.telegramHelper")}
/>
)}
<TextField
label="Nachricht (optional)"
label={t("productDialogs.messageLabel")}
value={message}
onChange={this.handleInputChange('message')}
fullWidth
multiline
rows={3}
disabled={loading}
placeholder="Zusätzliche Informationen oder Fragen..."
placeholder={t("productDialogs.messagePlaceholder")}
/>
<Button
@@ -225,10 +226,10 @@ class ArticleAvailabilityForm extends Component {
{loading ? (
<>
<CircularProgress size={20} sx={{ mr: 1 }} />
Wird gesendet...
{t("productDialogs.sending")}
</>
) : (
'Verfügbarkeit anfragen'
t("productDialogs.submitAvailability")
)}
</Button>
</Box>

View File

@@ -98,7 +98,7 @@ class ArticleQuestionForm extends Component {
} else {
this.setState({
loading: false,
error: response.error || 'Ein Fehler ist aufgetreten'
error: response.error || this.props.t("productDialogs.errorGeneric")
});
}
@@ -110,7 +110,7 @@ class ArticleQuestionForm extends Component {
} catch {
this.setState({
loading: false,
error: 'Fehler beim Verarbeiten der Fotos'
error: this.props.t("productDialogs.errorPhotos")
});
}
@@ -140,20 +140,21 @@ class ArticleQuestionForm extends Component {
render() {
const { name, email, question, loading, success, error } = this.state;
const { t } = this.props;
return (
<Paper sx={{ p: 3, mt: 4, borderRadius: 2, boxShadow: "0 2px 8px rgba(0,0,0,0.08)" }}>
<Typography variant="h5" gutterBottom sx={{ fontWeight: 600, color: '#333' }}>
Frage zum Artikel
{t("productDialogs.questionTitle")}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Haben Sie eine Frage zu diesem Artikel? Wir helfen Ihnen gerne weiter.
{t("productDialogs.questionSubtitle")}
</Typography>
{success && (
<Alert severity="success" sx={{ mb: 3 }}>
Vielen Dank für Ihre Frage! Wir werden uns schnellstmöglich bei Ihnen melden.
{t("productDialogs.questionSuccess")}
</Alert>
)}
@@ -165,28 +166,28 @@ class ArticleQuestionForm extends Component {
<Box component="form" onSubmit={this.handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Name"
label={t("productDialogs.nameLabel")}
value={name}
onChange={this.handleInputChange('name')}
required
fullWidth
disabled={loading}
placeholder="Ihr Name"
placeholder={t("productDialogs.namePlaceholder")}
/>
<TextField
label="E-Mail"
label={t("productDialogs.emailLabel")}
type="email"
value={email}
onChange={this.handleInputChange('email')}
required
fullWidth
disabled={loading}
placeholder="ihre.email@example.com"
placeholder={t("productDialogs.emailPlaceholder")}
/>
<TextField
label="Ihre Frage"
label={t("productDialogs.questionLabel")}
value={question}
onChange={this.handleInputChange('question')}
required
@@ -194,7 +195,7 @@ class ArticleQuestionForm extends Component {
multiline
rows={4}
disabled={loading}
placeholder="Beschreiben Sie Ihre Frage zu diesem Artikel..."
placeholder={t("productDialogs.questionPlaceholder")}
/>
<PhotoUpload
@@ -202,7 +203,7 @@ class ArticleQuestionForm extends Component {
onChange={this.handlePhotosChange}
disabled={loading}
maxFiles={3}
label="Fotos zur Frage anhängen (optional)"
label={t("productDialogs.photosLabelQuestion")}
/>
<Button
@@ -219,10 +220,10 @@ class ArticleQuestionForm extends Component {
{loading ? (
<>
<CircularProgress size={20} sx={{ mr: 1 }} />
Wird gesendet...
{t("productDialogs.sending")}
</>
) : (
'Frage senden'
t("productDialogs.submitQuestion")
)}
</Button>
</Box>

View File

@@ -106,7 +106,7 @@ class ArticleRatingForm extends Component {
} else {
this.setState({
loading: false,
error: response.error || 'Ein Fehler ist aufgetreten'
error: response.error || this.props.t("productDialogs.errorGeneric")
});
}
@@ -118,7 +118,7 @@ class ArticleRatingForm extends Component {
} catch {
this.setState({
loading: false,
error: 'Fehler beim Verarbeiten der Fotos'
error: this.props.t("productDialogs.errorPhotos")
});
}
@@ -149,20 +149,21 @@ class ArticleRatingForm extends Component {
render() {
const { name, email, rating, review, loading, success, error } = this.state;
const { t } = this.props;
return (
<Paper sx={{ p: 3, mt: 4, borderRadius: 2, boxShadow: "0 2px 8px rgba(0,0,0,0.08)" }}>
<Typography variant="h5" gutterBottom sx={{ fontWeight: 600, color: '#333' }}>
Artikel Bewerten
{t("productDialogs.ratingTitle")}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Teilen Sie Ihre Erfahrungen mit diesem Artikel und helfen Sie anderen Kunden bei der Entscheidung.
{t("productDialogs.ratingSubtitle")}
</Typography>
{success && (
<Alert severity="success" sx={{ mb: 3 }}>
Vielen Dank für Ihre Bewertung! Sie wird nach Prüfung veröffentlicht.
{t("productDialogs.ratingSuccess")}
</Alert>
)}
@@ -174,30 +175,30 @@ class ArticleRatingForm extends Component {
<Box component="form" onSubmit={this.handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Name"
label={t("productDialogs.nameLabel")}
value={name}
onChange={this.handleInputChange('name')}
required
fullWidth
disabled={loading}
placeholder="Ihr Name"
placeholder={t("productDialogs.namePlaceholder")}
/>
<TextField
label="E-Mail"
label={t("productDialogs.emailLabel")}
type="email"
value={email}
onChange={this.handleInputChange('email')}
required
fullWidth
disabled={loading}
placeholder="ihre.email@example.com"
helperText="Ihre E-Mail wird nicht veröffentlicht"
placeholder={t("productDialogs.emailPlaceholder")}
helperText={t("productDialogs.emailHelper")}
/>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
Bewertung *
{t("productDialogs.ratingLabel")}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Rating
@@ -209,20 +210,20 @@ class ArticleRatingForm extends Component {
emptyIcon={<StarIcon style={{ opacity: 0.55 }} fontSize="inherit" />}
/>
<Typography variant="body2" color="text.secondary">
{rating > 0 ? `${rating} von 5 Sternen` : 'Bitte bewerten'}
{rating > 0 ? t("productDialogs.ratingStars", { rating }) : t("productDialogs.pleaseRate")}
</Typography>
</Box>
</Box>
<TextField
label="Ihre Bewertung (optional)"
label={t("productDialogs.reviewLabel")}
value={review}
onChange={this.handleInputChange('review')}
fullWidth
multiline
rows={4}
disabled={loading}
placeholder="Beschreiben Sie Ihre Erfahrungen mit diesem Artikel..."
placeholder={t("productDialogs.reviewPlaceholder")}
/>
<PhotoUpload
@@ -230,7 +231,7 @@ class ArticleRatingForm extends Component {
onChange={this.handlePhotosChange}
disabled={loading}
maxFiles={5}
label="Fotos zur Bewertung anhängen (optional)"
label={t("productDialogs.photosLabelRating")}
/>
<Button
@@ -247,10 +248,10 @@ class ArticleRatingForm extends Component {
{loading ? (
<>
<CircularProgress size={20} sx={{ mr: 1 }} />
Wird gesendet...
{t("productDialogs.sending")}
</>
) : (
'Bewertung abgeben'
t("productDialogs.submitRating")
)}
</Button>
</Box>

View File

@@ -23,7 +23,7 @@ class CartItem extends Component {
window.socketManager.emit('getPic', { bildId:picid, size:'tiny' }, (res) => {
if(res.success){
window.tinyPicCache[picid] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
window.tinyPicCache[picid] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/avif' }));
this.setState({image: window.tinyPicCache[picid], loading: false});
}
})

View File

@@ -47,7 +47,7 @@ const CategoryBox = ({
// Create fresh blob URL from cached binary data
try {
const uint8Array = new Uint8Array(cachedImageData);
const blob = new Blob([uint8Array], { type: 'image/jpeg' });
const blob = new Blob([uint8Array], { type: 'image/avif' });
objectUrl = URL.createObjectURL(blob);
setImageUrl(objectUrl);
setImageError(false);
@@ -73,7 +73,7 @@ const CategoryBox = ({
try {
// Convert binary data to blob URL
const uint8Array = new Uint8Array(imageData);
const blob = new Blob([uint8Array], { type: 'image/jpeg' });
const blob = new Blob([uint8Array], { type: 'image/avif' });
objectUrl = URL.createObjectURL(blob);
setImageUrl(objectUrl);
setImageError(false);
@@ -158,7 +158,7 @@ const CategoryBox = ({
position: 'relative',
backgroundImage: ((typeof window !== 'undefined' && window.__PRERENDER_FALLBACK__) ||
(typeof global !== 'undefined' && global.window && global.window.__PRERENDER_FALLBACK__))
? `url("/assets/images/cat${id}.jpg")`
? `url("/assets/images/cat${id}.avif")`
: (imageUrl && !imageError ? `url("${imageUrl}")` : 'none'),
backgroundSize: 'cover',
backgroundPosition: 'center',

File diff suppressed because it is too large Load Diff

View File

@@ -296,7 +296,7 @@ class Footer extends Component {
>
<Box
component="img"
src="/assets/images/gg.png"
src="/assets/images/gg.avif"
alt="Google Reviews"
sx={{
height: { xs: 50, md: 60 },
@@ -326,7 +326,7 @@ class Footer extends Component {
>
<Box
component="img"
src="/assets/images/maps.png"
src="/assets/images/maps.avif"
alt="Google Maps"
sx={{
height: { xs: 40, md: 50 },
@@ -352,6 +352,9 @@ class Footer extends Component {
<Typography variant="body2" sx={{ fontSize: { xs: '11px', md: '14px' }, lineHeight: 1.5 }}>
© {new Date().getFullYear()} <StyledDomainLink href="https://growheads.de" target="_blank" rel="noopener noreferrer">GrowHeads.de</StyledDomainLink>
</Typography>
<Typography variant="body2" sx={{ fontSize: { xs: '9px', md: '9px' }, lineHeight: 1.5, mt: 1 }}>
<StyledDomainLink href="https://telegraf.growheads.de" target="_blank" rel="noreferrer">Telegraf - sicherer Chat mit unseren Mitarbeitern</StyledDomainLink>
</Typography>
</Box>
</Stack>
</Box>

View File

@@ -56,7 +56,7 @@ class Images extends Component {
pics.push(window.tinyPicCache[bildId]);
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
}else{
pics.push(`/assets/images/prod${bildId}.jpg`);
pics.push(`/assets/images/prod${bildId}.avif`);
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
}
}else{
@@ -84,7 +84,7 @@ class Images extends Component {
window.socketManager.emit('getPic', { bildId, size }, (res) => {
if(res.success){
const url = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
const url = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/avif' }));
if(size === 'medium') window.mediumPicCache[bildId] = url;
if(size === 'small') window.smallPicCache[bildId] = url;
@@ -118,7 +118,7 @@ class Images extends Component {
if (!this.props.pictureList || !this.props.pictureList.trim()) {
return '/assets/images/nopicture.jpg';
}
return `/assets/images/prod${this.props.pictureList.split(',')[0].trim()}.jpg`;
return `/assets/images/prod${this.props.pictureList.split(',')[0].trim()}.avif`;
};
return (

View File

@@ -175,12 +175,12 @@ export class LoginComponent extends Component {
const { location, navigate } = this.props;
if (!email || !password) {
this.setState({ error: 'Bitte füllen Sie alle Felder aus' });
this.setState({ error: this.props.t ? this.props.t('auth.errors.fillAllFields') : 'Bitte füllen Sie alle Felder aus' });
return;
}
if (!this.validateEmail(email)) {
this.setState({ error: 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
this.setState({ error: this.props.t ? this.props.t('auth.errors.invalidEmail') : 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
return;
}
@@ -238,7 +238,7 @@ export class LoginComponent extends Component {
} else {
this.setState({
loading: false,
error: response.message || 'Anmeldung fehlgeschlagen'
error: response.message || (this.props.t ? this.props.t('auth.errors.loginFailed') : 'Anmeldung fehlgeschlagen')
});
}
});
@@ -248,22 +248,22 @@ export class LoginComponent extends Component {
const { email, password, confirmPassword } = this.state;
if (!email || !password || !confirmPassword) {
this.setState({ error: 'Bitte füllen Sie alle Felder aus' });
this.setState({ error: this.props.t ? this.props.t('auth.errors.fillAllFields') : 'Bitte füllen Sie alle Felder aus' });
return;
}
if (!this.validateEmail(email)) {
this.setState({ error: 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
this.setState({ error: this.props.t ? this.props.t('auth.errors.invalidEmail') : 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
return;
}
if (password !== confirmPassword) {
this.setState({ error: 'Passwörter stimmen nicht überein' });
this.setState({ error: this.props.t ? this.props.t('auth.errors.passwordsNotMatchShort') : 'Passwörter stimmen nicht überein' });
return;
}
if (password.length < 8) {
this.setState({ error: 'Das Passwort muss mindestens 8 Zeichen lang sein' });
this.setState({ error: this.props.t ? this.props.t('auth.passwordMinLength') : 'Das Passwort muss mindestens 8 Zeichen lang sein' });
return;
}
@@ -274,14 +274,14 @@ export class LoginComponent extends Component {
if (response.success) {
this.setState({
loading: false,
success: 'Registrierung erfolgreich. Sie können sich jetzt anmelden.',
success: this.props.t ? this.props.t('auth.success.registerComplete') : 'Registrierung erfolgreich. Sie können sich jetzt anmelden.',
tabValue: 0 // Switch to login tab
});
} else {
let errorMessage = 'Registrierung fehlgeschlagen';
let errorMessage = this.props.t ? this.props.t('auth.errors.registerFailed') : 'Registrierung fehlgeschlagen';
if (response.cause === 'emailExists') {
errorMessage = 'Ein Benutzer mit dieser E-Mail-Adresse existiert bereits. Bitte verwenden Sie eine andere E-Mail-Adresse oder melden Sie sich an.';
errorMessage = this.props.t ? this.props.t('auth.errors.emailExists') : 'Ein Benutzer mit dieser E-Mail-Adresse existiert bereits. Bitte verwenden Sie eine andere E-Mail-Adresse oder melden Sie sich an.';
} else if (response.message) {
errorMessage = response.message;
}
@@ -322,12 +322,12 @@ export class LoginComponent extends Component {
const { email } = this.state;
if (!email) {
this.setState({ error: 'Bitte geben Sie Ihre E-Mail-Adresse ein' });
this.setState({ error: this.props.t ? this.props.t('auth.errors.enterEmail') : 'Bitte geben Sie Ihre E-Mail-Adresse ein' });
return;
}
if (!this.validateEmail(email)) {
this.setState({ error: 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
this.setState({ error: this.props.t ? this.props.t('auth.errors.invalidEmail') : 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
return;
}
@@ -342,12 +342,12 @@ export class LoginComponent extends Component {
if (response.success) {
this.setState({
loading: false,
success: 'Ein Link zum Zurücksetzen des Passworts wurde an Ihre E-Mail-Adresse gesendet.'
success: this.props.t ? this.props.t('auth.resetPassword.emailSent') : 'Ein Link zum Zurücksetzen des Passworts wurde an Ihre E-Mail-Adresse gesendet.'
});
} else {
this.setState({
loading: false,
error: response.message || 'Fehler beim Senden der E-Mail'
error: response.message || (this.props.t ? this.props.t('auth.resetPassword.emailError') : 'Fehler beim Senden der E-Mail')
});
}
});
@@ -408,7 +408,7 @@ export class LoginComponent extends Component {
} else {
this.setState({
loading: false,
error: 'Google-Anmeldung fehlgeschlagen',
error: this.props.t ? this.props.t('auth.errors.googleLoginFailed') : 'Google-Anmeldung fehlgeschlagen',
showGoogleAuth: false // Reset Google auth state on failed login
});
}
@@ -418,7 +418,7 @@ export class LoginComponent extends Component {
handleGoogleLoginError = (error) => {
console.error('Google Login Error:', error);
this.setState({
error: 'Google-Anmeldung fehlgeschlagen',
error: this.props.t ? this.props.t('auth.errors.googleLoginFailed') : 'Google-Anmeldung fehlgeschlagen',
showGoogleAuth: false, // Reset Google auth state on error
loading: false
});

View File

@@ -156,15 +156,15 @@ const MainPageLayout = () => {
};
const allTitles = {
home: t('titles.home') ,
home: t('titles.home'),
aktionen: t('titles.aktionen'),
filiale: t('titles.filiale')
};
const allContentBoxes = {
home: [
{ title: t('sections.seeds'), image: "/assets/images/seeds.jpg", bgcolor: "#e1f0d3", link: "/Kategorie/Seeds" },
{ title: t('sections.stecklinge'), image: "/assets/images/cutlings.jpg", bgcolor: "#e8f5d6", link: "/Kategorie/Stecklinge" }
{ title: t('sections.seeds'), image: "/assets/images/seeds.avif", bgcolor: "#e1f0d3", link: "/Kategorie/Seeds" },
{ title: t('sections.konfigurator'), image: "/assets/images/konfigurator.avif", bgcolor: "#e8f5d6", link: "/Konfigurator" }
],
aktionen: [
{ title: t('sections.oilPress'), image: "/assets/images/presse.jpg", bgcolor: "#e1f0d3", link: "/presseverleih" },
@@ -262,16 +262,16 @@ const MainPageLayout = () => {
position: pageType === "home" ? "relative" : "absolute", top: 0, left: 0, width: "100%", pointerEvents: getOpacity(pageType) === 1 ? "auto" : "none"
}}>
{contentBoxes.map((box, index) => (
<ContentBox
key={`${pageType}-${index}`}
box={box}
index={index}
pageType={pageType}
starHovered={starHovered}
setStarHovered={setStarHovered}
opacity={getOpacity(pageType)}
translatedContent={translatedContent}
/>
<ContentBox
key={`${pageType}-${index}`}
box={box}
index={index}
pageType={pageType}
starHovered={starHovered}
setStarHovered={setStarHovered}
opacity={getOpacity(pageType)}
translatedContent={translatedContent}
/>
))}
</Grid>
))}

View File

@@ -10,6 +10,7 @@ import {
} from '@mui/material';
import Delete from '@mui/icons-material/Delete';
import CloudUpload from '@mui/icons-material/CloudUpload';
import { withI18n } from '../i18n/withTranslation.js';
class PhotoUpload extends Component {
constructor(props) {
@@ -30,7 +31,7 @@ class PhotoUpload extends Component {
// Validate file count
if (this.state.files.length + selectedFiles.length > maxFiles) {
this.setState({
error: `Maximal ${maxFiles} Dateien erlaubt`
error: this.props.t("productDialogs.photoUploadErrorMaxFiles", { max: maxFiles })
});
return;
}
@@ -43,14 +44,14 @@ class PhotoUpload extends Component {
for (const file of selectedFiles) {
if (!validTypes.includes(file.type)) {
this.setState({
error: 'Nur Bilddateien (JPEG, PNG, GIF, WebP) sind erlaubt'
error: this.props.t("productDialogs.photoUploadErrorFileType")
});
continue;
}
if (file.size > maxSize) {
this.setState({
error: `Datei zu groß. Maximum: ${Math.round(maxSize / (1024 * 1024))}MB`
error: this.props.t("productDialogs.photoUploadErrorFileSize", { maxSize: Math.round(maxSize / (1024 * 1024)) })
});
continue;
}
@@ -167,12 +168,12 @@ class PhotoUpload extends Component {
render() {
const { files, previews, error } = this.state;
const { disabled, label } = this.props;
const { disabled, label, t } = this.props;
return (
<Box>
<Typography variant="body2" sx={{ mb: 1, fontWeight: 500 }}>
{label || 'Fotos anhängen (optional)'}
{label || t("productDialogs.photoUploadLabelDefault")}
</Typography>
<input
@@ -192,7 +193,7 @@ class PhotoUpload extends Component {
disabled={disabled}
sx={{ mb: 2 }}
>
Fotos auswählen
{t("productDialogs.photoUploadSelect")}
</Button>
{error && (
@@ -228,7 +229,7 @@ class PhotoUpload extends Component {
size="small"
onClick={() => this.handleRemoveFile(index)}
disabled={disabled}
aria-label="Bild entfernen"
aria-label={t("productDialogs.photoUploadRemove")}
sx={{
position: 'absolute',
top: 4,
@@ -269,10 +270,10 @@ class PhotoUpload extends Component {
{files.length > 0 && (
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
{files.length} Datei(en) ausgewählt
{t("productDialogs.photoUploadSelectedFiles", { count: files.length })}
{previews.length > 0 && previews.some(p => p.originalSize && p.compressedSize) && (
<span style={{ marginLeft: '8px' }}>
(komprimiert für Upload)
{t("productDialogs.photoUploadCompressed")}
</span>
)}
</Typography>
@@ -282,4 +283,4 @@ class PhotoUpload extends Component {
}
}
export default PhotoUpload;
export default withI18n()(PhotoUpload);

View File

@@ -101,7 +101,7 @@ class Product extends Component {
console.log('loadImagevisSocket', bildId);
window.socketManager.emit('getPic', { bildId, size:'small' }, (res) => {
if(res.success){
window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/avif' }));
if (this._isMounted) {
this.setState({image: window.smallPicCache[bildId], loading: false});
} else {

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { Link } from "react-router-dom";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
@@ -26,7 +27,7 @@ class ProductCarousel extends React.Component {
constructor(props) {
super(props);
const { i18n } = props;
this.state = {
products: [],
currentLanguage: (i18n && i18n.language) || 'de',
@@ -39,14 +40,14 @@ class ProductCarousel extends React.Component {
componentDidMount() {
this._isMounted = true;
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n.language;
console.log("ProductCarousel componentDidMount: Loading products for categoryId", this.props.categoryId, "language", currentLanguage);
this.loadProducts(currentLanguage);
}
componentDidUpdate(prevProps) {
console.log("ProductCarousel componentDidUpdate", prevProps.languageContext?.currentLanguage, this.props.languageContext?.currentLanguage);
if(prevProps.languageContext?.currentLanguage !== this.props.languageContext?.currentLanguage) {
if (prevProps.languageContext?.currentLanguage !== this.props.languageContext?.currentLanguage) {
this.setState({ products: [] }, () => {
this.loadProducts(this.props.languageContext?.currentLanguage || this.props.i18n.language);
});
@@ -55,13 +56,13 @@ class ProductCarousel extends React.Component {
loadProducts = (language) => {
const { categoryId } = this.props;
window.socketManager.emit(
"getCategoryProducts",
{
categoryId: categoryId === "neu" ? "neu" : categoryId,
language: language,
requestTranslation: language === 'de' ? false : true
{
categoryId: categoryId === "neu" ? "neu" : categoryId,
language: language,
requestTranslation: language === 'de' ? false : true
},
(response) => {
console.log("ProductCarousel getCategoryProducts response:", response);
@@ -136,7 +137,7 @@ class ProductCarousel extends React.Component {
showScrollbarFlash = () => {
this.clearScrollbarTimer();
this.setState({ showScrollbar: true });
this.scrollbarTimer = setTimeout(() => {
if (this._isMounted) {
this.setState({ showScrollbar: false });
@@ -146,7 +147,7 @@ class ProductCarousel extends React.Component {
handleAutoScroll = () => {
if (!this.autoScrollActive || this.originalProducts.length === 0) return;
this.translateX -= AUTO_SCROLL_SPEED;
this.updateTrackTransform();
@@ -185,7 +186,7 @@ class ProductCarousel extends React.Component {
scrollBy = (direction) => {
if (this.originalProducts.length === 0) return;
// direction: 1 = left (scroll content right), -1 = right (scroll content left)
const originalItemCount = this.originalProducts.length;
const maxScroll = ITEM_WIDTH * originalItemCount;
@@ -202,7 +203,7 @@ class ProductCarousel extends React.Component {
}
this.updateTrackTransform();
// Force scrollbar to update immediately after wrap-around
if (this.state.showScrollbar) {
this.forceUpdate();
@@ -217,10 +218,10 @@ class ProductCarousel extends React.Component {
const originalItemCount = this.originalProducts.length;
const viewportWidth = 1080; // carousel container max-width
const itemsInView = Math.floor(viewportWidth / ITEM_WIDTH);
// Calculate which item is currently at the left edge (first visible)
let currentItemIndex;
if (this.translateX === 0) {
currentItemIndex = 0;
} else if (this.translateX > 0) {
@@ -230,10 +231,10 @@ class ProductCarousel extends React.Component {
} else {
currentItemIndex = Math.floor(Math.abs(this.translateX) / ITEM_WIDTH);
}
// Ensure we stay within bounds
currentItemIndex = Math.max(0, Math.min(currentItemIndex, originalItemCount - 1));
// Calculate scrollbar position
const lastPossibleFirstItem = Math.max(0, originalItemCount - itemsInView);
const thumbPosition = lastPossibleFirstItem > 0 ? Math.min((currentItemIndex / lastPossibleFirstItem) * 100, 100) : 0;
@@ -277,25 +278,41 @@ class ProductCarousel extends React.Component {
const { t, title } = this.props;
const { products } = this.state;
if(!products || products.length === 0) {
if (!products || products.length === 0) {
return null;
}
return (
<Box sx={{ mt: 3 }}>
<Typography
variant="h4"
component="h2"
<Box
component={Link}
to="/Kategorie/neu"
sx={{
mb: 2,
fontFamily: "SwashingtonCP",
display: "flex",
alignItems: "center",
justifyContent: "center",
textDecoration: "none",
color: "primary.main",
textAlign: "center",
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)"
mb: 2,
transition: "all 0.3s ease",
"&:hover": {
transform: "translateX(5px)",
color: "primary.dark"
}
}}
>
{title || t('product.new')}
</Typography>
<Typography
variant="h4"
component="span"
sx={{
fontFamily: "SwashingtonCP",
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)"
}}
>
{title || t('product.new')}
</Typography>
<ChevronRight sx={{ fontSize: "2.5rem", ml: 1 }} />
</Box>
<div
className="product-carousel-wrapper"
@@ -420,7 +437,7 @@ class ProductCarousel extends React.Component {
</div>
))}
</div>
{/* Virtual Scrollbar */}
{this.renderVirtualScrollbar()}
</div>

View File

@@ -94,6 +94,9 @@ class ProductDetailPage extends Component {
komponentenImages: {}, // Store tiny pictures for komponenten
totalKomponentenPrice: 0,
totalSavings: 0,
// Embedded products from <product> tags in description
embeddedProducts: {},
embeddedProductImages: {},
// Collapsible sections state
showQuestionForm: false,
showRatingForm: false,
@@ -143,6 +146,9 @@ class ProductDetailPage extends Component {
komponentenImages: {}, // Store tiny pictures for komponenten
totalKomponentenPrice: 0,
totalSavings: 0,
// Embedded products from <product> tags in description
embeddedProducts: {},
embeddedProductImages: {},
// Collapsible sections state
showQuestionForm: false,
showRatingForm: false,
@@ -174,6 +180,9 @@ class ProductDetailPage extends Component {
komponentenImages: {}, // Store tiny pictures for komponenten
totalKomponentenPrice: 0,
totalSavings: 0,
// Embedded products from <product> tags in description
embeddedProducts: {},
embeddedProductImages: {},
// Collapsible sections state
showQuestionForm: false,
showRatingForm: false,
@@ -192,6 +201,17 @@ class ProductDetailPage extends Component {
}
componentDidMount() {
// Update context with cached product if available
if (this.state.product && this.props.setCurrentProduct) {
console.log('ProductDetailPage: Setting product context from cache', this.state.product.name);
this.props.setCurrentProduct({
name: this.state.product.name,
categoryId: this.state.product.kategorien ? this.state.product.kategorien.split(',')[0] : undefined
});
} else if (this.state.product) {
console.warn('ProductDetailPage: setCurrentProduct prop is missing despite having product');
}
// Load product data if we have no product or if we're in upgrading state
if (!this.state.product || this.state.upgrading) {
this.loadProductData();
@@ -212,6 +232,11 @@ class ProductDetailPage extends Component {
componentDidUpdate(prevProps) {
// Check for seoName changes
if (prevProps.seoName !== this.props.seoName) {
// Clear context when navigating to new product
if (this.props.setCurrentProduct) {
this.props.setCurrentProduct(null);
}
this.setState(
{ product: null, loading: true, upgrading: false, error: null, imageDialogOpen: false, similarProducts: [] },
this.loadProductData
@@ -280,7 +305,7 @@ class ProductDetailPage extends Component {
window.socketManager.emit('getPic', { bildId, size: 'small' }, (res) => {
if (res.success) {
// Cache the image
window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/avif' }));
// Update state
this.setState(prevState => ({
@@ -521,7 +546,7 @@ class ProductDetailPage extends Component {
console.log("getAttributePicture", res);
if (res.success && !res.noPicture) {
const blob = new Blob([res.imageBuffer], {
type: "image/jpeg",
type: "image/avif",
});
const url = URL.createObjectURL(blob);
@@ -574,13 +599,16 @@ class ProductDetailPage extends Component {
const productData = res.translatedProduct || res.product;
productData.seoName = this.props.seoName;
// Use translated attributes if available
const attributesData = res.translatedAttributes || res.attributes;
// Initialize cache if it doesn't exist
if (!window.productDetailCache) {
window.productDetailCache = {};
}
// Cache the complete response data (product + attributes) - cache the response with translated product
const cacheData = { ...res, product: productData };
const cacheData = { ...res, product: productData, attributes: attributesData };
window.productDetailCache[cacheKey] = cacheData;
// Clean up prerender fallback since we now have real data
@@ -603,11 +631,22 @@ class ProductDetailPage extends Component {
upgrading: false, // Clear upgrading state since we now have complete data
error: null,
imageDialogOpen: false,
attributes: res.attributes,
attributes: attributesData,
komponenten: komponenten,
komponentenLoaded: komponenten.length === 0, // If no komponenten, mark as loaded
similarProducts: res.similarProducts || []
}, () => {
// Update context
if (this.props.setCurrentProduct) {
console.log('ProductDetailPage: Setting product context from fetch', productData.name);
this.props.setCurrentProduct({
name: productData.name,
categoryId: productData.kategorien ? productData.kategorien.split(',')[0] : undefined
});
} else {
console.warn('ProductDetailPage: setCurrentProduct prop is missing after fetch');
}
if(komponenten.length > 0) {
for(const komponent of komponenten) {
this.loadKomponent(komponent.id, komponent.count);
@@ -617,7 +656,7 @@ class ProductDetailPage extends Component {
console.log("getProductView", res);
// Load attribute images
this.loadAttributeImages(res.attributes);
this.loadAttributeImages(attributesData);
} else {
console.error(
"Error loading product:",
@@ -726,7 +765,7 @@ class ProductDetailPage extends Component {
handleEmbedShare = () => {
const embedCode = `<iframe src="${this.getProductUrl()}" width="100%" height="600" frameborder="0"></iframe>`;
navigator.clipboard.writeText(embedCode).then(() => {
this.showSnackbar("Einbettungscode wurde in die Zwischenablage kopiert!");
this.showSnackbar(this.props.t ? this.props.t("productDialogs.shareSuccessEmbed") : "Einbettungscode wurde in die Zwischenablage kopiert!");
}).catch(() => {
// Fallback for older browsers
try {
@@ -736,9 +775,9 @@ class ProductDetailPage extends Component {
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
this.showSnackbar("Einbettungscode wurde in die Zwischenablage kopiert!");
this.showSnackbar(this.props.t ? this.props.t("productDialogs.shareSuccessEmbed") : "Einbettungscode wurde in die Zwischenablage kopiert!");
} catch {
this.showSnackbar("Fehler beim Kopieren des Einbettungscodes", "error");
this.showSnackbar(this.props.t ? this.props.t("productDialogs.shareErrorEmbed") : "Fehler beim Kopieren des Einbettungscodes", "error");
}
});
this.handleShareClose();
@@ -746,7 +785,10 @@ class ProductDetailPage extends Component {
handleWhatsAppShare = () => {
const url = this.getProductUrl();
const text = `Schau dir dieses Produkt an: ${cleanProductName(this.state.product.name)}`;
const productName = cleanProductName(this.state.product.name);
const text = this.props.t
? this.props.t("productDialogs.shareWhatsAppText", { name: productName })
: `Schau dir dieses Produkt an: ${productName}`;
const whatsappUrl = `https://wa.me/?text=${encodeURIComponent(text + ' ' + url)}`;
window.open(whatsappUrl, '_blank');
this.handleShareClose();
@@ -761,7 +803,10 @@ class ProductDetailPage extends Component {
handleTelegramShare = () => {
const url = this.getProductUrl();
const text = `Schau dir dieses Produkt an: ${cleanProductName(this.state.product.name)}`;
const productName = cleanProductName(this.state.product.name);
const text = this.props.t
? this.props.t("productDialogs.shareTelegramText", { name: productName })
: `Schau dir dieses Produkt an: ${productName}`;
const telegramUrl = `https://t.me/share/url?url=${encodeURIComponent(url)}&text=${encodeURIComponent(text)}`;
window.open(telegramUrl, '_blank');
this.handleShareClose();
@@ -769,8 +814,18 @@ class ProductDetailPage extends Component {
handleEmailShare = () => {
const url = this.getProductUrl();
const subject = `Produktempfehlung: ${cleanProductName(this.state.product.name)}`;
const body = `Hallo,\n\nich möchte dir dieses Produkt empfehlen:\n\n${cleanProductName(this.state.product.name)}\n${url}\n\nViele Grüße`;
const productName = cleanProductName(this.state.product.name);
const subject = this.props.t
? `${this.props.t("productDialogs.shareEmailSubject")}: ${productName}`
: `Produktempfehlung: ${productName}`;
const body = this.props.t
? this.props.t("productDialogs.shareEmailBody", {
name: productName,
url: url
})
: `Hallo,\n\nich möchte dir dieses Produkt empfehlen:\n\n${productName}\n${url}\n\nViele Grüße`;
const emailUrl = `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
window.location.href = emailUrl;
this.handleShareClose();
@@ -779,7 +834,7 @@ class ProductDetailPage extends Component {
handleLinkCopy = () => {
const url = this.getProductUrl();
navigator.clipboard.writeText(url).then(() => {
this.showSnackbar("Link wurde in die Zwischenablage kopiert!");
this.showSnackbar(this.props.t ? this.props.t("productDialogs.shareSuccessLink") : "Link wurde in die Zwischenablage kopiert!");
}).catch(() => {
// Fallback for older browsers
try {
@@ -789,7 +844,7 @@ class ProductDetailPage extends Component {
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
this.showSnackbar("Link wurde in die Zwischenablage kopiert!");
this.showSnackbar(this.props.t ? this.props.t("productDialogs.shareSuccessLink") : "Link wurde in die Zwischenablage kopiert!");
} catch {
this.showSnackbar("Fehler beim Kopieren des Links", "error");
}
@@ -797,7 +852,241 @@ class ProductDetailPage extends Component {
this.handleShareClose();
};
render() {
// Render embedded product from <product articlenr="..."> tag in description
renderEmbeddedProduct = (articleNr) => {
console.log('renderEmbeddedProduct called with articleNr:', articleNr);
// Check if we already have this product data in state
const embeddedProducts = this.state.embeddedProducts || {};
const productData = embeddedProducts[articleNr];
console.log('Embedded product data:', productData);
// If there was an error loading, show error message (don't retry infinitely)
if (productData && productData.error) {
return (
<Box
key={`embedded-${articleNr}`}
sx={{
my: 2,
p: 2,
background: "#fff3f3",
borderRadius: 2,
border: "1px solid #ffcdd2"
}}
>
<Typography variant="body2" color="error">
Produkt nicht gefunden (Artikelnr: {articleNr})
</Typography>
</Box>
);
}
if (!productData || !productData.loaded) {
// If not loaded yet and not currently loading, fetch it
if (!productData || (!productData.loading && !productData.error)) {
console.log('Starting to load embedded product:', articleNr);
this.loadEmbeddedProduct(articleNr);
}
// Return loading state
return (
<Box
key={`embedded-${articleNr}`}
sx={{
my: 2,
p: 2,
background: "#f9f9f9",
borderRadius: 2,
border: "1px solid #e0e0e0"
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<Box sx={{ width: 60, height: 60, flexShrink: 0, backgroundColor: "#f5f5f5", borderRadius: 1, border: "1px solid #e0e0e0" }}>
{/* Empty placeholder for image */}
</Box>
<Box>
<Typography variant="body1">
{this.props.t('product.loadingProduct')}
</Typography>
<Typography variant="body2" color="text.secondary">
{this.props.t('product.articleNumber')}: {articleNr}
</Typography>
</Box>
</Box>
</Box>
);
}
// Product data is loaded, render it
const embeddedImages = this.state.embeddedProductImages || {};
const productImage = embeddedImages[articleNr];
return (
<Box
key={`embedded-${articleNr}`}
component={Link}
to={`/Artikel/${productData.seoName}`}
sx={{
display: "block",
my: 2,
p: 2,
background: "#f9f9f9",
borderRadius: 2,
border: "1px solid #e0e0e0",
textDecoration: "none",
color: "inherit",
transition: "all 0.2s",
"&:hover": {
backgroundColor: "#f0f0f0",
boxShadow: "0 2px 8px rgba(0,0,0,0.1)"
}
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<Box sx={{ width: 60, height: 60, flexShrink: 0 }}>
{productImage ? (
<CardMedia
component="img"
height="60"
image={productImage}
alt={productData.name}
sx={{
objectFit: "contain",
borderRadius: 1,
border: "1px solid #e0e0e0"
}}
/>
) : (
<CardMedia
component="img"
height="60"
image="/assets/images/nopicture.jpg"
alt={productData.name}
sx={{
objectFit: "contain",
borderRadius: 1,
border: "1px solid #e0e0e0"
}}
/>
)}
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="body1" sx={{ fontWeight: 500, mb: 0.5 }}>
{cleanProductName(productData.name)}
</Typography>
<Typography variant="body2" color="text.secondary">
{this.props.t('product.articleNumber')}: {productData.articleNumber}
</Typography>
</Box>
<Box sx={{ textAlign: "right" }}>
<Typography variant="h6" sx={{ fontWeight: 600, color: "primary.main" }}>
{new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
}).format(productData.price)}
</Typography>
<Typography variant="caption" color="text.secondary">
{this.props.t ? this.props.t('product.inclVatSimple') : 'inkl. MwSt.'}
</Typography>
</Box>
</Box>
</Box>
);
};
// Load embedded product data by article number
loadEmbeddedProduct = (articleNr) => {
console.log('loadEmbeddedProduct', articleNr);
// Mark as loading
this.setState(prevState => ({
embeddedProducts: {
...prevState.embeddedProducts,
[articleNr]: { loading: true, loaded: false }
}
}));
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
// Fetch product data from API using getProductView (same as komponenten)
window.socketManager.emit('getProductView', {
articleNr: articleNr,
language: currentLanguage,
requestTranslation: currentLanguage === 'de' ? false : true
}, (response) => {
console.log('loadEmbeddedProduct response:', articleNr, response);
if (response.success && response.product) {
// Use translated product if available, otherwise use original product
const product = response.translatedProduct || response.product;
console.log('Successfully loaded embedded product:', articleNr, product.name);
// Update state with loaded product data
this.setState(prevState => ({
embeddedProducts: {
...prevState.embeddedProducts,
[articleNr]: {
...product,
loading: false,
loaded: true
}
}
}));
// Load product image if available
if (product.pictureList && product.pictureList.length > 0) {
const bildId = product.pictureList.split(',')[0];
this.loadEmbeddedProductImage(articleNr, bildId);
}
} else {
console.warn(`Failed to load embedded product ${articleNr}:`, response);
// Mark as failed to load
this.setState(prevState => ({
embeddedProducts: {
...prevState.embeddedProducts,
[articleNr]: {
loading: false,
loaded: false,
error: true,
errorMessage: response.error || 'Unknown error'
}
}
}));
}
});
};
// Load embedded product image
loadEmbeddedProductImage = (articleNr, bildId) => {
console.log('loadEmbeddedProductImage', articleNr, bildId);
window.socketManager.emit('getPic', { bildId, size: 'small' }, (res) => {
console.log('loadEmbeddedProductImage response:', articleNr, res.success);
if (res.success) {
const imageUrl = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/avif' }));
this.setState(prevState => {
console.log('Setting embedded product image for', articleNr);
return {
embeddedProductImages: {
...prevState.embeddedProductImages,
[articleNr]: imageUrl
}
};
});
}
});
};
componentWillUnmount() {
if (this.props.setCurrentProduct) {
this.props.setCurrentProduct(null);
}
}
render() {
const { product, loading, upgrading, error, attributeImages, isSteckling, attributes, komponentenLoaded, komponentenData, komponentenImages, totalKomponentenPrice, totalSavings, shareAnchorEl, sharePopperOpen, snackbarOpen, snackbarMessage, snackbarSeverity } =
this.state;
@@ -818,23 +1107,8 @@ class ProductDetailPage extends Component {
);
}
// Fallback to loading message if no prerender content
return (
<Box
sx={{
p: 4,
textAlign: "center",
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<Typography variant="h2" component="h2" gutterBottom>
Produkt wird geladen...
</Typography>
</Box>
);
// Fallback to blank page if no prerender content
return <div style={{ minHeight: "60vh" }} />;
}
if (error) {
@@ -992,14 +1266,39 @@ class ProductDetailPage extends Component {
{product.manufacturer && (
<Box sx={{ display: "flex", alignItems: "center", mb: 2 }}>
<Typography variant="body2" sx={{ fontStyle: "italic" }}>
{this.props.t ? this.props.t('product.manufacturer') : 'Hersteller'}: {product.manufacturer}
{this.props.t ? this.props.t('product.manufacturer') : 'Hersteller'}:
<Button
variant="text"
size="small"
onClick={() => {
if (this.props.navigate) {
this.props.navigate(`/search?q=${encodeURIComponent(product.manufacturer)}`);
}
}}
sx={{
ml: 0.5,
p: 0,
minWidth: 'auto',
height: 'auto',
fontSize: 'inherit',
fontStyle: 'inherit',
textTransform: 'none',
'&:hover': {
backgroundColor: 'transparent',
textDecoration: 'underline',
}
}}
>
{product.manufacturer}
</Button>
</Typography>
</Box>
)}
{/* Attribute images and chips with action buttons */}
{(attributes.some(attr => attributeImages[attr.kMerkmalWert]) || attributes.some(attr => !attributeImages[attr.kMerkmalWert])) && (
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", mb: 2, gap: 2 }}>
<Box sx={{ minHeight: "107px", display: "flex", justifyContent: "space-between", alignItems: "flex-start", mb: 2, gap: 2 }}>
{(attributes.some(attr => attributeImages[attr.kMerkmalWert]) || attributes.some(attr => !attributeImages[attr.kMerkmalWert])) && (
<Stack direction="row" spacing={0} sx={{ flexWrap: "wrap", gap: 1, flex: 1 }}>
{attributes
.filter(attribute => attributeImages[attribute.kMerkmalWert])
@@ -1035,62 +1334,63 @@ class ProductDetailPage extends Component {
/>
))}
</Stack>
)}
{/* Right-aligned action buttons */}
<Stack direction="column" spacing={1} sx={{ flexShrink: 0 }}>
{/* Right-aligned action buttons */}
<Stack direction="column" spacing={1} sx={{ flexShrink: 0 }}>
<Button
variant="outlined"
size="small"
onClick={this.toggleQuestionForm}
sx={{
fontSize: "0.75rem",
px: 1.5,
py: 0.5,
minWidth: "auto",
whiteSpace: "nowrap"
}}
>
{this.props.t ? this.props.t('productDialogs.questionTitle') : "Frage zum Artikel"}
</Button>
<Button
variant="outlined"
size="small"
onClick={this.toggleRatingForm}
sx={{
fontSize: "0.75rem",
px: 1.5,
py: 0.5,
minWidth: "auto",
whiteSpace: "nowrap"
}}
>
{this.props.t ? this.props.t('productDialogs.ratingTitle') : "Artikel Bewerten"}
</Button>
{(product.available !== 1 && product.availableSupplier !== 1) && (
<Button
variant="outlined"
size="small"
onClick={this.toggleQuestionForm}
onClick={this.toggleAvailabilityForm}
sx={{
fontSize: "0.75rem",
px: 1.5,
py: 0.5,
minWidth: "auto",
whiteSpace: "nowrap"
whiteSpace: "nowrap",
borderColor: "warning.main",
color: "warning.main",
"&:hover": {
borderColor: "warning.dark",
backgroundColor: "warning.light"
}
}}
>
Frage zum Artikel
{this.props.t ? this.props.t('productDialogs.availabilityTitle') : "Verfügbarkeit anfragen"}
</Button>
<Button
variant="outlined"
size="small"
onClick={this.toggleRatingForm}
sx={{
fontSize: "0.75rem",
px: 1.5,
py: 0.5,
minWidth: "auto",
whiteSpace: "nowrap"
}}
>
Artikel Bewerten
</Button>
{(product.available !== 1 && product.availableSupplier !== 1) && (
<Button
variant="outlined"
size="small"
onClick={this.toggleAvailabilityForm}
sx={{
fontSize: "0.75rem",
px: 1.5,
py: 0.5,
minWidth: "auto",
whiteSpace: "nowrap",
borderColor: "warning.main",
color: "warning.main",
"&:hover": {
borderColor: "warning.dark",
backgroundColor: "warning.light"
}
}}
>
Verfügbarkeit anfragen
</Button>
)}
</Stack>
</Box>
)}
)}
</Stack>
</Box>
{/* Weight */}
{product.weight > 0 && (
@@ -1314,7 +1614,7 @@ class ProductDetailPage extends Component {
}}
size="small"
>
Teilen
{this.props.t ? this.props.t("productDialogs.shareTitle") : "Teilen"}
</Button>
<Box
sx={{
@@ -1326,16 +1626,30 @@ class ProductDetailPage extends Component {
>
{product.description ? (() => {
try {
// Sanitize HTML to remove invalid tags, but preserve style attributes
return parse(sanitizeHtml(product.description, {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
// Sanitize HTML to remove invalid tags, but preserve style attributes and <product> tags
const sanitized = sanitizeHtml(product.description, {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img', 'product']),
allowedAttributes: {
'*': ['class', 'style'],
'a': ['href', 'title'],
'img': ['src', 'alt', 'width', 'height']
'img': ['src', 'alt', 'width', 'height'],
'product': ['articlenr']
},
disallowedTagsMode: 'discard'
}));
});
// Parse with custom replace function to handle <product> tags
return parse(sanitized, {
replace: (domNode) => {
if (domNode.type === 'tag' && domNode.name === 'product') {
const articleNr = domNode.attribs && domNode.attribs['articlenr'];
if (articleNr) {
// Render embedded product component
return this.renderEmbeddedProduct(articleNr);
}
}
}
});
} catch (error) {
console.warn('Failed to parse product description HTML:', error);
// Fallback to rendering as plain text if HTML parsing fails
@@ -1374,7 +1688,7 @@ class ProductDetailPage extends Component {
<ListItemIcon>
<CodeIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary="Einbetten" />
<ListItemText primary={this.props.t ? this.props.t("productDialogs.shareEmbed") : "Einbetten"} />
</MenuItem>
<MenuItem onClick={this.handleWhatsAppShare}>
<ListItemIcon>
@@ -1404,7 +1718,7 @@ class ProductDetailPage extends Component {
<ListItemIcon>
<LinkIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary="Link kopieren" />
<ListItemText primary={this.props.t ? this.props.t("productDialogs.shareCopyLink") : "Link kopieren"} />
</MenuItem>
</MenuList>
</Box>
@@ -1658,7 +1972,7 @@ class ProductDetailPage extends Component {
gap: 2
}}>
{this.state.similarProducts.map((similarProductData, index) => {
const product = similarProductData.product;
const product = similarProductData.translatedProduct || similarProductData.product;
return (
<Box key={product.id} sx={{ display: 'flex', justifyContent: 'center' }}>
<Product

View File

@@ -1,15 +1,22 @@
import React from 'react';
import { useParams, useNavigate, useLocation } from 'react-router-dom';
import ProductDetailPage from './ProductDetailPage.js';
import { useProduct } from '../context/ProductContext.js';
const ProductDetailWithSocket = () => {
const { seoName } = useParams();
const navigate = useNavigate();
const location = useLocation();
const { setCurrentProduct } = useProduct();
return (
<ProductDetailPage seoName={seoName} navigate={navigate} location={location} />
<ProductDetailPage
seoName={seoName}
navigate={navigate}
location={location}
setCurrentProduct={setCurrentProduct}
/>
);
};
export default ProductDetailWithSocket;
export default ProductDetailWithSocket;

View File

@@ -209,7 +209,7 @@ class ProductFilters extends Component {
color: 'primary.main'
}}
>
{this.props.dataParam}
{this.props.categoryName}
</Typography>
)}

View File

@@ -430,7 +430,7 @@ class ProductList extends Component {
<Stack direction="row" spacing={2} sx={{ display: { xs: 'none', sm: 'flex' }, px: { xs: 1, sm: 0 } }}>
<Typography variant="body2" color="text.secondary">
{/*this.props.dataType == 'category' && (<>Kategorie: {this.props.dataParam}</>)}*/}
{this.props.dataType == 'search' && (<>Suchergebnisse für: "{this.props.dataParam}"</>)}
{this.props.dataType == 'search' && (this.props.t ? this.props.t('search.searchResultsFor', { query: this.props.dataParam }) : `Suchergebnisse für: "${this.props.dataParam}"`)}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>
{this.getProductCountText()}

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { Link } from 'react-router-dom';
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
@@ -27,7 +28,7 @@ class SharedCarousel extends React.Component {
constructor(props) {
super(props);
const { i18n } = props;
// Don't load categories in constructor - will be loaded in componentDidMount with correct language
this.state = {
categories: [],
@@ -41,7 +42,7 @@ class SharedCarousel extends React.Component {
componentDidMount() {
this._isMounted = true;
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n.language;
// ALWAYS reload categories to ensure correct language
console.log("SharedCarousel componentDidMount: ALWAYS RELOADING categories for language", currentLanguage);
window.categoryService.get(209, currentLanguage).then((response) => {
@@ -60,12 +61,12 @@ class SharedCarousel extends React.Component {
componentDidUpdate(prevProps) {
console.log("componentDidUpdate", prevProps.languageContext?.currentLanguage, this.props.languageContext?.currentLanguage);
if(prevProps.languageContext?.currentLanguage !== this.props.languageContext?.currentLanguage) {
this.setState({ categories: [] },() => {
window.categoryService.get(209,this.props.languageContext?.currentLanguage || this.props.i18n.language).then((response) => {
if (prevProps.languageContext?.currentLanguage !== this.props.languageContext?.currentLanguage) {
this.setState({ categories: [] }, () => {
window.categoryService.get(209, this.props.languageContext?.currentLanguage || this.props.i18n.language).then((response) => {
console.log("response", response);
if (response.children && response.children.length > 0) {
this.originalCategories = response.children;
this.originalCategories = response.children;
this.categories = [...response.children, ...response.children];
this.setState({ categories: this.categories });
this.startAutoScroll();
@@ -123,7 +124,7 @@ class SharedCarousel extends React.Component {
showScrollbarFlash = () => {
this.clearScrollbarTimer();
this.setState({ showScrollbar: true });
this.scrollbarTimer = setTimeout(() => {
if (this._isMounted) {
this.setState({ showScrollbar: false });
@@ -133,7 +134,7 @@ class SharedCarousel extends React.Component {
handleAutoScroll = () => {
if (!this.autoScrollActive || this.originalCategories.length === 0) return;
this.translateX -= AUTO_SCROLL_SPEED;
this.updateTrackTransform();
@@ -172,7 +173,7 @@ class SharedCarousel extends React.Component {
scrollBy = (direction) => {
if (this.originalCategories.length === 0) return;
// direction: 1 = left (scroll content right), -1 = right (scroll content left)
const originalItemCount = this.originalCategories.length;
const maxScroll = ITEM_WIDTH * originalItemCount;
@@ -189,7 +190,7 @@ class SharedCarousel extends React.Component {
}
this.updateTrackTransform();
// Force scrollbar to update immediately after wrap-around
if (this.state.showScrollbar) {
this.forceUpdate();
@@ -204,11 +205,11 @@ class SharedCarousel extends React.Component {
const originalItemCount = this.originalCategories.length;
const viewportWidth = 1080; // carousel container max-width
const itemsInView = Math.floor(viewportWidth / ITEM_WIDTH);
// Calculate which item is currently at the left edge (first visible)
// Map translateX directly to item index using the same logic as scrollBy
let currentItemIndex;
if (this.translateX === 0) {
// At the beginning - item 0 is visible
currentItemIndex = 0;
@@ -221,10 +222,10 @@ class SharedCarousel extends React.Component {
// Normal negative scrolling - calculate which item is at left edge
currentItemIndex = Math.floor(Math.abs(this.translateX) / ITEM_WIDTH);
}
// Ensure we stay within bounds
currentItemIndex = Math.max(0, Math.min(currentItemIndex, originalItemCount - 1));
// Calculate scrollbar position: 0% when item 0 is first visible, 100% when last item is first visible
const lastPossibleFirstItem = Math.max(0, originalItemCount - itemsInView);
const thumbPosition = lastPossibleFirstItem > 0 ? Math.min((currentItemIndex / lastPossibleFirstItem) * 100, 100) : 0;
@@ -268,25 +269,41 @@ class SharedCarousel extends React.Component {
const { t } = this.props;
const { categories } = this.state;
if(!categories || categories.length === 0) {
if (!categories || categories.length === 0) {
return null;
}
return (
<Box sx={{ mt: 3 }}>
<Typography
variant="h4"
component="h1"
<Box
component={Link}
to="/Kategorien"
sx={{
mb: 2,
fontFamily: "SwashingtonCP",
display: "flex",
alignItems: "center",
justifyContent: "center",
textDecoration: "none",
color: "primary.main",
textAlign: "center",
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)"
mb: 2,
transition: "all 0.3s ease",
"&:hover": {
transform: "translateX(5px)",
color: "primary.dark"
}
}}
>
{t('navigation.categories')}
</Typography>
<Typography
variant="h4"
component="span"
sx={{
fontFamily: "SwashingtonCP",
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)"
}}
>
{t('navigation.categories')}
</Typography>
<ChevronRight sx={{ fontSize: "2.5rem", ml: 1 }} />
</Box>
<div
className="carousel-wrapper"
@@ -394,7 +411,7 @@ class SharedCarousel extends React.Component {
</div>
))}
</div>
{/* Virtual Scrollbar */}
{this.renderVirtualScrollbar()}
</div>

View File

@@ -0,0 +1,53 @@
import React, { Component } from 'react';
import { withProduct } from '../context/ProductContext.js';
import { withCategory } from '../context/CategoryContext.js';
// Utility function to clean product names (duplicated from ProductDetailPage to ensure consistency)
const cleanProductName = (name) => {
if (!name) return "";
// Remove patterns like " (1)", " (3)", " (10)" at the end of the string
return name.replace(/\s*\(\d+\)\s*$/, "").trim();
};
class TitleUpdater extends Component {
componentDidMount() {
this.updateTitle();
}
componentDidUpdate(prevProps) {
console.log('TitleUpdater: Update triggered', {
prevProduct: prevProps.productContext.currentProduct,
currProduct: this.props.productContext.currentProduct,
prevCategory: prevProps.categoryContext.currentCategory,
currCategory: this.props.categoryContext.currentCategory
});
if (
prevProps.productContext.currentProduct !== this.props.productContext.currentProduct ||
prevProps.categoryContext.currentCategory !== this.props.categoryContext.currentCategory
) {
this.updateTitle();
}
}
updateTitle() {
const { currentProduct } = this.props.productContext;
const { currentCategory } = this.props.categoryContext;
console.log('TitleUpdater: Updating title with', { currentProduct, currentCategory });
if (currentProduct && currentProduct.name) {
document.title = `GrowHeads.de - ${cleanProductName(currentProduct.name)}`;
} else if (currentCategory && currentCategory.name) {
document.title = `GrowHeads.de - ${currentCategory.name}`;
} else {
document.title = 'GrowHeads.de';
}
}
render() {
return null;
}
}
export default withCategory(withProduct(TitleUpdater));

View File

@@ -63,7 +63,7 @@ class ExtrasSelector extends Component {
this.loadingImages.add(bildId);
window.socketManager.emit('getPic', { bildId, size:'small' }, (res) => {
if (res.success) {
window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/avif' }));
this.forceUpdate();
}
this.loadingImages.delete(bildId);

View File

@@ -5,6 +5,7 @@ import CardContent from '@mui/material/CardContent';
import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import { withI18n } from '../../i18n/withTranslation.js';
class TentShapeSelector extends Component {
// Generate plant layout based on tent shape
@@ -180,12 +181,20 @@ class TentShapeSelector extends Component {
</Box>
<Typography variant="body2" color="text.secondary" gutterBottom>
{shape.description}
{this.props.t && shape.descriptionKey ? this.props.t(shape.descriptionKey) : shape.description}
</Typography>
<Box sx={{ mt: 2 }}>
<Chip
label={`${shape.minPlants}-${shape.maxPlants} Pflanzen`}
label={this.props.t
? (
shape.minPlants === 1 && shape.maxPlants === 2 ? this.props.t("kitConfig.plants1to2") :
shape.minPlants === 2 && shape.maxPlants === 4 ? this.props.t("kitConfig.plants2to4") :
shape.minPlants === 4 && shape.maxPlants === 6 ? this.props.t("kitConfig.plants4to6") :
shape.minPlants === 3 && shape.maxPlants === 6 ? this.props.t("kitConfig.plants3to6") :
`${shape.minPlants}-${shape.maxPlants} Pflanzen`
)
: `${shape.minPlants}-${shape.maxPlants} Pflanzen`}
size="small"
sx={{
bgcolor: isSelected ? '#2e7d32' : '#f0f0f0',
@@ -205,7 +214,7 @@ class TentShapeSelector extends Component {
transition: 'opacity 0.3s ease'
}}
>
Ausgewählt
{this.props.t ? this.props.t("kitConfig.selected") : "✓ Ausgewählt"}
</Typography>
</Box>
</CardContent>
@@ -238,4 +247,4 @@ class TentShapeSelector extends Component {
}
}
export default TentShapeSelector;
export default withI18n()(TentShapeSelector);

View File

@@ -32,9 +32,9 @@ class CategoryList extends Component {
console.log(" i18n.language:", this.props.i18n?.language);
console.log(" sessionStorage i18nextLng:", typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('i18nextLng') : 'N/A');
console.log(" localStorage i18nextLng:", typeof localStorage !== 'undefined' ? localStorage.getItem('i18nextLng') : 'N/A');
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n.language;
// ALWAYS reload categories to ensure correct language
console.log("CategoryList componentDidMount: ALWAYS RELOADING categories for language", currentLanguage);
this.setState({ categories: [] }); // Clear any cached categories
@@ -53,15 +53,15 @@ class CategoryList extends Component {
componentDidUpdate(prevProps) {
console.log("componentDidUpdate", prevProps.languageContext?.currentLanguage, this.props.languageContext?.currentLanguage);
if(prevProps.languageContext?.currentLanguage !== this.props.languageContext?.currentLanguage) {
if (prevProps.languageContext?.currentLanguage !== this.props.languageContext?.currentLanguage) {
this.setState({
categories: [],
activeCategoryId: null
},() => {
window.categoryService.get(209,this.props.languageContext?.currentLanguage || this.props.i18n.language).then((response) => {
}, () => {
window.categoryService.get(209, this.props.languageContext?.currentLanguage || this.props.i18n.language).then((response) => {
console.log("response", response);
if (response.children && response.children.length > 0) {
this.setState({
this.setState({
categories: response.children,
activeCategoryId: this.setLevel1CategoryId(this.props.activeCategoryId)
});
@@ -69,14 +69,14 @@ class CategoryList extends Component {
});
});
}
if (prevProps.activeCategoryId !== this.props.activeCategoryId) {
this.setLevel1CategoryId(this.props.activeCategoryId);
}
if (prevProps.activeCategoryId !== this.props.activeCategoryId) {
this.setLevel1CategoryId(this.props.activeCategoryId);
}
}
setLevel1CategoryId = (input) => {
if(input) {
if (input) {
const language = this.props.languageContext?.currentLanguage || this.props.i18n.language;
const categoryTreeCache = window.categoryService.getSync(209, language);
@@ -136,7 +136,7 @@ class CategoryList extends Component {
this.setState({ activeCategoryId: null });
}
handleMobileMenuToggle = () => {
this.setState(prevState => ({
@@ -167,152 +167,147 @@ class CategoryList extends Component {
display: "flex",
justifyContent: "flex-start",
alignItems: "center",
flexWrap: isMobile ? "wrap" : "nowrap",
overflowX: isMobile ? "visible" : "auto",
flexWrap: "wrap",
overflowX: "visible",
flexDirection: isMobile ? "column" : "row",
py: 0.5, // Add vertical padding to prevent border clipping
"&::-webkit-scrollbar": {
display: "none",
},
scrollbarWidth: "none",
msOverflowStyle: "none",
}}
>
<Button
component={Link}
to="/"
color="inherit"
size="small"
aria-label="Zur Startseite"
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{
fontSize: "0.75rem",
textTransform: "none",
whiteSpace: "nowrap",
opacity: 0.9,
mx: isMobile ? 0 : 0.5,
my: 0.25,
minWidth: isMobile ? "100%" : "auto",
borderRadius: 1,
justifyContent: isMobile ? "flex-start" : "center",
transition: "all 0.2s ease",
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
position: "relative",
...(activeCategoryId === null && {
bgcolor: "#fff",
textShadow: "none",
opacity: 1,
}),
"&:hover": {
opacity: 1,
bgcolor: "#fff",
textShadow: "none",
"& .MuiSvgIcon-root": {
color: "#2e7d32 !important",
},
"& .bold-text": {
color: "#2e7d32 !important",
},
"& .thin-text": {
color: "transparent !important",
},
<Button
component={Link}
to="/"
color="inherit"
size="small"
aria-label="Zur Startseite"
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{
fontSize: "0.75rem",
textTransform: "none",
whiteSpace: "nowrap",
opacity: 0.9,
mx: isMobile ? 0 : 0.5,
my: 0.25,
minWidth: isMobile ? "100%" : "auto",
borderRadius: 1,
justifyContent: isMobile ? "flex-start" : "center",
transition: "all 0.2s ease",
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
position: "relative",
...(activeCategoryId === null && {
bgcolor: "#fff",
textShadow: "none",
opacity: 1,
}),
"&:hover": {
opacity: 1,
bgcolor: "#fff",
textShadow: "none",
"& .MuiSvgIcon-root": {
color: "#2e7d32 !important",
},
}}
>
<HomeIcon sx={{
fontSize: "1rem",
mr: isMobile ? 1 : 0,
color: activeCategoryId === null ? "#2e7d32" : "inherit"
}} />
{isMobile && (
<Box sx={{ position: "relative", display: "inline-block" }}>
{/* Bold text (always rendered to set width) */}
<Box
className="bold-text"
sx={{
fontWeight: "bold",
color: activeCategoryId === null ? "#2e7d32" : "transparent",
position: "relative",
zIndex: 2,
}}
>
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
</Box>
{/* Thin text (positioned on top) */}
<Box
className="thin-text"
sx={{
fontWeight: "400",
color: activeCategoryId === null ? "transparent" : "inherit",
position: "absolute",
top: 0,
left: 0,
zIndex: 1,
}}
>
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
</Box>
"& .bold-text": {
color: "#2e7d32 !important",
},
"& .thin-text": {
color: "transparent !important",
},
},
}}
>
<HomeIcon sx={{
fontSize: "1rem",
mr: isMobile ? 1 : 0,
color: activeCategoryId === null ? "#2e7d32" : "inherit"
}} />
{isMobile && (
<Box sx={{ position: "relative", display: "inline-block" }}>
{/* Bold text (always rendered to set width) */}
<Box
className="bold-text"
sx={{
fontWeight: "bold",
color: activeCategoryId === null ? "#2e7d32" : "transparent",
position: "relative",
zIndex: 2,
}}
>
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
</Box>
)}
</Button>
{/* Thin text (positioned on top) */}
<Box
className="thin-text"
sx={{
fontWeight: "400",
color: activeCategoryId === null ? "transparent" : "inherit",
position: "absolute",
top: 0,
left: 0,
zIndex: 1,
}}
>
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
</Box>
</Box>
)}
</Button>
<Button
component={Link}
to="/Kategorie/neu"
color="inherit"
size="small"
aria-label="Neuheiten"
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{
fontSize: "0.75rem",
textTransform: "none",
whiteSpace: "nowrap",
opacity: 0.9,
mx: isMobile ? 0 : 0.5,
my: 0.25,
minWidth: isMobile ? "100%" : "auto",
borderRadius: 1,
justifyContent: isMobile ? "flex-start" : "center",
transition: "all 0.2s ease",
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
position: "relative"
}}
>
<FiberNewIcon sx={{
fontSize: "1rem",
mr: isMobile ? 1 : 0
}} />
{isMobile && (
<Box sx={{ position: "relative", display: "inline-block" }}>
{/* Bold text (always rendered to set width) */}
<Box
className="bold-text"
sx={{
fontWeight: "bold",
color: "transparent",
position: "relative",
zIndex: 2,
}}
>
{this.props.t ? this.props.t('navigation.new') : 'Neuheiten'}
</Box>
{/* Thin text (positioned on top) */}
<Box
className="thin-text"
sx={{
fontWeight: "400",
color: "inherit",
position: "absolute",
top: 0,
left: 0,
zIndex: 1,
}}
>
{this.props.t ? this.props.t('navigation.new') : 'Neuheiten'}
</Box>
<Button
component={Link}
to="/Kategorie/neu"
color="inherit"
size="small"
aria-label="Neuheiten"
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{
fontSize: "0.75rem",
textTransform: "none",
whiteSpace: "nowrap",
opacity: 0.9,
mx: isMobile ? 0 : 0.5,
my: 0.25,
minWidth: isMobile ? "100%" : "auto",
borderRadius: 1,
justifyContent: isMobile ? "flex-start" : "center",
transition: "all 0.2s ease",
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
position: "relative"
}}
>
<FiberNewIcon sx={{
fontSize: "1rem",
mr: isMobile ? 1 : 0
}} />
{isMobile && (
<Box sx={{ position: "relative", display: "inline-block" }}>
{/* Bold text (always rendered to set width) */}
<Box
className="bold-text"
sx={{
fontWeight: "bold",
color: "transparent",
position: "relative",
zIndex: 2,
}}
>
{this.props.t ? this.props.t('navigation.new') : 'Neuheiten'}
</Box>
)}
</Button>
{/* Thin text (positioned on top) */}
<Box
className="thin-text"
sx={{
fontWeight: "400",
color: "inherit",
position: "absolute",
top: 0,
left: 0,
zIndex: 1,
}}
>
{this.props.t ? this.props.t('navigation.new') : 'Neuheiten'}
</Box>
</Box>
)}
</Button>
{categories.length > 0 ? (
@@ -390,100 +385,100 @@ class CategoryList extends Component {
);
})}
</>
) : ( !isMobile && (
<Typography
variant="caption"
color="inherit"
sx={{
display: "inline-flex",
alignItems: "center",
height: "33px", // Match small button height
px: 1,
fontSize: "0.75rem",
opacity: 0.9,
}}
>
&nbsp;
</Typography>
)
)}
<Button
component={Link}
to="/Konfigurator"
) : (!isMobile && (
<Typography
variant="caption"
color="inherit"
size="small"
aria-label="Zur Startseite"
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{
display: "inline-flex",
alignItems: "center",
height: "33px", // Match small button height
px: 1,
fontSize: "0.75rem",
textTransform: "none",
whiteSpace: "nowrap",
opacity: 0.9,
mx: isMobile ? 0 : 0.5,
my: 0.25,
minWidth: isMobile ? "100%" : "auto",
borderRadius: 1,
justifyContent: isMobile ? "flex-start" : "center",
transition: "all 0.2s ease",
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
position: "relative",
...(activeCategoryId === null && {
bgcolor: "#fff",
textShadow: "none",
opacity: 1,
}),
"&:hover": {
opacity: 1,
bgcolor: "#fff",
textShadow: "none",
"& .MuiSvgIcon-root": {
color: "#2e7d32 !important",
},
"& .bold-text": {
color: "#2e7d32 !important",
},
"& .thin-text": {
color: "transparent !important",
},
},
}}
>
<SettingsIcon sx={{
fontSize: "1rem",
mr: isMobile ? 1 : 0,
color: activeCategoryId === null ? "#2e7d32" : "inherit"
}} />
{isMobile && (
<Box sx={{ position: "relative", display: "inline-block" }}>
{/* Bold text (always rendered to set width) */}
<Box
className="bold-text"
sx={{
fontWeight: "bold",
color: activeCategoryId === null ? "#2e7d32" : "transparent",
position: "relative",
zIndex: 2,
}}
>
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
</Box>
{/* Thin text (positioned on top) */}
<Box
className="thin-text"
sx={{
fontWeight: "400",
color: activeCategoryId === null ? "transparent" : "inherit",
position: "absolute",
top: 0,
left: 0,
zIndex: 1,
}}
>
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
</Box>
&nbsp;
</Typography>
)
)}
<Button
component={Link}
to="/Konfigurator"
color="inherit"
size="small"
aria-label="Zur Startseite"
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{
fontSize: "0.75rem",
textTransform: "none",
whiteSpace: "nowrap",
opacity: 0.9,
mx: isMobile ? 0 : 0.5,
my: 0.25,
minWidth: isMobile ? "100%" : "auto",
borderRadius: 1,
justifyContent: isMobile ? "flex-start" : "center",
transition: "all 0.2s ease",
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
position: "relative",
...(activeCategoryId === null && {
bgcolor: "#fff",
textShadow: "none",
opacity: 1,
}),
"&:hover": {
opacity: 1,
bgcolor: "#fff",
textShadow: "none",
"& .MuiSvgIcon-root": {
color: "#2e7d32 !important",
},
"& .bold-text": {
color: "#2e7d32 !important",
},
"& .thin-text": {
color: "transparent !important",
},
},
}}
>
<SettingsIcon sx={{
fontSize: "1rem",
mr: isMobile ? 1 : 0,
color: activeCategoryId === null ? "#2e7d32" : "inherit"
}} />
{isMobile && (
<Box sx={{ position: "relative", display: "inline-block" }}>
{/* Bold text (always rendered to set width) */}
<Box
className="bold-text"
sx={{
fontWeight: "bold",
color: activeCategoryId === null ? "#2e7d32" : "transparent",
position: "relative",
zIndex: 2,
}}
>
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
</Box>
)}
</Button>
{/* Thin text (positioned on top) */}
<Box
className="thin-text"
sx={{
fontWeight: "400",
color: activeCategoryId === null ? "transparent" : "inherit",
position: "absolute",
top: 0,
left: 0,
zIndex: 1,
}}
>
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
</Box>
</Box>
)}
</Button>
</Box>
);
@@ -521,11 +516,11 @@ class CategoryList extends Component {
>
<Container maxWidth="lg" sx={{ px: 2 }}>
{/* Toggle Button */}
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
py: 1,
cursor: "pointer",
"&:hover": {
@@ -535,7 +530,7 @@ class CategoryList extends Component {
onClick={this.handleMobileMenuToggle}
role="button"
tabIndex={0}
aria-label={this.props.t ?
aria-label={this.props.t ?
(mobileMenuOpen ? this.props.t('navigation.categoriesClose') : this.props.t('navigation.categoriesOpen')) :
(mobileMenuOpen ? "Kategorien schließen" : "Kategorien öffnen")
}
@@ -546,11 +541,11 @@ class CategoryList extends Component {
}
}}
>
<Typography variant="subtitle2" color="inherit" sx={{
<Typography variant="subtitle2" color="inherit" sx={{
fontWeight: "bold",
textShadow: "0 1px 2px rgba(0,0,0,0.3)"
}}>
{this.props.t ? this.props.t('navigation.categories') : 'Kategorien'}
{this.props.t ? this.props.t('navigation.categories') : 'Kategorien'}
</Typography>
<Box sx={{ display: "flex", alignItems: "center" }}>
{mobileMenuOpen ? <CloseIcon /> : <MenuIcon />}

View File

@@ -16,7 +16,7 @@ const Logo = () => {
}}
>
<img
src="/assets/images/sh.png"
src="/assets/images/sh.avif"
alt="SH Logo"
width="108px"
height="45px"

View File

@@ -79,7 +79,7 @@ const SearchBar = () => {
(response) => {
if (response && response.products) {
// getSearchProducts returns response.products array
const suggestions = response.products.slice(0, 8); // Limit to 8 suggestions
const suggestions = response.products.map(p => p.translatedProduct || p).slice(0, 8); // Limit to 8 suggestions
setSuggestions(suggestions);
setShowSuggestions(suggestions.length > 0);
setSelectedIndex(-1); // Reset selection
@@ -244,7 +244,7 @@ const SearchBar = () => {
>
<TextField
ref={inputRef}
placeholder="Produkte suchen..."
placeholder={t('search.searchProducts')}
variant="outlined"
size="small"
fullWidth
@@ -353,6 +353,25 @@ const SearchBar = () => {
</ListItem>
))}
</List>
<Box sx={{ p: 1, borderTop: 1, borderColor: 'divider' }}>
<IconButton
fullWidth
onClick={handleEnterClick}
sx={{
justifyContent: 'center',
py: 1,
color: 'primary.main',
'&:hover': {
backgroundColor: 'action.hover',
},
}}
>
<Typography variant="body2" sx={{ mr: 1 }}>
{t('common.more')}
</Typography>
<KeyboardReturnIcon fontSize="small" />
</IconButton>
</Box>
</Paper>
)}
</Box>

View File

@@ -17,7 +17,7 @@ const DeliveryMethodSelector = ({ deliveryMethod, onChange, isPickupOnly, cartIt
name: 'DHL',
description: isPickupOnly ? (t ? t('delivery.descriptions.notAvailable') : "nicht auswählbar weil ein oder mehrere Artikel nur abgeholt werden können") :
isFreeShipping ? (t ? t('delivery.descriptions.standardFree') : 'Standardversand - KOSTENLOS ab 100€ Warenwert!') : (t ? t('delivery.descriptions.standard') : 'Standardversand'),
price: isFreeShipping ? (t ? t('delivery.prices.free') : 'kostenlos') : (t ? t('delivery.prices.dhl') : '6,99 €'),
price: isFreeShipping ? (t ? t('delivery.prices.free') : 'kostenlos') : (t ? t('delivery.prices.dhl') : '5,90 €'),
disabled: isPickupOnly
},
{

View File

@@ -31,6 +31,7 @@ const getStatusTranslation = (status, t) => {
new: t ? t('orders.status.new') : "in Bearbeitung",
pending: t ? t('orders.status.pending') : "Neu",
processing: t ? t('orders.status.processing') : "in Bearbeitung",
paid: t ? t('orders.status.paid') : "Bezahlt",
cancelled: t ? t('orders.status.cancelled') : "Storniert",
shipped: t ? t('orders.status.shipped') : "Verschickt",
delivered: t ? t('orders.status.delivered') : "Geliefert",
@@ -39,29 +40,23 @@ const getStatusTranslation = (status, t) => {
};
const statusEmojis = {
"in Bearbeitung": "⚙️",
new: "⚙️",
pending: "⏳",
processing: "🔄",
paid: "🏦",
cancelled: "❌",
Verschickt: "🚚",
Geliefert: "✅",
Storniert: "❌",
Retoure: "↩️",
"Teil Retoure": "↪️",
"Teil geliefert": "⚡",
shipped: "🚚",
delivered: "✅",
};
const statusColors = {
"in Bearbeitung": "#ed6c02", // orange
new: "#ed6c02", // orange
pending: "#ff9800", // orange for pending
processing: "#2196f3", // blue for processing
paid: "#2e7d32", // green
cancelled: "#d32f2f", // red for cancelled
Verschickt: "#2e7d32", // green
Geliefert: "#2e7d32", // green
Storniert: "#d32f2f", // red
Retoure: "#9c27b0", // purple
"Teil Retoure": "#9c27b0", // purple
"Teil geliefert": "#009688", // teal
shipped: "#2e7d32", // green
delivered: "#2e7d32", // green
};
const currencyFormatter = new Intl.NumberFormat("de-DE", {
@@ -229,11 +224,11 @@ const OrdersTab = ({ orderIdFromHash, t }) => {
display: "flex",
alignItems: "center",
gap: "8px",
color: getStatusColor(displayStatus),
color: getStatusColor(order.status),
}}
>
<span style={{ fontSize: "1.2rem" }}>
{getStatusEmoji(displayStatus)}
{getStatusEmoji(order.status)}
</span>
<Typography
variant="body2"
@@ -243,6 +238,18 @@ const OrdersTab = ({ orderIdFromHash, t }) => {
{displayStatus}
</Typography>
</Box>
{order.delivery_method === 'DHL' && order.trackingCode && (
<Box sx={{ mt: 0.5 }}>
<a
href={`https://www.dhl.de/de/privatkunden/dhl-sendungsverfolgung.html?piececode=${order.trackingCode}`}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: '0.85rem', color: '#d40511' }}
>
📦 {t ? t('orders.trackShipment') : 'Sendung verfolgen'}
</a>
</Box>
)}
</TableCell>
<TableCell>
{order.items

View File

@@ -200,13 +200,13 @@ const config = {
// Shipping
shipping: {
defaultCost: "4.99 EUR",
defaultCost: "5.90 EUR",
defaultService: "Standard"
},
// Images
images: {
logo: "/assets/images/sh.png",
logo: "/assets/images/sh.avif",
placeholder: "/assets/images/nopicture.jpg"
},

View File

@@ -0,0 +1,31 @@
import React, { createContext, useState, useContext } from 'react';
const CategoryContext = createContext({
currentCategory: null,
setCurrentCategory: () => {}
});
export const useCategory = () => useContext(CategoryContext);
export const withCategory = (Component) => {
return (props) => {
const categoryContext = useCategory();
return <Component {...props} categoryContext={categoryContext} />;
};
};
export const CategoryContextProvider = ({ children }) => {
const [currentCategory, setCurrentCategory] = useState(null);
const setCurrentCategoryWithLog = (category) => {
console.log('CategoryContext: Setting current category to:', category);
setCurrentCategory(category);
};
return (
<CategoryContext.Provider value={{ currentCategory, setCurrentCategory: setCurrentCategoryWithLog }}>
{children}
</CategoryContext.Provider>
);
};

View File

@@ -0,0 +1,31 @@
import React, { createContext, useState, useContext } from 'react';
const ProductContext = createContext({
currentProduct: null,
setCurrentProduct: () => {}
});
export const useProduct = () => useContext(ProductContext);
export const withProduct = (Component) => {
return (props) => {
const productContext = useProduct();
return <Component {...props} productContext={productContext} />;
};
};
export const ProductContextProvider = ({ children }) => {
const [currentProduct, setCurrentProduct] = useState(null);
const setCurrentProductWithLog = (product) => {
console.log('ProductContext: Setting current product to:', product);
setCurrentProduct(product);
};
return (
<ProductContext.Provider value={{ currentProduct, setCurrentProduct: setCurrentProductWithLog }}>
{children}
</ProductContext.Provider>
);
};

View File

@@ -1,9 +1,10 @@
// @note Dummy data for grow tent configurator - no backend calls
// descriptions now keys for translation
export const tentShapes = [
{
id: '60x60',
name: '60x60cm',
description: 'Kompakt - ideal für kleine Räume',
descriptionKey: 'kitConfig.description60x60',
footprint: '60x60',
minPlants: 1,
maxPlants: 2,
@@ -13,7 +14,7 @@ export const tentShapes = [
{
id: '80x80',
name: '80x80cm',
description: 'Mittel - perfekte Balance',
descriptionKey: 'kitConfig.description80x80',
footprint: '80x80',
minPlants: 2,
maxPlants: 4,
@@ -23,7 +24,7 @@ export const tentShapes = [
{
id: '100x100',
name: '100x100cm',
description: 'Groß - für erfahrene Grower',
descriptionKey: 'kitConfig.description100x100',
footprint: '100x100',
minPlants: 4,
maxPlants: 6,
@@ -33,7 +34,7 @@ export const tentShapes = [
{
id: '120x60',
name: '120x60cm',
description: 'Rechteckig - maximale Raumnutzung',
descriptionKey: 'kitConfig.description120x60',
footprint: '120x60',
minPlants: 3,
maxPlants: 6,

View File

@@ -5,6 +5,7 @@ export default {
"profile": "الملف الشخصي",
"email": "البريد الإلكتروني",
"password": "كلمة المرور",
"newPassword": "كلمة المرور الجديدة",
"confirmPassword": "تأكيد كلمة المرور",
"forgotPassword": "هل نسيت كلمة المرور؟",
"loginWithGoogle": "تسجيل الدخول باستخدام جوجل",
@@ -13,6 +14,7 @@ export default {
"privacyPolicy": "سياسة الخصوصية",
"passwordMinLength": "يجب أن تكون كلمة المرور 8 أحرف على الأقل",
"newPasswordMinLength": "يجب أن تكون كلمة المرور الجديدة 8 أحرف على الأقل",
"backToHome": "العودة إلى الصفحة الرئيسية",
"menu": {
"profile": "الملف الشخصي",
"myProfile": "ملفي الشخصي",
@@ -21,5 +23,28 @@ export default {
"settings": "الإعدادات",
"adminDashboard": "لوحة تحكم المسؤول",
"adminUsers": "مستخدمو المسؤول"
},
"resetPassword": {
"title": "إعادة تعيين كلمة المرور",
"button": "إعادة تعيين كلمة المرور",
"success": "تم إعادة تعيين كلمة المرور بنجاح! سيتم توجيهك لتسجيل الدخول قريبًا...",
"invalidToken": "لم يتم العثور على رمز صالح. يرجى استخدام الرابط من بريدك الإلكتروني.",
"error": "حدث خطأ أثناء إعادة تعيين كلمة المرور",
"emailSent": "تم إرسال رابط لإعادة تعيين كلمة المرور إلى بريدك الإلكتروني.",
"emailError": "حدث خطأ أثناء إرسال البريد الإلكتروني"
},
"errors": {
"fillAllFields": "يرجى ملء جميع الحقول",
"invalidEmail": "يرجى إدخال بريد إلكتروني صالح",
"passwordsNotMatch": "كلمات المرور غير متطابقة",
"passwordsNotMatchShort": "كلمات المرور غير متطابقة",
"enterEmail": "يرجى إدخال بريدك الإلكتروني",
"loginFailed": "فشل تسجيل الدخول",
"registerFailed": "فشل التسجيل",
"googleLoginFailed": "فشل تسجيل الدخول عبر جوجل",
"emailExists": "يوجد مستخدم بهذا البريد الإلكتروني بالفعل. يرجى استخدام بريد إلكتروني آخر أو تسجيل الدخول."
},
"success": {
"registerComplete": "تم التسجيل بنجاح. يمكنك الآن تسجيل الدخول."
}
};

View File

@@ -15,5 +15,6 @@ export default {
"remove": "إزالة",
"products": "منتجات",
"product": "منتج",
"days": "أيام"
"days": "أيام",
"more": "المزيد"
};

View File

@@ -8,15 +8,15 @@ export default {
},
"descriptions": {
"standard": "الشحن العادي",
"standardFree": "الشحن العادي - مجاني من قيمة طلب 100€!",
"notAvailable": "غير قابل للاختيار لأن عنصر واحد أو أكثر يمكن استلامه فقط",
"standardFree": "الشحن العادي - مجاني للطلبات فوق 100€!",
"notAvailable": "غير متاح للاختيار لأن عنصر واحد أو أكثر يمكن استلامه فقط",
"bulky": "للعناصر الكبيرة والثقيلة",
"pickupOnly": "الاستلام فقط"
},
"prices": {
"free": "مجاني",
"freeFrom100": "(مجاني من 100€)",
"dhl": "6.99 €",
"dhl": "5.90 €",
"dpd": "4.90 €",
"sperrgut": "28.99 €"
},
@@ -27,7 +27,7 @@ export default {
},
"selector": {
"title": "اختر طريقة الشحن",
"freeShippingInfo": "💡 الشحن مجاني من قيمة طلب 100€!",
"freeShippingInfo": "💡 الشحن مجاني للطلبات فوق 100€!",
"remainingForFree": "أضف {{amount}}€ أخرى للشحن المجاني.",
"congratsFreeShipping": "🎉 مبروك! حصلت على شحن مجاني!",
"cartQualifiesFree": "سلة مشترياتك بقيمة {{amount}}€ مؤهلة للشحن المجاني."

View File

@@ -3,6 +3,7 @@ import navigation from './navigation.js';
import auth from './auth.js';
import cart from './cart.js';
import product from './product.js';
import productDialogs from './productDialogs.js';
import search from './search.js';
import sorting from './sorting.js';
import chat from './chat.js';
@@ -18,6 +19,7 @@ import pages from './pages.js';
import orders from './orders.js';
import settings from './settings.js';
import common from './common.js';
import kitConfig from './kitConfig.js';
import legalDatenschutzBasic from './legal-datenschutz-basic.js';
import legalDatenschutzCustomer from './legal-datenschutz-customer.js';
import legalDatenschutzGoogleOrders from './legal-datenschutz-google-orders.js';
@@ -35,6 +37,7 @@ export default {
"auth": auth,
"cart": cart,
"product": product,
"productDialogs": productDialogs,
"search": search,
"sorting": sorting,
"chat": chat,
@@ -50,6 +53,7 @@ export default {
"orders": orders,
"settings": settings,
"common": common,
"kitConfig": kitConfig,
"legalDatenschutzBasic": legalDatenschutzBasic,
"legalDatenschutzCustomer": legalDatenschutzCustomer,
"legalDatenschutzGoogleOrders": legalDatenschutzGoogleOrders,

View File

@@ -0,0 +1,43 @@
export default {
"pageTitle": "🌱 مُكوّن جروبوكس",
"pageSubtitle": "ركّب إعداد النمو الداخلي المثالي بتاعك",
"bundleDiscountTitle": "🎯 احصل على خصم الباقة!",
"loadingProducts": "جارٍ تحميل منتجات الجروبوكس...",
"loadingLighting": "جارٍ تحميل منتجات الإضاءة...",
"loadingVentilation": "جارٍ تحميل منتجات التهوية...",
"loadingExtras": "جارٍ تحميل الإضافات...",
"noProductsAvailable": "لا توجد منتجات متاحة لهذا الحجم",
"noLightingAvailable": "لا توجد أضواء مناسبة لحجم الخيمة {{shape}}.",
"noVentilationAvailable": "لا توجد تهوية مناسبة لحجم الخيمة {{shape}}.",
"noExtrasAvailable": "لا توجد إضافات متاحة",
"selectShapeTitle": "1. اختر شكل الجروبوكس",
"selectShapeSubtitle": "اختار أولاً مساحة قاعدة الجروبوكس بتاعتك",
"selectProductTitle": "2. اختر منتج الجروبوكس",
"selectProductSubtitle": "اختار المنتج المناسب لجروبوكس {{shape}} بتاعك",
"selectLightingTitle": "3. اختر الإضاءة",
"selectLightingTitleShape": "3. اختر الإضاءة - {{shape}}",
"selectLightingSubtitle": "من فضلك اختار حجم الخيمة الأول.",
"selectVentilationTitle": "4. اختر التهوية",
"selectVentilationTitleShape": "4. اختر التهوية - {{shape}}",
"selectVentilationSubtitle": "من فضلك اختار حجم الخيمة الأول.",
"selectExtrasTitle": "5. أضف إضافات (اختياري)",
"yourConfiguration": "🎯 التكوين بتاعك",
"growboxLabel": "جروبوكس: {{name}}",
"lightingLabel": "الإضاءة: {{name}}",
"ventilationLabel": "التهوية: {{name}}",
"extraLabel": "إضافة: {{name}}",
"totalPrice": "السعر الكلي:",
"addToCart": "أضف إلى السلة",
"selected": "✓ تم الاختيار",
"notDeliverable": "غير متوفر للتوصيل",
"noPrice": "لا يوجد سعر",
"setName": "طقم جروبوكس - {{shape}}",
"description60x60": "مُدمج - مثالي للمساحات الصغيرة",
"description80x80": "متوسط - توازن مثالي",
"description100x100": "كبير - للمزارعين المتمرسين",
"description120x60": "مستطيل - استخدام أقصى للمساحة",
"plants1to2": "1-2 نباتات",
"plants2to4": "2-4 نباتات",
"plants4to6": "4-6 نباتات",
"plants3to6": "3-6 نباتات"
};

View File

@@ -3,7 +3,8 @@ export default {
"new": "قيد التنفيذ",
"pending": "جديد",
"processing": "قيد التنفيذ",
"cancelled": لغاة",
"paid": دفوع",
"cancelled": "ملغي",
"shipped": "تم الشحن",
"delivered": "تم التوصيل",
"return": "إرجاع",
@@ -24,6 +25,7 @@ export default {
"cancelOrder": "إلغاء الطلب"
},
"noOrders": "لم تقم بوضع أي طلبات بعد.",
"trackShipment": "تتبع الشحنة",
"details": {
"title": "تفاصيل الطلب: {{orderId}}",
"deliveryAddress": "عنوان التوصيل",
@@ -36,14 +38,13 @@ export default {
"item": "العنصر",
"quantity": "الكمية",
"price": "السعر",
"vat": "ضريبة القيمة المضافة",
"total": "الإجمالي",
"cancelOrder": "إلغاء الطلب"
},
"cancelConfirm": {
"title": "إلغاء الطلب",
"message": "هل أنت متأكد أنك تريد إلغاء هذا الطلب؟",
"confirm": "إلغاء الطلب",
"confirm": "إلغاء",
"cancelling": "جارٍ الإلغاء..."
},
"processing": "يتم إكمال الطلب..."

View File

@@ -5,9 +5,10 @@ export default {
"notFoundDescription": "المنتج الذي تبحث عنه غير موجود أو تم إزالته.",
"backToHome": "العودة إلى الصفحة الرئيسية",
"error": "خطأ",
"articleNumber": "رقم المقال",
"articleNumber": "رقم المنتج",
"manufacturer": "الشركة المصنعة",
"inclVat": "شامل {{vat}}% ضريبة القيمة المضافة",
"inclVatSimple": "شامل ضريبة القيمة المضافة",
"priceUnit": "{{price}}/{{unit}}",
"new": "جديد",
"weeks": "أسابيع",
@@ -22,9 +23,10 @@ export default {
"weight": "الوزن: {{weight}} كجم",
"youSave": "أنت توفر: {{amount}}",
"cheaperThanIndividual": "أرخص من الشراء بشكل فردي",
"pickupPrice": "سعر الاستلام: 19.90 يورو لكل قطعة.",
"pickupPrice": "سعر الاستلام: 19.90 لكل قطعة.",
"consistsOf": "يتكون من:",
"loadingComponentDetails": "{{index}}. جارٍ تحميل تفاصيل المكون...",
"loadingProduct": "جارٍ تحميل المنتج...",
"individualPriceTotal": "إجمالي السعر الفردي:",
"setPrice": "سعر المجموعة:",
"yourSavings": "توفيرك:",

View File

@@ -0,0 +1,61 @@
export default {
"questionTitle": "سؤال عن المنتج",
"questionSubtitle": "هل لديك سؤال عن هذا المنتج؟ نحن سعداء بمساعدتك.",
"questionSuccess": "شكرًا على سؤالك! سنرد عليك في أقرب وقت ممكن.",
"nameLabel": "الاسم",
"namePlaceholder": "اسمك",
"emailLabel": "البريد الإلكتروني",
"emailPlaceholder": "your.email@example.com",
"questionLabel": "سؤالك",
"questionPlaceholder": "صف سؤالك عن هذا المنتج...",
"photosLabelQuestion": "أرفق صورًا مع سؤالك (اختياري)",
"submitQuestion": "إرسال السؤال",
"sending": "جارٍ الإرسال...",
"ratingTitle": "قيم المنتج",
"ratingSubtitle": "شارك تجربتك مع هذا المنتج وساعد العملاء الآخرين في اتخاذ قرارهم.",
"ratingSuccess": "شكرًا على تقييمك! سيتم نشره بعد المراجعة.",
"emailHelper": "لن يتم نشر بريدك الإلكتروني",
"ratingLabel": "التقييم *",
"pleaseRate": "يرجى التقييم",
"ratingStars": "{{rating}} من 5 نجوم",
"reviewLabel": "تقييمك (اختياري)",
"reviewPlaceholder": "صف تجاربك مع هذا المنتج...",
"photosLabelRating": "أرفق صورًا مع تقييمك (اختياري)",
"submitRating": "إرسال التقييم",
"errorGeneric": "حدث خطأ",
"errorPhotos": "خطأ في معالجة الصور",
"availabilityTitle": "طلب التوفر",
"availabilitySubtitle": "هذا المنتج غير متوفر حاليًا. سنكون سعداء بإبلاغك بمجرد عودته للمخزون.",
"availabilitySuccessEmail": "شكرًا على طلبك! سنخطرك عبر البريد الإلكتروني بمجرد توفر المنتج مرة أخرى.",
"availabilitySuccessTelegram": "شكرًا على طلبك! سنخطرك عبر تيليجرام بمجرد توفر المنتج مرة أخرى.",
"notificationMethodLabel": "كيف تود أن يتم إعلامك؟",
"telegramBotLabel": "بوت تيليجرام",
"telegramIdLabel": "معرف تيليجرام",
"telegramPlaceholder": "@اسمكعلىتيليجرام أو معرف تيليجرام",
"telegramHelper": "أدخل اسم المستخدم الخاص بك على تيليجرام (مع @) أو معرف تيليجرام",
"messageLabel": "رسالة (اختياري)",
"messagePlaceholder": "معلومات إضافية أو أسئلة...",
"submitAvailability": "طلب التوفر",
"photoUploadSelect": "اختر الصور",
"photoUploadErrorMaxFiles": "الحد الأقصى {{max}} ملفات مسموح بها",
"photoUploadErrorFileType": "مسموح فقط بملفات الصور (JPEG, PNG, GIF, WebP)",
"photoUploadErrorFileSize": "الملف كبير جدًا. الحد الأقصى: {{maxSize}} ميجابايت",
"photoUploadSelectedFiles": "{{count}} ملف(ملفات) مختارة",
"photoUploadCompressed": "(تم الضغط للرفع)",
"photoUploadRemove": "إزالة الصورة",
"photoUploadLabelDefault": "أرفق صورًا (اختياري)",
"shareTitle": "مشاركة",
"shareEmbed": "تضمين",
"shareCopyLink": "نسخ الرابط",
"shareSuccessEmbed": "تم نسخ كود التضمين إلى الحافظة!",
"shareErrorEmbed": "حدث خطأ أثناء نسخ كود التضمين",
"shareSuccessLink": "تم نسخ الرابط إلى الحافظة!",
"shareWhatsAppText": "شوف المنتج ده: {{name}}",
"shareTelegramText": "شوف المنتج ده: {{name}}",
"shareEmailSubject": "توصية بمنتج",
"shareEmailBody": "مرحبًا،\n\nحابب أوصي لك بالمنتج ده:\n\n{{name}}\n{{url}}\n\nمع أطيب التحيات"
};

View File

@@ -2,4 +2,5 @@ export default {
"placeholder": "ممكن تسألني عن أنواع الحشيش...",
"recording": "جاري التسجيل...",
"searchProducts": "ابحث عن المنتجات...",
"searchResultsFor": "نتائج البحث عن: \"{{query}}\"",
};

View File

@@ -1,11 +1,12 @@
export default {
"seeds": "بذور",
"stecklinge": "قصاصات",
"oilPress": "استعارة معصرة الزيت",
"konfigurator": "المُكوّن",
"oilPress": "استعارة مكبس الزيت",
"thcTest": "اختبار THC",
"address1": "Trachenberger Straße 14",
"address2": "01129 Dresden",
"showUsPhoto": "ورينا أجمل صورة عندك",
"selectSeedRate": "اختار البذرة واضغط تقييم",
"indoorSeason": "موسم الزراعة الداخلية بدأ"
"address1": "شارع تراشينبرجر 14",
"address2": "01129 دريسدن",
"showUsPhoto": "اعرض لنا أجمل صورة لديك",
"selectSeedRate": "اختر البذرة، واضغط للتقييم",
"indoorSeason": "بدأ موسم الزراعة الداخلية"
};

View File

@@ -1,5 +1,5 @@
export default {
"home": "بذور وقصاصات القنب الممتازة",
"home": "بذور القنب الممتازة",
"aktionen": "العروض والتخفيضات الحالية",
"filiale": "متجرنا في دريسدن",
"filiale": "متجرنا في دريسدن"
};

View File

@@ -5,14 +5,16 @@ export default {
"profile": "Профил",
"email": "Имейл",
"password": "Парола",
"newPassword": "Нова парола",
"confirmPassword": "Потвърдете паролата",
"forgotPassword": "Забравена парола?",
"loginWithGoogle": "Вход с Google",
"or": "ИЛИ",
"privacyAccept": "С натискане на \"Вход с Google\" приемам",
"privacyAccept": "С натискането на \"Вход с Google\" приемам",
"privacyPolicy": "Политиката за поверителност",
"passwordMinLength": "Паролата трябва да е поне 8 символа",
"newPasswordMinLength": "Новата парола трябва да е поне 8 символа",
"backToHome": "Обратно към началната страница",
"menu": {
"profile": "Профил",
"myProfile": "Моят профил",
@@ -21,5 +23,28 @@ export default {
"settings": "Настройки",
"adminDashboard": "Админ табло",
"adminUsers": "Админ потребители"
},
"resetPassword": {
"title": "Нулиране на парола",
"button": "Нулиране на парола",
"success": "Вашата парола беше успешно нулирана! Скоро ще бъдете пренасочени към вход...",
"invalidToken": "Няма валиден токен. Моля, използвайте линка от имейла си.",
"error": "Грешка при нулиране на паролата",
"emailSent": "Линк за нулиране на паролата беше изпратен на вашия имейл.",
"emailError": "Грешка при изпращане на имейла"
},
"errors": {
"fillAllFields": "Моля, попълнете всички полета",
"invalidEmail": "Моля, въведете валиден имейл адрес",
"passwordsNotMatch": "Паролите не съвпадат",
"passwordsNotMatchShort": "Паролите не съвпадат",
"enterEmail": "Моля, въведете вашия имейл адрес",
"loginFailed": "Входът не бе успешен",
"registerFailed": "Регистрацията не бе успешна",
"googleLoginFailed": "Вход с Google не бе успешен",
"emailExists": "Потребител с този имейл вече съществува. Моля, използвайте друг имейл или влезте в системата."
},
"success": {
"registerComplete": "Регистрацията беше успешна. Сега можете да влезете."
}
};

View File

@@ -15,5 +15,6 @@ export default {
"remove": "Премахни",
"products": "Продукти",
"product": "Продукт",
"days": "Дни"
"days": "Дни",
"more": "още"
};

View File

@@ -16,7 +16,7 @@ export default {
"prices": {
"free": "безплатно",
"freeFrom100": "(безплатно от 100€)",
"dhl": "6.99 €",
"dhl": "5.90 €",
"dpd": "4.90 €",
"sperrgut": "28.99 €"
},
@@ -29,7 +29,7 @@ export default {
"title": "Изберете метод на доставка",
"freeShippingInfo": "💡 Безплатна доставка при поръчка над 100€!",
"remainingForFree": "Добавете още {{amount}}€ за безплатна доставка.",
"congratsFreeShipping": "🎉 Поздравления! Вие получавате безплатна доставка!",
"cartQualifiesFree": "Вашата количка на стойност {{amount}}€ се квалифицира за безплатна доставка."
"congratsFreeShipping": "🎉 Поздравления! Получавате безплатна доставка!",
"cartQualifiesFree": "Вашата количка на стойност {{amount}}€ отговаря на условията за безплатна доставка."
}
};

View File

@@ -3,6 +3,7 @@ import navigation from './navigation.js';
import auth from './auth.js';
import cart from './cart.js';
import product from './product.js';
import productDialogs from './productDialogs.js';
import search from './search.js';
import sorting from './sorting.js';
import chat from './chat.js';
@@ -18,6 +19,7 @@ import pages from './pages.js';
import orders from './orders.js';
import settings from './settings.js';
import common from './common.js';
import kitConfig from './kitConfig.js';
import legalDatenschutzBasic from './legal-datenschutz-basic.js';
import legalDatenschutzCustomer from './legal-datenschutz-customer.js';
import legalDatenschutzGoogleOrders from './legal-datenschutz-google-orders.js';
@@ -35,6 +37,7 @@ export default {
"auth": auth,
"cart": cart,
"product": product,
"productDialogs": productDialogs,
"search": search,
"sorting": sorting,
"chat": chat,
@@ -50,6 +53,7 @@ export default {
"orders": orders,
"settings": settings,
"common": common,
"kitConfig": kitConfig,
"legalDatenschutzBasic": legalDatenschutzBasic,
"legalDatenschutzCustomer": legalDatenschutzCustomer,
"legalDatenschutzGoogleOrders": legalDatenschutzGoogleOrders,

View File

@@ -0,0 +1,43 @@
export default {
"pageTitle": "🌱 Конфигуратор за Growbox",
"pageSubtitle": "Създайте перфектната си вътрешна система за отглеждане",
"bundleDiscountTitle": "🎯 Вземете отстъпка за комплект!",
"loadingProducts": "Зареждане на продукти за growbox...",
"loadingLighting": "Зареждане на осветителни продукти...",
"loadingVentilation": "Зареждане на вентилационни продукти...",
"loadingExtras": "Зареждане на допълнителни продукти...",
"noProductsAvailable": "Няма налични продукти за този размер",
"noLightingAvailable": "Няма подходящи лампи за размер на палатка {{shape}}.",
"noVentilationAvailable": "Няма подходяща вентилация за размер на палатка {{shape}}.",
"noExtrasAvailable": "Няма налични допълнения",
"selectShapeTitle": "1. Изберете форма на growbox",
"selectShapeSubtitle": "Първо изберете основната площ на вашия growbox",
"selectProductTitle": "2. Изберете продукт за growbox",
"selectProductSubtitle": "Изберете подходящия продукт за вашия {{shape}} growbox",
"selectLightingTitle": "3. Изберете осветление",
"selectLightingTitleShape": "3. Изберете осветление - {{shape}}",
"selectLightingSubtitle": "Моля, първо изберете размер на палатка.",
"selectVentilationTitle": "4. Изберете вентилация",
"selectVentilationTitleShape": "4. Изберете вентилация - {{shape}}",
"selectVentilationSubtitle": "Моля, първо изберете размер на палатка.",
"selectExtrasTitle": "5. Добавете допълнения (по избор)",
"yourConfiguration": "🎯 Вашата конфигурация",
"growboxLabel": "Growbox: {{name}}",
"lightingLabel": "Осветление: {{name}}",
"ventilationLabel": "Вентилация: {{name}}",
"extraLabel": "Допълнение: {{name}}",
"totalPrice": "Обща цена:",
"addToCart": "Добави в количката",
"selected": "✓ Избрано",
"notDeliverable": "Не е налично за доставка",
"noPrice": "Няма цена",
"setName": "Комплект Growbox - {{shape}}",
"description60x60": "Компактен - идеален за малки пространства",
"description80x80": "Среден - перфектен баланс",
"description100x100": "Голям - за опитни отглеждачи",
"description120x60": "Правоъгълен - максимално използване на пространството",
"plants1to2": "1-2 растения",
"plants2to4": "2-4 растения",
"plants4to6": "4-6 растения",
"plants3to6": "3-6 растения"
};

View File

@@ -1,14 +1,15 @@
export default {
"status": {
"new": "В процес",
"pending": "Нова",
"processing": "В процес",
"cancelled": "Отменена",
"shipped": "Изпратена",
"delivered": "Доставена",
"new": "в процес",
"pending": "Ново",
"processing": "в процес",
"paid": "Платено",
"cancelled": "Отменено",
"shipped": "Изпратено",
"delivered": "Доставено",
"return": "Връщане",
"partialReturn": "Частично връщане",
"partialDelivered": "Частично доставена"
"partialDelivered": "Частично доставено"
},
"table": {
"orderNumber": "Номер на поръчка",
@@ -24,6 +25,7 @@ export default {
"cancelOrder": "Отмени поръчката"
},
"noOrders": "Все още не сте направили поръчки.",
"trackShipment": "Проследи пратката",
"details": {
"title": "Подробности за поръчка: {{orderId}}",
"deliveryAddress": "Адрес за доставка",
@@ -36,15 +38,14 @@ export default {
"item": "Артикул",
"quantity": "Количество",
"price": "Цена",
"vat": "ДДС",
"total": "Общо",
"cancelOrder": "Отмени поръчката"
},
"cancelConfirm": {
"title": "Отмяна на поръчка",
"message": "Сигурни ли сте, че искате да отмените тази поръчка?",
"confirm": "Отмени поръчката",
"cancelling": "Отмяна..."
"title": "Отмени поръчката",
"message": "Сигурни ли сте, че искате да отмените тази поръчка?",
"confirm": "Отмени",
"cancelling": "Отмяна..."
},
"processing": "Поръчката се обработва...",
"processing": "Поръчката се обработва..."
};

View File

@@ -8,9 +8,10 @@ export default {
"articleNumber": "Номер на артикул",
"manufacturer": "Производител",
"inclVat": "вкл. {{vat}}% ДДС",
"inclVatSimple": "вкл. ДДС",
"priceUnit": "{{price}}/{{unit}}",
"new": "Нов",
"weeks": "седмици",
"weeks": "Седмици",
"arriving": "Пристигане:",
"inclVatFooter": "вкл. {{vat}}% ДДС,*",
"availability": "Наличност",
@@ -25,6 +26,7 @@ export default {
"pickupPrice": "Цена за вземане: 19,90 € на резник.",
"consistsOf": "Състои се от:",
"loadingComponentDetails": "{{index}}. Зареждане на детайли за компонента...",
"loadingProduct": "Зареждане на продукта...",
"individualPriceTotal": "Обща индивидуална цена:",
"setPrice": "Цена на комплекта:",
"yourSavings": "Вашите спестявания:",

View File

@@ -0,0 +1,61 @@
export default {
"questionTitle": "Въпрос за продукта",
"questionSubtitle": "Имате ли въпрос за този продукт? Ще се радваме да ви помогнем.",
"questionSuccess": "Благодарим ви за въпроса! Ще се свържем с вас възможно най-скоро.",
"nameLabel": "Име",
"namePlaceholder": "Вашето име",
"emailLabel": "Имейл",
"emailPlaceholder": "your.email@example.com",
"questionLabel": "Вашият въпрос",
"questionPlaceholder": "Опишете въпроса си за този продукт...",
"photosLabelQuestion": "Прикачете снимки към въпроса си (по избор)",
"submitQuestion": "Изпрати въпроса",
"sending": "Изпращане...",
"ratingTitle": "Оценете продукта",
"ratingSubtitle": "Споделете опита си с този продукт и помогнете на други клиенти да вземат решение.",
"ratingSuccess": "Благодарим ви за вашия отзив! Той ще бъде публикуван след проверка.",
"emailHelper": "Вашият имейл няма да бъде публикуван",
"ratingLabel": "Оценка *",
"pleaseRate": "Моля, оценете",
"ratingStars": "{{rating}} от 5 звезди",
"reviewLabel": "Вашият отзив (по избор)",
"reviewPlaceholder": "Опишете опита си с този продукт...",
"photosLabelRating": "Прикачете снимки към отзива си (по избор)",
"submitRating": "Изпрати отзива",
"errorGeneric": "Възникна грешка",
"errorPhotos": "Грешка при обработка на снимките",
"availabilityTitle": "Запитване за наличност",
"availabilitySubtitle": "Този продукт в момента не е наличен. Ще се радваме да ви уведомим веднага щом бъде наличен отново.",
"availabilitySuccessEmail": "Благодарим ви за запитването! Ще ви уведомим по имейл веднага щом продуктът отново е наличен.",
"availabilitySuccessTelegram": "Благодарим ви за запитването! Ще ви уведомим чрез Telegram веднага щом продуктът отново е наличен.",
"notificationMethodLabel": "Как бихте искали да бъдете уведомени?",
"telegramBotLabel": "Telegram Bot",
"telegramIdLabel": "Telegram ID",
"telegramPlaceholder": "@yourTelegramName or Telegram ID",
"telegramHelper": "Въведете вашето потребителско име в Telegram (с @) или Telegram ID",
"messageLabel": "Съобщение (по избор)",
"messagePlaceholder": "Допълнителна информация или въпроси...",
"submitAvailability": "Запитване за наличност",
"photoUploadSelect": "Изберете снимки",
"photoUploadErrorMaxFiles": "Максимум {{max}} файла са разрешени",
"photoUploadErrorFileType": "Разрешени са само файлове с изображения (JPEG, PNG, GIF, WebP)",
"photoUploadErrorFileSize": "Файлът е твърде голям. Максимум: {{maxSize}}MB",
"photoUploadSelectedFiles": "{{count}} файл(ове) избрани",
"photoUploadCompressed": "(компресиран за качване)",
"photoUploadRemove": "Премахни изображението",
"photoUploadLabelDefault": "Прикачи снимки (по избор)",
"shareTitle": "Сподели",
"shareEmbed": "Вграждане",
"shareCopyLink": "Копирай линка",
"shareSuccessEmbed": "Кодът за вграждане е копиран в клипборда!",
"shareErrorEmbed": "Грешка при копиране на кода за вграждане",
"shareSuccessLink": "Линкът е копиран в клипборда!",
"shareWhatsAppText": "Виж този продукт: {{name}}",
"shareTelegramText": "Виж този продукт: {{name}}",
"shareEmailSubject": "Препоръка за продукт",
"shareEmailBody": "Здравейте,\n\nИскам да ви препоръчам този продукт:\n\n{{name}}\n{{url}}\n\nПоздрави"
};

View File

@@ -2,4 +2,5 @@ export default {
"placeholder": "Можете да ме попитате за сортове канабис...",
"recording": "Записът е в ход...",
"searchProducts": "Търсене на продукти...",
"searchResultsFor": "Резултати от търсенето за: \"{{query}}\"",
};

View File

@@ -1,7 +1,8 @@
export default {
"seeds": "Семена",
"stecklinge": "Резници",
"oilPress": "Наеми преса за масло",
"konfigurator": "Конфигуратор",
"oilPress": "Наеми преса за олио",
"thcTest": "THC тест",
"address1": "Trachenberger Straße 14",
"address2": "01129 Dresden",

View File

@@ -1,5 +1,5 @@
export default {
"home": "Фини семена и резници от канабис",
"home": "Качествени канабис семена",
"aktionen": "Текущи промоции и оферти",
"filiale": "Нашият магазин в Дрезден",
"filiale": "Нашият магазин в Дрезден"
};

View File

@@ -5,6 +5,7 @@ export default {
"profile": "Profil",
"email": "Email",
"password": "Heslo",
"newPassword": "Nové heslo",
"confirmPassword": "Potvrdit heslo",
"forgotPassword": "Zapomněli jste heslo?",
"loginWithGoogle": "Přihlásit se přes Google",
@@ -13,6 +14,7 @@ export default {
"privacyPolicy": "Zásadami ochrany osobních údajů",
"passwordMinLength": "Heslo musí mít alespoň 8 znaků",
"newPasswordMinLength": "Nové heslo musí mít alespoň 8 znaků",
"backToHome": "Zpět na domovskou stránku",
"menu": {
"profile": "Profil",
"myProfile": "Můj profil",
@@ -21,5 +23,28 @@ export default {
"settings": "Nastavení",
"adminDashboard": "Admin Dashboard",
"adminUsers": "Admin Users"
},
"resetPassword": {
"title": "Obnovení hesla",
"button": "Obnovit heslo",
"success": "Vaše heslo bylo úspěšně obnoveno! Brzy budete přesměrováni na přihlášení...",
"invalidToken": "Nebyl nalezen platný token. Použijte prosím odkaz z vašeho e-mailu.",
"error": "Chyba při obnově hesla",
"emailSent": "Odkaz pro obnovení hesla byl odeslán na vaši e-mailovou adresu.",
"emailError": "Chyba při odesílání e-mailu"
},
"errors": {
"fillAllFields": "Vyplňte prosím všechna pole",
"invalidEmail": "Zadejte platnou e-mailovou adresu",
"passwordsNotMatch": "Hesla se neshodují",
"passwordsNotMatchShort": "Hesla se neshodují",
"enterEmail": "Zadejte prosím svou e-mailovou adresu",
"loginFailed": "Přihlášení selhalo",
"registerFailed": "Registrace selhala",
"googleLoginFailed": "Přihlášení přes Google selhalo",
"emailExists": "Uživatel s touto e-mailovou adresou již existuje. Použijte prosím jinou e-mailovou adresu nebo se přihlaste."
},
"success": {
"registerComplete": "Registrace byla úspěšná. Nyní se můžete přihlásit."
}
};

View File

@@ -15,5 +15,6 @@ export default {
"remove": "Odebrat",
"products": "Produkty",
"product": "Produkt",
"days": "Dny"
"days": "Dny",
"more": "více"
};

View File

@@ -8,17 +8,17 @@ export default {
},
"descriptions": {
"standard": "Standardní doprava",
"standardFree": "Standardní doprava - ZDARMA od objednávky nad 100€!",
"notAvailable": "Nelze vybrat, protože jeden nebo více produktů lze pouze vyzvednout",
"bulky": "Pro velké a těžké předměty",
"standardFree": "Standardní doprava - ZDARMA od hodnoty objednávky 100€!",
"notAvailable": "Nelze vybrat, protože jeden nebo více položek lze pouze vyzvednout",
"bulky": "Pro velké a těžké položky",
"pickupOnly": "Pouze osobní odběr"
},
"prices": {
"free": "zdarma",
"freeFrom100": "(zdarma od 100€)",
"dhl": "6,99 €",
"dpd": "4,90 €",
"sperrgut": "28,99 €"
"dhl": "5.90 €",
"dpd": "4.90 €",
"sperrgut": "28.99 €"
},
"times": {
"cutting14Days": "Doba dodání: 14 dní",
@@ -27,9 +27,9 @@ export default {
},
"selector": {
"title": "Vyberte způsob dopravy",
"freeShippingInfo": "💡 Doprava zdarma od objednávky nad 100€!",
"freeShippingInfo": "💡 Doprava zdarma od hodnoty objednávky 100€!",
"remainingForFree": "Přidejte ještě {{amount}}€ pro dopravu zdarma.",
"congratsFreeShipping": "🎉 Gratulujeme! Máte dopravu zdarma!",
"cartQualifiesFree": "Váš košík za {{amount}}€ má nárok na dopravu zdarma."
"cartQualifiesFree": "Váš košík v hodnotě {{amount}}€ má nárok na dopravu zdarma."
}
};

View File

@@ -3,6 +3,7 @@ import navigation from './navigation.js';
import auth from './auth.js';
import cart from './cart.js';
import product from './product.js';
import productDialogs from './productDialogs.js';
import search from './search.js';
import sorting from './sorting.js';
import chat from './chat.js';
@@ -18,6 +19,7 @@ import pages from './pages.js';
import orders from './orders.js';
import settings from './settings.js';
import common from './common.js';
import kitConfig from './kitConfig.js';
import legalDatenschutzBasic from './legal-datenschutz-basic.js';
import legalDatenschutzCustomer from './legal-datenschutz-customer.js';
import legalDatenschutzGoogleOrders from './legal-datenschutz-google-orders.js';
@@ -35,6 +37,7 @@ export default {
"auth": auth,
"cart": cart,
"product": product,
"productDialogs": productDialogs,
"search": search,
"sorting": sorting,
"chat": chat,
@@ -50,6 +53,7 @@ export default {
"orders": orders,
"settings": settings,
"common": common,
"kitConfig": kitConfig,
"legalDatenschutzBasic": legalDatenschutzBasic,
"legalDatenschutzCustomer": legalDatenschutzCustomer,
"legalDatenschutzGoogleOrders": legalDatenschutzGoogleOrders,

View File

@@ -0,0 +1,43 @@
export default {
"pageTitle": "🌱 Konfigurátor Growboxu",
"pageSubtitle": "Sestavte si svůj dokonalý indoor grow setup",
"bundleDiscountTitle": "🎯 Zajistěte si slevu na balíček!",
"loadingProducts": "Načítání produktů growboxu...",
"loadingLighting": "Načítání osvětlení...",
"loadingVentilation": "Načítání ventilace...",
"loadingExtras": "Načítání doplňků...",
"noProductsAvailable": "Pro tuto velikost nejsou k dispozici žádné produkty",
"noLightingAvailable": "Pro velikost stanu {{shape}} nejsou k dispozici žádná vhodná světla.",
"noVentilationAvailable": "Pro velikost stanu {{shape}} není k dispozici vhodná ventilace.",
"noExtrasAvailable": "Žádné doplňky nejsou k dispozici",
"selectShapeTitle": "1. Vyberte tvar growboxu",
"selectShapeSubtitle": "Nejprve vyberte základní plochu vašeho growboxu",
"selectProductTitle": "2. Vyberte produkt growboxu",
"selectProductSubtitle": "Vyberte správný produkt pro váš growbox {{shape}}",
"selectLightingTitle": "3. Vyberte osvětlení",
"selectLightingTitleShape": "3. Vyberte osvětlení - {{shape}}",
"selectLightingSubtitle": "Nejprve prosím vyberte velikost stanu.",
"selectVentilationTitle": "4. Vyberte ventilaci",
"selectVentilationTitleShape": "4. Vyberte ventilaci - {{shape}}",
"selectVentilationSubtitle": "Nejprve prosím vyberte velikost stanu.",
"selectExtrasTitle": "5. Přidejte doplňky (volitelné)",
"yourConfiguration": "🎯 Vaše konfigurace",
"growboxLabel": "Growbox: {{name}}",
"lightingLabel": "Osvětlení: {{name}}",
"ventilationLabel": "Ventilace: {{name}}",
"extraLabel": "Doplněk: {{name}}",
"totalPrice": "Celková cena:",
"addToCart": "Přidat do košíku",
"selected": "✓ Vybráno",
"notDeliverable": "Nedodává se",
"noPrice": "Bez ceny",
"setName": "Sada growboxu - {{shape}}",
"description60x60": "Kompaktní - ideální pro malé prostory",
"description80x80": "Střední - perfektní rovnováha",
"description100x100": "Velký - pro zkušené pěstitele",
"description120x60": "Obdélníkový - maximální využití prostoru",
"plants1to2": "1-2 rostliny",
"plants2to4": "2-4 rostliny",
"plants4to6": "4-6 rostlin",
"plants3to6": "3-6 rostlin"
};

View File

@@ -1,50 +1,51 @@
export default {
"status": {
"new": "Probíhá",
"pending": "Nová",
"processing": "Probíhá",
"cancelled": "Zrušeno",
"shipped": "Odesláno",
"delivered": "Doručeno",
"return": "Vrácení",
"partialReturn": "Částečné vrácení",
"partialDelivered": "Částečně doručeno"
"new": "probíhá",
"pending": "Nové",
"processing": "probíhá",
"paid": "Zaplaceno",
"cancelled": "Zrušeno",
"shipped": "Odesláno",
"delivered": "Doručeno",
"return": "Vrácení",
"partialReturn": "Částečné vrácení",
"partialDelivered": "Částečně doručeno"
},
"table": {
"orderNumber": "Číslo objednávky",
"date": "Datum",
"status": "Stav",
"items": "Položky",
"total": "Celkem",
"actions": "Akce",
"viewDetails": "Zobrazit detaily"
"orderNumber": "Číslo objednávky",
"date": "Datum",
"status": "Stav",
"items": "Položky",
"total": "Celkem",
"actions": "Akce",
"viewDetails": "Zobrazit detaily"
},
"tooltips": {
"viewDetails": "Zobrazit detaily",
"cancelOrder": "Zrušit objednávku"
"viewDetails": "Zobrazit detaily",
"cancelOrder": "Zrušit objednávku"
},
"noOrders": "Ještě jste neprovedli žádné objednávky.",
"noOrders": "Ještě jste neprovedli žádné objednávky.",
"trackShipment": "Sledovat zásilku",
"details": {
"title": "Detaily objednávky: {{orderId}}",
"deliveryAddress": "Dodací adresa",
"invoiceAddress": "Fakturační adresa",
"orderDetails": "Detaily objednávky",
"deliveryMethod": "Způsob doručení:",
"paymentMethod": "Způsob platby:",
"notSpecified": "Nespecifikováno",
"orderedItems": "Objednané položky",
"item": "Položka",
"quantity": "Množství",
"price": "Cena",
"vat": "DPH",
"total": "Celkem",
"cancelOrder": "Zrušit objednávku"
"title": "Detaily objednávky: {{orderId}}",
"deliveryAddress": "Dodací adresa",
"invoiceAddress": "Fakturační adresa",
"orderDetails": "Detaily objednávky",
"deliveryMethod": "Způsob doručení:",
"paymentMethod": "Způsob platby:",
"notSpecified": "Nespecifikováno",
"orderedItems": "Objednané položky",
"item": "Položka",
"quantity": "Množství",
"price": "Cena",
"total": "Celkem",
"cancelOrder": "Zrušit objednávku"
},
"cancelConfirm": {
"title": "Zrušit objednávku",
"message": "Opravdu chcete tuto objednávku zrušit?",
"confirm": "Zrušit objednávku",
"confirm": "Zrušit",
"cancelling": "Rušení..."
},
"processing": "Objednávka se dokončuje...",
"processing": "Objednávka se dokončuje..."
};

View File

@@ -8,23 +8,25 @@ export default {
"articleNumber": "Číslo artiklu",
"manufacturer": "Výrobce",
"inclVat": "včetně {{vat}}% DPH",
"inclVatSimple": "včetně DPH",
"priceUnit": "{{price}}/{{unit}}",
"new": "Nové",
"weeks": "týdnů",
"arriving": "Příchod:",
"weeks": "Týdny",
"arriving": "Příjezd:",
"inclVatFooter": "včetně {{vat}}% DPH,*",
"availability": "Dostupnost",
"inStock": "skladem",
"comingSoon": "Brzy k dispozici",
"deliveryTime": "Doba dodání",
"inclShort": "vč.",
"inclShort": "včetně",
"vatShort": "DPH",
"weight": "Hmotnost: {{weight}} kg",
"youSave": "Ušetříte: {{amount}}",
"cheaperThanIndividual": "Levnější než nákup jednotlivě",
"pickupPrice": "Cena za vyzvednutí: 19,90 € za řízek.",
"pickupPrice": "Cena za odběr: 19,90 € za řízek.",
"consistsOf": "Skládá se z:",
"loadingComponentDetails": "{{index}}. Načítání detailů komponenty...",
"loadingProduct": "Načítání produktu...",
"individualPriceTotal": "Celková cena jednotlivě:",
"setPrice": "Cena sady:",
"yourSavings": "Vaše úspory:",

View File

@@ -0,0 +1,61 @@
export default {
"questionTitle": "Otázka ohledně produktu",
"questionSubtitle": "Máte otázku ohledně tohoto produktu? Rádi vám pomůžeme.",
"questionSuccess": "Děkujeme za vaši otázku! Ozveme se vám co nejdříve.",
"nameLabel": "Jméno",
"namePlaceholder": "Vaše jméno",
"emailLabel": "Email",
"emailPlaceholder": "vas.email@priklad.cz",
"questionLabel": "Vaše otázka",
"questionPlaceholder": "Popište svou otázku ohledně tohoto produktu...",
"photosLabelQuestion": "Přiložte fotografie k vaší otázce (volitelné)",
"submitQuestion": "Odeslat otázku",
"sending": "Odesílání...",
"ratingTitle": "Ohodnoťte produkt",
"ratingSubtitle": "Podělte se o své zkušenosti s tímto produktem a pomozte ostatním zákazníkům s rozhodnutím.",
"ratingSuccess": "Děkujeme za vaši recenzi! Bude zveřejněna po ověření.",
"emailHelper": "Váš email nebude zveřejněn",
"ratingLabel": "Hodnocení *",
"pleaseRate": "Prosím ohodnoťte",
"ratingStars": "{{rating}} z 5 hvězdiček",
"reviewLabel": "Vaše recenze (volitelné)",
"reviewPlaceholder": "Popište své zkušenosti s tímto produktem...",
"photosLabelRating": "Přiložte fotografie k vaší recenzi (volitelné)",
"submitRating": "Odeslat recenzi",
"errorGeneric": "Došlo k chybě",
"errorPhotos": "Chyba při zpracování fotografií",
"availabilityTitle": "Požádejte o dostupnost",
"availabilitySubtitle": "Tento produkt momentálně není dostupný. Rádi vás informujeme, jakmile bude opět skladem.",
"availabilitySuccessEmail": "Děkujeme za váš požadavek! Jakmile bude produkt opět dostupný, budeme vás informovat e-mailem.",
"availabilitySuccessTelegram": "Děkujeme za váš požadavek! Jakmile bude produkt opět dostupný, budeme vás informovat přes Telegram.",
"notificationMethodLabel": "Jak chcete být informováni?",
"telegramBotLabel": "Telegram Bot",
"telegramIdLabel": "Telegram ID",
"telegramPlaceholder": "@vaseTelegramJmeno nebo Telegram ID",
"telegramHelper": "Zadejte své uživatelské jméno na Telegramu (s @) nebo Telegram ID",
"messageLabel": "Zpráva (volitelné)",
"messagePlaceholder": "Další informace nebo otázky...",
"submitAvailability": "Požádat o dostupnost",
"photoUploadSelect": "Vybrat fotografie",
"photoUploadErrorMaxFiles": "Maximálně {{max}} souborů povoleno",
"photoUploadErrorFileType": "Jsou povoleny pouze obrazové soubory (JPEG, PNG, GIF, WebP)",
"photoUploadErrorFileSize": "Soubor je příliš velký. Maximum: {{maxSize}}MB",
"photoUploadSelectedFiles": "Vybráno {{count}} souborů",
"photoUploadCompressed": "(komprimováno pro nahrání)",
"photoUploadRemove": "Odstranit obrázek",
"photoUploadLabelDefault": "Přiložit fotografie (volitelné)",
"shareTitle": "Sdílet",
"shareEmbed": "Vložit",
"shareCopyLink": "Kopírovat odkaz",
"shareSuccessEmbed": "Kód pro vložení zkopírován do schránky!",
"shareErrorEmbed": "Chyba při kopírování kódu pro vložení",
"shareSuccessLink": "Odkaz zkopírován do schránky!",
"shareWhatsAppText": "Podívejte se na tento produkt: {{name}}",
"shareTelegramText": "Podívejte se na tento produkt: {{name}}",
"shareEmailSubject": "Doporučení produktu",
"shareEmailBody": "Dobrý den,\n\nrád bych vám doporučil tento produkt:\n\n{{name}}\n{{url}}\n\nS pozdravem"
};

View File

@@ -2,4 +2,5 @@ export default {
"placeholder": "Můžeš se mě zeptat na odrůdy konopí...",
"recording": "Probíhá nahrávání...",
"searchProducts": "Hledat produkty...",
"searchResultsFor": "Výsledky hledání pro: \"{{query}}\"",
};

View File

@@ -1,6 +1,7 @@
export default {
"seeds": "Semena",
"stecklinge": "Řízky",
"konfigurator": "Konfigurátor",
"oilPress": "Půjčit lis na olej",
"thcTest": "THC test",
"address1": "Trachenberger Straße 14",

View File

@@ -1,5 +1,5 @@
export default {
"home": "Kvalitní semena a řízky konopí",
"home": "Kvalitní semena konopí",
"aktionen": "Aktuální akce a nabídky",
"filiale": "Naše prodejna v Drážďanech",
"filiale": "Naše prodejna v Drážďanech"
};

View File

@@ -5,6 +5,7 @@ export default {
"profile": "Profil",
"email": "E-Mail",
"password": "Passwort",
"newPassword": "Neues Passwort",
"confirmPassword": "Passwort bestätigen",
"forgotPassword": "Passwort vergessen?",
"loginWithGoogle": "Mit Google anmelden",
@@ -13,6 +14,7 @@ export default {
"privacyPolicy": "Datenschutzbestimmungen",
"passwordMinLength": "Das Passwort muss mindestens 8 Zeichen lang sein",
"newPasswordMinLength": "Das neue Passwort muss mindestens 8 Zeichen lang sein",
"backToHome": "Zurück zur Startseite",
"menu": {
"profile": "Profil",
"myProfile": "Mein Profil",
@@ -21,5 +23,28 @@ export default {
"settings": "Einstellungen",
"adminDashboard": "Admin Dashboard",
"adminUsers": "Admin Users"
},
"resetPassword": {
"title": "Passwort zurücksetzen",
"button": "Passwort zurücksetzen",
"success": "Ihr Passwort wurde erfolgreich zurückgesetzt! Sie werden in Kürze zur Anmeldung weitergeleitet...",
"invalidToken": "Kein gültiger Token gefunden. Bitte verwenden Sie den Link aus Ihrer E-Mail.",
"error": "Fehler beim Zurücksetzen des Passworts",
"emailSent": "Ein Link zum Zurücksetzen des Passworts wurde an Ihre E-Mail-Adresse gesendet.",
"emailError": "Fehler beim Senden der E-Mail"
},
"errors": {
"fillAllFields": "Bitte füllen Sie alle Felder aus",
"invalidEmail": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"passwordsNotMatch": "Die Passwörter stimmen nicht überein",
"passwordsNotMatchShort": "Passwörter stimmen nicht überein",
"enterEmail": "Bitte geben Sie Ihre E-Mail-Adresse ein",
"loginFailed": "Anmeldung fehlgeschlagen",
"registerFailed": "Registrierung fehlgeschlagen",
"googleLoginFailed": "Google-Anmeldung fehlgeschlagen",
"emailExists": "Ein Benutzer mit dieser E-Mail-Adresse existiert bereits. Bitte verwenden Sie eine andere E-Mail-Adresse oder melden Sie sich an."
},
"success": {
"registerComplete": "Registrierung erfolgreich. Sie können sich jetzt anmelden."
}
};

View File

@@ -15,5 +15,6 @@ export default {
"remove": "Entfernen",
"products": "Produkte",
"product": "Produkt",
"days": "Tage"
"days": "Tage",
"more": "mehr"
};

View File

@@ -16,7 +16,7 @@ export default {
"prices": {
"free": "kostenlos",
"freeFrom100": "(kostenlos ab 100€)",
"dhl": "6,99 €",
"dhl": "5,90 €",
"dpd": "4,90 €",
"sperrgut": "28,99 €"
},

View File

@@ -3,6 +3,7 @@ import navigation from './navigation.js';
import auth from './auth.js';
import cart from './cart.js';
import product from './product.js';
import productDialogs from './productDialogs.js';
import search from './search.js';
import sorting from './sorting.js';
import chat from './chat.js';
@@ -18,6 +19,7 @@ import pages from './pages.js';
import orders from './orders.js';
import settings from './settings.js';
import common from './common.js';
import kitConfig from './kitConfig.js';
import legalDatenschutzBasic from './legal-datenschutz-basic.js';
import legalDatenschutzCustomer from './legal-datenschutz-customer.js';
import legalDatenschutzGoogleOrders from './legal-datenschutz-google-orders.js';
@@ -35,6 +37,7 @@ export default {
"auth": auth,
"cart": cart,
"product": product,
"productDialogs": productDialogs,
"search": search,
"sorting": sorting,
"chat": chat,
@@ -50,6 +53,7 @@ export default {
"orders": orders,
"settings": settings,
"common": common,
"kitConfig": kitConfig,
"legalDatenschutzBasic": legalDatenschutzBasic,
"legalDatenschutzCustomer": legalDatenschutzCustomer,
"legalDatenschutzGoogleOrders": legalDatenschutzGoogleOrders,

View File

@@ -0,0 +1,44 @@
export default {
"pageTitle": "🌱 Growbox Konfigurator",
"pageSubtitle": "Stelle dein perfektes Indoor Grow Setup zusammen",
"bundleDiscountTitle": "🎯 Bundle-Rabatt sichern!",
"loadingProducts": "Lade Growbox-Produkte...",
"loadingLighting": "Lade Beleuchtungs-Produkte...",
"loadingVentilation": "Lade Belüftungs-Produkte...",
"loadingExtras": "Lade Extras...",
"noProductsAvailable": "Keine Produkte für diese Größe verfügbar",
"noLightingAvailable": "Keine passenden Lampen für Zeltgröße {{shape}} verfügbar.",
"noVentilationAvailable": "Keine passenden Belüftung für Zeltgröße {{shape}} verfügbar.",
"noExtrasAvailable": "Keine Extras verfügbar",
"selectShapeTitle": "1. Growbox-Form auswählen",
"selectShapeSubtitle": "Wähle zuerst die Grundfläche deiner Growbox aus",
"selectProductTitle": "2. Growbox Produkt auswählen",
"selectProductSubtitle": "Wähle das passende Produkt für deine {{shape}} Growbox",
"selectLightingTitle": "3. Beleuchtung wählen",
"selectLightingTitleShape": "3. Beleuchtung wählen - {{shape}}",
"selectLightingSubtitle": "Bitte wählen Sie zuerst eine Zeltgröße aus.",
"selectVentilationTitle": "4. Belüftung auswählen",
"selectVentilationTitleShape": "4. Belüftung auswählen - {{shape}}",
"selectVentilationSubtitle": "Bitte wählen Sie zuerst eine Zeltgröße aus.",
"selectExtrasTitle": "5. Extras hinzufügen (optional)",
"yourConfiguration": "🎯 Ihre Konfiguration",
"growboxLabel": "Growbox: {{name}}",
"lightingLabel": "Beleuchtung: {{name}}",
"ventilationLabel": "Belüftung: {{name}}",
"extraLabel": "Extra: {{name}}",
"totalPrice": "Gesamtpreis:",
"addToCart": "In den Warenkorb",
"selected": "✓ Ausgewählt",
"notDeliverable": "Nicht lieferbar",
"noPrice": "Kein Preis",
"setName": "Growbox Set - {{shape}}",
"description60x60": "Kompakt - ideal für kleine Räume",
"description80x80": "Mittel - perfekte Balance",
"description100x100": "Groß - für erfahrene Grower",
"description120x60": "Rechteckig - maximale Raumnutzung",
"plants1to2": "1-2 Pflanzen",
"plants2to4": "2-4 Pflanzen",
"plants4to6": "4-6 Pflanzen",
"plants3to6": "3-6 Pflanzen"
};

Some files were not shown because too many files have changed in this diff Show More