# Promo Page

> A limited-time sale page: a drawn coupon hero with a countdown and copyable code, plan cards with live totals and a savings calculator.

- Type: React template (Templates)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add promo-page`
- Docs: https://wingo-ui.com/templates/promo-page

## Interaction

The hero rises in once on mount beside a drawn composition: a perforated coupon for 40% off with notches, a barcode stub and the code, in front of two gift tags with the Pro and Business deal prices and a starburst sticker. The countdown ticks from a fixed clock (so server and client agree) to the end of the sale, or to its start in the teaser phase. Claim the deal jumps to the plans; the dashed code chip copies ACME40 with a check and a screen reader announcement. A progress bar shows how many onboarding spots are claimed. Each plan card shows the deal price beside the crossed-out regular one; its seats stepper (buttons, arrows, typing) rolls the first-year total and the savings live. Claim spins for 900 ms (or while onClaim runs), then shows the code applied with the seats and total and a Continue to checkout link, or an error if onClaim rejects; Change seats goes back. In the savings calculator the team size slider and the Pro / Business switch roll the amount saved and the two price bars; Use these seats copies them into that plan's card and scrolls to it. Bonus tiles, quotes from last year's sale, plain-English terms (one open at a time) and the FAQ follow, then a closing band with its own countdown. Teaser swaps the offer for a validated notify-me form (an error under the field, then success) with the number blurred on the coupon; last call turns the pill, label and countdown red; ended stamps the coupon, shows regular prices and a waitlist form. On phones a bar pinned to the bottom (above the safe area) shows the lowest price and the time left once the hero scrolls away, with a Claim (or Notify me) button. Reduced motion keeps everything still.

## Installation

```bash
npx wingo-ui@latest add promo-page
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/promo-page` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/templates/promo-page.tsx`
- `components/templates/promo-page-data.ts`

npm dependencies: `npm i lucide-react motion`

Wingo UI dependencies (installed automatically):

