From 47a882b66782b8d465ba799c6268d2356e4b5715 Mon Sep 17 00:00:00 2001 From: sebseb7 Date: Fri, 18 Jul 2025 11:56:37 +0200 Subject: [PATCH] Add article interaction forms: Implemented ArticleAvailabilityForm, ArticleQuestionForm, and ArticleRatingForm components for user inquiries, ratings, and availability requests. Integrated photo upload functionality and enhanced user experience with collapsible sections in ProductDetailPage for better interaction with product details. --- src/components/ArticleAvailabilityForm.js | 242 +++++++++++++++++++ src/components/ArticleQuestionForm.js | 225 ++++++++++++++++++ src/components/ArticleRatingForm.js | 254 ++++++++++++++++++++ src/components/PhotoUpload.js | 272 ++++++++++++++++++++++ src/components/ProductDetailPage.js | 208 ++++++++++++++--- 5 files changed, 1170 insertions(+), 31 deletions(-) create mode 100644 src/components/ArticleAvailabilityForm.js create mode 100644 src/components/ArticleQuestionForm.js create mode 100644 src/components/ArticleRatingForm.js create mode 100644 src/components/PhotoUpload.js 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:'}