Example store

The full example storefront in one install: announcement, nav with a mobile drawer, hero, proof, categories, grid, product detail, lab results, reviews, guarantees, how it works, FAQ, CTA, footer and a working cart drawer.

pagestoreanycartatcsubscribereviewscoa
$npx shadcn@latest add @storekit/page-store
page-store

Live and interactive. Switch the brand in the frame header to see the same source under another token file.

Installation

$npx shadcn@latest add @storekit/page-store
$pnpm dlx shadcn@latest add @storekit/page-store
$yarn dlx shadcn@latest add @storekit/page-store
$bunx --bun shadcn@latest add @storekit/page-store

Source

components/storekit/page/page-store.tsx
224 lines
"use client"

import { useState } from "react"
import { cn } from "@/components/storekit/lib/cn"
import { AnnouncementCountdown } from "@/components/storekit/announcement-bar/announcement-countdown"
import { NavMinimal } from "@/components/storekit/nav/nav-minimal"
import { NavDrawer } from "@/components/storekit/nav/nav-drawer"
import { CartDrawer, type CartDrawerLine } from "@/components/storekit/cart/cart-drawer"
import { HeroSplit } from "@/components/storekit/hero/hero-split"
import { TrustStats } from "@/components/storekit/trust/trust-stats"
import { TrustGuarantees } from "@/components/storekit/trust/trust-guarantees"
import { CollectionCategories } from "@/components/storekit/collection/collection-categories"
import { CollectionGrid, type GridProduct } from "@/components/storekit/collection/collection-grid"
import { PdpGallery } from "@/components/storekit/pdp/pdp-gallery"
import { PdpBuybox } from "@/components/storekit/pdp/pdp-buybox"
import { PdpCoaBand } from "@/components/storekit/pdp/pdp-coa-band"
import { ReviewsSummary } from "@/components/storekit/reviews/reviews-summary"
import { ReviewsList } from "@/components/storekit/reviews/reviews-list"
import { HowItWorks } from "@/components/storekit/marketing/how-it-works"
import { FaqAccordion } from "@/components/storekit/marketing/faq-accordion"
import { CtaBand } from "@/components/storekit/marketing/cta-band"
import { FooterColumns } from "@/components/storekit/footer/footer-columns"

/* A complete storefront composed from eighteen StoreKit blocks and nothing
   else: announcement, nav (desktop bar plus mobile drawer), hero, proof,
   categories, grid, product detail, lab results, reviews, guarantees, how it
   works, FAQ, CTA, footer and a cart drawer that every add-to-cart opens.
   Swap the sample catalog for yours; the brand tokens do the rest. */

export type StoreProduct = {
  id: string
  title: string
  /** Integer cents. */
  price: number
  compareAt?: number
  image: string
  /** Shop category, e.g. "Powders". Drives the category tiles and grid filters. */
  category: string
  /** Optional product form ("powder", "capsule", "drink"...). */
  form?: string
}

export type StoreCartLine = { productId: string; variantId?: string; quantity: number; subscribe?: boolean }

const SAMPLE_PRODUCTS: StoreProduct[] = [
  { id: "daily-greens", title: "Daily Greens", price: 4900, compareAt: 5900, image: "/placeholder.webp", category: "Powders", form: "powder" },
  { id: "electrolytes", title: "Electrolytes", price: 3200, image: "/placeholder.webp", category: "Powders", form: "powder" },
  { id: "magnesium-night", title: "Magnesium Night", price: 2900, image: "/placeholder.webp", category: "Capsules & gummies", form: "gummy" },
  { id: "protein", title: "Protein", price: 5400, compareAt: 6200, image: "/placeholder.webp", category: "Powders", form: "powder" },
  { id: "focus", title: "Focus", price: 3900, image: "/placeholder.webp", category: "Capsules & gummies", form: "capsule" },
  { id: "sparkling-recovery", title: "Sparkling Recovery", price: 3600, image: "/placeholder.webp", category: "Drinks", form: "drink" },
]

