diff --git a/.components/carousel.tsx b/.components/carousel.tsx new file mode 100644 index 000000000..286d4dfea --- /dev/null +++ b/.components/carousel.tsx @@ -0,0 +1,40 @@ +import { getCollectionProducts } from 'lib/shopify'; +import Link from 'next/link'; +import { GridTileImage } from './grid/tile'; + +export async function Carousel() { + // Collections that start with `hidden-*` are hidden from the search page. + const products = await getCollectionProducts({ collection: 'hidden-homepage-carousel' }); + + if (!products?.length) return null; + + // Purposefully duplicating products to make the carousel loop and not run out of products on wide screens. + const carouselProducts = [...products, ...products, ...products]; + + return ( +
+ +
+ ); +} diff --git a/.components/cart/actions.ts b/.components/cart/actions.ts new file mode 100644 index 000000000..fa2c34d37 --- /dev/null +++ b/.components/cart/actions.ts @@ -0,0 +1,83 @@ +'use server'; + +import { TAGS } from 'lib/constants'; +import { addToCart, createCart, getCart, removeFromCart, updateCart } from 'lib/shopify'; +import { revalidateTag } from 'next/cache'; +import { cookies } from 'next/headers'; + +export async function addItem(prevState: any, selectedVariantId: string | undefined) { + let cartId = cookies().get('cartId')?.value; + let cart; + + if (cartId) { + cart = await getCart(cartId); + } + + if (!cartId || !cart) { + cart = await createCart(); + cartId = cart.id; + cookies().set('cartId', cartId); + } + + if (!selectedVariantId) { + return 'Missing product variant ID'; + } + + try { + await addToCart(cartId, [{ merchandiseId: selectedVariantId, quantity: 1 }]); + revalidateTag(TAGS.cart); + } catch (e) { + return 'Error adding item to cart'; + } +} + +export async function removeItem(prevState: any, lineId: string) { + const cartId = cookies().get('cartId')?.value; + + if (!cartId) { + return 'Missing cart ID'; + } + + try { + await removeFromCart(cartId, [lineId]); + revalidateTag(TAGS.cart); + } catch (e) { + return 'Error removing item from cart'; + } +} + +export async function updateItemQuantity( + prevState: any, + payload: { + lineId: string; + variantId: string; + quantity: number; + } +) { + const cartId = cookies().get('cartId')?.value; + + if (!cartId) { + return 'Missing cart ID'; + } + + const { lineId, variantId, quantity } = payload; + + try { + if (quantity === 0) { + await removeFromCart(cartId, [lineId]); + revalidateTag(TAGS.cart); + return; + } + + await updateCart(cartId, [ + { + id: lineId, + merchandiseId: variantId, + quantity + } + ]); + revalidateTag(TAGS.cart); + } catch (e) { + return 'Error updating item quantity'; + } +} diff --git a/.components/cart/add-to-cart.tsx b/.components/cart/add-to-cart.tsx new file mode 100644 index 000000000..35329b03d --- /dev/null +++ b/.components/cart/add-to-cart.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { PlusIcon } from '@heroicons/react/24/outline'; +import clsx from 'clsx'; +import { addItem } from 'components/cart/actions'; +import LoadingDots from 'components/loading-dots'; +import { ProductVariant } from 'lib/shopify/types'; +import { useSearchParams } from 'next/navigation'; +import { useFormState, useFormStatus } from 'react-dom'; + +function SubmitButton({ + availableForSale, + selectedVariantId +}: { + availableForSale: boolean; + selectedVariantId: string | undefined; +}) { + const { pending } = useFormStatus(); + const buttonClasses = + 'relative flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white'; + const disabledClasses = 'cursor-not-allowed opacity-60 hover:opacity-60'; + + if (!availableForSale) { + return ( + + ); + } + + if (!selectedVariantId) { + return ( + + ); + } + + return ( + + ); +} + +export function AddToCart({ + variants, + availableForSale +}: { + variants: ProductVariant[]; + availableForSale: boolean; +}) { + const [message, formAction] = useFormState(addItem, null); + const searchParams = useSearchParams(); + const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined; + const variant = variants.find((variant: ProductVariant) => + variant.selectedOptions.every( + (option) => option.value === searchParams.get(option.name.toLowerCase()) + ) + ); + const selectedVariantId = variant?.id || defaultVariantId; + const actionWithVariant = formAction.bind(null, selectedVariantId); + + return ( +
+ +

+ {message} +

+ + ); +} diff --git a/.components/cart/close-cart.tsx b/.components/cart/close-cart.tsx new file mode 100644 index 000000000..515b94843 --- /dev/null +++ b/.components/cart/close-cart.tsx @@ -0,0 +1,10 @@ +import { XMarkIcon } from '@heroicons/react/24/outline'; +import clsx from 'clsx'; + +export default function CloseCart({ className }: { className?: string }) { + return ( +
+ +
+ ); +} diff --git a/.components/cart/delete-item-button.tsx b/.components/cart/delete-item-button.tsx new file mode 100644 index 000000000..814e1f389 --- /dev/null +++ b/.components/cart/delete-item-button.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { XMarkIcon } from '@heroicons/react/24/outline'; +import clsx from 'clsx'; +import { removeItem } from 'components/cart/actions'; +import LoadingDots from 'components/loading-dots'; +import type { CartItem } from 'lib/shopify/types'; +import { useFormState, useFormStatus } from 'react-dom'; + +function SubmitButton() { + const { pending } = useFormStatus(); + + return ( + + ); +} + +export function DeleteItemButton({ item }: { item: CartItem }) { + const [message, formAction] = useFormState(removeItem, null); + const itemId = item.id; + const actionWithVariant = formAction.bind(null, itemId); + + return ( +
+ +

+ {message} +

+ + ); +} diff --git a/.components/cart/edit-item-quantity-button.tsx b/.components/cart/edit-item-quantity-button.tsx new file mode 100644 index 000000000..b743ab704 --- /dev/null +++ b/.components/cart/edit-item-quantity-button.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { MinusIcon, PlusIcon } from '@heroicons/react/24/outline'; +import clsx from 'clsx'; +import { updateItemQuantity } from 'components/cart/actions'; +import LoadingDots from 'components/loading-dots'; +import type { CartItem } from 'lib/shopify/types'; +import { useFormState, useFormStatus } from 'react-dom'; + +function SubmitButton({ type }: { type: 'plus' | 'minus' }) { + const { pending } = useFormStatus(); + + return ( + + ); +} + +export function EditItemQuantityButton({ item, type }: { item: CartItem; type: 'plus' | 'minus' }) { + const [message, formAction] = useFormState(updateItemQuantity, null); + const payload = { + lineId: item.id, + variantId: item.merchandise.id, + quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1 + }; + const actionWithVariant = formAction.bind(null, payload); + + return ( +
+ +

+ {message} +

+ + ); +} diff --git a/.components/cart/index.tsx b/.components/cart/index.tsx new file mode 100644 index 000000000..3e250ba93 --- /dev/null +++ b/.components/cart/index.tsx @@ -0,0 +1,14 @@ +import { getCart } from 'lib/shopify'; +import { cookies } from 'next/headers'; +import CartModal from './modal'; + +export default async function Cart() { + const cartId = cookies().get('cartId')?.value; + let cart; + + if (cartId) { + cart = await getCart(cartId); + } + + return ; +} diff --git a/.components/cart/modal.tsx b/.components/cart/modal.tsx new file mode 100644 index 000000000..a30818940 --- /dev/null +++ b/.components/cart/modal.tsx @@ -0,0 +1,191 @@ +'use client'; + +import { Dialog, Transition } from '@headlessui/react'; +import { ShoppingCartIcon } from '@heroicons/react/24/outline'; +import Price from 'components/price'; +import { DEFAULT_OPTION } from 'lib/constants'; +import type { Cart } from 'lib/shopify/types'; +import { createUrl } from 'lib/utils'; +import Image from 'next/image'; +import Link from 'next/link'; +import { Fragment, useEffect, useRef, useState } from 'react'; +import CloseCart from './close-cart'; +import { DeleteItemButton } from './delete-item-button'; +import { EditItemQuantityButton } from './edit-item-quantity-button'; +import OpenCart from './open-cart'; + +type MerchandiseSearchParams = { + [key: string]: string; +}; + +export default function CartModal({ cart }: { cart: Cart | undefined }) { + const [isOpen, setIsOpen] = useState(false); + const quantityRef = useRef(cart?.totalQuantity); + const openCart = () => setIsOpen(true); + const closeCart = () => setIsOpen(false); + + useEffect(() => { + // Open cart modal when quantity changes. + if (cart?.totalQuantity !== quantityRef.current) { + // But only if it's not already open (quantity also changes when editing items in cart). + if (!isOpen) { + setIsOpen(true); + } + + // Always update the quantity reference + quantityRef.current = cart?.totalQuantity; + } + }, [isOpen, cart?.totalQuantity, quantityRef]); + + return ( + <> + + + + + + + + ); +} diff --git a/.components/cart/open-cart.tsx b/.components/cart/open-cart.tsx new file mode 100644 index 000000000..fa8226ab5 --- /dev/null +++ b/.components/cart/open-cart.tsx @@ -0,0 +1,24 @@ +import { ShoppingCartIcon } from '@heroicons/react/24/outline'; +import clsx from 'clsx'; + +export default function OpenCart({ + className, + quantity +}: { + className?: string; + quantity?: number; +}) { + return ( +
+ + + {quantity ? ( +
+ {quantity} +
+ ) : null} +
+ ); +} diff --git a/.components/grid/index.tsx b/.components/grid/index.tsx new file mode 100644 index 000000000..92681555a --- /dev/null +++ b/.components/grid/index.tsx @@ -0,0 +1,21 @@ +import clsx from 'clsx'; + +function Grid(props: React.ComponentProps<'ul'>) { + return ( +
    + {props.children} +
+ ); +} + +function GridItem(props: React.ComponentProps<'li'>) { + return ( +
  • + {props.children} +
  • + ); +} + +Grid.Item = GridItem; + +export default Grid; diff --git a/.components/grid/three-items.tsx b/.components/grid/three-items.tsx new file mode 100644 index 000000000..23b3f8991 --- /dev/null +++ b/.components/grid/three-items.tsx @@ -0,0 +1,57 @@ +import { GridTileImage } from 'components/grid/tile'; +import { getCollectionProducts } from 'lib/shopify'; +import type { Product } from 'lib/shopify/types'; +import Link from 'next/link'; + +function ThreeItemGridItem({ + item, + size, + priority +}: { + item: Product; + size: 'full' | 'half'; + priority?: boolean; +}) { + return ( +
    + + + +
    + ); +} + +export async function ThreeItemGrid() { + // Collections that start with `hidden-*` are hidden from the search page. + const homepageItems = await getCollectionProducts({ + collection: 'hidden-homepage-featured-items' + }); + + if (!homepageItems[0] || !homepageItems[1] || !homepageItems[2]) return null; + + const [firstProduct, secondProduct, thirdProduct] = homepageItems; + + return ( +
    + + + +
    + ); +} diff --git a/.components/grid/tile.tsx b/.components/grid/tile.tsx new file mode 100644 index 000000000..f79a459f4 --- /dev/null +++ b/.components/grid/tile.tsx @@ -0,0 +1,50 @@ +import clsx from 'clsx'; +import Image from 'next/image'; +import Label from '../label'; + +export function GridTileImage({ + isInteractive = true, + active, + label, + ...props +}: { + isInteractive?: boolean; + active?: boolean; + label?: { + title: string; + amount: string; + currencyCode: string; + position?: 'bottom' | 'center'; + }; +} & React.ComponentProps) { + return ( +
    + {props.src ? ( + // eslint-disable-next-line jsx-a11y/alt-text -- `alt` is inherited from `props`, which is being enforced with TypeScript + + ) : null} + {label ? ( +
    + ); +} diff --git a/.components/icons/logo.tsx b/.components/icons/logo.tsx new file mode 100644 index 000000000..46fa02464 --- /dev/null +++ b/.components/icons/logo.tsx @@ -0,0 +1,16 @@ +import clsx from 'clsx'; + +export default function LogoIcon(props: React.ComponentProps<'svg'>) { + return ( + + + + + ); +} diff --git a/.components/label.tsx b/.components/label.tsx new file mode 100644 index 000000000..113afacb0 --- /dev/null +++ b/.components/label.tsx @@ -0,0 +1,34 @@ +import clsx from 'clsx'; +import Price from './price'; + +const Label = ({ + title, + amount, + currencyCode, + position = 'bottom' +}: { + title: string; + amount: string; + currencyCode: string; + position?: 'bottom' | 'center'; +}) => { + return ( +
    +
    +

    {title}

    + +
    +
    + ); +}; + +export default Label; diff --git a/360/layout/footer-menu.tsx b/.components/layout/footer-menu.tsx similarity index 100% rename from 360/layout/footer-menu.tsx rename to .components/layout/footer-menu.tsx diff --git a/360/layout/footer.tsx b/.components/layout/footer.tsx similarity index 93% rename from 360/layout/footer.tsx rename to .components/layout/footer.tsx index 704ff359b..ef1b1e8c7 100644 --- a/360/layout/footer.tsx +++ b/.components/layout/footer.tsx @@ -1,6 +1,8 @@ import Link from 'next/link'; +import FooterMenu from 'components/layout/footer-menu'; import LogoSquare from 'components/logo-square'; +import { getMenu } from 'lib/shopify'; import { Suspense } from 'react'; const { COMPANY_NAME, SITE_NAME } = process.env; @@ -9,7 +11,7 @@ export default async function Footer() { const currentYear = new Date().getFullYear(); const copyrightDate = 2023 + (currentYear > 2023 ? `-${currentYear}` : ''); const skeleton = 'w-full h-6 animate-pulse rounded bg-neutral-200 dark:bg-neutral-700'; - // const menu = await getMenu('next-js-frontend-footer-menu'); + const menu = await getMenu('next-js-frontend-footer-menu'); const copyrightName = COMPANY_NAME || SITE_NAME || ''; return ( @@ -33,7 +35,7 @@ export default async function Footer() { } > - {/* */} +
    - {/* */} - sdfsd + + +
    diff --git a/360/layout/navbar/mobile-menu.tsx b/.components/layout/navbar/mobile-menu.tsx similarity index 100% rename from 360/layout/navbar/mobile-menu.tsx rename to .components/layout/navbar/mobile-menu.tsx diff --git a/360/layout/navbar/search.tsx b/.components/layout/navbar/search.tsx similarity index 100% rename from 360/layout/navbar/search.tsx rename to .components/layout/navbar/search.tsx diff --git a/360/layout/product-grid-items.tsx b/.components/layout/product-grid-items.tsx similarity index 100% rename from 360/layout/product-grid-items.tsx rename to .components/layout/product-grid-items.tsx diff --git a/360/layout/search/collections.tsx b/.components/layout/search/collections.tsx similarity index 100% rename from 360/layout/search/collections.tsx rename to .components/layout/search/collections.tsx diff --git a/360/layout/search/filter/dropdown.tsx b/.components/layout/search/filter/dropdown.tsx similarity index 100% rename from 360/layout/search/filter/dropdown.tsx rename to .components/layout/search/filter/dropdown.tsx diff --git a/360/layout/search/filter/index.tsx b/.components/layout/search/filter/index.tsx similarity index 100% rename from 360/layout/search/filter/index.tsx rename to .components/layout/search/filter/index.tsx diff --git a/360/layout/search/filter/item.tsx b/.components/layout/search/filter/item.tsx similarity index 100% rename from 360/layout/search/filter/item.tsx rename to .components/layout/search/filter/item.tsx diff --git a/.components/loading-dots.tsx b/.components/loading-dots.tsx new file mode 100644 index 000000000..10e642229 --- /dev/null +++ b/.components/loading-dots.tsx @@ -0,0 +1,15 @@ +import clsx from 'clsx'; + +const dots = 'mx-[1px] inline-block h-1 w-1 animate-blink rounded-md'; + +const LoadingDots = ({ className }: { className: string }) => { + return ( + + + + + + ); +}; + +export default LoadingDots; diff --git a/.components/logo-square.tsx b/.components/logo-square.tsx new file mode 100644 index 000000000..eccf5cba7 --- /dev/null +++ b/.components/logo-square.tsx @@ -0,0 +1,23 @@ +import clsx from 'clsx'; +import LogoIcon from './icons/logo'; + +export default function LogoSquare({ size }: { size?: 'sm' | undefined }) { + return ( +
    + +
    + ); +} diff --git a/.components/opengraph-image.tsx b/.components/opengraph-image.tsx new file mode 100644 index 000000000..288e0bd50 --- /dev/null +++ b/.components/opengraph-image.tsx @@ -0,0 +1,40 @@ +import { ImageResponse } from 'next/og'; +import LogoIcon from './icons/logo'; + +export type Props = { + title?: string; +}; + +export default async function OpengraphImage(props?: Props): Promise { + const { title } = { + ...{ + title: process.env.SITE_NAME + }, + ...props + }; + + return new ImageResponse( + ( +
    +
    + +
    +

    {title}

    +
    + ), + { + width: 1200, + height: 630, + fonts: [ + { + name: 'Inter', + data: await fetch(new URL('../fonts/Inter-Bold.ttf', import.meta.url)).then((res) => + res.arrayBuffer() + ), + style: 'normal', + weight: 700 + } + ] + } + ); +} diff --git a/.components/price.tsx b/.components/price.tsx new file mode 100644 index 000000000..e7090148d --- /dev/null +++ b/.components/price.tsx @@ -0,0 +1,24 @@ +import clsx from 'clsx'; + +const Price = ({ + amount, + className, + currencyCode = 'USD', + currencyCodeClassName +}: { + amount: string; + className?: string; + currencyCode: string; + currencyCodeClassName?: string; +} & React.ComponentProps<'p'>) => ( +

    + {`${new Intl.NumberFormat(undefined, { + style: 'currency', + currency: currencyCode, + currencyDisplay: 'narrowSymbol' + }).format(parseFloat(amount))}`} + {`${currencyCode}`} +

    +); + +export default Price; diff --git a/.components/product/gallery.tsx b/.components/product/gallery.tsx new file mode 100644 index 000000000..0b03557a5 --- /dev/null +++ b/.components/product/gallery.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline'; +import { GridTileImage } from 'components/grid/tile'; +import { createUrl } from 'lib/utils'; +import Image from 'next/image'; +import Link from 'next/link'; +import { usePathname, useSearchParams } from 'next/navigation'; + +export function Gallery({ images }: { images: { src: string; altText: string }[] }) { + const pathname = usePathname(); + const searchParams = useSearchParams(); + const imageSearchParam = searchParams.get('image'); + const imageIndex = imageSearchParam ? parseInt(imageSearchParam) : 0; + + const nextSearchParams = new URLSearchParams(searchParams.toString()); + const nextImageIndex = imageIndex + 1 < images.length ? imageIndex + 1 : 0; + nextSearchParams.set('image', nextImageIndex.toString()); + const nextUrl = createUrl(pathname, nextSearchParams); + + const previousSearchParams = new URLSearchParams(searchParams.toString()); + const previousImageIndex = imageIndex === 0 ? images.length - 1 : imageIndex - 1; + previousSearchParams.set('image', previousImageIndex.toString()); + const previousUrl = createUrl(pathname, previousSearchParams); + + const buttonClassName = + 'h-full px-6 transition-all ease-in-out hover:scale-110 hover:text-black dark:hover:text-white flex items-center justify-center'; + + return ( + <> +
    + {images[imageIndex] && ( + {images[imageIndex]?.altText + )} + + {images.length > 1 ? ( +
    +
    + + + +
    + + + +
    +
    + ) : null} +
    + + {images.length > 1 ? ( +
      + {images.map((image, index) => { + const isActive = index === imageIndex; + const imageSearchParams = new URLSearchParams(searchParams.toString()); + + imageSearchParams.set('image', index.toString()); + + return ( +
    • + + + +
    • + ); + })} +
    + ) : null} + + ); +} diff --git a/.components/product/product-description.tsx b/.components/product/product-description.tsx new file mode 100644 index 000000000..10232ae3d --- /dev/null +++ b/.components/product/product-description.tsx @@ -0,0 +1,36 @@ +import { AddToCart } from 'components/cart/add-to-cart'; +import Price from 'components/price'; +import Prose from 'components/prose'; +import { Product } from 'lib/shopify/types'; +import { Suspense } from 'react'; +import { VariantSelector } from './variant-selector'; + +export function ProductDescription({ product }: { product: Product }) { + return ( + <> +
    +

    {product.title}

    +
    + +
    +
    + + + + + {product.descriptionHtml ? ( + + ) : null} + + + + + + ); +} diff --git a/.components/product/variant-selector.tsx b/.components/product/variant-selector.tsx new file mode 100644 index 000000000..9d47eb5c8 --- /dev/null +++ b/.components/product/variant-selector.tsx @@ -0,0 +1,106 @@ +'use client'; + +import clsx from 'clsx'; +import { ProductOption, ProductVariant } from 'lib/shopify/types'; +import { createUrl } from 'lib/utils'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; + +type Combination = { + id: string; + availableForSale: boolean; + [key: string]: string | boolean; // ie. { color: 'Red', size: 'Large', ... } +}; + +export function VariantSelector({ + options, + variants +}: { + options: ProductOption[]; + variants: ProductVariant[]; +}) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const hasNoOptionsOrJustOneOption = + !options.length || (options.length === 1 && options[0]?.values.length === 1); + + if (hasNoOptionsOrJustOneOption) { + return null; + } + + const combinations: Combination[] = variants.map((variant) => ({ + id: variant.id, + availableForSale: variant.availableForSale, + // Adds key / value pairs for each variant (ie. "color": "Black" and "size": 'M"). + ...variant.selectedOptions.reduce( + (accumulator, option) => ({ ...accumulator, [option.name.toLowerCase()]: option.value }), + {} + ) + })); + + return options.map((option) => ( +
    +
    {option.name}
    +
    + {option.values.map((value) => { + const optionNameLowerCase = option.name.toLowerCase(); + + // Base option params on current params so we can preserve any other param state in the url. + const optionSearchParams = new URLSearchParams(searchParams.toString()); + + // Update the option params using the current option to reflect how the url *would* change, + // if the option was clicked. + optionSearchParams.set(optionNameLowerCase, value); + const optionUrl = createUrl(pathname, optionSearchParams); + + // In order to determine if an option is available for sale, we need to: + // + // 1. Filter out all other param state + // 2. Filter out invalid options + // 3. Check if the option combination is available for sale + // + // This is the "magic" that will cross check possible variant combinations and preemptively + // disable combinations that are not available. For example, if the color gray is only available in size medium, + // then all other sizes should be disabled. + const filtered = Array.from(optionSearchParams.entries()).filter(([key, value]) => + options.find( + (option) => option.name.toLowerCase() === key && option.values.includes(value) + ) + ); + const isAvailableForSale = combinations.find((combination) => + filtered.every( + ([key, value]) => combination[key] === value && combination.availableForSale + ) + ); + + // The option is active if it's in the url params. + const isActive = searchParams.get(optionNameLowerCase) === value; + + return ( + + ); + })} +
    +
    + )); +} diff --git a/.components/prose.tsx b/.components/prose.tsx new file mode 100644 index 000000000..f910d2296 --- /dev/null +++ b/.components/prose.tsx @@ -0,0 +1,21 @@ +import clsx from 'clsx'; +import type { FunctionComponent } from 'react'; + +interface TextProps { + html: string; + className?: string; +} + +const Prose: FunctionComponent = ({ html, className }) => { + return ( +
    + ); +}; + +export default Prose;