Compare commits

269 Commits
mollie ... live

Author SHA1 Message Date
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
950 changed files with 35248 additions and 4579 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

@@ -0,0 +1,209 @@
# Multilingual Implementation Guide
## Overview
Your website now supports multiple languages using **react-i18next**. The implementation is designed to work seamlessly with your existing class components and provides:
- **German (default)** and **English** support
- Language persistence in localStorage
- Dynamic language switching
- SEO-friendly language attributes
- Class component compatibility
## Features Implemented
### 1. Language Switcher
- Located in the header next to the login/profile button
- Shows current language (DE/EN) with flag icon
- Dropdown menu for language selection
- Persists selection in browser storage
### 2. Translated Components
- **Header navigation**: Categories, Home links
- **Authentication**: Login/register forms, profile menu
- **Main pages**: Home, Actions, Store pages
- **Cart**: Shopping cart title and sync dialog
- **Product pages**: Basic UI elements (more can be added)
- **Footer**: Basic elements (can be expanded)
### 3. Architecture
- `src/i18n/index.js` - Main i18n configuration
- `src/i18n/withTranslation.js` - HOCs for class components
- `src/i18n/locales/de/translation.json` - German translations
- `src/i18n/locales/en/translation.json` - English translations
- `src/components/LanguageSwitcher.js` - Language selection component
## Usage for Developers
### Using Translations in Class Components
```javascript
import { withI18n } from '../i18n/withTranslation.js';
class MyComponent extends Component {
render() {
const { t } = this.props; // Translation function
return (
<Typography>
{t('navigation.home')} // Translates to "Startseite" or "Home"
</Typography>
);
}
}
export default withI18n()(MyComponent);
```
### Using Translations in Function Components
```javascript
import { useTranslation } from 'react-i18next';
const MyComponent = () => {
const { t } = useTranslation();
return (
<Typography>
{t('navigation.home')}
</Typography>
);
};
```
### Language Context Access
```javascript
import { withLanguage } from '../i18n/withTranslation.js';
class MyComponent extends Component {
render() {
const { languageContext } = this.props;
return (
<Button onClick={() => languageContext.changeLanguage('en')}>
Switch to English
</Button>
);
}
}
export default withLanguage(MyComponent);
```
## Adding New Translations
### 1. Add to German (`src/i18n/locales/de/translation.json`)
```json
{
"newSection": {
"title": "Neuer Titel",
"description": "Neue Beschreibung"
}
}
```
### 2. Add to English (`src/i18n/locales/en/translation.json`)
```json
{
"newSection": {
"title": "New Title",
"description": "New Description"
}
}
```
### 3. Use in Components
```javascript
{t('newSection.title')}
```
## Adding New Languages
### 1. Create Translation File
Create `src/i18n/locales/fr/translation.json` for French
### 2. Update i18n Configuration
```javascript
// src/i18n/index.js
import translationFR from './locales/fr/translation.json';
const resources = {
de: { translation: translationDE },
en: { translation: translationEN },
fr: { translation: translationFR } // Add new language
};
```
### 3. Update Language Provider
```javascript
// src/i18n/withTranslation.js
availableLanguages: ['de', 'en', 'fr'] // Add to available languages
```
### 4. Update Language Switcher
```javascript
// src/components/LanguageSwitcher.js
const names = {
'de': 'Deutsch',
'en': 'English',
'fr': 'Français' // Add language name
};
```
## Configuration Options
### Language Detection Order
Currently set to: `['localStorage', 'navigator', 'htmlTag']`
- First checks localStorage for saved preference
- Falls back to browser language
- Finally checks HTML lang attribute
### Fallback Language
Set to German (`de`) as your primary language
### Debug Mode
Enabled in development mode for easier debugging
## SEO Considerations
- HTML `lang` attribute updates automatically
- Config object provides language-specific metadata
- Descriptions and keywords are language-aware
- Can be extended for hreflang tags and URL localization
## Best Practices
1. **Namespace your translations** - Use nested objects for organization
2. **Provide fallbacks** - Always have German as fallback since it's your primary market
3. **Use interpolation** - For dynamic content: `t('welcome', { name: 'John' })`
4. **Keep translations consistent** - Use same structure in all language files
5. **Test thoroughly** - Verify all UI elements in both languages
## Current Translation Coverage
- ✅ Navigation and menus
- ✅ Authentication flows
- ✅ Basic product elements
- ✅ Cart functionality
- ✅ Main page content
- ⏳ Detailed product descriptions (can be added)
- ⏳ Legal pages content (can be added)
- ⏳ Form validation messages (can be added)
- ⏳ Error messages (can be added)
## Performance
- Translations are bundled and loaded immediately
- No additional network requests
- Lightweight implementation
- Language switching is instant
## Browser Support
Works with all modern browsers that support:
- ES6 modules
- localStorage
- React 19
The implementation is production-ready and can be extended based on your specific needs!

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

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|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.1';
// 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.1",
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

