Delivery estimate
ZIP input and an arrival window computed from today in business days, with a pickup option row.
componentdeliveryanyshippingpickup
$
npx shadcn@latest add @storekit/pdp-delivery-estimatepdp-delivery-estimate
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/pdp-delivery-estimate$
pnpm dlx shadcn@latest add @storekit/pdp-delivery-estimate$
yarn dlx shadcn@latest add @storekit/pdp-delivery-estimate$
bunx --bun shadcn@latest add @storekit/pdp-delivery-estimateSource
components/storekit/pdp/pdp-delivery-estimate.tsx
234 lines
"use client"
import { useEffect, useId, useRef, useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { MapPin, Store, Truck } from "lucide-react"
import { cn } from "@/components/storekit/lib/cn"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"
/* Delivery date and pickup, next to the buy button. The arrival window is
computed from today in business days: processing (plus one if past the
dispatch cutoff) then transit, skipping weekends. Dates are computed after
mount, so server HTML never disagrees with the shopper's clock. Pass
`transitFor` to plug in your carrier's real estimate for a ZIP. */
type Method = "ship" | "pickup"
type Transit = { min: number; max: number }
const RING = "outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper"
const EASE = [0.16, 1, 0.3, 1] as const
function addBusinessDays(from: Date, days: number) {
const d = new Date(from)
let left = days
while (left > 0) {
d.setDate(d.getDate() + 1)
const wd = d.getDay()
if (wd !== 0 && wd !== 6) left--
}
return d
}
function nextBusinessDay(from: Date) {
const d = new Date(from)
while (d.getDay() === 0 || d.getDay() === 6) d.setDate(d.getDate() + 1)
return d
}
const fmt = (d: Date) => d.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" })
/** Demo heuristic: distance between ZIP regions. Replace with a carrier call. */
function defaultTransit(zip: string, originZip: string): Transit {
const hop = Math.abs(Number(zip[0] ?? 0) - Number(originZip[0] ?? 0))
const base = hop <= 1 ? 1 : hop <= 4 ? 2 : 3
return { min: base, max: base + 2 }
}
export function PdpDeliveryEstimate({
defaultZip = "",
originZip = "14201",
processingDays = 0,
cutoffHour = 14,
pickup = { name: "Buffalo, Elmwood Ave", readyIn: "Ready in 2 hours" },
transitFor,
onChange,
className,
}: {
defaultZip?: string
originZip?: string
/** Business days before an order leaves the warehouse. */
processingDays?: number
/** Local hour after which today's orders count from tomorrow. */
cutoffHour?: number
pickup?: { name: string; readyIn: string } | null
transitFor?: (zip: string) => Transit
onChange?: (sel: { method: Method; zip: string }) => void
className?: string
}) {
const reduce = useReducedMotion()
const uid = useId()
const sk = useSkBlock("pdp", "pdp-delivery-estimate")
const [zip, setZip] = useState(defaultZip)
const [draft, setDraft] = useState(defaultZip)
const [editing, setEditing] = useState(!defaultZip)
const [error, setError] = useState<string | null>(null)
const [method, setMethod] = useState<Method>("ship")
const [now, setNow] = useState<Date | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const methodRefs = useRef<(HTMLButtonElement | null)[]>([])
useEffect(() => setNow(new Date()), [])
let arrival: string | null = null
let shipsLine: string | null = null
if (now) {
const pastCutoff = now.getHours() >= cutoffHour
const start = nextBusinessDay(pastCutoff || now.getDay() === 0 || now.getDay() === 6 ? addBusinessDays(now, 1) : now)
const dispatch = processingDays > 0 ? addBusinessDays(start, processingDays) : start
const w = zip ? (transitFor ?? ((z: string) => defaultTransit(z, originZip)))(zip) : { min: 2, max: 5 }
arrival = `${fmt(addBusinessDays(dispatch, w.min))} to ${fmt(addBusinessDays(dispatch, w.max))}`
const sameDay = dispatch.toDateString() === now.toDateString()
shipsLine = sameDay ? `Ships today if ordered by ${cutoffHour > 12 ? cutoffHour - 12 : cutoffHour}${cutoffHour >= 12 ? "pm" : "am"}` : `Ships ${fmt(dispatch)}`
}
const submit = (e: React.FormEvent) => {
e.preventDefault()
const z = draft.trim()
if (!/^\d{5}$/.test(z)) {
setError("Enter a 5 digit ZIP code.")
return
}
setError(null)
setZip(z)
setEditing(false)
sk.emit("interact", "zip", undefined)
onChange?.({ method, zip: z })
}
const choose = (m: Method, focus = false) => {
if (m !== method) sk.emit("interact", m, undefined)
setMethod(m)
onChange?.({ method: m, zip })
if (focus) methodRefs.current[m === "ship" ? 0 : 1]?.focus()
}
const opts: Method[] = pickup ? ["ship", "pickup"] : ["ship"]
const t = { duration: reduce ? 0 : 0.25, ease: EASE }
return (
<div {...sk.attrs} ref={sk.ref as React.RefObject<HTMLDivElement>} className={cn("flex w-full flex-col gap-3", className)}>
<div className="flex min-h-[44px] flex-wrap items-center justify-between gap-x-3 gap-y-2">
<span className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">Delivery</span>
{editing ? (
<form onSubmit={submit} noValidate className="flex items-center gap-2">
<label htmlFor={`${uid}-zip`} className="sr-only">
ZIP code
</label>
<input
ref={inputRef}
id={`${uid}-zip`}
inputMode="numeric"
autoComplete="postal-code"
maxLength={5}
placeholder="ZIP code"
value={draft}
onChange={(e) => setDraft(e.target.value.replace(/\D/g, ""))}
aria-invalid={!!error || undefined}
aria-describedby={error ? `${uid}-err` : undefined}
className={cn(
"h-11 w-28 rounded-sk-btn border bg-sk-card px-3 text-base tabular-nums text-sk-ink outline-none sm:text-sm transition-[border-color,box-shadow] duration-200 placeholder:text-sk-muted/70",
"focus:border-sk-accent focus:ring-[3px] focus:ring-sk-accent/15",
error ? "border-sk-sale" : "border-sk-line"
)}
/>
<button
type="submit"
className={cn("h-11 rounded-sk-btn bg-sk-ink px-4 text-[13px] font-medium text-sk-paper transition-[opacity,transform] duration-200 hover:opacity-90 active:scale-[0.97]", RING)}
>
Check
</button>
</form>
) : (
<button
type="button"
onClick={() => {
setEditing(true)
setDraft(zip)
requestAnimationFrame(() => inputRef.current?.focus())
}}
className={cn("inline-flex h-11 items-center gap-1.5 rounded-sk-btn px-2 text-sm text-sk-ink transition-[background-color,transform] duration-200 hover:bg-sk-ink/[0.05] active:scale-[0.97]", RING)}
>
<MapPin size={14} aria-hidden className="text-sk-muted" />
<span className="tabular-nums">to {zip}</span>
<span className="text-sk-muted underline decoration-sk-line underline-offset-4">Change</span>
</button>
)}
</div>
{error ? (
<span id={`${uid}-err`} className="-mt-1 text-right text-xs text-sk-sale">
{error}
</span>
) : null}
<div role="radiogroup" aria-label="Delivery method" className="flex flex-col divide-y divide-sk-line overflow-hidden rounded-sk-card border border-sk-line bg-sk-card">
{opts.map((m, i) => {
const on = m === method
const Icon = m === "ship" ? Truck : Store
return (
<button
key={m}
ref={(el) => {
methodRefs.current[i] = el
}}
type="button"
role="radio"
aria-checked={on}
tabIndex={on ? 0 : -1}
onClick={() => choose(m)}
onKeyDown={(e) => {
if (["ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight"].includes(e.key) && opts.length > 1) {
e.preventDefault()
choose(opts[(i + 1) % opts.length], true)
}
}}
className={cn("flex min-h-[64px] w-full items-center gap-3 px-4 py-3 text-left transition-colors duration-200", on ? "bg-sk-accent/[0.04]" : "hover:bg-sk-ink/[0.02]", RING, "focus-visible:ring-inset focus-visible:ring-offset-0")}
>
<span className={cn("grid h-[18px] w-[18px] shrink-0 place-items-center rounded-full border transition-colors duration-200", on ? "border-sk-accent" : "border-sk-line")}>
<motion.span initial={false} animate={{ scale: on ? 1 : 0 }} transition={t} className="h-2.5 w-2.5 rounded-full bg-sk-accent" />
</span>
<Icon size={16} aria-hidden className="shrink-0 text-sk-muted" />
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="text-sm font-medium text-sk-ink">{m === "ship" ? "Ship to me" : `Pick up at ${pickup?.name}`}</span>
<span className="relative block min-h-5 text-[13px] leading-5 text-sk-muted" aria-live="polite">
{m === "ship" ? (
now ? (
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={arrival}
initial={{ opacity: 0, y: reduce ? 0 : 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduce ? 0 : -6 }}
transition={t}
className="block tabular-nums"
>
Arrives <span className="font-medium text-sk-ink">{arrival}</span>
</motion.span>
</AnimatePresence>
) : (
<span aria-hidden className="mt-1 block h-3 w-40 rounded-sk-chip bg-sk-ink/[0.06]" />
)
) : (
<span className="block">{pickup?.readyIn}</span>
)}
</span>
</span>
<span className="shrink-0 text-[13px] font-medium text-sk-ink">Free</span>
</button>
)
})}
</div>
<p className="min-h-[20px] text-[13px] text-sk-muted">{shipsLine ?? ""}</p>
</div>
)
}
Usage
Pass `transitFor(zip)` to use your carrier's real transit days. Dates compute after mount.
