Compare commits
36 Commits
91388244d8
...
live
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66a1efd87b | ||
|
|
2c0b7aa84d | ||
|
|
a56377a1fd | ||
|
|
468eb1c3ae | ||
|
|
e699a8003f | ||
|
|
b5256d6597 | ||
|
|
18c528302d | ||
|
|
9054c8d2fd | ||
|
|
8bce10e61b | ||
|
|
2540d00c8e | ||
|
|
52c9888a6a | ||
|
|
ab55761411 | ||
|
|
5e5a733d36 | ||
|
|
36360df648 | ||
|
|
21d86565f1 | ||
|
|
c503de3a11 | ||
|
|
2ced182570 | ||
|
|
52c62541b0 | ||
|
|
7202c43dfa | ||
|
|
5b7f0f788c | ||
|
|
47ed2ec231 | ||
|
|
188c883450 | ||
|
|
ba66b82b2b | ||
|
|
defe3c9521 | ||
|
|
de8e59f1bb | ||
|
|
4b634414e5 | ||
|
|
e8517372f2 | ||
|
|
c6ea6e70fe | ||
|
|
d37eb950d1 | ||
|
|
665e48e868 | ||
|
|
e0c6d47d98 | ||
|
|
bfeb5be1d5 | ||
|
|
1897ceb7c5 | ||
|
|
c5dce64ac9 | ||
|
|
9e77deb4f8 | ||
|
|
5515a59fa1 |
1
docs/README.md
Normal file
1
docs/README.md
Normal file
@@ -0,0 +1 @@
|
||||
src/components/MainPageLayout.js is the Main gues homepage.
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
10
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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"...');
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,43 @@
|
||||
/** Safe for double-quoted HTML attributes */
|
||||
const escAttr = (str) =>
|
||||
String(str ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<");
|
||||
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
};
|
||||
116
prerender/seo/hersteller.cjs
Normal file
116
prerender/seo/hersteller.cjs
Normal file
@@ -0,0 +1,116 @@
|
||||
/** Safe for double-quoted HTML attributes */
|
||||
const escAttr = (str) =>
|
||||
String(str ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<");
|
||||
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 += `
|
||||
|
||||
@@ -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>`;
|
||||
};
|
||||
|
||||
|
||||
BIN
public/assets/images/cutlings2.avif
Normal file
BIN
public/assets/images/cutlings2.avif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
13
src/App.js
13
src/App.js
@@ -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"
|
||||
|
||||
84
src/PrerenderHerstellerPage.js
Normal file
84
src/PrerenderHerstellerPage.js
Normal 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;
|
||||
@@ -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'
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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%' }}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
121
src/components/IdleMainPagesSlideshow.js
Normal file
121
src/components/IdleMainPagesSlideshow.js
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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]) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 isn’t 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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": "صورة مرفوعة"
|
||||
};
|
||||
|
||||
@@ -3,5 +3,7 @@ export default {
|
||||
"perPage": "لكل صفحة",
|
||||
"availability": "التوفر",
|
||||
"manufacturer": "المصنّع",
|
||||
"all": "الكل"
|
||||
"all": "الكل",
|
||||
"notifyNewArticles": "إشعار عند توفر منتجات جديدة",
|
||||
"notifyNewArticlesBrowserUnsupported": "المتصفح لا يدعم إشعارات الدفع."
|
||||
};
|
||||
|
||||
@@ -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/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export default {
|
||||
"home": "الرئيسية",
|
||||
"konfiguratorAria": "اذهب إلى المُكوِّن",
|
||||
"new": "وصل حديثًا",
|
||||
"soon": "قريبًا",
|
||||
"aktionen": "العروض الترويجية",
|
||||
|
||||
@@ -6,7 +6,7 @@ export default {
|
||||
"thcTest": "اختبار THC",
|
||||
"address1": "Trachenberger Street 14",
|
||||
"address2": "01129 Dresden",
|
||||
"showUsPhoto": "ورّينا أجمل صورة عندك",
|
||||
"buildYourSet": "جهّز مجموعتك",
|
||||
"selectSeedRate": "اختار البذرة، واضغط قيّم",
|
||||
"outdoorSeason": "موسم الزراعة الخارجية بيبدأ"
|
||||
};
|
||||
|
||||
@@ -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": "Качено изображение"
|
||||
};
|
||||
|
||||
@@ -3,5 +3,7 @@ export default {
|
||||
"perPage": "на страница",
|
||||
"availability": "Наличност",
|
||||
"manufacturer": "Производител",
|
||||
"all": "Всички"
|
||||
"all": "Всички",
|
||||
"notifyNewArticles": "Уведомявай ме за нови продукти",
|
||||
"notifyNewArticlesBrowserUnsupported": "Вашият браузър не поддържа push известия."
|
||||
};
|
||||
|
||||
@@ -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/"
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
export default {
|
||||
"home": "Начало",
|
||||
"new": "Нови артикули",
|
||||
"konfiguratorAria": "Отидете на Конфигуратора",
|
||||
"new": "Нови попълнения",
|
||||
"soon": "Очаквайте скоро",
|
||||
"aktionen": "Промоции",
|
||||
"filiale": "Магазин",
|
||||
"categories": "Категории",
|
||||
"categoriesOpen": "Отвори категориите",
|
||||
"categoriesClose": "Затвори категориите",
|
||||
"categoriesOpen": "Отвори категории",
|
||||
"categoriesClose": "Затвори категории",
|
||||
"otherCategories": "Други категории"
|
||||
};
|
||||
|
||||
@@ -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": "Открива се сезонът на открито"
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
@@ -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í."
|
||||
};
|
||||
|
||||
@@ -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 ně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/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
@@ -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."
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
export default {
|
||||
"home": "Startseite",
|
||||
"konfiguratorAria": "Zum Konfigurator",
|
||||
"new": "Neuheiten",
|
||||
"soon": "Demnächst",
|
||||
"aktionen": "Aktionen",
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
@@ -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": "Ανεβασμένη εικόνα"
|
||||
};
|
||||
|
||||
@@ -3,5 +3,7 @@ export default {
|
||||
"perPage": "ανά σελίδα",
|
||||
"availability": "Διαθεσιμότητα",
|
||||
"manufacturer": "Κατασκευαστής",
|
||||
"all": "Όλα"
|
||||
"all": "Όλα",
|
||||
"notifyNewArticles": "Ειδοποίησέ με για νέα προϊόντα",
|
||||
"notifyNewArticlesBrowserUnsupported": "Ο φυλλομετρητής σας δεν υποστηρίζει push ειδοποιήσεις."
|
||||
};
|
||||
|
||||
@@ -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/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
export default {
|
||||
"home": "Αρχική",
|
||||
"new": "Νέες Αφίξεις",
|
||||
"soon": "Έρχεται Σύντομα",
|
||||
"konfiguratorAria": "Μετάβαση στον Configurator",
|
||||
"new": "Νέα Άφιξη",
|
||||
"soon": "Σύντομα",
|
||||
"aktionen": "Προσφορές",
|
||||
"filiale": "Κατάστημα",
|
||||
"categories": "Κατηγορίες",
|
||||
|
||||
@@ -6,7 +6,7 @@ export default {
|
||||
"thcTest": "Τεστ THC",
|
||||
"address1": "Trachenberger Street 14",
|
||||
"address2": "01129 Dresden",
|
||||
"showUsPhoto": "Δείξε μας τη πιο όμορφη φωτογραφία σου",
|
||||
"buildYourSet": "Φτιάξε το δικό σου σετ",
|
||||
"selectSeedRate": "Επίλεξε σπόρο, κάνε κλικ στη βαθμολογία",
|
||||
"outdoorSeason": "Ξεκινά η υπαίθρια σεζόν"
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
@@ -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."
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
@@ -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."
|
||||
};
|
||||
|
||||
@@ -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/"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export default {
|
||||
"home": "Inicio",
|
||||
"konfiguratorAria": "Ir al Configurator",
|
||||
"new": "Novedades",
|
||||
"soon": "Próximamente",
|
||||
"aktionen": "Promociones",
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
@@ -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."
|
||||
};
|
||||
|
||||
@@ -2,13 +2,13 @@ export default {
|
||||
"sections": {
|
||||
"chatbot": {
|
||||
"title": "Chatbot IA (API OpenAI)",
|
||||
"intro": "Nous utilisons sur notre site web un chatbot basé sur l’IA, fourni via l’API OpenAI, afin de répondre automatiquement aux demandes et d’améliorer notre support.",
|
||||
"intro": "Nous utilisons sur notre site web un chatbot assisté par IA, mis à disposition via l’API d’OpenAI, afin de répondre automatiquement aux demandes et d’améliorer notre support.",
|
||||
"processing": "Lors de l’utilisation 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 l’art. 6, par. 1, lit. f DSGVO. Notre intérêt légitime réside dans le traitement efficace des demandes ainsi que dans l’amélioration de notre offre en ligne.",
|
||||
"dataRecipient": "Le destinataire des données est OpenAI. Pour les utilisateurs dans l’Espace é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 à l’art. 44 et suiv. DSGVO, en particulier par l’utilisation de clauses contractuelles types.",
|
||||
"legalBasis": "La base juridique du traitement est l’art. 6, al. 1, let. f DSGVO. Notre intérêt légitime réside dans le traitement efficace des demandes ainsi que dans l’amélioration de notre offre en ligne.",
|
||||
"dataRecipient": "Le destinataire des données est OpenAI. Pour les utilisateurs de l’Espace é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 à l’art. 44 et s. DSGVO, notamment par l’utilisation de clauses contractuelles types.",
|
||||
"modelTraining": "Selon les informations d’OpenAI, les données issues de l’utilisation de l’API ne sont pas utilisées par défaut pour l’entraî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 d’obligations 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 s’appliquent.",
|
||||
"voluntaryUse": "L’utilisation 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 à l’adresse suivante :",
|
||||
"privacyLinkUrl": "https://openai.com/de-DE/policies/eu-privacy-policy/"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export default {
|
||||
"home": "Accueil",
|
||||
"konfiguratorAria": "Aller au Configurateur",
|
||||
"new": "Nouveautés",
|
||||
"soon": "Bientôt disponible",
|
||||
"aktionen": "Promotions",
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
@@ -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."
|
||||
};
|
||||
|
||||
@@ -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/"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
@@ -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."
|
||||
};
|
||||
|
||||
@@ -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/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
@@ -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."
|
||||
};
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
export default {
|
||||
"sections": {
|
||||
"chatbot": {
|
||||
"title": "Chatbot AI (API OpenAI)",
|
||||
"intro": "Utilizziamo sul nostro sito web un chatbot supportato dall’IA, fornito tramite l’API 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 è l’Art. 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 dell’Art. 44 ff. 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 standardmente utilizzati per l’addestramento 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": "L’uso 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 all’indirizzo:",
|
||||
"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/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
@@ -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."
|
||||
};
|
||||
|
||||
@@ -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/"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
@@ -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ă"
|
||||
};
|
||||
|
||||
@@ -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."
|
||||
};
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user