Dispatch cutoff
A countdown to the real warehouse cutoff in its own time zone that names the next dispatch day after it passes.
componentdispatch-cutoffanyshipping
$
npx shadcn@latest add @storekit/upsell-dispatch-cutoffupsell-dispatch-cutoff
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/upsell-dispatch-cutoff$
pnpm dlx shadcn@latest add @storekit/upsell-dispatch-cutoff$
yarn dlx shadcn@latest add @storekit/upsell-dispatch-cutoff$
bunx --bun shadcn@latest add @storekit/upsell-dispatch-cutoffSource
components/storekit/upsell/upsell-dispatch-cutoff.tsx
156 lines
"use client"
import { useEffect, useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { PackageCheck, Truck } from "lucide-react"
import { cn } from "@/components/storekit/lib/cn"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"
/* PDP dispatch cutoff. A countdown to a real warehouse cutoff in the
warehouse's own time zone, not the shopper's. After the cutoff, and on
weekends, it switches to naming the next dispatch day instead of
resetting a timer. Nothing is computed on the server: it renders a static
policy line and starts ticking after mount, so there is no hydration
drift and no fake clock. */
const EASE = [0.16, 1, 0.3, 1] as const
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
function clockIn(timeZone: string, now: Date) {
const parts = new Intl.DateTimeFormat("en-US", { timeZone, weekday: "short", hour: "numeric", minute: "numeric", second: "numeric", hourCycle: "h23" }).formatToParts(now)
const get = (t: string) => parts.find((p) => p.type === t)?.value ?? "0"
const day = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].indexOf(get("weekday"))
return { day, secs: Number(get("hour")) * 3600 + Number(get("minute")) * 60 + Number(get("second")) }
}
function Digits({ value, reduce }: { value: string; reduce: boolean | null }) {
return (
<span className="inline-flex">
{value.split("").map((ch, i) => (
<span key={i} className="relative inline-block h-[1.3em] w-[1ch] overflow-hidden align-bottom">
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={ch}
initial={reduce ? { opacity: 0 } : { y: 8, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={reduce ? { opacity: 0 } : { y: -8, opacity: 0 }}
transition={{ duration: 0.35, ease: EASE }}
className="absolute inset-0 text-center"
>
{ch}
</motion.span>
</AnimatePresence>
</span>
))}
</span>
)
}
export function UpsellDispatchCutoff({
cutoffHour = 14,
timeZone = "America/New_York",
zoneLabel = "ET",
dispatchDays = [1, 2, 3, 4, 5],
showSeconds = true,
className,
}: {
/** Cutoff hour in the warehouse time zone, 24h. */
cutoffHour?: number
/** IANA zone of the warehouse. */
timeZone?: string
zoneLabel?: string
/** Days the warehouse dispatches, 0 = Sunday. */
dispatchDays?: number[]
showSeconds?: boolean
className?: string
}) {
const reduce = useReducedMotion()
const sk = useSkBlock("upsell", "upsell-dispatch-cutoff")
const [now, setNow] = useState<Date | null>(null)
useEffect(() => {
setNow(new Date())
const id = window.setInterval(() => setNow(new Date()), 1000)
return () => window.clearInterval(id)
}, [])
const hourLabel = `${cutoffHour % 12 === 0 ? 12 : cutoffHour % 12}${cutoffHour < 12 ? "am" : "pm"} ${zoneLabel}`
const pad = (n: number) => String(n).padStart(2, "0")
let state: { kind: "idle" } | { kind: "today"; h: number; m: number; s: number } | { kind: "next"; day: string } = { kind: "idle" }
if (now) {
const { day, secs } = clockIn(timeZone, now)
const left = cutoffHour * 3600 - secs
if (dispatchDays.includes(day) && left > 0) {
state = { kind: "today", h: Math.floor(left / 3600), m: Math.floor((left % 3600) / 60), s: left % 60 }
} else {
let next = day
for (let i = 1; i <= 7; i++) {
next = (day + i) % 7
if (dispatchDays.includes(next)) break
}
state = { kind: "next", day: next === (day + 1) % 7 ? "tomorrow" : DAYS[next] }
}
}
return (
<div
{...sk.attrs}
ref={sk.ref as React.RefObject<HTMLDivElement>}
role="status"
aria-live="off"
className={cn("flex w-full items-start gap-3 rounded-sk-card border border-sk-line bg-sk-card px-4 py-3", className)}
>
<span className={cn("mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-full transition-colors duration-300", state.kind === "today" ? "bg-sk-savings/[0.12] text-sk-savings" : "bg-sk-surface text-sk-muted")}>
{state.kind === "today" ? <Truck size={15} aria-hidden /> : <PackageCheck size={15} aria-hidden />}
</span>
<div className="flex min-w-0 flex-col gap-0.5">
<AnimatePresence mode="wait" initial={false}>
<motion.p
key={state.kind}
initial={{ opacity: 0, y: reduce ? 0 : 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduce ? 0 : -6 }}
transition={{ duration: reduce ? 0 : 0.3, ease: EASE }}
className="text-sm leading-[1.5] text-sk-ink"
>
{state.kind === "today" ? (
<>
Order within{" "}
<span className="font-semibold tabular-nums">
{state.h > 0 ? (
<>
<Digits value={String(state.h)} reduce={reduce} />h{" "}
</>
) : null}
<Digits value={pad(state.m)} reduce={reduce} />m
{showSeconds ? (
<>
{" "}
<Digits value={pad(state.s)} reduce={reduce} />s
</>
) : null}
</span>{" "}
to ship <span className="font-semibold text-sk-savings">today</span>
</>
) : state.kind === "next" ? (
<>
Order now to ship <span className="font-semibold">{state.day}</span>
</>
) : (
<>Same-day dispatch on orders before {hourLabel}</>
)}
</motion.p>
</AnimatePresence>
<p className="text-[12px] text-sk-muted">
Cutoff {hourLabel}, {dispatchDays.length === 5 && [1, 2, 3, 4, 5].every((d) => dispatchDays.includes(d)) ? "Monday to Friday" : dispatchDays.map((d) => DAYS[d].slice(0, 3)).join(", ")}. Tracking sent on dispatch.
</p>
</div>
{/* Screen readers get one stable sentence instead of a ticking clock. */}
<span className="sr-only">
{state.kind === "next" ? `Ships ${state.day}.` : `Orders before ${hourLabel} on dispatch days ship the same day.`}
</span>
</div>
)
}
Usage
Import from "@/components/storekit/upsell/upsell-dispatch-cutoff" and pass your product data. The demo above exercises the full prop surface with sample values.