1818
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,13 +7,20 @@
"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"
},
"keywords": [],
"author": "",
@@ -26,12 +33,19 @@
"@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",
"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",
"sharp": "^0.34.2",
"socket.io-client": "^4.7.5"
},
@@ -64,6 +78,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,208 @@
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, {
path: "/socket.io/",
transports: ["websocket"],
reconnection: false,
timeout: 10000,
});
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,7 +121,6 @@ const shopConfig = require("./src/config.js").default;
const { renderPage } = require("./prerender/renderer.cjs");
const {
collectAllCategories,
writeCombinedCssFile,
} = require("./prerender/utils.cjs");
const {
generateProductMetaTags,
@@ -51,6 +136,7 @@ const {
generateLlmsTxt,
generateCategoryLlmsTxt,
generateAllCategoryLlmsPages,
generateCategoryProductList,
} = require("./prerender/seo.cjs");
const {
fetchCategoryProducts,
@@ -73,6 +159,7 @@ 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;
@@ -81,7 +168,7 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback,
const socketUrl = "http://127.0.0.1:9303";
const workerSocket = io(socketUrl, {
path: "/socket.io/",
transports: ["polling", "websocket"],
transports: ["websocket"],
reconnection: false,
timeout: 10000,
});
@@ -107,6 +194,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 +221,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 +370,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 +423,14 @@ 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}`);
}
// Render static pages
console.log("\n📄 Rendering static pages...");
@@ -369,6 +466,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 +542,14 @@ const renderApp = async (categoryData, socket) => {
let categoryPagesRendered = 0;
let categoriesWithProducts = 0;
for (const category of allCategories) {
const allCategoriesPlusNeu = [...allCategories, {
id: "neu",
name: "Neuheiten",
seoName: "neu",
parentId: 209
}];
for (const category of allCategoriesPlusNeu) {
// Skip categories without seoName
if (!category.seoName) {
console.log(
@@ -456,8 +567,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`
);
@@ -572,8 +682,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 +739,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 +811,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;
@@ -721,7 +856,7 @@ const fetchCategoryDataAndRender = () => {
const socket = io(socketUrl, {
path: "/socket.io/",
transports: ["polling", "websocket"], // Using polling first is more robust
transports: ["websocket"],
reconnection: false,
timeout: 10000,
});

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();

View File

@@ -37,9 +37,15 @@ 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,
categoryId: categoryId === "neu" ? "neu" : parseInt(categoryId),
language: 'de',
requestTranslation: false
},
(response) => {
clearTimeout(timeout);
if (response && response.products !== undefined) {
@@ -68,7 +74,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 +152,7 @@ const saveProductImages = async (socket, products, categoryName, outputDir) => {
"public",
"assets",
"images",
"sh.png"
"sh.avif"
);
// Ensure assets/images directory exists
@@ -173,7 +185,7 @@ const saveProductImages = async (socket, products, categoryName, outputDir) => {
if (imageIds.length > 0) {
// Process first image for each product
const bildId = parseInt(imageIds[0]);
const estimatedFilename = `prod${bildId}.jpg`; // We'll generate a filename based on the ID
const estimatedFilename = `prod${bildId}.avif`; // We'll generate a filename based on the ID
const imagePath = path.join(assetsPath, estimatedFilename);
@@ -187,7 +199,7 @@ const saveProductImages = async (socket, products, categoryName, outputDir) => {
const imageBuffer = await fetchProductImage(socket, bildId);
// 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));
@@ -219,12 +231,12 @@ const saveProductImages = async (socket, products, categoryName, outputDir) => {
opacity: 0.3,
},
])
.jpeg() // Ensure output is JPEG
.avif() // Ensure output is AVIF
.toBuffer();
fs.writeFileSync(imagePath, processedImageBuffer);
console.log(
` ✅ Applied centered inverted sh.png overlay to ${estimatedFilename}`
` ✅ Applied centered inverted sh.avif overlay to ${estimatedFilename}`
);
} catch (overlayError) {
console.log(
@@ -269,7 +281,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 +308,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,6 +1,19 @@
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 categoryUrl = `${baseUrl}/Kategorie/${category.seoName}`;
// Calculate price valid date (current date + 3 months)
const priceValidDate = new Date();
priceValidDate.setMonth(priceValidDate.getMonth() + 3);
const priceValidUntil = priceValidDate.toISOString().split("T")[0];
const jsonLd = {
"@context": "https://schema.org/",
"@type": "CollectionPage",
@@ -42,7 +55,7 @@ const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
product.pictureList && product.pictureList.trim()
? `${baseUrl}/assets/images/prod${product.pictureList
.split(",")[0]
.trim()}.jpg`
.trim()}.avif`
: `${baseUrl}/assets/images/nopicture.jpg`,
description: product.description
? product.description.replace(/<[^>]*>/g, "").substring(0, 200)
@@ -57,6 +70,7 @@ const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
url: `${baseUrl}/Artikel/${product.seoName}`,
price: product.price && !isNaN(product.price) ? product.price.toString() : "0.00",
priceCurrency: config.currency,
priceValidUntil: priceValidUntil,
availability: product.available
? "https://schema.org/InStock"
: "https://schema.org/OutOfStock",
@@ -65,6 +79,41 @@ const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
name: config.brandName,
},
itemCondition: "https://schema.org/NewCondition",
hasMerchantReturnPolicy: {
"@type": "MerchantReturnPolicy",
applicableCountry: "DE",
returnPolicyCategory: "https://schema.org/MerchantReturnFiniteReturnWindow",
merchantReturnDays: 14,
returnMethod: "https://schema.org/ReturnByMail",
returnFees: "https://schema.org/FreeReturn",
},
shippingDetails: {
"@type": "OfferShippingDetails",
shippingRate: {
"@type": "MonetaryAmount",
value: 5.90,
currency: "EUR",
},
shippingDestination: {
"@type": "DefinedRegion",
addressCountry: "DE",
},
deliveryTime: {
"@type": "ShippingDeliveryTime",
handlingTime: {
"@type": "QuantitativeValue",
minValue: 0,
maxValue: 1,
unitCode: "DAY",
},
transitTime: {
"@type": "QuantitativeValue",
minValue: 2,
maxValue: 3,
unitCode: "DAY",
},
},
},
},
},
})),

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}">
@@ -41,7 +41,7 @@ const generateHomepageJsonLd = (baseUrl, config, categories = []) => {
"@type": "WebSite",
name: config.brandName,
url: canonicalUrl,
description: config.descriptions.long,
description: config.descriptions.de.long,
publisher: {
"@type": "Organization",
name: config.brandName,
@@ -73,7 +73,7 @@ const generateHomepageJsonLd = (baseUrl, config, categories = []) => {
"@type": "LocalBusiness",
"name": config.brandName,
"alternateName": config.siteName,
"description": config.descriptions.long,
"description": config.descriptions.de.long,
"url": canonicalUrl,
"logo": logoUrl,
"image": logoUrl,

View File

@@ -31,6 +31,7 @@ const {
generateLlmsTxt,
generateCategoryLlmsTxt,
generateAllCategoryLlmsPages,
generateCategoryProductList,
} = require('./llms.cjs');
// Export all functions for use in the main application
@@ -61,4 +62,5 @@ module.exports = {
generateLlmsTxt,
generateCategoryLlmsTxt,
generateAllCategoryLlmsPages,
generateCategoryProductList,
};

View File

@@ -183,7 +183,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 +254,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 +315,5 @@ module.exports = {
generateLlmsTxt,
generateCategoryLlmsTxt,
generateAllCategoryLlmsPages,
generateCategoryProductList,
};

View File

@@ -1,14 +1,21 @@
const generateProductMetaTags = (product, baseUrl, config) => {
const productUrl = `${baseUrl}/Artikel/${product.seoName}`;
const imageUrl =
product.pictureList && product.pictureList.trim()
? `${baseUrl}/assets/images/prod${product.pictureList
.split(",")[0]
.trim()}.jpg`
.trim()}.avif`
: `${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, " ")
@@ -47,6 +54,11 @@ const generateProductMetaTags = (product, baseUrl, config) => {
<!-- 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>
`;
};
@@ -56,7 +68,7 @@ const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) =>
product.pictureList && product.pictureList.trim()
? `${baseUrl}/assets/images/prod${product.pictureList
.split(",")[0]
.trim()}.jpg`
.trim()}.avif`
: `${baseUrl}/assets/images/nopicture.jpg`;
// Clean description for JSON-LD (remove HTML tags)
@@ -94,6 +106,41 @@ const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) =>
"@type": "Organization",
name: config.brandName,
},
hasMerchantReturnPolicy: {
"@type": "MerchantReturnPolicy",
applicableCountry: "DE",
returnPolicyCategory: "https://schema.org/MerchantReturnFiniteReturnWindow",
merchantReturnDays: 14,
returnMethod: "https://schema.org/ReturnByMail",
returnFees: "https://schema.org/FreeReturn",
},
shippingDetails: {
"@type": "OfferShippingDetails",
shippingRate: {
"@type": "MonetaryAmount",
value: 5.90,
currency: "EUR",
},
shippingDestination: {
"@type": "DefinedRegion",
addressCountry: "DE",
},
deliveryTime: {
"@type": "ShippingDeliveryTime",
handlingTime: {
"@type": "QuantitativeValue",
minValue: 0,
maxValue: 1,
unitCode: "DAY",
},
transitTime: {
"@type": "QuantitativeValue",
minValue: 2,
maxValue: 3,
unitCode: "DAY",
},
},
},
},
};

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
});
}

83
process_llms_cat.cjs Normal file
View File

@@ -0,0 +1,83 @@
const fs = require('fs');
const path = require('path');
// Read the input file
const inputFile = path.join(__dirname, 'dist', 'llms-cat.txt');
const outputFile = path.join(__dirname, 'output.csv');
// 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 {
const data = fs.readFileSync(inputFile, 'utf8');
const lines = data.trim().split('\n');
const outputLines = ['URL,SEO Description'];
for (const line of lines) {
if (line.trim() === '') continue;
// Parse the CSV line properly handling escaped quotes
const fields = parseCSVLine(line);
if (fields.length !== 3) {
console.warn(`Skipping malformed line (got ${fields.length} fields): ${line.substring(0, 100)}...`);
continue;
}
const [field1, field2, field3] = fields;
const url = field2;
// field3 is a JSON string - parse it directly
let seoDescription = '';
try {
const parsed = JSON.parse(field3);
seoDescription = parsed.seo_description || '';
} catch (e) {
console.warn(`Failed to parse JSON for URL ${url}: ${e.message}`);
console.warn(`JSON string: ${field3.substring(0, 200)}...`);
}
// Escape quotes for CSV output - URL doesn't need quotes, description does
const escapedDescription = '"' + seoDescription.replace(/"/g, '""') + '"';
outputLines.push(`${url},${escapedDescription}`);
}
// Write the output CSV
fs.writeFileSync(outputFile, outputLines.join('\n'), 'utf8');
console.log(`Processed ${lines.length} lines 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">

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

@@ -0,0 +1,90 @@
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."

View File

@@ -0,0 +1,61 @@
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);
console.log('d');
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);
}
};
console.log('dsfs');
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,30 @@ 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";
// 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"));
const ResetPassword = lazy(() => import(/* webpackChunkName: "reset-password" */ "./pages/ResetPassword.js"));
// TEMPORARILY DISABLE ALL LAZY LOADING TO ELIMINATE CircularProgress
import Content from "./components/Content.js";
import ProductDetail from "./components/ProductDetail.js";
import ProfilePage from "./pages/ProfilePage.js";
import ResetPassword from "./pages/ResetPassword.js";
// Lazy load admin pages - only loaded when admin users access them
const AdminPage = lazy(() => import(/* webpackChunkName: "admin" */ "./pages/AdminPage.js"));
@@ -40,8 +48,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 +59,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 +119,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 +181,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 +226,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 +249,55 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
>
<CircularProgress color="primary" />
</Box>
)
}>
<CarouselProvider>
<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 />} />
{/* 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 +308,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 +355,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 +370,7 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
>
<BugReportIcon sx={{ fontSize: "1.2rem" }} />
</Fab>
</Tooltip>
</Tooltip>*/}
{/* Development-only Theme Customizer FAB */}
{isDevelopment && (
@@ -315,9 +391,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 +448,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,497 @@ 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"],
top: {
xs: "80px",
sm: "80px",
md: "80px",
lg: "80px",
},
left: 0,
width: "100%",
display: "flex",
zIndex: 999, // Just below the AppBar
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',
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 +568,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,7 @@ 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 { withI18n } from "../i18n/withTranslation.js";
if (!Array.isArray(window.cart)) window.cart = [];
@@ -51,11 +52,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 {
@@ -150,12 +154,17 @@ class AddToCartButton extends Component {
},
}}
>
Ab{" "}
{new Date(incoming).toLocaleDateString("de-DE", {
{this.props.t ? this.props.t('cart.availableFrom', {
date: new Date(incoming).toLocaleDateString("de-DE", {
year: "numeric",
month: "long",
day: "numeric",
})}
})
}) : `Ab ${new Date(incoming).toLocaleDateString("de-DE", {
year: "numeric",
month: "long",
day: "numeric",
})}`}
</Button>
);
}
@@ -181,7 +190,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 +216,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 +266,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 +286,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 +317,7 @@ class AddToCartButton extends Component {
fontWeight: "bold",
}}
>
Out of Stock
{this.props.t ? this.props.t('product.outOfStock') : 'Out of Stock'}
</Button>
);
}
@@ -327,7 +342,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>
);
}
@@ -350,6 +367,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 +417,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 +437,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 +457,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

@@ -81,18 +81,6 @@ class ChatAssistant extends Component {
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() {
@@ -104,19 +92,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 +181,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', userMessage);
}
});
}
@@ -300,12 +287,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'
});
}
};
};
@@ -389,12 +374,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'
});
}
};
};
@@ -480,16 +465,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,
@@ -518,7 +503,7 @@ class ChatAssistant extends Component {
<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="Assistent schließen" sx={{ color: 'primary.contrastText' }}>
<CloseIcon />
</IconButton>
</Box>
@@ -581,6 +566,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',
@@ -619,11 +606,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="Aufnahme stoppen"
sx={{ ml: { xs: 0, sm: 1 } }}
>
<StopIcon />
</IconButton>
@@ -631,7 +620,8 @@ class ChatAssistant extends Component {
<IconButton
color="primary"
onClick={this.startRecording}
sx={{ ml: 1 }}
aria-label="Sprachaufnahme starten"
sx={{ ml: { xs: 0, sm: 1 } }}
disabled={isTyping || inputsDisabled}
>
<MicIcon />
@@ -641,7 +631,8 @@ class ChatAssistant extends Component {
<IconButton
color="primary"
onClick={this.handleImageUpload}
sx={{ ml: 1 }}
aria-label="Bild hochladen"
sx={{ ml: { xs: 0, sm: 1 } }}
disabled={isTyping || isRecording || inputsDisabled}
>
<PhotoCameraIcon />
@@ -649,13 +640,14 @@ class ChatAssistant extends Component {
<Button
variant="contained"
sx={{ ml: 1 }}
sx={{ ml: { xs: 0, sm: 1 } }}
onClick={this.handleSendMessage}
disabled={isTyping || isRecording || inputsDisabled}
>
Senden
</Button>
</Box>
</Box>
</Paper>
);
}

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,113 @@ 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 +313,49 @@ 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);
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 +363,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 +384,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 +410,27 @@ 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);
}
}
this.processData(enhancedResponse);
}
@@ -363,17 +452,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 +475,8 @@ class Content extends Component {
this.setState({
...getFilteredProducts(
this.state.unfilteredProducts,
this.state.attributes
this.state.attributes,
this.props.t
)
});
}
@@ -413,28 +504,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 +538,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 +556,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 +609,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 +659,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>
@@ -596,13 +694,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 +730,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,7 +760,7 @@ class Content extends Component {
p: 2,
}}>
<Typography sx={{ fontSize: '1.3rem', color: 'white', fontFamily: 'SwashingtonCP' }}>
Seeds
{this.props.t('sections.seeds')}
</Typography>
</Box>
</Box>
@@ -678,12 +791,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 +821,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 +830,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 +848,4 @@ class Content extends Component {
}
}
export default withRouter(Content);
export default withRouter(withI18n()(withCategory(Content)));

