Compare commits

...

212 Commits

Author SHA1 Message Date
sebseb7
dbd5df28f8 feat: Wrap product carousel title in a React Router Link and add a ChevronRight icon. 2025-12-14 10:01:37 +01:00
sebseb7
57515bfb85 feat: Refine i18n content across multiple locales and improve LLM SEO data processing for catalog generation. 2025-12-14 09:47:51 +01:00
sebseb7
9df5642a6e refactor: reimplement category page display with a recursive tree structure and add configurator image. 2025-12-13 02:14:20 +01:00
sebseb7
a50dd086c3 feat: Include 'Kategorien' in Nginx location regex for HTML content. 2025-12-06 21:02:22 +01:00
sebseb7
e88370ff3e feat: add Categories page with refined layout and translation support 2025-12-06 14:29:33 +01:00
sebseb7
5d3e0832fe doc 2025-12-01 13:11:24 +01:00
sebseb7
3347ba2754 Add missing auth translations and update components to use i18n keys
- Added new translation keys to de/auth.js:
  - resetPassword section (title, button, success, invalidToken, error, emailSent, emailError)
  - errors section (fillAllFields, invalidEmail, passwordsNotMatch, passwordsNotMatchShort, enterEmail, loginFailed, registerFailed, googleLoginFailed, emailExists)
  - success section (registerComplete)
  - newPassword, backToHome keys

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

