Parcel and Veg/Non-Veg Toggle added
This commit is contained in:
@@ -155,6 +155,15 @@ public class OrderController {
|
||||
// Use offer price if present, otherwise base price
|
||||
BigDecimal unitPrice = (product.getOfferPrice() != null && product.getOfferPrice().compareTo(BigDecimal.ZERO) > 0)
|
||||
? product.getOfferPrice() : product.getPrice();
|
||||
|
||||
// Add parcel fee if selected and parcellable
|
||||
if (item.getProductName() != null && item.getProductName().endsWith(" (Parcel)") && product.isParcellable()) {
|
||||
unitPrice = unitPrice.add(BigDecimal.valueOf(5));
|
||||
}
|
||||
|
||||
// Update item's saved price so it reflects the unitPrice + parcel fee
|
||||
item.setPrice(unitPrice);
|
||||
|
||||
serverTotal = serverTotal.add(unitPrice.multiply(BigDecimal.valueOf(item.getQuantity())));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@ public class ProductController {
|
||||
product.setVeg(productDetails.isVeg());
|
||||
product.setHasAllergy(productDetails.isHasAllergy());
|
||||
product.setParcelNotAllowed(productDetails.isParcelNotAllowed());
|
||||
product.setParcellable(productDetails.isParcellable());
|
||||
product.setSessionOptional(productDetails.isSessionOptional());
|
||||
|
||||
product.getSessions().clear();
|
||||
@@ -238,6 +239,7 @@ public class ProductController {
|
||||
live.setImageData(productDetails.getImageData());
|
||||
live.setDraft(false);
|
||||
live.setActive(true);
|
||||
live.setParcellable(productDetails.isParcellable());
|
||||
|
||||
Product savedLive = productRepository.save(live);
|
||||
productRepository.delete(product); // Delete the draft
|
||||
@@ -257,6 +259,7 @@ public class ProductController {
|
||||
product.setBarcode(productDetails.getBarcode());
|
||||
product.setImageData(productDetails.getImageData());
|
||||
product.setStock(productDetails.getStock());
|
||||
product.setParcellable(productDetails.isParcellable());
|
||||
|
||||
product.getSessions().clear();
|
||||
if (productDetails.getSessions() != null) {
|
||||
|
||||
@@ -49,11 +49,13 @@ public class Product {
|
||||
private boolean attributesOptional;
|
||||
|
||||
// Additional Attributes
|
||||
private boolean isVeg;
|
||||
private boolean isVeg = true;
|
||||
private boolean hasAllergy;
|
||||
|
||||
private boolean parcelNotAllowed;
|
||||
|
||||
private boolean parcellable;
|
||||
|
||||
private boolean sessionOptional;
|
||||
|
||||
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
@@ -133,6 +135,9 @@ public class Product {
|
||||
public boolean isParcelNotAllowed() { return parcelNotAllowed; }
|
||||
public void setParcelNotAllowed(boolean parcelNotAllowed) { this.parcelNotAllowed = parcelNotAllowed; }
|
||||
|
||||
public boolean isParcellable() { return parcellable; }
|
||||
public void setParcellable(boolean parcellable) { this.parcellable = parcellable; }
|
||||
|
||||
public boolean isSessionOptional() { return sessionOptional; }
|
||||
public void setSessionOptional(boolean sessionOptional) { this.sessionOptional = sessionOptional; }
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ interface Product {
|
||||
active: boolean;
|
||||
stock: number;
|
||||
stalls?: { id: number; name: string }[];
|
||||
parcellable?: boolean;
|
||||
}
|
||||
|
||||
interface Stall {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiFetch } from '../api';
|
||||
import { apiFetch } from '../api';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Plus, X, Search, Filter, MoreVertical, RefreshCw, Edit2, Power, PowerOff, Tag, Package, Image as ImageIcon, Barcode, DollarSign, ChevronDown, Clock, Check, Trash2, Database } from 'lucide-react';
|
||||
import Pagination from '../components/Pagination';
|
||||
@@ -36,6 +36,7 @@ interface Product {
|
||||
active: boolean;
|
||||
stock: number;
|
||||
stalls?: { id: number; name: string }[];
|
||||
parcellable?: boolean;
|
||||
}
|
||||
|
||||
interface Stall {
|
||||
@@ -69,14 +70,15 @@ const emptyProduct: Product = {
|
||||
parcelCharges: 0,
|
||||
barcode: '',
|
||||
attributesOptional: false,
|
||||
veg: false,
|
||||
veg: true,
|
||||
hasAllergy: false,
|
||||
parcelNotAllowed: false,
|
||||
sessionOptional: false,
|
||||
sessions: getDefaultSessions(),
|
||||
imageData: '',
|
||||
active: true,
|
||||
stock: 0
|
||||
stock: 0,
|
||||
parcellable: false
|
||||
};
|
||||
|
||||
const Products = () => {
|
||||
@@ -529,16 +531,29 @@ const Products = () => {
|
||||
<div className="mt-8 space-y-6">
|
||||
<div className="flex flex-wrap gap-8 p-6 bg-gray-50 rounded-2xl border border-[#e2e8f0]">
|
||||
<div className="flex flex-col gap-4 min-w-[200px]">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div><p className="text-sm font-bold text-[#1e293b]">Additional Attributes</p></div>
|
||||
<button type="button" onClick={() => setFormData({ ...formData, attributesOptional: !formData.attributesOptional })} className={`w-11 h-6 rounded-full relative transition-all ${formData.attributesOptional ? 'bg-[#001828]' : 'bg-gray-300'}`}><div className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform ${formData.attributesOptional ? 'translate-x-5' : ''}`} /></button>
|
||||
<div><p className="text-sm font-bold text-[#1e293b]">Additional Attributes</p></div>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer p-3 bg-white rounded-2xl border border-[#e2e8f0] shadow-sm hover:bg-gray-50 transition-all select-none">
|
||||
<input type="checkbox" checked={!formData.veg} onChange={(e) => setFormData({ ...formData, veg: !e.target.checked })} className="w-4 h-4 rounded text-[#001828]" />
|
||||
<span className="text-xs font-semibold">Non-Veg</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer p-3 bg-white rounded-2xl border border-[#e2e8f0] shadow-sm hover:bg-gray-50 transition-all select-none">
|
||||
<input type="checkbox" checked={formData.hasAllergy} onChange={(e) => setFormData({ ...formData, hasAllergy: e.target.checked })} className="w-4 h-4 rounded text-[#001828]" />
|
||||
<span className="text-xs font-semibold">Allergy</span>
|
||||
</label>
|
||||
</div>
|
||||
{formData.attributesOptional && (
|
||||
<div className="flex gap-4 animate-in fade-in slide-in-from-left-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer"><input type="checkbox" checked={formData.veg} onChange={(e) => setFormData({ ...formData, veg: e.target.checked })} className="w-4 h-4 rounded text-[#001828]" /><span className="text-xs font-semibold">Veg</span></label>
|
||||
<label className="flex items-center gap-2 cursor-pointer"><input type="checkbox" checked={formData.hasAllergy} onChange={(e) => setFormData({ ...formData, hasAllergy: e.target.checked })} className="w-4 h-4 rounded text-[#001828]" /><span className="text-xs font-semibold">Allergy</span></label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-center min-w-[150px]">
|
||||
<label className="flex items-center gap-3 cursor-pointer p-4 bg-white rounded-2xl border border-[#e2e8f0] shadow-sm hover:bg-gray-50 transition-all select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.parcellable}
|
||||
onChange={(e) => setFormData({ ...formData, parcellable: e.target.checked })}
|
||||
className="w-4 h-4 rounded text-[#001828] border-gray-300 focus:ring-[#001828]"
|
||||
/>
|
||||
<span className="text-sm font-bold text-[#1e293b]">Parcellable</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 min-w-[200px]">
|
||||
|
||||
@@ -11,6 +11,14 @@ interface ItemCardProps {
|
||||
variant?: 'carousel' | 'list';
|
||||
}
|
||||
|
||||
export const VegNonVegIcon: React.FC<{ isVeg: boolean }> = ({ isVeg }) => {
|
||||
return (
|
||||
<div className={`veg-nonveg-indicator ${isVeg ? 'veg' : 'non-veg'}`} title={isVeg ? 'Vegetarian' : 'Non-Vegetarian'}>
|
||||
<div className="dot"></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ItemCard: React.FC<ItemCardProps> = ({ item, isLast, variant = 'list' }) => {
|
||||
const navigate = useNavigate();
|
||||
const { addToCart, updateQuantity, getItemQuantity } = useCart();
|
||||
@@ -18,7 +26,8 @@ const ItemCard: React.FC<ItemCardProps> = ({ item, isLast, variant = 'list' }) =
|
||||
const isLimitReached = item.stock !== undefined && quantity >= item.stock && item.stock > 0;
|
||||
|
||||
// Visual helper values matching mockup metadata
|
||||
const rating = 4.5;
|
||||
const rating = item.rating !== undefined ? item.rating : 5.0;
|
||||
const ratingCount = item.ratingCount !== undefined ? item.ratingCount : 0;
|
||||
|
||||
if (variant === 'carousel') {
|
||||
return (
|
||||
@@ -26,10 +35,12 @@ const ItemCard: React.FC<ItemCardProps> = ({ item, isLast, variant = 'list' }) =
|
||||
className={`item-card-carousel ${item.stock === 0 ? 'out-of-stock' : ''}`}
|
||||
onClick={() => navigate(`/item/${item.id}`)}
|
||||
>
|
||||
<div className="carousel-rating-badge">
|
||||
<Star size={10} fill="currentColor" className="star-icon-filled" />
|
||||
<span>{rating}</span>
|
||||
</div>
|
||||
{ratingCount > 0 && (
|
||||
<div className="carousel-rating-badge">
|
||||
<Star size={10} fill="currentColor" className="star-icon-filled" />
|
||||
<span>{rating.toFixed(1)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="carousel-image-container">
|
||||
{item.image ? (
|
||||
@@ -40,8 +51,11 @@ const ItemCard: React.FC<ItemCardProps> = ({ item, isLast, variant = 'list' }) =
|
||||
</div>
|
||||
|
||||
<div className="carousel-info">
|
||||
<h3 className="carousel-item-name">{item.name}</h3>
|
||||
<p className="carousel-vendor">{item.stallName || 'Cookie Heaven'}</p>
|
||||
<div className="carousel-title-row" style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '2px', minWidth: 0 }}>
|
||||
<VegNonVegIcon isVeg={item.isVeg} />
|
||||
<h3 className="carousel-item-name" style={{ margin: 0, flex: 1, minWidth: 0 }}>{item.name}</h3>
|
||||
</div>
|
||||
<p className="carousel-vendor">{item.stallName || 'Unknown Stall'}</p>
|
||||
|
||||
<div className="carousel-footer" onClick={(e) => e.stopPropagation()}>
|
||||
<span className="carousel-price">🅡{item.price.toFixed(2)}</span>
|
||||
@@ -89,15 +103,19 @@ const ItemCard: React.FC<ItemCardProps> = ({ item, isLast, variant = 'list' }) =
|
||||
|
||||
<div className="list-info">
|
||||
<div className="list-title-row">
|
||||
<h3 className="list-item-name">{item.name}</h3>
|
||||
<div className="list-rating-badge">
|
||||
<Star size={10} fill="currentColor" className="star-icon-filled" />
|
||||
<span>{rating}</span>
|
||||
<div className="list-title-wrapper" style={{ display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0 }}>
|
||||
<VegNonVegIcon isVeg={item.isVeg} />
|
||||
<h3 className="list-item-name" style={{ lineHeight: 1.2 }}>{item.name}</h3>
|
||||
</div>
|
||||
{ratingCount > 0 && (
|
||||
<div className="list-rating-badge">
|
||||
<Star size={10} fill="currentColor" className="star-icon-filled" />
|
||||
<span>{rating.toFixed(1)} ({ratingCount})</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="list-vendor">{item.stallName || 'Cookie Heaven'}</p>
|
||||
<p className="list-address">📍 54 Summit Street</p>
|
||||
<p className="list-vendor">{item.stallName || 'Unknown Stall'}</p>
|
||||
|
||||
<div className="list-footer" onClick={(e) => e.stopPropagation()}>
|
||||
<span className="list-price">🅡{item.price.toFixed(2)}</span>
|
||||
|
||||
@@ -3,9 +3,10 @@ import type { FoodItem, CartItem } from '../types';
|
||||
|
||||
interface CartContextType {
|
||||
cart: CartItem[];
|
||||
addToCart: (item: FoodItem) => void;
|
||||
addToCart: (item: FoodItem, isParcel?: boolean) => void;
|
||||
removeFromCart: (itemId: string) => void;
|
||||
updateQuantity: (itemId: string, delta: number) => void;
|
||||
toggleParcel: (itemId: string) => void;
|
||||
clearCart: () => void;
|
||||
getItemQuantity: (itemId: string) => number;
|
||||
totalItems: number;
|
||||
@@ -29,7 +30,7 @@ export const CartProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
|
||||
const clearStockError = () => setStockError(null);
|
||||
|
||||
const addToCart = (item: FoodItem) => {
|
||||
const addToCart = (item: FoodItem, isParcel?: boolean) => {
|
||||
setCart((prevCart) => {
|
||||
const existingIndex = prevCart.findIndex((i) => i.id === item.id);
|
||||
|
||||
@@ -45,13 +46,22 @@ export const CartProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
newCart[existingIndex] = {
|
||||
...newCart[existingIndex],
|
||||
quantity: newCart[existingIndex].quantity + 1,
|
||||
isParcel: isParcel !== undefined ? isParcel : newCart[existingIndex].isParcel
|
||||
};
|
||||
return newCart;
|
||||
}
|
||||
return [...prevCart, { ...item, quantity: 1 }];
|
||||
return [...prevCart, { ...item, quantity: 1, isParcel: !!isParcel }];
|
||||
});
|
||||
};
|
||||
|
||||
const toggleParcel = (itemId: string) => {
|
||||
setCart((prevCart) =>
|
||||
prevCart.map((item) =>
|
||||
item.id === itemId ? { ...item, isParcel: !item.isParcel } : item
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const removeFromCart = (itemId: string) => {
|
||||
setCart((prevCart) => prevCart.filter((item) => item.id !== itemId));
|
||||
};
|
||||
@@ -92,7 +102,7 @@ export const CartProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
};
|
||||
|
||||
const totalItems = cart.reduce((sum, item) => sum + item.quantity, 0);
|
||||
const totalPrice = cart.reduce((sum, item) => sum + item.price * item.quantity, 0);
|
||||
const totalPrice = cart.reduce((sum, item) => sum + (item.price + (item.isParcel ? 5 : 0)) * item.quantity, 0);
|
||||
|
||||
return (
|
||||
<CartContext.Provider
|
||||
@@ -101,6 +111,7 @@ export const CartProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
addToCart,
|
||||
removeFromCart,
|
||||
updateQuantity,
|
||||
toggleParcel,
|
||||
clearCart,
|
||||
getItemQuantity,
|
||||
totalItems,
|
||||
|
||||
@@ -28,11 +28,20 @@ export const FoodProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
if (!silent) setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const [baseItemsRes, productsRes, stallsRes, statsRes] = await Promise.all([
|
||||
fetch(`${API_BASE_URL}/base-items?size=100`, { cache: 'no-store' }),
|
||||
fetch(`${API_BASE_URL}/products?size=100`, { cache: 'no-store' }),
|
||||
fetch(`${API_BASE_URL}/stalls/active`, { cache: 'no-store' }),
|
||||
fetch(`${API_BASE_URL}/feedback/stats`, { cache: 'no-store' }).catch(() => null)
|
||||
fetch(`${API_BASE_URL}/feedback/stats`, {
|
||||
cache: 'no-store',
|
||||
headers
|
||||
}).catch(() => null)
|
||||
]);
|
||||
|
||||
if (!baseItemsRes.ok || !productsRes.ok || !stallsRes.ok) {
|
||||
@@ -46,8 +55,9 @@ export const FoodProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
const baseItemsData = baseItemsDataRaw.content || baseItemsDataRaw;
|
||||
const productsData = productsDataRaw.content || productsDataRaw;
|
||||
|
||||
// Extract top rated items names by customer feedback count
|
||||
// Extract top rated items names by customer feedback count and rating maps
|
||||
let topRatedNames: string[] = [];
|
||||
const itemRatingsMap: Record<string, { average: number; count: number }> = {};
|
||||
if (statsRes && statsRes.ok) {
|
||||
try {
|
||||
const statsData = await statsRes.json();
|
||||
@@ -59,6 +69,15 @@ export const FoodProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
.filter((x: any) => x.count > 0)
|
||||
.slice(0, 3)
|
||||
.map((x: any) => x.name.toLowerCase());
|
||||
|
||||
ratedItems.forEach((x: any) => {
|
||||
if (x.name) {
|
||||
itemRatingsMap[x.name.toLowerCase()] = {
|
||||
average: x.average,
|
||||
count: x.count
|
||||
};
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Error parsing feedback stats:', e);
|
||||
}
|
||||
@@ -121,6 +140,7 @@ export const FoodProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
|
||||
// Dynamic bestseller isPopular flag based on feedback stats
|
||||
const isBestseller = topRatedNames.includes(item.name.toLowerCase());
|
||||
const ratingInfo = itemRatingsMap[item.name.toLowerCase()];
|
||||
|
||||
return {
|
||||
id: itemId,
|
||||
@@ -133,7 +153,10 @@ export const FoodProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
isPopular: isBestseller,
|
||||
stock: item.stock,
|
||||
stallId: (stallFromBackend?.id || stallFromMap?.id || stallFromCategory?.id),
|
||||
stallName: (stallFromBackend?.name || stallFromMap?.name || stallFromCategory?.name)
|
||||
stallName: (stallFromBackend?.name || stallFromMap?.name || stallFromCategory?.name),
|
||||
rating: ratingInfo ? ratingInfo.average : 5.0,
|
||||
ratingCount: ratingInfo ? ratingInfo.count : 0,
|
||||
parcellable: item.parcellable
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -232,3 +232,38 @@ main::-webkit-scrollbar {
|
||||
.latest-qr-wrapper.qr-expired canvas {
|
||||
filter: blur(6px) grayscale(100%) !important;
|
||||
}
|
||||
|
||||
/* Veg / Non-Veg Indicator Styling */
|
||||
.veg-nonveg-indicator {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1.5px solid;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.veg-nonveg-indicator.veg {
|
||||
border-color: #10b981; /* emerald-500 */
|
||||
}
|
||||
|
||||
.veg-nonveg-indicator.non-veg {
|
||||
border-color: #ef4444; /* red-500 */
|
||||
}
|
||||
|
||||
.veg-nonveg-indicator .dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.veg-nonveg-indicator.veg .dot {
|
||||
background-color: #10b981;
|
||||
}
|
||||
|
||||
.veg-nonveg-indicator.non-veg .dot {
|
||||
background-color: #ef4444;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import './CartScreen.css';
|
||||
|
||||
const CartScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { cart, updateQuantity, removeFromCart, totalPrice, totalItems } = useCart();
|
||||
const { cart, updateQuantity, removeFromCart, toggleParcel, totalPrice, totalItems } = useCart();
|
||||
|
||||
if (cart.length === 0) {
|
||||
return (
|
||||
@@ -45,8 +45,23 @@ const CartScreen: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{item.parcellable && (
|
||||
<div className="cart-item-parcel-toggle" style={{ margin: '4px 0', display: 'flex', alignItems: 'center' }}>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-[11px] text-slate-500 font-semibold">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!item.isParcel}
|
||||
onChange={() => toggleParcel(item.id)}
|
||||
className="w-3.5 h-3.5 rounded text-emerald-600 focus:ring-emerald-500 cursor-pointer"
|
||||
style={{ marginRight: '6px' }}
|
||||
/>
|
||||
<span>Pack as Parcel</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="cart-item-footer">
|
||||
<span className="cart-item-price">🅡{(item.price * item.quantity).toFixed(2)}</span>
|
||||
<span className="cart-item-price">🅡{((item.price + (item.isParcel ? 5 : 0)) * item.quantity).toFixed(2)}</span>
|
||||
|
||||
<div className="cart-quantity-controls">
|
||||
<button onClick={() => updateQuantity(item.id, -1)}>
|
||||
|
||||
@@ -66,8 +66,8 @@ const CheckoutScreen: React.FC = () => {
|
||||
orderType: 'MY_ORDER',
|
||||
items: cart.map(item => ({
|
||||
productId: Number(item.id),
|
||||
productName: item.name,
|
||||
price: item.price,
|
||||
productName: item.isParcel ? `${item.name} (Parcel)` : item.name,
|
||||
price: item.price + (item.isParcel ? 5 : 0),
|
||||
quantity: item.quantity,
|
||||
stallId: item.stallId ? Number(item.stallId) : null,
|
||||
stallName: item.stallName || null
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
import React from 'react';
|
||||
import type { FoodItem } from '../types';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { AlertCircle, Star, ChevronRight } from 'lucide-react';
|
||||
import { AlertCircle, Star } from 'lucide-react';
|
||||
import Header from '../components/Header';
|
||||
import { useCart } from '../contexts/CartContext';
|
||||
import { useFood } from '../contexts/FoodContext';
|
||||
import CartTab from '../components/CartTab';
|
||||
import { VegNonVegIcon } from '../components/ItemCard';
|
||||
import './ItemDetailScreen.css';
|
||||
|
||||
const ItemDetailScreen: React.FC = () => {
|
||||
const { itemId } = useParams<{ itemId: string }>();
|
||||
const { addToCart, updateQuantity, getItemQuantity } = useCart();
|
||||
const { cart, addToCart, updateQuantity, getItemQuantity, toggleParcel } = useCart();
|
||||
const { foodItems, isLoading: isGlobalLoading } = useFood();
|
||||
const [fetchedItem, setFetchedItem] = React.useState<FoodItem | null>(null);
|
||||
const [isFetching, setIsFetching] = React.useState(false);
|
||||
const [isParcel, setIsParcel] = React.useState(false);
|
||||
|
||||
const contextItem = foodItems.find((i) => i.id === itemId);
|
||||
const item = contextItem || fetchedItem;
|
||||
|
||||
const cartItem = cart.find((i) => i.id === itemId);
|
||||
React.useEffect(() => {
|
||||
if (cartItem) {
|
||||
setIsParcel(!!cartItem.isParcel);
|
||||
}
|
||||
}, [cartItem]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!contextItem && itemId) {
|
||||
const fetchItem = async () => {
|
||||
@@ -33,6 +42,23 @@ const ItemDetailScreen: React.FC = () => {
|
||||
|
||||
const stallInfo = data.stalls && data.stalls.length > 0 ? data.stalls[0] : null;
|
||||
|
||||
// Fetch product rating stats as well
|
||||
let finalRating = 5.0;
|
||||
let finalRatingCount = 0;
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const statsRes = await fetch(`http://${window.location.hostname}:8080/api/feedback/item-stats?productName=${encodeURIComponent(data.name)}`, {
|
||||
headers: token ? { 'Authorization': `Bearer ${token}` } : {}
|
||||
});
|
||||
if (statsRes.ok) {
|
||||
const statsData = await statsRes.json();
|
||||
finalRating = statsData.averageRating || 5.0;
|
||||
finalRatingCount = statsData.totalReviews || 0;
|
||||
}
|
||||
} catch (statsErr) {
|
||||
console.error('Error fetching individual product feedback stats:', statsErr);
|
||||
}
|
||||
|
||||
const mappedItem: FoodItem = {
|
||||
id: data.id.toString(),
|
||||
name: data.name,
|
||||
@@ -44,7 +70,10 @@ const ItemDetailScreen: React.FC = () => {
|
||||
isPopular: data.active,
|
||||
stock: data.stock,
|
||||
stallId: stallInfo?.id?.toString(),
|
||||
stallName: stallInfo?.name
|
||||
stallName: stallInfo?.name,
|
||||
rating: finalRating,
|
||||
ratingCount: finalRatingCount,
|
||||
parcellable: data.parcellable
|
||||
};
|
||||
setFetchedItem(mappedItem);
|
||||
}
|
||||
@@ -87,7 +116,8 @@ const ItemDetailScreen: React.FC = () => {
|
||||
const isLimitReached = item.stock !== undefined && quantity >= item.stock && item.stock > 0;
|
||||
|
||||
// Visual helper values matching mockup metadata
|
||||
const rating = 4.5;
|
||||
const rating = item.rating !== undefined ? item.rating : 5.0;
|
||||
const ratingCount = item.ratingCount !== undefined ? item.ratingCount : 0;
|
||||
|
||||
return (
|
||||
<div className={`container item-detail-page ${item.stock === 0 ? 'out-of-stock' : ''}`}>
|
||||
@@ -109,8 +139,11 @@ const ItemDetailScreen: React.FC = () => {
|
||||
<div className="item-details-content">
|
||||
<div className="item-title-section">
|
||||
<div className="title-left">
|
||||
<h1 className="item-name-large">{item.name}</h1>
|
||||
<p className="item-subtitle">{item.stallName || '54 Summit Street.'}</p>
|
||||
<div className="detail-title-wrapper" style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
|
||||
<VegNonVegIcon isVeg={item.isVeg} />
|
||||
<h1 className="item-name-large" style={{ margin: 0 }}>{item.name}</h1>
|
||||
</div>
|
||||
<p className="item-subtitle" style={{ marginTop: '4px' }}>{item.stallName || 'Unknown Stall'}</p>
|
||||
</div>
|
||||
|
||||
<div className="title-right" onClick={(e) => e.stopPropagation()}>
|
||||
@@ -122,7 +155,7 @@ const ItemDetailScreen: React.FC = () => {
|
||||
>−</button>
|
||||
<span className="qty-val">{quantity}</span>
|
||||
<button
|
||||
onClick={() => addToCart(item)}
|
||||
onClick={() => addToCart(item, isParcel)}
|
||||
disabled={isLimitReached || item.stock === 0}
|
||||
className="qty-btn"
|
||||
>+</button>
|
||||
@@ -130,22 +163,48 @@ const ItemDetailScreen: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="detail-badges-row">
|
||||
<div className="badge-item star">
|
||||
<Star size={14} fill="currentColor" />
|
||||
<span>{rating}</span>
|
||||
{ratingCount > 0 ? (
|
||||
<div className="detail-badges-row">
|
||||
<div className="badge-item star">
|
||||
<Star size={14} fill="currentColor" />
|
||||
<span>{rating.toFixed(1)} ({ratingCount} {ratingCount === 1 ? 'review' : 'reviews'})</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="detail-badges-row">
|
||||
<div className="badge-item star empty-rating">
|
||||
<Star size={14} className="text-slate-300" />
|
||||
<span>No reviews yet</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="item-description-section">
|
||||
<p className="item-long-description">
|
||||
{item.longDescription || item.description || 'Quality food prepared with fresh ingredients, crafted with care for a premium taste experience.'}
|
||||
</p>
|
||||
<button className="customize-trigger">
|
||||
Customize <ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{item.parcellable && (
|
||||
<div className="item-parcel-option mb-5">
|
||||
<label className="flex items-center gap-3 cursor-pointer p-4 bg-emerald-50/50 rounded-2xl border border-emerald-100/50 hover:bg-emerald-50 transition-all select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isParcel}
|
||||
onChange={(e) => {
|
||||
const checked = e.target.checked;
|
||||
setIsParcel(checked);
|
||||
if (quantity > 0) {
|
||||
toggleParcel(item.id);
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4 rounded text-emerald-600 focus:ring-emerald-500 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm font-bold text-slate-800">Parcel (+🅡5.00)</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="item-extra-info">
|
||||
<div className="info-row">
|
||||
<span className="info-label">Category</span>
|
||||
@@ -175,7 +234,7 @@ const ItemDetailScreen: React.FC = () => {
|
||||
<footer className="item-footer">
|
||||
<div className="footer-price-info">
|
||||
<span className="total-label">Total amount</span>
|
||||
<span className="total-value">🅡{(item.price * Math.max(1, quantity)).toFixed(2)}</span>
|
||||
<span className="total-value">🅡{((item.price + (isParcel ? 5 : 0)) * Math.max(1, quantity)).toFixed(2)}</span>
|
||||
</div>
|
||||
|
||||
<div className="footer-action">
|
||||
@@ -183,7 +242,7 @@ const ItemDetailScreen: React.FC = () => {
|
||||
className="primary-action-button"
|
||||
onClick={() => {
|
||||
if (quantity === 0) {
|
||||
addToCart(item);
|
||||
addToCart(item, isParcel);
|
||||
} else {
|
||||
// Already in cart, go to cart screen or show visual confirmation
|
||||
window.history.back();
|
||||
|
||||
@@ -11,6 +11,9 @@ export interface FoodItem {
|
||||
stock?: number;
|
||||
stallId?: string;
|
||||
stallName?: string;
|
||||
rating?: number;
|
||||
ratingCount?: number;
|
||||
parcellable?: boolean;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
@@ -26,6 +29,7 @@ export interface CartItem extends FoodItem {
|
||||
quantity: number;
|
||||
stallId?: string;
|
||||
stallName?: string;
|
||||
isParcel?: boolean;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
|
||||
Reference in New Issue
Block a user