HextaUI

Storefront

A complete store built from the HextaUI commerce blocks. Handles browsing, filters, the bag, checkout and order tracking on one shared store.

Open in new tab

Storefront is a whole online shop for Halden, the home goods brand every commerce block uses, put together from the other blocks. The header search is Product Search, the bag is Cart, every grid and rail is Product Card, the collection page is Product Filters, the product page is Product Gallery, Variant Picker, Price, Reviews and the Delivery Estimate from Shipping Methods, checkout is Checkout, and the last two pages are Order Confirmation and Order Tracking. Storefront adds only the glue: a header and footer, routing, one shared store and small page layouts.

There are nine pages: Home with a hero, shop-by-room tiles, a New arrivals rail and an editorial band; a collection page with filters in a sidebar on wide screens and a sheet on phones, a sort and density toolbar, removable chips and Load more; a product page; search results; a wishlist; your orders; a full-page checkout with its own minimal header; the order confirmation; and order tracking. The demo catalog has 37 pieces across Living, Bedding, Kitchen and Lighting, with sales, low stock, a pre-order and sold-out items.

Everything reads from one store, so state follows the shopper. Quick add on a card, Add to bag on a product page and the bag drawer all change the same cart, and the bag count in the header rolls on every page. Hearts on cards and the product page fill the same wishlist, products you open join Recently viewed, and a placed order shows up under Your orders with its tracking. The bag shows how far you are from free shipping over $150.

Routing lives in the block. Pages are hash URLs like #/collections/living?color=Oat&sort=newest, so Back and Forward work, links open in a new tab, and the filters, sort, Load more page and chosen variant survive a reload. Going back puts you where you were scrolled. Only the page scrolls: the header stays pinned, the frame fills its container, and pages cross-fade in 150 ms, or not at all with reduced motion.

  1. HextaUI Pro holen

    Eine einmalige Zahlung schaltet jeden Block frei, auch künftige. Preise ansehen.

  2. Die Pro-Registry zu components.json hinzufügen

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  3. Token hinzufügen

    Erstelle auf deiner Kontoseite einen Token und trage ihn in .env.local als HEXTAUI_PRO_TOKEN ein.

  4. Den Block hinzufügen

    pnpm dlx shadcn@latest add @hextaui-pro/storefront

Next.js App Router routes

The store routes with the URL hash by default. Pass a router built from usePathname, useSearchParams and useRouter to give every page a real URL under /shop, with filters, sort and the chosen variant in the query string.

"use client"

import * as React from "react"
import { usePathname, useRouter, useSearchParams } from "next/navigation"

import type { ShopCatalog } from "@/components/blocks/storefront/catalog"
import { routeFromPath, routeToPath, type StorefrontRouter } from "@/components/blocks/storefront/routes"
import { Storefront } from "@/components/blocks/storefront/storefront"
import { createStorefrontStore } from "@/components/blocks/storefront/store"

const base = "/shop"

function useShopRouter(): StorefrontRouter {
  const router = useRouter()
  const pathname = usePathname()
  const params = useSearchParams()
  const search = params.toString()
  const route = React.useMemo(
    () => routeFromPath(`${pathname.slice(base.length)}${search ? `?${search}` : ""}`),
    [pathname, search]
  )

  return React.useMemo(
    () => ({
      route,
      href: (next) => `${base}${routeToPath(next)}`.replace(/\/(\?|$)/, "$1"),
      navigate: (next, options) => {
        const href = `${base}${routeToPath(next)}`.replace(/\/(\?|$)/, "$1")
        if (options?.replace) router.replace(href, { scroll: false })
        else router.push(href, { scroll: false })
      },
    }),
    [route, router]
  )
}

export function Shop({ catalog }: { catalog: ShopCatalog }) {
  const router = useShopRouter()
  const [store] = React.useState(() => createStorefrontStore())
  return <Storefront className="h-svh" store={store} catalog={catalog} router={router} />
}

Your own cart and wishlist API

Pass api functions that call your server. Each one runs before the shared store changes, returns the server’s cart when it has one, and anything it throws shows where the shopper acted.

"use client"

import * as React from "react"

import type { Cart, CartLine } from "../cart/cart"
import type { ShopCatalog } from "@/components/blocks/storefront/catalog"
import { Storefront } from "@/components/blocks/storefront/storefront"
import { createStorefrontStore, type StorefrontApi } from "@/components/blocks/storefront/store"

async function post<T>(path: string, body: unknown): Promise<T> {
  const response = await fetch(path, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  })
  const json = (await response.json()) as T & { error?: string }
  if (!response.ok) throw new Error(json.error ?? "Something went wrong. Try again.")
  return json
}