**Description:**
Deleted unnecessary inline comments and streamlined responsive navigation/content rendering logic. Maintained core functionality while improving code clarity and reducing visual noise in the component structure.
2025-07-27 13:53:40 +02:00
sebseb7
7c78c6d85c refactor: update category data fetching in Content component to utilize CategoryService directly and improve clarity in category management 2025-07-24 11:55:08 +02:00
sebseb7
c1810b18b3 refactor: implement CategoryService for category data management and update caching logic in prerender and renderer components 2025-07-24 10:46:10 +02:00
sebseb7
3a8f31c109 Merge branch 'live' of https://git.sebgreen.net/seb/reactShop into live 2025-07-24 10:45:40 +02:00
sebseb7
02ed8c5f9d upd 2025-07-24 10:45:27 +02:00
sebseb7
5662177175 refactor: streamline category ID management in CategoryList by replacing getLevel1CategoryId with setLevel1CategoryId and improving state handling 2025-07-24 10:44:11 +02:00
sebseb7
b9e00ca134 refactor: remove CarouselProvider from PrerenderHome component to streamline layout and improve code clarity 2025-07-24 07:07:02 +02:00
sebseb7
b207377a8e refactor: enhance category data management in CategoryList and CategoryService by integrating async-mutex for improved concurrency control and simplifying state handling 2025-07-24 07:04:54 +02:00
sebseb7
2f753a81a4 refactor: integrate CategoryService into SharedCarousel for improved category data management and enhance component structure 2025-07-24 06:23:37 +02:00
sebseb7
1aabd3ef1e refactor: implement lazy loading for LoginComponent in ButtonGroup to enhance performance and user experience 2025-07-23 11:21:36 +02:00
sebseb7
4879f68998 refactor: simplify category data fetching logic in Content component by removing redundant cache checks and improving clarity in data handling 2025-07-23 10:36:45 +02:00
sebseb7
31c302493a refactor: clean up logging and simplify cache checks in Content component to enhance clarity and maintainability of category data fetching 2025-07-23 10:33:28 +02:00
sebseb7
934f6abc92 refactor: remove socket.io method overrides in Content component to streamline category data fetching and improve code clarity 2025-07-23 10:29:21 +02:00
sebseb7
4dd1b2d227 refactor: update cache handling and logging in SharedCarousel component to prioritize prerendered cache and improve clarity in data fetching 2025-07-23 10:25:04 +02:00
sebseb7
f3e8395000 refactor: improve cache handling and logging in CategoryList component to prioritize prerendered cache and enhance data fetching clarity 2025-07-23 10:24:41 +02:00
sebseb7
95f303bc68 refactor: enhance logging and cache checks in SharedCarousel and CategoryList components to improve data fetching clarity and performance 2025-07-23 10:22:19 +02:00
sebseb7
226ca3e834 refactor: improve cache utilization and data fetching logic in SharedCarousel and CategoryList components for enhanced performance and maintainability 2025-07-23 10:20:35 +02:00
sebseb7
146daf8eb1 refactor: simplify cache management in SharedCarousel and CategoryList components to enhance data fetching efficiency and maintainability 2025-07-23 10:15:49 +02:00
sebseb7
e472e6bb77 refactor: enhance category data fetching logic in SharedCarousel and CategoryList components by simplifying cache checks and improving logging for better maintainability 2025-07-23 10:13:41 +02:00
sebseb7
a2b7a2509f refactor: streamline category data fetching in Content component by reducing logging and simplifying cache checks for improved readability 2025-07-23 10:08:23 +02:00
sebseb7
21ed40c4ce refactor: implement socket.io method overrides and enhance logging for productCache in Content component to improve category data handling 2025-07-23 10:00:46 +02:00
sebseb7
abf94eba86 refactor: add detailed logging for productCache checks in Content component to aid in debugging category data fetching 2025-07-23 09:54:58 +02:00
sebseb7
cd4d124e22 u 2025-07-23 09:50:37 +02:00
sebseb7
b5a78b33cb u 2025-07-23 09:46:54 +02:00
sebseb7
bd4c0a50f1 refactor: optimize category data fetching in Content component by utilizing cached category tree for improved performance and reduced socket queries 2025-07-23 09:45:34 +02:00
sebseb7
23dbdec432 refactor: update image rendering logic in MainPageLayout to load images conditionally based on opacity for improved performance 2025-07-23 09:38:41 +02:00
sebseb7
72010c410e refactor: disable font and image preloading in InlineCssPlugin for improved clarity and maintainability 2025-07-23 09:24:35 +02:00
sebseb7
5dc0280fc7 refactor: enhance InlineCssPlugin to preload JavaScript files alongside fonts and images, improving asset loading order and console logging 2025-07-23 09:22:58 +02:00
sebseb7
bfcc320e6d refactor: ensure safe socket listener management in ButtonGroup and AdminPage components to prevent errors when socketManager is not available 2025-07-23 09:13:09 +02:00
sebseb7
a653908624 refactor: simplify image preloading logic in InlineCssPlugin to focus on homepage and enhance console logging 2025-07-23 09:10:45 +02:00
sebseb7
1e8e6d7ac1 refactor: comment out unused critical image in InlineCssPlugin for improved clarity and maintainability 2025-07-23 09:01:24 +02:00
sebseb7
acdfc38b4a refactor: update image preloading logic in InlineCssPlugin to use output filename for page type detection and improve console logging 2025-07-23 08:58:14 +02:00
sebseb7
c906e0c936 refactor: enhance image preloading logic in InlineCssPlugin for better performance and clarity 2025-07-23 08:52:53 +02:00
sebseb7
cee69c9a31 refactor: remove SocketContext and related dependencies from OrdersTab and ProfilePage components for improved performance and code clarity 2025-07-23 08:46:35 +02:00
sebseb7
1c777f8daa feat: add console logging for image loading and product data fetching in Product and ProductDetailPage components for improved debugging 2025-07-23 08:41:26 +02:00
sebseb7
602324b1fe fix: clear product image URL from window object on component unmount to prevent memory leaks 2025-07-23 08:36:13 +02:00
sebseb7
d16e979771 feat: store product image URL in window object for improved accessibility in image handling 2025-07-23 08:35:08 +02:00
sebseb7
61faf654bc refactor: standardize socket communication by replacing socket prop usage with window.socketManager across multiple components for improved consistency and maintainability 2025-07-23 08:21:30 +02:00
sebseb7
4e6b63a6a4 refactor: replace socket prop usage with window.socketManager for consistent socket handling across components 2025-07-23 08:08:58 +02:00
sebseb7
9982527f35 refactor: remove socket context dependencies and streamline socket handling in components for improved performance and readability 2025-07-23 07:57:13 +02:00
sebseb7
bde001c39b refactor: clean up imports and remove unused socket context in Header component for improved readability 2025-07-23 07:37:33 +02:00
sebseb7
bbd1371eb2 refactor: optimize socket connection handling by removing polling transport and implementing lazy connection strategy in SocketManager for improved performance 2025-07-23 07:29:15 +02:00
sebseb7
d8f438c3f3 feat: implement SocketManager for websocket communication, creating a singleton instance and attaching it to the window object 2025-07-23 07:19:54 +02:00
sebseb7
9c3a4ee91b chore: remove accessibility utility functions from accessibilityUtils.js as they are no longer needed 2025-07-21 01:40:55 +02:00
sebseb7
bad176a6d1 feat: implement accessibility improvements by ensuring alt text is always present on image error events and initialize accessibility checking in App component 2025-07-21 01:39:50 +02:00
sebseb7
d70fac24ed refactor: update Typography components across multiple files to improve consistency in heading levels and styles 2025-07-21 01:29:03 +02:00
sebseb7
c4bd28ba92 feat: add aria-label attributes to Image and PhotoUpload components for improved accessibility, and enhance OrdersTab with localized aria-labels for better user experience 2025-07-21 01:24:55 +02:00
sebseb7
24b762b9d6 refactor: streamline image preloading logic in webpack config by defining critical images outside of conditional checks and improving preload count logging 2025-07-21 01:12:21 +02:00
sebseb7
464f159556 feat: enhance SocketProvider to support polling and websocket transports, improve error logging for development, and update image preloading logic in webpack config for better performance on main pages 2025-07-21 01:10:13 +02:00
sebseb7
0a787f9d25 feat: update global CSS handling in prerender to differentiate between production and development environments, ensuring proper font path management 2025-07-20 15:47:58 +02:00
sebseb7
5202ff6e3e feat: implement lazy loading for languages in i18n, enhance LanguageSwitcher to handle language changes asynchronously, and update available languages management 2025-07-20 15:44:50 +02:00
sebseb7
2b64719758 feat: optimize image loading and critical rendering path by adding image preloads in webpack config, updating image handling in multiple components, and ensuring alt attributes are set for accessibility 2025-07-20 15:19:16 +02:00
sebseb7
e0da7ed312 feat: add aria-label attributes to various components for improved accessibility 2025-07-20 15:12:09 +02:00
sebseb7
a68d912c99 feat: enhance image loading performance by adding fetchPriority and loading attributes in multiple components, and update MUI icons chunking in webpack configuration 2025-07-20 15:05:29 +02:00
sebseb7
d3998133e5 feat: update CopyAssetsPlugin to exclude fonts during asset copying 2025-07-20 14:47:57 +02:00
sebseb7
1fd6ed85b6 remove prerender after socket event 2025-07-20 14:33:03 +02:00
sebseb7
b2474a595c feat: add InlineCssPlugin to inline CSS assets in production builds 2025-07-20 14:13:39 +02:00
sebseb7
f748056568 fix 2025-07-20 12:45:09 +02:00
sebseb7
5cff3e2c2a style: update comment formatting for GitHub Issue Reporter FAB in App.js 2025-07-20 12:40:24 +02:00
sebseb7
8629dc5d87 refactor: remove combined CSS file writing and optimize page-specific CSS handling in prerender logic 2025-07-20 12:35:05 +02:00
sebseb7
275ee3bea6 feat: update layout in PrerenderProduct to match SPA design with invisible placeholders for SearchBar and ButtonGroup 2025-07-20 12:00:11 +02:00
sebseb7
3d136775e2 feat: enhance image loading and socket handling in Product and Images components, and update prerender logic in App and ProductDetailPage 2025-07-20 11:53:27 +02:00
sebseb7
92987a518b feat: implement product image saving in prerender-single-product and enhance image loading logic in Images component 2025-07-20 10:46:22 +02:00
sebseb7
bffb1fed27 feat: add prerender scripts for single product rendering and enhance layout in PrerenderProduct and Footer components 2025-07-20 10:40:22 +02:00
sebseb7
b8d8003ac3 style: update Footer component styles for improved layout and responsiveness 2025-07-20 10:14:52 +02:00
sebseb7
19cf475b0e feat: add prerender-single-product script for single product rendering with i18n support 2025-07-20 10:12:16 +02:00
sebseb7
1fb92e2df9 refactor: simplify image loading logic and enhance prerender handling in Images component 2025-07-20 01:36:56 +02:00
sebseb7
bdd50770be fix: enhance image loading logic to handle static paths when sockets are unavailable 2025-07-20 01:28:36 +02:00
sebseb7
ca98c304e5 fix 2025-07-20 01:22:53 +02:00
sebseb7
543c8c4f30 refactor: remove unused Images import from ProductDetailPage component 2025-07-20 01:20:16 +02:00
sebseb7
bfd1803c6f refactor: remove isPrerender prop and update ProductImage component to unify fullscreen handling in PrerenderProduct and ProductDetailPage 2025-07-20 01:16:44 +02:00
sebseb7
ea488982a7 feat: introduce ProductImage component to streamline image handling in PrerenderProduct and ProductDetailPage 2025-07-20 01:12:50 +02:00
sebseb7
a21efab9d2 fix 2025-07-20 01:11:41 +02:00
sebseb7
abe1bbfb67 fix 2025-07-20 01:06:27 +02:00
sebseb7
195ff493b8 fix 2025-07-20 01:01:45 +02:00
sebseb7
e80fedf9a9 refactor: adjust alignment in Footer component for better visual consistency 2025-07-20 00:57:47 +02:00
sebseb7
b8441b3ceb refactor: remove fixed height and redundant minHeight properties in PrerenderProduct for improved layout flexibility 2025-07-20 00:48:15 +02:00
sebseb7
3df20cbc6a refactor: replace Box with Container for improved layout consistency in PrerenderProduct and ProductDetailPage components 2025-07-20 00:34:46 +02:00
sebseb7
cc679e77a9 refactor: simplify layout and improve styling for product attributes and sections in PrerenderProduct component 2025-07-20 00:28:47 +02:00
sebseb7
5d14bef740 upd 2025-07-20 00:22:43 +02:00
sebseb7
27de1c3406 fix 2025-07-20 00:15:33 +02:00
sebseb7
8e6e020a1b feat: update product detail caching and component loading logic for improved SPA performance 2025-07-20 00:10:55 +02:00
sebseb7
055e49c957 feat: enhance product detail caching for SPA hydration and improve rendering logic 2025-07-20 00:04:24 +02:00
sebseb7
5a3865aa3c feat: implement XML validation for Google Shopping schema compliance and add validation script 2025-07-19 23:37:08 +02:00
sebseb7
fe93bfd7df upd 2025-07-19 23:18:21 +02:00
sebseb7
3afce32e3d fix: update titles and content boxes in MainPageLayout for improved section mapping and navigation 2025-07-19 23:13:22 +02:00
sebseb7
349b004627 fix: validate product weight before XML generation and update weight handling 2025-07-19 23:04:52 +02:00
sebseb7
f2ee641bfd fix: update product meta tag generation and clean up footer comments 2025-07-19 22:35:52 +02:00
sebseb7
2774c6924f fix: add missing weight and description logs 2025-07-19 22:03:43 +02:00
sebseb7
f93cde5131 Enhance XML generation for products: Added logging for products missing weight and those with insufficient descriptions. Implemented checks to track and log these products, ensuring better data quality for SEO. Created log files for missing data, improving maintainability and oversight of product information. 2025-07-19 11:40:44 +02:00
sebseb7
dfb4f3e189 Refactor PrerenderProduct layout: Introduced a back button for navigation, improved styling with Box components, and ensured consistent heights for product details and price sections. Enhanced product display with fixed height placeholders and updated availability messaging for better user experience. 2025-07-19 11:30:37 +02:00
sebseb7
5fb3e10598 Enhance product display in PrerenderProduct: Added a utility function to clean product names, improved layout with Box components for better styling, and formatted price display with tax. Updated product details section to include manufacturer, weight, and availability status, ensuring a more informative and visually appealing product presentation. 2025-07-19 11:23:31 +02:00
sebseb7
b602444066 Skip out-of-stock products in XML generation: Added logic to exclude products that are not available from the generated XML, improving the accuracy of product listings for SEO purposes. 2025-07-19 06:25:57 +02:00
sebseb7
7eb70abb22 Refactor star graphics in MainPageLayout: Replaced div elements with Box components for improved styling and layout control. Adjusted positioning and added drop-shadow effects for enhanced visual appeal. Updated display properties to ensure responsiveness across different screen sizes. 2025-07-19 06:19:00 +02:00
sebseb7
65ffc1542f Refine language-specific category fallback in SharedCarousel: Updated the fallback logic to only use the old cache format for German language requests, preventing incorrect category displays for English users. Enhanced language state management to ensure synchronization with i18n initialization. 2025-07-18 17:20:51 +02:00
sebseb7
8bc80c872d Enhance GTIN validation in product XML generation: Improved the GTIN validation logic in feeds.cjs to include checksum verification, ensuring only products with valid GTINs are processed. This update enhances data integrity for SEO purposes. 2025-07-18 16:09:37 +02:00
sebseb7
85504c725f Add demo mode functionality to LanguageProvider: Introduced demo mode to automatically cycle through available languages every 5 seconds. Updated state management to include demo mode and current language index, and added methods to start and stop the demo. Enhanced language change handling to stop demo mode when a user manually selects a language. 2025-07-18 15:44:00 +02:00
sebseb7
04e97c2522 Add CSS animations for rotating stars in MainPageLayout: Implemented new animations for star graphics to enhance visual appeal. Updated SharedCarousel to support dynamic language changes and improved category fetching logic. Enhanced localization files with new text for indoor season prompts in multiple languages. 2025-07-18 15:26:11 +02:00
sebseb7
93887ce397 Add multi-pointed star feature to home page layout: Implemented a hoverable star graphic in the seeds box, enhancing visual engagement. Updated localization files to include new text for user prompts in multiple languages. 2025-07-18 14:55:50 +02:00
sebseb7
33fadc0279 Update excluded terms in product XML generation: Added 'marihuana' to the list of excluded terms in product titles within feeds.cjs, enhancing content filtering for SEO purposes. 2025-07-18 13:18:08 +02:00
sebseb7
5c2b4172da Enhance GTIN validation in product XML generation: Updated logic in feeds.cjs to skip products with invalid GTIN formats, ensuring only valid products are included in the generated XML. This improves data integrity for SEO purposes. 2025-07-18 13:13:37 +02:00
sebseb7
c663e902ea Implement product filtering based on excluded terms: Added logic to skip products with specific terms in their title or description during XML generation in feeds.cjs, enhancing content management for SEO purposes. 2025-07-18 12:49:54 +02:00
sebseb7
aa82e8d1d2 Update categoryList socket emissions to include language and translation request: Modified the socket.emit calls in SharedCarousel, Sitemap, and prerender.cjs to support language specification and translation requests. Added JTL language mappings in translate-i18n.js for improved localization. 2025-07-18 12:36:39 +02:00
sebseb7
67f0126343 Enhance photo upload functionality in ArticleQuestionForm and ArticleRatingForm: Added reset method to PhotoUpload component and integrated it into both forms to clear uploaded files upon submission. Improved user experience by ensuring the photo upload state resets after form submission. 2025-07-18 12:13:01 +02:00
sebseb7
47a882b667 Add article interaction forms: Implemented ArticleAvailabilityForm, ArticleQuestionForm, and ArticleRatingForm components for user inquiries, ratings, and availability requests. Integrated photo upload functionality and enhanced user experience with collapsible sections in ProductDetailPage for better interaction with product details. 2025-07-18 11:56:37 +02:00
sebseb7
e43b894bfc Filter out delivery items in OrdersTab quantity calculation: Updated the item quantity calculation to exclude specific delivery items, improving accuracy in order summaries. 2025-07-18 06:58:25 +02:00
sebseb7
c9477e53b6 Enhance localization in CategoryList component: Updated category fetching logic to support dynamic language changes and improved cache handling for category data based on the current language. Adjusted delivery class display in CartItem for better user experience. 2025-07-18 05:08:57 +02:00
693 changed files with 21311 additions and 7563 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```

62
.gitignore vendored
View File

@@ -1,63 +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
taxonomy-with-ids.de-DE*
# Local development notes
dev-notes.md
dev-notes.local.md
/logs

47
.vscode/launch.json vendored
View File

@@ -16,7 +16,8 @@
"skipFiles": [
"<node_internals>/**"
]
},{
},
{
"name": "Start",
"type": "node-terminal",
"request": "launch",
@@ -28,6 +29,50 @@
"skipFiles": [
"<node_internals>/**"
]
},
{
"type": "chrome",
"request": "launch",
"name": "Launch Chrome - Debug React App",
"url": "https://dev.seedheads.de",
"webRoot": "${workspaceFolder}/src",
"sourceMapPathOverrides": {
"webpack://reactshop/./src/*": "${webRoot}/*",
"webpack://reactshop/src/*": "${webRoot}/*",
"webpack:///src/*": "${webRoot}/*",
"webpack:///./src/*": "${webRoot}/*",
"webpack:///./*": "${workspaceFolder}/*",
"webpack:///./~/*": "${workspaceFolder}/node_modules/*",
"webpack://*": "${workspaceFolder}/*"
},
"smartStep": true,
"skipFiles": [
"<node_internals>/**",
"${workspaceFolder}/node_modules/**",
"${workspaceFolder}/dist/**"
]
},
{
"type": "chrome",
"request": "attach",
"name": "Attach to Chrome - Debug React App",
"port": 9222,
"webRoot": "${workspaceFolder}/src",
"sourceMapPathOverrides": {
"webpack://reactshop/./src/*": "${webRoot}/*",
"webpack://reactshop/src/*": "${webRoot}/*",
"webpack:///src/*": "${webRoot}/*",
"webpack:///./src/*": "${webRoot}/*",
"webpack:///./*": "${workspaceFolder}/*",
"webpack:///./~/*": "${workspaceFolder}/node_modules/*",
"webpack://*": "${workspaceFolder}/*"
},
"smartStep": true,
"skipFiles": [
"<node_internals>/**",
"${workspaceFolder}/node_modules/**",
"${workspaceFolder}/dist/**"
]
}
]
}

View File

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

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

114
package-lock.json generated
View File

@@ -15,6 +15,7 @@
"@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",
@@ -26,6 +27,7 @@
"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"
},
@@ -58,7 +60,9 @@
"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"
}
},
"node_modules/@ampproject/remapping": {
@@ -4198,6 +4202,15 @@
"node": ">= 0.4"
}
},
"node_modules/async-mutex": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz",
"integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -4541,9 +4554,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001727",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz",
"integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==",
"version": "1.0.30001757",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz",
"integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==",
"dev": true,
"funding": [
{
@@ -5253,6 +5266,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/default-browser": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz",
@@ -8874,7 +8896,6 @@
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true,
"funding": [
{
"type": "github",
@@ -9446,6 +9467,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parse-srcset": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
"license": "MIT"
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
@@ -9654,7 +9681,6 @@
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -10543,6 +10569,60 @@
"dev": true,
"license": "MIT"
},
"node_modules/sanitize-html": {
"version": "2.17.0",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.0.tgz",
"integrity": "sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==",
"license": "MIT",
"dependencies": {
"deepmerge": "^4.2.2",
"escape-string-regexp": "^4.0.0",
"htmlparser2": "^8.0.0",
"is-plain-object": "^5.0.0",
"parse-srcset": "^1.0.2",
"postcss": "^8.3.11"
}
},
"node_modules/sanitize-html/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/sanitize-html/node_modules/htmlparser2": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1",
"entities": "^4.4.0"
}
},
"node_modules/sanitize-html/node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
@@ -11215,7 +11295,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -11827,7 +11906,6 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"devOptional": true,
"license": "0BSD"
},
"node_modules/type-check": {
@@ -12747,6 +12825,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/xmldom": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.6.0.tgz",
"integrity": "sha512-iAcin401y58LckRZ0TkI4k0VSM1Qg0KGSc3i8rU+xrxe19A/BN1zHyVSJY7uoutVlaTSzYyk/v5AmkewAP7jtg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
@@ -12755,6 +12843,16 @@
"node": ">=0.4.0"
}
},
"node_modules/xpath": {
"version": "0.0.34",
"resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.34.tgz",
"integrity": "sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.6.0"
}
},
"node_modules/xtend": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-2.1.2.tgz",

View File

@@ -7,17 +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",
"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"
"translate:others": "node translate-i18n.js --skip-english",
"validate:products": "node scripts/validate-products-xml.cjs"
},
"keywords": [],
"author": "",
@@ -30,6 +33,7 @@
"@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",
@@ -41,6 +45,7 @@
"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"
},
@@ -73,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");
@@ -103,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,
@@ -119,6 +136,7 @@ const {
generateLlmsTxt,
generateCategoryLlmsTxt,
generateAllCategoryLlmsPages,
generateCategoryProductList,
} = require("./prerender/seo.cjs");
const {
fetchCategoryProducts,
@@ -141,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;
@@ -149,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,
});
@@ -171,7 +190,7 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback,
try {
const productDetails = await fetchProductDetails(workerSocket, productSeoName);
const actualSeoName = productDetails.product.seoName || productSeoName;
const productComponent = React.createElement(PrerenderProduct, {
productData: productDetails,
@@ -187,7 +206,7 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback,
}, shopConfig.baseUrl, shopConfig);
// Get category info from categoryMap if available
const categoryInfo = productDetails.product.categoryId ? categoryMap[productDetails.product.categoryId] : null;
const jsonLdScript = generateProductJsonLd({
...productDetails.product,
seoName: actualSeoName,
@@ -202,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) {
@@ -215,9 +235,9 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback,
success,
workerId
};
results.push(result);
// Call progress callback if provided
if (progressCallback) {
progressCallback(result);
@@ -233,14 +253,14 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback,
error: error.message,
workerId
};
results.push(result);
// Call progress callback if provided
if (progressCallback) {
progressCallback(result);
}
setTimeout(processNextProduct, 25);
}
};
@@ -272,16 +292,16 @@ const renderProductsInParallel = async (allProductsArray, maxWorkers, totalProdu
const barLength = 30;
const filledLength = Math.round((barLength * current) / total);
const bar = '█'.repeat(filledLength) + '░'.repeat(barLength - filledLength);
// @note Single line progress update to prevent flickering
const truncatedName = productName ? ` - ${productName.substring(0, 25)}${productName.length > 25 ? '...' : ''}` : '';
// Build worker stats on one line
let workerStats = '';
for (let i = 0; i < Math.min(maxWorkers, 8); i++) { // Limit to 8 workers to fit on screen
workerStats += `W${i + 1}:${workerCounts[i]}/${workerSuccess[i]} `;
}
// Single line update without complex cursor movements
process.stdout.write(`\r [${bar}] ${percentage}% (${current}/${total})${truncatedName}\n ${workerStats}${current < total ? '\x1b[1A' : '\n'}`);
};
@@ -289,26 +309,26 @@ const renderProductsInParallel = async (allProductsArray, maxWorkers, totalProdu
// Split products among workers
const productsPerWorker = Math.ceil(allProductsArray.length / maxWorkers);
const workerPromises = [];
// Initial progress bar
updateProgressBar(0, totalProducts);
for (let i = 0; i < maxWorkers; i++) {
const start = i * productsPerWorker;
const end = Math.min(start + productsPerWorker, allProductsArray.length);
const productsForWorker = allProductsArray.slice(start, end);
if (productsForWorker.length > 0) {
const promise = renderProductWorker(productsForWorker, i + 1, (result) => {
// Progress callback - called each time a product is completed
completedProducts++;
progressResults.push(result);
lastProductName = result.productName;
// Update per-worker counters
const workerIndex = result.workerId - 1; // Convert to 0-based index
workerCounts[workerIndex]++;
if (result.success) {
totalSuccessCount++;
workerSuccess[workerIndex]++;
@@ -316,11 +336,11 @@ const renderProductsInParallel = async (allProductsArray, maxWorkers, totalProdu
// Don't log errors immediately to avoid interfering with progress bar
// Errors will be shown after completion
}
// Update progress bar with worker stats
updateProgressBar(completedProducts, totalProducts, lastProductName);
}, categoryMap);
workerPromises.push(promise);
}
}
@@ -328,10 +348,10 @@ const renderProductsInParallel = async (allProductsArray, maxWorkers, totalProdu
try {
// Wait for all workers to complete
await Promise.all(workerPromises);
// Ensure final progress update
updateProgressBar(totalProducts, totalProducts, lastProductName);
// Show any errors that occurred
const errorResults = progressResults.filter(r => !r.success && r.error);
if (errorResults.length > 0) {
@@ -340,7 +360,7 @@ const renderProductsInParallel = async (allProductsArray, maxWorkers, totalProdu
console.log(` - ${result.productSeoName}: ${result.error}`);
});
}
return totalSuccessCount;
} catch (error) {
console.error('Error in parallel rendering:', error);
@@ -350,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
@@ -403,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...");
@@ -438,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" },
{
@@ -507,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(
@@ -525,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`
);
@@ -610,7 +651,7 @@ const renderApp = async (categoryData, socket) => {
const totalProducts = allProducts.size;
const numCPUs = os.cpus().length;
const maxWorkers = Math.min(numCPUs, totalProducts, 8); // Cap at 8 workers to avoid overwhelming the server
// Create category map for breadcrumbs
const categoryMap = {};
allCategories.forEach(category => {
@@ -619,11 +660,11 @@ const renderApp = async (categoryData, socket) => {
seoName: category.seoName
};
});
console.log(
`\n📦 Rendering ${totalProducts} individual product pages using ${maxWorkers} parallel workers...`
);
const productPagesRendered = await renderProductsInParallel(
Array.from(allProducts),
maxWorkers,
@@ -641,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...");
@@ -676,21 +716,21 @@ const renderApp = async (categoryData, socket) => {
// Generate products.xml (Google Shopping feed) in parallel to sitemap.xml
if (allProductsData.length > 0) {
console.log("\n🛒 Generating products.xml (Google Shopping feed)...");
try {
const productsXml = generateProductsXml(allProductsData, shopConfig.baseUrl, shopConfig);
const productsXmlPath = path.resolve(__dirname, config.outputDir, "products.xml");
// Write with explicit UTF-8 encoding
fs.writeFileSync(productsXmlPath, productsXml, { encoding: 'utf8' });
console.log(`✅ products.xml generated: ${productsXmlPath}`);
console.log(` - Products included: ${allProductsData.length}`);
console.log(` - Format: Google Shopping RSS 2.0 feed`);
console.log(` - Encoding: UTF-8`);
console.log(` - Includes: title, description, price, availability, images`);
// Verify the file is valid UTF-8
try {
const verification = fs.readFileSync(productsXmlPath, 'utf8');
@@ -698,7 +738,27 @@ const renderApp = async (categoryData, socket) => {
} catch (verifyError) {
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");
@@ -709,18 +769,18 @@ const renderApp = async (categoryData, socket) => {
// Generate llms.txt (LLM-friendly markdown sitemap) and category-specific files
console.log("\n🤖 Generating LLM sitemap files...");
try {
// Generate main llms.txt overview file
const llmsTxt = generateLlmsTxt(allCategories, allProductsData, shopConfig.baseUrl, shopConfig);
const llmsTxtPath = path.resolve(__dirname, config.outputDir, "llms.txt");
fs.writeFileSync(llmsTxtPath, llmsTxt, { encoding: 'utf8' });
console.log(`✅ Main llms.txt generated: ${llmsTxtPath}`);
console.log(` - Static pages: 8 pages`);
console.log(` - Categories: ${allCategories.length} with links to detailed files`);
console.log(` - File size: ${Math.round(llmsTxt.length / 1024)}KB`);
// Group products by category for category-specific files
const productsByCategory = {};
allProductsData.forEach((product) => {
@@ -730,47 +790,53 @@ const renderApp = async (categoryData, socket) => {
}
productsByCategory[categoryId].push(product);
});
// Generate category-specific LLM files with pagination
let categoryFilesGenerated = 0;
let totalCategoryProducts = 0;
let totalPaginatedFiles = 0;
for (const category of allCategories) {
if (category.seoName) {
const categoryProducts = productsByCategory[category.id] || [];
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
// Generate all paginated files for this category
const categoryPages = generateAllCategoryLlmsPages(category, categoryProducts, shopConfig.baseUrl, shopConfig);
// Write each paginated file
for (const page of categoryPages) {
const pagePath = path.resolve(__dirname, config.outputDir, page.fileName);
fs.writeFileSync(pagePath, page.content, { encoding: 'utf8' });
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;
}
}
console.log(` 📄 Total paginated files generated: ${totalPaginatedFiles}`);
console.log(` 📦 Total products across all categories: ${totalCategoryProducts}`);
try {
const verification = fs.readFileSync(llmsTxtPath, 'utf8');
console.log(` - File verification: ✅ All files valid UTF-8`);
} catch (verifyError) {
console.log(` - File verification: ⚠️ ${verifyError.message}`);
}
} catch (error) {
console.error(`❌ Error generating LLM sitemap files: ${error.message}`);
console.log("\n⚠ Skipping LLM sitemap generation due to errors");
@@ -790,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');
// Fix relative font paths for prerendered HTML (remove ../public to make them relative to public root)
globalCss = globalCss.replace(/url\('\.\.\/public/g, "url('");
// 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
@@ -177,28 +158,52 @@ const renderPage = (
const prerenderFallbackScript = `
<script>
// Save prerendered content to window object for SocketProvider fallback
window.__PRERENDER_FALLBACK__ = {
window.__PRERENDER_FALLBACK__ = {
path: '${location}',
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

@@ -104,6 +104,9 @@ const determineUnitPricingData = (product) => {
return result;
};
const fs = require('fs');
const path = require('path');
const generateProductsXml = (allProductsData = [], baseUrl, config) => {
const currentDate = new Date().toISOString();
@@ -119,6 +122,7 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
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: "4082", // Headshop Rauchzubehör
@@ -126,8 +130,13 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
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: "5631", // Headshop > Wiegen & Verpacken Aufbewahrung/Zubehör
@@ -137,6 +146,7 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
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: "3006", // Lampen Lampen (Beleuchtung)
@@ -223,7 +233,6 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
387: "543541", // Zubehör > Anbauzubehör > Literatur Bücher
// General categories
705: "2802", // Grow-Sets > Set-Konfigurator (ebenfalls Pflanzen-Anbausets)
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
@@ -246,9 +255,9 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
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>de-DE</language>`;
@@ -297,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;
}
@@ -313,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";
@@ -342,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;
}
@@ -350,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)
@@ -363,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}`
@@ -371,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)}`;
@@ -386,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>
@@ -412,11 +616,11 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
<g:gtin>${gtin}</g:gtin>`;
}
// Add weight if available
if (product.weight && !isNaN(product.weight)) {
productsXml += `
<g:shipping_weight>${parseFloat(product.weight).toFixed(2)} g</g:shipping_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>${weightInGrams.toFixed(2)} g</g:shipping_weight>`;
// Add unit pricing data (required by German law for many products)
const unitPricingData = determineUnitPricingData(product);
@@ -437,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'
});
}
});
@@ -444,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

@@ -55,29 +55,29 @@ GrowHeads.de is a German online shop and local store in Dresden specializing in
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
const productsPerPage = 50;
const totalPages = Math.ceil(productCount / productsPerPage);
llmsTxt += `#### ${category.name} (${productCount} products)`;
if (totalPages > 1) {
llmsTxt += `
- **Product Catalog**: ${totalPages} pages available
- **Page 1**: ${baseUrl}/llms-${categorySlug}-page-1.txt (Products 1-${Math.min(productsPerPage, productCount)})`;
if (totalPages > 2) {
llmsTxt += `
- **Page 2**: ${baseUrl}/llms-${categorySlug}-page-2.txt (Products ${productsPerPage + 1}-${Math.min(productsPerPage * 2, productCount)})`;
}
if (totalPages > 3) {
llmsTxt += `
- **...**: Additional pages available`;
}
if (totalPages > 2) {
llmsTxt += `
- **Page ${totalPages}**: ${baseUrl}/llms-${categorySlug}-page-${totalPages}.txt (Products ${((totalPages - 1) * productsPerPage) + 1}-${productCount})`;
}
llmsTxt += `
- **Access Pattern**: Replace "page-X" with desired page number (1-${totalPages})`;
} else if (productCount > 0) {
@@ -87,7 +87,7 @@ GrowHeads.de is a German online shop and local store in Dresden specializing in
llmsTxt += `
- **Product Catalog**: No products available`;
}
llmsTxt += `
`;
@@ -106,7 +106,7 @@ GrowHeads.de is a German online shop and local store in Dresden specializing in
const generateCategoryLlmsTxt = (category, categoryProducts = [], baseUrl, config, pageNumber = 1, productsPerPage = 50) => {
const currentDate = new Date().toISOString().split("T")[0]; // YYYY-MM-DD format
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
// Calculate pagination
const totalProducts = categoryProducts.length;
const totalPages = Math.ceil(totalProducts / productsPerPage);
@@ -140,28 +140,28 @@ This file contains products ${startIndex + 1}-${endIndex} of ${totalProducts} in
**How to access other pages in this category:**
`;
if (pageNumber > 1) {
categoryLlmsTxt += `- **Previous Page**: ${baseUrl}/llms-${categorySlug}-page-${pageNumber - 1}.txt
`;
}
if (pageNumber < totalPages) {
categoryLlmsTxt += `- **Next Page**: ${baseUrl}/llms-${categorySlug}-page-${pageNumber + 1}.txt
`;
}
categoryLlmsTxt += `- **First Page**: ${baseUrl}/llms-${categorySlug}-page-1.txt
- **Last Page**: ${baseUrl}/llms-${categorySlug}-page-${totalPages}.txt
**All pages in this category:**
`;
for (let i = 1; i <= totalPages; i++) {
categoryLlmsTxt += `- **Page ${i}**: ${baseUrl}/llms-${categorySlug}-page-${i}.txt (Products ${((i-1) * productsPerPage) + 1}-${Math.min(i * productsPerPage, totalProducts)})
categoryLlmsTxt += `- **Page ${i}**: ${baseUrl}/llms-${categorySlug}-page-${i}.txt (Products ${((i - 1) * productsPerPage) + 1}-${Math.min(i * productsPerPage, totalProducts)})
`;
}
categoryLlmsTxt += `
`;
@@ -173,17 +173,23 @@ This file contains products ${startIndex + 1}-${endIndex} of ${totalProducts} in
// Clean description for markdown (remove HTML tags and limit length)
const cleanDescription = product.description
? product.description
.replace(/<[^>]*>/g, "")
.replace(/\n/g, " ")
.trim()
.substring(0, 300)
.replace(/<[^>]*>/g, "")
.replace(/\n/g, " ")
.trim()
.substring(0, 300)
: "";
const globalIndex = startIndex + index + 1;
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'}`;
@@ -228,13 +234,13 @@ This category currently contains no products.
if (pageNumber > 1) {
categoryLlmsTxt += `← [Previous Page](${baseUrl}/llms-${categorySlug}-page-${pageNumber - 1}.txt) | `;
}
categoryLlmsTxt += `[Category Overview](${baseUrl}/llms-${categorySlug}-page-1.txt)`;
if (pageNumber < totalPages) {
categoryLlmsTxt += ` | [Next Page](${baseUrl}/llms-${categorySlug}-page-${pageNumber + 1}.txt) →`;
}
categoryLlmsTxt += `
`;
@@ -248,17 +254,52 @@ 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;
const totalPages = Math.ceil(totalProducts / productsPerPage);
const pages = [];
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber++) {
const pageContent = generateCategoryLlmsTxt(category, categoryProducts, baseUrl, config, pageNumber, productsPerPage);
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
const fileName = `llms-${categorySlug}-page-${pageNumber}.txt`;
pages.push({
fileName,
content: pageContent,
@@ -266,7 +307,7 @@ const generateAllCategoryLlmsPages = (category, categoryProducts = [], baseUrl,
totalPages
});
}
return pages;
};
@@ -274,4 +315,5 @@ module.exports = {
generateLlmsTxt,
generateCategoryLlmsTxt,
generateAllCategoryLlmsPages,
generateCategoryProductList,
};

View File

@@ -1,19 +1,26 @@
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
? product.description
const cleanDescription = product.kurzBeschreibung
? product.kurzBeschreibung
.replace(/<[^>]*>/g, "")
.replace(/\n/g, " ")
.substring(0, 160)
: `${product.name} - Art.-Nr.: ${product.articleNumber}`;
: product.description
? product.description
.replace(/<[^>]*>/g, "")
.replace(/\n/g, " ")
.substring(0, 160)
: `${product.name} - Art.-Nr.: ${product.articleNumber}`;
return `
<!-- SEO Meta Tags -->
@@ -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
});
}

105
process_llms_cat.cjs Normal file
View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 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: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

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

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

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,11 +15,12 @@ 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";
@@ -32,11 +34,11 @@ import Header from "./components/Header.js";
import Footer from "./components/Footer.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"));
@@ -48,6 +50,7 @@ const Datenschutz = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/D
const AGB = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/AGB.js"));
//const NotFound404 = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/NotFound404.js")); <Route path="/404" element={<NotFound404 />} />
const Sitemap = lazy(() => import(/* webpackChunkName: "sitemap" */ "./pages/Sitemap.js"));
const CategoriesPage = lazy(() => import(/* webpackChunkName: "categories" */ "./pages/CategoriesPage.js"));
const 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"));
@@ -63,44 +66,32 @@ const ThcTestPage = lazy(() => import(/* webpackChunkName: "thc-test" */ "./page
// 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();
@@ -128,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();
@@ -156,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={{
@@ -196,21 +226,30 @@ 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={
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<CircularProgress color="primary" />
</Box>
// 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",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<CircularProgress color="primary" />
</Box>
)
}>
<CarouselProvider>
<Routes>
@@ -222,19 +261,19 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
{/* 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 />} />
@@ -242,22 +281,23 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
{/* 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="/sitemap" element={<Sitemap />} />
<Route path="/Kategorien" element={<CategoriesPage />} />
<Route path="/impressum" element={<Impressum />} />
<Route
path="/batteriegesetzhinweise"
@@ -266,7 +306,7 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
<Route path="/widerrufsrecht" element={<Widerrufsrecht />} />
{/* Grow Tent Configurator */}
<Route path="/Konfigurator" element={<GrowTentKonfigurator socket={socket} socketB={socketB} />} />
<Route path="/Konfigurator" element={<GrowTentKonfigurator />} />
{/* Separate pages that are truly different */}
<Route path="/presseverleih" element={<PresseverleihPage />} />
@@ -280,11 +320,20 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
</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>
)}
@@ -306,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"
@@ -321,7 +370,7 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
>
<BugReportIcon sx={{ fontSize: "1.2rem" }} />
</Fab>
</Tooltip>
</Tooltip>*/}
{/* Development-only Theme Customizer FAB */}
{isDevelopment && (
@@ -342,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)}
@@ -378,27 +456,16 @@ const App = () => {
return (
<LanguageProvider i18n={i18n}>
<ThemeProvider theme={dynamicTheme}>
<CssBaseline />
<SocketProvider
url={config.apiBaseUrl}
fallback={
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100vh",
}}
>
<CircularProgress color="primary" />
</Box>
}
>
<AppContent
currentTheme={currentTheme}
onThemeChange={handleThemeChange}
/>
</SocketProvider>
<ProductContextProvider>
<CategoryContextProvider>
<CssBaseline />
<AppContent
currentTheme={currentTheme}
dynamicTheme={dynamicTheme}
onThemeChange={handleThemeChange}
/>
</CategoryContextProvider>
</ProductContextProvider>
</ThemeProvider>
</LanguageProvider>
);

View File

@@ -44,7 +44,7 @@ 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={<MainPageLayout />} />

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,14 +1,13 @@
const React = require('react');
const {
Box,
AppBar,
Toolbar,
Container
} = require('@mui/material');
const Footer = require('./components/Footer.js').default;
const { Logo, CategoryList } = require('./components/header/index.js');
const MainPageLayout = require('./components/MainPageLayout.js').default;
const { CarouselProvider } = require('./contexts/CarouselContext.js');
import React from 'react';
import {
Box,
AppBar,
Toolbar,
Container
} from '@mui/material';
import Footer from './components/Footer.js';
import { Logo, CategoryList } from './components/header/index.js';
class PrerenderHome extends React.Component {
render() {
@@ -29,45 +28,103 @@ 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,
{
sx: {
display: 'flex',
alignItems: 'center',
{
sx: {
display: 'flex',
alignItems: 'center',
width: '100%',
flexDirection: { xs: 'column', sm: 'row' }
}
},
React.createElement(
Box,
{
sx: {
display: 'flex',
alignItems: 'center',
{
sx: {
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(CarouselProvider, null, React.createElement(MainPageLayout))
),
React.createElement(Footer)
);
}
}
module.exports = { default: PrerenderHome };
export default PrerenderHome;

View File

@@ -1,13 +1,13 @@
const React = require('react');
const {
Box,
AppBar,
Toolbar,
Container
} = require('@mui/material');
const Footer = require('./components/Footer.js').default;
const { Logo } = require('./components/header/index.js');
const NotFound404 = require('./pages/NotFound404.js').default;
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() {
@@ -89,4 +89,4 @@ class PrerenderNotFound extends React.Component {
}
}
module.exports = { default: PrerenderNotFound };
export default PrerenderNotFound;

View File

@@ -1,18 +1,25 @@
const React = require('react');
const {
Container,
Typography,
Card,
CardMedia,
Grid,
import React from 'react';
import {
Container,
Typography,
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,
@@ -48,149 +63,509 @@ class PrerenderProduct extends React.Component {
bgcolor: 'background.default'
}
},
React.createElement(
React.createElement(
AppBar,
{ position: 'sticky', color: 'primary', elevation: 0, sx: { zIndex: 1100 } },
React.createElement(
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(
Container,
{ maxWidth: 'lg', sx: { py: 4, flexGrow: 1 } },
Box,
{ sx: { flexGrow: 1 } },
React.createElement(
Grid,
{ container: true, spacing: 4 },
// Product Image
Container,
{
maxWidth: "lg",
sx: {
p: { xs: 2, md: 2 },
pb: { xs: 4, md: 8 },
flexGrow: 1
}
},
// Back button (breadcrumbs section)
React.createElement(
Grid,
{ item: true, xs: 12, md: 6 },
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(
Card,
{ sx: { height: '100%' } },
React.createElement(
CardMedia,
{
component: 'img',
height: '400',
image: mainImage,
alt: product.name,
sx: { objectFit: 'contain', p: 2 }
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'
)
)
)
),
// Product Details
React.createElement(
Grid,
{ item: true, xs: 12, md: 6 },
React.createElement(
Stack,
{ spacing: 3 },
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(
Typography,
{ variant: 'h3', component: 'h1', gutterBottom: true },
product.name
),
React.createElement(
Typography,
{ variant: 'h6', color: 'text.secondary' },
(this.props.t ? this.props.t('product.articleNumber') : 'Artikelnummer')+': '+product.articleNumber+' '+(product.gtin ? ` | GTIN: ${product.gtin}` : "")
ProductImage,
{
product: product,
socket: null,
socketB: null,
fullscreenOpen: false,
onOpenFullscreen: null,
onCloseFullscreen: null
}
),
// Product Details Section
React.createElement(
Box,
{ sx: { mt: 1 } },
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.`
),
React.createElement(
Typography,
{
variant: 'body1',
color: product.available ? 'success.main' : 'error.main',
fontWeight: 'medium',
sx: { mt: 1 }
},
product.available ? '✅ Verfügbar' : '❌ Nicht verfügbar'
)
),
product.description && React.createElement(
Box,
{ sx: { mt: 2 } },
React.createElement(
Typography,
{ variant: 'h6', gutterBottom: true },
'Beschreibung'
),
React.createElement(
'div',
{
dangerouslySetInnerHTML: { __html: product.description },
style: {
fontFamily: '"Roboto","Helvetica","Arial",sans-serif',
fontSize: '1rem',
lineHeight: '1.5',
color: '#33691E'
}
{
sx: {
flex: "1 1 60%",
p: { xs: 2, md: 4 },
display: "flex",
flexDirection: "column",
}
)
),
// Product specifications
React.createElement(
Box,
{ sx: { mt: 2 } },
},
// 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: 'h6', gutterBottom: true },
'Produktdetails'
{
variant: 'h4',
component: 'h1',
gutterBottom: true,
sx: {
fontWeight: 600,
color: "#333"
}
},
cleanProductName(product.name)
),
React.createElement(
Stack,
{ direction: 'row', spacing: 1, flexWrap: 'wrap', gap: 1 },
product.manufacturer && React.createElement(
Chip,
{ label: `Hersteller: ${product.manufacturer}`, variant: 'outlined' }
// 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.weight && product.weight > 0 && React.createElement(
Chip,
{ label: `Gewicht: ${product.weight} kg`, variant: 'outlined' }
),
...attributes.map((attr, index) =>
// Right side - action buttons (exact replica with invisible versions)
React.createElement(
Stack,
{ direction: 'column', spacing: 1, sx: { flexShrink: 0 } },
// "Frage zum Artikel" button - exact replica but invisible
React.createElement(
Chip,
Button,
{
key: index,
label: `${attr.cName}: ${attr.cWert}`,
variant: 'outlined',
color: 'primary'
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(
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"
),
// "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: { mb: 2 } },
React.createElement(
Typography,
{ variant: 'body2', color: 'text.secondary' },
(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: "h4",
color: "primary",
sx: { fontWeight: "bold" }
},
priceWithTax
),
// VAT info (exact match to SPA - direct Typography, no wrapper)
React.createElement(
Typography,
{ 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: 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.7',
color: '#333'
}
}
)
)
)
)
)
),
React.createElement(Footer)
);
}
}
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

@@ -216,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 />
@@ -265,6 +266,7 @@ class AddToCartButton extends Component {
<IconButton
color="inherit"
onClick={this.handleIncrement}
aria-label="Menge erhöhen"
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
>
<AddIcon />
@@ -274,6 +276,7 @@ class AddToCartButton extends Component {
<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" },
@@ -287,6 +290,7 @@ class AddToCartButton extends Component {
<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" },
@@ -363,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 />
@@ -412,6 +417,7 @@ class AddToCartButton extends Component {
<IconButton
color="inherit"
onClick={this.handleIncrement}
aria-label="Menge erhöhen"
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
>
<AddIcon />
@@ -421,6 +427,7 @@ class AddToCartButton extends Component {
<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" },
@@ -434,6 +441,7 @@ class AddToCartButton extends Component {
<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" },

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

@@ -74,7 +74,6 @@ class CartDropdown extends Component {
{cartItems.map((item) => (
<CartItem
key={item.id}
socket={this.props.socket}
item={item}
id={item.id}
/>

View File

@@ -20,14 +20,13 @@ 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});
}
})
}
}
}
}
@@ -76,10 +75,24 @@ class CartItem extends Component {
component="div"
sx={{ fontWeight: 'bold', mb: 0.5 }}
>
<Link to={`/Artikel/${item.seoName}`} style={{ textDecoration: 'none', color: 'inherit' }}>
{item.name}
</Link>
{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
@@ -117,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 && (
@@ -147,7 +160,7 @@ class CartItem extends Component {
display: "block"
}}
>
{this.props.id.toString().endsWith("steckling") ?
{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") :
@@ -163,4 +176,4 @@ class CartItem extends Component {
}
}
export default withI18n()(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
@@ -22,7 +22,7 @@ const CategoryBox = ({
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',

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'
});
}
};
};
@@ -476,20 +461,20 @@ class ChatAssistant extends Component {
const inputsDisabled = isGuest && !privacyConfirmed;
return (
<Paper
elevation={4}
<Paper
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>
@@ -578,11 +563,13 @@ class ChatAssistant extends Component {
)}
<div ref={this.messagesEndRef} />
</Box>
<Box
sx={{
display: 'flex',
p: 1,
borderTop: 1,
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
gap: { xs: 1, sm: 0 },
p: 1,
borderTop: 1,
borderColor: 'divider',
flexShrink: 0,
}}
@@ -594,22 +581,22 @@ class ChatAssistant extends Component {
onChange={this.handleFileChange}
style={{ display: 'none' }}
/>
<TextField
fullWidth
variant="outlined"
<TextField
fullWidth
variant="outlined"
size="small"
autoComplete="off"
autoFocus
autoCapitalize="off"
autoCorrect="off"
placeholder={isRecording ? "Aufnahme läuft..." : "Du kannst mich nach Cannabissorten fragen..."}
placeholder={isRecording ? "Aufnahme läuft..." : "Du kannst mich nach Cannabissorten fragen..."}
value={inputValue}
onChange={this.handleInputChange}
onKeyDown={this.handleKeyDown}
disabled={isRecording || inputsDisabled}
slotProps={{
input: {
maxLength: 300,
input: {
maxLength: 300,
endAdornment: isRecording && (
<Typography variant="caption" color="primary" sx={{ mr: 1 }}>
{this.formatTime(recordingTime)}
@@ -619,42 +606,47 @@ class ChatAssistant extends Component {
}}
/>
{isRecording ? (
<IconButton
color="error"
onClick={this.stopRecording}
sx={{ ml: 1 }}
>
<StopIcon />
</IconButton>
) : (
<IconButton
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
{isRecording ? (
<IconButton
color="error"
onClick={this.stopRecording}
aria-label="Aufnahme stoppen"
sx={{ ml: { xs: 0, sm: 1 } }}
>
<StopIcon />
</IconButton>
) : (
<IconButton
color="primary"
onClick={this.startRecording}
aria-label="Sprachaufnahme starten"
sx={{ ml: { xs: 0, sm: 1 } }}
disabled={isTyping || inputsDisabled}
>
<MicIcon />
</IconButton>
)}
<IconButton
color="primary"
onClick={this.startRecording}
sx={{ ml: 1 }}
disabled={isTyping || inputsDisabled}
onClick={this.handleImageUpload}
aria-label="Bild hochladen"
sx={{ ml: { xs: 0, sm: 1 } }}
disabled={isTyping || isRecording || inputsDisabled}
>
<MicIcon />
<PhotoCameraIcon />
</IconButton>
)}
<IconButton
color="primary"
onClick={this.handleImageUpload}
sx={{ ml: 1 }}
disabled={isTyping || isRecording || inputsDisabled}
>
<PhotoCameraIcon />
</IconButton>
<Button
variant="contained"
sx={{ ml: 1 }}
onClick={this.handleSendMessage}
disabled={isTyping || isRecording || inputsDisabled}
>
Senden
</Button>
<Button
variant="contained"
sx={{ ml: { xs: 0, sm: 1 } }}
onClick={this.handleSendMessage}
disabled={isTyping || isRecording || inputsDisabled}
>
Senden
</Button>
</Box>
</Box>
</Paper>
);

File diff suppressed because it is too large Load Diff

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

@@ -247,7 +247,7 @@ class Footer extends Component {
<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}
@@ -264,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"
@@ -272,12 +272,12 @@ class Footer extends Component {
justifyContent="center"
alignItems="center"
>
<Stack
<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"
@@ -286,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': {
@@ -312,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))',
@@ -344,6 +352,9 @@ class Footer extends Component {
<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>

View File

@@ -2,7 +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/index.js';
// import { withI18n } from '../i18n/withTranslation.js'; // Temporarily commented out for debugging
class GoogleLoginButton extends Component {
static contextType = GoogleAuthContext;
@@ -187,17 +187,20 @@ class GoogleLoginButton extends Component {
};
render() {
const { disabled, style, className, text = (this.props.t ? this.props.t('auth.loginWithGoogle') : '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}
style={{ backgroundColor: '#4285F4', color: 'white', ...style }}
fullWidth
style={{backgroundColor: '#4285F4', color: 'white', ...style }}
className={className}
>
{isLoading ? 'Loading...' : text}
@@ -206,4 +209,4 @@ class GoogleLoginButton extends Component {
}
}
export default withI18n(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,8 +34,7 @@ class Header extends Component {
};
render() {
// Get socket directly from context in render method
const {socket,socketB} = this.context;
const { isHomePage, isProfilePage, isAktionenPage, isFilialePage } = this.props;
return (
@@ -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 || isAktionenPage || isFilialePage) && <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>
);
}
@@ -107,11 +104,12 @@ const HeaderWithContext = (props) => {
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} isAktionenPage={isAktionenPage} isFilialePage={isFilialePage} />}
</SocketContext.Consumer>
<Header {...props} isHomePage={isHomePage} isArtikel={isArtikel} isProfilePage={isProfilePage} isAktionenPage={isAktionenPage} isFilialePage={isFilialePage} />
);
};

View File

@@ -12,9 +12,7 @@ import LoupeIcon from '@mui/icons-material/Loupe';
class Images extends Component {
constructor(props) {
super(props);
this.state = { mainPic:0,pics:[]};
console.log('Images constructor',props);
this.state = { mainPic:0,pics:[] };
}
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,43 +106,68 @@ 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' }}>
<CardMedia
component="img"
height="400"
sx={{
objectFit: 'contain',
cursor: 'pointer',
transition: 'transform 0.2s ease-in-out',
'&:hover': {
transform: 'scale(1.02)'
}
}}
image={this.state.pics[this.state.mainPic]}
onClick={this.props.onOpenFullscreen}
/>
<IconButton
size="small"
disableRipple
sx={{
position: 'absolute',
top: 8,
right: 8,
color: 'white',
backgroundColor: 'rgba(0, 0, 0, 0.4)',
pointerEvents: 'none',
'&:hover': {
backgroundColor: 'rgba(0, 0, 0, 0.6)'
}
}}
>
<LoupeIcon fontSize="small" />
</IconButton>
</Box>
)}
<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={getImageSrc()}
onClick={this.props.onOpenFullscreen}
/>
<IconButton
size="small"
disableRipple
aria-label="Zoom-Symbol"
sx={{
position: 'absolute',
top: 8,
right: 8,
color: 'white',
backgroundColor: 'rgba(0, 0, 0, 0.4)',
pointerEvents: 'none',
'&:hover': {
backgroundColor: 'rgba(0, 0, 0, 0.6)'
}
}}
>
<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

@@ -24,10 +24,14 @@ class LanguageSwitcher extends Component {
this.setState({ anchorEl: null });
};
handleLanguageChange = (language) => {
handleLanguageChange = async (language) => {
const { languageContext } = this.props;
if (languageContext) {
languageContext.changeLanguage(language);
try {
await languageContext.changeLanguage(language);
} catch (error) {
console.error('Failed to change language:', error);
}
}
this.handleClose();
};
@@ -56,6 +60,7 @@ class LanguageSwitcher extends Component {
'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),
@@ -126,8 +131,8 @@ class LanguageSwitcher extends Component {
const { languageContext } = this.props;
if (anchorEl && !prevState.anchorEl && languageContext) {
// Menu just opened, lazy load all flags
languageContext.availableLanguages.forEach(lang => {
// 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);
}
@@ -153,6 +158,7 @@ class LanguageSwitcher extends Component {
'ru': 'RU',
'sk': 'SK',
'sl': 'SI',
'sq': 'AL',
'sr': 'RS',
'sv': 'SE',
'tr': 'TR',
@@ -180,6 +186,7 @@ class LanguageSwitcher extends Component {
'ru': 'Русский',
'sk': 'Slovenčina',
'sl': 'Slovenščina',
'sq': 'Shqip',
'sr': 'Српски',
'sv': 'Svenska',
'tr': 'Türkçe',
@@ -197,7 +204,7 @@ class LanguageSwitcher extends Component {
return null;
}
const { currentLanguage, availableLanguages } = languageContext;
const { currentLanguage, allLanguages } = languageContext;
const open = Boolean(anchorEl);
return (
@@ -237,29 +244,31 @@ class LanguageSwitcher extends Component {
horizontal: 'right',
}}
>
{availableLanguages.map((language) => (
<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)}
{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>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto' }}>
{this.getLanguageLabel(language)}
</Typography>
</MenuItem>
))}
</MenuItem>
);
})}
</Menu>
</Box>
);

View File

@@ -23,6 +23,7 @@ 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'));
@@ -171,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));
@@ -216,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) {
@@ -245,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')
});
}
});
@@ -253,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
});
}
});
@@ -311,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'));
@@ -343,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) => {
@@ -366,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')
});
}
});
@@ -379,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));
@@ -407,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) {
@@ -434,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
});
}
@@ -444,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
});
@@ -457,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;
@@ -641,25 +615,26 @@ export class LoginComponent extends Component {
)}
{showGoogleAuth && (
<Suspense fallback={
<Button
variant="contained"
startIcon={<PersonIcon />}
sx={{ width: '100%', backgroundColor: '#4285F4', color: 'white' }}
>
Mit Google anmelden
</Button>
}>
<GoogleAuthProvider clientId={config.googleClientId}>
<GoogleLoginButton
onSuccess={this.handleGoogleLoginSuccess}
onError={this.handleGoogleLoginError}
text="Mit Google anmelden"
style={{ width: '100%', backgroundColor: '#4285F4' }}
autoInitiate={true}
/>
</GoogleAuthProvider>
</Suspense>
<Suspense fallback={
<Button
variant="contained"
startIcon={<GoogleIcon />}
disabled
fullWidth
style={{backgroundColor: '#4285F4', color: 'white' }}
>
Loading...
</Button>
}>
<GoogleAuthProvider clientId={config.googleClientId}>
<GoogleLoginButton
onSuccess={this.handleGoogleLoginSuccess}
onError={this.handleGoogleLoginError}
style={{ width: '100%', backgroundColor: '#4285F4' }}
autoInitiate={true}
/>
</GoogleAuthProvider>
</Suspense>
)}
</Box>

