Compare commits
24 Commits
de8e59f1bb
...
live
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66a1efd87b | ||
|
|
2c0b7aa84d | ||
|
|
a56377a1fd | ||
|
|
468eb1c3ae | ||
|
|
e699a8003f | ||
|
|
b5256d6597 | ||
|
|
18c528302d | ||
|
|
9054c8d2fd | ||
|
|
8bce10e61b | ||
|
|
2540d00c8e | ||
|
|
52c9888a6a | ||
|
|
ab55761411 | ||
|
|
5e5a733d36 | ||
|
|
36360df648 | ||
|
|
21d86565f1 | ||
|
|
c503de3a11 | ||
|
|
2ced182570 | ||
|
|
52c62541b0 | ||
|
|
7202c43dfa | ||
|
|
5b7f0f788c | ||
|
|
47ed2ec231 | ||
|
|
188c883450 | ||
|
|
ba66b82b2b | ||
|
|
defe3c9521 |
1
docs/README.md
Normal file
1
docs/README.md
Normal file
@@ -0,0 +1 @@
|
||||
src/components/MainPageLayout.js is the Main gues homepage.
|
||||
@@ -83,7 +83,7 @@ server {
|
||||
default_type application/xml;
|
||||
}
|
||||
|
||||
location ~ ^/(datenschutz|impressum|batteriegesetzhinweise|widerrufsrecht|sitemap|agb|Kategorien|Konfigurator|404|profile|resetPassword|thc-test|filiale|aktionen|presseverleih|payment/success)(/|$) {
|
||||
location ~ ^/(datenschutz|impressum|batteriegesetzhinweise|widerrufsrecht|sitemap|agb|Kategorien|Konfigurator|404|profile|resetPassword|thc-test|linkTelegram|filiale|aktionen|presseverleih|payment/success)(/|$) {
|
||||
types {}
|
||||
default_type text/html;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,30 @@ import babelParser from '@babel/eslint-parser';
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ['**/*.cjs'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'commonjs',
|
||||
globals: {
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': 'warn',
|
||||
//'no-console': 'warn',
|
||||
'no-debugger': 'warn',
|
||||
'no-alert': 'warn',
|
||||
'no-unused-expressions': 'warn',
|
||||
'no-var': 'warn',
|
||||
'prefer-const': 'warn',
|
||||
'no-trailing-spaces': 'warn',
|
||||
'eqeqeq': ['warn', 'always'],
|
||||
'no-empty': 'warn',
|
||||
'no-eval': 'warn',
|
||||
'no-script-url': 'warn',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
languageOptions: {
|
||||
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@fontsource-variable/outfit": "^5.2.8",
|
||||
"@mui/icons-material": "^7.1.1",
|
||||
"@mui/material": "^7.1.1",
|
||||
"@stripe/react-stripe-js": "^3.7.0",
|
||||
@@ -2222,6 +2223,15 @@
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/outfit": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/outfit/-/outfit-5.2.8.tgz",
|
||||
"integrity": "sha512-4oUDCZx/Tcz6HZP423w/niqEH31Gks5IsqHV2ZZz1qKHaVIZdj2f0/S1IK2n8jl6Xo0o3N+3RjNHlV9R73ozQA==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@fontsource-variable/outfit": "^5.2.8",
|
||||
"@mui/icons-material": "^7.1.1",
|
||||
"@mui/material": "^7.1.1",
|
||||
"@stripe/react-stripe-js": "^3.7.0",
|
||||
|
||||
@@ -66,12 +66,7 @@ const renderSingleProduct = async (productSeoName) => {
|
||||
const socketUrl = "http://127.0.0.1:9303";
|
||||
console.log(`🔌 Connecting to socket at ${socketUrl}...`);
|
||||
|
||||
const socket = io(socketUrl, {
|
||||
path: "/socket.io/",
|
||||
transports: ["websocket"],
|
||||
reconnection: false,
|
||||
timeout: 10000,
|
||||
});
|
||||
const socket = io(socketUrl, config.socketIoClientOptions);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
|
||||
@@ -125,11 +125,14 @@ const {
|
||||
const {
|
||||
generateProductMetaTags,
|
||||
generateProductJsonLd,
|
||||
generateCategoryMetaTags,
|
||||
generateCategoryJsonLd,
|
||||
generateHomepageMetaTags,
|
||||
generateHomepageJsonLd,
|
||||
generateSitemapJsonLd,
|
||||
generateKonfiguratorMetaTags,
|
||||
generateHerstellerMetaTags,
|
||||
generateHerstellerJsonLd,
|
||||
generateXmlSitemap,
|
||||
generateRobotsTxt,
|
||||
generateProductsXml,
|
||||
@@ -141,6 +144,7 @@ const {
|
||||
const {
|
||||
fetchCategoryProducts,
|
||||
fetchProductDetails,
|
||||
fetchManufacturers,
|
||||
saveProductImages,
|
||||
saveCategoryImages,
|
||||
} = require("./prerender/data-fetching.cjs");
|
||||
@@ -160,18 +164,14 @@ const Widerrufsrecht = require("./src/pages/Widerrufsrecht.js").default;
|
||||
const Sitemap = require("./src/pages/Sitemap.js").default;
|
||||
const PrerenderSitemap = require("./src/PrerenderSitemap.js").default;
|
||||
const PrerenderCategoriesPage = require("./src/PrerenderCategoriesPage.js").default;
|
||||
const PrerenderHerstellerPage = require("./src/PrerenderHerstellerPage.js").default;
|
||||
const AGB = require("./src/pages/AGB.js").default;
|
||||
const NotFound404 = require("./src/pages/NotFound404.js").default;
|
||||
|
||||
// Worker function for parallel product rendering
|
||||
const renderProductWorker = async (productSeoNames, workerId, progressCallback, categoryMap = {}) => {
|
||||
const socketUrl = "http://127.0.0.1:9303";
|
||||
const workerSocket = io(socketUrl, {
|
||||
path: "/socket.io/",
|
||||
transports: ["websocket"],
|
||||
reconnection: false,
|
||||
timeout: 10000,
|
||||
});
|
||||
const workerSocket = io(socketUrl, config.socketIoClientOptions);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let processedCount = 0;
|
||||
@@ -380,6 +380,29 @@ const renderApp = async (categoryData, socket) => {
|
||||
global.categoryCache = {};
|
||||
}
|
||||
|
||||
// Fetch manufacturers data for Hersteller page
|
||||
let manufacturerData = null;
|
||||
console.log("🏭 [renderApp] Starting manufacturer fetch...");
|
||||
console.log("🏭 [renderApp] socket exists:", !!socket);
|
||||
console.log("🏭 [renderApp] socket.connected:", socket ? socket.connected : "N/A");
|
||||
|
||||
if (!socket) {
|
||||
console.error("🏭 [renderApp] FATAL: No socket - cannot fetch manufacturers!");
|
||||
} else if (!socket.connected) {
|
||||
console.error("🏭 [renderApp] FATAL: Socket not connected - cannot fetch manufacturers!");
|
||||
} else {
|
||||
try {
|
||||
console.log("🏭 [renderApp] Calling fetchManufacturers...");
|
||||
manufacturerData = await fetchManufacturers(socket);
|
||||
console.log("🏭 [renderApp] ✅ Fetched " + manufacturerData.length + " manufacturers");
|
||||
} catch (error) {
|
||||
console.error("🏭 [renderApp] ❌ Failed to fetch manufacturers:", error.message);
|
||||
manufacturerData = [];
|
||||
}
|
||||
}
|
||||
|
||||
console.log("🏭 [renderApp] Final manufacturerData:", manufacturerData ? (manufacturerData.length + " items") : "null");
|
||||
|
||||
// Helper to call renderPage with config
|
||||
const render = (
|
||||
component,
|
||||
@@ -387,8 +410,10 @@ const renderApp = async (categoryData, socket) => {
|
||||
filename,
|
||||
description,
|
||||
metaTags = "",
|
||||
needsRouter = false
|
||||
needsRouter = false,
|
||||
manufacturerDataForPage = null
|
||||
) => {
|
||||
console.log(" 📦 [render helper] Calling renderPage for", filename, "with manufacturerData:", manufacturerDataForPage ? (manufacturerDataForPage.length + " items") : "null");
|
||||
return renderPage(
|
||||
component,
|
||||
location,
|
||||
@@ -396,7 +421,10 @@ const renderApp = async (categoryData, socket) => {
|
||||
description,
|
||||
metaTags,
|
||||
needsRouter,
|
||||
config
|
||||
config,
|
||||
false, // suppressLogs
|
||||
null, // productData
|
||||
manufacturerDataForPage // manufacturerData - 10th parameter!
|
||||
);
|
||||
};
|
||||
|
||||
@@ -429,6 +457,11 @@ const renderApp = async (categoryData, socket) => {
|
||||
const resetPasswordPath = path.resolve(__dirname, config.outputDir, "resetPassword");
|
||||
fs.copyFileSync(indexPath, resetPasswordPath);
|
||||
console.log(`✅ Copied index.html to ${resetPasswordPath}`);
|
||||
|
||||
// Copy index.html to linkTelegram (no file extension) for SPA routing
|
||||
const linkTelegramPath = path.resolve(__dirname, config.outputDir, "linkTelegram");
|
||||
fs.copyFileSync(indexPath, linkTelegramPath);
|
||||
console.log(`✅ Copied index.html to ${linkTelegramPath}`);
|
||||
}
|
||||
|
||||
// Render static pages
|
||||
@@ -473,6 +506,13 @@ const renderApp = async (categoryData, socket) => {
|
||||
description: "Categories page",
|
||||
needsCategoryData: true,
|
||||
},
|
||||
{
|
||||
component: PrerenderHerstellerPage,
|
||||
path: "/Hersteller",
|
||||
filename: "Hersteller",
|
||||
description: "Hersteller page",
|
||||
needsManufacturerData: true,
|
||||
},
|
||||
{ component: AGB, path: "/agb", filename: "agb", description: "AGB page" },
|
||||
{ component: NotFound404, path: "/404", filename: "404", description: "404 Not Found page" },
|
||||
{
|
||||
@@ -491,8 +531,17 @@ const renderApp = async (categoryData, socket) => {
|
||||
|
||||
let staticPagesRendered = 0;
|
||||
for (const page of staticPages) {
|
||||
// Pass category data as props if needed
|
||||
const pageProps = page.needsCategoryData ? { categoryData } : null;
|
||||
// Pass category and manufacturer data as props if needed
|
||||
let pageProps = null;
|
||||
if (page.needsCategoryData || page.needsManufacturerData) {
|
||||
pageProps = {};
|
||||
if (page.needsCategoryData) {
|
||||
pageProps.categoryData = categoryData;
|
||||
}
|
||||
if (page.needsManufacturerData) {
|
||||
pageProps.manufacturerData = manufacturerData;
|
||||
}
|
||||
}
|
||||
const pageComponent = React.createElement(page.component, pageProps);
|
||||
let metaTags = "";
|
||||
|
||||
@@ -508,13 +557,25 @@ const renderApp = async (categoryData, socket) => {
|
||||
metaTags = konfiguratorMetaTags;
|
||||
}
|
||||
|
||||
// Special handling for Hersteller page to include SEO tags
|
||||
if (page.filename === "Hersteller") {
|
||||
const manufacturerCount = manufacturerData ? manufacturerData.length : 0;
|
||||
const herstellerMetaTags = generateHerstellerMetaTags(shopConfig.baseUrl, shopConfig, manufacturerCount);
|
||||
const herstellerJsonLd = generateHerstellerJsonLd(shopConfig.baseUrl, shopConfig);
|
||||
metaTags = herstellerMetaTags + "\n" + herstellerJsonLd;
|
||||
}
|
||||
|
||||
// Pass manufacturerData only for Hersteller page
|
||||
const pageManufacturerData = page.needsManufacturerData ? manufacturerData : null;
|
||||
|
||||
const success = render(
|
||||
pageComponent,
|
||||
page.path,
|
||||
page.filename,
|
||||
page.description,
|
||||
metaTags,
|
||||
true
|
||||
true,
|
||||
pageManufacturerData
|
||||
);
|
||||
if (success) {
|
||||
staticPagesRendered++;
|
||||
@@ -621,19 +682,25 @@ const renderApp = async (categoryData, socket) => {
|
||||
const filename = `Kategorie/${category.seoName}`;
|
||||
const location = `/Kategorie/${category.seoName}`;
|
||||
const description = `Category "${category.name}" (ID: ${category.id})`;
|
||||
const categoryMetaTags = generateCategoryMetaTags(
|
||||
category,
|
||||
shopConfig.baseUrl,
|
||||
shopConfig
|
||||
);
|
||||
const categoryJsonLd = generateCategoryJsonLd(
|
||||
category,
|
||||
productData?.products || [],
|
||||
shopConfig.baseUrl,
|
||||
shopConfig
|
||||
);
|
||||
const combinedCategoryHead = categoryMetaTags + "\n" + categoryJsonLd;
|
||||
|
||||
const success = render(
|
||||
categoryComponent,
|
||||
location,
|
||||
filename,
|
||||
description,
|
||||
categoryJsonLd,
|
||||
combinedCategoryHead,
|
||||
true
|
||||
);
|
||||
if (success) {
|
||||
@@ -863,12 +930,7 @@ const fetchCategoryDataAndRender = () => {
|
||||
process.exit(1);
|
||||
}, 15000);
|
||||
|
||||
const socket = io(socketUrl, {
|
||||
path: "/socket.io/",
|
||||
transports: ["websocket"],
|
||||
reconnection: false,
|
||||
timeout: 10000,
|
||||
});
|
||||
const socket = io(socketUrl, config.socketIoClientOptions);
|
||||
|
||||
socket.on("connect", () => {
|
||||
console.log('Socket connected. Emitting "categoryList"...');
|
||||
|
||||
@@ -69,11 +69,21 @@ const globalCssCollection = new Set();
|
||||
// Get webpack entrypoints
|
||||
const webpackEntrypoints = getWebpackEntrypoints();
|
||||
|
||||
/** Socket.IO client options for prerender scripts: skip backend connection counters (balanced on disconnect). */
|
||||
const socketIoClientOptions = {
|
||||
path: "/socket.io/",
|
||||
transports: ["websocket"],
|
||||
reconnection: false,
|
||||
timeout: 10000,
|
||||
auth: { prerender: true },
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
isProduction,
|
||||
outputDir,
|
||||
getWebpackEntrypoints,
|
||||
globalCss,
|
||||
globalCssCollection,
|
||||
webpackEntrypoints
|
||||
webpackEntrypoints,
|
||||
socketIoClientOptions,
|
||||
};
|
||||
@@ -42,6 +42,7 @@ const fetchCategoryProducts = (socket, categoryId) => {
|
||||
"getCategoryProducts",
|
||||
{
|
||||
full: true,
|
||||
nocount: true,
|
||||
categoryId:
|
||||
categoryId === "neu" || categoryId === "bald"
|
||||
? categoryId
|
||||
@@ -139,6 +140,38 @@ const fetchCategoryImage = (socket, categoryId) => {
|
||||
});
|
||||
};
|
||||
|
||||
const fetchManufacturers = (socket) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error("Timeout fetching manufacturers"));
|
||||
}, 10000);
|
||||
|
||||
socket.emit("getHerstellerImages", {}, (response) => {
|
||||
clearTimeout(timeout);
|
||||
if (response?.success && Array.isArray(response.manufacturers)) {
|
||||
// Filter and format manufacturers similar to HerstellerPage.js
|
||||
const manufacturers = response.manufacturers
|
||||
.filter(m => m.imageBuffer)
|
||||
.map(m => ({
|
||||
id: m.id,
|
||||
name: m.name || '',
|
||||
slug: m.slug || '',
|
||||
imageBuffer: m.imageBuffer,
|
||||
}))
|
||||
.sort((a, b) => (a.name || '').localeCompare(b.name || '', undefined, { sensitivity: 'base' }));
|
||||
|
||||
resolve(manufacturers);
|
||||
} else {
|
||||
reject(
|
||||
new Error(
|
||||
`Invalid manufacturers response: ${JSON.stringify(response)}`
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const saveProductImages = async (socket, products, categoryName, outputDir) => {
|
||||
if (!products || products.length === 0) return;
|
||||
|
||||
@@ -382,6 +415,7 @@ module.exports = {
|
||||
fetchProductDetails,
|
||||
fetchProductImage,
|
||||
fetchCategoryImage,
|
||||
fetchManufacturers,
|
||||
saveProductImages,
|
||||
saveCategoryImages,
|
||||
};
|
||||
|
||||
@@ -18,7 +18,8 @@ const renderPage = (
|
||||
needsRouter = false,
|
||||
config,
|
||||
suppressLogs = false,
|
||||
productData = null
|
||||
productData = null,
|
||||
manufacturerData = null
|
||||
) => {
|
||||
const {
|
||||
isProduction,
|
||||
@@ -171,22 +172,44 @@ const renderPage = (
|
||||
</script>
|
||||
`;
|
||||
|
||||
// @note Create script to populate window.productCache with ONLY the static category tree
|
||||
// @note Create script to populate window.productCache with static category tree and herstellerImages
|
||||
let productCacheScript = '';
|
||||
if (typeof global !== "undefined" && global.window && global.window.categoryCache) {
|
||||
// Only include the static categoryTree_209, not any dynamic data that gets added during rendering
|
||||
const staticCache = {};
|
||||
if (global.window.categoryCache["209_de"]) {
|
||||
staticCache["209_de"] = global.window.categoryCache["209_de"];
|
||||
const hasCategoryCache = typeof global !== "undefined" && global.window && global.window.categoryCache;
|
||||
const hasManufacturerData = manufacturerData && manufacturerData.length > 0;
|
||||
|
||||
console.log(" 📦 [" + filename + "] manufacturerData =", manufacturerData ? (manufacturerData.length + " items") : "null");
|
||||
|
||||
if (hasCategoryCache || hasManufacturerData) {
|
||||
const cacheData = {};
|
||||
|
||||
// Add static categoryTree_209
|
||||
if (hasCategoryCache && global.window.categoryCache["209_de"]) {
|
||||
cacheData["209_de"] = global.window.categoryCache["209_de"];
|
||||
}
|
||||
|
||||
const staticCacheData = JSON.stringify(staticCache);
|
||||
productCacheScript = `
|
||||
<script>
|
||||
// Populate window.categoryCache with static category tree only
|
||||
window.categoryCache = ${staticCacheData};
|
||||
</script>
|
||||
`;
|
||||
// Add herstellerImages
|
||||
if (hasManufacturerData) {
|
||||
cacheData.herstellerImages = manufacturerData;
|
||||
}
|
||||
|
||||
const cacheDataJson = JSON.stringify(cacheData);
|
||||
let extraScripts = '';
|
||||
|
||||
if (hasCategoryCache && cacheData["209_de"]) {
|
||||
const categoryCacheJson = JSON.stringify({ "209_de": cacheData["209_de"] });
|
||||
extraScripts += 'window.categoryCache = ' + categoryCacheJson + ';';
|
||||
}
|
||||
|
||||
if (hasManufacturerData) {
|
||||
const herstellerJson = JSON.stringify(manufacturerData);
|
||||
extraScripts += 'window.herstellerImages = ' + herstellerJson + ';';
|
||||
}
|
||||
|
||||
productCacheScript = '<script>' +
|
||||
'if (!window.productCache) { window.productCache = {}; }' +
|
||||
'Object.assign(window.productCache, ' + cacheDataJson + ');' +
|
||||
extraScripts +
|
||||
'</script>';
|
||||
}
|
||||
|
||||
// Create script to populate window.productDetailCache for individual product pages
|
||||
|
||||
@@ -1,3 +1,43 @@
|
||||
/** Safe for double-quoted HTML attributes */
|
||||
const escAttr = (str) =>
|
||||
String(str ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<");
|
||||
|
||||
/**
|
||||
* Head tags for prerendered category URLs — explicit canonical per /Kategorie/{slug}
|
||||
* so Google does not cluster different listing pages (e.g. neu vs Seeds) as duplicates.
|
||||
*/
|
||||
const generateCategoryMetaTags = (category, baseUrl, config) => {
|
||||
const root = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
|
||||
const categoryUrl = `${root}/Kategorie/${category.seoName}`;
|
||||
const name = category.name || `Kategorie ${category.seoName}`;
|
||||
const site = config.siteName || config.brandName;
|
||||
const desc = `${name} bei ${config.brandName}: Growshop-Sortiment online kaufen. Schnelle Lieferung, Laden Dresden.`;
|
||||
const descShort = desc.length > 160 ? `${desc.slice(0, 157)}...` : desc;
|
||||
const e = escAttr;
|
||||
const logoUrl =
|
||||
config.images && config.images.logo
|
||||
? `${root}${config.images.logo}`
|
||||
: `${root}/assets/images/nopicture.jpg`;
|
||||
|
||||
return `
|
||||
<meta name="description" content="${e(descShort)}">
|
||||
<meta property="og:title" content="${e(`${name} | ${site}`)}">
|
||||
<meta property="og:description" content="${e(descShort)}">
|
||||
<meta property="og:url" content="${categoryUrl}">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:image" content="${e(logoUrl)}">
|
||||
<meta property="og:site_name" content="${e(site)}">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="${e(`${name} | ${site}`)}">
|
||||
<meta name="twitter:description" content="${e(descShort)}">
|
||||
<meta name="robots" content="index, follow">
|
||||
<link rel="canonical" href="${categoryUrl}">
|
||||
`;
|
||||
};
|
||||
|
||||
const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
|
||||
// Category IDs to skip (seeds, plants, headshop items)
|
||||
const skipCategoryIds = [689, 706, 709, 711, 714, 748, 749, 896, 710, 924, 923, 922, 921, 916, 278, 259, 258];
|
||||
@@ -7,124 +47,103 @@ const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
|
||||
return '';
|
||||
}
|
||||
|
||||
const categoryUrl = `${baseUrl}/Kategorie/${category.seoName}`;
|
||||
const root = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
|
||||
const categoryUrl = `${root}/Kategorie/${category.seoName}`;
|
||||
|
||||
// Calculate price valid date (current date + 3 months)
|
||||
const priceValidDate = new Date();
|
||||
priceValidDate.setMonth(priceValidDate.getMonth() + 3);
|
||||
const priceValidUntil = priceValidDate.toISOString().split("T")[0];
|
||||
const id = {
|
||||
business: `${root}#business`,
|
||||
website: `${root}#website`,
|
||||
breadcrumb: `${categoryUrl}#breadcrumb`,
|
||||
itemList: `${categoryUrl}#itemlist`,
|
||||
};
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org/",
|
||||
const logoUrl =
|
||||
config.images && config.images.logo
|
||||
? `${root}${config.images.logo}`
|
||||
: undefined;
|
||||
|
||||
const businessNode = {
|
||||
"@id": id.business,
|
||||
"@type": ["GardenStore", "LocalBusiness", "Organization"],
|
||||
name: config.brandName,
|
||||
url: root,
|
||||
...(logoUrl && {
|
||||
logo: { "@type": "ImageObject", url: logoUrl },
|
||||
image: { "@type": "ImageObject", url: logoUrl },
|
||||
}),
|
||||
};
|
||||
|
||||
const websiteNode = {
|
||||
"@id": id.website,
|
||||
"@type": "WebSite",
|
||||
name: config.siteName || config.brandName,
|
||||
url: root,
|
||||
publisher: { "@id": id.business },
|
||||
};
|
||||
|
||||
const breadcrumbNode = {
|
||||
"@id": id.breadcrumb,
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 1,
|
||||
name: "Home",
|
||||
item: root,
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 2,
|
||||
name: category.name,
|
||||
item: categoryUrl,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const collectionPageNode = {
|
||||
"@id": categoryUrl,
|
||||
"@type": "CollectionPage",
|
||||
name: category.name,
|
||||
url: categoryUrl,
|
||||
description: `${category.name} - Entdecken Sie unsere Auswahl an hochwertigen Produkten`,
|
||||
breadcrumb: {
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 1,
|
||||
name: "Home",
|
||||
item: baseUrl,
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 2,
|
||||
name: category.name,
|
||||
item: categoryUrl,
|
||||
},
|
||||
],
|
||||
},
|
||||
isPartOf: { "@id": id.website },
|
||||
breadcrumb: { "@id": id.breadcrumb },
|
||||
};
|
||||
|
||||
// Add product list if products are available
|
||||
if (products && products.length > 0) {
|
||||
jsonLd.mainEntity = {
|
||||
const graph = [businessNode, websiteNode, breadcrumbNode, collectionPageNode];
|
||||
|
||||
// ItemList: URLs only — full Product/Offer markup belongs on each /Artikel/… page (Google guidelines).
|
||||
const withUrls = (products || []).filter((p) => p && p.seoName);
|
||||
if (withUrls.length > 0) {
|
||||
collectionPageNode.mainEntity = { "@id": id.itemList };
|
||||
|
||||
graph.push({
|
||||
"@id": id.itemList,
|
||||
"@type": "ItemList",
|
||||
numberOfItems: products.length,
|
||||
itemListElement: products.slice(0, 20).map((product, index) => ({
|
||||
"@type": "ListItem",
|
||||
position: index + 1,
|
||||
item: {
|
||||
"@type": "Product",
|
||||
name: product.name,
|
||||
url: `${baseUrl}/Artikel/${product.seoName}`,
|
||||
image:
|
||||
product.pictureList && product.pictureList.trim()
|
||||
? `${baseUrl}/assets/images/prod${product.pictureList
|
||||
.split(",")[0]
|
||||
.trim()}.avif`
|
||||
: `${baseUrl}/assets/images/nopicture.jpg`,
|
||||
description: product.description
|
||||
? product.description.replace(/<[^>]*>/g, "").substring(0, 200)
|
||||
: `${product.name} - Hochwertiges Growshop Produkt`,
|
||||
sku: product.articleNumber || product.seoName,
|
||||
brand: {
|
||||
"@type": "Brand",
|
||||
name: product.manufacturer || config.brandName,
|
||||
},
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
url: `${baseUrl}/Artikel/${product.seoName}`,
|
||||
price: product.price && !isNaN(product.price) ? product.price.toString() : "0.00",
|
||||
priceCurrency: config.currency,
|
||||
priceValidUntil: priceValidUntil,
|
||||
availability: product.available
|
||||
? "https://schema.org/InStock"
|
||||
: "https://schema.org/OutOfStock",
|
||||
seller: {
|
||||
"@type": "Organization",
|
||||
name: config.brandName,
|
||||
},
|
||||
itemCondition: "https://schema.org/NewCondition",
|
||||
hasMerchantReturnPolicy: {
|
||||
"@type": "MerchantReturnPolicy",
|
||||
applicableCountry: "DE",
|
||||
returnPolicyCategory: "https://schema.org/MerchantReturnFiniteReturnWindow",
|
||||
merchantReturnDays: 14,
|
||||
returnMethod: "https://schema.org/ReturnByMail",
|
||||
returnFees: "https://schema.org/FreeReturn",
|
||||
},
|
||||
shippingDetails: {
|
||||
"@type": "OfferShippingDetails",
|
||||
shippingRate: {
|
||||
"@type": "MonetaryAmount",
|
||||
value: 5.90,
|
||||
currency: "EUR",
|
||||
},
|
||||
shippingDestination: {
|
||||
"@type": "DefinedRegion",
|
||||
addressCountry: "DE",
|
||||
},
|
||||
deliveryTime: {
|
||||
"@type": "ShippingDeliveryTime",
|
||||
handlingTime: {
|
||||
"@type": "QuantitativeValue",
|
||||
minValue: 0,
|
||||
maxValue: 1,
|
||||
unitCode: "DAY",
|
||||
},
|
||||
transitTime: {
|
||||
"@type": "QuantitativeValue",
|
||||
minValue: 2,
|
||||
maxValue: 3,
|
||||
unitCode: "DAY",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
};
|
||||
numberOfItems: withUrls.length,
|
||||
itemListElement: withUrls.map((product, index) => {
|
||||
const productPageUrl = `${root}/Artikel/${product.seoName}`;
|
||||
return {
|
||||
"@type": "ListItem",
|
||||
position: index + 1,
|
||||
url: productPageUrl,
|
||||
item: productPageUrl,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const categoryGraph = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": graph,
|
||||
};
|
||||
|
||||
return `<script type="application/ld+json">${JSON.stringify(
|
||||
jsonLd
|
||||
categoryGraph
|
||||
)}</script>`;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
generateCategoryMetaTags,
|
||||
generateCategoryJsonLd,
|
||||
};
|
||||
};
|
||||
116
prerender/seo/hersteller.cjs
Normal file
116
prerender/seo/hersteller.cjs
Normal file
@@ -0,0 +1,116 @@
|
||||
/** Safe for double-quoted HTML attributes */
|
||||
const escAttr = (str) =>
|
||||
String(str ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<");
|
||||
|
||||
/**
|
||||
* Head tags for prerendered Hersteller (Manufacturers) page
|
||||
*/
|
||||
const generateHerstellerMetaTags = (baseUrl, config, manufacturerCount = 0) => {
|
||||
const root = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
|
||||
const herstellerUrl = root + "/Hersteller";
|
||||
const site = config.siteName || config.brandName;
|
||||
const desc = manufacturerCount + " Hersteller bei " + config.brandName + ": Top-Marken für Growshop-Produkte. Schnelle Lieferung, Laden Dresden.";
|
||||
const descShort = desc.length > 160 ? desc.slice(0, 157) + "..." : desc;
|
||||
const e = escAttr;
|
||||
const logoUrl =
|
||||
config.images && config.images.logo
|
||||
? root + config.images.logo
|
||||
: root + "/assets/images/nopicture.jpg";
|
||||
|
||||
return `
|
||||
<meta name="description" content="${e(descShort)}">
|
||||
<meta property="og:title" content="${e("Hersteller | " + site)}">
|
||||
<meta property="og:description" content="${e(descShort)}">
|
||||
<meta property="og:url" content="${herstellerUrl}">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:image" content="${e(logoUrl)}">
|
||||
<meta property="og:site_name" content="${e(site)}">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="${e("Hersteller | " + site)}">
|
||||
<meta name="twitter:description" content="${e(descShort)}">
|
||||
<meta name="robots" content="index, follow">
|
||||
<link rel="canonical" href="${herstellerUrl}">
|
||||
`;
|
||||
};
|
||||
|
||||
const generateHerstellerJsonLd = (baseUrl, config) => {
|
||||
const root = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
|
||||
const herstellerUrl = root + "/Hersteller";
|
||||
|
||||
const id = {
|
||||
business: root + "#business",
|
||||
website: root + "#website",
|
||||
breadcrumb: herstellerUrl + "#breadcrumb",
|
||||
};
|
||||
|
||||
const logoUrl =
|
||||
config.images && config.images.logo
|
||||
? root + config.images.logo
|
||||
: undefined;
|
||||
|
||||
const businessNode = {
|
||||
"@id": id.business,
|
||||
"@type": ["GardenStore", "LocalBusiness", "Organization"],
|
||||
name: config.brandName,
|
||||
url: root,
|
||||
};
|
||||
|
||||
if (logoUrl) {
|
||||
businessNode.logo = { "@type": "ImageObject", url: logoUrl };
|
||||
businessNode.image = { "@type": "ImageObject", url: logoUrl };
|
||||
}
|
||||
|
||||
const websiteNode = {
|
||||
"@id": id.website,
|
||||
"@type": "WebSite",
|
||||
name: config.siteName || config.brandName,
|
||||
url: root,
|
||||
publisher: { "@id": id.business },
|
||||
};
|
||||
|
||||
const breadcrumbNode = {
|
||||
"@id": id.breadcrumb,
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 1,
|
||||
name: "Home",
|
||||
item: root,
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 2,
|
||||
name: "Hersteller",
|
||||
item: herstellerUrl,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const collectionPageNode = {
|
||||
"@id": herstellerUrl,
|
||||
"@type": "CollectionPage",
|
||||
name: "Hersteller",
|
||||
url: herstellerUrl,
|
||||
description: "Alle Hersteller und Marken für Growshop-Produkte",
|
||||
isPartOf: { "@id": id.website },
|
||||
breadcrumb: { "@id": id.breadcrumb },
|
||||
};
|
||||
|
||||
const graph = [businessNode, websiteNode, breadcrumbNode, collectionPageNode];
|
||||
|
||||
const herstellerGraph = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": graph,
|
||||
};
|
||||
|
||||
return "<script type=\"application/ld+json\">" + JSON.stringify(herstellerGraph) + "</script>";
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
generateHerstellerMetaTags,
|
||||
generateHerstellerJsonLd,
|
||||
};
|
||||
@@ -36,177 +36,198 @@ const generateHomepageJsonLd = (baseUrl, config, categories = []) => {
|
||||
const canonicalUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
||||
const logoUrl = `${canonicalUrl}${config.images.logo}`;
|
||||
|
||||
const websiteJsonLd = {
|
||||
"@context": "https://schema.org/",
|
||||
const id = {
|
||||
business: `${canonicalUrl}#business`,
|
||||
website: `${canonicalUrl}#website`,
|
||||
faq: `${canonicalUrl}#faq`,
|
||||
categoryList: `${canonicalUrl}#category-list`,
|
||||
sitemapPage: `${canonicalUrl}/sitemap#webpage`,
|
||||
};
|
||||
|
||||
const organizationNode = {
|
||||
"@id": id.business,
|
||||
"@type": ["GardenStore", "LocalBusiness", "Organization"],
|
||||
name: config.brandName,
|
||||
alternateName: config.siteName,
|
||||
description: config.descriptions.de.long,
|
||||
url: canonicalUrl,
|
||||
logo: {
|
||||
"@type": "ImageObject",
|
||||
url: logoUrl,
|
||||
},
|
||||
image: {
|
||||
"@type": "ImageObject",
|
||||
url: logoUrl,
|
||||
},
|
||||
telephone: "015208491860",
|
||||
email: "service@growheads.de",
|
||||
address: {
|
||||
"@type": "PostalAddress",
|
||||
streetAddress: "Trachenberger Strasse 14",
|
||||
addressLocality: "Dresden",
|
||||
postalCode: "01129",
|
||||
addressCountry: "DE",
|
||||
addressRegion: "Sachsen",
|
||||
},
|
||||
geo: {
|
||||
"@type": "GeoCoordinates",
|
||||
latitude: "51.083675",
|
||||
longitude: "13.727215",
|
||||
},
|
||||
openingHours: [
|
||||
"Mo-Fr 10:00:00-20:00:00",
|
||||
"Sa 11:00:00-19:00:00",
|
||||
],
|
||||
paymentAccepted: "Cash, Credit Card, PayPal, Bank Transfer",
|
||||
currenciesAccepted: "EUR",
|
||||
priceRange: "€€",
|
||||
areaServed: {
|
||||
"@type": "Country",
|
||||
name: "Germany",
|
||||
},
|
||||
contactPoint: [
|
||||
{
|
||||
"@type": "ContactPoint",
|
||||
telephone: "015208491860",
|
||||
contactType: "customer service",
|
||||
availableLanguage: "German",
|
||||
hoursAvailable: {
|
||||
"@type": "OpeningHoursSpecification",
|
||||
dayOfWeek: [
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
],
|
||||
opens: "10:00:00",
|
||||
closes: "20:00:00",
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "ContactPoint",
|
||||
email: "service@growheads.de",
|
||||
contactType: "customer service",
|
||||
availableLanguage: "German",
|
||||
},
|
||||
],
|
||||
sameAs: [],
|
||||
};
|
||||
|
||||
const sitemapWebPageNode = {
|
||||
"@id": id.sitemapPage,
|
||||
"@type": "WebPage",
|
||||
name: "Sitemap",
|
||||
url: `${canonicalUrl}/sitemap`,
|
||||
description: "Vollständige Sitemap mit allen Kategorien und Seiten",
|
||||
isPartOf: { "@id": id.website },
|
||||
};
|
||||
|
||||
const websiteNode = {
|
||||
"@id": id.website,
|
||||
"@type": "WebSite",
|
||||
name: config.brandName,
|
||||
url: canonicalUrl,
|
||||
description: config.descriptions.de.long,
|
||||
publisher: {
|
||||
"@type": "Organization",
|
||||
name: config.brandName,
|
||||
url: canonicalUrl,
|
||||
logo: {
|
||||
"@type": "ImageObject",
|
||||
url: logoUrl,
|
||||
},
|
||||
},
|
||||
publisher: { "@id": id.business },
|
||||
potentialAction: {
|
||||
"@type": "SearchAction",
|
||||
target: `${canonicalUrl}/search?q={search_term_string}`,
|
||||
query: "required name=search_term_string"
|
||||
query: "required name=search_term_string",
|
||||
},
|
||||
mainEntity: {
|
||||
"@type": "WebPage",
|
||||
name: "Sitemap",
|
||||
url: `${canonicalUrl}/sitemap`,
|
||||
description: "Vollständige Sitemap mit allen Kategorien und Seiten",
|
||||
},
|
||||
sameAs: [
|
||||
// Add your social media URLs here if available
|
||||
],
|
||||
mainEntity: { "@id": id.sitemapPage },
|
||||
sameAs: [],
|
||||
};
|
||||
|
||||
// Organization/LocalBusiness Schema for rich results
|
||||
const organizationJsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "LocalBusiness",
|
||||
"name": config.brandName,
|
||||
"alternateName": config.siteName,
|
||||
"description": config.descriptions.de.long,
|
||||
"url": canonicalUrl,
|
||||
"logo": logoUrl,
|
||||
"image": logoUrl,
|
||||
"telephone": "015208491860",
|
||||
"email": "service@growheads.de",
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"streetAddress": "Trachenberger Strasse 14",
|
||||
"addressLocality": "Dresden",
|
||||
"postalCode": "01129",
|
||||
"addressCountry": "DE",
|
||||
"addressRegion": "Sachsen"
|
||||
},
|
||||
"geo": {
|
||||
"@type": "GeoCoordinates",
|
||||
"latitude": "51.083675",
|
||||
"longitude": "13.727215"
|
||||
},
|
||||
"openingHours": [
|
||||
"Mo-Fr 10:00:00-20:00:00",
|
||||
"Sa 11:00:00-19:00:00"
|
||||
],
|
||||
"paymentAccepted": "Cash, Credit Card, PayPal, Bank Transfer",
|
||||
"currenciesAccepted": "EUR",
|
||||
"priceRange": "€€",
|
||||
"areaServed": {
|
||||
"@type": "Country",
|
||||
"name": "Germany"
|
||||
},
|
||||
"contactPoint": [
|
||||
{
|
||||
"@type": "ContactPoint",
|
||||
"telephone": "015208491860",
|
||||
"contactType": "customer service",
|
||||
"availableLanguage": "German",
|
||||
"hoursAvailable": {
|
||||
"@type": "OpeningHoursSpecification",
|
||||
"dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
|
||||
"opens": "10:00:00",
|
||||
"closes": "20:00:00"
|
||||
}
|
||||
const faqMainEntity = [
|
||||
{
|
||||
"@type": "Question",
|
||||
name: "Welche Zahlungsmethoden akzeptiert GrowHeads?",
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: "Wir akzeptieren Kreditkarten, PayPal, Banküberweisung, Nachnahme und Barzahlung bei Abholung in unserem Laden in Dresden.",
|
||||
},
|
||||
{
|
||||
"@type": "ContactPoint",
|
||||
"email": "service@growheads.de",
|
||||
"contactType": "customer service",
|
||||
"availableLanguage": "German"
|
||||
}
|
||||
],
|
||||
"sameAs": [
|
||||
// Add social media URLs when available
|
||||
// "https://www.facebook.com/growheads",
|
||||
// "https://www.instagram.com/growheads"
|
||||
]
|
||||
};
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
name: "Liefert GrowHeads deutschlandweit?",
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: "Ja, wir liefern deutschlandweit. Zusätzlich haben wir einen Laden in Dresden (Trachenberger Strasse 14) für lokale Kunden.",
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
name: "Welche Produkte bietet GrowHeads?",
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: "Wir bieten ein komplettes Sortiment für den Indoor-Anbau: Beleuchtung, Belüftung, Dünger, Töpfe, Zelte, Messgeräte und vieles mehr für professionelle Zuchtanlagen.",
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
name: "Hat GrowHeads einen physischen Laden?",
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: "Ja, unser Laden befindet sich in Dresden, Trachenberger Strasse 14. Öffnungszeiten: Mo-Fr 10-20 Uhr, Sa 11-19 Uhr. Sie können auch online bestellen.",
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
name: "Bietet GrowHeads Beratung zum Indoor-Anbau?",
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: "Ja, unser erfahrenes Team berät Sie gerne zu allen Aspekten des Indoor-Anbaus. Kontaktieren Sie uns telefonisch unter 015208491860 oder besuchen Sie unseren Laden.",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// FAQPage Schema for common questions
|
||||
const faqJsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
const faqNode = {
|
||||
"@id": id.faq,
|
||||
"@type": "FAQPage",
|
||||
"mainEntity": [
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Welche Zahlungsmethoden akzeptiert GrowHeads?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Wir akzeptieren Kreditkarten, PayPal, Banküberweisung, Nachnahme und Barzahlung bei Abholung in unserem Laden in Dresden."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Liefert GrowHeads deutschlandweit?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Ja, wir liefern deutschlandweit. Zusätzlich haben wir einen Laden in Dresden (Trachenberger Strasse 14) für lokale Kunden."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Welche Produkte bietet GrowHeads?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Wir bieten ein komplettes Sortiment für den Indoor-Anbau: Beleuchtung, Belüftung, Dünger, Töpfe, Zelte, Messgeräte und vieles mehr für professionelle Zuchtanlagen."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Hat GrowHeads einen physischen Laden?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Ja, unser Laden befindet sich in Dresden, Trachenberger Strasse 14. Öffnungszeiten: Mo-Fr 10-20 Uhr, Sa 11-19 Uhr. Sie können auch online bestellen."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Bietet GrowHeads Beratung zum Indoor-Anbau?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Ja, unser erfahrenes Team berät Sie gerne zu allen Aspekten des Indoor-Anbaus. Kontaktieren Sie uns telefonisch unter 015208491860 oder besuchen Sie unseren Laden."
|
||||
}
|
||||
}
|
||||
]
|
||||
url: canonicalUrl,
|
||||
publisher: { "@id": id.business },
|
||||
isPartOf: { "@id": id.website },
|
||||
mainEntity: faqMainEntity,
|
||||
};
|
||||
|
||||
// Generate ItemList for all categories (more appropriate for homepage)
|
||||
const categoriesListJsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "ItemList",
|
||||
"name": "Produktkategorien",
|
||||
"description": "Alle verfügbaren Produktkategorien in unserem Online-Shop",
|
||||
"numberOfItems": categories.filter(category => category.seoName).length,
|
||||
"itemListElement": categories
|
||||
.filter(category => category.seoName) // Only include categories with seoName
|
||||
.map((category, index) => ({
|
||||
const filteredCategories = categories.filter((c) => c.seoName);
|
||||
|
||||
const graph = [
|
||||
organizationNode,
|
||||
websiteNode,
|
||||
sitemapWebPageNode,
|
||||
faqNode,
|
||||
];
|
||||
|
||||
if (filteredCategories.length > 0) {
|
||||
graph.push({
|
||||
"@id": id.categoryList,
|
||||
"@type": "ItemList",
|
||||
name: "Produktkategorien",
|
||||
description: "Alle verfügbaren Produktkategorien in unserem Online-Shop",
|
||||
numberOfItems: filteredCategories.length,
|
||||
isPartOf: { "@id": id.website },
|
||||
itemListElement: filteredCategories.map((category, index) => ({
|
||||
"@type": "ListItem",
|
||||
"position": index + 1,
|
||||
"item": {
|
||||
position: index + 1,
|
||||
item: {
|
||||
"@type": "Thing",
|
||||
"name": category.name,
|
||||
"url": `${canonicalUrl}/Kategorie/${category.seoName}`
|
||||
}
|
||||
}))
|
||||
name: category.name,
|
||||
url: `${canonicalUrl}/Kategorie/${category.seoName}`,
|
||||
},
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const homepageGraph = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": graph,
|
||||
};
|
||||
|
||||
// Return all JSON-LD scripts
|
||||
const websiteScript = `<script type="application/ld+json">${JSON.stringify(websiteJsonLd)}</script>`;
|
||||
const organizationScript = `<script type="application/ld+json">${JSON.stringify(organizationJsonLd)}</script>`;
|
||||
const faqScript = `<script type="application/ld+json">${JSON.stringify(faqJsonLd)}</script>`;
|
||||
const categoriesScript = categories.length > 0
|
||||
? `<script type="application/ld+json">${JSON.stringify(categoriesListJsonLd)}</script>`
|
||||
: '';
|
||||
|
||||
return websiteScript + '\n' + organizationScript + '\n' + faqScript + (categoriesScript ? '\n' + categoriesScript : '');
|
||||
return `<script type="application/ld+json">${JSON.stringify(
|
||||
homepageGraph
|
||||
)}</script>`;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -5,6 +5,7 @@ const {
|
||||
} = require('./product.cjs');
|
||||
|
||||
const {
|
||||
generateCategoryMetaTags,
|
||||
generateCategoryJsonLd,
|
||||
} = require('./category.cjs');
|
||||
|
||||
@@ -22,6 +23,11 @@ const {
|
||||
generateKonfiguratorMetaTags,
|
||||
} = require('./konfigurator.cjs');
|
||||
|
||||
const {
|
||||
generateHerstellerMetaTags,
|
||||
generateHerstellerJsonLd,
|
||||
} = require('./hersteller.cjs');
|
||||
|
||||
const {
|
||||
generateRobotsTxt,
|
||||
generateProductsXml,
|
||||
@@ -41,6 +47,7 @@ module.exports = {
|
||||
generateProductJsonLd,
|
||||
|
||||
// Category functions
|
||||
generateCategoryMetaTags,
|
||||
generateCategoryJsonLd,
|
||||
|
||||
// Homepage functions
|
||||
@@ -54,6 +61,10 @@ module.exports = {
|
||||
// Konfigurator functions
|
||||
generateKonfiguratorMetaTags,
|
||||
|
||||
// Hersteller functions
|
||||
generateHerstellerMetaTags,
|
||||
generateHerstellerJsonLd,
|
||||
|
||||
// Feed/Export functions
|
||||
generateRobotsTxt,
|
||||
generateProductsXml,
|
||||
|
||||
@@ -68,14 +68,15 @@ const generateProductMetaTags = (product, baseUrl, config) => {
|
||||
};
|
||||
|
||||
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 =
|
||||
product.pictureList && product.pictureList.trim()
|
||||
? product.pictureList.split(",")[0].trim()
|
||||
: null;
|
||||
const imageUrl = pictureFirstId
|
||||
? `${baseUrl}/assets/images/prod${pictureFirstId}.avif`
|
||||
: `${baseUrl}/assets/images/nopicture.jpg`;
|
||||
? `${root}/assets/images/prod${pictureFirstId}.avif`
|
||||
: `${root}/assets/images/nopicture.jpg`;
|
||||
|
||||
// Clean description for JSON-LD (remove HTML tags)
|
||||
const cleanDescription = product.description
|
||||
@@ -86,8 +87,87 @@ const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) =>
|
||||
const priceValidDate = new Date();
|
||||
priceValidDate.setMonth(priceValidDate.getMonth() + 3);
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org/",
|
||||
const id = {
|
||||
business: `${root}#business`,
|
||||
website: `${root}#website`,
|
||||
product: `${productUrl}#product`,
|
||||
breadcrumb: `${productUrl}#breadcrumb`,
|
||||
};
|
||||
|
||||
const logoUrl =
|
||||
config.images && config.images.logo
|
||||
? `${root}${config.images.logo}`
|
||||
: undefined;
|
||||
|
||||
const businessNode = {
|
||||
"@id": id.business,
|
||||
"@type": ["GardenStore", "LocalBusiness", "Organization"],
|
||||
name: config.brandName,
|
||||
url: root,
|
||||
...(logoUrl && {
|
||||
logo: { "@type": "ImageObject", url: logoUrl },
|
||||
image: { "@type": "ImageObject", url: logoUrl },
|
||||
}),
|
||||
};
|
||||
|
||||
const websiteNode = {
|
||||
"@id": id.website,
|
||||
"@type": "WebSite",
|
||||
name: config.siteName || config.brandName,
|
||||
url: root,
|
||||
publisher: { "@id": id.business },
|
||||
};
|
||||
|
||||
const offer = {
|
||||
"@type": "Offer",
|
||||
url: productUrl,
|
||||
priceCurrency: config.currency,
|
||||
price: product.price.toString(),
|
||||
priceValidUntil: priceValidDate.toISOString().split("T")[0],
|
||||
itemCondition: "https://schema.org/NewCondition",
|
||||
availability: product.available
|
||||
? "https://schema.org/InStock"
|
||||
: "https://schema.org/OutOfStock",
|
||||
seller: { "@id": id.business },
|
||||
hasMerchantReturnPolicy: {
|
||||
"@type": "MerchantReturnPolicy",
|
||||
applicableCountry: "DE",
|
||||
returnPolicyCategory: "https://schema.org/MerchantReturnFiniteReturnWindow",
|
||||
merchantReturnDays: 14,
|
||||
returnMethod: "https://schema.org/ReturnByMail",
|
||||
returnFees: "https://schema.org/FreeReturn",
|
||||
},
|
||||
shippingDetails: {
|
||||
"@type": "OfferShippingDetails",
|
||||
shippingRate: {
|
||||
"@type": "MonetaryAmount",
|
||||
value: 5.9,
|
||||
currency: "EUR",
|
||||
},
|
||||
shippingDestination: {
|
||||
"@type": "DefinedRegion",
|
||||
addressCountry: "DE",
|
||||
},
|
||||
deliveryTime: {
|
||||
"@type": "ShippingDeliveryTime",
|
||||
handlingTime: {
|
||||
"@type": "QuantitativeValue",
|
||||
minValue: 0,
|
||||
maxValue: 1,
|
||||
unitCode: "DAY",
|
||||
},
|
||||
transitTime: {
|
||||
"@type": "QuantitativeValue",
|
||||
minValue: 2,
|
||||
maxValue: 3,
|
||||
unitCode: "DAY",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const productNode = {
|
||||
"@id": id.product,
|
||||
"@type": "Product",
|
||||
name: product.name,
|
||||
image: [imageUrl],
|
||||
@@ -98,87 +178,64 @@ const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) =>
|
||||
"@type": "Brand",
|
||||
name: product.manufacturer || "Unknown",
|
||||
},
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
url: productUrl,
|
||||
priceCurrency: config.currency,
|
||||
price: product.price.toString(),
|
||||
priceValidUntil: priceValidDate.toISOString().split("T")[0],
|
||||
itemCondition: "https://schema.org/NewCondition",
|
||||
availability: product.available
|
||||
? "https://schema.org/InStock"
|
||||
: "https://schema.org/OutOfStock",
|
||||
seller: {
|
||||
"@type": "Organization",
|
||||
name: config.brandName,
|
||||
},
|
||||
hasMerchantReturnPolicy: {
|
||||
"@type": "MerchantReturnPolicy",
|
||||
applicableCountry: "DE",
|
||||
returnPolicyCategory: "https://schema.org/MerchantReturnFiniteReturnWindow",
|
||||
merchantReturnDays: 14,
|
||||
returnMethod: "https://schema.org/ReturnByMail",
|
||||
returnFees: "https://schema.org/FreeReturn",
|
||||
},
|
||||
shippingDetails: {
|
||||
"@type": "OfferShippingDetails",
|
||||
shippingRate: {
|
||||
"@type": "MonetaryAmount",
|
||||
value: 5.90,
|
||||
currency: "EUR",
|
||||
},
|
||||
shippingDestination: {
|
||||
"@type": "DefinedRegion",
|
||||
addressCountry: "DE",
|
||||
},
|
||||
deliveryTime: {
|
||||
"@type": "ShippingDeliveryTime",
|
||||
handlingTime: {
|
||||
"@type": "QuantitativeValue",
|
||||
minValue: 0,
|
||||
maxValue: 1,
|
||||
unitCode: "DAY",
|
||||
},
|
||||
transitTime: {
|
||||
"@type": "QuantitativeValue",
|
||||
minValue: 2,
|
||||
maxValue: 3,
|
||||
unitCode: "DAY",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
offers: offer,
|
||||
};
|
||||
|
||||
// Add breadcrumb if category information is available
|
||||
if (categoryInfo && categoryInfo.name && categoryInfo.seoName) {
|
||||
jsonLd.breadcrumb = {
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 1,
|
||||
name: "Home",
|
||||
item: baseUrl,
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 2,
|
||||
name: categoryInfo.name,
|
||||
item: `${baseUrl}/Kategorie/${categoryInfo.seoName}`,
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 3,
|
||||
name: product.name,
|
||||
item: productUrl,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
const hasBreadcrumb =
|
||||
categoryInfo && categoryInfo.name && categoryInfo.seoName;
|
||||
|
||||
const breadcrumbList = hasBreadcrumb
|
||||
? {
|
||||
"@id": id.breadcrumb,
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 1,
|
||||
name: "Home",
|
||||
item: root,
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 2,
|
||||
name: categoryInfo.name,
|
||||
item: `${root}/Kategorie/${categoryInfo.seoName}`,
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 3,
|
||||
name: product.name,
|
||||
item: productUrl,
|
||||
},
|
||||
],
|
||||
}
|
||||
: null;
|
||||
|
||||
const itemPageNode = {
|
||||
"@id": productUrl,
|
||||
"@type": "ItemPage",
|
||||
url: productUrl,
|
||||
name: product.name,
|
||||
isPartOf: { "@id": id.website },
|
||||
mainEntity: { "@id": id.product },
|
||||
...(hasBreadcrumb && { breadcrumb: { "@id": id.breadcrumb } }),
|
||||
};
|
||||
|
||||
const graph = [
|
||||
businessNode,
|
||||
websiteNode,
|
||||
itemPageNode,
|
||||
...(breadcrumbList ? [breadcrumbList] : []),
|
||||
productNode,
|
||||
];
|
||||
|
||||
const productGraph = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": graph,
|
||||
};
|
||||
|
||||
return `<script type="application/ld+json">${JSON.stringify(
|
||||
jsonLd
|
||||
productGraph
|
||||
)}</script>`;
|
||||
};
|
||||
|
||||
|
||||
BIN
public/assets/images/cutlings2.avif
Normal file
BIN
public/assets/images/cutlings2.avif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
13
src/App.js
13
src/App.js
@@ -33,6 +33,7 @@ import i18n from './i18n/index.js';
|
||||
import Header from "./components/Header.js";
|
||||
import Footer from "./components/Footer.js";
|
||||
import MainPageLayout from "./components/MainPageLayout.js";
|
||||
import IdleMainPagesSlideshow from "./components/IdleMainPagesSlideshow.js";
|
||||
|
||||
import Content from "./components/Content.js";
|
||||
import ProductDetail from "./components/ProductDetail.js";
|
||||
@@ -40,6 +41,7 @@ import ProductDetail from "./components/ProductDetail.js";
|
||||
// Lazy load rarely-accessed pages
|
||||
const ProfilePage = lazy(() => import(/* webpackChunkName: "profile" */ "./pages/ProfilePage.js"));
|
||||
const ResetPassword = lazy(() => import(/* webpackChunkName: "reset-password" */ "./pages/ResetPassword.js"));
|
||||
const LinkTelegramPage = lazy(() => import(/* webpackChunkName: "link-telegram" */ "./pages/LinkTelegramPage.js"));
|
||||
|
||||
// Lazy load admin pages - only loaded when admin users access them
|
||||
const AdminPage = lazy(() => import(/* webpackChunkName: "admin" */ "./pages/AdminPage.js"));
|
||||
@@ -52,6 +54,7 @@ const AGB = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/AGB.js"))
|
||||
//const NotFound404 = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/NotFound404.js")); <Route path="/404" element={<NotFound404 />} />
|
||||
const Sitemap = lazy(() => import(/* webpackChunkName: "sitemap" */ "./pages/Sitemap.js"));
|
||||
const CategoriesPage = lazy(() => import(/* webpackChunkName: "categories" */ "./pages/CategoriesPage.js"));
|
||||
const HerstellerPage = lazy(() => import(/* webpackChunkName: "hersteller" */ "./pages/HerstellerPage.js"));
|
||||
const Impressum = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Impressum.js"));
|
||||
const Batteriegesetzhinweise = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Batteriegesetzhinweise.js"));
|
||||
const Widerrufsrecht = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Widerrufsrecht.js"));
|
||||
@@ -253,6 +256,7 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
|
||||
)
|
||||
}>
|
||||
<CarouselProvider>
|
||||
<IdleMainPagesSlideshow />
|
||||
<Routes>
|
||||
{/* Main pages using unified component */}
|
||||
<Route path="/" element={<MainPageLayout />} />
|
||||
@@ -264,6 +268,11 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
|
||||
path="/Kategorie/:categoryId"
|
||||
element={<Content />}
|
||||
/>
|
||||
{/* Manufacturer page - Render Content in parallel */}
|
||||
<Route
|
||||
path="/Hersteller/:categoryId"
|
||||
element={<Content />}
|
||||
/>
|
||||
{/* Single product page */}
|
||||
<Route
|
||||
path="/Artikel/:seoName"
|
||||
@@ -275,6 +284,9 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
|
||||
|
||||
{/* Profile page */}
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
{/* Link Telegram id (expects ?id=... or /linkTelegram/:id) */}
|
||||
<Route path="/linkTelegram" element={<LinkTelegramPage />} />
|
||||
<Route path="/linkTelegram/:id" element={<LinkTelegramPage />} />
|
||||
|
||||
{/* Payment success page for Mollie redirects */}
|
||||
<Route path="/payment/success" element={<PaymentSuccess />} />
|
||||
@@ -299,6 +311,7 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
|
||||
<Route path="/agb" element={<AGB />} />
|
||||
<Route path="/sitemap" element={<Sitemap />} />
|
||||
<Route path="/Kategorien" element={<CategoriesPage />} />
|
||||
<Route path="/Hersteller" element={<HerstellerPage />} />
|
||||
<Route path="/impressum" element={<Impressum />} />
|
||||
<Route
|
||||
path="/batteriegesetzhinweise"
|
||||
|
||||
84
src/PrerenderHerstellerPage.js
Normal file
84
src/PrerenderHerstellerPage.js
Normal file
@@ -0,0 +1,84 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Link from '@mui/material/Link';
|
||||
import LegalPage from './pages/LegalPage.js';
|
||||
|
||||
const PrerenderHerstellerPage = ({ manufacturerData }) => {
|
||||
// Use prop data (passed from prerender.cjs)
|
||||
const manufacturers = manufacturerData;
|
||||
|
||||
// If no manufacturer data, show empty state
|
||||
if (!manufacturers || manufacturers.length === 0) {
|
||||
const content = (
|
||||
<Box>
|
||||
<Typography variant="body1" paragraph>
|
||||
Keine Hersteller gefunden.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
return <LegalPage title="Hersteller" content={content} />;
|
||||
}
|
||||
|
||||
// Render manufacturers similar to HerstellerPage.js
|
||||
const content = (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{manufacturers.map((manufacturer) => (
|
||||
<Paper
|
||||
key={manufacturer.id}
|
||||
component={Link}
|
||||
href={`/Hersteller/${encodeURIComponent(manufacturer.slug || '')}`}
|
||||
elevation={3}
|
||||
style={{
|
||||
width: '140px',
|
||||
height: '140px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
userSelect: 'none',
|
||||
textDecoration: 'none',
|
||||
cursor: 'pointer',
|
||||
borderRadius: '8px',
|
||||
position: 'relative',
|
||||
zIndex: 10,
|
||||
backgroundColor: '#f0f0f0',
|
||||
boxShadow: '0px 2px 1px -1px rgba(0,0,0,0.2), 0px 1px 1px 0px rgba(0,0,0,0.14), 0px 1px 3px 0px rgba(0,0,0,0.12)'
|
||||
}}
|
||||
sx={{
|
||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-5px)',
|
||||
boxShadow: 8,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{manufacturer.imageBuffer && (
|
||||
<img
|
||||
src={`data:image/avif;base64,${Buffer.from(manufacturer.imageBuffer).toString('base64')}`}
|
||||
alt={manufacturer.name}
|
||||
draggable={false}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
|
||||
return <LegalPage title="Hersteller" content={content} />;
|
||||
};
|
||||
|
||||
export default PrerenderHerstellerPage;
|
||||
@@ -551,7 +551,7 @@ class PrerenderProduct extends React.Component {
|
||||
})
|
||||
},
|
||||
style: {
|
||||
fontFamily: '"Roboto","Helvetica","Arial",sans-serif',
|
||||
fontFamily: '"Outfit Variable","Roboto","Helvetica","Arial",sans-serif',
|
||||
fontSize: '1rem',
|
||||
lineHeight: '1.7',
|
||||
color: '#333'
|
||||
|
||||
@@ -15,6 +15,8 @@ import NotificationsActiveIcon from "@mui/icons-material/NotificationsActive";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import { withI18n } from "../i18n/withTranslation.js";
|
||||
import {
|
||||
PUSH_SUBSCRIPTIONS_CHANGED_EVENT,
|
||||
emitPushSubscriptionsChanged,
|
||||
isPushApiSupported,
|
||||
fetchPushConfiguration,
|
||||
registerPushServiceWorker,
|
||||
@@ -109,9 +111,10 @@ class AddToCartButton extends Component {
|
||||
this.setState({ pushSubscribed: false, pushBusy: false });
|
||||
return;
|
||||
}
|
||||
const res = await articlePushUnsubscribe(subscription.endpoint);
|
||||
const res = await articlePushUnsubscribe(subscription.endpoint, kArtikel);
|
||||
if (parseSuccess(res)) {
|
||||
this.setState({ pushSubscribed: false });
|
||||
emitPushSubscriptionsChanged();
|
||||
} else {
|
||||
this.setState({
|
||||
pushError:
|
||||
@@ -146,6 +149,7 @@ class AddToCartButton extends Component {
|
||||
const res = await articlePushSubscribe(kArtikel, subscription);
|
||||
if (parseSuccess(res)) {
|
||||
this.setState({ pushSubscribed: true });
|
||||
emitPushSubscriptionsChanged();
|
||||
} else {
|
||||
this.setState({
|
||||
pushError:
|
||||
@@ -174,7 +178,14 @@ class AddToCartButton extends Component {
|
||||
if (this.state.quantity !== newQuantity)
|
||||
this.setState({ quantity: newQuantity });
|
||||
};
|
||||
this.onPushSubscriptionsChanged = () => {
|
||||
this.refreshIncomingPushStatus();
|
||||
};
|
||||
window.addEventListener("cart", this.cart);
|
||||
window.addEventListener(
|
||||
PUSH_SUBSCRIPTIONS_CHANGED_EVENT,
|
||||
this.onPushSubscriptionsChanged
|
||||
);
|
||||
this.refreshIncomingPushStatus();
|
||||
}
|
||||
|
||||
@@ -190,6 +201,10 @@ class AddToCartButton extends Component {
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener("cart", this.cart);
|
||||
window.removeEventListener(
|
||||
PUSH_SUBSCRIPTIONS_CHANGED_EVENT,
|
||||
this.onPushSubscriptionsChanged
|
||||
);
|
||||
}
|
||||
|
||||
handleIncrement = () => {
|
||||
@@ -370,7 +385,7 @@ class AddToCartButton extends Component {
|
||||
startIcon={<ShoppingCartIcon />}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
fontWeight: "bold",
|
||||
whiteSpace: "nowrap",
|
||||
backgroundColor: "#9ccc65", // yellowish green
|
||||
color: "#000000",
|
||||
"&:hover": {
|
||||
@@ -524,7 +539,7 @@ class AddToCartButton extends Component {
|
||||
startIcon={<ShoppingCartIcon />}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
fontWeight: "bold",
|
||||
whiteSpace: "nowrap",
|
||||
"&:hover": {
|
||||
backgroundColor: "primary.dark",
|
||||
},
|
||||
|
||||
@@ -55,7 +55,10 @@ class ChatAssistant extends Component {
|
||||
|
||||
buildPrivacyPromptHtml = () => {
|
||||
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). */
|
||||
@@ -228,7 +231,7 @@ class ChatAssistant extends Component {
|
||||
}, () => {
|
||||
// Emit message to socket server after state is updated
|
||||
if (userMessage.trim()) {
|
||||
window.socketManager.emit('aiassyMessage', userMessage);
|
||||
window.socketManager.emit('aiassyMessage', { message: userMessage, lang: this.props.i18n?.language });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import ProductList from './ProductList.js';
|
||||
import CategoryBoxGrid from './CategoryBoxGrid.js';
|
||||
import CategoryBox from './CategoryBox.js';
|
||||
|
||||
import { useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useParams, useSearchParams, useLocation } from 'react-router-dom';
|
||||
import { getAllSettingsWithPrefix } from '../utils/sessionStorage.js';
|
||||
import { withI18n } from '../i18n/withTranslation.js';
|
||||
import { withCategory } from '../context/CategoryContext.js';
|
||||
@@ -24,17 +24,19 @@ const withRouter = (ClassComponent) => {
|
||||
return (props) => {
|
||||
const params = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
return <ClassComponent {...props} params={params} searchParams={searchParams} />;
|
||||
const location = useLocation();
|
||||
const isHersteller = location.pathname.startsWith('/Hersteller/');
|
||||
return <ClassComponent {...props} params={params} searchParams={searchParams} isHersteller={isHersteller} />;
|
||||
};
|
||||
};
|
||||
|
||||
function getCachedCategoryData(categoryId, language = 'de') {
|
||||
function getCachedCategoryData(categoryId, language = 'de', isHersteller = false) {
|
||||
if (!window.productCache) {
|
||||
window.productCache = {};
|
||||
}
|
||||
|
||||
try {
|
||||
const cacheKey = `categoryProducts_${categoryId}_${language}`;
|
||||
const cacheKey = `${isHersteller ? 'manufacturer' : 'category'}Products_${categoryId}_${language}`;
|
||||
const cachedData = window.productCache[cacheKey];
|
||||
|
||||
if (cachedData) {
|
||||
@@ -166,7 +168,7 @@ function getFilteredProducts(unfilteredProducts, attributes, t) {
|
||||
|
||||
return { filteredProducts, activeAttributeFilters: activeAttributeFiltersWithNames, activeManufacturerFilters: activeManufacturerFiltersWithNames, activeAvailabilityFilters };
|
||||
}
|
||||
function setCachedCategoryData(categoryId, data, language = 'de') {
|
||||
function setCachedCategoryData(categoryId, data, language = 'de', isHersteller = false) {
|
||||
if (!window.productCache) {
|
||||
window.productCache = {};
|
||||
}
|
||||
@@ -175,7 +177,7 @@ function setCachedCategoryData(categoryId, data, language = 'de') {
|
||||
}
|
||||
|
||||
try {
|
||||
const cacheKey = `categoryProducts_${categoryId}_${language}`;
|
||||
const cacheKey = `${isHersteller ? 'manufacturer' : 'category'}Products_${categoryId}_${language}`;
|
||||
if (data.products) for (const product of data.products) {
|
||||
const productCacheKey = `product_${product.id}_${language}`;
|
||||
window.productDetailCache[productCacheKey] = product;
|
||||
@@ -221,9 +223,10 @@ class Content extends Component {
|
||||
componentDidUpdate(prevProps) {
|
||||
const currentLanguage = this.props.i18n?.language || 'de';
|
||||
const categoryChanged = this.props.params.categoryId && (prevProps.params.categoryId !== this.props.params.categoryId);
|
||||
const routeTypeChanged = !!prevProps.isHersteller !== !!this.props.isHersteller;
|
||||
const searchChanged = this.props.searchParams?.get('q') && (prevProps.searchParams?.get('q') !== this.props.searchParams?.get('q'));
|
||||
|
||||
if (categoryChanged) {
|
||||
if (categoryChanged || routeTypeChanged) {
|
||||
// Clear context for new category loading
|
||||
if (this.props.categoryContext && this.props.categoryContext.setCurrentCategory) {
|
||||
this.props.categoryContext.setCurrentCategory(null);
|
||||
@@ -233,7 +236,7 @@ class Content extends Component {
|
||||
this.setState({ loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: [], lastFetchedLanguage: currentLanguage }, () => {
|
||||
this.fetchCategoryData(this.props.params.categoryId);
|
||||
});
|
||||
return; // Don't check language change if category changed
|
||||
return; // Don't check language change if category or route type changed
|
||||
}
|
||||
else if (searchChanged) {
|
||||
this.setState({ loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: [], lastFetchedLanguage: currentLanguage }, () => {
|
||||
@@ -345,7 +348,8 @@ class Content extends Component {
|
||||
sessionStorage.setItem('filter_availability', '1');
|
||||
}
|
||||
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
|
||||
const cachedData = getCachedCategoryData(categoryId, currentLanguage);
|
||||
const isHersteller = !!this.props.isHersteller;
|
||||
const cachedData = getCachedCategoryData(categoryId, currentLanguage, isHersteller);
|
||||
if (cachedData) {
|
||||
this.processDataWithCategoryTree(cachedData, categoryId);
|
||||
return;
|
||||
@@ -360,7 +364,7 @@ class Content extends Component {
|
||||
window.socketManager.on(`productList:${categoryId}`, (response) => {
|
||||
console.log("getCategoryProducts full response", response);
|
||||
receivedFullResponse = true;
|
||||
setCachedCategoryData(categoryId, response, currentLanguage);
|
||||
setCachedCategoryData(categoryId, response, currentLanguage, isHersteller);
|
||||
if (response && response.products !== undefined) {
|
||||
this.processDataWithCategoryTree(response, categoryId);
|
||||
} else {
|
||||
@@ -370,12 +374,17 @@ class Content extends Component {
|
||||
|
||||
window.socketManager.emit(
|
||||
"getCategoryProducts",
|
||||
{ categoryId: categoryId, language: currentLanguage, requestTranslation: currentLanguage === 'de' ? false : true },
|
||||
{
|
||||
categoryId: categoryId,
|
||||
language: currentLanguage,
|
||||
requestTranslation: currentLanguage === 'de' ? false : true,
|
||||
isHersteller,
|
||||
},
|
||||
(response) => {
|
||||
console.log("getCategoryProducts stub response", response);
|
||||
// Only process stub response if we haven't received the full response yet
|
||||
if (!receivedFullResponse) {
|
||||
setCachedCategoryData(categoryId, response, currentLanguage);
|
||||
setCachedCategoryData(categoryId, response, currentLanguage, isHersteller);
|
||||
if (response && response.products !== undefined) {
|
||||
this.processDataWithCategoryTree(response, categoryId);
|
||||
} else {
|
||||
@@ -454,7 +463,7 @@ class Content extends Component {
|
||||
const n = typeof v === 'number' ? v : parseInt(String(v), 10);
|
||||
return Number.isFinite(n) && n > 0;
|
||||
};
|
||||
if (categoryId !== 'neu' && categoryId !== 'bald' && !isValidJtlCategoryId(enhancedResponse.dataParam)) {
|
||||
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);
|
||||
|
||||
@@ -16,6 +16,7 @@ const StyledRouterLink = styled(RouterLink)(() => ({
|
||||
lineHeight: '1.5',
|
||||
display: 'block',
|
||||
padding: '4px 8px',
|
||||
whiteSpace: 'nowrap',
|
||||
'&:hover': {
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
@@ -223,25 +224,13 @@ class Footer extends Component {
|
||||
alignItems={{ xs: 'center', md: 'flex-end' }}
|
||||
>
|
||||
{/* Legal Links Section */}
|
||||
<Stack
|
||||
direction={{ xs: 'row', md: 'column' }}
|
||||
spacing={{ xs: 2, md: 0.5 }}
|
||||
justifyContent="center"
|
||||
alignItems={{ xs: 'center', md: 'left' }}
|
||||
flexWrap="wrap"
|
||||
>
|
||||
<Stack direction="column" spacing={0.5} justifyContent="center" alignItems="flex-start">
|
||||
<StyledRouterLink to="/datenschutz">{this.props.t ? this.props.t('footer.legal.datenschutz') : 'Datenschutz'}</StyledRouterLink>
|
||||
<StyledRouterLink to="/agb">{this.props.t ? this.props.t('footer.legal.agb') : 'AGB'}</StyledRouterLink>
|
||||
<StyledRouterLink to="/sitemap">{this.props.t ? this.props.t('footer.legal.sitemap') : 'Sitemap'}</StyledRouterLink>
|
||||
</Stack>
|
||||
|
||||
<Stack
|
||||
direction={{ xs: 'row', md: 'column' }}
|
||||
spacing={{ xs: 2, md: 0.5 }}
|
||||
justifyContent="center"
|
||||
alignItems={{ xs: 'center', md: 'left' }}
|
||||
flexWrap="wrap"
|
||||
>
|
||||
<Stack direction="column" spacing={0.5} justifyContent="center" alignItems="flex-start">
|
||||
<StyledRouterLink to="/impressum">{this.props.t ? this.props.t('footer.legal.impressum') : 'Impressum'}</StyledRouterLink>
|
||||
<StyledRouterLink to="/batteriegesetzhinweise">{this.props.t ? this.props.t('footer.legal.batteriegesetzhinweise') : 'Batteriegesetzhinweise'}</StyledRouterLink>
|
||||
<StyledRouterLink to="/widerrufsrecht">{this.props.t ? this.props.t('footer.legal.widerrufsrecht') : 'Widerrufsrecht'}</StyledRouterLink>
|
||||
@@ -346,7 +335,7 @@ class Footer extends Component {
|
||||
|
||||
{/* Copyright Section */}
|
||||
<Box sx={{ pb: 0, textAlign: 'center', filter: 'drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3))', opacity: 0.7 }}>
|
||||
<Typography variant="body2" sx={{ mb: 1, fontSize: { xs: '11px', md: '14px' }, lineHeight: 1.5 }}>
|
||||
<Typography variant="body2" sx={{ mb: 1, fontSize: { xs: '11px', md: '14px' }, lineHeight: 1.5, whiteSpace: 'nowrap' }}>
|
||||
{this.props.t ? this.props.t('footer.allPricesIncl') : '* Alle Preise inkl. gesetzlicher USt., zzgl. Versand'}
|
||||
</Typography>
|
||||
<Typography
|
||||
|
||||
121
src/components/IdleMainPagesSlideshow.js
Normal file
121
src/components/IdleMainPagesSlideshow.js
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
/** Same order as the main landing tiles (home → Aktionen → Filiale). */
|
||||
const MAIN_PAGE_PATHS = ["/", "/aktionen", "/filiale"];
|
||||
|
||||
/** No input for this long before the slideshow starts. */
|
||||
const IDLE_MS = 90_000;
|
||||
|
||||
/** Time between automatic page changes once the slideshow is running. */
|
||||
const SLIDESHOW_STEP_MS = 14_000;
|
||||
|
||||
/** Ignore duplicate events (mousemove etc.) within this window. */
|
||||
const ACTIVITY_THROTTLE_MS = 400;
|
||||
|
||||
/**
|
||||
* After auto-navigation, ignore user-activity handlers briefly — route changes
|
||||
* often emit scroll / mousemove / focus events that would call resetIdle() and
|
||||
* clear the slideshow interval (only one slide before stopping).
|
||||
*/
|
||||
const POST_NAV_GRACE_MS = 3_000;
|
||||
|
||||
/**
|
||||
* After idle on /, /aktionen, or /filiale, cycles those routes slowly.
|
||||
* Lives outside MainPageLayout so it is not reset when the route changes.
|
||||
*/
|
||||
export default function IdleMainPagesSlideshow() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const idleTimerRef = useRef(null);
|
||||
const slideTimerRef = useRef(null);
|
||||
const pathRef = useRef(location.pathname);
|
||||
const wasOnMainPageRef = useRef(false);
|
||||
const lastActivityRef = useRef(0);
|
||||
const ignoreActivityUntilRef = useRef(0);
|
||||
|
||||
const resetIdleRef = useRef(() => {});
|
||||
const clearTimersRef = useRef(() => {});
|
||||
|
||||
pathRef.current = location.pathname;
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
if (idleTimerRef.current != null) {
|
||||
clearTimeout(idleTimerRef.current);
|
||||
idleTimerRef.current = null;
|
||||
}
|
||||
if (slideTimerRef.current != null) {
|
||||
clearInterval(slideTimerRef.current);
|
||||
slideTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
clearTimersRef.current = clearTimers;
|
||||
|
||||
const startSlideshow = useCallback(() => {
|
||||
let idx = MAIN_PAGE_PATHS.indexOf(pathRef.current);
|
||||
if (idx < 0) idx = 0;
|
||||
const advance = () => {
|
||||
idx = (idx + 1) % MAIN_PAGE_PATHS.length;
|
||||
ignoreActivityUntilRef.current = Date.now() + POST_NAV_GRACE_MS;
|
||||
navigate(MAIN_PAGE_PATHS[idx], { replace: true });
|
||||
};
|
||||
slideTimerRef.current = setInterval(advance, SLIDESHOW_STEP_MS);
|
||||
}, [navigate]);
|
||||
|
||||
const resetIdle = useCallback(() => {
|
||||
clearTimers();
|
||||
if (!MAIN_PAGE_PATHS.includes(pathRef.current)) return;
|
||||
idleTimerRef.current = setTimeout(() => {
|
||||
idleTimerRef.current = null;
|
||||
startSlideshow();
|
||||
}, IDLE_MS);
|
||||
}, [clearTimers, startSlideshow]);
|
||||
|
||||
resetIdleRef.current = resetIdle;
|
||||
|
||||
useEffect(() => {
|
||||
const nowMain = MAIN_PAGE_PATHS.includes(location.pathname);
|
||||
if (!nowMain) {
|
||||
clearTimers();
|
||||
wasOnMainPageRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!wasOnMainPageRef.current) {
|
||||
resetIdle();
|
||||
}
|
||||
wasOnMainPageRef.current = true;
|
||||
}, [location.pathname, clearTimers, resetIdle]);
|
||||
|
||||
useEffect(() => {
|
||||
const onActivity = () => {
|
||||
const now = Date.now();
|
||||
if (now < ignoreActivityUntilRef.current) return;
|
||||
if (now - lastActivityRef.current < ACTIVITY_THROTTLE_MS) return;
|
||||
lastActivityRef.current = now;
|
||||
resetIdleRef.current();
|
||||
};
|
||||
|
||||
const events = [
|
||||
"mousedown",
|
||||
"keydown",
|
||||
"touchstart",
|
||||
"touchmove",
|
||||
"wheel",
|
||||
"click",
|
||||
"scroll",
|
||||
];
|
||||
events.forEach((ev) =>
|
||||
window.addEventListener(ev, onActivity, { passive: true })
|
||||
);
|
||||
window.addEventListener("mousemove", onActivity, { passive: true });
|
||||
|
||||
return () => {
|
||||
events.forEach((ev) => window.removeEventListener(ev, onActivity));
|
||||
window.removeEventListener("mousemove", onActivity);
|
||||
clearTimersRef.current();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -240,7 +240,15 @@ export class LoginComponent extends Component {
|
||||
isAdmin: !!response.user.admin
|
||||
});
|
||||
|
||||
const redirectTo = location && location.hash ? `/profile${location.hash}` : '/profile';
|
||||
const redirectTo = (() => {
|
||||
// If we started login from the linkTelegram flow, come back there after auth.
|
||||
// This prevents LinkTelegramPage from getting unmounted before the socket emit runs.
|
||||
if (location?.pathname && location.pathname.startsWith('/linkTelegram')) {
|
||||
return `${location.pathname}${location.search || ''}${location.hash || ''}`;
|
||||
}
|
||||
|
||||
return location && location.hash ? `/profile${location.hash}` : '/profile';
|
||||
})();
|
||||
const dispatchLoginEvent = () => {
|
||||
window.dispatchEvent(new CustomEvent('userLoggedIn'));
|
||||
navigate(redirectTo);
|
||||
@@ -415,7 +423,14 @@ export class LoginComponent extends Component {
|
||||
user: response.user
|
||||
});
|
||||
|
||||
const redirectTo = location && location.hash ? `/profile${location.hash}` : '/profile';
|
||||
const redirectTo = (() => {
|
||||
// If we started login from the linkTelegram flow, come back there after auth.
|
||||
if (location?.pathname && location.pathname.startsWith('/linkTelegram')) {
|
||||
return `${location.pathname}${location.search || ''}${location.hash || ''}`;
|
||||
}
|
||||
|
||||
return location && location.hash ? `/profile${location.hash}` : '/profile';
|
||||
})();
|
||||
const dispatchLoginEvent = () => {
|
||||
window.dispatchEvent(new CustomEvent('userLoggedIn'));
|
||||
navigate(redirectTo);
|
||||
|
||||
@@ -291,8 +291,22 @@ const MainPageLayout = () => {
|
||||
const currentPath = location.pathname;
|
||||
const { t } = useTranslation();
|
||||
const [starHovered, setStarHovered] = React.useState(false);
|
||||
|
||||
// State to track kiosk mode
|
||||
const [isKiosk, setIsKiosk] = React.useState(() => window.growheadskiosk === true);
|
||||
|
||||
// Listen for the custom event
|
||||
React.useEffect(() => {
|
||||
const handleKioskChange = () => {
|
||||
setIsKiosk(window.growheadskiosk === true);
|
||||
};
|
||||
|
||||
window.addEventListener('growheadskiosk-change', handleKioskChange);
|
||||
return () => window.removeEventListener('growheadskiosk-change', handleKioskChange);
|
||||
}, []);
|
||||
|
||||
const translatedContent = {
|
||||
buildYourSet: t('sections.buildYourSet'),
|
||||
buildYourSet: isKiosk ? 'Schau in den Stecklingskatalog' : t('sections.buildYourSet'),
|
||||
selectSeedRate: t('sections.selectSeedRate'),
|
||||
outdoorSeason: t('sections.outdoorSeason')
|
||||
};
|
||||
@@ -317,11 +331,11 @@ const MainPageLayout = () => {
|
||||
const allContentBoxes = {
|
||||
home: [
|
||||
{ title: t('sections.seeds'), image: "/assets/images/seeds.avif", bgcolor: "#e1f0d3", link: "/Kategorie/Seeds" },
|
||||
{ title: t('sections.konfigurator'), image: "/assets/images/konfigurator.avif", bgcolor: "#e8f5d6", link: "/Konfigurator" }
|
||||
{ title: isKiosk ? 'Stecklingskatalog' : t('sections.konfigurator'), image: isKiosk ? "/assets/images/cutlings2.avif" : "/assets/images/konfigurator.avif", bgcolor: "#e8f5d6", link: isKiosk ? "https://cloneheads.de" : "/Konfigurator" }
|
||||
],
|
||||
aktionen: [
|
||||
{ title: t('sections.oilPress'), image: "/assets/images/presse.jpg", bgcolor: "#e1f0d3", link: "/presseverleih" },
|
||||
{ title: t('sections.thcTest'), image: "/assets/images/purpl.jpg", bgcolor: "#e8f5d6", link: "/thc-test" }
|
||||
{ title: t('sections.oilPress'), image: "/assets/images/presse.jpg", bgcolor: "#e1f0d3", link: "/Artikel/Graveda-10t-presse-tagesmiete-inkl-prepress-vorpressform" },
|
||||
{ title: t('sections.thcTest'), image: "/assets/images/purpl.jpg", bgcolor: "#e8f5d6", link: "/Artikel/1x-messung-purplpro-thc-cbd-restfeuchte-wasseraktivitaet" }
|
||||
],
|
||||
filiale: [
|
||||
{ title: t('sections.address1'), image: "/assets/images/filiale1.jpg", bgcolor: "#e1f0d3", link: "/filiale" },
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import ChevronLeft from "@mui/icons-material/ChevronLeft";
|
||||
import ChevronRight from "@mui/icons-material/ChevronRight";
|
||||
import { withTranslation } from 'react-i18next';
|
||||
import { withLanguage } from '../i18n/withTranslation.js';
|
||||
|
||||
const ITEM_WIDTH = 140 + 16; // 140px width + 16px gap
|
||||
const AUTO_SCROLL_SPEED = 1.0;
|
||||
const AUTOSCROLL_RESTART_DELAY = 5000;
|
||||
|
||||
class ManufacturerCarousel extends React.Component {
|
||||
_isMounted = false;
|
||||
originalItems = [];
|
||||
animationFrame = null;
|
||||
autoScrollActive = true;
|
||||
translateX = 0;
|
||||
inactivityTimer = null;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
@@ -28,10 +36,8 @@ class ManufacturerCarousel extends React.Component {
|
||||
|
||||
componentWillUnmount() {
|
||||
this._isMounted = false;
|
||||
if (this.animationFrame) {
|
||||
cancelAnimationFrame(this.animationFrame);
|
||||
this.animationFrame = null;
|
||||
}
|
||||
this.stopAutoScroll();
|
||||
this.clearInactivityTimer();
|
||||
// Revoke object URLs to avoid memory leaks
|
||||
for (const item of this.originalItems) {
|
||||
if (item.src) URL.revokeObjectURL(item.src);
|
||||
@@ -46,7 +52,12 @@ class ManufacturerCarousel extends React.Component {
|
||||
.filter(m => m.imageBuffer)
|
||||
.map(m => {
|
||||
const blob = new Blob([m.imageBuffer], { type: 'image/avif' });
|
||||
return { id: m.id, name: m.name || '', src: URL.createObjectURL(blob) };
|
||||
return {
|
||||
id: m.id,
|
||||
name: m.name || '',
|
||||
slug: m.slug || '',
|
||||
src: URL.createObjectURL(blob),
|
||||
};
|
||||
})
|
||||
.sort(() => Math.random() - 0.5);
|
||||
|
||||
@@ -60,13 +71,38 @@ class ManufacturerCarousel extends React.Component {
|
||||
};
|
||||
|
||||
startAutoScroll = () => {
|
||||
this.autoScrollActive = true;
|
||||
if (!this.animationFrame) {
|
||||
this.animationFrame = requestAnimationFrame(this.tick);
|
||||
}
|
||||
};
|
||||
|
||||
stopAutoScroll = () => {
|
||||
this.autoScrollActive = false;
|
||||
if (this.animationFrame) {
|
||||
cancelAnimationFrame(this.animationFrame);
|
||||
this.animationFrame = null;
|
||||
}
|
||||
};
|
||||
|
||||
clearInactivityTimer = () => {
|
||||
if (this.inactivityTimer) {
|
||||
clearTimeout(this.inactivityTimer);
|
||||
this.inactivityTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
startInactivityTimer = () => {
|
||||
this.clearInactivityTimer();
|
||||
this.inactivityTimer = setTimeout(() => {
|
||||
if (this._isMounted) {
|
||||
this.startAutoScroll();
|
||||
}
|
||||
}, AUTOSCROLL_RESTART_DELAY);
|
||||
};
|
||||
|
||||
tick = () => {
|
||||
if (!this._isMounted || this.originalItems.length === 0) return;
|
||||
if (!this._isMounted || !this.autoScrollActive || this.originalItems.length === 0) return;
|
||||
|
||||
this.translateX -= AUTO_SCROLL_SPEED;
|
||||
|
||||
@@ -82,6 +118,41 @@ class ManufacturerCarousel extends React.Component {
|
||||
this.animationFrame = requestAnimationFrame(this.tick);
|
||||
};
|
||||
|
||||
updateTrackTransform = () => {
|
||||
if (this.carouselTrackRef.current) {
|
||||
this.carouselTrackRef.current.style.transform = `translateX(${this.translateX}px)`;
|
||||
}
|
||||
};
|
||||
|
||||
scrollBy = (direction) => {
|
||||
if (this.originalItems.length === 0) return;
|
||||
|
||||
const originalItemCount = this.originalItems.length;
|
||||
const maxScroll = ITEM_WIDTH * originalItemCount;
|
||||
|
||||
this.translateX += direction * ITEM_WIDTH;
|
||||
|
||||
if (this.translateX > 0) {
|
||||
this.translateX = -(maxScroll - ITEM_WIDTH);
|
||||
} else if (Math.abs(this.translateX) >= maxScroll) {
|
||||
this.translateX = 0;
|
||||
}
|
||||
|
||||
this.updateTrackTransform();
|
||||
};
|
||||
|
||||
handleLeftClick = () => {
|
||||
this.stopAutoScroll();
|
||||
this.scrollBy(1);
|
||||
this.startInactivityTimer();
|
||||
};
|
||||
|
||||
handleRightClick = () => {
|
||||
this.stopAutoScroll();
|
||||
this.scrollBy(-1);
|
||||
this.startInactivityTimer();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
const { items } = this.state;
|
||||
@@ -90,19 +161,36 @@ class ManufacturerCarousel extends React.Component {
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 4, mb: 4 }}>
|
||||
<Typography
|
||||
variant="h4"
|
||||
component="div"
|
||||
<Box
|
||||
component={Link}
|
||||
to="/Hersteller"
|
||||
sx={{
|
||||
fontFamily: 'SwashingtonCP',
|
||||
textShadow: '3px 3px 10px rgba(0, 0, 0, 0.4)',
|
||||
textAlign: 'center',
|
||||
mb: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
textDecoration: 'none',
|
||||
color: 'primary.main',
|
||||
mb: 2,
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateX(5px)',
|
||||
color: 'primary.dark',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{t('product.manufacturer')}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h4"
|
||||
component="span"
|
||||
sx={{
|
||||
fontFamily: 'SwashingtonCP',
|
||||
textShadow: '3px 3px 10px rgba(0, 0, 0, 0.4)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{t('product.manufacturer')}
|
||||
</Typography>
|
||||
<ChevronRight sx={{ fontSize: '2.5rem', ml: 1 }} />
|
||||
</Box>
|
||||
|
||||
<div
|
||||
style={{
|
||||
@@ -129,6 +217,46 @@ class ManufacturerCarousel extends React.Component {
|
||||
zIndex: 2, pointerEvents: 'none',
|
||||
}} />
|
||||
|
||||
{/* Left Arrow */}
|
||||
<IconButton
|
||||
aria-label="Vorherige Hersteller anzeigen"
|
||||
onClick={this.handleLeftClick}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '8px',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 1000,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: '50%'
|
||||
}}
|
||||
>
|
||||
<ChevronLeft />
|
||||
</IconButton>
|
||||
|
||||
{/* Right Arrow */}
|
||||
<IconButton
|
||||
aria-label="Nächste Hersteller anzeigen"
|
||||
onClick={this.handleRightClick}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
right: '8px',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 1000,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: '50%'
|
||||
}}
|
||||
>
|
||||
<ChevronRight />
|
||||
</IconButton>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
@@ -151,8 +279,11 @@ class ManufacturerCarousel extends React.Component {
|
||||
}}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
<Paper
|
||||
key={`${item.id}-${index}`}
|
||||
component={Link}
|
||||
to={`/Hersteller/${encodeURIComponent(item.slug || '')}`}
|
||||
elevation={3}
|
||||
style={{
|
||||
flex: '0 0 140px',
|
||||
width: '140px',
|
||||
@@ -162,7 +293,20 @@ class ManufacturerCarousel extends React.Component {
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
userSelect: 'none',
|
||||
pointerEvents: 'none',
|
||||
textDecoration: 'none',
|
||||
cursor: 'pointer',
|
||||
borderRadius: '8px',
|
||||
position: 'relative',
|
||||
zIndex: 10,
|
||||
backgroundColor: '#f0f0f0',
|
||||
boxShadow: '0px 2px 1px -1px rgba(0,0,0,0.2), 0px 1px 1px 0px rgba(0,0,0,0.14), 0px 1px 3px 0px rgba(0,0,0,0.12)'
|
||||
}}
|
||||
sx={{
|
||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-5px)',
|
||||
boxShadow: 8,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<img
|
||||
@@ -176,7 +320,7 @@ class ManufacturerCarousel extends React.Component {
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Paper>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,8 @@ import { useParams, useSearchParams, useNavigate, useLocation } from 'react-rout
|
||||
import { setSessionSetting, removeSessionSetting, clearAllSessionSettings } from '../utils/sessionStorage.js';
|
||||
import { withI18n } from '../i18n/withTranslation.js';
|
||||
import {
|
||||
PUSH_SUBSCRIPTIONS_CHANGED_EVENT,
|
||||
emitPushSubscriptionsChanged,
|
||||
isPushApiSupported,
|
||||
fetchPushConfiguration,
|
||||
registerPushServiceWorker,
|
||||
@@ -68,14 +70,25 @@ class ProductFilters extends Component {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.onPushSubscriptionsChanged = () => {
|
||||
this.refreshCategoryPushStatus();
|
||||
};
|
||||
this.adjustPaperHeight();
|
||||
window.addEventListener('resize', this.adjustPaperHeight);
|
||||
window.addEventListener(
|
||||
PUSH_SUBSCRIPTIONS_CHANGED_EVENT,
|
||||
this.onPushSubscriptionsChanged
|
||||
);
|
||||
this._loadManufacturerImages();
|
||||
this.refreshCategoryPushStatus();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('resize', this.adjustPaperHeight);
|
||||
window.removeEventListener(
|
||||
PUSH_SUBSCRIPTIONS_CHANGED_EVENT,
|
||||
this.onPushSubscriptionsChanged
|
||||
);
|
||||
this._manufacturerImageUrls.forEach(url => URL.revokeObjectURL(url));
|
||||
}
|
||||
|
||||
@@ -204,9 +217,10 @@ class ProductFilters extends Component {
|
||||
this.setState({ pushSubscribed: false, pushBusy: false });
|
||||
return;
|
||||
}
|
||||
const res = await categoryPushUnsubscribe(subscription.endpoint);
|
||||
const res = await categoryPushUnsubscribe(subscription.endpoint, kKat);
|
||||
if (parseSuccess(res)) {
|
||||
this.setState({ pushSubscribed: false });
|
||||
emitPushSubscriptionsChanged();
|
||||
} else {
|
||||
this.setState({
|
||||
pushError:
|
||||
@@ -237,6 +251,7 @@ class ProductFilters extends Component {
|
||||
const res = await categoryPushSubscribe(kKat, subscription);
|
||||
if (parseSuccess(res)) {
|
||||
this.setState({ pushSubscribed: true });
|
||||
emitPushSubscriptionsChanged();
|
||||
} else {
|
||||
this.setState({
|
||||
pushError:
|
||||
@@ -258,13 +273,15 @@ class ProductFilters extends Component {
|
||||
|
||||
|
||||
adjustPaperHeight = () => {
|
||||
// Skip height adjustment on xs screens
|
||||
if (window.innerWidth < 600) return;
|
||||
|
||||
// Get reference to our paper element
|
||||
const paperEl = document.getElementById('filters-paper');
|
||||
if (!paperEl) return;
|
||||
|
||||
// No min-height on mobile — also clears inline style after resize from desktop
|
||||
if (window.innerWidth < 600) {
|
||||
paperEl.style.minHeight = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Get viewport height
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ class Stripe extends Component {
|
||||
colorWarning: '#FF9800', // Orange for warnings
|
||||
|
||||
// Typography matching your Roboto setup
|
||||
fontFamily: "'Roboto', 'Helvetica', 'Arial', sans-serif",
|
||||
fontFamily: "'Outfit Variable', 'Roboto', 'Helvetica', 'Arial', sans-serif",
|
||||
fontSizeBase: '16px', // Base font size for mobile compatibility
|
||||
fontWeightNormal: '400', // Normal Roboto weight
|
||||
fontWeightMedium: '500', // Medium Roboto weight
|
||||
|
||||
@@ -71,7 +71,7 @@ const ThemeCustomizerDialog = ({ open, onClose, theme, onThemeChange }) => {
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: "'Roboto', 'Helvetica', 'Arial', sans-serif",
|
||||
fontFamily: "'Outfit Variable', 'Roboto', 'Helvetica', 'Arial', sans-serif",
|
||||
h4: {
|
||||
fontWeight: 600,
|
||||
color: '#33691E',
|
||||
|
||||
@@ -183,7 +183,7 @@ class CategoryList extends Component {
|
||||
display: "flex",
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
flexWrap: isMobile ? "wrap" : "nowrap",
|
||||
overflowX: "visible",
|
||||
flexDirection: isMobile ? "column" : "row",
|
||||
py: 0.5, // Add vertical padding to prevent border clipping
|
||||
@@ -197,7 +197,7 @@ class CategoryList extends Component {
|
||||
aria-label="Zur Startseite"
|
||||
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
|
||||
sx={{
|
||||
fontSize: "0.75rem",
|
||||
fontSize: "0.85rem",
|
||||
textTransform: "none",
|
||||
whiteSpace: "nowrap",
|
||||
opacity: 0.9,
|
||||
@@ -275,7 +275,7 @@ class CategoryList extends Component {
|
||||
aria-label="Neuheiten"
|
||||
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
|
||||
sx={{
|
||||
fontSize: "0.75rem",
|
||||
fontSize: "0.85rem",
|
||||
textTransform: "none",
|
||||
whiteSpace: "nowrap",
|
||||
opacity: 0.9,
|
||||
@@ -353,7 +353,7 @@ class CategoryList extends Component {
|
||||
aria-label={this.props.t ? this.props.t('navigation.soon') : 'Demnächst'}
|
||||
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
|
||||
sx={{
|
||||
fontSize: "0.75rem",
|
||||
fontSize: "0.85rem",
|
||||
textTransform: "none",
|
||||
whiteSpace: "nowrap",
|
||||
opacity: 0.9,
|
||||
@@ -435,7 +435,7 @@ class CategoryList extends Component {
|
||||
size="small"
|
||||
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
|
||||
sx={{
|
||||
fontSize: "0.75rem",
|
||||
fontSize: "0.85rem",
|
||||
textTransform: "none",
|
||||
whiteSpace: "nowrap",
|
||||
opacity: 0.9,
|
||||
@@ -506,7 +506,7 @@ class CategoryList extends Component {
|
||||
alignItems: "center",
|
||||
height: "33px", // Match small button height
|
||||
px: 1,
|
||||
fontSize: "0.75rem",
|
||||
fontSize: "0.85rem",
|
||||
opacity: 0.9,
|
||||
}}
|
||||
>
|
||||
@@ -522,7 +522,7 @@ class CategoryList extends Component {
|
||||
aria-label={this.props.t ? this.props.t('navigation.konfiguratorAria') : 'Zum Konfigurator'}
|
||||
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
|
||||
sx={{
|
||||
fontSize: "0.75rem",
|
||||
fontSize: "0.85rem",
|
||||
textTransform: "none",
|
||||
whiteSpace: "nowrap",
|
||||
opacity: 0.9,
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: Roboto, Helvetica, Arial, sans-serif;
|
||||
font-family: 'Outfit Variable', Roboto, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overflow-y: scroll; /* Always show vertical scrollbar */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import "@fontsource-variable/outfit";
|
||||
import "./index.css";
|
||||
import App from "./App.js";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
|
||||
168
src/pages/HerstellerPage.js
Normal file
168
src/pages/HerstellerPage.js
Normal 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);
|
||||
238
src/pages/LinkTelegramPage.js
Normal file
238
src/pages/LinkTelegramPage.js
Normal 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;
|
||||
|
||||
@@ -153,6 +153,12 @@ class SocketManager {
|
||||
auth: this._buildSocketAuth()
|
||||
});
|
||||
|
||||
this._socket.on('kiosk', () => {
|
||||
window.growheadskiosk = true;
|
||||
window.dispatchEvent(new Event('growheadskiosk-change'));
|
||||
console.warn('Kiosk mode enabled via socket event');
|
||||
});
|
||||
|
||||
// Always refresh auth data before reconnect attempts.
|
||||
if (this._socket.io && this._socket.io.on) {
|
||||
this._socket.io.on('reconnect_attempt', () => {
|
||||
|
||||
@@ -29,7 +29,7 @@ const theme = createTheme({
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: "'Roboto', 'Helvetica', 'Arial', sans-serif",
|
||||
fontFamily: "'Outfit Variable', 'Roboto', 'Helvetica', 'Arial', sans-serif",
|
||||
h4: {
|
||||
fontWeight: 600,
|
||||
color: '#33691E',
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
* Web Push helpers for article notifications (VAPID + backend API).
|
||||
*/
|
||||
|
||||
/** Fired after any article/category push subscribe or unsubscribe so all UIs re-query status. */
|
||||
export const PUSH_SUBSCRIPTIONS_CHANGED_EVENT = "growheads-push-subscriptions-changed";
|
||||
|
||||
export function emitPushSubscriptionsChanged() {
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent(PUSH_SUBSCRIPTIONS_CHANGED_EVENT));
|
||||
}
|
||||
}
|
||||
|
||||
export const ARTICLE_PUSH_SW_PATH = "/api/check/push/service-worker";
|
||||
export const ARTICLE_PUSH_VAPID_URL = "/api/check/push/vapid-public-key";
|
||||
|
||||
@@ -88,14 +97,22 @@ export async function articlePushSubscribe(kArtikel, subscription) {
|
||||
return { ...data, httpOk: res.ok };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} endpoint
|
||||
*/
|
||||
export async function articlePushUnsubscribe(endpoint) {
|
||||
/** POST /api/article/push/unsubscribe — body is always `{ endpoint, kArtikel }` (scoped row only). */
|
||||
export async function articlePushUnsubscribe(endpoint, kArtikel) {
|
||||
const parsed =
|
||||
kArtikel != null && kArtikel !== ""
|
||||
? typeof kArtikel === "number"
|
||||
? kArtikel
|
||||
: parseInt(String(kArtikel), 10)
|
||||
: NaN;
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return { success: false, httpOk: false, error: "missing_kArtikel" };
|
||||
}
|
||||
const body = { endpoint, kArtikel: parsed };
|
||||
const res = await fetch("/api/article/push/unsubscribe", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ endpoint }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return { ...data, httpOk: res.ok };
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
*/
|
||||
|
||||
export {
|
||||
PUSH_SUBSCRIPTIONS_CHANGED_EVENT,
|
||||
emitPushSubscriptionsChanged,
|
||||
fetchPushConfiguration,
|
||||
registerPushServiceWorker,
|
||||
ensurePushSubscription,
|
||||
@@ -43,14 +45,22 @@ export async function categoryPushSubscribe(kKategorie, subscription) {
|
||||
return { ...data, httpOk: res.ok };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} endpoint
|
||||
*/
|
||||
export async function categoryPushUnsubscribe(endpoint) {
|
||||
/** POST /api/category/push/unsubscribe — body is always `{ endpoint, kKategorie }` (scoped row only). */
|
||||
export async function categoryPushUnsubscribe(endpoint, kKategorie) {
|
||||
const parsed =
|
||||
kKategorie != null && kKategorie !== ""
|
||||
? typeof kKategorie === "number"
|
||||
? kKategorie
|
||||
: parseInt(String(kKategorie), 10)
|
||||
: NaN;
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return { success: false, httpOk: false, error: "missing_kKategorie" };
|
||||
}
|
||||
const body = { endpoint, kKategorie: parsed };
|
||||
const res = await fetch("/api/category/push/unsubscribe", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ endpoint }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return { ...data, httpOk: res.ok };
|
||||
|
||||
@@ -5,17 +5,31 @@ import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin';
|
||||
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
|
||||
import ESLintPlugin from 'eslint-webpack-plugin';
|
||||
import { cpSync } from 'fs';
|
||||
import { execSync } from 'child_process';
|
||||
import { execFileSync } from 'child_process';
|
||||
import webpack from 'webpack';
|
||||
import fs from 'fs';
|
||||
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
|
||||
|
||||
// Get git commit hash
|
||||
// Git hash for meta tag / currentHash.json — prefer env (CI), then git without shell (avoids EPERM on spawn /bin/sh in some sandboxes)
|
||||
const getGitCommitHash = () => {
|
||||
const fromEnv =
|
||||
process.env.GIT_COMMIT ||
|
||||
process.env.VERCEL_GIT_COMMIT_SHA ||
|
||||
process.env.CI_COMMIT_SHA ||
|
||||
process.env.GITHUB_SHA ||
|
||||
'';
|
||||
if (fromEnv) return String(fromEnv).trim();
|
||||
|
||||
try {
|
||||
return execSync('git rev-parse HEAD').toString().trim();
|
||||
return execFileSync('git', ['rev-parse', 'HEAD'], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 1024 * 1024,
|
||||
}).trim();
|
||||
} catch (e) {
|
||||
console.error('Failed to get git commit hash:', e);
|
||||
console.warn(
|
||||
'Git commit hash unavailable (set GIT_COMMIT or run build in a git repo):',
|
||||
e && e.message ? e.message : e
|
||||
);
|
||||
return 'unknown';
|
||||
}
|
||||
};
|
||||
@@ -301,14 +315,53 @@ export default {
|
||||
priority: 20,
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
// Keep Stripe checkout out of the initial vendor bundle (loaded with payment routes)
|
||||
stripe: {
|
||||
test: /[\\/]node_modules[\\/]@stripe[\\/]/,
|
||||
name: 'stripe',
|
||||
priority: 19,
|
||||
chunks: 'async',
|
||||
reuseExistingChunk: true,
|
||||
enforce: true,
|
||||
},
|
||||
// QR / SEPA helpers — checkout & profile only (not htmlParser: that caused CLS on product pages)
|
||||
payments: {
|
||||
test: /[\\/]node_modules[\\/](qrcode|sepa-payment-qr-code|iban)[\\/]/,
|
||||
name: 'payments',
|
||||
priority: 17,
|
||||
chunks: 'async',
|
||||
reuseExistingChunk: true,
|
||||
enforce: true,
|
||||
},
|
||||
// socket.io-client and its dependencies — always async, never initial
|
||||
socketio: {
|
||||
test: /[\\/]node_modules[\\/](socket\.io-client|engine\.io-client|@socket\.io|socket\.io-parser|socket\.io-msgpack-parser)[\\/]/,
|
||||
test: /[\\/]node_modules[\\/](socket\.io-client|engine\.io-client|engine\.io-parser|@socket\.io|socket\.io-parser|socket\.io-msgpack-parser)[\\/]/,
|
||||
name: 'socketio',
|
||||
priority: 15,
|
||||
chunks: 'async',
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
// Language switcher flags: dynamic import() must stay async-only. The catch-all `vendor`
|
||||
// group below uses chunks: 'all' and would otherwise merge this into vendor → no extra
|
||||
// network request on menu open (flags shipped in initial load).
|
||||
countryFlagIcons: {
|
||||
test: /[\\/]node_modules[\\/]country-flag-icons[\\/]/,
|
||||
name: 'country-flag-icons',
|
||||
priority: 12,
|
||||
chunks: 'async',
|
||||
reuseExistingChunk: true,
|
||||
enforce: true,
|
||||
},
|
||||
// LazySanitizedHtml (product HTML etc.): keep parser stack out of initial vendor.
|
||||
// Do NOT include postcss here — it is used by other tools and forced async caused CLS before.
|
||||
htmlSanitizeAsync: {
|
||||
test: /[\\/]node_modules[\\/](html-react-parser|sanitize-html|htmlparser2|domhandler|dom-serializer|entities|react-property|parse-srcset)[\\/]/,
|
||||
name: 'html-sanitize-async',
|
||||
priority: 11,
|
||||
chunks: 'async',
|
||||
reuseExistingChunk: true,
|
||||
enforce: true,
|
||||
},
|
||||
// Other vendor libraries
|
||||
vendor: {
|
||||
test: /[\\/]node_modules[\\/]/,
|
||||
|
||||
Reference in New Issue
Block a user