const api: Partial<StorefrontApi> = {
  addToCart: (line: CartLine) => post<Cart>("/api/cart/lines", { variantId: line.merchandise.id, quantity: line.quantity }),
  updateLine: (lineId, quantity) => post<Cart>("/api/cart/update", { lineId, quantity }),
  removeLine: (lineId) => post<Cart>("/api/cart/remove", { lineId }),
  restoreLine: (line) => post<Cart>("/api/cart/lines", { variantId: line.merchandise.id, quantity: line.quantity }),
  setWishlisted: async (product, saved) => {
    await post("/api/wishlist", { productId: product.id, saved })
  },
  notifyMe: async (_product, variant, email) => {
    await post("/api/back-in-stock", { variantId: variant.id, email })
  },
  subscribe: async (email) => {
    await post("/api/newsletter", { email })
  },
}

export function Shop({ catalog, cart }: { catalog: ShopCatalog; cart: Cart }) {
  const [store] = React.useState(() => createStorefrontStore({ cart }))
  return <Storefront className="h-svh" store={store} catalog={catalog} api={api} />
}

Shopify Storefront API

Map addToCart, updateLine and removeLine to cartLinesAdd, cartLinesUpdate and cartLinesRemove, convert the returned cart once, and let userErrors become the message under the button or the line.

"use client"

import * as React from "react"

import type { Cart } from "../cart/cart"
import { money } from "../price/money"
import type { ShopCatalog } from "@/components/blocks/storefront/catalog"
import { Storefront } from "@/components/blocks/storefront/storefront"
import { createStorefrontStore, type StorefrontApi } from "@/components/blocks/storefront/store"

type ShopifyCartLine = {
  id: string
  quantity: number
  cost: { amountPerQuantity: { amount: string; currencyCode: string } }
  merchandise: {
    id: string
    title: string
    product: { id: string; title: string; handle: string }
    image: { url: string; altText: string | null } | null
  }
}

type ShopifyCart = {
  id: string
  checkoutUrl: string
  lines: { nodes: ShopifyCartLine[] }
  cost: { subtotalAmount: { currencyCode: string } }
}

const cartFields = `id checkoutUrl cost { subtotalAmount { currencyCode } }
  lines(first: 100) { nodes { id quantity cost { amountPerQuantity { amount currencyCode } }
  merchandise { ... on ProductVariant { id title product { id title handle } image { url altText } } } } }`

async function storefront<T>(query: string, variables: Record<string, unknown>): Promise<T> {
  const response = await fetch("/api/shopify", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  })
  const { data, errors } = (await response.json()) as { data: T; errors?: { message: string }[] }
  if (errors?.length) throw new Error(errors[0].message)
  return data
}

function toCart(cart: ShopifyCart): Cart {
  return {
    id: cart.id,
    currencyCode: cart.cost.subtotalAmount.currencyCode,
    lines: cart.lines.nodes.map((line) => ({
      id: line.id,
      quantity: line.quantity,
      price: money(line.cost.amountPerQuantity.amount, line.cost.amountPerQuantity.currencyCode),
      merchandise: {
        id: line.merchandise.id,
        productId: line.merchandise.product.id,
        productTitle: line.merchandise.product.title,
        variantTitle: line.merchandise.title === "Default Title" ? undefined : line.merchandise.title,
        image: line.merchandise.image
          ? { id: line.merchandise.image.url, src: line.merchandise.image.url, alt: line.merchandise.image.altText ?? "" }
          : undefined,
      },
    })),
  }
}

type Result = { cart: ShopifyCart; userErrors: { message: string }[] }

async function mutate(name: string, query: string, variables: Record<string, unknown>) {
  const data = await storefront<Record<string, Result>>(query, variables)
  const result = data[name]
  if (result.userErrors.length) throw new Error(result.userErrors[0].message)
  return toCart(result.cart)
}

const api: Partial<StorefrontApi> = {
  addToCart: (line, cart) =>
    mutate(
      "cartLinesAdd",
      `mutation($cartId: ID!, $lines: [CartLineInput!]!) { cartLinesAdd(cartId: $cartId, lines: $lines) { cart { ${cartFields} } userErrors { message } } }`,
      { cartId: cart.id, lines: [{ merchandiseId: line.merchandise.id, quantity: line.quantity }] }
    ),
  updateLine: (lineId, quantity, cart) =>
    mutate(
      "cartLinesUpdate",
      `mutation($cartId: ID!, $lines: [CartLineUpdateInput!]!) { cartLinesUpdate(cartId: $cartId, lines: $lines) { cart { ${cartFields} } userErrors { message } } }`,
      { cartId: cart.id, lines: [{ id: lineId, quantity }] }
    ),
  removeLine: (lineId, cart) =>
    mutate(
      "cartLinesRemove",
      `mutation($cartId: ID!, $lineIds: [ID!]!) { cartLinesRemove(cartId: $cartId, lineIds: $lineIds) { cart { ${cartFields} } userErrors { message } } }`,
      { cartId: cart.id, lineIds: [lineId] }
    ),
}