export function PageStore({
  brand = { name: "Morrow", mark: "/placeholder.webp" },
  products = SAMPLE_PRODUCTS,
  hero = {
    headline: "Daily wellness, made simple.",
    subcopy: "Clean ingredients, third-party tested, made in the USA. One scoop a day, delivered on your schedule.",
    image: products[0]?.image ?? "/placeholder.webp",
  },
  categoryImages,
  ctaImage,
  onAddToCart,
  className,
}: {
  brand?: { name: string; mark: string }
  products?: StoreProduct[]
  hero?: { headline: string; subcopy: string; image: string }
  /** Tile image per category title. Defaults to the first product in that category. */
  categoryImages?: Partial<Record<string, string>>
  /** Image for the closing CTA band. Defaults to the lead product. */
  ctaImage?: string
  onAddToCart?: (line: StoreCartLine) => void
  className?: string
}) {
  const lead = products[0] ?? SAMPLE_PRODUCTS[0]
  const leadVariants = [
    { id: "30", label: "30 servings", price: lead.price },
    { id: "60", label: "60 servings", price: Math.round(lead.price * 1.8), compareAt: Math.round(lead.price * 2) },
    { id: "bundle", label: "3-pouch bundle", price: Math.round(lead.price * 2.55), compareAt: Math.round(lead.price * 3) },
  ]
  const subscribeDiscount = 0.15

  // The page owns the cart lines; the drawer edits them and reports back.
  // Adding remounts the drawer (it is closed at that moment) with the new lines.
  const [lines, setLines] = useState<CartDrawerLine[]>([])
  const [cartOpen, setCartOpen] = useState(false)
  const [cartRev, setCartRev] = useState(0)
  // Upsells are picked when the drawer mounts so the row does not reshuffle mid-session.
  const pickUpsells = (inCart: string[]) =>
    products
      .filter((x) => !inCart.includes(x.id))
      .slice(0, 2)
      .map((x) => ({ id: x.id, title: x.title, price: x.price, image: x.image }))
  const [upsells, setUpsells] = useState(() => pickUpsells([]))
  const cartCount = lines.reduce((n, l) => n + l.quantity, 0)

  const add = (line: StoreCartLine) => {
    const product = products.find((x) => x.id === line.productId)
    if (product) {
      const variant = product.id === lead.id ? leadVariants.find((v) => v.id === line.variantId) : undefined
      const base = variant?.price ?? product.price
      const price = line.subscribe ? Math.round(base * (1 - subscribeDiscount)) : base
      const compareAt = line.subscribe ? base : variant?.compareAt ?? product.compareAt
      const id = [product.id, line.variantId, line.subscribe ? "sub" : ""].filter(Boolean).join(":")
      const label = [variant?.label, line.subscribe ? "Subscribe and save" : ""].filter(Boolean).join(" / ") || undefined
      setLines((ls) =>
        ls.some((l) => l.id === id)
          ? ls.map((l) => (l.id === id ? { ...l, quantity: l.quantity + line.quantity } : l))
          : [...ls, { id, title: product.title, variant: label, price, compareAt, image: product.image, quantity: line.quantity }]
      )
      setUpsells(pickUpsells([...lines.map((l) => l.id.split(":")[0]), product.id]))
      setCartRev((r) => r + 1)
      setCartOpen(true)
    }
    onAddToCart?.(line)
  }

  const links = [
    { label: "Shop", href: "#shop" },
    { label: "Lab results", href: "#coa" },
    { label: "Reviews", href: "#reviews" },
    { label: "FAQ", href: "#faq" },
  ]

  const grid: GridProduct[] = products.map((x, i) => ({
    id: x.id,
    title: x.title,
    price: x.price,
    compareAt: x.compareAt,
    image: x.image,
    category: x.category,
    form: x.form,
    stock: i === 3 ? "low" : "in",
    isNew: i === 2,
  }))

  const titles = Array.from(new Set(products.map((x) => x.category))).slice(0, 3)
  const categories = titles.map((t) => ({
    title: t,
    count: products.filter((x) => x.category === t).length,
    image: categoryImages?.[t] ?? products.find((x) => x.category === t)?.image ?? lead.image,
    href: "#shop",
  }))
  const filters = [{ label: "All" }, ...titles.map((t) => ({ label: t, category: t }))]

  return (
    <div data-sk-section="page" data-sk-variant="page-store" className={cn("bg-sk-paper text-sk-ink", className)}>
      <AnnouncementCountdown cutoffHour={14} />
      <NavMinimal className="hidden md:block" brandName={brand.name} markSrc={brand.mark} cartCount={cartCount} onCart={() => setCartOpen(true)} links={links} />
      <NavDrawer className="md:hidden" brandName={brand.name} markSrc={brand.mark} cartCount={cartCount} onCart={() => setCartOpen(true)} categories={links} />
      <HeroSplit
        eyebrow={brand.name}
        headline={hero.headline}
        subcopy={hero.subcopy}
        image={hero.image}
        imageAlt={lead.title}
        primaryCta={{ label: "Shop the range", href: "#shop" }}
        secondaryCta={{ label: "See lab results", href: "#coa" }}
        specs={[
          { label: "Bestseller", value: lead.title },
          { label: "Servings", value: "30 per pouch" },
          { label: "Per serving", value: `$${(lead.price / 3000).toFixed(2)}`, highlight: true },
          { label: "Tested", value: "Third party" },
        ]}
      />
      <section aria-label="Proof" className="mx-auto max-w-6xl px-4 sm:px-8">
        <TrustStats />
      </section>
      <section id="shop" className="mx-auto max-w-6xl scroll-mt-20 px-4 py-12 sm:px-8 sm:py-16">
        <CollectionCategories categories={categories} />
      </section>
      <section className="mx-auto max-w-6xl px-4 pb-12 sm:px-8 sm:pb-16">
        <CollectionGrid products={grid} filters={filters} onAdd={(p) => add({ productId: (p as GridProduct).id, quantity: 1 })} />
      </section>
      <section className="border-y border-sk-line bg-sk-surface/60">
        <div className="mx-auto grid max-w-6xl gap-10 px-4 py-12 sm:px-8 sm:py-16 lg:grid-cols-[1.05fr_1fr] lg:gap-16">
          <PdpGallery images={products.slice(0, 4).map((x) => ({ src: x.image, alt: x.title }))} />
          <PdpBuybox
            title={lead.title}
            variants={leadVariants}
            subscribeDiscount={subscribeDiscount}
            onAddToCart={(line) => add({ productId: lead.id, variantId: line.variantId, quantity: line.quantity, subscribe: line.subscribe })}
          />
        </div>
      </section>
      <div id="coa" className="scroll-mt-20">
        <PdpCoaBand product={lead.title} lab={`${brand.name} partner lab`} />
      </div>
      <section id="reviews" className="mx-auto grid scroll-mt-20 max-w-6xl gap-10 px-4 py-12 sm:px-8 sm:py-16 lg:grid-cols-[1fr_1.3fr]">
        <ReviewsSummary className="self-start lg:sticky lg:top-24 lg:grid-cols-1 lg:gap-8" />
        <ReviewsList />
      </section>
      <section className="border-t border-sk-line">
        <div className="mx-auto max-w-6xl px-4 py-12 sm:px-8 sm:py-16">
          <TrustGuarantees />
        </div>
      </section>
      <HowItWorks
        className="sm:py-16"
        steps={[
          { title: "Pick your routine", body: "Choose a product or two. Subscribe and save 15%, skip or cancel anytime.", meta: "2 min", image: lead.image },
          { title: "Mix and go", body: "One scoop in water or a smoothie. No clumps, no chalky aftertaste.", meta: "30 sec", image: products[1]?.image ?? lead.image },
          { title: "Feel the difference", body: "Most people notice steadier energy within two weeks. If not, it's on us.", meta: "30 days", image: products[2]?.image ?? lead.image },
        ]}
      />
      <div id="faq" className="scroll-mt-20">
        <FaqAccordion className="pt-0 sm:pt-0" />
      </div>
      <CtaBand image={ctaImage ?? lead.image} />
      <FooterColumns brandName={brand.name} markSrc={brand.mark} />
      <CartDrawer
        key={cartRev}
        open={cartOpen}
        onOpenChange={setCartOpen}
        items={lines}
        onLinesChange={setLines}
        upsells={upsells}
      />
    </div>
  )
}
components/storekit/page/store-page.tsx
27 lines
import { PageStore } from "@/components/storekit/page/page-store"

