Compare commits

...

125 Commits

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

2
.gitignore vendored
View File

@@ -56,6 +56,8 @@ yarn-error.log*
# Local configuration
src/config.local.js
taxonomy-with-ids.de-DE*
# Local development notes
dev-notes.md
dev-notes.local.md

21
.vscode/launch.json vendored
View File

@@ -3,20 +3,31 @@
// This will install dependencies before starting the dev server
"version": "0.2.0",
"configurations": [
{
"type": "node-terminal",
"name": "Start with API propxy to seedheads.de (Install Deps)",
"request": "launch",
"command": "npm run start:seedheads",
"preLaunchTask": "npm: install",
"cwd": "${workspaceFolder}"
}, {
"type": "node-terminal",
"env": {
"NODE_ENV": "development"
},
"envFile": "${workspaceFolder}/.env",
"skipFiles": [
"<node_internals>/**"
]
},{
"name": "Start",
"type": "node-terminal",
"request": "launch",
"command": "npm run start",
"cwd": "${workspaceFolder}"
"env": {
"NODE_ENV": "development"
},
"envFile": "${workspaceFolder}/.env",
"skipFiles": [
"<node_internals>/**"
]
}
]
}

View File

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

View File

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

1734
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,14 @@
"lint": "eslint src/**/*.{js,jsx}",
"prerender": "node prerender.cjs",
"prerender:prod": "cross-env NODE_ENV=production node prerender.cjs",
"build:prerender": "npm run build:client && npm run prerender:prod"
"prerender:product": "node prerender-single-product.cjs",
"prerender:product:prod": "cross-env NODE_ENV=production node prerender-single-product.cjs",
"build:prerender": "npm run build:client && npm run prerender:prod",
"translate": "node translate-i18n.js",
"translate:english": "node translate-i18n.js --only-english",
"translate:skip-english": "node translate-i18n.js --skip-english",
"translate:others": "node translate-i18n.js --skip-english",
"validate:products": "node scripts/validate-products-xml.cjs"
},
"keywords": [],
"author": "",
@@ -27,10 +34,15 @@
"@stripe/react-stripe-js": "^3.7.0",
"@stripe/stripe-js": "^7.3.1",
"chart.js": "^4.5.0",
"country-flag-icons": "^1.5.19",
"html-react-parser": "^5.2.5",
"i18next": "^25.3.2",
"i18next-browser-languagedetector": "^8.2.0",
"openai": "^4.0.0",
"react": "^19.1.0",
"react-chartjs-2": "^5.3.0",
"react-dom": "^19.1.0",
"react-i18next": "^15.6.0",
"react-router-dom": "^7.6.2",
"sharp": "^0.34.2",
"socket.io-client": "^4.7.5"
@@ -64,6 +76,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

@@ -27,6 +27,74 @@ const io = require("socket.io-client");
const os = require("os");
const { Worker, isMainThread, parentPort, workerData } = require("worker_threads");
// Initialize i18n for prerendering with German as default
const i18n = require("i18next");
const { initReactI18next } = require("react-i18next");
// Import all translation files
const translationDE = require("./src/i18n/locales/de/translation.js").default;
const translationEN = require("./src/i18n/locales/en/translation.js").default;
const translationAR = require("./src/i18n/locales/ar/translation.js").default;
const translationBG = require("./src/i18n/locales/bg/translation.js").default;
const translationCS = require("./src/i18n/locales/cs/translation.js").default;
const translationEL = require("./src/i18n/locales/el/translation.js").default;
const translationES = require("./src/i18n/locales/es/translation.js").default;
const translationFR = require("./src/i18n/locales/fr/translation.js").default;
const translationHR = require("./src/i18n/locales/hr/translation.js").default;
const translationHU = require("./src/i18n/locales/hu/translation.js").default;
const translationIT = require("./src/i18n/locales/it/translation.js").default;
const translationPL = require("./src/i18n/locales/pl/translation.js").default;
const translationRO = require("./src/i18n/locales/ro/translation.js").default;
const translationRU = require("./src/i18n/locales/ru/translation.js").default;
const translationSK = require("./src/i18n/locales/sk/translation.js").default;
const translationSL = require("./src/i18n/locales/sl/translation.js").default;
const translationSR = require("./src/i18n/locales/sr/translation.js").default;
const translationSV = require("./src/i18n/locales/sv/translation.js").default;
const translationTR = require("./src/i18n/locales/tr/translation.js").default;
const translationUK = require("./src/i18n/locales/uk/translation.js").default;
const translationZH = require("./src/i18n/locales/zh/translation.js").default;
// Initialize i18n for prerendering
i18n
.use(initReactI18next)
.init({
resources: {
de: { translation: translationDE },
en: { translation: translationEN },
ar: { translation: translationAR },
bg: { translation: translationBG },
cs: { translation: translationCS },
el: { translation: translationEL },
es: { translation: translationES },
fr: { translation: translationFR },
hr: { translation: translationHR },
hu: { translation: translationHU },
it: { translation: translationIT },
pl: { translation: translationPL },
ro: { translation: translationRO },
ru: { translation: translationRU },
sk: { translation: translationSK },
sl: { translation: translationSL },
sr: { translation: translationSR },
sv: { translation: translationSV },
tr: { translation: translationTR },
uk: { translation: translationUK },
zh: { translation: translationZH }
},
lng: 'de', // Default to German for prerendering
fallbackLng: 'de',
debug: false,
interpolation: {
escapeValue: false
},
react: {
useSuspense: false
}
});
// Make i18n available globally for components
global.i18n = i18n;
// Import split modules
const config = require("./prerender/config.cjs");
@@ -35,7 +103,6 @@ const shopConfig = require("./src/config.js").default;
const { renderPage } = require("./prerender/renderer.cjs");
const {
collectAllCategories,
writeCombinedCssFile,
} = require("./prerender/utils.cjs");
const {
generateProductMetaTags,
@@ -81,7 +148,7 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback,
const socketUrl = "http://127.0.0.1:9303";
const workerSocket = io(socketUrl, {
path: "/socket.io/",
transports: ["polling", "websocket"],
transports: ["websocket"],
reconnection: false,
timeout: 10000,
});
@@ -107,6 +174,7 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback,
const actualSeoName = productDetails.product.seoName || productSeoName;
const productComponent = React.createElement(PrerenderProduct, {
productData: productDetails,
t: global.i18n.t.bind(global.i18n),
});
const filename = `Artikel/${actualSeoName}`;
@@ -133,7 +201,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) {
@@ -572,8 +641,7 @@ const renderApp = async (categoryData, socket) => {
);
}
// Write the combined CSS file after all pages are rendered
writeCombinedCssFile(config.globalCssCollection, config.outputDir);
// No longer writing combined CSS file - each page has its own embedded CSS
// Generate XML sitemap with all rendered pages
console.log("\n🗺 Generating XML sitemap...");
@@ -630,6 +698,26 @@ const renderApp = async (categoryData, socket) => {
console.log(` - File verification: ⚠️ ${verifyError.message}`);
}
// Validate XML against Google Shopping schema
try {
const ProductsXmlValidator = require('./scripts/validate-products-xml.cjs');
const validator = new ProductsXmlValidator(productsXmlPath);
const validationResults = await validator.validate();
if (validationResults.valid) {
console.log(` - Schema validation: ✅ Valid Google Shopping RSS 2.0`);
} else {
console.log(` - Schema validation: ⚠️ ${validationResults.summary.errorCount} errors, ${validationResults.summary.warningCount} warnings`);
// Show first few errors for quick debugging
if (validationResults.errors.length > 0) {
console.log(` - First error: ${validationResults.errors[0].message}`);
}
}
} catch (validationError) {
console.log(` - Schema validation: ⚠️ Validation failed: ${validationError.message}`);
}
} catch (error) {
console.error(`❌ Error generating products.xml: ${error.message}`);
console.log("\n⚠ Skipping products.xml generation due to errors");
@@ -721,7 +809,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

@@ -187,7 +187,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));

View File

