Compare commits

..

28 Commits

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

1
docs/README.md Normal file
View File

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

View File

@@ -83,7 +83,7 @@ server {
default_type application/xml; 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 {} types {}
default_type text/html; default_type text/html;
} }

View File

@@ -6,6 +6,30 @@ import babelParser from '@babel/eslint-parser';
export default [ export default [
js.configs.recommended, 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}'], files: ['**/*.{js,jsx}'],
languageOptions: { languageOptions: {

10
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@fontsource-variable/outfit": "^5.2.8",
"@mui/icons-material": "^7.1.1", "@mui/icons-material": "^7.1.1",
"@mui/material": "^7.1.1", "@mui/material": "^7.1.1",
"@stripe/react-stripe-js": "^3.7.0", "@stripe/react-stripe-js": "^3.7.0",
@@ -2222,6 +2223,15 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "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": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",

View File

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

View File

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

View File

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

View File

@@ -69,11 +69,21 @@ const globalCssCollection = new Set();
// Get webpack entrypoints // Get webpack entrypoints
const webpackEntrypoints = getWebpackEntrypoints(); 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 = { module.exports = {
isProduction, isProduction,
outputDir, outputDir,
getWebpackEntrypoints, getWebpackEntrypoints,
globalCss, globalCss,
globalCssCollection, globalCssCollection,
webpackEntrypoints webpackEntrypoints,
socketIoClientOptions,
}; };

View File

@@ -42,6 +42,7 @@ const fetchCategoryProducts = (socket, categoryId) => {
"getCategoryProducts", "getCategoryProducts",
{ {
full: true, full: true,
nocount: true,
categoryId: categoryId:
categoryId === "neu" || categoryId === "bald" categoryId === "neu" || categoryId === "bald"
? categoryId ? 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) => { const saveProductImages = async (socket, products, categoryName, outputDir) => {
if (!products || products.length === 0) return; if (!products || products.length === 0) return;
@@ -382,6 +415,7 @@ module.exports = {
fetchProductDetails, fetchProductDetails,
fetchProductImage, fetchProductImage,
fetchCategoryImage, fetchCategoryImage,
fetchManufacturers,
saveProductImages, saveProductImages,
saveCategoryImages, saveCategoryImages,
}; };

View File

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

View File

@@ -1,3 +1,43 @@
/** Safe for double-quoted HTML attributes */
const escAttr = (str) =>
String(str ?? "")
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;");
/**
* Head tags for prerendered category URLs — explicit canonical per /Kategorie/{slug}
* so Google does not cluster different listing pages (e.g. neu vs Seeds) as duplicates.
*/
const generateCategoryMetaTags = (category, baseUrl, config) => {
const root = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
const categoryUrl = `${root}/Kategorie/${category.seoName}`;
const name = category.name || `Kategorie ${category.seoName}`;
const site = config.siteName || config.brandName;
const desc = `${name} bei ${config.brandName}: Growshop-Sortiment online kaufen. Schnelle Lieferung, Laden Dresden.`;
const descShort = desc.length > 160 ? `${desc.slice(0, 157)}...` : desc;
const e = escAttr;
const logoUrl =
config.images && config.images.logo
? `${root}${config.images.logo}`
: `${root}/assets/images/nopicture.jpg`;
return `
<meta name="description" content="${e(descShort)}">
<meta property="og:title" content="${e(`${name} | ${site}`)}">
<meta property="og:description" content="${e(descShort)}">
<meta property="og:url" content="${categoryUrl}">
<meta property="og:type" content="website">
<meta property="og:image" content="${e(logoUrl)}">
<meta property="og:site_name" content="${e(site)}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${e(`${name} | ${site}`)}">
<meta name="twitter:description" content="${e(descShort)}">
<meta name="robots" content="index, follow">
<link rel="canonical" href="${categoryUrl}">
`;
};
const generateCategoryJsonLd = (category, products = [], baseUrl, config) => { const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
// Category IDs to skip (seeds, plants, headshop items) // Category IDs to skip (seeds, plants, headshop items)
const skipCategoryIds = [689, 706, 709, 711, 714, 748, 749, 896, 710, 924, 923, 922, 921, 916, 278, 259, 258]; const skipCategoryIds = [689, 706, 709, 711, 714, 748, 749, 896, 710, 924, 923, 922, 921, 916, 278, 259, 258];
@@ -7,27 +47,49 @@ const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
return ''; 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 id = {
const priceValidDate = new Date(); business: `${root}#business`,
priceValidDate.setMonth(priceValidDate.getMonth() + 3); website: `${root}#website`,
const priceValidUntil = priceValidDate.toISOString().split("T")[0]; breadcrumb: `${categoryUrl}#breadcrumb`,
itemList: `${categoryUrl}#itemlist`,
};
const jsonLd = { const logoUrl =
"@context": "https://schema.org/", config.images && config.images.logo
"@type": "CollectionPage", ? `${root}${config.images.logo}`
name: category.name, : undefined;
url: categoryUrl,
description: `${category.name} - Entdecken Sie unsere Auswahl an hochwertigen Produkten`, const businessNode = {
breadcrumb: { "@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", "@type": "BreadcrumbList",
itemListElement: [ itemListElement: [
{ {
"@type": "ListItem", "@type": "ListItem",
position: 1, position: 1,
name: "Home", name: "Home",
item: baseUrl, item: root,
}, },
{ {
"@type": "ListItem", "@type": "ListItem",
@@ -36,95 +98,52 @@ const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
item: categoryUrl, item: categoryUrl,
}, },
], ],
},
}; };
// Add product list if products are available const collectionPageNode = {
if (products && products.length > 0) { "@id": categoryUrl,
jsonLd.mainEntity = { "@type": "CollectionPage",
name: category.name,
url: categoryUrl,
description: `${category.name} - Entdecken Sie unsere Auswahl an hochwertigen Produkten`,
isPartOf: { "@id": id.website },
breadcrumb: { "@id": id.breadcrumb },
};
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", "@type": "ItemList",
numberOfItems: products.length, numberOfItems: withUrls.length,
itemListElement: products.slice(0, 20).map((product, index) => ({ itemListElement: withUrls.map((product, index) => {
const productPageUrl = `${root}/Artikel/${product.seoName}`;
return {
"@type": "ListItem", "@type": "ListItem",
position: index + 1, position: index + 1,
item: { url: productPageUrl,
"@type": "Product", item: productPageUrl,
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",
},
},
},
},
},
})),
}; };
}),
});
} }
const categoryGraph = {
"@context": "https://schema.org",
"@graph": graph,
};
return `<script type="application/ld+json">${JSON.stringify( return `<script type="application/ld+json">${JSON.stringify(
jsonLd categoryGraph
)}</script>`; )}</script>`;
}; };
module.exports = { module.exports = {
generateCategoryMetaTags,
generateCategoryJsonLd, generateCategoryJsonLd,
}; };

View File

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

View File

