Promo
A sale, a launch or a free-shipping offer, set as a band across the store.
Timed sale with a countdown
promo-01A department's sale: what it takes off, the last day in words, a countdown to the close of that day, the way in, and three bestsellers at their sale prices over the prices they replace.
import * as React from "react"
import Link from "next/link"
import { ArrowRightIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import { Countdown } from "@/components/ui/countdown"
import { Price } from "@/components/ui/price"
import { ProductArt } from "@/components/ui/product-art"
import { SALE, onSale, saleEnds, saleNow } from "./promo-01.data"
// The store's day, not the reader's.
const LAST_DAY = new Intl.DateTimeFormat("en-US", { weekday: "long", month: "long", day: "numeric", timeZone: "UTC" })
/**
* A timed sale: the department and what it takes off, the last day in
* words, a countdown to the close of that day, the way in, and three of the
* department's bestsellers at their sale prices over the prices they replace.
* On a phone the three sit in a row under the copy.
*
* A server component: the terms, the last day, the sample world's now and
* the products come from `./promo-01.data`. The countdown is the kit's,
* counting from that now, so the sale is never over before the page opens.
*/
export function Promo01() {
const titleId = React.useId()
const endsId = React.useId()
const products = onSale(3)
const lastDay = LAST_DAY.format(new Date(saleEnds().getTime() - 1))
const off = Math.round(SALE.off * 100)
const department = SALE.department.toLowerCase()
return (
<section
data-section="promo-01"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto grid max-w-7xl gap-10 rounded-2xl bg-surface p-5 ring-1 ring-border sm:p-8 lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)] lg:items-center lg:gap-12 lg:p-12">
<div className="flex flex-col items-start gap-5">
<p id={endsId} className="type-eyebrow">
Ends {lastDay}
</p>
<h2 id={titleId} className="type-display text-3xl text-balance md:text-4xl">
{off}% off everything in {SALE.department}
</h2>
<p className="max-w-md text-sm text-pretty text-muted-foreground md:text-base">
The whole {department} range is marked down until the close of {lastDay.split(",")[0]}. No code needed: the prices below are the sale prices.
</p>
{/* Its ticks are silent and its name says what is left; the last day describes it. */}
<Countdown
to={saleEnds()}
now={saleNow()}
format="units"
aria-describedby={endsId}
className="[&_[data-slot=countdown-units]]:bg-card"
valueClassName="md:text-3xl"
/>
<Link href={`/sale?department=${encodeURIComponent(SALE.department)}`} className={cn(buttonVariants({ size: "lg" }), "group/sale h-10 px-5")}>
Shop the sale
<ArrowRightIcon
aria-hidden="true"
data-icon="inline-end"
className="transition-transform duration-(--duration-fast) ease-(--ease-standard) group-hover/sale:translate-x-0.5"
/>
</Link>
</div>
<ul aria-label={`On sale in ${SALE.department}`} className="grid grid-cols-3 gap-2 sm:gap-4">
{products.map((product) => (
<li key={product.id} className="relative flex flex-col gap-2 panel p-2 has-[a:focus-visible]:focus-outline sm:p-3">
<ProductArt name={product.name} category={product.category} className="rounded-lg" />
<h3 className="text-xs font-semibold text-foreground sm:text-sm">
<Link href={product.href} className="outline-none after:absolute after:inset-0">
{product.name}
</Link>
</h3>
<Price amount={product.saleCents / 100} compareAt={product.listCents / 100} size="sm" className="mt-auto" />
</li>
))}
</ul>
</div>
</section>
)
}
export default Promo01/**
* What `promo-01` reads: a timed sale on one department. Its terms — which
* department, how much off — are the promotion's own, set here; when it ends
* is counted from `REFERENCE_DATE` (the close of the Sunday after it), and
* the countdown counts from that same now, so the band's last day and its
* countdown agree with the sample world. The
* products it shows are the department's bestsellers in stock, off the
* storefront's shelf, each at a sale price worked out from its list price.
*
* Selectors only, reading the store inside functions and run on the server.
* The island takes plain props; it never imports this module.
*/
import { storeShelf, type ShelfProduct } from "@/lib/dashboards/ecommerce/storefront"
import { REFERENCE_DATE } from "@/lib/sample-data"
/** The sale's terms: the department it covers and the share it takes off. */
export const SALE = { department: "Outdoor", off: 0.2 } as const
const DAY_MS = 86_400_000
/** When the sale ends: midnight UTC at the close of the first Sunday after `REFERENCE_DATE`. */
export function saleEnds(): Date {
const midnight = Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth(), REFERENCE_DATE.getUTCDate())
const toSunday = (7 - REFERENCE_DATE.getUTCDay()) % 7 || 7
return new Date(midnight + (toSunday + 1) * DAY_MS)
}
/** The sample world's now, which the countdown counts from so the sale is never over before the page opens. */
export function saleNow(): Date {
return REFERENCE_DATE
}
/** A list price less the sale's share, rounded down to a whole dollar so the saving is never less than promised; a .99 price keeps its ending. */
export function salePriceCents(listCents: number, off: number = SALE.off): number {
const dollars = Math.floor((listCents * (1 - off)) / 100) * 100
return listCents % 100 === 99 ? dollars - 1 : dollars
}
/** A product on sale as the band shows it. */
export type SaleRow = Pick<ShelfProduct, "id" | "name" | "category" | "href"> & { listCents: number; saleCents: number }
/** The department's `count` bestsellers in stock, at their sale prices. */
export function onSale(count = 3): SaleRow[] {
return storeShelf()
.filter((product) => product.category === SALE.department && product.stock > 0)
.slice(0, count)
.map(({ id, name, category, href, priceCents }) => ({ id, name, category, href, listCents: priceCents, saleCents: salePriceCents(priceCents) }))
}npx shadcn@latest add @vibra/promo-01Bundle offer
promo-02Three things that go together, side by side with a plus between them, each with its score and price and a box to keep it in, and the price of the whole beside them, less than the parts.
import * as React from "react"
import { formatNumber } from "@/lib/format"
import { ProductArt } from "@/components/ui/product-art"
import { Rating } from "@/components/ui/rating"
import { BundleOffer } from "./promo-02.client"
import { BUNDLE, bundleParts, bundlePriceCents } from "./promo-02.data"
/** A mean as a score: one decimal, always. */
const score = (value: number) => formatNumber(value, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
/**
* A bundle: the things a first night outside needs, side by side with a plus
* between them, each with its score and price and a box to keep it in, and
* the price of the whole beside them — less than the parts, by how much said
* in words. On a phone the parts stack, the price under them.
*
* A server component: the terms, the parts and the bundle's price come from
* `./promo-02.data`; every picture and score is drawn here.
*/
export function Promo02() {
const titleId = React.useId()
const parts = bundleParts().map((part) => ({
...part,
art: <ProductArt name={part.name} category={part.category} className="rounded-lg" />,
score: part.ratings ? (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Rating value={Math.round(part.rating)} size="sm" aria-hidden="true" />
<span aria-hidden="true" className="tabular-nums">
{score(part.rating)} ({part.ratings})
</span>
<span className="sr-only">
Rated {score(part.rating)} out of 5 from {part.ratings} reviews
</span>
</div>
) : null,
}))
const off = Math.round(BUNDLE.off * 100)
return (
<section
data-section="promo-02"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="flex max-w-xl flex-col gap-2">
<p className="type-eyebrow">Bundle</p>
<h2 id={titleId} className="type-display text-2xl text-balance md:text-3xl">
{BUNDLE.name}
</h2>
<p className="text-sm text-pretty text-muted-foreground md:text-base">
Everything a first night outside needs, for {off}% less than buying the pieces one by one.
</p>
</div>
<BundleOffer parts={parts} bundleCents={bundlePriceCents()} />
</div>
</section>
)
}
export default Promo02"use client"
import * as React from "react"
import Link from "next/link"
import { CircleCheckIcon, PlusIcon, ShoppingBagIcon } from "lucide-react"
import { money } from "@/lib/dashboards/ecommerce/checkout"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
import { Price } from "@/components/ui/price"
import type { BundlePart } from "./promo-02.data"
export type BundleCard = BundlePart & { art: React.ReactNode; score: React.ReactNode }
/**
* The bundle: its parts side by side, each ticked in to start with, and what
* the whole costs beside them. With every part in, the bundle price stands
* over the parts' sum and says the saving; take one out and the price becomes
* the plain sum of what is left, with a line on what putting it back would
* save. The button adds what is ticked and says so in the status line. It
* sends nothing: wire the button to your cart.
*/
export function BundleOffer({ parts, bundleCents }: { parts: BundleCard[]; bundleCents: number }) {
const base = React.useId()
const [included, setIncluded] = React.useState<ReadonlySet<string>>(() => new Set(parts.map((part) => part.id)))
const [added, setAdded] = React.useState("")
const chosen = parts.filter((part) => included.has(part.id))
const sum = parts.reduce((total, part) => total + part.priceCents, 0)
const whole = chosen.length === parts.length
const price = whole ? bundleCents : chosen.reduce((total, part) => total + part.priceCents, 0)
const missing = parts.filter((part) => !included.has(part.id))
function toggle(id: string, on: boolean) {
setAdded("")
setIncluded((current) => {
const next = new Set(current)
if (on) next.add(id)
else next.delete(id)
return next
})
}
return (
<div className="mt-8 grid gap-6 md:mt-10 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-center lg:gap-10">
<ul aria-label="In the bundle" className="grid gap-3 sm:grid-cols-3 sm:gap-8">
{parts.map((part, index) => (
<li
key={part.id}
data-out={included.has(part.id) ? undefined : ""}
className="group/part relative flex items-center gap-3 panel p-3 data-[out]:bg-surface sm:flex-col sm:items-stretch sm:p-4"
>
{index > 0 ? (
<span aria-hidden="true" className="absolute top-1/2 -left-7 hidden size-6 -translate-y-1/2 items-center justify-center rounded-full bg-background text-muted-foreground ring-1 ring-border sm:flex">
<PlusIcon className="size-3.5" />
</span>
) : null}
{/* A part taken out keeps its words at full contrast; only its picture steps back. */}
<div className="w-20 shrink-0 transition-opacity duration-(--duration-fast) group-data-[out]/part:opacity-40 sm:w-full">{part.art}</div>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<Link href={part.href} className="w-fit rounded-sm text-sm font-semibold text-foreground focus-ring hover:underline">
{part.name}
</Link>
{part.score}
<span className="text-sm tabular-nums">{money(part.priceCents)}</span>
<div className="mt-1 flex items-center gap-2">
<Checkbox id={`${base}${part.id}`} checked={included.has(part.id)} onCheckedChange={(on) => toggle(part.id, on)} />
<Label htmlFor={`${base}${part.id}`} className="text-xs font-normal text-muted-foreground">
In the bundle<span className="sr-only">: {part.name}</span>
</Label>
</div>
</div>
</li>
))}
</ul>
<div className="flex flex-col gap-4 frame p-5">
<p className="text-sm text-muted-foreground">{whole ? `All ${parts.length} together` : chosen.length === 0 ? "Nothing ticked" : `${chosen.length} of ${parts.length}, bought separately`}</p>
<Price amount={price / 100} compareAt={whole ? sum / 100 : undefined} size="lg" />
<p className="text-sm text-pretty text-foreground">
{whole
? `You save ${money(sum - bundleCents)} on buying them one by one.`
: chosen.length === 0
? "Tick what you want in the bundle."
: `Put the ${missing.map((part) => part.name).join(" and the ")} back to buy the whole bundle and save ${money(sum - bundleCents)}.`}
</p>
<Button
size="lg"
className="h-10"
disabled={chosen.length === 0}
onClick={() =>
setAdded(
whole
? `Added the bundle to your cart: ${parts.length} items for ${money(bundleCents)}.`
: `Added ${chosen.length === 1 ? "1 item" : `${chosen.length} items`} to your cart for ${money(price)}.`
)
}
>
<ShoppingBagIcon data-icon="inline-start" aria-hidden="true" />
{whole ? "Add the bundle" : chosen.length === 0 ? "Add to cart" : chosen.length === 1 ? "Add 1 item" : `Add ${chosen.length} items`}
</Button>
<p role="status" aria-live="polite" className="text-sm text-foreground">
{added ? (
<span className="inline-flex items-start gap-2">
<CircleCheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-success" />
{added}
</span>
) : null}
</p>
</div>
</div>
)
}/**
* What `promo-02` reads: a bundle — the things a first night outside needs,
* sold together for less. Which things, and how much less, are the
* promotion's terms, set here: each part is the bestselling product in stock
* whose name says what it is, off the storefront's shelf, so the bundle
* follows the shelf. The price of the whole is worked out from the parts'
* list prices, never typed in.
*
* Selectors only, reading the store inside functions and run on the server.
* The island takes plain props; it never imports this module.
*/
import { storeShelf, type ShelfProduct } from "@/lib/dashboards/ecommerce/storefront"
/** The bundle's terms: what it is called, what goes in it (a pattern over a product's name per part) and the share off the whole. */
export const BUNDLE = {
name: "A first night outside",
parts: [/\btent\b/i, /sleeping bag/i, /lantern/i],
off: 0.15,
} as const
/** A part of the bundle as the band shows it. */
export type BundlePart = Pick<ShelfProduct, "id" | "name" | "category" | "priceCents" | "rating" | "ratings" | "href">
/** Each part's product: the bestseller in stock that the part's pattern names. */
export function bundleParts(): BundlePart[] {
const shelf = storeShelf().filter((product) => product.stock > 0)
return BUNDLE.parts.flatMap((part) => {
const product = shelf.find((row) => part.test(row.name))
if (!product) return []
const { id, name, category, priceCents, rating, ratings, href } = product
return [{ id, name, category, priceCents, rating, ratings, href }]
})
}
/** What the whole bundle costs: the parts' sum less the share off, rounded down to a whole dollar. */
export function bundlePriceCents(parts: readonly Pick<BundlePart, "priceCents">[] = bundleParts()): number {
const sum = parts.reduce((total, part) => total + part.priceCents, 0)
return Math.floor((sum * (1 - BUNDLE.off)) / 100) * 100
}npx shadcn@latest add @vibra/promo-02Free-delivery progress
promo-03How far the cart is from free delivery, as a heading and a meter, beside three products that would each carry it over the line, added in place; the heading says it ships free once it does.
import * as React from "react"
import { ProductArt } from "@/components/ui/product-art"
import { FreeDeliveryNudge } from "./promo-03.client"
import { deliveryGap } from "./promo-03.data"
/**
* Free delivery, measured: how far the shopper's cart is from it, as a
* heading and a meter, beside three products that would each carry it over
* the line, added in place. The heading turns to "Your order ships free" once
* it does. On a phone the three stack under the meter.
*
* A server component: the cart's goods, the line and the three products come
* from `./promo-03.data`; every picture is drawn here, and the island holds
* what was added.
*/
export function Promo03() {
const titleId = React.useId()
const { goodsCents, freeFromCents, units, fillers } = deliveryGap(3)
return (
<section
data-section="promo-03"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<FreeDeliveryNudge
titleId={titleId}
goodsCents={goodsCents}
freeFromCents={freeFromCents}
units={units}
fillers={fillers.map((filler) => ({ ...filler, art: <ProductArt name={filler.name} category={filler.category} className="rounded-lg" /> }))}
cartHref="/cart"
/>
</div>
</section>
)
}
export default Promo03"use client"
import * as React from "react"
import Link from "next/link"
import { PlusIcon, TruckIcon, XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { itemCount, money } from "@/lib/dashboards/ecommerce/checkout"
import { formatCurrency } from "@/lib/format"
import { Button, buttonVariants } from "@/components/ui/button"
import { Progress } from "@/components/ui/progress"
import type { Filler } from "./promo-03.data"
export type FillerCard = Filler & { art: React.ReactNode }
/** A round amount without its cents: "$100", but "$74.99". */
const amount = (cents: number) => (cents % 100 === 0 ? formatCurrency(cents / 100, "USD", { maximumFractionDigits: 0 }) : money(cents))
/**
* Free delivery, measured: the heading says how far the cart is from it, a
* meter shows it, and three products that would each close the gap on their
* own can be added in place. Adding one moves the meter; once the goods cross
* the line the heading says the order ships free, and the status line says
* what each press did. A product added can be taken back out with the same
* button. It sends nothing: wire the button to your cart.
*/
export function FreeDeliveryNudge({
titleId,
goodsCents,
freeFromCents,
units,
fillers,
cartHref,
}: {
titleId: string
goodsCents: number
freeFromCents: number
units: number
fillers: FillerCard[]
cartHref: string
}) {
const [added, setAdded] = React.useState<ReadonlySet<string>>(new Set())
const [notice, setNotice] = React.useState("")
const extra = fillers.filter((filler) => added.has(filler.id))
const goods = goodsCents + extra.reduce((sum, filler) => sum + filler.priceCents, 0)
const free = goods >= freeFromCents
const meterId = React.useId()
function toggle(filler: FillerCard) {
const next = new Set(added)
const adding = !next.has(filler.id)
if (adding) next.add(filler.id)
else next.delete(filler.id)
const after = goodsCents + fillers.filter((entry) => next.has(entry.id)).reduce((sum, entry) => sum + entry.priceCents, 0)
setAdded(next)
setNotice(
adding
? `Added the ${filler.name}. ${after >= freeFromCents ? "Your order now ships free." : `${money(freeFromCents - after)} to go.`}`
: `Took the ${filler.name} back out. ${after >= freeFromCents ? "Your order still ships free." : `${money(freeFromCents - after)} to go for free delivery.`}`
)
}
return (
<div className="grid gap-8 lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)] lg:items-center lg:gap-12">
<div className="flex flex-col items-start gap-4">
<p className="type-eyebrow flex items-center gap-2">
<TruckIcon aria-hidden="true" className="size-4" />
Free delivery from {amount(freeFromCents)}
</p>
<h2 id={titleId} className="type-display text-2xl text-balance md:text-3xl">
{free ? "Your order ships free" : `You are ${money(freeFromCents - goods)} from free delivery`}
</h2>
<div className="flex w-full max-w-md flex-col gap-2">
<Progress
value={Math.min(100, (goods / freeFromCents) * 100)}
aria-labelledby={meterId}
aria-valuetext={`${money(goods)} of ${amount(freeFromCents)}`}
/>
<p id={meterId} className="text-sm text-muted-foreground tabular-nums">
{money(goods)} of {amount(freeFromCents)} in your cart, {itemCount(units + extra.length)}
</p>
</div>
<p className="max-w-md text-sm text-pretty text-muted-foreground">
{free ? "Anything else you add ships free too." : "Any one of these closes the gap on its own."}
</p>
<Link href={cartHref} className={buttonVariants({ variant: "outline" })}>
View cart
</Link>
</div>
<ul aria-label="Close the gap with one of these" className="grid gap-3 sm:grid-cols-3">
{fillers.map((filler) => {
const inCart = added.has(filler.id)
return (
<li key={filler.id} className={cn("flex items-center gap-3 panel p-3 sm:flex-col sm:items-stretch", inCart && "bg-brand-muted")}>
<div className="w-16 shrink-0 sm:w-full">{filler.art}</div>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<Link href={filler.href} className="w-fit rounded-sm text-sm font-semibold text-foreground focus-ring hover:underline">
{filler.name}
</Link>
<span className="text-sm tabular-nums">{money(filler.priceCents)}</span>
</div>
<Button variant={inCart ? "outline" : "default"} size="sm" className="shrink-0" onClick={() => toggle(filler)}>
{inCart ? <XIcon data-icon="inline-start" aria-hidden="true" /> : <PlusIcon data-icon="inline-start" aria-hidden="true" />}
{inCart ? "Remove" : "Add"} <span className="sr-only">the {filler.name}</span>
</Button>
</li>
)
})}
</ul>
<p role="status" aria-live="polite" className="sr-only">
{notice}
</p>
</div>
)
}/**
* What `promo-03` reads: how far the shopper's cart (`shopperCart`, from
* cart-ecommerce) is from free delivery by the checkout terms, and the
* products that would close the gap on their own — the cheapest in stock that
* cost at least the gap and are not in the cart yet, the better seller first
* where two cost the same. Every figure comes from the cart, the shelf and
* the terms; none is typed in.
*
* Selectors only, reading the store inside functions and run on the server.
* The island takes plain props; it never imports this module.
*/
import { shopperCart } from "@/lib/dashboards/ecommerce/cart"
import { FREE_DELIVERY_FROM_CENTS, cartTotals } from "@/lib/dashboards/ecommerce/checkout"
import { storeShelf, type ShelfProduct } from "@/lib/dashboards/ecommerce/storefront"
/** A product that closes the gap on its own. */
export type Filler = Pick<ShelfProduct, "id" | "name" | "category" | "priceCents" | "href">
/** The cart's goods, the line they are measured against, and `count` products that would each carry them over it. */
export function deliveryGap(count = 3) {
const lines = shopperCart()
const goodsCents = cartTotals(lines).goodsCents
const gap = Math.max(0, FREE_DELIVERY_FROM_CENTS - goodsCents)
const inCart = new Set(lines.map((line) => line.productId))
const fillers: Filler[] = storeShelf()
.filter((product) => product.stock > 0 && !inCart.has(product.id) && product.priceCents >= gap)
.sort((a, b) => a.priceCents - b.priceCents)
.slice(0, count)
.map(({ id, name, category, priceCents, href }) => ({ id, name, category, priceCents, href }))
return { goodsCents, freeFromCents: FREE_DELIVERY_FROM_CENTS, units: lines.reduce((sum, line) => sum + line.qty, 0), fillers }
}npx shadcn@latest add @vibra/promo-03Seasonal edit
promo-04The season's edit as an editorial grid: its name and a line on it, the way to the whole edit, and its five bestsellers, the first drawn large, each with its department, score and price.
import * as React from "react"
import Link from "next/link"
import { ArrowRightIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatNumber } from "@/lib/format"
import { buttonVariants } from "@/components/ui/button"
import { Price } from "@/components/ui/price"
import { ProductArt } from "@/components/ui/product-art"
import { seasonalEdit } from "./promo-04.data"
/** A mean as a score: one decimal, always. */
const score = (value: number) => formatNumber(value, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
/**
* The season's edit: what the season is called and a line on it, the way to
* the whole edit, and its five bestsellers as an editorial grid — the first
* large, the rest around it — each its picture, name, department, score and
* price, one stretched link. On a phone the first runs full width and the
* rest go two by two.
*
* A server component: the season, its copy and the products come from
* `./promo-04.data`; the season follows `REFERENCE_DATE`.
*/
export function Promo04() {
const titleId = React.useId()
const { season, year, title, lede, products } = seasonalEdit(5)
return (
<section
data-section="promo-04"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="flex flex-wrap items-end justify-between gap-x-6 gap-y-4">
<div className="flex max-w-xl flex-col gap-2">
<p className="type-eyebrow capitalize">
{season} {year}
</p>
<h2 id={titleId} className="type-display text-2xl md:text-3xl">
{title}
</h2>
<p className="text-sm text-pretty text-muted-foreground md:text-base">{lede}</p>
</div>
<Link href={`/edit/${season}`} className={cn(buttonVariants({ variant: "outline" }), "group/edit")}>
Shop the edit
<ArrowRightIcon
aria-hidden="true"
data-icon="inline-end"
className="transition-transform duration-(--duration-fast) ease-(--ease-standard) group-hover/edit:translate-x-0.5"
/>
</Link>
</div>
<ul className="mt-8 grid grid-cols-2 gap-3 md:mt-10 md:gap-4 lg:grid-cols-4 lg:grid-rows-2">
{products.map((product, index) => (
<li
key={product.id}
className={cn(
"relative flex flex-col gap-3 panel p-3 transition-shadow duration-(--duration-base) ease-(--ease-standard) hover:shadow-(--shadow-float) has-[a:focus-visible]:focus-outline",
index === 0 && "col-span-2 lg:row-span-2 lg:p-5"
)}
>
<ProductArt name={product.name} category={product.category} className={cn("rounded-lg", index === 0 && "lg:flex-1")} />
<div className="flex flex-col gap-1">
<p className="text-xs text-muted-foreground">
{product.category}
{product.ratings ? (
<>
{" · "}
<span className="tabular-nums">{score(product.rating)} from {product.ratings} reviews</span>
</>
) : null}
</p>
<h3 className={cn("font-semibold text-foreground", index === 0 ? "text-base md:text-lg" : "text-sm")}>
<Link href={product.href} className="outline-none after:absolute after:inset-0">
{product.name}
</Link>
</h3>
<Price amount={product.priceCents / 100} size={index === 0 ? "default" : "sm"} />
</div>
</li>
))}
</ul>
</div>
</section>
)
}
export default Promo04/**
* What `promo-04` reads: the season's edit. Which season it is comes from
* `REFERENCE_DATE` (the northern year: September to November is autumn), and
* each season names what belongs in its edit by the words in a product's name
* — the shop's own merchandising, set here. The edit is the bestsellers in
* stock that the season's words name, off the storefront's shelf, with the
* score their buyers gave them.
*
* Selectors only, reading the store inside functions and run on the server.
*/
import { storeShelf, type ShelfProduct } from "@/lib/dashboards/ecommerce/storefront"
import { REFERENCE_DATE } from "@/lib/sample-data"
export type Season = "winter" | "spring" | "summer" | "autumn"
/** Each season's edit: what it is called, the line under it, and the words that put a product in it. */
export const SEASONS: Record<Season, { title: string; lede: string; words: RegExp }> = {
winter: { title: "The winter edit", lede: "Wool, warm drinks and a blanket for the long evenings.", words: /sweater|beanie|blanket|socks|kettle|mug|fleece/i },
spring: { title: "The spring edit", lede: "Light layers and the kit for the first days outside.", words: /rain|jacket|tote|bottle|shoes|backpack/i },
summer: { title: "The summer edit", lede: "Long days outdoors, cold drinks and nights under canvas.", words: /tent|hammock|cooler|grill|bottle|lantern/i },
autumn: { title: "The autumn edit", lede: "Layers for cooler mornings and the things that make evenings in feel good.", words: /sweater|jacket|vest|beanie|socks|blanket|kettle|mug|lamp/i },
}
/** The season `date` falls in, in the northern year. */
export function seasonOf(date: Date = REFERENCE_DATE): Season {
const month = date.getUTCMonth()
if (month === 11 || month <= 1) return "winter"
if (month <= 4) return "spring"
return month <= 7 ? "summer" : "autumn"
}
/** A product in the edit as the band shows it. */
export type EditRow = Pick<ShelfProduct, "id" | "name" | "category" | "priceCents" | "rating" | "ratings" | "href">
/** The season and its year, its words, and the `count` bestsellers in stock its words name. */
export function seasonalEdit(count = 5) {
const season = seasonOf()
const { title, lede, words } = SEASONS[season]
const products: EditRow[] = storeShelf()
.filter((product) => product.stock > 0 && words.test(product.name))
.slice(0, count)
.map(({ id, name, category, priceCents, rating, ratings, href }) => ({ id, name, category, priceCents, rating, ratings, href }))
return { season, year: REFERENCE_DATE.getUTCFullYear(), title, lede, products }
}npx shadcn@latest add @vibra/promo-04