Compare commits

..

36 Commits

Author SHA1 Message Date
sebseb7
66a1efd87b feat: add Hersteller page with manufacturer data fetching and SEO support 2026-04-21 16:04:11 +02:00
sebseb7
2c0b7aa84d clickable Herstellerkarousel part 1 2026-04-13 19:47:04 +02:00
sebseb7
a56377a1fd you have to start somewhere 2026-04-13 19:43:34 +02:00
sebseb7
468eb1c3ae button arrange 2026-04-13 19:43:17 +02:00
sebseb7
e699a8003f feat: implement kiosk mode functionality and update UI elements accordingly 2026-04-11 22:58:25 +02:00
sebseb7
b5256d6597 feat: add Outfit Variable font and update global typography settings 2026-04-01 15:13:29 +02:00
sebseb7
18c528302d correct ai assy language 2026-03-31 10:19:47 +02:00
sebseb7
9054c8d2fd Formatting fixed that affected the Czech version. 2026-03-31 10:00:13 +02:00
sebseb7
8bce10e61b refactor: Improve git commit hash retrieval method and enhance webpack configuration for better lazy loading of components 2026-03-28 18:21:39 +01:00
sebseb7
2540d00c8e Revert "refactor: Update webpack configuration to improve git commit hash retrieval and enhance lazy loading of components for better performance"
This reverts commit 52c9888a6a.
2026-03-28 18:04:55 +01:00
sebseb7
52c9888a6a refactor: Update webpack configuration to improve git commit hash retrieval and enhance lazy loading of components for better performance 2026-03-28 17:56:12 +01:00
sebseb7
ab55761411 refactor: Update JSON-LD itemListElement structure in category SEO to include URL field for better clarity and compliance with SEO standards 2026-03-28 17:37:38 +01:00
sebseb7
5e5a733d36 refactor: Simplify JSON-LD generation in category SEO by removing unnecessary product details and focusing on URLs for improved compliance with Google guidelines 2026-03-28 17:34:00 +01:00
sebseb7
36360df648 feat: Add generateCategoryMetaTags function for enhanced SEO in category pages and integrate it into prerender process 2026-03-28 17:21:43 +01:00
sebseb7
21d86565f1 refactor: Enhance JSON-LD structure in category and product generation functions for improved SEO and consistency across URLs 2026-03-28 17:10:14 +01:00
sebseb7
c503de3a11 refactor: Update organization JSON-LD structure to include specific subtype (GardenStore) for better merchant categorization 2026-03-28 16:57:45 +01:00
sebseb7
2ced182570 refactor: Update JSON-LD generation in generateProductJsonLd function to separate product and breadcrumb scripts for improved SEO structure 2026-03-28 16:30:39 +01:00
sebseb7
52c62541b0 fix: Adjust ProductFilters component to clear min-height on mobile screens for improved responsiveness 2026-03-27 01:37:14 +01:00
sebseb7
7202c43dfa feat: Add LinkTelegram page and routing; enhance login flow to support redirection from linkTelegram 2026-03-27 01:29:04 +01:00
sebseb7
5b7f0f788c refactor: Centralize Socket.IO client options in config for improved maintainability and consistency across prerender scripts 2026-03-26 21:57:50 +01:00
sebseb7
47ed2ec231 refactor: Simplify unsubscribe functionality in articlePush and categoryPush by enforcing required identifiers in request body 2026-03-26 21:35:26 +01:00
sebseb7
188c883450 feat: Implement push subscription event handling in AddToCartButton and ProductFilters components; enhance article and category unsubscribe functionality with optional identifiers 2026-03-26 21:28:49 +01:00
sebseb7
ba66b82b2b feat: Add grace period for user activity handling in IdleMainPagesSlideshow to prevent premature slideshow reset after navigation 2026-03-26 21:24:46 +01:00
sebseb7
defe3c9521 feat: Integrate IdleMainPagesSlideshow component into App.js and update links in MainPageLayout for improved navigation to articles 2026-03-26 21:10:46 +01:00
sebseb7
de8e59f1bb feat: Enhance ChatAssistant and ProductFilters components with dynamic privacy prompts and category push notification support; update localization strings for new article notifications across multiple languages 2026-03-26 20:51:28 +01:00
sebseb7
4b634414e5 feat: Update MainPageLayout with enhanced star layer effects and improved drop-shadow filters; refine star polygon coordinates for better visual consistency during animations 2026-03-26 16:28:17 +01:00
sebseb7
e8517372f2 feat: Enhance MainPageLayout with improved star decoration animations and initial fill colors; add new teal star layers and update localization strings across multiple languages for better user experience 2026-03-26 15:24:00 +01:00
sebseb7
c6ea6e70fe refactor: Update layout and styling in various components for improved responsiveness and visual consistency on mobile; adjust zIndex and position properties, and enhance navigation handling in ProductDetailPage 2026-03-26 14:59:11 +01:00
sebseb7
d37eb950d1 fix: Update ProductList component to improve responsiveness by adjusting display property for mobile and small screens 2026-03-26 14:37:08 +01:00
sebseb7
665e48e868 feat: Enhance Filter component with collapsible options and clear filter functionality; improve responsiveness and UI feedback on mobile 2026-03-26 14:32:06 +01:00
sebseb7
e0c6d47d98 feat: Enhance ChatAssistant component with dynamic privacy prompt and localization support; update various UI elements for improved accessibility and user experience
Fix product card width on mobile.
2026-03-26 14:21:03 +01:00
sebseb7
bfeb5be1d5 feat: Refactor product catalog output to dynamically generate page links and improve readability of available products 2026-03-26 12:22:21 +01:00
sebseb7
1897ceb7c5 feat: Enhance image processing in data-fetching and update SEO meta tags for product images; add Telegram assistant link in ChatAssistant component with localization support 2026-03-26 11:56:07 +01:00
sebseb7
c5dce64ac9 feat: fix star decoration layers to MainPageLayout and refactor star polygon usage in Product component for improved visual consistency 2026-03-25 17:16:12 +01:00
sebseb7
9e77deb4f8 feat: Implement socket error telemetry in SocketManager to enhance error reporting for socket.io events 2026-03-25 11:22:37 +01:00
sebseb7
5515a59fa1 feat: Refactor Header and CategoryList components to improve category navigation handling and enhance active state management based on pathname 2026-03-25 11:17:24 +01:00
159 changed files with 3601 additions and 977 deletions

1
docs/README.md Normal file
View File

@@ -0,0 +1 @@
src/components/MainPageLayout.js is the Main gues homepage.

View File

