Compare commits

..

325 Commits

Author SHA1 Message Date
sebseb7
b5256d6597 feat: add Outfit Variable font and update global typography settings 2026-04-01 15:13:29 +02:00
sebseb7
18c528302d correct ai assy language 2026-03-31 10:19:47 +02:00
sebseb7
9054c8d2fd Formatting fixed that affected the Czech version. 2026-03-31 10:00:13 +02:00
sebseb7
8bce10e61b refactor: Improve git commit hash retrieval method and enhance webpack configuration for better lazy loading of components 2026-03-28 18:21:39 +01:00
sebseb7
2540d00c8e Revert "refactor: Update webpack configuration to improve git commit hash retrieval and enhance lazy loading of components for better performance"
This reverts commit 52c9888a6a.
2026-03-28 18:04:55 +01:00
sebseb7
52c9888a6a refactor: Update webpack configuration to improve git commit hash retrieval and enhance lazy loading of components for better performance 2026-03-28 17:56:12 +01:00
sebseb7
ab55761411 refactor: Update JSON-LD itemListElement structure in category SEO to include URL field for better clarity and compliance with SEO standards 2026-03-28 17:37:38 +01:00
sebseb7
5e5a733d36 refactor: Simplify JSON-LD generation in category SEO by removing unnecessary product details and focusing on URLs for improved compliance with Google guidelines 2026-03-28 17:34:00 +01:00
sebseb7
36360df648 feat: Add generateCategoryMetaTags function for enhanced SEO in category pages and integrate it into prerender process 2026-03-28 17:21:43 +01:00
sebseb7
21d86565f1 refactor: Enhance JSON-LD structure in category and product generation functions for improved SEO and consistency across URLs 2026-03-28 17:10:14 +01:00
sebseb7
c503de3a11 refactor: Update organization JSON-LD structure to include specific subtype (GardenStore) for better merchant categorization 2026-03-28 16:57:45 +01:00
sebseb7
2ced182570 refactor: Update JSON-LD generation in generateProductJsonLd function to separate product and breadcrumb scripts for improved SEO structure 2026-03-28 16:30:39 +01:00
sebseb7
52c62541b0 fix: Adjust ProductFilters component to clear min-height on mobile screens for improved responsiveness 2026-03-27 01:37:14 +01:00
sebseb7
7202c43dfa feat: Add LinkTelegram page and routing; enhance login flow to support redirection from linkTelegram 2026-03-27 01:29:04 +01:00
sebseb7
5b7f0f788c refactor: Centralize Socket.IO client options in config for improved maintainability and consistency across prerender scripts 2026-03-26 21:57:50 +01:00
sebseb7
47ed2ec231 refactor: Simplify unsubscribe functionality in articlePush and categoryPush by enforcing required identifiers in request body 2026-03-26 21:35:26 +01:00
sebseb7
188c883450 feat: Implement push subscription event handling in AddToCartButton and ProductFilters components; enhance article and category unsubscribe functionality with optional identifiers 2026-03-26 21:28:49 +01:00
sebseb7
ba66b82b2b feat: Add grace period for user activity handling in IdleMainPagesSlideshow to prevent premature slideshow reset after navigation 2026-03-26 21:24:46 +01:00
sebseb7
defe3c9521 feat: Integrate IdleMainPagesSlideshow component into App.js and update links in MainPageLayout for improved navigation to articles 2026-03-26 21:10:46 +01:00
sebseb7
de8e59f1bb feat: Enhance ChatAssistant and ProductFilters components with dynamic privacy prompts and category push notification support; update localization strings for new article notifications across multiple languages 2026-03-26 20:51:28 +01:00
sebseb7
4b634414e5 feat: Update MainPageLayout with enhanced star layer effects and improved drop-shadow filters; refine star polygon coordinates for better visual consistency during animations 2026-03-26 16:28:17 +01:00
sebseb7
e8517372f2 feat: Enhance MainPageLayout with improved star decoration animations and initial fill colors; add new teal star layers and update localization strings across multiple languages for better user experience 2026-03-26 15:24:00 +01:00
sebseb7
c6ea6e70fe refactor: Update layout and styling in various components for improved responsiveness and visual consistency on mobile; adjust zIndex and position properties, and enhance navigation handling in ProductDetailPage 2026-03-26 14:59:11 +01:00
sebseb7
d37eb950d1 fix: Update ProductList component to improve responsiveness by adjusting display property for mobile and small screens 2026-03-26 14:37:08 +01:00
sebseb7
665e48e868 feat: Enhance Filter component with collapsible options and clear filter functionality; improve responsiveness and UI feedback on mobile 2026-03-26 14:32:06 +01:00
sebseb7
e0c6d47d98 feat: Enhance ChatAssistant component with dynamic privacy prompt and localization support; update various UI elements for improved accessibility and user experience
Fix product card width on mobile.
2026-03-26 14:21:03 +01:00
sebseb7
bfeb5be1d5 feat: Refactor product catalog output to dynamically generate page links and improve readability of available products 2026-03-26 12:22:21 +01:00
sebseb7
1897ceb7c5 feat: Enhance image processing in data-fetching and update SEO meta tags for product images; add Telegram assistant link in ChatAssistant component with localization support 2026-03-26 11:56:07 +01:00
sebseb7
c5dce64ac9 feat: fix star decoration layers to MainPageLayout and refactor star polygon usage in Product component for improved visual consistency 2026-03-25 17:16:12 +01:00
sebseb7
9e77deb4f8 feat: Implement socket error telemetry in SocketManager to enhance error reporting for socket.io events 2026-03-25 11:22:37 +01:00
sebseb7
5515a59fa1 feat: Refactor Header and CategoryList components to improve category navigation handling and enhance active state management based on pathname 2026-03-25 11:17:24 +01:00
sebseb7
91388244d8 feat: Update legal text for user consent and data processing across multiple locales, ensuring clarity and compliance with regulations 2026-03-25 10:59:10 +01:00
sebseb7
e1a545e4a8 i18n 2026-03-25 10:58:55 +01:00
sebseb7
c8af0bb57a feat: Update legal text for Google login and order processing across multiple locales, improving clarity and compliance with data protection regulations 2026-03-25 09:33:07 +01:00
sebseb7
8df2fc070c feat: Update legal text for AI chatbot across multiple locales, enhancing clarity and compliance with data protection regulations 2026-03-25 09:26:04 +01:00
sebseb7
bc84095361 feat: Add push notifications legal text 2026-03-25 09:02:30 +01:00
sebseb7
ddc460f877 feat: Enhance ProductCarousel with logging and response summarization, update category handling to support virtual categories 'neu' and 'bald' 2026-03-25 08:46:57 +01:00
sebseb7
af6893b5b0 feat: Implement push notification functionality in AddToCartButton, including subscription management and UI updates, and enhance translations for related error messages across multiple locales 2026-03-25 07:57:34 +01:00
sebseb7
0ce8ce3626 feat: Add new virtual categories 'bald' and 'neu', update category handling in renderApp, and enhance translations across multiple locales 2026-03-25 07:04:50 +01:00
sebseb7
e5a3b7bcce feat: Update translation models and enhance order status handling in OrdersTab with new 'picked_up' status and corresponding translations across multiple locales 2026-03-24 07:27:40 +01:00
sebseb7
1840512aac feat: Implement default payment method for pickup and enhance payment method handling in CartTab and PaymentMethodSelector components 2026-03-24 06:51:47 +01:00
sebseb7
e6f3fb7c18 feat: Add 'ready_for_pickup' status to OrdersTab and implement corresponding translations across multiple locales 2026-03-24 06:15:34 +01:00
sebseb7
a9bf1aee5f feat: Integrate Girocode functionality for wire payments in OrdersTab and LoginComponent, enhance user experience with pending payment notifications, and update translations across multiple locales 2026-03-24 00:48:22 +01:00
sebseb7
f47fbc5c39 feat: Add 'awaiting_tracking' status to OrdersTab and update translations across multiple locales 2026-03-23 15:37:42 +01:00
sebseb7
a93aa22a76 feat: Enhance LoginComponent and SocketManager for improved session management and reauthentication 2026-03-19 05:44:03 +01:00
sebseb7
f0e4a94dfc feat: normalize delivery methods in CartTab and DeliveryMethodSelector components 2026-03-18 06:59:20 +01:00
sebseb7
1e1a0c7320 feat: Update translations for indoor season to outdoor season across multiple locales and adjust MainPageLayout component accordingly 2026-03-16 08:13:01 +01:00
sebseb7
80b066842d feat: Enhance ProductFilters to load manufacturer images and display them in the Filter component 2026-03-11 06:41:00 +01:00
sebseb7
78bb99b418 feat: Remove Telegraf link from footer and add ManufacturerCarousel to SharedCarousel component 2026-03-11 05:56:25 +01:00
sebseb7
65a676de46 feat: Implement multi-image product display with fading and hover effects, and introduce lazy-loaded HTML sanitization. 2026-03-10 11:27:15 +01:00
sebseb7
fb6c1159fe feat: Upgrade model version to gpt-5.4 for category description generation and German to English translation 2026-03-06 10:07:56 +01:00
sebseb7
738db9b085 typo 2026-03-06 06:37:33 +01:00
sebseb7
02045cf2ce feat: Update translations for product information and add GPSR safety info across multiple locales 2026-03-06 06:34:31 +01:00
sebseb7
0ccb00db32 feat: Add GPSR safety info translation and remove pre-order cutting button functionality. 2025-12-19 12:46:49 +01:00
sebseb7
dbd5df28f8 feat: Wrap product carousel title in a React Router Link and add a ChevronRight icon. 2025-12-14 10:01:37 +01:00
sebseb7
57515bfb85 feat: Refine i18n content across multiple locales and improve LLM SEO data processing for catalog generation. 2025-12-14 09:47:51 +01:00
sebseb7
9df5642a6e refactor: reimplement category page display with a recursive tree structure and add configurator image. 2025-12-13 02:14:20 +01:00
sebseb7
a50dd086c3 feat: Include 'Kategorien' in Nginx location regex for HTML content. 2025-12-06 21:02:22 +01:00
sebseb7
e88370ff3e feat: add Categories page with refined layout and translation support 2025-12-06 14:29:33 +01:00
sebseb7
5d3e0832fe doc 2025-12-01 13:11:24 +01:00
sebseb7
3347ba2754 Add missing auth translations and update components to use i18n keys
- Added new translation keys to de/auth.js:
  - resetPassword section (title, button, success, invalidToken, error, emailSent, emailError)
  - errors section (fillAllFields, invalidEmail, passwordsNotMatch, passwordsNotMatchShort, enterEmail, loginFailed, registerFailed, googleLoginFailed, emailExists)
  - success section (registerComplete)
  - newPassword, backToHome keys

- Updated ResetPassword.js to use translation keys instead of hardcoded German strings
- Updated LoginComponent.js to use translation keys instead of hardcoded German strings
- translate-i18n.js generated translations for other languages
2025-12-01 13:02:03 +01:00
sebseb7
013a38ca98 fix: update caniuse-lite version and enhance SPA routing for resetPassword
- Updated caniuse-lite to version 1.0.30001757 in package-lock.json for improved compatibility.
- Added functionality to copy index.html to the resetPassword directory for better SPA routing in production environments.
2025-12-01 12:50:41 +01:00
sebseb7
2d6c8ff25f feat(Orders): add tracking shipment link and update translations
- Implemented a tracking shipment link in the OrdersTab component for DHL deliveries, enhancing user experience by allowing direct access to shipment tracking.
- Added 'trackShipment' translation key across multiple languages to support the new feature.
- Updated existing translations for consistency and improved localization in the orders module.
2025-11-29 14:05:59 +01:00
sebseb7
d2ac8d3fc1 feat(Orders): update order status translations and colors
- Refactored order status translations to use English keys for 'new', 'shipped', and 'delivered'.
- Updated corresponding status colors to maintain consistency in the UI.
- Adjusted the display logic to reflect the new status keys in the OrdersTab component.
2025-11-29 13:45:39 +01:00
sebseb7
8928b3f283 feat(Orders): add 'paid' status and update translations across multiple languages
- Introduced 'paid' status to the orders system, enhancing order tracking capabilities.
- Updated translations for 'paid' status in various languages including German, Spanish, French, and more.
- Adjusted related UI components to reflect the new status and ensure consistent user experience across the application.
2025-11-29 13:21:35 +01:00
sebseb7
87db7ba3ea feat(Content, ProductDetail, SearchBar): enhance product handling with translation support
- Updated Content component to process products using translated attributes, improving localization.
- Modified ProductDetailPage to utilize translatedProduct for similar products.
- Adjusted SearchBar to provide suggestions based on translated products, enhancing user experience across components.
2025-11-23 07:53:37 +01:00
sebseb7
766fef2796 feat(ProductDetail): enhance attribute handling with translation support
- Updated ProductDetailPage to utilize translated attributes if available, improving localization.
- Cached both product and attribute data for better performance.
- Adjusted state management to reflect the use of translated attributes in the component.
2025-11-22 12:48:40 +01:00
sebseb7
a08c90a521 fix 2025-11-22 10:12:41 +01:00
sebseb7
10d60d5827 fix 2025-11-22 10:02:59 +01:00
sebseb7
905eee57d5 feat(Translation): add kitConfig.js for improved localization in GrowTentKonfigurator
- Updated the translation model by adding kitConfig.js to the list of translation files.
- Enhanced the GrowTentKonfigurator component to utilize translation functions for various UI texts, improving localization support throughout the configuration process.
2025-11-22 09:59:47 +01:00
sebseb7
3389a9b66c feat(Translation): enhance product dialogs and update translation model
- Added new translation files for product dialogs to support additional languages.
- Refactored various components to utilize translation functions for error messages, labels, and placeholders, enhancing localization support.
2025-11-22 09:43:51 +01:00
sebseb7
d63c385a97 feat(Content): pass categoryName prop to ProductFilters for improved filtering
- Added categoryName prop to the ProductFilters component to have it translated
2025-11-22 08:49:58 +01:00
sebseb7
1b51da69a9 feat(Images): update image URLs to AVIF format in SEO components
- Changed image file extensions from JPG to AVIF in category, feeds, and product SEO components to enhance performance and reduce file sizes.
- Ensured consistent image handling across the application by updating relevant image paths.
2025-11-21 13:21:58 +01:00
sebseb7
da81479d9b refactor(App): update Box component to use 'main' role for improved semantics
- Changed Box component in App.js and PrerenderAppContent.js to use 'main' as the component role, enhancing accessibility and semantic structure of the application.
2025-11-21 11:57:39 +01:00
sebseb7
d8678e261d feat(Images): update image handling to AVIF format across components
- Changed image file extensions from JPG to AVIF in data-fetching, product, category, and image components for improved performance and reduced file sizes.
- Updated image blob creation to reflect the new AVIF format in various components, ensuring consistency in image handling throughout the application.
2025-11-21 11:10:50 +01:00
sebseb7
ef91e50aa5 feat(Images): convert images to AVIF format for improved performance
- Updated image references in various components and configuration files to use AVIF format instead of PNG and JPG.
- Modified the build process to include a script for converting images to AVIF, enhancing loading times and reducing file sizes.
- Ensured consistency across the application by updating image paths in the footer, main layout, and content components.
2025-11-20 11:43:07 +01:00
sebseb7
061bf5ff17 feat(SEO): add price validity date to category JSON-LD
- Introduced a priceValidUntil field in the category JSON-LD to indicate the validity period of product prices, set to three months from the current date.
- This enhancement improves the structured data for SEO, providing clearer information about pricing timelines.
2025-11-20 11:13:48 +01:00
sebseb7
0b915db9eb refactor(ProductDetailPage): improve layout and button functionality
- Adjusted layout to ensure a minimum height for attribute images and action buttons.
- Enhanced button functionality by updating the availability request button and ensuring proper alignment of action buttons.
- Cleaned up conditional rendering for attribute images to streamline the component's structure.
2025-11-20 07:31:11 +01:00
sebseb7
43e67ee4c4 feat(Context): integrate Product and Category context providers into App
- Wrapped AppContent with ProductContextProvider and CategoryContextProvider to manage product and category states.
- Added TitleUpdater component for dynamic title management.
- Enhanced Content and ProductDetailPage components to utilize the new context for setting and clearing current product and category states.
- Updated ProductDetailWithSocket to pass setCurrentProduct function from context.
2025-11-19 09:25:21 +01:00
sebseb7
b599e6424b refactor(CategoryList): simplify layout styles for mobile and desktop views
- Removed conditional styles for flexWrap and overflowX, setting them to "wrap" and "visible" respectively for consistency across devices.
- Cleaned up unused scrollbar styles to streamline the component's CSS.
2025-11-19 08:27:58 +01:00
sebseb7
1ddbafaa51 u 2025-11-19 07:00:33 +01:00
sebseb7
e6faa63219 fix(SEO): update skipCategoryIds to include additional categories
- Expanded the skipCategoryIds array to include new category IDs: 924, 923, 922, 921, 916, 278, 259, and 258.
- This change ensures that the specified categories are excluded from product XML generation.
2025-11-18 08:35:19 +01:00
sebseb7
277edea15e feat(SEO): enhance product and category JSON-LD with shipping and return policy details
- Added merchant return policy and shipping details to the JSON-LD for both product and category schemas.
- Updated delivery method price to reflect the new shipping rate of 5.90 €.
- Improved localization files to include loading messages for products and updated various translations for consistency.
2025-11-18 08:18:51 +01:00
sebseb7
b267b9132a feat(ProductDetailPage): implement embedded product rendering and loading
- Add functionality to render embedded products from <product> tags in the product description.
- Introduce state management for embedded products and their images, including loading and error handling.
- Update localization files to include loading messages for embedded products.
2025-11-17 10:16:16 +01:00
sebseb7
c82a6a8f62 u 2025-11-17 09:19:57 +01:00
sebseb7
6b0ab27a3a u 2025-11-17 09:07:07 +01:00
sebseb7
289baec8cf u 2025-11-17 09:06:47 +01:00
sebseb7
11ba2db893 u 2025-11-17 08:43:57 +01:00
sebseb7
521cc307a3 u 2025-11-17 07:53:04 +01:00
sebseb7
d397930f2c u 2025-11-17 07:49:06 +01:00
sebseb7
8e43eaaede u 2025-11-17 07:38:16 +01:00
sebseb7
13c63db643 u 2025-11-17 07:26:46 +01:00
sebseb7
5b12dad435 u 2025-11-17 07:21:23 +01:00
sebseb7
f20628f71c feat(ProductDetailPage): add manufacturer button for search navigation
- Enhance the ProductDetailPage by introducing a button for the manufacturer that allows users to navigate to search results for that manufacturer.
- Improve user interaction with styling adjustments for the button, ensuring a seamless experience when accessing related products.
2025-11-16 08:19:08 +01:00
sebseb7
f9437a79e6 typo 2025-11-16 08:15:23 +01:00
sebseb7
f665e7c5f8 feat(i18n): add 'searchResultsFor' translation key across multiple languages
- Introduce a new 'searchResultsFor' translation key in various language files to enhance search functionality.
- Update language files for Arabic, Bulgarian, Czech, German, Greek, English, Spanish, French, Croatian, Hungarian, Italian, Polish, Romanian, Russian, Slovak, Slovenian, Albanian, Serbian, Swedish, Turkish, Ukrainian, and Chinese to include this key.
- Ensure consistency in language context for search results across the application.
2025-11-16 08:08:33 +01:00
sebseb7
4f5a44dc7d feat(i18n): add 'more' translation key across multiple languages and enhance SearchBar
- Introduce a new 'more' translation key in various language files to improve internationalization support.
- Update SearchBar component to include an IconButton for additional actions, enhancing user interaction.
- Ensure consistency in language context by adding the 'more' key in Arabic, Bulgarian, Czech, German, Greek, English, Spanish, French, Croatian, Hungarian, Italian, Polish, Romanian, Russian, Slovak, Slovenian, Albanian, Serbian, Swedish, Turkish, Ukrainian, and Chinese.
2025-11-16 07:58:08 +01:00
sebseb7
bf2e5f56ce fix(i18n): update SearchBar placeholder for translation support
- Change SearchBar placeholder text to utilize translation function for improved internationalization.
- Ensure consistency in language context across the application by integrating translation keys.
2025-11-16 07:44:39 +01:00
sebseb7
0c92591d32 feat(navigation): enhance article category handling and product navigation
- Introduce state management for article categories in App component to track active categories.
- Implement logic to clear article category state when navigating away from article pages.
- Update Product component to navigate to article pages with associated category information in the state.
- Modify Header and CategoryList components to accommodate new category handling logic.
- Ensure ProductCarousel and ProductDetailPage components receive and utilize category IDs for improved product organization.
2025-11-16 07:34:39 +01:00
sebseb7
8ea2e50432 feat(i18n): enhance data fetching and caching with language support
- Update data-fetching functions to include language and translation request parameters for improved internationalization.
- Modify caching logic in Content and GrowTentKonfigurator components to utilize language-aware cache keys.
- Ensure ProductDetailPage retrieves cached data based on the current language, enhancing user experience across different locales.
- Integrate language handling in various components to maintain consistency in data management and rendering.
2025-11-15 08:51:23 +01:00
sebseb7
8649408957 feat(i18n): enhance SearchBar to support language context in search queries
- Update SearchBar component to utilize LanguageContext for current language management.
- Modify search request to include language and translation request parameters, improving internationalization support.
- Ensure state management reflects changes in language context for more accurate search functionality.
2025-11-13 06:59:36 +01:00
sebseb7
9e9d9ada4a feat(sanitize-html): integrate sanitize-html for product descriptions
- Add sanitize-html package to sanitize product descriptions, ensuring safe rendering of HTML content.
- Update PrerenderProduct and ProductDetailPage components to utilize sanitize-html for improved security and content integrity.
- Enhance error handling in ProductDetailPage to fallback to plain text if HTML parsing fails.
2025-11-13 06:44:06 +01:00
sebseb7
2bb9a151a3 feat(i18n): add 'similarProducts' key to multiple language files
- Introduce 'similarProducts' translation key across various language files to enhance product detail pages.
- Update existing translations for improved clarity and consistency in product descriptions.
- Ensure proper localization support for the new key in Arabic, Bulgarian, Czech, Greek, French, Croatian, Hungarian, Italian, Polish, Romanian, Russian, Slovak, Slovenian, Albanian, Serbian, Swedish, Turkish, Ukrainian, and Chinese.
2025-11-12 07:46:19 +01:00
sebseb7
4ae9344b63 feat(i18n): enhance caching and data fetching for language support
- Update Content and ProductFilters components to include language in cache keys for category and product data.
- Modify componentDidUpdate to handle language changes, ensuring data is re-fetched or filtered appropriately.
- Improve state management by tracking the last fetched language, enhancing internationalization support across the application.
2025-11-12 07:26:35 +01:00
sebseb7
e00c226b9a feat(i18n): update product filtering on language change
- Enhance Content and ProductFilters components to re-filter products when the language changes or the translation function updates.
- Implement logic in componentDidUpdate to regenerate availability values and filter products accordingly, improving internationalization support.
2025-11-12 06:01:01 +01:00
sebseb7
cf12323dfa feat(carousel): shuffle products in ProductCarousel for enhanced display
- Update ProductCarousel to filter and shuffle a random selection of 15 products with images for display.
- Implement a shuffleArray method using the Fisher-Yates algorithm to ensure a varied product presentation.
- Maintain seamless looping of products in the carousel for improved user experience.
2025-11-11 14:57:30 +01:00
sebseb7
95177c8df7 feat(carousel): add ProductCarousel component and integrate into SharedCarousel
- Introduce a new ProductCarousel component for displaying products in a scrollable format.
- Implement auto-scrolling functionality and manual navigation controls.
- Integrate ProductCarousel into SharedCarousel for the 'neu' category, enhancing the user interface and product visibility.
- Update Product component rendering within the carousel to ensure proper display of product details.
2025-11-02 09:54:54 +01:00
sebseb7
65f29144a6 ignorance 2025-10-31 21:12:58 +01:00
sebseb7
ded5fe330d feat(prerender): add 'Neuheiten' category and update data fetching logic
- Introduce a new category 'Neuheiten' to the rendering process by appending it to the existing categories.
- Modify the data fetching logic to handle the 'neu' category ID correctly.
- Enhance the UI by adding a button for the 'Neuheiten' category in the CategoryList component, complete with styling and internationalization support.
2025-10-21 02:10:49 +02:00
sebseb7
1c9d3d5ad0 feat(ProductDetailPage): implement attribute image loading and caching
- Add loadAttributeImages method to fetch and cache attribute images based on product attributes.
- Update product detail loading logic to include attribute image loading when product data is cached.
- Ensure efficient state management by caching results to minimize server requests.
2025-10-13 05:50:06 +02:00
sebseb7
0e29ab2a61 feat(ui): add similar products section to ProductDetailPage
- Introduce a new section displaying similar products on the ProductDetailPage.
- Update state management to include similar products data.
- Enhance internationalization by adding translation keys for similar products in English, German, and Spanish.
2025-10-08 06:26:00 +02:00
sebseb7
f8f2658653 chat windows full screen in mobileVertical 2025-10-05 22:21:03 +02:00
sebseb7
c82cd5ea78 Fix SearchBar React warnings and improve price display
- Fix duplicate key warning by using seoName-index combination
- Fix deprecated button prop warning by using component='button'
- Add proper button styling (remove default browser styles)
- Fix translation namespace access (use dot notation for nested keys)
- Improve price formatting and prominence in search suggestions
- Align VAT info and delivery time at same height level
2025-10-03 14:13:18 +02:00
sebseb7
f490f60cb7 Enhance SearchBar: remove loading state, add product details in suggestions
- Remove CircularProgress component and loadingSuggestions state
- Remove maxHeight limit from suggestions dropdown (already limiting to 8 results)
- Display price, VAT, and delivery days in suggestion list
- Use existing i18n translation keys (product:inclVat, delivery:times.*)
2025-10-03 13:21:27 +02:00
sebseb7
a13c786b0b feat(ui): implement share functionality in ProductDetailPage
Add a share button with a popper menu to the ProductDetailPage, allowing users to share products via various platforms including WhatsApp, Facebook, and email. Implement snackbar notifications for user feedback on successful actions. Enhance state management to handle share popper and snackbar visibility.
2025-09-18 15:35:13 +02:00
sebseb7
33ad3dd20b feat(ui): add product detail view button to Extras and Product selectors
Enhance the ExtrasSelector and ProductSelector components by introducing a button that links to detailed product views. The button features a ZoomInIcon and is styled for a consistent user experience. This addition improves navigation and accessibility for users seeking more information on products.
2025-09-12 10:36:50 +02:00
sebseb7
3f01ca12b4 feat(ui): add strikethrough original price display for rebated products
Update Product component to show original price with red strikethrough and reduced opacity above the current price when rebate > 0. Calculate original price by reversing rebate percentage. Adjust layout with relative positioning and z-index for overlay. Ensure rebate prop is passed from ProductList to support this feature.
2025-09-11 06:47:57 +02:00
sebseb7
71fb9bafcd feat(ui): add original price display for rebated products 2025-09-11 06:30:10 +02:00
sebseb7
8abaef8110 feat(GrowTentKonfigurator): implement add to cart functionality with component collection 2025-09-10 06:12:38 +02:00
sebseb7
4e708d0a14 feat(ui): add configurator navigation button to header
Introduce a new button in the CategoryList component that links to the
Konfigurator page, featuring a SettingsIcon and responsive styling for
mobile and desktop views. Includes text overlay effects for active state
visualization and i18n support for "home" label.
2025-09-09 19:35:31 +02:00
sebseb7
964a64a96a chore(GrowTentKonfigurator): adjust bundle discount rates
Reduce discount for 5+ items from 24% to 22% and for 7+ items from 36% to 28%.
Update calculation logic and UI display to reflect new rates.
2025-09-09 18:59:40 +02:00
sebseb7
0dd1e01018 feat(GrowTentKonfigurator): add periodic cache validity checking
Implement interval-based cache monitoring every 60 seconds to detect
misses or expirations across categories (Zelte, Lampen, Abluft-sets,
Set-zubehoer). Update per-category load status tracking to conditionally
render sections independently, improving UX by avoiding global loading
delays and ensuring timely refetches. Clear interval on unmount to prevent
memory leaks.
2025-09-09 18:10:08 +02:00
sebseb7
77ffe864b1 feat(GrowTentKonfigurator): add category load status tracking
Introduce categoryLoadStatus state to track loading for product categories.
Replace forceUpdate with setState to properly update loading status on socket response.
2025-09-09 11:39:44 +02:00
sebseb7
9d93ab8f2c feat: add short descriptions to product and extras displays
- Included kurzBeschreibung in the GrowTentKonfigurator for products, lamps, and ventilation components to enhance user information.
- Updated ExtrasSelector to display kurzBeschreibung for each extra, improving clarity and user experience.
2025-09-08 09:10:33 +02:00
sebseb7
09e015a529 fix(GrowTentKonfigurator): simplify force update logic on product list response
Removed conditional check for category 'Zelte' to always force re-render when new product list data arrives, ensuring the UI updates consistently with the latest information.
2025-09-08 08:38:49 +02:00
sebseb7
8ec92ad718 feat(seo): add short description to product LLM text
Include kurzBeschreibung in the generated LLM-friendly product details
for better SEO context, if available.
2025-09-08 05:32:59 +02:00
sebseb7
bccaf703ef feat(seo): prioritize short description for product meta tags
Now uses `kurzBeschreibung` for SEO meta description if available,
falling back to full `description` or name/article number otherwise.
This improves relevance and conciseness of meta tags.
2025-09-08 05:26:03 +02:00
sebseb7
3bf80ce3d7 fix: add pointerEvents to Content component for better interaction handling
- Included pointerEvents: 'none' in the Box of the Content component to prevent user interactions when necessary, enhancing overall UI behavior.
2025-09-08 00:10:19 +02:00
sebseb7
29a4bfc1c6 fix: update Content and ProductDetailPage components for improved UI and functionality
- Added pointerEvents: 'none' to the Content component's Box for better interaction handling.
- Adjusted spacing in the ProductDetailPage's Stack component for a more consistent layout.
- Enhanced disabled Chip styling in ProductDetailPage to improve visibility and user experience.
2025-09-07 12:14:03 +02:00
sebseb7
ea05a83901 u 2025-09-07 07:10:40 +02:00
sebseb7
12ed71b406 refactor: streamline GrowTentKonfigurator by removing unused variables and console logs
- Eliminated unnecessary tracking of response status and related logic for product list updates.
- Removed console logging statements to clean up the code and improve performance.
- Added a check to ensure all category data sections are loaded before rendering related components, enhancing user experience.
2025-09-07 06:09:14 +02:00
sebseb7
1ac253d5f3 format 2025-09-07 05:13:56 +02:00
sebseb7
cbb8dc463f feat: enhance ExtrasSelector and GrowTentKonfigurator for improved extras handling and UI
- Refactored ExtrasSelector to implement dynamic image loading with caching, improving performance and user experience.
- Updated GrowTentKonfigurator to fetch and display extras from a new category, ensuring accurate pricing and availability.
- Enhanced UI elements for better layout and clarity, including loading indicators and improved styling for extras display.
- Added handling for cases when no extras are available, providing clear feedback to users.
2025-09-04 10:45:55 +02:00
sebseb7
479e328e7c feat: update ExtrasSelector and GrowTentKonfigurator for VAT display and extras handling
- Refactored ExtrasSelector to include VAT information for each extra, enhancing clarity for users.
- Removed unused extras data from configuratorData.js to streamline the codebase.
- Updated GrowTentKonfigurator to dynamically retrieve extras from cached data, ensuring accurate pricing and VAT display for selected items.
- Improved UI layout for price and VAT information across various components for better user experience.
2025-09-04 10:31:59 +02:00
sebseb7
3660f80277 feat: improve ventilation selection logic in GrowTentKonfigurator
- Added functionality to reset ventilation selection when the tent shape changes and the current selection is not deliverable.
- Updated product filtering to include all size-matching products while marking their availability status.
- Enhanced UI to visually indicate non-deliverable ventilation options, improving user experience and clarity in selection.
- Adjusted price calculations to consider only deliverable ventilation products.
2025-09-04 07:07:21 +02:00
sebseb7
8862f0c6b8 feat: integrate ventilation selection into GrowTentKonfigurator
- Removed unused imports related to ventilation types and updated the product selection logic to dynamically filter ventilation options based on the selected tent shape.
- Implemented new methods to retrieve and filter available ventilation products, enhancing the user experience by ensuring only relevant options are displayed.
- Updated the UI to reflect the selected tent shape and provide feedback when no matching ventilation products are available.
- Improved overall rendering of the ventilation section with better styling and selection indicators.
2025-09-04 05:54:01 +02:00
sebseb7
21ae00b3f7 feat: enhance GrowTentKonfigurator with lamp filtering and improved UI
- Implemented filtering logic for lamps based on selected tent shape and availability.
- Updated rendering of lamp selection to dynamically display available options based on user input.
- Enhanced user interface with improved styling and selection indicators for better user experience.
- Added console logging for debugging purposes to track filtering and selection processes.
2025-09-03 11:57:06 +02:00
sebseb7
ead44afb69 feat: enhance GrowTentKonfigurator with tent filtering and improved rendering
- Added helper functions to filter tent products by shape and generate coverage descriptions based on dimensions.
- Implemented logic to handle product image rendering with caching and loading states.
- Updated tent selection process to dynamically find and display products based on selected tent shape.
- Enhanced user interface with loading indicators and improved layout for product selection.
2025-09-03 11:31:24 +02:00
sebseb7
1a5143a55d i18n 2025-08-31 07:19:31 +02:00
sebseb7
3a97c2571e fix: update MainPageLayout titles and content boxes for accuracy
- Corrected the title for the home section to reflect the correct translation.
- Swapped content boxes for the home and filiale sections to ensure accurate representation of their respective content.
- Enhanced localization by ensuring all section titles and links are correctly aligned with their intended content.
2025-08-31 07:08:39 +02:00
sebseb7
b3810fded7 fix: update Logo component styling for consistency
- Adjusted Logo component to explicitly set width and height attributes for the logo image.
- Ensured inline styles reflect the new dimensions for better rendering across different devices.
2025-08-31 06:46:34 +02:00
sebseb7
64bf798843 feat: enhance PrerenderHome layout with responsive placeholders
- Updated PrerenderHome component to improve layout responsiveness with additional padding and margin adjustments.
- Added invisible placeholders for SearchBar and ButtonGroup to maintain layout consistency across different screen sizes.
- Enhanced styling for child components to ensure proper alignment and spacing in both mobile and desktop views.
2025-08-31 06:43:20 +02:00
sebseb7
6a144f7441 feat: add prerendering support and improve component imports
- Introduced a new PrerenderHome component for development testing.
- Updated App.js to conditionally render the PrerenderHome based on the route.
- Refactored several components to use ES6 import/export syntax for consistency.
- Enhanced AppContent to manage dynamic theming and added a development-only FAB for prerender testing.
- Minor adjustments to props in PrerenderCategory for clarity.
2025-08-31 06:04:55 +02:00
sebseb7
2ac9baada0 feat: enhance language support in data fetching across components
- Updated Content, ProductDetailPage, and GrowTentKonfigurator to include current language context when emitting WebSocket requests for product and category data.
- Improved caching logic to ensure fresh data loading based on language changes.
- Enhanced localization by adding language parameters to data requests, improving user experience across different languages.
2025-08-09 10:06:43 +02:00
sebseb7
d40e311b51 feat: enhance ProductDetailPage to handle language changes and improve caching logic
- Added logic to detect language changes and clear relevant caches to ensure fresh data loading.
- Updated component state management to reset and reload product data upon language context updates.
- Improved debugging output for better tracking of language-related changes.
2025-08-06 09:50:37 +02:00
sebseb7
7a8d07ffc3 feat: improve product data handling in ProductDetailPage for better localization support
- Updated ProductDetailPage to utilize translated product data when available, enhancing localization.
- Adjusted caching logic to store translated products and their attributes.
- Ensured that component images and related data are loaded from the correct product source, improving user experience.
2025-08-06 08:05:32 +02:00
sebseb7
09cd68c144 feat: enhance ProductDetailPage with language context support for product view requests
- Updated the ProductDetailPage to include the current language context when emitting product view requests via WebSocket.
- Added logic to determine the appropriate language setting, improving localization and user experience.
- Enhanced debugging output for better tracking of language-related issues.
2025-08-06 07:46:16 +02:00
sebseb7
97fd7ee484 feat: update legal document translations and remove obsolete files
- Added translations for legal documents related to consumer rights, battery disposal, and data protection across multiple languages.
- Removed outdated legal-agb.js and legal-datenschutz.js files to streamline the localization structure.
- Improved existing translations for clarity and consistency in legal terminology.
2025-08-05 19:34:11 +02:00
sebseb7
22a0f78db2 feat: enhance AGB component with improved translation support for delivery, payment, and consumer rights
- Updated the AGB component to utilize specific translation functions for delivery, payment, and consumer-related legal terms.
- Improved localization by ensuring all relevant sections are translated correctly based on the context.
- Maintained fallback text for untranslated terms to ensure clarity for users.
2025-08-05 18:30:47 +02:00
sebseb7
55d9f6a543 feat: update data protection legal documents and enhance translation support
- Added new legal documents for data protection, including basic, customer, Google orders, newsletter, chatbot, cookies, and rights.
- Updated the main Datenschutz component to utilize the new translation files for improved localization.
- Removed the old legal-datenschutz.js file to streamline the structure and ensure clarity in legal documentation.
2025-08-05 18:22:50 +02:00
sebseb7
c1d2205e6c feat: update legal document translations and add new language support
- Replaced the existing legal document files with more specific ones for delivery, payment, and consumer rights.
- Added new legal documents related to data protection, including basic, customer, Google orders, newsletter, chatbot, cookies, and rights.
- Introduced Albanian language support in the translation files and language switcher component.
- Enhanced the translation functions to ensure structural files are copied correctly for new languages.
2025-08-05 18:17:08 +02:00
sebseb7
9f707737b4 feat: enhance ProductDetailPage to support partial data loading and improve user feedback with loading descriptions 2025-08-05 15:48:56 +02:00
sebseb7
0a7f7e653b chore: add watchOptions to webpack configuration for improved file watching performance 2025-07-31 08:30:10 +02:00
sebseb7
42fa46f2f9 refactor: unify category data fetching across components to support language context and improve state management 2025-07-31 08:26:10 +02:00
sebseb7
9b38ed6f2a refactor: enhance SharedCarousel to support language context updates and improve category fetching logic 2025-07-31 04:50:55 +02:00
sebseb7
b29b946aaf refactor: enhance category data fetching in CategoryList to support language context updates and improve state management 2025-07-31 04:16:23 +02:00
sebseb7
14787cbd74 feat: add WebSocket URL for client and update GoogleLoginButton text to 'Loading...' for better user feedback 2025-07-30 16:02:11 +02:00
sebseb7
afcdbb29c9 refactor: temporarily remove withI18n from GoogleLoginButton for debugging and enhance error handling in LoginComponent 2025-07-30 15:34:23 +02:00
sebseb7
4584da1199 size optimize 2025-07-27 14:04:36 +02:00
sebseb7
c1f2be99a7 **Commit message:**
Remove redundant comments and simplify layout logic in MainPageLayout

**Description:**
Deleted unnecessary inline comments and streamlined responsive navigation/content rendering logic. Maintained core functionality while improving code clarity and reducing visual noise in the component structure.
2025-07-27 13:53:40 +02:00
sebseb7
7c78c6d85c refactor: update category data fetching in Content component to utilize CategoryService directly and improve clarity in category management 2025-07-24 11:55:08 +02:00
sebseb7
c1810b18b3 refactor: implement CategoryService for category data management and update caching logic in prerender and renderer components 2025-07-24 10:46:10 +02:00
sebseb7
3a8f31c109 Merge branch 'live' of https://git.sebgreen.net/seb/reactShop into live 2025-07-24 10:45:40 +02:00
sebseb7
02ed8c5f9d upd 2025-07-24 10:45:27 +02:00
sebseb7
5662177175 refactor: streamline category ID management in CategoryList by replacing getLevel1CategoryId with setLevel1CategoryId and improving state handling 2025-07-24 10:44:11 +02:00
sebseb7
b9e00ca134 refactor: remove CarouselProvider from PrerenderHome component to streamline layout and improve code clarity 2025-07-24 07:07:02 +02:00
sebseb7
b207377a8e refactor: enhance category data management in CategoryList and CategoryService by integrating async-mutex for improved concurrency control and simplifying state handling 2025-07-24 07:04:54 +02:00
sebseb7
2f753a81a4 refactor: integrate CategoryService into SharedCarousel for improved category data management and enhance component structure 2025-07-24 06:23:37 +02:00
sebseb7
1aabd3ef1e refactor: implement lazy loading for LoginComponent in ButtonGroup to enhance performance and user experience 2025-07-23 11:21:36 +02:00
sebseb7
4879f68998 refactor: simplify category data fetching logic in Content component by removing redundant cache checks and improving clarity in data handling 2025-07-23 10:36:45 +02:00
sebseb7
31c302493a refactor: clean up logging and simplify cache checks in Content component to enhance clarity and maintainability of category data fetching 2025-07-23 10:33:28 +02:00
sebseb7
934f6abc92 refactor: remove socket.io method overrides in Content component to streamline category data fetching and improve code clarity 2025-07-23 10:29:21 +02:00
sebseb7
4dd1b2d227 refactor: update cache handling and logging in SharedCarousel component to prioritize prerendered cache and improve clarity in data fetching 2025-07-23 10:25:04 +02:00
sebseb7
f3e8395000 refactor: improve cache handling and logging in CategoryList component to prioritize prerendered cache and enhance data fetching clarity 2025-07-23 10:24:41 +02:00
sebseb7
95f303bc68 refactor: enhance logging and cache checks in SharedCarousel and CategoryList components to improve data fetching clarity and performance 2025-07-23 10:22:19 +02:00
sebseb7
226ca3e834 refactor: improve cache utilization and data fetching logic in SharedCarousel and CategoryList components for enhanced performance and maintainability 2025-07-23 10:20:35 +02:00
sebseb7
146daf8eb1 refactor: simplify cache management in SharedCarousel and CategoryList components to enhance data fetching efficiency and maintainability 2025-07-23 10:15:49 +02:00
sebseb7
e472e6bb77 refactor: enhance category data fetching logic in SharedCarousel and CategoryList components by simplifying cache checks and improving logging for better maintainability 2025-07-23 10:13:41 +02:00
sebseb7
a2b7a2509f refactor: streamline category data fetching in Content component by reducing logging and simplifying cache checks for improved readability 2025-07-23 10:08:23 +02:00
sebseb7
21ed40c4ce refactor: implement socket.io method overrides and enhance logging for productCache in Content component to improve category data handling 2025-07-23 10:00:46 +02:00
sebseb7
abf94eba86 refactor: add detailed logging for productCache checks in Content component to aid in debugging category data fetching 2025-07-23 09:54:58 +02:00
sebseb7
cd4d124e22 u 2025-07-23 09:50:37 +02:00
sebseb7
b5a78b33cb u 2025-07-23 09:46:54 +02:00
sebseb7
bd4c0a50f1 refactor: optimize category data fetching in Content component by utilizing cached category tree for improved performance and reduced socket queries 2025-07-23 09:45:34 +02:00
sebseb7
23dbdec432 refactor: update image rendering logic in MainPageLayout to load images conditionally based on opacity for improved performance 2025-07-23 09:38:41 +02:00
sebseb7
72010c410e refactor: disable font and image preloading in InlineCssPlugin for improved clarity and maintainability 2025-07-23 09:24:35 +02:00
sebseb7
5dc0280fc7 refactor: enhance InlineCssPlugin to preload JavaScript files alongside fonts and images, improving asset loading order and console logging 2025-07-23 09:22:58 +02:00
sebseb7
bfcc320e6d refactor: ensure safe socket listener management in ButtonGroup and AdminPage components to prevent errors when socketManager is not available 2025-07-23 09:13:09 +02:00
sebseb7
a653908624 refactor: simplify image preloading logic in InlineCssPlugin to focus on homepage and enhance console logging 2025-07-23 09:10:45 +02:00
sebseb7
1e8e6d7ac1 refactor: comment out unused critical image in InlineCssPlugin for improved clarity and maintainability 2025-07-23 09:01:24 +02:00
sebseb7
acdfc38b4a refactor: update image preloading logic in InlineCssPlugin to use output filename for page type detection and improve console logging 2025-07-23 08:58:14 +02:00
sebseb7
c906e0c936 refactor: enhance image preloading logic in InlineCssPlugin for better performance and clarity 2025-07-23 08:52:53 +02:00
sebseb7
cee69c9a31 refactor: remove SocketContext and related dependencies from OrdersTab and ProfilePage components for improved performance and code clarity 2025-07-23 08:46:35 +02:00
sebseb7
1c777f8daa feat: add console logging for image loading and product data fetching in Product and ProductDetailPage components for improved debugging 2025-07-23 08:41:26 +02:00
sebseb7
602324b1fe fix: clear product image URL from window object on component unmount to prevent memory leaks 2025-07-23 08:36:13 +02:00
sebseb7
d16e979771 feat: store product image URL in window object for improved accessibility in image handling 2025-07-23 08:35:08 +02:00
sebseb7
61faf654bc refactor: standardize socket communication by replacing socket prop usage with window.socketManager across multiple components for improved consistency and maintainability 2025-07-23 08:21:30 +02:00
sebseb7
4e6b63a6a4 refactor: replace socket prop usage with window.socketManager for consistent socket handling across components 2025-07-23 08:08:58 +02:00
sebseb7
9982527f35 refactor: remove socket context dependencies and streamline socket handling in components for improved performance and readability 2025-07-23 07:57:13 +02:00
sebseb7
bde001c39b refactor: clean up imports and remove unused socket context in Header component for improved readability 2025-07-23 07:37:33 +02:00
sebseb7
bbd1371eb2 refactor: optimize socket connection handling by removing polling transport and implementing lazy connection strategy in SocketManager for improved performance 2025-07-23 07:29:15 +02:00
sebseb7
d8f438c3f3 feat: implement SocketManager for websocket communication, creating a singleton instance and attaching it to the window object 2025-07-23 07:19:54 +02:00
sebseb7
9c3a4ee91b chore: remove accessibility utility functions from accessibilityUtils.js as they are no longer needed 2025-07-21 01:40:55 +02:00
sebseb7
bad176a6d1 feat: implement accessibility improvements by ensuring alt text is always present on image error events and initialize accessibility checking in App component 2025-07-21 01:39:50 +02:00
sebseb7
d70fac24ed refactor: update Typography components across multiple files to improve consistency in heading levels and styles 2025-07-21 01:29:03 +02:00
sebseb7
c4bd28ba92 feat: add aria-label attributes to Image and PhotoUpload components for improved accessibility, and enhance OrdersTab with localized aria-labels for better user experience 2025-07-21 01:24:55 +02:00
sebseb7
24b762b9d6 refactor: streamline image preloading logic in webpack config by defining critical images outside of conditional checks and improving preload count logging 2025-07-21 01:12:21 +02:00
sebseb7
464f159556 feat: enhance SocketProvider to support polling and websocket transports, improve error logging for development, and update image preloading logic in webpack config for better performance on main pages 2025-07-21 01:10:13 +02:00
sebseb7
0a787f9d25 feat: update global CSS handling in prerender to differentiate between production and development environments, ensuring proper font path management 2025-07-20 15:47:58 +02:00
sebseb7
5202ff6e3e feat: implement lazy loading for languages in i18n, enhance LanguageSwitcher to handle language changes asynchronously, and update available languages management 2025-07-20 15:44:50 +02:00
sebseb7
2b64719758 feat: optimize image loading and critical rendering path by adding image preloads in webpack config, updating image handling in multiple components, and ensuring alt attributes are set for accessibility 2025-07-20 15:19:16 +02:00
sebseb7
e0da7ed312 feat: add aria-label attributes to various components for improved accessibility 2025-07-20 15:12:09 +02:00
sebseb7
a68d912c99 feat: enhance image loading performance by adding fetchPriority and loading attributes in multiple components, and update MUI icons chunking in webpack configuration 2025-07-20 15:05:29 +02:00
sebseb7
d3998133e5 feat: update CopyAssetsPlugin to exclude fonts during asset copying 2025-07-20 14:47:57 +02:00
sebseb7
1fd6ed85b6 remove prerender after socket event 2025-07-20 14:33:03 +02:00
sebseb7
b2474a595c feat: add InlineCssPlugin to inline CSS assets in production builds 2025-07-20 14:13:39 +02:00
sebseb7
f748056568 fix 2025-07-20 12:45:09 +02:00
sebseb7
5cff3e2c2a style: update comment formatting for GitHub Issue Reporter FAB in App.js 2025-07-20 12:40:24 +02:00
sebseb7
8629dc5d87 refactor: remove combined CSS file writing and optimize page-specific CSS handling in prerender logic 2025-07-20 12:35:05 +02:00
sebseb7
275ee3bea6 feat: update layout in PrerenderProduct to match SPA design with invisible placeholders for SearchBar and ButtonGroup 2025-07-20 12:00:11 +02:00
sebseb7
3d136775e2 feat: enhance image loading and socket handling in Product and Images components, and update prerender logic in App and ProductDetailPage 2025-07-20 11:53:27 +02:00
sebseb7
92987a518b feat: implement product image saving in prerender-single-product and enhance image loading logic in Images component 2025-07-20 10:46:22 +02:00
sebseb7
bffb1fed27 feat: add prerender scripts for single product rendering and enhance layout in PrerenderProduct and Footer components 2025-07-20 10:40:22 +02:00
sebseb7
b8d8003ac3 style: update Footer component styles for improved layout and responsiveness 2025-07-20 10:14:52 +02:00
sebseb7
19cf475b0e feat: add prerender-single-product script for single product rendering with i18n support 2025-07-20 10:12:16 +02:00
sebseb7
1fb92e2df9 refactor: simplify image loading logic and enhance prerender handling in Images component 2025-07-20 01:36:56 +02:00
sebseb7
bdd50770be fix: enhance image loading logic to handle static paths when sockets are unavailable 2025-07-20 01:28:36 +02:00
sebseb7
ca98c304e5 fix 2025-07-20 01:22:53 +02:00
sebseb7
543c8c4f30 refactor: remove unused Images import from ProductDetailPage component 2025-07-20 01:20:16 +02:00
sebseb7
bfd1803c6f refactor: remove isPrerender prop and update ProductImage component to unify fullscreen handling in PrerenderProduct and ProductDetailPage 2025-07-20 01:16:44 +02:00
sebseb7
ea488982a7 feat: introduce ProductImage component to streamline image handling in PrerenderProduct and ProductDetailPage 2025-07-20 01:12:50 +02:00
sebseb7
a21efab9d2 fix 2025-07-20 01:11:41 +02:00
sebseb7
abe1bbfb67 fix 2025-07-20 01:06:27 +02:00
sebseb7
195ff493b8 fix 2025-07-20 01:01:45 +02:00
sebseb7
e80fedf9a9 refactor: adjust alignment in Footer component for better visual consistency 2025-07-20 00:57:47 +02:00
sebseb7
b8441b3ceb refactor: remove fixed height and redundant minHeight properties in PrerenderProduct for improved layout flexibility 2025-07-20 00:48:15 +02:00
sebseb7
3df20cbc6a refactor: replace Box with Container for improved layout consistency in PrerenderProduct and ProductDetailPage components 2025-07-20 00:34:46 +02:00
sebseb7
cc679e77a9 refactor: simplify layout and improve styling for product attributes and sections in PrerenderProduct component 2025-07-20 00:28:47 +02:00
sebseb7
5d14bef740 upd 2025-07-20 00:22:43 +02:00
sebseb7
27de1c3406 fix 2025-07-20 00:15:33 +02:00
sebseb7
8e6e020a1b feat: update product detail caching and component loading logic for improved SPA performance 2025-07-20 00:10:55 +02:00
sebseb7
055e49c957 feat: enhance product detail caching for SPA hydration and improve rendering logic 2025-07-20 00:04:24 +02:00
sebseb7
5a3865aa3c feat: implement XML validation for Google Shopping schema compliance and add validation script 2025-07-19 23:37:08 +02:00
sebseb7
fe93bfd7df upd 2025-07-19 23:18:21 +02:00
sebseb7
3afce32e3d fix: update titles and content boxes in MainPageLayout for improved section mapping and navigation 2025-07-19 23:13:22 +02:00
sebseb7
349b004627 fix: validate product weight before XML generation and update weight handling 2025-07-19 23:04:52 +02:00
sebseb7
f2ee641bfd fix: update product meta tag generation and clean up footer comments 2025-07-19 22:35:52 +02:00
sebseb7
2774c6924f fix: add missing weight and description logs 2025-07-19 22:03:43 +02:00
sebseb7
f93cde5131 Enhance XML generation for products: Added logging for products missing weight and those with insufficient descriptions. Implemented checks to track and log these products, ensuring better data quality for SEO. Created log files for missing data, improving maintainability and oversight of product information. 2025-07-19 11:40:44 +02:00
sebseb7
dfb4f3e189 Refactor PrerenderProduct layout: Introduced a back button for navigation, improved styling with Box components, and ensured consistent heights for product details and price sections. Enhanced product display with fixed height placeholders and updated availability messaging for better user experience. 2025-07-19 11:30:37 +02:00
sebseb7
5fb3e10598 Enhance product display in PrerenderProduct: Added a utility function to clean product names, improved layout with Box components for better styling, and formatted price display with tax. Updated product details section to include manufacturer, weight, and availability status, ensuring a more informative and visually appealing product presentation. 2025-07-19 11:23:31 +02:00
sebseb7
b602444066 Skip out-of-stock products in XML generation: Added logic to exclude products that are not available from the generated XML, improving the accuracy of product listings for SEO purposes. 2025-07-19 06:25:57 +02:00
sebseb7
7eb70abb22 Refactor star graphics in MainPageLayout: Replaced div elements with Box components for improved styling and layout control. Adjusted positioning and added drop-shadow effects for enhanced visual appeal. Updated display properties to ensure responsiveness across different screen sizes. 2025-07-19 06:19:00 +02:00
sebseb7
65ffc1542f Refine language-specific category fallback in SharedCarousel: Updated the fallback logic to only use the old cache format for German language requests, preventing incorrect category displays for English users. Enhanced language state management to ensure synchronization with i18n initialization. 2025-07-18 17:20:51 +02:00
sebseb7
8bc80c872d Enhance GTIN validation in product XML generation: Improved the GTIN validation logic in feeds.cjs to include checksum verification, ensuring only products with valid GTINs are processed. This update enhances data integrity for SEO purposes. 2025-07-18 16:09:37 +02:00
sebseb7
85504c725f Add demo mode functionality to LanguageProvider: Introduced demo mode to automatically cycle through available languages every 5 seconds. Updated state management to include demo mode and current language index, and added methods to start and stop the demo. Enhanced language change handling to stop demo mode when a user manually selects a language. 2025-07-18 15:44:00 +02:00
sebseb7
04e97c2522 Add CSS animations for rotating stars in MainPageLayout: Implemented new animations for star graphics to enhance visual appeal. Updated SharedCarousel to support dynamic language changes and improved category fetching logic. Enhanced localization files with new text for indoor season prompts in multiple languages. 2025-07-18 15:26:11 +02:00
sebseb7
93887ce397 Add multi-pointed star feature to home page layout: Implemented a hoverable star graphic in the seeds box, enhancing visual engagement. Updated localization files to include new text for user prompts in multiple languages. 2025-07-18 14:55:50 +02:00
sebseb7
33fadc0279 Update excluded terms in product XML generation: Added 'marihuana' to the list of excluded terms in product titles within feeds.cjs, enhancing content filtering for SEO purposes. 2025-07-18 13:18:08 +02:00
sebseb7
5c2b4172da Enhance GTIN validation in product XML generation: Updated logic in feeds.cjs to skip products with invalid GTIN formats, ensuring only valid products are included in the generated XML. This improves data integrity for SEO purposes. 2025-07-18 13:13:37 +02:00
sebseb7
c663e902ea Implement product filtering based on excluded terms: Added logic to skip products with specific terms in their title or description during XML generation in feeds.cjs, enhancing content management for SEO purposes. 2025-07-18 12:49:54 +02:00
sebseb7
aa82e8d1d2 Update categoryList socket emissions to include language and translation request: Modified the socket.emit calls in SharedCarousel, Sitemap, and prerender.cjs to support language specification and translation requests. Added JTL language mappings in translate-i18n.js for improved localization. 2025-07-18 12:36:39 +02:00
sebseb7
67f0126343 Enhance photo upload functionality in ArticleQuestionForm and ArticleRatingForm: Added reset method to PhotoUpload component and integrated it into both forms to clear uploaded files upon submission. Improved user experience by ensuring the photo upload state resets after form submission. 2025-07-18 12:13:01 +02:00
sebseb7
47a882b667 Add article interaction forms: Implemented ArticleAvailabilityForm, ArticleQuestionForm, and ArticleRatingForm components for user inquiries, ratings, and availability requests. Integrated photo upload functionality and enhanced user experience with collapsible sections in ProductDetailPage for better interaction with product details. 2025-07-18 11:56:37 +02:00
sebseb7
e43b894bfc Filter out delivery items in OrdersTab quantity calculation: Updated the item quantity calculation to exclude specific delivery items, improving accuracy in order summaries. 2025-07-18 06:58:25 +02:00
sebseb7
c9477e53b6 Enhance localization in CategoryList component: Updated category fetching logic to support dynamic language changes and improved cache handling for category data based on the current language. Adjusted delivery class display in CartItem for better user experience. 2025-07-18 05:08:57 +02:00
sebseb7
0015872894 Add tooltips and cancellation confirmation for orders: Enhanced localization by adding tooltips for viewing details and canceling orders across multiple languages. Implemented cancellation confirmation messages to improve user experience when canceling orders. 2025-07-18 01:28:28 +02:00
sebseb7
cb8ce69903 Implement order cancellation feature in OrdersTab: Added functionality to confirm and cancel orders, including a confirmation dialog. Enhanced payment method display in OrderDetailsDialog and updated VAT calculations. Improved localization for order-related messages across multiple languages. 2025-07-17 21:35:00 +02:00
sebseb7
64048e6d0b Enhance order ID handling and navigation in ProfilePage and OrdersTab: Updated hash processing to validate order IDs, improved tab navigation, and added console logging for better debugging. Adjusted navigation to ensure correct hash updates when switching tabs and viewing orders. 2025-07-17 12:43:39 +02:00
sebseb7
e4b70dcbe2 Refactor payment method handling: Changed payment method from 'mollie' to 'wire' in CheckoutValidation and PaymentMethodSelector components. Removed Mollie-related content from Datenschutz page to streamline payment information. 2025-07-16 16:35:05 +02:00
sebseb7
1de3ba0115 Update PaymentSuccess component to set redirect hash based on payment status: '#orders' for successful payments and '#cart' for failed payments. 2025-07-16 13:47:16 +02:00
sebseb7
4ef27da561 Update shipping costs label in OrderDetailsDialog for localization support 2025-07-16 13:18:16 +02:00
sebseb7
0895919448 Add comprehensive legal translations: Expanded legal document translations to include multiple languages, ensuring accurate localization for various legal sections. Updated translation handling for dynamic content and improved consistency across all legal texts. 2025-07-16 13:12:58 +02:00
sebseb7
6a017400fa Enhance legal document translations: Improved accuracy and consistency in translations for various legal sections, including updates to dynamic content rendering and minor formatting corrections. Ensured compliance with localization standards for legal terminology. 2025-07-16 13:00:11 +02:00
sebseb7
3611908021 Refactor legal document translations: Updated various legal sections across multiple languages, ensuring accurate localization and dynamic content rendering in components. Enhanced translation handling for battery regulations and privacy policies, while correcting minor text formatting issues. 2025-07-16 12:52:42 +02:00
sebseb7
98393cea21 Add legal document translations: Introduced new legal sections for AGB, Impressum, Datenschutz, Widerrufsrecht, and Batteriegesetzhinweise. Updated components to utilize translation hooks for dynamic content rendering, ensuring accurate localization of legal terms and conditions. 2025-07-16 12:00:10 +02:00
sebseb7
c078abec1a Add special handling for legal document translations: Introduced new constants for legal files and system prompts in translate-i18n.js. Enhanced translation functions to accommodate legal document requirements, ensuring preservation of German legal terms and structure during translations. 2025-07-16 11:49:37 +02:00
sebseb7
a7cfbce072 more translations 2025-07-16 11:31:48 +02:00
sebseb7
65611865c8 trnalsate 2025-07-16 10:37:13 +02:00
sebseb7
a8c77e1107 upd 2025-07-16 10:12:06 +02:00
sebseb7
4f5bc96c9b upd 2025-07-16 09:57:45 +02:00
sebseb7
3e3e676ded trasnlsater 2025-07-16 09:45:11 +02:00
sebseb7
08c04909e0 translate 2025-07-16 09:21:40 +02:00
sebseb7
510907b48a Fix syntax error in English translation file: Corrected closing bracket in translation.js to ensure proper export structure. 2025-07-16 08:26:20 +02:00
sebseb7
e02b18e17f Refactor translation files and update import syntax: Converted CommonJS to ES module syntax in translate-i18n.js. Enhanced English translation file with new phrases for profile, order summary, and settings sections, while improving existing translations for clarity and consistency. 2025-07-16 08:24:09 +02:00
sebseb7
9ffbd5b84e Enhance German translation file: Updated phrases for clarity and consistency, added new terms for delivery and settings sections, and improved error messages for better user guidance. 2025-07-16 08:20:58 +02:00
sebseb7
f8dbb24823 Refactor project for improved localization and user experience: Renamed project to "reactshop" and updated package-lock.json with new dependencies. Enhanced profile navigation by updating the ButtonGroup component to navigate to the cart section. Improved German translation files with additional phrases for better context and clarity. Updated CartTab component to utilize the latest i18n functionality. 2025-07-16 08:14:16 +02:00
sebseb7
13f1e14a3d Update MainPageLayout styles for improved layout consistency: Adjusted positioning properties for titles to enhance visual alignment and responsiveness across different page types, ensuring a more cohesive user experience. 2025-07-16 06:30:49 +02:00
sebseb7
6b7bcf4155 Remove unnecessary alignment properties from MainPageLayout: Simplified layout code by eliminating redundant alignItems settings for better readability and maintainability. 2025-07-16 06:28:21 +02:00
sebseb7
45258ac522 Refactor MainPageLayout for improved responsiveness: Updated layout to stack title above navigation on portrait phones, adjusted flex properties for better alignment, and enhanced navigation rendering for different screen sizes, ensuring a more user-friendly experience across devices. 2025-07-16 06:26:37 +02:00
sebseb7
080515af68 Add new language support for Slovenian, Croatian, Swedish, Turkish, Greek, Arabic, and Chinese: Updated config.js to include additional language configurations, enhancing localization capabilities across the application. 2025-07-16 06:25:50 +02:00
sebseb7
33b229728f Update Arabic and Chinese translation files: Improved localization by enhancing existing translations with better context and comments for clarity. Adjusted navigation, authentication, cart, product, and other sections to ensure accurate representation of terms and phrases in both languages. 2025-07-16 06:19:37 +02:00
sebseb7
8d69b0566b Enhance translation functionality and localization support: Updated translate-i18n.js to include new command line options for skipping and only translating English. Modified package.json to add new translation scripts. Improved localization files for multiple languages with better comments for clarity and accuracy, ensuring comprehensive support for internationalization. 2025-07-16 06:17:27 +02:00
sebseb7
280916224a Enhance localization support for Polish translations: Updated Polish translation files with improved comments for clarity and accuracy, ensuring better context for users. Added export of i18n utilities for easier access in the application. 2025-07-16 06:10:40 +02:00
sebseb7
fd77fc8f7f Integrate i18n support for German and enhance localization: Initialize i18next for prerendering with German as the default language. Import and configure translation files for multiple languages, including Hungarian, Italian, and others, ensuring comprehensive localization support across the application. Update Hungarian and Italian translation files with improved comments for clarity and accuracy. 2025-07-16 06:09:01 +02:00
sebseb7
f5d6778def Refactor project structure and enhance localization: Rename project to "reactshop" and update package.json with new dependencies and scripts for development and production. Update Greek, Spanish, French, and Croatian translation files with improved comments for clarity and accuracy, ensuring better localization support across the application. 2025-07-16 06:06:08 +02:00
sebseb7
11a3522a97 Enhance i18n support by adding new language translations: Introduced Arabic, Croatian, Czech, Greek, Hungarian, Slovak, Slovenian, Swedish, Turkish, and updated existing language configurations. Updated available languages in LanguageContext and LanguageProvider to reflect the new additions, ensuring comprehensive localization across the application. 2025-07-16 06:02:04 +02:00
sebseb7
51471d4a55 Refactor project for i18n support: Rename project to "i18n-translator" and update package.json and package-lock.json accordingly. Enhance localization by integrating translation functions across various components, including AddToCartButton, Content, GoogleLoginButton, and others, to provide dynamic text rendering based on user language preferences. Update localization files for multiple languages, ensuring comprehensive support for internationalization. 2025-07-16 05:59:48 +02:00
sebseb7
859a2c06d8 Add Chinese language support and update localization files: Introduced translations for Chinese (zh) in LanguageSwitcher and i18n configuration. Removed outdated translation files for several languages, streamlining localization resources. Enhanced language context to include Chinese in available languages. 2025-07-16 03:34:10 +02:00
sebseb7
5c90d048fb Integrate i18n support across multiple components: Update AddToCartButton, CartDropdown, CartItem, Footer, ProductFilters, ProductList, and profile components to utilize translation functions for dynamic text rendering. Enhance user experience by providing localized content for various UI elements, including buttons, labels, and tax information. 2025-07-16 03:03:47 +02:00
sebseb7
cff9c88808 Implement multilingual support: Integrate i18next for language translation across components, update configuration for multilingual descriptions and keywords, and enhance user interface elements with dynamic language switching. Add new dependencies for i18next and related libraries in package.json and package-lock.json. 2025-07-16 02:34:36 +02:00
sebseb7
b78de53786 Enhance delivery cost calculation and shipping information display: Implement free shipping threshold for cart value in DeliveryMethodSelector and OrderProcessingService. Update CartDropdown and OrderSummary to reflect shipping costs and free shipping messages based on cart value, improving user clarity on shipping fees. 2025-07-16 01:59:43 +02:00
sebseb7
925667fc2c Update font size and padding in CategoryBox component for improved readability and layout consistency. 2025-07-15 21:00:27 +02:00
sebseb7
251352c660 Update payment method name in PaymentMethodSelector to include additional options: Apple Pay, Google Pay, and PayPal, enhancing clarity for users on available payment methods. 2025-07-15 13:25:52 +02:00
sebseb7
88c757fd35 Refactor webpack configuration to copy index.html to payment directory, enhancing clarity in log messages and ensuring proper directory structure for asset management. 2025-07-15 13:06:06 +02:00
sebseb7
d8c802c2f1 Update webpack configuration to copy index.html to payment/success/success, improving clarity in log messages for asset copying. 2025-07-15 13:04:42 +02:00
sebseb7
056b63efa0 Update webpack configuration to copy index.html to payment/success directory and change base URL in config.js to production URL. 2025-07-15 13:03:34 +02:00
sebseb7
c7afad68b0 Refactor error handling in PaymentSuccess component to redirect users to profile on payment failure, simplifying the user experience by removing the error display box. 2025-07-15 12:29:57 +02:00
sebseb7
5157b7d781 Add Mollie payment integration: Implement lazy loading for PaymentSuccess page, update CartTab and OrderProcessingService to handle Mollie payment intents, and enhance ProfilePage to manage payment completion for both Stripe and Mollie. Update base URL for development environment. 2025-07-15 12:13:57 +02:00
seb
9072a3c977 Implement Mollie payment integration across CartTab, CheckoutValidation, and PaymentMethodSelector components. Update payment method handling to prioritize Mollie for specific delivery methods and ensure proper session storage for Mollie transactions. Enhance Datenschutz page to include Mollie payment processing details. 2025-07-15 10:41:10 +02:00
sebseb7
838e2fd786 upd 2025-07-15 09:46:25 +02:00
seb
abbb5e222d Fix CSS formatting in index.css by removing unnecessary whitespace after closing code block, ensuring cleaner styling and consistency across the stylesheet. 2025-07-08 00:09:19 +02:00
seb
c216154bd7 Update launch configuration for development environment and enhance ProductDetailPage styling. Added environment variables and skip files to launch.json, and improved image handling in ProductDetailPage with consistent dimensions and border styling. 2025-07-07 11:41:49 +02:00
seb
9000b28ce5 Enhance product detail handling by integrating komponenten data into relevant components. Updated AddToCartButton, CartItem, Product, and ProductDetailPage to support new komponenten properties, improving product representation and pricing logic. Updated 404 image asset for better user experience. 2025-07-07 08:25:24 +02:00
seb
8f2253f155 Update GrowTentKonfigurator to include socket-based category data fetching and caching. Modify MainPageLayout titles for improved clarity and update route to pass socket props for configurator component. 2025-07-07 05:28:01 +02:00
seb
b33ece2875 Update link in MainPageLayout from German to English category for improved clarity and consistency in navigation. 2025-07-07 02:59:42 +02:00
seb
02aff1e456 Enhance unit pricing logic in feeds.cjs by adding a comprehensive unit mapping for German to Google Shopping units. Implemented a helper function to convert units and adjusted base measure calculations to ensure compliance with German regulations. Improved formatting of unit pricing measures for better clarity. 2025-07-07 02:34:13 +02:00
seb
9e14827c91 Enhance 404 handling in webpack configuration with async middleware for prerendering. Updated NotFound404 page to improve user experience with localized messaging and responsive image styling. Added taxonomy ID mappings in feeds.cjs for better compliance and clarity in product categorization. 2025-07-07 02:12:19 +02:00
seb
8698816875 Add middleware to handle 404 routes in webpack configuration. Implemented custom response handling to ensure proper 404 status and no-cache headers, along with rewrites for SPA fallback to improve routing behavior. 2025-07-06 23:50:30 +02:00
seb
987de641e4 Refactor unit pricing logic in feeds.cjs to enhance compliance with German regulations. Updated the helper function to return structured unit pricing data, including both unit and base measures, and adjusted XML generation accordingly. 2025-07-06 22:54:13 +02:00
seb
23e1742e40 Add unit pricing measure to product XML generation in feeds.cjs. Updated Product, ProductDetailPage, and AddToCartButton components to support new pricing fields (fGrundPreis, cGrundEinheit) for compliance with German regulations. Enhanced SearchBar with enter icon functionality for improved user experience. 2025-07-06 20:36:23 +02:00
seb
205558d06c Add CarouselProvider to Prerender components for improved layout structure. Updated PrerenderAppContent and PrerenderHome to wrap MainPageLayout with CarouselProvider, enhancing component organization and consistency. 2025-07-06 09:35:34 +02:00
seb
046979a64d Refactor Prerender components to replace Home page with MainPageLayout, improving structure and consistency across the application. Updated routing in PrerenderAppContent and PrerenderHome to utilize the new layout component. 2025-07-06 09:33:34 +02:00
seb
161e377de4 Update category mappings in feeds.cjs for improved accuracy and clarity. Adjusted several category paths to reflect more specific classifications, and added validation to ensure non-empty category returns. Updated language setting to 'de-DE' for consistency. 2025-07-06 09:30:10 +02:00
seb
73a88f508b Refactor App component to replace Home page with MainPageLayout, integrating CarouselProvider for improved page structure. Added new routes for Presseverleih and ThcTest pages, enhancing navigation and organization. Updated Header component to support new page states for Aktionen and Filiale. 2025-07-06 09:25:39 +02:00
987 changed files with 40582 additions and 5934 deletions

View File

@@ -0,0 +1,5 @@
---
alwaysApply: false
---
never run your own dev sever, it can be restarted with ```pm2 restart dev_seedheads_fron```
get logoutput lioke this ```pm2 log dev_seedheads_fron --lines 20 --nostream```

60
.gitignore vendored
View File

@@ -1,61 +1,3 @@
# dependencies
/node_modules
/.pnp
.pnp.js
.cursor/
# testing
/coverage
# production
/build
/dist
/public/index.prerender.html
/public/assets/images/prod*.jpg
/public/assets/images/cat*.jpg
/public/prerender.css
/public/Artikel/*
/public/Kategorie/*
/public/agb
/public/batteriegesetzhinweise
/public/datenschutz
/public/impressum
/public/sitemap
/public/widerrufsrecht
/public/robots.txt
/public/sitemap.xml
/public/index.prerender.html
/public/Konfigurator
/public/profile
/public/404
/public/products.xml
/public/llms*
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
.hintrc
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor directories and files
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Local configuration
src/config.local.js
# Local development notes
dev-notes.md
dev-notes.local.md
/logs

66
.vscode/launch.json vendored
View File

@@ -3,20 +3,76 @@
// This will install dependencies before starting the dev server
"version": "0.2.0",
"configurations": [
{
"type": "node-terminal",
"name": "Start with API propxy to seedheads.de (Install Deps)",
"request": "launch",
"command": "npm run start:seedheads",
"preLaunchTask": "npm: install",
"cwd": "${workspaceFolder}"
}, {
"type": "node-terminal",
"env": {
"NODE_ENV": "development"
},
"envFile": "${workspaceFolder}/.env",
"skipFiles": [
"<node_internals>/**"
]
},
{
"name": "Start",
"type": "node-terminal",
"request": "launch",
"command": "npm run start",
"cwd": "${workspaceFolder}"
"env": {
"NODE_ENV": "development"
},
"envFile": "${workspaceFolder}/.env",
"skipFiles": [
"<node_internals>/**"
]
},
{
"type": "chrome",
"request": "launch",
"name": "Launch Chrome - Debug React App",
"url": "https://dev.seedheads.de",
"webRoot": "${workspaceFolder}/src",
"sourceMapPathOverrides": {
"webpack://reactshop/./src/*": "${webRoot}/*",
"webpack://reactshop/src/*": "${webRoot}/*",
"webpack:///src/*": "${webRoot}/*",
"webpack:///./src/*": "${webRoot}/*",
"webpack:///./*": "${workspaceFolder}/*",
"webpack:///./~/*": "${workspaceFolder}/node_modules/*",
"webpack://*": "${workspaceFolder}/*"
},
"smartStep": true,
"skipFiles": [
"<node_internals>/**",
"${workspaceFolder}/node_modules/**",
"${workspaceFolder}/dist/**"
]
},
{
"type": "chrome",
"request": "attach",
"name": "Attach to Chrome - Debug React App",
"port": 9222,
"webRoot": "${workspaceFolder}/src",
"sourceMapPathOverrides": {
"webpack://reactshop/./src/*": "${webRoot}/*",
"webpack://reactshop/src/*": "${webRoot}/*",
"webpack:///src/*": "${webRoot}/*",
"webpack:///./src/*": "${webRoot}/*",
"webpack:///./*": "${workspaceFolder}/*",
"webpack:///./~/*": "${workspaceFolder}/node_modules/*",
"webpack://*": "${workspaceFolder}/*"
},
"smartStep": true,
"skipFiles": [
"<node_internals>/**",
"${workspaceFolder}/node_modules/**",
"${workspaceFolder}/dist/**"
]
}
]
}

View File

@@ -11,3 +11,43 @@ Entpacken & Doppelklick auf `start-dev-seedheads.bat` - das Skript wird:
- Abhängigkeiten automatisch installieren falls nötig
- Entwicklungsserver mit API-Proxy zu seedheads.de starten
- Browser öffnen auf http://localhost:9500
## Socket Connection Optimization
The application uses Socket.IO for real-time communication with the server. To improve initial loading performance, sockets are now connected lazily:
- Sockets are created with `autoConnect: false` and only establish a connection when:
- The first `emit` is called on the socket
- An explicit connection is requested via the context methods
### Usage
```jsx
// In a component
import React, { useContext, useEffect } from 'react';
import SocketContext from '../contexts/SocketContext';
import { emitAsync } from '../utils/socketUtils';
const MyComponent = () => {
const { socket, socketB } = useContext(SocketContext);
useEffect(() => {
// The socket will automatically connect when emit is called
socket.emit('someEvent', { data: 'example' });
// Or use the utility for Promise-based responses
emitAsync(socket, 'getData', { id: 123 })
.then(response => console.log(response))
.catch(error => console.error(error));
}, [socket]);
return <div>My Component</div>;
};
```
### Benefits
- Reduced initial page load time
- Connections established only when needed
- Automatic fallback to polling if WebSocket fails
- Promise-based utilities for easier async/await usage

282
docs/i18n.md Normal file
View File

@@ -0,0 +1,282 @@
# Internationalization (i18n)
This project uses [i18next](https://www.i18next.com/) with [react-i18next](https://react.i18next.com/). The setup works with both function components and class components (HOCs).
## Overview
- **Default language:** German (`de`), bundled at startup.
- **Other languages:** Loaded on demand when the user switches (see `loadLanguage` in `src/i18n/index.js`).
- **Persistence:** Language choice is stored in `sessionStorage` / `localStorage` (`i18nextLng`) via a custom detector in `src/i18n/index.js`.
- **HTML `lang`:** Updated when the language changes (see `LanguageProvider` in `src/i18n/withTranslation.js`).
- **Fallback:** `fallbackLng` is `de`.
### Product-facing behavior
- **Language switcher** — `src/components/LanguageSwitcher.js`: shows current language with flag, dropdown to change language, persists selection.
- **Translated areas** — Navigation, auth, cart, checkout, product UI, footer, profile, and more; legal pages use dedicated **`legal-*`** namespaces (long-form text).
## Layout
| Path | Role |
|------|------|
| `src/i18n/index.js` | Initializes i18n, registers namespaces, lazy-loads non-German locales |
| `src/i18n/withTranslation.js` | `LanguageProvider`, `withTranslation`, `withI18n`, `withLanguage` |
| `src/i18n/locales/<lang>/` | Per-language modules (`de`, `en`, …) |
Each language folder contains:
- **`index.js`** — Merges feature modules into the main **`translation`** namespace (shop UI: `navigation`, `cart`, `product`, …). Some legal content is also re-exported here under camelCase keys (e.g. `legalAgbDelivery`) for convenience; runtime legal pages use separate namespaces (see below).
- **One file per topic** — e.g. `cart.js`, `navigation.js`, `product.js` (not a single `translation.json`).
- **Legal bundles** — e.g. `legal-agb-delivery.js`, `legal-datenschutz-basic.js`, `legal-impressum.js`, registered as their **own i18next namespaces** in `src/i18n/index.js`.
## Namespaces
- **`translation`** (default) — Everything merged in `locales/<lang>/index.js`. Keys are dot paths such as `cart.addToCart` or `product.title`.
- **`legal-*`** — Long-form legal copy. Loaded the same way, but you select the namespace in `useTranslation('legal-impressum')` (or equivalent). Keys are relative to that namespace, e.g. `sections.operator.title`, not `legal-impressum.sections.operator.title`.
Default namespace is `translation`; omit the argument for shop UI:
```javascript
const { t } = useTranslation();
// same as useTranslation('translation')
```
Legal page example:
```javascript
const { t } = useTranslation('legal-impressum');
return t('title');
```
Multiple namespaces in one component:
```javascript
const { t: tDelivery } = useTranslation('legal-agb-delivery');
const { t: tPayment } = useTranslation('legal-agb-payment');
```
## Usage in components
### Function components
```javascript
import { useTranslation } from 'react-i18next';
const MyComponent = () => {
const { t } = useTranslation();
return <Typography>{t('navigation.home')}</Typography>;
};
```
### Class components — `withI18n` (translation + language context)
`withI18n` combines translation and language context. Use it when you need `t` and optionally `languageContext`:
```javascript
import { withI18n } from '../i18n/withTranslation.js';
class MyComponent extends Component {
render() {
const { t } = this.props;
return <Typography>{t('navigation.home')}</Typography>;
}
}
export default withI18n()(MyComponent);
```
### Class components — `withTranslation` + `withLanguage`
Some components only need `t` and `languageContext` from separate HOCs:
```javascript
import { withTranslation } from 'react-i18next';
import { withLanguage } from '../i18n/withTranslation.js';
export default withTranslation()(withLanguage(MyComponent));
```
Destructuring in `render`:
```javascript
const { t, title } = this.props;
t('product.new');
```
`this.props.t('…')` resolves against the default `translation` namespace.
### Language context (`changeLanguage`)
```javascript
import { withLanguage } from '../i18n/withTranslation.js';
class MyComponent extends Component {
render() {
const { languageContext } = this.props;
return (
<Button onClick={() => languageContext.changeLanguage('en')}>
English
</Button>
);
}
}
export default withLanguage(MyComponent);
```
Available language codes are defined on `LanguageProvider` (`allLanguages` in `withTranslation.js`); the switcher only offers languages that are loaded or loading.
## Key naming
- Use **dot notation** for nested objects in the JS exports: `product.inclVat`, `delivery.times.standard2to3Days`.
- File names in `locales/de/` map to **top-level segments** inside `index.js` (e.g. `cart.js``cart.*`). Keep new strings in the appropriate module and **export them from `index.js`** if you add a new file.
## Interpolation
i18next interpolation uses `{{name}}` in strings and an options object:
```javascript
t('product.weight', { weight: '1,2' });
```
```javascript
// locale string example: "Gewicht: {{weight}} kg"
```
## Language switching
`LanguageProvider` coordinates `i18n.changeLanguage` and lazy loading. Non-default languages are loaded via `loadLanguage` in `src/i18n/index.js`, which dynamic-imports the same file layout under `locales/<lang>/`.
## Adding or changing copy
1. Edit the right module under `src/i18n/locales/de/` (source of truth for structure).
2. Mirror changes in other languages under `src/i18n/locales/<lang>/` as needed, or use the projects translation tooling (`npm run translate`, etc. — see `translate-i18n.js` and `package.json` scripts).
3. For a **new top-level section**, add the module import and export in `locales/<lang>/index.js` for every language you ship.
### Example (new keys in an existing file)
`src/i18n/locales/de/cart.js`:
```javascript
export default {
// ...
newFeature: {
title: 'Neuer Titel',
description: 'Neue Beschreibung',
},
};
```
Use in code: `t('cart.newFeature.title')` (assuming the `cart` object is merged under `cart` in `index.js`).
## Adding a new language
1. Add a folder `src/i18n/locales/<code>/` with the same **file set** as German (at minimum `index.js` and every module `index.js` imports, plus all `legal-*.js` files registered in `src/i18n/index.js`).
2. Ensure `loadLanguage` in `src/i18n/index.js` can import that folder (pattern already uses `import(\`./locales/${language}/...\`)`).
3. Include the code in `allLanguages` (and any UI lists) in `withTranslation.js` / `LanguageSwitcher.js` as needed.
4. Clear the language cache logic if you add special cases (`languageCache` in `index.js`).
## Configuration notes
- **Detection:** Custom detector in `src/i18n/index.js` prefers session/localStorage, then defaults to `de` (browser language is not used for the initial default).
- **Debug:** i18next `debug` follows `NODE_ENV` (on in development).
- **Missing keys:** `saveMissing` can run in development (see `src/i18n/index.js`).
## SEO
- Document `lang` updates with language changes.
- You can extend the app with hreflang, localized routes, or metadata per language as needed.
## Best practices
1. **Keep structure parallel** across `locales/<lang>/` files.
2. **Prefer German (`de`) as the structural source**; keep `fallbackLng` aligned with your primary market.
3. **Use interpolation** for variable fragments: `t('key', { name: value })`.
4. **Test** critical flows after adding strings or languages.
## Translation coverage (high level)
Coverage varies by screen; shop flows, auth, cart, products, and legal pages have substantial strings. Remaining gaps are normal for a growing app—search for hard-coded German/English in components when polishing.
## Performance
- **German** is in the initial bundle.
- **Other languages** load on first switch (dynamic import), then stay cached in the i18n layer for the session.
## Browser support
Modern browsers with ES modules, `localStorage` / `sessionStorage`, and your supported React version.
---
## Key usage check (`scripts/check-i18n-keys.mjs`)
The check answers: *which keys defined for German (`src/i18n/locales/de`) appear to be unused in `src/`?*
Run:
```bash
npm run i18n:check-keys
# or
node scripts/check-i18n-keys.mjs
```
Machine-readable output:
```bash
node scripts/check-i18n-keys.mjs --json
```
### What the script detects
The script only understands **static** key strings:
- `t('cart.addToCart')`, `t("…")`
- `this.props.t('…')`, `props.t('…')`
- Aliases from `useTranslation('namespace')`, e.g. `const { t: tDelivery } = useTranslation('legal-agb-delivery');` then `tDelivery('deliveryTerms.1')`
- Destructuring `const { t } = this.props` / `props` for HOC-wrapped components
- Fully static template literals without interpolation: `` t(`some.key`) ``
It flattens locale objects the same way i18next does (leaf keys only).
### Embedded legal copy vs separate namespaces
Legal strings exist in two shapes:
1. **Separate namespace** (what pages use): `legal-agb-delivery` + key `deliveryTerms.1`.
2. **Embedded in `translation`**: `legalAgbDelivery.deliveryTerms.1` inside `locales/de/index.js`.
The script treats a key as used in the **embedded** `translation` tree when the matching **separate-namespace** key is used, so you do not see false “unused” duplicates for the same text.
### Dynamic keys (`t(\`…${x}…\`)`)
If the key is built at runtime, the static scan will not see it. The script includes **hard-coded expansions** for known patterns on:
- `src/pages/AGB.js`
- `src/pages/Datenschutz.js` (basic Datenschutz sections)
- `src/pages/Impressum.js`
If you add **new** dynamic patterns elsewhere, either:
- Prefer **static** keys where reasonable (`t('feature.section.title')` per section), or
- Extend `addKnownDynamicLegalKeys` (or equivalent) in `scripts/check-i18n-keys.mjs` so the checker knows which key paths can occur.
Other files may still be listed under “template literals in `t(...)`” when the script cannot prove all keys.
### Making new keys “count” for the checker
1. Call `t` with a **string literal** (or a template literal **without** `${…}`) as the first argument.
2. Use **`useTranslation('namespace')`** with a **string literal** namespace so aliases like `tFoo` map correctly.
3. For class components, keep **`const { t } = this.props`** (or `props`) so the script ties `t` to `translation`.
Avoid only referencing a key from non-`src` code (build scripts, server-only files): the checker only scans `src/` and skips `src/i18n/locales/**`.
### Interpreting results
- **Unused** — No static (and no expanded dynamic) reference found; may still be dead copy, or only used via variables/dynamic templates.
- **Used but not in locale files** — A reference in code does not match any defined German key (typo or missing `de` entry).
The report is **best-effort**; use it to find obvious dead strings and drift, not as a formal guarantee.

154
docs/nginx.conf Normal file
View File

@@ -0,0 +1,154 @@
server {
client_max_body_size 64M;
listen 443 ssl;
http2 on;
server_name example.de;
ssl_certificate /etc/letsencrypt/live/example.de/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.de/privkey.pem;
gzip on;
gzip_comp_level 6;
gzip_min_length 256;
gzip_vary on;
gzip_proxied any;
gzip_types
text/css
text/javascript
application/javascript
application/x-javascript
application/json
application/xml
image/svg+xml;
index index.html;
root /example/dist;
error_log logs/error.log info;
access_log logs/access.log combined;
location /socket.io/ {
proxy_pass http://localhost:9303/socket.io/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_connect_timeout 3600s;
proxy_send_timeout 3600s;
proxy_read_timeout 3600s;
send_timeout 3600s;
proxy_buffering off;
proxy_cache off;
keepalive_timeout 65;
keepalive_requests 100;
}
location /api/ {
proxy_pass http://localhost:9303/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header User-Agent $http_user_agent;
proxy_set_header Content-Type $content_type;
proxy_set_header Content-Length $content_length;
proxy_set_header X-API-Key $http_x_api_key;
proxy_connect_timeout 30s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
proxy_buffering off;
client_max_body_size 10M;
}
location ^~ /Kategorie/ {
types {}
default_type text/html;
}
location ^~ /Artikel/ {
types {}
default_type text/html;
}
location = /sitemap.xml {
types {}
default_type application/xml;
}
location ~ ^/(datenschutz|impressum|batteriegesetzhinweise|widerrufsrecht|sitemap|agb|Kategorien|Konfigurator|404|profile|resetPassword|thc-test|linkTelegram|filiale|aktionen|presseverleih|payment/success)(/|$) {
types {}
default_type text/html;
}
location = /404 {
error_page 404 =404 /404-big.html;
return 404;
}
location = /404-big.html {
internal;
alias /home/seb/src/growheads_de/dist/404;
default_type text/html;
}
error_page 404 /404.html;
location = /404.html {
internal;
default_type text/html;
return 404 '<!doctype html><html><body>
<script>
if (!navigator.userAgent.includes("bot")) { location.href="/404"; }
</script>
</body></html>';
}
location ~* \.(js|css)\?.*$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Vary Accept-Encoding;
}
location ~* \.(js|css)$ {
if ($uri ~ "\.[a-f0-9]{7,}\.(js|css)$") {
expires 1y;
add_header Cache-Control "public, immutable";
break;
}
expires 1d;
add_header Cache-Control "public";
add_header Vary Accept-Encoding;
}
location ~* \.(ttf|otf|woff|woff2|eot)$ {
expires 1y;
add_header Cache-Control "public";
add_header Access-Control-Allow-Origin "*";
}
location ~* \.(jpg|jpeg|png|gif|ico|svg|webp)$ {
expires 1y;
add_header Cache-Control "public";
add_header Vary Accept-Encoding;
}
location = /prerender.css {
expires 1w;
add_header Cache-Control "public";
add_header Vary Accept-Encoding;
}
location /assets/ {
expires 1y;
add_header Cache-Control "public";
add_header Vary Accept-Encoding;
}
}

View File

@@ -0,0 +1,380 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
import OpenAI from 'openai';
// Configuration
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
const DIST_DIR = './dist';
const OUTPUT_CSV = './category-descriptions.csv';
// Model configuration
const MODEL = 'gpt-5.4';
// Initialize OpenAI client
const openai = new OpenAI({
apiKey: OPENAI_API_KEY,
});
// System prompt for generating SEO descriptions
const SEO_DESCRIPTION_PROMPT = `You are given a list of products from a specific category. Create a SEO-friendly description for that category that would be suitable for a product catalog page.
Requirements:
- Write in German
- Make it SEO-optimized with relevant keywords
The product list format is:
First line: categoryName,categoryId
Subsequent lines: articleNumber,price,productName,shortDescription
Generate a compelling category description based on this product data.`;
// Function to find all *-list.txt files in dist directory
function findListFiles() {
try {
const files = fs.readdirSync(DIST_DIR);
return files.filter(file => file.endsWith('-list.txt'));
} catch (error) {
console.error('Error reading dist directory:', error.message);
return [];
}
}
// Function to read a list file and extract category info
function readListFile(filePath) {
try {
const content = fs.readFileSync(filePath, 'utf8');
const lines = content.trim().split('\n');
if (lines.length < 1) {
throw new Error('File is empty');
}
// Parse first line: categoryName,categoryId,[subcategoryIds]
const firstLine = lines[0];
const parts = firstLine.split(',');
if (parts.length < 2) {
throw new Error('Invalid first line format');
}
const categoryName = parts[0].replace(/^"|"$/g, '');
const categoryId = parts[1].replace(/^"|"$/g, '');
// Parse subcategory IDs from array notation [id1,id2,...]
let subcategoryIds = [];
if (parts.length >= 3) {
const subcatString = parts.slice(2).join(','); // Handle case where array spans multiple comma-separated values
const match = subcatString.match(/\[(.*?)\]/);
if (match && match[1]) {
subcategoryIds = match[1].split(',').map(id => id.trim()).filter(id => id);
}
}
if (!categoryName || !categoryId) {
throw new Error('Invalid first line format');
}
return {
categoryName: categoryName,
categoryId: categoryId,
subcategoryIds: subcategoryIds,
content: content
};
} catch (error) {
console.error(`Error reading ${filePath}:`, error.message);
return null;
}
}
// Function to build processing order based on dependencies
function buildProcessingOrder(categories) {
const categoryMap = new Map();
const processed = new Set();
const processingOrder = [];
// Create a map of categoryId -> category data
categories.forEach(cat => {
categoryMap.set(cat.categoryId, cat);
});
// Function to check if all subcategories are processed
function canProcess(category) {
return category.subcategoryIds.every(subId => processed.has(subId));
}
// Keep processing until all categories are done
while (processingOrder.length < categories.length) {
const beforeLength = processingOrder.length;
// Find categories that can be processed now
for (const category of categories) {
if (!processed.has(category.categoryId) && canProcess(category)) {
processingOrder.push(category);
processed.add(category.categoryId);
}
}
// If no progress was made, there might be a circular dependency or missing category
if (processingOrder.length === beforeLength) {
console.error('⚠️ Unable to resolve all category dependencies');
// Add remaining categories anyway
for (const category of categories) {
if (!processed.has(category.categoryId)) {
console.warn(` Adding ${category.categoryName} (${category.categoryId}) despite unresolved dependencies`);
processingOrder.push(category);
processed.add(category.categoryId);
}
}
break;
}
}
return processingOrder;
}
// Function to generate SEO description using OpenAI
async function generateSEODescription(productListContent, categoryName, categoryId, subcategoryDescriptions = []) {
try {
console.log(`🔄 Generating SEO description for category: ${categoryName} (ID: ${categoryId})`);
// Prepend subcategory information if present
let fullContent = productListContent;
if (subcategoryDescriptions.length > 0) {
const subcatInfo = 'This category has the following subcategories:\n' +
subcategoryDescriptions.map(sub => `- "${sub.name}": ${sub.description}`).join('\n') +
'\n\n';
fullContent = subcatInfo + productListContent;
}
const response = await openai.responses.create({
model: "gpt-5.4",
input: [
{
"role": "developer",
"content": [
{
"type": "input_text",
"text": SEO_DESCRIPTION_PROMPT
}
]
},
{
"role": "user",
"content": [
{
"type": "input_text",
"text": fullContent
}
]
}
],
text: {
"format": {
"type": "json_schema",
"name": "descriptions",
"strict": true,
"schema": {
"type": "object",
"properties": {
"seo_description": {
"type": "string",
"description": "A concise description intended for SEO purposes. 155 characters"
},
"long_description": {
"type": "string",
"description": "A comprehensive description, 2-5 Sentences"
}
},
"required": [
"seo_description",
"long_description"
],
"additionalProperties": false
}
},
"verbosity": "medium"
},
reasoning: {
"effort": "none"
}
});
const description = response.output_text;
console.log(`✅ Generated description for ${categoryName}`);
return description;
} catch (error) {
console.error(`❌ Error generating description for ${categoryName}:`, error.message);
return `Error generating description: ${error.message}`;
}
}
// Function to write CSV file
function writeCSV(results) {
try {
const csvHeader = 'categoryId,listFileName,seoDescription\n';
const csvRows = results.map(result =>
`"${result.categoryId}","${result.listFileName}","${result.description.replace(/"/g, '""')}"`
).join('\n');
const csvContent = csvHeader + csvRows;
fs.writeFileSync(OUTPUT_CSV, csvContent, 'utf8');
console.log(`✅ CSV file written: ${OUTPUT_CSV}`);
console.log(`📊 Processed ${results.length} categories`);
} catch (error) {
console.error('Error writing CSV file:', error.message);
}
}
// Main execution function
async function main() {
console.log('🚀 Starting category description generation...');
// Check if OpenAI API key is set
if (!OPENAI_API_KEY) {
console.error('❌ OPENAI_API_KEY environment variable is not set');
console.log('Please set your OpenAI API key: export OPENAI_API_KEY="your-api-key-here"');
process.exit(1);
}
// Check if dist directory exists
if (!fs.existsSync(DIST_DIR)) {
console.error(`❌ Dist directory not found: ${DIST_DIR}`);
process.exit(1);
}
// Find all list files
const listFiles = findListFiles();
if (listFiles.length === 0) {
console.log('⚠️ No *-list.txt files found in dist directory');
console.log('💡 Make sure to run the prerender script first to generate the list files');
process.exit(1);
}
console.log(`📂 Found ${listFiles.length} list files to process`);
// Step 1: Read all list files and extract category information
console.log('📖 Reading all category files...');
const categories = [];
const fileDataMap = new Map(); // Map categoryId -> fileData
for (const listFile of listFiles) {
const filePath = path.join(DIST_DIR, listFile);
const fileData = readListFile(filePath);
if (!fileData) {
console.log(`⚠️ Skipping ${listFile} due to read error`);
continue;
}
categories.push({
categoryId: fileData.categoryId,
categoryName: fileData.categoryName,
subcategoryIds: fileData.subcategoryIds,
listFileName: listFile
});
fileDataMap.set(fileData.categoryId, {
...fileData,
listFileName: listFile
});
}
console.log(`✅ Read ${categories.length} categories`);
// Step 2: Build processing order based on dependencies
console.log('🔨 Building processing order based on category hierarchy...');
const processingOrder = buildProcessingOrder(categories);
const leafCategories = processingOrder.filter(cat => cat.subcategoryIds.length === 0);
const parentCategories = processingOrder.filter(cat => cat.subcategoryIds.length > 0);
console.log(` 📄 ${leafCategories.length} leaf categories (no subcategories)`);
console.log(` 📁 ${parentCategories.length} parent categories (with subcategories)`);
// Step 3: Process categories in order
const results = [];
const generatedDescriptions = new Map(); // Map categoryId -> {seo_description, long_description}
for (const category of processingOrder) {
const fileData = fileDataMap.get(category.categoryId);
if (!fileData) {
console.log(`⚠️ Skipping ${category.categoryName} - no file data found`);
continue;
}
// Gather subcategory descriptions
const subcategoryDescriptions = [];
for (const subId of category.subcategoryIds) {
const subDesc = generatedDescriptions.get(subId);
const subCategory = categories.find(cat => cat.categoryId === subId);
if (subDesc && subCategory) {
subcategoryDescriptions.push({
name: subCategory.categoryName,
description: subDesc.long_description || subDesc.seo_description
});
} else if (subCategory) {
console.warn(` ⚠️ Subcategory ${subCategory.categoryName} (${subId}) not yet processed`);
}
}
// Generate SEO description
const descriptionJSON = await generateSEODescription(
fileData.content,
fileData.categoryName,
fileData.categoryId,
subcategoryDescriptions
);
// Parse the JSON response
let parsedDescription;
try {
parsedDescription = JSON.parse(descriptionJSON);
generatedDescriptions.set(category.categoryId, parsedDescription);
} catch (error) {
console.error(` ❌ Failed to parse JSON for ${category.categoryName}:`, error.message);
parsedDescription = { seo_description: descriptionJSON, long_description: descriptionJSON };
generatedDescriptions.set(category.categoryId, parsedDescription);
}
// Store result
results.push({
categoryId: category.categoryId,
listFileName: fileData.listFileName,
description: parsedDescription.seo_description || descriptionJSON
});
// Add delay to avoid rate limiting
await new Promise(resolve => setTimeout(resolve, 1000));
}
// Write CSV output
if (results.length > 0) {
writeCSV(results);
console.log('🎉 Category description generation completed successfully!');
} else {
console.error('❌ No results to write - all files failed processing');
process.exit(1);
}
}
// Run the script
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch(error => {
console.error('❌ Script failed:', error.message);
process.exit(1);
});
}
export {
findListFiles,
readListFile,
buildProcessingOrder,
generateSEODescription,
writeCSV
};

1949
out Normal file

File diff suppressed because it is too large Load Diff

2082
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,13 +7,21 @@
"start": "cross-env NODE_OPTIONS=\"--no-deprecation\" webpack serve --progress --mode development --no-open",
"start:seedheads": "cross-env PROXY_TARGET=https://seedheads.de NODE_OPTIONS=\"--no-deprecation\" webpack serve --progress --mode development --no-open",
"prod": "webpack serve --progress --mode production --no-client-overlay --no-client --no-web-socket-server --no-open --no-live-reload --no-hot --compress --no-devtool",
"build:client": "cross-env NODE_ENV=production webpack --progress --mode production && shx cp dist/index.html dist/index_template.html",
"build:client": "node scripts/convert-images-to-avif.cjs && cross-env NODE_ENV=production webpack --progress --mode production && shx cp dist/index.html dist/index_template.html",
"build": "npm run build:client",
"analyze": "cross-env ANALYZE=true NODE_ENV=production webpack --progress --mode production",
"lint": "eslint src/**/*.{js,jsx}",
"prerender": "node prerender.cjs",
"prerender:prod": "cross-env NODE_ENV=production node prerender.cjs",
"build:prerender": "npm run build:client && npm run prerender:prod"
"prerender:product": "node prerender-single-product.cjs",
"prerender:product:prod": "cross-env NODE_ENV=production node prerender-single-product.cjs",
"build:prerender": "npm run build:client && npm run prerender:prod",
"translate": "node translate-i18n.js",
"translate:english": "node translate-i18n.js --only-english",
"translate:skip-english": "node translate-i18n.js --skip-english",
"translate:others": "node translate-i18n.js --skip-english",
"validate:products": "node scripts/validate-products-xml.cjs",
"i18n:check-keys": "node scripts/check-i18n-keys.mjs"
},
"keywords": [],
"author": "",
@@ -22,16 +30,26 @@
"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",
"@stripe/stripe-js": "^7.3.1",
"async-mutex": "^0.5.0",
"chart.js": "^4.5.0",
"country-flag-icons": "^1.5.19",
"html-react-parser": "^5.2.5",
"i18next": "^25.3.2",
"i18next-browser-languagedetector": "^8.2.0",
"openai": "^4.0.0",
"qrcode": "^1.5.4",
"react": "^19.1.0",
"react-chartjs-2": "^5.3.0",
"react-dom": "^19.1.0",
"react-i18next": "^15.6.0",
"react-router-dom": "^7.6.2",
"sanitize-html": "^2.17.0",
"sepa-payment-qr-code": "^2.0.2",
"sharp": "^0.34.2",
"socket.io-client": "^4.7.5"
},
@@ -64,6 +82,8 @@
"webpack-bundle-analyzer": "^4.10.2",
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.2",
"webpack-node-externals": "^3.0.0"
"webpack-node-externals": "^3.0.0",
"xmldom": "^0.6.0",
"xpath": "^0.0.34"
}
}

View File

@@ -0,0 +1,203 @@
require("@babel/register")({
presets: [
["@babel/preset-env", { targets: { node: "current" } }],
"@babel/preset-react",
],
extensions: [".js", ".jsx"],
ignore: [/node_modules/],
});
// Minimal globals for socket.io-client only - no JSDOM to avoid interference
global.window = {}; // Minimal window object for productCache
global.navigator = { userAgent: "node.js" };
global.URL = require("url").URL;
global.Blob = class MockBlob {
constructor(data, options) {
this.data = data;
this.type = options?.type || "";
}
};
// Import modules
const fs = require("fs");
const path = require("path");
const React = require("react");
const io = require("socket.io-client");
// Initialize i18n for prerendering with German as default
const i18n = require("i18next");
const { initReactI18next } = require("react-i18next");
// Import translation (just German for testing)
const translationDE = require("./src/i18n/locales/de/translation.js").default;
// Initialize i18n
i18n
.use(initReactI18next)
.init({
resources: {
de: { translation: translationDE }
},
lng: 'de',
fallbackLng: 'de',
debug: false,
interpolation: {
escapeValue: false
},
react: {
useSuspense: false
}
});
// Make i18n available globally
global.i18n = i18n;
// Import prerender modules
const config = require("./prerender/config.cjs");
const shopConfig = require("./src/config.js").default;
const { renderPage } = require("./prerender/renderer.cjs");
const { generateProductMetaTags, generateProductJsonLd } = require("./prerender/seo.cjs");
const { fetchProductDetails, saveProductImages } = require("./prerender/data-fetching.cjs");
// Import product component
const PrerenderProduct = require("./src/PrerenderProduct.js").default;
const renderSingleProduct = async (productSeoName) => {
const socketUrl = "http://127.0.0.1:9303";
console.log(`🔌 Connecting to socket at ${socketUrl}...`);
const socket = io(socketUrl, config.socketIoClientOptions);
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
console.error("❌ Timeout: Could not connect to backend after 15 seconds");
socket.disconnect();
reject(new Error("Connection timeout"));
}, 15000);
socket.on("connect", async () => {
console.log(`✅ Socket connected. Fetching product: ${productSeoName}`);
try {
// Fetch product details
const productDetails = await fetchProductDetails(socket, productSeoName);
console.log(`📦 Product found: ${productDetails.product.name}`);
// Save product image to static files
if (productDetails.product) {
console.log(`📷 Saving product image...`);
await saveProductImages(socket, [productDetails.product], "Single Product", config.outputDir);
}
// Set up minimal global cache (empty for single product test)
global.window.productCache = {};
global.productCache = {};
// Create product component
const productComponent = React.createElement(PrerenderProduct, {
productData: productDetails,
t: global.i18n.t.bind(global.i18n),
});
// Generate metadata
const actualSeoName = productDetails.product.seoName || productSeoName;
const filename = `Artikel/${actualSeoName}`;
const location = `/Artikel/${actualSeoName}`;
const description = `Product "${productDetails.product.name}" (seoName: ${productSeoName})`;
const metaTags = generateProductMetaTags({
...productDetails.product,
seoName: actualSeoName,
}, shopConfig.baseUrl, shopConfig);
const jsonLdScript = generateProductJsonLd({
...productDetails.product,
seoName: actualSeoName,
}, shopConfig.baseUrl, shopConfig);
const combinedMetaTags = metaTags + "\n" + jsonLdScript;
// Render the page
console.log(`🎨 Rendering product page...`);
const success = renderPage(
productComponent,
location,
filename,
description,
combinedMetaTags,
true, // needsRouter
config,
false, // suppressLogs
productDetails // productData for cache
);
if (success) {
const outputPath = path.resolve(__dirname, config.outputDir, `${filename}.html`);
console.log(`✅ Product page rendered successfully!`);
console.log(`📄 Output file: ${outputPath}`);
console.log(`🌐 Test URL: http://localhost:3000/Artikel/${actualSeoName}`);
// Show file size
if (fs.existsSync(outputPath)) {
const stats = fs.statSync(outputPath);
console.log(`📊 File size: ${Math.round(stats.size / 1024)}KB`);
}
} else {
console.log(`❌ Failed to render product page`);
}
clearTimeout(timeout);
socket.disconnect();
resolve(success);
} catch (error) {
console.error(`❌ Error fetching/rendering product: ${error.message}`);
clearTimeout(timeout);
socket.disconnect();
reject(error);
}
});
socket.on("connect_error", (err) => {
clearTimeout(timeout);
console.error("❌ Socket connection error:", err);
console.log("💡 Make sure the backend server is running on http://127.0.0.1:9303");
reject(err);
});
socket.on("error", (err) => {
clearTimeout(timeout);
console.error("❌ Socket error:", err);
reject(err);
});
});
};
// Get product seoName from command line arguments
const productSeoName = process.argv[2];
if (!productSeoName) {
console.log("❌ Usage: node prerender-single-product.cjs <product-seo-name>");
console.log("📝 Example: node prerender-single-product.cjs led-grow-light-600w");
process.exit(1);
}
console.log(`🚀 Starting single product prerender test...`);
console.log(`🎯 Product SEO name: ${productSeoName}`);
console.log(`🔧 Mode: ${config.isProduction ? 'PRODUCTION' : 'DEVELOPMENT'}`);
console.log(`📁 Output directory: ${config.outputDir}`);
renderSingleProduct(productSeoName)
.then((success) => {
if (success) {
console.log(`\n🎉 Single product prerender completed successfully!`);
process.exit(0);
} else {
console.log(`\n💥 Single product prerender failed!`);
process.exit(1);
}
})
.catch((error) => {
console.error(`\n💥 Single product prerender failed:`, error.message);
process.exit(1);
});

View File

@@ -19,6 +19,24 @@ global.Blob = class MockBlob {
}
};
class CategoryService {
constructor() {
this.get = this.get.bind(this);
}
getSync(categoryId, language = "de") {
const cacheKey = `${categoryId}_${language}`;
return null;
}
async get(categoryId, language = "de") {
const cacheKey = `${categoryId}_${language}`;
return null;
}
}
global.window.categoryService = new CategoryService();
// Import modules
const fs = require("fs");
const path = require("path");
@@ -27,6 +45,74 @@ const io = require("socket.io-client");
const os = require("os");
const { Worker, isMainThread, parentPort, workerData } = require("worker_threads");
// Initialize i18n for prerendering with German as default
const i18n = require("i18next");
const { initReactI18next } = require("react-i18next");
// Import all translation files
const translationDE = require("./src/i18n/locales/de/translation.js").default;
const translationEN = require("./src/i18n/locales/en/translation.js").default;
const translationAR = require("./src/i18n/locales/ar/translation.js").default;
const translationBG = require("./src/i18n/locales/bg/translation.js").default;
const translationCS = require("./src/i18n/locales/cs/translation.js").default;
const translationEL = require("./src/i18n/locales/el/translation.js").default;
const translationES = require("./src/i18n/locales/es/translation.js").default;
const translationFR = require("./src/i18n/locales/fr/translation.js").default;
const translationHR = require("./src/i18n/locales/hr/translation.js").default;
const translationHU = require("./src/i18n/locales/hu/translation.js").default;
const translationIT = require("./src/i18n/locales/it/translation.js").default;
const translationPL = require("./src/i18n/locales/pl/translation.js").default;
const translationRO = require("./src/i18n/locales/ro/translation.js").default;
const translationRU = require("./src/i18n/locales/ru/translation.js").default;
const translationSK = require("./src/i18n/locales/sk/translation.js").default;
const translationSL = require("./src/i18n/locales/sl/translation.js").default;
const translationSR = require("./src/i18n/locales/sr/translation.js").default;
const translationSV = require("./src/i18n/locales/sv/translation.js").default;
const translationTR = require("./src/i18n/locales/tr/translation.js").default;
const translationUK = require("./src/i18n/locales/uk/translation.js").default;
const translationZH = require("./src/i18n/locales/zh/translation.js").default;
// Initialize i18n for prerendering
i18n
.use(initReactI18next)
.init({
resources: {
de: { translation: translationDE },
en: { translation: translationEN },
ar: { translation: translationAR },
bg: { translation: translationBG },
cs: { translation: translationCS },
el: { translation: translationEL },
es: { translation: translationES },
fr: { translation: translationFR },
hr: { translation: translationHR },
hu: { translation: translationHU },
it: { translation: translationIT },
pl: { translation: translationPL },
ro: { translation: translationRO },
ru: { translation: translationRU },
sk: { translation: translationSK },
sl: { translation: translationSL },
sr: { translation: translationSR },
sv: { translation: translationSV },
tr: { translation: translationTR },
uk: { translation: translationUK },
zh: { translation: translationZH }
},
lng: 'de', // Default to German for prerendering
fallbackLng: 'de',
debug: false,
interpolation: {
escapeValue: false
},
react: {
useSuspense: false
}
});
// Make i18n available globally for components
global.i18n = i18n;
// Import split modules
const config = require("./prerender/config.cjs");
@@ -35,11 +121,11 @@ const shopConfig = require("./src/config.js").default;
const { renderPage } = require("./prerender/renderer.cjs");
const {
collectAllCategories,
writeCombinedCssFile,
} = require("./prerender/utils.cjs");
const {
generateProductMetaTags,
generateProductJsonLd,
generateCategoryMetaTags,
generateCategoryJsonLd,
generateHomepageMetaTags,
generateHomepageJsonLd,
@@ -51,6 +137,7 @@ const {
generateLlmsTxt,
generateCategoryLlmsTxt,
generateAllCategoryLlmsPages,
generateCategoryProductList,
} = require("./prerender/seo.cjs");
const {
fetchCategoryProducts,
@@ -73,18 +160,14 @@ const Batteriegesetzhinweise =
const Widerrufsrecht = require("./src/pages/Widerrufsrecht.js").default;
const Sitemap = require("./src/pages/Sitemap.js").default;
const PrerenderSitemap = require("./src/PrerenderSitemap.js").default;
const PrerenderCategoriesPage = require("./src/PrerenderCategoriesPage.js").default;
const AGB = require("./src/pages/AGB.js").default;
const NotFound404 = require("./src/pages/NotFound404.js").default;
// 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: ["polling", "websocket"],
reconnection: false,
timeout: 10000,
});
const workerSocket = io(socketUrl, config.socketIoClientOptions);
return new Promise((resolve) => {
let processedCount = 0;
@@ -107,6 +190,7 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback,
const actualSeoName = productDetails.product.seoName || productSeoName;
const productComponent = React.createElement(PrerenderProduct, {
productData: productDetails,
t: global.i18n.t.bind(global.i18n),
});
const filename = `Artikel/${actualSeoName}`;
@@ -133,7 +217,8 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback,
combinedMetaTags,
true,
config,
true // Suppress logs during parallel rendering to avoid interfering with progress bar
true, // Suppress logs during parallel rendering to avoid interfering with progress bar
productDetails // Pass product data for cache population
);
if (success) {
@@ -281,14 +366,14 @@ const renderProductsInParallel = async (allProductsArray, maxWorkers, totalProdu
const renderApp = async (categoryData, socket) => {
if (categoryData) {
global.window.productCache = {
categoryTree_209: { categoryTree: categoryData, timestamp: Date.now() },
global.window.categoryCache = {
"209_de": categoryData,
};
// @note Make cache available to components during rendering
global.productCache = global.window.productCache;
global.categoryCache = global.window.categoryCache;
} else {
global.window.productCache = {};
global.productCache = {};
global.window.categoryCache = {};
global.categoryCache = {};
}
// Helper to call renderPage with config
@@ -334,6 +419,19 @@ const renderApp = async (categoryData, socket) => {
process.exit(1);
}
// Copy index.html to resetPassword (no file extension) for SPA routing
if (config.isProduction) {
const indexPath = path.resolve(__dirname, config.outputDir, "index.html");
const resetPasswordPath = path.resolve(__dirname, config.outputDir, "resetPassword");
fs.copyFileSync(indexPath, resetPasswordPath);
console.log(`✅ Copied index.html to ${resetPasswordPath}`);
// 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
console.log("\n📄 Rendering static pages...");
@@ -369,6 +467,13 @@ const renderApp = async (categoryData, socket) => {
description: "Sitemap page",
needsCategoryData: true,
},
{
component: PrerenderCategoriesPage,
path: "/Kategorien",
filename: "Kategorien",
description: "Categories page",
needsCategoryData: true,
},
{ component: AGB, path: "/agb", filename: "agb", description: "AGB page" },
{ component: NotFound404, path: "/404", filename: "404", description: "404 Not Found page" },
{
@@ -438,7 +543,23 @@ const renderApp = async (categoryData, socket) => {
let categoryPagesRendered = 0;
let categoriesWithProducts = 0;
for (const category of allCategories) {
const allCategoriesWithVirtual = [
...allCategories,
{
id: "neu",
name: "Neuheiten",
seoName: "neu",
parentId: 209,
},
{
id: "bald",
name: "Demnächst",
seoName: "bald",
parentId: 209,
},
];
for (const category of allCategoriesWithVirtual) {
// Skip categories without seoName
if (!category.seoName) {
console.log(
@@ -456,8 +577,7 @@ const renderApp = async (categoryData, socket) => {
try {
productData = await fetchCategoryProducts(socket, category.id);
console.log(
` ✅ Found ${
productData.products ? productData.products.length : 0
` ✅ Found ${productData.products ? productData.products.length : 0
} products`
);
@@ -502,19 +622,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) {
@@ -572,8 +698,7 @@ const renderApp = async (categoryData, socket) => {
);
}
// Write the combined CSS file after all pages are rendered
writeCombinedCssFile(config.globalCssCollection, config.outputDir);
// No longer writing combined CSS file - each page has its own embedded CSS
// Generate XML sitemap with all rendered pages
console.log("\n🗺 Generating XML sitemap...");
@@ -630,6 +755,26 @@ const renderApp = async (categoryData, socket) => {
console.log(` - File verification: ⚠️ ${verifyError.message}`);
}
// Validate XML against Google Shopping schema
try {
const ProductsXmlValidator = require('./scripts/validate-products-xml.cjs');
const validator = new ProductsXmlValidator(productsXmlPath);
const validationResults = await validator.validate();
if (validationResults.valid) {
console.log(` - Schema validation: ✅ Valid Google Shopping RSS 2.0`);
} else {
console.log(` - Schema validation: ⚠️ ${validationResults.summary.errorCount} errors, ${validationResults.summary.warningCount} warnings`);
// Show first few errors for quick debugging
if (validationResults.errors.length > 0) {
console.log(` - First error: ${validationResults.errors[0].message}`);
}
}
} catch (validationError) {
console.log(` - Schema validation: ⚠️ Validation failed: ${validationError.message}`);
}
} catch (error) {
console.error(`❌ Error generating products.xml: ${error.message}`);
console.log("\n⚠ Skipping products.xml generation due to errors");
@@ -682,10 +827,16 @@ const renderApp = async (categoryData, socket) => {
totalPaginatedFiles++;
}
// Generate and write the product list file for this category
const productList = generateCategoryProductList(category, categoryProducts);
const listPath = path.resolve(__dirname, config.outputDir, productList.fileName);
fs.writeFileSync(listPath, productList.content, { encoding: 'utf8' });
const pageCount = categoryPages.length;
const totalSize = categoryPages.reduce((sum, page) => sum + page.content.length, 0);
console.log(` ✅ llms-${categorySlug}-page-*.txt - ${categoryProducts.length} products across ${pageCount} pages (${Math.round(totalSize / 1024)}KB total)`);
console.log(` 📋 ${productList.fileName} - ${productList.productCount} products (${Math.round(productList.content.length / 1024)}KB)`);
categoryFilesGenerated++;
totalCategoryProducts += categoryProducts.length;
@@ -719,12 +870,7 @@ const fetchCategoryDataAndRender = () => {
process.exit(1);
}, 15000);
const socket = io(socketUrl, {
path: "/socket.io/",
transports: ["polling", "websocket"], // Using polling first is more robust
reconnection: false,
timeout: 10000,
});
const socket = io(socketUrl, config.socketIoClientOptions);
socket.on("connect", () => {
console.log('Socket connected. Emitting "categoryList"...');

View File

@@ -50,10 +50,18 @@ const getWebpackEntrypoints = () => {
return entrypoints;
};
// Read global CSS styles and fix font paths for prerender
let globalCss = fs.readFileSync(path.resolve(__dirname, '..', 'src', 'index.css'), 'utf8');
// Read global CSS styles - use webpack processed CSS in production, raw CSS in development
let globalCss = '';
if (isProduction) {
// In production, webpack has already processed fonts and inlined CSS
// Don't read raw src/index.css as it has unprocessed font paths
globalCss = ''; // CSS will be handled by webpack's inlined CSS
} else {
// In development, read raw CSS and fix font paths for prerender
globalCss = fs.readFileSync(path.resolve(__dirname, '..', 'src', 'index.css'), 'utf8');
// Fix relative font paths for prerendered HTML (remove ../public to make them relative to public root)
globalCss = globalCss.replace(/url\('\.\.\/public/g, "url('");
}
// Global CSS collection
const globalCssCollection = new Set();
@@ -61,11 +69,21 @@ const globalCssCollection = new Set();
// Get webpack entrypoints
const webpackEntrypoints = getWebpackEntrypoints();
/** Socket.IO client options for prerender scripts: skip backend connection counters (balanced on disconnect). */
const socketIoClientOptions = {
path: "/socket.io/",
transports: ["websocket"],
reconnection: false,
timeout: 10000,
auth: { prerender: true },
};
module.exports = {
isProduction,
outputDir,
getWebpackEntrypoints,
globalCss,
globalCssCollection,
webpackEntrypoints
webpackEntrypoints,
socketIoClientOptions,
};

View File

@@ -37,9 +37,19 @@ const fetchCategoryProducts = (socket, categoryId) => {
reject(new Error(`Timeout fetching products for category ${categoryId}`));
}, 5000);
// Prerender system fetches German version by default
socket.emit(
"getCategoryProducts",
{ full:true, categoryId: parseInt(categoryId) },
{
full: true,
nocount: true,
categoryId:
categoryId === "neu" || categoryId === "bald"
? categoryId
: parseInt(categoryId),
language: 'de',
requestTranslation: false
},
(response) => {
clearTimeout(timeout);
if (response && response.products !== undefined) {
@@ -68,7 +78,13 @@ const fetchProductDetails = (socket, productSeoName) => {
);
}, 5000);
socket.emit("getProductView", { seoName: productSeoName, nocount: true }, (response) => {
// Prerender system fetches German version by default
socket.emit("getProductView", {
seoName: productSeoName,
nocount: true,
language: 'de',
requestTranslation: false
}, (response) => {
clearTimeout(timeout);
if (response && response.product) {
response.product.seoName = productSeoName;
@@ -140,7 +156,7 @@ const saveProductImages = async (socket, products, categoryName, outputDir) => {
"public",
"assets",
"images",
"sh.png"
"sh.avif"
);
// Ensure assets/images directory exists
@@ -171,43 +187,64 @@ const saveProductImages = async (socket, products, categoryName, outputDir) => {
.filter((id) => id);
if (imageIds.length > 0) {
// Process first image for each product
// Process first image for each product — store AVIF + JPEG (e.g. for Twitter / social)
const bildId = parseInt(imageIds[0]);
const estimatedFilename = `prod${bildId}.jpg`; // We'll generate a filename based on the ID
const avifFilename = `prod${bildId}.avif`;
const jpegFilename = `prod${bildId}.jpg`;
const avifPath = path.join(assetsPath, avifFilename);
const jpegPath = path.join(assetsPath, jpegFilename);
const imagePath = path.join(assetsPath, estimatedFilename);
// Skip if image already exists
if (fs.existsSync(imagePath)) {
if (fs.existsSync(avifPath) && fs.existsSync(jpegPath)) {
imagesSkipped++;
continue;
}
const writeAvifAndJpegFromBuffer = async (buf) => {
if (!fs.existsSync(avifPath)) {
await sharp(buf).avif().toFile(avifPath);
}
if (!fs.existsSync(jpegPath)) {
await sharp(buf)
.jpeg({ quality: 85, mozjpeg: true })
.toFile(jpegPath);
}
};
try {
if (fs.existsSync(avifPath) && !fs.existsSync(jpegPath)) {
await sharp(avifPath)
.jpeg({ quality: 85, mozjpeg: true })
.toFile(jpegPath);
} else if (!fs.existsSync(avifPath) && fs.existsSync(jpegPath)) {
await sharp(jpegPath).avif().toFile(avifPath);
} else {
const imageBuffer = await fetchProductImage(socket, bildId);
const buf = Buffer.from(imageBuffer);
// If overlay exists, apply it to the image
if (fs.existsSync(overlayPath)) {
if (false && fs.existsSync(overlayPath)) {
try {
// Get image dimensions to center the overlay
const baseImage = sharp(Buffer.from(imageBuffer));
const baseImage = sharp(buf);
const baseMetadata = await baseImage.metadata();
const overlaySize = Math.min(baseMetadata.width, baseMetadata.height) * 0.4;
const overlaySize =
Math.min(baseMetadata.width, baseMetadata.height) * 0.4;
// Resize overlay to 20% of base image size and get its buffer
const resizedOverlayBuffer = await sharp(overlayPath)
.resize({
width: Math.round(overlaySize),
height: Math.round(overlaySize),
fit: 'contain', // Keep full overlay visible
background: { r: 0, g: 0, b: 0, alpha: 0 } // Transparent background instead of black bars
fit: "contain",
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.toBuffer();
// Calculate center position for the resized overlay
const centerX = Math.floor((baseMetadata.width - overlaySize) / 2);
const centerY = Math.floor((baseMetadata.height - overlaySize) / 2);
const centerX = Math.floor(
(baseMetadata.width - overlaySize) / 2
);
const centerY = Math.floor(
(baseMetadata.height - overlaySize) / 2
);
const processedImageBuffer = await baseImage
.composite([
@@ -215,36 +252,33 @@ const saveProductImages = async (socket, products, categoryName, outputDir) => {
input: resizedOverlayBuffer,
top: centerY,
left: centerX,
blend: "multiply", // Darkens the image, visible on all backgrounds
blend: "multiply",
opacity: 0.3,
},
])
.jpeg() // Ensure output is JPEG
.toBuffer();
fs.writeFileSync(imagePath, processedImageBuffer);
await writeAvifAndJpegFromBuffer(processedImageBuffer);
console.log(
` ✅ Applied centered inverted sh.png overlay to ${estimatedFilename}`
` ✅ Applied overlay ${avifFilename} + ${jpegFilename}`
);
} catch (overlayError) {
console.log(
` ⚠️ Failed to apply overlay to ${estimatedFilename}: ${overlayError.message}`
` ⚠️ Failed to apply overlay to prod${bildId}: ${overlayError.message}`
);
// Fallback: save without overlay
fs.writeFileSync(imagePath, Buffer.from(imageBuffer));
await writeAvifAndJpegFromBuffer(buf);
}
} else {
// Save without overlay if overlay file doesn't exist
fs.writeFileSync(imagePath, Buffer.from(imageBuffer));
await writeAvifAndJpegFromBuffer(buf);
}
}
imagesSaved++;
// Small delay to avoid overwhelming server
await new Promise((resolve) => setTimeout(resolve, 50));
} catch (error) {
console.log(
` ⚠️ Failed to fetch image ${estimatedFilename} (ID: ${bildId}): ${error.message}`
` ⚠️ Failed to fetch/save prod${bildId} (${avifFilename} / ${jpegFilename}): ${error.message}`
);
}
}
@@ -269,7 +303,7 @@ const saveCategoryImages = async (socket, categories, outputDir) => {
// Debug: Log categories that will be processed
console.log(" 🔍 Categories to process:");
categories.forEach((cat, index) => {
console.log(` ${index + 1}. "${cat.name}" (ID: ${cat.id}) -> cat${cat.id}.jpg`);
console.log(` ${index + 1}. "${cat.name}" (ID: ${cat.id}) -> cat${cat.id}.avif`);
});
const assetsPath = path.resolve(
@@ -296,7 +330,7 @@ const saveCategoryImages = async (socket, categories, outputDir) => {
for (const category of categories) {
categoriesProcessed++;
const estimatedFilename = `cat${category.id}.jpg`; // Use 'cat' prefix with category ID
const estimatedFilename = `cat${category.id}.avif`; // Use 'cat' prefix with category ID
const imagePath = path.join(assetsPath, estimatedFilename);
// Skip if image already exists

View File

@@ -17,7 +17,8 @@ const renderPage = (
metaTags = "",
needsRouter = false,
config,
suppressLogs = false
suppressLogs = false,
productData = null
) => {
const {
isProduction,
@@ -26,7 +27,7 @@ const renderPage = (
globalCssCollection,
webpackEntrypoints,
} = config;
const { writeCombinedCssFile, optimizeCss } = require("./utils.cjs");
const { optimizeCss } = require("./utils.cjs");
// @note Set prerender fallback flag in global environment for CategoryBox during SSR
if (typeof global !== "undefined" && global.window) {
@@ -51,26 +52,20 @@ const renderPage = (
);
let renderedMarkup;
let pageSpecificCss = ""; // Declare outside try block for broader scope
try {
renderedMarkup = ReactDOMServer.renderToString(pageElement);
const emotionChunks = extractCriticalToChunks(renderedMarkup);
// Collect CSS from this page
// Collect CSS from this page for direct inlining (no global accumulation)
if (emotionChunks.styles.length > 0) {
const oldSize = globalCssCollection.size;
emotionChunks.styles.forEach((style) => {
if (style.css) {
globalCssCollection.add(style.css);
pageSpecificCss += style.css + "\n";
}
});
// Check if new styles were added
if (globalCssCollection.size > oldSize) {
// Write CSS file immediately when new styles are added
writeCombinedCssFile(globalCssCollection, outputDir);
}
if (!suppressLogs) console.log(` - CSS rules: ${emotionChunks.styles.length}`);
}
} catch (error) {
console.error(`❌ Rendering failed for ${filename}:`, error);
@@ -126,26 +121,12 @@ const renderPage = (
}
});
// Read and inline prerender CSS to eliminate render-blocking request
try {
const prerenderCssPath = path.resolve(__dirname, "..", outputDir, "prerender.css");
if (fs.existsSync(prerenderCssPath)) {
const prerenderCssContent = fs.readFileSync(prerenderCssPath, "utf8");
// Use advanced CSS optimization
const optimizedPrerenderCss = optimizeCss(prerenderCssContent);
inlinedCss += optimizedPrerenderCss;
if (!suppressLogs) console.log(` ✅ Inlined prerender CSS (${Math.round(optimizedPrerenderCss.length / 1024)}KB)`);
} else {
// Fallback to external loading if prerender.css doesn't exist yet
additionalTags += `<link rel="preload" href="/prerender.css" as="style" onload="this.onload=null;this.rel='stylesheet'">`;
additionalTags += `<noscript><link rel="stylesheet" href="/prerender.css"></noscript>`;
if (!suppressLogs) console.log(` ⚠️ prerender.css not found for inlining, using async loading`);
}
} catch (error) {
// Fallback to external loading
additionalTags += `<link rel="preload" href="/prerender.css" as="style" onload="this.onload=null;this.rel='stylesheet'">`;
additionalTags += `<noscript><link rel="stylesheet" href="/prerender.css"></noscript>`;
if (!suppressLogs) console.log(` ⚠️ Error reading prerender.css: ${error.message}, using async loading`);
// Inline page-specific CSS directly (no shared prerender.css file)
if (pageSpecificCss.trim()) {
// Use advanced CSS optimization on page-specific CSS
const optimizedPageCss = optimizeCss(pageSpecificCss);
inlinedCss += optimizedPageCss;
if (!suppressLogs) console.log(` ✅ Inlined page-specific CSS (${Math.round(optimizedPageCss.length / 1024)}KB)`);
}
// Add JavaScript files
@@ -182,23 +163,47 @@ const renderPage = (
content: ${JSON.stringify(renderedMarkup)},
timestamp: ${Date.now()}
};
// DEBUG: Multiple alerts throughout the loading process
// Debug alerts removed
</script>
`;
// @note Create script to populate window.productCache with ONLY the static category tree
let productCacheScript = '';
if (typeof global !== "undefined" && global.window && global.window.productCache) {
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.productCache.categoryTree_209) {
staticCache.categoryTree_209 = global.window.productCache.categoryTree_209;
if (global.window.categoryCache["209_de"]) {
staticCache["209_de"] = global.window.categoryCache["209_de"];
}
const staticCacheData = JSON.stringify(staticCache);
productCacheScript = `
<script>
// Populate window.productCache with static category tree only
window.productCache = ${staticCacheData};
// Populate window.categoryCache with static category tree only
window.categoryCache = ${staticCacheData};
</script>
`;
}
// Create script to populate window.productDetailCache for individual product pages
let productDetailCacheScript = '';
if (productData && productData.product) {
// Cache the entire response object (includes product, attributes, etc.)
// Use language-aware cache key (prerender defaults to German)
const productDetailCacheData = JSON.stringify(productData);
const language = 'de'; // Prerender system caches German version
const cacheKey = `product_${productData.product.seoName}_${language}`;
productDetailCacheScript = `
<script>
// Populate window.productDetailCache with complete product data for SPA hydration
if (!window.productDetailCache) {
window.productDetailCache = {};
}
window.productDetailCache['${cacheKey}'] = ${productDetailCacheData};
</script>
`;
}
@@ -214,7 +219,7 @@ const renderPage = (
template = template.replace(
"</head>",
`${resourceHints}${combinedCssTag}${additionalTags}${metaTags}${prerenderFallbackScript}${productCacheScript}</head>`
`${resourceHints}${combinedCssTag}${additionalTags}${metaTags}${prerenderFallbackScript}${productCacheScript}${productDetailCacheScript}</head>`
);
const rootDivRegex = /<div id="root"[\s\S]*?>[\s\S]*?<\/div>/;
@@ -222,8 +227,10 @@ const renderPage = (
let newHtml;
if (rootDivRegex.test(template)) {
if (!suppressLogs) console.log(` 📝 Root div found, replacing with ${renderedMarkup.length} chars of markup`);
newHtml = template.replace(rootDivRegex, replacementHtml);
} else {
if (!suppressLogs) console.log(` ⚠️ No root div found, appending to body`);
newHtml = template.replace("<body>", `<body>${replacementHtml}`);
}
@@ -240,10 +247,9 @@ const renderPage = (
if (!suppressLogs) {
console.log(`${description} prerendered to ${outputPath}`);
console.log(` - Markup length: ${renderedMarkup.length} characters`);
console.log(` - CSS rules: ${Object.keys(cache.inserted).length}`);
console.log(` - Total inlined CSS: ${Math.round(combinedCss.length / 1024)}KB`);
console.log(` - Render-blocking CSS eliminated: ${inlinedCss ? 'YES' : 'NO'}`);
console.log(` - Fallback content saved to window.__PRERENDER_FALLBACK__`);
if (productDetailCacheScript) {
console.log(` - Product detail cache populated for SPA hydration`);
}
}
return true;

View File

@@ -1,20 +1,95 @@
const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
const categoryUrl = `${baseUrl}/Kategorie/${category.seoName}`;
/** Safe for double-quoted HTML attributes */
const escAttr = (str) =>
String(str ?? "")
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;");
const jsonLd = {
"@context": "https://schema.org/",
"@type": "CollectionPage",
name: category.name,
url: categoryUrl,
description: `${category.name} - Entdecken Sie unsere Auswahl an hochwertigen Produkten`,
breadcrumb: {
/**
* 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];
// Check if category ID is in skip list
if (category.id && skipCategoryIds.includes(parseInt(category.id))) {
return '';
}
const root = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
const categoryUrl = `${root}/Kategorie/${category.seoName}`;
const id = {
business: `${root}#business`,
website: `${root}#website`,
breadcrumb: `${categoryUrl}#breadcrumb`,
itemList: `${categoryUrl}#itemlist`,
};
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: baseUrl,
item: root,
},
{
"@type": "ListItem",
@@ -23,59 +98,52 @@ const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
item: categoryUrl,
},
],
},
};
// Add product list if products are available
if (products && products.length > 0) {
jsonLd.mainEntity = {
const collectionPageNode = {
"@id": categoryUrl,
"@type": "CollectionPage",
name: category.name,
url: categoryUrl,
description: `${category.name} - Entdecken Sie unsere Auswahl an hochwertigen Produkten`,
isPartOf: { "@id": id.website },
breadcrumb: { "@id": id.breadcrumb },
};
const graph = [businessNode, websiteNode, breadcrumbNode, collectionPageNode];
// ItemList: URLs only — full Product/Offer markup belongs on each /Artikel/… page (Google guidelines).
const withUrls = (products || []).filter((p) => p && p.seoName);
if (withUrls.length > 0) {
collectionPageNode.mainEntity = { "@id": id.itemList };
graph.push({
"@id": id.itemList,
"@type": "ItemList",
numberOfItems: products.length,
itemListElement: products.slice(0, 20).map((product, index) => ({
numberOfItems: withUrls.length,
itemListElement: withUrls.map((product, index) => {
const productPageUrl = `${root}/Artikel/${product.seoName}`;
return {
"@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()}.jpg`
: `${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,
availability: product.available
? "https://schema.org/InStock"
: "https://schema.org/OutOfStock",
seller: {
"@type": "Organization",
name: config.brandName,
},
itemCondition: "https://schema.org/NewCondition",
},
},
})),
url: productPageUrl,
item: productPageUrl,
};
}),
});
}
const categoryGraph = {
"@context": "https://schema.org",
"@graph": graph,
};
return `<script type="application/ld+json">${JSON.stringify(
jsonLd
categoryGraph
)}</script>`;
};
module.exports = {
generateCategoryMetaTags,
generateCategoryJsonLd,
};

View File

@@ -11,6 +11,102 @@ Crawl-delay: 0
return robotsTxt;
};
// Helper function to determine unit pricing data based on product data
const determineUnitPricingData = (product) => {
const result = {
unit_pricing_measure: null,
unit_pricing_base_measure: null
};
// Unit mapping from German to Google Shopping accepted units
const unitMapping = {
// Volume (German -> Google)
'Milliliter': 'ml',
'milliliter': 'ml',
'ml': 'ml',
'Liter': 'l',
'liter': 'l',
'l': 'l',
'Zentiliter': 'cl',
'zentiliter': 'cl',
'cl': 'cl',
// Weight (German -> Google)
'Gramm': 'g',
'gramm': 'g',
'g': 'g',
'Kilogramm': 'kg',
'kilogramm': 'kg',
'kg': 'kg',
'Milligramm': 'mg',
'milligramm': 'mg',
'mg': 'mg',
// Length (German -> Google)
'Meter': 'm',
'meter': 'm',
'm': 'm',
'Zentimeter': 'cm',
'zentimeter': 'cm',
'cm': 'cm',
// Count (German -> Google)
'Stück': 'ct',
'stück': 'ct',
'Stk': 'ct',
'stk': 'ct',
'ct': 'ct',
'Blatt': 'sheet',
'blatt': 'sheet',
'sheet': 'sheet'
};
// Helper function to convert German unit to Google Shopping unit
const convertUnit = (unit) => {
if (!unit) return null;
const trimmedUnit = unit.trim();
return unitMapping[trimmedUnit] || trimmedUnit.toLowerCase();
};
// unit_pricing_measure: The quantity unit of the product as it's sold
if (product.fEinheitMenge && product.cEinheit) {
const amount = parseFloat(product.fEinheitMenge);
const unit = convertUnit(product.cEinheit);
if (amount > 0 && unit) {
result.unit_pricing_measure = `${amount} ${unit}`;
}
}
// unit_pricing_base_measure: The base quantity unit for unit pricing
if (product.cGrundEinheit && product.cGrundEinheit.trim()) {
const baseUnit = convertUnit(product.cGrundEinheit);
if (baseUnit) {
// Base measure usually needs a quantity (like 100g, 1l, etc.)
// If it's just a unit, we'll add a default quantity
if (baseUnit.match(/^[a-z]+$/)) {
// For weight/volume units, use standard base quantities
if (['g', 'kg', 'mg'].includes(baseUnit)) {
result.unit_pricing_base_measure = baseUnit === 'kg' ? '1 kg' : '100 g';
} else if (['ml', 'l', 'cl'].includes(baseUnit)) {
result.unit_pricing_base_measure = baseUnit === 'l' ? '1 l' : '100 ml';
} else if (['m', 'cm'].includes(baseUnit)) {
result.unit_pricing_base_measure = baseUnit === 'm' ? '1 m' : '100 cm';
} else {
result.unit_pricing_base_measure = `1 ${baseUnit}`;
}
} else {
result.unit_pricing_base_measure = baseUnit;
}
}
}
return result;
};
const fs = require('fs');
const path = require('path');
const generateProductsXml = (allProductsData = [], baseUrl, config) => {
const currentDate = new Date().toISOString();
@@ -23,134 +119,147 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
const getGoogleProductCategory = (categoryId) => {
const categoryMappings = {
// Seeds & Plants
689: "Home & Garden > Plants > Seeds",
706: "Home & Garden > Plants", // Stecklinge (cuttings)
376: "Home & Garden > Plants > Plant & Herb Growing Kits", // Grow-Sets
689: "543561", // Seeds (Saatgut)
706: "543561", // Stecklinge (cuttings) ebenfalls Pflanzen/Saatgut
376: "2802", // Grow-Sets Pflanzen- & Kräuteranbausets
915: "2802", // Grow-Sets > Set-Zubehör Pflanzen- & Kräuteranbausets
// Headshop & Accessories
709: "Arts & Entertainment > Hobbies & Creative Arts", // Headshop
711: "Arts & Entertainment > Hobbies & Creative Arts", // Bongs
714: "Arts & Entertainment > Hobbies & Creative Arts", // Zubehör
748: "Arts & Entertainment > Hobbies & Creative Arts", // Köpfe
749: "Arts & Entertainment > Hobbies & Creative Arts", // Chillums / Diffusoren / Kupplungen
896: "Electronics > Electronics Accessories", // Vaporizer
710: "Home & Garden > Kitchen & Dining > Kitchen Tools & Utensils", // Grinder
709: "4082", // Headshop Rauchzubehör
711: "4082", // Headshop > Bongs Rauchzubehör
714: "4082", // Headshop > Bongs > Zubehör Rauchzubehör
748: "4082", // Headshop > Bongs > Köpfe Rauchzubehör
749: "4082", // Headshop > Bongs > Chillums/Diffusoren/Kupplungen Rauchzubehör
921: "4082", // Headshop > Pfeifen Rauchzubehör
924: "4082", // Headshop > Dabbing Rauchzubehör
896: "3151", // Headshop > Vaporizer Vaporizer
923: "4082", // Headshop > Papes & Blunts Rauchzubehör
710: "5109", // Headshop > Grinder Gewürzmühlen (Küchenhelfer)
922: "4082", // Headshop > Aktivkohlefilter & Tips Rauchzubehör
916: "4082", // Headshop > Rollen & Bauen Rauchzubehör
// Measuring & Packaging
186: "Business & Industrial", // Wiegen & Verpacken
187: "Business & Industrial > Science & Laboratory > Lab Equipment", // Waagen
346: "Home & Garden > Kitchen & Dining > Food Storage", // Vakuumbeutel
355: "Home & Garden > Kitchen & Dining > Food Storage", // Boveda & Integra Boost
407: "Home & Garden > Kitchen & Dining > Food Storage", // Grove Bags
449: "Home & Garden > Kitchen & Dining > Food Storage", // Cliptütchen
539: "Home & Garden > Kitchen & Dining > Food Storage", // Gläser & Dosen
186: "5631", // Headshop > Wiegen & Verpacken Aufbewahrung/Zubehör
187: "4767", // Headshop > Waagen Personenwaagen (Medizinisch)
346: "7118", // Headshop > Vakuumbeutel Vakuumierer-Beutel
355: "606", // Headshop > Boveda & Integra Boost Luftentfeuchter (nächstmögliche)
407: "3561", // Headshop > Grove Bags Aufbewahrungsbehälter
449: "1496", // Headshop > Cliptütchen Lebensmittelverpackungsmaterial
539: "3110", // Headshop > Gläser & Dosen Lebensmittelbehälter
920: "581", // Headshop > Räucherstäbchen Raumdüfte (Home Fragrances)
// Lighting & Equipment
694: "Home & Garden > Lighting", // Lampen
261: "Home & Garden > Lighting", // Lampenzubehör
694: "3006", // Lampen Lampen (Beleuchtung)
261: "3006", // Zubehör > Lampenzubehör Lampen
// Plants & Growing
691: "Home & Garden > Lawn & Garden > Fertilizers", // Dünger
692: "Home & Garden > Lawn & Garden > Fertilizers", // Dünger - Zubehör
693: "Sporting Goods > Outdoor Recreation > Camping & Hiking > Tents", // Zelte
691: "500033", // Dünger Dünger
692: "5633", // Zubehör > Dünger-Zubehör Zubehör für Gartenarbeit
693: "5655", // Zelte Zelte
// Pots & Containers
219: "Home & Garden > Decor > Planters & Pots", // Töpfe
220: "Home & Garden > Decor > Planters & Pots", // Untersetzer
301: "Home & Garden > Decor > Planters & Pots", // Stofftöpfe
317: "Home & Garden > Decor > Planters & Pots", // Air-Pot
364: "Home & Garden > Decor > Planters & Pots", // Kunststofftöpfe
292: "Home & Garden > Decor > Planters & Pots", // Trays & Fluttische
219: "113", // Töpfe Blumentöpfe & Pflanzgefäße
220: "3173", // Töpfe > Untersetzer Gartentopfuntersetzer und Trays
301: "113", // Töpfe > Stofftöpfe (Blumentöpfe/Pflanzgefäße)
317: "113", // Töpfe > Air-Pot (Blumentöpfe/Pflanzgefäße)
364: "113", // Töpfe > Kunststofftöpfe (Blumentöpfe/Pflanzgefäße)
292: "3568", // Bewässerung > Trays & Fluttische Bewässerungssysteme
// Ventilation & Climate
703: "Home & Garden > Outdoor Power Tools", // Abluft-Sets
247: "Home & Garden > Outdoor Power Tools", // Belüftung
214: "Home & Garden > Outdoor Power Tools", // Umluft-Ventilatoren
308: "Home & Garden > Outdoor Power Tools", // Ab- und Zuluft
609: "Home & Garden > Outdoor Power Tools", // Schalldämpfer
248: "Home & Garden > Pool & Spa > Pool & Spa Filters", // Aktivkohlefilter
392: "Home & Garden > Pool & Spa > Pool & Spa Filters", // Zuluftfilter
658: "Home & Garden > Climate Control > Dehumidifiers", // Luftbe- und entfeuchter
310: "Home & Garden > Climate Control > Heating", // Heizmatten
379: "Home & Garden > Household Supplies > Air Fresheners", // Geruchsneutralisation
703: "2802", // Grow-Sets > Abluft-Sets (verwendet Pflanzen-Kräuter-Anbausets)
247: "1700", // Belüftung Ventilatoren (Klimatisierung)
214: "1700", // Belüftung > Umluft-Ventilatoren Ventilatoren
308: "1700", // Belüftung > Ab- und Zuluft Ventilatoren
609: "1700", // Belüftung > Ab- und Zuluft > Schalldämpfer Ventilatoren
248: "1700", // Belüftung > Aktivkohlefilter Ventilatoren (nächstmögliche)
392: "1700", // Belüftung > Ab- und Zuluft > Zuluftfilter Ventilatoren
658: "606", // Belüftung > Luftbe- und -entfeuchter Luftentfeuchter
310: "2802", // Anzucht > Heizmatten Pflanzen- & Kräuteranbausets
379: "5631", // Belüftung > Geruchsneutralisation Haushaltsbedarf: Aufbewahrung
// Irrigation & Watering
221: "Home & Garden > Lawn & Garden > Watering Equipment", // Bewässerung
250: "Home & Garden > Lawn & Garden > Watering Equipment", // Schläuche
297: "Home & Garden > Lawn & Garden > Watering Equipment", // Pumpen
354: "Home & Garden > Lawn & Garden > Watering Equipment", // Sprüher
372: "Home & Garden > Lawn & Garden > Watering Equipment", // AutoPot
389: "Home & Garden > Lawn & Garden > Watering Equipment", // Blumat
405: "Home & Garden > Lawn & Garden > Watering Equipment", // Schläuche
425: "Home & Garden > Lawn & Garden > Watering Equipment", // Wassertanks
480: "Home & Garden > Lawn & Garden > Watering Equipment", // Tropfer
519: "Home & Garden > Lawn & Garden > Watering Equipment", // Pumpsprüher
221: "3568", // Bewässerung Bewässerungssysteme (Gesamt)
250: "6318", // Bewässerung > Schläuche Gartenschläuche
297: "500100", // Bewässerung > Pumpen Bewässerung-/Sprinklerpumpen
354: "3780", // Bewässerung > Sprüher Sprinkler & Sprühköpfe
372: "3568", // Bewässerung > AutoPot Bewässerungssysteme
389: "3568", // Bewässerung > Blumat Bewässerungssysteme
405: "6318", // Bewässerung > Schläuche Gartenschläuche
425: "3568", // Bewässerung > Wassertanks Bewässerungssysteme
480: "3568", // Bewässerung > Tropfer Bewässerungssysteme
519: "3568", // Bewässerung > Pumpsprüher Bewässerungssysteme
// Growing Media & Soils
242: "Home & Garden > Lawn & Garden > Fertilizers", // Böden
243: "Home & Garden > Lawn & Garden > Fertilizers", // Erde
269: "Home & Garden > Lawn & Garden > Fertilizers", // Kokos
580: "Home & Garden > Lawn & Garden > Fertilizers", // Perlite & Blähton
242: "543677", // Böden Gartenerde
243: "543677", // Böden > Erde Gartenerde
269: "543677", // Böden > Kokos Gartenerde
580: "543677", // Böden > Perlite & Blähton Gartenerde
// Propagation & Starting
286: "Home & Garden > Plants", // Anzucht
298: "Home & Garden > Plants", // Steinwolltrays
421: "Home & Garden > Plants", // Vermehrungszubehör
489: "Home & Garden > Plants", // EazyPlug & Jiffy
359: "Home & Garden > Outdoor Structures > Greenhouses", // Gewächshäuser
286: "2802", // Anzucht Pflanzen- & Kräuteranbausets
298: "2802", // Anzucht > Steinwolltrays Pflanzen- & Kräuteranbausets
421: "2802", // Anzucht > Vermehrungszubehör Pflanzen- & Kräuteranbausets
489: "2802", // Anzucht > EazyPlug & Jiffy Pflanzen- & Kräuteranbausets
359: "3103", // Anzucht > Gewächshäuser Gewächshäuser
// Tools & Equipment
373: "Home & Garden > Tools > Hand Tools", // GrowTool
403: "Home & Garden > Kitchen & Dining > Kitchen Tools & Utensils", // Messbecher & mehr
259: "Home & Garden > Tools > Hand Tools", // Pressen
280: "Home & Garden > Tools > Hand Tools", // Erntescheeren
258: "Home & Garden > Tools", // Ernte & Verarbeitung
278: "Home & Garden > Tools", // Extraktion
302: "Home & Garden > Tools", // Erntemaschinen
373: "3568", // Bewässerung > GrowTool Bewässerungssysteme
403: "3999", // Bewässerung > Messbecher & mehr Messbecher & Dosierlöffel
259: "756", // Zubehör > Ernte & Verarbeitung > Pressen Nudelmaschinen
280: "2948", // Zubehör > Ernte & Verarbeitung > Erntescheeren Küchenmesser
258: "684", // Zubehör > Ernte & Verarbeitung Abfallzerkleinerer
278: "5057", // Zubehör > Ernte & Verarbeitung > Extraktion Slush-Eis-Maschinen
302: "7332", // Zubehör > Ernte & Verarbeitung > Erntemaschinen Gartenmaschinen
// Hardware & Plumbing
222: "Hardware > Plumbing", // PE-Teile
374: "Hardware > Plumbing > Plumbing Fittings", // Verbindungsteile
222: "3568", // Bewässerung > PE-Teile Bewässerungssysteme
374: "1700", // Belüftung > Ab- und Zuluft > Verbindungsteile Ventilatoren
// Electronics & Control
314: "Electronics > Electronics Accessories", // Steuergeräte
408: "Electronics > Electronics Accessories", // GrowControl
344: "Business & Industrial > Science & Laboratory > Lab Equipment", // Messgeräte
555: "Business & Industrial > Science & Laboratory > Lab Equipment > Microscopes", // Mikroskope
314: "1700", // Belüftung > Steuergeräte Ventilatoren
408: "1700", // Belüftung > Steuergeräte > GrowControl Ventilatoren
344: "1207", // Zubehör > Messgeräte Messwerkzeuge & Messwertgeber
555: "4555", // Zubehör > Anbauzubehör > Mikroskope Mikroskope
// Camping & Outdoor
226: "Sporting Goods > Outdoor Recreation > Camping & Hiking", // Zeltzubehör
226: "5655", // Zubehör > Zeltzubehör Zelte
// Plant Care & Protection
239: "Home & Garden > Lawn & Garden > Pest Control", // Pflanzenschutz
240: "Home & Garden > Plants", // Anbauzubehör
239: "4085", // Zubehör > Anbauzubehör > Pflanzenschutz Herbizide
240: "5633", // Zubehör > Anbauzubehör Zubehör für Gartenarbeit
// Office & Media
424: "Office Supplies > Labels", // Etiketten & Schilder
387: "Media > Books", // Literatur
424: "4377", // Zubehör > Anbauzubehör > Etiketten & Schilder Etiketten & Anhängerschilder
387: "543541", // Zubehör > Anbauzubehör > Literatur Bücher
// General categories
705: "Home & Garden", // Set-Konfigurator
686: "Home & Garden", // Zubehör
741: "Home & Garden", // Zubehör
294: "Home & Garden", // Zubehör
695: "Home & Garden", // Zubehör
293: "Home & Garden", // Trockennetze
4: "Home & Garden", // Sonstiges
450: "Home & Garden", // Restposten
686: "1700", // Belüftung > Aktivkohlefilter > Zubehör Ventilatoren
741: "1700", // Belüftung > Ab- und Zuluft > Zubehör Ventilatoren
294: "3568", // Bewässerung > Zubehör Bewässerungssysteme
695: "5631", // Zubehör Haushaltsbedarf: Aufbewahrung
293: "5631", // Zubehör > Ernte & Verarbeitung > Trockennetze Haushaltsbedarf: Aufbewahrung
4: "5631", // Zubehör > Anbauzubehör > Sonstiges Haushaltsbedarf: Aufbewahrung
450: "5631", // Zubehör > Anbauzubehör > Restposten Haushaltsbedarf: Aufbewahrung
};
return categoryMappings[categoryId] || "Home & Garden > Plants";
const categoryId_str = categoryMappings[categoryId] || "5631"; // Default to Haushaltsbedarf: Aufbewahrung
// Validate that the category ID is not empty
if (!categoryId_str || categoryId_str.trim() === "") {
return "5631"; // Haushaltsbedarf: Aufbewahrung
}
return categoryId_str;
};
let productsXml = `<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:g="http://base.google.com/ns/1.0" version="2.0">
<channel>
<title>${config.descriptions.short}</title>
<title>${config.descriptions.de.short}</title>
<link>${baseUrl}</link>
<description>${config.descriptions.short}</description>
<description>${config.descriptions.de.short}</description>
<lastBuildDate>${currentDate}</lastBuildDate>
<language>${config.language}</language>`;
<language>de-DE</language>`;
// Helper function to clean text content of problematic characters
const cleanTextContent = (text) => {
@@ -197,15 +306,43 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
let processedCount = 0;
let skippedCount = 0;
// Track skip reasons with counts and product lists
const skipReasons = {
noProductOrSeoName: { count: 0, products: [] },
excludedCategory: { count: 0, products: [] },
excludedTermsTitle: { count: 0, products: [] },
excludedTermsDescription: { count: 0, products: [] },
missingGTIN: { count: 0, products: [] },
invalidGTINChecksum: { count: 0, products: [] },
missingPicture: { count: 0, products: [] },
missingWeight: { count: 0, products: [] },
insufficientDescription: { count: 0, products: [] },
nameTooShort: { count: 0, products: [] },
outOfStock: { count: 0, products: [] },
zeroPriceOrInvalid: { count: 0, products: [] },
processingError: { count: 0, products: [] }
};
// Legacy arrays for backward compatibility
const productsNeedingWeight = [];
const productsNeedingDescription = [];
// Category IDs to skip (seeds, plants, headshop items)
const skipCategoryIds = [689, 706, 709, 711, 714, 748, 749, 896, 710];
const skipCategoryIds = [689, 706, 709, 711, 714, 748, 749, 896, 710, 924, 923, 922, 921, 916, 278, 259, 258];
// Add each product as an item
allProductsData.forEach((product, index) => {
try {
// Skip products without essential data
if (!product || !product.seoName) {
skippedCount++;
skipReasons.noProductOrSeoName.count++;
skipReasons.noProductOrSeoName.products.push({
id: product?.articleNumber || 'N/A',
name: product?.name || 'N/A',
url: product?.seoName ? `/Artikel/${product.seoName}` : 'N/A'
});
return;
}
@@ -213,27 +350,168 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
const productCategoryId = product.categoryId || product.category_id || product.category || null;
if (productCategoryId && skipCategoryIds.includes(parseInt(productCategoryId))) {
skippedCount++;
skipReasons.excludedCategory.count++;
skipReasons.excludedCategory.products.push({
id: product.articleNumber || product.seoName,
name: product.name || 'N/A',
categoryId: productCategoryId,
url: `/Artikel/${product.seoName}`
});
return;
}
// Skip products without GTIN
// Skip products with excluded terms in title or description
const productTitle = (product.name || "").toLowerCase();
// Get description early so we can check it for excluded terms
const productDescription = product.kurzBeschreibung || product.description || '';
const excludedTerms = {
title: ['canna', 'hash', 'marijuana', 'marihuana'],
description: ['cannabis']
};
// Check title for excluded terms
const excludedTitleTerm = excludedTerms.title.find(term => productTitle.includes(term));
if (excludedTitleTerm) {
skippedCount++;
skipReasons.excludedTermsTitle.count++;
skipReasons.excludedTermsTitle.products.push({
id: product.articleNumber || product.seoName,
name: product.name || 'N/A',
term: excludedTitleTerm,
url: `/Artikel/${product.seoName}`
});
return;
}
// Check description for excluded terms
const excludedDescTerm = excludedTerms.description.find(term => productDescription.toLowerCase().includes(term));
if (excludedDescTerm) {
skippedCount++;
skipReasons.excludedTermsDescription.count++;
skipReasons.excludedTermsDescription.products.push({
id: product.articleNumber || product.seoName,
name: product.name || 'N/A',
term: excludedDescTerm,
url: `/Artikel/${product.seoName}`
});
return;
}
// Skip products without GTIN or with invalid GTIN
if (!product.gtin || !product.gtin.toString().trim()) {
skippedCount++;
skipReasons.missingGTIN.count++;
skipReasons.missingGTIN.products.push({
id: product.articleNumber || product.seoName,
name: product.name || 'N/A',
url: `/Artikel/${product.seoName}`
});
return;
}
// Validate GTIN format and checksum
const gtinString = product.gtin.toString().trim();
// Helper function to validate GTIN with proper checksum validation
const isValidGTIN = (gtin) => {
if (!/^\d{8}$|^\d{12,14}$/.test(gtin)) return false; // Only 8, 12, 13, 14 digits allowed
const digits = gtin.split('').map(Number);
const length = digits.length;
let sum = 0;
if (length === 8) {
// EAN-8: positions 0-6, check digit at 7
// Multipliers: 3,1,3,1,3,1,3 for positions 0-6
for (let i = 0; i < 7; i++) {
const multiplier = (i % 2 === 0) ? 3 : 1;
sum += digits[i] * multiplier;
}
} else if (length === 12) {
// UPC-A: positions 0-10, check digit at 11
// Multipliers: 3,1,3,1,3,1,3,1,3,1,3 for positions 0-10
for (let i = 0; i < 11; i++) {
const multiplier = (i % 2 === 0) ? 3 : 1;
sum += digits[i] * multiplier;
}
} else if (length === 13) {
// EAN-13: positions 0-11, check digit at 12
// Multipliers: 1,3,1,3,1,3,1,3,1,3,1,3 for positions 0-11
for (let i = 0; i < 12; i++) {
const multiplier = (i % 2 === 0) ? 1 : 3;
sum += digits[i] * multiplier;
}
} else if (length === 14) {
// EAN-14: similar to EAN-13 but 14 digits
for (let i = 0; i < 13; i++) {
const multiplier = (i % 2 === 0) ? 1 : 3;
sum += digits[i] * multiplier;
}
}
const checkDigit = (10 - (sum % 10)) % 10;
return checkDigit === digits[length - 1];
};
if (!isValidGTIN(gtinString)) {
skippedCount++;
skipReasons.invalidGTINChecksum.count++;
skipReasons.invalidGTINChecksum.products.push({
id: product.articleNumber || product.seoName,
name: product.name || 'N/A',
gtin: gtinString,
url: `/Artikel/${product.seoName}`
});
return;
}
// Skip products without pictures
if (!product.pictureList || !product.pictureList.trim()) {
skippedCount++;
skipReasons.missingPicture.count++;
skipReasons.missingPicture.products.push({
id: product.articleNumber || product.seoName,
name: product.name || 'N/A',
url: `/Artikel/${product.seoName}`
});
return;
}
// Check if product has weight data - validate BEFORE building XML
if (!product.weight || isNaN(product.weight)) {
// Track products without weight
const productInfo = {
id: product.articleNumber || product.seoName,
name: product.name || 'Unnamed',
url: `/Artikel/${product.seoName}`
};
productsNeedingWeight.push(productInfo);
skipReasons.missingWeight.count++;
skipReasons.missingWeight.products.push(productInfo);
skippedCount++;
return;
}
// Check if description is missing or too short (less than 20 characters) - skip if insufficient
const originalDescription = productDescription ? cleanTextContent(productDescription) : '';
if (!originalDescription || originalDescription.length < 20) {
const productInfo = {
id: product.articleNumber || product.seoName,
name: product.name || 'Unnamed',
currentDescription: originalDescription || 'NONE',
url: `/Artikel/${product.seoName}`
};
productsNeedingDescription.push(productInfo);
skipReasons.insufficientDescription.count++;
skipReasons.insufficientDescription.products.push(productInfo);
skippedCount++;
return;
}
// Clean description for feed (remove HTML tags and limit length)
const rawDescription = product.description
? cleanTextContent(product.description).substring(0, 500)
: `${product.name || 'Product'} - Art.-Nr.: ${product.articleNumber || 'N/A'}`;
const cleanDescription = escapeXml(rawDescription) || "Produktbeschreibung nicht verfügbar";
const feedDescription = cleanTextContent(productDescription).substring(0, 500);
const cleanDescription = escapeXml(feedDescription) || "Produktbeschreibung nicht verfügbar";
// Clean product name
const rawName = product.name || "Unnamed Product";
@@ -242,6 +520,13 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
// Validate essential fields
if (!cleanName || cleanName.length < 2) {
skippedCount++;
skipReasons.nameTooShort.count++;
skipReasons.nameTooShort.products.push({
id: product.articleNumber || product.seoName,
name: rawName,
cleanedName: cleanName,
url: `/Artikel/${product.seoName}`
});
return;
}
@@ -250,7 +535,7 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
// Generate image URL
const imageUrl = product.pictureList && product.pictureList.trim()
? `${baseUrl}/assets/images/prod${product.pictureList.split(",")[0].trim()}.jpg`
? `${baseUrl}/assets/images/prod${product.pictureList.split(",")[0].trim()}.avif`
: `${baseUrl}/assets/images/nopicture.jpg`;
// Generate brand (manufacturer)
@@ -263,6 +548,18 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
// Generate availability
const availability = product.available ? "in stock" : "out of stock";
// Skip products that are out of stock
if (!product.available) {
skippedCount++;
skipReasons.outOfStock.count++;
skipReasons.outOfStock.products.push({
id: product.articleNumber || product.seoName,
name: product.name || 'N/A',
url: `/Artikel/${product.seoName}`
});
return;
}
// Generate price (ensure it's a valid number)
const price = product.price && !isNaN(product.price)
? `${parseFloat(product.price).toFixed(2)} ${config.currency}`
@@ -271,11 +568,18 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
// Skip products with price == 0
if (!product.price || parseFloat(product.price) === 0) {
skippedCount++;
skipReasons.zeroPriceOrInvalid.count++;
skipReasons.zeroPriceOrInvalid.products.push({
id: product.articleNumber || product.seoName,
name: product.name || 'N/A',
price: product.price,
url: `/Artikel/${product.seoName}`
});
return;
}
// Generate GTIN/EAN if available
const gtin = product.gtin ? escapeXml(product.gtin.toString().trim()) : null;
// Generate GTIN/EAN if available (use the already validated gtinString)
const gtin = gtinString ? escapeXml(gtinString) : null;
// Generate product ID (using articleNumber or seoName)
const rawProductId = product.articleNumber || product.seoName || `product_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`;
@@ -286,7 +590,7 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
const googleCategory = getGoogleProductCategory(categoryId);
const escapedGoogleCategory = escapeXml(googleCategory);
// Build item XML with proper formatting
// Build item XML with proper formatting (all validation passed, safe to write XML)
productsXml += `
<item>
<g:id>${productId}</g:id>
@@ -312,10 +616,21 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
<g:gtin>${gtin}</g:gtin>`;
}
// Add weight if available
if (product.weight && !isNaN(product.weight)) {
// Add weight (we know it exists at this point since we validated it earlier)
// Convert from kg to grams (multiply by 1000)
const weightInGrams = parseFloat(product.weight) * 1000;
productsXml += `
<g:shipping_weight>${parseFloat(product.weight).toFixed(2)} g</g:shipping_weight>`;
<g:shipping_weight>${weightInGrams.toFixed(2)} g</g:shipping_weight>`;
// Add unit pricing data (required by German law for many products)
const unitPricingData = determineUnitPricingData(product);
if (unitPricingData.unit_pricing_measure) {
productsXml += `
<g:unit_pricing_measure>${unitPricingData.unit_pricing_measure}</g:unit_pricing_measure>`;
}
if (unitPricingData.unit_pricing_base_measure) {
productsXml += `
<g:unit_pricing_base_measure>${unitPricingData.unit_pricing_base_measure}</g:unit_pricing_base_measure>`;
}
productsXml += `
@@ -326,6 +641,13 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
} catch (itemError) {
console.log(` ⚠️ Skipped product ${index + 1}: ${itemError.message}`);
skippedCount++;
skipReasons.processingError.count++;
skipReasons.processingError.products.push({
id: product?.articleNumber || product?.seoName || 'N/A',
name: product?.name || 'N/A',
error: itemError.message,
url: product?.seoName ? `/Artikel/${product.seoName}` : 'N/A'
});
}
});
@@ -333,7 +655,133 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
</channel>
</rss>`;
console.log(` 📊 Processing summary: ${processedCount} products included, ${skippedCount} skipped`);
console.log(`\n 📊 Processing summary: ${processedCount} products included, ${skippedCount} skipped`);
// Display skip reason totals
console.log(`\n 📋 Skip Reasons Breakdown:`);
console.log(` ────────────────────────────────────────────────────────────`);
const skipReasonLabels = {
noProductOrSeoName: 'No Product or SEO Name',
excludedCategory: 'Excluded Category',
excludedTermsTitle: 'Excluded Terms in Title',
excludedTermsDescription: 'Excluded Terms in Description',
missingGTIN: 'Missing GTIN',
invalidGTINChecksum: 'Invalid GTIN Checksum',
missingPicture: 'Missing Picture',
missingWeight: 'Missing Weight',
insufficientDescription: 'Insufficient Description',
nameTooShort: 'Name Too Short',
outOfStock: 'Out of Stock',
zeroPriceOrInvalid: 'Zero or Invalid Price',
processingError: 'Processing Error'
};
let hasAnySkips = false;
Object.entries(skipReasons).forEach(([key, data]) => {
if (data.count > 0) {
hasAnySkips = true;
const label = skipReasonLabels[key] || key;
console.log(`${label}: ${data.count}`);
}
});
if (!hasAnySkips) {
console.log(` ✅ No products were skipped`);
}
console.log(` ────────────────────────────────────────────────────────────`);
console.log(` Total: ${skippedCount} products skipped\n`);
// Write log files for products needing attention
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const logsDir = path.join(process.cwd(), 'logs');
// Ensure logs directory exists
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir, { recursive: true });
}
// Write comprehensive skip reasons log
const skipLogPath = path.join(logsDir, `skip-reasons-${timestamp}.log`);
let skipLogContent = `# Product Skip Reasons Report
# Generated: ${new Date().toISOString()}
# Total products processed: ${processedCount}
# Total products skipped: ${skippedCount}
# Base URL: ${baseUrl}
`;
Object.entries(skipReasons).forEach(([key, data]) => {
if (data.count > 0) {
const label = skipReasonLabels[key] || key;
skipLogContent += `\n## ${label} (${data.count} products)\n`;
skipLogContent += `${'='.repeat(80)}\n`;
data.products.forEach(product => {
skipLogContent += `ID: ${product.id}\n`;
skipLogContent += `Name: ${product.name}\n`;
if (product.categoryId !== undefined) {
skipLogContent += `Category ID: ${product.categoryId}\n`;
}
if (product.term !== undefined) {
skipLogContent += `Excluded Term: ${product.term}\n`;
}
if (product.gtin !== undefined) {
skipLogContent += `GTIN: ${product.gtin}\n`;
}
if (product.currentDescription !== undefined) {
skipLogContent += `Current Description: "${product.currentDescription}"\n`;
}
if (product.cleanedName !== undefined) {
skipLogContent += `Cleaned Name: "${product.cleanedName}"\n`;
}
if (product.price !== undefined) {
skipLogContent += `Price: ${product.price}\n`;
}
if (product.error !== undefined) {
skipLogContent += `Error: ${product.error}\n`;
}
skipLogContent += `URL: ${baseUrl}${product.url}\n`;
skipLogContent += `${'-'.repeat(80)}\n`;
});
}
});
fs.writeFileSync(skipLogPath, skipLogContent, 'utf8');
console.log(` 📄 Detailed skip reasons report saved to: ${skipLogPath}`);
// Write missing weight log (for backward compatibility)
if (productsNeedingWeight.length > 0) {
const weightLogContent = `# Products Missing Weight Data
# Generated: ${new Date().toISOString()}
# Total products missing weight: ${productsNeedingWeight.length}
${productsNeedingWeight.map(product => `${product.id}\t${product.name}\t${baseUrl}${product.url}`).join('\n')}
`;
const weightLogPath = path.join(logsDir, `missing-weight-${timestamp}.log`);
fs.writeFileSync(weightLogPath, weightLogContent, 'utf8');
console.log(` ⚠️ Products missing weight (${productsNeedingWeight.length}) - saved to: ${weightLogPath}`);
}
// Write missing description log (for backward compatibility)
if (productsNeedingDescription.length > 0) {
const descLogContent = `# Products With Insufficient Description Data
# Generated: ${new Date().toISOString()}
# Total products needing description: ${productsNeedingDescription.length}
${productsNeedingDescription.map(product => `${product.id}\t${product.name}\t"${product.currentDescription}"\t${baseUrl}${product.url}`).join('\n')}
`;
const descLogPath = path.join(logsDir, `missing-description-${timestamp}.log`);
fs.writeFileSync(descLogPath, descLogContent, 'utf8');
console.log(` ⚠️ Products with insufficient description (${productsNeedingDescription.length}) - saved to: ${descLogPath}`);
}
if (productsNeedingWeight.length === 0 && productsNeedingDescription.length === 0) {
console.log(` ✅ All products have adequate weight and description data`);
}
return productsXml;
};

View File

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

View File

@@ -5,6 +5,7 @@ const {
} = require('./product.cjs');
const {
generateCategoryMetaTags,
generateCategoryJsonLd,
} = require('./category.cjs');
@@ -31,6 +32,7 @@ const {
generateLlmsTxt,
generateCategoryLlmsTxt,
generateAllCategoryLlmsPages,
generateCategoryProductList,
} = require('./llms.cjs');
// Export all functions for use in the main application
@@ -40,6 +42,7 @@ module.exports = {
generateProductJsonLd,
// Category functions
generateCategoryMetaTags,
generateCategoryJsonLd,
// Homepage functions
@@ -61,4 +64,5 @@ module.exports = {
generateLlmsTxt,
generateCategoryLlmsTxt,
generateAllCategoryLlmsPages,
generateCategoryProductList,
};

View File

@@ -60,32 +60,19 @@ GrowHeads.de is a German online shop and local store in Dresden specializing in
if (totalPages > 1) {
llmsTxt += `
- **Product Catalog**: ${totalPages} pages available
- **Page 1**: ${baseUrl}/llms-${categorySlug}-page-1.txt (Products 1-${Math.min(productsPerPage, productCount)})`;
if (totalPages > 2) {
${totalPages} pages available`;
for (let p = 1; p <= totalPages; p++) {
const start = (p - 1) * productsPerPage + 1;
const end = Math.min(p * productsPerPage, productCount);
llmsTxt += `
- **Page 2**: ${baseUrl}/llms-${categorySlug}-page-2.txt (Products ${productsPerPage + 1}-${Math.min(productsPerPage * 2, productCount)})`;
- **Page ${p}**: ${baseUrl}/llms-${categorySlug}-page-${p}.txt (Products ${start}-${end})`;
}
if (totalPages > 3) {
llmsTxt += `
- **...**: Additional pages available`;
}
if (totalPages > 2) {
llmsTxt += `
- **Page ${totalPages}**: ${baseUrl}/llms-${categorySlug}-page-${totalPages}.txt (Products ${((totalPages - 1) * productsPerPage) + 1}-${productCount})`;
}
llmsTxt += `
- **Access Pattern**: Replace "page-X" with desired page number (1-${totalPages})`;
} else if (productCount > 0) {
llmsTxt += `
- **Product Catalog**: ${baseUrl}/llms-${categorySlug}-page-1.txt`;
${baseUrl}/llms-${categorySlug}-page-1.txt`;
} else {
llmsTxt += `
- **Product Catalog**: No products available`;
No products available`;
}
llmsTxt += `
@@ -183,7 +170,13 @@ This file contains products ${startIndex + 1}-${endIndex} of ${totalProducts} in
categoryLlmsTxt += `## ${globalIndex}. ${product.name}
- **Product URL**: ${baseUrl}/Artikel/${product.seoName}
- **Article Number**: ${product.articleNumber || 'N/A'}
`;
if (product.kurzBeschreibung) {
categoryLlmsTxt += `- **Desc:** ${product.kurzBeschreibung}\n`;
}
categoryLlmsTxt += `- **Article Number**: ${product.articleNumber || 'N/A'}
- **Price**: €${product.price || '0.00'}
- **Brand**: ${product.manufacturer || config.brandName}
- **Availability**: ${product.available ? 'In Stock' : 'Out of Stock'}`;
@@ -248,6 +241,41 @@ This category currently contains no products.
return categoryLlmsTxt;
};
// Helper function to generate a simple product list for a category
const generateCategoryProductList = (category, categoryProducts = []) => {
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
const fileName = `llms-${categorySlug}-list.txt`;
const subcategoryIds = (category.subcategories || []).join(',');
let content = '';//`${String(category.name)},${String(category.id)},[${subcategoryIds}]\n`;
categoryProducts.forEach((product) => {
const artnr = String(product.articleNumber || '');
const price = String(product.price || '0.00');
const name = String(product.name || '');
const kurzBeschreibung = String(product.kurzBeschreibung || '');
// Escape commas in fields by wrapping in quotes if they contain commas
const escapeField = (field) => {
const fieldStr = String(field || '');
if (fieldStr.includes(',')) {
return `"${fieldStr.replace(/"/g, '""')}"`;
}
return fieldStr;
};
content += `${escapeField(artnr)},${escapeField(price)},${escapeField(name)},${escapeField(kurzBeschreibung)}\n`;
});
return {
fileName,
content,
categoryName: category.name,
categoryId: category.id,
productCount: categoryProducts.length
};
};
// Helper function to generate all pages for a category
const generateAllCategoryLlmsPages = (category, categoryProducts = [], baseUrl, config, productsPerPage = 50) => {
const totalProducts = categoryProducts.length;
@@ -274,4 +302,5 @@ module.exports = {
generateLlmsTxt,
generateCategoryLlmsTxt,
generateAllCategoryLlmsPages,
generateCategoryProductList,
};

View File

@@ -1,14 +1,26 @@
const generateProductMetaTags = (product, baseUrl, config) => {
const productUrl = `${baseUrl}/Artikel/${product.seoName}`;
const imageUrl =
const pictureFirstId =
product.pictureList && product.pictureList.trim()
? `${baseUrl}/assets/images/prod${product.pictureList
.split(",")[0]
.trim()}.jpg`
? product.pictureList.split(",")[0].trim()
: null;
const imageUrl = pictureFirstId
? `${baseUrl}/assets/images/prod${pictureFirstId}.avif`
: `${baseUrl}/assets/images/nopicture.jpg`;
const twitterImageUrl = pictureFirstId
? `${baseUrl}/assets/images/prod${pictureFirstId}.jpg`
: `${baseUrl}/assets/images/nopicture.jpg`;
// Clean description for meta (remove HTML tags and limit length)
const cleanDescription = product.description
const cleanDescription = product.kurzBeschreibung
? product.kurzBeschreibung
.replace(/<[^>]*>/g, "")
.replace(/\n/g, " ")
.substring(0, 160)
: product.description
? product.description
.replace(/<[^>]*>/g, "")
.replace(/\n/g, " ")
@@ -25,7 +37,7 @@ const generateProductMetaTags = (product, baseUrl, config) => {
<!-- Open Graph Meta Tags -->
<meta property="og:title" content="${product.name}">
<meta property="og:description" content="${cleanDescription}">
<meta property="og:image" content="${imageUrl}">
<meta property="og:image" content="${twitterImageUrl}">
<meta property="og:url" content="${productUrl}">
<meta property="og:type" content="product">
<meta property="og:site_name" content="${config.siteName}">
@@ -42,22 +54,29 @@ const generateProductMetaTags = (product, baseUrl, config) => {
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${product.name}">
<meta name="twitter:description" content="${cleanDescription}">
<meta name="twitter:image" content="${imageUrl}">
<meta name="twitter:image" content="${twitterImageUrl}">
<!-- Additional Meta Tags -->
<meta name="robots" content="index, follow">
<link rel="canonical" href="${productUrl}">
<!-- Store image URL in window object -->
<script>
window.productImageUrl = "${imageUrl}";
</script>
`;
};
const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) => {
const productUrl = `${baseUrl}/Artikel/${product.seoName}`;
const imageUrl =
const root = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
const productUrl = `${root}/Artikel/${product.seoName}`;
const pictureFirstId =
product.pictureList && product.pictureList.trim()
? `${baseUrl}/assets/images/prod${product.pictureList
.split(",")[0]
.trim()}.jpg`
: `${baseUrl}/assets/images/nopicture.jpg`;
? product.pictureList.split(",")[0].trim()
: null;
const imageUrl = pictureFirstId
? `${root}/assets/images/prod${pictureFirstId}.avif`
: `${root}/assets/images/nopicture.jpg`;
// Clean description for JSON-LD (remove HTML tags)
const cleanDescription = product.description
@@ -68,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],
@@ -80,39 +178,28 @@ 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,
},
},
offers: offer,
};
// Add breadcrumb if category information is available
if (categoryInfo && categoryInfo.name && categoryInfo.seoName) {
jsonLd.breadcrumb = {
const hasBreadcrumb =
categoryInfo && categoryInfo.name && categoryInfo.seoName;
const breadcrumbList = hasBreadcrumb
? {
"@id": id.breadcrumb,
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Home",
item: baseUrl,
item: root,
},
{
"@type": "ListItem",
position: 2,
name: categoryInfo.name,
item: `${baseUrl}/Kategorie/${categoryInfo.seoName}`,
item: `${root}/Kategorie/${categoryInfo.seoName}`,
},
{
"@type": "ListItem",
@@ -121,11 +208,34 @@ const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) =>
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>`;
};

View File

@@ -7,11 +7,17 @@ const collectAllCategories = (categoryNode, categories = []) => {
// Add current category (skip root category 209)
if (categoryNode.id !== 209) {
// Extract subcategory IDs from children
const subcategoryIds = categoryNode.children
? categoryNode.children.map(child => child.id)
: [];
categories.push({
id: categoryNode.id,
name: categoryNode.name,
seoName: categoryNode.seoName,
parentId: categoryNode.parentId
parentId: categoryNode.parentId,
subcategories: subcategoryIds
});
}

105
process_llms_cat.cjs Normal file
View File

@@ -0,0 +1,105 @@
const fs = require('fs');
const path = require('path');
// Read the input file from public
const inputFile = path.join(__dirname, 'public', 'llms-cat.txt');
// Write the output file to dist
const outputFile = path.join(__dirname, 'dist', 'llms-cat.txt');
// Function to parse a CSV line with escaped quotes
function parseCSVLine(line) {
const fields = [];
let current = '';
let inQuotes = false;
let i = 0;
while (i < line.length) {
const char = line[i];
if (char === '"') {
// Check if this is an escaped quote
if (i + 1 < line.length && line[i + 1] === '"') {
current += '"'; // Add single quote (unescaped)
i += 2; // Skip both quotes
continue;
} else {
inQuotes = !inQuotes; // Toggle quote state
}
} else if (char === ',' && !inQuotes) {
fields.push(current);
current = '';
} else {
current += char;
}
i++;
}
fields.push(current); // Add the last field
return fields;
}
try {
if (!fs.existsSync(inputFile)) {
throw new Error(`Input file not found: ${inputFile}`);
}
const data = fs.readFileSync(inputFile, 'utf8');
const lines = data.trim().split('\n');
// Keep the header as intended: URL and Description
const outputLines = ['URL of product list for article numbers,SEO Description'];
let skippedLines = 0;
let processedLines = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.trim() === '') continue;
// Skip comment lines or lines not starting with a number/quote (simple heuristic for header/comments)
// The file starts with text "this file has..." and then header "categoryId..."
// Actual data lines start with "
if (!line.trim().startsWith('"')) {
continue;
}
// Parse the CSV line properly handling escaped quotes
const fields = parseCSVLine(line);
if (fields.length !== 3) {
console.warn(`Skipping malformed line ${i + 1} (got ${fields.length} fields): ${line.substring(0, 50)}...`);
skippedLines++;
continue;
}
// Input: categoryId, listFileName, seoDescription
// Output: URL, SEO Description
const [categoryId, listFileName, seoDescription] = fields;
// Use listFileName as URL
const url = listFileName;
// Use seoDescription as description directly (it's already a string)
const description = seoDescription;
// Escape quotes for CSV output
const escapedDescription = '"' + description.replace(/"/g, '""') + '"';
outputLines.push(`${url},${escapedDescription}`);
processedLines++;
}
// Ensure dist directory exists
const distDir = path.dirname(outputFile);
if (!fs.existsSync(distDir)) {
fs.mkdirSync(distDir, { recursive: true });
}
// Write the output CSV
fs.writeFileSync(outputFile, outputLines.join('\n'), 'utf8');
console.log(`Processed ${processedLines} lines (skipped ${skippedLines}) and created ${outputFile}`);
} catch (error) {
console.error('Error processing file:', error.message);
process.exit(1);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="de">
<html lang="de" data-i18n-lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">

92
public/llms-cat.txt Normal file
View File

@@ -0,0 +1,92 @@
this file has the list of category overview lists, where you can find article numbers
categoryId,listFileName,seoDescription
"703","https://growheads.de/llms-abluft-sets-list.txt","Abluft-Sets für Growbox & Indoor-Grow: leise, energiesparend & mit Aktivkohlefilter zur Geruchsneutralisation. Ideal für Zelte von 60 cm bis 1 m²."
"317","https://growheads.de/llms-air-pot-list.txt","Air-Pot Pflanztöpfe für maximales Wurzelwachstum: Air-Pruning, optimale Belüftung & Drainage. Ideal für Indoor, Outdoor, Hydroponik & Anzucht."
"922","https://growheads.de/llms-aktivkohlefilter-tips-list.txt","Aktivkohlefilter & Tips für Zigaretten und Selbstgedrehte milderer Geschmack, weniger Schadstoffe, optimale Rauchfilterung und hoher Genuss."
"372","https://growheads.de/llms-autopot-list.txt","AutoPot Bewässerungssysteme & Zubehör: Stromlose, automatische Pflanzenbewässerung mit Tanks, Schläuchen, FlexiPots & AQUAvalve5 für Hydroponik & Garten."
"389","https://growheads.de/llms-blumat-list.txt","Blumat Bewässerungssysteme & Zubehör: Tropf-Bewässerung, Erdfeuchte-Sensoren und Ventile für automatische, bedarfsgerechte Pflanzenbewässerung."
"355","https://growheads.de/llms-boveda-integra-boost-list.txt","Boveda & Integra Boost Hygro-Packs für perfekte Feuchtigkeitskontrolle Deiner Kräuter. Verhindern Schimmel, Austrocknung und Aroma-Verlust bei der Lagerung."
"749","https://growheads.de/llms-chillums-diffusoren-kupplungen-list.txt","Chillums, Diffusoren & Kupplungen für Bongs große Auswahl an 14,5mm, 18,8mm & 29,2mm Adaptern aus Glas für bessere Kühlung & sanfteren Rauchgenuss."
"449","https://growheads.de/llms-cliptuetchen-list.txt","Cliptütchen & Mylarbeutel: hochwertige Zip Bags und Schnellverschlussbeutel in vielen Größen, starken Folienstärken und Farben ideal zur sicheren Lagerung."
"924","https://growheads.de/llms-dabbing-list.txt","Entdecken Sie hochwertiges Dabbing-Zubehör für konzentrierte Aromagenuss Dab Rigs, Tools und mehr für ein intensives, sauberes Dab-Erlebnis."
"691","https://growheads.de/llms-duenger-list.txt","Dünger & Pflanzennährstoffe für Erde, Coco & Hydro: Bio- und Mineraldünger, Booster, Wurzelstimulatoren, PK-Additive & pH-Regulatoren für maximale Erträge."
"692","https://growheads.de/llms-duenger-zubehoer-list.txt","Dünger-Zubehör: Abfüllhähne, Wurmhumus, pH-Eichlösungen & Desinfektionsmittel für präzise Dosierung, Hygiene und optimale Nährstoffversorgung der Pflanzen."
"489","https://growheads.de/llms-eazyplug-jiffy-list.txt","EazyPlug & Jiffy Anzuchtmedien: nachhaltige Anzuchtwürfel, Torftöpfe & Trays für Stecklinge und Sämlinge mit optimalem Wasser-Luft-Verhältnis."
"243","https://growheads.de/llms-erde-list.txt","Hochwertige Blumenerde & Bio-Substrate: torffrei, organisch, vorgedüngt ideal für Indoor-Grow, Urban Gardening, Kräuter, Gemüse & Cannabis-Anbau."
"302","https://growheads.de/llms-erntemaschinen-list.txt","Erntemaschinen & Leaf Trimmer für professionelle Ernteverarbeitung vom manuellen Trimmer bis zur automatisierten Profigerät, inkl. Zubehör & Ersatzteile."
"280","https://growheads.de/llms-erntescheeren-list.txt","Hochwertige Erntescheren & Gartenscheren für präzise Pflanzenschnitte. Entdecken Sie Profi-Erntescheren aus Edelstahl & Japanstahl für Garten & Indoor-Grow."
"424","https://growheads.de/llms-etiketten-schilder-list.txt","Etiketten & Schilder für Garten & Gewächshaus wasserfeste Stecketiketten in vielen Farben für Pflanzenbeschriftung, Sortierung und Kennzeichnung."
"278","https://growheads.de/llms-extraktion-list.txt","Hochwertige Extraktionszubehör & -geräte: Pollenmaschinen, Extraktorbeutel, DME-Gas, Rosin Bags & Infuser für saubere Pflanzen- und Öl-Extraktionen."
"379","https://growheads.de/llms-geruchsneutralisation-list.txt","Effektive Geruchsneutralisation für Haushalt, Grow-Räume & Gewerbe ONA & BIODOR Gel, Spray, Filter und Ozongeneratoren gegen Tabak-, Cannabis- & Tiergerüche."
"359","https://growheads.de/llms-gewaechshaeuser-list.txt","Gewächshäuser & Anzuchtgewächshäuser für drinnen: beheizt, mit LED & Lüftung. Ideal für Kräuter, Gemüse & Stecklinge für erfolgreiche Pflanzenanzucht."
"539","https://growheads.de/llms-glaeser-dosen-list.txt","Gläser & Dosen für luftdichte, lichtgeschützte und geruchsneutrale Aufbewahrung von Kräutern, Lebensmitteln & Wertsachen. Vakuum-, Stash- & Miron-Glas."
"710","https://growheads.de/llms-grinder-list.txt","Hochwertige Grinder & Kräutermühlen aus Aluminium, Holz & Edelstahl 2-, 3- & 4-teilig, Pollinator, Non-Sticky & Design-Grinder für Tabak & Kräuter."
"407","https://growheads.de/llms-grove-bags-list.txt","Grove Bags TerpLoc professionelle Lagerung für Cannabis & Kräuter. Schimmelschutz, Feuchtigkeitskontrolle, Terpene & Aroma optimal bewahren."
"408","https://growheads.de/llms-growcontrol-list.txt","GrowControl Steuerungen & Sensoren für präzises Klima-, CO₂- und Lichtmanagement im Indoor-Grow. Made in Germany, kompatibel mit EC-Lüftern & LED-Systemen."
"373","https://growheads.de/llms-growtool-list.txt","GrowTool Zubehör für professionelle Bewässerung & GrowRacks: stabile Unterbauten, aeroponische Systeme, Adapter & Wasserkühler für optimales Indoor-Growing."
"310","https://growheads.de/llms-heizmatten-list.txt","Heizmatten für Gewächshaus, Growbox & Terrarium: Effiziente Wurzelwärme, schnellere Keimung & gesundes Pflanzenwachstum mit Thermostat-Steuerung."
"748","https://growheads.de/llms-koepfe-list.txt","Hochwertige Bong-Köpfe & Glasbowls: Entdecke Trichterköpfe, Flutschköpfe und Zenit Premium-Köpfe in 14,5 & 18,8 mm in vielen Farben online."
"269","https://growheads.de/llms-kokos-list.txt","Entdecken Sie hochwertige Kokossubstrate & Kokosmatten für Hydroponik, Indoor-Grow & Topfkulturen torffreie, nachhaltige Coco-Erden für starkes Wurzelwachstum."
"364","https://growheads.de/llms-kunststofftoepfe-list.txt","Kunststofftöpfe für Pflanzen, Anzucht und Umtopfen: Viereckige Pflanztöpfe, Airpots & Mini-Pots in vielen Größen für gesundes Wurzelwachstum."
"694","https://growheads.de/llms-lampen-list.txt","Entdecken Sie hochwertige LED Grow Lampen & Pflanzenlampen mit Vollspektrum für Indoor-Grow, Wachstum & Blüte. Effizient, dimmbar & langlebig."
"261","https://growheads.de/llms-lampenzubehoer-list.txt","Hochwertiges Lampenzubehör für Growbox & Gewächshaus: Aufhängungen, Dimmer, Reflektoren, Netzteile & SANlight-Zubehör für optimales Pflanzenlicht."
"387","https://growheads.de/llms-literatur-list.txt","Entdecke Fachliteratur zu Cannabis-Anbau, Bio-Grow, LED, Hydrokultur & Extraktion praxisnahe Bücher für Indoor- und Outdoor-Gärtner, Anfänger & Profis."
"658","https://growheads.de/llms-luftbe-und-entfeuchter-list.txt","Effektive Luftbefeuchter & Luftentfeuchter für Growroom & Indoor-Anbau. Optimale Luftfeuchtigkeit, Schimmelvorbeugung & gesundes Pflanzenwachstum."
"403","https://growheads.de/llms-messbecher-mehr-list.txt","Messbecher, Pipetten & Einwegspritzen zum präzisen Abmessen von Flüssigkeiten ideal für Dünger, Zusätze, Labor und Garten. Verschiedene Größen."
"344","https://growheads.de/llms-messgeraete-list.txt","Präzise pH-, EC-, Temperatur- und Klimamessgeräte für Garten, Hydroponik & Labor. Entdecke Profi-Messinstrumente, Sonden und Kalibrierlösungen."
"555","https://growheads.de/llms-mikroskope-list.txt","Mikroskope & Lupen für Hobby, Schule & Elektronik: Entdecken Sie 5x100x Vergrößerung, USB-Mikroskope, LED-Modelle und mobile Zoom-Mikroskope."
"923","https://growheads.de/llms-papes-blunts-list.txt","Entdecke hochwertige Papers & Blunts für perfekten Rauchgenuss von klassischen Blättchen bis aromatisierten Blunt Wraps in vielen Größen und Stärken."
"222","https://growheads.de/llms-pe-teile-list.txt","PE-Teile für Bewässerung: Absperrhähne, Kupplungen, T-Stücke & Endkappen für PE-Schläuche ideal für Gartenbewässerung und Tropfbewässerung."
"580","https://growheads.de/llms-perlite-blaehton-list.txt","Perlite & Blähton für Hydroponik, Hydrokultur & Gartenbau. Optimale Drainage, Belüftung und Wasserspeicherung für gesundes Wurzelwachstum."
"921","https://growheads.de/llms-pfeifen-list.txt","Entdecken Sie Pfeifen für Aktivkohlefilter: langlebige Holzpfeifen und hochwertige Aluminium-Pfeifen mit Royal Filter Adapter für ein reines Raucherlebnis."
"239","https://growheads.de/llms-pflanzenschutz-list.txt","Pflanzenschutz biologisch & chemiefrei: Nützlinge, Neemöl, Gelbtafeln & Raubmilben gegen Trauermücken, Thripse, Spinnmilben, Blattläuse & Weiße Fliege."
"259","https://growheads.de/llms-pressen-list.txt","Hydraulische Rosin Pressen, Pollenpressen & Rosin Bags für professionelle, lösungsmittelfreie Extraktion und Harzpressung. Große Auswahl & Top-Marken."
"297","https://growheads.de/llms-pumpen-list.txt","Entdecken Sie leistungsstarke Pumpen für Bewässerung, Hydroponik & Aquaristik: Tauchpumpen, Umwälzpumpen, Belüftungs- und Luftpumpen für jeden Bedarf."
"519","https://growheads.de/llms-pumpsprueher-list.txt","Pumpsprüher & Drucksprüher für Garten, Haushalt & Industrie. Hochwertige 18L Sprüher für Pflanzenpflege, Reinigung und Pflanzenschutz online kaufen."
"920","https://growheads.de/llms-raeucherstaebchen-list.txt","Entdecken Sie hochwertige Räucherstäbchen wie Goloka Nag Champa und Satya für Meditation, Ayurveda, Chakra-Harmonisierung und entspannende Duftmomente."
"450","https://growheads.de/llms-restposten-list.txt","Günstige Restposten: stark reduzierte Markenartikel, Sonderposten und Einzelstücke für cleveres Sparen. Jetzt Restbestände kaufen und Schnäppchen sichern."
"916","https://growheads.de/llms-rollen-bauen-list.txt","Entdecke Rolling Trays, Tin Boxen & Rolling Sets für perfektes Drehen praktische Aufbewahrung, integriertes Zubehör & stylische Designs."
"609","https://growheads.de/llms-schalldaempfer-list.txt","Schalldämpfer für Lüftungsanlagen: hochwertige Rohr- & Telefonieschalldämpfer zur effektiven Geräuschreduzierung in Wohnraum, Gewerbe & Technikräumen."
"405","https://growheads.de/llms-schlaeuche-1-list.txt","Schläuche für Bewässerung & Garten: Tropfschläuche, Mikroschläuche und flexible Gartenschläuche in verschiedenen Durchmessern für präzise Wasserversorgung."
"250","https://growheads.de/llms-schlaeuche-list.txt","Hochwertige Lüftungs- und Abluftschläuche: Aluflex-, Combi-, Phonic Trap & Sonodec für leise, effiziente Belüftung in Growroom, Werkstatt & Haus."
"689","https://growheads.de/llms-seeds-list.txt","Entdecke hochwertige Samen: Cannabis-, Gemüse- und Kräutersamen für Indoor & Outdoor, inklusive Autoflower, CBD, Fast Version & Bio-Gartensaatgut."
"915","https://growheads.de/llms-set-zubehoer-list.txt","Set-Zubehör für Grow & Indoor-Garten: Erde, Dünger-Starterkit, Ernteschere, Thermo-Hygrometer & WLAN Zeitschaltuhr für optimale Pflanzenpflege."
"4","https://growheads.de/llms-sonstiges-list.txt","Sonstiges Garten- & Grow-Zubehör: LST Pflanzenbieger, Kabel, CBD-Aromaöle, Adventskalender, Schutzbrillen & Bambusstäbe günstig online kaufen."
"354","https://growheads.de/llms-sprueher-list.txt","Sprüher & Sprühflaschen fürs Pflanzenwässern: Drucksprüher, Handsprüher, Gießstäbe & Hozelock-Spritzdüsen für Gewächshaus, Garten & Indoor-Grow."
"706","https://growheads.de/llms-stecklinge-list.txt","Entdecke hochwertige Cannabis-Stecklinge: Top-Genetiken, feminisierte & Autoflower Sorten, hohe THC- und CBD-Werte, ideal für Indoor- & Outdoor-Grower."
"298","https://growheads.de/llms-steinwolltrays-list.txt","Steinwolltrays & Anzuchtsysteme für Stecklinge & Samen Grodan, Speedgrow & Joplug. Optimale Bewurzelung, Hydroponik-tauglich, pH-neutral & effizient."
"314","https://growheads.de/llms-steuergeraete-list.txt","Steuergeräte für Indoor-Grow & Gewächshaus: Klimacontroller, Lüftersteuerungen, CO₂-Regler & Thermostate für optimales Grow-Klima online kaufen."
"301","https://growheads.de/llms-stofftoepfe-list.txt","Stofftöpfe & Pflanzsäcke für gesundes Wurzelwachstum atmungsaktive, nachhaltige Fabric Pots aus recyceltem Material für Indoor & Outdoor Anbau."
"292","https://growheads.de/llms-trays-fluttische-list.txt","Trays & Fluttische für Growbox & Gewächshaus: stabile Pflanzschalen, Fluttischböden, Water Trays und Eisenracks für effiziente Bewässerung & Trocknung."
"293","https://growheads.de/llms-trockennetze-list.txt","Trockennetze & Dry Bags für Kräuter, Blüten & Samen: platzsparend, geruchsarm & schimmelfrei trocknen ideal für Growbox, Indoor & Balkon."
"480","https://growheads.de/llms-tropfer-list.txt","Tropfer & Mikroschläuche für professionelle Tropfbewässerung Zubehör, Verbinder und Systeme für Garten, Gewächshaus & Containerpflanzen."
"214","https://growheads.de/llms-umluft-ventilatoren-list.txt","Umluft-Ventilatoren für Growbox, Growraum & Haushalt: leise Clip, Box und Wandventilatoren mit Oszillation, EC-Motor, energieeffizient & langlebig."
"220","https://growheads.de/llms-untersetzer-list.txt","Untersetzer & Auffangschalen für Pflanztöpfe: eckig & rund, verschiedene Größen, robust, wasserdicht ideal für Indoor-Grow, Balkon & Zimmerpflanzen."
"346","https://growheads.de/llms-vakuumbeutel-list.txt","Vakuumbeutel & Alu-Bügelbeutel für Lebensmittel, Fermentation & Lagerung luftdicht, robust, BPA-frei. Passend zu allen gängigen Vakuumierern."
"896","https://growheads.de/llms-vaporizer-list.txt","Vaporizer & E-Rigs für Kräuter & Konzentrate: Entdecke Premium-Verdampfer, Dab Tools & Zubehör von Puffco, Storz & Bickel, Wolkenkraft u.v.m."
"374","https://growheads.de/llms-verbindungsteile-list.txt","Verbindungsteile für Lüftungsanlagen: Außen- & Innenverbinder, Reduzierstücke, Gummimuffen, Dichtbänder, T- und Y-Stücke für luftdichte Rohrverbindungen."
"421","https://growheads.de/llms-vermehrungszubehoer-list.txt","Vermehrungszubehör für Stecklinge & Jungpflanzen: Bewurzelungsgel, Clonex Mist, Jiffy Quelltöpfe, Skalpelle & Substrate für erfolgreiche Pflanzenzucht."
"187","https://growheads.de/llms-waagen-list.txt","Präzisionswaagen, Taschenwaagen & Paketwaagen: Entdecken Sie digitale Waagen, Juwelierwaagen und Eichgewichte für Labor, Versand, Haushalt & Hobby."
"425","https://growheads.de/llms-wassertanks-list.txt","Wassertanks & Nährstofftanks für Bewässerung & Hydroponik robuste Tanks, flexible Flex-Tanks, Tankdurchführungen & Zubehör für Growbox und Garten."
"186","https://growheads.de/llms-wiegen-verpacken-list.txt","Wiegen & Verpacken: Präzisionswaagen, Vakuumbeutel, Grove Bags, Boveda, Integra Boost, Cliptütchen sowie Gläser & Dosen für sichere Lagerung."
"693","https://growheads.de/llms-zelte-list.txt","Entdecke hochwertige Growzelte für Indoor-Growing von kompakten Mini-Growboxen bis zu Profi-Zelten mit Mylar, PAR+ & stabilen Stahlrahmen."
"226","https://growheads.de/llms-zeltzubehoer-list.txt","Zeltzubehör für Growbox & Growzelt: Scrog-Netze, Stütznetze, Space Booster, Stoffböden, Verbinder & Zubehör für stabile, effiziente Indoor-Grows."
"686","https://growheads.de/llms-zubehoer-1-list.txt","Zubehör für Aktivkohlefilter, Vorfilter und Flansche: hochwertiges Lüftungs- & Filterzubehör für Prima Klima und Can Filters in Profi-Qualität."
"741","https://growheads.de/llms-zubehoer-2-list.txt","Zubehör für Lüfter & Klima: EC-Controller, Temperaturregler, Netzstecker & Gewebeband für leisen, effizienten und sicheren Betrieb Ihrer Lüftungsanlage."
"294","https://growheads.de/llms-zubehoer-3-list.txt","Praktisches Zubehör für Bewässerung, Hydroponik & Garten: Schläuche, Filter, Heizstäbe, Verbinder und mehr für effiziente Grow- & Bewässerungssysteme."
"714","https://growheads.de/llms-zubehoer-list.txt","Zubehör für Bongs & Wasserpfeifen: Aktivkohle, Adapter, Filter, Köpfe, Vorkühler, Reinigungsmittel & Tools von Zenit, Smokebuddy, Black Leaf u.v.m."
"392","https://growheads.de/llms-zuluftfilter-list.txt","Zuluftfilter für Growroom & Gewächshaus: saubere Frischluft, Schutz vor Pollen, Staub & Insekten, optimales Klima und gesundes Pflanzenwachstum."
"308","https://growheads.de/llms-ab-und-zuluft-list.txt","Ab- und Zuluft für Growbox & Raumklima: leise EC-Lüfter, Rohrventilatoren, Iso-Boxen, Schläuche, Filter, Schalldämpfer & Zubehör für Profi-Lüftung."
"248","https://growheads.de/llms-aktivkohlefilter-list.txt","Aktivkohlefilter für Growbox, Industrie & Lüftung: hochwertige Geruchsneutralisation, Luftreinigung und Zubehör von Can Filters, Prima Klima, Rhino u.v.m."
"240","https://growheads.de/llms-anbauzubehoer-list.txt","Anbauzubehör für Indoor & Outdoor Grow: Kabel, Zeitschaltuhren, Pflanzentraining, Befestigung, Gewächshausheizung & mehr für Hobby- und Profigärtner."
"286","https://growheads.de/llms-anzucht-list.txt","Anzucht-Zubehör für erfolgreiches Vorziehen: Steinwolltrays, Heizmatten, Gewächshäuser, Vermehrungszubehör sowie EazyPlug & Jiffy Substrate online kaufen."
"247","https://growheads.de/llms-belueftung-list.txt","Belüftung für Growbox & Indoor-Grow: Umluft-Ventilatoren, Aktivkohlefilter, Ab- und Zuluft, Steuergeräte, Luftbefeuchter & Entfeuchter, Geruchsneutralisation."
"221","https://growheads.de/llms-bewaesserung-list.txt","Bewässerungssysteme für Garten, Gewächshaus & Indoor-Grow: Pumpen, Schläuche, Tropfer, AutoPot, Blumat, Trays, Wassertanks & Zubehör günstig kaufen."
"242","https://growheads.de/llms-boeden-list.txt","Böden & Substrate für Profi- und Hobby-Grower: Erde, Kokos, Perlite & Blähton für Indoor-Grow, Hydroponik und Gartenbau. Optimale Drainage & Nährstoffversorgung."
"711","https://growheads.de/llms-bongs-list.txt","Bongs online kaufen: Glasbongs, Acrylbongs & Ölbongs von Black Leaf, Jelly Joker, Grace Glass, Boost, Zenit u.v.m. Für Kräuter, Öl & Dabs große Auswahl."
"258","https://growheads.de/llms-ernte-verarbeitung-list.txt","Ernte & Verarbeitung: Pressen, Extraktion, Erntescheren, Trockennetze & Erntemaschinen für effiziente, schonende Blüten- und Kräuterverarbeitung."
"376","https://growheads.de/llms-grow-sets-list.txt","Entdecken Sie hochwertige Grow-Sets für Indoor-Growing: Komplettsets mit LED-Beleuchtung, Growbox, Abluftsystem & Zubehör für Anfänger und Profis."
"709","https://growheads.de/llms-headshop-list.txt","Headshop mit Bongs, Vaporizern, Pfeifen, DabbingZubehör, Papes, Grinder, Filtern, Waagen, Rolling Trays & Räucherstäbchen alles für dein RauchSetup."
"219","https://growheads.de/llms-toepfe-list.txt","Töpfe für Indoor- und Outdoorgrowing: Stofftöpfe, Air-Pots, Kunststofftöpfe & Untersetzer für optimales Wurzelwachstum und professionelle Pflanzenzucht."
"695","https://growheads.de/llms-zubehoer-4-list.txt","Zubehör für Growbox & Indoor-Grow: Zeltzubehör, Anbauzubehör, Lampenzubehör, Messgeräte, Ernte & Verarbeitung sowie Dünger-Zubehör online kaufen."

368
scripts/check-i18n-keys.mjs Normal file
View File

@@ -0,0 +1,368 @@
#!/usr/bin/env node
/**
* Reports which keys from src/i18n/locales/de are referenced in application code.
*
* - Loads the same namespaces as src/i18n/index.js (translation bundle + legal-* bundles).
* - Parses static t("...") / t('...') calls and maps useTranslation() aliases to namespaces.
* - Legal text is duplicated: separate namespaces (e.g. legal-agb-delivery) AND embedded under
* translation (legalAgbDelivery.*). When a separate-namespace key is used, the embedded
* translation::* copy is treated as used too.
* - Known dynamic patterns from AGB.js, Datenschutz.js, Impressum.js are expanded so those
* keys are not falsely listed as unused.
*
* Usage: node scripts/check-i18n-keys.mjs
* node scripts/check-i18n-keys.mjs --json
*/
import { readFile, readdir } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, '..');
const SRC = path.join(ROOT, 'src');
const LOCALE_DE = path.join(SRC, 'i18n', 'locales', 'de');
const SEPARATE_NAMESPACES = [
'legal-agb-delivery',
'legal-agb-payment',
'legal-agb-consumer',
'legal-datenschutz-basic',
'legal-datenschutz-customer',
'legal-datenschutz-google-orders',
'legal-datenschutz-newsletter',
'legal-datenschutz-chatbot',
'legal-datenschutz-push',
'legal-datenschutz-cookies-payment',
'legal-datenschutz-rights',
'legal-impressum',
'legal-widerruf',
'legal-batterie',
];
/** Separate i18n namespace -> key prefix inside translation bundle (locales/de/index.js). */
const NS_TO_EMBEDDED_PREFIX = {
'legal-agb-delivery': 'legalAgbDelivery',
'legal-agb-payment': 'legalAgbPayment',
'legal-agb-consumer': 'legalAgbConsumer',
'legal-datenschutz-basic': 'legalDatenschutzBasic',
'legal-datenschutz-customer': 'legalDatenschutzCustomer',
'legal-datenschutz-google-orders': 'legalDatenschutzGoogleOrders',
'legal-datenschutz-newsletter': 'legalDatenschutzNewsletter',
'legal-datenschutz-chatbot': 'legalDatenschutzChatbot',
'legal-datenschutz-cookies-payment': 'legalDatenschutzCookiesPayment',
'legal-datenschutz-rights': 'legalDatenschutzRights',
};
/**
* Keys reached only via t(`…${var}…`) in a few pages — expand so they count as used.
*/
function addKnownDynamicLegalKeys(used) {
for (let n = 1; n <= 14; n++) {
used.add(keySet('legal-agb-delivery', `deliveryTerms.${n}`));
}
for (const n of [1, 2, 3, 5, 6, 7, 8]) {
used.add(keySet('legal-agb-consumer', `distanceSelling.sections.${n}.title`));
used.add(keySet('legal-agb-consumer', `distanceSelling.sections.${n}.content`));
}
for (const section of ['informationDeletion', 'serverLogfiles']) {
used.add(keySet('legal-datenschutz-basic', `sections.${section}.title`));
used.add(keySet('legal-datenschutz-basic', `sections.${section}.content`));
}
for (const section of ['operator', 'contact', 'vatId', 'disclaimer', 'copyright']) {
used.add(keySet('legal-impressum', `sections.${section}.title`));
used.add(keySet('legal-impressum', `sections.${section}.content`));
}
}
/** If legal-agb-delivery::foo is used, translation::legalAgbDelivery.foo is the same strings. */
function propagateEmbeddedCopies(used) {
const additions = [];
for (const entry of used) {
const sep = entry.indexOf('::');
if (sep === -1) continue;
const ns = entry.slice(0, sep);
const keyPath = entry.slice(sep + 2);
const prefix = NS_TO_EMBEDDED_PREFIX[ns];
if (!prefix) continue;
additions.push(keySet('translation', `${prefix}.${keyPath}`));
}
for (const a of additions) used.add(a);
}
function flattenLeaves(obj, prefix = '') {
const keys = [];
if (obj === null || obj === undefined) return keys;
if (typeof obj !== 'object' || Array.isArray(obj)) {
if (prefix) keys.push(prefix);
return keys;
}
const entries = Object.entries(obj);
if (entries.length === 0 && prefix) keys.push(prefix);
for (const [k, v] of entries) {
const next = prefix ? `${prefix}.${k}` : k;
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
keys.push(...flattenLeaves(v, next));
} else {
keys.push(next);
}
}
return keys;
}
function keySet(ns, keyPath) {
return `${ns}::${keyPath}`;
}
async function loadDefinedKeys() {
const translationMod = await import(
pathToFileUrl(path.join(LOCALE_DE, 'index.js'))
);
const translation = translationMod.default;
const defined = new Map();
defined.set('translation', new Set(flattenLeaves(translation)));
for (const ns of SEPARATE_NAMESPACES) {
const mod = await import(pathToFileUrl(path.join(LOCALE_DE, `${ns}.js`)));
defined.set(ns, new Set(flattenLeaves(mod.default)));
}
return defined;
}
function pathToFileUrl(p) {
const normalized = path.resolve(p);
return new URL(`file://${normalized}`).href;
}
async function collectSourceFiles(dir, out = []) {
const entries = await readdir(dir, { withFileTypes: true });
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
if (full.includes(`${path.sep}i18n${path.sep}locales`)) continue;
await collectSourceFiles(full, out);
} else if (/\.(jsx?|tsx?)$/.test(e.name)) {
out.push(full);
}
}
return out;
}
/**
* Per file: map translation function alias -> i18next namespace.
*/
function buildAliasMap(source) {
const map = new Map();
const named = /const\s*\{\s*t:\s*(\w+)\s*\}\s*=\s*useTranslation\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
let m;
while ((m = named.exec(source))) {
map.set(m[1], m[2]);
}
const tWithNs =
/const\s*\{\s*t\s*\}\s*=\s*useTranslation\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
while ((m = tWithNs.exec(source))) {
map.set('t', m[1]);
}
const tDefault = /const\s*\{\s*t\s*\}\s*=\s*useTranslation\s*\(\s*\)/g;
while (tDefault.exec(source)) {
map.set('t', 'translation');
}
// withTranslation HOC: t comes from props (any position in destructuring)
const tFromThisProps =
/const\s*\{[^}]*\bt(?:\s*:\s*(\w+))?\b[^}]*\}\s*=\s*this\.props/;
const mProps = tFromThisProps.exec(source);
if (mProps) {
map.set(mProps[1] || 't', 'translation');
}
const tFromProps =
/const\s*\{[^}]*\bt(?:\s*:\s*(\w+))?\b[^}]*\}\s*=\s*props\b/;
const mP = tFromProps.exec(source);
if (mP) {
map.set(mP[1] || 't', 'translation');
}
if (!map.has('t')) {
map.set('t', 'translation');
}
return map;
}
function escapeRe(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Extract static keys from t-like calls for known aliases.
*/
function extractUsedKeysFromSource(source, filePath) {
const aliasMap = buildAliasMap(source);
const used = new Set();
const dynamicHints = [];
let m;
const propsT =
/(?:this\.props\.t|props\.t)\(\s*['"]([^'"]+)['"]/g;
while ((m = propsT.exec(source))) {
used.add(keySet('translation', m[1]));
}
for (const [alias, ns] of aliasMap) {
const re = new RegExp(
`\\b${escapeRe(alias)}\\(\\s*['"]([^'"]+)['"]`,
'g'
);
while ((m = re.exec(source))) {
used.add(keySet(ns, m[1]));
}
}
const tplStatic = /\b(\w+)\(\s*`([^`${}]*)`\s*\)/g;
while ((m = tplStatic.exec(source))) {
const alias = m[1];
const key = m[2].trim();
if (!key || !aliasMap.has(alias)) continue;
if (/^[a-zA-Z][a-zA-Z0-9_.]*$/.test(key)) {
used.add(keySet(aliasMap.get(alias), key));
}
}
if (
/(?:this\.props\.t|props\.t|\bt\w*)\(\s*`[^`]*\$\{/.test(source)
) {
dynamicHints.push(filePath);
}
return { used, dynamicHints };
}
function mergeSets(into, from) {
for (const x of from) into.add(x);
}
async function main() {
const jsonOut = process.argv.includes('--json');
const defined = await loadDefinedKeys();
const files = await collectSourceFiles(SRC);
const allUsed = new Set();
const dynamicFiles = new Set();
for (const file of files) {
const source = await readFile(file, 'utf8');
const { used, dynamicHints } = extractUsedKeysFromSource(source, file);
mergeSets(allUsed, used);
if (dynamicHints.length) dynamicFiles.add(file);
}
addKnownDynamicLegalKeys(allUsed);
propagateEmbeddedCopies(allUsed);
const unusedByNs = new Map();
let totalDefined = 0;
let totalUnused = 0;
for (const [ns, keys] of defined) {
const unused = [];
for (const k of keys) {
totalDefined++;
const full = keySet(ns, k);
if (!allUsed.has(full)) {
unused.push(k);
totalUnused++;
}
}
if (unused.length) unusedByNs.set(ns, unused.sort());
}
const orphans = new Set();
for (const u of allUsed) {
const [ns, keyPath] = u.split('::');
const set = defined.get(ns);
if (!set || !set.has(keyPath)) {
orphans.add(u);
}
}
if (jsonOut) {
console.log(
JSON.stringify(
{
totalDefined,
totalUsed: allUsed.size,
totalUnused,
unusedByNamespace: Object.fromEntries(unusedByNs),
usedButNotDefined: [...orphans].sort(),
filesWithLikelyDynamicT: [...dynamicFiles].map((f) =>
path.relative(ROOT, f)
),
},
null,
2
)
);
return;
}
console.log('i18n key usage (locale: de)\n');
console.log(
'Legal strings exist twice: separate namespaces (AGB, Datenschutz, …) and embedded copies'
);
console.log(
'under translation (legalAgbDelivery.*, …). The latter are marked used when the former are.\n'
);
console.log(`Defined keys (all namespaces): ${totalDefined}`);
console.log(`References after static scan + known dynamic legal patterns: ${allUsed.size}`);
console.log(`Unused (best-effort): ${totalUnused}`);
console.log(`Used but not in locale files: ${orphans.size}\n`);
const stillDynamic = [...dynamicFiles].filter(
(f) =>
![
'pages/AGB.js',
'pages/Datenschutz.js',
'pages/Impressum.js',
].includes(path.relative(SRC, f).replace(/\\/g, '/'))
);
if (stillDynamic.length) {
console.log(
'Other files with t(`…${…}…`) (may still hide unused keys — not expanded):'
);
for (const f of stillDynamic.sort()) {
console.log(` ${path.relative(ROOT, f)}`);
}
console.log('');
}
for (const [ns, keys] of [...unusedByNs.entries()].sort((a, b) =>
a[0].localeCompare(b[0])
)) {
console.log(`--- ${ns} (${keys.length} unused) ---`);
for (const k of keys) {
console.log(` ${k}`);
}
console.log('');
}
if (orphans.size) {
console.log('--- Referenced in code but missing from de locale ---');
for (const o of [...orphans].sort()) {
console.log(` ${o.replace('::', ' / ')}`);
}
}
console.log(
'\nNote: Keys built from arbitrary variables or unknown dynamic templates may still look unused.'
);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,59 @@
const sharp = require('sharp');
const path = require('path');
const fs = require('fs');
const imagesToConvert = [
{ src: 'sh.png', dest: 'sh.avif' },
{ src: 'seeds.jpg', dest: 'seeds.avif' },
{ src: 'cutlings.jpg', dest: 'cutlings.avif' },
{ src: 'gg.png', dest: 'gg.avif' },
{ src: 'konfigurator.png', dest: 'konfigurator.avif' },
{ src: 'maps.png', dest: 'maps.avif' }
];
const run = async () => {
const imagesDir = path.join(__dirname, '../public/assets/images');
let hasError = false;
for (const image of imagesToConvert) {
const inputPath = path.join(imagesDir, image.src);
const outputPath = path.join(imagesDir, image.dest);
if (!fs.existsSync(inputPath)) {
console.warn(`⚠️ Input file not found: ${inputPath}`);
continue;
}
// Check if output file exists and compare modification times
// Only convert if source is newer or destination doesn't exist
let shouldConvert = true;
if (fs.existsSync(outputPath)) {
const inputStat = fs.statSync(inputPath);
const outputStat = fs.statSync(outputPath);
if (inputStat.mtime <= outputStat.mtime) {
shouldConvert = false;
}
}
if (shouldConvert) {
try {
await sharp(inputPath)
.toFormat('avif')
.toFile(outputPath);
console.log(`✅ Converted ${image.src} to ${image.dest}`);
} catch (error) {
console.error(`❌ Error converting ${image.src}:`, error.message);
hasError = true;
}
} else {
// Silent skip if already up to date to keep logs clean, or use verbose flag
// console.log(`Skipping ${image.src} (already up to date)`);
}
}
if (hasError) {
process.exit(1);
}
};
run();

View File

@@ -0,0 +1,26 @@
const sharp = require('sharp');
const path = require('path');
const fs = require('fs');
const run = async () => {
const inputPath = path.join(__dirname, '../public/assets/images/sh.png');
const outputPath = path.join(__dirname, '../public/assets/images/sh.avif');
if (!fs.existsSync(inputPath)) {
console.error('Input file not found:', inputPath);
process.exit(1);
}
try {
await sharp(inputPath)
.toFormat('avif')
.toFile(outputPath);
console.log(`Successfully converted ${inputPath} to ${outputPath}`);
} catch (error) {
console.error('Error converting image:', error);
process.exit(1);
}
};
run();

View File

@@ -0,0 +1,344 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const { DOMParser } = require('xmldom');
/**
* Validates products.xml against Google Shopping RSS 2.0 requirements
*/
class ProductsXmlValidator {
constructor(xmlFilePath) {
this.xmlFilePath = xmlFilePath;
this.errors = [];
this.warnings = [];
this.stats = {
totalItems: 0,
validItems: 0,
invalidItems: 0
};
}
addError(message, itemId = null) {
this.errors.push({ message, itemId, type: 'error' });
}
addWarning(message, itemId = null) {
this.warnings.push({ message, itemId, type: 'warning' });
}
validateXmlStructure(xmlContent) {
try {
const parser = new DOMParser({
errorHandler: {
warning: (msg) => this.addWarning(`XML Warning: ${msg}`),
error: (msg) => this.addError(`XML Error: ${msg}`),
fatalError: (msg) => this.addError(`XML Fatal Error: ${msg}`)
}
});
const doc = parser.parseFromString(xmlContent, 'text/xml');
// Check for parsing errors
const parserErrors = doc.getElementsByTagName('parsererror');
if (parserErrors.length > 0) {
this.addError('XML parsing failed - invalid XML structure');
return null;
}
return doc;
} catch (error) {
this.addError(`Failed to parse XML: ${error.message}`);
return null;
}
}
validateRootStructure(doc) {
// Check RSS root element
const rssElement = doc.getElementsByTagName('rss')[0];
if (!rssElement) {
this.addError('Missing required <rss> root element');
return false;
}
// Check RSS version
const version = rssElement.getAttribute('version');
if (version !== '2.0') {
this.addError(`Invalid RSS version: expected "2.0", got "${version}"`);
}
// Check Google namespace
const googleNamespace = rssElement.getAttribute('xmlns:g');
if (googleNamespace !== 'http://base.google.com/ns/1.0') {
this.addError(`Missing or invalid Google namespace: expected "http://base.google.com/ns/1.0", got "${googleNamespace}"`);
}
// Check channel element
const channelElement = doc.getElementsByTagName('channel')[0];
if (!channelElement) {
this.addError('Missing required <channel> element');
return false;
}
return true;
}
validateChannelInfo(doc) {
const channel = doc.getElementsByTagName('channel')[0];
const requiredChannelElements = ['title', 'link', 'description'];
requiredChannelElements.forEach(elementName => {
const element = channel.getElementsByTagName(elementName)[0];
if (!element || !element.textContent.trim()) {
this.addError(`Missing or empty required channel element: <${elementName}>`);
}
});
// Check language
const language = channel.getElementsByTagName('language')[0];
if (!language || !language.textContent.trim()) {
this.addWarning('Missing <language> element in channel');
} else if (!language.textContent.match(/^[a-z]{2}(-[A-Z]{2})?$/)) {
this.addWarning(`Invalid language format: ${language.textContent} (should be like "de-DE")`);
}
}
validateItem(item, index) {
const itemId = this.getItemId(item, index);
this.stats.totalItems++;
// Required Google Shopping attributes
const requiredAttributes = [
'g:id',
'g:title',
'g:description',
'g:link',
'g:image_link',
'g:condition',
'g:availability',
'g:price'
];
let hasErrors = false;
requiredAttributes.forEach(attr => {
const element = item.getElementsByTagName(attr)[0];
if (!element || !element.textContent.trim()) {
this.addError(`Missing required attribute: <${attr}>`, itemId);
hasErrors = true;
}
});
// Validate specific attribute formats
this.validatePrice(item, itemId);
this.validateCondition(item, itemId);
this.validateAvailability(item, itemId);
this.validateUrls(item, itemId);
this.validateGtin(item, itemId);
this.validateShippingWeight(item, itemId);
if (hasErrors) {
this.stats.invalidItems++;
} else {
this.stats.validItems++;
}
}
getItemId(item, index) {
const idElement = item.getElementsByTagName('g:id')[0];
return idElement ? idElement.textContent.trim() : `item-${index + 1}`;
}
validatePrice(item, itemId) {
const priceElement = item.getElementsByTagName('g:price')[0];
if (priceElement) {
const priceText = priceElement.textContent.trim();
// Price should be in format "XX.XX EUR" or similar
if (!priceText.match(/^\d+(\.\d{2})?\s+[A-Z]{3}$/)) {
this.addError(`Invalid price format: "${priceText}" (should be "XX.XX EUR")`, itemId);
}
}
}
validateCondition(item, itemId) {
const conditionElement = item.getElementsByTagName('g:condition')[0];
if (conditionElement) {
const condition = conditionElement.textContent.trim();
const validConditions = ['new', 'refurbished', 'used'];
if (!validConditions.includes(condition)) {
this.addError(`Invalid condition: "${condition}" (must be: ${validConditions.join(', ')})`, itemId);
}
}
}
validateAvailability(item, itemId) {
const availabilityElement = item.getElementsByTagName('g:availability')[0];
if (availabilityElement) {
const availability = availabilityElement.textContent.trim();
const validAvailability = ['in stock', 'out of stock', 'preorder', 'backorder'];
if (!validAvailability.includes(availability)) {
this.addError(`Invalid availability: "${availability}" (must be: ${validAvailability.join(', ')})`, itemId);
}
}
}
validateUrls(item, itemId) {
const urlElements = ['g:link', 'g:image_link'];
urlElements.forEach(elementName => {
const element = item.getElementsByTagName(elementName)[0];
if (element) {
const url = element.textContent.trim();
try {
new URL(url);
if (!url.startsWith('https://')) {
this.addWarning(`URL should use HTTPS: ${url}`, itemId);
}
} catch (error) {
this.addError(`Invalid URL in <${elementName}>: ${url}`, itemId);
}
}
});
}
validateGtin(item, itemId) {
const gtinElement = item.getElementsByTagName('g:gtin')[0];
if (gtinElement) {
const gtin = gtinElement.textContent.trim();
// GTIN should be 8, 12, 13, or 14 digits
if (!gtin.match(/^\d{8}$|^\d{12,14}$/)) {
this.addError(`Invalid GTIN format: "${gtin}" (should be 8, 12, 13, or 14 digits)`, itemId);
}
} else {
this.addWarning(`Missing GTIN - recommended for better product matching`, itemId);
}
}
validateShippingWeight(item, itemId) {
const weightElement = item.getElementsByTagName('g:shipping_weight')[0];
if (weightElement) {
const weight = weightElement.textContent.trim();
// Weight should be in format "XX.XX g" or similar
if (!weight.match(/^\d+(\.\d+)?\s+[a-zA-Z]+$/)) {
this.addError(`Invalid shipping weight format: "${weight}" (should be "XX.XX g")`, itemId);
}
} else {
this.addWarning(`Missing shipping weight`, itemId);
}
}
validateGoogleProductCategory(item, itemId) {
const categoryElement = item.getElementsByTagName('g:google_product_category')[0];
if (categoryElement) {
const category = categoryElement.textContent.trim();
// Should be a numeric category ID
if (!category.match(/^\d+$/)) {
this.addError(`Invalid Google product category: "${category}" (should be numeric)`, itemId);
}
}
}
async validate() {
console.log(`🔍 Validating products.xml: ${this.xmlFilePath}`);
// Check if file exists
if (!fs.existsSync(this.xmlFilePath)) {
this.addError(`File not found: ${this.xmlFilePath}`);
return this.getResults();
}
// Read and parse XML
const xmlContent = fs.readFileSync(this.xmlFilePath, 'utf8');
const doc = this.validateXmlStructure(xmlContent);
if (!doc) {
return this.getResults();
}
// Validate root structure
if (!this.validateRootStructure(doc)) {
return this.getResults();
}
// Validate channel information
this.validateChannelInfo(doc);
// Validate all items
const items = doc.getElementsByTagName('item');
console.log(`📦 Found ${items.length} product items to validate`);
for (let i = 0; i < items.length; i++) {
this.validateItem(items[i], i);
}
return this.getResults();
}
getResults() {
const hasErrors = this.errors.length > 0;
const hasWarnings = this.warnings.length > 0;
return {
valid: !hasErrors,
stats: this.stats,
errors: this.errors,
warnings: this.warnings,
summary: {
totalIssues: this.errors.length + this.warnings.length,
errorCount: this.errors.length,
warningCount: this.warnings.length,
validationPassed: !hasErrors
}
};
}
printResults(results) {
console.log('\n📊 Validation Results:');
console.log(` - Total items: ${results.stats.totalItems}`);
console.log(` - Valid items: ${results.stats.validItems}`);
console.log(` - Invalid items: ${results.stats.invalidItems}`);
if (results.errors.length > 0) {
console.log(`\n❌ Errors (${results.errors.length}):`);
results.errors.forEach((error, index) => {
const itemInfo = error.itemId ? ` [${error.itemId}]` : '';
console.log(` ${index + 1}. ${error.message}${itemInfo}`);
});
}
if (results.warnings.length > 0) {
console.log(`\n⚠️ Warnings (${results.warnings.length}):`);
results.warnings.slice(0, 10).forEach((warning, index) => {
const itemInfo = warning.itemId ? ` [${warning.itemId}]` : '';
console.log(` ${index + 1}. ${warning.message}${itemInfo}`);
});
if (results.warnings.length > 10) {
console.log(` ... and ${results.warnings.length - 10} more warnings`);
}
}
if (results.valid) {
console.log('\n✅ Validation passed! products.xml is valid for Google Shopping.');
} else {
console.log('\n❌ Validation failed! Please fix the errors above.');
}
return results.valid;
}
}
// CLI usage
if (require.main === module) {
const xmlFilePath = process.argv[2] || path.join(__dirname, '../dist/products.xml');
const validator = new ProductsXmlValidator(xmlFilePath);
validator.validate().then(results => {
const isValid = validator.printResults(results);
process.exit(isValid ? 0 : 1);
}).catch(error => {
console.error('❌ Validation failed:', error.message);
process.exit(1);
});
}
module.exports = ProductsXmlValidator;

View File

@@ -1,10 +1,11 @@
import React, { useState, useEffect, useRef, useContext, lazy, Suspense } from "react";
import React, { useState, useEffect, lazy, Suspense } from "react";
import { createTheme } from "@mui/material/styles";
import {
Routes,
Route,
Navigate,
useLocation,
useNavigate,
useNavigate
} from "react-router-dom";
import { ThemeProvider } from "@mui/material/styles";
import CssBaseline from "@mui/material/CssBaseline";
@@ -14,23 +15,33 @@ import Fab from "@mui/material/Fab";
import Tooltip from "@mui/material/Tooltip";
import SmartToyIcon from "@mui/icons-material/SmartToy";
import PaletteIcon from "@mui/icons-material/Palette";
import BugReportIcon from "@mui/icons-material/BugReport";
import ScienceIcon from "@mui/icons-material/Science";
import SocketProvider from "./providers/SocketProvider.js";
import SocketContext from "./contexts/SocketContext.js";
import { CarouselProvider } from "./contexts/CarouselContext.js";
import { ProductContextProvider } from "./context/ProductContext.js";
import { CategoryContextProvider } from "./context/CategoryContext.js";
import TitleUpdater from "./components/TitleUpdater.js";
import config from "./config.js";
import ScrollToTop from "./components/ScrollToTop.js";
// Import i18n
import './i18n/index.js';
import { LanguageProvider } from './i18n/withTranslation.js';
import i18n from './i18n/index.js';
//import TelemetryService from './services/telemetryService.js';
import Header from "./components/Header.js";
import Footer from "./components/Footer.js";
import Home from "./pages/Home.js";
import MainPageLayout from "./components/MainPageLayout.js";
import IdleMainPagesSlideshow from "./components/IdleMainPagesSlideshow.js";
// Lazy load all route components to reduce initial bundle size
const Content = lazy(() => import(/* webpackChunkName: "content" */ "./components/Content.js"));
const ProductDetailWithSocket = lazy(() => import(/* webpackChunkName: "product-detail" */ "./components/ProductDetailWithSocket.js"));
const ProfilePageWithSocket = lazy(() => import(/* webpackChunkName: "profile" */ "./pages/ProfilePage.js"));
import Content from "./components/Content.js";
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"));
@@ -40,8 +51,9 @@ const ServerLogsPage = lazy(() => import(/* webpackChunkName: "admin-logs" */ ".
// Lazy load legal pages - rarely accessed
const Datenschutz = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Datenschutz.js"));
const AGB = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/AGB.js"));
const NotFound404 = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/NotFound404.js"));
//const NotFound404 = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/NotFound404.js")); <Route path="/404" element={<NotFound404 />} />
const Sitemap = lazy(() => import(/* webpackChunkName: "sitemap" */ "./pages/Sitemap.js"));
const CategoriesPage = lazy(() => import(/* webpackChunkName: "categories" */ "./pages/CategoriesPage.js"));
const Impressum = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Impressum.js"));
const Batteriegesetzhinweise = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Batteriegesetzhinweise.js"));
const Widerrufsrecht = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Widerrufsrecht.js"));
@@ -50,55 +62,54 @@ const Widerrufsrecht = lazy(() => import(/* webpackChunkName: "legal" */ "./page
const GrowTentKonfigurator = lazy(() => import(/* webpackChunkName: "konfigurator" */ "./pages/GrowTentKonfigurator.js"));
const ChatAssistant = lazy(() => import(/* webpackChunkName: "chat" */ "./components/ChatAssistant.js"));
// Lazy load separate pages that are truly different
const PresseverleihPage = lazy(() => import(/* webpackChunkName: "presseverleih" */ "./pages/PresseverleihPage.js"));
const ThcTestPage = lazy(() => import(/* webpackChunkName: "thc-test" */ "./pages/ThcTestPage.js"));
// Lazy load payment success page
const PaymentSuccess = lazy(() => import(/* webpackChunkName: "payment" */ "./components/PaymentSuccess.js"));
// Lazy load prerender component (development testing only)
const PrerenderHome = lazy(() => import(/* webpackChunkName: "prerender-home" */ "./PrerenderHome.js"));
// Import theme from separate file to reduce main bundle size
import defaultTheme from "./theme.js";
// Lazy load theme customizer for development only
const ThemeCustomizerDialog = lazy(() => import(/* webpackChunkName: "theme-customizer" */ "./components/ThemeCustomizerDialog.js"));
import { createTheme } from "@mui/material/styles";
const deleteMessages = () => {
console.log("Deleting messages");
window.chatMessages = [];
};
// Component to initialize telemetry service with socket
const TelemetryInitializer = ({ socket }) => {
const telemetryServiceRef = useRef(null);
useEffect(() => {
if (socket && !telemetryServiceRef.current) {
//telemetryServiceRef.current = new TelemetryService(socket);
//telemetryServiceRef.current.init();
}
return () => {
if (telemetryServiceRef.current) {
telemetryServiceRef.current.destroy();
telemetryServiceRef.current = null;
}
};
}, [socket]);
return null; // This component doesn't render anything
};
const AppContent = ({ currentTheme, onThemeChange }) => {
const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
// State to manage chat visibility
const [isChatOpen, setChatOpen] = useState(false);
const [authVersion, setAuthVersion] = useState(0);
// @note Theme customizer state for development mode
const [isThemeCustomizerOpen, setThemeCustomizerOpen] = useState(false);
// State to track active category for article pages
const [articleCategoryId, setArticleCategoryId] = useState(null);
// Remove duplicate theme state since it's passed as prop
// const [dynamicTheme, setDynamicTheme] = useState(createTheme(defaultTheme));
// Get current location
const location = useLocation();
const navigate = useNavigate();
useEffect(() => {
if (location.hash && location.hash.startsWith("#ORD-")) {
if (location.hash && location.hash.length > 1) {
// Check if it's a potential order ID (starts with # and has alphanumeric characters with dashes)
const potentialOrderId = location.hash.substring(1);
if (/^[A-Z0-9]+-[A-Z0-9]+$/i.test(potentialOrderId)) {
if (location.pathname !== "/profile") {
navigate(`/profile${location.hash}`, { replace: true });
}
}
}
}, [location, navigate]);
useEffect(() => {
@@ -111,10 +122,44 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
};
}, []);
// Extract categoryId from pathname if on category route
// Clear article category when navigating away from article pages
useEffect(() => {
const isArticlePage = location.pathname.startsWith('/Artikel/');
const isCategoryPage = location.pathname.startsWith('/Kategorie/');
const isHomePage = location.pathname === '/';
// Only clear article category when navigating to non-article pages
// (but keep it when going from category to article)
if (!isArticlePage && !isCategoryPage && !isHomePage) {
setArticleCategoryId(null);
}
}, [location.pathname]);
// Read article category from navigation state (when coming from product click)
useEffect(() => {
if (location.state && location.state.articleCategoryId !== undefined) {
if (location.state.articleCategoryId !== null) {
setArticleCategoryId(location.state.articleCategoryId);
}
// Clear the state so it doesn't persist on page refresh
navigate(location.pathname, { replace: true, state: {} });
}
}, [location.state, navigate, location.pathname]);
// Extract categoryId from pathname if on category route, or use article category
const getCategoryId = () => {
const match = location.pathname.match(/^\/Kategorie\/(.+)$/);
return match ? match[1] : null;
if (match) {
return match[1];
}
// For article pages, use the article category if available
const isArticlePage = location.pathname.startsWith('/Artikel/');
if (isArticlePage && articleCategoryId) {
return articleCategoryId;
}
return null;
};
const categoryId = getCategoryId();
@@ -139,35 +184,40 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
setThemeCustomizerOpen(!isThemeCustomizerOpen);
};
// Handler to open GitHub issue reporting
const handleReportIssue = () => {
const issueTitle = encodeURIComponent("Fehlerbericht");
const issueBody = encodeURIComponent(
`**Seite:** ${window.location.href}
**Browser:** ${navigator.userAgent.split(' ')[0]}
**Datum:** ${new Date().toLocaleDateString('de-DE')}
**Problem:**
[Beschreibe kurz das Problem]
**So ist es passiert:**
1.
2.
**Was sollte passieren:**
[Was erwartet wurde]`
);
const githubIssueUrl = `https://github.com/Growheads-de/shopFrontEnd/issues/new?title=${issueTitle}&body=${issueBody}`;
window.open(githubIssueUrl, '_blank');
};
// Check if we're in development mode
const isDevelopment = process.env.NODE_ENV === "development";
const {socket,socketB} = useContext(SocketContext);
console.log("AppContent: socket", socket);
// Check if current route is a prerender test route
const isPrerenderTestRoute = isDevelopment && location.pathname === "/prerenderTest/home";
// If it's a prerender test route, render it standalone without app layout
if (isPrerenderTestRoute) {
return (
<LanguageProvider i18n={i18n}>
<ThemeProvider theme={dynamicTheme}>
<CssBaseline />
<Suspense fallback={
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "100vh",
}}
>
<CircularProgress color="primary" />
</Box>
}>
<PrerenderHome />
</Suspense>
</ThemeProvider>
</LanguageProvider>
);
}
// Regular app layout for all other routes
return (
<Box
sx={{
@@ -179,11 +229,19 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
bgcolor: "background.default",
}}
>
<TitleUpdater />
<ScrollToTop />
<TelemetryInitializer socket={socket} />
<Header active categoryId={categoryId} key={authVersion} />
<Box sx={{ flexGrow: 1 }}>
<Box component="main" sx={{ flexGrow: 1 }}>
<Suspense fallback={
// Use prerender fallback if available, otherwise show loading spinner
typeof window !== "undefined" && window.__PRERENDER_FALLBACK__ ? (
<div
dangerouslySetInnerHTML={{
__html: window.__PRERENDER_FALLBACK__.content,
}}
/>
) : (
<Box
sx={{
display: "flex",
@@ -194,48 +252,59 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
>
<CircularProgress color="primary" />
</Box>
)
}>
<CarouselProvider>
<IdleMainPagesSlideshow />
<Routes>
{/* Home page with text only */}
<Route path="/" element={<Home />} />
{/* Main pages using unified component */}
<Route path="/" element={<MainPageLayout />} />
<Route path="/aktionen" element={<MainPageLayout />} />
<Route path="/filiale" element={<MainPageLayout />} />
{/* Category page - Render Content in parallel */}
<Route
path="/Kategorie/:categoryId"
element={<Content socket={socket} socketB={socketB} />}
element={<Content />}
/>
{/* Single product page */}
<Route
path="/Artikel/:seoName"
element={<ProductDetailWithSocket />}
element={<ProductDetail />}
/>
{/* Search page - Render Content in parallel */}
<Route path="/search" element={<Content socket={socket} socketB={socketB} />} />
<Route path="/search" element={<Content />} />
{/* Profile page */}
<Route path="/profile" element={<ProfilePageWithSocket />} />
<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 />} />
{/* Reset password page */}
<Route
path="/resetPassword"
element={<ResetPassword socket={socket} socketB={socketB} />}
element={<ResetPassword />}
/>
{/* Admin page */}
<Route path="/admin" element={<AdminPage socket={socket} socketB={socketB} />} />
<Route path="/admin" element={<AdminPage />} />
{/* Admin Users page */}
<Route path="/admin/users" element={<UsersPage socket={socket} socketB={socketB} />} />
<Route path="/admin/users" element={<UsersPage />} />
{/* Admin Server Logs page */}
<Route path="/admin/logs" element={<ServerLogsPage socket={socket} socketB={socketB} />} />
<Route path="/admin/logs" element={<ServerLogsPage />} />
{/* Legal pages */}
<Route path="/datenschutz" element={<Datenschutz />} />
<Route path="/agb" element={<AGB />} />
<Route path="/404" element={<NotFound404 />} />
<Route path="/sitemap" element={<Sitemap />} />
<Route path="/Kategorien" element={<CategoriesPage />} />
<Route path="/impressum" element={<Impressum />} />
<Route
path="/batteriegesetzhinweise"
@@ -246,18 +315,32 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
{/* Grow Tent Configurator */}
<Route path="/Konfigurator" element={<GrowTentKonfigurator />} />
{/* Separate pages that are truly different */}
<Route path="/presseverleih" element={<PresseverleihPage />} />
<Route path="/thc-test" element={<ThcTestPage />} />
{/* Fallback for undefined routes */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</CarouselProvider>
</Suspense>
</Box>
{/* Conditionally render the Chat Assistant */}
{isChatOpen && (
<Suspense fallback={<CircularProgress size={20} />}>
<Suspense fallback={
typeof window !== "undefined" && window.__PRERENDER_FALLBACK__ ? (
<div
dangerouslySetInnerHTML={{
__html: window.__PRERENDER_FALLBACK__.content,
}}
/>
) : (
<CircularProgress size={20} />
)
}>
<ChatAssistant
open={isChatOpen}
onClose={handleChatClose}
socket={socket}
/>
</Suspense>
)}
@@ -279,7 +362,7 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
</Fab>
</Tooltip>
{/* GitHub Issue Reporter FAB */}
{/* GitHub Issue Reporter FAB
<Tooltip title="Fehler oder Problem melden" placement="left">
<Fab
color="error"
@@ -294,7 +377,7 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
>
<BugReportIcon sx={{ fontSize: "1.2rem" }} />
</Fab>
</Tooltip>
</Tooltip>*/}
{/* Development-only Theme Customizer FAB */}
{isDevelopment && (
@@ -315,9 +398,38 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
</Tooltip>
)}
{/* Development-only Prerender Test FAB */}
{isDevelopment && (
<Tooltip title="Test Prerender Home" placement="left">
<Fab
color="warning"
aria-label="prerender test"
size="small"
sx={{
position: "fixed",
bottom: 31,
right: 75,
}}
onClick={() => navigate('/prerenderTest/home')}
>
<ScienceIcon sx={{ fontSize: "1.2rem" }} />
</Fab>
</Tooltip>
)}
{/* Development-only Theme Customizer Dialog */}
{isDevelopment && isThemeCustomizerOpen && (
<Suspense fallback={<CircularProgress size={20} />}>
<Suspense fallback={
typeof window !== "undefined" && window.__PRERENDER_FALLBACK__ ? (
<div
dangerouslySetInnerHTML={{
__html: window.__PRERENDER_FALLBACK__.content,
}}
/>
) : (
<CircularProgress size={20} />
)
}>
<ThemeCustomizerDialog
open={isThemeCustomizerOpen}
onClose={() => setThemeCustomizerOpen(false)}
@@ -343,30 +455,26 @@ const App = () => {
setDynamicTheme(createTheme(newTheme));
};
// Make config globally available for language switching
useEffect(() => {
window.shopConfig = config;
}, []);
return (
<LanguageProvider i18n={i18n}>
<ThemeProvider theme={dynamicTheme}>
<ProductContextProvider>
<CategoryContextProvider>
<CssBaseline />
<SocketProvider
url={config.apiBaseUrl}
fallback={
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100vh",
}}
>
<CircularProgress color="primary" />
</Box>
}
>
<AppContent
currentTheme={currentTheme}
dynamicTheme={dynamicTheme}
onThemeChange={handleThemeChange}
/>
</SocketProvider>
</CategoryContextProvider>
</ProductContextProvider>
</ThemeProvider>
</LanguageProvider>
);
};

View File

@@ -3,7 +3,8 @@ import { Box, AppBar, Toolbar, Container} from '@mui/material';
import { Routes, Route } from 'react-router-dom';
import Footer from './components/Footer.js';
import { Logo, CategoryList } from './components/header/index.js';
import Home from './pages/Home.js';
import MainPageLayout from './components/MainPageLayout.js';
import { CarouselProvider } from './contexts/CarouselContext.js';
const PrerenderAppContent = (socket) => (
<Box
@@ -43,10 +44,12 @@ const PrerenderAppContent = (socket) => (
<CategoryList categoryId={209} activeCategoryId={null} socket={socket}/>
</AppBar>
<Box sx={{ flexGrow: 1 }}>
<Box component="main" sx={{ flexGrow: 1 }}>
<CarouselProvider>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/" element={<MainPageLayout />} />
</Routes>
</CarouselProvider>
</Box>
<Footer/>

View File

@@ -0,0 +1,118 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Paper from '@mui/material/Paper';
import LegalPage from './pages/LegalPage.js';
import CategoryBox from './components/CategoryBox.js';
const PrerenderCategoriesPage = ({ categoryData }) => {
// Helper function to recursively collect all categories from the tree
const collectAllCategories = (categoryNode, categories = [], level = 0) => {
if (!categoryNode) return categories;
// Add current category (skip root category 209)
if (categoryNode.id !== 209 && categoryNode.seoName) {
categories.push({
id: categoryNode.id,
name: categoryNode.name,
seoName: categoryNode.seoName,
level: level
});
}
// Recursively add children
if (categoryNode.children) {
for (const child of categoryNode.children) {
collectAllCategories(child, categories, level + 1);
}
}
return categories;
};
// The categoryData passed prop is the root tree (id: 209)
const rootTree = categoryData;
const renderLevel1Section = (l1Node) => {
// Collect all descendants (excluding the L1 node itself, which collectAllCategories would include first)
const descendants = collectAllCategories(l1Node).slice(1);
return (
<Paper
key={l1Node.id}
elevation={1}
sx={{
p: 2,
mb: 3,
display: 'flex',
flexDirection: { xs: 'column', md: 'row' },
alignItems: { xs: 'flex-start', md: 'flex-start' },
gap: 3
}}
>
{/* Level 1 Header/Box */}
<Box sx={{
minWidth: '150px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1
}}>
<CategoryBox
id={l1Node.id}
name={l1Node.name}
seoName={l1Node.seoName}
sx={{
boxShadow: 4,
width: '150px',
height: '150px'
}}
/>
</Box>
{/* Descendants area */}
<Box sx={{ flex: 1 }}>
<Box sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 2
}}>
{descendants.map((cat) => (
<CategoryBox
key={cat.id}
id={cat.id}
name={cat.name}
seoName={cat.seoName}
sx={{
width: '100px',
height: '100px',
minWidth: '100px',
minHeight: '100px',
boxShadow: 1,
fontSize: '0.9rem'
}}
/>
))}
</Box>
</Box>
</Paper>
);
};
const content = (
<Box>
<Box>
{rootTree && rootTree.children && rootTree.children.map((child) => (
renderLevel1Section(child)
))}
{(!rootTree || !rootTree.children || rootTree.children.length === 0) && (
<Typography>Keine Kategorien gefunden.</Typography>
)}
</Box>
</Box>
);
return <LegalPage title="Kategorien" content={content} />;
};
export default PrerenderCategoriesPage;

View File

@@ -3,7 +3,7 @@ import { Box, AppBar, Toolbar, Container, Typography, Grid, Card, CardMedia, Car
import Footer from './components/Footer.js';
import { Logo, SearchBar, CategoryList } from './components/header/index.js';
const PrerenderCategory = ({ categoryId, categoryName, categorySeoName, productData }) => {
const PrerenderCategory = ({ categoryId, categoryName, categorySeoName: _categorySeoName, productData }) => {
const products = productData?.products || [];
return (
@@ -111,7 +111,7 @@ const PrerenderCategory = ({ categoryId, categoryName, categorySeoName, productD
component="img"
height="200"
image={product.pictureList && product.pictureList.trim()
? `/assets/images/prod${product.pictureList.split(',')[0].trim()}.jpg`
? `/assets/images/prod${product.pictureList.split(',')[0].trim()}.avif`
: '/assets/images/nopicture.jpg'
}
alt={product.name}

View File

@@ -1,13 +1,13 @@
const React = require('react');
const {
import React from 'react';
import {
Box,
AppBar,
Toolbar,
Container
} = require('@mui/material');
const Footer = require('./components/Footer.js').default;
const { Logo, CategoryList } = require('./components/header/index.js');
const Home = require('./pages/Home.js').default;
} from '@mui/material';
import Footer from './components/Footer.js';
import { Logo, CategoryList } from './components/header/index.js';
class PrerenderHome extends React.Component {
render() {
@@ -28,10 +28,14 @@ class PrerenderHome extends React.Component {
{ position: 'sticky', color: 'primary', elevation: 0, sx: { zIndex: 1100 } },
React.createElement(
Toolbar,
{ sx: { minHeight: 64 } },
{ sx: { minHeight: 64, py: { xs: 0.5, sm: 0 } } },
React.createElement(
Container,
{ maxWidth: 'lg', sx: { display: 'flex', alignItems: 'center' } },
{ maxWidth: 'lg', sx: {
display: 'flex',
alignItems: 'center',
px: { xs: 0, sm: 3 }
} },
React.createElement(
Box,
{
@@ -49,24 +53,78 @@ class PrerenderHome extends React.Component {
display: 'flex',
alignItems: 'center',
width: '100%',
justifyContent: { xs: 'space-between', sm: 'flex-start' }
justifyContent: { xs: 'space-between', sm: 'flex-start' },
minHeight: { xs: 52, sm: 'auto' },
px: { xs: 0, sm: 0 }
}
},
React.createElement(Logo)
React.createElement(Logo),
// Invisible SearchBar placeholder on desktop
React.createElement(
Box,
{
sx: {
display: { xs: 'none', sm: 'block' },
flexGrow: 1,
height: 41, // Reserve space for SearchBar
opacity: 0 // Invisible placeholder
}
}
),
// Invisible ButtonGroup placeholder
React.createElement(
Box,
{
sx: {
display: 'flex',
alignItems: { xs: 'flex-end', sm: 'center' },
transform: { xs: 'translateY(4px) translateX(9px)', sm: 'none' },
ml: { xs: 0, sm: 0 },
gap: { xs: 0.5, sm: 1 },
opacity: 0 // Invisible placeholder
}
},
// Placeholder for LanguageSwitcher (approx width)
React.createElement(
Box,
{ sx: { width: 40, height: 40 } }
),
// Placeholder for LoginComponent (approx width)
React.createElement(
Box,
{ sx: { width: 40, height: 40 } }
),
// Placeholder for Cart button (approx width)
React.createElement(
Box,
{ sx: { width: 48, height: 40, ml: 1 } }
)
)
),
// Invisible SearchBar placeholder on mobile
React.createElement(
Box,
{
sx: {
display: { xs: 'block', sm: 'none' },
width: '100%',
mt: { xs: 1, sm: 0 },
mb: { xs: 0.5, sm: 0 },
px: { xs: 0, sm: 0 },
height: 41, // Reserve space for SearchBar
opacity: 0 // Invisible placeholder
}
}
)
)
)
),
React.createElement(CategoryList, { categoryId: 209, activeCategoryId: null })
),
React.createElement(
Box,
{ sx: { flexGrow: 1 } },
React.createElement(Home)
),
React.createElement(Footer)
);
}
}
module.exports = { default: PrerenderHome };
export default PrerenderHome;

View File

@@ -66,6 +66,7 @@ class PrerenderKonfigurator extends Component {
15%
</Typography>
<Typography variant="body2">
{/* Note: This is a prerender file - translation key would be: product.discount.from3Products */}
ab 3 Produkten
</Typography>
</Box>
@@ -74,6 +75,7 @@ class PrerenderKonfigurator extends Component {
24%
</Typography>
<Typography variant="body2">
{/* Note: This is a prerender file - translation key would be: product.discount.from5Products */}
ab 5 Produkten
</Typography>
</Box>
@@ -82,11 +84,13 @@ class PrerenderKonfigurator extends Component {
36%
</Typography>
<Typography variant="body2">
{/* Note: This is a prerender file - translation key would be: product.discount.from7Products */}
ab 7 Produkten
</Typography>
</Box>
</Box>
<Typography variant="caption" sx={{ color: '#666', mt: 1, display: 'block' }}>
{/* Note: This is a prerender file - translation key would be: product.discount.moreProductsMoreSavings */}
Je mehr Produkte du auswählst, desto mehr sparst du!
</Typography>
</Paper>

92
src/PrerenderNotFound.js Normal file
View File

@@ -0,0 +1,92 @@
import React from 'react';
import {
Box,
AppBar,
Toolbar,
Container
} from '@mui/material';
import Footer from './components/Footer.js';
import { Logo } from './components/header/index.js';
import NotFound404 from './pages/NotFound404.js';
class PrerenderNotFound extends React.Component {
render() {
return React.createElement(
Box,
{
sx: {
display: 'flex',
flexDirection: 'column',
minHeight: '100vh',
mb: 0,
pb: 0,
bgcolor: 'background.default'
}
},
React.createElement(
AppBar,
{ position: 'sticky', color: 'primary', elevation: 0, sx: { zIndex: 1100 } },
React.createElement(
Toolbar,
{ sx: { minHeight: 64, py: { xs: 0.5, sm: 0 } } },
React.createElement(
Container,
{
maxWidth: 'lg',
sx: {
display: 'flex',
alignItems: 'center',
px: { xs: 0, sm: 3 }
}
},
React.createElement(
Box,
{ sx: {
display: 'flex',
alignItems: 'center',
width: '100%',
flexDirection: { xs: 'column', sm: 'row' }
}
},
React.createElement(
Box,
{ sx: {
display: 'flex',
alignItems: 'center',
width: '100%',
justifyContent: { xs: 'space-between', sm: 'flex-start' },
minHeight: { xs: 52, sm: 'auto' },
px: { xs: 0, sm: 0 }
}
},
React.createElement(Logo)
),
// Reserve space for SearchBar on mobile (invisible placeholder)
React.createElement(
Box,
{ sx: {
display: { xs: 'block', sm: 'none' },
width: '100%',
mt: { xs: 1, sm: 0 },
mb: { xs: 0.5, sm: 0 },
px: { xs: 0, sm: 0 },
height: 40, // Reserve space for SearchBar
opacity: 0 // Invisible placeholder
}
}
)
)
)
)
),
React.createElement(
Box,
{ sx: { flexGrow: 1 } },
React.createElement(NotFound404)
),
React.createElement(Footer)
);
}
}
export default PrerenderNotFound;

View File

@@ -1,18 +1,25 @@
const React = require('react');
const {
import React from 'react';
import {
Container,
Typography,
Card,
CardMedia,
Grid,
Box,
Chip,
Stack,
AppBar,
Toolbar
} = require('@mui/material');
const Footer = require('./components/Footer.js').default;
const { Logo } = require('./components/header/index.js');
Toolbar,
Button
} from '@mui/material';
import sanitizeHtml from 'sanitize-html';
import Footer from './components/Footer.js';
import { Logo } from './components/header/index.js';
import ProductImage from './components/ProductImage.js';
// Utility function to clean product names by removing trailing number in parentheses
const cleanProductName = (name) => {
if (!name) return "";
// Remove patterns like " (1)", " (3)", " (10)" at the end of the string
return name.replace(/\s*\(\d+\)\s*$/, "").trim();
};
class PrerenderProduct extends React.Component {
render() {
@@ -20,21 +27,29 @@ class PrerenderProduct extends React.Component {
if (!productData) {
return React.createElement(
Container,
{ maxWidth: 'lg', sx: { py: 4 } },
Box,
{ sx: { p: 4, textAlign: "center" } },
React.createElement(
Typography,
{ variant: 'h4', component: 'h1', gutterBottom: true },
'Product not found'
{ variant: 'h5', gutterBottom: true },
'Produkt nicht gefunden'
),
React.createElement(
Typography,
null,
'Das gesuchte Produkt existiert nicht oder wurde entfernt.'
)
);
}
const product = productData.product;
const attributes = productData.attributes || [];
const mainImage = product.pictureList && product.pictureList.trim()
? `/assets/images/prod${product.pictureList.split(',')[0].trim()}.jpg`
: '/assets/images/nopicture.jpg';
// Format price with tax
const priceWithTax = new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
}).format(product.price);
return React.createElement(
Box,
@@ -53,137 +68,496 @@ class PrerenderProduct extends React.Component {
{ position: 'sticky', color: 'primary', elevation: 0, sx: { zIndex: 1100 } },
React.createElement(
Toolbar,
{ sx: { minHeight: 64 } },
{ sx: { minHeight: 64, py: { xs: 0.5, sm: 0 } } },
React.createElement(
Container,
{ maxWidth: 'lg', sx: { display: 'flex', alignItems: 'center' } },
React.createElement(Logo)
{ maxWidth: 'lg', sx: { display: 'flex', alignItems: 'center', px: { xs: 0, sm: 3 } } },
// Desktop: simple layout, Mobile: column layout with SearchBar space
React.createElement(
Box,
{
sx: {
display: 'flex',
alignItems: 'center',
width: '100%',
flexDirection: { xs: 'column', sm: 'row' }
}
},
// First row: Logo and invisible placeholders to match SPA layout
React.createElement(
Box,
{
sx: {
display: 'flex',
alignItems: 'center',
width: '100%',
justifyContent: { xs: 'space-between', sm: 'flex-start' }, // Match SPA layout
minHeight: { xs: 52, sm: 'auto' },
px: { xs: 0, sm: 0 }
}
},
React.createElement(Logo),
// Invisible SearchBar placeholder on desktop to match SPA spacing
React.createElement(
Box,
{
sx: {
display: { xs: 'none', sm: 'block' },
flexGrow: 1,
mx: { xs: 0, sm: 2, md: 4 },
visibility: 'hidden',
height: 40 // Match SearchBar height
}
}
),
// Invisible ButtonGroup placeholder to match SPA spacing
React.createElement(
Box,
{
sx: {
display: { xs: 'flex', sm: 'flex' },
alignItems: { xs: 'flex-end', sm: 'center' },
transform: { xs: 'translateY(4px) translateX(9px)', sm: 'none' },
ml: { xs: 0, sm: 0 },
visibility: 'hidden',
width: { xs: 'auto', sm: '120px' }, // Approximate ButtonGroup width
height: 40
}
}
)
),
// Second row: SearchBar placeholder only on mobile
React.createElement(
Box,
{
sx: {
display: { xs: 'block', sm: 'none' },
width: '100%',
mt: { xs: 1, sm: 0 },
mb: { xs: 0.5, sm: 0 },
px: { xs: 0, sm: 0 },
height: 41, // Small TextField height
visibility: 'hidden'
}
}
)
)
)
)
),
React.createElement(
Box,
{ sx: { flexGrow: 1 } },
React.createElement(
Container,
{ maxWidth: 'lg', sx: { py: 4, flexGrow: 1 } },
React.createElement(
Grid,
{ container: true, spacing: 4 },
// Product Image
React.createElement(
Grid,
{ item: true, xs: 12, md: 6 },
React.createElement(
Card,
{ sx: { height: '100%' } },
React.createElement(
CardMedia,
{
component: 'img',
height: '400',
image: mainImage,
alt: product.name,
sx: { objectFit: 'contain', p: 2 }
maxWidth: "lg",
sx: {
p: { xs: 2, md: 2 },
pb: { xs: 4, md: 8 },
flexGrow: 1
}
},
// Back button (breadcrumbs section)
React.createElement(
Box,
{
sx: {
mb: 2,
position: ["-webkit-sticky", "sticky"],
// No CategoryList in prerender — two-row toolbar only; safe-area for notched phones.
top: {
xs: "calc(env(safe-area-inset-top, 0px) + 128px)",
sm: "80px",
},
left: 0,
width: "100%",
display: "flex",
zIndex: (theme) => theme.zIndex.appBar - 1,
py: 0,
px: 2,
}
},
React.createElement(
Box,
{
sx: {
ml: { xs: 0, md: 0 },
display: "inline-flex",
px: 0,
py: 1,
backgroundColor: "#2e7d32", // primary dark green
borderRadius: 1,
}
},
React.createElement(
Typography,
{ variant: "body2", color: "text.secondary" },
React.createElement(
'a',
{
href: "#",
onClick: (e) => {
e.preventDefault();
if (window.history.length > 1) {
window.history.back();
} else {
window.location.href = '/';
}
},
style: {
paddingLeft: 16,
paddingRight: 16,
paddingTop: 8,
paddingBottom: 8,
textDecoration: "none",
color: "#fff",
fontWeight: "bold",
cursor: "pointer"
}
},
this.props.t ? this.props.t('common.back') : 'Zurück'
)
)
)
),
React.createElement(
Box,
{
sx: {
display: "flex",
flexDirection: { xs: "column", md: "row" },
gap: 4,
background: "#fff",
borderRadius: 2,
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
}
},
// Product Image Section
React.createElement(
ProductImage,
{
product: product,
socket: null,
socketB: null,
fullscreenOpen: false,
onOpenFullscreen: null,
onCloseFullscreen: null
}
),
// Product Details Section
React.createElement(
Box,
{
sx: {
flex: "1 1 60%",
p: { xs: 2, md: 4 },
display: "flex",
flexDirection: "column",
}
},
// Product identifiers
React.createElement(
Box,
{ sx: { mb: 1 } },
React.createElement(
Typography,
{ variant: 'body2', color: 'text.secondary' },
(this.props.t ? this.props.t('product.articleNumber') : 'Artikelnummer')+': '+product.articleNumber+' '+(product.gtin ? ` | GTIN: ${product.gtin}` : "")
)
),
// Product title
React.createElement(
Typography,
{
variant: 'h4',
component: 'h1',
gutterBottom: true,
sx: {
fontWeight: 600,
color: "#333"
}
},
cleanProductName(product.name)
),
// Manufacturer if available - exact match to SPA: only render Box if manufacturer exists
product.manufacturer && React.createElement(
Box,
{ sx: { display: "flex", alignItems: "center", mb: 2 } },
React.createElement(
Typography,
{ variant: 'body2', sx: { fontStyle: "italic" } },
(this.props.t ? this.props.t('product.manufacturer') : 'Hersteller')+': '+product.manufacturer
)
),
// Attribute images and chips with action buttons section - exact replica of SPA version
// SPA condition: (attributes.some(attr => attributeImages[attr.kMerkmalWert]) || attributes.some(attr => !attributeImages[attr.kMerkmalWert]))
// This essentially means "if there are any attributes at all"
// For products with no attributes (like Vakuumbeutel), this section should NOT render
(attributes.length > 0) && React.createElement(
Box,
{ sx: { display: "flex", justifyContent: "space-between", alignItems: "flex-start", mb: 2, gap: 2 } },
// Left side - attributes
React.createElement(
Stack,
{ direction: 'row', spacing: 2, sx: { flexWrap: "wrap", gap: 1, flex: 1 } },
// In prerender: attributes.filter(attribute => attributeImages[attribute.kMerkmalWert]) = [] (empty)
// Then: attributes.filter(attribute => !attributeImages[attribute.kMerkmalWert]) = all attributes as Chips
...attributes.map((attribute, index) =>
React.createElement(
Chip,
{
key: attribute.kMerkmalWert || index,
label: attribute.cWert,
disabled: true,
sx: { mb: 1 }
}
)
)
),
// Product Details
React.createElement(
Grid,
{ item: true, xs: 12, md: 6 },
// Right side - action buttons (exact replica with invisible versions)
React.createElement(
Stack,
{ spacing: 3 },
{ direction: 'column', spacing: 1, sx: { flexShrink: 0 } },
// "Frage zum Artikel" button - exact replica but invisible
React.createElement(
Typography,
{ variant: 'h3', component: 'h1', gutterBottom: true },
product.name
Button,
{
variant: "outlined",
size: "small",
sx: {
fontSize: "0.75rem",
px: 1.5,
py: 0.5,
minWidth: "auto",
whiteSpace: "nowrap",
visibility: "hidden",
pointerEvents: "none"
}
},
"Frage zum Artikel"
),
// "Artikel Bewerten" button - exact replica but invisible
React.createElement(
Typography,
{ variant: 'h6', color: 'text.secondary' },
'Artikelnummer: '+product.articleNumber+' '+(product.gtin ? ` | GTIN: ${product.gtin}` : "")
Button,
{
variant: "outlined",
size: "small",
sx: {
fontSize: "0.75rem",
px: 1.5,
py: 0.5,
minWidth: "auto",
whiteSpace: "nowrap",
visibility: "hidden",
pointerEvents: "none"
}
},
"Artikel Bewerten"
),
React.createElement(
// "Verfügbarkeit anfragen" button - conditional, exact replica but invisible
(product.available !== 1 && product.availableSupplier !== 1) && React.createElement(
Button,
{
variant: "outlined",
size: "small",
sx: {
fontSize: "0.75rem",
px: 1.5,
py: 0.5,
minWidth: "auto",
whiteSpace: "nowrap",
borderColor: "warning.main",
color: "warning.main",
"&:hover": {
borderColor: "warning.dark",
backgroundColor: "warning.light"
},
visibility: "hidden",
pointerEvents: "none"
}
},
"Verfügbarkeit anfragen"
)
)
),
// Weight
(product.weight && product.weight > 0) ? React.createElement(
Box,
{ sx: { mt: 1 } },
{ sx: { mb: 2 } },
React.createElement(
Typography,
{ variant: 'h4', color: 'primary', fontWeight: 'bold' },
new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR'
}).format(product.price)
),
product.vat && React.createElement(
Typography,
{ variant: 'body2', color: 'text.secondary' },
`inkl. ${product.vat}% MwSt.`
),
(this.props.t ? this.props.t('product.weight', { weight: product.weight.toFixed(1).replace(".", ",") }) : `Gewicht: ${product.weight.toFixed(1).replace(".", ",")} kg`)
)
) : null,
// Price and availability section
React.createElement(
Box,
{
sx: {
mt: "auto",
transform: "translateY(-1px)", // Move 1px up
p: 3,
background: "#f9f9f9",
borderRadius: 2,
}
},
React.createElement(
Box,
{
sx: {
display: "flex",
flexDirection: { xs: "column", sm: "row" },
justifyContent: "space-between",
alignItems: { xs: "flex-start", sm: "flex-start" },
gap: 2,
}
},
// Left side - Price information (exact match to SPA)
React.createElement(
Box,
null,
React.createElement(
Typography,
{
variant: 'body1',
color: product.available ? 'success.main' : 'error.main',
fontWeight: 'medium',
sx: { mt: 1 }
variant: "h4",
color: "primary",
sx: { fontWeight: "bold" }
},
product.available ? '✅ Verfügbar' : '❌ Nicht verfügbar'
)
priceWithTax
),
product.description && React.createElement(
Box,
{ sx: { mt: 2 } },
// VAT info (exact match to SPA - direct Typography, no wrapper)
React.createElement(
Typography,
{ variant: 'h6', gutterBottom: true },
'Beschreibung'
{ variant: 'body2', color: 'text.secondary' },
(this.props.t ? this.props.t('product.inclVat', { vat: product.vat }) : `inkl. ${product.vat}% MwSt.`) +
(product.cGrundEinheit && product.fGrundPreis ?
`; ${new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(product.fGrundPreis)}/${product.cGrundEinheit}` :
"")
),
// Shipping class (exact match to SPA - direct Typography, conditional render)
product.versandklasse &&
product.versandklasse != "standard" &&
product.versandklasse != "kostenlos" && React.createElement(
Typography,
{ variant: 'body2', color: 'text.secondary' },
product.versandklasse
)
),
// Right side - Complex cart button area structure (matching SPA exactly)
React.createElement(
Box,
{
sx: {
display: "flex",
flexDirection: { xs: "column", sm: "row" },
gap: 2,
alignItems: "flex-start",
}
},
// Empty steckling column placeholder - maintains flex positioning
React.createElement(
Box,
{ sx: { display: "flex", flexDirection: "column" } }
// Empty - no steckling for this product
),
// Main cart button column (exact match to SPA structure)
React.createElement(
Box,
{
sx: {
display: "flex",
flexDirection: "column",
}
},
// AddToCartButton placeholder - invisible button that reserves exact space
React.createElement(
Button,
{
variant: "contained",
size: "large",
sx: {
visibility: "hidden",
pointerEvents: "none",
height: "36px",
width: "140px",
minWidth: "140px",
maxWidth: "140px"
}
},
"In den Warenkorb"
),
// Delivery time Typography (exact match to SPA)
React.createElement(
Typography,
{
variant: 'caption',
sx: {
fontStyle: "italic",
color: "text.secondary",
textAlign: "center",
mt: 1
}
},
product.id && product.id.toString().endsWith("steckling") ?
(this.props.t ? this.props.t('delivery.times.cutting14Days') : "Lieferzeit: 14 Tage") :
product.available == 1 ?
(this.props.t ? this.props.t('delivery.times.standard2to3Days') : "Lieferzeit: 2-3 Tage") :
product.availableSupplier == 1 ?
(this.props.t ? this.props.t('delivery.times.supplier7to9Days') : "Lieferzeit: 7-9 Tage") :
""
)
)
)
)
)
)
),
// Product full description - separate card
product.description && React.createElement(
Box,
{
sx: {
mt: 4,
p: 4,
background: "#fff",
borderRadius: 2,
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
}
},
React.createElement(
Box,
{
sx: {
mt: 2,
lineHeight: 1.7,
"& p": { mt: 0, mb: 2 },
"& strong": { fontWeight: 600 },
}
},
React.createElement(
'div',
{
dangerouslySetInnerHTML: { __html: product.description },
dangerouslySetInnerHTML: {
__html: sanitizeHtml(product.description, {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
allowedAttributes: {
'*': ['class', 'style'],
'a': ['href', 'title'],
'img': ['src', 'alt', 'width', 'height']
},
disallowedTagsMode: 'discard'
})
},
style: {
fontFamily: '"Roboto","Helvetica","Arial",sans-serif',
fontFamily: '"Outfit Variable","Roboto","Helvetica","Arial",sans-serif',
fontSize: '1rem',
lineHeight: '1.5',
color: '#33691E'
lineHeight: '1.7',
color: '#333'
}
}
)
),
// Product specifications
React.createElement(
Box,
{ sx: { mt: 2 } },
React.createElement(
Typography,
{ variant: 'h6', gutterBottom: true },
'Produktdetails'
),
React.createElement(
Stack,
{ direction: 'row', spacing: 1, flexWrap: 'wrap', gap: 1 },
product.manufacturer && React.createElement(
Chip,
{ label: `Hersteller: ${product.manufacturer}`, variant: 'outlined' }
),
product.weight && product.weight > 0 && React.createElement(
Chip,
{ label: `Gewicht: ${product.weight} kg`, variant: 'outlined' }
),
...attributes.map((attr, index) =>
React.createElement(
Chip,
{
key: index,
label: `${attr.cName}: ${attr.cWert}`,
variant: 'outlined',
color: 'primary'
}
)
)
)
)
)
)
)
@@ -193,4 +567,4 @@ class PrerenderProduct extends React.Component {
}
}
module.exports = { default: PrerenderProduct };
export default PrerenderProduct;

View File

@@ -1,17 +1,11 @@
const React = require('react');
const {
Box,
AppBar,
Toolbar,
Container,
import React from 'react';
import {
Typography,
List,
ListItem,
ListItemText
} = require('@mui/material');
const Footer = require('./components/Footer.js').default;
const { Logo, CategoryList } = require('./components/header/index.js');
const LegalPage = require('./pages/LegalPage.js').default;
} from '@mui/material';
import LegalPage from './pages/LegalPage.js';
const PrerenderSitemap = ({ categoryData }) => {
// Process category data to flatten the hierarchy
@@ -134,4 +128,4 @@ const PrerenderSitemap = ({ categoryData }) => {
return React.createElement(LegalPage, { title: 'Sitemap', content: content });
};
module.exports = { default: PrerenderSitemap };
export default PrerenderSitemap;

View File

@@ -10,6 +10,23 @@ import AddIcon from "@mui/icons-material/Add";
import RemoveIcon from "@mui/icons-material/Remove";
import ShoppingCartIcon from "@mui/icons-material/ShoppingCart";
import DeleteIcon from "@mui/icons-material/Delete";
import NotificationsIcon from "@mui/icons-material/Notifications";
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,
ensurePushSubscription,
articlePushStatus,
articlePushSubscribe,
articlePushUnsubscribe,
parseSubscribedStatus,
parseSuccess,
} from "../utils/articlePush.js";
if (!Array.isArray(window.cart)) window.cart = [];
@@ -23,9 +40,136 @@ class AddToCartButton extends Component {
: 0,
isEditing: false,
editValue: "",
pushInteractive: false,
pushSubscribed: false,
pushBusy: false,
pushError: null,
};
}
kArtikelNumber = () => {
const { id } = this.props;
const n = typeof id === "number" ? id : parseInt(id, 10);
return Number.isFinite(n) && n > 0 ? n : null;
};
refreshIncomingPushStatus = async () => {
const { available, incoming } = this.props;
if (available || !incoming) {
this.setState({
pushInteractive: false,
pushSubscribed: false,
pushError: null,
});
return;
}
const kArtikel = this.kArtikelNumber();
if (!kArtikel || !isPushApiSupported()) {
this.setState({ pushInteractive: false });
return;
}
try {
const cfg = await fetchPushConfiguration();
if (!cfg.configured || !cfg.publicKey) {
this.setState({ pushInteractive: false });
return;
}
await registerPushServiceWorker();
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (!subscription) {
this.setState({
pushInteractive: true,
pushSubscribed: false,
pushError: null,
});
return;
}
const statusData = await articlePushStatus(kArtikel, subscription.endpoint);
this.setState({
pushInteractive: true,
pushSubscribed: parseSubscribedStatus(statusData),
pushError: null,
});
} catch (e) {
console.warn("AddToCartButton: incoming push init failed", e);
this.setState({ pushInteractive: false });
}
};
handleIncomingPushClick = async () => {
if (!this.state.pushInteractive || this.state.pushBusy) return;
const kArtikel = this.kArtikelNumber();
if (!kArtikel) return;
const t = this.props.t;
this.setState({ pushBusy: true, pushError: null });
try {
if (this.state.pushSubscribed) {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (!subscription) {
this.setState({ pushSubscribed: false, pushBusy: false });
return;
}
const res = await articlePushUnsubscribe(subscription.endpoint, kArtikel);
if (parseSuccess(res)) {
this.setState({ pushSubscribed: false });
emitPushSubscriptionsChanged();
} else {
this.setState({
pushError:
res?.message ||
res?.error ||
(t ? t("productDialogs.pushNotifyError") : ""),
});
}
} else {
const perm = await Notification.requestPermission();
if (perm !== "granted") {
this.setState({
pushError: t
? t("productDialogs.pushNotifyPermissionDenied")
: "",
pushBusy: false,
});
return;
}
const cfg = await fetchPushConfiguration();
if (!cfg.configured || !cfg.publicKey) {
this.setState({
pushError: t
? t("productDialogs.pushNotifyServerDisabled")
: "",
pushBusy: false,
});
return;
}
await registerPushServiceWorker();
const subscription = await ensurePushSubscription(cfg.publicKey);
const res = await articlePushSubscribe(kArtikel, subscription);
if (parseSuccess(res)) {
this.setState({ pushSubscribed: true });
emitPushSubscriptionsChanged();
} else {
this.setState({
pushError:
res?.message ||
res?.error ||
(t ? t("productDialogs.pushNotifyError") : ""),
});
}
}
} catch (e) {
console.error("AddToCartButton: incoming push", e);
this.setState({
pushError:
e.message || (t ? t("productDialogs.pushNotifyError") : ""),
});
} finally {
this.setState({ pushBusy: false });
}
};
componentDidMount() {
this.cart = () => {
if (!Array.isArray(window.cart)) window.cart = [];
@@ -34,11 +178,33 @@ 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();
}
componentDidUpdate(prevProps) {
if (
prevProps.available !== this.props.available ||
prevProps.incoming !== this.props.incoming ||
prevProps.id !== this.props.id
) {
this.refreshIncomingPushStatus();
}
}
componentWillUnmount() {
window.removeEventListener("cart", this.cart);
window.removeEventListener(
PUSH_SUBSCRIPTIONS_CHANGED_EVENT,
this.onPushSubscriptionsChanged
);
}
handleIncrement = () => {
@@ -51,11 +217,14 @@ class AddToCartButton extends Component {
seoName: this.props.seoName,
pictureList: this.props.pictureList,
price: this.props.price,
fGrundPreis: this.props.fGrundPreis,
cGrundEinheit: this.props.cGrundEinheit,
quantity: 1,
weight: this.props.weight,
vat: this.props.vat,
versandklasse: this.props.versandklasse,
availableSupplier: this.props.availableSupplier,
komponenten: this.props.komponenten,
available: this.props.available
});
} else {
@@ -129,34 +298,77 @@ class AddToCartButton extends Component {
};
render() {
const { quantity, isEditing, editValue } = this.state;
const {
quantity,
isEditing,
editValue,
pushInteractive,
pushSubscribed,
pushBusy,
pushError,
} = this.state;
const { available, size, incoming, availableSupplier } = this.props;
// Button is disabled if product is not available
if (!available) {
if (incoming) {
const dateStr = new Date(incoming).toLocaleDateString("de-DE", {
year: "numeric",
month: "long",
day: "numeric",
});
const dateLabel = this.props.t
? this.props.t("cart.availableFrom", { date: dateStr })
: `Ab ${dateStr}`;
return (
<Box sx={{ width: "100%" }}>
<Button
fullWidth
variant="contained"
size={size || "medium"}
onClick={pushInteractive ? this.handleIncomingPushClick : undefined}
disabled={pushInteractive && pushBusy}
startIcon={
pushBusy ? (
<CircularProgress size={18} sx={{ color: "inherit" }} />
) : pushSubscribed ? (
<NotificationsActiveIcon sx={{ color: "#2e7d32" }} />
) : (
<NotificationsIcon sx={{ color: "rgba(0,0,0,0.75)" }} />
)
}
sx={{
borderRadius: 2,
fontWeight: "bold",
backgroundColor: "#ffeb3b",
color: "#000000",
whiteSpace: "nowrap",
flexWrap: "nowrap",
"& .MuiButton-label": {
whiteSpace: "nowrap",
flexWrap: "nowrap",
},
"&:hover": {
backgroundColor: "#fdd835",
},
...(pushInteractive && {
cursor: "pointer",
}),
}}
>
Ab{" "}
{new Date(incoming).toLocaleDateString("de-DE", {
year: "numeric",
month: "long",
day: "numeric",
})}
{dateLabel}
</Button>
{pushError && (
<Typography
variant="caption"
color="error"
sx={{ display: "block", mt: 0.5, textAlign: "center" }}
>
{pushError}
</Typography>
)}
</Box>
);
}
@@ -173,7 +385,7 @@ class AddToCartButton extends Component {
startIcon={<ShoppingCartIcon />}
sx={{
borderRadius: 2,
fontWeight: "bold",
whiteSpace: "nowrap",
backgroundColor: "#9ccc65", // yellowish green
color: "#000000",
"&:hover": {
@@ -181,7 +393,9 @@ class AddToCartButton extends Component {
},
}}
>
{this.props.steckling ? "Als Steckling vorbestellen" : "In den Korb"}
{/*this.props.steckling ?
(this.props.t ? this.props.t('cart.preorderCutting') : "Als Steckling vorbestellen") : */
(this.props.t ? this.props.t('cart.addToCart') : "In den Korb")}
</Button>
);
}
@@ -205,6 +419,7 @@ class AddToCartButton extends Component {
<IconButton
color="inherit"
onClick={this.handleDecrement}
aria-label="Menge verringern"
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
>
<RemoveIcon />
@@ -254,15 +469,17 @@ class AddToCartButton extends Component {
<IconButton
color="inherit"
onClick={this.handleIncrement}
aria-label="Menge erhöhen"
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
>
<AddIcon />
</IconButton>
<Tooltip title="Aus dem Warenkorb entfernen" arrow>
<Tooltip title={this.props.t ? this.props.t('cart.removeFromCart') : 'Aus dem Warenkorb entfernen'} arrow>
<IconButton
color="inherit"
onClick={this.handleClearCart}
aria-label={this.props.t ? this.props.t('cart.removeFromCart') : 'Aus dem Warenkorb entfernen'}
sx={{
borderRadius: 0,
"&:hover": { color: "error.light" },
@@ -272,10 +489,11 @@ class AddToCartButton extends Component {
</IconButton>
</Tooltip>
{this.props.cartButton && (
<Tooltip title="Warenkorb öffnen" arrow>
<Tooltip title={this.props.t ? this.props.t('cart.openCart') : 'Warenkorb öffnen'} arrow>
<IconButton
color="inherit"
onClick={this.toggleCart}
aria-label={this.props.t ? this.props.t('cart.openCart') : 'Warenkorb öffnen'}
sx={{
borderRadius: 0,
"&:hover": { color: "primary.light" },
@@ -302,7 +520,7 @@ class AddToCartButton extends Component {
fontWeight: "bold",
}}
>
Out of Stock
{this.props.t ? this.props.t('product.outOfStock') : 'Out of Stock'}
</Button>
);
}
@@ -321,13 +539,15 @@ class AddToCartButton extends Component {
startIcon={<ShoppingCartIcon />}
sx={{
borderRadius: 2,
fontWeight: "bold",
whiteSpace: "nowrap",
"&:hover": {
backgroundColor: "primary.dark",
},
}}
>
{this.props.steckling ? "Als Steckling vorbestellen" : "In den Korb"}
{/*this.props.steckling ?
(this.props.t ? this.props.t('cart.preorderCutting') : "Als Steckling vorbestellen") :*/
(this.props.t ? this.props.t('cart.addToCart') : "In den Korb")}
</Button>
);
}
@@ -350,6 +570,7 @@ class AddToCartButton extends Component {
<IconButton
color="inherit"
onClick={this.handleDecrement}
aria-label="Menge verringern"
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
>
<RemoveIcon />
@@ -399,15 +620,17 @@ class AddToCartButton extends Component {
<IconButton
color="inherit"
onClick={this.handleIncrement}
aria-label="Menge erhöhen"
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
>
<AddIcon />
</IconButton>
<Tooltip title="Aus dem Warenkorb entfernen" arrow>
<Tooltip title={this.props.t ? this.props.t('cart.removeFromCart') : 'Aus dem Warenkorb entfernen'} arrow>
<IconButton
color="inherit"
onClick={this.handleClearCart}
aria-label={this.props.t ? this.props.t('cart.removeFromCart') : 'Aus dem Warenkorb entfernen'}
sx={{
borderRadius: 0,
"&:hover": { color: "error.light" },
@@ -417,10 +640,11 @@ class AddToCartButton extends Component {
</IconButton>
</Tooltip>
{this.props.cartButton && (
<Tooltip title="Warenkorb öffnen" arrow>
<Tooltip title={this.props.t ? this.props.t('cart.openCart') : 'Warenkorb öffnen'} arrow>
<IconButton
color="inherit"
onClick={this.toggleCart}
aria-label={this.props.t ? this.props.t('cart.openCart') : 'Warenkorb öffnen'}
sx={{
borderRadius: 0,
"&:hover": { color: "primary.light" },
@@ -436,4 +660,4 @@ class AddToCartButton extends Component {
}
}
export default AddToCartButton;
export default withI18n()(AddToCartButton);

View File

@@ -0,0 +1,241 @@
import React, { Component } from 'react';
import {
Box,
Typography,
TextField,
Button,
Paper,
Alert,
CircularProgress,
FormControl,
FormLabel,
RadioGroup,
FormControlLabel,
Radio
} from '@mui/material';
import { withI18n } from '../i18n/withTranslation.js';
class ArticleAvailabilityForm extends Component {
constructor(props) {
super(props);
this.state = {
name: '',
email: '',
telegramId: '',
notificationMethod: 'email',
message: '',
loading: false,
success: false,
error: null
};
}
handleInputChange = (field) => (event) => {
this.setState({ [field]: event.target.value });
};
handleNotificationMethodChange = (event) => {
this.setState({
notificationMethod: event.target.value,
// Clear the other field when switching methods
email: event.target.value === 'email' ? this.state.email : '',
telegramId: event.target.value === 'telegram' ? this.state.telegramId : ''
});
};
handleSubmit = (event) => {
event.preventDefault();
// Prepare data for API emission
const availabilityData = {
type: 'availability_inquiry',
productId: this.props.productId,
productName: this.props.productName,
name: this.state.name,
notificationMethod: this.state.notificationMethod,
email: this.state.notificationMethod === 'email' ? this.state.email : '',
telegramId: this.state.notificationMethod === 'telegram' ? this.state.telegramId : '',
message: this.state.message,
timestamp: new Date().toISOString()
};
// Emit data via socket
console.log('Availability Inquiry Data to emit:', availabilityData);
window.socketManager.emit('availability_inquiry_submit', availabilityData);
// Set up response handler
window.socketManager.once('availability_inquiry_response', (response) => {
if (response.success) {
this.setState({
loading: false,
success: true,
name: '',
email: '',
telegramId: '',
notificationMethod: 'email',
message: ''
});
} else {
this.setState({
loading: false,
error: response.error || this.props.t("productDialogs.errorGeneric")
});
}
// Clear messages after 3 seconds
setTimeout(() => {
this.setState({ success: false, error: null });
}, 3000);
});
this.setState({ loading: true });
// Fallback timeout in case backend doesn't respond
setTimeout(() => {
if (this.state.loading) {
this.setState({
loading: false,
success: true,
name: '',
email: '',
telegramId: '',
notificationMethod: 'email',
message: ''
});
// Clear success message after 3 seconds
setTimeout(() => {
this.setState({ success: false });
}, 3000);
}
}, 5000);
};
render() {
const { name, email, telegramId, notificationMethod, message, loading, success, error } = this.state;
const { t } = this.props;
return (
<Paper id="availability-form" sx={{ p: 3, mt: 4, borderRadius: 2, boxShadow: "0 2px 8px rgba(0,0,0,0.08)" }}>
<Typography variant="h5" gutterBottom sx={{ fontWeight: 600, color: '#333' }}>
{t("productDialogs.availabilityTitle")}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{t("productDialogs.availabilitySubtitle")}
</Typography>
{success && (
<Alert severity="success" sx={{ mb: 3 }}>
{notificationMethod === 'email' ? t("productDialogs.availabilitySuccessEmail") : t("productDialogs.availabilitySuccessTelegram")}
</Alert>
)}
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
<Box component="form" onSubmit={this.handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label={t("productDialogs.nameLabel")}
value={name}
onChange={this.handleInputChange('name')}
required
fullWidth
disabled={loading}
placeholder={t("productDialogs.namePlaceholder")}
/>
<FormControl component="fieldset" disabled={loading}>
<FormLabel component="legend" sx={{ mb: 1 }}>
{t("productDialogs.notificationMethodLabel")}
</FormLabel>
<RadioGroup
value={notificationMethod}
onChange={this.handleNotificationMethodChange}
row
>
<FormControlLabel
value="email"
control={<Radio />}
label={t("productDialogs.emailLabel")}
/>
<FormControlLabel
value="telegram"
control={<Radio />}
label={t("productDialogs.telegramBotLabel")}
/>
</RadioGroup>
</FormControl>
{notificationMethod === 'email' && (
<TextField
label={t("productDialogs.emailLabel")}
type="email"
value={email}
onChange={this.handleInputChange('email')}
required
fullWidth
disabled={loading}
placeholder={t("productDialogs.emailPlaceholder")}
/>
)}
{notificationMethod === 'telegram' && (
<TextField
label={t("productDialogs.telegramIdLabel")}
value={telegramId}
onChange={this.handleInputChange('telegramId')}
required
fullWidth
disabled={loading}
placeholder={t("productDialogs.telegramPlaceholder")}
helperText={t("productDialogs.telegramHelper")}
/>
)}
<TextField
label={t("productDialogs.messageLabel")}
value={message}
onChange={this.handleInputChange('message')}
fullWidth
multiline
rows={3}
disabled={loading}
placeholder={t("productDialogs.messagePlaceholder")}
/>
<Button
type="submit"
variant="contained"
disabled={loading || !name || (notificationMethod === 'email' && !email) || (notificationMethod === 'telegram' && !telegramId)}
sx={{
mt: 2,
py: 1.5,
fontSize: '1rem',
fontWeight: 600,
backgroundColor: 'warning.main',
'&:hover': {
backgroundColor: 'warning.dark'
}
}}
>
{loading ? (
<>
<CircularProgress size={20} sx={{ mr: 1 }} />
{t("productDialogs.sending")}
</>
) : (
t("productDialogs.submitAvailability")
)}
</Button>
</Box>
</Paper>
);
}
}
export default withI18n()(ArticleAvailabilityForm);

View File

@@ -0,0 +1,235 @@
import React, { Component } from 'react';
import {
Box,
Typography,
TextField,
Button,
Paper,
Alert,
CircularProgress
} from '@mui/material';
import { withI18n } from '../i18n/withTranslation.js';
import PhotoUpload from './PhotoUpload.js';
class ArticleQuestionForm extends Component {
constructor(props) {
super(props);
this.state = {
name: '',
email: '',
question: '',
photos: [],
loading: false,
success: false,
error: null
};
this.photoUploadRef = React.createRef();
}
handleInputChange = (field) => (event) => {
this.setState({ [field]: event.target.value });
};
handlePhotosChange = (files) => {
this.setState({ photos: files });
};
convertPhotosToBase64 = (photos) => {
return Promise.all(
photos.map(photo => {
return new Promise((resolve, _reject) => {
const reader = new FileReader();
reader.onload = (e) => {
resolve({
name: photo.name,
type: photo.type,
size: photo.size,
data: e.target.result // base64 string
});
};
reader.readAsDataURL(photo);
});
})
);
};
handleSubmit = async (event) => {
event.preventDefault();
this.setState({ loading: true });
try {
// Convert photos to base64
const photosBase64 = await this.convertPhotosToBase64(this.state.photos);
// Prepare data for API emission
const questionData = {
type: 'article_question',
productId: this.props.productId,
productName: this.props.productName,
name: this.state.name,
email: this.state.email,
question: this.state.question,
photos: photosBase64,
timestamp: new Date().toISOString()
};
// Emit data via socket
console.log('Article Question Data to emit:', questionData);
window.socketManager.emit('article_question_submit', questionData);
// Set up response handler
window.socketManager.once('article_question_response', (response) => {
if (response.success) {
this.setState({
loading: false,
success: true,
name: '',
email: '',
question: '',
photos: []
});
// Reset the photo upload component
if (this.photoUploadRef.current) {
this.photoUploadRef.current.reset();
}
} else {
this.setState({
loading: false,
error: response.error || this.props.t("productDialogs.errorGeneric")
});
}
// Clear messages after 3 seconds
setTimeout(() => {
this.setState({ success: false, error: null });
}, 3000);
});
} catch {
this.setState({
loading: false,
error: this.props.t("productDialogs.errorPhotos")
});
}
// Fallback timeout in case backend doesn't respond
setTimeout(() => {
if (this.state.loading) {
this.setState({
loading: false,
success: true,
name: '',
email: '',
question: '',
photos: []
});
// Reset the photo upload component
if (this.photoUploadRef.current) {
this.photoUploadRef.current.reset();
}
// Clear success message after 3 seconds
setTimeout(() => {
this.setState({ success: false });
}, 3000);
}
}, 5000);
};
render() {
const { name, email, question, loading, success, error } = this.state;
const { t } = this.props;
return (
<Paper sx={{ p: 3, mt: 4, borderRadius: 2, boxShadow: "0 2px 8px rgba(0,0,0,0.08)" }}>
<Typography variant="h5" gutterBottom sx={{ fontWeight: 600, color: '#333' }}>
{t("productDialogs.questionTitle")}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{t("productDialogs.questionSubtitle")}
</Typography>
{success && (
<Alert severity="success" sx={{ mb: 3 }}>
{t("productDialogs.questionSuccess")}
</Alert>
)}
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
<Box component="form" onSubmit={this.handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label={t("productDialogs.nameLabel")}
value={name}
onChange={this.handleInputChange('name')}
required
fullWidth
disabled={loading}
placeholder={t("productDialogs.namePlaceholder")}
/>
<TextField
label={t("productDialogs.emailLabel")}
type="email"
value={email}
onChange={this.handleInputChange('email')}
required
fullWidth
disabled={loading}
placeholder={t("productDialogs.emailPlaceholder")}
/>
<TextField
label={t("productDialogs.questionLabel")}
value={question}
onChange={this.handleInputChange('question')}
required
fullWidth
multiline
rows={4}
disabled={loading}
placeholder={t("productDialogs.questionPlaceholder")}
/>
<PhotoUpload
ref={this.photoUploadRef}
onChange={this.handlePhotosChange}
disabled={loading}
maxFiles={3}
label={t("productDialogs.photosLabelQuestion")}
/>
<Button
type="submit"
variant="contained"
disabled={loading || !name || !email || !question}
sx={{
mt: 2,
py: 1.5,
fontSize: '1rem',
fontWeight: 600
}}
>
{loading ? (
<>
<CircularProgress size={20} sx={{ mr: 1 }} />
{t("productDialogs.sending")}
</>
) : (
t("productDialogs.submitQuestion")
)}
</Button>
</Box>
</Paper>
);
}
}
export default withI18n()(ArticleQuestionForm);

View File

@@ -0,0 +1,263 @@
import React, { Component } from 'react';
import {
Box,
Typography,
TextField,
Button,
Paper,
Alert,
CircularProgress,
Rating
} from '@mui/material';
import StarIcon from '@mui/icons-material/Star';
import { withI18n } from '../i18n/withTranslation.js';
import PhotoUpload from './PhotoUpload.js';
class ArticleRatingForm extends Component {
constructor(props) {
super(props);
this.state = {
name: '',
email: '',
rating: 0,
review: '',
photos: [],
loading: false,
success: false,
error: null
};
this.photoUploadRef = React.createRef();
}
handleInputChange = (field) => (event) => {
this.setState({ [field]: event.target.value });
};
handleRatingChange = (event, newValue) => {
this.setState({ rating: newValue });
};
handlePhotosChange = (files) => {
this.setState({ photos: files });
};
convertPhotosToBase64 = (photos) => {
return Promise.all(
photos.map(photo => {
return new Promise((resolve, _reject) => {
const reader = new FileReader();
reader.onload = (e) => {
resolve({
name: photo.name,
type: photo.type,
size: photo.size,
data: e.target.result // base64 string
});
};
reader.readAsDataURL(photo);
});
})
);
};
handleSubmit = async (event) => {
event.preventDefault();
this.setState({ loading: true });
try {
// Convert photos to base64
const photosBase64 = await this.convertPhotosToBase64(this.state.photos);
// Prepare data for API emission
const ratingData = {
type: 'article_rating',
productId: this.props.productId,
productName: this.props.productName,
name: this.state.name,
email: this.state.email,
rating: this.state.rating,
review: this.state.review,
photos: photosBase64,
timestamp: new Date().toISOString()
};
// Emit data via socket
console.log('Article Rating Data to emit:', ratingData);
window.socketManager.emit('article_rating_submit', ratingData);
// Set up response handler
window.socketManager.once('article_rating_response', (response) => {
if (response.success) {
this.setState({
loading: false,
success: true,
name: '',
email: '',
rating: 0,
review: '',
photos: []
});
// Reset the photo upload component
if (this.photoUploadRef.current) {
this.photoUploadRef.current.reset();
}
} else {
this.setState({
loading: false,
error: response.error || this.props.t("productDialogs.errorGeneric")
});
}
// Clear messages after 3 seconds
setTimeout(() => {
this.setState({ success: false, error: null });
}, 3000);
});
} catch {
this.setState({
loading: false,
error: this.props.t("productDialogs.errorPhotos")
});
}
// Fallback timeout in case backend doesn't respond
setTimeout(() => {
if (this.state.loading) {
this.setState({
loading: false,
success: true,
name: '',
email: '',
rating: 0,
review: '',
photos: []
});
// Reset the photo upload component
if (this.photoUploadRef.current) {
this.photoUploadRef.current.reset();
}
// Clear success message after 3 seconds
setTimeout(() => {
this.setState({ success: false });
}, 3000);
}
}, 5000);
};
render() {
const { name, email, rating, review, loading, success, error } = this.state;
const { t } = this.props;
return (
<Paper sx={{ p: 3, mt: 4, borderRadius: 2, boxShadow: "0 2px 8px rgba(0,0,0,0.08)" }}>
<Typography variant="h5" gutterBottom sx={{ fontWeight: 600, color: '#333' }}>
{t("productDialogs.ratingTitle")}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{t("productDialogs.ratingSubtitle")}
</Typography>
{success && (
<Alert severity="success" sx={{ mb: 3 }}>
{t("productDialogs.ratingSuccess")}
</Alert>
)}
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
<Box component="form" onSubmit={this.handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label={t("productDialogs.nameLabel")}
value={name}
onChange={this.handleInputChange('name')}
required
fullWidth
disabled={loading}
placeholder={t("productDialogs.namePlaceholder")}
/>
<TextField
label={t("productDialogs.emailLabel")}
type="email"
value={email}
onChange={this.handleInputChange('email')}
required
fullWidth
disabled={loading}
placeholder={t("productDialogs.emailPlaceholder")}
helperText={t("productDialogs.emailHelper")}
/>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{t("productDialogs.ratingLabel")}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Rating
name="article-rating"
value={rating}
onChange={this.handleRatingChange}
size="large"
disabled={loading}
emptyIcon={<StarIcon style={{ opacity: 0.55 }} fontSize="inherit" />}
/>
<Typography variant="body2" color="text.secondary">
{rating > 0 ? t("productDialogs.ratingStars", { rating }) : t("productDialogs.pleaseRate")}
</Typography>
</Box>
</Box>
<TextField
label={t("productDialogs.reviewLabel")}
value={review}
onChange={this.handleInputChange('review')}
fullWidth
multiline
rows={4}
disabled={loading}
placeholder={t("productDialogs.reviewPlaceholder")}
/>
<PhotoUpload
ref={this.photoUploadRef}
onChange={this.handlePhotosChange}
disabled={loading}
maxFiles={5}
label={t("productDialogs.photosLabelRating")}
/>
<Button
type="submit"
variant="contained"
disabled={loading || !name || !email || rating === 0}
sx={{
mt: 2,
py: 1.5,
fontSize: '1rem',
fontWeight: 600
}}
>
{loading ? (
<>
<CircularProgress size={20} sx={{ mr: 1 }} />
{t("productDialogs.sending")}
</>
) : (
t("productDialogs.submitRating")
)}
</Button>
</Box>
</Paper>
);
}
}
export default withI18n()(ArticleRatingForm);

View File

@@ -8,6 +8,7 @@ import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableRow from '@mui/material/TableRow';
import CartItem from './CartItem.js';
import { withI18n } from '../i18n/withTranslation.js';
class CartDropdown extends Component {
@@ -53,8 +54,8 @@ class CartDropdown extends Component {
currency: 'EUR'
});
const shippingNetPrice = deliveryCost / (1 + 19 / 100);
const shippingVat = deliveryCost - shippingNetPrice;
const shippingNetPrice = deliveryCost > 0 ? deliveryCost / (1 + 19 / 100) : 0;
const shippingVat = deliveryCost > 0 ? deliveryCost - shippingNetPrice : 0;
const totalVat7 = priceCalculations.vat7;
const totalVat19 = priceCalculations.vat19 + shippingVat;
const totalGross = priceCalculations.totalGross + deliveryCost;
@@ -63,7 +64,7 @@ class CartDropdown extends Component {
<>
<Box sx={{ bgcolor: 'primary.main', color: 'white', p: 2 }}>
<Typography variant="h6">
{cartItems.length} {cartItems.length === 1 ? 'Produkt' : 'Produkte'}
{cartItems.length} {cartItems.length === 1 ? (this.props.t ? this.props.t('cart.itemCount.singular') : 'Produkt') : (this.props.t ? this.props.t('cart.itemCount.plural') : 'Produkte')}
</Typography>
</Box>
@@ -73,7 +74,6 @@ class CartDropdown extends Component {
{cartItems.map((item) => (
<CartItem
key={item.id}
socket={this.props.socket}
item={item}
id={item.id}
/>
@@ -83,7 +83,7 @@ class CartDropdown extends Component {
{/* Display total weight if greater than 0 */}
{totalWeight > 0 && (
<Typography variant="subtitle2" sx={{ px: 2, mb: 1 }}>
Gesamtgewicht: {totalWeight.toFixed(2)} kg
{this.props.t ? this.props.t('cart.summary.totalWeight', { weight: totalWeight.toFixed(2) }) : `Gesamtgewicht: ${totalWeight.toFixed(2)} kg`}
</Typography>
)}
@@ -94,7 +94,7 @@ class CartDropdown extends Component {
// Detailed summary with shipping costs
<>
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
Bestellübersicht
{this.props.t ? this.props.t('cart.summary.title') : 'Bestellübersicht'}
</Typography>
{deliveryMethod && (
<Typography variant="body2" sx={{ mb: 1, color: 'text.secondary' }}>
@@ -104,14 +104,14 @@ class CartDropdown extends Component {
<Table size="small">
<TableBody>
<TableRow>
<TableCell>Waren (netto):</TableCell>
<TableCell>{this.props.t ? this.props.t('cart.summary.goodsNet') : 'Waren (netto):'}</TableCell>
<TableCell align="right">
{currencyFormatter.format(priceCalculations.totalNet)}
</TableCell>
</TableRow>
{deliveryCost > 0 && (
<TableRow>
<TableCell>Versandkosten (netto):</TableCell>
<TableCell>{this.props.t ? this.props.t('cart.summary.shippingNet') : 'Versandkosten (netto):'}</TableCell>
<TableCell align="right">
{currencyFormatter.format(shippingNetPrice)}
</TableCell>
@@ -119,7 +119,7 @@ class CartDropdown extends Component {
)}
{totalVat7 > 0 && (
<TableRow>
<TableCell>7% Mehrwertsteuer:</TableCell>
<TableCell>{this.props.t ? this.props.t('tax.vat7') : '7% Mehrwertsteuer'}:</TableCell>
<TableCell align="right">
{currencyFormatter.format(totalVat7)}
</TableCell>
@@ -127,28 +127,37 @@ class CartDropdown extends Component {
)}
{totalVat19 > 0 && (
<TableRow>
<TableCell>19% Mehrwertsteuer (inkl. Versand):</TableCell>
<TableCell>{this.props.t ? this.props.t('tax.vat19WithShipping') : '19% Mehrwertsteuer (inkl. Versand)'}:</TableCell>
<TableCell align="right">
{currencyFormatter.format(totalVat19)}
</TableCell>
</TableRow>
)}
<TableRow>
<TableCell sx={{ fontWeight: 'bold' }}>Gesamtsumme Waren:</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>{this.props.t ? this.props.t('cart.summary.totalGoods') : 'Gesamtsumme Waren:'}</TableCell>
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
{currencyFormatter.format(priceCalculations.totalGross)}
</TableCell>
</TableRow>
{deliveryCost > 0 && (
<TableRow>
<TableCell sx={{ fontWeight: 'bold' }}>Versandkosten:</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>
{this.props.t ? this.props.t('cart.summary.shippingCosts') : 'Versandkosten:'}
{deliveryCost === 0 && priceCalculations.totalGross < 100 && (
<span style={{ color: '#2e7d32', fontSize: '0.8em', marginLeft: '4px' }}>
{this.props.t ? this.props.t('cart.summary.freeFrom100') : '(kostenlos ab 100€)'}
</span>
)}
</TableCell>
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
{currencyFormatter.format(deliveryCost)}
{deliveryCost === 0 ? (
<span style={{ color: '#2e7d32' }}>{this.props.t ? this.props.t('cart.summary.free') : 'kostenlos'}</span>
) : (
currencyFormatter.format(deliveryCost)
)}
</TableCell>
</TableRow>
)}
<TableRow sx={{ borderTop: '1px solid #e0e0e0' }}>
<TableCell sx={{ fontWeight: 'bold', pt: 2 }}>Gesamtsumme:</TableCell>
<TableCell sx={{ fontWeight: 'bold', pt: 2 }}>{this.props.t ? this.props.t('cart.summary.total') : 'Gesamtsumme:'}</TableCell>
<TableCell align="right" sx={{ fontWeight: 'bold', pt: 2 }}>
{currencyFormatter.format(totalGross)}
</TableCell>
@@ -161,14 +170,14 @@ class CartDropdown extends Component {
<Table size="small">
<TableBody>
<TableRow>
<TableCell>Gesamtnettopreis:</TableCell>
<TableCell>{this.props.t ? this.props.t('tax.totalNet') : 'Gesamtnettopreis'}:</TableCell>
<TableCell align="right">
{new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(priceCalculations.totalNet)}
</TableCell>
</TableRow>
{priceCalculations.vat7 > 0 && (
<TableRow>
<TableCell>7% Mehrwertsteuer:</TableCell>
<TableCell>{this.props.t ? this.props.t('tax.vat7') : '7% Mehrwertsteuer'}:</TableCell>
<TableCell align="right">
{new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(priceCalculations.vat7)}
</TableCell>
@@ -176,14 +185,14 @@ class CartDropdown extends Component {
)}
{priceCalculations.vat19 > 0 && (
<TableRow>
<TableCell>19% Mehrwertsteuer:</TableCell>
<TableCell>{this.props.t ? this.props.t('tax.vat19') : '19% Mehrwertsteuer'}:</TableCell>
<TableCell align="right">
{new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(priceCalculations.vat19)}
</TableCell>
</TableRow>
)}
<TableRow>
<TableCell sx={{ fontWeight: 'bold' }}>Gesamtbruttopreis ohne Versand:</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>{this.props.t ? this.props.t('tax.totalGross') : 'Gesamtbruttopreis ohne Versand'}:</TableCell>
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
{new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(priceCalculations.totalGross)}
</TableCell>
@@ -201,7 +210,7 @@ class CartDropdown extends Component {
fullWidth
onClick={onClose}
>
Weiter einkaufen
{this.props.t ? this.props.t('cart.continueShopping') : 'Weiter einkaufen'}
</Button>
)}
@@ -213,7 +222,7 @@ class CartDropdown extends Component {
sx={{ mt: 2 }}
onClick={onCheckout}
>
Weiter zur Kasse
{this.props.t ? this.props.t('cart.proceedToCheckout') : 'Weiter zur Kasse'}
</Button>
)}
</>
@@ -223,4 +232,4 @@ class CartDropdown extends Component {
}
}
export default CartDropdown;
export default withI18n()(CartDropdown);

View File

@@ -6,6 +6,7 @@ import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box';
import { Link } from 'react-router-dom';
import AddToCartButton from './AddToCartButton.js';
import { withI18n } from '../i18n/withTranslation.js';
class CartItem extends Component {
@@ -19,17 +20,16 @@ class CartItem extends Component {
this.setState({image:window.tinyPicCache[picid],loading:false, error: false})
}else{
this.setState({image: null, loading: true, error: false});
if(this.props.socket){
this.props.socket.emit('getPic', { bildId:picid, size:'tiny' }, (res) => {
window.socketManager.emit('getPic', { bildId:picid, size:'tiny' }, (res) => {
if(res.success){
window.tinyPicCache[picid] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
window.tinyPicCache[picid] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/avif' }));
this.setState({image: window.tinyPicCache[picid], loading: false});
}
})
}
}
}
}
handleIncrement = () => {
const { item, onQuantityChange } = this.props;
@@ -75,11 +75,25 @@ class CartItem extends Component {
component="div"
sx={{ fontWeight: 'bold', mb: 0.5 }}
>
{item.seoName ? (
<Link to={`/Artikel/${item.seoName}`} style={{ textDecoration: 'none', color: 'inherit' }}>
{item.name}
</Link>
) : (
item.name
)}
</Typography>
{item.komponenten && Array.isArray(item.komponenten) && (
<Box sx={{ ml: 2, mb: 1 }}>
{item.komponenten.map((comp, index) => (
<Typography key={index} variant="body2" color="text.secondary">
{comp.name}
</Typography>
))}
</Box>
)}
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1, mt: 1 }}>
<Typography
variant="body2"
@@ -116,7 +130,7 @@ class CartItem extends Component {
)}
{item.versandklasse && item.versandklasse != 'standard' && item.versandklasse != 'kostenlos' && (
<Typography variant="body2" color="warning.main" fontWeight="medium" component="div">
{item.versandklasse}
{item.versandklasse == 'nur Abholung' ? this.props.t('delivery.descriptions.pickupOnly') : item.versandklasse}
</Typography>
)}
{item.vat && (
@@ -126,9 +140,9 @@ class CartItem extends Component {
fontStyle="italic"
component="div"
>
inkl. {new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(
{this.props.t ? this.props.t('product.inclShort') : 'inkl.'} {new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(
(item.price * item.quantity) - ((item.price * item.quantity) / (1 + item.vat / 100))
)} MwSt. ({item.vat}%)
)} {this.props.t ? this.props.t('product.vatShort') : 'MwSt.'} ({item.vat}%)
</Typography>
)}
@@ -146,11 +160,14 @@ class CartItem extends Component {
display: "block"
}}
>
{this.props.id.toString().endsWith("steckling") ? "Lieferzeit: 14 Tage" :
item.available == 1 ? "Lieferzeit: 2-3 Tage" :
item.availableSupplier == 1 ? "Lieferzeit: 7-9 Tage" : ""}
{this.props.id?.toString().endsWith("steckling") ?
(this.props.t ? this.props.t('delivery.times.cutting14Days') : "Lieferzeit: 14 Tage") :
item.available == 1 ?
(this.props.t ? this.props.t('delivery.times.standard2to3Days') : "Lieferzeit: 2-3 Tage") :
item.availableSupplier == 1 ?
(this.props.t ? this.props.t('delivery.times.supplier7to9Days') : "Lieferzeit: 7-9 Tage") : ""}
</Typography>
<AddToCartButton available={1} id={this.props.id} availableSupplier={item.availableSupplier} price={item.price} seoName={item.seoName} name={item.name} weight={item.weight} vat={item.vat} versandklasse={item.versandklasse}/>
<AddToCartButton available={1} id={this.props.id} komponenten={item.komponenten} availableSupplier={item.availableSupplier} price={item.price} seoName={item.seoName} name={item.name} weight={item.weight} vat={item.vat} versandklasse={item.versandklasse}/>
</Box>
</Box>
</ListItem>
@@ -159,4 +176,4 @@ class CartItem extends Component {
}
}
export default CartItem;
export default withI18n()(CartItem);

View File

@@ -1,8 +1,8 @@
import React, { useState, useEffect, useContext } from 'react';
import React, { useState, useEffect } from 'react';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import { Link } from 'react-router-dom';
import SocketContext from '../contexts/SocketContext.js';
// @note SwashingtonCP font is now loaded globally via index.css
@@ -16,13 +16,13 @@ const CategoryBox = ({
name,
seoName,
bgcolor,
fontSize = '0.8rem',
fontSize = '1.2rem',
...props
}) => {
const [imageUrl, setImageUrl] = useState(null);
const [imageError, setImageError] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const context = useContext(SocketContext);
useEffect(() => {
let objectUrl = null;
@@ -47,7 +47,7 @@ const CategoryBox = ({
// Create fresh blob URL from cached binary data
try {
const uint8Array = new Uint8Array(cachedImageData);
const blob = new Blob([uint8Array], { type: 'image/jpeg' });
const blob = new Blob([uint8Array], { type: 'image/avif' });
objectUrl = URL.createObjectURL(blob);
setImageUrl(objectUrl);
setImageError(false);
@@ -60,11 +60,10 @@ const CategoryBox = ({
return;
}
// If socket is available and connected, fetch the image
if (context && context.socket && context.socket.connected && id && !isLoading) {
if (id && !isLoading) {
setIsLoading(true);
context.socket.emit('getCategoryPic', { categoryId: id }, (response) => {
window.socketManager.emit('getCategoryPic', { categoryId: id }, (response) => {
setIsLoading(false);
if (response.success) {
@@ -74,7 +73,7 @@ const CategoryBox = ({
try {
// Convert binary data to blob URL
const uint8Array = new Uint8Array(imageData);
const blob = new Blob([uint8Array], { type: 'image/jpeg' });
const blob = new Blob([uint8Array], { type: 'image/avif' });
objectUrl = URL.createObjectURL(blob);
setImageUrl(objectUrl);
setImageError(false);
@@ -119,7 +118,7 @@ const CategoryBox = ({
URL.revokeObjectURL(objectUrl);
}
};
}, [context, context?.socket?.connected, id, isLoading]);
}, [id, isLoading]);
return (
<Paper
@@ -159,7 +158,7 @@ const CategoryBox = ({
position: 'relative',
backgroundImage: ((typeof window !== 'undefined' && window.__PRERENDER_FALLBACK__) ||
(typeof global !== 'undefined' && global.window && global.window.__PRERENDER_FALLBACK__))
? `url("/assets/images/cat${id}.jpg")`
? `url("/assets/images/cat${id}.avif")`
: (imageUrl && !imageError ? `url("${imageUrl}")` : 'none'),
backgroundSize: 'cover',
backgroundPosition: 'center',
@@ -186,7 +185,7 @@ const CategoryBox = ({
fontFamily: 'SwashingtonCP, "Times New Roman", Georgia, serif',
fontWeight: 'normal',
lineHeight: '1.2',
padding: '0 8px'
padding: '12px 8px'
}}>
{name}
</div>

View File

@@ -15,7 +15,13 @@ import StopIcon from '@mui/icons-material/Stop';
import PhotoCameraIcon from '@mui/icons-material/PhotoCamera';
import parse, { domToReact } from 'html-react-parser';
import { Link } from 'react-router-dom';
import MuiLink from '@mui/material/Link';
import { alpha } from '@mui/material/styles';
import TelegramIcon from '@mui/icons-material/Telegram';
import { isUserLoggedIn } from './LoginComponent.js';
import { withTranslation } from '../i18n/withTranslation.js';
const TELEGRAM_ASSISTANT_URL = 'https://t.me/Growheads_de_Bot';
// Initialize window object for storing messages
if (!window.chatMessages) {
window.chatMessages = [];
@@ -47,23 +53,59 @@ class ChatAssistant extends Component {
this.recordingTimer = null;
}
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>`;
};
/** Keep stored privacy bubble in sync with i18n (language switcher, lazy bundle load). */
applyPrivacyPromptTranslation = () => {
this.setState((prev) => {
if (prev.privacyConfirmed) return null;
const idx = prev.messages.findIndex((m) => m.id === 'privacy-prompt');
if (idx === -1) return null;
const updatedMessages = [...prev.messages];
updatedMessages[idx] = {
...updatedMessages[idx],
text: this.buildPrivacyPromptHtml(),
};
window.chatMessages = updatedMessages;
return { messages: updatedMessages };
});
};
handleI18nLanguageChanged = () => {
this.applyPrivacyPromptTranslation();
};
componentDidMount() {
// Add socket listeners if socket is available and connected
this.addSocketListeners();
this.props.i18n?.on('languageChanged', this.handleI18nLanguageChanged);
const userStatus = isUserLoggedIn();
const isGuest = !userStatus.isLoggedIn;
if (isGuest && !this.state.privacyConfirmed) {
this.setState(prevState => {
if (prevState.messages.find(msg => msg.id === 'privacy-prompt')) {
return { isGuest: true };
const updatedMessages = prevState.messages.map((msg) =>
msg.id === 'privacy-prompt'
? { ...msg, text: this.buildPrivacyPromptHtml() }
: msg
);
window.chatMessages = updatedMessages;
return {
messages: updatedMessages,
isGuest: true,
};
}
const privacyMessage = {
id: 'privacy-prompt',
sender: 'bot',
text: 'Bitte bestätigen Sie, dass Sie die <a href="/datenschutz" target="_blank" rel="noopener noreferrer">Datenschutzbestimmungen</a> gelesen haben und damit einverstanden sind. <button data-confirm-privacy="true">Gelesen & Akzeptiert</button>',
text: this.buildPrivacyPromptHtml(),
};
const updatedMessages = [privacyMessage, ...prevState.messages];
window.chatMessages = updatedMessages;
@@ -78,24 +120,16 @@ class ChatAssistant extends Component {
}
componentDidUpdate(prevProps, prevState) {
if (prevProps.i18n?.language !== this.props.i18n?.language) {
this.applyPrivacyPromptTranslation();
}
if (prevState.messages !== this.state.messages || prevState.isTyping !== this.state.isTyping) {
this.scrollToBottom();
}
// Handle socket connection changes
const wasConnected = prevProps.socket && prevProps.socket.connected;
const isNowConnected = this.props.socket && this.props.socket.connected;
if (!wasConnected && isNowConnected) {
// Socket just connected, add listeners
this.addSocketListeners();
} else if (wasConnected && !isNowConnected) {
// Socket just disconnected, remove listeners
this.removeSocketListeners();
}
}
componentWillUnmount() {
this.props.i18n?.off('languageChanged', this.handleI18nLanguageChanged);
this.removeSocketListeners();
this.stopRecording();
if (this.recordingTimer) {
@@ -104,19 +138,18 @@ class ChatAssistant extends Component {
}
addSocketListeners = () => {
if (this.props.socket && this.props.socket.connected) {
// Remove existing listeners first to avoid duplicates
this.removeSocketListeners();
this.props.socket.on('aiassyResponse', this.handleBotResponse);
this.props.socket.on('aiassyStatus', this.handleStateResponse);
}
window.socketManager.on('aiassyResponse', this.handleBotResponse);
window.socketManager.on('aiassyStatus', this.handleStateResponse);
}
removeSocketListeners = () => {
if (this.props.socket) {
this.props.socket.off('aiassyResponse', this.handleBotResponse);
this.props.socket.off('aiassyStatus', this.handleStateResponse);
}
window.socketManager.off('aiassyResponse', this.handleBotResponse);
window.socketManager.off('aiassyStatus', this.handleStateResponse);
}
handleBotResponse = (msgId,response) => {
@@ -194,8 +227,8 @@ class ChatAssistant extends Component {
};
}, () => {
// Emit message to socket server after state is updated
if (userMessage.trim() && this.props.socket && this.props.socket.connected) {
this.props.socket.emit('aiassyMessage', userMessage);
if (userMessage.trim()) {
window.socketManager.emit('aiassyMessage', { message: userMessage, lang: this.props.i18n?.language });
}
});
}
@@ -251,7 +284,7 @@ class ChatAssistant extends Component {
});
} catch (err) {
console.error("Error accessing microphone:", err);
alert("Could not access microphone. Please check your browser permissions.");
alert(this.props.t('chat.micPermissionDenied'));
}
};
@@ -300,12 +333,10 @@ class ChatAssistant extends Component {
reader.onloadend = () => {
const base64Audio = reader.result.split(',')[1];
// Send audio data to server
if (this.props.socket && this.props.socket.connected) {
this.props.socket.emit('aiassyAudioMessage', {
window.socketManager.emit('aiassyAudioMessage', {
audio: base64Audio,
format: 'wav'
});
}
};
};
@@ -368,7 +399,7 @@ class ChatAssistant extends Component {
const newUserMessage = {
id: Date.now(),
sender: 'user',
text: `<img src="${imageUrl}" alt="Uploaded image" style="max-width: 100%; height: auto; border-radius: 8px;" />`,
text: `<img src="${imageUrl}" alt="${this.props.t('chat.uploadedImageAlt')}" style="max-width: 100%; height: auto; border-radius: 8px;" />`,
isImage: true
};
@@ -389,12 +420,12 @@ class ChatAssistant extends Component {
reader.onloadend = () => {
const base64Image = reader.result.split(',')[1];
// Send image data to server
if (this.props.socket && this.props.socket.connected) {
this.props.socket.emit('aiassyPicMessage', {
window.socketManager.emit('aiassyPicMessage', {
image: base64Image,
format: 'jpeg'
});
}
};
};
@@ -460,14 +491,15 @@ class ChatAssistant extends Component {
}
if (domNode.name === 'button' && domNode.attribs && domNode.attribs['data-confirm-privacy']) {
return <Button variant="contained" size="small" onClick={this.handlePrivacyConfirm}>Gelesen & Akzeptiert</Button>;
return <Button variant="contained" size="small" onClick={this.handlePrivacyConfirm}>{this.props.t('chat.privacyRead')}</Button>;
}
}
});
render() {
const { open, onClose } = this.props;
const { open, onClose, t } = this.props;
const { messages, inputValue, isTyping, isRecording, recordingTime, isGuest, privacyConfirmed } = this.state;
const showTelegramHint = !messages.some((m) => m.sender === 'user');
if (!open) {
return null;
@@ -480,16 +512,16 @@ class ChatAssistant extends Component {
elevation={4}
sx={{
position: 'fixed',
bottom: { xs: 16, sm: 80 },
right: { xs: 16, sm: 16 },
left: { xs: 16, sm: 'auto' },
top: { xs: 16, sm: 'auto' },
width: { xs: 'calc(100vw - 32px)', sm: 450, md: 600, lg: 750 },
height: { xs: 'calc(100vh - 32px)', sm: 600, md: 650, lg: 700 },
bottom: { xs: 0, sm: 80 },
right: { xs: 0, sm: 16 },
left: { xs: 0, sm: 'auto' },
top: { xs: 0, sm: 'auto' },
width: { xs: '100vw', sm: 450, md: 600, lg: 750 },
height: { xs: '100vh', sm: 600, md: 650, lg: 700 },
maxWidth: { xs: 'none', sm: 450, md: 600, lg: 750 },
maxHeight: { xs: 'calc(100vh - 72px)', sm: 600, md: 650, lg: 700 },
maxHeight: { xs: '100vh', sm: 600, md: 650, lg: 700 },
bgcolor: 'background.paper',
borderRadius: 2,
borderRadius: { xs: 0, sm: 2 },
display: 'flex',
flexDirection: 'column',
zIndex: 1300,
@@ -513,12 +545,12 @@ class ChatAssistant extends Component {
}}
>
<Typography variant="h6" component="div">
Assistent
{t('chat.assistantTitle')}
<Typography component="span" color={this.state.aiThink ? "error" : "text.disabled"} sx={{ display: 'inline' }}>🧠</Typography>
<Typography component="span" color={this.state.atDatabase ? "error" : "text.disabled"} sx={{ display: 'inline' }}>🛢</Typography>
<Typography component="span" color={this.state.atWeb ? "error" : "text.disabled"} sx={{ display: 'inline' }}>🌐</Typography>
</Typography>
<IconButton onClick={onClose} size="small" sx={{ color: 'primary.contrastText' }}>
<IconButton onClick={onClose} size="small" aria-label={t('chat.closeAria')} sx={{ color: 'primary.contrastText' }}>
<CloseIcon />
</IconButton>
</Box>
@@ -532,6 +564,58 @@ class ChatAssistant extends Component {
gap: 2,
}}
>
{showTelegramHint && (
<Paper
elevation={4}
sx={{
p: 2,
borderRadius: 2,
border: 2,
borderColor: 'primary.main',
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.14),
boxShadow: (theme) =>
`0 4px 14px ${alpha(theme.palette.primary.main, 0.35)}`,
}}
>
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'flex-start' }}>
<TelegramIcon
sx={{
fontSize: 40,
color: 'primary.main',
flexShrink: 0,
filter: (theme) =>
`drop-shadow(0 1px 2px ${alpha(theme.palette.primary.dark, 0.45)})`,
}}
/>
<Box sx={{ minWidth: 0 }}>
<Typography
variant="subtitle1"
component="div"
fontWeight={700}
color="text.primary"
sx={{ lineHeight: 1.45, mb: 0.25 }}
>
{t('chat.telegramAssistantIntro')}
</Typography>
<MuiLink
href={TELEGRAM_ASSISTANT_URL}
target="_blank"
rel="noopener noreferrer"
variant="subtitle1"
fontWeight={800}
sx={{
wordBreak: 'break-all',
color: 'primary.dark',
textDecorationColor: 'primary.main',
'&:hover': { color: 'primary.main' },
}}
>
{t('chat.telegramAssistantLink')}
</MuiLink>
</Box>
</Box>
</Paper>
)}
{messages &&messages.map((message) => (
<Box
key={message.id}
@@ -581,6 +665,8 @@ class ChatAssistant extends Component {
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
gap: { xs: 1, sm: 0 },
p: 1,
borderTop: 1,
borderColor: 'divider',
@@ -602,7 +688,7 @@ class ChatAssistant extends Component {
autoFocus
autoCapitalize="off"
autoCorrect="off"
placeholder={isRecording ? "Aufnahme läuft..." : "Du kannst mich nach Cannabissorten fragen..."}
placeholder={isRecording ? t('chat.placeholderRecording') : t('chat.inputPlaceholder')}
value={inputValue}
onChange={this.handleInputChange}
onKeyDown={this.handleKeyDown}
@@ -619,11 +705,13 @@ class ChatAssistant extends Component {
}}
/>
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
{isRecording ? (
<IconButton
color="error"
onClick={this.stopRecording}
sx={{ ml: 1 }}
aria-label={t('chat.micStopAria')}
sx={{ ml: { xs: 0, sm: 1 } }}
>
<StopIcon />
</IconButton>
@@ -631,7 +719,8 @@ class ChatAssistant extends Component {
<IconButton
color="primary"
onClick={this.startRecording}
sx={{ ml: 1 }}
aria-label={t('chat.micStartAria')}
sx={{ ml: { xs: 0, sm: 1 } }}
disabled={isTyping || inputsDisabled}
>
<MicIcon />
@@ -641,7 +730,8 @@ class ChatAssistant extends Component {
<IconButton
color="primary"
onClick={this.handleImageUpload}
sx={{ ml: 1 }}
aria-label={t('chat.uploadImageAria')}
sx={{ ml: { xs: 0, sm: 1 } }}
disabled={isTyping || isRecording || inputsDisabled}
>
<PhotoCameraIcon />
@@ -649,16 +739,17 @@ class ChatAssistant extends Component {
<Button
variant="contained"
sx={{ ml: 1 }}
sx={{ ml: { xs: 0, sm: 1 } }}
onClick={this.handleSendMessage}
disabled={isTyping || isRecording || inputsDisabled}
>
Senden
{t('chat.send')}
</Button>
</Box>
</Box>
</Paper>
);
}
}
export default ChatAssistant;
export default withTranslation()(ChatAssistant);

View File

@@ -13,6 +13,8 @@ import CategoryBox from './CategoryBox.js';
import { useParams, useSearchParams } from 'react-router-dom';
import { getAllSettingsWithPrefix } from '../utils/sessionStorage.js';
import { withI18n } from '../i18n/withTranslation.js';
import { withCategory } from '../context/CategoryContext.js';
const isNew = (neu) => neu && (new Date().getTime() - new Date(neu).getTime() < 30 * 24 * 60 * 60 * 1000);
@@ -26,13 +28,13 @@ const withRouter = (ClassComponent) => {
};
};
function getCachedCategoryData(categoryId) {
function getCachedCategoryData(categoryId, language = 'de') {
if (!window.productCache) {
window.productCache = {};
}
try {
const cacheKey = `categoryProducts_${categoryId}`;
const cacheKey = `categoryProducts_${categoryId}_${language}`;
const cachedData = window.productCache[cacheKey];
if (cachedData) {
@@ -52,7 +54,7 @@ function getCachedCategoryData(categoryId) {
function getFilteredProducts(unfilteredProducts, attributes) {
function getFilteredProducts(unfilteredProducts, attributes, t) {
const attributeSettings = getAllSettingsWithPrefix('filter_attribute_');
const manufacturerSettings = getAllSettingsWithPrefix('filter_manufacturer_');
const availabilitySettings = getAllSettingsWithPrefix('filter_availability_');
@@ -149,22 +151,22 @@ function getFilteredProducts(unfilteredProducts, attributes) {
// Check for "auf Lager" filter (in stock) - it's active when filter_availability is NOT set to '1'
if (availabilityFilter !== '1') {
activeAvailabilityFilters.push({id: '1', name: 'auf Lager'});
activeAvailabilityFilters.push({ id: '1', name: t ? t('product.inStock') : 'auf Lager' });
}
// Check for "Neu" filter (new) - only show if there are actually new products and filter is active
if (availabilityFilters.includes('2') && hasNewProducts) {
activeAvailabilityFilters.push({id: '2', name: 'Neu'});
activeAvailabilityFilters.push({ id: '2', name: t ? t('product.new') : 'Neu' });
}
// Check for "Bald verfügbar" filter (coming soon) - only show if there are actually coming soon products and filter is active
if (availabilityFilters.includes('3') && hasComingSoonProducts) {
activeAvailabilityFilters.push({id: '3', name: 'Bald verfügbar'});
activeAvailabilityFilters.push({ id: '3', name: t ? t('product.comingSoon') : 'Bald verfügbar' });
}
return { filteredProducts, activeAttributeFilters: activeAttributeFiltersWithNames, activeManufacturerFilters: activeManufacturerFiltersWithNames, activeAvailabilityFilters };
}
function setCachedCategoryData(categoryId, data) {
function setCachedCategoryData(categoryId, data, language = 'de') {
if (!window.productCache) {
window.productCache = {};
}
@@ -173,9 +175,10 @@ function setCachedCategoryData(categoryId, data) {
}
try {
const cacheKey = `categoryProducts_${categoryId}`;
const cacheKey = `categoryProducts_${categoryId}_${language}`;
if (data.products) for (const product of data.products) {
window.productDetailCache[product.id] = product;
const productCacheKey = `product_${product.id}_${language}`;
window.productDetailCache[productCacheKey] = product;
}
window.productCache[cacheKey] = {
...data,
@@ -196,67 +199,115 @@ class Content extends Component {
unfilteredProducts: [],
filteredProducts: [],
attributes: [],
childCategories: []
childCategories: [],
lastFetchedLanguage: props.i18n?.language || 'de'
};
}
componentDidMount() {
if(this.props.params.categoryId) {this.setState({loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: []}, () => {
const currentLanguage = this.props.i18n?.language || 'de';
if (this.props.params.categoryId) {
this.setState({ loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: [], lastFetchedLanguage: currentLanguage }, () => {
this.fetchCategoryData(this.props.params.categoryId);
})}
})
}
else if (this.props.searchParams?.get('q')) {
this.setState({loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: []}, () => {
this.setState({ loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: [], lastFetchedLanguage: currentLanguage }, () => {
this.fetchSearchData(this.props.searchParams?.get('q'));
})
}
}
componentDidUpdate(prevProps) {
if(this.props.params.categoryId && (prevProps.params.categoryId !== this.props.params.categoryId)) {
const currentLanguage = this.props.i18n?.language || 'de';
const categoryChanged = this.props.params.categoryId && (prevProps.params.categoryId !== this.props.params.categoryId);
const searchChanged = this.props.searchParams?.get('q') && (prevProps.searchParams?.get('q') !== this.props.searchParams?.get('q'));
if (categoryChanged) {
// Clear context for new category loading
if (this.props.categoryContext && this.props.categoryContext.setCurrentCategory) {
this.props.categoryContext.setCurrentCategory(null);
}
window.currentSearchQuery = null;
this.setState({loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: []}, () => {
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
}
else if (this.props.searchParams?.get('q') && (prevProps.searchParams?.get('q') !== this.props.searchParams?.get('q'))) {
this.setState({loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: []}, () => {
else if (searchChanged) {
this.setState({ loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: [], lastFetchedLanguage: currentLanguage }, () => {
this.fetchSearchData(this.props.searchParams?.get('q'));
})
});
return; // Don't check language change if search changed
}
// Handle socket connection changes
const wasConnected = prevProps.socket && prevProps.socket.connected;
const isNowConnected = this.props.socket && this.props.socket.connected;
// Re-fetch products when language changes to get translated content
const languageChanged = currentLanguage !== this.state.lastFetchedLanguage;
if (!wasConnected && isNowConnected && !this.state.loaded) {
// Socket just connected and we haven't loaded data yet, retry loading
console.log('Content componentDidUpdate:', {
languageChanged,
lastFetchedLang: this.state.lastFetchedLanguage,
currentLang: currentLanguage,
prevPropsLang: prevProps.i18n?.language,
hasCategoryId: !!this.props.params.categoryId,
categoryId: this.props.params.categoryId,
hasSearchQuery: !!this.props.searchParams?.get('q')
});
if (languageChanged) {
console.log('Content: Language changed! Re-fetching data...');
// Re-fetch current data with new language
// Note: Language is now part of the cache key, so it will automatically fetch fresh data
if (this.props.params.categoryId) {
// Re-fetch category data with new language
console.log('Content: Re-fetching category', this.props.params.categoryId);
this.setState({ loaded: false, lastFetchedLanguage: currentLanguage }, () => {
this.fetchCategoryData(this.props.params.categoryId);
});
} else if (this.props.searchParams?.get('q')) {
// Re-fetch search data with new language
console.log('Content: Re-fetching search', this.props.searchParams?.get('q'));
this.setState({ loaded: false, lastFetchedLanguage: currentLanguage }, () => {
this.fetchSearchData(this.props.searchParams?.get('q'));
});
} else {
// If not viewing category or search, just re-filter existing products
console.log('Content: Just re-filtering existing products');
this.setState({ lastFetchedLanguage: currentLanguage });
this.filterProducts();
}
}
}
processData(response) {
const unfilteredProducts = response.products;
const rawProducts = response.products;
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
if (!window.individualProductCache) {
window.individualProductCache = {};
}
//console.log("processData", unfilteredProducts);
if(unfilteredProducts) unfilteredProducts.forEach(product => {
window.individualProductCache[product.id] = {
data: product,
const unfilteredProducts = [];
//console.log("processData", rawProducts);
if (rawProducts) rawProducts.forEach(product => {
const effectiveProduct = product.translatedProduct || product;
const cacheKey = `${effectiveProduct.id}_${currentLanguage}`;
window.individualProductCache[cacheKey] = {
data: effectiveProduct,
timestamp: Date.now()
};
unfilteredProducts.push(effectiveProduct);
});
this.setState({
unfilteredProducts: unfilteredProducts,
...getFilteredProducts(
unfilteredProducts,
response.attributes
response.attributes,
this.props.t
),
categoryName: response.categoryName || response.name || null,
dataType: response.dataType,
@@ -264,34 +315,52 @@ class Content extends Component {
attributes: response.attributes,
childCategories: response.childCategories || [],
loaded: true
}, () => {
console.log('Content: processData finished', {
hasContext: !!this.props.categoryContext,
categoryName: response.categoryName,
name: response.name
});
if (this.props.categoryContext && this.props.categoryContext.setCurrentCategory) {
if (response.categoryName || response.name) {
console.log('Content: Setting category context');
this.props.categoryContext.setCurrentCategory({
id: this.props.params.categoryId,
name: response.categoryName || response.name
});
} else {
console.log('Content: No category name found to set in context');
}
} else {
console.warn('Content: categoryContext prop is missing!');
}
});
}
fetchCategoryData(categoryId) {
const cachedData = getCachedCategoryData(categoryId);
if (categoryId === 'bald') {
sessionStorage.setItem('filter_availability', '1');
}
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
const cachedData = getCachedCategoryData(categoryId, currentLanguage);
if (cachedData) {
this.processDataWithCategoryTree(cachedData, categoryId);
return;
}
if (!this.props.socket || !this.props.socket.connected) {
// Socket not connected yet, but don't show error immediately on first load
// The componentDidUpdate will retry when socket connects
console.log("Socket not connected yet, waiting for connection to fetch category data");
return;
}
console.log(`productList:${categoryId}`);
this.props.socket.off(`productList:${categoryId}`);
window.socketManager.off(`productList:${categoryId}`);
// Track if we've received the full response to ignore stub response if needed
let receivedFullResponse = false;
this.props.socket.on(`productList:${categoryId}`,(response) => {
window.socketManager.on(`productList:${categoryId}`, (response) => {
console.log("getCategoryProducts full response", response);
receivedFullResponse = true;
setCachedCategoryData(categoryId, response);
setCachedCategoryData(categoryId, response, currentLanguage);
if (response && response.products !== undefined) {
this.processDataWithCategoryTree(response, categoryId);
} else {
@@ -299,12 +368,14 @@ class Content extends Component {
}
});
this.props.socket.emit("getCategoryProducts", { categoryId: categoryId },
window.socketManager.emit(
"getCategoryProducts",
{ categoryId: categoryId, language: currentLanguage, requestTranslation: currentLanguage === 'de' ? false : true },
(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);
setCachedCategoryData(categoryId, response, currentLanguage);
if (response && response.products !== undefined) {
this.processDataWithCategoryTree(response, categoryId);
} else {
@@ -318,15 +389,17 @@ class Content extends Component {
}
processDataWithCategoryTree(response, categoryId) {
console.log("---------------processDataWithCategoryTree", response, categoryId);
// Get child categories from the cached category tree
let childCategories = [];
try {
const categoryTreeCache = window.productCache && window.productCache['categoryTree_209'];
if (categoryTreeCache && categoryTreeCache.categoryTree) {
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
const categoryTreeCache = window.categoryService.getSync(209, currentLanguage);
if (categoryTreeCache) {
// If categoryId is a string (SEO name), find by seoName, otherwise by ID
const targetCategory = typeof categoryId === 'string'
? this.findCategoryBySeoName(categoryTreeCache.categoryTree, categoryId)
: this.findCategoryById(categoryTreeCache.categoryTree, categoryId);
? this.findCategoryBySeoName(categoryTreeCache, categoryId)
: this.findCategoryById(categoryTreeCache, categoryId);
if (targetCategory && targetCategory.children) {
childCategories = targetCategory.children;
@@ -342,6 +415,62 @@ class Content extends Component {
childCategories
};
// Attempt to set category name from the tree if missing in response
if (!enhancedResponse.categoryName && !enhancedResponse.name) {
// Try to find name in the tree using the ID or SEO name
try {
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
const categoryTreeCache = window.categoryService.getSync(209, currentLanguage);
if (categoryTreeCache) {
const targetCategory = typeof categoryId === 'string'
? this.findCategoryBySeoName(categoryTreeCache, categoryId)
: this.findCategoryById(categoryTreeCache, categoryId);
if (targetCategory && targetCategory.name) {
enhancedResponse.categoryName = targetCategory.name;
}
}
} catch (err) {
console.error('Error finding category name in tree:', err);
}
if (!enhancedResponse.categoryName && !enhancedResponse.name) {
if (categoryId === 'neu') {
enhancedResponse.categoryName = this.props.t
? this.props.t('navigation.new')
: 'Neuheiten';
} else if (categoryId === 'bald') {
enhancedResponse.categoryName = this.props.t
? this.props.t('navigation.soon')
: 'Demnächst';
}
}
}
// JTL kKategorie for category push: backend may omit dataParam — resolve from tree (same id as product list)
const isValidJtlCategoryId = (v) => {
if (v == null || v === '') return false;
const n = typeof v === 'number' ? v : parseInt(String(v), 10);
return Number.isFinite(n) && n > 0;
};
if (categoryId !== 'neu' && categoryId !== 'bald' && !isValidJtlCategoryId(enhancedResponse.dataParam)) {
try {
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
const categoryTreeCache = window.categoryService.getSync(209, currentLanguage);
if (categoryTreeCache) {
const targetCategory = typeof categoryId === 'string'
? this.findCategoryBySeoName(categoryTreeCache, categoryId)
: this.findCategoryById(categoryTreeCache, categoryId);
if (targetCategory && typeof targetCategory.id === 'number' && targetCategory.id > 0) {
enhancedResponse.dataParam = targetCategory.id;
}
}
} catch (err) {
console.error('Error resolving dataParam from category tree:', err);
}
}
this.processData(enhancedResponse);
}
@@ -363,17 +492,18 @@ class Content extends Component {
}
fetchSearchData(query) {
if (!this.props.socket || !this.props.socket.connected) {
// Socket not connected yet, but don't show error immediately on first load
// The componentDidUpdate will retry when socket connects
console.log("Socket not connected yet, waiting for connection to fetch search data");
return;
}
this.props.socket.emit("getSearchProducts", { query },
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
window.socketManager.emit(
"getSearchProducts",
{ query, language: currentLanguage, requestTranslation: currentLanguage === 'de' ? false : true },
(response) => {
if (response && response.products) {
this.processData(response);
// Map products to use translatedProduct if available
const enhancedResponse = {
...response,
products: response.products.map(p => p.translatedProduct || p)
};
this.processData(enhancedResponse);
} else {
console.log("fetchSearchData in Content failed", response);
}
@@ -385,7 +515,8 @@ class Content extends Component {
this.setState({
...getFilteredProducts(
this.state.unfilteredProducts,
this.state.attributes
this.state.attributes,
this.props.t
)
});
}
@@ -413,28 +544,30 @@ class Content extends Component {
const seoName = this.props.params.categoryId;
// Get the category tree from cache
const categoryTreeCache = window.productCache && window.productCache['categoryTree_209'];
if (!categoryTreeCache || !categoryTreeCache.categoryTree) {
return null;
}
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
const categoryTreeCache = window.categoryService.getSync(209, currentLanguage);
// Find the category by seoName
const category = this.findCategoryBySeoName(categoryTreeCache.categoryTree, seoName);
const category = this.findCategoryBySeoName(categoryTreeCache, seoName);
return category ? category.id : null;
}
componentWillUnmount() {
if (this.props.categoryContext && this.props.categoryContext.setCurrentCategory) {
this.props.categoryContext.setCurrentCategory(null);
}
}
renderParentCategoryNavigation = () => {
const currentCategoryId = this.getCurrentCategoryId();
if (!currentCategoryId) return null;
// Get the category tree from cache
const categoryTreeCache = window.productCache && window.productCache['categoryTree_209'];
if (!categoryTreeCache || !categoryTreeCache.categoryTree) {
return null;
}
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
const categoryTreeCache = window.categoryService.getSync(209, currentLanguage);
// Find the current category in the tree
const currentCategory = this.findCategoryById(categoryTreeCache.categoryTree, currentCategoryId);
const currentCategory = this.findCategoryById(categoryTreeCache, currentCategoryId);
if (!currentCategory) {
return null;
}
@@ -445,7 +578,7 @@ class Content extends Component {
}
// Find the parent category
const parentCategory = this.findCategoryById(categoryTreeCache.categoryTree, currentCategory.parentId);
const parentCategory = this.findCategoryById(categoryTreeCache, currentCategory.parentId);
if (!parentCategory) {
return null;
}
@@ -463,11 +596,14 @@ class Content extends Component {
}
render() {
// console.log('Content props:', this.props);
// Check if we should show category boxes instead of product list
const showCategoryBoxes = this.state.loaded &&
this.state.unfilteredProducts.length === 0 &&
this.state.childCategories.length > 0;
console.log("showCategoryBoxes", showCategoryBoxes, this.state.unfilteredProducts.length, this.state.childCategories.length);
return (
<Container maxWidth="xl" sx={{ py: { xs: 0, sm: 2 }, px: { xs: 0, sm: 3 }, flexGrow: 1, display: 'grid', gridTemplateRows: '1fr' }}>
@@ -513,7 +649,8 @@ class Content extends Component {
height: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
justifyContent: 'center',
pointerEvents: 'none'
}}>
<KeyboardArrowUpIcon sx={{ color: 'white', fontSize: '1.2rem' }} />
</Box>
@@ -562,7 +699,8 @@ class Content extends Component {
height: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
justifyContent: 'center',
pointerEvents: 'none'
}}>
<KeyboardArrowUpIcon sx={{ color: 'white', fontSize: '1.2rem' }} />
</Box>
@@ -586,7 +724,7 @@ class Content extends Component {
minHeight: { xs: 'min-content', sm: '100%' }
}}>
<Box >
<Box sx={{ overflow: 'visible', minWidth: 0 }}>
<ProductFilters
products={this.state.unfilteredProducts}
@@ -596,13 +734,14 @@ class Content extends Component {
onFilterChange={() => { this.filterProducts() }}
dataType={this.state.dataType}
dataParam={this.state.dataParam}
categoryName={this.state.categoryName}
/>
</Box>
{(this.getCurrentCategoryId() == 706 || this.getCurrentCategoryId() == 689) &&
{(this.props.params.categoryId == 'Stecklinge___' || this.props.params.categoryId == 'Seeds___') &&
<Box sx={{ display: { xs: 'none', sm: 'block' } }}>
<Typography variant="h6" sx={{ mt: 3 }}>
Andere Kategorien
{this.props.t ? this.props.t('navigation.otherCategories') : 'Andere Kategorien'}
</Typography>
</Box>
}
@@ -631,12 +770,26 @@ class Content extends Component {
<Box sx={{
height: '100%',
bgcolor: '#e1f0d3',
backgroundImage: 'url("/assets/images/seeds.jpg")',
backgroundSize: 'contain',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
position: 'relative'
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<img
src="/assets/images/seeds.avif"
alt="Seeds"
fetchPriority="high"
loading="eager"
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)'
}}
/>
{/* Overlay text - optional */}
<Box sx={{
position: 'absolute',
@@ -647,14 +800,14 @@ class Content extends Component {
p: 2,
}}>
<Typography sx={{ fontSize: '1.3rem', color: 'white', fontFamily: 'SwashingtonCP' }}>
Seeds
{this.props.t('sections.seeds')}
</Typography>
</Box>
</Box>
</Paper>
}
{this.props.params.categoryId == 'Seeds' && <Paper
{this.props.params.categoryId == 'Seeds___' && <Paper
component={Link}
to="/Kategorie/Stecklinge"
sx={{
@@ -678,12 +831,26 @@ class Content extends Component {
<Box sx={{
height: '100%',
bgcolor: '#e8f5d6',
backgroundImage: 'url("/assets/images/cutlings.jpg")',
backgroundSize: 'contain',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
position: 'relative'
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<img
src="/assets/images/cutlings.avif"
alt="Stecklinge"
fetchPriority="high"
loading="eager"
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)'
}}
/>
{/* Overlay text - optional */}
<Box sx={{
position: 'absolute',
@@ -694,7 +861,7 @@ class Content extends Component {
p: 2,
}}>
<Typography sx={{ fontSize: '1.3rem', color: 'white', fontFamily: 'SwashingtonCP' }}>
Stecklinge
{this.props.t('sections.stecklinge')}
</Typography>
</Box>
</Box>
@@ -703,8 +870,6 @@ class Content extends Component {
<Box>
<ProductList
socket={this.props.socket}
socketB={this.props.socketB}
totalProductCount={(this.state.unfilteredProducts || []).length}
products={this.state.filteredProducts || []}
activeAttributeFilters={this.state.activeAttributeFilters || []}
@@ -723,4 +888,4 @@ class Content extends Component {
}
}
export default withRouter(Content);
export default withRouter(withI18n()(withCategory(Content)));

View File

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

View File

@@ -6,6 +6,7 @@ import Link from '@mui/material/Link';
import { Link as RouterLink } from 'react-router-dom';
import { styled } from '@mui/material/styles';
import Paper from '@mui/material/Paper';
import { withI18n } from '../i18n/withTranslation.js';
// Styled component for the router links
const StyledRouterLink = styled(RouterLink)(() => ({
@@ -15,6 +16,7 @@ const StyledRouterLink = styled(RouterLink)(() => ({
lineHeight: '1.5',
display: 'block',
padding: '4px 8px',
whiteSpace: 'nowrap',
'&:hover': {
textDecoration: 'underline',
},
@@ -222,31 +224,19 @@ 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"
>
<StyledRouterLink to="/datenschutz">Datenschutz</StyledRouterLink>
<StyledRouterLink to="/agb">AGB</StyledRouterLink>
<StyledRouterLink to="/sitemap">Sitemap</StyledRouterLink>
<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"
>
<StyledRouterLink to="/impressum">Impressum</StyledRouterLink>
<StyledRouterLink to="/batteriegesetzhinweise">Batteriegesetzhinweise</StyledRouterLink>
<StyledRouterLink to="/widerrufsrecht">Widerrufsrecht</StyledRouterLink>
<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>
</Stack>
{/* Payment Methods Section */}
{/* Payment Methods Section
<Stack
direction="column"
spacing={1}
@@ -263,7 +253,7 @@ class Footer extends Component {
<Box component="img" src="/assets/images/cards.png" alt="Cash" sx={{ height: { xs: 80, md: 95 } }} />
</Stack>
</Stack>
*/}
{/* Google Services Badge Section */}
<Stack
direction="column"
@@ -274,9 +264,9 @@ class Footer extends Component {
<Stack
direction="row"
spacing={{ xs: 1, md: 2 }}
sx={{pb: '10px'}}
sx={{pt: '10px', height: { xs: 50, md: 60 }, transform: 'translateY(-3px)'}}
justifyContent="center"
alignItems="center"
alignItems="flex-end"
>
<Link
href="https://reviewthis.biz/growheads"
@@ -285,17 +275,21 @@ class Footer extends Component {
sx={{
textDecoration: 'none',
position: 'relative',
zIndex: 9999
zIndex: 9999,
display: 'inline-block',
height: { xs: 57, md: 67 },
lineHeight: 1
}}
onMouseEnter={this.handleReviewsMouseEnter}
onMouseLeave={this.handleReviewsMouseLeave}
>
<Box
component="img"
src="/assets/images/gg.png"
src="/assets/images/gg.avif"
alt="Google Reviews"
sx={{
height: { xs: 50, md: 60 },
width: { xs: 105, md: 126 },
cursor: 'pointer',
transition: 'all 2s ease',
'&:hover': {
@@ -311,17 +305,21 @@ class Footer extends Component {
sx={{
textDecoration: 'none',
position: 'relative',
zIndex: 9999
zIndex: 9999,
display: 'inline-block',
height: { xs: 47, md: 67 },
lineHeight: 1
}}
onMouseEnter={this.handleMapsMouseEnter}
onMouseLeave={this.handleMapsMouseLeave}
>
<Box
component="img"
src="/assets/images/maps.png"
src="/assets/images/maps.avif"
alt="Google Maps"
sx={{
height: { xs: 40, md: 50 },
width: { xs: 38, md: 49 },
cursor: 'pointer',
transition: 'all 2s ease',
filter: 'drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3))',
@@ -336,9 +334,44 @@ class Footer extends Component {
</Stack>
{/* Copyright Section */}
<Box sx={{ pb:'20px',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 }}>
* Alle Preise inkl. gesetzlicher USt., zzgl. Versand
<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, whiteSpace: 'nowrap' }}>
{this.props.t ? this.props.t('footer.allPricesIncl') : '* Alle Preise inkl. gesetzlicher USt., zzgl. Versand'}
</Typography>
<Typography
variant="body2"
sx={{
mb: 1,
fontSize: { xs: '11px', md: '14px' },
lineHeight: 1.5,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: 0.75,
}}
>
Made with
<Box
component="span"
sx={{
backgroundColor: '#1976d2',
color: '#ffffff',
borderRadius: '3px',
px: 0.6,
py: 0.15,
fontWeight: 700,
lineHeight: 1,
fontSize: { xs: '10px', md: '12px' },
letterSpacing: '0.3px',
display: 'inline-flex',
alignItems: 'center',
}}
>
jB
</Box>
<StyledDomainLink href="https://jbuddy.de" target="_blank" rel="noopener noreferrer">
jBuddy.de
</StyledDomainLink>
</Typography>
<Typography variant="body2" sx={{ fontSize: { xs: '11px', md: '14px' }, lineHeight: 1.5 }}>
© {new Date().getFullYear()} <StyledDomainLink href="https://growheads.de" target="_blank" rel="noopener noreferrer">GrowHeads.de</StyledDomainLink>
@@ -351,4 +384,4 @@ class Footer extends Component {
}
}
export default Footer;
export default withI18n()(Footer);

View File

@@ -2,6 +2,7 @@ import React, { Component } from 'react';
import Button from '@mui/material/Button';
import GoogleIcon from '@mui/icons-material/Google';
import GoogleAuthContext from '../contexts/GoogleAuthContext.js';
// import { withI18n } from '../i18n/withTranslation.js'; // Temporarily commented out for debugging
class GoogleLoginButton extends Component {
static contextType = GoogleAuthContext;
@@ -186,16 +187,19 @@ class GoogleLoginButton extends Component {
};
render() {
const { disabled, style, className, text = 'Mit Google anmelden' } = this.props;
const { disabled, style, className, text = 'Loading...'} = this.props;
const { isInitializing, isPrompting } = this.state;
const isLoading = isInitializing || isPrompting || (this.context && !this.context.isLoaded);
return (
<Button
variant="contained"
startIcon={<GoogleIcon />}
onClick={this.handleClick}
disabled={disabled || isLoading}
fullWidth
style={{backgroundColor: '#4285F4', color: 'white', ...style }}
className={className}
>
@@ -205,4 +209,4 @@ class GoogleLoginButton extends Component {
}
}
export default GoogleLoginButton;
export default GoogleLoginButton; // Temporarily removed withI18n for debugging

View File

@@ -4,7 +4,6 @@ import Toolbar from '@mui/material/Toolbar';
import Container from '@mui/material/Container';
import Box from '@mui/material/Box';
import SocketContext from '../contexts/SocketContext.js';
import { useLocation } from 'react-router-dom';
// Import extracted components
@@ -12,7 +11,6 @@ import { Logo, SearchBar, ButtonGroupWithRouter, CategoryList } from './header/i
// Main Header Component
class Header extends Component {
static contextType = SocketContext;
constructor(props) {
super(props);
@@ -36,9 +34,8 @@ class Header extends Component {
};
render() {
// Get socket directly from context in render method
const {socket,socketB} = this.context;
const { isHomePage, isProfilePage } = this.props;
const { isHomePage, isProfilePage, isAktionenPage, isFilialePage } = this.props;
return (
<AppBar position="sticky" color="primary" elevation={0} sx={{ zIndex: 1100 }}>
@@ -75,7 +72,7 @@ class Header extends Component {
transform: { xs: 'translateY(4px) translateX(9px)', sm: 'none' },
ml: { xs: 0, sm: 0 }
}}>
<ButtonGroupWithRouter socket={socket}/>
<ButtonGroupWithRouter/>
</Box>
</Box>
@@ -94,7 +91,9 @@ class Header extends Component {
</Box>
</Container>
</Toolbar>
{(isHomePage || this.props.categoryId || isProfilePage) && <CategoryList categoryId={209} activeCategoryId={this.props.categoryId} socket={socket} socketB={socketB} />}
{(isHomePage || this.props.categoryId || isProfilePage || isAktionenPage || isFilialePage || this.props.isArtikel) && (
<CategoryList categoryId={209} activeCategoryId={this.props.categoryId} pathname={this.props.pathname} />
)}
</AppBar>
);
}
@@ -105,11 +104,20 @@ const HeaderWithContext = (props) => {
const location = useLocation();
const isHomePage = location.pathname === '/';
const isProfilePage = location.pathname === '/profile';
const isAktionenPage = location.pathname === '/aktionen';
const isFilialePage = location.pathname === '/filiale';
const isArtikel = location.pathname.startsWith('/Artikel/');
return (
<SocketContext.Consumer>
{({socket,socketB}) => <Header {...props} socket={socket} socketB={socketB} isHomePage={isHomePage} isProfilePage={isProfilePage} />}
</SocketContext.Consumer>
<Header
{...props}
isHomePage={isHomePage}
isArtikel={isArtikel}
isProfilePage={isProfilePage}
isAktionenPage={isAktionenPage}
isFilialePage={isFilialePage}
pathname={location.pathname}
/>
);
};

View File

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

View File

@@ -13,8 +13,6 @@ class Images extends Component {
constructor(props) {
super(props);
this.state = { mainPic:0,pics:[] };
console.log('Images constructor',props);
}
componentDidMount () {
@@ -25,6 +23,9 @@ class Images extends Component {
this.updatePics();
}
}
componentWillUnmount() {
window.productImageUrl = null;
}
updatePics = (newMainPic = this.state.mainPic) => {
if (!window.tinyPicCache) window.tinyPicCache = {};
@@ -41,6 +42,7 @@ class Images extends Component {
for(const bildId of bildIds){
if(bildId == mainPicId){
if(window.productImageUrl) continue;
if(window.largePicCache[bildId]){
pics.push(window.largePicCache[bildId]);
@@ -51,10 +53,10 @@ class Images extends Component {
pics.push(window.smallPicCache[bildId]);
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
}else if(window.tinyPicCache[bildId]){
pics.push(bildId);
pics.push(window.tinyPicCache[bildId]);
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
}else{
pics.push(bildId);
pics.push(`/assets/images/prod${bildId}.avif`);
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
}
}else{
@@ -69,7 +71,8 @@ class Images extends Component {
}
}
}
console.log('pics',pics);
console.log('DEBUG: pics array contents:', pics);
console.log('DEBUG: pics array types:', pics.map(p => typeof p + ': ' + p));
this.setState({ pics, mainPic: newMainPic });
}else{
if(this.state.pics.length > 0) this.setState({ pics:[], mainPic: newMainPic });
@@ -77,9 +80,11 @@ class Images extends Component {
}
loadPic = (size,bildId,index) => {
this.props.socketB.emit('getPic', { bildId, size }, (res) => {
window.socketManager.emit('getPic', { bildId, size }, (res) => {
if(res.success){
const url = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
const url = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/avif' }));
if(size === 'medium') window.mediumPicCache[bildId] = url;
if(size === 'small') window.smallPicCache[bildId] = url;
@@ -101,27 +106,53 @@ class Images extends Component {
}
render() {
// SPA version - full functionality with static fallback
const getImageSrc = () => {
if(window.productImageUrl) return window.productImageUrl;
// If dynamic image is loaded, use it
if (this.state.pics[this.state.mainPic]) {
return this.state.pics[this.state.mainPic];
}
// Otherwise, use static fallback (same as prerender)
if (!this.props.pictureList || !this.props.pictureList.trim()) {
return '/assets/images/nopicture.jpg';
}
return `/assets/images/prod${this.props.pictureList.split(',')[0].trim()}.avif`;
};
return (
<>
{this.state.pics[this.state.mainPic] && (
<Box sx={{ position: 'relative', display: 'inline-block' }}>
<Box sx={{ position: 'relative', display: 'inline-block', width: '499px', maxWidth: '100%' }}>
<CardMedia
component="img"
height="400"
fetchPriority="high"
loading="eager"
alt={this.props.productName || 'Produktbild'}
onError={(e) => {
// Ensure alt text is always present even on error
if (!e.target.alt) {
e.target.alt = this.props.productName || 'Produktbild';
}
}}
sx={{
objectFit: 'contain',
cursor: 'pointer',
transition: 'transform 0.2s ease-in-out',
width: '499px',
maxWidth: '100%',
'&:hover': {
transform: 'scale(1.02)'
}
}}
image={this.state.pics[this.state.mainPic]}
image={getImageSrc()}
onClick={this.props.onOpenFullscreen}
/>
<IconButton
size="small"
disableRipple
aria-label="Zoom-Symbol"
sx={{
position: 'absolute',
top: 8,
@@ -137,7 +168,6 @@ class Images extends Component {
<LoupeIcon fontSize="small" />
</IconButton>
</Box>
)}
<Stack direction="row" spacing={2} sx={{ justifyContent: 'flex-start', mt: 1,mb: 1 }}>
{this.state.pics.filter(pic => pic !== null && pic !== this.state.pics[this.state.mainPic]).map((pic, filterIndex) => {
// Find the original index in the full pics array
@@ -169,6 +199,13 @@ class Images extends Component {
<CardMedia
component="img"
height="80"
alt={`${this.props.productName || 'Produktbild'} - Bild ${originalIndex + 1}`}
onError={(e) => {
// Ensure alt text is always present even on error
if (!e.target.alt) {
e.target.alt = `${this.props.productName || 'Produktbild'} - Bild ${originalIndex + 1}`;
}
}}
sx={{
objectFit: 'contain',
cursor: 'pointer',
@@ -223,6 +260,7 @@ class Images extends Component {
{/* Close Button */}
<IconButton
onClick={this.props.onCloseFullscreen}
aria-label="Vollbild schließen"
sx={{
position: 'absolute',
top: 16,
@@ -241,6 +279,13 @@ class Images extends Component {
{this.state.pics[this.state.mainPic] && (
<CardMedia
component="img"
alt={this.props.productName || 'Produktbild'}
onError={(e) => {
// Ensure alt text is always present even on error
if (!e.target.alt) {
e.target.alt = this.props.productName || 'Produktbild';
}
}}
sx={{
objectFit: 'contain',
width: '90vw',
@@ -294,6 +339,13 @@ class Images extends Component {
<CardMedia
component="img"
height="60"
alt={`${this.props.productName || 'Produktbild'} - Miniaturansicht ${originalIndex + 1}`}
onError={(e) => {
// Ensure alt text is always present even on error
if (!e.target.alt) {
e.target.alt = `${this.props.productName || 'Produktbild'} - Miniaturansicht ${originalIndex + 1}`;
}
}}
sx={{
objectFit: 'contain',
cursor: 'pointer',

View File

@@ -0,0 +1,278 @@
import React, { Component } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import Typography from '@mui/material/Typography';
import { withI18n } from '../i18n/withTranslation.js';
class LanguageSwitcher extends Component {
constructor(props) {
super(props);
this.state = {
anchorEl: null,
loadedFlags: {}
};
}
handleClick = (event) => {
this.setState({ anchorEl: event.currentTarget });
};
handleClose = () => {
this.setState({ anchorEl: null });
};
handleLanguageChange = async (language) => {
const { languageContext } = this.props;
if (languageContext) {
try {
await languageContext.changeLanguage(language);
} catch (error) {
console.error('Failed to change language:', error);
}
}
this.handleClose();
};
// Lazy load flag components
loadFlagComponent = async (lang) => {
if (this.state.loadedFlags[lang]) {
return this.state.loadedFlags[lang];
}
try {
const flagMap = {
'ar': () => import('country-flag-icons/react/3x2').then(m => m.EG),
'bg': () => import('country-flag-icons/react/3x2').then(m => m.BG),
'cs': () => import('country-flag-icons/react/3x2').then(m => m.CZ),
'de': () => import('country-flag-icons/react/3x2').then(m => m.DE),
'el': () => import('country-flag-icons/react/3x2').then(m => m.GR),
'en': () => import('country-flag-icons/react/3x2').then(m => m.US),
'es': () => import('country-flag-icons/react/3x2').then(m => m.ES),
'fr': () => import('country-flag-icons/react/3x2').then(m => m.FR),
'hr': () => import('country-flag-icons/react/3x2').then(m => m.HR),
'hu': () => import('country-flag-icons/react/3x2').then(m => m.HU),
'it': () => import('country-flag-icons/react/3x2').then(m => m.IT),
'pl': () => import('country-flag-icons/react/3x2').then(m => m.PL),
'ro': () => import('country-flag-icons/react/3x2').then(m => m.RO),
'ru': () => import('country-flag-icons/react/3x2').then(m => m.RU),
'sk': () => import('country-flag-icons/react/3x2').then(m => m.SK),
'sl': () => import('country-flag-icons/react/3x2').then(m => m.SI),
'sq': () => import('country-flag-icons/react/3x2').then(m => m.AL),
'sr': () => import('country-flag-icons/react/3x2').then(m => m.RS),
'sv': () => import('country-flag-icons/react/3x2').then(m => m.SE),
'tr': () => import('country-flag-icons/react/3x2').then(m => m.TR),
'uk': () => import('country-flag-icons/react/3x2').then(m => m.UA),
'zh': () => import('country-flag-icons/react/3x2').then(m => m.CN)
};
const flagLoader = flagMap[lang];
if (flagLoader) {
const FlagComponent = await flagLoader();
this.setState(prevState => ({
loadedFlags: {
...prevState.loadedFlags,
[lang]: FlagComponent
}
}));
return FlagComponent;
}
} catch (error) {
console.warn(`Failed to load flag for language: ${lang}`, error);
}
return null;
};
getLanguageFlag = (lang) => {
const FlagComponent = this.state.loadedFlags[lang];
if (FlagComponent) {
return (
<FlagComponent
style={{
width: '20px',
height: '14px',
borderRadius: '2px',
border: '1px solid #ddd'
}}
/>
);
}
// Loading placeholder or fallback
return (
<Box
component="span"
sx={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: '20px',
height: '14px',
backgroundColor: '#f5f5f5',
color: '#666',
fontSize: '8px',
fontWeight: 'bold',
borderRadius: '2px',
fontFamily: 'monospace',
border: '1px solid #ddd'
}}
>
{this.getLanguageLabel(lang)}
</Box>
);
};
// Load flags when menu opens
componentDidUpdate(prevProps, prevState) {
const { anchorEl } = this.state;
const { languageContext } = this.props;
if (anchorEl && !prevState.anchorEl && languageContext) {
// Menu just opened, lazy load flags for all languages (not just available ones)
languageContext.allLanguages.forEach(lang => {
if (!this.state.loadedFlags[lang]) {
this.loadFlagComponent(lang);
}
});
}
}
getLanguageLabel = (lang) => {
const labels = {
'ar': 'EG',
'bg': 'BG',
'cs': 'CZ',
'de': 'DE',
'el': 'GR',
'en': 'US',
'es': 'ES',
'fr': 'FR',
'hr': 'HR',
'hu': 'HU',
'it': 'IT',
'pl': 'PL',
'ro': 'RO',
'ru': 'RU',
'sk': 'SK',
'sl': 'SI',
'sq': 'AL',
'sr': 'RS',
'sv': 'SE',
'tr': 'TR',
'uk': 'UA',
'zh': 'CN'
};
return labels[lang] || lang.toUpperCase();
};
getLanguageName = (lang) => {
const names = {
'ar': 'العربية',
'bg': 'Български',
'cs': 'Čeština',
'de': 'Deutsch',
'el': 'Ελληνικά',
'en': 'English',
'es': 'Español',
'fr': 'Français',
'hr': 'Hrvatski',
'hu': 'Magyar',
'it': 'Italiano',
'pl': 'Polski',
'ro': 'Română',
'ru': 'Русский',
'sk': 'Slovenčina',
'sl': 'Slovenščina',
'sq': 'Shqip',
'sr': 'Српски',
'sv': 'Svenska',
'tr': 'Türkçe',
'uk': 'Українська',
'zh': '中文'
};
return names[lang] || lang;
};
render() {
const { languageContext } = this.props;
const { anchorEl } = this.state;
if (!languageContext) {
return null;
}
const { currentLanguage, allLanguages } = languageContext;
const open = Boolean(anchorEl);
return (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Button
aria-controls={open ? 'language-menu' : undefined}
aria-haspopup="true"
aria-expanded={open ? 'true' : undefined}
onClick={this.handleClick}
color="inherit"
size="small"
sx={{
my: 1,
mx: 0.5,
minWidth: 'auto',
textTransform: 'none',
fontSize: '0.875rem'
}}
>
{this.getLanguageLabel(currentLanguage)}
</Button>
<Menu
id="language-menu"
anchorEl={anchorEl}
open={open}
onClose={this.handleClose}
disableScrollLock={true}
MenuListProps={{
'aria-labelledby': 'language-button',
}}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
>
{allLanguages.map((language) => {
return (
<MenuItem
key={language}
onClick={() => this.handleLanguageChange(language)}
selected={language === currentLanguage}
sx={{
minWidth: 160,
display: 'flex',
justifyContent: 'space-between',
gap: 2
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{this.getLanguageFlag(language)}
<Typography variant="body2">
{this.getLanguageName(language)}
</Typography>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto' }}>
{this.getLanguageLabel(language)}
</Typography>
</MenuItem>
);
})}
</Menu>
</Box>
);
}
}
export default withI18n()(LanguageSwitcher);

View File

@@ -22,10 +22,37 @@ import GoogleLoginButton from './GoogleLoginButton.js';
import CartSyncDialog from './CartSyncDialog.js';
import { localAndArchiveServer, mergeCarts } from '../utils/cartUtils.js';
import config from '../config.js';
import { withI18n } from '../i18n/withTranslation.js';
import {
hasPendingWirePaymentOrder,
WIRE_PAYMENT_PENDING_EVENT,
} from '../utils/wireGirocodeEligibility.js';
import GoogleIcon from '@mui/icons-material/Google';
// Lazy load GoogleAuthProvider
const GoogleAuthProvider = lazy(() => import('../providers/GoogleAuthProvider.js'));
const getTokenFromAuthResponse = (response) =>
response?.token ||
response?.accessToken ||
response?.jwt ||
response?.user?.token ||
response?.user?.accessToken ||
null;
const persistSessionAuth = (response) => {
if (response?.user) {
sessionStorage.setItem('user', JSON.stringify(response.user));
}
const token = getTokenFromAuthResponse(response);
if (token) {
sessionStorage.setItem('authToken', token);
} else {
sessionStorage.removeItem('authToken');
}
};
// Function to check if user is logged in
export const isUserLoggedIn = () => {
const storedUser = sessionStorage.getItem('user');
@@ -75,6 +102,7 @@ function cartsAreIdentical(cartA, cartB) {
export class LoginComponent extends Component {
constructor(props) {
super(props);
const { isLoggedIn, user, isAdmin } = isUserLoggedIn();
this.state = {
open: false,
tabValue: 0,
@@ -84,47 +112,64 @@ export class LoginComponent extends Component {
error: '',
loading: false,
success: '',
isLoggedIn: false,
isAdmin: false,
user: null,
isLoggedIn,
isAdmin,
user,
anchorEl: null,
showGoogleAuth: false,
cartSyncOpen: false,
localCartSync: [],
serverCartSync: [],
pendingNavigate: null,
privacyConfirmed: sessionStorage.getItem('privacyConfirmed') === 'true'
privacyConfirmed: sessionStorage.getItem('privacyConfirmed') === 'true',
pendingWirePaymentOrders: false
};
}
refreshPendingWireOrders = () => {
if (typeof window === 'undefined' || !window.socketManager) return;
window.socketManager.emit('getOrders', (response) => {
if (response.success && Array.isArray(response.orders)) {
this.setState({
pendingWirePaymentOrders: hasPendingWirePaymentOrder(response.orders),
});
}
});
};
handleWirePaymentPendingEvent = (e) => {
if (e.detail && typeof e.detail.pending === 'boolean') {
this.setState({ pendingWirePaymentOrders: e.detail.pending });
}
};
componentDidMount() {
// Make the open function available globally
window.openLoginDrawer = this.handleOpen;
// Check if user is logged in
const { isLoggedIn: userIsLoggedIn, user: storedUser } = isUserLoggedIn();
if (userIsLoggedIn) {
this.setState({
user: storedUser,
isAdmin: !!storedUser.admin,
isLoggedIn: true
});
}
if (this.props.open) {
this.setState({ open: true });
}
if (this.state.isLoggedIn) {
this.refreshPendingWireOrders();
}
window.addEventListener(WIRE_PAYMENT_PENDING_EVENT, this.handleWirePaymentPendingEvent);
}
componentDidUpdate(prevProps) {
componentDidUpdate(prevProps, prevState) {
if (this.props.open !== prevProps.open) {
this.setState({ open: this.props.open });
}
if (this.state.isLoggedIn && !prevState.isLoggedIn) {
this.refreshPendingWireOrders();
}
}
componentWillUnmount() {
// Cleanup function to remove global reference when component unmounts
window.openLoginDrawer = undefined;
window.removeEventListener(WIRE_PAYMENT_PENDING_EVENT, this.handleWirePaymentPendingEvent);
}
resetForm = () => {
@@ -170,40 +215,40 @@ export class LoginComponent extends Component {
handleLogin = () => {
const { email, password } = this.state;
const { socket, location, navigate } = this.props;
const { location, navigate } = this.props;
if (!email || !password) {
this.setState({ error: 'Bitte füllen Sie alle Felder aus' });
this.setState({ error: this.props.t ? this.props.t('auth.errors.fillAllFields') : 'Bitte füllen Sie alle Felder aus' });
return;
}
if (!this.validateEmail(email)) {
this.setState({ error: 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
this.setState({ error: this.props.t ? this.props.t('auth.errors.invalidEmail') : 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
return;
}
this.setState({ loading: true, error: '' });
// Call verifyUser socket endpoint
if (!socket || !socket.connected) {
this.setState({
loading: false,
error: 'Verbindung zum Server verloren. Bitte versuchen Sie es erneut.'
});
return;
}
socket.emit('verifyUser', { email, password }, (response) => {
window.socketManager.emit('verifyUser', { email, password }, (response) => {
console.log('LoginComponent: verifyUser', response);
if (response.success) {
sessionStorage.setItem('user', JSON.stringify(response.user));
persistSessionAuth(response);
this.setState({
user: response.user,
isLoggedIn: true,
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);
@@ -215,9 +260,9 @@ export class LoginComponent extends Component {
const serverCartArr = newCart ? Object.values(newCart) : [];
if (serverCartArr.length === 0) {
if (socket && socket.connected) {
socket.emit('updateCart', window.cart);
}
window.socketManager.emit('updateCart', window.cart);
this.handleClose();
dispatchLoginEvent();
} else if (localCartArr.length === 0 && serverCartArr.length > 0) {
@@ -244,7 +289,7 @@ export class LoginComponent extends Component {
} else {
this.setState({
loading: false,
error: response.message || 'Anmeldung fehlgeschlagen'
error: response.message || (this.props.t ? this.props.t('auth.errors.loginFailed') : 'Anmeldung fehlgeschlagen')
});
}
});
@@ -252,50 +297,49 @@ export class LoginComponent extends Component {
handleRegister = () => {
const { email, password, confirmPassword } = this.state;
const { socket } = this.props;
if (!email || !password || !confirmPassword) {
this.setState({ error: 'Bitte füllen Sie alle Felder aus' });
this.setState({ error: this.props.t ? this.props.t('auth.errors.fillAllFields') : 'Bitte füllen Sie alle Felder aus' });
return;
}
if (!this.validateEmail(email)) {
this.setState({ error: 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
this.setState({ error: this.props.t ? this.props.t('auth.errors.invalidEmail') : 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
return;
}
if (password !== confirmPassword) {
this.setState({ error: 'Passwörter stimmen nicht überein' });
this.setState({ error: this.props.t ? this.props.t('auth.errors.passwordsNotMatchShort') : 'Passwörter stimmen nicht überein' });
return;
}
if (password.length < 8) {
this.setState({ error: 'Das Passwort muss mindestens 8 Zeichen lang sein' });
this.setState({ error: this.props.t ? this.props.t('auth.passwordMinLength') : 'Das Passwort muss mindestens 8 Zeichen lang sein' });
return;
}
this.setState({ loading: true, error: '' });
// Call createUser socket endpoint
if (!socket || !socket.connected) {
this.setState({
loading: false,
error: 'Verbindung zum Server verloren. Bitte versuchen Sie es erneut.'
});
return;
}
socket.emit('createUser', { email, password }, (response) => {
window.socketManager.emit('createUser', { email, password }, (response) => {
if (response.success) {
this.setState({
loading: false,
success: 'Registrierung erfolgreich. Sie können sich jetzt anmelden.',
success: this.props.t ? this.props.t('auth.success.registerComplete') : 'Registrierung erfolgreich. Sie können sich jetzt anmelden.',
tabValue: 0 // Switch to login tab
});
} else {
let errorMessage = this.props.t ? this.props.t('auth.errors.registerFailed') : 'Registrierung fehlgeschlagen';
if (response.cause === 'emailExists') {
errorMessage = this.props.t ? this.props.t('auth.errors.emailExists') : 'Ein Benutzer mit dieser E-Mail-Adresse existiert bereits. Bitte verwenden Sie eine andere E-Mail-Adresse oder melden Sie sich an.';
} else if (response.message) {
errorMessage = response.message;
}
this.setState({
loading: false,
error: response.message || 'Registrierung fehlgeschlagen'
error: errorMessage
});
}
});
@@ -303,6 +347,7 @@ export class LoginComponent extends Component {
handleUserMenuClick = (event) => {
this.setState({ anchorEl: event.currentTarget });
this.refreshPendingWireOrders();
};
handleUserMenuClose = () => {
@@ -310,24 +355,10 @@ export class LoginComponent extends Component {
};
handleLogout = () => {
if (!this.props.socket || !this.props.socket.connected) {
// If socket is not connected, just clear local storage
sessionStorage.removeItem('user');
window.cart = [];
window.dispatchEvent(new CustomEvent('cart'));
window.dispatchEvent(new CustomEvent('userLoggedOut'));
this.setState({
isLoggedIn: false,
user: null,
isAdmin: false,
anchorEl: null
});
return;
}
this.props.socket.emit('logout', (response) => {
window.socketManager.emit('logout', (response) => {
if(response.success){
sessionStorage.removeItem('user');
sessionStorage.removeItem('authToken');
window.dispatchEvent(new CustomEvent('userLoggedIn'));
this.props.navigate('/');
this.setState({
@@ -335,6 +366,7 @@ export class LoginComponent extends Component {
isLoggedIn: false,
isAdmin: false,
anchorEl: null,
pendingWirePaymentOrders: false,
});
}
});
@@ -342,22 +374,21 @@ export class LoginComponent extends Component {
handleForgotPassword = () => {
const { email } = this.state;
const { socket } = this.props;
if (!email) {
this.setState({ error: 'Bitte geben Sie Ihre E-Mail-Adresse ein' });
this.setState({ error: this.props.t ? this.props.t('auth.errors.enterEmail') : 'Bitte geben Sie Ihre E-Mail-Adresse ein' });
return;
}
if (!this.validateEmail(email)) {
this.setState({ error: 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
this.setState({ error: this.props.t ? this.props.t('auth.errors.invalidEmail') : 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
return;
}
this.setState({ loading: true, error: '' });
// Call resetPassword socket endpoint
socket.emit('resetPassword', {
window.socketManager.emit('resetPassword', {
email,
domain: window.location.origin
}, (response) => {
@@ -365,12 +396,12 @@ export class LoginComponent extends Component {
if (response.success) {
this.setState({
loading: false,
success: 'Ein Link zum Zurücksetzen des Passworts wurde an Ihre E-Mail-Adresse gesendet.'
success: this.props.t ? this.props.t('auth.resetPassword.emailSent') : 'Ein Link zum Zurücksetzen des Passworts wurde an Ihre E-Mail-Adresse gesendet.'
});
} else {
this.setState({
loading: false,
error: response.message || 'Fehler beim Senden der E-Mail'
error: response.message || (this.props.t ? this.props.t('auth.resetPassword.emailError') : 'Fehler beim Senden der E-Mail')
});
}
});
@@ -378,23 +409,28 @@ export class LoginComponent extends Component {
// Google login functionality
handleGoogleLoginSuccess = (credentialResponse) => {
const { socket, location, navigate } = this.props;
const { location, navigate } = this.props;
this.setState({ loading: true, error: '' });
console.log('beforeG',credentialResponse)
socket.emit('verifyGoogleUser', { credential: credentialResponse.credential }, (response) => {
window.socketManager.emit('verifyGoogleUser', { credential: credentialResponse.credential }, (response) => {
console.log('google respo',response);
if (response.success) {
sessionStorage.setItem('user', JSON.stringify(response.user));
persistSessionAuth(response);
this.setState({
isLoggedIn: true,
isAdmin: !!response.user.admin,
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);
@@ -406,7 +442,7 @@ export class LoginComponent extends Component {
const serverCartArr = newCart ? Object.values(newCart) : [];
if (serverCartArr.length === 0) {
socket.emit('updateCart', window.cart);
window.socketManager.emit('updateCart', window.cart);
this.handleClose();
dispatchLoginEvent();
} else if (localCartArr.length === 0 && serverCartArr.length > 0) {
@@ -433,7 +469,7 @@ export class LoginComponent extends Component {
} else {
this.setState({
loading: false,
error: 'Google-Anmeldung fehlgeschlagen',
error: this.props.t ? this.props.t('auth.errors.googleLoginFailed') : 'Google-Anmeldung fehlgeschlagen',
showGoogleAuth: false // Reset Google auth state on failed login
});
}
@@ -443,7 +479,7 @@ export class LoginComponent extends Component {
handleGoogleLoginError = (error) => {
console.error('Google Login Error:', error);
this.setState({
error: 'Google-Anmeldung fehlgeschlagen',
error: this.props.t ? this.props.t('auth.errors.googleLoginFailed') : 'Google-Anmeldung fehlgeschlagen',
showGoogleAuth: false, // Reset Google auth state on error
loading: false
});
@@ -456,7 +492,7 @@ export class LoginComponent extends Component {
localAndArchiveServer(localCartSync, serverCartSync);
break;
case 'deleteServer':
this.props.socket.emit('updateCart', window.cart)
window.socketManager.emit('updateCart', window.cart)
break;
case 'useServer':
window.cart = serverCartSync;
@@ -492,7 +528,8 @@ export class LoginComponent extends Component {
cartSyncOpen,
localCartSync,
serverCartSync,
privacyConfirmed
privacyConfirmed,
pendingWirePaymentOrders
} = this.state;
const { open: openProp, handleClose: handleCloseProp } = this.props;
@@ -510,7 +547,7 @@ export class LoginComponent extends Component {
color={isAdmin ? 'secondary' : 'inherit'}
sx={{ my: 1, mx: 1.5 }}
>
Profil
{this.props.t ? this.props.t('auth.profile') : 'Profil'}
</Button>
<Menu
disableScrollLock={true}
@@ -526,14 +563,33 @@ export class LoginComponent extends Component {
horizontal: 'right',
}}
>
<MenuItem component={Link} to="/profile" onClick={this.handleUserMenuClose}>Profil</MenuItem>
<MenuItem component={Link} to="/profile#cart" onClick={this.handleUserMenuClose} sx={{ pl: 4 }}>Bestellabschluss</MenuItem>
<MenuItem component={Link} to="/profile#orders" onClick={this.handleUserMenuClose} sx={{ pl: 4 }}>Bestellungen</MenuItem>
<MenuItem component={Link} to="/profile#settings" onClick={this.handleUserMenuClose} sx={{ pl: 4 }}>Einstellungen</MenuItem>
<MenuItem component={Link} to="/profile" onClick={this.handleUserMenuClose}>
{this.props.t ? this.props.t('auth.menu.profile') : 'Profil'}
</MenuItem>
<MenuItem component={Link} to="/profile#cart" onClick={this.handleUserMenuClose} sx={{ pl: 4 }}>
{this.props.t ? this.props.t('auth.menu.checkout') : 'Bestellabschluss'}
</MenuItem>
<MenuItem component={Link} to="/profile#orders" onClick={this.handleUserMenuClose} sx={{ pl: 4, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
<span>{this.props.t ? this.props.t('auth.menu.orders') : 'Bestellungen'}</span>
{pendingWirePaymentOrders ? (
<Typography component="span" sx={{ color: 'error.main', fontWeight: 700, fontSize: '0.875rem', flexShrink: 0 }} aria-label="!">
[!]
</Typography>
) : null}
</MenuItem>
<MenuItem component={Link} to="/profile#settings" onClick={this.handleUserMenuClose} sx={{ pl: 4 }}>
{this.props.t ? this.props.t('auth.menu.settings') : 'Einstellungen'}
</MenuItem>
<Divider />
{isAdmin ? <MenuItem component={Link} to="/admin" onClick={this.handleUserMenuClose}>Admin Dashboard</MenuItem> : null}
{isAdmin ? <MenuItem component={Link} to="/admin/users" onClick={this.handleUserMenuClose}>Admin Users</MenuItem> : null}
<MenuItem onClick={this.handleLogout}>Abmelden</MenuItem>
{isAdmin ? <MenuItem component={Link} to="/admin" onClick={this.handleUserMenuClose}>
{this.props.t ? this.props.t('auth.menu.adminDashboard') : 'Admin Dashboard'}
</MenuItem> : null}
{isAdmin ? <MenuItem component={Link} to="/admin/users" onClick={this.handleUserMenuClose}>
{this.props.t ? this.props.t('auth.menu.adminUsers') : 'Admin Users'}
</MenuItem> : null}
<MenuItem onClick={this.handleLogout}>
{this.props.t ? this.props.t('auth.logout') : 'Abmelden'}
</MenuItem>
</Menu>
</>
) : (
@@ -543,7 +599,7 @@ export class LoginComponent extends Component {
onClick={this.handleOpen}
sx={{ my: 1, mx: 1.5 }}
>
Login
{this.props.t ? this.props.t('auth.login') : 'Login'}
</Button>
)
)}
@@ -558,7 +614,10 @@ export class LoginComponent extends Component {
<DialogTitle sx={{ bgcolor: 'white', pb: 0 }}>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Typography variant="h6" color="#2e7d32" fontWeight="bold">
{tabValue === 0 ? 'Anmelden' : 'Registrieren'}
{tabValue === 0 ?
(this.props.t ? this.props.t('auth.login') : 'Anmelden') :
(this.props.t ? this.props.t('auth.register') : 'Registrieren')
}
</Typography>
<IconButton edge="end" onClick={this.handleClose} aria-label="close">
<CloseIcon />
@@ -578,14 +637,14 @@ export class LoginComponent extends Component {
textColor="inherit"
>
<Tab
label="ANMELDEN"
label={this.props.t ? this.props.t('auth.login').toUpperCase() : "ANMELDEN"}
sx={{
color: tabValue === 0 ? '#2e7d32' : 'inherit',
fontWeight: 'bold'
}}
/>
<Tab
label="REGISTRIEREN"
label={this.props.t ? this.props.t('auth.register').toUpperCase() : "REGISTRIEREN"}
sx={{
color: tabValue === 1 ? '#2e7d32' : 'inherit',
fontWeight: 'bold'
@@ -598,7 +657,14 @@ export class LoginComponent extends Component {
<Box sx={{ width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', mb: 2 }}>
{!privacyConfirmed && (
<Typography variant="caption" sx={{ mb: 1, textAlign: 'center' }}>
{this.props.t ?
<>
{this.props.t('auth.privacyAccept')} <Link to="/datenschutz" style={{ color: '#4285F4' }}>{this.props.t('auth.privacyPolicy')}</Link>
</> :
<>
Mit dem Click auf "Mit Google anmelden" akzeptiere ich die <Link to="/datenschutz" style={{ color: '#4285F4' }}>Datenschutzbestimmungen</Link>
</>
}
</Typography>
)}
{!showGoogleAuth && (
@@ -611,7 +677,7 @@ export class LoginComponent extends Component {
}}
sx={{ width: '100%', backgroundColor: '#4285F4', color: 'white' }}
>
Mit Google anmelden
{this.props.t ? this.props.t('auth.loginWithGoogle') : 'Mit Google anmelden'}
</Button>
)}
@@ -619,17 +685,18 @@ export class LoginComponent extends Component {
<Suspense fallback={
<Button
variant="contained"
startIcon={<PersonIcon />}
sx={{ width: '100%', backgroundColor: '#4285F4', color: 'white' }}
startIcon={<GoogleIcon />}
disabled
fullWidth
style={{backgroundColor: '#4285F4', color: 'white' }}
>
Mit Google anmelden
Loading...
</Button>
}>
<GoogleAuthProvider clientId={config.googleClientId}>
<GoogleLoginButton
onSuccess={this.handleGoogleLoginSuccess}
onError={this.handleGoogleLoginError}
text="Mit Google anmelden"
style={{ width: '100%', backgroundColor: '#4285F4' }}
autoInitiate={true}
/>
@@ -643,7 +710,9 @@ export class LoginComponent extends Component {
{/* OR Divider */}
<Box sx={{ display: 'flex', alignItems: 'center', my: 2 }}>
<Box sx={{ flex: 1, height: '1px', backgroundColor: '#e0e0e0' }} />
<Typography variant="body2" sx={{ px: 2, color: '#757575' }}>ODER</Typography>
<Typography variant="body2" sx={{ px: 2, color: '#757575' }}>
{this.props.t ? this.props.t('auth.or') : 'ODER'}
</Typography>
<Box sx={{ flex: 1, height: '1px', backgroundColor: '#e0e0e0' }} />
</Box>
@@ -654,7 +723,7 @@ export class LoginComponent extends Component {
<Box sx={{ py: 1 }}>
<TextField
margin="dense"
label="E-Mail"
label={this.props.t ? this.props.t('auth.email') : 'E-Mail'}
type="email"
fullWidth
variant="outlined"
@@ -665,7 +734,7 @@ export class LoginComponent extends Component {
<TextField
margin="dense"
label="Passwort"
label={this.props.t ? this.props.t('auth.password') : 'Passwort'}
type="password"
fullWidth
variant="outlined"
@@ -687,7 +756,7 @@ export class LoginComponent extends Component {
'&:hover': { backgroundColor: 'transparent', textDecoration: 'underline' }
}}
>
Passwort vergessen?
{this.props.t ? this.props.t('auth.forgotPassword') : 'Passwort vergessen?'}
</Button>
</Box>
)}
@@ -695,7 +764,7 @@ export class LoginComponent extends Component {
{tabValue === 1 && (
<TextField
margin="dense"
label="Passwort bestätigen"
label={this.props.t ? this.props.t('auth.confirmPassword') : 'Passwort bestätigen'}
type="password"
fullWidth
variant="outlined"
@@ -717,7 +786,7 @@ export class LoginComponent extends Component {
onClick={tabValue === 0 ? this.handleLogin : this.handleRegister}
sx={{ mt: 2, bgcolor: '#2e7d32', '&:hover': { bgcolor: '#1b5e20' } }}
>
{tabValue === 0 ? 'ANMELDEN' : 'REGISTRIEREN'}
{tabValue === 0 ? (this.props.t ? this.props.t('auth.login').toUpperCase() : 'ANMELDEN') : (this.props.t ? this.props.t('auth.register').toUpperCase() : 'REGISTRIEREN')}
</Button>
)}
</Box>
@@ -740,4 +809,4 @@ export class LoginComponent extends Component {
}
}
export default withRouter(LoginComponent);
export default withRouter(withI18n()(LoginComponent));

View File

@@ -0,0 +1,438 @@
import React from "react";
import { useLocation } from "react-router-dom";
import Container from "@mui/material/Container";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Paper from "@mui/material/Paper";
import Grid from "@mui/material/Grid";
import ChevronLeft from "@mui/icons-material/ChevronLeft";
import ChevronRight from "@mui/icons-material/ChevronRight";
import { Link } from "react-router-dom";
import SharedCarousel from "./SharedCarousel.js";
import { getCombinedAnimatedBorderStyles } from "../utils/animatedBorderStyles.js";
import { STAR_POLYGON_POINTS } from "../utils/starPolygon.js";
import { useTranslation } from 'react-i18next';
const HOME_STAR_LAYERS = [
{ className: "star-rotate-slow-cw", size: 168 },
{ className: "star-rotate-slow-ccw", size: 159 },
{ className: "star-rotate-medium-cw", size: 150 },
];
/** Teal/cyan stack for the right (Konfigurator) star — same motion, blue color scheme */
const TEAL_STAR_LAYERS = [
{ className: "star-rotate-slow-ccw", size: 168 },
{ className: "star-rotate-medium-cw", size: 159 },
{ className: "star-rotate-slow-cw", size: 150 },
];
/** Initial fill per variant (matches keyframe 0%) — avoids black flash before CSS animates */
const STAR_INITIAL_FILLS = {
home: ["#B8860B", "#DAA520", "#FFD700"],
filiale: ["#5F9EA0", "#7FCDCD", "#AFEEEE"],
};
/** Injected in render (not useEffect) so first paint already has keyframes — avoids angle/color snap on load */
const STAR_DECORATION_CSS = `
@keyframes rotateClockwise {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes rotateCounterClockwise {
from { transform: rotate(0deg); }
to { transform: rotate(-360deg); }
}
.star-rotate-slow-cw,
.star-rotate-slow-ccw,
.star-rotate-medium-cw {
transform-box: fill-box;
transform-origin: center;
}
.star-rotate-slow-cw {
animation: rotateClockwise 60s linear infinite;
}
.star-rotate-slow-ccw {
animation: rotateCounterClockwise 45s linear infinite;
}
.star-rotate-medium-cw {
animation: rotateClockwise 30s linear infinite;
}
.star-layer-svg-home {
mix-blend-mode: screen;
opacity: 0.92;
filter: drop-shadow(3px 3px 6px rgba(0, 0, 0, 0.5)) drop-shadow(0 0 10px rgba(255, 215, 0, 0.6)) drop-shadow(0 0 20px rgba(255, 215, 0, 0.3));
}
.star-layer-svg-filiale {
mix-blend-mode: soft-light;
opacity: 0.94;
filter: drop-shadow(3px 3px 6px rgba(0, 0, 0, 0.5)) drop-shadow(0 0 10px rgba(127, 205, 205, 0.6)) drop-shadow(0 0 18px rgba(95, 158, 160, 0.35));
}
.star-layer-svg {
shape-rendering: geometricPrecision;
transform: translateZ(0);
}
@keyframes starFillHome0 {
0%, 100% { fill: #B8860B; }
33% { fill: #FFD700; }
66% { fill: #DAA520; }
}
@keyframes starFillHome1 {
0%, 100% { fill: #DAA520; }
33% { fill: #B8860B; }
66% { fill: #FFD700; }
}
@keyframes starFillHome2 {
0%, 100% { fill: #FFD700; }
33% { fill: #DAA520; }
66% { fill: #B8860B; }
}
@keyframes starDriftHome0 {
0%, 100% { transform: rotate(20deg) translate(0px, 0px); }
50% { transform: rotate(20deg) translate(5px, -5px); }
}
@keyframes starDriftHome1 {
0%, 100% { transform: rotate(-25deg) translate(0px, 0px); }
50% { transform: rotate(-25deg) translate(-4px, 6px); }
}
@keyframes starDriftHome2 {
0%, 100% { transform: translate(0px, 0px); }
50% { transform: translate(3px, 4px); }
}
.star-layer-wrap.star-layer-home-0 {
animation: starDriftHome0 6.5s ease-in-out infinite;
animation-fill-mode: both;
}
.star-layer-wrap.star-layer-home-1 {
animation: starDriftHome1 7s ease-in-out 0.4s infinite;
animation-fill-mode: both;
}
.star-layer-wrap.star-layer-home-2 {
animation: starDriftHome2 5.5s ease-in-out 0.8s infinite;
animation-fill-mode: both;
}
.star-poly-home-0 { animation: starFillHome0 10s ease-in-out 0s infinite both; }
.star-poly-home-1 { animation: starFillHome1 10s ease-in-out 1.1s infinite both; }
.star-poly-home-2 { animation: starFillHome2 10s ease-in-out 2.2s infinite both; }
@keyframes starFillFil0 {
0%, 100% { fill: #5F9EA0; }
33% { fill: #AFEEEE; }
66% { fill: #7FCDCD; }
}
@keyframes starFillFil1 {
0%, 100% { fill: #7FCDCD; }
33% { fill: #5F9EA0; }
66% { fill: #AFEEEE; }
}
@keyframes starFillFil2 {
0%, 100% { fill: #AFEEEE; }
33% { fill: #7FCDCD; }
66% { fill: #5F9EA0; }
}
@keyframes starDriftFil0 {
0%, 100% { transform: rotate(20deg) translate(0px, 0px); }
50% { transform: rotate(20deg) translate(4px, -4px); }
}
@keyframes starDriftFil1 {
0%, 100% { transform: rotate(-25deg) translate(0px, 0px); }
50% { transform: rotate(-25deg) translate(-5px, 5px); }
}
@keyframes starDriftFil2 {
0%, 100% { transform: translate(0px, 0px); }
50% { transform: translate(3px, 3px); }
}
.star-layer-wrap.star-layer-filiale-0 {
animation: starDriftFil0 6.5s ease-in-out infinite;
animation-fill-mode: both;
}
.star-layer-wrap.star-layer-filiale-1 {
animation: starDriftFil1 7s ease-in-out 0.4s infinite;
animation-fill-mode: both;
}
.star-layer-wrap.star-layer-filiale-2 {
animation: starDriftFil2 5.5s ease-in-out 0.8s infinite;
animation-fill-mode: both;
}
.star-poly-filiale-0 { animation: starFillFil0 10s ease-in-out 0s infinite both; }
.star-poly-filiale-1 { animation: starFillFil1 10s ease-in-out 1.1s infinite both; }
.star-poly-filiale-2 { animation: starFillFil2 10s ease-in-out 2.2s infinite both; }
`;
const StarDecorationLayers = ({ layers, variant }) => (
<>
{layers.map(({ className, size }, i) => {
const half = size / 2;
const initialFill = STAR_INITIAL_FILLS[variant][i];
return (
<div
key={i}
className={`star-layer-wrap star-layer-${variant}-${i}`}
style={{
position: "absolute",
left: "50%",
top: "50%",
width: size,
height: size,
marginLeft: -half,
marginTop: -half,
zIndex: 3 - i,
}}
>
<svg
viewBox="0 0 60 60"
width="100%"
height="100%"
className={`${className} star-layer-svg star-layer-svg-${variant}`}
style={{ display: "block" }}
>
<polygon
points={STAR_POLYGON_POINTS}
fill={initialFill}
className={`star-poly-fill star-poly-${variant}-${i}`}
/>
</svg>
</div>
);
})}
</>
);
const ContentBox = ({ box, index, pageType, starHovered, setStarHovered, opacity, translatedContent }) => (
<Grid key={`${pageType}-${index}`} item xs={12} sm={6} sx={{ p: 2, width: "50%", position: 'relative' }}>
{index === 0 && pageType === "home" && (
<Box
sx={{
position: 'absolute',
top: '-55px',
left: '-45px',
width: '180px',
height: '180px',
zIndex: 999,
pointerEvents: 'none',
'& *': { pointerEvents: 'none' },
display: { xs: 'none', sm: 'block' }
}}
>
<StarDecorationLayers layers={HOME_STAR_LAYERS} variant="home" />
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%) rotate(-10deg)', color: 'white', fontWeight: '900', fontSize: '20px', textShadow: '0px 3px 6px rgba(0,0,0,0.5)', zIndex: 1000, textAlign: 'center', lineHeight: '1.1', width: '135px', transition: 'opacity 0.3s ease', opacity: starHovered ? 0 : 1 }}>
{translatedContent.outdoorSeason}
</div>
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%) rotate(-10deg)', color: 'white', fontWeight: '900', fontSize: '20px', textShadow: '0px 3px 6px rgba(0,0,0,0.5)', zIndex: 1000, textAlign: 'center', lineHeight: '1.1', width: '135px', opacity: starHovered ? 1 : 0, transition: 'opacity 0.3s ease' }}>
{translatedContent.selectSeedRate}
</div>
</Box>
)}
{index === 1 && pageType === "home" && (
<Box
sx={{
position: 'absolute',
bottom: '-45px',
right: '-65px',
width: '180px',
height: '180px',
zIndex: 999,
pointerEvents: 'none',
'& *': { pointerEvents: 'none' },
display: { xs: 'none', sm: 'block' }
}}
>
<StarDecorationLayers layers={TEAL_STAR_LAYERS} variant="filiale" />
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%) rotate(10deg)', color: 'white', fontWeight: '900', fontSize: '20px', textShadow: '0px 3px 6px rgba(0,0,0,0.5)', zIndex: 1000, textAlign: 'center', lineHeight: '1.1', width: '135px' }}>
{translatedContent.buildYourSet}
</div>
</Box>
)}
<div className={`animated-border-card ${index === 0 ? 'seeds-card' : 'cutlings-card'}`}>
<Paper
component={Link}
to={box.link}
sx={{
p: 0,
textDecoration: "none",
color: "text.primary",
borderRadius: 2,
overflow: "hidden",
height: { xs: 150, sm: 200, md: 300 },
display: "flex",
flexDirection: "column",
boxShadow: 10,
transition: "box-shadow 0.3s ease",
"&:hover": { boxShadow: 20 },
}}
onMouseEnter={
pageType === "home" && index === 0
? () => setStarHovered(true)
: undefined
}
onMouseLeave={
pageType === "home" && index === 0
? () => setStarHovered(false)
: undefined
}
>
<Box sx={{ height: "100%", bgcolor: box.bgcolor, position: "relative", display: "flex", alignItems: "center", justifyContent: "center" }}>
{opacity === 1 && (
<img src={box.image} alt={box.title} style={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain", position: "absolute", top: "50%", left: "50%", transform: "translate(-50%, -50%)" }} />
)}
<Box sx={{ position: "absolute", bottom: 0, left: 0, right: 0, bgcolor: "rgba(27, 94, 32, 0.8)", p: 1 }}>
<Typography sx={{ fontSize: "1.6rem", color: "white", fontFamily: "SwashingtonCP" }}>{box.title}</Typography>
</Box>
</Box>
</Paper>
</div>
</Grid>
);
const MainPageLayout = () => {
const location = useLocation();
const currentPath = location.pathname;
const { t } = useTranslation();
const [starHovered, setStarHovered] = React.useState(false);
const translatedContent = {
buildYourSet: t('sections.buildYourSet'),
selectSeedRate: t('sections.selectSeedRate'),
outdoorSeason: t('sections.outdoorSeason')
};
const isHome = currentPath === "/";
const isAktionen = currentPath === "/aktionen";
const isFiliale = currentPath === "/filiale";
const getNavigationConfig = () => {
if (isHome) return { leftNav: { text: t('navigation.aktionen'), link: "/aktionen" }, rightNav: { text: t('navigation.filiale'), link: "/filiale" } };
if (isAktionen) return { leftNav: { text: t('navigation.filiale'), link: "/filiale" }, rightNav: { text: t('navigation.home'), link: "/" } };
if (isFiliale) return { leftNav: { text: t('navigation.home'), link: "/" }, rightNav: { text: t('navigation.aktionen'), link: "/aktionen" } };
return { leftNav: null, rightNav: null };
};
const allTitles = {
home: t('titles.home'),
aktionen: t('titles.aktionen'),
filiale: t('titles.filiale')
};
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" }
],
aktionen: [
{ 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" },
{ title: t('sections.address2'), image: "/assets/images/filiale2.jpg", bgcolor: "#e8f5d6", link: "/filiale" }
]
};
const getOpacity = (pageType) => {
if (pageType === "home" && isHome) return 1;
if (pageType === "aktionen" && isAktionen) return 1;
if (pageType === "filiale" && isFiliale) return 1;
return 0;
};
const navConfig = getNavigationConfig();
const navTexts = [
{ key: 'aktionen', text: t('navigation.aktionen'), link: '/aktionen' },
{ key: 'filiale', text: t('navigation.filiale'), link: '/filiale' },
{ key: 'home', text: t('navigation.home'), link: '/' }
];
return (
<Container maxWidth="lg" sx={{ py: 2 }}>
<style>{getCombinedAnimatedBorderStyles(['seeds', 'cutlings'])}</style>
<style>{STAR_DECORATION_CSS}</style>
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mb: 4, mt: 2, px: 0, transition: "all 0.3s ease-in-out", flexDirection: { xs: "column", sm: "row" } }}>
<Box sx={{ display: { xs: "block", sm: "none" }, mb: { xs: 2, sm: 0 }, width: "100%", textAlign: "center", position: "relative" }}>
{Object.entries(allTitles).map(([pageType, title]) => (
<Typography key={pageType} variant="h3" component="h1" sx={{
fontFamily: "SwashingtonCP", fontSize: { xs: "2.125rem", sm: "2.125rem", md: "3rem" }, textAlign: "center", color: "primary.main",
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)", transition: "opacity 0.5s ease-in-out", opacity: getOpacity(pageType),
position: pageType === "home" ? "relative" : "absolute", top: pageType !== "home" ? 0 : "auto", left: pageType !== "home" ? 0 : "auto",
transform: "none", width: "100%", pointerEvents: getOpacity(pageType) === 1 ? "auto" : "none",
lineHeight: { xs: "1.2", sm: "1.2", md: "1.1" }, wordWrap: "break-word", hyphens: "auto"
}}>{title}</Typography>
))}
</Box>
<Box sx={{ display: { xs: "flex", sm: "contents" }, width: { xs: "100%", sm: "auto" }, justifyContent: { xs: "space-between", sm: "initial" }, alignItems: "center" }}>
<Box sx={{ display: "flex", alignItems: "center", flexShrink: 0, justifyContent: "flex-start", position: "relative", mr: { xs: 0, sm: 2 } }}>
{navTexts.map((navItem, index) => {
const isActive = navConfig.leftNav && navConfig.leftNav.text === navItem.text;
return (
<Box key={navItem.key} component={Link} to={navItem.link} sx={{
display: "flex", alignItems: "center", textDecoration: "none", color: "inherit", transition: "all 0.3s ease",
opacity: isActive ? 1 : 0, position: index === 0 ? "relative" : "absolute", left: index !== 0 ? 0 : "auto",
pointerEvents: isActive ? "auto" : "none", "&:hover": { transform: "translateX(-5px)", color: "primary.main" }
}}>
<ChevronLeft sx={{ fontSize: "2rem", mr: 1 }} />
<Typography sx={{
fontFamily: "SwashingtonCP", fontSize: { xs: "1.25rem", sm: "1.25rem", md: "2.125rem" },
textShadow: "2px 2px 4px rgba(0, 0, 0, 0.3)", lineHeight: { xs: "1.2", sm: "1.2", md: "1.1" }, whiteSpace: "nowrap"
}}>{navItem.text}</Typography>
</Box>
);
})}
</Box>
<Box sx={{ flex: 1, display: { xs: "none", sm: "flex" }, justifyContent: "center", alignItems: "center", px: 0, position: "relative", minWidth: 0 }}>
{Object.entries(allTitles).map(([pageType, title]) => (
<Typography key={pageType} variant="h3" component="h1" sx={{
fontFamily: "SwashingtonCP", fontSize: { xs: "2.125rem", sm: "2.125rem", md: "3rem" }, textAlign: "center", color: "primary.main",
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)", transition: "opacity 0.5s ease-in-out", opacity: getOpacity(pageType),
position: pageType === "home" ? "relative" : "absolute", top: pageType !== "home" ? "50%" : "auto", left: pageType !== "home" ? "50%" : "auto",
transform: pageType !== "home" ? "translate(-50%, -50%)" : "none", width: "100%", pointerEvents: getOpacity(pageType) === 1 ? "auto" : "none",
lineHeight: { xs: "1.2", sm: "1.2", md: "1.1" }, wordWrap: "break-word", hyphens: "auto"
}}>{title}</Typography>
))}
</Box>
<Box sx={{ display: "flex", alignItems: "center", flexShrink: 0, justifyContent: "flex-end", position: "relative", ml: { xs: 0, sm: 2 } }}>
{navTexts.map((navItem, index) => {
const isActive = navConfig.rightNav && navConfig.rightNav.text === navItem.text;
return (
<Box key={navItem.key} component={Link} to={navItem.link} sx={{
display: "flex", alignItems: "center", textDecoration: "none", color: "inherit", transition: "all 0.3s ease",
opacity: isActive ? 1 : 0, position: index === 0 ? "relative" : "absolute", right: index !== 0 ? 0 : "auto",
pointerEvents: isActive ? "auto" : "none", "&:hover": { transform: "translateX(5px)", color: "primary.main" }
}}>
<Typography sx={{
fontFamily: "SwashingtonCP", fontSize: { xs: "1.25rem", sm: "1.25rem", md: "2.125rem" },
textShadow: "2px 2px 4px rgba(0, 0, 0, 0.3)", lineHeight: { xs: "1.2", sm: "1.2", md: "1.1" }, whiteSpace: "nowrap"
}}>{navItem.text}</Typography>
<ChevronRight sx={{ fontSize: "2rem", ml: 1 }} />
</Box>
);
})}
</Box>
</Box>
</Box>
<Box sx={{ position: "relative", mb: 4 }}>
{Object.entries(allContentBoxes).map(([pageType, contentBoxes]) => (
<Grid key={pageType} container spacing={0} sx={{
transition: "opacity 0.5s ease-in-out", opacity: getOpacity(pageType),
position: pageType === "home" ? "relative" : "absolute", top: 0, left: 0, width: "100%", pointerEvents: getOpacity(pageType) === 1 ? "auto" : "none"
}}>
{contentBoxes.map((box, index) => (
<ContentBox
key={`${pageType}-${index}`}
box={box}
index={index}
pageType={pageType}
starHovered={starHovered}
setStarHovered={setStarHovered}
opacity={getOpacity(pageType)}
translatedContent={translatedContent}
/>
))}
</Grid>
))}
</Box>
<SharedCarousel />
</Container>
);
};
export default MainPageLayout;

View File

@@ -0,0 +1,189 @@
import React from 'react';
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
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;
class ManufacturerCarousel extends React.Component {
_isMounted = false;
originalItems = [];
animationFrame = null;
translateX = 0;
constructor(props) {
super(props);
this.state = {
items: [], // [{ id, name, src }]
};
this.carouselTrackRef = React.createRef();
}
componentDidMount() {
this._isMounted = true;
this.loadImages();
}
componentWillUnmount() {
this._isMounted = false;
if (this.animationFrame) {
cancelAnimationFrame(this.animationFrame);
this.animationFrame = null;
}
// Revoke object URLs to avoid memory leaks
for (const item of this.originalItems) {
if (item.src) URL.revokeObjectURL(item.src);
}
}
loadImages = () => {
window.socketManager.emit('getHerstellerImages', {}, (res) => {
if (!this._isMounted || !res?.success || !res.manufacturers?.length) return;
const items = res.manufacturers
.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) };
})
.sort(() => Math.random() - 0.5);
if (items.length === 0) return;
this.originalItems = items;
this.setState({ items: [...items, ...items] }, () => {
this.startAutoScroll();
});
});
};
startAutoScroll = () => {
if (!this.animationFrame) {
this.animationFrame = requestAnimationFrame(this.tick);
}
};
tick = () => {
if (!this._isMounted || this.originalItems.length === 0) return;
this.translateX -= AUTO_SCROLL_SPEED;
const maxScroll = ITEM_WIDTH * this.originalItems.length;
if (Math.abs(this.translateX) >= maxScroll) {
this.translateX = 0;
}
if (this.carouselTrackRef.current) {
this.carouselTrackRef.current.style.transform = `translateX(${this.translateX}px)`;
}
this.animationFrame = requestAnimationFrame(this.tick);
};
render() {
const { t } = this.props;
const { items } = this.state;
if (!items || items.length === 0) return null;
return (
<Box sx={{ mt: 4, mb: 4 }}>
<Typography
variant="h4"
component="div"
sx={{
fontFamily: 'SwashingtonCP',
textShadow: '3px 3px 10px rgba(0, 0, 0, 0.4)',
textAlign: 'center',
mb: 2,
color: 'primary.main',
}}
>
{t('product.manufacturer')}
</Typography>
<div
style={{
position: 'relative',
overflow: 'hidden',
width: '100%',
maxWidth: '1200px',
margin: '0 auto',
padding: '0 20px',
boxSizing: 'border-box',
}}
>
{/* Fade edges */}
<div style={{
position: 'absolute', top: 0, left: 0,
width: '60px', height: '100%',
background: 'linear-gradient(to right, #c8e6c9, transparent)',
zIndex: 2, pointerEvents: 'none',
}} />
<div style={{
position: 'absolute', top: 0, right: 0,
width: '60px', height: '100%',
background: 'linear-gradient(to left, #c8e6c9, transparent)',
zIndex: 2, pointerEvents: 'none',
}} />
<div
style={{
position: 'relative',
overflow: 'hidden',
padding: '12px 0',
width: '100%',
maxWidth: '1080px',
margin: '0 auto',
boxSizing: 'border-box',
}}
>
<div
ref={this.carouselTrackRef}
style={{
display: 'flex',
gap: '16px',
alignItems: 'center',
width: 'fit-content',
transform: 'translateX(0px)',
}}
>
{items.map((item, index) => (
<div
key={`${item.id}-${index}`}
style={{
flex: '0 0 140px',
width: '140px',
height: '140px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
userSelect: 'none',
pointerEvents: 'none',
}}
>
<img
src={item.src}
alt={item.name}
draggable={false}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
display: 'block',
}}
/>
</div>
))}
</div>
</div>
</div>
</Box>
);
}
}
export default withTranslation()(withLanguage(ManufacturerCarousel));

View File

@@ -1,381 +0,0 @@
import React, { Component, useState } from "react";
import { Button, Box, Typography, CircularProgress } from "@mui/material";
import config from "../config.js";
// Function to lazy load Mollie script
const loadMollie = () => {
return new Promise((resolve, reject) => {
// Check if Mollie is already loaded
if (window.Mollie) {
resolve(window.Mollie);
return;
}
// Create script element
const script = document.createElement('script');
script.src = 'https://js.mollie.com/v1/mollie.js';
script.async = true;
script.onload = () => {
if (window.Mollie) {
resolve(window.Mollie);
} else {
reject(new Error('Mollie failed to load'));
}
};
script.onerror = () => {
reject(new Error('Failed to load Mollie script'));
};
document.head.appendChild(script);
});
};
const CheckoutForm = ({ mollie }) => {
const [errorMessage, setErrorMessage] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false);
React.useEffect(() => {
if (!mollie) return;
let mountedComponents = {
cardNumber: null,
cardHolder: null,
expiryDate: null,
verificationCode: null
};
try {
// Create Mollie components
const cardNumber = mollie.createComponent('cardNumber');
const cardHolder = mollie.createComponent('cardHolder');
const expiryDate = mollie.createComponent('expiryDate');
const verificationCode = mollie.createComponent('verificationCode');
// Store references for cleanup
mountedComponents = {
cardNumber,
cardHolder,
expiryDate,
verificationCode
};
// Mount components
cardNumber.mount('#card-number');
cardHolder.mount('#card-holder');
expiryDate.mount('#expiry-date');
verificationCode.mount('#verification-code');
// Set up error handling
cardNumber.addEventListener('change', event => {
const errorElement = document.querySelector('#card-number-error');
if (errorElement) {
if (event.error && event.touched) {
errorElement.textContent = event.error;
} else {
errorElement.textContent = '';
}
}
});
cardHolder.addEventListener('change', event => {
const errorElement = document.querySelector('#card-holder-error');
if (errorElement) {
if (event.error && event.touched) {
errorElement.textContent = event.error;
} else {
errorElement.textContent = '';
}
}
});
expiryDate.addEventListener('change', event => {
const errorElement = document.querySelector('#expiry-date-error');
if (errorElement) {
if (event.error && event.touched) {
errorElement.textContent = event.error;
} else {
errorElement.textContent = '';
}
}
});
verificationCode.addEventListener('change', event => {
const errorElement = document.querySelector('#verification-code-error');
if (errorElement) {
if (event.error && event.touched) {
errorElement.textContent = event.error;
} else {
errorElement.textContent = '';
}
}
});
// Components are now mounted and ready
} catch (error) {
console.error('Error creating Mollie components:', error);
setErrorMessage('Failed to initialize payment form. Please try again.');
}
// Cleanup function
return () => {
try {
if (mountedComponents.cardNumber) mountedComponents.cardNumber.unmount();
if (mountedComponents.cardHolder) mountedComponents.cardHolder.unmount();
if (mountedComponents.expiryDate) mountedComponents.expiryDate.unmount();
if (mountedComponents.verificationCode) mountedComponents.verificationCode.unmount();
} catch (error) {
console.error('Error cleaning up Mollie components:', error);
}
};
}, [mollie]);
const handleSubmit = async (event) => {
event.preventDefault();
if (!mollie || isSubmitting) {
return;
}
setIsSubmitting(true);
setErrorMessage(null);
try {
const { token, error } = await mollie.createToken();
if (error) {
setErrorMessage(error.message || 'Payment failed. Please try again.');
setIsSubmitting(false);
return;
}
if (token) {
// Handle successful token creation
// Create a payment completion event similar to Stripe
const mollieCompletionData = {
mollieToken: token,
paymentMethod: 'mollie'
};
// Dispatch a custom event to notify the parent component
const completionEvent = new CustomEvent('molliePaymentComplete', {
detail: mollieCompletionData
});
window.dispatchEvent(completionEvent);
// For now, redirect to profile with completion data
const returnUrl = `${window.location.origin}/profile?complete&mollie_token=${token}`;
window.location.href = returnUrl;
}
} catch (error) {
console.error('Error creating Mollie token:', error);
setErrorMessage('Payment failed. Please try again.');
setIsSubmitting(false);
}
};
return (
<Box sx={{ maxWidth: 600, mx: 'auto', p: 3 }}>
<Typography variant="h6" gutterBottom>
Kreditkarte oder Sofortüberweisung
</Typography>
<form onSubmit={handleSubmit}>
<Box sx={{ mb: 3 }}>
<Typography variant="body2" gutterBottom>
Kartennummer
</Typography>
<Box
id="card-number"
sx={{
p: 2,
border: '1px solid #e0e0e0',
borderRadius: 1,
minHeight: 40,
backgroundColor: '#fff'
}}
/>
<Typography
id="card-number-error"
variant="caption"
sx={{ color: 'error.main', minHeight: 16, display: 'block' }}
/>
</Box>
<Box sx={{ mb: 3 }}>
<Typography variant="body2" gutterBottom>
Karteninhaber
</Typography>
<Box
id="card-holder"
sx={{
p: 2,
border: '1px solid #e0e0e0',
borderRadius: 1,
minHeight: 40,
backgroundColor: '#fff'
}}
/>
<Typography
id="card-holder-error"
variant="caption"
sx={{ color: 'error.main', minHeight: 16, display: 'block' }}
/>
</Box>
<Box sx={{ display: 'flex', gap: 2, mb: 3 }}>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" gutterBottom>
Ablaufdatum
</Typography>
<Box
id="expiry-date"
sx={{
p: 2,
border: '1px solid #e0e0e0',
borderRadius: 1,
minHeight: 40,
backgroundColor: '#fff'
}}
/>
<Typography
id="expiry-date-error"
variant="caption"
sx={{ color: 'error.main', minHeight: 16, display: 'block' }}
/>
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" gutterBottom>
Sicherheitscode
</Typography>
<Box
id="verification-code"
sx={{
p: 2,
border: '1px solid #e0e0e0',
borderRadius: 1,
minHeight: 40,
backgroundColor: '#fff'
}}
/>
<Typography
id="verification-code-error"
variant="caption"
sx={{ color: 'error.main', minHeight: 16, display: 'block' }}
/>
</Box>
</Box>
<Button
variant="contained"
disabled={!mollie || isSubmitting}
type="submit"
fullWidth
sx={{
mt: 2,
backgroundColor: '#2e7d32',
'&:hover': {
backgroundColor: '#1b5e20'
}
}}
>
{isSubmitting ? (
<>
<CircularProgress size={20} sx={{ mr: 1, color: 'white' }} />
Verarbeitung...
</>
) : (
'Bezahlung Abschließen'
)}
</Button>
{errorMessage && (
<Typography
variant="body2"
sx={{ color: 'error.main', mt: 2, textAlign: 'center' }}
>
{errorMessage}
</Typography>
)}
</form>
</Box>
);
};
class Mollie extends Component {
constructor(props) {
super(props);
this.state = {
mollie: null,
loading: true,
error: null,
};
this.molliePromise = loadMollie();
}
componentDidMount() {
this.molliePromise
.then((MollieClass) => {
try {
// Initialize Mollie with profile key
const mollie = MollieClass(config.mollieProfileKey, {
locale: 'de_DE',
testmode: true // Set to false for production
});
this.setState({ mollie, loading: false });
} catch (error) {
console.error('Error initializing Mollie:', error);
this.setState({
error: 'Failed to initialize payment system. Please try again.',
loading: false
});
}
})
.catch((error) => {
console.error('Error loading Mollie:', error);
this.setState({
error: 'Failed to load payment system. Please try again.',
loading: false
});
});
}
render() {
const { mollie, loading, error } = this.state;
if (loading) {
return (
<Box sx={{ textAlign: 'center', py: 4 }}>
<CircularProgress sx={{ color: '#2e7d32' }} />
<Typography variant="body1" sx={{ mt: 2 }}>
Zahlungskomponente wird geladen...
</Typography>
</Box>
);
}
if (error) {
return (
<Box sx={{ textAlign: 'center', py: 4 }}>
<Typography variant="body1" sx={{ color: 'error.main' }}>
{error}
</Typography>
<Button
variant="outlined"
onClick={() => window.location.reload()}
sx={{ mt: 2 }}
>
Seite neu laden
</Button>
</Box>
);
}
return <CheckoutForm mollie={mollie} />;
}
}
export default Mollie;

View File

@@ -0,0 +1,158 @@
import React, { Component } from 'react';
import { Navigate } from 'react-router-dom';
import { Box, CircularProgress, Typography } from '@mui/material';
class PaymentSuccess extends Component {
constructor(props) {
super(props);
this.state = {
redirectUrl: null,
processing: true,
error: null
};
}
componentDidMount() {
this.processMolliePayment();
}
processMolliePayment = () => {
try {
// Get the stored payment ID from localStorage
const pendingPayment = localStorage.getItem('pendingPayment');
if (!pendingPayment) {
console.error('No pending payment found in localStorage');
this.setState({
redirectUrl: '/profile#cart',
processing: false,
error: 'No payment information found'
});
return;
}
let paymentData;
try {
paymentData = JSON.parse(pendingPayment);
// Clear the pending payment data
localStorage.removeItem('pendingPayment');
} catch (error) {
console.error('Error parsing pending payment data:', error);
this.setState({
redirectUrl: '/profile#cart',
processing: false,
error: 'Invalid payment data'
});
return;
}
if (!paymentData.paymentId) {
console.error('No payment ID found in stored payment data');
this.setState({
redirectUrl: '/profile#cart',
processing: false,
error: 'Missing payment ID'
});
return;
}
// Check payment status with backend
this.checkMolliePaymentStatus(paymentData.paymentId);
} catch (error) {
console.error('Error processing Mollie payment:', error);
this.setState({
redirectUrl: '/profile#cart',
processing: false,
error: 'Payment processing failed'
});
}
};
checkMolliePaymentStatus = (paymentId) => {
window.socketManager.emit('checkMollieIntent', { paymentId }, (response) => {
if (response.success) {
console.log('Payment Status:', response.payment.status);
console.log('Is Paid:', response.payment.isPaid);
console.log('Order Created:', response.order.created);
if (response.order.orderId) {
console.log('Order ID:', response.order.orderId);
}
// Build the redirect URL with Mollie completion parameters
const profileUrl = new URL('/profile', window.location.origin);
profileUrl.searchParams.set('mollieComplete', 'true');
profileUrl.searchParams.set('mollie_payment_id', paymentId);
profileUrl.searchParams.set('mollie_status', response.payment.status);
profileUrl.searchParams.set('mollie_amount', response.payment.amount);
profileUrl.searchParams.set('mollie_timestamp', Date.now().toString());
profileUrl.searchParams.set('mollie_is_paid', response.payment.isPaid.toString());
if (response.order.orderId) {
profileUrl.searchParams.set('mollie_order_id', response.order.orderId.toString());
}
// Set hash based on payment success: orders for successful payments, cart for failed payments
profileUrl.hash = response.payment.isPaid ? '#orders' : '#cart';
this.setState({
redirectUrl: profileUrl.pathname + profileUrl.search + profileUrl.hash,
processing: false
});
} else {
console.error('Failed to check payment status:', response.error);
this.setState({
redirectUrl: '/profile#cart',
processing: false,
error: response.error || 'Payment verification failed'
});
}
});
};
render() {
const { redirectUrl, processing, error } = this.state;
if (processing) {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
minHeight: '60vh',
gap: 2
}}
>
<CircularProgress size={60} />
<Typography variant="h6" color="text.secondary">
Zahlung wird überprüft...
</Typography>
<Typography variant="body2" color="text.secondary">
Bitte warten Sie, während wir Ihre Zahlung bei Mollie überprüfen.
</Typography>
</Box>
);
}
if (error) {
return <Navigate to="/profile#cart" replace />;
}
if (redirectUrl) {
return <Navigate to={redirectUrl} replace />;
}
// Fallback redirect to profile
return <Navigate to="/profile#cart" replace />;
}
}
export default PaymentSuccess;

View File

@@ -0,0 +1,286 @@
import React, { Component } from 'react';
import {
Box,
Button,
Typography,
IconButton,
Paper,
Grid,
Alert
} from '@mui/material';
import Delete from '@mui/icons-material/Delete';
import CloudUpload from '@mui/icons-material/CloudUpload';
import { withI18n } from '../i18n/withTranslation.js';
class PhotoUpload extends Component {
constructor(props) {
super(props);
this.state = {
files: [],
previews: [],
error: null
};
this.fileInputRef = React.createRef();
}
handleFileSelect = (event) => {
const selectedFiles = Array.from(event.target.files);
const maxFiles = this.props.maxFiles || 5;
const maxSize = this.props.maxSize || 50 * 1024 * 1024; // 50MB default - will be compressed
// Validate file count
if (this.state.files.length + selectedFiles.length > maxFiles) {
this.setState({
error: this.props.t("productDialogs.photoUploadErrorMaxFiles", { max: maxFiles })
});
return;
}
// Validate file types and sizes
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
const validFiles = [];
const newPreviews = [];
for (const file of selectedFiles) {
if (!validTypes.includes(file.type)) {
this.setState({
error: this.props.t("productDialogs.photoUploadErrorFileType")
});
continue;
}
if (file.size > maxSize) {
this.setState({
error: this.props.t("productDialogs.photoUploadErrorFileSize", { maxSize: Math.round(maxSize / (1024 * 1024)) })
});
continue;
}
validFiles.push(file);
// Create preview and compress image
const reader = new FileReader();
reader.onload = (e) => {
// Compress the image
this.compressImage(e.target.result, file.name, (compressedFile) => {
newPreviews.push({
file: compressedFile,
preview: e.target.result,
name: file.name,
originalSize: file.size,
compressedSize: compressedFile.size
});
if (newPreviews.length === validFiles.length) {
const compressedFiles = newPreviews.map(p => p.file);
this.setState(prevState => ({
files: [...prevState.files, ...compressedFiles],
previews: [...prevState.previews, ...newPreviews],
error: null
}), () => {
// Notify parent component
if (this.props.onChange) {
this.props.onChange(this.state.files);
}
});
}
});
};
reader.readAsDataURL(file);
}
// Reset input
event.target.value = '';
};
handleRemoveFile = (index) => {
this.setState(prevState => {
const newFiles = prevState.files.filter((_, i) => i !== index);
const newPreviews = prevState.previews.filter((_, i) => i !== index);
// Notify parent component
if (this.props.onChange) {
this.props.onChange(newFiles);
}
return {
files: newFiles,
previews: newPreviews
};
});
};
compressImage = (dataURL, fileName, callback) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
img.onload = () => {
// Calculate new dimensions (max 1920x1080 for submission)
const maxWidth = 1920;
const maxHeight = 1080;
let { width, height } = img;
if (width > height) {
if (width > maxWidth) {
height = (height * maxWidth) / width;
width = maxWidth;
}
} else {
if (height > maxHeight) {
width = (width * maxHeight) / height;
height = maxHeight;
}
}
canvas.width = width;
canvas.height = height;
// Draw and compress
ctx.drawImage(img, 0, 0, width, height);
// Convert to blob with compression
canvas.toBlob((blob) => {
const compressedFile = new File([blob], fileName, {
type: 'image/jpeg',
lastModified: Date.now()
});
callback(compressedFile);
}, 'image/jpeg', 0.8); // 80% quality
};
img.src = dataURL;
};
// Method to reset the component
reset = () => {
this.setState({
files: [],
previews: [],
error: null
});
// Also reset the file input
if (this.fileInputRef.current) {
this.fileInputRef.current.value = '';
}
};
render() {
const { files, previews, error } = this.state;
const { disabled, label, t } = this.props;
return (
<Box>
<Typography variant="body2" sx={{ mb: 1, fontWeight: 500 }}>
{label || t("productDialogs.photoUploadLabelDefault")}
</Typography>
<input
ref={this.fileInputRef}
type="file"
multiple
accept="image/*"
style={{ display: 'none' }}
onChange={this.handleFileSelect}
disabled={disabled}
/>
<Button
variant="outlined"
startIcon={<CloudUpload />}
onClick={() => this.fileInputRef.current?.click()}
disabled={disabled}
sx={{ mb: 2 }}
>
{t("productDialogs.photoUploadSelect")}
</Button>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{previews.length > 0 && (
<Grid container spacing={2}>
{previews.map((preview, index) => (
<Grid item xs={6} sm={4} md={3} key={index}>
<Paper
sx={{
position: 'relative',
p: 1,
borderRadius: 1,
overflow: 'hidden'
}}
>
<Box
component="img"
src={preview.preview}
alt={preview.name}
sx={{
width: '100%',
height: '100px',
objectFit: 'cover',
borderRadius: 1
}}
/>
<IconButton
size="small"
onClick={() => this.handleRemoveFile(index)}
disabled={disabled}
aria-label={t("productDialogs.photoUploadRemove")}
sx={{
position: 'absolute',
top: 4,
right: 4,
backgroundColor: 'rgba(0,0,0,0.7)',
color: 'white',
'&:hover': {
backgroundColor: 'rgba(0,0,0,0.9)'
}
}}
>
<Delete fontSize="small" />
</IconButton>
<Typography
variant="caption"
sx={{
position: 'absolute',
bottom: 4,
left: 4,
right: 4,
backgroundColor: 'rgba(0,0,0,0.7)',
color: 'white',
p: 0.5,
borderRadius: 0.5,
fontSize: '0.7rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{preview.name}
</Typography>
</Paper>
</Grid>
))}
</Grid>
)}
{files.length > 0 && (
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
{t("productDialogs.photoUploadSelectedFiles", { count: files.length })}
{previews.length > 0 && previews.some(p => p.originalSize && p.compressedSize) && (
<span style={{ marginLeft: '8px' }}>
{t("productDialogs.photoUploadCompressed")}
</span>
)}
</Typography>
)}
</Box>
);
}
}
export default withI18n()(PhotoUpload);

View File

@@ -7,8 +7,72 @@ import Typography from '@mui/material/Typography';
import CircularProgress from '@mui/material/CircularProgress';
import IconButton from '@mui/material/IconButton';
import AddToCartButton from './AddToCartButton.js';
import { Link } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import { withI18n } from '../i18n/withTranslation.js';
import ZoomInIcon from '@mui/icons-material/ZoomIn';
import { STAR_POLYGON_POINTS } from '../utils/starPolygon.js';
import {
PRODUCT_CARD_MOBILE_MAX_WIDTH_PX,
PRODUCT_CARD_WIDTH_SM_PX,
PRODUCT_CARD_WIDTH_XS_PX,
} from '../utils/productCardLayout.js';
// Helper function to find level 1 category ID from any category ID
const findLevel1CategoryId = (categoryId) => {
try {
const currentLanguage = 'de'; // Default to German
const categoryTreeCache = window.categoryService?.getSync(209, currentLanguage);
if (!categoryTreeCache || !categoryTreeCache.children) {
return null;
}
// Helper function to find category by ID and get its level 1 parent
const findCategoryAndLevel1 = (categories, targetId) => {
for (const category of categories) {
if (category.id === targetId) {
// Found the category, now find its level 1 parent
return findLevel1Parent(categoryTreeCache.children, category);
}
if (category.children && category.children.length > 0) {
const result = findCategoryAndLevel1(category.children, targetId);
if (result) return result;
}
}
return null;
};
// Helper function to find the level 1 parent (direct child of root category 209)
const findLevel1Parent = (level1Categories, category) => {
// If this category's parent is 209, it's already level 1
if (category.parentId === 209) {
return category.id;
}
// Otherwise, find the parent and check if it's level 1
for (const level1Category of level1Categories) {
if (level1Category.id === category.parentId) {
return level1Category.id;
}
// If parent has children, search recursively
if (level1Category.children && level1Category.children.length > 0) {
const result = findLevel1Parent(level1Category.children, category);
if (result) return result;
}
}
return null;
};
return findCategoryAndLevel1(categoryTreeCache.children, parseInt(categoryId));
} catch (error) {
console.error('Error finding level 1 category:', error);
return null;
}
};
class Product extends Component {
constructor(props) {
@@ -20,44 +84,150 @@ class Product extends Component {
window.smallPicCache = {};
}
if(this.props.pictureList && this.props.pictureList.length > 0 && this.props.pictureList.split(',').length > 0) {
const bildId = this.props.pictureList.split(',')[0];
if(window.smallPicCache[bildId]){
this.state = {image:window.smallPicCache[bildId],loading:false, error: false}
}else{
this.state = {image: null, loading: true, error: false};
console.log("Product: Fetching image from socketB", this.props.socketB);
this.props.socketB.emit('getPic', { bildId, size:'small' }, (res) => {
if(res.success){
window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
if (this._isMounted) {
this.setState({image: window.smallPicCache[bildId], loading: false});
} else {
this.state.image = window.smallPicCache[bildId];
this.state.loading = false;
const pictureIds = (this.props.pictureList && this.props.pictureList.length > 0)
? this.props.pictureList.split(',').filter(id => id.trim().length > 0)
: [];
if (pictureIds.length > 0) {
const initialImages = pictureIds.map(id => window.smallPicCache[id] || null);
const isFirstCached = !!initialImages[0];
this.state = {
images: initialImages,
currentImageIndex: 0,
loading: !isFirstCached,
error: false,
isHovering: false
};
pictureIds.forEach((id, index) => {
if (!window.smallPicCache[id]) {
this.loadImage(id, index);
}
});
} else {
console.log('Fehler beim Laden des Bildes:', res);
if (this._isMounted) {
this.setState({error: true, loading: false});
} else {
this.state.error = true;
this.state.loading = false;
}
}
})
}
}else{
this.state = {image: null, loading: false, error: false};
this.state = { images: [], currentImageIndex: 0, loading: false, error: false, isHovering: false };
}
}
componentDidMount() {
this._isMounted = true;
this.startRandomFading();
}
startRandomFading = () => {
if (this.state.isHovering) return;
const pictureIds = (this.props.pictureList && this.props.pictureList.length > 0)
? this.props.pictureList.split(',').filter(id => id.trim().length > 0)
: [];
if (pictureIds.length > 1) {
const minInterval = 4000;
const maxInterval = 8000;
const randomInterval = Math.floor(Math.random() * (maxInterval - minInterval + 1)) + minInterval;
this.fadeTimeout = setTimeout(() => {
if (this._isMounted) {
this.setState(prevState => {
let nextIndex = (prevState.currentImageIndex + 1) % pictureIds.length;
let attempts = 0;
while (!prevState.images[nextIndex] && attempts < pictureIds.length) {
nextIndex = (nextIndex + 1) % pictureIds.length;
attempts++;
}
if (attempts < pictureIds.length && nextIndex !== prevState.currentImageIndex) {
return { currentImageIndex: nextIndex };
}
return null;
}, () => {
this.startRandomFading();
});
}
}, randomInterval);
}
}
handleMouseMove = (e) => {
const pictureIds = (this.props.pictureList && this.props.pictureList.length > 0)
? this.props.pictureList.split(',').filter(id => id.trim().length > 0)
: [];
if (pictureIds.length > 1) {
if (this.fadeTimeout) {
clearTimeout(this.fadeTimeout);
this.fadeTimeout = null;
}
const { left, width } = e.currentTarget.getBoundingClientRect();
const x = e.clientX - left;
const segmentWidth = width / pictureIds.length;
let targetIndex = Math.floor(x / segmentWidth);
if (targetIndex >= pictureIds.length) targetIndex = pictureIds.length - 1;
if (targetIndex < 0) targetIndex = 0;
if (this.state.currentImageIndex !== targetIndex) {
if (this.state.images[targetIndex]) {
this.setState({ currentImageIndex: targetIndex, isHovering: true });
} else {
this.setState({ isHovering: true });
}
} else if (!this.state.isHovering) {
this.setState({ isHovering: true });
}
}
}
handleMouseLeave = () => {
if (this.state.isHovering) {
this.setState({ isHovering: false }, () => {
this.startRandomFading();
});
}
}
loadImage = (bildId, index) => {
console.log('loadImagevisSocket', bildId);
window.socketManager.emit('getPic', { bildId, size: 'small' }, (res) => {
if (res.success) {
window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/avif' }));
if (this._isMounted) {
this.setState(prevState => {
const newImages = [...prevState.images];
newImages[index] = window.smallPicCache[bildId];
return {
images: newImages,
loading: index === 0 ? false : prevState.loading
};
});
} else {
this.state.images[index] = window.smallPicCache[bildId];
if (index === 0) this.state.loading = false;
}
} else {
console.log('Fehler beim Laden des Bildes:', res);
if (this._isMounted) {
this.setState(prevState => ({
error: index === 0 ? true : prevState.error,
loading: index === 0 ? false : prevState.loading
}));
} else {
if (index === 0) {
this.state.error = true;
this.state.loading = false;
}
}
}
});
}
componentWillUnmount() {
this._isMounted = false;
if (this.fadeTimeout) {
clearTimeout(this.fadeTimeout);
}
}
handleQuantityChange = (quantity) => {
@@ -65,11 +235,28 @@ class Product extends Component {
// In a real app, this would update a cart state in a parent component or Redux store
}
handleProductClick = (e) => {
e.preventDefault();
const { categoryId } = this.props;
// Find the level 1 category for this product
const level1CategoryId = categoryId ? findLevel1CategoryId(categoryId) : null;
// Navigate to the product page WITH the category information in the state
const navigate = this.props.navigate;
if (navigate) {
navigate(`/Artikel/${this.props.seoName}`, {
state: { articleCategoryId: level1CategoryId }
});
}
}
render() {
const {
id, name, price, available, manufacturer, seoName,
currency, vat, massMenge, massEinheit, thc,
floweringWeeks,incoming, neu, weight, versandklasse, availableSupplier
currency, vat, cGrundEinheit, fGrundPreis, thc,
floweringWeeks, incoming, neu, weight, versandklasse, availableSupplier, komponenten
} = this.props;
const isNew = neu && (new Date().getTime() - new Date(neu).getTime() < 30 * 24 * 60 * 60 * 1000);
@@ -94,7 +281,16 @@ class Product extends Component {
<Box sx={{
position: 'relative',
height: '100%',
width: { xs: '100%', sm: 'auto' }
/* Match card width on xs so absolute NEU star is relative to the card, not the full grid row */
width: {
xs: `${PRODUCT_CARD_WIDTH_XS_PX}px`,
sm: 'auto',
},
minWidth: { xs: `${PRODUCT_CARD_WIDTH_XS_PX}px`, sm: 'auto' },
maxWidth: { xs: `${PRODUCT_CARD_WIDTH_XS_PX}px`, sm: 'none' },
display: 'flex',
justifyContent: { xs: 'center', sm: 'flex-start' },
mx: { xs: 'auto', sm: 0 },
}}>
{isNew && (
<div
@@ -121,7 +317,7 @@ class Product extends Component {
}}
>
<polygon
points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20"
points={STAR_POLYGON_POINTS}
fill="#20403a"
stroke="none"
/>
@@ -140,7 +336,7 @@ class Product extends Component {
}}
>
<polygon
points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20"
points={STAR_POLYGON_POINTS}
fill="#40736b"
stroke="none"
/>
@@ -153,7 +349,7 @@ class Product extends Component {
height="50"
>
<polygon
points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20"
points={STAR_POLYGON_POINTS}
fill="#609688"
stroke="none"
/>
@@ -163,7 +359,7 @@ class Product extends Component {
<div
style={{
position: 'absolute',
top: '45%',
top: '40%',
left: '45%',
transform: 'translate(-50%, -50%) rotate(-10deg)',
color: 'white',
@@ -173,29 +369,43 @@ class Product extends Component {
zIndex: 1000
}}
>
NEU
{this.props.t ? this.props.t('product.new') : 'NEU'}
</div>
</div>
)}
<Card
sx={{
width: { xs: '100vw', sm: '250px' },
minWidth: { xs: '100vw', sm: '250px' },
width: {
xs: `${PRODUCT_CARD_WIDTH_XS_PX}px`,
sm: `${PRODUCT_CARD_WIDTH_SM_PX}px`,
},
minWidth: {
xs: `${PRODUCT_CARD_WIDTH_XS_PX}px`,
sm: `${PRODUCT_CARD_WIDTH_SM_PX}px`,
},
maxWidth: {
xs: `${PRODUCT_CARD_WIDTH_XS_PX}px`,
sm: `${PRODUCT_CARD_WIDTH_SM_PX}px`,
},
height: '100%',
display: 'flex',
flexDirection: 'column',
transition: 'transform 0.3s ease, box-shadow 0.3s ease',
position: 'relative',
overflow: 'hidden',
borderRadius: { xs: 0, sm: '8px' },
border: { xs: 'none', sm: 'inherit' },
boxShadow: { xs: 'none', sm: 'inherit' },
mx: { xs: 0, sm: 'auto' },
borderRadius: { xs: '8px', sm: '8px' },
border: { xs: '1px solid', sm: 'inherit' },
borderColor: { xs: 'divider', sm: 'inherit' },
boxShadow: { xs: '0 1px 4px rgba(0,0,0,0.08)', sm: 'inherit' },
mx: { xs: 'auto', sm: 'auto' },
'&:hover': {
transform: { xs: 'none', sm: 'translateY(-5px)' },
boxShadow: { xs: 'none', sm: '0px 10px 20px rgba(0,0,0,0.1)' }
}
boxShadow: {
xs: '0 1px 4px rgba(0,0,0,0.08)',
sm: '0px 10px 20px rgba(0,0,0,0.1)',
},
},
}}
>
{showThcBadge && (
@@ -240,23 +450,26 @@ class Product extends Component {
transformOrigin: 'top left'
}}
>
{floweringWeeks} Wochen
{floweringWeeks} {this.props.t ? this.props.t('product.weeks') : 'Wochen'}
</div>
)}
<Box
component={Link}
to={`/Artikel/${seoName}`}
onClick={this.handleProductClick}
sx={{
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
textDecoration: 'none',
color: 'inherit'
color: 'inherit',
cursor: 'pointer'
}}
>
<Box sx={{
<Box
onMouseMove={this.handleMouseMove}
onMouseLeave={this.handleMouseLeave}
sx={{
position: 'relative',
height: { xs: '240px', sm: '180px' },
display: 'flex',
@@ -268,31 +481,62 @@ class Product extends Component {
}}>
{this.state.loading ? (
<CircularProgress sx={{ color: '#90ffc0' }} />
) : this.state.image === null ? (
) : this.state.images && this.state.images.length > 0 && this.state.images.some(img => img !== null) ? (
this.state.images.map((imgSrc, index) => {
if (!imgSrc) return null;
return (
<CardMedia
key={index}
component="img"
height={ window.innerWidth < 600 ? "240" : "180" }
image="/assets/images/nopicture.jpg"
height={window.innerWidth < PRODUCT_CARD_MOBILE_MAX_WIDTH_PX ? "240" : "180"}
image={imgSrc}
alt={name}
fetchPriority={this.props.priority === 'high' && index === 0 ? 'high' : 'auto'}
loading={this.props.priority === 'high' && index === 0 ? 'eager' : 'lazy'}
onError={(e) => {
// Ensure alt text is always present even on error
if (!e.target.alt) {
e.target.alt = name || 'Produktbild';
}
}}
sx={{
objectFit: 'contain',
borderTopLeftRadius: '8px',
borderTopRightRadius: '8px',
width: '100%'
width: '100%',
height: '100%',
position: 'absolute',
top: 0,
left: 0,
opacity: this.state.currentImageIndex === index ? 1 : 0,
transition: this.state.isHovering ? 'opacity 0.2s ease-in-out' : 'opacity 1s ease-in-out'
}}
/>
);
})
) : (
<CardMedia
component="img"
height={ window.innerWidth < 600 ? "240" : "180" }
image={this.state.image}
height={window.innerWidth < PRODUCT_CARD_MOBILE_MAX_WIDTH_PX ? "240" : "180"}
image="/assets/images/nopicture.jpg"
alt={name}
fetchPriority={this.props.priority === 'high' ? 'high' : 'auto'}
loading={this.props.priority === 'high' ? 'eager' : 'lazy'}
onError={(e) => {
// Ensure alt text is always present even on error
if (!e.target.alt) {
e.target.alt = name || 'Produktbild';
}
}}
sx={{
objectFit: 'contain',
borderTopLeftRadius: '8px',
borderTopRightRadius: '8px',
width: '100%'
width: '100%',
height: '100%',
position: 'absolute',
top: 0,
left: 0
}}
/>
)}
@@ -329,20 +573,49 @@ class Product extends Component {
</Typography>
</Box>
<div style={{padding:'0px',margin:'0px',minHeight:'3.8em'}}>
<div style={{ padding: '0px', margin: '0px' }}>
<Typography
variant="h6"
color="primary"
sx={{ mt: 'auto', fontWeight: 'bold', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}
sx={{
fontWeight: 'bold',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
position: 'relative'
}}
>
<span>{new Intl.NumberFormat('de-DE', {style: 'currency', currency: currency || 'EUR'}).format(price)}</span>
<small style={{ color: '#77aa77', fontSize: '0.6em' }}>(incl. {vat}% USt.,*)</small>
<Box sx={{ position: 'relative', display: 'inline-block' }}>
{this.props.rebate && this.props.rebate > 0 && (
<span
style={{
position: 'absolute',
top: -8,
left: -8,
fontWeight: 'bold',
color: 'red',
textDecoration: 'line-through',
opacity: 0.4,
zIndex: 1,
pointerEvents: 'none',
fontSize: 'inherit'
}}
>
{(() => {
const rebatePct = this.props.rebate / 100;
const originalPrice = Math.round((price / (1 - rebatePct)) * 10) / 10;
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: currency || 'EUR' }).format(originalPrice);
})()}
</span>
)}
<span style={{ position: 'relative', zIndex: 2 }}>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: currency || 'EUR' }).format(price)}</span>
</Box>
<small style={{ color: '#77aa77', fontSize: '0.6em' }}>({this.props.t ? this.props.t('product.inclVatFooter', { vat }) : `incl. ${vat}% USt.,*`})</small>
</Typography>
{massMenge != 1 && massEinheit && (<Typography variant="body2" color="text.secondary" sx={{ m: 0,p: 0 }}>
({new Intl.NumberFormat('de-DE', {style: 'currency', currency: currency || 'EUR'}).format(price/massMenge)}/{massEinheit})
</div>
<div style={{ minHeight: '1.5em' }}>
{cGrundEinheit && fGrundPreis && fGrundPreis != price && (<Typography variant="body2" color="text.secondary" sx={{ m: 0, p: 0 }}>
({new Intl.NumberFormat('de-DE', { style: 'currency', currency: currency || 'EUR' }).format(fGrundPreis)}/{cGrundEinheit})
</Typography>)}
</div>
{/*incoming*/}
@@ -354,11 +627,12 @@ class Product extends Component {
component={Link}
to={`/Artikel/${seoName}`}
size="small"
aria-label="Produktdetails anzeigen"
sx={{ mr: 1, color: 'text.secondary' }}
>
<ZoomInIcon />
</IconButton>
<AddToCartButton cartButton={true} availableSupplier={availableSupplier} available={available} incoming={incoming} seoName={seoName} pictureList={this.props.pictureList} id={id} price={price} vat={vat} weight={weight} name={name} versandklasse={versandklasse}/>
<AddToCartButton cartButton={true} availableSupplier={availableSupplier} komponenten={komponenten} cGrundEinheit={cGrundEinheit} fGrundPreis={fGrundPreis} available={available} incoming={incoming} seoName={seoName} pictureList={this.props.pictureList} id={id} price={price} vat={vat} weight={weight} name={name} versandklasse={versandklasse} />
</Box>
</Card>
</Box>
@@ -366,4 +640,10 @@ class Product extends Component {
}
}
export default Product;
// Wrapper component to provide navigate hook
const ProductWithNavigation = (props) => {
const navigate = useNavigate();
return <Product {...props} navigate={navigate} />;
};
export default withI18n()(ProductWithNavigation);

View File

@@ -0,0 +1,773 @@
import React from 'react';
import { Link } from "react-router-dom";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import ChevronLeft from "@mui/icons-material/ChevronLeft";
import ChevronRight from "@mui/icons-material/ChevronRight";
import Product from "./Product.js";
import { withTranslation } from 'react-i18next';
import { withLanguage } from '../i18n/withTranslation.js';
import {
getProductCarouselItemStridePx,
PRODUCT_CARD_WIDTH_SM_PX,
PRODUCT_CARD_WIDTH_XS_PX,
} from "../utils/productCardLayout.js";
const AUTO_SCROLL_SPEED = 0.5; // px per frame (~60fps, so ~30px/sec)
const AUTOSCROLL_RESTART_DELAY = 5000; // 5 seconds of inactivity before restarting autoscroll
const SCROLLBAR_FLASH_DURATION = 3000; // 3 seconds to show the virtual scrollbar
const CAROUSEL_LOG = "[ProductCarousel]";
function logCarousel(phase, detail) {
try {
console.log(CAROUSEL_LOG, phase, detail != null ? detail : "");
} catch {
/* ignore */
}
}
/** Debug summary of getCategoryProducts callback / socket payload. */
function summarizeResponse(response, maxProducts = 8) {
if (response == null) {
return { type: "null" };
}
if (typeof response !== "object") {
return { type: typeof response, value: response };
}
const keys = Object.keys(response);
const products = response.products;
const n = Array.isArray(products) ? products.length : null;
const sample =
Array.isArray(products) && products.length > 0
? products.slice(0, maxProducts).map((p) => ({
id: p?.id,
seoName: p?.seoName,
available: p?.available,
availableSupplier: p?.availableSupplier,
hasPictureList: Boolean(
p?.pictureList &&
(typeof p.pictureList === "string"
? p.pictureList.trim().length > 0
: Array.isArray(p.pictureList)
? p.pictureList.length > 0
: false)
),
}))
: [];
return { keys, productsLength: n, sample };
}
function productHasImage(p) {
const pl = p?.pictureList;
if (!pl) return false;
if (typeof pl === "string") return pl.trim().length > 0;
if (Array.isArray(pl)) return pl.length > 0;
return false;
}
class ProductCarousel extends React.Component {
_isMounted = false;
products = [];
originalProducts = [];
animationFrame = null;
autoScrollActive = true;
translateX = 0;
inactivityTimer = null;
scrollbarTimer = null;
constructor(props) {
super(props);
const { i18n } = props;
this.state = {
products: [],
currentLanguage: (i18n && i18n.language) || 'de',
showScrollbar: false,
itemStride:
typeof window !== "undefined"
? getProductCarouselItemStridePx()
: PRODUCT_CARD_WIDTH_SM_PX + 16,
};
this.carouselTrackRef = React.createRef();
}
handleCarouselResize = () => {
if (!this._isMounted) return;
const next = getProductCarouselItemStridePx();
if (next !== this.state.itemStride) {
this.translateX = 0;
this.updateTrackTransform();
this.setState({ itemStride: next });
}
};
componentDidMount() {
this._isMounted = true;
if (typeof window !== "undefined") {
window.addEventListener("resize", this.handleCarouselResize);
this.setState({ itemStride: getProductCarouselItemStridePx() });
}
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n.language;
logCarousel("mount", {
categoryId: this.props.categoryId,
language: currentLanguage,
filter_availability:
typeof sessionStorage !== "undefined"
? sessionStorage.getItem("filter_availability")
: "(no sessionStorage)",
});
this.loadProducts(currentLanguage);
}
categoryIdsEqual(a, b) {
if (Array.isArray(a) && Array.isArray(b)) {
return a.length === b.length && a.every((v, i) => v === b[i]);
}
return a === b;
}
componentDidUpdate(prevProps) {
const lang = this.props.languageContext?.currentLanguage || this.props.i18n.language;
const langChanged =
prevProps.languageContext?.currentLanguage !==
this.props.languageContext?.currentLanguage;
const catChanged = !this.categoryIdsEqual(
prevProps.categoryId,
this.props.categoryId
);
if (langChanged || catChanged) {
logCarousel("didUpdate:reload", {
langChanged,
catChanged,
prevCategoryId: prevProps.categoryId,
categoryId: this.props.categoryId,
language: lang,
});
}
if (langChanged) {
this.setState({ products: [] }, () => {
this.loadProducts(lang);
});
return;
}
if (catChanged) {
this.setState({ products: [] }, () => {
this.loadProducts(lang);
});
}
}
/** Virtual slugs go to the socket as strings; other ids pass through. */
normalizeSocketCategoryId(id) {
if (id === "neu" || id === "bald") return id;
return id;
}
/**
* neu: stub only (no `full`). bald: `full: true` — one emit ack with the full list.
* Carousel does not subscribe to `productList:*` (no second update).
*/
buildGetCategoryProductsPayload(id, language) {
const slug = this.normalizeSocketCategoryId(id);
const payload = {
categoryId: slug,
language,
requestTranslation: language === "de" ? false : true,
};
if (slug === "bald") {
payload.full = true;
}
return payload;
}
/** Single getCategoryProducts response (emit ack only). */
emitGetCategoryProductsOnce = (rawId, language, onAck) => {
const slug = this.normalizeSocketCategoryId(rawId);
const payload = this.buildGetCategoryProductsPayload(rawId, language);
logCarousel("emit getCategoryProducts (ack only)", { slug, payload });
window.socketManager.emit("getCategoryProducts", payload, (response) => {
logCarousel("getCategoryProducts CALLBACK", {
slug,
summary: summarizeResponse(response),
});
onAck(response);
});
};
finalizeCarouselList = (products) => {
if (!this._isMounted) {
logCarousel("finalizeCarouselList:skip (unmounted)", {
incomingLen: products?.length,
});
return;
}
if (!products || products.length === 0) {
logCarousel("finalizeCarouselList:skip (empty)", {});
return;
}
const shuffledProducts = this.shuffleArray(products).slice(0, 15);
logCarousel("finalizeCarouselList:ok", {
inputCount: products.length,
afterShuffleCap15: shuffledProducts.length,
ids: shuffledProducts.map((p) => p?.id),
});
this.originalProducts = shuffledProducts;
this.products = [...shuffledProducts, ...shuffledProducts];
this.setState({ products: this.products });
this.startAutoScroll();
};
processLoadedProducts = (rawProducts, sourceTag = "processLoadedProducts") => {
if (!this._isMounted) {
logCarousel(`${sourceTag}:skip (unmounted)`, {
rawLen: rawProducts?.length,
});
return;
}
if (!rawProducts || rawProducts.length === 0) {
logCarousel(`${sourceTag}:skip (empty products)`, {});
return;
}
logCarousel(`${sourceTag}:incoming`, {
count: rawProducts.length,
idsSample: rawProducts.slice(0, 12).map((p) => p?.id),
});
this.finalizeCarouselList(rawProducts.slice());
};
/** Merge neu + bald; neu ids win on duplicate. */
processNeuAndBaldMerged = (neuRaw, baldRaw) => {
const neuList = neuRaw || [];
const baldList = baldRaw || [];
const seen = new Set();
const merged = [];
for (const p of neuList) {
if (p && p.id != null && !seen.has(p.id)) {
seen.add(p.id);
merged.push(p);
}
}
let baldWithImage = 0;
for (const p of baldList) {
if (!p || p.id == null || seen.has(p.id)) continue;
if (!productHasImage(p)) continue;
baldWithImage += 1;
seen.add(p.id);
merged.push(p);
}
logCarousel("processNeuAndBaldMerged:counts", {
neu: neuList.length,
bald: baldList.length,
baldWithImage,
mergedUnique: merged.length,
neuIdsSample: neuList.slice(0, 8).map((p) => p?.id),
baldIdsSample: baldList.slice(0, 8).map((p) => p?.id),
});
if (merged.length === 0) {
logCarousel("processNeuAndBaldMerged:skip (merged empty)", {});
return;
}
this.finalizeCarouselList(merged);
};
loadProducts = (language) => {
const { categoryId } = this.props;
const ids = Array.isArray(categoryId) ? categoryId : [categoryId];
const filterAvailability =
typeof sessionStorage !== "undefined"
? sessionStorage.getItem("filter_availability")
: null;
logCarousel("loadProducts:start", {
language,
categoryId,
ids,
filter_availability: filterAvailability,
});
if (ids.length === 1) {
const slug = this.normalizeSocketCategoryId(ids[0]);
this.emitGetCategoryProductsOnce(ids[0], language, (response) => {
let products = response?.products ?? [];
if (slug === "bald") {
const before = products.length;
products = products.filter(productHasImage);
logCarousel("loadProducts:single:bald filter image", {
before,
after: products.length,
});
}
if (products.length > 0) {
this.processLoadedProducts(products, "single");
} else {
logCarousel("loadProducts:single: empty", { slug });
}
});
return;
}
const neuBaldMode =
ids.length === 2 &&
ids.map((x) => this.normalizeSocketCategoryId(x)).sort().join(",") ===
"bald,neu";
if (neuBaldMode) {
logCarousel("loadProducts:mode neu+bald (stub neu + full bald → merge → shuffle → max 15)", {
ids,
});
let neuProducts = null;
let baldProducts = null;
const tryMerge = () => {
if (neuProducts === null || baldProducts === null) {
return;
}
logCarousel("loadProducts:neu+bald merge inputs", {
neuLen: neuProducts.length,
baldLen: baldProducts.length,
});
this.processNeuAndBaldMerged(neuProducts, baldProducts);
};
this.emitGetCategoryProductsOnce("neu", language, (response) => {
neuProducts = response?.products ?? [];
tryMerge();
});
this.emitGetCategoryProductsOnce("bald", language, (response) => {
baldProducts = response?.products ?? [];
tryMerge();
});
return;
}
logCarousel("loadProducts:mode generic merge", { ids });
const mergedById = new Map();
let remaining = ids.length;
const onResponse = (response, idForLog) => {
logCarousel("loadProducts:merge CALLBACK", {
id: idForLog,
summary: summarizeResponse(response),
});
let products = response?.products ?? [];
if (this.normalizeSocketCategoryId(idForLog) === "bald") {
products = products.filter(productHasImage);
}
if (products.length > 0) {
for (const p of products) {
if (p && p.id != null && !mergedById.has(p.id)) {
mergedById.set(p.id, p);
}
}
}
remaining -= 1;
if (remaining === 0) {
logCarousel("loadProducts:merge complete", {
unique: mergedById.size,
idsSample: Array.from(mergedById.keys()).slice(0, 15),
});
this.processLoadedProducts(
Array.from(mergedById.values()),
"genericMerge"
);
}
};
ids.forEach((id) => {
this.emitGetCategoryProductsOnce(id, language, (response) => {
onResponse(response, id);
});
});
}
componentWillUnmount() {
this._isMounted = false;
if (typeof window !== "undefined") {
window.removeEventListener("resize", this.handleCarouselResize);
}
this.stopAutoScroll();
this.clearInactivityTimer();
this.clearScrollbarTimer();
}
startAutoScroll = () => {
this.autoScrollActive = true;
if (!this.animationFrame) {
this.animationFrame = requestAnimationFrame(this.handleAutoScroll);
}
};
stopAutoScroll = () => {
this.autoScrollActive = false;
if (this.animationFrame) {
cancelAnimationFrame(this.animationFrame);
this.animationFrame = null;
}
};
clearInactivityTimer = () => {
if (this.inactivityTimer) {
clearTimeout(this.inactivityTimer);
this.inactivityTimer = null;
}
};
clearScrollbarTimer = () => {
if (this.scrollbarTimer) {
clearTimeout(this.scrollbarTimer);
this.scrollbarTimer = null;
}
};
startInactivityTimer = () => {
this.clearInactivityTimer();
this.inactivityTimer = setTimeout(() => {
if (this._isMounted) {
this.startAutoScroll();
}
}, AUTOSCROLL_RESTART_DELAY);
};
showScrollbarFlash = () => {
this.clearScrollbarTimer();
this.setState({ showScrollbar: true });
this.scrollbarTimer = setTimeout(() => {
if (this._isMounted) {
this.setState({ showScrollbar: false });
}
}, SCROLLBAR_FLASH_DURATION);
};
handleAutoScroll = () => {
if (!this.autoScrollActive || this.originalProducts.length === 0) return;
this.translateX -= AUTO_SCROLL_SPEED;
this.updateTrackTransform();
const { itemStride } = this.state;
const originalItemCount = this.originalProducts.length;
const maxScroll = itemStride * originalItemCount;
// Check if we've scrolled past the first set of items
if (Math.abs(this.translateX) >= maxScroll) {
// Reset to beginning seamlessly
this.translateX = 0;
this.updateTrackTransform();
}
this.animationFrame = requestAnimationFrame(this.handleAutoScroll);
};
updateTrackTransform = () => {
if (this.carouselTrackRef.current) {
this.carouselTrackRef.current.style.transform = `translateX(${this.translateX}px)`;
}
};
handleLeftClick = () => {
this.stopAutoScroll();
this.scrollBy(1);
this.showScrollbarFlash();
this.startInactivityTimer();
};
handleRightClick = () => {
this.stopAutoScroll();
this.scrollBy(-1);
this.showScrollbarFlash();
this.startInactivityTimer();
};
scrollBy = (direction) => {
if (this.originalProducts.length === 0) return;
// direction: 1 = left (scroll content right), -1 = right (scroll content left)
const { itemStride } = this.state;
const originalItemCount = this.originalProducts.length;
const maxScroll = itemStride * originalItemCount;
this.translateX += direction * itemStride;
// Handle wrap-around when scrolling left (positive translateX)
if (this.translateX > 0) {
this.translateX = -(maxScroll - itemStride);
}
// Handle wrap-around when scrolling right (negative translateX beyond limit)
else if (Math.abs(this.translateX) >= maxScroll) {
this.translateX = 0;
}
this.updateTrackTransform();
// Force scrollbar to update immediately after wrap-around
if (this.state.showScrollbar) {
this.forceUpdate();
}
};
renderVirtualScrollbar = () => {
if (!this.state.showScrollbar || this.originalProducts.length === 0) {
return null;
}
const { itemStride } = this.state;
const originalItemCount = this.originalProducts.length;
const viewportWidth =
typeof window !== "undefined"
? Math.min(1080, Math.max(0, window.innerWidth - 56))
: 1080;
const itemsInView = Math.max(1, Math.floor(viewportWidth / itemStride));
// Calculate which item is currently at the left edge (first visible)
let currentItemIndex;
if (this.translateX === 0) {
currentItemIndex = 0;
} else if (this.translateX > 0) {
const maxScroll = itemStride * originalItemCount;
const effectivePosition = maxScroll + this.translateX;
currentItemIndex = Math.floor(effectivePosition / itemStride);
} else {
currentItemIndex = Math.floor(Math.abs(this.translateX) / itemStride);
}
// Ensure we stay within bounds
currentItemIndex = Math.max(0, Math.min(currentItemIndex, originalItemCount - 1));
// Calculate scrollbar position
const lastPossibleFirstItem = Math.max(0, originalItemCount - itemsInView);
const thumbPosition = lastPossibleFirstItem > 0 ? Math.min((currentItemIndex / lastPossibleFirstItem) * 100, 100) : 0;
return (
<div
className="virtual-scrollbar"
style={{
position: 'absolute',
bottom: '5px',
left: '50%',
transform: 'translateX(-50%)',
width: '200px',
height: '4px',
backgroundColor: 'rgba(0, 0, 0, 0.1)',
borderRadius: '2px',
zIndex: 1000,
opacity: this.state.showScrollbar ? 1 : 0,
transition: 'opacity 0.3s ease-in-out'
}}
>
<div
className="scrollbar-thumb"
style={{
position: 'absolute',
top: '0',
left: `${thumbPosition}%`,
width: '20px',
height: '4px',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
borderRadius: '2px',
transform: 'translateX(-50%)',
transition: 'left 0.2s ease-out'
}}
/>
</div>
);
};
render() {
const { t, title } = this.props;
const { products } = this.state;
if (!products || products.length === 0) {
return null;
}
return (
<Box sx={{ mt: 3 }}>
<Box
component={Link}
to="/Kategorie/neu"
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
textDecoration: "none",
color: "primary.main",
mb: 2,
transition: "all 0.3s ease",
"&:hover": {
transform: "translateX(5px)",
color: "primary.dark"
}
}}
>
<Typography
variant="h4"
component="span"
sx={{
fontFamily: "SwashingtonCP",
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)"
}}
>
{title || t('product.new')}
</Typography>
<ChevronRight sx={{ fontSize: "2.5rem", ml: 1 }} />
</Box>
<div
className="product-carousel-wrapper"
style={{
position: 'relative',
overflowX: 'hidden',
overflowY: 'visible',
width: '100%',
maxWidth: '1200px',
margin: '0 auto',
padding: '0 20px',
boxSizing: 'border-box'
}}
>
{/* Left Arrow */}
<IconButton
aria-label="Vorherige Produkte 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 Produkte 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
className="product-carousel-container"
style={{
position: 'relative',
overflowX: 'hidden',
overflowY: 'visible',
padding: '20px 0',
width: '100%',
maxWidth: '1080px',
margin: '0 auto',
zIndex: 1,
boxSizing: 'border-box'
}}
>
<div
className="product-carousel-track"
ref={this.carouselTrackRef}
style={{
display: 'flex',
gap: '16px',
transition: 'none',
alignItems: 'flex-start',
width: 'fit-content',
overflow: 'visible',
position: 'relative',
transform: 'translateX(0px)',
margin: '0 auto'
}}
>
{products.map((product, index) => (
<Box
key={`${product.id}-${index}`}
className="product-carousel-item"
sx={{
flex: {
xs: `0 0 ${PRODUCT_CARD_WIDTH_XS_PX}px`,
sm: `0 0 ${PRODUCT_CARD_WIDTH_SM_PX}px`,
},
width: { xs: PRODUCT_CARD_WIDTH_XS_PX, sm: PRODUCT_CARD_WIDTH_SM_PX },
maxWidth: { xs: PRODUCT_CARD_WIDTH_XS_PX, sm: PRODUCT_CARD_WIDTH_SM_PX },
minWidth: { xs: PRODUCT_CARD_WIDTH_XS_PX, sm: PRODUCT_CARD_WIDTH_SM_PX },
boxSizing: "border-box",
position: "relative",
}}
>
<Product
id={product.id}
name={product.name}
seoName={product.seoName}
price={product.price}
currency={product.currency}
available={product.available}
manufacturer={product.manufacturer}
vat={product.vat}
cGrundEinheit={product.cGrundEinheit}
fGrundPreis={product.fGrundPreis}
incoming={product.incomingDate}
neu={product.neu}
thc={product.thc}
floweringWeeks={product.floweringWeeks}
versandklasse={product.versandklasse}
weight={product.weight}
pictureList={product.pictureList}
availableSupplier={product.availableSupplier}
komponenten={product.komponenten}
rebate={product.rebate}
categoryId={product.kategorien ? product.kategorien.split(',')[0] : undefined}
priority={index < 6 ? 'high' : 'auto'}
t={t}
/>
</Box>
))}
</div>
{/* Virtual Scrollbar */}
{this.renderVirtualScrollbar()}
</div>
</div>
</Box>
);
}
// Shuffle array using Fisher-Yates algorithm
shuffleArray(array) {
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}
}
export default withTranslation()(withLanguage(ProductCarousel));

View File

@@ -0,0 +1,4 @@
// This file re-exports ProductDetailWithSocket to maintain compatibility with App.js imports
import ProductDetailWithSocket from './ProductDetailWithSocket.js';
export default ProductDetailWithSocket;

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,21 @@
import React from 'react';
import { useParams, useNavigate, useLocation } from 'react-router-dom';
import SocketContext from '../contexts/SocketContext.js';
import ProductDetailPage from './ProductDetailPage.js';
import { useProduct } from '../context/ProductContext.js';
// Wrapper component for individual product detail page with socket
const ProductDetailWithSocket = () => {
const { seoName } = useParams();
const navigate = useNavigate();
const location = useLocation();
const { setCurrentProduct } = useProduct();
return (
<SocketContext.Consumer>
{({socket,socketB}) => <ProductDetailPage seoName={seoName} navigate={navigate} location={location} socket={socket} socketB={socketB} />}
</SocketContext.Consumer>
<ProductDetailPage
seoName={seoName}
navigate={navigate}
location={location}
setCurrentProduct={setCurrentProduct}
/>
);
};

View File

@@ -1,12 +1,35 @@
import React, { Component } from 'react';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import Tooltip from '@mui/material/Tooltip';
import CircularProgress from '@mui/material/CircularProgress';
import NotificationsIcon from '@mui/icons-material/Notifications';
import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive';
import Filter from './Filter.js';
import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom';
import { setSessionSetting, removeSessionSetting, clearAllSessionSettings } from '../utils/sessionStorage.js';
import { withI18n } from '../i18n/withTranslation.js';
import {
PUSH_SUBSCRIPTIONS_CHANGED_EVENT,
emitPushSubscriptionsChanged,
isPushApiSupported,
fetchPushConfiguration,
registerPushServiceWorker,
ensurePushSubscription,
categoryPushStatus,
categoryPushSubscribe,
categoryPushUnsubscribe,
parseSubscribedStatus,
parseSuccess,
} from '../utils/categoryPush.js';
const isNew = (neu) => neu && (new Date().getTime() - new Date(neu).getTime() < 30 * 24 * 60 * 60 * 1000);
/** Category push subscribe UI only when the category has more than this many articles. */
const MIN_ARTICLES_FOR_CATEGORY_PUSH = 10;
// HOC to provide router props to class components
const withRouter = (ClassComponent) => {
return (props) => {
@@ -35,30 +58,230 @@ class ProductFilters extends Component {
this.state = {
availabilityValues,
uniqueManufacturerArray,
attributeGroups
attributeGroups,
manufacturerImages: new Map(), // id (number) → object URL
pushInteractive: false,
pushSubscribed: false,
pushBusy: false,
pushError: null,
};
this._manufacturerImageUrls = []; // track for cleanup
}
componentDidMount() {
// Measure the available space dynamically
this.onPushSubscriptionsChanged = () => {
this.refreshCategoryPushStatus();
};
this.adjustPaperHeight();
// Add event listener for window resize
window.addEventListener('resize', this.adjustPaperHeight);
window.addEventListener(
PUSH_SUBSCRIPTIONS_CHANGED_EVENT,
this.onPushSubscriptionsChanged
);
this._loadManufacturerImages();
this.refreshCategoryPushStatus();
}
componentWillUnmount() {
// Remove event listener when component unmounts
window.removeEventListener('resize', this.adjustPaperHeight);
window.removeEventListener(
PUSH_SUBSCRIPTIONS_CHANGED_EVENT,
this.onPushSubscriptionsChanged
);
this._manufacturerImageUrls.forEach(url => URL.revokeObjectURL(url));
}
adjustPaperHeight = () => {
// Skip height adjustment on xs screens
if (window.innerWidth < 600) return;
_loadManufacturerImages = () => {
window.socketManager.emit('getHerstellerImages', {}, (res) => {
if (!res?.success || !res.manufacturers?.length) return;
const map = new Map();
for (const m of res.manufacturers) {
if (!m.imageBuffer) continue;
const blob = new Blob([m.imageBuffer], { type: 'image/avif' });
const url = URL.createObjectURL(blob);
map.set(m.id, url);
this._manufacturerImageUrls.push(url);
}
this.setState({ manufacturerImages: map });
});
};
// Get reference to our paper element
componentDidUpdate(prevProps) {
// Regenerate values when products, attributes, or language changes
const productsChanged = this.props.products !== prevProps.products;
const attributesChanged = this.props.attributes !== prevProps.attributes;
const languageChanged = this.props.i18n && prevProps.i18n && this.props.i18n.language !== prevProps.i18n.language;
const tFunctionChanged = this.props.t !== prevProps.t;
if(languageChanged) {
console.log('ProductFilters: Language changed, will update when new data arrives');
}
if(productsChanged || languageChanged || tFunctionChanged) {
console.log('ProductFilters: Updating manufacturers and availability', {
productsChanged,
languageChanged,
tFunctionChanged,
productCount: this.props.products?.length
});
const uniqueManufacturerArray = this._getUniqueManufacturers(this.props.products);
const availabilityValues = this._getAvailabilityValues(this.props.products);
this.setState({uniqueManufacturerArray, availabilityValues});
}
if(attributesChanged || (languageChanged && this.props.attributes)) {
console.log('ProductFilters: Updating attributes', {
attributesChanged,
languageChanged,
attributeCount: this.props.attributes?.length,
firstAttribute: this.props.attributes?.[0]
});
const attributeGroups = this._getAttributeGroups(this.props.attributes);
this.setState({attributeGroups});
}
const prevCount = prevProps.products?.length || 0;
const nextCount = this.props.products?.length || 0;
if (
prevProps.dataParam !== this.props.dataParam ||
prevProps.dataType !== this.props.dataType ||
prevProps.params?.categoryId !== this.props.params?.categoryId ||
prevCount !== nextCount
) {
this.refreshCategoryPushStatus();
}
}
kKategorieNumber = () => {
const { dataParam, dataType } = this.props;
if (dataType !== 'category') return null;
if (dataParam == null || dataParam === '') return null;
const n = typeof dataParam === 'number' ? dataParam : parseInt(String(dataParam), 10);
return Number.isFinite(n) && n > 0 ? n : null;
};
shouldShowCategoryPush = () =>
(this.props.products?.length || 0) > MIN_ARTICLES_FOR_CATEGORY_PUSH;
refreshCategoryPushStatus = async () => {
const kKat = this.kKategorieNumber();
if (!kKat || !this.shouldShowCategoryPush() || !isPushApiSupported()) {
this.setState({
pushInteractive: false,
pushSubscribed: false,
pushError: null,
});
return;
}
try {
const cfg = await fetchPushConfiguration();
if (!cfg.configured || !cfg.publicKey) {
this.setState({ pushInteractive: false });
return;
}
await registerPushServiceWorker();
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (!subscription) {
this.setState({
pushInteractive: true,
pushSubscribed: false,
pushError: null,
});
return;
}
const statusData = await categoryPushStatus(kKat, subscription.endpoint);
this.setState({
pushInteractive: true,
pushSubscribed: parseSubscribedStatus(statusData),
pushError: null,
});
} catch (e) {
console.warn('ProductFilters: category push init failed', e);
this.setState({ pushInteractive: false });
}
};
handleCategoryPushClick = async () => {
const t = this.props.t;
if (!this.state.pushInteractive || this.state.pushBusy) return;
const kKat = this.kKategorieNumber();
if (!kKat || !this.shouldShowCategoryPush()) return;
this.setState({ pushBusy: true, pushError: null });
try {
if (this.state.pushSubscribed) {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (!subscription) {
this.setState({ pushSubscribed: false, pushBusy: false });
return;
}
const res = await categoryPushUnsubscribe(subscription.endpoint, kKat);
if (parseSuccess(res)) {
this.setState({ pushSubscribed: false });
emitPushSubscriptionsChanged();
} else {
this.setState({
pushError:
res?.message ||
res?.error ||
(t ? t('productDialogs.pushNotifyError') : ''),
});
}
} else {
const perm = await Notification.requestPermission();
if (perm !== 'granted') {
this.setState({
pushError: t ? t('productDialogs.pushNotifyPermissionDenied') : '',
pushBusy: false,
});
return;
}
const cfg = await fetchPushConfiguration();
if (!cfg.configured || !cfg.publicKey) {
this.setState({
pushError: t ? t('productDialogs.pushNotifyServerDisabled') : '',
pushBusy: false,
});
return;
}
await registerPushServiceWorker();
const subscription = await ensurePushSubscription(cfg.publicKey);
const res = await categoryPushSubscribe(kKat, subscription);
if (parseSuccess(res)) {
this.setState({ pushSubscribed: true });
emitPushSubscriptionsChanged();
} else {
this.setState({
pushError:
res?.message ||
res?.error ||
(t ? t('productDialogs.pushNotifyError') : ''),
});
}
}
} catch (e) {
console.error('ProductFilters: category push', e);
this.setState({
pushError: e.message || (t ? t('productDialogs.pushNotifyError') : ''),
});
} finally {
this.setState({ pushBusy: false });
}
};
adjustPaperHeight = () => {
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;
@@ -93,14 +316,14 @@ class ProductFilters extends Component {
}
_getAvailabilityValues = (products) => {
const filters = [{id:1,name:'auf Lager'}];
const filters = [{id:1,name: this.props.t ? this.props.t('product.inStock') : 'auf Lager'}];
for(const product of products){
if(isNew(product.neu)){
if(!filters.find(filter => filter.id == 2)) filters.push({id:2,name:'Neu'});
if(!filters.find(filter => filter.id == 2)) filters.push({id:2,name: this.props.t ? this.props.t('product.new') : 'Neu'});
}
if(!product.available && product.incomingDate){
if(!filters.find(filter => filter.id == 3)) filters.push({id:3,name:'Bald verfügbar'});
if(!filters.find(filter => filter.id == 3)) filters.push({id:3,name: this.props.t ? this.props.t('product.comingSoon') : 'Bald verfügbar'});
}
}
return filters
@@ -115,19 +338,6 @@ class ProductFilters extends Component {
return attributeGroups;
}
shouldComponentUpdate(nextProps) {
if(nextProps.products !== this.props.products) {
const uniqueManufacturerArray = this._getUniqueManufacturers(nextProps.products);
const availabilityValues = this._getAvailabilityValues(nextProps.products);
this.setState({uniqueManufacturerArray, availabilityValues});
}
if(nextProps.attributes !== this.props.attributes) {
const attributeGroups = this._getAttributeGroups(nextProps.attributes);
this.setState({attributeGroups});
}
return true;
}
generateAttributeFilters = () => {
const filters = [];
const sortedAttributeGroups = Object.values(this.state.attributeGroups)
@@ -159,41 +369,146 @@ class ProductFilters extends Component {
}
render() {
const kKategorie = this.kKategorieNumber();
const showCategoryPush = kKategorie && this.shouldShowCategoryPush();
const {
pushInteractive,
pushSubscribed,
pushBusy,
pushError,
} = this.state;
const pushDisabledHint =
showCategoryPush && !pushInteractive && !pushBusy
? isPushApiSupported()
? this.props.t
? this.props.t('productDialogs.pushNotifyServerDisabled')
: ''
: this.props.t
? this.props.t('filters.notifyNewArticlesBrowserUnsupported')
: 'Ihr Browser unterstützt keine Push-Benachrichtigungen.'
: '';
return (
<Box
sx={{
px: { xs: 2, sm: 0 },
pt: { xs: 2, sm: 0 },
/* Room below Paper so elevation shadow isnt clipped by grid/parent */
pb: { xs: 2, sm: 2 },
overflow: 'visible',
/* Same green as ProductList / product strip mobile (#e8f5e8), not theme background.default (#C8E6C9) */
bgcolor: { xs: '#e8f5e8', sm: 'transparent' },
}}
>
<Paper
id="filters-paper"
elevation={window.innerWidth < 600 ? 0 : 1}
elevation={1}
sx={{
p: { xs: 1, sm: 2 },
borderRadius: { xs: 0, sm: 2 },
p: { xs: 2.5, sm: 2.5 },
mx: { sm: 'auto' },
maxWidth: '100%',
borderRadius: 2,
bgcolor: 'background.paper',
display: 'flex',
flexDirection: 'column',
border: { xs: 'none', sm: 'inherit' },
boxShadow: { xs: 'none', sm: 'inherit' },
mx: { xs: 0, sm: 'auto' },
width: { xs: '100%', sm: 'auto' }
boxSizing: 'border-box',
overflow: 'visible',
}}
>
{this.props.dataType == 'category' && (
<Box sx={{ mb: 4 }}>
<Typography
variant="h3"
component="h1"
sx={{
mb: 4,
mb: showCategoryPush ? 1.5 : 4,
fontFamily: 'SwashingtonCP',
color: 'primary.main'
color: 'primary.main',
}}
>
{this.props.dataParam}
{this.props.categoryName}
</Typography>
{showCategoryPush && (
<Box sx={{ width: '100%' }}>
<Tooltip title={pushDisabledHint} arrow>
<span style={{ display: 'block', width: '100%' }}>
<Button
fullWidth
variant="outlined"
color="inherit"
size="small"
onClick={this.handleCategoryPushClick}
disabled={!pushInteractive || pushBusy}
startIcon={
pushBusy ? (
<CircularProgress size={14} sx={{ color: 'inherit' }} />
) : pushSubscribed ? (
<NotificationsActiveIcon sx={{ fontSize: 18, color: '#2e7d32' }} />
) : (
<NotificationsIcon sx={{ fontSize: 18, color: 'rgba(0,0,0,0.65)' }} />
)
}
sx={{
borderRadius: 1,
fontWeight: 600,
fontSize: '0.7rem',
lineHeight: 1.2,
backgroundColor: '#fff',
color: 'text.primary',
border: '1px solid',
borderColor: 'divider',
boxShadow: 'none',
whiteSpace: 'normal',
textAlign: 'center',
py: 0.4,
px: 0.75,
minHeight: 28,
'& .MuiButton-label': {
whiteSpace: 'normal',
lineHeight: 1.2,
},
'& .MuiButton-startIcon': {
mr: 0.5,
'& > *:nth-of-type(1)': { fontSize: 18 },
},
'&:hover': {
backgroundColor: 'grey.50',
borderColor: 'divider',
boxShadow: 'none',
},
'&.Mui-disabled': {
backgroundColor: '#fff',
color: 'action.disabled',
borderColor: 'action.disabledBackground',
},
}}
>
{this.props.t
? this.props.t('filters.notifyNewArticles')
: 'Bei neuen Artikeln benachrichtigen'}
</Button>
</span>
</Tooltip>
{pushError && (
<Typography
variant="caption"
color="error"
sx={{ display: 'block', mt: 0.5, textAlign: 'center' }}
>
{pushError}
</Typography>
)}
</Box>
)}
</Box>
)}
{this.props.products.length > 0 && (
<><Filter
title="Verfügbarkeit"
title={this.props.t ? this.props.t('filters.availability') : 'Verfügbarkeit'}
options={this.state.availabilityValues}
searchParams={this.props.searchParams}
products={this.props.products}
@@ -236,12 +551,13 @@ class ProductFilters extends Component {
{this.generateAttributeFilters()}
<Filter
title="Hersteller"
title={this.props.t ? this.props.t('filters.manufacturer') : 'Hersteller'}
options={this.state.uniqueManufacturerArray}
filterType="manufacturer"
products={this.props.products}
filteredProducts={this.props.filteredProducts}
attributes={this.props.attributes}
manufacturerImages={this.state.manufacturerImages}
onFilterChange={(msg)=>{
if(msg.value) {
setSessionSetting(`filter_${msg.type}_${msg.name}`, 'true');
@@ -253,8 +569,9 @@ class ProductFilters extends Component {
/>
</>)}
</Paper>
</Box>
);
}
}
export default withRouter(ProductFilters);
export default withRouter(withI18n()(ProductFilters));

View File

@@ -0,0 +1,56 @@
import React from 'react';
import Box from '@mui/material/Box';
import CardMedia from '@mui/material/CardMedia';
import Images from './Images.js';
const ProductImage = ({
product,
fullscreenOpen,
onOpenFullscreen,
onCloseFullscreen
}) => {
// Container styling - unified for all versions
const containerSx = {
width: { xs: "100%", sm: "555px" },
maxWidth: "100%",
minHeight: "400px",
background: "#f8f8f8",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
};
return (
<Box sx={containerSx}>
{!product.pictureList && (
<CardMedia
component="img"
height="400"
image="/assets/images/nopicture.jpg"
alt={product.name}
fetchPriority="high"
loading="eager"
onError={(e) => {
// Ensure alt text is always present even on error
if (!e.target.alt) {
e.target.alt = product.name || 'Produktbild';
}
}}
sx={{ objectFit: "cover" }}
/>
)}
{product.pictureList && (
<Images
pictureList={product.pictureList}
productName={product.name}
fullscreenOpen={fullscreenOpen}
onOpenFullscreen={onOpenFullscreen}
onCloseFullscreen={onCloseFullscreen}
/>
)}
</Box>
);
};
export default ProductImage;

View File

@@ -11,6 +11,7 @@ import Chip from '@mui/material/Chip';
import Stack from '@mui/material/Stack';
import Product from './Product.js';
import { removeSessionSetting } from '../utils/sessionStorage.js';
import { withI18n } from '../i18n/withTranslation.js';
// Sort products by fuzzy similarity to their name/description
function sortProductsByFuzzySimilarity(products, searchTerm) {
@@ -141,12 +142,12 @@ class ProductList extends Component {
onChange={this.handlePageChange}
color="primary"
size={"large"}
siblingCount={window.innerWidth < 600 ? 0 : 1}
boundaryCount={window.innerWidth < 600 ? 1 : 1}
hideNextButton={false}
hidePrevButton={false}
showFirstButton={window.innerWidth >= 600}
showLastButton={window.innerWidth >= 600}
siblingCount={1}
boundaryCount={1}
hideNextButton={true}
hidePrevButton={true}
showFirstButton={false}
showLastButton={false}
sx={{
'& .MuiPagination-ul': {
flexWrap: 'nowrap',
@@ -184,7 +185,7 @@ class ProductList extends Component {
px: 2
}}>
<Typography variant="h6" color="text.secondary" sx={{ textAlign: 'center' }}>
Entferne Filter um Produkte zu sehen
{this.props.t ? this.props.t('product.removeFiltersToSee') : 'Entferne Filter um Produkte zu sehen'}
</Typography>
</Box>
);
@@ -200,14 +201,14 @@ class ProductList extends Component {
if (!isFiltered) {
// No filters applied
if (filteredCount === 0) return "0 Produkte";
if (filteredCount === 1) return "1 Produkt";
return `${filteredCount} Produkte`;
if (filteredCount === 0) return this.props.t ? this.props.t('product.countDisplay.noProducts') : "0 Produkte";
if (filteredCount === 1) return this.props.t ? this.props.t('product.countDisplay.oneProduct') : "1 Produkt";
return this.props.t ? this.props.t('product.countDisplay.multipleProducts', { count: filteredCount }) : `${filteredCount} Produkte`;
} else {
// Filters applied
if (totalCount === 0) return "0 Produkte";
if (totalCount === 1) return `${filteredCount} von 1 Produkt`;
return `${filteredCount} von ${totalCount} Produkten`;
if (totalCount === 0) return this.props.t ? this.props.t('product.countDisplay.noProducts') : "0 Produkte";
if (totalCount === 1) return this.props.t ? this.props.t('product.countDisplay.filteredOneProduct', { filtered: filteredCount }) : `${filteredCount} von 1 Produkt`;
return this.props.t ? this.props.t('product.countDisplay.filteredProducts', { filtered: filteredCount, total: totalCount }) : `${filteredCount} von ${totalCount} Produkten`;
}
}
@@ -240,7 +241,7 @@ class ProductList extends Component {
<Box sx={{
display: 'flex',
display: { xs: 'none', sm: 'flex' },
gap: { xs: 0.5, sm: 1 },
alignItems: 'center',
flexWrap: 'wrap',
@@ -327,13 +328,13 @@ class ProductList extends Component {
minWidth: { xs: 120, sm: 140 }
}}
>
<InputLabel id="sort-by-label">Sortierung</InputLabel>
<InputLabel id="sort-by-label">{this.props.t ? this.props.t('filters.sorting') : 'Sortierung'}</InputLabel>
<Select
size="small"
labelId="sort-by-label"
value={(this.state.sortBy==='searchField')&&(window.currentSearchQuery)?this.state.sortBy:this.state.sortBy==='price-low-high'?this.state.sortBy:this.state.sortBy==='price-low-high'?this.state.sortBy:'name'}
onChange={this.handleSortChange}
label="Sortierung"
label={this.props.t ? this.props.t('filters.sorting') : 'Sortierung'}
MenuProps={{
disableScrollLock: true,
anchorOrigin: {
@@ -353,10 +354,10 @@ class ProductList extends Component {
}
}}
>
<MenuItem value="name">Name</MenuItem>
{window.currentSearchQuery && <MenuItem value="searchField">Suchbegriff</MenuItem>}
<MenuItem value="price-low-high">Preis: Niedrig zu Hoch</MenuItem>
<MenuItem value="price-high-low">Preis: Hoch zu Niedrig</MenuItem>
<MenuItem value="name">{this.props.t ? this.props.t('sorting.name') : 'Name'}</MenuItem>
{window.currentSearchQuery && <MenuItem value="searchField">{this.props.t ? this.props.t('sorting.searchField') : 'Suchbegriff'}</MenuItem>}
<MenuItem value="price-low-high">{this.props.t ? this.props.t('sorting.priceLowHigh') : 'Preis: Niedrig zu Hoch'}</MenuItem>
<MenuItem value="price-high-low">{this.props.t ? this.props.t('sorting.priceHighLow') : 'Preis: Hoch zu Niedrig'}</MenuItem>
</Select>
</FormControl>
@@ -368,12 +369,12 @@ class ProductList extends Component {
minWidth: { xs: 80, sm: 100 }
}}
>
<InputLabel id="products-per-page-label">pro Seite</InputLabel>
<InputLabel id="products-per-page-label">{this.props.t ? this.props.t('filters.perPage') : 'pro Seite'}</InputLabel>
<Select
labelId="products-per-page-label"
value={this.state.itemsPerPage}
onChange={this.handleProductsPerPageChange}
label="pro Seite"
label={this.props.t ? this.props.t('filters.perPage') : 'pro Seite'}
MenuProps={{
disableScrollLock: true,
anchorOrigin: {
@@ -398,7 +399,7 @@ class ProductList extends Component {
>
<MenuItem value={20}>20</MenuItem>
<MenuItem value={50}>50</MenuItem>
<MenuItem value="all">Alle</MenuItem>
<MenuItem value="all">{this.props.t ? this.props.t('filters.all') : 'Alle'}</MenuItem>
</Select>
</FormControl>
@@ -429,7 +430,7 @@ class ProductList extends Component {
<Stack direction="row" spacing={2} sx={{ display: { xs: 'none', sm: 'flex' }, px: { xs: 1, sm: 0 } }}>
<Typography variant="body2" color="text.secondary">
{/*this.props.dataType == 'category' && (<>Kategorie: {this.props.dataParam}</>)}*/}
{this.props.dataType == 'search' && (<>Suchergebnisse für: "{this.props.dataParam}"</>)}
{this.props.dataType == 'search' && (this.props.t ? this.props.t('search.searchResultsFor', { query: this.props.dataParam }) : `Suchergebnisse für: "${this.props.dataParam}"`)}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>
{this.getProductCountText()}
@@ -437,7 +438,11 @@ class ProductList extends Component {
</Stack>
</Box>
<Grid container spacing={{ xs: 0, sm: 2 }}>
<Grid
container
spacing={{ xs: 0, sm: 2 }}
sx={{ bgcolor: { xs: '#e8f5e8', sm: 'transparent' } }}
>
{this.renderNoProductsMessage()}
{products.map((product, index) => (
<Grid
@@ -447,6 +452,7 @@ class ProductList extends Component {
justifyContent: { xs: 'stretch', sm: 'center' },
mb: { xs: 0, sm: 1 },
width: { xs: '100%', sm: 'auto' },
bgcolor: { xs: '#e8f5e8', sm: 'transparent' },
borderBottom: {
xs: index < products.length - 1 ? '16px solid #e8f5e8' : 'none',
sm: 'none'
@@ -462,18 +468,21 @@ class ProductList extends Component {
available={product.available}
manufacturer={product.manufacturer}
vat={product.vat}
massMenge={product.massMenge}
massEinheit={product.massEinheit}
cGrundEinheit={product.cGrundEinheit}
fGrundPreis={product.fGrundPreis}
incoming={product.incomingDate}
neu={product.neu}
thc={product.thc}
floweringWeeks={product.floweringWeeks}
versandklasse={product.versandklasse}
weight={product.weight}
socket={this.props.socket}
socketB={this.props.socketB}
pictureList={product.pictureList}
availableSupplier={product.availableSupplier}
komponenten={product.komponenten}
rebate={product.rebate}
categoryId={product.kategorien ? product.kategorien.split(',')[0] : undefined}
priority={index < 6 ? 'high' : 'auto'}
t={this.props.t}
/>
</Grid>
))}
@@ -495,4 +504,4 @@ class ProductList extends Component {
}
}
export default ProductList;
export default withI18n()(ProductList);

View File

@@ -0,0 +1,431 @@
import React from 'react';
import { Link } from 'react-router-dom';
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import ChevronLeft from "@mui/icons-material/ChevronLeft";
import ChevronRight from "@mui/icons-material/ChevronRight";
import CategoryBox from "./CategoryBox.js";
import ProductCarousel from "./ProductCarousel.js";
import ManufacturerCarousel from "./ManufacturerCarousel.js";
import { withTranslation } from 'react-i18next';
import { withLanguage } from '../i18n/withTranslation.js';
const ITEM_WIDTH = 130 + 16; // 130px width + 16px gap
const AUTO_SCROLL_SPEED = 0.5; // px per frame (~60fps, so ~30px/sec)
const AUTOSCROLL_RESTART_DELAY = 5000; // 5 seconds of inactivity before restarting autoscroll
const SCROLLBAR_FLASH_DURATION = 3000; // 3 seconds to show the virtual scrollbar
class SharedCarousel extends React.Component {
_isMounted = false;
categories = [];
originalCategories = [];
animationFrame = null;
autoScrollActive = true;
translateX = 0;
inactivityTimer = null;
scrollbarTimer = null;
constructor(props) {
super(props);
const { i18n } = props;
// Don't load categories in constructor - will be loaded in componentDidMount with correct language
this.state = {
categories: [],
currentLanguage: (i18n && i18n.language) || 'de',
showScrollbar: false,
};
this.carouselTrackRef = React.createRef();
}
componentDidMount() {
this._isMounted = true;
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n.language;
// ALWAYS reload categories to ensure correct language
console.log("SharedCarousel componentDidMount: ALWAYS RELOADING categories for language", currentLanguage);
window.categoryService.get(209, currentLanguage).then((response) => {
console.log("SharedCarousel categoryService.get response for language '" + currentLanguage + "':", response);
if (this._isMounted && response.children && response.children.length > 0) {
console.log("SharedCarousel: Setting categories with", response.children.length, "items");
console.log("SharedCarousel: First category name:", response.children[0]?.name);
this.originalCategories = response.children;
// Duplicate for seamless looping
this.categories = [...response.children, ...response.children];
this.setState({ categories: this.categories });
this.startAutoScroll();
}
});
}
componentDidUpdate(prevProps) {
console.log("componentDidUpdate", prevProps.languageContext?.currentLanguage, this.props.languageContext?.currentLanguage);
if (prevProps.languageContext?.currentLanguage !== this.props.languageContext?.currentLanguage) {
this.setState({ categories: [] }, () => {
window.categoryService.get(209, this.props.languageContext?.currentLanguage || this.props.i18n.language).then((response) => {
console.log("response", response);
if (response.children && response.children.length > 0) {
this.originalCategories = response.children;
this.categories = [...response.children, ...response.children];
this.setState({ categories: this.categories });
this.startAutoScroll();
}
});
});
}
}
componentWillUnmount() {
this._isMounted = false;
this.stopAutoScroll();
this.clearInactivityTimer();
this.clearScrollbarTimer();
}
startAutoScroll = () => {
this.autoScrollActive = true;
if (!this.animationFrame) {
this.animationFrame = requestAnimationFrame(this.handleAutoScroll);
}
};
stopAutoScroll = () => {
this.autoScrollActive = false;
if (this.animationFrame) {
cancelAnimationFrame(this.animationFrame);
this.animationFrame = null;
}
};
clearInactivityTimer = () => {
if (this.inactivityTimer) {
clearTimeout(this.inactivityTimer);
this.inactivityTimer = null;
}
};
clearScrollbarTimer = () => {
if (this.scrollbarTimer) {
clearTimeout(this.scrollbarTimer);
this.scrollbarTimer = null;
}
};
startInactivityTimer = () => {
this.clearInactivityTimer();
this.inactivityTimer = setTimeout(() => {
if (this._isMounted) {
this.startAutoScroll();
}
}, AUTOSCROLL_RESTART_DELAY);
};
showScrollbarFlash = () => {
this.clearScrollbarTimer();
this.setState({ showScrollbar: true });
this.scrollbarTimer = setTimeout(() => {
if (this._isMounted) {
this.setState({ showScrollbar: false });
}
}, SCROLLBAR_FLASH_DURATION);
};
handleAutoScroll = () => {
if (!this.autoScrollActive || this.originalCategories.length === 0) return;
this.translateX -= AUTO_SCROLL_SPEED;
this.updateTrackTransform();
const originalItemCount = this.originalCategories.length;
const maxScroll = ITEM_WIDTH * originalItemCount;
// Check if we've scrolled past the first set of items
if (Math.abs(this.translateX) >= maxScroll) {
// Reset to beginning seamlessly
this.translateX = 0;
this.updateTrackTransform();
}
this.animationFrame = requestAnimationFrame(this.handleAutoScroll);
};
updateTrackTransform = () => {
if (this.carouselTrackRef.current) {
this.carouselTrackRef.current.style.transform = `translateX(${this.translateX}px)`;
}
};
handleLeftClick = () => {
this.stopAutoScroll();
this.scrollBy(1);
this.showScrollbarFlash();
this.startInactivityTimer();
};
handleRightClick = () => {
this.stopAutoScroll();
this.scrollBy(-1);
this.showScrollbarFlash();
this.startInactivityTimer();
};
scrollBy = (direction) => {
if (this.originalCategories.length === 0) return;
// direction: 1 = left (scroll content right), -1 = right (scroll content left)
const originalItemCount = this.originalCategories.length;
const maxScroll = ITEM_WIDTH * originalItemCount;
this.translateX += direction * ITEM_WIDTH;
// Handle wrap-around when scrolling left (positive translateX)
if (this.translateX > 0) {
this.translateX = -(maxScroll - ITEM_WIDTH);
}
// Handle wrap-around when scrolling right (negative translateX beyond limit)
else if (Math.abs(this.translateX) >= maxScroll) {
this.translateX = 0;
}
this.updateTrackTransform();
// Force scrollbar to update immediately after wrap-around
if (this.state.showScrollbar) {
this.forceUpdate();
}
};
renderVirtualScrollbar = () => {
if (!this.state.showScrollbar || this.originalCategories.length === 0) {
return null;
}
const originalItemCount = this.originalCategories.length;
const viewportWidth = 1080; // carousel container max-width
const itemsInView = Math.floor(viewportWidth / ITEM_WIDTH);
// Calculate which item is currently at the left edge (first visible)
// Map translateX directly to item index using the same logic as scrollBy
let currentItemIndex;
if (this.translateX === 0) {
// At the beginning - item 0 is visible
currentItemIndex = 0;
} else if (this.translateX > 0) {
// Wrapped to show end items (this happens when scrolling left past beginning)
const maxScroll = ITEM_WIDTH * originalItemCount;
const effectivePosition = maxScroll + this.translateX;
currentItemIndex = Math.floor(effectivePosition / ITEM_WIDTH);
} else {
// Normal negative scrolling - calculate which item is at left edge
currentItemIndex = Math.floor(Math.abs(this.translateX) / ITEM_WIDTH);
}
// Ensure we stay within bounds
currentItemIndex = Math.max(0, Math.min(currentItemIndex, originalItemCount - 1));
// Calculate scrollbar position: 0% when item 0 is first visible, 100% when last item is first visible
const lastPossibleFirstItem = Math.max(0, originalItemCount - itemsInView);
const thumbPosition = lastPossibleFirstItem > 0 ? Math.min((currentItemIndex / lastPossibleFirstItem) * 100, 100) : 0;
return (
<div
className="virtual-scrollbar"
style={{
position: 'absolute',
bottom: '5px',
left: '50%',
transform: 'translateX(-50%)',
width: '200px',
height: '4px',
backgroundColor: 'rgba(0, 0, 0, 0.1)',
borderRadius: '2px',
zIndex: 1000,
opacity: this.state.showScrollbar ? 1 : 0,
transition: 'opacity 0.3s ease-in-out'
}}
>
<div
className="scrollbar-thumb"
style={{
position: 'absolute',
top: '0',
left: `${thumbPosition}%`,
width: '20px',
height: '4px',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
borderRadius: '2px',
transform: 'translateX(-50%)',
transition: 'left 0.2s ease-out'
}}
/>
</div>
);
};
render() {
const { t } = this.props;
const { categories } = this.state;
if (!categories || categories.length === 0) {
return null;
}
return (
<Box sx={{ mt: 3 }}>
<Box
component={Link}
to="/Kategorien"
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
textDecoration: "none",
color: "primary.main",
mb: 2,
transition: "all 0.3s ease",
"&:hover": {
transform: "translateX(5px)",
color: "primary.dark"
}
}}
>
<Typography
variant="h4"
component="span"
sx={{
fontFamily: "SwashingtonCP",
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)"
}}
>
{t('navigation.categories')}
</Typography>
<ChevronRight sx={{ fontSize: "2.5rem", ml: 1 }} />
</Box>
<div
className="carousel-wrapper"
style={{
position: 'relative',
overflow: 'hidden',
width: '100%',
maxWidth: '1200px',
margin: '0 auto',
padding: '0 20px',
boxSizing: 'border-box'
}}
>
{/* Left Arrow */}
<IconButton
aria-label="Vorherige Kategorien 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 Kategorien 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
className="carousel-container"
style={{
position: 'relative',
overflow: 'hidden',
padding: '20px 0',
width: '100%',
maxWidth: '1080px',
margin: '0 auto',
zIndex: 1,
boxSizing: 'border-box'
}}
>
<div
className="home-carousel-track"
ref={this.carouselTrackRef}
style={{
display: 'flex',
gap: '16px',
transition: 'none',
alignItems: 'flex-start',
width: 'fit-content',
overflow: 'visible',
position: 'relative',
transform: 'translateX(0px)',
margin: '0 auto'
}}
>
{categories.map((category, index) => (
<div
key={`${category.id}-${index}`}
className="carousel-item"
style={{
flex: '0 0 130px',
width: '130px',
maxWidth: '130px',
minWidth: '130px',
height: '130px',
maxHeight: '130px',
minHeight: '130px',
boxSizing: 'border-box',
position: 'relative'
}}
>
<CategoryBox
id={category.id}
name={category.name}
seoName={category.seoName}
image={category.image}
bgcolor={category.bgcolor}
/>
</div>
))}
</div>
{/* Virtual Scrollbar */}
{this.renderVirtualScrollbar()}
</div>
</div>
{/* Product Carousel: virtual categories Neuheiten + Demnächst */}
<ProductCarousel categoryId={["neu", "bald"]} />
{/* Manufacturer logo carousel */}
<ManufacturerCarousel />
</Box>
);
}
}
export default withTranslation()(withLanguage(SharedCarousel));

View File

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

View File

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

View File

@@ -0,0 +1,53 @@
import React, { Component } from 'react';
import { withProduct } from '../context/ProductContext.js';
import { withCategory } from '../context/CategoryContext.js';
// Utility function to clean product names (duplicated from ProductDetailPage to ensure consistency)
const cleanProductName = (name) => {
if (!name) return "";
// Remove patterns like " (1)", " (3)", " (10)" at the end of the string
return name.replace(/\s*\(\d+\)\s*$/, "").trim();
};
class TitleUpdater extends Component {
componentDidMount() {
this.updateTitle();
}
componentDidUpdate(prevProps) {
console.log('TitleUpdater: Update triggered', {
prevProduct: prevProps.productContext.currentProduct,
currProduct: this.props.productContext.currentProduct,
prevCategory: prevProps.categoryContext.currentCategory,
currCategory: this.props.categoryContext.currentCategory
});
if (
prevProps.productContext.currentProduct !== this.props.productContext.currentProduct ||
prevProps.categoryContext.currentCategory !== this.props.categoryContext.currentCategory
) {
this.updateTitle();
}
}
updateTitle() {
const { currentProduct } = this.props.productContext;
const { currentCategory } = this.props.categoryContext;
console.log('TitleUpdater: Updating title with', { currentProduct, currentCategory });
if (currentProduct && currentProduct.name) {
document.title = `GrowHeads.de - ${cleanProductName(currentProduct.name)}`;
} else if (currentCategory && currentCategory.name) {
document.title = `GrowHeads.de - ${currentCategory.name}`;
} else {
document.title = 'GrowHeads.de';
}
}
render() {
return null;
}
}
export default withCategory(withProduct(TitleUpdater));

View File

@@ -1,12 +1,13 @@
import React, { Component } from 'react';
import Grid from '@mui/material/Grid';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import CardMedia from '@mui/material/CardMedia';
import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box';
import Checkbox from '@mui/material/Checkbox';
import FormControlLabel from '@mui/material/FormControlLabel';
import Stack from '@mui/material/Stack';
import CircularProgress from '@mui/material/CircularProgress';
import { Link } from 'react-router-dom';
import IconButton from '@mui/material/IconButton';
import ZoomInIcon from '@mui/icons-material/ZoomIn';
class ExtrasSelector extends Component {
formatPrice(price) {
@@ -16,127 +17,178 @@ class ExtrasSelector extends Component {
}).format(price);
}
// Render product image using working code from GrowTentKonfigurator
renderProductImage(product) {
if (!window.smallPicCache) {
window.smallPicCache = {};
}
const pictureList = product.pictureList;
if (!pictureList || pictureList.length === 0 || !pictureList.split(',').length) {
return (
<CardMedia
component="img"
height="160"
image="/assets/images/nopicture.jpg"
alt={product.name || 'Produktbild'}
sx={{
objectFit: 'contain',
width: '100%'
}}
/>
);
}
const bildId = pictureList.split(',')[0];
if (window.smallPicCache[bildId]) {
return (
<CardMedia
component="img"
height="160"
image={window.smallPicCache[bildId]}
alt={product.name || 'Produktbild'}
sx={{
objectFit: 'contain',
width: '100%'
}}
/>
);
}
// Load image if not cached
if (!this.loadingImages) this.loadingImages = new Set();
if (!this.loadingImages.has(bildId)) {
this.loadingImages.add(bildId);
window.socketManager.emit('getPic', { bildId, size:'small' }, (res) => {
if (res.success) {
window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/avif' }));
this.forceUpdate();
}
this.loadingImages.delete(bildId);
});
}
return (
<Box sx={{ height: '160px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<CircularProgress sx={{ color: '#90ffc0' }} />
</Box>
);
}
renderExtraCard(extra) {
const { selectedExtras, onExtraToggle, showImage = true } = this.props;
const isSelected = selectedExtras.includes(extra.id);
return (
<Card
key={extra.id}
sx={{
<Box sx={{
width: { xs: '100%', sm: '250px' },
height: '100%',
display: 'flex',
flexDirection: 'column',
borderRadius: '8px',
overflow: 'hidden',
cursor: 'pointer',
border: '2px solid',
borderColor: isSelected ? '#2e7d32' : '#e0e0e0',
backgroundColor: isSelected ? '#f1f8e9' : '#ffffff',
'&:hover': {
boxShadow: 5,
boxShadow: 6,
borderColor: isSelected ? '#2e7d32' : '#90caf9'
},
transition: 'all 0.3s ease',
cursor: 'pointer'
transition: 'all 0.3s ease'
}}
onClick={() => onExtraToggle(extra.id)}
>
onClick={() => onExtraToggle(extra.id)}>
{/* Image */}
{showImage && (
<CardMedia
component="img"
height="160"
image={extra.image}
alt={extra.name}
sx={{ objectFit: 'cover' }}
/>
)}
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 2 }}>
<FormControlLabel
control={
<Checkbox
checked={isSelected}
onChange={(e) => {
e.stopPropagation();
onExtraToggle(extra.id);
}}
sx={{
color: '#2e7d32',
'&.Mui-checked': { color: '#2e7d32' },
padding: 0
}}
/>
}
label=""
sx={{ margin: 0 }}
onClick={(e) => e.stopPropagation()}
/>
<Typography variant="h6" sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
{this.formatPrice(extra.price)}
</Typography>
<Box sx={{
height: { xs: '240px', sm: '180px' },
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#ffffff'
}}>
{this.renderProductImage(extra)}
</Box>
)}
{/* Content */}
<Box sx={{ p: 2, flexGrow: 1, display: 'flex', flexDirection: 'column' }}>
{/* Name */}
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>
{extra.name}
</Typography>
<Typography variant="body2" color="text.secondary">
{extra.description}
<Typography gutterBottom>
{extra.kurzBeschreibung}
</Typography>
{isSelected && (
<Box sx={{ mt: 2, textAlign: 'center' }}>
<Typography variant="body2" sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
Hinzugefügt
</Typography>
</Box>
{/* Price with VAT - Same as other sections */}
<Typography variant="h6" sx={{
color: '#2e7d32',
fontWeight: 'bold',
mt: 2,
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'wrap'
}}>
<span>{extra.price ? this.formatPrice(extra.price) : 'Kein Preis'}</span>
{extra.vat && (
<small style={{ color: '#77aa77', fontSize: '0.6em' }}>
(incl. {extra.vat}% MwSt.,*)
</small>
)}
</CardContent>
</Card>
</Typography>
{/* Selection Indicator - Separate line */}
{isSelected && (
<Typography variant="body2" sx={{
color: '#2e7d32',
fontWeight: 'bold',
mt: 1,
textAlign: 'center'
}}>
Ausgewählt
</Typography>
)}
<Stack direction="row" spacing={1} justifyContent="center">
<IconButton
component={Link}
to={`/Artikel/${extra.seoName}`}
size="small"
aria-label="Produktdetails anzeigen"
sx={{ mr: 1, color: 'text.secondary' }}
onClick={(event) => event.stopPropagation()}
>
<ZoomInIcon />
</IconButton>
</Stack>
</Box>
</Box>
);
}
render() {
const { extras, title, subtitle, groupByCategory = true, gridSize = { xs: 12, sm: 6, md: 4 } } = this.props;
if (groupByCategory) {
// Group extras by category
const groupedExtras = extras.reduce((acc, extra) => {
if (!acc[extra.category]) {
acc[extra.category] = [];
}
acc[extra.category].push(extra);
return acc;
}, {});
const { extras, title, subtitle, gridSize = { xs: 12, sm: 6, md: 4 } } = this.props;
if (!extras || !Array.isArray(extras)) {
return (
<Box sx={{ mb: 4 }}>
<Typography variant="h5" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
<Typography variant="h2" component="h2" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
{title}
</Typography>
{subtitle && (
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
{subtitle}
Keine Extras verfügbar
</Typography>
)}
{Object.entries(groupedExtras).map(([category, categoryExtras]) => (
<Box key={category} sx={{ mb: 3 }}>
<Typography variant="h6" gutterBottom sx={{ color: '#1976d2' }}>
{category}
</Typography>
<Grid container spacing={2}>
{categoryExtras.map(extra => (
<Grid item {...gridSize} key={extra.id}>
{this.renderExtraCard(extra)}
</Grid>
))}
</Grid>
</Box>
))}
</Box>
);
}
// Render without category grouping
return (
<Box sx={{ mb: 4 }}>
<Typography variant="h5" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
<Typography variant="h2" component="h2" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
{title}
</Typography>
{subtitle && (

View File

@@ -6,6 +6,10 @@ import CardMedia from '@mui/material/CardMedia';
import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import Stack from '@mui/material/Stack';
import { Link } from 'react-router-dom';
import IconButton from '@mui/material/IconButton';
import ZoomInIcon from '@mui/icons-material/ZoomIn';
class ProductSelector extends Component {
formatPrice(price) {
@@ -65,6 +69,19 @@ class ProductSelector extends Component {
Ausgewählt
</Typography>
)}
<Stack direction="row" spacing={1} justifyContent="center">
<IconButton
component={Link}
to={`/Artikel/${product.seoName}`}
size="small"
aria-label="Produktdetails anzeigen"
sx={{ mr: 1, color: 'text.secondary' }}
onClick={(event) => event.stopPropagation()}
>
<ZoomInIcon />
</IconButton>
</Stack>
</Box>
</CardContent>
</Card>
@@ -147,7 +164,7 @@ class ProductSelector extends Component {
return (
<Box sx={{ mb: 4 }}>
<Typography variant="h5" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
<Typography variant="h2" component="h2" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
{title}
</Typography>
{subtitle && (

View File

@@ -5,6 +5,7 @@ import CardContent from '@mui/material/CardContent';
import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import { withI18n } from '../../i18n/withTranslation.js';
class TentShapeSelector extends Component {
// Generate plant layout based on tent shape
@@ -90,7 +91,7 @@ class TentShapeSelector extends Component {
onClick={() => onShapeSelect(shape.id)}
>
<CardContent sx={{ textAlign: 'center', p: 3 }}>
<Typography variant="h5" gutterBottom sx={{ fontWeight: 'bold' }}>
<Typography variant="h4" component="h4" gutterBottom sx={{ fontWeight: 'bold' }}>
{shape.name}
</Typography>
@@ -180,12 +181,20 @@ class TentShapeSelector extends Component {
</Box>
<Typography variant="body2" color="text.secondary" gutterBottom>
{shape.description}
{this.props.t && shape.descriptionKey ? this.props.t(shape.descriptionKey) : shape.description}
</Typography>
<Box sx={{ mt: 2 }}>
<Chip
label={`${shape.minPlants}-${shape.maxPlants} Pflanzen`}
label={this.props.t
? (
shape.minPlants === 1 && shape.maxPlants === 2 ? this.props.t("kitConfig.plants1to2") :
shape.minPlants === 2 && shape.maxPlants === 4 ? this.props.t("kitConfig.plants2to4") :
shape.minPlants === 4 && shape.maxPlants === 6 ? this.props.t("kitConfig.plants4to6") :
shape.minPlants === 3 && shape.maxPlants === 6 ? this.props.t("kitConfig.plants3to6") :
`${shape.minPlants}-${shape.maxPlants} Pflanzen`
)
: `${shape.minPlants}-${shape.maxPlants} Pflanzen`}
size="small"
sx={{
bgcolor: isSelected ? '#2e7d32' : '#f0f0f0',
@@ -205,7 +214,7 @@ class TentShapeSelector extends Component {
transition: 'opacity 0.3s ease'
}}
>
Ausgewählt
{this.props.t ? this.props.t("kitConfig.selected") : "✓ Ausgewählt"}
</Typography>
</Box>
</CardContent>
@@ -218,7 +227,7 @@ class TentShapeSelector extends Component {
return (
<Box sx={{ mb: 4 }}>
<Typography variant="h5" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
<Typography variant="h2" component="h2" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
{title}
</Typography>
{subtitle && (
@@ -238,4 +247,4 @@ class TentShapeSelector extends Component {
}
}
export default TentShapeSelector;
export default withI18n()(TentShapeSelector);

View File

@@ -1,4 +1,4 @@
import React, { Component } from 'react';
import React, { Component, lazy } from 'react';
import Box from '@mui/material/Box';
import Badge from '@mui/material/Badge';
import Drawer from '@mui/material/Drawer';
@@ -8,9 +8,18 @@ import Typography from '@mui/material/Typography';
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
import CloseIcon from '@mui/icons-material/Close';
import { useNavigate } from 'react-router-dom';
import LoginComponent from '../LoginComponent.js';
import CircularProgress from '@mui/material/CircularProgress';
import { Suspense } from 'react';
//import LoginComponent from '../LoginComponent.js';
const LoginComponent = lazy(() => import(/* webpackChunkName: "login" */ "../LoginComponent.js"));
import CartDropdown from '../CartDropdown.js';
import LanguageSwitcher from '../LanguageSwitcher.js';
import { isUserLoggedIn } from '../LoginComponent.js';
import { withI18n } from '../../i18n/withTranslation.js';
function getBadgeNumber() {
let count = 0;
@@ -32,9 +41,8 @@ class ButtonGroup extends Component {
componentDidMount() {
this.cart = () => {
// @note Only emit if socket exists, is connected, AND the update didn't come from socket
if (this.props.socket && this.props.socket.connected && !this.isUpdatingFromSocket) {
this.props.socket.emit('updateCart', window.cart);
if (!this.isUpdatingFromSocket) {
window.socketManager.emit('updateCart', window.cart);
}
this.setState({
@@ -51,19 +59,6 @@ class ButtonGroup extends Component {
this.addSocketListeners();
}
componentDidUpdate(prevProps) {
// Handle socket connection changes
const wasConnected = prevProps.socket && prevProps.socket.connected;
const isNowConnected = this.props.socket && this.props.socket.connected;
if (!wasConnected && isNowConnected) {
// Socket just connected, add listeners
this.addSocketListeners();
} else if (wasConnected && !isNowConnected) {
// Socket just disconnected, remove listeners
this.removeSocketListeners();
}
}
componentWillUnmount() {
window.removeEventListener('cart', this.cart);
@@ -72,16 +67,17 @@ class ButtonGroup extends Component {
}
addSocketListeners = () => {
if (this.props.socket && this.props.socket.connected) {
// Remove existing listeners first to avoid duplicates
this.removeSocketListeners();
this.props.socket.on('cartUpdated', this.handleCartUpdated);
if (window.socketManager) {
window.socketManager.on('cartUpdated', this.handleCartUpdated);
}
}
removeSocketListeners = () => {
if (this.props.socket) {
this.props.socket.off('cartUpdated', this.handleCartUpdated);
if (window.socketManager) {
window.socketManager.off('cartUpdated', this.handleCartUpdated);
}
}
@@ -116,19 +112,22 @@ class ButtonGroup extends Component {
}
render() {
const { socket, navigate } = this.props;
const { navigate, t } = this.props;
const { isCartOpen } = this.state;
const cartItems = Array.isArray(window.cart) ? window.cart : [];
return (
<Box sx={{ display: 'flex', gap: { xs: 0.5, sm: 1 } }}>
<LoginComponent socket={socket} />
<LanguageSwitcher />
<Suspense fallback={<CircularProgress size={20} />}>
<LoginComponent/>
</Suspense>
<IconButton
color="inherit"
onClick={this.toggleCart}
aria-label="Warenkorb öffnen"
sx={{ ml: 1 }}
>
<Badge badgeContent={this.state.badgeNumber} color="error">
@@ -154,6 +153,7 @@ class ButtonGroup extends Component {
<IconButton
onClick={this.toggleCart}
size="small"
aria-label="Warenkorb schließen"
sx={{
bgcolor: 'primary.main',
color: 'primary.contrastText',
@@ -164,16 +164,16 @@ class ButtonGroup extends Component {
>
<CloseIcon />
</IconButton>
<Typography variant="h6">Warenkorb</Typography>
<Typography variant="h6">{t ? t('cart.title') : 'Warenkorb'}</Typography>
</Box>
<Divider sx={{ mb: 2 }} />
<CartDropdown cartItems={cartItems} socket={socket} onClose={this.toggleCart} onCheckout={()=>{
<CartDropdown cartItems={cartItems} onClose={this.toggleCart} onCheckout={()=>{
/*open the Drawer inside <LoginComponent */
if (isUserLoggedIn().isLoggedIn) {
this.toggleCart(); // Close the cart drawer
navigate('/profile');
navigate('/profile#cart');
} else if (window.openLoginDrawer) {
window.openLoginDrawer(); // Call global function to open login drawer
this.toggleCart(); // Close the cart drawer
@@ -189,10 +189,11 @@ class ButtonGroup extends Component {
}
}
// Wrapper for ButtonGroup to provide navigate function
// Wrapper for ButtonGroup to provide navigate function and translations
const ButtonGroupWithRouter = (props) => {
const navigate = useNavigate();
return <ButtonGroup {...props} navigate={navigate} />;
const ButtonGroupWithTranslation = withI18n()(ButtonGroup);
return <ButtonGroupWithTranslation {...props} navigate={navigate} />;
};
export default ButtonGroupWithRouter;

View File

@@ -6,316 +6,138 @@ import Typography from "@mui/material/Typography";
import Collapse from "@mui/material/Collapse";
import { Link } from "react-router-dom";
import HomeIcon from "@mui/icons-material/Home";
import FiberNewIcon from '@mui/icons-material/FiberNew';
import LocalShippingIcon from "@mui/icons-material/LocalShipping";
import SettingsIcon from "@mui/icons-material/Settings";
import MenuIcon from "@mui/icons-material/Menu";
import CloseIcon from "@mui/icons-material/Close";
import { withI18n } from "../../i18n/withTranslation.js";
class CategoryList extends Component {
findCategoryById = (category, targetId) => {
if (!category) return null;
if (category.seoName === targetId) {
return category;
}
if (category.children) {
for (let child of category.children) {
const found = this.findCategoryById(child, targetId);
if (found) return found;
}
}
return null;
};
getPathToCategory = (category, targetId, currentPath = []) => {
if (!category) return null;
const newPath = [...currentPath, category];
if (category.seoName === targetId) {
return newPath;
}
if (category.children) {
for (let child of category.children) {
const found = this.getPathToCategory(child, targetId, newPath);
if (found) return found;
}
}
return null;
};
constructor(props) {
super(props);
//const { i18n } = props;
const categories = window.categoryService.getSync(209);
// Check for cached data during SSR/initial render
let initialState = {
categoryTree: null,
level1Categories: [], // Children of category 209 (Home) - always shown
level2Categories: [], // Children of active level 1 category
level3Categories: [], // Children of active level 2 category
activePath: [], // Array of active category objects for each level
fetchedCategories: false,
mobileMenuOpen: false, // State for mobile collapsible menu
this.state = {
categories: categories && categories.children && categories.children.length > 0 ? categories.children : [],
mobileMenuOpen: false,
activeCategoryId: null // Will be set properly after categories are loaded
};
// Try to get cached data for SSR
try {
// @note Check both global.window (SSR) and window (browser) for cache
const productCache = (typeof global !== "undefined" && global.window && global.window.productCache) ||
(typeof window !== "undefined" && window.productCache);
if (productCache) {
const cacheKey = "categoryTree_209";
const cachedData = productCache[cacheKey];
if (cachedData && cachedData.categoryTree) {
const { categoryTree, timestamp } = cachedData;
const cacheAge = Date.now() - timestamp;
const tenMinutes = 10 * 60 * 1000;
// Use cached data if it's fresh
if (cacheAge < tenMinutes) {
initialState.categoryTree = categoryTree;
initialState.fetchedCategories = true;
// Process category tree to set up navigation
const level1Categories =
categoryTree && categoryTree.id === 209
? categoryTree.children || []
: [];
initialState.level1Categories = level1Categories;
// Process active category path if needed
if (props.activeCategoryId) {
const activeCategory = this.findCategoryById(
categoryTree,
props.activeCategoryId
);
if (activeCategory) {
const pathToActive = this.getPathToCategory(
categoryTree,
props.activeCategoryId
);
initialState.activePath = pathToActive
? pathToActive.slice(1)
: [];
if (initialState.activePath.length >= 1) {
const level1Category = initialState.activePath[0];
initialState.level2Categories = level1Category.children || [];
}
if (initialState.activePath.length >= 2) {
const level2Category = initialState.activePath[1];
initialState.level3Categories = level2Category.children || [];
}
}
}
}
}
}
} catch (err) {
console.error("Error reading cache in constructor:", err);
}
this.state = initialState;
this.productCategoryCheckInterval = null;
}
componentDidMount() {
this.fetchCategories();
console.log("CategoryList componentDidMount - Debug info:");
console.log(" languageContext:", this.props.languageContext);
console.log(" i18n.language:", this.props.i18n?.language);
console.log(" sessionStorage i18nextLng:", typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('i18nextLng') : 'N/A');
console.log(" localStorage i18nextLng:", typeof localStorage !== 'undefined' ? localStorage.getItem('i18nextLng') : 'N/A');
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n.language;
// ALWAYS reload categories to ensure correct language
console.log("CategoryList componentDidMount: ALWAYS RELOADING categories for language", currentLanguage);
this.setState({ categories: [] }); // Clear any cached categories
window.categoryService.get(209, currentLanguage).then((response) => {
console.log("categoryService.get response for language '" + currentLanguage + "':", response);
if (response.children && response.children.length > 0) {
console.log("Setting categories with", response.children.length, "items");
console.log("First category name:", response.children[0]?.name);
this.setState({
categories: response.children,
activeCategoryId: this.setLevel1CategoryId(this.props.activeCategoryId)
});
}
});
}
componentDidUpdate(prevProps) {
// Handle socket connection changes
const wasConnected = prevProps.socket && prevProps.socket.connected;
const isNowConnected = this.props.socket && this.props.socket.connected;
if (!wasConnected && isNowConnected && !this.state.fetchedCategories) {
// Socket just connected and we haven't fetched categories yet
this.setState(
{
fetchedCategories: false,
},
() => {
this.fetchCategories();
console.log("componentDidUpdate", prevProps.languageContext?.currentLanguage, this.props.languageContext?.currentLanguage);
if (prevProps.languageContext?.currentLanguage !== this.props.languageContext?.currentLanguage) {
this.setState({
categories: [],
activeCategoryId: null
}, () => {
window.categoryService.get(209, this.props.languageContext?.currentLanguage || this.props.i18n.language).then((response) => {
console.log("response", response);
if (response.children && response.children.length > 0) {
this.setState({
categories: response.children,
activeCategoryId: this.setLevel1CategoryId(this.props.activeCategoryId)
});
}
);
});
});
}
// If activeCategoryId changes, update subcategories
if (
prevProps.activeCategoryId !== this.props.activeCategoryId &&
this.state.categoryTree
) {
this.processCategoryTree(this.state.categoryTree);
if (prevProps.activeCategoryId !== this.props.activeCategoryId) {
this.setLevel1CategoryId(this.props.activeCategoryId);
}
}
fetchCategories = () => {
const { socket } = this.props;
if (!socket || !socket.connected) {
// Socket not connected yet, but don't show error immediately on first load
// The componentDidUpdate will retry when socket connects
console.log("Socket not connected yet, waiting for connection to fetch categories");
return;
}
if (this.state.fetchedCategories) {
console.log('Categories already fetched, skipping');
return;
}
setLevel1CategoryId = (input) => {
if (input) {
const language = this.props.languageContext?.currentLanguage || this.props.i18n.language;
const categoryTreeCache = window.categoryService.getSync(209, language);
// Initialize global cache object if it doesn't exist
// @note Handle both SSR (global.window) and browser (window) environments
const windowObj = (typeof global !== "undefined" && global.window) ||
(typeof window !== "undefined" && window);
if (categoryTreeCache && categoryTreeCache.children) {
let level1CategoryId = null;
if (windowObj && !windowObj.productCache) {
windowObj.productCache = {};
}
// Check if we have a valid cache in the global object
try {
const cacheKey = "categoryTree_209";
const cachedData = windowObj && windowObj.productCache ? windowObj.productCache[cacheKey] : null;
if (cachedData) {
const { categoryTree, fetching } = cachedData;
//const cacheAge = Date.now() - timestamp;
//const tenMinutes = 10 * 60 * 1000; // 10 minutes in milliseconds
// If data is currently being fetched, wait for it
if (fetching) {
//console.log('CategoryList: Data is being fetched, waiting...');
const checkInterval = setInterval(() => {
const currentCache = windowObj && windowObj.productCache ? windowObj.productCache[cacheKey] : null;
if (currentCache && !currentCache.fetching) {
clearInterval(checkInterval);
if (currentCache.categoryTree) {
this.processCategoryTree(currentCache.categoryTree);
}
}
}, 100);
return;
}
// If cache is less than 10 minutes old, use it
if (/*cacheAge < tenMinutes &&*/ categoryTree) {
//console.log('Using cached category tree, age:', Math.round(cacheAge/1000), 'seconds');
// Defer processing to next tick to avoid blocking
//setTimeout(() => {
this.processCategoryTree(categoryTree);
//}, 0);
//return;
}
}
} catch (err) {
console.error("Error reading from cache:", err);
}
// Mark as being fetched to prevent concurrent calls
const cacheKey = "categoryTree_209";
if (windowObj && windowObj.productCache) {
windowObj.productCache[cacheKey] = {
fetching: true,
timestamp: Date.now(),
};
}
this.setState({ fetchedCategories: true });
//console.log('CategoryList: Fetching categories from socket');
socket.emit("categoryList", { categoryId: 209 }, (response) => {
if (response && response.categoryTree) {
// Store in global cache with timestamp
try {
const cacheKey = "categoryTree_209";
if (windowObj && windowObj.productCache) {
windowObj.productCache[cacheKey] = {
categoryTree: response.categoryTree,
timestamp: Date.now(),
fetching: false,
};
}
} catch (err) {
console.error("Error writing to cache:", err);
}
this.processCategoryTree(response.categoryTree);
// Check if input is already a numeric level 1 category ID
const inputAsNumber = parseInt(input);
if (!isNaN(inputAsNumber)) {
// Check if this is already a level 1 category ID
const level1Category = categoryTreeCache.children.find(cat => cat.id === inputAsNumber);
if (level1Category) {
console.log("Input is already a level 1 category ID:", inputAsNumber);
level1CategoryId = inputAsNumber;
} else {
try {
const cacheKey = "categoryTree_209";
if (windowObj && windowObj.productCache) {
windowObj.productCache[cacheKey] = {
categoryTree: null,
timestamp: Date.now(),
};
// It's a category ID, find its level 1 parent
const findLevel1FromId = (categories, targetId) => {
for (const category of categories) {
if (category.id === targetId) {
return category.parentId === 209 ? category.id : findLevel1FromId(categoryTreeCache.children, category.parentId);
}
} catch (err) {
console.error("Error writing to cache:", err);
if (category.children && category.children.length > 0) {
const result = findLevel1FromId(category.children, targetId);
if (result) return result;
}
}
return null;
};
level1CategoryId = findLevel1FromId(categoryTreeCache.children, inputAsNumber);
}
} else {
// It's an SEO name, find the level 1 category
const findLevel1FromSeoName = (categories, targetSeoName, level1Id = null) => {
for (const category of categories) {
const currentLevel1Id = level1Id || category.id;
if (category.seoName === targetSeoName) {
return currentLevel1Id;
}
if (category.children && category.children.length > 0) {
const result = findLevel1FromSeoName(category.children, targetSeoName, currentLevel1Id);
if (result) return result;
}
}
return null;
};
level1CategoryId = findLevel1FromSeoName(categoryTreeCache.children, input);
}
this.setState({
categoryTree: null,
level1Categories: [],
level2Categories: [],
level3Categories: [],
activePath: [],
activeCategoryId: level1CategoryId
});
}
});
};
processCategoryTree = (categoryTree) => {
// Level 1 categories are always the children of category 209 (Home)
const level1Categories =
categoryTree && categoryTree.id === 209
? categoryTree.children || []
: [];
// Build the navigation path and determine what to show at each level
let level2Categories = [];
let level3Categories = [];
let activePath = [];
if (this.props.activeCategoryId) {
const activeCategory = this.findCategoryById(
categoryTree,
this.props.activeCategoryId
);
if (activeCategory) {
// Build the path from root to active category
const pathToActive = this.getPathToCategory(
categoryTree,
this.props.activeCategoryId
);
activePath = pathToActive.slice(1); // Remove root (209) from path
// Determine what to show at each level based on the path depth
if (activePath.length >= 1) {
// Show children of the level 1 category
const level1Category = activePath[0];
level2Categories = level1Category.children || [];
}
if (activePath.length >= 2) {
// Show children of the level 2 category
const level2Category = activePath[1];
level3Categories = level2Category.children || [];
return;
}
}
this.setState({ activeCategoryId: null });
}
this.setState({
categoryTree,
level1Categories,
level2Categories,
level3Categories,
activePath,
fetchedCategories: true,
});
};
handleMobileMenuToggle = () => {
this.setState(prevState => ({
@@ -330,28 +152,43 @@ class CategoryList extends Component {
});
};
render() {
const { level1Categories, activePath, mobileMenuOpen } =
this.state;
componentWillUnmount() {
if (this.productCategoryCheckInterval) {
clearInterval(this.productCategoryCheckInterval);
this.productCategoryCheckInterval = null;
}
}
const renderCategoryRow = (categories, level = 1, isMobile = false) => (
/**
* Which nav item should appear active: home, konfigurator, neu, bald, a level-1 category id, or null.
* neu/bald are not in the category tree as seoNames, so pathname / explicit props must drive them.
* Home vs Konfigurator both had categoryId null from the app; pathname disambiguates.
*/
getNavHighlightKey() {
const pathname = this.props.pathname || "";
if (pathname === "/") return "home";
if (pathname === "/Konfigurator" || pathname.startsWith("/Konfigurator/")) return "konfigurator";
if (pathname === "/Kategorie/neu" || this.props.activeCategoryId === "neu") return "neu";
if (pathname === "/Kategorie/bald" || this.props.activeCategoryId === "bald") return "bald";
return this.state.activeCategoryId;
}
render() {
const { categories, mobileMenuOpen } = this.state;
const navKey = this.getNavHighlightKey();
const renderCategoryRow = (categories, isMobile = false) => (
<Box
sx={{
display: "flex",
justifyContent: "flex-start",
alignItems: "center",
flexWrap: isMobile ? "wrap" : "nowrap",
overflowX: isMobile ? "visible" : "auto",
overflowX: "visible",
flexDirection: isMobile ? "column" : "row",
py: 0.5, // Add vertical padding to prevent border clipping
"&::-webkit-scrollbar": {
display: "none",
},
scrollbarWidth: "none",
msOverflowStyle: "none",
}}
>
{level === 1 && (
<Button
component={Link}
to="/"
@@ -372,7 +209,7 @@ class CategoryList extends Component {
transition: "all 0.2s ease",
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
position: "relative",
...(this.props.activeCategoryId === null && {
...(navKey === "home" && {
bgcolor: "#fff",
textShadow: "none",
opacity: 1,
@@ -396,7 +233,7 @@ class CategoryList extends Component {
<HomeIcon sx={{
fontSize: "1rem",
mr: isMobile ? 1 : 0,
color: this.props.activeCategoryId === null ? "#2e7d32" : "inherit"
color: navKey === "home" ? "#2e7d32" : "inherit"
}} />
{isMobile && (
<Box sx={{ position: "relative", display: "inline-block" }}>
@@ -405,38 +242,189 @@ class CategoryList extends Component {
className="bold-text"
sx={{
fontWeight: "bold",
color: this.props.activeCategoryId === null ? "#2e7d32" : "transparent",
color: navKey === "home" ? "#2e7d32" : "transparent",
position: "relative",
zIndex: 2,
}}
>
Startseite
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
</Box>
{/* Thin text (positioned on top) */}
<Box
className="thin-text"
sx={{
fontWeight: "400",
color: this.props.activeCategoryId === null ? "transparent" : "inherit",
color: navKey === "home" ? "transparent" : "inherit",
position: "absolute",
top: 0,
left: 0,
zIndex: 1,
}}
>
Startseite
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
</Box>
</Box>
)}
</Button>
<Button
component={Link}
to="/Kategorie/neu"
color="inherit"
size="small"
aria-label="Neuheiten"
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{
fontSize: "0.75rem",
textTransform: "none",
whiteSpace: "nowrap",
opacity: 0.9,
mx: isMobile ? 0 : 0.5,
my: 0.25,
minWidth: isMobile ? "100%" : "auto",
borderRadius: 1,
justifyContent: isMobile ? "flex-start" : "center",
transition: "all 0.2s ease",
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
position: "relative",
...(navKey === "neu" && {
bgcolor: "#fff",
textShadow: "none",
opacity: 1,
}),
"&:hover": {
opacity: 1,
bgcolor: "#fff",
textShadow: "none",
"& .MuiSvgIcon-root": {
color: "#2e7d32 !important",
},
"& .bold-text": {
color: "#2e7d32 !important",
},
"& .thin-text": {
color: "transparent !important",
},
},
}}
>
<FiberNewIcon sx={{
fontSize: "1rem",
mr: isMobile ? 1 : 0,
color: navKey === "neu" ? "#2e7d32" : "inherit"
}} />
{isMobile && (
<Box sx={{ position: "relative", display: "inline-block" }}>
{/* Bold text (always rendered to set width) */}
<Box
className="bold-text"
sx={{
fontWeight: "bold",
color: navKey === "neu" ? "#2e7d32" : "transparent",
position: "relative",
zIndex: 2,
}}
>
{this.props.t ? this.props.t('navigation.new') : 'Neuheiten'}
</Box>
{/* Thin text (positioned on top) */}
<Box
className="thin-text"
sx={{
fontWeight: "400",
color: navKey === "neu" ? "transparent" : "inherit",
position: "absolute",
top: 0,
left: 0,
zIndex: 1,
}}
>
{this.props.t ? this.props.t('navigation.new') : 'Neuheiten'}
</Box>
</Box>
)}
{this.state.fetchedCategories && categories.length > 0 ? (
</Button>
<Button
component={Link}
to="/Kategorie/bald"
color="inherit"
size="small"
aria-label={this.props.t ? this.props.t('navigation.soon') : 'Demnächst'}
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{
fontSize: "0.75rem",
textTransform: "none",
whiteSpace: "nowrap",
opacity: 0.9,
mx: isMobile ? 0 : 0.5,
my: 0.25,
minWidth: isMobile ? "100%" : "auto",
borderRadius: 1,
justifyContent: isMobile ? "flex-start" : "center",
transition: "all 0.2s ease",
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
position: "relative",
...(navKey === "bald" && {
bgcolor: "#fff",
textShadow: "none",
opacity: 1,
}),
"&:hover": {
opacity: 1,
bgcolor: "#fff",
textShadow: "none",
"& .MuiSvgIcon-root": {
color: "#2e7d32 !important",
},
"& .bold-text": {
color: "#2e7d32 !important",
},
"& .thin-text": {
color: "transparent !important",
},
},
}}
>
<LocalShippingIcon sx={{
fontSize: "1rem",
mr: isMobile ? 1 : 0,
color: navKey === "bald" ? "#2e7d32" : "inherit"
}} />
{isMobile && (
<Box sx={{ position: "relative", display: "inline-block" }}>
<Box
className="bold-text"
sx={{
fontWeight: "bold",
color: navKey === "bald" ? "#2e7d32" : "transparent",
position: "relative",
zIndex: 2,
}}
>
{this.props.t ? this.props.t('navigation.soon') : 'Demnächst'}
</Box>
<Box
className="thin-text"
sx={{
fontWeight: "400",
color: navKey === "bald" ? "transparent" : "inherit",
position: "absolute",
top: 0,
left: 0,
zIndex: 1,
}}
>
{this.props.t ? this.props.t('navigation.soon') : 'Demnächst'}
</Box>
</Box>
)}
</Button>
{categories.length > 0 ? (
<>
{categories.map((category) => {
// Determine if this category is active at this level
const isActiveAtThisLevel =
activePath[level - 1] &&
activePath[level - 1].id === category.id;
return (
<Button
@@ -459,7 +447,7 @@ class CategoryList extends Component {
transition: "all 0.2s ease",
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
position: "relative",
...(isActiveAtThisLevel && {
...(navKey === category.id && {
bgcolor: "#fff",
textShadow: "none",
opacity: 1,
@@ -483,7 +471,7 @@ class CategoryList extends Component {
className="bold-text"
sx={{
fontWeight: "bold",
color: isActiveAtThisLevel ? "#2e7d32" : "transparent",
color: navKey === category.id ? "#2e7d32" : "transparent",
position: "relative",
zIndex: 2,
}}
@@ -495,7 +483,7 @@ class CategoryList extends Component {
className="thin-text"
sx={{
fontWeight: "400",
color: isActiveAtThisLevel ? "transparent" : "inherit",
color: navKey === category.id ? "transparent" : "inherit",
position: "absolute",
top: 0,
left: 0,
@@ -509,15 +497,14 @@ class CategoryList extends Component {
);
})}
</>
) : (
level === 1 && !isMobile && (
) : (!isMobile && (
<Typography
variant="caption"
color="inherit"
sx={{
display: "inline-flex",
alignItems: "center",
height: "30px", // Match small button height
height: "33px", // Match small button height
px: 1,
fontSize: "0.75rem",
opacity: 0.9,
@@ -527,6 +514,84 @@ class CategoryList extends Component {
</Typography>
)
)}
<Button
component={Link}
to="/Konfigurator"
color="inherit"
size="small"
aria-label={this.props.t ? this.props.t('navigation.konfiguratorAria') : 'Zum Konfigurator'}
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{
fontSize: "0.75rem",
textTransform: "none",
whiteSpace: "nowrap",
opacity: 0.9,
mx: isMobile ? 0 : 0.5,
my: 0.25,
minWidth: isMobile ? "100%" : "auto",
borderRadius: 1,
justifyContent: isMobile ? "flex-start" : "center",
transition: "all 0.2s ease",
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
position: "relative",
...(navKey === "konfigurator" && {
bgcolor: "#fff",
textShadow: "none",
opacity: 1,
}),
"&:hover": {
opacity: 1,
bgcolor: "#fff",
textShadow: "none",
"& .MuiSvgIcon-root": {
color: "#2e7d32 !important",
},
"& .bold-text": {
color: "#2e7d32 !important",
},
"& .thin-text": {
color: "transparent !important",
},
},
}}
>
<SettingsIcon sx={{
fontSize: "1rem",
mr: isMobile ? 1 : 0,
color: navKey === "konfigurator" ? "#2e7d32" : "inherit"
}} />
{isMobile && (
<Box sx={{ position: "relative", display: "inline-block" }}>
{/* Bold text (always rendered to set width) */}
<Box
className="bold-text"
sx={{
fontWeight: "bold",
color: navKey === "konfigurator" ? "#2e7d32" : "transparent",
position: "relative",
zIndex: 2,
}}
>
{this.props.t ? this.props.t('sections.konfigurator') : 'Konfigurator'}
</Box>
{/* Thin text (positioned on top) */}
<Box
className="thin-text"
sx={{
fontWeight: "400",
color: navKey === "konfigurator" ? "transparent" : "inherit",
position: "absolute",
top: 0,
left: 0,
zIndex: 1,
}}
>
{this.props.t ? this.props.t('sections.konfigurator') : 'Konfigurator'}
</Box>
</Box>
)}
</Button>
</Box>
);
@@ -549,25 +614,7 @@ class CategoryList extends Component {
}}
>
<Container maxWidth="lg" sx={{ px: 2 }}>
{/* Level 1 Categories Row - Always shown */}
{renderCategoryRow(level1Categories, 1, false)}
{/* Level 2 Categories Row - Show when level 1 is selected */}
{/* DISABLED FOR NOW
{level2Categories.length > 0 && (
<Box sx={{ mt: 0.5 }}>
{renderCategoryRow(level2Categories, 2, false)}
</Box>
)}
{/* Level 3 Categories Row - Show when level 2 is selected */}
{/* DISABLED FOR NOW
{level3Categories.length > 0 && (
<Box sx={{ mt: 0.5 }}>
{renderCategoryRow(level3Categories, 3, false)}
</Box>
)}
*/}
{renderCategoryRow(categories, false)}
</Container>
</Box>
@@ -595,7 +642,10 @@ class CategoryList extends Component {
onClick={this.handleMobileMenuToggle}
role="button"
tabIndex={0}
aria-label={mobileMenuOpen ? "Kategorien schließen" : "Kategorien öffnen"}
aria-label={this.props.t ?
(mobileMenuOpen ? this.props.t('navigation.categoriesClose') : this.props.t('navigation.categoriesOpen')) :
(mobileMenuOpen ? "Kategorien schließen" : "Kategorien öffnen")
}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
@@ -607,7 +657,7 @@ class CategoryList extends Component {
fontWeight: "bold",
textShadow: "0 1px 2px rgba(0,0,0,0.3)"
}}>
Kategorien
{this.props.t ? this.props.t('navigation.categories') : 'Kategorien'}
</Typography>
<Box sx={{ display: "flex", alignItems: "center" }}>
{mobileMenuOpen ? <CloseIcon /> : <MenuIcon />}
@@ -618,7 +668,7 @@ class CategoryList extends Component {
<Collapse in={mobileMenuOpen}>
<Box sx={{ pb: 2 }}>
{/* Level 1 Categories - Only level shown in mobile menu */}
{renderCategoryRow(level1Categories, 1, true)}
{renderCategoryRow(categories, true)}
</Box>
</Collapse>
</Container>
@@ -628,4 +678,4 @@ class CategoryList extends Component {
}
}
export default CategoryList;
export default withI18n()(CategoryList);

View File

@@ -16,9 +16,11 @@ const Logo = () => {
}}
>
<img
src="/assets/images/sh.png"
src="/assets/images/sh.avif"
alt="SH Logo"
style={{ height: "45px" }}
width="108px"
height="45px"
style={{ width: "108px", height: "45px" }}
/>
</Box>
);

View File

@@ -7,16 +7,19 @@ import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import ListItemText from "@mui/material/ListItemText";
import Typography from "@mui/material/Typography";
import CircularProgress from "@mui/material/CircularProgress";
import IconButton from "@mui/material/IconButton";
import SearchIcon from "@mui/icons-material/Search";
import KeyboardReturnIcon from "@mui/icons-material/KeyboardReturn";
import { useNavigate, useLocation } from "react-router-dom";
import SocketContext from "../../contexts/SocketContext.js";
import { useTranslation } from "react-i18next";
import { LanguageContext } from "../../i18n/withTranslation.js";
const SearchBar = () => {
const navigate = useNavigate();
const location = useLocation();
const context = React.useContext(SocketContext);
const searchParams = new URLSearchParams(location.search);
const { t, i18n } = useTranslation();
const languageContext = React.useContext(LanguageContext);
// State management
const [searchQuery, setSearchQuery] = React.useState(
@@ -25,7 +28,6 @@ const SearchBar = () => {
const [suggestions, setSuggestions] = React.useState([]);
const [showSuggestions, setShowSuggestions] = React.useState(false);
const [selectedIndex, setSelectedIndex] = React.useState(-1);
const [loadingSuggestions, setLoadingSuggestions] = React.useState(false);
// Refs for debouncing and timers
const debounceTimerRef = React.useRef(null);
@@ -58,27 +60,26 @@ const SearchBar = () => {
// @note Autocomplete function using getSearchProducts Socket.io API - returns objects with name and seoName
const fetchAutocomplete = React.useCallback(
(query) => {
if (!context || !context.socket || !context.socket.connected || !query || query.length < 2) {
if (!query || query.length < 2) {
setSuggestions([]);
setShowSuggestions(false);
setLoadingSuggestions(false);
return;
}
setLoadingSuggestions(true);
const currentLanguage = languageContext?.currentLanguage || i18n?.language || 'de';
context.socket.emit(
window.socketManager.emit(
"getSearchProducts",
{
query: query.trim(),
limit: 8,
language: currentLanguage,
requestTranslation: currentLanguage === 'de' ? false : true,
},
(response) => {
setLoadingSuggestions(false);
if (response && response.products) {
// getSearchProducts returns response.products array
const suggestions = response.products.slice(0, 8); // Limit to 8 suggestions
const suggestions = response.products.map(p => p.translatedProduct || p).slice(0, 8); // Limit to 8 suggestions
setSuggestions(suggestions);
setShowSuggestions(suggestions.length > 0);
setSelectedIndex(-1); // Reset selection
@@ -90,7 +91,7 @@ const SearchBar = () => {
}
);
},
[context]
[languageContext, i18n]
);
const handleSearchChange = (e) => {
@@ -184,6 +185,24 @@ const SearchBar = () => {
}, 200);
};
// Get delivery days based on availability
const getDeliveryDays = (product) => {
if (product.available === 1) {
return t('delivery.times.standard2to3Days');
} else if (product.incoming === 1 || product.availableSupplier === 1) {
return t('delivery.times.supplier7to9Days');
}
};
// Handle enter icon click
const handleEnterClick = () => {
delete window.currentSearchQuery;
setShowSuggestions(false);
if (searchQuery.trim()) {
navigate(`/search?q=${encodeURIComponent(searchQuery)}`);
}
};
// Clean up timers on unmount
React.useEffect(() => {
return () => {
@@ -225,7 +244,7 @@ const SearchBar = () => {
>
<TextField
ref={inputRef}
placeholder="Produkte suchen..."
placeholder={t('search.searchProducts')}
variant="outlined"
size="small"
fullWidth
@@ -244,9 +263,22 @@ const SearchBar = () => {
<SearchIcon />
</InputAdornment>
),
endAdornment: loadingSuggestions && (
endAdornment: (
<InputAdornment position="end">
<CircularProgress size={16} />
<IconButton
size="small"
onClick={handleEnterClick}
aria-label="Suche starten"
sx={{
p: 0.5,
color: "text.secondary",
"&:hover": {
color: "primary.main",
},
}}
>
<KeyboardReturnIcon fontSize="small" />
</IconButton>
</InputAdornment>
),
sx: { borderRadius: 2, bgcolor: "background.paper" },
@@ -264,8 +296,6 @@ const SearchBar = () => {
left: 0,
right: 0,
zIndex: 1300,
maxHeight: "300px",
overflow: "auto",
mt: 0.5,
borderRadius: 2,
}}
@@ -273,12 +303,19 @@ const SearchBar = () => {
<List disablePadding>
{suggestions.map((suggestion, index) => (
<ListItem
key={suggestion.seoName || index}
button
key={`${suggestion.seoName || 'suggestion'}-${index}`}
component="button"
selected={index === selectedIndex}
onClick={() => handleSuggestionClick(suggestion)}
sx={{
cursor: "pointer",
border: "none",
background: "none",
padding: 0,
margin: 0,
width: "100%",
textAlign: "left",
px: 2, // Add horizontal padding back
"&:hover": {
backgroundColor: "action.hover",
},
@@ -293,14 +330,48 @@ const SearchBar = () => {
>
<ListItemText
primary={
<Typography variant="body2" noWrap>
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 2 }}>
<Box sx={{ flexGrow: 1, minWidth: 0, display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
<Typography variant="body2" noWrap sx={{ mb: 0.5 }}>
{suggestion.name}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
{getDeliveryDays(suggestion)}
</Typography>
</Box>
<Box sx={{ textAlign: 'right', flexShrink: 0, display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
<Typography variant="body1" color="primary" sx={{ fontWeight: 'bold', mb: 0.5 }}>
{new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(suggestion.price)}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.65rem' }}>
{t('product.inclVat', { vat: suggestion.vat })}
</Typography>
</Box>
</Box>
}
/>
</ListItem>
))}
</List>
<Box sx={{ p: 1, borderTop: 1, borderColor: 'divider' }}>
<IconButton
fullWidth
onClick={handleEnterClick}
sx={{
justifyContent: 'center',
py: 1,
color: 'primary.main',
'&:hover': {
backgroundColor: 'action.hover',
},
}}
>
<Typography variant="body2" sx={{ mr: 1 }}>
{t('common.more')}
</Typography>
<KeyboardReturnIcon fontSize="small" />
</IconButton>
</Box>
</Paper>
)}
</Box>

View File

@@ -1,7 +1,8 @@
import React from "react";
import { Box, TextField, Typography } from "@mui/material";
import { withI18n } from "../../i18n/withTranslation.js";
const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
const AddressForm = ({ title, address, onChange, errors, namePrefix, t }) => {
// Helper function to determine if a required field should show error styling
const getRequiredFieldError = (fieldName, value) => {
const isEmpty = !value || value.trim() === "";
@@ -36,7 +37,7 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
}}
>
<TextField
label="Vorname"
label={t ? t('checkout.addressFields.firstName') : 'Vorname'}
name="firstName"
value={address.firstName}
onChange={onChange}
@@ -49,7 +50,7 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
}}
/>
<TextField
label="Nachname"
label={t ? t('checkout.addressFields.lastName') : 'Nachname'}
name="lastName"
value={address.lastName}
onChange={onChange}
@@ -62,7 +63,7 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
}}
/>
<TextField
label="Adresszusatz"
label={t ? t('checkout.addressFields.addressSupplement') : 'Adresszusatz'}
name="addressAddition"
value={address.addressAddition || ""}
onChange={onChange}
@@ -70,7 +71,7 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
InputLabelProps={{ shrink: true }}
/>
<TextField
label="Straße"
label={t ? t('checkout.addressFields.street') : 'Straße'}
name="street"
value={address.street}
onChange={onChange}
@@ -83,7 +84,7 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
}}
/>
<TextField
label="Hausnummer"
label={t ? t('checkout.addressFields.houseNumber') : 'Hausnummer'}
name="houseNumber"
value={address.houseNumber}
onChange={onChange}
@@ -96,7 +97,7 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
}}
/>
<TextField
label="PLZ"
label={t ? t('checkout.addressFields.postalCode') : 'PLZ'}
name="postalCode"
value={address.postalCode}
onChange={onChange}
@@ -109,7 +110,7 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
}}
/>
<TextField
label="Stadt"
label={t ? t('checkout.addressFields.city') : 'Stadt'}
name="city"
value={address.city}
onChange={onChange}
@@ -122,7 +123,7 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
}}
/>
<TextField
label="Land"
label={t ? t('checkout.addressFields.country') : 'Land'}
name="country"
value={address.country}
onChange={onChange}
@@ -135,4 +136,4 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
);
};
export default AddressForm;
export default withI18n()(AddressForm);

View File

@@ -5,9 +5,11 @@ import CheckoutForm from "./CheckoutForm.js";
import PaymentConfirmationDialog from "./PaymentConfirmationDialog.js";
import OrderProcessingService from "./OrderProcessingService.js";
import CheckoutValidation from "./CheckoutValidation.js";
import SocketContext from "../../contexts/SocketContext.js";
import { withI18n } from "../../i18n/index.js";
class CartTab extends Component {
normalizeDeliveryMethod = (deliveryMethod) => (deliveryMethod === "DPD" ? "DHL" : deliveryMethod);
constructor(props) {
super(props);
@@ -51,9 +53,6 @@ class CartTab extends Component {
showStripePayment: false,
StripeComponent: null,
isLoadingStripe: false,
showMolliePayment: false,
MollieComponent: null,
isLoadingMollie: false,
showPaymentConfirmation: false,
orderCompleted: false,
originalCartItems: []
@@ -70,8 +69,7 @@ class CartTab extends Component {
// @note Add method to fetch and apply order template prefill data
fetchOrderTemplate = () => {
if (this.context && this.context.socket && this.context.socket.connected) {
this.context.socket.emit('getOrderTemplate', (response) => {
window.socketManager.emit('getOrderTemplate', (response) => {
if (response.success && response.orderTemplate) {
const template = response.orderTemplate;
@@ -111,15 +109,17 @@ class CartTab extends Component {
// Map delivery method values if needed
const deliveryMethodMap = {
"standard": "DHL",
"express": "DPD",
"express": "DHL",
"pickup": "Abholung"
};
prefillDeliveryMethod = deliveryMethodMap[prefillDeliveryMethod] || prefillDeliveryMethod;
prefillDeliveryMethod = this.normalizeDeliveryMethod(
deliveryMethodMap[prefillDeliveryMethod] || prefillDeliveryMethod
);
// Determine payment method - respect constraints
let prefillPaymentMethod = template.payment_method || "wire";
const paymentMethodMap = {
"credit_card": "mollie",//stripe
"credit_card": "mollie",/*stripe*/
"bank_transfer": "wire",
"cash_on_delivery": "onDelivery",
"cash": "cash"
@@ -149,7 +149,6 @@ class CartTab extends Component {
console.log("No order template available or failed to fetch");
}
});
}
};
componentDidMount() {
@@ -173,7 +172,9 @@ class CartTab extends Component {
const cartItems = Array.isArray(window.cart) ? window.cart : [];
const shouldForcePickup = CheckoutValidation.shouldForcePickupDelivery(cartItems);
const newDeliveryMethod = shouldForcePickup ? "Abholung" : this.state.deliveryMethod;
const newDeliveryMethod = shouldForcePickup
? "Abholung"
: this.normalizeDeliveryMethod(this.state.deliveryMethod);
const deliveryCost = this.orderService.getDeliveryCost();
// Get optimal payment method for the current state
@@ -220,9 +221,18 @@ class CartTab extends Component {
};
handleDeliveryMethodChange = (event) => {
const newDeliveryMethod = event.target.value;
const newDeliveryMethod = this.normalizeDeliveryMethod(event.target.value);
const deliveryCost = this.orderService.getDeliveryCost();
// Pickup should default to in-store payment.
if (newDeliveryMethod === "Abholung") {
this.setState({
deliveryMethod: newDeliveryMethod,
paymentMethod: "cash",
});
return;
}
// Get optimal payment method for the new delivery method
const optimalPaymentMethod = CheckoutValidation.getOptimalPaymentMethod(
newDeliveryMethod,
@@ -295,7 +305,7 @@ class CartTab extends Component {
};
validateAddressForm = () => {
const errors = CheckoutValidation.validateAddressForm(this.state);
const errors = CheckoutValidation.validateAddressForm(this.state, this.props.t);
this.setState({ addressFormErrors: errors });
return Object.keys(errors).length === 0;
};
@@ -322,31 +332,10 @@ class CartTab extends Component {
}
};
loadMollieComponent = async () => {
this.setState({ isLoadingMollie: true });
try {
const { default: Mollie } = await import("../Mollie.js");
this.setState({
MollieComponent: Mollie,
showMolliePayment: true,
isCompletingOrder: false,
isLoadingMollie: false,
});
} catch (error) {
console.error("Failed to load Mollie component:", error);
this.setState({
isCompletingOrder: false,
isLoadingMollie: false,
completionError: "Failed to load payment component. Please try again.",
});
}
};
handleCompleteOrder = () => {
this.setState({ completionError: null }); // Clear previous errors
const validationError = CheckoutValidation.getValidationErrorMessage(this.state);
const validationError = CheckoutValidation.getValidationErrorMessage(this.state, false, this.props.t);
if (validationError) {
this.setState({ completionError: validationError });
this.validateAddressForm(); // To show field-specific errors
@@ -387,23 +376,38 @@ class CartTab extends Component {
this.orderService.createStripeIntent(totalAmount, this.loadStripeComponent);
return;
}
// Handle Mollie payment differently
// Handle molllie payment differently
if (paymentMethod === "mollie") {
// Store the cart items used for Mollie payment in sessionStorage for later reference
// Store the cart items used for mollie payment in sessionStorage for later reference
try {
sessionStorage.setItem('molliePaymentCart', JSON.stringify(cartItems));
} catch (error) {
console.error("Failed to store Mollie payment cart:", error);
console.error("Failed to store mollie payment cart:", error);
}
// Calculate total amount for Mollie
// Calculate total amount for mollie
const subtotal = cartItems.reduce(
(total, item) => total + item.price * item.quantity,
0
);
const totalAmount = Math.round((subtotal + deliveryCost) * 100); // Convert to cents
const totalAmount = Math.round((subtotal + deliveryCost) * 100) / 100;
// Prepare complete order data for Mollie intent creation
const mollieOrderData = {
amount: totalAmount,
items: cartItems,
invoiceAddress,
deliveryAddress: useSameAddress ? invoiceAddress : deliveryAddress,
deliveryMethod,
paymentMethod: "mollie",
deliveryCost,
note,
domain: window.location.origin,
saveAddressForFuture,
};
this.orderService.createMollieIntent(mollieOrderData);
this.orderService.createMollieIntent(totalAmount, this.loadMollieComponent);
return;
}
@@ -441,9 +445,6 @@ class CartTab extends Component {
showStripePayment,
StripeComponent,
isLoadingStripe,
showMolliePayment,
MollieComponent,
isLoadingMollie,
showPaymentConfirmation,
orderCompleted,
} = this.state;
@@ -451,7 +452,7 @@ class CartTab extends Component {
const deliveryCost = this.orderService.getDeliveryCost();
const { isPickupOnly, hasStecklinge } = CheckoutValidation.getCartItemFlags(cartItems);
const preSubmitError = CheckoutValidation.getValidationErrorMessage(this.state);
const preSubmitError = CheckoutValidation.getValidationErrorMessage(this.state, false, this.props.t);
const displayError = completionError || preSubmitError;
return (
@@ -479,8 +480,7 @@ class CartTab extends Component {
{!showPaymentConfirmation && (
<CartDropdown
cartItems={cartItems}
socket={this.context.socket}
showDetailedSummary={showStripePayment || showMolliePayment}
showDetailedSummary={showStripePayment}
deliveryMethod={deliveryMethod}
deliveryCost={deliveryCost}
/>
@@ -488,10 +488,10 @@ class CartTab extends Component {
{cartItems.length > 0 && (
<Box sx={{ mt: 3 }}>
{isLoadingStripe || isLoadingMollie ? (
{isLoadingStripe ? (
<Box sx={{ textAlign: 'center', py: 4 }}>
<Typography variant="body1">
Zahlungskomponente wird geladen...
{this.props.t ? this.props.t('payment.loadingPaymentComponent') : 'Zahlungskomponente wird geladen...'}
</Typography>
</Box>
) : showStripePayment && StripeComponent ? (
@@ -509,31 +509,11 @@ class CartTab extends Component {
}
}}
>
Zurück zur Bestellung
{this.props.t ? this.props.t('cart.backToOrder') : '← Zurück zur Bestellung'}
</Button>
</Box>
<StripeComponent clientSecret={stripeClientSecret} />
</>
) : showMolliePayment && MollieComponent ? (
<>
<Box sx={{ mb: 2 }}>
<Button
variant="outlined"
onClick={() => this.setState({ showMolliePayment: false })}
sx={{
color: '#2e7d32',
borderColor: '#2e7d32',
'&:hover': {
backgroundColor: 'rgba(46, 125, 50, 0.04)',
borderColor: '#1b5e20'
}
}}
>
Zurück zur Bestellung
</Button>
</Box>
<MollieComponent />
</>
) : (
<CheckoutForm
paymentMethod={paymentMethod}
@@ -570,7 +550,4 @@ class CartTab extends Component {
}
}
// Set static contextType to access the socket
CartTab.contextType = SocketContext;
export default CartTab;
export default withI18n()(CartTab);

View File

@@ -4,6 +4,7 @@ import AddressForm from "./AddressForm.js";
import DeliveryMethodSelector from "./DeliveryMethodSelector.js";
import PaymentMethodSelector from "./PaymentMethodSelector.js";
import OrderSummary from "./OrderSummary.js";
import { withI18n } from "../../i18n/withTranslation.js";
class CheckoutForm extends Component {
render() {
@@ -40,7 +41,7 @@ class CheckoutForm extends Component {
{paymentMethod !== "cash" && (
<>
<AddressForm
title="Rechnungsadresse"
title={this.props.t ? this.props.t('checkout.invoiceAddress') : 'Rechnungsadresse'}
address={invoiceAddress}
onChange={onInvoiceAddressChange}
errors={addressFormErrors}
@@ -57,7 +58,7 @@ class CheckoutForm extends Component {
}
label={
<Typography variant="body2">
Für zukünftige Bestellungen speichern
{this.props.t ? this.props.t('checkout.saveForFuture') : 'Für zukünftige Bestellungen speichern'}
</Typography>
}
sx={{ mb: 2 }}
@@ -70,13 +71,12 @@ class CheckoutForm extends Component {
variant="body1"
sx={{ mb: 2, fontWeight: "bold", color: "#2e7d32" }}
>
Für welchen Termin ist die Abholung der Stecklinge
gewünscht?
{this.props.t ? this.props.t('checkout.pickupDate') : 'Für welchen Termin ist die Abholung der Stecklinge gewünscht?'}
</Typography>
)}
<TextField
label="Anmerkung"
label={this.props.t ? this.props.t('checkout.note') : 'Anmerkung'}
name="note"
value={note}
onChange={onNoteChange}
@@ -93,9 +93,10 @@ class CheckoutForm extends Component {
deliveryMethod={deliveryMethod}
onChange={onDeliveryMethodChange}
isPickupOnly={isPickupOnly || hasStecklinge}
cartItems={cartItems}
/>
{(deliveryMethod === "DHL" || deliveryMethod === "DPD") && (
{deliveryMethod === "DHL" && (
<>
<FormControlLabel
control={
@@ -107,7 +108,7 @@ class CheckoutForm extends Component {
}
label={
<Typography variant="body1">
Lieferadresse ist identisch mit Rechnungsadresse
{this.props.t ? this.props.t('checkout.sameAddress') : 'Lieferadresse ist identisch mit Rechnungsadresse'}
</Typography>
}
sx={{ mb: 2 }}
@@ -115,7 +116,7 @@ class CheckoutForm extends Component {
{!useSameAddress && (
<AddressForm
title="Lieferadresse"
title={this.props.t ? this.props.t('checkout.deliveryAddress') : 'Lieferadresse'}
address={deliveryAddress}
onChange={onDeliveryAddressChange}
errors={addressFormErrors}
@@ -150,8 +151,7 @@ class CheckoutForm extends Component {
}
label={
<Typography variant="body2">
Ich habe die AGBs, die Datenschutzerklärung und die
Bestimmungen zum Widerrufsrecht gelesen
{this.props.t ? this.props.t('checkout.termsAccept') : 'Ich habe die AGBs, die Datenschutzerklärung und die Bestimmungen zum Widerrufsrecht gelesen'}
</Typography>
}
sx={{ mb: 3, mt: 2 }}
@@ -174,12 +174,12 @@ class CheckoutForm extends Component {
disabled={isCompletingOrder || !!preSubmitError}
>
{isCompletingOrder
? "Bestellung wird verarbeitet..."
: "Bestellung abschließen"}
? (this.props.t ? this.props.t('checkout.processingOrder') : 'Bestellung wird verarbeitet...')
: (this.props.t ? this.props.t('checkout.completeOrder') : 'Bestellung abschließen')}
</Button>
</>
);
}
}
export default CheckoutForm;
export default withI18n()(CheckoutForm);

View File

@@ -1,5 +1,5 @@
class CheckoutValidation {
static validateAddressForm(state) {
static validateAddressForm(state, t = null) {
const {
invoiceAddress,
deliveryAddress,
@@ -12,15 +12,15 @@ class CheckoutValidation {
// Validate invoice address (skip if payment method is "cash")
if (paymentMethod !== "cash") {
if (!invoiceAddress.firstName)
errors.invoiceFirstName = "Vorname erforderlich";
errors.invoiceFirstName = t ? t('checkout.validationErrors.firstNameRequired') : "Vorname erforderlich";
if (!invoiceAddress.lastName)
errors.invoiceLastName = "Nachname erforderlich";
if (!invoiceAddress.street) errors.invoiceStreet = "Straße erforderlich";
errors.invoiceLastName = t ? t('checkout.validationErrors.lastNameRequired') : "Nachname erforderlich";
if (!invoiceAddress.street) errors.invoiceStreet = t ? t('checkout.validationErrors.streetRequired') : "Straße erforderlich";
if (!invoiceAddress.houseNumber)
errors.invoiceHouseNumber = "Hausnummer erforderlich";
errors.invoiceHouseNumber = t ? t('checkout.validationErrors.houseNumberRequired') : "Hausnummer erforderlich";
if (!invoiceAddress.postalCode)
errors.invoicePostalCode = "PLZ erforderlich";
if (!invoiceAddress.city) errors.invoiceCity = "Stadt erforderlich";
errors.invoicePostalCode = t ? t('checkout.validationErrors.postalCodeRequired') : "PLZ erforderlich";
if (!invoiceAddress.city) errors.invoiceCity = t ? t('checkout.validationErrors.cityRequired') : "Stadt erforderlich";
}
// Validate delivery address for shipping methods that require it
@@ -29,37 +29,37 @@ class CheckoutValidation {
(deliveryMethod === "DHL" || deliveryMethod === "DPD")
) {
if (!deliveryAddress.firstName)
errors.deliveryFirstName = "Vorname erforderlich";
errors.deliveryFirstName = t ? t('checkout.validationErrors.firstNameRequired') : "Vorname erforderlich";
if (!deliveryAddress.lastName)
errors.deliveryLastName = "Nachname erforderlich";
errors.deliveryLastName = t ? t('checkout.validationErrors.lastNameRequired') : "Nachname erforderlich";
if (!deliveryAddress.street)
errors.deliveryStreet = "Straße erforderlich";
errors.deliveryStreet = t ? t('checkout.validationErrors.streetRequired') : "Straße erforderlich";
if (!deliveryAddress.houseNumber)
errors.deliveryHouseNumber = "Hausnummer erforderlich";
errors.deliveryHouseNumber = t ? t('checkout.validationErrors.houseNumberRequired') : "Hausnummer erforderlich";
if (!deliveryAddress.postalCode)
errors.deliveryPostalCode = "PLZ erforderlich";
if (!deliveryAddress.city) errors.deliveryCity = "Stadt erforderlich";
errors.deliveryPostalCode = t ? t('checkout.validationErrors.postalCodeRequired') : "PLZ erforderlich";
if (!deliveryAddress.city) errors.deliveryCity = t ? t('checkout.validationErrors.cityRequired') : "Stadt erforderlich";
}
return errors;
}
static getValidationErrorMessage(state, isAddressOnly = false) {
static getValidationErrorMessage(state, isAddressOnly = false, t = null) {
const { termsAccepted } = state;
const addressErrors = this.validateAddressForm(state);
const addressErrors = this.validateAddressForm(state, t);
if (isAddressOnly) {
return addressErrors;
}
if (Object.keys(addressErrors).length > 0) {
return "Bitte überprüfen Sie Ihre Eingaben in den Adressfeldern.";
return t ? t('checkout.addressValidationError') : "Bitte überprüfen Sie Ihre Eingaben in den Adressfeldern.";
}
// Validate terms acceptance
if (!termsAccepted) {
return "Bitte akzeptieren Sie die AGBs, Datenschutzerklärung und Widerrufsrecht, um fortzufahren.";
return t ? t('checkout.termsValidationError') : "Bitte akzeptieren Sie die AGBs, Datenschutzerklärung und Widerrufsrecht, um fortzufahren.";
}
return null;
@@ -80,9 +80,14 @@ class CheckoutValidation {
return "wire";
}
// Prefer stripe when available and meets minimum amount
if (deliveryMethod === "DHL" || deliveryMethod === "DPD" || deliveryMethod === "Abholung") {
return "stripe";
// Pickup defaults to in-store payment
if (deliveryMethod === "Abholung") {
return "cash";
}
// Prefer wire for shippable delivery methods
if (deliveryMethod === "DHL" || deliveryMethod === "DPD") {
return "wire";/*stripe*/
}
// Fall back to wire transfer
@@ -96,9 +101,10 @@ class CheckoutValidation {
const subtotal = cartItems.reduce((total, item) => total + item.price * item.quantity, 0);
const totalAmount = subtotal + deliveryCost;
// Reset payment method if it's no longer valid
// Reset "Nachnahme" when delivery is not DHL.
// For pickup, default to in-store payment.
if (deliveryMethod !== "DHL" && paymentMethod === "onDelivery") {
newPaymentMethod = "wire";
newPaymentMethod = deliveryMethod === "Abholung" ? "cash" : "wire";
}
// Allow stripe for DHL, DPD, and Abholung delivery methods, but check minimum amount
@@ -106,11 +112,21 @@ class CheckoutValidation {
newPaymentMethod = "wire";
}
// Allow mollie for DHL, DPD, and Abholung delivery methods, but check minimum amount
if (deliveryMethod !== "DHL" && deliveryMethod !== "DPD" && deliveryMethod !== "Abholung" && paymentMethod === "mollie") {
newPaymentMethod = "wire";
}
// Check minimum amount for stripe payments
if (paymentMethod === "stripe" && totalAmount < 0.50) {
newPaymentMethod = "wire";
}
// Check minimum amount for mollie payments
if (paymentMethod === "mollie" && totalAmount < 0.50) {
newPaymentMethod = "wire";
}
if (deliveryMethod !== "Abholung" && paymentMethod === "cash") {
newPaymentMethod = "wire";
}

View File

@@ -3,34 +3,34 @@ import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Radio from '@mui/material/Radio';
import Checkbox from '@mui/material/Checkbox';
import { withI18n } from '../../i18n/withTranslation.js';
const DeliveryMethodSelector = ({ deliveryMethod, onChange, isPickupOnly, cartItems = [], t }) => {
// Calculate cart value for free shipping threshold
const cartValue = cartItems.reduce((total, item) => total + item.price * item.quantity, 0);
const isFreeShipping = cartValue >= 100;
const remainingForFreeShipping = Math.max(0, 100 - cartValue);
const DeliveryMethodSelector = ({ deliveryMethod, onChange, isPickupOnly }) => {
const deliveryOptions = [
{
id: 'DHL',
name: 'DHL',
description: isPickupOnly ? "nicht auswählbar weil ein oder mehrere Artikel nur abgeholt werden können" : 'Standardversand',
price: '6,99 €',
disabled: isPickupOnly
},
{
id: 'DPD',
name: 'DPD',
description: isPickupOnly ? "nicht auswählbar weil ein oder mehrere Artikel nur abgeholt werden können" : 'Standardversand',
price: '4,90 €',
description: isPickupOnly ? (t ? t('delivery.descriptions.notAvailable') : "nicht auswählbar weil ein oder mehrere Artikel nur abgeholt werden können") :
isFreeShipping ? (t ? t('delivery.descriptions.standardFree') : 'Standardversand - KOSTENLOS ab 100€ Warenwert!') : (t ? t('delivery.descriptions.standard') : 'Standardversand'),
price: isFreeShipping ? (t ? t('delivery.prices.free') : 'kostenlos') : (t ? t('delivery.prices.dhl') : '5,90 €'),
disabled: isPickupOnly
},
{
id: 'Sperrgut',
name: 'Sperrgut',
description: 'Für große und schwere Artikel',
price: '28,99 €',
name: t ? t('delivery.methods.sperrgutName') : 'Sperrgut',
description: t ? t('delivery.descriptions.bulky') : 'Für große und schwere Artikel',
price: t ? t('delivery.prices.sperrgut') : '28,99 €',
disabled: true,
isCheckbox: true
},
{
id: 'Abholung',
name: 'Abholung in der Filiale',
name: t ? t('delivery.methods.pickup') : 'Abholung in der Filiale',
description: '',
price: ''
}
@@ -39,7 +39,7 @@ const DeliveryMethodSelector = ({ deliveryMethod, onChange, isPickupOnly }) => {
return (
<>
<Typography variant="h6" gutterBottom>
Versandart wählen
{t ? t('delivery.selector.title') : 'Versandart wählen'}
</Typography>
<Box sx={{ mb: 3 }}>
@@ -114,9 +114,44 @@ const DeliveryMethodSelector = ({ deliveryMethod, onChange, isPickupOnly }) => {
</Typography>
</Box>
))}
{/* Free shipping information */}
{!isFreeShipping && remainingForFreeShipping > 0 && (
<Box sx={{
mt: 2,
p: 2,
backgroundColor: '#f0f8ff',
borderRadius: 1,
border: '1px solid #2196f3'
}}>
<Typography variant="body2" color="primary" sx={{ fontWeight: 'medium' }}>
{t ? t('delivery.selector.freeShippingInfo') : '💡 Versandkostenfrei ab 100€ Warenwert!'}
</Typography>
<Typography variant="body2" color="text.secondary">
{t ? t('delivery.selector.remainingForFree', { amount: remainingForFreeShipping.toFixed(2).replace('.', ',') }) : `Noch ${remainingForFreeShipping.toFixed(2).replace('.', ',')}€ für kostenlosen Versand hinzufügen.`}
</Typography>
</Box>
)}
{isFreeShipping && (
<Box sx={{
mt: 2,
p: 2,
backgroundColor: '#e8f5e8',
borderRadius: 1,
border: '1px solid #2e7d32'
}}>
<Typography variant="body2" color="success.main" sx={{ fontWeight: 'medium' }}>
{t ? t('delivery.selector.congratsFreeShipping') : '🎉 Glückwunsch! Sie erhalten kostenlosen Versand!'}
</Typography>
<Typography variant="body2" color="text.secondary">
{t ? t('delivery.selector.cartQualifiesFree', { amount: cartValue.toFixed(2).replace('.', ',') }) : `Ihr Warenkorb von ${cartValue.toFixed(2).replace('.', ',')}€ qualifiziert sich für kostenlosen Versand.`}
</Typography>
</Box>
)}
</Box>
</>
);
};
export default DeliveryMethodSelector;
export default withI18n()(DeliveryMethodSelector);

View File

@@ -15,22 +15,36 @@ import {
TableRow,
Paper
} from '@mui/material';
import { useTranslation } from 'react-i18next';
const OrderDetailsDialog = ({ open, onClose, order }) => {
const { t } = useTranslation();
if (!order) {
return null;
}
const currencyFormatter = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" });
// Helper function to translate payment methods
const getPaymentMethodDisplay = (paymentMethod) => {
if (!paymentMethod) return t('orders.details.notSpecified');
switch (paymentMethod.toLowerCase()) {
case 'wire':
return t('payment.methods.bankTransfer');
default:
return paymentMethod;
}
};
const handleCancelOrder = () => {
// Implement order cancellation logic here
console.log(`Cancel order: ${order.orderId}`);
onClose(); // Close the dialog after action
};
const subtotal = order.items.reduce((acc, item) => acc + item.price * item.quantity_ordered, 0);
const total = subtotal + order.delivery_cost;
const total = order.items.reduce((acc, item) => acc + item.price * item.quantity_ordered, 0);
// Calculate VAT breakdown similar to CartDropdown
const vatCalculations = order.items.reduce((acc, item) => {
@@ -52,10 +66,10 @@ const OrderDetailsDialog = ({ open, onClose, order }) => {
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>Bestelldetails: {order.orderId}</DialogTitle>
<DialogTitle>{t('orders.details.title', { orderId: order.orderId })}</DialogTitle>
<DialogContent>
<Box sx={{ mb: 2 }}>
<Typography variant="h6">Lieferadresse</Typography>
<Typography variant="h6">{t('orders.details.deliveryAddress')}</Typography>
<Typography>{order.shipping_address_name}</Typography>
<Typography>{order.shipping_address_street} {order.shipping_address_house_number}</Typography>
<Typography>{order.shipping_address_postal_code} {order.shipping_address_city}</Typography>
@@ -63,7 +77,7 @@ const OrderDetailsDialog = ({ open, onClose, order }) => {
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="h6">Rechnungsadresse</Typography>
<Typography variant="h6">{t('orders.details.invoiceAddress')}</Typography>
<Typography>{order.invoice_address_name}</Typography>
<Typography>{order.invoice_address_street} {order.invoice_address_house_number}</Typography>
<Typography>{order.invoice_address_postal_code} {order.invoice_address_city}</Typography>
@@ -72,28 +86,29 @@ const OrderDetailsDialog = ({ open, onClose, order }) => {
{/* Order Details Section */}
<Box sx={{ mb: 2 }}>
<Typography variant="h6" gutterBottom>Bestelldetails</Typography>
<Typography variant="h6" gutterBottom>{t('orders.details.orderDetails')}</Typography>
<Box sx={{ display: 'flex', gap: 4 }}>
<Box>
<Typography variant="body2" color="text.secondary">Lieferart:</Typography>
<Typography variant="body1">{order.deliveryMethod || order.delivery_method || 'Nicht angegeben'}</Typography>
<Typography variant="body2" color="text.secondary">{t('orders.details.deliveryMethod')}</Typography>
<Typography variant="body1">{order.deliveryMethod || order.delivery_method || t('orders.details.notSpecified')}</Typography>
</Box>
<Box>
<Typography variant="body2" color="text.secondary">Zahlungsart:</Typography>
<Typography variant="body1">{order.paymentMethod || order.payment_method || 'Nicht angegeben'}</Typography>
<Typography variant="body2" color="text.secondary">{t('orders.details.paymentMethod')}</Typography>
<Typography variant="body1">{getPaymentMethodDisplay(order.paymentMethod || order.payment_method)}</Typography>
</Box>
</Box>
</Box>
<Typography variant="h6" gutterBottom>Bestellte Artikel</Typography>
<Typography variant="h6" gutterBottom>{t('orders.details.orderedItems')}</Typography>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Artikel</TableCell>
<TableCell align="right">Menge</TableCell>
<TableCell align="right">Preis</TableCell>
<TableCell align="right">Gesamt</TableCell>
<TableCell>{t('orders.details.item')}</TableCell>
<TableCell align="right">{t('orders.details.quantity')}</TableCell>
<TableCell align="right">{t('orders.details.price')}</TableCell>
<TableCell align="right">{t('product.vatShort')}</TableCell>
<TableCell align="right">{t('orders.details.total')}</TableCell>
</TableRow>
</TableHead>
<TableBody>
@@ -102,13 +117,13 @@ const OrderDetailsDialog = ({ open, onClose, order }) => {
<TableCell>{item.name}</TableCell>
<TableCell align="right">{item.quantity_ordered}</TableCell>
<TableCell align="right">{currencyFormatter.format(item.price)}</TableCell>
<TableCell align="right">{item.vat}%</TableCell>
<TableCell align="right">{currencyFormatter.format(item.price * item.quantity_ordered)}</TableCell>
</TableRow>
))}
<TableRow>
<TableCell colSpan={2} />
<TableCell align="right">
<Typography fontWeight="bold">Gesamtnettopreis</Typography>
<TableCell colSpan={4} align="right">
<Typography fontWeight="bold">{t ? t('tax.totalNet') : 'Gesamtnettopreis'}</Typography>
</TableCell>
<TableCell align="right">
<Typography fontWeight="bold">{currencyFormatter.format(vatCalculations.totalNet)}</Typography>
@@ -116,36 +131,19 @@ const OrderDetailsDialog = ({ open, onClose, order }) => {
</TableRow>
{vatCalculations.vat7 > 0 && (
<TableRow>
<TableCell colSpan={2} />
<TableCell align="right">7% Mehrwertsteuer</TableCell>
<TableCell colSpan={4} align="right">{t ? t('tax.vat7') : '7% Mehrwertsteuer'}</TableCell>
<TableCell align="right">{currencyFormatter.format(vatCalculations.vat7)}</TableCell>
</TableRow>
)}
{vatCalculations.vat19 > 0 && (
<TableRow>
<TableCell colSpan={2} />
<TableCell align="right">19% Mehrwertsteuer</TableCell>
<TableCell colSpan={4} align="right">{t ? t('tax.vat19') : '19% Mehrwertsteuer'}</TableCell>
<TableCell align="right">{currencyFormatter.format(vatCalculations.vat19)}</TableCell>
</TableRow>
)}
<TableRow>
<TableCell colSpan={2} />
<TableCell align="right">
<Typography fontWeight="bold">Zwischensumme</Typography>
</TableCell>
<TableCell align="right">
<Typography fontWeight="bold">{currencyFormatter.format(subtotal)}</Typography>
</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={2} />
<TableCell align="right">Lieferkosten</TableCell>
<TableCell align="right">{currencyFormatter.format(order.delivery_cost)}</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={2} />
<TableCell align="right">
<Typography fontWeight="bold">Gesamtsumme</Typography>
<TableCell colSpan={4} align="right">
<Typography fontWeight="bold">{t ? t('cart.summary.total') : 'Gesamtsumme'}</Typography>
</TableCell>
<TableCell align="right">
<Typography fontWeight="bold">{currencyFormatter.format(total)}</Typography>
@@ -159,10 +157,10 @@ const OrderDetailsDialog = ({ open, onClose, order }) => {
<DialogActions>
{order.status === 'new' && (
<Button onClick={handleCancelOrder} color="error">
Bestellung stornieren
{t('orders.details.cancelOrder')}
</Button>
)}
<Button onClick={onClose}>Schließen</Button>
<Button onClick={onClose}>{t ? t('common.close') : 'Schließen'}</Button>
</DialogActions>
</Dialog>
);

View File

@@ -49,18 +49,29 @@ class OrderProcessingService {
waitForVerifyTokenAndProcessOrder() {
// Check if window.cart is already populated (verifyToken already completed)
if (Array.isArray(window.cart) && window.cart.length > 0) {
if (this.paymentCompletionData && this.paymentCompletionData.paymentType === 'mollie') {
this.processMollieOrderWithCart(window.cart);
} else {
this.processStripeOrderWithCart(window.cart);
}
return;
}
// Listen for cart event which is dispatched after verifyToken completes
this.verifyTokenHandler = () => {
if (Array.isArray(window.cart) && window.cart.length > 0) {
this.processStripeOrderWithCart([...window.cart]); // Copy the cart
const cartCopy = [...window.cart]; // Copy the cart
// Clear window.cart after copying
window.cart = [];
window.dispatchEvent(new CustomEvent("cart"));
// Process based on payment type
if (this.paymentCompletionData && this.paymentCompletionData.paymentType === 'mollie') {
this.processMollieOrderWithCart(cartCopy);
} else {
this.processStripeOrderWithCart(cartCopy);
}
} else {
this.setState({
completionError: "Cart is empty. Please add items to your cart before placing an order."
@@ -111,6 +122,21 @@ class OrderProcessingService {
});
}
processMollieOrderWithCart(cartItems) {
// Clear timeout if it exists
if (this.verifyTokenTimeout) {
clearTimeout(this.verifyTokenTimeout);
this.verifyTokenTimeout = null;
}
// Store cart items in state and process order
this.setState({
originalCartItems: cartItems
}, () => {
this.processMollieOrder();
});
}
processStripeOrder() {
// If no original cart items, don't process
if (!this.getState().originalCartItems || this.getState().originalCartItems.length === 0) {
@@ -119,26 +145,24 @@ class OrderProcessingService {
}
// If socket is ready, process immediately
const context = this.getContext();
if (context && context.socket && context.socket.connected) {
const { isLoggedIn: isAuthenticated } = isUserLoggedIn();
if (isAuthenticated) {
this.sendStripeOrder();
return;
}
}
// Wait for socket to be ready
this.socketHandler = () => {
const context = this.getContext();
if (context && context.socket && context.socket.connected) {
const { isLoggedIn: isAuthenticated } = isUserLoggedIn();
const state = this.getState();
if (isAuthenticated && state.showPaymentConfirmation && !state.isCompletingOrder) {
this.sendStripeOrder();
}
}
// Clean up
if (this.socketHandler) {
window.removeEventListener('cart', this.socketHandler);
@@ -187,9 +211,8 @@ class OrderProcessingService {
saveAddressForFuture,
};
// Emit stripe order to backend via socket.io
const context = this.getContext();
context.socket.emit("issueStripeOrder", orderData, (response) => {
window.socketManager.emit("issueStripeOrder", orderData, (response) => {
if (response.success) {
this.setState({
isCompletingOrder: false,
@@ -205,11 +228,24 @@ class OrderProcessingService {
});
}
processMollieOrder() {
// For Mollie payments, the backend handles order creation automatically
// when payment is successful. We just need to show success state.
this.setState({
isCompletingOrder: false,
orderCompleted: true,
completionError: null,
});
// Clear the cart since order was created by backend
window.cart = [];
window.dispatchEvent(new CustomEvent("cart"));
}
// Process regular (non-Stripe) orders
processRegularOrder(orderData) {
const context = this.getContext();
if (context && context.socket && context.socket.connected) {
context.socket.emit("issueOrder", orderData, (response) => {
window.socketManager.emit("issueOrder", orderData, (response) => {
if (response.success) {
// Clear the cart
window.cart = [];
@@ -234,20 +270,12 @@ class OrderProcessingService {
});
}
});
} else {
console.error("Socket context not available");
this.setState({
isCompletingOrder: false,
completionError: "Cannot connect to server. Please try again later.",
});
}
}
// Create Stripe payment intent
createStripeIntent(totalAmount, loadStripeComponent) {
const context = this.getContext();
if (context && context.socket && context.socket.connected) {
context.socket.emit(
window.socketManager.emit(
"createStripeIntent",
{ amount: totalAmount },
(response) => {
@@ -262,22 +290,38 @@ class OrderProcessingService {
}
}
);
};
// Create Mollie payment intent
createMollieIntent(mollieOrderData) {
window.socketManager.emit(
"createMollieIntent",
mollieOrderData,
(response) => {
if (response.success) {
// Store pending payment info and redirect
localStorage.setItem('pendingPayment', JSON.stringify({
paymentId: response.paymentId,
amount: mollieOrderData.amount,
timestamp: Date.now()
}));
window.location.href = response.checkoutUrl;
} else {
console.error("Socket context not available");
console.error("Error:", response.error);
this.setState({
isCompletingOrder: false,
completionError: "Cannot connect to server. Please try again later.",
completionError: response.error || "Failed to create Mollie payment intent. Please try again.",
});
}
}
// Create Mollie payment intent
createMollieIntent(totalAmount, loadMollieComponent) {
loadMollieComponent();
);
}
// Calculate delivery cost
getDeliveryCost() {
const { deliveryMethod, paymentMethod } = this.getState();
const { deliveryMethod, paymentMethod, cartItems } = this.getState();
let cost = 0;
switch (deliveryMethod) {
@@ -297,7 +341,16 @@ class OrderProcessingService {
cost = 6.99;
}
// Add onDelivery surcharge if selected
// Check for free shipping threshold (>= 100€ cart value)
// Free shipping applies to DHL, DPD, and Sperrgut deliveries when cart value >= 100€
if (cartItems && Array.isArray(cartItems) && deliveryMethod !== "Abholung") {
const cartValue = cartItems.reduce((total, item) => total + item.price * item.quantity, 0);
if (cartValue >= 100) {
cost = 0; // Free shipping for orders >= 100€
}
}
// Add onDelivery surcharge if selected (still applies even with free shipping)
if (paymentMethod === "onDelivery") {
cost += 8.99;
}

View File

@@ -5,8 +5,10 @@ import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableRow from '@mui/material/TableRow';
import { useTranslation } from 'react-i18next';
const OrderSummary = ({ deliveryCost, cartItems = [] }) => {
const { t } = useTranslation();
const currencyFormatter = new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR'
@@ -30,9 +32,9 @@ const OrderSummary = ({ deliveryCost, cartItems = [] }) => {
return acc;
}, { totalGross: 0, totalNet: 0, vat7: 0, vat19: 0 });
// Calculate shipping VAT (19% VAT for shipping costs)
const shippingNetPrice = deliveryCost / (1 + 19 / 100);
const shippingVat = deliveryCost - shippingNetPrice;
// Calculate shipping VAT (19% VAT for shipping costs) - only if there are shipping costs
const shippingNetPrice = deliveryCost > 0 ? deliveryCost / (1 + 19 / 100) : 0;
const shippingVat = deliveryCost > 0 ? deliveryCost - shippingNetPrice : 0;
// Combine totals - add shipping VAT to the 19% VAT total
const totalVat7 = cartVatCalculations.vat7;
@@ -42,20 +44,20 @@ const OrderSummary = ({ deliveryCost, cartItems = [] }) => {
return (
<Box sx={{ my: 3, p: 2, bgcolor: '#f5f5f5', borderRadius: 1 }}>
<Typography variant="h6" gutterBottom>
Bestellübersicht
{t ? t('cart.summary.title') : 'Bestellübersicht'}
</Typography>
<Table size="small">
<TableBody>
<TableRow>
<TableCell>Waren (netto):</TableCell>
<TableCell>{t ? t('cart.summary.goodsNet') : 'Waren (netto):'}</TableCell>
<TableCell align="right">
{currencyFormatter.format(cartVatCalculations.totalNet)}
</TableCell>
</TableRow>
{deliveryCost > 0 && (
<TableRow>
<TableCell>Versandkosten (netto):</TableCell>
<TableCell>{t ? t('cart.summary.shippingNet') : 'Versandkosten (netto):'}</TableCell>
<TableCell align="right">
{currencyFormatter.format(shippingNetPrice)}
</TableCell>
@@ -63,7 +65,7 @@ const OrderSummary = ({ deliveryCost, cartItems = [] }) => {
)}
{totalVat7 > 0 && (
<TableRow>
<TableCell>7% Mehrwertsteuer:</TableCell>
<TableCell>{t ? t('tax.vat7') : '7% Mehrwertsteuer'}:</TableCell>
<TableCell align="right">
{currencyFormatter.format(totalVat7)}
</TableCell>
@@ -71,28 +73,37 @@ const OrderSummary = ({ deliveryCost, cartItems = [] }) => {
)}
{totalVat19 > 0 && (
<TableRow>
<TableCell>19% Mehrwertsteuer (inkl. Versand):</TableCell>
<TableCell>{t ? t('tax.vat19WithShipping') : '19% Mehrwertsteuer (inkl. Versand)'}:</TableCell>
<TableCell align="right">
{currencyFormatter.format(totalVat19)}
</TableCell>
</TableRow>
)}
<TableRow>
<TableCell sx={{ fontWeight: 'bold' }}>Gesamtsumme Waren:</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>{t ? t('cart.summary.totalGoods') : 'Gesamtsumme Waren:'}</TableCell>
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
{currencyFormatter.format(cartVatCalculations.totalGross)}
</TableCell>
</TableRow>
{deliveryCost > 0 && (
<TableRow>
<TableCell sx={{ fontWeight: 'bold' }}>Versandkosten:</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>
{t ? t('cart.summary.shippingCosts') : 'Versandkosten:'}
{deliveryCost === 0 && cartVatCalculations.totalGross < 100 && (
<span style={{ color: '#2e7d32', fontSize: '0.8em', marginLeft: '4px' }}>
{t ? t('cart.summary.freeFrom100') : '(kostenlos ab 100€)'}
</span>
)}
</TableCell>
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
{currencyFormatter.format(deliveryCost)}
{deliveryCost === 0 ? (
<span style={{ color: '#2e7d32' }}>{t ? t('cart.summary.free') : 'kostenlos'}</span>
) : (
currencyFormatter.format(deliveryCost)
)}
</TableCell>
</TableRow>
)}
<TableRow sx={{ borderTop: '1px solid #e0e0e0' }}>
<TableCell sx={{ fontWeight: 'bold', pt: 2 }}>Gesamtsumme:</TableCell>
<TableCell sx={{ fontWeight: 'bold', pt: 2 }}>{t ? t('cart.summary.total') : 'Gesamtsumme:'}</TableCell>
<TableCell align="right" sx={{ fontWeight: 'bold', pt: 2 }}>
{currencyFormatter.format(totalGross)}
</TableCell>

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