Compare commits

...

80 Commits

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

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

View File

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

62
.gitignore vendored
View File

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

154
docs/nginx.conf Normal file
View File

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

View File

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

1949
out Normal file

File diff suppressed because it is too large Load Diff

79
package-lock.json generated
View File

@@ -27,6 +27,7 @@
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-i18next": "^15.6.0", "react-i18next": "^15.6.0",
"react-router-dom": "^7.6.2", "react-router-dom": "^7.6.2",
"sanitize-html": "^2.17.0",
"sharp": "^0.34.2", "sharp": "^0.34.2",
"socket.io-client": "^4.7.5" "socket.io-client": "^4.7.5"
}, },
@@ -4553,9 +4554,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001727", "version": "1.0.30001757",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz",
"integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", "integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -5265,6 +5266,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/default-browser": { "node_modules/default-browser": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz",
@@ -8886,7 +8896,6 @@
"version": "3.3.11", "version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -9458,6 +9467,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/parse-srcset": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
"license": "MIT"
},
"node_modules/parse5": { "node_modules/parse5": {
"version": "7.3.0", "version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
@@ -9666,7 +9681,6 @@
"version": "8.5.6", "version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -10555,6 +10569,60 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/sanitize-html": {
"version": "2.17.0",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.0.tgz",
"integrity": "sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==",
"license": "MIT",
"dependencies": {
"deepmerge": "^4.2.2",
"escape-string-regexp": "^4.0.0",
"htmlparser2": "^8.0.0",
"is-plain-object": "^5.0.0",
"parse-srcset": "^1.0.2",
"postcss": "^8.3.11"
}
},
"node_modules/sanitize-html/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/sanitize-html/node_modules/htmlparser2": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1",
"entities": "^4.4.0"
}
},
"node_modules/sanitize-html/node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/saxes": { "node_modules/saxes": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
@@ -11227,7 +11295,6 @@
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"

View File

@@ -7,7 +7,7 @@
"start": "cross-env NODE_OPTIONS=\"--no-deprecation\" webpack serve --progress --mode development --no-open", "start": "cross-env NODE_OPTIONS=\"--no-deprecation\" webpack serve --progress --mode development --no-open",
"start:seedheads": "cross-env PROXY_TARGET=https://seedheads.de NODE_OPTIONS=\"--no-deprecation\" webpack serve --progress --mode development --no-open", "start:seedheads": "cross-env PROXY_TARGET=https://seedheads.de NODE_OPTIONS=\"--no-deprecation\" webpack serve --progress --mode development --no-open",
"prod": "webpack serve --progress --mode production --no-client-overlay --no-client --no-web-socket-server --no-open --no-live-reload --no-hot --compress --no-devtool", "prod": "webpack serve --progress --mode production --no-client-overlay --no-client --no-web-socket-server --no-open --no-live-reload --no-hot --compress --no-devtool",
"build:client": "cross-env NODE_ENV=production webpack --progress --mode production && shx cp dist/index.html dist/index_template.html", "build:client": "node scripts/convert-images-to-avif.cjs && cross-env NODE_ENV=production webpack --progress --mode production && shx cp dist/index.html dist/index_template.html",
"build": "npm run build:client", "build": "npm run build:client",
"analyze": "cross-env ANALYZE=true NODE_ENV=production webpack --progress --mode production", "analyze": "cross-env ANALYZE=true NODE_ENV=production webpack --progress --mode production",
"lint": "eslint src/**/*.{js,jsx}", "lint": "eslint src/**/*.{js,jsx}",
@@ -45,6 +45,7 @@
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-i18next": "^15.6.0", "react-i18next": "^15.6.0",
"react-router-dom": "^7.6.2", "react-router-dom": "^7.6.2",
"sanitize-html": "^2.17.0",
"sharp": "^0.34.2", "sharp": "^0.34.2",
"socket.io-client": "^4.7.5" "socket.io-client": "^4.7.5"
}, },

View File

