Compare commits

...

40 Commits

Author SHA1 Message Date
sebseb7
66a1efd87b feat: add Hersteller page with manufacturer data fetching and SEO support 2026-04-21 16:04:11 +02:00
sebseb7
2c0b7aa84d clickable Herstellerkarousel part 1 2026-04-13 19:47:04 +02:00
sebseb7
a56377a1fd you have to start somewhere 2026-04-13 19:43:34 +02:00
sebseb7
468eb1c3ae button arrange 2026-04-13 19:43:17 +02:00
sebseb7
e699a8003f feat: implement kiosk mode functionality and update UI elements accordingly 2026-04-11 22:58:25 +02:00
sebseb7
b5256d6597 feat: add Outfit Variable font and update global typography settings 2026-04-01 15:13:29 +02:00
sebseb7
18c528302d correct ai assy language 2026-03-31 10:19:47 +02:00
sebseb7
9054c8d2fd Formatting fixed that affected the Czech version. 2026-03-31 10:00:13 +02:00
sebseb7
8bce10e61b refactor: Improve git commit hash retrieval method and enhance webpack configuration for better lazy loading of components 2026-03-28 18:21:39 +01:00
sebseb7
2540d00c8e Revert "refactor: Update webpack configuration to improve git commit hash retrieval and enhance lazy loading of components for better performance"
This reverts commit 52c9888a6a.
2026-03-28 18:04:55 +01:00
sebseb7
52c9888a6a refactor: Update webpack configuration to improve git commit hash retrieval and enhance lazy loading of components for better performance 2026-03-28 17:56:12 +01:00
sebseb7
ab55761411 refactor: Update JSON-LD itemListElement structure in category SEO to include URL field for better clarity and compliance with SEO standards 2026-03-28 17:37:38 +01:00
sebseb7
5e5a733d36 refactor: Simplify JSON-LD generation in category SEO by removing unnecessary product details and focusing on URLs for improved compliance with Google guidelines 2026-03-28 17:34:00 +01:00
sebseb7
36360df648 feat: Add generateCategoryMetaTags function for enhanced SEO in category pages and integrate it into prerender process 2026-03-28 17:21:43 +01:00
sebseb7
21d86565f1 refactor: Enhance JSON-LD structure in category and product generation functions for improved SEO and consistency across URLs 2026-03-28 17:10:14 +01:00
sebseb7
c503de3a11 refactor: Update organization JSON-LD structure to include specific subtype (GardenStore) for better merchant categorization 2026-03-28 16:57:45 +01:00
sebseb7
2ced182570 refactor: Update JSON-LD generation in generateProductJsonLd function to separate product and breadcrumb scripts for improved SEO structure 2026-03-28 16:30:39 +01:00
sebseb7
52c62541b0 fix: Adjust ProductFilters component to clear min-height on mobile screens for improved responsiveness 2026-03-27 01:37:14 +01:00
sebseb7
7202c43dfa feat: Add LinkTelegram page and routing; enhance login flow to support redirection from linkTelegram 2026-03-27 01:29:04 +01:00
sebseb7
5b7f0f788c refactor: Centralize Socket.IO client options in config for improved maintainability and consistency across prerender scripts 2026-03-26 21:57:50 +01:00
sebseb7
47ed2ec231 refactor: Simplify unsubscribe functionality in articlePush and categoryPush by enforcing required identifiers in request body 2026-03-26 21:35:26 +01:00
sebseb7
188c883450 feat: Implement push subscription event handling in AddToCartButton and ProductFilters components; enhance article and category unsubscribe functionality with optional identifiers 2026-03-26 21:28:49 +01:00
sebseb7
ba66b82b2b feat: Add grace period for user activity handling in IdleMainPagesSlideshow to prevent premature slideshow reset after navigation 2026-03-26 21:24:46 +01:00
sebseb7
defe3c9521 feat: Integrate IdleMainPagesSlideshow component into App.js and update links in MainPageLayout for improved navigation to articles 2026-03-26 21:10:46 +01:00
sebseb7
de8e59f1bb feat: Enhance ChatAssistant and ProductFilters components with dynamic privacy prompts and category push notification support; update localization strings for new article notifications across multiple languages 2026-03-26 20:51:28 +01:00
sebseb7
4b634414e5 feat: Update MainPageLayout with enhanced star layer effects and improved drop-shadow filters; refine star polygon coordinates for better visual consistency during animations 2026-03-26 16:28:17 +01:00
sebseb7
e8517372f2 feat: Enhance MainPageLayout with improved star decoration animations and initial fill colors; add new teal star layers and update localization strings across multiple languages for better user experience 2026-03-26 15:24:00 +01:00
sebseb7
c6ea6e70fe refactor: Update layout and styling in various components for improved responsiveness and visual consistency on mobile; adjust zIndex and position properties, and enhance navigation handling in ProductDetailPage 2026-03-26 14:59:11 +01:00
sebseb7
d37eb950d1 fix: Update ProductList component to improve responsiveness by adjusting display property for mobile and small screens 2026-03-26 14:37:08 +01:00
sebseb7
665e48e868 feat: Enhance Filter component with collapsible options and clear filter functionality; improve responsiveness and UI feedback on mobile 2026-03-26 14:32:06 +01:00
sebseb7
e0c6d47d98 feat: Enhance ChatAssistant component with dynamic privacy prompt and localization support; update various UI elements for improved accessibility and user experience
Fix product card width on mobile.
2026-03-26 14:21:03 +01:00
sebseb7
bfeb5be1d5 feat: Refactor product catalog output to dynamically generate page links and improve readability of available products 2026-03-26 12:22:21 +01:00
sebseb7
1897ceb7c5 feat: Enhance image processing in data-fetching and update SEO meta tags for product images; add Telegram assistant link in ChatAssistant component with localization support 2026-03-26 11:56:07 +01:00
sebseb7
c5dce64ac9 feat: fix star decoration layers to MainPageLayout and refactor star polygon usage in Product component for improved visual consistency 2026-03-25 17:16:12 +01:00
sebseb7
9e77deb4f8 feat: Implement socket error telemetry in SocketManager to enhance error reporting for socket.io events 2026-03-25 11:22:37 +01:00
sebseb7
5515a59fa1 feat: Refactor Header and CategoryList components to improve category navigation handling and enhance active state management based on pathname 2026-03-25 11:17:24 +01:00
sebseb7
91388244d8 feat: Update legal text for user consent and data processing across multiple locales, ensuring clarity and compliance with regulations 2026-03-25 10:59:10 +01:00
sebseb7
e1a545e4a8 i18n 2026-03-25 10:58:55 +01:00
sebseb7
c8af0bb57a feat: Update legal text for Google login and order processing across multiple locales, improving clarity and compliance with data protection regulations 2026-03-25 09:33:07 +01:00
sebseb7
8df2fc070c feat: Update legal text for AI chatbot across multiple locales, enhancing clarity and compliance with data protection regulations 2026-03-25 09:26:04 +01:00
799 changed files with 9602 additions and 7421 deletions

View File

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

1
docs/README.md Normal file
View File

@@ -0,0 +1 @@
src/components/MainPageLayout.js is the Main gues homepage.

282
docs/i18n.md Normal file
View File

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

View File

@@ -83,7 +83,7 @@ server {
default_type application/xml; default_type application/xml;
} }
location ~ ^/(datenschutz|impressum|batteriegesetzhinweise|widerrufsrecht|sitemap|agb|Kategorien|Konfigurator|404|profile|resetPassword|thc-test|filiale|aktionen|presseverleih|payment/success)(/|$) { location ~ ^/(datenschutz|impressum|batteriegesetzhinweise|widerrufsrecht|sitemap|agb|Kategorien|Konfigurator|404|profile|resetPassword|thc-test|linkTelegram|filiale|aktionen|presseverleih|payment/success)(/|$) {
types {} types {}
default_type text/html; default_type text/html;
} }

View File

