11 changed files with 2918 additions and 564 deletions
@ -0,0 +1,55 @@ |
|||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const express = require('express'); |
||||||
|
const stripe = require('stripe')(process.env.REACT_APP_STRIPE_SKEY); |
||||||
|
const app = express(); |
||||||
|
|
||||||
|
app.use(express.json()); |
||||||
|
|
||||||
|
app.post('/api/create-payment-intent', async (req, res) => { |
||||||
|
try { |
||||||
|
const { amount, currency = 'usd', items } = req.body; |
||||||
|
|
||||||
|
// Create a PaymentIntent with the order amount and currency
|
||||||
|
const paymentIntent = await stripe.paymentIntents.create({ |
||||||
|
amount: amount, |
||||||
|
currency: currency, |
||||||
|
metadata: { |
||||||
|
integration_check: 'accept_a_payment', |
||||||
|
items: JSON.stringify(items) |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
res.send({ |
||||||
|
clientSecret: paymentIntent.client_secret |
||||||
|
}); |
||||||
|
} catch (error) { |
||||||
|
console.error('Error creating payment intent:', error); |
||||||
|
res.status(400).send({ error: error.message }); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
// Additional endpoints for order management
|
||||||
|
app.post('/api/create-order', async (req, res) => { |
||||||
|
try { |
||||||
|
// Process order and save to database
|
||||||
|
const order = { |
||||||
|
items: req.body.items, |
||||||
|
total: req.body.total, |
||||||
|
status: 'pending', |
||||||
|
createdAt: new Date() |
||||||
|
}; |
||||||
|
|
||||||
|
// Save to your database
|
||||||
|
// const savedOrder = await saveOrderToDatabase(order);
|
||||||
|
|
||||||
|
res.json({ order }); |
||||||
|
} catch (error) { |
||||||
|
res.status(400).json({ error: 'Failed to create order' }); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
app.listen(process.env.SERVER_PORT || 5000, () => { |
||||||
|
console.log('test', process.env.SERVER_PORT); |
||||||
|
console.log(`Server running on port ${process.env.SERVER_PORT || 5000}`); |
||||||
|
}); |
||||||
@ -1,38 +1,263 @@ |
|||||||
.App { |
.App { |
||||||
|
max-width: 1200px; |
||||||
|
margin: 0 auto; |
||||||
|
padding: 20px; |
||||||
|
font-family: Arial, sans-serif; |
||||||
|
} |
||||||
|
|
||||||
|
.header { |
||||||
|
background-color: #333; |
||||||
|
color: white; |
||||||
|
padding: 1rem; |
||||||
|
position: sticky; |
||||||
|
top: 0; |
||||||
|
z-index: 100; |
||||||
|
} |
||||||
|
|
||||||
|
.header-content { |
||||||
|
display: flex; |
||||||
|
justify-content: space-between; |
||||||
|
align-items: center; |
||||||
|
} |
||||||
|
|
||||||
|
.cart-count { |
||||||
|
background-color: #ff4d4d; |
||||||
|
border-radius: 50%; |
||||||
|
padding: 0.2rem 0.5rem; |
||||||
|
font-size: 0.8rem; |
||||||
|
margin-left: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.main-content { |
||||||
|
display: grid; |
||||||
|
grid-template-columns: 2fr 1fr; |
||||||
|
gap: 2rem; |
||||||
|
margin-top: 2rem; |
||||||
|
} |
||||||
|
|
||||||
|
.product-list h2, .cart h2 { |
||||||
|
margin-bottom: 1rem; |
||||||
|
color: #333; |
||||||
|
} |
||||||
|
|
||||||
|
.products-grid { |
||||||
|
display: grid; |
||||||
|
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); |
||||||
|
gap: 1.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.product-item { |
||||||
|
border: 1px solid #ddd; |
||||||
|
border-radius: 8px; |
||||||
|
padding: 1rem; |
||||||
text-align: center; |
text-align: center; |
||||||
|
background: #f9f9f9; |
||||||
} |
} |
||||||
|
|
||||||
.App-logo { |
.product-item img { |
||||||
height: 40vmin; |
max-width: 100%; |
||||||
pointer-events: none; |
height: 200px; |
||||||
|
object-fit: cover; |
||||||
|
border-radius: 4px; |
||||||
} |
} |
||||||
|
|
||||||
@media (prefers-reduced-motion: no-preference) { |
.product-item h3 { |
||||||
.App-logo { |
margin: 0.5rem 0; |
||||||
animation: App-logo-spin infinite 20s linear; |
font-size: 1.2rem; |
||||||
} |
} |
||||||
|
|
||||||
|
.price { |
||||||
|
font-weight: bold; |
||||||
|
color: #007bff; |
||||||
|
font-size: 1.1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.description { |
||||||
|
color: #666; |
||||||
|
font-size: 0.9rem; |
||||||
|
margin: 0.5rem 0; |
||||||
|
} |
||||||
|
|
||||||
|
.product-item button { |
||||||
|
background-color: #007bff; |
||||||
|
color: white; |
||||||
|
border: none; |
||||||
|
padding: 0.5rem 1rem; |
||||||
|
border-radius: 4px; |
||||||
|
cursor: pointer; |
||||||
|
margin-top: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.product-item button:hover { |
||||||
|
background-color: #0056b3; |
||||||
|
} |
||||||
|
|
||||||
|
.cart { |
||||||
|
border: 1px solid #ddd; |
||||||
|
border-radius: 8px; |
||||||
|
padding: 1rem; |
||||||
|
background: #f9f9f9; |
||||||
} |
} |
||||||
|
|
||||||
.App-header { |
.cart-items { |
||||||
background-color: #282c34; |
list-style: none; |
||||||
min-height: 100vh; |
padding: 0; |
||||||
|
} |
||||||
|
|
||||||
|
.cart-item { |
||||||
display: flex; |
display: flex; |
||||||
flex-direction: column; |
justify-content: space-between; |
||||||
align-items: center; |
align-items: center; |
||||||
justify-content: center; |
padding: 0.5rem 0; |
||||||
font-size: calc(10px + 2vmin); |
border-bottom: 1px solid #eee; |
||||||
|
} |
||||||
|
|
||||||
|
.quantity-controls { |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
} |
||||||
|
|
||||||
|
.quantity-controls button { |
||||||
|
width: 25px; |
||||||
|
height: 25px; |
||||||
|
margin: 0 0.2rem; |
||||||
|
border: 1px solid #ccc; |
||||||
|
background: white; |
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
|
|
||||||
|
.cart-total { |
||||||
|
margin: 1rem 0; |
||||||
|
padding-top: 1rem; |
||||||
|
border-top: 2px solid #333; |
||||||
|
text-align: right; |
||||||
|
} |
||||||
|
|
||||||
|
.checkout-btn { |
||||||
|
width: 100%; |
||||||
|
padding: 0.75rem; |
||||||
|
background-color: #28a745; |
||||||
color: white; |
color: white; |
||||||
|
border: none; |
||||||
|
border-radius: 4px; |
||||||
|
font-size: 1rem; |
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
|
|
||||||
|
.checkout-btn:hover { |
||||||
|
background-color: #218838; |
||||||
} |
} |
||||||
|
|
||||||
.App-link { |
.loading { |
||||||
color: #61dafb; |
text-align: center; |
||||||
|
padding: 2rem; |
||||||
|
font-size: 1.2rem; |
||||||
} |
} |
||||||
|
|
||||||
@keyframes App-logo-spin { |
@media (max-width: 768px) { |
||||||
from { |
.main-content { |
||||||
transform: rotate(0deg); |
grid-template-columns: 1fr; |
||||||
} |
} |
||||||
to { |
|
||||||
transform: rotate(360deg); |
.products-grid { |
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); |
||||||
} |
} |
||||||
} |
} |
||||||
|
|
||||||
|
.modal-overlay { |
||||||
|
position: fixed; |
||||||
|
top: 0; |
||||||
|
left: 0; |
||||||
|
right: 0; |
||||||
|
bottom: 0; |
||||||
|
background: rgba(0, 0, 0, 0.5); |
||||||
|
display: flex; |
||||||
|
justify-content: center; |
||||||
|
align-items: center; |
||||||
|
z-index: 1000; |
||||||
|
} |
||||||
|
|
||||||
|
.modal-content { |
||||||
|
background: white; |
||||||
|
padding: 2rem; |
||||||
|
border-radius: 8px; |
||||||
|
width: 90%; |
||||||
|
max-width: 500px; |
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); |
||||||
|
} |
||||||
|
|
||||||
|
.close-modal { |
||||||
|
margin-top: 1rem; |
||||||
|
padding: 0.5rem 1rem; |
||||||
|
background: #6c757d; |
||||||
|
color: white; |
||||||
|
border: none; |
||||||
|
border-radius: 4px; |
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
|
|
||||||
|
.close-modal:hover { |
||||||
|
background: #5a6268; |
||||||
|
} |
||||||
|
|
||||||
|
.form-row { |
||||||
|
margin-bottom: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.form-row label { |
||||||
|
display: block; |
||||||
|
margin-bottom: 0.5rem; |
||||||
|
font-weight: bold; |
||||||
|
} |
||||||
|
|
||||||
|
.submit-button { |
||||||
|
width: 100%; |
||||||
|
padding: 1rem; |
||||||
|
background: #007bff; |
||||||
|
color: white; |
||||||
|
border: none; |
||||||
|
border-radius: 4px; |
||||||
|
font-size: 1rem; |
||||||
|
cursor: pointer; |
||||||
|
transition: background 0.3s; |
||||||
|
} |
||||||
|
|
||||||
|
.submit-button:hover:not(:disabled) { |
||||||
|
background: #0056b3; |
||||||
|
} |
||||||
|
|
||||||
|
.submit-button:disabled { |
||||||
|
background: #6c757d; |
||||||
|
cursor: not-allowed; |
||||||
|
} |
||||||
|
|
||||||
|
.error-message { |
||||||
|
color: #dc3545; |
||||||
|
margin-top: 0.5rem; |
||||||
|
padding: 0.5rem; |
||||||
|
background: #f8d7da; |
||||||
|
border-radius: 4px; |
||||||
|
} |
||||||
|
|
||||||
|
.order-complete { |
||||||
|
position: fixed; |
||||||
|
top: 50%; |
||||||
|
left: 50%; |
||||||
|
transform: translate(-50%, -50%); |
||||||
|
background: white; |
||||||
|
padding: 2rem; |
||||||
|
border-radius: 8px; |
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); |
||||||
|
text-align: center; |
||||||
|
z-index: 1001; |
||||||
|
} |
||||||
|
|
||||||
|
.order-complete button { |
||||||
|
margin-top: 1rem; |
||||||
|
padding: 0.5rem 1rem; |
||||||
|
background: #28a745; |
||||||
|
color: white; |
||||||
|
border: none; |
||||||
|
border-radius: 4px; |
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
@ -1,25 +1,134 @@ |
|||||||
import logo from './logo.svg'; |
import React, { useState, useEffect } from 'react'; |
||||||
|
import { loadStripe } from '@stripe/stripe-js'; |
||||||
|
import { Elements } from '@stripe/react-stripe-js'; |
||||||
|
import ProductList from './components/ProductList'; |
||||||
|
import Cart from './components/Cart'; |
||||||
|
import Header from './components/Header'; |
||||||
|
import CheckoutForm from './components/CheckoutForm'; |
||||||
import './App.css'; |
import './App.css'; |
||||||
|
|
||||||
function App() { |
// Initialize Stripe (use your publishable key)
|
||||||
|
const stripePromise = loadStripe(process.env.REACT_APP_STRIPE_PKEY); |
||||||
|
|
||||||
|
const App = () => { |
||||||
|
console.log('stripe', process.env.REACT_APP_STRIPE_PKEY) |
||||||
|
const [products, setProducts] = useState([]); |
||||||
|
const [cart, setCart] = useState([]); |
||||||
|
const [loading, setLoading] = useState(true); |
||||||
|
const [isCheckoutOpen, setIsCheckoutOpen] = useState(false); |
||||||
|
const [orderComplete, setOrderComplete] = useState(false); |
||||||
|
|
||||||
|
// Mock product data
|
||||||
|
const mockProducts = [ |
||||||
|
{ id: 1, name: 'Product 1', price: 29.99, image: '/placeholder1.jpg', description: 'Description 1' }, |
||||||
|
{ id: 2, name: 'Product 2', price: 39.99, image: '/placeholder2.jpg', description: 'Description 2' }, |
||||||
|
{ id: 3, name: 'Product 3', price: 19.99, image: '/placeholder3.jpg', description: 'Description 3' }, |
||||||
|
{ id: 4, name: 'Product 4', price: 49.99, image: '/placeholder4.jpg', description: 'Description 4' }, |
||||||
|
]; |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
// Simulate API fetch
|
||||||
|
setTimeout(() => { |
||||||
|
setProducts(mockProducts); |
||||||
|
setLoading(false); |
||||||
|
}, 1000); |
||||||
|
}, []); |
||||||
|
|
||||||
|
const addToCart = (product) => { |
||||||
|
setCart(prevCart => { |
||||||
|
const existingItem = prevCart.find(item => item.id === product.id); |
||||||
|
if (existingItem) { |
||||||
|
return prevCart.map(item => |
||||||
|
item.id === product.id |
||||||
|
? { ...item, quantity: item.quantity + 1 } |
||||||
|
: item |
||||||
|
); |
||||||
|
} |
||||||
|
return [...prevCart, { ...product, quantity: 1 }]; |
||||||
|
}); |
||||||
|
}; |
||||||
|
|
||||||
|
const removeFromCart = (productId) => { |
||||||
|
setCart(prevCart => prevCart.filter(item => item.id !== productId)); |
||||||
|
}; |
||||||
|
|
||||||
|
const updateQuantity = (productId, quantity) => { |
||||||
|
if (quantity < 1) return; |
||||||
|
setCart(prevCart => |
||||||
|
prevCart.map(item => |
||||||
|
item.id === productId ? { ...item, quantity } : item |
||||||
|
) |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
const cartTotal = cart.reduce((total, item) => total + (item.price * item.quantity), 0); |
||||||
|
|
||||||
|
const handleCheckout = () => { |
||||||
|
setIsCheckoutOpen(true); |
||||||
|
}; |
||||||
|
|
||||||
|
const handlePaymentSuccess = () => { |
||||||
|
setIsCheckoutOpen(false); |
||||||
|
setOrderComplete(true); |
||||||
|
setCart([]); // Clear cart after successful payment
|
||||||
|
}; |
||||||
|
|
||||||
|
const handlePaymentError = () => { |
||||||
|
setIsCheckoutOpen(false); |
||||||
|
}; |
||||||
|
|
||||||
return ( |
return ( |
||||||
<div className="App"> |
<div className="App"> |
||||||
<header className="App-header"> |
<Header cartCount={cart.length} /> |
||||||
<img src={logo} className="App-logo" alt="logo" /> |
<main className="main-content"> |
||||||
<p> |
{loading ? ( |
||||||
Edit <code>src/App.js</code> and save to reload. |
<div className="loading">Loading products...</div> |
||||||
</p> |
) : ( |
||||||
<a |
<> |
||||||
className="App-link" |
<ProductList products={products} onAddToCart={addToCart} /> |
||||||
href="https://reactjs.org" |
<Cart
|
||||||
target="_blank" |
items={cart}
|
||||||
rel="noopener noreferrer" |
onRemove={removeFromCart} |
||||||
> |
onUpdateQuantity={updateQuantity} |
||||||
Learn React |
total={cartTotal} |
||||||
</a> |
onCheckout={handleCheckout} |
||||||
</header> |
/> |
||||||
|
</> |
||||||
|
)} |
||||||
|
</main> |
||||||
|
|
||||||
|
{/* Stripe Checkout Modal */} |
||||||
|
{isCheckoutOpen && ( |
||||||
|
<div className="modal-overlay"> |
||||||
|
<div className="modal-content"> |
||||||
|
<h2>Checkout</h2> |
||||||
|
<Elements stripe={stripePromise}> |
||||||
|
<CheckoutForm
|
||||||
|
amount={cartTotal * 100} // Stripe expects amount in cents
|
||||||
|
onPaymentSuccess={handlePaymentSuccess} |
||||||
|
onPaymentError={handlePaymentError} |
||||||
|
cartItems={cart} |
||||||
|
/> |
||||||
|
</Elements> |
||||||
|
<button
|
||||||
|
className="close-modal"
|
||||||
|
onClick={() => setIsCheckoutOpen(false)} |
||||||
|
> |
||||||
|
Close |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
|
||||||
|
{orderComplete && ( |
||||||
|
<div className="order-complete"> |
||||||
|
<h2>Order Successful!</h2> |
||||||
|
<p>Thank you for your purchase. Your order is being processed.</p> |
||||||
|
<button onClick={() => setOrderComplete(false)}>Continue Shopping</button> |
||||||
|
</div> |
||||||
|
)} |
||||||
</div> |
</div> |
||||||
); |
); |
||||||
} |
}; |
||||||
|
|
||||||
export default App; |
export default App; |
||||||
@ -0,0 +1,35 @@ |
|||||||
|
import React from 'react'; |
||||||
|
|
||||||
|
const Cart = ({ items, onRemove, onUpdateQuantity, onCheckout, total }) => { |
||||||
|
return ( |
||||||
|
<div className="cart"> |
||||||
|
<h2>Shopping Cart</h2> |
||||||
|
{items.length === 0 ? ( |
||||||
|
<p>Your cart is empty</p> |
||||||
|
) : ( |
||||||
|
<> |
||||||
|
<ul className="cart-items"> |
||||||
|
{items.map(item => ( |
||||||
|
<li key={item.id} className="cart-item"> |
||||||
|
<span>{item.name}</span> |
||||||
|
<span>${(item.price * item.quantity).toFixed(2)}</span> |
||||||
|
<div className="quantity-controls"> |
||||||
|
<button onClick={() => onUpdateQuantity(item.id, item.quantity - 1)}>-</button> |
||||||
|
<span>{item.quantity}</span> |
||||||
|
<button onClick={() => onUpdateQuantity(item.id, item.quantity + 1)}>+</button> |
||||||
|
</div> |
||||||
|
<button onClick={() => onRemove(item.id)}>Remove</button> |
||||||
|
</li> |
||||||
|
))} |
||||||
|
</ul> |
||||||
|
<div className="cart-total"> |
||||||
|
<strong>Total: ${total.toFixed(2)}</strong> |
||||||
|
</div> |
||||||
|
<button onClick={() => onCheckout()} className="checkout-btn">Proceed to Checkout</button> |
||||||
|
</> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
export default Cart; |
||||||
@ -0,0 +1,82 @@ |
|||||||
|
import React, { useState } from 'react'; |
||||||
|
import { useStripe, useElements, CardElement } from '@stripe/react-stripe-js'; |
||||||
|
|
||||||
|
const CheckoutForm = ({ amount, onPaymentSuccess, onPaymentError, cartItems }) => { |
||||||
|
const stripe = useStripe(); |
||||||
|
const elements = useElements(); |
||||||
|
const [loading, setLoading] = useState(false); |
||||||
|
const [error, setError] = useState(null); |
||||||
|
|
||||||
|
const handleSubmit = async (event) => { |
||||||
|
event.preventDefault(); |
||||||
|
|
||||||
|
if (!stripe || !elements) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
setLoading(true); |
||||||
|
setError(null); |
||||||
|
|
||||||
|
try { |
||||||
|
// Create payment intent on your backend
|
||||||
|
const response = await fetch('/api/create-payment-intent', { |
||||||
|
method: 'POST', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json', |
||||||
|
}, |
||||||
|
body: JSON.stringify({ |
||||||
|
amount: amount, |
||||||
|
currency: 'usd', |
||||||
|
items: cartItems |
||||||
|
}), |
||||||
|
}); |
||||||
|
|
||||||
|
const { clientSecret } = await response.json(); |
||||||
|
|
||||||
|
// Confirm card payment
|
||||||
|
const result = await stripe.confirmCardPayment(clientSecret, { |
||||||
|
payment_method: { |
||||||
|
card: elements.getElement(CardElement), |
||||||
|
billing_details: { |
||||||
|
name: 'Customer Name', |
||||||
|
}, |
||||||
|
}, |
||||||
|
}); |
||||||
|
|
||||||
|
if (result.error) { |
||||||
|
setError(result.error.message); |
||||||
|
onPaymentError(); |
||||||
|
} else { |
||||||
|
// Payment successful
|
||||||
|
onPaymentSuccess(); |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
setError('Payment processing failed. Please try again.'); |
||||||
|
onPaymentError(); |
||||||
|
} finally { |
||||||
|
setLoading(false); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
return ( |
||||||
|
<form onSubmit={handleSubmit}> |
||||||
|
<div className="form-row"> |
||||||
|
<label htmlFor="card-element"> |
||||||
|
Credit or debit card |
||||||
|
</label> |
||||||
|
<CardElement id="card-element" /> |
||||||
|
{error && <div className="error-message">{error}</div>} |
||||||
|
</div> |
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!stripe || loading} |
||||||
|
className="submit-button" |
||||||
|
> |
||||||
|
{loading ? 'Processing...' : `Pay $${(amount / 100).toFixed(2)}`} |
||||||
|
</button> |
||||||
|
</form> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
export default CheckoutForm; |
||||||
@ -0,0 +1,17 @@ |
|||||||
|
import React from 'react'; |
||||||
|
|
||||||
|
const Header = ({ cartCount }) => { |
||||||
|
return ( |
||||||
|
<header className="header"> |
||||||
|
<div className="header-content"> |
||||||
|
<h1>My Store</h1> |
||||||
|
<div className="cart-icon"> |
||||||
|
<span>Cart</span> |
||||||
|
{cartCount > 0 && <span className="cart-count">{cartCount}</span>} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</header> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
export default Header; |
||||||
@ -0,0 +1,17 @@ |
|||||||
|
import React from 'react'; |
||||||
|
|
||||||
|
const ProductItem = ({ product, onAddToCart }) => { |
||||||
|
return ( |
||||||
|
<div className="product-item"> |
||||||
|
<img src={product.image} alt={product.name} /> |
||||||
|
<h3>{product.name}</h3> |
||||||
|
<p className="price">${product.price.toFixed(2)}</p> |
||||||
|
<p className="description">{product.description}</p> |
||||||
|
<button onClick={() => onAddToCart(product)}> |
||||||
|
Add to Cart |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
export default ProductItem; |
||||||
@ -0,0 +1,21 @@ |
|||||||
|
import React from 'react'; |
||||||
|
import ProductItem from './ProductItem'; |
||||||
|
|
||||||
|
const ProductList = ({ products, onAddToCart }) => { |
||||||
|
return ( |
||||||
|
<div className="product-list"> |
||||||
|
<h2>Products</h2> |
||||||
|
<div className="products-grid"> |
||||||
|
{products.map(product => ( |
||||||
|
<ProductItem
|
||||||
|
key={product.id}
|
||||||
|
product={product}
|
||||||
|
onAddToCart={onAddToCart}
|
||||||
|
/> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
export default ProductList; |
||||||
Loading…
Reference in new issue