Rating added
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
package com.rit.canteen.sales.controller;
|
||||
|
||||
import com.rit.canteen.sales.model.Feedback;
|
||||
import com.rit.canteen.sales.model.ItemRating;
|
||||
import com.rit.canteen.sales.model.Order;
|
||||
import com.rit.canteen.sales.repository.FeedbackRepository;
|
||||
import com.rit.canteen.sales.repository.OrderRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/feedback")
|
||||
public class FeedbackController {
|
||||
|
||||
@Autowired
|
||||
private FeedbackRepository feedbackRepository;
|
||||
|
||||
@Autowired
|
||||
private OrderRepository orderRepository;
|
||||
|
||||
@GetMapping
|
||||
public Page<Feedback> getAllFeedback(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size) {
|
||||
return feedbackRepository.findAllByOrderByCreatedAtDesc(PageRequest.of(page, size));
|
||||
}
|
||||
|
||||
@GetMapping("/item-details")
|
||||
public Page<Map<String, Object>> getItemDetails(
|
||||
@RequestParam String productName,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size) {
|
||||
Page<ItemRating> ratings = feedbackRepository.findByProductName(productName, PageRequest.of(page, size));
|
||||
return ratings.map(r -> {
|
||||
Map<String, Object> map = new HashMap<>();
|
||||
map.put("rating", r.getRating());
|
||||
|
||||
// Priority: Item-specific comment -> parent Feedback comment -> empty
|
||||
String comment = r.getComment();
|
||||
if (comment == null || comment.isBlank()) {
|
||||
if (r.getFeedback() != null) {
|
||||
comment = r.getFeedback().getComment();
|
||||
}
|
||||
}
|
||||
|
||||
map.put("comment", comment != null ? comment : "");
|
||||
map.put("date", r.getFeedback() != null ? r.getFeedback().getCreatedAt() : java.time.LocalDateTime.now());
|
||||
return map;
|
||||
});
|
||||
}
|
||||
|
||||
@GetMapping("/stats")
|
||||
public Map<String, Object> getFeedbackStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
|
||||
Double avg = feedbackRepository.getAverageRating();
|
||||
stats.put("averageRating", avg != null ? avg : 0.0);
|
||||
|
||||
List<Object[]> distribution = feedbackRepository.getRatingDistribution();
|
||||
List<Map<String, Object>> distList = new ArrayList<>();
|
||||
for (Object[] row : distribution) {
|
||||
Map<String, Object> item = new HashMap<>();
|
||||
item.put("rating", row[0]);
|
||||
item.put("count", row[1]);
|
||||
distList.add(item);
|
||||
}
|
||||
stats.put("distribution", distList);
|
||||
|
||||
List<Object[]> topRated = feedbackRepository.getTopRatedItems();
|
||||
List<Map<String, Object>> topList = new ArrayList<>();
|
||||
for (Object[] row : topRated) {
|
||||
Map<String, Object> item = new HashMap<>();
|
||||
item.put("name", row[0]);
|
||||
item.put("average", row[1]);
|
||||
item.put("count", row[2]);
|
||||
topList.add(item);
|
||||
}
|
||||
stats.put("ratedItems", topList);
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
@GetMapping("/latest-unrated/{userId}")
|
||||
public ResponseEntity<Order> getLatestUnratedOrder(@PathVariable Long userId) {
|
||||
// Look for any order that is either PAID or COMPLETED
|
||||
Optional<Order> latestOrderOpt = orderRepository.findFirstByUserIdOrderByCreatedAtDesc(userId);
|
||||
|
||||
if (latestOrderOpt.isPresent()) {
|
||||
Order order = latestOrderOpt.get();
|
||||
String status = order.getStatus().toUpperCase();
|
||||
if ((status.equals("PAID") || status.equals("COMPLETED")) && !order.isHasFeedback()) {
|
||||
return ResponseEntity.ok(order);
|
||||
}
|
||||
}
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping("/skip/{orderId}")
|
||||
@Transactional
|
||||
public ResponseEntity<Void> skipFeedback(@PathVariable Long orderId) {
|
||||
Optional<Order> orderOpt = orderRepository.findById(orderId);
|
||||
if (orderOpt.isPresent()) {
|
||||
Order order = orderOpt.get();
|
||||
order.setHasFeedback(true);
|
||||
orderRepository.save(order);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
@PostMapping("/submit")
|
||||
@Transactional
|
||||
public ResponseEntity<Feedback> submitFeedback(@RequestBody Feedback feedback) {
|
||||
if (feedback.getOrder() == null || feedback.getOrder().getId() == null) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
Optional<Order> orderOpt = orderRepository.findById(feedback.getOrder().getId());
|
||||
if (orderOpt.isEmpty()) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
Order order = orderOpt.get();
|
||||
feedback.setOrder(order);
|
||||
|
||||
// Link item ratings to feedback and propagate comment
|
||||
if (feedback.getItemRatings() != null) {
|
||||
for (ItemRating rating : feedback.getItemRatings()) {
|
||||
rating.setFeedback(feedback);
|
||||
// Propagate the general comment to each item for better visibility in item-wise dashboard
|
||||
if (rating.getComment() == null || rating.getComment().isBlank()) {
|
||||
rating.setComment(feedback.getComment());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Feedback savedFeedback = feedbackRepository.save(feedback);
|
||||
|
||||
// Mark order as rated
|
||||
order.setHasFeedback(true);
|
||||
orderRepository.save(order);
|
||||
|
||||
return ResponseEntity.ok(savedFeedback);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.rit.canteen.sales.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Entity
|
||||
@Table(name = "canteen_feedbacks")
|
||||
public class Feedback {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@OneToOne
|
||||
@JoinColumn(name = "order_id", nullable = false)
|
||||
private Order order;
|
||||
|
||||
private Long userId;
|
||||
private String userName;
|
||||
|
||||
@OneToMany(mappedBy = "feedback", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
private List<ItemRating> itemRatings = new ArrayList<>();
|
||||
|
||||
private String comment;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
public Feedback() {
|
||||
this.createdAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
if (createdAt == null) createdAt = LocalDateTime.now();
|
||||
if (itemRatings != null) {
|
||||
for (ItemRating rating : itemRatings) {
|
||||
rating.setFeedback(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
|
||||
public Order getOrder() { return order; }
|
||||
public void setOrder(Order order) { this.order = order; }
|
||||
|
||||
public Long getUserId() { return userId; }
|
||||
public void setUserId(Long userId) { this.userId = userId; }
|
||||
|
||||
public String getUserName() { return userName; }
|
||||
public void setUserName(String userName) { this.userName = userName; }
|
||||
|
||||
public List<ItemRating> getItemRatings() { return itemRatings; }
|
||||
public void setItemRatings(List<ItemRating> itemRatings) { this.itemRatings = itemRatings; }
|
||||
|
||||
public String getComment() { return comment; }
|
||||
public void setComment(String comment) { this.comment = comment; }
|
||||
|
||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.rit.canteen.sales.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
|
||||
@Entity
|
||||
@Table(name = "canteen_item_ratings")
|
||||
public class ItemRating {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.EAGER)
|
||||
@JoinColumn(name = "feedback_id")
|
||||
@JsonIgnore
|
||||
private Feedback feedback;
|
||||
|
||||
private Long productId;
|
||||
private String productName;
|
||||
private int rating; // 1 to 5
|
||||
|
||||
@Column(length = 500)
|
||||
private String comment;
|
||||
|
||||
public ItemRating() {}
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
|
||||
public Feedback getFeedback() { return feedback; }
|
||||
public void setFeedback(Feedback feedback) { this.feedback = feedback; }
|
||||
|
||||
public Long getProductId() { return productId; }
|
||||
public void setProductId(Long productId) { this.productId = productId; }
|
||||
|
||||
public String getProductName() { return productName; }
|
||||
public void setProductName(String productName) { this.productName = productName; }
|
||||
|
||||
public int getRating() { return rating; }
|
||||
public void setRating(int rating) { this.rating = rating; }
|
||||
|
||||
public String getComment() { return comment; }
|
||||
public void setComment(String comment) { this.comment = comment; }
|
||||
}
|
||||
@@ -47,6 +47,9 @@ public class Order {
|
||||
@Column(name = "is_archived", nullable = false)
|
||||
private boolean isArchived = false;
|
||||
|
||||
@Column(name = "has_feedback", nullable = false)
|
||||
private boolean hasFeedback = false;
|
||||
|
||||
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, fetch = FetchType.EAGER, orphanRemoval = true)
|
||||
private List<OrderItem> items = new ArrayList<>();
|
||||
|
||||
@@ -100,6 +103,9 @@ public class Order {
|
||||
public boolean isArchived() { return isArchived; }
|
||||
public void setArchived(boolean archived) { isArchived = archived; }
|
||||
|
||||
public boolean isHasFeedback() { return hasFeedback; }
|
||||
public void setHasFeedback(boolean hasFeedback) { this.hasFeedback = hasFeedback; }
|
||||
|
||||
public String getOrderType() { return orderType; }
|
||||
public void setOrderType(String orderType) { this.orderType = orderType; }
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.rit.canteen.sales.repository;
|
||||
|
||||
import com.rit.canteen.sales.model.Feedback;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface FeedbackRepository extends JpaRepository<Feedback, Long> {
|
||||
Optional<Feedback> findByOrderId(Long orderId);
|
||||
boolean existsByOrderId(Long orderId);
|
||||
Page<Feedback> findAllByOrderByCreatedAtDesc(Pageable pageable);
|
||||
|
||||
@Query("SELECT AVG(ir.rating) FROM ItemRating ir")
|
||||
Double getAverageRating();
|
||||
|
||||
@Query("SELECT ir.rating, COUNT(ir) FROM ItemRating ir GROUP BY ir.rating")
|
||||
List<Object[]> getRatingDistribution();
|
||||
|
||||
@Query("SELECT ir.productName, AVG(ir.rating), COUNT(ir) FROM ItemRating ir GROUP BY ir.productName ORDER BY AVG(ir.rating) DESC")
|
||||
List<Object[]> getTopRatedItems();
|
||||
|
||||
@Query("SELECT ir FROM ItemRating ir WHERE TRIM(LOWER(ir.productName)) = TRIM(LOWER(:productName)) ORDER BY ir.feedback.createdAt DESC")
|
||||
Page<com.rit.canteen.sales.model.ItemRating> findByProductName(@org.springframework.data.repository.query.Param("productName") String productName, Pageable pageable);
|
||||
}
|
||||
@@ -13,6 +13,9 @@ import java.util.Optional;
|
||||
|
||||
public interface OrderRepository extends JpaRepository<Order, Long>, JpaSpecificationExecutor<Order> {
|
||||
List<Order> findByUserIdOrderByCreatedAtDesc(Long userId);
|
||||
Optional<Order> findFirstByUserIdOrderByCreatedAtDesc(Long userId);
|
||||
Optional<Order> findFirstByUserIdAndStatusAndHasFeedbackFalseOrderByCreatedAtDesc(Long userId, String status);
|
||||
Optional<Order> findFirstByUserIdAndStatusOrderByCreatedAtDesc(Long userId, String status);
|
||||
long countByCreatedAtGreaterThanEqual(LocalDateTime startOfDay);
|
||||
long countByCreatedAtBetween(LocalDateTime start, LocalDateTime end);
|
||||
List<Order> findByIsArchivedFalseAndCreatedAtBefore(LocalDateTime timestamp);
|
||||
|
||||
@@ -27,11 +27,33 @@ public class DatabaseSeeder implements CommandLineRunner {
|
||||
@Override
|
||||
public void run(String... args) throws Exception {
|
||||
repairStallsSchema();
|
||||
repairOrdersSchema();
|
||||
repairFeedbackSchema();
|
||||
repairLobColumns();
|
||||
seedCategories();
|
||||
seedProducts();
|
||||
}
|
||||
|
||||
private void repairFeedbackSchema() {
|
||||
System.out.println("Checking schema consistency for 'canteen_item_ratings' table...");
|
||||
try {
|
||||
jdbcTemplate.execute("ALTER TABLE canteen_item_ratings ADD COLUMN IF NOT EXISTS comment VARCHAR(500)");
|
||||
System.out.println("Feedback schema consistency confirmed.");
|
||||
} catch (Exception e) {
|
||||
System.err.println("Feedback schema repair notice: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void repairOrdersSchema() {
|
||||
System.out.println("Checking schema consistency for 'canteen_orders' table...");
|
||||
try {
|
||||
jdbcTemplate.execute("ALTER TABLE canteen_orders ADD COLUMN IF NOT EXISTS has_feedback BOOLEAN DEFAULT FALSE");
|
||||
System.out.println("Orders schema consistency confirmed.");
|
||||
} catch (Exception e) {
|
||||
System.err.println("Orders schema repair notice: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void repairStallsSchema() {
|
||||
System.out.println("Checking schema consistency for 'stalls' table...");
|
||||
try {
|
||||
|
||||
@@ -21,6 +21,7 @@ import PurchaseSummary from './pages/PurchaseSummary.tsx';
|
||||
import IntentDashboard from './pages/IntentDashboard.tsx';
|
||||
import IntentList from './pages/IntentList.tsx';
|
||||
import Reports from './pages/Reports.tsx';
|
||||
import Feedback from './pages/Feedback.tsx';
|
||||
|
||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const isLoggedIn = sessionStorage.getItem('isLoggedIn') === 'true';
|
||||
@@ -86,20 +87,13 @@ function App() {
|
||||
|
||||
{/* Others */}
|
||||
<Route path="reports" element={<Reports />} />
|
||||
<Route path="table" element={<PlaceholderPage title="Table Management" />} />
|
||||
<Route path="wallet" element={<PlaceholderPage title="Wallet" />} />
|
||||
<Route path="feedback" element={<PlaceholderPage title="Feedback" />} />
|
||||
<Route path="feedback" element={<Feedback />} />
|
||||
|
||||
{/* Stores */}
|
||||
<Route path="stores/terminals" element={<Terminals />} />
|
||||
<Route path="stores/managers" element={<Managers />} />
|
||||
<Route path="stores/staffs" element={<Staff />} />
|
||||
<Route path="stores/stalls" element={<Stalls />} />
|
||||
|
||||
{/* Promotions */}
|
||||
<Route path="promotions/discounts" element={<PlaceholderPage title="Discounts" />} />
|
||||
<Route path="promotions/coupon" element={<PlaceholderPage title="Coupons" />} />
|
||||
<Route path="promotions/coupon-template" element={<PlaceholderPage title="Coupon Templates" />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Router>
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
CreditCard,
|
||||
Gauge,
|
||||
LayoutGrid,
|
||||
Megaphone,
|
||||
MessageSquare,
|
||||
ShoppingCart,
|
||||
Store,
|
||||
@@ -114,17 +113,6 @@ const menuItems: MenuItem[] = [
|
||||
{ title: 'Stalls', path: '/stores/stalls' }
|
||||
]
|
||||
},
|
||||
{ title: 'Table', icon: Table2, path: '/table' },
|
||||
{ title: 'Wallet', icon: CreditCard, path: '/wallet' },
|
||||
{
|
||||
title: 'Promotions',
|
||||
icon: Megaphone,
|
||||
subMenu: [
|
||||
{ title: 'Discounts', path: '/promotions/discounts' },
|
||||
{ title: 'Coupon', path: '/promotions/coupon' },
|
||||
{ title: 'CouponTemplate', path: '/promotions/coupon-template' }
|
||||
]
|
||||
},
|
||||
{ title: 'Feedback', icon: MessageSquare, path: '/feedback' },
|
||||
];
|
||||
|
||||
@@ -152,9 +140,6 @@ const Sidebar = () => {
|
||||
'Expense': 'expense',
|
||||
'Reports': 'reports',
|
||||
'Stores': 'stores',
|
||||
'Table': 'table',
|
||||
'Wallet': 'wallet',
|
||||
'Promotions': 'promotions',
|
||||
'Feedback': 'feedback'
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
ShoppingBag,
|
||||
ArrowRight,
|
||||
Wallet,
|
||||
BarChart3
|
||||
BarChart3,
|
||||
MessageSquare
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
AreaChart,
|
||||
@@ -139,6 +140,14 @@ const Dashboard = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => navigate('/feedback')}
|
||||
className="flex items-center gap-2 bg-amber-50 text-amber-600 px-4 py-2 rounded-xl text-xs font-black uppercase tracking-widest hover:bg-amber-500 hover:text-white transition-all shadow-sm active:scale-95"
|
||||
>
|
||||
<MessageSquare size={16} />
|
||||
Customer Feedback
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/reports')}
|
||||
className="flex items-center gap-2 bg-indigo-50 text-indigo-600 px-4 py-2 rounded-xl text-xs font-black uppercase tracking-widest hover:bg-indigo-600 hover:text-white transition-all shadow-sm active:scale-95"
|
||||
|
||||
415
frontend/src/pages/Feedback.tsx
Normal file
415
frontend/src/pages/Feedback.tsx
Normal file
@@ -0,0 +1,415 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Star,
|
||||
MessageSquare,
|
||||
TrendingUp,
|
||||
ChevronRight,
|
||||
RefreshCw,
|
||||
PieChart as PieChartIcon,
|
||||
ThumbsUp,
|
||||
X,
|
||||
Clock
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid
|
||||
} from 'recharts';
|
||||
import { format } from 'date-fns';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import Pagination from '../components/Pagination';
|
||||
|
||||
interface RatedItem {
|
||||
name: string;
|
||||
average: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface ItemDetail {
|
||||
rating: number;
|
||||
comment: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
interface FeedbackStats {
|
||||
averageRating: number;
|
||||
distribution: Array<{
|
||||
rating: number;
|
||||
count: number;
|
||||
}>;
|
||||
ratedItems: RatedItem[];
|
||||
}
|
||||
|
||||
const Feedback: React.FC = () => {
|
||||
const [stats, setStats] = useState<FeedbackStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Feedbacks list states
|
||||
const [feedbacks, setFeedbacks] = useState<any[]>([]);
|
||||
const [page, setPage] = useState(0);
|
||||
const [totalElements, setTotalElements] = useState(0);
|
||||
const pageSize = 10;
|
||||
|
||||
// Modal states
|
||||
const [selectedItem, setSelectedItem] = useState<RatedItem | null>(null);
|
||||
const [itemDetails, setItemDetails] = useState<ItemDetail[]>([]);
|
||||
const [detailsLoading, setItemDetailsLoading] = useState(false);
|
||||
const [detailsPage, setDetailsPage] = useState(0);
|
||||
const [detailsTotal, setDetailsTotal] = useState(0);
|
||||
const detailsPageSize = 5;
|
||||
|
||||
const fetchStats = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`http://${window.location.hostname}:8080/api/feedback/stats`);
|
||||
const data = await response.json();
|
||||
setStats(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching feedback stats:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchFeedbacks = async () => {
|
||||
try {
|
||||
const response = await fetch(`http://${window.location.hostname}:8080/api/feedback?page=${page}&size=${pageSize}`);
|
||||
const data = await response.json();
|
||||
setFeedbacks(data?.content || []);
|
||||
setTotalElements(data?.totalElements || 0);
|
||||
} catch (error) {
|
||||
console.error('Error fetching feedbacks:', error);
|
||||
setFeedbacks([]);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchItemDetails = async (itemName: string, pageNum: number) => {
|
||||
setItemDetailsLoading(true);
|
||||
try {
|
||||
const response = await fetch(`http://${window.location.hostname}:8080/api/feedback/item-details?productName=${encodeURIComponent(itemName)}&page=${pageNum}&size=${detailsPageSize}`);
|
||||
const data = await response.json();
|
||||
setItemDetails(data?.content || []);
|
||||
setDetailsTotal(data?.totalElements || 0);
|
||||
} catch (error) {
|
||||
console.error('Error fetching item details:', error);
|
||||
setItemDetails([]);
|
||||
} finally {
|
||||
setItemDetailsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFeedbacks();
|
||||
}, [page]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedItem) {
|
||||
fetchItemDetails(selectedItem.name, detailsPage);
|
||||
}
|
||||
}, [selectedItem, detailsPage]);
|
||||
|
||||
const COLORS = ['#ef4444', '#f59e0b', '#facc15', '#84cc16', '#22c55e'];
|
||||
|
||||
const pieData = (stats?.distribution || []).map(d => ({
|
||||
name: `${d.rating} Stars`,
|
||||
value: d.count
|
||||
})).sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const renderStars = (rating: number, size = 12) => {
|
||||
return (
|
||||
<div className="flex gap-0.5">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<Star
|
||||
key={star}
|
||||
size={size}
|
||||
className={star <= rating ? 'fill-amber-400 text-amber-400' : 'text-slate-200'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const handleItemClick = (item: RatedItem) => {
|
||||
setSelectedItem(item);
|
||||
setDetailsPage(0);
|
||||
setItemDetails([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8 bg-[#f8fafc] min-h-screen">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-slate-900 tracking-tight">Food Quality Feedback</h1>
|
||||
<p className="text-slate-500 mt-1">Item-wise performance monitoring and anonymous reviews</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => fetchStats()}
|
||||
className="flex items-center gap-2 bg-white text-slate-600 px-4 py-2 rounded-xl font-bold border border-slate-200 hover:bg-slate-50 transition-all shadow-sm active:scale-95"
|
||||
>
|
||||
<RefreshCw size={18} className={loading ? 'animate-spin' : ''} />
|
||||
Sync Data
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!stats ? (
|
||||
<div className="flex flex-col items-center justify-center py-32 bg-white rounded-3xl border border-slate-100 shadow-sm">
|
||||
<RefreshCw size={48} className="text-indigo-200 animate-spin mb-4" />
|
||||
<p className="text-slate-400 font-medium">Aggregating quality data...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="bg-white p-6 rounded-3xl border border-slate-100 shadow-sm flex items-center gap-6">
|
||||
<div className="p-4 bg-amber-50 text-amber-500 rounded-2xl">
|
||||
<Star size={32} fill="currentColor" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-400 text-xs font-bold uppercase tracking-widest">Global Average</p>
|
||||
<h2 className="text-4xl font-black text-slate-900">{stats.averageRating.toFixed(1)} <span className="text-lg text-slate-300">/ 5.0</span></h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-3xl border border-slate-100 shadow-sm flex items-center gap-6">
|
||||
<div className="p-4 bg-indigo-50 text-indigo-500 rounded-2xl">
|
||||
<MessageSquare size={32} fill="currentColor" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-400 text-xs font-bold uppercase tracking-widest">Rated Products</p>
|
||||
<h2 className="text-4xl font-black text-slate-900">{stats?.ratedItems?.length || 0}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-3xl border border-slate-100 shadow-sm flex items-center gap-6">
|
||||
<div className="p-4 bg-emerald-50 text-emerald-500 rounded-2xl">
|
||||
<ThumbsUp size={32} fill="currentColor" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-400 text-xs font-bold uppercase tracking-widest">Highest Rated</p>
|
||||
<h2 className="text-xl font-black text-slate-900 truncate max-w-[200px]">{stats?.ratedItems?.[0]?.name || 'N/A'}</h2>
|
||||
<p className="text-[10px] text-emerald-600 font-bold">{stats?.ratedItems?.[0]?.average?.toFixed(1) || '0.0'} avg rating</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Rating Distribution Chart */}
|
||||
<div className="bg-white p-8 rounded-[2rem] border border-slate-100 shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<div className="p-2.5 bg-amber-50 text-amber-500 rounded-xl">
|
||||
<PieChartIcon size={20} />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-slate-900">Rating Distribution</h3>
|
||||
</div>
|
||||
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={pieData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={100}
|
||||
paddingAngle={5}
|
||||
dataKey="value"
|
||||
>
|
||||
{pieData.map((_, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)' }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quality Trend / Top items chart */}
|
||||
<div className="bg-white p-8 rounded-[2rem] border border-slate-100 shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<div className="p-2.5 bg-indigo-50 text-indigo-500 rounded-xl">
|
||||
<TrendingUp size={20} />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-slate-900">Top Performing Foods</h3>
|
||||
</div>
|
||||
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={(stats?.ratedItems || []).slice(0, 5)} layout="vertical">
|
||||
<CartesianGrid strokeDasharray="3 3" horizontal={true} vertical={false} stroke="#f1f5f9" />
|
||||
<XAxis type="number" domain={[0, 5]} hide />
|
||||
<YAxis
|
||||
dataKey="name"
|
||||
type="category"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={120}
|
||||
tick={{ fontSize: 11, fontWeight: 700, fill: '#64748b' }}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: '#f8fafc' }}
|
||||
contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)' }}
|
||||
/>
|
||||
<Bar dataKey="average" fill="#6366f1" radius={[0, 4, 4, 0]} barSize={24} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Food Items List */}
|
||||
<div className="bg-white rounded-[2rem] border border-slate-100 shadow-sm overflow-hidden">
|
||||
<div className="p-8 border-b border-slate-50">
|
||||
<h3 className="text-lg font-bold text-slate-900">Product Performance Log</h3>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-slate-50/50">
|
||||
<th className="px-8 py-4 text-xs font-bold text-slate-400 uppercase tracking-widest">Food Item</th>
|
||||
<th className="px-8 py-4 text-xs font-bold text-slate-400 uppercase tracking-widest">Average Rating</th>
|
||||
<th className="px-8 py-4 text-xs font-bold text-slate-400 uppercase tracking-widest text-center">Total Reviews</th>
|
||||
<th className="px-8 py-4 text-xs font-bold text-slate-400 uppercase tracking-widest text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{(stats?.ratedItems || []).map((item, index) => (
|
||||
<tr
|
||||
key={index}
|
||||
className="group hover:bg-slate-50/50 transition-colors cursor-pointer"
|
||||
onClick={() => handleItemClick(item)}
|
||||
>
|
||||
<td className="px-8 py-6">
|
||||
<div className="font-bold text-slate-700">{item.name}</div>
|
||||
<div className="text-[10px] text-indigo-500 font-bold uppercase tracking-wider mt-0.5">Primary Menu</div>
|
||||
</td>
|
||||
<td className="px-8 py-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-black text-slate-900">{item.average.toFixed(1)}</span>
|
||||
{renderStars(Math.round(item.average))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-6 text-center">
|
||||
<div className="inline-flex items-center px-3 py-1 bg-slate-100 rounded-full text-xs font-bold text-slate-500">
|
||||
{item.count} Feedbacks
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-6 text-right">
|
||||
<button className="p-2 text-slate-300 group-hover:text-indigo-600 transition-colors">
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Details Modal */}
|
||||
<AnimatePresence>
|
||||
{selectedItem && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setSelectedItem(null)}
|
||||
className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
exit={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||
className="relative bg-white w-full max-w-2xl rounded-[2.5rem] shadow-2xl overflow-hidden flex flex-col max-h-[85vh]"
|
||||
>
|
||||
<div className="p-8 bg-indigo-600 text-white flex justify-between items-start">
|
||||
<div>
|
||||
<h2 className="text-3xl font-black tracking-tight">{selectedItem.name}</h2>
|
||||
<div className="flex items-center gap-4 mt-2">
|
||||
<div className="flex items-center gap-1 bg-white/10 px-3 py-1 rounded-full text-xs font-bold">
|
||||
<Star size={14} fill="currentColor" /> {selectedItem.average.toFixed(1)}
|
||||
</div>
|
||||
<span className="text-white/60 text-xs font-bold uppercase tracking-widest">{selectedItem.count} TOTAL REVIEWS</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedItem(null)}
|
||||
className="p-2 hover:bg-white/10 rounded-full transition-colors"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-8 overflow-y-auto flex-1 bg-slate-50/50">
|
||||
<div className="space-y-4">
|
||||
{detailsLoading && itemDetails.length === 0 ? (
|
||||
<div className="py-20 text-center">
|
||||
<RefreshCw size={32} className="animate-spin mx-auto text-indigo-200 mb-4" />
|
||||
<p className="text-slate-400 font-bold uppercase text-xs tracking-widest">Loading reviews...</p>
|
||||
</div>
|
||||
) : itemDetails.map((detail, idx) => (
|
||||
<div key={idx} className="bg-white p-6 rounded-3xl border border-slate-100 shadow-sm">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-slate-50 rounded-xl text-slate-400">
|
||||
<Clock size={16} />
|
||||
</div>
|
||||
<span className="text-xs font-bold text-slate-400 uppercase">{format(new Date(detail.date), 'dd MMM yyyy, hh:mm a')}</span>
|
||||
</div>
|
||||
{renderStars(detail.rating, 16)}
|
||||
</div>
|
||||
<p className="text-slate-600 leading-relaxed font-medium italic">
|
||||
"{detail.comment || 'No written review provided'}"
|
||||
</p>
|
||||
<div className="mt-4 pt-4 border-t border-slate-50 flex items-center gap-2">
|
||||
<div className="w-6 h-6 bg-indigo-50 rounded-full flex items-center justify-center text-[10px] font-black text-indigo-500">A</div>
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Anonymous Customer</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!detailsLoading && itemDetails.length === 0 && (
|
||||
<div className="py-20 text-center text-slate-400 font-bold">
|
||||
No detailed reviews found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detailsTotal > detailsPageSize && (
|
||||
<div className="p-6 bg-white border-t border-slate-100">
|
||||
<Pagination
|
||||
currentPage={detailsPage}
|
||||
pageSize={detailsPageSize}
|
||||
totalElements={detailsTotal}
|
||||
onPageChange={setDetailsPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Feedback;
|
||||
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 = () => {
|
||||
@@ -15,6 +17,74 @@ const HomeScreen: React.FC = () => {
|
||||
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]);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
@@ -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