Skip to content

Commit 73eb466

Browse files
feat: add increase/decrease product quantity from firebase when using cart (#72)
* feat: implement add/remove item from quantity in firebase * test cases update * erase * feat: add/subtract from quantity in firestore wqhen using cart * feat: live stock gets displayed when adjusted and adds remove button for each item * fix: had to remove small whitespace --------- Co-authored-by: mercedes-mathews <[email protected]>
1 parent e6bec87 commit 73eb466

File tree

6 files changed

+174
-100
lines changed

6 files changed

+174
-100
lines changed

market-manager/src/app/customerCart/CustomerCart.module.css

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,23 @@
4242
transition: transform 0.3s ease;
4343
}
4444

45-
.button:hover {
45+
.button:hover .removeButton:hover{
4646
background: #5a791dd2;
4747
transform: translateY(-2px);
4848
}
4949

50+
.removeButton {
51+
padding: 0.75rem 1.5rem;
52+
background: linear-gradient(to right, #6a8e23d2, #749a28);
53+
color: #ffffff;
54+
font-weight: 600;
55+
border: none;
56+
border-radius: 0.5rem;
57+
cursor: pointer;
58+
transition: transform 0.3s ease;
59+
}
60+
61+
5062
/* Table Styles */
5163
.table {
5264
width: 100%;

market-manager/src/app/customerCart/page.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22

33
import { useEffect, useState } from 'react';
44
import Link from 'next/link';
5-
import { getCartFromFirestore } from '@/src/lib/cart';
5+
import { getCartFromFirestore, removeProductFromCart } from '@/src/lib/cart';
66
import useUser from '../hooks/useUser';
77
import { Product } from '@/src/types/product';
88
import styles from './CustomerCart.module.css';
99

10+
1011
export default function CustomerCart() {
1112
const user = useUser();
1213
const [items, setItems] = useState<(Product & { quantity: number })[]>([]);
@@ -21,6 +22,19 @@ export default function CustomerCart() {
2122

2223
const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
2324

25+
const handleRemove = async (productId: string) => {
26+
if (!user?.uid) return;
27+
28+
try {
29+
await removeProductFromCart(user.uid, productId);
30+
const updatedCart = await getCartFromFirestore(user.uid);
31+
setItems(updatedCart);
32+
} catch (error) {
33+
console.error('Error removing item:', error);
34+
}
35+
};
36+
37+
2438
return (
2539
<div className={styles.container}>
2640
<div className={styles.section}>
@@ -36,6 +50,7 @@ export default function CustomerCart() {
3650
<th>Qty</th>
3751
<th>Price</th>
3852
<th>Subtotal</th>
53+
<th></th>
3954
</tr>
4055
</thead>
4156
<tbody>
@@ -45,13 +60,22 @@ export default function CustomerCart() {
4560
<td>{item.quantity}</td>
4661
<td>${item.price.toFixed(2)}</td>
4762
<td>${(item.price * item.quantity).toFixed(2)}</td>
63+
<td>
64+
<button
65+
className={styles.removeButton}
66+
onClick={() => handleRemove(item.id)}
67+
>
68+
Remove
69+
</button>
70+
</td>
4871
</tr>
4972
))}
5073
</tbody>
5174
<tfoot>
5275
<tr>
5376
<td colSpan={3}><strong>Total</strong></td>
5477
<td><strong>${total.toFixed(2)}</strong></td>
78+
<td colSpan={1}></td>
5579
</tr>
5680
</tfoot>
5781
</table>

market-manager/src/app/home/HomePage.tsx

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,22 +10,27 @@ import { addProductToCart } from '@/src/lib/cart';
1010
import { auth } from '@/src/lib/firebase';
1111
import { ToastContainer, toast } from 'react-toastify';
1212
import 'react-toastify/dist/ReactToastify.css';
13+
import { collection, onSnapshot } from 'firebase/firestore';
14+
import { db } from '@/src/lib/firebase';
1315

1416
export default function HomePage() {
1517
const [products, setProducts] = useState<Product[]>([]);
1618
const [isShrunk, setIsShrunk] = useState(false);
1719
const [quantities, setQuantities] = useState<{ [productId: string]: number }>({});
1820

1921
useEffect(() => {
20-
fetch('/api/products')
21-
.then((response) => response.json())
22-
.then((data) => {
23-
setProducts(data);
24-
})
25-
.catch((error) => {
26-
console.error('Error calling fruit API:', error);
27-
});
22+
const unsubscribe = onSnapshot(collection(db, 'products'), (snapshot) => {
23+
const realTimeProducts: Product[] = snapshot.docs.map((doc) => ({
24+
id: doc.id,
25+
...doc.data(),
26+
})) as Product[];
27+
28+
setProducts(realTimeProducts);
29+
});
30+
31+
return () => unsubscribe(); // Clean up listener on unmount
2832
}, []);
33+
2934

3035
useEffect(() => {
3136
const handleScroll = () => {
@@ -60,13 +65,19 @@ export default function HomePage() {
6065
await addProductToCart(user.uid, product, quantity);
6166
toast.success(`Added ${quantity} of ${product.name} to cart!`);
6267
setQuantities((prev) => ({ ...prev, [product.id]: 0 }));
63-
} catch (err) {
68+
} catch (err: unknown) {
6469
console.error(err);
65-
toast.error('Failed to add to cart.');
70+
71+
if (err instanceof Error && err.message.toLowerCase().includes('stock')) {
72+
toast.error(err.message);
73+
} else {
74+
toast.error('Failed to add to cart.');
75+
}
6676
}
6777
};
6878

6979

80+
7081

7182
return (
7283
<>
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { useEffect, useState } from 'react';
2+
import { doc, onSnapshot } from 'firebase/firestore';
3+
import { db } from '@/src/lib/firebase';
4+
5+
export function useRealTimeStock(productId: string) {
6+
const [stock, setStock] = useState<number | null>(null);
7+
8+
useEffect(() => {
9+
const productRef = doc(db, 'products', productId);
10+
11+
const unsubscribe = onSnapshot(productRef, (snapshot) => {
12+
if (snapshot.exists()) {
13+
const data = snapshot.data();
14+
setStock(data.quantity ?? 0);
15+
}
16+
});
17+
18+
return () => unsubscribe(); // Cleanup when component unmounts
19+
}, [productId]);
20+
21+
return stock;
22+
}

market-manager/src/lib/cart.ts

Lines changed: 93 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,33 +3,85 @@ import {
33
doc,
44
getDoc,
55
setDoc,
6-
serverTimestamp
6+
serverTimestamp,
7+
runTransaction,
78
} from 'firebase/firestore';
89
import type { Product } from '@/src/types/product';
910

11+
// ✅ Updated: use Firestore transaction to safely update stock
12+
async function updateProductStock(productId: string, quantityDelta: number) {
13+
const productRef = doc(db, 'products', productId);
14+
15+
try {
16+
await runTransaction(db, async (transaction) => {
17+
const productDoc = await transaction.get(productRef);
18+
19+
if (!productDoc.exists()) {
20+
throw new Error('Product does not exist!');
21+
}
22+
23+
const currentStock = productDoc.data().quantity || 0;
24+
const newStock = currentStock + quantityDelta;
25+
26+
if (newStock < 0) {
27+
throw new Error('Not enough stock available');
28+
}
29+
30+
transaction.update(productRef, { quantity: newStock });
31+
});
32+
} catch (error) {
33+
console.error('Failed to update stock:', error);
34+
throw error;
35+
}
36+
}
1037

1138
export async function addProductToCart(
1239
userId: string,
1340
product: Product,
1441
quantity: number
1542
) {
1643
const cartRef = doc(db, 'carts', userId);
17-
const snapshot = await getDoc(cartRef);
44+
const productRef = doc(db, 'products', product.id);
45+
46+
const [cartSnap, productSnap] = await Promise.all([
47+
getDoc(cartRef),
48+
getDoc(productRef),
49+
]);
50+
51+
if (!productSnap.exists()) {
52+
throw new Error('Product does not exist!');
53+
}
54+
55+
const availableStock = productSnap.data().quantity || 0;
56+
57+
if (quantity > availableStock) {
58+
throw new Error(`Only ${availableStock} of ${product.name} left in stock`);
59+
}
60+
1861
let existingProducts: (Product & { quantity: number })[] = [];
1962

20-
if (snapshot.exists()) {
21-
existingProducts = snapshot.data().products || [];
63+
if (cartSnap.exists()) {
64+
existingProducts = cartSnap.data().products || [];
2265
}
2366

2467
const updatedProducts = [...existingProducts];
2568
const index = updatedProducts.findIndex((p) => p.id === product.id);
2669

2770
if (index !== -1) {
71+
// ✅ We no longer need to pre-check cart + quantity vs stock here
2872
updatedProducts[index].quantity += quantity;
2973
} else {
3074
updatedProducts.push({ ...product, quantity });
3175
}
3276

77+
// ✅ Still use the transaction to actually update stock safely
78+
try {
79+
await updateProductStock(product.id, -quantity);
80+
} catch (error) {
81+
console.error('Error updating stock:', error);
82+
throw new Error('Failed to update stock');
83+
}
84+
3385
await setDoc(
3486
cartRef,
3587
{
@@ -42,6 +94,42 @@ export async function addProductToCart(
4294
}
4395

4496

97+
export async function removeProductFromCart(
98+
userId: string,
99+
productId: string
100+
) {
101+
const cartRef = doc(db, 'carts', userId);
102+
const snapshot = await getDoc(cartRef);
103+
104+
if (!snapshot.exists()) return;
105+
106+
const existingProducts: (Product & { quantity: number })[] =
107+
snapshot.data().products || [];
108+
109+
const removedProduct = existingProducts.find((p) => p.id === productId);
110+
111+
if (!removedProduct) return;
112+
113+
const updatedProducts = existingProducts.filter((p) => p.id !== productId);
114+
115+
// ✅ Add product quantity back to stock
116+
try {
117+
await updateProductStock(productId, removedProduct.quantity);
118+
} catch (error) {
119+
console.error('Error restoring stock on removal:', error);
120+
}
121+
122+
await setDoc(
123+
cartRef,
124+
{
125+
customerId: userId,
126+
products: updatedProducts,
127+
updatedAt: serverTimestamp(),
128+
},
129+
{ merge: true }
130+
);
131+
}
132+
45133
export async function saveCartToFirestore(
46134
userId: string,
47135
products: (Product & { quantity: number })[]
@@ -70,4 +158,4 @@ export async function getCartFromFirestore(
70158
}
71159

72160
return [];
73-
}
161+
}

market-manager/src/test/cart.test.ts

Lines changed: 0 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,83 +0,0 @@
1-
import {
2-
saveCartToFirestore,
3-
getCartFromFirestore,
4-
} from '../lib/cart';
5-
import {
6-
doc,
7-
getDoc,
8-
setDoc,
9-
} from 'firebase/firestore';
10-
11-
jest.mock('../lib/firebase', () => ({
12-
db: {}, // mock the Firestore db export
13-
}));
14-
15-
jest.mock('firebase/firestore', () => ({
16-
doc: jest.fn(),
17-
getDoc: jest.fn(),
18-
setDoc: jest.fn(),
19-
serverTimestamp: jest.fn(() => 'mockTimestamp'),
20-
}));
21-
22-
describe('Firestore Cart Functions', () => {
23-
const mockUserId = 'testUser123';
24-
const mockCartRef = { path: 'mock/doc/path' };
25-
const mockProducts = [
26-
{ id: '1', name: 'Apple', price: 1.0, quantity: 3, category: 'fruit' },
27-
{ id: '2', name: 'Bread', price: 2.5, quantity: 1, category: 'bakery' },
28-
];
29-
30-
beforeEach(() => {
31-
jest.clearAllMocks();
32-
(doc as jest.Mock).mockReturnValue(mockCartRef);
33-
});
34-
35-
it('should save cart to Firestore', async () => {
36-
await saveCartToFirestore(mockUserId, mockProducts);
37-
38-
expect(doc).toHaveBeenCalledWith(expect.anything(), 'carts', mockUserId);
39-
expect(setDoc).toHaveBeenCalledWith(
40-
mockCartRef,
41-
{
42-
customerId: mockUserId,
43-
products: mockProducts,
44-
updatedAt: 'mockTimestamp',
45-
createdAt: 'mockTimestamp',
46-
},
47-
{ merge: true }
48-
);
49-
});
50-
51-
it('should return products from Firestore if document exists', async () => {
52-
(getDoc as jest.Mock).mockResolvedValue({
53-
exists: () => true,
54-
data: () => ({
55-
products: mockProducts,
56-
}),
57-
});
58-
59-
const result = await getCartFromFirestore(mockUserId);
60-
expect(doc).toHaveBeenCalledWith(expect.anything(), 'carts', mockUserId);
61-
expect(getDoc).toHaveBeenCalledWith(mockCartRef);
62-
expect(result).toEqual(mockProducts);
63-
});
64-
65-
it('should return empty array if document does not exist', async () => {
66-
(getDoc as jest.Mock).mockResolvedValue({
67-
exists: () => false,
68-
});
69-
70-
const result = await getCartFromFirestore(mockUserId);
71-
expect(result).toEqual([]);
72-
});
73-
74-
it('should return empty array if products field is undefined', async () => {
75-
(getDoc as jest.Mock).mockResolvedValue({
76-
exists: () => true,
77-
data: () => ({}),
78-
});
79-
80-
const result = await getCartFromFirestore(mockUserId);
81-
expect(result).toEqual([]);
82-
});
83-
});

0 commit comments

Comments
 (0)