View File

@@ -267,7 +267,7 @@ 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 }}>
{isCollapsed ? <ExpandMoreIcon /> : <ExpandLessIcon />}
</IconButton>
)}

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)(() => ({
@@ -229,9 +230,9 @@ class Footer extends Component {
alignItems={{ xs: 'center', md: 'left' }}
flexWrap="wrap"
>
<StyledRouterLink to="/datenschutz">Datenschutz</StyledRouterLink>
<StyledRouterLink to="/agb">AGB</StyledRouterLink>
<StyledRouterLink to="/sitemap">Sitemap</StyledRouterLink>
<StyledRouterLink to="/datenschutz">{this.props.t ? this.props.t('footer.legal.datenschutz') : 'Datenschutz'}</StyledRouterLink>
<StyledRouterLink to="/agb">{this.props.t ? this.props.t('footer.legal.agb') : 'AGB'}</StyledRouterLink>
<StyledRouterLink to="/sitemap">{this.props.t ? this.props.t('footer.legal.sitemap') : 'Sitemap'}</StyledRouterLink>
</Stack>
<Stack
@@ -241,12 +242,12 @@ class Footer extends Component {
alignItems={{ xs: 'center', md: 'left' }}
flexWrap="wrap"
>
<StyledRouterLink to="/impressum">Impressum</StyledRouterLink>
<StyledRouterLink to="/batteriegesetzhinweise">Batteriegesetzhinweise</StyledRouterLink>
<StyledRouterLink to="/widerrufsrecht">Widerrufsrecht</StyledRouterLink>
<StyledRouterLink to="/impressum">{this.props.t ? this.props.t('footer.legal.impressum') : 'Impressum'}</StyledRouterLink>
<StyledRouterLink to="/batteriegesetzhinweise">{this.props.t ? this.props.t('footer.legal.batteriegesetzhinweise') : 'Batteriegesetzhinweise'}</StyledRouterLink>
<StyledRouterLink to="/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 +264,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 +275,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 +286,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 +316,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))',
@@ -338,11 +347,14 @@ class Footer extends Component {
{/* 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
{this.props.t ? this.props.t('footer.allPricesIncl') : '* Alle Preise inkl. gesetzlicher USt., zzgl. Versand'}
</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>
</Typography>
<Typography variant="body2" sx={{ fontSize: { xs: '9px', md: '9px' }, lineHeight: 1.5, mt: 1 }}>
<StyledDomainLink href="https://telegraf.growheads.de" target="_blank" rel="noreferrer">Telegraf - sicherer Chat mit unseren Mitarbeitern</StyledDomainLink>
</Typography>
</Box>
</Stack>
</Box>
@@ -351,4 +363,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,7 @@ 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}/>}
</AppBar>
);
}
@@ -105,11 +102,14 @@ 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} />
);
};

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,6 +22,8 @@ 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 GoogleIcon from '@mui/icons-material/Google';
// Lazy load GoogleAuthProvider
const GoogleAuthProvider = lazy(() => import('../providers/GoogleAuthProvider.js'));
@@ -170,30 +172,22 @@ 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));
@@ -215,9 +209,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 +238,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 +246,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
});
}
});
@@ -310,22 +303,7 @@ 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');
window.dispatchEvent(new CustomEvent('userLoggedIn'));
@@ -342,22 +320,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 +342,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,13 +355,11 @@ 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));
@@ -406,7 +381,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 +408,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 +418,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 +431,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;
@@ -510,7 +485,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 +501,28 @@ 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 }}>
{this.props.t ? this.props.t('auth.menu.orders') : 'Bestellungen'}
</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 +532,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 +547,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 +570,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 +590,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 +610,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 +618,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 +643,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 +656,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 +667,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 +689,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 +697,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 +719,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 +742,4 @@ export class LoginComponent extends Component {
}
}
export default withRouter(LoginComponent);
export default withRouter(withI18n()(LoginComponent));

View File

@@ -0,0 +1,284 @@
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 { useTranslation } from 'react-i18next';
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 === "filiale" && (
<Box
sx={{
position: 'absolute',
top: '-55px',
left: '-45px',
width: '180px',
height: '180px',
zIndex: 999,
pointerEvents: 'none',
'& *': { pointerEvents: 'none' },
filter: 'drop-shadow(6px 6px 12px rgba(0, 0, 0, 0.6)) drop-shadow(0 0 20px rgba(255, 215, 0, 0.8)) drop-shadow(0 0 40px rgba(255, 215, 0, 0.4))',
display: { xs: 'none', sm: 'block' }
}}
>
<svg viewBox="0 0 60 60" width="168" height="168" className="star-rotate-slow-cw" style={{ position: 'absolute', top: '-9px', left: '-9px', transform: 'rotate(20deg)' }}>
<polygon points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20" fill="#B8860B" />
</svg>
<svg viewBox="0 0 60 60" width="159" height="159" className="star-rotate-slow-ccw" style={{ position: 'absolute', top: '-4.5px', left: '-4.5px', transform: 'rotate(-25deg)' }}>
<polygon points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20" fill="#DAA520" />
</svg>
<svg viewBox="0 0 60 60" width="150" height="150" className="star-rotate-medium-cw">
<polygon points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20" fill="#FFD700" />
</svg>
<div style={{ position: 'absolute', top: '45%', left: '43%', transform: 'translate(-50%, -50%) rotate(-10deg)', color: 'white', fontWeight: '900', fontSize: '20px', textShadow: '0px 3px 6px rgba(0,0,0,0.5)', zIndex: 1000, textAlign: 'center', lineHeight: '1.1', width: '135px', transition: 'opacity 0.3s ease', opacity: starHovered ? 0 : 1 }}>
{translatedContent.showUsPhoto}
</div>
<div style={{ position: 'absolute', top: '45%', left: '43%', transform: 'translate(-50%, -50%) rotate(-10deg)', color: 'white', fontWeight: '900', fontSize: '20px', textShadow: '0px 3px 6px rgba(0,0,0,0.5)', zIndex: 1000, textAlign: 'center', lineHeight: '1.1', width: '135px', opacity: starHovered ? 1 : 0, transition: 'opacity 0.3s ease' }}>
{translatedContent.selectSeedRate}
</div>
</Box>
)}
{index === 1 && pageType === "filiale" && (
<Box
sx={{
position: 'absolute',
bottom: '-45px',
right: '-65px',
width: '180px',
height: '180px',
zIndex: 999,
pointerEvents: 'none',
'& *': { pointerEvents: 'none' },
filter: 'drop-shadow(6px 6px 12px rgba(0, 0, 0, 0.6)) drop-shadow(0 0 20px rgba(175, 238, 238, 0.8)) drop-shadow(0 0 40px rgba(175, 238, 238, 0.4))',
display: { xs: 'none', sm: 'block' }
}}
>
<svg viewBox="0 0 60 60" width="168" height="168" className="star-rotate-slow-ccw" style={{ position: 'absolute', top: '-9px', left: '-9px', transform: 'rotate(20deg)' }}>
<polygon points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20" fill="#5F9EA0" />
</svg>
<svg viewBox="0 0 60 60" width="159" height="159" className="star-rotate-medium-cw" style={{ position: 'absolute', top: '-4.5px', left: '-4.5px', transform: 'rotate(-25deg)' }}>
<polygon points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20" fill="#7FCDCD" />
</svg>
<svg viewBox="0 0 60 60" width="150" height="150" className="star-rotate-slow-cw">
<polygon points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20" fill="#AFEEEE" />
</svg>
<div style={{ position: 'absolute', top: '42%', left: '45%', transform: 'translate(-50%, -50%) rotate(10deg)', color: 'white', fontWeight: '900', fontSize: '20px', textShadow: '0px 3px 6px rgba(0,0,0,0.5)', zIndex: 1000, textAlign: 'center', lineHeight: '1.1', width: '135px' }}>
{translatedContent.indoorSeason}
</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: "all 0.3s ease",
"&:hover": { transform: "translateY(-5px)", boxShadow: 20 },
}}
onMouseEnter={index === 0 && pageType === "filiale" ? () => setStarHovered(true) : undefined}
onMouseLeave={index === 0 && pageType === "filiale" ? () => 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 = {
showUsPhoto: t('sections.showUsPhoto'),
selectSeedRate: t('sections.selectSeedRate'),
indoorSeason: t('sections.indoorSeason')
};
const isHome = currentPath === "/";
const isAktionen = currentPath === "/aktionen";
const isFiliale = currentPath === "/filiale";
React.useEffect(() => {
const style = document.createElement('style');
style.textContent = `
@keyframes rotateClockwise {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes rotateCounterClockwise {
from { transform: rotate(0deg); }
to { transform: rotate(-360deg); }
}
.star-rotate-slow-cw {
animation: rotateClockwise 60s linear infinite;
}
.star-rotate-slow-ccw {
animation: rotateCounterClockwise 45s linear infinite;
}
.star-rotate-medium-cw {
animation: rotateClockwise 30s linear infinite;
}
`;
document.head.appendChild(style);
return () => document.head.removeChild(style);
}, []);
const getNavigationConfig = () => {
if (isHome) return { leftNav: { text: t('navigation.aktionen'), link: "/aktionen" }, rightNav: { text: t('navigation.filiale'), link: "/filiale" } };
if (isAktionen) return { leftNav: { text: t('navigation.filiale'), link: "/filiale" }, rightNav: { text: t('navigation.home'), link: "/" } };
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.stecklinge'), image: "/assets/images/cutlings.avif", bgcolor: "#e8f5d6", link: "/Kategorie/Stecklinge" }
],
aktionen: [
{ title: t('sections.oilPress'), image: "/assets/images/presse.jpg", bgcolor: "#e1f0d3", link: "/presseverleih" },
{ title: t('sections.thcTest'), image: "/assets/images/purpl.jpg", bgcolor: "#e8f5d6", link: "/thc-test" }
],
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>
<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,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,9 +7,67 @@ 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';
// 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) {
super(props);
@@ -26,10 +84,24 @@ class Product extends Component {
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) => {
this.loadImage(bildId);
}
}else{
this.state = {image: null, loading: false, error: false};
}
}
componentDidMount() {
this._isMounted = true;
}
loadImage = (bildId) => {
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/jpeg' }));
window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/avif' }));
if (this._isMounted) {
this.setState({image: window.smallPicCache[bildId], loading: false});
} else {
@@ -45,16 +117,9 @@ class Product extends Component {
this.state.loading = false;
}
}
})
}
}else{
this.state = {image: null, loading: false, error: false};
}
});
}
componentDidMount() {
this._isMounted = true;
}
componentWillUnmount() {
this._isMounted = false;
@@ -65,11 +130,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);
@@ -173,7 +255,7 @@ class Product extends Component {
zIndex: 1000
}}
>
NEU
{this.props.t ? this.props.t('product.new') : 'NEU'}
</div>
</div>
)}
@@ -240,20 +322,20 @@ 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={{
@@ -275,6 +357,14 @@ class Product extends Component {
height={ window.innerWidth < 600 ? "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',
@@ -288,6 +378,14 @@ class Product extends Component {
height={ window.innerWidth < 600 ? "240" : "180" }
image={this.state.image}
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',
@@ -329,20 +427,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 +481,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 +494,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,444 @@
import React from 'react';
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';
const ITEM_WIDTH = 250 + 16; // 250px 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 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,
};
this.carouselTrackRef = React.createRef();
}
componentDidMount() {
this._isMounted = true;
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n.language;
console.log("ProductCarousel componentDidMount: Loading products for categoryId", this.props.categoryId, "language", currentLanguage);
this.loadProducts(currentLanguage);
}
componentDidUpdate(prevProps) {
console.log("ProductCarousel componentDidUpdate", prevProps.languageContext?.currentLanguage, this.props.languageContext?.currentLanguage);
if(prevProps.languageContext?.currentLanguage !== this.props.languageContext?.currentLanguage) {
this.setState({ products: [] }, () => {
this.loadProducts(this.props.languageContext?.currentLanguage || this.props.i18n.language);
});
}
}
loadProducts = (language) => {
const { categoryId } = this.props;
window.socketManager.emit(
"getCategoryProducts",
{
categoryId: categoryId === "neu" ? "neu" : categoryId,
language: language,
requestTranslation: language === 'de' ? false : true
},
(response) => {
console.log("ProductCarousel getCategoryProducts response:", response);
if (this._isMounted && response && response.products && response.products.length > 0) {
// Filter products to only show those with pictures
const productsWithPictures = response.products.filter(product =>
product.pictureList && product.pictureList.length > 0
);
console.log("ProductCarousel: Filtered", productsWithPictures.length, "products with pictures from", response.products.length, "total");
if (productsWithPictures.length > 0) {
// Take random 15 products and shuffle them
const shuffledProducts = this.shuffleArray(productsWithPictures.slice(0, 15));
console.log("ProductCarousel: Selected and shuffled", shuffledProducts.length, "products");
this.originalProducts = shuffledProducts;
// Duplicate for seamless looping
this.products = [...shuffledProducts, ...shuffledProducts];
this.setState({ products: this.products });
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.originalProducts.length === 0) return;
this.translateX -= AUTO_SCROLL_SPEED;
this.updateTrackTransform();
const originalItemCount = this.originalProducts.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.originalProducts.length === 0) return;
// direction: 1 = left (scroll content right), -1 = right (scroll content left)
const originalItemCount = this.originalProducts.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.originalProducts.length === 0) {
return null;
}
const originalItemCount = this.originalProducts.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)
let currentItemIndex;
if (this.translateX === 0) {
currentItemIndex = 0;
} else if (this.translateX > 0) {
const maxScroll = ITEM_WIDTH * originalItemCount;
const effectivePosition = maxScroll + this.translateX;
currentItemIndex = Math.floor(effectivePosition / ITEM_WIDTH);
} else {
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
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 }}>
<Typography
variant="h4"
component="h2"
sx={{
mb: 2,
fontFamily: "SwashingtonCP",
color: "primary.main",
textAlign: "center",
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)"
}}
>
{title || t('product.new')}
</Typography>
<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: 1200,
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: 1200,
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) => (
<div
key={`${product.id}-${index}`}
className="product-carousel-item"
style={{
flex: '0 0 250px',
width: '250px',
maxWidth: '250px',
minWidth: '250px',
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}
/>
</div>
))}
</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

@@ -4,6 +4,7 @@ import Typography from '@mui/material/Typography';
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';
const isNew = (neu) => neu && (new Date().getTime() - new Date(neu).getTime() < 30 * 24 * 60 * 60 * 1000);
@@ -46,6 +47,41 @@ class ProductFilters extends Component {
window.addEventListener('resize', this.adjustPaperHeight);
}
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});
}
}
componentWillUnmount() {
// Remove event listener when component unmounts
window.removeEventListener('resize', this.adjustPaperHeight);
@@ -93,14 +129,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 +151,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)
@@ -186,14 +209,14 @@ class ProductFilters extends Component {
color: 'primary.main'
}}
>
{this.props.dataParam}
{this.props.categoryName}
</Typography>
)}
{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,7 +259,7 @@ 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}
@@ -257,4 +280,4 @@ class ProductFilters extends Component {
}
}
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`;
}
}
@@ -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()}
@@ -462,18 +463,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 +499,4 @@ class ProductList extends Component {
}
}
export default ProductList;
export default withI18n()(ProductList);

View File

@@ -0,0 +1,427 @@
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 { 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: 1200,
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: 1200,
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 for "neu" category */}
<ProductCarousel categoryId="neu" />
</Box>
);
}
}
export default withTranslation()(withLanguage(SharedCarousel));

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,137 @@ 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 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 +151,28 @@ 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) => (
render() {
const { categories, mobileMenuOpen, activeCategoryId } = this.state;
const renderCategoryRow = (categories, isMobile = false) => (
<Box
sx={{
display: "flex",
justifyContent: "flex-start",
alignItems: "center",
flexWrap: isMobile ? "wrap" : "nowrap",
overflowX: isMobile ? "visible" : "auto",
flexWrap: "wrap",
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 +193,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 && {
...(activeCategoryId === null && {
bgcolor: "#fff",
textShadow: "none",
opacity: 1,
@@ -396,7 +217,7 @@ class CategoryList extends Component {
<HomeIcon sx={{
fontSize: "1rem",
mr: isMobile ? 1 : 0,
color: this.props.activeCategoryId === null ? "#2e7d32" : "inherit"
color: activeCategoryId === null ? "#2e7d32" : "inherit"
}} />
{isMobile && (
<Box sx={{ position: "relative", display: "inline-block" }}>
@@ -405,38 +226,93 @@ class CategoryList extends Component {
className="bold-text"
sx={{
fontWeight: "bold",
color: this.props.activeCategoryId === null ? "#2e7d32" : "transparent",
color: activeCategoryId === null ? "#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: activeCategoryId === null ? "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"
}}
>
<FiberNewIcon sx={{
fontSize: "1rem",
mr: isMobile ? 1 : 0
}} />
{isMobile && (
<Box sx={{ position: "relative", display: "inline-block" }}>
{/* Bold text (always rendered to set width) */}
<Box
className="bold-text"
sx={{
fontWeight: "bold",
color: "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: "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>
{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 +335,7 @@ class CategoryList extends Component {
transition: "all 0.2s ease",
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
position: "relative",
...(isActiveAtThisLevel && {
...(activeCategoryId === category.id && {
bgcolor: "#fff",
textShadow: "none",
opacity: 1,
@@ -483,7 +359,7 @@ class CategoryList extends Component {
className="bold-text"
sx={{
fontWeight: "bold",
color: isActiveAtThisLevel ? "#2e7d32" : "transparent",
color: activeCategoryId === category.id ? "#2e7d32" : "transparent",
position: "relative",
zIndex: 2,
}}
@@ -495,7 +371,7 @@ class CategoryList extends Component {
className="thin-text"
sx={{
fontWeight: "400",
color: isActiveAtThisLevel ? "transparent" : "inherit",
color: activeCategoryId === category.id ? "transparent" : "inherit",
position: "absolute",
top: 0,
left: 0,
@@ -509,15 +385,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 +402,84 @@ class CategoryList extends Component {
</Typography>
)
)}
<Button
component={Link}
to="/Konfigurator"
color="inherit"
size="small"
aria-label="Zur Startseite"
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",
...(activeCategoryId === null && {
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: activeCategoryId === null ? "#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: activeCategoryId === null ? "#2e7d32" : "transparent",
position: "relative",
zIndex: 2,
}}
>
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
</Box>
{/* Thin text (positioned on top) */}
<Box
className="thin-text"
sx={{
fontWeight: "400",
color: activeCategoryId === null ? "transparent" : "inherit",
position: "absolute",
top: 0,
left: 0,
zIndex: 1,
}}
>
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
</Box>
</Box>
)}
</Button>
</Box>
);
@@ -549,25 +502,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 +530,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 +545,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 +556,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 +566,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,7 +5,7 @@ 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 {
constructor(props) {
@@ -67,8 +67,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;
@@ -116,7 +115,7 @@ class CartTab extends Component {
// Determine payment method - respect constraints
let prefillPaymentMethod = template.payment_method || "wire";
const paymentMethodMap = {
"credit_card": "stripe",
"credit_card": "mollie",/*stripe*/
"bank_transfer": "wire",
"cash_on_delivery": "onDelivery",
"cash": "cash"
@@ -146,7 +145,6 @@ class CartTab extends Component {
console.log("No order template available or failed to fetch");
}
});
}
};
componentDidMount() {
@@ -292,7 +290,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,7 +320,7 @@ class CartTab extends Component {
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
@@ -363,6 +361,40 @@ class CartTab extends Component {
this.orderService.createStripeIntent(totalAmount, this.loadStripeComponent);
return;
}
// Handle molllie payment differently
if (paymentMethod === "mollie") {
// 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);
}
// Calculate total amount for mollie
const subtotal = cartItems.reduce(
(total, item) => total + item.price * item.quantity,
0
);
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);
return;
}
// Handle regular orders
const orderData = {
@@ -405,7 +437,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 (
@@ -433,7 +465,6 @@ class CartTab extends Component {
{!showPaymentConfirmation && (
<CartDropdown
cartItems={cartItems}
socket={this.context.socket}
showDetailedSummary={showStripePayment}
deliveryMethod={deliveryMethod}
deliveryCost={deliveryCost}
@@ -445,7 +476,7 @@ class CartTab extends Component {
{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 ? (
@@ -463,7 +494,7 @@ 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} />
@@ -504,7 +535,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,6 +93,7 @@ class CheckoutForm extends Component {
deliveryMethod={deliveryMethod}
onChange={onDeliveryMethodChange}
isPickupOnly={isPickupOnly || hasStecklinge}
cartItems={cartItems}
/>
{(deliveryMethod === "DHL" || deliveryMethod === "DPD") && (
@@ -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;
@@ -82,7 +82,7 @@ class CheckoutValidation {
// Prefer stripe when available and meets minimum amount
if (deliveryMethod === "DHL" || deliveryMethod === "DPD" || deliveryMethod === "Abholung") {
return "stripe";
return "wire";/*stripe*/
}
// Fall back to wire transfer
@@ -106,11 +106,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,42 @@ 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 €',
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: '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.dpd') : '4,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 +47,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 +122,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,18 +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.",
});
}
}
);
}
// Calculate delivery cost
getDeliveryCost() {
const { deliveryMethod, paymentMethod } = this.getState();
const { deliveryMethod, paymentMethod, cartItems } = this.getState();
let cost = 0;
switch (deliveryMethod) {
@@ -293,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>

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect, useContext, useCallback } from "react";
import React, { useState, useEffect, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { withI18n } from "../../i18n/withTranslation.js";
import {
Box,
Paper,
@@ -14,45 +15,48 @@ import {
Tooltip,
CircularProgress,
Typography,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
} from "@mui/material";
import SearchIcon from "@mui/icons-material/Search";
import SocketContext from "../../contexts/SocketContext.js";
import CancelIcon from "@mui/icons-material/Cancel";
import OrderDetailsDialog from "./OrderDetailsDialog.js";
// Constants
const statusTranslations = {
new: "in Bearbeitung",
pending: "Neu",
processing: "in Bearbeitung",
cancelled: "Storniert",
shipped: "Verschickt",
delivered: "Geliefert",
const getStatusTranslation = (status, t) => {
const statusMap = {
new: t ? t('orders.status.new') : "in Bearbeitung",
pending: t ? t('orders.status.pending') : "Neu",
processing: t ? t('orders.status.processing') : "in Bearbeitung",
paid: t ? t('orders.status.paid') : "Bezahlt",
cancelled: t ? t('orders.status.cancelled') : "Storniert",
shipped: t ? t('orders.status.shipped') : "Verschickt",
delivered: t ? t('orders.status.delivered') : "Geliefert",
};
return statusMap[status] || status;
};
const statusEmojis = {
"in Bearbeitung": "⚙️",
new: "⚙️",
pending: "⏳",
processing: "🔄",
paid: "🏦",
cancelled: "❌",
Verschickt: "🚚",
Geliefert: "✅",
Storniert: "❌",
Retoure: "↩️",
"Teil Retoure": "↪️",
"Teil geliefert": "⚡",
shipped: "🚚",
delivered: "✅",
};
const statusColors = {
"in Bearbeitung": "#ed6c02", // orange
new: "#ed6c02", // orange
pending: "#ff9800", // orange for pending
processing: "#2196f3", // blue for processing
paid: "#2e7d32", // green
cancelled: "#d32f2f", // red for cancelled
Verschickt: "#2e7d32", // green
Geliefert: "#2e7d32", // green
Storniert: "#d32f2f", // red
Retoure: "#9c27b0", // purple
"Teil Retoure": "#9c27b0", // purple
"Teil geliefert": "#009688", // teal
shipped: "#2e7d32", // green
delivered: "#2e7d32", // green
};
const currencyFormatter = new Intl.NumberFormat("de-DE", {
@@ -61,14 +65,16 @@ const currencyFormatter = new Intl.NumberFormat("de-DE", {
});
// Orders Tab Content Component
const OrdersTab = ({ orderIdFromHash }) => {
const OrdersTab = ({ orderIdFromHash, t }) => {
const [orders, setOrders] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [selectedOrder, setSelectedOrder] = useState(null);
const [isDetailsDialogOpen, setIsDetailsDialogOpen] = useState(false);
const [cancelConfirmOpen, setCancelConfirmOpen] = useState(false);
const [orderToCancel, setOrderToCancel] = useState(null);
const [isCancelling, setIsCancelling] = useState(false);
const {socket} = useContext(SocketContext);
const navigate = useNavigate();
const handleViewDetails = useCallback(
@@ -77,16 +83,18 @@ const OrdersTab = ({ orderIdFromHash }) => {
if (orderToView) {
setSelectedOrder(orderToView);
setIsDetailsDialogOpen(true);
// Update the hash to include the order ID
navigate(`/profile#${orderId}`, { replace: true });
}
},
[orders]
[orders, navigate]
);
const fetchOrders = useCallback(() => {
if (socket && socket.connected) {
setLoading(true);
setError(null);
socket.emit("getOrders", (response) => {
window.socketManager.emit("getOrders", (response) => {
if (response.success) {
setOrders(response.orders);
} else {
@@ -94,25 +102,13 @@ const OrdersTab = ({ orderIdFromHash }) => {
}
setLoading(false);
});
} else {
// Socket not connected yet, but don't show error immediately on first load
console.log("Socket not connected yet, waiting for connection to fetch orders");
setLoading(false); // Stop loading when socket is not connected
}
}, [socket]);
}, []);
useEffect(() => {
fetchOrders();
}, [fetchOrders]);
// Monitor socket connection changes
useEffect(() => {
if (socket && socket.connected && orders.length === 0) {
// Socket just connected and we don't have orders yet, fetch them
fetchOrders();
}
}, [socket, socket?.connected, orders.length, fetchOrders]);
useEffect(() => {
if (orderIdFromHash && orders.length > 0) {
handleViewDetails(orderIdFromHash);
@@ -120,7 +116,7 @@ const OrdersTab = ({ orderIdFromHash }) => {
}, [orderIdFromHash, orders, handleViewDetails]);
const getStatusDisplay = (status) => {
return statusTranslations[status] || status;
return getStatusTranslation(status, t);
};
const getStatusEmoji = (status) => {
@@ -134,7 +130,48 @@ const OrdersTab = ({ orderIdFromHash }) => {
const handleCloseDetailsDialog = () => {
setIsDetailsDialogOpen(false);
setSelectedOrder(null);
navigate("/profile", { replace: true });
navigate("/profile#orders", { replace: true });
};
// Check if order can be cancelled
const isOrderCancelable = (order) => {
const cancelableStatuses = ['new', 'pending', 'processing'];
return cancelableStatuses.includes(order.status);
};
// Handle cancel button click
const handleCancelClick = (order) => {
setOrderToCancel(order);
setCancelConfirmOpen(true);
};
// Handle cancel confirmation
const handleConfirmCancel = () => {
if (!orderToCancel) return;
setIsCancelling(true);
window.socketManager.emit('cancelOrder', { orderId: orderToCancel.orderId }, (response) => {
setIsCancelling(false);
setCancelConfirmOpen(false);
if (response.success) {
console.log('Order cancelled:', response.orderId);
// Refresh orders list
fetchOrders();
} else {
setError(response.error || 'Failed to cancel order');
}
setOrderToCancel(null);
});
};
// Handle cancel dialog close
const handleCancelDialogClose = () => {
if (!isCancelling) {
setCancelConfirmOpen(false);
setOrderToCancel(null);
}
};
if (loading) {
@@ -160,22 +197,21 @@ const OrdersTab = ({ orderIdFromHash }) => {
<Table>
<TableHead>
<TableRow>
<TableCell>Bestellnummer</TableCell>
<TableCell>Datum</TableCell>
<TableCell>Status</TableCell>
<TableCell>Artikel</TableCell>
<TableCell align="right">Summe</TableCell>
<TableCell align="center">Aktionen</TableCell>
<TableCell>{t ? t('orders.table.orderNumber') : 'Bestellnummer'}</TableCell>
<TableCell>{t ? t('orders.table.date') : 'Datum'}</TableCell>
<TableCell>{t ? t('orders.table.status') : 'Status'}</TableCell>
<TableCell>{t ? t('orders.table.items') : 'Artikel'}</TableCell>
<TableCell align="right">{t ? t('orders.table.total') : 'Summe'}</TableCell>
<TableCell align="center">{t ? t('orders.table.actions') : 'Aktionen'}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{orders.map((order) => {
const displayStatus = getStatusDisplay(order.status);
const subtotal = order.items.reduce(
const total = order.items.reduce(
(acc, item) => acc + item.price * item.quantity_ordered,
0
);
const total = subtotal + order.delivery_cost;
return (
<TableRow key={order.orderId} hover>
<TableCell>{order.orderId}</TableCell>
@@ -188,11 +224,11 @@ const OrdersTab = ({ orderIdFromHash }) => {
display: "flex",
alignItems: "center",
gap: "8px",
color: getStatusColor(displayStatus),
color: getStatusColor(order.status),
}}
>
<span style={{ fontSize: "1.2rem" }}>
{getStatusEmoji(displayStatus)}
{getStatusEmoji(order.status)}
</span>
<Typography
variant="body2"
@@ -202,9 +238,30 @@ const OrdersTab = ({ orderIdFromHash }) => {
{displayStatus}
</Typography>
</Box>
{order.delivery_method === 'DHL' && order.trackingCode && (
<Box sx={{ mt: 0.5 }}>
<a
href={`https://www.dhl.de/de/privatkunden/dhl-sendungsverfolgung.html?piececode=${order.trackingCode}`}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: '0.85rem', color: '#d40511' }}
>
📦 {t ? t('orders.trackShipment') : 'Sendung verfolgen'}
</a>
</Box>
)}
</TableCell>
<TableCell>
{order.items.reduce(
{order.items
.filter(item => {
// Exclude delivery items - backend uses deliveryMethod ID as item name
const itemName = item.name || '';
return itemName !== 'DHL' &&
itemName !== 'DPD' &&
itemName !== 'Sperrgut' &&
itemName !== 'Abholung';
})
.reduce(
(acc, item) => acc + item.quantity_ordered,
0
)}
@@ -213,15 +270,30 @@ const OrdersTab = ({ orderIdFromHash }) => {
{currencyFormatter.format(total)}
</TableCell>
<TableCell align="center">
<Tooltip title="Details anzeigen">
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'center' }}>
<Tooltip title={t ? t('orders.tooltips.viewDetails') : 'Details anzeigen'}>
<IconButton
size="small"
color="primary"
onClick={() => handleViewDetails(order.orderId)}
aria-label={t ? t('orders.tooltips.viewDetails') : 'Details anzeigen'}
>
<SearchIcon />
</IconButton>
</Tooltip>
{isOrderCancelable(order) && (
<Tooltip title={t ? t('orders.tooltips.cancelOrder') : 'Bestellung stornieren'}>
<IconButton
size="small"
color="error"
onClick={() => handleCancelClick(order)}
aria-label={t ? t('orders.tooltips.cancelOrder') : 'Bestellung stornieren'}
>
<CancelIcon />
</IconButton>
</Tooltip>
)}
</Box>
</TableCell>
</TableRow>
);
@@ -231,7 +303,7 @@ const OrdersTab = ({ orderIdFromHash }) => {
</TableContainer>
) : (
<Alert severity="info">
Sie haben noch keine Bestellungen aufgegeben.
{t ? t('orders.noOrders') : 'Sie haben noch keine Bestellungen aufgegeben.'}
</Alert>
)}
<OrderDetailsDialog
@@ -239,8 +311,49 @@ const OrdersTab = ({ orderIdFromHash }) => {
onClose={handleCloseDetailsDialog}
order={selectedOrder}
/>
{/* Cancel Confirmation Dialog */}
<Dialog
open={cancelConfirmOpen}
onClose={handleCancelDialogClose}
maxWidth="sm"
fullWidth
>
<DialogTitle>
{t ? t('orders.cancelConfirm.title') : 'Bestellung stornieren'}
</DialogTitle>
<DialogContent>
<Typography>
{t ? t('orders.cancelConfirm.message') : 'Sind Sie sicher, dass Sie diese Bestellung stornieren möchten?'}
</Typography>
{orderToCancel && (
<Typography variant="body2" sx={{ mt: 1, fontWeight: 'bold' }}>
{t ? t('orders.table.orderNumber') : 'Bestellnummer'}: {orderToCancel.orderId}
</Typography>
)}
</DialogContent>
<DialogActions>
<Button
onClick={handleCancelDialogClose}
disabled={isCancelling}
>
{t ? t('common.cancel') : 'Abbrechen'}
</Button>
<Button
onClick={handleConfirmCancel}
color="error"
variant="contained"
disabled={isCancelling}
>
{isCancelling
? (t ? t('orders.cancelConfirm.cancelling') : 'Wird storniert...')
: (t ? t('orders.cancelConfirm.confirm') : 'Stornieren')
}
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default OrdersTab;
export default withI18n()(OrdersTab);

View File

@@ -1,5 +1,6 @@
import React, { Component } from "react";
import { Box, Typography, Button } from "@mui/material";
import { withI18n } from "../../i18n/withTranslation.js";
class PaymentConfirmationDialog extends Component {
render() {
@@ -28,30 +29,32 @@ class PaymentConfirmationDialog extends Component {
color: paymentCompletionData.isSuccessful ? '#2e7d32' : '#d32f2f',
fontWeight: 'bold'
}}>
{paymentCompletionData.isSuccessful ? 'Zahlung erfolgreich!' : 'Zahlung fehlgeschlagen'}
{paymentCompletionData.isSuccessful ?
(this.props.t ? this.props.t('payment.successful') : 'Zahlung erfolgreich!') :
(this.props.t ? this.props.t('payment.failed') : 'Zahlung fehlgeschlagen')}
</Typography>
{paymentCompletionData.isSuccessful ? (
<>
{orderCompleted ? (
<Typography variant="body1" sx={{ mt: 2, color: '#2e7d32' }}>
🎉 Ihre Bestellung wurde erfolgreich abgeschlossen! Sie können jetzt Ihre Bestellungen einsehen.
{this.props.t ? this.props.t('payment.orderCompleted') : '🎉 Ihre Bestellung wurde erfolgreich abgeschlossen! Sie können jetzt Ihre Bestellungen einsehen.'}
</Typography>
) : (
<Typography variant="body1" sx={{ mt: 2, color: '#2e7d32' }}>
Ihre Zahlung wurde erfolgreich verarbeitet. Die Bestellung wird automatisch abgeschlossen.
{this.props.t ? this.props.t('payment.orderProcessing') : 'Ihre Zahlung wurde erfolgreich verarbeitet. Die Bestellung wird automatisch abgeschlossen.'}
</Typography>
)}
</>
) : (
<Typography variant="body1" sx={{ mt: 2, color: '#d32f2f' }}>
Ihre Zahlung konnte nicht verarbeitet werden. Bitte versuchen Sie es erneut oder wählen Sie eine andere Zahlungsmethode.
{this.props.t ? this.props.t('payment.paymentError') : 'Ihre Zahlung konnte nicht verarbeitet werden. Bitte versuchen Sie es erneut oder wählen Sie eine andere Zahlungsmethode.'}
</Typography>
)}
{isCompletingOrder && (
<Typography variant="body2" sx={{ mt: 2, color: '#2e7d32', p: 2, bgcolor: '#e8f5e8', borderRadius: 1 }}>
Bestellung wird abgeschlossen...
{this.props.t ? this.props.t('orders.processing') : 'Bestellung wird abgeschlossen...'}
</Typography>
)}
@@ -75,7 +78,7 @@ class PaymentConfirmationDialog extends Component {
}
}}
>
Weiter einkaufen
{this.props.t ? this.props.t('cart.continueShopping') : 'Weiter einkaufen'}
</Button>
<Button
onClick={onViewOrders}
@@ -85,7 +88,7 @@ class PaymentConfirmationDialog extends Component {
'&:hover': { bgcolor: '#1b5e20' }
}}
>
Zu meinen Bestellungen
{this.props.t ? this.props.t('payment.viewOrders') : 'Zu meinen Bestellungen'}
</Button>
</Box>
)}
@@ -94,4 +97,4 @@ class PaymentConfirmationDialog extends Component {
}
}
export default PaymentConfirmationDialog;
export default withI18n()(PaymentConfirmationDialog);

View File

@@ -1,7 +1,8 @@
import React, { useEffect, useCallback } from "react";
import { Box, Typography, Radio } from "@mui/material";
import { withI18n } from "../../i18n/withTranslation.js";
const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeliveryMethodChange, cartItems = [], deliveryCost = 0 }) => {
const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeliveryMethodChange, cartItems = [], deliveryCost = 0, t }) => {
// Calculate total amount
const subtotal = cartItems.reduce((total, item) => total + item.price * item.quantity, 0);
@@ -24,7 +25,7 @@ const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeli
// Handle delivery method changes - auto-switch to stripe when DHL/DPD is selected
useEffect(() => {
if ((deliveryMethod === "DHL" || deliveryMethod === "DPD") && paymentMethod === "cash") {
handlePaymentMethodChange({ target: { value: "stripe" } });
handlePaymentMethodChange({ target: { value: "wire" /*stripe*/ } });
}
}, [deliveryMethod, paymentMethod, handlePaymentMethodChange]);
@@ -38,11 +39,11 @@ const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeli
const paymentOptions = [
{
id: "wire",
name: "Überweisung",
description: "Bezahlen Sie per Banküberweisung",
name: t ? t('payment.methods.bankTransfer') : "Überweisung",
description: t ? t('payment.methods.bankTransferDescription') : "Bezahlen Sie per Banküberweisung",
disabled: totalAmount === 0,
},
{
/*{
id: "stripe",
name: "Karte oder Sofortüberweisung",
description: totalAmount < 0.50 && totalAmount > 0
@@ -55,18 +56,32 @@ const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeli
"/assets/images/mastercard.png",
"/assets/images/visa_electron.png",
],
},
},*/
/*{
id: "mollie",
name: t ? t('payment.methods.cardPayment') : "Karte, Sofortüberweisung, Apple Pay, Google Pay, PayPal",
description: totalAmount < 0.50 && totalAmount > 0
? (t ? t('payment.methods.cardPaymentMinAmount') : "Bezahlen Sie per Karte oder Sofortüberweisung (Mindestbetrag: 0,50 €)")
: (t ? t('payment.methods.cardPaymentDescription') : "Bezahlen Sie per Karte oder Sofortüberweisung"),
disabled: totalAmount < 0.50 || (deliveryMethod !== "DHL" && deliveryMethod !== "DPD" && deliveryMethod !== "Abholung"),
icons: [
"/assets/images/giropay.png",
"/assets/images/maestro.png",
"/assets/images/mastercard.png",
"/assets/images/visa_electron.png",
],
},*/
{
id: "onDelivery",
name: "Nachnahme",
description: "Bezahlen Sie bei Lieferung (8,99 € Aufschlag)",
name: t ? t('payment.methods.cashOnDelivery') : "Nachnahme",
description: t ? t('payment.methods.cashOnDeliveryDescription') : "Bezahlen Sie bei Lieferung (8,99 € Aufschlag)",
disabled: totalAmount === 0 || deliveryMethod !== "DHL",
icons: ["/assets/images/cash.png"],
},
{
id: "cash",
name: "Zahlung in der Filiale",
description: "Bei Abholung bezahlen",
name: t ? t('payment.methods.cashInStore') : "Zahlung in der Filiale",
description: t ? t('payment.methods.cashInStoreDescription') : "Bei Abholung bezahlen",
disabled: false, // Always enabled
icons: ["/assets/images/cash.png"],
},
@@ -75,7 +90,7 @@ const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeli
return (
<>
<Typography variant="h6" gutterBottom>
Zahlungsart wählen
{t ? t('payment.methods.selectPaymentMethod') : 'Zahlungsart wählen'}
</Typography>
<Box sx={{ mb: 3 }}>
@@ -175,4 +190,4 @@ const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeli
);
};
export default PaymentMethodSelector;
export default withI18n()(PaymentMethodSelector);

View File

@@ -11,7 +11,8 @@ import {
IconButton,
Snackbar
} from '@mui/material';
import { ContentCopy } from '@mui/icons-material';
import ContentCopy from '@mui/icons-material/ContentCopy';
import { withI18n } from '../../i18n/withTranslation.js';
class SettingsTab extends Component {
constructor(props) {
@@ -47,7 +48,7 @@ class SettingsTab extends Component {
this.setState({ newEmail: user.email || '' });
// Check if user has an API key
this.props.socket.emit('isApiKey', (response) => {
window.socketManager.emit('isApiKey', (response) => {
if (response.success && response.hasApiKey) {
this.setState({
hasApiKey: true,
@@ -72,38 +73,38 @@ class SettingsTab extends Component {
// Validation
if (!this.state.currentPassword || !this.state.newPassword || !this.state.confirmPassword) {
this.setState({ passwordError: 'Bitte füllen Sie alle Felder aus' });
this.setState({ passwordError: this.props.t ? this.props.t('settings.errors.fillAllFields') : 'Bitte füllen Sie alle Felder aus' });
return;
}
if (this.state.newPassword !== this.state.confirmPassword) {
this.setState({ passwordError: 'Die neuen Passwörter stimmen nicht überein' });
this.setState({ passwordError: this.props.t ? this.props.t('settings.errors.passwordsNotMatch') : 'Die neuen Passwörter stimmen nicht überein' });
return;
}
if (this.state.newPassword.length < 8) {
this.setState({ passwordError: 'Das neue Passwort muss mindestens 8 Zeichen lang sein' });
this.setState({ passwordError: this.props.t ? this.props.t('settings.errors.passwordTooShort') : 'Das neue Passwort muss mindestens 8 Zeichen lang sein' });
return;
}
this.setState({ loading: true });
// Call socket.io endpoint to update password
this.props.socket.emit('updatePassword',
window.socketManager.emit('updatePassword',
{ oldPassword: this.state.currentPassword, newPassword: this.state.newPassword },
(response) => {
this.setState({ loading: false });
if (response.success) {
this.setState({
passwordSuccess: 'Passwort erfolgreich aktualisiert',
passwordSuccess: this.props.t ? this.props.t('settings.success.passwordUpdated') : 'Passwort erfolgreich aktualisiert',
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
} else {
this.setState({
passwordError: response.message || 'Fehler beim Aktualisieren des Passworts'
passwordError: response.message || (this.props.t ? this.props.t('settings.errors.passwordUpdateError') : 'Fehler beim Aktualisieren des Passworts')
});
}
}
@@ -121,26 +122,26 @@ class SettingsTab extends Component {
// Validation
if (!this.state.password || !this.state.newEmail) {
this.setState({ emailError: 'Bitte füllen Sie alle Felder aus' });
this.setState({ emailError: this.props.t ? this.props.t('settings.errors.fillAllFields') : 'Bitte füllen Sie alle Felder aus' });
return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.state.newEmail)) {
this.setState({ emailError: 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
this.setState({ emailError: this.props.t ? this.props.t('settings.errors.invalidEmail') : 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
return;
}
this.setState({ loading: true });
// Call socket.io endpoint to update email
this.props.socket.emit('updateEmail',
window.socketManager.emit('updateEmail',
{ password: this.state.password, email: this.state.newEmail },
(response) => {
this.setState({ loading: false });
if (response.success) {
this.setState({
emailSuccess: 'E-Mail-Adresse erfolgreich aktualisiert',
emailSuccess: this.props.t ? this.props.t('settings.success.emailUpdated') : 'E-Mail-Adresse erfolgreich aktualisiert',
password: ''
});
@@ -157,7 +158,7 @@ class SettingsTab extends Component {
}
} else {
this.setState({
emailError: response.message || 'Fehler beim Aktualisieren der E-Mail-Adresse'
emailError: response.message || (this.props.t ? this.props.t('settings.errors.emailUpdateError') : 'Fehler beim Aktualisieren der E-Mail-Adresse')
});
}
}
@@ -183,7 +184,7 @@ class SettingsTab extends Component {
try {
const user = JSON.parse(storedUser);
this.props.socket.emit('createApiKey', user.id, (response) => {
window.socketManager.emit('createApiKey', user.id, (response) => {
this.setState({ loadingApiKey: false });
if (response.success) {
@@ -238,7 +239,7 @@ class SettingsTab extends Component {
<Box sx={{ p: { xs: 1, sm: 3 } }}>
<Paper sx={{ p: { xs: 2, sm: 3 } }}>
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
Passwort ändern
{this.props.t ? this.props.t('settings.changePassword') : 'Passwort ändern'}
</Typography>
{this.state.passwordError && <Alert severity="error" sx={{ mb: 2 }}>{this.state.passwordError}</Alert>}
@@ -247,7 +248,7 @@ class SettingsTab extends Component {
<Box component="form" onSubmit={this.handleUpdatePassword}>
<TextField
margin="normal"
label="Aktuelles Passwort"
label={this.props.t ? this.props.t('settings.currentPassword') : 'Aktuelles Passwort'}
type="password"
fullWidth
value={this.state.currentPassword}
@@ -257,7 +258,7 @@ class SettingsTab extends Component {
<TextField
margin="normal"
label="Neues Passwort"
label={this.props.t ? this.props.t('settings.newPassword') : 'Neues Passwort'}
type="password"
fullWidth
value={this.state.newPassword}
@@ -267,7 +268,7 @@ class SettingsTab extends Component {
<TextField
margin="normal"
label="Neues Passwort bestätigen"
label={this.props.t ? this.props.t('settings.confirmNewPassword') : 'Neues Passwort bestätigen'}
type="password"
fullWidth
value={this.state.confirmPassword}
@@ -282,7 +283,7 @@ class SettingsTab extends Component {
sx={{ mt: 2, bgcolor: '#2e7d32', '&:hover': { bgcolor: '#1b5e20' } }}
disabled={this.state.loading}
>
{this.state.loading ? <CircularProgress size={24} /> : 'Passwort aktualisieren'}
{this.state.loading ? <CircularProgress size={24} /> : (this.props.t ? this.props.t('settings.updatePassword') : 'Passwort aktualisieren')}
</Button>
</Box>
</Paper>
@@ -291,7 +292,7 @@ class SettingsTab extends Component {
<Paper sx={{ p: { xs: 2, sm: 3 } }}>
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
E-Mail-Adresse ändern
{this.props.t ? this.props.t('settings.changeEmail') : 'E-Mail-Adresse ändern'}
</Typography>
{this.state.emailError && <Alert severity="error" sx={{ mb: 2 }}>{this.state.emailError}</Alert>}
@@ -300,7 +301,7 @@ class SettingsTab extends Component {
<Box component="form" onSubmit={this.handleUpdateEmail}>
<TextField
margin="normal"
label="Passwort"
label={this.props.t ? this.props.t('settings.password') : 'Passwort'}
type="password"
fullWidth
value={this.state.password}
@@ -310,7 +311,7 @@ class SettingsTab extends Component {
<TextField
margin="normal"
label="Neue E-Mail-Adresse"
label={this.props.t ? this.props.t('settings.newEmail') : 'Neue E-Mail-Adresse'}
type="email"
fullWidth
value={this.state.newEmail}
@@ -325,7 +326,7 @@ class SettingsTab extends Component {
sx={{ mt: 2, bgcolor: '#2e7d32', '&:hover': { bgcolor: '#1b5e20' } }}
disabled={this.state.loading}
>
{this.state.loading ? <CircularProgress size={24} /> : 'E-Mail aktualisieren'}
{this.state.loading ? <CircularProgress size={24} /> : (this.props.t ? this.props.t('settings.updateEmail') : 'E-Mail aktualisieren')}
</Button>
</Box>
</Paper>
@@ -334,11 +335,11 @@ class SettingsTab extends Component {
<Paper sx={{ p: { xs: 2, sm: 3 } }}>
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
API-Schlüssel
{this.props.t ? this.props.t('settings.apiKey') : 'API-Schlüssel'}
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
Verwenden Sie Ihren API-Schlüssel für die Integration mit externen Anwendungen.
{this.props.t ? this.props.t('settings.apiKeyDescription') : 'Verwenden Sie Ihren API-Schlüssel für die Integration mit externen Anwendungen.'}
</Typography>
{this.state.apiKeyError && <Alert severity="error" sx={{ mb: 2 }}>{this.state.apiKeyError}</Alert>}
@@ -347,14 +348,14 @@ class SettingsTab extends Component {
{this.state.apiKeySuccess}
{this.state.apiKey && this.state.apiKeyDisplay !== '************' && (
<Typography variant="body2" sx={{ mt: 1 }}>
Speichern Sie diesen Schlüssel sicher. Er wird aus Sicherheitsgründen in 10 Sekunden ausgeblendet.
{this.props.t ? this.props.t('settings.success.apiKeyWarning') : 'Speichern Sie diesen Schlüssel sicher. Er wird aus Sicherheitsgründen in 10 Sekunden ausgeblendet.'}
</Typography>
)}
</Alert>
)}
<Typography variant="body2" sx={{ mb: 2 }}>
API-Dokumentation: {' '}
{this.props.t ? this.props.t('settings.apiDocumentation') : 'API-Dokumentation:'} {' '}
<a
href={`${window.location.protocol}//${window.location.host}/api/`}
target="_blank"
@@ -367,7 +368,7 @@ class SettingsTab extends Component {
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', mt: 2 }}>
<TextField
label="API-Schlüssel"
label={this.props.t ? this.props.t('settings.apiKey') : 'API-Schlüssel'}
value={this.state.apiKeyDisplay}
disabled
fullWidth
@@ -381,11 +382,12 @@ class SettingsTab extends Component {
{this.state.apiKeyDisplay !== '************' && this.state.apiKey && (
<IconButton
onClick={this.handleCopyToClipboard}
aria-label={this.props.t ? this.props.t('settings.copyToClipboard') : 'In Zwischenablage kopieren'}
sx={{
color: '#2e7d32',
'&:hover': { bgcolor: 'rgba(46, 125, 50, 0.1)' }
}}
title="In Zwischenablage kopieren"
title={this.props.t ? this.props.t('settings.copyToClipboard') : 'In Zwischenablage kopieren'}
>
<ContentCopy />
</IconButton>
@@ -405,7 +407,7 @@ class SettingsTab extends Component {
{this.state.loadingApiKey ? (
<CircularProgress size={24} />
) : (
this.state.hasApiKey ? 'Regenerieren' : 'Generieren'
this.state.hasApiKey ? (this.props.t ? this.props.t('settings.regenerate') : 'Regenerieren') : (this.props.t ? this.props.t('settings.generate') : 'Generieren')
)}
</Button>
</Box>
@@ -415,7 +417,7 @@ class SettingsTab extends Component {
open={this.state.copySnackbarOpen}
autoHideDuration={3000}
onClose={this.handleCloseSnackbar}
message="API-Schlüssel in Zwischenablage kopiert"
message={this.props.t ? this.props.t('settings.apiKeyCopied') : 'API-Schlüssel in Zwischenablage kopiert'}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
/>
</Box>
@@ -423,4 +425,4 @@ class SettingsTab extends Component {
}
}
export default SettingsTab;
export default withI18n()(SettingsTab);

View File

@@ -8,27 +8,205 @@ const config = {
siteName: "Growheads.de",
brandName: "GrowHeads",
currency: "EUR",
language: "de-DE",
language: "de-DE", // Will be updated dynamically based on i18n
country: "DE",
// Shop Descriptions
descriptions: {
short: "GrowHeads - Online-Shop für Cannanis-Samen, Stecklinge und Gartenbedarf",
long: "GrowHeads - Ihr Online-Shop für hochwertige Samen, Pflanzen und Gartenbedarf zur Cannabis Kultivierung. Entdecken Sie unser großes Sortiment an Saatgut, Pflanzen und Gartenzubehör für Ihren grünen Daumen."
// Multilingual configurations
languages: {
de: {
code: "de-DE",
name: "Deutsch",
shortName: "DE"
},
en: {
code: "en-US",
name: "English",
shortName: "EN"
},
es: {
code: "es-ES",
name: "Español",
shortName: "ES"
},
fr: {
code: "fr-FR",
name: "Français",
shortName: "FR"
},
it: {
code: "it-IT",
name: "Italiano",
shortName: "IT"
},
pl: {
code: "pl-PL",
name: "Polski",
shortName: "PL"
},
hu: {
code: "hu-HU",
name: "Magyar",
shortName: "HU"
},
sr: {
code: "sr-RS",
name: "Српски",
shortName: "SR"
},
bg: {
code: "bg-BG",
name: "Български",
shortName: "BG"
},
ru: {
code: "ru-RU",
name: "Русский",
shortName: "RU"
},
uk: {
code: "uk-UA",
name: "Українська",
shortName: "UK"
},
sk: {
code: "sk-SK",
name: "Slovenčina",
shortName: "SK"
},
sl: {
code: "sl-SI",
name: "Slovenščina",
shortName: "SI"
},
cs: {
code: "cs-CZ",
name: "Čeština",
shortName: "CS"
},
ro: {
code: "ro-RO",
name: "Română",
shortName: "RO"
},
hr: {
code: "hr-HR",
name: "Hrvatski",
shortName: "HR"
},
sv: {
code: "sv-SE",
name: "Svenska",
shortName: "SE"
},
tr: {
code: "tr-TR",
name: "Türkçe",
shortName: "TR"
},
el: {
code: "el-GR",
name: "Ελληνικά",
shortName: "GR"
},
ar: {
code: "ar-EG",
name: "العربية",
shortName: "EG"
},
zh: {
code: "zh-CN",
name: "中文",
shortName: "CN"
}
},
// Keywords
keywords: "Seeds, Steckling, Cannabis, Biobizz, Growheads",
// Shop Descriptions - Multilingual
descriptions: {
de: {
short: "GrowHeads - Online-Shop für Cannabis-Samen, Stecklinge und Gartenbedarf",
long: "GrowHeads - Ihr Online-Shop für hochwertige Samen, Pflanzen und Gartenbedarf zur Cannabis Kultivierung. Entdecken Sie unser großes Sortiment an Saatgut, Pflanzen und Gartenzubehör für Ihren grünen Daumen."
},
en: {
short: "GrowHeads - Online Shop for Cannabis Seeds, Cuttings and Garden Supplies",
long: "GrowHeads - Your online shop for high-quality seeds, plants and garden supplies for cannabis cultivation. Discover our large assortment of seeds, plants and garden accessories for your green thumb."
},
es: {
short: "GrowHeads - Tienda Online de Semillas de Cannabis, Esquejes y Suministros de Jardín",
long: "GrowHeads - Tu tienda online de semillas, plantas y suministros de jardín de alta calidad para el cultivo de cannabis. Descubre nuestro gran surtido de semillas, plantas y accesorios de jardín para tu pulgar verde."
},
fr: {
short: "GrowHeads - Boutique en ligne de Graines de Cannabis, Boutures et Fournitures de Jardinage",
long: "GrowHeads - Votre boutique en ligne pour des graines, plantes et fournitures de jardinage de haute qualité pour la culture du cannabis. Découvrez notre large assortiment de graines, plantes et accessoires de jardinage pour votre pouce vert."
},
it: {
short: "GrowHeads - Negozio Online di Semi di Cannabis, Talee e Forniture da Giardino",
long: "GrowHeads - Il tuo negozio online per semi, piante e forniture da giardino di alta qualità per la coltivazione di cannabis. Scopri il nostro vasto assortimento di semi, piante e accessori da giardino per il tuo pollice verde."
},
pl: {
short: "GrowHeads - Sklep Online z Nasionami Konopi, Sadzonkami i Artykułami Ogrodniczymi",
long: "GrowHeads - Twój sklep online z wysokiej jakości nasionami, roślinami i artykułami ogrodniczymi do uprawy konopi. Odkryj nasz duży asortyment nasion, roślin i akcesoriów ogrodniczych dla Twojego zielonego kciuka."
},
hu: {
short: "GrowHeads - Online Bolt Kannabisz Magokhoz, Dugványokhoz és Kerti Kellékekhez",
long: "GrowHeads - Az Ön online boltja minőségi magokhoz, növényekhez és kerti kellékekhez a kannabisz termesztéshez. Fedezze fel nagy választékunkat magokból, növényekből és kerti kiegészítőkből a zöld hüvelykujjához."
},
sr: {
short: "GrowHeads - Онлајн продавница за семена канабиса, резнице и вртларски прибор",
long: "GrowHeads - Ваша онлајн продавница за висококвалитетна семена, биљке и вртларски прибор за узгајање канабиса. Откријте наш велики асортиман семена, биљака и вртларских додатака за ваш зелени палац."
},
bg: {
short: "GrowHeads - Онлайн магазин за семена на канабис, резници и градински принадлежности",
long: "GrowHeads - Вашият онлайн магазин за висококачествени семена, растения и градински принадлежности за отглеждане на канабис. Открийте нашия голям асортимент от семена, растения и градински аксесоари за вашия зелен палец."
},
ru: {
short: "GrowHeads - Интернет-магазин семян каннабиса, черенков и садовых принадлежностей",
long: "GrowHeads - Ваш интернет-магазин высококачественных семян, растений и садовых принадлежностей для выращивания каннабиса. Откройте для себя наш большой ассортимент семян, растений и садовых аксессуаров для вашего зеленого пальца."
},
uk: {
short: "GrowHeads - Інтернет-магазин насіння канабісу, живців та садових приладдя",
long: "GrowHeads - Ваш інтернет-магазин високоякісного насіння, рослин та садових приладдя для вирощування канабісу. Відкрийте для себе наш великий асортимент насіння, рослин та садових аксесуарів для вашого зеленого пальця."
},
sk: {
short: "GrowHeads - Online obchod so semenami konopy, sadenicami a záhradnými potrebami",
long: "GrowHeads - Váš online obchod s vysoko kvalitnými semenami, rastlinami a záhradnými potrebami na pestovanie konopy. Objavte náš veľký sortiment semien, rastlín a záhradných doplnkov pre váš zelený palec."
},
cs: {
short: "GrowHeads - Online obchod se semeny konopí, sazenicemi a zahradními potřebami",
long: "GrowHeads - Váš online obchod s vysoce kvalitními semeny, rostlinami a zahradními potřebami pro pěstování konopí. Objevte náš velký sortiment semen, rostlin a zahradních doplňků pro váš zelený palec."
},
ro: {
short: "GrowHeads - Magazin Online de Semințe de Cannabis, Butași și Articole de Grădinărit",
long: "GrowHeads - Magazinul dumneavoastră online pentru semințe, plante și articole de grădinărit de înaltă calitate pentru cultivarea de cannabis. Descoperiți sortimentul nostru mare de semințe, plante și accesorii de grădinărit pentru degetul verde."
}
},
// Keywords - Multilingual
keywords: {
de: "Seeds, Steckling, Cannabis, Biobizz, Growheads",
en: "Seeds, Cuttings, Cannabis, Biobizz, Growheads",
es: "Semillas, Esquejes, Cannabis, Biobizz, Growheads",
fr: "Graines, Boutures, Cannabis, Biobizz, Growheads",
it: "Semi, Talee, Cannabis, Biobizz, Growheads",
pl: "Nasiona, Sadzonki, Konopie, Biobizz, Growheads",
hu: "Magok, Dugványok, Kannabisz, Biobizz, Growheads",
sr: "Семена, Резнице, Канабис, Biobizz, Growheads",
bg: "Семена, Резници, Канабис, Biobizz, Growheads",
ru: "Семена, Черенки, Каннабис, Biobizz, Growheads",
uk: "Насіння, Живці, Канабіс, Biobizz, Growheads",
sk: "Semená, Sadenky, Konope, Biobizz, Growheads",
cs: "Semena, Sazenice, Konopí, Biobizz, Growheads",
ro: "Semințe, Butași, Cannabis, Biobizz, Growheads"
},
// Shipping
shipping: {
defaultCost: "4.99 EUR",
defaultCost: "5.90 EUR",
defaultService: "Standard"
},
// Images
images: {
logo: "/assets/images/sh.png",
logo: "/assets/images/sh.avif",
placeholder: "/assets/images/nopicture.jpg"
},

View File

@@ -0,0 +1,31 @@
import React, { createContext, useState, useContext } from 'react';
const CategoryContext = createContext({
currentCategory: null,
setCurrentCategory: () => {}
});
export const useCategory = () => useContext(CategoryContext);
export const withCategory = (Component) => {
return (props) => {
const categoryContext = useCategory();
return <Component {...props} categoryContext={categoryContext} />;
};
};
export const CategoryContextProvider = ({ children }) => {
const [currentCategory, setCurrentCategory] = useState(null);
const setCurrentCategoryWithLog = (category) => {
console.log('CategoryContext: Setting current category to:', category);
setCurrentCategory(category);
};
return (
<CategoryContext.Provider value={{ currentCategory, setCurrentCategory: setCurrentCategoryWithLog }}>
{children}
</CategoryContext.Provider>
);
};

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