@@ -17,7 +17,8 @@ const renderPage = (
metaTags = "",
needsRouter = false,
config,
suppressLogs = false
suppressLogs = false,
productData = null
) => {
const {
isProduction,
@@ -26,7 +27,7 @@ const renderPage = (
globalCssCollection,
webpackEntrypoints,
} = config;
const { writeCombinedCssFile, optimizeCss } = require("./utils.cjs");
const { optimizeCss } = require("./utils.cjs");
// @note Set prerender fallback flag in global environment for CategoryBox during SSR
if (typeof global !== "undefined" && global.window) {
@@ -51,26 +52,20 @@ const renderPage = (
);
let renderedMarkup;
let pageSpecificCss = ""; // Declare outside try block for broader scope
try {
renderedMarkup = ReactDOMServer.renderToString(pageElement);
const emotionChunks = extractCriticalToChunks(renderedMarkup);
// Collect CSS from this page
// Collect CSS from this page for direct inlining (no global accumulation)
if (emotionChunks.styles.length > 0) {
const oldSize = globalCssCollection.size;
emotionChunks.styles.forEach((style) => {
if (style.css) {
globalCssCollection.add(style.css);
pageSpecificCss += style.css + "\n";
}
});
// Check if new styles were added
if (globalCssCollection.size > oldSize) {
// Write CSS file immediately when new styles are added
writeCombinedCssFile(globalCssCollection, outputDir);
}
if (!suppressLogs) console.log(` - CSS rules: ${emotionChunks.styles.length}`);
}
} catch (error) {
console.error(`❌ Rendering failed for ${filename}:`, error);
@@ -126,26 +121,12 @@ const renderPage = (
}
});
// Read and inline prerender CSS to eliminate render-blocking request
try {
const prerenderCssPath = path.resolve(__dirname, "..", outputDir, "prerender.css");
if (fs.existsSync(prerenderCssPath)) {
const prerenderCssContent = fs.readFileSync(prerenderCssPath, "utf8");
// Use advanced CSS optimization
const optimizedPrerenderCss = optimizeCss(prerenderCssContent);
inlinedCss += optimizedPrerenderCss;
if (!suppressLogs) console.log(` ✅ Inlined prerender CSS (${Math.round(optimizedPrerenderCss.length / 1024)}KB)`);
} else {
// Fallback to external loading if prerender.css doesn't exist yet
additionalTags += `<link rel="preload" href="/prerender.css" as="style" onload="this.onload=null;this.rel='stylesheet'">`;
additionalTags += `<noscript><link rel="stylesheet" href="/prerender.css"></noscript>`;
if (!suppressLogs) console.log(` ⚠️ prerender.css not found for inlining, using async loading`);
}
} catch (error) {
// Fallback to external loading
additionalTags += `<link rel="preload" href="/prerender.css" as="style" onload="this.onload=null;this.rel='stylesheet'">`;
additionalTags += `<noscript><link rel="stylesheet" href="/prerender.css"></noscript>`;
if (!suppressLogs) console.log(` ⚠️ Error reading prerender.css: ${error.message}, using async loading`);
// Inline page-specific CSS directly (no shared prerender.css file)
if (pageSpecificCss.trim()) {
// Use advanced CSS optimization on page-specific CSS
const optimizedPageCss = optimizeCss(pageSpecificCss);
inlinedCss += optimizedPageCss;
if (!suppressLogs) console.log(` ✅ Inlined page-specific CSS (${Math.round(optimizedPageCss.length / 1024)}KB)`);
}
// Add JavaScript files
@@ -182,6 +163,11 @@ const renderPage = (
content: ${JSON.stringify(renderedMarkup)},
timestamp: ${Date.now()}
};
// DEBUG: Multiple alerts throughout the loading process
// Debug alerts removed
</script>
`;
@@ -203,6 +189,22 @@ const renderPage = (
`;
}
// 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.)
const productDetailCacheData = JSON.stringify(productData);
productDetailCacheScript = `
<script>
// Populate window.productDetailCache with complete product data for SPA hydration
if (!window.productDetailCache) {
window.productDetailCache = {};
}
window.productDetailCache['${productData.product.seoName}'] = ${productDetailCacheData};
</script>
`;
}
// Combine all CSS (global + inlined) into a single optimized style tag
const combinedCss = globalCss + (inlinedCss ? '\n' + inlinedCss : '');
const combinedCssTag = combinedCss ? `<style type="text/css">${combinedCss}</style>` : '';
@@ -214,7 +216,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 +224,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}`);
}
@@ -244,6 +248,9 @@ const renderPage = (
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

@@ -11,6 +11,102 @@ Crawl-delay: 0
return robotsTxt;
};
// Helper function to determine unit pricing data based on product data
const determineUnitPricingData = (product) => {
const result = {
unit_pricing_measure: null,
unit_pricing_base_measure: null
};
// Unit mapping from German to Google Shopping accepted units
const unitMapping = {
// Volume (German -> Google)
'Milliliter': 'ml',
'milliliter': 'ml',
'ml': 'ml',
'Liter': 'l',
'liter': 'l',
'l': 'l',
'Zentiliter': 'cl',
'zentiliter': 'cl',
'cl': 'cl',
// Weight (German -> Google)
'Gramm': 'g',
'gramm': 'g',
'g': 'g',
'Kilogramm': 'kg',
'kilogramm': 'kg',
'kg': 'kg',
'Milligramm': 'mg',
'milligramm': 'mg',
'mg': 'mg',
// Length (German -> Google)
'Meter': 'm',
'meter': 'm',
'm': 'm',
'Zentimeter': 'cm',
'zentimeter': 'cm',
'cm': 'cm',
// Count (German -> Google)
'Stück': 'ct',
'stück': 'ct',
'Stk': 'ct',
'stk': 'ct',
'ct': 'ct',
'Blatt': 'sheet',
'blatt': 'sheet',
'sheet': 'sheet'
};
// Helper function to convert German unit to Google Shopping unit
const convertUnit = (unit) => {
if (!unit) return null;
const trimmedUnit = unit.trim();
return unitMapping[trimmedUnit] || trimmedUnit.toLowerCase();
};
// unit_pricing_measure: The quantity unit of the product as it's sold
if (product.fEinheitMenge && product.cEinheit) {
const amount = parseFloat(product.fEinheitMenge);
const unit = convertUnit(product.cEinheit);
if (amount > 0 && unit) {
result.unit_pricing_measure = `${amount} ${unit}`;
}
}
// unit_pricing_base_measure: The base quantity unit for unit pricing
if (product.cGrundEinheit && product.cGrundEinheit.trim()) {
const baseUnit = convertUnit(product.cGrundEinheit);
if (baseUnit) {
// Base measure usually needs a quantity (like 100g, 1l, etc.)
// If it's just a unit, we'll add a default quantity
if (baseUnit.match(/^[a-z]+$/)) {
// For weight/volume units, use standard base quantities
if (['g', 'kg', 'mg'].includes(baseUnit)) {
result.unit_pricing_base_measure = baseUnit === 'kg' ? '1 kg' : '100 g';
} else if (['ml', 'l', 'cl'].includes(baseUnit)) {
result.unit_pricing_base_measure = baseUnit === 'l' ? '1 l' : '100 ml';
} else if (['m', 'cm'].includes(baseUnit)) {
result.unit_pricing_base_measure = baseUnit === 'm' ? '1 m' : '100 cm';
} else {
result.unit_pricing_base_measure = `1 ${baseUnit}`;
}
} else {
result.unit_pricing_base_measure = baseUnit;
}
}
}
return result;
};
const fs = require('fs');
const path = require('path');
const generateProductsXml = (allProductsData = [], baseUrl, config) => {
const currentDate = new Date().toISOString();
@@ -23,124 +119,131 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
const getGoogleProductCategory = (categoryId) => {
const categoryMappings = {
// Seeds & Plants
689: "Home & Garden > Plants > Seeds",
706: "Home & Garden > Plants", // Stecklinge (cuttings)
376: "Home & Garden > Plants > Plant & Herb Growing Kits", // Grow-Sets
689: "543561", // Seeds (Saatgut)
706: "543561", // Stecklinge (cuttings) ebenfalls Pflanzen/Saatgut
376: "2802", // Grow-Sets Pflanzen- & Kräuteranbausets
// Headshop & Accessories
709: "Arts & Entertainment > Hobbies & Creative Arts", // Headshop
711: "Arts & Entertainment > Hobbies & Creative Arts", // Bongs
714: "Arts & Entertainment > Hobbies & Creative Arts", // Zubehör
748: "Arts & Entertainment > Hobbies & Creative Arts", // Köpfe
749: "Arts & Entertainment > Hobbies & Creative Arts", // Chillums / Diffusoren / Kupplungen
896: "Electronics > Electronics Accessories", // Vaporizer
710: "Home & Garden > Kitchen & Dining > Kitchen Tools & Utensils", // Grinder
709: "4082", // Headshop Rauchzubehör
711: "4082", // Headshop > Bongs Rauchzubehör
714: "4082", // Headshop > Bongs > Zubehör Rauchzubehör
748: "4082", // Headshop > Bongs > Köpfe Rauchzubehör
749: "4082", // Headshop > Bongs > Chillums/Diffusoren/Kupplungen Rauchzubehör
896: "3151", // Headshop > Vaporizer Vaporizer
710: "5109", // Headshop > Grinder Gewürzmühlen (Küchenhelfer)
// Measuring & Packaging
186: "Business & Industrial", // Wiegen & Verpacken
187: "Business & Industrial > Science & Laboratory > Lab Equipment", // Waagen
346: "Home & Garden > Kitchen & Dining > Food Storage", // Vakuumbeutel
355: "Home & Garden > Kitchen & Dining > Food Storage", // Boveda & Integra Boost
407: "Home & Garden > Kitchen & Dining > Food Storage", // Grove Bags
449: "Home & Garden > Kitchen & Dining > Food Storage", // Cliptütchen
539: "Home & Garden > Kitchen & Dining > Food Storage", // Gläser & Dosen
186: "5631", // Headshop > Wiegen & Verpacken Aufbewahrung/Zubehör
187: "4767", // Headshop > Waagen Personenwaagen (Medizinisch)
346: "7118", // Headshop > Vakuumbeutel Vakuumierer-Beutel
355: "606", // Headshop > Boveda & Integra Boost Luftentfeuchter (nächstmögliche)
407: "3561", // Headshop > Grove Bags Aufbewahrungsbehälter
449: "1496", // Headshop > Cliptütchen Lebensmittelverpackungsmaterial
539: "3110", // Headshop > Gläser & Dosen Lebensmittelbehälter
// Lighting & Equipment
694: "Home & Garden > Lighting", // Lampen
261: "Home & Garden > Lighting", // Lampenzubehör
694: "3006", // Lampen Lampen (Beleuchtung)
261: "3006", // Zubehör > Lampenzubehör Lampen
// Plants & Growing
691: "Home & Garden > Lawn & Garden > Fertilizers", // Dünger
692: "Home & Garden > Lawn & Garden > Fertilizers", // Dünger - Zubehör
693: "Sporting Goods > Outdoor Recreation > Camping & Hiking > Tents", // Zelte
691: "500033", // Dünger Dünger
692: "5633", // Zubehör > Dünger-Zubehör Zubehör für Gartenarbeit
693: "5655", // Zelte Zelte
// Pots & Containers
219: "Home & Garden > Decor > Planters & Pots", // Töpfe
220: "Home & Garden > Decor > Planters & Pots", // Untersetzer
301: "Home & Garden > Decor > Planters & Pots", // Stofftöpfe
317: "Home & Garden > Decor > Planters & Pots", // Air-Pot
364: "Home & Garden > Decor > Planters & Pots", // Kunststofftöpfe
292: "Home & Garden > Decor > Planters & Pots", // Trays & Fluttische
219: "113", // Töpfe Blumentöpfe & Pflanzgefäße
220: "3173", // Töpfe > Untersetzer Gartentopfuntersetzer und Trays
301: "113", // Töpfe > Stofftöpfe (Blumentöpfe/Pflanzgefäße)
317: "113", // Töpfe > Air-Pot (Blumentöpfe/Pflanzgefäße)
364: "113", // Töpfe > Kunststofftöpfe (Blumentöpfe/Pflanzgefäße)
292: "3568", // Bewässerung > Trays & Fluttische Bewässerungssysteme
// Ventilation & Climate
703: "Home & Garden > Outdoor Power Tools", // Abluft-Sets
247: "Home & Garden > Outdoor Power Tools", // Belüftung
214: "Home & Garden > Outdoor Power Tools", // Umluft-Ventilatoren
308: "Home & Garden > Outdoor Power Tools", // Ab- und Zuluft
609: "Home & Garden > Outdoor Power Tools", // Schalldämpfer
248: "Home & Garden > Pool & Spa > Pool & Spa Filters", // Aktivkohlefilter
392: "Home & Garden > Pool & Spa > Pool & Spa Filters", // Zuluftfilter
658: "Home & Garden > Climate Control > Dehumidifiers", // Luftbe- und entfeuchter
310: "Home & Garden > Climate Control > Heating", // Heizmatten
379: "Home & Garden > Household Supplies > Air Fresheners", // Geruchsneutralisation
703: "2802", // Grow-Sets > Abluft-Sets (verwendet Pflanzen-Kräuter-Anbausets)
247: "1700", // Belüftung Ventilatoren (Klimatisierung)
214: "1700", // Belüftung > Umluft-Ventilatoren Ventilatoren
308: "1700", // Belüftung > Ab- und Zuluft Ventilatoren
609: "1700", // Belüftung > Ab- und Zuluft > Schalldämpfer Ventilatoren
248: "1700", // Belüftung > Aktivkohlefilter Ventilatoren (nächstmögliche)
392: "1700", // Belüftung > Ab- und Zuluft > Zuluftfilter Ventilatoren
658: "606", // Belüftung > Luftbe- und -entfeuchter Luftentfeuchter
310: "2802", // Anzucht > Heizmatten Pflanzen- & Kräuteranbausets
379: "5631", // Belüftung > Geruchsneutralisation Haushaltsbedarf: Aufbewahrung
// Irrigation & Watering
221: "Home & Garden > Lawn & Garden > Watering Equipment", // Bewässerung
250: "Home & Garden > Lawn & Garden > Watering Equipment", // Schläuche
297: "Home & Garden > Lawn & Garden > Watering Equipment", // Pumpen
354: "Home & Garden > Lawn & Garden > Watering Equipment", // Sprüher
372: "Home & Garden > Lawn & Garden > Watering Equipment", // AutoPot
389: "Home & Garden > Lawn & Garden > Watering Equipment", // Blumat
405: "Home & Garden > Lawn & Garden > Watering Equipment", // Schläuche
425: "Home & Garden > Lawn & Garden > Watering Equipment", // Wassertanks
480: "Home & Garden > Lawn & Garden > Watering Equipment", // Tropfer
519: "Home & Garden > Lawn & Garden > Watering Equipment", // Pumpsprüher
221: "3568", // Bewässerung Bewässerungssysteme (Gesamt)
250: "6318", // Bewässerung > Schläuche Gartenschläuche
297: "500100", // Bewässerung > Pumpen Bewässerung-/Sprinklerpumpen
354: "3780", // Bewässerung > Sprüher Sprinkler & Sprühköpfe
372: "3568", // Bewässerung > AutoPot Bewässerungssysteme
389: "3568", // Bewässerung > Blumat Bewässerungssysteme
405: "6318", // Bewässerung > Schläuche Gartenschläuche
425: "3568", // Bewässerung > Wassertanks Bewässerungssysteme
480: "3568", // Bewässerung > Tropfer Bewässerungssysteme
519: "3568", // Bewässerung > Pumpsprüher Bewässerungssysteme
// Growing Media & Soils
242: "Home & Garden > Lawn & Garden > Fertilizers", // Böden
243: "Home & Garden > Lawn & Garden > Fertilizers", // Erde
269: "Home & Garden > Lawn & Garden > Fertilizers", // Kokos
580: "Home & Garden > Lawn & Garden > Fertilizers", // Perlite & Blähton
242: "543677", // Böden Gartenerde
243: "543677", // Böden > Erde Gartenerde
269: "543677", // Böden > Kokos Gartenerde
580: "543677", // Böden > Perlite & Blähton Gartenerde
// Propagation & Starting
286: "Home & Garden > Plants", // Anzucht
298: "Home & Garden > Plants", // Steinwolltrays
421: "Home & Garden > Plants", // Vermehrungszubehör
489: "Home & Garden > Plants", // EazyPlug & Jiffy
359: "Home & Garden > Outdoor Structures > Greenhouses", // Gewächshäuser
286: "2802", // Anzucht Pflanzen- & Kräuteranbausets
298: "2802", // Anzucht > Steinwolltrays Pflanzen- & Kräuteranbausets
421: "2802", // Anzucht > Vermehrungszubehör Pflanzen- & Kräuteranbausets
489: "2802", // Anzucht > EazyPlug & Jiffy Pflanzen- & Kräuteranbausets
359: "3103", // Anzucht > Gewächshäuser Gewächshäuser
// Tools & Equipment
373: "Home & Garden > Tools > Hand Tools", // GrowTool
403: "Home & Garden > Kitchen & Dining > Kitchen Tools & Utensils", // Messbecher & mehr
259: "Home & Garden > Tools > Hand Tools", // Pressen
280: "Home & Garden > Tools > Hand Tools", // Erntescheeren
258: "Home & Garden > Tools", // Ernte & Verarbeitung
278: "Home & Garden > Tools", // Extraktion
302: "Home & Garden > Tools", // Erntemaschinen
373: "3568", // Bewässerung > GrowTool Bewässerungssysteme
403: "3999", // Bewässerung > Messbecher & mehr Messbecher & Dosierlöffel
259: "756", // Zubehör > Ernte & Verarbeitung > Pressen Nudelmaschinen
280: "2948", // Zubehör > Ernte & Verarbeitung > Erntescheeren Küchenmesser
258: "684", // Zubehör > Ernte & Verarbeitung Abfallzerkleinerer
278: "5057", // Zubehör > Ernte & Verarbeitung > Extraktion Slush-Eis-Maschinen
302: "7332", // Zubehör > Ernte & Verarbeitung > Erntemaschinen Gartenmaschinen
// Hardware & Plumbing
222: "Hardware > Plumbing", // PE-Teile
374: "Hardware > Plumbing > Plumbing Fittings", // Verbindungsteile
222: "3568", // Bewässerung > PE-Teile Bewässerungssysteme
374: "1700", // Belüftung > Ab- und Zuluft > Verbindungsteile Ventilatoren
// Electronics & Control
314: "Electronics > Electronics Accessories", // Steuergeräte
408: "Electronics > Electronics Accessories", // GrowControl
344: "Business & Industrial > Science & Laboratory > Lab Equipment", // Messgeräte
555: "Business & Industrial > Science & Laboratory > Lab Equipment > Microscopes", // Mikroskope
314: "1700", // Belüftung > Steuergeräte Ventilatoren
408: "1700", // Belüftung > Steuergeräte > GrowControl Ventilatoren
344: "1207", // Zubehör > Messgeräte Messwerkzeuge & Messwertgeber
555: "4555", // Zubehör > Anbauzubehör > Mikroskope Mikroskope
// Camping & Outdoor
226: "Sporting Goods > Outdoor Recreation > Camping & Hiking", // Zeltzubehör
226: "5655", // Zubehör > Zeltzubehör Zelte
// Plant Care & Protection
239: "Home & Garden > Lawn & Garden > Pest Control", // Pflanzenschutz
240: "Home & Garden > Plants", // Anbauzubehör
239: "4085", // Zubehör > Anbauzubehör > Pflanzenschutz Herbizide
240: "5633", // Zubehör > Anbauzubehör Zubehör für Gartenarbeit
// Office & Media
424: "Office Supplies > Labels", // Etiketten & Schilder
387: "Media > Books", // Literatur
424: "4377", // Zubehör > Anbauzubehör > Etiketten & Schilder Etiketten & Anhängerschilder
387: "543541", // Zubehör > Anbauzubehör > Literatur Bücher
// General categories
705: "Home & Garden", // Set-Konfigurator
686: "Home & Garden", // Zubehör
741: "Home & Garden", // Zubehör
294: "Home & Garden", // Zubehör
695: "Home & Garden", // Zubehör
293: "Home & Garden", // Trockennetze
4: "Home & Garden", // Sonstiges
450: "Home & Garden", // Restposten
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
695: "5631", // Zubehör Haushaltsbedarf: Aufbewahrung
293: "5631", // Zubehör > Ernte & Verarbeitung > Trockennetze Haushaltsbedarf: Aufbewahrung
4: "5631", // Zubehör > Anbauzubehör > Sonstiges Haushaltsbedarf: Aufbewahrung
450: "5631", // Zubehör > Anbauzubehör > Restposten Haushaltsbedarf: Aufbewahrung
};
return categoryMappings[categoryId] || "Home & Garden > Plants";
const categoryId_str = categoryMappings[categoryId] || "5631"; // Default to Haushaltsbedarf: Aufbewahrung
// Validate that the category ID is not empty
if (!categoryId_str || categoryId_str.trim() === "") {
return "5631"; // Haushaltsbedarf: Aufbewahrung
}
return categoryId_str;
};
let productsXml = `<?xml version="1.0" encoding="UTF-8"?>
@@ -150,7 +253,7 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
<link>${baseUrl}</link>
<description>${config.descriptions.short}</description>
<lastBuildDate>${currentDate}</lastBuildDate>
<language>${config.language}</language>`;
<language>de-DE</language>`;
// Helper function to clean text content of problematic characters
const cleanTextContent = (text) => {
@@ -197,6 +300,10 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
let processedCount = 0;
let skippedCount = 0;
// Track products with missing data for logging
const productsNeedingWeight = [];
const productsNeedingDescription = [];
// Category IDs to skip (seeds, plants, headshop items)
const skipCategoryIds = [689, 706, 709, 711, 714, 748, 749, 896, 710];
@@ -216,23 +323,96 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
return;
}
// Skip products without GTIN
// Skip products with excluded terms in title or description
const productTitle = (product.name || "").toLowerCase();
const productDescription = (product.description || "").toLowerCase();
const excludedTerms = {
title: ['canna', 'hash', 'marijuana', 'marihuana'],
description: ['cannabis']
};
// Check title for excluded terms
if (excludedTerms.title.some(term => productTitle.includes(term))) {
skippedCount++;
return;
}
// Check description for excluded terms
if (excludedTerms.description.some(term => productDescription.includes(term))) {
skippedCount++;
return;
}
// Skip products without GTIN or with invalid GTIN
if (!product.gtin || !product.gtin.toString().trim()) {
skippedCount++;
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;
for (let i = 0; i < length - 1; i++) {
// Even/odd multiplier depends on GTIN length
let multiplier = 1;
if (length === 8) {
multiplier = (i % 2 === 0) ? 3 : 1;
} else {
multiplier = ((length - i) % 2 === 0) ? 3 : 1;
}
sum += digits[i] * multiplier;
}
const checkDigit = (10 - (sum % 10)) % 10;
return checkDigit === digits[length - 1];
};
if (!isValidGTIN(gtinString)) {
skippedCount++;
return;
}
// Skip products without pictures
if (!product.pictureList || !product.pictureList.trim()) {
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'}`;
// Check if product has weight data - validate BEFORE building XML
if (!product.weight || isNaN(product.weight)) {
// Track products without weight
productsNeedingWeight.push({
id: product.articleNumber || product.seoName,
name: product.name || 'Unnamed',
url: `/Artikel/${product.seoName}`
});
skippedCount++;
return;
}
// Check if description is missing or too short (less than 20 characters) - skip if insufficient
const originalDescription = product.description ? cleanTextContent(product.description) : '';
if (!originalDescription || originalDescription.length < 20) {
productsNeedingDescription.push({
id: product.articleNumber || product.seoName,
name: product.name || 'Unnamed',
currentDescription: originalDescription || 'NONE',
url: `/Artikel/${product.seoName}`
});
skippedCount++;
return;
}
// Clean description for feed (remove HTML tags and limit length)
const rawDescription = cleanTextContent(product.description).substring(0, 500);
const cleanDescription = escapeXml(rawDescription) || "Produktbeschreibung nicht verfügbar";
// Clean product name
@@ -263,6 +443,12 @@ 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++;
return;
}
// Generate price (ensure it's a valid number)
const price = product.price && !isNaN(product.price)
? `${parseFloat(product.price).toFixed(2)} ${config.currency}`
@@ -274,8 +460,8 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
return;
}
// Generate GTIN/EAN if available
const gtin = product.gtin ? escapeXml(product.gtin.toString().trim()) : null;
// Generate GTIN/EAN if available (use the already validated gtinString)
const gtin = gtinString ? escapeXml(gtinString) : null;
// Generate product ID (using articleNumber or seoName)
const rawProductId = product.articleNumber || product.seoName || `product_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`;
@@ -286,7 +472,7 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
const googleCategory = getGoogleProductCategory(categoryId);
const escapedGoogleCategory = escapeXml(googleCategory);
// Build item XML with proper formatting
// Build item XML with proper formatting (all validation passed, safe to write XML)
productsXml += `
<item>
<g:id>${productId}</g:id>
@@ -312,10 +498,21 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
<g:gtin>${gtin}</g:gtin>`;
}
// Add weight if available
if (product.weight && !isNaN(product.weight)) {
// Add weight (we know it exists at this point since we validated it earlier)
// Convert from kg to grams (multiply by 1000)
const weightInGrams = parseFloat(product.weight) * 1000;
productsXml += `
<g:shipping_weight>${parseFloat(product.weight).toFixed(2)} g</g:shipping_weight>`;
<g:shipping_weight>${weightInGrams.toFixed(2)} g</g:shipping_weight>`;
// Add unit pricing data (required by German law for many products)
const unitPricingData = determineUnitPricingData(product);
if (unitPricingData.unit_pricing_measure) {
productsXml += `
<g:unit_pricing_measure>${unitPricingData.unit_pricing_measure}</g:unit_pricing_measure>`;
}
if (unitPricingData.unit_pricing_base_measure) {
productsXml += `
<g:unit_pricing_base_measure>${unitPricingData.unit_pricing_base_measure}</g:unit_pricing_base_measure>`;
}
productsXml += `
@@ -335,6 +532,47 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
console.log(` 📊 Processing summary: ${processedCount} products included, ${skippedCount} skipped`);
// 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 missing weight log
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(`\n ⚠️ Products missing weight (${productsNeedingWeight.length}) - saved to: ${weightLogPath}`);
}
// Write missing description log
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(`\n ⚠️ 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,5 +1,6 @@
const generateProductMetaTags = (product, baseUrl, config) => {
const productUrl = `${baseUrl}/Artikel/${product.seoName}`;
const imageUrl =
product.pictureList && product.pictureList.trim()
? `${baseUrl}/assets/images/prod${product.pictureList

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

View File

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

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

@@ -14,23 +14,26 @@ 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 SocketProvider from "./providers/SocketProvider.js";
import SocketContext from "./contexts/SocketContext.js";
import { CarouselProvider } from "./contexts/CarouselContext.js";
import config from "./config.js";
import ScrollToTop from "./components/ScrollToTop.js";
// Import i18n
import './i18n/index.js';
import { LanguageProvider } from './i18n/withTranslation.js';
import i18n from './i18n/index.js';
//import TelemetryService from './services/telemetryService.js';
import Header from "./components/Header.js";
import Footer from "./components/Footer.js";
import Home from "./pages/Home.js";
import MainPageLayout from "./components/MainPageLayout.js";
// Lazy load all route components to reduce initial bundle size
const Content = lazy(() => import(/* webpackChunkName: "content" */ "./components/Content.js"));
const ProductDetailWithSocket = lazy(() => import(/* webpackChunkName: "product-detail" */ "./components/ProductDetailWithSocket.js"));
const ProfilePageWithSocket = lazy(() => import(/* webpackChunkName: "profile" */ "./pages/ProfilePage.js"));
const ResetPassword = lazy(() => import(/* webpackChunkName: "reset-password" */ "./pages/ResetPassword.js"));
// TEMPORARILY DISABLE ALL LAZY LOADING TO ELIMINATE CircularProgress
import Content from "./components/Content.js";
import ProductDetail from "./components/ProductDetail.js";
import ProfilePage from "./pages/ProfilePage.js";
import ResetPassword from "./pages/ResetPassword.js";
// Lazy load admin pages - only loaded when admin users access them
const AdminPage = lazy(() => import(/* webpackChunkName: "admin" */ "./pages/AdminPage.js"));
@@ -40,7 +43,7 @@ const ServerLogsPage = lazy(() => import(/* webpackChunkName: "admin-logs" */ ".
// Lazy load legal pages - rarely accessed
const Datenschutz = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Datenschutz.js"));
const AGB = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/AGB.js"));
const NotFound404 = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/NotFound404.js"));
//const NotFound404 = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/NotFound404.js")); <Route path="/404" element={<NotFound404 />} />
const Sitemap = lazy(() => import(/* webpackChunkName: "sitemap" */ "./pages/Sitemap.js"));
const Impressum = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Impressum.js"));
const Batteriegesetzhinweise = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Batteriegesetzhinweise.js"));
@@ -50,6 +53,13 @@ const Widerrufsrecht = lazy(() => import(/* webpackChunkName: "legal" */ "./page
const GrowTentKonfigurator = lazy(() => import(/* webpackChunkName: "konfigurator" */ "./pages/GrowTentKonfigurator.js"));
const ChatAssistant = lazy(() => import(/* webpackChunkName: "chat" */ "./components/ChatAssistant.js"));
// Lazy load separate pages that are truly different
const PresseverleihPage = lazy(() => import(/* webpackChunkName: "presseverleih" */ "./pages/PresseverleihPage.js"));
const ThcTestPage = lazy(() => import(/* webpackChunkName: "thc-test" */ "./pages/ThcTestPage.js"));
// Lazy load payment success page
const PaymentSuccess = lazy(() => import(/* webpackChunkName: "payment" */ "./components/PaymentSuccess.js"));
// Import theme from separate file to reduce main bundle size
import defaultTheme from "./theme.js";
// Lazy load theme customizer for development only
@@ -61,26 +71,6 @@ const deleteMessages = () => {
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 }) => {
// State to manage chat visibility
@@ -94,11 +84,15 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
const navigate = useNavigate();
useEffect(() => {
if (location.hash && location.hash.startsWith("#ORD-")) {
if (location.hash && location.hash.length > 1) {
// Check if it's a potential order ID (starts with # and has alphanumeric characters with dashes)
const potentialOrderId = location.hash.substring(1);
if (/^[A-Z0-9]+-[A-Z0-9]+$/i.test(potentialOrderId)) {
if (location.pathname !== "/profile") {
navigate(`/profile${location.hash}`, { replace: true });
}
}
}
}, [location, navigate]);
useEffect(() => {
@@ -139,35 +133,11 @@ 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);
return (
<Box
sx={{
@@ -180,10 +150,17 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
}}
>
<ScrollToTop />
<TelemetryInitializer socket={socket} />
<Header active categoryId={categoryId} key={authVersion} />
<Box sx={{ flexGrow: 1 }}>
<Suspense fallback={
// Use prerender fallback if available, otherwise show loading spinner
typeof window !== "undefined" && window.__PRERENDER_FALLBACK__ ? (
<div
dangerouslySetInnerHTML={{
__html: window.__PRERENDER_FALLBACK__.content,
}}
/>
) : (
<Box
sx={{
display: "flex",
@@ -194,47 +171,53 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
>
<CircularProgress color="primary" />
</Box>
)
}>
<CarouselProvider>
<Routes>
{/* Home page with text only */}
<Route path="/" element={<Home />} />
{/* Main pages using unified component */}
<Route path="/" element={<MainPageLayout />} />
<Route path="/aktionen" element={<MainPageLayout />} />
<Route path="/filiale" element={<MainPageLayout />} />
{/* Category page - Render Content in parallel */}
<Route
path="/Kategorie/:categoryId"
element={<Content socket={socket} socketB={socketB} />}
element={<Content/>}
/>
{/* Single product page */}
<Route
path="/Artikel/:seoName"
element={<ProductDetailWithSocket />}
element={<ProductDetail/>}
/>
{/* Search page - Render Content in parallel */}
<Route path="/search" element={<Content socket={socket} socketB={socketB} />} />
<Route path="/search" element={<Content/>} />
{/* Profile page */}
<Route path="/profile" element={<ProfilePageWithSocket />} />
<Route path="/profile" element={<ProfilePage/>} />
{/* Payment success page for Mollie redirects */}
<Route path="/payment/success" element={<PaymentSuccess />} />
{/* Reset password page */}
<Route
path="/resetPassword"
element={<ResetPassword socket={socket} socketB={socketB} />}
element={<ResetPassword/>}
/>
{/* Admin page */}
<Route path="/admin" element={<AdminPage socket={socket} socketB={socketB} />} />
<Route path="/admin" element={<AdminPage/>} />
{/* Admin Users page */}
<Route path="/admin/users" element={<UsersPage socket={socket} socketB={socketB} />} />
<Route path="/admin/users" element={<UsersPage/>} />
{/* Admin Server Logs page */}
<Route path="/admin/logs" element={<ServerLogsPage socket={socket} socketB={socketB} />} />
<Route path="/admin/logs" element={<ServerLogsPage/>} />
{/* Legal pages */}
<Route path="/datenschutz" element={<Datenschutz />} />
<Route path="/agb" element={<AGB />} />
<Route path="/404" element={<NotFound404 />} />
<Route path="/sitemap" element={<Sitemap />} />
<Route path="/impressum" element={<Impressum />} />
<Route
@@ -244,20 +227,34 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
<Route path="/widerrufsrecht" element={<Widerrufsrecht />} />
{/* Grow Tent Configurator */}
<Route path="/Konfigurator" element={<GrowTentKonfigurator />} />
<Route path="/Konfigurator" element={<GrowTentKonfigurator/>} />
{/* Separate pages that are truly different */}
<Route path="/presseverleih" element={<PresseverleihPage />} />
<Route path="/thc-test" element={<ThcTestPage />} />
{/* Fallback for undefined routes */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</CarouselProvider>
</Suspense>
</Box>
{/* Conditionally render the Chat Assistant */}
{isChatOpen && (
<Suspense fallback={<CircularProgress size={20} />}>
<Suspense fallback={
typeof window !== "undefined" && window.__PRERENDER_FALLBACK__ ? (
<div
dangerouslySetInnerHTML={{
__html: window.__PRERENDER_FALLBACK__.content,
}}
/>
) : (
<CircularProgress size={20} />
)
}>
<ChatAssistant
open={isChatOpen}
onClose={handleChatClose}
socket={socket}
/>
</Suspense>
)}
@@ -279,7 +276,7 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
</Fab>
</Tooltip>
{/* GitHub Issue Reporter FAB */}
{/* GitHub Issue Reporter FAB
<Tooltip title="Fehler oder Problem melden" placement="left">
<Fab
color="error"
@@ -294,7 +291,7 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
>
<BugReportIcon sx={{ fontSize: "1.2rem" }} />
</Fab>
</Tooltip>
</Tooltip>*/}
{/* Development-only Theme Customizer FAB */}
{isDevelopment && (
@@ -317,7 +314,17 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
{/* Development-only Theme Customizer Dialog */}
{isDevelopment && isThemeCustomizerOpen && (
<Suspense fallback={<CircularProgress size={20} />}>
<Suspense fallback={
typeof window !== "undefined" && window.__PRERENDER_FALLBACK__ ? (
<div
dangerouslySetInnerHTML={{
__html: window.__PRERENDER_FALLBACK__.content,
}}
/>
) : (
<CircularProgress size={20} />
)
}>
<ThemeCustomizerDialog
open={isThemeCustomizerOpen}
onClose={() => setThemeCustomizerOpen(false)}
@@ -343,30 +350,21 @@ const App = () => {
setDynamicTheme(createTheme(newTheme));
};
// Make config globally available for language switching
useEffect(() => {
window.shopConfig = config;
}, []);
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>
</ThemeProvider>
</LanguageProvider>
);
};

View File

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

View File

@@ -7,7 +7,8 @@ const {
} = require('@mui/material');
const Footer = require('./components/Footer.js').default;
const { Logo, CategoryList } = require('./components/header/index.js');
const Home = require('./pages/Home.js').default;
const MainPageLayout = require('./components/MainPageLayout.js').default;
const { CarouselProvider } = require('./contexts/CarouselContext.js');
class PrerenderHome extends React.Component {
render() {
@@ -62,7 +63,7 @@ class PrerenderHome extends React.Component {
React.createElement(
Box,
{ sx: { flexGrow: 1 } },
React.createElement(Home)
React.createElement(CarouselProvider, null, React.createElement(MainPageLayout))
),
React.createElement(Footer)
);

View File

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

92
src/PrerenderNotFound.js Normal file
View File

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

View File

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

View File

@@ -10,6 +10,7 @@ import AddIcon from "@mui/icons-material/Add";
import RemoveIcon from "@mui/icons-material/Remove";
import ShoppingCartIcon from "@mui/icons-material/ShoppingCart";
import DeleteIcon from "@mui/icons-material/Delete";
import { withI18n } from "../i18n/withTranslation.js";
if (!Array.isArray(window.cart)) window.cart = [];
@@ -51,11 +52,14 @@ class AddToCartButton extends Component {
seoName: this.props.seoName,
pictureList: this.props.pictureList,
price: this.props.price,
fGrundPreis: this.props.fGrundPreis,
cGrundEinheit: this.props.cGrundEinheit,
quantity: 1,
weight: this.props.weight,
vat: this.props.vat,
versandklasse: this.props.versandklasse,
availableSupplier: this.props.availableSupplier,
komponenten: this.props.komponenten,
available: this.props.available
});
} else {
@@ -150,12 +154,17 @@ class AddToCartButton extends Component {
},
}}
>
Ab{" "}
{new Date(incoming).toLocaleDateString("de-DE", {
{this.props.t ? this.props.t('cart.availableFrom', {
date: new Date(incoming).toLocaleDateString("de-DE", {
year: "numeric",
month: "long",
day: "numeric",
})}
})
}) : `Ab ${new Date(incoming).toLocaleDateString("de-DE", {
year: "numeric",
month: "long",
day: "numeric",
})}`}
</Button>
);
}
@@ -181,7 +190,9 @@ class AddToCartButton extends Component {
},
}}
>
{this.props.steckling ? "Als Steckling vorbestellen" : "In den Korb"}
{this.props.steckling ?
(this.props.t ? this.props.t('cart.preorderCutting') : "Als Steckling vorbestellen") :
(this.props.t ? this.props.t('cart.addToCart') : "In den Korb")}
</Button>
);
}
@@ -205,6 +216,7 @@ class AddToCartButton extends Component {
<IconButton
color="inherit"
onClick={this.handleDecrement}
aria-label="Menge verringern"
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
>
<RemoveIcon />
@@ -254,15 +266,17 @@ class AddToCartButton extends Component {
<IconButton
color="inherit"
onClick={this.handleIncrement}
aria-label="Menge erhöhen"
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
>
<AddIcon />
</IconButton>
<Tooltip title="Aus dem Warenkorb entfernen" arrow>
<Tooltip title={this.props.t ? this.props.t('cart.removeFromCart') : 'Aus dem Warenkorb entfernen'} arrow>
<IconButton
color="inherit"
onClick={this.handleClearCart}
aria-label={this.props.t ? this.props.t('cart.removeFromCart') : 'Aus dem Warenkorb entfernen'}
sx={{
borderRadius: 0,
"&:hover": { color: "error.light" },
@@ -272,10 +286,11 @@ class AddToCartButton extends Component {
</IconButton>
</Tooltip>
{this.props.cartButton && (
<Tooltip title="Warenkorb öffnen" arrow>
<Tooltip title={this.props.t ? this.props.t('cart.openCart') : 'Warenkorb öffnen'} arrow>
<IconButton
color="inherit"
onClick={this.toggleCart}
aria-label={this.props.t ? this.props.t('cart.openCart') : 'Warenkorb öffnen'}
sx={{
borderRadius: 0,
"&:hover": { color: "primary.light" },
@@ -302,7 +317,7 @@ class AddToCartButton extends Component {
fontWeight: "bold",
}}
>
Out of Stock
{this.props.t ? this.props.t('product.outOfStock') : 'Out of Stock'}
</Button>
);
}
@@ -327,7 +342,9 @@ class AddToCartButton extends Component {
},
}}
>
{this.props.steckling ? "Als Steckling vorbestellen" : "In den Korb"}
{this.props.steckling ?
(this.props.t ? this.props.t('cart.preorderCutting') : "Als Steckling vorbestellen") :
(this.props.t ? this.props.t('cart.addToCart') : "In den Korb")}
</Button>
);
}
@@ -350,6 +367,7 @@ class AddToCartButton extends Component {
<IconButton
color="inherit"
onClick={this.handleDecrement}
aria-label="Menge verringern"
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
>
<RemoveIcon />
@@ -399,15 +417,17 @@ class AddToCartButton extends Component {
<IconButton
color="inherit"
onClick={this.handleIncrement}
aria-label="Menge erhöhen"
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
>
<AddIcon />
</IconButton>
<Tooltip title="Aus dem Warenkorb entfernen" arrow>
<Tooltip title={this.props.t ? this.props.t('cart.removeFromCart') : 'Aus dem Warenkorb entfernen'} arrow>
<IconButton
color="inherit"
onClick={this.handleClearCart}
aria-label={this.props.t ? this.props.t('cart.removeFromCart') : 'Aus dem Warenkorb entfernen'}
sx={{
borderRadius: 0,
"&:hover": { color: "error.light" },
@@ -417,10 +437,11 @@ class AddToCartButton extends Component {
</IconButton>
</Tooltip>
{this.props.cartButton && (
<Tooltip title="Warenkorb öffnen" arrow>
<Tooltip title={this.props.t ? this.props.t('cart.openCart') : 'Warenkorb öffnen'} arrow>
<IconButton
color="inherit"
onClick={this.toggleCart}
aria-label={this.props.t ? this.props.t('cart.openCart') : 'Warenkorb öffnen'}
sx={{
borderRadius: 0,
"&:hover": { color: "primary.light" },
@@ -436,4 +457,4 @@ class AddToCartButton extends Component {
}
}
export default AddToCartButton;
export default withI18n()(AddToCartButton);

View File

@@ -0,0 +1,242 @@
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);
if (this.props.socket) {
this.props.socket.emit('availability_inquiry_submit', availabilityData);
// Set up response handler
this.props.socket.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 || 'Ein Fehler ist aufgetreten'
});
}
// 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;
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' }}>
Verfügbarkeit anfragen
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Dieser Artikel ist derzeit nicht verfügbar. Gerne informieren wir Sie, sobald er wieder lieferbar ist.
</Typography>
{success && (
<Alert severity="success" sx={{ mb: 3 }}>
Vielen Dank für Ihre Anfrage! Wir werden Sie {notificationMethod === 'email' ? 'per E-Mail' : 'über Telegram'} informieren, sobald der Artikel wieder verfügbar ist.
</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="Name"
value={name}
onChange={this.handleInputChange('name')}
required
fullWidth
disabled={loading}
placeholder="Ihr Name"
/>
<FormControl component="fieldset" disabled={loading}>
<FormLabel component="legend" sx={{ mb: 1 }}>
Wie möchten Sie benachrichtigt werden?
</FormLabel>
<RadioGroup
value={notificationMethod}
onChange={this.handleNotificationMethodChange}
row
>
<FormControlLabel
value="email"
control={<Radio />}
label="E-Mail"
/>
<FormControlLabel
value="telegram"
control={<Radio />}
label="Telegram Bot"
/>
</RadioGroup>
</FormControl>
{notificationMethod === 'email' && (
<TextField
label="E-Mail"
type="email"
value={email}
onChange={this.handleInputChange('email')}
required
fullWidth
disabled={loading}
placeholder="ihre.email@example.com"
/>
)}
{notificationMethod === 'telegram' && (
<TextField
label="Telegram ID"
value={telegramId}
onChange={this.handleInputChange('telegramId')}
required
fullWidth
disabled={loading}
placeholder="@IhrTelegramName oder Telegram ID"
helperText="Geben Sie Ihren Telegram-Benutzernamen (mit @) oder Ihre Telegram-ID ein"
/>
)}
<TextField
label="Nachricht (optional)"
value={message}
onChange={this.handleInputChange('message')}
fullWidth
multiline
rows={3}
disabled={loading}
placeholder="Zusätzliche Informationen oder Fragen..."
/>
<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 }} />
Wird gesendet...
</>
) : (
'Verfügbarkeit anfragen'
)}
</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);
if (this.props.socket) {
this.props.socket.emit('article_question_submit', questionData);
// Set up response handler
this.props.socket.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 || 'Ein Fehler ist aufgetreten'
});
}
// Clear messages after 3 seconds
setTimeout(() => {
this.setState({ success: false, error: null });
}, 3000);
});
}
} catch {
this.setState({
loading: false,
error: 'Fehler beim Verarbeiten der Fotos'
});
}
// 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;
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' }}>
Frage zum Artikel
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Haben Sie eine Frage zu diesem Artikel? Wir helfen Ihnen gerne weiter.
</Typography>
{success && (
<Alert severity="success" sx={{ mb: 3 }}>
Vielen Dank für Ihre Frage! Wir werden uns schnellstmöglich bei Ihnen melden.
</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="Name"
value={name}
onChange={this.handleInputChange('name')}
required
fullWidth
disabled={loading}
placeholder="Ihr Name"
/>
<TextField
label="E-Mail"
type="email"
value={email}
onChange={this.handleInputChange('email')}
required
fullWidth
disabled={loading}
placeholder="ihre.email@example.com"
/>
<TextField
label="Ihre Frage"
value={question}
onChange={this.handleInputChange('question')}
required
fullWidth
multiline
rows={4}
disabled={loading}
placeholder="Beschreiben Sie Ihre Frage zu diesem Artikel..."
/>
<PhotoUpload
ref={this.photoUploadRef}
onChange={this.handlePhotosChange}
disabled={loading}
maxFiles={3}
label="Fotos zur Frage anhängen (optional)"
/>
<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 }} />
Wird gesendet...
</>
) : (
'Frage senden'
)}
</Button>
</Box>
</Paper>
);
}
}
export default withI18n()(ArticleQuestionForm);