@@ -36,177 +36,198 @@ const generateHomepageJsonLd = (baseUrl, config, categories = []) => {
const canonicalUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; const canonicalUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
const logoUrl = `${canonicalUrl}${config.images.logo}`; const logoUrl = `${canonicalUrl}${config.images.logo}`;
const websiteJsonLd = { const id = {
"@context": "https://schema.org/", business: `${canonicalUrl}#business`,
"@type": "WebSite", 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, name: config.brandName,
url: canonicalUrl, alternateName: config.siteName,
description: config.descriptions.de.long, description: config.descriptions.de.long,
publisher: {
"@type": "Organization",
name: config.brandName,
url: canonicalUrl, url: canonicalUrl,
logo: { logo: {
"@type": "ImageObject", "@type": "ImageObject",
url: logoUrl, url: logoUrl,
}, },
image: {
"@type": "ImageObject",
url: logoUrl,
}, },
potentialAction: { telephone: "015208491860",
"@type": "SearchAction", email: "service@growheads.de",
target: `${canonicalUrl}/search?q={search_term_string}`, address: {
query: "required name=search_term_string" "@type": "PostalAddress",
streetAddress: "Trachenberger Strasse 14",
addressLocality: "Dresden",
postalCode: "01129",
addressCountry: "DE",
addressRegion: "Sachsen",
}, },
mainEntity: { 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", "@type": "WebPage",
name: "Sitemap", name: "Sitemap",
url: `${canonicalUrl}/sitemap`, url: `${canonicalUrl}/sitemap`,
description: "Vollständige Sitemap mit allen Kategorien und Seiten", description: "Vollständige Sitemap mit allen Kategorien und Seiten",
}, isPartOf: { "@id": id.website },
sameAs: [
// Add your social media URLs here if available
],
}; };
// Organization/LocalBusiness Schema for rich results const websiteNode = {
const organizationJsonLd = { "@id": id.website,
"@context": "https://schema.org", "@type": "WebSite",
"@type": "LocalBusiness", name: config.brandName,
"name": config.brandName, url: canonicalUrl,
"alternateName": config.siteName, description: config.descriptions.de.long,
"description": config.descriptions.de.long, publisher: { "@id": id.business },
"url": canonicalUrl, potentialAction: {
"logo": logoUrl, "@type": "SearchAction",
"image": logoUrl, target: `${canonicalUrl}/search?q={search_term_string}`,
"telephone": "015208491860", query: "required name=search_term_string",
"email": "service@growheads.de",
"address": {
"@type": "PostalAddress",
"streetAddress": "Trachenberger Strasse 14",
"addressLocality": "Dresden",
"postalCode": "01129",
"addressCountry": "DE",
"addressRegion": "Sachsen"
}, },
"geo": { mainEntity: { "@id": id.sitemapPage },
"@type": "GeoCoordinates", sameAs: [],
"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": [
// Add social media URLs when available
// "https://www.facebook.com/growheads",
// "https://www.instagram.com/growheads"
]
}; };
// FAQPage Schema for common questions const faqMainEntity = [
const faqJsonLd = { {
"@context": "https://schema.org", "@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.",
},
},
];
const faqNode = {
"@id": id.faq,
"@type": "FAQPage", "@type": "FAQPage",
"mainEntity": [ url: canonicalUrl,
{ publisher: { "@id": id.business },
"@type": "Question", isPartOf: { "@id": id.website },
"name": "Welche Zahlungsmethoden akzeptiert GrowHeads?", mainEntity: faqMainEntity,
"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."
}
}
]
}; };
// Generate ItemList for all categories (more appropriate for homepage) const filteredCategories = categories.filter((c) => c.seoName);
const categoriesListJsonLd = {
"@context": "https://schema.org", const graph = [
organizationNode,
websiteNode,
sitemapWebPageNode,
faqNode,
];
if (filteredCategories.length > 0) {
graph.push({
"@id": id.categoryList,
"@type": "ItemList", "@type": "ItemList",
"name": "Produktkategorien", name: "Produktkategorien",
"description": "Alle verfügbaren Produktkategorien in unserem Online-Shop", description: "Alle verfügbaren Produktkategorien in unserem Online-Shop",
"numberOfItems": categories.filter(category => category.seoName).length, numberOfItems: filteredCategories.length,
"itemListElement": categories isPartOf: { "@id": id.website },
.filter(category => category.seoName) // Only include categories with seoName itemListElement: filteredCategories.map((category, index) => ({
.map((category, index) => ({
"@type": "ListItem", "@type": "ListItem",
"position": index + 1, position: index + 1,
"item": { item: {
"@type": "Thing", "@type": "Thing",
"name": category.name, name: category.name,
"url": `${canonicalUrl}/Kategorie/${category.seoName}` url: `${canonicalUrl}/Kategorie/${category.seoName}`,
},
})),
});
} }
}))
const homepageGraph = {
"@context": "https://schema.org",
"@graph": graph,
}; };
// Return all JSON-LD scripts return `<script type="application/ld+json">${JSON.stringify(
const websiteScript = `<script type="application/ld+json">${JSON.stringify(websiteJsonLd)}</script>`; homepageGraph
const organizationScript = `<script type="application/ld+json">${JSON.stringify(organizationJsonLd)}</script>`; )}</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 : '');
}; };
module.exports = { module.exports = {

View File

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

View File

@@ -68,14 +68,15 @@ const generateProductMetaTags = (product, baseUrl, config) => {
}; };
const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) => { const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) => {
const productUrl = `${baseUrl}/Artikel/${product.seoName}`; const root = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
const productUrl = `${root}/Artikel/${product.seoName}`;
const pictureFirstId = const pictureFirstId =
product.pictureList && product.pictureList.trim() product.pictureList && product.pictureList.trim()
? product.pictureList.split(",")[0].trim() ? product.pictureList.split(",")[0].trim()
: null; : null;
const imageUrl = pictureFirstId const imageUrl = pictureFirstId
? `${baseUrl}/assets/images/prod${pictureFirstId}.avif` ? `${root}/assets/images/prod${pictureFirstId}.avif`
: `${baseUrl}/assets/images/nopicture.jpg`; : `${root}/assets/images/nopicture.jpg`;
// Clean description for JSON-LD (remove HTML tags) // Clean description for JSON-LD (remove HTML tags)
const cleanDescription = product.description const cleanDescription = product.description
@@ -86,19 +87,38 @@ const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) =>
const priceValidDate = new Date(); const priceValidDate = new Date();
priceValidDate.setMonth(priceValidDate.getMonth() + 3); priceValidDate.setMonth(priceValidDate.getMonth() + 3);
const jsonLd = { const id = {
"@context": "https://schema.org/", business: `${root}#business`,
"@type": "Product", website: `${root}#website`,
name: product.name, product: `${productUrl}#product`,
image: [imageUrl], breadcrumb: `${productUrl}#breadcrumb`,
description: cleanDescription, };
sku: product.articleNumber,
...(product.gtin && { gtin: product.gtin }), const logoUrl =
brand: { config.images && config.images.logo
"@type": "Brand", ? `${root}${config.images.logo}`
name: product.manufacturer || "Unknown", : undefined;
},
offers: { 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", "@type": "Offer",
url: productUrl, url: productUrl,
priceCurrency: config.currency, priceCurrency: config.currency,
@@ -108,10 +128,7 @@ const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) =>
availability: product.available availability: product.available
? "https://schema.org/InStock" ? "https://schema.org/InStock"
: "https://schema.org/OutOfStock", : "https://schema.org/OutOfStock",
seller: { seller: { "@id": id.business },
"@type": "Organization",
name: config.brandName,
},
hasMerchantReturnPolicy: { hasMerchantReturnPolicy: {
"@type": "MerchantReturnPolicy", "@type": "MerchantReturnPolicy",
applicableCountry: "DE", applicableCountry: "DE",
@@ -124,7 +141,7 @@ const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) =>
"@type": "OfferShippingDetails", "@type": "OfferShippingDetails",
shippingRate: { shippingRate: {
"@type": "MonetaryAmount", "@type": "MonetaryAmount",
value: 5.90, value: 5.9,
currency: "EUR", currency: "EUR",
}, },
shippingDestination: { shippingDestination: {
@@ -147,25 +164,42 @@ const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) =>
}, },
}, },
}, },
},
}; };
// Add breadcrumb if category information is available const productNode = {
if (categoryInfo && categoryInfo.name && categoryInfo.seoName) { "@id": id.product,
jsonLd.breadcrumb = { "@type": "Product",
name: product.name,
image: [imageUrl],
description: cleanDescription,
sku: product.articleNumber,
...(product.gtin && { gtin: product.gtin }),
brand: {
"@type": "Brand",
name: product.manufacturer || "Unknown",
},
offers: offer,
};
const hasBreadcrumb =
categoryInfo && categoryInfo.name && categoryInfo.seoName;
const breadcrumbList = hasBreadcrumb
? {
"@id": id.breadcrumb,
"@type": "BreadcrumbList", "@type": "BreadcrumbList",
itemListElement: [ itemListElement: [
{ {
"@type": "ListItem", "@type": "ListItem",
position: 1, position: 1,
name: "Home", name: "Home",
item: baseUrl, item: root,
}, },
{ {
"@type": "ListItem", "@type": "ListItem",
position: 2, position: 2,
name: categoryInfo.name, name: categoryInfo.name,
item: `${baseUrl}/Kategorie/${categoryInfo.seoName}`, item: `${root}/Kategorie/${categoryInfo.seoName}`,
}, },
{ {
"@type": "ListItem", "@type": "ListItem",
@@ -174,11 +208,34 @@ const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) =>
item: productUrl, 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( return `<script type="application/ld+json">${JSON.stringify(
jsonLd productGraph
)}</script>`; )}</script>`;
}; };

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

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

View File

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

View File

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

View File

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

View File

@@ -55,20 +55,54 @@ class ChatAssistant extends Component {
buildPrivacyPromptHtml = () => { buildPrivacyPromptHtml = () => {
const { t } = this.props; const { t } = this.props;
return `${t('chat.privacyPromptBefore')}<a href="/datenschutz" target="_blank" rel="noopener noreferrer">${t('chat.privacyPolicyLink')}</a>${t('chat.privacyPromptAfter')}<button data-confirm-privacy="true">${t('chat.privacyRead')}</button>`; 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() { componentDidMount() {
// Add socket listeners if socket is available and connected // Add socket listeners if socket is available and connected
this.addSocketListeners(); this.addSocketListeners();
this.props.i18n?.on('languageChanged', this.handleI18nLanguageChanged);
const userStatus = isUserLoggedIn(); const userStatus = isUserLoggedIn();
const isGuest = !userStatus.isLoggedIn; const isGuest = !userStatus.isLoggedIn;
if (isGuest && !this.state.privacyConfirmed) { if (isGuest && !this.state.privacyConfirmed) {
this.setState(prevState => { this.setState(prevState => {
if (prevState.messages.find(msg => msg.id === 'privacy-prompt')) { 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 = { const privacyMessage = {
@@ -90,17 +124,7 @@ class ChatAssistant extends Component {
componentDidUpdate(prevProps, prevState) { componentDidUpdate(prevProps, prevState) {
if (prevProps.i18n?.language !== this.props.i18n?.language) { if (prevProps.i18n?.language !== this.props.i18n?.language) {
this.setState((prev) => { this.applyPrivacyPromptTranslation();
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 };
});
} }
if (prevState.messages !== this.state.messages || prevState.isTyping !== this.state.isTyping) { if (prevState.messages !== this.state.messages || prevState.isTyping !== this.state.isTyping) {
this.scrollToBottom(); this.scrollToBottom();
@@ -108,6 +132,7 @@ class ChatAssistant extends Component {
} }
componentWillUnmount() { componentWillUnmount() {
this.props.i18n?.off('languageChanged', this.handleI18nLanguageChanged);
this.removeSocketListeners(); this.removeSocketListeners();
this.stopRecording(); this.stopRecording();
if (this.recordingTimer) { if (this.recordingTimer) {
@@ -206,7 +231,7 @@ class ChatAssistant extends Component {
}, () => { }, () => {
// Emit message to socket server after state is updated // Emit message to socket server after state is updated
if (userMessage.trim()) { if (userMessage.trim()) {
window.socketManager.emit('aiassyMessage', userMessage); window.socketManager.emit('aiassyMessage', { message: userMessage, lang: this.props.i18n?.language });
} }
}); });
} }

View File

@@ -11,7 +11,7 @@ import ProductList from './ProductList.js';
import CategoryBoxGrid from './CategoryBoxGrid.js'; import CategoryBoxGrid from './CategoryBoxGrid.js';
import CategoryBox from './CategoryBox.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 { getAllSettingsWithPrefix } from '../utils/sessionStorage.js';
import { withI18n } from '../i18n/withTranslation.js'; import { withI18n } from '../i18n/withTranslation.js';
import { withCategory } from '../context/CategoryContext.js'; import { withCategory } from '../context/CategoryContext.js';
@@ -24,17 +24,19 @@ const withRouter = (ClassComponent) => {
return (props) => { return (props) => {
const params = useParams(); const params = useParams();
const [searchParams] = useSearchParams(); 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) { if (!window.productCache) {
window.productCache = {}; window.productCache = {};
} }
try { try {
const cacheKey = `categoryProducts_${categoryId}_${language}`; const cacheKey = `${isHersteller ? 'manufacturer' : 'category'}Products_${categoryId}_${language}`;
const cachedData = window.productCache[cacheKey]; const cachedData = window.productCache[cacheKey];
if (cachedData) { if (cachedData) {
@@ -166,7 +168,7 @@ function getFilteredProducts(unfilteredProducts, attributes, t) {
return { filteredProducts, activeAttributeFilters: activeAttributeFiltersWithNames, activeManufacturerFilters: activeManufacturerFiltersWithNames, activeAvailabilityFilters }; return { filteredProducts, activeAttributeFilters: activeAttributeFiltersWithNames, activeManufacturerFilters: activeManufacturerFiltersWithNames, activeAvailabilityFilters };
} }
function setCachedCategoryData(categoryId, data, language = 'de') { function setCachedCategoryData(categoryId, data, language = 'de', isHersteller = false) {
if (!window.productCache) { if (!window.productCache) {
window.productCache = {}; window.productCache = {};
} }
@@ -175,7 +177,7 @@ function setCachedCategoryData(categoryId, data, language = 'de') {
} }
try { try {
const cacheKey = `categoryProducts_${categoryId}_${language}`; const cacheKey = `${isHersteller ? 'manufacturer' : 'category'}Products_${categoryId}_${language}`;
if (data.products) for (const product of data.products) { if (data.products) for (const product of data.products) {
const productCacheKey = `product_${product.id}_${language}`; const productCacheKey = `product_${product.id}_${language}`;
window.productDetailCache[productCacheKey] = product; window.productDetailCache[productCacheKey] = product;
@@ -221,9 +223,10 @@ class Content extends Component {
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
const currentLanguage = this.props.i18n?.language || 'de'; const currentLanguage = this.props.i18n?.language || 'de';
const categoryChanged = this.props.params.categoryId && (prevProps.params.categoryId !== this.props.params.categoryId); const categoryChanged = this.props.params.categoryId && (prevProps.params.categoryId !== this.props.params.categoryId);
const routeTypeChanged = !!prevProps.isHersteller !== !!this.props.isHersteller;
const searchChanged = this.props.searchParams?.get('q') && (prevProps.searchParams?.get('q') !== this.props.searchParams?.get('q')); const searchChanged = this.props.searchParams?.get('q') && (prevProps.searchParams?.get('q') !== this.props.searchParams?.get('q'));
if (categoryChanged) { if (categoryChanged || routeTypeChanged) {
// Clear context for new category loading // Clear context for new category loading
if (this.props.categoryContext && this.props.categoryContext.setCurrentCategory) { if (this.props.categoryContext && this.props.categoryContext.setCurrentCategory) {
this.props.categoryContext.setCurrentCategory(null); 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.setState({ loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: [], lastFetchedLanguage: currentLanguage }, () => {
this.fetchCategoryData(this.props.params.categoryId); 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) { else if (searchChanged) {
this.setState({ loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: [], lastFetchedLanguage: currentLanguage }, () => { this.setState({ loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: [], lastFetchedLanguage: currentLanguage }, () => {
@@ -345,7 +348,8 @@ class Content extends Component {
sessionStorage.setItem('filter_availability', '1'); sessionStorage.setItem('filter_availability', '1');
} }
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de'; 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) { if (cachedData) {
this.processDataWithCategoryTree(cachedData, categoryId); this.processDataWithCategoryTree(cachedData, categoryId);
return; return;
@@ -360,7 +364,7 @@ class Content extends Component {
window.socketManager.on(`productList:${categoryId}`, (response) => { window.socketManager.on(`productList:${categoryId}`, (response) => {
console.log("getCategoryProducts full response", response); console.log("getCategoryProducts full response", response);
receivedFullResponse = true; receivedFullResponse = true;
setCachedCategoryData(categoryId, response, currentLanguage); setCachedCategoryData(categoryId, response, currentLanguage, isHersteller);
if (response && response.products !== undefined) { if (response && response.products !== undefined) {
this.processDataWithCategoryTree(response, categoryId); this.processDataWithCategoryTree(response, categoryId);
} else { } else {
@@ -370,12 +374,17 @@ class Content extends Component {
window.socketManager.emit( window.socketManager.emit(
"getCategoryProducts", "getCategoryProducts",
{ categoryId: categoryId, language: currentLanguage, requestTranslation: currentLanguage === 'de' ? false : true }, {
categoryId: categoryId,
language: currentLanguage,
requestTranslation: currentLanguage === 'de' ? false : true,
isHersteller,
},
(response) => { (response) => {
console.log("getCategoryProducts stub response", response); console.log("getCategoryProducts stub response", response);
// Only process stub response if we haven't received the full response yet // Only process stub response if we haven't received the full response yet
if (!receivedFullResponse) { if (!receivedFullResponse) {
setCachedCategoryData(categoryId, response, currentLanguage); setCachedCategoryData(categoryId, response, currentLanguage, isHersteller);
if (response && response.products !== undefined) { if (response && response.products !== undefined) {
this.processDataWithCategoryTree(response, categoryId); this.processDataWithCategoryTree(response, categoryId);
} else { } 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); this.processData(enhancedResponse);
} }
@@ -701,7 +733,7 @@ class Content extends Component {
minHeight: { xs: 'min-content', sm: '100%' } minHeight: { xs: 'min-content', sm: '100%' }
}}> }}>
<Box > <Box sx={{ overflow: 'visible', minWidth: 0 }}>
<ProductFilters <ProductFilters
products={this.state.unfilteredProducts} products={this.state.unfilteredProducts}

View File

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

View File

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

View File

@@ -240,7 +240,15 @@ export class LoginComponent extends Component {
isAdmin: !!response.user.admin 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 = () => { const dispatchLoginEvent = () => {
window.dispatchEvent(new CustomEvent('userLoggedIn')); window.dispatchEvent(new CustomEvent('userLoggedIn'));
navigate(redirectTo); navigate(redirectTo);
@@ -415,7 +423,14 @@ export class LoginComponent extends Component {
user: response.user 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 = () => { const dispatchLoginEvent = () => {
window.dispatchEvent(new CustomEvent('userLoggedIn')); window.dispatchEvent(new CustomEvent('userLoggedIn'));
navigate(redirectTo); navigate(redirectTo);

View File

@@ -14,24 +14,162 @@ import { STAR_POLYGON_POINTS } from "../utils/starPolygon.js";
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
const HOME_STAR_LAYERS = [ const HOME_STAR_LAYERS = [
{ className: "star-rotate-slow-cw", size: 168, staticDeg: 20, fill: "#B8860B" }, { className: "star-rotate-slow-cw", size: 168 },
{ className: "star-rotate-slow-ccw", size: 159, staticDeg: -25, fill: "#DAA520" }, { className: "star-rotate-slow-ccw", size: 159 },
{ className: "star-rotate-medium-cw", size: 150, staticDeg: null, fill: "#FFD700" }, { className: "star-rotate-medium-cw", size: 150 },
]; ];
const FILIALE_STAR_LAYERS = [ /** Teal/cyan stack for the right (Konfigurator) star — same motion, blue color scheme */
{ className: "star-rotate-slow-ccw", size: 168, staticDeg: 20, fill: "#5F9EA0" }, const TEAL_STAR_LAYERS = [
{ className: "star-rotate-medium-cw", size: 159, staticDeg: -25, fill: "#7FCDCD" }, { className: "star-rotate-slow-ccw", size: 168 },
{ className: "star-rotate-slow-cw", size: 150, staticDeg: null, fill: "#AFEEEE" }, { className: "star-rotate-medium-cw", size: 159 },
{ className: "star-rotate-slow-cw", size: 150 },
]; ];
const StarDecorationLayers = ({ layers }) => ( /** 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, staticDeg, fill }, i) => { {layers.map(({ className, size }, i) => {
const half = size / 2; const half = size / 2;
const initialFill = STAR_INITIAL_FILLS[variant][i];
return ( return (
<div <div
key={i} key={i}
className={`star-layer-wrap star-layer-${variant}-${i}`}
style={{ style={{
position: "absolute", position: "absolute",
left: "50%", left: "50%",
@@ -40,11 +178,21 @@ const StarDecorationLayers = ({ layers }) => (
height: size, height: size,
marginLeft: -half, marginLeft: -half,
marginTop: -half, marginTop: -half,
...(staticDeg != null ? { transform: `rotate(${staticDeg}deg)` } : {}), zIndex: 3 - i,
}} }}
> >
<svg viewBox="0 0 60 60" width="100%" height="100%" className={className} style={{ display: "block" }}> <svg
<polygon points={STAR_POLYGON_POINTS} fill={fill} /> 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> </svg>
</div> </div>
); );
@@ -65,11 +213,10 @@ const ContentBox = ({ box, index, pageType, starHovered, setStarHovered, opacity
zIndex: 999, zIndex: 999,
pointerEvents: 'none', pointerEvents: 'none',
'& *': { 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' } display: { xs: 'none', sm: 'block' }
}} }}
> >
<StarDecorationLayers layers={HOME_STAR_LAYERS} /> <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 }}> <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} {translatedContent.outdoorSeason}
</div> </div>
@@ -78,7 +225,7 @@ const ContentBox = ({ box, index, pageType, starHovered, setStarHovered, opacity
</div> </div>
</Box> </Box>
)} )}
{index === 1 && pageType === "filiale" && ( {index === 1 && pageType === "home" && (
<Box <Box
sx={{ sx={{
position: 'absolute', position: 'absolute',
@@ -89,13 +236,12 @@ const ContentBox = ({ box, index, pageType, starHovered, setStarHovered, opacity
zIndex: 999, zIndex: 999,
pointerEvents: 'none', pointerEvents: 'none',
'& *': { 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' } display: { xs: 'none', sm: 'block' }
}} }}
> >
<StarDecorationLayers layers={FILIALE_STAR_LAYERS} /> <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' }}> <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.showUsPhoto} {translatedContent.buildYourSet}
</div> </div>
</Box> </Box>
)} )}
@@ -113,16 +259,16 @@ const ContentBox = ({ box, index, pageType, starHovered, setStarHovered, opacity
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
boxShadow: 10, boxShadow: 10,
transition: "all 0.3s ease", transition: "box-shadow 0.3s ease",
"&:hover": { transform: "translateY(-5px)", boxShadow: 20 }, "&:hover": { boxShadow: 20 },
}} }}
onMouseEnter={ onMouseEnter={
(pageType === "home" && index === 0) || (pageType === "filiale" && index === 1) pageType === "home" && index === 0
? () => setStarHovered(true) ? () => setStarHovered(true)
: undefined : undefined
} }
onMouseLeave={ onMouseLeave={
(pageType === "home" && index === 0) || (pageType === "filiale" && index === 1) pageType === "home" && index === 0
? () => setStarHovered(false) ? () => setStarHovered(false)
: undefined : undefined
} }
@@ -145,8 +291,22 @@ const MainPageLayout = () => {
const currentPath = location.pathname; const currentPath = location.pathname;
const { t } = useTranslation(); const { t } = useTranslation();
const [starHovered, setStarHovered] = React.useState(false); 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 = { const translatedContent = {
showUsPhoto: t('sections.showUsPhoto'), buildYourSet: isKiosk ? 'Schau in den Stecklingskatalog' : t('sections.buildYourSet'),
selectSeedRate: t('sections.selectSeedRate'), selectSeedRate: t('sections.selectSeedRate'),
outdoorSeason: t('sections.outdoorSeason') outdoorSeason: t('sections.outdoorSeason')
}; };
@@ -155,37 +315,6 @@ const MainPageLayout = () => {
const isAktionen = currentPath === "/aktionen"; const isAktionen = currentPath === "/aktionen";
const isFiliale = currentPath === "/filiale"; 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,
.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;
}
`;
document.head.appendChild(style);
return () => document.head.removeChild(style);
}, []);
const getNavigationConfig = () => { const getNavigationConfig = () => {
if (isHome) return { leftNav: { text: t('navigation.aktionen'), link: "/aktionen" }, rightNav: { text: t('navigation.filiale'), link: "/filiale" } }; 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: "/" } }; if (isAktionen) return { leftNav: { text: t('navigation.filiale'), link: "/filiale" }, rightNav: { text: t('navigation.home'), link: "/" } };
@@ -202,11 +331,11 @@ const MainPageLayout = () => {
const allContentBoxes = { const allContentBoxes = {
home: [ home: [
{ title: t('sections.seeds'), image: "/assets/images/seeds.avif", bgcolor: "#e1f0d3", link: "/Kategorie/Seeds" }, { 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: [ aktionen: [
{ title: t('sections.oilPress'), image: "/assets/images/presse.jpg", bgcolor: "#e1f0d3", link: "/presseverleih" }, { title: t('sections.oilPress'), image: "/assets/images/presse.jpg", bgcolor: "#e1f0d3", link: "/Artikel/Graveda-10t-presse-tagesmiete-inkl-prepress-vorpressform" },
{ title: t('sections.thcTest'), image: "/assets/images/purpl.jpg", bgcolor: "#e8f5d6", link: "/thc-test" } { title: t('sections.thcTest'), image: "/assets/images/purpl.jpg", bgcolor: "#e8f5d6", link: "/Artikel/1x-messung-purplpro-thc-cbd-restfeuchte-wasseraktivitaet" }
], ],
filiale: [ filiale: [
{ title: t('sections.address1'), image: "/assets/images/filiale1.jpg", bgcolor: "#e1f0d3", link: "/filiale" }, { title: t('sections.address1'), image: "/assets/images/filiale1.jpg", bgcolor: "#e1f0d3", link: "/filiale" },
@@ -231,6 +360,7 @@ const MainPageLayout = () => {
return ( return (
<Container maxWidth="lg" sx={{ py: 2 }}> <Container maxWidth="lg" sx={{ py: 2 }}>
<style>{getCombinedAnimatedBorderStyles(['seeds', 'cutlings'])}</style> <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: "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" }}> <Box sx={{ display: { xs: "block", sm: "none" }, mb: { xs: 2, sm: 0 }, width: "100%", textAlign: "center", position: "relative" }}>
{Object.entries(allTitles).map(([pageType, title]) => ( {Object.entries(allTitles).map(([pageType, title]) => (

View File

@@ -1,17 +1,25 @@
import React from 'react'; import React from 'react';
import { Link } from 'react-router-dom';
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import 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 { withTranslation } from 'react-i18next';
import { withLanguage } from '../i18n/withTranslation.js'; import { withLanguage } from '../i18n/withTranslation.js';
const ITEM_WIDTH = 140 + 16; // 140px width + 16px gap const ITEM_WIDTH = 140 + 16; // 140px width + 16px gap
const AUTO_SCROLL_SPEED = 1.0; const AUTO_SCROLL_SPEED = 1.0;
const AUTOSCROLL_RESTART_DELAY = 5000;
class ManufacturerCarousel extends React.Component { class ManufacturerCarousel extends React.Component {
_isMounted = false; _isMounted = false;
originalItems = []; originalItems = [];
animationFrame = null; animationFrame = null;
autoScrollActive = true;
translateX = 0; translateX = 0;
inactivityTimer = null;
constructor(props) { constructor(props) {
super(props); super(props);
@@ -28,10 +36,8 @@ class ManufacturerCarousel extends React.Component {
componentWillUnmount() { componentWillUnmount() {
this._isMounted = false; this._isMounted = false;
if (this.animationFrame) { this.stopAutoScroll();
cancelAnimationFrame(this.animationFrame); this.clearInactivityTimer();
this.animationFrame = null;
}
// Revoke object URLs to avoid memory leaks // Revoke object URLs to avoid memory leaks
for (const item of this.originalItems) { for (const item of this.originalItems) {
if (item.src) URL.revokeObjectURL(item.src); if (item.src) URL.revokeObjectURL(item.src);
@@ -46,7 +52,12 @@ class ManufacturerCarousel extends React.Component {
.filter(m => m.imageBuffer) .filter(m => m.imageBuffer)
.map(m => { .map(m => {
const blob = new Blob([m.imageBuffer], { type: 'image/avif' }); 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); .sort(() => Math.random() - 0.5);
@@ -60,13 +71,38 @@ class ManufacturerCarousel extends React.Component {
}; };
startAutoScroll = () => { startAutoScroll = () => {
this.autoScrollActive = true;
if (!this.animationFrame) { if (!this.animationFrame) {
this.animationFrame = requestAnimationFrame(this.tick); 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 = () => { tick = () => {
if (!this._isMounted || this.originalItems.length === 0) return; if (!this._isMounted || !this.autoScrollActive || this.originalItems.length === 0) return;
this.translateX -= AUTO_SCROLL_SPEED; this.translateX -= AUTO_SCROLL_SPEED;
@@ -82,6 +118,41 @@ class ManufacturerCarousel extends React.Component {
this.animationFrame = requestAnimationFrame(this.tick); 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() { render() {
const { t } = this.props; const { t } = this.props;
const { items } = this.state; const { items } = this.state;
@@ -90,19 +161,36 @@ class ManufacturerCarousel extends React.Component {
return ( return (
<Box sx={{ mt: 4, mb: 4 }}> <Box sx={{ mt: 4, mb: 4 }}>
<Box
component={Link}
to="/Hersteller"
sx={{
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',
},
}}
>
<Typography <Typography
variant="h4" variant="h4"
component="div" component="span"
sx={{ sx={{
fontFamily: 'SwashingtonCP', fontFamily: 'SwashingtonCP',
textShadow: '3px 3px 10px rgba(0, 0, 0, 0.4)', textShadow: '3px 3px 10px rgba(0, 0, 0, 0.4)',
textAlign: 'center', textAlign: 'center',
mb: 2,
color: 'primary.main',
}} }}
> >
{t('product.manufacturer')} {t('product.manufacturer')}
</Typography> </Typography>
<ChevronRight sx={{ fontSize: '2.5rem', ml: 1 }} />
</Box>
<div <div
style={{ style={{
@@ -129,6 +217,46 @@ class ManufacturerCarousel extends React.Component {
zIndex: 2, pointerEvents: 'none', 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 <div
style={{ style={{
position: 'relative', position: 'relative',
@@ -151,8 +279,11 @@ class ManufacturerCarousel extends React.Component {
}} }}
> >
{items.map((item, index) => ( {items.map((item, index) => (
<div <Paper
key={`${item.id}-${index}`} key={`${item.id}-${index}`}
component={Link}
to={`/Hersteller/${encodeURIComponent(item.slug || '')}`}
elevation={3}
style={{ style={{
flex: '0 0 140px', flex: '0 0 140px',
width: '140px', width: '140px',
@@ -162,7 +293,20 @@ class ManufacturerCarousel extends React.Component {
justifyContent: 'center', justifyContent: 'center',
overflow: 'hidden', overflow: 'hidden',
userSelect: 'none', 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 <img
@@ -176,7 +320,7 @@ class ManufacturerCarousel extends React.Component {
display: 'block', display: 'block',
}} }}
/> />
</div> </Paper>
))} ))}
</div> </div>
</div> </div>

View File

@@ -1174,18 +1174,17 @@ class ProductDetailPage extends Component {
<Box <Box
sx={{ sx={{
mb: 2, mb: 2,
position: ["-webkit-sticky", "sticky"], // Provide both prefixed and standard position: "sticky",
top: { top: {
xs: "110px", xs: "calc(env(safe-area-inset-top, 0px) + 160px)",
sm: "110px", sm: "110px",
md: "110px", md: "110px",
lg: "110px", lg: "110px",
} /* Offset to sit below the header 120 mith menu for md and lg*/, },
left: 0, left: 0,
width: "100%", width: "100%",
display: "flex", display: "flex",
zIndex: (theme) => zIndex: (theme) => theme.zIndex.appBar - 1,
theme.zIndex.appBar - 1 /* Just below the AppBar */,
py: 0, py: 0,
px: 2, px: 2,
}} }}
@@ -1200,10 +1199,19 @@ class ProductDetailPage extends Component {
borderRadius: 1, borderRadius: 1,
}} }}
> >
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary" component="div">
<Link <Link
to="/" 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={{ style={{
paddingLeft: 16, paddingLeft: 16,
paddingRight: 16, paddingRight: 16,

View File

@@ -1,13 +1,35 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper'; import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography'; 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 Filter from './Filter.js';
import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom'; import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom';
import { setSessionSetting, removeSessionSetting, clearAllSessionSettings } from '../utils/sessionStorage.js'; import { setSessionSetting, removeSessionSetting, clearAllSessionSettings } from '../utils/sessionStorage.js';
import { withI18n } from '../i18n/withTranslation.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); 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 // HOC to provide router props to class components
const withRouter = (ClassComponent) => { const withRouter = (ClassComponent) => {
return (props) => { return (props) => {
@@ -38,19 +60,35 @@ class ProductFilters extends Component {
uniqueManufacturerArray, uniqueManufacturerArray,
attributeGroups, attributeGroups,
manufacturerImages: new Map(), // id (number) → object URL manufacturerImages: new Map(), // id (number) → object URL
pushInteractive: false,
pushSubscribed: false,
pushBusy: false,
pushError: null,
}; };
this._manufacturerImageUrls = []; // track for cleanup this._manufacturerImageUrls = []; // track for cleanup
} }
componentDidMount() { componentDidMount() {
this.onPushSubscriptionsChanged = () => {
this.refreshCategoryPushStatus();
};
this.adjustPaperHeight(); this.adjustPaperHeight();
window.addEventListener('resize', this.adjustPaperHeight); window.addEventListener('resize', this.adjustPaperHeight);
window.addEventListener(
PUSH_SUBSCRIPTIONS_CHANGED_EVENT,
this.onPushSubscriptionsChanged
);
this._loadManufacturerImages(); this._loadManufacturerImages();
this.refreshCategoryPushStatus();
} }
componentWillUnmount() { componentWillUnmount() {
window.removeEventListener('resize', this.adjustPaperHeight); window.removeEventListener('resize', this.adjustPaperHeight);
window.removeEventListener(
PUSH_SUBSCRIPTIONS_CHANGED_EVENT,
this.onPushSubscriptionsChanged
);
this._manufacturerImageUrls.forEach(url => URL.revokeObjectURL(url)); this._manufacturerImageUrls.forEach(url => URL.revokeObjectURL(url));
} }
@@ -102,17 +140,148 @@ class ProductFilters extends Component {
const attributeGroups = this._getAttributeGroups(this.props.attributes); const attributeGroups = this._getAttributeGroups(this.props.attributes);
this.setState({attributeGroups}); 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 = () => { adjustPaperHeight = () => {
// Skip height adjustment on xs screens
if (window.innerWidth < 600) return;
// Get reference to our paper element
const paperEl = document.getElementById('filters-paper'); const paperEl = document.getElementById('filters-paper');
if (!paperEl) return; 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 // Get viewport height
const viewportHeight = window.innerHeight; const viewportHeight = window.innerHeight;
@@ -200,35 +369,140 @@ class ProductFilters extends Component {
} }
render() { 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 ( return (
<Box
sx={{
px: { xs: 2, sm: 0 },
pt: { xs: 2, sm: 0 },
/* Room below Paper so elevation shadow isnt clipped by grid/parent */
pb: { xs: 2, sm: 2 },
overflow: 'visible',
/* Same green as ProductList / product strip mobile (#e8f5e8), not theme background.default (#C8E6C9) */
bgcolor: { xs: '#e8f5e8', sm: 'transparent' },
}}
>
<Paper <Paper
id="filters-paper" id="filters-paper"
elevation={window.innerWidth < 600 ? 0 : 1} elevation={1}
sx={{ sx={{
p: { xs: 1, sm: 2 }, p: { xs: 2.5, sm: 2.5 },
borderRadius: { xs: 0, sm: 2 }, mx: { sm: 'auto' },
maxWidth: '100%',
borderRadius: 2,
bgcolor: 'background.paper', bgcolor: 'background.paper',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
border: { xs: 'none', sm: 'inherit' }, border: { xs: 'none', sm: 'inherit' },
boxShadow: { xs: 'none', sm: 'inherit' }, boxSizing: 'border-box',
mx: { xs: 0, sm: 'auto' }, overflow: 'visible',
width: { xs: '100%', sm: 'auto' }
}} }}
> >
{this.props.dataType == 'category' && ( {this.props.dataType == 'category' && (
<Box sx={{ mb: 4 }}>
<Typography <Typography
variant="h3" variant="h3"
component="h1" component="h1"
sx={{ sx={{
mb: 4, mb: showCategoryPush ? 1.5 : 4,
fontFamily: 'SwashingtonCP', fontFamily: 'SwashingtonCP',
color: 'primary.main' color: 'primary.main',
}} }}
> >
{this.props.categoryName} {this.props.categoryName}
</Typography> </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> </Paper>
</Box>
); );
} }
} }

View File

@@ -85,7 +85,7 @@ class Stripe extends Component {
colorWarning: '#FF9800', // Orange for warnings colorWarning: '#FF9800', // Orange for warnings
// Typography matching your Roboto setup // 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 fontSizeBase: '16px', // Base font size for mobile compatibility
fontWeightNormal: '400', // Normal Roboto weight fontWeightNormal: '400', // Normal Roboto weight
fontWeightMedium: '500', // Medium Roboto weight fontWeightMedium: '500', // Medium Roboto weight

View File

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

View File

@@ -183,7 +183,7 @@ class CategoryList extends Component {
display: "flex", display: "flex",
justifyContent: "flex-start", justifyContent: "flex-start",
alignItems: "center", alignItems: "center",
flexWrap: "wrap", flexWrap: isMobile ? "wrap" : "nowrap",
overflowX: "visible", overflowX: "visible",
flexDirection: isMobile ? "column" : "row", flexDirection: isMobile ? "column" : "row",
py: 0.5, // Add vertical padding to prevent border clipping py: 0.5, // Add vertical padding to prevent border clipping
@@ -197,7 +197,7 @@ class CategoryList extends Component {
aria-label="Zur Startseite" aria-label="Zur Startseite"
onClick={isMobile ? this.handleMobileCategoryClick : undefined} onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{ sx={{
fontSize: "0.75rem", fontSize: "0.85rem",
textTransform: "none", textTransform: "none",
whiteSpace: "nowrap", whiteSpace: "nowrap",
opacity: 0.9, opacity: 0.9,
@@ -275,7 +275,7 @@ class CategoryList extends Component {
aria-label="Neuheiten" aria-label="Neuheiten"
onClick={isMobile ? this.handleMobileCategoryClick : undefined} onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{ sx={{
fontSize: "0.75rem", fontSize: "0.85rem",
textTransform: "none", textTransform: "none",
whiteSpace: "nowrap", whiteSpace: "nowrap",
opacity: 0.9, opacity: 0.9,
@@ -353,7 +353,7 @@ class CategoryList extends Component {
aria-label={this.props.t ? this.props.t('navigation.soon') : 'Demnächst'} aria-label={this.props.t ? this.props.t('navigation.soon') : 'Demnächst'}
onClick={isMobile ? this.handleMobileCategoryClick : undefined} onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{ sx={{
fontSize: "0.75rem", fontSize: "0.85rem",
textTransform: "none", textTransform: "none",
whiteSpace: "nowrap", whiteSpace: "nowrap",
opacity: 0.9, opacity: 0.9,
@@ -435,7 +435,7 @@ class CategoryList extends Component {
size="small" size="small"
onClick={isMobile ? this.handleMobileCategoryClick : undefined} onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{ sx={{
fontSize: "0.75rem", fontSize: "0.85rem",
textTransform: "none", textTransform: "none",
whiteSpace: "nowrap", whiteSpace: "nowrap",
opacity: 0.9, opacity: 0.9,
@@ -506,7 +506,7 @@ class CategoryList extends Component {
alignItems: "center", alignItems: "center",
height: "33px", // Match small button height height: "33px", // Match small button height
px: 1, px: 1,
fontSize: "0.75rem", fontSize: "0.85rem",
opacity: 0.9, opacity: 0.9,
}} }}
> >
@@ -522,7 +522,7 @@ class CategoryList extends Component {
aria-label={this.props.t ? this.props.t('navigation.konfiguratorAria') : 'Zum Konfigurator'} aria-label={this.props.t ? this.props.t('navigation.konfiguratorAria') : 'Zum Konfigurator'}
onClick={isMobile ? this.handleMobileCategoryClick : undefined} onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{ sx={{
fontSize: "0.75rem", fontSize: "0.85rem",
textTransform: "none", textTransform: "none",
whiteSpace: "nowrap", whiteSpace: "nowrap",
opacity: 0.9, opacity: 0.9,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ export default {
"thcTest": "THC test", "thcTest": "THC test",
"address1": "Trachenberger Street 14", "address1": "Trachenberger Street 14",
"address2": "01129 Dresden", "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í", "selectSeedRate": "Vyberte semeno, klikněte na hodnocení",
"outdoorSeason": "Začíná venkovní sezóna" "outdoorSeason": "Začíná venkovní sezóna"
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ export default {
"thcTest": "THC Test", // THC Test "thcTest": "THC Test", // THC Test
"address1": "Trachenberger Street 14", // Trachenberger Straße 14 "address1": "Trachenberger Street 14", // Trachenberger Straße 14
"address2": "01129 Dresden", // 01129 Dresden "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 "selectSeedRate": "Choose seed, click rate", // Wähle Seed aus, klicke Bewerten
"outdoorSeason": "The outdoor season begins" // Die Outdoorsaison beginnt "outdoorSeason": "The outdoor season begins" // Die Outdoorsaison beginnt
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ export default {
"thcTest": "THC teszt", "thcTest": "THC teszt",
"address1": "Trachenberger utca 14", "address1": "Trachenberger utca 14",
"address2": "01129 Dresden", "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", "selectSeedRate": "Válassz magot, kattints az értékelésre",
"outdoorSeason": "Kezdődik a szabadtéri szezon" "outdoorSeason": "Kezdődik a szabadtéri szezon"
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,12 @@
export default { export default {
"home": "Acasă", "home": "Acasă",
"konfiguratorAria": "Mergi la Configurator",
"new": "Noutăți", "new": "Noutăți",
"soon": "În curând", "soon": "În curând",
"aktionen": "Promoții", "aktionen": "Promoții",
"filiale": "Magazin", "filiale": "Magazin",
"categories": "Categorii", "categories": "Categorii",
"categoriesOpen": "Deschide categorii", "categoriesOpen": "Deschide categoriile",
"categoriesClose": "Închide categorii", "categoriesClose": "Închide categoriile",
"otherCategories": "Alte categorii" "otherCategories": "Alte categorii"
}; };

View File

@@ -6,7 +6,7 @@ export default {
"thcTest": "Test THC", "thcTest": "Test THC",
"address1": "Trachenberger Street 14", "address1": "Trachenberger Street 14",
"address2": "01129 Dresden", "address2": "01129 Dresden",
"showUsPhoto": "Arată-ne cea mai frumoasă fotografie a ta", "buildYourSet": "Construiește-ți setul",
"selectSeedRate": "Alege sămânța, apasă pe evaluare", "selectSeedRate": "Alege sămânța, apasă pe evaluare",
"outdoorSeason": "Sezonul outdoor începe" "outdoorSeason": "Sezonul outdoor începe"
}; };

View File

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

View File

@@ -1,7 +1,8 @@
export default { export default {
"home": "Главная", "home": "Главная",
"konfiguratorAria": "Перейти к конфигуратору",
"new": "Новинки", "new": "Новинки",
"soon": "Скоро", "soon": "Скоро в продаже",
"aktionen": "Акции", "aktionen": "Акции",
"filiale": "Магазин", "filiale": "Магазин",
"categories": "Категории", "categories": "Категории",

View File

@@ -6,7 +6,7 @@ export default {
"thcTest": "Тест THC", "thcTest": "Тест THC",
"address1": "Trachenberger Street 14", "address1": "Trachenberger Street 14",
"address2": "01129 Dresden", "address2": "01129 Dresden",
"showUsPhoto": "Покажите нам ваше самое красивое фото", "buildYourSet": "Соберите свой комплект",
"selectSeedRate": "Выберите семя, нажмите оценить", "selectSeedRate": "Выберите семя, нажмите оценить",
"outdoorSeason": "Начинается сезон на открытом воздухе" "outdoorSeason": "Начинается сезон на открытом воздухе"
}; };

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "na stránku", "perPage": "na stránku",
"availability": "Dostupnosť", "availability": "Dostupnosť",
"manufacturer": "Výrobca", "manufacturer": "Výrobca",
"all": "Všetko" "all": "Všetko",
"notifyNewArticles": "Upozorni ma na nové produkty",
"notifyNewArticlesBrowserUnsupported": "Váš prehliadač nepodporuje push notifikácie."
}; };

View File

@@ -1,11 +1,12 @@
export default { export default {
"home": "Domov", "home": "Domov",
"konfiguratorAria": "Prejsť do konfigurátora",
"new": "Novinky", "new": "Novinky",
"soon": "Čoskoro", "soon": "Čoskoro",
"aktionen": "Akcie", "aktionen": "Akcie",
"filiale": "Predajňa", "filiale": "Predajňa",
"categories": "Kategórie", "categories": "Kategórie",
"categoriesOpen": "Otvoriť kategórie", "categoriesOpen": "Otvoriť kategórie",
"categoriesClose": "Zavrieť kategórie", "categoriesClose": "Zatvoriť kategórie",
"otherCategories": "Ďalšie kategórie" "otherCategories": "Ostatné kategórie"
}; };

View File

@@ -6,7 +6,7 @@ export default {
"thcTest": "THC test", "thcTest": "THC test",
"address1": "Trachenberger Street 14", "address1": "Trachenberger Street 14",
"address2": "01129 Dresden", "address2": "01129 Dresden",
"showUsPhoto": "Ukážte nám svoju najkrajšiu fotku", "buildYourSet": "Zostavte si svoj set",
"selectSeedRate": "Vyberte semeno, kliknite na hodnotenie", "selectSeedRate": "Vyberte semeno, kliknite na hodnotenie",
"outdoorSeason": "Začína vonkajšia sezóna" "outdoorSeason": "Začína vonkajšia sezóna"
}; };

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "na stran", "perPage": "na stran",
"availability": "Razpoložljivost", "availability": "Razpoložljivost",
"manufacturer": "Proizvajalec", "manufacturer": "Proizvajalec",
"all": "Vse" "all": "Vse",
"notifyNewArticles": "Obvesti me o novih izdelkih",
"notifyNewArticlesBrowserUnsupported": "Vaš brskalnik ne podpira potisnih obvestil."
}; };

View File

@@ -1,7 +1,8 @@
export default { export default {
"home": "Domov", "home": "Domov",
"new": "Nove prihajajoče", "konfiguratorAria": "Pojdi v konfigurator",
"soon": "Kmalu prihaja", "new": "Nove izdelke",
"soon": "Kmalu na voljo",
"aktionen": "Promocije", "aktionen": "Promocije",
"filiale": "Trgovina", "filiale": "Trgovina",
"categories": "Kategorije", "categories": "Kategorije",

View File

@@ -6,7 +6,7 @@ export default {
"thcTest": "THC test", "thcTest": "THC test",
"address1": "Trachenberger Straße 14", "address1": "Trachenberger Straße 14",
"address2": "01129 Dresden", "address2": "01129 Dresden",
"showUsPhoto": "Pokaži nam svojo najlepšo fotografijo", "buildYourSet": "Sestavi svoj set",
"selectSeedRate": "Izberi seme, klikni oceno", "selectSeedRate": "Izberi seme, klikni oceno",
"outdoorSeason": "Zunanja sezona se začenja" "outdoorSeason": "Zunanja sezona se začenja"
}; };

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "për faqe", "perPage": "për faqe",
"availability": "Disponueshmëria", "availability": "Disponueshmëria",
"manufacturer": "Prodhuesi", "manufacturer": "Prodhuesi",
"all": "Të gjitha" "all": "Të gjitha",
"notifyNewArticles": "Njoftom për artikuj të rinj",
"notifyNewArticlesBrowserUnsupported": "Shfletuesi juaj nuk mbështet njoftimet push."
}; };

View File

@@ -1,5 +1,6 @@
export default { export default {
"home": "Shtëpia", "home": "Kreu",
"konfiguratorAria": "Shko te Konfiguratori",
"new": "Arritjet e reja", "new": "Arritjet e reja",
"soon": "Së shpejti", "soon": "Së shpejti",
"aktionen": "Promocione", "aktionen": "Promocione",
@@ -7,5 +8,5 @@ export default {
"categories": "Kategoritë", "categories": "Kategoritë",
"categoriesOpen": "Hap kategoritë", "categoriesOpen": "Hap kategoritë",
"categoriesClose": "Mbyll kategoritë", "categoriesClose": "Mbyll kategoritë",
"otherCategories": "Kategori të tjera" "otherCategories": "Kategoritë e tjera"
}; };

View File

@@ -6,7 +6,7 @@ export default {
"thcTest": "Test THC", "thcTest": "Test THC",
"address1": "Rruga Trachenberger 14", "address1": "Rruga Trachenberger 14",
"address2": "01129 Dresden", "address2": "01129 Dresden",
"showUsPhoto": "Na tregoni foton tuaj më të bukur", "buildYourSet": "Ndërto setin tënd",
"selectSeedRate": "Zgjidhni farën, klikoni vlerësimin", "selectSeedRate": "Zgjidhni farën, klikoni vlerësimin",
"outdoorSeason": "Fillon sezoni i jashtëm" "outdoorSeason": "Fillon sezoni i jashtëm"
}; };

View File

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

View File

@@ -1,6 +1,7 @@
export default { export default {
"home": "Početna", "home": "Početna",
"new": "Nove kolekcije", "konfiguratorAria": "Idi na Konfigurator",
"new": "Novi artikli",
"soon": "Uskoro", "soon": "Uskoro",
"aktionen": "Promocije", "aktionen": "Promocije",
"filiale": "Prodavnica", "filiale": "Prodavnica",

View File

@@ -6,7 +6,7 @@ export default {
"thcTest": "THC test", "thcTest": "THC test",
"address1": "Trachenberger ulica 14", "address1": "Trachenberger ulica 14",
"address2": "01129 Dresden", "address2": "01129 Dresden",
"showUsPhoto": "Pokažite nam svoju najlepšu fotografiju", "buildYourSet": "Sastavite svoj set",
"selectSeedRate": "Izaberite seme, kliknite ocenu", "selectSeedRate": "Izaberite seme, kliknite ocenu",
"outdoorSeason": "Počinje sezona na otvorenom" "outdoorSeason": "Počinje sezona na otvorenom"
}; };

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "per sida", "perPage": "per sida",
"availability": "Tillgänglighet", "availability": "Tillgänglighet",
"manufacturer": "Tillverkare", "manufacturer": "Tillverkare",
"all": "Alla" "all": "Alla",
"notifyNewArticles": "Meddela mig om nya artiklar",
"notifyNewArticlesBrowserUnsupported": "Din webbläsare stöder inte push-notiser."
}; };

View File

@@ -1,6 +1,7 @@
export default { export default {
"home": "Hem", "home": "Hem",
"new": "Nya produkter", "konfiguratorAria": "Gå till konfiguratorn",
"new": "Nyheter",
"soon": "Kommer snart", "soon": "Kommer snart",
"aktionen": "Kampanjer", "aktionen": "Kampanjer",
"filiale": "Butik", "filiale": "Butik",

View File

@@ -6,7 +6,7 @@ export default {
"thcTest": "THC-test", "thcTest": "THC-test",
"address1": "Trachenberger Street 14", "address1": "Trachenberger Street 14",
"address2": "01129 Dresden", "address2": "01129 Dresden",
"showUsPhoto": "Visa oss ditt vackraste foto", "buildYourSet": "Sätt ihop ditt set",
"selectSeedRate": "Välj frö, klicka på betygsätt", "selectSeedRate": "Välj frö, klicka på betygsätt",
"outdoorSeason": "Utomhussäsongen börjar" "outdoorSeason": "Utomhussäsongen börjar"
}; };

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "sayfa başına", "perPage": "sayfa başına",
"availability": "Stok Durumu", "availability": "Stok Durumu",
"manufacturer": "Üretici", "manufacturer": "Üretici",
"all": "Tümü" "all": "Tümü",
"notifyNewArticles": "Yeni ürünlerden haberdar et",
"notifyNewArticlesBrowserUnsupported": "Tarayıcınız anlık bildirimleri desteklemiyor."
}; };

View File

@@ -1,7 +1,8 @@
export default { export default {
"home": "Ana Sayfa", "home": "Ana Sayfa",
"konfiguratorAria": "Yapılandırıcıya Git",
"new": "Yeni Gelenler", "new": "Yeni Gelenler",
"soon": "Çok Yakında", "soon": "Yakında",
"aktionen": "Kampanyalar", "aktionen": "Kampanyalar",
"filiale": "Mağaza", "filiale": "Mağaza",
"categories": "Kategoriler", "categories": "Kategoriler",

View File

@@ -6,7 +6,7 @@ export default {
"thcTest": "THC Testi", "thcTest": "THC Testi",
"address1": "Trachenberger Street 14", "address1": "Trachenberger Street 14",
"address2": "01129 Dresden", "address2": "01129 Dresden",
"showUsPhoto": "Bize en güzel fotoğrafınızı gösterin", "buildYourSet": "Setini bir araya getir",
"selectSeedRate": "Tohum seçin, puan verin", "selectSeedRate": "Tohum seçin, puan verin",
"outdoorSeason": "Dış mekân sezonu başlıyor" "outdoorSeason": "Dış mekân sezonu başlıyor"
}; };

View File

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

View File

@@ -1,5 +1,6 @@
export default { export default {
"home": "Головна", "home": "Головна",
"konfiguratorAria": "Перейти до конфігуратора",
"new": "Новинки", "new": "Новинки",
"soon": "Незабаром", "soon": "Незабаром",
"aktionen": "Акції", "aktionen": "Акції",

View File

@@ -6,7 +6,7 @@ export default {
"thcTest": "Тест THC", "thcTest": "Тест THC",
"address1": "Trachenberger Street 14", "address1": "Trachenberger Street 14",
"address2": "01129 Dresden", "address2": "01129 Dresden",
"showUsPhoto": "Покажіть нам ваше найкрасивіше фото", "buildYourSet": "Зберіть свій комплект",
"selectSeedRate": "Оберіть насіння, натисніть оцінити", "selectSeedRate": "Оберіть насіння, натисніть оцінити",
"outdoorSeason": "Починається сезон вирощування на відкритому повітрі" "outdoorSeason": "Починається сезон вирощування на відкритому повітрі"
}; };

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "每页", "perPage": "每页",
"availability": "库存情况", "availability": "库存情况",
"manufacturer": "制造商", "manufacturer": "制造商",
"all": "全部" "all": "全部",
"notifyNewArticles": "新商品上架时通知我",
"notifyNewArticlesBrowserUnsupported": "您的浏览器不支持推送通知。"
}; };

View File

@@ -1,6 +1,7 @@
export default { export default {
"home": "首页", "home": "首页",
"new": "新品", "konfiguratorAria": "前往配置器",
"new": "新品上架",
"soon": "即将推出", "soon": "即将推出",
"aktionen": "促销活动", "aktionen": "促销活动",
"filiale": "门店", "filiale": "门店",

View File

@@ -6,7 +6,7 @@ export default {
"thcTest": "THC 测试", "thcTest": "THC 测试",
"address1": "Trachenberger 街 14", "address1": "Trachenberger 街 14",
"address2": "01129 德累斯顿", "address2": "01129 德累斯顿",
"showUsPhoto": "展示你最美的照片", "buildYourSet": "组合你的套装",
"selectSeedRate": "选择种子,点击评分", "selectSeedRate": "选择种子,点击评分",
"outdoorSeason": "户外季节开始了" "outdoorSeason": "户外季节开始了"
}; };

View File

@@ -41,7 +41,7 @@
body { body {
margin: 0; margin: 0;
padding: 0; padding: 0;
font-family: Roboto, Helvetica, Arial, sans-serif; font-family: 'Outfit Variable', Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
overflow-y: scroll; /* Always show vertical scrollbar */ overflow-y: scroll; /* Always show vertical scrollbar */

View File

@@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import "@fontsource-variable/outfit";
import "./index.css"; import "./index.css";
import App from "./App.js"; import App from "./App.js";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";

168
src/pages/HerstellerPage.js Normal file
View File

@@ -0,0 +1,168 @@
import React, { Component } from 'react';
import { Link } from 'react-router-dom';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import LegalPage from './LegalPage.js';
import { withI18n } from '../i18n/withTranslation.js';
class HerstellerPage extends Component {
constructor(props) {
super(props);
this.state = {
loading: true,
manufacturers: [],
};
this._isMounted = false;
this._objectUrls = [];
}
componentDidMount() {
this._isMounted = true;
this.loadManufacturers();
}
componentWillUnmount() {
this._isMounted = false;
for (const url of this._objectUrls) {
URL.revokeObjectURL(url);
}
this._objectUrls = [];
}
loadManufacturers = () => {
// Check if manufacturers data is already cached from prerendering
if (window.herstellerImages && Array.isArray(window.herstellerImages) && window.herstellerImages.length > 0) {
if (!this._isMounted) return;
const manufacturers = window.herstellerImages
.filter(m => m.imageBuffer)
.map(m => {
const blob = new Blob([m.imageBuffer], { type: 'image/avif' });
const src = URL.createObjectURL(blob);
this._objectUrls.push(src);
return {
id: m.id,
name: m.name || '',
slug: m.slug || '',
src,
};
})
.sort((a, b) => (a.name || '').localeCompare(b.name || '', undefined, { sensitivity: 'base' }));
this.setState({
loading: false,
manufacturers,
});
return;
}
// Fallback: fetch from socket if no cached data
window.socketManager.emit('getHerstellerImages', {}, (res) => {
if (!this._isMounted) return;
if (!res?.success || !Array.isArray(res.manufacturers)) {
this.setState({ loading: false, manufacturers: [] });
return;
}
const manufacturers = res.manufacturers
.filter(m => m.imageBuffer)
.map(m => {
const blob = new Blob([m.imageBuffer], { type: 'image/avif' });
const src = URL.createObjectURL(blob);
this._objectUrls.push(src);
return {
id: m.id,
name: m.name || '',
slug: m.slug || '',
src,
};
})
.sort((a, b) => (a.name || '').localeCompare(b.name || '', undefined, { sensitivity: 'base' }));
this.setState({
loading: false,
manufacturers,
});
});
};
renderManufacturerGrid = () => {
const { manufacturers } = this.state;
if (!manufacturers.length) {
return null
}
return (
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
gap: 2,
}}
>
{manufacturers.map((manufacturer) => (
<Paper
key={manufacturer.id}
component={Link}
to={`/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,
},
}}
>
<img
src={manufacturer.src}
alt={manufacturer.name}
draggable={false}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
display: 'block',
}}
/>
</Paper>
))}
</Box>
);
};
render() {
const { t } = this.props;
const { loading } = this.state;
const content = (
<Box>
{loading ? null : (
this.renderManufacturerGrid()
)}
</Box>
);
return <LegalPage title={t ? t('product.manufacturer') : 'Hersteller'} content={content} />;
}
}
export default withI18n()(HerstellerPage);

View File

@@ -0,0 +1,238 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate, useParams, Navigate } from 'react-router-dom';
import {
Alert,
Box,
Button,
CircularProgress,
Container,
Paper,
Typography,
} from '@mui/material';
import LoginComponent from '../components/LoginComponent.js';
const LinkTelegramPage = () => {
const location = useLocation();
const navigate = useNavigate();
const params = useParams();
const idFromQuery = useMemo(() => {
const urlParams = new URLSearchParams(location.search);
return urlParams.get('id');
}, [location.search]);
const id = idFromQuery || params.id || null;
const [user, setUser] = useState(null);
const [authToken, setAuthToken] = useState(() =>
typeof window === 'undefined' ? null : sessionStorage.getItem('authToken')
);
const [loadingAuth, setLoadingAuth] = useState(true);
const [showLogin, setShowLogin] = useState(false);
const [linking, setLinking] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const attemptedKeyRef = useRef('');
const handleLoginClose = () => {
setShowLogin(false);
const storedUser = sessionStorage.getItem('user');
if (!storedUser) {
navigate('/', { replace: true });
}
};
useEffect(() => {
let isMounted = true;
const loadUserFromSession = () => {
const storedUser = sessionStorage.getItem('user');
if (!storedUser) return null;
try {
return JSON.parse(storedUser);
} catch (e) {
console.error('Error parsing user from sessionStorage:', e);
return null;
}
};
const checkAuth = () => {
const token = sessionStorage.getItem('authToken');
setAuthToken(token);
const userData = loadUserFromSession();
if (userData) {
setUser(userData);
setShowLogin(false);
setLoadingAuth(false);
return;
}
// If we have a token but no user yet, silently restore session via verifyToken.
if (token && window.socketManager) {
setLoadingAuth(true);
window.socketManager.emit('verifyToken', { token }, (res) => {
if (!isMounted) return;
if (res?.success && res?.user) {
try {
sessionStorage.setItem('user', JSON.stringify(res.user));
} catch (e) {
console.error('Failed to persist verified user:', e);
}
setUser(res.user);
setShowLogin(false);
} else {
setUser(null);
setShowLogin(true);
}
setLoadingAuth(false);
});
return;
}
// No user + no token => need login.
setUser(null);
setShowLogin(true);
setLoadingAuth(false);
};
checkAuth();
const onUserLoggedIn = () => checkAuth();
window.addEventListener('userLoggedIn', onUserLoggedIn);
const handleStorageChange = (e) => {
if (e.key === 'user' && !e.newValue) {
setShowLogin(true);
setUser(null);
}
if (e.key === 'authToken') {
checkAuth();
}
};
window.addEventListener('storage', handleStorageChange);
return () => {
isMounted = false;
window.removeEventListener('userLoggedIn', onUserLoggedIn);
window.removeEventListener('storage', handleStorageChange);
};
}, []);
useEffect(() => {
if (!user) return;
if (!id) {
setError('Missing Telegram id.');
setSuccess(false);
return;
}
if (!window.socketManager) return;
const attemptKey = `${user?.id || 'anon'}:${id}:${authToken || ''}`;
if (attemptedKeyRef.current === attemptKey) return;
attemptedKeyRef.current = attemptKey;
if (!authToken) {
setError('Not authenticated (missing auth token).');
setSuccess(false);
setShowLogin(true);
return;
}
setLinking(true);
setError('');
setSuccess(false);
// 1) Verify token so server-side socket flags are set.
window.socketManager.emit('verifyToken', { token: authToken }, (verifyRes) => {
if (!verifyRes?.success) {
setLinking(false);
setError(verifyRes?.message || 'Not authenticated.');
setShowLogin(true);
return;
}
// 2) Link Telegram id after authentication.
window.socketManager.emit('linkTelegram', { id }, (linkRes) => {
setLinking(false);
if (linkRes?.success) {
setSuccess(true);
setError('');
return;
}
setSuccess(false);
setError(
linkRes?.error || linkRes?.message || 'Failed to link Telegram.'
);
});
});
}, [id, user, authToken]);
if (showLogin) {
return <LoginComponent open={showLogin} handleClose={handleLoginClose} location={location} />;
}
if (loadingAuth) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 5 }}>
<CircularProgress />
</Box>
);
}
if (!user) {
return <Navigate to="/" replace />;
}
return (
<Container maxWidth="sm" sx={{ mt: 8, mb: 4 }}>
<Paper elevation={3} sx={{ p: 4 }}>
<Typography component="h1" variant="h5" gutterBottom>
Telegram verknüpfen
</Typography>
{error ? (
<Alert severity="error" sx={{ mt: 2 }}>
{error}
</Alert>
) : null}
{success ? (
<Alert severity="success" sx={{ mt: 2 }}>
Telegram wurde erfolgreich verknüpft.
</Alert>
) : null}
{(!success && !error) || linking ? (
<Box sx={{ mt: 3, display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography variant="body2" color="text.secondary">
Bitte warten, wir verifizieren und verknüpfen dein Telegram.
</Typography>
{linking ? <CircularProgress /> : null}
</Box>
) : null}
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end' }}>
<Button
variant="contained"
onClick={() => navigate('/profile#settings')}
sx={{ bgcolor: '#2e7d32', '&:hover': { bgcolor: '#1b5e20' } }}
disabled={linking}
>
Zurück zum Profil
</Button>
</Box>
</Paper>
</Container>
);
};
export default LinkTelegramPage;

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