View File

@@ -12,90 +12,170 @@ 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')
};
// Determine which page we're on
const isHome = currentPath === "/";
const isAktionen = currentPath === "/aktionen";
const isFiliale = currentPath === "/filiale";
// Get navigation config based on current page
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" }
};
} else if (isAktionen) {
return {
leftNav: { text: t('navigation.filiale'), link: "/filiale" },
rightNav: { text: t('navigation.home'), link: "/" }
};
} else if (isFiliale) {
return {
leftNav: { text: t('navigation.home'), link: "/" },
rightNav: { text: t('navigation.aktionen'), link: "/aktionen" }
};
}
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'),
aktionen: t('titles.aktionen'),
filiale: t('titles.filiale')
};
// Define all content boxes for layered rendering
const allContentBoxes = {
home: [
{
title: t('sections.seeds'),
image: "/assets/images/seeds.jpg",
bgcolor: "#e1f0d3",
link: "/Kategorie/Seeds"
},
{
title: t('sections.stecklinge'),
image: "/assets/images/cutlings.jpg",
bgcolor: "#e8f5d6",
link: "/Kategorie/Stecklinge"
}
{ title: t('sections.seeds'), image: "/assets/images/seeds.avif", bgcolor: "#e1f0d3", link: "/Kategorie/Seeds" },
{ title: t('sections.konfigurator'), image: "/assets/images/konfigurator.avif", bgcolor: "#e8f5d6", link: "/Konfigurator" }
],
aktionen: [
{
title: t('sections.oilPress'),
image: "/assets/images/presse.jpg",
bgcolor: "#e1f0d3",
link: "/presseverleih"
},
{
title: t('sections.thcTest'),
image: "/assets/images/purpl.jpg",
bgcolor: "#e8f5d6",
link: "/thc-test"
}
{ title: t('sections.oilPress'), image: "/assets/images/presse.jpg", bgcolor: "#e1f0d3", link: "/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"
}
{ 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" }
]
};
// Get opacity for each page layer
const getOpacity = (pageType) => {
if (pageType === "home" && isHome) return 1;
if (pageType === "aktionen" && isAktionen) return 1;
@@ -104,8 +184,6 @@ const MainPageLayout = () => {
};
const navConfig = getNavigationConfig();
// Navigation text mapping for translation
const navTexts = [
{ key: 'aktionen', text: t('navigation.aktionen'), link: '/aktionen' },
{ key: 'filiale', text: t('navigation.filiale'), link: '/filiale' },
@@ -115,201 +193,61 @@ const MainPageLayout = () => {
return (
<Container maxWidth="lg" sx={{ py: 2 }}>
<style>{getCombinedAnimatedBorderStyles(['seeds', 'cutlings'])}</style>
{/* Main Navigation Header */}
<Box sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
mb: 4,
mt: 2,
px: 0,
transition: "all 0.3s ease-in-out",
// Portrait phone: stack title above navigation
flexDirection: {
xs: "column",
sm: "row"
}
}}>
{/* Title for portrait phones - shown first */}
<Box sx={{
display: { xs: "block", sm: "none" },
mb: { xs: 2, sm: 0 },
width: "100%",
textAlign: "center",
position: "relative"
}}>
<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>
<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>
{/* Navigation container for portrait phones */}
<Box sx={{
display: { xs: "flex", sm: "contents" },
width: { xs: "100%", sm: "auto" },
justifyContent: { xs: "space-between", sm: "initial" },
alignItems: "center"
}}>
{/* Left Navigation - Layered rendering */}
<Box sx={{
display: "flex",
alignItems: "center",
flexShrink: 0,
justifyContent: "flex-start",
position: "relative",
mr: { xs: 0, sm: 2 }
}}>
<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;
const link = navItem.link;
return (
<Box
key={navItem.key}
component={Link}
to={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"
}
}}
>
<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>
<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>
{/* Center Title - Layered rendering - Hidden on portrait phones, shown on larger screens */}
<Box sx={{
flex: 1,
display: { xs: "none", sm: "flex" },
justifyContent: "center",
alignItems: "center",
px: 0,
position: "relative",
minWidth: 0
}}>
<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>
<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>
{/* Right Navigation - Layered rendering */}
<Box sx={{
display: "flex",
alignItems: "center",
flexShrink: 0,
justifyContent: "flex-end",
position: "relative",
ml: { xs: 0, sm: 2 }
}}>
<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;
const link = navItem.link;
return (
<Box
key={navItem.key}
component={Link}
to={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>
<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>
);
@@ -317,91 +255,30 @@ const MainPageLayout = () => {
</Box>
</Box>
</Box>
{/* Content Boxes - Layered rendering */}
<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"
}}
>
<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) => (
<Grid key={`${pageType}-${index}`} item xs={12} sm={6} sx={{ p: 2, width: "50%" }}>
<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,
},
}}
>
<Box
sx={{
height: "100%",
bgcolor: box.bgcolor,
backgroundImage: `url("${box.image}")`,
backgroundSize: "contain",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
position: "relative",
}}
>
<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>
<ContentBox
key={`${pageType}-${index}`}
box={box}
index={index}
pageType={pageType}
starHovered={starHovered}
setStarHovered={setStarHovered}
opacity={getOpacity(pageType)}
translatedContent={translatedContent}
/>
))}
</Grid>
))}
</Box>
{/* Shared Carousel */}
<SharedCarousel />
</Container>
);
};
export default MainPageLayout;
export default MainPageLayout;