@@ -28,7 +28,7 @@ class CategoryService {
const cacheKey = `${categoryId}_${language}`; const cacheKey = `${categoryId}_${language}`;
return null; return null;
} }
async get(categoryId, language = "de") { async get(categoryId, language = "de") {
const cacheKey = `${categoryId}_${language}`; const cacheKey = `${categoryId}_${language}`;
return null; return null;
@@ -136,6 +136,7 @@ const {
generateLlmsTxt, generateLlmsTxt,
generateCategoryLlmsTxt, generateCategoryLlmsTxt,
generateAllCategoryLlmsPages, generateAllCategoryLlmsPages,
generateCategoryProductList,
} = require("./prerender/seo.cjs"); } = require("./prerender/seo.cjs");
const { const {
fetchCategoryProducts, fetchCategoryProducts,
@@ -158,6 +159,7 @@ const Batteriegesetzhinweise =
const Widerrufsrecht = require("./src/pages/Widerrufsrecht.js").default; const Widerrufsrecht = require("./src/pages/Widerrufsrecht.js").default;
const Sitemap = require("./src/pages/Sitemap.js").default; const Sitemap = require("./src/pages/Sitemap.js").default;
const PrerenderSitemap = require("./src/PrerenderSitemap.js").default; const PrerenderSitemap = require("./src/PrerenderSitemap.js").default;
const PrerenderCategoriesPage = require("./src/PrerenderCategoriesPage.js").default;
const AGB = require("./src/pages/AGB.js").default; const AGB = require("./src/pages/AGB.js").default;
const NotFound404 = require("./src/pages/NotFound404.js").default; const NotFound404 = require("./src/pages/NotFound404.js").default;
@@ -188,7 +190,7 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback,
try { try {
const productDetails = await fetchProductDetails(workerSocket, productSeoName); const productDetails = await fetchProductDetails(workerSocket, productSeoName);
const actualSeoName = productDetails.product.seoName || productSeoName; const actualSeoName = productDetails.product.seoName || productSeoName;
const productComponent = React.createElement(PrerenderProduct, { const productComponent = React.createElement(PrerenderProduct, {
productData: productDetails, productData: productDetails,
@@ -204,7 +206,7 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback,
}, shopConfig.baseUrl, shopConfig); }, shopConfig.baseUrl, shopConfig);
// Get category info from categoryMap if available // Get category info from categoryMap if available
const categoryInfo = productDetails.product.categoryId ? categoryMap[productDetails.product.categoryId] : null; const categoryInfo = productDetails.product.categoryId ? categoryMap[productDetails.product.categoryId] : null;
const jsonLdScript = generateProductJsonLd({ const jsonLdScript = generateProductJsonLd({
...productDetails.product, ...productDetails.product,
seoName: actualSeoName, seoName: actualSeoName,
@@ -233,9 +235,9 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback,
success, success,
workerId workerId
}; };
results.push(result); results.push(result);
// Call progress callback if provided // Call progress callback if provided
if (progressCallback) { if (progressCallback) {
progressCallback(result); progressCallback(result);
@@ -251,14 +253,14 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback,
error: error.message, error: error.message,
workerId workerId
}; };
results.push(result); results.push(result);
// Call progress callback if provided // Call progress callback if provided
if (progressCallback) { if (progressCallback) {
progressCallback(result); progressCallback(result);
} }
setTimeout(processNextProduct, 25); setTimeout(processNextProduct, 25);
} }
}; };
@@ -290,16 +292,16 @@ const renderProductsInParallel = async (allProductsArray, maxWorkers, totalProdu
const barLength = 30; const barLength = 30;
const filledLength = Math.round((barLength * current) / total); const filledLength = Math.round((barLength * current) / total);
const bar = '█'.repeat(filledLength) + '░'.repeat(barLength - filledLength); const bar = '█'.repeat(filledLength) + '░'.repeat(barLength - filledLength);
// @note Single line progress update to prevent flickering // @note Single line progress update to prevent flickering
const truncatedName = productName ? ` - ${productName.substring(0, 25)}${productName.length > 25 ? '...' : ''}` : ''; const truncatedName = productName ? ` - ${productName.substring(0, 25)}${productName.length > 25 ? '...' : ''}` : '';
// Build worker stats on one line // Build worker stats on one line
let workerStats = ''; let workerStats = '';
for (let i = 0; i < Math.min(maxWorkers, 8); i++) { // Limit to 8 workers to fit on screen for (let i = 0; i < Math.min(maxWorkers, 8); i++) { // Limit to 8 workers to fit on screen
workerStats += `W${i + 1}:${workerCounts[i]}/${workerSuccess[i]} `; workerStats += `W${i + 1}:${workerCounts[i]}/${workerSuccess[i]} `;
} }
// Single line update without complex cursor movements // Single line update without complex cursor movements
process.stdout.write(`\r [${bar}] ${percentage}% (${current}/${total})${truncatedName}\n ${workerStats}${current < total ? '\x1b[1A' : '\n'}`); process.stdout.write(`\r [${bar}] ${percentage}% (${current}/${total})${truncatedName}\n ${workerStats}${current < total ? '\x1b[1A' : '\n'}`);
}; };
@@ -307,26 +309,26 @@ const renderProductsInParallel = async (allProductsArray, maxWorkers, totalProdu
// Split products among workers // Split products among workers
const productsPerWorker = Math.ceil(allProductsArray.length / maxWorkers); const productsPerWorker = Math.ceil(allProductsArray.length / maxWorkers);
const workerPromises = []; const workerPromises = [];
// Initial progress bar // Initial progress bar
updateProgressBar(0, totalProducts); updateProgressBar(0, totalProducts);
for (let i = 0; i < maxWorkers; i++) { for (let i = 0; i < maxWorkers; i++) {
const start = i * productsPerWorker; const start = i * productsPerWorker;
const end = Math.min(start + productsPerWorker, allProductsArray.length); const end = Math.min(start + productsPerWorker, allProductsArray.length);
const productsForWorker = allProductsArray.slice(start, end); const productsForWorker = allProductsArray.slice(start, end);
if (productsForWorker.length > 0) { if (productsForWorker.length > 0) {
const promise = renderProductWorker(productsForWorker, i + 1, (result) => { const promise = renderProductWorker(productsForWorker, i + 1, (result) => {
// Progress callback - called each time a product is completed // Progress callback - called each time a product is completed
completedProducts++; completedProducts++;
progressResults.push(result); progressResults.push(result);
lastProductName = result.productName; lastProductName = result.productName;
// Update per-worker counters // Update per-worker counters
const workerIndex = result.workerId - 1; // Convert to 0-based index const workerIndex = result.workerId - 1; // Convert to 0-based index
workerCounts[workerIndex]++; workerCounts[workerIndex]++;
if (result.success) { if (result.success) {
totalSuccessCount++; totalSuccessCount++;
workerSuccess[workerIndex]++; workerSuccess[workerIndex]++;
@@ -334,11 +336,11 @@ const renderProductsInParallel = async (allProductsArray, maxWorkers, totalProdu
// Don't log errors immediately to avoid interfering with progress bar // Don't log errors immediately to avoid interfering with progress bar
// Errors will be shown after completion // Errors will be shown after completion
} }
// Update progress bar with worker stats // Update progress bar with worker stats
updateProgressBar(completedProducts, totalProducts, lastProductName); updateProgressBar(completedProducts, totalProducts, lastProductName);
}, categoryMap); }, categoryMap);
workerPromises.push(promise); workerPromises.push(promise);
} }
} }
@@ -346,10 +348,10 @@ const renderProductsInParallel = async (allProductsArray, maxWorkers, totalProdu
try { try {
// Wait for all workers to complete // Wait for all workers to complete
await Promise.all(workerPromises); await Promise.all(workerPromises);
// Ensure final progress update // Ensure final progress update
updateProgressBar(totalProducts, totalProducts, lastProductName); updateProgressBar(totalProducts, totalProducts, lastProductName);
// Show any errors that occurred // Show any errors that occurred
const errorResults = progressResults.filter(r => !r.success && r.error); const errorResults = progressResults.filter(r => !r.success && r.error);
if (errorResults.length > 0) { if (errorResults.length > 0) {
@@ -358,7 +360,7 @@ const renderProductsInParallel = async (allProductsArray, maxWorkers, totalProdu
console.log(` - ${result.productSeoName}: ${result.error}`); console.log(` - ${result.productSeoName}: ${result.error}`);
}); });
} }
return totalSuccessCount; return totalSuccessCount;
} catch (error) { } catch (error) {
console.error('Error in parallel rendering:', error); console.error('Error in parallel rendering:', error);
@@ -421,6 +423,14 @@ const renderApp = async (categoryData, socket) => {
process.exit(1); process.exit(1);
} }
// Copy index.html to resetPassword (no file extension) for SPA routing
if (config.isProduction) {
const indexPath = path.resolve(__dirname, config.outputDir, "index.html");
const resetPasswordPath = path.resolve(__dirname, config.outputDir, "resetPassword");
fs.copyFileSync(indexPath, resetPasswordPath);
console.log(`✅ Copied index.html to ${resetPasswordPath}`);
}
// Render static pages // Render static pages
console.log("\n📄 Rendering static pages..."); console.log("\n📄 Rendering static pages...");
@@ -456,6 +466,13 @@ const renderApp = async (categoryData, socket) => {
description: "Sitemap page", description: "Sitemap page",
needsCategoryData: true, needsCategoryData: true,
}, },
{
component: PrerenderCategoriesPage,
path: "/Kategorien",
filename: "Kategorien",
description: "Categories page",
needsCategoryData: true,
},
{ component: AGB, path: "/agb", filename: "agb", description: "AGB page" }, { component: AGB, path: "/agb", filename: "agb", description: "AGB page" },
{ component: NotFound404, path: "/404", filename: "404", description: "404 Not Found page" }, { component: NotFound404, path: "/404", filename: "404", description: "404 Not Found page" },
{ {
@@ -525,7 +542,14 @@ const renderApp = async (categoryData, socket) => {
let categoryPagesRendered = 0; let categoryPagesRendered = 0;
let categoriesWithProducts = 0; let categoriesWithProducts = 0;
for (const category of allCategories) { const allCategoriesPlusNeu = [...allCategories, {
id: "neu",
name: "Neuheiten",
seoName: "neu",
parentId: 209
}];
for (const category of allCategoriesPlusNeu) {
// Skip categories without seoName // Skip categories without seoName
if (!category.seoName) { if (!category.seoName) {
console.log( console.log(
@@ -543,8 +567,7 @@ const renderApp = async (categoryData, socket) => {
try { try {
productData = await fetchCategoryProducts(socket, category.id); productData = await fetchCategoryProducts(socket, category.id);
console.log( console.log(
` ✅ Found ${ ` ✅ Found ${productData.products ? productData.products.length : 0
productData.products ? productData.products.length : 0
} products` } products`
); );
@@ -628,7 +651,7 @@ const renderApp = async (categoryData, socket) => {
const totalProducts = allProducts.size; const totalProducts = allProducts.size;
const numCPUs = os.cpus().length; const numCPUs = os.cpus().length;
const maxWorkers = Math.min(numCPUs, totalProducts, 8); // Cap at 8 workers to avoid overwhelming the server const maxWorkers = Math.min(numCPUs, totalProducts, 8); // Cap at 8 workers to avoid overwhelming the server
// Create category map for breadcrumbs // Create category map for breadcrumbs
const categoryMap = {}; const categoryMap = {};
allCategories.forEach(category => { allCategories.forEach(category => {
@@ -637,11 +660,11 @@ const renderApp = async (categoryData, socket) => {
seoName: category.seoName seoName: category.seoName
}; };
}); });
console.log( console.log(
`\n📦 Rendering ${totalProducts} individual product pages using ${maxWorkers} parallel workers...` `\n📦 Rendering ${totalProducts} individual product pages using ${maxWorkers} parallel workers...`
); );
const productPagesRendered = await renderProductsInParallel( const productPagesRendered = await renderProductsInParallel(
Array.from(allProducts), Array.from(allProducts),
maxWorkers, maxWorkers,
@@ -693,21 +716,21 @@ const renderApp = async (categoryData, socket) => {
// Generate products.xml (Google Shopping feed) in parallel to sitemap.xml // Generate products.xml (Google Shopping feed) in parallel to sitemap.xml
if (allProductsData.length > 0) { if (allProductsData.length > 0) {
console.log("\n🛒 Generating products.xml (Google Shopping feed)..."); console.log("\n🛒 Generating products.xml (Google Shopping feed)...");
try { try {
const productsXml = generateProductsXml(allProductsData, shopConfig.baseUrl, shopConfig); const productsXml = generateProductsXml(allProductsData, shopConfig.baseUrl, shopConfig);
const productsXmlPath = path.resolve(__dirname, config.outputDir, "products.xml"); const productsXmlPath = path.resolve(__dirname, config.outputDir, "products.xml");
// Write with explicit UTF-8 encoding // Write with explicit UTF-8 encoding
fs.writeFileSync(productsXmlPath, productsXml, { encoding: 'utf8' }); fs.writeFileSync(productsXmlPath, productsXml, { encoding: 'utf8' });
console.log(`✅ products.xml generated: ${productsXmlPath}`); console.log(`✅ products.xml generated: ${productsXmlPath}`);
console.log(` - Products included: ${allProductsData.length}`); console.log(` - Products included: ${allProductsData.length}`);
console.log(` - Format: Google Shopping RSS 2.0 feed`); console.log(` - Format: Google Shopping RSS 2.0 feed`);
console.log(` - Encoding: UTF-8`); console.log(` - Encoding: UTF-8`);
console.log(` - Includes: title, description, price, availability, images`); console.log(` - Includes: title, description, price, availability, images`);
// Verify the file is valid UTF-8 // Verify the file is valid UTF-8
try { try {
const verification = fs.readFileSync(productsXmlPath, 'utf8'); const verification = fs.readFileSync(productsXmlPath, 'utf8');
@@ -715,18 +738,18 @@ const renderApp = async (categoryData, socket) => {
} catch (verifyError) { } catch (verifyError) {
console.log(` - File verification: ⚠️ ${verifyError.message}`); console.log(` - File verification: ⚠️ ${verifyError.message}`);
} }
// Validate XML against Google Shopping schema // Validate XML against Google Shopping schema
try { try {
const ProductsXmlValidator = require('./scripts/validate-products-xml.cjs'); const ProductsXmlValidator = require('./scripts/validate-products-xml.cjs');
const validator = new ProductsXmlValidator(productsXmlPath); const validator = new ProductsXmlValidator(productsXmlPath);
const validationResults = await validator.validate(); const validationResults = await validator.validate();
if (validationResults.valid) { if (validationResults.valid) {
console.log(` - Schema validation: ✅ Valid Google Shopping RSS 2.0`); console.log(` - Schema validation: ✅ Valid Google Shopping RSS 2.0`);
} else { } else {
console.log(` - Schema validation: ⚠️ ${validationResults.summary.errorCount} errors, ${validationResults.summary.warningCount} warnings`); console.log(` - Schema validation: ⚠️ ${validationResults.summary.errorCount} errors, ${validationResults.summary.warningCount} warnings`);
// Show first few errors for quick debugging // Show first few errors for quick debugging
if (validationResults.errors.length > 0) { if (validationResults.errors.length > 0) {
console.log(` - First error: ${validationResults.errors[0].message}`); console.log(` - First error: ${validationResults.errors[0].message}`);
@@ -735,7 +758,7 @@ const renderApp = async (categoryData, socket) => {
} catch (validationError) { } catch (validationError) {
console.log(` - Schema validation: ⚠️ Validation failed: ${validationError.message}`); console.log(` - Schema validation: ⚠️ Validation failed: ${validationError.message}`);
} }
} catch (error) { } catch (error) {
console.error(`❌ Error generating products.xml: ${error.message}`); console.error(`❌ Error generating products.xml: ${error.message}`);
console.log("\n⚠ Skipping products.xml generation due to errors"); console.log("\n⚠ Skipping products.xml generation due to errors");
@@ -746,18 +769,18 @@ const renderApp = async (categoryData, socket) => {
// Generate llms.txt (LLM-friendly markdown sitemap) and category-specific files // Generate llms.txt (LLM-friendly markdown sitemap) and category-specific files
console.log("\n🤖 Generating LLM sitemap files..."); console.log("\n🤖 Generating LLM sitemap files...");
try { try {
// Generate main llms.txt overview file // Generate main llms.txt overview file
const llmsTxt = generateLlmsTxt(allCategories, allProductsData, shopConfig.baseUrl, shopConfig); const llmsTxt = generateLlmsTxt(allCategories, allProductsData, shopConfig.baseUrl, shopConfig);
const llmsTxtPath = path.resolve(__dirname, config.outputDir, "llms.txt"); const llmsTxtPath = path.resolve(__dirname, config.outputDir, "llms.txt");
fs.writeFileSync(llmsTxtPath, llmsTxt, { encoding: 'utf8' }); fs.writeFileSync(llmsTxtPath, llmsTxt, { encoding: 'utf8' });
console.log(`✅ Main llms.txt generated: ${llmsTxtPath}`); console.log(`✅ Main llms.txt generated: ${llmsTxtPath}`);
console.log(` - Static pages: 8 pages`); console.log(` - Static pages: 8 pages`);
console.log(` - Categories: ${allCategories.length} with links to detailed files`); console.log(` - Categories: ${allCategories.length} with links to detailed files`);
console.log(` - File size: ${Math.round(llmsTxt.length / 1024)}KB`); console.log(` - File size: ${Math.round(llmsTxt.length / 1024)}KB`);
// Group products by category for category-specific files // Group products by category for category-specific files
const productsByCategory = {}; const productsByCategory = {};
allProductsData.forEach((product) => { allProductsData.forEach((product) => {
@@ -767,47 +790,53 @@ const renderApp = async (categoryData, socket) => {
} }
productsByCategory[categoryId].push(product); productsByCategory[categoryId].push(product);
}); });
// Generate category-specific LLM files with pagination // Generate category-specific LLM files with pagination
let categoryFilesGenerated = 0; let categoryFilesGenerated = 0;
let totalCategoryProducts = 0; let totalCategoryProducts = 0;
let totalPaginatedFiles = 0; let totalPaginatedFiles = 0;
for (const category of allCategories) { for (const category of allCategories) {
if (category.seoName) { if (category.seoName) {
const categoryProducts = productsByCategory[category.id] || []; const categoryProducts = productsByCategory[category.id] || [];
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-'); const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
// Generate all paginated files for this category // Generate all paginated files for this category
const categoryPages = generateAllCategoryLlmsPages(category, categoryProducts, shopConfig.baseUrl, shopConfig); const categoryPages = generateAllCategoryLlmsPages(category, categoryProducts, shopConfig.baseUrl, shopConfig);
// Write each paginated file // Write each paginated file
for (const page of categoryPages) { for (const page of categoryPages) {
const pagePath = path.resolve(__dirname, config.outputDir, page.fileName); const pagePath = path.resolve(__dirname, config.outputDir, page.fileName);
fs.writeFileSync(pagePath, page.content, { encoding: 'utf8' }); fs.writeFileSync(pagePath, page.content, { encoding: 'utf8' });
totalPaginatedFiles++; totalPaginatedFiles++;
} }
// Generate and write the product list file for this category
const productList = generateCategoryProductList(category, categoryProducts);
const listPath = path.resolve(__dirname, config.outputDir, productList.fileName);
fs.writeFileSync(listPath, productList.content, { encoding: 'utf8' });
const pageCount = categoryPages.length; const pageCount = categoryPages.length;
const totalSize = categoryPages.reduce((sum, page) => sum + page.content.length, 0); const totalSize = categoryPages.reduce((sum, page) => sum + page.content.length, 0);
console.log(` ✅ llms-${categorySlug}-page-*.txt - ${categoryProducts.length} products across ${pageCount} pages (${Math.round(totalSize / 1024)}KB total)`); console.log(` ✅ llms-${categorySlug}-page-*.txt - ${categoryProducts.length} products across ${pageCount} pages (${Math.round(totalSize / 1024)}KB total)`);
console.log(` 📋 ${productList.fileName} - ${productList.productCount} products (${Math.round(productList.content.length / 1024)}KB)`);
categoryFilesGenerated++; categoryFilesGenerated++;
totalCategoryProducts += categoryProducts.length; totalCategoryProducts += categoryProducts.length;
} }
} }
console.log(` 📄 Total paginated files generated: ${totalPaginatedFiles}`); console.log(` 📄 Total paginated files generated: ${totalPaginatedFiles}`);
console.log(` 📦 Total products across all categories: ${totalCategoryProducts}`); console.log(` 📦 Total products across all categories: ${totalCategoryProducts}`);
try { try {
const verification = fs.readFileSync(llmsTxtPath, 'utf8'); const verification = fs.readFileSync(llmsTxtPath, 'utf8');
console.log(` - File verification: ✅ All files valid UTF-8`); console.log(` - File verification: ✅ All files valid UTF-8`);
} catch (verifyError) { } catch (verifyError) {
console.log(` - File verification: ⚠️ ${verifyError.message}`); console.log(` - File verification: ⚠️ ${verifyError.message}`);
} }
} catch (error) { } catch (error) {
console.error(`❌ Error generating LLM sitemap files: ${error.message}`); console.error(`❌ Error generating LLM sitemap files: ${error.message}`);
console.log("\n⚠ Skipping LLM sitemap generation due to errors"); console.log("\n⚠ Skipping LLM sitemap generation due to errors");
@@ -827,7 +856,7 @@ const fetchCategoryDataAndRender = () => {
const socket = io(socketUrl, { const socket = io(socketUrl, {
path: "/socket.io/", path: "/socket.io/",
transports: [ "websocket"], transports: ["websocket"],
reconnection: false, reconnection: false,
timeout: 10000, timeout: 10000,
}); });

View File

@@ -37,9 +37,15 @@ const fetchCategoryProducts = (socket, categoryId) => {
reject(new Error(`Timeout fetching products for category ${categoryId}`)); reject(new Error(`Timeout fetching products for category ${categoryId}`));
}, 5000); }, 5000);
// Prerender system fetches German version by default
socket.emit( socket.emit(
"getCategoryProducts", "getCategoryProducts",
{ full:true, categoryId: parseInt(categoryId) }, {
full: true,
categoryId: categoryId === "neu" ? "neu" : parseInt(categoryId),
language: 'de',
requestTranslation: false
},
(response) => { (response) => {
clearTimeout(timeout); clearTimeout(timeout);
if (response && response.products !== undefined) { if (response && response.products !== undefined) {
@@ -68,7 +74,13 @@ const fetchProductDetails = (socket, productSeoName) => {
); );
}, 5000); }, 5000);
socket.emit("getProductView", { seoName: productSeoName, nocount: true }, (response) => { // Prerender system fetches German version by default
socket.emit("getProductView", {
seoName: productSeoName,
nocount: true,
language: 'de',
requestTranslation: false
}, (response) => {
clearTimeout(timeout); clearTimeout(timeout);
if (response && response.product) { if (response && response.product) {
response.product.seoName = productSeoName; response.product.seoName = productSeoName;
@@ -140,7 +152,7 @@ const saveProductImages = async (socket, products, categoryName, outputDir) => {
"public", "public",
"assets", "assets",
"images", "images",
"sh.png" "sh.avif"
); );
// Ensure assets/images directory exists // Ensure assets/images directory exists
@@ -173,7 +185,7 @@ const saveProductImages = async (socket, products, categoryName, outputDir) => {
if (imageIds.length > 0) { if (imageIds.length > 0) {
// Process first image for each product // Process first image for each product
const bildId = parseInt(imageIds[0]); const bildId = parseInt(imageIds[0]);
const estimatedFilename = `prod${bildId}.jpg`; // We'll generate a filename based on the ID const estimatedFilename = `prod${bildId}.avif`; // We'll generate a filename based on the ID
const imagePath = path.join(assetsPath, estimatedFilename); const imagePath = path.join(assetsPath, estimatedFilename);
@@ -219,12 +231,12 @@ const saveProductImages = async (socket, products, categoryName, outputDir) => {
opacity: 0.3, opacity: 0.3,
}, },
]) ])
.jpeg() // Ensure output is JPEG .avif() // Ensure output is AVIF
.toBuffer(); .toBuffer();
fs.writeFileSync(imagePath, processedImageBuffer); fs.writeFileSync(imagePath, processedImageBuffer);
console.log( console.log(
` ✅ Applied centered inverted sh.png overlay to ${estimatedFilename}` ` ✅ Applied centered inverted sh.avif overlay to ${estimatedFilename}`
); );
} catch (overlayError) { } catch (overlayError) {
console.log( console.log(
@@ -269,7 +281,7 @@ const saveCategoryImages = async (socket, categories, outputDir) => {
// Debug: Log categories that will be processed // Debug: Log categories that will be processed
console.log(" 🔍 Categories to process:"); console.log(" 🔍 Categories to process:");
categories.forEach((cat, index) => { categories.forEach((cat, index) => {
console.log(` ${index + 1}. "${cat.name}" (ID: ${cat.id}) -> cat${cat.id}.jpg`); console.log(` ${index + 1}. "${cat.name}" (ID: ${cat.id}) -> cat${cat.id}.avif`);
}); });
const assetsPath = path.resolve( const assetsPath = path.resolve(
@@ -296,7 +308,7 @@ const saveCategoryImages = async (socket, categories, outputDir) => {
for (const category of categories) { for (const category of categories) {
categoriesProcessed++; categoriesProcessed++;
const estimatedFilename = `cat${category.id}.jpg`; // Use 'cat' prefix with category ID const estimatedFilename = `cat${category.id}.avif`; // Use 'cat' prefix with category ID
const imagePath = path.join(assetsPath, estimatedFilename); const imagePath = path.join(assetsPath, estimatedFilename);
// Skip if image already exists // Skip if image already exists

View File

@@ -193,14 +193,17 @@ const renderPage = (
let productDetailCacheScript = ''; let productDetailCacheScript = '';
if (productData && productData.product) { if (productData && productData.product) {
// Cache the entire response object (includes product, attributes, etc.) // Cache the entire response object (includes product, attributes, etc.)
// Use language-aware cache key (prerender defaults to German)
const productDetailCacheData = JSON.stringify(productData); const productDetailCacheData = JSON.stringify(productData);
const language = 'de'; // Prerender system caches German version
const cacheKey = `product_${productData.product.seoName}_${language}`;
productDetailCacheScript = ` productDetailCacheScript = `
<script> <script>
// Populate window.productDetailCache with complete product data for SPA hydration // Populate window.productDetailCache with complete product data for SPA hydration
if (!window.productDetailCache) { if (!window.productDetailCache) {
window.productDetailCache = {}; window.productDetailCache = {};
} }
window.productDetailCache['${productData.product.seoName}'] = ${productDetailCacheData}; window.productDetailCache['${cacheKey}'] = ${productDetailCacheData};
</script> </script>
`; `;
} }
@@ -244,10 +247,6 @@ const renderPage = (
if (!suppressLogs) { if (!suppressLogs) {
console.log(`${description} prerendered to ${outputPath}`); console.log(`${description} prerendered to ${outputPath}`);
console.log(` - Markup length: ${renderedMarkup.length} characters`); console.log(` - Markup length: ${renderedMarkup.length} characters`);
console.log(` - CSS rules: ${Object.keys(cache.inserted).length}`);
console.log(` - Total inlined CSS: ${Math.round(combinedCss.length / 1024)}KB`);
console.log(` - Render-blocking CSS eliminated: ${inlinedCss ? 'YES' : 'NO'}`);
console.log(` - Fallback content saved to window.__PRERENDER_FALLBACK__`);
if (productDetailCacheScript) { if (productDetailCacheScript) {
console.log(` - Product detail cache populated for SPA hydration`); console.log(` - Product detail cache populated for SPA hydration`);
} }

View File

@@ -1,6 +1,19 @@
const generateCategoryJsonLd = (category, products = [], baseUrl, config) => { const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
// Category IDs to skip (seeds, plants, headshop items)
const skipCategoryIds = [689, 706, 709, 711, 714, 748, 749, 896, 710, 924, 923, 922, 921, 916, 278, 259, 258];
// Check if category ID is in skip list
if (category.id && skipCategoryIds.includes(parseInt(category.id))) {
return '';
}
const categoryUrl = `${baseUrl}/Kategorie/${category.seoName}`; const categoryUrl = `${baseUrl}/Kategorie/${category.seoName}`;
// Calculate price valid date (current date + 3 months)
const priceValidDate = new Date();
priceValidDate.setMonth(priceValidDate.getMonth() + 3);
const priceValidUntil = priceValidDate.toISOString().split("T")[0];
const jsonLd = { const jsonLd = {
"@context": "https://schema.org/", "@context": "https://schema.org/",
"@type": "CollectionPage", "@type": "CollectionPage",
@@ -42,7 +55,7 @@ const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
product.pictureList && product.pictureList.trim() product.pictureList && product.pictureList.trim()
? `${baseUrl}/assets/images/prod${product.pictureList ? `${baseUrl}/assets/images/prod${product.pictureList
.split(",")[0] .split(",")[0]
.trim()}.jpg` .trim()}.avif`
: `${baseUrl}/assets/images/nopicture.jpg`, : `${baseUrl}/assets/images/nopicture.jpg`,
description: product.description description: product.description
? product.description.replace(/<[^>]*>/g, "").substring(0, 200) ? product.description.replace(/<[^>]*>/g, "").substring(0, 200)
@@ -57,6 +70,7 @@ const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
url: `${baseUrl}/Artikel/${product.seoName}`, url: `${baseUrl}/Artikel/${product.seoName}`,
price: product.price && !isNaN(product.price) ? product.price.toString() : "0.00", price: product.price && !isNaN(product.price) ? product.price.toString() : "0.00",
priceCurrency: config.currency, priceCurrency: config.currency,
priceValidUntil: priceValidUntil,
availability: product.available availability: product.available
? "https://schema.org/InStock" ? "https://schema.org/InStock"
: "https://schema.org/OutOfStock", : "https://schema.org/OutOfStock",
@@ -65,6 +79,41 @@ const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
name: config.brandName, name: config.brandName,
}, },
itemCondition: "https://schema.org/NewCondition", itemCondition: "https://schema.org/NewCondition",
hasMerchantReturnPolicy: {
"@type": "MerchantReturnPolicy",
applicableCountry: "DE",
returnPolicyCategory: "https://schema.org/MerchantReturnFiniteReturnWindow",
merchantReturnDays: 14,
returnMethod: "https://schema.org/ReturnByMail",
returnFees: "https://schema.org/FreeReturn",
},
shippingDetails: {
"@type": "OfferShippingDetails",
shippingRate: {
"@type": "MonetaryAmount",
value: 5.90,
currency: "EUR",
},
shippingDestination: {
"@type": "DefinedRegion",
addressCountry: "DE",
},
deliveryTime: {
"@type": "ShippingDeliveryTime",
handlingTime: {
"@type": "QuantitativeValue",
minValue: 0,
maxValue: 1,
unitCode: "DAY",
},
transitTime: {
"@type": "QuantitativeValue",
minValue: 2,
maxValue: 3,
unitCode: "DAY",
},
},
},
}, },
}, },
})), })),

View File

@@ -122,6 +122,7 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
689: "543561", // Seeds (Saatgut) 689: "543561", // Seeds (Saatgut)
706: "543561", // Stecklinge (cuttings) ebenfalls Pflanzen/Saatgut 706: "543561", // Stecklinge (cuttings) ebenfalls Pflanzen/Saatgut
376: "2802", // Grow-Sets Pflanzen- & Kräuteranbausets 376: "2802", // Grow-Sets Pflanzen- & Kräuteranbausets
915: "2802", // Grow-Sets > Set-Zubehör Pflanzen- & Kräuteranbausets
// Headshop & Accessories // Headshop & Accessories
709: "4082", // Headshop Rauchzubehör 709: "4082", // Headshop Rauchzubehör
@@ -129,8 +130,13 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
714: "4082", // Headshop > Bongs > Zubehör Rauchzubehör 714: "4082", // Headshop > Bongs > Zubehör Rauchzubehör
748: "4082", // Headshop > Bongs > Köpfe Rauchzubehör 748: "4082", // Headshop > Bongs > Köpfe Rauchzubehör
749: "4082", // Headshop > Bongs > Chillums/Diffusoren/Kupplungen Rauchzubehör 749: "4082", // Headshop > Bongs > Chillums/Diffusoren/Kupplungen Rauchzubehör
921: "4082", // Headshop > Pfeifen Rauchzubehör
924: "4082", // Headshop > Dabbing Rauchzubehör
896: "3151", // Headshop > Vaporizer Vaporizer 896: "3151", // Headshop > Vaporizer Vaporizer
923: "4082", // Headshop > Papes & Blunts Rauchzubehör
710: "5109", // Headshop > Grinder Gewürzmühlen (Küchenhelfer) 710: "5109", // Headshop > Grinder Gewürzmühlen (Küchenhelfer)
922: "4082", // Headshop > Aktivkohlefilter & Tips Rauchzubehör
916: "4082", // Headshop > Rollen & Bauen Rauchzubehör
// Measuring & Packaging // Measuring & Packaging
186: "5631", // Headshop > Wiegen & Verpacken Aufbewahrung/Zubehör 186: "5631", // Headshop > Wiegen & Verpacken Aufbewahrung/Zubehör
@@ -140,6 +146,7 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
407: "3561", // Headshop > Grove Bags Aufbewahrungsbehälter 407: "3561", // Headshop > Grove Bags Aufbewahrungsbehälter
449: "1496", // Headshop > Cliptütchen Lebensmittelverpackungsmaterial 449: "1496", // Headshop > Cliptütchen Lebensmittelverpackungsmaterial
539: "3110", // Headshop > Gläser & Dosen Lebensmittelbehälter 539: "3110", // Headshop > Gläser & Dosen Lebensmittelbehälter
920: "581", // Headshop > Räucherstäbchen Raumdüfte (Home Fragrances)
// Lighting & Equipment // Lighting & Equipment
694: "3006", // Lampen Lampen (Beleuchtung) 694: "3006", // Lampen Lampen (Beleuchtung)
@@ -226,7 +233,6 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
387: "543541", // Zubehör > Anbauzubehör > Literatur Bücher 387: "543541", // Zubehör > Anbauzubehör > Literatur Bücher
// General categories // General categories
705: "2802", // Grow-Sets > Set-Konfigurator (ebenfalls Pflanzen-Anbausets)
686: "1700", // Belüftung > Aktivkohlefilter > Zubehör Ventilatoren 686: "1700", // Belüftung > Aktivkohlefilter > Zubehör Ventilatoren
741: "1700", // Belüftung > Ab- und Zuluft > Zubehör Ventilatoren 741: "1700", // Belüftung > Ab- und Zuluft > Zubehör Ventilatoren
294: "3568", // Bewässerung > Zubehör Bewässerungssysteme 294: "3568", // Bewässerung > Zubehör Bewässerungssysteme
@@ -249,9 +255,9 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
let productsXml = `<?xml version="1.0" encoding="UTF-8"?> let productsXml = `<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:g="http://base.google.com/ns/1.0" version="2.0"> <rss xmlns:g="http://base.google.com/ns/1.0" version="2.0">
<channel> <channel>
<title>${config.descriptions.short}</title> <title>${config.descriptions.de.short}</title>
<link>${baseUrl}</link> <link>${baseUrl}</link>
<description>${config.descriptions.short}</description> <description>${config.descriptions.de.short}</description>
<lastBuildDate>${currentDate}</lastBuildDate> <lastBuildDate>${currentDate}</lastBuildDate>
<language>de-DE</language>`; <language>de-DE</language>`;
@@ -300,19 +306,43 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
let processedCount = 0; let processedCount = 0;
let skippedCount = 0; let skippedCount = 0;
// Track products with missing data for logging // Track skip reasons with counts and product lists
const skipReasons = {
noProductOrSeoName: { count: 0, products: [] },
excludedCategory: { count: 0, products: [] },
excludedTermsTitle: { count: 0, products: [] },
excludedTermsDescription: { count: 0, products: [] },
missingGTIN: { count: 0, products: [] },
invalidGTINChecksum: { count: 0, products: [] },
missingPicture: { count: 0, products: [] },
missingWeight: { count: 0, products: [] },
insufficientDescription: { count: 0, products: [] },
nameTooShort: { count: 0, products: [] },
outOfStock: { count: 0, products: [] },
zeroPriceOrInvalid: { count: 0, products: [] },
processingError: { count: 0, products: [] }
};
// Legacy arrays for backward compatibility
const productsNeedingWeight = []; const productsNeedingWeight = [];
const productsNeedingDescription = []; const productsNeedingDescription = [];
// Category IDs to skip (seeds, plants, headshop items) // Category IDs to skip (seeds, plants, headshop items)
const skipCategoryIds = [689, 706, 709, 711, 714, 748, 749, 896, 710]; const skipCategoryIds = [689, 706, 709, 711, 714, 748, 749, 896, 710, 924, 923, 922, 921, 916, 278, 259, 258];
// Add each product as an item // Add each product as an item
allProductsData.forEach((product, index) => { allProductsData.forEach((product, index) => {
try { try {
// Skip products without essential data // Skip products without essential data
if (!product || !product.seoName) { if (!product || !product.seoName) {
skippedCount++; skippedCount++;
skipReasons.noProductOrSeoName.count++;
skipReasons.noProductOrSeoName.products.push({
id: product?.articleNumber || 'N/A',
name: product?.name || 'N/A',
url: product?.seoName ? `/Artikel/${product.seoName}` : 'N/A'
});
return; return;
} }
@@ -320,12 +350,21 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
const productCategoryId = product.categoryId || product.category_id || product.category || null; const productCategoryId = product.categoryId || product.category_id || product.category || null;
if (productCategoryId && skipCategoryIds.includes(parseInt(productCategoryId))) { if (productCategoryId && skipCategoryIds.includes(parseInt(productCategoryId))) {
skippedCount++; skippedCount++;
skipReasons.excludedCategory.count++;
skipReasons.excludedCategory.products.push({
id: product.articleNumber || product.seoName,
name: product.name || 'N/A',
categoryId: productCategoryId,
url: `/Artikel/${product.seoName}`
});
return; return;
} }
// Skip products with excluded terms in title or description // Skip products with excluded terms in title or description
const productTitle = (product.name || "").toLowerCase(); const productTitle = (product.name || "").toLowerCase();
const productDescription = (product.description || "").toLowerCase();
// Get description early so we can check it for excluded terms
const productDescription = product.kurzBeschreibung || product.description || '';
const excludedTerms = { const excludedTerms = {
title: ['canna', 'hash', 'marijuana', 'marihuana'], title: ['canna', 'hash', 'marijuana', 'marihuana'],
@@ -333,20 +372,42 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
}; };
// Check title for excluded terms // Check title for excluded terms
if (excludedTerms.title.some(term => productTitle.includes(term))) { const excludedTitleTerm = excludedTerms.title.find(term => productTitle.includes(term));
if (excludedTitleTerm) {
skippedCount++; skippedCount++;
skipReasons.excludedTermsTitle.count++;
skipReasons.excludedTermsTitle.products.push({
id: product.articleNumber || product.seoName,
name: product.name || 'N/A',
term: excludedTitleTerm,
url: `/Artikel/${product.seoName}`
});
return; return;
} }
// Check description for excluded terms // Check description for excluded terms
if (excludedTerms.description.some(term => productDescription.includes(term))) { const excludedDescTerm = excludedTerms.description.find(term => productDescription.toLowerCase().includes(term));
if (excludedDescTerm) {
skippedCount++; skippedCount++;
skipReasons.excludedTermsDescription.count++;
skipReasons.excludedTermsDescription.products.push({
id: product.articleNumber || product.seoName,
name: product.name || 'N/A',
term: excludedDescTerm,
url: `/Artikel/${product.seoName}`
});
return; return;
} }
// Skip products without GTIN or with invalid GTIN // Skip products without GTIN or with invalid GTIN
if (!product.gtin || !product.gtin.toString().trim()) { if (!product.gtin || !product.gtin.toString().trim()) {
skippedCount++; skippedCount++;
skipReasons.missingGTIN.count++;
skipReasons.missingGTIN.products.push({
id: product.articleNumber || product.seoName,
name: product.name || 'N/A',
url: `/Artikel/${product.seoName}`
});
return; return;
} }
@@ -361,15 +422,33 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
const length = digits.length; const length = digits.length;
let sum = 0; let sum = 0;
for (let i = 0; i < length - 1; i++) { if (length === 8) {
// Even/odd multiplier depends on GTIN length // EAN-8: positions 0-6, check digit at 7
let multiplier = 1; // Multipliers: 3,1,3,1,3,1,3 for positions 0-6
if (length === 8) { for (let i = 0; i < 7; i++) {
multiplier = (i % 2 === 0) ? 3 : 1; const multiplier = (i % 2 === 0) ? 3 : 1;
} else { sum += digits[i] * multiplier;
multiplier = ((length - i) % 2 === 0) ? 3 : 1; }
} else if (length === 12) {
// UPC-A: positions 0-10, check digit at 11
// Multipliers: 3,1,3,1,3,1,3,1,3,1,3 for positions 0-10
for (let i = 0; i < 11; i++) {
const multiplier = (i % 2 === 0) ? 3 : 1;
sum += digits[i] * multiplier;
}
} else if (length === 13) {
// EAN-13: positions 0-11, check digit at 12
// Multipliers: 1,3,1,3,1,3,1,3,1,3,1,3 for positions 0-11
for (let i = 0; i < 12; i++) {
const multiplier = (i % 2 === 0) ? 1 : 3;
sum += digits[i] * multiplier;
}
} else if (length === 14) {
// EAN-14: similar to EAN-13 but 14 digits
for (let i = 0; i < 13; i++) {
const multiplier = (i % 2 === 0) ? 1 : 3;
sum += digits[i] * multiplier;
} }
sum += digits[i] * multiplier;
} }
const checkDigit = (10 - (sum % 10)) % 10; const checkDigit = (10 - (sum % 10)) % 10;
return checkDigit === digits[length - 1]; return checkDigit === digits[length - 1];
@@ -377,43 +456,62 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
if (!isValidGTIN(gtinString)) { if (!isValidGTIN(gtinString)) {
skippedCount++; skippedCount++;
skipReasons.invalidGTINChecksum.count++;
skipReasons.invalidGTINChecksum.products.push({
id: product.articleNumber || product.seoName,
name: product.name || 'N/A',
gtin: gtinString,
url: `/Artikel/${product.seoName}`
});
return; return;
} }
// Skip products without pictures // Skip products without pictures
if (!product.pictureList || !product.pictureList.trim()) { if (!product.pictureList || !product.pictureList.trim()) {
skippedCount++; skippedCount++;
skipReasons.missingPicture.count++;
skipReasons.missingPicture.products.push({
id: product.articleNumber || product.seoName,
name: product.name || 'N/A',
url: `/Artikel/${product.seoName}`
});
return; return;
} }
// Check if product has weight data - validate BEFORE building XML // Check if product has weight data - validate BEFORE building XML
if (!product.weight || isNaN(product.weight)) { if (!product.weight || isNaN(product.weight)) {
// Track products without weight // Track products without weight
productsNeedingWeight.push({ const productInfo = {
id: product.articleNumber || product.seoName, id: product.articleNumber || product.seoName,
name: product.name || 'Unnamed', name: product.name || 'Unnamed',
url: `/Artikel/${product.seoName}` url: `/Artikel/${product.seoName}`
}); };
productsNeedingWeight.push(productInfo);
skipReasons.missingWeight.count++;
skipReasons.missingWeight.products.push(productInfo);
skippedCount++; skippedCount++;
return; return;
} }
// Check if description is missing or too short (less than 20 characters) - skip if insufficient // Check if description is missing or too short (less than 20 characters) - skip if insufficient
const originalDescription = product.description ? cleanTextContent(product.description) : ''; const originalDescription = productDescription ? cleanTextContent(productDescription) : '';
if (!originalDescription || originalDescription.length < 20) { if (!originalDescription || originalDescription.length < 20) {
productsNeedingDescription.push({ const productInfo = {
id: product.articleNumber || product.seoName, id: product.articleNumber || product.seoName,
name: product.name || 'Unnamed', name: product.name || 'Unnamed',
currentDescription: originalDescription || 'NONE', currentDescription: originalDescription || 'NONE',
url: `/Artikel/${product.seoName}` url: `/Artikel/${product.seoName}`
}); };
productsNeedingDescription.push(productInfo);
skipReasons.insufficientDescription.count++;
skipReasons.insufficientDescription.products.push(productInfo);
skippedCount++; skippedCount++;
return; return;
} }
// Clean description for feed (remove HTML tags and limit length) // Clean description for feed (remove HTML tags and limit length)
const rawDescription = cleanTextContent(product.description).substring(0, 500); const feedDescription = cleanTextContent(productDescription).substring(0, 500);
const cleanDescription = escapeXml(rawDescription) || "Produktbeschreibung nicht verfügbar"; const cleanDescription = escapeXml(feedDescription) || "Produktbeschreibung nicht verfügbar";
// Clean product name // Clean product name
const rawName = product.name || "Unnamed Product"; const rawName = product.name || "Unnamed Product";
@@ -422,6 +520,13 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
// Validate essential fields // Validate essential fields
if (!cleanName || cleanName.length < 2) { if (!cleanName || cleanName.length < 2) {
skippedCount++; skippedCount++;
skipReasons.nameTooShort.count++;
skipReasons.nameTooShort.products.push({
id: product.articleNumber || product.seoName,
name: rawName,
cleanedName: cleanName,
url: `/Artikel/${product.seoName}`
});
return; return;
} }
@@ -430,7 +535,7 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
// Generate image URL // Generate image URL
const imageUrl = product.pictureList && product.pictureList.trim() const imageUrl = product.pictureList && product.pictureList.trim()
? `${baseUrl}/assets/images/prod${product.pictureList.split(",")[0].trim()}.jpg` ? `${baseUrl}/assets/images/prod${product.pictureList.split(",")[0].trim()}.avif`
: `${baseUrl}/assets/images/nopicture.jpg`; : `${baseUrl}/assets/images/nopicture.jpg`;
// Generate brand (manufacturer) // Generate brand (manufacturer)
@@ -446,6 +551,12 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
// Skip products that are out of stock // Skip products that are out of stock
if (!product.available) { if (!product.available) {
skippedCount++; skippedCount++;
skipReasons.outOfStock.count++;
skipReasons.outOfStock.products.push({
id: product.articleNumber || product.seoName,
name: product.name || 'N/A',
url: `/Artikel/${product.seoName}`
});
return; return;
} }
@@ -457,6 +568,13 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
// Skip products with price == 0 // Skip products with price == 0
if (!product.price || parseFloat(product.price) === 0) { if (!product.price || parseFloat(product.price) === 0) {
skippedCount++; skippedCount++;
skipReasons.zeroPriceOrInvalid.count++;
skipReasons.zeroPriceOrInvalid.products.push({
id: product.articleNumber || product.seoName,
name: product.name || 'N/A',
price: product.price,
url: `/Artikel/${product.seoName}`
});
return; return;
} }
@@ -523,6 +641,13 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
} catch (itemError) { } catch (itemError) {
console.log(` ⚠️ Skipped product ${index + 1}: ${itemError.message}`); console.log(` ⚠️ Skipped product ${index + 1}: ${itemError.message}`);
skippedCount++; skippedCount++;
skipReasons.processingError.count++;
skipReasons.processingError.products.push({
id: product?.articleNumber || product?.seoName || 'N/A',
name: product?.name || 'N/A',
error: itemError.message,
url: product?.seoName ? `/Artikel/${product.seoName}` : 'N/A'
});
} }
}); });
@@ -530,7 +655,43 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
</channel> </channel>
</rss>`; </rss>`;
console.log(` 📊 Processing summary: ${processedCount} products included, ${skippedCount} skipped`); console.log(`\n 📊 Processing summary: ${processedCount} products included, ${skippedCount} skipped`);
// Display skip reason totals
console.log(`\n 📋 Skip Reasons Breakdown:`);
console.log(` ────────────────────────────────────────────────────────────`);
const skipReasonLabels = {
noProductOrSeoName: 'No Product or SEO Name',
excludedCategory: 'Excluded Category',
excludedTermsTitle: 'Excluded Terms in Title',
excludedTermsDescription: 'Excluded Terms in Description',
missingGTIN: 'Missing GTIN',
invalidGTINChecksum: 'Invalid GTIN Checksum',
missingPicture: 'Missing Picture',
missingWeight: 'Missing Weight',
insufficientDescription: 'Insufficient Description',
nameTooShort: 'Name Too Short',
outOfStock: 'Out of Stock',
zeroPriceOrInvalid: 'Zero or Invalid Price',
processingError: 'Processing Error'
};
let hasAnySkips = false;
Object.entries(skipReasons).forEach(([key, data]) => {
if (data.count > 0) {
hasAnySkips = true;
const label = skipReasonLabels[key] || key;
console.log(`${label}: ${data.count}`);
}
});
if (!hasAnySkips) {
console.log(` ✅ No products were skipped`);
}
console.log(` ────────────────────────────────────────────────────────────`);
console.log(` Total: ${skippedCount} products skipped\n`);
// Write log files for products needing attention // Write log files for products needing attention
const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
@@ -541,7 +702,56 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
fs.mkdirSync(logsDir, { recursive: true }); fs.mkdirSync(logsDir, { recursive: true });
} }
// Write missing weight log // Write comprehensive skip reasons log
const skipLogPath = path.join(logsDir, `skip-reasons-${timestamp}.log`);
let skipLogContent = `# Product Skip Reasons Report
# Generated: ${new Date().toISOString()}
# Total products processed: ${processedCount}
# Total products skipped: ${skippedCount}
# Base URL: ${baseUrl}
`;
Object.entries(skipReasons).forEach(([key, data]) => {
if (data.count > 0) {
const label = skipReasonLabels[key] || key;
skipLogContent += `\n## ${label} (${data.count} products)\n`;
skipLogContent += `${'='.repeat(80)}\n`;
data.products.forEach(product => {
skipLogContent += `ID: ${product.id}\n`;
skipLogContent += `Name: ${product.name}\n`;
if (product.categoryId !== undefined) {
skipLogContent += `Category ID: ${product.categoryId}\n`;
}
if (product.term !== undefined) {
skipLogContent += `Excluded Term: ${product.term}\n`;
}
if (product.gtin !== undefined) {
skipLogContent += `GTIN: ${product.gtin}\n`;
}
if (product.currentDescription !== undefined) {
skipLogContent += `Current Description: "${product.currentDescription}"\n`;
}
if (product.cleanedName !== undefined) {
skipLogContent += `Cleaned Name: "${product.cleanedName}"\n`;
}
if (product.price !== undefined) {
skipLogContent += `Price: ${product.price}\n`;
}
if (product.error !== undefined) {
skipLogContent += `Error: ${product.error}\n`;
}
skipLogContent += `URL: ${baseUrl}${product.url}\n`;
skipLogContent += `${'-'.repeat(80)}\n`;
});
}
});
fs.writeFileSync(skipLogPath, skipLogContent, 'utf8');
console.log(` 📄 Detailed skip reasons report saved to: ${skipLogPath}`);
// Write missing weight log (for backward compatibility)
if (productsNeedingWeight.length > 0) { if (productsNeedingWeight.length > 0) {
const weightLogContent = `# Products Missing Weight Data const weightLogContent = `# Products Missing Weight Data
# Generated: ${new Date().toISOString()} # Generated: ${new Date().toISOString()}
@@ -552,10 +762,10 @@ ${productsNeedingWeight.map(product => `${product.id}\t${product.name}\t${baseUr
const weightLogPath = path.join(logsDir, `missing-weight-${timestamp}.log`); const weightLogPath = path.join(logsDir, `missing-weight-${timestamp}.log`);
fs.writeFileSync(weightLogPath, weightLogContent, 'utf8'); fs.writeFileSync(weightLogPath, weightLogContent, 'utf8');
console.log(`\n ⚠️ Products missing weight (${productsNeedingWeight.length}) - saved to: ${weightLogPath}`); console.log(` ⚠️ Products missing weight (${productsNeedingWeight.length}) - saved to: ${weightLogPath}`);
} }
// Write missing description log // Write missing description log (for backward compatibility)
if (productsNeedingDescription.length > 0) { if (productsNeedingDescription.length > 0) {
const descLogContent = `# Products With Insufficient Description Data const descLogContent = `# Products With Insufficient Description Data
# Generated: ${new Date().toISOString()} # Generated: ${new Date().toISOString()}
@@ -566,7 +776,7 @@ ${productsNeedingDescription.map(product => `${product.id}\t${product.name}\t"${
const descLogPath = path.join(logsDir, `missing-description-${timestamp}.log`); const descLogPath = path.join(logsDir, `missing-description-${timestamp}.log`);
fs.writeFileSync(descLogPath, descLogContent, 'utf8'); fs.writeFileSync(descLogPath, descLogContent, 'utf8');
console.log(`\n ⚠️ Products with insufficient description (${productsNeedingDescription.length}) - saved to: ${descLogPath}`); console.log(` ⚠️ Products with insufficient description (${productsNeedingDescription.length}) - saved to: ${descLogPath}`);
} }
if (productsNeedingWeight.length === 0 && productsNeedingDescription.length === 0) { if (productsNeedingWeight.length === 0 && productsNeedingDescription.length === 0) {

View File

@@ -1,6 +1,6 @@
const generateHomepageMetaTags = (baseUrl, config) => { const generateHomepageMetaTags = (baseUrl, config) => {
const description = config.descriptions.long; const description = config.descriptions.de.long;
const keywords = config.keywords; const keywords = config.keywords.de;
const imageUrl = `${baseUrl}${config.images.logo}`; const imageUrl = `${baseUrl}${config.images.logo}`;
// Ensure URLs are properly formatted // Ensure URLs are properly formatted
@@ -12,7 +12,7 @@ const generateHomepageMetaTags = (baseUrl, config) => {
<meta name="keywords" content="${keywords}"> <meta name="keywords" content="${keywords}">
<!-- Open Graph Meta Tags --> <!-- Open Graph Meta Tags -->
<meta property="og:title" content="${config.descriptions.short}"> <meta property="og:title" content="${config.descriptions.de.short}">
<meta property="og:description" content="${description}"> <meta property="og:description" content="${description}">
<meta property="og:image" content="${imageUrl}"> <meta property="og:image" content="${imageUrl}">
<meta property="og:url" content="${canonicalUrl}"> <meta property="og:url" content="${canonicalUrl}">
@@ -21,7 +21,7 @@ const generateHomepageMetaTags = (baseUrl, config) => {
<!-- Twitter Card Meta Tags --> <!-- Twitter Card Meta Tags -->
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${config.descriptions.short}"> <meta name="twitter:title" content="${config.descriptions.de.short}">
<meta name="twitter:description" content="${description}"> <meta name="twitter:description" content="${description}">
<meta name="twitter:image" content="${imageUrl}"> <meta name="twitter:image" content="${imageUrl}">
@@ -41,7 +41,7 @@ const generateHomepageJsonLd = (baseUrl, config, categories = []) => {
"@type": "WebSite", "@type": "WebSite",
name: config.brandName, name: config.brandName,
url: canonicalUrl, url: canonicalUrl,
description: config.descriptions.long, description: config.descriptions.de.long,
publisher: { publisher: {
"@type": "Organization", "@type": "Organization",
name: config.brandName, name: config.brandName,
@@ -73,7 +73,7 @@ const generateHomepageJsonLd = (baseUrl, config, categories = []) => {
"@type": "LocalBusiness", "@type": "LocalBusiness",
"name": config.brandName, "name": config.brandName,
"alternateName": config.siteName, "alternateName": config.siteName,
"description": config.descriptions.long, "description": config.descriptions.de.long,
"url": canonicalUrl, "url": canonicalUrl,
"logo": logoUrl, "logo": logoUrl,
"image": logoUrl, "image": logoUrl,

View File

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

View File

@@ -55,29 +55,29 @@ GrowHeads.de is a German online shop and local store in Dresden specializing in
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-'); const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
const productsPerPage = 50; const productsPerPage = 50;
const totalPages = Math.ceil(productCount / productsPerPage); const totalPages = Math.ceil(productCount / productsPerPage);
llmsTxt += `#### ${category.name} (${productCount} products)`; llmsTxt += `#### ${category.name} (${productCount} products)`;
if (totalPages > 1) { if (totalPages > 1) {
llmsTxt += ` llmsTxt += `
- **Product Catalog**: ${totalPages} pages available - **Product Catalog**: ${totalPages} pages available
- **Page 1**: ${baseUrl}/llms-${categorySlug}-page-1.txt (Products 1-${Math.min(productsPerPage, productCount)})`; - **Page 1**: ${baseUrl}/llms-${categorySlug}-page-1.txt (Products 1-${Math.min(productsPerPage, productCount)})`;
if (totalPages > 2) { if (totalPages > 2) {
llmsTxt += ` llmsTxt += `
- **Page 2**: ${baseUrl}/llms-${categorySlug}-page-2.txt (Products ${productsPerPage + 1}-${Math.min(productsPerPage * 2, productCount)})`; - **Page 2**: ${baseUrl}/llms-${categorySlug}-page-2.txt (Products ${productsPerPage + 1}-${Math.min(productsPerPage * 2, productCount)})`;
} }
if (totalPages > 3) { if (totalPages > 3) {
llmsTxt += ` llmsTxt += `
- **...**: Additional pages available`; - **...**: Additional pages available`;
} }
if (totalPages > 2) { if (totalPages > 2) {
llmsTxt += ` llmsTxt += `
- **Page ${totalPages}**: ${baseUrl}/llms-${categorySlug}-page-${totalPages}.txt (Products ${((totalPages - 1) * productsPerPage) + 1}-${productCount})`; - **Page ${totalPages}**: ${baseUrl}/llms-${categorySlug}-page-${totalPages}.txt (Products ${((totalPages - 1) * productsPerPage) + 1}-${productCount})`;
} }
llmsTxt += ` llmsTxt += `
- **Access Pattern**: Replace "page-X" with desired page number (1-${totalPages})`; - **Access Pattern**: Replace "page-X" with desired page number (1-${totalPages})`;
} else if (productCount > 0) { } else if (productCount > 0) {
@@ -87,7 +87,7 @@ GrowHeads.de is a German online shop and local store in Dresden specializing in
llmsTxt += ` llmsTxt += `
- **Product Catalog**: No products available`; - **Product Catalog**: No products available`;
} }
llmsTxt += ` llmsTxt += `
`; `;
@@ -106,7 +106,7 @@ GrowHeads.de is a German online shop and local store in Dresden specializing in
const generateCategoryLlmsTxt = (category, categoryProducts = [], baseUrl, config, pageNumber = 1, productsPerPage = 50) => { const generateCategoryLlmsTxt = (category, categoryProducts = [], baseUrl, config, pageNumber = 1, productsPerPage = 50) => {
const currentDate = new Date().toISOString().split("T")[0]; // YYYY-MM-DD format const currentDate = new Date().toISOString().split("T")[0]; // YYYY-MM-DD format
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-'); const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
// Calculate pagination // Calculate pagination
const totalProducts = categoryProducts.length; const totalProducts = categoryProducts.length;
const totalPages = Math.ceil(totalProducts / productsPerPage); const totalPages = Math.ceil(totalProducts / productsPerPage);
@@ -140,28 +140,28 @@ This file contains products ${startIndex + 1}-${endIndex} of ${totalProducts} in
**How to access other pages in this category:** **How to access other pages in this category:**
`; `;
if (pageNumber > 1) { if (pageNumber > 1) {
categoryLlmsTxt += `- **Previous Page**: ${baseUrl}/llms-${categorySlug}-page-${pageNumber - 1}.txt categoryLlmsTxt += `- **Previous Page**: ${baseUrl}/llms-${categorySlug}-page-${pageNumber - 1}.txt
`; `;
} }
if (pageNumber < totalPages) { if (pageNumber < totalPages) {
categoryLlmsTxt += `- **Next Page**: ${baseUrl}/llms-${categorySlug}-page-${pageNumber + 1}.txt categoryLlmsTxt += `- **Next Page**: ${baseUrl}/llms-${categorySlug}-page-${pageNumber + 1}.txt
`; `;
} }
categoryLlmsTxt += `- **First Page**: ${baseUrl}/llms-${categorySlug}-page-1.txt categoryLlmsTxt += `- **First Page**: ${baseUrl}/llms-${categorySlug}-page-1.txt
- **Last Page**: ${baseUrl}/llms-${categorySlug}-page-${totalPages}.txt - **Last Page**: ${baseUrl}/llms-${categorySlug}-page-${totalPages}.txt
**All pages in this category:** **All pages in this category:**
`; `;
for (let i = 1; i <= totalPages; i++) { for (let i = 1; i <= totalPages; i++) {
categoryLlmsTxt += `- **Page ${i}**: ${baseUrl}/llms-${categorySlug}-page-${i}.txt (Products ${((i-1) * productsPerPage) + 1}-${Math.min(i * productsPerPage, totalProducts)}) categoryLlmsTxt += `- **Page ${i}**: ${baseUrl}/llms-${categorySlug}-page-${i}.txt (Products ${((i - 1) * productsPerPage) + 1}-${Math.min(i * productsPerPage, totalProducts)})
`; `;
} }
categoryLlmsTxt += ` categoryLlmsTxt += `
`; `;
@@ -173,17 +173,23 @@ This file contains products ${startIndex + 1}-${endIndex} of ${totalProducts} in
// Clean description for markdown (remove HTML tags and limit length) // Clean description for markdown (remove HTML tags and limit length)
const cleanDescription = product.description const cleanDescription = product.description
? product.description ? product.description
.replace(/<[^>]*>/g, "") .replace(/<[^>]*>/g, "")
.replace(/\n/g, " ") .replace(/\n/g, " ")
.trim() .trim()
.substring(0, 300) .substring(0, 300)
: ""; : "";
const globalIndex = startIndex + index + 1; const globalIndex = startIndex + index + 1;
categoryLlmsTxt += `## ${globalIndex}. ${product.name} categoryLlmsTxt += `## ${globalIndex}. ${product.name}
- **Product URL**: ${baseUrl}/Artikel/${product.seoName} - **Product URL**: ${baseUrl}/Artikel/${product.seoName}
- **Article Number**: ${product.articleNumber || 'N/A'} `;
if (product.kurzBeschreibung) {
categoryLlmsTxt += `- **Desc:** ${product.kurzBeschreibung}\n`;
}
categoryLlmsTxt += `- **Article Number**: ${product.articleNumber || 'N/A'}
- **Price**: €${product.price || '0.00'} - **Price**: €${product.price || '0.00'}
- **Brand**: ${product.manufacturer || config.brandName} - **Brand**: ${product.manufacturer || config.brandName}
- **Availability**: ${product.available ? 'In Stock' : 'Out of Stock'}`; - **Availability**: ${product.available ? 'In Stock' : 'Out of Stock'}`;
@@ -228,13 +234,13 @@ This category currently contains no products.
if (pageNumber > 1) { if (pageNumber > 1) {
categoryLlmsTxt += `← [Previous Page](${baseUrl}/llms-${categorySlug}-page-${pageNumber - 1}.txt) | `; categoryLlmsTxt += `← [Previous Page](${baseUrl}/llms-${categorySlug}-page-${pageNumber - 1}.txt) | `;
} }
categoryLlmsTxt += `[Category Overview](${baseUrl}/llms-${categorySlug}-page-1.txt)`; categoryLlmsTxt += `[Category Overview](${baseUrl}/llms-${categorySlug}-page-1.txt)`;
if (pageNumber < totalPages) { if (pageNumber < totalPages) {
categoryLlmsTxt += ` | [Next Page](${baseUrl}/llms-${categorySlug}-page-${pageNumber + 1}.txt) →`; categoryLlmsTxt += ` | [Next Page](${baseUrl}/llms-${categorySlug}-page-${pageNumber + 1}.txt) →`;
} }
categoryLlmsTxt += ` categoryLlmsTxt += `
`; `;
@@ -248,17 +254,52 @@ This category currently contains no products.
return categoryLlmsTxt; return categoryLlmsTxt;
}; };
// Helper function to generate a simple product list for a category
const generateCategoryProductList = (category, categoryProducts = []) => {
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
const fileName = `llms-${categorySlug}-list.txt`;
const subcategoryIds = (category.subcategories || []).join(',');
let content = '';//`${String(category.name)},${String(category.id)},[${subcategoryIds}]\n`;
categoryProducts.forEach((product) => {
const artnr = String(product.articleNumber || '');
const price = String(product.price || '0.00');
const name = String(product.name || '');
const kurzBeschreibung = String(product.kurzBeschreibung || '');
// Escape commas in fields by wrapping in quotes if they contain commas
const escapeField = (field) => {
const fieldStr = String(field || '');
if (fieldStr.includes(',')) {
return `"${fieldStr.replace(/"/g, '""')}"`;
}
return fieldStr;
};
content += `${escapeField(artnr)},${escapeField(price)},${escapeField(name)},${escapeField(kurzBeschreibung)}\n`;
});
return {
fileName,
content,
categoryName: category.name,
categoryId: category.id,
productCount: categoryProducts.length
};
};
// Helper function to generate all pages for a category // Helper function to generate all pages for a category
const generateAllCategoryLlmsPages = (category, categoryProducts = [], baseUrl, config, productsPerPage = 50) => { const generateAllCategoryLlmsPages = (category, categoryProducts = [], baseUrl, config, productsPerPage = 50) => {
const totalProducts = categoryProducts.length; const totalProducts = categoryProducts.length;
const totalPages = Math.ceil(totalProducts / productsPerPage); const totalPages = Math.ceil(totalProducts / productsPerPage);
const pages = []; const pages = [];
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber++) { for (let pageNumber = 1; pageNumber <= totalPages; pageNumber++) {
const pageContent = generateCategoryLlmsTxt(category, categoryProducts, baseUrl, config, pageNumber, productsPerPage); const pageContent = generateCategoryLlmsTxt(category, categoryProducts, baseUrl, config, pageNumber, productsPerPage);
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-'); const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
const fileName = `llms-${categorySlug}-page-${pageNumber}.txt`; const fileName = `llms-${categorySlug}-page-${pageNumber}.txt`;
pages.push({ pages.push({
fileName, fileName,
content: pageContent, content: pageContent,
@@ -266,7 +307,7 @@ const generateAllCategoryLlmsPages = (category, categoryProducts = [], baseUrl,
totalPages totalPages
}); });
} }
return pages; return pages;
}; };
@@ -274,4 +315,5 @@ module.exports = {
generateLlmsTxt, generateLlmsTxt,
generateCategoryLlmsTxt, generateCategoryLlmsTxt,
generateAllCategoryLlmsPages, generateAllCategoryLlmsPages,
generateCategoryProductList,
}; };

View File

@@ -5,16 +5,22 @@ const generateProductMetaTags = (product, baseUrl, config) => {
product.pictureList && product.pictureList.trim() product.pictureList && product.pictureList.trim()
? `${baseUrl}/assets/images/prod${product.pictureList ? `${baseUrl}/assets/images/prod${product.pictureList
.split(",")[0] .split(",")[0]
.trim()}.jpg` .trim()}.avif`
: `${baseUrl}/assets/images/nopicture.jpg`; : `${baseUrl}/assets/images/nopicture.jpg`;
// Clean description for meta (remove HTML tags and limit length) // Clean description for meta (remove HTML tags and limit length)
const cleanDescription = product.description const cleanDescription = product.kurzBeschreibung
? product.description ? product.kurzBeschreibung
.replace(/<[^>]*>/g, "") .replace(/<[^>]*>/g, "")
.replace(/\n/g, " ") .replace(/\n/g, " ")
.substring(0, 160) .substring(0, 160)
: `${product.name} - Art.-Nr.: ${product.articleNumber}`; : product.description
? product.description
.replace(/<[^>]*>/g, "")
.replace(/\n/g, " ")
.substring(0, 160)
: `${product.name} - Art.-Nr.: ${product.articleNumber}`;
return ` return `
<!-- SEO Meta Tags --> <!-- SEO Meta Tags -->
@@ -62,7 +68,7 @@ const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) =>
product.pictureList && product.pictureList.trim() product.pictureList && product.pictureList.trim()
? `${baseUrl}/assets/images/prod${product.pictureList ? `${baseUrl}/assets/images/prod${product.pictureList
.split(",")[0] .split(",")[0]
.trim()}.jpg` .trim()}.avif`
: `${baseUrl}/assets/images/nopicture.jpg`; : `${baseUrl}/assets/images/nopicture.jpg`;
// Clean description for JSON-LD (remove HTML tags) // Clean description for JSON-LD (remove HTML tags)
@@ -100,6 +106,41 @@ const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) =>
"@type": "Organization", "@type": "Organization",
name: config.brandName, name: config.brandName,
}, },
hasMerchantReturnPolicy: {
"@type": "MerchantReturnPolicy",
applicableCountry: "DE",
returnPolicyCategory: "https://schema.org/MerchantReturnFiniteReturnWindow",
merchantReturnDays: 14,
returnMethod: "https://schema.org/ReturnByMail",
returnFees: "https://schema.org/FreeReturn",
},
shippingDetails: {
"@type": "OfferShippingDetails",
shippingRate: {
"@type": "MonetaryAmount",
value: 5.90,
currency: "EUR",
},
shippingDestination: {
"@type": "DefinedRegion",
addressCountry: "DE",
},
deliveryTime: {
"@type": "ShippingDeliveryTime",
handlingTime: {
"@type": "QuantitativeValue",
minValue: 0,
maxValue: 1,
unitCode: "DAY",
},
transitTime: {
"@type": "QuantitativeValue",
minValue: 2,
maxValue: 3,
unitCode: "DAY",
},
},
},
}, },
}; };

View File

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

105
process_llms_cat.cjs Normal file
View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

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

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ import {
Route, Route,
Navigate, Navigate,
useLocation, useLocation,
useNavigate, useNavigate
} from "react-router-dom"; } from "react-router-dom";
import { ThemeProvider } from "@mui/material/styles"; import { ThemeProvider } from "@mui/material/styles";
import CssBaseline from "@mui/material/CssBaseline"; import CssBaseline from "@mui/material/CssBaseline";
@@ -18,6 +18,9 @@ import PaletteIcon from "@mui/icons-material/Palette";
import ScienceIcon from "@mui/icons-material/Science"; import ScienceIcon from "@mui/icons-material/Science";
import { CarouselProvider } from "./contexts/CarouselContext.js"; import { CarouselProvider } from "./contexts/CarouselContext.js";
import { ProductContextProvider } from "./context/ProductContext.js";
import { CategoryContextProvider } from "./context/CategoryContext.js";
import TitleUpdater from "./components/TitleUpdater.js";
import config from "./config.js"; import config from "./config.js";
import ScrollToTop from "./components/ScrollToTop.js"; import ScrollToTop from "./components/ScrollToTop.js";
@@ -47,6 +50,7 @@ const Datenschutz = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/D
const AGB = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/AGB.js")); const AGB = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/AGB.js"));
//const NotFound404 = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/NotFound404.js")); <Route path="/404" element={<NotFound404 />} /> //const NotFound404 = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/NotFound404.js")); <Route path="/404" element={<NotFound404 />} />
const Sitemap = lazy(() => import(/* webpackChunkName: "sitemap" */ "./pages/Sitemap.js")); const Sitemap = lazy(() => import(/* webpackChunkName: "sitemap" */ "./pages/Sitemap.js"));
const CategoriesPage = lazy(() => import(/* webpackChunkName: "categories" */ "./pages/CategoriesPage.js"));
const Impressum = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Impressum.js")); const Impressum = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Impressum.js"));
const Batteriegesetzhinweise = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Batteriegesetzhinweise.js")); const Batteriegesetzhinweise = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Batteriegesetzhinweise.js"));
const Widerrufsrecht = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Widerrufsrecht.js")); const Widerrufsrecht = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Widerrufsrecht.js"));
@@ -82,6 +86,9 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
const [authVersion, setAuthVersion] = useState(0); const [authVersion, setAuthVersion] = useState(0);
// @note Theme customizer state for development mode // @note Theme customizer state for development mode
const [isThemeCustomizerOpen, setThemeCustomizerOpen] = useState(false); const [isThemeCustomizerOpen, setThemeCustomizerOpen] = useState(false);
// State to track active category for article pages
const [articleCategoryId, setArticleCategoryId] = useState(null);
// Remove duplicate theme state since it's passed as prop // Remove duplicate theme state since it's passed as prop
// const [dynamicTheme, setDynamicTheme] = useState(createTheme(defaultTheme)); // const [dynamicTheme, setDynamicTheme] = useState(createTheme(defaultTheme));
@@ -112,10 +119,44 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
}; };
}, []); }, []);
// Extract categoryId from pathname if on category route // Clear article category when navigating away from article pages
useEffect(() => {
const isArticlePage = location.pathname.startsWith('/Artikel/');
const isCategoryPage = location.pathname.startsWith('/Kategorie/');
const isHomePage = location.pathname === '/';
// Only clear article category when navigating to non-article pages
// (but keep it when going from category to article)
if (!isArticlePage && !isCategoryPage && !isHomePage) {
setArticleCategoryId(null);
}
}, [location.pathname]);
// Read article category from navigation state (when coming from product click)
useEffect(() => {
if (location.state && location.state.articleCategoryId !== undefined) {
if (location.state.articleCategoryId !== null) {
setArticleCategoryId(location.state.articleCategoryId);
}
// Clear the state so it doesn't persist on page refresh
navigate(location.pathname, { replace: true, state: {} });
}
}, [location.state, navigate, location.pathname]);
// Extract categoryId from pathname if on category route, or use article category
const getCategoryId = () => { const getCategoryId = () => {
const match = location.pathname.match(/^\/Kategorie\/(.+)$/); const match = location.pathname.match(/^\/Kategorie\/(.+)$/);
return match ? match[1] : null; if (match) {
return match[1];
}
// For article pages, use the article category if available
const isArticlePage = location.pathname.startsWith('/Artikel/');
if (isArticlePage && articleCategoryId) {
return articleCategoryId;
}
return null;
}; };
const categoryId = getCategoryId(); const categoryId = getCategoryId();
@@ -185,9 +226,10 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
bgcolor: "background.default", bgcolor: "background.default",
}} }}
> >
<TitleUpdater />
<ScrollToTop /> <ScrollToTop />
<Header active categoryId={categoryId} key={authVersion} /> <Header active categoryId={categoryId} key={authVersion} />
<Box sx={{ flexGrow: 1 }}> <Box component="main" sx={{ flexGrow: 1 }}>
<Suspense fallback={ <Suspense fallback={
// Use prerender fallback if available, otherwise show loading spinner // Use prerender fallback if available, otherwise show loading spinner
typeof window !== "undefined" && window.__PRERENDER_FALLBACK__ ? ( typeof window !== "undefined" && window.__PRERENDER_FALLBACK__ ? (
@@ -219,19 +261,19 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
{/* Category page - Render Content in parallel */} {/* Category page - Render Content in parallel */}
<Route <Route
path="/Kategorie/:categoryId" path="/Kategorie/:categoryId"
element={<Content/>} element={<Content />}
/> />
{/* Single product page */} {/* Single product page */}
<Route <Route
path="/Artikel/:seoName" path="/Artikel/:seoName"
element={<ProductDetail/>} element={<ProductDetail />}
/> />
{/* Search page - Render Content in parallel */} {/* Search page - Render Content in parallel */}
<Route path="/search" element={<Content/>} /> <Route path="/search" element={<Content />} />
{/* Profile page */} {/* Profile page */}
<Route path="/profile" element={<ProfilePage/>} /> <Route path="/profile" element={<ProfilePage />} />
{/* Payment success page for Mollie redirects */} {/* Payment success page for Mollie redirects */}
<Route path="/payment/success" element={<PaymentSuccess />} /> <Route path="/payment/success" element={<PaymentSuccess />} />
@@ -239,22 +281,23 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
{/* Reset password page */} {/* Reset password page */}
<Route <Route
path="/resetPassword" path="/resetPassword"
element={<ResetPassword/>} element={<ResetPassword />}
/> />
{/* Admin page */} {/* Admin page */}
<Route path="/admin" element={<AdminPage/>} /> <Route path="/admin" element={<AdminPage />} />
{/* Admin Users page */} {/* Admin Users page */}
<Route path="/admin/users" element={<UsersPage/>} /> <Route path="/admin/users" element={<UsersPage />} />
{/* Admin Server Logs page */} {/* Admin Server Logs page */}
<Route path="/admin/logs" element={<ServerLogsPage/>} /> <Route path="/admin/logs" element={<ServerLogsPage />} />
{/* Legal pages */} {/* Legal pages */}
<Route path="/datenschutz" element={<Datenschutz />} /> <Route path="/datenschutz" element={<Datenschutz />} />
<Route path="/agb" element={<AGB />} /> <Route path="/agb" element={<AGB />} />
<Route path="/sitemap" element={<Sitemap />} /> <Route path="/sitemap" element={<Sitemap />} />
<Route path="/Kategorien" element={<CategoriesPage />} />
<Route path="/impressum" element={<Impressum />} /> <Route path="/impressum" element={<Impressum />} />
<Route <Route
path="/batteriegesetzhinweise" path="/batteriegesetzhinweise"
@@ -263,7 +306,7 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
<Route path="/widerrufsrecht" element={<Widerrufsrecht />} /> <Route path="/widerrufsrecht" element={<Widerrufsrecht />} />
{/* Grow Tent Configurator */} {/* Grow Tent Configurator */}
<Route path="/Konfigurator" element={<GrowTentKonfigurator/>} /> <Route path="/Konfigurator" element={<GrowTentKonfigurator />} />
{/* Separate pages that are truly different */} {/* Separate pages that are truly different */}
<Route path="/presseverleih" element={<PresseverleihPage />} /> <Route path="/presseverleih" element={<PresseverleihPage />} />
@@ -413,12 +456,16 @@ const App = () => {
return ( return (
<LanguageProvider i18n={i18n}> <LanguageProvider i18n={i18n}>
<ThemeProvider theme={dynamicTheme}> <ThemeProvider theme={dynamicTheme}>
<CssBaseline /> <ProductContextProvider>
<AppContent <CategoryContextProvider>
currentTheme={currentTheme} <CssBaseline />
dynamicTheme={dynamicTheme} <AppContent
onThemeChange={handleThemeChange} currentTheme={currentTheme}
/> dynamicTheme={dynamicTheme}
onThemeChange={handleThemeChange}
/>
</CategoryContextProvider>
</ProductContextProvider>
</ThemeProvider> </ThemeProvider>
</LanguageProvider> </LanguageProvider>
); );

View File

@@ -44,7 +44,7 @@ const PrerenderAppContent = (socket) => (
<CategoryList categoryId={209} activeCategoryId={null} socket={socket}/> <CategoryList categoryId={209} activeCategoryId={null} socket={socket}/>
</AppBar> </AppBar>
<Box sx={{ flexGrow: 1 }}> <Box component="main" sx={{ flexGrow: 1 }}>
<CarouselProvider> <CarouselProvider>
<Routes> <Routes>
<Route path="/" element={<MainPageLayout />} /> <Route path="/" element={<MainPageLayout />} />

View File

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

View File

@@ -111,7 +111,7 @@ const PrerenderCategory = ({ categoryId, categoryName, categorySeoName: _categor
component="img" component="img"
height="200" height="200"
image={product.pictureList && product.pictureList.trim() image={product.pictureList && product.pictureList.trim()
? `/assets/images/prod${product.pictureList.split(',')[0].trim()}.jpg` ? `/assets/images/prod${product.pictureList.split(',')[0].trim()}.avif`
: '/assets/images/nopicture.jpg' : '/assets/images/nopicture.jpg'
} }
alt={product.name} alt={product.name}

View File

@@ -9,6 +9,7 @@ import {
Toolbar, Toolbar,
Button Button
} from '@mui/material'; } from '@mui/material';
import sanitizeHtml from 'sanitize-html';
import Footer from './components/Footer.js'; import Footer from './components/Footer.js';
import { Logo } from './components/header/index.js'; import { Logo } from './components/header/index.js';
import ProductImage from './components/ProductImage.js'; import ProductImage from './components/ProductImage.js';
@@ -539,7 +540,17 @@ class PrerenderProduct extends React.Component {
React.createElement( React.createElement(
'div', 'div',
{ {
dangerouslySetInnerHTML: { __html: product.description }, dangerouslySetInnerHTML: {
__html: sanitizeHtml(product.description, {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
allowedAttributes: {
'*': ['class', 'style'],
'a': ['href', 'title'],
'img': ['src', 'alt', 'width', 'height']
},
disallowedTagsMode: 'discard'
})
},
style: { style: {
fontFamily: '"Roboto","Helvetica","Arial",sans-serif', fontFamily: '"Roboto","Helvetica","Arial",sans-serif',
fontSize: '1rem', fontSize: '1rem',

View File

@@ -79,7 +79,7 @@ class ArticleAvailabilityForm extends Component {
} else { } else {
this.setState({ this.setState({
loading: false, loading: false,
error: response.error || 'Ein Fehler ist aufgetreten' error: response.error || this.props.t("productDialogs.errorGeneric")
}); });
} }
@@ -114,20 +114,21 @@ class ArticleAvailabilityForm extends Component {
render() { render() {
const { name, email, telegramId, notificationMethod, message, loading, success, error } = this.state; const { name, email, telegramId, notificationMethod, message, loading, success, error } = this.state;
const { t } = this.props;
return ( return (
<Paper id="availability-form" sx={{ p: 3, mt: 4, borderRadius: 2, boxShadow: "0 2px 8px rgba(0,0,0,0.08)" }}> <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' }}> <Typography variant="h5" gutterBottom sx={{ fontWeight: 600, color: '#333' }}>
Verfügbarkeit anfragen {t("productDialogs.availabilityTitle")}
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}> <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. {t("productDialogs.availabilitySubtitle")}
</Typography> </Typography>
{success && ( {success && (
<Alert severity="success" sx={{ mb: 3 }}> <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. {notificationMethod === 'email' ? t("productDialogs.availabilitySuccessEmail") : t("productDialogs.availabilitySuccessTelegram")}
</Alert> </Alert>
)} )}
@@ -139,18 +140,18 @@ class ArticleAvailabilityForm extends Component {
<Box component="form" onSubmit={this.handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box component="form" onSubmit={this.handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField <TextField
label="Name" label={t("productDialogs.nameLabel")}
value={name} value={name}
onChange={this.handleInputChange('name')} onChange={this.handleInputChange('name')}
required required
fullWidth fullWidth
disabled={loading} disabled={loading}
placeholder="Ihr Name" placeholder={t("productDialogs.namePlaceholder")}
/> />
<FormControl component="fieldset" disabled={loading}> <FormControl component="fieldset" disabled={loading}>
<FormLabel component="legend" sx={{ mb: 1 }}> <FormLabel component="legend" sx={{ mb: 1 }}>
Wie möchten Sie benachrichtigt werden? {t("productDialogs.notificationMethodLabel")}
</FormLabel> </FormLabel>
<RadioGroup <RadioGroup
value={notificationMethod} value={notificationMethod}
@@ -160,51 +161,51 @@ class ArticleAvailabilityForm extends Component {
<FormControlLabel <FormControlLabel
value="email" value="email"
control={<Radio />} control={<Radio />}
label="E-Mail" label={t("productDialogs.emailLabel")}
/> />
<FormControlLabel <FormControlLabel
value="telegram" value="telegram"
control={<Radio />} control={<Radio />}
label="Telegram Bot" label={t("productDialogs.telegramBotLabel")}
/> />
</RadioGroup> </RadioGroup>
</FormControl> </FormControl>
{notificationMethod === 'email' && ( {notificationMethod === 'email' && (
<TextField <TextField
label="E-Mail" label={t("productDialogs.emailLabel")}
type="email" type="email"
value={email} value={email}
onChange={this.handleInputChange('email')} onChange={this.handleInputChange('email')}
required required
fullWidth fullWidth
disabled={loading} disabled={loading}
placeholder="ihre.email@example.com" placeholder={t("productDialogs.emailPlaceholder")}
/> />
)} )}
{notificationMethod === 'telegram' && ( {notificationMethod === 'telegram' && (
<TextField <TextField
label="Telegram ID" label={t("productDialogs.telegramIdLabel")}
value={telegramId} value={telegramId}
onChange={this.handleInputChange('telegramId')} onChange={this.handleInputChange('telegramId')}
required required
fullWidth fullWidth
disabled={loading} disabled={loading}
placeholder="@IhrTelegramName oder Telegram ID" placeholder={t("productDialogs.telegramPlaceholder")}
helperText="Geben Sie Ihren Telegram-Benutzernamen (mit @) oder Ihre Telegram-ID ein" helperText={t("productDialogs.telegramHelper")}
/> />
)} )}
<TextField <TextField
label="Nachricht (optional)" label={t("productDialogs.messageLabel")}
value={message} value={message}
onChange={this.handleInputChange('message')} onChange={this.handleInputChange('message')}
fullWidth fullWidth
multiline multiline
rows={3} rows={3}
disabled={loading} disabled={loading}
placeholder="Zusätzliche Informationen oder Fragen..." placeholder={t("productDialogs.messagePlaceholder")}
/> />
<Button <Button
@@ -225,10 +226,10 @@ class ArticleAvailabilityForm extends Component {
{loading ? ( {loading ? (
<> <>
<CircularProgress size={20} sx={{ mr: 1 }} /> <CircularProgress size={20} sx={{ mr: 1 }} />
Wird gesendet... {t("productDialogs.sending")}
</> </>
) : ( ) : (
'Verfügbarkeit anfragen' t("productDialogs.submitAvailability")
)} )}
</Button> </Button>
</Box> </Box>

View File

@@ -98,7 +98,7 @@ class ArticleQuestionForm extends Component {
} else { } else {
this.setState({ this.setState({
loading: false, loading: false,
error: response.error || 'Ein Fehler ist aufgetreten' error: response.error || this.props.t("productDialogs.errorGeneric")
}); });
} }
@@ -110,7 +110,7 @@ class ArticleQuestionForm extends Component {
} catch { } catch {
this.setState({ this.setState({
loading: false, loading: false,
error: 'Fehler beim Verarbeiten der Fotos' error: this.props.t("productDialogs.errorPhotos")
}); });
} }
@@ -140,20 +140,21 @@ class ArticleQuestionForm extends Component {
render() { render() {
const { name, email, question, loading, success, error } = this.state; const { name, email, question, loading, success, error } = this.state;
const { t } = this.props;
return ( return (
<Paper sx={{ p: 3, mt: 4, borderRadius: 2, boxShadow: "0 2px 8px rgba(0,0,0,0.08)" }}> <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' }}> <Typography variant="h5" gutterBottom sx={{ fontWeight: 600, color: '#333' }}>
Frage zum Artikel {t("productDialogs.questionTitle")}
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Haben Sie eine Frage zu diesem Artikel? Wir helfen Ihnen gerne weiter. {t("productDialogs.questionSubtitle")}
</Typography> </Typography>
{success && ( {success && (
<Alert severity="success" sx={{ mb: 3 }}> <Alert severity="success" sx={{ mb: 3 }}>
Vielen Dank für Ihre Frage! Wir werden uns schnellstmöglich bei Ihnen melden. {t("productDialogs.questionSuccess")}
</Alert> </Alert>
)} )}
@@ -165,28 +166,28 @@ class ArticleQuestionForm extends Component {
<Box component="form" onSubmit={this.handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box component="form" onSubmit={this.handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField <TextField
label="Name" label={t("productDialogs.nameLabel")}
value={name} value={name}
onChange={this.handleInputChange('name')} onChange={this.handleInputChange('name')}
required required
fullWidth fullWidth
disabled={loading} disabled={loading}
placeholder="Ihr Name" placeholder={t("productDialogs.namePlaceholder")}
/> />
<TextField <TextField
label="E-Mail" label={t("productDialogs.emailLabel")}
type="email" type="email"
value={email} value={email}
onChange={this.handleInputChange('email')} onChange={this.handleInputChange('email')}
required required
fullWidth fullWidth
disabled={loading} disabled={loading}
placeholder="ihre.email@example.com" placeholder={t("productDialogs.emailPlaceholder")}
/> />
<TextField <TextField
label="Ihre Frage" label={t("productDialogs.questionLabel")}
value={question} value={question}
onChange={this.handleInputChange('question')} onChange={this.handleInputChange('question')}
required required
@@ -194,7 +195,7 @@ class ArticleQuestionForm extends Component {
multiline multiline
rows={4} rows={4}
disabled={loading} disabled={loading}
placeholder="Beschreiben Sie Ihre Frage zu diesem Artikel..." placeholder={t("productDialogs.questionPlaceholder")}
/> />
<PhotoUpload <PhotoUpload
@@ -202,7 +203,7 @@ class ArticleQuestionForm extends Component {
onChange={this.handlePhotosChange} onChange={this.handlePhotosChange}
disabled={loading} disabled={loading}
maxFiles={3} maxFiles={3}
label="Fotos zur Frage anhängen (optional)" label={t("productDialogs.photosLabelQuestion")}
/> />
<Button <Button
@@ -219,10 +220,10 @@ class ArticleQuestionForm extends Component {
{loading ? ( {loading ? (
<> <>
<CircularProgress size={20} sx={{ mr: 1 }} /> <CircularProgress size={20} sx={{ mr: 1 }} />
Wird gesendet... {t("productDialogs.sending")}
</> </>
) : ( ) : (
'Frage senden' t("productDialogs.submitQuestion")
)} )}
</Button> </Button>
</Box> </Box>

View File

@@ -106,7 +106,7 @@ class ArticleRatingForm extends Component {
} else { } else {
this.setState({ this.setState({
loading: false, loading: false,
error: response.error || 'Ein Fehler ist aufgetreten' error: response.error || this.props.t("productDialogs.errorGeneric")
}); });
} }
@@ -118,7 +118,7 @@ class ArticleRatingForm extends Component {
} catch { } catch {
this.setState({ this.setState({
loading: false, loading: false,
error: 'Fehler beim Verarbeiten der Fotos' error: this.props.t("productDialogs.errorPhotos")
}); });
} }
@@ -149,20 +149,21 @@ class ArticleRatingForm extends Component {
render() { render() {
const { name, email, rating, review, loading, success, error } = this.state; const { name, email, rating, review, loading, success, error } = this.state;
const { t } = this.props;
return ( return (
<Paper sx={{ p: 3, mt: 4, borderRadius: 2, boxShadow: "0 2px 8px rgba(0,0,0,0.08)" }}> <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' }}> <Typography variant="h5" gutterBottom sx={{ fontWeight: 600, color: '#333' }}>
Artikel Bewerten {t("productDialogs.ratingTitle")}
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Teilen Sie Ihre Erfahrungen mit diesem Artikel und helfen Sie anderen Kunden bei der Entscheidung. {t("productDialogs.ratingSubtitle")}
</Typography> </Typography>
{success && ( {success && (
<Alert severity="success" sx={{ mb: 3 }}> <Alert severity="success" sx={{ mb: 3 }}>
Vielen Dank für Ihre Bewertung! Sie wird nach Prüfung veröffentlicht. {t("productDialogs.ratingSuccess")}
</Alert> </Alert>
)} )}
@@ -174,30 +175,30 @@ class ArticleRatingForm extends Component {
<Box component="form" onSubmit={this.handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box component="form" onSubmit={this.handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField <TextField
label="Name" label={t("productDialogs.nameLabel")}
value={name} value={name}
onChange={this.handleInputChange('name')} onChange={this.handleInputChange('name')}
required required
fullWidth fullWidth
disabled={loading} disabled={loading}
placeholder="Ihr Name" placeholder={t("productDialogs.namePlaceholder")}
/> />
<TextField <TextField
label="E-Mail" label={t("productDialogs.emailLabel")}
type="email" type="email"
value={email} value={email}
onChange={this.handleInputChange('email')} onChange={this.handleInputChange('email')}
required required
fullWidth fullWidth
disabled={loading} disabled={loading}
placeholder="ihre.email@example.com" placeholder={t("productDialogs.emailPlaceholder")}
helperText="Ihre E-Mail wird nicht veröffentlicht" helperText={t("productDialogs.emailHelper")}
/> />
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }}> <Typography variant="body2" sx={{ fontWeight: 500 }}>
Bewertung * {t("productDialogs.ratingLabel")}
</Typography> </Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Rating <Rating
@@ -209,20 +210,20 @@ class ArticleRatingForm extends Component {
emptyIcon={<StarIcon style={{ opacity: 0.55 }} fontSize="inherit" />} emptyIcon={<StarIcon style={{ opacity: 0.55 }} fontSize="inherit" />}
/> />
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{rating > 0 ? `${rating} von 5 Sternen` : 'Bitte bewerten'} {rating > 0 ? t("productDialogs.ratingStars", { rating }) : t("productDialogs.pleaseRate")}
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
<TextField <TextField
label="Ihre Bewertung (optional)" label={t("productDialogs.reviewLabel")}
value={review} value={review}
onChange={this.handleInputChange('review')} onChange={this.handleInputChange('review')}
fullWidth fullWidth
multiline multiline
rows={4} rows={4}
disabled={loading} disabled={loading}
placeholder="Beschreiben Sie Ihre Erfahrungen mit diesem Artikel..." placeholder={t("productDialogs.reviewPlaceholder")}
/> />
<PhotoUpload <PhotoUpload
@@ -230,7 +231,7 @@ class ArticleRatingForm extends Component {
onChange={this.handlePhotosChange} onChange={this.handlePhotosChange}
disabled={loading} disabled={loading}
maxFiles={5} maxFiles={5}
label="Fotos zur Bewertung anhängen (optional)" label={t("productDialogs.photosLabelRating")}
/> />
<Button <Button
@@ -247,10 +248,10 @@ class ArticleRatingForm extends Component {
{loading ? ( {loading ? (
<> <>
<CircularProgress size={20} sx={{ mr: 1 }} /> <CircularProgress size={20} sx={{ mr: 1 }} />
Wird gesendet... {t("productDialogs.sending")}
</> </>
) : ( ) : (
'Bewertung abgeben' t("productDialogs.submitRating")
)} )}
</Button> </Button>
</Box> </Box>

View File

@@ -23,7 +23,7 @@ class CartItem extends Component {
window.socketManager.emit('getPic', { bildId:picid, size:'tiny' }, (res) => { window.socketManager.emit('getPic', { bildId:picid, size:'tiny' }, (res) => {
if(res.success){ if(res.success){
window.tinyPicCache[picid] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' })); window.tinyPicCache[picid] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/avif' }));
this.setState({image: window.tinyPicCache[picid], loading: false}); this.setState({image: window.tinyPicCache[picid], loading: false});
} }
}) })
@@ -75,10 +75,24 @@ class CartItem extends Component {
component="div" component="div"
sx={{ fontWeight: 'bold', mb: 0.5 }} sx={{ fontWeight: 'bold', mb: 0.5 }}
> >
<Link to={`/Artikel/${item.seoName}`} style={{ textDecoration: 'none', color: 'inherit' }}> {item.seoName ? (
{item.name} <Link to={`/Artikel/${item.seoName}`} style={{ textDecoration: 'none', color: 'inherit' }}>
</Link> {item.name}
</Link>
) : (
item.name
)}
</Typography> </Typography>
{item.komponenten && Array.isArray(item.komponenten) && (
<Box sx={{ ml: 2, mb: 1 }}>
{item.komponenten.map((comp, index) => (
<Typography key={index} variant="body2" color="text.secondary">
{comp.name}
</Typography>
))}
</Box>
)}
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1, mt: 1 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1, mt: 1 }}>
<Typography <Typography
@@ -146,7 +160,7 @@ class CartItem extends Component {
display: "block" display: "block"
}} }}
> >
{this.props.id.toString().endsWith("steckling") ? {this.props.id?.toString().endsWith("steckling") ?
(this.props.t ? this.props.t('delivery.times.cutting14Days') : "Lieferzeit: 14 Tage") : (this.props.t ? this.props.t('delivery.times.cutting14Days') : "Lieferzeit: 14 Tage") :
item.available == 1 ? item.available == 1 ?
(this.props.t ? this.props.t('delivery.times.standard2to3Days') : "Lieferzeit: 2-3 Tage") : (this.props.t ? this.props.t('delivery.times.standard2to3Days') : "Lieferzeit: 2-3 Tage") :
@@ -162,4 +176,4 @@ class CartItem extends Component {
} }
} }
export default withI18n()(CartItem); export default withI18n()(CartItem);

View File

@@ -47,7 +47,7 @@ const CategoryBox = ({
// Create fresh blob URL from cached binary data // Create fresh blob URL from cached binary data
try { try {
const uint8Array = new Uint8Array(cachedImageData); const uint8Array = new Uint8Array(cachedImageData);
const blob = new Blob([uint8Array], { type: 'image/jpeg' }); const blob = new Blob([uint8Array], { type: 'image/avif' });
objectUrl = URL.createObjectURL(blob); objectUrl = URL.createObjectURL(blob);
setImageUrl(objectUrl); setImageUrl(objectUrl);
setImageError(false); setImageError(false);
@@ -73,7 +73,7 @@ const CategoryBox = ({
try { try {
// Convert binary data to blob URL // Convert binary data to blob URL
const uint8Array = new Uint8Array(imageData); const uint8Array = new Uint8Array(imageData);
const blob = new Blob([uint8Array], { type: 'image/jpeg' }); const blob = new Blob([uint8Array], { type: 'image/avif' });
objectUrl = URL.createObjectURL(blob); objectUrl = URL.createObjectURL(blob);
setImageUrl(objectUrl); setImageUrl(objectUrl);
setImageError(false); setImageError(false);
@@ -158,7 +158,7 @@ const CategoryBox = ({
position: 'relative', position: 'relative',
backgroundImage: ((typeof window !== 'undefined' && window.__PRERENDER_FALLBACK__) || backgroundImage: ((typeof window !== 'undefined' && window.__PRERENDER_FALLBACK__) ||
(typeof global !== 'undefined' && global.window && global.window.__PRERENDER_FALLBACK__)) (typeof global !== 'undefined' && global.window && global.window.__PRERENDER_FALLBACK__))
? `url("/assets/images/cat${id}.jpg")` ? `url("/assets/images/cat${id}.avif")`
: (imageUrl && !imageError ? `url("${imageUrl}")` : 'none'), : (imageUrl && !imageError ? `url("${imageUrl}")` : 'none'),
backgroundSize: 'cover', backgroundSize: 'cover',
backgroundPosition: 'center', backgroundPosition: 'center',

View File

@@ -461,20 +461,20 @@ class ChatAssistant extends Component {
const inputsDisabled = isGuest && !privacyConfirmed; const inputsDisabled = isGuest && !privacyConfirmed;
return ( return (
<Paper <Paper
elevation={4} elevation={4}
sx={{ sx={{
position: 'fixed', position: 'fixed',
bottom: { xs: 16, sm: 80 }, bottom: { xs: 0, sm: 80 },
right: { xs: 16, sm: 16 }, right: { xs: 0, sm: 16 },
left: { xs: 16, sm: 'auto' }, left: { xs: 0, sm: 'auto' },
top: { xs: 16, sm: 'auto' }, top: { xs: 0, sm: 'auto' },
width: { xs: 'calc(100vw - 32px)', sm: 450, md: 600, lg: 750 }, width: { xs: '100vw', sm: 450, md: 600, lg: 750 },
height: { xs: 'calc(100vh - 32px)', sm: 600, md: 650, lg: 700 }, height: { xs: '100vh', sm: 600, md: 650, lg: 700 },
maxWidth: { xs: 'none', sm: 450, md: 600, lg: 750 }, maxWidth: { xs: 'none', sm: 450, md: 600, lg: 750 },
maxHeight: { xs: 'calc(100vh - 72px)', sm: 600, md: 650, lg: 700 }, maxHeight: { xs: '100vh', sm: 600, md: 650, lg: 700 },
bgcolor: 'background.paper', bgcolor: 'background.paper',
borderRadius: 2, borderRadius: { xs: 0, sm: 2 },
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
zIndex: 1300, zIndex: 1300,
@@ -563,11 +563,13 @@ class ChatAssistant extends Component {
)} )}
<div ref={this.messagesEndRef} /> <div ref={this.messagesEndRef} />
</Box> </Box>
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
p: 1, flexDirection: { xs: 'column', sm: 'row' },
borderTop: 1, gap: { xs: 1, sm: 0 },
p: 1,
borderTop: 1,
borderColor: 'divider', borderColor: 'divider',
flexShrink: 0, flexShrink: 0,
}} }}
@@ -579,22 +581,22 @@ class ChatAssistant extends Component {
onChange={this.handleFileChange} onChange={this.handleFileChange}
style={{ display: 'none' }} style={{ display: 'none' }}
/> />
<TextField <TextField
fullWidth fullWidth
variant="outlined" variant="outlined"
size="small" size="small"
autoComplete="off" autoComplete="off"
autoFocus autoFocus
autoCapitalize="off" autoCapitalize="off"
autoCorrect="off" autoCorrect="off"
placeholder={isRecording ? "Aufnahme läuft..." : "Du kannst mich nach Cannabissorten fragen..."} placeholder={isRecording ? "Aufnahme läuft..." : "Du kannst mich nach Cannabissorten fragen..."}
value={inputValue} value={inputValue}
onChange={this.handleInputChange} onChange={this.handleInputChange}
onKeyDown={this.handleKeyDown} onKeyDown={this.handleKeyDown}
disabled={isRecording || inputsDisabled} disabled={isRecording || inputsDisabled}
slotProps={{ slotProps={{
input: { input: {
maxLength: 300, maxLength: 300,
endAdornment: isRecording && ( endAdornment: isRecording && (
<Typography variant="caption" color="primary" sx={{ mr: 1 }}> <Typography variant="caption" color="primary" sx={{ mr: 1 }}>
{this.formatTime(recordingTime)} {this.formatTime(recordingTime)}
@@ -604,45 +606,47 @@ class ChatAssistant extends Component {
}} }}
/> />
{isRecording ? ( <Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<IconButton {isRecording ? (
color="error" <IconButton
onClick={this.stopRecording} color="error"
aria-label="Aufnahme stoppen" onClick={this.stopRecording}
sx={{ ml: 1 }} aria-label="Aufnahme stoppen"
> sx={{ ml: { xs: 0, sm: 1 } }}
<StopIcon /> >
</IconButton> <StopIcon />
) : ( </IconButton>
<IconButton ) : (
<IconButton
color="primary"
onClick={this.startRecording}
aria-label="Sprachaufnahme starten"
sx={{ ml: { xs: 0, sm: 1 } }}
disabled={isTyping || inputsDisabled}
>
<MicIcon />
</IconButton>
)}
<IconButton
color="primary" color="primary"
onClick={this.startRecording} onClick={this.handleImageUpload}
aria-label="Sprachaufnahme starten" aria-label="Bild hochladen"
sx={{ ml: 1 }} sx={{ ml: { xs: 0, sm: 1 } }}
disabled={isTyping || inputsDisabled} disabled={isTyping || isRecording || inputsDisabled}
> >
<MicIcon /> <PhotoCameraIcon />
</IconButton> </IconButton>
)}
<Button
<IconButton variant="contained"
color="primary" sx={{ ml: { xs: 0, sm: 1 } }}
onClick={this.handleImageUpload} onClick={this.handleSendMessage}
aria-label="Bild hochladen" disabled={isTyping || isRecording || inputsDisabled}
sx={{ ml: 1 }} >
disabled={isTyping || isRecording || inputsDisabled} Senden
> </Button>
<PhotoCameraIcon /> </Box>
</IconButton>
<Button
variant="contained"
sx={{ ml: 1 }}
onClick={this.handleSendMessage}
disabled={isTyping || isRecording || inputsDisabled}
>
Senden
</Button>
</Box> </Box>
</Paper> </Paper>
); );

File diff suppressed because it is too large Load Diff

View File

@@ -296,7 +296,7 @@ class Footer extends Component {
> >
<Box <Box
component="img" component="img"
src="/assets/images/gg.png" src="/assets/images/gg.avif"
alt="Google Reviews" alt="Google Reviews"
sx={{ sx={{
height: { xs: 50, md: 60 }, height: { xs: 50, md: 60 },
@@ -326,7 +326,7 @@ class Footer extends Component {
> >
<Box <Box
component="img" component="img"
src="/assets/images/maps.png" src="/assets/images/maps.avif"
alt="Google Maps" alt="Google Maps"
sx={{ sx={{
height: { xs: 40, md: 50 }, height: { xs: 40, md: 50 },
@@ -352,6 +352,9 @@ class Footer extends Component {
<Typography variant="body2" sx={{ fontSize: { xs: '11px', md: '14px' }, lineHeight: 1.5 }}> <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> © {new Date().getFullYear()} <StyledDomainLink href="https://growheads.de" target="_blank" rel="noopener noreferrer">GrowHeads.de</StyledDomainLink>
</Typography> </Typography>
<Typography variant="body2" sx={{ fontSize: { xs: '9px', md: '9px' }, lineHeight: 1.5, mt: 1 }}>
<StyledDomainLink href="https://telegraf.growheads.de" target="_blank" rel="noreferrer">Telegraf - sicherer Chat mit unseren Mitarbeitern</StyledDomainLink>
</Typography>
</Box> </Box>
</Stack> </Stack>
</Box> </Box>

View File

@@ -91,7 +91,7 @@ class Header extends Component {
</Box> </Box>
</Container> </Container>
</Toolbar> </Toolbar>
{(isHomePage || this.props.categoryId || isProfilePage || isAktionenPage || isFilialePage) && <CategoryList categoryId={209} activeCategoryId={this.props.categoryId}/>} {(isHomePage || this.props.categoryId || isProfilePage || isAktionenPage || isFilialePage || this.props.isArtikel) && <CategoryList categoryId={209} activeCategoryId={this.props.categoryId}/>}
</AppBar> </AppBar>
); );
} }
@@ -104,10 +104,11 @@ const HeaderWithContext = (props) => {
const isProfilePage = location.pathname === '/profile'; const isProfilePage = location.pathname === '/profile';
const isAktionenPage = location.pathname === '/aktionen'; const isAktionenPage = location.pathname === '/aktionen';
const isFilialePage = location.pathname === '/filiale'; const isFilialePage = location.pathname === '/filiale';
const isArtikel = location.pathname.startsWith('/Artikel/');
return ( return (
<Header {...props} isHomePage={isHomePage} isProfilePage={isProfilePage} isAktionenPage={isAktionenPage} isFilialePage={isFilialePage} /> <Header {...props} isHomePage={isHomePage} isArtikel={isArtikel} isProfilePage={isProfilePage} isAktionenPage={isAktionenPage} isFilialePage={isFilialePage} />
); );
}; };

View File

@@ -56,7 +56,7 @@ class Images extends Component {
pics.push(window.tinyPicCache[bildId]); pics.push(window.tinyPicCache[bildId]);
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic); this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
}else{ }else{
pics.push(`/assets/images/prod${bildId}.jpg`); pics.push(`/assets/images/prod${bildId}.avif`);
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic); this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
} }
}else{ }else{
@@ -84,7 +84,7 @@ class Images extends Component {
window.socketManager.emit('getPic', { bildId, size }, (res) => { window.socketManager.emit('getPic', { bildId, size }, (res) => {
if(res.success){ if(res.success){
const url = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' })); const url = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/avif' }));
if(size === 'medium') window.mediumPicCache[bildId] = url; if(size === 'medium') window.mediumPicCache[bildId] = url;
if(size === 'small') window.smallPicCache[bildId] = url; if(size === 'small') window.smallPicCache[bildId] = url;
@@ -118,7 +118,7 @@ class Images extends Component {
if (!this.props.pictureList || !this.props.pictureList.trim()) { if (!this.props.pictureList || !this.props.pictureList.trim()) {
return '/assets/images/nopicture.jpg'; return '/assets/images/nopicture.jpg';
} }
return `/assets/images/prod${this.props.pictureList.split(',')[0].trim()}.jpg`; return `/assets/images/prod${this.props.pictureList.split(',')[0].trim()}.avif`;
}; };
return ( return (

View File

@@ -175,12 +175,12 @@ export class LoginComponent extends Component {
const { location, navigate } = this.props; const { location, navigate } = this.props;
if (!email || !password) { if (!email || !password) {
this.setState({ error: 'Bitte füllen Sie alle Felder aus' }); this.setState({ error: this.props.t ? this.props.t('auth.errors.fillAllFields') : 'Bitte füllen Sie alle Felder aus' });
return; return;
} }
if (!this.validateEmail(email)) { if (!this.validateEmail(email)) {
this.setState({ error: 'Bitte geben Sie eine gültige E-Mail-Adresse ein' }); this.setState({ error: this.props.t ? this.props.t('auth.errors.invalidEmail') : 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
return; return;
} }
@@ -238,7 +238,7 @@ export class LoginComponent extends Component {
} else { } else {
this.setState({ this.setState({
loading: false, loading: false,
error: response.message || 'Anmeldung fehlgeschlagen' error: response.message || (this.props.t ? this.props.t('auth.errors.loginFailed') : 'Anmeldung fehlgeschlagen')
}); });
} }
}); });
@@ -248,22 +248,22 @@ export class LoginComponent extends Component {
const { email, password, confirmPassword } = this.state; const { email, password, confirmPassword } = this.state;
if (!email || !password || !confirmPassword) { if (!email || !password || !confirmPassword) {
this.setState({ error: 'Bitte füllen Sie alle Felder aus' }); this.setState({ error: this.props.t ? this.props.t('auth.errors.fillAllFields') : 'Bitte füllen Sie alle Felder aus' });
return; return;
} }
if (!this.validateEmail(email)) { if (!this.validateEmail(email)) {
this.setState({ error: 'Bitte geben Sie eine gültige E-Mail-Adresse ein' }); this.setState({ error: this.props.t ? this.props.t('auth.errors.invalidEmail') : 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
return; return;
} }
if (password !== confirmPassword) { if (password !== confirmPassword) {
this.setState({ error: 'Passwörter stimmen nicht überein' }); this.setState({ error: this.props.t ? this.props.t('auth.errors.passwordsNotMatchShort') : 'Passwörter stimmen nicht überein' });
return; return;
} }
if (password.length < 8) { if (password.length < 8) {
this.setState({ error: 'Das Passwort muss mindestens 8 Zeichen lang sein' }); this.setState({ error: this.props.t ? this.props.t('auth.passwordMinLength') : 'Das Passwort muss mindestens 8 Zeichen lang sein' });
return; return;
} }
@@ -274,14 +274,14 @@ export class LoginComponent extends Component {
if (response.success) { if (response.success) {
this.setState({ this.setState({
loading: false, loading: false,
success: 'Registrierung erfolgreich. Sie können sich jetzt anmelden.', success: this.props.t ? this.props.t('auth.success.registerComplete') : 'Registrierung erfolgreich. Sie können sich jetzt anmelden.',
tabValue: 0 // Switch to login tab tabValue: 0 // Switch to login tab
}); });
} else { } else {
let errorMessage = 'Registrierung fehlgeschlagen'; let errorMessage = this.props.t ? this.props.t('auth.errors.registerFailed') : 'Registrierung fehlgeschlagen';
if (response.cause === 'emailExists') { if (response.cause === 'emailExists') {
errorMessage = 'Ein Benutzer mit dieser E-Mail-Adresse existiert bereits. Bitte verwenden Sie eine andere E-Mail-Adresse oder melden Sie sich an.'; errorMessage = this.props.t ? this.props.t('auth.errors.emailExists') : 'Ein Benutzer mit dieser E-Mail-Adresse existiert bereits. Bitte verwenden Sie eine andere E-Mail-Adresse oder melden Sie sich an.';
} else if (response.message) { } else if (response.message) {
errorMessage = response.message; errorMessage = response.message;
} }
@@ -322,12 +322,12 @@ export class LoginComponent extends Component {
const { email } = this.state; const { email } = this.state;
if (!email) { if (!email) {
this.setState({ error: 'Bitte geben Sie Ihre E-Mail-Adresse ein' }); this.setState({ error: this.props.t ? this.props.t('auth.errors.enterEmail') : 'Bitte geben Sie Ihre E-Mail-Adresse ein' });
return; return;
} }
if (!this.validateEmail(email)) { if (!this.validateEmail(email)) {
this.setState({ error: 'Bitte geben Sie eine gültige E-Mail-Adresse ein' }); this.setState({ error: this.props.t ? this.props.t('auth.errors.invalidEmail') : 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
return; return;
} }
@@ -342,12 +342,12 @@ export class LoginComponent extends Component {
if (response.success) { if (response.success) {
this.setState({ this.setState({
loading: false, loading: false,
success: 'Ein Link zum Zurücksetzen des Passworts wurde an Ihre E-Mail-Adresse gesendet.' success: this.props.t ? this.props.t('auth.resetPassword.emailSent') : 'Ein Link zum Zurücksetzen des Passworts wurde an Ihre E-Mail-Adresse gesendet.'
}); });
} else { } else {
this.setState({ this.setState({
loading: false, loading: false,
error: response.message || 'Fehler beim Senden der E-Mail' error: response.message || (this.props.t ? this.props.t('auth.resetPassword.emailError') : 'Fehler beim Senden der E-Mail')
}); });
} }
}); });
@@ -408,7 +408,7 @@ export class LoginComponent extends Component {
} else { } else {
this.setState({ this.setState({
loading: false, loading: false,
error: 'Google-Anmeldung fehlgeschlagen', error: this.props.t ? this.props.t('auth.errors.googleLoginFailed') : 'Google-Anmeldung fehlgeschlagen',
showGoogleAuth: false // Reset Google auth state on failed login showGoogleAuth: false // Reset Google auth state on failed login
}); });
} }
@@ -418,7 +418,7 @@ export class LoginComponent extends Component {
handleGoogleLoginError = (error) => { handleGoogleLoginError = (error) => {
console.error('Google Login Error:', error); console.error('Google Login Error:', error);
this.setState({ this.setState({
error: 'Google-Anmeldung fehlgeschlagen', error: this.props.t ? this.props.t('auth.errors.googleLoginFailed') : 'Google-Anmeldung fehlgeschlagen',
showGoogleAuth: false, // Reset Google auth state on error showGoogleAuth: false, // Reset Google auth state on error
loading: false loading: false
}); });

View File

@@ -156,15 +156,15 @@ const MainPageLayout = () => {
}; };
const allTitles = { const allTitles = {
home: t('titles.home') , home: t('titles.home'),
aktionen: t('titles.aktionen'), aktionen: t('titles.aktionen'),
filiale: t('titles.filiale') filiale: t('titles.filiale')
}; };
const allContentBoxes = { const allContentBoxes = {
home: [ home: [
{ title: t('sections.seeds'), image: "/assets/images/seeds.jpg", bgcolor: "#e1f0d3", link: "/Kategorie/Seeds" }, { title: t('sections.seeds'), image: "/assets/images/seeds.avif", bgcolor: "#e1f0d3", link: "/Kategorie/Seeds" },
{ title: t('sections.stecklinge'), image: "/assets/images/cutlings.jpg", bgcolor: "#e8f5d6", link: "/Kategorie/Stecklinge" } { title: t('sections.konfigurator'), image: "/assets/images/konfigurator.avif", bgcolor: "#e8f5d6", link: "/Konfigurator" }
], ],
aktionen: [ aktionen: [
{ title: t('sections.oilPress'), image: "/assets/images/presse.jpg", bgcolor: "#e1f0d3", link: "/presseverleih" }, { title: t('sections.oilPress'), image: "/assets/images/presse.jpg", bgcolor: "#e1f0d3", link: "/presseverleih" },
@@ -262,16 +262,16 @@ const MainPageLayout = () => {
position: pageType === "home" ? "relative" : "absolute", top: 0, left: 0, width: "100%", pointerEvents: getOpacity(pageType) === 1 ? "auto" : "none" position: pageType === "home" ? "relative" : "absolute", top: 0, left: 0, width: "100%", pointerEvents: getOpacity(pageType) === 1 ? "auto" : "none"
}}> }}>
{contentBoxes.map((box, index) => ( {contentBoxes.map((box, index) => (
<ContentBox <ContentBox
key={`${pageType}-${index}`} key={`${pageType}-${index}`}
box={box} box={box}
index={index} index={index}
pageType={pageType} pageType={pageType}
starHovered={starHovered} starHovered={starHovered}
setStarHovered={setStarHovered} setStarHovered={setStarHovered}
opacity={getOpacity(pageType)} opacity={getOpacity(pageType)}
translatedContent={translatedContent} translatedContent={translatedContent}
/> />
))} ))}
</Grid> </Grid>
))} ))}

View File

@@ -10,6 +10,7 @@ import {
} from '@mui/material'; } from '@mui/material';
import Delete from '@mui/icons-material/Delete'; import Delete from '@mui/icons-material/Delete';
import CloudUpload from '@mui/icons-material/CloudUpload'; import CloudUpload from '@mui/icons-material/CloudUpload';
import { withI18n } from '../i18n/withTranslation.js';
class PhotoUpload extends Component { class PhotoUpload extends Component {
constructor(props) { constructor(props) {
@@ -30,7 +31,7 @@ class PhotoUpload extends Component {
// Validate file count // Validate file count
if (this.state.files.length + selectedFiles.length > maxFiles) { if (this.state.files.length + selectedFiles.length > maxFiles) {
this.setState({ this.setState({
error: `Maximal ${maxFiles} Dateien erlaubt` error: this.props.t("productDialogs.photoUploadErrorMaxFiles", { max: maxFiles })
}); });
return; return;
} }
@@ -43,14 +44,14 @@ class PhotoUpload extends Component {
for (const file of selectedFiles) { for (const file of selectedFiles) {
if (!validTypes.includes(file.type)) { if (!validTypes.includes(file.type)) {
this.setState({ this.setState({
error: 'Nur Bilddateien (JPEG, PNG, GIF, WebP) sind erlaubt' error: this.props.t("productDialogs.photoUploadErrorFileType")
}); });
continue; continue;
} }
if (file.size > maxSize) { if (file.size > maxSize) {
this.setState({ this.setState({
error: `Datei zu groß. Maximum: ${Math.round(maxSize / (1024 * 1024))}MB` error: this.props.t("productDialogs.photoUploadErrorFileSize", { maxSize: Math.round(maxSize / (1024 * 1024)) })
}); });
continue; continue;
} }
@@ -167,12 +168,12 @@ class PhotoUpload extends Component {
render() { render() {
const { files, previews, error } = this.state; const { files, previews, error } = this.state;
const { disabled, label } = this.props; const { disabled, label, t } = this.props;
return ( return (
<Box> <Box>
<Typography variant="body2" sx={{ mb: 1, fontWeight: 500 }}> <Typography variant="body2" sx={{ mb: 1, fontWeight: 500 }}>
{label || 'Fotos anhängen (optional)'} {label || t("productDialogs.photoUploadLabelDefault")}
</Typography> </Typography>
<input <input
@@ -192,7 +193,7 @@ class PhotoUpload extends Component {
disabled={disabled} disabled={disabled}
sx={{ mb: 2 }} sx={{ mb: 2 }}
> >
Fotos auswählen {t("productDialogs.photoUploadSelect")}
</Button> </Button>
{error && ( {error && (
@@ -228,7 +229,7 @@ class PhotoUpload extends Component {
size="small" size="small"
onClick={() => this.handleRemoveFile(index)} onClick={() => this.handleRemoveFile(index)}
disabled={disabled} disabled={disabled}
aria-label="Bild entfernen" aria-label={t("productDialogs.photoUploadRemove")}
sx={{ sx={{
position: 'absolute', position: 'absolute',
top: 4, top: 4,
@@ -269,10 +270,10 @@ class PhotoUpload extends Component {
{files.length > 0 && ( {files.length > 0 && (
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}> <Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
{files.length} Datei(en) ausgewählt {t("productDialogs.photoUploadSelectedFiles", { count: files.length })}
{previews.length > 0 && previews.some(p => p.originalSize && p.compressedSize) && ( {previews.length > 0 && previews.some(p => p.originalSize && p.compressedSize) && (
<span style={{ marginLeft: '8px' }}> <span style={{ marginLeft: '8px' }}>
(komprimiert für Upload) {t("productDialogs.photoUploadCompressed")}
</span> </span>
)} )}
</Typography> </Typography>
@@ -282,4 +283,4 @@ class PhotoUpload extends Component {
} }
} }
export default PhotoUpload; export default withI18n()(PhotoUpload);

View File

@@ -7,10 +7,67 @@ import Typography from '@mui/material/Typography';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import AddToCartButton from './AddToCartButton.js'; import AddToCartButton from './AddToCartButton.js';
import { Link } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { withI18n } from '../i18n/withTranslation.js'; import { withI18n } from '../i18n/withTranslation.js';
import ZoomInIcon from '@mui/icons-material/ZoomIn'; import ZoomInIcon from '@mui/icons-material/ZoomIn';
// Helper function to find level 1 category ID from any category ID
const findLevel1CategoryId = (categoryId) => {
try {
const currentLanguage = 'de'; // Default to German
const categoryTreeCache = window.categoryService?.getSync(209, currentLanguage);
if (!categoryTreeCache || !categoryTreeCache.children) {
return null;
}
// Helper function to find category by ID and get its level 1 parent
const findCategoryAndLevel1 = (categories, targetId) => {
for (const category of categories) {
if (category.id === targetId) {
// Found the category, now find its level 1 parent
return findLevel1Parent(categoryTreeCache.children, category);
}
if (category.children && category.children.length > 0) {
const result = findCategoryAndLevel1(category.children, targetId);
if (result) return result;
}
}
return null;
};
// Helper function to find the level 1 parent (direct child of root category 209)
const findLevel1Parent = (level1Categories, category) => {
// If this category's parent is 209, it's already level 1
if (category.parentId === 209) {
return category.id;
}
// Otherwise, find the parent and check if it's level 1
for (const level1Category of level1Categories) {
if (level1Category.id === category.parentId) {
return level1Category.id;
}
// If parent has children, search recursively
if (level1Category.children && level1Category.children.length > 0) {
const result = findLevel1Parent(level1Category.children, category);
if (result) return result;
}
}
return null;
};
return findCategoryAndLevel1(categoryTreeCache.children, parseInt(categoryId));
} catch (error) {
console.error('Error finding level 1 category:', error);
return null;
}
};
class Product extends Component { class Product extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
@@ -44,7 +101,7 @@ class Product extends Component {
console.log('loadImagevisSocket', bildId); console.log('loadImagevisSocket', bildId);
window.socketManager.emit('getPic', { bildId, size:'small' }, (res) => { window.socketManager.emit('getPic', { bildId, size:'small' }, (res) => {
if(res.success){ if(res.success){
window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' })); window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/avif' }));
if (this._isMounted) { if (this._isMounted) {
this.setState({image: window.smallPicCache[bildId], loading: false}); this.setState({image: window.smallPicCache[bildId], loading: false});
} else { } else {
@@ -73,8 +130,25 @@ class Product extends Component {
// In a real app, this would update a cart state in a parent component or Redux store // In a real app, this would update a cart state in a parent component or Redux store
} }
handleProductClick = (e) => {
e.preventDefault();
const { categoryId } = this.props;
// Find the level 1 category for this product
const level1CategoryId = categoryId ? findLevel1CategoryId(categoryId) : null;
// Navigate to the product page WITH the category information in the state
const navigate = this.props.navigate;
if (navigate) {
navigate(`/Artikel/${this.props.seoName}`, {
state: { articleCategoryId: level1CategoryId }
});
}
}
render() { render() {
const { const {
id, name, price, available, manufacturer, seoName, id, name, price, available, manufacturer, seoName,
currency, vat, cGrundEinheit, fGrundPreis, thc, currency, vat, cGrundEinheit, fGrundPreis, thc,
floweringWeeks,incoming, neu, weight, versandklasse, availableSupplier, komponenten floweringWeeks,incoming, neu, weight, versandklasse, availableSupplier, komponenten
@@ -253,15 +327,15 @@ class Product extends Component {
)} )}
<Box <Box
component={Link} onClick={this.handleProductClick}
to={`/Artikel/${seoName}`} sx={{
sx={{ flexGrow: 1,
flexGrow: 1, display: 'flex',
display: 'flex', flexDirection: 'column',
flexDirection: 'column',
alignItems: 'stretch', alignItems: 'stretch',
textDecoration: 'none', textDecoration: 'none',
color: 'inherit' color: 'inherit',
cursor: 'pointer'
}} }}
> >
<Box sx={{ <Box sx={{
@@ -353,21 +427,50 @@ class Product extends Component {
</Typography> </Typography>
</Box> </Box>
<div style={{padding:'0px',margin:'0px',minHeight:'3.8em'}}> <div style={{padding:'0px',margin:'0px'}}>
<Typography <Typography
variant="h6" variant="h6"
color="primary" color="primary"
sx={{ mt: 'auto', fontWeight: 'bold', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }} sx={{
> fontWeight: 'bold',
<span>{new Intl.NumberFormat('de-DE', {style: 'currency', currency: currency || 'EUR'}).format(price)}</span> display: 'flex',
<small style={{ color: '#77aa77', fontSize: '0.6em' }}>({this.props.t ? this.props.t('product.inclVatFooter', { vat }) : `incl. ${vat}% USt.,*`})</small> justifyContent: 'space-between',
alignItems: 'center',
position: 'relative'
}}
</Typography> >
{cGrundEinheit && fGrundPreis && fGrundPreis != price && (<Typography variant="body2" color="text.secondary" sx={{ m: 0,p: 0 }}> <Box sx={{ position: 'relative', display: 'inline-block' }}>
({new Intl.NumberFormat('de-DE', {style: 'currency', currency: currency || 'EUR'}).format(fGrundPreis)}/{cGrundEinheit}) {this.props.rebate && this.props.rebate > 0 && (
</Typography> )} <span
style={{
position: 'absolute',
top: -8,
left: -8,
fontWeight: 'bold',
color: 'red',
textDecoration: 'line-through',
opacity: 0.4,
zIndex: 1,
pointerEvents: 'none',
fontSize: 'inherit'
}}
>
{(() => {
const rebatePct = this.props.rebate / 100;
const originalPrice = Math.round((price / (1 - rebatePct)) * 10) / 10;
return new Intl.NumberFormat('de-DE', {style: 'currency', currency: currency || 'EUR'}).format(originalPrice);
})()}
</span>
)}
<span style={{ position: 'relative', zIndex: 2 }}>{new Intl.NumberFormat('de-DE', {style: 'currency', currency: currency || 'EUR'}).format(price)}</span>
</Box>
<small style={{ color: '#77aa77', fontSize: '0.6em' }}>({this.props.t ? this.props.t('product.inclVatFooter', { vat }) : `incl. ${vat}% USt.,*`})</small>
</Typography>
</div>
<div style={{ minHeight: '1.5em' }}>
{cGrundEinheit && fGrundPreis && fGrundPreis != price && (<Typography variant="body2" color="text.secondary" sx={{ m: 0,p: 0 }}>
({new Intl.NumberFormat('de-DE', {style: 'currency', currency: currency || 'EUR'}).format(fGrundPreis)}/{cGrundEinheit})
</Typography> )}
</div> </div>
{/*incoming*/} {/*incoming*/}
</CardContent> </CardContent>
@@ -391,4 +494,10 @@ class Product extends Component {
} }
} }
export default withI18n()(Product); // Wrapper component to provide navigate hook
const ProductWithNavigation = (props) => {
const navigate = useNavigate();
return <Product {...props} navigate={navigate} />;
};
export default withI18n()(ProductWithNavigation);

View File

@@ -0,0 +1,461 @@
import React from 'react';
import { Link } from "react-router-dom";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import ChevronLeft from "@mui/icons-material/ChevronLeft";
import ChevronRight from "@mui/icons-material/ChevronRight";
import Product from "./Product.js";
import { withTranslation } from 'react-i18next';
import { withLanguage } from '../i18n/withTranslation.js';
const ITEM_WIDTH = 250 + 16; // 250px width + 16px gap
const AUTO_SCROLL_SPEED = 0.5; // px per frame (~60fps, so ~30px/sec)
const AUTOSCROLL_RESTART_DELAY = 5000; // 5 seconds of inactivity before restarting autoscroll
const SCROLLBAR_FLASH_DURATION = 3000; // 3 seconds to show the virtual scrollbar
class ProductCarousel extends React.Component {
_isMounted = false;
products = [];
originalProducts = [];
animationFrame = null;
autoScrollActive = true;
translateX = 0;
inactivityTimer = null;
scrollbarTimer = null;
constructor(props) {
super(props);
const { i18n } = props;
this.state = {
products: [],
currentLanguage: (i18n && i18n.language) || 'de',
showScrollbar: false,
};
this.carouselTrackRef = React.createRef();
}
componentDidMount() {
this._isMounted = true;
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n.language;
console.log("ProductCarousel componentDidMount: Loading products for categoryId", this.props.categoryId, "language", currentLanguage);
this.loadProducts(currentLanguage);
}
componentDidUpdate(prevProps) {
console.log("ProductCarousel componentDidUpdate", prevProps.languageContext?.currentLanguage, this.props.languageContext?.currentLanguage);
if (prevProps.languageContext?.currentLanguage !== this.props.languageContext?.currentLanguage) {
this.setState({ products: [] }, () => {
this.loadProducts(this.props.languageContext?.currentLanguage || this.props.i18n.language);
});
}
}
loadProducts = (language) => {
const { categoryId } = this.props;
window.socketManager.emit(
"getCategoryProducts",
{
categoryId: categoryId === "neu" ? "neu" : categoryId,
language: language,
requestTranslation: language === 'de' ? false : true
},
(response) => {
console.log("ProductCarousel getCategoryProducts response:", response);
if (this._isMounted && response && response.products && response.products.length > 0) {
// Filter products to only show those with pictures
const productsWithPictures = response.products.filter(product =>
product.pictureList && product.pictureList.length > 0
);
console.log("ProductCarousel: Filtered", productsWithPictures.length, "products with pictures from", response.products.length, "total");
if (productsWithPictures.length > 0) {
// Take random 15 products and shuffle them
const shuffledProducts = this.shuffleArray(productsWithPictures.slice(0, 15));
console.log("ProductCarousel: Selected and shuffled", shuffledProducts.length, "products");
this.originalProducts = shuffledProducts;
// Duplicate for seamless looping
this.products = [...shuffledProducts, ...shuffledProducts];
this.setState({ products: this.products });
this.startAutoScroll();
}
}
}
);
}
componentWillUnmount() {
this._isMounted = false;
this.stopAutoScroll();
this.clearInactivityTimer();
this.clearScrollbarTimer();
}
startAutoScroll = () => {
this.autoScrollActive = true;
if (!this.animationFrame) {
this.animationFrame = requestAnimationFrame(this.handleAutoScroll);
}
};
stopAutoScroll = () => {
this.autoScrollActive = false;
if (this.animationFrame) {
cancelAnimationFrame(this.animationFrame);
this.animationFrame = null;
}
};
clearInactivityTimer = () => {
if (this.inactivityTimer) {
clearTimeout(this.inactivityTimer);
this.inactivityTimer = null;
}
};
clearScrollbarTimer = () => {
if (this.scrollbarTimer) {
clearTimeout(this.scrollbarTimer);
this.scrollbarTimer = null;
}
};
startInactivityTimer = () => {
this.clearInactivityTimer();
this.inactivityTimer = setTimeout(() => {
if (this._isMounted) {
this.startAutoScroll();
}
}, AUTOSCROLL_RESTART_DELAY);
};
showScrollbarFlash = () => {
this.clearScrollbarTimer();
this.setState({ showScrollbar: true });
this.scrollbarTimer = setTimeout(() => {
if (this._isMounted) {
this.setState({ showScrollbar: false });
}
}, SCROLLBAR_FLASH_DURATION);
};
handleAutoScroll = () => {
if (!this.autoScrollActive || this.originalProducts.length === 0) return;
this.translateX -= AUTO_SCROLL_SPEED;
this.updateTrackTransform();
const originalItemCount = this.originalProducts.length;
const maxScroll = ITEM_WIDTH * originalItemCount;
// Check if we've scrolled past the first set of items
if (Math.abs(this.translateX) >= maxScroll) {
// Reset to beginning seamlessly
this.translateX = 0;
this.updateTrackTransform();
}
this.animationFrame = requestAnimationFrame(this.handleAutoScroll);
};
updateTrackTransform = () => {
if (this.carouselTrackRef.current) {
this.carouselTrackRef.current.style.transform = `translateX(${this.translateX}px)`;
}
};
handleLeftClick = () => {
this.stopAutoScroll();
this.scrollBy(1);
this.showScrollbarFlash();
this.startInactivityTimer();
};
handleRightClick = () => {
this.stopAutoScroll();
this.scrollBy(-1);
this.showScrollbarFlash();
this.startInactivityTimer();
};
scrollBy = (direction) => {
if (this.originalProducts.length === 0) return;
// direction: 1 = left (scroll content right), -1 = right (scroll content left)
const originalItemCount = this.originalProducts.length;
const maxScroll = ITEM_WIDTH * originalItemCount;
this.translateX += direction * ITEM_WIDTH;
// Handle wrap-around when scrolling left (positive translateX)
if (this.translateX > 0) {
this.translateX = -(maxScroll - ITEM_WIDTH);
}
// Handle wrap-around when scrolling right (negative translateX beyond limit)
else if (Math.abs(this.translateX) >= maxScroll) {
this.translateX = 0;
}
this.updateTrackTransform();
// Force scrollbar to update immediately after wrap-around
if (this.state.showScrollbar) {
this.forceUpdate();
}
};
renderVirtualScrollbar = () => {
if (!this.state.showScrollbar || this.originalProducts.length === 0) {
return null;
}
const originalItemCount = this.originalProducts.length;
const viewportWidth = 1080; // carousel container max-width
const itemsInView = Math.floor(viewportWidth / ITEM_WIDTH);
// Calculate which item is currently at the left edge (first visible)
let currentItemIndex;
if (this.translateX === 0) {
currentItemIndex = 0;
} else if (this.translateX > 0) {
const maxScroll = ITEM_WIDTH * originalItemCount;
const effectivePosition = maxScroll + this.translateX;
currentItemIndex = Math.floor(effectivePosition / ITEM_WIDTH);
} else {
currentItemIndex = Math.floor(Math.abs(this.translateX) / ITEM_WIDTH);
}
// Ensure we stay within bounds
currentItemIndex = Math.max(0, Math.min(currentItemIndex, originalItemCount - 1));
// Calculate scrollbar position
const lastPossibleFirstItem = Math.max(0, originalItemCount - itemsInView);
const thumbPosition = lastPossibleFirstItem > 0 ? Math.min((currentItemIndex / lastPossibleFirstItem) * 100, 100) : 0;
return (
<div
className="virtual-scrollbar"
style={{
position: 'absolute',
bottom: '5px',
left: '50%',
transform: 'translateX(-50%)',
width: '200px',
height: '4px',
backgroundColor: 'rgba(0, 0, 0, 0.1)',
borderRadius: '2px',
zIndex: 1000,
opacity: this.state.showScrollbar ? 1 : 0,
transition: 'opacity 0.3s ease-in-out'
}}
>
<div
className="scrollbar-thumb"
style={{
position: 'absolute',
top: '0',
left: `${thumbPosition}%`,
width: '20px',
height: '4px',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
borderRadius: '2px',
transform: 'translateX(-50%)',
transition: 'left 0.2s ease-out'
}}
/>
</div>
);
};
render() {
const { t, title } = this.props;
const { products } = this.state;
if (!products || products.length === 0) {
return null;
}
return (
<Box sx={{ mt: 3 }}>
<Box
component={Link}
to="/Kategorie/neu"
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
textDecoration: "none",
color: "primary.main",
mb: 2,
transition: "all 0.3s ease",
"&:hover": {
transform: "translateX(5px)",
color: "primary.dark"
}
}}
>
<Typography
variant="h4"
component="span"
sx={{
fontFamily: "SwashingtonCP",
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)"
}}
>
{title || t('product.new')}
</Typography>
<ChevronRight sx={{ fontSize: "2.5rem", ml: 1 }} />
</Box>
<div
className="product-carousel-wrapper"
style={{
position: 'relative',
overflowX: 'hidden',
overflowY: 'visible',
width: '100%',
maxWidth: '1200px',
margin: '0 auto',
padding: '0 20px',
boxSizing: 'border-box'
}}
>
{/* Left Arrow */}
<IconButton
aria-label="Vorherige Produkte anzeigen"
onClick={this.handleLeftClick}
style={{
position: 'absolute',
top: '50%',
left: '8px',
transform: 'translateY(-50%)',
zIndex: 1200,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
width: '48px',
height: '48px',
borderRadius: '50%'
}}
>
<ChevronLeft />
</IconButton>
{/* Right Arrow */}
<IconButton
aria-label="Nächste Produkte anzeigen"
onClick={this.handleRightClick}
style={{
position: 'absolute',
top: '50%',
right: '8px',
transform: 'translateY(-50%)',
zIndex: 1200,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
width: '48px',
height: '48px',
borderRadius: '50%'
}}
>
<ChevronRight />
</IconButton>
<div
className="product-carousel-container"
style={{
position: 'relative',
overflowX: 'hidden',
overflowY: 'visible',
padding: '20px 0',
width: '100%',
maxWidth: '1080px',
margin: '0 auto',
zIndex: 1,
boxSizing: 'border-box'
}}
>
<div
className="product-carousel-track"
ref={this.carouselTrackRef}
style={{
display: 'flex',
gap: '16px',
transition: 'none',
alignItems: 'flex-start',
width: 'fit-content',
overflow: 'visible',
position: 'relative',
transform: 'translateX(0px)',
margin: '0 auto'
}}
>
{products.map((product, index) => (
<div
key={`${product.id}-${index}`}
className="product-carousel-item"
style={{
flex: '0 0 250px',
width: '250px',
maxWidth: '250px',
minWidth: '250px',
boxSizing: 'border-box',
position: 'relative'
}}
>
<Product
id={product.id}
name={product.name}
seoName={product.seoName}
price={product.price}
currency={product.currency}
available={product.available}
manufacturer={product.manufacturer}
vat={product.vat}
cGrundEinheit={product.cGrundEinheit}
fGrundPreis={product.fGrundPreis}
incoming={product.incomingDate}
neu={product.neu}
thc={product.thc}
floweringWeeks={product.floweringWeeks}
versandklasse={product.versandklasse}
weight={product.weight}
pictureList={product.pictureList}
availableSupplier={product.availableSupplier}
komponenten={product.komponenten}
rebate={product.rebate}
categoryId={product.kategorien ? product.kategorien.split(',')[0] : undefined}
priority={index < 6 ? 'high' : 'auto'}
t={t}
/>
</div>
))}
</div>
{/* Virtual Scrollbar */}
{this.renderVirtualScrollbar()}
</div>
</div>
</Box>
);
}
// Shuffle array using Fisher-Yates algorithm
shuffleArray(array) {
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}
}
export default withTranslation()(withLanguage(ProductCarousel));

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,22 @@
import React from 'react'; import React from 'react';
import { useParams, useNavigate, useLocation } from 'react-router-dom'; import { useParams, useNavigate, useLocation } from 'react-router-dom';
import ProductDetailPage from './ProductDetailPage.js'; import ProductDetailPage from './ProductDetailPage.js';
import { useProduct } from '../context/ProductContext.js';
const ProductDetailWithSocket = () => { const ProductDetailWithSocket = () => {
const { seoName } = useParams(); const { seoName } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { setCurrentProduct } = useProduct();
return ( return (
<ProductDetailPage seoName={seoName} navigate={navigate} location={location} /> <ProductDetailPage
seoName={seoName}
navigate={navigate}
location={location}
setCurrentProduct={setCurrentProduct}
/>
); );
}; };
export default ProductDetailWithSocket; export default ProductDetailWithSocket;

View File

@@ -47,6 +47,41 @@ class ProductFilters extends Component {
window.addEventListener('resize', this.adjustPaperHeight); window.addEventListener('resize', this.adjustPaperHeight);
} }
componentDidUpdate(prevProps) {
// Regenerate values when products, attributes, or language changes
const productsChanged = this.props.products !== prevProps.products;
const attributesChanged = this.props.attributes !== prevProps.attributes;
const languageChanged = this.props.i18n && prevProps.i18n && this.props.i18n.language !== prevProps.i18n.language;
const tFunctionChanged = this.props.t !== prevProps.t;
if(languageChanged) {
console.log('ProductFilters: Language changed, will update when new data arrives');
}
if(productsChanged || languageChanged || tFunctionChanged) {
console.log('ProductFilters: Updating manufacturers and availability', {
productsChanged,
languageChanged,
tFunctionChanged,
productCount: this.props.products?.length
});
const uniqueManufacturerArray = this._getUniqueManufacturers(this.props.products);
const availabilityValues = this._getAvailabilityValues(this.props.products);
this.setState({uniqueManufacturerArray, availabilityValues});
}
if(attributesChanged || (languageChanged && this.props.attributes)) {
console.log('ProductFilters: Updating attributes', {
attributesChanged,
languageChanged,
attributeCount: this.props.attributes?.length,
firstAttribute: this.props.attributes?.[0]
});
const attributeGroups = this._getAttributeGroups(this.props.attributes);
this.setState({attributeGroups});
}
}
componentWillUnmount() { componentWillUnmount() {
// Remove event listener when component unmounts // Remove event listener when component unmounts
window.removeEventListener('resize', this.adjustPaperHeight); window.removeEventListener('resize', this.adjustPaperHeight);
@@ -116,19 +151,6 @@ class ProductFilters extends Component {
return attributeGroups; return attributeGroups;
} }
shouldComponentUpdate(nextProps) {
if(nextProps.products !== this.props.products) {
const uniqueManufacturerArray = this._getUniqueManufacturers(nextProps.products);
const availabilityValues = this._getAvailabilityValues(nextProps.products);
this.setState({uniqueManufacturerArray, availabilityValues});
}
if(nextProps.attributes !== this.props.attributes) {
const attributeGroups = this._getAttributeGroups(nextProps.attributes);
this.setState({attributeGroups});
}
return true;
}
generateAttributeFilters = () => { generateAttributeFilters = () => {
const filters = []; const filters = [];
const sortedAttributeGroups = Object.values(this.state.attributeGroups) const sortedAttributeGroups = Object.values(this.state.attributeGroups)
@@ -187,7 +209,7 @@ class ProductFilters extends Component {
color: 'primary.main' color: 'primary.main'
}} }}
> >
{this.props.dataParam} {this.props.categoryName}
</Typography> </Typography>
)} )}

View File

@@ -430,7 +430,7 @@ class ProductList extends Component {
<Stack direction="row" spacing={2} sx={{ display: { xs: 'none', sm: 'flex' }, px: { xs: 1, sm: 0 } }}> <Stack direction="row" spacing={2} sx={{ display: { xs: 'none', sm: 'flex' }, px: { xs: 1, sm: 0 } }}>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{/*this.props.dataType == 'category' && (<>Kategorie: {this.props.dataParam}</>)}*/} {/*this.props.dataType == 'category' && (<>Kategorie: {this.props.dataParam}</>)}*/}
{this.props.dataType == 'search' && (<>Suchergebnisse für: "{this.props.dataParam}"</>)} {this.props.dataType == 'search' && (this.props.t ? this.props.t('search.searchResultsFor', { query: this.props.dataParam }) : `Suchergebnisse für: "${this.props.dataParam}"`)}
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}> <Typography variant="body2" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>
{this.getProductCountText()} {this.getProductCountText()}
@@ -454,7 +454,7 @@ class ProductList extends Component {
} }
}} }}
> >
<Product <Product
id={product.id} id={product.id}
name={product.name} name={product.name}
seoName={product.seoName} seoName={product.seoName}
@@ -474,6 +474,8 @@ class ProductList extends Component {
pictureList={product.pictureList} pictureList={product.pictureList}
availableSupplier={product.availableSupplier} availableSupplier={product.availableSupplier}
komponenten={product.komponenten} komponenten={product.komponenten}
rebate={product.rebate}
categoryId={product.kategorien ? product.kategorien.split(',')[0] : undefined}
priority={index < 6 ? 'high' : 'auto'} priority={index < 6 ? 'high' : 'auto'}
t={this.props.t} t={this.props.t}
/> />

View File

@@ -1,10 +1,12 @@
import React from 'react'; import React from 'react';
import { Link } from 'react-router-dom';
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import ChevronLeft from "@mui/icons-material/ChevronLeft"; import ChevronLeft from "@mui/icons-material/ChevronLeft";
import ChevronRight from "@mui/icons-material/ChevronRight"; import ChevronRight from "@mui/icons-material/ChevronRight";
import CategoryBox from "./CategoryBox.js"; import CategoryBox from "./CategoryBox.js";
import ProductCarousel from "./ProductCarousel.js";
import { withTranslation } from 'react-i18next'; import { withTranslation } from 'react-i18next';
import { withLanguage } from '../i18n/withTranslation.js'; import { withLanguage } from '../i18n/withTranslation.js';
@@ -26,7 +28,7 @@ class SharedCarousel extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
const { i18n } = props; const { i18n } = props;
// Don't load categories in constructor - will be loaded in componentDidMount with correct language // Don't load categories in constructor - will be loaded in componentDidMount with correct language
this.state = { this.state = {
categories: [], categories: [],
@@ -40,7 +42,7 @@ class SharedCarousel extends React.Component {
componentDidMount() { componentDidMount() {
this._isMounted = true; this._isMounted = true;
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n.language; const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n.language;
// ALWAYS reload categories to ensure correct language // ALWAYS reload categories to ensure correct language
console.log("SharedCarousel componentDidMount: ALWAYS RELOADING categories for language", currentLanguage); console.log("SharedCarousel componentDidMount: ALWAYS RELOADING categories for language", currentLanguage);
window.categoryService.get(209, currentLanguage).then((response) => { window.categoryService.get(209, currentLanguage).then((response) => {
@@ -59,12 +61,12 @@ class SharedCarousel extends React.Component {
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
console.log("componentDidUpdate", prevProps.languageContext?.currentLanguage, this.props.languageContext?.currentLanguage); console.log("componentDidUpdate", prevProps.languageContext?.currentLanguage, this.props.languageContext?.currentLanguage);
if(prevProps.languageContext?.currentLanguage !== this.props.languageContext?.currentLanguage) { if (prevProps.languageContext?.currentLanguage !== this.props.languageContext?.currentLanguage) {
this.setState({ categories: [] },() => { this.setState({ categories: [] }, () => {
window.categoryService.get(209,this.props.languageContext?.currentLanguage || this.props.i18n.language).then((response) => { window.categoryService.get(209, this.props.languageContext?.currentLanguage || this.props.i18n.language).then((response) => {
console.log("response", response); console.log("response", response);
if (response.children && response.children.length > 0) { if (response.children && response.children.length > 0) {
this.originalCategories = response.children; this.originalCategories = response.children;
this.categories = [...response.children, ...response.children]; this.categories = [...response.children, ...response.children];
this.setState({ categories: this.categories }); this.setState({ categories: this.categories });
this.startAutoScroll(); this.startAutoScroll();
@@ -122,7 +124,7 @@ class SharedCarousel extends React.Component {
showScrollbarFlash = () => { showScrollbarFlash = () => {
this.clearScrollbarTimer(); this.clearScrollbarTimer();
this.setState({ showScrollbar: true }); this.setState({ showScrollbar: true });
this.scrollbarTimer = setTimeout(() => { this.scrollbarTimer = setTimeout(() => {
if (this._isMounted) { if (this._isMounted) {
this.setState({ showScrollbar: false }); this.setState({ showScrollbar: false });
@@ -132,7 +134,7 @@ class SharedCarousel extends React.Component {
handleAutoScroll = () => { handleAutoScroll = () => {
if (!this.autoScrollActive || this.originalCategories.length === 0) return; if (!this.autoScrollActive || this.originalCategories.length === 0) return;
this.translateX -= AUTO_SCROLL_SPEED; this.translateX -= AUTO_SCROLL_SPEED;
this.updateTrackTransform(); this.updateTrackTransform();
@@ -171,7 +173,7 @@ class SharedCarousel extends React.Component {
scrollBy = (direction) => { scrollBy = (direction) => {
if (this.originalCategories.length === 0) return; if (this.originalCategories.length === 0) return;
// direction: 1 = left (scroll content right), -1 = right (scroll content left) // direction: 1 = left (scroll content right), -1 = right (scroll content left)
const originalItemCount = this.originalCategories.length; const originalItemCount = this.originalCategories.length;
const maxScroll = ITEM_WIDTH * originalItemCount; const maxScroll = ITEM_WIDTH * originalItemCount;
@@ -188,7 +190,7 @@ class SharedCarousel extends React.Component {
} }
this.updateTrackTransform(); this.updateTrackTransform();
// Force scrollbar to update immediately after wrap-around // Force scrollbar to update immediately after wrap-around
if (this.state.showScrollbar) { if (this.state.showScrollbar) {
this.forceUpdate(); this.forceUpdate();
@@ -203,11 +205,11 @@ class SharedCarousel extends React.Component {
const originalItemCount = this.originalCategories.length; const originalItemCount = this.originalCategories.length;
const viewportWidth = 1080; // carousel container max-width const viewportWidth = 1080; // carousel container max-width
const itemsInView = Math.floor(viewportWidth / ITEM_WIDTH); const itemsInView = Math.floor(viewportWidth / ITEM_WIDTH);
// Calculate which item is currently at the left edge (first visible) // Calculate which item is currently at the left edge (first visible)
// Map translateX directly to item index using the same logic as scrollBy // Map translateX directly to item index using the same logic as scrollBy
let currentItemIndex; let currentItemIndex;
if (this.translateX === 0) { if (this.translateX === 0) {
// At the beginning - item 0 is visible // At the beginning - item 0 is visible
currentItemIndex = 0; currentItemIndex = 0;
@@ -220,10 +222,10 @@ class SharedCarousel extends React.Component {
// Normal negative scrolling - calculate which item is at left edge // Normal negative scrolling - calculate which item is at left edge
currentItemIndex = Math.floor(Math.abs(this.translateX) / ITEM_WIDTH); currentItemIndex = Math.floor(Math.abs(this.translateX) / ITEM_WIDTH);
} }
// Ensure we stay within bounds // Ensure we stay within bounds
currentItemIndex = Math.max(0, Math.min(currentItemIndex, originalItemCount - 1)); currentItemIndex = Math.max(0, Math.min(currentItemIndex, originalItemCount - 1));
// Calculate scrollbar position: 0% when item 0 is first visible, 100% when last item is first visible // Calculate scrollbar position: 0% when item 0 is first visible, 100% when last item is first visible
const lastPossibleFirstItem = Math.max(0, originalItemCount - itemsInView); const lastPossibleFirstItem = Math.max(0, originalItemCount - itemsInView);
const thumbPosition = lastPossibleFirstItem > 0 ? Math.min((currentItemIndex / lastPossibleFirstItem) * 100, 100) : 0; const thumbPosition = lastPossibleFirstItem > 0 ? Math.min((currentItemIndex / lastPossibleFirstItem) * 100, 100) : 0;
@@ -267,25 +269,41 @@ class SharedCarousel extends React.Component {
const { t } = this.props; const { t } = this.props;
const { categories } = this.state; const { categories } = this.state;
if(!categories || categories.length === 0) { if (!categories || categories.length === 0) {
return null; return null;
} }
return ( return (
<Box sx={{ mt: 3 }}> <Box sx={{ mt: 3 }}>
<Typography <Box
variant="h4" component={Link}
component="h1" to="/Kategorien"
sx={{ sx={{
mb: 2, display: "flex",
fontFamily: "SwashingtonCP", alignItems: "center",
justifyContent: "center",
textDecoration: "none",
color: "primary.main", color: "primary.main",
textAlign: "center", mb: 2,
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)" transition: "all 0.3s ease",
"&:hover": {
transform: "translateX(5px)",
color: "primary.dark"
}
}} }}
> >
{t('navigation.categories')} <Typography
</Typography> variant="h4"
component="span"
sx={{
fontFamily: "SwashingtonCP",
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)"
}}
>
{t('navigation.categories')}
</Typography>
<ChevronRight sx={{ fontSize: "2.5rem", ml: 1 }} />
</Box>
<div <div
className="carousel-wrapper" className="carousel-wrapper"
@@ -393,11 +411,14 @@ class SharedCarousel extends React.Component {
</div> </div>
))} ))}
</div> </div>
{/* Virtual Scrollbar */} {/* Virtual Scrollbar */}
{this.renderVirtualScrollbar()} {this.renderVirtualScrollbar()}
</div> </div>
</div> </div>
{/* Product Carousel for "neu" category */}
<ProductCarousel categoryId="neu" />
</Box> </Box>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,8 @@ import Typography from "@mui/material/Typography";
import Collapse from "@mui/material/Collapse"; import Collapse from "@mui/material/Collapse";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import HomeIcon from "@mui/icons-material/Home"; import HomeIcon from "@mui/icons-material/Home";
import FiberNewIcon from '@mui/icons-material/FiberNew';
import SettingsIcon from "@mui/icons-material/Settings";
import MenuIcon from "@mui/icons-material/Menu"; import MenuIcon from "@mui/icons-material/Menu";
import CloseIcon from "@mui/icons-material/Close"; import CloseIcon from "@mui/icons-material/Close";
import { withI18n } from "../../i18n/withTranslation.js"; import { withI18n } from "../../i18n/withTranslation.js";
@@ -21,6 +23,7 @@ class CategoryList extends Component {
mobileMenuOpen: false, mobileMenuOpen: false,
activeCategoryId: null // Will be set properly after categories are loaded activeCategoryId: null // Will be set properly after categories are loaded
}; };
this.productCategoryCheckInterval = null;
} }
componentDidMount() { componentDidMount() {
@@ -29,9 +32,9 @@ class CategoryList extends Component {
console.log(" i18n.language:", this.props.i18n?.language); console.log(" i18n.language:", this.props.i18n?.language);
console.log(" sessionStorage i18nextLng:", typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('i18nextLng') : 'N/A'); console.log(" sessionStorage i18nextLng:", typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('i18nextLng') : 'N/A');
console.log(" localStorage i18nextLng:", typeof localStorage !== 'undefined' ? localStorage.getItem('i18nextLng') : 'N/A'); console.log(" localStorage i18nextLng:", typeof localStorage !== 'undefined' ? localStorage.getItem('i18nextLng') : 'N/A');
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n.language; const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n.language;
// ALWAYS reload categories to ensure correct language // ALWAYS reload categories to ensure correct language
console.log("CategoryList componentDidMount: ALWAYS RELOADING categories for language", currentLanguage); console.log("CategoryList componentDidMount: ALWAYS RELOADING categories for language", currentLanguage);
this.setState({ categories: [] }); // Clear any cached categories this.setState({ categories: [] }); // Clear any cached categories
@@ -40,7 +43,7 @@ class CategoryList extends Component {
if (response.children && response.children.length > 0) { if (response.children && response.children.length > 0) {
console.log("Setting categories with", response.children.length, "items"); console.log("Setting categories with", response.children.length, "items");
console.log("First category name:", response.children[0]?.name); console.log("First category name:", response.children[0]?.name);
this.setState({ this.setState({
categories: response.children, categories: response.children,
activeCategoryId: this.setLevel1CategoryId(this.props.activeCategoryId) activeCategoryId: this.setLevel1CategoryId(this.props.activeCategoryId)
}); });
@@ -50,15 +53,15 @@ class CategoryList extends Component {
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
console.log("componentDidUpdate", prevProps.languageContext?.currentLanguage, this.props.languageContext?.currentLanguage); console.log("componentDidUpdate", prevProps.languageContext?.currentLanguage, this.props.languageContext?.currentLanguage);
if(prevProps.languageContext?.currentLanguage !== this.props.languageContext?.currentLanguage) { if (prevProps.languageContext?.currentLanguage !== this.props.languageContext?.currentLanguage) {
this.setState({ this.setState({
categories: [], categories: [],
activeCategoryId: null activeCategoryId: null
},() => { }, () => {
window.categoryService.get(209,this.props.languageContext?.currentLanguage || this.props.i18n.language).then((response) => { window.categoryService.get(209, this.props.languageContext?.currentLanguage || this.props.i18n.language).then((response) => {
console.log("response", response); console.log("response", response);
if (response.children && response.children.length > 0) { if (response.children && response.children.length > 0) {
this.setState({ this.setState({
categories: response.children, categories: response.children,
activeCategoryId: this.setLevel1CategoryId(this.props.activeCategoryId) activeCategoryId: this.setLevel1CategoryId(this.props.activeCategoryId)
}); });
@@ -67,63 +70,73 @@ class CategoryList extends Component {
}); });
} }
if (prevProps.activeCategoryId !== this.props.activeCategoryId) { if (prevProps.activeCategoryId !== this.props.activeCategoryId) {
//detect path here
console.log("activeCategoryId updated", this.props.activeCategoryId);
this.setLevel1CategoryId(this.props.activeCategoryId); this.setLevel1CategoryId(this.props.activeCategoryId);
} }
} }
setLevel1CategoryId = (seoName) => { setLevel1CategoryId = (input) => {
console.log("setLevel1CategoryId called with seoName:", seoName); if (input) {
if(seoName) {
const language = this.props.languageContext?.currentLanguage || this.props.i18n.language; const language = this.props.languageContext?.currentLanguage || this.props.i18n.language;
console.log("setLevel1CategoryId - using language:", language);
console.log("setLevel1CategoryId - languageContext:", this.props.languageContext);
console.log("setLevel1CategoryId - i18n.language:", this.props.i18n?.language);
const categoryTreeCache = window.categoryService.getSync(209, language); const categoryTreeCache = window.categoryService.getSync(209, language);
console.log("setLevel1CategoryId - categoryTreeCache (language: " + language + "):", categoryTreeCache, seoName);
// Helper function to recursively search for seoName in category tree
const findLevel1CategoryId = (categories, targetSeoName, level1Id = null) => {
for (const category of categories) {
// If we're at level 1 (direct children of root), set this as potential level1Id
const currentLevel1Id = level1Id || category.id;
// Check if current category matches the seoName
if (category.seoName === targetSeoName) {
return currentLevel1Id;
}
// If category has children, search recursively
if (category.children && category.children.length > 0) {
const result = findLevel1CategoryId(category.children, targetSeoName, currentLevel1Id);
if (result) {
return result;
}
}
}
};
// Search in the children of the root category (209)
if (categoryTreeCache && categoryTreeCache.children) { if (categoryTreeCache && categoryTreeCache.children) {
const level1CategoryId = findLevel1CategoryId(categoryTreeCache.children, seoName); let level1CategoryId = null;
console.log("Found level1CategoryId:", level1CategoryId, "for seoName:", seoName);
// Check if input is already a numeric level 1 category ID
const inputAsNumber = parseInt(input);
if (!isNaN(inputAsNumber)) {
// Check if this is already a level 1 category ID
const level1Category = categoryTreeCache.children.find(cat => cat.id === inputAsNumber);
if (level1Category) {
console.log("Input is already a level 1 category ID:", inputAsNumber);
level1CategoryId = inputAsNumber;
} else {
// It's a category ID, find its level 1 parent
const findLevel1FromId = (categories, targetId) => {
for (const category of categories) {
if (category.id === targetId) {
return category.parentId === 209 ? category.id : findLevel1FromId(categoryTreeCache.children, category.parentId);
}
if (category.children && category.children.length > 0) {
const result = findLevel1FromId(category.children, targetId);
if (result) return result;
}
}
return null;
};
level1CategoryId = findLevel1FromId(categoryTreeCache.children, inputAsNumber);
}
} else {
// It's an SEO name, find the level 1 category
const findLevel1FromSeoName = (categories, targetSeoName, level1Id = null) => {
for (const category of categories) {
const currentLevel1Id = level1Id || category.id;
if (category.seoName === targetSeoName) {
return currentLevel1Id;
}
if (category.children && category.children.length > 0) {
const result = findLevel1FromSeoName(category.children, targetSeoName, currentLevel1Id);
if (result) return result;
}
}
return null;
};
level1CategoryId = findLevel1FromSeoName(categoryTreeCache.children, input);
}
this.setState({ this.setState({
activeCategoryId: level1CategoryId activeCategoryId: level1CategoryId
}); });
return; return;
} }
this.setState({ activeCategoryId: null });
}else{
this.setState({ activeCategoryId: null });
} }
this.setState({ activeCategoryId: null });
} }
handleMobileMenuToggle = () => { handleMobileMenuToggle = () => {
this.setState(prevState => ({ this.setState(prevState => ({
@@ -138,112 +151,164 @@ class CategoryList extends Component {
}); });
}; };
componentWillUnmount() {
if (this.productCategoryCheckInterval) {
clearInterval(this.productCategoryCheckInterval);
this.productCategoryCheckInterval = null;
}
}
render() { render() {
const { categories, mobileMenuOpen, activeCategoryId } = this.state; const { categories, mobileMenuOpen, activeCategoryId } = this.state;
console.log("RENDER DEBUG - About to render categories:");
console.log(" categories.length:", categories.length);
if (categories.length > 0) {
console.log(" First category name:", categories[0].name);
console.log(" First category id:", categories[0].id);
}
console.log(" Current language context:", this.props.languageContext?.currentLanguage);
console.log(" Current i18n language:", this.props.i18n?.language);
const renderCategoryRow = (categories, isMobile = false) => ( const renderCategoryRow = (categories, isMobile = false) => (
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
justifyContent: "flex-start", justifyContent: "flex-start",
alignItems: "center", alignItems: "center",
flexWrap: isMobile ? "wrap" : "nowrap", flexWrap: "wrap",
overflowX: isMobile ? "visible" : "auto", overflowX: "visible",
flexDirection: isMobile ? "column" : "row", flexDirection: isMobile ? "column" : "row",
py: 0.5, // Add vertical padding to prevent border clipping py: 0.5, // Add vertical padding to prevent border clipping
"&::-webkit-scrollbar": {
display: "none",
},
scrollbarWidth: "none",
msOverflowStyle: "none",
}} }}
> >
<Button <Button
component={Link} component={Link}
to="/" to="/"
color="inherit" color="inherit"
size="small" size="small"
aria-label="Zur Startseite" aria-label="Zur Startseite"
onClick={isMobile ? this.handleMobileCategoryClick : undefined} onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{ sx={{
fontSize: "0.75rem", fontSize: "0.75rem",
textTransform: "none", textTransform: "none",
whiteSpace: "nowrap", whiteSpace: "nowrap",
opacity: 0.9, opacity: 0.9,
mx: isMobile ? 0 : 0.5, mx: isMobile ? 0 : 0.5,
my: 0.25, my: 0.25,
minWidth: isMobile ? "100%" : "auto", minWidth: isMobile ? "100%" : "auto",
borderRadius: 1, borderRadius: 1,
justifyContent: isMobile ? "flex-start" : "center", justifyContent: isMobile ? "flex-start" : "center",
transition: "all 0.2s ease", transition: "all 0.2s ease",
textShadow: "0 1px 2px rgba(0,0,0,0.3)", textShadow: "0 1px 2px rgba(0,0,0,0.3)",
position: "relative", position: "relative",
...(activeCategoryId === null && { ...(activeCategoryId === null && {
bgcolor: "#fff", bgcolor: "#fff",
textShadow: "none", textShadow: "none",
opacity: 1, opacity: 1,
}), }),
"&:hover": { "&:hover": {
opacity: 1, opacity: 1,
bgcolor: "#fff", bgcolor: "#fff",
textShadow: "none", textShadow: "none",
"& .MuiSvgIcon-root": { "& .MuiSvgIcon-root": {
color: "#2e7d32 !important", color: "#2e7d32 !important",
},
"& .bold-text": {
color: "#2e7d32 !important",
},
"& .thin-text": {
color: "transparent !important",
},
}, },
}} "& .bold-text": {
> color: "#2e7d32 !important",
<HomeIcon sx={{ },
fontSize: "1rem", "& .thin-text": {
mr: isMobile ? 1 : 0, color: "transparent !important",
color: activeCategoryId === null ? "#2e7d32" : "inherit" },
}} /> },
{isMobile && ( }}
<Box sx={{ position: "relative", display: "inline-block" }}> >
{/* Bold text (always rendered to set width) */} <HomeIcon sx={{
<Box fontSize: "1rem",
className="bold-text" mr: isMobile ? 1 : 0,
sx={{ color: activeCategoryId === null ? "#2e7d32" : "inherit"
fontWeight: "bold", }} />
color: activeCategoryId === null ? "#2e7d32" : "transparent", {isMobile && (
position: "relative", <Box sx={{ position: "relative", display: "inline-block" }}>
zIndex: 2, {/* Bold text (always rendered to set width) */}
}} <Box
> className="bold-text"
{this.props.t ? this.props.t('navigation.home') : 'Startseite'} sx={{
</Box> fontWeight: "bold",
{/* Thin text (positioned on top) */} color: activeCategoryId === null ? "#2e7d32" : "transparent",
<Box position: "relative",
className="thin-text" zIndex: 2,
sx={{ }}
fontWeight: "400", >
color: activeCategoryId === null ? "transparent" : "inherit", {this.props.t ? this.props.t('navigation.home') : 'Startseite'}
position: "absolute",
top: 0,
left: 0,
zIndex: 1,
}}
>
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
</Box>
</Box> </Box>
)} {/* Thin text (positioned on top) */}
</Button> <Box
className="thin-text"
sx={{
fontWeight: "400",
color: activeCategoryId === null ? "transparent" : "inherit",
position: "absolute",
top: 0,
left: 0,
zIndex: 1,
}}
>
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
</Box>
</Box>
)}
</Button>
<Button
component={Link}
to="/Kategorie/neu"
color="inherit"
size="small"
aria-label="Neuheiten"
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{
fontSize: "0.75rem",
textTransform: "none",
whiteSpace: "nowrap",
opacity: 0.9,
mx: isMobile ? 0 : 0.5,
my: 0.25,
minWidth: isMobile ? "100%" : "auto",
borderRadius: 1,
justifyContent: isMobile ? "flex-start" : "center",
transition: "all 0.2s ease",
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
position: "relative"
}}
>
<FiberNewIcon sx={{
fontSize: "1rem",
mr: isMobile ? 1 : 0
}} />
{isMobile && (
<Box sx={{ position: "relative", display: "inline-block" }}>
{/* Bold text (always rendered to set width) */}
<Box
className="bold-text"
sx={{
fontWeight: "bold",
color: "transparent",
position: "relative",
zIndex: 2,
}}
>
{this.props.t ? this.props.t('navigation.new') : 'Neuheiten'}
</Box>
{/* Thin text (positioned on top) */}
<Box
className="thin-text"
sx={{
fontWeight: "400",
color: "inherit",
position: "absolute",
top: 0,
left: 0,
zIndex: 1,
}}
>
{this.props.t ? this.props.t('navigation.new') : 'Neuheiten'}
</Box>
</Box>
)}
</Button>
{categories.length > 0 ? ( {categories.length > 0 ? (
<> <>
@@ -320,23 +385,101 @@ class CategoryList extends Component {
); );
})} })}
</> </>
) : ( !isMobile && ( ) : (!isMobile && (
<Typography <Typography
variant="caption" variant="caption"
color="inherit" color="inherit"
sx={{ sx={{
display: "inline-flex", display: "inline-flex",
alignItems: "center", alignItems: "center",
height: "33px", // Match small button height height: "33px", // Match small button height
px: 1, px: 1,
fontSize: "0.75rem", fontSize: "0.75rem",
opacity: 0.9, opacity: 0.9,
}} }}
> >
&nbsp; &nbsp;
</Typography> </Typography>
) )
)} )}
<Button
component={Link}
to="/Konfigurator"
color="inherit"
size="small"
aria-label="Zur Startseite"
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{
fontSize: "0.75rem",
textTransform: "none",
whiteSpace: "nowrap",
opacity: 0.9,
mx: isMobile ? 0 : 0.5,
my: 0.25,
minWidth: isMobile ? "100%" : "auto",
borderRadius: 1,
justifyContent: isMobile ? "flex-start" : "center",
transition: "all 0.2s ease",
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
position: "relative",
...(activeCategoryId === null && {
bgcolor: "#fff",
textShadow: "none",
opacity: 1,
}),
"&:hover": {
opacity: 1,
bgcolor: "#fff",
textShadow: "none",
"& .MuiSvgIcon-root": {
color: "#2e7d32 !important",
},
"& .bold-text": {
color: "#2e7d32 !important",
},
"& .thin-text": {
color: "transparent !important",
},
},
}}
>
<SettingsIcon sx={{
fontSize: "1rem",
mr: isMobile ? 1 : 0,
color: activeCategoryId === null ? "#2e7d32" : "inherit"
}} />
{isMobile && (
<Box sx={{ position: "relative", display: "inline-block" }}>
{/* Bold text (always rendered to set width) */}
<Box
className="bold-text"
sx={{
fontWeight: "bold",
color: activeCategoryId === null ? "#2e7d32" : "transparent",
position: "relative",
zIndex: 2,
}}
>
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
</Box>
{/* Thin text (positioned on top) */}
<Box
className="thin-text"
sx={{
fontWeight: "400",
color: activeCategoryId === null ? "transparent" : "inherit",
position: "absolute",
top: 0,
left: 0,
zIndex: 1,
}}
>
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
</Box>
</Box>
)}
</Button>
</Box> </Box>
); );
@@ -373,11 +516,11 @@ class CategoryList extends Component {
> >
<Container maxWidth="lg" sx={{ px: 2 }}> <Container maxWidth="lg" sx={{ px: 2 }}>
{/* Toggle Button */} {/* Toggle Button */}
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center",
py: 1, py: 1,
cursor: "pointer", cursor: "pointer",
"&:hover": { "&:hover": {
@@ -387,7 +530,7 @@ class CategoryList extends Component {
onClick={this.handleMobileMenuToggle} onClick={this.handleMobileMenuToggle}
role="button" role="button"
tabIndex={0} tabIndex={0}
aria-label={this.props.t ? aria-label={this.props.t ?
(mobileMenuOpen ? this.props.t('navigation.categoriesClose') : this.props.t('navigation.categoriesOpen')) : (mobileMenuOpen ? this.props.t('navigation.categoriesClose') : this.props.t('navigation.categoriesOpen')) :
(mobileMenuOpen ? "Kategorien schließen" : "Kategorien öffnen") (mobileMenuOpen ? "Kategorien schließen" : "Kategorien öffnen")
} }
@@ -398,11 +541,11 @@ class CategoryList extends Component {
} }
}} }}
> >
<Typography variant="subtitle2" color="inherit" sx={{ <Typography variant="subtitle2" color="inherit" sx={{
fontWeight: "bold", fontWeight: "bold",
textShadow: "0 1px 2px rgba(0,0,0,0.3)" textShadow: "0 1px 2px rgba(0,0,0,0.3)"
}}> }}>
{this.props.t ? this.props.t('navigation.categories') : 'Kategorien'} {this.props.t ? this.props.t('navigation.categories') : 'Kategorien'}
</Typography> </Typography>
<Box sx={{ display: "flex", alignItems: "center" }}> <Box sx={{ display: "flex", alignItems: "center" }}>
{mobileMenuOpen ? <CloseIcon /> : <MenuIcon />} {mobileMenuOpen ? <CloseIcon /> : <MenuIcon />}

View File

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

View File

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

View File

@@ -17,7 +17,7 @@ const DeliveryMethodSelector = ({ deliveryMethod, onChange, isPickupOnly, cartIt
name: 'DHL', name: 'DHL',
description: isPickupOnly ? (t ? t('delivery.descriptions.notAvailable') : "nicht auswählbar weil ein oder mehrere Artikel nur abgeholt werden können") : 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'), isFreeShipping ? (t ? t('delivery.descriptions.standardFree') : 'Standardversand - KOSTENLOS ab 100€ Warenwert!') : (t ? t('delivery.descriptions.standard') : 'Standardversand'),
price: isFreeShipping ? (t ? t('delivery.prices.free') : 'kostenlos') : (t ? t('delivery.prices.dhl') : '6,99 €'), price: isFreeShipping ? (t ? t('delivery.prices.free') : 'kostenlos') : (t ? t('delivery.prices.dhl') : '5,90 €'),
disabled: isPickupOnly disabled: isPickupOnly
}, },
{ {

View File

@@ -31,6 +31,7 @@ const getStatusTranslation = (status, t) => {
new: t ? t('orders.status.new') : "in Bearbeitung", new: t ? t('orders.status.new') : "in Bearbeitung",
pending: t ? t('orders.status.pending') : "Neu", pending: t ? t('orders.status.pending') : "Neu",
processing: t ? t('orders.status.processing') : "in Bearbeitung", processing: t ? t('orders.status.processing') : "in Bearbeitung",
paid: t ? t('orders.status.paid') : "Bezahlt",
cancelled: t ? t('orders.status.cancelled') : "Storniert", cancelled: t ? t('orders.status.cancelled') : "Storniert",
shipped: t ? t('orders.status.shipped') : "Verschickt", shipped: t ? t('orders.status.shipped') : "Verschickt",
delivered: t ? t('orders.status.delivered') : "Geliefert", delivered: t ? t('orders.status.delivered') : "Geliefert",
@@ -39,29 +40,23 @@ const getStatusTranslation = (status, t) => {
}; };
const statusEmojis = { const statusEmojis = {
"in Bearbeitung": "⚙️", new: "⚙️",
pending: "⏳", pending: "⏳",
processing: "🔄", processing: "🔄",
paid: "🏦",
cancelled: "❌", cancelled: "❌",
Verschickt: "🚚", shipped: "🚚",
Geliefert: "✅", delivered: "✅",
Storniert: "❌",
Retoure: "↩️",
"Teil Retoure": "↪️",
"Teil geliefert": "⚡",
}; };
const statusColors = { const statusColors = {
"in Bearbeitung": "#ed6c02", // orange new: "#ed6c02", // orange
pending: "#ff9800", // orange for pending pending: "#ff9800", // orange for pending
processing: "#2196f3", // blue for processing processing: "#2196f3", // blue for processing
paid: "#2e7d32", // green
cancelled: "#d32f2f", // red for cancelled cancelled: "#d32f2f", // red for cancelled
Verschickt: "#2e7d32", // green shipped: "#2e7d32", // green
Geliefert: "#2e7d32", // green delivered: "#2e7d32", // green
Storniert: "#d32f2f", // red
Retoure: "#9c27b0", // purple
"Teil Retoure": "#9c27b0", // purple
"Teil geliefert": "#009688", // teal
}; };
const currencyFormatter = new Intl.NumberFormat("de-DE", { const currencyFormatter = new Intl.NumberFormat("de-DE", {
@@ -229,11 +224,11 @@ const OrdersTab = ({ orderIdFromHash, t }) => {
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: "8px", gap: "8px",
color: getStatusColor(displayStatus), color: getStatusColor(order.status),
}} }}
> >
<span style={{ fontSize: "1.2rem" }}> <span style={{ fontSize: "1.2rem" }}>
{getStatusEmoji(displayStatus)} {getStatusEmoji(order.status)}
</span> </span>
<Typography <Typography
variant="body2" variant="body2"
@@ -243,6 +238,18 @@ const OrdersTab = ({ orderIdFromHash, t }) => {
{displayStatus} {displayStatus}
</Typography> </Typography>
</Box> </Box>
{order.delivery_method === 'DHL' && order.trackingCode && (
<Box sx={{ mt: 0.5 }}>
<a
href={`https://www.dhl.de/de/privatkunden/dhl-sendungsverfolgung.html?piececode=${order.trackingCode}`}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: '0.85rem', color: '#d40511' }}
>
📦 {t ? t('orders.trackShipment') : 'Sendung verfolgen'}
</a>
</Box>
)}
</TableCell> </TableCell>
<TableCell> <TableCell>
{order.items {order.items

View File

@@ -200,13 +200,13 @@ const config = {
// Shipping // Shipping
shipping: { shipping: {
defaultCost: "4.99 EUR", defaultCost: "5.90 EUR",
defaultService: "Standard" defaultService: "Standard"
}, },
// Images // Images
images: { images: {
logo: "/assets/images/sh.png", logo: "/assets/images/sh.avif",
placeholder: "/assets/images/nopicture.jpg" placeholder: "/assets/images/nopicture.jpg"
}, },

View File

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

View File

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

View File

@@ -1,9 +1,10 @@
// @note Dummy data for grow tent configurator - no backend calls // @note Dummy data for grow tent configurator - no backend calls
// descriptions now keys for translation
export const tentShapes = [ export const tentShapes = [
{ {
id: '60x60', id: '60x60',
name: '60x60cm', name: '60x60cm',
description: 'Kompakt - ideal für kleine Räume', descriptionKey: 'kitConfig.description60x60',
footprint: '60x60', footprint: '60x60',
minPlants: 1, minPlants: 1,
maxPlants: 2, maxPlants: 2,
@@ -13,7 +14,7 @@ export const tentShapes = [
{ {
id: '80x80', id: '80x80',
name: '80x80cm', name: '80x80cm',
description: 'Mittel - perfekte Balance', descriptionKey: 'kitConfig.description80x80',
footprint: '80x80', footprint: '80x80',
minPlants: 2, minPlants: 2,
maxPlants: 4, maxPlants: 4,
@@ -23,7 +24,7 @@ export const tentShapes = [
{ {
id: '100x100', id: '100x100',
name: '100x100cm', name: '100x100cm',
description: 'Groß - für erfahrene Grower', descriptionKey: 'kitConfig.description100x100',
footprint: '100x100', footprint: '100x100',
minPlants: 4, minPlants: 4,
maxPlants: 6, maxPlants: 6,
@@ -33,7 +34,7 @@ export const tentShapes = [
{ {
id: '120x60', id: '120x60',
name: '120x60cm', name: '120x60cm',
description: 'Rechteckig - maximale Raumnutzung', descriptionKey: 'kitConfig.description120x60',
footprint: '120x60', footprint: '120x60',
minPlants: 3, minPlants: 3,
maxPlants: 6, maxPlants: 6,
@@ -41,54 +42,3 @@ export const tentShapes = [
visualDepth: 60 visualDepth: 60
} }
]; ];
export const extras = [
{
id: 'ph_tester',
name: 'pH-Messgerät',
description: 'Digitales pH-Meter',
price: 29.99,
image: '/assets/images/nopicture.jpg',
category: 'Messung'
},
{
id: 'nutrients_starter',
name: 'Dünger Starter-Set',
description: 'Komplettes Nährstoff-Set',
price: 39.99,
image: '/assets/images/nopicture.jpg',
category: 'Nährstoffe'
},
{
id: 'grow_pots',
name: 'Grow-Töpfe Set (5x)',
description: '5x Stofftöpfe 11L',
price: 24.99,
image: '/assets/images/nopicture.jpg',
category: 'Töpfe'
},
{
id: 'timer_socket',
name: 'Zeitschaltuhr',
description: 'Digitale Zeitschaltuhr',
price: 19.99,
image: '/assets/images/nopicture.jpg',
category: 'Steuerung'
},
{
id: 'thermometer',
name: 'Thermo-Hygrometer',
description: 'Min/Max Temperatur & Luftfeuchtigkeit',
price: 14.99,
image: '/assets/images/nopicture.jpg',
category: 'Messung'
},
{
id: 'pruning_shears',
name: 'Gartenschere',
description: 'Präzisions-Gartenschere',
price: 16.99,
image: '/assets/images/nopicture.jpg',
category: 'Werkzeug'
}
];

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import navigation from './navigation.js';
import auth from './auth.js'; import auth from './auth.js';
import cart from './cart.js'; import cart from './cart.js';
import product from './product.js'; import product from './product.js';
import productDialogs from './productDialogs.js';
import search from './search.js'; import search from './search.js';
import sorting from './sorting.js'; import sorting from './sorting.js';
import chat from './chat.js'; import chat from './chat.js';
@@ -18,6 +19,7 @@ import pages from './pages.js';
import orders from './orders.js'; import orders from './orders.js';
import settings from './settings.js'; import settings from './settings.js';
import common from './common.js'; import common from './common.js';
import kitConfig from './kitConfig.js';
import legalDatenschutzBasic from './legal-datenschutz-basic.js'; import legalDatenschutzBasic from './legal-datenschutz-basic.js';
import legalDatenschutzCustomer from './legal-datenschutz-customer.js'; import legalDatenschutzCustomer from './legal-datenschutz-customer.js';
import legalDatenschutzGoogleOrders from './legal-datenschutz-google-orders.js'; import legalDatenschutzGoogleOrders from './legal-datenschutz-google-orders.js';
@@ -35,6 +37,7 @@ export default {
"auth": auth, "auth": auth,
"cart": cart, "cart": cart,
"product": product, "product": product,
"productDialogs": productDialogs,
"search": search, "search": search,
"sorting": sorting, "sorting": sorting,
"chat": chat, "chat": chat,
@@ -50,6 +53,7 @@ export default {
"orders": orders, "orders": orders,
"settings": settings, "settings": settings,
"common": common, "common": common,
"kitConfig": kitConfig,
"legalDatenschutzBasic": legalDatenschutzBasic, "legalDatenschutzBasic": legalDatenschutzBasic,
"legalDatenschutzCustomer": legalDatenschutzCustomer, "legalDatenschutzCustomer": legalDatenschutzCustomer,
"legalDatenschutzGoogleOrders": legalDatenschutzGoogleOrders, "legalDatenschutzGoogleOrders": legalDatenschutzGoogleOrders,

View File

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

View File

@@ -3,7 +3,8 @@ export default {
"new": "قيد التنفيذ", "new": "قيد التنفيذ",
"pending": "جديد", "pending": "جديد",
"processing": "قيد التنفيذ", "processing": "قيد التنفيذ",
"cancelled": لغاة", "paid": دفوع",
"cancelled": "ملغي",
"shipped": "تم الشحن", "shipped": "تم الشحن",
"delivered": "تم التوصيل", "delivered": "تم التوصيل",
"return": "إرجاع", "return": "إرجاع",
@@ -24,6 +25,7 @@ export default {
"cancelOrder": "إلغاء الطلب" "cancelOrder": "إلغاء الطلب"
}, },
"noOrders": "لم تقم بوضع أي طلبات بعد.", "noOrders": "لم تقم بوضع أي طلبات بعد.",
"trackShipment": "تتبع الشحنة",
"details": { "details": {
"title": "تفاصيل الطلب: {{orderId}}", "title": "تفاصيل الطلب: {{orderId}}",
"deliveryAddress": "عنوان التوصيل", "deliveryAddress": "عنوان التوصيل",
@@ -36,14 +38,13 @@ export default {
"item": "العنصر", "item": "العنصر",
"quantity": "الكمية", "quantity": "الكمية",
"price": "السعر", "price": "السعر",
"vat": "ضريبة القيمة المضافة",
"total": "الإجمالي", "total": "الإجمالي",
"cancelOrder": "إلغاء الطلب" "cancelOrder": "إلغاء الطلب"
}, },
"cancelConfirm": { "cancelConfirm": {
"title": "إلغاء الطلب", "title": "إلغاء الطلب",
"message": "هل أنت متأكد أنك تريد إلغاء هذا الطلب؟", "message": "هل أنت متأكد أنك تريد إلغاء هذا الطلب؟",
"confirm": "إلغاء الطلب", "confirm": "إلغاء",
"cancelling": "جارٍ الإلغاء..." "cancelling": "جارٍ الإلغاء..."
}, },
"processing": "يتم إكمال الطلب..." "processing": "يتم إكمال الطلب..."

View File

@@ -5,9 +5,10 @@ export default {
"notFoundDescription": "المنتج الذي تبحث عنه غير موجود أو تم إزالته.", "notFoundDescription": "المنتج الذي تبحث عنه غير موجود أو تم إزالته.",
"backToHome": "العودة إلى الصفحة الرئيسية", "backToHome": "العودة إلى الصفحة الرئيسية",
"error": "خطأ", "error": "خطأ",
"articleNumber": "رقم المقال", "articleNumber": "رقم المنتج",
"manufacturer": "الشركة المصنعة", "manufacturer": "الشركة المصنعة",
"inclVat": "شامل {{vat}}% ضريبة القيمة المضافة", "inclVat": "شامل {{vat}}% ضريبة القيمة المضافة",
"inclVatSimple": "شامل ضريبة القيمة المضافة",
"priceUnit": "{{price}}/{{unit}}", "priceUnit": "{{price}}/{{unit}}",
"new": "جديد", "new": "جديد",
"weeks": "أسابيع", "weeks": "أسابيع",
@@ -22,18 +23,20 @@ export default {
"weight": "الوزن: {{weight}} كجم", "weight": "الوزن: {{weight}} كجم",
"youSave": "أنت توفر: {{amount}}", "youSave": "أنت توفر: {{amount}}",
"cheaperThanIndividual": "أرخص من الشراء بشكل فردي", "cheaperThanIndividual": "أرخص من الشراء بشكل فردي",
"pickupPrice": "سعر الاستلام: 19.90 يورو لكل قطعة.", "pickupPrice": "سعر الاستلام: 19.90 لكل قطعة.",
"consistsOf": "يتكون من:", "consistsOf": "يتكون من:",
"loadingComponentDetails": "{{index}}. جارٍ تحميل تفاصيل المكون...", "loadingComponentDetails": "{{index}}. جارٍ تحميل تفاصيل المكون...",
"loadingProduct": "جارٍ تحميل المنتج...",
"individualPriceTotal": "إجمالي السعر الفردي:", "individualPriceTotal": "إجمالي السعر الفردي:",
"setPrice": "سعر المجموعة:", "setPrice": "سعر المجموعة:",
"yourSavings": "توفيرك:", "yourSavings": "توفيرك:",
"similarProducts": "منتجات مشابهة",
"countDisplay": { "countDisplay": {
"noProducts": "0 منتجات", "noProducts": "0 منتجات",
"oneProduct": "منتج واحد", "oneProduct": "1 منتج",
"multipleProducts": "{{count}} منتجات", "multipleProducts": "{{count}} منتجات",
"filteredProducts": "{{filtered}} من {{total}} منتجات", "filteredProducts": "{{filtered}} من {{total}} منتجات",
"filteredOneProduct": "{{filtered}} من منتج واحد", "filteredOneProduct": "{{filtered}} من 1 منتج",
"xOfYProducts": "{{x}} من {{y}} منتجات" "xOfYProducts": "{{x}} من {{y}} منتجات"
}, },
"removeFiltersToSee": "قم بإزالة الفلاتر لرؤية المنتجات", "removeFiltersToSee": "قم بإزالة الفلاتر لرؤية المنتجات",

View File

@@ -0,0 +1,61 @@
export default {
"questionTitle": "سؤال عن المنتج",
"questionSubtitle": "هل لديك سؤال عن هذا المنتج؟ نحن سعداء بمساعدتك.",
"questionSuccess": "شكرًا على سؤالك! سنرد عليك في أقرب وقت ممكن.",
"nameLabel": "الاسم",
"namePlaceholder": "اسمك",
"emailLabel": "البريد الإلكتروني",
"emailPlaceholder": "your.email@example.com",
"questionLabel": "سؤالك",
"questionPlaceholder": "صف سؤالك عن هذا المنتج...",
"photosLabelQuestion": "أرفق صورًا مع سؤالك (اختياري)",
"submitQuestion": "إرسال السؤال",
"sending": "جارٍ الإرسال...",
"ratingTitle": "قيم المنتج",
"ratingSubtitle": "شارك تجربتك مع هذا المنتج وساعد العملاء الآخرين في اتخاذ قرارهم.",
"ratingSuccess": "شكرًا على تقييمك! سيتم نشره بعد المراجعة.",
"emailHelper": "لن يتم نشر بريدك الإلكتروني",
"ratingLabel": "التقييم *",
"pleaseRate": "يرجى التقييم",
"ratingStars": "{{rating}} من 5 نجوم",
"reviewLabel": "تقييمك (اختياري)",
"reviewPlaceholder": "صف تجاربك مع هذا المنتج...",
"photosLabelRating": "أرفق صورًا مع تقييمك (اختياري)",
"submitRating": "إرسال التقييم",
"errorGeneric": "حدث خطأ",
"errorPhotos": "خطأ في معالجة الصور",
"availabilityTitle": "طلب التوفر",
"availabilitySubtitle": "هذا المنتج غير متوفر حاليًا. سنكون سعداء بإبلاغك بمجرد عودته للمخزون.",
"availabilitySuccessEmail": "شكرًا على طلبك! سنخطرك عبر البريد الإلكتروني بمجرد توفر المنتج مرة أخرى.",
"availabilitySuccessTelegram": "شكرًا على طلبك! سنخطرك عبر تيليجرام بمجرد توفر المنتج مرة أخرى.",
"notificationMethodLabel": "كيف تود أن يتم إعلامك؟",
"telegramBotLabel": "بوت تيليجرام",
"telegramIdLabel": "معرف تيليجرام",
"telegramPlaceholder": "@اسمكعلىتيليجرام أو معرف تيليجرام",
"telegramHelper": "أدخل اسم المستخدم الخاص بك على تيليجرام (مع @) أو معرف تيليجرام",
"messageLabel": "رسالة (اختياري)",
"messagePlaceholder": "معلومات إضافية أو أسئلة...",
"submitAvailability": "طلب التوفر",
"photoUploadSelect": "اختر الصور",
"photoUploadErrorMaxFiles": "الحد الأقصى {{max}} ملفات مسموح بها",
"photoUploadErrorFileType": "مسموح فقط بملفات الصور (JPEG, PNG, GIF, WebP)",
"photoUploadErrorFileSize": "الملف كبير جدًا. الحد الأقصى: {{maxSize}} ميجابايت",
"photoUploadSelectedFiles": "{{count}} ملف(ملفات) مختارة",
"photoUploadCompressed": "(تم الضغط للرفع)",
"photoUploadRemove": "إزالة الصورة",
"photoUploadLabelDefault": "أرفق صورًا (اختياري)",
"shareTitle": "مشاركة",
"shareEmbed": "تضمين",
"shareCopyLink": "نسخ الرابط",
"shareSuccessEmbed": "تم نسخ كود التضمين إلى الحافظة!",
"shareErrorEmbed": "حدث خطأ أثناء نسخ كود التضمين",
"shareSuccessLink": "تم نسخ الرابط إلى الحافظة!",
"shareWhatsAppText": "شوف المنتج ده: {{name}}",
"shareTelegramText": "شوف المنتج ده: {{name}}",
"shareEmailSubject": "توصية بمنتج",
"shareEmailBody": "مرحبًا،\n\nحابب أوصي لك بالمنتج ده:\n\n{{name}}\n{{url}}\n\nمع أطيب التحيات"
};

View File

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

View File

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

View File

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

View File

@@ -5,14 +5,16 @@ export default {
"profile": "Профил", "profile": "Профил",
"email": "Имейл", "email": "Имейл",
"password": "Парола", "password": "Парола",
"newPassword": "Нова парола",
"confirmPassword": "Потвърдете паролата", "confirmPassword": "Потвърдете паролата",
"forgotPassword": "Забравена парола?", "forgotPassword": "Забравена парола?",
"loginWithGoogle": "Вход с Google", "loginWithGoogle": "Вход с Google",
"or": "ИЛИ", "or": "ИЛИ",
"privacyAccept": "С натискане на \"Вход с Google\" приемам", "privacyAccept": "С натискането на \"Вход с Google\" приемам",
"privacyPolicy": "Политиката за поверителност", "privacyPolicy": "Политиката за поверителност",
"passwordMinLength": "Паролата трябва да е поне 8 символа", "passwordMinLength": "Паролата трябва да е поне 8 символа",
"newPasswordMinLength": "Новата парола трябва да е поне 8 символа", "newPasswordMinLength": "Новата парола трябва да е поне 8 символа",
"backToHome": "Обратно към началната страница",
"menu": { "menu": {
"profile": "Профил", "profile": "Профил",
"myProfile": "Моят профил", "myProfile": "Моят профил",
@@ -21,5 +23,28 @@ export default {
"settings": "Настройки", "settings": "Настройки",
"adminDashboard": "Админ табло", "adminDashboard": "Админ табло",
"adminUsers": "Админ потребители" "adminUsers": "Админ потребители"
},
"resetPassword": {
"title": "Нулиране на парола",
"button": "Нулиране на парола",
"success": "Вашата парола беше успешно нулирана! Скоро ще бъдете пренасочени към вход...",
"invalidToken": "Няма валиден токен. Моля, използвайте линка от имейла си.",
"error": "Грешка при нулиране на паролата",
"emailSent": "Линк за нулиране на паролата беше изпратен на вашия имейл.",
"emailError": "Грешка при изпращане на имейла"
},
"errors": {
"fillAllFields": "Моля, попълнете всички полета",
"invalidEmail": "Моля, въведете валиден имейл адрес",
"passwordsNotMatch": "Паролите не съвпадат",
"passwordsNotMatchShort": "Паролите не съвпадат",
"enterEmail": "Моля, въведете вашия имейл адрес",
"loginFailed": "Входът не бе успешен",
"registerFailed": "Регистрацията не бе успешна",
"googleLoginFailed": "Вход с Google не бе успешен",
"emailExists": "Потребител с този имейл вече съществува. Моля, използвайте друг имейл или влезте в системата."
},
"success": {
"registerComplete": "Регистрацията беше успешна. Сега можете да влезете."
} }
}; };

View File

@@ -15,5 +15,6 @@ export default {
"remove": "Премахни", "remove": "Премахни",
"products": "Продукти", "products": "Продукти",
"product": "Продукт", "product": "Продукт",
"days": "Дни" "days": "Дни",
"more": "още"
}; };

View File

@@ -16,7 +16,7 @@ export default {
"prices": { "prices": {
"free": "безплатно", "free": "безплатно",
"freeFrom100": "(безплатно от 100€)", "freeFrom100": "(безплатно от 100€)",
"dhl": "6.99 €", "dhl": "5.90 €",
"dpd": "4.90 €", "dpd": "4.90 €",
"sperrgut": "28.99 €" "sperrgut": "28.99 €"
}, },
@@ -29,7 +29,7 @@ export default {
"title": "Изберете метод на доставка", "title": "Изберете метод на доставка",
"freeShippingInfo": "💡 Безплатна доставка при поръчка над 100€!", "freeShippingInfo": "💡 Безплатна доставка при поръчка над 100€!",
"remainingForFree": "Добавете още {{amount}}€ за безплатна доставка.", "remainingForFree": "Добавете още {{amount}}€ за безплатна доставка.",
"congratsFreeShipping": "🎉 Поздравления! Вие получавате безплатна доставка!", "congratsFreeShipping": "🎉 Поздравления! Получавате безплатна доставка!",
"cartQualifiesFree": "Вашата количка на стойност {{amount}}€ се квалифицира за безплатна доставка." "cartQualifiesFree": "Вашата количка на стойност {{amount}}€ отговаря на условията за безплатна доставка."
} }
}; };

View File

@@ -3,6 +3,7 @@ import navigation from './navigation.js';
import auth from './auth.js'; import auth from './auth.js';
import cart from './cart.js'; import cart from './cart.js';
import product from './product.js'; import product from './product.js';
import productDialogs from './productDialogs.js';
import search from './search.js'; import search from './search.js';
import sorting from './sorting.js'; import sorting from './sorting.js';
import chat from './chat.js'; import chat from './chat.js';
@@ -18,6 +19,7 @@ import pages from './pages.js';
import orders from './orders.js'; import orders from './orders.js';
import settings from './settings.js'; import settings from './settings.js';
import common from './common.js'; import common from './common.js';
import kitConfig from './kitConfig.js';
import legalDatenschutzBasic from './legal-datenschutz-basic.js'; import legalDatenschutzBasic from './legal-datenschutz-basic.js';
import legalDatenschutzCustomer from './legal-datenschutz-customer.js'; import legalDatenschutzCustomer from './legal-datenschutz-customer.js';
import legalDatenschutzGoogleOrders from './legal-datenschutz-google-orders.js'; import legalDatenschutzGoogleOrders from './legal-datenschutz-google-orders.js';
@@ -35,6 +37,7 @@ export default {
"auth": auth, "auth": auth,
"cart": cart, "cart": cart,
"product": product, "product": product,
"productDialogs": productDialogs,
"search": search, "search": search,
"sorting": sorting, "sorting": sorting,
"chat": chat, "chat": chat,
@@ -50,6 +53,7 @@ export default {
"orders": orders, "orders": orders,
"settings": settings, "settings": settings,
"common": common, "common": common,
"kitConfig": kitConfig,
"legalDatenschutzBasic": legalDatenschutzBasic, "legalDatenschutzBasic": legalDatenschutzBasic,
"legalDatenschutzCustomer": legalDatenschutzCustomer, "legalDatenschutzCustomer": legalDatenschutzCustomer,
"legalDatenschutzGoogleOrders": legalDatenschutzGoogleOrders, "legalDatenschutzGoogleOrders": legalDatenschutzGoogleOrders,

View File

@@ -0,0 +1,43 @@
export default {
"pageTitle": "🌱 Конфигуратор за Growbox",
"pageSubtitle": "Създайте перфектната си вътрешна система за отглеждане",
"bundleDiscountTitle": "🎯 Вземете отстъпка за комплект!",
"loadingProducts": "Зареждане на продукти за growbox...",
"loadingLighting": "Зареждане на осветителни продукти...",
"loadingVentilation": "Зареждане на вентилационни продукти...",
"loadingExtras": "Зареждане на допълнителни продукти...",
"noProductsAvailable": "Няма налични продукти за този размер",
"noLightingAvailable": "Няма подходящи лампи за размер на палатка {{shape}}.",
"noVentilationAvailable": "Няма подходяща вентилация за размер на палатка {{shape}}.",
"noExtrasAvailable": "Няма налични допълнения",
"selectShapeTitle": "1. Изберете форма на growbox",
"selectShapeSubtitle": "Първо изберете основната площ на вашия growbox",
"selectProductTitle": "2. Изберете продукт за growbox",
"selectProductSubtitle": "Изберете подходящия продукт за вашия {{shape}} growbox",
"selectLightingTitle": "3. Изберете осветление",
"selectLightingTitleShape": "3. Изберете осветление - {{shape}}",
"selectLightingSubtitle": "Моля, първо изберете размер на палатка.",
"selectVentilationTitle": "4. Изберете вентилация",
"selectVentilationTitleShape": "4. Изберете вентилация - {{shape}}",
"selectVentilationSubtitle": "Моля, първо изберете размер на палатка.",
"selectExtrasTitle": "5. Добавете допълнения (по избор)",
"yourConfiguration": "🎯 Вашата конфигурация",
"growboxLabel": "Growbox: {{name}}",
"lightingLabel": "Осветление: {{name}}",
"ventilationLabel": "Вентилация: {{name}}",
"extraLabel": "Допълнение: {{name}}",
"totalPrice": "Обща цена:",
"addToCart": "Добави в количката",
"selected": "✓ Избрано",
"notDeliverable": "Не е налично за доставка",
"noPrice": "Няма цена",
"setName": "Комплект Growbox - {{shape}}",
"description60x60": "Компактен - идеален за малки пространства",
"description80x80": "Среден - перфектен баланс",
"description100x100": "Голям - за опитни отглеждачи",
"description120x60": "Правоъгълен - максимално използване на пространството",
"plants1to2": "1-2 растения",
"plants2to4": "2-4 растения",
"plants4to6": "4-6 растения",
"plants3to6": "3-6 растения"
};

View File

@@ -1,14 +1,15 @@
export default { export default {
"status": { "status": {
"new": "В процес", "new": "в процес",
"pending": "Нова", "pending": "Ново",
"processing": "В процес", "processing": "в процес",
"cancelled": "Отменена", "paid": "Платено",
"shipped": "Изпратена", "cancelled": "Отменено",
"delivered": "Доставена", "shipped": "Изпратено",
"delivered": "Доставено",
"return": "Връщане", "return": "Връщане",
"partialReturn": "Частично връщане", "partialReturn": "Частично връщане",
"partialDelivered": "Частично доставена" "partialDelivered": "Частично доставено"
}, },
"table": { "table": {
"orderNumber": "Номер на поръчка", "orderNumber": "Номер на поръчка",
@@ -24,6 +25,7 @@ export default {
"cancelOrder": "Отмени поръчката" "cancelOrder": "Отмени поръчката"
}, },
"noOrders": "Все още не сте направили поръчки.", "noOrders": "Все още не сте направили поръчки.",
"trackShipment": "Проследи пратката",
"details": { "details": {
"title": "Подробности за поръчка: {{orderId}}", "title": "Подробности за поръчка: {{orderId}}",
"deliveryAddress": "Адрес за доставка", "deliveryAddress": "Адрес за доставка",
@@ -36,15 +38,14 @@ export default {
"item": "Артикул", "item": "Артикул",
"quantity": "Количество", "quantity": "Количество",
"price": "Цена", "price": "Цена",
"vat": "ДДС",
"total": "Общо", "total": "Общо",
"cancelOrder": "Отмени поръчката" "cancelOrder": "Отмени поръчката"
}, },
"cancelConfirm": { "cancelConfirm": {
"title": "Отмяна на поръчка", "title": "Отмени поръчката",
"message": "Сигурни ли сте, че искате да отмените тази поръчка?", "message": "Сигурни ли сте, че искате да отмените тази поръчка?",
"confirm": "Отмени поръчката", "confirm": "Отмени",
"cancelling": "Отмяна..." "cancelling": "Отмяна..."
}, },
"processing": "Поръчката се обработва...", "processing": "Поръчката се обработва..."
}; };

View File

@@ -8,13 +8,14 @@ export default {
"articleNumber": "Номер на артикул", "articleNumber": "Номер на артикул",
"manufacturer": "Производител", "manufacturer": "Производител",
"inclVat": "вкл. {{vat}}% ДДС", "inclVat": "вкл. {{vat}}% ДДС",
"inclVatSimple": "вкл. ДДС",
"priceUnit": "{{price}}/{{unit}}", "priceUnit": "{{price}}/{{unit}}",
"new": "Нов", "new": "Нов",
"weeks": "седмици", "weeks": "Седмици",
"arriving": "Пристигане:", "arriving": "Пристигане:",
"inclVatFooter": "вкл. {{vat}}% ДДС,*", "inclVatFooter": "вкл. {{vat}}% ДДС,*",
"availability": "Наличност", "availability": "Наличност",
"inStock": "налично", "inStock": "налично на склад",
"comingSoon": "Очаква се скоро", "comingSoon": "Очаква се скоро",
"deliveryTime": "Срок на доставка", "deliveryTime": "Срок на доставка",
"inclShort": "вкл.", "inclShort": "вкл.",
@@ -25,9 +26,11 @@ export default {
"pickupPrice": "Цена за вземане: 19,90 € на резник.", "pickupPrice": "Цена за вземане: 19,90 € на резник.",
"consistsOf": "Състои се от:", "consistsOf": "Състои се от:",
"loadingComponentDetails": "{{index}}. Зареждане на детайли за компонента...", "loadingComponentDetails": "{{index}}. Зареждане на детайли за компонента...",
"loadingProduct": "Зареждане на продукта...",
"individualPriceTotal": "Обща индивидуална цена:", "individualPriceTotal": "Обща индивидуална цена:",
"setPrice": "Цена на комплекта:", "setPrice": "Цена на комплекта:",
"yourSavings": "Вашите спестявания:", "yourSavings": "Вашите спестявания:",
"similarProducts": "Подобни продукти",
"countDisplay": { "countDisplay": {
"noProducts": "0 продукта", "noProducts": "0 продукта",
"oneProduct": "1 продукт", "oneProduct": "1 продукт",

View File

@@ -0,0 +1,61 @@
export default {
"questionTitle": "Въпрос за продукта",
"questionSubtitle": "Имате ли въпрос за този продукт? Ще се радваме да ви помогнем.",
"questionSuccess": "Благодарим ви за въпроса! Ще се свържем с вас възможно най-скоро.",
"nameLabel": "Име",
"namePlaceholder": "Вашето име",
"emailLabel": "Имейл",
"emailPlaceholder": "your.email@example.com",
"questionLabel": "Вашият въпрос",
"questionPlaceholder": "Опишете въпроса си за този продукт...",
"photosLabelQuestion": "Прикачете снимки към въпроса си (по избор)",
"submitQuestion": "Изпрати въпроса",
"sending": "Изпращане...",
"ratingTitle": "Оценете продукта",
"ratingSubtitle": "Споделете опита си с този продукт и помогнете на други клиенти да вземат решение.",
"ratingSuccess": "Благодарим ви за вашия отзив! Той ще бъде публикуван след проверка.",
"emailHelper": "Вашият имейл няма да бъде публикуван",
"ratingLabel": "Оценка *",
"pleaseRate": "Моля, оценете",
"ratingStars": "{{rating}} от 5 звезди",
"reviewLabel": "Вашият отзив (по избор)",
"reviewPlaceholder": "Опишете опита си с този продукт...",
"photosLabelRating": "Прикачете снимки към отзива си (по избор)",
"submitRating": "Изпрати отзива",
"errorGeneric": "Възникна грешка",
"errorPhotos": "Грешка при обработка на снимките",
"availabilityTitle": "Запитване за наличност",
"availabilitySubtitle": "Този продукт в момента не е наличен. Ще се радваме да ви уведомим веднага щом бъде наличен отново.",
"availabilitySuccessEmail": "Благодарим ви за запитването! Ще ви уведомим по имейл веднага щом продуктът отново е наличен.",
"availabilitySuccessTelegram": "Благодарим ви за запитването! Ще ви уведомим чрез Telegram веднага щом продуктът отново е наличен.",
"notificationMethodLabel": "Как бихте искали да бъдете уведомени?",
"telegramBotLabel": "Telegram Bot",
"telegramIdLabel": "Telegram ID",
"telegramPlaceholder": "@yourTelegramName or Telegram ID",
"telegramHelper": "Въведете вашето потребителско име в Telegram (с @) или Telegram ID",
"messageLabel": "Съобщение (по избор)",
"messagePlaceholder": "Допълнителна информация или въпроси...",
"submitAvailability": "Запитване за наличност",
"photoUploadSelect": "Изберете снимки",
"photoUploadErrorMaxFiles": "Максимум {{max}} файла са разрешени",
"photoUploadErrorFileType": "Разрешени са само файлове с изображения (JPEG, PNG, GIF, WebP)",
"photoUploadErrorFileSize": "Файлът е твърде голям. Максимум: {{maxSize}}MB",
"photoUploadSelectedFiles": "{{count}} файл(ове) избрани",
"photoUploadCompressed": "(компресиран за качване)",
"photoUploadRemove": "Премахни изображението",
"photoUploadLabelDefault": "Прикачи снимки (по избор)",
"shareTitle": "Сподели",
"shareEmbed": "Вграждане",
"shareCopyLink": "Копирай линка",
"shareSuccessEmbed": "Кодът за вграждане е копиран в клипборда!",
"shareErrorEmbed": "Грешка при копиране на кода за вграждане",
"shareSuccessLink": "Линкът е копиран в клипборда!",
"shareWhatsAppText": "Виж този продукт: {{name}}",
"shareTelegramText": "Виж този продукт: {{name}}",
"shareEmailSubject": "Препоръка за продукт",
"shareEmailBody": "Здравейте,\n\nИскам да ви препоръчам този продукт:\n\n{{name}}\n{{url}}\n\nПоздрави"
};

View File

@@ -2,4 +2,5 @@ export default {
"placeholder": "Можете да ме попитате за сортове канабис...", "placeholder": "Можете да ме попитате за сортове канабис...",
"recording": "Записът е в ход...", "recording": "Записът е в ход...",
"searchProducts": "Търсене на продукти...", "searchProducts": "Търсене на продукти...",
"searchResultsFor": "Резултати от търсенето за: \"{{query}}\"",
}; };

View File

@@ -1,7 +1,8 @@
export default { export default {
"seeds": "Семена", "seeds": "Семена",
"stecklinge": "Резници", "stecklinge": "Резници",
"oilPress": "Наеми преса за масло", "konfigurator": "Конфигуратор",
"oilPress": "Наеми преса за олио",
"thcTest": "THC тест", "thcTest": "THC тест",
"address1": "Trachenberger Straße 14", "address1": "Trachenberger Straße 14",
"address2": "01129 Dresden", "address2": "01129 Dresden",

View File

@@ -1,5 +1,5 @@
export default { export default {
"home": "Фини семена и резници от канабис", "home": "Качествени канабис семена",
"aktionen": "Текущи промоции и оферти", "aktionen": "Текущи промоции и оферти",
"filiale": "Нашият магазин в Дрезден", "filiale": "Нашият магазин в Дрезден"
}; };

View File

@@ -5,6 +5,7 @@ export default {
"profile": "Profil", "profile": "Profil",
"email": "Email", "email": "Email",
"password": "Heslo", "password": "Heslo",
"newPassword": "Nové heslo",
"confirmPassword": "Potvrdit heslo", "confirmPassword": "Potvrdit heslo",
"forgotPassword": "Zapomněli jste heslo?", "forgotPassword": "Zapomněli jste heslo?",
"loginWithGoogle": "Přihlásit se přes Google", "loginWithGoogle": "Přihlásit se přes Google",
@@ -13,6 +14,7 @@ export default {
"privacyPolicy": "Zásadami ochrany osobních údajů", "privacyPolicy": "Zásadami ochrany osobních údajů",
"passwordMinLength": "Heslo musí mít alespoň 8 znaků", "passwordMinLength": "Heslo musí mít alespoň 8 znaků",
"newPasswordMinLength": "Nové heslo musí mít alespoň 8 znaků", "newPasswordMinLength": "Nové heslo musí mít alespoň 8 znaků",
"backToHome": "Zpět na domovskou stránku",
"menu": { "menu": {
"profile": "Profil", "profile": "Profil",
"myProfile": "Můj profil", "myProfile": "Můj profil",
@@ -21,5 +23,28 @@ export default {
"settings": "Nastavení", "settings": "Nastavení",
"adminDashboard": "Admin Dashboard", "adminDashboard": "Admin Dashboard",
"adminUsers": "Admin Users" "adminUsers": "Admin Users"
},
"resetPassword": {
"title": "Obnovení hesla",
"button": "Obnovit heslo",
"success": "Vaše heslo bylo úspěšně obnoveno! Brzy budete přesměrováni na přihlášení...",
"invalidToken": "Nebyl nalezen platný token. Použijte prosím odkaz z vašeho e-mailu.",
"error": "Chyba při obnově hesla",
"emailSent": "Odkaz pro obnovení hesla byl odeslán na vaši e-mailovou adresu.",
"emailError": "Chyba při odesílání e-mailu"
},
"errors": {
"fillAllFields": "Vyplňte prosím všechna pole",
"invalidEmail": "Zadejte platnou e-mailovou adresu",
"passwordsNotMatch": "Hesla se neshodují",
"passwordsNotMatchShort": "Hesla se neshodují",
"enterEmail": "Zadejte prosím svou e-mailovou adresu",
"loginFailed": "Přihlášení selhalo",
"registerFailed": "Registrace selhala",
"googleLoginFailed": "Přihlášení přes Google selhalo",
"emailExists": "Uživatel s touto e-mailovou adresou již existuje. Použijte prosím jinou e-mailovou adresu nebo se přihlaste."
},
"success": {
"registerComplete": "Registrace byla úspěšná. Nyní se můžete přihlásit."
} }
}; };

View File

@@ -15,5 +15,6 @@ export default {
"remove": "Odebrat", "remove": "Odebrat",
"products": "Produkty", "products": "Produkty",
"product": "Produkt", "product": "Produkt",
"days": "Dny" "days": "Dny",
"more": "více"
}; };

View File

@@ -8,17 +8,17 @@ export default {
}, },
"descriptions": { "descriptions": {
"standard": "Standardní doprava", "standard": "Standardní doprava",
"standardFree": "Standardní doprava - ZDARMA od objednávky nad 100€!", "standardFree": "Standardní doprava - ZDARMA od hodnoty objednávky 100€!",
"notAvailable": "Nelze vybrat, protože jeden nebo více produktů lze pouze vyzvednout", "notAvailable": "Nelze vybrat, protože jeden nebo více položek lze pouze vyzvednout",
"bulky": "Pro velké a těžké předměty", "bulky": "Pro velké a těžké položky",
"pickupOnly": "Pouze osobní odběr" "pickupOnly": "Pouze osobní odběr"
}, },
"prices": { "prices": {
"free": "zdarma", "free": "zdarma",
"freeFrom100": "(zdarma od 100€)", "freeFrom100": "(zdarma od 100€)",
"dhl": "6,99 €", "dhl": "5.90 €",
"dpd": "4,90 €", "dpd": "4.90 €",
"sperrgut": "28,99 €" "sperrgut": "28.99 €"
}, },
"times": { "times": {
"cutting14Days": "Doba dodání: 14 dní", "cutting14Days": "Doba dodání: 14 dní",
@@ -27,9 +27,9 @@ export default {
}, },
"selector": { "selector": {
"title": "Vyberte způsob dopravy", "title": "Vyberte způsob dopravy",
"freeShippingInfo": "💡 Doprava zdarma od objednávky nad 100€!", "freeShippingInfo": "💡 Doprava zdarma od hodnoty objednávky 100€!",
"remainingForFree": "Přidejte ještě {{amount}}€ pro dopravu zdarma.", "remainingForFree": "Přidejte ještě {{amount}}€ pro dopravu zdarma.",
"congratsFreeShipping": "🎉 Gratulujeme! Máte dopravu zdarma!", "congratsFreeShipping": "🎉 Gratulujeme! Máte dopravu zdarma!",
"cartQualifiesFree": "Váš košík za {{amount}}€ má nárok na dopravu zdarma." "cartQualifiesFree": "Váš košík v hodnotě {{amount}}€ má nárok na dopravu zdarma."
} }
}; };

View File

@@ -3,6 +3,7 @@ import navigation from './navigation.js';
import auth from './auth.js'; import auth from './auth.js';
import cart from './cart.js'; import cart from './cart.js';
import product from './product.js'; import product from './product.js';
import productDialogs from './productDialogs.js';
import search from './search.js'; import search from './search.js';
import sorting from './sorting.js'; import sorting from './sorting.js';
import chat from './chat.js'; import chat from './chat.js';
@@ -18,6 +19,7 @@ import pages from './pages.js';
import orders from './orders.js'; import orders from './orders.js';
import settings from './settings.js'; import settings from './settings.js';
import common from './common.js'; import common from './common.js';
import kitConfig from './kitConfig.js';
import legalDatenschutzBasic from './legal-datenschutz-basic.js'; import legalDatenschutzBasic from './legal-datenschutz-basic.js';
import legalDatenschutzCustomer from './legal-datenschutz-customer.js'; import legalDatenschutzCustomer from './legal-datenschutz-customer.js';
import legalDatenschutzGoogleOrders from './legal-datenschutz-google-orders.js'; import legalDatenschutzGoogleOrders from './legal-datenschutz-google-orders.js';
@@ -35,6 +37,7 @@ export default {
"auth": auth, "auth": auth,
"cart": cart, "cart": cart,
"product": product, "product": product,
"productDialogs": productDialogs,
"search": search, "search": search,
"sorting": sorting, "sorting": sorting,
"chat": chat, "chat": chat,
@@ -50,6 +53,7 @@ export default {
"orders": orders, "orders": orders,
"settings": settings, "settings": settings,
"common": common, "common": common,
"kitConfig": kitConfig,
"legalDatenschutzBasic": legalDatenschutzBasic, "legalDatenschutzBasic": legalDatenschutzBasic,
"legalDatenschutzCustomer": legalDatenschutzCustomer, "legalDatenschutzCustomer": legalDatenschutzCustomer,
"legalDatenschutzGoogleOrders": legalDatenschutzGoogleOrders, "legalDatenschutzGoogleOrders": legalDatenschutzGoogleOrders,

View File

@@ -0,0 +1,43 @@
export default {
"pageTitle": "🌱 Konfigurátor Growboxu",
"pageSubtitle": "Sestavte si svůj dokonalý indoor grow setup",
"bundleDiscountTitle": "🎯 Zajistěte si slevu na balíček!",
"loadingProducts": "Načítání produktů growboxu...",
"loadingLighting": "Načítání osvětlení...",
"loadingVentilation": "Načítání ventilace...",
"loadingExtras": "Načítání doplňků...",
"noProductsAvailable": "Pro tuto velikost nejsou k dispozici žádné produkty",
"noLightingAvailable": "Pro velikost stanu {{shape}} nejsou k dispozici žádná vhodná světla.",
"noVentilationAvailable": "Pro velikost stanu {{shape}} není k dispozici vhodná ventilace.",
"noExtrasAvailable": "Žádné doplňky nejsou k dispozici",
"selectShapeTitle": "1. Vyberte tvar growboxu",
"selectShapeSubtitle": "Nejprve vyberte základní plochu vašeho growboxu",
"selectProductTitle": "2. Vyberte produkt growboxu",
"selectProductSubtitle": "Vyberte správný produkt pro váš growbox {{shape}}",
"selectLightingTitle": "3. Vyberte osvětlení",
"selectLightingTitleShape": "3. Vyberte osvětlení - {{shape}}",
"selectLightingSubtitle": "Nejprve prosím vyberte velikost stanu.",
"selectVentilationTitle": "4. Vyberte ventilaci",
"selectVentilationTitleShape": "4. Vyberte ventilaci - {{shape}}",
"selectVentilationSubtitle": "Nejprve prosím vyberte velikost stanu.",
"selectExtrasTitle": "5. Přidejte doplňky (volitelné)",
"yourConfiguration": "🎯 Vaše konfigurace",
"growboxLabel": "Growbox: {{name}}",
"lightingLabel": "Osvětlení: {{name}}",
"ventilationLabel": "Ventilace: {{name}}",
"extraLabel": "Doplněk: {{name}}",
"totalPrice": "Celková cena:",
"addToCart": "Přidat do košíku",
"selected": "✓ Vybráno",
"notDeliverable": "Nedodává se",
"noPrice": "Bez ceny",
"setName": "Sada growboxu - {{shape}}",
"description60x60": "Kompaktní - ideální pro malé prostory",
"description80x80": "Střední - perfektní rovnováha",
"description100x100": "Velký - pro zkušené pěstitele",
"description120x60": "Obdélníkový - maximální využití prostoru",
"plants1to2": "1-2 rostliny",
"plants2to4": "2-4 rostliny",
"plants4to6": "4-6 rostlin",
"plants3to6": "3-6 rostlin"
};

View File

@@ -1,50 +1,51 @@
export default { export default {
"status": { "status": {
"new": "Probíhá", "new": "probíhá",
"pending": "Nová", "pending": "Nové",
"processing": "Probíhá", "processing": "probíhá",
"cancelled": "Zrušeno", "paid": "Zaplaceno",
"shipped": "Odesláno", "cancelled": "Zrušeno",
"delivered": "Doručeno", "shipped": "Odesláno",
"return": "Vrácení", "delivered": "Doručeno",
"partialReturn": "Částečné vrácení", "return": "Vrácení",
"partialDelivered": "Částečně doručeno" "partialReturn": "Částečné vrácení",
"partialDelivered": "Částečně doručeno"
}, },
"table": { "table": {
"orderNumber": "Číslo objednávky", "orderNumber": "Číslo objednávky",
"date": "Datum", "date": "Datum",
"status": "Stav", "status": "Stav",
"items": "Položky", "items": "Položky",
"total": "Celkem", "total": "Celkem",
"actions": "Akce", "actions": "Akce",
"viewDetails": "Zobrazit detaily" "viewDetails": "Zobrazit detaily"
}, },
"tooltips": { "tooltips": {
"viewDetails": "Zobrazit detaily", "viewDetails": "Zobrazit detaily",
"cancelOrder": "Zrušit objednávku" "cancelOrder": "Zrušit objednávku"
}, },
"noOrders": "Ještě jste neprovedli žádné objednávky.", "noOrders": "Ještě jste neprovedli žádné objednávky.",
"trackShipment": "Sledovat zásilku",
"details": { "details": {
"title": "Detaily objednávky: {{orderId}}", "title": "Detaily objednávky: {{orderId}}",
"deliveryAddress": "Dodací adresa", "deliveryAddress": "Dodací adresa",
"invoiceAddress": "Fakturační adresa", "invoiceAddress": "Fakturační adresa",
"orderDetails": "Detaily objednávky", "orderDetails": "Detaily objednávky",
"deliveryMethod": "Způsob doručení:", "deliveryMethod": "Způsob doručení:",
"paymentMethod": "Způsob platby:", "paymentMethod": "Způsob platby:",
"notSpecified": "Nespecifikováno", "notSpecified": "Nespecifikováno",
"orderedItems": "Objednané položky", "orderedItems": "Objednané položky",
"item": "Položka", "item": "Položka",
"quantity": "Množství", "quantity": "Množství",
"price": "Cena", "price": "Cena",
"vat": "DPH", "total": "Celkem",
"total": "Celkem", "cancelOrder": "Zrušit objednávku"
"cancelOrder": "Zrušit objednávku"
}, },
"cancelConfirm": { "cancelConfirm": {
"title": "Zrušit objednávku", "title": "Zrušit objednávku",
"message": "Opravdu chcete tuto objednávku zrušit?", "message": "Opravdu chcete tuto objednávku zrušit?",
"confirm": "Zrušit objednávku", "confirm": "Zrušit",
"cancelling": "Rušení..." "cancelling": "Rušení..."
}, },
"processing": "Objednávka se dokončuje...", "processing": "Objednávka se dokončuje..."
}; };

View File

@@ -8,16 +8,17 @@ export default {
"articleNumber": "Číslo artiklu", "articleNumber": "Číslo artiklu",
"manufacturer": "Výrobce", "manufacturer": "Výrobce",
"inclVat": "včetně {{vat}}% DPH", "inclVat": "včetně {{vat}}% DPH",
"inclVatSimple": "včetně DPH",
"priceUnit": "{{price}}/{{unit}}", "priceUnit": "{{price}}/{{unit}}",
"new": "Nové", "new": "Nové",
"weeks": "týdnů", "weeks": "Týdny",
"arriving": "Příchod:", "arriving": "Příjezd:",
"inclVatFooter": "včetně {{vat}}% DPH,*", "inclVatFooter": "včetně {{vat}}% DPH,*",
"availability": "Dostupnost", "availability": "Dostupnost",
"inStock": "skladem", "inStock": "skladem",
"comingSoon": "Brzy k dispozici", "comingSoon": "Brzy k dispozici",
"deliveryTime": "Doba dodání", "deliveryTime": "Doba dodání",
"inclShort": "vč.", "inclShort": "včetně",
"vatShort": "DPH", "vatShort": "DPH",
"weight": "Hmotnost: {{weight}} kg", "weight": "Hmotnost: {{weight}} kg",
"youSave": "Ušetříte: {{amount}}", "youSave": "Ušetříte: {{amount}}",
@@ -25,9 +26,11 @@ export default {
"pickupPrice": "Cena za odběr: 19,90 € za řízek.", "pickupPrice": "Cena za odběr: 19,90 € za řízek.",
"consistsOf": "Skládá se z:", "consistsOf": "Skládá se z:",
"loadingComponentDetails": "{{index}}. Načítání detailů komponenty...", "loadingComponentDetails": "{{index}}. Načítání detailů komponenty...",
"loadingProduct": "Načítání produktu...",
"individualPriceTotal": "Celková cena jednotlivě:", "individualPriceTotal": "Celková cena jednotlivě:",
"setPrice": "Cena sady:", "setPrice": "Cena sady:",
"yourSavings": "Vaše úspory:", "yourSavings": "Vaše úspory:",
"similarProducts": "Podobné produkty",
"countDisplay": { "countDisplay": {
"noProducts": "0 produktů", "noProducts": "0 produktů",
"oneProduct": "1 produkt", "oneProduct": "1 produkt",

View File

@@ -0,0 +1,61 @@
export default {
"questionTitle": "Otázka ohledně produktu",
"questionSubtitle": "Máte otázku ohledně tohoto produktu? Rádi vám pomůžeme.",
"questionSuccess": "Děkujeme za vaši otázku! Ozveme se vám co nejdříve.",
"nameLabel": "Jméno",
"namePlaceholder": "Vaše jméno",
"emailLabel": "Email",
"emailPlaceholder": "vas.email@priklad.cz",
"questionLabel": "Vaše otázka",
"questionPlaceholder": "Popište svou otázku ohledně tohoto produktu...",
"photosLabelQuestion": "Přiložte fotografie k vaší otázce (volitelné)",
"submitQuestion": "Odeslat otázku",
"sending": "Odesílání...",
"ratingTitle": "Ohodnoťte produkt",
"ratingSubtitle": "Podělte se o své zkušenosti s tímto produktem a pomozte ostatním zákazníkům s rozhodnutím.",
"ratingSuccess": "Děkujeme za vaši recenzi! Bude zveřejněna po ověření.",
"emailHelper": "Váš email nebude zveřejněn",
"ratingLabel": "Hodnocení *",
"pleaseRate": "Prosím ohodnoťte",
"ratingStars": "{{rating}} z 5 hvězdiček",
"reviewLabel": "Vaše recenze (volitelné)",
"reviewPlaceholder": "Popište své zkušenosti s tímto produktem...",
"photosLabelRating": "Přiložte fotografie k vaší recenzi (volitelné)",
"submitRating": "Odeslat recenzi",
"errorGeneric": "Došlo k chybě",
"errorPhotos": "Chyba při zpracování fotografií",
"availabilityTitle": "Požádejte o dostupnost",
"availabilitySubtitle": "Tento produkt momentálně není dostupný. Rádi vás informujeme, jakmile bude opět skladem.",
"availabilitySuccessEmail": "Děkujeme za váš požadavek! Jakmile bude produkt opět dostupný, budeme vás informovat e-mailem.",
"availabilitySuccessTelegram": "Děkujeme za váš požadavek! Jakmile bude produkt opět dostupný, budeme vás informovat přes Telegram.",
"notificationMethodLabel": "Jak chcete být informováni?",
"telegramBotLabel": "Telegram Bot",
"telegramIdLabel": "Telegram ID",
"telegramPlaceholder": "@vaseTelegramJmeno nebo Telegram ID",
"telegramHelper": "Zadejte své uživatelské jméno na Telegramu (s @) nebo Telegram ID",
"messageLabel": "Zpráva (volitelné)",
"messagePlaceholder": "Další informace nebo otázky...",
"submitAvailability": "Požádat o dostupnost",
"photoUploadSelect": "Vybrat fotografie",
"photoUploadErrorMaxFiles": "Maximálně {{max}} souborů povoleno",
"photoUploadErrorFileType": "Jsou povoleny pouze obrazové soubory (JPEG, PNG, GIF, WebP)",
"photoUploadErrorFileSize": "Soubor je příliš velký. Maximum: {{maxSize}}MB",
"photoUploadSelectedFiles": "Vybráno {{count}} souborů",
"photoUploadCompressed": "(komprimováno pro nahrání)",
"photoUploadRemove": "Odstranit obrázek",
"photoUploadLabelDefault": "Přiložit fotografie (volitelné)",
"shareTitle": "Sdílet",
"shareEmbed": "Vložit",
"shareCopyLink": "Kopírovat odkaz",
"shareSuccessEmbed": "Kód pro vložení zkopírován do schránky!",
"shareErrorEmbed": "Chyba při kopírování kódu pro vložení",
"shareSuccessLink": "Odkaz zkopírován do schránky!",
"shareWhatsAppText": "Podívejte se na tento produkt: {{name}}",
"shareTelegramText": "Podívejte se na tento produkt: {{name}}",
"shareEmailSubject": "Doporučení produktu",
"shareEmailBody": "Dobrý den,\n\nrád bych vám doporučil tento produkt:\n\n{{name}}\n{{url}}\n\nS pozdravem"
};

View File

@@ -2,4 +2,5 @@ export default {
"placeholder": "Můžeš se mě zeptat na odrůdy konopí...", "placeholder": "Můžeš se mě zeptat na odrůdy konopí...",
"recording": "Probíhá nahrávání...", "recording": "Probíhá nahrávání...",
"searchProducts": "Hledat produkty...", "searchProducts": "Hledat produkty...",
"searchResultsFor": "Výsledky hledání pro: \"{{query}}\"",
}; };

View File

@@ -1,6 +1,7 @@
export default { export default {
"seeds": "Semena", "seeds": "Semena",
"stecklinge": "Řízky", "stecklinge": "Řízky",
"konfigurator": "Konfigurátor",
"oilPress": "Půjčit lis na olej", "oilPress": "Půjčit lis na olej",
"thcTest": "THC test", "thcTest": "THC test",
"address1": "Trachenberger Straße 14", "address1": "Trachenberger Straße 14",

View File

@@ -1,5 +1,5 @@
export default { export default {
"home": "Kvalitní semena a řízky konopí", "home": "Kvalitní semena konopí",
"aktionen": "Aktuální akce a nabídky", "aktionen": "Aktuální akce a nabídky",
"filiale": "Naše prodejna v Drážďanech", "filiale": "Naše prodejna v Drážďanech"
}; };

View File

@@ -5,6 +5,7 @@ export default {
"profile": "Profil", "profile": "Profil",
"email": "E-Mail", "email": "E-Mail",
"password": "Passwort", "password": "Passwort",
"newPassword": "Neues Passwort",
"confirmPassword": "Passwort bestätigen", "confirmPassword": "Passwort bestätigen",
"forgotPassword": "Passwort vergessen?", "forgotPassword": "Passwort vergessen?",
"loginWithGoogle": "Mit Google anmelden", "loginWithGoogle": "Mit Google anmelden",
@@ -13,6 +14,7 @@ export default {
"privacyPolicy": "Datenschutzbestimmungen", "privacyPolicy": "Datenschutzbestimmungen",
"passwordMinLength": "Das Passwort muss mindestens 8 Zeichen lang sein", "passwordMinLength": "Das Passwort muss mindestens 8 Zeichen lang sein",
"newPasswordMinLength": "Das neue Passwort muss mindestens 8 Zeichen lang sein", "newPasswordMinLength": "Das neue Passwort muss mindestens 8 Zeichen lang sein",
"backToHome": "Zurück zur Startseite",
"menu": { "menu": {
"profile": "Profil", "profile": "Profil",
"myProfile": "Mein Profil", "myProfile": "Mein Profil",
@@ -21,5 +23,28 @@ export default {
"settings": "Einstellungen", "settings": "Einstellungen",
"adminDashboard": "Admin Dashboard", "adminDashboard": "Admin Dashboard",
"adminUsers": "Admin Users" "adminUsers": "Admin Users"
},
"resetPassword": {
"title": "Passwort zurücksetzen",
"button": "Passwort zurücksetzen",
"success": "Ihr Passwort wurde erfolgreich zurückgesetzt! Sie werden in Kürze zur Anmeldung weitergeleitet...",
"invalidToken": "Kein gültiger Token gefunden. Bitte verwenden Sie den Link aus Ihrer E-Mail.",
"error": "Fehler beim Zurücksetzen des Passworts",
"emailSent": "Ein Link zum Zurücksetzen des Passworts wurde an Ihre E-Mail-Adresse gesendet.",
"emailError": "Fehler beim Senden der E-Mail"
},
"errors": {
"fillAllFields": "Bitte füllen Sie alle Felder aus",
"invalidEmail": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"passwordsNotMatch": "Die Passwörter stimmen nicht überein",
"passwordsNotMatchShort": "Passwörter stimmen nicht überein",
"enterEmail": "Bitte geben Sie Ihre E-Mail-Adresse ein",
"loginFailed": "Anmeldung fehlgeschlagen",
"registerFailed": "Registrierung fehlgeschlagen",
"googleLoginFailed": "Google-Anmeldung fehlgeschlagen",
"emailExists": "Ein Benutzer mit dieser E-Mail-Adresse existiert bereits. Bitte verwenden Sie eine andere E-Mail-Adresse oder melden Sie sich an."
},
"success": {
"registerComplete": "Registrierung erfolgreich. Sie können sich jetzt anmelden."
} }
}; };

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