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;
}
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 {}
default_type text/html;
}

View File

@@ -6,6 +6,30 @@ import babelParser from '@babel/eslint-parser';
export default [
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}'],
languageOptions: {

10
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@fontsource-variable/outfit": "^5.2.8",
"@mui/icons-material": "^7.1.1",
"@mui/material": "^7.1.1",
"@stripe/react-stripe-js": "^3.7.0",
@@ -2222,6 +2223,15 @@
"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": {
"version": "0.19.1",
"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:skip-english": "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": [],
"author": "",
@@ -29,6 +30,7 @@
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@fontsource-variable/outfit": "^5.2.8",
"@mui/icons-material": "^7.1.1",
"@mui/material": "^7.1.1",
"@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";
console.log(`🔌 Connecting to socket at ${socketUrl}...`);
const socket = io(socketUrl, {
path: "/socket.io/",
transports: ["websocket"],
reconnection: false,
timeout: 10000,
});
const socket = io(socketUrl, config.socketIoClientOptions);
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {

View File

@@ -125,11 +125,14 @@ const {
const {
generateProductMetaTags,
generateProductJsonLd,
generateCategoryMetaTags,
generateCategoryJsonLd,
generateHomepageMetaTags,
generateHomepageJsonLd,
generateSitemapJsonLd,
generateKonfiguratorMetaTags,
generateHerstellerMetaTags,
generateHerstellerJsonLd,
generateXmlSitemap,
generateRobotsTxt,
generateProductsXml,
@@ -141,6 +144,7 @@ const {
const {
fetchCategoryProducts,
fetchProductDetails,
fetchManufacturers,
saveProductImages,
saveCategoryImages,
} = 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 PrerenderSitemap = require("./src/PrerenderSitemap.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 NotFound404 = require("./src/pages/NotFound404.js").default;
// Worker function for parallel product rendering
const renderProductWorker = async (productSeoNames, workerId, progressCallback, categoryMap = {}) => {
const socketUrl = "http://127.0.0.1:9303";
const workerSocket = io(socketUrl, {
path: "/socket.io/",
transports: ["websocket"],
reconnection: false,
timeout: 10000,
});
const workerSocket = io(socketUrl, config.socketIoClientOptions);
return new Promise((resolve) => {
let processedCount = 0;
@@ -380,6 +380,29 @@ const renderApp = async (categoryData, socket) => {
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
const render = (
component,
@@ -387,8 +410,10 @@ const renderApp = async (categoryData, socket) => {
filename,
description,
metaTags = "",
needsRouter = false
needsRouter = false,
manufacturerDataForPage = null
) => {
console.log(" 📦 [render helper] Calling renderPage for", filename, "with manufacturerData:", manufacturerDataForPage ? (manufacturerDataForPage.length + " items") : "null");
return renderPage(
component,
location,
@@ -396,7 +421,10 @@ const renderApp = async (categoryData, socket) => {
description,
metaTags,
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");
fs.copyFileSync(indexPath, 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
@@ -473,6 +506,13 @@ const renderApp = async (categoryData, socket) => {
description: "Categories page",
needsCategoryData: true,
},
{
component: PrerenderHerstellerPage,
path: "/Hersteller",
filename: "Hersteller",
description: "Hersteller page",
needsManufacturerData: true,
},
{ component: AGB, path: "/agb", filename: "agb", description: "AGB page" },
{ component: NotFound404, path: "/404", filename: "404", description: "404 Not Found page" },
{
@@ -491,8 +531,17 @@ const renderApp = async (categoryData, socket) => {
let staticPagesRendered = 0;
for (const page of staticPages) {
// Pass category data as props if needed
const pageProps = page.needsCategoryData ? { categoryData } : null;
// Pass category and manufacturer data as props if needed
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);
let metaTags = "";
@@ -508,13 +557,25 @@ const renderApp = async (categoryData, socket) => {
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(
pageComponent,
page.path,
page.filename,
page.description,
metaTags,
true
true,
pageManufacturerData
);
if (success) {
staticPagesRendered++;
@@ -621,19 +682,25 @@ const renderApp = async (categoryData, socket) => {
const filename = `Kategorie/${category.seoName}`;
const location = `/Kategorie/${category.seoName}`;
const description = `Category "${category.name}" (ID: ${category.id})`;
const categoryMetaTags = generateCategoryMetaTags(
category,
shopConfig.baseUrl,
shopConfig
);
const categoryJsonLd = generateCategoryJsonLd(
category,
productData?.products || [],
shopConfig.baseUrl,
shopConfig
);
const combinedCategoryHead = categoryMetaTags + "\n" + categoryJsonLd;
const success = render(
categoryComponent,
location,
filename,
description,
categoryJsonLd,
combinedCategoryHead,
true
);
if (success) {
@@ -863,12 +930,7 @@ const fetchCategoryDataAndRender = () => {
process.exit(1);
}, 15000);
const socket = io(socketUrl, {
path: "/socket.io/",
transports: ["websocket"],
reconnection: false,
timeout: 10000,
});
const socket = io(socketUrl, config.socketIoClientOptions);
socket.on("connect", () => {
console.log('Socket connected. Emitting "categoryList"...');

View File

@@ -69,11 +69,21 @@ const globalCssCollection = new Set();
// Get webpack entrypoints
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 = {
isProduction,
outputDir,
getWebpackEntrypoints,
globalCss,
globalCssCollection,
webpackEntrypoints
webpackEntrypoints,
socketIoClientOptions,
};

View File

@@ -42,6 +42,7 @@ const fetchCategoryProducts = (socket, categoryId) => {
"getCategoryProducts",
{
full: true,
nocount: true,
categoryId:
categoryId === "neu" || categoryId === "bald"
? 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) => {
if (!products || products.length === 0) return;
@@ -186,80 +219,98 @@ const saveProductImages = async (socket, products, categoryName, outputDir) => {
.filter((id) => id);
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 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);
// Skip if image already exists
if (fs.existsSync(imagePath)) {
if (fs.existsSync(avifPath) && fs.existsSync(jpegPath)) {
imagesSkipped++;
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 {
const imageBuffer = await fetchProductImage(socket, bildId);
// If overlay exists, apply it to the image
if (false && fs.existsSync(overlayPath)) {
try {
// Get image dimensions to center the overlay
const baseImage = sharp(Buffer.from(imageBuffer));
const baseMetadata = await baseImage.metadata();
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)
.resize({
width: Math.round(overlaySize),
height: Math.round(overlaySize),
fit: 'contain', // Keep full overlay visible
background: { r: 0, g: 0, b: 0, alpha: 0 } // Transparent background instead of black bars
})
.toBuffer();
// Calculate center position for the resized overlay
const centerX = Math.floor((baseMetadata.width - overlaySize) / 2);
const centerY = Math.floor((baseMetadata.height - overlaySize) / 2);
const processedImageBuffer = await baseImage
.composite([
{
input: resizedOverlayBuffer,
top: centerY,
left: centerX,
blend: "multiply", // Darkens the image, visible on all backgrounds
opacity: 0.3,
},
])
.avif() // Ensure output is AVIF
.toBuffer();
fs.writeFileSync(imagePath, processedImageBuffer);
console.log(
` ✅ Applied centered inverted sh.avif overlay to ${estimatedFilename}`
);
} catch (overlayError) {
console.log(
` ⚠️ Failed to apply overlay to ${estimatedFilename}: ${overlayError.message}`
);
// Fallback: save without overlay
fs.writeFileSync(imagePath, Buffer.from(imageBuffer));
}
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 {
// Save without overlay if overlay file doesn't exist
fs.writeFileSync(imagePath, Buffer.from(imageBuffer));
const imageBuffer = await fetchProductImage(socket, bildId);
const buf = Buffer.from(imageBuffer);
// If overlay exists, apply it to the image
if (false && fs.existsSync(overlayPath)) {
try {
const baseImage = sharp(buf);
const baseMetadata = await baseImage.metadata();
const overlaySize =
Math.min(baseMetadata.width, baseMetadata.height) * 0.4;
const resizedOverlayBuffer = await sharp(overlayPath)
.resize({
width: Math.round(overlaySize),
height: Math.round(overlaySize),
fit: "contain",
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.toBuffer();
const centerX = Math.floor(
(baseMetadata.width - overlaySize) / 2
);
const centerY = Math.floor(
(baseMetadata.height - overlaySize) / 2
);
const processedImageBuffer = await baseImage
.composite([
{
input: resizedOverlayBuffer,
top: centerY,
left: centerX,
blend: "multiply",
opacity: 0.3,
},
])
.toBuffer();
await writeAvifAndJpegFromBuffer(processedImageBuffer);
console.log(
` ✅ Applied overlay → ${avifFilename} + ${jpegFilename}`
);
} catch (overlayError) {
console.log(
` ⚠️ Failed to apply overlay to prod${bildId}: ${overlayError.message}`
);
await writeAvifAndJpegFromBuffer(buf);
}
} else {
await writeAvifAndJpegFromBuffer(buf);
}
}
imagesSaved++;
// Small delay to avoid overwhelming server
await new Promise((resolve) => setTimeout(resolve, 50));
} catch (error) {
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,
fetchProductImage,
fetchCategoryImage,
fetchManufacturers,
saveProductImages,
saveCategoryImages,
};

View File

@@ -18,7 +18,8 @@ const renderPage = (
needsRouter = false,
config,
suppressLogs = false,
productData = null
productData = null,
manufacturerData = null
) => {
const {
isProduction,
@@ -171,22 +172,44 @@ const renderPage = (
</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 = '';
if (typeof global !== "undefined" && global.window && global.window.categoryCache) {
// Only include the static categoryTree_209, not any dynamic data that gets added during rendering
const staticCache = {};
if (global.window.categoryCache["209_de"]) {
staticCache["209_de"] = global.window.categoryCache["209_de"];
const hasCategoryCache = typeof global !== "undefined" && global.window && global.window.categoryCache;
const hasManufacturerData = manufacturerData && manufacturerData.length > 0;
console.log(" 📦 [" + filename + "] manufacturerData =", manufacturerData ? (manufacturerData.length + " items") : "null");
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);
productCacheScript = `
<script>
// Populate window.categoryCache with static category tree only
window.categoryCache = ${staticCacheData};
</script>
`;
// Add herstellerImages
if (hasManufacturerData) {
cacheData.herstellerImages = manufacturerData;
}
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

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) => {
// 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];
@@ -7,124 +47,103 @@ const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
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 priceValidDate = new Date();
priceValidDate.setMonth(priceValidDate.getMonth() + 3);
const priceValidUntil = priceValidDate.toISOString().split("T")[0];
const id = {
business: `${root}#business`,
website: `${root}#website`,
breadcrumb: `${categoryUrl}#breadcrumb`,
itemList: `${categoryUrl}#itemlist`,
};
const jsonLd = {
"@context": "https://schema.org/",
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,
...(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",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Home",
item: root,
},
{
"@type": "ListItem",
position: 2,
name: category.name,
item: categoryUrl,
},
],
};
const collectionPageNode = {
"@id": categoryUrl,
"@type": "CollectionPage",
name: category.name,
url: categoryUrl,
description: `${category.name} - Entdecken Sie unsere Auswahl an hochwertigen Produkten`,
breadcrumb: {
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Home",
item: baseUrl,
},
{
"@type": "ListItem",
position: 2,
name: category.name,
item: categoryUrl,
},
],
},
isPartOf: { "@id": id.website },
breadcrumb: { "@id": id.breadcrumb },
};
// Add product list if products are available
if (products && products.length > 0) {
jsonLd.mainEntity = {
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",
numberOfItems: products.length,
itemListElement: products.slice(0, 20).map((product, index) => ({
"@type": "ListItem",
position: index + 1,
item: {
"@type": "Product",
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",
},
},
},
},
},
})),
};
numberOfItems: withUrls.length,
itemListElement: withUrls.map((product, index) => {
const productPageUrl = `${root}/Artikel/${product.seoName}`;
return {
"@type": "ListItem",
position: index + 1,
url: productPageUrl,
item: productPageUrl,
};
}),
});
}
const categoryGraph = {
"@context": "https://schema.org",
"@graph": graph,
};
return `<script type="application/ld+json">${JSON.stringify(
jsonLd
categoryGraph
)}</script>`;
};
module.exports = {
generateCategoryMetaTags,
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 logoUrl = `${canonicalUrl}${config.images.logo}`;
const websiteJsonLd = {
"@context": "https://schema.org/",
const id = {
business: `${canonicalUrl}#business`,
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,
alternateName: config.siteName,
description: config.descriptions.de.long,
url: canonicalUrl,
logo: {
"@type": "ImageObject",
url: logoUrl,
},
image: {
"@type": "ImageObject",
url: logoUrl,
},
telephone: "015208491860",
email: "service@growheads.de",
address: {
"@type": "PostalAddress",
streetAddress: "Trachenberger Strasse 14",
addressLocality: "Dresden",
postalCode: "01129",
addressCountry: "DE",
addressRegion: "Sachsen",
},
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",
name: "Sitemap",
url: `${canonicalUrl}/sitemap`,
description: "Vollständige Sitemap mit allen Kategorien und Seiten",
isPartOf: { "@id": id.website },
};
const websiteNode = {
"@id": id.website,
"@type": "WebSite",
name: config.brandName,
url: canonicalUrl,
description: config.descriptions.de.long,
publisher: {
"@type": "Organization",
name: config.brandName,
url: canonicalUrl,
logo: {
"@type": "ImageObject",
url: logoUrl,
},
},
publisher: { "@id": id.business },
potentialAction: {
"@type": "SearchAction",
target: `${canonicalUrl}/search?q={search_term_string}`,
query: "required name=search_term_string"
query: "required name=search_term_string",
},
mainEntity: {
"@type": "WebPage",
name: "Sitemap",
url: `${canonicalUrl}/sitemap`,
description: "Vollständige Sitemap mit allen Kategorien und Seiten",
},
sameAs: [
// Add your social media URLs here if available
],
mainEntity: { "@id": id.sitemapPage },
sameAs: [],
};
// Organization/LocalBusiness Schema for rich results
const organizationJsonLd = {
"@context": "https://schema.org",
"@type": "LocalBusiness",
"name": config.brandName,
"alternateName": config.siteName,
"description": config.descriptions.de.long,
"url": canonicalUrl,
"logo": logoUrl,
"image": logoUrl,
"telephone": "015208491860",
"email": "service@growheads.de",
"address": {
"@type": "PostalAddress",
"streetAddress": "Trachenberger Strasse 14",
"addressLocality": "Dresden",
"postalCode": "01129",
"addressCountry": "DE",
"addressRegion": "Sachsen"
},
"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"
}
const faqMainEntity = [
{
"@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": "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"
]
};
},
{
"@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.",
},
},
];
// FAQPage Schema for common questions
const faqJsonLd = {
"@context": "https://schema.org",
const faqNode = {
"@id": id.faq,
"@type": "FAQPage",
"mainEntity": [
{
"@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."
}
}
]
url: canonicalUrl,
publisher: { "@id": id.business },
isPartOf: { "@id": id.website },
mainEntity: faqMainEntity,
};
// Generate ItemList for all categories (more appropriate for homepage)
const categoriesListJsonLd = {
"@context": "https://schema.org",
"@type": "ItemList",
"name": "Produktkategorien",
"description": "Alle verfügbaren Produktkategorien in unserem Online-Shop",
"numberOfItems": categories.filter(category => category.seoName).length,
"itemListElement": categories
.filter(category => category.seoName) // Only include categories with seoName
.map((category, index) => ({
const filteredCategories = categories.filter((c) => c.seoName);
const graph = [
organizationNode,
websiteNode,
sitemapWebPageNode,
faqNode,
];
if (filteredCategories.length > 0) {
graph.push({
"@id": id.categoryList,
"@type": "ItemList",
name: "Produktkategorien",
description: "Alle verfügbaren Produktkategorien in unserem Online-Shop",
numberOfItems: filteredCategories.length,
isPartOf: { "@id": id.website },
itemListElement: filteredCategories.map((category, index) => ({
"@type": "ListItem",
"position": index + 1,
"item": {
position: index + 1,
item: {
"@type": "Thing",
"name": category.name,
"url": `${canonicalUrl}/Kategorie/${category.seoName}`
}
}))
name: category.name,
url: `${canonicalUrl}/Kategorie/${category.seoName}`,
},
})),
});
}
const homepageGraph = {
"@context": "https://schema.org",
"@graph": graph,
};
// Return all JSON-LD scripts
const websiteScript = `<script type="application/ld+json">${JSON.stringify(websiteJsonLd)}</script>`;
const organizationScript = `<script type="application/ld+json">${JSON.stringify(organizationJsonLd)}</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 : '');
return `<script type="application/ld+json">${JSON.stringify(
homepageGraph
)}</script>`;
};
module.exports = {

View File

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

View File

@@ -60,32 +60,19 @@ GrowHeads.de is a German online shop and local store in Dresden specializing in
if (totalPages > 1) {
llmsTxt += `
- **Product Catalog**: ${totalPages} pages available
- **Page 1**: ${baseUrl}/llms-${categorySlug}-page-1.txt (Products 1-${Math.min(productsPerPage, productCount)})`;
if (totalPages > 2) {
${totalPages} pages available`;
for (let p = 1; p <= totalPages; p++) {
const start = (p - 1) * productsPerPage + 1;
const end = Math.min(p * productsPerPage, productCount);
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) {
llmsTxt += `
- **Product Catalog**: ${baseUrl}/llms-${categorySlug}-page-1.txt`;
${baseUrl}/llms-${categorySlug}-page-1.txt`;
} else {
llmsTxt += `
- **Product Catalog**: No products available`;
No products available`;
}
llmsTxt += `

View File

@@ -1,13 +1,18 @@
const generateProductMetaTags = (product, baseUrl, config) => {
const productUrl = `${baseUrl}/Artikel/${product.seoName}`;
const imageUrl =
const pictureFirstId =
product.pictureList && product.pictureList.trim()
? `${baseUrl}/assets/images/prod${product.pictureList
.split(",")[0]
.trim()}.avif`
: `${baseUrl}/assets/images/nopicture.jpg`;
? product.pictureList.split(",")[0].trim()
: null;
const imageUrl = pictureFirstId
? `${baseUrl}/assets/images/prod${pictureFirstId}.avif`
: `${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)
const cleanDescription = product.kurzBeschreibung
@@ -32,7 +37,7 @@ const generateProductMetaTags = (product, baseUrl, config) => {
<!-- Open Graph Meta Tags -->
<meta property="og:title" content="${product.name}">
<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:type" content="product">
<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:title" content="${product.name}">
<meta name="twitter:description" content="${cleanDescription}">
<meta name="twitter:image" content="${imageUrl}">
<meta name="twitter:image" content="${twitterImageUrl}">
<!-- Additional Meta Tags -->
<meta name="robots" content="index, follow">
@@ -63,13 +68,15 @@ const generateProductMetaTags = (product, baseUrl, config) => {
};
const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) => {
const productUrl = `${baseUrl}/Artikel/${product.seoName}`;
const imageUrl =
const root = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
const productUrl = `${root}/Artikel/${product.seoName}`;
const pictureFirstId =
product.pictureList && product.pictureList.trim()
? `${baseUrl}/assets/images/prod${product.pictureList
.split(",")[0]
.trim()}.avif`
: `${baseUrl}/assets/images/nopicture.jpg`;
? product.pictureList.split(",")[0].trim()
: null;
const imageUrl = pictureFirstId
? `${root}/assets/images/prod${pictureFirstId}.avif`
: `${root}/assets/images/nopicture.jpg`;
// Clean description for JSON-LD (remove HTML tags)
const cleanDescription = product.description
@@ -80,8 +87,87 @@ const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) =>
const priceValidDate = new Date();
priceValidDate.setMonth(priceValidDate.getMonth() + 3);
const jsonLd = {
"@context": "https://schema.org/",
const id = {
business: `${root}#business`,
website: `${root}#website`,
product: `${productUrl}#product`,
breadcrumb: `${productUrl}#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,
...(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",
url: productUrl,
priceCurrency: config.currency,
price: product.price.toString(),
priceValidUntil: priceValidDate.toISOString().split("T")[0],
itemCondition: "https://schema.org/NewCondition",
availability: product.available
? "https://schema.org/InStock"
: "https://schema.org/OutOfStock",
seller: { "@id": id.business },
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.9,
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 productNode = {
"@id": id.product,
"@type": "Product",
name: product.name,
image: [imageUrl],
@@ -92,87 +178,64 @@ const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) =>
"@type": "Brand",
name: product.manufacturer || "Unknown",
},
offers: {
"@type": "Offer",
url: productUrl,
priceCurrency: config.currency,
price: product.price.toString(),
priceValidUntil: priceValidDate.toISOString().split("T")[0],
itemCondition: "https://schema.org/NewCondition",
availability: product.available
? "https://schema.org/InStock"
: "https://schema.org/OutOfStock",
seller: {
"@type": "Organization",
name: config.brandName,
},
hasMerchantReturnPolicy: {
"@type": "MerchantReturnPolicy",
applicableCountry: "DE",
returnPolicyCategory: "https://schema.org/MerchantReturnFiniteReturnWindow",
merchantReturnDays: 14,
returnMethod: "https://schema.org/ReturnByMail",
returnFees: "https://schema.org/FreeReturn",
},
shippingDetails: {
"@type": "OfferShippingDetails",
shippingRate: {
"@type": "MonetaryAmount",
value: 5.90,
currency: "EUR",
},
shippingDestination: {
"@type": "DefinedRegion",
addressCountry: "DE",
},
deliveryTime: {
"@type": "ShippingDeliveryTime",
handlingTime: {
"@type": "QuantitativeValue",
minValue: 0,
maxValue: 1,
unitCode: "DAY",
},
transitTime: {
"@type": "QuantitativeValue",
minValue: 2,
maxValue: 3,
unitCode: "DAY",
},
},
},
},
offers: offer,
};
// Add breadcrumb if category information is available
if (categoryInfo && categoryInfo.name && categoryInfo.seoName) {
jsonLd.breadcrumb = {
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Home",
item: baseUrl,
},
{
"@type": "ListItem",
position: 2,
name: categoryInfo.name,
item: `${baseUrl}/Kategorie/${categoryInfo.seoName}`,
},
{
"@type": "ListItem",
position: 3,
name: product.name,
item: productUrl,
},
],
};
}
const hasBreadcrumb =
categoryInfo && categoryInfo.name && categoryInfo.seoName;
const breadcrumbList = hasBreadcrumb
? {
"@id": id.breadcrumb,
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Home",
item: root,
},
{
"@type": "ListItem",
position: 2,
name: categoryInfo.name,
item: `${root}/Kategorie/${categoryInfo.seoName}`,
},
{
"@type": "ListItem",
position: 3,
name: product.name,
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(
jsonLd
productGraph
)}</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 Footer from "./components/Footer.js";
import MainPageLayout from "./components/MainPageLayout.js";
import IdleMainPagesSlideshow from "./components/IdleMainPagesSlideshow.js";
import Content from "./components/Content.js";
import ProductDetail from "./components/ProductDetail.js";
@@ -40,6 +41,7 @@ import ProductDetail from "./components/ProductDetail.js";
// Lazy load rarely-accessed pages
const ProfilePage = lazy(() => import(/* webpackChunkName: "profile" */ "./pages/ProfilePage.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
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 Sitemap = lazy(() => import(/* webpackChunkName: "sitemap" */ "./pages/Sitemap.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 Batteriegesetzhinweise = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Batteriegesetzhinweise.js"));
const Widerrufsrecht = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Widerrufsrecht.js"));
@@ -253,6 +256,7 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
)
}>
<CarouselProvider>
<IdleMainPagesSlideshow />
<Routes>
{/* Main pages using unified component */}
<Route path="/" element={<MainPageLayout />} />
@@ -264,6 +268,11 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
path="/Kategorie/:categoryId"
element={<Content />}
/>
{/* Manufacturer page - Render Content in parallel */}
<Route
path="/Hersteller/:categoryId"
element={<Content />}
/>
{/* Single product page */}
<Route
path="/Artikel/:seoName"
@@ -275,6 +284,9 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
{/* Profile page */}
<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 */}
<Route path="/payment/success" element={<PaymentSuccess />} />
@@ -299,6 +311,7 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
<Route path="/agb" element={<AGB />} />
<Route path="/sitemap" element={<Sitemap />} />
<Route path="/Kategorien" element={<CategoriesPage />} />
<Route path="/Hersteller" element={<HerstellerPage />} />
<Route path="/impressum" element={<Impressum />} />
<Route
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: {
mb: 2,
position: ["-webkit-sticky", "sticky"],
// No CategoryList in prerender — two-row toolbar only; safe-area for notched phones.
top: {
xs: "80px",
xs: "calc(env(safe-area-inset-top, 0px) + 128px)",
sm: "80px",
md: "80px",
lg: "80px",
},
left: 0,
width: "100%",
display: "flex",
zIndex: 999, // Just below the AppBar
zIndex: (theme) => theme.zIndex.appBar - 1,
py: 0,
px: 2,
}
@@ -552,7 +551,7 @@ class PrerenderProduct extends React.Component {
})
},
style: {
fontFamily: '"Roboto","Helvetica","Arial",sans-serif',
fontFamily: '"Outfit Variable","Roboto","Helvetica","Arial",sans-serif',
fontSize: '1rem',
lineHeight: '1.7',
color: '#333'

View File

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

View File

@@ -15,7 +15,13 @@ import StopIcon from '@mui/icons-material/Stop';
import PhotoCameraIcon from '@mui/icons-material/PhotoCamera';
import parse, { domToReact } from 'html-react-parser';
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 { withTranslation } from '../i18n/withTranslation.js';
const TELEGRAM_ASSISTANT_URL = 'https://t.me/Growheads_de_Bot';
// Initialize window object for storing messages
if (!window.chatMessages) {
window.chatMessages = [];
@@ -47,23 +53,62 @@ class ChatAssistant extends Component {
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() {
// Add socket listeners if socket is available and connected
this.addSocketListeners();
this.props.i18n?.on('languageChanged', this.handleI18nLanguageChanged);
const userStatus = isUserLoggedIn();
const isGuest = !userStatus.isLoggedIn;
if (isGuest && !this.state.privacyConfirmed) {
this.setState(prevState => {
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 = {
id: 'privacy-prompt',
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];
window.chatMessages = updatedMessages;
@@ -78,12 +123,16 @@ class ChatAssistant extends Component {
}
componentDidUpdate(prevProps, prevState) {
if (prevProps.i18n?.language !== this.props.i18n?.language) {
this.applyPrivacyPromptTranslation();
}
if (prevState.messages !== this.state.messages || prevState.isTyping !== this.state.isTyping) {
this.scrollToBottom();
}
}
componentWillUnmount() {
this.props.i18n?.off('languageChanged', this.handleI18nLanguageChanged);
this.removeSocketListeners();
this.stopRecording();
if (this.recordingTimer) {
@@ -182,7 +231,7 @@ class ChatAssistant extends Component {
}, () => {
// Emit message to socket server after state is updated
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) {
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 = {
id: Date.now(),
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
};
@@ -445,14 +494,15 @@ class ChatAssistant extends Component {
}
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() {
const { open, onClose } = this.props;
const { open, onClose, t } = this.props;
const { messages, inputValue, isTyping, isRecording, recordingTime, isGuest, privacyConfirmed } = this.state;
const showTelegramHint = !messages.some((m) => m.sender === 'user');
if (!open) {
return null;
@@ -498,12 +548,12 @@ class ChatAssistant extends Component {
}}
>
<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.atDatabase ? "error" : "text.disabled"} sx={{ display: 'inline' }}>🛢</Typography>
<Typography component="span" color={this.state.atWeb ? "error" : "text.disabled"} sx={{ display: 'inline' }}>🌐</Typography>
</Typography>
<IconButton onClick={onClose} size="small" aria-label="Assistent schließen" sx={{ color: 'primary.contrastText' }}>
<IconButton onClick={onClose} size="small" aria-label={t('chat.closeAria')} sx={{ color: 'primary.contrastText' }}>
<CloseIcon />
</IconButton>
</Box>
@@ -517,6 +567,58 @@ class ChatAssistant extends Component {
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) => (
<Box
key={message.id}
@@ -589,7 +691,7 @@ class ChatAssistant extends Component {
autoFocus
autoCapitalize="off"
autoCorrect="off"
placeholder={isRecording ? "Aufnahme läuft..." : "Du kannst mich nach Cannabissorten fragen..."}
placeholder={isRecording ? t('chat.placeholderRecording') : t('chat.inputPlaceholder')}
value={inputValue}
onChange={this.handleInputChange}
onKeyDown={this.handleKeyDown}
@@ -611,7 +713,7 @@ class ChatAssistant extends Component {
<IconButton
color="error"
onClick={this.stopRecording}
aria-label="Aufnahme stoppen"
aria-label={t('chat.micStopAria')}
sx={{ ml: { xs: 0, sm: 1 } }}
>
<StopIcon />
@@ -620,7 +722,7 @@ class ChatAssistant extends Component {
<IconButton
color="primary"
onClick={this.startRecording}
aria-label="Sprachaufnahme starten"
aria-label={t('chat.micStartAria')}
sx={{ ml: { xs: 0, sm: 1 } }}
disabled={isTyping || inputsDisabled}
>
@@ -631,7 +733,7 @@ class ChatAssistant extends Component {
<IconButton
color="primary"
onClick={this.handleImageUpload}
aria-label="Bild hochladen"
aria-label={t('chat.uploadImageAria')}
sx={{ ml: { xs: 0, sm: 1 } }}
disabled={isTyping || isRecording || inputsDisabled}
>
@@ -644,7 +746,7 @@ class ChatAssistant extends Component {
onClick={this.handleSendMessage}
disabled={isTyping || isRecording || inputsDisabled}
>
Senden
{t('chat.send')}
</Button>
</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 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 { withI18n } from '../i18n/withTranslation.js';
import { withCategory } from '../context/CategoryContext.js';
@@ -24,17 +24,19 @@ const withRouter = (ClassComponent) => {
return (props) => {
const params = useParams();
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) {
window.productCache = {};
}
try {
const cacheKey = `categoryProducts_${categoryId}_${language}`;
const cacheKey = `${isHersteller ? 'manufacturer' : 'category'}Products_${categoryId}_${language}`;
const cachedData = window.productCache[cacheKey];
if (cachedData) {
@@ -166,7 +168,7 @@ function getFilteredProducts(unfilteredProducts, attributes, t) {
return { filteredProducts, activeAttributeFilters: activeAttributeFiltersWithNames, activeManufacturerFilters: activeManufacturerFiltersWithNames, activeAvailabilityFilters };
}
function setCachedCategoryData(categoryId, data, language = 'de') {
function setCachedCategoryData(categoryId, data, language = 'de', isHersteller = false) {
if (!window.productCache) {
window.productCache = {};
}
@@ -175,7 +177,7 @@ function setCachedCategoryData(categoryId, data, language = 'de') {
}
try {
const cacheKey = `categoryProducts_${categoryId}_${language}`;
const cacheKey = `${isHersteller ? 'manufacturer' : 'category'}Products_${categoryId}_${language}`;
if (data.products) for (const product of data.products) {
const productCacheKey = `product_${product.id}_${language}`;
window.productDetailCache[productCacheKey] = product;
@@ -221,9 +223,10 @@ class Content extends Component {
componentDidUpdate(prevProps) {
const currentLanguage = this.props.i18n?.language || 'de';
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'));
if (categoryChanged) {
if (categoryChanged || routeTypeChanged) {
// Clear context for new category loading
if (this.props.categoryContext && this.props.categoryContext.setCurrentCategory) {
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.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) {
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');
}
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) {
this.processDataWithCategoryTree(cachedData, categoryId);
return;
@@ -360,7 +364,7 @@ class Content extends Component {
window.socketManager.on(`productList:${categoryId}`, (response) => {
console.log("getCategoryProducts full response", response);
receivedFullResponse = true;
setCachedCategoryData(categoryId, response, currentLanguage);
setCachedCategoryData(categoryId, response, currentLanguage, isHersteller);
if (response && response.products !== undefined) {
this.processDataWithCategoryTree(response, categoryId);
} else {
@@ -370,12 +374,17 @@ class Content extends Component {
window.socketManager.emit(
"getCategoryProducts",
{ categoryId: categoryId, language: currentLanguage, requestTranslation: currentLanguage === 'de' ? false : true },
{
categoryId: categoryId,
language: currentLanguage,
requestTranslation: currentLanguage === 'de' ? false : true,
isHersteller,
},
(response) => {
console.log("getCategoryProducts stub response", response);
// Only process stub response if we haven't received the full response yet
if (!receivedFullResponse) {
setCachedCategoryData(categoryId, response, currentLanguage);
setCachedCategoryData(categoryId, response, currentLanguage, isHersteller);
if (response && response.products !== undefined) {
this.processDataWithCategoryTree(response, categoryId);
} 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);
}
@@ -701,7 +733,7 @@ class Content extends Component {
minHeight: { xs: 'min-content', sm: '100%' }
}}>
<Box >
<Box sx={{ overflow: 'visible', minWidth: 0 }}>
<ProductFilters
products={this.state.unfilteredProducts}

View File

@@ -1,6 +1,7 @@
import React, { Component } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Chip from '@mui/material/Chip';
import Checkbox from '@mui/material/Checkbox';
import IconButton from '@mui/material/IconButton';
import Collapse from '@mui/material/Collapse';
@@ -138,21 +139,25 @@ class Filter extends Component {
handleOptionChange = (event) => {
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 => ({
options: {
this.setState((prevState) => {
const nextOptions = {
...prevState.options,
[name]: checked
}
}));
[name]: checked,
};
return {
options: nextOptions,
...(narrow && checked ? { isCollapsed: true } : {}),
};
});
// Then notify the parent component
if (this.props.onFilterChange) {
this.props.onFilterChange({
type: this.props.filterType || 'default',
name: name,
value: checked
type: this.props.filterType || "default",
name,
value: checked,
});
}
};
@@ -181,6 +186,13 @@ class Filter extends Component {
}));
};
clearFilterOption = (optionId) => (event) => {
event.stopPropagation();
this.handleOptionChange({
target: { name: optionId, checked: false },
});
};
render() {
const { options, counts, isCollapsed } = this.state;
const { title, options: optionsList = [] } = this.props;
@@ -267,12 +279,80 @@ class Filter extends Component {
)}
</Typography>
{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 />}
</IconButton>
)}
</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}>
<Box sx={{ width: '100%' }}>
<table style={tableStyle}>

View File

@@ -16,6 +16,7 @@ const StyledRouterLink = styled(RouterLink)(() => ({
lineHeight: '1.5',
display: 'block',
padding: '4px 8px',
whiteSpace: 'nowrap',
'&:hover': {
textDecoration: 'underline',
},
@@ -223,25 +224,13 @@ class Footer extends Component {
alignItems={{ xs: 'center', md: 'flex-end' }}
>
{/* Legal Links Section */}
<Stack
direction={{ xs: 'row', md: 'column' }}
spacing={{ xs: 2, md: 0.5 }}
justifyContent="center"
alignItems={{ xs: 'center', md: 'left' }}
flexWrap="wrap"
>
<Stack direction="column" spacing={0.5} justifyContent="center" alignItems="flex-start">
<StyledRouterLink to="/datenschutz">{this.props.t ? this.props.t('footer.legal.datenschutz') : 'Datenschutz'}</StyledRouterLink>
<StyledRouterLink to="/agb">{this.props.t ? this.props.t('footer.legal.agb') : 'AGB'}</StyledRouterLink>
<StyledRouterLink to="/sitemap">{this.props.t ? this.props.t('footer.legal.sitemap') : 'Sitemap'}</StyledRouterLink>
</Stack>
<Stack
direction={{ xs: 'row', md: 'column' }}
spacing={{ xs: 2, md: 0.5 }}
justifyContent="center"
alignItems={{ xs: 'center', md: 'left' }}
flexWrap="wrap"
>
<Stack direction="column" spacing={0.5} justifyContent="center" alignItems="flex-start">
<StyledRouterLink to="/impressum">{this.props.t ? this.props.t('footer.legal.impressum') : 'Impressum'}</StyledRouterLink>
<StyledRouterLink to="/batteriegesetzhinweise">{this.props.t ? this.props.t('footer.legal.batteriegesetzhinweise') : 'Batteriegesetzhinweise'}</StyledRouterLink>
<StyledRouterLink to="/widerrufsrecht">{this.props.t ? this.props.t('footer.legal.widerrufsrecht') : 'Widerrufsrecht'}</StyledRouterLink>
@@ -346,7 +335,7 @@ class Footer extends Component {
{/* Copyright Section */}
<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'}
</Typography>
<Typography

View File

@@ -91,7 +91,9 @@ class Header extends Component {
</Box>
</Container>
</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>
);
}
@@ -107,9 +109,15 @@ const HeaderWithContext = (props) => {
const isArtikel = location.pathname.startsWith('/Artikel/');
return (
<Header {...props} isHomePage={isHomePage} isArtikel={isArtikel} isProfilePage={isProfilePage} isAktionenPage={isAktionenPage} isFilialePage={isFilialePage} />
<Header
{...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
});
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 = () => {
window.dispatchEvent(new CustomEvent('userLoggedIn'));
navigate(redirectTo);
@@ -415,7 +423,14 @@ export class LoginComponent extends Component {
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 = () => {
window.dispatchEvent(new CustomEvent('userLoggedIn'));
navigate(redirectTo);

View File

@@ -10,8 +10,196 @@ import ChevronRight from "@mui/icons-material/ChevronRight";
import { Link } from "react-router-dom";
import SharedCarousel from "./SharedCarousel.js";
import { getCombinedAnimatedBorderStyles } from "../utils/animatedBorderStyles.js";
import { STAR_POLYGON_POINTS } from "../utils/starPolygon.js";
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 }) => (
<Grid key={`${pageType}-${index}`} item xs={12} sm={6} sx={{ p: 2, width: "50%", position: 'relative' }}>
{index === 0 && pageType === "home" && (
@@ -25,28 +213,19 @@ const ContentBox = ({ box, index, pageType, starHovered, setStarHovered, opacity
zIndex: 999,
pointerEvents: 'none',
'& *': { pointerEvents: 'none' },
filter: 'drop-shadow(6px 6px 12px rgba(0, 0, 0, 0.6)) drop-shadow(0 0 20px rgba(255, 215, 0, 0.8)) drop-shadow(0 0 40px rgba(255, 215, 0, 0.4))',
display: { xs: 'none', sm: 'block' }
}}
>
<svg viewBox="0 0 60 60" width="168" height="168" className="star-rotate-slow-cw" style={{ position: 'absolute', top: '-9px', left: '-9px', transform: 'rotate(20deg)' }}>
<polygon points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20" fill="#B8860B" />
</svg>
<svg viewBox="0 0 60 60" width="159" height="159" className="star-rotate-slow-ccw" style={{ position: 'absolute', top: '-4.5px', left: '-4.5px', transform: 'rotate(-25deg)' }}>
<polygon points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20" fill="#DAA520" />
</svg>
<svg viewBox="0 0 60 60" width="150" height="150" className="star-rotate-medium-cw">
<polygon points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20" fill="#FFD700" />
</svg>
<div style={{ position: 'absolute', top: '45%', left: '43%', transform: 'translate(-50%, -50%) rotate(-10deg)', color: 'white', fontWeight: '900', fontSize: '20px', textShadow: '0px 3px 6px rgba(0,0,0,0.5)', zIndex: 1000, textAlign: 'center', lineHeight: '1.1', width: '135px', transition: 'opacity 0.3s ease', opacity: starHovered ? 0 : 1 }}>
<StarDecorationLayers layers={HOME_STAR_LAYERS} variant="home" />
<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 }}>
{translatedContent.outdoorSeason}
</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}
</div>
</Box>
)}
{index === 1 && pageType === "filiale" && (
{index === 1 && pageType === "home" && (
<Box
sx={{
position: 'absolute',
@@ -57,21 +236,12 @@ const ContentBox = ({ box, index, pageType, starHovered, setStarHovered, opacity
zIndex: 999,
pointerEvents: 'none',
'& *': { pointerEvents: 'none' },
filter: 'drop-shadow(6px 6px 12px rgba(0, 0, 0, 0.6)) drop-shadow(0 0 20px rgba(175, 238, 238, 0.8)) drop-shadow(0 0 40px rgba(175, 238, 238, 0.4))',
display: { xs: 'none', sm: 'block' }
}}
>
<svg viewBox="0 0 60 60" width="168" height="168" className="star-rotate-slow-ccw" style={{ position: 'absolute', top: '-9px', left: '-9px', transform: 'rotate(20deg)' }}>
<polygon points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20" fill="#5F9EA0" />
</svg>
<svg viewBox="0 0 60 60" width="159" height="159" className="star-rotate-medium-cw" style={{ position: 'absolute', top: '-4.5px', left: '-4.5px', transform: 'rotate(-25deg)' }}>
<polygon points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20" fill="#7FCDCD" />
</svg>
<svg viewBox="0 0 60 60" width="150" height="150" className="star-rotate-slow-cw">
<polygon points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20" fill="#AFEEEE" />
</svg>
<div style={{ position: 'absolute', top: '42%', left: '45%', transform: 'translate(-50%, -50%) rotate(10deg)', color: 'white', fontWeight: '900', fontSize: '20px', textShadow: '0px 3px 6px rgba(0,0,0,0.5)', zIndex: 1000, textAlign: 'center', lineHeight: '1.1', width: '135px' }}>
{translatedContent.showUsPhoto}
<StarDecorationLayers layers={TEAL_STAR_LAYERS} variant="filiale" />
<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' }}>
{translatedContent.buildYourSet}
</div>
</Box>
)}
@@ -89,11 +259,19 @@ const ContentBox = ({ box, index, pageType, starHovered, setStarHovered, opacity
display: "flex",
flexDirection: "column",
boxShadow: 10,
transition: "all 0.3s ease",
"&:hover": { transform: "translateY(-5px)", boxShadow: 20 },
transition: "box-shadow 0.3s ease",
"&:hover": { boxShadow: 20 },
}}
onMouseEnter={index === 0 && pageType === "filiale" ? () => setStarHovered(true) : undefined}
onMouseLeave={index === 0 && pageType === "filiale" ? () => setStarHovered(false) : undefined}
onMouseEnter={
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" }}>
{opacity === 1 && (
@@ -113,8 +291,22 @@ const MainPageLayout = () => {
const currentPath = location.pathname;
const { t } = useTranslation();
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 = {
showUsPhoto: t('sections.showUsPhoto'),
buildYourSet: isKiosk ? 'Schau in den Stecklingskatalog' : t('sections.buildYourSet'),
selectSeedRate: t('sections.selectSeedRate'),
outdoorSeason: t('sections.outdoorSeason')
};
@@ -123,31 +315,6 @@ const MainPageLayout = () => {
const isAktionen = currentPath === "/aktionen";
const isFiliale = currentPath === "/filiale";
React.useEffect(() => {
const style = document.createElement('style');
style.textContent = `
@keyframes rotateClockwise {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes rotateCounterClockwise {
from { transform: rotate(0deg); }
to { transform: rotate(-360deg); }
}
.star-rotate-slow-cw {
animation: rotateClockwise 60s linear infinite;
}
.star-rotate-slow-ccw {
animation: rotateCounterClockwise 45s linear infinite;
}
.star-rotate-medium-cw {
animation: rotateClockwise 30s linear infinite;
}
`;
document.head.appendChild(style);
return () => document.head.removeChild(style);
}, []);
const getNavigationConfig = () => {
if (isHome) return { leftNav: { text: t('navigation.aktionen'), link: "/aktionen" }, rightNav: { text: t('navigation.filiale'), link: "/filiale" } };
if (isAktionen) return { leftNav: { text: t('navigation.filiale'), link: "/filiale" }, rightNav: { text: t('navigation.home'), link: "/" } };
@@ -164,11 +331,11 @@ const MainPageLayout = () => {
const allContentBoxes = {
home: [
{ 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: [
{ title: t('sections.oilPress'), image: "/assets/images/presse.jpg", bgcolor: "#e1f0d3", link: "/presseverleih" },
{ title: t('sections.thcTest'), image: "/assets/images/purpl.jpg", bgcolor: "#e8f5d6", link: "/thc-test" }
{ title: t('sections.oilPress'), image: "/assets/images/presse.jpg", bgcolor: "#e1f0d3", link: "/Artikel/Graveda-10t-presse-tagesmiete-inkl-prepress-vorpressform" },
{ title: t('sections.thcTest'), image: "/assets/images/purpl.jpg", bgcolor: "#e8f5d6", link: "/Artikel/1x-messung-purplpro-thc-cbd-restfeuchte-wasseraktivitaet" }
],
filiale: [
{ title: t('sections.address1'), image: "/assets/images/filiale1.jpg", bgcolor: "#e1f0d3", link: "/filiale" },
@@ -193,6 +360,7 @@ const MainPageLayout = () => {
return (
<Container maxWidth="lg" sx={{ py: 2 }}>
<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: { xs: "block", sm: "none" }, mb: { xs: 2, sm: 0 }, width: "100%", textAlign: "center", position: "relative" }}>
{Object.entries(allTitles).map(([pageType, title]) => (

View File

@@ -1,17 +1,25 @@
import React from 'react';
import { Link } from 'react-router-dom';
import Box from "@mui/material/Box";
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 { withLanguage } from '../i18n/withTranslation.js';
const ITEM_WIDTH = 140 + 16; // 140px width + 16px gap
const AUTO_SCROLL_SPEED = 1.0;
const AUTOSCROLL_RESTART_DELAY = 5000;
class ManufacturerCarousel extends React.Component {
_isMounted = false;
originalItems = [];
animationFrame = null;
autoScrollActive = true;
translateX = 0;
inactivityTimer = null;
constructor(props) {
super(props);
@@ -28,10 +36,8 @@ class ManufacturerCarousel extends React.Component {
componentWillUnmount() {
this._isMounted = false;
if (this.animationFrame) {
cancelAnimationFrame(this.animationFrame);
this.animationFrame = null;
}
this.stopAutoScroll();
this.clearInactivityTimer();
// Revoke object URLs to avoid memory leaks
for (const item of this.originalItems) {
if (item.src) URL.revokeObjectURL(item.src);
@@ -46,7 +52,12 @@ class ManufacturerCarousel extends React.Component {
.filter(m => m.imageBuffer)
.map(m => {
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);
@@ -60,13 +71,38 @@ class ManufacturerCarousel extends React.Component {
};
startAutoScroll = () => {
this.autoScrollActive = true;
if (!this.animationFrame) {
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 = () => {
if (!this._isMounted || this.originalItems.length === 0) return;
if (!this._isMounted || !this.autoScrollActive || this.originalItems.length === 0) return;
this.translateX -= AUTO_SCROLL_SPEED;
@@ -82,6 +118,41 @@ class ManufacturerCarousel extends React.Component {
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() {
const { t } = this.props;
const { items } = this.state;
@@ -90,19 +161,36 @@ class ManufacturerCarousel extends React.Component {
return (
<Box sx={{ mt: 4, mb: 4 }}>
<Typography
variant="h4"
component="div"
<Box
component={Link}
to="/Hersteller"
sx={{
fontFamily: 'SwashingtonCP',
textShadow: '3px 3px 10px rgba(0, 0, 0, 0.4)',
textAlign: 'center',
mb: 2,
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',
},
}}
>
{t('product.manufacturer')}
</Typography>
<Typography
variant="h4"
component="span"
sx={{
fontFamily: 'SwashingtonCP',
textShadow: '3px 3px 10px rgba(0, 0, 0, 0.4)',
textAlign: 'center',
}}
>
{t('product.manufacturer')}
</Typography>
<ChevronRight sx={{ fontSize: '2.5rem', ml: 1 }} />
</Box>
<div
style={{
@@ -129,6 +217,46 @@ class ManufacturerCarousel extends React.Component {
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
style={{
position: 'relative',
@@ -151,8 +279,11 @@ class ManufacturerCarousel extends React.Component {
}}
>
{items.map((item, index) => (
<div
<Paper
key={`${item.id}-${index}`}
component={Link}
to={`/Hersteller/${encodeURIComponent(item.slug || '')}`}
elevation={3}
style={{
flex: '0 0 140px',
width: '140px',
@@ -162,7 +293,20 @@ class ManufacturerCarousel extends React.Component {
justifyContent: 'center',
overflow: 'hidden',
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
@@ -176,7 +320,7 @@ class ManufacturerCarousel extends React.Component {
display: 'block',
}}
/>
</div>
</Paper>
))}
</div>
</div>

View File

@@ -10,6 +10,12 @@ import AddToCartButton from './AddToCartButton.js';
import { Link, useNavigate } from 'react-router-dom';
import { withI18n } from '../i18n/withTranslation.js';
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
const findLevel1CategoryId = (categoryId) => {
@@ -275,7 +281,16 @@ class Product extends Component {
<Box sx={{
position: 'relative',
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 && (
<div
@@ -302,7 +317,7 @@ class Product extends Component {
}}
>
<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"
stroke="none"
/>
@@ -321,7 +336,7 @@ class Product extends Component {
}}
>
<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"
stroke="none"
/>
@@ -334,7 +349,7 @@ class Product extends Component {
height="50"
>
<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"
stroke="none"
/>
@@ -344,7 +359,7 @@ class Product extends Component {
<div
style={{
position: 'absolute',
top: '45%',
top: '40%',
left: '45%',
transform: 'translate(-50%, -50%) rotate(-10deg)',
color: 'white',
@@ -361,22 +376,36 @@ class Product extends Component {
<Card
sx={{
width: { xs: '100vw', sm: '250px' },
minWidth: { xs: '100vw', sm: '250px' },
width: {
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%',
display: 'flex',
flexDirection: 'column',
transition: 'transform 0.3s ease, box-shadow 0.3s ease',
position: 'relative',
overflow: 'hidden',
borderRadius: { xs: 0, sm: '8px' },
border: { xs: 'none', sm: 'inherit' },
boxShadow: { xs: 'none', sm: 'inherit' },
mx: { xs: 0, sm: 'auto' },
borderRadius: { xs: '8px', sm: '8px' },
border: { xs: '1px solid', sm: 'inherit' },
borderColor: { xs: 'divider', sm: 'inherit' },
boxShadow: { xs: '0 1px 4px rgba(0,0,0,0.08)', sm: 'inherit' },
mx: { xs: 'auto', sm: 'auto' },
'&:hover': {
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 && (
@@ -459,7 +488,7 @@ class Product extends Component {
<CardMedia
key={index}
component="img"
height={window.innerWidth < 600 ? "240" : "180"}
height={window.innerWidth < PRODUCT_CARD_MOBILE_MAX_WIDTH_PX ? "240" : "180"}
image={imgSrc}
alt={name}
fetchPriority={this.props.priority === 'high' && index === 0 ? 'high' : 'auto'}
@@ -488,7 +517,7 @@ class Product extends Component {
) : (
<CardMedia
component="img"
height={window.innerWidth < 600 ? "240" : "180"}
height={window.innerWidth < PRODUCT_CARD_MOBILE_MAX_WIDTH_PX ? "240" : "180"}
image="/assets/images/nopicture.jpg"
alt={name}
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 { withTranslation } from 'react-i18next';
import { withLanguage } from '../i18n/withTranslation.js';
const ITEM_WIDTH = 250 + 16; // 250px width + 16px gap
import {
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 AUTOSCROLL_RESTART_DELAY = 5000; // 5 seconds of inactivity before restarting autoscroll
const SCROLLBAR_FLASH_DURATION = 3000; // 3 seconds to show the virtual scrollbar
@@ -81,13 +84,31 @@ class ProductCarousel extends React.Component {
products: [],
currentLanguage: (i18n && i18n.language) || 'de',
showScrollbar: false,
itemStride:
typeof window !== "undefined"
? getProductCarouselItemStridePx()
: PRODUCT_CARD_WIDTH_SM_PX + 16,
};
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() {
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;
logCarousel("mount", {
@@ -370,6 +391,9 @@ class ProductCarousel extends React.Component {
componentWillUnmount() {
this._isMounted = false;
if (typeof window !== "undefined") {
window.removeEventListener("resize", this.handleCarouselResize);
}
this.stopAutoScroll();
this.clearInactivityTimer();
this.clearScrollbarTimer();
@@ -430,8 +454,9 @@ class ProductCarousel extends React.Component {
this.translateX -= AUTO_SCROLL_SPEED;
this.updateTrackTransform();
const { itemStride } = this.state;
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
if (Math.abs(this.translateX) >= maxScroll) {
@@ -467,14 +492,15 @@ class ProductCarousel extends React.Component {
if (this.originalProducts.length === 0) return;
// direction: 1 = left (scroll content right), -1 = right (scroll content left)
const { itemStride } = this.state;
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)
if (this.translateX > 0) {
this.translateX = -(maxScroll - ITEM_WIDTH);
this.translateX = -(maxScroll - itemStride);
}
// Handle wrap-around when scrolling right (negative translateX beyond limit)
else if (Math.abs(this.translateX) >= maxScroll) {
@@ -494,9 +520,13 @@ class ProductCarousel extends React.Component {
return null;
}
const { itemStride } = this.state;
const originalItemCount = this.originalProducts.length;
const viewportWidth = 1080; // carousel container max-width
const itemsInView = Math.floor(viewportWidth / ITEM_WIDTH);
const viewportWidth =
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)
let currentItemIndex;
@@ -504,11 +534,11 @@ class ProductCarousel extends React.Component {
if (this.translateX === 0) {
currentItemIndex = 0;
} else if (this.translateX > 0) {
const maxScroll = ITEM_WIDTH * originalItemCount;
const maxScroll = itemStride * originalItemCount;
const effectivePosition = maxScroll + this.translateX;
currentItemIndex = Math.floor(effectivePosition / ITEM_WIDTH);
currentItemIndex = Math.floor(effectivePosition / itemStride);
} else {
currentItemIndex = Math.floor(Math.abs(this.translateX) / ITEM_WIDTH);
currentItemIndex = Math.floor(Math.abs(this.translateX) / itemStride);
}
// Ensure we stay within bounds
@@ -615,7 +645,7 @@ class ProductCarousel extends React.Component {
top: '50%',
left: '8px',
transform: 'translateY(-50%)',
zIndex: 1200,
zIndex: 1000,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
width: '48px',
@@ -635,7 +665,7 @@ class ProductCarousel extends React.Component {
top: '50%',
right: '8px',
transform: 'translateY(-50%)',
zIndex: 1200,
zIndex: 1000,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
width: '48px',
@@ -676,16 +706,19 @@ class ProductCarousel extends React.Component {
}}
>
{products.map((product, index) => (
<div
<Box
key={`${product.id}-${index}`}
className="product-carousel-item"
style={{
flex: '0 0 250px',
width: '250px',
maxWidth: '250px',
minWidth: '250px',
boxSizing: 'border-box',
position: 'relative'
sx={{
flex: {
xs: `0 0 ${PRODUCT_CARD_WIDTH_XS_PX}px`,
sm: `0 0 ${PRODUCT_CARD_WIDTH_SM_PX}px`,
},
width: { xs: PRODUCT_CARD_WIDTH_XS_PX, sm: PRODUCT_CARD_WIDTH_SM_PX },
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
@@ -713,7 +746,7 @@ class ProductCarousel extends React.Component {
priority={index < 6 ? 'high' : 'auto'}
t={t}
/>
</div>
</Box>
))}
</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 } =
this.state;
const hasAttributeImages = attributes.some((attr) => attributeImages[attr.kMerkmalWert]);
// Debug alerts removed
@@ -1172,18 +1174,17 @@ class ProductDetailPage extends Component {
<Box
sx={{
mb: 2,
position: ["-webkit-sticky", "sticky"], // Provide both prefixed and standard
position: "sticky",
top: {
xs: "110px",
xs: "calc(env(safe-area-inset-top, 0px) + 160px)",
sm: "110px",
md: "110px",
lg: "110px",
} /* Offset to sit below the header 120 mith menu for md and lg*/,
},
left: 0,
width: "100%",
display: "flex",
zIndex: (theme) =>
theme.zIndex.appBar - 1 /* Just below the AppBar */,
zIndex: (theme) => theme.zIndex.appBar - 1,
py: 0,
px: 2,
}}
@@ -1198,10 +1199,19 @@ class ProductDetailPage extends Component {
borderRadius: 1,
}}
>
<Typography variant="body2" color="text.secondary">
<Typography variant="body2" color="text.secondary" component="div">
<Link
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={{
paddingLeft: 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 }}>
{(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
.filter(attribute => attributeImages[attribute.kMerkmalWert])
.map((attribute) => {
@@ -1321,7 +1343,11 @@ class ProductDetailPage extends Component {
key={attribute.kMerkmalWert}
label={attribute.cWert}
disabled
sx={{
sx={(theme) => ({
// Max-width query: reliable on portrait phones (avoids display:contents wrapper quirks)
[theme.breakpoints.down('sm')]: {
display: 'none',
},
'&.Mui-disabled': {
opacity: 1, // ← Remove the "fog"
},
@@ -1329,7 +1355,7 @@ class ProductDetailPage extends Component {
fontWeight: 'bold',
color: 'inherit', // ← Keep normal text color
},
}}
})}
/>
))}
</Stack>

View File

@@ -1,13 +1,35 @@
import React, { Component } from 'react';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
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 { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom';
import { setSessionSetting, removeSessionSetting, clearAllSessionSettings } from '../utils/sessionStorage.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);
/** 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
const withRouter = (ClassComponent) => {
return (props) => {
@@ -38,19 +60,35 @@ class ProductFilters extends Component {
uniqueManufacturerArray,
attributeGroups,
manufacturerImages: new Map(), // id (number) → object URL
pushInteractive: false,
pushSubscribed: false,
pushBusy: false,
pushError: null,
};
this._manufacturerImageUrls = []; // track for cleanup
}
componentDidMount() {
this.onPushSubscriptionsChanged = () => {
this.refreshCategoryPushStatus();
};
this.adjustPaperHeight();
window.addEventListener('resize', this.adjustPaperHeight);
window.addEventListener(
PUSH_SUBSCRIPTIONS_CHANGED_EVENT,
this.onPushSubscriptionsChanged
);
this._loadManufacturerImages();
this.refreshCategoryPushStatus();
}
componentWillUnmount() {
window.removeEventListener('resize', this.adjustPaperHeight);
window.removeEventListener(
PUSH_SUBSCRIPTIONS_CHANGED_EVENT,
this.onPushSubscriptionsChanged
);
this._manufacturerImageUrls.forEach(url => URL.revokeObjectURL(url));
}
@@ -102,17 +140,148 @@ class ProductFilters extends Component {
const attributeGroups = this._getAttributeGroups(this.props.attributes);
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 = () => {
// Skip height adjustment on xs screens
if (window.innerWidth < 600) return;
// Get reference to our paper element
const paperEl = document.getElementById('filters-paper');
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
const viewportHeight = window.innerHeight;
@@ -200,35 +369,140 @@ class ProductFilters extends Component {
}
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 (
<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
id="filters-paper"
elevation={window.innerWidth < 600 ? 0 : 1}
elevation={1}
sx={{
p: { xs: 1, sm: 2 },
borderRadius: { xs: 0, sm: 2 },
p: { xs: 2.5, sm: 2.5 },
mx: { sm: 'auto' },
maxWidth: '100%',
borderRadius: 2,
bgcolor: 'background.paper',
display: 'flex',
flexDirection: 'column',
border: { xs: 'none', sm: 'inherit' },
boxShadow: { xs: 'none', sm: 'inherit' },
mx: { xs: 0, sm: 'auto' },
width: { xs: '100%', sm: 'auto' }
boxSizing: 'border-box',
overflow: 'visible',
}}
>
{this.props.dataType == 'category' && (
<Typography
variant="h3"
component="h1"
sx={{
mb: 4,
fontFamily: 'SwashingtonCP',
color: 'primary.main'
}}
>
{this.props.categoryName}
</Typography>
<Box sx={{ mb: 4 }}>
<Typography
variant="h3"
component="h1"
sx={{
mb: showCategoryPush ? 1.5 : 4,
fontFamily: 'SwashingtonCP',
color: 'primary.main',
}}
>
{this.props.categoryName}
</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>
</Box>
);
}
}

View File

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

View File

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

View File

@@ -85,7 +85,7 @@ class Stripe extends Component {
colorWarning: '#FF9800', // Orange for warnings
// 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
fontWeightNormal: '400', // Normal Roboto weight
fontWeightMedium: '500', // Medium Roboto weight

View File

@@ -71,7 +71,7 @@ const ThemeCustomizerDialog = ({ open, onClose, theme, onThemeChange }) => {
},
},
typography: {
fontFamily: "'Roboto', 'Helvetica', 'Arial', sans-serif",
fontFamily: "'Outfit Variable', 'Roboto', 'Helvetica', 'Arial', sans-serif",
h4: {
fontWeight: 600,
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() {
const { categories, mobileMenuOpen, activeCategoryId } = this.state;
const { categories, mobileMenuOpen } = this.state;
const navKey = this.getNavHighlightKey();
const renderCategoryRow = (categories, isMobile = false) => (
<Box
@@ -168,7 +183,7 @@ class CategoryList extends Component {
display: "flex",
justifyContent: "flex-start",
alignItems: "center",
flexWrap: "wrap",
flexWrap: isMobile ? "wrap" : "nowrap",
overflowX: "visible",
flexDirection: isMobile ? "column" : "row",
py: 0.5, // Add vertical padding to prevent border clipping
@@ -182,7 +197,7 @@ class CategoryList extends Component {
aria-label="Zur Startseite"
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{
fontSize: "0.75rem",
fontSize: "0.85rem",
textTransform: "none",
whiteSpace: "nowrap",
opacity: 0.9,
@@ -194,7 +209,7 @@ class CategoryList extends Component {
transition: "all 0.2s ease",
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
position: "relative",
...(activeCategoryId === null && {
...(navKey === "home" && {
bgcolor: "#fff",
textShadow: "none",
opacity: 1,
@@ -218,7 +233,7 @@ class CategoryList extends Component {
<HomeIcon sx={{
fontSize: "1rem",
mr: isMobile ? 1 : 0,
color: activeCategoryId === null ? "#2e7d32" : "inherit"
color: navKey === "home" ? "#2e7d32" : "inherit"
}} />
{isMobile && (
<Box sx={{ position: "relative", display: "inline-block" }}>
@@ -227,7 +242,7 @@ class CategoryList extends Component {
className="bold-text"
sx={{
fontWeight: "bold",
color: activeCategoryId === null ? "#2e7d32" : "transparent",
color: navKey === "home" ? "#2e7d32" : "transparent",
position: "relative",
zIndex: 2,
}}
@@ -239,7 +254,7 @@ class CategoryList extends Component {
className="thin-text"
sx={{
fontWeight: "400",
color: activeCategoryId === null ? "transparent" : "inherit",
color: navKey === "home" ? "transparent" : "inherit",
position: "absolute",
top: 0,
left: 0,
@@ -260,7 +275,7 @@ class CategoryList extends Component {
aria-label="Neuheiten"
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{
fontSize: "0.75rem",
fontSize: "0.85rem",
textTransform: "none",
whiteSpace: "nowrap",
opacity: 0.9,
@@ -271,12 +286,32 @@ class CategoryList extends Component {
justifyContent: isMobile ? "flex-start" : "center",
transition: "all 0.2s ease",
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={{
fontSize: "1rem",
mr: isMobile ? 1 : 0
mr: isMobile ? 1 : 0,
color: navKey === "neu" ? "#2e7d32" : "inherit"
}} />
{isMobile && (
<Box sx={{ position: "relative", display: "inline-block" }}>
@@ -285,7 +320,7 @@ class CategoryList extends Component {
className="bold-text"
sx={{
fontWeight: "bold",
color: "transparent",
color: navKey === "neu" ? "#2e7d32" : "transparent",
position: "relative",
zIndex: 2,
}}
@@ -297,7 +332,7 @@ class CategoryList extends Component {
className="thin-text"
sx={{
fontWeight: "400",
color: "inherit",
color: navKey === "neu" ? "transparent" : "inherit",
position: "absolute",
top: 0,
left: 0,
@@ -318,7 +353,7 @@ class CategoryList extends Component {
aria-label={this.props.t ? this.props.t('navigation.soon') : 'Demnächst'}
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{
fontSize: "0.75rem",
fontSize: "0.85rem",
textTransform: "none",
whiteSpace: "nowrap",
opacity: 0.9,
@@ -329,12 +364,32 @@ class CategoryList extends Component {
justifyContent: isMobile ? "flex-start" : "center",
transition: "all 0.2s ease",
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={{
fontSize: "1rem",
mr: isMobile ? 1 : 0
mr: isMobile ? 1 : 0,
color: navKey === "bald" ? "#2e7d32" : "inherit"
}} />
{isMobile && (
<Box sx={{ position: "relative", display: "inline-block" }}>
@@ -342,7 +397,7 @@ class CategoryList extends Component {
className="bold-text"
sx={{
fontWeight: "bold",
color: "transparent",
color: navKey === "bald" ? "#2e7d32" : "transparent",
position: "relative",
zIndex: 2,
}}
@@ -353,7 +408,7 @@ class CategoryList extends Component {
className="thin-text"
sx={{
fontWeight: "400",
color: "inherit",
color: navKey === "bald" ? "transparent" : "inherit",
position: "absolute",
top: 0,
left: 0,
@@ -380,7 +435,7 @@ class CategoryList extends Component {
size="small"
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{
fontSize: "0.75rem",
fontSize: "0.85rem",
textTransform: "none",
whiteSpace: "nowrap",
opacity: 0.9,
@@ -392,7 +447,7 @@ class CategoryList extends Component {
transition: "all 0.2s ease",
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
position: "relative",
...(activeCategoryId === category.id && {
...(navKey === category.id && {
bgcolor: "#fff",
textShadow: "none",
opacity: 1,
@@ -416,7 +471,7 @@ class CategoryList extends Component {
className="bold-text"
sx={{
fontWeight: "bold",
color: activeCategoryId === category.id ? "#2e7d32" : "transparent",
color: navKey === category.id ? "#2e7d32" : "transparent",
position: "relative",
zIndex: 2,
}}
@@ -428,7 +483,7 @@ class CategoryList extends Component {
className="thin-text"
sx={{
fontWeight: "400",
color: activeCategoryId === category.id ? "transparent" : "inherit",
color: navKey === category.id ? "transparent" : "inherit",
position: "absolute",
top: 0,
left: 0,
@@ -451,7 +506,7 @@ class CategoryList extends Component {
alignItems: "center",
height: "33px", // Match small button height
px: 1,
fontSize: "0.75rem",
fontSize: "0.85rem",
opacity: 0.9,
}}
>
@@ -464,10 +519,10 @@ class CategoryList extends Component {
to="/Konfigurator"
color="inherit"
size="small"
aria-label="Zur Startseite"
aria-label={this.props.t ? this.props.t('navigation.konfiguratorAria') : 'Zum Konfigurator'}
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{
fontSize: "0.75rem",
fontSize: "0.85rem",
textTransform: "none",
whiteSpace: "nowrap",
opacity: 0.9,
@@ -479,7 +534,7 @@ class CategoryList extends Component {
transition: "all 0.2s ease",
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
position: "relative",
...(activeCategoryId === null && {
...(navKey === "konfigurator" && {
bgcolor: "#fff",
textShadow: "none",
opacity: 1,
@@ -503,7 +558,7 @@ class CategoryList extends Component {
<SettingsIcon sx={{
fontSize: "1rem",
mr: isMobile ? 1 : 0,
color: activeCategoryId === null ? "#2e7d32" : "inherit"
color: navKey === "konfigurator" ? "#2e7d32" : "inherit"
}} />
{isMobile && (
<Box sx={{ position: "relative", display: "inline-block" }}>
@@ -512,26 +567,26 @@ class CategoryList extends Component {
className="bold-text"
sx={{
fontWeight: "bold",
color: activeCategoryId === null ? "#2e7d32" : "transparent",
color: navKey === "konfigurator" ? "#2e7d32" : "transparent",
position: "relative",
zIndex: 2,
}}
>
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
{this.props.t ? this.props.t('sections.konfigurator') : 'Konfigurator'}
</Box>
{/* Thin text (positioned on top) */}
<Box
className="thin-text"
sx={{
fontWeight: "400",
color: activeCategoryId === null ? "transparent" : "inherit",
color: navKey === "konfigurator" ? "transparent" : "inherit",
position: "absolute",
top: 0,
left: 0,
zIndex: 1,
}}
>
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
{this.props.t ? this.props.t('sections.konfigurator') : 'Konfigurator'}
</Box>
</Box>
)}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,17 @@
export default {
"sections": {
"chatbot": {
"title": "استخدام روبوت دردشة ذكي (OpenAI API)",
"content": "نحن نستخدم روبوت دردشة مدعوم بالذكاء الاصطناعي على موقعنا الإلكتروني، والذي يتم تشغيله عبر واجهة برمجة التطبيقات (API) لمزود الخدمة OpenAI. يهدف روبوت الدردشة إلى الرد على استفسارات الزوار بكفاءة وبشكل تلقائي، وبالتالي توفير وظيفة دعم. عند استخدامك لروبوت الدردشة، يتم معالجة مدخلاتك بواسطة النظام لتوليد ردود مناسبة. تتم المعالجة بشكل مجهول لا يتم جمع أو تخزين عناوين IP أو أي بيانات شخصية أخرى (مثل الاسم أو بيانات الاتصال).",
"legalBasis": "الأساس القانوني لاستخدام روبوت الدردشة هو مصلحتنا المشروعة وفقًا للمادة 6 الفقرة 1 الحرف f من DSGVO. تكمن هذه المصلحة في توفير دعم فعال للزوار وكذلك تحسين تجربة المستخدم على موقعنا الإلكتروني.",
"dataRecipient": "المستلم لبيانات الدردشة هو OpenAI (OpenAI OpCo, LLC) كمزود خدمة فني. تقوم OpenAI بمعالجة محتوى الدردشة المرسل على خوادمها حصريًا لغرض توليد الردود. تعمل OpenAI كمعالج بيانات وفقًا للمادة 28 من DSGVO ولا تستخدم البيانات لأغراضها الخاصة. لقد أبرمنا عقد معالجة بيانات مع OpenAI، والذي يتضمن بنود العقد النموذجية للاتحاد الأوروبي كضمانات مناسبة لحماية البيانات. يقع المقر الرئيسي لـ OpenAI في الولايات المتحدة الأمريكية؛ ومن خلال الموافقة على بنود العقد النموذجية، يتم ضمان مستوى حماية بيانات يعادل مستوى الاتحاد الأوروبي عند نقل بياناتك.",
"dataRetention": "نحتفظ باستفسارات الدردشة الخاصة بك فقط طالما كان ذلك ضروريًا للمعالجة والرد. بمجرد الانتهاء من طلبك، يتم حذف سجلات الدردشة أو إخفاء هويتها على الفور. وفقًا لتصريحاتها الخاصة، تحتفظ OpenAI ببيانات الدردشة المعالجة مؤقتًا فقط وتحذفها تلقائيًا بعد مدة أقصاها 30 يومًا.",
"voluntaryUse": "استخدام روبوت الدردشة اختياري. إذا لم تستخدم روبوت الدردشة، فلن يتم نقل أي بيانات إلى OpenAI. يرجى عدم إدخال أي بيانات شخصية حساسة في الدردشة."
"title": "شات بوت بالذكاء الاصطناعي (OpenAI API)",
"intro": "بنستخدم شات بوت مدعوم بالذكاء الاصطناعي على موقعنا الإلكتروني، وبيتوفّر من خلال OpenAI API، علشان نرد تلقائيًا على الاستفسارات ونحسّن الدعم بتاعنا.",
"processing": "عند استخدام الشات بوت، المحتوى اللي بتدخّله بيتنقل إلى OpenAI وبيتتم معالجته هناك نيابةً عنّا علشان يتكوّن رد مناسب. برجاء ملاحظة إن المحتوى المُدخل ممكن كمان يحتوي على بيانات شخصية لو إنت أدخلت المعلومات دي بنفسك.",
"legalBasis": "الأساس القانوني للمعالجة هو Art. 6 para. 1 lit. f DSGVO. المصلحة المشروعة بتاعتنا بتكمن في التعامل بكفاءة مع الاستفسارات وتحسين العرض الإلكتروني بتاعنا.",
"dataRecipient": "الجهة المستلمة للبيانات هي OpenAI. بالنسبة للمستخدمين في المنطقة الاقتصادية الأوروبية وسويسرا، فإن OpenAI Ireland Limited, 1st Floor, The Liffey Trust Centre, 117-126 Sheriff Street Upper, Dublin 1, D01 YC43, Ireland، هي الطرف التعاقدي المعني.",
"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": {
"title": "الكوكيز",
"intro": "موقعنا بيستخدم الكوكيز في الحالات التالية:",
"payment": "1. عملية الدفع: عند الدفع ببطاقات الائتمان أو التحويلات الفورية (مثلاً Klarna Sofort)، بيتم استخدام كوكيز تقنية ضرورية. الكوكيز دي بتحتوي على سلسلة مميزة من الأحرف بتسمح بالتعرف الفريد على المتصفح. الكوكيز دي بيتم تعيينها من قبل مزود خدمة الدفع Stripe وضرورية تمامًا لمعالجة المدفوعات بأمان وسلاسة. من غير الكوكيز دي، مش ممكن تقديم طلب باستخدام طرق الدفع دي. المعالجة بتتم بناءً على المادة 6 (1) بند ب من DSGVO لتنفيذ العقد.",
"googleSSO": "2. تسجيل الدخول الموحد من جوجل (SSO): عند استخدام تسجيل الدخول عبر جوجل، بيتم تعيين كوكيز من جوجل ضرورية لعملية تسجيل الدخول والمصادقة. الكوكيز دي بتسمحلك تسجل دخولك بسهولة بحساب جوجل بتاعك من غير ما تحتاج تسجل دخول كل مرة. المعالجة بتتم بناءً على المادة 6 (1) بند ب من DSGVO (تنفيذ العقد) والمادة 6 (1) بند ف من DSGVO (مصلحة مشروعة في تسجيل دخول سهل الاستخدام).",
"otherPayments": "بالنسبة لطرق الدفع الأخرى الخصم المباشر، الاستلام، أو الدفع عند الاستلام مفيش كوكيز إضافية مستخدمة، إلا لو استخدمت تسجيل الدخول عبر جوجل."
"payment": "1. عملية الدفع: في حالة الدفع ببطاقة الائتمان أو التحويل البنكي الفوري (مثلاً Klarna Sofort)، بيتم استخدام كوكيز ضرورية تقنياً. الكوكيز دي بتحتوي على سلسلة مميزة من الأحرف بتسمح بتحديد المتصفح بشكل فريد. الكوكيز دي بيتم ضبطها من خلال مزوّد خدمة الدفع Stripe وهي ضرورية تماماً علشان معالجة المدفوعات بشكل آمن وسلس. من غير الكوكيز دي، ماينفعش إتمام الطلب باستخدام طرق الدفع دي. بيتم المعالجة على أساس Art. 6 (1) lit. b DSGVO لتنفيذ العقد.",
"googleSSO": "2. Google Single Sign-On (SSO): عند استخدام تسجيل الدخول بحساب Google، Google بتضبط كوكيز مطلوبة لعملية تسجيل الدخول والمصادقة. الكوكيز دي بتخليك تسجّل دخولك بسهولة باستخدام حساب Google من غير ما تحتاج تعيد تسجيل الدخول كل مرة. بيتم المعالجة على أساس Art. 6 (1) lit. b DSGVO (تنفيذ العقد) وArt. 6 (1) lit. f DSGVO (المصلحة المشروعة في تسجيل دخول سهل الاستخدام).",
"otherPayments": "بالنسبة لطرق الدفع التانية - الخصم المباشر، الاستلام، أو الدفع عند الاستلام - مافيش كوكيز إضافية مستخدمة إلا لو استخدمت تسجيل الدخول بحساب Google."
},
"mollie": {
"title": "Mollie (معالجة الدفع)",
"content": "احنا بنستخدم مزود خدمة الدفع Mollie على موقعنا لمعالجة المدفوعات. مزود الخدمة هو Mollie B.V., Keizersgracht 126, 1015 CW Amsterdam, Netherlands. في السياق ده، بيتم نقل البيانات الشخصية المطلوبة لمعالجة الدفع لـ Mollie خصوصًا اسمك، بريدك الإلكتروني، عنوان الفاتورة، معلومات الدفع (مثلاً بيانات بطاقة الائتمان)، وعنوان الـ IP. معالجة البيانات بتتم لغرض معالجة الدفع؛ الأساس القانوني هو المادة 6 الفقرة 1 بند ب من DSGVO، لأنها بتخدم تنفيذ عقد معاك.",
"responsibility": "Mollie كمان بتعالج بيانات معينة كمسؤول مستقل، مثلاً لتنفيذ الالتزامات القانونية (زي مكافحة غسيل الأموال) ولمنع الاحتيال. بالإضافة لكده، احنا موقّعين عقد معالجة بيانات مع Mollie وفقًا للمادة 28 من DSGVO؛ وبموجب العقد ده، Mollie بتتصرف فقط بتعليماتنا عند معالجة المدفوعات.",
"dataTransfer": "لو Mollie بتعالج بيانات شخصية خارج الاتحاد الأوروبي، خصوصًا في الولايات المتحدة الأمريكية، ده بيتم مع الالتزام بضمانات مناسبة. Mollie بتستخدم بنود العقد النموذجية للاتحاد الأوروبي حسب المادة 46 من DSGVO لضمان مستوى مناسب من حماية البيانات. مع ذلك، بنحب نوضح إن الولايات المتحدة الأمريكية بتعتبر دولة ثالثة بموجب قانون حماية البيانات مع احتمال وجود مستوى حماية بيانات غير كافي. ممكن تلاقي معلومات أكتر في سياسة الخصوصية الخاصة بـ Mollie على https://www.mollie.com/de/privacy."
"title": "Mollie (معالجة المدفوعات)",
"content": "إحنا بنستخدم مزوّد خدمة الدفع Mollie على موقعنا لمعالجة المدفوعات. مزوّد الخدمة هو Mollie B.V., Keizersgracht 126, 1015 CW Amsterdam, Netherlands. في السياق ده، البيانات الشخصية المطلوبة لمعالجة الدفع بيتم إرسالها إلى Mollie - وبالأخص اسمك، عنوان البريد الإلكتروني، عنوان الفوترة، معلومات الدفع (مثلاً بيانات بطاقة الائتمان)، وكمان عنوان IP. معالجة البيانات بتتم لغرض معالجة الدفع؛ والأساس القانوني هو Art. 6 para. 1 lit. b DSGVO، لأنه بيساعد في تنفيذ عقد معاك.",
"responsibility": "Mollie كمان بتعالج بعض البيانات كمسؤول مستقل، مثلاً عشان تلتزم بالالتزامات القانونية (زي منع غسيل الأموال) ولمكافحة الاحتيال. بالإضافة إلى كده، إحنا أبرمنا مع Mollie اتفاقية معالجة بيانات وفقًا لـ Art. 28 DSGVO؛ وفي نطاق الاتفاقية دي، Mollie بتتصرف حصريًا حسب تعليماتنا أثناء معالجة المدفوعات.",
"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": {
"customerAccount": {
"title": "حساب العميل",
"content": "عند فتح حساب عميل، نجمع بياناتك الشخصية بالقدر المحدد هناك. معالجة البيانات تهدف إلى تحسين تجربة التسوق الخاصة بك وتسهيل معالجة الطلبات. تتم المعالجة بناءً على المادة 6 (1) حرف a من DSGVO بموافقتك. يمكنك سحب موافقتك في أي وقت بإبلاغنا، دون التأثير على قانونية المعالجة التي تمت بناءً على الموافقة حتى سحبها. سيتم بعد ذلك حذف حساب العميل الخاص بك."
"content": "عند فتح حساب عميل، بنجمع بياناتك الشخصية بالقدر المحدد هناك. معالجة البيانات بتتم بهدف تحسين تجربة التسوق الخاصة بيك وتسهيل معالجة الطلبات. المعالجة بتتم على أساس Art. 6 (1) lit. a DSGVO بموافقتك. يمكنك سحب موافقتك في أي وقت عن طريق إخطارنا، من غير ما ده يأثر على قانونية المعالجة اللي تمت على أساس الموافقة لحد وقت السحب. بعد كده هيتم حذف حساب العميل الخاص بيك."
}
}
};

View File

@@ -1,15 +1,18 @@
export default {
"sections": {
"googleSSO": {
"title": "تسجيل الدخول باستخدام Google (تسجيل الدخول الموحد من Google)",
"content": "نقدم لك خيار تسجيل الدخول إلى حساب العميل الخاص بك باستخدام حساب Google الخاص بك. إذا استخدمت وظيفة \"تسجيل الدخول باستخدام Google\"، يتم التحقق من الهوية عبر خدمة Google Single Sign-On. في هذه العملية، قد يتم تخزين ملفات تعريف الارتباط (كوكيز) من Google على جهازك، والتي تكون ضرورية لعملية تسجيل الدخول والتحقق من الهوية. كجزء من تسجيل الدخول عبر Google، نتلقى من Google بيانات شخصية معينة للتحقق من هويتك. على وجه الخصوص، تقوم Google بإرسال اسمك، وعنوان بريدك الإلكتروني، وإذا كان مخزناً في حساب Google الخاص بك، صورة ملفك الشخصي إلينا. يتم توفير هذه المعلومات من قبل Google بمجرد تسجيل دخولك إلى متجرنا الإلكتروني باستخدام حساب Google الخاص بك. يمكن لـ Google، كمزود طرف ثالث، الوصول إلى هذه البيانات ومعالجتها؛ وقد يشمل ذلك نقل البيانات إلى الولايات المتحدة الأمريكية. لقد أبرمنا مع Google بنود حماية بيانات قياسية وفقًا للمادة 46 الفقرة 2 الحرف ج من DSGVO لضمان مستوى مناسب من حماية البيانات عند نقل بياناتك. يمكن العثور على مزيد من التفاصيل حول معالجة البيانات بواسطة Google في سياسة الخصوصية الخاصة بـ Google (على <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."
"title": "تسجيل الدخول باستخدام Google (Google Single Sign-On)",
"content": "بنقدملك إمكانية تسجيل الدخول على حساب العميل بتاعك باستخدام حساب Google الخاص بيك. لو استخدمت وظيفة \"Sign in with Google\"، فالمصادقة بتتم من خلال خدمة Google Single Sign-On. أثناء العملية دي، ممكن Google تخزن ملفات تعريف ارتباط (cookies) على جهازك، ودي بتكون لازمة لعملية تسجيل الدخول والمصادقة. كجزء من تسجيل الدخول باستخدام Google، بنستلم من Google بعض البيانات الشخصية علشان نتحقق من هويتك. وبشكل خاص، Google بتبعتلنا اسمك، وعنوان بريدك الإلكتروني، وكمان—لو كانت محفوظة في حساب Google بتاعك—صورة الملف الشخصي بتاعتك. المعلومات دي بتتقدم من Google بمجرد ما تسجل دخولك على المتجر الإلكتروني بتاعنا بحساب Google الخاص بيك. Google، كمزوّد طرف ثالث، ممكن يطّلع على البيانات دي ويعالجها؛ وده ممكن كمان يشمل نقل البيانات إلى الولايات المتحدة الأمريكية. وإحنا أبرمنا بنود تعاقدية قياسية مع Google وفقًا للمادة 46 الفقرة 2 الحرف c من DSGVO علشان نضمن مستوى مناسب من حماية البيانات عند نقل بياناتك.",
"privacyLinkIntro": "مزيد من التفاصيل عن معالجة البيانات بواسطة Google ممكن تلاقيها في سياسة الخصوصية الخاصة بـ Google (على ",
"privacyLinkUrl": "https://policies.google.com/privacy?hl=de",
"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": {
"title": "جمع ومعالجة واستخدام البيانات الشخصية للطلبات",
"content": "عند تقديم طلب، نقوم بجمع واستخدام بياناتك الشخصية فقط بالقدر اللازم لتنفيذ ومعالجة طلبك وللتعامل مع استفساراتك. تقديم هذه البيانات ضروري لإبرام العقد. عدم تقديم البيانات يعني أنه لا يمكن إبرام العقد. تتم المعالجة على أساس المادة 6 (1) الحرف ب من DSGVO وهي ضرورية لتنفيذ عقد معك. لن يتم تمرير بياناتك إلى أطراف ثالثة بدون موافقتك الصريحة. الاستثناء الوحيد هم شركاء الخدمة الذين نحتاجهم لمعالجة العلاقة التعاقدية أو مقدمو الخدمات الذين نستخدمهم في إطار معالجة الطلبات. بالإضافة إلى المستلمين المذكورين في البنود الخاصة بهذه السياسة، قد يشمل ذلك مستلمين من الفئات التالية: مزودو خدمات الشحن، مزودو خدمات الدفع، مزودو خدمات إدارة البضائع، مقدمو خدمات معالجة الطلبات، مزودو استضافة الويب، مزودو خدمات تكنولوجيا المعلومات، وتجار الدروب شيبنج. في جميع الحالات، نلتزم بدقة بالمتطلبات القانونية. نطاق نقل البيانات محدود إلى الحد الأدنى."
"content": "لما بتقدم طلب، بنجمع وبنستخدم بياناتك الشخصية فقط بالقدر الضروري لتنفيذ ومعالجة طلبك وللتعامل مع استفساراتك. تقديم البيانات مطلوب لإبرام العقد. عدم تقديم البيانات هيؤدي إلى عدم إبرام العقد. المعالجة بتتم على أساس المادة 6 (1) الحرف b من DSGVO وهي ضرورية لتنفيذ عقد معك. بياناتك مش هتتسلم لأطراف تانية من غير موافقتك الصريحة. والاستثناء من ده هو فقط شركاء الخدمة بتوعنا اللي بنحتاجهم لمعالجة العلاقة التعاقدية أو مقدمي الخدمات اللي بنستخدمهم في إطار المعالجة بالنيابة عنا. وبالإضافة إلى الجهات المستلمة المذكورة في البنود الخاصة بكل جزء من سياسة الخصوصية دي، فده مثلًا بيشمل جهات مستلمة من الفئات التالية: مقدمو خدمات الشحن، مقدمو خدمات الدفع، مقدمو خدمات إدارة البضائع، مقدمو خدمات معالجة الطلبات، مستضيفو الويب، مقدمو خدمات تكنولوجيا المعلومات، وتجار الدروبشيبينج. وفي كل الحالات، بنلتزم بدقة بالمتطلبات القانونية. ونطاق نقل البيانات بيكون محدود للحد الأدنى.",
}
}
};

View File

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

View File

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

View File

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

View File

@@ -1,25 +1,25 @@
export default {
"title": "الإشعار القانوني (Impressum)",
"title": "الإشعار القانوني", // Impressum
"sections": {
"operator": {
"title": "المشغل والمسؤول عن محتوى هذا المتجر هو:",
"content": "Growheads\nMax Schön\nTrachenberger Straße 14\n01129 Dresden"
"title": "المشغّل والمسؤول عن محتوى هذا المتجر هو:", // Betreiber und verantwortlich für die Inhalte dieses Shops ist:
"content": "Growheads\nMax Schön\nTrachenberger Straße 14\n01129 Dresden" // Growheads\nMax Schön\nTrachenberger Straße 14\n01129 Dresden
},
"contact": {
"title": "الاتصال:",
"content": "البريد الإلكتروني: service@growheads.de"
"title": "التواصل:", // Kontakt:
"content": "Email: service@growheads.de" // E-Mail: service@growheads.de
},
"vatId": {
"title": "رقم ضريبة القيمة المضافة:",
"content": "رقم ضريبة القيمة المضافة: DE323017152"
"title": "رقم ضريبة القيمة المضافة:", // Umsatzsteuer-ID:
"content": "VAT ID No.: DE323017152" // USt.-IdNr.: DE323017152
},
"disclaimer": {
"title": "تنصل من المسؤولية:",
"content": "لا نتحمل أي مسؤولية عن محتوى عناوين الإنترنت الخارجية المرتبطة بهذه الصفحات. المشغلون المعنيون هم المسؤولون عن محتوى المواقع غير التابعة للشركة."
"title": "إخلاء المسؤولية:", // Haftungsausschluss:
"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": {
"title": "بند حقوق النشر:",
"content": "المحتوى المعروض هنا يخضع بشكل عام لحقوق النشر ولا يجوز توزيعه إلا بموافقة كتابية.\nحقوق المواد المصورة أو النصية من أطراف أخرى ليست مقيدة أو ملغاة بهذا البند."
"title": "بند حقوق النشر:", // Urheberrechtsklausel:
"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 {
"title": "حق الانسحاب",
"withdrawalRight": "لديك الحق في الانسحاب من هذا العقد خلال أربعة عشر يومًا دون إبداء أي سبب. تبدأ فترة الانسحاب من اليوم الذي استلمت فيه أنت أو طرف ثالث معين من قبلك، وليس الناقل، البضائع.",
"exerciseWithdrawal": "لممارسة حقك في الانسحاب، يجب عليك إبلاغنا",
"contactInfo": "Growheads\nTrachenberger Straße 14\n01129 Dresden\nE-Mail: service@growheads.de",
"withdrawalProcess": "ببيان واضح (مثل رسالة مرسلة بالبريد، أو فاكس، أو بريد إلكتروني) عن قرارك بالانسحاب من هذا العقد. يمكنك استخدام نموذج الانسحاب المرفق لهذا الغرض، لكنه ليس إلزاميًا. وللحفاظ على مهلة الانسحاب، يكفي أن ترسل إشعارك بممارسة حق الانسحاب قبل انتهاء فترة الانسحاب.",
"consequencesTitle": "عواقب الانسحاب",
"consequences": "إذا انسحبت من هذا العقد، سنقوم برد جميع المدفوعات التي تلقيناها منك، بما في ذلك تكاليف التسليم (باستثناء التكاليف الإضافية الناتجة إذا اخترت نوع تسليم غير أرخص نوع تسليم قياسي نقدمه)، دون تأخير غير مبرر وفي موعد أقصاه أربعة عشر يومًا من اليوم الذي استلمنا فيه إشعار انسحابك من هذا العقد. سنستخدم نفس وسيلة الدفع التي استخدمتها في المعاملة الأصلية لهذا السداد، ما لم يتم الاتفاق صراحة معك على خلاف ذلك؛ ولن يتم فرض أي رسوم عليك مقابل هذا السداد. قد نحتجز السداد حتى نستلم البضائع مرة أخرى أو تقدم دليلاً على أنك أعدت إرسال البضائع، أيهما يحدث أولاً. يجب عليك إعادة البضائع أو تسليمها لنا دون تأخير غير مبرر وفي كل الأحوال في موعد أقصاه أربعة عشر يومًا من اليوم الذي تخطرنا فيه بانسحابك من هذا العقد. يتم الوفاء بالموعد إذا أرسلت البضائع قبل انتهاء فترة الأربعة عشر يومًا. ستتحمل التكلفة المباشرة لإعادة البضائع. أنت مسؤول فقط عن أي انخفاض في قيمة البضائع ناتج عن التعامل معها بطريقة تتجاوز ما هو ضروري لتحديد طبيعة وخصائص وعمل البضائع.",
"noWithdrawalTitle": شعار بعدم وجود حق الانسحاب",
"noWithdrawal": "لا ينطبق حق الانسحاب على البضائع التي تم تصنيعها أو تفصيلها حسب مواصفات العميل (الأفلام والأنابيب)، لكن يمكن منحه باتفاق. كما أن حاويات الأسمدة التي تم إزالة ختمها أو تدميره بفتحها مستبعدة أيضًا من حق الانسحاب."
"title": "حقّ الرجوع",
"withdrawalRight": "عندك الحق إنك ترجع عن العقد ده خلال أربع عشرة يوم من غير ما تدي أي سبب. مدة الرجوع هي أربع عشرة يوم من اليوم اللي استلمت فيه أنت، أو أي طرف تالت عيّنته أنت ومش هو الناقل، البضاعة.",
"exerciseWithdrawal": "علشان تمارس حقّ الرجوع، لازم تبلغنا",
"contactInfo": "Growheads\nTrachenberger Straße 14\n01129 Dresden\nEmail: service@growheads.de",
"withdrawalProcess": "عن طريق بيان واضح (مثلاً خطاب متبعت بالبريد، أو بالفاكس أو بالإيميل) يفيد بقرارك إنك ترجع عن العقد ده. ممكن تستخدم نموذج الرجوع المرفق للغرض ده، لكن ده مش إلزامي. وللاستفادة من مدة الرجوع، يكفي إنك تبعت الإخطار الخاص بممارسة حقّ الرجوع قبل انتهاء مدة الرجوع.",
"consequencesTitle": "نتائج الرجوع",
"consequences": "لو رجعت عن العقد ده، هنردّلك كل المدفوعات اللي استلمناها منك، بما فيها تكاليف التوصيل (ما عدا التكاليف الإضافية الناتجة عن اختيارك نوع توصيل غير أرخص توصيل قياسي بنقدمه)، من غير تأخير غير مبرر وعلى أقصى تقدير خلال أربع عشرة يوم من اليوم اللي وصلتنا فيه إخطاراتك بالرجوع عن العقد ده. وللردّ ده هنستخدم نفس وسيلة الدفع اللي استخدمتها في المعاملة الأصلية، إلا لو اتفقنا معاك صراحة على حاجة تانية؛ وفي أي حال مش هيتخصم منك أي رسوم بسبب الردّ ده. يحق لينا نرفض الردّ لحد ما نستلم البضاعة تاني أو لحد ما تقدم إثبات إنك رجّعت البضاعة، أيهما أسبق. لازم تبعتلنا البضاعة أو تسلّمها لينا من غير تأخير غير مبرر وعلى أي حال بحد أقصى خلال أربع عشرة يوم من اليوم اللي بلغتنا فيه بالرجوع عن العقد ده. الميعاد بيكون مستوفى لو بعتّ البضاعة قبل انتهاء مدة الأربع عشرة يوم. أنت بتتحمل التكاليف المباشرة لإرجاع البضاعة. ومش عليك تدفع عن أي نقص في قيمة البضاعة إلا لو النقص ده سببه التعامل مع البضاعة بشكل مش كان ضروري علشان التأكد من حالتها وخصائصها وطريقة عملها.",
"noWithdrawalTitle": خطار بعدم وجود حقّ الرجوع",
"noWithdrawal": "حقّ الرجوع ما ينطبقش على البضاعة اللي اتصنعت أو اتقصّت حسب مواصفات العميل (الفلّيمات والخراطيم)، لكن ممكن يتمنح بعد الاتفاق. برضه أوعية السماد اللي اتشال أو اتدمر فيها ختم الإغلاق بسبب الفتح مستثناة من حقّ الرجوع."
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,20 +1,20 @@
export default {
"title": "Общи условия", // Allgemeine Geschäftsbedingungen
"deliveryShippingConditions": "Условия за доставка и изпращане", // Liefer- & Versandbedingungen
"title": "Общи условия и правила",
"deliveryShippingConditions": "Условия за доставка и изпращане",
"deliveryTerms": {
"1": "Доставката отнема между 1 и 7 дни.", // Der Versand dauert zwischen 1 und 7 Tagen.
"2": "Стоките остават собственост на Growheads до получаване на пълното плащане.", // Die Ware bleibt bis zur vollständigen Bezahlung Eigentum von 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.
"4": "При връщане на дефектни стоки клиентът трябва да се увери, че стоките са правилно опаковани.", // Bei der Rücksendung mangelhafter Ware hat der Kunde Sorge zu tragen, dass die Ware ordnungsgemäß verpackt wird.
"5": "Всички връщания трябва да бъдат предварително регистрирани при Growheads.", // Alle Rücksendungen sind vorher bei Growheads anzumelden.
"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.
"7": "Growheads има право да организира вземането на стоките чрез Deutsche Post/GLS или избран от него спедитор.", // Growheads ist berechtigt, die Ware durch die Deutsche Post/GLS oder einen Spediteur seiner Wahl, abholen zu lassen.
"8": "Разходите за пощенски услуги се изчисляват според теглото. Growheads си запазва правото да прехвърля евентуални увеличения на цените от транспортните компании (такси, горивни надбавки).", // Die Portokosten werden nach Gewicht berechnet. Eventuelle Preiserhöhungen der Transportunternehmen (Maut, Treibstoffzuschläge) behält sich Growheads vor.
"9": "Нашите пратки обикновено се изпращат с: GLS, DHL & Deutsche Post AG.", // Unsere Pakete werden in der Regel versendet mit: GLS, DHL & der Deutschen 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.
"11": "Плащането предварително може да се извърши чрез банков превод към посочената банкова сметка.", // Es kann per Vorkasse an die angegebene Bankverbindung überwiesen werden.
"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.
"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.
"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.
"1": "Изпращането отнема между 1 и 7 дни.",
"2": "Стоката остава собственост на Growheads до извършване на пълното плащане.",
"3": "Ако има съмнение, че стоката е била повредена по време на транспорта или че липсва стока, транспортната опаковка трябва да се съхрани за преглед от вещо лице. Повреда на опаковката трябва да бъде потвърдена от превозвача в товарителницата по вид и обем. Щетите при изпращане трябва незабавно да бъдат съобщени писмено на Growheads по факс, имейл или по пощата. За тази цел трябва да се направят снимки на повредената стока, както и на повредената транспортна кутия, включително адресния етикет. Повредената транспортна кутия също трябва да се запази. Те са необходими, за да бъде щетата предявена към транспортната фирма.",
"4": "При връщане на дефектна стока клиентът трябва да се погрижи стоката да бъде правилно опакована.",
"5": "Всички връщания трябва предварително да бъдат заявени при Growheads.",
"6": "Клиентът носи риска при изпращане на предмети до нас, освен ако това не се отнася до връщане на дефектна стока.",
"7": "Growheads има право да възложи вземането на стоката от Deutsche Post/GLS или от спедитор по свой избор.",
"8": "Пощенските разходи се изчисляват според теглото. Growheads си запазва правото да прехвърли евентуални увеличения на цените от транспортните компании (пътни такси, допълнителни такси за гориво).",
"9": "Нашите пратки по правило се изпращат с: GLS, DHL и Deutsche Post AG.",
"10": "За особено тежки или обемисти артикули си запазваме правото да начисляваме допълнителни такси към разходите за доставка. По правило тези допълнителни такси са посочени в ценовата листа.",
"11": "Плащането може да се извърши предварително чрез банков превод към посочената банкова сметка.",
"12": "Ако възникне забавяне на доставката, за което ние носим отговорност, срокът на допълнителния срок, който купувачът има право да определи, е фиксиран на две седмици. Срокът започва да тече от получаването от Growheads на уведомлението за даване на допълнителен срок.",
"13": "Явните недостатъци на стоката трябва да бъдат съобщени писмено незабавно след доставката. Ако клиентът не изпълни това задължение, претенции по гаранцията поради явни недостатъци са изключени.",
"14": "Ако клиентът предяви рекламация за недостатък, той трябва да ни върне дефектната стока заедно с възможно най-точно описание на повредата. Към пратката трябва да бъде приложено копие от нашата фактура. Стоката трябва да бъде върната в оригиналната опаковка или в опаковка, която защитава стоката по същия начин както оригиналната опаковка, така че да се избегнат повреди по време на обратния транспорт."
}
};

View File

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

View File

@@ -1,8 +1,8 @@
export default {
"title": "Информация за Закона за батериите",
"intro": "Във връзка с продажбата на батерии или доставката на устройства, съдържащи батерии, ние сме задължени да ви информираме за следното:",
"returnObligation": "Като краен потребител, вие сте законово задължени да връщате използвани батерии. Можете да върнете стари батерии, които ние имаме или сме имали в продуктовия си асортимент като нови батерии, безплатно в нашия склад за изпращане (адрес за доставка).",
"symbolsInfo": "Символите, показани на батериите, имат следното значение:",
"wasteSymbol": "Символът на пресеченото кошче за отпадъци означава, че батерията не трябва да се изхвърля с битовите отпадъци.",
"chemicalSymbols": "Pb = Батерията съдържа повече от 0,004 процента олово по маса\nCd = Батерията съдържа повече от 0,002 процента кадмий по маса\nHg = Батерията съдържа повече от 0,0005 процента живак по маса."
"title": "Указания относно Закона за батериите",
"intro": "Във връзка с продажбата на батерии или доставката на устройства, които съдържат батерии, сме длъжни да Ви информираме за следното:",
"returnObligation": "Като краен потребител Вие сте законово задължени да върнете използваните батерии. Можете да върнете безплатно изразходвани батерии, които предлагаме или сме предлагали като нови батерии в нашия продуктов асортимент, в нашия склад за изпращане (адрес за доставка).",
"symbolsInfo": "Символите, изобразени върху батериите, имат следното значение:",
"wasteSymbol": "Символът на зачеркнатата кофа за отпадъци означава, че батерията не трябва да се изхвърля с битовите отпадъци.",
"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 {
"title": "Политика за поверителност",
"responsibleParty": {
"title": "Отговорно лице по смисъла на закона за защита на данните:",
"title": "Администратор по смисъла на законодателството за защита на данните:",
"company": "Growheads\nTrachenberger Straße 14\n01129 Dresden"
},
"generalInfo": "Освен ако по-долу не е посочено друго, предоставянето на вашите лични данни не е нито законово, нито договорно изискване, нито е необходимо за сключване на договор. Не сте задължени да предоставяте данните. Непредоставянето им няма да има последствия. Това важи само доколкото при следващите обработки не е посочено друго. Лични данни означава всяка информация, отнасяща се до идентифицирано или идентифицируемо физическо лице.",
"generalInfo": "Освен ако по-долу не е посочено друго, предоставянето на вашите лични данни не е нито законово, нито договорно задължително, нито е необходимо за сключването на договор. Вие не сте длъжни да предоставяте данните. Непредоставянето им няма да има последици. Това важи само доколкото в следващите операции по обработване не е посочено друго. \"Лични данни\" означава всяка информация, отнасяща се до идентифицирано или подлежащо на идентифициране физическо лице.",
"sections": {
"informationDeletion": {
"title": "Информация, изтриване, блокиране",
"content": "По всяко време можете да поискате информация за вашите лични данни, техния произход и получатели, както и целта на обработката на данните, и можете безплатно да поискате корекция, блокиране или изтриване на тези данни. Моля, използвайте предоставените възможности за контакт в долния колонтитул на страницата или в правния импресум за тази цел. Ние сме на разположение по всяко време за допълнителни въпроси по темата. Моля, имайте предвид, че не сме упълномощени и няма да изтриваме данни за фактури, банкови данни и данни, които са били изпратени на доставчик на куриерски услуги. Данни, които могат да бъдат изтрити, включват: клиентски акаунти на уеб сървъра, както и в системата за управление на стоките, и имейли, които не са пряко свързани с поръчка."
"content": "По всяко време можете да получите информация относно личните данни, техния произход и получатели, както и целта на обработването на данните, и да поискате безплатно коригиране, блокиране или изтриване на тези данни. Моля, използвайте за тази цел контактните възможности, посочени във футъра на страницата или в правното уведомление. На разположение сме също така по всяко време за допълнителни въпроси по тази тема. Моля, имайте предвид, че нямаме право да и няма да изтриваме данни за фактури, банкови данни и данни, които са били предадени на доставчик на транспортни услуги. Данни, които могат да бъдат изтрити, са: данни за клиентски акаунт на уеб сървъра, както и в системата за управление на стоки, и имейли, които не са пряко свързани с поръчка."
},
"serverLogfiles": {
"title": "Сървърни лог файлове",
"content": "Можете да посещавате нашите уебсайтове без да предоставяте информация за себе си. При всяко посещение на нашия уебсайт, данни за използването се предават от вашия интернет браузър и се съхраняват в лог файлове (сървърни лог файлове). Тези съхранени данни включват например името на посетената страница, дата и час на достъп, количество прехвърлени данни и доставчика, който прави заявката. Тези данни се използват изключително за осигуряване на безпроблемната работа на нашия уебсайт и за подобряване на нашето предложение. Тези данни не са лични данни. Не се извършва обединяване на тези данни с други източници на данни. Ако станем наясно с конкретни индикации за незаконна употреба, си запазваме правото да проверим тези данни впоследствие."
"content": "Можете да посещавате нашите уебсайтове, без да предоставяте лична информация. При всяко посещение на нашия уебсайт чрез вашия интернет браузър се предават данни за използването и се съхраняват в протоколни данни (сървърни лог файлове). Сред тези съхранени данни са например името на посетената страница, датата и часът на достъпа, количеството предадени данни и заявяващият доставчик. Тези данни служат единствено за осигуряване на безпроблемната работа на нашия уебсайт и за подобряване на нашето предложение. Тези данни не са лични данни. Не се извършва обединяване на тези данни с други източници на данни. Ако ни станат известни конкретни индикации за неправомерно използване, си запазваме правото да проверим тези данни със задна дата."
}
}
};

View File

@@ -1,12 +1,17 @@
export default {
"sections": {
"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. Моля, не въвеждайте чувствителни лични данни в чата."
"title": И чатбот (OpenAI API)",
"intro": "Използваме на нашия уебсайт чатбот, подпомаган от изкуствен интелект, който се предоставя чрез OpenAI API, за автоматично отговаряне на запитвания и подобряване на нашата поддръжка.",
"processing": "При използване на чатбота съдържанието, което въвеждате, се предава на OpenAI и се обработва там от наше име, за да се генерира подходящ отговор. Моля, имайте предвид, че въведеното съдържание може да съдържа и лични данни, ако сами предоставите такава информация.",
"legalBasis": "Правното основание за обработването е чл. 6, ал. 1, буква f DSGVO. Нашият легитимен интерес се състои в ефективното обработване на запитвания и в подобряването на нашето онлайн предложение.",
"dataRecipient": "Получател на данните е OpenAI. За потребители в Европейското икономическо пространство и в Швейцария OpenAI Ireland Limited, 1st Floor, The Liffey Trust Centre, 117-126 Sheriff Street Upper, Dublin 1, D01 YC43, Ireland, е съответната договорна страна.",
"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": {
"title": "Бисквитки",
"intro": "Нашият уебсайт използва бисквитки в следните случаи:",
"payment": "1. Процес на плащане: При плащания с кредитна карта или незабавни преводи (напр. Klarna Sofort) се използват технически необходими бисквитки. Те съдържат характерен низ, който позволява уникална идентификация на браузъра. Бисквитките се задават от платежния доставчик Stripe и са абсолютно необходими за сигурното и безпроблемно обработване на плащанията. Без тези бисквитки не е възможно да се направи поръчка с тези методи на плащане. Обработката се извършва на основание чл. 6 (1) т. б DSGVO за изпълнение на договор.",
"googleSSO": "2. Google Single Sign-On (SSO): При използване на вход с Google, Google задава бисквитки, които са необходими за процеса на вход и удостоверяване. Тези бисквитки ви позволяват удобно да влизате с вашия Google акаунт, без да се налага да въвеждате данните си всеки път. Обработката се извършва на основание чл. 6 (1) т. б DSGVO (изпълнение на договор) и чл. 6 (1) т. ф DSGVO (легитимен интерес за удобен вход).",
"otherPayments": "За други методи на плащане директен дебит, вземане на място или наложен платеж не се използват допълнителни бисквитки, освен ако не използвате вход с Google."
"payment": "1. Процес на плащане: При плащания с кредитна карта или незабавни банкови преводи (напр. Klarna Sofort) се използват технически необходими бисквитки. Те съдържат характерна низова последователност от знаци, която позволява браузърът да бъде уникално идентифициран. Бисквитките се задават от платежния доставчик Stripe и са абсолютно необходими за сигурното и безпроблемно обработване на плащанията. Без тези бисквитки не е възможно да се направи поръчка с тези методи на плащане. Обработването се извършва на основание чл. 6, ал. 1, буква б DSGVO за изпълнение на договор.",
"googleSSO": "2. Google Single Sign-On (SSO): При използване на вход с Google бисквитките се задават от Google и са необходими за процеса на вход и удостоверяване. Тези бисквитки ви позволяват удобно да влизате с вашия Google акаунт, без да е нужно да се вписвате отново всеки път. Обработването се извършва на основание чл. 6, ал. 1, буква б DSGVO (изпълнение на договор) и чл. 6, ал. 1, буква f DSGVO (легитимен интерес от лесен за ползване вход).",
"otherPayments": "За други методи на плащане директен дебит, получаване на място или наложен платеж не се използват допълнителни бисквитки, освен ако не използвате вход с Google."
},
"mollie": {
"title": "Mollie (Обработка на плащания)",
"content": "Използваме платежния доставчик Mollie на нашия уебсайт за обработка на плащания. Доставчик на услугата е Mollie B.V., Keizersgracht 126, 1015 CW Amsterdam, Netherlands. В този контекст се предават на Mollie лични данни, необходими за обработка на плащания по-специално вашето име, имейл адрес, адрес за фактуриране, платежна информация (напр. данни за кредитна карта) и IP адрес. Обработката на данни се извършва с цел обработка на плащания; правното основание е чл. 6 ал. 1 т. б DSGVO, тъй като служи за изпълнение на договор с вас.",
"responsibility": "Mollie също така обработва определени данни като самостоятелен администратор, например за изпълнение на законови задължения (като предотвратяване на пране на пари) и за предотвратяване на измами. Освен това сме сключили договор за обработка на данни с Mollie съгласно чл. 28 DSGVO; по силата на този договор Mollie действа изключително по наши инструкции при обработката на плащания.",
"dataTransfer": "Ако Mollie обработва лични данни извън ЕС, особено в САЩ, това се прави при спазване на подходящи гаранции. Mollie използва стандартните договорни клаузи на ЕС съгласно чл. 46 DSGVO, за да осигури адекватно ниво на защита на данните. Въпреки това бихме искали да отбележим, че САЩ се считат за трета държава по смисъла на защитата на данните с потенциално недостатъчно ниво на защита. Допълнителна информация можете да намерите в политиката за поверителност на Mollie на https://www.mollie.com/de/privacy."
"content": "Използваме платежния доставчик Mollie на нашия уебсайт за обработка на плащания. Доставчик на услугата е Mollie B.V., Keizersgracht 126, 1015 CW Amsterdam, Netherlands. В този контекст лични данни, необходими за обработката на плащането, се предават на Mollie - по-специално вашето име, вашият имейл адрес, адрес за фактуриране, платежна информация (напр. данни за кредитна карта), както и IP адресът. Обработването на данните се извършва с цел обработка на плащането; правното основание е чл. 6, ал. 1, буква б DSGVO, тъй като то служи за изпълнение на договор с вас.",
"responsibility": "Mollie също така обработва определени данни като самостоятелен администратор, например за изпълнение на законови задължения (като предотвратяване на изпирането на пари) и за предотвратяване на измами. Освен това сме сключили договор за обработване на данни с Mollie съгласно чл. 28 DSGVO; в рамките на това споразумение Mollie действа изключително по наши указания при обработката на плащания.",
"dataTransfer": "Доколкото Mollie обработва лични данни извън ЕС, по-специално в САЩ, това се извършва при спазване на подходящи гаранции. За тази цел Mollie използва стандартните договорни клаузи на ЕС съгласно чл. 46 DSGVO, за да осигури адекватно ниво на защита на данните. Все пак бихме искали да отбележим, че съгласно правото за защита на данните САЩ се считат за трета държава с потенциално недостатъчно ниво на защита на данните. Допълнителна информация можете да намерите в политиката за поверителност на Mollie на https://www.mollie.com/de/privacy."
}
}
};

View File

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

View File

@@ -1,15 +1,18 @@
export default {
"sections": {
"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."
"title": "Влизане с Google (Google Single Sign-On)",
"content": "Предлагаме Ви възможността да влезете в клиентския си акаунт с Вашия Google акаунт. Ако използвате функцията \"Влизане с Google\", удостоверяването се извършва чрез услугата Google Single Sign-On. При това Google може да съхранява бисквитки на Вашето крайно устройство, които са необходими за процеса на влизане и удостоверяването. В рамките на Google влизането получаваме от Google определени лични данни за потвърждаване на Вашата самоличност. По-специално Google ни предава Вашето име, Вашия имейл адрес и ако са записани във Вашия Google акаунт Вашата профилна снимка. Тази информация се предоставя от Google веднага щом влезете в нашия онлайн магазин с Вашия Google акаунт. Google, като доставчик трета страна, може да има достъп до тези данни и да ги обработва; при това може да се стигне и до предаване на данни в САЩ. Сключили сме Стандартни договорни клаузи с Google съгласно чл. 46, ал. 2, буква c DSGVO, за да осигурим адекватно ниво на защита на данните при предаването на Вашите данни.",
"privacyLinkIntro": "Допълнителни подробности относно обработването на данни от Google можете да намерите в Политиката за поверителност на Google (на ",
"privacyLinkUrl": "https://policies.google.com/privacy?hl=de",
"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": {
"title": "Събиране, обработка и използване на лични данни при поръчки",
"content": "Когато правите поръчка, ние събираме и използваме вашите лични данни само в необходимия обем за изпълнение и обработка на вашата поръчка и за обработка на вашите запитвания. Предоставянето на тези данни е необходимо за сключване на договора. Непредоставянето им води до невъзможност за сключване на договор. Обработката се извършва на основание чл. 6 (1) т. б DSGVO и е необходима за изпълнение на договор с вас. Вашите данни няма да бъдат предавани на трети страни без вашето изрично съгласие. Единствените изключения са нашите партньори по услуги, които са необходими за обработка на договорните отношения, или доставчици, които използваме в рамките на обработка по поръчка. Освен получателите, посочени в съответните клаузи на тази политика за поверителност, това могат да бъдат получатели в следните категории: доставчици на куриерски услуги, доставчици на платежни услуги, доставчици на системи за управление на стоките, доставчици на услуги за обработка на поръчки, уеб хостинг доставчици, IT доставчици и търговци по дропшипинг. При всички случаи стриктно спазваме законовите изисквания. Обемът на предаваните данни е ограничен до минимум."
"title": "Събиране, обработване и използване на лични данни при поръчки",
"content": "Когато направите поръчка, ние събираме и използваме Вашите лични данни само доколкото това е необходимо за изпълнението и обработката на Вашата поръчка, както и за обработването на Вашите запитвания. Предоставянето на данните е необходимо за сключването на договора. Непредоставянето им води до това, че не може да бъде сключен договор. Обработването се извършва на основание чл. 6 (1) буква b DSGVO и е необходимо за изпълнението на договор с Вас. Вашите данни няма да бъдат предавани на трети лица без Вашето изрично съгласие. Изключение от това правят единствено нашите партньори по услуги, които са ни необходими за обработката на договорното правоотношение, или доставчици на услуги, които използваме в рамките на обработване от наше име. Освен получателите, посочени в съответните клаузи на тази Политика за поверителност, това са например получатели от следните категории: доставчици на услуги за доставка, доставчици на платежни услуги, доставчици на системи за управление на стоките, доставчици на услуги за обработка на поръчки, уеб хостове, IT доставчици и търговци, работещи по модела dropshipping. Във всички случаи стриктно спазваме законовите изисквания. Обхватът на предаването на данни е ограничен до минимум."
}
}
};

View File

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

View File

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

View File

@@ -1,16 +1,16 @@
export default {
"sections": {
"dataRetention": {
"title": "Продължителност на съхранение",
"content": "След пълното изпълнение на договора, данните първоначално ще се съхраняват за продължителността на гаранционния срок, а след това с оглед на законовите, по-специално данъчните и търговските срокове за съхранение, и след изтичането на тези срокове ще бъдат изтрити, освен ако не сте дали съгласие за по-нататъшна обработка и използване."
"title": "Срок на съхранение",
"content": "След пълното изпълнение на договора данните първоначално ще бъдат съхранявани за срока на гаранцията, след това с оглед на законовите срокове за съхранение, по-специално съгласно данъчното и търговското право, и след изтичане на тези срокове ще бъдат изтрити, освен ако не сте дали съгласие за по-нататъшна обработка и използване."
},
"dataSubjectRights": {
"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": {
"title": "Право на жалба пред надзорния орган",
"content": "Съгласно чл. 77 DSGVO имате право да подадете жалба пред надзорния орган, ако смятате, че обработката на вашите лични данни не е законна."
"title": "Право на подаване на жалба до надзорния орган",
"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