diff --git a/src/components/ArticleAvailabilityForm.js b/src/components/ArticleAvailabilityForm.js new file mode 100644 index 0000000..62596ec --- /dev/null +++ b/src/components/ArticleAvailabilityForm.js @@ -0,0 +1,242 @@ +import React, { Component } from 'react'; +import { + Box, + Typography, + TextField, + Button, + Paper, + Alert, + CircularProgress, + FormControl, + FormLabel, + RadioGroup, + FormControlLabel, + Radio +} from '@mui/material'; +import { withI18n } from '../i18n/withTranslation.js'; + +class ArticleAvailabilityForm extends Component { + constructor(props) { + super(props); + this.state = { + name: '', + email: '', + telegramId: '', + notificationMethod: 'email', + message: '', + loading: false, + success: false, + error: null + }; + } + + handleInputChange = (field) => (event) => { + this.setState({ [field]: event.target.value }); + }; + + handleNotificationMethodChange = (event) => { + this.setState({ + notificationMethod: event.target.value, + // Clear the other field when switching methods + email: event.target.value === 'email' ? this.state.email : '', + telegramId: event.target.value === 'telegram' ? this.state.telegramId : '' + }); + }; + + handleSubmit = (event) => { + event.preventDefault(); + + // Prepare data for API emission + const availabilityData = { + type: 'availability_inquiry', + productId: this.props.productId, + productName: this.props.productName, + name: this.state.name, + notificationMethod: this.state.notificationMethod, + email: this.state.notificationMethod === 'email' ? this.state.email : '', + telegramId: this.state.notificationMethod === 'telegram' ? this.state.telegramId : '', + message: this.state.message, + timestamp: new Date().toISOString() + }; + + // Emit data via socket + console.log('Availability Inquiry Data to emit:', availabilityData); + + if (this.props.socket) { + this.props.socket.emit('availability_inquiry_submit', availabilityData); + + // Set up response handler + this.props.socket.once('availability_inquiry_response', (response) => { + if (response.success) { + this.setState({ + loading: false, + success: true, + name: '', + email: '', + telegramId: '', + notificationMethod: 'email', + message: '' + }); + } else { + this.setState({ + loading: false, + error: response.error || 'Ein Fehler ist aufgetreten' + }); + } + + // Clear messages after 3 seconds + setTimeout(() => { + this.setState({ success: false, error: null }); + }, 3000); + }); + } + + this.setState({ loading: true }); + + // Fallback timeout in case backend doesn't respond + setTimeout(() => { + if (this.state.loading) { + this.setState({ + loading: false, + success: true, + name: '', + email: '', + telegramId: '', + notificationMethod: 'email', + message: '' + }); + + // Clear success message after 3 seconds + setTimeout(() => { + this.setState({ success: false }); + }, 3000); + } + }, 5000); + }; + + render() { + const { name, email, telegramId, notificationMethod, message, loading, success, error } = this.state; + + return ( + + + Verfügbarkeit anfragen + + + + Dieser Artikel ist derzeit nicht verfügbar. Gerne informieren wir Sie, sobald er wieder lieferbar ist. + + + {success && ( + + Vielen Dank für Ihre Anfrage! Wir werden Sie {notificationMethod === 'email' ? 'per E-Mail' : 'über Telegram'} informieren, sobald der Artikel wieder verfügbar ist. + + )} + + {error && ( + + {error} + + )} + + + + + + + Wie möchten Sie benachrichtigt werden? + + + } + label="E-Mail" + /> + } + label="Telegram Bot" + /> + + + + {notificationMethod === 'email' && ( + + )} + + {notificationMethod === 'telegram' && ( + + )} + + + + + + + ); + } +} + +export default withI18n()(ArticleAvailabilityForm); \ No newline at end of file diff --git a/src/components/ArticleQuestionForm.js b/src/components/ArticleQuestionForm.js new file mode 100644 index 0000000..dabd97a --- /dev/null +++ b/src/components/ArticleQuestionForm.js @@ -0,0 +1,225 @@ +import React, { Component } from 'react'; +import { + Box, + Typography, + TextField, + Button, + Paper, + Alert, + CircularProgress +} from '@mui/material'; +import { withI18n } from '../i18n/withTranslation.js'; +import PhotoUpload from './PhotoUpload.js'; + +class ArticleQuestionForm extends Component { + constructor(props) { + super(props); + this.state = { + name: '', + email: '', + question: '', + photos: [], + loading: false, + success: false, + error: null + }; + } + + handleInputChange = (field) => (event) => { + this.setState({ [field]: event.target.value }); + }; + + handlePhotosChange = (files) => { + this.setState({ photos: files }); + }; + + convertPhotosToBase64 = (photos) => { + return Promise.all( + photos.map(photo => { + return new Promise((resolve, _reject) => { + const reader = new FileReader(); + reader.onload = (e) => { + resolve({ + name: photo.name, + type: photo.type, + size: photo.size, + data: e.target.result // base64 string + }); + }; + reader.readAsDataURL(photo); + }); + }) + ); + }; + + handleSubmit = async (event) => { + event.preventDefault(); + + this.setState({ loading: true }); + + try { + // Convert photos to base64 + const photosBase64 = await this.convertPhotosToBase64(this.state.photos); + + // Prepare data for API emission + const questionData = { + type: 'article_question', + productId: this.props.productId, + productName: this.props.productName, + name: this.state.name, + email: this.state.email, + question: this.state.question, + photos: photosBase64, + timestamp: new Date().toISOString() + }; + + // Emit data via socket + console.log('Article Question Data to emit:', questionData); + + if (this.props.socket) { + this.props.socket.emit('article_question_submit', questionData); + + // Set up response handler + this.props.socket.once('article_question_response', (response) => { + if (response.success) { + this.setState({ + loading: false, + success: true, + name: '', + email: '', + question: '', + photos: [] + }); + } else { + this.setState({ + loading: false, + error: response.error || 'Ein Fehler ist aufgetreten' + }); + } + + // Clear messages after 3 seconds + setTimeout(() => { + this.setState({ success: false, error: null }); + }, 3000); + }); + } + } catch { + this.setState({ + loading: false, + error: 'Fehler beim Verarbeiten der Fotos' + }); + } + + // Fallback timeout in case backend doesn't respond + setTimeout(() => { + if (this.state.loading) { + this.setState({ + loading: false, + success: true, + name: '', + email: '', + question: '', + photos: [] + }); + + // Clear success message after 3 seconds + setTimeout(() => { + this.setState({ success: false }); + }, 3000); + } + }, 5000); + }; + + render() { + const { name, email, question, loading, success, error } = this.state; + + return ( + + + Frage zum Artikel + + + + Haben Sie eine Frage zu diesem Artikel? Wir helfen Ihnen gerne weiter. + + + {success && ( + + Vielen Dank für Ihre Frage! Wir werden uns schnellstmöglich bei Ihnen melden. + + )} + + {error && ( + + {error} + + )} + + + + + + + + + + + + + + ); + } +} + +export default withI18n()(ArticleQuestionForm); \ No newline at end of file diff --git a/src/components/ArticleRatingForm.js b/src/components/ArticleRatingForm.js new file mode 100644 index 0000000..b3bd91f --- /dev/null +++ b/src/components/ArticleRatingForm.js @@ -0,0 +1,254 @@ +import React, { Component } from 'react'; +import { + Box, + Typography, + TextField, + Button, + Paper, + Alert, + CircularProgress, + Rating +} from '@mui/material'; +import StarIcon from '@mui/icons-material/Star'; +import { withI18n } from '../i18n/withTranslation.js'; +import PhotoUpload from './PhotoUpload.js'; + +class ArticleRatingForm extends Component { + constructor(props) { + super(props); + this.state = { + name: '', + email: '', + rating: 0, + review: '', + photos: [], + loading: false, + success: false, + error: null + }; + } + + handleInputChange = (field) => (event) => { + this.setState({ [field]: event.target.value }); + }; + + handleRatingChange = (event, newValue) => { + this.setState({ rating: newValue }); + }; + + handlePhotosChange = (files) => { + this.setState({ photos: files }); + }; + + convertPhotosToBase64 = (photos) => { + return Promise.all( + photos.map(photo => { + return new Promise((resolve, _reject) => { + const reader = new FileReader(); + reader.onload = (e) => { + resolve({ + name: photo.name, + type: photo.type, + size: photo.size, + data: e.target.result // base64 string + }); + }; + reader.readAsDataURL(photo); + }); + }) + ); + }; + + handleSubmit = async (event) => { + event.preventDefault(); + + this.setState({ loading: true }); + + try { + // Convert photos to base64 + const photosBase64 = await this.convertPhotosToBase64(this.state.photos); + + // Prepare data for API emission + const ratingData = { + type: 'article_rating', + productId: this.props.productId, + productName: this.props.productName, + name: this.state.name, + email: this.state.email, + rating: this.state.rating, + review: this.state.review, + photos: photosBase64, + timestamp: new Date().toISOString() + }; + + // Emit data via socket + console.log('Article Rating Data to emit:', ratingData); + + if (this.props.socket) { + this.props.socket.emit('article_rating_submit', ratingData); + + // Set up response handler + this.props.socket.once('article_rating_response', (response) => { + if (response.success) { + this.setState({ + loading: false, + success: true, + name: '', + email: '', + rating: 0, + review: '', + photos: [] + }); + } else { + this.setState({ + loading: false, + error: response.error || 'Ein Fehler ist aufgetreten' + }); + } + + // Clear messages after 3 seconds + setTimeout(() => { + this.setState({ success: false, error: null }); + }, 3000); + }); + } + } catch { + this.setState({ + loading: false, + error: 'Fehler beim Verarbeiten der Fotos' + }); + } + + // Fallback timeout in case backend doesn't respond + setTimeout(() => { + if (this.state.loading) { + this.setState({ + loading: false, + success: true, + name: '', + email: '', + rating: 0, + review: '', + photos: [] + }); + + // Clear success message after 3 seconds + setTimeout(() => { + this.setState({ success: false }); + }, 3000); + } + }, 5000); + }; + + render() { + const { name, email, rating, review, loading, success, error } = this.state; + + return ( + + + Artikel Bewerten + + + + Teilen Sie Ihre Erfahrungen mit diesem Artikel und helfen Sie anderen Kunden bei der Entscheidung. + + + {success && ( + + Vielen Dank für Ihre Bewertung! Sie wird nach Prüfung veröffentlicht. + + )} + + {error && ( + + {error} + + )} + + + + + + + + + Bewertung * + + + } + /> + + {rating > 0 ? `${rating} von 5 Sternen` : 'Bitte bewerten'} + + + + + + + + + + + + ); + } +} + +export default withI18n()(ArticleRatingForm); \ No newline at end of file diff --git a/src/components/PhotoUpload.js b/src/components/PhotoUpload.js new file mode 100644 index 0000000..fc2e591 --- /dev/null +++ b/src/components/PhotoUpload.js @@ -0,0 +1,272 @@ +import React, { Component } from 'react'; +import { + Box, + Button, + Typography, + IconButton, + Paper, + Grid, + Alert +} from '@mui/material'; +import { + Delete, + CloudUpload +} from '@mui/icons-material'; + +class PhotoUpload extends Component { + constructor(props) { + super(props); + this.state = { + files: [], + previews: [], + error: null + }; + this.fileInputRef = React.createRef(); + } + + handleFileSelect = (event) => { + const selectedFiles = Array.from(event.target.files); + const maxFiles = this.props.maxFiles || 5; + const maxSize = this.props.maxSize || 50 * 1024 * 1024; // 50MB default - will be compressed + + // Validate file count + if (this.state.files.length + selectedFiles.length > maxFiles) { + this.setState({ + error: `Maximal ${maxFiles} Dateien erlaubt` + }); + return; + } + + // Validate file types and sizes + const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; + const validFiles = []; + const newPreviews = []; + + for (const file of selectedFiles) { + if (!validTypes.includes(file.type)) { + this.setState({ + error: 'Nur Bilddateien (JPEG, PNG, GIF, WebP) sind erlaubt' + }); + continue; + } + + if (file.size > maxSize) { + this.setState({ + error: `Datei zu groß. Maximum: ${Math.round(maxSize / (1024 * 1024))}MB` + }); + continue; + } + + validFiles.push(file); + + // Create preview and compress image + const reader = new FileReader(); + reader.onload = (e) => { + // Compress the image + this.compressImage(e.target.result, file.name, (compressedFile) => { + newPreviews.push({ + file: compressedFile, + preview: e.target.result, + name: file.name, + originalSize: file.size, + compressedSize: compressedFile.size + }); + + if (newPreviews.length === validFiles.length) { + const compressedFiles = newPreviews.map(p => p.file); + this.setState(prevState => ({ + files: [...prevState.files, ...compressedFiles], + previews: [...prevState.previews, ...newPreviews], + error: null + }), () => { + // Notify parent component + if (this.props.onChange) { + this.props.onChange(this.state.files); + } + }); + } + }); + }; + reader.readAsDataURL(file); + } + + // Reset input + event.target.value = ''; + }; + + handleRemoveFile = (index) => { + this.setState(prevState => { + const newFiles = prevState.files.filter((_, i) => i !== index); + const newPreviews = prevState.previews.filter((_, i) => i !== index); + + // Notify parent component + if (this.props.onChange) { + this.props.onChange(newFiles); + } + + return { + files: newFiles, + previews: newPreviews + }; + }); + }; + + compressImage = (dataURL, fileName, callback) => { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + const img = new Image(); + + img.onload = () => { + // Calculate new dimensions (max 1920x1080 for submission) + const maxWidth = 1920; + const maxHeight = 1080; + let { width, height } = img; + + if (width > height) { + if (width > maxWidth) { + height = (height * maxWidth) / width; + width = maxWidth; + } + } else { + if (height > maxHeight) { + width = (width * maxHeight) / height; + height = maxHeight; + } + } + + canvas.width = width; + canvas.height = height; + + // Draw and compress + ctx.drawImage(img, 0, 0, width, height); + + // Convert to blob with compression + canvas.toBlob((blob) => { + const compressedFile = new File([blob], fileName, { + type: 'image/jpeg', + lastModified: Date.now() + }); + callback(compressedFile); + }, 'image/jpeg', 0.8); // 80% quality + }; + + img.src = dataURL; + }; + + render() { + const { files, previews, error } = this.state; + const { disabled, label } = this.props; + + return ( + + + {label || 'Fotos anhängen (optional)'} + + + + + + + {error && ( + + {error} + + )} + + {previews.length > 0 && ( + + {previews.map((preview, index) => ( + + + + this.handleRemoveFile(index)} + disabled={disabled} + sx={{ + position: 'absolute', + top: 4, + right: 4, + backgroundColor: 'rgba(0,0,0,0.7)', + color: 'white', + '&:hover': { + backgroundColor: 'rgba(0,0,0,0.9)' + } + }} + > + + + + {preview.name} + + + + ))} + + )} + + {files.length > 0 && ( + + {files.length} Datei(en) ausgewählt + {previews.length > 0 && previews.some(p => p.originalSize && p.compressedSize) && ( + + (komprimiert für Upload) + + )} + + )} + + ); + } +} + +export default PhotoUpload; \ No newline at end of file diff --git a/src/components/ProductDetailPage.js b/src/components/ProductDetailPage.js index b974394..1b7addd 100644 --- a/src/components/ProductDetailPage.js +++ b/src/components/ProductDetailPage.js @@ -1,10 +1,13 @@ import React, { Component } from "react"; -import { Box, Typography, CardMedia, Stack, Chip } from "@mui/material"; +import { Box, Typography, CardMedia, Stack, Chip, Button, Collapse } from "@mui/material"; import { Link } from "react-router-dom"; import parse from "html-react-parser"; import AddToCartButton from "./AddToCartButton.js"; import Images from "./Images.js"; import { withI18n } from "../i18n/withTranslation.js"; +import ArticleQuestionForm from "./ArticleQuestionForm.js"; +import ArticleRatingForm from "./ArticleRatingForm.js"; +import ArticleAvailabilityForm from "./ArticleAvailabilityForm.js"; // Utility function to clean product names by removing trailing number in parentheses const cleanProductName = (name) => { @@ -35,7 +38,11 @@ class ProductDetailPage extends Component { komponentenData: {}, // Store individual komponent data with loading states komponentenImages: {}, // Store tiny pictures for komponenten totalKomponentenPrice: 0, - totalSavings: 0 + totalSavings: 0, + // Collapsible sections state + showQuestionForm: false, + showRatingForm: false, + showAvailabilityForm: false }; } else { this.state = { @@ -51,7 +58,11 @@ class ProductDetailPage extends Component { komponentenData: {}, // Store individual komponent data with loading states komponentenImages: {}, // Store tiny pictures for komponenten totalKomponentenPrice: 0, - totalSavings: 0 + totalSavings: 0, + // Collapsible sections state + showQuestionForm: false, + showRatingForm: false, + showAvailabilityForm: false }; } } @@ -459,6 +470,52 @@ class ProductDetailPage extends Component { this.setState({ imageDialogOpen: false }); }; + toggleQuestionForm = () => { + this.setState(prevState => ({ + showQuestionForm: !prevState.showQuestionForm, + showRatingForm: false, + showAvailabilityForm: false + }), () => { + if (this.state.showQuestionForm) { + setTimeout(() => this.scrollToSection('question-form'), 100); + } + }); + }; + + toggleRatingForm = () => { + this.setState(prevState => ({ + showRatingForm: !prevState.showRatingForm, + showQuestionForm: false, + showAvailabilityForm: false + }), () => { + if (this.state.showRatingForm) { + setTimeout(() => this.scrollToSection('rating-form'), 100); + } + }); + }; + + toggleAvailabilityForm = () => { + this.setState(prevState => ({ + showAvailabilityForm: !prevState.showAvailabilityForm, + showQuestionForm: false, + showRatingForm: false + }), () => { + if (this.state.showAvailabilityForm) { + setTimeout(() => this.scrollToSection('availability-form'), 100); + } + }); + }; + + scrollToSection = (sectionId) => { + const element = document.getElementById(sectionId); + if (element) { + element.scrollIntoView({ + behavior: 'smooth', + block: 'start' + }); + } + }; + render() { const { product, loading, error, attributeImages, isSteckling, attributes, komponentenLoaded, komponentenData, komponentenImages, totalKomponentenPrice, totalSavings } = this.state; @@ -658,35 +715,91 @@ class ProductDetailPage extends Component { )} - {/* Attribute images and chips */} + {/* Attribute images and chips with action buttons */} {(attributes.some(attr => attributeImages[attr.kMerkmalWert]) || attributes.some(attr => !attributeImages[attr.kMerkmalWert])) && ( - - {attributes - .filter(attribute => attributeImages[attribute.kMerkmalWert]) - .map((attribute) => { - const key = attribute.kMerkmalWert; - return ( - - - - ); - })} - {attributes - .filter(attribute => !attributeImages[attribute.kMerkmalWert]) - .map((attribute) => ( - - ))} - + + + {attributes + .filter(attribute => attributeImages[attribute.kMerkmalWert]) + .map((attribute) => { + const key = attribute.kMerkmalWert; + return ( + + + + ); + })} + {attributes + .filter(attribute => !attributeImages[attribute.kMerkmalWert]) + .map((attribute) => ( + + ))} + + + {/* Right-aligned action buttons */} + + + + {(product.available !== 1 && product.availableSupplier !== 1) && ( + + )} + + )} {/* Weight */} @@ -888,6 +1001,39 @@ class ProductDetailPage extends Component { )} + {/* Article Question Form */} + +
+ +
+
+ + {/* Article Rating Form */} + +
+ +
+
+ + {/* Article Availability Form - only show for out of stock items */} + {(product.available !== 1 && product.availableSupplier !== 1) && ( + + + + )} + {product.komponenten && product.komponenten.split(",").length > 0 && ( {this.props.t ? this.props.t('product.consistsOf') : 'Bestehend aus:'}