@@ -83,7 +83,7 @@ server {
default_type application/xml;
}
location ~ ^/(datenschutz|impressum|batteriegesetzhinweise|widerrufsrecht|sitemap|agb|Kategorien|Konfigurator|404|profile|resetPassword|thc-test|filiale|aktionen|presseverleih|payment/success)(/|$) {
location ~ ^/(datenschutz|impressum|batteriegesetzhinweise|widerrufsrecht|sitemap|agb|Kategorien|Konfigurator|404|profile|resetPassword|thc-test|linkTelegram|filiale|aktionen|presseverleih|payment/success)(/|$) {
types {}
default_type text/html;
}

View File

@@ -6,6 +6,30 @@ import babelParser from '@babel/eslint-parser';
export default [
js.configs.recommended,
{
files: ['**/*.cjs'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'commonjs',
globals: {
...globals.node,
},
},
rules: {
'no-unused-vars': 'warn',
//'no-console': 'warn',
'no-debugger': 'warn',
'no-alert': 'warn',
'no-unused-expressions': 'warn',
'no-var': 'warn',
'prefer-const': 'warn',
'no-trailing-spaces': 'warn',
'eqeqeq': ['warn', 'always'],
'no-empty': 'warn',
'no-eval': 'warn',
'no-script-url': 'warn',
},
},
{
files: ['**/*.{js,jsx}'],
languageOptions: {

10
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@fontsource-variable/outfit": "^5.2.8",
"@mui/icons-material": "^7.1.1",
"@mui/material": "^7.1.1",
"@stripe/react-stripe-js": "^3.7.0",
@@ -2222,6 +2223,15 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@fontsource-variable/outfit": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/outfit/-/outfit-5.2.8.tgz",
"integrity": "sha512-4oUDCZx/Tcz6HZP423w/niqEH31Gks5IsqHV2ZZz1qKHaVIZdj2f0/S1IK2n8jl6Xo0o3N+3RjNHlV9R73ozQA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",

View File

@@ -30,6 +30,7 @@
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@fontsource-variable/outfit": "^5.2.8",
"@mui/icons-material": "^7.1.1",
"@mui/material": "^7.1.1",
"@stripe/react-stripe-js": "^3.7.0",

View File

@@ -66,12 +66,7 @@ const renderSingleProduct = async (productSeoName) => {
const socketUrl = "http://127.0.0.1:9303";
console.log(`🔌 Connecting to socket at ${socketUrl}...`);
const socket = io(socketUrl, {
path: "/socket.io/",
transports: ["websocket"],
reconnection: false,
timeout: 10000,
});
const socket = io(socketUrl, config.socketIoClientOptions);
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {

View File

@@ -125,11 +125,14 @@ const {
const {
generateProductMetaTags,
generateProductJsonLd,
generateCategoryMetaTags,
generateCategoryJsonLd,
generateHomepageMetaTags,
generateHomepageJsonLd,
generateSitemapJsonLd,
generateKonfiguratorMetaTags,
generateHerstellerMetaTags,
generateHerstellerJsonLd,
generateXmlSitemap,
generateRobotsTxt,
generateProductsXml,
@@ -141,6 +144,7 @@ const {
const {
fetchCategoryProducts,
fetchProductDetails,
fetchManufacturers,
saveProductImages,
saveCategoryImages,
} = require("./prerender/data-fetching.cjs");
@@ -160,18 +164,14 @@ 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 PrerenderHerstellerPage = require("./src/PrerenderHerstellerPage.js").default;
const AGB = require("./src/pages/AGB.js").default;
const NotFound404 = require("./src/pages/NotFound404.js").default;
// Worker function for parallel product rendering
const renderProductWorker = async (productSeoNames, workerId, progressCallback, categoryMap = {}) => {
const socketUrl = "http://127.0.0.1:9303";
const workerSocket = io(socketUrl, {
path: "/socket.io/",
transports: ["websocket"],
reconnection: false,
timeout: 10000,
});
const workerSocket = io(socketUrl, config.socketIoClientOptions);
return new Promise((resolve) => {
let processedCount = 0;
@@ -380,6 +380,29 @@ const renderApp = async (categoryData, socket) => {
global.categoryCache = {};
}
// Fetch manufacturers data for Hersteller page
let manufacturerData = null;
console.log("🏭 [renderApp] Starting manufacturer fetch...");
console.log("🏭 [renderApp] socket exists:", !!socket);
console.log("🏭 [renderApp] socket.connected:", socket ? socket.connected : "N/A");
if (!socket) {
console.error("🏭 [renderApp] FATAL: No socket - cannot fetch manufacturers!");
} else if (!socket.connected) {
console.error("🏭 [renderApp] FATAL: Socket not connected - cannot fetch manufacturers!");
} else {
try {
console.log("🏭 [renderApp] Calling fetchManufacturers...");
manufacturerData = await fetchManufacturers(socket);
console.log("🏭 [renderApp] ✅ Fetched " + manufacturerData.length + " manufacturers");
} catch (error) {
console.error("🏭 [renderApp] ❌ Failed to fetch manufacturers:", error.message);
manufacturerData = [];
}
}
console.log("🏭 [renderApp] Final manufacturerData:", manufacturerData ? (manufacturerData.length + " items") : "null");
// Helper to call renderPage with config
const render = (
component,
@@ -387,8 +410,10 @@ const renderApp = async (categoryData, socket) => {
filename,
description,
metaTags = "",
needsRouter = false
needsRouter = false,
manufacturerDataForPage = null
) => {
console.log(" 📦 [render helper] Calling renderPage for", filename, "with manufacturerData:", manufacturerDataForPage ? (manufacturerDataForPage.length + " items") : "null");
return renderPage(
component,
location,
@@ -396,7 +421,10 @@ const renderApp = async (categoryData, socket) => {
description,
metaTags,
needsRouter,
config
config,
false, // suppressLogs
null, // productData
manufacturerDataForPage // manufacturerData - 10th parameter!
);
};
@@ -429,6 +457,11 @@ const renderApp = async (categoryData, socket) => {
const resetPasswordPath = path.resolve(__dirname, config.outputDir, "resetPassword");
fs.copyFileSync(indexPath, resetPasswordPath);
console.log(`✅ Copied index.html to ${resetPasswordPath}`);
// Copy index.html to linkTelegram (no file extension) for SPA routing
const linkTelegramPath = path.resolve(__dirname, config.outputDir, "linkTelegram");
fs.copyFileSync(indexPath, linkTelegramPath);
console.log(`✅ Copied index.html to ${linkTelegramPath}`);
}
// Render static pages
@@ -473,6 +506,13 @@ const renderApp = async (categoryData, socket) => {
description: "Categories page",
needsCategoryData: true,
},
{
component: PrerenderHerstellerPage,
path: "/Hersteller",
filename: "Hersteller",
description: "Hersteller page",
needsManufacturerData: true,
},
{ component: AGB, path: "/agb", filename: "agb", description: "AGB page" },
{ component: NotFound404, path: "/404", filename: "404", description: "404 Not Found page" },
{
@@ -491,8 +531,17 @@ const renderApp = async (categoryData, socket) => {
let staticPagesRendered = 0;
for (const page of staticPages) {
// Pass category data as props if needed
const pageProps = page.needsCategoryData ? { categoryData } : null;
// Pass category and manufacturer data as props if needed
let pageProps = null;
if (page.needsCategoryData || page.needsManufacturerData) {
pageProps = {};
if (page.needsCategoryData) {
pageProps.categoryData = categoryData;
}
if (page.needsManufacturerData) {
pageProps.manufacturerData = manufacturerData;
}
}
const pageComponent = React.createElement(page.component, pageProps);
let metaTags = "";
@@ -508,13 +557,25 @@ const renderApp = async (categoryData, socket) => {
metaTags = konfiguratorMetaTags;
}
// Special handling for Hersteller page to include SEO tags
if (page.filename === "Hersteller") {
const manufacturerCount = manufacturerData ? manufacturerData.length : 0;
const herstellerMetaTags = generateHerstellerMetaTags(shopConfig.baseUrl, shopConfig, manufacturerCount);
const herstellerJsonLd = generateHerstellerJsonLd(shopConfig.baseUrl, shopConfig);
metaTags = herstellerMetaTags + "\n" + herstellerJsonLd;
}
// Pass manufacturerData only for Hersteller page
const pageManufacturerData = page.needsManufacturerData ? manufacturerData : null;
const success = render(
pageComponent,
page.path,
page.filename,
page.description,
metaTags,
true
true,
pageManufacturerData
);
if (success) {
staticPagesRendered++;
@@ -621,19 +682,25 @@ const renderApp = async (categoryData, socket) => {
const filename = `Kategorie/${category.seoName}`;
const location = `/Kategorie/${category.seoName}`;
const description = `Category "${category.name}" (ID: ${category.id})`;
const categoryMetaTags = generateCategoryMetaTags(
category,
shopConfig.baseUrl,
shopConfig
);
const categoryJsonLd = generateCategoryJsonLd(
category,
productData?.products || [],
shopConfig.baseUrl,
shopConfig
);
const combinedCategoryHead = categoryMetaTags + "\n" + categoryJsonLd;
const success = render(
categoryComponent,
location,
filename,
description,
categoryJsonLd,
combinedCategoryHead,
true
);
if (success) {
@@ -863,12 +930,7 @@ const fetchCategoryDataAndRender = () => {
process.exit(1);
}, 15000);
const socket = io(socketUrl, {
path: "/socket.io/",
transports: ["websocket"],
reconnection: false,
timeout: 10000,
});
const socket = io(socketUrl, config.socketIoClientOptions);
socket.on("connect", () => {
console.log('Socket connected. Emitting "categoryList"...');

View File

@@ -69,11 +69,21 @@ const globalCssCollection = new Set();
// Get webpack entrypoints
const webpackEntrypoints = getWebpackEntrypoints();
/** Socket.IO client options for prerender scripts: skip backend connection counters (balanced on disconnect). */
const socketIoClientOptions = {
path: "/socket.io/",
transports: ["websocket"],
reconnection: false,
timeout: 10000,
auth: { prerender: true },
};
module.exports = {
isProduction,
outputDir,
getWebpackEntrypoints,
globalCss,
globalCssCollection,
webpackEntrypoints
webpackEntrypoints,
socketIoClientOptions,
};

View File

@@ -42,6 +42,7 @@ const fetchCategoryProducts = (socket, categoryId) => {
"getCategoryProducts",
{
full: true,
nocount: true,
categoryId:
categoryId === "neu" || categoryId === "bald"
? categoryId
@@ -139,6 +140,38 @@ const fetchCategoryImage = (socket, categoryId) => {
});
};
const fetchManufacturers = (socket) => {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("Timeout fetching manufacturers"));
}, 10000);
socket.emit("getHerstellerImages", {}, (response) => {
clearTimeout(timeout);
if (response?.success && Array.isArray(response.manufacturers)) {
// Filter and format manufacturers similar to HerstellerPage.js
const manufacturers = response.manufacturers
.filter(m => m.imageBuffer)
.map(m => ({
id: m.id,
name: m.name || '',
slug: m.slug || '',
imageBuffer: m.imageBuffer,
}))
.sort((a, b) => (a.name || '').localeCompare(b.name || '', undefined, { sensitivity: 'base' }));
resolve(manufacturers);
} else {
reject(
new Error(
`Invalid manufacturers response: ${JSON.stringify(response)}`
)
);
}
});
});
};
const saveProductImages = async (socket, products, categoryName, outputDir) => {
if (!products || products.length === 0) return;
@@ -186,80 +219,98 @@ const saveProductImages = async (socket, products, categoryName, outputDir) => {
.filter((id) => id);
if (imageIds.length > 0) {
// Process first image for each product
// Process first image for each product — store AVIF + JPEG (e.g. for Twitter / social)
const bildId = parseInt(imageIds[0]);
const estimatedFilename = `prod${bildId}.avif`; // We'll generate a filename based on the ID
const avifFilename = `prod${bildId}.avif`;
const jpegFilename = `prod${bildId}.jpg`;
const avifPath = path.join(assetsPath, avifFilename);
const jpegPath = path.join(assetsPath, jpegFilename);
const imagePath = path.join(assetsPath, estimatedFilename);
// Skip if image already exists
if (fs.existsSync(imagePath)) {
if (fs.existsSync(avifPath) && fs.existsSync(jpegPath)) {
imagesSkipped++;
continue;
}
const writeAvifAndJpegFromBuffer = async (buf) => {
if (!fs.existsSync(avifPath)) {
await sharp(buf).avif().toFile(avifPath);
}
if (!fs.existsSync(jpegPath)) {
await sharp(buf)
.jpeg({ quality: 85, mozjpeg: true })
.toFile(jpegPath);
}
};
try {
const imageBuffer = await fetchProductImage(socket, bildId);
// If overlay exists, apply it to the image
if (false && fs.existsSync(overlayPath)) {
try {
// Get image dimensions to center the overlay
const baseImage = sharp(Buffer.from(imageBuffer));
const baseMetadata = await baseImage.metadata();
const overlaySize = Math.min(baseMetadata.width, baseMetadata.height) * 0.4;
// Resize overlay to 20% of base image size and get its buffer
const resizedOverlayBuffer = await sharp(overlayPath)
.resize({
width: Math.round(overlaySize),
height: Math.round(overlaySize),
fit: 'contain', // Keep full overlay visible
background: { r: 0, g: 0, b: 0, alpha: 0 } // Transparent background instead of black bars
})
.toBuffer();
// Calculate center position for the resized overlay
const centerX = Math.floor((baseMetadata.width - overlaySize) / 2);
const centerY = Math.floor((baseMetadata.height - overlaySize) / 2);
const processedImageBuffer = await baseImage
.composite([
{
input: resizedOverlayBuffer,
top: centerY,
left: centerX,
blend: "multiply", // Darkens the image, visible on all backgrounds
opacity: 0.3,
},
])
.avif() // Ensure output is AVIF
.toBuffer();
fs.writeFileSync(imagePath, processedImageBuffer);
console.log(
` ✅ Applied centered inverted sh.avif overlay to ${estimatedFilename}`
);
} catch (overlayError) {
console.log(
` ⚠️ Failed to apply overlay to ${estimatedFilename}: ${overlayError.message}`
);
// Fallback: save without overlay
fs.writeFileSync(imagePath, Buffer.from(imageBuffer));
}
if (fs.existsSync(avifPath) && !fs.existsSync(jpegPath)) {
await sharp(avifPath)
.jpeg({ quality: 85, mozjpeg: true })
.toFile(jpegPath);
} else if (!fs.existsSync(avifPath) && fs.existsSync(jpegPath)) {
await sharp(jpegPath).avif().toFile(avifPath);
} else {
// Save without overlay if overlay file doesn't exist
fs.writeFileSync(imagePath, Buffer.from(imageBuffer));
const imageBuffer = await fetchProductImage(socket, bildId);
const buf = Buffer.from(imageBuffer);
// If overlay exists, apply it to the image
if (false && fs.existsSync(overlayPath)) {
try {
const baseImage = sharp(buf);
const baseMetadata = await baseImage.metadata();
const overlaySize =
Math.min(baseMetadata.width, baseMetadata.height) * 0.4;
const resizedOverlayBuffer = await sharp(overlayPath)
.resize({
width: Math.round(overlaySize),
height: Math.round(overlaySize),
fit: "contain",
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.toBuffer();
const centerX = Math.floor(
(baseMetadata.width - overlaySize) / 2
);
const centerY = Math.floor(
(baseMetadata.height - overlaySize) / 2
);
const processedImageBuffer = await baseImage
.composite([
{
input: resizedOverlayBuffer,
top: centerY,
left: centerX,
blend: "multiply",
opacity: 0.3,
},
])
.toBuffer();
await writeAvifAndJpegFromBuffer(processedImageBuffer);
console.log(
` ✅ Applied overlay → ${avifFilename} + ${jpegFilename}`
);
} catch (overlayError) {
console.log(
` ⚠️ Failed to apply overlay to prod${bildId}: ${overlayError.message}`
);
await writeAvifAndJpegFromBuffer(buf);
}
} else {
await writeAvifAndJpegFromBuffer(buf);
}
}
imagesSaved++;
// Small delay to avoid overwhelming server
await new Promise((resolve) => setTimeout(resolve, 50));
} catch (error) {
console.log(
` ⚠️ Failed to fetch image ${estimatedFilename} (ID: ${bildId}): ${error.message}`
` ⚠️ Failed to fetch/save prod${bildId} (${avifFilename} / ${jpegFilename}): ${error.message}`
);
}
}
@@ -364,6 +415,7 @@ module.exports = {
fetchProductDetails,
fetchProductImage,
fetchCategoryImage,
fetchManufacturers,
saveProductImages,
saveCategoryImages,
};

View File

@@ -18,7 +18,8 @@ const renderPage = (
needsRouter = false,
config,
suppressLogs = false,
productData = null
productData = null,
manufacturerData = null
) => {
const {
isProduction,
@@ -171,22 +172,44 @@ const renderPage = (
</script>
`;
// @note Create script to populate window.productCache with ONLY the static category tree
// @note Create script to populate window.productCache with static category tree and herstellerImages
let productCacheScript = '';
if (typeof global !== "undefined" && global.window && global.window.categoryCache) {
// Only include the static categoryTree_209, not any dynamic data that gets added during rendering
const staticCache = {};
if (global.window.categoryCache["209_de"]) {
staticCache["209_de"] = global.window.categoryCache["209_de"];
const hasCategoryCache = typeof global !== "undefined" && global.window && global.window.categoryCache;
const hasManufacturerData = manufacturerData && manufacturerData.length > 0;
console.log(" 📦 [" + filename + "] manufacturerData =", manufacturerData ? (manufacturerData.length + " items") : "null");
if (hasCategoryCache || hasManufacturerData) {
const cacheData = {};
// Add static categoryTree_209
if (hasCategoryCache && global.window.categoryCache["209_de"]) {
cacheData["209_de"] = global.window.categoryCache["209_de"];
}
const staticCacheData = JSON.stringify(staticCache);
productCacheScript = `
<script>
// Populate window.categoryCache with static category tree only
window.categoryCache = ${staticCacheData};
</script>
`;
// Add herstellerImages
if (hasManufacturerData) {
cacheData.herstellerImages = manufacturerData;
}
const cacheDataJson = JSON.stringify(cacheData);
let extraScripts = '';
if (hasCategoryCache && cacheData["209_de"]) {
const categoryCacheJson = JSON.stringify({ "209_de": cacheData["209_de"] });
extraScripts += 'window.categoryCache = ' + categoryCacheJson + ';';
}
if (hasManufacturerData) {
const herstellerJson = JSON.stringify(manufacturerData);
extraScripts += 'window.herstellerImages = ' + herstellerJson + ';';
}
productCacheScript = '<script>' +
'if (!window.productCache) { window.productCache = {}; }' +
'Object.assign(window.productCache, ' + cacheDataJson + ');' +
extraScripts +
'</script>';
}
// Create script to populate window.productDetailCache for individual product pages

View File

@@ -1,3 +1,43 @@
/** Safe for double-quoted HTML attributes */
const escAttr = (str) =>
String(str ?? "")
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;");
/**
* Head tags for prerendered category URLs — explicit canonical per /Kategorie/{slug}
* so Google does not cluster different listing pages (e.g. neu vs Seeds) as duplicates.
*/
const generateCategoryMetaTags = (category, baseUrl, config) => {
const root = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
const categoryUrl = `${root}/Kategorie/${category.seoName}`;
const name = category.name || `Kategorie ${category.seoName}`;
const site = config.siteName || config.brandName;
const desc = `${name} bei ${config.brandName}: Growshop-Sortiment online kaufen. Schnelle Lieferung, Laden Dresden.`;
const descShort = desc.length > 160 ? `${desc.slice(0, 157)}...` : desc;
const e = escAttr;
const logoUrl =
config.images && config.images.logo
? `${root}${config.images.logo}`
: `${root}/assets/images/nopicture.jpg`;
return `
<meta name="description" content="${e(descShort)}">
<meta property="og:title" content="${e(`${name} | ${site}`)}">
<meta property="og:description" content="${e(descShort)}">
<meta property="og:url" content="${categoryUrl}">
<meta property="og:type" content="website">
<meta property="og:image" content="${e(logoUrl)}">
<meta property="og:site_name" content="${e(site)}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${e(`${name} | ${site}`)}">
<meta name="twitter:description" content="${e(descShort)}">
<meta name="robots" content="index, follow">
<link rel="canonical" href="${categoryUrl}">
`;
};
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];
@@ -7,124 +47,103 @@ const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
return '';
}
const categoryUrl = `${baseUrl}/Kategorie/${category.seoName}`;
const root = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
const categoryUrl = `${root}/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 id = {
business: `${root}#business`,
website: `${root}#website`,
breadcrumb: `${categoryUrl}#breadcrumb`,
itemList: `${categoryUrl}#itemlist`,
};
const jsonLd = {
"@context": "https://schema.org/",
const logoUrl =
config.images && config.images.logo
? `${root}${config.images.logo}`
: undefined;
const businessNode = {
"@id": id.business,
"@type": ["GardenStore", "LocalBusiness", "Organization"],
name: config.brandName,
url: root,
...(logoUrl && {
logo: { "@type": "ImageObject", url: logoUrl },
image: { "@type": "ImageObject", url: logoUrl },
}),
};
const websiteNode = {
"@id": id.website,
"@type": "WebSite",
name: config.siteName || config.brandName,
url: root,
publisher: { "@id": id.business },
};
const breadcrumbNode = {
"@id": id.breadcrumb,
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Home",
item: root,
},
{
"@type": "ListItem",
position: 2,
name: category.name,
item: categoryUrl,
},
],
};
const collectionPageNode = {
"@id": categoryUrl,
"@type": "CollectionPage",
name: category.name,
url: categoryUrl,
description: `${category.name} - Entdecken Sie unsere Auswahl an hochwertigen Produkten`,
breadcrumb: {
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Home",
item: baseUrl,
},
{
"@type": "ListItem",
position: 2,
name: category.name,
item: categoryUrl,
},
],
},
isPartOf: { "@id": id.website },
breadcrumb: { "@id": id.breadcrumb },
};
// Add product list if products are available
if (products && products.length > 0) {
jsonLd.mainEntity = {
const graph = [businessNode, websiteNode, breadcrumbNode, collectionPageNode];
// ItemList: URLs only — full Product/Offer markup belongs on each /Artikel/… page (Google guidelines).
const withUrls = (products || []).filter((p) => p && p.seoName);
if (withUrls.length > 0) {
collectionPageNode.mainEntity = { "@id": id.itemList };
graph.push({
"@id": id.itemList,
"@type": "ItemList",
numberOfItems: products.length,
itemListElement: products.slice(0, 20).map((product, index) => ({
"@type": "ListItem",
position: index + 1,
item: {
"@type": "Product",
name: product.name,
url: `${baseUrl}/Artikel/${product.seoName}`,
image:
product.pictureList && product.pictureList.trim()
? `${baseUrl}/assets/images/prod${product.pictureList
.split(",")[0]
.trim()}.avif`
: `${baseUrl}/assets/images/nopicture.jpg`,
description: product.description
? product.description.replace(/<[^>]*>/g, "").substring(0, 200)
: `${product.name} - Hochwertiges Growshop Produkt`,
sku: product.articleNumber || product.seoName,
brand: {
"@type": "Brand",
name: product.manufacturer || config.brandName,
},
offers: {
"@type": "Offer",
url: `${baseUrl}/Artikel/${product.seoName}`,
price: product.price && !isNaN(product.price) ? product.price.toString() : "0.00",
priceCurrency: config.currency,
priceValidUntil: priceValidUntil,
availability: product.available
? "https://schema.org/InStock"
: "https://schema.org/OutOfStock",
seller: {
"@type": "Organization",
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",
},
},
},
},
},
})),
};
numberOfItems: withUrls.length,
itemListElement: withUrls.map((product, index) => {
const productPageUrl = `${root}/Artikel/${product.seoName}`;
return {
"@type": "ListItem",
position: index + 1,
url: productPageUrl,
item: productPageUrl,
};
}),
});
}
const categoryGraph = {
"@context": "https://schema.org",
"@graph": graph,
};
return `<script type="application/ld+json">${JSON.stringify(
jsonLd
categoryGraph
)}</script>`;
};
module.exports = {
generateCategoryMetaTags,
generateCategoryJsonLd,
};
};

View File

@@ -0,0 +1,116 @@
/** Safe for double-quoted HTML attributes */
const escAttr = (str) =>
String(str ?? "")
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;");
/**
* Head tags for prerendered Hersteller (Manufacturers) page
*/
const generateHerstellerMetaTags = (baseUrl, config, manufacturerCount = 0) => {
const root = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
const herstellerUrl = root + "/Hersteller";
const site = config.siteName || config.brandName;
const desc = manufacturerCount + " Hersteller bei " + config.brandName + ": Top-Marken für Growshop-Produkte. Schnelle Lieferung, Laden Dresden.";
const descShort = desc.length > 160 ? desc.slice(0, 157) + "..." : desc;
const e = escAttr;
const logoUrl =
config.images && config.images.logo
? root + config.images.logo
: root + "/assets/images/nopicture.jpg";
return `
<meta name="description" content="${e(descShort)}">
<meta property="og:title" content="${e("Hersteller | " + site)}">
<meta property="og:description" content="${e(descShort)}">
<meta property="og:url" content="${herstellerUrl}">
<meta property="og:type" content="website">
<meta property="og:image" content="${e(logoUrl)}">
<meta property="og:site_name" content="${e(site)}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${e("Hersteller | " + site)}">
<meta name="twitter:description" content="${e(descShort)}">
<meta name="robots" content="index, follow">
<link rel="canonical" href="${herstellerUrl}">
`;
};
const generateHerstellerJsonLd = (baseUrl, config) => {
const root = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
const herstellerUrl = root + "/Hersteller";
const id = {
business: root + "#business",
website: root + "#website",
breadcrumb: herstellerUrl + "#breadcrumb",
};
const logoUrl =
config.images && config.images.logo
? root + config.images.logo
: undefined;
const businessNode = {
"@id": id.business,
"@type": ["GardenStore", "LocalBusiness", "Organization"],
name: config.brandName,
url: root,
};
if (logoUrl) {
businessNode.logo = { "@type": "ImageObject", url: logoUrl };
businessNode.image = { "@type": "ImageObject", url: logoUrl };
}
const websiteNode = {
"@id": id.website,
"@type": "WebSite",
name: config.siteName || config.brandName,
url: root,
publisher: { "@id": id.business },
};
const breadcrumbNode = {
"@id": id.breadcrumb,
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Home",
item: root,
},
{
"@type": "ListItem",
position: 2,
name: "Hersteller",
item: herstellerUrl,
},
],
};
const collectionPageNode = {
"@id": herstellerUrl,
"@type": "CollectionPage",
name: "Hersteller",
url: herstellerUrl,
description: "Alle Hersteller und Marken für Growshop-Produkte",
isPartOf: { "@id": id.website },
breadcrumb: { "@id": id.breadcrumb },
};
const graph = [businessNode, websiteNode, breadcrumbNode, collectionPageNode];
const herstellerGraph = {
"@context": "https://schema.org",
"@graph": graph,
};
return "<script type=\"application/ld+json\">" + JSON.stringify(herstellerGraph) + "</script>";
};
module.exports = {
generateHerstellerMetaTags,
generateHerstellerJsonLd,
};

View File

@@ -36,177 +36,198 @@ const generateHomepageJsonLd = (baseUrl, config, categories = []) => {
const canonicalUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
const logoUrl = `${canonicalUrl}${config.images.logo}`;
const websiteJsonLd = {
"@context": "https://schema.org/",
const id = {
business: `${canonicalUrl}#business`,
website: `${canonicalUrl}#website`,
faq: `${canonicalUrl}#faq`,
categoryList: `${canonicalUrl}#category-list`,
sitemapPage: `${canonicalUrl}/sitemap#webpage`,
};
const organizationNode = {
"@id": id.business,
"@type": ["GardenStore", "LocalBusiness", "Organization"],
name: config.brandName,
alternateName: config.siteName,
description: config.descriptions.de.long,
url: canonicalUrl,
logo: {
"@type": "ImageObject",
url: logoUrl,
},
image: {
"@type": "ImageObject",
url: logoUrl,
},
telephone: "015208491860",
email: "service@growheads.de",
address: {
"@type": "PostalAddress",
streetAddress: "Trachenberger Strasse 14",
addressLocality: "Dresden",
postalCode: "01129",
addressCountry: "DE",
addressRegion: "Sachsen",
},
geo: {
"@type": "GeoCoordinates",
latitude: "51.083675",
longitude: "13.727215",
},
openingHours: [
"Mo-Fr 10:00:00-20:00:00",
"Sa 11:00:00-19:00:00",
],
paymentAccepted: "Cash, Credit Card, PayPal, Bank Transfer",
currenciesAccepted: "EUR",
priceRange: "€€",
areaServed: {
"@type": "Country",
name: "Germany",
},
contactPoint: [
{
"@type": "ContactPoint",
telephone: "015208491860",
contactType: "customer service",
availableLanguage: "German",
hoursAvailable: {
"@type": "OpeningHoursSpecification",
dayOfWeek: [
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
],
opens: "10:00:00",
closes: "20:00:00",
},
},
{
"@type": "ContactPoint",
email: "service@growheads.de",
contactType: "customer service",
availableLanguage: "German",
},
],
sameAs: [],
};
const sitemapWebPageNode = {
"@id": id.sitemapPage,
"@type": "WebPage",
name: "Sitemap",
url: `${canonicalUrl}/sitemap`,
description: "Vollständige Sitemap mit allen Kategorien und Seiten",
isPartOf: { "@id": id.website },
};
const websiteNode = {
"@id": id.website,
"@type": "WebSite",
name: config.brandName,
url: canonicalUrl,
description: config.descriptions.de.long,
publisher: {
"@type": "Organization",
name: config.brandName,
url: canonicalUrl,
logo: {
"@type": "ImageObject",
url: logoUrl,
},
},
publisher: { "@id": id.business },
potentialAction: {
"@type": "SearchAction",
target: `${canonicalUrl}/search?q={search_term_string}`,
query: "required name=search_term_string"
query: "required name=search_term_string",
},
mainEntity: {
"@type": "WebPage",
name: "Sitemap",
url: `${canonicalUrl}/sitemap`,
description: "Vollständige Sitemap mit allen Kategorien und Seiten",
},
sameAs: [
// Add your social media URLs here if available
],
mainEntity: { "@id": id.sitemapPage },
sameAs: [],
};
// Organization/LocalBusiness Schema for rich results
const organizationJsonLd = {
"@context": "https://schema.org",
"@type": "LocalBusiness",
"name": config.brandName,
"alternateName": config.siteName,
"description": config.descriptions.de.long,
"url": canonicalUrl,
"logo": logoUrl,
"image": logoUrl,
"telephone": "015208491860",
"email": "service@growheads.de",
"address": {
"@type": "PostalAddress",
"streetAddress": "Trachenberger Strasse 14",
"addressLocality": "Dresden",
"postalCode": "01129",
"addressCountry": "DE",
"addressRegion": "Sachsen"
},
"geo": {
"@type": "GeoCoordinates",
"latitude": "51.083675",
"longitude": "13.727215"
},
"openingHours": [
"Mo-Fr 10:00:00-20:00:00",
"Sa 11:00:00-19:00:00"
],
"paymentAccepted": "Cash, Credit Card, PayPal, Bank Transfer",
"currenciesAccepted": "EUR",
"priceRange": "€€",
"areaServed": {
"@type": "Country",
"name": "Germany"
},
"contactPoint": [
{
"@type": "ContactPoint",
"telephone": "015208491860",
"contactType": "customer service",
"availableLanguage": "German",
"hoursAvailable": {
"@type": "OpeningHoursSpecification",
"dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
"opens": "10:00:00",
"closes": "20:00:00"
}
const faqMainEntity = [
{
"@type": "Question",
name: "Welche Zahlungsmethoden akzeptiert GrowHeads?",
acceptedAnswer: {
"@type": "Answer",
text: "Wir akzeptieren Kreditkarten, PayPal, Banküberweisung, Nachnahme und Barzahlung bei Abholung in unserem Laden in Dresden.",
},
{
"@type": "ContactPoint",
"email": "service@growheads.de",
"contactType": "customer service",
"availableLanguage": "German"
}
],
"sameAs": [
// Add social media URLs when available
// "https://www.facebook.com/growheads",
// "https://www.instagram.com/growheads"
]
};
},
{
"@type": "Question",
name: "Liefert GrowHeads deutschlandweit?",
acceptedAnswer: {
"@type": "Answer",
text: "Ja, wir liefern deutschlandweit. Zusätzlich haben wir einen Laden in Dresden (Trachenberger Strasse 14) für lokale Kunden.",
},
},
{
"@type": "Question",
name: "Welche Produkte bietet GrowHeads?",
acceptedAnswer: {
"@type": "Answer",
text: "Wir bieten ein komplettes Sortiment für den Indoor-Anbau: Beleuchtung, Belüftung, Dünger, Töpfe, Zelte, Messgeräte und vieles mehr für professionelle Zuchtanlagen.",
},
},
{
"@type": "Question",
name: "Hat GrowHeads einen physischen Laden?",
acceptedAnswer: {
"@type": "Answer",
text: "Ja, unser Laden befindet sich in Dresden, Trachenberger Strasse 14. Öffnungszeiten: Mo-Fr 10-20 Uhr, Sa 11-19 Uhr. Sie können auch online bestellen.",
},
},
{
"@type": "Question",
name: "Bietet GrowHeads Beratung zum Indoor-Anbau?",
acceptedAnswer: {
"@type": "Answer",
text: "Ja, unser erfahrenes Team berät Sie gerne zu allen Aspekten des Indoor-Anbaus. Kontaktieren Sie uns telefonisch unter 015208491860 oder besuchen Sie unseren Laden.",
},
},
];
// FAQPage Schema for common questions
const faqJsonLd = {
"@context": "https://schema.org",
const faqNode = {
"@id": id.faq,
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "Welche Zahlungsmethoden akzeptiert GrowHeads?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Wir akzeptieren Kreditkarten, PayPal, Banküberweisung, Nachnahme und Barzahlung bei Abholung in unserem Laden in Dresden."
}
},
{
"@type": "Question",
"name": "Liefert GrowHeads deutschlandweit?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Ja, wir liefern deutschlandweit. Zusätzlich haben wir einen Laden in Dresden (Trachenberger Strasse 14) für lokale Kunden."
}
},
{
"@type": "Question",
"name": "Welche Produkte bietet GrowHeads?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Wir bieten ein komplettes Sortiment für den Indoor-Anbau: Beleuchtung, Belüftung, Dünger, Töpfe, Zelte, Messgeräte und vieles mehr für professionelle Zuchtanlagen."
}
},
{
"@type": "Question",
"name": "Hat GrowHeads einen physischen Laden?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Ja, unser Laden befindet sich in Dresden, Trachenberger Strasse 14. Öffnungszeiten: Mo-Fr 10-20 Uhr, Sa 11-19 Uhr. Sie können auch online bestellen."
}
},
{
"@type": "Question",
"name": "Bietet GrowHeads Beratung zum Indoor-Anbau?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Ja, unser erfahrenes Team berät Sie gerne zu allen Aspekten des Indoor-Anbaus. Kontaktieren Sie uns telefonisch unter 015208491860 oder besuchen Sie unseren Laden."
}
}
]
url: canonicalUrl,
publisher: { "@id": id.business },
isPartOf: { "@id": id.website },
mainEntity: faqMainEntity,
};
// Generate ItemList for all categories (more appropriate for homepage)
const categoriesListJsonLd = {
"@context": "https://schema.org",
"@type": "ItemList",
"name": "Produktkategorien",
"description": "Alle verfügbaren Produktkategorien in unserem Online-Shop",
"numberOfItems": categories.filter(category => category.seoName).length,
"itemListElement": categories
.filter(category => category.seoName) // Only include categories with seoName
.map((category, index) => ({
const filteredCategories = categories.filter((c) => c.seoName);
const graph = [
organizationNode,
websiteNode,
sitemapWebPageNode,
faqNode,
];
if (filteredCategories.length > 0) {
graph.push({
"@id": id.categoryList,
"@type": "ItemList",
name: "Produktkategorien",
description: "Alle verfügbaren Produktkategorien in unserem Online-Shop",
numberOfItems: filteredCategories.length,
isPartOf: { "@id": id.website },
itemListElement: filteredCategories.map((category, index) => ({
"@type": "ListItem",
"position": index + 1,
"item": {
position: index + 1,
item: {
"@type": "Thing",
"name": category.name,
"url": `${canonicalUrl}/Kategorie/${category.seoName}`
}
}))
name: category.name,
url: `${canonicalUrl}/Kategorie/${category.seoName}`,
},
})),
});
}
const homepageGraph = {
"@context": "https://schema.org",
"@graph": graph,
};
// Return all JSON-LD scripts
const websiteScript = `<script type="application/ld+json">${JSON.stringify(websiteJsonLd)}</script>`;
const organizationScript = `<script type="application/ld+json">${JSON.stringify(organizationJsonLd)}</script>`;
const faqScript = `<script type="application/ld+json">${JSON.stringify(faqJsonLd)}</script>`;
const categoriesScript = categories.length > 0
? `<script type="application/ld+json">${JSON.stringify(categoriesListJsonLd)}</script>`
: '';
return websiteScript + '\n' + organizationScript + '\n' + faqScript + (categoriesScript ? '\n' + categoriesScript : '');
return `<script type="application/ld+json">${JSON.stringify(
homepageGraph
)}</script>`;
};
module.exports = {

View File

@@ -5,6 +5,7 @@ const {
} = require('./product.cjs');
const {
generateCategoryMetaTags,
generateCategoryJsonLd,
} = require('./category.cjs');
@@ -22,6 +23,11 @@ const {
generateKonfiguratorMetaTags,
} = require('./konfigurator.cjs');
const {
generateHerstellerMetaTags,
generateHerstellerJsonLd,
} = require('./hersteller.cjs');
const {
generateRobotsTxt,
generateProductsXml,
@@ -41,6 +47,7 @@ module.exports = {
generateProductJsonLd,
// Category functions
generateCategoryMetaTags,
generateCategoryJsonLd,
// Homepage functions
@@ -54,6 +61,10 @@ module.exports = {
// Konfigurator functions
generateKonfiguratorMetaTags,
// Hersteller functions
generateHerstellerMetaTags,
generateHerstellerJsonLd,
// Feed/Export functions
generateRobotsTxt,
generateProductsXml,

View File

@@ -60,32 +60,19 @@ GrowHeads.de is a German online shop and local store in Dresden specializing in
if (totalPages > 1) {
llmsTxt += `
- **Product Catalog**: ${totalPages} pages available
- **Page 1**: ${baseUrl}/llms-${categorySlug}-page-1.txt (Products 1-${Math.min(productsPerPage, productCount)})`;
if (totalPages > 2) {
${totalPages} pages available`;
for (let p = 1; p <= totalPages; p++) {
const start = (p - 1) * productsPerPage + 1;
const end = Math.min(p * productsPerPage, productCount);
llmsTxt += `
- **Page 2**: ${baseUrl}/llms-${categorySlug}-page-2.txt (Products ${productsPerPage + 1}-${Math.min(productsPerPage * 2, productCount)})`;
- **Page ${p}**: ${baseUrl}/llms-${categorySlug}-page-${p}.txt (Products ${start}-${end})`;
}
if (totalPages > 3) {
llmsTxt += `
- **...**: Additional pages available`;
}
if (totalPages > 2) {
llmsTxt += `
- **Page ${totalPages}**: ${baseUrl}/llms-${categorySlug}-page-${totalPages}.txt (Products ${((totalPages - 1) * productsPerPage) + 1}-${productCount})`;
}
llmsTxt += `
- **Access Pattern**: Replace "page-X" with desired page number (1-${totalPages})`;
} else if (productCount > 0) {
llmsTxt += `
- **Product Catalog**: ${baseUrl}/llms-${categorySlug}-page-1.txt`;
${baseUrl}/llms-${categorySlug}-page-1.txt`;
} else {
llmsTxt += `
- **Product Catalog**: No products available`;
No products available`;
}
llmsTxt += `

View File

@@ -1,13 +1,18 @@
const generateProductMetaTags = (product, baseUrl, config) => {
const productUrl = `${baseUrl}/Artikel/${product.seoName}`;
const imageUrl =
product.pictureList && product.pictureList.trim()
? `${baseUrl}/assets/images/prod${product.pictureList
.split(",")[0]
.trim()}.avif`
: `${baseUrl}/assets/images/nopicture.jpg`;
const pictureFirstId =
product.pictureList && product.pictureList.trim()
? product.pictureList.split(",")[0].trim()
: null;
const imageUrl = pictureFirstId
? `${baseUrl}/assets/images/prod${pictureFirstId}.avif`
: `${baseUrl}/assets/images/nopicture.jpg`;
const twitterImageUrl = pictureFirstId
? `${baseUrl}/assets/images/prod${pictureFirstId}.jpg`
: `${baseUrl}/assets/images/nopicture.jpg`;
// Clean description for meta (remove HTML tags and limit length)
const cleanDescription = product.kurzBeschreibung
@@ -32,7 +37,7 @@ const generateProductMetaTags = (product, baseUrl, config) => {
<!-- Open Graph Meta Tags -->
<meta property="og:title" content="${product.name}">
<meta property="og:description" content="${cleanDescription}">
<meta property="og:image" content="${imageUrl}">
<meta property="og:image" content="${twitterImageUrl}">
<meta property="og:url" content="${productUrl}">
<meta property="og:type" content="product">
<meta property="og:site_name" content="${config.siteName}">
@@ -49,7 +54,7 @@ const generateProductMetaTags = (product, baseUrl, config) => {
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${product.name}">
<meta name="twitter:description" content="${cleanDescription}">
<meta name="twitter:image" content="${imageUrl}">
<meta name="twitter:image" content="${twitterImageUrl}">
<!-- Additional Meta Tags -->
<meta name="robots" content="index, follow">
@@ -63,13 +68,15 @@ const generateProductMetaTags = (product, baseUrl, config) => {
};
const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) => {
const productUrl = `${baseUrl}/Artikel/${product.seoName}`;
const imageUrl =
const root = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
const productUrl = `${root}/Artikel/${product.seoName}`;
const pictureFirstId =
product.pictureList && product.pictureList.trim()
? `${baseUrl}/assets/images/prod${product.pictureList
.split(",")[0]
.trim()}.avif`
: `${baseUrl}/assets/images/nopicture.jpg`;
? product.pictureList.split(",")[0].trim()
: null;
const imageUrl = pictureFirstId
? `${root}/assets/images/prod${pictureFirstId}.avif`
: `${root}/assets/images/nopicture.jpg`;
// Clean description for JSON-LD (remove HTML tags)
const cleanDescription = product.description
@@ -80,8 +87,87 @@ const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) =>
const priceValidDate = new Date();
priceValidDate.setMonth(priceValidDate.getMonth() + 3);
const jsonLd = {
"@context": "https://schema.org/",
const id = {
business: `${root}#business`,
website: `${root}#website`,
product: `${productUrl}#product`,
breadcrumb: `${productUrl}#breadcrumb`,
};
const logoUrl =
config.images && config.images.logo
? `${root}${config.images.logo}`
: undefined;
const businessNode = {
"@id": id.business,
"@type": ["GardenStore", "LocalBusiness", "Organization"],
name: config.brandName,
url: root,
...(logoUrl && {
logo: { "@type": "ImageObject", url: logoUrl },
image: { "@type": "ImageObject", url: logoUrl },
}),
};
const websiteNode = {
"@id": id.website,
"@type": "WebSite",
name: config.siteName || config.brandName,
url: root,
publisher: { "@id": id.business },
};
const offer = {
"@type": "Offer",
url: productUrl,
priceCurrency: config.currency,
price: product.price.toString(),
priceValidUntil: priceValidDate.toISOString().split("T")[0],
itemCondition: "https://schema.org/NewCondition",
availability: product.available
? "https://schema.org/InStock"
: "https://schema.org/OutOfStock",
seller: { "@id": id.business },
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.9,
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",
},
},
},
};
const productNode = {
"@id": id.product,
"@type": "Product",
name: product.name,
image: [imageUrl],
@@ -92,87 +178,64 @@ const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) =>
"@type": "Brand",
name: product.manufacturer || "Unknown",
},
offers: {
"@type": "Offer",
url: productUrl,
priceCurrency: config.currency,
price: product.price.toString(),
priceValidUntil: priceValidDate.toISOString().split("T")[0],
itemCondition: "https://schema.org/NewCondition",
availability: product.available
? "https://schema.org/InStock"
: "https://schema.org/OutOfStock",
seller: {
"@type": "Organization",
name: config.brandName,
},
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",
},
},
},
},
offers: offer,
};
// Add breadcrumb if category information is available
if (categoryInfo && categoryInfo.name && categoryInfo.seoName) {
jsonLd.breadcrumb = {
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Home",
item: baseUrl,
},
{
"@type": "ListItem",
position: 2,
name: categoryInfo.name,
item: `${baseUrl}/Kategorie/${categoryInfo.seoName}`,
},
{
"@type": "ListItem",
position: 3,
name: product.name,
item: productUrl,
},
],
};
}
const hasBreadcrumb =
categoryInfo && categoryInfo.name && categoryInfo.seoName;
const breadcrumbList = hasBreadcrumb
? {
"@id": id.breadcrumb,
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Home",
item: root,
},
{
"@type": "ListItem",
position: 2,
name: categoryInfo.name,
item: `${root}/Kategorie/${categoryInfo.seoName}`,
},
{
"@type": "ListItem",
position: 3,
name: product.name,
item: productUrl,
},
],
}
: null;
const itemPageNode = {
"@id": productUrl,
"@type": "ItemPage",
url: productUrl,
name: product.name,
isPartOf: { "@id": id.website },
mainEntity: { "@id": id.product },
...(hasBreadcrumb && { breadcrumb: { "@id": id.breadcrumb } }),
};
const graph = [
businessNode,
websiteNode,
itemPageNode,
...(breadcrumbList ? [breadcrumbList] : []),
productNode,
];
const productGraph = {
"@context": "https://schema.org",
"@graph": graph,
};
return `<script type="application/ld+json">${JSON.stringify(
jsonLd
productGraph
)}</script>`;
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -33,6 +33,7 @@ import i18n from './i18n/index.js';
import Header from "./components/Header.js";
import Footer from "./components/Footer.js";
import MainPageLayout from "./components/MainPageLayout.js";
import IdleMainPagesSlideshow from "./components/IdleMainPagesSlideshow.js";
import Content from "./components/Content.js";
import ProductDetail from "./components/ProductDetail.js";
@@ -40,6 +41,7 @@ import ProductDetail from "./components/ProductDetail.js";
// Lazy load rarely-accessed pages
const ProfilePage = lazy(() => import(/* webpackChunkName: "profile" */ "./pages/ProfilePage.js"));
const ResetPassword = lazy(() => import(/* webpackChunkName: "reset-password" */ "./pages/ResetPassword.js"));
const LinkTelegramPage = lazy(() => import(/* webpackChunkName: "link-telegram" */ "./pages/LinkTelegramPage.js"));
// Lazy load admin pages - only loaded when admin users access them
const AdminPage = lazy(() => import(/* webpackChunkName: "admin" */ "./pages/AdminPage.js"));
@@ -52,6 +54,7 @@ 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 HerstellerPage = lazy(() => import(/* webpackChunkName: "hersteller" */ "./pages/HerstellerPage.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"));
@@ -253,6 +256,7 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
)
}>
<CarouselProvider>
<IdleMainPagesSlideshow />
<Routes>
{/* Main pages using unified component */}
<Route path="/" element={<MainPageLayout />} />
@@ -264,6 +268,11 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
path="/Kategorie/:categoryId"
element={<Content />}
/>
{/* Manufacturer page - Render Content in parallel */}
<Route
path="/Hersteller/:categoryId"
element={<Content />}
/>
{/* Single product page */}
<Route
path="/Artikel/:seoName"
@@ -275,6 +284,9 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
{/* Profile page */}
<Route path="/profile" element={<ProfilePage />} />
{/* Link Telegram id (expects ?id=... or /linkTelegram/:id) */}
<Route path="/linkTelegram" element={<LinkTelegramPage />} />
<Route path="/linkTelegram/:id" element={<LinkTelegramPage />} />
{/* Payment success page for Mollie redirects */}
<Route path="/payment/success" element={<PaymentSuccess />} />
@@ -299,6 +311,7 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
<Route path="/agb" element={<AGB />} />
<Route path="/sitemap" element={<Sitemap />} />
<Route path="/Kategorien" element={<CategoriesPage />} />
<Route path="/Hersteller" element={<HerstellerPage />} />
<Route path="/impressum" element={<Impressum />} />
<Route
path="/batteriegesetzhinweise"

View File

@@ -0,0 +1,84 @@
import React from 'react';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import Link from '@mui/material/Link';
import LegalPage from './pages/LegalPage.js';
const PrerenderHerstellerPage = ({ manufacturerData }) => {
// Use prop data (passed from prerender.cjs)
const manufacturers = manufacturerData;
// If no manufacturer data, show empty state
if (!manufacturers || manufacturers.length === 0) {
const content = (
<Box>
<Typography variant="body1" paragraph>
Keine Hersteller gefunden.
</Typography>
</Box>
);
return <LegalPage title="Hersteller" content={content} />;
}
// Render manufacturers similar to HerstellerPage.js
const content = (
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
gap: 2,
}}
>
{manufacturers.map((manufacturer) => (
<Paper
key={manufacturer.id}
component={Link}
href={`/Hersteller/${encodeURIComponent(manufacturer.slug || '')}`}
elevation={3}
style={{
width: '140px',
height: '140px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
userSelect: 'none',
textDecoration: 'none',
cursor: 'pointer',
borderRadius: '8px',
position: 'relative',
zIndex: 10,
backgroundColor: '#f0f0f0',
boxShadow: '0px 2px 1px -1px rgba(0,0,0,0.2), 0px 1px 1px 0px rgba(0,0,0,0.14), 0px 1px 3px 0px rgba(0,0,0,0.12)'
}}
sx={{
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
'&:hover': {
transform: 'translateY(-5px)',
boxShadow: 8,
},
}}
>
{manufacturer.imageBuffer && (
<img
src={`data:image/avif;base64,${Buffer.from(manufacturer.imageBuffer).toString('base64')}`}
alt={manufacturer.name}
draggable={false}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
display: 'block',
}}
/>
)}
</Paper>
))}
</Box>
);
return <LegalPage title="Hersteller" content={content} />;
};
export default PrerenderHerstellerPage;

