Initial commit - Route Commerce platform

This commit is contained in:
2026-06-01 19:40:55 +00:00
commit 53a9671461
617 changed files with 106132 additions and 0 deletions
+310
View File
@@ -0,0 +1,310 @@
"use client";
import { CartItem } from "@/types";
import {
createContext,
useContext,
useState,
useEffect,
useCallback,
ReactNode,
} from "react";
type StopInfo = {
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
brand_id: string;
};
type CartContextType = {
cart: CartItem[];
subtotal: number;
selectedStop: StopInfo | null;
cartBrandId: string | null;
cartBrandSlug: string | null;
justAdded: CartItem | null;
cartRestored: boolean; // true when server cart was loaded (for toast)
setSelectedStop: (stop: StopInfo | null) => void;
addToCart: (item: Omit<CartItem, "quantity">, fulfillment?: "pickup" | "ship") => void;
increaseQuantity: (id: string) => void;
decreaseQuantity: (id: string) => void;
removeFromCart: (id: string) => void;
clearCart: () => void;
dismissToast: () => void;
/** Returns raw local cart — used by login page to merge before redirecting */
getLocalCart: () => CartItem[];
/** Loads server cart for a logged-in user, replaces local cart + localStorage.
* Call once on admin page mount for logged-in users.
* Returns true if a server cart was loaded (shows the restored toast). */
loadServerCart: (userId: string) => Promise<boolean>;
/** Dismiss the cart-restored toast */
dismissRestoredToast: () => void;
};
const CART_KEY = "route_commerce_cart";
const STOP_KEY = "route_commerce_stop";
const CartContext = createContext<CartContextType | undefined>(undefined);
function parsePrice(price: string): number {
const cleaned = String(price).replace("$", "").replace(",", "");
return Number(cleaned) || 0;
}
export function CartProvider({ children }: { children: ReactNode }) {
const [cart, setCart] = useState<CartItem[]>([]);
const [selectedStop, setSelectedStopState] = useState<StopInfo | null>(null);
const [justAdded, setJustAdded] = useState<CartItem | null>(null);
const [hydrated, setHydrated] = useState(false);
const [cartRestored, setCartRestored] = useState(false); // shown once per session
const [restoredDismissed, setRestoredDismissed] = useState(false);
useEffect(() => {
try {
const storedCart = localStorage.getItem(CART_KEY);
if (storedCart) {
const parsed = JSON.parse(storedCart);
if (Array.isArray(parsed)) setCart(parsed);
}
const storedStop = localStorage.getItem(STOP_KEY);
if (storedStop) {
const parsed = JSON.parse(storedStop);
if (parsed && typeof parsed === "object" && parsed.id) {
setSelectedStopState(parsed);
}
}
} catch {
// ignore
}
setHydrated(true);
}, []);
useEffect(() => {
if (!hydrated) return;
try {
localStorage.setItem(CART_KEY, JSON.stringify(cart));
} catch {
// ignore
}
}, [cart, hydrated]);
// ── Cross-tab sync ─────────────────────────────────────────────────────────
// Listen for cart changes in other tabs and reconcile.
// This ensures a product added in tab A immediately appears in tab B.
useEffect(() => {
if (!hydrated) return;
function handleStorageChange(e: StorageEvent) {
if (e.key !== CART_KEY) return;
if (e.newValue === null) return; // cleared elsewhere — let local clear handle it
try {
const remote = JSON.parse(e.newValue);
if (!Array.isArray(remote)) return;
setCart((prev) => {
// Only accept remote if it differs from our current cart
// (avoids overwriting local with same value after our own write)
const localJson = JSON.stringify(prev);
if (localJson === e.newValue) return prev;
return remote;
});
} catch {
// ignore malformed data
}
}
window.addEventListener("storage", handleStorageChange);
return () => window.removeEventListener("storage", handleStorageChange);
}, [hydrated]);
// ── Validate / repair stale session data on hydration ─────────────────
useEffect(() => {
if (!hydrated) return;
// Retrieve current cart and stop from localStorage
let storedCart: CartItem[] = [];
let storedStop: StopInfo | null = null;
try {
const rawCart = localStorage.getItem(CART_KEY);
if (rawCart) storedCart = JSON.parse(rawCart);
const rawStop = localStorage.getItem(STOP_KEY);
if (rawStop) storedStop = JSON.parse(rawStop);
} catch {
// ignore — treat as empty
}
if (storedCart.length === 0) {
// Empty cart — ensure no stale stop either
if (storedStop) setSelectedStop(null);
return;
}
const cartBrandId = storedCart[0]?.brand_id;
// Cart items from before the fix lack brand_id — clear everything
if (!cartBrandId) {
localStorage.removeItem(CART_KEY);
localStorage.removeItem(STOP_KEY);
setCart([]);
setSelectedStop(null);
return;
}
// Stop exists but brand doesn't match cart brand — clear stop
if (storedStop?.brand_id && storedStop.brand_id !== cartBrandId) {
localStorage.removeItem(STOP_KEY);
setSelectedStop(null);
}
}, [hydrated]);
function setSelectedStop(stop: StopInfo | null) {
setSelectedStopState(stop);
try {
if (stop) {
localStorage.setItem(STOP_KEY, JSON.stringify(stop));
} else {
localStorage.removeItem(STOP_KEY);
}
} catch {
// ignore
}
}
const setSelectedStopStable = useCallback(setSelectedStop, []);
function addToCart(item: Omit<CartItem, "quantity">, fulfillment?: "pickup" | "ship") {
setCart((prev) => {
const existing = prev.find((p) => p.id === item.id);
if (existing) {
const updated = prev.map((p) =>
p.id === item.id ? { ...p, quantity: p.quantity + 1 } : p
);
const resultItem = { ...existing, quantity: existing.quantity + 1 };
setJustAdded(resultItem);
return updated;
}
// Cross-brand guard: clear cart + selectedStop if items exist from a different brand
const newItem: CartItem = { ...item, quantity: 1, fulfillment: fulfillment ?? "pickup" };
setJustAdded(newItem);
if (prev.length > 0 && prev[0].brand_id !== item.brand_id) {
// Clear selectedStop since brand is changing
setSelectedStop(null);
return [newItem];
}
return [...prev, newItem];
});
}
function increaseQuantity(id: string) {
setCart((prev) =>
prev.map((item) => (item.id === id ? { ...item, quantity: item.quantity + 1 } : item))
);
}
function decreaseQuantity(id: string) {
setCart((prev) =>
prev.map((item) => (item.id === id ? { ...item, quantity: item.quantity - 1 } : item))
.filter((item) => item.quantity > 0)
);
}
function removeFromCart(id: string) {
setCart((prev) => {
const next = prev.filter((item) => item.id !== id);
// Clear stop if cart is now empty
if (next.length === 0) {
setSelectedStop(null);
}
return next;
});
}
function clearCart() {
setCart([]);
try {
localStorage.removeItem(CART_KEY);
localStorage.removeItem(STOP_KEY);
} catch {
// ignore
}
}
function dismissToast() {
setJustAdded(null);
}
function dismissRestoredToast() {
setRestoredDismissed(true);
}
async function loadServerCart(userId: string): Promise<boolean> {
if (!userId || !hydrated) return false;
try {
const { getServerCart } = await import("@/actions/checkout");
const serverItems = await getServerCart(userId);
if (serverItems.length === 0) return false;
// Replace localStorage so cross-tab stays in sync
localStorage.setItem(CART_KEY, JSON.stringify(serverItems));
setCart(serverItems);
setCartRestored(true);
setRestoredDismissed(false);
return true;
} catch {
return false;
}
}
function getLocalCart(): CartItem[] {
try {
const raw = localStorage.getItem(CART_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
const subtotal = cart.reduce((sum, item) => sum + parsePrice(item.price) * item.quantity, 0);
const cartBrandId = cart.length > 0 ? cart[0].brand_id : null;
const cartBrandSlug = cart.length > 0 ? cart[0].brand_slug : null;
const showRestoredToast = cartRestored && !restoredDismissed;
return (
<CartContext.Provider
value={{
cart,
subtotal,
selectedStop,
cartBrandId,
cartBrandSlug,
justAdded,
cartRestored: showRestoredToast,
setSelectedStop: setSelectedStopStable,
addToCart,
increaseQuantity,
decreaseQuantity,
removeFromCart,
clearCart,
dismissToast,
getLocalCart,
loadServerCart,
dismissRestoredToast,
}}
>
{children}
</CartContext.Provider>
);
}
export function useCart() {
const context = useContext(CartContext);
if (!context) throw new Error("useCart must be used inside CartProvider");
return context;
}