View File

@@ -0,0 +1,264 @@
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);
if (this.props.socket) {
this.props.socket.emit('article_rating_submit', ratingData);
// Set up response handler
this.props.socket.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 || 'Ein Fehler ist aufgetreten'
});
}
// Clear messages after 3 seconds
setTimeout(() => {
this.setState({ success: false, error: null });
}, 3000);
});
}
} catch {
this.setState({
loading: false,
error: 'Fehler beim Verarbeiten der Fotos'
});
}
// 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;
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' }}>
Artikel Bewerten
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Teilen Sie Ihre Erfahrungen mit diesem Artikel und helfen Sie anderen Kunden bei der Entscheidung.
</Typography>
{success && (
<Alert severity="success" sx={{ mb: 3 }}>
Vielen Dank für Ihre Bewertung! Sie wird nach Prüfung veröffentlicht.
</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="Name"
value={name}
onChange={this.handleInputChange('name')}
required
fullWidth
disabled={loading}
placeholder="Ihr Name"
/>
<TextField
label="E-Mail"
type="email"
value={email}
onChange={this.handleInputChange('email')}
required
fullWidth
disabled={loading}
placeholder="ihre.email@example.com"
helperText="Ihre E-Mail wird nicht veröffentlicht"
/>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
Bewertung *
</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 ? `${rating} von 5 Sternen` : 'Bitte bewerten'}
</Typography>
</Box>
</Box>
<TextField
label="Ihre Bewertung (optional)"
value={review}
onChange={this.handleInputChange('review')}
fullWidth
multiline
rows={4}
disabled={loading}
placeholder="Beschreiben Sie Ihre Erfahrungen mit diesem Artikel..."
/>
<PhotoUpload
ref={this.photoUploadRef}
onChange={this.handlePhotosChange}
disabled={loading}
maxFiles={5}
label="Fotos zur Bewertung anhängen (optional)"
/>
<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 }} />
Wird gesendet...
</>
) : (
'Bewertung abgeben'
)}
</Button>
</Box>
</Paper>
);
}
}
export default withI18n()(ArticleRatingForm);

View File

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

View File

@@ -6,6 +6,7 @@ import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box';
import { Link } from 'react-router-dom';
import AddToCartButton from './AddToCartButton.js';
import { withI18n } from '../i18n/withTranslation.js';
class CartItem extends Component {
@@ -19,14 +20,14 @@ 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){
//if(this.props.socket && this.props.socket.connected){
this.props.socket.emit('getPic', { bildId:picid, size:'tiny' }, (res) => {
if(res.success){
window.tinyPicCache[picid] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
this.setState({image: window.tinyPicCache[picid], loading: false});
}
})
}
// }
}
}
}
@@ -116,7 +117,7 @@ class CartItem extends Component {
)}
{item.versandklasse && item.versandklasse != 'standard' && item.versandklasse != 'kostenlos' && (
<Typography variant="body2" color="warning.main" fontWeight="medium" component="div">
{item.versandklasse}
{item.versandklasse == 'nur Abholung' ? this.props.t('delivery.descriptions.pickupOnly') : item.versandklasse}
</Typography>
)}
{item.vat && (
@@ -126,9 +127,9 @@ class CartItem extends Component {
fontStyle="italic"
component="div"
>
inkl. {new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(
{this.props.t ? this.props.t('product.inclShort') : 'inkl.'} {new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(
(item.price * item.quantity) - ((item.price * item.quantity) / (1 + item.vat / 100))
)} MwSt. ({item.vat}%)
)} {this.props.t ? this.props.t('product.vatShort') : 'MwSt.'} ({item.vat}%)
</Typography>
)}
@@ -146,11 +147,14 @@ class CartItem extends Component {
display: "block"
}}
>
{this.props.id.toString().endsWith("steckling") ? "Lieferzeit: 14 Tage" :
item.available == 1 ? "Lieferzeit: 2-3 Tage" :
item.availableSupplier == 1 ? "Lieferzeit: 7-9 Tage" : ""}
{this.props.id.toString().endsWith("steckling") ?
(this.props.t ? this.props.t('delivery.times.cutting14Days') : "Lieferzeit: 14 Tage") :
item.available == 1 ?
(this.props.t ? this.props.t('delivery.times.standard2to3Days') : "Lieferzeit: 2-3 Tage") :
item.availableSupplier == 1 ?
(this.props.t ? this.props.t('delivery.times.supplier7to9Days') : "Lieferzeit: 7-9 Tage") : ""}
</Typography>
<AddToCartButton available={1} id={this.props.id} availableSupplier={item.availableSupplier} price={item.price} seoName={item.seoName} name={item.name} weight={item.weight} vat={item.vat} versandklasse={item.versandklasse}/>
<AddToCartButton available={1} id={this.props.id} komponenten={item.komponenten} availableSupplier={item.availableSupplier} price={item.price} seoName={item.seoName} name={item.name} weight={item.weight} vat={item.vat} versandklasse={item.versandklasse}/>
</Box>
</Box>
</ListItem>
@@ -159,4 +163,4 @@ class CartItem extends Component {
}
}
export default CartItem;
export default withI18n()(CartItem);

View File

