From 9ab52e0590d5ee5b388d5c2b8c17d902b6c13b30 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Sat, 8 Jan 2022 12:14:17 -0800 Subject: [PATCH] feat: added wishlist --- app/components/cart-popover.tsx | 1 - app/components/footer.tsx | 2 +- app/components/navbar.tsx | 24 ++- app/components/scrolling-product-list.tsx | 9 +- app/components/three-product-grid.tsx | 58 ++++- app/components/wishlist-listitem.tsx | 201 ++++++++++++++++++ app/components/wishlist-popover.tsx | 129 +++++++++++ app/containers/cdp/cdp.server.ts | 11 +- app/containers/home/home.component.tsx | 2 + app/containers/home/home.server.ts | 22 +- app/containers/layout/layout.component.tsx | 34 ++- app/containers/layout/layout.server.ts | 18 +- .../wishlist/wishlist.component.tsx | 44 ++++ app/containers/wishlist/wishlist.server.ts | 133 ++++++++++++ app/models/ecommerce-provider.server.ts | 24 ++- .../ecommerce-providers/shopify.server.ts | 82 +++++-- app/routes/$lang/wishlist.ts | 11 + app/routes/wishlist.ts | 11 + app/session.server.ts | 61 +++++- app/translations.server.tsx | 16 ++ 20 files changed, 833 insertions(+), 60 deletions(-) create mode 100644 app/components/wishlist-listitem.tsx create mode 100644 app/components/wishlist-popover.tsx create mode 100644 app/containers/wishlist/wishlist.component.tsx create mode 100644 app/containers/wishlist/wishlist.server.ts create mode 100644 app/routes/$lang/wishlist.ts create mode 100644 app/routes/wishlist.ts diff --git a/app/components/cart-popover.tsx b/app/components/cart-popover.tsx index 582fc72..f4017b6 100644 --- a/app/components/cart-popover.tsx +++ b/app/components/cart-popover.tsx @@ -73,7 +73,6 @@ export function CartPopover({ {translations.Close} - {translations.Close} {!!cartCount && ( void; onOpenWishlist: () => void; lang: Language; @@ -80,7 +82,8 @@ export function Navbar({
  • {category.name} @@ -100,7 +103,8 @@ export function Navbar({
    { event.preventDefault(); @@ -121,8 +125,9 @@ export function Navbar({ )} { event.preventDefault(); onOpenWishlist(); @@ -132,6 +137,14 @@ export function Navbar({ {translations ? translations["Wishlist"] : null} + {!!wishlistCount && ( + + {wishlistCount} + + )} {({ close }) => ( @@ -182,7 +195,8 @@ export function Navbar({ close()} className="text-xl text-blue-400 hover:text-blue-500" - prefetch="intent" to={category.to} + prefetch="intent" + to={category.to} > {category.name} diff --git a/app/components/scrolling-product-list.tsx b/app/components/scrolling-product-list.tsx index 957349a..2b10364 100644 --- a/app/components/scrolling-product-list.tsx +++ b/app/components/scrolling-product-list.tsx @@ -7,7 +7,7 @@ import cn from "classnames"; import { OptimizedImage } from "./optimized-image"; export type ScrollingProductListProduct = { - id: string | number; + id: string; title: ReactNode; image: string; to: To; @@ -27,7 +27,8 @@ function ScrollingProductItem({ return (
  • @@ -65,7 +66,7 @@ export function ScrollingProductList({ key={product.id} image={product.image} title={product.title} - prefetch="intent" to={product.to} + to={product.to} /> )), [products] @@ -80,7 +81,7 @@ export function ScrollingProductList({ key={product.id} image={product.image} title={product.title} - prefetch="intent" to={product.to} + to={product.to} disabled /> )), diff --git a/app/components/three-product-grid.tsx b/app/components/three-product-grid.tsx index ffe4d60..c5dedd9 100644 --- a/app/components/three-product-grid.tsx +++ b/app/components/three-product-grid.tsx @@ -1,15 +1,17 @@ import type { ReactNode } from "react"; import { useMemo } from "react"; -import { Link } from "remix"; +import { Link, useFetcher, useLocation } from "remix"; import type { To } from "react-router-dom"; import cn from "classnames"; import { useId } from "@reach/auto-id"; import { WishlistIcon } from "./icons"; import { OptimizedImage } from "./optimized-image"; +import { PickTranslations } from "~/translations.server"; export type ThreeProductGridProduct = { - id: string | number; + id: string; + defaultVariantId: string; title: ReactNode; formattedPrice: ReactNode; favorited: boolean; @@ -22,13 +24,17 @@ function ThreeProductGridItem({ wishlistColors, product, index, + translations, }: { backgroundColor: string; wishlistColors: string[]; product: ThreeProductGridProduct; index: number; + translations: PickTranslations<"Add to wishlist" | "Remove from wishlist">; }) { let id = `three-product-grid-item-${useId()}`; + let { Form } = useFetcher(); + let location = useLocation(); return (
  • @@ -38,7 +44,12 @@ function ThreeProductGridItem({ backgroundColor )} > - +
    -
    +
    + + + + + -
    +
    @@ -124,9 +161,11 @@ function ThreeProductGridItem({ export function ThreeProductGrid({ products, variant = "primary", + translations, }: { products: ThreeProductGridProduct[]; variant?: "primary" | "secondary"; + translations: PickTranslations<"Add to wishlist" | "Remove from wishlist">; }) { let [backgroundColors, wishlistColors] = useMemo( () => @@ -176,6 +215,7 @@ export function ThreeProductGrid({ index={index} backgroundColor={backgroundColors[index % 3]} wishlistColors={wishlistColors[index % 3]} + translations={translations} /> ))} diff --git a/app/components/wishlist-listitem.tsx b/app/components/wishlist-listitem.tsx new file mode 100644 index 0000000..f125d37 --- /dev/null +++ b/app/components/wishlist-listitem.tsx @@ -0,0 +1,201 @@ +import type { ReactNode } from "react"; +import { useFetcher, useLocation } from "remix"; +import cn from "classnames"; + +import { PickTranslations } from "~/translations.server"; + +import { OptimizedImage } from "./optimized-image"; + +import { CartIcon, CloseIcon, MinusIcon, PlusIcon } from "./icons"; + +export function WishlistListItem({ + formattedOptions, + formattedPrice, + image, + quantity, + title, + variantId, + productId, + translations, +}: { + formattedOptions: ReactNode; + formattedPrice: ReactNode; + image: string; + quantity: number; + title: ReactNode; + variantId: string; + productId: string; + translations: PickTranslations< + | "Add item" + | "Remove from wishlist" + | "Subtract item" + | "Quantity: $1" + | "Move to cart" + >; +}) { + let location = useLocation(); + let { Form } = useFetcher(); + + return ( +
  • +
    +
    + +
    +
    +

    {title}

    + {formattedOptions ? ( +

    {formattedOptions}

    + ) : null} +
    +

    {formattedPrice}

    +
    +
    +
    + + + + +
    +
    + + {translations["Quantity: $1"]?.replace("$1", quantity.toString())} + + {quantity} +
    +
    + + + + + + +
    +
    + + + + + + +
    +
    + + + + +
    +
    +
  • + ); +} diff --git a/app/components/wishlist-popover.tsx b/app/components/wishlist-popover.tsx new file mode 100644 index 0000000..6b35b52 --- /dev/null +++ b/app/components/wishlist-popover.tsx @@ -0,0 +1,129 @@ +import { Fragment } from "react"; +import { Dialog, Transition } from "@headlessui/react"; + +import { FullWishlistItem } from "~/models/ecommerce-provider.server"; +import { PickTranslations } from "~/translations.server"; + +import { CloseIcon, WishlistIcon } from "./icons"; + +import { WishlistListItem } from "./wishlist-listitem"; + +export function WishlistPopover({ + wishlist, + wishlistCount, + open, + onClose, + translations, +}: { + wishlist?: FullWishlistItem[]; + wishlistCount?: number; + open: boolean; + onClose: () => void; + translations: PickTranslations< + | "Wishlist" + | "Close" + | "Your wishlist is empty" + | "Quantity: $1" + | "Remove from wishlist" + | "Subtract item" + | "Add item" + | "Move to cart" + >; +}) { + return ( + + +
    + + + + + +
    +
    + + + + {!!wishlistCount && ( + + {wishlistCount} + + )} + +
    +
    + {!wishlist ? ( +
    + + + + + {translations["Your wishlist is empty"]} + +
    + ) : ( + <> +
    + + {translations.Wishlist} + +
      + {wishlist.map((item) => ( + + ))} +
    +
    + + )} +
    +
    +
    +
    +
    +
    + ); +} diff --git a/app/containers/cdp/cdp.server.ts b/app/containers/cdp/cdp.server.ts index 3186b47..0ff683e 100644 --- a/app/containers/cdp/cdp.server.ts +++ b/app/containers/cdp/cdp.server.ts @@ -10,7 +10,7 @@ import commerce from "~/commerce.server"; import { getSession } from "~/session.server"; export type CDPProduct = { - id: string | number; + id: string; title: string; formattedPrice: string; favorited: boolean; @@ -36,12 +36,17 @@ export let loader: LoaderFunction = async ({ request, params }) => { let sort = url.searchParams.get("sort") || undefined; let search = url.searchParams.get("q") || undefined; - let [categories, sortByOptions, products] = await Promise.all([ + let [categories, sortByOptions, products, wishlist] = await Promise.all([ commerce.getCategories(lang, 250), commerce.getSortByOptions(lang), commerce.getProducts(lang, category, sort, search), + session.getWishlist(), ]); + let wishlistHasProduct = new Set( + wishlist.map((item) => item.productId) + ); + return json({ category, sort, @@ -49,7 +54,7 @@ export let loader: LoaderFunction = async ({ request, params }) => { search, sortByOptions, products: products.map((product) => ({ - favorited: product.favorited, + favorited: wishlistHasProduct.has(product.id), formattedPrice: product.formattedPrice, id: product.id, image: product.image, diff --git a/app/containers/home/home.component.tsx b/app/containers/home/home.component.tsx index 5e4ac02..99f6d14 100644 --- a/app/containers/home/home.component.tsx +++ b/app/containers/home/home.component.tsx @@ -29,6 +29,7 @@ export default function IndexPage() { () => chunkProducts(0, 3, featuredProducts), [featuredProducts] )} + translations={translations} /> chunkProducts(6, 3, featuredProducts), [featuredProducts] )} + translations={translations} /> ; }; export let loader: LoaderFunction = async ({ request, params }) => { let session = await getSession(request, params); let lang = session.getLanguage(); - let featuredProducts = await commerce.getFeaturedProducts(lang); + let [featuredProducts, wishlist] = await Promise.all([ + commerce.getFeaturedProducts(lang), + session.getWishlist(), + ]); + + let wishlistHasProduct = new Set( + wishlist.map((item) => item.productId) + ); return json({ featuredProducts: featuredProducts.map( - ({ favorited, formattedPrice, id, image, slug, title }) => ({ - favorited, + ({ formattedPrice, id, image, slug, title, defaultVariantId }) => ({ + favorited: wishlistHasProduct.has(id), formattedPrice, id, + defaultVariantId, image, title, to: `/${lang}/product/${slug}`, @@ -35,6 +47,8 @@ export let loader: LoaderFunction = async ({ request, params }) => { "MockCTADescription", "MockCTAHeadline", "MockCTALink", + "Add to wishlist", + "Remove from wishlist", ]), }); }; diff --git a/app/containers/layout/layout.component.tsx b/app/containers/layout/layout.component.tsx index 30842ee..cc53b0e 100644 --- a/app/containers/layout/layout.component.tsx +++ b/app/containers/layout/layout.component.tsx @@ -33,6 +33,11 @@ let LanguageDialog = lazy(() => default: LanguageDialog, })) ); +let WishlistPopover = lazy(() => + import("~/components/wishlist-popover").then(({ WishlistPopover }) => ({ + default: WishlistPopover, + })) +); export const meta: MetaFunction = () => { return { @@ -60,10 +65,11 @@ export function Document({ children: ReactNode; loaderData?: LoaderData; }) { - let { cart, categories, lang, pages, translations } = loaderData || { - lang: "en", - pages: [], - }; + let { cart, categories, lang, pages, translations, wishlist } = + loaderData || { + lang: "en", + pages: [], + }; let allCategories = useMemo(() => { let results: NavbarCategory[] = translations @@ -89,6 +95,11 @@ export function Document({ [cart] ); + let wishlistCount = useMemo( + () => wishlist?.reduce((sum, item) => sum + item.quantity, 0), + [wishlist] + ); + return ( @@ -100,6 +111,7 @@ export function Document({ ) : null} + {translations ? ( + + + setWishlistOpen(false)} + /> + + + ) : null} + {translations ? ( diff --git a/app/containers/layout/layout.server.ts b/app/containers/layout/layout.server.ts index 013d608..58a288e 100644 --- a/app/containers/layout/layout.server.ts +++ b/app/containers/layout/layout.server.ts @@ -5,7 +5,10 @@ import commerce from "~/commerce.server"; import { getSession } from "~/session.server"; import { getTranslations } from "~/translations.server"; import type { PickTranslations } from "~/translations.server"; -import type { CartInfo } from "~/models/ecommerce-provider.server"; +import type { + CartInfo, + FullWishlistItem, +} from "~/models/ecommerce-provider.server"; import type { Language } from "~/models/language"; import type { FooterPage } from "~/components/footer"; @@ -16,6 +19,7 @@ export type LoaderData = { lang: Language; categories: NavbarCategory[]; pages: FooterPage[]; + wishlist?: FullWishlistItem[]; translations: PickTranslations< | "All" | "Cart" @@ -40,18 +44,24 @@ export type LoaderData = { | "Total" | "Taxes" | "Shipping" + | "Your wishlist is empty" + | "Remove from wishlist" + | "Move to cart" >; }; export let loader: LoaderFunction = async ({ request, params }) => { let session = await getSession(request, params); let lang = session.getLanguage(); - let [categories, pages, cart] = await Promise.all([ + let [categories, pages, cart, wishlist] = await Promise.all([ commerce.getCategories(lang, 2), commerce.getPages(lang), session .getCart() .then((cartItems) => commerce.getCartInfo(lang, cartItems)), + session + .getWishlist() + .then((wishlistItems) => commerce.getWishlistInfo(lang, wishlistItems)), ]); let translations = getTranslations(lang, [ @@ -78,6 +88,9 @@ export let loader: LoaderFunction = async ({ request, params }) => { "Total", "Taxes", "Shipping", + "Your wishlist is empty", + "Remove from wishlist", + "Move to cart", ]); return json({ @@ -102,5 +115,6 @@ export let loader: LoaderFunction = async ({ request, params }) => { })), ], translations, + wishlist, }); }; diff --git a/app/containers/wishlist/wishlist.component.tsx b/app/containers/wishlist/wishlist.component.tsx new file mode 100644 index 0000000..2316d67 --- /dev/null +++ b/app/containers/wishlist/wishlist.component.tsx @@ -0,0 +1,44 @@ +import { useLoaderData } from "remix"; + +import { WishlistListItem } from "~/components/wishlist-listitem"; +import { WishlistIcon } from "~/components/icons"; + +import type { LoaderData } from "./wishlist.server"; + +export default function Wishlist() { + let { wishlist, translations } = useLoaderData(); + + return ( +
    +

    {translations.Wishlist}

    + {!wishlist ? ( +
    + + + +

    + {translations["Your wishlist is empty"]} +

    +
    + ) : ( + <> +
      + {wishlist.map((item) => ( + + ))} +
    + + )} +
    + ); +} diff --git a/app/containers/wishlist/wishlist.server.ts b/app/containers/wishlist/wishlist.server.ts new file mode 100644 index 0000000..65319f3 --- /dev/null +++ b/app/containers/wishlist/wishlist.server.ts @@ -0,0 +1,133 @@ +import { json, redirect } from "remix"; +import type { ActionFunction, HeadersFunction, LoaderFunction } from "remix"; + +import { + updateCartItem, + addToWishlist, + updateWishlistItem, + removeWishlistItem, + getSession, +} from "~/session.server"; +import { getTranslations, PickTranslations } from "~/translations.server"; +import commerce from "~/commerce.server"; +import type { FullWishlistItem } from "~/models/ecommerce-provider.server"; +import { validateRedirect } from "~/utils/redirect.server"; + +export let headers: HeadersFunction = ({ actionHeaders }) => { + return actionHeaders; +}; + +export let action: ActionFunction = async ({ request, params }) => { + let [body, session] = await Promise.all([ + request.text(), + getSession(request, params), + ]); + + let formData = new URLSearchParams(body); + let redirectTo = validateRedirect(formData.get("redirect"), "/wishlist"); + let action = formData.get("_action"); + + try { + let wishlist = await session.getWishlist(); + + switch (action) { + case "add": { + let productId = formData.get("productId"); + let variantId = formData.get("variantId"); + if (!productId || !variantId) { + break; + } + wishlist = addToWishlist(wishlist, productId, variantId, 1); + break; + } + case "set-quantity": { + let productId = formData.get("productId"); + let variantId = formData.get("variantId"); + let quantityStr = formData.get("quantity"); + console.log({ productId, variantId, quantityStr }); + if (!productId || !variantId || !quantityStr) { + break; + } + let quantity = Number.parseInt(quantityStr, 10); + wishlist = updateWishlistItem(wishlist, productId, variantId, quantity); + break; + } + case "delete": { + let variantId = formData.get("variantId"); + if (!variantId) { + break; + } + wishlist = removeWishlistItem(wishlist, variantId); + break; + } + case "move-to-cart": { + let variantId = formData.get("variantId"); + if (!variantId) { + break; + } + let wishlistItem = wishlist.find( + (item) => item.variantId === variantId + ); + if (!wishlistItem) { + break; + } + let cart = await session.getCart(); + let existingCartItem = cart.find( + (item) => item.variantId === variantId + ); + wishlist = removeWishlistItem(wishlist, variantId); + cart = updateCartItem( + cart, + wishlistItem.variantId, + wishlistItem.quantity + (existingCartItem?.quantity || 0) + ); + await session.setCart(cart); + } + } + + await session.setWishlist(wishlist); + return redirect(redirectTo, { + headers: { + "Set-Cookie": await session.commitSession(), + }, + }); + } catch (error) { + console.error(error); + } + + return redirect(redirectTo); +}; + +export type LoaderData = { + wishlist?: FullWishlistItem[]; + translations: PickTranslations< + | "Add item" + | "Remove from wishlist" + | "Subtract item" + | "Quantity: $1" + | "Your wishlist is empty" + | "Wishlist" + | "Move to cart" + >; +}; + +export let loader: LoaderFunction = async ({ request, params }) => { + let session = await getSession(request, params); + let lang = session.getLanguage(); + let wishlist = await session + .getWishlist() + .then((wishlistItems) => commerce.getWishlistInfo(lang, wishlistItems)); + + return json({ + wishlist, + translations: getTranslations(lang, [ + "Add item", + "Remove from wishlist", + "Subtract item", + "Quantity: $1", + "Your wishlist is empty", + "Wishlist", + "Move to cart", + ]), + }); +}; diff --git a/app/models/ecommerce-provider.server.ts b/app/models/ecommerce-provider.server.ts index 7bfb3bb..1a1e15c 100644 --- a/app/models/ecommerce-provider.server.ts +++ b/app/models/ecommerce-provider.server.ts @@ -17,19 +17,29 @@ export interface CartInfo { items: FullCartItem[]; } +export interface WishlistItem { + productId: string; + variantId: string; + quantity: number; +} + +export interface FullWishlistItem extends WishlistItem { + info: Product; +} + export interface Category { name: string; slug: string; } export interface Product { - id: string | number; + id: string; title: string; formattedPrice: string; formattedOptions?: string; - favorited: boolean; image: string; slug: string; + defaultVariantId: string; } export interface ProductOption { @@ -47,7 +57,7 @@ export interface FullProduct extends Product { } export interface Page { - id: string | number; + id: string; slug: string; title: string; } @@ -75,13 +85,13 @@ export interface EcommerceProvider { getCategories(language: Language, count: number): Promise; getCheckoutUrl(language: Language, items: CartItem[]): Promise; getFeaturedProducts(language: Language): Promise; - getPage(language: Language, slug: string): Promise; + getPage(language: Language, slug: string): Promise; getPages(language: Language): Promise; getProduct( language: Language, slug: string, selectedOptions?: SelectedProductOption[] - ): Promise; + ): Promise; getProducts( language: Language, category?: string, @@ -89,4 +99,8 @@ export interface EcommerceProvider { search?: string ): Promise; getSortByOptions(language: Language): Promise; + getWishlistInfo( + locale: Language, + items: WishlistItem[] + ): Promise; } diff --git a/app/models/ecommerce-providers/shopify.server.ts b/app/models/ecommerce-providers/shopify.server.ts index c41b529..78a0446 100644 --- a/app/models/ecommerce-providers/shopify.server.ts +++ b/app/models/ecommerce-providers/shopify.server.ts @@ -6,6 +6,7 @@ import type { Page, Product, FullCartItem, + FullWishlistItem, } from "../ecommerce-provider.server"; import type { RequestResponseCache } from "../request-response-cache.server"; @@ -74,8 +75,8 @@ export function createShopifyProvider({ quantity: itemInput.quantity, variantId: itemInput.variantId, info: { + defaultVariantId: item.id, id: item.product.id, - favorited: false, formattedPrice: formatPrice(item.priceV2), image: item.image?.originalSrc || @@ -148,11 +149,11 @@ export function createShopifyProvider({ let products = json.data.products.edges.map( ({ - node: { id, handle, title, images, priceRange }, + node: { id, handle, title, images, priceRange, variants }, }: any): Product => ({ - favorited: false, formattedPrice: formatPrice(priceRange.minVariantPrice), id, + defaultVariantId: variants.edges[0].node.id, image: images.edges[0].node.originalSrc, slug: handle, title, @@ -165,7 +166,7 @@ export function createShopifyProvider({ let json = await query(locale, getPageQuery, { query: `handle:${slug}` }); let page = json.data.pages.edges[0]?.node; if (!page) { - return null; + return undefined; } return { @@ -190,7 +191,7 @@ export function createShopifyProvider({ let json = await query(locale, getProductQuery, { slug }); if (!json.data.productByHandle) { - return null; + return undefined; } let { @@ -212,10 +213,14 @@ export function createShopifyProvider({ .map((option) => [option.name, option.value]) ); + let defaultVariantId: string | undefined = undefined; let selectedVariantId: string | undefined; let availableForSale = false; let price = priceRange.minVariantPrice; for (let { node } of variants.edges) { + if (typeof defaultVariantId === "undefined") { + defaultVariantId = node.id; + } if ( node.selectedOptions.every( (option: any) => @@ -230,9 +235,9 @@ export function createShopifyProvider({ } return { - favorited: false, formattedPrice: formatPrice(price), id, + defaultVariantId: defaultVariantId!, image: images.edges[0].node.originalSrc, images: images.edges.map( ({ node: { originalSrc } }: any) => originalSrc @@ -301,11 +306,11 @@ export function createShopifyProvider({ let products = edges.map( ({ - node: { id, handle, title, images, priceRange }, + node: { id, handle, title, images, priceRange, variants }, }: any): Product => ({ - favorited: false, formattedPrice: formatPrice(priceRange.minVariantPrice), id, + defaultVariantId: variants.edges[0].node.id, image: images.edges[0].node.originalSrc, slug: handle, title, @@ -340,6 +345,47 @@ export function createShopifyProvider({ }, ]; }, + async getWishlistInfo(locale, items) { + let json = await query(locale, getProductVariantsQuery, { + ids: items.map((item) => item.variantId), + }); + + if (!json?.data?.nodes) { + return undefined; + } + + let itemsMap = new Map(items.map((item) => [item.variantId, item])); + let fullItems: FullWishlistItem[] = []; + for (let item of json.data.nodes) { + let itemInput = itemsMap.get(item.id); + if (!itemInput) { + continue; + } + + fullItems.push({ + productId: item.product.id, + quantity: itemInput.quantity, + variantId: itemInput.variantId, + info: { + defaultVariantId: item.id, + id: item.product.id, + formattedPrice: formatPrice(item.priceV2), + image: + item.image?.originalSrc || + item.product.images.edges[0].node.originalSrc, + title: item.product.title, + formattedOptions: item.title, + slug: item.product.handle, + }, + }); + } + + if (!fullItems.length) { + return undefined; + } + + return fullItems; + }, }; } @@ -435,19 +481,6 @@ let getPageQuery = /* GraphQL */ ` } } `; -// let getPageQuery = /* GraphQL */ ` -// query getPage($handle: ID!) { -// node(handle: $handle) { -// id -// ... on Page { -// title -// handle -// body -// bodySummary -// } -// } -// } -// `; let productConnectionFragment = /* GraphQL */ ` fragment productConnection on ProductConnection { @@ -481,6 +514,13 @@ let productConnectionFragment = /* GraphQL */ ` } } } + variants(first: 1) { + edges { + node { + id + } + } + } } } } diff --git a/app/routes/$lang/wishlist.ts b/app/routes/$lang/wishlist.ts new file mode 100644 index 0000000..0a1ec77 --- /dev/null +++ b/app/routes/$lang/wishlist.ts @@ -0,0 +1,11 @@ +import Component from "~/containers/wishlist/wishlist.component"; +import { action, headers, loader } from "~/containers/wishlist/wishlist.server"; +import { GenericCatchBoundary } from "~/containers/boundaries/generic-catch-boundary"; +import { GenericErrorBoundary } from "~/containers/boundaries/generic-error-boundary"; + +export default Component; +export { action, headers, loader }; +export { + GenericCatchBoundary as CatchBoundary, + GenericErrorBoundary as ErrorBoundary, +}; diff --git a/app/routes/wishlist.ts b/app/routes/wishlist.ts new file mode 100644 index 0000000..0a1ec77 --- /dev/null +++ b/app/routes/wishlist.ts @@ -0,0 +1,11 @@ +import Component from "~/containers/wishlist/wishlist.component"; +import { action, headers, loader } from "~/containers/wishlist/wishlist.server"; +import { GenericCatchBoundary } from "~/containers/boundaries/generic-catch-boundary"; +import { GenericErrorBoundary } from "~/containers/boundaries/generic-error-boundary"; + +export default Component; +export { action, headers, loader }; +export { + GenericCatchBoundary as CatchBoundary, + GenericErrorBoundary as ErrorBoundary, +}; diff --git a/app/session.server.ts b/app/session.server.ts index d913974..b2eddb3 100644 --- a/app/session.server.ts +++ b/app/session.server.ts @@ -1,7 +1,10 @@ import { createCookieSessionStorage } from "remix"; import type { Params } from "react-router-dom"; -import type { CartItem } from "./models/ecommerce-provider.server"; +import type { + CartItem, + WishlistItem, +} from "./models/ecommerce-provider.server"; import { validateLanguage } from "./models/language"; import type { Language } from "./models/language"; @@ -21,6 +24,7 @@ let sessionStorage = createCookieSessionStorage({ let cartSessionKey = "cart"; let langSessionKey = "language"; +let wishlistSessionKey = "wishlist"; export async function getSession( input: Request | string | null | undefined, @@ -42,6 +46,14 @@ export async function getSession( async setCart(cart: CartItem[]) { session.set(cartSessionKey, JSON.stringify(cart)); }, + // TODO: Get and set wishlist from redis or something if user is logged in (could probably use a storage abstraction) + async getWishlist(): Promise { + let wishlist = JSON.parse(session.get(wishlistSessionKey) || "[]"); + return wishlist; + }, + async setWishlist(wishlist: WishlistItem[]) { + session.set(wishlistSessionKey, JSON.stringify(wishlist)); + }, getLanguage(): Language { if (validateLanguage(params.lang)) { return params.lang; @@ -96,3 +108,50 @@ export function updateCartItem( export function removeCartItem(cart: CartItem[], variantId: string) { return cart.filter((item) => item.variantId !== variantId); } + +export function addToWishlist( + wishlist: WishlistItem[], + productId: string, + variantId: string, + quantity: number +) { + let added = false; + for (let item of wishlist) { + if (item.variantId === variantId) { + item.quantity += quantity; + added = true; + break; + } + } + if (!added) { + wishlist.push({ productId, variantId, quantity }); + } + return wishlist; +} + +export function updateWishlistItem( + wishlist: WishlistItem[], + productId: string, + variantId: string, + quantity: number +) { + let updated = false; + for (let item of wishlist) { + if (item.variantId === variantId) { + item.quantity = quantity; + updated = true; + break; + } + } + if (!updated) { + wishlist.push({ productId, variantId, quantity }); + } + return wishlist; +} + +export function removeWishlistItem( + wishlist: WishlistItem[], + variantId: string +) { + return wishlist.filter((item) => item.variantId !== variantId); +} diff --git a/app/translations.server.tsx b/app/translations.server.tsx index 3fb8949..a3ea1c9 100644 --- a/app/translations.server.tsx +++ b/app/translations.server.tsx @@ -144,6 +144,22 @@ let translations = { en: "Shipping", es: "Envío", }, + "Your wishlist is empty": { + en: "Your wishlist is empty", + es: "Tu lista de deseos está vacía", + }, + "Remove from wishlist": { + en: "Remove from wishlist", + es: "Eliminar de la lista de deseos", + }, + "Move to cart": { + en: "Move to cart", + es: "Mover al carrito", + }, + "Add to wishlist": { + en: "Add to wishlist", + es: "Añadir a la lista de deseos", + }, MockCTAHeadline: { en: "Dessert dragée halvah croissant.",