Complete the routine

Three numbered routine steps for the cart page; each explains its role, steps in the cart show as included, add the rest in one click.

componentgridanyupsellbundleatc
$npx shadcn@latest add @storekit/cart-routine-grid
cart-routine-grid

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/cart-routine-grid
$pnpm dlx shadcn@latest add @storekit/cart-routine-grid
$yarn dlx shadcn@latest add @storekit/cart-routine-grid
$bunx --bun shadcn@latest add @storekit/cart-routine-grid

Source

components/storekit/cart/cart-routine-grid.tsx
112 lines
"use client"

import * as React from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { Check, Plus } from "lucide-react"
import { cn, money } from "@/components/storekit/lib/cn"
import { Button } from "@/components/storekit/ui/button"
import { ProductImage } from "@/components/storekit/ui/product-image"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"

/* "Complete the routine" grid for the cart page. Three numbered steps that
   explain where each product fits, so the add-on reads as the next step and
   not a random recommendation. Steps already in the cart show as included. */

export type RoutineStep = { id: string; step: string; title: string; purpose: string; price: number; image: string; inCart?: boolean }

const SAMPLE: RoutineStep[] = [
  { id: "daily-greens", step: "Morning", title: "Daily Greens", purpose: "One scoop of greens before coffee.", price: 4900, image: "/brands/morrow/greens.webp", inCart: true },
  { id: "electrolytes", step: "Midday", title: "Electrolytes", purpose: "Salt and potassium for training days and long afternoons.", price: 3200, image: "/brands/morrow/electrolytes.webp" },
  { id: "magnesium-night", step: "Night", title: "Magnesium Night", purpose: "Two gummies to wind down before bed.", price: 2900, image: "/brands/morrow/magnesium.webp" },
]

export function CartRoutineGrid({
  title = "Complete the routine",
  steps = SAMPLE,
  onAdd,
  className,
}: {
  title?: string
  steps?: RoutineStep[]
  onAdd?: (ids: string[]) => void
  className?: string
}) {
  const reduce = useReducedMotion()
  const sk = useSkBlock("cart", "cart-routine-grid")
  const [inCart, setInCart] = React.useState<Record<string, boolean>>(() => Object.fromEntries(steps.map((s) => [s.id, !!s.inCart])))
  const missing = steps.filter((s) => !inCart[s.id])
  const missingTotal = missing.reduce((n, s) => n + s.price, 0)
  const titleId = React.useId()

  const add = (ids: string[]) => {
    if (!ids.length) return
    setInCart((m) => ({ ...m, ...Object.fromEntries(ids.map((id) => [id, true])) }))
    sk.emit("convert", ids.length > 1 ? "routine_add_all" : "routine_add", steps.filter((s) => ids.includes(s.id)).reduce((n, s) => n + s.price, 0))
    onAdd?.(ids)
  }

  return (
    <section ref={sk.ref as React.Ref<HTMLElement>} {...sk.attrs} aria-labelledby={titleId} className={cn("w-full", className)}>
      <div className="flex flex-wrap items-end justify-between gap-3">
        <div>
          <p className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">
            <span className="tabular-nums">{steps.length - missing.length}</span> of <span className="tabular-nums">{steps.length}</span> in your cart
          </p>
          <h3 id={titleId} className="mt-1 font-sk-display text-xl font-semibold leading-[1.1] tracking-[-0.02em]">
            {title}
          </h3>
        </div>
        {missing.length > 1 ? (
          <Button size="sm" variant="outline" className="active:scale-[0.97]" onClick={() => add(missing.map((s) => s.id))}>
            Add the remaining {missing.length} <span className="tabular-nums">{money(missingTotal)}</span>
          </Button>
        ) : null}
      </div>

      <ol className="mt-4 grid gap-3 sm:grid-cols-3">
        {steps.map((s, i) => {
          const on = inCart[s.id]
          return (
            <li key={s.id} className={cn("flex gap-3 rounded-sk-card border border-sk-line bg-sk-card p-3 sm:flex-col sm:p-4")}>
              <ProductImage src={s.image} alt={s.title} className="h-24 w-24 shrink-0 rounded-sk-card sm:h-auto sm:w-full" zoomOnHover={false} />
              <div className="flex min-w-0 flex-1 flex-col">
                <p className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">
                  Step <span className="tabular-nums">{i + 1}</span> / {s.step}
                </p>
                <p className="mt-1 truncate text-[15px] font-medium text-sk-ink">{s.title}</p>
                <p className="mt-0.5 text-[13px] leading-[1.5] text-sk-muted">{s.purpose}</p>
                <div className="mt-auto flex items-center justify-between gap-2 pt-3">
                  <span className="text-sm font-semibold tabular-nums text-sk-price">{money(s.price)}</span>
                  <button
                    type="button"
                    disabled={on}
                    onClick={() => add([s.id])}
                    className={cn(
                      "inline-flex h-10 items-center gap-1.5 rounded-sk-btn px-3 text-[13px] font-medium transition-[background-color,color,transform] duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-card active:scale-[0.97]",
                      on ? "cursor-default bg-sk-savings/15 text-sk-savings" : "bg-sk-accent text-sk-accent-ink hover:brightness-[1.06]"
                    )}
                  >
                    <AnimatePresence mode="popLayout" initial={false}>
                      <motion.span
                        key={on ? "in" : "add"}
                        className="inline-flex items-center gap-1.5"
                        initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6 }}
                        animate={{ opacity: 1, y: 0 }}
                        exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6 }}
                        transition={{ duration: reduce ? 0 : 0.2 }}
                      >
                        {on ? <Check size={14} /> : <Plus size={14} />}
                        {on ? "In cart" : "Add"}
                      </motion.span>
                    </AnimatePresence>
                  </button>
                </div>
              </div>
            </li>
          )
        })}
      </ol>
    </section>
  )
}

Usage

Import from "@/components/storekit/cart/cart-routine-grid" and pass your product data. The demo above exercises the full prop surface with sample values.

Complete the routine, StoreKit