/* Installed as app/store/page.tsx. Next.js page files may only export the
   page itself, so the store lives in components/storekit/page/page-store.tsx
   and this file feeds it your catalog. Prices are integer cents. */

export default function StorePage() {
  return (
    <PageStore
      brand={{ name: "Morrow", mark: "/placeholder.webp" }}
      products={[
        { id: "daily-greens", title: "Daily Greens", price: 4900, compareAt: 5900, image: "/placeholder.webp", category: "Powders", form: "powder" },
        { id: "electrolytes", title: "Electrolytes", price: 3200, image: "/placeholder.webp", category: "Powders", form: "powder" },
        { id: "magnesium-night", title: "Magnesium Night", price: 2900, image: "/placeholder.webp", category: "Capsules & gummies", form: "gummy" },
        { id: "protein", title: "Protein", price: 5400, compareAt: 6200, image: "/placeholder.webp", category: "Powders", form: "powder" },
        { id: "focus", title: "Focus", price: 3900, image: "/placeholder.webp", category: "Capsules & gummies", form: "capsule" },
        { id: "sparkling-recovery", title: "Sparkling Recovery", price: 3600, image: "/placeholder.webp", category: "Drinks", form: "drink" },
      ]}
      hero={{
        headline: "Daily wellness, made simple.",
        subcopy: "Clean ingredients, third-party tested, made in the USA. One scoop a day, delivered on your schedule.",
        image: "/placeholder.webp",
      }}
    />
  )
}

Usage

Installs the full example store at app/store/page.tsx. Replace the sample products with your catalog.

Example store, StoreKit