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-gridcart-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-gridSource
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.