@@ -16,7 +16,7 @@ const CategoryBox = ({
name,
seoName,
bgcolor,
fontSize = '0.8rem',
fontSize = '1.2rem',
...props
}) => {
const [imageUrl, setImageUrl] = useState(null);
@@ -61,7 +61,7 @@ const CategoryBox = ({
}
// If socket is available and connected, fetch the image
if (context && context.socket && context.socket.connected && id && !isLoading) {
if (context && context.socket /*&& context.socket.connected*/ && id && !isLoading) {
setIsLoading(true);
context.socket.emit('getCategoryPic', { categoryId: id }, (response) => {
@@ -186,7 +186,7 @@ const CategoryBox = ({
fontFamily: 'SwashingtonCP, "Times New Roman", Georgia, serif',
fontWeight: 'normal',
lineHeight: '1.2',
padding: '0 8px'
padding: '12px 8px'
}}>
{name}
</div>

View File

@@ -518,7 +518,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>
@@ -623,6 +623,7 @@ class ChatAssistant extends Component {
<IconButton
color="error"
onClick={this.stopRecording}
aria-label="Aufnahme stoppen"
sx={{ ml: 1 }}
>
<StopIcon />
@@ -631,6 +632,7 @@ class ChatAssistant extends Component {
<IconButton
color="primary"
onClick={this.startRecording}
aria-label="Sprachaufnahme starten"
sx={{ ml: 1 }}
disabled={isTyping || inputsDisabled}
>
@@ -641,6 +643,7 @@ class ChatAssistant extends Component {
<IconButton
color="primary"
onClick={this.handleImageUpload}
aria-label="Bild hochladen"
sx={{ ml: 1 }}
disabled={isTyping || isRecording || inputsDisabled}
>

View File

@@ -13,6 +13,7 @@ import CategoryBox from './CategoryBox.js';
import { useParams, useSearchParams } from 'react-router-dom';
import { getAllSettingsWithPrefix } from '../utils/sessionStorage.js';
import { withI18n } from '../i18n/withTranslation.js';
const isNew = (neu) => neu && (new Date().getTime() - new Date(neu).getTime() < 30 * 24 * 60 * 60 * 1000);
@@ -52,7 +53,7 @@ function getCachedCategoryData(categoryId) {
function getFilteredProducts(unfilteredProducts, attributes) {
function getFilteredProducts(unfilteredProducts, attributes, t) {
const attributeSettings = getAllSettingsWithPrefix('filter_attribute_');
const manufacturerSettings = getAllSettingsWithPrefix('filter_manufacturer_');
const availabilitySettings = getAllSettingsWithPrefix('filter_availability_');
@@ -149,17 +150,17 @@ function getFilteredProducts(unfilteredProducts, attributes) {
// Check for "auf Lager" filter (in stock) - it's active when filter_availability is NOT set to '1'
if (availabilityFilter !== '1') {
activeAvailabilityFilters.push({id: '1', name: 'auf Lager'});
activeAvailabilityFilters.push({id: '1', name: t ? t('product.inStock') : 'auf Lager'});
}
// Check for "Neu" filter (new) - only show if there are actually new products and filter is active
if (availabilityFilters.includes('2') && hasNewProducts) {
activeAvailabilityFilters.push({id: '2', name: 'Neu'});
activeAvailabilityFilters.push({id: '2', name: t ? t('product.new') : 'Neu'});
}
// Check for "Bald verfügbar" filter (coming soon) - only show if there are actually coming soon products and filter is active
if (availabilityFilters.includes('3') && hasComingSoonProducts) {
activeAvailabilityFilters.push({id: '3', name: 'Bald verfügbar'});
activeAvailabilityFilters.push({id: '3', name: t ? t('product.comingSoon') : 'Bald verfügbar'});
}
return {filteredProducts,activeAttributeFilters:activeAttributeFiltersWithNames,activeManufacturerFilters:activeManufacturerFiltersWithNames,activeAvailabilityFilters};
@@ -256,7 +257,8 @@ class Content extends Component {
unfilteredProducts: unfilteredProducts,
...getFilteredProducts(
unfilteredProducts,
response.attributes
response.attributes,
this.props.t
),
categoryName: response.categoryName || response.name || null,
dataType: response.dataType,
@@ -276,12 +278,12 @@ class Content extends Component {
return;
}
if (!this.props.socket || !this.props.socket.connected) {
//if (!this.props.socket || !this.props.socket.connected) {
// Socket not connected yet, but don't show error immediately on first load
// The componentDidUpdate will retry when socket connects
console.log("Socket not connected yet, waiting for connection to fetch category data");
return;
}
// console.log("Socket not connected yet, waiting for connection to fetch category data");
// return;
//}
console.log(`productList:${categoryId}`);
this.props.socket.off(`productList:${categoryId}`);
@@ -363,12 +365,12 @@ class Content extends Component {
}
fetchSearchData(query) {
if (!this.props.socket || !this.props.socket.connected) {
// if (!this.props.socket || !this.props.socket.connected) {
// Socket not connected yet, but don't show error immediately on first load
// The componentDidUpdate will retry when socket connects
console.log("Socket not connected yet, waiting for connection to fetch search data");
return;
}
// console.log("Socket not connected yet, waiting for connection to fetch search data");
// return;
// }
this.props.socket.emit("getSearchProducts", { query },
(response) => {
@@ -385,7 +387,8 @@ class Content extends Component {
this.setState({
...getFilteredProducts(
this.state.unfilteredProducts,
this.state.attributes
this.state.attributes,
this.props.t
)
});
}
@@ -602,7 +605,7 @@ class Content extends Component {
{(this.getCurrentCategoryId() == 706 || this.getCurrentCategoryId() == 689) &&
<Box sx={{ display: { xs: 'none', sm: 'block' } }}>
<Typography variant="h6" sx={{mt:3}}>
Andere Kategorien
{this.props.t ? this.props.t('navigation.otherCategories') : 'Andere Kategorien'}
</Typography>
</Box>
}
@@ -631,12 +634,26 @@ class Content extends Component {
<Box sx={{
height: '100%',
bgcolor: '#e1f0d3',
backgroundImage: 'url("/assets/images/seeds.jpg")',
backgroundSize: 'contain',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
position: 'relative'
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<img
src="/assets/images/seeds.jpg"
alt="Seeds"
fetchPriority="high"
loading="eager"
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)'
}}
/>
{/* Overlay text - optional */}
<Box sx={{
position: 'absolute',
@@ -647,7 +664,7 @@ class Content extends Component {
p: 2,
}}>
<Typography sx={{ fontSize: '1.3rem', color: 'white', fontFamily: 'SwashingtonCP' }}>
Seeds
{this.props.t('sections.seeds')}
</Typography>
</Box>
</Box>
@@ -678,12 +695,26 @@ class Content extends Component {
<Box sx={{
height: '100%',
bgcolor: '#e8f5d6',
backgroundImage: 'url("/assets/images/cutlings.jpg")',
backgroundSize: 'contain',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
position: 'relative'
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<img
src="/assets/images/cutlings.jpg"
alt="Stecklinge"
fetchPriority="high"
loading="eager"
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)'
}}
/>
{/* Overlay text - optional */}
<Box sx={{
position: 'absolute',
@@ -694,7 +725,7 @@ class Content extends Component {
p: 2,
}}>
<Typography sx={{ fontSize: '1.3rem', color: 'white', fontFamily: 'SwashingtonCP' }}>
Stecklinge
{this.props.t('sections.stecklinge')}
</Typography>
</Box>
</Box>
@@ -723,4 +754,4 @@ class Content extends Component {
}
}
export default withRouter(Content);
export default withRouter(withI18n()(Content));

View File

@@ -267,7 +267,7 @@ class Filter extends Component {
)}
</Typography>
{isXsScreen && (
<IconButton size="small" sx={{ p: 0 }}>
<IconButton size="small" aria-label={isCollapsed ? "Filter erweitern" : "Filter einklappen"} sx={{ p: 0 }}>
{isCollapsed ? <ExpandMoreIcon /> : <ExpandLessIcon />}
</IconButton>
)}

View File

@@ -6,6 +6,7 @@ import Link from '@mui/material/Link';
import { Link as RouterLink } from 'react-router-dom';
import { styled } from '@mui/material/styles';
import Paper from '@mui/material/Paper';
import { withI18n } from '../i18n/withTranslation.js';
// Styled component for the router links
const StyledRouterLink = styled(RouterLink)(() => ({
@@ -229,9 +230,9 @@ class Footer extends Component {
alignItems={{ xs: 'center', md: 'left' }}
flexWrap="wrap"
>
<StyledRouterLink to="/datenschutz">Datenschutz</StyledRouterLink>
<StyledRouterLink to="/agb">AGB</StyledRouterLink>
<StyledRouterLink to="/sitemap">Sitemap</StyledRouterLink>
<StyledRouterLink to="/datenschutz">{this.props.t ? this.props.t('footer.legal.datenschutz') : 'Datenschutz'}</StyledRouterLink>
<StyledRouterLink to="/agb">{this.props.t ? this.props.t('footer.legal.agb') : 'AGB'}</StyledRouterLink>
<StyledRouterLink to="/sitemap">{this.props.t ? this.props.t('footer.legal.sitemap') : 'Sitemap'}</StyledRouterLink>
</Stack>
<Stack
@@ -241,12 +242,12 @@ class Footer extends Component {
alignItems={{ xs: 'center', md: 'left' }}
flexWrap="wrap"
>
<StyledRouterLink to="/impressum">Impressum</StyledRouterLink>
<StyledRouterLink to="/batteriegesetzhinweise">Batteriegesetzhinweise</StyledRouterLink>
<StyledRouterLink to="/widerrufsrecht">Widerrufsrecht</StyledRouterLink>
<StyledRouterLink to="/impressum">{this.props.t ? this.props.t('footer.legal.impressum') : 'Impressum'}</StyledRouterLink>
<StyledRouterLink to="/batteriegesetzhinweise">{this.props.t ? this.props.t('footer.legal.batteriegesetzhinweise') : 'Batteriegesetzhinweise'}</StyledRouterLink>
<StyledRouterLink to="/widerrufsrecht">{this.props.t ? this.props.t('footer.legal.widerrufsrecht') : 'Widerrufsrecht'}</StyledRouterLink>
</Stack>
{/* Payment Methods Section */}
{/* Payment Methods Section
<Stack
direction="column"
spacing={1}
@@ -263,7 +264,7 @@ class Footer extends Component {
<Box component="img" src="/assets/images/cards.png" alt="Cash" sx={{ height: { xs: 80, md: 95 } }} />
</Stack>
</Stack>
*/}
{/* Google Services Badge Section */}
<Stack
direction="column"
@@ -274,9 +275,9 @@ class Footer extends Component {
<Stack
direction="row"
spacing={{ xs: 1, md: 2 }}
sx={{pb: '10px'}}
sx={{pt: '10px', height: { xs: 50, md: 60 }, transform: 'translateY(-3px)'}}
justifyContent="center"
alignItems="center"
alignItems="flex-end"
>
<Link
href="https://reviewthis.biz/growheads"
@@ -285,7 +286,10 @@ 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}
@@ -311,7 +315,10 @@ 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}
@@ -338,7 +345,7 @@ class Footer extends Component {
{/* Copyright Section */}
<Box sx={{ pb:'20px',textAlign: 'center', filter: 'drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3))', opacity: 0.7 }}>
<Typography variant="body2" sx={{ mb: 1, fontSize: { xs: '11px', md: '14px' }, lineHeight: 1.5 }}>
* Alle Preise inkl. gesetzlicher USt., zzgl. Versand
{this.props.t ? this.props.t('footer.allPricesIncl') : '* Alle Preise inkl. gesetzlicher USt., zzgl. Versand'}
</Typography>
<Typography variant="body2" sx={{ fontSize: { xs: '11px', md: '14px' }, lineHeight: 1.5 }}>
© {new Date().getFullYear()} <StyledDomainLink href="https://growheads.de" target="_blank" rel="noopener noreferrer">GrowHeads.de</StyledDomainLink>
@@ -351,4 +358,4 @@ class Footer extends Component {
}
}
export default Footer;
export default withI18n()(Footer);

View File

@@ -2,6 +2,7 @@ import React, { Component } from 'react';
import Button from '@mui/material/Button';
import GoogleIcon from '@mui/icons-material/Google';
import GoogleAuthContext from '../contexts/GoogleAuthContext.js';
import { withI18n } from '../i18n/index.js';
class GoogleLoginButton extends Component {
static contextType = GoogleAuthContext;
@@ -186,7 +187,7 @@ class GoogleLoginButton extends Component {
};
render() {
const { disabled, style, className, text = 'Mit Google anmelden' } = this.props;
const { disabled, style, className, text = (this.props.t ? this.props.t('auth.loginWithGoogle') : 'Mit Google anmelden') } = this.props;
const { isInitializing, isPrompting } = this.state;
const isLoading = isInitializing || isPrompting || (this.context && !this.context.isLoaded);
@@ -205,4 +206,4 @@ class GoogleLoginButton extends Component {
}
}
export default GoogleLoginButton;
export default withI18n(GoogleLoginButton);

View File

@@ -38,7 +38,7 @@ class Header extends Component {
render() {
// Get socket directly from context in render method
const {socket,socketB} = this.context;
const { isHomePage, isProfilePage } = this.props;
const { isHomePage, isProfilePage, isAktionenPage, isFilialePage } = this.props;
return (
<AppBar position="sticky" color="primary" elevation={0} sx={{ zIndex: 1100 }}>
@@ -94,7 +94,7 @@ class Header extends Component {
</Box>
</Container>
</Toolbar>
{(isHomePage || this.props.categoryId || isProfilePage) && <CategoryList categoryId={209} activeCategoryId={this.props.categoryId} socket={socket} socketB={socketB} />}
{(isHomePage || this.props.categoryId || isProfilePage || isAktionenPage || isFilialePage) && <CategoryList categoryId={209} activeCategoryId={this.props.categoryId} socket={socket} socketB={socketB} />}
</AppBar>
);
}
@@ -105,10 +105,12 @@ const HeaderWithContext = (props) => {
const location = useLocation();
const isHomePage = location.pathname === '/';
const isProfilePage = location.pathname === '/profile';
const isAktionenPage = location.pathname === '/aktionen';
const isFilialePage = location.pathname === '/filiale';
return (
<SocketContext.Consumer>
{({socket,socketB}) => <Header {...props} socket={socket} socketB={socketB} isHomePage={isHomePage} isProfilePage={isProfilePage} />}
{({socket,socketB}) => <Header {...props} socket={socket} socketB={socketB} isHomePage={isHomePage} isProfilePage={isProfilePage} isAktionenPage={isAktionenPage} isFilialePage={isFilialePage} />}
</SocketContext.Consumer>
);
};

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:[], needsSocketRetry: false };
}
componentDidMount () {
@@ -24,6 +22,15 @@ class Images extends Component {
if (prevProps.fullscreenOpen !== this.props.fullscreenOpen) {
this.updatePics();
}
// Retry loading images if socket just became available
const wasConnected = prevProps.socketB && prevProps.socketB.connected;
const isNowConnected = this.props.socketB && this.props.socketB.connected;
if (!wasConnected && isNowConnected && this.state.needsSocketRetry) {
this.setState({ needsSocketRetry: false });
this.updatePics();
}
}
updatePics = (newMainPic = this.state.mainPic) => {
@@ -51,10 +58,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}.jpg`);
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
}
}else{
@@ -69,7 +76,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,6 +85,13 @@ class Images extends Component {
}
loadPic = (size,bildId,index) => {
// Check if socketB is available and connected before emitting
if (!this.props.socketB || !this.props.socketB.connected) {
console.log("Images: socketB not available, will retry when connected");
this.setState({ needsSocketRetry: true });
return;
}
this.props.socketB.emit('getPic', { bildId, size }, (res) => {
if(res.success){
const url = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
@@ -101,27 +116,89 @@ class Images extends Component {
}
render() {
// Prerender detection - if no sockets, render simple CardMedia with static path
if (!this.props.socketB) {
const getImagePath = (pictureList) => {
if (!pictureList || !pictureList.trim()) {
return '/assets/images/nopicture.jpg';
}
return `/assets/images/prod${pictureList.split(',')[0].trim()}.jpg`;
};
return (
<>
{this.state.pics[this.state.mainPic] && (
<Box sx={{ position: 'relative', display: 'inline-block' }}>
<Box sx={{ position: 'relative', display: 'inline-block', width: '499px', maxWidth: '100%' }}>
<CardMedia
component="img"
height="400"
image={getImagePath(this.props.pictureList)}
alt={this.props.productName || 'Produktbild'}
fetchPriority="high"
loading="eager"
sx={{
objectFit: 'contain',
cursor: 'pointer',
transition: 'transform 0.2s ease-in-out',
width: '499px',
maxWidth: '100%',
'&:hover': {
transform: 'scale(1.02)'
}
}}
image={this.state.pics[this.state.mainPic]}
/>
</Box>
<Stack direction="row" spacing={2} sx={{ justifyContent: 'flex-start', mt: 1, mb: 1 }}>
{/* Empty thumbnail gallery for prerender - reserves the mt+mb spacing (16px) */}
</Stack>
</>
);
}
// SPA version - full functionality with static fallback
const getImageSrc = () => {
// 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()}.jpg`;
};
return (
<>
<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,
@@ -137,7 +214,6 @@ class Images extends Component {
<LoupeIcon fontSize="small" />
</IconButton>
</Box>
)}
<Stack direction="row" spacing={2} sx={{ justifyContent: 'flex-start', mt: 1,mb: 1 }}>
{this.state.pics.filter(pic => pic !== null && pic !== this.state.pics[this.state.mainPic]).map((pic, filterIndex) => {
// Find the original index in the full pics array
@@ -169,6 +245,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 +306,7 @@ class Images extends Component {
{/* Close Button */}
<IconButton
onClick={this.props.onCloseFullscreen}
aria-label="Vollbild schließen"
sx={{
position: 'absolute',
top: 16,
@@ -241,6 +325,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 +385,13 @@ class Images extends Component {
<CardMedia
component="img"
height="60"
alt={`${this.props.productName || 'Produktbild'} - Miniaturansicht ${originalIndex + 1}`}
onError={(e) => {
// Ensure alt text is always present even on error
if (!e.target.alt) {
e.target.alt = `${this.props.productName || 'Produktbild'} - Miniaturansicht ${originalIndex + 1}`;
}
}}
sx={{
objectFit: 'contain',
cursor: 'pointer',

View File

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

View File

@@ -22,6 +22,7 @@ import GoogleLoginButton from './GoogleLoginButton.js';
import CartSyncDialog from './CartSyncDialog.js';
import { localAndArchiveServer, mergeCarts } from '../utils/cartUtils.js';
import config from '../config.js';
import { withI18n } from '../i18n/withTranslation.js';
// Lazy load GoogleAuthProvider
const GoogleAuthProvider = lazy(() => import('../providers/GoogleAuthProvider.js'));
@@ -510,7 +511,7 @@ export class LoginComponent extends Component {
color={isAdmin ? 'secondary' : 'inherit'}
sx={{ my: 1, mx: 1.5 }}
>
Profil
{this.props.t ? this.props.t('auth.profile') : 'Profil'}
</Button>
<Menu
disableScrollLock={true}
@@ -526,14 +527,28 @@ export class LoginComponent extends Component {
horizontal: 'right',
}}
>
<MenuItem component={Link} to="/profile" onClick={this.handleUserMenuClose}>Profil</MenuItem>
<MenuItem component={Link} to="/profile#cart" onClick={this.handleUserMenuClose} sx={{ pl: 4 }}>Bestellabschluss</MenuItem>
<MenuItem component={Link} to="/profile#orders" onClick={this.handleUserMenuClose} sx={{ pl: 4 }}>Bestellungen</MenuItem>
<MenuItem component={Link} to="/profile#settings" onClick={this.handleUserMenuClose} sx={{ pl: 4 }}>Einstellungen</MenuItem>
<MenuItem component={Link} to="/profile" onClick={this.handleUserMenuClose}>
{this.props.t ? this.props.t('auth.menu.profile') : 'Profil'}
</MenuItem>
<MenuItem component={Link} to="/profile#cart" onClick={this.handleUserMenuClose} sx={{ pl: 4 }}>
{this.props.t ? this.props.t('auth.menu.checkout') : 'Bestellabschluss'}
</MenuItem>
<MenuItem component={Link} to="/profile#orders" onClick={this.handleUserMenuClose} sx={{ pl: 4 }}>
{this.props.t ? this.props.t('auth.menu.orders') : 'Bestellungen'}
</MenuItem>
<MenuItem component={Link} to="/profile#settings" onClick={this.handleUserMenuClose} sx={{ pl: 4 }}>
{this.props.t ? this.props.t('auth.menu.settings') : 'Einstellungen'}
</MenuItem>
<Divider />
{isAdmin ? <MenuItem component={Link} to="/admin" onClick={this.handleUserMenuClose}>Admin Dashboard</MenuItem> : null}
{isAdmin ? <MenuItem component={Link} to="/admin/users" onClick={this.handleUserMenuClose}>Admin Users</MenuItem> : null}
<MenuItem onClick={this.handleLogout}>Abmelden</MenuItem>
{isAdmin ? <MenuItem component={Link} to="/admin" onClick={this.handleUserMenuClose}>
{this.props.t ? this.props.t('auth.menu.adminDashboard') : 'Admin Dashboard'}
</MenuItem> : null}
{isAdmin ? <MenuItem component={Link} to="/admin/users" onClick={this.handleUserMenuClose}>
{this.props.t ? this.props.t('auth.menu.adminUsers') : 'Admin Users'}
</MenuItem> : null}
<MenuItem onClick={this.handleLogout}>
{this.props.t ? this.props.t('auth.logout') : 'Abmelden'}
</MenuItem>
</Menu>
</>
) : (
@@ -543,7 +558,7 @@ export class LoginComponent extends Component {
onClick={this.handleOpen}
sx={{ my: 1, mx: 1.5 }}
>
Login
{this.props.t ? this.props.t('auth.login') : 'Login'}
</Button>
)
)}
@@ -558,7 +573,10 @@ export class LoginComponent extends Component {
<DialogTitle sx={{ bgcolor: 'white', pb: 0 }}>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Typography variant="h6" color="#2e7d32" fontWeight="bold">
{tabValue === 0 ? 'Anmelden' : 'Registrieren'}
{tabValue === 0 ?
(this.props.t ? this.props.t('auth.login') : 'Anmelden') :
(this.props.t ? this.props.t('auth.register') : 'Registrieren')
}
</Typography>
<IconButton edge="end" onClick={this.handleClose} aria-label="close">
<CloseIcon />
@@ -578,14 +596,14 @@ export class LoginComponent extends Component {
textColor="inherit"
>
<Tab
label="ANMELDEN"
label={this.props.t ? this.props.t('auth.login').toUpperCase() : "ANMELDEN"}
sx={{
color: tabValue === 0 ? '#2e7d32' : 'inherit',
fontWeight: 'bold'
}}
/>
<Tab
label="REGISTRIEREN"
label={this.props.t ? this.props.t('auth.register').toUpperCase() : "REGISTRIEREN"}
sx={{
color: tabValue === 1 ? '#2e7d32' : 'inherit',
fontWeight: 'bold'
@@ -598,7 +616,14 @@ export class LoginComponent extends Component {
<Box sx={{ width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', mb: 2 }}>
{!privacyConfirmed && (
<Typography variant="caption" sx={{ mb: 1, textAlign: 'center' }}>
{this.props.t ?
<>
{this.props.t('auth.privacyAccept')} <Link to="/datenschutz" style={{ color: '#4285F4' }}>{this.props.t('auth.privacyPolicy')}</Link>
</> :
<>
Mit dem Click auf "Mit Google anmelden" akzeptiere ich die <Link to="/datenschutz" style={{ color: '#4285F4' }}>Datenschutzbestimmungen</Link>
</>
}
</Typography>
)}
{!showGoogleAuth && (
@@ -611,7 +636,7 @@ export class LoginComponent extends Component {
}}
sx={{ width: '100%', backgroundColor: '#4285F4', color: 'white' }}
>
Mit Google anmelden
{this.props.t ? this.props.t('auth.loginWithGoogle') : 'Mit Google anmelden'}
</Button>
)}
@@ -643,7 +668,9 @@ export class LoginComponent extends Component {
{/* OR Divider */}
<Box sx={{ display: 'flex', alignItems: 'center', my: 2 }}>
<Box sx={{ flex: 1, height: '1px', backgroundColor: '#e0e0e0' }} />
<Typography variant="body2" sx={{ px: 2, color: '#757575' }}>ODER</Typography>
<Typography variant="body2" sx={{ px: 2, color: '#757575' }}>
{this.props.t ? this.props.t('auth.or') : 'ODER'}
</Typography>
<Box sx={{ flex: 1, height: '1px', backgroundColor: '#e0e0e0' }} />
</Box>
@@ -654,7 +681,7 @@ export class LoginComponent extends Component {
<Box sx={{ py: 1 }}>
<TextField
margin="dense"
label="E-Mail"
label={this.props.t ? this.props.t('auth.email') : 'E-Mail'}
type="email"
fullWidth
variant="outlined"
@@ -665,7 +692,7 @@ export class LoginComponent extends Component {
<TextField
margin="dense"
label="Passwort"
label={this.props.t ? this.props.t('auth.password') : 'Passwort'}
type="password"
fullWidth
variant="outlined"
@@ -687,7 +714,7 @@ export class LoginComponent extends Component {
'&:hover': { backgroundColor: 'transparent', textDecoration: 'underline' }
}}
>
Passwort vergessen?
{this.props.t ? this.props.t('auth.forgotPassword') : 'Passwort vergessen?'}
</Button>
</Box>
)}
@@ -695,7 +722,7 @@ export class LoginComponent extends Component {
{tabValue === 1 && (
<TextField
margin="dense"
label="Passwort bestätigen"
label={this.props.t ? this.props.t('auth.confirmPassword') : 'Passwort bestätigen'}
type="password"
fullWidth
variant="outlined"
@@ -717,7 +744,7 @@ export class LoginComponent extends Component {
onClick={tabValue === 0 ? this.handleLogin : this.handleRegister}
sx={{ mt: 2, bgcolor: '#2e7d32', '&:hover': { bgcolor: '#1b5e20' } }}
>
{tabValue === 0 ? 'ANMELDEN' : 'REGISTRIEREN'}
{tabValue === 0 ? (this.props.t ? this.props.t('auth.login').toUpperCase() : 'ANMELDEN') : (this.props.t ? this.props.t('auth.register').toUpperCase() : 'REGISTRIEREN')}
</Button>
)}
</Box>
@@ -740,4 +767,4 @@ export class LoginComponent extends Component {
}
}
export default withRouter(LoginComponent);
export default withRouter(withI18n()(LoginComponent));

View File

@@ -0,0 +1,665 @@
import React from "react";
import { useLocation } from "react-router-dom";
import Container from "@mui/material/Container";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Paper from "@mui/material/Paper";
import Grid from "@mui/material/Grid";
import ChevronLeft from "@mui/icons-material/ChevronLeft";
import ChevronRight from "@mui/icons-material/ChevronRight";
import { Link } from "react-router-dom";
import SharedCarousel from "./SharedCarousel.js";
import { getCombinedAnimatedBorderStyles } from "../utils/animatedBorderStyles.js";
import { useTranslation } from 'react-i18next';
const MainPageLayout = () => {
const location = useLocation();
const currentPath = location.pathname;
const { t } = useTranslation();
const [starHovered, setStarHovered] = React.useState(false);
// Determine which page we're on
const isHome = currentPath === "/";
const isAktionen = currentPath === "/aktionen";
const isFiliale = currentPath === "/filiale";
// Add CSS animations for rotating stars
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);
};
}, []);
// Get navigation config based on current page
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" }
};
}
return { leftNav: null, rightNav: null };
};
const allTitles = {
home: t('titles.filiale'),
aktionen: t('titles.aktionen'),
filiale: t('titles.home')
};
// Define all content boxes for layered rendering
const allContentBoxes = {
home: [
{
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"
}
],
aktionen: [
{
title: t('sections.oilPress'),
image: "/assets/images/presse.jpg",
bgcolor: "#e1f0d3",
link: "/presseverleih"
},
{
title: t('sections.thcTest'),
image: "/assets/images/purpl.jpg",
bgcolor: "#e8f5d6",
link: "/thc-test"
}
],
filiale: [
{
title: t('sections.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"
}
]
};
// Get opacity for each page layer
const getOpacity = (pageType) => {
if (pageType === "home" && isHome) return 1;
if (pageType === "aktionen" && isAktionen) return 1;
if (pageType === "filiale" && isFiliale) return 1;
return 0;
};
const navConfig = getNavigationConfig();
// Navigation text mapping for translation
const navTexts = [
{ key: 'aktionen', text: t('navigation.aktionen'), link: '/aktionen' },
{ key: 'filiale', text: t('navigation.filiale'), link: '/filiale' },
{ key: 'home', text: t('navigation.home'), link: '/' }
];
return (
<Container maxWidth="lg" sx={{ py: 2 }}>
<style>{getCombinedAnimatedBorderStyles(['seeds', 'cutlings'])}</style>
{/* 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"
}}>
{Object.entries(allTitles).map(([pageType, title]) => (
<Typography
key={pageType}
variant="h3"
component="h1"
sx={{
fontFamily: "SwashingtonCP",
fontSize: { xs: "2.125rem", sm: "2.125rem", md: "3rem" },
textAlign: "center",
color: "primary.main",
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)",
transition: "opacity 0.5s ease-in-out",
opacity: getOpacity(pageType),
position: pageType === "home" ? "relative" : "absolute",
top: pageType !== "home" ? 0 : "auto",
left: pageType !== "home" ? 0 : "auto",
transform: "none",
width: "100%",
pointerEvents: getOpacity(pageType) === 1 ? "auto" : "none",
lineHeight: { xs: "1.2", sm: "1.2", md: "1.1" },
wordWrap: "break-word",
hyphens: "auto"
}}
>
{title}
</Typography>
))}
</Box>
{/* 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 }
}}>
{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"
}
}}
>
<ChevronLeft sx={{ fontSize: "2rem", mr: 1 }} />
<Typography
sx={{
fontFamily: "SwashingtonCP",
fontSize: { xs: "1.25rem", sm: "1.25rem", md: "2.125rem" },
textShadow: "2px 2px 4px rgba(0, 0, 0, 0.3)",
lineHeight: { xs: "1.2", sm: "1.2", md: "1.1" },
whiteSpace: "nowrap"
}}
>
{navItem.text}
</Typography>
</Box>
);
})}
</Box>
{/* 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
}}>
{Object.entries(allTitles).map(([pageType, title]) => (
<Typography
key={pageType}
variant="h3"
component="h1"
sx={{
fontFamily: "SwashingtonCP",
fontSize: { xs: "2.125rem", sm: "2.125rem", md: "3rem" },
textAlign: "center",
color: "primary.main",
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)",
transition: "opacity 0.5s ease-in-out",
opacity: getOpacity(pageType),
position: pageType === "home" ? "relative" : "absolute",
top: pageType !== "home" ? "50%" : "auto",
left: pageType !== "home" ? "50%" : "auto",
transform: pageType !== "home" ? "translate(-50%, -50%)" : "none",
width: "100%",
pointerEvents: getOpacity(pageType) === 1 ? "auto" : "none",
lineHeight: { xs: "1.2", sm: "1.2", md: "1.1" },
wordWrap: "break-word",
hyphens: "auto"
}}
>
{title}
</Typography>
))}
</Box>
{/* Right Navigation - Layered rendering */}
<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>
<ChevronRight sx={{ fontSize: "2rem", ml: 1 }} />
</Box>
);
})}
</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"
}}
>
{contentBoxes.map((box, index) => (
<Grid key={`${pageType}-${index}`} item xs={12} sm={6} sx={{ p: 2, width: "50%", position: 'relative' }}>
{/* Multi-pointed star for seeds box - moved to Grid level */}
{index === 0 && pageType === "filiale" && (
<Box
sx={{
position: 'absolute',
top: '-55px',
left: '-45px',
width: '180px',
height: '180px',
zIndex: 999,
pointerEvents: 'auto',
cursor: 'pointer',
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' }
}}
onMouseEnter={() => setStarHovered(true)}
onMouseLeave={() => setStarHovered(false)}
>
{/* Background star - slightly larger and rotated */}
<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"
stroke="none"
/>
</svg>
{/* Middle star - medium size with different rotation */}
<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"
stroke="none"
/>
</svg>
{/* Foreground star - main star with text */}
<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"
stroke="none"
/>
</svg>
{/* Text positioned in the center of the star */}
<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
}}
>
{t('sections.showUsPhoto')}
</div>
{/* Hover text */}
<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'
}}
>
{t('sections.selectSeedRate')}
</div>
</Box>
)}
{/* Multi-pointed star for stecklinge box - bottom right */}
{index === 1 && pageType === "filiale" && (
<Box
sx={{
position: 'absolute',
bottom: '-45px',
right: '-65px',
width: '180px',
height: '180px',
zIndex: 999,
pointerEvents: 'auto',
cursor: 'pointer',
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' }
}}
>
{/* Background star - slightly larger and rotated */}
<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"
stroke="none"
/>
</svg>
{/* Middle star - medium size with different rotation */}
<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"
stroke="none"
/>
</svg>
{/* Foreground star - main star with text */}
<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"
stroke="none"
/>
</svg>
{/* Text positioned in the center of the star */}
<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'
}}
>
{t('sections.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,
},
}}
>
<Box
sx={{
height: "100%",
bgcolor: box.bgcolor,
position: "relative",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<img
src={box.image}
alt={box.title}
fetchPriority="high"
loading="eager"
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>
))}
</Grid>
))}
</Box>
{/* Shared Carousel */}
<SharedCarousel />
</Container>
);
};
export default MainPageLayout;

View File

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

View File

@@ -0,0 +1,285 @@
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';
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: `Maximal ${maxFiles} Dateien erlaubt`
});
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: 'Nur Bilddateien (JPEG, PNG, GIF, WebP) sind erlaubt'
});
continue;
}
if (file.size > maxSize) {
this.setState({
error: `Datei zu groß. Maximum: ${Math.round(maxSize / (1024 * 1024))}MB`
});
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 } = this.props;
return (
<Box>
<Typography variant="body2" sx={{ mb: 1, fontWeight: 500 }}>
{label || 'Fotos anhängen (optional)'}
</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 }}
>
Fotos auswählen
</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="Bild entfernen"
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' }}>
{files.length} Datei(en) ausgewählt
{previews.length > 0 && previews.some(p => p.originalSize && p.compressedSize) && (
<span style={{ marginLeft: '8px' }}>
(komprimiert für Upload)
</span>
)}
</Typography>
)}
</Box>
);
}
}
export default PhotoUpload;

View File

@@ -8,6 +8,7 @@ import CircularProgress from '@mui/material/CircularProgress';
import IconButton from '@mui/material/IconButton';
import AddToCartButton from './AddToCartButton.js';
import { Link } from 'react-router-dom';
import { withI18n } from '../i18n/withTranslation.js';
import ZoomInIcon from '@mui/icons-material/ZoomIn';
class Product extends Component {
@@ -27,6 +28,43 @@ class Product extends Component {
}else{
this.state = {image: null, loading: true, error: false};
console.log("Product: Fetching image from socketB", this.props.socketB);
// Check if socketB is available and connected before emitting
if (this.props.socketB && this.props.socketB.connected) {
this.loadImage(bildId);
} else {
// Socket not available, set error state or wait
console.log("Product: socketB not available, will retry when connected");
this.state.error = true;
this.state.loading = false;
}
}
}else{
this.state = {image: null, loading: false, error: false};
}
}
componentDidMount() {
this._isMounted = true;
}
componentDidUpdate(prevProps) {
// Retry loading image if socket just became available
const wasConnected = prevProps.socketB && prevProps.socketB.connected;
const isNowConnected = this.props.socketB && this.props.socketB.connected;
if (!wasConnected && isNowConnected && this.state.error && this.props.pictureList) {
// Socket just connected and we had an error, retry loading
const bildId = this.props.pictureList.split(',')[0];
if (!window.smallPicCache[bildId]) {
this.setState({loading: true, error: false});
this.loadImage(bildId);
}
}
}
loadImage = (bildId) => {
if (this.props.socketB && this.props.socketB.connected) {
this.props.socketB.emit('getPic', { bildId, size:'small' }, (res) => {
if(res.success){
window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
@@ -45,15 +83,8 @@ class Product extends Component {
this.state.loading = false;
}
}
})
});
}
}else{
this.state = {image: null, loading: false, error: false};
}
}
componentDidMount() {
this._isMounted = true;
}
componentWillUnmount() {
@@ -68,8 +99,8 @@ class Product extends Component {
render() {
const {
id, name, price, available, manufacturer, seoName,
currency, vat, massMenge, massEinheit, thc,
floweringWeeks,incoming, neu, weight, versandklasse, availableSupplier
currency, vat, cGrundEinheit, fGrundPreis, thc,
floweringWeeks,incoming, neu, weight, versandklasse, availableSupplier, komponenten
} = this.props;
const isNew = neu && (new Date().getTime() - new Date(neu).getTime() < 30 * 24 * 60 * 60 * 1000);
@@ -173,7 +204,7 @@ class Product extends Component {
zIndex: 1000
}}
>
NEU
{this.props.t ? this.props.t('product.new') : 'NEU'}
</div>
</div>
)}
@@ -240,7 +271,7 @@ class Product extends Component {
transformOrigin: 'top left'
}}
>
{floweringWeeks} Wochen
{floweringWeeks} {this.props.t ? this.props.t('product.weeks') : 'Wochen'}
</div>
)}
@@ -275,6 +306,14 @@ class Product extends Component {
height={ window.innerWidth < 600 ? "240" : "180" }
image="/assets/images/nopicture.jpg"
alt={name}
fetchPriority={this.props.priority === 'high' ? 'high' : 'auto'}
loading={this.props.priority === 'high' ? 'eager' : 'lazy'}
onError={(e) => {
// Ensure alt text is always present even on error
if (!e.target.alt) {
e.target.alt = name || 'Produktbild';
}
}}
sx={{
objectFit: 'contain',
borderTopLeftRadius: '8px',
@@ -288,6 +327,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',
@@ -336,13 +383,13 @@ class Product extends Component {
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' }}>(incl. {vat}% USt.,*)</small>
<small style={{ color: '#77aa77', fontSize: '0.6em' }}>({this.props.t ? this.props.t('product.inclVatFooter', { vat }) : `incl. ${vat}% USt.,*`})</small>
</Typography>
{massMenge != 1 && massEinheit && (<Typography variant="body2" color="text.secondary" sx={{ m: 0,p: 0 }}>
({new Intl.NumberFormat('de-DE', {style: 'currency', currency: currency || 'EUR'}).format(price/massMenge)}/{massEinheit})
{cGrundEinheit && fGrundPreis && fGrundPreis != price && (<Typography variant="body2" color="text.secondary" sx={{ m: 0,p: 0 }}>
({new Intl.NumberFormat('de-DE', {style: 'currency', currency: currency || 'EUR'}).format(fGrundPreis)}/{cGrundEinheit})
</Typography> )}
</div>
{/*incoming*/}
@@ -354,11 +401,12 @@ class Product extends Component {
component={Link}
to={`/Artikel/${seoName}`}
size="small"
aria-label="Produktdetails anzeigen"
sx={{ mr: 1, color: 'text.secondary' }}
>
<ZoomInIcon />
</IconButton>
<AddToCartButton cartButton={true} availableSupplier={availableSupplier} available={available} incoming={incoming} seoName={seoName} pictureList={this.props.pictureList} id={id} price={price} vat={vat} weight={weight} name={name} versandklasse={versandklasse}/>
<AddToCartButton cartButton={true} availableSupplier={availableSupplier} komponenten={komponenten} cGrundEinheit={cGrundEinheit} fGrundPreis={fGrundPreis} available={available} incoming={incoming} seoName={seoName} pictureList={this.props.pictureList} id={id} price={price} vat={vat} weight={weight} name={name} versandklasse={versandklasse}/>
</Box>
</Card>
</Box>
@@ -366,4 +414,4 @@ class Product extends Component {
}
}
export default Product;
export default withI18n()(Product);

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ import Typography from '@mui/material/Typography';
import Filter from './Filter.js';
import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom';
import { setSessionSetting, removeSessionSetting, clearAllSessionSettings } from '../utils/sessionStorage.js';
import { withI18n } from '../i18n/withTranslation.js';
const isNew = (neu) => neu && (new Date().getTime() - new Date(neu).getTime() < 30 * 24 * 60 * 60 * 1000);
@@ -93,14 +94,14 @@ class ProductFilters extends Component {
}
_getAvailabilityValues = (products) => {
const filters = [{id:1,name:'auf Lager'}];
const filters = [{id:1,name: this.props.t ? this.props.t('product.inStock') : 'auf Lager'}];
for(const product of products){
if(isNew(product.neu)){
if(!filters.find(filter => filter.id == 2)) filters.push({id:2,name:'Neu'});
if(!filters.find(filter => filter.id == 2)) filters.push({id:2,name: this.props.t ? this.props.t('product.new') : 'Neu'});
}
if(!product.available && product.incomingDate){
if(!filters.find(filter => filter.id == 3)) filters.push({id:3,name:'Bald verfügbar'});
if(!filters.find(filter => filter.id == 3)) filters.push({id:3,name: this.props.t ? this.props.t('product.comingSoon') : 'Bald verfügbar'});
}
}
return filters
@@ -193,7 +194,7 @@ class ProductFilters extends Component {
{this.props.products.length > 0 && (
<><Filter
title="Verfügbarkeit"
title={this.props.t ? this.props.t('filters.availability') : 'Verfügbarkeit'}
options={this.state.availabilityValues}
searchParams={this.props.searchParams}
products={this.props.products}
@@ -236,7 +237,7 @@ class ProductFilters extends Component {
{this.generateAttributeFilters()}
<Filter
title="Hersteller"
title={this.props.t ? this.props.t('filters.manufacturer') : 'Hersteller'}
options={this.state.uniqueManufacturerArray}
filterType="manufacturer"
products={this.props.products}
@@ -257,4 +258,4 @@ class ProductFilters extends Component {
}
}
export default withRouter(ProductFilters);
export default withRouter(withI18n()(ProductFilters));

View File

@@ -0,0 +1,60 @@
import React from 'react';
import Box from '@mui/material/Box';
import CardMedia from '@mui/material/CardMedia';
import Images from './Images.js';
const ProductImage = ({
product,
socket,
socketB,
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
socket={socket}
socketB={socketB}
pictureList={product.pictureList}
productName={product.name}
fullscreenOpen={fullscreenOpen}
onOpenFullscreen={onOpenFullscreen}
onCloseFullscreen={onCloseFullscreen}
/>
)}
</Box>
);
};
export default ProductImage;

View File

@@ -11,6 +11,7 @@ import Chip from '@mui/material/Chip';
import Stack from '@mui/material/Stack';
import Product from './Product.js';
import { removeSessionSetting } from '../utils/sessionStorage.js';
import { withI18n } from '../i18n/withTranslation.js';
// Sort products by fuzzy similarity to their name/description
function sortProductsByFuzzySimilarity(products, searchTerm) {
@@ -141,12 +142,12 @@ class ProductList extends Component {
onChange={this.handlePageChange}
color="primary"
size={"large"}
siblingCount={window.innerWidth < 600 ? 0 : 1}
boundaryCount={window.innerWidth < 600 ? 1 : 1}
hideNextButton={false}
hidePrevButton={false}
showFirstButton={window.innerWidth >= 600}
showLastButton={window.innerWidth >= 600}
siblingCount={1}
boundaryCount={1}
hideNextButton={true}
hidePrevButton={true}
showFirstButton={false}
showLastButton={false}
sx={{
'& .MuiPagination-ul': {
flexWrap: 'nowrap',
@@ -184,7 +185,7 @@ class ProductList extends Component {
px: 2
}}>
<Typography variant="h6" color="text.secondary" sx={{ textAlign: 'center' }}>
Entferne Filter um Produkte zu sehen
{this.props.t ? this.props.t('product.removeFiltersToSee') : 'Entferne Filter um Produkte zu sehen'}
</Typography>
</Box>
);
@@ -200,14 +201,14 @@ class ProductList extends Component {
if (!isFiltered) {
// No filters applied
if (filteredCount === 0) return "0 Produkte";
if (filteredCount === 1) return "1 Produkt";
return `${filteredCount} Produkte`;
if (filteredCount === 0) return this.props.t ? this.props.t('product.countDisplay.noProducts') : "0 Produkte";
if (filteredCount === 1) return this.props.t ? this.props.t('product.countDisplay.oneProduct') : "1 Produkt";
return this.props.t ? this.props.t('product.countDisplay.multipleProducts', { count: filteredCount }) : `${filteredCount} Produkte`;
} else {
// Filters applied
if (totalCount === 0) return "0 Produkte";
if (totalCount === 1) return `${filteredCount} von 1 Produkt`;
return `${filteredCount} von ${totalCount} Produkten`;
if (totalCount === 0) return this.props.t ? this.props.t('product.countDisplay.noProducts') : "0 Produkte";
if (totalCount === 1) return this.props.t ? this.props.t('product.countDisplay.filteredOneProduct', { filtered: filteredCount }) : `${filteredCount} von 1 Produkt`;
return this.props.t ? this.props.t('product.countDisplay.filteredProducts', { filtered: filteredCount, total: totalCount }) : `${filteredCount} von ${totalCount} Produkten`;
}
}
@@ -327,13 +328,13 @@ class ProductList extends Component {
minWidth: { xs: 120, sm: 140 }
}}
>
<InputLabel id="sort-by-label">Sortierung</InputLabel>
<InputLabel id="sort-by-label">{this.props.t ? this.props.t('filters.sorting') : 'Sortierung'}</InputLabel>
<Select
size="small"
labelId="sort-by-label"
value={(this.state.sortBy==='searchField')&&(window.currentSearchQuery)?this.state.sortBy:this.state.sortBy==='price-low-high'?this.state.sortBy:this.state.sortBy==='price-low-high'?this.state.sortBy:'name'}
onChange={this.handleSortChange}
label="Sortierung"
label={this.props.t ? this.props.t('filters.sorting') : 'Sortierung'}
MenuProps={{
disableScrollLock: true,
anchorOrigin: {
@@ -353,10 +354,10 @@ class ProductList extends Component {
}
}}
>
<MenuItem value="name">Name</MenuItem>
{window.currentSearchQuery && <MenuItem value="searchField">Suchbegriff</MenuItem>}
<MenuItem value="price-low-high">Preis: Niedrig zu Hoch</MenuItem>
<MenuItem value="price-high-low">Preis: Hoch zu Niedrig</MenuItem>
<MenuItem value="name">{this.props.t ? this.props.t('sorting.name') : 'Name'}</MenuItem>
{window.currentSearchQuery && <MenuItem value="searchField">{this.props.t ? this.props.t('sorting.searchField') : 'Suchbegriff'}</MenuItem>}
<MenuItem value="price-low-high">{this.props.t ? this.props.t('sorting.priceLowHigh') : 'Preis: Niedrig zu Hoch'}</MenuItem>
<MenuItem value="price-high-low">{this.props.t ? this.props.t('sorting.priceHighLow') : 'Preis: Hoch zu Niedrig'}</MenuItem>
</Select>
</FormControl>
@@ -368,12 +369,12 @@ class ProductList extends Component {
minWidth: { xs: 80, sm: 100 }
}}
>
<InputLabel id="products-per-page-label">pro Seite</InputLabel>
<InputLabel id="products-per-page-label">{this.props.t ? this.props.t('filters.perPage') : 'pro Seite'}</InputLabel>
<Select
labelId="products-per-page-label"
value={this.state.itemsPerPage}
onChange={this.handleProductsPerPageChange}
label="pro Seite"
label={this.props.t ? this.props.t('filters.perPage') : 'pro Seite'}
MenuProps={{
disableScrollLock: true,
anchorOrigin: {
@@ -462,8 +463,8 @@ class ProductList extends Component {
available={product.available}
manufacturer={product.manufacturer}
vat={product.vat}
massMenge={product.massMenge}
massEinheit={product.massEinheit}
cGrundEinheit={product.cGrundEinheit}
fGrundPreis={product.fGrundPreis}
incoming={product.incomingDate}
neu={product.neu}
thc={product.thc}
@@ -474,6 +475,9 @@ class ProductList extends Component {
socketB={this.props.socketB}
pictureList={product.pictureList}
availableSupplier={product.availableSupplier}
komponenten={product.komponenten}
priority={index < 6 ? 'high' : 'auto'}
t={this.props.t}
/>
</Grid>
))}
@@ -495,4 +499,4 @@ class ProductList extends Component {
}
}
export default ProductList;
export default withI18n()(ProductList);

View File

@@ -0,0 +1,283 @@
import React, { useContext, useEffect, useState } from "react";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import ChevronLeft from "@mui/icons-material/ChevronLeft";
import ChevronRight from "@mui/icons-material/ChevronRight";
import CategoryBox from "./CategoryBox.js";
import SocketContext from "../contexts/SocketContext.js";
import { useCarousel } from "../contexts/CarouselContext.js";
import { useTranslation } from 'react-i18next';
// Helper to process and set categories
const processCategoryTree = (categoryTree) => {
if (
categoryTree &&
categoryTree.id === 209 &&
Array.isArray(categoryTree.children)
) {
return categoryTree.children;
} else {
return [];
}
};
// Check for cached data
const getProductCache = () => {
if (typeof window !== "undefined" && window.productCache) {
return window.productCache;
}
if (
typeof global !== "undefined" &&
global.window &&
global.window.productCache
) {
return global.window.productCache;
}
return null;
};
// Initialize categories
const initializeCategories = (language = 'en') => {
const productCache = getProductCache();
if (productCache && productCache[`categoryTree_209_${language}`]) {
const cached = productCache[`categoryTree_209_${language}`];
if (cached.categoryTree) {
return processCategoryTree(cached.categoryTree);
}
}
// Only fallback to old cache format if we're looking for German (default language)
// This prevents showing German categories when user wants English categories
if (language === 'de' && 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, i18n } = useTranslation();
const [rootCategories, setRootCategories] = useState([]);
const [currentLanguage, setCurrentLanguage] = useState(i18n.language || 'de');
useEffect(() => {
const initialCategories = initializeCategories(currentLanguage);
setRootCategories(initialCategories);
}, [currentLanguage]);
// Also listen for i18n ready state
useEffect(() => {
if (i18n.isInitialized && i18n.language !== currentLanguage) {
setCurrentLanguage(i18n.language);
}
}, [i18n.isInitialized, i18n.language, currentLanguage]);
// Listen for language changes
useEffect(() => {
const handleLanguageChange = (lng) => {
setCurrentLanguage(lng);
// Clear categories to force refetch
setRootCategories([]);
};
i18n.on('languageChanged', handleLanguageChange);
return () => {
i18n.off('languageChanged', handleLanguageChange);
};
}, [i18n]);
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, language: currentLanguage, requestTranslation: true }, (response) => {
if (response && response.success) {
// Use translated data if available, otherwise fall back to original
const categoryTreeToUse = response.translation || response.categoryTree;
if (categoryTreeToUse) {
// Store in cache with language-specific key
try {
if (!window.productCache) window.productCache = {};
window.productCache[`categoryTree_209_${currentLanguage}`] = {
categoryTree: categoryTreeToUse,
timestamp: Date.now(),
};
} catch (err) {
console.error(err);
}
const newCategories = categoryTreeToUse.children || [];
setRootCategories(newCategories);
}
} else if (response && response.categoryTree) {
// Fallback for old response format
// Store in cache with language-specific key
try {
if (!window.productCache) window.productCache = {};
window.productCache[`categoryTree_209_${currentLanguage}`] = {
categoryTree: response.categoryTree,
timestamp: Date.now(),
};
} catch (err) {
console.error(err);
}
const newCategories = response.categoryTree.children || [];
setRootCategories(newCategories);
}
});
}
}, [context, context?.socket?.connected, rootCategories.length, currentLanguage]);
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>
<div
className="carousel-wrapper"
style={{
position: 'relative',
overflow: 'hidden',
width: '100%',
maxWidth: '1200px',
margin: '0 auto',
padding: '0 20px',
boxSizing: 'border-box'
}}
>
{/* Left Arrow */}
<IconButton
onClick={() => moveCarousel("left")}
aria-label="Vorherige Kategorien anzeigen"
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
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"
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={carouselRef}
style={{
display: 'flex',
gap: '16px',
transition: 'none',
alignItems: 'flex-start',
width: 'fit-content',
overflow: 'visible',
position: 'relative',
transform: 'translateX(0px)',
margin: '0 auto'
}}
>
{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>
))}
</div>
</div>
</div>
</Box>
);
};
export default SharedCarousel;

View File

@@ -106,7 +106,7 @@ class ExtrasSelector 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 && (
@@ -136,7 +136,7 @@ class ExtrasSelector extends Component {
// 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

@@ -147,7 +147,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

@@ -90,7 +90,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>
@@ -218,7 +218,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 && (

View File

@@ -10,7 +10,9 @@ import CloseIcon from '@mui/icons-material/Close';
import { useNavigate } from 'react-router-dom';
import LoginComponent from '../LoginComponent.js';
import CartDropdown from '../CartDropdown.js';
import LanguageSwitcher from '../LanguageSwitcher.js';
import { isUserLoggedIn } from '../LoginComponent.js';
import { withI18n } from '../../i18n/withTranslation.js';
function getBadgeNumber() {
let count = 0;
@@ -116,19 +118,20 @@ class ButtonGroup extends Component {
}
render() {
const { socket, navigate } = this.props;
const { socket, navigate, t } = this.props;
const { isCartOpen } = this.state;
const cartItems = Array.isArray(window.cart) ? window.cart : [];
return (
<Box sx={{ display: 'flex', gap: { xs: 0.5, sm: 1 } }}>
<LanguageSwitcher />
<LoginComponent socket={socket} />
<IconButton
color="inherit"
onClick={this.toggleCart}
aria-label="Warenkorb öffnen"
sx={{ ml: 1 }}
>
<Badge badgeContent={this.state.badgeNumber} color="error">
@@ -154,6 +157,7 @@ class ButtonGroup extends Component {
<IconButton
onClick={this.toggleCart}
size="small"
aria-label="Warenkorb schließen"
sx={{
bgcolor: 'primary.main',
color: 'primary.contrastText',
@@ -164,7 +168,7 @@ class ButtonGroup extends Component {
>
<CloseIcon />
</IconButton>
<Typography variant="h6">Warenkorb</Typography>
<Typography variant="h6">{t ? t('cart.title') : 'Warenkorb'}</Typography>
</Box>
<Divider sx={{ mb: 2 }} />
@@ -173,7 +177,7 @@ class ButtonGroup extends Component {
if (isUserLoggedIn().isLoggedIn) {
this.toggleCart(); // Close the cart drawer
navigate('/profile');
navigate('/profile#cart');
} else if (window.openLoginDrawer) {
window.openLoginDrawer(); // Call global function to open login drawer
this.toggleCart(); // Close the cart drawer
@@ -189,10 +193,11 @@ class ButtonGroup extends Component {
}
}
// Wrapper for ButtonGroup to provide navigate function
// Wrapper for ButtonGroup to provide navigate function and translations
const ButtonGroupWithRouter = (props) => {
const navigate = useNavigate();
return <ButtonGroup {...props} navigate={navigate} />;
const ButtonGroupWithTranslation = withI18n()(ButtonGroup);
return <ButtonGroupWithTranslation {...props} navigate={navigate} />;
};
export default ButtonGroupWithRouter;

View File

@@ -8,6 +8,7 @@ import { Link } from "react-router-dom";
import HomeIcon from "@mui/icons-material/Home";
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) => {
@@ -49,6 +50,9 @@ class CategoryList extends Component {
constructor(props) {
super(props);
// Get current language from props (provided by withI18n HOC)
const currentLanguage = props.languageContext?.currentLanguage || 'de';
// Check for cached data during SSR/initial render
let initialState = {
categoryTree: null,
@@ -58,6 +62,7 @@ class CategoryList extends Component {
activePath: [], // Array of active category objects for each level
fetchedCategories: false,
mobileMenuOpen: false, // State for mobile collapsible menu
currentLanguage: currentLanguage,
};
// Try to get cached data for SSR
@@ -67,7 +72,7 @@ class CategoryList extends Component {
(typeof window !== "undefined" && window.productCache);
if (productCache) {
const cacheKey = "categoryTree_209";
const cacheKey = `categoryTree_209_${currentLanguage}`;
const cachedData = productCache[cacheKey];
if (cachedData && cachedData.categoryTree) {
const { categoryTree, timestamp } = cachedData;
@@ -127,8 +132,27 @@ class CategoryList extends Component {
}
componentDidUpdate(prevProps) {
// Handle socket connection changes
// Handle language changes
const currentLanguage = this.props.languageContext?.currentLanguage || 'de';
const prevLanguage = prevProps.languageContext?.currentLanguage || 'de';
if (currentLanguage !== prevLanguage) {
// Language changed, need to refetch categories
this.setState({
currentLanguage: currentLanguage,
fetchedCategories: false,
categoryTree: null,
level1Categories: [],
level2Categories: [],
level3Categories: [],
activePath: [],
}, () => {
this.fetchCategories();
});
return;
}
// Handle socket connection changes
const wasConnected = prevProps.socket && prevProps.socket.connected;
const isNowConnected = this.props.socket && this.props.socket.connected;
@@ -157,8 +181,6 @@ class CategoryList extends Component {
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;
}
@@ -168,6 +190,9 @@ class CategoryList extends Component {
return;
}
// Get current language from state
const currentLanguage = this.state.currentLanguage || 'de';
// 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) ||
@@ -179,7 +204,7 @@ class CategoryList extends Component {
// Check if we have a valid cache in the global object
try {
const cacheKey = "categoryTree_209";
const cacheKey = `categoryTree_209_${currentLanguage}`;
const cachedData = windowObj && windowObj.productCache ? windowObj.productCache[cacheKey] : null;
if (cachedData) {
const { categoryTree, fetching } = cachedData;
@@ -216,7 +241,7 @@ class CategoryList extends Component {
}
// Mark as being fetched to prevent concurrent calls
const cacheKey = "categoryTree_209";
const cacheKey = `categoryTree_209_${currentLanguage}`;
if (windowObj && windowObj.productCache) {
windowObj.productCache[cacheKey] = {
fetching: true,
@@ -226,15 +251,18 @@ class CategoryList extends Component {
this.setState({ fetchedCategories: true });
//console.log('CategoryList: Fetching categories from socket');
socket.emit("categoryList", { categoryId: 209 }, (response) => {
if (response && response.categoryTree) {
socket.emit("categoryList", { categoryId: 209, language: currentLanguage, requestTranslation: true }, (response) => {
if (response && response.success) {
// Use translated data if available, otherwise fall back to original
const categoryTreeToUse = response.translation || response.categoryTree;
if (categoryTreeToUse) {
// Store in global cache with timestamp
try {
const cacheKey = "categoryTree_209";
const cacheKey = `categoryTree_209_${currentLanguage}`;
if (windowObj && windowObj.productCache) {
windowObj.productCache[cacheKey] = {
categoryTree: response.categoryTree,
categoryTree: categoryTreeToUse,
timestamp: Date.now(),
fetching: false,
};
@@ -242,14 +270,40 @@ class CategoryList extends Component {
} catch (err) {
console.error("Error writing to cache:", err);
}
this.processCategoryTree(response.categoryTree);
this.processCategoryTree(categoryTreeToUse);
} else {
console.error('No category tree found in response');
// Clear cache on error
try {
const cacheKey = "categoryTree_209";
const cacheKey = `categoryTree_209_${currentLanguage}`;
if (windowObj && windowObj.productCache) {
windowObj.productCache[cacheKey] = {
categoryTree: null,
timestamp: Date.now(),
fetching: false,
};
}
} catch (err) {
console.error("Error writing to cache:", err);
}
this.setState({
categoryTree: null,
level1Categories: [],
level2Categories: [],
level3Categories: [],
activePath: [],
});
}
} else {
console.error('Failed to fetch categories:', response);
try {
const cacheKey = `categoryTree_209_${currentLanguage}`;
if (windowObj && windowObj.productCache) {
windowObj.productCache[cacheKey] = {
categoryTree: null,
timestamp: Date.now(),
fetching: false,
};
}
} catch (err) {
@@ -410,7 +464,7 @@ class CategoryList extends Component {
zIndex: 2,
}}
>
Startseite
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
</Box>
{/* Thin text (positioned on top) */}
<Box
@@ -424,7 +478,7 @@ class CategoryList extends Component {
zIndex: 1,
}}
>
Startseite
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
</Box>
</Box>
)}
@@ -595,7 +649,10 @@ class CategoryList extends Component {
onClick={this.handleMobileMenuToggle}
role="button"
tabIndex={0}
aria-label={mobileMenuOpen ? "Kategorien schließen" : "Kategorien öffnen"}
aria-label={this.props.t ?
(mobileMenuOpen ? this.props.t('navigation.categoriesClose') : this.props.t('navigation.categoriesOpen')) :
(mobileMenuOpen ? "Kategorien schließen" : "Kategorien öffnen")
}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
@@ -607,7 +664,7 @@ class CategoryList extends Component {
fontWeight: "bold",
textShadow: "0 1px 2px rgba(0,0,0,0.3)"
}}>
Kategorien
{this.props.t ? this.props.t('navigation.categories') : 'Kategorien'}
</Typography>
<Box sx={{ display: "flex", alignItems: "center" }}>
{mobileMenuOpen ? <CloseIcon /> : <MenuIcon />}
@@ -628,4 +685,4 @@ class CategoryList extends Component {
}
}
export default CategoryList;
export default withI18n()(CategoryList);

View File

@@ -8,7 +8,9 @@ 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";
@@ -184,6 +186,15 @@ const SearchBar = () => {
}, 200);
};
// Handle enter icon click
const handleEnterClick = () => {
delete window.currentSearchQuery;
setShowSuggestions(false);
if (searchQuery.trim()) {
navigate(`/search?q=${encodeURIComponent(searchQuery)}`);
}
};
// Clean up timers on unmount
React.useEffect(() => {
return () => {
@@ -244,9 +255,24 @@ const SearchBar = () => {
<SearchIcon />
</InputAdornment>
),
endAdornment: loadingSuggestions && (
endAdornment: (
<InputAdornment position="end">
<CircularProgress size={16} />
{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": {
color: "primary.main",
},
}}
>
<KeyboardReturnIcon fontSize="small" />
</IconButton>
</InputAdornment>
),
sx: { borderRadius: 2, bgcolor: "background.paper" },

View File

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

View File

@@ -6,6 +6,7 @@ import PaymentConfirmationDialog from "./PaymentConfirmationDialog.js";
import OrderProcessingService from "./OrderProcessingService.js";
import CheckoutValidation from "./CheckoutValidation.js";
import SocketContext from "../../contexts/SocketContext.js";
import { withI18n } from "../../i18n/index.js";
class CartTab extends Component {
constructor(props) {
@@ -116,7 +117,7 @@ class CartTab extends Component {
// Determine payment method - respect constraints
let prefillPaymentMethod = template.payment_method || "wire";
const paymentMethodMap = {
"credit_card": "stripe",
"credit_card": "mollie",/*stripe*/
"bank_transfer": "wire",
"cash_on_delivery": "onDelivery",
"cash": "cash"
@@ -292,7 +293,7 @@ class CartTab extends Component {
};
validateAddressForm = () => {
const errors = CheckoutValidation.validateAddressForm(this.state);
const errors = CheckoutValidation.validateAddressForm(this.state, this.props.t);
this.setState({ addressFormErrors: errors });
return Object.keys(errors).length === 0;
};
@@ -322,7 +323,7 @@ class CartTab extends Component {
handleCompleteOrder = () => {
this.setState({ completionError: null }); // Clear previous errors
const validationError = CheckoutValidation.getValidationErrorMessage(this.state);
const validationError = CheckoutValidation.getValidationErrorMessage(this.state, false, this.props.t);
if (validationError) {
this.setState({ completionError: validationError });
this.validateAddressForm(); // To show field-specific errors
@@ -363,6 +364,40 @@ class CartTab extends Component {
this.orderService.createStripeIntent(totalAmount, this.loadStripeComponent);
return;
}
// Handle molllie payment differently
if (paymentMethod === "mollie") {
// Store the cart items used for mollie payment in sessionStorage for later reference
try {
sessionStorage.setItem('molliePaymentCart', JSON.stringify(cartItems));
} catch (error) {
console.error("Failed to store mollie payment cart:", error);
}
// Calculate total amount for mollie
const subtotal = cartItems.reduce(
(total, item) => total + item.price * item.quantity,
0
);
const totalAmount = Math.round((subtotal + deliveryCost) * 100) / 100;
// Prepare complete order data for Mollie intent creation
const mollieOrderData = {
amount: totalAmount,
items: cartItems,
invoiceAddress,
deliveryAddress: useSameAddress ? invoiceAddress : deliveryAddress,
deliveryMethod,
paymentMethod: "mollie",
deliveryCost,
note,
domain: window.location.origin,
saveAddressForFuture,
};
this.orderService.createMollieIntent(mollieOrderData);
return;
}
// Handle regular orders
const orderData = {
@@ -405,7 +440,7 @@ class CartTab extends Component {
const deliveryCost = this.orderService.getDeliveryCost();
const { isPickupOnly, hasStecklinge } = CheckoutValidation.getCartItemFlags(cartItems);
const preSubmitError = CheckoutValidation.getValidationErrorMessage(this.state);
const preSubmitError = CheckoutValidation.getValidationErrorMessage(this.state, false, this.props.t);
const displayError = completionError || preSubmitError;
return (
@@ -445,7 +480,7 @@ class CartTab extends Component {
{isLoadingStripe ? (
<Box sx={{ textAlign: 'center', py: 4 }}>
<Typography variant="body1">
Zahlungskomponente wird geladen...
{this.props.t ? this.props.t('payment.loadingPaymentComponent') : 'Zahlungskomponente wird geladen...'}
</Typography>
</Box>
) : showStripePayment && StripeComponent ? (
@@ -463,7 +498,7 @@ class CartTab extends Component {
}
}}
>
Zurück zur Bestellung
{this.props.t ? this.props.t('cart.backToOrder') : '← Zurück zur Bestellung'}
</Button>
</Box>
<StripeComponent clientSecret={stripeClientSecret} />
@@ -507,4 +542,4 @@ class CartTab extends Component {
// Set static contextType to access the socket
CartTab.contextType = SocketContext;
export default CartTab;
export default withI18n()(CartTab);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect, useContext, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { withI18n } from "../../i18n/withTranslation.js";
import {
Box,
Paper,
@@ -14,19 +15,28 @@ import {
Tooltip,
CircularProgress,
Typography,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
} 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
const statusTranslations = {
new: "in Bearbeitung",
pending: "Neu",
processing: "in Bearbeitung",
cancelled: "Storniert",
shipped: "Verschickt",
delivered: "Geliefert",
const getStatusTranslation = (status, t) => {
const statusMap = {
new: t ? t('orders.status.new') : "in Bearbeitung",
pending: t ? t('orders.status.pending') : "Neu",
processing: t ? t('orders.status.processing') : "in Bearbeitung",
cancelled: t ? t('orders.status.cancelled') : "Storniert",
shipped: t ? t('orders.status.shipped') : "Verschickt",
delivered: t ? t('orders.status.delivered') : "Geliefert",
};
return statusMap[status] || status;
};
const statusEmojis = {
@@ -61,12 +71,15 @@ const currencyFormatter = new Intl.NumberFormat("de-DE", {
});
// Orders Tab Content Component
const OrdersTab = ({ orderIdFromHash }) => {
const OrdersTab = ({ orderIdFromHash, t }) => {
const [orders, setOrders] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [selectedOrder, setSelectedOrder] = useState(null);
const [isDetailsDialogOpen, setIsDetailsDialogOpen] = useState(false);
const [cancelConfirmOpen, setCancelConfirmOpen] = useState(false);
const [orderToCancel, setOrderToCancel] = useState(null);
const [isCancelling, setIsCancelling] = useState(false);
const {socket} = useContext(SocketContext);
const navigate = useNavigate();
@@ -77,9 +90,11 @@ const OrdersTab = ({ orderIdFromHash }) => {
if (orderToView) {
setSelectedOrder(orderToView);
setIsDetailsDialogOpen(true);
// Update the hash to include the order ID
navigate(`/profile#${orderId}`, { replace: true });
}
},
[orders]
[orders, navigate]
);
const fetchOrders = useCallback(() => {
@@ -120,7 +135,7 @@ const OrdersTab = ({ orderIdFromHash }) => {
}, [orderIdFromHash, orders, handleViewDetails]);
const getStatusDisplay = (status) => {
return statusTranslations[status] || status;
return getStatusTranslation(status, t);
};
const getStatusEmoji = (status) => {
@@ -134,7 +149,48 @@ const OrdersTab = ({ orderIdFromHash }) => {
const handleCloseDetailsDialog = () => {
setIsDetailsDialogOpen(false);
setSelectedOrder(null);
navigate("/profile", { replace: true });
navigate("/profile#orders", { replace: true });
};
// Check if order can be cancelled
const isOrderCancelable = (order) => {
const cancelableStatuses = ['new', 'pending', 'processing'];
return cancelableStatuses.includes(order.status);
};
// Handle cancel button click
const handleCancelClick = (order) => {
setOrderToCancel(order);
setCancelConfirmOpen(true);
};
// Handle cancel confirmation
const handleConfirmCancel = () => {
if (!orderToCancel || !socket) return;
setIsCancelling(true);
socket.emit('cancelOrder', { orderId: orderToCancel.orderId }, (response) => {
setIsCancelling(false);
setCancelConfirmOpen(false);
if (response.success) {
console.log('Order cancelled:', response.orderId);
// Refresh orders list
fetchOrders();
} else {
setError(response.error || 'Failed to cancel order');
}
setOrderToCancel(null);
});
};
// Handle cancel dialog close
const handleCancelDialogClose = () => {
if (!isCancelling) {
setCancelConfirmOpen(false);
setOrderToCancel(null);
}
};
if (loading) {
@@ -160,22 +216,21 @@ const OrdersTab = ({ orderIdFromHash }) => {
<Table>
<TableHead>
<TableRow>
<TableCell>Bestellnummer</TableCell>
<TableCell>Datum</TableCell>
<TableCell>Status</TableCell>
<TableCell>Artikel</TableCell>
<TableCell align="right">Summe</TableCell>
<TableCell align="center">Aktionen</TableCell>
<TableCell>{t ? t('orders.table.orderNumber') : 'Bestellnummer'}</TableCell>
<TableCell>{t ? t('orders.table.date') : 'Datum'}</TableCell>
<TableCell>{t ? t('orders.table.status') : 'Status'}</TableCell>
<TableCell>{t ? t('orders.table.items') : 'Artikel'}</TableCell>
<TableCell align="right">{t ? t('orders.table.total') : 'Summe'}</TableCell>
<TableCell align="center">{t ? t('orders.table.actions') : 'Aktionen'}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{orders.map((order) => {
const displayStatus = getStatusDisplay(order.status);
const subtotal = order.items.reduce(
const total = order.items.reduce(
(acc, item) => acc + item.price * item.quantity_ordered,
0
);
const total = subtotal + order.delivery_cost;
return (
<TableRow key={order.orderId} hover>
<TableCell>{order.orderId}</TableCell>
@@ -204,7 +259,16 @@ const OrdersTab = ({ orderIdFromHash }) => {
</Box>
</TableCell>
<TableCell>
{order.items.reduce(
{order.items
.filter(item => {
// Exclude delivery items - backend uses deliveryMethod ID as item name
const itemName = item.name || '';
return itemName !== 'DHL' &&
itemName !== 'DPD' &&
itemName !== 'Sperrgut' &&
itemName !== 'Abholung';
})
.reduce(
(acc, item) => acc + item.quantity_ordered,
0
)}
@@ -213,15 +277,30 @@ const OrdersTab = ({ orderIdFromHash }) => {
{currencyFormatter.format(total)}
</TableCell>
<TableCell align="center">
<Tooltip title="Details anzeigen">
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'center' }}>
<Tooltip title={t ? t('orders.tooltips.viewDetails') : 'Details anzeigen'}>
<IconButton
size="small"
color="primary"
onClick={() => handleViewDetails(order.orderId)}
aria-label={t ? t('orders.tooltips.viewDetails') : 'Details anzeigen'}
>
<SearchIcon />
</IconButton>
</Tooltip>
{isOrderCancelable(order) && (
<Tooltip title={t ? t('orders.tooltips.cancelOrder') : 'Bestellung stornieren'}>
<IconButton
size="small"
color="error"
onClick={() => handleCancelClick(order)}
aria-label={t ? t('orders.tooltips.cancelOrder') : 'Bestellung stornieren'}
>
<CancelIcon />
</IconButton>
</Tooltip>
)}
</Box>
</TableCell>
</TableRow>
);
@@ -231,7 +310,7 @@ const OrdersTab = ({ orderIdFromHash }) => {
</TableContainer>
) : (
<Alert severity="info">
Sie haben noch keine Bestellungen aufgegeben.
{t ? t('orders.noOrders') : 'Sie haben noch keine Bestellungen aufgegeben.'}
</Alert>
)}
<OrderDetailsDialog
@@ -239,8 +318,49 @@ const OrdersTab = ({ orderIdFromHash }) => {
onClose={handleCloseDetailsDialog}
order={selectedOrder}
/>
{/* Cancel Confirmation Dialog */}
<Dialog
open={cancelConfirmOpen}
onClose={handleCancelDialogClose}
maxWidth="sm"
fullWidth
>
<DialogTitle>
{t ? t('orders.cancelConfirm.title') : 'Bestellung stornieren'}
</DialogTitle>
<DialogContent>
<Typography>
{t ? t('orders.cancelConfirm.message') : 'Sind Sie sicher, dass Sie diese Bestellung stornieren möchten?'}
</Typography>
{orderToCancel && (
<Typography variant="body2" sx={{ mt: 1, fontWeight: 'bold' }}>
{t ? t('orders.table.orderNumber') : 'Bestellnummer'}: {orderToCancel.orderId}
</Typography>
)}
</DialogContent>
<DialogActions>
<Button
onClick={handleCancelDialogClose}
disabled={isCancelling}
>
{t ? t('common.cancel') : 'Abbrechen'}
</Button>
<Button
onClick={handleConfirmCancel}
color="error"
variant="contained"
disabled={isCancelling}
>
{isCancelling
? (t ? t('orders.cancelConfirm.cancelling') : 'Wird storniert...')
: (t ? t('orders.cancelConfirm.confirm') : 'Stornieren')
}
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default OrdersTab;
export default withI18n()(OrdersTab);

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,225 @@
import React, { createContext, useContext, useRef, useEffect, useState } from 'react';
const CarouselContext = createContext();
export const useCarousel = () => {
const context = useContext(CarouselContext);
if (!context) {
throw new Error('useCarousel must be used within a CarouselProvider');
}
return context;
};
export const CarouselProvider = ({ children }) => {
const carouselRef = useRef(null);
const scrollPositionRef = useRef(0);
const animationIdRef = useRef(null);
const isPausedRef = useRef(false);
const resumeTimeoutRef = useRef(null);
const [filteredCategories, setFilteredCategories] = useState([]);
// Initialize refs properly
useEffect(() => {
isPausedRef.current = false;
scrollPositionRef.current = 0;
}, []);
// Auto-scroll effect
useEffect(() => {
if (filteredCategories.length === 0) return;
const startAnimation = () => {
if (!carouselRef.current) {
return false;
}
isPausedRef.current = false;
const itemWidth = 146; // 130px + 16px gap
const totalWidth = filteredCategories.length * itemWidth;
const animate = () => {
if (!animationIdRef.current || isPausedRef.current) {
return;
}
scrollPositionRef.current += 0.5;
if (scrollPositionRef.current >= totalWidth) {
scrollPositionRef.current = 0;
}
if (carouselRef.current) {
const transform = `translateX(-${scrollPositionRef.current}px)`;
carouselRef.current.style.transform = transform;
}
animationIdRef.current = requestAnimationFrame(animate);
};
if (!isPausedRef.current) {
animationIdRef.current = requestAnimationFrame(animate);
return true;
}
return false;
};
// Try immediately, then with increasing delays
if (!startAnimation()) {
const timeout1 = setTimeout(() => {
if (!startAnimation()) {
const timeout2 = setTimeout(() => {
if (!startAnimation()) {
const timeout3 = setTimeout(startAnimation, 2000);
return () => clearTimeout(timeout3);
}
}, 1000);
return () => clearTimeout(timeout2);
}
}, 100);
return () => {
isPausedRef.current = true;
clearTimeout(timeout1);
if (animationIdRef.current) {
cancelAnimationFrame(animationIdRef.current);
}
if (resumeTimeoutRef.current) {
clearTimeout(resumeTimeoutRef.current);
}
};
}
return () => {
isPausedRef.current = true;
if (animationIdRef.current) {
cancelAnimationFrame(animationIdRef.current);
}
if (resumeTimeoutRef.current) {
clearTimeout(resumeTimeoutRef.current);
}
};
}, [filteredCategories]);
// Additional effect for when ref becomes available
useEffect(() => {
if (filteredCategories.length > 0 && carouselRef.current && !animationIdRef.current) {
isPausedRef.current = false;
const itemWidth = 146;
const totalWidth = filteredCategories.length * itemWidth;
const animate = () => {
if (!animationIdRef.current || isPausedRef.current) {
return;
}
scrollPositionRef.current += 0.5;
if (scrollPositionRef.current >= totalWidth) {
scrollPositionRef.current = 0;
}
if (carouselRef.current) {
const transform = `translateX(-${scrollPositionRef.current}px)`;
carouselRef.current.style.transform = transform;
}
animationIdRef.current = requestAnimationFrame(animate);
};
if (!isPausedRef.current) {
animationIdRef.current = requestAnimationFrame(animate);
}
}
});
// Manual navigation
const moveCarousel = (direction) => {
if (!carouselRef.current) return;
// Pause auto-scroll
isPausedRef.current = true;
if (animationIdRef.current) {
cancelAnimationFrame(animationIdRef.current);
animationIdRef.current = null;
}
const itemWidth = 146;
const moveAmount = itemWidth * 3;
const totalWidth = filteredCategories.length * itemWidth;
if (direction === "left") {
scrollPositionRef.current -= moveAmount;
if (scrollPositionRef.current < 0) {
scrollPositionRef.current = totalWidth + scrollPositionRef.current;
}
} else {
scrollPositionRef.current += moveAmount;
if (scrollPositionRef.current >= totalWidth) {
scrollPositionRef.current = scrollPositionRef.current % totalWidth;
}
}
// Apply smooth transition
carouselRef.current.style.transition = "transform 0.5s ease-in-out";
carouselRef.current.style.transform = `translateX(-${scrollPositionRef.current}px)`;
// Remove transition after animation
setTimeout(() => {
if (carouselRef.current) {
carouselRef.current.style.transition = "none";
}
}, 500);
// Clear existing timeout
if (resumeTimeoutRef.current) {
clearTimeout(resumeTimeoutRef.current);
}
// Resume auto-scroll after 3 seconds
resumeTimeoutRef.current = setTimeout(() => {
isPausedRef.current = false;
const animate = () => {
if (!animationIdRef.current || isPausedRef.current) {
return;
}
scrollPositionRef.current += 0.5;
if (scrollPositionRef.current >= totalWidth) {
scrollPositionRef.current = 0;
}
if (carouselRef.current) {
carouselRef.current.style.transform = `translateX(-${scrollPositionRef.current}px)`;
}
animationIdRef.current = requestAnimationFrame(animate);
};
animationIdRef.current = requestAnimationFrame(animate);
}, 3000);
};
const value = {
carouselRef,
scrollPositionRef,
animationIdRef,
isPausedRef,
resumeTimeoutRef,
filteredCategories,
setFilteredCategories,
moveCarousel
};
return (
<CarouselContext.Provider value={value}>
{children}
</CarouselContext.Provider>
);
};
export default CarouselContext;

221
src/i18n/index.js Normal file
View File

@@ -0,0 +1,221 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
// Note: LanguageDetector not used - we have custom detector
// Only import German translations by default
import translationDE from './locales/de/index.js';
import legalAgbDE from './locales/de/legal-agb.js';
import legalDatenschutzDE from './locales/de/legal-datenschutz.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';
// Language loading cache to prevent duplicate loads
const languageCache = new Set(['de']);
const loadingPromises = new Map();
// Lazy loading function for languages
const loadLanguage = async (language) => {
if (languageCache.has(language)) {
return; // Already loaded
}
if (loadingPromises.has(language)) {
return loadingPromises.get(language); // Already loading
}
const loadingPromise = (async () => {
try {
console.log(`🌍 Lazy loading language: ${language}`);
// Dynamic imports for lazy loading
const [
translation,
legalAgb,
legalDatenschutz,
legalImpressum,
legalWiderruf,
legalBatterie
] = await Promise.all([
import(`./locales/${language}/index.js`),
import(`./locales/${language}/legal-agb.js`),
import(`./locales/${language}/legal-datenschutz.js`),
import(`./locales/${language}/legal-impressum.js`),
import(`./locales/${language}/legal-widerruf.js`),
import(`./locales/${language}/legal-batterie.js`)
]);
// Add the loaded resources to i18n
i18n.addResourceBundle(language, 'translation', translation.default);
i18n.addResourceBundle(language, 'legal-agb', legalAgb.default);
i18n.addResourceBundle(language, 'legal-datenschutz', legalDatenschutz.default);
i18n.addResourceBundle(language, 'legal-impressum', legalImpressum.default);
i18n.addResourceBundle(language, 'legal-widerruf', legalWiderruf.default);
i18n.addResourceBundle(language, 'legal-batterie', legalBatterie.default);
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);
}
})();
loadingPromises.set(language, loadingPromise);
return loadingPromise;
};
// 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';
}
// 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
}
// 2. Check localStorage
try {
if (typeof localStorage !== 'undefined') {
const localLang = localStorage.getItem('i18nextLng');
if (localLang && localLang !== 'de') {
return localLang;
}
}
} catch {
// LocalStorage not available
}
// 3. Always default to German (don't detect browser language)
return 'de';
},
cacheUserLanguage(lng) {
// Only cache in browser environment
if (typeof window === 'undefined') {
return;
}
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-impressum': legalImpressumDE,
'legal-widerruf': legalWiderrufDE,
'legal-batterie': legalBatterieDE
}
};
i18n
.use({
type: 'languageDetector',
async: false,
detect: customDetector.lookup,
init() {},
cacheUserLanguage: customDetector.cacheUserLanguage
})
.use(initReactI18next)
.init({
resources,
lng: 'de', // Force German as default
fallbackLng: 'de',
debug: process.env.NODE_ENV === 'development',
// Disable automatic language detection from browser
detection: {
order: ['customDetector'],
caches: ['localStorage', 'sessionStorage']
},
interpolation: {
escapeValue: false // React already escapes values
},
// Namespace configuration
defaultNS: 'translation',
// React-specific options
react: {
useSuspense: false // Disable suspense for class components compatibility
},
// Load missing keys as fallback
saveMissing: process.env.NODE_ENV === 'development'
});
// 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);
};
// 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

@@ -0,0 +1,25 @@
export default {
"login": "تسجيل الدخول",
"register": "تسجيل",
"logout": "تسجيل خروج",
"profile": "الملف الشخصي",
"email": "البريد الإلكتروني",
"password": "كلمة المرور",
"confirmPassword": "تأكيد كلمة المرور",
"forgotPassword": "هل نسيت كلمة المرور؟",
"loginWithGoogle": "تسجيل الدخول باستخدام جوجل",
"or": "أو",
"privacyAccept": "بالنقر على \"تسجيل الدخول باستخدام جوجل\" أوافق على",
"privacyPolicy": "سياسة الخصوصية",
"passwordMinLength": "يجب أن تكون كلمة المرور 8 أحرف على الأقل",
"newPasswordMinLength": "يجب أن تكون كلمة المرور الجديدة 8 أحرف على الأقل",
"menu": {
"profile": "الملف الشخصي",
"myProfile": "ملفي الشخصي",
"checkout": "إتمام الشراء",
"orders": "الطلبات",
"settings": "الإعدادات",
"adminDashboard": "لوحة تحكم المسؤول",
"adminUsers": "مستخدمو المسؤول"
}
};

View File

@@ -0,0 +1,39 @@
export default {
"title": "العربة",
"empty": "فارغ",
"addToCart": "أضف إلى العربة",
"preorderCutting": "اطلب مسبقًا كقطع",
"continueShopping": "تابع التسوق",
"proceedToCheckout": "المتابعة إلى الدفع",
"productCount": "{{count}} {{count, plural, one {منتج} other {منتجات}}}",
"productSingular": "منتج",
"productPlural": "منتجات",
"removeFromCart": "إزالة من العربة",
"openCart": "افتح العربة",
"availableFrom": "متاح من {{date}}",
"backToOrder": "← العودة إلى الطلب",
"summary": {
"title": "ملخص الطلب",
"goodsNet": "البضائع (صافي):",
"shippingNet": "الشحن (صافي):",
"totalGoods": "إجمالي البضائع:",
"shippingCosts": "تكاليف الشحن:",
"total": "الإجمالي:",
"totalWeight": "الوزن الكلي: {{weight}} كجم",
"freeFrom100": "(مجاني من €100)",
"free": "مجاني"
},
"itemCount": {
"singular": "منتج",
"plural": "منتجات"
},
"sync": {
"title": "مزامنة العربة",
"description": "لديك عربة محفوظة في حسابك. يرجى اختيار كيفية المتابعة:",
"deleteServer": "حذف عربة الخادم",
"useServer": "استخدام عربة الخادم",
"merge": "دمج العربات",
"currentCart": "عربتك الحالية",
"serverCart": "العربة المحفوظة في ملفك الشخصي"
}
};

View File

@@ -0,0 +1,3 @@
export default {
"privacyRead": "تم القراءة والموافقة",
};

View File

@@ -0,0 +1,34 @@
export default {
"invoiceAddress": "عنوان الفاتورة",
"deliveryAddress": "عنوان التوصيل",
"saveForFuture": "احفظ للطلبات المستقبلية",
"pickupDate": "لمين التاريخ مطلوب استلام القصاصات؟",
"note": "ملاحظة",
"sameAddress": "عنوان التوصيل هو نفسه عنوان الفاتورة",
"termsAccept": "لقد قرأت الشروط والأحكام، سياسة الخصوصية، وأحكام حق الانسحاب",
"selectDeliveryMethod": "اختر طريقة الشحن",
"selectPaymentMethod": "اختر طريقة الدفع",
"orderSummary": "ملخص الطلب",
"addressValidationError": "يرجى التحقق من بياناتك في حقول العنوان.",
"processingOrder": "يتم معالجة الطلب...",
"completeOrder": "إتمام الطلب",
"termsValidationError": "يرجى قبول الشروط والأحكام، سياسة الخصوصية، وحق الانسحاب للمتابعة.",
"addressFields": {
"firstName": "الاسم الأول",
"lastName": "اسم العائلة",
"addressSupplement": "إضافة للعنوان",
"street": "الشارع",
"houseNumber": "رقم المنزل",
"postalCode": "الرمز البريدي",
"city": "المدينة",
"country": "البلد"
},
"validationErrors": {
"firstNameRequired": "الاسم الأول مطلوب",
"lastNameRequired": "اسم العائلة مطلوب",
"streetRequired": "الشارع مطلوب",
"houseNumberRequired": "رقم المنزل مطلوب",
"postalCodeRequired": "الرمز البريدي مطلوب",
"cityRequired": "المدينة مطلوبة"
}
};

View File

@@ -0,0 +1,19 @@
export default {
"loading": "جارٍ التحميل...",
"error": "خطأ",
"close": "إغلاق",
"save": "حفظ",
"cancel": "إلغاء",
"ok": "موافق",
"yes": "نعم",
"no": "لا",
"next": "التالي",
"back": "رجوع",
"edit": "تعديل",
"delete": "حذف",
"add": "إضافة",
"remove": "إزالة",
"products": "منتجات",
"product": "منتج",
"days": "أيام"
};

View File

@@ -0,0 +1,35 @@
export default {
"methods": {
"dhl": "DHL",
"dpd": "DPD",
"sperrgut": "بضائع ضخمة",
"sperrgutName": "بضائع ضخمة",
"pickup": "استلام من المتجر"
},
"descriptions": {
"standard": "الشحن العادي",
"standardFree": "الشحن العادي - مجاني من قيمة طلب 100€!",
"notAvailable": "غير قابل للاختيار لأن عنصر واحد أو أكثر يمكن استلامه فقط",
"bulky": "للعناصر الكبيرة والثقيلة",
"pickupOnly": "الاستلام فقط"
},
"prices": {
"free": "مجاني",
"freeFrom100": "(مجاني من 100€)",
"dhl": "6.99 €",
"dpd": "4.90 €",
"sperrgut": "28.99 €"
},
"times": {
"cutting14Days": "مدة التوصيل: 14 يوم",
"standard2to3Days": "مدة التوصيل: 2-3 أيام",
"supplier7to9Days": "مدة التوصيل: 7-9 أيام"
},
"selector": {
"title": "اختر طريقة الشحن",
"freeShippingInfo": "💡 الشحن مجاني من قيمة طلب 100€!",
"remainingForFree": "أضف {{amount}}€ أخرى للشحن المجاني.",
"congratsFreeShipping": "🎉 مبروك! حصلت على شحن مجاني!",
"cartQualifiesFree": "سلة مشترياتك بقيمة {{amount}}€ مؤهلة للشحن المجاني."
}
};

View File

@@ -0,0 +1,7 @@
export default {
"sorting": "الترتيب",
"perPage": "لكل صفحة",
"availability": "التوفر",
"manufacturer": "الشركة المصنعة",
"all": "الكل"
};

View File

@@ -0,0 +1,15 @@
export default {
"hours": "السبت 11 صباحًا - 7 مساءً",
"address": "شارع تراشنبرجر 14 - دريسدن",
"location": "بين محطة بيسشن وميدان تراشنبرجر",
"allPricesIncl": "* جميع الأسعار تشمل ضريبة القيمة المضافة القانونية، بالإضافة إلى الشحن",
"copyright": "© {{year}} GrowHeads.de",
"legal": {
"datenschutz": "سياسة الخصوصية",
"agb": "الشروط والأحكام",
"sitemap": "خريطة الموقع",
"impressum": "الإشعار القانوني",
"batteriegesetzhinweise": "معلومات قانون البطاريات",
"widerrufsrecht": "حق الانسحاب"
}
};

View File

@@ -0,0 +1,43 @@
import locale from './locale.js';
import navigation from './navigation.js';
import auth from './auth.js';
import cart from './cart.js';
import product from './product.js';
import search from './search.js';
import sorting from './sorting.js';
import chat from './chat.js';
import delivery from './delivery.js';
import checkout from './checkout.js';
import payment from './payment.js';
import filters from './filters.js';
import tax from './tax.js';
import footer from './footer.js';
import titles from './titles.js';
import sections from './sections.js';
import pages from './pages.js';
import orders from './orders.js';
import settings from './settings.js';
import common from './common.js';
export default {
"locale": locale,
"navigation": navigation,
"auth": auth,
"cart": cart,
"product": product,
"search": search,
"sorting": sorting,
"chat": chat,
"delivery": delivery,
"checkout": checkout,
"payment": payment,
"filters": filters,
"tax": tax,
"footer": footer,
"titles": titles,
"sections": sections,
"pages": pages,
"orders": orders,
"settings": settings,
"common": common
};

View File

@@ -0,0 +1,69 @@
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

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

View File

@@ -0,0 +1,70 @@
export default {
"title": "سياسة الخصوصية",
"responsibleParty": {
"title": "الجهة المسؤولة بموجب قانون حماية البيانات:",
"company": "Growheads\nTrachenberger Straße 14\n01129 Dresden"
},
"generalInfo": "ما لم يُذكر خلاف ذلك أدناه، فإن تقديم بياناتك الشخصية ليس مطلوبًا قانونيًا أو تعاقديًا، ولا هو ضروري لإبرام عقد. أنت غير ملزم بتقديم البيانات. عدم تقديمها لا يترتب عليه أي عواقب. هذا ينطبق فقط طالما لم يُذكر خلاف ذلك في عمليات المعالجة التالية. \"البيانات الشخصية\" تعني جميع المعلومات المتعلقة بشخص طبيعي محدد أو قابل للتحديد.",
"sections": {
"informationDeletion": {
"title": "المعلومات، الحذف، الحظر",
"content": "يمكنك في أي وقت طلب معلومات عن بياناتك الشخصية، مصدرها والمستلمين لها، وهدف معالجة البيانات، كما يمكنك طلب تصحيح أو حظر أو حذف هذه البيانات مجانًا. يرجى استخدام خيارات الاتصال الموجودة في تذييل الصفحة أو في الإشعار القانوني لهذا الغرض. نحن متاحون أيضًا في أي وقت لأي أسئلة إضافية حول الموضوع. يرجى ملاحظة أننا غير مخولين ولن نقوم بحذف بيانات الفواتير، بيانات البنك، والبيانات التي تم إرسالها لمزود خدمة الشحن. البيانات التي يمكن حذفها تشمل: حسابات العملاء على خادم الويب، وكذلك في نظام إدارة البضائع، والبريد الإلكتروني الذي لا يرتبط مباشرة بطلب.",
},
"serverLogfiles": {
"title": "ملفات سجل الخادم",
"content": "يمكنك زيارة مواقعنا الإلكترونية دون تقديم أي معلومات عن نفسك. في كل مرة تصل فيها إلى موقعنا، يتم إرسال بيانات الاستخدام من متصفح الإنترنت الخاص بك وتخزينها في بيانات السجل (ملفات سجل الخادم). تشمل هذه البيانات المخزنة، على سبيل المثال، اسم الصفحة التي تم الوصول إليها، تاريخ ووقت الوصول، كمية البيانات المنقولة، ومزود الخدمة الذي يطلب البيانات. تُستخدم هذه البيانات فقط لضمان التشغيل السلس لموقعنا وتحسين عرضنا. هذه البيانات ليست بيانات شخصية. لا يتم دمج هذه البيانات مع مصادر بيانات أخرى. إذا علمنا بمؤشرات محددة على استخدام غير قانوني، نحتفظ بالحق في مراجعة هذه البيانات لاحقًا.",
},
"customerAccount": {
"title": "حساب العميل",
"content": "عند فتح حساب عميل، نجمع بياناتك الشخصية بالقدر المحدد هناك. تهدف معالجة البيانات إلى تحسين تجربة التسوق الخاصة بك وتبسيط معالجة الطلبات. تتم المعالجة بناءً على المادة 6 (1) حرف أ من DSGVO بموافقتك. يمكنك سحب موافقتك في أي وقت بإبلاغنا، دون التأثير على قانونية المعالجة التي تمت بناءً على الموافقة حتى السحب. سيتم حذف حساب العميل الخاص بك بعد ذلك.",
},
"googleSSO": {
"title": "تسجيل الدخول باستخدام Google (تسجيل الدخول الموحد من Google)",
"content": "نقدم لك خيار تسجيل الدخول إلى حساب العميل الخاص بك باستخدام حساب Google الخاص بك. عند استخدام وظيفة \"تسجيل الدخول باستخدام Google\"، يتم التحقق من الهوية عبر خدمة Google Single Sign-On. في هذه العملية، قد يتم تخزين ملفات تعريف الارتباط من Google على جهازك، وهي ضرورية لعملية تسجيل الدخول والتحقق من الهوية. كجزء من تسجيل الدخول عبر Google، نتلقى من Google بيانات شخصية معينة للتحقق من هويتك. على وجه الخصوص، تنقل Google لنا اسمك، عنوان بريدك الإلكتروني، وإذا كان مخزنًا في حساب Google الخاص بك، صورة ملفك الشخصي. يتم توفير هذه المعلومات من Google بمجرد تسجيل دخولك إلى متجرنا الإلكتروني باستخدام حساب Google الخاص بك. يمكن لـ Google، كمزود طرف ثالث، الوصول إلى هذه البيانات ومعالجتها؛ وقد يشمل ذلك نقل البيانات إلى الولايات المتحدة الأمريكية. لقد أبرمنا مع Google بنود حماية بيانات قياسية وفقًا للمادة 46 (2) حرف ج من DSGVO لضمان مستوى مناسب من حماية البيانات عند نقل بياناتك. يمكن العثور على مزيد من التفاصيل حول معالجة البيانات بواسطة Google في سياسة الخصوصية الخاصة بـ Google (على https://policies.google.com/privacy?hl=en).",
"legalBasis": "تتم معالجة البيانات المتعلقة بتسجيل الدخول عبر Google بناءً على المادة 6 (1) حرف ب من DSGVO (تنفيذ التدابير التمهيدية للعقد وتنفيذ العقد، مثل إنشاء واستخدام حساب العميل الخاص بك) وكذلك المادة 6 (1) حرف ف من DSGVO (مصلحتنا المشروعة في توفير خيار تسجيل دخول سريع ومريح لك).",
"voluntaryUse": "استخدام وظيفة \"تسجيل الدخول باستخدام Google\" هو أمر طوعي. بالطبع يمكنك أيضًا استخدام متجرنا الإلكتروني وحساب العميل الخاص بك بدون Google SSO عن طريق التسجيل أو تسجيل الدخول باستخدام بريدك الإلكتروني وكلمة المرور كالمعتاد. إذا اخترت استخدام تسجيل الدخول عبر Google، يمكنك قطع هذا الرابط في أي وقت عن طريق إزالة الاتصال في إعدادات حساب Google الخاص بك.",
"yourRights": "فيما يتعلق بالبيانات الشخصية المعالجة عبر Google SSO، لديك الحقوق القانونية كصاحب بيانات. على وجه الخصوص، لديك الحق في الحصول على معلومات حول البيانات المخزنة عنك (المادة 15 DSGVO)، وتصحيح البيانات غير الدقيقة (المادة 16 DSGVO)، أو طلب حذف بياناتك (المادة 17 DSGVO). علاوة على ذلك، لديك الحق في تقييد معالجة بياناتك (المادة 18 DSGVO) والحق في نقل البيانات (المادة 20 DSGVO). إذا استندنا في المعالجة إلى مصلحتنا المشروعة، يمكنك الاعتراض على المعالجة (المادة 21 DSGVO). بالإضافة إلى ذلك، يمكنك الاتصال في أي وقت بالسلطة المختصة لحماية البيانات لتقديم شكوى. تنطبق حقوقك وخياراتك القائمة من بقية سياسة الخصوصية أيضًا على استخدام تسجيل الدخول عبر Google.",
},
"orders": {
"title": "جمع، معالجة واستخدام البيانات الشخصية للطلبات",
"content": "عند تقديم طلب، نجمع ونستخدم بياناتك الشخصية فقط بالقدر اللازم لتنفيذ ومعالجة طلبك وللتعامل مع استفساراتك. تقديم البيانات مطلوب لإبرام العقد. عدم تقديمها يؤدي إلى عدم إبرام العقد. تتم المعالجة بناءً على المادة 6 (1) حرف ب من DSGVO وهي ضرورية لتنفيذ عقد معك. لن يتم تمرير بياناتك إلى أطراف ثالثة بدون موافقتك الصريحة. الاستثناء الوحيد هم شركاؤنا في الخدمة الذين نحتاجهم لمعالجة العلاقة التعاقدية أو مزودو الخدمات الذين نستخدمهم في إطار معالجة بالنيابة. بالإضافة إلى المستلمين المذكورين في البنود الخاصة بهذه السياسة، يشمل ذلك على سبيل المثال مستلمي الفئات التالية: مزودو خدمات الشحن، مزودو خدمات الدفع، مزودو خدمات إدارة البضائع، مزودو خدمات معالجة الطلبات، مستضيفو الويب، مزودو خدمات تكنولوجيا المعلومات وتجار الدروبشيبينغ. في جميع الحالات، نلتزم بدقة بالمتطلبات القانونية. نطاق نقل البيانات محدود إلى الحد الأدنى.",
},
"newsletter": {
"title": "استخدام عنوان البريد الإلكتروني لإرسال النشرات الإخبارية",
"content": "نستخدم عنوان بريدك الإلكتروني بشكل مستقل عن معالجة العقد فقط لأغراضنا الإعلانية الخاصة لإرسال النشرات الإخبارية، بشرط أن تكون قد وافقت على ذلك صراحة. تتم المعالجة بناءً على المادة 6 (1) حرف أ من DSGVO بموافقتك. يمكنك سحب موافقتك في أي وقت دون التأثير على قانونية المعالجة التي تمت بناءً على الموافقة حتى السحب. يمكنك إلغاء الاشتراك في النشرة الإخبارية في أي وقت باستخدام الرابط المناسب في النشرة أو بإبلاغنا. سيتم بعد ذلك إزالة عنوان بريدك الإلكتروني من قائمة التوزيع. يتم تمرير بياناتك إلى مزود خدمة للتسويق عبر البريد الإلكتروني في إطار معالجة بالنيابة. لا يتم نقلها إلى أطراف ثالثة أخرى. سيتم نقل بياناتك إلى دولة ثالثة يوجد بشأنها قرار كفاية من المفوضية الأوروبية.",
},
"chatbot": {
"title": "استخدام روبوت الدردشة الذكي (OpenAI API)",
"content": "نستخدم روبوت دردشة مدعوم بالذكاء الاصطناعي على موقعنا الإلكتروني، يتم تشغيله عبر واجهة برمجة التطبيقات (API) لمزود الخدمة OpenAI. يهدف روبوت الدردشة إلى الرد على استفسارات الزوار بكفاءة وبشكل آلي، مما يوفر وظيفة دعم. عند استخدام روبوت الدردشة، تتم معالجة مدخلاتك بواسطة النظام لتوليد ردود مناسبة. تتم المعالجة بشكل مجهول - لا يتم جمع أو تخزين عناوين IP أو بيانات شخصية أخرى (مثل الاسم أو بيانات الاتصال).",
"legalBasis": "الأساس القانوني لاستخدام روبوت الدردشة هو مصلحتنا المشروعة وفقًا للمادة 6 (1) حرف ف من DSGVO. تكمن هذه المصلحة في توفير دعم فعال للزوار وتحسين تجربة المستخدم على موقعنا.",
"dataRecipient": "المستلم لبيانات الدردشة هو OpenAI (OpenAI OpCo, LLC) كمزود خدمة فني. تقوم OpenAI بمعالجة محتوى الدردشة المرسل على خوادمها فقط لغرض توليد الردود. تعمل OpenAI كمعالج بيانات وفقًا للمادة 28 من DSGVO ولا تستخدم البيانات لأغراضها الخاصة. لقد أبرمنا عقد معالجة بيانات مع OpenAI يتضمن بنودًا تعاقدية قياسية للاتحاد الأوروبي كضمانات مناسبة لحماية البيانات. يقع المقر الرئيسي لـ OpenAI في الولايات المتحدة الأمريكية؛ يضمن الاتفاق على البنود التعاقدية القياسية مستوى حماية بيانات مناسبًا يتوافق مع الاتحاد الأوروبي عند نقل بياناتك.",
"dataRetention": "نحتفظ بطلبات الدردشة الخاصة بك فقط طالما كان ذلك ضروريًا للمعالجة والرد. بمجرد الانتهاء من طلبك، يتم حذف أو إخفاء سجلات الدردشة بسرعة. وفقًا لما صرحت به OpenAI، يتم الاحتفاظ ببيانات الدردشة المعالجة مؤقتًا فقط ويتم حذفها تلقائيًا بعد 30 يومًا كحد أقصى.",
"voluntaryUse": "استخدام روبوت الدردشة هو أمر طوعي. إذا لم تستخدم روبوت الدردشة، فلن يتم نقل أي بيانات إلى OpenAI. يرجى عدم إدخال أي بيانات شخصية حساسة في الدردشة.",
},
"cookies": {
"title": "ملفات تعريف الارتباط (Cookies)",
"intro": "يستخدم موقعنا ملفات تعريف الارتباط في الحالات التالية:",
"payment": "1. عملية الدفع: عند الدفع ببطاقات الائتمان أو التحويلات الفورية (مثل Klarna Instant)، يتم استخدام ملفات تعريف ارتباط ضرورية تقنيًا. تحتوي هذه على سلسلة مميزة تتيح التعرف الفريد على المتصفح. يتم تعيين ملفات تعريف الارتباط بواسطة مزود خدمة الدفع Stripe وهي ضرورية تمامًا لمعالجة المدفوعات بأمان وسلاسة. بدون هذه الملفات، لا يمكن إتمام الطلب باستخدام هذه طرق الدفع. تتم المعالجة بناءً على المادة 6 (1) حرف ب من DSGVO لتنفيذ العقد.",
"googleSSO": "2. تسجيل الدخول الموحد من Google (SSO): عند استخدام تسجيل الدخول عبر Google، يتم تعيين ملفات تعريف ارتباط بواسطة Google ضرورية لعملية تسجيل الدخول والتحقق من الهوية. تتيح لك هذه الملفات تسجيل الدخول بسهولة باستخدام حساب Google الخاص بك دون الحاجة لتسجيل الدخول في كل مرة. تتم المعالجة بناءً على المادة 6 (1) حرف ب من DSGVO (تنفيذ العقد) والمادة 6 (1) حرف ف من DSGVO (مصلحة مشروعة في تسجيل دخول سهل الاستخدام).",
"otherPayments": "بالنسبة لطرق الدفع الأخرى الخصم المباشر، الاستلام أو الدفع عند الاستلام لا يتم استخدام ملفات تعريف ارتباط إضافية، ما لم تستخدم تسجيل الدخول عبر Google.",
},
"mollie": {
"title": "Mollie (معالجة الدفع)",
"content": "نستخدم مزود خدمة الدفع Mollie على موقعنا لمعالجة المدفوعات. مزود الخدمة هو Mollie B.V.، Keizersgracht 126، 1015 CW Amsterdam، هولندا. في هذا السياق، يتم نقل البيانات الشخصية اللازمة لمعالجة الدفع إلى Mollie - على وجه الخصوص اسمك، عنوان بريدك الإلكتروني، عنوان الفاتورة، معلومات الدفع (مثل بيانات بطاقة الائتمان) وعنوان IP. تتم معالجة البيانات لغرض معالجة الدفع؛ الأساس القانوني هو المادة 6 (1) حرف ب من DSGVO، لأنها تخدم تنفيذ عقد معك.",
"responsibility": "تعالج Mollie أيضًا بعض البيانات كمسؤول مستقل، على سبيل المثال للوفاء بالالتزامات القانونية (مثل مكافحة غسيل الأموال) ومنع الاحتيال. بالإضافة إلى ذلك، أبرمنا عقد معالجة بيانات مع Mollie وفقًا للمادة 28 من DSGVO؛ في إطار هذا العقد، تتصرف Mollie عند معالجة المدفوعات فقط وفقًا لتعليماتنا.",
"dataTransfer": "في حال معالجة Mollie بيانات شخصية خارج الاتحاد الأوروبي، وخاصة في الولايات المتحدة الأمريكية، يتم ذلك مع الالتزام بضمانات مناسبة. تستخدم Mollie البنود التعاقدية القياسية للاتحاد الأوروبي وفقًا للمادة 46 من DSGVO لضمان مستوى مناسب من حماية البيانات. ومع ذلك، نشير إلى أن الولايات المتحدة تُعتبر دولة ثالثة قد لا توفر مستوى حماية بيانات كافٍ بموجب قانون حماية البيانات. يمكن العثور على مزيد من المعلومات في سياسة الخصوصية الخاصة بـ Mollie على https://www.mollie.com/en/privacy.",
},
"dataRetention": {
"title": "مدة التخزين",
"content": "بعد إتمام معالجة العقد بالكامل، يتم تخزين البيانات في البداية لمدة فترة الضمان، ثم مع مراعاة الفترات القانونية، وخاصة فترات الحفظ الضريبية والتجارية، ثم يتم حذفها بعد انتهاء الفترة، ما لم تكن قد وافقت على المعالجة والاستخدام الإضافيين.",
},
"dataSubjectRights": {
"title": "حقوق صاحب البيانات",
"content": "إذا توفرت الشروط القانونية، لديك الحقوق التالية وفقًا للمادة 15 إلى 20 من DSGVO: الحق في الحصول على المعلومات، التصحيح، الحذف، تقييد المعالجة، نقل البيانات. بالإضافة إلى ذلك، وفقًا للمادة 21 (1) من DSGVO، لديك الحق في الاعتراض على المعالجة التي تستند إلى المادة 6 (1) حرف ف من DSGVO، وكذلك على المعالجة لأغراض التسويق المباشر. اتصل بنا إذا رغبت. يمكنك العثور على بيانات الاتصال في إشعارنا القانوني.",
},
"supervisoryAuthority": {
"title": "الحق في تقديم شكوى إلى السلطة الرقابية",
"content": "وفقًا للمادة 77 من DSGVO، لديك الحق في تقديم شكوى إلى السلطة الرقابية إذا كنت تعتقد أن معالجة بياناتك الشخصية غير قانونية.",
}
}
};

View File

@@ -0,0 +1,25 @@
export default {
"title": "الإشعار القانوني (Impressum)",
"sections": {
"operator": {
"title": "المشغل والمسؤول عن محتوى هذا المتجر هو:",
"content": "Growheads\nMax Schön\nTrachenberger Straße 14\n01129 Dresden"
},
"contact": {
"title": "الاتصال:",
"content": "البريد الإلكتروني: service@growheads.de"
},
"vatId": {
"title": "رقم ضريبة القيمة المضافة:",
"content": "رقم ضريبة القيمة المضافة: DE323017152"
},
"disclaimer": {
"title": "تنصل من المسؤولية:",
"content": "لا نتحمل أي مسؤولية عن محتوى عناوين الإنترنت الخارجية المرتبطة بهذه الصفحات. المشغلون المعنيون هم المسؤولون عن محتوى المواقع غير التابعة للشركة."
},
"copyright": {
"title": "بند حقوق النشر:",
"content": "المحتوى المعروض هنا يخضع بشكل عام لحقوق النشر ولا يجوز توزيعه إلا بموافقة كتابية.\nحقوق المواد المصورة أو النصية الخاصة بأطراف أخرى ليست مقيدة أو ملغاة بهذا البند."
}
}
};

View File

@@ -0,0 +1,11 @@
export default {
"title": "حق الانسحاب",
"withdrawalRight": "لديك الحق في الانسحاب من هذا العقد خلال أربعة عشر يومًا دون إبداء أي سبب. تبدأ فترة الانسحاب من اليوم الذي تستلم فيه أنت أو طرف ثالث تعينه، وليس الناقل، البضائع.",
"exerciseWithdrawal": "لممارسة حقك في الانسحاب، يجب عليك إبلاغنا",
"contactInfo": "Growheads\nTrachenberger Straße 14\n01129 Dresden\nE-Mail: service@growheads.de",
"withdrawalProcess": "ببيان واضح (مثل رسالة مرسلة بالبريد، فاكس أو بريد إلكتروني) عن قرارك بالانسحاب من هذا العقد. يمكنك استخدام نموذج الانسحاب المرفق لهذا الغرض، لكنه ليس إلزاميًا. وللحفاظ على مهلة الانسحاب، يكفي أن ترسل إشعارك بممارسة حق الانسحاب قبل انتهاء فترة الانسحاب.",
"consequencesTitle": "عواقب الانسحاب",
"consequences": "إذا انسحبت من هذا العقد، سنرد لك جميع المدفوعات التي تلقيناها منك، بما في ذلك تكاليف التوصيل (باستثناء التكاليف الإضافية الناتجة عن اختيارك لنوع توصيل غير أرخص توصيل قياسي نقدمه)، دون تأخير غير مبرر وفي موعد أقصاه أربعة عشر يومًا من اليوم الذي استلمنا فيه إشعار انسحابك من هذا العقد. سنستخدم نفس وسيلة الدفع التي استخدمتها في المعاملة الأصلية لهذا السداد، ما لم يتم الاتفاق معك صراحة على خلاف ذلك؛ ولن تُفرض عليك أي رسوم مقابل هذا السداد. قد نرفض السداد حتى نستلم البضائع مرة أخرى أو تقدم دليلاً على إرسال البضائع، أيهما أسبق. يجب عليك إعادة البضائع أو تسليمها لنا دون تأخير غير مبرر وفي كل الأحوال خلال أربعة عشر يومًا من اليوم الذي تخطرنا فيه بانسحابك من هذا العقد. تُعتبر المهلة محفوظة إذا أرسلت البضائع قبل انتهاء فترة الأربعة عشر يومًا. تتحمل أنت التكاليف المباشرة لإعادة البضائع. أنت مسؤول فقط عن أي انخفاض في قيمة البضائع ناتج عن تعامل غير ضروري لتحديد طبيعة وخصائص وعمل البضائع.",
"noWithdrawalTitle": "إشعار بعدم وجود حق الانسحاب",
"noWithdrawal": "لا ينطبق حق الانسحاب على البضائع التي تم تصنيعها أو تفصيلها حسب طلب العميل (الأفلام والأنابيب)، لكن يمكن منحه باتفاق. كما أن حاويات الأسمدة التي تم إزالة ختمها أو تدميره بفتحها مستثناة أيضًا من حق الانسحاب."
};

View File

@@ -0,0 +1,3 @@
export default {
"code": "ar-EG"
};

View File

@@ -0,0 +1,9 @@
export default {
"home": "الرئيسية",
"aktionen": "العروض",
"filiale": "الفرع",
"categories": "الفئات",
"categoriesOpen": "افتح الفئات",
"categoriesClose": "أغلق الفئات",
"otherCategories": "فئات أخرى"
};

View File

@@ -0,0 +1,50 @@
export default {
"status": {
"new": "قيد التنفيذ",
"pending": "جديد",
"processing": "قيد التنفيذ",
"cancelled": "ملغاة",
"shipped": "تم الشحن",
"delivered": "تم التوصيل",
"return": "إرجاع",
"partialReturn": "إرجاع جزئي",
"partialDelivered": "تم التوصيل جزئياً"
},
"table": {
"orderNumber": "رقم الطلب",
"date": "التاريخ",
"status": "الحالة",
"items": "العناصر",
"total": "الإجمالي",
"actions": "الإجراءات",
"viewDetails": "عرض التفاصيل"
},
"tooltips": {
"viewDetails": "عرض التفاصيل",
"cancelOrder": "إلغاء الطلب"
},
"noOrders": "لم تقم بوضع أي طلبات بعد.",
"details": {
"title": "تفاصيل الطلب: {{orderId}}",
"deliveryAddress": "عنوان التوصيل",
"invoiceAddress": "عنوان الفاتورة",
"orderDetails": "تفاصيل الطلب",
"deliveryMethod": "طريقة التوصيل:",
"paymentMethod": "طريقة الدفع:",
"notSpecified": "غير محدد",
"orderedItems": "العناصر المطلوبة",
"item": "العنصر",
"quantity": "الكمية",
"price": "السعر",
"vat": "ضريبة القيمة المضافة",
"total": "الإجمالي",
"cancelOrder": "إلغاء الطلب"
},
"cancelConfirm": {
"title": "إلغاء الطلب",
"message": "هل أنت متأكد أنك تريد إلغاء هذا الطلب؟",
"confirm": "إلغاء الطلب",
"cancelling": "جارٍ الإلغاء..."
},
"processing": "يتم إكمال الطلب..."
};

View File

@@ -0,0 +1,10 @@
export default {
"oilPress": {
"title": "استعارة معصرة زيت",
"comingSoon": "المحتوى قادم قريباً..."
},
"thcTest": {
"title": "اختبار THC",
"comingSoon": "المحتوى قادم قريباً..."
}
};

View File

@@ -0,0 +1,21 @@
export default {
"successful": "تم الدفع بنجاح!",
"failed": "فشل الدفع",
"orderCompleted": "🎉 تم إكمال طلبك بنجاح! يمكنك الآن عرض طلباتك.",
"orderProcessing": "تمت معالجة دفعتك بنجاح. سيتم إكمال الطلب تلقائيًا.",
"paymentError": "لم نتمكن من معالجة دفعتك. يرجى المحاولة مرة أخرى أو اختيار طريقة دفع أخرى.",
"viewOrders": "عرض طلباتي",
"loadingPaymentComponent": "جارٍ تحميل مكون الدفع...",
"methods": {
"selectPaymentMethod": "اختر طريقة الدفع",
"bankTransfer": "تحويل بنكي",
"bankTransferDescription": "ادفع عن طريق التحويل البنكي",
"cardPayment": "بطاقة، Sofortüberweisung، Apple Pay، Google Pay، PayPal",
"cardPaymentDescription": "ادفع بالبطاقة أو Sofortüberweisung",
"cardPaymentMinAmount": "ادفع بالبطاقة أو Sofortüberweisung (الحد الأدنى: €0.50)",
"cashOnDelivery": "الدفع عند الاستلام",
"cashOnDeliveryDescription": "ادفع عند الاستلام (رسوم إضافية €8.99)",
"cashInStore": "الدفع في المتجر",
"cashInStoreDescription": "ادفع عند الاستلام",
}
};

View File

@@ -0,0 +1,47 @@
export default {
"loading": "جارٍ تحميل المنتج...",
"notFound": "المنتج غير موجود",
"notFoundDescription": "المنتج الذي تبحث عنه غير موجود أو تم إزالته.",
"backToHome": "العودة إلى الصفحة الرئيسية",
"error": "خطأ",
"articleNumber": "رقم الصنف",
"manufacturer": "الشركة المصنعة",
"inclVat": "شامل {{vat}}% ضريبة القيمة المضافة",
"priceUnit": "{{price}}/{{unit}}",
"new": "جديد",
"weeks": "أسابيع",
"arriving": "الوصول:",
"inclVatFooter": "شامل {{vat}}% ضريبة القيمة المضافة,*",
"availability": "التوفر",
"inStock": "متوفر في المخزون",
"comingSoon": "قريبًا",
"deliveryTime": "مدة التوصيل",
"inclShort": "شامل",
"vatShort": "ضريبة القيمة المضافة",
"weight": "الوزن: {{weight}} كجم",
"youSave": "أنت توفر: {{amount}}",
"cheaperThanIndividual": "أرخص من الشراء بشكل فردي",
"pickupPrice": "سعر الاستلام: 19.90 € لكل قطعة.",
"consistsOf": "يتكون من:",
"loadingComponentDetails": "{{index}}. جارٍ تحميل تفاصيل المكون...",
"individualPriceTotal": "إجمالي السعر الفردي:",
"setPrice": "سعر المجموعة:",
"yourSavings": "توفيرك:",
"countDisplay": {
"noProducts": "0 منتجات",
"oneProduct": "منتج واحد",
"multipleProducts": "{{count}} منتجات",
"filteredProducts": "{{filtered}} من {{total}} منتجات",
"filteredOneProduct": "{{filtered}} من منتج واحد",
"xOfYProducts": "{{x}} من {{y}} منتجات"
},
"removeFiltersToSee": "قم بإزالة الفلاتر لرؤية المنتجات",
"outOfStock": "غير متوفر في المخزون",
"fromXProducts": "من {{count}} منتجات",
"discount": {
"from3Products": "من 3 منتجات",
"from5Products": "من 5 منتجات",
"from7Products": "من 7 منتجات",
"moreProductsMoreSavings": "كلما اخترت منتجات أكثر، كلما وفرت أكثر!"
}
};

View File

@@ -0,0 +1,5 @@
export default {
"placeholder": "ممكن تسألني عن أنواع الحشيش...",
"recording": "جاري التسجيل...",
"searchProducts": "ابحث عن المنتجات...",
};

View File

@@ -0,0 +1,11 @@
export default {
"seeds": "بذور",
"stecklinge": "قصاصات",
"oilPress": "استعارة معصرة الزيت",
"thcTest": "اختبار THC",
"address1": "Trachenberger Straße 14",
"address2": "01129 Dresden",
"showUsPhoto": "ورينا أجمل صورة عندك",
"selectSeedRate": "اختار البذرة واضغط تقييم",
"indoorSeason": "موسم الزراعة الداخلية بدأ"
};

View File

@@ -0,0 +1,34 @@
export default {
"changePassword": "تغيير كلمة المرور",
"currentPassword": "كلمة المرور الحالية",
"newPassword": "كلمة المرور الجديدة",
"confirmNewPassword": "تأكيد كلمة المرور الجديدة",
"updatePassword": "تحديث كلمة المرور",
"changeEmail": "تغيير عنوان البريد الإلكتروني",
"password": "كلمة المرور",
"newEmail": "عنوان البريد الإلكتروني الجديد",
"updateEmail": "تحديث البريد الإلكتروني",
"apiKey": "مفتاح API",
"apiKeyDescription": "استخدم مفتاح API الخاص بك للتكامل مع التطبيقات الخارجية.",
"apiDocumentation": "توثيق API:",
"copyToClipboard": "نسخ إلى الحافظة",
"generate": "إنشاء",
"regenerate": "إعادة إنشاء",
"apiKeyCopied": "تم نسخ مفتاح API إلى الحافظة",
"errors": {
"fillAllFields": "يرجى ملء جميع الحقول",
"passwordsNotMatch": "كلمات المرور الجديدة غير متطابقة",
"passwordTooShort": "يجب أن تكون كلمة المرور الجديدة 8 أحرف على الأقل",
"passwordUpdateError": "حدث خطأ أثناء تحديث كلمة المرور",
"invalidEmail": "يرجى إدخال عنوان بريد إلكتروني صالح",
"emailUpdateError": "حدث خطأ أثناء تحديث عنوان البريد الإلكتروني",
"userNotFound": "المستخدم غير موجود",
"apiKeyGenerationError": "حدث خطأ أثناء إنشاء مفتاح API"
},
"success": {
"passwordUpdated": "تم تحديث كلمة المرور بنجاح",
"emailUpdated": "تم تحديث عنوان البريد الإلكتروني بنجاح",
"apiKeyGenerated": "تم إنشاء مفتاح API بنجاح",
"apiKeyWarning": "احفظ هذا المفتاح بأمان. لأسباب أمنية، سيتم إخفاؤه خلال 10 ثوانٍ."
}
};

View File

@@ -0,0 +1,6 @@
export default {
"name": "الاسم",
"searchField": "كلمة البحث",
"priceLowHigh": "السعر: من الأقل للأعلى",
"priceHighLow": "السعر: من الأعلى للأقل"
};

View File

@@ -0,0 +1,12 @@
export default {
"vat": "ضريبة القيمة المضافة",
"vat7": "ضريبة القيمة المضافة 7%",
"vat19": "ضريبة القيمة المضافة 19%",
"vat19WithShipping": "ضريبة القيمة المضافة 19% (شاملة الشحن)",
"totalNet": "إجمالي السعر الصافي",
"totalGross": "إجمالي السعر الإجمالي بدون الشحن",
"subtotal": "المجموع الفرعي",
"incl7Vat": "شاملة ضريبة القيمة المضافة 7%",
"inclVatWithFooter": "(شاملة {{vat}}% ضريبة القيمة المضافة،*)",
"inclVatAmount": "شاملة {{amount}} € ضريبة القيمة المضافة ({{rate}}%)"
};

View File

@@ -0,0 +1,5 @@
export default {
"home": "بذور وقصاصات القنب الممتازة",
"aktionen": "العروض والتخفيضات الحالية",
"filiale": "متجرنا في دريسدن",
};

View File

@@ -0,0 +1,3 @@
import translations from './index.js';
export default translations;

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