Compare commits
37 Commits
f20628f71c
...
live
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9df5642a6e | ||
|
|
a50dd086c3 | ||
|
|
e88370ff3e | ||
|
|
5d3e0832fe | ||
|
|
3347ba2754 | ||
|
|
013a38ca98 | ||
|
|
2d6c8ff25f | ||
|
|
d2ac8d3fc1 | ||
|
|
8928b3f283 | ||
|
|
87db7ba3ea | ||
|
|
766fef2796 | ||
|
|
a08c90a521 | ||
|
|
10d60d5827 | ||
|
|
905eee57d5 | ||
|
|
3389a9b66c | ||
|
|
d63c385a97 | ||
|
|
1b51da69a9 | ||
|
|
da81479d9b | ||
|
|
d8678e261d | ||
|
|
ef91e50aa5 | ||
|
|
061bf5ff17 | ||
|
|
0b915db9eb | ||
|
|
43e67ee4c4 | ||
|
|
b599e6424b | ||
|
|
1ddbafaa51 | ||
|
|
e6faa63219 | ||
|
|
277edea15e | ||
|
|
b267b9132a | ||
|
|
c82a6a8f62 | ||
|
|
6b0ab27a3a | ||
|
|
289baec8cf | ||
|
|
11ba2db893 | ||
|
|
521cc307a3 | ||
|
|
d397930f2c | ||
|
|
8e43eaaede | ||
|
|
13c63db643 | ||
|
|
5b12dad435 |
5
.cursor/rules/devserver.mdc
Normal 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
@@ -1,64 +1,3 @@
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.cursor/
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
/dist
|
||||
/public/index.prerender.html
|
||||
/public/assets/images/prod*.jpg
|
||||
/public/assets/images/cat*.jpg
|
||||
/public/prerender.css
|
||||
/public/Artikel/*
|
||||
/public/Kategorie/*
|
||||
/public/agb
|
||||
/public/batteriegesetzhinweise
|
||||
/public/datenschutz
|
||||
/public/impressum
|
||||
/public/sitemap
|
||||
/public/widerrufsrecht
|
||||
/public/robots.txt
|
||||
/public/sitemap.xml
|
||||
/public/index.prerender.html
|
||||
/public/Konfigurator
|
||||
/public/profile
|
||||
/public/404
|
||||
|
||||
/public/products.xml
|
||||
/public/llms*
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
.hintrc
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Local configuration
|
||||
src/config.local.js
|
||||
|
||||
taxonomy-with-ids.de-DE*
|
||||
|
||||
# Local development notes
|
||||
dev-notes.md
|
||||
dev-notes.local.md
|
||||
logs/
|
||||
/logs
|
||||
154
docs/nginx.conf
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
380
generate-category-descriptions.js
Normal 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
|
||||
};
|
||||
6
package-lock.json
generated
@@ -4554,9 +4554,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001727",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz",
|
||||
"integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==",
|
||||
"version": "1.0.30001757",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz",
|
||||
"integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"start": "cross-env NODE_OPTIONS=\"--no-deprecation\" webpack serve --progress --mode development --no-open",
|
||||
"start:seedheads": "cross-env PROXY_TARGET=https://seedheads.de NODE_OPTIONS=\"--no-deprecation\" webpack serve --progress --mode development --no-open",
|
||||
"prod": "webpack serve --progress --mode production --no-client-overlay --no-client --no-web-socket-server --no-open --no-live-reload --no-hot --compress --no-devtool",
|
||||
"build:client": "cross-env NODE_ENV=production webpack --progress --mode production && shx cp dist/index.html dist/index_template.html",
|
||||
"build:client": "node scripts/convert-images-to-avif.cjs && cross-env NODE_ENV=production webpack --progress --mode production && shx cp dist/index.html dist/index_template.html",
|
||||
"build": "npm run build:client",
|
||||
"analyze": "cross-env ANALYZE=true NODE_ENV=production webpack --progress --mode production",
|
||||
"lint": "eslint src/**/*.{js,jsx}",
|
||||
|
||||
120
prerender.cjs
@@ -28,7 +28,7 @@ class CategoryService {
|
||||
const cacheKey = `${categoryId}_${language}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
async get(categoryId, language = "de") {
|
||||
const cacheKey = `${categoryId}_${language}`;
|
||||
return null;
|
||||
@@ -136,6 +136,7 @@ const {
|
||||
generateLlmsTxt,
|
||||
generateCategoryLlmsTxt,
|
||||
generateAllCategoryLlmsPages,
|
||||
generateCategoryProductList,
|
||||
} = require("./prerender/seo.cjs");
|
||||
const {
|
||||
fetchCategoryProducts,
|
||||
@@ -158,6 +159,7 @@ const Batteriegesetzhinweise =
|
||||
const Widerrufsrecht = require("./src/pages/Widerrufsrecht.js").default;
|
||||
const Sitemap = require("./src/pages/Sitemap.js").default;
|
||||
const PrerenderSitemap = require("./src/PrerenderSitemap.js").default;
|
||||
const PrerenderCategoriesPage = require("./src/PrerenderCategoriesPage.js").default;
|
||||
const AGB = require("./src/pages/AGB.js").default;
|
||||
const NotFound404 = require("./src/pages/NotFound404.js").default;
|
||||
|
||||
@@ -188,7 +190,7 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback,
|
||||
|
||||
try {
|
||||
const productDetails = await fetchProductDetails(workerSocket, productSeoName);
|
||||
|
||||
|
||||
const actualSeoName = productDetails.product.seoName || productSeoName;
|
||||
const productComponent = React.createElement(PrerenderProduct, {
|
||||
productData: productDetails,
|
||||
@@ -204,7 +206,7 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback,
|
||||
}, shopConfig.baseUrl, shopConfig);
|
||||
// Get category info from categoryMap if available
|
||||
const categoryInfo = productDetails.product.categoryId ? categoryMap[productDetails.product.categoryId] : null;
|
||||
|
||||
|
||||
const jsonLdScript = generateProductJsonLd({
|
||||
...productDetails.product,
|
||||
seoName: actualSeoName,
|
||||
@@ -233,9 +235,9 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback,
|
||||
success,
|
||||
workerId
|
||||
};
|
||||
|
||||
|
||||
results.push(result);
|
||||
|
||||
|
||||
// Call progress callback if provided
|
||||
if (progressCallback) {
|
||||
progressCallback(result);
|
||||
@@ -251,14 +253,14 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback,
|
||||
error: error.message,
|
||||
workerId
|
||||
};
|
||||
|
||||
|
||||
results.push(result);
|
||||
|
||||
|
||||
// Call progress callback if provided
|
||||
if (progressCallback) {
|
||||
progressCallback(result);
|
||||
}
|
||||
|
||||
|
||||
setTimeout(processNextProduct, 25);
|
||||
}
|
||||
};
|
||||
@@ -290,16 +292,16 @@ const renderProductsInParallel = async (allProductsArray, maxWorkers, totalProdu
|
||||
const barLength = 30;
|
||||
const filledLength = Math.round((barLength * current) / total);
|
||||
const bar = '█'.repeat(filledLength) + '░'.repeat(barLength - filledLength);
|
||||
|
||||
|
||||
// @note Single line progress update to prevent flickering
|
||||
const truncatedName = productName ? ` - ${productName.substring(0, 25)}${productName.length > 25 ? '...' : ''}` : '';
|
||||
|
||||
|
||||
// Build worker stats on one line
|
||||
let workerStats = '';
|
||||
for (let i = 0; i < Math.min(maxWorkers, 8); i++) { // Limit to 8 workers to fit on screen
|
||||
workerStats += `W${i + 1}:${workerCounts[i]}/${workerSuccess[i]} `;
|
||||
}
|
||||
|
||||
|
||||
// Single line update without complex cursor movements
|
||||
process.stdout.write(`\r [${bar}] ${percentage}% (${current}/${total})${truncatedName}\n ${workerStats}${current < total ? '\x1b[1A' : '\n'}`);
|
||||
};
|
||||
@@ -307,26 +309,26 @@ const renderProductsInParallel = async (allProductsArray, maxWorkers, totalProdu
|
||||
// Split products among workers
|
||||
const productsPerWorker = Math.ceil(allProductsArray.length / maxWorkers);
|
||||
const workerPromises = [];
|
||||
|
||||
|
||||
// Initial progress bar
|
||||
updateProgressBar(0, totalProducts);
|
||||
|
||||
|
||||
for (let i = 0; i < maxWorkers; i++) {
|
||||
const start = i * productsPerWorker;
|
||||
const end = Math.min(start + productsPerWorker, allProductsArray.length);
|
||||
const productsForWorker = allProductsArray.slice(start, end);
|
||||
|
||||
|
||||
if (productsForWorker.length > 0) {
|
||||
const promise = renderProductWorker(productsForWorker, i + 1, (result) => {
|
||||
// Progress callback - called each time a product is completed
|
||||
completedProducts++;
|
||||
progressResults.push(result);
|
||||
lastProductName = result.productName;
|
||||
|
||||
|
||||
// Update per-worker counters
|
||||
const workerIndex = result.workerId - 1; // Convert to 0-based index
|
||||
workerCounts[workerIndex]++;
|
||||
|
||||
|
||||
if (result.success) {
|
||||
totalSuccessCount++;
|
||||
workerSuccess[workerIndex]++;
|
||||
@@ -334,11 +336,11 @@ const renderProductsInParallel = async (allProductsArray, maxWorkers, totalProdu
|
||||
// Don't log errors immediately to avoid interfering with progress bar
|
||||
// Errors will be shown after completion
|
||||
}
|
||||
|
||||
|
||||
// Update progress bar with worker stats
|
||||
updateProgressBar(completedProducts, totalProducts, lastProductName);
|
||||
}, categoryMap);
|
||||
|
||||
|
||||
workerPromises.push(promise);
|
||||
}
|
||||
}
|
||||
@@ -346,10 +348,10 @@ const renderProductsInParallel = async (allProductsArray, maxWorkers, totalProdu
|
||||
try {
|
||||
// Wait for all workers to complete
|
||||
await Promise.all(workerPromises);
|
||||
|
||||
|
||||
// Ensure final progress update
|
||||
updateProgressBar(totalProducts, totalProducts, lastProductName);
|
||||
|
||||
|
||||
// Show any errors that occurred
|
||||
const errorResults = progressResults.filter(r => !r.success && r.error);
|
||||
if (errorResults.length > 0) {
|
||||
@@ -358,7 +360,7 @@ const renderProductsInParallel = async (allProductsArray, maxWorkers, totalProdu
|
||||
console.log(` - ${result.productSeoName}: ${result.error}`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return totalSuccessCount;
|
||||
} catch (error) {
|
||||
console.error('Error in parallel rendering:', error);
|
||||
@@ -421,6 +423,14 @@ const renderApp = async (categoryData, socket) => {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Copy index.html to resetPassword (no file extension) for SPA routing
|
||||
if (config.isProduction) {
|
||||
const indexPath = path.resolve(__dirname, config.outputDir, "index.html");
|
||||
const resetPasswordPath = path.resolve(__dirname, config.outputDir, "resetPassword");
|
||||
fs.copyFileSync(indexPath, resetPasswordPath);
|
||||
console.log(`✅ Copied index.html to ${resetPasswordPath}`);
|
||||
}
|
||||
|
||||
// Render static pages
|
||||
console.log("\n📄 Rendering static pages...");
|
||||
|
||||
@@ -456,6 +466,13 @@ const renderApp = async (categoryData, socket) => {
|
||||
description: "Sitemap page",
|
||||
needsCategoryData: true,
|
||||
},
|
||||
{
|
||||
component: PrerenderCategoriesPage,
|
||||
path: "/Kategorien",
|
||||
filename: "Kategorien",
|
||||
description: "Categories page",
|
||||
needsCategoryData: true,
|
||||
},
|
||||
{ component: AGB, path: "/agb", filename: "agb", description: "AGB page" },
|
||||
{ component: NotFound404, path: "/404", filename: "404", description: "404 Not Found page" },
|
||||
{
|
||||
@@ -550,8 +567,7 @@ const renderApp = async (categoryData, socket) => {
|
||||
try {
|
||||
productData = await fetchCategoryProducts(socket, category.id);
|
||||
console.log(
|
||||
` ✅ Found ${
|
||||
productData.products ? productData.products.length : 0
|
||||
` ✅ Found ${productData.products ? productData.products.length : 0
|
||||
} products`
|
||||
);
|
||||
|
||||
@@ -635,7 +651,7 @@ const renderApp = async (categoryData, socket) => {
|
||||
const totalProducts = allProducts.size;
|
||||
const numCPUs = os.cpus().length;
|
||||
const maxWorkers = Math.min(numCPUs, totalProducts, 8); // Cap at 8 workers to avoid overwhelming the server
|
||||
|
||||
|
||||
// Create category map for breadcrumbs
|
||||
const categoryMap = {};
|
||||
allCategories.forEach(category => {
|
||||
@@ -644,11 +660,11 @@ const renderApp = async (categoryData, socket) => {
|
||||
seoName: category.seoName
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
console.log(
|
||||
`\n📦 Rendering ${totalProducts} individual product pages using ${maxWorkers} parallel workers...`
|
||||
);
|
||||
|
||||
|
||||
const productPagesRendered = await renderProductsInParallel(
|
||||
Array.from(allProducts),
|
||||
maxWorkers,
|
||||
@@ -700,21 +716,21 @@ const renderApp = async (categoryData, socket) => {
|
||||
// Generate products.xml (Google Shopping feed) in parallel to sitemap.xml
|
||||
if (allProductsData.length > 0) {
|
||||
console.log("\n🛒 Generating products.xml (Google Shopping feed)...");
|
||||
|
||||
|
||||
try {
|
||||
const productsXml = generateProductsXml(allProductsData, shopConfig.baseUrl, shopConfig);
|
||||
|
||||
|
||||
const productsXmlPath = path.resolve(__dirname, config.outputDir, "products.xml");
|
||||
|
||||
|
||||
// Write with explicit UTF-8 encoding
|
||||
fs.writeFileSync(productsXmlPath, productsXml, { encoding: 'utf8' });
|
||||
|
||||
|
||||
console.log(`✅ products.xml generated: ${productsXmlPath}`);
|
||||
console.log(` - Products included: ${allProductsData.length}`);
|
||||
console.log(` - Format: Google Shopping RSS 2.0 feed`);
|
||||
console.log(` - Encoding: UTF-8`);
|
||||
console.log(` - Includes: title, description, price, availability, images`);
|
||||
|
||||
|
||||
// Verify the file is valid UTF-8
|
||||
try {
|
||||
const verification = fs.readFileSync(productsXmlPath, 'utf8');
|
||||
@@ -722,18 +738,18 @@ const renderApp = async (categoryData, socket) => {
|
||||
} catch (verifyError) {
|
||||
console.log(` - File verification: ⚠️ ${verifyError.message}`);
|
||||
}
|
||||
|
||||
|
||||
// Validate XML against Google Shopping schema
|
||||
try {
|
||||
const ProductsXmlValidator = require('./scripts/validate-products-xml.cjs');
|
||||
const validator = new ProductsXmlValidator(productsXmlPath);
|
||||
const validationResults = await validator.validate();
|
||||
|
||||
|
||||
if (validationResults.valid) {
|
||||
console.log(` - Schema validation: ✅ Valid Google Shopping RSS 2.0`);
|
||||
} else {
|
||||
console.log(` - Schema validation: ⚠️ ${validationResults.summary.errorCount} errors, ${validationResults.summary.warningCount} warnings`);
|
||||
|
||||
|
||||
// Show first few errors for quick debugging
|
||||
if (validationResults.errors.length > 0) {
|
||||
console.log(` - First error: ${validationResults.errors[0].message}`);
|
||||
@@ -742,7 +758,7 @@ const renderApp = async (categoryData, socket) => {
|
||||
} catch (validationError) {
|
||||
console.log(` - Schema validation: ⚠️ Validation failed: ${validationError.message}`);
|
||||
}
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ Error generating products.xml: ${error.message}`);
|
||||
console.log("\n⚠️ Skipping products.xml generation due to errors");
|
||||
@@ -753,18 +769,18 @@ const renderApp = async (categoryData, socket) => {
|
||||
|
||||
// Generate llms.txt (LLM-friendly markdown sitemap) and category-specific files
|
||||
console.log("\n🤖 Generating LLM sitemap files...");
|
||||
|
||||
|
||||
try {
|
||||
// Generate main llms.txt overview file
|
||||
const llmsTxt = generateLlmsTxt(allCategories, allProductsData, shopConfig.baseUrl, shopConfig);
|
||||
const llmsTxtPath = path.resolve(__dirname, config.outputDir, "llms.txt");
|
||||
fs.writeFileSync(llmsTxtPath, llmsTxt, { encoding: 'utf8' });
|
||||
|
||||
|
||||
console.log(`✅ Main llms.txt generated: ${llmsTxtPath}`);
|
||||
console.log(` - Static pages: 8 pages`);
|
||||
console.log(` - Categories: ${allCategories.length} with links to detailed files`);
|
||||
console.log(` - File size: ${Math.round(llmsTxt.length / 1024)}KB`);
|
||||
|
||||
|
||||
// Group products by category for category-specific files
|
||||
const productsByCategory = {};
|
||||
allProductsData.forEach((product) => {
|
||||
@@ -774,47 +790,53 @@ const renderApp = async (categoryData, socket) => {
|
||||
}
|
||||
productsByCategory[categoryId].push(product);
|
||||
});
|
||||
|
||||
|
||||
// Generate category-specific LLM files with pagination
|
||||
let categoryFilesGenerated = 0;
|
||||
let totalCategoryProducts = 0;
|
||||
let totalPaginatedFiles = 0;
|
||||
|
||||
|
||||
for (const category of allCategories) {
|
||||
if (category.seoName) {
|
||||
const categoryProducts = productsByCategory[category.id] || [];
|
||||
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
||||
|
||||
|
||||
// Generate all paginated files for this category
|
||||
const categoryPages = generateAllCategoryLlmsPages(category, categoryProducts, shopConfig.baseUrl, shopConfig);
|
||||
|
||||
|
||||
// Write each paginated file
|
||||
for (const page of categoryPages) {
|
||||
const pagePath = path.resolve(__dirname, config.outputDir, page.fileName);
|
||||
fs.writeFileSync(pagePath, page.content, { encoding: 'utf8' });
|
||||
totalPaginatedFiles++;
|
||||
}
|
||||
|
||||
|
||||
// Generate and write the product list file for this category
|
||||
const productList = generateCategoryProductList(category, categoryProducts);
|
||||
const listPath = path.resolve(__dirname, config.outputDir, productList.fileName);
|
||||
fs.writeFileSync(listPath, productList.content, { encoding: 'utf8' });
|
||||
|
||||
const pageCount = categoryPages.length;
|
||||
const totalSize = categoryPages.reduce((sum, page) => sum + page.content.length, 0);
|
||||
|
||||
|
||||
console.log(` ✅ llms-${categorySlug}-page-*.txt - ${categoryProducts.length} products across ${pageCount} pages (${Math.round(totalSize / 1024)}KB total)`);
|
||||
|
||||
console.log(` 📋 ${productList.fileName} - ${productList.productCount} products (${Math.round(productList.content.length / 1024)}KB)`);
|
||||
|
||||
categoryFilesGenerated++;
|
||||
totalCategoryProducts += categoryProducts.length;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
console.log(` 📄 Total paginated files generated: ${totalPaginatedFiles}`);
|
||||
console.log(` 📦 Total products across all categories: ${totalCategoryProducts}`);
|
||||
|
||||
|
||||
try {
|
||||
const verification = fs.readFileSync(llmsTxtPath, 'utf8');
|
||||
console.log(` - File verification: ✅ All files valid UTF-8`);
|
||||
} catch (verifyError) {
|
||||
console.log(` - File verification: ⚠️ ${verifyError.message}`);
|
||||
}
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ Error generating LLM sitemap files: ${error.message}`);
|
||||
console.log("\n⚠️ Skipping LLM sitemap generation due to errors");
|
||||
@@ -834,7 +856,7 @@ const fetchCategoryDataAndRender = () => {
|
||||
|
||||
const socket = io(socketUrl, {
|
||||
path: "/socket.io/",
|
||||
transports: [ "websocket"],
|
||||
transports: ["websocket"],
|
||||
reconnection: false,
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
@@ -152,7 +152,7 @@ const saveProductImages = async (socket, products, categoryName, outputDir) => {
|
||||
"public",
|
||||
"assets",
|
||||
"images",
|
||||
"sh.png"
|
||||
"sh.avif"
|
||||
);
|
||||
|
||||
// Ensure assets/images directory exists
|
||||
@@ -185,7 +185,7 @@ const saveProductImages = async (socket, products, categoryName, outputDir) => {
|
||||
if (imageIds.length > 0) {
|
||||
// Process first image for each product
|
||||
const bildId = parseInt(imageIds[0]);
|
||||
const estimatedFilename = `prod${bildId}.jpg`; // We'll generate a filename based on the ID
|
||||
const estimatedFilename = `prod${bildId}.avif`; // We'll generate a filename based on the ID
|
||||
|
||||
const imagePath = path.join(assetsPath, estimatedFilename);
|
||||
|
||||
@@ -231,12 +231,12 @@ const saveProductImages = async (socket, products, categoryName, outputDir) => {
|
||||
opacity: 0.3,
|
||||
},
|
||||
])
|
||||
.jpeg() // Ensure output is JPEG
|
||||
.avif() // Ensure output is AVIF
|
||||
.toBuffer();
|
||||
|
||||
fs.writeFileSync(imagePath, processedImageBuffer);
|
||||
console.log(
|
||||
` ✅ Applied centered inverted sh.png overlay to ${estimatedFilename}`
|
||||
` ✅ Applied centered inverted sh.avif overlay to ${estimatedFilename}`
|
||||
);
|
||||
} catch (overlayError) {
|
||||
console.log(
|
||||
@@ -281,7 +281,7 @@ const saveCategoryImages = async (socket, categories, outputDir) => {
|
||||
// Debug: Log categories that will be processed
|
||||
console.log(" 🔍 Categories to process:");
|
||||
categories.forEach((cat, index) => {
|
||||
console.log(` ${index + 1}. "${cat.name}" (ID: ${cat.id}) -> cat${cat.id}.jpg`);
|
||||
console.log(` ${index + 1}. "${cat.name}" (ID: ${cat.id}) -> cat${cat.id}.avif`);
|
||||
});
|
||||
|
||||
const assetsPath = path.resolve(
|
||||
@@ -308,7 +308,7 @@ const saveCategoryImages = async (socket, categories, outputDir) => {
|
||||
for (const category of categories) {
|
||||
categoriesProcessed++;
|
||||
|
||||
const estimatedFilename = `cat${category.id}.jpg`; // Use 'cat' prefix with category ID
|
||||
const estimatedFilename = `cat${category.id}.avif`; // Use 'cat' prefix with category ID
|
||||
const imagePath = path.join(assetsPath, estimatedFilename);
|
||||
|
||||
// Skip if image already exists
|
||||
|
||||
@@ -247,10 +247,6 @@ const renderPage = (
|
||||
if (!suppressLogs) {
|
||||
console.log(`✅ ${description} prerendered to ${outputPath}`);
|
||||
console.log(` - Markup length: ${renderedMarkup.length} characters`);
|
||||
console.log(` - CSS rules: ${Object.keys(cache.inserted).length}`);
|
||||
console.log(` - Total inlined CSS: ${Math.round(combinedCss.length / 1024)}KB`);
|
||||
console.log(` - Render-blocking CSS eliminated: ${inlinedCss ? 'YES' : 'NO'}`);
|
||||
console.log(` - Fallback content saved to window.__PRERENDER_FALLBACK__`);
|
||||
if (productDetailCacheScript) {
|
||||
console.log(` - Product detail cache populated for SPA hydration`);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
|
||||
// Category IDs to skip (seeds, plants, headshop items)
|
||||
const skipCategoryIds = [689, 706, 709, 711, 714, 748, 749, 896, 710, 924, 923, 922, 921, 916, 278, 259, 258];
|
||||
|
||||
// Check if category ID is in skip list
|
||||
if (category.id && skipCategoryIds.includes(parseInt(category.id))) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const categoryUrl = `${baseUrl}/Kategorie/${category.seoName}`;
|
||||
|
||||
// Calculate price valid date (current date + 3 months)
|
||||
const priceValidDate = new Date();
|
||||
priceValidDate.setMonth(priceValidDate.getMonth() + 3);
|
||||
const priceValidUntil = priceValidDate.toISOString().split("T")[0];
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org/",
|
||||
"@type": "CollectionPage",
|
||||
@@ -42,7 +55,7 @@ const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
|
||||
product.pictureList && product.pictureList.trim()
|
||||
? `${baseUrl}/assets/images/prod${product.pictureList
|
||||
.split(",")[0]
|
||||
.trim()}.jpg`
|
||||
.trim()}.avif`
|
||||
: `${baseUrl}/assets/images/nopicture.jpg`,
|
||||
description: product.description
|
||||
? product.description.replace(/<[^>]*>/g, "").substring(0, 200)
|
||||
@@ -57,6 +70,7 @@ const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
|
||||
url: `${baseUrl}/Artikel/${product.seoName}`,
|
||||
price: product.price && !isNaN(product.price) ? product.price.toString() : "0.00",
|
||||
priceCurrency: config.currency,
|
||||
priceValidUntil: priceValidUntil,
|
||||
availability: product.available
|
||||
? "https://schema.org/InStock"
|
||||
: "https://schema.org/OutOfStock",
|
||||
@@ -65,6 +79,41 @@ const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
|
||||
name: config.brandName,
|
||||
},
|
||||
itemCondition: "https://schema.org/NewCondition",
|
||||
hasMerchantReturnPolicy: {
|
||||
"@type": "MerchantReturnPolicy",
|
||||
applicableCountry: "DE",
|
||||
returnPolicyCategory: "https://schema.org/MerchantReturnFiniteReturnWindow",
|
||||
merchantReturnDays: 14,
|
||||
returnMethod: "https://schema.org/ReturnByMail",
|
||||
returnFees: "https://schema.org/FreeReturn",
|
||||
},
|
||||
shippingDetails: {
|
||||
"@type": "OfferShippingDetails",
|
||||
shippingRate: {
|
||||
"@type": "MonetaryAmount",
|
||||
value: 5.90,
|
||||
currency: "EUR",
|
||||
},
|
||||
shippingDestination: {
|
||||
"@type": "DefinedRegion",
|
||||
addressCountry: "DE",
|
||||
},
|
||||
deliveryTime: {
|
||||
"@type": "ShippingDeliveryTime",
|
||||
handlingTime: {
|
||||
"@type": "QuantitativeValue",
|
||||
minValue: 0,
|
||||
maxValue: 1,
|
||||
unitCode: "DAY",
|
||||
},
|
||||
transitTime: {
|
||||
"@type": "QuantitativeValue",
|
||||
minValue: 2,
|
||||
maxValue: 3,
|
||||
unitCode: "DAY",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
|
||||
@@ -122,6 +122,7 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
689: "543561", // Seeds (Saatgut)
|
||||
706: "543561", // Stecklinge (cuttings) – ebenfalls Pflanzen/Saatgut
|
||||
376: "2802", // Grow-Sets – Pflanzen- & Kräuteranbausets
|
||||
915: "2802", // Grow-Sets > Set-Zubehör – Pflanzen- & Kräuteranbausets
|
||||
|
||||
// Headshop & Accessories
|
||||
709: "4082", // Headshop – Rauchzubehör
|
||||
@@ -129,8 +130,13 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
714: "4082", // Headshop > Bongs > Zubehör – Rauchzubehör
|
||||
748: "4082", // Headshop > Bongs > Köpfe – Rauchzubehör
|
||||
749: "4082", // Headshop > Bongs > Chillums/Diffusoren/Kupplungen – Rauchzubehör
|
||||
921: "4082", // Headshop > Pfeifen – Rauchzubehör
|
||||
924: "4082", // Headshop > Dabbing – Rauchzubehör
|
||||
896: "3151", // Headshop > Vaporizer – Vaporizer
|
||||
923: "4082", // Headshop > Papes & Blunts – Rauchzubehör
|
||||
710: "5109", // Headshop > Grinder – Gewürzmühlen (Küchenhelfer)
|
||||
922: "4082", // Headshop > Aktivkohlefilter & Tips – Rauchzubehör
|
||||
916: "4082", // Headshop > Rollen & Bauen – Rauchzubehör
|
||||
|
||||
// Measuring & Packaging
|
||||
186: "5631", // Headshop > Wiegen & Verpacken – Aufbewahrung/Zubehör
|
||||
@@ -140,6 +146,7 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
407: "3561", // Headshop > Grove Bags – Aufbewahrungsbehälter
|
||||
449: "1496", // Headshop > Cliptütchen – Lebensmittelverpackungsmaterial
|
||||
539: "3110", // Headshop > Gläser & Dosen – Lebensmittelbehälter
|
||||
920: "581", // Headshop > Räucherstäbchen – Raumdüfte (Home Fragrances)
|
||||
|
||||
// Lighting & Equipment
|
||||
694: "3006", // Lampen – Lampen (Beleuchtung)
|
||||
@@ -248,9 +255,9 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
let productsXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss xmlns:g="http://base.google.com/ns/1.0" version="2.0">
|
||||
<channel>
|
||||
<title>${config.descriptions.short}</title>
|
||||
<title>${config.descriptions.de.short}</title>
|
||||
<link>${baseUrl}</link>
|
||||
<description>${config.descriptions.short}</description>
|
||||
<description>${config.descriptions.de.short}</description>
|
||||
<lastBuildDate>${currentDate}</lastBuildDate>
|
||||
<language>de-DE</language>`;
|
||||
|
||||
@@ -299,19 +306,43 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
let processedCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
// Track products with missing data for logging
|
||||
// Track skip reasons with counts and product lists
|
||||
const skipReasons = {
|
||||
noProductOrSeoName: { count: 0, products: [] },
|
||||
excludedCategory: { count: 0, products: [] },
|
||||
excludedTermsTitle: { count: 0, products: [] },
|
||||
excludedTermsDescription: { count: 0, products: [] },
|
||||
missingGTIN: { count: 0, products: [] },
|
||||
invalidGTINChecksum: { count: 0, products: [] },
|
||||
missingPicture: { count: 0, products: [] },
|
||||
missingWeight: { count: 0, products: [] },
|
||||
insufficientDescription: { count: 0, products: [] },
|
||||
nameTooShort: { count: 0, products: [] },
|
||||
outOfStock: { count: 0, products: [] },
|
||||
zeroPriceOrInvalid: { count: 0, products: [] },
|
||||
processingError: { count: 0, products: [] }
|
||||
};
|
||||
|
||||
// Legacy arrays for backward compatibility
|
||||
const productsNeedingWeight = [];
|
||||
const productsNeedingDescription = [];
|
||||
|
||||
// Category IDs to skip (seeds, plants, headshop items)
|
||||
const skipCategoryIds = [689, 706, 709, 711, 714, 748, 749, 896, 710];
|
||||
const skipCategoryIds = [689, 706, 709, 711, 714, 748, 749, 896, 710, 924, 923, 922, 921, 916, 278, 259, 258];
|
||||
|
||||
// Add each product as an item
|
||||
allProductsData.forEach((product, index) => {
|
||||
|
||||
try {
|
||||
// Skip products without essential data
|
||||
if (!product || !product.seoName) {
|
||||
skippedCount++;
|
||||
skipReasons.noProductOrSeoName.count++;
|
||||
skipReasons.noProductOrSeoName.products.push({
|
||||
id: product?.articleNumber || 'N/A',
|
||||
name: product?.name || 'N/A',
|
||||
url: product?.seoName ? `/Artikel/${product.seoName}` : 'N/A'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -319,12 +350,21 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
const productCategoryId = product.categoryId || product.category_id || product.category || null;
|
||||
if (productCategoryId && skipCategoryIds.includes(parseInt(productCategoryId))) {
|
||||
skippedCount++;
|
||||
skipReasons.excludedCategory.count++;
|
||||
skipReasons.excludedCategory.products.push({
|
||||
id: product.articleNumber || product.seoName,
|
||||
name: product.name || 'N/A',
|
||||
categoryId: productCategoryId,
|
||||
url: `/Artikel/${product.seoName}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip products with excluded terms in title or description
|
||||
const productTitle = (product.name || "").toLowerCase();
|
||||
const productDescription = (product.description || "").toLowerCase();
|
||||
|
||||
// Get description early so we can check it for excluded terms
|
||||
const productDescription = product.kurzBeschreibung || product.description || '';
|
||||
|
||||
const excludedTerms = {
|
||||
title: ['canna', 'hash', 'marijuana', 'marihuana'],
|
||||
@@ -332,20 +372,42 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
};
|
||||
|
||||
// Check title for excluded terms
|
||||
if (excludedTerms.title.some(term => productTitle.includes(term))) {
|
||||
const excludedTitleTerm = excludedTerms.title.find(term => productTitle.includes(term));
|
||||
if (excludedTitleTerm) {
|
||||
skippedCount++;
|
||||
skipReasons.excludedTermsTitle.count++;
|
||||
skipReasons.excludedTermsTitle.products.push({
|
||||
id: product.articleNumber || product.seoName,
|
||||
name: product.name || 'N/A',
|
||||
term: excludedTitleTerm,
|
||||
url: `/Artikel/${product.seoName}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check description for excluded terms
|
||||
if (excludedTerms.description.some(term => productDescription.includes(term))) {
|
||||
const excludedDescTerm = excludedTerms.description.find(term => productDescription.toLowerCase().includes(term));
|
||||
if (excludedDescTerm) {
|
||||
skippedCount++;
|
||||
skipReasons.excludedTermsDescription.count++;
|
||||
skipReasons.excludedTermsDescription.products.push({
|
||||
id: product.articleNumber || product.seoName,
|
||||
name: product.name || 'N/A',
|
||||
term: excludedDescTerm,
|
||||
url: `/Artikel/${product.seoName}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip products without GTIN or with invalid GTIN
|
||||
if (!product.gtin || !product.gtin.toString().trim()) {
|
||||
skippedCount++;
|
||||
skipReasons.missingGTIN.count++;
|
||||
skipReasons.missingGTIN.products.push({
|
||||
id: product.articleNumber || product.seoName,
|
||||
name: product.name || 'N/A',
|
||||
url: `/Artikel/${product.seoName}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -360,15 +422,33 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
const length = digits.length;
|
||||
let sum = 0;
|
||||
|
||||
for (let i = 0; i < length - 1; i++) {
|
||||
// Even/odd multiplier depends on GTIN length
|
||||
let multiplier = 1;
|
||||
if (length === 8) {
|
||||
multiplier = (i % 2 === 0) ? 3 : 1;
|
||||
} else {
|
||||
multiplier = ((length - i) % 2 === 0) ? 3 : 1;
|
||||
if (length === 8) {
|
||||
// EAN-8: positions 0-6, check digit at 7
|
||||
// Multipliers: 3,1,3,1,3,1,3 for positions 0-6
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const multiplier = (i % 2 === 0) ? 3 : 1;
|
||||
sum += digits[i] * multiplier;
|
||||
}
|
||||
} else if (length === 12) {
|
||||
// UPC-A: positions 0-10, check digit at 11
|
||||
// Multipliers: 3,1,3,1,3,1,3,1,3,1,3 for positions 0-10
|
||||
for (let i = 0; i < 11; i++) {
|
||||
const multiplier = (i % 2 === 0) ? 3 : 1;
|
||||
sum += digits[i] * multiplier;
|
||||
}
|
||||
} else if (length === 13) {
|
||||
// EAN-13: positions 0-11, check digit at 12
|
||||
// Multipliers: 1,3,1,3,1,3,1,3,1,3,1,3 for positions 0-11
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const multiplier = (i % 2 === 0) ? 1 : 3;
|
||||
sum += digits[i] * multiplier;
|
||||
}
|
||||
} else if (length === 14) {
|
||||
// EAN-14: similar to EAN-13 but 14 digits
|
||||
for (let i = 0; i < 13; i++) {
|
||||
const multiplier = (i % 2 === 0) ? 1 : 3;
|
||||
sum += digits[i] * multiplier;
|
||||
}
|
||||
sum += digits[i] * multiplier;
|
||||
}
|
||||
const checkDigit = (10 - (sum % 10)) % 10;
|
||||
return checkDigit === digits[length - 1];
|
||||
@@ -376,43 +456,62 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
|
||||
if (!isValidGTIN(gtinString)) {
|
||||
skippedCount++;
|
||||
skipReasons.invalidGTINChecksum.count++;
|
||||
skipReasons.invalidGTINChecksum.products.push({
|
||||
id: product.articleNumber || product.seoName,
|
||||
name: product.name || 'N/A',
|
||||
gtin: gtinString,
|
||||
url: `/Artikel/${product.seoName}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip products without pictures
|
||||
if (!product.pictureList || !product.pictureList.trim()) {
|
||||
skippedCount++;
|
||||
skipReasons.missingPicture.count++;
|
||||
skipReasons.missingPicture.products.push({
|
||||
id: product.articleNumber || product.seoName,
|
||||
name: product.name || 'N/A',
|
||||
url: `/Artikel/${product.seoName}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if product has weight data - validate BEFORE building XML
|
||||
if (!product.weight || isNaN(product.weight)) {
|
||||
// Track products without weight
|
||||
productsNeedingWeight.push({
|
||||
const productInfo = {
|
||||
id: product.articleNumber || product.seoName,
|
||||
name: product.name || 'Unnamed',
|
||||
url: `/Artikel/${product.seoName}`
|
||||
});
|
||||
};
|
||||
productsNeedingWeight.push(productInfo);
|
||||
skipReasons.missingWeight.count++;
|
||||
skipReasons.missingWeight.products.push(productInfo);
|
||||
skippedCount++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if description is missing or too short (less than 20 characters) - skip if insufficient
|
||||
const originalDescription = product.description ? cleanTextContent(product.description) : '';
|
||||
const originalDescription = productDescription ? cleanTextContent(productDescription) : '';
|
||||
if (!originalDescription || originalDescription.length < 20) {
|
||||
productsNeedingDescription.push({
|
||||
const productInfo = {
|
||||
id: product.articleNumber || product.seoName,
|
||||
name: product.name || 'Unnamed',
|
||||
currentDescription: originalDescription || 'NONE',
|
||||
url: `/Artikel/${product.seoName}`
|
||||
});
|
||||
};
|
||||
productsNeedingDescription.push(productInfo);
|
||||
skipReasons.insufficientDescription.count++;
|
||||
skipReasons.insufficientDescription.products.push(productInfo);
|
||||
skippedCount++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean description for feed (remove HTML tags and limit length)
|
||||
const rawDescription = cleanTextContent(product.description).substring(0, 500);
|
||||
const cleanDescription = escapeXml(rawDescription) || "Produktbeschreibung nicht verfügbar";
|
||||
const feedDescription = cleanTextContent(productDescription).substring(0, 500);
|
||||
const cleanDescription = escapeXml(feedDescription) || "Produktbeschreibung nicht verfügbar";
|
||||
|
||||
// Clean product name
|
||||
const rawName = product.name || "Unnamed Product";
|
||||
@@ -421,6 +520,13 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
// Validate essential fields
|
||||
if (!cleanName || cleanName.length < 2) {
|
||||
skippedCount++;
|
||||
skipReasons.nameTooShort.count++;
|
||||
skipReasons.nameTooShort.products.push({
|
||||
id: product.articleNumber || product.seoName,
|
||||
name: rawName,
|
||||
cleanedName: cleanName,
|
||||
url: `/Artikel/${product.seoName}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -429,7 +535,7 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
|
||||
// Generate image URL
|
||||
const imageUrl = product.pictureList && product.pictureList.trim()
|
||||
? `${baseUrl}/assets/images/prod${product.pictureList.split(",")[0].trim()}.jpg`
|
||||
? `${baseUrl}/assets/images/prod${product.pictureList.split(",")[0].trim()}.avif`
|
||||
: `${baseUrl}/assets/images/nopicture.jpg`;
|
||||
|
||||
// Generate brand (manufacturer)
|
||||
@@ -445,6 +551,12 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
// Skip products that are out of stock
|
||||
if (!product.available) {
|
||||
skippedCount++;
|
||||
skipReasons.outOfStock.count++;
|
||||
skipReasons.outOfStock.products.push({
|
||||
id: product.articleNumber || product.seoName,
|
||||
name: product.name || 'N/A',
|
||||
url: `/Artikel/${product.seoName}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -456,6 +568,13 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
// Skip products with price == 0
|
||||
if (!product.price || parseFloat(product.price) === 0) {
|
||||
skippedCount++;
|
||||
skipReasons.zeroPriceOrInvalid.count++;
|
||||
skipReasons.zeroPriceOrInvalid.products.push({
|
||||
id: product.articleNumber || product.seoName,
|
||||
name: product.name || 'N/A',
|
||||
price: product.price,
|
||||
url: `/Artikel/${product.seoName}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -522,6 +641,13 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
} catch (itemError) {
|
||||
console.log(` ⚠️ Skipped product ${index + 1}: ${itemError.message}`);
|
||||
skippedCount++;
|
||||
skipReasons.processingError.count++;
|
||||
skipReasons.processingError.products.push({
|
||||
id: product?.articleNumber || product?.seoName || 'N/A',
|
||||
name: product?.name || 'N/A',
|
||||
error: itemError.message,
|
||||
url: product?.seoName ? `/Artikel/${product.seoName}` : 'N/A'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -529,7 +655,43 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
console.log(` 📊 Processing summary: ${processedCount} products included, ${skippedCount} skipped`);
|
||||
console.log(`\n 📊 Processing summary: ${processedCount} products included, ${skippedCount} skipped`);
|
||||
|
||||
// Display skip reason totals
|
||||
console.log(`\n 📋 Skip Reasons Breakdown:`);
|
||||
console.log(` ────────────────────────────────────────────────────────────`);
|
||||
|
||||
const skipReasonLabels = {
|
||||
noProductOrSeoName: 'No Product or SEO Name',
|
||||
excludedCategory: 'Excluded Category',
|
||||
excludedTermsTitle: 'Excluded Terms in Title',
|
||||
excludedTermsDescription: 'Excluded Terms in Description',
|
||||
missingGTIN: 'Missing GTIN',
|
||||
invalidGTINChecksum: 'Invalid GTIN Checksum',
|
||||
missingPicture: 'Missing Picture',
|
||||
missingWeight: 'Missing Weight',
|
||||
insufficientDescription: 'Insufficient Description',
|
||||
nameTooShort: 'Name Too Short',
|
||||
outOfStock: 'Out of Stock',
|
||||
zeroPriceOrInvalid: 'Zero or Invalid Price',
|
||||
processingError: 'Processing Error'
|
||||
};
|
||||
|
||||
let hasAnySkips = false;
|
||||
Object.entries(skipReasons).forEach(([key, data]) => {
|
||||
if (data.count > 0) {
|
||||
hasAnySkips = true;
|
||||
const label = skipReasonLabels[key] || key;
|
||||
console.log(` • ${label}: ${data.count}`);
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasAnySkips) {
|
||||
console.log(` ✅ No products were skipped`);
|
||||
}
|
||||
|
||||
console.log(` ────────────────────────────────────────────────────────────`);
|
||||
console.log(` Total: ${skippedCount} products skipped\n`);
|
||||
|
||||
// Write log files for products needing attention
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
@@ -540,7 +702,56 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Write missing weight log
|
||||
// Write comprehensive skip reasons log
|
||||
const skipLogPath = path.join(logsDir, `skip-reasons-${timestamp}.log`);
|
||||
let skipLogContent = `# Product Skip Reasons Report
|
||||
# Generated: ${new Date().toISOString()}
|
||||
# Total products processed: ${processedCount}
|
||||
# Total products skipped: ${skippedCount}
|
||||
# Base URL: ${baseUrl}
|
||||
|
||||
`;
|
||||
|
||||
Object.entries(skipReasons).forEach(([key, data]) => {
|
||||
if (data.count > 0) {
|
||||
const label = skipReasonLabels[key] || key;
|
||||
skipLogContent += `\n## ${label} (${data.count} products)\n`;
|
||||
skipLogContent += `${'='.repeat(80)}\n`;
|
||||
|
||||
data.products.forEach(product => {
|
||||
skipLogContent += `ID: ${product.id}\n`;
|
||||
skipLogContent += `Name: ${product.name}\n`;
|
||||
if (product.categoryId !== undefined) {
|
||||
skipLogContent += `Category ID: ${product.categoryId}\n`;
|
||||
}
|
||||
if (product.term !== undefined) {
|
||||
skipLogContent += `Excluded Term: ${product.term}\n`;
|
||||
}
|
||||
if (product.gtin !== undefined) {
|
||||
skipLogContent += `GTIN: ${product.gtin}\n`;
|
||||
}
|
||||
if (product.currentDescription !== undefined) {
|
||||
skipLogContent += `Current Description: "${product.currentDescription}"\n`;
|
||||
}
|
||||
if (product.cleanedName !== undefined) {
|
||||
skipLogContent += `Cleaned Name: "${product.cleanedName}"\n`;
|
||||
}
|
||||
if (product.price !== undefined) {
|
||||
skipLogContent += `Price: ${product.price}\n`;
|
||||
}
|
||||
if (product.error !== undefined) {
|
||||
skipLogContent += `Error: ${product.error}\n`;
|
||||
}
|
||||
skipLogContent += `URL: ${baseUrl}${product.url}\n`;
|
||||
skipLogContent += `${'-'.repeat(80)}\n`;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
fs.writeFileSync(skipLogPath, skipLogContent, 'utf8');
|
||||
console.log(` 📄 Detailed skip reasons report saved to: ${skipLogPath}`);
|
||||
|
||||
// Write missing weight log (for backward compatibility)
|
||||
if (productsNeedingWeight.length > 0) {
|
||||
const weightLogContent = `# Products Missing Weight Data
|
||||
# Generated: ${new Date().toISOString()}
|
||||
@@ -551,10 +762,10 @@ ${productsNeedingWeight.map(product => `${product.id}\t${product.name}\t${baseUr
|
||||
|
||||
const weightLogPath = path.join(logsDir, `missing-weight-${timestamp}.log`);
|
||||
fs.writeFileSync(weightLogPath, weightLogContent, 'utf8');
|
||||
console.log(`\n ⚠️ Products missing weight (${productsNeedingWeight.length}) - saved to: ${weightLogPath}`);
|
||||
console.log(` ⚠️ Products missing weight (${productsNeedingWeight.length}) - saved to: ${weightLogPath}`);
|
||||
}
|
||||
|
||||
// Write missing description log
|
||||
// Write missing description log (for backward compatibility)
|
||||
if (productsNeedingDescription.length > 0) {
|
||||
const descLogContent = `# Products With Insufficient Description Data
|
||||
# Generated: ${new Date().toISOString()}
|
||||
@@ -565,7 +776,7 @@ ${productsNeedingDescription.map(product => `${product.id}\t${product.name}\t"${
|
||||
|
||||
const descLogPath = path.join(logsDir, `missing-description-${timestamp}.log`);
|
||||
fs.writeFileSync(descLogPath, descLogContent, 'utf8');
|
||||
console.log(`\n ⚠️ Products with insufficient description (${productsNeedingDescription.length}) - saved to: ${descLogPath}`);
|
||||
console.log(` ⚠️ Products with insufficient description (${productsNeedingDescription.length}) - saved to: ${descLogPath}`);
|
||||
}
|
||||
|
||||
if (productsNeedingWeight.length === 0 && productsNeedingDescription.length === 0) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const generateHomepageMetaTags = (baseUrl, config) => {
|
||||
const description = config.descriptions.long;
|
||||
const keywords = config.keywords;
|
||||
const description = config.descriptions.de.long;
|
||||
const keywords = config.keywords.de;
|
||||
const imageUrl = `${baseUrl}${config.images.logo}`;
|
||||
|
||||
// Ensure URLs are properly formatted
|
||||
@@ -12,7 +12,7 @@ const generateHomepageMetaTags = (baseUrl, config) => {
|
||||
<meta name="keywords" content="${keywords}">
|
||||
|
||||
<!-- Open Graph Meta Tags -->
|
||||
<meta property="og:title" content="${config.descriptions.short}">
|
||||
<meta property="og:title" content="${config.descriptions.de.short}">
|
||||
<meta property="og:description" content="${description}">
|
||||
<meta property="og:image" content="${imageUrl}">
|
||||
<meta property="og:url" content="${canonicalUrl}">
|
||||
@@ -21,7 +21,7 @@ const generateHomepageMetaTags = (baseUrl, config) => {
|
||||
|
||||
<!-- Twitter Card Meta Tags -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="${config.descriptions.short}">
|
||||
<meta name="twitter:title" content="${config.descriptions.de.short}">
|
||||
<meta name="twitter:description" content="${description}">
|
||||
<meta name="twitter:image" content="${imageUrl}">
|
||||
|
||||
@@ -41,7 +41,7 @@ const generateHomepageJsonLd = (baseUrl, config, categories = []) => {
|
||||
"@type": "WebSite",
|
||||
name: config.brandName,
|
||||
url: canonicalUrl,
|
||||
description: config.descriptions.long,
|
||||
description: config.descriptions.de.long,
|
||||
publisher: {
|
||||
"@type": "Organization",
|
||||
name: config.brandName,
|
||||
@@ -73,7 +73,7 @@ const generateHomepageJsonLd = (baseUrl, config, categories = []) => {
|
||||
"@type": "LocalBusiness",
|
||||
"name": config.brandName,
|
||||
"alternateName": config.siteName,
|
||||
"description": config.descriptions.long,
|
||||
"description": config.descriptions.de.long,
|
||||
"url": canonicalUrl,
|
||||
"logo": logoUrl,
|
||||
"image": logoUrl,
|
||||
|
||||
@@ -31,6 +31,7 @@ const {
|
||||
generateLlmsTxt,
|
||||
generateCategoryLlmsTxt,
|
||||
generateAllCategoryLlmsPages,
|
||||
generateCategoryProductList,
|
||||
} = require('./llms.cjs');
|
||||
|
||||
// Export all functions for use in the main application
|
||||
@@ -61,4 +62,5 @@ module.exports = {
|
||||
generateLlmsTxt,
|
||||
generateCategoryLlmsTxt,
|
||||
generateAllCategoryLlmsPages,
|
||||
generateCategoryProductList,
|
||||
};
|
||||
@@ -254,17 +254,52 @@ This category currently contains no products.
|
||||
return categoryLlmsTxt;
|
||||
};
|
||||
|
||||
// Helper function to generate a simple product list for a category
|
||||
const generateCategoryProductList = (category, categoryProducts = []) => {
|
||||
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
||||
const fileName = `llms-${categorySlug}-list.txt`;
|
||||
|
||||
const subcategoryIds = (category.subcategories || []).join(',');
|
||||
let content = `${String(category.name)},${String(category.id)},[${subcategoryIds}]\n`;
|
||||
|
||||
categoryProducts.forEach((product) => {
|
||||
const artnr = String(product.articleNumber || '');
|
||||
const price = String(product.price || '0.00');
|
||||
const name = String(product.name || '');
|
||||
const kurzBeschreibung = String(product.kurzBeschreibung || '');
|
||||
|
||||
// Escape commas in fields by wrapping in quotes if they contain commas
|
||||
const escapeField = (field) => {
|
||||
const fieldStr = String(field || '');
|
||||
if (fieldStr.includes(',')) {
|
||||
return `"${fieldStr.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return fieldStr;
|
||||
};
|
||||
|
||||
content += `${escapeField(artnr)},${escapeField(price)},${escapeField(name)},${escapeField(kurzBeschreibung)}\n`;
|
||||
});
|
||||
|
||||
return {
|
||||
fileName,
|
||||
content,
|
||||
categoryName: category.name,
|
||||
categoryId: category.id,
|
||||
productCount: categoryProducts.length
|
||||
};
|
||||
};
|
||||
|
||||
// Helper function to generate all pages for a category
|
||||
const generateAllCategoryLlmsPages = (category, categoryProducts = [], baseUrl, config, productsPerPage = 50) => {
|
||||
const totalProducts = categoryProducts.length;
|
||||
const totalPages = Math.ceil(totalProducts / productsPerPage);
|
||||
const pages = [];
|
||||
|
||||
|
||||
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber++) {
|
||||
const pageContent = generateCategoryLlmsTxt(category, categoryProducts, baseUrl, config, pageNumber, productsPerPage);
|
||||
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
||||
const fileName = `llms-${categorySlug}-page-${pageNumber}.txt`;
|
||||
|
||||
|
||||
pages.push({
|
||||
fileName,
|
||||
content: pageContent,
|
||||
@@ -272,7 +307,7 @@ const generateAllCategoryLlmsPages = (category, categoryProducts = [], baseUrl,
|
||||
totalPages
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return pages;
|
||||
};
|
||||
|
||||
@@ -280,4 +315,5 @@ module.exports = {
|
||||
generateLlmsTxt,
|
||||
generateCategoryLlmsTxt,
|
||||
generateAllCategoryLlmsPages,
|
||||
generateCategoryProductList,
|
||||
};
|
||||
@@ -5,7 +5,7 @@ const generateProductMetaTags = (product, baseUrl, config) => {
|
||||
product.pictureList && product.pictureList.trim()
|
||||
? `${baseUrl}/assets/images/prod${product.pictureList
|
||||
.split(",")[0]
|
||||
.trim()}.jpg`
|
||||
.trim()}.avif`
|
||||
: `${baseUrl}/assets/images/nopicture.jpg`;
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) =>
|
||||
product.pictureList && product.pictureList.trim()
|
||||
? `${baseUrl}/assets/images/prod${product.pictureList
|
||||
.split(",")[0]
|
||||
.trim()}.jpg`
|
||||
.trim()}.avif`
|
||||
: `${baseUrl}/assets/images/nopicture.jpg`;
|
||||
|
||||
// Clean description for JSON-LD (remove HTML tags)
|
||||
@@ -106,6 +106,41 @@ const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) =>
|
||||
"@type": "Organization",
|
||||
name: config.brandName,
|
||||
},
|
||||
hasMerchantReturnPolicy: {
|
||||
"@type": "MerchantReturnPolicy",
|
||||
applicableCountry: "DE",
|
||||
returnPolicyCategory: "https://schema.org/MerchantReturnFiniteReturnWindow",
|
||||
merchantReturnDays: 14,
|
||||
returnMethod: "https://schema.org/ReturnByMail",
|
||||
returnFees: "https://schema.org/FreeReturn",
|
||||
},
|
||||
shippingDetails: {
|
||||
"@type": "OfferShippingDetails",
|
||||
shippingRate: {
|
||||
"@type": "MonetaryAmount",
|
||||
value: 5.90,
|
||||
currency: "EUR",
|
||||
},
|
||||
shippingDestination: {
|
||||
"@type": "DefinedRegion",
|
||||
addressCountry: "DE",
|
||||
},
|
||||
deliveryTime: {
|
||||
"@type": "ShippingDeliveryTime",
|
||||
handlingTime: {
|
||||
"@type": "QuantitativeValue",
|
||||
minValue: 0,
|
||||
maxValue: 1,
|
||||
unitCode: "DAY",
|
||||
},
|
||||
transitTime: {
|
||||
"@type": "QuantitativeValue",
|
||||
minValue: 2,
|
||||
maxValue: 3,
|
||||
unitCode: "DAY",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -7,11 +7,17 @@ const collectAllCategories = (categoryNode, categories = []) => {
|
||||
|
||||
// Add current category (skip root category 209)
|
||||
if (categoryNode.id !== 209) {
|
||||
// Extract subcategory IDs from children
|
||||
const subcategoryIds = categoryNode.children
|
||||
? categoryNode.children.map(child => child.id)
|
||||
: [];
|
||||
|
||||
categories.push({
|
||||
id: categoryNode.id,
|
||||
name: categoryNode.name,
|
||||
seoName: categoryNode.seoName,
|
||||
parentId: categoryNode.parentId
|
||||
parentId: categoryNode.parentId,
|
||||
subcategories: subcategoryIds
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
83
process_llms_cat.cjs
Normal file
@@ -0,0 +1,83 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Read the input file
|
||||
const inputFile = path.join(__dirname, 'dist', 'llms-cat.txt');
|
||||
const outputFile = path.join(__dirname, 'output.csv');
|
||||
|
||||
// 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 {
|
||||
const data = fs.readFileSync(inputFile, 'utf8');
|
||||
const lines = data.trim().split('\n');
|
||||
|
||||
const outputLines = ['URL,SEO Description'];
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim() === '') continue;
|
||||
|
||||
// Parse the CSV line properly handling escaped quotes
|
||||
const fields = parseCSVLine(line);
|
||||
|
||||
if (fields.length !== 3) {
|
||||
console.warn(`Skipping malformed line (got ${fields.length} fields): ${line.substring(0, 100)}...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const [field1, field2, field3] = fields;
|
||||
const url = field2;
|
||||
|
||||
// field3 is a JSON string - parse it directly
|
||||
let seoDescription = '';
|
||||
try {
|
||||
const parsed = JSON.parse(field3);
|
||||
seoDescription = parsed.seo_description || '';
|
||||
} catch (e) {
|
||||
console.warn(`Failed to parse JSON for URL ${url}: ${e.message}`);
|
||||
console.warn(`JSON string: ${field3.substring(0, 200)}...`);
|
||||
}
|
||||
|
||||
// Escape quotes for CSV output - URL doesn't need quotes, description does
|
||||
const escapedDescription = '"' + seoDescription.replace(/"/g, '""') + '"';
|
||||
|
||||
outputLines.push(`${url},${escapedDescription}`);
|
||||
}
|
||||
|
||||
// Write the output CSV
|
||||
fs.writeFileSync(outputFile, outputLines.join('\n'), 'utf8');
|
||||
console.log(`Processed ${lines.length} lines and created ${outputFile}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing file:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
BIN
public/assets/images/cutlings.avif
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
public/assets/images/gg.avif
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
public/assets/images/konfigurator.avif
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
public/assets/images/konfigurator.png
Normal file
|
After Width: | Height: | Size: 362 KiB |
BIN
public/assets/images/maps.avif
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
public/assets/images/seeds.avif
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
public/assets/images/sh.avif
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
90
public/llms-cat.txt
Normal file
@@ -0,0 +1,90 @@
|
||||
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 5x–100x 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 1–8L 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, Dabbing‑Zubehör, Papes, Grinder, Filtern, Waagen, Rolling Trays & Räucherstäbchen – alles für dein Rauch‑Setup."
|
||||
"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."
|
||||
61
scripts/convert-images-to-avif.cjs
Normal 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();
|
||||
26
scripts/convert-logo-to-avif.js
Normal 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();
|
||||
|
||||
42
src/App.js
@@ -18,6 +18,9 @@ import PaletteIcon from "@mui/icons-material/Palette";
|
||||
import ScienceIcon from "@mui/icons-material/Science";
|
||||
|
||||
import { CarouselProvider } from "./contexts/CarouselContext.js";
|
||||
import { ProductContextProvider } from "./context/ProductContext.js";
|
||||
import { CategoryContextProvider } from "./context/CategoryContext.js";
|
||||
import TitleUpdater from "./components/TitleUpdater.js";
|
||||
import config from "./config.js";
|
||||
import ScrollToTop from "./components/ScrollToTop.js";
|
||||
|
||||
@@ -47,6 +50,7 @@ const Datenschutz = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/D
|
||||
const AGB = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/AGB.js"));
|
||||
//const NotFound404 = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/NotFound404.js")); <Route path="/404" element={<NotFound404 />} />
|
||||
const Sitemap = lazy(() => import(/* webpackChunkName: "sitemap" */ "./pages/Sitemap.js"));
|
||||
const CategoriesPage = lazy(() => import(/* webpackChunkName: "categories" */ "./pages/CategoriesPage.js"));
|
||||
const Impressum = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Impressum.js"));
|
||||
const Batteriegesetzhinweise = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Batteriegesetzhinweise.js"));
|
||||
const Widerrufsrecht = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Widerrufsrecht.js"));
|
||||
@@ -222,9 +226,10 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
|
||||
bgcolor: "background.default",
|
||||
}}
|
||||
>
|
||||
<TitleUpdater />
|
||||
<ScrollToTop />
|
||||
<Header active categoryId={categoryId} key={authVersion} />
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Box component="main" sx={{ flexGrow: 1 }}>
|
||||
<Suspense fallback={
|
||||
// Use prerender fallback if available, otherwise show loading spinner
|
||||
typeof window !== "undefined" && window.__PRERENDER_FALLBACK__ ? (
|
||||
@@ -256,19 +261,19 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
|
||||
{/* Category page - Render Content in parallel */}
|
||||
<Route
|
||||
path="/Kategorie/:categoryId"
|
||||
element={<Content/>}
|
||||
element={<Content />}
|
||||
/>
|
||||
{/* Single product page */}
|
||||
<Route
|
||||
path="/Artikel/:seoName"
|
||||
element={<ProductDetail/>}
|
||||
element={<ProductDetail />}
|
||||
/>
|
||||
|
||||
{/* Search page - Render Content in parallel */}
|
||||
<Route path="/search" element={<Content/>} />
|
||||
<Route path="/search" element={<Content />} />
|
||||
|
||||
{/* Profile page */}
|
||||
<Route path="/profile" element={<ProfilePage/>} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
|
||||
{/* Payment success page for Mollie redirects */}
|
||||
<Route path="/payment/success" element={<PaymentSuccess />} />
|
||||
@@ -276,22 +281,23 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
|
||||
{/* Reset password page */}
|
||||
<Route
|
||||
path="/resetPassword"
|
||||
element={<ResetPassword/>}
|
||||
element={<ResetPassword />}
|
||||
/>
|
||||
|
||||
{/* Admin page */}
|
||||
<Route path="/admin" element={<AdminPage/>} />
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
|
||||
{/* Admin Users page */}
|
||||
<Route path="/admin/users" element={<UsersPage/>} />
|
||||
<Route path="/admin/users" element={<UsersPage />} />
|
||||
|
||||
{/* Admin Server Logs page */}
|
||||
<Route path="/admin/logs" element={<ServerLogsPage/>} />
|
||||
<Route path="/admin/logs" element={<ServerLogsPage />} />
|
||||
|
||||
{/* Legal pages */}
|
||||
<Route path="/datenschutz" element={<Datenschutz />} />
|
||||
<Route path="/agb" element={<AGB />} />
|
||||
<Route path="/sitemap" element={<Sitemap />} />
|
||||
<Route path="/Kategorien" element={<CategoriesPage />} />
|
||||
<Route path="/impressum" element={<Impressum />} />
|
||||
<Route
|
||||
path="/batteriegesetzhinweise"
|
||||
@@ -300,7 +306,7 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
|
||||
<Route path="/widerrufsrecht" element={<Widerrufsrecht />} />
|
||||
|
||||
{/* Grow Tent Configurator */}
|
||||
<Route path="/Konfigurator" element={<GrowTentKonfigurator/>} />
|
||||
<Route path="/Konfigurator" element={<GrowTentKonfigurator />} />
|
||||
|
||||
{/* Separate pages that are truly different */}
|
||||
<Route path="/presseverleih" element={<PresseverleihPage />} />
|
||||
@@ -450,12 +456,16 @@ const App = () => {
|
||||
return (
|
||||
<LanguageProvider i18n={i18n}>
|
||||
<ThemeProvider theme={dynamicTheme}>
|
||||
<CssBaseline />
|
||||
<AppContent
|
||||
currentTheme={currentTheme}
|
||||
dynamicTheme={dynamicTheme}
|
||||
onThemeChange={handleThemeChange}
|
||||
/>
|
||||
<ProductContextProvider>
|
||||
<CategoryContextProvider>
|
||||
<CssBaseline />
|
||||
<AppContent
|
||||
currentTheme={currentTheme}
|
||||
dynamicTheme={dynamicTheme}
|
||||
onThemeChange={handleThemeChange}
|
||||
/>
|
||||
</CategoryContextProvider>
|
||||
</ProductContextProvider>
|
||||
</ThemeProvider>
|
||||
</LanguageProvider>
|
||||
);
|
||||
|
||||
@@ -44,7 +44,7 @@ const PrerenderAppContent = (socket) => (
|
||||
<CategoryList categoryId={209} activeCategoryId={null} socket={socket}/>
|
||||
</AppBar>
|
||||
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Box component="main" sx={{ flexGrow: 1 }}>
|
||||
<CarouselProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<MainPageLayout />} />
|
||||
|
||||
118
src/PrerenderCategoriesPage.js
Normal 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;
|
||||
@@ -111,7 +111,7 @@ const PrerenderCategory = ({ categoryId, categoryName, categorySeoName: _categor
|
||||
component="img"
|
||||
height="200"
|
||||
image={product.pictureList && product.pictureList.trim()
|
||||
? `/assets/images/prod${product.pictureList.split(',')[0].trim()}.jpg`
|
||||
? `/assets/images/prod${product.pictureList.split(',')[0].trim()}.avif`
|
||||
: '/assets/images/nopicture.jpg'
|
||||
}
|
||||
alt={product.name}
|
||||
|
||||
@@ -79,7 +79,7 @@ class ArticleAvailabilityForm extends Component {
|
||||
} else {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: response.error || 'Ein Fehler ist aufgetreten'
|
||||
error: response.error || this.props.t("productDialogs.errorGeneric")
|
||||
});
|
||||
}
|
||||
|
||||
@@ -114,20 +114,21 @@ class ArticleAvailabilityForm extends Component {
|
||||
|
||||
render() {
|
||||
const { name, email, telegramId, notificationMethod, message, loading, success, error } = this.state;
|
||||
const { t } = this.props;
|
||||
|
||||
return (
|
||||
<Paper id="availability-form" sx={{ p: 3, mt: 4, borderRadius: 2, boxShadow: "0 2px 8px rgba(0,0,0,0.08)" }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ fontWeight: 600, color: '#333' }}>
|
||||
Verfügbarkeit anfragen
|
||||
{t("productDialogs.availabilityTitle")}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Dieser Artikel ist derzeit nicht verfügbar. Gerne informieren wir Sie, sobald er wieder lieferbar ist.
|
||||
{t("productDialogs.availabilitySubtitle")}
|
||||
</Typography>
|
||||
|
||||
{success && (
|
||||
<Alert severity="success" sx={{ mb: 3 }}>
|
||||
Vielen Dank für Ihre Anfrage! Wir werden Sie {notificationMethod === 'email' ? 'per E-Mail' : 'über Telegram'} informieren, sobald der Artikel wieder verfügbar ist.
|
||||
{notificationMethod === 'email' ? t("productDialogs.availabilitySuccessEmail") : t("productDialogs.availabilitySuccessTelegram")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -139,18 +140,18 @@ class ArticleAvailabilityForm extends Component {
|
||||
|
||||
<Box component="form" onSubmit={this.handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label="Name"
|
||||
label={t("productDialogs.nameLabel")}
|
||||
value={name}
|
||||
onChange={this.handleInputChange('name')}
|
||||
required
|
||||
fullWidth
|
||||
disabled={loading}
|
||||
placeholder="Ihr Name"
|
||||
placeholder={t("productDialogs.namePlaceholder")}
|
||||
/>
|
||||
|
||||
<FormControl component="fieldset" disabled={loading}>
|
||||
<FormLabel component="legend" sx={{ mb: 1 }}>
|
||||
Wie möchten Sie benachrichtigt werden?
|
||||
{t("productDialogs.notificationMethodLabel")}
|
||||
</FormLabel>
|
||||
<RadioGroup
|
||||
value={notificationMethod}
|
||||
@@ -160,51 +161,51 @@ class ArticleAvailabilityForm extends Component {
|
||||
<FormControlLabel
|
||||
value="email"
|
||||
control={<Radio />}
|
||||
label="E-Mail"
|
||||
label={t("productDialogs.emailLabel")}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="telegram"
|
||||
control={<Radio />}
|
||||
label="Telegram Bot"
|
||||
label={t("productDialogs.telegramBotLabel")}
|
||||
/>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
|
||||
{notificationMethod === 'email' && (
|
||||
<TextField
|
||||
label="E-Mail"
|
||||
label={t("productDialogs.emailLabel")}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={this.handleInputChange('email')}
|
||||
required
|
||||
fullWidth
|
||||
disabled={loading}
|
||||
placeholder="ihre.email@example.com"
|
||||
placeholder={t("productDialogs.emailPlaceholder")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{notificationMethod === 'telegram' && (
|
||||
<TextField
|
||||
label="Telegram ID"
|
||||
label={t("productDialogs.telegramIdLabel")}
|
||||
value={telegramId}
|
||||
onChange={this.handleInputChange('telegramId')}
|
||||
required
|
||||
fullWidth
|
||||
disabled={loading}
|
||||
placeholder="@IhrTelegramName oder Telegram ID"
|
||||
helperText="Geben Sie Ihren Telegram-Benutzernamen (mit @) oder Ihre Telegram-ID ein"
|
||||
placeholder={t("productDialogs.telegramPlaceholder")}
|
||||
helperText={t("productDialogs.telegramHelper")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
label="Nachricht (optional)"
|
||||
label={t("productDialogs.messageLabel")}
|
||||
value={message}
|
||||
onChange={this.handleInputChange('message')}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
disabled={loading}
|
||||
placeholder="Zusätzliche Informationen oder Fragen..."
|
||||
placeholder={t("productDialogs.messagePlaceholder")}
|
||||
/>
|
||||
|
||||
<Button
|
||||
@@ -225,10 +226,10 @@ class ArticleAvailabilityForm extends Component {
|
||||
{loading ? (
|
||||
<>
|
||||
<CircularProgress size={20} sx={{ mr: 1 }} />
|
||||
Wird gesendet...
|
||||
{t("productDialogs.sending")}
|
||||
</>
|
||||
) : (
|
||||
'Verfügbarkeit anfragen'
|
||||
t("productDialogs.submitAvailability")
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
@@ -98,7 +98,7 @@ class ArticleQuestionForm extends Component {
|
||||
} else {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: response.error || 'Ein Fehler ist aufgetreten'
|
||||
error: response.error || this.props.t("productDialogs.errorGeneric")
|
||||
});
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ class ArticleQuestionForm extends Component {
|
||||
} catch {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: 'Fehler beim Verarbeiten der Fotos'
|
||||
error: this.props.t("productDialogs.errorPhotos")
|
||||
});
|
||||
}
|
||||
|
||||
@@ -140,20 +140,21 @@ class ArticleQuestionForm extends Component {
|
||||
|
||||
render() {
|
||||
const { name, email, question, loading, success, error } = this.state;
|
||||
const { t } = this.props;
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 3, mt: 4, borderRadius: 2, boxShadow: "0 2px 8px rgba(0,0,0,0.08)" }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ fontWeight: 600, color: '#333' }}>
|
||||
Frage zum Artikel
|
||||
{t("productDialogs.questionTitle")}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Haben Sie eine Frage zu diesem Artikel? Wir helfen Ihnen gerne weiter.
|
||||
{t("productDialogs.questionSubtitle")}
|
||||
</Typography>
|
||||
|
||||
{success && (
|
||||
<Alert severity="success" sx={{ mb: 3 }}>
|
||||
Vielen Dank für Ihre Frage! Wir werden uns schnellstmöglich bei Ihnen melden.
|
||||
{t("productDialogs.questionSuccess")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -165,28 +166,28 @@ class ArticleQuestionForm extends Component {
|
||||
|
||||
<Box component="form" onSubmit={this.handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label="Name"
|
||||
label={t("productDialogs.nameLabel")}
|
||||
value={name}
|
||||
onChange={this.handleInputChange('name')}
|
||||
required
|
||||
fullWidth
|
||||
disabled={loading}
|
||||
placeholder="Ihr Name"
|
||||
placeholder={t("productDialogs.namePlaceholder")}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="E-Mail"
|
||||
label={t("productDialogs.emailLabel")}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={this.handleInputChange('email')}
|
||||
required
|
||||
fullWidth
|
||||
disabled={loading}
|
||||
placeholder="ihre.email@example.com"
|
||||
placeholder={t("productDialogs.emailPlaceholder")}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Ihre Frage"
|
||||
label={t("productDialogs.questionLabel")}
|
||||
value={question}
|
||||
onChange={this.handleInputChange('question')}
|
||||
required
|
||||
@@ -194,7 +195,7 @@ class ArticleQuestionForm extends Component {
|
||||
multiline
|
||||
rows={4}
|
||||
disabled={loading}
|
||||
placeholder="Beschreiben Sie Ihre Frage zu diesem Artikel..."
|
||||
placeholder={t("productDialogs.questionPlaceholder")}
|
||||
/>
|
||||
|
||||
<PhotoUpload
|
||||
@@ -202,7 +203,7 @@ class ArticleQuestionForm extends Component {
|
||||
onChange={this.handlePhotosChange}
|
||||
disabled={loading}
|
||||
maxFiles={3}
|
||||
label="Fotos zur Frage anhängen (optional)"
|
||||
label={t("productDialogs.photosLabelQuestion")}
|
||||
/>
|
||||
|
||||
<Button
|
||||
@@ -219,10 +220,10 @@ class ArticleQuestionForm extends Component {
|
||||
{loading ? (
|
||||
<>
|
||||
<CircularProgress size={20} sx={{ mr: 1 }} />
|
||||
Wird gesendet...
|
||||
{t("productDialogs.sending")}
|
||||
</>
|
||||
) : (
|
||||
'Frage senden'
|
||||
t("productDialogs.submitQuestion")
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
@@ -106,7 +106,7 @@ class ArticleRatingForm extends Component {
|
||||
} else {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: response.error || 'Ein Fehler ist aufgetreten'
|
||||
error: response.error || this.props.t("productDialogs.errorGeneric")
|
||||
});
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ class ArticleRatingForm extends Component {
|
||||
} catch {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: 'Fehler beim Verarbeiten der Fotos'
|
||||
error: this.props.t("productDialogs.errorPhotos")
|
||||
});
|
||||
}
|
||||
|
||||
@@ -149,20 +149,21 @@ class ArticleRatingForm extends Component {
|
||||
|
||||
render() {
|
||||
const { name, email, rating, review, loading, success, error } = this.state;
|
||||
const { t } = this.props;
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 3, mt: 4, borderRadius: 2, boxShadow: "0 2px 8px rgba(0,0,0,0.08)" }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ fontWeight: 600, color: '#333' }}>
|
||||
Artikel Bewerten
|
||||
{t("productDialogs.ratingTitle")}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Teilen Sie Ihre Erfahrungen mit diesem Artikel und helfen Sie anderen Kunden bei der Entscheidung.
|
||||
{t("productDialogs.ratingSubtitle")}
|
||||
</Typography>
|
||||
|
||||
{success && (
|
||||
<Alert severity="success" sx={{ mb: 3 }}>
|
||||
Vielen Dank für Ihre Bewertung! Sie wird nach Prüfung veröffentlicht.
|
||||
{t("productDialogs.ratingSuccess")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -174,30 +175,30 @@ class ArticleRatingForm extends Component {
|
||||
|
||||
<Box component="form" onSubmit={this.handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label="Name"
|
||||
label={t("productDialogs.nameLabel")}
|
||||
value={name}
|
||||
onChange={this.handleInputChange('name')}
|
||||
required
|
||||
fullWidth
|
||||
disabled={loading}
|
||||
placeholder="Ihr Name"
|
||||
placeholder={t("productDialogs.namePlaceholder")}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="E-Mail"
|
||||
label={t("productDialogs.emailLabel")}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={this.handleInputChange('email')}
|
||||
required
|
||||
fullWidth
|
||||
disabled={loading}
|
||||
placeholder="ihre.email@example.com"
|
||||
helperText="Ihre E-Mail wird nicht veröffentlicht"
|
||||
placeholder={t("productDialogs.emailPlaceholder")}
|
||||
helperText={t("productDialogs.emailHelper")}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
Bewertung *
|
||||
{t("productDialogs.ratingLabel")}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Rating
|
||||
@@ -209,20 +210,20 @@ class ArticleRatingForm extends Component {
|
||||
emptyIcon={<StarIcon style={{ opacity: 0.55 }} fontSize="inherit" />}
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{rating > 0 ? `${rating} von 5 Sternen` : 'Bitte bewerten'}
|
||||
{rating > 0 ? t("productDialogs.ratingStars", { rating }) : t("productDialogs.pleaseRate")}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
label="Ihre Bewertung (optional)"
|
||||
label={t("productDialogs.reviewLabel")}
|
||||
value={review}
|
||||
onChange={this.handleInputChange('review')}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={4}
|
||||
disabled={loading}
|
||||
placeholder="Beschreiben Sie Ihre Erfahrungen mit diesem Artikel..."
|
||||
placeholder={t("productDialogs.reviewPlaceholder")}
|
||||
/>
|
||||
|
||||
<PhotoUpload
|
||||
@@ -230,7 +231,7 @@ class ArticleRatingForm extends Component {
|
||||
onChange={this.handlePhotosChange}
|
||||
disabled={loading}
|
||||
maxFiles={5}
|
||||
label="Fotos zur Bewertung anhängen (optional)"
|
||||
label={t("productDialogs.photosLabelRating")}
|
||||
/>
|
||||
|
||||
<Button
|
||||
@@ -247,10 +248,10 @@ class ArticleRatingForm extends Component {
|
||||
{loading ? (
|
||||
<>
|
||||
<CircularProgress size={20} sx={{ mr: 1 }} />
|
||||
Wird gesendet...
|
||||
{t("productDialogs.sending")}
|
||||
</>
|
||||
) : (
|
||||
'Bewertung abgeben'
|
||||
t("productDialogs.submitRating")
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
@@ -23,7 +23,7 @@ class CartItem extends Component {
|
||||
|
||||
window.socketManager.emit('getPic', { bildId:picid, size:'tiny' }, (res) => {
|
||||
if(res.success){
|
||||
window.tinyPicCache[picid] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
|
||||
window.tinyPicCache[picid] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/avif' }));
|
||||
this.setState({image: window.tinyPicCache[picid], loading: false});
|
||||
}
|
||||
})
|
||||
|
||||
@@ -47,7 +47,7 @@ const CategoryBox = ({
|
||||
// Create fresh blob URL from cached binary data
|
||||
try {
|
||||
const uint8Array = new Uint8Array(cachedImageData);
|
||||
const blob = new Blob([uint8Array], { type: 'image/jpeg' });
|
||||
const blob = new Blob([uint8Array], { type: 'image/avif' });
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setImageUrl(objectUrl);
|
||||
setImageError(false);
|
||||
@@ -73,7 +73,7 @@ const CategoryBox = ({
|
||||
try {
|
||||
// Convert binary data to blob URL
|
||||
const uint8Array = new Uint8Array(imageData);
|
||||
const blob = new Blob([uint8Array], { type: 'image/jpeg' });
|
||||
const blob = new Blob([uint8Array], { type: 'image/avif' });
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setImageUrl(objectUrl);
|
||||
setImageError(false);
|
||||
@@ -158,7 +158,7 @@ const CategoryBox = ({
|
||||
position: 'relative',
|
||||
backgroundImage: ((typeof window !== 'undefined' && window.__PRERENDER_FALLBACK__) ||
|
||||
(typeof global !== 'undefined' && global.window && global.window.__PRERENDER_FALLBACK__))
|
||||
? `url("/assets/images/cat${id}.jpg")`
|
||||
? `url("/assets/images/cat${id}.avif")`
|
||||
: (imageUrl && !imageError ? `url("${imageUrl}")` : 'none'),
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
|
||||
@@ -14,6 +14,7 @@ import CategoryBox from './CategoryBox.js';
|
||||
import { useParams, useSearchParams } from 'react-router-dom';
|
||||
import { getAllSettingsWithPrefix } from '../utils/sessionStorage.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);
|
||||
|
||||
@@ -221,6 +222,11 @@ class Content extends Component {
|
||||
const searchChanged = this.props.searchParams?.get('q') && (prevProps.searchParams?.get('q') !== this.props.searchParams?.get('q'));
|
||||
|
||||
if(categoryChanged) {
|
||||
// Clear context for new category loading
|
||||
if (this.props.categoryContext && this.props.categoryContext.setCurrentCategory) {
|
||||
this.props.categoryContext.setCurrentCategory(null);
|
||||
}
|
||||
|
||||
window.currentSearchQuery = null;
|
||||
this.setState({loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: [], lastFetchedLanguage: currentLanguage}, () => {
|
||||
this.fetchCategoryData(this.props.params.categoryId);
|
||||
@@ -273,17 +279,25 @@ class Content extends Component {
|
||||
}
|
||||
|
||||
processData(response) {
|
||||
const unfilteredProducts = response.products;
|
||||
const rawProducts = response.products;
|
||||
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
|
||||
|
||||
if (!window.individualProductCache) {
|
||||
window.individualProductCache = {};
|
||||
}
|
||||
//console.log("processData", unfilteredProducts);
|
||||
if(unfilteredProducts) unfilteredProducts.forEach(product => {
|
||||
window.individualProductCache[product.id] = {
|
||||
data: product,
|
||||
|
||||
const unfilteredProducts = [];
|
||||
|
||||
//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()
|
||||
};
|
||||
unfilteredProducts.push(effectiveProduct);
|
||||
});
|
||||
|
||||
this.setState({
|
||||
@@ -299,6 +313,26 @@ class Content extends Component {
|
||||
attributes: response.attributes,
|
||||
childCategories: response.childCategories || [],
|
||||
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!');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -375,6 +409,27 @@ class Content extends Component {
|
||||
...response,
|
||||
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);
|
||||
}
|
||||
@@ -403,7 +458,12 @@ class Content extends Component {
|
||||
{ query, language: currentLanguage, requestTranslation: currentLanguage === 'de' ? false : true },
|
||||
(response) => {
|
||||
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 {
|
||||
console.log("fetchSearchData in Content failed", response);
|
||||
}
|
||||
@@ -452,6 +512,12 @@ class Content extends Component {
|
||||
return category ? category.id : null;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.props.categoryContext && this.props.categoryContext.setCurrentCategory) {
|
||||
this.props.categoryContext.setCurrentCategory(null);
|
||||
}
|
||||
}
|
||||
|
||||
renderParentCategoryNavigation = () => {
|
||||
const currentCategoryId = this.getCurrentCategoryId();
|
||||
if (!currentCategoryId) return null;
|
||||
@@ -490,6 +556,7 @@ class Content extends Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
// console.log('Content props:', this.props);
|
||||
// Check if we should show category boxes instead of product list
|
||||
const showCategoryBoxes = this.state.loaded &&
|
||||
this.state.unfilteredProducts.length === 0 &&
|
||||
@@ -627,6 +694,7 @@ class Content extends Component {
|
||||
onFilterChange={()=>{this.filterProducts()}}
|
||||
dataType={this.state.dataType}
|
||||
dataParam={this.state.dataParam}
|
||||
categoryName={this.state.categoryName}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -668,7 +736,7 @@ class Content extends Component {
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<img
|
||||
src="/assets/images/seeds.jpg"
|
||||
src="/assets/images/seeds.avif"
|
||||
alt="Seeds"
|
||||
fetchPriority="high"
|
||||
loading="eager"
|
||||
@@ -729,7 +797,7 @@ class Content extends Component {
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<img
|
||||
src="/assets/images/cutlings.jpg"
|
||||
src="/assets/images/cutlings.avif"
|
||||
alt="Stecklinge"
|
||||
fetchPriority="high"
|
||||
loading="eager"
|
||||
@@ -780,4 +848,4 @@ class Content extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(withI18n()(Content));
|
||||
export default withRouter(withI18n()(withCategory(Content)));
|
||||
@@ -296,7 +296,7 @@ class Footer extends Component {
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src="/assets/images/gg.png"
|
||||
src="/assets/images/gg.avif"
|
||||
alt="Google Reviews"
|
||||
sx={{
|
||||
height: { xs: 50, md: 60 },
|
||||
@@ -326,7 +326,7 @@ class Footer extends Component {
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src="/assets/images/maps.png"
|
||||
src="/assets/images/maps.avif"
|
||||
alt="Google Maps"
|
||||
sx={{
|
||||
height: { xs: 40, md: 50 },
|
||||
@@ -352,6 +352,9 @@ class Footer extends Component {
|
||||
<Typography variant="body2" sx={{ fontSize: { xs: '11px', md: '14px' }, lineHeight: 1.5 }}>
|
||||
© {new Date().getFullYear()} <StyledDomainLink href="https://growheads.de" target="_blank" rel="noopener noreferrer">GrowHeads.de</StyledDomainLink>
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontSize: { xs: '9px', md: '9px' }, lineHeight: 1.5, mt: 1 }}>
|
||||
<StyledDomainLink href="https://telegraf.growheads.de" target="_blank" rel="noreferrer">Telegraf - sicherer Chat mit unseren Mitarbeitern</StyledDomainLink>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
@@ -56,7 +56,7 @@ class Images extends Component {
|
||||
pics.push(window.tinyPicCache[bildId]);
|
||||
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
|
||||
}else{
|
||||
pics.push(`/assets/images/prod${bildId}.jpg`);
|
||||
pics.push(`/assets/images/prod${bildId}.avif`);
|
||||
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
|
||||
}
|
||||
}else{
|
||||
@@ -84,7 +84,7 @@ class Images extends Component {
|
||||
|
||||
window.socketManager.emit('getPic', { bildId, size }, (res) => {
|
||||
if(res.success){
|
||||
const url = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
|
||||
const url = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/avif' }));
|
||||
|
||||
if(size === 'medium') window.mediumPicCache[bildId] = url;
|
||||
if(size === 'small') window.smallPicCache[bildId] = url;
|
||||
@@ -118,7 +118,7 @@ class Images extends Component {
|
||||
if (!this.props.pictureList || !this.props.pictureList.trim()) {
|
||||
return '/assets/images/nopicture.jpg';
|
||||
}
|
||||
return `/assets/images/prod${this.props.pictureList.split(',')[0].trim()}.jpg`;
|
||||
return `/assets/images/prod${this.props.pictureList.split(',')[0].trim()}.avif`;
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -175,12 +175,12 @@ export class LoginComponent extends Component {
|
||||
const { location, navigate } = this.props;
|
||||
|
||||
if (!email || !password) {
|
||||
this.setState({ error: 'Bitte füllen Sie alle Felder aus' });
|
||||
this.setState({ error: this.props.t ? this.props.t('auth.errors.fillAllFields') : 'Bitte füllen Sie alle Felder aus' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.validateEmail(email)) {
|
||||
this.setState({ error: 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
|
||||
this.setState({ error: this.props.t ? this.props.t('auth.errors.invalidEmail') : 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -238,7 +238,7 @@ export class LoginComponent extends Component {
|
||||
} else {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: response.message || 'Anmeldung fehlgeschlagen'
|
||||
error: response.message || (this.props.t ? this.props.t('auth.errors.loginFailed') : 'Anmeldung fehlgeschlagen')
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -248,22 +248,22 @@ export class LoginComponent extends Component {
|
||||
const { email, password, confirmPassword } = this.state;
|
||||
|
||||
if (!email || !password || !confirmPassword) {
|
||||
this.setState({ error: 'Bitte füllen Sie alle Felder aus' });
|
||||
this.setState({ error: this.props.t ? this.props.t('auth.errors.fillAllFields') : 'Bitte füllen Sie alle Felder aus' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.validateEmail(email)) {
|
||||
this.setState({ error: 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
|
||||
this.setState({ error: this.props.t ? this.props.t('auth.errors.invalidEmail') : 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
this.setState({ error: 'Passwörter stimmen nicht überein' });
|
||||
this.setState({ error: this.props.t ? this.props.t('auth.errors.passwordsNotMatchShort') : 'Passwörter stimmen nicht überein' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
this.setState({ error: 'Das Passwort muss mindestens 8 Zeichen lang sein' });
|
||||
this.setState({ error: this.props.t ? this.props.t('auth.passwordMinLength') : 'Das Passwort muss mindestens 8 Zeichen lang sein' });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -274,14 +274,14 @@ export class LoginComponent extends Component {
|
||||
if (response.success) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
success: 'Registrierung erfolgreich. Sie können sich jetzt anmelden.',
|
||||
success: this.props.t ? this.props.t('auth.success.registerComplete') : 'Registrierung erfolgreich. Sie können sich jetzt anmelden.',
|
||||
tabValue: 0 // Switch to login tab
|
||||
});
|
||||
} else {
|
||||
let errorMessage = 'Registrierung fehlgeschlagen';
|
||||
let errorMessage = this.props.t ? this.props.t('auth.errors.registerFailed') : 'Registrierung fehlgeschlagen';
|
||||
|
||||
if (response.cause === 'emailExists') {
|
||||
errorMessage = 'Ein Benutzer mit dieser E-Mail-Adresse existiert bereits. Bitte verwenden Sie eine andere E-Mail-Adresse oder melden Sie sich an.';
|
||||
errorMessage = this.props.t ? this.props.t('auth.errors.emailExists') : 'Ein Benutzer mit dieser E-Mail-Adresse existiert bereits. Bitte verwenden Sie eine andere E-Mail-Adresse oder melden Sie sich an.';
|
||||
} else if (response.message) {
|
||||
errorMessage = response.message;
|
||||
}
|
||||
@@ -322,12 +322,12 @@ export class LoginComponent extends Component {
|
||||
const { email } = this.state;
|
||||
|
||||
if (!email) {
|
||||
this.setState({ error: 'Bitte geben Sie Ihre E-Mail-Adresse ein' });
|
||||
this.setState({ error: this.props.t ? this.props.t('auth.errors.enterEmail') : 'Bitte geben Sie Ihre E-Mail-Adresse ein' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.validateEmail(email)) {
|
||||
this.setState({ error: 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
|
||||
this.setState({ error: this.props.t ? this.props.t('auth.errors.invalidEmail') : 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -342,12 +342,12 @@ export class LoginComponent extends Component {
|
||||
if (response.success) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
success: 'Ein Link zum Zurücksetzen des Passworts wurde an Ihre E-Mail-Adresse gesendet.'
|
||||
success: this.props.t ? this.props.t('auth.resetPassword.emailSent') : 'Ein Link zum Zurücksetzen des Passworts wurde an Ihre E-Mail-Adresse gesendet.'
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: response.message || 'Fehler beim Senden der E-Mail'
|
||||
error: response.message || (this.props.t ? this.props.t('auth.resetPassword.emailError') : 'Fehler beim Senden der E-Mail')
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -408,7 +408,7 @@ export class LoginComponent extends Component {
|
||||
} else {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: 'Google-Anmeldung fehlgeschlagen',
|
||||
error: this.props.t ? this.props.t('auth.errors.googleLoginFailed') : 'Google-Anmeldung fehlgeschlagen',
|
||||
showGoogleAuth: false // Reset Google auth state on failed login
|
||||
});
|
||||
}
|
||||
@@ -418,7 +418,7 @@ export class LoginComponent extends Component {
|
||||
handleGoogleLoginError = (error) => {
|
||||
console.error('Google Login Error:', error);
|
||||
this.setState({
|
||||
error: 'Google-Anmeldung fehlgeschlagen',
|
||||
error: this.props.t ? this.props.t('auth.errors.googleLoginFailed') : 'Google-Anmeldung fehlgeschlagen',
|
||||
showGoogleAuth: false, // Reset Google auth state on error
|
||||
loading: false
|
||||
});
|
||||
|
||||
@@ -163,8 +163,8 @@ const MainPageLayout = () => {
|
||||
|
||||
const allContentBoxes = {
|
||||
home: [
|
||||
{ title: t('sections.seeds'), image: "/assets/images/seeds.jpg", bgcolor: "#e1f0d3", link: "/Kategorie/Seeds" },
|
||||
{ title: t('sections.stecklinge'), image: "/assets/images/cutlings.jpg", bgcolor: "#e8f5d6", link: "/Kategorie/Stecklinge" }
|
||||
{ title: t('sections.seeds'), image: "/assets/images/seeds.avif", bgcolor: "#e1f0d3", link: "/Kategorie/Seeds" },
|
||||
{ title: t('sections.stecklinge'), image: "/assets/images/cutlings.avif", bgcolor: "#e8f5d6", link: "/Kategorie/Stecklinge" }
|
||||
],
|
||||
aktionen: [
|
||||
{ title: t('sections.oilPress'), image: "/assets/images/presse.jpg", bgcolor: "#e1f0d3", link: "/presseverleih" },
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@mui/material';
|
||||
import Delete from '@mui/icons-material/Delete';
|
||||
import CloudUpload from '@mui/icons-material/CloudUpload';
|
||||
import { withI18n } from '../i18n/withTranslation.js';
|
||||
|
||||
class PhotoUpload extends Component {
|
||||
constructor(props) {
|
||||
@@ -30,7 +31,7 @@ class PhotoUpload extends Component {
|
||||
// Validate file count
|
||||
if (this.state.files.length + selectedFiles.length > maxFiles) {
|
||||
this.setState({
|
||||
error: `Maximal ${maxFiles} Dateien erlaubt`
|
||||
error: this.props.t("productDialogs.photoUploadErrorMaxFiles", { max: maxFiles })
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -43,14 +44,14 @@ class PhotoUpload extends Component {
|
||||
for (const file of selectedFiles) {
|
||||
if (!validTypes.includes(file.type)) {
|
||||
this.setState({
|
||||
error: 'Nur Bilddateien (JPEG, PNG, GIF, WebP) sind erlaubt'
|
||||
error: this.props.t("productDialogs.photoUploadErrorFileType")
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (file.size > maxSize) {
|
||||
this.setState({
|
||||
error: `Datei zu groß. Maximum: ${Math.round(maxSize / (1024 * 1024))}MB`
|
||||
error: this.props.t("productDialogs.photoUploadErrorFileSize", { maxSize: Math.round(maxSize / (1024 * 1024)) })
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -167,12 +168,12 @@ class PhotoUpload extends Component {
|
||||
|
||||
render() {
|
||||
const { files, previews, error } = this.state;
|
||||
const { disabled, label } = this.props;
|
||||
const { disabled, label, t } = this.props;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ mb: 1, fontWeight: 500 }}>
|
||||
{label || 'Fotos anhängen (optional)'}
|
||||
{label || t("productDialogs.photoUploadLabelDefault")}
|
||||
</Typography>
|
||||
|
||||
<input
|
||||
@@ -192,7 +193,7 @@ class PhotoUpload extends Component {
|
||||
disabled={disabled}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
Fotos auswählen
|
||||
{t("productDialogs.photoUploadSelect")}
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
@@ -228,7 +229,7 @@ class PhotoUpload extends Component {
|
||||
size="small"
|
||||
onClick={() => this.handleRemoveFile(index)}
|
||||
disabled={disabled}
|
||||
aria-label="Bild entfernen"
|
||||
aria-label={t("productDialogs.photoUploadRemove")}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
@@ -269,10 +270,10 @@ class PhotoUpload extends Component {
|
||||
|
||||
{files.length > 0 && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||
{files.length} Datei(en) ausgewählt
|
||||
{t("productDialogs.photoUploadSelectedFiles", { count: files.length })}
|
||||
{previews.length > 0 && previews.some(p => p.originalSize && p.compressedSize) && (
|
||||
<span style={{ marginLeft: '8px' }}>
|
||||
(komprimiert für Upload)
|
||||
{t("productDialogs.photoUploadCompressed")}
|
||||
</span>
|
||||
)}
|
||||
</Typography>
|
||||
@@ -282,4 +283,4 @@ class PhotoUpload extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default PhotoUpload;
|
||||
export default withI18n()(PhotoUpload);
|
||||
@@ -101,7 +101,7 @@ class Product extends Component {
|
||||
console.log('loadImagevisSocket', bildId);
|
||||
window.socketManager.emit('getPic', { bildId, size:'small' }, (res) => {
|
||||
if(res.success){
|
||||
window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
|
||||
window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/avif' }));
|
||||
if (this._isMounted) {
|
||||
this.setState({image: window.smallPicCache[bildId], loading: false});
|
||||
} else {
|
||||
|
||||
@@ -94,6 +94,9 @@ class ProductDetailPage extends Component {
|
||||
komponentenImages: {}, // Store tiny pictures for komponenten
|
||||
totalKomponentenPrice: 0,
|
||||
totalSavings: 0,
|
||||
// Embedded products from <product> tags in description
|
||||
embeddedProducts: {},
|
||||
embeddedProductImages: {},
|
||||
// Collapsible sections state
|
||||
showQuestionForm: false,
|
||||
showRatingForm: false,
|
||||
@@ -143,6 +146,9 @@ class ProductDetailPage extends Component {
|
||||
komponentenImages: {}, // Store tiny pictures for komponenten
|
||||
totalKomponentenPrice: 0,
|
||||
totalSavings: 0,
|
||||
// Embedded products from <product> tags in description
|
||||
embeddedProducts: {},
|
||||
embeddedProductImages: {},
|
||||
// Collapsible sections state
|
||||
showQuestionForm: false,
|
||||
showRatingForm: false,
|
||||
@@ -174,6 +180,9 @@ class ProductDetailPage extends Component {
|
||||
komponentenImages: {}, // Store tiny pictures for komponenten
|
||||
totalKomponentenPrice: 0,
|
||||
totalSavings: 0,
|
||||
// Embedded products from <product> tags in description
|
||||
embeddedProducts: {},
|
||||
embeddedProductImages: {},
|
||||
// Collapsible sections state
|
||||
showQuestionForm: false,
|
||||
showRatingForm: false,
|
||||
@@ -192,6 +201,17 @@ class ProductDetailPage extends Component {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// Update context with cached product if available
|
||||
if (this.state.product && this.props.setCurrentProduct) {
|
||||
console.log('ProductDetailPage: Setting product context from cache', this.state.product.name);
|
||||
this.props.setCurrentProduct({
|
||||
name: this.state.product.name,
|
||||
categoryId: this.state.product.kategorien ? this.state.product.kategorien.split(',')[0] : undefined
|
||||
});
|
||||
} else if (this.state.product) {
|
||||
console.warn('ProductDetailPage: setCurrentProduct prop is missing despite having product');
|
||||
}
|
||||
|
||||
// Load product data if we have no product or if we're in upgrading state
|
||||
if (!this.state.product || this.state.upgrading) {
|
||||
this.loadProductData();
|
||||
@@ -212,6 +232,11 @@ class ProductDetailPage extends Component {
|
||||
componentDidUpdate(prevProps) {
|
||||
// Check for seoName changes
|
||||
if (prevProps.seoName !== this.props.seoName) {
|
||||
// Clear context when navigating to new product
|
||||
if (this.props.setCurrentProduct) {
|
||||
this.props.setCurrentProduct(null);
|
||||
}
|
||||
|
||||
this.setState(
|
||||
{ product: null, loading: true, upgrading: false, error: null, imageDialogOpen: false, similarProducts: [] },
|
||||
this.loadProductData
|
||||
@@ -280,7 +305,7 @@ class ProductDetailPage extends Component {
|
||||
window.socketManager.emit('getPic', { bildId, size: 'small' }, (res) => {
|
||||
if (res.success) {
|
||||
// Cache the image
|
||||
window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
|
||||
window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/avif' }));
|
||||
|
||||
// Update state
|
||||
this.setState(prevState => ({
|
||||
@@ -521,7 +546,7 @@ class ProductDetailPage extends Component {
|
||||
console.log("getAttributePicture", res);
|
||||
if (res.success && !res.noPicture) {
|
||||
const blob = new Blob([res.imageBuffer], {
|
||||
type: "image/jpeg",
|
||||
type: "image/avif",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
@@ -574,13 +599,16 @@ class ProductDetailPage extends Component {
|
||||
const productData = res.translatedProduct || res.product;
|
||||
productData.seoName = this.props.seoName;
|
||||
|
||||
// Use translated attributes if available
|
||||
const attributesData = res.translatedAttributes || res.attributes;
|
||||
|
||||
// Initialize cache if it doesn't exist
|
||||
if (!window.productDetailCache) {
|
||||
window.productDetailCache = {};
|
||||
}
|
||||
|
||||
// Cache the complete response data (product + attributes) - cache the response with translated product
|
||||
const cacheData = { ...res, product: productData };
|
||||
const cacheData = { ...res, product: productData, attributes: attributesData };
|
||||
window.productDetailCache[cacheKey] = cacheData;
|
||||
|
||||
// Clean up prerender fallback since we now have real data
|
||||
@@ -603,11 +631,22 @@ class ProductDetailPage extends Component {
|
||||
upgrading: false, // Clear upgrading state since we now have complete data
|
||||
error: null,
|
||||
imageDialogOpen: false,
|
||||
attributes: res.attributes,
|
||||
attributes: attributesData,
|
||||
komponenten: komponenten,
|
||||
komponentenLoaded: komponenten.length === 0, // If no komponenten, mark as loaded
|
||||
similarProducts: res.similarProducts || []
|
||||
}, () => {
|
||||
// Update context
|
||||
if (this.props.setCurrentProduct) {
|
||||
console.log('ProductDetailPage: Setting product context from fetch', productData.name);
|
||||
this.props.setCurrentProduct({
|
||||
name: productData.name,
|
||||
categoryId: productData.kategorien ? productData.kategorien.split(',')[0] : undefined
|
||||
});
|
||||
} else {
|
||||
console.warn('ProductDetailPage: setCurrentProduct prop is missing after fetch');
|
||||
}
|
||||
|
||||
if(komponenten.length > 0) {
|
||||
for(const komponent of komponenten) {
|
||||
this.loadKomponent(komponent.id, komponent.count);
|
||||
@@ -617,7 +656,7 @@ class ProductDetailPage extends Component {
|
||||
console.log("getProductView", res);
|
||||
|
||||
// Load attribute images
|
||||
this.loadAttributeImages(res.attributes);
|
||||
this.loadAttributeImages(attributesData);
|
||||
} else {
|
||||
console.error(
|
||||
"Error loading product:",
|
||||
@@ -726,7 +765,7 @@ class ProductDetailPage extends Component {
|
||||
handleEmbedShare = () => {
|
||||
const embedCode = `<iframe src="${this.getProductUrl()}" width="100%" height="600" frameborder="0"></iframe>`;
|
||||
navigator.clipboard.writeText(embedCode).then(() => {
|
||||
this.showSnackbar("Einbettungscode wurde in die Zwischenablage kopiert!");
|
||||
this.showSnackbar(this.props.t ? this.props.t("productDialogs.shareSuccessEmbed") : "Einbettungscode wurde in die Zwischenablage kopiert!");
|
||||
}).catch(() => {
|
||||
// Fallback for older browsers
|
||||
try {
|
||||
@@ -736,9 +775,9 @@ class ProductDetailPage extends Component {
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
this.showSnackbar("Einbettungscode wurde in die Zwischenablage kopiert!");
|
||||
this.showSnackbar(this.props.t ? this.props.t("productDialogs.shareSuccessEmbed") : "Einbettungscode wurde in die Zwischenablage kopiert!");
|
||||
} catch {
|
||||
this.showSnackbar("Fehler beim Kopieren des Einbettungscodes", "error");
|
||||
this.showSnackbar(this.props.t ? this.props.t("productDialogs.shareErrorEmbed") : "Fehler beim Kopieren des Einbettungscodes", "error");
|
||||
}
|
||||
});
|
||||
this.handleShareClose();
|
||||
@@ -746,7 +785,10 @@ class ProductDetailPage extends Component {
|
||||
|
||||
handleWhatsAppShare = () => {
|
||||
const url = this.getProductUrl();
|
||||
const text = `Schau dir dieses Produkt an: ${cleanProductName(this.state.product.name)}`;
|
||||
const productName = cleanProductName(this.state.product.name);
|
||||
const text = this.props.t
|
||||
? this.props.t("productDialogs.shareWhatsAppText", { name: productName })
|
||||
: `Schau dir dieses Produkt an: ${productName}`;
|
||||
const whatsappUrl = `https://wa.me/?text=${encodeURIComponent(text + ' ' + url)}`;
|
||||
window.open(whatsappUrl, '_blank');
|
||||
this.handleShareClose();
|
||||
@@ -761,7 +803,10 @@ class ProductDetailPage extends Component {
|
||||
|
||||
handleTelegramShare = () => {
|
||||
const url = this.getProductUrl();
|
||||
const text = `Schau dir dieses Produkt an: ${cleanProductName(this.state.product.name)}`;
|
||||
const productName = cleanProductName(this.state.product.name);
|
||||
const text = this.props.t
|
||||
? this.props.t("productDialogs.shareTelegramText", { name: productName })
|
||||
: `Schau dir dieses Produkt an: ${productName}`;
|
||||
const telegramUrl = `https://t.me/share/url?url=${encodeURIComponent(url)}&text=${encodeURIComponent(text)}`;
|
||||
window.open(telegramUrl, '_blank');
|
||||
this.handleShareClose();
|
||||
@@ -769,8 +814,18 @@ class ProductDetailPage extends Component {
|
||||
|
||||
handleEmailShare = () => {
|
||||
const url = this.getProductUrl();
|
||||
const subject = `Produktempfehlung: ${cleanProductName(this.state.product.name)}`;
|
||||
const body = `Hallo,\n\nich möchte dir dieses Produkt empfehlen:\n\n${cleanProductName(this.state.product.name)}\n${url}\n\nViele Grüße`;
|
||||
const productName = cleanProductName(this.state.product.name);
|
||||
const subject = this.props.t
|
||||
? `${this.props.t("productDialogs.shareEmailSubject")}: ${productName}`
|
||||
: `Produktempfehlung: ${productName}`;
|
||||
|
||||
const body = this.props.t
|
||||
? this.props.t("productDialogs.shareEmailBody", {
|
||||
name: productName,
|
||||
url: url
|
||||
})
|
||||
: `Hallo,\n\nich möchte dir dieses Produkt empfehlen:\n\n${productName}\n${url}\n\nViele Grüße`;
|
||||
|
||||
const emailUrl = `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
|
||||
window.location.href = emailUrl;
|
||||
this.handleShareClose();
|
||||
@@ -779,7 +834,7 @@ class ProductDetailPage extends Component {
|
||||
handleLinkCopy = () => {
|
||||
const url = this.getProductUrl();
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
this.showSnackbar("Link wurde in die Zwischenablage kopiert!");
|
||||
this.showSnackbar(this.props.t ? this.props.t("productDialogs.shareSuccessLink") : "Link wurde in die Zwischenablage kopiert!");
|
||||
}).catch(() => {
|
||||
// Fallback for older browsers
|
||||
try {
|
||||
@@ -789,7 +844,7 @@ class ProductDetailPage extends Component {
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
this.showSnackbar("Link wurde in die Zwischenablage kopiert!");
|
||||
this.showSnackbar(this.props.t ? this.props.t("productDialogs.shareSuccessLink") : "Link wurde in die Zwischenablage kopiert!");
|
||||
} catch {
|
||||
this.showSnackbar("Fehler beim Kopieren des Links", "error");
|
||||
}
|
||||
@@ -797,7 +852,241 @@ class ProductDetailPage extends Component {
|
||||
this.handleShareClose();
|
||||
};
|
||||
|
||||
render() {
|
||||
// Render embedded product from <product articlenr="..."> tag in description
|
||||
renderEmbeddedProduct = (articleNr) => {
|
||||
console.log('renderEmbeddedProduct called with articleNr:', articleNr);
|
||||
|
||||
// Check if we already have this product data in state
|
||||
const embeddedProducts = this.state.embeddedProducts || {};
|
||||
const productData = embeddedProducts[articleNr];
|
||||
|
||||
console.log('Embedded product data:', productData);
|
||||
|
||||
// If there was an error loading, show error message (don't retry infinitely)
|
||||
if (productData && productData.error) {
|
||||
return (
|
||||
<Box
|
||||
key={`embedded-${articleNr}`}
|
||||
sx={{
|
||||
my: 2,
|
||||
p: 2,
|
||||
background: "#fff3f3",
|
||||
borderRadius: 2,
|
||||
border: "1px solid #ffcdd2"
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="error">
|
||||
Produkt nicht gefunden (Artikelnr: {articleNr})
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!productData || !productData.loaded) {
|
||||
// If not loaded yet and not currently loading, fetch it
|
||||
if (!productData || (!productData.loading && !productData.error)) {
|
||||
console.log('Starting to load embedded product:', articleNr);
|
||||
this.loadEmbeddedProduct(articleNr);
|
||||
}
|
||||
|
||||
// Return loading state
|
||||
return (
|
||||
<Box
|
||||
key={`embedded-${articleNr}`}
|
||||
sx={{
|
||||
my: 2,
|
||||
p: 2,
|
||||
background: "#f9f9f9",
|
||||
borderRadius: 2,
|
||||
border: "1px solid #e0e0e0"
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||
<Box sx={{ width: 60, height: 60, flexShrink: 0, backgroundColor: "#f5f5f5", borderRadius: 1, border: "1px solid #e0e0e0" }}>
|
||||
{/* Empty placeholder for image */}
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="body1">
|
||||
{this.props.t('product.loadingProduct')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{this.props.t('product.articleNumber')}: {articleNr}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Product data is loaded, render it
|
||||
const embeddedImages = this.state.embeddedProductImages || {};
|
||||
const productImage = embeddedImages[articleNr];
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={`embedded-${articleNr}`}
|
||||
component={Link}
|
||||
to={`/Artikel/${productData.seoName}`}
|
||||
sx={{
|
||||
display: "block",
|
||||
my: 2,
|
||||
p: 2,
|
||||
background: "#f9f9f9",
|
||||
borderRadius: 2,
|
||||
border: "1px solid #e0e0e0",
|
||||
textDecoration: "none",
|
||||
color: "inherit",
|
||||
transition: "all 0.2s",
|
||||
"&:hover": {
|
||||
backgroundColor: "#f0f0f0",
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.1)"
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||
<Box sx={{ width: 60, height: 60, flexShrink: 0 }}>
|
||||
{productImage ? (
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="60"
|
||||
image={productImage}
|
||||
alt={productData.name}
|
||||
sx={{
|
||||
objectFit: "contain",
|
||||
borderRadius: 1,
|
||||
border: "1px solid #e0e0e0"
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="60"
|
||||
image="/assets/images/nopicture.jpg"
|
||||
alt={productData.name}
|
||||
sx={{
|
||||
objectFit: "contain",
|
||||
borderRadius: 1,
|
||||
border: "1px solid #e0e0e0"
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="body1" sx={{ fontWeight: 500, mb: 0.5 }}>
|
||||
{cleanProductName(productData.name)}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{this.props.t('product.articleNumber')}: {productData.articleNumber}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ textAlign: "right" }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: "primary.main" }}>
|
||||
{new Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}).format(productData.price)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{this.props.t ? this.props.t('product.inclVatSimple') : 'inkl. MwSt.'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Load embedded product data by article number
|
||||
loadEmbeddedProduct = (articleNr) => {
|
||||
console.log('loadEmbeddedProduct', articleNr);
|
||||
|
||||
// Mark as loading
|
||||
this.setState(prevState => ({
|
||||
embeddedProducts: {
|
||||
...prevState.embeddedProducts,
|
||||
[articleNr]: { loading: true, loaded: false }
|
||||
}
|
||||
}));
|
||||
|
||||
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
|
||||
|
||||
// Fetch product data from API using getProductView (same as komponenten)
|
||||
window.socketManager.emit('getProductView', {
|
||||
articleNr: articleNr,
|
||||
language: currentLanguage,
|
||||
requestTranslation: currentLanguage === 'de' ? false : true
|
||||
}, (response) => {
|
||||
console.log('loadEmbeddedProduct response:', articleNr, response);
|
||||
|
||||
if (response.success && response.product) {
|
||||
// Use translated product if available, otherwise use original product
|
||||
const product = response.translatedProduct || response.product;
|
||||
|
||||
console.log('Successfully loaded embedded product:', articleNr, product.name);
|
||||
|
||||
// Update state with loaded product data
|
||||
this.setState(prevState => ({
|
||||
embeddedProducts: {
|
||||
...prevState.embeddedProducts,
|
||||
[articleNr]: {
|
||||
...product,
|
||||
loading: false,
|
||||
loaded: true
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Load product image if available
|
||||
if (product.pictureList && product.pictureList.length > 0) {
|
||||
const bildId = product.pictureList.split(',')[0];
|
||||
this.loadEmbeddedProductImage(articleNr, bildId);
|
||||
}
|
||||
} else {
|
||||
console.warn(`Failed to load embedded product ${articleNr}:`, response);
|
||||
// Mark as failed to load
|
||||
this.setState(prevState => ({
|
||||
embeddedProducts: {
|
||||
...prevState.embeddedProducts,
|
||||
[articleNr]: {
|
||||
loading: false,
|
||||
loaded: false,
|
||||
error: true,
|
||||
errorMessage: response.error || 'Unknown error'
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Load embedded product image
|
||||
loadEmbeddedProductImage = (articleNr, bildId) => {
|
||||
console.log('loadEmbeddedProductImage', articleNr, bildId);
|
||||
|
||||
window.socketManager.emit('getPic', { bildId, size: 'small' }, (res) => {
|
||||
console.log('loadEmbeddedProductImage response:', articleNr, res.success);
|
||||
|
||||
if (res.success) {
|
||||
const imageUrl = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/avif' }));
|
||||
this.setState(prevState => {
|
||||
console.log('Setting embedded product image for', articleNr);
|
||||
return {
|
||||
embeddedProductImages: {
|
||||
...prevState.embeddedProductImages,
|
||||
[articleNr]: imageUrl
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.props.setCurrentProduct) {
|
||||
this.props.setCurrentProduct(null);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { product, loading, upgrading, error, attributeImages, isSteckling, attributes, komponentenLoaded, komponentenData, komponentenImages, totalKomponentenPrice, totalSavings, shareAnchorEl, sharePopperOpen, snackbarOpen, snackbarMessage, snackbarSeverity } =
|
||||
this.state;
|
||||
|
||||
@@ -818,23 +1107,8 @@ class ProductDetailPage extends Component {
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback to loading message if no prerender content
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: "center",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "60vh",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h2" component="h2" gutterBottom>
|
||||
Produkt wird geladen...
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
// Fallback to blank page if no prerender content
|
||||
return <div style={{ minHeight: "60vh" }} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
@@ -1022,8 +1296,9 @@ class ProductDetailPage extends Component {
|
||||
)}
|
||||
|
||||
{/* Attribute images and chips with action buttons */}
|
||||
{(attributes.some(attr => attributeImages[attr.kMerkmalWert]) || attributes.some(attr => !attributeImages[attr.kMerkmalWert])) && (
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", mb: 2, gap: 2 }}>
|
||||
|
||||
<Box sx={{ minHeight: "107px", display: "flex", justifyContent: "space-between", alignItems: "flex-start", mb: 2, gap: 2 }}>
|
||||
{(attributes.some(attr => attributeImages[attr.kMerkmalWert]) || attributes.some(attr => !attributeImages[attr.kMerkmalWert])) && (
|
||||
<Stack direction="row" spacing={0} sx={{ flexWrap: "wrap", gap: 1, flex: 1 }}>
|
||||
{attributes
|
||||
.filter(attribute => attributeImages[attribute.kMerkmalWert])
|
||||
@@ -1059,62 +1334,63 @@ class ProductDetailPage extends Component {
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Right-aligned action buttons */}
|
||||
<Stack direction="column" spacing={1} sx={{ flexShrink: 0 }}>
|
||||
{/* Right-aligned action buttons */}
|
||||
<Stack direction="column" spacing={1} sx={{ flexShrink: 0 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={this.toggleQuestionForm}
|
||||
sx={{
|
||||
fontSize: "0.75rem",
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
minWidth: "auto",
|
||||
whiteSpace: "nowrap"
|
||||
}}
|
||||
>
|
||||
{this.props.t ? this.props.t('productDialogs.questionTitle') : "Frage zum Artikel"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={this.toggleRatingForm}
|
||||
sx={{
|
||||
fontSize: "0.75rem",
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
minWidth: "auto",
|
||||
whiteSpace: "nowrap"
|
||||
}}
|
||||
>
|
||||
{this.props.t ? this.props.t('productDialogs.ratingTitle') : "Artikel Bewerten"}
|
||||
</Button>
|
||||
{(product.available !== 1 && product.availableSupplier !== 1) && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={this.toggleQuestionForm}
|
||||
onClick={this.toggleAvailabilityForm}
|
||||
sx={{
|
||||
fontSize: "0.75rem",
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
minWidth: "auto",
|
||||
whiteSpace: "nowrap"
|
||||
whiteSpace: "nowrap",
|
||||
borderColor: "warning.main",
|
||||
color: "warning.main",
|
||||
"&:hover": {
|
||||
borderColor: "warning.dark",
|
||||
backgroundColor: "warning.light"
|
||||
}
|
||||
}}
|
||||
>
|
||||
Frage zum Artikel
|
||||
{this.props.t ? this.props.t('productDialogs.availabilityTitle') : "Verfügbarkeit anfragen"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={this.toggleRatingForm}
|
||||
sx={{
|
||||
fontSize: "0.75rem",
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
minWidth: "auto",
|
||||
whiteSpace: "nowrap"
|
||||
}}
|
||||
>
|
||||
Artikel Bewerten
|
||||
</Button>
|
||||
{(product.available !== 1 && product.availableSupplier !== 1) && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={this.toggleAvailabilityForm}
|
||||
sx={{
|
||||
fontSize: "0.75rem",
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
minWidth: "auto",
|
||||
whiteSpace: "nowrap",
|
||||
borderColor: "warning.main",
|
||||
color: "warning.main",
|
||||
"&:hover": {
|
||||
borderColor: "warning.dark",
|
||||
backgroundColor: "warning.light"
|
||||
}
|
||||
}}
|
||||
>
|
||||
Verfügbarkeit anfragen
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
|
||||
{/* Weight */}
|
||||
{product.weight > 0 && (
|
||||
@@ -1338,7 +1614,7 @@ class ProductDetailPage extends Component {
|
||||
}}
|
||||
size="small"
|
||||
>
|
||||
Teilen
|
||||
{this.props.t ? this.props.t("productDialogs.shareTitle") : "Teilen"}
|
||||
</Button>
|
||||
<Box
|
||||
sx={{
|
||||
@@ -1350,16 +1626,30 @@ class ProductDetailPage extends Component {
|
||||
>
|
||||
{product.description ? (() => {
|
||||
try {
|
||||
// Sanitize HTML to remove invalid tags, but preserve style attributes
|
||||
return parse(sanitizeHtml(product.description, {
|
||||
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
|
||||
// Sanitize HTML to remove invalid tags, but preserve style attributes and <product> tags
|
||||
const sanitized = sanitizeHtml(product.description, {
|
||||
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img', 'product']),
|
||||
allowedAttributes: {
|
||||
'*': ['class', 'style'],
|
||||
'a': ['href', 'title'],
|
||||
'img': ['src', 'alt', 'width', 'height']
|
||||
'img': ['src', 'alt', 'width', 'height'],
|
||||
'product': ['articlenr']
|
||||
},
|
||||
disallowedTagsMode: 'discard'
|
||||
}));
|
||||
});
|
||||
|
||||
// Parse with custom replace function to handle <product> tags
|
||||
return parse(sanitized, {
|
||||
replace: (domNode) => {
|
||||
if (domNode.type === 'tag' && domNode.name === 'product') {
|
||||
const articleNr = domNode.attribs && domNode.attribs['articlenr'];
|
||||
if (articleNr) {
|
||||
// Render embedded product component
|
||||
return this.renderEmbeddedProduct(articleNr);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse product description HTML:', error);
|
||||
// Fallback to rendering as plain text if HTML parsing fails
|
||||
@@ -1398,7 +1688,7 @@ class ProductDetailPage extends Component {
|
||||
<ListItemIcon>
|
||||
<CodeIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Einbetten" />
|
||||
<ListItemText primary={this.props.t ? this.props.t("productDialogs.shareEmbed") : "Einbetten"} />
|
||||
</MenuItem>
|
||||
<MenuItem onClick={this.handleWhatsAppShare}>
|
||||
<ListItemIcon>
|
||||
@@ -1428,7 +1718,7 @@ class ProductDetailPage extends Component {
|
||||
<ListItemIcon>
|
||||
<LinkIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Link kopieren" />
|
||||
<ListItemText primary={this.props.t ? this.props.t("productDialogs.shareCopyLink") : "Link kopieren"} />
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Box>
|
||||
@@ -1682,7 +1972,7 @@ class ProductDetailPage extends Component {
|
||||
gap: 2
|
||||
}}>
|
||||
{this.state.similarProducts.map((similarProductData, index) => {
|
||||
const product = similarProductData.product;
|
||||
const product = similarProductData.translatedProduct || similarProductData.product;
|
||||
return (
|
||||
<Box key={product.id} sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Product
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import React from 'react';
|
||||
import { useParams, useNavigate, useLocation } from 'react-router-dom';
|
||||
import ProductDetailPage from './ProductDetailPage.js';
|
||||
import { useProduct } from '../context/ProductContext.js';
|
||||
|
||||
const ProductDetailWithSocket = () => {
|
||||
const { seoName } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { setCurrentProduct } = useProduct();
|
||||
|
||||
return (
|
||||
<ProductDetailPage seoName={seoName} navigate={navigate} location={location} />
|
||||
<ProductDetailPage
|
||||
seoName={seoName}
|
||||
navigate={navigate}
|
||||
location={location}
|
||||
setCurrentProduct={setCurrentProduct}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductDetailWithSocket;
|
||||
export default ProductDetailWithSocket;
|
||||
|
||||
@@ -209,7 +209,7 @@ class ProductFilters extends Component {
|
||||
color: 'primary.main'
|
||||
}}
|
||||
>
|
||||
{this.props.dataParam}
|
||||
{this.props.categoryName}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
@@ -27,7 +28,7 @@ class SharedCarousel extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const { i18n } = props;
|
||||
|
||||
|
||||
// Don't load categories in constructor - will be loaded in componentDidMount with correct language
|
||||
this.state = {
|
||||
categories: [],
|
||||
@@ -41,7 +42,7 @@ class SharedCarousel extends React.Component {
|
||||
componentDidMount() {
|
||||
this._isMounted = true;
|
||||
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n.language;
|
||||
|
||||
|
||||
// ALWAYS reload categories to ensure correct language
|
||||
console.log("SharedCarousel componentDidMount: ALWAYS RELOADING categories for language", currentLanguage);
|
||||
window.categoryService.get(209, currentLanguage).then((response) => {
|
||||
@@ -60,12 +61,12 @@ class SharedCarousel extends React.Component {
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
console.log("componentDidUpdate", prevProps.languageContext?.currentLanguage, this.props.languageContext?.currentLanguage);
|
||||
if(prevProps.languageContext?.currentLanguage !== this.props.languageContext?.currentLanguage) {
|
||||
this.setState({ categories: [] },() => {
|
||||
window.categoryService.get(209,this.props.languageContext?.currentLanguage || this.props.i18n.language).then((response) => {
|
||||
if (prevProps.languageContext?.currentLanguage !== this.props.languageContext?.currentLanguage) {
|
||||
this.setState({ categories: [] }, () => {
|
||||
window.categoryService.get(209, this.props.languageContext?.currentLanguage || this.props.i18n.language).then((response) => {
|
||||
console.log("response", response);
|
||||
if (response.children && response.children.length > 0) {
|
||||
this.originalCategories = response.children;
|
||||
this.originalCategories = response.children;
|
||||
this.categories = [...response.children, ...response.children];
|
||||
this.setState({ categories: this.categories });
|
||||
this.startAutoScroll();
|
||||
@@ -123,7 +124,7 @@ class SharedCarousel extends React.Component {
|
||||
showScrollbarFlash = () => {
|
||||
this.clearScrollbarTimer();
|
||||
this.setState({ showScrollbar: true });
|
||||
|
||||
|
||||
this.scrollbarTimer = setTimeout(() => {
|
||||
if (this._isMounted) {
|
||||
this.setState({ showScrollbar: false });
|
||||
@@ -133,7 +134,7 @@ class SharedCarousel extends React.Component {
|
||||
|
||||
handleAutoScroll = () => {
|
||||
if (!this.autoScrollActive || this.originalCategories.length === 0) return;
|
||||
|
||||
|
||||
this.translateX -= AUTO_SCROLL_SPEED;
|
||||
this.updateTrackTransform();
|
||||
|
||||
@@ -172,7 +173,7 @@ class SharedCarousel extends React.Component {
|
||||
|
||||
scrollBy = (direction) => {
|
||||
if (this.originalCategories.length === 0) return;
|
||||
|
||||
|
||||
// direction: 1 = left (scroll content right), -1 = right (scroll content left)
|
||||
const originalItemCount = this.originalCategories.length;
|
||||
const maxScroll = ITEM_WIDTH * originalItemCount;
|
||||
@@ -189,7 +190,7 @@ class SharedCarousel extends React.Component {
|
||||
}
|
||||
|
||||
this.updateTrackTransform();
|
||||
|
||||
|
||||
// Force scrollbar to update immediately after wrap-around
|
||||
if (this.state.showScrollbar) {
|
||||
this.forceUpdate();
|
||||
@@ -204,11 +205,11 @@ class SharedCarousel extends React.Component {
|
||||
const originalItemCount = this.originalCategories.length;
|
||||
const viewportWidth = 1080; // carousel container max-width
|
||||
const itemsInView = Math.floor(viewportWidth / ITEM_WIDTH);
|
||||
|
||||
|
||||
// Calculate which item is currently at the left edge (first visible)
|
||||
// Map translateX directly to item index using the same logic as scrollBy
|
||||
let currentItemIndex;
|
||||
|
||||
|
||||
if (this.translateX === 0) {
|
||||
// At the beginning - item 0 is visible
|
||||
currentItemIndex = 0;
|
||||
@@ -221,10 +222,10 @@ class SharedCarousel extends React.Component {
|
||||
// Normal negative scrolling - calculate which item is at left edge
|
||||
currentItemIndex = Math.floor(Math.abs(this.translateX) / ITEM_WIDTH);
|
||||
}
|
||||
|
||||
|
||||
// Ensure we stay within bounds
|
||||
currentItemIndex = Math.max(0, Math.min(currentItemIndex, originalItemCount - 1));
|
||||
|
||||
|
||||
// Calculate scrollbar position: 0% when item 0 is first visible, 100% when last item is first visible
|
||||
const lastPossibleFirstItem = Math.max(0, originalItemCount - itemsInView);
|
||||
const thumbPosition = lastPossibleFirstItem > 0 ? Math.min((currentItemIndex / lastPossibleFirstItem) * 100, 100) : 0;
|
||||
@@ -268,25 +269,41 @@ class SharedCarousel extends React.Component {
|
||||
const { t } = this.props;
|
||||
const { categories } = this.state;
|
||||
|
||||
if(!categories || categories.length === 0) {
|
||||
if (!categories || categories.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Typography
|
||||
variant="h4"
|
||||
component="h1"
|
||||
<Box
|
||||
component={Link}
|
||||
to="/Kategorien"
|
||||
sx={{
|
||||
mb: 2,
|
||||
fontFamily: "SwashingtonCP",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
textDecoration: "none",
|
||||
color: "primary.main",
|
||||
textAlign: "center",
|
||||
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)"
|
||||
mb: 2,
|
||||
transition: "all 0.3s ease",
|
||||
"&:hover": {
|
||||
transform: "translateX(5px)",
|
||||
color: "primary.dark"
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('navigation.categories')}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h4"
|
||||
component="span"
|
||||
sx={{
|
||||
fontFamily: "SwashingtonCP",
|
||||
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)"
|
||||
}}
|
||||
>
|
||||
{t('navigation.categories')}
|
||||
</Typography>
|
||||
<ChevronRight sx={{ fontSize: "2.5rem", ml: 1 }} />
|
||||
</Box>
|
||||
|
||||
<div
|
||||
className="carousel-wrapper"
|
||||
@@ -394,7 +411,7 @@ class SharedCarousel extends React.Component {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Virtual Scrollbar */}
|
||||
{this.renderVirtualScrollbar()}
|
||||
</div>
|
||||
|
||||
53
src/components/TitleUpdater.js
Normal 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));
|
||||
|
||||
@@ -63,7 +63,7 @@ class ExtrasSelector extends Component {
|
||||
this.loadingImages.add(bildId);
|
||||
window.socketManager.emit('getPic', { bildId, size:'small' }, (res) => {
|
||||
if (res.success) {
|
||||
window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
|
||||
window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/avif' }));
|
||||
this.forceUpdate();
|
||||
}
|
||||
this.loadingImages.delete(bildId);
|
||||
|
||||
@@ -5,6 +5,7 @@ import CardContent from '@mui/material/CardContent';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import { withI18n } from '../../i18n/withTranslation.js';
|
||||
|
||||
class TentShapeSelector extends Component {
|
||||
// Generate plant layout based on tent shape
|
||||
@@ -180,12 +181,20 @@ class TentShapeSelector extends Component {
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
{shape.description}
|
||||
{this.props.t && shape.descriptionKey ? this.props.t(shape.descriptionKey) : shape.description}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Chip
|
||||
label={`${shape.minPlants}-${shape.maxPlants} Pflanzen`}
|
||||
label={this.props.t
|
||||
? (
|
||||
shape.minPlants === 1 && shape.maxPlants === 2 ? this.props.t("kitConfig.plants1to2") :
|
||||
shape.minPlants === 2 && shape.maxPlants === 4 ? this.props.t("kitConfig.plants2to4") :
|
||||
shape.minPlants === 4 && shape.maxPlants === 6 ? this.props.t("kitConfig.plants4to6") :
|
||||
shape.minPlants === 3 && shape.maxPlants === 6 ? this.props.t("kitConfig.plants3to6") :
|
||||
`${shape.minPlants}-${shape.maxPlants} Pflanzen`
|
||||
)
|
||||
: `${shape.minPlants}-${shape.maxPlants} Pflanzen`}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: isSelected ? '#2e7d32' : '#f0f0f0',
|
||||
@@ -205,7 +214,7 @@ class TentShapeSelector extends Component {
|
||||
transition: 'opacity 0.3s ease'
|
||||
}}
|
||||
>
|
||||
✓ Ausgewählt
|
||||
{this.props.t ? this.props.t("kitConfig.selected") : "✓ Ausgewählt"}
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
@@ -238,4 +247,4 @@ class TentShapeSelector extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default TentShapeSelector;
|
||||
export default withI18n()(TentShapeSelector);
|
||||
@@ -32,9 +32,9 @@ class CategoryList extends Component {
|
||||
console.log(" i18n.language:", this.props.i18n?.language);
|
||||
console.log(" sessionStorage i18nextLng:", typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('i18nextLng') : 'N/A');
|
||||
console.log(" localStorage i18nextLng:", typeof localStorage !== 'undefined' ? localStorage.getItem('i18nextLng') : 'N/A');
|
||||
|
||||
|
||||
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n.language;
|
||||
|
||||
|
||||
// ALWAYS reload categories to ensure correct language
|
||||
console.log("CategoryList componentDidMount: ALWAYS RELOADING categories for language", currentLanguage);
|
||||
this.setState({ categories: [] }); // Clear any cached categories
|
||||
@@ -53,15 +53,15 @@ class CategoryList extends Component {
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
console.log("componentDidUpdate", prevProps.languageContext?.currentLanguage, this.props.languageContext?.currentLanguage);
|
||||
if(prevProps.languageContext?.currentLanguage !== this.props.languageContext?.currentLanguage) {
|
||||
if (prevProps.languageContext?.currentLanguage !== this.props.languageContext?.currentLanguage) {
|
||||
this.setState({
|
||||
categories: [],
|
||||
activeCategoryId: null
|
||||
},() => {
|
||||
window.categoryService.get(209,this.props.languageContext?.currentLanguage || this.props.i18n.language).then((response) => {
|
||||
}, () => {
|
||||
window.categoryService.get(209, this.props.languageContext?.currentLanguage || this.props.i18n.language).then((response) => {
|
||||
console.log("response", response);
|
||||
if (response.children && response.children.length > 0) {
|
||||
this.setState({
|
||||
this.setState({
|
||||
categories: response.children,
|
||||
activeCategoryId: this.setLevel1CategoryId(this.props.activeCategoryId)
|
||||
});
|
||||
@@ -69,14 +69,14 @@ class CategoryList extends Component {
|
||||
});
|
||||
});
|
||||
}
|
||||
if (prevProps.activeCategoryId !== this.props.activeCategoryId) {
|
||||
this.setLevel1CategoryId(this.props.activeCategoryId);
|
||||
}
|
||||
if (prevProps.activeCategoryId !== this.props.activeCategoryId) {
|
||||
this.setLevel1CategoryId(this.props.activeCategoryId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
setLevel1CategoryId = (input) => {
|
||||
if(input) {
|
||||
if (input) {
|
||||
const language = this.props.languageContext?.currentLanguage || this.props.i18n.language;
|
||||
const categoryTreeCache = window.categoryService.getSync(209, language);
|
||||
|
||||
@@ -136,7 +136,7 @@ class CategoryList extends Component {
|
||||
this.setState({ activeCategoryId: null });
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
handleMobileMenuToggle = () => {
|
||||
this.setState(prevState => ({
|
||||
@@ -167,152 +167,147 @@ class CategoryList extends Component {
|
||||
display: "flex",
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "center",
|
||||
flexWrap: isMobile ? "wrap" : "nowrap",
|
||||
overflowX: isMobile ? "visible" : "auto",
|
||||
flexWrap: "wrap",
|
||||
overflowX: "visible",
|
||||
flexDirection: isMobile ? "column" : "row",
|
||||
py: 0.5, // Add vertical padding to prevent border clipping
|
||||
"&::-webkit-scrollbar": {
|
||||
display: "none",
|
||||
},
|
||||
scrollbarWidth: "none",
|
||||
msOverflowStyle: "none",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
component={Link}
|
||||
to="/"
|
||||
color="inherit"
|
||||
size="small"
|
||||
aria-label="Zur Startseite"
|
||||
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
|
||||
sx={{
|
||||
fontSize: "0.75rem",
|
||||
textTransform: "none",
|
||||
whiteSpace: "nowrap",
|
||||
opacity: 0.9,
|
||||
mx: isMobile ? 0 : 0.5,
|
||||
my: 0.25,
|
||||
minWidth: isMobile ? "100%" : "auto",
|
||||
borderRadius: 1,
|
||||
justifyContent: isMobile ? "flex-start" : "center",
|
||||
transition: "all 0.2s ease",
|
||||
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
|
||||
position: "relative",
|
||||
...(activeCategoryId === null && {
|
||||
bgcolor: "#fff",
|
||||
textShadow: "none",
|
||||
opacity: 1,
|
||||
}),
|
||||
"&:hover": {
|
||||
opacity: 1,
|
||||
bgcolor: "#fff",
|
||||
textShadow: "none",
|
||||
"& .MuiSvgIcon-root": {
|
||||
color: "#2e7d32 !important",
|
||||
},
|
||||
"& .bold-text": {
|
||||
color: "#2e7d32 !important",
|
||||
},
|
||||
"& .thin-text": {
|
||||
color: "transparent !important",
|
||||
},
|
||||
<Button
|
||||
component={Link}
|
||||
to="/"
|
||||
color="inherit"
|
||||
size="small"
|
||||
aria-label="Zur Startseite"
|
||||
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
|
||||
sx={{
|
||||
fontSize: "0.75rem",
|
||||
textTransform: "none",
|
||||
whiteSpace: "nowrap",
|
||||
opacity: 0.9,
|
||||
mx: isMobile ? 0 : 0.5,
|
||||
my: 0.25,
|
||||
minWidth: isMobile ? "100%" : "auto",
|
||||
borderRadius: 1,
|
||||
justifyContent: isMobile ? "flex-start" : "center",
|
||||
transition: "all 0.2s ease",
|
||||
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
|
||||
position: "relative",
|
||||
...(activeCategoryId === null && {
|
||||
bgcolor: "#fff",
|
||||
textShadow: "none",
|
||||
opacity: 1,
|
||||
}),
|
||||
"&:hover": {
|
||||
opacity: 1,
|
||||
bgcolor: "#fff",
|
||||
textShadow: "none",
|
||||
"& .MuiSvgIcon-root": {
|
||||
color: "#2e7d32 !important",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<HomeIcon sx={{
|
||||
fontSize: "1rem",
|
||||
mr: isMobile ? 1 : 0,
|
||||
color: activeCategoryId === null ? "#2e7d32" : "inherit"
|
||||
}} />
|
||||
{isMobile && (
|
||||
<Box sx={{ position: "relative", display: "inline-block" }}>
|
||||
{/* Bold text (always rendered to set width) */}
|
||||
<Box
|
||||
className="bold-text"
|
||||
sx={{
|
||||
fontWeight: "bold",
|
||||
color: activeCategoryId === null ? "#2e7d32" : "transparent",
|
||||
position: "relative",
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
|
||||
</Box>
|
||||
{/* Thin text (positioned on top) */}
|
||||
<Box
|
||||
className="thin-text"
|
||||
sx={{
|
||||
fontWeight: "400",
|
||||
color: activeCategoryId === null ? "transparent" : "inherit",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
|
||||
</Box>
|
||||
"& .bold-text": {
|
||||
color: "#2e7d32 !important",
|
||||
},
|
||||
"& .thin-text": {
|
||||
color: "transparent !important",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<HomeIcon sx={{
|
||||
fontSize: "1rem",
|
||||
mr: isMobile ? 1 : 0,
|
||||
color: activeCategoryId === null ? "#2e7d32" : "inherit"
|
||||
}} />
|
||||
{isMobile && (
|
||||
<Box sx={{ position: "relative", display: "inline-block" }}>
|
||||
{/* Bold text (always rendered to set width) */}
|
||||
<Box
|
||||
className="bold-text"
|
||||
sx={{
|
||||
fontWeight: "bold",
|
||||
color: activeCategoryId === null ? "#2e7d32" : "transparent",
|
||||
position: "relative",
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
|
||||
</Box>
|
||||
)}
|
||||
</Button>
|
||||
{/* Thin text (positioned on top) */}
|
||||
<Box
|
||||
className="thin-text"
|
||||
sx={{
|
||||
fontWeight: "400",
|
||||
color: activeCategoryId === null ? "transparent" : "inherit",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
component={Link}
|
||||
to="/Kategorie/neu"
|
||||
color="inherit"
|
||||
size="small"
|
||||
aria-label="Neuheiten"
|
||||
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
|
||||
sx={{
|
||||
fontSize: "0.75rem",
|
||||
textTransform: "none",
|
||||
whiteSpace: "nowrap",
|
||||
opacity: 0.9,
|
||||
mx: isMobile ? 0 : 0.5,
|
||||
my: 0.25,
|
||||
minWidth: isMobile ? "100%" : "auto",
|
||||
borderRadius: 1,
|
||||
justifyContent: isMobile ? "flex-start" : "center",
|
||||
transition: "all 0.2s ease",
|
||||
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
|
||||
position: "relative"
|
||||
}}
|
||||
>
|
||||
<FiberNewIcon sx={{
|
||||
fontSize: "1rem",
|
||||
mr: isMobile ? 1 : 0
|
||||
}} />
|
||||
{isMobile && (
|
||||
<Box sx={{ position: "relative", display: "inline-block" }}>
|
||||
{/* Bold text (always rendered to set width) */}
|
||||
<Box
|
||||
className="bold-text"
|
||||
sx={{
|
||||
fontWeight: "bold",
|
||||
color: "transparent",
|
||||
position: "relative",
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
{this.props.t ? this.props.t('navigation.new') : 'Neuheiten'}
|
||||
</Box>
|
||||
{/* Thin text (positioned on top) */}
|
||||
<Box
|
||||
className="thin-text"
|
||||
sx={{
|
||||
fontWeight: "400",
|
||||
color: "inherit",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{this.props.t ? this.props.t('navigation.new') : 'Neuheiten'}
|
||||
</Box>
|
||||
<Button
|
||||
component={Link}
|
||||
to="/Kategorie/neu"
|
||||
color="inherit"
|
||||
size="small"
|
||||
aria-label="Neuheiten"
|
||||
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
|
||||
sx={{
|
||||
fontSize: "0.75rem",
|
||||
textTransform: "none",
|
||||
whiteSpace: "nowrap",
|
||||
opacity: 0.9,
|
||||
mx: isMobile ? 0 : 0.5,
|
||||
my: 0.25,
|
||||
minWidth: isMobile ? "100%" : "auto",
|
||||
borderRadius: 1,
|
||||
justifyContent: isMobile ? "flex-start" : "center",
|
||||
transition: "all 0.2s ease",
|
||||
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
|
||||
position: "relative"
|
||||
}}
|
||||
>
|
||||
<FiberNewIcon sx={{
|
||||
fontSize: "1rem",
|
||||
mr: isMobile ? 1 : 0
|
||||
}} />
|
||||
{isMobile && (
|
||||
<Box sx={{ position: "relative", display: "inline-block" }}>
|
||||
{/* Bold text (always rendered to set width) */}
|
||||
<Box
|
||||
className="bold-text"
|
||||
sx={{
|
||||
fontWeight: "bold",
|
||||
color: "transparent",
|
||||
position: "relative",
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
{this.props.t ? this.props.t('navigation.new') : 'Neuheiten'}
|
||||
</Box>
|
||||
)}
|
||||
</Button>
|
||||
{/* Thin text (positioned on top) */}
|
||||
<Box
|
||||
className="thin-text"
|
||||
sx={{
|
||||
fontWeight: "400",
|
||||
color: "inherit",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{this.props.t ? this.props.t('navigation.new') : 'Neuheiten'}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
|
||||
{categories.length > 0 ? (
|
||||
@@ -390,100 +385,100 @@ class CategoryList extends Component {
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : ( !isMobile && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="inherit"
|
||||
sx={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
height: "33px", // Match small button height
|
||||
px: 1,
|
||||
fontSize: "0.75rem",
|
||||
opacity: 0.9,
|
||||
}}
|
||||
>
|
||||
|
||||
</Typography>
|
||||
)
|
||||
)}
|
||||
<Button
|
||||
component={Link}
|
||||
to="/Konfigurator"
|
||||
) : (!isMobile && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="inherit"
|
||||
size="small"
|
||||
aria-label="Zur Startseite"
|
||||
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
|
||||
sx={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
height: "33px", // Match small button height
|
||||
px: 1,
|
||||
fontSize: "0.75rem",
|
||||
textTransform: "none",
|
||||
whiteSpace: "nowrap",
|
||||
opacity: 0.9,
|
||||
mx: isMobile ? 0 : 0.5,
|
||||
my: 0.25,
|
||||
minWidth: isMobile ? "100%" : "auto",
|
||||
borderRadius: 1,
|
||||
justifyContent: isMobile ? "flex-start" : "center",
|
||||
transition: "all 0.2s ease",
|
||||
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
|
||||
position: "relative",
|
||||
...(activeCategoryId === null && {
|
||||
bgcolor: "#fff",
|
||||
textShadow: "none",
|
||||
opacity: 1,
|
||||
}),
|
||||
"&:hover": {
|
||||
opacity: 1,
|
||||
bgcolor: "#fff",
|
||||
textShadow: "none",
|
||||
"& .MuiSvgIcon-root": {
|
||||
color: "#2e7d32 !important",
|
||||
},
|
||||
"& .bold-text": {
|
||||
color: "#2e7d32 !important",
|
||||
},
|
||||
"& .thin-text": {
|
||||
color: "transparent !important",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SettingsIcon sx={{
|
||||
fontSize: "1rem",
|
||||
mr: isMobile ? 1 : 0,
|
||||
color: activeCategoryId === null ? "#2e7d32" : "inherit"
|
||||
}} />
|
||||
{isMobile && (
|
||||
<Box sx={{ position: "relative", display: "inline-block" }}>
|
||||
{/* Bold text (always rendered to set width) */}
|
||||
<Box
|
||||
className="bold-text"
|
||||
sx={{
|
||||
fontWeight: "bold",
|
||||
color: activeCategoryId === null ? "#2e7d32" : "transparent",
|
||||
position: "relative",
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
|
||||
</Box>
|
||||
{/* Thin text (positioned on top) */}
|
||||
<Box
|
||||
className="thin-text"
|
||||
sx={{
|
||||
fontWeight: "400",
|
||||
color: activeCategoryId === null ? "transparent" : "inherit",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
|
||||
</Box>
|
||||
|
||||
</Typography>
|
||||
)
|
||||
)}
|
||||
<Button
|
||||
component={Link}
|
||||
to="/Konfigurator"
|
||||
color="inherit"
|
||||
size="small"
|
||||
aria-label="Zur Startseite"
|
||||
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
|
||||
sx={{
|
||||
fontSize: "0.75rem",
|
||||
textTransform: "none",
|
||||
whiteSpace: "nowrap",
|
||||
opacity: 0.9,
|
||||
mx: isMobile ? 0 : 0.5,
|
||||
my: 0.25,
|
||||
minWidth: isMobile ? "100%" : "auto",
|
||||
borderRadius: 1,
|
||||
justifyContent: isMobile ? "flex-start" : "center",
|
||||
transition: "all 0.2s ease",
|
||||
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
|
||||
position: "relative",
|
||||
...(activeCategoryId === null && {
|
||||
bgcolor: "#fff",
|
||||
textShadow: "none",
|
||||
opacity: 1,
|
||||
}),
|
||||
"&:hover": {
|
||||
opacity: 1,
|
||||
bgcolor: "#fff",
|
||||
textShadow: "none",
|
||||
"& .MuiSvgIcon-root": {
|
||||
color: "#2e7d32 !important",
|
||||
},
|
||||
"& .bold-text": {
|
||||
color: "#2e7d32 !important",
|
||||
},
|
||||
"& .thin-text": {
|
||||
color: "transparent !important",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SettingsIcon sx={{
|
||||
fontSize: "1rem",
|
||||
mr: isMobile ? 1 : 0,
|
||||
color: activeCategoryId === null ? "#2e7d32" : "inherit"
|
||||
}} />
|
||||
{isMobile && (
|
||||
<Box sx={{ position: "relative", display: "inline-block" }}>
|
||||
{/* Bold text (always rendered to set width) */}
|
||||
<Box
|
||||
className="bold-text"
|
||||
sx={{
|
||||
fontWeight: "bold",
|
||||
color: activeCategoryId === null ? "#2e7d32" : "transparent",
|
||||
position: "relative",
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
|
||||
</Box>
|
||||
)}
|
||||
</Button>
|
||||
{/* Thin text (positioned on top) */}
|
||||
<Box
|
||||
className="thin-text"
|
||||
sx={{
|
||||
fontWeight: "400",
|
||||
color: activeCategoryId === null ? "transparent" : "inherit",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
</Box>
|
||||
);
|
||||
@@ -521,11 +516,11 @@ class CategoryList extends Component {
|
||||
>
|
||||
<Container maxWidth="lg" sx={{ px: 2 }}>
|
||||
{/* Toggle Button */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
py: 1,
|
||||
cursor: "pointer",
|
||||
"&:hover": {
|
||||
@@ -535,7 +530,7 @@ class CategoryList extends Component {
|
||||
onClick={this.handleMobileMenuToggle}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={this.props.t ?
|
||||
aria-label={this.props.t ?
|
||||
(mobileMenuOpen ? this.props.t('navigation.categoriesClose') : this.props.t('navigation.categoriesOpen')) :
|
||||
(mobileMenuOpen ? "Kategorien schließen" : "Kategorien öffnen")
|
||||
}
|
||||
@@ -546,11 +541,11 @@ class CategoryList extends Component {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" color="inherit" sx={{
|
||||
<Typography variant="subtitle2" color="inherit" sx={{
|
||||
fontWeight: "bold",
|
||||
textShadow: "0 1px 2px rgba(0,0,0,0.3)"
|
||||
}}>
|
||||
{this.props.t ? this.props.t('navigation.categories') : 'Kategorien'}
|
||||
{this.props.t ? this.props.t('navigation.categories') : 'Kategorien'}
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||
{mobileMenuOpen ? <CloseIcon /> : <MenuIcon />}
|
||||
|
||||
@@ -16,7 +16,7 @@ const Logo = () => {
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/assets/images/sh.png"
|
||||
src="/assets/images/sh.avif"
|
||||
alt="SH Logo"
|
||||
width="108px"
|
||||
height="45px"
|
||||
|
||||
@@ -79,7 +79,7 @@ const SearchBar = () => {
|
||||
(response) => {
|
||||
if (response && response.products) {
|
||||
// getSearchProducts returns response.products array
|
||||
const suggestions = response.products.slice(0, 8); // Limit to 8 suggestions
|
||||
const suggestions = response.products.map(p => p.translatedProduct || p).slice(0, 8); // Limit to 8 suggestions
|
||||
setSuggestions(suggestions);
|
||||
setShowSuggestions(suggestions.length > 0);
|
||||
setSelectedIndex(-1); // Reset selection
|
||||
|
||||
@@ -17,7 +17,7 @@ const DeliveryMethodSelector = ({ deliveryMethod, onChange, isPickupOnly, cartIt
|
||||
name: 'DHL',
|
||||
description: isPickupOnly ? (t ? t('delivery.descriptions.notAvailable') : "nicht auswählbar weil ein oder mehrere Artikel nur abgeholt werden können") :
|
||||
isFreeShipping ? (t ? t('delivery.descriptions.standardFree') : 'Standardversand - KOSTENLOS ab 100€ Warenwert!') : (t ? t('delivery.descriptions.standard') : 'Standardversand'),
|
||||
price: isFreeShipping ? (t ? t('delivery.prices.free') : 'kostenlos') : (t ? t('delivery.prices.dhl') : '6,99 €'),
|
||||
price: isFreeShipping ? (t ? t('delivery.prices.free') : 'kostenlos') : (t ? t('delivery.prices.dhl') : '5,90 €'),
|
||||
disabled: isPickupOnly
|
||||
},
|
||||
{
|
||||
|
||||
@@ -31,6 +31,7 @@ const getStatusTranslation = (status, t) => {
|
||||
new: t ? t('orders.status.new') : "in Bearbeitung",
|
||||
pending: t ? t('orders.status.pending') : "Neu",
|
||||
processing: t ? t('orders.status.processing') : "in Bearbeitung",
|
||||
paid: t ? t('orders.status.paid') : "Bezahlt",
|
||||
cancelled: t ? t('orders.status.cancelled') : "Storniert",
|
||||
shipped: t ? t('orders.status.shipped') : "Verschickt",
|
||||
delivered: t ? t('orders.status.delivered') : "Geliefert",
|
||||
@@ -39,29 +40,23 @@ const getStatusTranslation = (status, t) => {
|
||||
};
|
||||
|
||||
const statusEmojis = {
|
||||
"in Bearbeitung": "⚙️",
|
||||
new: "⚙️",
|
||||
pending: "⏳",
|
||||
processing: "🔄",
|
||||
paid: "🏦",
|
||||
cancelled: "❌",
|
||||
Verschickt: "🚚",
|
||||
Geliefert: "✅",
|
||||
Storniert: "❌",
|
||||
Retoure: "↩️",
|
||||
"Teil Retoure": "↪️",
|
||||
"Teil geliefert": "⚡",
|
||||
shipped: "🚚",
|
||||
delivered: "✅",
|
||||
};
|
||||
|
||||
const statusColors = {
|
||||
"in Bearbeitung": "#ed6c02", // orange
|
||||
new: "#ed6c02", // orange
|
||||
pending: "#ff9800", // orange for pending
|
||||
processing: "#2196f3", // blue for processing
|
||||
paid: "#2e7d32", // green
|
||||
cancelled: "#d32f2f", // red for cancelled
|
||||
Verschickt: "#2e7d32", // green
|
||||
Geliefert: "#2e7d32", // green
|
||||
Storniert: "#d32f2f", // red
|
||||
Retoure: "#9c27b0", // purple
|
||||
"Teil Retoure": "#9c27b0", // purple
|
||||
"Teil geliefert": "#009688", // teal
|
||||
shipped: "#2e7d32", // green
|
||||
delivered: "#2e7d32", // green
|
||||
};
|
||||
|
||||
const currencyFormatter = new Intl.NumberFormat("de-DE", {
|
||||
@@ -229,11 +224,11 @@ const OrdersTab = ({ orderIdFromHash, t }) => {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
color: getStatusColor(displayStatus),
|
||||
color: getStatusColor(order.status),
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "1.2rem" }}>
|
||||
{getStatusEmoji(displayStatus)}
|
||||
{getStatusEmoji(order.status)}
|
||||
</span>
|
||||
<Typography
|
||||
variant="body2"
|
||||
@@ -243,6 +238,18 @@ const OrdersTab = ({ orderIdFromHash, t }) => {
|
||||
{displayStatus}
|
||||
</Typography>
|
||||
</Box>
|
||||
{order.delivery_method === 'DHL' && order.trackingCode && (
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
<a
|
||||
href={`https://www.dhl.de/de/privatkunden/dhl-sendungsverfolgung.html?piececode=${order.trackingCode}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ fontSize: '0.85rem', color: '#d40511' }}
|
||||
>
|
||||
📦 {t ? t('orders.trackShipment') : 'Sendung verfolgen'}
|
||||
</a>
|
||||
</Box>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{order.items
|
||||
|
||||
@@ -200,13 +200,13 @@ const config = {
|
||||
|
||||
// Shipping
|
||||
shipping: {
|
||||
defaultCost: "4.99 EUR",
|
||||
defaultCost: "5.90 EUR",
|
||||
defaultService: "Standard"
|
||||
},
|
||||
|
||||
// Images
|
||||
images: {
|
||||
logo: "/assets/images/sh.png",
|
||||
logo: "/assets/images/sh.avif",
|
||||
placeholder: "/assets/images/nopicture.jpg"
|
||||
},
|
||||
|
||||
|
||||
31
src/context/CategoryContext.js
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
31
src/context/ProductContext.js
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// @note Dummy data for grow tent configurator - no backend calls
|
||||
// descriptions now keys for translation
|
||||
export const tentShapes = [
|
||||
{
|
||||
id: '60x60',
|
||||
name: '60x60cm',
|
||||
description: 'Kompakt - ideal für kleine Räume',
|
||||
descriptionKey: 'kitConfig.description60x60',
|
||||
footprint: '60x60',
|
||||
minPlants: 1,
|
||||
maxPlants: 2,
|
||||
@@ -13,7 +14,7 @@ export const tentShapes = [
|
||||
{
|
||||
id: '80x80',
|
||||
name: '80x80cm',
|
||||
description: 'Mittel - perfekte Balance',
|
||||
descriptionKey: 'kitConfig.description80x80',
|
||||
footprint: '80x80',
|
||||
minPlants: 2,
|
||||
maxPlants: 4,
|
||||
@@ -23,7 +24,7 @@ export const tentShapes = [
|
||||
{
|
||||
id: '100x100',
|
||||
name: '100x100cm',
|
||||
description: 'Groß - für erfahrene Grower',
|
||||
descriptionKey: 'kitConfig.description100x100',
|
||||
footprint: '100x100',
|
||||
minPlants: 4,
|
||||
maxPlants: 6,
|
||||
@@ -33,7 +34,7 @@ export const tentShapes = [
|
||||
{
|
||||
id: '120x60',
|
||||
name: '120x60cm',
|
||||
description: 'Rechteckig - maximale Raumnutzung',
|
||||
descriptionKey: 'kitConfig.description120x60',
|
||||
footprint: '120x60',
|
||||
minPlants: 3,
|
||||
maxPlants: 6,
|
||||
|
||||
@@ -5,6 +5,7 @@ export default {
|
||||
"profile": "الملف الشخصي",
|
||||
"email": "البريد الإلكتروني",
|
||||
"password": "كلمة المرور",
|
||||
"newPassword": "كلمة المرور الجديدة",
|
||||
"confirmPassword": "تأكيد كلمة المرور",
|
||||
"forgotPassword": "هل نسيت كلمة المرور؟",
|
||||
"loginWithGoogle": "تسجيل الدخول باستخدام جوجل",
|
||||
@@ -13,6 +14,7 @@ export default {
|
||||
"privacyPolicy": "سياسة الخصوصية",
|
||||
"passwordMinLength": "يجب أن تكون كلمة المرور 8 أحرف على الأقل",
|
||||
"newPasswordMinLength": "يجب أن تكون كلمة المرور الجديدة 8 أحرف على الأقل",
|
||||
"backToHome": "العودة إلى الصفحة الرئيسية",
|
||||
"menu": {
|
||||
"profile": "الملف الشخصي",
|
||||
"myProfile": "ملفي الشخصي",
|
||||
@@ -21,5 +23,28 @@ export default {
|
||||
"settings": "الإعدادات",
|
||||
"adminDashboard": "لوحة تحكم المسؤول",
|
||||
"adminUsers": "مستخدمو المسؤول"
|
||||
},
|
||||
"resetPassword": {
|
||||
"title": "إعادة تعيين كلمة المرور",
|
||||
"button": "إعادة تعيين كلمة المرور",
|
||||
"success": "تم إعادة تعيين كلمة المرور بنجاح! سيتم توجيهك لتسجيل الدخول قريبًا...",
|
||||
"invalidToken": "لم يتم العثور على رمز صالح. يرجى استخدام الرابط من بريدك الإلكتروني.",
|
||||
"error": "حدث خطأ أثناء إعادة تعيين كلمة المرور",
|
||||
"emailSent": "تم إرسال رابط لإعادة تعيين كلمة المرور إلى بريدك الإلكتروني.",
|
||||
"emailError": "حدث خطأ أثناء إرسال البريد الإلكتروني"
|
||||
},
|
||||
"errors": {
|
||||
"fillAllFields": "يرجى ملء جميع الحقول",
|
||||
"invalidEmail": "يرجى إدخال بريد إلكتروني صالح",
|
||||
"passwordsNotMatch": "كلمات المرور غير متطابقة",
|
||||
"passwordsNotMatchShort": "كلمات المرور غير متطابقة",
|
||||
"enterEmail": "يرجى إدخال بريدك الإلكتروني",
|
||||
"loginFailed": "فشل تسجيل الدخول",
|
||||
"registerFailed": "فشل التسجيل",
|
||||
"googleLoginFailed": "فشل تسجيل الدخول عبر جوجل",
|
||||
"emailExists": "يوجد مستخدم بهذا البريد الإلكتروني بالفعل. يرجى استخدام بريد إلكتروني آخر أو تسجيل الدخول."
|
||||
},
|
||||
"success": {
|
||||
"registerComplete": "تم التسجيل بنجاح. يمكنك الآن تسجيل الدخول."
|
||||
}
|
||||
};
|
||||
|
||||
@@ -8,15 +8,15 @@ export default {
|
||||
},
|
||||
"descriptions": {
|
||||
"standard": "الشحن العادي",
|
||||
"standardFree": "الشحن العادي - مجاني من قيمة طلب 100€!",
|
||||
"notAvailable": "غير قابل للاختيار لأن عنصر واحد أو أكثر يمكن استلامه فقط",
|
||||
"standardFree": "الشحن العادي - مجاني للطلبات فوق 100€!",
|
||||
"notAvailable": "غير متاح للاختيار لأن عنصر واحد أو أكثر يمكن استلامه فقط",
|
||||
"bulky": "للعناصر الكبيرة والثقيلة",
|
||||
"pickupOnly": "الاستلام فقط"
|
||||
},
|
||||
"prices": {
|
||||
"free": "مجاني",
|
||||
"freeFrom100": "(مجاني من 100€)",
|
||||
"dhl": "6.99 €",
|
||||
"dhl": "5.90 €",
|
||||
"dpd": "4.90 €",
|
||||
"sperrgut": "28.99 €"
|
||||
},
|
||||
@@ -27,7 +27,7 @@ export default {
|
||||
},
|
||||
"selector": {
|
||||
"title": "اختر طريقة الشحن",
|
||||
"freeShippingInfo": "💡 الشحن مجاني من قيمة طلب 100€!",
|
||||
"freeShippingInfo": "💡 الشحن مجاني للطلبات فوق 100€!",
|
||||
"remainingForFree": "أضف {{amount}}€ أخرى للشحن المجاني.",
|
||||
"congratsFreeShipping": "🎉 مبروك! حصلت على شحن مجاني!",
|
||||
"cartQualifiesFree": "سلة مشترياتك بقيمة {{amount}}€ مؤهلة للشحن المجاني."
|
||||
|
||||
@@ -3,6 +3,7 @@ import navigation from './navigation.js';
|
||||
import auth from './auth.js';
|
||||
import cart from './cart.js';
|
||||
import product from './product.js';
|
||||
import productDialogs from './productDialogs.js';
|
||||
import search from './search.js';
|
||||
import sorting from './sorting.js';
|
||||
import chat from './chat.js';
|
||||
@@ -18,6 +19,7 @@ import pages from './pages.js';
|
||||
import orders from './orders.js';
|
||||
import settings from './settings.js';
|
||||
import common from './common.js';
|
||||
import kitConfig from './kitConfig.js';
|
||||
import legalDatenschutzBasic from './legal-datenschutz-basic.js';
|
||||
import legalDatenschutzCustomer from './legal-datenschutz-customer.js';
|
||||
import legalDatenschutzGoogleOrders from './legal-datenschutz-google-orders.js';
|
||||
@@ -35,6 +37,7 @@ export default {
|
||||
"auth": auth,
|
||||
"cart": cart,
|
||||
"product": product,
|
||||
"productDialogs": productDialogs,
|
||||
"search": search,
|
||||
"sorting": sorting,
|
||||
"chat": chat,
|
||||
@@ -50,6 +53,7 @@ export default {
|
||||
"orders": orders,
|
||||
"settings": settings,
|
||||
"common": common,
|
||||
"kitConfig": kitConfig,
|
||||
"legalDatenschutzBasic": legalDatenschutzBasic,
|
||||
"legalDatenschutzCustomer": legalDatenschutzCustomer,
|
||||
"legalDatenschutzGoogleOrders": legalDatenschutzGoogleOrders,
|
||||
|
||||
43
src/i18n/locales/ar/kitConfig.js
Normal 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 نباتات"
|
||||
};
|
||||
@@ -3,7 +3,8 @@ export default {
|
||||
"new": "قيد التنفيذ",
|
||||
"pending": "جديد",
|
||||
"processing": "قيد التنفيذ",
|
||||
"cancelled": "ملغاة",
|
||||
"paid": "مدفوع",
|
||||
"cancelled": "ملغي",
|
||||
"shipped": "تم الشحن",
|
||||
"delivered": "تم التوصيل",
|
||||
"return": "إرجاع",
|
||||
@@ -24,6 +25,7 @@ export default {
|
||||
"cancelOrder": "إلغاء الطلب"
|
||||
},
|
||||
"noOrders": "لم تقم بوضع أي طلبات بعد.",
|
||||
"trackShipment": "تتبع الشحنة",
|
||||
"details": {
|
||||
"title": "تفاصيل الطلب: {{orderId}}",
|
||||
"deliveryAddress": "عنوان التوصيل",
|
||||
@@ -36,14 +38,13 @@ export default {
|
||||
"item": "العنصر",
|
||||
"quantity": "الكمية",
|
||||
"price": "السعر",
|
||||
"vat": "ضريبة القيمة المضافة",
|
||||
"total": "الإجمالي",
|
||||
"cancelOrder": "إلغاء الطلب"
|
||||
},
|
||||
"cancelConfirm": {
|
||||
"title": "إلغاء الطلب",
|
||||
"message": "هل أنت متأكد أنك تريد إلغاء هذا الطلب؟",
|
||||
"confirm": "إلغاء الطلب",
|
||||
"confirm": "إلغاء",
|
||||
"cancelling": "جارٍ الإلغاء..."
|
||||
},
|
||||
"processing": "يتم إكمال الطلب..."
|
||||
|
||||
@@ -5,9 +5,10 @@ export default {
|
||||
"notFoundDescription": "المنتج الذي تبحث عنه غير موجود أو تم إزالته.",
|
||||
"backToHome": "العودة إلى الصفحة الرئيسية",
|
||||
"error": "خطأ",
|
||||
"articleNumber": "رقم المقال",
|
||||
"articleNumber": "رقم المنتج",
|
||||
"manufacturer": "الشركة المصنعة",
|
||||
"inclVat": "شامل {{vat}}% ضريبة القيمة المضافة",
|
||||
"inclVatSimple": "شامل ضريبة القيمة المضافة",
|
||||
"priceUnit": "{{price}}/{{unit}}",
|
||||
"new": "جديد",
|
||||
"weeks": "أسابيع",
|
||||
@@ -22,9 +23,10 @@ export default {
|
||||
"weight": "الوزن: {{weight}} كجم",
|
||||
"youSave": "أنت توفر: {{amount}}",
|
||||
"cheaperThanIndividual": "أرخص من الشراء بشكل فردي",
|
||||
"pickupPrice": "سعر الاستلام: 19.90 يورو لكل قطعة.",
|
||||
"pickupPrice": "سعر الاستلام: 19.90 € لكل قطعة.",
|
||||
"consistsOf": "يتكون من:",
|
||||
"loadingComponentDetails": "{{index}}. جارٍ تحميل تفاصيل المكون...",
|
||||
"loadingProduct": "جارٍ تحميل المنتج...",
|
||||
"individualPriceTotal": "إجمالي السعر الفردي:",
|
||||
"setPrice": "سعر المجموعة:",
|
||||
"yourSavings": "توفيرك:",
|
||||
|
||||
61
src/i18n/locales/ar/productDialogs.js
Normal 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مع أطيب التحيات"
|
||||
};
|
||||
@@ -5,14 +5,16 @@ export default {
|
||||
"profile": "Профил",
|
||||
"email": "Имейл",
|
||||
"password": "Парола",
|
||||
"newPassword": "Нова парола",
|
||||
"confirmPassword": "Потвърдете паролата",
|
||||
"forgotPassword": "Забравена парола?",
|
||||
"loginWithGoogle": "Вход с Google",
|
||||
"or": "ИЛИ",
|
||||
"privacyAccept": "С натискане на \"Вход с Google\" приемам",
|
||||
"privacyAccept": "С натискането на \"Вход с Google\" приемам",
|
||||
"privacyPolicy": "Политиката за поверителност",
|
||||
"passwordMinLength": "Паролата трябва да е поне 8 символа",
|
||||
"newPasswordMinLength": "Новата парола трябва да е поне 8 символа",
|
||||
"backToHome": "Обратно към началната страница",
|
||||
"menu": {
|
||||
"profile": "Профил",
|
||||
"myProfile": "Моят профил",
|
||||
@@ -21,5 +23,28 @@ export default {
|
||||
"settings": "Настройки",
|
||||
"adminDashboard": "Админ табло",
|
||||
"adminUsers": "Админ потребители"
|
||||
},
|
||||
"resetPassword": {
|
||||
"title": "Нулиране на парола",
|
||||
"button": "Нулиране на парола",
|
||||
"success": "Вашата парола беше успешно нулирана! Скоро ще бъдете пренасочени към вход...",
|
||||
"invalidToken": "Няма валиден токен. Моля, използвайте линка от имейла си.",
|
||||
"error": "Грешка при нулиране на паролата",
|
||||
"emailSent": "Линк за нулиране на паролата беше изпратен на вашия имейл.",
|
||||
"emailError": "Грешка при изпращане на имейла"
|
||||
},
|
||||
"errors": {
|
||||
"fillAllFields": "Моля, попълнете всички полета",
|
||||
"invalidEmail": "Моля, въведете валиден имейл адрес",
|
||||
"passwordsNotMatch": "Паролите не съвпадат",
|
||||
"passwordsNotMatchShort": "Паролите не съвпадат",
|
||||
"enterEmail": "Моля, въведете вашия имейл адрес",
|
||||
"loginFailed": "Входът не бе успешен",
|
||||
"registerFailed": "Регистрацията не бе успешна",
|
||||
"googleLoginFailed": "Вход с Google не бе успешен",
|
||||
"emailExists": "Потребител с този имейл вече съществува. Моля, използвайте друг имейл или влезте в системата."
|
||||
},
|
||||
"success": {
|
||||
"registerComplete": "Регистрацията беше успешна. Сега можете да влезете."
|
||||
}
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ export default {
|
||||
"prices": {
|
||||
"free": "безплатно",
|
||||
"freeFrom100": "(безплатно от 100€)",
|
||||
"dhl": "6.99 €",
|
||||
"dhl": "5.90 €",
|
||||
"dpd": "4.90 €",
|
||||
"sperrgut": "28.99 €"
|
||||
},
|
||||
@@ -29,7 +29,7 @@ export default {
|
||||
"title": "Изберете метод на доставка",
|
||||
"freeShippingInfo": "💡 Безплатна доставка при поръчка над 100€!",
|
||||
"remainingForFree": "Добавете още {{amount}}€ за безплатна доставка.",
|
||||
"congratsFreeShipping": "🎉 Поздравления! Вие получавате безплатна доставка!",
|
||||
"cartQualifiesFree": "Вашата количка на стойност {{amount}}€ се квалифицира за безплатна доставка."
|
||||
"congratsFreeShipping": "🎉 Поздравления! Получавате безплатна доставка!",
|
||||
"cartQualifiesFree": "Вашата количка на стойност {{amount}}€ отговаря на условията за безплатна доставка."
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import navigation from './navigation.js';
|
||||
import auth from './auth.js';
|
||||
import cart from './cart.js';
|
||||
import product from './product.js';
|
||||
import productDialogs from './productDialogs.js';
|
||||
import search from './search.js';
|
||||
import sorting from './sorting.js';
|
||||
import chat from './chat.js';
|
||||
@@ -18,6 +19,7 @@ import pages from './pages.js';
|
||||
import orders from './orders.js';
|
||||
import settings from './settings.js';
|
||||
import common from './common.js';
|
||||
import kitConfig from './kitConfig.js';
|
||||
import legalDatenschutzBasic from './legal-datenschutz-basic.js';
|
||||
import legalDatenschutzCustomer from './legal-datenschutz-customer.js';
|
||||
import legalDatenschutzGoogleOrders from './legal-datenschutz-google-orders.js';
|
||||
@@ -35,6 +37,7 @@ export default {
|
||||
"auth": auth,
|
||||
"cart": cart,
|
||||
"product": product,
|
||||
"productDialogs": productDialogs,
|
||||
"search": search,
|
||||
"sorting": sorting,
|
||||
"chat": chat,
|
||||
@@ -50,6 +53,7 @@ export default {
|
||||
"orders": orders,
|
||||
"settings": settings,
|
||||
"common": common,
|
||||
"kitConfig": kitConfig,
|
||||
"legalDatenschutzBasic": legalDatenschutzBasic,
|
||||
"legalDatenschutzCustomer": legalDatenschutzCustomer,
|
||||
"legalDatenschutzGoogleOrders": legalDatenschutzGoogleOrders,
|
||||
|
||||
43
src/i18n/locales/bg/kitConfig.js
Normal 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 растения"
|
||||
};
|
||||
@@ -1,14 +1,15 @@
|
||||
export default {
|
||||
"status": {
|
||||
"new": "В процес",
|
||||
"pending": "Нова",
|
||||
"processing": "В процес",
|
||||
"cancelled": "Отменена",
|
||||
"shipped": "Изпратена",
|
||||
"delivered": "Доставена",
|
||||
"new": "в процес",
|
||||
"pending": "Ново",
|
||||
"processing": "в процес",
|
||||
"paid": "Платено",
|
||||
"cancelled": "Отменено",
|
||||
"shipped": "Изпратено",
|
||||
"delivered": "Доставено",
|
||||
"return": "Връщане",
|
||||
"partialReturn": "Частично връщане",
|
||||
"partialDelivered": "Частично доставена"
|
||||
"partialDelivered": "Частично доставено"
|
||||
},
|
||||
"table": {
|
||||
"orderNumber": "Номер на поръчка",
|
||||
@@ -24,6 +25,7 @@ export default {
|
||||
"cancelOrder": "Отмени поръчката"
|
||||
},
|
||||
"noOrders": "Все още не сте направили поръчки.",
|
||||
"trackShipment": "Проследи пратката",
|
||||
"details": {
|
||||
"title": "Подробности за поръчка: {{orderId}}",
|
||||
"deliveryAddress": "Адрес за доставка",
|
||||
@@ -36,15 +38,14 @@ export default {
|
||||
"item": "Артикул",
|
||||
"quantity": "Количество",
|
||||
"price": "Цена",
|
||||
"vat": "ДДС",
|
||||
"total": "Общо",
|
||||
"cancelOrder": "Отмени поръчката"
|
||||
},
|
||||
"cancelConfirm": {
|
||||
"title": "Отмяна на поръчка",
|
||||
"message": "Сигурни ли сте, че искате да отмените тази поръчка?",
|
||||
"confirm": "Отмени поръчката",
|
||||
"cancelling": "Отмяна..."
|
||||
"title": "Отмени поръчката",
|
||||
"message": "Сигурни ли сте, че искате да отмените тази поръчка?",
|
||||
"confirm": "Отмени",
|
||||
"cancelling": "Отмяна..."
|
||||
},
|
||||
"processing": "Поръчката се обработва...",
|
||||
"processing": "Поръчката се обработва..."
|
||||
};
|
||||
|
||||
@@ -8,9 +8,10 @@ export default {
|
||||
"articleNumber": "Номер на артикул",
|
||||
"manufacturer": "Производител",
|
||||
"inclVat": "вкл. {{vat}}% ДДС",
|
||||
"inclVatSimple": "вкл. ДДС",
|
||||
"priceUnit": "{{price}}/{{unit}}",
|
||||
"new": "Нов",
|
||||
"weeks": "седмици",
|
||||
"weeks": "Седмици",
|
||||
"arriving": "Пристигане:",
|
||||
"inclVatFooter": "вкл. {{vat}}% ДДС,*",
|
||||
"availability": "Наличност",
|
||||
@@ -25,6 +26,7 @@ export default {
|
||||
"pickupPrice": "Цена за вземане: 19,90 € на резник.",
|
||||
"consistsOf": "Състои се от:",
|
||||
"loadingComponentDetails": "{{index}}. Зареждане на детайли за компонента...",
|
||||
"loadingProduct": "Зареждане на продукта...",
|
||||
"individualPriceTotal": "Обща индивидуална цена:",
|
||||
"setPrice": "Цена на комплекта:",
|
||||
"yourSavings": "Вашите спестявания:",
|
||||
|
||||
61
src/i18n/locales/bg/productDialogs.js
Normal 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Поздрави"
|
||||
};
|
||||
@@ -5,6 +5,7 @@ export default {
|
||||
"profile": "Profil",
|
||||
"email": "Email",
|
||||
"password": "Heslo",
|
||||
"newPassword": "Nové heslo",
|
||||
"confirmPassword": "Potvrdit heslo",
|
||||
"forgotPassword": "Zapomněli jste heslo?",
|
||||
"loginWithGoogle": "Přihlásit se přes Google",
|
||||
@@ -13,6 +14,7 @@ export default {
|
||||
"privacyPolicy": "Zásadami ochrany osobních údajů",
|
||||
"passwordMinLength": "Heslo musí mít alespoň 8 znaků",
|
||||
"newPasswordMinLength": "Nové heslo musí mít alespoň 8 znaků",
|
||||
"backToHome": "Zpět na domovskou stránku",
|
||||
"menu": {
|
||||
"profile": "Profil",
|
||||
"myProfile": "Můj profil",
|
||||
@@ -21,5 +23,28 @@ export default {
|
||||
"settings": "Nastavení",
|
||||
"adminDashboard": "Admin Dashboard",
|
||||
"adminUsers": "Admin Users"
|
||||
},
|
||||
"resetPassword": {
|
||||
"title": "Obnovení hesla",
|
||||
"button": "Obnovit heslo",
|
||||
"success": "Vaše heslo bylo úspěšně obnoveno! Brzy budete přesměrováni na přihlášení...",
|
||||
"invalidToken": "Nebyl nalezen platný token. Použijte prosím odkaz z vašeho e-mailu.",
|
||||
"error": "Chyba při obnově hesla",
|
||||
"emailSent": "Odkaz pro obnovení hesla byl odeslán na vaši e-mailovou adresu.",
|
||||
"emailError": "Chyba při odesílání e-mailu"
|
||||
},
|
||||
"errors": {
|
||||
"fillAllFields": "Vyplňte prosím všechna pole",
|
||||
"invalidEmail": "Zadejte platnou e-mailovou adresu",
|
||||
"passwordsNotMatch": "Hesla se neshodují",
|
||||
"passwordsNotMatchShort": "Hesla se neshodují",
|
||||
"enterEmail": "Zadejte prosím svou e-mailovou adresu",
|
||||
"loginFailed": "Přihlášení selhalo",
|
||||
"registerFailed": "Registrace selhala",
|
||||
"googleLoginFailed": "Přihlášení přes Google selhalo",
|
||||
"emailExists": "Uživatel s touto e-mailovou adresou již existuje. Použijte prosím jinou e-mailovou adresu nebo se přihlaste."
|
||||
},
|
||||
"success": {
|
||||
"registerComplete": "Registrace byla úspěšná. Nyní se můžete přihlásit."
|
||||
}
|
||||
};
|
||||
|
||||
@@ -8,17 +8,17 @@ export default {
|
||||
},
|
||||
"descriptions": {
|
||||
"standard": "Standardní doprava",
|
||||
"standardFree": "Standardní doprava - ZDARMA od objednávky nad 100€!",
|
||||
"notAvailable": "Nelze vybrat, protože jeden nebo více produktů lze pouze vyzvednout",
|
||||
"bulky": "Pro velké a těžké předměty",
|
||||
"standardFree": "Standardní doprava - ZDARMA od hodnoty objednávky 100€!",
|
||||
"notAvailable": "Nelze vybrat, protože jeden nebo více položek lze pouze vyzvednout",
|
||||
"bulky": "Pro velké a těžké položky",
|
||||
"pickupOnly": "Pouze osobní odběr"
|
||||
},
|
||||
"prices": {
|
||||
"free": "zdarma",
|
||||
"freeFrom100": "(zdarma od 100€)",
|
||||
"dhl": "6,99 €",
|
||||
"dpd": "4,90 €",
|
||||
"sperrgut": "28,99 €"
|
||||
"dhl": "5.90 €",
|
||||
"dpd": "4.90 €",
|
||||
"sperrgut": "28.99 €"
|
||||
},
|
||||
"times": {
|
||||
"cutting14Days": "Doba dodání: 14 dní",
|
||||
@@ -27,9 +27,9 @@ export default {
|
||||
},
|
||||
"selector": {
|
||||
"title": "Vyberte způsob dopravy",
|
||||
"freeShippingInfo": "💡 Doprava zdarma od objednávky nad 100€!",
|
||||
"freeShippingInfo": "💡 Doprava zdarma od hodnoty objednávky 100€!",
|
||||
"remainingForFree": "Přidejte ještě {{amount}}€ pro dopravu zdarma.",
|
||||
"congratsFreeShipping": "🎉 Gratulujeme! Máte dopravu zdarma!",
|
||||
"cartQualifiesFree": "Váš košík za {{amount}}€ má nárok na dopravu zdarma."
|
||||
"cartQualifiesFree": "Váš košík v hodnotě {{amount}}€ má nárok na dopravu zdarma."
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import navigation from './navigation.js';
|
||||
import auth from './auth.js';
|
||||
import cart from './cart.js';
|
||||
import product from './product.js';
|
||||
import productDialogs from './productDialogs.js';
|
||||
import search from './search.js';
|
||||
import sorting from './sorting.js';
|
||||
import chat from './chat.js';
|
||||
@@ -18,6 +19,7 @@ import pages from './pages.js';
|
||||
import orders from './orders.js';
|
||||
import settings from './settings.js';
|
||||
import common from './common.js';
|
||||
import kitConfig from './kitConfig.js';
|
||||
import legalDatenschutzBasic from './legal-datenschutz-basic.js';
|
||||
import legalDatenschutzCustomer from './legal-datenschutz-customer.js';
|
||||
import legalDatenschutzGoogleOrders from './legal-datenschutz-google-orders.js';
|
||||
@@ -35,6 +37,7 @@ export default {
|
||||
"auth": auth,
|
||||
"cart": cart,
|
||||
"product": product,
|
||||
"productDialogs": productDialogs,
|
||||
"search": search,
|
||||
"sorting": sorting,
|
||||
"chat": chat,
|
||||
@@ -50,6 +53,7 @@ export default {
|
||||
"orders": orders,
|
||||
"settings": settings,
|
||||
"common": common,
|
||||
"kitConfig": kitConfig,
|
||||
"legalDatenschutzBasic": legalDatenschutzBasic,
|
||||
"legalDatenschutzCustomer": legalDatenschutzCustomer,
|
||||
"legalDatenschutzGoogleOrders": legalDatenschutzGoogleOrders,
|
||||
|
||||
43
src/i18n/locales/cs/kitConfig.js
Normal 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"
|
||||
};
|
||||
@@ -1,50 +1,51 @@
|
||||
export default {
|
||||
"status": {
|
||||
"new": "Probíhá",
|
||||
"pending": "Nová",
|
||||
"processing": "Probíhá",
|
||||
"cancelled": "Zrušeno",
|
||||
"shipped": "Odesláno",
|
||||
"delivered": "Doručeno",
|
||||
"return": "Vrácení",
|
||||
"partialReturn": "Částečné vrácení",
|
||||
"partialDelivered": "Částečně doručeno"
|
||||
"new": "probíhá",
|
||||
"pending": "Nové",
|
||||
"processing": "probíhá",
|
||||
"paid": "Zaplaceno",
|
||||
"cancelled": "Zrušeno",
|
||||
"shipped": "Odesláno",
|
||||
"delivered": "Doručeno",
|
||||
"return": "Vrácení",
|
||||
"partialReturn": "Částečné vrácení",
|
||||
"partialDelivered": "Částečně doručeno"
|
||||
},
|
||||
"table": {
|
||||
"orderNumber": "Číslo objednávky",
|
||||
"date": "Datum",
|
||||
"status": "Stav",
|
||||
"items": "Položky",
|
||||
"total": "Celkem",
|
||||
"actions": "Akce",
|
||||
"viewDetails": "Zobrazit detaily"
|
||||
"orderNumber": "Číslo objednávky",
|
||||
"date": "Datum",
|
||||
"status": "Stav",
|
||||
"items": "Položky",
|
||||
"total": "Celkem",
|
||||
"actions": "Akce",
|
||||
"viewDetails": "Zobrazit detaily"
|
||||
},
|
||||
"tooltips": {
|
||||
"viewDetails": "Zobrazit detaily",
|
||||
"cancelOrder": "Zrušit objednávku"
|
||||
"viewDetails": "Zobrazit detaily",
|
||||
"cancelOrder": "Zrušit objednávku"
|
||||
},
|
||||
"noOrders": "Ještě jste neprovedli žádné objednávky.",
|
||||
"noOrders": "Ještě jste neprovedli žádné objednávky.",
|
||||
"trackShipment": "Sledovat zásilku",
|
||||
"details": {
|
||||
"title": "Detaily objednávky: {{orderId}}",
|
||||
"deliveryAddress": "Dodací adresa",
|
||||
"invoiceAddress": "Fakturační adresa",
|
||||
"orderDetails": "Detaily objednávky",
|
||||
"deliveryMethod": "Způsob doručení:",
|
||||
"paymentMethod": "Způsob platby:",
|
||||
"notSpecified": "Nespecifikováno",
|
||||
"orderedItems": "Objednané položky",
|
||||
"item": "Položka",
|
||||
"quantity": "Množství",
|
||||
"price": "Cena",
|
||||
"vat": "DPH",
|
||||
"total": "Celkem",
|
||||
"cancelOrder": "Zrušit objednávku"
|
||||
"title": "Detaily objednávky: {{orderId}}",
|
||||
"deliveryAddress": "Dodací adresa",
|
||||
"invoiceAddress": "Fakturační adresa",
|
||||
"orderDetails": "Detaily objednávky",
|
||||
"deliveryMethod": "Způsob doručení:",
|
||||
"paymentMethod": "Způsob platby:",
|
||||
"notSpecified": "Nespecifikováno",
|
||||
"orderedItems": "Objednané položky",
|
||||
"item": "Položka",
|
||||
"quantity": "Množství",
|
||||
"price": "Cena",
|
||||
"total": "Celkem",
|
||||
"cancelOrder": "Zrušit objednávku"
|
||||
},
|
||||
"cancelConfirm": {
|
||||
"title": "Zrušit objednávku",
|
||||
"message": "Opravdu chcete tuto objednávku zrušit?",
|
||||
"confirm": "Zrušit objednávku",
|
||||
"confirm": "Zrušit",
|
||||
"cancelling": "Rušení..."
|
||||
},
|
||||
"processing": "Objednávka se dokončuje...",
|
||||
"processing": "Objednávka se dokončuje..."
|
||||
};
|
||||
|
||||
@@ -8,23 +8,25 @@ export default {
|
||||
"articleNumber": "Číslo artiklu",
|
||||
"manufacturer": "Výrobce",
|
||||
"inclVat": "včetně {{vat}}% DPH",
|
||||
"inclVatSimple": "včetně DPH",
|
||||
"priceUnit": "{{price}}/{{unit}}",
|
||||
"new": "Nové",
|
||||
"weeks": "týdnů",
|
||||
"arriving": "Příchod:",
|
||||
"weeks": "Týdny",
|
||||
"arriving": "Příjezd:",
|
||||
"inclVatFooter": "včetně {{vat}}% DPH,*",
|
||||
"availability": "Dostupnost",
|
||||
"inStock": "skladem",
|
||||
"comingSoon": "Brzy k dispozici",
|
||||
"deliveryTime": "Doba dodání",
|
||||
"inclShort": "vč.",
|
||||
"inclShort": "včetně",
|
||||
"vatShort": "DPH",
|
||||
"weight": "Hmotnost: {{weight}} kg",
|
||||
"youSave": "Ušetříte: {{amount}}",
|
||||
"cheaperThanIndividual": "Levnější než nákup jednotlivě",
|
||||
"pickupPrice": "Cena za vyzvednutí: 19,90 € za řízek.",
|
||||
"pickupPrice": "Cena za odběr: 19,90 € za řízek.",
|
||||
"consistsOf": "Skládá se z:",
|
||||
"loadingComponentDetails": "{{index}}. Načítání detailů komponenty...",
|
||||
"loadingProduct": "Načítání produktu...",
|
||||
"individualPriceTotal": "Celková cena jednotlivě:",
|
||||
"setPrice": "Cena sady:",
|
||||
"yourSavings": "Vaše úspory:",
|
||||
|
||||
61
src/i18n/locales/cs/productDialogs.js
Normal 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"
|
||||
};
|
||||
@@ -5,6 +5,7 @@ export default {
|
||||
"profile": "Profil",
|
||||
"email": "E-Mail",
|
||||
"password": "Passwort",
|
||||
"newPassword": "Neues Passwort",
|
||||
"confirmPassword": "Passwort bestätigen",
|
||||
"forgotPassword": "Passwort vergessen?",
|
||||
"loginWithGoogle": "Mit Google anmelden",
|
||||
@@ -13,6 +14,7 @@ export default {
|
||||
"privacyPolicy": "Datenschutzbestimmungen",
|
||||
"passwordMinLength": "Das Passwort muss mindestens 8 Zeichen lang sein",
|
||||
"newPasswordMinLength": "Das neue Passwort muss mindestens 8 Zeichen lang sein",
|
||||
"backToHome": "Zurück zur Startseite",
|
||||
"menu": {
|
||||
"profile": "Profil",
|
||||
"myProfile": "Mein Profil",
|
||||
@@ -21,5 +23,28 @@ export default {
|
||||
"settings": "Einstellungen",
|
||||
"adminDashboard": "Admin Dashboard",
|
||||
"adminUsers": "Admin Users"
|
||||
},
|
||||
"resetPassword": {
|
||||
"title": "Passwort zurücksetzen",
|
||||
"button": "Passwort zurücksetzen",
|
||||
"success": "Ihr Passwort wurde erfolgreich zurückgesetzt! Sie werden in Kürze zur Anmeldung weitergeleitet...",
|
||||
"invalidToken": "Kein gültiger Token gefunden. Bitte verwenden Sie den Link aus Ihrer E-Mail.",
|
||||
"error": "Fehler beim Zurücksetzen des Passworts",
|
||||
"emailSent": "Ein Link zum Zurücksetzen des Passworts wurde an Ihre E-Mail-Adresse gesendet.",
|
||||
"emailError": "Fehler beim Senden der E-Mail"
|
||||
},
|
||||
"errors": {
|
||||
"fillAllFields": "Bitte füllen Sie alle Felder aus",
|
||||
"invalidEmail": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
|
||||
"passwordsNotMatch": "Die Passwörter stimmen nicht überein",
|
||||
"passwordsNotMatchShort": "Passwörter stimmen nicht überein",
|
||||
"enterEmail": "Bitte geben Sie Ihre E-Mail-Adresse ein",
|
||||
"loginFailed": "Anmeldung fehlgeschlagen",
|
||||
"registerFailed": "Registrierung fehlgeschlagen",
|
||||
"googleLoginFailed": "Google-Anmeldung fehlgeschlagen",
|
||||
"emailExists": "Ein Benutzer mit dieser E-Mail-Adresse existiert bereits. Bitte verwenden Sie eine andere E-Mail-Adresse oder melden Sie sich an."
|
||||
},
|
||||
"success": {
|
||||
"registerComplete": "Registrierung erfolgreich. Sie können sich jetzt anmelden."
|
||||
}
|
||||
};
|
||||
@@ -16,7 +16,7 @@ export default {
|
||||
"prices": {
|
||||
"free": "kostenlos",
|
||||
"freeFrom100": "(kostenlos ab 100€)",
|
||||
"dhl": "6,99 €",
|
||||
"dhl": "5,90 €",
|
||||
"dpd": "4,90 €",
|
||||
"sperrgut": "28,99 €"
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import navigation from './navigation.js';
|
||||
import auth from './auth.js';
|
||||
import cart from './cart.js';
|
||||
import product from './product.js';
|
||||
import productDialogs from './productDialogs.js';
|
||||
import search from './search.js';
|
||||
import sorting from './sorting.js';
|
||||
import chat from './chat.js';
|
||||
@@ -18,6 +19,7 @@ import pages from './pages.js';
|
||||
import orders from './orders.js';
|
||||
import settings from './settings.js';
|
||||
import common from './common.js';
|
||||
import kitConfig from './kitConfig.js';
|
||||
import legalDatenschutzBasic from './legal-datenschutz-basic.js';
|
||||
import legalDatenschutzCustomer from './legal-datenschutz-customer.js';
|
||||
import legalDatenschutzGoogleOrders from './legal-datenschutz-google-orders.js';
|
||||
@@ -35,6 +37,7 @@ export default {
|
||||
"auth": auth,
|
||||
"cart": cart,
|
||||
"product": product,
|
||||
"productDialogs": productDialogs,
|
||||
"search": search,
|
||||
"sorting": sorting,
|
||||
"chat": chat,
|
||||
@@ -50,6 +53,7 @@ export default {
|
||||
"orders": orders,
|
||||
"settings": settings,
|
||||
"common": common,
|
||||
"kitConfig": kitConfig,
|
||||
"legalDatenschutzBasic": legalDatenschutzBasic,
|
||||
"legalDatenschutzCustomer": legalDatenschutzCustomer,
|
||||
"legalDatenschutzGoogleOrders": legalDatenschutzGoogleOrders,
|
||||
|
||||
44
src/i18n/locales/de/kitConfig.js
Normal 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"
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ export default {
|
||||
"new": "in Bearbeitung",
|
||||
"pending": "Neu",
|
||||
"processing": "in Bearbeitung",
|
||||
"paid": "Bezahlt",
|
||||
"cancelled": "Storniert",
|
||||
"shipped": "Verschickt",
|
||||
"delivered": "Geliefert",
|
||||
@@ -24,6 +25,7 @@ export default {
|
||||
"cancelOrder": "Bestellung stornieren"
|
||||
},
|
||||
"noOrders": "Sie haben noch keine Bestellungen aufgegeben.",
|
||||
"trackShipment": "Sendung verfolgen",
|
||||
"details": {
|
||||
"title": "Bestelldetails: {{orderId}}",
|
||||
"deliveryAddress": "Lieferadresse",
|
||||
|
||||
@@ -8,6 +8,7 @@ export default {
|
||||
"articleNumber": "Artikelnummer",
|
||||
"manufacturer": "Hersteller",
|
||||
"inclVat": "inkl. {{vat}}% MwSt.",
|
||||
"inclVatSimple": "inkl. MwSt.",
|
||||
"priceUnit": "{{price}}/{{unit}}",
|
||||
"new": "Neu",
|
||||
"weeks": "Wochen",
|
||||
@@ -25,6 +26,7 @@ export default {
|
||||
"pickupPrice": "Abholpreis: 19,90 € pro Steckling.",
|
||||
"consistsOf": "Bestehend aus:",
|
||||
"loadingComponentDetails": "{{index}}. Lädt Komponent-Details...",
|
||||
"loadingProduct": "Produkt wird geladen...",
|
||||
"individualPriceTotal": "Einzelpreis gesamt:",
|
||||
"setPrice": "Set-Preis:",
|
||||
"yourSavings": "Ihre Ersparnis:",
|
||||
|
||||
62
src/i18n/locales/de/productDialogs.js
Normal 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"
|
||||
};
|
||||
|
||||
@@ -5,14 +5,16 @@ export default {
|
||||
"profile": "Προφίλ",
|
||||
"email": "Email",
|
||||
"password": "Κωδικός",
|
||||
"newPassword": "Νέος κωδικός",
|
||||
"confirmPassword": "Επιβεβαίωση κωδικού",
|
||||
"forgotPassword": "Ξεχάσατε τον κωδικό;",
|
||||
"loginWithGoogle": "Σύνδεση με Google",
|
||||
"or": "Ή",
|
||||
"privacyAccept": "Κάνοντας κλικ στο \"Σύνδεση με Google\" αποδέχομαι την",
|
||||
"privacyPolicy": "Πολιτική Απορρήτου",
|
||||
"privacyPolicy": "Πολιτική απορρήτου",
|
||||
"passwordMinLength": "Ο κωδικός πρέπει να έχει τουλάχιστον 8 χαρακτήρες",
|
||||
"newPasswordMinLength": "Ο νέος κωδικός πρέπει να έχει τουλάχιστον 8 χαρακτήρες",
|
||||
"backToHome": "Επιστροφή στην αρχική σελίδα",
|
||||
"menu": {
|
||||
"profile": "Προφίλ",
|
||||
"myProfile": "Το προφίλ μου",
|
||||
@@ -21,5 +23,28 @@ export default {
|
||||
"settings": "Ρυθμίσεις",
|
||||
"adminDashboard": "Πίνακας διαχείρισης",
|
||||
"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": "Η εγγραφή ολοκληρώθηκε με επιτυχία. Μπορείτε τώρα να συνδεθείτε."
|
||||
}
|
||||
};
|
||||
|
||||
@@ -10,13 +10,13 @@ export default {
|
||||
"standard": "Τυπική αποστολή",
|
||||
"standardFree": "Τυπική αποστολή - ΔΩΡΕΑΝ από παραγγελίες άνω των 100€!",
|
||||
"notAvailable": "Δεν είναι επιλέξιμο γιατί ένα ή περισσότερα είδη μπορούν να παραληφθούν μόνο από το κατάστημα",
|
||||
"bulky": "Για μεγάλα και βαριά αντικείμενα",
|
||||
"bulky": "Για μεγάλα και βαριά είδη",
|
||||
"pickupOnly": "Μόνο παραλαβή"
|
||||
},
|
||||
"prices": {
|
||||
"free": "δωρεάν",
|
||||
"freeFrom100": "(δωρεάν από 100€)",
|
||||
"dhl": "6.99 €",
|
||||
"dhl": "5.90 €",
|
||||
"dpd": "4.90 €",
|
||||
"sperrgut": "28.99 €"
|
||||
},
|
||||
@@ -30,6 +30,6 @@ export default {
|
||||
"freeShippingInfo": "💡 Δωρεάν αποστολή από παραγγελίες άνω των 100€!",
|
||||
"remainingForFree": "Προσθέστε ακόμα {{amount}}€ για δωρεάν αποστολή.",
|
||||
"congratsFreeShipping": "🎉 Συγχαρητήρια! Έχετε δωρεάν αποστολή!",
|
||||
"cartQualifiesFree": "Το καλάθι σας αξίας {{amount}}€ δικαιούται δωρεάν αποστολή."
|
||||
"cartQualifiesFree": "Το καλάθι σας των {{amount}}€ πληροί τις προϋποθέσεις για δωρεάν αποστολή."
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import navigation from './navigation.js';
|
||||
import auth from './auth.js';
|
||||
import cart from './cart.js';
|
||||
import product from './product.js';
|
||||
import productDialogs from './productDialogs.js';
|
||||
import search from './search.js';
|
||||
import sorting from './sorting.js';
|
||||
import chat from './chat.js';
|
||||
@@ -18,6 +19,7 @@ import pages from './pages.js';
|
||||
import orders from './orders.js';
|
||||
import settings from './settings.js';
|
||||
import common from './common.js';
|
||||
import kitConfig from './kitConfig.js';
|
||||
import legalDatenschutzBasic from './legal-datenschutz-basic.js';
|
||||
import legalDatenschutzCustomer from './legal-datenschutz-customer.js';
|
||||
import legalDatenschutzGoogleOrders from './legal-datenschutz-google-orders.js';
|
||||
@@ -35,6 +37,7 @@ export default {
|
||||
"auth": auth,
|
||||
"cart": cart,
|
||||
"product": product,
|
||||
"productDialogs": productDialogs,
|
||||
"search": search,
|
||||
"sorting": sorting,
|
||||
"chat": chat,
|
||||
@@ -50,6 +53,7 @@ export default {
|
||||
"orders": orders,
|
||||
"settings": settings,
|
||||
"common": common,
|
||||
"kitConfig": kitConfig,
|
||||
"legalDatenschutzBasic": legalDatenschutzBasic,
|
||||
"legalDatenschutzCustomer": legalDatenschutzCustomer,
|
||||
"legalDatenschutzGoogleOrders": legalDatenschutzGoogleOrders,
|
||||
|
||||
43
src/i18n/locales/el/kitConfig.js
Normal 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": "Επιλέξτε το κατάλληλο προϊόν για το growbox {{shape}} σας",
|
||||
"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 φυτά"
|
||||
};
|
||||
@@ -1,10 +1,11 @@
|
||||
export default {
|
||||
"status": {
|
||||
"new": "Σε εξέλιξη",
|
||||
"new": "σε εξέλιξη",
|
||||
"pending": "Νέο",
|
||||
"processing": "Σε εξέλιξη",
|
||||
"processing": "σε εξέλιξη",
|
||||
"paid": "Πληρωμένο",
|
||||
"cancelled": "Ακυρώθηκε",
|
||||
"shipped": "Απεστάλη",
|
||||
"shipped": "Απεσταλμένο",
|
||||
"delivered": "Παραδόθηκε",
|
||||
"return": "Επιστροφή",
|
||||
"partialReturn": "Μερική επιστροφή",
|
||||
@@ -24,10 +25,11 @@ export default {
|
||||
"cancelOrder": "Ακύρωση παραγγελίας"
|
||||
},
|
||||
"noOrders": "Δεν έχετε κάνει ακόμα καμία παραγγελία.",
|
||||
"trackShipment": "Παρακολούθηση αποστολής",
|
||||
"details": {
|
||||
"title": "Λεπτομέρειες παραγγελίας: {{orderId}}",
|
||||
"deliveryAddress": "Διεύθυνση παράδοσης",
|
||||
"invoiceAddress": "Διεύθυνση τιμολόγησης",
|
||||
"invoiceAddress": "Διεύθυνση τιμολογίου",
|
||||
"orderDetails": "Λεπτομέρειες παραγγελίας",
|
||||
"deliveryMethod": "Τρόπος παράδοσης:",
|
||||
"paymentMethod": "Τρόπος πληρωμής:",
|
||||
@@ -36,15 +38,14 @@ export default {
|
||||
"item": "Είδος",
|
||||
"quantity": "Ποσότητα",
|
||||
"price": "Τιμή",
|
||||
"vat": "ΦΠΑ",
|
||||
"total": "Σύνολο",
|
||||
"cancelOrder": "Ακύρωση παραγγελίας"
|
||||
},
|
||||
"cancelConfirm": {
|
||||
"title": "Ακύρωση παραγγελίας",
|
||||
"message": "Είστε σίγουροι ότι θέλετε να ακυρώσετε αυτήν την παραγγελία;",
|
||||
"confirm": "Ακύρωση παραγγελίας",
|
||||
"cancelling": "Ακύρωση..."
|
||||
"message": "Είστε σίγουροι ότι θέλετε να ακυρώσετε αυτή την παραγγελία;",
|
||||
"confirm": "Ακύρωση",
|
||||
"cancelling": "Ακύρωση σε εξέλιξη..."
|
||||
},
|
||||
"processing": "Η παραγγελία ολοκληρώνεται..."
|
||||
};
|
||||
|
||||
@@ -8,9 +8,10 @@ export default {
|
||||
"articleNumber": "Αριθμός άρθρου",
|
||||
"manufacturer": "Κατασκευαστής",
|
||||
"inclVat": "συμπ. {{vat}}% ΦΠΑ",
|
||||
"inclVatSimple": "συμπ. ΦΠΑ",
|
||||
"priceUnit": "{{price}}/{{unit}}",
|
||||
"new": "Νέο",
|
||||
"weeks": "εβδομάδες",
|
||||
"weeks": "Εβδομάδες",
|
||||
"arriving": "Άφιξη:",
|
||||
"inclVatFooter": "συμπ. {{vat}}% ΦΠΑ,*",
|
||||
"availability": "Διαθεσιμότητα",
|
||||
@@ -25,9 +26,10 @@ export default {
|
||||
"pickupPrice": "Τιμή παραλαβής: €19.90 ανά μοσχεύμα.",
|
||||
"consistsOf": "Αποτελείται από:",
|
||||
"loadingComponentDetails": "{{index}}. Φόρτωση λεπτομερειών συστατικού...",
|
||||
"loadingProduct": "Φόρτωση προϊόντος...",
|
||||
"individualPriceTotal": "Συνολική τιμή μεμονωμένων:",
|
||||
"setPrice": "Τιμή σετ:",
|
||||
"yourSavings": "Η εξοικονόμησή σας:",
|
||||
"yourSavings": "Οι εξοικονομήσεις σας:",
|
||||
"similarProducts": "Παρόμοια προϊόντα",
|
||||
"countDisplay": {
|
||||
"noProducts": "0 προϊόντα",
|
||||
|
||||
61
src/i18n/locales/el/productDialogs.js
Normal file
@@ -0,0 +1,61 @@
|
||||
export default {
|
||||
"questionTitle": "Ερώτηση σχετικά με το προϊόν",
|
||||
"questionSubtitle": "Έχετε κάποια ερώτηση για αυτό το προϊόν; Είμαστε εδώ για να σας βοηθήσουμε.",
|
||||
"questionSuccess": "Ευχαριστούμε για την ερώτησή σας! Θα επικοινωνήσουμε μαζί σας το συντομότερο δυνατό.",
|
||||
"nameLabel": "Όνομα",
|
||||
"namePlaceholder": "Το όνομά σας",
|
||||
"emailLabel": "Email",
|
||||
"emailPlaceholder": "your.email@example.com",
|
||||
"questionLabel": "Η ερώτησή σας",
|
||||
"questionPlaceholder": "Περιγράψτε την ερώτησή σας σχετικά με αυτό το προϊόν...",
|
||||
"photosLabelQuestion": "Επισυνάψτε φωτογραφίες στην ερώτησή σας (προαιρετικό)",
|
||||
"submitQuestion": "Αποστολή ερώτησης",
|
||||
"sending": "Αποστολή...",
|
||||
|
||||
"ratingTitle": "Αξιολογήστε το προϊόν",
|
||||
"ratingSubtitle": "Μοιραστείτε την εμπειρία σας με αυτό το προϊόν και βοηθήστε άλλους πελάτες να πάρουν την απόφασή τους.",
|
||||
"ratingSuccess": "Ευχαριστούμε για την αξιολόγησή σας! Θα δημοσιευτεί μετά από έλεγχο.",
|
||||
"emailHelper": "Το email σας δεν θα δημοσιευτεί",
|
||||
"ratingLabel": "Αξιολόγηση *",
|
||||
"pleaseRate": "Παρακαλώ αξιολογήστε",
|
||||
"ratingStars": "{{rating}} από 5 αστέρια",
|
||||
"reviewLabel": "Η κριτική σας (προαιρετικό)",
|
||||
"reviewPlaceholder": "Περιγράψτε τις εμπειρίες σας με αυτό το προϊόν...",
|
||||
"photosLabelRating": "Επισυνάψτε φωτογραφίες στην κριτική σας (προαιρετικό)",
|
||||
"submitRating": "Υποβολή κριτικής",
|
||||
"errorGeneric": "Παρουσιάστηκε σφάλμα",
|
||||
"errorPhotos": "Σφάλμα κατά την επεξεργασία των φωτογραφιών",
|
||||
|
||||
"availabilityTitle": "Ζητήστε διαθεσιμότητα",
|
||||
"availabilitySubtitle": "Αυτό το προϊόν δεν είναι διαθέσιμο αυτή τη στιγμή. Θα χαρούμε να σας ενημερώσουμε μόλις είναι ξανά διαθέσιμο.",
|
||||
"availabilitySuccessEmail": "Ευχαριστούμε για το αίτημά σας! Θα σας ενημερώσουμε μέσω email μόλις το προϊόν είναι ξανά διαθέσιμο.",
|
||||
"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Με εκτίμηση"
|
||||
};
|
||||
@@ -5,14 +5,16 @@ export default {
|
||||
"profile": "Profile", // Profil
|
||||
"email": "Email", // E-Mail
|
||||
"password": "Password", // Passwort
|
||||
"newPassword": "New password", // Neues Passwort
|
||||
"confirmPassword": "Confirm password", // Passwort bestätigen
|
||||
"forgotPassword": "Forgot password?", // Passwort vergessen?
|
||||
"loginWithGoogle": "Sign in with Google", // Mit Google anmelden
|
||||
"or": "OR", // ODER
|
||||
"privacyAccept": "By clicking \"Sign in with Google\" I accept the", // Mit dem Click auf "Mit Google anmelden" akzeptiere ich die
|
||||
"privacyPolicy": "Privacy Policy", // Datenschutzbestimmungen
|
||||
"privacyAccept": "By clicking on \"Sign in with Google\" I accept the", // Mit dem Click auf "Mit Google anmelden" akzeptiere ich die
|
||||
"privacyPolicy": "Privacy policy", // Datenschutzbestimmungen
|
||||
"passwordMinLength": "The password must be at least 8 characters long", // Das Passwort muss mindestens 8 Zeichen lang sein
|
||||
"newPasswordMinLength": "The new password must be at least 8 characters long", // Das neue Passwort muss mindestens 8 Zeichen lang sein
|
||||
"backToHome": "Back to homepage", // Zurück zur Startseite
|
||||
"menu": {
|
||||
"profile": "Profile", // Profil
|
||||
"myProfile": "My profile", // Mein Profil
|
||||
@@ -21,5 +23,28 @@ export default {
|
||||
"settings": "Settings", // Einstellungen
|
||||
"adminDashboard": "Admin Dashboard", // Admin Dashboard
|
||||
"adminUsers": "Admin Users" // Admin Users
|
||||
},
|
||||
"resetPassword": {
|
||||
"title": "Reset password", // Passwort zurücksetzen
|
||||
"button": "Reset password", // Passwort zurücksetzen
|
||||
"success": "Your password has been reset successfully! You will be redirected to login shortly...", // Ihr Passwort wurde erfolgreich zurückgesetzt! Sie werden in Kürze zur Anmeldung weitergeleitet...
|
||||
"invalidToken": "No valid token found. Please use the link from your email.", // Kein gültiger Token gefunden. Bitte verwenden Sie den Link aus Ihrer E-Mail.
|
||||
"error": "Error resetting password", // Fehler beim Zurücksetzen des Passworts
|
||||
"emailSent": "A link to reset your password has been sent to your email address.", // Ein Link zum Zurücksetzen des Passworts wurde an Ihre E-Mail-Adresse gesendet.
|
||||
"emailError": "Error sending email" // Fehler beim Senden der E-Mail
|
||||
},
|
||||
"errors": {
|
||||
"fillAllFields": "Please fill in all fields", // Bitte füllen Sie alle Felder aus
|
||||
"invalidEmail": "Please enter a valid email address", // Bitte geben Sie eine gültige E-Mail-Adresse ein
|
||||
"passwordsNotMatch": "The passwords do not match", // Die Passwörter stimmen nicht überein
|
||||
"passwordsNotMatchShort": "Passwords do not match", // Passwörter stimmen nicht überein
|
||||
"enterEmail": "Please enter your email address", // Bitte geben Sie Ihre E-Mail-Adresse ein
|
||||
"loginFailed": "Login failed", // Anmeldung fehlgeschlagen
|
||||
"registerFailed": "Registration failed", // Registrierung fehlgeschlagen
|
||||
"googleLoginFailed": "Google login failed", // Google-Anmeldung fehlgeschlagen
|
||||
"emailExists": "A user with this email address already exists. Please use another email address or log in." // Ein Benutzer mit dieser E-Mail-Adresse existiert bereits. Bitte verwenden Sie eine andere E-Mail-Adresse oder melden Sie sich an.
|
||||
},
|
||||
"success": {
|
||||
"registerComplete": "Registration successful. You can now log in." // Registrierung erfolgreich. Sie können sich jetzt anmelden.
|
||||
}
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ export default {
|
||||
"prices": {
|
||||
"free": "free", // kostenlos
|
||||
"freeFrom100": "(free from 100€)", // (kostenlos ab 100€)
|
||||
"dhl": "6.99 €", // 6,99 €
|
||||
"dhl": "5.90 €", // 5,90 €
|
||||
"dpd": "4.90 €", // 4,90 €
|
||||
"sperrgut": "28.99 €" // 28,99 €
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import navigation from './navigation.js';
|
||||
import auth from './auth.js';
|
||||
import cart from './cart.js';
|
||||
import product from './product.js';
|
||||
import productDialogs from './productDialogs.js';
|
||||
import search from './search.js';
|
||||
import sorting from './sorting.js';
|
||||
import chat from './chat.js';
|
||||
@@ -18,6 +19,7 @@ import pages from './pages.js';
|
||||
import orders from './orders.js';
|
||||
import settings from './settings.js';
|
||||
import common from './common.js';
|
||||
import kitConfig from './kitConfig.js';
|
||||
import legalDatenschutzBasic from './legal-datenschutz-basic.js';
|
||||
import legalDatenschutzCustomer from './legal-datenschutz-customer.js';
|
||||
import legalDatenschutzGoogleOrders from './legal-datenschutz-google-orders.js';
|
||||
@@ -35,6 +37,7 @@ export default {
|
||||
"auth": auth,
|
||||
"cart": cart,
|
||||
"product": product,
|
||||
"productDialogs": productDialogs,
|
||||
"search": search,
|
||||
"sorting": sorting,
|
||||
"chat": chat,
|
||||
@@ -50,6 +53,7 @@ export default {
|
||||
"orders": orders,
|
||||
"settings": settings,
|
||||
"common": common,
|
||||
"kitConfig": kitConfig,
|
||||
"legalDatenschutzBasic": legalDatenschutzBasic,
|
||||
"legalDatenschutzCustomer": legalDatenschutzCustomer,
|
||||
"legalDatenschutzGoogleOrders": legalDatenschutzGoogleOrders,
|
||||
|
||||
43
src/i18n/locales/en/kitConfig.js
Normal file
@@ -0,0 +1,43 @@
|
||||
export default {
|
||||
"pageTitle": "🌱 Growbox Configurator", // 🌱 Growbox Konfigurator
|
||||
"pageSubtitle": "Put together your perfect indoor grow setup", // Stelle dein perfektes Indoor Grow Setup zusammen
|
||||
"bundleDiscountTitle": "🎯 Secure bundle discount!", // 🎯 Bundle-Rabatt sichern!
|
||||
"loadingProducts": "Loading growbox products...", // Lade Growbox-Produkte...
|
||||
"loadingLighting": "Loading lighting products...", // Lade Beleuchtungs-Produkte...
|
||||
"loadingVentilation": "Loading ventilation products...", // Lade Belüftungs-Produkte...
|
||||
"loadingExtras": "Loading extras...", // Lade Extras...
|
||||
"noProductsAvailable": "No products available for this size", // Keine Produkte für diese Größe verfügbar
|
||||
"noLightingAvailable": "No suitable lights available for tent size {{shape}}.", // Keine passenden Lampen für Zeltgröße {{shape}} verfügbar.
|
||||
"noVentilationAvailable": "No suitable ventilation available for tent size {{shape}}.", // Keine passenden Belüftung für Zeltgröße {{shape}} verfügbar.
|
||||
"noExtrasAvailable": "No extras available", // Keine Extras verfügbar
|
||||
"selectShapeTitle": "1. Select growbox shape", // 1. Growbox-Form auswählen
|
||||
"selectShapeSubtitle": "First select the base area of your growbox", // Wähle zuerst die Grundfläche deiner Growbox aus
|
||||
"selectProductTitle": "2. Select growbox product", // 2. Growbox Produkt auswählen
|
||||
"selectProductSubtitle": "Choose the right product for your {{shape}} growbox", // Wähle das passende Produkt für deine {{shape}} Growbox
|
||||
"selectLightingTitle": "3. Choose lighting", // 3. Beleuchtung wählen
|
||||
"selectLightingTitleShape": "3. Choose lighting - {{shape}}", // 3. Beleuchtung wählen - {{shape}}
|
||||
"selectLightingSubtitle": "Please select a tent size first.", // Bitte wählen Sie zuerst eine Zeltgröße aus.
|
||||
"selectVentilationTitle": "4. Select ventilation", // 4. Belüftung auswählen
|
||||
"selectVentilationTitleShape": "4. Select ventilation - {{shape}}", // 4. Belüftung auswählen - {{shape}}
|
||||
"selectVentilationSubtitle": "Please select a tent size first.", // Bitte wählen Sie zuerst eine Zeltgröße aus.
|
||||
"selectExtrasTitle": "5. Add extras (optional)", // 5. Extras hinzufügen (optional)
|
||||
"yourConfiguration": "🎯 Your configuration", // 🎯 Ihre Konfiguration
|
||||
"growboxLabel": "Growbox: {{name}}", // Growbox: {{name}}
|
||||
"lightingLabel": "Lighting: {{name}}", // Beleuchtung: {{name}}
|
||||
"ventilationLabel": "Ventilation: {{name}}", // Belüftung: {{name}}
|
||||
"extraLabel": "Extra: {{name}}", // Extra: {{name}}
|
||||
"totalPrice": "Total price:", // Gesamtpreis:
|
||||
"addToCart": "Add to cart", // In den Warenkorb
|
||||
"selected": "✓ Selected", // ✓ Ausgewählt
|
||||
"notDeliverable": "Not deliverable", // Nicht lieferbar
|
||||
"noPrice": "No price", // Kein Preis
|
||||
"setName": "Growbox set - {{shape}}", // Growbox Set - {{shape}}
|
||||
"description60x60": "Compact - ideal for small spaces", // Kompakt - ideal für kleine Räume
|
||||
"description80x80": "Medium - perfect balance", // Mittel - perfekte Balance
|
||||
"description100x100": "Large - for experienced growers", // Groß - für erfahrene Grower
|
||||
"description120x60": "Rectangular - maximum space usage", // Rechteckig - maximale Raumnutzung
|
||||
"plants1to2": "1-2 plants", // 1-2 Pflanzen
|
||||
"plants2to4": "2-4 plants", // 2-4 Pflanzen
|
||||
"plants4to6": "4-6 plants", // 4-6 Pflanzen
|
||||
"plants3to6": "3-6 plants" // 3-6 Pflanzen
|
||||
};
|
||||
@@ -1,8 +1,9 @@
|
||||
export default {
|
||||
"status": {
|
||||
"new": "In progress", // in Bearbeitung
|
||||
"new": "in progress", // in Bearbeitung
|
||||
"pending": "New", // Neu
|
||||
"processing": "In progress", // in Bearbeitung
|
||||
"processing": "in progress", // in Bearbeitung
|
||||
"paid": "Paid", // Bezahlt
|
||||
"cancelled": "Cancelled", // Storniert
|
||||
"shipped": "Shipped", // Verschickt
|
||||
"delivered": "Delivered", // Geliefert
|
||||
@@ -24,6 +25,7 @@ export default {
|
||||
"cancelOrder": "Cancel order" // Bestellung stornieren
|
||||
},
|
||||
"noOrders": "You have not placed any orders yet.", // Sie haben noch keine Bestellungen aufgegeben.
|
||||
"trackShipment": "Track shipment", // Sendung verfolgen
|
||||
"details": {
|
||||
"title": "Order details: {{orderId}}", // Bestelldetails: {{orderId}}
|
||||
"deliveryAddress": "Delivery address", // Lieferadresse
|
||||
@@ -36,15 +38,14 @@ export default {
|
||||
"item": "Item", // Artikel
|
||||
"quantity": "Quantity", // Menge
|
||||
"price": "Price", // Preis
|
||||
"vat": "VAT", // MwSt.
|
||||
"total": "Total", // Gesamt
|
||||
"cancelOrder": "Cancel order" // Bestellung stornieren
|
||||
},
|
||||
"cancelConfirm": {
|
||||
"title": "Cancel Order",
|
||||
"message": "Are you sure you want to cancel this order?",
|
||||
"confirm": "Cancel Order",
|
||||
"cancelling": "Cancelling..."
|
||||
"title": "Cancel order", // Bestellung stornieren
|
||||
"message": "Are you sure you want to cancel this order?", // Sind Sie sicher, dass Sie diese Bestellung stornieren möchten?
|
||||
"confirm": "Cancel", // Stornieren
|
||||
"cancelling": "Cancelling..." // Wird storniert...
|
||||
},
|
||||
"processing": "Order is being completed...", // Bestellung wird abgeschlossen...
|
||||
"processing": "Order is being completed..." // Bestellung wird abgeschlossen...
|
||||
};
|
||||
|
||||