export function ShopifyStorefront({ catalog, cart }: { catalog: ShopCatalog; cart: ShopifyCart }) {
  const [store] = React.useState(() => createStorefrontStore({ cart: toCart(cart) }))
  return <Storefront className="h-svh" store={store} catalog={catalog} api={api} />
}

Aufbau

Die Teile, die du zusammensetzt, von außen nach innen.

PartBeschreibung
StorefrontThe app shell: announcement bar, header with nav, search, account, wishlist and bag, the page area, footer with a newsletter field, and routing.
createStorefrontStore, useStorefrontStoreA tiny external store holding the cart, wishlist, recently viewed products and placed orders, read with useSyncExternalStore.
simulatedApiThe default api: waits like a real server, merges cart lines, refuses more than the stock on hand, and places orders through the Checkout demo helpers.
useHashRouter, routeFromPath, routeToPathThe built-in hash router and the helpers to build your own adapter.
ShopCatalogYour products (variant-picker Product plus collection, category, material, colors, description and reviews) and the collections shown in the nav.
shopFacets, shopAccessors, sortProducts, runSearchPure helpers that build facets with counts, sort a listing and search the catalog with typo tolerance.

Storefront

Also takes div props for the root. Give it a height, such as h-svh, and it fills it.

PropTypStandard
storeThe shared state, from createStorefrontStore({ cart, wishlist, recent, orders }).
StorefrontStore–
catalog{ products, collections, colors?, currencyCode }. Products drive every grid, facet, search result and recommendation.
ShopCatalog–
nameThe wordmark in the header, footer and checkout.
ReactNode"Halden"
navCollection ids for the header nav, in order.
string[]every collection
heroThe home page hero. cta is { label, to } with a route.
{ image, eyebrow?, title, description?, cta? }–
editorialThe quiet band on the home page.
{ image, eyebrow?, title, description, cta? }–
announcementThe thin bar above the header. null hides it.
ReactNode | nullfree shipping and returns line
apiServer calls: addToCart, updateLine, removeLine, restoreLine, setWishlisted, notifyMe, estimateDelivery, subscribe, getRates, estimateTax, applyCode, removeCode, placeOrder, express. Missing ones are simulated. Return the server’s cart to use it, or throw to show the message.
Partial<StorefrontApi>simulatedApi()
router{ route, navigate, href }. Replaces the built-in hash router.
StorefrontRouteruseHashRouter()
searchYour own search for the header field, such as Algolia or Shopify predictive search.
SearchFnsearches the catalog
trendingTrending searches shown when the search field is empty.
string[][]
customerThe signed-in shopper for checkout, with saved addresses and cards.
CheckoutCustomer | null–
onSignIn, onSignOutShow Sign in and Sign out in checkout.
() => void–
checkoutexpressMethods, paymentMethods, addressForm, payment, footer, defaultCountry and labels, passed to Checkout.
Pick<CheckoutProps, …>–
freeShippingThresholdDrives the bag’s free shipping bar and free delivery on the product page. null hides both.
Money | null$150
pageSizeProducts per Load more step on collection pages.
number12
localeLocale for money, numbers and dates.
string"en-US"

StorefrontRoute

What the router passes around.

PropTypStandard
pageThe page.
"home" | "collection" | "product" | "search" | "wishlist" | "account" | "checkout" | "order" | "tracking"–
idThe collection id, product handle or order id.
string–
searchQuery params: filters and sort on collections, q on search, option values like glaze=Sand on products.
string–

createStorefrontStore

Returns a StorefrontStore with setCart, clearCart, setWishlisted, viewProduct, clearRecent and addOrder.

PropTypStandard
cartThe starting cart, from your server or a cookie.
Cartan empty USD cart
wishlistSaved product ids.
string[][]
recentRecently viewed product ids, newest first. Keeps 12.
string[][]
ordersOrders shown under Your orders.
Order[][]
TasteAktion
⌘KOpens search from any page. / works too.
TabMoves through the header, then the page. After a page change focus starts at the top of the new page.
EnterOpens a link, adds to the bag, or searches for the typed text.
←→Choose a color or size in the variant picker, and move between gallery images.
EscCloses the bag, the menu, the filter sheet or the search dropdown.
  • Every page keeps the accessibility of the block that draws it: the bag announces changes politely, Add to bag says what was added, filters announce the result count, and the variant picker is a real radio group with sold-out sizes still focusable for Notify me.
  • Links are real links with real hrefs, so they work with a middle click and in a new tab. A page change announces the new page and moves focus to its start, and Back restores your scroll position.
  • Nothing important is shown only by color or motion. Sold out, low stock and sale are words, the header buttons say how many items are in the bag and the wishlist, and page fades, rails and the hero button respect reduced motion.

Gebaut mit

Die kostenlosen HextaUI-Komponenten, aus denen Storefront besteht. Jede lässt sich einzeln installieren.

Code

11 Dateien, hinzugefügt zu components/blocks/storefront.