View File

@@ -165,16 +165,15 @@ class PrerenderProduct extends React.Component {
sx: {
mb: 2,
position: ["-webkit-sticky", "sticky"],
// No CategoryList in prerender — two-row toolbar only; safe-area for notched phones.
top: {
xs: "80px",
xs: "calc(env(safe-area-inset-top, 0px) + 128px)",
sm: "80px",
md: "80px",
lg: "80px",
},
left: 0,
width: "100%",
display: "flex",
zIndex: 999, // Just below the AppBar
zIndex: (theme) => theme.zIndex.appBar - 1,
py: 0,
px: 2,
}
@@ -552,7 +551,7 @@ class PrerenderProduct extends React.Component {
})
},
style: {
fontFamily: '"Roboto","Helvetica","Arial",sans-serif',
fontFamily: '"Outfit Variable","Roboto","Helvetica","Arial",sans-serif',
fontSize: '1rem',
lineHeight: '1.7',
color: '#333'

View File

@@ -15,6 +15,8 @@ import NotificationsActiveIcon from "@mui/icons-material/NotificationsActive";
import CircularProgress from "@mui/material/CircularProgress";
import { withI18n } from "../i18n/withTranslation.js";
import {
PUSH_SUBSCRIPTIONS_CHANGED_EVENT,
emitPushSubscriptionsChanged,
isPushApiSupported,
fetchPushConfiguration,
registerPushServiceWorker,
@@ -109,9 +111,10 @@ class AddToCartButton extends Component {
this.setState({ pushSubscribed: false, pushBusy: false });
return;
}
const res = await articlePushUnsubscribe(subscription.endpoint);
const res = await articlePushUnsubscribe(subscription.endpoint, kArtikel);
if (parseSuccess(res)) {
this.setState({ pushSubscribed: false });
emitPushSubscriptionsChanged();
} else {
this.setState({
pushError:
@@ -146,6 +149,7 @@ class AddToCartButton extends Component {
const res = await articlePushSubscribe(kArtikel, subscription);
if (parseSuccess(res)) {
this.setState({ pushSubscribed: true });
emitPushSubscriptionsChanged();
} else {
this.setState({
pushError:
@@ -174,7 +178,14 @@ class AddToCartButton extends Component {
if (this.state.quantity !== newQuantity)
this.setState({ quantity: newQuantity });
};
this.onPushSubscriptionsChanged = () => {
this.refreshIncomingPushStatus();
};
window.addEventListener("cart", this.cart);
window.addEventListener(
PUSH_SUBSCRIPTIONS_CHANGED_EVENT,
this.onPushSubscriptionsChanged
);
this.refreshIncomingPushStatus();
}
@@ -190,6 +201,10 @@ class AddToCartButton extends Component {
componentWillUnmount() {
window.removeEventListener("cart", this.cart);
window.removeEventListener(
PUSH_SUBSCRIPTIONS_CHANGED_EVENT,
this.onPushSubscriptionsChanged
);
}
handleIncrement = () => {
@@ -370,7 +385,7 @@ class AddToCartButton extends Component {
startIcon={<ShoppingCartIcon />}
sx={{
borderRadius: 2,
fontWeight: "bold",
whiteSpace: "nowrap",
backgroundColor: "#9ccc65", // yellowish green
color: "#000000",
"&:hover": {
@@ -524,7 +539,7 @@ class AddToCartButton extends Component {
startIcon={<ShoppingCartIcon />}
sx={{
borderRadius: 2,
fontWeight: "bold",
whiteSpace: "nowrap",
"&:hover": {
backgroundColor: "primary.dark",
},

View File

@@ -15,7 +15,13 @@ import StopIcon from '@mui/icons-material/Stop';
import PhotoCameraIcon from '@mui/icons-material/PhotoCamera';
import parse, { domToReact } from 'html-react-parser';
import { Link } from 'react-router-dom';
import MuiLink from '@mui/material/Link';
import { alpha } from '@mui/material/styles';
import TelegramIcon from '@mui/icons-material/Telegram';
import { isUserLoggedIn } from './LoginComponent.js';
import { withTranslation } from '../i18n/withTranslation.js';
const TELEGRAM_ASSISTANT_URL = 'https://t.me/Growheads_de_Bot';
// Initialize window object for storing messages
if (!window.chatMessages) {
window.chatMessages = [];
@@ -46,24 +52,63 @@ class ChatAssistant extends Component {
this.fileInputRef = React.createRef();
this.recordingTimer = null;
}
buildPrivacyPromptHtml = () => {
const { t } = this.props;
return `<div style="display: flex; flex-direction: column; gap: 8px; line-height: 1.5;">
<div>${t('chat.privacyPromptBefore')}<a href="/datenschutz" target="_blank" rel="noopener noreferrer">${t('chat.privacyPolicyLink')}</a>${t('chat.privacyPromptAfter')}</div>
<div><button data-confirm-privacy="true">${t('chat.privacyRead')}</button></div>
</div>`;
};
/** Keep stored privacy bubble in sync with i18n (language switcher, lazy bundle load). */
applyPrivacyPromptTranslation = () => {
this.setState((prev) => {
if (prev.privacyConfirmed) return null;
const idx = prev.messages.findIndex((m) => m.id === 'privacy-prompt');
if (idx === -1) return null;
const updatedMessages = [...prev.messages];
updatedMessages[idx] = {
...updatedMessages[idx],
text: this.buildPrivacyPromptHtml(),
};
window.chatMessages = updatedMessages;
return { messages: updatedMessages };
});
};
handleI18nLanguageChanged = () => {
this.applyPrivacyPromptTranslation();
};
componentDidMount() {
// Add socket listeners if socket is available and connected
this.addSocketListeners();
this.props.i18n?.on('languageChanged', this.handleI18nLanguageChanged);
const userStatus = isUserLoggedIn();
const isGuest = !userStatus.isLoggedIn;
if (isGuest && !this.state.privacyConfirmed) {
this.setState(prevState => {
if (prevState.messages.find(msg => msg.id === 'privacy-prompt')) {
return { isGuest: true };
const updatedMessages = prevState.messages.map((msg) =>
msg.id === 'privacy-prompt'
? { ...msg, text: this.buildPrivacyPromptHtml() }
: msg
);
window.chatMessages = updatedMessages;
return {
messages: updatedMessages,
isGuest: true,
};
}
const privacyMessage = {
id: 'privacy-prompt',
sender: 'bot',
text: 'Bitte bestätigen Sie, dass Sie die <a href="/datenschutz" target="_blank" rel="noopener noreferrer">Datenschutzbestimmungen</a> gelesen haben und damit einverstanden sind. <button data-confirm-privacy="true">Gelesen & Akzeptiert</button>',
text: this.buildPrivacyPromptHtml(),
};
const updatedMessages = [privacyMessage, ...prevState.messages];
window.chatMessages = updatedMessages;
@@ -78,12 +123,16 @@ class ChatAssistant extends Component {
}
componentDidUpdate(prevProps, prevState) {
if (prevProps.i18n?.language !== this.props.i18n?.language) {
this.applyPrivacyPromptTranslation();
}
if (prevState.messages !== this.state.messages || prevState.isTyping !== this.state.isTyping) {
this.scrollToBottom();
}
}
componentWillUnmount() {
this.props.i18n?.off('languageChanged', this.handleI18nLanguageChanged);
this.removeSocketListeners();
this.stopRecording();
if (this.recordingTimer) {
@@ -182,7 +231,7 @@ class ChatAssistant extends Component {
}, () => {
// Emit message to socket server after state is updated
if (userMessage.trim()) {
window.socketManager.emit('aiassyMessage', userMessage);
window.socketManager.emit('aiassyMessage', { message: userMessage, lang: this.props.i18n?.language });
}
});
}
@@ -238,7 +287,7 @@ class ChatAssistant extends Component {
});
} catch (err) {
console.error("Error accessing microphone:", err);
alert("Could not access microphone. Please check your browser permissions.");
alert(this.props.t('chat.micPermissionDenied'));
}
};
@@ -353,7 +402,7 @@ class ChatAssistant extends Component {
const newUserMessage = {
id: Date.now(),
sender: 'user',
text: `<img src="${imageUrl}" alt="Uploaded image" style="max-width: 100%; height: auto; border-radius: 8px;" />`,
text: `<img src="${imageUrl}" alt="${this.props.t('chat.uploadedImageAlt')}" style="max-width: 100%; height: auto; border-radius: 8px;" />`,
isImage: true
};
@@ -445,14 +494,15 @@ class ChatAssistant extends Component {
}
if (domNode.name === 'button' && domNode.attribs && domNode.attribs['data-confirm-privacy']) {
return <Button variant="contained" size="small" onClick={this.handlePrivacyConfirm}>Gelesen & Akzeptiert</Button>;
return <Button variant="contained" size="small" onClick={this.handlePrivacyConfirm}>{this.props.t('chat.privacyRead')}</Button>;
}
}
});
render() {
const { open, onClose } = this.props;
const { open, onClose, t } = this.props;
const { messages, inputValue, isTyping, isRecording, recordingTime, isGuest, privacyConfirmed } = this.state;
const showTelegramHint = !messages.some((m) => m.sender === 'user');
if (!open) {
return null;
@@ -498,12 +548,12 @@ class ChatAssistant extends Component {
}}
>
<Typography variant="h6" component="div">
Assistent
{t('chat.assistantTitle')}
<Typography component="span" color={this.state.aiThink ? "error" : "text.disabled"} sx={{ display: 'inline' }}>🧠</Typography>
<Typography component="span" color={this.state.atDatabase ? "error" : "text.disabled"} sx={{ display: 'inline' }}>🛢</Typography>
<Typography component="span" color={this.state.atWeb ? "error" : "text.disabled"} sx={{ display: 'inline' }}>🌐</Typography>
</Typography>
<IconButton onClick={onClose} size="small" aria-label="Assistent schließen" sx={{ color: 'primary.contrastText' }}>
<IconButton onClick={onClose} size="small" aria-label={t('chat.closeAria')} sx={{ color: 'primary.contrastText' }}>
<CloseIcon />
</IconButton>
</Box>
@@ -517,6 +567,58 @@ class ChatAssistant extends Component {
gap: 2,
}}
>
{showTelegramHint && (
<Paper
elevation={4}
sx={{
p: 2,
borderRadius: 2,
border: 2,
borderColor: 'primary.main',
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.14),
boxShadow: (theme) =>
`0 4px 14px ${alpha(theme.palette.primary.main, 0.35)}`,
}}
>
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'flex-start' }}>
<TelegramIcon
sx={{
fontSize: 40,
color: 'primary.main',
flexShrink: 0,
filter: (theme) =>
`drop-shadow(0 1px 2px ${alpha(theme.palette.primary.dark, 0.45)})`,
}}
/>
<Box sx={{ minWidth: 0 }}>
<Typography
variant="subtitle1"
component="div"
fontWeight={700}
color="text.primary"
sx={{ lineHeight: 1.45, mb: 0.25 }}
>
{t('chat.telegramAssistantIntro')}
</Typography>
<MuiLink
href={TELEGRAM_ASSISTANT_URL}
target="_blank"
rel="noopener noreferrer"
variant="subtitle1"
fontWeight={800}
sx={{
wordBreak: 'break-all',
color: 'primary.dark',
textDecorationColor: 'primary.main',
'&:hover': { color: 'primary.main' },
}}
>
{t('chat.telegramAssistantLink')}
</MuiLink>
</Box>
</Box>
</Paper>
)}
{messages &&messages.map((message) => (
<Box
key={message.id}
@@ -589,7 +691,7 @@ class ChatAssistant extends Component {
autoFocus
autoCapitalize="off"
autoCorrect="off"
placeholder={isRecording ? "Aufnahme läuft..." : "Du kannst mich nach Cannabissorten fragen..."}
placeholder={isRecording ? t('chat.placeholderRecording') : t('chat.inputPlaceholder')}
value={inputValue}
onChange={this.handleInputChange}
onKeyDown={this.handleKeyDown}
@@ -611,7 +713,7 @@ class ChatAssistant extends Component {
<IconButton
color="error"
onClick={this.stopRecording}
aria-label="Aufnahme stoppen"
aria-label={t('chat.micStopAria')}
sx={{ ml: { xs: 0, sm: 1 } }}
>
<StopIcon />
@@ -620,7 +722,7 @@ class ChatAssistant extends Component {
<IconButton
color="primary"
onClick={this.startRecording}
aria-label="Sprachaufnahme starten"
aria-label={t('chat.micStartAria')}
sx={{ ml: { xs: 0, sm: 1 } }}
disabled={isTyping || inputsDisabled}
>
@@ -631,7 +733,7 @@ class ChatAssistant extends Component {
<IconButton
color="primary"
onClick={this.handleImageUpload}
aria-label="Bild hochladen"
aria-label={t('chat.uploadImageAria')}
sx={{ ml: { xs: 0, sm: 1 } }}
disabled={isTyping || isRecording || inputsDisabled}
>
@@ -644,7 +746,7 @@ class ChatAssistant extends Component {
onClick={this.handleSendMessage}
disabled={isTyping || isRecording || inputsDisabled}
>
Senden
{t('chat.send')}
</Button>
</Box>
</Box>
@@ -653,4 +755,4 @@ class ChatAssistant extends Component {
}
}
export default ChatAssistant;
export default withTranslation()(ChatAssistant);

View File

@@ -11,7 +11,7 @@ import ProductList from './ProductList.js';
import CategoryBoxGrid from './CategoryBoxGrid.js';
import CategoryBox from './CategoryBox.js';
import { useParams, useSearchParams } from 'react-router-dom';
import { useParams, useSearchParams, useLocation } from 'react-router-dom';
import { getAllSettingsWithPrefix } from '../utils/sessionStorage.js';
import { withI18n } from '../i18n/withTranslation.js';
import { withCategory } from '../context/CategoryContext.js';
@@ -24,17 +24,19 @@ const withRouter = (ClassComponent) => {
return (props) => {
const params = useParams();
const [searchParams] = useSearchParams();
return <ClassComponent {...props} params={params} searchParams={searchParams} />;
const location = useLocation();
const isHersteller = location.pathname.startsWith('/Hersteller/');
return <ClassComponent {...props} params={params} searchParams={searchParams} isHersteller={isHersteller} />;
};
};
function getCachedCategoryData(categoryId, language = 'de') {
function getCachedCategoryData(categoryId, language = 'de', isHersteller = false) {
if (!window.productCache) {
window.productCache = {};
}
try {
const cacheKey = `categoryProducts_${categoryId}_${language}`;
const cacheKey = `${isHersteller ? 'manufacturer' : 'category'}Products_${categoryId}_${language}`;
const cachedData = window.productCache[cacheKey];
if (cachedData) {
@@ -166,7 +168,7 @@ function getFilteredProducts(unfilteredProducts, attributes, t) {
return { filteredProducts, activeAttributeFilters: activeAttributeFiltersWithNames, activeManufacturerFilters: activeManufacturerFiltersWithNames, activeAvailabilityFilters };
}
function setCachedCategoryData(categoryId, data, language = 'de') {
function setCachedCategoryData(categoryId, data, language = 'de', isHersteller = false) {
if (!window.productCache) {
window.productCache = {};
}
@@ -175,7 +177,7 @@ function setCachedCategoryData(categoryId, data, language = 'de') {
}
try {
const cacheKey = `categoryProducts_${categoryId}_${language}`;
const cacheKey = `${isHersteller ? 'manufacturer' : 'category'}Products_${categoryId}_${language}`;
if (data.products) for (const product of data.products) {
const productCacheKey = `product_${product.id}_${language}`;
window.productDetailCache[productCacheKey] = product;
@@ -221,9 +223,10 @@ class Content extends Component {
componentDidUpdate(prevProps) {
const currentLanguage = this.props.i18n?.language || 'de';
const categoryChanged = this.props.params.categoryId && (prevProps.params.categoryId !== this.props.params.categoryId);
const routeTypeChanged = !!prevProps.isHersteller !== !!this.props.isHersteller;
const searchChanged = this.props.searchParams?.get('q') && (prevProps.searchParams?.get('q') !== this.props.searchParams?.get('q'));
if (categoryChanged) {
if (categoryChanged || routeTypeChanged) {
// Clear context for new category loading
if (this.props.categoryContext && this.props.categoryContext.setCurrentCategory) {
this.props.categoryContext.setCurrentCategory(null);
@@ -233,7 +236,7 @@ class Content extends Component {
this.setState({ loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: [], lastFetchedLanguage: currentLanguage }, () => {
this.fetchCategoryData(this.props.params.categoryId);
});
return; // Don't check language change if category changed
return; // Don't check language change if category or route type changed
}
else if (searchChanged) {
this.setState({ loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: [], lastFetchedLanguage: currentLanguage }, () => {
@@ -345,7 +348,8 @@ class Content extends Component {
sessionStorage.setItem('filter_availability', '1');
}
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
const cachedData = getCachedCategoryData(categoryId, currentLanguage);
const isHersteller = !!this.props.isHersteller;
const cachedData = getCachedCategoryData(categoryId, currentLanguage, isHersteller);
if (cachedData) {
this.processDataWithCategoryTree(cachedData, categoryId);
return;
@@ -360,7 +364,7 @@ class Content extends Component {
window.socketManager.on(`productList:${categoryId}`, (response) => {
console.log("getCategoryProducts full response", response);
receivedFullResponse = true;
setCachedCategoryData(categoryId, response, currentLanguage);
setCachedCategoryData(categoryId, response, currentLanguage, isHersteller);
if (response && response.products !== undefined) {
this.processDataWithCategoryTree(response, categoryId);
} else {
@@ -370,12 +374,17 @@ class Content extends Component {
window.socketManager.emit(
"getCategoryProducts",
{ categoryId: categoryId, language: currentLanguage, requestTranslation: currentLanguage === 'de' ? false : true },
{
categoryId: categoryId,
language: currentLanguage,
requestTranslation: currentLanguage === 'de' ? false : true,
isHersteller,
},
(response) => {
console.log("getCategoryProducts stub response", response);
// Only process stub response if we haven't received the full response yet
if (!receivedFullResponse) {
setCachedCategoryData(categoryId, response, currentLanguage);
setCachedCategoryData(categoryId, response, currentLanguage, isHersteller);
if (response && response.products !== undefined) {
this.processDataWithCategoryTree(response, categoryId);
} else {
@@ -448,6 +457,29 @@ class Content extends Component {
}
}
// JTL kKategorie for category push: backend may omit dataParam — resolve from tree (same id as product list)
const isValidJtlCategoryId = (v) => {
if (v == null || v === '') return false;
const n = typeof v === 'number' ? v : parseInt(String(v), 10);
return Number.isFinite(n) && n > 0;
};
if (!this.props.isHersteller && categoryId !== 'neu' && categoryId !== 'bald' && !isValidJtlCategoryId(enhancedResponse.dataParam)) {
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 && typeof targetCategory.id === 'number' && targetCategory.id > 0) {
enhancedResponse.dataParam = targetCategory.id;
}
}
} catch (err) {
console.error('Error resolving dataParam from category tree:', err);
}
}
this.processData(enhancedResponse);
}
@@ -701,7 +733,7 @@ class Content extends Component {
minHeight: { xs: 'min-content', sm: '100%' }
}}>
<Box >
<Box sx={{ overflow: 'visible', minWidth: 0 }}>
<ProductFilters
products={this.state.unfilteredProducts}

View File

@@ -1,6 +1,7 @@
import React, { Component } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Chip from '@mui/material/Chip';
import Checkbox from '@mui/material/Checkbox';
import IconButton from '@mui/material/IconButton';
import Collapse from '@mui/material/Collapse';
@@ -138,21 +139,25 @@ class Filter extends Component {
handleOptionChange = (event) => {
const { name, checked } = event.target;
// Update local state first to ensure immediate UI feedback
this.setState(prevState => ({
options: {
const narrow =
typeof window !== "undefined" && window.innerWidth < 600;
this.setState((prevState) => {
const nextOptions = {
...prevState.options,
[name]: checked
}
}));
// Then notify the parent component
[name]: checked,
};
return {
options: nextOptions,
...(narrow && checked ? { isCollapsed: true } : {}),
};
});
if (this.props.onFilterChange) {
this.props.onFilterChange({
type: this.props.filterType || 'default',
name: name,
value: checked
this.props.onFilterChange({
type: this.props.filterType || "default",
name,
value: checked,
});
}
};
@@ -181,6 +186,13 @@ class Filter extends Component {
}));
};
clearFilterOption = (optionId) => (event) => {
event.stopPropagation();
this.handleOptionChange({
target: { name: optionId, checked: false },
});
};
render() {
const { options, counts, isCollapsed } = this.state;
const { title, options: optionsList = [] } = this.props;
@@ -267,11 +279,79 @@ class Filter extends Component {
)}
</Typography>
{isXsScreen && (
<IconButton size="small" aria-label={isCollapsed ? "Filter erweitern" : "Filter einklappen"} sx={{ p: 0 }}>
<IconButton
size="small"
aria-label={isCollapsed ? "Filter erweitern" : "Filter einklappen"}
sx={{ p: 0 }}
onClick={(e) => {
e.stopPropagation();
this.toggleCollapse();
}}
>
{isCollapsed ? <ExpandMoreIcon /> : <ExpandLessIcon />}
</IconButton>
)}
</Box>
{isXsScreen &&
isCollapsed &&
optionsList.some((o) => options[o.id]) && (
<Box
sx={{
display: "flex",
flexWrap: "wrap",
gap: 0.75,
mt: 0.5,
mb: 1,
pl: 0.25,
}}
>
{optionsList
.filter((o) => options[o.id])
.map((option) => (
<Chip
key={option.id}
size="small"
variant="outlined"
clickable
onClick={this.clearFilterOption(option.id)}
onDelete={this.clearFilterOption(option.id)}
label={
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
maxWidth: 200,
}}
>
{this.props.filterType === "manufacturer" &&
this.props.manufacturerImages?.get(option.id) && (
<img
src={this.props.manufacturerImages.get(option.id)}
alt=""
style={{
height: 14,
width: "auto",
objectFit: "contain",
}}
/>
)}
<span
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{option.name}
</span>
</span>
}
/>
))}
</Box>
)}
<Collapse in={!isXsScreen || !isCollapsed}>
<Box sx={{ width: '100%' }}>

View File

@@ -16,6 +16,7 @@ const StyledRouterLink = styled(RouterLink)(() => ({
lineHeight: '1.5',
display: 'block',
padding: '4px 8px',
whiteSpace: 'nowrap',
'&:hover': {
textDecoration: 'underline',
},
@@ -223,25 +224,13 @@ class Footer extends Component {
alignItems={{ xs: 'center', md: 'flex-end' }}
>
{/* Legal Links Section */}
<Stack
direction={{ xs: 'row', md: 'column' }}
spacing={{ xs: 2, md: 0.5 }}
justifyContent="center"
alignItems={{ xs: 'center', md: 'left' }}
flexWrap="wrap"
>
<Stack direction="column" spacing={0.5} justifyContent="center" alignItems="flex-start">
<StyledRouterLink to="/datenschutz">{this.props.t ? this.props.t('footer.legal.datenschutz') : 'Datenschutz'}</StyledRouterLink>
<StyledRouterLink to="/agb">{this.props.t ? this.props.t('footer.legal.agb') : 'AGB'}</StyledRouterLink>
<StyledRouterLink to="/sitemap">{this.props.t ? this.props.t('footer.legal.sitemap') : 'Sitemap'}</StyledRouterLink>
</Stack>
<Stack
direction={{ xs: 'row', md: 'column' }}
spacing={{ xs: 2, md: 0.5 }}
justifyContent="center"
alignItems={{ xs: 'center', md: 'left' }}
flexWrap="wrap"
>
<Stack direction="column" spacing={0.5} justifyContent="center" alignItems="flex-start">
<StyledRouterLink to="/impressum">{this.props.t ? this.props.t('footer.legal.impressum') : 'Impressum'}</StyledRouterLink>
<StyledRouterLink to="/batteriegesetzhinweise">{this.props.t ? this.props.t('footer.legal.batteriegesetzhinweise') : 'Batteriegesetzhinweise'}</StyledRouterLink>
<StyledRouterLink to="/widerrufsrecht">{this.props.t ? this.props.t('footer.legal.widerrufsrecht') : 'Widerrufsrecht'}</StyledRouterLink>
@@ -346,7 +335,7 @@ class Footer extends Component {
{/* Copyright Section */}
<Box sx={{ pb: 0, textAlign: 'center', filter: 'drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3))', opacity: 0.7 }}>
<Typography variant="body2" sx={{ mb: 1, fontSize: { xs: '11px', md: '14px' }, lineHeight: 1.5 }}>
<Typography variant="body2" sx={{ mb: 1, fontSize: { xs: '11px', md: '14px' }, lineHeight: 1.5, whiteSpace: 'nowrap' }}>
{this.props.t ? this.props.t('footer.allPricesIncl') : '* Alle Preise inkl. gesetzlicher USt., zzgl. Versand'}
</Typography>
<Typography

View File

@@ -91,7 +91,9 @@ class Header extends Component {
</Box>
</Container>
</Toolbar>
{(isHomePage || this.props.categoryId || isProfilePage || isAktionenPage || isFilialePage || this.props.isArtikel) && <CategoryList categoryId={209} activeCategoryId={this.props.categoryId}/>}
{(isHomePage || this.props.categoryId || isProfilePage || isAktionenPage || isFilialePage || this.props.isArtikel) && (
<CategoryList categoryId={209} activeCategoryId={this.props.categoryId} pathname={this.props.pathname} />
)}
</AppBar>
);
}
@@ -107,9 +109,15 @@ const HeaderWithContext = (props) => {
const isArtikel = location.pathname.startsWith('/Artikel/');
return (
<Header {...props} isHomePage={isHomePage} isArtikel={isArtikel} isProfilePage={isProfilePage} isAktionenPage={isAktionenPage} isFilialePage={isFilialePage} />
<Header
{...props}
isHomePage={isHomePage}
isArtikel={isArtikel}
isProfilePage={isProfilePage}
isAktionenPage={isAktionenPage}
isFilialePage={isFilialePage}
pathname={location.pathname}
/>
);
};

View File

@@ -0,0 +1,121 @@
import { useEffect, useRef, useCallback } from "react";
import { useLocation, useNavigate } from "react-router-dom";
/** Same order as the main landing tiles (home → Aktionen → Filiale). */
const MAIN_PAGE_PATHS = ["/", "/aktionen", "/filiale"];
/** No input for this long before the slideshow starts. */
const IDLE_MS = 90_000;
/** Time between automatic page changes once the slideshow is running. */
const SLIDESHOW_STEP_MS = 14_000;
/** Ignore duplicate events (mousemove etc.) within this window. */
const ACTIVITY_THROTTLE_MS = 400;
/**
* After auto-navigation, ignore user-activity handlers briefly — route changes
* often emit scroll / mousemove / focus events that would call resetIdle() and
* clear the slideshow interval (only one slide before stopping).
*/
const POST_NAV_GRACE_MS = 3_000;
/**
* After idle on /, /aktionen, or /filiale, cycles those routes slowly.
* Lives outside MainPageLayout so it is not reset when the route changes.
*/
export default function IdleMainPagesSlideshow() {
const location = useLocation();
const navigate = useNavigate();
const idleTimerRef = useRef(null);
const slideTimerRef = useRef(null);
const pathRef = useRef(location.pathname);
const wasOnMainPageRef = useRef(false);
const lastActivityRef = useRef(0);
const ignoreActivityUntilRef = useRef(0);
const resetIdleRef = useRef(() => {});
const clearTimersRef = useRef(() => {});
pathRef.current = location.pathname;
const clearTimers = useCallback(() => {
if (idleTimerRef.current != null) {
clearTimeout(idleTimerRef.current);
idleTimerRef.current = null;
}
if (slideTimerRef.current != null) {
clearInterval(slideTimerRef.current);
slideTimerRef.current = null;
}
}, []);
clearTimersRef.current = clearTimers;
const startSlideshow = useCallback(() => {
let idx = MAIN_PAGE_PATHS.indexOf(pathRef.current);
if (idx < 0) idx = 0;
const advance = () => {
idx = (idx + 1) % MAIN_PAGE_PATHS.length;
ignoreActivityUntilRef.current = Date.now() + POST_NAV_GRACE_MS;
navigate(MAIN_PAGE_PATHS[idx], { replace: true });
};
slideTimerRef.current = setInterval(advance, SLIDESHOW_STEP_MS);
}, [navigate]);
const resetIdle = useCallback(() => {
clearTimers();
if (!MAIN_PAGE_PATHS.includes(pathRef.current)) return;
idleTimerRef.current = setTimeout(() => {
idleTimerRef.current = null;
startSlideshow();
}, IDLE_MS);
}, [clearTimers, startSlideshow]);
resetIdleRef.current = resetIdle;
useEffect(() => {
const nowMain = MAIN_PAGE_PATHS.includes(location.pathname);
if (!nowMain) {
clearTimers();
wasOnMainPageRef.current = false;
return;
}
if (!wasOnMainPageRef.current) {
resetIdle();
}
wasOnMainPageRef.current = true;
}, [location.pathname, clearTimers, resetIdle]);
useEffect(() => {
const onActivity = () => {
const now = Date.now();
if (now < ignoreActivityUntilRef.current) return;
if (now - lastActivityRef.current < ACTIVITY_THROTTLE_MS) return;
lastActivityRef.current = now;
resetIdleRef.current();
};
const events = [
"mousedown",
"keydown",
"touchstart",
"touchmove",
"wheel",
"click",
"scroll",
];
events.forEach((ev) =>
window.addEventListener(ev, onActivity, { passive: true })
);
window.addEventListener("mousemove", onActivity, { passive: true });
return () => {
events.forEach((ev) => window.removeEventListener(ev, onActivity));
window.removeEventListener("mousemove", onActivity);
clearTimersRef.current();
};
}, []);
return null;
}

View File

@@ -240,7 +240,15 @@ export class LoginComponent extends Component {
isAdmin: !!response.user.admin
});
const redirectTo = location && location.hash ? `/profile${location.hash}` : '/profile';
const redirectTo = (() => {
// If we started login from the linkTelegram flow, come back there after auth.
// This prevents LinkTelegramPage from getting unmounted before the socket emit runs.
if (location?.pathname && location.pathname.startsWith('/linkTelegram')) {
return `${location.pathname}${location.search || ''}${location.hash || ''}`;
}
return location && location.hash ? `/profile${location.hash}` : '/profile';
})();
const dispatchLoginEvent = () => {
window.dispatchEvent(new CustomEvent('userLoggedIn'));
navigate(redirectTo);
@@ -415,7 +423,14 @@ export class LoginComponent extends Component {
user: response.user
});
const redirectTo = location && location.hash ? `/profile${location.hash}` : '/profile';
const redirectTo = (() => {
// If we started login from the linkTelegram flow, come back there after auth.
if (location?.pathname && location.pathname.startsWith('/linkTelegram')) {
return `${location.pathname}${location.search || ''}${location.hash || ''}`;
}
return location && location.hash ? `/profile${location.hash}` : '/profile';
})();
const dispatchLoginEvent = () => {
window.dispatchEvent(new CustomEvent('userLoggedIn'));
navigate(redirectTo);

View File

@@ -10,8 +10,196 @@ import ChevronRight from "@mui/icons-material/ChevronRight";
import { Link } from "react-router-dom";
import SharedCarousel from "./SharedCarousel.js";
import { getCombinedAnimatedBorderStyles } from "../utils/animatedBorderStyles.js";
import { STAR_POLYGON_POINTS } from "../utils/starPolygon.js";
import { useTranslation } from 'react-i18next';
const HOME_STAR_LAYERS = [
{ className: "star-rotate-slow-cw", size: 168 },
{ className: "star-rotate-slow-ccw", size: 159 },
{ className: "star-rotate-medium-cw", size: 150 },
];
/** Teal/cyan stack for the right (Konfigurator) star — same motion, blue color scheme */
const TEAL_STAR_LAYERS = [
{ className: "star-rotate-slow-ccw", size: 168 },
{ className: "star-rotate-medium-cw", size: 159 },
{ className: "star-rotate-slow-cw", size: 150 },
];
/** Initial fill per variant (matches keyframe 0%) — avoids black flash before CSS animates */
const STAR_INITIAL_FILLS = {
home: ["#B8860B", "#DAA520", "#FFD700"],
filiale: ["#5F9EA0", "#7FCDCD", "#AFEEEE"],
};
/** Injected in render (not useEffect) so first paint already has keyframes — avoids angle/color snap on load */
const STAR_DECORATION_CSS = `
@keyframes rotateClockwise {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes rotateCounterClockwise {
from { transform: rotate(0deg); }
to { transform: rotate(-360deg); }
}
.star-rotate-slow-cw,
.star-rotate-slow-ccw,
.star-rotate-medium-cw {
transform-box: fill-box;
transform-origin: center;
}
.star-rotate-slow-cw {
animation: rotateClockwise 60s linear infinite;
}
.star-rotate-slow-ccw {
animation: rotateCounterClockwise 45s linear infinite;
}
.star-rotate-medium-cw {
animation: rotateClockwise 30s linear infinite;
}
.star-layer-svg-home {
mix-blend-mode: screen;
opacity: 0.92;
filter: drop-shadow(3px 3px 6px rgba(0, 0, 0, 0.5)) drop-shadow(0 0 10px rgba(255, 215, 0, 0.6)) drop-shadow(0 0 20px rgba(255, 215, 0, 0.3));
}
.star-layer-svg-filiale {
mix-blend-mode: soft-light;
opacity: 0.94;
filter: drop-shadow(3px 3px 6px rgba(0, 0, 0, 0.5)) drop-shadow(0 0 10px rgba(127, 205, 205, 0.6)) drop-shadow(0 0 18px rgba(95, 158, 160, 0.35));
}
.star-layer-svg {
shape-rendering: geometricPrecision;
transform: translateZ(0);
}
@keyframes starFillHome0 {
0%, 100% { fill: #B8860B; }
33% { fill: #FFD700; }
66% { fill: #DAA520; }
}
@keyframes starFillHome1 {
0%, 100% { fill: #DAA520; }
33% { fill: #B8860B; }
66% { fill: #FFD700; }
}
@keyframes starFillHome2 {
0%, 100% { fill: #FFD700; }
33% { fill: #DAA520; }
66% { fill: #B8860B; }
}
@keyframes starDriftHome0 {
0%, 100% { transform: rotate(20deg) translate(0px, 0px); }
50% { transform: rotate(20deg) translate(5px, -5px); }
}
@keyframes starDriftHome1 {
0%, 100% { transform: rotate(-25deg) translate(0px, 0px); }
50% { transform: rotate(-25deg) translate(-4px, 6px); }
}
@keyframes starDriftHome2 {
0%, 100% { transform: translate(0px, 0px); }
50% { transform: translate(3px, 4px); }
}
.star-layer-wrap.star-layer-home-0 {
animation: starDriftHome0 6.5s ease-in-out infinite;
animation-fill-mode: both;
}
.star-layer-wrap.star-layer-home-1 {
animation: starDriftHome1 7s ease-in-out 0.4s infinite;
animation-fill-mode: both;
}
.star-layer-wrap.star-layer-home-2 {
animation: starDriftHome2 5.5s ease-in-out 0.8s infinite;
animation-fill-mode: both;
}
.star-poly-home-0 { animation: starFillHome0 10s ease-in-out 0s infinite both; }
.star-poly-home-1 { animation: starFillHome1 10s ease-in-out 1.1s infinite both; }
.star-poly-home-2 { animation: starFillHome2 10s ease-in-out 2.2s infinite both; }
@keyframes starFillFil0 {
0%, 100% { fill: #5F9EA0; }
33% { fill: #AFEEEE; }
66% { fill: #7FCDCD; }
}
@keyframes starFillFil1 {
0%, 100% { fill: #7FCDCD; }
33% { fill: #5F9EA0; }
66% { fill: #AFEEEE; }
}
@keyframes starFillFil2 {
0%, 100% { fill: #AFEEEE; }
33% { fill: #7FCDCD; }
66% { fill: #5F9EA0; }
}
@keyframes starDriftFil0 {
0%, 100% { transform: rotate(20deg) translate(0px, 0px); }
50% { transform: rotate(20deg) translate(4px, -4px); }
}
@keyframes starDriftFil1 {
0%, 100% { transform: rotate(-25deg) translate(0px, 0px); }
50% { transform: rotate(-25deg) translate(-5px, 5px); }
}
@keyframes starDriftFil2 {
0%, 100% { transform: translate(0px, 0px); }
50% { transform: translate(3px, 3px); }
}
.star-layer-wrap.star-layer-filiale-0 {
animation: starDriftFil0 6.5s ease-in-out infinite;
animation-fill-mode: both;
}
.star-layer-wrap.star-layer-filiale-1 {
animation: starDriftFil1 7s ease-in-out 0.4s infinite;
animation-fill-mode: both;
}
.star-layer-wrap.star-layer-filiale-2 {
animation: starDriftFil2 5.5s ease-in-out 0.8s infinite;
animation-fill-mode: both;
}
.star-poly-filiale-0 { animation: starFillFil0 10s ease-in-out 0s infinite both; }
.star-poly-filiale-1 { animation: starFillFil1 10s ease-in-out 1.1s infinite both; }
.star-poly-filiale-2 { animation: starFillFil2 10s ease-in-out 2.2s infinite both; }
`;
const StarDecorationLayers = ({ layers, variant }) => (
<>
{layers.map(({ className, size }, i) => {
const half = size / 2;
const initialFill = STAR_INITIAL_FILLS[variant][i];
return (
<div
key={i}
className={`star-layer-wrap star-layer-${variant}-${i}`}
style={{
position: "absolute",
left: "50%",
top: "50%",
width: size,
height: size,
marginLeft: -half,
marginTop: -half,
zIndex: 3 - i,
}}
>
<svg
viewBox="0 0 60 60"
width="100%"
height="100%"
className={`${className} star-layer-svg star-layer-svg-${variant}`}
style={{ display: "block" }}
>
<polygon
points={STAR_POLYGON_POINTS}
fill={initialFill}
className={`star-poly-fill star-poly-${variant}-${i}`}
/>
</svg>
</div>
);
})}
</>
);
const ContentBox = ({ box, index, pageType, starHovered, setStarHovered, opacity, translatedContent }) => (
<Grid key={`${pageType}-${index}`} item xs={12} sm={6} sx={{ p: 2, width: "50%", position: 'relative' }}>
{index === 0 && pageType === "home" && (
@@ -25,28 +213,19 @@ const ContentBox = ({ box, index, pageType, starHovered, setStarHovered, opacity
zIndex: 999,
pointerEvents: 'none',
'& *': { pointerEvents: 'none' },
filter: 'drop-shadow(6px 6px 12px rgba(0, 0, 0, 0.6)) drop-shadow(0 0 20px rgba(255, 215, 0, 0.8)) drop-shadow(0 0 40px rgba(255, 215, 0, 0.4))',
display: { xs: 'none', sm: 'block' }
}}
>
<svg viewBox="0 0 60 60" width="168" height="168" className="star-rotate-slow-cw" style={{ position: 'absolute', top: '-9px', left: '-9px', transform: 'rotate(20deg)' }}>
<polygon points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20" fill="#B8860B" />
</svg>
<svg viewBox="0 0 60 60" width="159" height="159" className="star-rotate-slow-ccw" style={{ position: 'absolute', top: '-4.5px', left: '-4.5px', transform: 'rotate(-25deg)' }}>
<polygon points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20" fill="#DAA520" />
</svg>
<svg viewBox="0 0 60 60" width="150" height="150" className="star-rotate-medium-cw">
<polygon points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20" fill="#FFD700" />
</svg>
<div style={{ position: 'absolute', top: '45%', left: '43%', transform: 'translate(-50%, -50%) rotate(-10deg)', color: 'white', fontWeight: '900', fontSize: '20px', textShadow: '0px 3px 6px rgba(0,0,0,0.5)', zIndex: 1000, textAlign: 'center', lineHeight: '1.1', width: '135px', transition: 'opacity 0.3s ease', opacity: starHovered ? 0 : 1 }}>
<StarDecorationLayers layers={HOME_STAR_LAYERS} variant="home" />
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%) rotate(-10deg)', color: 'white', fontWeight: '900', fontSize: '20px', textShadow: '0px 3px 6px rgba(0,0,0,0.5)', zIndex: 1000, textAlign: 'center', lineHeight: '1.1', width: '135px', transition: 'opacity 0.3s ease', opacity: starHovered ? 0 : 1 }}>
{translatedContent.outdoorSeason}
</div>
<div style={{ position: 'absolute', top: '45%', left: '43%', transform: 'translate(-50%, -50%) rotate(-10deg)', color: 'white', fontWeight: '900', fontSize: '20px', textShadow: '0px 3px 6px rgba(0,0,0,0.5)', zIndex: 1000, textAlign: 'center', lineHeight: '1.1', width: '135px', opacity: starHovered ? 1 : 0, transition: 'opacity 0.3s ease' }}>
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%) rotate(-10deg)', color: 'white', fontWeight: '900', fontSize: '20px', textShadow: '0px 3px 6px rgba(0,0,0,0.5)', zIndex: 1000, textAlign: 'center', lineHeight: '1.1', width: '135px', opacity: starHovered ? 1 : 0, transition: 'opacity 0.3s ease' }}>
{translatedContent.selectSeedRate}
</div>
</Box>
)}
{index === 1 && pageType === "filiale" && (
{index === 1 && pageType === "home" && (
<Box
sx={{
position: 'absolute',
@@ -57,21 +236,12 @@ const ContentBox = ({ box, index, pageType, starHovered, setStarHovered, opacity
zIndex: 999,
pointerEvents: 'none',
'& *': { pointerEvents: 'none' },
filter: 'drop-shadow(6px 6px 12px rgba(0, 0, 0, 0.6)) drop-shadow(0 0 20px rgba(175, 238, 238, 0.8)) drop-shadow(0 0 40px rgba(175, 238, 238, 0.4))',
display: { xs: 'none', sm: 'block' }
}}
>
<svg viewBox="0 0 60 60" width="168" height="168" className="star-rotate-slow-ccw" style={{ position: 'absolute', top: '-9px', left: '-9px', transform: 'rotate(20deg)' }}>
<polygon points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20" fill="#5F9EA0" />
</svg>
<svg viewBox="0 0 60 60" width="159" height="159" className="star-rotate-medium-cw" style={{ position: 'absolute', top: '-4.5px', left: '-4.5px', transform: 'rotate(-25deg)' }}>
<polygon points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20" fill="#7FCDCD" />
</svg>
<svg viewBox="0 0 60 60" width="150" height="150" className="star-rotate-slow-cw">
<polygon points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20" fill="#AFEEEE" />
</svg>
<div style={{ position: 'absolute', top: '42%', left: '45%', transform: 'translate(-50%, -50%) rotate(10deg)', color: 'white', fontWeight: '900', fontSize: '20px', textShadow: '0px 3px 6px rgba(0,0,0,0.5)', zIndex: 1000, textAlign: 'center', lineHeight: '1.1', width: '135px' }}>
{translatedContent.showUsPhoto}
<StarDecorationLayers layers={TEAL_STAR_LAYERS} variant="filiale" />
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%) rotate(10deg)', color: 'white', fontWeight: '900', fontSize: '20px', textShadow: '0px 3px 6px rgba(0,0,0,0.5)', zIndex: 1000, textAlign: 'center', lineHeight: '1.1', width: '135px' }}>
{translatedContent.buildYourSet}
</div>
</Box>
)}
@@ -89,11 +259,19 @@ const ContentBox = ({ box, index, pageType, starHovered, setStarHovered, opacity
display: "flex",
flexDirection: "column",
boxShadow: 10,
transition: "all 0.3s ease",
"&:hover": { transform: "translateY(-5px)", boxShadow: 20 },
transition: "box-shadow 0.3s ease",
"&:hover": { boxShadow: 20 },
}}
onMouseEnter={index === 0 && pageType === "filiale" ? () => setStarHovered(true) : undefined}
onMouseLeave={index === 0 && pageType === "filiale" ? () => setStarHovered(false) : undefined}
onMouseEnter={
pageType === "home" && index === 0
? () => setStarHovered(true)
: undefined
}
onMouseLeave={
pageType === "home" && index === 0
? () => setStarHovered(false)
: undefined
}
>
<Box sx={{ height: "100%", bgcolor: box.bgcolor, position: "relative", display: "flex", alignItems: "center", justifyContent: "center" }}>
{opacity === 1 && (
@@ -113,8 +291,22 @@ const MainPageLayout = () => {
const currentPath = location.pathname;
const { t } = useTranslation();
const [starHovered, setStarHovered] = React.useState(false);
// State to track kiosk mode
const [isKiosk, setIsKiosk] = React.useState(() => window.growheadskiosk === true);
// Listen for the custom event
React.useEffect(() => {
const handleKioskChange = () => {
setIsKiosk(window.growheadskiosk === true);
};
window.addEventListener('growheadskiosk-change', handleKioskChange);
return () => window.removeEventListener('growheadskiosk-change', handleKioskChange);
}, []);
const translatedContent = {
showUsPhoto: t('sections.showUsPhoto'),
buildYourSet: isKiosk ? 'Schau in den Stecklingskatalog' : t('sections.buildYourSet'),
selectSeedRate: t('sections.selectSeedRate'),
outdoorSeason: t('sections.outdoorSeason')
};
@@ -123,31 +315,6 @@ const MainPageLayout = () => {
const isAktionen = currentPath === "/aktionen";
const isFiliale = currentPath === "/filiale";
React.useEffect(() => {
const style = document.createElement('style');
style.textContent = `
@keyframes rotateClockwise {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes rotateCounterClockwise {
from { transform: rotate(0deg); }
to { transform: rotate(-360deg); }
}
.star-rotate-slow-cw {
animation: rotateClockwise 60s linear infinite;
}
.star-rotate-slow-ccw {
animation: rotateCounterClockwise 45s linear infinite;
}
.star-rotate-medium-cw {
animation: rotateClockwise 30s linear infinite;
}
`;
document.head.appendChild(style);
return () => document.head.removeChild(style);
}, []);
const getNavigationConfig = () => {
if (isHome) return { leftNav: { text: t('navigation.aktionen'), link: "/aktionen" }, rightNav: { text: t('navigation.filiale'), link: "/filiale" } };
if (isAktionen) return { leftNav: { text: t('navigation.filiale'), link: "/filiale" }, rightNav: { text: t('navigation.home'), link: "/" } };
@@ -164,11 +331,11 @@ const MainPageLayout = () => {
const allContentBoxes = {
home: [
{ title: t('sections.seeds'), image: "/assets/images/seeds.avif", bgcolor: "#e1f0d3", link: "/Kategorie/Seeds" },
{ title: t('sections.konfigurator'), image: "/assets/images/konfigurator.avif", bgcolor: "#e8f5d6", link: "/Konfigurator" }
{ title: isKiosk ? 'Stecklingskatalog' : t('sections.konfigurator'), image: isKiosk ? "/assets/images/cutlings2.avif" : "/assets/images/konfigurator.avif", bgcolor: "#e8f5d6", link: isKiosk ? "https://cloneheads.de" : "/Konfigurator" }
],
aktionen: [
{ title: t('sections.oilPress'), image: "/assets/images/presse.jpg", bgcolor: "#e1f0d3", link: "/presseverleih" },
{ title: t('sections.thcTest'), image: "/assets/images/purpl.jpg", bgcolor: "#e8f5d6", link: "/thc-test" }
{ title: t('sections.oilPress'), image: "/assets/images/presse.jpg", bgcolor: "#e1f0d3", link: "/Artikel/Graveda-10t-presse-tagesmiete-inkl-prepress-vorpressform" },
{ title: t('sections.thcTest'), image: "/assets/images/purpl.jpg", bgcolor: "#e8f5d6", link: "/Artikel/1x-messung-purplpro-thc-cbd-restfeuchte-wasseraktivitaet" }
],
filiale: [
{ title: t('sections.address1'), image: "/assets/images/filiale1.jpg", bgcolor: "#e1f0d3", link: "/filiale" },
@@ -193,6 +360,7 @@ const MainPageLayout = () => {
return (
<Container maxWidth="lg" sx={{ py: 2 }}>
<style>{getCombinedAnimatedBorderStyles(['seeds', 'cutlings'])}</style>
<style>{STAR_DECORATION_CSS}</style>
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mb: 4, mt: 2, px: 0, transition: "all 0.3s ease-in-out", flexDirection: { xs: "column", sm: "row" } }}>
<Box sx={{ display: { xs: "block", sm: "none" }, mb: { xs: 2, sm: 0 }, width: "100%", textAlign: "center", position: "relative" }}>
{Object.entries(allTitles).map(([pageType, title]) => (

View File

@@ -1,17 +1,25 @@
import React from 'react';
import { Link } from 'react-router-dom';
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Paper from "@mui/material/Paper";
import IconButton from "@mui/material/IconButton";
import ChevronLeft from "@mui/icons-material/ChevronLeft";
import ChevronRight from "@mui/icons-material/ChevronRight";
import { withTranslation } from 'react-i18next';
import { withLanguage } from '../i18n/withTranslation.js';
const ITEM_WIDTH = 140 + 16; // 140px width + 16px gap
const AUTO_SCROLL_SPEED = 1.0;
const AUTOSCROLL_RESTART_DELAY = 5000;
class ManufacturerCarousel extends React.Component {
_isMounted = false;
originalItems = [];
animationFrame = null;
autoScrollActive = true;
translateX = 0;
inactivityTimer = null;
constructor(props) {
super(props);
@@ -28,10 +36,8 @@ class ManufacturerCarousel extends React.Component {
componentWillUnmount() {
this._isMounted = false;
if (this.animationFrame) {
cancelAnimationFrame(this.animationFrame);
this.animationFrame = null;
}
this.stopAutoScroll();
this.clearInactivityTimer();
// Revoke object URLs to avoid memory leaks
for (const item of this.originalItems) {
if (item.src) URL.revokeObjectURL(item.src);
@@ -46,7 +52,12 @@ class ManufacturerCarousel extends React.Component {
.filter(m => m.imageBuffer)
.map(m => {
const blob = new Blob([m.imageBuffer], { type: 'image/avif' });
return { id: m.id, name: m.name || '', src: URL.createObjectURL(blob) };
return {
id: m.id,
name: m.name || '',
slug: m.slug || '',
src: URL.createObjectURL(blob),
};
})
.sort(() => Math.random() - 0.5);
@@ -60,13 +71,38 @@ class ManufacturerCarousel extends React.Component {
};
startAutoScroll = () => {
this.autoScrollActive = true;
if (!this.animationFrame) {
this.animationFrame = requestAnimationFrame(this.tick);
}
};
stopAutoScroll = () => {
this.autoScrollActive = false;
if (this.animationFrame) {
cancelAnimationFrame(this.animationFrame);
this.animationFrame = null;
}
};
clearInactivityTimer = () => {
if (this.inactivityTimer) {
clearTimeout(this.inactivityTimer);
this.inactivityTimer = null;
}
};
startInactivityTimer = () => {
this.clearInactivityTimer();
this.inactivityTimer = setTimeout(() => {
if (this._isMounted) {
this.startAutoScroll();
}
}, AUTOSCROLL_RESTART_DELAY);
};
tick = () => {
if (!this._isMounted || this.originalItems.length === 0) return;
if (!this._isMounted || !this.autoScrollActive || this.originalItems.length === 0) return;
this.translateX -= AUTO_SCROLL_SPEED;
@@ -82,6 +118,41 @@ class ManufacturerCarousel extends React.Component {
this.animationFrame = requestAnimationFrame(this.tick);
};
updateTrackTransform = () => {
if (this.carouselTrackRef.current) {
this.carouselTrackRef.current.style.transform = `translateX(${this.translateX}px)`;
}
};
scrollBy = (direction) => {
if (this.originalItems.length === 0) return;
const originalItemCount = this.originalItems.length;
const maxScroll = ITEM_WIDTH * originalItemCount;
this.translateX += direction * ITEM_WIDTH;
if (this.translateX > 0) {
this.translateX = -(maxScroll - ITEM_WIDTH);
} else if (Math.abs(this.translateX) >= maxScroll) {
this.translateX = 0;
}
this.updateTrackTransform();
};
handleLeftClick = () => {
this.stopAutoScroll();
this.scrollBy(1);
this.startInactivityTimer();
};
handleRightClick = () => {
this.stopAutoScroll();
this.scrollBy(-1);
this.startInactivityTimer();
};
render() {
const { t } = this.props;
const { items } = this.state;
@@ -90,19 +161,36 @@ class ManufacturerCarousel extends React.Component {
return (
<Box sx={{ mt: 4, mb: 4 }}>
<Typography
variant="h4"
component="div"
<Box
component={Link}
to="/Hersteller"
sx={{
fontFamily: 'SwashingtonCP',
textShadow: '3px 3px 10px rgba(0, 0, 0, 0.4)',
textAlign: 'center',
mb: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
textDecoration: 'none',
color: 'primary.main',
mb: 2,
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateX(5px)',
color: 'primary.dark',
},
}}
>
{t('product.manufacturer')}
</Typography>
<Typography
variant="h4"
component="span"
sx={{
fontFamily: 'SwashingtonCP',
textShadow: '3px 3px 10px rgba(0, 0, 0, 0.4)',
textAlign: 'center',
}}
>
{t('product.manufacturer')}
</Typography>
<ChevronRight sx={{ fontSize: '2.5rem', ml: 1 }} />
</Box>
<div
style={{
@@ -129,6 +217,46 @@ class ManufacturerCarousel extends React.Component {
zIndex: 2, pointerEvents: 'none',
}} />
{/* Left Arrow */}
<IconButton
aria-label="Vorherige Hersteller anzeigen"
onClick={this.handleLeftClick}
style={{
position: 'absolute',
top: '50%',
left: '8px',
transform: 'translateY(-50%)',
zIndex: 1000,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
width: '48px',
height: '48px',
borderRadius: '50%'
}}
>
<ChevronLeft />
</IconButton>
{/* Right Arrow */}
<IconButton
aria-label="Nächste Hersteller anzeigen"
onClick={this.handleRightClick}
style={{
position: 'absolute',
top: '50%',
right: '8px',
transform: 'translateY(-50%)',
zIndex: 1000,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
width: '48px',
height: '48px',
borderRadius: '50%'
}}
>
<ChevronRight />
</IconButton>
<div
style={{
position: 'relative',
@@ -151,8 +279,11 @@ class ManufacturerCarousel extends React.Component {
}}
>
{items.map((item, index) => (
<div
<Paper
key={`${item.id}-${index}`}
component={Link}
to={`/Hersteller/${encodeURIComponent(item.slug || '')}`}
elevation={3}
style={{
flex: '0 0 140px',
width: '140px',
@@ -162,7 +293,20 @@ class ManufacturerCarousel extends React.Component {
justifyContent: 'center',
overflow: 'hidden',
userSelect: 'none',
pointerEvents: 'none',
textDecoration: 'none',
cursor: 'pointer',
borderRadius: '8px',
position: 'relative',
zIndex: 10,
backgroundColor: '#f0f0f0',
boxShadow: '0px 2px 1px -1px rgba(0,0,0,0.2), 0px 1px 1px 0px rgba(0,0,0,0.14), 0px 1px 3px 0px rgba(0,0,0,0.12)'
}}
sx={{
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
'&:hover': {
transform: 'translateY(-5px)',
boxShadow: 8,
},
}}
>
<img
@@ -176,7 +320,7 @@ class ManufacturerCarousel extends React.Component {
display: 'block',
}}
/>
</div>
</Paper>
))}
</div>
</div>

View File

@@ -10,6 +10,12 @@ import AddToCartButton from './AddToCartButton.js';
import { Link, useNavigate } from 'react-router-dom';
import { withI18n } from '../i18n/withTranslation.js';
import ZoomInIcon from '@mui/icons-material/ZoomIn';
import { STAR_POLYGON_POINTS } from '../utils/starPolygon.js';
import {
PRODUCT_CARD_MOBILE_MAX_WIDTH_PX,
PRODUCT_CARD_WIDTH_SM_PX,
PRODUCT_CARD_WIDTH_XS_PX,
} from '../utils/productCardLayout.js';
// Helper function to find level 1 category ID from any category ID
const findLevel1CategoryId = (categoryId) => {
@@ -275,7 +281,16 @@ class Product extends Component {
<Box sx={{
position: 'relative',
height: '100%',
width: { xs: '100%', sm: 'auto' }
/* Match card width on xs so absolute NEU star is relative to the card, not the full grid row */
width: {
xs: `${PRODUCT_CARD_WIDTH_XS_PX}px`,
sm: 'auto',
},
minWidth: { xs: `${PRODUCT_CARD_WIDTH_XS_PX}px`, sm: 'auto' },
maxWidth: { xs: `${PRODUCT_CARD_WIDTH_XS_PX}px`, sm: 'none' },
display: 'flex',
justifyContent: { xs: 'center', sm: 'flex-start' },
mx: { xs: 'auto', sm: 0 },
}}>
{isNew && (
<div
@@ -302,7 +317,7 @@ class Product extends Component {
}}
>
<polygon
points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20"
points={STAR_POLYGON_POINTS}
fill="#20403a"
stroke="none"
/>
@@ -321,7 +336,7 @@ class Product extends Component {
}}
>
<polygon
points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20"
points={STAR_POLYGON_POINTS}
fill="#40736b"
stroke="none"
/>
@@ -334,7 +349,7 @@ class Product extends Component {
height="50"
>
<polygon
points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20"
points={STAR_POLYGON_POINTS}
fill="#609688"
stroke="none"
/>
@@ -344,7 +359,7 @@ class Product extends Component {
<div
style={{
position: 'absolute',
top: '45%',
top: '40%',
left: '45%',
transform: 'translate(-50%, -50%) rotate(-10deg)',
color: 'white',
@@ -361,22 +376,36 @@ class Product extends Component {
<Card
sx={{
width: { xs: '100vw', sm: '250px' },
minWidth: { xs: '100vw', sm: '250px' },
width: {
xs: `${PRODUCT_CARD_WIDTH_XS_PX}px`,
sm: `${PRODUCT_CARD_WIDTH_SM_PX}px`,
},
minWidth: {
xs: `${PRODUCT_CARD_WIDTH_XS_PX}px`,
sm: `${PRODUCT_CARD_WIDTH_SM_PX}px`,
},
maxWidth: {
xs: `${PRODUCT_CARD_WIDTH_XS_PX}px`,
sm: `${PRODUCT_CARD_WIDTH_SM_PX}px`,
},
height: '100%',
display: 'flex',
flexDirection: 'column',
transition: 'transform 0.3s ease, box-shadow 0.3s ease',
position: 'relative',
overflow: 'hidden',
borderRadius: { xs: 0, sm: '8px' },
border: { xs: 'none', sm: 'inherit' },
boxShadow: { xs: 'none', sm: 'inherit' },
mx: { xs: 0, sm: 'auto' },
borderRadius: { xs: '8px', sm: '8px' },
border: { xs: '1px solid', sm: 'inherit' },
borderColor: { xs: 'divider', sm: 'inherit' },
boxShadow: { xs: '0 1px 4px rgba(0,0,0,0.08)', sm: 'inherit' },
mx: { xs: 'auto', sm: 'auto' },
'&:hover': {
transform: { xs: 'none', sm: 'translateY(-5px)' },
boxShadow: { xs: 'none', sm: '0px 10px 20px rgba(0,0,0,0.1)' }
}
boxShadow: {
xs: '0 1px 4px rgba(0,0,0,0.08)',
sm: '0px 10px 20px rgba(0,0,0,0.1)',
},
},
}}
>
{showThcBadge && (
@@ -459,7 +488,7 @@ class Product extends Component {
<CardMedia
key={index}
component="img"
height={window.innerWidth < 600 ? "240" : "180"}
height={window.innerWidth < PRODUCT_CARD_MOBILE_MAX_WIDTH_PX ? "240" : "180"}
image={imgSrc}
alt={name}
fetchPriority={this.props.priority === 'high' && index === 0 ? 'high' : 'auto'}
@@ -488,7 +517,7 @@ class Product extends Component {
) : (
<CardMedia
component="img"
height={window.innerWidth < 600 ? "240" : "180"}
height={window.innerWidth < PRODUCT_CARD_MOBILE_MAX_WIDTH_PX ? "240" : "180"}
image="/assets/images/nopicture.jpg"
alt={name}
fetchPriority={this.props.priority === 'high' ? 'high' : 'auto'}

View File

@@ -8,8 +8,11 @@ import ChevronRight from "@mui/icons-material/ChevronRight";
import Product from "./Product.js";
import { withTranslation } from 'react-i18next';
import { withLanguage } from '../i18n/withTranslation.js';
const ITEM_WIDTH = 250 + 16; // 250px width + 16px gap
import {
getProductCarouselItemStridePx,
PRODUCT_CARD_WIDTH_SM_PX,
PRODUCT_CARD_WIDTH_XS_PX,
} from "../utils/productCardLayout.js";
const AUTO_SCROLL_SPEED = 0.5; // px per frame (~60fps, so ~30px/sec)
const AUTOSCROLL_RESTART_DELAY = 5000; // 5 seconds of inactivity before restarting autoscroll
const SCROLLBAR_FLASH_DURATION = 3000; // 3 seconds to show the virtual scrollbar
@@ -81,13 +84,31 @@ class ProductCarousel extends React.Component {
products: [],
currentLanguage: (i18n && i18n.language) || 'de',
showScrollbar: false,
itemStride:
typeof window !== "undefined"
? getProductCarouselItemStridePx()
: PRODUCT_CARD_WIDTH_SM_PX + 16,
};
this.carouselTrackRef = React.createRef();
}
handleCarouselResize = () => {
if (!this._isMounted) return;
const next = getProductCarouselItemStridePx();
if (next !== this.state.itemStride) {
this.translateX = 0;
this.updateTrackTransform();
this.setState({ itemStride: next });
}
};
componentDidMount() {
this._isMounted = true;
if (typeof window !== "undefined") {
window.addEventListener("resize", this.handleCarouselResize);
this.setState({ itemStride: getProductCarouselItemStridePx() });
}
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n.language;
logCarousel("mount", {
@@ -370,6 +391,9 @@ class ProductCarousel extends React.Component {
componentWillUnmount() {
this._isMounted = false;
if (typeof window !== "undefined") {
window.removeEventListener("resize", this.handleCarouselResize);
}
this.stopAutoScroll();
this.clearInactivityTimer();
this.clearScrollbarTimer();
@@ -430,8 +454,9 @@ class ProductCarousel extends React.Component {
this.translateX -= AUTO_SCROLL_SPEED;
this.updateTrackTransform();
const { itemStride } = this.state;
const originalItemCount = this.originalProducts.length;
const maxScroll = ITEM_WIDTH * originalItemCount;
const maxScroll = itemStride * originalItemCount;
// Check if we've scrolled past the first set of items
if (Math.abs(this.translateX) >= maxScroll) {
@@ -467,14 +492,15 @@ class ProductCarousel extends React.Component {
if (this.originalProducts.length === 0) return;
// direction: 1 = left (scroll content right), -1 = right (scroll content left)
const { itemStride } = this.state;
const originalItemCount = this.originalProducts.length;
const maxScroll = ITEM_WIDTH * originalItemCount;
const maxScroll = itemStride * originalItemCount;
this.translateX += direction * ITEM_WIDTH;
this.translateX += direction * itemStride;
// Handle wrap-around when scrolling left (positive translateX)
if (this.translateX > 0) {
this.translateX = -(maxScroll - ITEM_WIDTH);
this.translateX = -(maxScroll - itemStride);
}
// Handle wrap-around when scrolling right (negative translateX beyond limit)
else if (Math.abs(this.translateX) >= maxScroll) {
@@ -494,9 +520,13 @@ class ProductCarousel extends React.Component {
return null;
}
const { itemStride } = this.state;
const originalItemCount = this.originalProducts.length;
const viewportWidth = 1080; // carousel container max-width
const itemsInView = Math.floor(viewportWidth / ITEM_WIDTH);
const viewportWidth =
typeof window !== "undefined"
? Math.min(1080, Math.max(0, window.innerWidth - 56))
: 1080;
const itemsInView = Math.max(1, Math.floor(viewportWidth / itemStride));
// Calculate which item is currently at the left edge (first visible)
let currentItemIndex;
@@ -504,11 +534,11 @@ class ProductCarousel extends React.Component {
if (this.translateX === 0) {
currentItemIndex = 0;
} else if (this.translateX > 0) {
const maxScroll = ITEM_WIDTH * originalItemCount;
const maxScroll = itemStride * originalItemCount;
const effectivePosition = maxScroll + this.translateX;
currentItemIndex = Math.floor(effectivePosition / ITEM_WIDTH);
currentItemIndex = Math.floor(effectivePosition / itemStride);
} else {
currentItemIndex = Math.floor(Math.abs(this.translateX) / ITEM_WIDTH);
currentItemIndex = Math.floor(Math.abs(this.translateX) / itemStride);
}
// Ensure we stay within bounds
@@ -615,7 +645,7 @@ class ProductCarousel extends React.Component {
top: '50%',
left: '8px',
transform: 'translateY(-50%)',
zIndex: 1200,
zIndex: 1000,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
width: '48px',
@@ -635,7 +665,7 @@ class ProductCarousel extends React.Component {
top: '50%',
right: '8px',
transform: 'translateY(-50%)',
zIndex: 1200,
zIndex: 1000,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
width: '48px',
@@ -676,16 +706,19 @@ class ProductCarousel extends React.Component {
}}
>
{products.map((product, index) => (
<div
<Box
key={`${product.id}-${index}`}
className="product-carousel-item"
style={{
flex: '0 0 250px',
width: '250px',
maxWidth: '250px',
minWidth: '250px',
boxSizing: 'border-box',
position: 'relative'
sx={{
flex: {
xs: `0 0 ${PRODUCT_CARD_WIDTH_XS_PX}px`,
sm: `0 0 ${PRODUCT_CARD_WIDTH_SM_PX}px`,
},
width: { xs: PRODUCT_CARD_WIDTH_XS_PX, sm: PRODUCT_CARD_WIDTH_SM_PX },
maxWidth: { xs: PRODUCT_CARD_WIDTH_XS_PX, sm: PRODUCT_CARD_WIDTH_SM_PX },
minWidth: { xs: PRODUCT_CARD_WIDTH_XS_PX, sm: PRODUCT_CARD_WIDTH_SM_PX },
boxSizing: "border-box",
position: "relative",
}}
>
<Product
@@ -713,7 +746,7 @@ class ProductCarousel extends React.Component {
priority={index < 6 ? 'high' : 'auto'}
t={t}
/>
</div>
</Box>
))}
</div>

View File

@@ -1089,6 +1089,8 @@ class ProductDetailPage extends Component {
const { product, loading, upgrading, error, attributeImages, /*isSteckling,*/ attributes, komponentenLoaded, komponentenData, komponentenImages, totalKomponentenPrice, totalSavings, shareAnchorEl, sharePopperOpen, snackbarOpen, snackbarMessage, snackbarSeverity } =
this.state;
const hasAttributeImages = attributes.some((attr) => attributeImages[attr.kMerkmalWert]);
// Debug alerts removed
@@ -1172,18 +1174,17 @@ class ProductDetailPage extends Component {
<Box
sx={{
mb: 2,
position: ["-webkit-sticky", "sticky"], // Provide both prefixed and standard
position: "sticky",
top: {
xs: "110px",
xs: "calc(env(safe-area-inset-top, 0px) + 160px)",
sm: "110px",
md: "110px",
lg: "110px",
} /* Offset to sit below the header 120 mith menu for md and lg*/,
},
left: 0,
width: "100%",
display: "flex",
zIndex: (theme) =>
theme.zIndex.appBar - 1 /* Just below the AppBar */,
zIndex: (theme) => theme.zIndex.appBar - 1,
py: 0,
px: 2,
}}
@@ -1198,10 +1199,19 @@ class ProductDetailPage extends Component {
borderRadius: 1,
}}
>
<Typography variant="body2" color="text.secondary">
<Typography variant="body2" color="text.secondary" component="div">
<Link
to="/"
onClick={() => this.props.navigate(-1)}
onClick={(e) => {
e.preventDefault();
if (this.props.navigate) {
if (typeof window !== "undefined" && window.history.length > 1) {
this.props.navigate(-1);
} else {
this.props.navigate("/");
}
}
}}
style={{
paddingLeft: 16,
paddingRight: 16,
@@ -1298,7 +1308,19 @@ class ProductDetailPage extends Component {
<Box sx={{ minHeight: "107px", display: "flex", justifyContent: "space-between", alignItems: "flex-start", mb: 2, gap: 2 }}>
{(attributes.some(attr => attributeImages[attr.kMerkmalWert]) || attributes.some(attr => !attributeImages[attr.kMerkmalWert])) && (
<Stack direction="row" spacing={0} sx={{ flexWrap: "wrap", gap: 1, flex: 1 }}>
<Stack
direction="row"
spacing={0}
sx={{
flexWrap: "wrap",
gap: 1,
flex: 1,
display: {
xs: hasAttributeImages ? "flex" : "none",
sm: "flex",
},
}}
>
{attributes
.filter(attribute => attributeImages[attribute.kMerkmalWert])
.map((attribute) => {
@@ -1321,7 +1343,11 @@ class ProductDetailPage extends Component {
key={attribute.kMerkmalWert}
label={attribute.cWert}
disabled
sx={{
sx={(theme) => ({
// Max-width query: reliable on portrait phones (avoids display:contents wrapper quirks)
[theme.breakpoints.down('sm')]: {
display: 'none',
},
'&.Mui-disabled': {
opacity: 1, // ← Remove the "fog"
},
@@ -1329,7 +1355,7 @@ class ProductDetailPage extends Component {
fontWeight: 'bold',
color: 'inherit', // ← Keep normal text color
},
}}
})}
/>
))}
</Stack>

View File

@@ -1,13 +1,35 @@
import React, { Component } from 'react';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import Tooltip from '@mui/material/Tooltip';
import CircularProgress from '@mui/material/CircularProgress';
import NotificationsIcon from '@mui/icons-material/Notifications';
import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive';
import Filter from './Filter.js';
import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom';
import { setSessionSetting, removeSessionSetting, clearAllSessionSettings } from '../utils/sessionStorage.js';
import { withI18n } from '../i18n/withTranslation.js';
import {
PUSH_SUBSCRIPTIONS_CHANGED_EVENT,
emitPushSubscriptionsChanged,
isPushApiSupported,
fetchPushConfiguration,
registerPushServiceWorker,
ensurePushSubscription,
categoryPushStatus,
categoryPushSubscribe,
categoryPushUnsubscribe,
parseSubscribedStatus,
parseSuccess,
} from '../utils/categoryPush.js';
const isNew = (neu) => neu && (new Date().getTime() - new Date(neu).getTime() < 30 * 24 * 60 * 60 * 1000);
/** Category push subscribe UI only when the category has more than this many articles. */
const MIN_ARTICLES_FOR_CATEGORY_PUSH = 10;
// HOC to provide router props to class components
const withRouter = (ClassComponent) => {
return (props) => {
@@ -38,19 +60,35 @@ class ProductFilters extends Component {
uniqueManufacturerArray,
attributeGroups,
manufacturerImages: new Map(), // id (number) → object URL
pushInteractive: false,
pushSubscribed: false,
pushBusy: false,
pushError: null,
};
this._manufacturerImageUrls = []; // track for cleanup
}
componentDidMount() {
this.onPushSubscriptionsChanged = () => {
this.refreshCategoryPushStatus();
};
this.adjustPaperHeight();
window.addEventListener('resize', this.adjustPaperHeight);
window.addEventListener(
PUSH_SUBSCRIPTIONS_CHANGED_EVENT,
this.onPushSubscriptionsChanged
);
this._loadManufacturerImages();
this.refreshCategoryPushStatus();
}
componentWillUnmount() {
window.removeEventListener('resize', this.adjustPaperHeight);
window.removeEventListener(
PUSH_SUBSCRIPTIONS_CHANGED_EVENT,
this.onPushSubscriptionsChanged
);
this._manufacturerImageUrls.forEach(url => URL.revokeObjectURL(url));
}
@@ -102,17 +140,148 @@ class ProductFilters extends Component {
const attributeGroups = this._getAttributeGroups(this.props.attributes);
this.setState({attributeGroups});
}
const prevCount = prevProps.products?.length || 0;
const nextCount = this.props.products?.length || 0;
if (
prevProps.dataParam !== this.props.dataParam ||
prevProps.dataType !== this.props.dataType ||
prevProps.params?.categoryId !== this.props.params?.categoryId ||
prevCount !== nextCount
) {
this.refreshCategoryPushStatus();
}
}
kKategorieNumber = () => {
const { dataParam, dataType } = this.props;
if (dataType !== 'category') return null;
if (dataParam == null || dataParam === '') return null;
const n = typeof dataParam === 'number' ? dataParam : parseInt(String(dataParam), 10);
return Number.isFinite(n) && n > 0 ? n : null;
};
shouldShowCategoryPush = () =>
(this.props.products?.length || 0) > MIN_ARTICLES_FOR_CATEGORY_PUSH;
refreshCategoryPushStatus = async () => {
const kKat = this.kKategorieNumber();
if (!kKat || !this.shouldShowCategoryPush() || !isPushApiSupported()) {
this.setState({
pushInteractive: false,
pushSubscribed: false,
pushError: null,
});
return;
}
try {
const cfg = await fetchPushConfiguration();
if (!cfg.configured || !cfg.publicKey) {
this.setState({ pushInteractive: false });
return;
}
await registerPushServiceWorker();
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (!subscription) {
this.setState({
pushInteractive: true,
pushSubscribed: false,
pushError: null,
});
return;
}
const statusData = await categoryPushStatus(kKat, subscription.endpoint);
this.setState({
pushInteractive: true,
pushSubscribed: parseSubscribedStatus(statusData),
pushError: null,
});
} catch (e) {
console.warn('ProductFilters: category push init failed', e);
this.setState({ pushInteractive: false });
}
};
handleCategoryPushClick = async () => {
const t = this.props.t;
if (!this.state.pushInteractive || this.state.pushBusy) return;
const kKat = this.kKategorieNumber();
if (!kKat || !this.shouldShowCategoryPush()) return;
this.setState({ pushBusy: true, pushError: null });
try {
if (this.state.pushSubscribed) {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (!subscription) {
this.setState({ pushSubscribed: false, pushBusy: false });
return;
}
const res = await categoryPushUnsubscribe(subscription.endpoint, kKat);
if (parseSuccess(res)) {
this.setState({ pushSubscribed: false });
emitPushSubscriptionsChanged();
} else {
this.setState({
pushError:
res?.message ||
res?.error ||
(t ? t('productDialogs.pushNotifyError') : ''),
});
}
} else {
const perm = await Notification.requestPermission();
if (perm !== 'granted') {
this.setState({
pushError: t ? t('productDialogs.pushNotifyPermissionDenied') : '',
pushBusy: false,
});
return;
}
const cfg = await fetchPushConfiguration();
if (!cfg.configured || !cfg.publicKey) {
this.setState({
pushError: t ? t('productDialogs.pushNotifyServerDisabled') : '',
pushBusy: false,
});
return;
}
await registerPushServiceWorker();
const subscription = await ensurePushSubscription(cfg.publicKey);
const res = await categoryPushSubscribe(kKat, subscription);
if (parseSuccess(res)) {
this.setState({ pushSubscribed: true });
emitPushSubscriptionsChanged();
} else {
this.setState({
pushError:
res?.message ||
res?.error ||
(t ? t('productDialogs.pushNotifyError') : ''),
});
}
}
} catch (e) {
console.error('ProductFilters: category push', e);
this.setState({
pushError: e.message || (t ? t('productDialogs.pushNotifyError') : ''),
});
} finally {
this.setState({ pushBusy: false });
}
};
adjustPaperHeight = () => {
// Skip height adjustment on xs screens
if (window.innerWidth < 600) return;
// Get reference to our paper element
const paperEl = document.getElementById('filters-paper');
if (!paperEl) return;
// No min-height on mobile — also clears inline style after resize from desktop
if (window.innerWidth < 600) {
paperEl.style.minHeight = '';
return;
}
// Get viewport height
const viewportHeight = window.innerHeight;
@@ -200,35 +369,140 @@ class ProductFilters extends Component {
}
render() {
const kKategorie = this.kKategorieNumber();
const showCategoryPush = kKategorie && this.shouldShowCategoryPush();
const {
pushInteractive,
pushSubscribed,
pushBusy,
pushError,
} = this.state;
const pushDisabledHint =
showCategoryPush && !pushInteractive && !pushBusy
? isPushApiSupported()
? this.props.t
? this.props.t('productDialogs.pushNotifyServerDisabled')
: ''
: this.props.t
? this.props.t('filters.notifyNewArticlesBrowserUnsupported')
: 'Ihr Browser unterstützt keine Push-Benachrichtigungen.'
: '';
return (
<Box
sx={{
px: { xs: 2, sm: 0 },
pt: { xs: 2, sm: 0 },
/* Room below Paper so elevation shadow isnt clipped by grid/parent */
pb: { xs: 2, sm: 2 },
overflow: 'visible',
/* Same green as ProductList / product strip mobile (#e8f5e8), not theme background.default (#C8E6C9) */
bgcolor: { xs: '#e8f5e8', sm: 'transparent' },
}}
>
<Paper
id="filters-paper"
elevation={window.innerWidth < 600 ? 0 : 1}
elevation={1}
sx={{
p: { xs: 1, sm: 2 },
borderRadius: { xs: 0, sm: 2 },
p: { xs: 2.5, sm: 2.5 },
mx: { sm: 'auto' },
maxWidth: '100%',
borderRadius: 2,
bgcolor: 'background.paper',
display: 'flex',
flexDirection: 'column',
border: { xs: 'none', sm: 'inherit' },
boxShadow: { xs: 'none', sm: 'inherit' },
mx: { xs: 0, sm: 'auto' },
width: { xs: '100%', sm: 'auto' }
boxSizing: 'border-box',
overflow: 'visible',
}}
>
{this.props.dataType == 'category' && (
<Typography
variant="h3"
component="h1"
sx={{
mb: 4,
fontFamily: 'SwashingtonCP',
color: 'primary.main'
}}
>
{this.props.categoryName}
</Typography>
<Box sx={{ mb: 4 }}>
<Typography
variant="h3"
component="h1"
sx={{
mb: showCategoryPush ? 1.5 : 4,
fontFamily: 'SwashingtonCP',
color: 'primary.main',
}}
>
{this.props.categoryName}
</Typography>
{showCategoryPush && (
<Box sx={{ width: '100%' }}>
<Tooltip title={pushDisabledHint} arrow>
<span style={{ display: 'block', width: '100%' }}>
<Button
fullWidth
variant="outlined"
color="inherit"
size="small"
onClick={this.handleCategoryPushClick}
disabled={!pushInteractive || pushBusy}
startIcon={
pushBusy ? (
<CircularProgress size={14} sx={{ color: 'inherit' }} />
) : pushSubscribed ? (
<NotificationsActiveIcon sx={{ fontSize: 18, color: '#2e7d32' }} />
) : (
<NotificationsIcon sx={{ fontSize: 18, color: 'rgba(0,0,0,0.65)' }} />
)
}
sx={{
borderRadius: 1,
fontWeight: 600,
fontSize: '0.7rem',
lineHeight: 1.2,
backgroundColor: '#fff',
color: 'text.primary',
border: '1px solid',
borderColor: 'divider',
boxShadow: 'none',
whiteSpace: 'normal',
textAlign: 'center',
py: 0.4,
px: 0.75,
minHeight: 28,
'& .MuiButton-label': {
whiteSpace: 'normal',
lineHeight: 1.2,
},
'& .MuiButton-startIcon': {
mr: 0.5,
'& > *:nth-of-type(1)': { fontSize: 18 },
},
'&:hover': {
backgroundColor: 'grey.50',
borderColor: 'divider',
boxShadow: 'none',
},
'&.Mui-disabled': {
backgroundColor: '#fff',
color: 'action.disabled',
borderColor: 'action.disabledBackground',
},
}}
>
{this.props.t
? this.props.t('filters.notifyNewArticles')
: 'Bei neuen Artikeln benachrichtigen'}
</Button>
</span>
</Tooltip>
{pushError && (
<Typography
variant="caption"
color="error"
sx={{ display: 'block', mt: 0.5, textAlign: 'center' }}
>
{pushError}
</Typography>
)}
</Box>
)}
</Box>
)}
@@ -295,6 +569,7 @@ class ProductFilters extends Component {
/>
</>)}
</Paper>
</Box>
);
}
}

View File

@@ -241,7 +241,7 @@ class ProductList extends Component {
<Box sx={{
display: 'flex',
display: { xs: 'none', sm: 'flex' },
gap: { xs: 0.5, sm: 1 },
alignItems: 'center',
flexWrap: 'wrap',
@@ -438,7 +438,11 @@ class ProductList extends Component {
</Stack>
</Box>
<Grid container spacing={{ xs: 0, sm: 2 }}>
<Grid
container
spacing={{ xs: 0, sm: 2 }}
sx={{ bgcolor: { xs: '#e8f5e8', sm: 'transparent' } }}
>
{this.renderNoProductsMessage()}
{products.map((product, index) => (
<Grid
@@ -448,6 +452,7 @@ class ProductList extends Component {
justifyContent: { xs: 'stretch', sm: 'center' },
mb: { xs: 0, sm: 1 },
width: { xs: '100%', sm: 'auto' },
bgcolor: { xs: '#e8f5e8', sm: 'transparent' },
borderBottom: {
xs: index < products.length - 1 ? '16px solid #e8f5e8' : 'none',
sm: 'none'

View File

@@ -327,7 +327,7 @@ class SharedCarousel extends React.Component {
top: '50%',
left: '8px',
transform: 'translateY(-50%)',
zIndex: 1200,
zIndex: 1000,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
width: '48px',
@@ -347,7 +347,7 @@ class SharedCarousel extends React.Component {
top: '50%',
right: '8px',
transform: 'translateY(-50%)',
zIndex: 1200,
zIndex: 1000,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
width: '48px',

View File

@@ -85,7 +85,7 @@ class Stripe extends Component {
colorWarning: '#FF9800', // Orange for warnings
// Typography matching your Roboto setup
fontFamily: "'Roboto', 'Helvetica', 'Arial', sans-serif",
fontFamily: "'Outfit Variable', 'Roboto', 'Helvetica', 'Arial', sans-serif",
fontSizeBase: '16px', // Base font size for mobile compatibility
fontWeightNormal: '400', // Normal Roboto weight
fontWeightMedium: '500', // Medium Roboto weight

View File

@@ -71,7 +71,7 @@ const ThemeCustomizerDialog = ({ open, onClose, theme, onThemeChange }) => {
},
},
typography: {
fontFamily: "'Roboto', 'Helvetica', 'Arial', sans-serif",
fontFamily: "'Outfit Variable', 'Roboto', 'Helvetica', 'Arial', sans-serif",
h4: {
fontWeight: 600,
color: '#33691E',

View File

@@ -159,8 +159,23 @@ class CategoryList extends Component {
}
}
/**
* Which nav item should appear active: home, konfigurator, neu, bald, a level-1 category id, or null.
* neu/bald are not in the category tree as seoNames, so pathname / explicit props must drive them.
* Home vs Konfigurator both had categoryId null from the app; pathname disambiguates.
*/
getNavHighlightKey() {
const pathname = this.props.pathname || "";
if (pathname === "/") return "home";
if (pathname === "/Konfigurator" || pathname.startsWith("/Konfigurator/")) return "konfigurator";
if (pathname === "/Kategorie/neu" || this.props.activeCategoryId === "neu") return "neu";
if (pathname === "/Kategorie/bald" || this.props.activeCategoryId === "bald") return "bald";
return this.state.activeCategoryId;
}
render() {
const { categories, mobileMenuOpen, activeCategoryId } = this.state;
const { categories, mobileMenuOpen } = this.state;
const navKey = this.getNavHighlightKey();
const renderCategoryRow = (categories, isMobile = false) => (
<Box
@@ -168,7 +183,7 @@ class CategoryList extends Component {
display: "flex",
justifyContent: "flex-start",
alignItems: "center",
flexWrap: "wrap",
flexWrap: isMobile ? "wrap" : "nowrap",
overflowX: "visible",
flexDirection: isMobile ? "column" : "row",
py: 0.5, // Add vertical padding to prevent border clipping
@@ -182,7 +197,7 @@ class CategoryList extends Component {
aria-label="Zur Startseite"
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{
fontSize: "0.75rem",
fontSize: "0.85rem",
textTransform: "none",
whiteSpace: "nowrap",
opacity: 0.9,
@@ -194,7 +209,7 @@ class CategoryList extends Component {
transition: "all 0.2s ease",
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
position: "relative",
...(activeCategoryId === null && {
...(navKey === "home" && {
bgcolor: "#fff",
textShadow: "none",
opacity: 1,
@@ -218,7 +233,7 @@ class CategoryList extends Component {
<HomeIcon sx={{
fontSize: "1rem",
mr: isMobile ? 1 : 0,
color: activeCategoryId === null ? "#2e7d32" : "inherit"
color: navKey === "home" ? "#2e7d32" : "inherit"
}} />
{isMobile && (
<Box sx={{ position: "relative", display: "inline-block" }}>
@@ -227,7 +242,7 @@ class CategoryList extends Component {
className="bold-text"
sx={{
fontWeight: "bold",
color: activeCategoryId === null ? "#2e7d32" : "transparent",
color: navKey === "home" ? "#2e7d32" : "transparent",
position: "relative",
zIndex: 2,
}}
@@ -239,7 +254,7 @@ class CategoryList extends Component {
className="thin-text"
sx={{
fontWeight: "400",
color: activeCategoryId === null ? "transparent" : "inherit",
color: navKey === "home" ? "transparent" : "inherit",
position: "absolute",
top: 0,
left: 0,
@@ -260,7 +275,7 @@ class CategoryList extends Component {
aria-label="Neuheiten"
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{
fontSize: "0.75rem",
fontSize: "0.85rem",
textTransform: "none",
whiteSpace: "nowrap",
opacity: 0.9,
@@ -271,12 +286,32 @@ class CategoryList extends Component {
justifyContent: isMobile ? "flex-start" : "center",
transition: "all 0.2s ease",
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
position: "relative"
position: "relative",
...(navKey === "neu" && {
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",
},
},
}}
>
<FiberNewIcon sx={{
fontSize: "1rem",
mr: isMobile ? 1 : 0
mr: isMobile ? 1 : 0,
color: navKey === "neu" ? "#2e7d32" : "inherit"
}} />
{isMobile && (
<Box sx={{ position: "relative", display: "inline-block" }}>
@@ -285,7 +320,7 @@ class CategoryList extends Component {
className="bold-text"
sx={{
fontWeight: "bold",
color: "transparent",
color: navKey === "neu" ? "#2e7d32" : "transparent",
position: "relative",
zIndex: 2,
}}
@@ -297,7 +332,7 @@ class CategoryList extends Component {
className="thin-text"
sx={{
fontWeight: "400",
color: "inherit",
color: navKey === "neu" ? "transparent" : "inherit",
position: "absolute",
top: 0,
left: 0,
@@ -318,7 +353,7 @@ class CategoryList extends Component {
aria-label={this.props.t ? this.props.t('navigation.soon') : 'Demnächst'}
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{
fontSize: "0.75rem",
fontSize: "0.85rem",
textTransform: "none",
whiteSpace: "nowrap",
opacity: 0.9,
@@ -329,12 +364,32 @@ class CategoryList extends Component {
justifyContent: isMobile ? "flex-start" : "center",
transition: "all 0.2s ease",
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
position: "relative"
position: "relative",
...(navKey === "bald" && {
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",
},
},
}}
>
<LocalShippingIcon sx={{
fontSize: "1rem",
mr: isMobile ? 1 : 0
mr: isMobile ? 1 : 0,
color: navKey === "bald" ? "#2e7d32" : "inherit"
}} />
{isMobile && (
<Box sx={{ position: "relative", display: "inline-block" }}>
@@ -342,7 +397,7 @@ class CategoryList extends Component {
className="bold-text"
sx={{
fontWeight: "bold",
color: "transparent",
color: navKey === "bald" ? "#2e7d32" : "transparent",
position: "relative",
zIndex: 2,
}}
@@ -353,7 +408,7 @@ class CategoryList extends Component {
className="thin-text"
sx={{
fontWeight: "400",
color: "inherit",
color: navKey === "bald" ? "transparent" : "inherit",
position: "absolute",
top: 0,
left: 0,
@@ -380,7 +435,7 @@ class CategoryList extends Component {
size="small"
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{
fontSize: "0.75rem",
fontSize: "0.85rem",
textTransform: "none",
whiteSpace: "nowrap",
opacity: 0.9,
@@ -392,7 +447,7 @@ class CategoryList extends Component {
transition: "all 0.2s ease",
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
position: "relative",
...(activeCategoryId === category.id && {
...(navKey === category.id && {
bgcolor: "#fff",
textShadow: "none",
opacity: 1,
@@ -416,7 +471,7 @@ class CategoryList extends Component {
className="bold-text"
sx={{
fontWeight: "bold",
color: activeCategoryId === category.id ? "#2e7d32" : "transparent",
color: navKey === category.id ? "#2e7d32" : "transparent",
position: "relative",
zIndex: 2,
}}
@@ -428,7 +483,7 @@ class CategoryList extends Component {
className="thin-text"
sx={{
fontWeight: "400",
color: activeCategoryId === category.id ? "transparent" : "inherit",
color: navKey === category.id ? "transparent" : "inherit",
position: "absolute",
top: 0,
left: 0,
@@ -451,7 +506,7 @@ class CategoryList extends Component {
alignItems: "center",
height: "33px", // Match small button height
px: 1,
fontSize: "0.75rem",
fontSize: "0.85rem",
opacity: 0.9,
}}
>
@@ -464,10 +519,10 @@ class CategoryList extends Component {
to="/Konfigurator"
color="inherit"
size="small"
aria-label="Zur Startseite"
aria-label={this.props.t ? this.props.t('navigation.konfiguratorAria') : 'Zum Konfigurator'}
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{
fontSize: "0.75rem",
fontSize: "0.85rem",
textTransform: "none",
whiteSpace: "nowrap",
opacity: 0.9,
@@ -479,7 +534,7 @@ class CategoryList extends Component {
transition: "all 0.2s ease",
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
position: "relative",
...(activeCategoryId === null && {
...(navKey === "konfigurator" && {
bgcolor: "#fff",
textShadow: "none",
opacity: 1,
@@ -503,7 +558,7 @@ class CategoryList extends Component {
<SettingsIcon sx={{
fontSize: "1rem",
mr: isMobile ? 1 : 0,
color: activeCategoryId === null ? "#2e7d32" : "inherit"
color: navKey === "konfigurator" ? "#2e7d32" : "inherit"
}} />
{isMobile && (
<Box sx={{ position: "relative", display: "inline-block" }}>
@@ -512,26 +567,26 @@ class CategoryList extends Component {
className="bold-text"
sx={{
fontWeight: "bold",
color: activeCategoryId === null ? "#2e7d32" : "transparent",
color: navKey === "konfigurator" ? "#2e7d32" : "transparent",
position: "relative",
zIndex: 2,
}}
>
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
{this.props.t ? this.props.t('sections.konfigurator') : 'Konfigurator'}
</Box>
{/* Thin text (positioned on top) */}
<Box
className="thin-text"
sx={{
fontWeight: "400",
color: activeCategoryId === null ? "transparent" : "inherit",
color: navKey === "konfigurator" ? "transparent" : "inherit",
position: "absolute",
top: 0,
left: 0,
zIndex: 1,
}}
>
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
{this.props.t ? this.props.t('sections.konfigurator') : 'Konfigurator'}
</Box>
</Box>
)}

View File

@@ -1,3 +1,18 @@
export default {
"privacyRead": "تمت القراءة والموافقة",
"privacyRead": "قريت ووافقت",
"privacyPromptBefore": "من فضلك أكد إنك قرأت ",
"privacyPolicyLink": "سياسة الخصوصية",
"privacyPromptAfter": " ووافقت عليها. ",
"telegramAssistantIntro": "كمان تقدر تتواصل مع مساعد Growheads على Telegram:",
"telegramAssistantLink": "t.me/Growheads_de_Bot",
"assistantTitle": "المساعد",
"placeholderRecording": "جارٍ التسجيل…",
"inputPlaceholder": "تقدر تسألني عن سلالات القنب…",
"send": "إرسال",
"closeAria": "إغلاق المساعد",
"micStartAria": "ابدأ تسجيل الصوت",
"micStopAria": "إيقاف التسجيل",
"uploadImageAria": "رفع صورة",
"micPermissionDenied": "تعذر الوصول إلى الميكروفون. من فضلك راجع أذونات المتصفح.",
"uploadedImageAlt": "صورة مرفوعة"
};

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "لكل صفحة",
"availability": "التوفر",
"manufacturer": "المصنّع",
"all": "الكل"
"all": "الكل",
"notifyNewArticles": "إشعار عند توفر منتجات جديدة",
"notifyNewArticlesBrowserUnsupported": "المتصفح لا يدعم إشعارات الدفع."
};

View File

@@ -2,15 +2,15 @@ export default {
"sections": {
"chatbot": {
"title": "شات بوت بالذكاء الاصطناعي (OpenAI API)",
"intro": "إحنا بنستخدم شات بوت مدعوم بالذكاء الاصطناعي على موقعنا، واللي بيتوفر عن طريق OpenAI API، علشان يرد تلقائيًا على الاستفسارات ويحسّن الدعم بتاعنا.",
"processing": "عند استخدام الشات بوت، المحتوى اللي بتدخّله بيتبعت إلى OpenAI وبيتتمت معالجته هناك نيابةً عنّا علشان يتولد رد مناسب. برجاء ملاحظة إن المحتوى المُدخل ممكن كمان يحتوي على بيانات شخصية لو إنت وفّرت المعلومات دي بنفسك.",
"legalBasis": "الأساس القانوني للمعالجة هو Art. 6 para. 1 lit. f DSGVO. مصلحتنا المشروعة بتتمثل في التعامل بكفاءة مع الاستفسارات وتحسين عرضنا الإلكتروني.",
"intro": "بنستخدم شات بوت مدعوم بالذكاء الاصطناعي على موقعنا الإلكتروني، وبيتوفّر من خلال OpenAI API، علشان نرد تلقائيًا على الاستفسارات ونحسّن الدعم بتاعنا.",
"processing": "عند استخدام الشات بوت، المحتوى اللي بتدخّله بيتنقل إلى OpenAI وبيتتم معالجته هناك نيابةً عنّا علشان يتكوّن رد مناسب. برجاء ملاحظة إن المحتوى المُدخل ممكن كمان يحتوي على بيانات شخصية لو إنت أدخلت المعلومات دي بنفسك.",
"legalBasis": "الأساس القانوني للمعالجة هو Art. 6 para. 1 lit. f DSGVO. المصلحة المشروعة بتاعتنا بتكمن في التعامل بكفاءة مع الاستفسارات وتحسين العرض الإلكتروني بتاعنا.",
"dataRecipient": "الجهة المستلمة للبيانات هي OpenAI. بالنسبة للمستخدمين في المنطقة الاقتصادية الأوروبية وسويسرا، فإن OpenAI Ireland Limited, 1st Floor, The Liffey Trust Centre, 117-126 Sheriff Street Upper, Dublin 1, D01 YC43, Ireland، هي الطرف التعاقدي المعني.",
"thirdCountryTransfer": "لا يمكن استبعاد نقل البيانات إلى دول ثالثة (وخاصةً الولايات المتحدة). وده بيتم على أساس ضمانات مناسبة وفقًا لـ Art. 44 ff. DSGVO، وبالأخص من خلال استخدام البنود التعاقدية القياسية.",
"modelTraining": "بحسب ما ذكرته OpenAI، البيانات الناتجة عن استخدام الـ API لا تُستخدم لتدريب النماذج بشكل افتراضي.",
"dataRetention": "إحنا بنحتفظ بمحتوى المحادثات فقط للمدة اللازمة لمعالجة استفسارك، وبعد كده بنحذفه أو بنخليه مجهول الهوية، إلا لو كانت فيه التزامات احتفاظ قانونية.",
"thirdCountryTransfer": "نقل البيانات إلى دول ثالثة (وبالأخص الولايات المتحدة) ما ينفعش نستبعده. وده بيتم على أساس ضمانات مناسبة وفقًا لـ Art. 44 ff. DSGVO، وبالأخص من خلال استخدام البنود التعاقدية القياسية.",
"modelTraining": "حسب ما أعلنت OpenAI، البيانات الناتجة عن استخدام الـ API لا يتم استخدامها لتدريب النماذج بشكل افتراضي.",
"dataRetention": "بنحتفظ بمحتوى المحادثات فقط للمدة اللازمة لمعالجة استفسارك، وبعد كده بنحذفه أو بنخليه مجهول الهوية، ما لم تكن في التزامات احتفاظ قانونية.",
"voluntaryUse": "استخدام الشات بوت اختياري. برجاء عدم إدخال أي بيانات شخصية حساسة في الشات.",
"privacyLinkIntro": "معلومات إضافية عن معالجة البيانات بواسطة OpenAI ممكن تلاقيها على:",
"privacyLinkIntro": زيد من المعلومات عن معالجة البيانات بواسطة OpenAI هتلاقيها هنا:",
"privacyLinkUrl": "https://openai.com/de-DE/policies/eu-privacy-policy/"
}
}

View File

@@ -1,5 +1,6 @@
export default {
"home": "الرئيسية",
"konfiguratorAria": "اذهب إلى المُكوِّن",
"new": "وصل حديثًا",
"soon": "قريبًا",
"aktionen": "العروض الترويجية",

View File

@@ -6,7 +6,7 @@ export default {
"thcTest": "اختبار THC",
"address1": "Trachenberger Street 14",
"address2": "01129 Dresden",
"showUsPhoto": "ورّينا أجمل صورة عندك",
"buildYourSet": "جهّز مجموعتك",
"selectSeedRate": "اختار البذرة، واضغط قيّم",
"outdoorSeason": "موسم الزراعة الخارجية بيبدأ"
};

View File

@@ -1,3 +1,18 @@
export default {
"privacyRead": "Прочетено и прието",
"privacyPromptBefore": "Моля, потвърдете, че сте прочели ",
"privacyPolicyLink": "политиката за поверителност",
"privacyPromptAfter": " и сте съгласни с нея. ",
"telegramAssistantIntro": "Можете също да се свържете с асистента на Growheads в Telegram:",
"telegramAssistantLink": "t.me/Growheads_de_Bot",
"assistantTitle": "Асистент",
"placeholderRecording": "Записване…",
"inputPlaceholder": "Можете да ме питате за сортове канабис…",
"send": "Изпрати",
"closeAria": "Затвори асистента",
"micStartAria": "Стартиране на гласов запис",
"micStopAria": "Спиране на записа",
"uploadImageAria": "Качване на изображение",
"micPermissionDenied": "Не беше възможен достъп до микрофона. Моля, проверете разрешенията на браузъра си.",
"uploadedImageAlt": "Качено изображение"
};

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "на страница",
"availability": "Наличност",
"manufacturer": "Производител",
"all": "Всички"
"all": "Всички",
"notifyNewArticles": "Уведомявай ме за нови продукти",
"notifyNewArticlesBrowserUnsupported": "Вашият браузър не поддържа push известия."
};

View File

@@ -1,15 +1,15 @@
export default {
"sections": {
"chatbot": {
"title": "AI Chatbot (OpenAI API)",
"intro": "Използваме на нашия уебсайт AI-поддържан чатбот, предоставян чрез OpenAI API, за автоматично отговаряне на запитвания и подобряване на нашата поддръжка.",
"processing": "При използване на чатбота въведеното от Вас съдържание се предава на OpenAI и се обработва там от наше име, за да се генерира подходящ отговор. Моля, имайте предвид, че въведеното съдържание може да съдържа и лични данни, ако Вие сами предоставите такава информация.",
"legalBasis": "Правното основание за обработването е чл. 6, ал. 1, буква f DSGVO. Нашият легитимен интерес се състои в ефективното обработване на запитванията, както и в подобряването на нашето онлайн предложение.",
"dataRecipient": "Получател на данните е OpenAI. За потребители в Европейското икономическо пространство и в Швейцария релевантният договорен партньор е OpenAI Ireland Limited, 1st Floor, The Liffey Trust Centre, 117-126 Sheriff Street Upper, Dublin 1, D01 YC43, Ireland.",
"thirdCountryTransfer": "Не може да се изключи прехвърляне на данни към трети държави (по-специално САЩ). Това се извършва на основание на подходящи гаранции съгласно чл. 44 и сл. DSGVO, по-специално чрез използването на стандартни договорни клаузи.",
"modelTraining": "Според OpenAI данните от използването на API по подразбиране не се използват за обучение на моделите.",
"dataRetention": "Съхраняваме съдържанието на чата само толкова дълго, колкото е необходимо за обработването на Вашето запитване, и след това го изтриваме или анонимизираме, освен ако не съществуват законови задължения за съхранение.",
"voluntaryUse": "Използването на чатбота е доброволно. Моля, не въвеждайте в чата никакви чувствителни лични данни.",
"title": "ИИ чатбот (OpenAI API)",
"intro": "Използваме на нашия уебсайт чатбот, подпомаган от изкуствен интелект, който се предоставя чрез OpenAI API, за автоматично отговаряне на запитвания и подобряване на нашата поддръжка.",
"processing": "При използване на чатбота съдържанието, което въвеждате, се предава на OpenAI и се обработва там от наше име, за да се генерира подходящ отговор. Моля, имайте предвид, че въведеното съдържание може да съдържа и лични данни, ако сами предоставите такава информация.",
"legalBasis": "Правното основание за обработването е чл. 6, ал. 1, буква f DSGVO. Нашият легитимен интерес се състои в ефективното обработване на запитвания и в подобряването на нашето онлайн предложение.",
"dataRecipient": "Получател на данните е OpenAI. За потребители в Европейското икономическо пространство и в Швейцария OpenAI Ireland Limited, 1st Floor, The Liffey Trust Centre, 117-126 Sheriff Street Upper, Dublin 1, D01 YC43, Ireland, е съответната договорна страна.",
"thirdCountryTransfer": "Не може да бъде изключено предаване на данни към трети държави (по-специално САЩ). Това се извършва въз основа на подходящи гаранции съгласно чл. 44 и сл. DSGVO, по-специално чрез използване на стандартни договорни клаузи.",
"modelTraining": "Според информацията на OpenAI данните от използването на API по подразбиране не се използват за обучение на моделите.",
"dataRetention": "Съхраняваме съдържанието на чата само толкова дълго, колкото е необходимо за обработване на Вашето запитване, и след това го изтриваме или анонимизираме, освен ако не съществуват законови задължения за съхранение.",
"voluntaryUse": "Използването на чатбота е доброволно. Моля, не въвеждайте чувствителни лични данни в чата.",
"privacyLinkIntro": "Допълнителна информация относно обработването на данни от OpenAI можете да намерите на:",
"privacyLinkUrl": "https://openai.com/de-DE/policies/eu-privacy-policy/"
}

View File

@@ -1,11 +1,12 @@
export default {
"home": "Начало",
"new": "Нови артикули",
"konfiguratorAria": "Отидете на Конфигуратора",
"new": "Нови попълнения",
"soon": "Очаквайте скоро",
"aktionen": "Промоции",
"filiale": "Магазин",
"categories": "Категории",
"categoriesOpen": "Отвори категориите",
"categoriesClose": "Затвори категориите",
"categoriesOpen": "Отвори категории",
"categoriesClose": "Затвори категории",
"otherCategories": "Други категории"
};

View File

@@ -2,11 +2,11 @@ export default {
"seeds": "Семена",
"stecklinge": "Резници",
"konfigurator": "Конфигуратор",
"oilPress": "Вземете назаем преса за масло",
"oilPress": "Вземете на заем маслоизстисквачка",
"thcTest": "THC тест",
"address1": "Trachenberger Street 14",
"address2": "01129 Dresden",
"showUsPhoto": "Покажете ни най-красивата си снимка",
"selectSeedRate": "Изберете семе, кликнете за оценка",
"outdoorSeason": "Започва сезонът на открито"
"buildYourSet": "Сглобете своя комплект",
"selectSeedRate": "Изберете семе, кликнете върху оценка",
"outdoorSeason": "Открива се сезонът на открито"
};

View File

@@ -1,3 +1,18 @@
export default {
"privacyRead": "Přečteno a přijato",
"privacyPromptBefore": "Prosím potvrďte, že jste si přečetli ",
"privacyPolicyLink": "zásady ochrany osobních údajů",
"privacyPromptAfter": " a souhlasíte s nimi. ",
"telegramAssistantIntro": "Asistenta Growheads můžete také kontaktovat na Telegramu:",
"telegramAssistantLink": "t.me/Growheads_de_Bot",
"assistantTitle": "Asistent",
"placeholderRecording": "Nahrávání…",
"inputPlaceholder": "Můžete se mě zeptat na odrůdy konopí…",
"send": "Odeslat",
"closeAria": "Zavřít asistenta",
"micStartAria": "Spustit nahrávání hlasu",
"micStopAria": "Zastavit nahrávání",
"uploadImageAria": "Nahrát obrázek",
"micPermissionDenied": "Nepodařilo se získat přístup k mikrofonu. Zkontrolujte prosím oprávnění ve svém prohlížeči.",
"uploadedImageAlt": "Nahraný obrázek"
};

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "na stránku",
"availability": "Dostupnost",
"manufacturer": "Výrobce",
"all": "Vše"
"all": "Vše",
"notifyNewArticles": "Upozornit na nové produkty",
"notifyNewArticlesBrowserUnsupported": "Váš prohlížeč nepodporuje push oznámení."
};

View File

@@ -2,15 +2,15 @@ export default {
"sections": {
"chatbot": {
"title": "AI Chatbot (OpenAI API)",
"intro": "Na našich webových stránkách používáme chatbot podporovaný umělou inteligencí, poskytovaný prostřednictvím OpenAI API, k automatickému vyřizování dotazů a zlepšování naší podpory.",
"processing": "Při používání chatu jsou vámi zadané obsahy předávány společnosti OpenAI a tam jsou jejím prostřednictvím zpracovávány, aby bylo možné vygenerovat odpovídající odpověď. Upozorňujeme, že zadané obsahy mohou obsahovat i osobní údaje, pokud takové údaje sami uvedete.",
"legalBasis": "Právním základem pro zpracování je čl. 6 odst. 1 písm. f DSGVO. Náš oprávněný zájem spočívá v efektivním vyřizování dotazů a zlepšování naší online nabídky.",
"intro": "Na našich webových stránkách používáme AI podporovaného chatbota, který je poskytován prostřednictvím OpenAI API, aby automaticky odpovídal na dotazy a zlepšoval naši podporu.",
"processing": "Při používání chatbota jsou Vámi zadané obsahy přenášeny do OpenAI a tam jsou naším jménem zpracovávány za účelem vygenerování vhodné odpovědi. Upozorňujeme, že zadaný obsah může rovněž obsahovat osobní údaje, pokud takové údaje poskytnete sami.",
"legalBasis": "Právním základem pro zpracování je čl. 6 odst. 1 písm. f DSGVO. Náš oprávněný zájem spočívá v efektivním vyřizování dotazů a ve zlepšování naší online nabídky.",
"dataRecipient": "Příjemcem údajů je OpenAI. Pro uživatele v Evropském hospodářském prostoru a ve Švýcarsku je relevantním smluvním partnerem OpenAI Ireland Limited, 1st Floor, The Liffey Trust Centre, 117-126 Sheriff Street Upper, Dublin 1, D01 YC43, Ireland.",
"thirdCountryTransfer": "Předání údajů do třetích zemí (zejména do USA) nelze vyloučit. K tomu dochází na základě vhodných záruk podle čl. 44 a násl. DSGVO, zejména prostřednictvím použití standardních smluvních doložek.",
"modelTraining": "Podle údajů společnosti OpenAI nejsou data z používání API standardně používána k trénování modelů.",
"dataRetention": "Obsah chatu uchováváme pouze po dobu nezbytně nutnou k vyřízení vašeho dotazu a poté jej mažeme nebo anonymizujeme, pokud neexistují zákonné povinnosti uchovávání.",
"voluntaryUse": "Používání chatu je dobrovolné. Do chatu prosím nezadávejte žádné citlivé osobní údaje.",
"privacyLinkIntro": "Další informace o zpracování údajů společností OpenAI naleznete zde:",
"thirdCountryTransfer": "Přenos údajů do třetích zemí (zejména do USA) nelze vyloučit. K mu dochází na základě vhodných záruk podle čl. 44 a násl. DSGVO, zejména prostřednictvím použití standardních smluvních doložek.",
"modelTraining": "Podle údajů společnosti OpenAI nejsou údaje z používání API standardně používány k trénování modelů.",
"dataRetention": "Obsah chatu uchováváme pouze po dobu nezbytnou pro vyřízení Vašeho dotazu a poté jej mažeme nebo anonymizujeme, pokud neexistují zákonné povinnosti uchovávání.",
"voluntaryUse": "Používání chatbota je dobrovolné. Prosím, nezadávejte do chatu žádné citlivé osobní údaje.",
"privacyLinkIntro": "Další informace o zpracování údajů společností OpenAI naleznete na:",
"privacyLinkUrl": "https://openai.com/de-DE/policies/eu-privacy-policy/"
}
}

View File

@@ -1,5 +1,6 @@
export default {
"home": "Domů",
"konfiguratorAria": "Přejít do konfigurátoru",
"new": "Novinky",
"soon": "Již brzy",
"aktionen": "Akce",
@@ -7,5 +8,5 @@ export default {
"categories": "Kategorie",
"categoriesOpen": "Otevřít kategorie",
"categoriesClose": "Zavřít kategorie",
"otherCategories": "Další kategorie"
"otherCategories": "Ostatní kategorie"
};

View File

@@ -6,7 +6,7 @@ export default {
"thcTest": "THC test",
"address1": "Trachenberger Street 14",
"address2": "01129 Dresden",
"showUsPhoto": "Ukažte nám svou nejkrásnější fotografii",
"buildYourSet": "Sestavte si svou sadu",
"selectSeedRate": "Vyberte semeno, klikněte na hodnocení",
"outdoorSeason": "Začíná venkovní sezóna"
};

View File

@@ -1,3 +1,18 @@
export default {
"privacyRead": "Gelesen & Akzeptiert"
"privacyRead": "Gelesen & Akzeptiert",
"privacyPromptBefore": "Bitte bestätigen Sie, dass Sie die ",
"privacyPolicyLink": "Datenschutzbestimmungen",
"privacyPromptAfter": " gelesen haben und damit einverstanden sind. ",
"telegramAssistantIntro": "Du kannst den Growheads Assistenten auch per Telegram erreichen:",
"telegramAssistantLink": "t.me/Growheads_de_Bot",
"assistantTitle": "Assistent",
"placeholderRecording": "Aufnahme läuft...",
"inputPlaceholder": "Du kannst mich nach Cannabissorten fragen...",
"send": "Senden",
"closeAria": "Assistent schließen",
"micStartAria": "Sprachaufnahme starten",
"micStopAria": "Aufnahme stoppen",
"uploadImageAria": "Bild hochladen",
"micPermissionDenied": "Mikrofon-Zugriff nicht möglich. Bitte prüfen Sie die Browser-Berechtigungen.",
"uploadedImageAlt": "Hochgeladenes Bild"
};

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "pro Seite",
"availability": "Verfügbarkeit",
"manufacturer": "Hersteller",
"all": "Alle"
"all": "Alle",
"notifyNewArticles": "Bei neuen Artikeln benachrichtigen",
"notifyNewArticlesBrowserUnsupported": "Ihr Browser unterstützt keine Push-Benachrichtigungen."
};

View File

@@ -1,5 +1,6 @@
export default {
"home": "Startseite",
"konfiguratorAria": "Zum Konfigurator",
"new": "Neuheiten",
"soon": "Demnächst",
"aktionen": "Aktionen",

View File

@@ -6,7 +6,7 @@ export default {
"thcTest": "THC Test",
"address1": "Trachenberger Straße 14",
"address2": "01129 Dresden",
"showUsPhoto": "Zeig uns dein schönstes Foto",
"buildYourSet": "Stelle dein Set zusammen",
"selectSeedRate": "Wähle Seed aus, klicke Bewerten",
"outdoorSeason": "Die Outdoorsaison beginnt"
};

View File

@@ -1,3 +1,18 @@
export default {
"privacyRead": "Διαβάστηκε & έγινε αποδεκτό",
"privacyRead": "Διαβάστηκε & Εγκρίθηκε",
"privacyPromptBefore": "Παρακαλώ επιβεβαιώστε ότι έχετε διαβάσει την ",
"privacyPolicyLink": "πολιτική απορρήτου",
"privacyPromptAfter": " και ότι συμφωνείτε με αυτήν. ",
"telegramAssistantIntro": "Μπορείτε επίσης να επικοινωνήσετε με τον βοηθό του Growheads στο Telegram:",
"telegramAssistantLink": "t.me/Growheads_de_Bot",
"assistantTitle": "Βοηθός",
"placeholderRecording": "Γίνεται εγγραφή…",
"inputPlaceholder": "Μπορείτε να με ρωτήσετε για ποικιλίες cannabis…",
"send": "Αποστολή",
"closeAria": "Κλείσιμο βοηθού",
"micStartAria": "Έναρξη ηχογράφησης φωνής",
"micStopAria": "Διακοπή εγγραφής",
"uploadImageAria": "Μεταφόρτωση εικόνας",
"micPermissionDenied": "Δεν ήταν δυνατή η πρόσβαση στο μικρόφωνο. Παρακαλώ ελέγξτε τα δικαιώματα του browser σας.",
"uploadedImageAlt": "Ανεβασμένη εικόνα"
};

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "ανά σελίδα",
"availability": "Διαθεσιμότητα",
"manufacturer": "Κατασκευαστής",
"all": "Όλα"
"all": "Όλα",
"notifyNewArticles": "Ειδοποίησέ με για νέα προϊόντα",
"notifyNewArticlesBrowserUnsupported": "Ο φυλλομετρητής σας δεν υποστηρίζει push ειδοποιήσεις."
};

View File

@@ -1,16 +1,16 @@
export default {
"sections": {
"chatbot": {
"title": "Chatbot Τεχνητής Νοημοσύνης (OpenAI API)",
"intro": "Χρησιμοποιούμε στον ιστότοπό μας έναν chatbot υποστηριζόμενο από Τεχνητή Νοημοσύνη, ο οποίος παρέχεται μέσω του OpenAI API, προκειμένου να απαντά αυτοματοποιημένα σε αιτήματα και να βελτιώνει την υποστήριξή μας.",
"processing": "Κατά τη χρήση του chatbot, το περιεχόμενο που εισάγετε διαβιβάζεται στην OpenAI και υποβάλλεται εκεί σε επεξεργασία για λογαριασμό μας, προκειμένου να παραχθεί μια κατάλληλη απάντηση. Παρακαλούμε σημειώστε ότι το περιεχόμενο που εισάγεται μπορεί επίσης να περιλαμβάνει προσωπικά δεδομένα, εφόσον παρέχετε τέτοιες πληροφορίες οι ίδιοι.",
"legalBasis": "Η νομική βάση για την επεξεργασία είναι το άρθρο 6 παρ. 1 περ. f DSGVO. Το έννομο συμφέρον μας έγκειται στην αποτελεσματική διεκπεραίωση των αιτημάτων και στη βελτίωση της διαδικτυακής μας προσφοράς.",
"title": "AI Chatbot (OpenAI API)",
"intro": "Χρησιμοποιούμε στην ιστοσελίδα μας ένα chatbot υποστηριζόμενο από τεχνητή νοημοσύνη, το οποίο παρέχεται μέσω του OpenAI API, για την αυτοματοποιημένη απάντηση σε αιτήματα και τη βελτίωση της υποστήριξής μας.",
"processing": "Κατά τη χρήση του chatbot, το περιεχόμενο που εισάγετε διαβιβάζεται στην OpenAI και επεξεργάζεται εκεί για λογαριασμό μας, προκειμένου να δημιουργηθεί μια κατάλληλη απάντηση. Σημειώστε ότι το περιεχόμενο που εισάγεται μπορεί επίσης να περιέχει προσωπικά δεδομένα, εφόσον παρέχετε εσείς οι ίδιοι τέτοιες πληροφορίες.",
"legalBasis": "Η νομική βάση για την επεξεργασία είναι το άρθ. 6 παρ. 1 στοιχ. f DSGVO. Το έννομο συμφέρον μας έγκειται στην αποτελεσματική διεκπεραίωση των αιτημάτων και στη βελτίωση της διαδικτυακής μας προσφοράς.",
"dataRecipient": "Αποδέκτης των δεδομένων είναι η OpenAI. Για χρήστες στον Ευρωπαϊκό Οικονομικό Χώρο και στην Ελβετία, η OpenAI Ireland Limited, 1st Floor, The Liffey Trust Centre, 117-126 Sheriff Street Upper, Dublin 1, D01 YC43, Ireland, αποτελεί τον σχετικό συμβατικό εταίρο.",
"thirdCountryTransfer": "Δεν μπορεί να αποκλειστεί διαβίβαση δεδομένων σε τρίτες χώρες (ιδίως στις ΗΠΑ). Αυτή πραγματοποιείται βάσει κατάλληλων εγγυήσεων σύμφωνα με το άρθρο 44 επ. DSGVO, ιδίως μέσω της χρήσης τυποποιημένων συμβατικών ρητρών.",
"modelTraining": "Σύμφωνα με την OpenAI, τα δεδομένα από τη χρήση του API δεν χρησιμοποιούνται από προεπιλογή για την εκπαίδευση των μοντέλων.",
"dataRetention": "Αποθηκεύουμε το περιεχόμενο των συνομιλιών μόνο για όσο διάστημα είναι απαραίτητο για την επεξεργασία του αιτήματός σας και στη συνέχεια το διαγράφουμε ή το ανωνυμοποιούμε, εκτός εάν υπάρχουν νόμιμες υποχρεώσεις διατήρησης.",
"thirdCountryTransfer": "Δεν μπορεί να αποκλειστεί η διαβίβαση δεδομένων σε τρίτες χώρες (ιδίως στις ΗΠΑ). Η διαβίβαση αυτή πραγματοποιείται βάσει κατάλληλων εγγυήσεων σύμφωνα με το άρθ. 44 επ. DSGVO, ιδίως μέσω της χρήσης τυποποιημένων συμβατικών ρητρών.",
"modelTraining": "Σύμφωνα με τις πληροφορίες της OpenAI, τα δεδομένα από τη χρήση του API δεν χρησιμοποιούνται από προεπιλογή για την εκπαίδευση των μοντέλων.",
"dataRetention": "Αποθηκεύουμε το περιεχόμενο του chat μόνο για όσο χρονικό διάστημα είναι αναγκαίο για την επεξεργασία του αιτήματός σας και στη συνέχεια το διαγράφουμε ή το ανωνυμοποιούμε, εκτός εάν υπάρχουν νόμιμες υποχρεώσεις διατήρησης.",
"voluntaryUse": "Η χρήση του chatbot είναι προαιρετική. Παρακαλούμε μην εισάγετε ευαίσθητα προσωπικά δεδομένα στο chat.",
"privacyLinkIntro": "Περισσότερες πληροφορίες σχετικά με την επεξεργασία δεδομένων από την OpenAI μπορείτε να βρείτε στη διεύθυνση:",
"privacyLinkIntro": "Περαιτέρω πληροφορίες σχετικά με την επεξεργασία δεδομένων από την OpenAI θα βρείτε στο:",
"privacyLinkUrl": "https://openai.com/de-DE/policies/eu-privacy-policy/"
}
}

View File

@@ -1,7 +1,8 @@
export default {
"home": "Αρχική",
"new": "Νέες Αφίξεις",
"soon": "Έρχεται Σύντομα",
"konfiguratorAria": "Μετάβαση στον Configurator",
"new": "Νέα Άφιξη",
"soon": "Σύντομα",
"aktionen": "Προσφορές",
"filiale": "Κατάστημα",
"categories": "Κατηγορίες",

View File

@@ -6,7 +6,7 @@ export default {
"thcTest": "Τεστ THC",
"address1": "Trachenberger Street 14",
"address2": "01129 Dresden",
"showUsPhoto": "Δείξε μας τη πιο όμορφη φωτογραφία σου",
"buildYourSet": "Φτιάξε το δικό σου σετ",
"selectSeedRate": "Επίλεξε σπόρο, κάνε κλικ στη βαθμολογία",
"outdoorSeason": "Ξεκινά η υπαίθρια σεζόν"
};

View File

@@ -1,3 +1,18 @@
export default {
"privacyRead": "Read & Accepted", // Gelesen & Akzeptiert
"privacyRead": "Read & Accepted",
"privacyPromptBefore": "Please confirm that you have read the ",
"privacyPolicyLink": "privacy policy",
"privacyPromptAfter": " and agree to it. ",
"telegramAssistantIntro": "You can also reach the Growheads assistant on Telegram:",
"telegramAssistantLink": "t.me/Growheads_de_Bot",
"assistantTitle": "Assistant",
"placeholderRecording": "Recording…",
"inputPlaceholder": "You can ask me about cannabis strains…",
"send": "Send",
"closeAria": "Close assistant",
"micStartAria": "Start voice recording",
"micStopAria": "Stop recording",
"uploadImageAria": "Upload image",
"micPermissionDenied": "Could not access the microphone. Please check your browser permissions.",
"uploadedImageAlt": "Uploaded image"
};

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "per page", // pro Seite
"availability": "Availability", // Verfügbarkeit
"manufacturer": "Manufacturer", // Hersteller
"all": "All" // Alle
"all": "All", // Alle
"notifyNewArticles": "Notify me about new articles",
"notifyNewArticlesBrowserUnsupported": "Your browser does not support push notifications."
};

View File

@@ -1,5 +1,6 @@
export default {
"home": "Home", // Startseite
"konfiguratorAria": "Go to Configurator", // Zum Konfigurator
"new": "New Arrivals", // Neuheiten
"soon": "Coming Soon", // Demnächst
"aktionen": "Promotions", // Aktionen

View File

@@ -6,7 +6,7 @@ export default {
"thcTest": "THC Test", // THC Test
"address1": "Trachenberger Street 14", // Trachenberger Straße 14
"address2": "01129 Dresden", // 01129 Dresden
"showUsPhoto": "Show us your most beautiful photo", // Zeig uns dein schönstes Foto
"buildYourSet": "Put your set together", // Stelle dein Set zusammen
"selectSeedRate": "Choose seed, click rate", // Wähle Seed aus, klicke Bewerten
"outdoorSeason": "The outdoor season begins" // Die Outdoorsaison beginnt
};

View File

@@ -1,3 +1,18 @@
export default {
"privacyRead": "Leído y aceptado",
"privacyPromptBefore": "Por favor, confirma que has leído la ",
"privacyPolicyLink": "política de privacidad",
"privacyPromptAfter": " y que estás de acuerdo con ella. ",
"telegramAssistantIntro": "También puedes contactar con el asistente de Growheads en Telegram:",
"telegramAssistantLink": "t.me/Growheads_de_Bot",
"assistantTitle": "Asistente",
"placeholderRecording": "Grabando…",
"inputPlaceholder": "Puedes preguntarme sobre cepas de cannabis…",
"send": "Enviar",
"closeAria": "Cerrar asistente",
"micStartAria": "Iniciar grabación de voz",
"micStopAria": "Detener grabación",
"uploadImageAria": "Subir imagen",
"micPermissionDenied": "No se pudo acceder al micrófono. Por favor, revisa los permisos de tu navegador.",
"uploadedImageAlt": "Imagen subida"
};

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "por página",
"availability": "Disponibilidad",
"manufacturer": "Fabricante",
"all": "Todos"
"all": "Todos",
"notifyNewArticles": "Notificarme sobre artículos nuevos",
"notifyNewArticlesBrowserUnsupported": "Su navegador no admite notificaciones push."
};

View File

@@ -2,13 +2,13 @@ export default {
"sections": {
"chatbot": {
"title": "Chatbot de IA (API de OpenAI)",
"intro": "Utilizamos en nuestro sitio web un chatbot basado en IA, proporcionado a través de la API de OpenAI, para responder automáticamente a las consultas y mejorar nuestro soporte.",
"processing": "Al utilizar el chatbot, el contenido que usted introduce se transmite a OpenAI y se procesa allí en nuestro nombre con el fin de generar una respuesta adecuada. Tenga en cuenta que el contenido introducido también puede contener datos personales si usted proporciona dicha información.",
"legalBasis": "La base jurídica del tratamiento es el art. 6, apdo. 1, letra f del DSGVO. Nuestro interés legítimo reside en la tramitación eficiente de las consultas y en la mejora de nuestra oferta en línea.",
"intro": "Utilizamos en nuestro sitio web un chatbot asistido por IA, proporcionado a través de la API de OpenAI, para responder automáticamente a las consultas y mejorar nuestro soporte.",
"processing": "Al utilizar el chatbot, el contenido que usted introduce se transmite a OpenAI y se procesa allí por nuestra cuenta con el fin de generar una respuesta adecuada. Tenga en cuenta que el contenido introducido también puede contener datos personales si usted proporciona dicha información.",
"legalBasis": "La base jurídica para el tratamiento es el art. 6 apartado 1 letra f DSGVO. Nuestro interés legítimo reside en la tramitación eficiente de consultas y en la mejora de nuestra oferta en línea.",
"dataRecipient": "El destinatario de los datos es OpenAI. Para los usuarios del Espacio Económico Europeo y de Suiza, OpenAI Ireland Limited, 1st Floor, The Liffey Trust Centre, 117-126 Sheriff Street Upper, Dublin 1, D01 YC43, Ireland, es la parte contractual pertinente.",
"thirdCountryTransfer": "No puede excluirse una transferencia de datos a terceros países (en particular, a EE. UU.). Esta se realiza sobre la base de garantías adecuadas conforme al art. 44 y ss. del DSGVO, en particular mediante la utilización de cláusulas contractuales tipo.",
"modelTraining": "Según OpenAI, los datos del uso de la API no se utilizan por defecto para entrenar los modelos.",
"dataRetention": "Solo almacenamos los contenidos del chat durante el tiempo necesario para tramitar su consulta y, posteriormente, los eliminamos o anonimizamos, salvo que existan obligaciones legales de conservación.",
"thirdCountryTransfer": "No puede descartarse una transferencia de datos a terceros países (en particular, EE. UU.). Esta se realiza sobre la base de garantías adecuadas conforme al art. 44 ff. DSGVO, en particular mediante la utilización de cláusulas contractuales tipo.",
"modelTraining": "Según OpenAI, los datos derivados del uso de la API no se utilizan por defecto para entrenar los modelos.",
"dataRetention": "Almacenamos los contenidos del chat solo durante el tiempo necesario para tramitar su consulta y, posteriormente, los eliminamos o anonimizamos, salvo que existan obligaciones legales de conservación.",
"voluntaryUse": "El uso del chatbot es voluntario. Por favor, no introduzca datos personales sensibles en el chat.",
"privacyLinkIntro": "Puede encontrar más información sobre el tratamiento de datos por parte de OpenAI en:",
"privacyLinkUrl": "https://openai.com/de-DE/policies/eu-privacy-policy/"

View File

@@ -1,5 +1,6 @@
export default {
"home": "Inicio",
"konfiguratorAria": "Ir al Configurator",
"new": "Novedades",
"soon": "Próximamente",
"aktionen": "Promociones",

View File

@@ -6,7 +6,7 @@ export default {
"thcTest": "Prueba de THC",
"address1": "Trachenberger Street 14",
"address2": "01129 Dresden",
"showUsPhoto": "Muéstranos tu foto más bonita",
"buildYourSet": "Monta tu equipo",
"selectSeedRate": "Elige la semilla, haz clic en valorar",
"outdoorSeason": "Comienza la temporada al aire libre"
};

View File

@@ -1,3 +1,18 @@
export default {
"privacyRead": "Lu et accepté",
"privacyPromptBefore": "Veuillez confirmer que vous avez lu la ",
"privacyPolicyLink": "politique de confidentialité",
"privacyPromptAfter": " et que vous l'acceptez. ",
"telegramAssistantIntro": "Vous pouvez également contacter l'assistant Growheads sur Telegram :",
"telegramAssistantLink": "t.me/Growheads_de_Bot",
"assistantTitle": "Assistant",
"placeholderRecording": "Enregistrement…",
"inputPlaceholder": "Vous pouvez me demander des informations sur les variétés de cannabis…",
"send": "Envoyer",
"closeAria": "Fermer l'assistant",
"micStartAria": "Démarrer l'enregistrement vocal",
"micStopAria": "Arrêter l'enregistrement",
"uploadImageAria": "Télécharger une image",
"micPermissionDenied": "Impossible d'accéder au microphone. Veuillez vérifier les autorisations de votre navigateur.",
"uploadedImageAlt": "Image téléchargée"
};

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "par page",
"availability": "Disponibilité",
"manufacturer": "Fabricant",
"all": "Tous"
"all": "Tous",
"notifyNewArticles": "Être notifié des nouveaux articles",
"notifyNewArticlesBrowserUnsupported": "Votre navigateur ne prend pas en charge les notifications push."
};

View File

@@ -2,13 +2,13 @@ export default {
"sections": {
"chatbot": {
"title": "Chatbot IA (API OpenAI)",
"intro": "Nous utilisons sur notre site web un chatbot basé sur lIA, fourni via lAPI OpenAI, afin de répondre automatiquement aux demandes et daméliorer notre support.",
"intro": "Nous utilisons sur notre site web un chatbot assisté par IA, mis à disposition via lAPI dOpenAI, afin de répondre automatiquement aux demandes et daméliorer notre support.",
"processing": "Lors de lutilisation du chatbot, les contenus que vous saisissez sont transmis à OpenAI et y sont traités en notre nom afin de générer une réponse appropriée. Veuillez noter que les contenus saisis peuvent également contenir des données à caractère personnel si vous fournissez vous-même de telles informations.",
"legalBasis": "La base juridique du traitement est lart. 6, par. 1, lit. f DSGVO. Notre intérêt légitime réside dans le traitement efficace des demandes ainsi que dans lamélioration de notre offre en ligne.",
"dataRecipient": "Le destinataire des données est OpenAI. Pour les utilisateurs dans lEspace économique européen et en Suisse, OpenAI Ireland Limited, 1st Floor, The Liffey Trust Centre, 117-126 Sheriff Street Upper, Dublin 1, D01 YC43, Irlande, est le partenaire contractuel concerné.",
"thirdCountryTransfer": "Un transfert de données vers des pays tiers (en particulier les États-Unis) ne peut pas être exclu. Celui-ci a lieu sur la base de garanties appropriées conformément à lart. 44 et suiv. DSGVO, en particulier par lutilisation de clauses contractuelles types.",
"legalBasis": "La base juridique du traitement est lart. 6, al. 1, let. f DSGVO. Notre intérêt légitime réside dans le traitement efficace des demandes ainsi que dans lamélioration de notre offre en ligne.",
"dataRecipient": "Le destinataire des données est OpenAI. Pour les utilisateurs de lEspace économique européen et de la Suisse, OpenAI Ireland Limited, 1st Floor, The Liffey Trust Centre, 117-126 Sheriff Street Upper, Dublin 1, D01 YC43, Irlande, est le partenaire contractuel déterminant.",
"thirdCountryTransfer": "Un transfert de données vers des pays tiers (en particulier les États-Unis) ne peut pas être exclu. Celui-ci a lieu sur la base de garanties appropriées conformément à lart. 44 et s. DSGVO, notamment par lutilisation de clauses contractuelles types.",
"modelTraining": "Selon les informations dOpenAI, les données issues de lutilisation de lAPI ne sont pas utilisées par défaut pour lentraînement des modèles.",
"dataRetention": "Nous conservons les contenus du chat uniquement aussi longtemps que nécessaire pour traiter votre demande, puis nous les supprimons ou les anonymisons, sauf en présence dobligations légales de conservation.",
"dataRetention": "Nous conservons les contenus du chat uniquement aussi longtemps que nécessaire pour traiter votre demande, puis les supprimons ou les anonymisons, sauf si des obligations légales de conservation sappliquent.",
"voluntaryUse": "Lutilisation du chatbot est facultative. Veuillez ne saisir aucune donnée personnelle sensible dans le chat.",
"privacyLinkIntro": "Vous trouverez de plus amples informations sur le traitement des données par OpenAI à ladresse suivante :",
"privacyLinkUrl": "https://openai.com/de-DE/policies/eu-privacy-policy/"

View File

@@ -1,5 +1,6 @@
export default {
"home": "Accueil",
"konfiguratorAria": "Aller au Configurateur",
"new": "Nouveautés",
"soon": "Bientôt disponible",
"aktionen": "Promotions",

View File

@@ -6,7 +6,7 @@ export default {
"thcTest": "Test THC",
"address1": "Trachenberger Street 14",
"address2": "01129 Dresde",
"showUsPhoto": "Montrez-nous votre plus belle photo",
"buildYourSet": "Composez votre ensemble",
"selectSeedRate": "Choisissez une graine, cliquez sur évaluer",
"outdoorSeason": "La saison en extérieur commence"
};

View File

@@ -1,3 +1,18 @@
export default {
"privacyRead": "Pročitano i prihvaćeno",
"privacyPromptBefore": "Molimo potvrdite da ste pročitali ",
"privacyPolicyLink": "pravila privatnosti",
"privacyPromptAfter": " i da ih prihvaćate. ",
"telegramAssistantIntro": "Također možete kontaktirati Growheads asistenta na Telegramu:",
"telegramAssistantLink": "t.me/Growheads_de_Bot",
"assistantTitle": "Asistent",
"placeholderRecording": "Snimanje…",
"inputPlaceholder": "Možete me pitati o sortama kanabisa…",
"send": "Pošalji",
"closeAria": "Zatvori asistenta",
"micStartAria": "Pokreni glasovno snimanje",
"micStopAria": "Zaustavi snimanje",
"uploadImageAria": "Prenesi sliku",
"micPermissionDenied": "Nije moguće pristupiti mikrofonu. Molimo provjerite dopuštenja u pregledniku.",
"uploadedImageAlt": "Prenesena slika"
};

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "po stranici",
"availability": "Dostupnost",
"manufacturer": "Proizvođač",
"all": "Sve"
"all": "Sve",
"notifyNewArticles": "Obavijesti me o novim artiklima",
"notifyNewArticlesBrowserUnsupported": "Vaš preglednik ne podržava push obavijesti."
};

View File

@@ -2,13 +2,13 @@ export default {
"sections": {
"chatbot": {
"title": "AI Chatbot (OpenAI API)",
"intro": "Na našoj web stranici koristimo AI-om podržani chatbot, koji se pruža putem OpenAI API-ja, kako bismo automatski odgovarali na upite i poboljšali našu podršku.",
"processing": "Prilikom korištenja chatbota, sadržaj koji unesete prenosi se OpenAI-ju i tamo obrađuje u naše ime kako bi se generirao odgovarajući odgovor. Imajte na umu da uneseni sadržaj može sadržavati i osobne podatke ako takve informacije sami navedete.",
"legalBasis": "Pravna osnova za obradu je čl. 6 st. 1 toč. f DSGVO. Naš legitimni interes leži u učinkovitom obradi upita te poboljšanju naše online ponude.",
"dataRecipient": "Primatelj podataka je OpenAI. Za korisnike u Europskom gospodarskom prostoru i u Švicarskoj, OpenAI Ireland Limited, 1st Floor, The Liffey Trust Centre, 117-126 Sheriff Street Upper, Dublin 1, D01 YC43, Ireland, predstavlja relevantnog ugovornog partnera.",
"thirdCountryTransfer": "Prijenos podataka u treće zemlje (osobito SAD) ne može se isključiti. To se provodi na temelju odgovarajućih jamstava u skladu s čl. 44 i dalje DSGVO, osobito putem primjene standardnih ugovornih klauzula.",
"modelTraining": "Prema navodima OpenAI-ja, podaci iz korištenja API-ja prema zadanim postavkama ne koriste se za treniranje modela.",
"dataRetention": "Sadržaj chata pohranjujemo samo onoliko dugo koliko je potrebno za obradu vašeg upita te ga potom brišemo ili anonimiziramo, osim ako postoje zakonske obveze čuvanja.",
"intro": "Na našoj web-stranici koristimo chatbot podržan umjetnom inteligencijom, koji se pruža putem OpenAI API-ja, kako bismo automatski odgovarali na upite i poboljšali našu podršku.",
"processing": "Pri korištenju chatbota sadržaj koji unesete prenosi se OpenAI-ju i ondje se obrađuje u naše ime kako bi se generirao odgovarajući odgovor. Imajte na umu da uneseni sadržaj također može sadržavati osobne podatke ako takve informacije sami navedete.",
"legalBasis": "Pravna osnova za obradu je čl. 6 st. 1 toč. f DSGVO. Naš legitimni interes leži u učinkovitom obradi upita te poboljšanju naše internetske ponude.",
"dataRecipient": "Primatelj podataka je OpenAI. Za korisnike u Europskom gospodarskom prostoru i Švicarskoj relevantni ugovorni partner je OpenAI Ireland Limited, 1st Floor, The Liffey Trust Centre, 117-126 Sheriff Street Upper, Dublin 1, D01 YC43, Ireland.",
"thirdCountryTransfer": "Prijenos podataka u treće zemlje (osobito u SAD) ne može se isključiti. To se odvija na temelju odgovarajućih jamstava u skladu s čl. 44 i sl. DSGVO, osobito putem primjene standardnih ugovornih klauzula.",
"modelTraining": "Prema navodima OpenAI-ja, podaci iz korištenja API-ja standardno se ne koriste za treniranje modela.",
"dataRetention": "Sadržaj razgovora pohranjujemo samo onoliko dugo koliko je potrebno za obradu vašeg upita, a zatim ga brišemo ili anonimiziramo, osim ako postoje zakonske obveze čuvanja.",
"voluntaryUse": "Korištenje chatbota je dobrovoljno. Molimo vas da u chat ne unosite nikakve osjetljive osobne podatke.",
"privacyLinkIntro": "Dodatne informacije o obradi podataka od strane OpenAI-ja možete pronaći na:",
"privacyLinkUrl": "https://openai.com/de-DE/policies/eu-privacy-policy/"

View File

@@ -1,7 +1,8 @@
export default {
"home": "Početna",
"konfiguratorAria": "Idi na Konfigurator",
"new": "Novi proizvodi",
"soon": "Uskoro",
"soon": "Uskoro dostupno",
"aktionen": "Promocije",
"filiale": "Trgovina",
"categories": "Kategorije",

View File

@@ -6,7 +6,7 @@ export default {
"thcTest": "THC test",
"address1": "Trachenberger Street 14",
"address2": "01129 Dresden",
"showUsPhoto": "Pokažite nam svoju najljepšu fotografiju",
"buildYourSet": "Sastavite svoj set",
"selectSeedRate": "Odaberite sjeme, kliknite ocjenu",
"outdoorSeason": "Počinje sezona za vanjsku uzgoj"
};

View File

@@ -1,3 +1,18 @@
export default {
"privacyRead": "Elolvasva és elfogadva",
"privacyRead": "Elolvastam és elfogadtam",
"privacyPromptBefore": "Kérjük, erősítse meg, hogy elolvasta a ",
"privacyPolicyLink": "adatvédelmi szabályzatot",
"privacyPromptAfter": " és elfogadja azt. ",
"telegramAssistantIntro": "A Growheads asszisztenst Telegramon is elérheti:",
"telegramAssistantLink": "t.me/Growheads_de_Bot",
"assistantTitle": "Asszisztens",
"placeholderRecording": "Rögzítés…",
"inputPlaceholder": "Kérdezhet tőlem kannabiszfajtákról…",
"send": "Küldés",
"closeAria": "Asszisztens bezárása",
"micStartAria": "Hangrögzítés indítása",
"micStopAria": "Rögzítés leállítása",
"uploadImageAria": "Kép feltöltése",
"micPermissionDenied": "Nem sikerült hozzáférni a mikrofonhoz. Kérjük, ellenőrizze a böngésző engedélyeit.",
"uploadedImageAlt": "Feltöltött kép"
};

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "oldalanként",
"availability": "Elérhetőség",
"manufacturer": "Gyártó",
"all": "Összes"
"all": "Összes",
"notifyNewArticles": "Értesítés új termékekről",
"notifyNewArticlesBrowserUnsupported": "A böngésző nem támogatja a push értesítéseket."
};

View File

@@ -1,16 +1,16 @@
export default {
"sections": {
"chatbot": {
"title": "AI Chatbot (OpenAI API)",
"intro": "Weboldalunkon egy mesterséges intelligenciával támogatott chatbotot használunk, amelyet az OpenAI API-n keresztül biztosítanak, hogy a megkereséseket automatikusan megválaszoljuk és ügyfélszolgálatunkat fejlesszük.",
"processing": "A chatbot használata során az Ön által megadott tartalmak továbbításra kerülnek az OpenAI részére, és ott a nevünkben feldolgozásra kerülnek egy megfelelő válasz létrehozása érdekében. Kérjük, vegye figyelembe, hogy a megadott tartalmak személyes adatokat is tartalmazhatnak, amennyiben ilyen adatokat saját maga ad meg.",
"legalBasis": "A feldolgozás jogalapja az Art. 6 para. 1 lit. f DSGVO. Jogos érdekünk a megkeresések hatékony kezelése, valamint online ajánlatunk fejlesztése.",
"dataRecipient": "Az adatok címzettje az OpenAI. Az Európai Gazdasági Térségben és Svájcban tartózkodó felhasználók esetében az OpenAI Ireland Limited, 1st Floor, The Liffey Trust Centre, 117-126 Sheriff Street Upper, Dublin 1, D01 YC43, Ireland az irányadó szerződő fél.",
"thirdCountryTransfer": "Az adatok harmadik országokba történő továbbítása (különösen az USA-ba) nem zárható ki. Ez a Art. 44 ff. DSGVO szerinti megfelelő garanciák alapján történik, különösen standard szerződési kikötések alkalmazásával.",
"modelTraining": "Az OpenAI tájékoztatása szerint az API-használatból származó adatokat alapértelmezés szerint nem használják a modellek képzésére.",
"dataRetention": "A chat tartalmát csak addig tároljuk, ameddig az Ön megkeresésének feldolgozásához szükséges, majd ezt követően töröljük vagy anonimizáljuk, kivéve, ha jogszabályon alapuló megőrzési kötelezettségek állnak fenn.",
"voluntaryUse": "A chatbot használata önkéntes. Kérjük, ne adjon meg érzékeny személyes adatokat a chatben.",
"privacyLinkIntro": "Az OpenAI által végzett adatkezeléssel kapcsolatos további információk itt találhatók:",
"title": "MI-csevegőrobot (OpenAI API)",
"intro": "Weboldalunkon egy MI-támogatott csevegőrobotot használunk, amelyet az OpenAI API-n keresztül biztosítanak, hogy az érdeklődéseket automatikusan megválaszoljuk és támogatásunkat javítsuk.",
"processing": "A csevegőrobot használata során az Ön által megadott tartalmakat továbbítjuk az OpenAI részére, és azok ott a nevünkben feldolgozásra kerülnek egy megfelelő válasz létrehozása érdekében. Kérjük, vegye figyelembe, hogy a megadott tartalom személyes adatokat is tartalmazhat, amennyiben Ön ilyen adatokat maga ad meg.",
"legalBasis": "Az adatkezelés jogalapja a DSGVO 6. cikk (1) bekezdés f) pontja. Jogos érdekünk az érdeklődések hatékony kezelése, valamint online kínálatunk fejlesztése.",
"dataRecipient": "Az adatok címzettje az OpenAI. Az Európai Gazdasági Térségben és Svájcban tartózkodó felhasználók esetében az OpenAI Ireland Limited, 1st Floor, The Liffey Trust Centre, 117-126 Sheriff Street Upper, Dublin 1, D01 YC43, Ireland a releváns szerződő fél.",
"thirdCountryTransfer": "Adatok harmadik országokba, különösen az USA-ba történő továbbítása nem zárható ki. Ez a DSGVO 44. és azt követő cikkei szerinti megfelelő garanciák alapján történik, különösen standard szerződési klauzulák alkalmazásával.",
"modelTraining": "Az OpenAI tájékoztatása szerint az API használatából származó adatokat alapértelmezés szerint nem használják a modellek betanítására.",
"dataRetention": "A csevegés tartalmát csak addig tároljuk, ameddig az Ön megkeresésének feldolgozásához szükséges, ezt követően töröljük vagy anonimizáljuk, kivéve, ha törvényi megőrzési kötelezettségek állnak fenn.",
"voluntaryUse": "A csevegőrobot használata önkéntes. Kérjük, ne adjon meg semmilyen érzékeny személyes adatot a csevegésben.",
"privacyLinkIntro": "Az OpenAI általi adatkezelésről további információk itt találhatók:",
"privacyLinkUrl": "https://openai.com/de-DE/policies/eu-privacy-policy/"
}
}

View File

@@ -1,9 +1,10 @@
export default {
"home": "Főoldal",
"home": "Kezdőlap",
"konfiguratorAria": "Ugrás a konfigurátorhoz",
"new": "Újdonságok",
"soon": "Hamarosan",
"aktionen": "Promóciók",
"filiale": "Üzlet",
"aktionen": "Akciók",
"filiale": "Bolt",
"categories": "Kategóriák",
"categoriesOpen": "Kategóriák megnyitása",
"categoriesClose": "Kategóriák bezárása",

View File

@@ -6,7 +6,7 @@ export default {
"thcTest": "THC teszt",
"address1": "Trachenberger utca 14",
"address2": "01129 Dresden",
"showUsPhoto": "Mutasd meg nekünk a legszebb fotódat",
"buildYourSet": "Állítsd össze a szettet",
"selectSeedRate": "Válassz magot, kattints az értékelésre",
"outdoorSeason": "Kezdődik a szabadtéri szezon"
};

View File

@@ -1,3 +1,18 @@
export default {
"privacyRead": "Letto e accettato",
"privacyPromptBefore": "Conferma di aver letto la ",
"privacyPolicyLink": "informativa sulla privacy",
"privacyPromptAfter": " e di accettarla. ",
"telegramAssistantIntro": "Puoi أيضًا contattare l'assistente di Growheads su Telegram:",
"telegramAssistantLink": "t.me/Growheads_de_Bot",
"assistantTitle": "Assistente",
"placeholderRecording": "Registrazione…",
"inputPlaceholder": "Puoi chiedermi informazioni sulle varietà di cannabis…",
"send": "Invia",
"closeAria": "Chiudi assistente",
"micStartAria": "Avvia registrazione vocale",
"micStopAria": "Interrompi registrazione",
"uploadImageAria": "Carica immagine",
"micPermissionDenied": "Impossibile accedere al microfono. Controlla i permessi del browser.",
"uploadedImageAlt": "Immagine caricata"
};

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "per pagina",
"availability": "Disponibilità",
"manufacturer": "Produttore",
"all": "Tutti"
"all": "Tutti",
"notifyNewArticles": "Avvisami sui nuovi articoli",
"notifyNewArticlesBrowserUnsupported": "Il tuo browser non supporta le notifiche push."
};

View File

@@ -1,16 +1,16 @@
export default {
"sections": {
"chatbot": {
"title": "Chatbot AI (API OpenAI)",
"intro": "Utilizziamo sul nostro sito web un chatbot supportato dallIA, fornito tramite lAPI di OpenAI, per rispondere automaticamente alle richieste e migliorare il nostro supporto.",
"processing": "Quando utilizzi il chatbot, i contenuti che inserisci vengono trasmessi a OpenAI e lì trattati per nostro conto al fine di generare una risposta adeguata. Ti preghiamo di notare che i contenuti inseriti possono contenere anche dati personali, qualora tu stesso fornisca tali informazioni.",
"legalBasis": "La base giuridica per il trattamento è lArt. 6 para. 1 lit. f DSGVO. Il nostro legittimo interesse risiede nella gestione efficiente delle richieste e nel miglioramento della nostra offerta online.",
"dataRecipient": "Il destinatario dei dati è OpenAI. Per gli utenti nello Spazio economico europeo e in Svizzera, OpenAI Ireland Limited, 1st Floor, The Liffey Trust Centre, 117-126 Sheriff Street Upper, Dublin 1, D01 YC43, Irlanda, è il partner contrattuale rilevante.",
"thirdCountryTransfer": "Un trasferimento di dati verso Paesi terzi (in particolare gli USA) non può essere escluso. Ciò avviene sulla base di garanzie adeguate ai sensi dellArt. 44 ff. DSGVO, in particolare mediante luso di clausole contrattuali standard.",
"modelTraining": "Secondo quanto indicato da OpenAI, i dati derivanti dallutilizzo dellAPI non vengono standardmente utilizzati per laddestramento dei modelli.",
"dataRetention": "Conserviamo i contenuti della chat solo per il tempo necessario alla gestione della tua richiesta e successivamente li cancelliamo o li rendiamo anonimi, salvo obblighi legali di conservazione.",
"voluntaryUse": "Luso del chatbot è volontario. Ti preghiamo di non inserire nel chat dati personali sensibili.",
"privacyLinkIntro": "Ulteriori informazioni sul trattamento dei dati da parte di OpenAI sono disponibili allindirizzo:",
"title": "Chatbot AI (API di OpenAI)",
"intro": "Utilizziamo sul nostro sito web un chatbot supportato da IA, fornito tramite l'API di OpenAI, per rispondere automaticamente alle richieste e migliorare il nostro supporto.",
"processing": "Durante l'uso del chatbot, i contenuti da voi inseriti vengono trasmessi a OpenAI e lì trattati per nostro conto al fine di generare una risposta adeguata. Si prega di notare che i contenuti inseriti possono contenere anche dati personali, qualora si forniscano tali informazioni di propria iniziativa.",
"legalBasis": "La base giuridica del trattamento è l'art. 6 par. 1 lett. f DSGVO. Il nostro legittimo interesse risiede nell'elaborazione efficiente delle richieste e nel miglioramento della nostra offerta online.",
"dataRecipient": "Il destinatario dei dati è OpenAI. Per gli utenti nello Spazio economico europeo e in Svizzera, OpenAI Ireland Limited, 1st Floor, The Liffey Trust Centre, 117-126 Sheriff Street Upper, Dublin 1, D01 YC43, Irlanda, è il relativo partner contrattuale.",
"thirdCountryTransfer": "Non si può escludere un trasferimento di dati verso paesi terzi (in particolare gli USA). Ciò avviene sulla base di garanzie adeguate ai sensi dell'art. 44 ss. DSGVO, in particolare mediante l'uso di clausole contrattuali standard.",
"modelTraining": "Secondo quanto indicato da OpenAI, i dati derivanti dall'utilizzo dell'API non vengono utilizzati di default per l'addestramento dei modelli.",
"dataRetention": "Conserviamo i contenuti della chat solo per il tempo necessario all'elaborazione della vostra richiesta e successivamente li cancelliamo o anonimizzamo, salvo che sussistano obblighi legali di conservazione.",
"voluntaryUse": "L'uso del chatbot è volontario. Si prega di non inserire nella chat dati personali sensibili.",
"privacyLinkIntro": "Ulteriori informazioni sul trattamento dei dati da parte di OpenAI sono disponibili al seguente indirizzo:",
"privacyLinkUrl": "https://openai.com/de-DE/policies/eu-privacy-policy/"
}
}

View File

@@ -1,11 +1,12 @@
export default {
"home": "Home",
"new": "Novità",
"konfiguratorAria": "Vai al Configuratore",
"new": "Nuovi Arrivi",
"soon": "Prossimamente",
"aktionen": "Promozioni",
"filiale": "Negozio",
"categories": "Categorie",
"categoriesOpen": "Apri categorie",
"categoriesClose": "Chiudi categorie",
"otherCategories": "Altre categorie"
"categoriesOpen": "Apri Categorie",
"categoriesClose": "Chiudi Categorie",
"otherCategories": "Altre Categorie"
};

View File

@@ -6,7 +6,7 @@ export default {
"thcTest": "Test THC",
"address1": "Trachenberger Street 14",
"address2": "01129 Dresda",
"showUsPhoto": "Mostraci la tua foto più bella",
"buildYourSet": "Componi il tuo set",
"selectSeedRate": "Scegli il seme, clicca valuta",
"outdoorSeason": "La stagione outdoor inizia"
};

View File

@@ -1,3 +1,18 @@
export default {
"privacyRead": "Przeczytano i zaakceptowano",
"privacyPromptBefore": "Proszę potwierdzić, że przeczytałeś(aś) ",
"privacyPolicyLink": "politykę prywatności",
"privacyPromptAfter": " i zgadzasz się na nią. ",
"telegramAssistantIntro": "Możesz również skontaktować się z asystentem Growheads na Telegramie:",
"telegramAssistantLink": "t.me/Growheads_de_Bot",
"assistantTitle": "Asystent",
"placeholderRecording": "Nagrywanie…",
"inputPlaceholder": "Możesz zapytać mnie o odmiany konopi…",
"send": "Wyślij",
"closeAria": "Zamknij asystenta",
"micStartAria": "Rozpocznij nagrywanie głosu",
"micStopAria": "Zatrzymaj nagrywanie",
"uploadImageAria": "Prześlij obraz",
"micPermissionDenied": "Nie udało się uzyskać dostępu do mikrofonu. Sprawdź uprawnienia w przeglądarce.",
"uploadedImageAlt": "Przesłany obraz"
};

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "na stronę",
"availability": "Dostępność",
"manufacturer": "Producent",
"all": "Wszystkie"
"all": "Wszystkie",
"notifyNewArticles": "Powiadamiaj o nowych produktach",
"notifyNewArticlesBrowserUnsupported": "Twoja przeglądarka nie obsługuje powiadomień push."
};

View File

@@ -1,15 +1,15 @@
export default {
"sections": {
"chatbot": {
"title": "Chatbot AI (API OpenAI)",
"intro": "Na naszej stronie internetowej korzystamy z chatbota wspieranego przez AI, udostępnianego za pośrednictwem API OpenAI, aby automatycznie odpowiadać na zapytania i usprawniać naszą obsługę.",
"processing": "Podczas korzystania z chatbota wprowadzone przez Państwa treści są przekazywane do OpenAI i tam przetwarzane w naszym imieniu w celu wygenerowania odpowiedzi. Proszę pamiętać, że wprowadzone treści mogą również zawierać dane osobowe, jeśli samodzielnie podadzą Państwo takie informacje.",
"legalBasis": "Podstawą prawną przetwarzania jest art. 6 ust. 1 lit. f DSGVO. Nasz uzasadniony interes polega na efektywnym rozpatrywaniu zapytań oraz ulepszaniu naszej oferty online.",
"title": "Chatbot AI (OpenAI API)",
"intro": "Na naszej stronie internetowej używamy chatbota wspieranego przez AI, udostępnianego za pośrednictwem API OpenAI, aby automatycznie odpowiadać na zapytania i usprawniać naszą obsługę.",
"processing": "Podczas korzystania z chatbota wprowadzone przez Państwa treści są przekazywane do OpenAI i tam przetwarzane w naszym imieniu w celu wygenerowania odpowiedniej odpowiedzi. Proszę pamiętać, że wprowadzone treści mogą również zawierać dane osobowe, jeśli sami Państwo takie informacje podadzą.",
"legalBasis": "Podstawą prawną przetwarzania jest art. 6 ust. 1 lit. f DSGVO. Nasz uzasadniony interes polega na sprawnym rozpatrywaniu zapytań oraz ulepszaniu naszej oferty online.",
"dataRecipient": "Odbiorcą danych jest OpenAI. W przypadku użytkowników z Europejskiego Obszaru Gospodarczego i Szwajcarii właściwym partnerem umownym jest OpenAI Ireland Limited, 1st Floor, The Liffey Trust Centre, 117-126 Sheriff Street Upper, Dublin 1, D01 YC43, Irlandia.",
"thirdCountryTransfer": "Nie można wykluczyć przekazania danych do państw trzecich (w szczególności do USA). Odbywa się ono na podstawie odpowiednich zabezpieczeń zgodnie z art. 44 ff. DSGVO, w szczególności poprzez stosowanie standardowych klauzul umownych.",
"modelTraining": "Zgodnie z informacjami OpenAI dane z korzystania z API nie są standardowo wykorzystywane do szkolenia modeli.",
"dataRetention": "Przechowujemy treści czatu tylko tak długo, jak jest to konieczne do rozpatrzenia Państwa zapytania, a następnie je usuwamy lub anonimizujemy, o ile nie istnieją ustawowe obowiązki przechowywania.",
"voluntaryUse": "Korzystanie z chatbota jest dobrowolne. Prosimy nie wpisywać do czatu żadnych wrażliwych danych osobowych.",
"thirdCountryTransfer": "Przekazanie danych do państw trzecich (w szczególności do USA) nie może zostać wykluczone. Odbywa się ono na podstawie odpowiednich zabezpieczeń zgodnie z art. 44 ff. DSGVO, w szczególności poprzez zastosowanie standardowych klauzul umownych.",
"modelTraining": "Zgodnie z informacjami OpenAI dane z korzystania z API nie są domyślnie wykorzystywane do trenowania modeli.",
"dataRetention": "Przechowujemy treść rozmów tylko tak długo, jak jest to konieczne do rozpatrzenia Państwa zapytania, a następnie je usuwamy lub anonimizujemy, o ile nie istnieją ustawowe obowiązki przechowywania.",
"voluntaryUse": "Korzystanie z chatbota jest dobrowolne. Prosimy nie wprowadzać do czatu żadnych wrażliwych danych osobowych.",
"privacyLinkIntro": "Dalsze informacje dotyczące przetwarzania danych przez OpenAI można znaleźć pod adresem:",
"privacyLinkUrl": "https://openai.com/de-DE/policies/eu-privacy-policy/"
}

View File

@@ -1,7 +1,8 @@
export default {
"home": "Strona główna",
"konfiguratorAria": "Przejdź do Konfiguratora",
"new": "Nowości",
"soon": "Wkrótce",
"soon": "Wkrótce dostępne",
"aktionen": "Promocje",
"filiale": "Sklep",
"categories": "Kategorie",

View File

@@ -6,7 +6,7 @@ export default {
"thcTest": "Test THC",
"address1": "Trachenberger Street 14",
"address2": "01129 Dresden",
"showUsPhoto": "Pokaż nam swoje najpiękniejsze zdjęcie",
"buildYourSet": "Złóż swój zestaw",
"selectSeedRate": "Wybierz nasiono, kliknij ocenę",
"outdoorSeason": "Sezon outdoorowy się zaczyna"
};

View File

@@ -1,3 +1,18 @@
export default {
"privacyRead": "Citit și acceptat",
"privacyPromptBefore": "Te rugăm să confirmi că ai citit ",
"privacyPolicyLink": "politica de confidențialitate",
"privacyPromptAfter": " și ești de acord cu ea. ",
"telegramAssistantIntro": "Poți contacta și asistentul Growheads pe Telegram:",
"telegramAssistantLink": "t.me/Growheads_de_Bot",
"assistantTitle": "Asistent",
"placeholderRecording": "Înregistrare…",
"inputPlaceholder": "Mă poți întreba despre soiuri de cannabis…",
"send": "Trimite",
"closeAria": "Închide asistentul",
"micStartAria": "Începe înregistrarea vocală",
"micStopAria": "Oprește înregistrarea",
"uploadImageAria": "Încarcă imaginea",
"micPermissionDenied": "Nu s-a putut accesa microfonul. Te rugăm să verifici permisiunile din browser.",
"uploadedImageAlt": "Imagine încărcată"
};

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "pe pagină",
"availability": "Disponibilitate",
"manufacturer": "Producător",
"all": "Toate"
"all": "Toate",
"notifyNewArticles": "Anunță-mă despre articole noi",
"notifyNewArticlesBrowserUnsupported": "Browserul nu acceptă notificări push."
};

View File

@@ -1,15 +1,15 @@
export default {
"sections": {
"chatbot": {
"title": "Chatbot AI (API OpenAI)",
"intro": "Utilizăm pe site-ul nostru un chatbot asistat de inteligență artificială, pus la dispoziție prin API-ul OpenAI, pentru a răspunde automat la solicitări și a ne îmbunătăți suportul.",
"processing": "La utilizarea chatbotului, conținutul introdus de dumneavoastră este transmis către OpenAI și prelucrat acolo în numele nostru, pentru a genera un răspuns adecvat. Vă rugăm să aveți în vedere că informațiile introduse pot conține, de asemenea, date cu caracter personal, dacă furnizați astfel de informații chiar dumneavoastră.",
"legalBasis": "Temeiul juridic pentru prelucrare este art. 6 alin. 1 lit. f DSGVO. Interesul nostru legitim constă în gestionarea eficientă a solicitărilor și în îmbunătățirea ofertei noastre online.",
"dataRecipient": "Destinatarul datelor este OpenAI. Pentru utilizatorii din Spațiul Economic European și din Elveția, OpenAI Ireland Limited, 1st Floor, The Liffey Trust Centre, 117-126 Sheriff Street Upper, Dublin 1, D01 YC43, Irlanda, este partenerul contractual relevant.",
"thirdCountryTransfer": "O transmitere a datelor către țări terțe (în special SUA) nu poate fi exclusă. Aceasta are loc pe baza unor garanții adecvate conform art. 44 ff. DSGVO, în special prin utilizarea clauzelor contractuale standard.",
"modelTraining": "Potrivit informațiilor furnizate de OpenAI, datele rezultate din utilizarea API-ului nu sunt utilizate în mod implicit pentru antrenarea modelelor.",
"dataRetention": "Stocăm conținutul conversațiilor doar atât timp cât este necesar pentru prelucrarea solicitării dumneavoastră și îl ștergem sau anonimizăm ulterior, cu excepția cazului în care există obligații legale de păstrare.",
"voluntaryUse": "Utilizarea chatbotului este voluntară. Vă rugăm să nu introduceți în chat date cu caracter personal sensibile.",
"title": "Chatbot IA (OpenAI API)",
"intro": "Utilizăm pe site-ul nostru un chatbot bazat pe inteligență artificială, furnizat prin OpenAI API, pentru a răspunde automat solicitărilor și a îmbunătăți suportul nostru.",
"processing": "La utilizarea chatbotului, conținutul introdus de dvs. este transmis către OpenAI și prelucrat acolo în numele nostru, pentru a genera un răspuns adecvat. Vă rugăm să rețineți că conținutul introdus poate conține și date cu caracter personal, dacă furnizați astfel de informații în mod voluntar.",
"legalBasis": "Temeiul juridic pentru prelucrare este art. 6 alin. 1 lit. f DSGVO. Interesul nostru legitim constă în procesarea eficientă a solicitărilor și în îmbunătățirea ofertei noastre online.",
"dataRecipient": "Destinatarul datelor este OpenAI. Pentru utilizatorii din Spațiul Economic European și din Elveția, OpenAI Ireland Limited, 1st Floor, The Liffey Trust Centre, 117-126 Sheriff Street Upper, Dublin 1, D01 YC43, Ireland, este partenerul contractual relevant.",
"thirdCountryTransfer": "Nu poate fi exclus un transfer de date către țări terțe (în special către SUA). Acesta are loc pe baza unor garanții adecvate în conformitate cu art. 44 și urm. DSGVO, în special prin utilizarea clauzelor contractuale standard.",
"modelTraining": "Potrivit informațiilor furnizate de OpenAI, datele rezultate din utilizarea API nu sunt utilizate în mod implicit pentru instruirea modelelor.",
"dataRetention": "Stocăm conținutul chatului doar atât timp cât este necesar pentru procesarea solicitării dvs., după care îl ștergem sau îl anonimizăm, cu excepția cazului în care există obligații legale de păstrare.",
"voluntaryUse": "Utilizarea chatbotului este voluntară. Vă rugăm să nu introduceți în chat date personale sensibile.",
"privacyLinkIntro": "Informații suplimentare privind prelucrarea datelor de către OpenAI pot fi găsite la:",
"privacyLinkUrl": "https://openai.com/de-DE/policies/eu-privacy-policy/"
}

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