Rating added
This commit is contained in:
162
ordering_site/src/components/FeedbackModal.css
Normal file
162
ordering_site/src/components/FeedbackModal.css
Normal file
@@ -0,0 +1,162 @@
|
||||
.feedback-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(8px);
|
||||
z-index: 5000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.feedback-modal {
|
||||
background: white;
|
||||
width: 100%;
|
||||
max-width: 450px;
|
||||
border-radius: 32px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.feedback-header {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
background: linear-gradient(135deg, var(--primary) 0%, #4338ca 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.feedback-header h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.feedback-header p {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.9;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.feedback-body {
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.rating-item-card {
|
||||
background: var(--bg);
|
||||
border-radius: 20px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.rating-item-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.rating-item-name {
|
||||
font-weight: 700;
|
||||
color: var(--text-dark);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.stars-container {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.star-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.star-btn.active {
|
||||
color: #fbbf24;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.star-btn:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.feedback-comment-section {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.feedback-comment-section label {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-mid);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.feedback-comment-section textarea {
|
||||
width: 100%;
|
||||
border-radius: 16px;
|
||||
border: 2px solid var(--border);
|
||||
padding: 12px 16px;
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
resize: none;
|
||||
min-height: 80px;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.feedback-comment-section textarea:focus {
|
||||
border-color: var(--primary);
|
||||
background: white;
|
||||
}
|
||||
|
||||
.feedback-footer {
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
background: white;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-submit-feedback {
|
||||
flex: 2;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(79, 70, 229, 0.3);
|
||||
}
|
||||
|
||||
.btn-submit-feedback:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-skip-feedback {
|
||||
flex: 1;
|
||||
background: var(--bg);
|
||||
color: var(--text-mid);
|
||||
border: 1px solid var(--border);
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
}
|
||||
141
ordering_site/src/components/FeedbackModal.tsx
Normal file
141
ordering_site/src/components/FeedbackModal.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Star, Send, X } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import './FeedbackModal.css';
|
||||
|
||||
interface OrderItem {
|
||||
productId: number;
|
||||
productName: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
interface Order {
|
||||
id: number;
|
||||
orderNumber: string;
|
||||
items: OrderItem[];
|
||||
}
|
||||
|
||||
interface FeedbackModalProps {
|
||||
order: Order;
|
||||
userName: string;
|
||||
userId: number;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: any) => Promise<void>;
|
||||
}
|
||||
|
||||
const FeedbackModal: React.FC<FeedbackModalProps> = ({ order, userName, userId, onClose, onSubmit }) => {
|
||||
const [ratings, setRatings] = useState<Record<number, number>>(
|
||||
order.items.reduce((acc, item) => ({ ...acc, [item.productId]: 0 }), {})
|
||||
);
|
||||
const [comment, setComment] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSetRating = (productId: number, rating: number) => {
|
||||
setRatings(prev => ({ ...prev, [productId]: rating }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Check if at least one item is rated
|
||||
const hasAtLeastOneRating = Object.values(ratings).some(r => r > 0);
|
||||
if (!hasAtLeastOneRating) {
|
||||
alert('Please provide at least one rating');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
const itemRatings = order.items.map(item => ({
|
||||
productId: item.productId,
|
||||
productName: item.productName,
|
||||
rating: ratings[item.productId] || 0
|
||||
}));
|
||||
|
||||
const feedbackData = {
|
||||
order: { id: order.id },
|
||||
userId: userId,
|
||||
userName: userName,
|
||||
itemRatings: itemRatings,
|
||||
comment: comment
|
||||
};
|
||||
|
||||
try {
|
||||
await onSubmit(feedbackData);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error submitting feedback:', error);
|
||||
alert('Failed to submit feedback. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="feedback-overlay" onClick={onClose}>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
className="feedback-modal"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="feedback-header">
|
||||
<h2>Rate Your Meal</h2>
|
||||
<p>How was your experience with Order #{order.orderNumber.split('-')[1]}?</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="feedback-body">
|
||||
{order.items.map((item) => (
|
||||
<div key={item.productId} className="rating-item-card">
|
||||
<div className="rating-item-info">
|
||||
<span className="rating-item-name">{item.productName}</span>
|
||||
<span className="text-[10px] font-bold text-text-muted uppercase tracking-widest opacity-60">
|
||||
{item.quantity} {item.quantity > 1 ? 'Units' : 'Unit'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="stars-container">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
className={`star-btn ${ratings[item.productId] >= star ? 'active' : ''}`}
|
||||
onClick={() => handleSetRating(item.productId, star)}
|
||||
>
|
||||
<Star size={32} fill={ratings[item.productId] >= star ? "currentColor" : "none"} strokeWidth={2} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="feedback-comment-section">
|
||||
<label>Additional Comments</label>
|
||||
<textarea
|
||||
placeholder="What did you like or what can we improve?"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="feedback-footer">
|
||||
<button type="button" className="btn-skip-feedback" onClick={onClose}>
|
||||
Skip
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-submit-feedback"
|
||||
disabled={isSubmitting}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{isSubmitting ? 'Sending...' : 'Submit Rating'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeedbackModal;
|
||||
@@ -48,6 +48,7 @@ export const FoodProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
// Map BaseItems to Categories
|
||||
const mappedCategories: Category[] = baseItemsData.map((item: any, index: number) => ({
|
||||
id: item.id.toString(),
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
emoji: EMOJIS[index % EMOJIS.length],
|
||||
image: item.imageData?.trim() ? (item.imageData.trim().startsWith('data:') ? item.imageData.trim() : `data:image/png;base64,${item.imageData.trim()}`) : '',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useParams } from 'react-router-dom';
|
||||
import Header from '../components/Header';
|
||||
import ItemCard from '../components/ItemCard';
|
||||
import CartTab from '../components/CartTab';
|
||||
import BottomNav from '../components/BottomNav';
|
||||
import { useFood } from '../contexts/FoodContext';
|
||||
|
||||
const CategoryScreen: React.FC = () => {
|
||||
@@ -39,6 +40,7 @@ const CategoryScreen: React.FC = () => {
|
||||
</div>
|
||||
</main>
|
||||
<CartTab />
|
||||
<BottomNav />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -273,3 +273,62 @@
|
||||
text-align: center;
|
||||
color: var(--text-mid);
|
||||
}
|
||||
|
||||
/* Feedback Snackbar */
|
||||
.feedback-snackbar {
|
||||
position: fixed;
|
||||
bottom: 80px; /* Above bottom nav */
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
|
||||
border: 1px solid var(--primary-light);
|
||||
z-index: 4000;
|
||||
cursor: pointer;
|
||||
animation: snackbarSlideUp 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
@keyframes snackbarSlideUp {
|
||||
from { transform: translateY(100px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.feedback-snackbar-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.feedback-snackbar-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: var(--primary-light);
|
||||
color: var(--primary);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.feedback-snackbar-text h4 {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
color: var(--text-dark);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.feedback-snackbar-text p {
|
||||
font-size: 11px;
|
||||
color: var(--text-mid);
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.feedback-snackbar-action {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Search, RefreshCcw, X } from 'lucide-react';
|
||||
import { Search, RefreshCcw, X, Star, ChevronRight } from 'lucide-react';
|
||||
import Header from '../components/Header';
|
||||
import ItemCard from '../components/ItemCard';
|
||||
import CartTab from '../components/CartTab';
|
||||
import BottomNav from '../components/BottomNav';
|
||||
import FeedbackModal from '../components/FeedbackModal';
|
||||
import { useFood } from '../contexts/FoodContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import './HomeScreen.css';
|
||||
|
||||
const HomeScreen: React.FC = () => {
|
||||
@@ -14,6 +16,74 @@ const HomeScreen: React.FC = () => {
|
||||
const { categories, stalls, foodItems, isLoading, error, refreshData } = useFood();
|
||||
const { user } = useAuth();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Feedback state
|
||||
const [unratedOrder, setUnratedOrder] = useState<any>(null);
|
||||
const [showFeedbackModal, setShowFeedbackModal] = useState(false);
|
||||
const [showFeedbackSnackbar, setShowFeedbackSnackbar] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.id) {
|
||||
checkForUnratedOrder();
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const checkForUnratedOrder = async () => {
|
||||
// Don't show if already dismissed in this session
|
||||
if (sessionStorage.getItem('feedback_dismissed')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`http://${window.location.hostname}:8080/api/feedback/latest-unrated/${user?.id}`);
|
||||
if (response.status === 200) {
|
||||
const order = await response.json();
|
||||
console.log('Unrated order found:', order);
|
||||
setUnratedOrder(order);
|
||||
setShowFeedbackSnackbar(true);
|
||||
} else {
|
||||
console.log('No unrated orders or error status:', response.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking for unrated orders:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkipFeedback = async () => {
|
||||
if (!unratedOrder) return;
|
||||
|
||||
try {
|
||||
await fetch(`http://${window.location.hostname}:8080/api/feedback/skip/${unratedOrder.id}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
setShowFeedbackModal(false);
|
||||
setShowFeedbackSnackbar(false);
|
||||
setUnratedOrder(null);
|
||||
sessionStorage.setItem('feedback_dismissed', 'true');
|
||||
} catch (error) {
|
||||
console.error('Error skipping feedback:', error);
|
||||
// Fallback: still hide it locally
|
||||
setShowFeedbackModal(false);
|
||||
setShowFeedbackSnackbar(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFeedbackSubmit = async (feedbackData: any) => {
|
||||
try {
|
||||
const response = await fetch(`http://${window.location.hostname}:8080/api/feedback/submit`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(feedbackData)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setShowFeedbackModal(false);
|
||||
setShowFeedbackSnackbar(false);
|
||||
setUnratedOrder(null);
|
||||
sessionStorage.setItem('feedback_dismissed', 'true'); // Don't show for others in this session
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const popularItems = useMemo(() => foodItems.filter(item => item.isPopular), [foodItems]);
|
||||
|
||||
@@ -179,6 +249,43 @@ const HomeScreen: React.FC = () => {
|
||||
</main>
|
||||
<CartTab />
|
||||
<BottomNav />
|
||||
|
||||
<AnimatePresence>
|
||||
{showFeedbackSnackbar && unratedOrder && !showFeedbackModal && (
|
||||
<motion.div
|
||||
initial={{ y: 100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 100, opacity: 0 }}
|
||||
className="feedback-snackbar"
|
||||
onClick={() => setShowFeedbackModal(true)}
|
||||
>
|
||||
<div className="feedback-snackbar-content">
|
||||
<div className="feedback-snackbar-icon">
|
||||
<Star size={20} fill="currentColor" />
|
||||
</div>
|
||||
<div className="feedback-snackbar-text">
|
||||
<h4>Rate your last meal</h4>
|
||||
<p>Order #{unratedOrder.displayOrderId || unratedOrder.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="feedback-snackbar-action">
|
||||
<ChevronRight size={20} />
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{showFeedbackModal && unratedOrder && (
|
||||
<FeedbackModal
|
||||
order={unratedOrder}
|
||||
userId={user?.id || 0}
|
||||
userName={user?.name || 'User'}
|
||||
onClose={handleSkipFeedback}
|
||||
onSubmit={handleFeedbackSubmit}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user