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-estimate
pdp-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-estimate

Source

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.

Delivery estimate, StoreKit