- [Marketing Section](https://wingo-ui.com/components/marketing-section) ([Markdown](https://wingo-ui.com/components/marketing-section.md))
- [Marketing Header](https://wingo-ui.com/components/marketing-header) ([Markdown](https://wingo-ui.com/components/marketing-header.md))
- [Marketing Footer](https://wingo-ui.com/components/marketing-footer) ([Markdown](https://wingo-ui.com/components/marketing-footer.md))
- [Testimonials Section](https://wingo-ui.com/components/testimonials-section) ([Markdown](https://wingo-ui.com/components/testimonials-section.md))
- [FAQ Section](https://wingo-ui.com/components/faq-section) ([Markdown](https://wingo-ui.com/components/faq-section.md))
- [Accordion](https://wingo-ui.com/components/accordion) ([Markdown](https://wingo-ui.com/components/accordion.md))
- [Animated Counter](https://wingo-ui.com/components/animated-counter) ([Markdown](https://wingo-ui.com/components/animated-counter.md))
- [Badge](https://wingo-ui.com/components/badge) ([Markdown](https://wingo-ui.com/components/badge.md))
- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Copy Button](https://wingo-ui.com/components/copy-button) ([Markdown](https://wingo-ui.com/components/copy-button.md))
- [Countdown](https://wingo-ui.com/components/countdown) ([Markdown](https://wingo-ui.com/components/countdown.md))
- [Number Input](https://wingo-ui.com/components/number-input) ([Markdown](https://wingo-ui.com/components/number-input.md))
- [Progress](https://wingo-ui.com/components/progress) ([Markdown](https://wingo-ui.com/components/progress.md))
- [Segmented Control](https://wingo-ui.com/components/segmented-control) ([Markdown](https://wingo-ui.com/components/segmented-control.md))
- [Slider](https://wingo-ui.com/components/slider) ([Markdown](https://wingo-ui.com/components/slider.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Navigation](https://wingo-ui.com/components/navigation) ([Markdown](https://wingo-ui.com/components/navigation.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
// app/(marketing)/deals/page.tsx
"use client"

import Link from "next/link"
import { getPromoPhase, PromoPage } from "@/components/templates/promo-page"
import { PROMO_ENDS_AT, PROMO_STARTS_AT } from "@/components/templates/promo-page-data"

export default function DealsRoute() {
  return (
    <PromoPage
      linkComponent={Link}
      phase={getPromoPhase(PROMO_STARTS_AT, PROMO_ENDS_AT)}
      now={null}
      ctaTone="brand"
      onClaim={async (plan, seats) => {
        const response = await fetch("/api/checkout", { method: "POST", body: JSON.stringify({ plan: plan.id, seats, code: "ACME40" }) })
        if (!response.ok) throw new Error("Could not apply the deal")
      }}
      onNotify={async (email) => {
        await fetch("/api/sale-alerts", { method: "POST", body: JSON.stringify({ email }) })
      }}
    />
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `design?` | `"classic" \| "bold" \| "minimal"` | `"classic"` | The whole page's look. classic: a split hero with the coupon and gift tags over a sunburst, a dark closing band; bold: the all-black Black Friday page in both themes, a pill header, a giant title in the offer color under a night-blue glow, offer-colored countdown tiles, rounder cards, a quote wall, FAQ cards, an offer-colored closing band and a wordmark footer; minimal: a transparent header, a centered hero with the coupon alone, outlined flat cards, no bands, a ruled closing band and a simple footer. The *Props overrides still win. |
| `phase?` | `"teaser" \| "live" \| "last-call" \| "ended"` | `"live"` | Where the sale is. teaser: a countdown to the start, a notify-me form and locked plan cards; live: the offer with a countdown to the end; last-call: the same with red urgency in the pill, label, countdown and phone bar; ended: a stamped coupon, regular prices, a waitlist form and no calculator, bonuses or terms. getPromoPhase computes it from the dates. |
| `discount?` | `number` | `40` | Percent off. It flows into every label ({discount}), the coupon, the stickers and every price and total. |
| `code?` | `string` | `"ACME40"` | The promo code on the coupon, the copy chips, the checkout link and the labels ({code}). |
| `startsAt?` | `Date \| number \| string` | `"2026-11-27T08:00:00Z"` | When the sale starts (Nov 27 at 12:00 AM PT): the teaser countdown and {start} in the copy. |
| `endsAt?` | `Date \| number \| string` | `"2026-12-02T07:59:59Z"` | When the sale ends (Dec 1 at 11:59 PM PT): the countdowns and {end} in the copy. |
| `now?` | `Date \| number \| string \| null` | `"PROMO_SAMPLE_NOW[phase]"` | The clock the countdowns start from. The default is a fixed sample per phase (a week before, mid-sale, the final hours, the day after) so server and client render the same digits; null uses the visitor's real clock. |
| `tick?` | `boolean` | `true` | The countdowns run on from now after hydration; false freezes them (screenshots). |
| `timeZone?` | `string` | `"America/Los_Angeles"` | The IANA zone the dates in the copy are formatted in, so every visitor reads the same day. |
| `plans?` | `PromoPlan[]` | `"PROMO_PLANS"` | The offer cards: { id: "pro" \| "business", name, description, yearlyMonthly (regular price per user per month, billed yearly), monthly, featuresTitle?, features, seats (on load), badge?, featured? }. |
| `extras?` | `PromoExtra[]` | `"PROMO_EXTRAS"` | The bonus tiles: { id, icon?, tag, title, description, value }. |
| `testimonials?` | `Testimonial[]` | `"PROMO_TESTIMONIALS"` | Quotes from last year's sale: { quote, name, role?, company?, companyIcon?, metric? }. |
| `terms?` | `PromoTerm[]` | `"PROMO_TERMS"` | The plain-English terms: { id, title, content }; strings may hold {discount}, {code}, {start} and {end}. |
| `faq?` | `FaqItem[]` | `"PROMO_FAQ"` | { question, answer } pairs; string answers may hold the same placeholders. |
| `spots?` | `PromoSpots` | `"{ claimed: 2418, total: 5000 }"` | The onboarding spots progress in the hero. |
| `labels?` | `Partial<PromoPageLabels>` | `"DEFAULT_PROMO_PAGE_LABELS"` | Every visible string, with {discount}, {code}, {start}, {end} and {year} filled in (a few take more, like {plan} or {seats}). |
| `hrefs?` | `Partial<PromoPageHrefs>` | `"DEFAULT_PROMO_PAGE_HREFS"` | { checkout, pricing, sales, terms }. The checkout link gets ?plan=&seats=&code= appended. |
| `defaultSeats?` | `number` | `25` | The savings calculator's team size on load. |
| `defaultPlan?` | `"pro" \| "business"` | `"business"` | The savings calculator's plan on load. |
| `maxSeats?` | `number` | `200` | The most seats the steppers and the calculator's slider go to. |
| `showAnnouncement?` | `boolean` | `true` | The strip above the header bar. |
| `showCountdown?` | `boolean` | `true` | The countdowns in the hero and the closing band. |
| `showCode?` | `boolean` | `true` | The copyable code chips. |
| `showSpots?` | `boolean` | `true` | The onboarding spots progress in the hero (live and last call). |
| `showPlans?` | `boolean` | `true` | The plan cards. |
| `showCalculator?` | `boolean` | `true` | The savings calculator (hidden once the sale ended). |
| `showExtras?` | `boolean` | `true` | The bonus tiles (hidden once the sale ended). |
| `showTestimonials?` | `boolean` | `true` | The quotes from last year's sale. |
| `showTerms?` | `boolean` | `true` | The plain-English terms (hidden once the sale ended). |
| `showFaq?` | `boolean` | `true` | The FAQ. |
| `showCta?` | `boolean` | `true` | The closing band. |
| `showStickyBar?` | `boolean` | `true` | The phone bar pinned to the bottom once the hero scrolls away (below 768px, not when ended). |
| `alternate?` | `boolean` | `true` | Bands in the bandBackground look on every other visible section. Applies to the classic and bold designs. |
| `background?` | `"plain" \| "soft" \| "glow" \| "aurora" \| "grid" \| "dots"` | `"aurora"` | The main background: plain is the bare page color; the others tint the page and add a glow, an aurora, a grid or dots over the first screen. A design picks its own unless you set one. classic uses aurora, bold a glow with gradient bands, minimal plain; on a plain page the bold hero draws its own offer-colored spotlight. |
| `backgroundColor?` | `string` | `"oklch(0.62 0.14 275)"` | The tint and glow color of the main background. |
| `bandBackground?` | `"muted" \| "tinted" \| "gradient" \| "dots"` | `"muted"` | The secondary background of the alternating bands. Cards, fields and tiles inside a band are lifted one step above it, so they never share its color. |
| `bandColor?` | `string` | `"oklch(0.62 0.14 275)"` | The tint of tinted and gradient bands; defaults to backgroundColor. |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | Checks, the slider, the spots bar, badges and the offer color when offerColor is empty. |
| `ctaTone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | Claim buttons, the notify form, the phone bar and the header's Get started. |
| `offerColor?` | `string` | `"#FFD43B"` | The festive color of the coupon, the savings stickers, the deal bar and the bold design's title and closing band. Text on it turns black or white by lightness. Empty follows tone. |
| `header?` | `boolean` | `true` | The marketing header; off inside a site layout. |
| `footer?` | `boolean` | `true` | The marketing footer with its newsletter form. |
| `headerProps?` | `Partial<MarketingHeaderProps>` |  | Passed to MarketingHeader. |
| `footerProps?` | `Partial<MarketingFooterProps>` |  | Passed to MarketingFooter. |
| `testimonialsProps?` | `Partial<TestimonialsSectionProps>` |  | Overrides for the TestimonialsSection. |
| `faqProps?` | `Partial<FaqSectionProps>` |  | Overrides for the FaqSection. |
| `onClaim?` | `(plan: PromoPlan, seats: number) => (void \| Promise<unknown>)` |  | A plan's Claim button. A promise keeps it spinning, a rejection shows an error. Without it: 900 ms, then the code applied and a checkout link. |
| `onNotify?` | `(email: string) => (void \| Promise<unknown>)` |  | The notify-me (teaser) and waitlist (ended) forms; a rejection shows an error. Without it: 900 ms, then success. |
| `onCopyCode?` | `(code: string) => void` |  | A code chip copied the code. |
| `onSubscribe?` | `(email: string) => (void \| Promise<unknown>)` |  | The footer newsletter; a rejected promise shows an error. |
| `animateIn?` | `boolean` | `true` | The hero rises in once and the coupon settles into place; false keeps the page still (thumbnails). Reduced motion always does. |
| `locale?` | `string` | `"en-US"` | Intl locale for prices, totals, counters and dates. Empty follows UIProvider. |
| `currency?` | `string` | `"USD"` | ISO currency for every price; the plan amounts are taken as is. |
| `linkComponent?` | `(props: LinkComponentProps) => ReactNode` | `"<a>"` | The router link for every link on the page (next/link in an app). |
| `className?` | `string` |  | Classes for the root. |
| `classNames?` | `Partial<Record<PromoPageSlot, string>>` |  | Classes for header, hero, pill, title, art, code, status, countdown, spots, notify, plans, plan, calculator, extras, extra, testimonials, terms, faq, cta, sticky and footer. |

## Parts

### getPromoPhase (function)

The phase a sale is in at a moment: (startsAt, endsAt, now = Date.now(), lastCallHours = 24) => "teaser" | "live" | "last-call" | "ended". Pair it with now={null} for a page that follows the real clock.

### PROMO_PAGE_DESIGNS (constant)

The design names: classic, bold and minimal.

### DEFAULT_PROMO_PAGE_LABELS (constant)

The English strings with their placeholders, to spread under a translation.

### DEFAULT_PROMO_PAGE_HREFS (constant)

/checkout, /pricing, /contact and /legal/terms.

### PROMO_PAGE_SAMPLE (constant)

All the sample content in one object: discount, code, dates, time zone, sample clocks, spots, plans, extras, quotes, terms and FAQ.

### PROMO_PLANS (constant)

Pro ($10 per user per month billed yearly, 10 seats) and Business ($20, 25 seats, Best value).

### PROMO_EXTRAS (constant)

Acme AI at half price for three months, a free onboarding session and a white-glove migration.

### PROMO_TESTIMONIALS (constant)

Three quotes from last year's sale with a metric each.

### PROMO_TERMS (constant)

Who can claim, how long the price lasts, seats, refunds, stacking and the deadline.

### PROMO_FAQ (constant)

Five questions about the code, billing, current plans, seat limits and next year.

### PROMO_SAMPLE_NOW (constant)

The fixed sample clock per phase: Nov 20, Nov 29, the afternoon of Dec 1 and Dec 3, 2026.

### PROMO_SPOTS (constant)

{ claimed: 2418, total: 5000 }.

### PROMO_CODE (constant)

"ACME40".

### PROMO_DISCOUNT (constant)

40.

### PROMO_STARTS_AT (constant)

"2026-11-27T08:00:00Z", Nov 27 at 12:00 AM PT.

### PROMO_ENDS_AT (constant)

"2026-12-02T07:59:59Z", Dec 1 at 11:59 PM PT.

### PROMO_TIME_ZONE (constant)

"America/Los_Angeles".

### PromoPageProps (type)

Props of PromoPage.

### PromoPageDesign (type)

"classic" | "bold" | "minimal".

### PromoPagePhase (type)

"teaser" | "live" | "last-call" | "ended".

### PromoPlan (type)

One offer card.

### PromoPlanId (type)

"pro" | "business".

### PromoExtra (type)

One bonus tile.

### PromoTerm (type)

One plain-English term.

### PromoSpots (type)

{ claimed, total }.

### PromoPageLabels (type)

The strings labels can override.

### PromoPageHrefs (type)

The link targets hrefs can override.

### PromoPageSlot (type)

The classNames keys.

## Examples

- **Bold design**: design="bold": the all-black Black Friday page in both themes, a giant title in the offer color, offer-colored countdown tiles and closing band.
- **Minimal design**: design="minimal": a transparent header, a centered hero with the coupon alone, outlined cards, no gray bands and a simple footer.
- **Teaser before the sale**: phase="teaser": a countdown to Nov 27, a blurred number on the coupon, a validated notify-me form and locked plan cards.
- **Last call in brand orange**: phase="last-call" with ctaTone brand: red urgency on the pill, label and countdown, final hours left.
- **Sale ended**: phase="ended": a stamped coupon, regular prices with links to pricing and a waitlist for next year.
- **Cyber Monday at 30% off, checkout down**: discount 30 with code CYBER30 flows into every string and price; an onClaim that rejects shows the error in the card.

## See also

- [Pricing Page](https://wingo-ui.com/components/pricing-page) ([Markdown](https://wingo-ui.com/components/pricing-page.md))
- [Launch Page](https://wingo-ui.com/templates/launch-page) ([Markdown](https://wingo-ui.com/templates/launch-page.md))
- [Countdown](https://wingo-ui.com/components/countdown) ([Markdown](https://wingo-ui.com/components/countdown.md))
- [Copy Button](https://wingo-ui.com/components/copy-button) ([Markdown](https://wingo-ui.com/components/copy-button.md))
- [Landing Page](https://wingo-ui.com/templates/landing-page) ([Markdown](https://wingo-ui.com/templates/landing-page.md))

---

Canonical: https://wingo-ui.com/templates/promo-page
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