@@ -6,6 +6,30 @@ import babelParser from '@babel/eslint-parser';
export default [ export default [
js.configs.recommended, js.configs.recommended,
{
files: ['**/*.cjs'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'commonjs',
globals: {
...globals.node,
},
},
rules: {
'no-unused-vars': 'warn',
//'no-console': 'warn',
'no-debugger': 'warn',
'no-alert': 'warn',
'no-unused-expressions': 'warn',
'no-var': 'warn',
'prefer-const': 'warn',
'no-trailing-spaces': 'warn',
'eqeqeq': ['warn', 'always'],
'no-empty': 'warn',
'no-eval': 'warn',
'no-script-url': 'warn',
},
},
{ {
files: ['**/*.{js,jsx}'], files: ['**/*.{js,jsx}'],
languageOptions: { languageOptions: {

10
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@fontsource-variable/outfit": "^5.2.8",
"@mui/icons-material": "^7.1.1", "@mui/icons-material": "^7.1.1",
"@mui/material": "^7.1.1", "@mui/material": "^7.1.1",
"@stripe/react-stripe-js": "^3.7.0", "@stripe/react-stripe-js": "^3.7.0",
@@ -2222,6 +2223,15 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
}, },
"node_modules/@fontsource-variable/outfit": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/outfit/-/outfit-5.2.8.tgz",
"integrity": "sha512-4oUDCZx/Tcz6HZP423w/niqEH31Gks5IsqHV2ZZz1qKHaVIZdj2f0/S1IK2n8jl6Xo0o3N+3RjNHlV9R73ozQA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",

View File

@@ -20,7 +20,8 @@
"translate:english": "node translate-i18n.js --only-english", "translate:english": "node translate-i18n.js --only-english",
"translate:skip-english": "node translate-i18n.js --skip-english", "translate:skip-english": "node translate-i18n.js --skip-english",
"translate:others": "node translate-i18n.js --skip-english", "translate:others": "node translate-i18n.js --skip-english",
"validate:products": "node scripts/validate-products-xml.cjs" "validate:products": "node scripts/validate-products-xml.cjs",
"i18n:check-keys": "node scripts/check-i18n-keys.mjs"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
@@ -29,6 +30,7 @@
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@fontsource-variable/outfit": "^5.2.8",
"@mui/icons-material": "^7.1.1", "@mui/icons-material": "^7.1.1",
"@mui/material": "^7.1.1", "@mui/material": "^7.1.1",
"@stripe/react-stripe-js": "^3.7.0", "@stripe/react-stripe-js": "^3.7.0",

View File

@@ -66,12 +66,7 @@ const renderSingleProduct = async (productSeoName) => {
const socketUrl = "http://127.0.0.1:9303"; const socketUrl = "http://127.0.0.1:9303";
console.log(`🔌 Connecting to socket at ${socketUrl}...`); console.log(`🔌 Connecting to socket at ${socketUrl}...`);
const socket = io(socketUrl, { const socket = io(socketUrl, config.socketIoClientOptions);
path: "/socket.io/",
transports: ["websocket"],
reconnection: false,
timeout: 10000,
});
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {

View File

@@ -125,11 +125,14 @@ const {
const { const {
generateProductMetaTags, generateProductMetaTags,
generateProductJsonLd, generateProductJsonLd,
generateCategoryMetaTags,
generateCategoryJsonLd, generateCategoryJsonLd,
generateHomepageMetaTags, generateHomepageMetaTags,
generateHomepageJsonLd, generateHomepageJsonLd,
generateSitemapJsonLd, generateSitemapJsonLd,
generateKonfiguratorMetaTags, generateKonfiguratorMetaTags,
generateHerstellerMetaTags,
generateHerstellerJsonLd,
generateXmlSitemap, generateXmlSitemap,
generateRobotsTxt, generateRobotsTxt,
generateProductsXml, generateProductsXml,
@@ -141,6 +144,7 @@ const {
const { const {
fetchCategoryProducts, fetchCategoryProducts,
fetchProductDetails, fetchProductDetails,
fetchManufacturers,
saveProductImages, saveProductImages,
saveCategoryImages, saveCategoryImages,
} = require("./prerender/data-fetching.cjs"); } = require("./prerender/data-fetching.cjs");
@@ -160,18 +164,14 @@ 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 PrerenderCategoriesPage = require("./src/PrerenderCategoriesPage.js").default;
const PrerenderHerstellerPage = require("./src/PrerenderHerstellerPage.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;
// Worker function for parallel product rendering // Worker function for parallel product rendering
const renderProductWorker = async (productSeoNames, workerId, progressCallback, categoryMap = {}) => { const renderProductWorker = async (productSeoNames, workerId, progressCallback, categoryMap = {}) => {
const socketUrl = "http://127.0.0.1:9303"; const socketUrl = "http://127.0.0.1:9303";
const workerSocket = io(socketUrl, { const workerSocket = io(socketUrl, config.socketIoClientOptions);
path: "/socket.io/",
transports: ["websocket"],
reconnection: false,
timeout: 10000,
});
return new Promise((resolve) => { return new Promise((resolve) => {
let processedCount = 0; let processedCount = 0;
@@ -380,6 +380,29 @@ const renderApp = async (categoryData, socket) => {
global.categoryCache = {}; global.categoryCache = {};
} }
// Fetch manufacturers data for Hersteller page
let manufacturerData = null;
console.log("🏭 [renderApp] Starting manufacturer fetch...");
console.log("🏭 [renderApp] socket exists:", !!socket);
console.log("🏭 [renderApp] socket.connected:", socket ? socket.connected : "N/A");
if (!socket) {
console.error("🏭 [renderApp] FATAL: No socket - cannot fetch manufacturers!");
} else if (!socket.connected) {
console.error("🏭 [renderApp] FATAL: Socket not connected - cannot fetch manufacturers!");
} else {
try {
console.log("🏭 [renderApp] Calling fetchManufacturers...");
manufacturerData = await fetchManufacturers(socket);
console.log("🏭 [renderApp] ✅ Fetched " + manufacturerData.length + " manufacturers");
} catch (error) {
console.error("🏭 [renderApp] ❌ Failed to fetch manufacturers:", error.message);
manufacturerData = [];
}
}
console.log("🏭 [renderApp] Final manufacturerData:", manufacturerData ? (manufacturerData.length + " items") : "null");
// Helper to call renderPage with config // Helper to call renderPage with config
const render = ( const render = (
component, component,
@@ -387,8 +410,10 @@ const renderApp = async (categoryData, socket) => {
filename, filename,
description, description,
metaTags = "", metaTags = "",
needsRouter = false needsRouter = false,
manufacturerDataForPage = null
) => { ) => {
console.log(" 📦 [render helper] Calling renderPage for", filename, "with manufacturerData:", manufacturerDataForPage ? (manufacturerDataForPage.length + " items") : "null");
return renderPage( return renderPage(
component, component,
location, location,
@@ -396,7 +421,10 @@ const renderApp = async (categoryData, socket) => {
description, description,
metaTags, metaTags,
needsRouter, needsRouter,
config config,
false, // suppressLogs
null, // productData
manufacturerDataForPage // manufacturerData - 10th parameter!
); );
}; };
@@ -429,6 +457,11 @@ const renderApp = async (categoryData, socket) => {
const resetPasswordPath = path.resolve(__dirname, config.outputDir, "resetPassword"); const resetPasswordPath = path.resolve(__dirname, config.outputDir, "resetPassword");
fs.copyFileSync(indexPath, resetPasswordPath); fs.copyFileSync(indexPath, resetPasswordPath);
console.log(`✅ Copied index.html to ${resetPasswordPath}`); console.log(`✅ Copied index.html to ${resetPasswordPath}`);
// Copy index.html to linkTelegram (no file extension) for SPA routing
const linkTelegramPath = path.resolve(__dirname, config.outputDir, "linkTelegram");
fs.copyFileSync(indexPath, linkTelegramPath);
console.log(`✅ Copied index.html to ${linkTelegramPath}`);
} }
// Render static pages // Render static pages
@@ -473,6 +506,13 @@ const renderApp = async (categoryData, socket) => {
description: "Categories page", description: "Categories page",
needsCategoryData: true, needsCategoryData: true,
}, },
{
component: PrerenderHerstellerPage,
path: "/Hersteller",
filename: "Hersteller",
description: "Hersteller page",
needsManufacturerData: 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" },
{ {
@@ -491,8 +531,17 @@ const renderApp = async (categoryData, socket) => {
let staticPagesRendered = 0; let staticPagesRendered = 0;
for (const page of staticPages) { for (const page of staticPages) {
// Pass category data as props if needed // Pass category and manufacturer data as props if needed
const pageProps = page.needsCategoryData ? { categoryData } : null; let pageProps = null;
if (page.needsCategoryData || page.needsManufacturerData) {
pageProps = {};
if (page.needsCategoryData) {
pageProps.categoryData = categoryData;
}
if (page.needsManufacturerData) {
pageProps.manufacturerData = manufacturerData;
}
}
const pageComponent = React.createElement(page.component, pageProps); const pageComponent = React.createElement(page.component, pageProps);
let metaTags = ""; let metaTags = "";
@@ -508,13 +557,25 @@ const renderApp = async (categoryData, socket) => {
metaTags = konfiguratorMetaTags; metaTags = konfiguratorMetaTags;
} }
// Special handling for Hersteller page to include SEO tags
if (page.filename === "Hersteller") {
const manufacturerCount = manufacturerData ? manufacturerData.length : 0;
const herstellerMetaTags = generateHerstellerMetaTags(shopConfig.baseUrl, shopConfig, manufacturerCount);
const herstellerJsonLd = generateHerstellerJsonLd(shopConfig.baseUrl, shopConfig);
metaTags = herstellerMetaTags + "\n" + herstellerJsonLd;
}
// Pass manufacturerData only for Hersteller page
const pageManufacturerData = page.needsManufacturerData ? manufacturerData : null;
const success = render( const success = render(
pageComponent, pageComponent,
page.path, page.path,
page.filename, page.filename,
page.description, page.description,
metaTags, metaTags,
true true,
pageManufacturerData
); );
if (success) { if (success) {
staticPagesRendered++; staticPagesRendered++;
@@ -621,19 +682,25 @@ const renderApp = async (categoryData, socket) => {
const filename = `Kategorie/${category.seoName}`; const filename = `Kategorie/${category.seoName}`;
const location = `/Kategorie/${category.seoName}`; const location = `/Kategorie/${category.seoName}`;
const description = `Category "${category.name}" (ID: ${category.id})`; const description = `Category "${category.name}" (ID: ${category.id})`;
const categoryMetaTags = generateCategoryMetaTags(
category,
shopConfig.baseUrl,
shopConfig
);
const categoryJsonLd = generateCategoryJsonLd( const categoryJsonLd = generateCategoryJsonLd(
category, category,
productData?.products || [], productData?.products || [],
shopConfig.baseUrl, shopConfig.baseUrl,
shopConfig shopConfig
); );
const combinedCategoryHead = categoryMetaTags + "\n" + categoryJsonLd;
const success = render( const success = render(
categoryComponent, categoryComponent,
location, location,
filename, filename,
description, description,
categoryJsonLd, combinedCategoryHead,
true true
); );
if (success) { if (success) {
@@ -863,12 +930,7 @@ const fetchCategoryDataAndRender = () => {
process.exit(1); process.exit(1);
}, 15000); }, 15000);
const socket = io(socketUrl, { const socket = io(socketUrl, config.socketIoClientOptions);
path: "/socket.io/",
transports: ["websocket"],
reconnection: false,
timeout: 10000,
});
socket.on("connect", () => { socket.on("connect", () => {
console.log('Socket connected. Emitting "categoryList"...'); console.log('Socket connected. Emitting "categoryList"...');

View File

@@ -69,11 +69,21 @@ const globalCssCollection = new Set();
// Get webpack entrypoints // Get webpack entrypoints
const webpackEntrypoints = getWebpackEntrypoints(); const webpackEntrypoints = getWebpackEntrypoints();
/** Socket.IO client options for prerender scripts: skip backend connection counters (balanced on disconnect). */
const socketIoClientOptions = {
path: "/socket.io/",
transports: ["websocket"],
reconnection: false,
timeout: 10000,
auth: { prerender: true },
};
module.exports = { module.exports = {
isProduction, isProduction,
outputDir, outputDir,
getWebpackEntrypoints, getWebpackEntrypoints,
globalCss, globalCss,
globalCssCollection, globalCssCollection,
webpackEntrypoints webpackEntrypoints,
socketIoClientOptions,
}; };

View File

@@ -42,6 +42,7 @@ const fetchCategoryProducts = (socket, categoryId) => {
"getCategoryProducts", "getCategoryProducts",
{ {
full: true, full: true,
nocount: true,
categoryId: categoryId:
categoryId === "neu" || categoryId === "bald" categoryId === "neu" || categoryId === "bald"
? categoryId ? categoryId
@@ -139,6 +140,38 @@ const fetchCategoryImage = (socket, categoryId) => {
}); });
}; };
const fetchManufacturers = (socket) => {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("Timeout fetching manufacturers"));
}, 10000);
socket.emit("getHerstellerImages", {}, (response) => {
clearTimeout(timeout);
if (response?.success && Array.isArray(response.manufacturers)) {
// Filter and format manufacturers similar to HerstellerPage.js
const manufacturers = response.manufacturers
.filter(m => m.imageBuffer)
.map(m => ({
id: m.id,
name: m.name || '',
slug: m.slug || '',
imageBuffer: m.imageBuffer,
}))
.sort((a, b) => (a.name || '').localeCompare(b.name || '', undefined, { sensitivity: 'base' }));
resolve(manufacturers);
} else {
reject(
new Error(
`Invalid manufacturers response: ${JSON.stringify(response)}`
)
);
}
});
});
};
const saveProductImages = async (socket, products, categoryName, outputDir) => { const saveProductImages = async (socket, products, categoryName, outputDir) => {
if (!products || products.length === 0) return; if (!products || products.length === 0) return;
@@ -186,43 +219,64 @@ const saveProductImages = async (socket, products, categoryName, outputDir) => {
.filter((id) => id); .filter((id) => id);
if (imageIds.length > 0) { if (imageIds.length > 0) {
// Process first image for each product // Process first image for each product — store AVIF + JPEG (e.g. for Twitter / social)
const bildId = parseInt(imageIds[0]); const bildId = parseInt(imageIds[0]);
const estimatedFilename = `prod${bildId}.avif`; // We'll generate a filename based on the ID const avifFilename = `prod${bildId}.avif`;
const jpegFilename = `prod${bildId}.jpg`;
const avifPath = path.join(assetsPath, avifFilename);
const jpegPath = path.join(assetsPath, jpegFilename);
const imagePath = path.join(assetsPath, estimatedFilename); if (fs.existsSync(avifPath) && fs.existsSync(jpegPath)) {
// Skip if image already exists
if (fs.existsSync(imagePath)) {
imagesSkipped++; imagesSkipped++;
continue; continue;
} }
const writeAvifAndJpegFromBuffer = async (buf) => {
if (!fs.existsSync(avifPath)) {
await sharp(buf).avif().toFile(avifPath);
}
if (!fs.existsSync(jpegPath)) {
await sharp(buf)
.jpeg({ quality: 85, mozjpeg: true })
.toFile(jpegPath);
}
};
try { try {
if (fs.existsSync(avifPath) && !fs.existsSync(jpegPath)) {
await sharp(avifPath)
.jpeg({ quality: 85, mozjpeg: true })
.toFile(jpegPath);
} else if (!fs.existsSync(avifPath) && fs.existsSync(jpegPath)) {
await sharp(jpegPath).avif().toFile(avifPath);
} else {
const imageBuffer = await fetchProductImage(socket, bildId); const imageBuffer = await fetchProductImage(socket, bildId);
const buf = Buffer.from(imageBuffer);
// If overlay exists, apply it to the image // If overlay exists, apply it to the image
if (false && fs.existsSync(overlayPath)) { if (false && fs.existsSync(overlayPath)) {
try { try {
// Get image dimensions to center the overlay const baseImage = sharp(buf);
const baseImage = sharp(Buffer.from(imageBuffer));
const baseMetadata = await baseImage.metadata(); const baseMetadata = await baseImage.metadata();
const overlaySize = Math.min(baseMetadata.width, baseMetadata.height) * 0.4; const overlaySize =
Math.min(baseMetadata.width, baseMetadata.height) * 0.4;
// Resize overlay to 20% of base image size and get its buffer
const resizedOverlayBuffer = await sharp(overlayPath) const resizedOverlayBuffer = await sharp(overlayPath)
.resize({ .resize({
width: Math.round(overlaySize), width: Math.round(overlaySize),
height: Math.round(overlaySize), height: Math.round(overlaySize),
fit: 'contain', // Keep full overlay visible fit: "contain",
background: { r: 0, g: 0, b: 0, alpha: 0 } // Transparent background instead of black bars background: { r: 0, g: 0, b: 0, alpha: 0 },
}) })
.toBuffer(); .toBuffer();
// Calculate center position for the resized overlay const centerX = Math.floor(
const centerX = Math.floor((baseMetadata.width - overlaySize) / 2); (baseMetadata.width - overlaySize) / 2
const centerY = Math.floor((baseMetadata.height - overlaySize) / 2); );
const centerY = Math.floor(
(baseMetadata.height - overlaySize) / 2
);
const processedImageBuffer = await baseImage const processedImageBuffer = await baseImage
.composite([ .composite([
@@ -230,36 +284,33 @@ const saveProductImages = async (socket, products, categoryName, outputDir) => {
input: resizedOverlayBuffer, input: resizedOverlayBuffer,
top: centerY, top: centerY,
left: centerX, left: centerX,
blend: "multiply", // Darkens the image, visible on all backgrounds blend: "multiply",
opacity: 0.3, opacity: 0.3,
}, },
]) ])
.avif() // Ensure output is AVIF
.toBuffer(); .toBuffer();
fs.writeFileSync(imagePath, processedImageBuffer); await writeAvifAndJpegFromBuffer(processedImageBuffer);
console.log( console.log(
` ✅ Applied centered inverted sh.avif overlay to ${estimatedFilename}` ` ✅ Applied overlay → ${avifFilename} + ${jpegFilename}`
); );
} catch (overlayError) { } catch (overlayError) {
console.log( console.log(
` ⚠️ Failed to apply overlay to ${estimatedFilename}: ${overlayError.message}` ` ⚠️ Failed to apply overlay to prod${bildId}: ${overlayError.message}`
); );
// Fallback: save without overlay await writeAvifAndJpegFromBuffer(buf);
fs.writeFileSync(imagePath, Buffer.from(imageBuffer));
} }
} else { } else {
// Save without overlay if overlay file doesn't exist await writeAvifAndJpegFromBuffer(buf);
fs.writeFileSync(imagePath, Buffer.from(imageBuffer)); }
} }
imagesSaved++; imagesSaved++;
// Small delay to avoid overwhelming server
await new Promise((resolve) => setTimeout(resolve, 50)); await new Promise((resolve) => setTimeout(resolve, 50));
} catch (error) { } catch (error) {
console.log( console.log(
` ⚠️ Failed to fetch image ${estimatedFilename} (ID: ${bildId}): ${error.message}` ` ⚠️ Failed to fetch/save prod${bildId} (${avifFilename} / ${jpegFilename}): ${error.message}`
); );
} }
} }
@@ -364,6 +415,7 @@ module.exports = {
fetchProductDetails, fetchProductDetails,
fetchProductImage, fetchProductImage,
fetchCategoryImage, fetchCategoryImage,
fetchManufacturers,
saveProductImages, saveProductImages,
saveCategoryImages, saveCategoryImages,
}; };

View File

@@ -18,7 +18,8 @@ const renderPage = (
needsRouter = false, needsRouter = false,
config, config,
suppressLogs = false, suppressLogs = false,
productData = null productData = null,
manufacturerData = null
) => { ) => {
const { const {
isProduction, isProduction,
@@ -171,22 +172,44 @@ const renderPage = (
</script> </script>
`; `;
// @note Create script to populate window.productCache with ONLY the static category tree // @note Create script to populate window.productCache with static category tree and herstellerImages
let productCacheScript = ''; let productCacheScript = '';
if (typeof global !== "undefined" && global.window && global.window.categoryCache) { const hasCategoryCache = typeof global !== "undefined" && global.window && global.window.categoryCache;
// Only include the static categoryTree_209, not any dynamic data that gets added during rendering const hasManufacturerData = manufacturerData && manufacturerData.length > 0;
const staticCache = {};
if (global.window.categoryCache["209_de"]) { console.log(" 📦 [" + filename + "] manufacturerData =", manufacturerData ? (manufacturerData.length + " items") : "null");
staticCache["209_de"] = global.window.categoryCache["209_de"];
if (hasCategoryCache || hasManufacturerData) {
const cacheData = {};
// Add static categoryTree_209
if (hasCategoryCache && global.window.categoryCache["209_de"]) {
cacheData["209_de"] = global.window.categoryCache["209_de"];
} }
const staticCacheData = JSON.stringify(staticCache); // Add herstellerImages
productCacheScript = ` if (hasManufacturerData) {
<script> cacheData.herstellerImages = manufacturerData;
// Populate window.categoryCache with static category tree only }
window.categoryCache = ${staticCacheData};
</script> const cacheDataJson = JSON.stringify(cacheData);
`; let extraScripts = '';
if (hasCategoryCache && cacheData["209_de"]) {
const categoryCacheJson = JSON.stringify({ "209_de": cacheData["209_de"] });
extraScripts += 'window.categoryCache = ' + categoryCacheJson + ';';
}
if (hasManufacturerData) {
const herstellerJson = JSON.stringify(manufacturerData);
extraScripts += 'window.herstellerImages = ' + herstellerJson + ';';
}
productCacheScript = '<script>' +
'if (!window.productCache) { window.productCache = {}; }' +
'Object.assign(window.productCache, ' + cacheDataJson + ');' +
extraScripts +
'</script>';
} }
// Create script to populate window.productDetailCache for individual product pages // Create script to populate window.productDetailCache for individual product pages

View File

@@ -1,3 +1,43 @@
/** Safe for double-quoted HTML attributes */
const escAttr = (str) =>
String(str ?? "")
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;");
/**
* Head tags for prerendered category URLs — explicit canonical per /Kategorie/{slug}
* so Google does not cluster different listing pages (e.g. neu vs Seeds) as duplicates.
*/
const generateCategoryMetaTags = (category, baseUrl, config) => {
const root = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
const categoryUrl = `${root}/Kategorie/${category.seoName}`;
const name = category.name || `Kategorie ${category.seoName}`;
const site = config.siteName || config.brandName;
const desc = `${name} bei ${config.brandName}: Growshop-Sortiment online kaufen. Schnelle Lieferung, Laden Dresden.`;
const descShort = desc.length > 160 ? `${desc.slice(0, 157)}...` : desc;
const e = escAttr;
const logoUrl =
config.images && config.images.logo
? `${root}${config.images.logo}`
: `${root}/assets/images/nopicture.jpg`;
return `
<meta name="description" content="${e(descShort)}">
<meta property="og:title" content="${e(`${name} | ${site}`)}">
<meta property="og:description" content="${e(descShort)}">
<meta property="og:url" content="${categoryUrl}">
<meta property="og:type" content="website">
<meta property="og:image" content="${e(logoUrl)}">
<meta property="og:site_name" content="${e(site)}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${e(`${name} | ${site}`)}">
<meta name="twitter:description" content="${e(descShort)}">
<meta name="robots" content="index, follow">
<link rel="canonical" href="${categoryUrl}">
`;
};
const generateCategoryJsonLd = (category, products = [], baseUrl, config) => { const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
// 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, 924, 923, 922, 921, 916, 278, 259, 258]; const skipCategoryIds = [689, 706, 709, 711, 714, 748, 749, 896, 710, 924, 923, 922, 921, 916, 278, 259, 258];
@@ -7,27 +47,49 @@ const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
return ''; return '';
} }
const categoryUrl = `${baseUrl}/Kategorie/${category.seoName}`; const root = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
const categoryUrl = `${root}/Kategorie/${category.seoName}`;
// Calculate price valid date (current date + 3 months) const id = {
const priceValidDate = new Date(); business: `${root}#business`,
priceValidDate.setMonth(priceValidDate.getMonth() + 3); website: `${root}#website`,
const priceValidUntil = priceValidDate.toISOString().split("T")[0]; breadcrumb: `${categoryUrl}#breadcrumb`,
itemList: `${categoryUrl}#itemlist`,
};
const jsonLd = { const logoUrl =
"@context": "https://schema.org/", config.images && config.images.logo
"@type": "CollectionPage", ? `${root}${config.images.logo}`
name: category.name, : undefined;
url: categoryUrl,
description: `${category.name} - Entdecken Sie unsere Auswahl an hochwertigen Produkten`, const businessNode = {
breadcrumb: { "@id": id.business,
"@type": ["GardenStore", "LocalBusiness", "Organization"],
name: config.brandName,
url: root,
...(logoUrl && {
logo: { "@type": "ImageObject", url: logoUrl },
image: { "@type": "ImageObject", url: logoUrl },
}),
};
const websiteNode = {
"@id": id.website,
"@type": "WebSite",
name: config.siteName || config.brandName,
url: root,
publisher: { "@id": id.business },
};
const breadcrumbNode = {
"@id": id.breadcrumb,
"@type": "BreadcrumbList", "@type": "BreadcrumbList",
itemListElement: [ itemListElement: [
{ {
"@type": "ListItem", "@type": "ListItem",
position: 1, position: 1,
name: "Home", name: "Home",
item: baseUrl, item: root,
}, },
{ {
"@type": "ListItem", "@type": "ListItem",
@@ -36,95 +98,52 @@ const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
item: categoryUrl, item: categoryUrl,
}, },
], ],
},
}; };
// Add product list if products are available const collectionPageNode = {
if (products && products.length > 0) { "@id": categoryUrl,
jsonLd.mainEntity = { "@type": "CollectionPage",
name: category.name,
url: categoryUrl,
description: `${category.name} - Entdecken Sie unsere Auswahl an hochwertigen Produkten`,
isPartOf: { "@id": id.website },
breadcrumb: { "@id": id.breadcrumb },
};
const graph = [businessNode, websiteNode, breadcrumbNode, collectionPageNode];
// ItemList: URLs only — full Product/Offer markup belongs on each /Artikel/… page (Google guidelines).
const withUrls = (products || []).filter((p) => p && p.seoName);
if (withUrls.length > 0) {
collectionPageNode.mainEntity = { "@id": id.itemList };
graph.push({
"@id": id.itemList,
"@type": "ItemList", "@type": "ItemList",
numberOfItems: products.length, numberOfItems: withUrls.length,
itemListElement: products.slice(0, 20).map((product, index) => ({ itemListElement: withUrls.map((product, index) => {
const productPageUrl = `${root}/Artikel/${product.seoName}`;
return {
"@type": "ListItem", "@type": "ListItem",
position: index + 1, position: index + 1,
item: { url: productPageUrl,
"@type": "Product", item: productPageUrl,
name: product.name,
url: `${baseUrl}/Artikel/${product.seoName}`,
image:
product.pictureList && product.pictureList.trim()
? `${baseUrl}/assets/images/prod${product.pictureList
.split(",")[0]
.trim()}.avif`
: `${baseUrl}/assets/images/nopicture.jpg`,
description: product.description
? product.description.replace(/<[^>]*>/g, "").substring(0, 200)
: `${product.name} - Hochwertiges Growshop Produkt`,
sku: product.articleNumber || product.seoName,
brand: {
"@type": "Brand",
name: product.manufacturer || config.brandName,
},
offers: {
"@type": "Offer",
url: `${baseUrl}/Artikel/${product.seoName}`,
price: product.price && !isNaN(product.price) ? product.price.toString() : "0.00",
priceCurrency: config.currency,
priceValidUntil: priceValidUntil,
availability: product.available
? "https://schema.org/InStock"
: "https://schema.org/OutOfStock",
seller: {
"@type": "Organization",
name: config.brandName,
},
itemCondition: "https://schema.org/NewCondition",
hasMerchantReturnPolicy: {
"@type": "MerchantReturnPolicy",
applicableCountry: "DE",
returnPolicyCategory: "https://schema.org/MerchantReturnFiniteReturnWindow",
merchantReturnDays: 14,
returnMethod: "https://schema.org/ReturnByMail",
returnFees: "https://schema.org/FreeReturn",
},
shippingDetails: {
"@type": "OfferShippingDetails",
shippingRate: {
"@type": "MonetaryAmount",
value: 5.90,
currency: "EUR",
},
shippingDestination: {
"@type": "DefinedRegion",
addressCountry: "DE",
},
deliveryTime: {
"@type": "ShippingDeliveryTime",
handlingTime: {
"@type": "QuantitativeValue",
minValue: 0,
maxValue: 1,
unitCode: "DAY",
},
transitTime: {
"@type": "QuantitativeValue",
minValue: 2,
maxValue: 3,
unitCode: "DAY",
},
},
},
},
},
})),
}; };
}),
});
} }
const categoryGraph = {
"@context": "https://schema.org",
"@graph": graph,
};
return `<script type="application/ld+json">${JSON.stringify( return `<script type="application/ld+json">${JSON.stringify(
jsonLd categoryGraph
)}</script>`; )}</script>`;
}; };
module.exports = { module.exports = {
generateCategoryMetaTags,
generateCategoryJsonLd, generateCategoryJsonLd,
}; };

View File

@@ -0,0 +1,116 @@
/** Safe for double-quoted HTML attributes */
const escAttr = (str) =>
String(str ?? "")
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;");
/**
* Head tags for prerendered Hersteller (Manufacturers) page
*/
const generateHerstellerMetaTags = (baseUrl, config, manufacturerCount = 0) => {
const root = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
const herstellerUrl = root + "/Hersteller";
const site = config.siteName || config.brandName;
const desc = manufacturerCount + " Hersteller bei " + config.brandName + ": Top-Marken für Growshop-Produkte. Schnelle Lieferung, Laden Dresden.";
const descShort = desc.length > 160 ? desc.slice(0, 157) + "..." : desc;
const e = escAttr;
const logoUrl =
config.images && config.images.logo
? root + config.images.logo
: root + "/assets/images/nopicture.jpg";
return `
<meta name="description" content="${e(descShort)}">
<meta property="og:title" content="${e("Hersteller | " + site)}">
<meta property="og:description" content="${e(descShort)}">
<meta property="og:url" content="${herstellerUrl}">
<meta property="og:type" content="website">
<meta property="og:image" content="${e(logoUrl)}">
<meta property="og:site_name" content="${e(site)}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${e("Hersteller | " + site)}">
<meta name="twitter:description" content="${e(descShort)}">
<meta name="robots" content="index, follow">
<link rel="canonical" href="${herstellerUrl}">
`;
};
const generateHerstellerJsonLd = (baseUrl, config) => {
const root = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
const herstellerUrl = root + "/Hersteller";
const id = {
business: root + "#business",
website: root + "#website",
breadcrumb: herstellerUrl + "#breadcrumb",
};
const logoUrl =
config.images && config.images.logo
? root + config.images.logo
: undefined;
const businessNode = {
"@id": id.business,
"@type": ["GardenStore", "LocalBusiness", "Organization"],
name: config.brandName,
url: root,
};
if (logoUrl) {
businessNode.logo = { "@type": "ImageObject", url: logoUrl };
businessNode.image = { "@type": "ImageObject", url: logoUrl };
}
const websiteNode = {
"@id": id.website,
"@type": "WebSite",
name: config.siteName || config.brandName,
url: root,
publisher: { "@id": id.business },
};
const breadcrumbNode = {
"@id": id.breadcrumb,
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Home",
item: root,
},
{
"@type": "ListItem",
position: 2,
name: "Hersteller",
item: herstellerUrl,
},
],
};
const collectionPageNode = {
"@id": herstellerUrl,
"@type": "CollectionPage",
name: "Hersteller",
url: herstellerUrl,
description: "Alle Hersteller und Marken für Growshop-Produkte",
isPartOf: { "@id": id.website },
breadcrumb: { "@id": id.breadcrumb },
};
const graph = [businessNode, websiteNode, breadcrumbNode, collectionPageNode];
const herstellerGraph = {
"@context": "https://schema.org",
"@graph": graph,
};
return "<script type=\"application/ld+json\">" + JSON.stringify(herstellerGraph) + "</script>";
};
module.exports = {
generateHerstellerMetaTags,
generateHerstellerJsonLd,
};

View File

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

View File

@@ -5,6 +5,7 @@ const {
} = require('./product.cjs'); } = require('./product.cjs');
const { const {
generateCategoryMetaTags,
generateCategoryJsonLd, generateCategoryJsonLd,
} = require('./category.cjs'); } = require('./category.cjs');
@@ -22,6 +23,11 @@ const {
generateKonfiguratorMetaTags, generateKonfiguratorMetaTags,
} = require('./konfigurator.cjs'); } = require('./konfigurator.cjs');
const {
generateHerstellerMetaTags,
generateHerstellerJsonLd,
} = require('./hersteller.cjs');
const { const {
generateRobotsTxt, generateRobotsTxt,
generateProductsXml, generateProductsXml,
@@ -41,6 +47,7 @@ module.exports = {
generateProductJsonLd, generateProductJsonLd,
// Category functions // Category functions
generateCategoryMetaTags,
generateCategoryJsonLd, generateCategoryJsonLd,
// Homepage functions // Homepage functions
@@ -54,6 +61,10 @@ module.exports = {
// Konfigurator functions // Konfigurator functions
generateKonfiguratorMetaTags, generateKonfiguratorMetaTags,
// Hersteller functions
generateHerstellerMetaTags,
generateHerstellerJsonLd,
// Feed/Export functions // Feed/Export functions
generateRobotsTxt, generateRobotsTxt,
generateProductsXml, generateProductsXml,

View File

@@ -60,32 +60,19 @@ GrowHeads.de is a German online shop and local store in Dresden specializing in
if (totalPages > 1) { if (totalPages > 1) {
llmsTxt += ` llmsTxt += `
- **Product Catalog**: ${totalPages} pages available ${totalPages} pages available`;
- **Page 1**: ${baseUrl}/llms-${categorySlug}-page-1.txt (Products 1-${Math.min(productsPerPage, productCount)})`; for (let p = 1; p <= totalPages; p++) {
const start = (p - 1) * productsPerPage + 1;
if (totalPages > 2) { const end = Math.min(p * productsPerPage, productCount);
llmsTxt += ` llmsTxt += `
- **Page 2**: ${baseUrl}/llms-${categorySlug}-page-2.txt (Products ${productsPerPage + 1}-${Math.min(productsPerPage * 2, productCount)})`; - **Page ${p}**: ${baseUrl}/llms-${categorySlug}-page-${p}.txt (Products ${start}-${end})`;
} }
if (totalPages > 3) {
llmsTxt += `
- **...**: Additional pages available`;
}
if (totalPages > 2) {
llmsTxt += `
- **Page ${totalPages}**: ${baseUrl}/llms-${categorySlug}-page-${totalPages}.txt (Products ${((totalPages - 1) * productsPerPage) + 1}-${productCount})`;
}
llmsTxt += `
- **Access Pattern**: Replace "page-X" with desired page number (1-${totalPages})`;
} else if (productCount > 0) { } else if (productCount > 0) {
llmsTxt += ` llmsTxt += `
- **Product Catalog**: ${baseUrl}/llms-${categorySlug}-page-1.txt`; ${baseUrl}/llms-${categorySlug}-page-1.txt`;
} else { } else {
llmsTxt += ` llmsTxt += `
- **Product Catalog**: No products available`; No products available`;
} }
llmsTxt += ` llmsTxt += `

View File

@@ -1,13 +1,18 @@
const generateProductMetaTags = (product, baseUrl, config) => { const generateProductMetaTags = (product, baseUrl, config) => {
const productUrl = `${baseUrl}/Artikel/${product.seoName}`; const productUrl = `${baseUrl}/Artikel/${product.seoName}`;
const imageUrl = const pictureFirstId =
product.pictureList && product.pictureList.trim() product.pictureList && product.pictureList.trim()
? `${baseUrl}/assets/images/prod${product.pictureList ? product.pictureList.split(",")[0].trim()
.split(",")[0] : null;
.trim()}.avif`
const imageUrl = pictureFirstId
? `${baseUrl}/assets/images/prod${pictureFirstId}.avif`
: `${baseUrl}/assets/images/nopicture.jpg`; : `${baseUrl}/assets/images/nopicture.jpg`;
const twitterImageUrl = pictureFirstId
? `${baseUrl}/assets/images/prod${pictureFirstId}.jpg`
: `${baseUrl}/assets/images/nopicture.jpg`;
// Clean description for meta (remove HTML tags and limit length) // Clean description for meta (remove HTML tags and limit length)
const cleanDescription = product.kurzBeschreibung const cleanDescription = product.kurzBeschreibung
@@ -32,7 +37,7 @@ const generateProductMetaTags = (product, baseUrl, config) => {
<!-- Open Graph Meta Tags --> <!-- Open Graph Meta Tags -->
<meta property="og:title" content="${product.name}"> <meta property="og:title" content="${product.name}">
<meta property="og:description" content="${cleanDescription}"> <meta property="og:description" content="${cleanDescription}">
<meta property="og:image" content="${imageUrl}"> <meta property="og:image" content="${twitterImageUrl}">
<meta property="og:url" content="${productUrl}"> <meta property="og:url" content="${productUrl}">
<meta property="og:type" content="product"> <meta property="og:type" content="product">
<meta property="og:site_name" content="${config.siteName}"> <meta property="og:site_name" content="${config.siteName}">
@@ -49,7 +54,7 @@ const generateProductMetaTags = (product, baseUrl, config) => {
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${product.name}"> <meta name="twitter:title" content="${product.name}">
<meta name="twitter:description" content="${cleanDescription}"> <meta name="twitter:description" content="${cleanDescription}">
<meta name="twitter:image" content="${imageUrl}"> <meta name="twitter:image" content="${twitterImageUrl}">
<!-- Additional Meta Tags --> <!-- Additional Meta Tags -->
<meta name="robots" content="index, follow"> <meta name="robots" content="index, follow">
@@ -63,13 +68,15 @@ const generateProductMetaTags = (product, baseUrl, config) => {
}; };
const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) => { const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) => {
const productUrl = `${baseUrl}/Artikel/${product.seoName}`; const root = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
const imageUrl = const productUrl = `${root}/Artikel/${product.seoName}`;
const pictureFirstId =
product.pictureList && product.pictureList.trim() product.pictureList && product.pictureList.trim()
? `${baseUrl}/assets/images/prod${product.pictureList ? product.pictureList.split(",")[0].trim()
.split(",")[0] : null;
.trim()}.avif` const imageUrl = pictureFirstId
: `${baseUrl}/assets/images/nopicture.jpg`; ? `${root}/assets/images/prod${pictureFirstId}.avif`
: `${root}/assets/images/nopicture.jpg`;
// Clean description for JSON-LD (remove HTML tags) // Clean description for JSON-LD (remove HTML tags)
const cleanDescription = product.description const cleanDescription = product.description
@@ -80,19 +87,38 @@ const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) =>
const priceValidDate = new Date(); const priceValidDate = new Date();
priceValidDate.setMonth(priceValidDate.getMonth() + 3); priceValidDate.setMonth(priceValidDate.getMonth() + 3);
const jsonLd = { const id = {
"@context": "https://schema.org/", business: `${root}#business`,
"@type": "Product", website: `${root}#website`,
name: product.name, product: `${productUrl}#product`,
image: [imageUrl], breadcrumb: `${productUrl}#breadcrumb`,
description: cleanDescription, };
sku: product.articleNumber,
...(product.gtin && { gtin: product.gtin }), const logoUrl =
brand: { config.images && config.images.logo
"@type": "Brand", ? `${root}${config.images.logo}`
name: product.manufacturer || "Unknown", : undefined;
},
offers: { const businessNode = {
"@id": id.business,
"@type": ["GardenStore", "LocalBusiness", "Organization"],
name: config.brandName,
url: root,
...(logoUrl && {
logo: { "@type": "ImageObject", url: logoUrl },
image: { "@type": "ImageObject", url: logoUrl },
}),
};
const websiteNode = {
"@id": id.website,
"@type": "WebSite",
name: config.siteName || config.brandName,
url: root,
publisher: { "@id": id.business },
};
const offer = {
"@type": "Offer", "@type": "Offer",
url: productUrl, url: productUrl,
priceCurrency: config.currency, priceCurrency: config.currency,
@@ -102,10 +128,7 @@ const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) =>
availability: product.available availability: product.available
? "https://schema.org/InStock" ? "https://schema.org/InStock"
: "https://schema.org/OutOfStock", : "https://schema.org/OutOfStock",
seller: { seller: { "@id": id.business },
"@type": "Organization",
name: config.brandName,
},
hasMerchantReturnPolicy: { hasMerchantReturnPolicy: {
"@type": "MerchantReturnPolicy", "@type": "MerchantReturnPolicy",
applicableCountry: "DE", applicableCountry: "DE",
@@ -118,7 +141,7 @@ const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) =>
"@type": "OfferShippingDetails", "@type": "OfferShippingDetails",
shippingRate: { shippingRate: {
"@type": "MonetaryAmount", "@type": "MonetaryAmount",
value: 5.90, value: 5.9,
currency: "EUR", currency: "EUR",
}, },
shippingDestination: { shippingDestination: {
@@ -141,25 +164,42 @@ const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) =>
}, },
}, },
}, },
},
}; };
// Add breadcrumb if category information is available const productNode = {
if (categoryInfo && categoryInfo.name && categoryInfo.seoName) { "@id": id.product,
jsonLd.breadcrumb = { "@type": "Product",
name: product.name,
image: [imageUrl],
description: cleanDescription,
sku: product.articleNumber,
...(product.gtin && { gtin: product.gtin }),
brand: {
"@type": "Brand",
name: product.manufacturer || "Unknown",
},
offers: offer,
};
const hasBreadcrumb =
categoryInfo && categoryInfo.name && categoryInfo.seoName;
const breadcrumbList = hasBreadcrumb
? {
"@id": id.breadcrumb,
"@type": "BreadcrumbList", "@type": "BreadcrumbList",
itemListElement: [ itemListElement: [
{ {
"@type": "ListItem", "@type": "ListItem",
position: 1, position: 1,
name: "Home", name: "Home",
item: baseUrl, item: root,
}, },
{ {
"@type": "ListItem", "@type": "ListItem",
position: 2, position: 2,
name: categoryInfo.name, name: categoryInfo.name,
item: `${baseUrl}/Kategorie/${categoryInfo.seoName}`, item: `${root}/Kategorie/${categoryInfo.seoName}`,
}, },
{ {
"@type": "ListItem", "@type": "ListItem",
@@ -168,11 +208,34 @@ const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) =>
item: productUrl, item: productUrl,
}, },
], ],
};
} }
: null;
const itemPageNode = {
"@id": productUrl,
"@type": "ItemPage",
url: productUrl,
name: product.name,
isPartOf: { "@id": id.website },
mainEntity: { "@id": id.product },
...(hasBreadcrumb && { breadcrumb: { "@id": id.breadcrumb } }),
};
const graph = [
businessNode,
websiteNode,
itemPageNode,
...(breadcrumbList ? [breadcrumbList] : []),
productNode,
];
const productGraph = {
"@context": "https://schema.org",
"@graph": graph,
};
return `<script type="application/ld+json">${JSON.stringify( return `<script type="application/ld+json">${JSON.stringify(
jsonLd productGraph
)}</script>`; )}</script>`;
}; };

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

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

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

View File

@@ -33,6 +33,7 @@ import i18n from './i18n/index.js';
import Header from "./components/Header.js"; import Header from "./components/Header.js";
import Footer from "./components/Footer.js"; import Footer from "./components/Footer.js";
import MainPageLayout from "./components/MainPageLayout.js"; import MainPageLayout from "./components/MainPageLayout.js";
import IdleMainPagesSlideshow from "./components/IdleMainPagesSlideshow.js";
import Content from "./components/Content.js"; import Content from "./components/Content.js";
import ProductDetail from "./components/ProductDetail.js"; import ProductDetail from "./components/ProductDetail.js";
@@ -40,6 +41,7 @@ import ProductDetail from "./components/ProductDetail.js";
// Lazy load rarely-accessed pages // Lazy load rarely-accessed pages
const ProfilePage = lazy(() => import(/* webpackChunkName: "profile" */ "./pages/ProfilePage.js")); const ProfilePage = lazy(() => import(/* webpackChunkName: "profile" */ "./pages/ProfilePage.js"));
const ResetPassword = lazy(() => import(/* webpackChunkName: "reset-password" */ "./pages/ResetPassword.js")); const ResetPassword = lazy(() => import(/* webpackChunkName: "reset-password" */ "./pages/ResetPassword.js"));
const LinkTelegramPage = lazy(() => import(/* webpackChunkName: "link-telegram" */ "./pages/LinkTelegramPage.js"));
// Lazy load admin pages - only loaded when admin users access them // Lazy load admin pages - only loaded when admin users access them
const AdminPage = lazy(() => import(/* webpackChunkName: "admin" */ "./pages/AdminPage.js")); const AdminPage = lazy(() => import(/* webpackChunkName: "admin" */ "./pages/AdminPage.js"));
@@ -52,6 +54,7 @@ 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 CategoriesPage = lazy(() => import(/* webpackChunkName: "categories" */ "./pages/CategoriesPage.js"));
const HerstellerPage = lazy(() => import(/* webpackChunkName: "hersteller" */ "./pages/HerstellerPage.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"));
@@ -253,6 +256,7 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
) )
}> }>
<CarouselProvider> <CarouselProvider>
<IdleMainPagesSlideshow />
<Routes> <Routes>
{/* Main pages using unified component */} {/* Main pages using unified component */}
<Route path="/" element={<MainPageLayout />} /> <Route path="/" element={<MainPageLayout />} />
@@ -264,6 +268,11 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
path="/Kategorie/:categoryId" path="/Kategorie/:categoryId"
element={<Content />} element={<Content />}
/> />
{/* Manufacturer page - Render Content in parallel */}
<Route
path="/Hersteller/:categoryId"
element={<Content />}
/>
{/* Single product page */} {/* Single product page */}
<Route <Route
path="/Artikel/:seoName" path="/Artikel/:seoName"
@@ -275,6 +284,9 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
{/* Profile page */} {/* Profile page */}
<Route path="/profile" element={<ProfilePage />} /> <Route path="/profile" element={<ProfilePage />} />
{/* Link Telegram id (expects ?id=... or /linkTelegram/:id) */}
<Route path="/linkTelegram" element={<LinkTelegramPage />} />
<Route path="/linkTelegram/:id" element={<LinkTelegramPage />} />
{/* Payment success page for Mollie redirects */} {/* Payment success page for Mollie redirects */}
<Route path="/payment/success" element={<PaymentSuccess />} /> <Route path="/payment/success" element={<PaymentSuccess />} />
@@ -299,6 +311,7 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
<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="/Kategorien" element={<CategoriesPage />} />
<Route path="/Hersteller" element={<HerstellerPage />} />
<Route path="/impressum" element={<Impressum />} /> <Route path="/impressum" element={<Impressum />} />
<Route <Route
path="/batteriegesetzhinweise" path="/batteriegesetzhinweise"

View File

@@ -0,0 +1,84 @@
import React from 'react';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import Link from '@mui/material/Link';
import LegalPage from './pages/LegalPage.js';
const PrerenderHerstellerPage = ({ manufacturerData }) => {
// Use prop data (passed from prerender.cjs)
const manufacturers = manufacturerData;
// If no manufacturer data, show empty state
if (!manufacturers || manufacturers.length === 0) {
const content = (
<Box>
<Typography variant="body1" paragraph>
Keine Hersteller gefunden.
</Typography>
</Box>
);
return <LegalPage title="Hersteller" content={content} />;
}
// Render manufacturers similar to HerstellerPage.js
const content = (
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
gap: 2,
}}
>
{manufacturers.map((manufacturer) => (
<Paper
key={manufacturer.id}
component={Link}
href={`/Hersteller/${encodeURIComponent(manufacturer.slug || '')}`}
elevation={3}
style={{
width: '140px',
height: '140px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
userSelect: 'none',
textDecoration: 'none',
cursor: 'pointer',
borderRadius: '8px',
position: 'relative',
zIndex: 10,
backgroundColor: '#f0f0f0',
boxShadow: '0px 2px 1px -1px rgba(0,0,0,0.2), 0px 1px 1px 0px rgba(0,0,0,0.14), 0px 1px 3px 0px rgba(0,0,0,0.12)'
}}
sx={{
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
'&:hover': {
transform: 'translateY(-5px)',
boxShadow: 8,
},
}}
>
{manufacturer.imageBuffer && (
<img
src={`data:image/avif;base64,${Buffer.from(manufacturer.imageBuffer).toString('base64')}`}
alt={manufacturer.name}
draggable={false}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
display: 'block',
}}
/>
)}
</Paper>
))}
</Box>
);
return <LegalPage title="Hersteller" content={content} />;
};
export default PrerenderHerstellerPage;

View File

@@ -165,16 +165,15 @@ class PrerenderProduct extends React.Component {
sx: { sx: {
mb: 2, mb: 2,
position: ["-webkit-sticky", "sticky"], position: ["-webkit-sticky", "sticky"],
// No CategoryList in prerender — two-row toolbar only; safe-area for notched phones.
top: { top: {
xs: "80px", xs: "calc(env(safe-area-inset-top, 0px) + 128px)",
sm: "80px", sm: "80px",
md: "80px",
lg: "80px",
}, },
left: 0, left: 0,
width: "100%", width: "100%",
display: "flex", display: "flex",
zIndex: 999, // Just below the AppBar zIndex: (theme) => theme.zIndex.appBar - 1,
py: 0, py: 0,
px: 2, px: 2,
} }
@@ -552,7 +551,7 @@ class PrerenderProduct extends React.Component {
}) })
}, },
style: { style: {
fontFamily: '"Roboto","Helvetica","Arial",sans-serif', fontFamily: '"Outfit Variable","Roboto","Helvetica","Arial",sans-serif',
fontSize: '1rem', fontSize: '1rem',
lineHeight: '1.7', lineHeight: '1.7',
color: '#333' color: '#333'

View File

@@ -15,6 +15,8 @@ import NotificationsActiveIcon from "@mui/icons-material/NotificationsActive";
import CircularProgress from "@mui/material/CircularProgress"; import CircularProgress from "@mui/material/CircularProgress";
import { withI18n } from "../i18n/withTranslation.js"; import { withI18n } from "../i18n/withTranslation.js";
import { import {
PUSH_SUBSCRIPTIONS_CHANGED_EVENT,
emitPushSubscriptionsChanged,
isPushApiSupported, isPushApiSupported,
fetchPushConfiguration, fetchPushConfiguration,
registerPushServiceWorker, registerPushServiceWorker,
@@ -109,9 +111,10 @@ class AddToCartButton extends Component {
this.setState({ pushSubscribed: false, pushBusy: false }); this.setState({ pushSubscribed: false, pushBusy: false });
return; return;
} }
const res = await articlePushUnsubscribe(subscription.endpoint); const res = await articlePushUnsubscribe(subscription.endpoint, kArtikel);
if (parseSuccess(res)) { if (parseSuccess(res)) {
this.setState({ pushSubscribed: false }); this.setState({ pushSubscribed: false });
emitPushSubscriptionsChanged();
} else { } else {
this.setState({ this.setState({
pushError: pushError:
@@ -146,6 +149,7 @@ class AddToCartButton extends Component {
const res = await articlePushSubscribe(kArtikel, subscription); const res = await articlePushSubscribe(kArtikel, subscription);
if (parseSuccess(res)) { if (parseSuccess(res)) {
this.setState({ pushSubscribed: true }); this.setState({ pushSubscribed: true });
emitPushSubscriptionsChanged();
} else { } else {
this.setState({ this.setState({
pushError: pushError:
@@ -174,7 +178,14 @@ class AddToCartButton extends Component {
if (this.state.quantity !== newQuantity) if (this.state.quantity !== newQuantity)
this.setState({ quantity: newQuantity }); this.setState({ quantity: newQuantity });
}; };
this.onPushSubscriptionsChanged = () => {
this.refreshIncomingPushStatus();
};
window.addEventListener("cart", this.cart); window.addEventListener("cart", this.cart);
window.addEventListener(
PUSH_SUBSCRIPTIONS_CHANGED_EVENT,
this.onPushSubscriptionsChanged
);
this.refreshIncomingPushStatus(); this.refreshIncomingPushStatus();
} }
@@ -190,6 +201,10 @@ class AddToCartButton extends Component {
componentWillUnmount() { componentWillUnmount() {
window.removeEventListener("cart", this.cart); window.removeEventListener("cart", this.cart);
window.removeEventListener(
PUSH_SUBSCRIPTIONS_CHANGED_EVENT,
this.onPushSubscriptionsChanged
);
} }
handleIncrement = () => { handleIncrement = () => {
@@ -370,7 +385,7 @@ class AddToCartButton extends Component {
startIcon={<ShoppingCartIcon />} startIcon={<ShoppingCartIcon />}
sx={{ sx={{
borderRadius: 2, borderRadius: 2,
fontWeight: "bold", whiteSpace: "nowrap",
backgroundColor: "#9ccc65", // yellowish green backgroundColor: "#9ccc65", // yellowish green
color: "#000000", color: "#000000",
"&:hover": { "&:hover": {
@@ -524,7 +539,7 @@ class AddToCartButton extends Component {
startIcon={<ShoppingCartIcon />} startIcon={<ShoppingCartIcon />}
sx={{ sx={{
borderRadius: 2, borderRadius: 2,
fontWeight: "bold", whiteSpace: "nowrap",
"&:hover": { "&:hover": {
backgroundColor: "primary.dark", backgroundColor: "primary.dark",
}, },

View File

@@ -15,7 +15,13 @@ import StopIcon from '@mui/icons-material/Stop';
import PhotoCameraIcon from '@mui/icons-material/PhotoCamera'; import PhotoCameraIcon from '@mui/icons-material/PhotoCamera';
import parse, { domToReact } from 'html-react-parser'; import parse, { domToReact } from 'html-react-parser';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import MuiLink from '@mui/material/Link';
import { alpha } from '@mui/material/styles';
import TelegramIcon from '@mui/icons-material/Telegram';
import { isUserLoggedIn } from './LoginComponent.js'; import { isUserLoggedIn } from './LoginComponent.js';
import { withTranslation } from '../i18n/withTranslation.js';
const TELEGRAM_ASSISTANT_URL = 'https://t.me/Growheads_de_Bot';
// Initialize window object for storing messages // Initialize window object for storing messages
if (!window.chatMessages) { if (!window.chatMessages) {
window.chatMessages = []; window.chatMessages = [];
@@ -47,23 +53,62 @@ class ChatAssistant extends Component {
this.recordingTimer = null; this.recordingTimer = null;
} }
buildPrivacyPromptHtml = () => {
const { t } = this.props;
return `<div style="display: flex; flex-direction: column; gap: 8px; line-height: 1.5;">
<div>${t('chat.privacyPromptBefore')}<a href="/datenschutz" target="_blank" rel="noopener noreferrer">${t('chat.privacyPolicyLink')}</a>${t('chat.privacyPromptAfter')}</div>
<div><button data-confirm-privacy="true">${t('chat.privacyRead')}</button></div>
</div>`;
};
/** Keep stored privacy bubble in sync with i18n (language switcher, lazy bundle load). */
applyPrivacyPromptTranslation = () => {
this.setState((prev) => {
if (prev.privacyConfirmed) return null;
const idx = prev.messages.findIndex((m) => m.id === 'privacy-prompt');
if (idx === -1) return null;
const updatedMessages = [...prev.messages];
updatedMessages[idx] = {
...updatedMessages[idx],
text: this.buildPrivacyPromptHtml(),
};
window.chatMessages = updatedMessages;
return { messages: updatedMessages };
});
};
handleI18nLanguageChanged = () => {
this.applyPrivacyPromptTranslation();
};
componentDidMount() { componentDidMount() {
// Add socket listeners if socket is available and connected // Add socket listeners if socket is available and connected
this.addSocketListeners(); this.addSocketListeners();
this.props.i18n?.on('languageChanged', this.handleI18nLanguageChanged);
const userStatus = isUserLoggedIn(); const userStatus = isUserLoggedIn();
const isGuest = !userStatus.isLoggedIn; const isGuest = !userStatus.isLoggedIn;
if (isGuest && !this.state.privacyConfirmed) { if (isGuest && !this.state.privacyConfirmed) {
this.setState(prevState => { this.setState(prevState => {
if (prevState.messages.find(msg => msg.id === 'privacy-prompt')) { if (prevState.messages.find(msg => msg.id === 'privacy-prompt')) {
return { isGuest: true }; const updatedMessages = prevState.messages.map((msg) =>
msg.id === 'privacy-prompt'
? { ...msg, text: this.buildPrivacyPromptHtml() }
: msg
);
window.chatMessages = updatedMessages;
return {
messages: updatedMessages,
isGuest: true,
};
} }
const privacyMessage = { const privacyMessage = {
id: 'privacy-prompt', id: 'privacy-prompt',
sender: 'bot', sender: 'bot',
text: 'Bitte bestätigen Sie, dass Sie die <a href="/datenschutz" target="_blank" rel="noopener noreferrer">Datenschutzbestimmungen</a> gelesen haben und damit einverstanden sind. <button data-confirm-privacy="true">Gelesen & Akzeptiert</button>', text: this.buildPrivacyPromptHtml(),
}; };
const updatedMessages = [privacyMessage, ...prevState.messages]; const updatedMessages = [privacyMessage, ...prevState.messages];
window.chatMessages = updatedMessages; window.chatMessages = updatedMessages;
@@ -78,12 +123,16 @@ class ChatAssistant extends Component {
} }
componentDidUpdate(prevProps, prevState) { componentDidUpdate(prevProps, prevState) {
if (prevProps.i18n?.language !== this.props.i18n?.language) {
this.applyPrivacyPromptTranslation();
}
if (prevState.messages !== this.state.messages || prevState.isTyping !== this.state.isTyping) { if (prevState.messages !== this.state.messages || prevState.isTyping !== this.state.isTyping) {
this.scrollToBottom(); this.scrollToBottom();
} }
} }
componentWillUnmount() { componentWillUnmount() {
this.props.i18n?.off('languageChanged', this.handleI18nLanguageChanged);
this.removeSocketListeners(); this.removeSocketListeners();
this.stopRecording(); this.stopRecording();
if (this.recordingTimer) { if (this.recordingTimer) {
@@ -182,7 +231,7 @@ class ChatAssistant extends Component {
}, () => { }, () => {
// Emit message to socket server after state is updated // Emit message to socket server after state is updated
if (userMessage.trim()) { if (userMessage.trim()) {
window.socketManager.emit('aiassyMessage', userMessage); window.socketManager.emit('aiassyMessage', { message: userMessage, lang: this.props.i18n?.language });
} }
}); });
} }
@@ -238,7 +287,7 @@ class ChatAssistant extends Component {
}); });
} catch (err) { } catch (err) {
console.error("Error accessing microphone:", err); console.error("Error accessing microphone:", err);
alert("Could not access microphone. Please check your browser permissions."); alert(this.props.t('chat.micPermissionDenied'));
} }
}; };
@@ -353,7 +402,7 @@ class ChatAssistant extends Component {
const newUserMessage = { const newUserMessage = {
id: Date.now(), id: Date.now(),
sender: 'user', sender: 'user',
text: `<img src="${imageUrl}" alt="Uploaded image" style="max-width: 100%; height: auto; border-radius: 8px;" />`, text: `<img src="${imageUrl}" alt="${this.props.t('chat.uploadedImageAlt')}" style="max-width: 100%; height: auto; border-radius: 8px;" />`,
isImage: true isImage: true
}; };
@@ -445,14 +494,15 @@ class ChatAssistant extends Component {
} }
if (domNode.name === 'button' && domNode.attribs && domNode.attribs['data-confirm-privacy']) { if (domNode.name === 'button' && domNode.attribs && domNode.attribs['data-confirm-privacy']) {
return <Button variant="contained" size="small" onClick={this.handlePrivacyConfirm}>Gelesen & Akzeptiert</Button>; return <Button variant="contained" size="small" onClick={this.handlePrivacyConfirm}>{this.props.t('chat.privacyRead')}</Button>;
} }
} }
}); });
render() { render() {
const { open, onClose } = this.props; const { open, onClose, t } = this.props;
const { messages, inputValue, isTyping, isRecording, recordingTime, isGuest, privacyConfirmed } = this.state; const { messages, inputValue, isTyping, isRecording, recordingTime, isGuest, privacyConfirmed } = this.state;
const showTelegramHint = !messages.some((m) => m.sender === 'user');
if (!open) { if (!open) {
return null; return null;
@@ -498,12 +548,12 @@ class ChatAssistant extends Component {
}} }}
> >
<Typography variant="h6" component="div"> <Typography variant="h6" component="div">
Assistent {t('chat.assistantTitle')}
<Typography component="span" color={this.state.aiThink ? "error" : "text.disabled"} sx={{ display: 'inline' }}>🧠</Typography> <Typography component="span" color={this.state.aiThink ? "error" : "text.disabled"} sx={{ display: 'inline' }}>🧠</Typography>
<Typography component="span" color={this.state.atDatabase ? "error" : "text.disabled"} sx={{ display: 'inline' }}>🛢</Typography> <Typography component="span" color={this.state.atDatabase ? "error" : "text.disabled"} sx={{ display: 'inline' }}>🛢</Typography>
<Typography component="span" color={this.state.atWeb ? "error" : "text.disabled"} sx={{ display: 'inline' }}>🌐</Typography> <Typography component="span" color={this.state.atWeb ? "error" : "text.disabled"} sx={{ display: 'inline' }}>🌐</Typography>
</Typography> </Typography>
<IconButton onClick={onClose} size="small" aria-label="Assistent schließen" sx={{ color: 'primary.contrastText' }}> <IconButton onClick={onClose} size="small" aria-label={t('chat.closeAria')} sx={{ color: 'primary.contrastText' }}>
<CloseIcon /> <CloseIcon />
</IconButton> </IconButton>
</Box> </Box>
@@ -517,6 +567,58 @@ class ChatAssistant extends Component {
gap: 2, gap: 2,
}} }}
> >
{showTelegramHint && (
<Paper
elevation={4}
sx={{
p: 2,
borderRadius: 2,
border: 2,
borderColor: 'primary.main',
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.14),
boxShadow: (theme) =>
`0 4px 14px ${alpha(theme.palette.primary.main, 0.35)}`,
}}
>
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'flex-start' }}>
<TelegramIcon
sx={{
fontSize: 40,
color: 'primary.main',
flexShrink: 0,
filter: (theme) =>
`drop-shadow(0 1px 2px ${alpha(theme.palette.primary.dark, 0.45)})`,
}}
/>
<Box sx={{ minWidth: 0 }}>
<Typography
variant="subtitle1"
component="div"
fontWeight={700}
color="text.primary"
sx={{ lineHeight: 1.45, mb: 0.25 }}
>
{t('chat.telegramAssistantIntro')}
</Typography>
<MuiLink
href={TELEGRAM_ASSISTANT_URL}
target="_blank"
rel="noopener noreferrer"
variant="subtitle1"
fontWeight={800}
sx={{
wordBreak: 'break-all',
color: 'primary.dark',
textDecorationColor: 'primary.main',
'&:hover': { color: 'primary.main' },
}}
>
{t('chat.telegramAssistantLink')}
</MuiLink>
</Box>
</Box>
</Paper>
)}
{messages &&messages.map((message) => ( {messages &&messages.map((message) => (
<Box <Box
key={message.id} key={message.id}
@@ -589,7 +691,7 @@ class ChatAssistant extends Component {
autoFocus autoFocus
autoCapitalize="off" autoCapitalize="off"
autoCorrect="off" autoCorrect="off"
placeholder={isRecording ? "Aufnahme läuft..." : "Du kannst mich nach Cannabissorten fragen..."} placeholder={isRecording ? t('chat.placeholderRecording') : t('chat.inputPlaceholder')}
value={inputValue} value={inputValue}
onChange={this.handleInputChange} onChange={this.handleInputChange}
onKeyDown={this.handleKeyDown} onKeyDown={this.handleKeyDown}
@@ -611,7 +713,7 @@ class ChatAssistant extends Component {
<IconButton <IconButton
color="error" color="error"
onClick={this.stopRecording} onClick={this.stopRecording}
aria-label="Aufnahme stoppen" aria-label={t('chat.micStopAria')}
sx={{ ml: { xs: 0, sm: 1 } }} sx={{ ml: { xs: 0, sm: 1 } }}
> >
<StopIcon /> <StopIcon />
@@ -620,7 +722,7 @@ class ChatAssistant extends Component {
<IconButton <IconButton
color="primary" color="primary"
onClick={this.startRecording} onClick={this.startRecording}
aria-label="Sprachaufnahme starten" aria-label={t('chat.micStartAria')}
sx={{ ml: { xs: 0, sm: 1 } }} sx={{ ml: { xs: 0, sm: 1 } }}
disabled={isTyping || inputsDisabled} disabled={isTyping || inputsDisabled}
> >
@@ -631,7 +733,7 @@ class ChatAssistant extends Component {
<IconButton <IconButton
color="primary" color="primary"
onClick={this.handleImageUpload} onClick={this.handleImageUpload}
aria-label="Bild hochladen" aria-label={t('chat.uploadImageAria')}
sx={{ ml: { xs: 0, sm: 1 } }} sx={{ ml: { xs: 0, sm: 1 } }}
disabled={isTyping || isRecording || inputsDisabled} disabled={isTyping || isRecording || inputsDisabled}
> >
@@ -644,7 +746,7 @@ class ChatAssistant extends Component {
onClick={this.handleSendMessage} onClick={this.handleSendMessage}
disabled={isTyping || isRecording || inputsDisabled} disabled={isTyping || isRecording || inputsDisabled}
> >
Senden {t('chat.send')}
</Button> </Button>
</Box> </Box>
</Box> </Box>
@@ -653,4 +755,4 @@ class ChatAssistant extends Component {
} }
} }
export default ChatAssistant; export default withTranslation()(ChatAssistant);

View File

@@ -11,7 +11,7 @@ import ProductList from './ProductList.js';
import CategoryBoxGrid from './CategoryBoxGrid.js'; import CategoryBoxGrid from './CategoryBoxGrid.js';
import CategoryBox from './CategoryBox.js'; import CategoryBox from './CategoryBox.js';
import { useParams, useSearchParams } from 'react-router-dom'; import { useParams, useSearchParams, useLocation } from 'react-router-dom';
import { getAllSettingsWithPrefix } from '../utils/sessionStorage.js'; import { getAllSettingsWithPrefix } from '../utils/sessionStorage.js';
import { withI18n } from '../i18n/withTranslation.js'; import { withI18n } from '../i18n/withTranslation.js';
import { withCategory } from '../context/CategoryContext.js'; import { withCategory } from '../context/CategoryContext.js';
@@ -24,17 +24,19 @@ const withRouter = (ClassComponent) => {
return (props) => { return (props) => {
const params = useParams(); const params = useParams();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
return <ClassComponent {...props} params={params} searchParams={searchParams} />; const location = useLocation();
const isHersteller = location.pathname.startsWith('/Hersteller/');
return <ClassComponent {...props} params={params} searchParams={searchParams} isHersteller={isHersteller} />;
}; };
}; };
function getCachedCategoryData(categoryId, language = 'de') { function getCachedCategoryData(categoryId, language = 'de', isHersteller = false) {
if (!window.productCache) { if (!window.productCache) {
window.productCache = {}; window.productCache = {};
} }
try { try {
const cacheKey = `categoryProducts_${categoryId}_${language}`; const cacheKey = `${isHersteller ? 'manufacturer' : 'category'}Products_${categoryId}_${language}`;
const cachedData = window.productCache[cacheKey]; const cachedData = window.productCache[cacheKey];
if (cachedData) { if (cachedData) {
@@ -166,7 +168,7 @@ function getFilteredProducts(unfilteredProducts, attributes, t) {
return { filteredProducts, activeAttributeFilters: activeAttributeFiltersWithNames, activeManufacturerFilters: activeManufacturerFiltersWithNames, activeAvailabilityFilters }; return { filteredProducts, activeAttributeFilters: activeAttributeFiltersWithNames, activeManufacturerFilters: activeManufacturerFiltersWithNames, activeAvailabilityFilters };
} }
function setCachedCategoryData(categoryId, data, language = 'de') { function setCachedCategoryData(categoryId, data, language = 'de', isHersteller = false) {
if (!window.productCache) { if (!window.productCache) {
window.productCache = {}; window.productCache = {};
} }
@@ -175,7 +177,7 @@ function setCachedCategoryData(categoryId, data, language = 'de') {
} }
try { try {
const cacheKey = `categoryProducts_${categoryId}_${language}`; const cacheKey = `${isHersteller ? 'manufacturer' : 'category'}Products_${categoryId}_${language}`;
if (data.products) for (const product of data.products) { if (data.products) for (const product of data.products) {
const productCacheKey = `product_${product.id}_${language}`; const productCacheKey = `product_${product.id}_${language}`;
window.productDetailCache[productCacheKey] = product; window.productDetailCache[productCacheKey] = product;
@@ -221,9 +223,10 @@ class Content extends Component {
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
const currentLanguage = this.props.i18n?.language || 'de'; const currentLanguage = this.props.i18n?.language || 'de';
const categoryChanged = this.props.params.categoryId && (prevProps.params.categoryId !== this.props.params.categoryId); const categoryChanged = this.props.params.categoryId && (prevProps.params.categoryId !== this.props.params.categoryId);
const routeTypeChanged = !!prevProps.isHersteller !== !!this.props.isHersteller;
const searchChanged = this.props.searchParams?.get('q') && (prevProps.searchParams?.get('q') !== this.props.searchParams?.get('q')); const searchChanged = this.props.searchParams?.get('q') && (prevProps.searchParams?.get('q') !== this.props.searchParams?.get('q'));
if (categoryChanged) { if (categoryChanged || routeTypeChanged) {
// Clear context for new category loading // Clear context for new category loading
if (this.props.categoryContext && this.props.categoryContext.setCurrentCategory) { if (this.props.categoryContext && this.props.categoryContext.setCurrentCategory) {
this.props.categoryContext.setCurrentCategory(null); this.props.categoryContext.setCurrentCategory(null);
@@ -233,7 +236,7 @@ class Content extends Component {
this.setState({ loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: [], lastFetchedLanguage: currentLanguage }, () => { this.setState({ loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: [], lastFetchedLanguage: currentLanguage }, () => {
this.fetchCategoryData(this.props.params.categoryId); this.fetchCategoryData(this.props.params.categoryId);
}); });
return; // Don't check language change if category changed return; // Don't check language change if category or route type changed
} }
else if (searchChanged) { else if (searchChanged) {
this.setState({ loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: [], lastFetchedLanguage: currentLanguage }, () => { this.setState({ loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: [], lastFetchedLanguage: currentLanguage }, () => {
@@ -345,7 +348,8 @@ class Content extends Component {
sessionStorage.setItem('filter_availability', '1'); sessionStorage.setItem('filter_availability', '1');
} }
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de'; const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
const cachedData = getCachedCategoryData(categoryId, currentLanguage); const isHersteller = !!this.props.isHersteller;
const cachedData = getCachedCategoryData(categoryId, currentLanguage, isHersteller);
if (cachedData) { if (cachedData) {
this.processDataWithCategoryTree(cachedData, categoryId); this.processDataWithCategoryTree(cachedData, categoryId);
return; return;
@@ -360,7 +364,7 @@ class Content extends Component {
window.socketManager.on(`productList:${categoryId}`, (response) => { window.socketManager.on(`productList:${categoryId}`, (response) => {
console.log("getCategoryProducts full response", response); console.log("getCategoryProducts full response", response);
receivedFullResponse = true; receivedFullResponse = true;
setCachedCategoryData(categoryId, response, currentLanguage); setCachedCategoryData(categoryId, response, currentLanguage, isHersteller);
if (response && response.products !== undefined) { if (response && response.products !== undefined) {
this.processDataWithCategoryTree(response, categoryId); this.processDataWithCategoryTree(response, categoryId);
} else { } else {
@@ -370,12 +374,17 @@ class Content extends Component {
window.socketManager.emit( window.socketManager.emit(
"getCategoryProducts", "getCategoryProducts",
{ categoryId: categoryId, language: currentLanguage, requestTranslation: currentLanguage === 'de' ? false : true }, {
categoryId: categoryId,
language: currentLanguage,
requestTranslation: currentLanguage === 'de' ? false : true,
isHersteller,
},
(response) => { (response) => {
console.log("getCategoryProducts stub response", response); console.log("getCategoryProducts stub response", response);
// Only process stub response if we haven't received the full response yet // Only process stub response if we haven't received the full response yet
if (!receivedFullResponse) { if (!receivedFullResponse) {
setCachedCategoryData(categoryId, response, currentLanguage); setCachedCategoryData(categoryId, response, currentLanguage, isHersteller);
if (response && response.products !== undefined) { if (response && response.products !== undefined) {
this.processDataWithCategoryTree(response, categoryId); this.processDataWithCategoryTree(response, categoryId);
} else { } else {
@@ -448,6 +457,29 @@ class Content extends Component {
} }
} }
// JTL kKategorie for category push: backend may omit dataParam — resolve from tree (same id as product list)
const isValidJtlCategoryId = (v) => {
if (v == null || v === '') return false;
const n = typeof v === 'number' ? v : parseInt(String(v), 10);
return Number.isFinite(n) && n > 0;
};
if (!this.props.isHersteller && categoryId !== 'neu' && categoryId !== 'bald' && !isValidJtlCategoryId(enhancedResponse.dataParam)) {
try {
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
const categoryTreeCache = window.categoryService.getSync(209, currentLanguage);
if (categoryTreeCache) {
const targetCategory = typeof categoryId === 'string'
? this.findCategoryBySeoName(categoryTreeCache, categoryId)
: this.findCategoryById(categoryTreeCache, categoryId);
if (targetCategory && typeof targetCategory.id === 'number' && targetCategory.id > 0) {
enhancedResponse.dataParam = targetCategory.id;
}
}
} catch (err) {
console.error('Error resolving dataParam from category tree:', err);
}
}
this.processData(enhancedResponse); this.processData(enhancedResponse);
} }
@@ -701,7 +733,7 @@ class Content extends Component {
minHeight: { xs: 'min-content', sm: '100%' } minHeight: { xs: 'min-content', sm: '100%' }
}}> }}>
<Box > <Box sx={{ overflow: 'visible', minWidth: 0 }}>
<ProductFilters <ProductFilters
products={this.state.unfilteredProducts} products={this.state.unfilteredProducts}

View File

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

View File

@@ -16,6 +16,7 @@ const StyledRouterLink = styled(RouterLink)(() => ({
lineHeight: '1.5', lineHeight: '1.5',
display: 'block', display: 'block',
padding: '4px 8px', padding: '4px 8px',
whiteSpace: 'nowrap',
'&:hover': { '&:hover': {
textDecoration: 'underline', textDecoration: 'underline',
}, },
@@ -223,25 +224,13 @@ class Footer extends Component {
alignItems={{ xs: 'center', md: 'flex-end' }} alignItems={{ xs: 'center', md: 'flex-end' }}
> >
{/* Legal Links Section */} {/* Legal Links Section */}
<Stack <Stack direction="column" spacing={0.5} justifyContent="center" alignItems="flex-start">
direction={{ xs: 'row', md: 'column' }}
spacing={{ xs: 2, md: 0.5 }}
justifyContent="center"
alignItems={{ xs: 'center', md: 'left' }}
flexWrap="wrap"
>
<StyledRouterLink to="/datenschutz">{this.props.t ? this.props.t('footer.legal.datenschutz') : 'Datenschutz'}</StyledRouterLink> <StyledRouterLink to="/datenschutz">{this.props.t ? this.props.t('footer.legal.datenschutz') : 'Datenschutz'}</StyledRouterLink>
<StyledRouterLink to="/agb">{this.props.t ? this.props.t('footer.legal.agb') : 'AGB'}</StyledRouterLink> <StyledRouterLink to="/agb">{this.props.t ? this.props.t('footer.legal.agb') : 'AGB'}</StyledRouterLink>
<StyledRouterLink to="/sitemap">{this.props.t ? this.props.t('footer.legal.sitemap') : 'Sitemap'}</StyledRouterLink> <StyledRouterLink to="/sitemap">{this.props.t ? this.props.t('footer.legal.sitemap') : 'Sitemap'}</StyledRouterLink>
</Stack> </Stack>
<Stack <Stack direction="column" spacing={0.5} justifyContent="center" alignItems="flex-start">
direction={{ xs: 'row', md: 'column' }}
spacing={{ xs: 2, md: 0.5 }}
justifyContent="center"
alignItems={{ xs: 'center', md: 'left' }}
flexWrap="wrap"
>
<StyledRouterLink to="/impressum">{this.props.t ? this.props.t('footer.legal.impressum') : 'Impressum'}</StyledRouterLink> <StyledRouterLink to="/impressum">{this.props.t ? this.props.t('footer.legal.impressum') : 'Impressum'}</StyledRouterLink>
<StyledRouterLink to="/batteriegesetzhinweise">{this.props.t ? this.props.t('footer.legal.batteriegesetzhinweise') : 'Batteriegesetzhinweise'}</StyledRouterLink> <StyledRouterLink to="/batteriegesetzhinweise">{this.props.t ? this.props.t('footer.legal.batteriegesetzhinweise') : 'Batteriegesetzhinweise'}</StyledRouterLink>
<StyledRouterLink to="/widerrufsrecht">{this.props.t ? this.props.t('footer.legal.widerrufsrecht') : 'Widerrufsrecht'}</StyledRouterLink> <StyledRouterLink to="/widerrufsrecht">{this.props.t ? this.props.t('footer.legal.widerrufsrecht') : 'Widerrufsrecht'}</StyledRouterLink>
@@ -346,7 +335,7 @@ class Footer extends Component {
{/* Copyright Section */} {/* Copyright Section */}
<Box sx={{ pb: 0, textAlign: 'center', filter: 'drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3))', opacity: 0.7 }}> <Box sx={{ pb: 0, textAlign: 'center', filter: 'drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3))', opacity: 0.7 }}>
<Typography variant="body2" sx={{ mb: 1, fontSize: { xs: '11px', md: '14px' }, lineHeight: 1.5 }}> <Typography variant="body2" sx={{ mb: 1, fontSize: { xs: '11px', md: '14px' }, lineHeight: 1.5, whiteSpace: 'nowrap' }}>
{this.props.t ? this.props.t('footer.allPricesIncl') : '* Alle Preise inkl. gesetzlicher USt., zzgl. Versand'} {this.props.t ? this.props.t('footer.allPricesIncl') : '* Alle Preise inkl. gesetzlicher USt., zzgl. Versand'}
</Typography> </Typography>
<Typography <Typography

View File

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

View File

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

View File

@@ -240,7 +240,15 @@ export class LoginComponent extends Component {
isAdmin: !!response.user.admin isAdmin: !!response.user.admin
}); });
const redirectTo = location && location.hash ? `/profile${location.hash}` : '/profile'; const redirectTo = (() => {
// If we started login from the linkTelegram flow, come back there after auth.
// This prevents LinkTelegramPage from getting unmounted before the socket emit runs.
if (location?.pathname && location.pathname.startsWith('/linkTelegram')) {
return `${location.pathname}${location.search || ''}${location.hash || ''}`;
}
return location && location.hash ? `/profile${location.hash}` : '/profile';
})();
const dispatchLoginEvent = () => { const dispatchLoginEvent = () => {
window.dispatchEvent(new CustomEvent('userLoggedIn')); window.dispatchEvent(new CustomEvent('userLoggedIn'));
navigate(redirectTo); navigate(redirectTo);
@@ -415,7 +423,14 @@ export class LoginComponent extends Component {
user: response.user user: response.user
}); });
const redirectTo = location && location.hash ? `/profile${location.hash}` : '/profile'; const redirectTo = (() => {
// If we started login from the linkTelegram flow, come back there after auth.
if (location?.pathname && location.pathname.startsWith('/linkTelegram')) {
return `${location.pathname}${location.search || ''}${location.hash || ''}`;
}
return location && location.hash ? `/profile${location.hash}` : '/profile';
})();
const dispatchLoginEvent = () => { const dispatchLoginEvent = () => {
window.dispatchEvent(new CustomEvent('userLoggedIn')); window.dispatchEvent(new CustomEvent('userLoggedIn'));
navigate(redirectTo); navigate(redirectTo);

View File

@@ -10,8 +10,196 @@ import ChevronRight from "@mui/icons-material/ChevronRight";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import SharedCarousel from "./SharedCarousel.js"; import SharedCarousel from "./SharedCarousel.js";
import { getCombinedAnimatedBorderStyles } from "../utils/animatedBorderStyles.js"; import { getCombinedAnimatedBorderStyles } from "../utils/animatedBorderStyles.js";
import { STAR_POLYGON_POINTS } from "../utils/starPolygon.js";
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
const HOME_STAR_LAYERS = [
{ className: "star-rotate-slow-cw", size: 168 },
{ className: "star-rotate-slow-ccw", size: 159 },
{ className: "star-rotate-medium-cw", size: 150 },
];
/** Teal/cyan stack for the right (Konfigurator) star — same motion, blue color scheme */
const TEAL_STAR_LAYERS = [
{ className: "star-rotate-slow-ccw", size: 168 },
{ className: "star-rotate-medium-cw", size: 159 },
{ className: "star-rotate-slow-cw", size: 150 },
];
/** Initial fill per variant (matches keyframe 0%) — avoids black flash before CSS animates */
const STAR_INITIAL_FILLS = {
home: ["#B8860B", "#DAA520", "#FFD700"],
filiale: ["#5F9EA0", "#7FCDCD", "#AFEEEE"],
};
/** Injected in render (not useEffect) so first paint already has keyframes — avoids angle/color snap on load */
const STAR_DECORATION_CSS = `
@keyframes rotateClockwise {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes rotateCounterClockwise {
from { transform: rotate(0deg); }
to { transform: rotate(-360deg); }
}
.star-rotate-slow-cw,
.star-rotate-slow-ccw,
.star-rotate-medium-cw {
transform-box: fill-box;
transform-origin: center;
}
.star-rotate-slow-cw {
animation: rotateClockwise 60s linear infinite;
}
.star-rotate-slow-ccw {
animation: rotateCounterClockwise 45s linear infinite;
}
.star-rotate-medium-cw {
animation: rotateClockwise 30s linear infinite;
}
.star-layer-svg-home {
mix-blend-mode: screen;
opacity: 0.92;
filter: drop-shadow(3px 3px 6px rgba(0, 0, 0, 0.5)) drop-shadow(0 0 10px rgba(255, 215, 0, 0.6)) drop-shadow(0 0 20px rgba(255, 215, 0, 0.3));
}
.star-layer-svg-filiale {
mix-blend-mode: soft-light;
opacity: 0.94;
filter: drop-shadow(3px 3px 6px rgba(0, 0, 0, 0.5)) drop-shadow(0 0 10px rgba(127, 205, 205, 0.6)) drop-shadow(0 0 18px rgba(95, 158, 160, 0.35));
}
.star-layer-svg {
shape-rendering: geometricPrecision;
transform: translateZ(0);
}
@keyframes starFillHome0 {
0%, 100% { fill: #B8860B; }
33% { fill: #FFD700; }
66% { fill: #DAA520; }
}
@keyframes starFillHome1 {
0%, 100% { fill: #DAA520; }
33% { fill: #B8860B; }
66% { fill: #FFD700; }
}
@keyframes starFillHome2 {
0%, 100% { fill: #FFD700; }
33% { fill: #DAA520; }
66% { fill: #B8860B; }
}
@keyframes starDriftHome0 {
0%, 100% { transform: rotate(20deg) translate(0px, 0px); }
50% { transform: rotate(20deg) translate(5px, -5px); }
}
@keyframes starDriftHome1 {
0%, 100% { transform: rotate(-25deg) translate(0px, 0px); }
50% { transform: rotate(-25deg) translate(-4px, 6px); }
}
@keyframes starDriftHome2 {
0%, 100% { transform: translate(0px, 0px); }
50% { transform: translate(3px, 4px); }
}
.star-layer-wrap.star-layer-home-0 {
animation: starDriftHome0 6.5s ease-in-out infinite;
animation-fill-mode: both;
}
.star-layer-wrap.star-layer-home-1 {
animation: starDriftHome1 7s ease-in-out 0.4s infinite;
animation-fill-mode: both;
}
.star-layer-wrap.star-layer-home-2 {
animation: starDriftHome2 5.5s ease-in-out 0.8s infinite;
animation-fill-mode: both;
}
.star-poly-home-0 { animation: starFillHome0 10s ease-in-out 0s infinite both; }
.star-poly-home-1 { animation: starFillHome1 10s ease-in-out 1.1s infinite both; }
.star-poly-home-2 { animation: starFillHome2 10s ease-in-out 2.2s infinite both; }
@keyframes starFillFil0 {
0%, 100% { fill: #5F9EA0; }
33% { fill: #AFEEEE; }
66% { fill: #7FCDCD; }
}
@keyframes starFillFil1 {
0%, 100% { fill: #7FCDCD; }
33% { fill: #5F9EA0; }
66% { fill: #AFEEEE; }
}
@keyframes starFillFil2 {
0%, 100% { fill: #AFEEEE; }
33% { fill: #7FCDCD; }
66% { fill: #5F9EA0; }
}
@keyframes starDriftFil0 {
0%, 100% { transform: rotate(20deg) translate(0px, 0px); }
50% { transform: rotate(20deg) translate(4px, -4px); }
}
@keyframes starDriftFil1 {
0%, 100% { transform: rotate(-25deg) translate(0px, 0px); }
50% { transform: rotate(-25deg) translate(-5px, 5px); }
}
@keyframes starDriftFil2 {
0%, 100% { transform: translate(0px, 0px); }
50% { transform: translate(3px, 3px); }
}
.star-layer-wrap.star-layer-filiale-0 {
animation: starDriftFil0 6.5s ease-in-out infinite;
animation-fill-mode: both;
}
.star-layer-wrap.star-layer-filiale-1 {
animation: starDriftFil1 7s ease-in-out 0.4s infinite;
animation-fill-mode: both;
}
.star-layer-wrap.star-layer-filiale-2 {
animation: starDriftFil2 5.5s ease-in-out 0.8s infinite;
animation-fill-mode: both;
}
.star-poly-filiale-0 { animation: starFillFil0 10s ease-in-out 0s infinite both; }
.star-poly-filiale-1 { animation: starFillFil1 10s ease-in-out 1.1s infinite both; }
.star-poly-filiale-2 { animation: starFillFil2 10s ease-in-out 2.2s infinite both; }
`;
const StarDecorationLayers = ({ layers, variant }) => (
<>
{layers.map(({ className, size }, i) => {
const half = size / 2;
const initialFill = STAR_INITIAL_FILLS[variant][i];
return (
<div
key={i}
className={`star-layer-wrap star-layer-${variant}-${i}`}
style={{
position: "absolute",
left: "50%",
top: "50%",
width: size,
height: size,
marginLeft: -half,
marginTop: -half,
zIndex: 3 - i,
}}
>
<svg
viewBox="0 0 60 60"
width="100%"
height="100%"
className={`${className} star-layer-svg star-layer-svg-${variant}`}
style={{ display: "block" }}
>
<polygon
points={STAR_POLYGON_POINTS}
fill={initialFill}
className={`star-poly-fill star-poly-${variant}-${i}`}
/>
</svg>
</div>
);
})}
</>
);
const ContentBox = ({ box, index, pageType, starHovered, setStarHovered, opacity, translatedContent }) => ( const ContentBox = ({ box, index, pageType, starHovered, setStarHovered, opacity, translatedContent }) => (
<Grid key={`${pageType}-${index}`} item xs={12} sm={6} sx={{ p: 2, width: "50%", position: 'relative' }}> <Grid key={`${pageType}-${index}`} item xs={12} sm={6} sx={{ p: 2, width: "50%", position: 'relative' }}>
{index === 0 && pageType === "home" && ( {index === 0 && pageType === "home" && (
@@ -25,28 +213,19 @@ const ContentBox = ({ box, index, pageType, starHovered, setStarHovered, opacity
zIndex: 999, zIndex: 999,
pointerEvents: 'none', pointerEvents: 'none',
'& *': { pointerEvents: 'none' }, '& *': { pointerEvents: 'none' },
filter: 'drop-shadow(6px 6px 12px rgba(0, 0, 0, 0.6)) drop-shadow(0 0 20px rgba(255, 215, 0, 0.8)) drop-shadow(0 0 40px rgba(255, 215, 0, 0.4))',
display: { xs: 'none', sm: 'block' } display: { xs: 'none', sm: 'block' }
}} }}
> >
<svg viewBox="0 0 60 60" width="168" height="168" className="star-rotate-slow-cw" style={{ position: 'absolute', top: '-9px', left: '-9px', transform: 'rotate(20deg)' }}> <StarDecorationLayers layers={HOME_STAR_LAYERS} variant="home" />
<polygon points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20" fill="#B8860B" /> <div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%) rotate(-10deg)', color: 'white', fontWeight: '900', fontSize: '20px', textShadow: '0px 3px 6px rgba(0,0,0,0.5)', zIndex: 1000, textAlign: 'center', lineHeight: '1.1', width: '135px', transition: 'opacity 0.3s ease', opacity: starHovered ? 0 : 1 }}>
</svg>
<svg viewBox="0 0 60 60" width="159" height="159" className="star-rotate-slow-ccw" style={{ position: 'absolute', top: '-4.5px', left: '-4.5px', transform: 'rotate(-25deg)' }}>
<polygon points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20" fill="#DAA520" />
</svg>
<svg viewBox="0 0 60 60" width="150" height="150" className="star-rotate-medium-cw">
<polygon points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20" fill="#FFD700" />
</svg>
<div style={{ position: 'absolute', top: '45%', left: '43%', transform: 'translate(-50%, -50%) rotate(-10deg)', color: 'white', fontWeight: '900', fontSize: '20px', textShadow: '0px 3px 6px rgba(0,0,0,0.5)', zIndex: 1000, textAlign: 'center', lineHeight: '1.1', width: '135px', transition: 'opacity 0.3s ease', opacity: starHovered ? 0 : 1 }}>
{translatedContent.outdoorSeason} {translatedContent.outdoorSeason}
</div> </div>
<div style={{ position: 'absolute', top: '45%', left: '43%', transform: 'translate(-50%, -50%) rotate(-10deg)', color: 'white', fontWeight: '900', fontSize: '20px', textShadow: '0px 3px 6px rgba(0,0,0,0.5)', zIndex: 1000, textAlign: 'center', lineHeight: '1.1', width: '135px', opacity: starHovered ? 1 : 0, transition: 'opacity 0.3s ease' }}> <div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%) rotate(-10deg)', color: 'white', fontWeight: '900', fontSize: '20px', textShadow: '0px 3px 6px rgba(0,0,0,0.5)', zIndex: 1000, textAlign: 'center', lineHeight: '1.1', width: '135px', opacity: starHovered ? 1 : 0, transition: 'opacity 0.3s ease' }}>
{translatedContent.selectSeedRate} {translatedContent.selectSeedRate}
</div> </div>
</Box> </Box>
)} )}
{index === 1 && pageType === "filiale" && ( {index === 1 && pageType === "home" && (
<Box <Box
sx={{ sx={{
position: 'absolute', position: 'absolute',
@@ -57,21 +236,12 @@ const ContentBox = ({ box, index, pageType, starHovered, setStarHovered, opacity
zIndex: 999, zIndex: 999,
pointerEvents: 'none', pointerEvents: 'none',
'& *': { pointerEvents: 'none' }, '& *': { pointerEvents: 'none' },
filter: 'drop-shadow(6px 6px 12px rgba(0, 0, 0, 0.6)) drop-shadow(0 0 20px rgba(175, 238, 238, 0.8)) drop-shadow(0 0 40px rgba(175, 238, 238, 0.4))',
display: { xs: 'none', sm: 'block' } display: { xs: 'none', sm: 'block' }
}} }}
> >
<svg viewBox="0 0 60 60" width="168" height="168" className="star-rotate-slow-ccw" style={{ position: 'absolute', top: '-9px', left: '-9px', transform: 'rotate(20deg)' }}> <StarDecorationLayers layers={TEAL_STAR_LAYERS} variant="filiale" />
<polygon points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20" fill="#5F9EA0" /> <div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%) rotate(10deg)', color: 'white', fontWeight: '900', fontSize: '20px', textShadow: '0px 3px 6px rgba(0,0,0,0.5)', zIndex: 1000, textAlign: 'center', lineHeight: '1.1', width: '135px' }}>
</svg> {translatedContent.buildYourSet}
<svg viewBox="0 0 60 60" width="159" height="159" className="star-rotate-medium-cw" style={{ position: 'absolute', top: '-4.5px', left: '-4.5px', transform: 'rotate(-25deg)' }}>
<polygon points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20" fill="#7FCDCD" />
</svg>
<svg viewBox="0 0 60 60" width="150" height="150" className="star-rotate-slow-cw">
<polygon points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20" fill="#AFEEEE" />
</svg>
<div style={{ position: 'absolute', top: '42%', left: '45%', transform: 'translate(-50%, -50%) rotate(10deg)', color: 'white', fontWeight: '900', fontSize: '20px', textShadow: '0px 3px 6px rgba(0,0,0,0.5)', zIndex: 1000, textAlign: 'center', lineHeight: '1.1', width: '135px' }}>
{translatedContent.showUsPhoto}
</div> </div>
</Box> </Box>
)} )}
@@ -89,11 +259,19 @@ const ContentBox = ({ box, index, pageType, starHovered, setStarHovered, opacity
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
boxShadow: 10, boxShadow: 10,
transition: "all 0.3s ease", transition: "box-shadow 0.3s ease",
"&:hover": { transform: "translateY(-5px)", boxShadow: 20 }, "&:hover": { boxShadow: 20 },
}} }}
onMouseEnter={index === 0 && pageType === "filiale" ? () => setStarHovered(true) : undefined} onMouseEnter={
onMouseLeave={index === 0 && pageType === "filiale" ? () => setStarHovered(false) : undefined} pageType === "home" && index === 0
? () => setStarHovered(true)
: undefined
}
onMouseLeave={
pageType === "home" && index === 0
? () => setStarHovered(false)
: undefined
}
> >
<Box sx={{ height: "100%", bgcolor: box.bgcolor, position: "relative", display: "flex", alignItems: "center", justifyContent: "center" }}> <Box sx={{ height: "100%", bgcolor: box.bgcolor, position: "relative", display: "flex", alignItems: "center", justifyContent: "center" }}>
{opacity === 1 && ( {opacity === 1 && (
@@ -113,8 +291,22 @@ const MainPageLayout = () => {
const currentPath = location.pathname; const currentPath = location.pathname;
const { t } = useTranslation(); const { t } = useTranslation();
const [starHovered, setStarHovered] = React.useState(false); const [starHovered, setStarHovered] = React.useState(false);
// State to track kiosk mode
const [isKiosk, setIsKiosk] = React.useState(() => window.growheadskiosk === true);
// Listen for the custom event
React.useEffect(() => {
const handleKioskChange = () => {
setIsKiosk(window.growheadskiosk === true);
};
window.addEventListener('growheadskiosk-change', handleKioskChange);
return () => window.removeEventListener('growheadskiosk-change', handleKioskChange);
}, []);
const translatedContent = { const translatedContent = {
showUsPhoto: t('sections.showUsPhoto'), buildYourSet: isKiosk ? 'Schau in den Stecklingskatalog' : t('sections.buildYourSet'),
selectSeedRate: t('sections.selectSeedRate'), selectSeedRate: t('sections.selectSeedRate'),
outdoorSeason: t('sections.outdoorSeason') outdoorSeason: t('sections.outdoorSeason')
}; };
@@ -123,31 +315,6 @@ const MainPageLayout = () => {
const isAktionen = currentPath === "/aktionen"; const isAktionen = currentPath === "/aktionen";
const isFiliale = currentPath === "/filiale"; const isFiliale = currentPath === "/filiale";
React.useEffect(() => {
const style = document.createElement('style');
style.textContent = `
@keyframes rotateClockwise {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes rotateCounterClockwise {
from { transform: rotate(0deg); }
to { transform: rotate(-360deg); }
}
.star-rotate-slow-cw {
animation: rotateClockwise 60s linear infinite;
}
.star-rotate-slow-ccw {
animation: rotateCounterClockwise 45s linear infinite;
}
.star-rotate-medium-cw {
animation: rotateClockwise 30s linear infinite;
}
`;
document.head.appendChild(style);
return () => document.head.removeChild(style);
}, []);
const getNavigationConfig = () => { const getNavigationConfig = () => {
if (isHome) return { leftNav: { text: t('navigation.aktionen'), link: "/aktionen" }, rightNav: { text: t('navigation.filiale'), link: "/filiale" } }; if (isHome) return { leftNav: { text: t('navigation.aktionen'), link: "/aktionen" }, rightNav: { text: t('navigation.filiale'), link: "/filiale" } };
if (isAktionen) return { leftNav: { text: t('navigation.filiale'), link: "/filiale" }, rightNav: { text: t('navigation.home'), link: "/" } }; if (isAktionen) return { leftNav: { text: t('navigation.filiale'), link: "/filiale" }, rightNav: { text: t('navigation.home'), link: "/" } };
@@ -164,11 +331,11 @@ const MainPageLayout = () => {
const allContentBoxes = { const allContentBoxes = {
home: [ home: [
{ title: t('sections.seeds'), image: "/assets/images/seeds.avif", bgcolor: "#e1f0d3", link: "/Kategorie/Seeds" }, { title: t('sections.seeds'), image: "/assets/images/seeds.avif", bgcolor: "#e1f0d3", link: "/Kategorie/Seeds" },
{ title: t('sections.konfigurator'), image: "/assets/images/konfigurator.avif", bgcolor: "#e8f5d6", link: "/Konfigurator" } { title: isKiosk ? 'Stecklingskatalog' : t('sections.konfigurator'), image: isKiosk ? "/assets/images/cutlings2.avif" : "/assets/images/konfigurator.avif", bgcolor: "#e8f5d6", link: isKiosk ? "https://cloneheads.de" : "/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: "/Artikel/Graveda-10t-presse-tagesmiete-inkl-prepress-vorpressform" },
{ title: t('sections.thcTest'), image: "/assets/images/purpl.jpg", bgcolor: "#e8f5d6", link: "/thc-test" } { title: t('sections.thcTest'), image: "/assets/images/purpl.jpg", bgcolor: "#e8f5d6", link: "/Artikel/1x-messung-purplpro-thc-cbd-restfeuchte-wasseraktivitaet" }
], ],
filiale: [ filiale: [
{ title: t('sections.address1'), image: "/assets/images/filiale1.jpg", bgcolor: "#e1f0d3", link: "/filiale" }, { title: t('sections.address1'), image: "/assets/images/filiale1.jpg", bgcolor: "#e1f0d3", link: "/filiale" },
@@ -193,6 +360,7 @@ const MainPageLayout = () => {
return ( return (
<Container maxWidth="lg" sx={{ py: 2 }}> <Container maxWidth="lg" sx={{ py: 2 }}>
<style>{getCombinedAnimatedBorderStyles(['seeds', 'cutlings'])}</style> <style>{getCombinedAnimatedBorderStyles(['seeds', 'cutlings'])}</style>
<style>{STAR_DECORATION_CSS}</style>
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mb: 4, mt: 2, px: 0, transition: "all 0.3s ease-in-out", flexDirection: { xs: "column", sm: "row" } }}> <Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mb: 4, mt: 2, px: 0, transition: "all 0.3s ease-in-out", flexDirection: { xs: "column", sm: "row" } }}>
<Box sx={{ display: { xs: "block", sm: "none" }, mb: { xs: 2, sm: 0 }, width: "100%", textAlign: "center", position: "relative" }}> <Box sx={{ display: { xs: "block", sm: "none" }, mb: { xs: 2, sm: 0 }, width: "100%", textAlign: "center", position: "relative" }}>
{Object.entries(allTitles).map(([pageType, title]) => ( {Object.entries(allTitles).map(([pageType, title]) => (

View File

@@ -1,17 +1,25 @@
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 Paper from "@mui/material/Paper";
import IconButton from "@mui/material/IconButton";
import ChevronLeft from "@mui/icons-material/ChevronLeft";
import ChevronRight from "@mui/icons-material/ChevronRight";
import { withTranslation } from 'react-i18next'; import { withTranslation } from 'react-i18next';
import { withLanguage } from '../i18n/withTranslation.js'; import { withLanguage } from '../i18n/withTranslation.js';
const ITEM_WIDTH = 140 + 16; // 140px width + 16px gap const ITEM_WIDTH = 140 + 16; // 140px width + 16px gap
const AUTO_SCROLL_SPEED = 1.0; const AUTO_SCROLL_SPEED = 1.0;
const AUTOSCROLL_RESTART_DELAY = 5000;
class ManufacturerCarousel extends React.Component { class ManufacturerCarousel extends React.Component {
_isMounted = false; _isMounted = false;
originalItems = []; originalItems = [];
animationFrame = null; animationFrame = null;
autoScrollActive = true;
translateX = 0; translateX = 0;
inactivityTimer = null;
constructor(props) { constructor(props) {
super(props); super(props);
@@ -28,10 +36,8 @@ class ManufacturerCarousel extends React.Component {
componentWillUnmount() { componentWillUnmount() {
this._isMounted = false; this._isMounted = false;
if (this.animationFrame) { this.stopAutoScroll();
cancelAnimationFrame(this.animationFrame); this.clearInactivityTimer();
this.animationFrame = null;
}
// Revoke object URLs to avoid memory leaks // Revoke object URLs to avoid memory leaks
for (const item of this.originalItems) { for (const item of this.originalItems) {
if (item.src) URL.revokeObjectURL(item.src); if (item.src) URL.revokeObjectURL(item.src);
@@ -46,7 +52,12 @@ class ManufacturerCarousel extends React.Component {
.filter(m => m.imageBuffer) .filter(m => m.imageBuffer)
.map(m => { .map(m => {
const blob = new Blob([m.imageBuffer], { type: 'image/avif' }); const blob = new Blob([m.imageBuffer], { type: 'image/avif' });
return { id: m.id, name: m.name || '', src: URL.createObjectURL(blob) }; return {
id: m.id,
name: m.name || '',
slug: m.slug || '',
src: URL.createObjectURL(blob),
};
}) })
.sort(() => Math.random() - 0.5); .sort(() => Math.random() - 0.5);
@@ -60,13 +71,38 @@ class ManufacturerCarousel extends React.Component {
}; };
startAutoScroll = () => { startAutoScroll = () => {
this.autoScrollActive = true;
if (!this.animationFrame) { if (!this.animationFrame) {
this.animationFrame = requestAnimationFrame(this.tick); this.animationFrame = requestAnimationFrame(this.tick);
} }
}; };
stopAutoScroll = () => {
this.autoScrollActive = false;
if (this.animationFrame) {
cancelAnimationFrame(this.animationFrame);
this.animationFrame = null;
}
};
clearInactivityTimer = () => {
if (this.inactivityTimer) {
clearTimeout(this.inactivityTimer);
this.inactivityTimer = null;
}
};
startInactivityTimer = () => {
this.clearInactivityTimer();
this.inactivityTimer = setTimeout(() => {
if (this._isMounted) {
this.startAutoScroll();
}
}, AUTOSCROLL_RESTART_DELAY);
};
tick = () => { tick = () => {
if (!this._isMounted || this.originalItems.length === 0) return; if (!this._isMounted || !this.autoScrollActive || this.originalItems.length === 0) return;
this.translateX -= AUTO_SCROLL_SPEED; this.translateX -= AUTO_SCROLL_SPEED;
@@ -82,6 +118,41 @@ class ManufacturerCarousel extends React.Component {
this.animationFrame = requestAnimationFrame(this.tick); this.animationFrame = requestAnimationFrame(this.tick);
}; };
updateTrackTransform = () => {
if (this.carouselTrackRef.current) {
this.carouselTrackRef.current.style.transform = `translateX(${this.translateX}px)`;
}
};
scrollBy = (direction) => {
if (this.originalItems.length === 0) return;
const originalItemCount = this.originalItems.length;
const maxScroll = ITEM_WIDTH * originalItemCount;
this.translateX += direction * ITEM_WIDTH;
if (this.translateX > 0) {
this.translateX = -(maxScroll - ITEM_WIDTH);
} else if (Math.abs(this.translateX) >= maxScroll) {
this.translateX = 0;
}
this.updateTrackTransform();
};
handleLeftClick = () => {
this.stopAutoScroll();
this.scrollBy(1);
this.startInactivityTimer();
};
handleRightClick = () => {
this.stopAutoScroll();
this.scrollBy(-1);
this.startInactivityTimer();
};
render() { render() {
const { t } = this.props; const { t } = this.props;
const { items } = this.state; const { items } = this.state;
@@ -90,19 +161,36 @@ class ManufacturerCarousel extends React.Component {
return ( return (
<Box sx={{ mt: 4, mb: 4 }}> <Box sx={{ mt: 4, mb: 4 }}>
<Box
component={Link}
to="/Hersteller"
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 <Typography
variant="h4" variant="h4"
component="div" component="span"
sx={{ sx={{
fontFamily: 'SwashingtonCP', fontFamily: 'SwashingtonCP',
textShadow: '3px 3px 10px rgba(0, 0, 0, 0.4)', textShadow: '3px 3px 10px rgba(0, 0, 0, 0.4)',
textAlign: 'center', textAlign: 'center',
mb: 2,
color: 'primary.main',
}} }}
> >
{t('product.manufacturer')} {t('product.manufacturer')}
</Typography> </Typography>
<ChevronRight sx={{ fontSize: '2.5rem', ml: 1 }} />
</Box>
<div <div
style={{ style={{
@@ -129,6 +217,46 @@ class ManufacturerCarousel extends React.Component {
zIndex: 2, pointerEvents: 'none', zIndex: 2, pointerEvents: 'none',
}} /> }} />
{/* Left Arrow */}
<IconButton
aria-label="Vorherige Hersteller anzeigen"
onClick={this.handleLeftClick}
style={{
position: 'absolute',
top: '50%',
left: '8px',
transform: 'translateY(-50%)',
zIndex: 1000,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
width: '48px',
height: '48px',
borderRadius: '50%'
}}
>
<ChevronLeft />
</IconButton>
{/* Right Arrow */}
<IconButton
aria-label="Nächste Hersteller anzeigen"
onClick={this.handleRightClick}
style={{
position: 'absolute',
top: '50%',
right: '8px',
transform: 'translateY(-50%)',
zIndex: 1000,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
width: '48px',
height: '48px',
borderRadius: '50%'
}}
>
<ChevronRight />
</IconButton>
<div <div
style={{ style={{
position: 'relative', position: 'relative',
@@ -151,8 +279,11 @@ class ManufacturerCarousel extends React.Component {
}} }}
> >
{items.map((item, index) => ( {items.map((item, index) => (
<div <Paper
key={`${item.id}-${index}`} key={`${item.id}-${index}`}
component={Link}
to={`/Hersteller/${encodeURIComponent(item.slug || '')}`}
elevation={3}
style={{ style={{
flex: '0 0 140px', flex: '0 0 140px',
width: '140px', width: '140px',
@@ -162,7 +293,20 @@ class ManufacturerCarousel extends React.Component {
justifyContent: 'center', justifyContent: 'center',
overflow: 'hidden', overflow: 'hidden',
userSelect: 'none', userSelect: 'none',
pointerEvents: 'none', textDecoration: 'none',
cursor: 'pointer',
borderRadius: '8px',
position: 'relative',
zIndex: 10,
backgroundColor: '#f0f0f0',
boxShadow: '0px 2px 1px -1px rgba(0,0,0,0.2), 0px 1px 1px 0px rgba(0,0,0,0.14), 0px 1px 3px 0px rgba(0,0,0,0.12)'
}}
sx={{
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
'&:hover': {
transform: 'translateY(-5px)',
boxShadow: 8,
},
}} }}
> >
<img <img
@@ -176,7 +320,7 @@ class ManufacturerCarousel extends React.Component {
display: 'block', display: 'block',
}} }}
/> />
</div> </Paper>
))} ))}
</div> </div>
</div> </div>

View File

@@ -10,6 +10,12 @@ import AddToCartButton from './AddToCartButton.js';
import { Link, useNavigate } 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';
import { STAR_POLYGON_POINTS } from '../utils/starPolygon.js';
import {
PRODUCT_CARD_MOBILE_MAX_WIDTH_PX,
PRODUCT_CARD_WIDTH_SM_PX,
PRODUCT_CARD_WIDTH_XS_PX,
} from '../utils/productCardLayout.js';
// Helper function to find level 1 category ID from any category ID // Helper function to find level 1 category ID from any category ID
const findLevel1CategoryId = (categoryId) => { const findLevel1CategoryId = (categoryId) => {
@@ -275,7 +281,16 @@ class Product extends Component {
<Box sx={{ <Box sx={{
position: 'relative', position: 'relative',
height: '100%', height: '100%',
width: { xs: '100%', sm: 'auto' } /* Match card width on xs so absolute NEU star is relative to the card, not the full grid row */
width: {
xs: `${PRODUCT_CARD_WIDTH_XS_PX}px`,
sm: 'auto',
},
minWidth: { xs: `${PRODUCT_CARD_WIDTH_XS_PX}px`, sm: 'auto' },
maxWidth: { xs: `${PRODUCT_CARD_WIDTH_XS_PX}px`, sm: 'none' },
display: 'flex',
justifyContent: { xs: 'center', sm: 'flex-start' },
mx: { xs: 'auto', sm: 0 },
}}> }}>
{isNew && ( {isNew && (
<div <div
@@ -302,7 +317,7 @@ class Product extends Component {
}} }}
> >
<polygon <polygon
points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20" points={STAR_POLYGON_POINTS}
fill="#20403a" fill="#20403a"
stroke="none" stroke="none"
/> />
@@ -321,7 +336,7 @@ class Product extends Component {
}} }}
> >
<polygon <polygon
points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20" points={STAR_POLYGON_POINTS}
fill="#40736b" fill="#40736b"
stroke="none" stroke="none"
/> />
@@ -334,7 +349,7 @@ class Product extends Component {
height="50" height="50"
> >
<polygon <polygon
points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20" points={STAR_POLYGON_POINTS}
fill="#609688" fill="#609688"
stroke="none" stroke="none"
/> />
@@ -344,7 +359,7 @@ class Product extends Component {
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
top: '45%', top: '40%',
left: '45%', left: '45%',
transform: 'translate(-50%, -50%) rotate(-10deg)', transform: 'translate(-50%, -50%) rotate(-10deg)',
color: 'white', color: 'white',
@@ -361,22 +376,36 @@ class Product extends Component {
<Card <Card
sx={{ sx={{
width: { xs: '100vw', sm: '250px' }, width: {
minWidth: { xs: '100vw', sm: '250px' }, xs: `${PRODUCT_CARD_WIDTH_XS_PX}px`,
sm: `${PRODUCT_CARD_WIDTH_SM_PX}px`,
},
minWidth: {
xs: `${PRODUCT_CARD_WIDTH_XS_PX}px`,
sm: `${PRODUCT_CARD_WIDTH_SM_PX}px`,
},
maxWidth: {
xs: `${PRODUCT_CARD_WIDTH_XS_PX}px`,
sm: `${PRODUCT_CARD_WIDTH_SM_PX}px`,
},
height: '100%', height: '100%',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
transition: 'transform 0.3s ease, box-shadow 0.3s ease', transition: 'transform 0.3s ease, box-shadow 0.3s ease',
position: 'relative', position: 'relative',
overflow: 'hidden', overflow: 'hidden',
borderRadius: { xs: 0, sm: '8px' }, borderRadius: { xs: '8px', sm: '8px' },
border: { xs: 'none', sm: 'inherit' }, border: { xs: '1px solid', sm: 'inherit' },
boxShadow: { xs: 'none', sm: 'inherit' }, borderColor: { xs: 'divider', sm: 'inherit' },
mx: { xs: 0, sm: 'auto' }, boxShadow: { xs: '0 1px 4px rgba(0,0,0,0.08)', sm: 'inherit' },
mx: { xs: 'auto', sm: 'auto' },
'&:hover': { '&:hover': {
transform: { xs: 'none', sm: 'translateY(-5px)' }, transform: { xs: 'none', sm: 'translateY(-5px)' },
boxShadow: { xs: 'none', sm: '0px 10px 20px rgba(0,0,0,0.1)' } boxShadow: {
} xs: '0 1px 4px rgba(0,0,0,0.08)',
sm: '0px 10px 20px rgba(0,0,0,0.1)',
},
},
}} }}
> >
{showThcBadge && ( {showThcBadge && (
@@ -459,7 +488,7 @@ class Product extends Component {
<CardMedia <CardMedia
key={index} key={index}
component="img" component="img"
height={window.innerWidth < 600 ? "240" : "180"} height={window.innerWidth < PRODUCT_CARD_MOBILE_MAX_WIDTH_PX ? "240" : "180"}
image={imgSrc} image={imgSrc}
alt={name} alt={name}
fetchPriority={this.props.priority === 'high' && index === 0 ? 'high' : 'auto'} fetchPriority={this.props.priority === 'high' && index === 0 ? 'high' : 'auto'}
@@ -488,7 +517,7 @@ class Product extends Component {
) : ( ) : (
<CardMedia <CardMedia
component="img" component="img"
height={window.innerWidth < 600 ? "240" : "180"} height={window.innerWidth < PRODUCT_CARD_MOBILE_MAX_WIDTH_PX ? "240" : "180"}
image="/assets/images/nopicture.jpg" image="/assets/images/nopicture.jpg"
alt={name} alt={name}
fetchPriority={this.props.priority === 'high' ? 'high' : 'auto'} fetchPriority={this.props.priority === 'high' ? 'high' : 'auto'}

View File

@@ -8,8 +8,11 @@ import ChevronRight from "@mui/icons-material/ChevronRight";
import Product from "./Product.js"; import Product from "./Product.js";
import { withTranslation } from 'react-i18next'; import { withTranslation } from 'react-i18next';
import { withLanguage } from '../i18n/withTranslation.js'; import { withLanguage } from '../i18n/withTranslation.js';
import {
const ITEM_WIDTH = 250 + 16; // 250px width + 16px gap getProductCarouselItemStridePx,
PRODUCT_CARD_WIDTH_SM_PX,
PRODUCT_CARD_WIDTH_XS_PX,
} from "../utils/productCardLayout.js";
const AUTO_SCROLL_SPEED = 0.5; // px per frame (~60fps, so ~30px/sec) const 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 AUTOSCROLL_RESTART_DELAY = 5000; // 5 seconds of inactivity before restarting autoscroll
const SCROLLBAR_FLASH_DURATION = 3000; // 3 seconds to show the virtual scrollbar const SCROLLBAR_FLASH_DURATION = 3000; // 3 seconds to show the virtual scrollbar
@@ -81,13 +84,31 @@ class ProductCarousel extends React.Component {
products: [], products: [],
currentLanguage: (i18n && i18n.language) || 'de', currentLanguage: (i18n && i18n.language) || 'de',
showScrollbar: false, showScrollbar: false,
itemStride:
typeof window !== "undefined"
? getProductCarouselItemStridePx()
: PRODUCT_CARD_WIDTH_SM_PX + 16,
}; };
this.carouselTrackRef = React.createRef(); this.carouselTrackRef = React.createRef();
} }
handleCarouselResize = () => {
if (!this._isMounted) return;
const next = getProductCarouselItemStridePx();
if (next !== this.state.itemStride) {
this.translateX = 0;
this.updateTrackTransform();
this.setState({ itemStride: next });
}
};
componentDidMount() { componentDidMount() {
this._isMounted = true; this._isMounted = true;
if (typeof window !== "undefined") {
window.addEventListener("resize", this.handleCarouselResize);
this.setState({ itemStride: getProductCarouselItemStridePx() });
}
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n.language; const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n.language;
logCarousel("mount", { logCarousel("mount", {
@@ -370,6 +391,9 @@ class ProductCarousel extends React.Component {
componentWillUnmount() { componentWillUnmount() {
this._isMounted = false; this._isMounted = false;
if (typeof window !== "undefined") {
window.removeEventListener("resize", this.handleCarouselResize);
}
this.stopAutoScroll(); this.stopAutoScroll();
this.clearInactivityTimer(); this.clearInactivityTimer();
this.clearScrollbarTimer(); this.clearScrollbarTimer();
@@ -430,8 +454,9 @@ class ProductCarousel extends React.Component {
this.translateX -= AUTO_SCROLL_SPEED; this.translateX -= AUTO_SCROLL_SPEED;
this.updateTrackTransform(); this.updateTrackTransform();
const { itemStride } = this.state;
const originalItemCount = this.originalProducts.length; const originalItemCount = this.originalProducts.length;
const maxScroll = ITEM_WIDTH * originalItemCount; const maxScroll = itemStride * originalItemCount;
// Check if we've scrolled past the first set of items // Check if we've scrolled past the first set of items
if (Math.abs(this.translateX) >= maxScroll) { if (Math.abs(this.translateX) >= maxScroll) {
@@ -467,14 +492,15 @@ class ProductCarousel extends React.Component {
if (this.originalProducts.length === 0) return; if (this.originalProducts.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 { itemStride } = this.state;
const originalItemCount = this.originalProducts.length; const originalItemCount = this.originalProducts.length;
const maxScroll = ITEM_WIDTH * originalItemCount; const maxScroll = itemStride * originalItemCount;
this.translateX += direction * ITEM_WIDTH; this.translateX += direction * itemStride;
// Handle wrap-around when scrolling left (positive translateX) // Handle wrap-around when scrolling left (positive translateX)
if (this.translateX > 0) { if (this.translateX > 0) {
this.translateX = -(maxScroll - ITEM_WIDTH); this.translateX = -(maxScroll - itemStride);
} }
// Handle wrap-around when scrolling right (negative translateX beyond limit) // Handle wrap-around when scrolling right (negative translateX beyond limit)
else if (Math.abs(this.translateX) >= maxScroll) { else if (Math.abs(this.translateX) >= maxScroll) {
@@ -494,9 +520,13 @@ class ProductCarousel extends React.Component {
return null; return null;
} }
const { itemStride } = this.state;
const originalItemCount = this.originalProducts.length; const originalItemCount = this.originalProducts.length;
const viewportWidth = 1080; // carousel container max-width const viewportWidth =
const itemsInView = Math.floor(viewportWidth / ITEM_WIDTH); typeof window !== "undefined"
? Math.min(1080, Math.max(0, window.innerWidth - 56))
: 1080;
const itemsInView = Math.max(1, Math.floor(viewportWidth / itemStride));
// Calculate which item is currently at the left edge (first visible) // Calculate which item is currently at the left edge (first visible)
let currentItemIndex; let currentItemIndex;
@@ -504,11 +534,11 @@ class ProductCarousel extends React.Component {
if (this.translateX === 0) { if (this.translateX === 0) {
currentItemIndex = 0; currentItemIndex = 0;
} else if (this.translateX > 0) { } else if (this.translateX > 0) {
const maxScroll = ITEM_WIDTH * originalItemCount; const maxScroll = itemStride * originalItemCount;
const effectivePosition = maxScroll + this.translateX; const effectivePosition = maxScroll + this.translateX;
currentItemIndex = Math.floor(effectivePosition / ITEM_WIDTH); currentItemIndex = Math.floor(effectivePosition / itemStride);
} else { } else {
currentItemIndex = Math.floor(Math.abs(this.translateX) / ITEM_WIDTH); currentItemIndex = Math.floor(Math.abs(this.translateX) / itemStride);
} }
// Ensure we stay within bounds // Ensure we stay within bounds
@@ -615,7 +645,7 @@ class ProductCarousel extends React.Component {
top: '50%', top: '50%',
left: '8px', left: '8px',
transform: 'translateY(-50%)', transform: 'translateY(-50%)',
zIndex: 1200, zIndex: 1000,
backgroundColor: 'rgba(255, 255, 255, 0.9)', backgroundColor: 'rgba(255, 255, 255, 0.9)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)', boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
width: '48px', width: '48px',
@@ -635,7 +665,7 @@ class ProductCarousel extends React.Component {
top: '50%', top: '50%',
right: '8px', right: '8px',
transform: 'translateY(-50%)', transform: 'translateY(-50%)',
zIndex: 1200, zIndex: 1000,
backgroundColor: 'rgba(255, 255, 255, 0.9)', backgroundColor: 'rgba(255, 255, 255, 0.9)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)', boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
width: '48px', width: '48px',
@@ -676,16 +706,19 @@ class ProductCarousel extends React.Component {
}} }}
> >
{products.map((product, index) => ( {products.map((product, index) => (
<div <Box
key={`${product.id}-${index}`} key={`${product.id}-${index}`}
className="product-carousel-item" className="product-carousel-item"
style={{ sx={{
flex: '0 0 250px', flex: {
width: '250px', xs: `0 0 ${PRODUCT_CARD_WIDTH_XS_PX}px`,
maxWidth: '250px', sm: `0 0 ${PRODUCT_CARD_WIDTH_SM_PX}px`,
minWidth: '250px', },
boxSizing: 'border-box', width: { xs: PRODUCT_CARD_WIDTH_XS_PX, sm: PRODUCT_CARD_WIDTH_SM_PX },
position: 'relative' maxWidth: { xs: PRODUCT_CARD_WIDTH_XS_PX, sm: PRODUCT_CARD_WIDTH_SM_PX },
minWidth: { xs: PRODUCT_CARD_WIDTH_XS_PX, sm: PRODUCT_CARD_WIDTH_SM_PX },
boxSizing: "border-box",
position: "relative",
}} }}
> >
<Product <Product
@@ -713,7 +746,7 @@ class ProductCarousel extends React.Component {
priority={index < 6 ? 'high' : 'auto'} priority={index < 6 ? 'high' : 'auto'}
t={t} t={t}
/> />
</div> </Box>
))} ))}
</div> </div>

View File

@@ -1089,6 +1089,8 @@ class ProductDetailPage extends Component {
const { product, loading, upgrading, error, attributeImages, /*isSteckling,*/ attributes, komponentenLoaded, komponentenData, komponentenImages, totalKomponentenPrice, totalSavings, shareAnchorEl, sharePopperOpen, snackbarOpen, snackbarMessage, snackbarSeverity } = const { product, loading, upgrading, error, attributeImages, /*isSteckling,*/ attributes, komponentenLoaded, komponentenData, komponentenImages, totalKomponentenPrice, totalSavings, shareAnchorEl, sharePopperOpen, snackbarOpen, snackbarMessage, snackbarSeverity } =
this.state; this.state;
const hasAttributeImages = attributes.some((attr) => attributeImages[attr.kMerkmalWert]);
// Debug alerts removed // Debug alerts removed
@@ -1172,18 +1174,17 @@ class ProductDetailPage extends Component {
<Box <Box
sx={{ sx={{
mb: 2, mb: 2,
position: ["-webkit-sticky", "sticky"], // Provide both prefixed and standard position: "sticky",
top: { top: {
xs: "110px", xs: "calc(env(safe-area-inset-top, 0px) + 160px)",
sm: "110px", sm: "110px",
md: "110px", md: "110px",
lg: "110px", lg: "110px",
} /* Offset to sit below the header 120 mith menu for md and lg*/, },
left: 0, left: 0,
width: "100%", width: "100%",
display: "flex", display: "flex",
zIndex: (theme) => zIndex: (theme) => theme.zIndex.appBar - 1,
theme.zIndex.appBar - 1 /* Just below the AppBar */,
py: 0, py: 0,
px: 2, px: 2,
}} }}
@@ -1198,10 +1199,19 @@ class ProductDetailPage extends Component {
borderRadius: 1, borderRadius: 1,
}} }}
> >
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary" component="div">
<Link <Link
to="/" to="/"
onClick={() => this.props.navigate(-1)} onClick={(e) => {
e.preventDefault();
if (this.props.navigate) {
if (typeof window !== "undefined" && window.history.length > 1) {
this.props.navigate(-1);
} else {
this.props.navigate("/");
}
}
}}
style={{ style={{
paddingLeft: 16, paddingLeft: 16,
paddingRight: 16, paddingRight: 16,
@@ -1298,7 +1308,19 @@ class ProductDetailPage extends Component {
<Box sx={{ minHeight: "107px", display: "flex", justifyContent: "space-between", alignItems: "flex-start", mb: 2, gap: 2 }}> <Box sx={{ minHeight: "107px", display: "flex", justifyContent: "space-between", alignItems: "flex-start", mb: 2, gap: 2 }}>
{(attributes.some(attr => attributeImages[attr.kMerkmalWert]) || attributes.some(attr => !attributeImages[attr.kMerkmalWert])) && ( {(attributes.some(attr => attributeImages[attr.kMerkmalWert]) || attributes.some(attr => !attributeImages[attr.kMerkmalWert])) && (
<Stack direction="row" spacing={0} sx={{ flexWrap: "wrap", gap: 1, flex: 1 }}> <Stack
direction="row"
spacing={0}
sx={{
flexWrap: "wrap",
gap: 1,
flex: 1,
display: {
xs: hasAttributeImages ? "flex" : "none",
sm: "flex",
},
}}
>
{attributes {attributes
.filter(attribute => attributeImages[attribute.kMerkmalWert]) .filter(attribute => attributeImages[attribute.kMerkmalWert])
.map((attribute) => { .map((attribute) => {
@@ -1321,7 +1343,11 @@ class ProductDetailPage extends Component {
key={attribute.kMerkmalWert} key={attribute.kMerkmalWert}
label={attribute.cWert} label={attribute.cWert}
disabled disabled
sx={{ sx={(theme) => ({
// Max-width query: reliable on portrait phones (avoids display:contents wrapper quirks)
[theme.breakpoints.down('sm')]: {
display: 'none',
},
'&.Mui-disabled': { '&.Mui-disabled': {
opacity: 1, // ← Remove the "fog" opacity: 1, // ← Remove the "fog"
}, },
@@ -1329,7 +1355,7 @@ class ProductDetailPage extends Component {
fontWeight: 'bold', fontWeight: 'bold',
color: 'inherit', // ← Keep normal text color color: 'inherit', // ← Keep normal text color
}, },
}} })}
/> />
))} ))}
</Stack> </Stack>

View File

@@ -1,13 +1,35 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper'; import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import Tooltip from '@mui/material/Tooltip';
import CircularProgress from '@mui/material/CircularProgress';
import NotificationsIcon from '@mui/icons-material/Notifications';
import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive';
import Filter from './Filter.js'; import Filter from './Filter.js';
import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom'; import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom';
import { setSessionSetting, removeSessionSetting, clearAllSessionSettings } from '../utils/sessionStorage.js'; import { setSessionSetting, removeSessionSetting, clearAllSessionSettings } from '../utils/sessionStorage.js';
import { withI18n } from '../i18n/withTranslation.js'; import { withI18n } from '../i18n/withTranslation.js';
import {
PUSH_SUBSCRIPTIONS_CHANGED_EVENT,
emitPushSubscriptionsChanged,
isPushApiSupported,
fetchPushConfiguration,
registerPushServiceWorker,
ensurePushSubscription,
categoryPushStatus,
categoryPushSubscribe,
categoryPushUnsubscribe,
parseSubscribedStatus,
parseSuccess,
} from '../utils/categoryPush.js';
const isNew = (neu) => neu && (new Date().getTime() - new Date(neu).getTime() < 30 * 24 * 60 * 60 * 1000); const isNew = (neu) => neu && (new Date().getTime() - new Date(neu).getTime() < 30 * 24 * 60 * 60 * 1000);
/** Category push subscribe UI only when the category has more than this many articles. */
const MIN_ARTICLES_FOR_CATEGORY_PUSH = 10;
// HOC to provide router props to class components // HOC to provide router props to class components
const withRouter = (ClassComponent) => { const withRouter = (ClassComponent) => {
return (props) => { return (props) => {
@@ -38,19 +60,35 @@ class ProductFilters extends Component {
uniqueManufacturerArray, uniqueManufacturerArray,
attributeGroups, attributeGroups,
manufacturerImages: new Map(), // id (number) → object URL manufacturerImages: new Map(), // id (number) → object URL
pushInteractive: false,
pushSubscribed: false,
pushBusy: false,
pushError: null,
}; };
this._manufacturerImageUrls = []; // track for cleanup this._manufacturerImageUrls = []; // track for cleanup
} }
componentDidMount() { componentDidMount() {
this.onPushSubscriptionsChanged = () => {
this.refreshCategoryPushStatus();
};
this.adjustPaperHeight(); this.adjustPaperHeight();
window.addEventListener('resize', this.adjustPaperHeight); window.addEventListener('resize', this.adjustPaperHeight);
window.addEventListener(
PUSH_SUBSCRIPTIONS_CHANGED_EVENT,
this.onPushSubscriptionsChanged
);
this._loadManufacturerImages(); this._loadManufacturerImages();
this.refreshCategoryPushStatus();
} }
componentWillUnmount() { componentWillUnmount() {
window.removeEventListener('resize', this.adjustPaperHeight); window.removeEventListener('resize', this.adjustPaperHeight);
window.removeEventListener(
PUSH_SUBSCRIPTIONS_CHANGED_EVENT,
this.onPushSubscriptionsChanged
);
this._manufacturerImageUrls.forEach(url => URL.revokeObjectURL(url)); this._manufacturerImageUrls.forEach(url => URL.revokeObjectURL(url));
} }
@@ -102,17 +140,148 @@ class ProductFilters extends Component {
const attributeGroups = this._getAttributeGroups(this.props.attributes); const attributeGroups = this._getAttributeGroups(this.props.attributes);
this.setState({attributeGroups}); this.setState({attributeGroups});
} }
const prevCount = prevProps.products?.length || 0;
const nextCount = this.props.products?.length || 0;
if (
prevProps.dataParam !== this.props.dataParam ||
prevProps.dataType !== this.props.dataType ||
prevProps.params?.categoryId !== this.props.params?.categoryId ||
prevCount !== nextCount
) {
this.refreshCategoryPushStatus();
} }
}
kKategorieNumber = () => {
const { dataParam, dataType } = this.props;
if (dataType !== 'category') return null;
if (dataParam == null || dataParam === '') return null;
const n = typeof dataParam === 'number' ? dataParam : parseInt(String(dataParam), 10);
return Number.isFinite(n) && n > 0 ? n : null;
};
shouldShowCategoryPush = () =>
(this.props.products?.length || 0) > MIN_ARTICLES_FOR_CATEGORY_PUSH;
refreshCategoryPushStatus = async () => {
const kKat = this.kKategorieNumber();
if (!kKat || !this.shouldShowCategoryPush() || !isPushApiSupported()) {
this.setState({
pushInteractive: false,
pushSubscribed: false,
pushError: null,
});
return;
}
try {
const cfg = await fetchPushConfiguration();
if (!cfg.configured || !cfg.publicKey) {
this.setState({ pushInteractive: false });
return;
}
await registerPushServiceWorker();
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (!subscription) {
this.setState({
pushInteractive: true,
pushSubscribed: false,
pushError: null,
});
return;
}
const statusData = await categoryPushStatus(kKat, subscription.endpoint);
this.setState({
pushInteractive: true,
pushSubscribed: parseSubscribedStatus(statusData),
pushError: null,
});
} catch (e) {
console.warn('ProductFilters: category push init failed', e);
this.setState({ pushInteractive: false });
}
};
handleCategoryPushClick = async () => {
const t = this.props.t;
if (!this.state.pushInteractive || this.state.pushBusy) return;
const kKat = this.kKategorieNumber();
if (!kKat || !this.shouldShowCategoryPush()) return;
this.setState({ pushBusy: true, pushError: null });
try {
if (this.state.pushSubscribed) {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (!subscription) {
this.setState({ pushSubscribed: false, pushBusy: false });
return;
}
const res = await categoryPushUnsubscribe(subscription.endpoint, kKat);
if (parseSuccess(res)) {
this.setState({ pushSubscribed: false });
emitPushSubscriptionsChanged();
} else {
this.setState({
pushError:
res?.message ||
res?.error ||
(t ? t('productDialogs.pushNotifyError') : ''),
});
}
} else {
const perm = await Notification.requestPermission();
if (perm !== 'granted') {
this.setState({
pushError: t ? t('productDialogs.pushNotifyPermissionDenied') : '',
pushBusy: false,
});
return;
}
const cfg = await fetchPushConfiguration();
if (!cfg.configured || !cfg.publicKey) {
this.setState({
pushError: t ? t('productDialogs.pushNotifyServerDisabled') : '',
pushBusy: false,
});
return;
}
await registerPushServiceWorker();
const subscription = await ensurePushSubscription(cfg.publicKey);
const res = await categoryPushSubscribe(kKat, subscription);
if (parseSuccess(res)) {
this.setState({ pushSubscribed: true });
emitPushSubscriptionsChanged();
} else {
this.setState({
pushError:
res?.message ||
res?.error ||
(t ? t('productDialogs.pushNotifyError') : ''),
});
}
}
} catch (e) {
console.error('ProductFilters: category push', e);
this.setState({
pushError: e.message || (t ? t('productDialogs.pushNotifyError') : ''),
});
} finally {
this.setState({ pushBusy: false });
}
};
adjustPaperHeight = () => { adjustPaperHeight = () => {
// Skip height adjustment on xs screens
if (window.innerWidth < 600) return;
// Get reference to our paper element
const paperEl = document.getElementById('filters-paper'); const paperEl = document.getElementById('filters-paper');
if (!paperEl) return; if (!paperEl) return;
// No min-height on mobile — also clears inline style after resize from desktop
if (window.innerWidth < 600) {
paperEl.style.minHeight = '';
return;
}
// Get viewport height // Get viewport height
const viewportHeight = window.innerHeight; const viewportHeight = window.innerHeight;
@@ -200,35 +369,140 @@ class ProductFilters extends Component {
} }
render() { render() {
const kKategorie = this.kKategorieNumber();
const showCategoryPush = kKategorie && this.shouldShowCategoryPush();
const {
pushInteractive,
pushSubscribed,
pushBusy,
pushError,
} = this.state;
const pushDisabledHint =
showCategoryPush && !pushInteractive && !pushBusy
? isPushApiSupported()
? this.props.t
? this.props.t('productDialogs.pushNotifyServerDisabled')
: ''
: this.props.t
? this.props.t('filters.notifyNewArticlesBrowserUnsupported')
: 'Ihr Browser unterstützt keine Push-Benachrichtigungen.'
: '';
return ( return (
<Box
sx={{
px: { xs: 2, sm: 0 },
pt: { xs: 2, sm: 0 },
/* Room below Paper so elevation shadow isnt clipped by grid/parent */
pb: { xs: 2, sm: 2 },
overflow: 'visible',
/* Same green as ProductList / product strip mobile (#e8f5e8), not theme background.default (#C8E6C9) */
bgcolor: { xs: '#e8f5e8', sm: 'transparent' },
}}
>
<Paper <Paper
id="filters-paper" id="filters-paper"
elevation={window.innerWidth < 600 ? 0 : 1} elevation={1}
sx={{ sx={{
p: { xs: 1, sm: 2 }, p: { xs: 2.5, sm: 2.5 },
borderRadius: { xs: 0, sm: 2 }, mx: { sm: 'auto' },
maxWidth: '100%',
borderRadius: 2,
bgcolor: 'background.paper', bgcolor: 'background.paper',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
border: { xs: 'none', sm: 'inherit' }, border: { xs: 'none', sm: 'inherit' },
boxShadow: { xs: 'none', sm: 'inherit' }, boxSizing: 'border-box',
mx: { xs: 0, sm: 'auto' }, overflow: 'visible',
width: { xs: '100%', sm: 'auto' }
}} }}
> >
{this.props.dataType == 'category' && ( {this.props.dataType == 'category' && (
<Box sx={{ mb: 4 }}>
<Typography <Typography
variant="h3" variant="h3"
component="h1" component="h1"
sx={{ sx={{
mb: 4, mb: showCategoryPush ? 1.5 : 4,
fontFamily: 'SwashingtonCP', fontFamily: 'SwashingtonCP',
color: 'primary.main' color: 'primary.main',
}} }}
> >
{this.props.categoryName} {this.props.categoryName}
</Typography> </Typography>
{showCategoryPush && (
<Box sx={{ width: '100%' }}>
<Tooltip title={pushDisabledHint} arrow>
<span style={{ display: 'block', width: '100%' }}>
<Button
fullWidth
variant="outlined"
color="inherit"
size="small"
onClick={this.handleCategoryPushClick}
disabled={!pushInteractive || pushBusy}
startIcon={
pushBusy ? (
<CircularProgress size={14} sx={{ color: 'inherit' }} />
) : pushSubscribed ? (
<NotificationsActiveIcon sx={{ fontSize: 18, color: '#2e7d32' }} />
) : (
<NotificationsIcon sx={{ fontSize: 18, color: 'rgba(0,0,0,0.65)' }} />
)
}
sx={{
borderRadius: 1,
fontWeight: 600,
fontSize: '0.7rem',
lineHeight: 1.2,
backgroundColor: '#fff',
color: 'text.primary',
border: '1px solid',
borderColor: 'divider',
boxShadow: 'none',
whiteSpace: 'normal',
textAlign: 'center',
py: 0.4,
px: 0.75,
minHeight: 28,
'& .MuiButton-label': {
whiteSpace: 'normal',
lineHeight: 1.2,
},
'& .MuiButton-startIcon': {
mr: 0.5,
'& > *:nth-of-type(1)': { fontSize: 18 },
},
'&:hover': {
backgroundColor: 'grey.50',
borderColor: 'divider',
boxShadow: 'none',
},
'&.Mui-disabled': {
backgroundColor: '#fff',
color: 'action.disabled',
borderColor: 'action.disabledBackground',
},
}}
>
{this.props.t
? this.props.t('filters.notifyNewArticles')
: 'Bei neuen Artikeln benachrichtigen'}
</Button>
</span>
</Tooltip>
{pushError && (
<Typography
variant="caption"
color="error"
sx={{ display: 'block', mt: 0.5, textAlign: 'center' }}
>
{pushError}
</Typography>
)}
</Box>
)}
</Box>
)} )}
@@ -295,6 +569,7 @@ class ProductFilters extends Component {
/> />
</>)} </>)}
</Paper> </Paper>
</Box>
); );
} }
} }

View File

@@ -241,7 +241,7 @@ class ProductList extends Component {
<Box sx={{ <Box sx={{
display: 'flex', display: { xs: 'none', sm: 'flex' },
gap: { xs: 0.5, sm: 1 }, gap: { xs: 0.5, sm: 1 },
alignItems: 'center', alignItems: 'center',
flexWrap: 'wrap', flexWrap: 'wrap',
@@ -438,7 +438,11 @@ class ProductList extends Component {
</Stack> </Stack>
</Box> </Box>
<Grid container spacing={{ xs: 0, sm: 2 }}> <Grid
container
spacing={{ xs: 0, sm: 2 }}
sx={{ bgcolor: { xs: '#e8f5e8', sm: 'transparent' } }}
>
{this.renderNoProductsMessage()} {this.renderNoProductsMessage()}
{products.map((product, index) => ( {products.map((product, index) => (
<Grid <Grid
@@ -448,6 +452,7 @@ class ProductList extends Component {
justifyContent: { xs: 'stretch', sm: 'center' }, justifyContent: { xs: 'stretch', sm: 'center' },
mb: { xs: 0, sm: 1 }, mb: { xs: 0, sm: 1 },
width: { xs: '100%', sm: 'auto' }, width: { xs: '100%', sm: 'auto' },
bgcolor: { xs: '#e8f5e8', sm: 'transparent' },
borderBottom: { borderBottom: {
xs: index < products.length - 1 ? '16px solid #e8f5e8' : 'none', xs: index < products.length - 1 ? '16px solid #e8f5e8' : 'none',
sm: 'none' sm: 'none'

View File

@@ -327,7 +327,7 @@ class SharedCarousel extends React.Component {
top: '50%', top: '50%',
left: '8px', left: '8px',
transform: 'translateY(-50%)', transform: 'translateY(-50%)',
zIndex: 1200, zIndex: 1000,
backgroundColor: 'rgba(255, 255, 255, 0.9)', backgroundColor: 'rgba(255, 255, 255, 0.9)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)', boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
width: '48px', width: '48px',
@@ -347,7 +347,7 @@ class SharedCarousel extends React.Component {
top: '50%', top: '50%',
right: '8px', right: '8px',
transform: 'translateY(-50%)', transform: 'translateY(-50%)',
zIndex: 1200, zIndex: 1000,
backgroundColor: 'rgba(255, 255, 255, 0.9)', backgroundColor: 'rgba(255, 255, 255, 0.9)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)', boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
width: '48px', width: '48px',

View File

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

View File

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

View File

@@ -159,8 +159,23 @@ class CategoryList extends Component {
} }
} }
/**
* Which nav item should appear active: home, konfigurator, neu, bald, a level-1 category id, or null.
* neu/bald are not in the category tree as seoNames, so pathname / explicit props must drive them.
* Home vs Konfigurator both had categoryId null from the app; pathname disambiguates.
*/
getNavHighlightKey() {
const pathname = this.props.pathname || "";
if (pathname === "/") return "home";
if (pathname === "/Konfigurator" || pathname.startsWith("/Konfigurator/")) return "konfigurator";
if (pathname === "/Kategorie/neu" || this.props.activeCategoryId === "neu") return "neu";
if (pathname === "/Kategorie/bald" || this.props.activeCategoryId === "bald") return "bald";
return this.state.activeCategoryId;
}
render() { render() {
const { categories, mobileMenuOpen, activeCategoryId } = this.state; const { categories, mobileMenuOpen } = this.state;
const navKey = this.getNavHighlightKey();
const renderCategoryRow = (categories, isMobile = false) => ( const renderCategoryRow = (categories, isMobile = false) => (
<Box <Box
@@ -168,7 +183,7 @@ class CategoryList extends Component {
display: "flex", display: "flex",
justifyContent: "flex-start", justifyContent: "flex-start",
alignItems: "center", alignItems: "center",
flexWrap: "wrap", flexWrap: isMobile ? "wrap" : "nowrap",
overflowX: "visible", 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
@@ -182,7 +197,7 @@ class CategoryList extends Component {
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.85rem",
textTransform: "none", textTransform: "none",
whiteSpace: "nowrap", whiteSpace: "nowrap",
opacity: 0.9, opacity: 0.9,
@@ -194,7 +209,7 @@ class CategoryList extends Component {
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 && { ...(navKey === "home" && {
bgcolor: "#fff", bgcolor: "#fff",
textShadow: "none", textShadow: "none",
opacity: 1, opacity: 1,
@@ -218,7 +233,7 @@ class CategoryList extends Component {
<HomeIcon sx={{ <HomeIcon sx={{
fontSize: "1rem", fontSize: "1rem",
mr: isMobile ? 1 : 0, mr: isMobile ? 1 : 0,
color: activeCategoryId === null ? "#2e7d32" : "inherit" color: navKey === "home" ? "#2e7d32" : "inherit"
}} /> }} />
{isMobile && ( {isMobile && (
<Box sx={{ position: "relative", display: "inline-block" }}> <Box sx={{ position: "relative", display: "inline-block" }}>
@@ -227,7 +242,7 @@ class CategoryList extends Component {
className="bold-text" className="bold-text"
sx={{ sx={{
fontWeight: "bold", fontWeight: "bold",
color: activeCategoryId === null ? "#2e7d32" : "transparent", color: navKey === "home" ? "#2e7d32" : "transparent",
position: "relative", position: "relative",
zIndex: 2, zIndex: 2,
}} }}
@@ -239,7 +254,7 @@ class CategoryList extends Component {
className="thin-text" className="thin-text"
sx={{ sx={{
fontWeight: "400", fontWeight: "400",
color: activeCategoryId === null ? "transparent" : "inherit", color: navKey === "home" ? "transparent" : "inherit",
position: "absolute", position: "absolute",
top: 0, top: 0,
left: 0, left: 0,
@@ -260,7 +275,7 @@ class CategoryList extends Component {
aria-label="Neuheiten" aria-label="Neuheiten"
onClick={isMobile ? this.handleMobileCategoryClick : undefined} onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{ sx={{
fontSize: "0.75rem", fontSize: "0.85rem",
textTransform: "none", textTransform: "none",
whiteSpace: "nowrap", whiteSpace: "nowrap",
opacity: 0.9, opacity: 0.9,
@@ -271,12 +286,32 @@ class CategoryList extends Component {
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",
...(navKey === "neu" && {
bgcolor: "#fff",
textShadow: "none",
opacity: 1,
}),
"&:hover": {
opacity: 1,
bgcolor: "#fff",
textShadow: "none",
"& .MuiSvgIcon-root": {
color: "#2e7d32 !important",
},
"& .bold-text": {
color: "#2e7d32 !important",
},
"& .thin-text": {
color: "transparent !important",
},
},
}} }}
> >
<FiberNewIcon sx={{ <FiberNewIcon sx={{
fontSize: "1rem", fontSize: "1rem",
mr: isMobile ? 1 : 0 mr: isMobile ? 1 : 0,
color: navKey === "neu" ? "#2e7d32" : "inherit"
}} /> }} />
{isMobile && ( {isMobile && (
<Box sx={{ position: "relative", display: "inline-block" }}> <Box sx={{ position: "relative", display: "inline-block" }}>
@@ -285,7 +320,7 @@ class CategoryList extends Component {
className="bold-text" className="bold-text"
sx={{ sx={{
fontWeight: "bold", fontWeight: "bold",
color: "transparent", color: navKey === "neu" ? "#2e7d32" : "transparent",
position: "relative", position: "relative",
zIndex: 2, zIndex: 2,
}} }}
@@ -297,7 +332,7 @@ class CategoryList extends Component {
className="thin-text" className="thin-text"
sx={{ sx={{
fontWeight: "400", fontWeight: "400",
color: "inherit", color: navKey === "neu" ? "transparent" : "inherit",
position: "absolute", position: "absolute",
top: 0, top: 0,
left: 0, left: 0,
@@ -318,7 +353,7 @@ class CategoryList extends Component {
aria-label={this.props.t ? this.props.t('navigation.soon') : 'Demnächst'} aria-label={this.props.t ? this.props.t('navigation.soon') : 'Demnächst'}
onClick={isMobile ? this.handleMobileCategoryClick : undefined} onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{ sx={{
fontSize: "0.75rem", fontSize: "0.85rem",
textTransform: "none", textTransform: "none",
whiteSpace: "nowrap", whiteSpace: "nowrap",
opacity: 0.9, opacity: 0.9,
@@ -329,12 +364,32 @@ class CategoryList extends Component {
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",
...(navKey === "bald" && {
bgcolor: "#fff",
textShadow: "none",
opacity: 1,
}),
"&:hover": {
opacity: 1,
bgcolor: "#fff",
textShadow: "none",
"& .MuiSvgIcon-root": {
color: "#2e7d32 !important",
},
"& .bold-text": {
color: "#2e7d32 !important",
},
"& .thin-text": {
color: "transparent !important",
},
},
}} }}
> >
<LocalShippingIcon sx={{ <LocalShippingIcon sx={{
fontSize: "1rem", fontSize: "1rem",
mr: isMobile ? 1 : 0 mr: isMobile ? 1 : 0,
color: navKey === "bald" ? "#2e7d32" : "inherit"
}} /> }} />
{isMobile && ( {isMobile && (
<Box sx={{ position: "relative", display: "inline-block" }}> <Box sx={{ position: "relative", display: "inline-block" }}>
@@ -342,7 +397,7 @@ class CategoryList extends Component {
className="bold-text" className="bold-text"
sx={{ sx={{
fontWeight: "bold", fontWeight: "bold",
color: "transparent", color: navKey === "bald" ? "#2e7d32" : "transparent",
position: "relative", position: "relative",
zIndex: 2, zIndex: 2,
}} }}
@@ -353,7 +408,7 @@ class CategoryList extends Component {
className="thin-text" className="thin-text"
sx={{ sx={{
fontWeight: "400", fontWeight: "400",
color: "inherit", color: navKey === "bald" ? "transparent" : "inherit",
position: "absolute", position: "absolute",
top: 0, top: 0,
left: 0, left: 0,
@@ -380,7 +435,7 @@ class CategoryList extends Component {
size="small" size="small"
onClick={isMobile ? this.handleMobileCategoryClick : undefined} onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{ sx={{
fontSize: "0.75rem", fontSize: "0.85rem",
textTransform: "none", textTransform: "none",
whiteSpace: "nowrap", whiteSpace: "nowrap",
opacity: 0.9, opacity: 0.9,
@@ -392,7 +447,7 @@ class CategoryList extends Component {
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 === category.id && { ...(navKey === category.id && {
bgcolor: "#fff", bgcolor: "#fff",
textShadow: "none", textShadow: "none",
opacity: 1, opacity: 1,
@@ -416,7 +471,7 @@ class CategoryList extends Component {
className="bold-text" className="bold-text"
sx={{ sx={{
fontWeight: "bold", fontWeight: "bold",
color: activeCategoryId === category.id ? "#2e7d32" : "transparent", color: navKey === category.id ? "#2e7d32" : "transparent",
position: "relative", position: "relative",
zIndex: 2, zIndex: 2,
}} }}
@@ -428,7 +483,7 @@ class CategoryList extends Component {
className="thin-text" className="thin-text"
sx={{ sx={{
fontWeight: "400", fontWeight: "400",
color: activeCategoryId === category.id ? "transparent" : "inherit", color: navKey === category.id ? "transparent" : "inherit",
position: "absolute", position: "absolute",
top: 0, top: 0,
left: 0, left: 0,
@@ -451,7 +506,7 @@ class CategoryList extends Component {
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.85rem",
opacity: 0.9, opacity: 0.9,
}} }}
> >
@@ -464,10 +519,10 @@ class CategoryList extends Component {
to="/Konfigurator" to="/Konfigurator"
color="inherit" color="inherit"
size="small" size="small"
aria-label="Zur Startseite" aria-label={this.props.t ? this.props.t('navigation.konfiguratorAria') : 'Zum Konfigurator'}
onClick={isMobile ? this.handleMobileCategoryClick : undefined} onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{ sx={{
fontSize: "0.75rem", fontSize: "0.85rem",
textTransform: "none", textTransform: "none",
whiteSpace: "nowrap", whiteSpace: "nowrap",
opacity: 0.9, opacity: 0.9,
@@ -479,7 +534,7 @@ class CategoryList extends Component {
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 && { ...(navKey === "konfigurator" && {
bgcolor: "#fff", bgcolor: "#fff",
textShadow: "none", textShadow: "none",
opacity: 1, opacity: 1,
@@ -503,7 +558,7 @@ class CategoryList extends Component {
<SettingsIcon sx={{ <SettingsIcon sx={{
fontSize: "1rem", fontSize: "1rem",
mr: isMobile ? 1 : 0, mr: isMobile ? 1 : 0,
color: activeCategoryId === null ? "#2e7d32" : "inherit" color: navKey === "konfigurator" ? "#2e7d32" : "inherit"
}} /> }} />
{isMobile && ( {isMobile && (
<Box sx={{ position: "relative", display: "inline-block" }}> <Box sx={{ position: "relative", display: "inline-block" }}>
@@ -512,26 +567,26 @@ class CategoryList extends Component {
className="bold-text" className="bold-text"
sx={{ sx={{
fontWeight: "bold", fontWeight: "bold",
color: activeCategoryId === null ? "#2e7d32" : "transparent", color: navKey === "konfigurator" ? "#2e7d32" : "transparent",
position: "relative", position: "relative",
zIndex: 2, zIndex: 2,
}} }}
> >
{this.props.t ? this.props.t('navigation.home') : 'Startseite'} {this.props.t ? this.props.t('sections.konfigurator') : 'Konfigurator'}
</Box> </Box>
{/* Thin text (positioned on top) */} {/* Thin text (positioned on top) */}
<Box <Box
className="thin-text" className="thin-text"
sx={{ sx={{
fontWeight: "400", fontWeight: "400",
color: activeCategoryId === null ? "transparent" : "inherit", color: navKey === "konfigurator" ? "transparent" : "inherit",
position: "absolute", position: "absolute",
top: 0, top: 0,
left: 0, left: 0,
zIndex: 1, zIndex: 1,
}} }}
> >
{this.props.t ? this.props.t('navigation.home') : 'Startseite'} {this.props.t ? this.props.t('sections.konfigurator') : 'Konfigurator'}
</Box> </Box>
</Box> </Box>
)} )}

View File

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

View File

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

View File

@@ -1,3 +1,18 @@
export default { export default {
"privacyRead": "تم القراءة والموافقة", "privacyRead": "قريت ووافقت",
"privacyPromptBefore": "من فضلك أكد إنك قرأت ",
"privacyPolicyLink": "سياسة الخصوصية",
"privacyPromptAfter": " ووافقت عليها. ",
"telegramAssistantIntro": "كمان تقدر تتواصل مع مساعد Growheads على Telegram:",
"telegramAssistantLink": "t.me/Growheads_de_Bot",
"assistantTitle": "المساعد",
"placeholderRecording": "جارٍ التسجيل…",
"inputPlaceholder": "تقدر تسألني عن سلالات القنب…",
"send": "إرسال",
"closeAria": "إغلاق المساعد",
"micStartAria": "ابدأ تسجيل الصوت",
"micStopAria": "إيقاف التسجيل",
"uploadImageAria": "رفع صورة",
"micPermissionDenied": "تعذر الوصول إلى الميكروفون. من فضلك راجع أذونات المتصفح.",
"uploadedImageAlt": "صورة مرفوعة"
}; };

View File

@@ -1,18 +1,15 @@
export default { export default {
"invoiceAddress": "عنوان الفاتورة", "invoiceAddress": "عنوان الفواتير",
"deliveryAddress": "عنوان التوصيل", "deliveryAddress": "عنوان التوصيل",
"saveForFuture": "احفظ للطلبات المستقبلية", "saveForFuture": "احفظ للطلبات القادمة",
"pickupDate": "لمين التاريخ مطلوب استلام القصاصات؟", "pickupDate": "إيه التاريخ المطلوب لاستلام العقل؟",
"note": "ملاحظة", "note": "ملاحظة",
"sameAddress": "عنوان التوصيل هو نفسه عنوان الفاتورة", "sameAddress": "عنوان التوصيل هو نفسه عنوان الفواتير",
"termsAccept": "لقد قرأت الشروط والأحكام، سياسة الخصوصية، وأحكام حق الانسحاب", "termsAccept": "لقد قرأت الشروط والأحكام، وسياسة الخصوصية، وسياسة الإلغاء",
"selectDeliveryMethod": "اختر طريقة الشحن", "addressValidationError": "من فضلك راجع البيانات اللي دخلتها في خانات العنوان.",
"selectPaymentMethod": "اختر طريقة الدفع", "processingOrder": "جاري معالجة الطلب...",
"orderSummary": "ملخص الطلب",
"addressValidationError": "يرجى التحقق من بياناتك في حقول العنوان.",
"processingOrder": "يتم معالجة الطلب...",
"completeOrder": "إتمام الطلب", "completeOrder": "إتمام الطلب",
"termsValidationError": "يرجى قبول الشروط والأحكام، سياسة الخصوصية، وحق الانسحاب للمتابعة.", "termsValidationError": "من فضلك وافق على الشروط والأحكام، وسياسة الخصوصية، وسياسة الإلغاء علشان تكمّل.",
"addressFields": { "addressFields": {
"firstName": "الاسم الأول", "firstName": "الاسم الأول",
"lastName": "اسم العائلة", "lastName": "اسم العائلة",

View File

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

View File

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

View File

@@ -2,6 +2,8 @@ export default {
"sorting": "الترتيب", "sorting": "الترتيب",
"perPage": "لكل صفحة", "perPage": "لكل صفحة",
"availability": "التوفر", "availability": "التوفر",
"manufacturer": "الشركة المصنعة", "manufacturer": "المصنّع",
"all": "الكل" "all": "الكل",
"notifyNewArticles": "إشعار عند توفر منتجات جديدة",
"notifyNewArticlesBrowserUnsupported": "المتصفح لا يدعم إشعارات الدفع."
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,17 @@
export default { export default {
"sections": { "sections": {
"chatbot": { "chatbot": {
"title": "استخدام روبوت دردشة ذكي (OpenAI API)", "title": "شات بوت بالذكاء الاصطناعي (OpenAI API)",
"content": "نحن نستخدم روبوت دردشة مدعوم بالذكاء الاصطناعي على موقعنا الإلكتروني، والذي يتم تشغيله عبر واجهة برمجة التطبيقات (API) لمزود الخدمة OpenAI. يهدف روبوت الدردشة إلى الرد على استفسارات الزوار بكفاءة وبشكل تلقائي، وبالتالي توفير وظيفة دعم. عند استخدامك لروبوت الدردشة، يتم معالجة مدخلاتك بواسطة النظام لتوليد ردود مناسبة. تتم المعالجة بشكل مجهول لا يتم جمع أو تخزين عناوين IP أو أي بيانات شخصية أخرى (مثل الاسم أو بيانات الاتصال).", "intro": "بنستخدم شات بوت مدعوم بالذكاء الاصطناعي على موقعنا الإلكتروني، وبيتوفّر من خلال OpenAI API، علشان نرد تلقائيًا على الاستفسارات ونحسّن الدعم بتاعنا.",
"legalBasis": "الأساس القانوني لاستخدام روبوت الدردشة هو مصلحتنا المشروعة وفقًا للمادة 6 الفقرة 1 الحرف f من DSGVO. تكمن هذه المصلحة في توفير دعم فعال للزوار وكذلك تحسين تجربة المستخدم على موقعنا الإلكتروني.", "processing": "عند استخدام الشات بوت، المحتوى اللي بتدخّله بيتنقل إلى OpenAI وبيتتم معالجته هناك نيابةً عنّا علشان يتكوّن رد مناسب. برجاء ملاحظة إن المحتوى المُدخل ممكن كمان يحتوي على بيانات شخصية لو إنت أدخلت المعلومات دي بنفسك.",
"dataRecipient": "المستلم لبيانات الدردشة هو OpenAI (OpenAI OpCo, LLC) كمزود خدمة فني. تقوم OpenAI بمعالجة محتوى الدردشة المرسل على خوادمها حصريًا لغرض توليد الردود. تعمل OpenAI كمعالج بيانات وفقًا للمادة 28 من DSGVO ولا تستخدم البيانات لأغراضها الخاصة. لقد أبرمنا عقد معالجة بيانات مع OpenAI، والذي يتضمن بنود العقد النموذجية للاتحاد الأوروبي كضمانات مناسبة لحماية البيانات. يقع المقر الرئيسي لـ OpenAI في الولايات المتحدة الأمريكية؛ ومن خلال الموافقة على بنود العقد النموذجية، يتم ضمان مستوى حماية بيانات يعادل مستوى الاتحاد الأوروبي عند نقل بياناتك.", "legalBasis": "الأساس القانوني للمعالجة هو Art. 6 para. 1 lit. f DSGVO. المصلحة المشروعة بتاعتنا بتكمن في التعامل بكفاءة مع الاستفسارات وتحسين العرض الإلكتروني بتاعنا.",
"dataRetention": "نحتفظ باستفسارات الدردشة الخاصة بك فقط طالما كان ذلك ضروريًا للمعالجة والرد. بمجرد الانتهاء من طلبك، يتم حذف سجلات الدردشة أو إخفاء هويتها على الفور. وفقًا لتصريحاتها الخاصة، تحتفظ OpenAI ببيانات الدردشة المعالجة مؤقتًا فقط وتحذفها تلقائيًا بعد مدة أقصاها 30 يومًا.", "dataRecipient": "الجهة المستلمة للبيانات هي OpenAI. بالنسبة للمستخدمين في المنطقة الاقتصادية الأوروبية وسويسرا، فإن OpenAI Ireland Limited, 1st Floor, The Liffey Trust Centre, 117-126 Sheriff Street Upper, Dublin 1, D01 YC43, Ireland، هي الطرف التعاقدي المعني.",
"voluntaryUse": "استخدام روبوت الدردشة اختياري. إذا لم تستخدم روبوت الدردشة، فلن يتم نقل أي بيانات إلى OpenAI. يرجى عدم إدخال أي بيانات شخصية حساسة في الدردشة." "thirdCountryTransfer": "نقل البيانات إلى دول ثالثة (وبالأخص الولايات المتحدة) ما ينفعش نستبعده. وده بيتم على أساس ضمانات مناسبة وفقًا لـ Art. 44 ff. DSGVO، وبالأخص من خلال استخدام البنود التعاقدية القياسية.",
"modelTraining": "حسب ما أعلنت OpenAI، البيانات الناتجة عن استخدام الـ API لا يتم استخدامها لتدريب النماذج بشكل افتراضي.",
"dataRetention": "بنحتفظ بمحتوى المحادثات فقط للمدة اللازمة لمعالجة استفسارك، وبعد كده بنحذفه أو بنخليه مجهول الهوية، ما لم تكن في التزامات احتفاظ قانونية.",
"voluntaryUse": "استخدام الشات بوت اختياري. برجاء عدم إدخال أي بيانات شخصية حساسة في الشات.",
"privacyLinkIntro": "مزيد من المعلومات عن معالجة البيانات بواسطة OpenAI هتلاقيها هنا:",
"privacyLinkUrl": "https://openai.com/de-DE/policies/eu-privacy-policy/"
} }
} }
}; };

View File

@@ -3,15 +3,15 @@ export default {
"cookies": { "cookies": {
"title": "الكوكيز", "title": "الكوكيز",
"intro": "موقعنا بيستخدم الكوكيز في الحالات التالية:", "intro": "موقعنا بيستخدم الكوكيز في الحالات التالية:",
"payment": "1. عملية الدفع: عند الدفع ببطاقات الائتمان أو التحويلات الفورية (مثلاً Klarna Sofort)، بيتم استخدام كوكيز تقنية ضرورية. الكوكيز دي بتحتوي على سلسلة مميزة من الأحرف بتسمح بالتعرف الفريد على المتصفح. الكوكيز دي بيتم تعيينها من قبل مزود خدمة الدفع Stripe وضرورية تمامًا لمعالجة المدفوعات بأمان وسلاسة. من غير الكوكيز دي، مش ممكن تقديم طلب باستخدام طرق الدفع دي. المعالجة بتتم بناءً على المادة 6 (1) بند ب من DSGVO لتنفيذ العقد.", "payment": "1. عملية الدفع: في حالة الدفع ببطاقة الائتمان أو التحويل البنكي الفوري (مثلاً Klarna Sofort)، بيتم استخدام كوكيز ضرورية تقنياً. الكوكيز دي بتحتوي على سلسلة مميزة من الأحرف بتسمح بتحديد المتصفح بشكل فريد. الكوكيز دي بيتم ضبطها من خلال مزوّد خدمة الدفع Stripe وهي ضرورية تماماً علشان معالجة المدفوعات بشكل آمن وسلس. من غير الكوكيز دي، ماينفعش إتمام الطلب باستخدام طرق الدفع دي. بيتم المعالجة على أساس Art. 6 (1) lit. b DSGVO لتنفيذ العقد.",
"googleSSO": "2. تسجيل الدخول الموحد من جوجل (SSO): عند استخدام تسجيل الدخول عبر جوجل، بيتم تعيين كوكيز من جوجل ضرورية لعملية تسجيل الدخول والمصادقة. الكوكيز دي بتسمحلك تسجل دخولك بسهولة بحساب جوجل بتاعك من غير ما تحتاج تسجل دخول كل مرة. المعالجة بتتم بناءً على المادة 6 (1) بند ب من DSGVO (تنفيذ العقد) والمادة 6 (1) بند ف من DSGVO (مصلحة مشروعة في تسجيل دخول سهل الاستخدام).", "googleSSO": "2. Google Single Sign-On (SSO): عند استخدام تسجيل الدخول بحساب Google، Google بتضبط كوكيز مطلوبة لعملية تسجيل الدخول والمصادقة. الكوكيز دي بتخليك تسجّل دخولك بسهولة باستخدام حساب Google من غير ما تحتاج تعيد تسجيل الدخول كل مرة. بيتم المعالجة على أساس Art. 6 (1) lit. b DSGVO (تنفيذ العقد) وArt. 6 (1) lit. f DSGVO (المصلحة المشروعة في تسجيل دخول سهل الاستخدام).",
"otherPayments": "بالنسبة لطرق الدفع الأخرى الخصم المباشر، الاستلام، أو الدفع عند الاستلام مفيش كوكيز إضافية مستخدمة، إلا لو استخدمت تسجيل الدخول عبر جوجل." "otherPayments": "بالنسبة لطرق الدفع التانية - الخصم المباشر، الاستلام، أو الدفع عند الاستلام - مافيش كوكيز إضافية مستخدمة إلا لو استخدمت تسجيل الدخول بحساب Google."
}, },
"mollie": { "mollie": {
"title": "Mollie (معالجة الدفع)", "title": "Mollie (معالجة المدفوعات)",
"content": "احنا بنستخدم مزود خدمة الدفع Mollie على موقعنا لمعالجة المدفوعات. مزود الخدمة هو Mollie B.V., Keizersgracht 126, 1015 CW Amsterdam, Netherlands. في السياق ده، بيتم نقل البيانات الشخصية المطلوبة لمعالجة الدفع لـ Mollie خصوصًا اسمك، بريدك الإلكتروني، عنوان الفاتورة، معلومات الدفع (مثلاً بيانات بطاقة الائتمان)، وعنوان الـ IP. معالجة البيانات بتتم لغرض معالجة الدفع؛ الأساس القانوني هو المادة 6 الفقرة 1 بند ب من DSGVO، لأنها بتخدم تنفيذ عقد معاك.", "content": "إحنا بنستخدم مزوّد خدمة الدفع Mollie على موقعنا لمعالجة المدفوعات. مزوّد الخدمة هو Mollie B.V., Keizersgracht 126, 1015 CW Amsterdam, Netherlands. في السياق ده، البيانات الشخصية المطلوبة لمعالجة الدفع بيتم إرسالها إلى Mollie - وبالأخص اسمك، عنوان البريد الإلكتروني، عنوان الفوترة، معلومات الدفع (مثلاً بيانات بطاقة الائتمان)، وكمان عنوان IP. معالجة البيانات بتتم لغرض معالجة الدفع؛ والأساس القانوني هو Art. 6 para. 1 lit. b DSGVO، لأنه بيساعد في تنفيذ عقد معاك.",
"responsibility": "Mollie كمان بتعالج بيانات معينة كمسؤول مستقل، مثلاً لتنفيذ الالتزامات القانونية (زي مكافحة غسيل الأموال) ولمنع الاحتيال. بالإضافة لكده، احنا موقّعين عقد معالجة بيانات مع Mollie وفقًا للمادة 28 من DSGVO؛ وبموجب العقد ده، Mollie بتتصرف فقط بتعليماتنا عند معالجة المدفوعات.", "responsibility": "Mollie كمان بتعالج بعض البيانات كمسؤول مستقل، مثلاً عشان تلتزم بالالتزامات القانونية (زي منع غسيل الأموال) ولمكافحة الاحتيال. بالإضافة إلى كده، إحنا أبرمنا مع Mollie اتفاقية معالجة بيانات وفقًا لـ Art. 28 DSGVO؛ وفي نطاق الاتفاقية دي، Mollie بتتصرف حصريًا حسب تعليماتنا أثناء معالجة المدفوعات.",
"dataTransfer": "لو Mollie بتعالج بيانات شخصية خارج الاتحاد الأوروبي، خصوصًا في الولايات المتحدة الأمريكية، ده بيتم مع الالتزام بضمانات مناسبة. Mollie بتستخدم بنود العقد النموذجية للاتحاد الأوروبي حسب المادة 46 من DSGVO لضمان مستوى مناسب من حماية البيانات. مع ذلك، بنحب نوضح إن الولايات المتحدة الأمريكية بتعتبر دولة ثالثة بموجب قانون حماية البيانات مع احتمال وجود مستوى حماية بيانات غير كافي. ممكن تلاقي معلومات أكتر في سياسة الخصوصية الخاصة بـ Mollie على https://www.mollie.com/de/privacy." "dataTransfer": "لو Mollie بتعالج بيانات شخصية خارج الاتحاد الأوروبي، وبالأخص في الولايات المتحدة الأمريكية، فده بيتم مع الالتزام بالضمانات المناسبة. ولغرض ده، Mollie بتستخدم EU Standard Contractual Clauses وفقًا لـ Art. 46 DSGVO لضمان مستوى مناسب من حماية البيانات. ومع ذلك، نود التنويه إلى إن الولايات المتحدة تُعتبر قانونيًا في مجال حماية البيانات دولة ثالثة قد يكون مستوى حماية البيانات فيها غير كافٍ. لمزيد من المعلومات، ممكن تراجع سياسة الخصوصية الخاصة بـ Mollie على https://www.mollie.com/de/privacy."
} }
} }
}; };

View File

@@ -2,7 +2,7 @@ export default {
"sections": { "sections": {
"customerAccount": { "customerAccount": {
"title": "حساب العميل", "title": "حساب العميل",
"content": "عند فتح حساب عميل، نجمع بياناتك الشخصية بالقدر المحدد هناك. معالجة البيانات تهدف إلى تحسين تجربة التسوق الخاصة بك وتسهيل معالجة الطلبات. تتم المعالجة بناءً على المادة 6 (1) حرف a من DSGVO بموافقتك. يمكنك سحب موافقتك في أي وقت بإبلاغنا، دون التأثير على قانونية المعالجة التي تمت بناءً على الموافقة حتى سحبها. سيتم بعد ذلك حذف حساب العميل الخاص بك." "content": "عند فتح حساب عميل، بنجمع بياناتك الشخصية بالقدر المحدد هناك. معالجة البيانات بتتم بهدف تحسين تجربة التسوق الخاصة بيك وتسهيل معالجة الطلبات. المعالجة بتتم على أساس Art. 6 (1) lit. a DSGVO بموافقتك. يمكنك سحب موافقتك في أي وقت عن طريق إخطارنا، من غير ما ده يأثر على قانونية المعالجة اللي تمت على أساس الموافقة لحد وقت السحب. بعد كده هيتم حذف حساب العميل الخاص بيك."
} }
} }
}; };

View File

@@ -1,15 +1,18 @@
export default { export default {
"sections": { "sections": {
"googleSSO": { "googleSSO": {
"title": "تسجيل الدخول باستخدام Google (تسجيل الدخول الموحد من Google)", "title": "تسجيل الدخول باستخدام Google (Google Single Sign-On)",
"content": "نقدم لك خيار تسجيل الدخول إلى حساب العميل الخاص بك باستخدام حساب Google الخاص بك. إذا استخدمت وظيفة \"تسجيل الدخول باستخدام Google\"، يتم التحقق من الهوية عبر خدمة Google Single Sign-On. في هذه العملية، قد يتم تخزين ملفات تعريف الارتباط (كوكيز) من Google على جهازك، والتي تكون ضرورية لعملية تسجيل الدخول والتحقق من الهوية. كجزء من تسجيل الدخول عبر Google، نتلقى من Google بيانات شخصية معينة للتحقق من هويتك. على وجه الخصوص، تقوم Google بإرسال اسمك، وعنوان بريدك الإلكتروني، وإذا كان مخزناً في حساب Google الخاص بك، صورة ملفك الشخصي إلينا. يتم توفير هذه المعلومات من قبل Google بمجرد تسجيل دخولك إلى متجرنا الإلكتروني باستخدام حساب Google الخاص بك. يمكن لـ Google، كمزود طرف ثالث، الوصول إلى هذه البيانات ومعالجتها؛ وقد يشمل ذلك نقل البيانات إلى الولايات المتحدة الأمريكية. لقد أبرمنا مع Google بنود حماية بيانات قياسية وفقًا للمادة 46 الفقرة 2 الحرف ج من DSGVO لضمان مستوى مناسب من حماية البيانات عند نقل بياناتك. يمكن العثور على مزيد من التفاصيل حول معالجة البيانات بواسطة Google في سياسة الخصوصية الخاصة بـ Google (على <a href=\"https://policies.google.com/privacy?hl=de\" target=\"_blank\" rel=\"noopener noreferrer\">policies.google.com/privacy?hl=de</a>).", "content": "بنقدملك إمكانية تسجيل الدخول على حساب العميل بتاعك باستخدام حساب Google الخاص بيك. لو استخدمت وظيفة \"Sign in with Google\"، فالمصادقة بتتم من خلال خدمة Google Single Sign-On. أثناء العملية دي، ممكن Google تخزن ملفات تعريف ارتباط (cookies) على جهازك، ودي بتكون لازمة لعملية تسجيل الدخول والمصادقة. كجزء من تسجيل الدخول باستخدام Google، بنستلم من Google بعض البيانات الشخصية علشان نتحقق من هويتك. وبشكل خاص، Google بتبعتلنا اسمك، وعنوان بريدك الإلكتروني، وكمان—لو كانت محفوظة في حساب Google بتاعك—صورة الملف الشخصي بتاعتك. المعلومات دي بتتقدم من Google بمجرد ما تسجل دخولك على المتجر الإلكتروني بتاعنا بحساب Google الخاص بيك. Google، كمزوّد طرف ثالث، ممكن يطّلع على البيانات دي ويعالجها؛ وده ممكن كمان يشمل نقل البيانات إلى الولايات المتحدة الأمريكية. وإحنا أبرمنا بنود تعاقدية قياسية مع Google وفقًا للمادة 46 الفقرة 2 الحرف c من DSGVO علشان نضمن مستوى مناسب من حماية البيانات عند نقل بياناتك.",
"legalBasis": "تتم معالجة البيانات المتعلقة بتسجيل الدخول عبر Google بناءً على المادة 6 الفقرة 1 الحرف ب من DSGVO (تنفيذ التدابير التمهيدية للعقد وتنفيذ العقد، مثل إنشاء واستخدام حساب العميل الخاص بك) وكذلك المادة 6 الفقرة 1 الحرف و من DSGVO (مصلحتنا المشروعة في توفير خيار تسجيل دخول سريع ومريح لك).", "privacyLinkIntro": "مزيد من التفاصيل عن معالجة البيانات بواسطة Google ممكن تلاقيها في سياسة الخصوصية الخاصة بـ Google (على ",
"voluntaryUse": "استخدام وظيفة \"تسجيل الدخول باستخدام Google\" هو أمر طوعي. بالطبع، يمكنك أيضًا استخدام متجرنا الإلكتروني وحساب العميل الخاص بك بدون Google SSO عن طريق التسجيل أو تسجيل الدخول باستخدام عنوان بريدك الإلكتروني وكلمة المرور كالمعتاد. إذا اخترت استخدام تسجيل الدخول عبر Google، يمكنك قطع هذا الرابط في أي وقت عن طريق إزالة الاتصال في إعدادات حساب Google الخاص بك.", "privacyLinkUrl": "https://policies.google.com/privacy?hl=de",
"yourRights": "فيما يتعلق بالبيانات الشخصية المعالجة عبر Google SSO، لديك حقوق قانونية كمالك للبيانات. على وجه الخصوص، لديك الحق في الحصول على معلومات حول البيانات المخزنة عنك (المادة 15 DSGVO)، وتصحيح البيانات غير الصحيحة (المادة 16 DSGVO)، أو طلب حذف بياناتك (المادة 17 DSGVO). علاوة على ذلك، لديك الحق في تقييد معالجة بياناتك (المادة 18 DSGVO) وحق نقل البيانات (المادة 20 DSGVO). إذا استندنا في المعالجة إلى مصلحتنا المشروعة، يمكنك الاعتراض على المعالجة (المادة 21 DSGVO). يمكنك أيضًا تقديم شكوى في أي وقت إلى السلطة المختصة بحماية البيانات. حقوقك وخياراتك القائمة من سياسة الخصوصية الأخرى تنطبق بالطبع أيضًا على استخدام تسجيل الدخول عبر Google." "privacyLinkSuffix": ").",
"legalBasis": "معالجة البيانات المرتبطة بتسجيل الدخول باستخدام Google بتتم على أساس المادة 6 الفقرة 1 الحرف b من DSGVO (تنفيذ إجراءات ما قبل التعاقد وأداء العقد، مثل إنشاء حساب العميل واستخدامه) وكمان المادة 6 الفقرة 1 الحرف f من DSGVO (مصلحتنا المشروعة في إننا نوفر لك خيار تسجيل دخول سريع ومريح).",
"voluntaryUse": "استخدام وظيفة \"Sign in with Google\" اختياري. طبعًا تقدر تستخدم المتجر الإلكتروني وحساب العميل بتاعك من غير Google SSO كمان، عن طريق التسجيل أو تسجيل الدخول بشكل عادي باستخدام عنوان البريد الإلكتروني وكلمة المرور. لو قررت تستخدم تسجيل الدخول عبر Google، تقدر تلغي الربط ده في أي وقت عن طريق فصل الارتباط من إعدادات حساب Google بتاعك.",
"yourRights": "بالنسبة للبيانات الشخصية اللي بتتعملها معالجة عبر Google SSO، ليك حقوق صاحب البيانات القانونية. وبشكل خاص، ليك الحق في الحصول على معلومات عن البيانات المخزنة عنك (Art. 15 DSGVO)، وطلب تصحيح البيانات غير الدقيقة (Art. 16 DSGVO)، أو طلب حذف بياناتك (Art. 17 DSGVO). وكمان ليك الحق في تقييد معالجة بياناتك (Art. 18 DSGVO) والحق في نقل البيانات (Art. 20 DSGVO). ولو كنا بنستند في المعالجة على مصلحتنا المشروعة، فممكن تعترض على المعالجة (Art. 21 DSGVO). بالإضافة لكده، تقدر في أي وقت تقدم شكوى لجهة الرقابة المختصة بحماية البيانات. وحقوقك وخياراتك الموجودة بالفعل في باقي سياسة الخصوصية بتاعتنا بتسري طبعًا كمان على استخدام تسجيل الدخول عبر Google."
}, },
"orders": { "orders": {
"title": "جمع ومعالجة واستخدام البيانات الشخصية للطلبات", "title": "جمع ومعالجة واستخدام البيانات الشخصية للطلبات",
"content": "عند تقديم طلب، نقوم بجمع واستخدام بياناتك الشخصية فقط بالقدر اللازم لتنفيذ ومعالجة طلبك وللتعامل مع استفساراتك. تقديم هذه البيانات ضروري لإبرام العقد. عدم تقديم البيانات يعني أنه لا يمكن إبرام العقد. تتم المعالجة على أساس المادة 6 (1) الحرف ب من DSGVO وهي ضرورية لتنفيذ عقد معك. لن يتم تمرير بياناتك إلى أطراف ثالثة بدون موافقتك الصريحة. الاستثناء الوحيد هم شركاء الخدمة الذين نحتاجهم لمعالجة العلاقة التعاقدية أو مقدمو الخدمات الذين نستخدمهم في إطار معالجة الطلبات. بالإضافة إلى المستلمين المذكورين في البنود الخاصة بهذه السياسة، قد يشمل ذلك مستلمين من الفئات التالية: مزودو خدمات الشحن، مزودو خدمات الدفع، مزودو خدمات إدارة البضائع، مقدمو خدمات معالجة الطلبات، مزودو استضافة الويب، مزودو خدمات تكنولوجيا المعلومات، وتجار الدروب شيبنج. في جميع الحالات، نلتزم بدقة بالمتطلبات القانونية. نطاق نقل البيانات محدود إلى الحد الأدنى." "content": "لما بتقدم طلب، بنجمع وبنستخدم بياناتك الشخصية فقط بالقدر الضروري لتنفيذ ومعالجة طلبك وللتعامل مع استفساراتك. تقديم البيانات مطلوب لإبرام العقد. عدم تقديم البيانات هيؤدي إلى عدم إبرام العقد. المعالجة بتتم على أساس المادة 6 (1) الحرف b من DSGVO وهي ضرورية لتنفيذ عقد معك. بياناتك مش هتتسلم لأطراف تانية من غير موافقتك الصريحة. والاستثناء من ده هو فقط شركاء الخدمة بتوعنا اللي بنحتاجهم لمعالجة العلاقة التعاقدية أو مقدمي الخدمات اللي بنستخدمهم في إطار المعالجة بالنيابة عنا. وبالإضافة إلى الجهات المستلمة المذكورة في البنود الخاصة بكل جزء من سياسة الخصوصية دي، فده مثلًا بيشمل جهات مستلمة من الفئات التالية: مقدمو خدمات الشحن، مقدمو خدمات الدفع، مقدمو خدمات إدارة البضائع، مقدمو خدمات معالجة الطلبات، مستضيفو الويب، مقدمو خدمات تكنولوجيا المعلومات، وتجار الدروبشيبينج. وفي كل الحالات، بنلتزم بدقة بالمتطلبات القانونية. ونطاق نقل البيانات بيكون محدود للحد الأدنى.",
} }
} }
}; };

View File

@@ -2,7 +2,7 @@ export default {
"sections": { "sections": {
"newsletter": { "newsletter": {
"title": "استخدام عنوان البريد الإلكتروني لإرسال النشرات الإخبارية", "title": "استخدام عنوان البريد الإلكتروني لإرسال النشرات الإخبارية",
"content": "نستخدم عنوان بريدك الإلكتروني، بغض النظر عن معالجة العقد، حصريًا لأغراضنا الإعلانية الخاصة لإرسال النشرات الإخبارية، بشرط أن تكون قد وافقت صراحةً على ذلك. تتم المعالجة على أساس المادة 6 (1) حرف a من DSGVO بموافقتك. يمكنك سحب موافقتك في أي وقت دون التأثير على قانونية المعالجة التي تمت بناءً على الموافقة حتى وقت السحب. يمكنك إلغاء الاشتراك في النشرة الإخبارية في أي وقت باستخدام الرابط المناسب في النشرة الإخبارية أو بإبلاغنا. سيتم بعد ذلك إزالة عنوان بريدك الإلكتروني من قائمة التوزيع. سيتم تمرير بياناتك إلى مزود خدمة للتسويق عبر البريد الإلكتروني في إطار معالجة الطلب. لن يتم الكشف عن بياناتك لأطراف ثالثة أخرى. سيتم نقل بياناتك إلى دولة ثالثة يوجد بشأنها قرار كفاية من المفوضية الأوروبية." "content": "نستخدم عنوان بريدك الإلكتروني، بشكل مستقل عن تنفيذ العقد، حصريًا لأغراضنا الإعلانية الخاصة لإرسال النشرات الإخبارية، بشرط أن تكون قد وافقت على ذلك صراحةً. تتم المعالجة على أساس المادة 6 (1) الفقرة a من DSGVO بموافقتك. يمكنك سحب موافقتك في أي وقت دون أن يؤثر ذلك على قانونية المعالجة التي تمت على أساس الموافقة حتى تاريخ سحبها. لهذا الغرض، يمكنك إلغاء الاشتراك من النشرة الإخبارية في أي وقت باستخدام الرابط المخصص لذلك داخل النشرة أو عن طريق إخطارنا. بعد ذلك سيتم حذف عنوان بريدك الإلكتروني من قائمة التوزيع. سيتم تمرير بياناتك إلى مزود خدمة تسويق عبر البريد الإلكتروني في إطار Auftragsverarbeitung. لا يتم الإفصاح لأي أطراف ثالثة أخرى. سيتم نقل بياناتك إلى دولة ثالثة توجد بشأنها قرار ملاءمة صادر عن المفوضية الأوروبية."
} }
} }
}; };

View File

@@ -2,10 +2,10 @@ export default {
"sections": { "sections": {
"webPush": { "webPush": {
"title": "إشعارات الدفع (Web Push / VAPID)", "title": "إشعارات الدفع (Web Push / VAPID)",
"intro": "لو كنت وافقت على استقبال إشعارات الدفع، بنستخدم ما يُسمّى بتقنيات Web Push علشان نعرض لك رسائل (مثلاً بخصوص الطلبات أو التوفّر) مباشرةً في المتصفح بتاعك.", "intro": "لو كنت وافقت على استقبال إشعارات الدفع، بنستخدم تقنيات Web Push علشان نعرض لك رسائل (مثلاً بخصوص الطلبات أو التوفّر) مباشرةً في المتصفح بتاعك.",
"subscriptionData": "ولغرض ده، بيتم处理 ما يُسمّى ببيانات الاشتراك في الدفع عند تسجيلك في خدمة الدفع. وده بيشمل، بشكل خاص، عنوان URL لنقطة النهاية ومفاتيح تشفيرية مخصّصة للمتصفح بتاعك.", "subscriptionData": "ولغرض ده، بيتم معالجة ما يُسمّى ببيانات اشتراك الدفع لما تسجّل في خدمة الدفع. وده بيشمل بشكل خاص عنوان URL لنقطة النهاية ومفاتيح تشفير مخصصة لمتصفحك.",
"consent": "بيتم المعالجة حصريًا على أساس موافقتك وفقًا للمادة 6 الفقرة 1 الحرف a من DSGVO. تقدر تسحب موافقتك في أي وقت عن طريق تعطيل إشعارات الدفع في إعدادات المتصفح بتاعك.", "consent": "المعالجة بتتم حصريًا على أساس موافقتك وفقًا للمادة 6 الفقرة 1 الحرف a من DSGVO. تقدر تسحب موافقتك في أي وقت عن طريق تعطيل إشعارات الدفع من إعدادات المتصفح.",
"thirdCountries": "بيتم استخدام خدمات مزوّدي المتصفحات المعنيين (مثلاً Google أو Mozilla أو Apple) لتوصيل رسائل الدفع. وفي السياق ده، ممكن يتم نقل بيانات شخصية إلى دول ثالثة، وبشكل خاص إلى الولايات المتحدة. وفي الحالات دي، بيتم النقل على أساس ضمانات مناسبة وفقًا للمادة 46 من DSGVO.", "thirdCountries": "بيتم استخدام خدمات مقدمي المتصفحات المعنيين (مثلاً Google أو Mozilla أو Apple) لتوصيل رسائل الدفع. وفي السياق ده، ممكن يتم نقل بيانات شخصية إلى دول ثالثة، خصوصًا الولايات المتحدة الأمريكية. في الحالات دي، بيتم النقل على أساس ضمانات مناسبة وفقًا للمادة 46 من DSGVO.",
"retention": "هيتم تخزين بياناتك فقط طالما أنت مشترك في إشعارات الدفع." "retention": "هيتم تخزين بياناتك فقط طالما أنت مشترك في إشعارات الدفع."
} }
} }

View File

@@ -1,16 +1,16 @@
export default { export default {
"sections": { "sections": {
"dataRetention": { "dataRetention": {
"title": "مدة التخزين", "title": "مدة الاحتفاظ",
"content": "بعد إتمام تنفيذ العقد بالكامل، سيتم تخزين البيانات في البداية لمدة فترة الضمان، وبعد ذلك مع مراعاة الفترات القانونية، وخاصة فترات الاحتفاظ الضريبية والتجارية، ثم يتم حذفها بعد انتهاء هذه الفترات، ما لم تكن قد وافقت على المعالجة والاستخدام الإضافيين." "content": "بعد الانتهاء الكامل من تنفيذ العقد، سيتم تخزين البيانات في البداية لمدة سريان مدة الضمان، ثم مع مراعاة مدد الاحتفاظ القانونية، وبالأخص بموجب قانون الضرائب والقانون التجاري، وبعدها سيتم حذفها بعد انتهاء تلك المدد، ما لم تكن قد وافقت على المزيد من المعالجة والاستخدام.",
}, },
"dataSubjectRights": { "dataSubjectRights": {
"title": "حقوق الشخص المعني بالبيانات", "title": "حقوق صاحب البيانات",
"content": "إذا توفرت الشروط القانونية، لديك الحقوق التالية بموجب المواد 15 إلى 20 من DSGVO: حق الوصول، وحق التصحيح، وحق الحذف، وحق تقييد المعالجة، وحق نقل البيانات. بالإضافة إلى ذلك، بموجب المادة 21 (1) من DSGVO، لديك الحق في الاعتراض على المعالجة التي تستند إلى المادة 6 (1) f من DSGVO، وكذلك على المعالجة لأغراض التسويق المباشر. يرجى الاتصال بنا إذا رغبت في ذلك. يمكنك العثور على بيانات الاتصال الخاصة بنا في الإشعار القانوني (Impressum)." "content": "بشرط توافر المتطلبات القانونية، يحق لك الحقوق التالية وفقًا للمادتين 15 إلى 20 من DSGVO: حق الوصول، والتصحيح، والحذف، وتقييد المعالجة، وقابلية نقل البيانات. بالإضافة إلى ذلك، وبموجب المادة 21 (1) من DSGVO، يحق لك الاعتراض على المعالجة التي تستند إلى المادة 6 (1) f من DSGVO، وكذلك على المعالجة لأغراض الإعلانات المباشرة. يُرجى التواصل معنا إذا رغبت. يمكنك العثور على بيانات الاتصال في الإشعار القانوني الخاص بنا.",
}, },
"supervisoryAuthority": { "supervisoryAuthority": {
"title": "الحق في تقديم شكوى إلى السلطة الرقابية", "title": "الحق في تقديم شكوى لدى الجهة الرقابية",
"content": "وفقًا للمادة 77 من DSGVO، لديك الحق في تقديم شكوى إلى السلطة الرقابية إذا كنت تعتقد أن معالجة بياناتك الشخصية غير قانونية." "content": "وفقًا للمادة 77 من DSGVO، يحق لك تقديم شكوى إلى الجهة الرقابية إذا كنت ترى أن معالجة بياناتك الشخصية لا تتم بشكل قانوني.",
} }
} }
}; };

View File

@@ -1,25 +1,25 @@
export default { export default {
"title": "الإشعار القانوني (Impressum)", "title": "الإشعار القانوني", // Impressum
"sections": { "sections": {
"operator": { "operator": {
"title": "المشغل والمسؤول عن محتوى هذا المتجر هو:", "title": "المشغّل والمسؤول عن محتوى هذا المتجر هو:", // Betreiber und verantwortlich für die Inhalte dieses Shops ist:
"content": "Growheads\nMax Schön\nTrachenberger Straße 14\n01129 Dresden" "content": "Growheads\nMax Schön\nTrachenberger Straße 14\n01129 Dresden" // Growheads\nMax Schön\nTrachenberger Straße 14\n01129 Dresden
}, },
"contact": { "contact": {
"title": "الاتصال:", "title": "التواصل:", // Kontakt:
"content": "البريد الإلكتروني: service@growheads.de" "content": "Email: service@growheads.de" // E-Mail: service@growheads.de
}, },
"vatId": { "vatId": {
"title": "رقم ضريبة القيمة المضافة:", "title": "رقم ضريبة القيمة المضافة:", // Umsatzsteuer-ID:
"content": "رقم ضريبة القيمة المضافة: DE323017152" "content": "VAT ID No.: DE323017152" // USt.-IdNr.: DE323017152
}, },
"disclaimer": { "disclaimer": {
"title": "تنصل من المسؤولية:", "title": "إخلاء المسؤولية:", // Haftungsausschluss:
"content": "لا نتحمل أي مسؤولية عن محتوى عناوين الإنترنت الخارجية المرتبطة بهذه الصفحات. المشغلون المعنيون هم المسؤولون عن محتوى المواقع غير التابعة للشركة." "content": "إحنا ما بنتحملش أي مسؤولية عن محتوى عناوين الإنترنت الخارجية المرتبطة في الصفحات دي. المشغّلين المعنيين هم المسؤولين عن محتوى نطاقات الأطراف الثالثة." // Für Inhalte von auf diesen Seiten verlinkten externen Internetadressen übernehmen wir keine Haftung. Für Inhalte betriebsfremder Domizile sind die jeweiligen Betreiber verantwortlich.
}, },
"copyright": { "copyright": {
"title": "بند حقوق النشر:", "title": "بند حقوق النشر:", // Urheberrechtsklausel:
"content": "المحتوى المعروض هنا يخضع بشكل عام لحقوق النشر ولا يجوز توزيعه إلا بموافقة كتابية.\nحقوق المواد المصورة أو النصية من أطراف أخرى ليست مقيدة أو ملغاة بهذا البند." "content": "المحتوى المعروض هنا خاضع بشكل عام لحقوق النشر، ومسموح بتوزيعه فقط بموافقة مكتوبة.\nحقوق المواد الخاصة بالصور أو النصوص الخاصة بأطراف أخرى لا يتم تقييدها ولا إلغاؤها بواسطة هذا البند." // Die hier dargestellten Inhalte unterliegen grundsätzlich dem Urheberrecht und dürfen nur mit schriftlicher Genehmigung verbreitet werden.\nDie Rechte an Foto- oder Textmaterial von anderen Parteien sind durch diese Klausel weder eingeschränkt noch aufgehoben.
} }
} }
}; };

View File

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

View File

@@ -1,9 +1,10 @@
export default { export default {
"new": "جديد",
"soon": "قريباً",
"home": "الرئيسية", "home": "الرئيسية",
"aktionen": "العروض", "konfiguratorAria": "اذهب إلى المُكوِّن",
"filiale": "الفرع", "new": "وصل حديثًا",
"soon": "قريبًا",
"aktionen": "العروض الترويجية",
"filiale": "المتجر",
"categories": "الفئات", "categories": "الفئات",
"categoriesOpen": "افتح الفئات", "categoriesOpen": "افتح الفئات",
"categoriesClose": "أغلق الفئات", "categoriesClose": "أغلق الفئات",

View File

@@ -1,17 +1,14 @@
export default { export default {
"status": { "status": {
"new": "جاري المعالجة", "new": "قيد التنفيذ",
"pending": "جديد", "pending": "جديد",
"processing": "جاري المعالجة", "processing": "قيد التنفيذ",
"paid": "مدفوع", "paid": "مدفوع",
"cancelled": "تم الإلغاء", "cancelled": "تم الإلغاء",
"shipped": "تم الشحن", "shipped": "تم الشحن",
"delivered": "تم التسليم", "delivered": "تم التسليم",
"picked_up": "تم الاستلام", "picked_up": "تم الاستلام",
"return": "إرجاع", "awaiting_tracking": "بيتم التجهيز",
"partialReturn": "إرجاع جزئي",
"partialDelivered": "تم التسليم جزئيًا",
"awaiting_tracking": "جاري التجهيز",
"ready_for_pickup": "جاهز للاستلام" "ready_for_pickup": "جاهز للاستلام"
}, },
"table": { "table": {
@@ -21,20 +18,19 @@ export default {
"items": "العناصر", "items": "العناصر",
"total": "الإجمالي", "total": "الإجمالي",
"actions": "الإجراءات", "actions": "الإجراءات",
"viewDetails": "عرض التفاصيل"
}, },
"tooltips": { "tooltips": {
"viewDetails": "عرض التفاصيل", "viewDetails": "عرض التفاصيل",
"cancelOrder": "إلغاء الطلب" "cancelOrder": "إلغاء الطلب"
}, },
"noOrders": "أنت لم تقم بأي طلبات لسه.", "noOrders": "ماعملتش أي طلبات لسه.",
"trackShipment": "تتبع الشحنة", "trackShipment": "تتبع الشحنة",
"girocode": { "girocode": {
"hint": "امسح بالكاميرا من تطبيق البنك عشان تدفع.", "hint": "امسح بالكاميرا في تطبيق البنك عشان تدفع.",
"alt": "Girocode للتحويل", "alt": "Girocode للتحويل البنكي",
"error": "تعذر إنشاء رمز QR.", "error": "ما قدرناش نولّد رمز QR.",
"paymentPending": "الطلب ده مستني التحويل البنكي بتاعك.", "paymentPending": "الطلب ده مستني التحويل البنكي بتاعك.",
"payToAccount": "من فضلك حوّل المبلغ إلى الحساب التالي:", "payToAccount": "من فضلك حوّل المبلغ للحساب التالي:",
"holder": "صاحب الحساب: {{name}}", "holder": "صاحب الحساب: {{name}}",
"iban": "IBAN: {{iban}}", "iban": "IBAN: {{iban}}",
"bic": "BIC: {{bic}}", "bic": "BIC: {{bic}}",
@@ -42,7 +38,7 @@ export default {
"purpose": "المرجع: {{orderId}}" "purpose": "المرجع: {{orderId}}"
}, },
"readyForPickup": { "readyForPickup": {
"line1": "طلبك {{orderId}} متجهز ومستنيك.", "line1": "طلبك {{orderId}} اتجهز ومستنيك.",
"line2": "تقدر تستلمه من المتجر بتاعنا من دلوقتي:", "line2": "تقدر تستلمه من المتجر بتاعنا من دلوقتي:",
"storeName": "Growheads", "storeName": "Growheads",
"addressLine1": "Trachenberger Street 14", "addressLine1": "Trachenberger Street 14",
@@ -65,9 +61,9 @@ export default {
}, },
"cancelConfirm": { "cancelConfirm": {
"title": "إلغاء الطلب", "title": "إلغاء الطلب",
"message": "هل أنت متأكد إنك عايز تلغي الطلب ده؟", "message": "إنت متأكد إنك عايز تلغي الطلب ده؟",
"confirm": "إلغاء", "confirm": "إلغاء",
"cancelling": "جاري الإلغاء..." "cancelling": "جارٍ الإلغاء..."
}, },
"processing": "جاري إكمال الطلب..." "processing": "جارٍ إكمال الطلب..."
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,35 +2,35 @@ export default {
"title": "Количка", "title": "Количка",
"empty": "празна", "empty": "празна",
"addToCart": "Добави в количката", "addToCart": "Добави в количката",
"preorderCutting": "Предварителна поръчка като резник", "preorderCutting": оръчай предварително като резник",
"continueShopping": "Продължи пазаруването", "continueShopping": "Продължи пазаруването",
"proceedToCheckout": "Продължи към плащане", "proceedToCheckout": "Продължи към плащане",
"productCount": "{{count}} {{count, plural, one {продукт} other {продукта}}}", "productCount": "{{count}} {{count, plural, one {продукт} other {продукта}}}",
"productSingular": "продукт", "productSingular": "Продукт",
"productPlural": "продукта", "productPlural": "Продукти",
"removeFromCart": "Премахни от количката", "removeFromCart": "Премахни от количката",
"openCart": "Отвори количката", "openCart": "Отвори количката",
"availableFrom": "Наличен от {{date}}", "availableFrom": "От {{date}}",
"backToOrder": "← Обратно към поръчката", "backToOrder": "← Назад към поръчката",
"summary": { "summary": {
"title": "Обобщение на поръчката", "title": "Обобщение на поръчката",
"goodsNet": "Стоки (нето):", "goodsNet": "Стоки (нето):",
"shippingNet": "Доставка (нето):", "shippingNet": "Разходи за доставка (нето):",
"totalGoods": "Общо стоки:", "totalGoods": "Общо стоки:",
"shippingCosts": "Разходи за доставка:", "shippingCosts": "Разходи за доставка:",
"total": "Общо:", "total": "Общо:",
"totalWeight": "Общо тегло: {{weight}} кг", "totalWeight": "Общо тегло: {{weight}} kg",
"freeFrom100": "(безплатно над 100)", "freeFrom100": "(безплатно от €100)",
"free": "безплатно" "free": "безплатно"
}, },
"itemCount": { "itemCount": {
"singular": "продукт", "singular": "Продукт",
"plural": "продукта" "plural": "Продукти"
}, },
"sync": { "sync": {
"title": "Синхронизация на количката", "title": "Синхронизация на количката",
"description": "Имате запазена количка в профила си. Моля, изберете как искате да продължите:", "description": "Имате запазена количка в акаунта си. Моля, изберете как желаете да продължите:",
"deleteServer": "Изтрий количката на сървъра", "deleteServer": "Изтрий количката от сървъра",
"useServer": "Използвай количката от сървъра", "useServer": "Използвай количката от сървъра",
"merge": "Обедини количките", "merge": "Обедини количките",
"currentCart": "Вашата текуща количка", "currentCart": "Вашата текуща количка",

View File

@@ -1,3 +1,18 @@
export default { export default {
"privacyRead": "Прочетено и прието", "privacyRead": "Прочетено и прието",
"privacyPromptBefore": "Моля, потвърдете, че сте прочели ",
"privacyPolicyLink": "политиката за поверителност",
"privacyPromptAfter": " и сте съгласни с нея. ",
"telegramAssistantIntro": "Можете също да се свържете с асистента на Growheads в Telegram:",
"telegramAssistantLink": "t.me/Growheads_de_Bot",
"assistantTitle": "Асистент",
"placeholderRecording": "Записване…",
"inputPlaceholder": "Можете да ме питате за сортове канабис…",
"send": "Изпрати",
"closeAria": "Затвори асистента",
"micStartAria": "Стартиране на гласов запис",
"micStopAria": "Спиране на записа",
"uploadImageAria": "Качване на изображение",
"micPermissionDenied": "Не беше възможен достъп до микрофона. Моля, проверете разрешенията на браузъра си.",
"uploadedImageAlt": "Качено изображение"
}; };

View File

@@ -1,34 +1,31 @@
export default { export default {
"invoiceAddress": "Адрес за фактура", "invoiceAddress": "Адрес за фактуриране",
"deliveryAddress": "Адрес за доставка", "deliveryAddress": "Адрес за доставка",
"saveForFuture": "Запази за бъдещи поръчки", "saveForFuture": "Запазване за бъдещи поръчки",
"pickupDate": "За коя дата е желано вземането на резниците?", "pickupDate": "За коя дата е заявено вземането на резниците?",
"note": "Бележка", "note": "Бележка",
"sameAddress": "Адресът за доставка е същият като адреса за фактура", "sameAddress": "Адресът за доставка е същият като адреса за фактуриране",
"termsAccept": "Прочетох Общите условия, Политиката за поверителност и разпоредбите за правото на отказ", "termsAccept": "Прочетох общите условия, политиката за поверителност и политиката за отказ",
"selectDeliveryMethod": "Изберете метод на доставка",
"selectPaymentMethod": "Изберете метод на плащане",
"orderSummary": "Резюме на поръчката",
"addressValidationError": "Моля, проверете въведените данни в полетата за адрес.", "addressValidationError": "Моля, проверете въведените данни в полетата за адрес.",
"processingOrder": "Поръчката се обработва...", "processingOrder": "Поръчката се обработва...",
"completeOrder": "Завърши поръчката", "completeOrder": "Завършване на поръчката",
"termsValidationError": "Моля, приемете Общите условия, Политиката за поверителност и правото на отказ, за да продължите.", "termsValidationError": "Моля, приемете общите условия, политиката за поверителност и политиката за отказ, за да продължите.",
"addressFields": { "addressFields": {
"firstName": "Име", "firstName": "Собствено име",
"lastName": "Фамилия", "lastName": "Фамилия",
"addressSupplement": "Допълнение към адреса", "addressSupplement": "Допълнение към адреса",
"street": "Улица", "street": "Улица",
"houseNumber": "Номер на къща", "houseNumber": "Номер",
"postalCode": "Пощенски код", "postalCode": "Пощенски код",
"city": "Град", "city": "Град",
"country": "Държава" "country": "Държава"
}, },
"validationErrors": { "validationErrors": {
"firstNameRequired": мето е задължително", "firstNameRequired": зисква се собствено име",
"lastNameRequired": "Фамилията е задължителна", "lastNameRequired": "Изисква се фамилия",
"streetRequired": "Улицата е задължителна", "streetRequired": "Изисква се улица",
"houseNumberRequired": "Номерът на къщата е задължителен", "houseNumberRequired": "Изисква се номер",
"postalCodeRequired": "Пощенският код е задължителен", "postalCodeRequired": "Изисква се пощенски код",
"cityRequired": "Градът е задължителен" "cityRequired": "Изисква се град"
} }
}; };

View File

@@ -1,20 +1,6 @@
export default { export default {
"loading": "Зареждане...",
"error": "Грешка",
"close": "Затвори", "close": "Затвори",
"save": "Запази",
"cancel": "Отказ", "cancel": "Отказ",
"ok": "OK",
"yes": "Да",
"no": "Не",
"next": "Напред",
"back": "Назад", "back": "Назад",
"edit": "Редактирай",
"delete": "Изтрий",
"add": "Добави",
"remove": "Премахни",
"products": "Продукти",
"product": "Продукт",
"days": "Дни",
"more": "още" "more": "още"
}; };

View File

@@ -1,33 +1,28 @@
export default { export default {
"methods": { "methods": {
"dhl": "DHL",
"dpd": "DPD",
"sperrgut": "Обемни стоки",
"sperrgutName": "Обемни стоки", "sperrgutName": "Обемни стоки",
"pickup": "Вземане от магазина" "pickup": "Вземане от магазина"
}, },
"descriptions": { "descriptions": {
"standard": "Стандартна доставка", "standard": "Стандартна доставка",
"standardFree": "Стандартна доставка - БЕЗПЛАТНО при поръчка над 100€!", "standardFree": "Стандартна доставка - БЕЗПЛАТНО за поръчки над 100€!",
"notAvailable": "Не може да се избере, защото един или повече артикули могат да се вземат само на място", "notAvailable": "не може да се избере, защото един или повече артикули могат да бъдат взети само на място",
"bulky": "За големи и тежки артикули", "bulky": "За големи и тежки артикули",
"pickupOnly": "Само вземане на място" "pickupOnly": "само вземане"
}, },
"prices": { "prices": {
"free": "безплатно", "free": "безплатно",
"freeFrom100": "(безплатно от 100€)", "dhl": "5,90 €",
"dhl": "5.90 €", "sperrgut": "28,99 €"
"dpd": "4.90 €",
"sperrgut": "28.99 €"
}, },
"times": { "times": {
"cutting14Days": "Срок на доставка: 14 дни", "cutting14Days": "Време за доставка: 14 дни",
"standard2to3Days": "Срок на доставка: 2-3 дни", "standard2to3Days": "Време за доставка: 2-3 дни",
"supplier7to9Days": "Срок на доставка: 7-9 дни" "supplier7to9Days": "Време за доставка: 7-9 дни"
}, },
"selector": { "selector": {
"title": "Изберете метод на доставка", "title": "Изберете начин на доставка",
"freeShippingInfo": "💡 Безплатна доставка при поръчка над 100€!", "freeShippingInfo": "💡 Безплатна доставка за поръчки над 100€!",
"remainingForFree": "Добавете още {{amount}}€ за безплатна доставка.", "remainingForFree": "Добавете още {{amount}}€ за безплатна доставка.",
"congratsFreeShipping": "🎉 Поздравления! Получавате безплатна доставка!", "congratsFreeShipping": "🎉 Поздравления! Получавате безплатна доставка!",
"cartQualifiesFree": "Вашата количка на стойност {{amount}}€ отговаря на условията за безплатна доставка." "cartQualifiesFree": "Вашата количка на стойност {{amount}}€ отговаря на условията за безплатна доставка."

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "на страница", "perPage": "на страница",
"availability": "Наличност", "availability": "Наличност",
"manufacturer": "Производител", "manufacturer": "Производител",
"all": "Всички" "all": "Всички",
"notifyNewArticles": "Уведомявай ме за нови продукти",
"notifyNewArticlesBrowserUnsupported": "Вашият браузър не поддържа push известия."
}; };

View File

@@ -1,15 +1,11 @@
export default { export default {
"hours": "Съб 11:00-19:00", "allPricesIncl": "* Всички цени включват законовия ДДС, плюс доставка",
"address": "Trachenberger Straße 14 - Dresden",
"location": "Между спирка Pieschen и Trachenberger Platz",
"allPricesIncl": "* Всички цени включват законен ДДС, плюс доставка",
"copyright": "© {{year}} GrowHeads.de",
"legal": { "legal": {
"datenschutz": "Политика за поверителност", "datenschutz": "Политика за поверителност",
"agb": "Общи условия", "agb": "Общи условия",
"sitemap": "Карта на сайта", "sitemap": "Карта на сайта",
"impressum": "Правно известие", "impressum": "Правна информация",
"batteriegesetzhinweise": "Информация за закона за батериите", "batteriegesetzhinweise": "Уведомления за Закона за батериите",
"widerrufsrecht": "Право на отказ" "widerrufsrecht": "Право на отказ"
} }
}; };

View File

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

View File

@@ -1,36 +1,36 @@
export default { export default {
"distanceSelling": { "distanceSelling": {
"title": "Информация съгласно Закона за дистанционните продажби", "title": "Информация съгласно Закона за дистанционните продажби",
"intro": "Следната информация важи само за договори, сключени между Growheads и потребители чрез поръчка по каталог, поръчка през интернет или други средства за дистанционна комуникация. Тя е ограничена до потребители в рамките на ЕС.", "intro": "Следната информация се прилага само за договори, сключени между Growheads и потребители чрез поръчка по каталог, поръчка през интернет или чрез други средства за комуникация от разстояние. Тя е ограничена до потребители в ЕО.",
"sections": { "sections": {
"1": { "1": {
"title": "Съществени характеристики на стоките", "title": "Съществени характеристики на стоката",
"content": "Моля, вижте обясненията в каталога или на нашия уебсайт за съществените характеристики на стоките. Офертите в нашия каталог и на нашия уебсайт не са обвързващи. Поръчките, направени при нас, се считат за обвързващи оферти. Growheads може да ги приеме в срок от 14 дни от получаване на поръчката чрез изпращане на потвърждение на поръчката или чрез изпращане на стоките." "content": "Моля, вижте обясненията в каталога или на нашия уебсайт относно съществените характеристики на стоката. Офертите в нашия каталог и на нашия уебсайт не са обвързващи. Поръчките, направени при нас, се считат за обвързващи оферти. Growheads може да ги приеме в срок от 14 дни от получаването на поръчката чрез потвърждение на поръчката или чрез изпращане на стоката.",
}, },
"2": { "2": {
"title": "Резервация", "title": "Запазване на правата",
"content": "Ако не всички поръчани артикули са налични за доставка, ние си запазваме правото да извършим частични доставки, при условие че това е разумно за клиента. Отделни артикули могат да се различават от илюстрациите и описанията в каталога и на уебсайта. Това важи особено за стоки, изработени ръчно. Следователно си запазваме правото, ако е необходимо, да доставим стоки с еквивалентно качество и цена." "content": "Ако не всички поръчани артикули са налични за доставка, си запазваме правото на частични доставки, доколкото това е приемливо за клиента. Отделни артикули могат евентуално да се различават от илюстрациите и описанията в каталога и на уебсайта. Това естествено важи по-специално за стоки, изработени ръчно. Поради това си запазваме правото при определени обстоятелства да доставим стоки с равностойно качество и цена.",
}, },
"3": { "3": {
"title": "Цени и данъци", "title": "Цени и данъци",
"content": "Цените на отделните артикули с включен ДДС можете да намерите в каталога или на нашия уебсайт. Цените губят валидност с публикуването на нов каталог." "content": "Цените на отделните артикули, включително Mehrwertsteuer, можете да намерите в каталога или на нашия уебсайт. Цените губят своята валидност с публикуването на нов каталог.",
}, },
"4": "Всички цени са с уговорката за грешки или колебания в цените. Ако има промяна в цената, купувачът може да упражни правото си на връщане.", "4": "Всички цени са под условие за грешки или ценови колебания. Ако настъпи промяна в цената, купувачът може да упражни правото си на връщане.",
"5": { "5": {
"title": "Гаранционен срок", "title": "Срок на Gewährleistung",
"content": "Приложим е законовият гаранционен срок от 24 (двадесет и четири) месеца. В отделни случаи могат да важат по-дълги срокове, ако са предоставени от производителя." "content": "Прилага се законовият срок на Gewährleistung от 24 (двадесет и четири) месеца. В отделни случаи могат да се прилагат по-дълги срокове, ако са предоставени от производителя.",
}, },
"6": { "6": {
"title": "Право на връщане / Право на отказ", "title": "Право на връщане / право на отказ",
"content": "Клиентът има 14-дневно право на връщане.\nСрокът започва с получаването на стоката от клиента и се спазва чрез навременното изпращане на отказа до Growheads. Изключени от това са храни и други бързоразвалящи се стоки, както и поръчки по индивидуален дизайн или стоки, специално поръчани по желание на клиента. Връщането трябва да се извърши чрез изпращане на стоката обратно в рамките на срока. Ако стоката не може да бъде изпратена, в рамките на срока трябва да бъде изпратено искане за връщане до нас чрез писмо, пощенска картичка, имейл или друг постоянен носител на данни. За спазване на срока е достатъчно навременното изпращане до посочения под точка 7) адрес на компанията. Отказът не изисква посочване на причина. Цената на покупката, както и евентуалните разходи за доставка и изпращане, ще бъдат възстановени след получаване на стоката от нас. Решаваща е стойността на върнатата стока към момента на покупката, а не стойността на цялата поръчка. Growheads обикновено може да организира вземане от вас." "content": "Клиентът има 14-дневно право на връщане.\nСрокът започва с получаването на стоката от клиента и се спазва чрез своевременно изпращане на уведомлението за отказ до Growheads. От това са изключени хранителни продукти и други бързо развалящи се стоки, както и изделия, изработени по поръчка, или стоки, които са били специално поръчани по искане на клиента. Връщането следва да се извърши чрез изпращане обратно на стоката в рамките на срока. Ако стоката не може да бъде изпратена, в рамките на срока трябва да ни бъде изпратено искане за обратно приемане чрез писмо, пощенска картичка, имейл или друг траен носител на данни. За спазване на срока е достатъчно своевременното изпращане до фирмения адрес, посочен в 7). Отказът не изисква мотиви. Покупната цена, както и евентуалните разходи за доставка и изпращане, ще бъдат възстановени след получаване на стоката от нас. Решаваща е стойността на върнатата стока към момента на покупката, а не стойността на цялата поръчка. Growheads по принцип може да организира вземане от вас.",
}, },
"7": { "7": {
"title": "Име и адрес на компанията, оплаквания, призовки", "title": "Име и адрес на дружеството, рекламации, връчване на документи",
"content": "Growheads\nTrachenberger Straße 14\n01129 Dresden" "content": "Growheads\nTrachenberger Straße 14\n01129 Dresden",
}, },
"8": { "8": {
"title": "Място на изпълнение и юрисдикция", "title": "Място на изпълнение и местна подсъдност",
"content": "Мястото на изпълнение и юрисдикцията за всички претенции е Дрезден, освен ако задължителни законови разпоредби не предвиждат друго." "content": "Мястото на изпълнение и местната подсъдност за всички претенции е Dresden, доколкото императивни законови разпоредби не възпрепятстват това.",
} }
} }
} }

View File

@@ -1,20 +1,20 @@
export default { export default {
"title": "Общи условия", // Allgemeine Geschäftsbedingungen "title": "Общи условия и правила",
"deliveryShippingConditions": "Условия за доставка и изпращане", // Liefer- & Versandbedingungen "deliveryShippingConditions": "Условия за доставка и изпращане",
"deliveryTerms": { "deliveryTerms": {
"1": "Доставката отнема между 1 и 7 дни.", // Der Versand dauert zwischen 1 und 7 Tagen. "1": "Изпращането отнема между 1 и 7 дни.",
"2": "Стоките остават собственост на Growheads до получаване на пълното плащане.", // Die Ware bleibt bis zur vollständigen Bezahlung Eigentum von Growheads. "2": "Стоката остава собственост на Growheads до извършване на пълното плащане.",
"3": "Ако се подозира, че стоките са повредени по време на транспорта или липсват артикули, опаковката за изпращане трябва да се запази за преглед от експерт. Всякакви повреди на опаковката трябва да бъдат потвърдени от превозвача в товарителницата, като се посочи видът и степента на повредата. Повредите при транспортиране трябва незабавно да бъдат съобщени на Growheads писмено чрез факс, имейл или по пощата. За това трябва да се направят снимки на повредените стоки, както и на повредената опаковка за изпращане, включително етикета с адреса. Повредената опаковка за изпращане също трябва да се запази. Те са необходими за предявяване на претенция към транспортната фирма.", // Bei der Vermutung, dass die Ware durch den Transport beschädigt wurde oder Ware fehlt, ist die Versandverpackung zur Ansicht durch einen Gutachter aufzubewahren. Eine Beschädigung der Verpackung ist durch den Transporteur nach Art und Umfang auf dem Lieferschein zu bestätigen. Versandschäden müssen sofort schriftlich per Fax, Email oder Post an Growheads gemeldet werden. Dafür müssen Fotos von der beschädigten Ware sowie von dem beschädigten Versandkarton samt Adressaufkleber erstellt werden. Der beschädigte Versandkarton ist auch aufzubewahren. Diese werden benötigt um den Schaden der Transportfirma in Rechnung zu stellen. "3": "Ако има съмнение, че стоката е била повредена по време на транспорта или че липсва стока, транспортната опаковка трябва да се съхрани за преглед от вещо лице. Повреда на опаковката трябва да бъде потвърдена от превозвача в товарителницата по вид и обем. Щетите при изпращане трябва незабавно да бъдат съобщени писмено на Growheads по факс, имейл или по пощата. За тази цел трябва да се направят снимки на повредената стока, както и на повредената транспортна кутия, включително адресния етикет. Повредената транспортна кутия също трябва да се запази. Те са необходими, за да бъде щетата предявена към транспортната фирма.",
"4": "При връщане на дефектни стоки клиентът трябва да се увери, че стоките са правилно опаковани.", // Bei der Rücksendung mangelhafter Ware hat der Kunde Sorge zu tragen, dass die Ware ordnungsgemäß verpackt wird. "4": "При връщане на дефектна стока клиентът трябва да се погрижи стоката да бъде правилно опакована.",
"5": "Всички връщания трябва да бъдат предварително регистрирани при Growheads.", // Alle Rücksendungen sind vorher bei Growheads anzumelden. "5": "Всички връщания трябва предварително да бъдат заявени при Growheads.",
"6": "Рискът при изпращане на артикули към нас се носи от клиента, освен ако не става въпрос за връщане на дефектни стоки.", // Für das Zusenden von Gegenständen an uns trägt der Kunde die Gefahr, soweit es sich dabei nicht um die Rücksendung mangelhafter Ware handelt. "6": "Клиентът носи риска при изпращане на предмети до нас, освен ако това не се отнася до връщане на дефектна стока.",
"7": "Growheads има право да организира вземането на стоките чрез Deutsche Post/GLS или избран от него спедитор.", // Growheads ist berechtigt, die Ware durch die Deutsche Post/GLS oder einen Spediteur seiner Wahl, abholen zu lassen. "7": "Growheads има право да възложи вземането на стоката от Deutsche Post/GLS или от спедитор по свой избор.",
"8": "Разходите за пощенски услуги се изчисляват според теглото. Growheads си запазва правото да прехвърля евентуални увеличения на цените от транспортните компании (такси, горивни надбавки).", // Die Portokosten werden nach Gewicht berechnet. Eventuelle Preiserhöhungen der Transportunternehmen (Maut, Treibstoffzuschläge) behält sich Growheads vor. "8": "Пощенските разходи се изчисляват според теглото. Growheads си запазва правото да прехвърли евентуални увеличения на цените от транспортните компании (пътни такси, допълнителни такси за гориво).",
"9": "Нашите пратки обикновено се изпращат с: GLS, DHL & Deutsche Post AG.", // Unsere Pakete werden in der Regel versendet mit: GLS, DHL & der Deutschen Post AG. "9": "Нашите пратки по правило се изпращат с: GLS, DHL и Deutsche Post AG.",
"10": "За особено тежки или обемисти артикули си запазваме правото да начисляваме допълнителни такси за доставка. Тези такси обикновено са посочени в ценовия лист.", // Bei besonders schweren oder sperrigen Artikeln behalten wir uns Zuschläge auf die Lieferkosten vor. In der Regel sind diese Zuschläge in der Preisliste aufgeführt. "10": "За особено тежки или обемисти артикули си запазваме правото да начисляваме допълнителни такси към разходите за доставка. По правило тези допълнителни такси са посочени в ценовата листа.",
"11": "Плащането предварително може да се извърши чрез банков превод към посочената банкова сметка.", // Es kann per Vorkasse an die angegebene Bankverbindung überwiesen werden. "11": "Плащането може да се извърши предварително чрез банков превод към посочената банкова сметка.",
"12": "Ако има забавяне на доставката, за което носим отговорност, срокът за допълнителен период, който купувачът има право да определи, е ограничен до две седмици. Срокът започва с получаването на уведомлението за допълнителния период от Growheads.", // Kommt es zu einer Lieferverzögerung, die von uns zu vertreten ist, so ist die Dauer der Nachfrist, die der Käufer zu setzen berechtigt ist, auf zwei Wochen festgelegt. Die Frist beginnt mit Eingang der Nachfristsetzung bei Growheads. "12": "Ако възникне забавяне на доставката, за което ние носим отговорност, срокът на допълнителния срок, който купувачът има право да определи, е фиксиран на две седмици. Срокът започва да тече от получаването от Growheads на уведомлението за даване на допълнителен срок.",
"13": "Очевидни дефекти на стоките трябва да бъдат съобщени писмено незабавно след доставката. Ако клиентът не изпълни това задължение, претенции по гаранцията за очевидни дефекти са изключени.", // Offensichtliche Mängel der Ware ist sofort nach Lieferung schriftlich anzuzeigen. Kommt der Kunde dieser Verpflichtung nicht nach, so sind Gewährleistungsansprüche wegen offensichtlicher Mängel ausgeschlossen. "13": "Явните недостатъци на стоката трябва да бъдат съобщени писмено незабавно след доставката. Ако клиентът не изпълни това задължение, претенции по гаранцията поради явни недостатъци са изключени.",
"14": "Ако клиентът подаде жалба за дефект, той трябва да върне дефектните стоки при нас с възможно най-точно описание на дефекта. Копие от нашата фактура трябва да бъде приложено към пратката. Стоките трябва да бъдат върнати в оригиналната опаковка или в опаковка, която защитава стоките по същия начин като оригиналната, за да се избегнат повреди по време на връщането.", // Rügt der Kunde einen Mangel, so hat er die mangelhafte Ware mit einer möglichst genauen Fehlerbeschreibung an uns zurück zu senden. Der Sendung ist eine Kopie unserer Rechnung beizulegen. Die Ware ist in der Originalverpackung zurück zu senden oder aber in einer Verpackung, welche die Ware entsprechend der Originalverpackung schützt, so dass Schäden auf dem Rücktransport vermieden werden. "14": "Ако клиентът предяви рекламация за недостатък, той трябва да ни върне дефектната стока заедно с възможно най-точно описание на повредата. Към пратката трябва да бъде приложено копие от нашата фактура. Стоката трябва да бъде върната в оригиналната опаковка или в опаковка, която защитава стоката по същия начин както оригиналната опаковка, така че да се избегнат повреди по време на обратния транспорт."
} }
}; };

View File

@@ -1,16 +1,16 @@
export default { export default {
"consultationLiability": { "consultationLiability": {
"title": "Консултации и Отговорност", "title": "Консултация и отговорност",
"1": "Ние предоставяме технически съвети за приложение според най-добрите ни знания, базирани на текущото състояние на нашия опит и експертиза.", "1": "Предоставяме технически консултации, свързани с приложението, добросъвестно и въз основа на актуалното състояние на нашия опит и знания.",
"2": "Купувачът носи отговорност за спазването на законовите разпоредби относно съхранението, по-нататъшния транспорт и използването на нашите стоки.", "2": "Купувачът носи отговорност за спазването на законовите разпоредби относно съхранението, по-нататъшния транспорт и употребата на нашите стоки.",
}, },
"paymentConditions": { "paymentConditions": {
"title": "Условия за плащане", "title": "Условия за плащане",
"1": "Стоките остават собственост на Growheads до пълното получаване на плащането.", "1": "Стоките остават собственост на Growheads до извършване на пълното плащане.",
"2": "Фактурите се плащат предварително чрез банков превод по нашата банкова сметка. Ако платите предварително, стоките ще бъдат изпратени веднага щом сумата бъде кредитирана по нашата сметка.", "2": "Фактурите трябва да бъдат заплатени предварително чрез банков превод към нашата банкова сметка. Ако платите предварително, стоките ще бъдат изпратени веднага щом сумата бъде кредитирана по нашата сметка.",
}, },
"retentionOfTitle": { "retentionOfTitle": {
"title": "Запазване на собствеността", "title": "Запазване на собствеността",
"content": "Доставените стоки остават собственост на Growheads, докато купувачът не уреди всички претенции срещу него. Ако продавачът препродаде стоките, той с настоящото прехвърля на нас претенциите, произтичащи от продажбата. Ако купувачът закъснее с плащането, ние можем по всяко време да изискаме връщането на стоките без да се отказваме от договора.", "content": "Доставените стоки остават собственост на Growheads, докато купувачът не погаси всички неизплатени вземания срещу него. Ако продавачът препродаде стоките, той с настоящото предварително ни прехвърля вземанията, произтичащи от продажбата, на които има право. Ако купувачът изпадне в забава с плащанията си, ние можем по всяко време да поискаме връщането на стоките, без да се отказваме от договора.",
} }
}; };

View File

@@ -1,8 +1,8 @@
export default { export default {
"title": "Информация за Закона за батериите", "title": "Указания относно Закона за батериите",
"intro": "Във връзка с продажбата на батерии или доставката на устройства, съдържащи батерии, ние сме задължени да ви информираме за следното:", "intro": "Във връзка с продажбата на батерии или доставката на устройства, които съдържат батерии, сме длъжни да Ви информираме за следното:",
"returnObligation": "Като краен потребител, вие сте законово задължени да връщате използвани батерии. Можете да върнете стари батерии, които ние имаме или сме имали в продуктовия си асортимент като нови батерии, безплатно в нашия склад за изпращане (адрес за доставка).", "returnObligation": "Като краен потребител Вие сте законово задължени да върнете използваните батерии. Можете да върнете безплатно изразходвани батерии, които предлагаме или сме предлагали като нови батерии в нашия продуктов асортимент, в нашия склад за изпращане (адрес за доставка).",
"symbolsInfo": "Символите, показани на батериите, имат следното значение:", "symbolsInfo": "Символите, изобразени върху батериите, имат следното значение:",
"wasteSymbol": "Символът на пресеченото кошче за отпадъци означава, че батерията не трябва да се изхвърля с битовите отпадъци.", "wasteSymbol": "Символът на зачеркнатата кофа за отпадъци означава, че батерията не трябва да се изхвърля с битовите отпадъци.",
"chemicalSymbols": "Pb = Батерията съдържа повече от 0,004 процента олово по маса\nCd = Батерията съдържа повече от 0,002 процента кадмий по маса\nHg = Батерията съдържа повече от 0,0005 процента живак по маса." "chemicalSymbols": "Pb = Батерията съдържа повече от 0,004 тегловни процента олово\nCd = Батерията съдържа повече от 0,002 тегловни процента кадмий\nHg = Батерията съдържа повече от 0,0005 тегловни процента живак.",
}; };

View File

@@ -1,19 +0,0 @@
export default {
"sections": {
"customerAccount": {
"title": "Клиентски акаунт",
"content": "При откриване на клиентски акаунт събираме вашите лични данни в посочения там обем. Обработката на данните служи за подобряване на вашето пазаруване и опростяване на обработката на поръчките. Обработката се извършва на основание чл. 6 (1) т. а DSGVO с вашето съгласие. Можете да оттеглите съгласието си по всяко време чрез уведомяване към нас, без това да засегне законосъобразността на обработката, извършена въз основа на съгласието до оттеглянето му. Вашият клиентски акаунт след това ще бъде изтрит."
},
"googleSSO": {
"title": "Вход с Google (Google Single Sign-On)",
"content": "Ние ви предлагаме възможността да влезете в клиентския си акаунт, използвайки вашия Google акаунт. Ако използвате функцията „Вход с Google“, удостоверяването се извършва чрез услугата Google Single Sign-On. В този процес на вашето устройство могат да бъдат съхранени бисквитки от Google, които са необходими за процеса на влизане и удостоверяване. В рамките на Google входа получаваме от Google определени лични данни за проверка на вашата самоличност. По-специално, Google ни предава вашето име, вашия имейл адрес и ако е съхранена във вашия Google акаунт вашата профилна снимка. Тази информация се предоставя от Google веднага щом влезете в нашия онлайн магазин с вашия Google акаунт. Google, като трета страна, може да има достъп до тези данни и да ги обработва; това може да включва и прехвърляне на данни в САЩ. Ние сме сключили стандартни клаузи за защита на данните с Google съгласно чл. 46 ал. 2 т. в DSGVO, за да осигурим адекватно ниво на защита на данните при прехвърлянето на вашите данни. Допълнителни подробности за обработката на данни от Google можете да намерите в Политиката за поверителност на Google (на <a href=\"https://policies.google.com/privacy?hl=de\" target=\"_blank\" rel=\"noopener noreferrer\">policies.google.com/privacy?hl=de</a>).",
"legalBasis": "Обработката на данни във връзка с входа чрез Google се основава на чл. 6 ал. 1 т. б DSGVO (изпълнение на предварителни договорни мерки и изпълнение на договора, напр. за създаване и използване на вашия клиентски акаунт), както и на чл. 6 ал. 1 т. ф DSGVO (нашият легитимен интерес да ви предоставим бърза и удобна възможност за вход).",
"voluntaryUse": "Използването на функцията „Вход с Google“ е доброволно. Разбира се, можете да използвате нашия онлайн магазин и вашия клиентски акаунт и без Google SSO, като се регистрирате или влезете с вашия имейл адрес и парола както обикновено. Ако изберете да използвате вход с Google, можете по всяко време да прекъснете тази връзка, като премахнете свързването в настройките на вашия Google акаунт.",
"yourRights": "Относно личните данни, обработвани чрез Google SSO, имате законните права на субект на данни. По-специално имате право да получите информация за съхраняваните за вас данни (чл. 15 DSGVO), да поискате корекция на неточни данни (чл. 16 DSGVO) или изтриване на вашите данни (чл. 17 DSGVO). Освен това имате право да ограничите обработката на вашите данни (чл. 18 DSGVO) и право на преносимост на данните (чл. 20 DSGVO). Ако основаваме обработката на нашия легитимен интерес, можете да възразите срещу обработката (чл. 21 DSGVO). Освен това можете по всяко време да се обърнете с жалба към компетентния надзорен орган по защита на данните. Вашите вече съществуващи права и възможности за избор от останалата част на политиката за поверителност разбира се важат и за използването на вход с Google."
},
"orders": {
"title": "Събиране, обработка и използване на лични данни при поръчки",
"content": "При подаване на поръчка събираме и използваме вашите лични данни само в обема, необходим за изпълнение и обработка на вашата поръчка и за обработка на вашите запитвания. Предоставянето на данните е необходимо за сключване на договора. Непредоставянето на данните води до невъзможност за сключване на договор. Обработката се извършва на основание чл. 6 (1) т. б DSGVO и е необходима за изпълнение на договор с вас. Вашите данни няма да бъдат предавани на трети страни без вашето изрично съгласие. Единствените изключения са нашите партньори по услуги, които са необходими за обработка на договорните отношения, или доставчици на услуги, които използваме във връзка с обработката на поръчките. Освен получателите, посочени в съответните клаузи на тази политика за поверителност, това могат да бъдат получатели от следните категории: доставчици на куриерски услуги, доставчици на платежни услуги, доставчици на услуги за управление на стоките, доставчици на услуги за обработка на поръчки, уеб хостинг доставчици, IT доставчици и дропшипинг търговци. Във всички случаи строго спазваме законовите изисквания. Обемът на предаваните данни е ограничен до минимум."
}
}
};

View File

@@ -1,18 +1,18 @@
export default { export default {
"title": "Политика за поверителност", "title": "Политика за поверителност",
"responsibleParty": { "responsibleParty": {
"title": "Отговорно лице по смисъла на закона за защита на данните:", "title": "Администратор по смисъла на законодателството за защита на данните:",
"company": "Growheads\nTrachenberger Straße 14\n01129 Dresden" "company": "Growheads\nTrachenberger Straße 14\n01129 Dresden"
}, },
"generalInfo": "Освен ако по-долу не е посочено друго, предоставянето на вашите лични данни не е нито законово, нито договорно изискване, нито е необходимо за сключване на договор. Не сте задължени да предоставяте данните. Непредоставянето им няма да има последствия. Това важи само доколкото при следващите обработки не е посочено друго. Лични данни означава всяка информация, отнасяща се до идентифицирано или идентифицируемо физическо лице.", "generalInfo": "Освен ако по-долу не е посочено друго, предоставянето на вашите лични данни не е нито законово, нито договорно задължително, нито е необходимо за сключването на договор. Вие не сте длъжни да предоставяте данните. Непредоставянето им няма да има последици. Това важи само доколкото в следващите операции по обработване не е посочено друго. \"Лични данни\" означава всяка информация, отнасяща се до идентифицирано или подлежащо на идентифициране физическо лице.",
"sections": { "sections": {
"informationDeletion": { "informationDeletion": {
"title": "Информация, изтриване, блокиране", "title": "Информация, изтриване, блокиране",
"content": "По всяко време можете да поискате информация за вашите лични данни, техния произход и получатели, както и целта на обработката на данните, и можете безплатно да поискате корекция, блокиране или изтриване на тези данни. Моля, използвайте предоставените възможности за контакт в долния колонтитул на страницата или в правния импресум за тази цел. Ние сме на разположение по всяко време за допълнителни въпроси по темата. Моля, имайте предвид, че не сме упълномощени и няма да изтриваме данни за фактури, банкови данни и данни, които са били изпратени на доставчик на куриерски услуги. Данни, които могат да бъдат изтрити, включват: клиентски акаунти на уеб сървъра, както и в системата за управление на стоките, и имейли, които не са пряко свързани с поръчка." "content": "По всяко време можете да получите информация относно личните данни, техния произход и получатели, както и целта на обработването на данните, и да поискате безплатно коригиране, блокиране или изтриване на тези данни. Моля, използвайте за тази цел контактните възможности, посочени във футъра на страницата или в правното уведомление. На разположение сме също така по всяко време за допълнителни въпроси по тази тема. Моля, имайте предвид, че нямаме право да и няма да изтриваме данни за фактури, банкови данни и данни, които са били предадени на доставчик на транспортни услуги. Данни, които могат да бъдат изтрити, са: данни за клиентски акаунт на уеб сървъра, както и в системата за управление на стоки, и имейли, които не са пряко свързани с поръчка."
}, },
"serverLogfiles": { "serverLogfiles": {
"title": "Сървърни лог файлове", "title": "Сървърни лог файлове",
"content": "Можете да посещавате нашите уебсайтове без да предоставяте информация за себе си. При всяко посещение на нашия уебсайт, данни за използването се предават от вашия интернет браузър и се съхраняват в лог файлове (сървърни лог файлове). Тези съхранени данни включват например името на посетената страница, дата и час на достъп, количество прехвърлени данни и доставчика, който прави заявката. Тези данни се използват изключително за осигуряване на безпроблемната работа на нашия уебсайт и за подобряване на нашето предложение. Тези данни не са лични данни. Не се извършва обединяване на тези данни с други източници на данни. Ако станем наясно с конкретни индикации за незаконна употреба, си запазваме правото да проверим тези данни впоследствие." "content": "Можете да посещавате нашите уебсайтове, без да предоставяте лична информация. При всяко посещение на нашия уебсайт чрез вашия интернет браузър се предават данни за използването и се съхраняват в протоколни данни (сървърни лог файлове). Сред тези съхранени данни са например името на посетената страница, датата и часът на достъпа, количеството предадени данни и заявяващият доставчик. Тези данни служат единствено за осигуряване на безпроблемната работа на нашия уебсайт и за подобряване на нашето предложение. Тези данни не са лични данни. Не се извършва обединяване на тези данни с други източници на данни. Ако ни станат известни конкретни индикации за неправомерно използване, си запазваме правото да проверим тези данни със задна дата."
} }
} }
}; };

View File

@@ -1,12 +1,17 @@
export default { export default {
"sections": { "sections": {
"chatbot": { "chatbot": {
"title": зползване на AI чатбот (OpenAI API)", "title": И чатбот (OpenAI API)",
"content": "Ние използваме AI-чатбот на нашия уебсайт, който се управлява чрез интерфейса за програмиране на приложения (API) на доставчика OpenAI. Чатботът е предназначен да отговаря ефективно и автоматично на запитванията на посетителите и по този начин да предоставя функция за поддръжка. Когато използвате чатбота, вашите въвеждания се обработват от системата за генериране на подходящи отговори. Обработката се извършва анонимно не се събират или съхраняват IP адреси или други лични данни (като име или контактна информация).", "intro": "Използваме на нашия уебсайт чатбот, подпомаган от изкуствен интелект, който се предоставя чрез OpenAI API, за автоматично отговаряне на запитвания и подобряване на нашата поддръжка.",
"legalBasis": "Правното основание за използването на чатбота е нашият легитимен интерес съгласно чл. 6, ал. 1, т. f DSGVO. Този интерес се състои в предоставянето на ефективна поддръжка на посетителите, както и в подобряването на потребителското изживяване на нашия уебсайт.", "processing": "При използване на чатбота съдържанието, което въвеждате, се предава на OpenAI и се обработва там от наше име, за да се генерира подходящ отговор. Моля, имайте предвид, че въведеното съдържание може да съдържа и лични данни, ако сами предоставите такава информация.",
"dataRecipient": "Получател на данните от чата е OpenAI (OpenAI OpCo, LLC) като технически доставчик на услуги. OpenAI обработва предаденото съдържание на чата на своите сървъри изключително с цел генериране на отговори. OpenAI действа като обработващ по смисъла на чл. 28 DSGVO и не използва данните за свои собствени цели. Ние сме сключили договор за обработка на данни с OpenAI, който включва стандартните договорни клаузи на ЕС като подходящи гаранции за защита на данните. OpenAI има седалище в САЩ; чрез съгласието със стандартните договорни клаузи се гарантира, че при прехвърлянето на вашите данни се осигурява ниво на защита на данните, еквивалентно на това в Европейския съюз.", "legalBasis": "Правното основание за обработването е чл. 6, ал. 1, буква f DSGVO. Нашият легитимен интерес се състои в ефективното обработване на запитвания и в подобряването на нашето онлайн предложение.",
"dataRetention": "Ние съхраняваме вашите чат запитвания само толкова дълго, колкото е необходимо за обработка и отговор. След като вашето запитване бъде приключено, чат историята се изтрива своевременно или се анонимизира. Според собствените си изявления OpenAI съхранява обработените чат данни само временно и ги изтрива автоматично най-късно след 30 дни.", "dataRecipient": "Получател на данните е OpenAI. За потребители в Европейското икономическо пространство и в Швейцария OpenAI Ireland Limited, 1st Floor, The Liffey Trust Centre, 117-126 Sheriff Street Upper, Dublin 1, D01 YC43, Ireland, е съответната договорна страна.",
"voluntaryUse": "Използването на чатбота е доброволно. Ако не използвате чатбота, няма да се предават данни на OpenAI. Моля, не въвеждайте чувствителни лични данни в чата." "thirdCountryTransfer": "Не може да бъде изключено предаване на данни към трети държави (по-специално САЩ). Това се извършва въз основа на подходящи гаранции съгласно чл. 44 и сл. DSGVO, по-специално чрез използване на стандартни договорни клаузи.",
"modelTraining": "Според информацията на OpenAI данните от използването на API по подразбиране не се използват за обучение на моделите.",
"dataRetention": "Съхраняваме съдържанието на чата само толкова дълго, колкото е необходимо за обработване на Вашето запитване, и след това го изтриваме или анонимизираме, освен ако не съществуват законови задължения за съхранение.",
"voluntaryUse": "Използването на чатбота е доброволно. Моля, не въвеждайте чувствителни лични данни в чата.",
"privacyLinkIntro": "Допълнителна информация относно обработването на данни от OpenAI можете да намерите на:",
"privacyLinkUrl": "https://openai.com/de-DE/policies/eu-privacy-policy/"
} }
} }
}; };

View File

@@ -3,15 +3,15 @@ export default {
"cookies": { "cookies": {
"title": "Бисквитки", "title": "Бисквитки",
"intro": "Нашият уебсайт използва бисквитки в следните случаи:", "intro": "Нашият уебсайт използва бисквитки в следните случаи:",
"payment": "1. Процес на плащане: При плащания с кредитна карта или незабавни преводи (напр. Klarna Sofort) се използват технически необходими бисквитки. Те съдържат характерен низ, който позволява уникална идентификация на браузъра. Бисквитките се задават от платежния доставчик Stripe и са абсолютно необходими за сигурното и безпроблемно обработване на плащанията. Без тези бисквитки не е възможно да се направи поръчка с тези методи на плащане. Обработката се извършва на основание чл. 6 (1) т. б DSGVO за изпълнение на договор.", "payment": "1. Процес на плащане: При плащания с кредитна карта или незабавни банкови преводи (напр. Klarna Sofort) се използват технически необходими бисквитки. Те съдържат характерна низова последователност от знаци, която позволява браузърът да бъде уникално идентифициран. Бисквитките се задават от платежния доставчик Stripe и са абсолютно необходими за сигурното и безпроблемно обработване на плащанията. Без тези бисквитки не е възможно да се направи поръчка с тези методи на плащане. Обработването се извършва на основание чл. 6, ал. 1, буква б DSGVO за изпълнение на договор.",
"googleSSO": "2. Google Single Sign-On (SSO): При използване на вход с Google, Google задава бисквитки, които са необходими за процеса на вход и удостоверяване. Тези бисквитки ви позволяват удобно да влизате с вашия Google акаунт, без да се налага да въвеждате данните си всеки път. Обработката се извършва на основание чл. 6 (1) т. б DSGVO (изпълнение на договор) и чл. 6 (1) т. ф DSGVO (легитимен интерес за удобен вход).", "googleSSO": "2. Google Single Sign-On (SSO): При използване на вход с Google бисквитките се задават от Google и са необходими за процеса на вход и удостоверяване. Тези бисквитки ви позволяват удобно да влизате с вашия Google акаунт, без да е нужно да се вписвате отново всеки път. Обработването се извършва на основание чл. 6, ал. 1, буква б DSGVO (изпълнение на договор) и чл. 6, ал. 1, буква f DSGVO (легитимен интерес от лесен за ползване вход).",
"otherPayments": "За други методи на плащане директен дебит, вземане на място или наложен платеж не се използват допълнителни бисквитки, освен ако не използвате вход с Google." "otherPayments": "За други методи на плащане директен дебит, получаване на място или наложен платеж не се използват допълнителни бисквитки, освен ако не използвате вход с Google."
}, },
"mollie": { "mollie": {
"title": "Mollie (Обработка на плащания)", "title": "Mollie (Обработка на плащания)",
"content": "Използваме платежния доставчик Mollie на нашия уебсайт за обработка на плащания. Доставчик на услугата е Mollie B.V., Keizersgracht 126, 1015 CW Amsterdam, Netherlands. В този контекст се предават на Mollie лични данни, необходими за обработка на плащания по-специално вашето име, имейл адрес, адрес за фактуриране, платежна информация (напр. данни за кредитна карта) и IP адрес. Обработката на данни се извършва с цел обработка на плащания; правното основание е чл. 6 ал. 1 т. б DSGVO, тъй като служи за изпълнение на договор с вас.", "content": "Използваме платежния доставчик Mollie на нашия уебсайт за обработка на плащания. Доставчик на услугата е Mollie B.V., Keizersgracht 126, 1015 CW Amsterdam, Netherlands. В този контекст лични данни, необходими за обработката на плащането, се предават на Mollie - по-специално вашето име, вашият имейл адрес, адрес за фактуриране, платежна информация (напр. данни за кредитна карта), както и IP адресът. Обработването на данните се извършва с цел обработка на плащането; правното основание е чл. 6, ал. 1, буква б DSGVO, тъй като то служи за изпълнение на договор с вас.",
"responsibility": "Mollie също така обработва определени данни като самостоятелен администратор, например за изпълнение на законови задължения (като предотвратяване на пране на пари) и за предотвратяване на измами. Освен това сме сключили договор за обработка на данни с Mollie съгласно чл. 28 DSGVO; по силата на този договор Mollie действа изключително по наши инструкции при обработката на плащания.", "responsibility": "Mollie също така обработва определени данни като самостоятелен администратор, например за изпълнение на законови задължения (като предотвратяване на изпирането на пари) и за предотвратяване на измами. Освен това сме сключили договор за обработване на данни с Mollie съгласно чл. 28 DSGVO; в рамките на това споразумение Mollie действа изключително по наши указания при обработката на плащания.",
"dataTransfer": "Ако Mollie обработва лични данни извън ЕС, особено в САЩ, това се прави при спазване на подходящи гаранции. Mollie използва стандартните договорни клаузи на ЕС съгласно чл. 46 DSGVO, за да осигури адекватно ниво на защита на данните. Въпреки това бихме искали да отбележим, че САЩ се считат за трета държава по смисъла на защитата на данните с потенциално недостатъчно ниво на защита. Допълнителна информация можете да намерите в политиката за поверителност на Mollie на https://www.mollie.com/de/privacy." "dataTransfer": "Доколкото Mollie обработва лични данни извън ЕС, по-специално в САЩ, това се извършва при спазване на подходящи гаранции. За тази цел Mollie използва стандартните договорни клаузи на ЕС съгласно чл. 46 DSGVO, за да осигури адекватно ниво на защита на данните. Все пак бихме искали да отбележим, че съгласно правото за защита на данните САЩ се считат за трета държава с потенциално недостатъчно ниво на защита на данните. Допълнителна информация можете да намерите в политиката за поверителност на Mollie на https://www.mollie.com/de/privacy."
} }
} }
}; };

View File

@@ -1,8 +1,8 @@
export default { export default {
"sections": { "sections": {
"customerAccount": { "customerAccount": {
"title": "Клиентски акаунт", "title": "Потребителски акаунт",
"content": "Когато отворите клиентски акаунт, ние събираме вашите лични данни в посочения там обем. Обработката на данните служи за подобряване на вашето пазаруване и опростяване на обработката на поръчките. Обработката се извършва въз основа на чл. 6 (1) т. a DSGVO с вашето съгласие. Можете да оттеглите съгласието си по всяко време чрез уведомяване до нас, без това да засяга законосъобразността на обработката, извършена въз основа на съгласието до оттеглянето му. Вашият клиентски акаунт ще бъде изтрит след това." "content": "При откриване на потребителски акаунт събираме Вашите лични данни в посочения там обем. Обработването на данните служи за подобряване на Вашето пазаруване и улесняване на обработката на поръчките. Обработването се извършва на основание чл. 6 (1) буква a DSGVO с Вашето съгласие. Можете да оттеглите съгласието си по всяко време, като ни уведомите, без това да засяга законосъобразността на обработването, извършено въз основа на съгласието до оттеглянето му. След това Вашият потребителски акаунт ще бъде изтрит."
} }
} }
}; };

View File

@@ -1,15 +1,18 @@
export default { export default {
"sections": { "sections": {
"googleSSO": { "googleSSO": {
"title": "Вход с Google (Google Single Sign-On)", "title": "Влизане с Google (Google Single Sign-On)",
"content": "Ние ви предлагаме възможността да влезете в своя клиентски акаунт, използвайки своя Google акаунт. Ако използвате функцията „Вход с Google, удостоверяването се извършва чрез услугата Google Single Sign-On. В този процес на вашето устройство могат да бъдат съхранени бисквитки от Google, които са необходими за процеса на вход и удостоверяване. В рамките на входа с Google получаваме от Google определени лични данни за проверка на вашата самоличност. По-специално, Google ни предава вашето име, вашия имейл адрес и ако е съхранена във вашия Google акаунт вашата профилна снимка. Тази информация се предоставя от Google веднага щом влезете в нашия онлайн магазин с вашия Google акаунт. Google, като трета страна, може да има достъп до тези данни и да ги обработва; това може да включва и прехвърляне на данни в САЩ. Ние сме сключили стандартни клаузи за защита на данните с Google съгласно чл. 46 ал. 2 т. в DSGVO, за да осигурим адекватно ниво на защита на данните при прехвърлянето на вашите данни. Допълнителни подробности относно обработката на данни от Google можете да намерите в Политиката за поверителност на Google (на <a href=\"https://policies.google.com/privacy?hl=de\" target=\"_blank\" rel=\"noopener noreferrer\">policies.google.com/privacy?hl=de</a>).", "content": "Предлагаме Ви възможността да влезете в клиентския си акаунт с Вашия Google акаунт. Ако използвате функцията \"Влизане с Google\", удостоверяването се извършва чрез услугата Google Single Sign-On. При това Google може да съхранява бисквитки на Вашето крайно устройство, които са необходими за процеса на влизане и удостоверяването. В рамките на Google влизането получаваме от Google определени лични данни за потвърждаване на Вашата самоличност. По-специално Google ни предава Вашето име, Вашия имейл адрес и ако са записани във Вашия Google акаунт Вашата профилна снимка. Тази информация се предоставя от Google веднага щом влезете в нашия онлайн магазин с Вашия Google акаунт. Google, като доставчик трета страна, може да има достъп до тези данни и да ги обработва; при това може да се стигне и до предаване на данни в САЩ. Сключили сме Стандартни договорни клаузи с Google съгласно чл. 46, ал. 2, буква c DSGVO, за да осигурим адекватно ниво на защита на данните при предаването на Вашите данни.",
"legalBasis": "Обработката на данни във връзка с входа чрез Google се основава на чл. 6 ал. 1 т. б DSGVO (изпълнение на предварителни договорни мерки и изпълнение на договора, напр. за създаване и използване на вашия клиентски акаунт), както и на чл. 6 ал. 1 т. ж DSGVO (нашият легитимен интерес да ви предоставим бърза и удобна възможност за вход).", "privacyLinkIntro": "Допълнителни подробности относно обработването на данни от Google можете да намерите в Политиката за поверителност на Google (на ",
"voluntaryUse": "Използването на функцията „Вход с Google“ е доброволно. Разбира се, можете да използвате нашия онлайн магазин и вашия клиентски акаунт и без Google SSO, като се регистрирате или влезете с вашия имейл адрес и парола както обикновено. Ако изберете да използвате вход с Google, можете по всяко време да прекъснете тази връзка, като премахнете свързването в настройките на вашия Google акаунт.", "privacyLinkUrl": "https://policies.google.com/privacy?hl=de",
"yourRights": "Относно личните данни, обработвани чрез Google SSO, имате законните права на субект на данни. По-специално, имате право да получите информация за съхраняваните за вас данни (чл. 15 DSGVO), да поискате корекция на неточни данни (чл. 16 DSGVO) или изтриване на вашите данни (чл. 17 DSGVO). Освен това имате право да ограничите обработката на вашите данни (чл. 18 DSGVO) и право на преносимост на данните (чл. 20 DSGVO). Ако основаваме обработката на нашия легитимен интерес, можете да възразите срещу обработката (чл. 21 DSGVO). Можете също така по всяко време да подадете жалба до компетентния надзорен орган за защита на данните. Вашите съществуващи права и възможности за избор от останалата част на политиката за поверителност естествено важат и за използването на вход с Google." "privacyLinkSuffix": ").",
"legalBasis": "Обработването на данни във връзка с Google влизането се извършва на основание чл. 6, ал. 1, буква b DSGVO (изпълнение на преддоговорни мерки и изпълнение на договор, напр. за създаването и използването на Вашия клиентски акаунт), както и чл. 6, ал. 1, буква f DSGVO (наш легитимен интерес да Ви предоставим бърза и удобна възможност за влизане).",
"voluntaryUse": "Използването на функцията \"Влизане с Google\" е доброволно. Разбира се, можете да използвате нашия онлайн магазин и Вашия клиентски акаунт и без Google SSO, като се регистрирате или влезете по обичайния начин с имейл адрес и парола. Ако решите да използвате Google влизането, можете да прекратите тази връзка по всяко време, като прекъснете свързването в настройките на Вашия Google акаунт.",
"yourRights": "По отношение на личните данни, обработвани чрез Google SSO, Вие имате законовите права на субекта на данните. По-специално имате право да получите информация за съхраняваните за Вас данни (чл. 15 DSGVO), да поискате коригиране на неточни данни (чл. 16 DSGVO) или изтриване на Вашите данни (чл. 17 DSGVO). Освен това имате право на ограничаване на обработването на Вашите данни (чл. 18 DSGVO) и право на преносимост на данните (чл. 20 DSGVO). Доколкото основаваме обработването на нашия легитимен интерес, можете да възразите срещу обработването (чл. 21 DSGVO). Освен това можете по всяко време да подадете жалба до компетентния надзорен орган за защита на данните. Вашите съществуващи права и избори съгласно останалата част от тази Политика за поверителност естествено се прилагат и за използването на Google влизане."
}, },
"orders": { "orders": {
"title": "Събиране, обработка и използване на лични данни при поръчки", "title": "Събиране, обработване и използване на лични данни при поръчки",
"content": "Когато правите поръчка, ние събираме и използваме вашите лични данни само в необходимия обем за изпълнение и обработка на вашата поръчка и за обработка на вашите запитвания. Предоставянето на тези данни е необходимо за сключване на договора. Непредоставянето им води до невъзможност за сключване на договор. Обработката се извършва на основание чл. 6 (1) т. б DSGVO и е необходима за изпълнение на договор с вас. Вашите данни няма да бъдат предавани на трети страни без вашето изрично съгласие. Единствените изключения са нашите партньори по услуги, които са необходими за обработка на договорните отношения, или доставчици, които използваме в рамките на обработка по поръчка. Освен получателите, посочени в съответните клаузи на тази политика за поверителност, това могат да бъдат получатели в следните категории: доставчици на куриерски услуги, доставчици на платежни услуги, доставчици на системи за управление на стоките, доставчици на услуги за обработка на поръчки, уеб хостинг доставчици, IT доставчици и търговци по дропшипинг. При всички случаи стриктно спазваме законовите изисквания. Обемът на предаваните данни е ограничен до минимум." "content": "Когато направите поръчка, ние събираме и използваме Вашите лични данни само доколкото това е необходимо за изпълнението и обработката на Вашата поръчка, както и за обработването на Вашите запитвания. Предоставянето на данните е необходимо за сключването на договора. Непредоставянето им води до това, че не може да бъде сключен договор. Обработването се извършва на основание чл. 6 (1) буква b DSGVO и е необходимо за изпълнението на договор с Вас. Вашите данни няма да бъдат предавани на трети лица без Вашето изрично съгласие. Изключение от това правят единствено нашите партньори по услуги, които са ни необходими за обработката на договорното правоотношение, или доставчици на услуги, които използваме в рамките на обработване от наше име. Освен получателите, посочени в съответните клаузи на тази Политика за поверителност, това са например получатели от следните категории: доставчици на услуги за доставка, доставчици на платежни услуги, доставчици на системи за управление на стоките, доставчици на услуги за обработка на поръчки, уеб хостове, IT доставчици и търговци, работещи по модела dropshipping. Във всички случаи стриктно спазваме законовите изисквания. Обхватът на предаването на данни е ограничен до минимум."
} }
} }
}; };

View File

@@ -2,7 +2,7 @@ export default {
"sections": { "sections": {
"newsletter": { "newsletter": {
"title": "Използване на имейл адреса за изпращане на бюлетини", "title": "Използване на имейл адреса за изпращане на бюлетини",
"content": "Ние използваме вашия имейл адрес, независимо от обработката на договора, изключително за наши собствени рекламни цели за изпращане на бюлетини, при условие че сте дали изричното си съгласие за това. Обработката се извършва въз основа на чл. 6 (1) т. a DSGVO с вашето съгласие. Можете да оттеглите съгласието си по всяко време, без това да засегне законосъобразността на обработката, извършена въз основа на съгласието до оттеглянето му. Можете да се отпишете от бюлетина по всяко време, като използвате съответната връзка в бюлетина или като ни уведомите. Вашият имейл адрес след това ще бъде премахнат от списъка за разпространение. Данните ви ще бъдат предадени на доставчик на услуги за имейл маркетинг в рамките на обработка по поръчка. Не се извършва разкриване на други трети страни. Данните ви ще бъдат прехвърлени в трета държава, за която съществува решение за адекватност на Европейската комисия." "content": "Използваме Вашия имейл адрес, независимо от изпълнението на договора, изключително за наши собствени рекламни цели за изпращане на бюлетини, при условие че сте дали изричното си съгласие за това. Обработването се извършва на основание на чл. 6, ал. 1, буква a DSGVO с Вашето съгласие. Можете да оттеглите съгласието си по всяко време, без това да засяга законосъобразността на обработването, извършено въз основа на съгласието до неговото оттегляне. За тази цел можете да се отпишете от бюлетина по всяко време, като използвате съответната връзка в бюлетина или като ни уведомите. След това Вашият имейл адрес ще бъде премахнат от списъка за разпращане. Вашите данни ще бъдат предадени на доставчик на услуга за имейл маркетинг в рамките на Auftragsverarbeitung. Не се извършва предоставяне на други трети лица. Вашите данни ще бъдат прехвърлени към трета държава, за която съществува решение за адекватност на Европейската комисия."
} }
} }
}; };

View File

@@ -2,11 +2,11 @@ export default {
"sections": { "sections": {
"webPush": { "webPush": {
"title": "Push известия (Web Push / VAPID)", "title": "Push известия (Web Push / VAPID)",
"intro": "Ако сте дали съгласие да получавате push известия, използваме т.нар. web push технологии, за да показваме съобщения (напр. относно поръчки или наличности) директно във вашия браузър.", "intro": "Ако сте се съгласили да получавате push известия, ние използваме така наречените web push технологии, за да показваме съобщения (напр. относно поръчки или наличности) директно във вашия браузър.",
"subscriptionData": "За тази цел при регистрацията за push услугата се обработват т.нар. данни за push абонамент. Те включват по-специално URL на крайна точка и криптографски ключове, присвоени на вашия браузър.", "subscriptionData": "За тази цел при регистрация за push услугата се обработват така наречените данни за push абонамент. Те включват по-специално URL адрес на крайна точка, както и криптографски ключове, които са свързани с вашия браузър.",
"consent": "Обработването се извършва изключително въз основа на вашето съгласие съгласно чл. 6, ал. 1, буква a DSGVO. Можете да оттеглите съгласието си по всяко време, като деактивирате push известията в настройките на вашия браузър.", "consent": "Обработването се извършва изключително въз основа на вашето съгласие съгласно чл. 6, ал. 1, буква a DSGVO. Можете да оттеглите съгласието си по всяко време, като деактивирате push известията в настройките на вашия браузър.",
"thirdCountries": "За доставяне на push съобщения се използват услуги на съответните доставчици на браузъри (напр. Google, Mozilla или Apple). В този контекст може да се извърши предаване на лични данни към трети държави, по-специално към САЩ. В такива случаи предаването се извършва въз основа на подходящи гаранции съгласно чл. 46 DSGVO.", "thirdCountries": "За доставянето на push съобщения се използват услуги на съответните доставчици на браузъри (напр. Google, Mozilla или Apple). В този контекст е възможно предаване на лични данни към трети държави, по-специално към САЩ. В такива случаи предаването се извършва въз основа на подходящи гаранции съгласно чл. 46 DSGVO.",
"retention": "Вашите данни ще се съхраняват само толкова дълго, колкото сте абонирани за push известия." "retention": "Вашите данни ще се съхраняват само докато сте абонирани за push известия."
} }
} }
}; };

View File

@@ -1,16 +1,16 @@
export default { export default {
"sections": { "sections": {
"dataRetention": { "dataRetention": {
"title": "Продължителност на съхранение", "title": "Срок на съхранение",
"content": "След пълното изпълнение на договора, данните първоначално ще се съхраняват за продължителността на гаранционния срок, а след това с оглед на законовите, по-специално данъчните и търговските срокове за съхранение, и след изтичането на тези срокове ще бъдат изтрити, освен ако не сте дали съгласие за по-нататъшна обработка и използване." "content": "След пълното изпълнение на договора данните първоначално ще бъдат съхранявани за срока на гаранцията, след това с оглед на законовите срокове за съхранение, по-специално съгласно данъчното и търговското право, и след изтичане на тези срокове ще бъдат изтрити, освен ако не сте дали съгласие за по-нататъшна обработка и използване."
}, },
"dataSubjectRights": { "dataSubjectRights": {
"title": "Права на субекта на данните", "title": "Права на субекта на данните",
"content": "Ако са изпълнени законовите изисквания, имате следните права съгласно чл. 15 до 20 DSGVO: право на достъп, право на корекция, право на изтриване, право на ограничаване на обработката и право на преносимост на данните. Освен това, съгласно чл. 21 (1) DSGVO, имате право да възразите срещу обработката, основана на чл. 6 (1) f DSGVO, както и срещу обработката за целите на директния маркетинг. Моля, свържете се с нас, ако желаете. Нашите контактни данни можете да намерите в нашия Impressum." "content": "При наличие на законовите изисквания Ви се полагат следните права съгласно чл. 15 до 20 DSGVO: право на достъп, коригиране, изтриване, ограничаване на обработването и преносимост на данните. Освен това, съгласно чл. 21 (1) DSGVO, имате право да възразите срещу обработването, основано на чл. 6 (1) f DSGVO, както и срещу обработването за целите на директната реклама. Моля, свържете се с нас при желание. Данните за контакт можете да намерите в нашето правно уведомление."
}, },
"supervisoryAuthority": { "supervisoryAuthority": {
"title": "Право на жалба пред надзорния орган", "title": "Право на подаване на жалба до надзорния орган",
"content": "Съгласно чл. 77 DSGVO имате право да подадете жалба пред надзорния орган, ако смятате, че обработката на вашите лични данни не е законна." "content": "Съгласно чл. 77 DSGVO имате право да подадете жалба до надзорния орган, ако считате, че обработването на Вашите лични данни не е законосъобразно."
} }
} }
}; };

View File

@@ -1,29 +0,0 @@
export default {
"sections": {
"newsletter": {
"title": "Използване на имейл адреса за изпращане на бюлетини",
"content": "Използваме вашия имейл адрес изключително за наши собствени рекламни цели за изпращане на бюлетини, независимо от обработката на договора, при условие че сте дали изричното си съгласие. Обработката се основава на чл. 6 (1) т. a DSGVO с вашето съгласие. Можете да оттеглите съгласието си по всяко време, без това да засегне законосъобразността на обработката, извършена въз основа на съгласието до оттеглянето му. Можете да се отпишете от бюлетина по всяко време, като използвате съответната връзка в бюлетина или като ни уведомите. Вашият имейл адрес след това ще бъде премахнат от списъка за разпространение. Данните ви ще бъдат предадени на доставчик на услуги за имейл маркетинг в рамките на обработка по поръчка. Не се извършва разкриване на други трети страни. Данните ви ще бъдат прехвърлени в трета страна, за която съществува решение за адекватност на Европейската комисия."
},
"chatbot": {
"title": "Използване на AI чатбот (OpenAI API)",
"content": "Използваме AI-базиран чатбот на нашия уебсайт, който се управлява чрез интерфейса за програмиране на приложения (API) на доставчика OpenAI. Чатботът се използва за ефективно и автоматично отговаряне на запитвания от посетители и по този начин предоставя функция за поддръжка. Когато използвате чатбота, вашите въвеждания се обработват от системата за генериране на подходящи отговори. Обработката е анонимизирана не се събират или съхраняват IP адреси или други лични данни (като име или контактна информация).",
"legalBasis": "Правното основание за използването на чатбота е нашият легитимен интерес съгласно чл. 6 ал. 1 т. f DSGVO. Този интерес се състои в предоставянето на ефективна поддръжка на посетителите и подобряване на потребителското изживяване на нашия уебсайт.",
"dataRecipient": "Получател на данните от чата е OpenAI (OpenAI OpCo, LLC) като технически доставчик на услуги. OpenAI обработва предаденото съдържание на чата на своите сървъри изключително с цел генериране на отговори. OpenAI действа като обработващ по поръчка съгласно чл. 28 DSGVO и не използва данните за свои цели. Имаме сключен договор за обработка на данни с OpenAI, който включва стандартните договорни клаузи на ЕС като подходящи гаранции за защита на данните. OpenAI има седалище в САЩ; чрез съгласието със стандартните договорни клаузи се гарантира, че при прехвърлянето на вашите данни се осигурява ниво на защита на данните, еквивалентно на това в Европейския съюз.",
"dataRetention": "Съхраняваме вашите чат запитвания само толкова дълго, колкото е необходимо за обработка и отговор. След като вашето запитване бъде приключено, чат историята се изтрива своевременно или се анонимизира. Според OpenAI обработените чат данни се съхраняват само временно и се изтриват автоматично най-късно след 30 дни.",
"voluntaryUse": "Използването на чатбота е доброволно. Ако не използвате чатбота, няма да се предават данни на OpenAI. Моля, не въвеждайте чувствителни лични данни в чата."
},
"cookies": {
"title": "Бисквитки",
"intro": "Нашият уебсайт използва бисквитки в следните случаи:",
"payment": "1. Процес на плащане: При плащания с кредитна карта или незабавни преводи (напр. Klarna Sofort) се използват технически необходими бисквитки. Те съдържат характерен низ, който позволява уникална идентификация на браузъра. Бисквитките се задават от платежния доставчик Stripe и са абсолютно необходими за сигурното и безпроблемно обработване на плащанията. Без тези бисквитки не е възможно да се направи поръчка с тези методи на плащане. Обработката се основава на чл. 6 (1) т. b DSGVO за изпълнение на договор.",
"googleSSO": "2. Google Single Sign-On (SSO): При използване на вход с Google се задават бисквитки от Google, които са необходими за процеса на влизане и удостоверяване. Тези бисквитки ви позволяват удобно да влизате с вашия Google акаунт, без да се налага да въвеждате отново данните си всеки път. Обработката се основава на чл. 6 (1) т. b DSGVO (изпълнение на договор) и чл. 6 (1) т. f DSGVO (легитимен интерес за удобен вход).",
"otherPayments": "За други методи на плащане директен дебит, вземане на място или наложен платеж не се използват допълнителни бисквитки, освен ако не използвате вход с Google."
},
"mollie": {
"title": "Mollie (Обработка на плащания)",
"content": "Използваме платежния доставчик Mollie на нашия уебсайт за обработка на плащания. Доставчик на услугата е Mollie B.V., Keizersgracht 126, 1015 CW Amsterdam, Netherlands. В този контекст се предават лични данни, необходими за обработка на плащанията по-специално вашето име, имейл адрес, адрес за фактуриране, платежна информация (напр. данни за кредитна карта) и IP адрес. Обработката на данни се извършва с цел обработка на плащания; правното основание е чл. 6 ал. 1 т. b DSGVO, тъй като служи за изпълнение на договор с вас.",
"responsibility": "Mollie също така обработва определени данни като самостоятелен администратор, например за изпълнение на законови задължения (като предотвратяване на пране на пари) и за предотвратяване на измами. Освен това имаме сключен договор за обработка на данни с Mollie съгласно чл. 28 DSGVO; по силата на този договор Mollie действа изключително по наши инструкции при обработката на плащания.",
"dataTransfer": "Ако Mollie обработва лични данни извън ЕС, особено в САЩ, това се извършва при спазване на подходящи гаранции. Mollie използва стандартните договорни клаузи на ЕС съгласно чл. 46 DSGVO, за да осигури адекватно ниво на защита на данните. Въпреки това, ние посочваме, че САЩ се считат за трета страна по отношение на защитата на данните с потенциално недостатъчно ниво на защита. Допълнителна информация можете да намерите в политиката за поверителност на Mollie на https://www.mollie.com/de/privacy."
}
}
};

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