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
|
// Use offer price if present, otherwise base price
|
||||||
BigDecimal unitPrice = (product.getOfferPrice() != null && product.getOfferPrice().compareTo(BigDecimal.ZERO) > 0)
|
BigDecimal unitPrice = (product.getOfferPrice() != null && product.getOfferPrice().compareTo(BigDecimal.ZERO) > 0)
|
||||||
? product.getOfferPrice() : product.getPrice();
|
? 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())));
|
serverTotal = serverTotal.add(unitPrice.multiply(BigDecimal.valueOf(item.getQuantity())));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ public class ProductController {
|
|||||||
product.setVeg(productDetails.isVeg());
|
product.setVeg(productDetails.isVeg());
|
||||||
product.setHasAllergy(productDetails.isHasAllergy());
|
product.setHasAllergy(productDetails.isHasAllergy());
|
||||||
product.setParcelNotAllowed(productDetails.isParcelNotAllowed());
|
product.setParcelNotAllowed(productDetails.isParcelNotAllowed());
|
||||||
|
product.setParcellable(productDetails.isParcellable());
|
||||||
product.setSessionOptional(productDetails.isSessionOptional());
|
product.setSessionOptional(productDetails.isSessionOptional());
|
||||||
|
|
||||||
product.getSessions().clear();
|
product.getSessions().clear();
|
||||||
@@ -238,6 +239,7 @@ public class ProductController {
|
|||||||
live.setImageData(productDetails.getImageData());
|
live.setImageData(productDetails.getImageData());
|
||||||
live.setDraft(false);
|
live.setDraft(false);
|
||||||
live.setActive(true);
|
live.setActive(true);
|
||||||
|
live.setParcellable(productDetails.isParcellable());
|
||||||
|
|
||||||
Product savedLive = productRepository.save(live);
|
Product savedLive = productRepository.save(live);
|
||||||
productRepository.delete(product); // Delete the draft
|
productRepository.delete(product); // Delete the draft
|
||||||
@@ -257,6 +259,7 @@ public class ProductController {
|
|||||||
product.setBarcode(productDetails.getBarcode());
|
product.setBarcode(productDetails.getBarcode());
|
||||||
product.setImageData(productDetails.getImageData());
|
product.setImageData(productDetails.getImageData());
|
||||||
product.setStock(productDetails.getStock());
|
product.setStock(productDetails.getStock());
|
||||||
|
product.setParcellable(productDetails.isParcellable());
|
||||||
|
|
||||||
product.getSessions().clear();
|
product.getSessions().clear();
|
||||||
if (productDetails.getSessions() != null) {
|
if (productDetails.getSessions() != null) {
|
||||||
|
|||||||
@@ -49,11 +49,13 @@ public class Product {
|
|||||||
private boolean attributesOptional;
|
private boolean attributesOptional;
|
||||||
|
|
||||||
// Additional Attributes
|
// Additional Attributes
|
||||||
private boolean isVeg;
|
private boolean isVeg = true;
|
||||||
private boolean hasAllergy;
|
private boolean hasAllergy;
|
||||||
|
|
||||||
private boolean parcelNotAllowed;
|
private boolean parcelNotAllowed;
|
||||||
|
|
||||||
|
private boolean parcellable;
|
||||||
|
|
||||||
private boolean sessionOptional;
|
private boolean sessionOptional;
|
||||||
|
|
||||||
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
|
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
|
||||||
@@ -133,6 +135,9 @@ public class Product {
|
|||||||
public boolean isParcelNotAllowed() { return parcelNotAllowed; }
|
public boolean isParcelNotAllowed() { return parcelNotAllowed; }
|
||||||
public void setParcelNotAllowed(boolean parcelNotAllowed) { this.parcelNotAllowed = 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 boolean isSessionOptional() { return sessionOptional; }
|
||||||
public void setSessionOptional(boolean sessionOptional) { this.sessionOptional = sessionOptional; }
|
public void setSessionOptional(boolean sessionOptional) { this.sessionOptional = sessionOptional; }
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ interface Product {
|
|||||||
active: boolean;
|
active: boolean;
|
||||||
stock: number;
|
stock: number;
|
||||||
stalls?: { id: number; name: string }[];
|
stalls?: { id: number; name: string }[];
|
||||||
|
parcellable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Stall {
|
interface Stall {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { apiFetch } from '../api';
|
import { apiFetch } from '../api';
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
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 { 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';
|
import Pagination from '../components/Pagination';
|
||||||
@@ -36,6 +36,7 @@ interface Product {
|
|||||||
active: boolean;
|
active: boolean;
|
||||||
stock: number;
|
stock: number;
|
||||||
stalls?: { id: number; name: string }[];
|
stalls?: { id: number; name: string }[];
|
||||||
|
parcellable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Stall {
|
interface Stall {
|
||||||
@@ -69,14 +70,15 @@ const emptyProduct: Product = {
|
|||||||
parcelCharges: 0,
|
parcelCharges: 0,
|
||||||
barcode: '',
|
barcode: '',
|
||||||
attributesOptional: false,
|
attributesOptional: false,
|
||||||
veg: false,
|
veg: true,
|
||||||
hasAllergy: false,
|
hasAllergy: false,
|
||||||
parcelNotAllowed: false,
|
parcelNotAllowed: false,
|
||||||
sessionOptional: false,
|
sessionOptional: false,
|
||||||
sessions: getDefaultSessions(),
|
sessions: getDefaultSessions(),
|
||||||
imageData: '',
|
imageData: '',
|
||||||
active: true,
|
active: true,
|
||||||
stock: 0
|
stock: 0,
|
||||||
|
parcellable: false
|
||||||
};
|
};
|
||||||
|
|
||||||
const Products = () => {
|
const Products = () => {
|
||||||
@@ -529,16 +531,29 @@ const Products = () => {
|
|||||||
<div className="mt-8 space-y-6">
|
<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-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 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>
|
||||||
<div><p className="text-sm font-bold text-[#1e293b]">Additional Attributes</p></div>
|
<div className="flex gap-4">
|
||||||
<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>
|
<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>
|
</div>
|
||||||
{formData.attributesOptional && (
|
</div>
|
||||||
<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>
|
<div className="flex flex-col justify-center min-w-[150px]">
|
||||||
<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>
|
<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">
|
||||||
</div>
|
<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>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 min-w-[200px]">
|
<div className="flex flex-col gap-4 min-w-[200px]">
|
||||||
|
|||||||
@@ -11,6 +11,14 @@ interface ItemCardProps {
|
|||||||
variant?: 'carousel' | 'list';
|
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 ItemCard: React.FC<ItemCardProps> = ({ item, isLast, variant = 'list' }) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { addToCart, updateQuantity, getItemQuantity } = useCart();
|
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;
|
const isLimitReached = item.stock !== undefined && quantity >= item.stock && item.stock > 0;
|
||||||
|
|
||||||
// Visual helper values matching mockup metadata
|
// 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') {
|
if (variant === 'carousel') {
|
||||||
return (
|
return (
|
||||||
@@ -26,10 +35,12 @@ const ItemCard: React.FC<ItemCardProps> = ({ item, isLast, variant = 'list' }) =
|
|||||||
className={`item-card-carousel ${item.stock === 0 ? 'out-of-stock' : ''}`}
|
className={`item-card-carousel ${item.stock === 0 ? 'out-of-stock' : ''}`}
|
||||||
onClick={() => navigate(`/item/${item.id}`)}
|
onClick={() => navigate(`/item/${item.id}`)}
|
||||||
>
|
>
|
||||||
<div className="carousel-rating-badge">
|
{ratingCount > 0 && (
|
||||||
<Star size={10} fill="currentColor" className="star-icon-filled" />
|
<div className="carousel-rating-badge">
|
||||||
<span>{rating}</span>
|
<Star size={10} fill="currentColor" className="star-icon-filled" />
|
||||||
</div>
|
<span>{rating.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="carousel-image-container">
|
<div className="carousel-image-container">
|
||||||
{item.image ? (
|
{item.image ? (
|
||||||
@@ -40,8 +51,11 @@ const ItemCard: React.FC<ItemCardProps> = ({ item, isLast, variant = 'list' }) =
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="carousel-info">
|
<div className="carousel-info">
|
||||||
<h3 className="carousel-item-name">{item.name}</h3>
|
<div className="carousel-title-row" style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '2px', minWidth: 0 }}>
|
||||||
<p className="carousel-vendor">{item.stallName || 'Cookie Heaven'}</p>
|
<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()}>
|
<div className="carousel-footer" onClick={(e) => e.stopPropagation()}>
|
||||||
<span className="carousel-price">🅡{item.price.toFixed(2)}</span>
|
<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-info">
|
||||||
<div className="list-title-row">
|
<div className="list-title-row">
|
||||||
<h3 className="list-item-name">{item.name}</h3>
|
<div className="list-title-wrapper" style={{ display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0 }}>
|
||||||
<div className="list-rating-badge">
|
<VegNonVegIcon isVeg={item.isVeg} />
|
||||||
<Star size={10} fill="currentColor" className="star-icon-filled" />
|
<h3 className="list-item-name" style={{ lineHeight: 1.2 }}>{item.name}</h3>
|
||||||
<span>{rating}</span>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<p className="list-vendor">{item.stallName || 'Cookie Heaven'}</p>
|
<p className="list-vendor">{item.stallName || 'Unknown Stall'}</p>
|
||||||
<p className="list-address">📍 54 Summit Street</p>
|
|
||||||
|
|
||||||
<div className="list-footer" onClick={(e) => e.stopPropagation()}>
|
<div className="list-footer" onClick={(e) => e.stopPropagation()}>
|
||||||
<span className="list-price">🅡{item.price.toFixed(2)}</span>
|
<span className="list-price">🅡{item.price.toFixed(2)}</span>
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import type { FoodItem, CartItem } from '../types';
|
|||||||
|
|
||||||
interface CartContextType {
|
interface CartContextType {
|
||||||
cart: CartItem[];
|
cart: CartItem[];
|
||||||
addToCart: (item: FoodItem) => void;
|
addToCart: (item: FoodItem, isParcel?: boolean) => void;
|
||||||
removeFromCart: (itemId: string) => void;
|
removeFromCart: (itemId: string) => void;
|
||||||
updateQuantity: (itemId: string, delta: number) => void;
|
updateQuantity: (itemId: string, delta: number) => void;
|
||||||
|
toggleParcel: (itemId: string) => void;
|
||||||
clearCart: () => void;
|
clearCart: () => void;
|
||||||
getItemQuantity: (itemId: string) => number;
|
getItemQuantity: (itemId: string) => number;
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
@@ -29,7 +30,7 @@ export const CartProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
|
|
||||||
const clearStockError = () => setStockError(null);
|
const clearStockError = () => setStockError(null);
|
||||||
|
|
||||||
const addToCart = (item: FoodItem) => {
|
const addToCart = (item: FoodItem, isParcel?: boolean) => {
|
||||||
setCart((prevCart) => {
|
setCart((prevCart) => {
|
||||||
const existingIndex = prevCart.findIndex((i) => i.id === item.id);
|
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] = {
|
||||||
...newCart[existingIndex],
|
...newCart[existingIndex],
|
||||||
quantity: newCart[existingIndex].quantity + 1,
|
quantity: newCart[existingIndex].quantity + 1,
|
||||||
|
isParcel: isParcel !== undefined ? isParcel : newCart[existingIndex].isParcel
|
||||||
};
|
};
|
||||||
return newCart;
|
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) => {
|
const removeFromCart = (itemId: string) => {
|
||||||
setCart((prevCart) => prevCart.filter((item) => item.id !== itemId));
|
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 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 (
|
return (
|
||||||
<CartContext.Provider
|
<CartContext.Provider
|
||||||
@@ -101,6 +111,7 @@ export const CartProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
addToCart,
|
addToCart,
|
||||||
removeFromCart,
|
removeFromCart,
|
||||||
updateQuantity,
|
updateQuantity,
|
||||||
|
toggleParcel,
|
||||||
clearCart,
|
clearCart,
|
||||||
getItemQuantity,
|
getItemQuantity,
|
||||||
totalItems,
|
totalItems,
|
||||||
|
|||||||
@@ -28,11 +28,20 @@ export const FoodProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
if (!silent) setIsLoading(true);
|
if (!silent) setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
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([
|
const [baseItemsRes, productsRes, stallsRes, statsRes] = await Promise.all([
|
||||||
fetch(`${API_BASE_URL}/base-items?size=100`, { cache: 'no-store' }),
|
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}/products?size=100`, { cache: 'no-store' }),
|
||||||
fetch(`${API_BASE_URL}/stalls/active`, { 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) {
|
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 baseItemsData = baseItemsDataRaw.content || baseItemsDataRaw;
|
||||||
const productsData = productsDataRaw.content || productsDataRaw;
|
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[] = [];
|
let topRatedNames: string[] = [];
|
||||||
|
const itemRatingsMap: Record<string, { average: number; count: number }> = {};
|
||||||
if (statsRes && statsRes.ok) {
|
if (statsRes && statsRes.ok) {
|
||||||
try {
|
try {
|
||||||
const statsData = await statsRes.json();
|
const statsData = await statsRes.json();
|
||||||
@@ -59,6 +69,15 @@ export const FoodProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
.filter((x: any) => x.count > 0)
|
.filter((x: any) => x.count > 0)
|
||||||
.slice(0, 3)
|
.slice(0, 3)
|
||||||
.map((x: any) => x.name.toLowerCase());
|
.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) {
|
} catch (e) {
|
||||||
console.error('Error parsing feedback stats:', 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
|
// Dynamic bestseller isPopular flag based on feedback stats
|
||||||
const isBestseller = topRatedNames.includes(item.name.toLowerCase());
|
const isBestseller = topRatedNames.includes(item.name.toLowerCase());
|
||||||
|
const ratingInfo = itemRatingsMap[item.name.toLowerCase()];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: itemId,
|
id: itemId,
|
||||||
@@ -133,7 +153,10 @@ export const FoodProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
isPopular: isBestseller,
|
isPopular: isBestseller,
|
||||||
stock: item.stock,
|
stock: item.stock,
|
||||||
stallId: (stallFromBackend?.id || stallFromMap?.id || stallFromCategory?.id),
|
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 {
|
.latest-qr-wrapper.qr-expired canvas {
|
||||||
filter: blur(6px) grayscale(100%) !important;
|
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 CartScreen: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { cart, updateQuantity, removeFromCart, totalPrice, totalItems } = useCart();
|
const { cart, updateQuantity, removeFromCart, toggleParcel, totalPrice, totalItems } = useCart();
|
||||||
|
|
||||||
if (cart.length === 0) {
|
if (cart.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -45,8 +45,23 @@ const CartScreen: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<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">
|
<div className="cart-quantity-controls">
|
||||||
<button onClick={() => updateQuantity(item.id, -1)}>
|
<button onClick={() => updateQuantity(item.id, -1)}>
|
||||||
|
|||||||
@@ -66,8 +66,8 @@ const CheckoutScreen: React.FC = () => {
|
|||||||
orderType: 'MY_ORDER',
|
orderType: 'MY_ORDER',
|
||||||
items: cart.map(item => ({
|
items: cart.map(item => ({
|
||||||
productId: Number(item.id),
|
productId: Number(item.id),
|
||||||
productName: item.name,
|
productName: item.isParcel ? `${item.name} (Parcel)` : item.name,
|
||||||
price: item.price,
|
price: item.price + (item.isParcel ? 5 : 0),
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
stallId: item.stallId ? Number(item.stallId) : null,
|
stallId: item.stallId ? Number(item.stallId) : null,
|
||||||
stallName: item.stallName || null
|
stallName: item.stallName || null
|
||||||
|
|||||||
@@ -1,23 +1,32 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { FoodItem } from '../types';
|
import type { FoodItem } from '../types';
|
||||||
import { useParams } from 'react-router-dom';
|
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 Header from '../components/Header';
|
||||||
import { useCart } from '../contexts/CartContext';
|
import { useCart } from '../contexts/CartContext';
|
||||||
import { useFood } from '../contexts/FoodContext';
|
import { useFood } from '../contexts/FoodContext';
|
||||||
import CartTab from '../components/CartTab';
|
import CartTab from '../components/CartTab';
|
||||||
|
import { VegNonVegIcon } from '../components/ItemCard';
|
||||||
import './ItemDetailScreen.css';
|
import './ItemDetailScreen.css';
|
||||||
|
|
||||||
const ItemDetailScreen: React.FC = () => {
|
const ItemDetailScreen: React.FC = () => {
|
||||||
const { itemId } = useParams<{ itemId: string }>();
|
const { itemId } = useParams<{ itemId: string }>();
|
||||||
const { addToCart, updateQuantity, getItemQuantity } = useCart();
|
const { cart, addToCart, updateQuantity, getItemQuantity, toggleParcel } = useCart();
|
||||||
const { foodItems, isLoading: isGlobalLoading } = useFood();
|
const { foodItems, isLoading: isGlobalLoading } = useFood();
|
||||||
const [fetchedItem, setFetchedItem] = React.useState<FoodItem | null>(null);
|
const [fetchedItem, setFetchedItem] = React.useState<FoodItem | null>(null);
|
||||||
const [isFetching, setIsFetching] = React.useState(false);
|
const [isFetching, setIsFetching] = React.useState(false);
|
||||||
|
const [isParcel, setIsParcel] = React.useState(false);
|
||||||
|
|
||||||
const contextItem = foodItems.find((i) => i.id === itemId);
|
const contextItem = foodItems.find((i) => i.id === itemId);
|
||||||
const item = contextItem || fetchedItem;
|
const item = contextItem || fetchedItem;
|
||||||
|
|
||||||
|
const cartItem = cart.find((i) => i.id === itemId);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (cartItem) {
|
||||||
|
setIsParcel(!!cartItem.isParcel);
|
||||||
|
}
|
||||||
|
}, [cartItem]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!contextItem && itemId) {
|
if (!contextItem && itemId) {
|
||||||
const fetchItem = async () => {
|
const fetchItem = async () => {
|
||||||
@@ -33,6 +42,23 @@ const ItemDetailScreen: React.FC = () => {
|
|||||||
|
|
||||||
const stallInfo = data.stalls && data.stalls.length > 0 ? data.stalls[0] : null;
|
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 = {
|
const mappedItem: FoodItem = {
|
||||||
id: data.id.toString(),
|
id: data.id.toString(),
|
||||||
name: data.name,
|
name: data.name,
|
||||||
@@ -44,7 +70,10 @@ const ItemDetailScreen: React.FC = () => {
|
|||||||
isPopular: data.active,
|
isPopular: data.active,
|
||||||
stock: data.stock,
|
stock: data.stock,
|
||||||
stallId: stallInfo?.id?.toString(),
|
stallId: stallInfo?.id?.toString(),
|
||||||
stallName: stallInfo?.name
|
stallName: stallInfo?.name,
|
||||||
|
rating: finalRating,
|
||||||
|
ratingCount: finalRatingCount,
|
||||||
|
parcellable: data.parcellable
|
||||||
};
|
};
|
||||||
setFetchedItem(mappedItem);
|
setFetchedItem(mappedItem);
|
||||||
}
|
}
|
||||||
@@ -87,7 +116,8 @@ const ItemDetailScreen: React.FC = () => {
|
|||||||
const isLimitReached = item.stock !== undefined && quantity >= item.stock && item.stock > 0;
|
const isLimitReached = item.stock !== undefined && quantity >= item.stock && item.stock > 0;
|
||||||
|
|
||||||
// Visual helper values matching mockup metadata
|
// 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 (
|
return (
|
||||||
<div className={`container item-detail-page ${item.stock === 0 ? 'out-of-stock' : ''}`}>
|
<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-details-content">
|
||||||
<div className="item-title-section">
|
<div className="item-title-section">
|
||||||
<div className="title-left">
|
<div className="title-left">
|
||||||
<h1 className="item-name-large">{item.name}</h1>
|
<div className="detail-title-wrapper" style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
|
||||||
<p className="item-subtitle">{item.stallName || '54 Summit Street.'}</p>
|
<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>
|
||||||
|
|
||||||
<div className="title-right" onClick={(e) => e.stopPropagation()}>
|
<div className="title-right" onClick={(e) => e.stopPropagation()}>
|
||||||
@@ -122,7 +155,7 @@ const ItemDetailScreen: React.FC = () => {
|
|||||||
>−</button>
|
>−</button>
|
||||||
<span className="qty-val">{quantity}</span>
|
<span className="qty-val">{quantity}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => addToCart(item)}
|
onClick={() => addToCart(item, isParcel)}
|
||||||
disabled={isLimitReached || item.stock === 0}
|
disabled={isLimitReached || item.stock === 0}
|
||||||
className="qty-btn"
|
className="qty-btn"
|
||||||
>+</button>
|
>+</button>
|
||||||
@@ -130,22 +163,48 @@ const ItemDetailScreen: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="detail-badges-row">
|
{ratingCount > 0 ? (
|
||||||
<div className="badge-item star">
|
<div className="detail-badges-row">
|
||||||
<Star size={14} fill="currentColor" />
|
<div className="badge-item star">
|
||||||
<span>{rating}</span>
|
<Star size={14} fill="currentColor" />
|
||||||
|
<span>{rating.toFixed(1)} ({ratingCount} {ratingCount === 1 ? 'review' : 'reviews'})</span>
|
||||||
|
</div>
|
||||||
</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">
|
<div className="item-description-section">
|
||||||
<p className="item-long-description">
|
<p className="item-long-description">
|
||||||
{item.longDescription || item.description || 'Quality food prepared with fresh ingredients, crafted with care for a premium taste experience.'}
|
{item.longDescription || item.description || 'Quality food prepared with fresh ingredients, crafted with care for a premium taste experience.'}
|
||||||
</p>
|
</p>
|
||||||
<button className="customize-trigger">
|
|
||||||
Customize <ChevronRight size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</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="item-extra-info">
|
||||||
<div className="info-row">
|
<div className="info-row">
|
||||||
<span className="info-label">Category</span>
|
<span className="info-label">Category</span>
|
||||||
@@ -175,7 +234,7 @@ const ItemDetailScreen: React.FC = () => {
|
|||||||
<footer className="item-footer">
|
<footer className="item-footer">
|
||||||
<div className="footer-price-info">
|
<div className="footer-price-info">
|
||||||
<span className="total-label">Total amount</span>
|
<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>
|
||||||
|
|
||||||
<div className="footer-action">
|
<div className="footer-action">
|
||||||
@@ -183,7 +242,7 @@ const ItemDetailScreen: React.FC = () => {
|
|||||||
className="primary-action-button"
|
className="primary-action-button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (quantity === 0) {
|
if (quantity === 0) {
|
||||||
addToCart(item);
|
addToCart(item, isParcel);
|
||||||
} else {
|
} else {
|
||||||
// Already in cart, go to cart screen or show visual confirmation
|
// Already in cart, go to cart screen or show visual confirmation
|
||||||
window.history.back();
|
window.history.back();
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ export interface FoodItem {
|
|||||||
stock?: number;
|
stock?: number;
|
||||||
stallId?: string;
|
stallId?: string;
|
||||||
stallName?: string;
|
stallName?: string;
|
||||||
|
rating?: number;
|
||||||
|
ratingCount?: number;
|
||||||
|
parcellable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Category {
|
export interface Category {
|
||||||
@@ -26,6 +29,7 @@ export interface CartItem extends FoodItem {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
stallId?: string;
|
stallId?: string;
|
||||||
stallName?: string;
|
stallName?: string;
|
||||||
|
isParcel?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
|
|||||||
Reference in New Issue
Block a user