279 lines
7.8 KiB
TypeScript
279 lines
7.8 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
|
import {
|
|
IonButton,
|
|
IonButtons,
|
|
IonContent,
|
|
IonHeader,
|
|
IonIcon,
|
|
IonItem,
|
|
IonLabel,
|
|
IonList,
|
|
IonLoading,
|
|
IonPage,
|
|
IonText,
|
|
IonTitle,
|
|
IonToolbar,
|
|
useIonViewWillEnter,
|
|
} from '@ionic/react';
|
|
import {
|
|
addOutline,
|
|
bagHandleOutline,
|
|
removeOutline,
|
|
trashOutline,
|
|
} from 'ionicons/icons';
|
|
import { useHistory } from 'react-router-dom';
|
|
import { supabase } from '../supabase';
|
|
import type { TablesInsert } from '../database.types';
|
|
import { CartItem, LAST_ORDER_STORAGE_KEY, readCart, writeCart } from './Home';
|
|
import {
|
|
setStatusBarBackground,
|
|
setStatusBarStyle,
|
|
Style,
|
|
} from '../utils/statusBar';
|
|
|
|
const Cart: React.FC = () => {
|
|
const history = useHistory();
|
|
const [cart, setCart] = useState<CartItem[]>([]);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [errorMessage, setErrorMessage] = useState('');
|
|
|
|
useIonViewWillEnter(() => {
|
|
setStatusBarStyle(Style.Dark);
|
|
setStatusBarBackground('#ffffff');
|
|
});
|
|
|
|
useEffect(() => {
|
|
setCart(readCart());
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const syncCartFromStorage = () => setCart(readCart());
|
|
window.addEventListener('storage', syncCartFromStorage);
|
|
window.addEventListener(
|
|
'cart:updated',
|
|
syncCartFromStorage as EventListener
|
|
);
|
|
return () => {
|
|
window.removeEventListener('storage', syncCartFromStorage);
|
|
window.removeEventListener(
|
|
'cart:updated',
|
|
syncCartFromStorage as EventListener
|
|
);
|
|
};
|
|
}, []);
|
|
|
|
const syncCart = (nextCart: CartItem[]) => {
|
|
setCart(nextCart);
|
|
writeCart(nextCart);
|
|
};
|
|
|
|
const showError = (message: string) => {
|
|
setErrorMessage(message);
|
|
window.setTimeout(() => setErrorMessage(''), 4000);
|
|
};
|
|
|
|
const itemCount = useMemo(
|
|
() => cart.reduce((sum, item) => sum + item.quantity, 0),
|
|
[cart]
|
|
);
|
|
|
|
const subtotal = useMemo(
|
|
() => cart.reduce((sum, item) => sum + item.price * item.quantity, 0),
|
|
[cart]
|
|
);
|
|
|
|
const deliveryFee = cart.length > 0 ? 2.99 : 0;
|
|
const total = subtotal + deliveryFee;
|
|
|
|
const updateQuantity = (id: string, delta: number) => {
|
|
const nextCart = cart
|
|
.map((item) =>
|
|
item.id === id ? { ...item, quantity: item.quantity + delta } : item
|
|
)
|
|
.filter((item) => item.quantity > 0);
|
|
syncCart(nextCart);
|
|
};
|
|
|
|
const removeItem = (id: string) => {
|
|
syncCart(cart.filter((item) => item.id !== id));
|
|
};
|
|
|
|
const placeOrder = async () => {
|
|
if (cart.length === 0 || isSubmitting) {
|
|
return;
|
|
}
|
|
|
|
setIsSubmitting(true);
|
|
|
|
const orderNumber = `QC-${Date.now().toString().slice(-6)}`;
|
|
|
|
const orderPayload: TablesInsert<'orders'> = {
|
|
order_number: orderNumber,
|
|
subtotal,
|
|
delivery_fee: deliveryFee,
|
|
total,
|
|
item_count: itemCount,
|
|
status: 'confirmed',
|
|
};
|
|
|
|
const { data: order, error: orderError } = await supabase
|
|
.from('orders')
|
|
.insert(orderPayload)
|
|
.select()
|
|
.single();
|
|
|
|
if (orderError || !order) {
|
|
setIsSubmitting(false);
|
|
showError('We could not place your order. Please try again.');
|
|
return;
|
|
}
|
|
|
|
const itemsPayload: TablesInsert<'order_items'>[] = cart.map((item) => ({
|
|
order_id: order.id,
|
|
product_id: item.id,
|
|
product_name: item.name,
|
|
unit_price: item.price,
|
|
quantity: item.quantity,
|
|
}));
|
|
|
|
const { error: itemsError } = await supabase
|
|
.from('order_items')
|
|
.insert(itemsPayload);
|
|
|
|
if (itemsError) {
|
|
await supabase.from('orders').delete().eq('id', order.id);
|
|
setIsSubmitting(false);
|
|
showError('We could not save your order items. Please try again.');
|
|
return;
|
|
}
|
|
|
|
localStorage.setItem(
|
|
LAST_ORDER_STORAGE_KEY,
|
|
JSON.stringify({
|
|
id: order.id,
|
|
orderNumber: order.order_number,
|
|
total: Number(order.total),
|
|
itemCount: order.item_count,
|
|
status: order.status,
|
|
})
|
|
);
|
|
|
|
writeCart([]);
|
|
setCart([]);
|
|
setIsSubmitting(false);
|
|
history.replace('/order-confirmation');
|
|
};
|
|
|
|
return (
|
|
<IonPage>
|
|
<IonHeader className="ion-no-border">
|
|
<IonToolbar>
|
|
<IonButtons slot="start">
|
|
<IonButton fill="clear" onClick={() => history.replace('/home')}>
|
|
Back
|
|
</IonButton>
|
|
</IonButtons>
|
|
<IonTitle>Your Cart</IonTitle>
|
|
</IonToolbar>
|
|
</IonHeader>
|
|
|
|
<IonContent className="ion-padding">
|
|
<IonLoading isOpen={isSubmitting} message="Placing your order..." />
|
|
{errorMessage && (
|
|
<IonText color="danger">
|
|
<p>{errorMessage}</p>
|
|
</IonText>
|
|
)}
|
|
{cart.length === 0 ? (
|
|
<div className="empty-state cart-empty">
|
|
<IonIcon icon={bagHandleOutline} />
|
|
<h3>Your cart is empty</h3>
|
|
<p className="muted-text">
|
|
Add your first product to place an order.
|
|
</p>
|
|
<IonButton onClick={() => history.push('/home')}>
|
|
Browse products
|
|
</IonButton>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="cart-overview-card">
|
|
<div>
|
|
<p className="overview-label">Items in cart</p>
|
|
<h2>
|
|
{itemCount} item{itemCount === 1 ? '' : 's'}
|
|
</h2>
|
|
</div>
|
|
<IonText color="medium">
|
|
<p>Review your selections before placing the order.</p>
|
|
</IonText>
|
|
</div>
|
|
|
|
<IonList lines="none" className="cart-list">
|
|
{cart.map((item) => (
|
|
<IonItem key={item.id} className="cart-item">
|
|
<IonLabel>
|
|
<h2>{item.name}</h2>
|
|
<p>${item.price.toFixed(2)} each</p>
|
|
<strong>${(item.price * item.quantity).toFixed(2)}</strong>
|
|
</IonLabel>
|
|
<div className="cart-actions">
|
|
<IonButton
|
|
fill="clear"
|
|
onClick={() => updateQuantity(item.id, -1)}
|
|
>
|
|
<IonIcon icon={removeOutline} />
|
|
</IonButton>
|
|
<span>{item.quantity}</span>
|
|
<IonButton
|
|
fill="clear"
|
|
onClick={() => updateQuantity(item.id, 1)}
|
|
>
|
|
<IonIcon icon={addOutline} />
|
|
</IonButton>
|
|
<IonButton
|
|
fill="clear"
|
|
color="danger"
|
|
onClick={() => removeItem(item.id)}
|
|
>
|
|
<IonIcon icon={trashOutline} />
|
|
</IonButton>
|
|
</div>
|
|
</IonItem>
|
|
))}
|
|
</IonList>
|
|
|
|
<div className="summary-card">
|
|
<div className="summary-row">
|
|
<IonText color="medium">Items</IonText>
|
|
<strong>{itemCount}</strong>
|
|
</div>
|
|
<div className="summary-row">
|
|
<IonText color="medium">Subtotal</IonText>
|
|
<strong>${subtotal.toFixed(2)}</strong>
|
|
</div>
|
|
<div className="summary-row">
|
|
<IonText color="medium">Delivery</IonText>
|
|
<strong>${deliveryFee.toFixed(2)}</strong>
|
|
</div>
|
|
<div className="summary-row total-row">
|
|
<span>Total</span>
|
|
<strong>${total.toFixed(2)}</strong>
|
|
</div>
|
|
<IonButton
|
|
expand="block"
|
|
onClick={placeOrder}
|
|
disabled={isSubmitting}
|
|
>
|
|
Place order
|
|
</IonButton>
|
|
</div>
|
|
</>
|
|
)}
|
|
</IonContent>
|
|
</IonPage>
|
|
);
|
|
};
|
|
|
|
export default Cart;
|