View File

@@ -1,10 +1,9 @@
import React, { Component } from 'react';
import { Navigate } from 'react-router-dom';
import { Box, CircularProgress, Typography } from '@mui/material';
import SocketContext from '../contexts/SocketContext.js';
class PaymentSuccess extends Component {
static contextType = SocketContext;
constructor(props) {
super(props);
@@ -73,19 +72,10 @@ class PaymentSuccess extends Component {
};
checkMolliePaymentStatus = (paymentId) => {
const { socket } = this.context;
if (!socket || !socket.connected) {
console.error('Socket not connected');
this.setState({
redirectUrl: '/profile#cart',
processing: false,
error: 'Connection error'
});
return;
}
socket.emit('checkMollieIntent', { paymentId }, (response) => {
window.socketManager.emit('checkMollieIntent', { paymentId }, (response) => {
if (response.success) {
console.log('Payment Status:', response.payment.status);
console.log('Is Paid:', response.payment.isPaid);

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,10 +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);
@@ -27,26 +84,8 @@ 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) => {
if(res.success){
window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
if (this._isMounted) {
this.setState({image: window.smallPicCache[bildId], loading: false});
} else {
this.state.image = window.smallPicCache[bildId];
this.state.loading = false;
}
}else{
console.log('Fehler beim Laden des Bildes:', res);
if (this._isMounted) {
this.setState({error: true, loading: false});
} else {
this.state.error = true;
this.state.loading = false;
}
}
})
this.loadImage(bildId);
}
}else{
this.state = {image: null, loading: false, error: false};
@@ -57,6 +96,31 @@ class Product extends Component {
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/avif' }));
if (this._isMounted) {
this.setState({image: window.smallPicCache[bildId], loading: false});
} else {
this.state.image = window.smallPicCache[bildId];
this.state.loading = false;
}
}else{
console.log('Fehler beim Laden des Bildes:', res);
if (this._isMounted) {
this.setState({error: true, loading: false});
} else {
this.state.error = true;
this.state.loading = false;
}
}
});
}
componentWillUnmount() {
this._isMounted = false;
}
@@ -66,8 +130,25 @@ 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 {
const {
id, name, price, available, manufacturer, seoName,
currency, vat, cGrundEinheit, fGrundPreis, thc,
floweringWeeks,incoming, neu, weight, versandklasse, availableSupplier, komponenten
@@ -246,15 +327,15 @@ class Product extends Component {
)}
<Box
component={Link}
to={`/Artikel/${seoName}`}
sx={{
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
onClick={this.handleProductClick}
sx={{
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
textDecoration: 'none',
color: 'inherit'
color: 'inherit',
cursor: 'pointer'
}}
>
<Box sx={{
@@ -276,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',
@@ -289,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',
@@ -330,21 +427,50 @@ class Product extends Component {
</Typography>
</Box>
<div style={{padding:'0px',margin:'0px',minHeight:'3.8em'}}>
<Typography
variant="h6"
color="primary"
sx={{ mt: 'auto', fontWeight: 'bold', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}
>
<span>{new Intl.NumberFormat('de-DE', {style: 'currency', currency: currency || 'EUR'}).format(price)}</span>
<small style={{ color: '#77aa77', fontSize: '0.6em' }}>({this.props.t ? this.props.t('product.inclVatFooter', { vat }) : `incl. ${vat}% USt.,*`})</small>
</Typography>
{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 style={{padding:'0px',margin:'0px'}}>
<Typography
variant="h6"
color="primary"
sx={{
fontWeight: 'bold',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
position: 'relative'
}}
>
<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>
</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*/}
</CardContent>
@@ -355,6 +481,7 @@ class Product extends Component {
component={Link}
to={`/Artikel/${seoName}`}
size="small"
aria-label="Produktdetails anzeigen"
sx={{ mr: 1, color: 'text.secondary' }}
>
<ZoomInIcon />
@@ -367,4 +494,10 @@ class Product extends Component {
}
}
export default withI18n()(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,461 @@
import React from 'react';
import { Link } from "react-router-dom";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import ChevronLeft from "@mui/icons-material/ChevronLeft";
import ChevronRight from "@mui/icons-material/ChevronRight";
import Product from "./Product.js";
import { withTranslation } from 'react-i18next';
import { withLanguage } from '../i18n/withTranslation.js';
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 }}>
<Box
component={Link}
to="/Kategorie/neu"
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
textDecoration: "none",
color: "primary.main",
mb: 2,
transition: "all 0.3s ease",
"&:hover": {
transform: "translateX(5px)",
color: "primary.dark"
}
}}
>
<Typography
variant="h4"
component="span"
sx={{
fontFamily: "SwashingtonCP",
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)"
}}
>
{title || t('product.new')}
</Typography>
<ChevronRight sx={{ fontSize: "2.5rem", ml: 1 }} />
</Box>
<div
className="product-carousel-wrapper"
style={{
position: 'relative',
overflowX: 'hidden',
overflowY: 'visible',
width: '100%',
maxWidth: '1200px',
margin: '0 auto',
padding: '0 20px',
boxSizing: 'border-box'
}}
>
{/* Left Arrow */}
<IconButton
aria-label="Vorherige Produkte anzeigen"
onClick={this.handleLeftClick}
style={{
position: 'absolute',
top: '50%',
left: '8px',
transform: 'translateY(-50%)',
zIndex: 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,19 +1,22 @@
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}
/>
);
};
export default ProductDetailWithSocket;
export default ProductDetailWithSocket;

View File

@@ -47,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);
@@ -116,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)
@@ -187,7 +209,7 @@ class ProductFilters extends Component {
color: 'primary.main'
}}
>
{this.props.dataParam}
{this.props.categoryName}
</Typography>
)}

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

