Compare commits

...

39 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
263 changed files with 8643 additions and 1627 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 /node_modules
/.pnp
.pnp.js
.cursor/
# testing
/coverage
# production
/build
/dist /dist
/public/index.prerender.html /logs
/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/

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": { "node_modules/caniuse-lite": {
"version": "1.0.30001727", "version": "1.0.30001757",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz",
"integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", "integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {

View File

@@ -7,7 +7,7 @@
"start": "cross-env NODE_OPTIONS=\"--no-deprecation\" webpack serve --progress --mode development --no-open", "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", "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", "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", "build": "npm run build:client",
"analyze": "cross-env ANALYZE=true NODE_ENV=production webpack --progress --mode production", "analyze": "cross-env ANALYZE=true NODE_ENV=production webpack --progress --mode production",
"lint": "eslint src/**/*.{js,jsx}", "lint": "eslint src/**/*.{js,jsx}",

View File

@@ -136,6 +136,7 @@ const {
generateLlmsTxt, generateLlmsTxt,
generateCategoryLlmsTxt, generateCategoryLlmsTxt,
generateAllCategoryLlmsPages, generateAllCategoryLlmsPages,
generateCategoryProductList,
} = require("./prerender/seo.cjs"); } = require("./prerender/seo.cjs");
const { const {
fetchCategoryProducts, fetchCategoryProducts,
@@ -158,6 +159,7 @@ const Batteriegesetzhinweise =
const Widerrufsrecht = require("./src/pages/Widerrufsrecht.js").default; const Widerrufsrecht = require("./src/pages/Widerrufsrecht.js").default;
const Sitemap = require("./src/pages/Sitemap.js").default; const Sitemap = require("./src/pages/Sitemap.js").default;
const PrerenderSitemap = require("./src/PrerenderSitemap.js").default; const PrerenderSitemap = require("./src/PrerenderSitemap.js").default;
const PrerenderCategoriesPage = require("./src/PrerenderCategoriesPage.js").default;
const AGB = require("./src/pages/AGB.js").default; const AGB = require("./src/pages/AGB.js").default;
const NotFound404 = require("./src/pages/NotFound404.js").default; const NotFound404 = require("./src/pages/NotFound404.js").default;
@@ -421,6 +423,14 @@ const renderApp = async (categoryData, socket) => {
process.exit(1); 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 // Render static pages
console.log("\n📄 Rendering static pages..."); console.log("\n📄 Rendering static pages...");
@@ -456,6 +466,13 @@ const renderApp = async (categoryData, socket) => {
description: "Sitemap page", description: "Sitemap page",
needsCategoryData: true, needsCategoryData: true,
}, },
{
component: PrerenderCategoriesPage,
path: "/Kategorien",
filename: "Kategorien",
description: "Categories page",
needsCategoryData: true,
},
{ component: AGB, path: "/agb", filename: "agb", description: "AGB page" }, { component: AGB, path: "/agb", filename: "agb", description: "AGB page" },
{ component: NotFound404, path: "/404", filename: "404", description: "404 Not Found page" }, { component: NotFound404, path: "/404", filename: "404", description: "404 Not Found page" },
{ {
@@ -550,8 +567,7 @@ const renderApp = async (categoryData, socket) => {
try { try {
productData = await fetchCategoryProducts(socket, category.id); productData = await fetchCategoryProducts(socket, category.id);
console.log( console.log(
` ✅ Found ${ ` ✅ Found ${productData.products ? productData.products.length : 0
productData.products ? productData.products.length : 0
} products` } products`
); );
@@ -795,10 +811,16 @@ const renderApp = async (categoryData, socket) => {
totalPaginatedFiles++; 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 pageCount = categoryPages.length;
const totalSize = categoryPages.reduce((sum, page) => sum + page.content.length, 0); 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(` ✅ 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++; categoryFilesGenerated++;
totalCategoryProducts += categoryProducts.length; totalCategoryProducts += categoryProducts.length;
@@ -834,7 +856,7 @@ const fetchCategoryDataAndRender = () => {
const socket = io(socketUrl, { const socket = io(socketUrl, {
path: "/socket.io/", path: "/socket.io/",
transports: [ "websocket"], transports: ["websocket"],
reconnection: false, reconnection: false,
timeout: 10000, timeout: 10000,
}); });

View File

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

View File

@@ -247,10 +247,6 @@ const renderPage = (
if (!suppressLogs) { if (!suppressLogs) {
console.log(`${description} prerendered to ${outputPath}`); console.log(`${description} prerendered to ${outputPath}`);
console.log(` - Markup length: ${renderedMarkup.length} characters`); 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) { if (productDetailCacheScript) {
console.log(` - Product detail cache populated for SPA hydration`); console.log(` - Product detail cache populated for SPA hydration`);
} }

View File

@@ -1,6 +1,19 @@
const generateCategoryJsonLd = (category, products = [], baseUrl, config) => { 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}`; 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 = { const jsonLd = {
"@context": "https://schema.org/", "@context": "https://schema.org/",
"@type": "CollectionPage", "@type": "CollectionPage",
@@ -42,7 +55,7 @@ const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
product.pictureList && product.pictureList.trim() product.pictureList && product.pictureList.trim()
? `${baseUrl}/assets/images/prod${product.pictureList ? `${baseUrl}/assets/images/prod${product.pictureList
.split(",")[0] .split(",")[0]
.trim()}.jpg` .trim()}.avif`
: `${baseUrl}/assets/images/nopicture.jpg`, : `${baseUrl}/assets/images/nopicture.jpg`,
description: product.description description: product.description
? product.description.replace(/<[^>]*>/g, "").substring(0, 200) ? product.description.replace(/<[^>]*>/g, "").substring(0, 200)
@@ -57,6 +70,7 @@ const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
url: `${baseUrl}/Artikel/${product.seoName}`, url: `${baseUrl}/Artikel/${product.seoName}`,
price: product.price && !isNaN(product.price) ? product.price.toString() : "0.00", price: product.price && !isNaN(product.price) ? product.price.toString() : "0.00",
priceCurrency: config.currency, priceCurrency: config.currency,
priceValidUntil: priceValidUntil,
availability: product.available availability: product.available
? "https://schema.org/InStock" ? "https://schema.org/InStock"
: "https://schema.org/OutOfStock", : "https://schema.org/OutOfStock",
@@ -65,6 +79,41 @@ const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
name: config.brandName, name: config.brandName,
}, },
itemCondition: "https://schema.org/NewCondition", 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) 689: "543561", // Seeds (Saatgut)
706: "543561", // Stecklinge (cuttings) ebenfalls Pflanzen/Saatgut 706: "543561", // Stecklinge (cuttings) ebenfalls Pflanzen/Saatgut
376: "2802", // Grow-Sets Pflanzen- & Kräuteranbausets 376: "2802", // Grow-Sets Pflanzen- & Kräuteranbausets
915: "2802", // Grow-Sets > Set-Zubehör Pflanzen- & Kräuteranbausets
// Headshop & Accessories // Headshop & Accessories
709: "4082", // Headshop Rauchzubehör 709: "4082", // Headshop Rauchzubehör
@@ -129,8 +130,13 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
714: "4082", // Headshop > Bongs > Zubehör Rauchzubehör 714: "4082", // Headshop > Bongs > Zubehör Rauchzubehör
748: "4082", // Headshop > Bongs > Köpfe Rauchzubehör 748: "4082", // Headshop > Bongs > Köpfe Rauchzubehör
749: "4082", // Headshop > Bongs > Chillums/Diffusoren/Kupplungen 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 896: "3151", // Headshop > Vaporizer Vaporizer
923: "4082", // Headshop > Papes & Blunts Rauchzubehör
710: "5109", // Headshop > Grinder Gewürzmühlen (Küchenhelfer) 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 // Measuring & Packaging
186: "5631", // Headshop > Wiegen & Verpacken Aufbewahrung/Zubehör 186: "5631", // Headshop > Wiegen & Verpacken Aufbewahrung/Zubehör
@@ -140,6 +146,7 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
407: "3561", // Headshop > Grove Bags Aufbewahrungsbehälter 407: "3561", // Headshop > Grove Bags Aufbewahrungsbehälter
449: "1496", // Headshop > Cliptütchen Lebensmittelverpackungsmaterial 449: "1496", // Headshop > Cliptütchen Lebensmittelverpackungsmaterial
539: "3110", // Headshop > Gläser & Dosen Lebensmittelbehälter 539: "3110", // Headshop > Gläser & Dosen Lebensmittelbehälter
920: "581", // Headshop > Räucherstäbchen Raumdüfte (Home Fragrances)
// Lighting & Equipment // Lighting & Equipment
694: "3006", // Lampen Lampen (Beleuchtung) 694: "3006", // Lampen Lampen (Beleuchtung)
@@ -248,9 +255,9 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
let productsXml = `<?xml version="1.0" encoding="UTF-8"?> let productsXml = `<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:g="http://base.google.com/ns/1.0" version="2.0"> <rss xmlns:g="http://base.google.com/ns/1.0" version="2.0">
<channel> <channel>
<title>${config.descriptions.short}</title> <title>${config.descriptions.de.short}</title>
<link>${baseUrl}</link> <link>${baseUrl}</link>
<description>${config.descriptions.short}</description> <description>${config.descriptions.de.short}</description>
<lastBuildDate>${currentDate}</lastBuildDate> <lastBuildDate>${currentDate}</lastBuildDate>
<language>de-DE</language>`; <language>de-DE</language>`;
@@ -299,19 +306,43 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
let processedCount = 0; let processedCount = 0;
let skippedCount = 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 productsNeedingWeight = [];
const productsNeedingDescription = []; const productsNeedingDescription = [];
// Category IDs to skip (seeds, plants, headshop items) // 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 // Add each product as an item
allProductsData.forEach((product, index) => { allProductsData.forEach((product, index) => {
try { try {
// Skip products without essential data // Skip products without essential data
if (!product || !product.seoName) { if (!product || !product.seoName) {
skippedCount++; 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; return;
} }
@@ -319,12 +350,21 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
const productCategoryId = product.categoryId || product.category_id || product.category || null; const productCategoryId = product.categoryId || product.category_id || product.category || null;
if (productCategoryId && skipCategoryIds.includes(parseInt(productCategoryId))) { if (productCategoryId && skipCategoryIds.includes(parseInt(productCategoryId))) {
skippedCount++; 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; return;
} }
// Skip products with excluded terms in title or description // Skip products with excluded terms in title or description
const productTitle = (product.name || "").toLowerCase(); 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 = { const excludedTerms = {
title: ['canna', 'hash', 'marijuana', 'marihuana'], title: ['canna', 'hash', 'marijuana', 'marihuana'],
@@ -332,20 +372,42 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
}; };
// Check title for excluded terms // 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++; 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; return;
} }
// Check description for excluded terms // 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++; 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; return;
} }
// Skip products without GTIN or with invalid GTIN // Skip products without GTIN or with invalid GTIN
if (!product.gtin || !product.gtin.toString().trim()) { if (!product.gtin || !product.gtin.toString().trim()) {
skippedCount++; skippedCount++;
skipReasons.missingGTIN.count++;
skipReasons.missingGTIN.products.push({
id: product.articleNumber || product.seoName,
name: product.name || 'N/A',
url: `/Artikel/${product.seoName}`
});
return; return;
} }
@@ -360,15 +422,33 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
const length = digits.length; const length = digits.length;
let sum = 0; let sum = 0;
for (let i = 0; i < length - 1; i++) { if (length === 8) {
// Even/odd multiplier depends on GTIN length // EAN-8: positions 0-6, check digit at 7
let multiplier = 1; // Multipliers: 3,1,3,1,3,1,3 for positions 0-6
if (length === 8) { for (let i = 0; i < 7; i++) {
multiplier = (i % 2 === 0) ? 3 : 1; const multiplier = (i % 2 === 0) ? 3 : 1;
} else { sum += digits[i] * multiplier;
multiplier = ((length - i) % 2 === 0) ? 3 : 1; }
} 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; const checkDigit = (10 - (sum % 10)) % 10;
return checkDigit === digits[length - 1]; return checkDigit === digits[length - 1];
@@ -376,43 +456,62 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
if (!isValidGTIN(gtinString)) { if (!isValidGTIN(gtinString)) {
skippedCount++; 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; return;
} }
// Skip products without pictures // Skip products without pictures
if (!product.pictureList || !product.pictureList.trim()) { if (!product.pictureList || !product.pictureList.trim()) {
skippedCount++; skippedCount++;
skipReasons.missingPicture.count++;
skipReasons.missingPicture.products.push({
id: product.articleNumber || product.seoName,
name: product.name || 'N/A',
url: `/Artikel/${product.seoName}`
});
return; return;
} }
// Check if product has weight data - validate BEFORE building XML // Check if product has weight data - validate BEFORE building XML
if (!product.weight || isNaN(product.weight)) { if (!product.weight || isNaN(product.weight)) {
// Track products without weight // Track products without weight
productsNeedingWeight.push({ const productInfo = {
id: product.articleNumber || product.seoName, id: product.articleNumber || product.seoName,
name: product.name || 'Unnamed', name: product.name || 'Unnamed',
url: `/Artikel/${product.seoName}` url: `/Artikel/${product.seoName}`
}); };
productsNeedingWeight.push(productInfo);
skipReasons.missingWeight.count++;
skipReasons.missingWeight.products.push(productInfo);
skippedCount++; skippedCount++;
return; return;
} }
// Check if description is missing or too short (less than 20 characters) - skip if insufficient // 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) { if (!originalDescription || originalDescription.length < 20) {
productsNeedingDescription.push({ const productInfo = {
id: product.articleNumber || product.seoName, id: product.articleNumber || product.seoName,
name: product.name || 'Unnamed', name: product.name || 'Unnamed',
currentDescription: originalDescription || 'NONE', currentDescription: originalDescription || 'NONE',
url: `/Artikel/${product.seoName}` url: `/Artikel/${product.seoName}`
}); };
productsNeedingDescription.push(productInfo);
skipReasons.insufficientDescription.count++;
skipReasons.insufficientDescription.products.push(productInfo);
skippedCount++; skippedCount++;
return; return;
} }
// Clean description for feed (remove HTML tags and limit length) // Clean description for feed (remove HTML tags and limit length)
const rawDescription = cleanTextContent(product.description).substring(0, 500); const feedDescription = cleanTextContent(productDescription).substring(0, 500);
const cleanDescription = escapeXml(rawDescription) || "Produktbeschreibung nicht verfügbar"; const cleanDescription = escapeXml(feedDescription) || "Produktbeschreibung nicht verfügbar";
// Clean product name // Clean product name
const rawName = product.name || "Unnamed Product"; const rawName = product.name || "Unnamed Product";
@@ -421,6 +520,13 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
// Validate essential fields // Validate essential fields
if (!cleanName || cleanName.length < 2) { if (!cleanName || cleanName.length < 2) {
skippedCount++; skippedCount++;
skipReasons.nameTooShort.count++;
skipReasons.nameTooShort.products.push({
id: product.articleNumber || product.seoName,
name: rawName,
cleanedName: cleanName,
url: `/Artikel/${product.seoName}`
});
return; return;
} }
@@ -429,7 +535,7 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
// Generate image URL // Generate image URL
const imageUrl = product.pictureList && product.pictureList.trim() 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`; : `${baseUrl}/assets/images/nopicture.jpg`;
// Generate brand (manufacturer) // Generate brand (manufacturer)
@@ -445,6 +551,12 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
// Skip products that are out of stock // Skip products that are out of stock
if (!product.available) { if (!product.available) {
skippedCount++; skippedCount++;
skipReasons.outOfStock.count++;
skipReasons.outOfStock.products.push({
id: product.articleNumber || product.seoName,
name: product.name || 'N/A',
url: `/Artikel/${product.seoName}`
});
return; return;
} }
@@ -456,6 +568,13 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
// Skip products with price == 0 // Skip products with price == 0
if (!product.price || parseFloat(product.price) === 0) { if (!product.price || parseFloat(product.price) === 0) {
skippedCount++; 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; return;
} }
@@ -522,6 +641,13 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
} catch (itemError) { } catch (itemError) {
console.log(` ⚠️ Skipped product ${index + 1}: ${itemError.message}`); console.log(` ⚠️ Skipped product ${index + 1}: ${itemError.message}`);
skippedCount++; 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> </channel>
</rss>`; </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 // Write log files for products needing attention
const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
@@ -540,7 +702,56 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
fs.mkdirSync(logsDir, { recursive: true }); 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) { if (productsNeedingWeight.length > 0) {
const weightLogContent = `# Products Missing Weight Data const weightLogContent = `# Products Missing Weight Data
# Generated: ${new Date().toISOString()} # 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`); const weightLogPath = path.join(logsDir, `missing-weight-${timestamp}.log`);
fs.writeFileSync(weightLogPath, weightLogContent, 'utf8'); 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) { if (productsNeedingDescription.length > 0) {
const descLogContent = `# Products With Insufficient Description Data const descLogContent = `# Products With Insufficient Description Data
# Generated: ${new Date().toISOString()} # 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`); const descLogPath = path.join(logsDir, `missing-description-${timestamp}.log`);
fs.writeFileSync(descLogPath, descLogContent, 'utf8'); 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) { if (productsNeedingWeight.length === 0 && productsNeedingDescription.length === 0) {

View File

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

View File

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

View File

@@ -158,7 +158,7 @@ This file contains products ${startIndex + 1}-${endIndex} of ${totalProducts} in
`; `;
for (let i = 1; i <= totalPages; i++) { 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)})
`; `;
} }
@@ -173,10 +173,10 @@ This file contains products ${startIndex + 1}-${endIndex} of ${totalProducts} in
// Clean description for markdown (remove HTML tags and limit length) // Clean description for markdown (remove HTML tags and limit length)
const cleanDescription = product.description const cleanDescription = product.description
? product.description ? product.description
.replace(/<[^>]*>/g, "") .replace(/<[^>]*>/g, "")
.replace(/\n/g, " ") .replace(/\n/g, " ")
.trim() .trim()
.substring(0, 300) .substring(0, 300)
: ""; : "";
const globalIndex = startIndex + index + 1; const globalIndex = startIndex + index + 1;
@@ -254,6 +254,41 @@ This category currently contains no products.
return categoryLlmsTxt; 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 // Helper function to generate all pages for a category
const generateAllCategoryLlmsPages = (category, categoryProducts = [], baseUrl, config, productsPerPage = 50) => { const generateAllCategoryLlmsPages = (category, categoryProducts = [], baseUrl, config, productsPerPage = 50) => {
const totalProducts = categoryProducts.length; const totalProducts = categoryProducts.length;
@@ -280,4 +315,5 @@ module.exports = {
generateLlmsTxt, generateLlmsTxt,
generateCategoryLlmsTxt, generateCategoryLlmsTxt,
generateAllCategoryLlmsPages, generateAllCategoryLlmsPages,
generateCategoryProductList,
}; };

View File

@@ -5,7 +5,7 @@ const generateProductMetaTags = (product, baseUrl, config) => {
product.pictureList && product.pictureList.trim() product.pictureList && product.pictureList.trim()
? `${baseUrl}/assets/images/prod${product.pictureList ? `${baseUrl}/assets/images/prod${product.pictureList
.split(",")[0] .split(",")[0]
.trim()}.jpg` .trim()}.avif`
: `${baseUrl}/assets/images/nopicture.jpg`; : `${baseUrl}/assets/images/nopicture.jpg`;
@@ -68,7 +68,7 @@ const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) =>
product.pictureList && product.pictureList.trim() product.pictureList && product.pictureList.trim()
? `${baseUrl}/assets/images/prod${product.pictureList ? `${baseUrl}/assets/images/prod${product.pictureList
.split(",")[0] .split(",")[0]
.trim()}.jpg` .trim()}.avif`
: `${baseUrl}/assets/images/nopicture.jpg`; : `${baseUrl}/assets/images/nopicture.jpg`;
// Clean description for JSON-LD (remove HTML tags) // Clean description for JSON-LD (remove HTML tags)
@@ -106,6 +106,41 @@ const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) =>
"@type": "Organization", "@type": "Organization",
name: config.brandName, 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) // Add current category (skip root category 209)
if (categoryNode.id !== 209) { if (categoryNode.id !== 209) {
// Extract subcategory IDs from children
const subcategoryIds = categoryNode.children
? categoryNode.children.map(child => child.id)
: [];
categories.push({ categories.push({
id: categoryNode.id, id: categoryNode.id,
name: categoryNode.name, name: categoryNode.name,
seoName: categoryNode.seoName, 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 ScienceIcon from "@mui/icons-material/Science";
import { CarouselProvider } from "./contexts/CarouselContext.js"; 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 config from "./config.js";
import ScrollToTop from "./components/ScrollToTop.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 AGB = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/AGB.js"));
//const NotFound404 = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/NotFound404.js")); <Route path="/404" element={<NotFound404 />} /> //const NotFound404 = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/NotFound404.js")); <Route path="/404" element={<NotFound404 />} />
const Sitemap = lazy(() => import(/* webpackChunkName: "sitemap" */ "./pages/Sitemap.js")); 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 Impressum = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Impressum.js"));
const Batteriegesetzhinweise = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Batteriegesetzhinweise.js")); const Batteriegesetzhinweise = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Batteriegesetzhinweise.js"));
const Widerrufsrecht = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Widerrufsrecht.js")); const Widerrufsrecht = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Widerrufsrecht.js"));
@@ -222,9 +226,10 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
bgcolor: "background.default", bgcolor: "background.default",
}} }}
> >
<TitleUpdater />
<ScrollToTop /> <ScrollToTop />
<Header active categoryId={categoryId} key={authVersion} /> <Header active categoryId={categoryId} key={authVersion} />
<Box sx={{ flexGrow: 1 }}> <Box component="main" sx={{ flexGrow: 1 }}>
<Suspense fallback={ <Suspense fallback={
// Use prerender fallback if available, otherwise show loading spinner // Use prerender fallback if available, otherwise show loading spinner
typeof window !== "undefined" && window.__PRERENDER_FALLBACK__ ? ( typeof window !== "undefined" && window.__PRERENDER_FALLBACK__ ? (
@@ -256,19 +261,19 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
{/* Category page - Render Content in parallel */} {/* Category page - Render Content in parallel */}
<Route <Route
path="/Kategorie/:categoryId" path="/Kategorie/:categoryId"
element={<Content/>} element={<Content />}
/> />
{/* Single product page */} {/* Single product page */}
<Route <Route
path="/Artikel/:seoName" path="/Artikel/:seoName"
element={<ProductDetail/>} element={<ProductDetail />}
/> />
{/* Search page - Render Content in parallel */} {/* Search page - Render Content in parallel */}
<Route path="/search" element={<Content/>} /> <Route path="/search" element={<Content />} />
{/* Profile page */} {/* Profile page */}
<Route path="/profile" element={<ProfilePage/>} /> <Route path="/profile" element={<ProfilePage />} />
{/* Payment success page for Mollie redirects */} {/* Payment success page for Mollie redirects */}
<Route path="/payment/success" element={<PaymentSuccess />} /> <Route path="/payment/success" element={<PaymentSuccess />} />
@@ -276,22 +281,23 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
{/* Reset password page */} {/* Reset password page */}
<Route <Route
path="/resetPassword" path="/resetPassword"
element={<ResetPassword/>} element={<ResetPassword />}
/> />
{/* Admin page */} {/* Admin page */}
<Route path="/admin" element={<AdminPage/>} /> <Route path="/admin" element={<AdminPage />} />
{/* Admin Users page */} {/* Admin Users page */}
<Route path="/admin/users" element={<UsersPage/>} /> <Route path="/admin/users" element={<UsersPage />} />
{/* Admin Server Logs page */} {/* Admin Server Logs page */}
<Route path="/admin/logs" element={<ServerLogsPage/>} /> <Route path="/admin/logs" element={<ServerLogsPage />} />
{/* Legal pages */} {/* Legal pages */}
<Route path="/datenschutz" element={<Datenschutz />} /> <Route path="/datenschutz" element={<Datenschutz />} />
<Route path="/agb" element={<AGB />} /> <Route path="/agb" element={<AGB />} />
<Route path="/sitemap" element={<Sitemap />} /> <Route path="/sitemap" element={<Sitemap />} />
<Route path="/Kategorien" element={<CategoriesPage />} />
<Route path="/impressum" element={<Impressum />} /> <Route path="/impressum" element={<Impressum />} />
<Route <Route
path="/batteriegesetzhinweise" path="/batteriegesetzhinweise"
@@ -300,7 +306,7 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
<Route path="/widerrufsrecht" element={<Widerrufsrecht />} /> <Route path="/widerrufsrecht" element={<Widerrufsrecht />} />
{/* Grow Tent Configurator */} {/* Grow Tent Configurator */}
<Route path="/Konfigurator" element={<GrowTentKonfigurator/>} /> <Route path="/Konfigurator" element={<GrowTentKonfigurator />} />
{/* Separate pages that are truly different */} {/* Separate pages that are truly different */}
<Route path="/presseverleih" element={<PresseverleihPage />} /> <Route path="/presseverleih" element={<PresseverleihPage />} />
@@ -450,12 +456,16 @@ const App = () => {
return ( return (
<LanguageProvider i18n={i18n}> <LanguageProvider i18n={i18n}>
<ThemeProvider theme={dynamicTheme}> <ThemeProvider theme={dynamicTheme}>
<CssBaseline /> <ProductContextProvider>
<AppContent <CategoryContextProvider>
currentTheme={currentTheme} <CssBaseline />
dynamicTheme={dynamicTheme} <AppContent
onThemeChange={handleThemeChange} currentTheme={currentTheme}
/> dynamicTheme={dynamicTheme}
onThemeChange={handleThemeChange}
/>
</CategoryContextProvider>
</ProductContextProvider>
</ThemeProvider> </ThemeProvider>
</LanguageProvider> </LanguageProvider>
); );

View File

@@ -44,7 +44,7 @@ const PrerenderAppContent = (socket) => (
<CategoryList categoryId={209} activeCategoryId={null} socket={socket}/> <CategoryList categoryId={209} activeCategoryId={null} socket={socket}/>
</AppBar> </AppBar>
<Box sx={{ flexGrow: 1 }}> <Box component="main" sx={{ flexGrow: 1 }}>
<CarouselProvider> <CarouselProvider>
<Routes> <Routes>
<Route path="/" element={<MainPageLayout />} /> <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" component="img"
height="200" height="200"
image={product.pictureList && product.pictureList.trim() 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' : '/assets/images/nopicture.jpg'
} }
alt={product.name} alt={product.name}

View File

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

View File

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

View File

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

View File

@@ -23,7 +23,7 @@ class CartItem extends Component {
window.socketManager.emit('getPic', { bildId:picid, size:'tiny' }, (res) => { window.socketManager.emit('getPic', { bildId:picid, size:'tiny' }, (res) => {
if(res.success){ 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}); this.setState({image: window.tinyPicCache[picid], loading: false});
} }
}) })

View File

@@ -47,7 +47,7 @@ const CategoryBox = ({
// Create fresh blob URL from cached binary data // Create fresh blob URL from cached binary data
try { try {
const uint8Array = new Uint8Array(cachedImageData); 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); objectUrl = URL.createObjectURL(blob);
setImageUrl(objectUrl); setImageUrl(objectUrl);
setImageError(false); setImageError(false);
@@ -73,7 +73,7 @@ const CategoryBox = ({
try { try {
// Convert binary data to blob URL // Convert binary data to blob URL
const uint8Array = new Uint8Array(imageData); 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); objectUrl = URL.createObjectURL(blob);
setImageUrl(objectUrl); setImageUrl(objectUrl);
setImageError(false); setImageError(false);
@@ -158,7 +158,7 @@ const CategoryBox = ({
position: 'relative', position: 'relative',
backgroundImage: ((typeof window !== 'undefined' && window.__PRERENDER_FALLBACK__) || backgroundImage: ((typeof window !== 'undefined' && window.__PRERENDER_FALLBACK__) ||
(typeof global !== 'undefined' && global.window && global.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'), : (imageUrl && !imageError ? `url("${imageUrl}")` : 'none'),
backgroundSize: 'cover', backgroundSize: 'cover',
backgroundPosition: 'center', backgroundPosition: 'center',

View File

@@ -14,6 +14,7 @@ import CategoryBox from './CategoryBox.js';
import { useParams, useSearchParams } from 'react-router-dom'; import { useParams, useSearchParams } from 'react-router-dom';
import { getAllSettingsWithPrefix } from '../utils/sessionStorage.js'; import { getAllSettingsWithPrefix } from '../utils/sessionStorage.js';
import { withI18n } from '../i18n/withTranslation.js'; import { withI18n } from '../i18n/withTranslation.js';
import { withCategory } from '../context/CategoryContext.js';
const isNew = (neu) => neu && (new Date().getTime() - new Date(neu).getTime() < 30 * 24 * 60 * 60 * 1000); const isNew = (neu) => neu && (new Date().getTime() - new Date(neu).getTime() < 30 * 24 * 60 * 60 * 1000);
@@ -81,7 +82,7 @@ function getFilteredProducts(unfilteredProducts, attributes, t) {
const uniqueAttributes = [...new Set((attributes || []).map(attr => attr.kMerkmalWert ? attr.kMerkmalWert.toString() : ''))]; const uniqueAttributes = [...new Set((attributes || []).map(attr => attr.kMerkmalWert ? attr.kMerkmalWert.toString() : ''))];
const uniqueManufacturers = [...new Set((unfilteredProducts || []).filter(product => product.manufacturerId).map(product => product.manufacturerId ? product.manufacturerId.toString() : ''))]; const uniqueManufacturers = [...new Set((unfilteredProducts || []).filter(product => product.manufacturerId).map(product => product.manufacturerId ? product.manufacturerId.toString() : ''))];
const uniqueManufacturersWithName = [...new Set((unfilteredProducts || []).filter(product => product.manufacturerId).map(product => ({id:product.manufacturerId ? product.manufacturerId.toString() : '',value:product.manufacturer})))]; const uniqueManufacturersWithName = [...new Set((unfilteredProducts || []).filter(product => product.manufacturerId).map(product => ({ id: product.manufacturerId ? product.manufacturerId.toString() : '', value: product.manufacturer })))];
const activeAttributeFilters = attributeFilters.filter(filter => uniqueAttributes.includes(filter)); const activeAttributeFilters = attributeFilters.filter(filter => uniqueAttributes.includes(filter));
const activeManufacturerFilters = manufacturerFilters.filter(filter => uniqueManufacturers.includes(filter)); const activeManufacturerFilters = manufacturerFilters.filter(filter => uniqueManufacturers.includes(filter));
const attributeFiltersByGroup = {}; const attributeFiltersByGroup = {};
@@ -97,7 +98,7 @@ function getFilteredProducts(unfilteredProducts, attributes, t) {
let filteredProducts = (unfilteredProducts || []).filter(product => { let filteredProducts = (unfilteredProducts || []).filter(product => {
const availabilityFilter = sessionStorage.getItem('filter_availability'); const availabilityFilter = sessionStorage.getItem('filter_availability');
let inStockMatch = availabilityFilter == 1 ? true : (product.available>0); let inStockMatch = availabilityFilter == 1 ? true : (product.available > 0);
// Check if there are any new products in the entire set // Check if there are any new products in the entire set
const hasNewProducts = (unfilteredProducts || []).some(product => isNew(product.neu)); const hasNewProducts = (unfilteredProducts || []).some(product => isNew(product.neu));
@@ -106,8 +107,8 @@ function getFilteredProducts(unfilteredProducts, attributes, t) {
const isNewMatch = availabilityFilters.includes('2') && hasNewProducts ? isNew(product.neu) : true; const isNewMatch = availabilityFilters.includes('2') && hasNewProducts ? isNew(product.neu) : true;
let soonMatch = availabilityFilters.includes('3') ? !product.available && product.incoming : true; let soonMatch = availabilityFilters.includes('3') ? !product.available && product.incoming : true;
const soon2Match = (availabilityFilter != 1)&&availabilityFilters.includes('3') ? (product.available) || (!product.available && product.incoming) : true; const soon2Match = (availabilityFilter != 1) && availabilityFilters.includes('3') ? (product.available) || (!product.available && product.incoming) : true;
if( (availabilityFilter != 1)&&availabilityFilters.includes('3') && ((product.available) || (!product.available && product.incoming))){ if ((availabilityFilter != 1) && availabilityFilters.includes('3') && ((product.available) || (!product.available && product.incoming))) {
inStockMatch = true; inStockMatch = true;
soonMatch = true; soonMatch = true;
console.log("soon2Match", product.cName); console.log("soon2Match", product.cName);
@@ -133,11 +134,11 @@ function getFilteredProducts(unfilteredProducts, attributes, t) {
const activeAttributeFiltersWithNames = activeAttributeFilters.map(filter => { const activeAttributeFiltersWithNames = activeAttributeFilters.map(filter => {
const attribute = attributes.find(attr => attr.kMerkmalWert.toString() === filter); const attribute = attributes.find(attr => attr.kMerkmalWert.toString() === filter);
return {name: attribute.cName, value: attribute.cWert, id: attribute.kMerkmalWert}; return { name: attribute.cName, value: attribute.cWert, id: attribute.kMerkmalWert };
}); });
const activeManufacturerFiltersWithNames = activeManufacturerFilters.map(filter => { const activeManufacturerFiltersWithNames = activeManufacturerFilters.map(filter => {
const manufacturer = uniqueManufacturersWithName.find(manufacturer => manufacturer.id === filter); const manufacturer = uniqueManufacturersWithName.find(manufacturer => manufacturer.id === filter);
return {name: manufacturer.value, value: manufacturer.id}; return { name: manufacturer.value, value: manufacturer.id };
}); });
// Extract active availability filters // Extract active availability filters
@@ -150,20 +151,20 @@ function getFilteredProducts(unfilteredProducts, attributes, t) {
// Check for "auf Lager" filter (in stock) - it's active when filter_availability is NOT set to '1' // Check for "auf Lager" filter (in stock) - it's active when filter_availability is NOT set to '1'
if (availabilityFilter !== '1') { if (availabilityFilter !== '1') {
activeAvailabilityFilters.push({id: '1', name: t ? t('product.inStock') : 'auf Lager'}); activeAvailabilityFilters.push({ id: '1', name: t ? t('product.inStock') : 'auf Lager' });
} }
// Check for "Neu" filter (new) - only show if there are actually new products and filter is active // Check for "Neu" filter (new) - only show if there are actually new products and filter is active
if (availabilityFilters.includes('2') && hasNewProducts) { if (availabilityFilters.includes('2') && hasNewProducts) {
activeAvailabilityFilters.push({id: '2', name: t ? t('product.new') : 'Neu'}); activeAvailabilityFilters.push({ id: '2', name: t ? t('product.new') : 'Neu' });
} }
// Check for "Bald verfügbar" filter (coming soon) - only show if there are actually coming soon products and filter is active // Check for "Bald verfügbar" filter (coming soon) - only show if there are actually coming soon products and filter is active
if (availabilityFilters.includes('3') && hasComingSoonProducts) { if (availabilityFilters.includes('3') && hasComingSoonProducts) {
activeAvailabilityFilters.push({id: '3', name: t ? t('product.comingSoon') : 'Bald verfügbar'}); activeAvailabilityFilters.push({ id: '3', name: t ? t('product.comingSoon') : 'Bald verfügbar' });
} }
return {filteredProducts,activeAttributeFilters:activeAttributeFiltersWithNames,activeManufacturerFilters:activeManufacturerFiltersWithNames,activeAvailabilityFilters}; return { filteredProducts, activeAttributeFilters: activeAttributeFiltersWithNames, activeManufacturerFilters: activeManufacturerFiltersWithNames, activeAvailabilityFilters };
} }
function setCachedCategoryData(categoryId, data, language = 'de') { function setCachedCategoryData(categoryId, data, language = 'de') {
if (!window.productCache) { if (!window.productCache) {
@@ -175,7 +176,7 @@ function setCachedCategoryData(categoryId, data, language = 'de') {
try { try {
const cacheKey = `categoryProducts_${categoryId}_${language}`; const cacheKey = `categoryProducts_${categoryId}_${language}`;
if(data.products) for(const product of data.products) { if (data.products) for (const product of data.products) {
const productCacheKey = `product_${product.id}_${language}`; const productCacheKey = `product_${product.id}_${language}`;
window.productDetailCache[productCacheKey] = product; window.productDetailCache[productCacheKey] = product;
} }
@@ -205,11 +206,13 @@ class Content extends Component {
componentDidMount() { componentDidMount() {
const currentLanguage = this.props.i18n?.language || 'de'; const currentLanguage = this.props.i18n?.language || 'de';
if(this.props.params.categoryId) {this.setState({loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: [], lastFetchedLanguage: currentLanguage}, () => { if (this.props.params.categoryId) {
this.setState({ loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: [], lastFetchedLanguage: currentLanguage }, () => {
this.fetchCategoryData(this.props.params.categoryId); this.fetchCategoryData(this.props.params.categoryId);
})} })
}
else if (this.props.searchParams?.get('q')) { else if (this.props.searchParams?.get('q')) {
this.setState({loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: [], lastFetchedLanguage: currentLanguage}, () => { this.setState({ loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: [], lastFetchedLanguage: currentLanguage }, () => {
this.fetchSearchData(this.props.searchParams?.get('q')); this.fetchSearchData(this.props.searchParams?.get('q'));
}) })
} }
@@ -220,15 +223,20 @@ class Content extends Component {
const categoryChanged = this.props.params.categoryId && (prevProps.params.categoryId !== this.props.params.categoryId); const categoryChanged = this.props.params.categoryId && (prevProps.params.categoryId !== this.props.params.categoryId);
const searchChanged = this.props.searchParams?.get('q') && (prevProps.searchParams?.get('q') !== this.props.searchParams?.get('q')); const searchChanged = this.props.searchParams?.get('q') && (prevProps.searchParams?.get('q') !== this.props.searchParams?.get('q'));
if(categoryChanged) { if (categoryChanged) {
window.currentSearchQuery = null; // Clear context for new category loading
this.setState({loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: [], lastFetchedLanguage: currentLanguage}, () => { if (this.props.categoryContext && this.props.categoryContext.setCurrentCategory) {
this.fetchCategoryData(this.props.params.categoryId); this.props.categoryContext.setCurrentCategory(null);
}); }
return; // Don't check language change if category changed
window.currentSearchQuery = null;
this.setState({ loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: [], lastFetchedLanguage: currentLanguage }, () => {
this.fetchCategoryData(this.props.params.categoryId);
});
return; // Don't check language change if category changed
} }
else if (searchChanged) { else if (searchChanged) {
this.setState({loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: [], lastFetchedLanguage: currentLanguage}, () => { this.setState({ loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: [], lastFetchedLanguage: currentLanguage }, () => {
this.fetchSearchData(this.props.searchParams?.get('q')); this.fetchSearchData(this.props.searchParams?.get('q'));
}); });
return; // Don't check language change if search changed return; // Don't check language change if search changed
@@ -247,43 +255,51 @@ class Content extends Component {
hasSearchQuery: !!this.props.searchParams?.get('q') hasSearchQuery: !!this.props.searchParams?.get('q')
}); });
if(languageChanged) { if (languageChanged) {
console.log('Content: Language changed! Re-fetching data...'); console.log('Content: Language changed! Re-fetching data...');
// Re-fetch current data with new language // Re-fetch current data with new language
// Note: Language is now part of the cache key, so it will automatically fetch fresh data // Note: Language is now part of the cache key, so it will automatically fetch fresh data
if(this.props.params.categoryId) { if (this.props.params.categoryId) {
// Re-fetch category data with new language // Re-fetch category data with new language
console.log('Content: Re-fetching category', this.props.params.categoryId); console.log('Content: Re-fetching category', this.props.params.categoryId);
this.setState({loaded: false, lastFetchedLanguage: currentLanguage}, () => { this.setState({ loaded: false, lastFetchedLanguage: currentLanguage }, () => {
this.fetchCategoryData(this.props.params.categoryId); this.fetchCategoryData(this.props.params.categoryId);
}); });
} else if(this.props.searchParams?.get('q')) { } else if (this.props.searchParams?.get('q')) {
// Re-fetch search data with new language // Re-fetch search data with new language
console.log('Content: Re-fetching search', this.props.searchParams?.get('q')); console.log('Content: Re-fetching search', this.props.searchParams?.get('q'));
this.setState({loaded: false, lastFetchedLanguage: currentLanguage}, () => { this.setState({ loaded: false, lastFetchedLanguage: currentLanguage }, () => {
this.fetchSearchData(this.props.searchParams?.get('q')); this.fetchSearchData(this.props.searchParams?.get('q'));
}); });
} else { } else {
// If not viewing category or search, just re-filter existing products // If not viewing category or search, just re-filter existing products
console.log('Content: Just re-filtering existing products'); console.log('Content: Just re-filtering existing products');
this.setState({lastFetchedLanguage: currentLanguage}); this.setState({ lastFetchedLanguage: currentLanguage });
this.filterProducts(); this.filterProducts();
} }
} }
} }
processData(response) { processData(response) {
const unfilteredProducts = response.products; const rawProducts = response.products;
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
if (!window.individualProductCache) { if (!window.individualProductCache) {
window.individualProductCache = {}; window.individualProductCache = {};
} }
//console.log("processData", unfilteredProducts);
if(unfilteredProducts) unfilteredProducts.forEach(product => { const unfilteredProducts = [];
window.individualProductCache[product.id] = {
data: product, //console.log("processData", rawProducts);
if (rawProducts) rawProducts.forEach(product => {
const effectiveProduct = product.translatedProduct || product;
const cacheKey = `${effectiveProduct.id}_${currentLanguage}`;
window.individualProductCache[cacheKey] = {
data: effectiveProduct,
timestamp: Date.now() timestamp: Date.now()
}; };
unfilteredProducts.push(effectiveProduct);
}); });
this.setState({ this.setState({
@@ -299,6 +315,26 @@ class Content extends Component {
attributes: response.attributes, attributes: response.attributes,
childCategories: response.childCategories || [], childCategories: response.childCategories || [],
loaded: true loaded: true
}, () => {
console.log('Content: processData finished', {
hasContext: !!this.props.categoryContext,
categoryName: response.categoryName,
name: response.name
});
if (this.props.categoryContext && this.props.categoryContext.setCurrentCategory) {
if (response.categoryName || response.name) {
console.log('Content: Setting category context');
this.props.categoryContext.setCurrentCategory({
id: this.props.params.categoryId,
name: response.categoryName || response.name
});
} else {
console.log('Content: No category name found to set in context');
}
} else {
console.warn('Content: categoryContext prop is missing!');
}
}); });
} }
@@ -318,7 +354,7 @@ class Content extends Component {
// Track if we've received the full response to ignore stub response if needed // Track if we've received the full response to ignore stub response if needed
let receivedFullResponse = false; let receivedFullResponse = false;
window.socketManager.on(`productList:${categoryId}`,(response) => { window.socketManager.on(`productList:${categoryId}`, (response) => {
console.log("getCategoryProducts full response", response); console.log("getCategoryProducts full response", response);
receivedFullResponse = true; receivedFullResponse = true;
setCachedCategoryData(categoryId, response, currentLanguage); setCachedCategoryData(categoryId, response, currentLanguage);
@@ -376,6 +412,27 @@ class Content extends Component {
childCategories childCategories
}; };
// Attempt to set category name from the tree if missing in response
if (!enhancedResponse.categoryName && !enhancedResponse.name) {
// Try to find name in the tree using the ID or SEO name
try {
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
const categoryTreeCache = window.categoryService.getSync(209, currentLanguage);
if (categoryTreeCache) {
const targetCategory = typeof categoryId === 'string'
? this.findCategoryBySeoName(categoryTreeCache, categoryId)
: this.findCategoryById(categoryTreeCache, categoryId);
if (targetCategory && targetCategory.name) {
enhancedResponse.categoryName = targetCategory.name;
}
}
} catch (err) {
console.error('Error finding category name in tree:', err);
}
}
this.processData(enhancedResponse); this.processData(enhancedResponse);
} }
@@ -403,7 +460,12 @@ class Content extends Component {
{ query, language: currentLanguage, requestTranslation: currentLanguage === 'de' ? false : true }, { query, language: currentLanguage, requestTranslation: currentLanguage === 'de' ? false : true },
(response) => { (response) => {
if (response && response.products) { if (response && response.products) {
this.processData(response); // Map products to use translatedProduct if available
const enhancedResponse = {
...response,
products: response.products.map(p => p.translatedProduct || p)
};
this.processData(enhancedResponse);
} else { } else {
console.log("fetchSearchData in Content failed", response); console.log("fetchSearchData in Content failed", response);
} }
@@ -452,6 +514,12 @@ class Content extends Component {
return category ? category.id : null; return category ? category.id : null;
} }
componentWillUnmount() {
if (this.props.categoryContext && this.props.categoryContext.setCurrentCategory) {
this.props.categoryContext.setCurrentCategory(null);
}
}
renderParentCategoryNavigation = () => { renderParentCategoryNavigation = () => {
const currentCategoryId = this.getCurrentCategoryId(); const currentCategoryId = this.getCurrentCategoryId();
if (!currentCategoryId) return null; if (!currentCategoryId) return null;
@@ -490,10 +558,11 @@ class Content extends Component {
} }
render() { render() {
// console.log('Content props:', this.props);
// Check if we should show category boxes instead of product list // Check if we should show category boxes instead of product list
const showCategoryBoxes = this.state.loaded && const showCategoryBoxes = this.state.loaded &&
this.state.unfilteredProducts.length === 0 && this.state.unfilteredProducts.length === 0 &&
this.state.childCategories.length > 0; this.state.childCategories.length > 0;
console.log("showCategoryBoxes", showCategoryBoxes, this.state.unfilteredProducts.length, this.state.childCategories.length); console.log("showCategoryBoxes", showCategoryBoxes, this.state.unfilteredProducts.length, this.state.childCategories.length);
@@ -511,98 +580,98 @@ class Content extends Component {
<> <>
{/* Show subcategories above main layout when there are both products and child categories */} {/* Show subcategories above main layout when there are both products and child categories */}
{this.state.loaded && {this.state.loaded &&
this.state.unfilteredProducts.length > 0 && this.state.unfilteredProducts.length > 0 &&
this.state.childCategories.length > 0 && ( this.state.childCategories.length > 0 && (
<Box sx={{ mb: 4 }}> <Box sx={{ mb: 4 }}>
{(() => { {(() => {
const parentCategory = this.renderParentCategoryNavigation(); const parentCategory = this.renderParentCategoryNavigation();
if (parentCategory) { if (parentCategory) {
// Show parent category to the left of subcategories // Show parent category to the left of subcategories
return ( return (
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 3, flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 3, flexWrap: 'wrap' }}>
{/* Parent Category Box */} {/* Parent Category Box */}
<Box sx={{ mt:2,position: 'relative', flexShrink: 0 }}> <Box sx={{ mt: 2, position: 'relative', flexShrink: 0 }}>
<CategoryBox <CategoryBox
id={parentCategory.id} id={parentCategory.id}
seoName={parentCategory.seoName} seoName={parentCategory.seoName}
name={parentCategory.name} name={parentCategory.name}
image={parentCategory.image} image={parentCategory.image}
height={130} height={130}
fontSize="1.0rem" fontSize="1.0rem"
/> />
{/* Up Arrow Overlay */} {/* Up Arrow Overlay */}
<Box sx={{ <Box sx={{
position: 'absolute', position: 'absolute',
top: 8, top: 8,
right: 8, right: 8,
bgcolor: 'rgba(27, 94, 32, 0.8)', bgcolor: 'rgba(27, 94, 32, 0.8)',
borderRadius: '50%', borderRadius: '50%',
zIndex: 100, zIndex: 100,
width: 32, width: 32,
height: 32, height: 32,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
pointerEvents: 'none' pointerEvents: 'none'
}}> }}>
<KeyboardArrowUpIcon sx={{ color: 'white', fontSize: '1.2rem' }} /> <KeyboardArrowUpIcon sx={{ color: 'white', fontSize: '1.2rem' }} />
</Box>
</Box>
{/* Subcategories Grid */}
<Box sx={{ flexGrow: 1 }}>
<CategoryBoxGrid categories={this.state.childCategories} />
</Box> </Box>
</Box> </Box>
);
{/* Subcategories Grid */} } else {
<Box sx={{ flexGrow: 1 }}> // No parent category, just show subcategories
<CategoryBoxGrid categories={this.state.childCategories} /> return <CategoryBoxGrid categories={this.state.childCategories} />;
</Box> }
</Box> })()}
); </Box>
} else { )}
// No parent category, just show subcategories
return <CategoryBoxGrid categories={this.state.childCategories} />;
}
})()}
</Box>
)}
{/* Show standalone parent category navigation when there are only products */} {/* Show standalone parent category navigation when there are only products */}
{this.state.loaded && {this.state.loaded &&
this.props.params.categoryId && this.props.params.categoryId &&
!(this.state.unfilteredProducts.length > 0 && this.state.childCategories.length > 0) && (() => { !(this.state.unfilteredProducts.length > 0 && this.state.childCategories.length > 0) && (() => {
const parentCategory = this.renderParentCategoryNavigation(); const parentCategory = this.renderParentCategoryNavigation();
if (parentCategory) { if (parentCategory) {
return ( return (
<Box sx={{ mb: 3 }}> <Box sx={{ mb: 3 }}>
<Box sx={{ position: 'relative', width: 'fit-content' }}> <Box sx={{ position: 'relative', width: 'fit-content' }}>
<CategoryBox <CategoryBox
id={parentCategory.id} id={parentCategory.id}
seoName={parentCategory.seoName} seoName={parentCategory.seoName}
name={parentCategory.name} name={parentCategory.name}
image={parentCategory.image} image={parentCategory.image}
height={130} height={130}
fontSize="1.0rem" fontSize="1.0rem"
/> />
{/* Up Arrow Overlay */} {/* Up Arrow Overlay */}
<Box sx={{ <Box sx={{
position: 'absolute', position: 'absolute',
top: 8, top: 8,
right: 8, right: 8,
bgcolor: 'rgba(27, 94, 32, 0.8)', bgcolor: 'rgba(27, 94, 32, 0.8)',
borderRadius: '50%', borderRadius: '50%',
zIndex: 100, zIndex: 100,
width: 32, width: 32,
height: 32, height: 32,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
pointerEvents: 'none' pointerEvents: 'none'
}}> }}>
<KeyboardArrowUpIcon sx={{ color: 'white', fontSize: '1.2rem' }} /> <KeyboardArrowUpIcon sx={{ color: 'white', fontSize: '1.2rem' }} />
</Box>
</Box> </Box>
</Box> </Box>
</Box> );
); }
} return null;
return null; })()}
})()}
{/* Show normal product list layout */} {/* Show normal product list layout */}
<Box sx={{ <Box sx={{
@@ -611,168 +680,169 @@ class Content extends Component {
gap: { xs: 0, sm: 3 } gap: { xs: 0, sm: 3 }
}}> }}>
<Stack direction="row" spacing={0} sx={{ <Stack direction="row" spacing={0} sx={{
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
minHeight: { xs: 'min-content', sm: '100%' } minHeight: { xs: 'min-content', sm: '100%' }
}}>
<Box >
<ProductFilters
products={this.state.unfilteredProducts}
filteredProducts={this.state.filteredProducts}
attributes={this.state.attributes}
searchParams={this.props.searchParams}
onFilterChange={()=>{this.filterProducts()}}
dataType={this.state.dataType}
dataParam={this.state.dataParam}
/>
</Box>
{(this.props.params.categoryId == 'Stecklinge' || this.props.params.categoryId == 'Seeds') &&
<Box sx={{ display: { xs: 'none', sm: 'block' } }}>
<Typography variant="h6" sx={{mt:3}}>
{this.props.t ? this.props.t('navigation.otherCategories') : 'Andere Kategorien'}
</Typography>
</Box>
}
{this.props.params.categoryId == 'Stecklinge' && <Paper
component={Link}
to="/Kategorie/Seeds"
sx={{
p:0,
mt: 1,
textDecoration: 'none',
color: 'text.primary',
borderRadius: 2,
overflow: 'hidden',
height: 300,
transition: 'all 0.3s ease',
boxShadow: 10,
display: { xs: 'none', sm: 'block' },
'&:hover': {
transform: 'translateY(-5px)',
boxShadow: 20
}
}}
>
{/* Image Container - Place your seeds image here */}
<Box sx={{
height: '100%',
bgcolor: '#e1f0d3',
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<img
src="/assets/images/seeds.jpg"
alt="Seeds"
fetchPriority="high"
loading="eager"
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)'
}}
/>
{/* Overlay text - optional */}
<Box sx={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
bgcolor: 'rgba(27, 94, 32, 0.8)',
p: 2,
}}> }}>
<Typography sx={{ fontSize: '1.3rem', color: 'white', fontFamily: 'SwashingtonCP' }}>
{this.props.t('sections.seeds')} <Box >
</Typography>
<ProductFilters
products={this.state.unfilteredProducts}
filteredProducts={this.state.filteredProducts}
attributes={this.state.attributes}
searchParams={this.props.searchParams}
onFilterChange={() => { this.filterProducts() }}
dataType={this.state.dataType}
dataParam={this.state.dataParam}
categoryName={this.state.categoryName}
/>
</Box>
{(this.props.params.categoryId == 'Stecklinge___' || this.props.params.categoryId == 'Seeds___') &&
<Box sx={{ display: { xs: 'none', sm: 'block' } }}>
<Typography variant="h6" sx={{ mt: 3 }}>
{this.props.t ? this.props.t('navigation.otherCategories') : 'Andere Kategorien'}
</Typography>
</Box>
}
{this.props.params.categoryId == 'Stecklinge' && <Paper
component={Link}
to="/Kategorie/Seeds"
sx={{
p: 0,
mt: 1,
textDecoration: 'none',
color: 'text.primary',
borderRadius: 2,
overflow: 'hidden',
height: 300,
transition: 'all 0.3s ease',
boxShadow: 10,
display: { xs: 'none', sm: 'block' },
'&:hover': {
transform: 'translateY(-5px)',
boxShadow: 20
}
}}
>
{/* Image Container - Place your seeds image here */}
<Box sx={{
height: '100%',
bgcolor: '#e1f0d3',
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<img
src="/assets/images/seeds.avif"
alt="Seeds"
fetchPriority="high"
loading="eager"
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)'
}}
/>
{/* Overlay text - optional */}
<Box sx={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
bgcolor: 'rgba(27, 94, 32, 0.8)',
p: 2,
}}>
<Typography sx={{ fontSize: '1.3rem', color: 'white', fontFamily: 'SwashingtonCP' }}>
{this.props.t('sections.seeds')}
</Typography>
</Box>
</Box>
</Paper>
}
{this.props.params.categoryId == 'Seeds___' && <Paper
component={Link}
to="/Kategorie/Stecklinge"
sx={{
p: 0,
mt: 1,
textDecoration: 'none',
color: 'text.primary',
borderRadius: 2,
overflow: 'hidden',
height: 300,
boxShadow: 10,
transition: 'all 0.3s ease',
display: { xs: 'none', sm: 'block' },
'&:hover': {
transform: 'translateY(-5px)',
boxShadow: 20
}
}}
>
{/* Image Container - Place your cutlings image here */}
<Box sx={{
height: '100%',
bgcolor: '#e8f5d6',
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<img
src="/assets/images/cutlings.avif"
alt="Stecklinge"
fetchPriority="high"
loading="eager"
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)'
}}
/>
{/* Overlay text - optional */}
<Box sx={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
bgcolor: 'rgba(27, 94, 32, 0.8)',
p: 2,
}}>
<Typography sx={{ fontSize: '1.3rem', color: 'white', fontFamily: 'SwashingtonCP' }}>
{this.props.t('sections.stecklinge')}
</Typography>
</Box>
</Box>
</Paper>}
</Stack>
<Box>
<ProductList
totalProductCount={(this.state.unfilteredProducts || []).length}
products={this.state.filteredProducts || []}
activeAttributeFilters={this.state.activeAttributeFilters || []}
activeManufacturerFilters={this.state.activeManufacturerFilters || []}
activeAvailabilityFilters={this.state.activeAvailabilityFilters || []}
onFilterChange={() => { this.filterProducts() }}
dataType={this.state.dataType}
dataParam={this.state.dataParam}
/>
</Box> </Box>
</Box> </Box>
</Paper>
}
{this.props.params.categoryId == 'Seeds' && <Paper
component={Link}
to="/Kategorie/Stecklinge"
sx={{
p: 0,
mt: 1,
textDecoration: 'none',
color: 'text.primary',
borderRadius: 2,
overflow: 'hidden',
height: 300,
boxShadow: 10,
transition: 'all 0.3s ease',
display: { xs: 'none', sm: 'block' },
'&:hover': {
transform: 'translateY(-5px)',
boxShadow: 20
}
}}
>
{/* Image Container - Place your cutlings image here */}
<Box sx={{
height: '100%',
bgcolor: '#e8f5d6',
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<img
src="/assets/images/cutlings.jpg"
alt="Stecklinge"
fetchPriority="high"
loading="eager"
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)'
}}
/>
{/* Overlay text - optional */}
<Box sx={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
bgcolor: 'rgba(27, 94, 32, 0.8)',
p: 2,
}}>
<Typography sx={{ fontSize: '1.3rem', color: 'white', fontFamily: 'SwashingtonCP' }}>
{this.props.t('sections.stecklinge')}
</Typography>
</Box>
</Box>
</Paper>}
</Stack>
<Box>
<ProductList
totalProductCount={(this.state.unfilteredProducts || []).length}
products={this.state.filteredProducts || []}
activeAttributeFilters={this.state.activeAttributeFilters || []}
activeManufacturerFilters={this.state.activeManufacturerFilters || []}
activeAvailabilityFilters={this.state.activeAvailabilityFilters || []}
onFilterChange={()=>{this.filterProducts()}}
dataType={this.state.dataType}
dataParam={this.state.dataParam}
/>
</Box>
</Box>
</> </>
)} )}
</Container> </Container>
@@ -780,4 +850,4 @@ class Content extends Component {
} }
} }
export default withRouter(withI18n()(Content)); export default withRouter(withI18n()(withCategory(Content)));

View File

@@ -296,7 +296,7 @@ class Footer extends Component {
> >
<Box <Box
component="img" component="img"
src="/assets/images/gg.png" src="/assets/images/gg.avif"
alt="Google Reviews" alt="Google Reviews"
sx={{ sx={{
height: { xs: 50, md: 60 }, height: { xs: 50, md: 60 },
@@ -326,7 +326,7 @@ class Footer extends Component {
> >
<Box <Box
component="img" component="img"
src="/assets/images/maps.png" src="/assets/images/maps.avif"
alt="Google Maps" alt="Google Maps"
sx={{ sx={{
height: { xs: 40, md: 50 }, 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 }}> <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> © {new Date().getFullYear()} <StyledDomainLink href="https://growheads.de" target="_blank" rel="noopener noreferrer">GrowHeads.de</StyledDomainLink>
</Typography> </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> </Box>
</Stack> </Stack>
</Box> </Box>

View File

@@ -56,7 +56,7 @@ class Images extends Component {
pics.push(window.tinyPicCache[bildId]); pics.push(window.tinyPicCache[bildId]);
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic); this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
}else{ }else{
pics.push(`/assets/images/prod${bildId}.jpg`); pics.push(`/assets/images/prod${bildId}.avif`);
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic); this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
} }
}else{ }else{
@@ -84,7 +84,7 @@ class Images extends Component {
window.socketManager.emit('getPic', { bildId, size }, (res) => { window.socketManager.emit('getPic', { bildId, size }, (res) => {
if(res.success){ 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 === 'medium') window.mediumPicCache[bildId] = url;
if(size === 'small') window.smallPicCache[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()) { if (!this.props.pictureList || !this.props.pictureList.trim()) {
return '/assets/images/nopicture.jpg'; 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 ( return (

View File

@@ -175,12 +175,12 @@ export class LoginComponent extends Component {
const { location, navigate } = this.props; const { location, navigate } = this.props;
if (!email || !password) { 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; return;
} }
if (!this.validateEmail(email)) { 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; return;
} }
@@ -238,7 +238,7 @@ export class LoginComponent extends Component {
} else { } else {
this.setState({ this.setState({
loading: false, 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; const { email, password, confirmPassword } = this.state;
if (!email || !password || !confirmPassword) { 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; return;
} }
if (!this.validateEmail(email)) { 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; return;
} }
if (password !== confirmPassword) { 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; return;
} }
if (password.length < 8) { 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; return;
} }
@@ -274,14 +274,14 @@ export class LoginComponent extends Component {
if (response.success) { if (response.success) {
this.setState({ this.setState({
loading: false, 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 tabValue: 0 // Switch to login tab
}); });
} else { } else {
let errorMessage = 'Registrierung fehlgeschlagen'; let errorMessage = this.props.t ? this.props.t('auth.errors.registerFailed') : 'Registrierung fehlgeschlagen';
if (response.cause === 'emailExists') { 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) { } else if (response.message) {
errorMessage = response.message; errorMessage = response.message;
} }
@@ -322,12 +322,12 @@ export class LoginComponent extends Component {
const { email } = this.state; const { email } = this.state;
if (!email) { 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; return;
} }
if (!this.validateEmail(email)) { 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; return;
} }
@@ -342,12 +342,12 @@ export class LoginComponent extends Component {
if (response.success) { if (response.success) {
this.setState({ this.setState({
loading: false, 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 { } else {
this.setState({ this.setState({
loading: false, 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 { } else {
this.setState({ this.setState({
loading: false, 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 showGoogleAuth: false // Reset Google auth state on failed login
}); });
} }
@@ -418,7 +418,7 @@ export class LoginComponent extends Component {
handleGoogleLoginError = (error) => { handleGoogleLoginError = (error) => {
console.error('Google Login Error:', error); console.error('Google Login Error:', error);
this.setState({ 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 showGoogleAuth: false, // Reset Google auth state on error
loading: false loading: false
}); });

View File

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

View File

@@ -10,6 +10,7 @@ import {
} from '@mui/material'; } from '@mui/material';
import Delete from '@mui/icons-material/Delete'; import Delete from '@mui/icons-material/Delete';
import CloudUpload from '@mui/icons-material/CloudUpload'; import CloudUpload from '@mui/icons-material/CloudUpload';
import { withI18n } from '../i18n/withTranslation.js';
class PhotoUpload extends Component { class PhotoUpload extends Component {
constructor(props) { constructor(props) {
@@ -30,7 +31,7 @@ class PhotoUpload extends Component {
// Validate file count // Validate file count
if (this.state.files.length + selectedFiles.length > maxFiles) { if (this.state.files.length + selectedFiles.length > maxFiles) {
this.setState({ this.setState({
error: `Maximal ${maxFiles} Dateien erlaubt` error: this.props.t("productDialogs.photoUploadErrorMaxFiles", { max: maxFiles })
}); });
return; return;
} }
@@ -43,14 +44,14 @@ class PhotoUpload extends Component {
for (const file of selectedFiles) { for (const file of selectedFiles) {
if (!validTypes.includes(file.type)) { if (!validTypes.includes(file.type)) {
this.setState({ this.setState({
error: 'Nur Bilddateien (JPEG, PNG, GIF, WebP) sind erlaubt' error: this.props.t("productDialogs.photoUploadErrorFileType")
}); });
continue; continue;
} }
if (file.size > maxSize) { if (file.size > maxSize) {
this.setState({ 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; continue;
} }
@@ -167,12 +168,12 @@ class PhotoUpload extends Component {
render() { render() {
const { files, previews, error } = this.state; const { files, previews, error } = this.state;
const { disabled, label } = this.props; const { disabled, label, t } = this.props;
return ( return (
<Box> <Box>
<Typography variant="body2" sx={{ mb: 1, fontWeight: 500 }}> <Typography variant="body2" sx={{ mb: 1, fontWeight: 500 }}>
{label || 'Fotos anhängen (optional)'} {label || t("productDialogs.photoUploadLabelDefault")}
</Typography> </Typography>
<input <input
@@ -192,7 +193,7 @@ class PhotoUpload extends Component {
disabled={disabled} disabled={disabled}
sx={{ mb: 2 }} sx={{ mb: 2 }}
> >
Fotos auswählen {t("productDialogs.photoUploadSelect")}
</Button> </Button>
{error && ( {error && (
@@ -228,7 +229,7 @@ class PhotoUpload extends Component {
size="small" size="small"
onClick={() => this.handleRemoveFile(index)} onClick={() => this.handleRemoveFile(index)}
disabled={disabled} disabled={disabled}
aria-label="Bild entfernen" aria-label={t("productDialogs.photoUploadRemove")}
sx={{ sx={{
position: 'absolute', position: 'absolute',
top: 4, top: 4,
@@ -269,10 +270,10 @@ class PhotoUpload extends Component {
{files.length > 0 && ( {files.length > 0 && (
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}> <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) && ( {previews.length > 0 && previews.some(p => p.originalSize && p.compressedSize) && (
<span style={{ marginLeft: '8px' }}> <span style={{ marginLeft: '8px' }}>
(komprimiert für Upload) {t("productDialogs.photoUploadCompressed")}
</span> </span>
)} )}
</Typography> </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); console.log('loadImagevisSocket', bildId);
window.socketManager.emit('getPic', { bildId, size:'small' }, (res) => { window.socketManager.emit('getPic', { bildId, size:'small' }, (res) => {
if(res.success){ 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) { if (this._isMounted) {
this.setState({image: window.smallPicCache[bildId], loading: false}); this.setState({image: window.smallPicCache[bildId], loading: false});
} else { } else {

View File

@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { Link } from "react-router-dom";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
@@ -46,7 +47,7 @@ class ProductCarousel extends React.Component {
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
console.log("ProductCarousel componentDidUpdate", prevProps.languageContext?.currentLanguage, this.props.languageContext?.currentLanguage); 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.setState({ products: [] }, () => {
this.loadProducts(this.props.languageContext?.currentLanguage || this.props.i18n.language); this.loadProducts(this.props.languageContext?.currentLanguage || this.props.i18n.language);
}); });
@@ -277,25 +278,41 @@ class ProductCarousel extends React.Component {
const { t, title } = this.props; const { t, title } = this.props;
const { products } = this.state; const { products } = this.state;
if(!products || products.length === 0) { if (!products || products.length === 0) {
return null; return null;
} }
return ( return (
<Box sx={{ mt: 3 }}> <Box sx={{ mt: 3 }}>
<Typography <Box
variant="h4" component={Link}
component="h2" to="/Kategorie/neu"
sx={{ sx={{
mb: 2, display: "flex",
fontFamily: "SwashingtonCP", alignItems: "center",
justifyContent: "center",
textDecoration: "none",
color: "primary.main", color: "primary.main",
textAlign: "center", mb: 2,
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)" 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 <div
className="product-carousel-wrapper" className="product-carousel-wrapper"

View File

@@ -94,6 +94,9 @@ class ProductDetailPage extends Component {
komponentenImages: {}, // Store tiny pictures for komponenten komponentenImages: {}, // Store tiny pictures for komponenten
totalKomponentenPrice: 0, totalKomponentenPrice: 0,
totalSavings: 0, totalSavings: 0,
// Embedded products from <product> tags in description
embeddedProducts: {},
embeddedProductImages: {},
// Collapsible sections state // Collapsible sections state
showQuestionForm: false, showQuestionForm: false,
showRatingForm: false, showRatingForm: false,
@@ -143,6 +146,9 @@ class ProductDetailPage extends Component {
komponentenImages: {}, // Store tiny pictures for komponenten komponentenImages: {}, // Store tiny pictures for komponenten
totalKomponentenPrice: 0, totalKomponentenPrice: 0,
totalSavings: 0, totalSavings: 0,
// Embedded products from <product> tags in description
embeddedProducts: {},
embeddedProductImages: {},
// Collapsible sections state // Collapsible sections state
showQuestionForm: false, showQuestionForm: false,
showRatingForm: false, showRatingForm: false,
@@ -174,6 +180,9 @@ class ProductDetailPage extends Component {
komponentenImages: {}, // Store tiny pictures for komponenten komponentenImages: {}, // Store tiny pictures for komponenten
totalKomponentenPrice: 0, totalKomponentenPrice: 0,
totalSavings: 0, totalSavings: 0,
// Embedded products from <product> tags in description
embeddedProducts: {},
embeddedProductImages: {},
// Collapsible sections state // Collapsible sections state
showQuestionForm: false, showQuestionForm: false,
showRatingForm: false, showRatingForm: false,
@@ -192,6 +201,17 @@ class ProductDetailPage extends Component {
} }
componentDidMount() { 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 // Load product data if we have no product or if we're in upgrading state
if (!this.state.product || this.state.upgrading) { if (!this.state.product || this.state.upgrading) {
this.loadProductData(); this.loadProductData();
@@ -212,6 +232,11 @@ class ProductDetailPage extends Component {
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
// Check for seoName changes // Check for seoName changes
if (prevProps.seoName !== this.props.seoName) { if (prevProps.seoName !== this.props.seoName) {
// Clear context when navigating to new product
if (this.props.setCurrentProduct) {
this.props.setCurrentProduct(null);
}
this.setState( this.setState(
{ product: null, loading: true, upgrading: false, error: null, imageDialogOpen: false, similarProducts: [] }, { product: null, loading: true, upgrading: false, error: null, imageDialogOpen: false, similarProducts: [] },
this.loadProductData this.loadProductData
@@ -280,7 +305,7 @@ class ProductDetailPage extends Component {
window.socketManager.emit('getPic', { bildId, size: 'small' }, (res) => { window.socketManager.emit('getPic', { bildId, size: 'small' }, (res) => {
if (res.success) { if (res.success) {
// Cache the image // 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 // Update state
this.setState(prevState => ({ this.setState(prevState => ({
@@ -521,7 +546,7 @@ class ProductDetailPage extends Component {
console.log("getAttributePicture", res); console.log("getAttributePicture", res);
if (res.success && !res.noPicture) { if (res.success && !res.noPicture) {
const blob = new Blob([res.imageBuffer], { const blob = new Blob([res.imageBuffer], {
type: "image/jpeg", type: "image/avif",
}); });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -574,13 +599,16 @@ class ProductDetailPage extends Component {
const productData = res.translatedProduct || res.product; const productData = res.translatedProduct || res.product;
productData.seoName = this.props.seoName; productData.seoName = this.props.seoName;
// Use translated attributes if available
const attributesData = res.translatedAttributes || res.attributes;
// Initialize cache if it doesn't exist // Initialize cache if it doesn't exist
if (!window.productDetailCache) { if (!window.productDetailCache) {
window.productDetailCache = {}; window.productDetailCache = {};
} }
// Cache the complete response data (product + attributes) - cache the response with translated product // 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; window.productDetailCache[cacheKey] = cacheData;
// Clean up prerender fallback since we now have real data // 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 upgrading: false, // Clear upgrading state since we now have complete data
error: null, error: null,
imageDialogOpen: false, imageDialogOpen: false,
attributes: res.attributes, attributes: attributesData,
komponenten: komponenten, komponenten: komponenten,
komponentenLoaded: komponenten.length === 0, // If no komponenten, mark as loaded komponentenLoaded: komponenten.length === 0, // If no komponenten, mark as loaded
similarProducts: res.similarProducts || [] 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) { if(komponenten.length > 0) {
for(const komponent of komponenten) { for(const komponent of komponenten) {
this.loadKomponent(komponent.id, komponent.count); this.loadKomponent(komponent.id, komponent.count);
@@ -617,7 +656,7 @@ class ProductDetailPage extends Component {
console.log("getProductView", res); console.log("getProductView", res);
// Load attribute images // Load attribute images
this.loadAttributeImages(res.attributes); this.loadAttributeImages(attributesData);
} else { } else {
console.error( console.error(
"Error loading product:", "Error loading product:",
@@ -726,7 +765,7 @@ class ProductDetailPage extends Component {
handleEmbedShare = () => { handleEmbedShare = () => {
const embedCode = `<iframe src="${this.getProductUrl()}" width="100%" height="600" frameborder="0"></iframe>`; const embedCode = `<iframe src="${this.getProductUrl()}" width="100%" height="600" frameborder="0"></iframe>`;
navigator.clipboard.writeText(embedCode).then(() => { 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(() => { }).catch(() => {
// Fallback for older browsers // Fallback for older browsers
try { try {
@@ -736,9 +775,9 @@ class ProductDetailPage extends Component {
textArea.select(); textArea.select();
document.execCommand('copy'); document.execCommand('copy');
document.body.removeChild(textArea); 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 { } 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(); this.handleShareClose();
@@ -746,7 +785,10 @@ class ProductDetailPage extends Component {
handleWhatsAppShare = () => { handleWhatsAppShare = () => {
const url = this.getProductUrl(); 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)}`; const whatsappUrl = `https://wa.me/?text=${encodeURIComponent(text + ' ' + url)}`;
window.open(whatsappUrl, '_blank'); window.open(whatsappUrl, '_blank');
this.handleShareClose(); this.handleShareClose();
@@ -761,7 +803,10 @@ class ProductDetailPage extends Component {
handleTelegramShare = () => { handleTelegramShare = () => {
const url = this.getProductUrl(); 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)}`; const telegramUrl = `https://t.me/share/url?url=${encodeURIComponent(url)}&text=${encodeURIComponent(text)}`;
window.open(telegramUrl, '_blank'); window.open(telegramUrl, '_blank');
this.handleShareClose(); this.handleShareClose();
@@ -769,8 +814,18 @@ class ProductDetailPage extends Component {
handleEmailShare = () => { handleEmailShare = () => {
const url = this.getProductUrl(); const url = this.getProductUrl();
const subject = `Produktempfehlung: ${cleanProductName(this.state.product.name)}`; const productName = 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 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)}`; const emailUrl = `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
window.location.href = emailUrl; window.location.href = emailUrl;
this.handleShareClose(); this.handleShareClose();
@@ -779,7 +834,7 @@ class ProductDetailPage extends Component {
handleLinkCopy = () => { handleLinkCopy = () => {
const url = this.getProductUrl(); const url = this.getProductUrl();
navigator.clipboard.writeText(url).then(() => { 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(() => { }).catch(() => {
// Fallback for older browsers // Fallback for older browsers
try { try {
@@ -789,7 +844,7 @@ class ProductDetailPage extends Component {
textArea.select(); textArea.select();
document.execCommand('copy'); document.execCommand('copy');
document.body.removeChild(textArea); 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 { } catch {
this.showSnackbar("Fehler beim Kopieren des Links", "error"); this.showSnackbar("Fehler beim Kopieren des Links", "error");
} }
@@ -797,7 +852,241 @@ class ProductDetailPage extends Component {
this.handleShareClose(); 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 } = const { product, loading, upgrading, error, attributeImages, isSteckling, attributes, komponentenLoaded, komponentenData, komponentenImages, totalKomponentenPrice, totalSavings, shareAnchorEl, sharePopperOpen, snackbarOpen, snackbarMessage, snackbarSeverity } =
this.state; this.state;
@@ -818,23 +1107,8 @@ class ProductDetailPage extends Component {
); );
} }
// Fallback to loading message if no prerender content // Fallback to blank page if no prerender content
return ( return <div style={{ minHeight: "60vh" }} />;
<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>
);
} }
if (error) { if (error) {
@@ -1022,8 +1296,9 @@ class ProductDetailPage extends Component {
)} )}
{/* Attribute images and chips with action buttons */} {/* 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 }}> <Stack direction="row" spacing={0} sx={{ flexWrap: "wrap", gap: 1, flex: 1 }}>
{attributes {attributes
.filter(attribute => attributeImages[attribute.kMerkmalWert]) .filter(attribute => attributeImages[attribute.kMerkmalWert])
@@ -1059,62 +1334,63 @@ class ProductDetailPage extends Component {
/> />
))} ))}
</Stack> </Stack>
)}
{/* Right-aligned action buttons */} {/* Right-aligned action buttons */}
<Stack direction="column" spacing={1} sx={{ flexShrink: 0 }}> <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 <Button
variant="outlined" variant="outlined"
size="small" size="small"
onClick={this.toggleQuestionForm} onClick={this.toggleAvailabilityForm}
sx={{ sx={{
fontSize: "0.75rem", fontSize: "0.75rem",
px: 1.5, px: 1.5,
py: 0.5, py: 0.5,
minWidth: "auto", 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>
<Button )}
variant="outlined" </Stack>
size="small" </Box>
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>
)}
{/* Weight */} {/* Weight */}
{product.weight > 0 && ( {product.weight > 0 && (
@@ -1338,7 +1614,7 @@ class ProductDetailPage extends Component {
}} }}
size="small" size="small"
> >
Teilen {this.props.t ? this.props.t("productDialogs.shareTitle") : "Teilen"}
</Button> </Button>
<Box <Box
sx={{ sx={{
@@ -1350,16 +1626,30 @@ class ProductDetailPage extends Component {
> >
{product.description ? (() => { {product.description ? (() => {
try { try {
// Sanitize HTML to remove invalid tags, but preserve style attributes // Sanitize HTML to remove invalid tags, but preserve style attributes and <product> tags
return parse(sanitizeHtml(product.description, { const sanitized = sanitizeHtml(product.description, {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']), allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img', 'product']),
allowedAttributes: { allowedAttributes: {
'*': ['class', 'style'], '*': ['class', 'style'],
'a': ['href', 'title'], 'a': ['href', 'title'],
'img': ['src', 'alt', 'width', 'height'] 'img': ['src', 'alt', 'width', 'height'],
'product': ['articlenr']
}, },
disallowedTagsMode: 'discard' 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) { } catch (error) {
console.warn('Failed to parse product description HTML:', error); console.warn('Failed to parse product description HTML:', error);
// Fallback to rendering as plain text if HTML parsing fails // Fallback to rendering as plain text if HTML parsing fails
@@ -1398,7 +1688,7 @@ class ProductDetailPage extends Component {
<ListItemIcon> <ListItemIcon>
<CodeIcon fontSize="small" /> <CodeIcon fontSize="small" />
</ListItemIcon> </ListItemIcon>
<ListItemText primary="Einbetten" /> <ListItemText primary={this.props.t ? this.props.t("productDialogs.shareEmbed") : "Einbetten"} />
</MenuItem> </MenuItem>
<MenuItem onClick={this.handleWhatsAppShare}> <MenuItem onClick={this.handleWhatsAppShare}>
<ListItemIcon> <ListItemIcon>
@@ -1428,7 +1718,7 @@ class ProductDetailPage extends Component {
<ListItemIcon> <ListItemIcon>
<LinkIcon fontSize="small" /> <LinkIcon fontSize="small" />
</ListItemIcon> </ListItemIcon>
<ListItemText primary="Link kopieren" /> <ListItemText primary={this.props.t ? this.props.t("productDialogs.shareCopyLink") : "Link kopieren"} />
</MenuItem> </MenuItem>
</MenuList> </MenuList>
</Box> </Box>
@@ -1682,7 +1972,7 @@ class ProductDetailPage extends Component {
gap: 2 gap: 2
}}> }}>
{this.state.similarProducts.map((similarProductData, index) => { {this.state.similarProducts.map((similarProductData, index) => {
const product = similarProductData.product; const product = similarProductData.translatedProduct || similarProductData.product;
return ( return (
<Box key={product.id} sx={{ display: 'flex', justifyContent: 'center' }}> <Box key={product.id} sx={{ display: 'flex', justifyContent: 'center' }}>
<Product <Product

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { Link } from 'react-router-dom';
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
@@ -60,9 +61,9 @@ class SharedCarousel extends React.Component {
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
console.log("componentDidUpdate", prevProps.languageContext?.currentLanguage, this.props.languageContext?.currentLanguage); 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: [] },() => { this.setState({ categories: [] }, () => {
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); console.log("response", response);
if (response.children && response.children.length > 0) { if (response.children && response.children.length > 0) {
this.originalCategories = response.children; this.originalCategories = response.children;
@@ -268,25 +269,41 @@ class SharedCarousel extends React.Component {
const { t } = this.props; const { t } = this.props;
const { categories } = this.state; const { categories } = this.state;
if(!categories || categories.length === 0) { if (!categories || categories.length === 0) {
return null; return null;
} }
return ( return (
<Box sx={{ mt: 3 }}> <Box sx={{ mt: 3 }}>
<Typography <Box
variant="h4" component={Link}
component="h1" to="/Kategorien"
sx={{ sx={{
mb: 2, display: "flex",
fontFamily: "SwashingtonCP", alignItems: "center",
justifyContent: "center",
textDecoration: "none",
color: "primary.main", color: "primary.main",
textAlign: "center", mb: 2,
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)" 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 <div
className="carousel-wrapper" className="carousel-wrapper"

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); this.loadingImages.add(bildId);
window.socketManager.emit('getPic', { bildId, size:'small' }, (res) => { window.socketManager.emit('getPic', { bildId, size:'small' }, (res) => {
if (res.success) { 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.forceUpdate();
} }
this.loadingImages.delete(bildId); this.loadingImages.delete(bildId);

View File

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

View File

@@ -53,12 +53,12 @@ class CategoryList extends Component {
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
console.log("componentDidUpdate", prevProps.languageContext?.currentLanguage, this.props.languageContext?.currentLanguage); 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({ this.setState({
categories: [], categories: [],
activeCategoryId: null 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); console.log("response", response);
if (response.children && response.children.length > 0) { if (response.children && response.children.length > 0) {
this.setState({ this.setState({
@@ -69,14 +69,14 @@ class CategoryList extends Component {
}); });
}); });
} }
if (prevProps.activeCategoryId !== this.props.activeCategoryId) { if (prevProps.activeCategoryId !== this.props.activeCategoryId) {
this.setLevel1CategoryId(this.props.activeCategoryId); this.setLevel1CategoryId(this.props.activeCategoryId);
} }
} }
setLevel1CategoryId = (input) => { setLevel1CategoryId = (input) => {
if(input) { if (input) {
const language = this.props.languageContext?.currentLanguage || this.props.i18n.language; const language = this.props.languageContext?.currentLanguage || this.props.i18n.language;
const categoryTreeCache = window.categoryService.getSync(209, language); const categoryTreeCache = window.categoryService.getSync(209, language);
@@ -167,152 +167,147 @@ class CategoryList extends Component {
display: "flex", display: "flex",
justifyContent: "flex-start", justifyContent: "flex-start",
alignItems: "center", alignItems: "center",
flexWrap: isMobile ? "wrap" : "nowrap", flexWrap: "wrap",
overflowX: isMobile ? "visible" : "auto", overflowX: "visible",
flexDirection: isMobile ? "column" : "row", flexDirection: isMobile ? "column" : "row",
py: 0.5, // Add vertical padding to prevent border clipping py: 0.5, // Add vertical padding to prevent border clipping
"&::-webkit-scrollbar": {
display: "none",
},
scrollbarWidth: "none",
msOverflowStyle: "none",
}} }}
> >
<Button <Button
component={Link} component={Link}
to="/" to="/"
color="inherit" color="inherit"
size="small" size="small"
aria-label="Zur Startseite" aria-label="Zur Startseite"
onClick={isMobile ? this.handleMobileCategoryClick : undefined} onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{ sx={{
fontSize: "0.75rem", fontSize: "0.75rem",
textTransform: "none", textTransform: "none",
whiteSpace: "nowrap", whiteSpace: "nowrap",
opacity: 0.9, opacity: 0.9,
mx: isMobile ? 0 : 0.5, mx: isMobile ? 0 : 0.5,
my: 0.25, my: 0.25,
minWidth: isMobile ? "100%" : "auto", minWidth: isMobile ? "100%" : "auto",
borderRadius: 1, borderRadius: 1,
justifyContent: isMobile ? "flex-start" : "center", justifyContent: isMobile ? "flex-start" : "center",
transition: "all 0.2s ease", transition: "all 0.2s ease",
textShadow: "0 1px 2px rgba(0,0,0,0.3)", textShadow: "0 1px 2px rgba(0,0,0,0.3)",
position: "relative", position: "relative",
...(activeCategoryId === null && { ...(activeCategoryId === null && {
bgcolor: "#fff", bgcolor: "#fff",
textShadow: "none", textShadow: "none",
opacity: 1, opacity: 1,
}), }),
"&:hover": { "&:hover": {
opacity: 1, opacity: 1,
bgcolor: "#fff", bgcolor: "#fff",
textShadow: "none", textShadow: "none",
"& .MuiSvgIcon-root": { "& .MuiSvgIcon-root": {
color: "#2e7d32 !important", color: "#2e7d32 !important",
},
"& .bold-text": {
color: "#2e7d32 !important",
},
"& .thin-text": {
color: "transparent !important",
},
}, },
}} "& .bold-text": {
> color: "#2e7d32 !important",
<HomeIcon sx={{ },
fontSize: "1rem", "& .thin-text": {
mr: isMobile ? 1 : 0, color: "transparent !important",
color: activeCategoryId === null ? "#2e7d32" : "inherit" },
}} /> },
{isMobile && ( }}
<Box sx={{ position: "relative", display: "inline-block" }}> >
{/* Bold text (always rendered to set width) */} <HomeIcon sx={{
<Box fontSize: "1rem",
className="bold-text" mr: isMobile ? 1 : 0,
sx={{ color: activeCategoryId === null ? "#2e7d32" : "inherit"
fontWeight: "bold", }} />
color: activeCategoryId === null ? "#2e7d32" : "transparent", {isMobile && (
position: "relative", <Box sx={{ position: "relative", display: "inline-block" }}>
zIndex: 2, {/* Bold text (always rendered to set width) */}
}} <Box
> className="bold-text"
{this.props.t ? this.props.t('navigation.home') : 'Startseite'} sx={{
</Box> fontWeight: "bold",
{/* Thin text (positioned on top) */} color: activeCategoryId === null ? "#2e7d32" : "transparent",
<Box position: "relative",
className="thin-text" zIndex: 2,
sx={{ }}
fontWeight: "400", >
color: activeCategoryId === null ? "transparent" : "inherit", {this.props.t ? this.props.t('navigation.home') : 'Startseite'}
position: "absolute",
top: 0,
left: 0,
zIndex: 1,
}}
>
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
</Box>
</Box> </Box>
)} {/* Thin text (positioned on top) */}
</Button> <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 <Button
component={Link} component={Link}
to="/Kategorie/neu" to="/Kategorie/neu"
color="inherit" color="inherit"
size="small" size="small"
aria-label="Neuheiten" aria-label="Neuheiten"
onClick={isMobile ? this.handleMobileCategoryClick : undefined} onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{ sx={{
fontSize: "0.75rem", fontSize: "0.75rem",
textTransform: "none", textTransform: "none",
whiteSpace: "nowrap", whiteSpace: "nowrap",
opacity: 0.9, opacity: 0.9,
mx: isMobile ? 0 : 0.5, mx: isMobile ? 0 : 0.5,
my: 0.25, my: 0.25,
minWidth: isMobile ? "100%" : "auto", minWidth: isMobile ? "100%" : "auto",
borderRadius: 1, borderRadius: 1,
justifyContent: isMobile ? "flex-start" : "center", justifyContent: isMobile ? "flex-start" : "center",
transition: "all 0.2s ease", transition: "all 0.2s ease",
textShadow: "0 1px 2px rgba(0,0,0,0.3)", textShadow: "0 1px 2px rgba(0,0,0,0.3)",
position: "relative" position: "relative"
}} }}
> >
<FiberNewIcon sx={{ <FiberNewIcon sx={{
fontSize: "1rem", fontSize: "1rem",
mr: isMobile ? 1 : 0 mr: isMobile ? 1 : 0
}} /> }} />
{isMobile && ( {isMobile && (
<Box sx={{ position: "relative", display: "inline-block" }}> <Box sx={{ position: "relative", display: "inline-block" }}>
{/* Bold text (always rendered to set width) */} {/* Bold text (always rendered to set width) */}
<Box <Box
className="bold-text" className="bold-text"
sx={{ sx={{
fontWeight: "bold", fontWeight: "bold",
color: "transparent", color: "transparent",
position: "relative", position: "relative",
zIndex: 2, zIndex: 2,
}} }}
> >
{this.props.t ? this.props.t('navigation.new') : 'Neuheiten'} {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>
</Box> </Box>
)} {/* Thin text (positioned on top) */}
</Button> <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 ? ( {categories.length > 0 ? (
@@ -390,100 +385,100 @@ class CategoryList extends Component {
); );
})} })}
</> </>
) : ( !isMobile && ( ) : (!isMobile && (
<Typography <Typography
variant="caption" 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"
color="inherit" color="inherit"
size="small"
aria-label="Zur Startseite"
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{ sx={{
display: "inline-flex",
alignItems: "center",
height: "33px", // Match small button height
px: 1,
fontSize: "0.75rem", fontSize: "0.75rem",
textTransform: "none",
whiteSpace: "nowrap",
opacity: 0.9, 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={{ &nbsp;
fontSize: "1rem", </Typography>
mr: isMobile ? 1 : 0, )
color: activeCategoryId === null ? "#2e7d32" : "inherit" )}
}} /> <Button
{isMobile && ( component={Link}
<Box sx={{ position: "relative", display: "inline-block" }}> to="/Konfigurator"
{/* Bold text (always rendered to set width) */} color="inherit"
<Box size="small"
className="bold-text" aria-label="Zur Startseite"
sx={{ onClick={isMobile ? this.handleMobileCategoryClick : undefined}
fontWeight: "bold", sx={{
color: activeCategoryId === null ? "#2e7d32" : "transparent", fontSize: "0.75rem",
position: "relative", textTransform: "none",
zIndex: 2, whiteSpace: "nowrap",
}} opacity: 0.9,
> mx: isMobile ? 0 : 0.5,
{this.props.t ? this.props.t('navigation.home') : 'Startseite'} my: 0.25,
</Box> minWidth: isMobile ? "100%" : "auto",
{/* Thin text (positioned on top) */} borderRadius: 1,
<Box justifyContent: isMobile ? "flex-start" : "center",
className="thin-text" transition: "all 0.2s ease",
sx={{ textShadow: "0 1px 2px rgba(0,0,0,0.3)",
fontWeight: "400", position: "relative",
color: activeCategoryId === null ? "transparent" : "inherit", ...(activeCategoryId === null && {
position: "absolute", bgcolor: "#fff",
top: 0, textShadow: "none",
left: 0, opacity: 1,
zIndex: 1, }),
}} "&:hover": {
> opacity: 1,
{this.props.t ? this.props.t('navigation.home') : 'Startseite'} bgcolor: "#fff",
</Box> 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> </Box>
)} {/* Thin text (positioned on top) */}
</Button> <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> </Box>
); );
@@ -550,7 +545,7 @@ class CategoryList extends Component {
fontWeight: "bold", fontWeight: "bold",
textShadow: "0 1px 2px rgba(0,0,0,0.3)" 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> </Typography>
<Box sx={{ display: "flex", alignItems: "center" }}> <Box sx={{ display: "flex", alignItems: "center" }}>
{mobileMenuOpen ? <CloseIcon /> : <MenuIcon />} {mobileMenuOpen ? <CloseIcon /> : <MenuIcon />}

View File

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

View File

@@ -79,7 +79,7 @@ const SearchBar = () => {
(response) => { (response) => {
if (response && response.products) { if (response && response.products) {
// getSearchProducts returns response.products array // 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); setSuggestions(suggestions);
setShowSuggestions(suggestions.length > 0); setShowSuggestions(suggestions.length > 0);
setSelectedIndex(-1); // Reset selection setSelectedIndex(-1); // Reset selection

View File

@@ -17,7 +17,7 @@ const DeliveryMethodSelector = ({ deliveryMethod, onChange, isPickupOnly, cartIt
name: 'DHL', name: 'DHL',
description: isPickupOnly ? (t ? t('delivery.descriptions.notAvailable') : "nicht auswählbar weil ein oder mehrere Artikel nur abgeholt werden können") : 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'), 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 disabled: isPickupOnly
}, },
{ {

View File

@@ -31,6 +31,7 @@ const getStatusTranslation = (status, t) => {
new: t ? t('orders.status.new') : "in Bearbeitung", new: t ? t('orders.status.new') : "in Bearbeitung",
pending: t ? t('orders.status.pending') : "Neu", pending: t ? t('orders.status.pending') : "Neu",
processing: t ? t('orders.status.processing') : "in Bearbeitung", processing: t ? t('orders.status.processing') : "in Bearbeitung",
paid: t ? t('orders.status.paid') : "Bezahlt",
cancelled: t ? t('orders.status.cancelled') : "Storniert", cancelled: t ? t('orders.status.cancelled') : "Storniert",
shipped: t ? t('orders.status.shipped') : "Verschickt", shipped: t ? t('orders.status.shipped') : "Verschickt",
delivered: t ? t('orders.status.delivered') : "Geliefert", delivered: t ? t('orders.status.delivered') : "Geliefert",
@@ -39,29 +40,23 @@ const getStatusTranslation = (status, t) => {
}; };
const statusEmojis = { const statusEmojis = {
"in Bearbeitung": "⚙️", new: "⚙️",
pending: "⏳", pending: "⏳",
processing: "🔄", processing: "🔄",
paid: "🏦",
cancelled: "❌", cancelled: "❌",
Verschickt: "🚚", shipped: "🚚",
Geliefert: "✅", delivered: "✅",
Storniert: "❌",
Retoure: "↩️",
"Teil Retoure": "↪️",
"Teil geliefert": "⚡",
}; };
const statusColors = { const statusColors = {
"in Bearbeitung": "#ed6c02", // orange new: "#ed6c02", // orange
pending: "#ff9800", // orange for pending pending: "#ff9800", // orange for pending
processing: "#2196f3", // blue for processing processing: "#2196f3", // blue for processing
paid: "#2e7d32", // green
cancelled: "#d32f2f", // red for cancelled cancelled: "#d32f2f", // red for cancelled
Verschickt: "#2e7d32", // green shipped: "#2e7d32", // green
Geliefert: "#2e7d32", // green delivered: "#2e7d32", // green
Storniert: "#d32f2f", // red
Retoure: "#9c27b0", // purple
"Teil Retoure": "#9c27b0", // purple
"Teil geliefert": "#009688", // teal
}; };
const currencyFormatter = new Intl.NumberFormat("de-DE", { const currencyFormatter = new Intl.NumberFormat("de-DE", {
@@ -229,11 +224,11 @@ const OrdersTab = ({ orderIdFromHash, t }) => {
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: "8px", gap: "8px",
color: getStatusColor(displayStatus), color: getStatusColor(order.status),
}} }}
> >
<span style={{ fontSize: "1.2rem" }}> <span style={{ fontSize: "1.2rem" }}>
{getStatusEmoji(displayStatus)} {getStatusEmoji(order.status)}
</span> </span>
<Typography <Typography
variant="body2" variant="body2"
@@ -243,6 +238,18 @@ const OrdersTab = ({ orderIdFromHash, t }) => {
{displayStatus} {displayStatus}
</Typography> </Typography>
</Box> </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>
<TableCell> <TableCell>
{order.items {order.items

View File

@@ -200,13 +200,13 @@ const config = {
// Shipping // Shipping
shipping: { shipping: {
defaultCost: "4.99 EUR", defaultCost: "5.90 EUR",
defaultService: "Standard" defaultService: "Standard"
}, },
// Images // Images
images: { images: {
logo: "/assets/images/sh.png", logo: "/assets/images/sh.avif",
placeholder: "/assets/images/nopicture.jpg" 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 // @note Dummy data for grow tent configurator - no backend calls
// descriptions now keys for translation
export const tentShapes = [ export const tentShapes = [
{ {
id: '60x60', id: '60x60',
name: '60x60cm', name: '60x60cm',
description: 'Kompakt - ideal für kleine Räume', descriptionKey: 'kitConfig.description60x60',
footprint: '60x60', footprint: '60x60',
minPlants: 1, minPlants: 1,
maxPlants: 2, maxPlants: 2,
@@ -13,7 +14,7 @@ export const tentShapes = [
{ {
id: '80x80', id: '80x80',
name: '80x80cm', name: '80x80cm',
description: 'Mittel - perfekte Balance', descriptionKey: 'kitConfig.description80x80',
footprint: '80x80', footprint: '80x80',
minPlants: 2, minPlants: 2,
maxPlants: 4, maxPlants: 4,
@@ -23,7 +24,7 @@ export const tentShapes = [
{ {
id: '100x100', id: '100x100',
name: '100x100cm', name: '100x100cm',
description: 'Groß - für erfahrene Grower', descriptionKey: 'kitConfig.description100x100',
footprint: '100x100', footprint: '100x100',
minPlants: 4, minPlants: 4,
maxPlants: 6, maxPlants: 6,
@@ -33,7 +34,7 @@ export const tentShapes = [
{ {
id: '120x60', id: '120x60',
name: '120x60cm', name: '120x60cm',
description: 'Rechteckig - maximale Raumnutzung', descriptionKey: 'kitConfig.description120x60',
footprint: '120x60', footprint: '120x60',
minPlants: 3, minPlants: 3,
maxPlants: 6, maxPlants: 6,

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import navigation from './navigation.js';
import auth from './auth.js'; import auth from './auth.js';
import cart from './cart.js'; import cart from './cart.js';
import product from './product.js'; import product from './product.js';
import productDialogs from './productDialogs.js';
import search from './search.js'; import search from './search.js';
import sorting from './sorting.js'; import sorting from './sorting.js';
import chat from './chat.js'; import chat from './chat.js';
@@ -18,6 +19,7 @@ import pages from './pages.js';
import orders from './orders.js'; import orders from './orders.js';
import settings from './settings.js'; import settings from './settings.js';
import common from './common.js'; import common from './common.js';
import kitConfig from './kitConfig.js';
import legalDatenschutzBasic from './legal-datenschutz-basic.js'; import legalDatenschutzBasic from './legal-datenschutz-basic.js';
import legalDatenschutzCustomer from './legal-datenschutz-customer.js'; import legalDatenschutzCustomer from './legal-datenschutz-customer.js';
import legalDatenschutzGoogleOrders from './legal-datenschutz-google-orders.js'; import legalDatenschutzGoogleOrders from './legal-datenschutz-google-orders.js';
@@ -35,6 +37,7 @@ export default {
"auth": auth, "auth": auth,
"cart": cart, "cart": cart,
"product": product, "product": product,
"productDialogs": productDialogs,
"search": search, "search": search,
"sorting": sorting, "sorting": sorting,
"chat": chat, "chat": chat,
@@ -50,6 +53,7 @@ export default {
"orders": orders, "orders": orders,
"settings": settings, "settings": settings,
"common": common, "common": common,
"kitConfig": kitConfig,
"legalDatenschutzBasic": legalDatenschutzBasic, "legalDatenschutzBasic": legalDatenschutzBasic,
"legalDatenschutzCustomer": legalDatenschutzCustomer, "legalDatenschutzCustomer": legalDatenschutzCustomer,
"legalDatenschutzGoogleOrders": legalDatenschutzGoogleOrders, "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": "قيد التنفيذ", "new": "قيد التنفيذ",
"pending": "جديد", "pending": "جديد",
"processing": "قيد التنفيذ", "processing": "قيد التنفيذ",
"cancelled": لغاة", "paid": دفوع",
"cancelled": "ملغي",
"shipped": "تم الشحن", "shipped": "تم الشحن",
"delivered": "تم التوصيل", "delivered": "تم التوصيل",
"return": "إرجاع", "return": "إرجاع",
@@ -24,6 +25,7 @@ export default {
"cancelOrder": "إلغاء الطلب" "cancelOrder": "إلغاء الطلب"
}, },
"noOrders": "لم تقم بوضع أي طلبات بعد.", "noOrders": "لم تقم بوضع أي طلبات بعد.",
"trackShipment": "تتبع الشحنة",
"details": { "details": {
"title": "تفاصيل الطلب: {{orderId}}", "title": "تفاصيل الطلب: {{orderId}}",
"deliveryAddress": "عنوان التوصيل", "deliveryAddress": "عنوان التوصيل",
@@ -36,14 +38,13 @@ export default {
"item": "العنصر", "item": "العنصر",
"quantity": "الكمية", "quantity": "الكمية",
"price": "السعر", "price": "السعر",
"vat": "ضريبة القيمة المضافة",
"total": "الإجمالي", "total": "الإجمالي",
"cancelOrder": "إلغاء الطلب" "cancelOrder": "إلغاء الطلب"
}, },
"cancelConfirm": { "cancelConfirm": {
"title": "إلغاء الطلب", "title": "إلغاء الطلب",
"message": "هل أنت متأكد أنك تريد إلغاء هذا الطلب؟", "message": "هل أنت متأكد أنك تريد إلغاء هذا الطلب؟",
"confirm": "إلغاء الطلب", "confirm": "إلغاء",
"cancelling": "جارٍ الإلغاء..." "cancelling": "جارٍ الإلغاء..."
}, },
"processing": "يتم إكمال الطلب..." "processing": "يتم إكمال الطلب..."

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import navigation from './navigation.js';
import auth from './auth.js'; import auth from './auth.js';
import cart from './cart.js'; import cart from './cart.js';
import product from './product.js'; import product from './product.js';
import productDialogs from './productDialogs.js';
import search from './search.js'; import search from './search.js';
import sorting from './sorting.js'; import sorting from './sorting.js';
import chat from './chat.js'; import chat from './chat.js';
@@ -18,6 +19,7 @@ import pages from './pages.js';
import orders from './orders.js'; import orders from './orders.js';
import settings from './settings.js'; import settings from './settings.js';
import common from './common.js'; import common from './common.js';
import kitConfig from './kitConfig.js';
import legalDatenschutzBasic from './legal-datenschutz-basic.js'; import legalDatenschutzBasic from './legal-datenschutz-basic.js';
import legalDatenschutzCustomer from './legal-datenschutz-customer.js'; import legalDatenschutzCustomer from './legal-datenschutz-customer.js';
import legalDatenschutzGoogleOrders from './legal-datenschutz-google-orders.js'; import legalDatenschutzGoogleOrders from './legal-datenschutz-google-orders.js';
@@ -35,6 +37,7 @@ export default {
"auth": auth, "auth": auth,
"cart": cart, "cart": cart,
"product": product, "product": product,
"productDialogs": productDialogs,
"search": search, "search": search,
"sorting": sorting, "sorting": sorting,
"chat": chat, "chat": chat,
@@ -50,6 +53,7 @@ export default {
"orders": orders, "orders": orders,
"settings": settings, "settings": settings,
"common": common, "common": common,
"kitConfig": kitConfig,
"legalDatenschutzBasic": legalDatenschutzBasic, "legalDatenschutzBasic": legalDatenschutzBasic,
"legalDatenschutzCustomer": legalDatenschutzCustomer, "legalDatenschutzCustomer": legalDatenschutzCustomer,
"legalDatenschutzGoogleOrders": legalDatenschutzGoogleOrders, "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 { export default {
"status": { "status": {
"new": "В процес", "new": "в процес",
"pending": "Нова", "pending": "Ново",
"processing": "В процес", "processing": "в процес",
"cancelled": "Отменена", "paid": "Платено",
"shipped": "Изпратена", "cancelled": "Отменено",
"delivered": "Доставена", "shipped": "Изпратено",
"delivered": "Доставено",
"return": "Връщане", "return": "Връщане",
"partialReturn": "Частично връщане", "partialReturn": "Частично връщане",
"partialDelivered": "Частично доставена" "partialDelivered": "Частично доставено"
}, },
"table": { "table": {
"orderNumber": "Номер на поръчка", "orderNumber": "Номер на поръчка",
@@ -24,6 +25,7 @@ export default {
"cancelOrder": "Отмени поръчката" "cancelOrder": "Отмени поръчката"
}, },
"noOrders": "Все още не сте направили поръчки.", "noOrders": "Все още не сте направили поръчки.",
"trackShipment": "Проследи пратката",
"details": { "details": {
"title": "Подробности за поръчка: {{orderId}}", "title": "Подробности за поръчка: {{orderId}}",
"deliveryAddress": "Адрес за доставка", "deliveryAddress": "Адрес за доставка",
@@ -36,15 +38,14 @@ export default {
"item": "Артикул", "item": "Артикул",
"quantity": "Количество", "quantity": "Количество",
"price": "Цена", "price": "Цена",
"vat": "ДДС",
"total": "Общо", "total": "Общо",
"cancelOrder": "Отмени поръчката" "cancelOrder": "Отмени поръчката"
}, },
"cancelConfirm": { "cancelConfirm": {
"title": "Отмяна на поръчка", "title": "Отмени поръчката",
"message": "Сигурни ли сте, че искате да отмените тази поръчка?", "message": "Сигурни ли сте, че искате да отмените тази поръчка?",
"confirm": "Отмени поръчката", "confirm": "Отмени",
"cancelling": "Отмяна..." "cancelling": "Отмяна..."
}, },
"processing": "Поръчката се обработва...", "processing": "Поръчката се обработва..."
}; };

View File

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

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

View File

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

View File

@@ -5,6 +5,7 @@ export default {
"profile": "Profil", "profile": "Profil",
"email": "Email", "email": "Email",
"password": "Heslo", "password": "Heslo",
"newPassword": "Nové heslo",
"confirmPassword": "Potvrdit heslo", "confirmPassword": "Potvrdit heslo",
"forgotPassword": "Zapomněli jste heslo?", "forgotPassword": "Zapomněli jste heslo?",
"loginWithGoogle": "Přihlásit se přes Google", "loginWithGoogle": "Přihlásit se přes Google",
@@ -13,6 +14,7 @@ export default {
"privacyPolicy": "Zásadami ochrany osobních údajů", "privacyPolicy": "Zásadami ochrany osobních údajů",
"passwordMinLength": "Heslo musí mít alespoň 8 znaků", "passwordMinLength": "Heslo musí mít alespoň 8 znaků",
"newPasswordMinLength": "Nové heslo musí mít alespoň 8 znaků", "newPasswordMinLength": "Nové heslo musí mít alespoň 8 znaků",
"backToHome": "Zpět na domovskou stránku",
"menu": { "menu": {
"profile": "Profil", "profile": "Profil",
"myProfile": "Můj profil", "myProfile": "Můj profil",
@@ -21,5 +23,28 @@ export default {
"settings": "Nastavení", "settings": "Nastavení",
"adminDashboard": "Admin Dashboard", "adminDashboard": "Admin Dashboard",
"adminUsers": "Admin Users" "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

@@ -8,17 +8,17 @@ export default {
}, },
"descriptions": { "descriptions": {
"standard": "Standardní doprava", "standard": "Standardní doprava",
"standardFree": "Standardní doprava - ZDARMA od objednávky nad 100€!", "standardFree": "Standardní doprava - ZDARMA od hodnoty objednávky 100€!",
"notAvailable": "Nelze vybrat, protože jeden nebo více produktů lze pouze vyzvednout", "notAvailable": "Nelze vybrat, protože jeden nebo více položek lze pouze vyzvednout",
"bulky": "Pro velké a těžké předměty", "bulky": "Pro velké a těžké položky",
"pickupOnly": "Pouze osobní odběr" "pickupOnly": "Pouze osobní odběr"
}, },
"prices": { "prices": {
"free": "zdarma", "free": "zdarma",
"freeFrom100": "(zdarma od 100€)", "freeFrom100": "(zdarma od 100€)",
"dhl": "6,99 €", "dhl": "5.90 €",
"dpd": "4,90 €", "dpd": "4.90 €",
"sperrgut": "28,99 €" "sperrgut": "28.99 €"
}, },
"times": { "times": {
"cutting14Days": "Doba dodání: 14 dní", "cutting14Days": "Doba dodání: 14 dní",
@@ -27,9 +27,9 @@ export default {
}, },
"selector": { "selector": {
"title": "Vyberte způsob dopravy", "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.", "remainingForFree": "Přidejte ještě {{amount}}€ pro dopravu zdarma.",
"congratsFreeShipping": "🎉 Gratulujeme! Máte 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 auth from './auth.js';
import cart from './cart.js'; import cart from './cart.js';
import product from './product.js'; import product from './product.js';
import productDialogs from './productDialogs.js';
import search from './search.js'; import search from './search.js';
import sorting from './sorting.js'; import sorting from './sorting.js';
import chat from './chat.js'; import chat from './chat.js';
@@ -18,6 +19,7 @@ import pages from './pages.js';
import orders from './orders.js'; import orders from './orders.js';
import settings from './settings.js'; import settings from './settings.js';
import common from './common.js'; import common from './common.js';
import kitConfig from './kitConfig.js';
import legalDatenschutzBasic from './legal-datenschutz-basic.js'; import legalDatenschutzBasic from './legal-datenschutz-basic.js';
import legalDatenschutzCustomer from './legal-datenschutz-customer.js'; import legalDatenschutzCustomer from './legal-datenschutz-customer.js';
import legalDatenschutzGoogleOrders from './legal-datenschutz-google-orders.js'; import legalDatenschutzGoogleOrders from './legal-datenschutz-google-orders.js';
@@ -35,6 +37,7 @@ export default {
"auth": auth, "auth": auth,
"cart": cart, "cart": cart,
"product": product, "product": product,
"productDialogs": productDialogs,
"search": search, "search": search,
"sorting": sorting, "sorting": sorting,
"chat": chat, "chat": chat,
@@ -50,6 +53,7 @@ export default {
"orders": orders, "orders": orders,
"settings": settings, "settings": settings,
"common": common, "common": common,
"kitConfig": kitConfig,
"legalDatenschutzBasic": legalDatenschutzBasic, "legalDatenschutzBasic": legalDatenschutzBasic,
"legalDatenschutzCustomer": legalDatenschutzCustomer, "legalDatenschutzCustomer": legalDatenschutzCustomer,
"legalDatenschutzGoogleOrders": legalDatenschutzGoogleOrders, "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,8 +1,9 @@
export default { export default {
"status": { "status": {
"new": "Probíhá", "new": "probíhá",
"pending": "Nová", "pending": "Nové",
"processing": "Probíhá", "processing": "probíhá",
"paid": "Zaplaceno",
"cancelled": "Zrušeno", "cancelled": "Zrušeno",
"shipped": "Odesláno", "shipped": "Odesláno",
"delivered": "Doručeno", "delivered": "Doručeno",
@@ -24,6 +25,7 @@ export default {
"cancelOrder": "Zrušit objednávku" "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": { "details": {
"title": "Detaily objednávky: {{orderId}}", "title": "Detaily objednávky: {{orderId}}",
"deliveryAddress": "Dodací adresa", "deliveryAddress": "Dodací adresa",
@@ -36,15 +38,14 @@ export default {
"item": "Položka", "item": "Položka",
"quantity": "Množství", "quantity": "Množství",
"price": "Cena", "price": "Cena",
"vat": "DPH",
"total": "Celkem", "total": "Celkem",
"cancelOrder": "Zrušit objednávku" "cancelOrder": "Zrušit objednávku"
}, },
"cancelConfirm": { "cancelConfirm": {
"title": "Zrušit objednávku", "title": "Zrušit objednávku",
"message": "Opravdu chcete tuto objednávku zrušit?", "message": "Opravdu chcete tuto objednávku zrušit?",
"confirm": "Zrušit objednávku", "confirm": "Zrušit",
"cancelling": "Rušení..." "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", "articleNumber": "Číslo artiklu",
"manufacturer": "Výrobce", "manufacturer": "Výrobce",
"inclVat": "včetně {{vat}}% DPH", "inclVat": "včetně {{vat}}% DPH",
"inclVatSimple": "včetně DPH",
"priceUnit": "{{price}}/{{unit}}", "priceUnit": "{{price}}/{{unit}}",
"new": "Nové", "new": "Nové",
"weeks": "týdnů", "weeks": "Týdny",
"arriving": "Příchod:", "arriving": "Příjezd:",
"inclVatFooter": "včetně {{vat}}% DPH,*", "inclVatFooter": "včetně {{vat}}% DPH,*",
"availability": "Dostupnost", "availability": "Dostupnost",
"inStock": "skladem", "inStock": "skladem",
"comingSoon": "Brzy k dispozici", "comingSoon": "Brzy k dispozici",
"deliveryTime": "Doba dodání", "deliveryTime": "Doba dodání",
"inclShort": "vč.", "inclShort": "včetně",
"vatShort": "DPH", "vatShort": "DPH",
"weight": "Hmotnost: {{weight}} kg", "weight": "Hmotnost: {{weight}} kg",
"youSave": "Ušetříte: {{amount}}", "youSave": "Ušetříte: {{amount}}",
"cheaperThanIndividual": "Levnější než nákup jednotlivě", "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:", "consistsOf": "Skládá se z:",
"loadingComponentDetails": "{{index}}. Načítání detailů komponenty...", "loadingComponentDetails": "{{index}}. Načítání detailů komponenty...",
"loadingProduct": "Načítání produktu...",
"individualPriceTotal": "Celková cena jednotlivě:", "individualPriceTotal": "Celková cena jednotlivě:",
"setPrice": "Cena sady:", "setPrice": "Cena sady:",
"yourSavings": "Vaše úspory:", "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

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

View File

@@ -1,5 +1,5 @@
export default { export default {
"home": "Kvalitní semena a řízky konopí", "home": "Kvalitní semena konopí",
"aktionen": "Aktuální akce a nabídky", "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", "profile": "Profil",
"email": "E-Mail", "email": "E-Mail",
"password": "Passwort", "password": "Passwort",
"newPassword": "Neues Passwort",
"confirmPassword": "Passwort bestätigen", "confirmPassword": "Passwort bestätigen",
"forgotPassword": "Passwort vergessen?", "forgotPassword": "Passwort vergessen?",
"loginWithGoogle": "Mit Google anmelden", "loginWithGoogle": "Mit Google anmelden",
@@ -13,6 +14,7 @@ export default {
"privacyPolicy": "Datenschutzbestimmungen", "privacyPolicy": "Datenschutzbestimmungen",
"passwordMinLength": "Das Passwort muss mindestens 8 Zeichen lang sein", "passwordMinLength": "Das Passwort muss mindestens 8 Zeichen lang sein",
"newPasswordMinLength": "Das neue Passwort muss mindestens 8 Zeichen lang sein", "newPasswordMinLength": "Das neue Passwort muss mindestens 8 Zeichen lang sein",
"backToHome": "Zurück zur Startseite",
"menu": { "menu": {
"profile": "Profil", "profile": "Profil",
"myProfile": "Mein Profil", "myProfile": "Mein Profil",
@@ -21,5 +23,28 @@ export default {
"settings": "Einstellungen", "settings": "Einstellungen",
"adminDashboard": "Admin Dashboard", "adminDashboard": "Admin Dashboard",
"adminUsers": "Admin Users" "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

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

View File

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

View File

@@ -3,6 +3,7 @@ export default {
"new": "in Bearbeitung", "new": "in Bearbeitung",
"pending": "Neu", "pending": "Neu",
"processing": "in Bearbeitung", "processing": "in Bearbeitung",
"paid": "Bezahlt",
"cancelled": "Storniert", "cancelled": "Storniert",
"shipped": "Verschickt", "shipped": "Verschickt",
"delivered": "Geliefert", "delivered": "Geliefert",
@@ -24,6 +25,7 @@ export default {
"cancelOrder": "Bestellung stornieren" "cancelOrder": "Bestellung stornieren"
}, },
"noOrders": "Sie haben noch keine Bestellungen aufgegeben.", "noOrders": "Sie haben noch keine Bestellungen aufgegeben.",
"trackShipment": "Sendung verfolgen",
"details": { "details": {
"title": "Bestelldetails: {{orderId}}", "title": "Bestelldetails: {{orderId}}",
"deliveryAddress": "Lieferadresse", "deliveryAddress": "Lieferadresse",

View File

@@ -8,6 +8,7 @@ export default {
"articleNumber": "Artikelnummer", "articleNumber": "Artikelnummer",
"manufacturer": "Hersteller", "manufacturer": "Hersteller",
"inclVat": "inkl. {{vat}}% MwSt.", "inclVat": "inkl. {{vat}}% MwSt.",
"inclVatSimple": "inkl. MwSt.",
"priceUnit": "{{price}}/{{unit}}", "priceUnit": "{{price}}/{{unit}}",
"new": "Neu", "new": "Neu",
"weeks": "Wochen", "weeks": "Wochen",
@@ -25,6 +26,7 @@ export default {
"pickupPrice": "Abholpreis: 19,90 € pro Steckling.", "pickupPrice": "Abholpreis: 19,90 € pro Steckling.",
"consistsOf": "Bestehend aus:", "consistsOf": "Bestehend aus:",
"loadingComponentDetails": "{{index}}. Lädt Komponent-Details...", "loadingComponentDetails": "{{index}}. Lädt Komponent-Details...",
"loadingProduct": "Produkt wird geladen...",
"individualPriceTotal": "Einzelpreis gesamt:", "individualPriceTotal": "Einzelpreis gesamt:",
"setPrice": "Set-Preis:", "setPrice": "Set-Preis:",
"yourSavings": "Ihre Ersparnis:", "yourSavings": "Ihre Ersparnis:",

View File

@@ -0,0 +1,62 @@
export default {
"questionTitle": "Frage zum Artikel",
"questionSubtitle": "Haben Sie eine Frage zu diesem Artikel? Wir helfen Ihnen gerne weiter.",
"questionSuccess": "Vielen Dank für Ihre Frage! Wir werden uns schnellstmöglich bei Ihnen melden.",
"nameLabel": "Name",
"namePlaceholder": "Ihr Name",
"emailLabel": "E-Mail",
"emailPlaceholder": "ihre.email@example.com",
"questionLabel": "Ihre Frage",
"questionPlaceholder": "Beschreiben Sie Ihre Frage zu diesem Artikel...",
"photosLabelQuestion": "Fotos zur Frage anhängen (optional)",
"submitQuestion": "Frage senden",
"sending": "Wird gesendet...",
"ratingTitle": "Artikel Bewerten",
"ratingSubtitle": "Teilen Sie Ihre Erfahrungen mit diesem Artikel und helfen Sie anderen Kunden bei der Entscheidung.",
"ratingSuccess": "Vielen Dank für Ihre Bewertung! Sie wird nach Prüfung veröffentlicht.",
"emailHelper": "Ihre E-Mail wird nicht veröffentlicht",
"ratingLabel": "Bewertung *",
"pleaseRate": "Bitte bewerten",
"ratingStars": "{{rating}} von 5 Sternen",
"reviewLabel": "Ihre Bewertung (optional)",
"reviewPlaceholder": "Beschreiben Sie Ihre Erfahrungen mit diesem Artikel...",
"photosLabelRating": "Fotos zur Bewertung anhängen (optional)",
"submitRating": "Bewertung abgeben",
"errorGeneric": "Ein Fehler ist aufgetreten",
"errorPhotos": "Fehler beim Verarbeiten der Fotos",
"availabilityTitle": "Verfügbarkeit anfragen",
"availabilitySubtitle": "Dieser Artikel ist derzeit nicht verfügbar. Gerne informieren wir Sie, sobald er wieder lieferbar ist.",
"availabilitySuccessEmail": "Vielen Dank für Ihre Anfrage! Wir werden Sie per E-Mail informieren, sobald der Artikel wieder verfügbar ist.",
"availabilitySuccessTelegram": "Vielen Dank für Ihre Anfrage! Wir werden Sie über Telegram informieren, sobald der Artikel wieder verfügbar ist.",
"notificationMethodLabel": "Wie möchten Sie benachrichtigt werden?",
"telegramBotLabel": "Telegram Bot",
"telegramIdLabel": "Telegram ID",
"telegramPlaceholder": "@IhrTelegramName oder Telegram ID",
"telegramHelper": "Geben Sie Ihren Telegram-Benutzernamen (mit @) oder Ihre Telegram-ID ein",
"messageLabel": "Nachricht (optional)",
"messagePlaceholder": "Zusätzliche Informationen oder Fragen...",
"submitAvailability": "Verfügbarkeit anfragen",
"photoUploadSelect": "Fotos auswählen",
"photoUploadErrorMaxFiles": "Maximal {{max}} Dateien erlaubt",
"photoUploadErrorFileType": "Nur Bilddateien (JPEG, PNG, GIF, WebP) sind erlaubt",
"photoUploadErrorFileSize": "Datei zu groß. Maximum: {{maxSize}}MB",
"photoUploadSelectedFiles": "{{count}} Datei(en) ausgewählt",
"photoUploadCompressed": "(komprimiert für Upload)",
"photoUploadRemove": "Bild entfernen",
"photoUploadLabelDefault": "Fotos anhängen (optional)",
"shareTitle": "Teilen",
"shareEmbed": "Einbetten",
"shareCopyLink": "Link kopieren",
"shareSuccessEmbed": "Einbettungscode wurde in die Zwischenablage kopiert!",
"shareErrorEmbed": "Fehler beim Kopieren des Einbettungscodes",
"shareSuccessLink": "Link wurde in die Zwischenablage kopiert!",
"shareWhatsAppText": "Schau dir dieses Produkt an: {{name}}",
"shareTelegramText": "Schau dir dieses Produkt an: {{name}}",
"shareEmailSubject": "Produktempfehlung",
"shareEmailBody": "Hallo,\n\nich möchte dir dieses Produkt empfehlen:\n\n{{name}}\n{{url}}\n\nViele Grüße"
};

View File

@@ -1,6 +1,7 @@
export default { export default {
"seeds": "Seeds", "seeds": "Seeds",
"stecklinge": "Stecklinge", "stecklinge": "Stecklinge",
"konfigurator": "Konfigurator",
"oilPress": "Ölpresse ausleihen", "oilPress": "Ölpresse ausleihen",
"thcTest": "THC Test", "thcTest": "THC Test",
"address1": "Trachenberger Straße 14", "address1": "Trachenberger Straße 14",

View File

@@ -1,5 +1,5 @@
export default { export default {
"home": "Fine Cannabis Seeds & Cuttings", "home": "Fine Cannabis Seeds",
"aktionen": "Aktuelle Aktionen & Angebote", "aktionen": "Aktuelle Aktionen & Angebote",
"filiale": "Unsere Filiale in Dresden" "filiale": "Unsere Filiale in Dresden"
}; };

View File

@@ -5,14 +5,16 @@ export default {
"profile": "Προφίλ", "profile": "Προφίλ",
"email": "Email", "email": "Email",
"password": "Κωδικός", "password": "Κωδικός",
"newPassword": "Νέος κωδικός",
"confirmPassword": "Επιβεβαίωση κωδικού", "confirmPassword": "Επιβεβαίωση κωδικού",
"forgotPassword": "Ξεχάσατε τον κωδικό;", "forgotPassword": "Ξεχάσατε τον κωδικό;",
"loginWithGoogle": "Σύνδεση με Google", "loginWithGoogle": "Σύνδεση με Google",
"or": "Ή", "or": "Ή",
"privacyAccept": "Κάνοντας κλικ στο \"Σύνδεση με Google\" αποδέχομαι την", "privacyAccept": "Κάνοντας κλικ στο \"Σύνδεση με Google\" αποδέχομαι την",
"privacyPolicy": "Πολιτική Απορρήτου", "privacyPolicy": "Πολιτική απορρήτου",
"passwordMinLength": "Ο κωδικός πρέπει να έχει τουλάχιστον 8 χαρακτήρες", "passwordMinLength": "Ο κωδικός πρέπει να έχει τουλάχιστον 8 χαρακτήρες",
"newPasswordMinLength": "Ο νέος κωδικός πρέπει να έχει τουλάχιστον 8 χαρακτήρες", "newPasswordMinLength": "Ο νέος κωδικός πρέπει να έχει τουλάχιστον 8 χαρακτήρες",
"backToHome": "Επιστροφή στην αρχική σελίδα",
"menu": { "menu": {
"profile": "Προφίλ", "profile": "Προφίλ",
"myProfile": "Το προφίλ μου", "myProfile": "Το προφίλ μου",
@@ -21,5 +23,28 @@ export default {
"settings": "Ρυθμίσεις", "settings": "Ρυθμίσεις",
"adminDashboard": "Πίνακας διαχείρισης", "adminDashboard": "Πίνακας διαχείρισης",
"adminUsers": "Διαχειριστές" "adminUsers": "Διαχειριστές"
},
"resetPassword": {
"title": "Επαναφορά κωδικού",
"button": "Επαναφορά κωδικού",
"success": "Ο κωδικός σας επαναφέρθηκε με επιτυχία! Θα ανακατευθυνθείτε στη σύνδεση σύντομα...",
"invalidToken": "Δεν βρέθηκε έγκυρο διακριτικό. Παρακαλώ χρησιμοποιήστε τον σύνδεσμο από το email σας.",
"error": "Σφάλμα κατά την επαναφορά του κωδικού",
"emailSent": "Ένας σύνδεσμος για επαναφορά του κωδικού σας έχει σταλεί στη διεύθυνση email σας.",
"emailError": "Σφάλμα κατά την αποστολή του email"
},
"errors": {
"fillAllFields": "Παρακαλώ συμπληρώστε όλα τα πεδία",
"invalidEmail": "Παρακαλώ εισάγετε μια έγκυρη διεύθυνση email",
"passwordsNotMatch": "Οι κωδικοί δεν ταιριάζουν",
"passwordsNotMatchShort": "Οι κωδικοί δεν ταιριάζουν",
"enterEmail": "Παρακαλώ εισάγετε τη διεύθυνση email σας",
"loginFailed": "Η σύνδεση απέτυχε",
"registerFailed": "Η εγγραφή απέτυχε",
"googleLoginFailed": "Η σύνδεση με Google απέτυχε",
"emailExists": "Υπάρχει ήδη χρήστης με αυτή τη διεύθυνση email. Παρακαλώ χρησιμοποιήστε άλλη διεύθυνση ή συνδεθείτε."
},
"success": {
"registerComplete": "Η εγγραφή ολοκληρώθηκε με επιτυχία. Μπορείτε τώρα να συνδεθείτε."
} }
}; };

View File

@@ -10,13 +10,13 @@ export default {
"standard": "Τυπική αποστολή", "standard": "Τυπική αποστολή",
"standardFree": "Τυπική αποστολή - ΔΩΡΕΑΝ από παραγγελίες άνω των 100€!", "standardFree": "Τυπική αποστολή - ΔΩΡΕΑΝ από παραγγελίες άνω των 100€!",
"notAvailable": "Δεν είναι επιλέξιμο γιατί ένα ή περισσότερα είδη μπορούν να παραληφθούν μόνο από το κατάστημα", "notAvailable": "Δεν είναι επιλέξιμο γιατί ένα ή περισσότερα είδη μπορούν να παραληφθούν μόνο από το κατάστημα",
"bulky": "Για μεγάλα και βαριά αντικείμενα", "bulky": "Για μεγάλα και βαριά είδη",
"pickupOnly": "Μόνο παραλαβή" "pickupOnly": "Μόνο παραλαβή"
}, },
"prices": { "prices": {
"free": "δωρεάν", "free": "δωρεάν",
"freeFrom100": "(δωρεάν από 100€)", "freeFrom100": "(δωρεάν από 100€)",
"dhl": "6.99 €", "dhl": "5.90 €",
"dpd": "4.90 €", "dpd": "4.90 €",
"sperrgut": "28.99 €" "sperrgut": "28.99 €"
}, },
@@ -30,6 +30,6 @@ export default {
"freeShippingInfo": "💡 Δωρεάν αποστολή από παραγγελίες άνω των 100€!", "freeShippingInfo": "💡 Δωρεάν αποστολή από παραγγελίες άνω των 100€!",
"remainingForFree": "Προσθέστε ακόμα {{amount}}€ για δωρεάν αποστολή.", "remainingForFree": "Προσθέστε ακόμα {{amount}}€ για δωρεάν αποστολή.",
"congratsFreeShipping": "🎉 Συγχαρητήρια! Έχετε δωρεάν αποστολή!", "congratsFreeShipping": "🎉 Συγχαρητήρια! Έχετε δωρεάν αποστολή!",
"cartQualifiesFree": "Το καλάθι σας αξίας {{amount}}€ δικαιούται δωρεάν αποστολή." "cartQualifiesFree": "Το καλάθι σας των {{amount}}€ πληροί τις προϋποθέσεις για δωρεάν αποστολή."
} }
}; };

View File

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

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