Skip to content

Commit

Permalink
Chore/migration (#209)
Browse files Browse the repository at this point in the history
* chore: git mv tailwind, assets, storybook

* wip

* pnpm version mismatch

* fix: ts version mismatch

* chore: basic tsup setup

* ci step

* chore: e2e ci

* refactor: ui -> shared-ui

* chore: shared tsconfig

* fix: tsconfig include/exclude

* chore: build for next deploy

* fix: pull in external classes

* mv Typography

* mv Input

* mv ui folder

* mv banner

* fix file path

* mv pill

* mv small components

* add dep

* mv select

* mv modal

* mv linktext blockcontent

* mv price

* mv quantityinput

* fix ci package dir

* wip

* wip

* wiring

* wiring and fixing

* price

* _id

* mv cart lineitem

* mv

* typo

* typo

* cleanup

* fix render

* initial story

* styling and helpers

* custom actions

* test

* tests

* move totals to reducer

* fix cart closing
  • Loading branch information
nlkluth authored Nov 6, 2023
1 parent ee0e0c0 commit 0adba6a
Show file tree
Hide file tree
Showing 16 changed files with 981 additions and 175 deletions.
107 changes: 18 additions & 89 deletions packages/nextjs/components/CartContext.tsx
Original file line number Diff line number Diff line change
@@ -1,56 +1,9 @@
import * as React from "react";
import { q } from "groqd";
import { useReducer } from "react";
import { CartItem, CartUpdate, CartProvider as SharedCartProvider } from "shared-ui";
import { runQuery } from "utils/sanityClient";

export type CartItem = {
_id: string;
qty: number;
variantInfo: CartItemVariant;
};

const initialValue = {
isFetchingCartItems: false,
cartItems: [] as CartItem[],
cartItemsErrorIds: [] as string[] | undefined,
cartTotal: 0,
totalCartPrice: 0,
updateCart: (() => null) as (productId: string, quantity: number) => void,
clearCart: (() => null) as () => void,
updateCartFromApi: (() => null) as () => void,
};

type CartContextValue = typeof initialValue;

export const CartContext = React.createContext<CartContextValue>(initialValue);
export const useCart = () => React.useContext(CartContext);

type State = {
cartItems: CartItem[];
cartItemsErrorIds: string[];
fetching: boolean;
};

type Action =
| { type: "loading" }
| { type: "success"; cartItems: CartItem[]; cartItemsErrorIds: string[] }
| { type: "clear" };

const defaultState: State = { cartItems: [], cartItemsErrorIds: [], fetching: false };

const cartReducer = (state: State, action: Action): State => {
switch (action.type) {
case "loading":
return { ...state, cartItemsErrorIds: [], fetching: true };
case "success":
return { ...state, cartItems: action.cartItems, cartItemsErrorIds: action.cartItemsErrorIds, fetching: false };
case "clear":
return defaultState;
}
};

export const CartProvider = ({ children }: React.PropsWithChildren) => {
const [{ cartItems, cartItemsErrorIds, fetching }, dispatch] = useReducer(cartReducer, defaultState);
const retrieveCartItems = React.useCallback(async (cart: Record<string, number>) => {
const cartEntries = Object.entries(cart);

Expand All @@ -72,36 +25,40 @@ export const CartProvider = ({ children }: React.PropsWithChildren) => {
const res = await throttle(getResults, 1000);
const errorRetrievingIds: string[] = [];

const formattedItems = cartEntries.reduce<CartItem[]>((acc, [variantId, quantity]) => {
const results = cartEntries.reduce<CartItem[]>((acc, [variantId, quantity]) => {
const productInfo = res.find((variant) => variant._id === variantId);
if (productInfo) {
return [...acc, { _id: variantId, qty: quantity, variantInfo: productInfo }];
return [
...acc,
{
_id: variantId,
quantity,
name: productInfo.name,
price: productInfo.price,
},
];
}

errorRetrievingIds.push(variantId);
return acc;
}, []);

dispatch({ type: "success", cartItems: formattedItems, cartItemsErrorIds: errorRetrievingIds });
} else {
dispatch({ type: "clear" });
return { results, errors: errorRetrievingIds };
}

return { results: [], errors: [] };
}, []);

const updateCartFromApi = React.useCallback(async () => {
const data = await fetch("/api/cart", {
credentials: "same-origin",
}).then((res) => res.json());

retrieveCartItems(data);
return retrieveCartItems(data);
}, [retrieveCartItems]);

React.useEffect(() => {
updateCartFromApi();
}, [updateCartFromApi]);

const updateCart = React.useCallback(
async (variantId: string, quantity: number) => {
async ({ _id: variantId, quantity }: CartUpdate) => {
if (!variantId) {
return;
}
Expand Down Expand Up @@ -135,47 +92,19 @@ export const CartProvider = ({ children }: React.PropsWithChildren) => {
"Content-Type": "application/json",
},
}).then((res) => res.json());

dispatch({ type: "clear" });
} catch (error) {
console.error(error);
}
};

// Calculates the total quantity of all items in the cart
const cartTotal = React.useMemo(() => cartItems.reduce((acc, { qty }) => acc + qty, 0), [cartItems]);

const totalCartPrice = React.useMemo(
() => cartItems.reduce((acc, { qty, variantInfo }) => acc + qty * variantInfo.price, 0),
[cartItems]
);

return (
<CartContext.Provider
value={{
isFetchingCartItems: fetching,
cartItems,
cartItemsErrorIds,
updateCart,
clearCart,
cartTotal,
updateCartFromApi,
totalCartPrice,
}}
>
<SharedCartProvider onCartClear={clearCart} onCartFetch={updateCartFromApi} onCartUpdate={updateCart}>
{children}
</CartContext.Provider>
</SharedCartProvider>
);
};

const wait = (ms: number) => new Promise((res) => setTimeout(res, ms));

const throttle = <T,>(action: () => Promise<T>, ms: number): Promise<T> =>
Promise.all([action(), wait(ms)]).then((res) => res[0]);

type CartItemVariant = {
_id: string;
name: string;
msrp: number;
price: number;
};
30 changes: 13 additions & 17 deletions packages/nextjs/components/Header/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import * as React from "react";
import Link from "next/link";
import classNames from "classnames";
import { useCart } from "components/CartContext";
import { Cart, CartContent, Button, useCart } from "shared-ui";
import { Search } from "components/Search";
import { Cart } from "./Cart";
import { MobileHeaderItems } from "./MobileHeaderItems";
import { NAV_ITEMS } from "./NavItems";
import { MobileNavMenu } from "./MobileNavMenu";
Expand All @@ -12,9 +11,7 @@ import { Logo } from "./Logo";

export const Header = () => {
const [isNavOpen, setIsNavOpen] = React.useState(false);

const [isCartOpen, setIsCartOpen] = React.useState(false);
const { cartTotal, isFetchingCartItems } = useCart();
const { toggleCartOpen } = useCart();

const onMobileNavClick = () => setIsNavOpen((prev) => !prev);
const onMobileNavClose = () => setIsNavOpen(false);
Expand All @@ -23,10 +20,6 @@ export const Header = () => {
document.body.classList[isNavOpen ? "add" : "remove"]("overflow-hidden");
}, [isNavOpen]);

React.useEffect(() => {
document.body.classList[isCartOpen ? "add" : "remove"]("overflow-hidden", "sm:overflow-auto");
}, [isCartOpen]);

return (
<header className={classNames("sticky top-0 z-10 flex flex-col", isNavOpen && "h-screen")}>
<nav className="h-[66px] sm:h-[110px] border-b-2 border-b-primary bg-secondary shadow transition-all text-primary">
Expand All @@ -44,14 +37,17 @@ export const Header = () => {
</div>
<div className="flex items-center">
<Search />
<Cart
cartTotal={cartTotal}
onMobileNavClose={onMobileNavClose}
isFetchingCartItems={isFetchingCartItems}
openCart={() => setIsCartOpen(true)}
isCartOpen={isCartOpen}
onCartClose={() => setIsCartOpen(false)}
/>
<Cart onMobileNavClose={onMobileNavClose}>
<CartContent
ProductListLink={
<Link href="/products" passHref legacyBehavior>
<Button as="a" variant="secondary" onClick={() => toggleCartOpen(false)}>
View Products
</Button>
</Link>
}
/>
</Cart>
<MobileNavMenu navOpen={isNavOpen} onMobileNavClick={onMobileNavClick} />
</div>
</div>
Expand Down
10 changes: 7 additions & 3 deletions packages/nextjs/pages/products/[slug].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,14 @@ import { GetServerSideProps, NextPage } from "next";
import { useRouter } from "next/router";
import { AnimatePresence } from "framer-motion";

import { H6, FadeInOut, BlockContent, Price, QuantityInput } from "shared-ui";
import { H6, FadeInOut, BlockContent, Price, QuantityInput, useCart } from "shared-ui";
import { setCachingHeaders } from "utils/setCachingHeaders";
import { isSlug } from "utils/isSlug";
import { SanityType } from "utils/consts";
import { getRecommendations } from "utils/getRecommendationsQuery";
import { getProductBySlug } from "utils/getProductBySlug";

import { ImageCarousel } from "components/ImageCarousel";
import { useCart } from "components/CartContext";
import { PageHead } from "components/PageHead";
import { StyleOptions } from "components/ProductPage/StyleOptions";
import { ProductVariantSelector } from "components/ProductPage/ProductVariantSelector";
Expand Down Expand Up @@ -104,7 +103,12 @@ const PageBody = ({ variant, product }: { product?: ProductType; variant?: Varia
// If the item is already in the cart allow user to click add to cart multiple times
const existingCartItem = cartItems.find((item) => item._id === variant._id);

updateCart(variant?._id, existingCartItem ? existingCartItem.qty + Number(quantity) : Number(quantity));
updateCart({
_id: variant._id,
name: variant.name,
price: variant.price,
quantity: existingCartItem ? existingCartItem.quantity + Number(quantity) : Number(quantity),
});
}
};

Expand Down
7 changes: 6 additions & 1 deletion packages/shared-ui/.storybook/main.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
const config = {
stories: ["../**/*.mdx", "../**/*.stories.@(js|jsx|mjs|ts|tsx)"],
stories: ["../**/*.mdx", "../components/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: [
"@storybook/addon-links",
"@storybook/addon-essentials",
"@storybook/addon-onboarding",
"@storybook/addon-interactions",
"@storybook/addon-a11y",
],
docs: {
autodocs: "tag",
},
framework: {
name: "@storybook/react-vite",
options: {},
},
};
export default config;
2 changes: 1 addition & 1 deletion packages/shared-ui/components/Price.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as React from "react";
import { Eyebrow } from "./Typography";
import { currencyFormatter } from "../uitls/currencyFormatter";
import { currencyFormatter } from "../utils/currencyFormatter";

interface Props {
msrp?: number | null;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,26 +1,14 @@
import * as React from "react";
import { FiShoppingCart, FiLoader } from "react-icons/fi";
import { AnimatePresence, motion } from "framer-motion";
import { CartContent } from "./CartContent";
import { useCart } from "./CartContext";

type CartProps = {
onMobileNavClose: () => void;
isFetchingCartItems: boolean;
cartTotal: number;
isCartOpen: boolean;
openCart: () => void;
onCartClose: () => void;
};

export const Cart = ({
cartTotal,
onMobileNavClose,
isFetchingCartItems,
openCart,
isCartOpen,
onCartClose,
}: CartProps) => {
const cartPopup = React.useRef<HTMLDivElement>(null);
export const Cart = ({ onMobileNavClose, children }: React.PropsWithChildren<CartProps>) => {
const { isCartOpen, cartPopupRef, toggleCartOpen, isLoading, totalQuantity } = useCart();

React.useEffect(() => {
if (!isCartOpen) return;
Expand All @@ -30,15 +18,15 @@ export const Cart = ({
const hit = evt.target;
if (!(hit instanceof Node && hit instanceof Element)) return;

if (hit.closest(".cart-popup") !== cartPopup.current) {
onCartClose();
if (hit.closest(".cart-popup") !== cartPopupRef?.current) {
toggleCartOpen(false);
}
};
window.addEventListener("click", handleClick);

// Esc-key
const handleKeyup = (evt: KeyboardEvent) => {
if (evt.key === "Escape") onCartClose();
if (evt.key === "Escape") toggleCartOpen(false);
};
window.addEventListener("keyup", handleKeyup);

Expand All @@ -55,12 +43,13 @@ export const Cart = ({
onClick={(e) => {
e.stopPropagation();
onMobileNavClose();
openCart();
toggleCartOpen(true);
}}
data-testid="cart"
>
<span className="hidden sm:block">Cart</span>
<AnimatePresence mode="popLayout">
{isFetchingCartItems ? (
{isLoading ? (
<motion.div key="loader" exit={{ opacity: 0, scale: 0.4 }}>
<FiLoader size={20} className="motion-safe:animate-[spin_2s_ease-in-out_infinite]" />
</motion.div>
Expand All @@ -73,21 +62,21 @@ export const Cart = ({
transition={{ duration: 0.2, ease: "easeIn" }}
>
<FiShoppingCart size={24} className="mx-2" />
<span>{cartTotal}</span>
<span>{totalQuantity}</span>
</motion.div>
)}
</AnimatePresence>
</button>
<AnimatePresence>
{isCartOpen && (
<motion.div
ref={cartPopup}
ref={cartPopupRef}
className="cart-popup fixed inset-0 sm:absolute bg-secondary sm:top-10 sm:right-0 sm:left-[inherit] sm:bottom-[inherit] sm:w-[400px] sm:max-h-[calc(100vh-100px)] sm:shadow-lg sm:rounded sm:border border-primary flex cursor-auto"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
>
<CartContent onClose={onCartClose} />
{children}
</motion.div>
)}
</AnimatePresence>
Expand Down
Loading

1 comment on commit 0adba6a

@vercel
Copy link

@vercel vercel bot commented on 0adba6a Nov 6, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.