@@ -399,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>
@@ -430,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()}
@@ -454,7 +454,7 @@ class ProductList extends Component {
}
}}
>
<Product
<Product
id={product.id}
name={product.name}
seoName={product.seoName}
@@ -471,11 +471,12 @@ class ProductList extends Component {
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>

View File

@@ -1,231 +1,427 @@
import React, { useContext, useEffect, useState } from "react";
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 SocketContext from "../contexts/SocketContext.js";
import { useCarousel } from "../contexts/CarouselContext.js";
import { useTranslation } from 'react-i18next';
import ProductCarousel from "./ProductCarousel.js";
import { withTranslation } from 'react-i18next';
import { withLanguage } from '../i18n/withTranslation.js';
// Helper to process and set categories
const processCategoryTree = (categoryTree) => {
if (
categoryTree &&
categoryTree.id === 209 &&
Array.isArray(categoryTree.children)
) {
return categoryTree.children;
} else {
return [];
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();
}
};
// Check for cached data
const getProductCache = () => {
if (typeof window !== "undefined" && window.productCache) {
return window.productCache;
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();
}
});
}
if (
typeof global !== "undefined" &&
global.window &&
global.window.productCache
) {
return global.window.productCache;
}
return null;
};
// Initialize categories
const initializeCategories = () => {
const productCache = getProductCache();
if (productCache && productCache["categoryTree_209"]) {
const cached = productCache["categoryTree_209"];
if (cached.categoryTree) {
return processCategoryTree(cached.categoryTree);
}
}
return [];
};
const SharedCarousel = () => {
const { carouselRef, filteredCategories, setFilteredCategories, moveCarousel } = useCarousel();
const context = useContext(SocketContext);
const { t } = useTranslation();
const [rootCategories, setRootCategories] = useState([]);
useEffect(() => {
const initialCategories = initializeCategories();
setRootCategories(initialCategories);
}, []);
useEffect(() => {
// Only fetch from socket if we don't already have categories
if (
rootCategories.length === 0 &&
context && context.socket && context.socket.connected &&
typeof window !== "undefined"
) {
context.socket.emit("categoryList", { categoryId: 209 }, (response) => {
if (response && response.categoryTree) {
// Store in cache
try {
if (!window.productCache) window.productCache = {};
window.productCache["categoryTree_209"] = {
categoryTree: response.categoryTree,
timestamp: Date.now(),
};
} catch (err) {
console.error(err);
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();
}
setRootCategories(response.categoryTree.children || []);
}
});
});
}
}, [context, context?.socket?.connected, rootCategories.length]);
useEffect(() => {
const filtered = rootCategories.filter(
(cat) => cat.id !== 689 && cat.id !== 706
);
setFilteredCategories(filtered);
}, [rootCategories, setFilteredCategories]);
// Create duplicated array for seamless scrolling
const displayCategories = [...filteredCategories, ...filteredCategories];
if (filteredCategories.length === 0) {
return null;
}
return (
<Box sx={{ mt: 3 }}>
<Typography
variant="h4"
component="h1"
sx={{
mb: 2,
fontFamily: "SwashingtonCP",
color: "primary.main",
textAlign: "center",
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)"
}}
>
{t('navigation.categories')}
</Typography>
componentWillUnmount() {
this._isMounted = false;
this.stopAutoScroll();
this.clearInactivityTimer();
this.clearScrollbarTimer();
}
<div
className="carousel-wrapper"
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: 'relative',
overflow: 'hidden',
width: '100%',
maxWidth: '1200px',
margin: '0 auto',
padding: '0 20px',
boxSizing: 'border-box'
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'
}}
>
{/* Left Arrow */}
<IconButton
onClick={() => moveCarousel("left")}
aria-label="Vorherige Kategorien anzeigen"
<div
className="scrollbar-thumb"
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%'
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"
}
}}
>
<ChevronLeft />
</IconButton>
<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>
{/* Right Arrow */}
<IconButton
onClick={() => moveCarousel("right")}
aria-label="Nächste Kategorien anzeigen"
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"
<div
className="carousel-wrapper"
style={{
position: 'relative',
overflow: 'hidden',
padding: '20px 0',
width: '100%',
maxWidth: '1080px',
maxWidth: '1200px',
margin: '0 auto',
zIndex: 1,
padding: '0 20px',
boxSizing: 'border-box'
}}
>
<div
className="home-carousel-track"
ref={carouselRef}
{/* Left Arrow */}
<IconButton
aria-label="Vorherige Kategorien anzeigen"
onClick={this.handleLeftClick}
style={{
display: 'flex',
gap: '16px',
transition: 'none',
alignItems: 'flex-start',
width: 'fit-content',
overflow: 'visible',
position: 'relative',
transform: 'translateX(0px)',
margin: '0 auto'
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%'
}}
>
{displayCategories.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>
))}
<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>
</div>
</Box>
);
};
export default SharedCarousel;
{/* 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={{
height: '100%',
border: '2px solid',
borderColor: isSelected ? '#2e7d32' : '#e0e0e0',
backgroundColor: isSelected ? '#f1f8e9' : '#ffffff',
'&:hover': {
boxShadow: 5,
borderColor: isSelected ? '#2e7d32' : '#90caf9'
},
transition: 'all 0.3s ease',
cursor: 'pointer'
}}
onClick={() => onExtraToggle(extra.id)}
>
<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: 6,
borderColor: isSelected ? '#2e7d32' : '#90caf9'
},
transition: 'all 0.3s ease'
}}
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>
{/* 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>
)}
</Typography>
{/* Selection Indicator - Separate line */}
{isSelected && (
<Box sx={{ mt: 2, textAlign: 'center' }}>
<Typography variant="body2" sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
Hinzugefügt
</Typography>
</Box>
<Typography variant="body2" sx={{
color: '#2e7d32',
fontWeight: 'bold',
mt: 1,
textAlign: 'center'
}}>
Ausgewählt
</Typography>
)}
</CardContent>
</Card>
<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}
</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>
))}
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Keine Extras verfügbar
</Typography>
</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,7 +8,14 @@ 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';
@@ -34,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({
@@ -53,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);
@@ -74,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);
// Remove existing listeners first to avoid duplicates
this.removeSocketListeners();
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);
}
}
@@ -118,7 +112,7 @@ class ButtonGroup extends Component {
}
render() {
const { socket, navigate, t } = this.props;
const { navigate, t } = this.props;
const { isCartOpen } = this.state;
const cartItems = Array.isArray(window.cart) ? window.cart : [];
@@ -126,11 +120,14 @@ class ButtonGroup extends Component {
<Box sx={{ display: 'flex', gap: { xs: 0.5, sm: 1 } }}>
<LanguageSwitcher />
<LoginComponent socket={socket} />
<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">
@@ -156,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',
@@ -170,7 +168,7 @@ class ButtonGroup extends Component {
</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) {

View File

@@ -6,317 +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');
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();
}
);
}
// If activeCategoryId changes, update subcategories
if (
prevProps.activeCategoryId !== this.props.activeCategoryId &&
this.state.categoryTree
) {
this.processCategoryTree(this.state.categoryTree);
}
}
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;
}
// 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 (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);
} else {
try {
const cacheKey = "categoryTree_209";
if (windowObj && windowObj.productCache) {
windowObj.productCache[cacheKey] = {
categoryTree: null,
timestamp: Date.now(),
};
}
} catch (err) {
console.error("Error writing to cache:", err);
}
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({
categoryTree: null,
level1Categories: [],
level2Categories: [],
level3Categories: [],
activePath: [],
categories: response.children,
activeCategoryId: this.setLevel1CategoryId(this.props.activeCategoryId)
});
}
});
};
}
processCategoryTree = (categoryTree) => {
// Level 1 categories are always the children of category 209 (Home)
const level1Categories =
categoryTree && categoryTree.id === 209
? categoryTree.children || []
: [];
componentDidUpdate(prevProps) {
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 (prevProps.activeCategoryId !== this.props.activeCategoryId) {
this.setLevel1CategoryId(this.props.activeCategoryId);
}
}
// 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
setLevel1CategoryId = (input) => {
if (input) {
const language = this.props.languageContext?.currentLanguage || this.props.i18n.language;
const categoryTreeCache = window.categoryService.getSync(209, language);
// 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 (categoryTreeCache && categoryTreeCache.children) {
let level1CategoryId = null;
// 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 {
// 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);
}
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);
}
if (activePath.length >= 2) {
// Show children of the level 2 category
const level2Category = activePath[1];
level3Categories = level2Category.children || [];
}
this.setState({
activeCategoryId: level1CategoryId
});
return;
}
}
this.setState({ activeCategoryId: null });
}
this.setState({
categoryTree,
level1Categories,
level2Categories,
level3Categories,
activePath,
fetchedCategories: true,
});
};
handleMobileMenuToggle = () => {
this.setState(prevState => ({
@@ -331,113 +151,168 @@ 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="/"
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",
...(this.props.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",
},
<Button
component={Link}
to="/"
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",
},
}}
>
<HomeIcon sx={{
fontSize: "1rem",
mr: isMobile ? 1 : 0,
color: this.props.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: this.props.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: this.props.activeCategoryId === null ? "transparent" : "inherit",
position: "absolute",
top: 0,
left: 0,
zIndex: 1,
}}
>
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
</Box>
"& .bold-text": {
color: "#2e7d32 !important",
},
"& .thin-text": {
color: "transparent !important",
},
},
}}
>
<HomeIcon 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>
)}
</Button>
)}
{this.state.fetchedCategories && categories.length > 0 ? (
{/* 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>
<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>
)}
</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
@@ -460,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,
@@ -484,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,
}}
@@ -496,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,
@@ -510,24 +385,101 @@ class CategoryList extends Component {
);
})}
</>
) : (
level === 1 && !isMobile && (
<Typography
variant="caption"
color="inherit"
sx={{
display: "inline-flex",
alignItems: "center",
height: "30px", // Match small button height
px: 1,
fontSize: "0.75rem",
opacity: 0.9,
}}
>
&nbsp;
</Typography>
)
) : (!isMobile && (
<Typography
variant="caption"
color="inherit"
sx={{
display: "inline-flex",
alignItems: "center",
height: "33px", // Match small button height
px: 1,
fontSize: "0.75rem",
opacity: 0.9,
}}
>
&nbsp;
</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>
);
@@ -550,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>
@@ -582,11 +516,11 @@ class CategoryList extends Component {
>
<Container maxWidth="lg" sx={{ px: 2 }}>
{/* Toggle Button */}
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
py: 1,
cursor: "pointer",
"&:hover": {
@@ -596,7 +530,7 @@ class CategoryList extends Component {
onClick={this.handleMobileMenuToggle}
role="button"
tabIndex={0}
aria-label={this.props.t ?
aria-label={this.props.t ?
(mobileMenuOpen ? this.props.t('navigation.categoriesClose') : this.props.t('navigation.categoriesOpen')) :
(mobileMenuOpen ? "Kategorien schließen" : "Kategorien öffnen")
}
@@ -607,11 +541,11 @@ class CategoryList extends Component {
}
}}
>
<Typography variant="subtitle2" color="inherit" sx={{
<Typography variant="subtitle2" color="inherit" sx={{
fontWeight: "bold",
textShadow: "0 1px 2px rgba(0,0,0,0.3)"
}}>
{this.props.t ? this.props.t('navigation.categories') : 'Kategorien'}
{this.props.t ? this.props.t('navigation.categories') : 'Kategorien'}
</Typography>
<Box sx={{ display: "flex", alignItems: "center" }}>
{mobileMenuOpen ? <CloseIcon /> : <MenuIcon />}
@@ -622,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>

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,18 +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(
@@ -27,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);
@@ -60,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
@@ -92,7 +91,7 @@ const SearchBar = () => {
}
);
},
[context]
[languageContext, i18n]
);
const handleSearchChange = (e) => {
@@ -186,6 +185,15 @@ 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;
@@ -236,7 +244,7 @@ const SearchBar = () => {
>
<TextField
ref={inputRef}
placeholder="Produkte suchen..."
placeholder={t('search.searchProducts')}
variant="outlined"
size="small"
fullWidth
@@ -257,12 +265,11 @@ const SearchBar = () => {
),
endAdornment: (
<InputAdornment position="end">
{loadingSuggestions && <CircularProgress size={16} />}
<IconButton
size="small"
onClick={handleEnterClick}
aria-label="Suche starten"
sx={{
ml: loadingSuggestions ? 0.5 : 0,
p: 0.5,
color: "text.secondary",
"&:hover": {
@@ -289,8 +296,6 @@ const SearchBar = () => {
left: 0,
right: 0,
zIndex: 1300,
maxHeight: "300px",
overflow: "auto",
mt: 0.5,
borderRadius: 2,
}}
@@ -298,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",
},
@@ -318,14 +330,48 @@ const SearchBar = () => {
>
<ListItemText
primary={
<Typography variant="body2" noWrap>
{suggestion.name}
</Typography>
<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

@@ -5,7 +5,6 @@ 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 {
@@ -68,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;
@@ -147,7 +145,6 @@ class CartTab extends Component {
console.log("No order template available or failed to fetch");
}
});
}
};
componentDidMount() {
@@ -468,7 +465,6 @@ class CartTab extends Component {
{!showPaymentConfirmation && (
<CartDropdown
cartItems={cartItems}
socket={this.context.socket}
showDetailedSummary={showStripePayment}
deliveryMethod={deliveryMethod}
deliveryCost={deliveryCost}
@@ -539,7 +535,4 @@ class CartTab extends Component {
}
}
// Set static contextType to access the socket
CartTab.contextType = SocketContext;
export default withI18n()(CartTab);

View File

@@ -17,7 +17,7 @@ const DeliveryMethodSelector = ({ deliveryMethod, onChange, isPickupOnly, cartIt
name: 'DHL',
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') : '6,99 €'),
price: isFreeShipping ? (t ? t('delivery.prices.free') : 'kostenlos') : (t ? t('delivery.prices.dhl') : '5,90 €'),
disabled: isPickupOnly
},
{

View File

@@ -145,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);
@@ -213,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,
@@ -247,9 +244,8 @@ class OrderProcessingService {
// 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 = [];
@@ -274,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) => {
@@ -302,20 +290,13 @@ class OrderProcessingService {
}
}
);
} else {
console.error("Socket context not available");
this.setState({
isCompletingOrder: false,
completionError: "Cannot connect to server. Please try again later.",
});
}
}
};
// Create Mollie payment intent
createMollieIntent(mollieOrderData) {
const context = this.getContext();
if (context && context.socket && context.socket.connected) {
context.socket.emit(
window.socketManager.emit(
"createMollieIntent",
mollieOrderData,
(response) => {
@@ -336,13 +317,6 @@ class OrderProcessingService {
}
}
);
} else {
console.error("Socket context not available");
this.setState({
isCompletingOrder: false,
completionError: "Cannot connect to server. Please try again later.",
});
}
}
// Calculate delivery cost

View File

@@ -1,4 +1,4 @@
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 {
@@ -23,7 +23,6 @@ import {
} from "@mui/material";
import SearchIcon from "@mui/icons-material/Search";
import CancelIcon from "@mui/icons-material/Cancel";
import SocketContext from "../../contexts/SocketContext.js";
import OrderDetailsDialog from "./OrderDetailsDialog.js";
// Constants
@@ -32,6 +31,7 @@ const getStatusTranslation = (status, t) => {
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",
@@ -40,29 +40,23 @@ const getStatusTranslation = (status, t) => {
};
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", {
@@ -81,7 +75,6 @@ const OrdersTab = ({ orderIdFromHash, t }) => {
const [orderToCancel, setOrderToCancel] = useState(null);
const [isCancelling, setIsCancelling] = useState(false);
const {socket} = useContext(SocketContext);
const navigate = useNavigate();
const handleViewDetails = useCallback(
@@ -98,10 +91,10 @@ const OrdersTab = ({ orderIdFromHash, t }) => {
);
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 {
@@ -109,25 +102,13 @@ const OrdersTab = ({ orderIdFromHash, t }) => {
}
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);
@@ -166,10 +147,10 @@ const OrdersTab = ({ orderIdFromHash, t }) => {
// Handle cancel confirmation
const handleConfirmCancel = () => {
if (!orderToCancel || !socket) return;
if (!orderToCancel) return;
setIsCancelling(true);
socket.emit('cancelOrder', { orderId: orderToCancel.orderId }, (response) => {
window.socketManager.emit('cancelOrder', { orderId: orderToCancel.orderId }, (response) => {
setIsCancelling(false);
setCancelConfirmOpen(false);
@@ -243,11 +224,11 @@ const OrdersTab = ({ orderIdFromHash, t }) => {
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"
@@ -257,12 +238,33 @@ const OrdersTab = ({ orderIdFromHash, t }) => {
{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(
(acc, item) => acc + item.quantity_ordered,
0
)}
{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
)}
</TableCell>
<TableCell align="right">
{currencyFormatter.format(total)}
@@ -274,6 +276,7 @@ const OrdersTab = ({ orderIdFromHash, t }) => {
size="small"
color="primary"
onClick={() => handleViewDetails(order.orderId)}
aria-label={t ? t('orders.tooltips.viewDetails') : 'Details anzeigen'}
>
<SearchIcon />
</IconButton>
@@ -284,6 +287,7 @@ const OrdersTab = ({ orderIdFromHash, t }) => {
size="small"
color="error"
onClick={() => handleCancelClick(order)}
aria-label={t ? t('orders.tooltips.cancelOrder') : 'Bestellung stornieren'}
>
<CancelIcon />
</IconButton>

View File

@@ -11,7 +11,7 @@ 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 {
@@ -48,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,
@@ -90,7 +90,7 @@ class SettingsTab extends Component {
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 });
@@ -134,7 +134,7 @@ class SettingsTab extends Component {
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 });
@@ -184,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) {
@@ -382,6 +382,7 @@ 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)' }

View File

@@ -200,13 +200,13 @@ const config = {
// 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>
);
};

View File

@@ -0,0 +1,31 @@
import React, { createContext, useState, useContext } from 'react';
const ProductContext = createContext({
currentProduct: null,
setCurrentProduct: () => {}
});
export const useProduct = () => useContext(ProductContext);
export const withProduct = (Component) => {
return (props) => {
const productContext = useProduct();
return <Component {...props} productContext={productContext} />;
};
};
export const ProductContextProvider = ({ children }) => {
const [currentProduct, setCurrentProduct] = useState(null);
const setCurrentProductWithLog = (product) => {
console.log('ProductContext: Setting current product to:', product);
setCurrentProduct(product);
};
return (
<ProductContext.Provider value={{ currentProduct, setCurrentProduct: setCurrentProductWithLog }}>
{children}
</ProductContext.Provider>
);
};

View File

@@ -1,7 +0,0 @@
import React from 'react';
// Create a new context for Socket.IO
const SocketContext = React.createContext(null);
export const SocketConsumer = SocketContext.Consumer;
export default SocketContext;

View File

@@ -1,9 +1,10 @@
// @note Dummy data for grow tent configurator - no backend calls
// descriptions now keys for translation
export const tentShapes = [
{
id: '60x60',
name: '60x60cm',
description: 'Kompakt - ideal für kleine Räume',
descriptionKey: 'kitConfig.description60x60',
footprint: '60x60',
minPlants: 1,
maxPlants: 2,
@@ -13,7 +14,7 @@ export const tentShapes = [
{
id: '80x80',
name: '80x80cm',
description: 'Mittel - perfekte Balance',
descriptionKey: 'kitConfig.description80x80',
footprint: '80x80',
minPlants: 2,
maxPlants: 4,
@@ -23,7 +24,7 @@ export const tentShapes = [
{
id: '100x100',
name: '100x100cm',
description: 'Groß - für erfahrene Grower',
descriptionKey: 'kitConfig.description100x100',
footprint: '100x100',
minPlants: 4,
maxPlants: 6,
@@ -33,7 +34,7 @@ export const tentShapes = [
{
id: '120x60',
name: '120x60cm',
description: 'Rechteckig - maximale Raumnutzung',
descriptionKey: 'kitConfig.description120x60',
footprint: '120x60',
minPlants: 3,
maxPlants: 6,
@@ -41,229 +42,3 @@ export const tentShapes = [
visualDepth: 60
}
];
export const tentSizes = [
// 60x60 tents
{
id: 'tent_60x60x140',
name: 'Basic 140cm',
description: 'Einsteigermodell',
price: 89.99,
image: '/assets/images/nopicture.jpg',
dimensions: '60x60x140cm',
coverage: '1-2 Pflanzen',
shapeId: '60x60',
height: 140
},
{
id: 'tent_60x60x160',
name: 'Premium 160cm',
description: 'Mehr Höhe für größere Pflanzen',
price: 109.99,
image: '/assets/images/nopicture.jpg',
dimensions: '60x60x160cm',
coverage: '1-2 Pflanzen',
shapeId: '60x60',
height: 160
},
// 80x80 tents
{
id: 'tent_80x80x160',
name: 'Standard 160cm',
description: 'Beliebtes Mittelklasse-Modell',
price: 129.99,
image: '/assets/images/nopicture.jpg',
dimensions: '80x80x160cm',
coverage: '2-4 Pflanzen',
shapeId: '80x80',
height: 160
},
{
id: 'tent_80x80x180',
name: 'Pro 180cm',
description: 'Extra Höhe für optimales Wachstum',
price: 149.99,
image: '/assets/images/nopicture.jpg',
dimensions: '80x80x180cm',
coverage: '2-4 Pflanzen',
shapeId: '80x80',
height: 180
},
// 100x100 tents
{
id: 'tent_100x100x180',
name: 'Professional 180cm',
description: 'Für anspruchsvolle Projekte',
price: 189.99,
image: '/assets/images/nopicture.jpg',
dimensions: '100x100x180cm',
coverage: '4-6 Pflanzen',
shapeId: '100x100',
height: 180
},
{
id: 'tent_100x100x200',
name: 'Expert 200cm',
description: 'Maximum an Wuchshöhe',
price: 219.99,
image: '/assets/images/nopicture.jpg',
dimensions: '100x100x200cm',
coverage: '4-6 Pflanzen',
shapeId: '100x100',
height: 200
},
// 120x60 tents
{
id: 'tent_120x60x160',
name: 'Rectangular 160cm',
description: 'Platzsparend und effizient',
price: 139.99,
image: '/assets/images/nopicture.jpg',
dimensions: '120x60x160cm',
coverage: '3-6 Pflanzen',
shapeId: '120x60',
height: 160
},
{
id: 'tent_120x60x180',
name: 'Rectangular Pro 180cm',
description: 'Optimale Raumausnutzung',
price: 169.99,
image: '/assets/images/nopicture.jpg',
dimensions: '120x60x180cm',
coverage: '3-6 Pflanzen',
shapeId: '120x60',
height: 180
}
];
export const lightTypes = [
{
id: 'led_quantum_board',
name: 'LED Quantum Board',
description: 'Energieeffizient, geringe Wärmeentwicklung',
price: 159.99,
image: '/assets/images/nopicture.jpg',
wattage: '240W',
coverage: 'Bis 100x100cm',
spectrum: 'Vollspektrum',
efficiency: 'Sehr hoch'
},
{
id: 'led_cob',
name: 'LED COB',
description: 'Hochintensive COB-LEDs',
price: 199.99,
image: '/assets/images/nopicture.jpg',
wattage: '300W',
coverage: 'Bis 120x120cm',
spectrum: 'Vollspektrum',
efficiency: 'Hoch'
},
{
id: 'hps_400w',
name: 'HPS 400W',
description: 'Bewährte Natriumdampflampe',
price: 89.99,
image: '/assets/images/nopicture.jpg',
wattage: '400W',
coverage: 'Bis 80x80cm',
spectrum: 'Blüte-optimiert',
efficiency: 'Mittel'
},
{
id: 'cmh_315w',
name: 'CMH 315W',
description: 'Keramik-Metallhalogenid',
price: 129.99,
image: '/assets/images/nopicture.jpg',
wattage: '315W',
coverage: 'Bis 90x90cm',
spectrum: 'Natürlich',
efficiency: 'Hoch'
}
];
export const ventilationTypes = [
{
id: 'basic_exhaust',
name: 'Basic Abluft-Set',
description: 'Lüfter + Aktivkohlefilter',
price: 79.99,
image: '/assets/images/nopicture.jpg',
airflow: '187 m³/h',
noiseLevel: '35 dB',
includes: ['Rohrventilator', 'Aktivkohlefilter', 'Aluflexrohr']
},
{
id: 'premium_ventilation',
name: 'Premium Klima-Set',
description: 'Komplette Klimakontrolle',
price: 159.99,
image: '/assets/images/nopicture.jpg',
airflow: '280 m³/h',
noiseLevel: '28 dB',
includes: ['EC-Lüfter', 'Aktivkohlefilter', 'Thermostat', 'Feuchtigkeitsmesser']
},
{
id: 'pro_climate',
name: 'Profi Klima-System',
description: 'Automatisierte Klimasteuerung',
price: 299.99,
image: '/assets/images/nopicture.jpg',
airflow: '420 m³/h',
noiseLevel: '25 dB',
includes: ['Digitaler Controller', 'EC-Lüfter', 'Aktivkohlefilter', 'Zu-/Abluft']
}
];
export const extras = [
{
id: 'ph_tester',
name: 'pH-Messgerät',
description: 'Digitales pH-Meter',
price: 29.99,
image: '/assets/images/nopicture.jpg',
category: 'Messung'
},
{
id: 'nutrients_starter',
name: 'Dünger Starter-Set',
description: 'Komplettes Nährstoff-Set',
price: 39.99,
image: '/assets/images/nopicture.jpg',
category: 'Nährstoffe'
},
{
id: 'grow_pots',
name: 'Grow-Töpfe Set (5x)',
description: '5x Stofftöpfe 11L',
price: 24.99,
image: '/assets/images/nopicture.jpg',
category: 'Töpfe'
},
{
id: 'timer_socket',
name: 'Zeitschaltuhr',
description: 'Digitale Zeitschaltuhr',
price: 19.99,
image: '/assets/images/nopicture.jpg',
category: 'Steuerung'
},
{
id: 'thermometer',
name: 'Thermo-Hygrometer',
description: 'Min/Max Temperatur & Luftfeuchtigkeit',
price: 14.99,
image: '/assets/images/nopicture.jpg',
category: 'Messung'
},
{
id: 'pruning_shears',
name: 'Gartenschere',
description: 'Präzisions-Gartenschere',
price: 16.99,
image: '/assets/images/nopicture.jpg',
category: 'Werkzeug'
}
];

View File

@@ -1,366 +1,197 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
// Note: LanguageDetector not used - we have custom detector
// Import all translation files
// Only import German translations by default
import translationDE from './locales/de/index.js';
import translationEN from './locales/en/index.js';
import translationAR from './locales/ar/translation.js';
import translationBG from './locales/bg/translation.js';
import translationCS from './locales/cs/translation.js';
import translationEL from './locales/el/translation.js';
import translationES from './locales/es/translation.js';
import translationFR from './locales/fr/translation.js';
import translationHR from './locales/hr/translation.js';
import translationHU from './locales/hu/translation.js';
import translationIT from './locales/it/translation.js';
import translationPL from './locales/pl/translation.js';
import translationRO from './locales/ro/translation.js';
import translationRU from './locales/ru/translation.js';
import translationSK from './locales/sk/translation.js';
import translationSL from './locales/sl/translation.js';
import translationSR from './locales/sr/translation.js';
import translationSV from './locales/sv/translation.js';
import translationTR from './locales/tr/translation.js';
import translationUK from './locales/uk/translation.js';
import translationZH from './locales/zh/translation.js';
// Import legal translations for all languages
// German
import legalAgbDE from './locales/de/legal-agb.js';
import legalDatenschutzDE from './locales/de/legal-datenschutz.js';
import legalAgbDeliveryDE from './locales/de/legal-agb-delivery.js';
import legalAgbPaymentDE from './locales/de/legal-agb-payment.js';
import legalAgbConsumerDE from './locales/de/legal-agb-consumer.js';
import legalDatenschutzBasicDE from './locales/de/legal-datenschutz-basic.js';
import legalDatenschutzCustomerDE from './locales/de/legal-datenschutz-customer.js';
import legalDatenschutzGoogleOrdersDE from './locales/de/legal-datenschutz-google-orders.js';
import legalDatenschutzNewsletterDE from './locales/de/legal-datenschutz-newsletter.js';
import legalDatenschutzChatbotDE from './locales/de/legal-datenschutz-chatbot.js';
import legalDatenschutzCookiesPaymentDE from './locales/de/legal-datenschutz-cookies-payment.js';
import legalDatenschutzRightsDE from './locales/de/legal-datenschutz-rights.js';
import legalImpressumDE from './locales/de/legal-impressum.js';
import legalWiderrufDE from './locales/de/legal-widerruf.js';
import legalBatterieDE from './locales/de/legal-batterie.js';
// English
import legalAgbEN from './locales/en/legal-agb.js';
import legalDatenschutzEN from './locales/en/legal-datenschutz.js';
import legalImpressumEN from './locales/en/legal-impressum.js';
import legalWiderrufEN from './locales/en/legal-widerruf.js';
import legalBatterieEN from './locales/en/legal-batterie.js';
// Language loading cache to prevent duplicate loads
const languageCache = new Set(['de']);
const loadingPromises = new Map();
// Arabic
import legalAgbAR from './locales/ar/legal-agb.js';
import legalDatenschutzAR from './locales/ar/legal-datenschutz.js';
import legalImpressumAR from './locales/ar/legal-impressum.js';
import legalWiderrufAR from './locales/ar/legal-widerruf.js';
import legalBatterieAR from './locales/ar/legal-batterie.js';
// Lazy loading function for languages
const loadLanguage = async (language) => {
if (languageCache.has(language)) {
return; // Already loaded
}
// Bulgarian
import legalAgbBG from './locales/bg/legal-agb.js';
import legalDatenschutzBG from './locales/bg/legal-datenschutz.js';
import legalImpressumBG from './locales/bg/legal-impressum.js';
import legalWiderrufBG from './locales/bg/legal-widerruf.js';
import legalBatterieBG from './locales/bg/legal-batterie.js';
if (loadingPromises.has(language)) {
return loadingPromises.get(language); // Already loading
}
// Czech
import legalAgbCS from './locales/cs/legal-agb.js';
import legalDatenschutzCS from './locales/cs/legal-datenschutz.js';
import legalImpressumCS from './locales/cs/legal-impressum.js';
import legalWiderrufCS from './locales/cs/legal-widerruf.js';
import legalBatterieCS from './locales/cs/legal-batterie.js';
const loadingPromise = (async () => {
try {
console.log(`🌍 Lazy loading language: ${language}`);
// Dynamic imports for lazy loading
const [
translation,
legalAgbDelivery,
legalAgbPayment,
legalAgbConsumer,
legalDatenschutzBasic,
legalDatenschutzCustomer,
legalDatenschutzGoogleOrders,
legalDatenschutzNewsletter,
legalDatenschutzChatbot,
legalDatenschutzCookiesPayment,
legalDatenschutzRights,
legalImpressum,
legalWiderruf,
legalBatterie
] = await Promise.all([
import(`./locales/${language}/index.js`),
import(`./locales/${language}/legal-agb-delivery.js`),
import(`./locales/${language}/legal-agb-payment.js`),
import(`./locales/${language}/legal-agb-consumer.js`),
import(`./locales/${language}/legal-datenschutz-basic.js`),
import(`./locales/${language}/legal-datenschutz-customer.js`),
import(`./locales/${language}/legal-datenschutz-google-orders.js`),
import(`./locales/${language}/legal-datenschutz-newsletter.js`),
import(`./locales/${language}/legal-datenschutz-chatbot.js`),
import(`./locales/${language}/legal-datenschutz-cookies-payment.js`),
import(`./locales/${language}/legal-datenschutz-rights.js`),
import(`./locales/${language}/legal-impressum.js`),
import(`./locales/${language}/legal-widerruf.js`),
import(`./locales/${language}/legal-batterie.js`)
]);
// Greek
import legalAgbEL from './locales/el/legal-agb.js';
import legalDatenschutzEL from './locales/el/legal-datenschutz.js';
import legalImpressumEL from './locales/el/legal-impressum.js';
import legalWiderrufEL from './locales/el/legal-widerruf.js';
import legalBatterieEL from './locales/el/legal-batterie.js';
// Add the loaded resources to i18n
i18n.addResourceBundle(language, 'translation', translation.default);
i18n.addResourceBundle(language, 'legal-agb-delivery', legalAgbDelivery.default);
i18n.addResourceBundle(language, 'legal-agb-payment', legalAgbPayment.default);
i18n.addResourceBundle(language, 'legal-agb-consumer', legalAgbConsumer.default);
i18n.addResourceBundle(language, 'legal-datenschutz-basic', legalDatenschutzBasic.default);
i18n.addResourceBundle(language, 'legal-datenschutz-customer', legalDatenschutzCustomer.default);
i18n.addResourceBundle(language, 'legal-datenschutz-google-orders', legalDatenschutzGoogleOrders.default);
i18n.addResourceBundle(language, 'legal-datenschutz-newsletter', legalDatenschutzNewsletter.default);
i18n.addResourceBundle(language, 'legal-datenschutz-chatbot', legalDatenschutzChatbot.default);
i18n.addResourceBundle(language, 'legal-datenschutz-cookies-payment', legalDatenschutzCookiesPayment.default);
i18n.addResourceBundle(language, 'legal-datenschutz-rights', legalDatenschutzRights.default);
i18n.addResourceBundle(language, 'legal-impressum', legalImpressum.default);
i18n.addResourceBundle(language, 'legal-widerruf', legalWiderruf.default);
i18n.addResourceBundle(language, 'legal-batterie', legalBatterie.default);
// Spanish
import legalAgbES from './locales/es/legal-agb.js';
import legalDatenschutzES from './locales/es/legal-datenschutz.js';
import legalImpressumES from './locales/es/legal-impressum.js';
import legalWiderrufES from './locales/es/legal-widerruf.js';
import legalBatterieES from './locales/es/legal-batterie.js';
languageCache.add(language);
console.log(`✅ Language ${language} loaded successfully`);
} catch (error) {
console.error(`❌ Failed to load language ${language}:`, error);
throw error;
} finally {
loadingPromises.delete(language);
}
})();
// French
import legalAgbFR from './locales/fr/legal-agb.js';
import legalDatenschutzFR from './locales/fr/legal-datenschutz.js';
import legalImpressumFR from './locales/fr/legal-impressum.js';
import legalWiderrufFR from './locales/fr/legal-widerruf.js';
import legalBatterieFR from './locales/fr/legal-batterie.js';
loadingPromises.set(language, loadingPromise);
return loadingPromise;
};
// Croatian
import legalAgbHR from './locales/hr/legal-agb.js';
import legalDatenschutzHR from './locales/hr/legal-datenschutz.js';
import legalImpressumHR from './locales/hr/legal-impressum.js';
import legalWiderrufHR from './locales/hr/legal-widerruf.js';
import legalBatterieHR from './locales/hr/legal-batterie.js';
// Custom language detector that prioritizes session storage and defaults to German
const customDetector = {
name: 'customDetector',
lookup() {
// Only try storage in browser environment
if (typeof window === 'undefined') {
return 'de';
}
// Hungarian
import legalAgbHU from './locales/hu/legal-agb.js';
import legalDatenschutzHU from './locales/hu/legal-datenschutz.js';
import legalImpressumHU from './locales/hu/legal-impressum.js';
import legalWiderrufHU from './locales/hu/legal-widerruf.js';
import legalBatterieHU from './locales/hu/legal-batterie.js';
// 1. Check session storage first
try {
if (typeof sessionStorage !== 'undefined') {
const sessionLang = sessionStorage.getItem('i18nextLng');
if (sessionLang && sessionLang !== 'de') {
return sessionLang;
}
}
} catch {
// Session storage not available
}
// Italian
import legalAgbIT from './locales/it/legal-agb.js';
import legalDatenschutzIT from './locales/it/legal-datenschutz.js';
import legalImpressumIT from './locales/it/legal-impressum.js';
import legalWiderrufIT from './locales/it/legal-widerruf.js';
import legalBatterieIT from './locales/it/legal-batterie.js';
// 2. Check localStorage
try {
if (typeof localStorage !== 'undefined') {
const localLang = localStorage.getItem('i18nextLng');
if (localLang && localLang !== 'de') {
return localLang;
}
}
} catch {
// LocalStorage not available
}
// Polish
import legalAgbPL from './locales/pl/legal-agb.js';
import legalDatenschutzPL from './locales/pl/legal-datenschutz.js';
import legalImpressumPL from './locales/pl/legal-impressum.js';
import legalWiderrufPL from './locales/pl/legal-widerruf.js';
import legalBatteriePL from './locales/pl/legal-batterie.js';
// 3. Always default to German (don't detect browser language)
return 'de';
},
cacheUserLanguage(lng) {
// Only cache in browser environment
if (typeof window === 'undefined') {
return;
}
// Romanian
import legalAgbRO from './locales/ro/legal-agb.js';
import legalDatenschutzRO from './locales/ro/legal-datenschutz.js';
import legalImpressumRO from './locales/ro/legal-impressum.js';
import legalWiderrufRO from './locales/ro/legal-widerruf.js';
import legalBatterieRO from './locales/ro/legal-batterie.js';
// Russian
import legalAgbRU from './locales/ru/legal-agb.js';
import legalDatenschutzRU from './locales/ru/legal-datenschutz.js';
import legalImpressumRU from './locales/ru/legal-impressum.js';
import legalWiderrufRU from './locales/ru/legal-widerruf.js';
import legalBatterieRU from './locales/ru/legal-batterie.js';
// Slovak
import legalAgbSK from './locales/sk/legal-agb.js';
import legalDatenschutzSK from './locales/sk/legal-datenschutz.js';
import legalImpressumSK from './locales/sk/legal-impressum.js';
import legalWiderrufSK from './locales/sk/legal-widerruf.js';
import legalBatterieSK from './locales/sk/legal-batterie.js';
// Slovenian
import legalAgbSL from './locales/sl/legal-agb.js';
import legalDatenschutzSL from './locales/sl/legal-datenschutz.js';
import legalImpressumSL from './locales/sl/legal-impressum.js';
import legalWiderrufSL from './locales/sl/legal-widerruf.js';
import legalBatterieSL from './locales/sl/legal-batterie.js';
// Serbian
import legalAgbSR from './locales/sr/legal-agb.js';
import legalDatenschutzSR from './locales/sr/legal-datenschutz.js';
import legalImpressumSR from './locales/sr/legal-impressum.js';
import legalWiderrufSR from './locales/sr/legal-widerruf.js';
import legalBatterieSR from './locales/sr/legal-batterie.js';
// Swedish
import legalAgbSV from './locales/sv/legal-agb.js';
import legalDatenschutzSV from './locales/sv/legal-datenschutz.js';
import legalImpressumSV from './locales/sv/legal-impressum.js';
import legalWiderrufSV from './locales/sv/legal-widerruf.js';
import legalBatterieSV from './locales/sv/legal-batterie.js';
// Turkish
import legalAgbTR from './locales/tr/legal-agb.js';
import legalDatenschutzTR from './locales/tr/legal-datenschutz.js';
import legalImpressumTR from './locales/tr/legal-impressum.js';
import legalWiderrufTR from './locales/tr/legal-widerruf.js';
import legalBatterieTR from './locales/tr/legal-batterie.js';
// Ukrainian
import legalAgbUK from './locales/uk/legal-agb.js';
import legalDatenschutzUK from './locales/uk/legal-datenschutz.js';
import legalImpressumUK from './locales/uk/legal-impressum.js';
import legalWiderrufUK from './locales/uk/legal-widerruf.js';
import legalBatterieUK from './locales/uk/legal-batterie.js';
// Chinese
import legalAgbZH from './locales/zh/legal-agb.js';
import legalDatenschutzZH from './locales/zh/legal-datenschutz.js';
import legalImpressumZH from './locales/zh/legal-impressum.js';
import legalWiderrufZH from './locales/zh/legal-widerruf.js';
import legalBatterieZH from './locales/zh/legal-batterie.js';
try {
if (typeof sessionStorage !== 'undefined') {
sessionStorage.setItem('i18nextLng', lng);
}
if (typeof localStorage !== 'undefined') {
localStorage.setItem('i18nextLng', lng);
}
} catch {
// Storage not available
}
}
};
// Initialize i18n with only German resources
const resources = {
de: {
translation: translationDE,
'legal-agb': legalAgbDE,
'legal-datenschutz': legalDatenschutzDE,
'legal-agb-delivery': legalAgbDeliveryDE,
'legal-agb-payment': legalAgbPaymentDE,
'legal-agb-consumer': legalAgbConsumerDE,
'legal-datenschutz-basic': legalDatenschutzBasicDE,
'legal-datenschutz-customer': legalDatenschutzCustomerDE,
'legal-datenschutz-google-orders': legalDatenschutzGoogleOrdersDE,
'legal-datenschutz-newsletter': legalDatenschutzNewsletterDE,
'legal-datenschutz-chatbot': legalDatenschutzChatbotDE,
'legal-datenschutz-cookies-payment': legalDatenschutzCookiesPaymentDE,
'legal-datenschutz-rights': legalDatenschutzRightsDE,
'legal-impressum': legalImpressumDE,
'legal-widerruf': legalWiderrufDE,
'legal-batterie': legalBatterieDE
},
en: {
translation: translationEN,
'legal-agb': legalAgbEN,
'legal-datenschutz': legalDatenschutzEN,
'legal-impressum': legalImpressumEN,
'legal-widerruf': legalWiderrufEN,
'legal-batterie': legalBatterieEN
},
ar: {
translation: translationAR,
'legal-agb': legalAgbAR,
'legal-datenschutz': legalDatenschutzAR,
'legal-impressum': legalImpressumAR,
'legal-widerruf': legalWiderrufAR,
'legal-batterie': legalBatterieAR
},
bg: {
translation: translationBG,
'legal-agb': legalAgbBG,
'legal-datenschutz': legalDatenschutzBG,
'legal-impressum': legalImpressumBG,
'legal-widerruf': legalWiderrufBG,
'legal-batterie': legalBatterieBG
},
cs: {
translation: translationCS,
'legal-agb': legalAgbCS,
'legal-datenschutz': legalDatenschutzCS,
'legal-impressum': legalImpressumCS,
'legal-widerruf': legalWiderrufCS,
'legal-batterie': legalBatterieCS
},
el: {
translation: translationEL,
'legal-agb': legalAgbEL,
'legal-datenschutz': legalDatenschutzEL,
'legal-impressum': legalImpressumEL,
'legal-widerruf': legalWiderrufEL,
'legal-batterie': legalBatterieEL
},
es: {
translation: translationES,
'legal-agb': legalAgbES,
'legal-datenschutz': legalDatenschutzES,
'legal-impressum': legalImpressumES,
'legal-widerruf': legalWiderrufES,
'legal-batterie': legalBatterieES
},
fr: {
translation: translationFR,
'legal-agb': legalAgbFR,
'legal-datenschutz': legalDatenschutzFR,
'legal-impressum': legalImpressumFR,
'legal-widerruf': legalWiderrufFR,
'legal-batterie': legalBatterieFR
},
hr: {
translation: translationHR,
'legal-agb': legalAgbHR,
'legal-datenschutz': legalDatenschutzHR,
'legal-impressum': legalImpressumHR,
'legal-widerruf': legalWiderrufHR,
'legal-batterie': legalBatterieHR
},
hu: {
translation: translationHU,
'legal-agb': legalAgbHU,
'legal-datenschutz': legalDatenschutzHU,
'legal-impressum': legalImpressumHU,
'legal-widerruf': legalWiderrufHU,
'legal-batterie': legalBatterieHU
},
it: {
translation: translationIT,
'legal-agb': legalAgbIT,
'legal-datenschutz': legalDatenschutzIT,
'legal-impressum': legalImpressumIT,
'legal-widerruf': legalWiderrufIT,
'legal-batterie': legalBatterieIT
},
pl: {
translation: translationPL,
'legal-agb': legalAgbPL,
'legal-datenschutz': legalDatenschutzPL,
'legal-impressum': legalImpressumPL,
'legal-widerruf': legalWiderrufPL,
'legal-batterie': legalBatteriePL
},
ro: {
translation: translationRO,
'legal-agb': legalAgbRO,
'legal-datenschutz': legalDatenschutzRO,
'legal-impressum': legalImpressumRO,
'legal-widerruf': legalWiderrufRO,
'legal-batterie': legalBatterieRO
},
ru: {
translation: translationRU,
'legal-agb': legalAgbRU,
'legal-datenschutz': legalDatenschutzRU,
'legal-impressum': legalImpressumRU,
'legal-widerruf': legalWiderrufRU,
'legal-batterie': legalBatterieRU
},
sk: {
translation: translationSK,
'legal-agb': legalAgbSK,
'legal-datenschutz': legalDatenschutzSK,
'legal-impressum': legalImpressumSK,
'legal-widerruf': legalWiderrufSK,
'legal-batterie': legalBatterieSK
},
sl: {
translation: translationSL,
'legal-agb': legalAgbSL,
'legal-datenschutz': legalDatenschutzSL,
'legal-impressum': legalImpressumSL,
'legal-widerruf': legalWiderrufSL,
'legal-batterie': legalBatterieSL
},
sr: {
translation: translationSR,
'legal-agb': legalAgbSR,
'legal-datenschutz': legalDatenschutzSR,
'legal-impressum': legalImpressumSR,
'legal-widerruf': legalWiderrufSR,
'legal-batterie': legalBatterieSR
},
sv: {
translation: translationSV,
'legal-agb': legalAgbSV,
'legal-datenschutz': legalDatenschutzSV,
'legal-impressum': legalImpressumSV,
'legal-widerruf': legalWiderrufSV,
'legal-batterie': legalBatterieSV
},
tr: {
translation: translationTR,
'legal-agb': legalAgbTR,
'legal-datenschutz': legalDatenschutzTR,
'legal-impressum': legalImpressumTR,
'legal-widerruf': legalWiderrufTR,
'legal-batterie': legalBatterieTR
},
uk: {
translation: translationUK,
'legal-agb': legalAgbUK,
'legal-datenschutz': legalDatenschutzUK,
'legal-impressum': legalImpressumUK,
'legal-widerruf': legalWiderrufUK,
'legal-batterie': legalBatterieUK
},
zh: {
translation: translationZH,
'legal-agb': legalAgbZH,
'legal-datenschutz': legalDatenschutzZH,
'legal-impressum': legalImpressumZH,
'legal-widerruf': legalWiderrufZH,
'legal-batterie': legalBatterieZH
}
};
i18n
.use(LanguageDetector)
.use({
type: 'languageDetector',
async: false,
detect: customDetector.lookup,
init() {},
cacheUserLanguage: customDetector.cacheUserLanguage
})
.use(initReactI18next)
.init({
resources,
fallbackLng: 'de', // German as fallback since it's your primary language
lng: 'de', // Default language
fallbackLng: 'de',
debug: process.env.NODE_ENV === 'development',
// Language detection options
// Disable automatic language detection from browser
detection: {
// Order of language detection methods
order: ['localStorage', 'navigator', 'htmlTag'],
// Cache the language selection
caches: ['localStorage'],
// Check for language in localStorage
lookupLocalStorage: 'i18nextLng'
order: ['customDetector'],
caches: ['localStorage', 'sessionStorage']
},
interpolation: {
@@ -373,10 +204,57 @@ i18n
// React-specific options
react: {
useSuspense: false // Disable suspense for class components compatibility
}
},
// Load missing keys as fallback
saveMissing: process.env.NODE_ENV === 'development'
});
// Export withI18n and other utilities for easy access
export { withI18n, withTranslation, withLanguage, LanguageContext, LanguageProvider } from './withTranslation.js';
// Override changeLanguage to load languages on demand
const originalChangeLanguage = i18n.changeLanguage.bind(i18n);
i18n.changeLanguage = async (language) => {
if (language !== 'de' && !languageCache.has(language)) {
try {
await loadLanguage(language);
} catch {
console.error(`Failed to load language ${language}, falling back to German`);
language = 'de';
}
}
return originalChangeLanguage(language);
};
export default i18n;
// Check session storage on initialization and load language if needed
const initializeLanguage = async () => {
// Only run in browser environment
if (typeof window === 'undefined' || typeof sessionStorage === 'undefined') {
return;
}
try {
const sessionLang = sessionStorage.getItem('i18nextLng');
if (sessionLang && sessionLang !== 'de' && !languageCache.has(sessionLang)) {
console.log(`🔄 Restoring session language: ${sessionLang}`);
await loadLanguage(sessionLang);
await i18n.changeLanguage(sessionLang);
}
} catch {
console.warn('Failed to restore session language');
}
};
// Initialize language on DOM ready (browser only)
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeLanguage);
} else {
initializeLanguage();
}
}
export default i18n;
export { loadLanguage };
// Re-export withI18n and other utilities for compatibility
export { withI18n, withTranslation, withLanguage, LanguageContext, LanguageProvider } from './withTranslation.js';

View File

@@ -5,6 +5,7 @@ export default {
"profile": "الملف الشخصي",
"email": "البريد الإلكتروني",
"password": "كلمة المرور",
"newPassword": "كلمة المرور الجديدة",
"confirmPassword": "تأكيد كلمة المرور",
"forgotPassword": "هل نسيت كلمة المرور؟",
"loginWithGoogle": "تسجيل الدخول باستخدام جوجل",
@@ -13,6 +14,7 @@ export default {
"privacyPolicy": "سياسة الخصوصية",
"passwordMinLength": "يجب أن تكون كلمة المرور 8 أحرف على الأقل",
"newPasswordMinLength": "يجب أن تكون كلمة المرور الجديدة 8 أحرف على الأقل",
"backToHome": "العودة إلى الصفحة الرئيسية",
"menu": {
"profile": "الملف الشخصي",
"myProfile": "ملفي الشخصي",
@@ -21,5 +23,28 @@ export default {
"settings": "الإعدادات",
"adminDashboard": "لوحة تحكم المسؤول",
"adminUsers": "مستخدمو المسؤول"
},
"resetPassword": {
"title": "إعادة تعيين كلمة المرور",
"button": "إعادة تعيين كلمة المرور",
"success": "تم إعادة تعيين كلمة المرور بنجاح! سيتم توجيهك لتسجيل الدخول قريبًا...",
"invalidToken": "لم يتم العثور على رمز صالح. يرجى استخدام الرابط من بريدك الإلكتروني.",
"error": "حدث خطأ أثناء إعادة تعيين كلمة المرور",
"emailSent": "تم إرسال رابط لإعادة تعيين كلمة المرور إلى بريدك الإلكتروني.",
"emailError": "حدث خطأ أثناء إرسال البريد الإلكتروني"
},
"errors": {
"fillAllFields": "يرجى ملء جميع الحقول",
"invalidEmail": "يرجى إدخال بريد إلكتروني صالح",
"passwordsNotMatch": "كلمات المرور غير متطابقة",
"passwordsNotMatchShort": "كلمات المرور غير متطابقة",
"enterEmail": "يرجى إدخال بريدك الإلكتروني",
"loginFailed": "فشل تسجيل الدخول",
"registerFailed": "فشل التسجيل",
"googleLoginFailed": "فشل تسجيل الدخول عبر جوجل",
"emailExists": "يوجد مستخدم بهذا البريد الإلكتروني بالفعل. يرجى استخدام بريد إلكتروني آخر أو تسجيل الدخول."
},
"success": {
"registerComplete": "تم التسجيل بنجاح. يمكنك الآن تسجيل الدخول."
}
};

View File

@@ -15,5 +15,6 @@ export default {
"remove": "إزالة",
"products": "منتجات",
"product": "منتج",
"days": "أيام"
"days": "أيام",
"more": "المزيد"
};

View File

@@ -16,7 +16,7 @@ export default {
"prices": {
"free": "مجاني",
"freeFrom100": "(مجاني من 100€)",
"dhl": "6.99 €",
"dhl": "5.90 €",
"dpd": "4.90 €",
"sperrgut": "28.99 €"
},
@@ -27,7 +27,7 @@ export default {
},
"selector": {
"title": "اختر طريقة الشحن",
"freeShippingInfo": "💡 شحن مجاني للطلبات فوق 100€!",
"freeShippingInfo": "💡 الشحن مجاني للطلبات فوق 100€!",
"remainingForFree": "أضف {{amount}}€ أخرى للشحن المجاني.",
"congratsFreeShipping": "🎉 مبروك! حصلت على شحن مجاني!",
"cartQualifiesFree": "سلة مشترياتك بقيمة {{amount}}€ مؤهلة للشحن المجاني."

View File

@@ -3,6 +3,7 @@ import navigation from './navigation.js';
import auth from './auth.js';
import cart from './cart.js';
import product from './product.js';
import productDialogs from './productDialogs.js';
import search from './search.js';
import sorting from './sorting.js';
import chat from './chat.js';
@@ -18,6 +19,17 @@ import pages from './pages.js';
import orders from './orders.js';
import settings from './settings.js';
import common from './common.js';
import kitConfig from './kitConfig.js';
import legalDatenschutzBasic from './legal-datenschutz-basic.js';
import legalDatenschutzCustomer from './legal-datenschutz-customer.js';
import legalDatenschutzGoogleOrders from './legal-datenschutz-google-orders.js';
import legalDatenschutzNewsletter from './legal-datenschutz-newsletter.js';
import legalDatenschutzChatbot from './legal-datenschutz-chatbot.js';
import legalDatenschutzCookiesPayment from './legal-datenschutz-cookies-payment.js';
import legalDatenschutzRights from './legal-datenschutz-rights.js';
import legalAgbDelivery from './legal-agb-delivery.js';
import legalAgbPayment from './legal-agb-payment.js';
import legalAgbConsumer from './legal-agb-consumer.js';
export default {
"locale": locale,
@@ -25,6 +37,7 @@ export default {
"auth": auth,
"cart": cart,
"product": product,
"productDialogs": productDialogs,
"search": search,
"sorting": sorting,
"chat": chat,
@@ -39,5 +52,16 @@ export default {
"pages": pages,
"orders": orders,
"settings": settings,
"common": common
"common": common,
"kitConfig": kitConfig,
"legalDatenschutzBasic": legalDatenschutzBasic,
"legalDatenschutzCustomer": legalDatenschutzCustomer,
"legalDatenschutzGoogleOrders": legalDatenschutzGoogleOrders,
"legalDatenschutzNewsletter": legalDatenschutzNewsletter,
"legalDatenschutzChatbot": legalDatenschutzChatbot,
"legalDatenschutzCookiesPayment": legalDatenschutzCookiesPayment,
"legalDatenschutzRights": legalDatenschutzRights,
"legalAgbDelivery": legalAgbDelivery,
"legalAgbPayment": legalAgbPayment,
"legalAgbConsumer": legalAgbConsumer
};

View File

@@ -0,0 +1,43 @@
export default {
"pageTitle": "🌱 مُكوّن جروبوكس",
"pageSubtitle": "ركّب إعداد النمو الداخلي المثالي بتاعك",
"bundleDiscountTitle": "🎯 احصل على خصم الباقة!",
"loadingProducts": "جارٍ تحميل منتجات الجروبوكس...",
"loadingLighting": "جارٍ تحميل منتجات الإضاءة...",
"loadingVentilation": "جارٍ تحميل منتجات التهوية...",
"loadingExtras": "جارٍ تحميل الإضافات...",
"noProductsAvailable": "لا توجد منتجات متاحة لهذا الحجم",
"noLightingAvailable": "لا توجد أضواء مناسبة لحجم الخيمة {{shape}}.",
"noVentilationAvailable": "لا توجد تهوية مناسبة لحجم الخيمة {{shape}}.",
"noExtrasAvailable": "لا توجد إضافات متاحة",
"selectShapeTitle": "1. اختر شكل الجروبوكس",
"selectShapeSubtitle": "اختار أولاً مساحة قاعدة الجروبوكس بتاعتك",
"selectProductTitle": "2. اختر منتج الجروبوكس",
"selectProductSubtitle": "اختار المنتج المناسب لجروبوكس {{shape}} بتاعك",
"selectLightingTitle": "3. اختر الإضاءة",
"selectLightingTitleShape": "3. اختر الإضاءة - {{shape}}",
"selectLightingSubtitle": "من فضلك اختار حجم الخيمة الأول.",
"selectVentilationTitle": "4. اختر التهوية",
"selectVentilationTitleShape": "4. اختر التهوية - {{shape}}",
"selectVentilationSubtitle": "من فضلك اختار حجم الخيمة الأول.",
"selectExtrasTitle": "5. أضف إضافات (اختياري)",
"yourConfiguration": "🎯 التكوين بتاعك",
"growboxLabel": "جروبوكس: {{name}}",
"lightingLabel": "الإضاءة: {{name}}",
"ventilationLabel": "التهوية: {{name}}",
"extraLabel": "إضافة: {{name}}",
"totalPrice": "السعر الكلي:",
"addToCart": "أضف إلى السلة",
"selected": "✓ تم الاختيار",
"notDeliverable": "غير متوفر للتوصيل",
"noPrice": "لا يوجد سعر",
"setName": "طقم جروبوكس - {{shape}}",
"description60x60": "مُدمج - مثالي للمساحات الصغيرة",
"description80x80": "متوسط - توازن مثالي",
"description100x100": "كبير - للمزارعين المتمرسين",
"description120x60": "مستطيل - استخدام أقصى للمساحة",
"plants1to2": "1-2 نباتات",
"plants2to4": "2-4 نباتات",
"plants4to6": "4-6 نباتات",
"plants3to6": "3-6 نباتات"
};

View File

@@ -0,0 +1,37 @@
export default {
"distanceSelling": {
"title": "معلومات وفقًا لقانون البيع عن بُعد",
"intro": "تنطبق المعلومات التالية فقط على العقود المبرمة بين Growheads والمستهلكين عن طريق طلب الكتالوج، طلب الإنترنت، أو وسائل الاتصال عن بُعد الأخرى. وهي محدودة للمستهلكين داخل الاتحاد الأوروبي.",
"sections": {
"1": {
"title": "الخصائص الأساسية للسلع",
"content": "يرجى الرجوع إلى الشروحات في الكتالوج أو على موقعنا الإلكتروني لمعرفة الخصائص الأساسية للسلع. العروض في كتالوجنا وعلى موقعنا الإلكتروني غير ملزمة. الطلبات المقدمة إلينا تُعتبر عروضًا ملزمة. يمكن لـ Growheads قبول هذه الطلبات خلال فترة 14 يومًا من استلام الطلب عن طريق إرسال تأكيد الطلب أو عن طريق شحن البضاعة."
},
"2": {
"title": "التحفظ",
"content": "إذا لم تكن جميع الأصناف المطلوبة متاحة للتسليم، نحتفظ بالحق في إجراء تسليمات جزئية، بشرط أن يكون ذلك معقولًا للعميل. قد تختلف بعض الأصناف عن الصور والوصف في الكتالوج وعلى الموقع الإلكتروني. هذا ينطبق بشكل خاص على السلع المصنوعة يدويًا. لذلك نحتفظ بالحق، إذا لزم الأمر، في تسليم سلع ذات جودة وسعر مكافئين."
},
"3": {
"title": "الأسعار والضرائب",
"content": "يمكنك العثور على أسعار الأصناف الفردية شاملة ضريبة القيمة المضافة في الكتالوج أو على موقعنا الإلكتروني. تفقد الأسعار صلاحيتها عند صدور كتالوج جديد."
},
"4": "جميع الأسعار عرضة للأخطاء أو تقلبات الأسعار. إذا حدث تغيير في السعر، يحق للمشتري ممارسة حقه في الإرجاع.",
"5": {
"title": "فترة الضمان",
"content": "تطبق فترة الضمان القانونية لمدة 24 (أربعة وعشرين) شهرًا. في بعض الحالات الفردية، قد تنطبق فترات أطول إذا منحها المصنع."
},
"6": {
"title": "حق الإرجاع / حق الانسحاب",
"content": "يتمتع العميل بحق إرجاع لمدة 14 يومًا.\nتبدأ الفترة عند استلام العميل للبضاعة ويتم احترامها بإرسال الانسحاب في الوقت المناسب إلى Growheads. تستثنى من ذلك المواد الغذائية وغيرها من السلع القابلة للتلف، وكذلك المنتجات المصممة خصيصًا أو السلع التي تم طلبها خصيصًا بناءً على طلب العميل. يجب أن يتم الإرجاع عن طريق إعادة إرسال البضاعة خلال الفترة المحددة. إذا لم يكن بالإمكان شحن البضاعة، يجب إرسال طلب الإرجاع إلينا خلال الفترة عن طريق رسالة، بطاقة بريدية، بريد إلكتروني، أو أي وسيلة دائمة أخرى. يكفي الإرسال في الوقت المناسب إلى عنوان الشركة المذكور في البند 7) للحفاظ على الموعد النهائي. لا يتطلب الانسحاب سببًا. سيتم رد ثمن الشراء وأي تكاليف توصيل وشحن بعد استلامنا للبضاعة. القيمة الحاسمة هي قيمة البضاعة المعادة وقت الشراء، وليس قيمة الطلب الكامل. عادةً ما يمكن لـ Growheads ترتيب استلام البضاعة منك."
},
"7": {
"title": "اسم وعنوان الشركة، الشكاوى، الاستدعاءات",
"content": "Growheads\nTrachenberger Straße 14\n01129 Dresden"
},
"8": {
"title": "مكان التنفيذ والاختصاص القضائي",
"content": "مكان التنفيذ والاختصاص القضائي لجميع المطالبات هو دريسدن، ما لم تنص أحكام قانونية إلزامية على خلاف ذلك."
}
}
}
};

View File

@@ -0,0 +1,20 @@
export default {
"title": "الشروط والأحكام العامة",
"deliveryShippingConditions": "شروط التسليم والشحن",
"deliveryTerms": {
"1": "يستغرق الشحن من 1 إلى 7 أيام.",
"2": "تظل البضاعة ملكًا لشركة Growheads حتى يتم استلام الدفعة كاملة.",
"3": "إذا كان هناك اشتباه في أن البضاعة قد تضررت أثناء النقل أو أن هناك عناصر مفقودة، يجب الاحتفاظ بتغليف الشحن لفحصه من قبل خبير. يجب أن يؤكد الناقل أي ضرر في التغليف على سند التسليم، مع تحديد نوع الضرر ومداه. يجب الإبلاغ عن أضرار الشحن إلى Growheads فورًا كتابيًا عبر الفاكس أو البريد الإلكتروني أو البريد. لهذا الغرض، يجب التقاط صور للبضاعة التالفة وكذلك لصندوق الشحن التالف مع ملصق العنوان. يجب أيضًا الاحتفاظ بصندوق الشحن التالف. هذه الوثائق مطلوبة للمطالبة بالتعويض من شركة النقل.",
"4": "عند إعادة البضائع المعيبة، يجب على العميل التأكد من أن البضائع معبأة بشكل صحيح.",
"5": "يجب تسجيل جميع عمليات الإرجاع مسبقًا لدى Growheads.",
"6": "يتحمل العميل مخاطر إرسال العناصر إلينا، ما لم يكن الأمر يتعلق بإرجاع بضائع معيبة.",
"7": "يحق لـ Growheads أن تطلب استلام البضاعة من خلال Deutsche Post/GLS أو شركة شحن تختارها.",
"8": "يتم حساب تكاليف البريد بناءً على الوزن. تحتفظ Growheads بحق تمرير أي زيادات في الأسعار من شركات النقل (رسوم المرور، رسوم الوقود).",
"9": "عادةً ما يتم شحن طرودنا عبر: GLS، DHL و Deutsche Post AG.",
"10": "بالنسبة للعناصر الثقيلة أو الضخمة بشكل خاص، نحتفظ بالحق في فرض رسوم إضافية على تكاليف التسليم. عادةً ما تكون هذه الرسوم مذكورة في قائمة الأسعار.",
"11": "يمكن الدفع مقدمًا عن طريق التحويل البنكي إلى الحساب المصرفي المحدد.",
"12": "إذا حدث تأخير في التسليم نتحمل مسؤوليته، فإن فترة السماح التي يحق للمشتري تحديدها محدودة بأسبوعين. تبدأ الفترة من استلام Growheads لإشعار فترة السماح.",
"13": "يجب الإبلاغ كتابيًا عن العيوب الظاهرة في البضاعة فور التسليم. إذا لم يلتزم العميل بهذا الالتزام، تُستبعد مطالبات الضمان المتعلقة بالعيوب الظاهرة.",
"14": "إذا اشتكى العميل من عيب، يجب عليه إعادة البضاعة المعيبة إلينا مع وصف دقيق للعيب قدر الإمكان. يجب إرفاق نسخة من فاتورتنا مع الشحنة. يجب إعادة البضاعة في التغليف الأصلي أو في تغليف يحمي البضاعة بنفس طريقة التغليف الأصلي، لتجنب التلف أثناء الإرجاع."
}
};

View File

@@ -0,0 +1,16 @@
export default {
"consultationLiability": {
"title": "الاستشارة والمسؤولية",
"1": "نقدم نصائح فنية تطبيقية حسب أفضل معرفتنا بناءً على مستوى خبرتنا ومعرفتنا الحالي.",
"2": "المشتري مسؤول عن الالتزام باللوائح القانونية المتعلقة بالتخزين، والنقل الإضافي، واستخدام بضائعنا.",
},
"paymentConditions": {
"title": "شروط الدفع",
"1": "تظل البضائع ملكًا لشركة Growheads حتى يتم استلام الدفعة كاملة.",
"2": "يتم دفع الفواتير مقدمًا عن طريق التحويل البنكي إلى حسابنا البنكي. إذا دفعت مقدمًا، سيتم شحن البضائع بمجرد تسجيل المبلغ في حسابنا.",
},
"retentionOfTitle": {
"title": "الاحتفاظ بالملكية",
"content": "تظل البضائع المسلمة ملكًا لشركة Growheads حتى يقوم المشتري بتسوية جميع المطالبات الموجهة ضده. إذا قام البائع بإعادة بيع البضائع، فإنه بموجب هذا يعهد إلينا بالمطالبات الناشئة عن البيع. إذا تأخر المشتري في السداد، يحق لنا في أي وقت طلب إعادة البضائع دون الانسحاب من العقد.",
}
};

View File

@@ -1,69 +0,0 @@
export default {
"title": "الشروط والأحكام العامة",
"deliveryShippingConditions": "شروط التسليم والشحن",
"deliveryTerms": {
"1": "يستغرق الشحن ما بين 1 إلى 7 أيام.",
"2": "تظل البضاعة ملكًا لشركة Growheads حتى يتم استلام الدفعة كاملة.",
"3": "إذا كان هناك اشتباه في تلف البضاعة أثناء النقل أو نقص في الأصناف، يجب الاحتفاظ بتغليف الشحن لفحصه من قبل خبير. يجب أن يؤكد الناقل أي تلف في التغليف على مذكرة التسليم مع تحديد نوع ومدى التلف. يجب الإبلاغ عن أضرار الشحن إلى Growheads فورًا كتابيًا عبر الفاكس أو البريد الإلكتروني أو البريد. لهذا الغرض، يجب التقاط صور للبضاعة التالفة وكذلك لصندوق الشحن التالف مع ملصق العنوان. يجب أيضًا الاحتفاظ بصندوق الشحن التالف. هذه الوثائق مطلوبة للمطالبة بالتعويض من شركة الشحن.",
"4": "عند إعادة البضائع المعيبة، يجب على العميل التأكد من تغليف البضائع بشكل صحيح.",
"5": "يجب تسجيل جميع المرتجعات مسبقًا لدى Growheads.",
"6": "يتحمل العميل مخاطر إرسال الأصناف إلينا، ما لم تكن إعادة البضائع المعيبة.",
"7": "يحق لـ Growheads أن تطلب استلام البضائع من خلال Deutsche Post/GLS أو ناقل من اختيارها.",
"8": "يتم حساب تكاليف البريد بناءً على الوزن. تحتفظ Growheads بحق تمرير أي زيادات في الأسعار من شركات الشحن (رسوم المرور، رسوم الوقود).",
"9": "عادةً ما يتم شحن طرودنا عبر: GLS، DHL و Deutsche Post AG.",
"10": "بالنسبة للأصناف الثقيلة أو الضخمة بشكل خاص، نحتفظ بحق فرض رسوم إضافية على تكاليف التسليم. عادةً ما تكون هذه الرسوم مذكورة في قائمة الأسعار.",
"11": "يمكن الدفع مقدمًا عن طريق التحويل البنكي إلى الحساب المصرفي المحدد.",
"12": "إذا حدث تأخير في التسليم نتحمل مسؤوليته، فإن فترة السماح التي يحق للمشتري تحديدها محدودة بأسبوعين. تبدأ الفترة من استلام Growheads لإشعار فترة السماح.",
"13": "يجب الإبلاغ كتابيًا عن العيوب الظاهرة في البضاعة فور التسليم. إذا لم يفعل العميل ذلك، تُستبعد مطالبات الضمان عن العيوب الظاهرة.",
"14": "إذا اشتكى العميل من عيب، يجب عليه إعادة البضاعة المعيبة إلينا مع وصف دقيق للعيب. يجب إرفاق نسخة من فاتورتنا مع الشحنة. يجب إعادة البضاعة في التغليف الأصلي أو في تغليف يحمي البضاعة بنفس طريقة التغليف الأصلي لتجنب التلف أثناء النقل العكسي."
},
"consultationLiability": {
"title": "الاستشارة والمسؤولية",
"1": "نقدم استشارات فنية تطبيقية بأفضل ما لدينا من معرفة بناءً على خبرتنا ومهاراتنا.",
"2": "المشتري مسؤول عن الالتزام باللوائح القانونية المتعلقة بالتخزين والنقل والاستخدام للبضائع."
},
"paymentConditions": {
"title": "شروط الدفع",
"1": "تظل البضاعة ملكًا لشركة Growheads حتى يتم استلام الدفعة كاملة.",
"2": "تُدفع الفواتير مقدمًا عن طريق التحويل البنكي إلى حسابنا. إذا دفعت مقدمًا، سيتم شحن البضاعة بمجرد تسجيل المبلغ في حسابنا."
},
"retentionOfTitle": {
"title": "الاحتفاظ بالملكية",
"content": "تظل البضاعة المسلمة ملكًا لشركة Growheads حتى يسدد المشتري جميع المطالبات الموجهة إليه. إذا قام البائع بإعادة بيع البضاعة، فإنه يتنازل بموجب هذا عن المطالبات الناشئة من البيع لنا. إذا تأخر المشتري في دفعاته، يمكننا في أي وقت طلب إعادة البضاعة دون الانسحاب من العقد."
},
"distanceSelling": {
"title": "معلومات وفقًا لقانون البيع عن بعد",
"intro": "تنطبق المعلومات التالية فقط على العقود المبرمة بين Growheads والمستهلكين عن طريق طلب الكتالوج أو الإنترنت أو وسائل الاتصال عن بعد الأخرى. وهي محدودة للمستهلكين داخل الاتحاد الأوروبي.",
"sections": {
"1": {
"title": "الخصائص الأساسية للبضاعة",
"content": "يرجى الرجوع إلى الشروحات في الكتالوج أو على موقعنا الإلكتروني لمعرفة الخصائص الأساسية للبضاعة. العروض في كتالوجنا وعلى موقعنا غير ملزمة. الطلبات المقدمة إلينا تعتبر عروضًا ملزمة. يمكن لـ Growheads قبولها خلال 14 يومًا من استلام الطلب عن طريق إرسال تأكيد الطلب أو شحن البضاعة."
},
"2": {
"title": "التحفظ",
"content": "إذا لم تكن جميع الأصناف المطلوبة متوفرة للتسليم، نحتفظ بحق التسليم الجزئي إذا كان ذلك معقولًا للعميل. قد تختلف بعض الأصناف عن الصور والوصف في الكتالوج والموقع الإلكتروني، خاصةً البضائع المصنوعة يدويًا. لذلك نحتفظ بحق تسليم بضائع ذات جودة وسعر معادل إذا لزم الأمر."
},
"3": {
"title": "الأسعار والضرائب",
"content": "يمكنك العثور على أسعار الأصناف الفردية شاملة ضريبة القيمة المضافة في الكتالوج أو على موقعنا. تصبح الأسعار غير صالحة عند صدور كتالوج جديد."
},
"4": "جميع الأسعار عرضة للأخطاء أو تقلبات الأسعار. إذا حدث تغيير في السعر، يحق للمشتري ممارسة حقه في الإرجاع.",
"5": {
"title": "مدة الضمان",
"content": "تطبق فترة الضمان القانونية لمدة 24 (أربعة وعشرين) شهرًا. في حالات فردية، قد تنطبق فترات أطول إذا منحها المصنع."
},
"6": {
"title": "حق الإرجاع / حق الانسحاب",
"content": "يتمتع العميل بحق إرجاع لمدة 14 يومًا.\nتبدأ الفترة من استلام العميل للبضاعة وتُعتبر محفوظة بإرسال الانسحاب في الوقت المناسب إلى Growheads. تستثنى من ذلك الأغذية والسلع القابلة للتلف، وكذلك المنتجات المصممة خصيصًا أو البضائع التي تم طلبها بناءً على رغبة العميل. يجب أن يتم الإرجاع عن طريق إعادة البضاعة خلال الفترة. إذا لم يكن بالإمكان شحن البضاعة، يجب إرسال طلب الإرجاع إلينا خلال الفترة عن طريق رسالة، بطاقة بريدية، بريد إلكتروني، أو وسيلة دائمة أخرى. يكفي الإرسال في الوقت المناسب إلى العنوان المذكور تحت 7) للحفاظ على المهلة. لا يتطلب الانسحاب سببًا. سيتم رد ثمن الشراء وأي تكاليف تسليم وشحن بعد استلامنا للبضاعة. القيمة الحاسمة هي قيمة البضاعة المعادة وقت الشراء، وليس قيمة الطلب الكامل. عادةً ما يمكن لـ Growheads ترتيب الاستلام منك."
},
"7": {
"title": "اسم وعنوان الشركة، الشكاوى، الاستدعاءات",
"content": "Growheads\nTrachenberger Straße 14\n01129 Dresden"
},
"8": {
"title": "مكان التنفيذ والاختصاص القضائي",
"content": "مكان التنفيذ والاختصاص القضائي لجميع المطالبات هو دريسدن، ما لم تنص أحكام قانونية إلزامية على خلاف ذلك."
}
}
}
};

View File

@@ -1,8 +1,8 @@
export default {
"title": "معلومات قانون البطاريات",
"intro": "فيما يتعلق ببيع البطاريات أو تسليم الأجهزة التي تحتوي على بطاريات، نحن ملزمون بإبلاغكم بما يلي:",
"returnObligation": "بصفتك مستخدم نهائي، أنت ملزم قانونيًا بإعادة البطاريات المستخدمة. يمكنك إعادة البطاريات القديمة التي نمتلكها أو التي كانت ضمن مجموعتنا كبطاريات جديدة مجانًا إلى مستودع الشحن الخاص بنا (عنوان الشحن).",
"returnObligation": "بصفتك مستخدم نهائي، أنت ملزم قانونيًا بإرجاع البطاريات المستخدمة. يمكنك إرجاع البطاريات القديمة التي نمتلكها أو التي كانت ضمن مجموعتنا كبطاريات جديدة مجانًا إلى مستودع الشحن الخاص بنا (عنوان الشحن).",
"symbolsInfo": "الرموز المعروضة على البطاريات تعني ما يلي:",
"wasteSymbol": "رمز سلة المهملات المعلمة بعلامة إلغاء يعني أنه لا يجوز التخلص من البطارية مع النفايات المنزلية.",
"chemicalSymbols": "Pb = البطارية تحتوي على أكثر من 0.004 بالمئة بالوزن من الرصاص\nCd = البطارية تحتوي على أكثر من 0.002 بالمئة بالوزن من الكادميوم\nHg = البطارية تحتوي على أكثر من 0.0005 بالمئة بالوزن من الزئبق."
"chemicalSymbols": "Pb = البطارية تحتوي على أكثر من 0.004 بالمئة رصاص بالوزن\nCd = البطارية تحتوي على أكثر من 0.002 بالمئة كادميوم بالوزن\nHg = البطارية تحتوي على أكثر من 0.0005 بالمئة زئبق بالوزن."
};

View File

@@ -0,0 +1,18 @@
export default {
"title": "سياسة الخصوصية",
"responsibleParty": {
"title": "الجهة المسؤولة بموجب قانون حماية البيانات:",
"company": "Growheads\nTrachenberger Straße 14\n01129 Dresden"
},
"generalInfo": "ما لم يُذكر خلاف ذلك أدناه، فإن تقديم بياناتك الشخصية ليس مطلوبًا قانونيًا أو تعاقديًا، ولا هو ضروري لإبرام عقد. أنت غير ملزم بتقديم البيانات. عدم تقديمها لن يترتب عليه أي عواقب. هذا ينطبق فقط طالما لم يتم الإشارة إلى خلاف ذلك في عمليات المعالجة التالية. \"البيانات الشخصية\" تعني جميع المعلومات المتعلقة بشخص طبيعي محدد أو يمكن تحديده.",
"sections": {
"informationDeletion": {
"title": "المعلومات، الحذف، الحظر",
"content": "يمكنك في أي وقت طلب معلومات عن بياناتك الشخصية، مصدرها والمستلمين لها، وهدف معالجة البيانات، كما يمكنك طلب تصحيح أو حظر أو حذف هذه البيانات مجانًا. يرجى استخدام خيارات الاتصال الموجودة في تذييل الصفحة أو في الإشعار القانوني (Impressum) لهذا الغرض. نحن متاحون أيضًا في أي وقت لأي أسئلة إضافية حول الموضوع. يرجى ملاحظة أننا غير مخولين ولن نقوم بحذف بيانات الفواتير، البيانات البنكية، والبيانات التي تم إرسالها إلى مزود خدمة الشحن. البيانات التي يمكن حذفها تشمل: حسابات العملاء على خادم الويب، وكذلك في نظام إدارة البضائع، والبريد الإلكتروني الذي لا يرتبط مباشرة بطلب.",
},
"serverLogfiles": {
"title": "ملفات سجل الخادم",
"content": "يمكنك زيارة مواقعنا الإلكترونية دون تقديم أي معلومات عن نفسك. في كل مرة تصل فيها إلى موقعنا، يتم إرسال بيانات الاستخدام من متصفح الإنترنت الخاص بك وتخزينها في ملفات السجل (ملفات سجل الخادم). تشمل هذه البيانات المخزنة، على سبيل المثال، اسم الصفحة التي تم الوصول إليها، تاريخ ووقت الوصول، كمية البيانات المنقولة، ومزود الخدمة الذي طلب الوصول. تُستخدم هذه البيانات فقط لضمان التشغيل السلس لموقعنا وتحسين عرضنا. هذه البيانات ليست بيانات شخصية. لا يتم دمج هذه البيانات مع مصادر بيانات أخرى. إذا علمنا بوجود مؤشرات محددة على استخدام غير قانوني، نحتفظ بالحق في فحص هذه البيانات لاحقًا."
}
}
};

View File

@@ -0,0 +1,12 @@
export default {
"sections": {
"chatbot": {
"title": "استخدام روبوت دردشة ذكي (OpenAI API)",
"content": "نحن نستخدم روبوت دردشة مدعوم بالذكاء الاصطناعي على موقعنا الإلكتروني، والذي يتم تشغيله عبر واجهة برمجة التطبيقات (API) لمزود الخدمة OpenAI. يهدف روبوت الدردشة إلى الرد على استفسارات الزوار بكفاءة وبشكل تلقائي، وبالتالي توفير وظيفة دعم. عند استخدامك لروبوت الدردشة، يتم معالجة مدخلاتك بواسطة النظام لتوليد ردود مناسبة. تتم المعالجة بشكل مجهول لا يتم جمع أو تخزين عناوين IP أو أي بيانات شخصية أخرى (مثل الاسم أو بيانات الاتصال).",
"legalBasis": "الأساس القانوني لاستخدام روبوت الدردشة هو مصلحتنا المشروعة وفقًا للمادة 6 الفقرة 1 الحرف f من DSGVO. تكمن هذه المصلحة في توفير دعم فعال للزوار وكذلك تحسين تجربة المستخدم على موقعنا الإلكتروني.",
"dataRecipient": "المستلم لبيانات الدردشة هو OpenAI (OpenAI OpCo, LLC) كمزود خدمة فني. تقوم OpenAI بمعالجة محتوى الدردشة المرسل على خوادمها حصريًا لغرض توليد الردود. تعمل OpenAI كمعالج بيانات وفقًا للمادة 28 من DSGVO ولا تستخدم البيانات لأغراضها الخاصة. لقد أبرمنا عقد معالجة بيانات مع OpenAI، والذي يتضمن بنود العقد النموذجية للاتحاد الأوروبي كضمانات مناسبة لحماية البيانات. يقع المقر الرئيسي لـ OpenAI في الولايات المتحدة الأمريكية؛ ومن خلال الموافقة على بنود العقد النموذجية، يتم ضمان مستوى حماية بيانات يعادل مستوى الاتحاد الأوروبي عند نقل بياناتك.",
"dataRetention": "نحتفظ باستفسارات الدردشة الخاصة بك فقط طالما كان ذلك ضروريًا للمعالجة والرد. بمجرد الانتهاء من طلبك، يتم حذف سجلات الدردشة أو إخفاء هويتها على الفور. وفقًا لتصريحاتها الخاصة، تحتفظ OpenAI ببيانات الدردشة المعالجة مؤقتًا فقط وتحذفها تلقائيًا بعد مدة أقصاها 30 يومًا.",
"voluntaryUse": "استخدام روبوت الدردشة اختياري. إذا لم تستخدم روبوت الدردشة، فلن يتم نقل أي بيانات إلى OpenAI. يرجى عدم إدخال أي بيانات شخصية حساسة في الدردشة."
}
}
};

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