HextaUI

Discount Builder

A discount builder where the offer is one editable sentence, with a live checkout preview. Handles code checks, drafts and schedules.

Open in new tab

Discount Builder is the page a merchant uses to create or edit a discount. A new discount opens with a short type dialog: amount off products, amount off order, buy X get Y or free shipping. After that the offer is a sentence the merchant edits in place: “Customers get [15%] off [Ring Vase and Glass Carafe]”. Each underlined part is a real control with a label, so screen readers hear “Percentage off, 15” rather than a sentence of buttons.

Everything optional sits in a Rules card as collapsed rows: Minimum purchase, Customers, Usage limits, Combines with and Schedule. Each row shows its current value, so a merchant can read the whole setup without opening anything, and each panel explains its setting with an example. Schedules start now by default and offer presets for 7 days, 30 days and the end of the month, in the store’s time zone.

The rail is a preview, not a list of settings. It shows the offer as one sentence and a sample checkout built from your products, with the discount applied and what the customer saves. A Qualifies / Doesn’t toggle swaps to a cart just under the minimum and shows the same message a storefront would, such as “Add $10.00 more to use SUMMER15”. Risky settings, like 100% off with no limit, show up as warnings under it.

Data goes in, async callbacks come out. onSave gets a cleaned Discount and the intent (draft or live); return field errors to show them on the sentence or the rule rows, or throw to show the message in the save bar. Errors appear when a field is left, never while typing. New discounts save from the header (Save draft and Create discount). Existing ones use the shared IslandBar, which appears only when there are unsaved changes.

  1. Obtén HextaUI Pro

    Un solo pago desbloquea todos los bloques, incluidos los futuros. Ver precios.

  2. Añade el registro Pro a components.json

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  3. Añade tu token

    Crea un token en tu página de cuenta y colócalo en .env.local como HEXTAUI_PRO_TOKEN.

  4. Añade el bloque

    pnpm dlx shadcn@latest add @hextaui-pro/discount-builder

Shopify Admin API

Map the Discount to discountCodeBasicCreate or discountAutomaticBxgyCreate and return Shopify’s userErrors as field errors. Drafts go to your own table, since Shopify has no draft discounts. The type comes from the route, so the type dialog is off.

"use client"

import type { Product } from "../variant-picker/product"
import type { Discount, DiscountErrors, DiscountField } from "@/components/blocks/discount-builder/discount"
import { DiscountBuilder } from "@/components/blocks/discount-builder/discount-builder"

const basicCreate = `
  mutation discountCodeBasicCreate($basicCodeDiscount: DiscountCodeBasicInput!) {
    discountCodeBasicCreate(basicCodeDiscount: $basicCodeDiscount) {
      codeDiscountNode { id }
      userErrors { field message }
    }
  }
`

const bxgyCreate = `
  mutation discountAutomaticBxgyCreate($automaticBxgyDiscount: DiscountAutomaticBxgyInput!) {
    discountAutomaticBxgyCreate(automaticBxgyDiscount: $automaticBxgyDiscount) {
      automaticDiscountNode { id }
      userErrors { field message }
    }
  }
`

type UserError = { field: string[] | null; message: string }

function items(type: "products" | "collections", ids: string[]) {
  return type === "products" ? { products: { productsToAdd: ids } } : { collections: { add: ids } }
}

function basicInput(discount: Discount) {
  return {
    title: discount.code,
    code: discount.code,
    startsAt: discount.startsAt,
    endsAt: discount.endsAt,
    usageLimit: discount.usage.limitTotal ? discount.usage.total : null,
    appliesOncePerCustomer: discount.usage.oncePerCustomer,
    context: discount.eligibility.type === "segments" ? { customerSegments: { add: discount.eligibility.ids } } : discount.eligibility.type === "customers" ? { customers: { add: discount.eligibility.ids } } : { all: "ALL" },
    combinesWith: {
      productDiscounts: discount.combinesWith.product,
      orderDiscounts: discount.combinesWith.order,
      shippingDiscounts: discount.combinesWith.shipping,
    },
    minimumRequirement:
      discount.minimum.type === "amount"
        ? { subtotal: { greaterThanOrEqualToSubtotal: discount.minimum.value } }
        : discount.minimum.type === "quantity"
          ? { quantity: { greaterThanOrEqualToQuantity: String(discount.minimum.value) } }
          : null,
    customerGets: {
      value:
        discount.valueType === "percentage"
          ? { percentage: (discount.value ?? 0) / 100 }
          : { discountAmount: { amount: discount.value, appliesOnEachItem: discount.type === "amount_off_products" } },
      items:
        discount.type === "amount_off_order" || discount.appliesTo.type === "all"
          ? { all: true }
          : items(discount.appliesTo.type, discount.appliesTo.ids),
    },
  }
}

function bxgyInput(discount: Discount) {
  return {
    title: discount.title,
    startsAt: discount.startsAt,
    endsAt: discount.endsAt,
    usesPerOrderLimit: discount.gets.limitPerOrder ? String(discount.gets.maxUsesPerOrder) : null,
    customerBuys: {
      value:
        discount.buys.type === "quantity"
          ? { quantity: String(discount.buys.value) }
          : { amount: String(discount.buys.value) },
      items: items(discount.buys.items.type, discount.buys.items.ids),
    },
    customerGets: {
      value: {
        discountOnQuantity: {
          quantity: String(discount.gets.quantity),
          effect: { percentage: discount.gets.effect === "free" ? 1 : (discount.gets.percentage ?? 0) / 100 },
        },
      },
      items: items(discount.gets.items.type, discount.gets.items.ids),
    },
  }
}

const fieldMap: Record<string, DiscountField> = {
  code: "code",
  title: "title",
  endsAt: "endsAt",
  startsAt: "startsAt",
  usageLimit: "usageLimit",
}

async function saveToShopify(discount: Discount): Promise<DiscountErrors | void> {
  const bxgy = discount.type === "buy_x_get_y" && discount.method === "automatic"
  const response = await fetch("/api/shopify/graphql", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(
      bxgy
        ? { query: bxgyCreate, variables: { automaticBxgyDiscount: bxgyInput(discount) } }
        : { query: basicCreate, variables: { basicCodeDiscount: basicInput(discount) } }
    ),
  })
  if (!response.ok) throw new Error("Shopify didn’t respond.")
  const { data } = await response.json()
  const result = bxgy ? data.discountAutomaticBxgyCreate : data.discountCodeBasicCreate
  const errors: DiscountErrors = {}
  for (const error of result.userErrors as UserError[]) {
    const field = fieldMap[error.field?.at(-1) ?? ""]
    if (field) errors[field] = error.message
    else throw new Error(error.message)
  }
  return errors
}

async function saveDraft(discount: Discount): Promise<DiscountErrors | void> {
  const response = await fetch("/api/discount-drafts", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(discount),
  })
  if (!response.ok) throw new Error("The draft wasn’t saved.")
}

export function NewShopifyDiscount({ discount, products }: { discount: Discount; products: Product[] }) {
  return (
    <DiscountBuilder
      discount={discount}
      products={products}
      timeZone="America/New_York"
      showTypePicker={false}
      existingDiscounts={[{ code: "FREESHIP", type: "free_shipping" }]}
      onSave={(next, { intent }) => (intent === "draft" ? saveDraft(next) : saveToShopify(next))}
      onCheckCode={async (code, { signal }) => {
        const response = await fetch(`/api/discounts/check?code=${encodeURIComponent(code)}`, { signal })
        const { available } = await response.json()
        return available
      }}
    />
  )
}

Stripe coupons and promotion codes

A live discount becomes a Stripe coupon plus a promotion code with its redemption limit, first-order rule and expiry. Stripe’s duplicate-code error comes back as a code field error.

"use server"

import { toMinorUnits, money } from "../price/money"
import type { Discount, DiscountErrors } from "@/components/blocks/discount-builder/discount"

type StripeError = { code?: string; message: string }

const stripeKey = process.env.STRIPE_SECRET_KEY ?? ""

async function stripe(path: string, body: Record<string, string>) {
  const response = await fetch(`https://api.stripe.com/v1/${path}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${stripeKey}`, "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams(body),
  })
  const json = await response.json()
  if (!response.ok) throw json.error as StripeError
  return json
}

export async function saveDiscount(discount: Discount): Promise<DiscountErrors | void> {
  if (discount.status === "draft") {
    await fetch(`${process.env.APP_URL}/api/discount-drafts`, { method: "POST", body: JSON.stringify(discount) })
    return
  }
  const coupon = await stripe("coupons", {
    name: discount.code,
    duration: "once",
    ...(discount.valueType === "percentage"
      ? { percent_off: String(discount.value) }
      : {
          amount_off: String(toMinorUnits(money(discount.value ?? 0, discount.currencyCode))),
          currency: discount.currencyCode.toLowerCase(),
        }),
    ...(discount.appliesTo.type === "products"
      ? Object.fromEntries(discount.appliesTo.ids.map((id, index) => [`applies_to[products][${index}]`, id]))
      : {}),
  })

  try {
    await stripe("promotion_codes", {
      coupon: coupon.id,
      code: discount.code,
      ...(discount.usage.limitTotal && discount.usage.total ? { max_redemptions: String(discount.usage.total) } : {}),
      ...(discount.endsAt ? { expires_at: String(Math.floor(Date.parse(discount.endsAt) / 1000)) } : {}),
      ...(discount.eligibility.type === "segments" && discount.eligibility.ids.includes("first-time")
        ? { "restrictions[first_time_transaction]": "true" }
        : {}),
      ...(discount.minimum.type === "amount" && discount.minimum.value
        ? {
            "restrictions[minimum_amount]": String(toMinorUnits(money(discount.minimum.value, discount.currencyCode))),
            "restrictions[minimum_amount_currency]": discount.currencyCode.toLowerCase(),
          }
        : {}),
    })
  } catch (error) {
    const { message } = error as StripeError
    if (/already exists/i.test(message)) return { code: `${discount.code} is already a promotion code in Stripe` }
    throw new Error(message)
  }
}

Medusa promotions

Medusa v2 promotions take the same shape and support draft status directly. onCheckCode lists promotions by code to check that a new one is free.

"use client"

import type { Product } from "../variant-picker/product"
import type { Discount } from "@/components/blocks/discount-builder/discount"
import { DiscountBuilder } from "@/components/blocks/discount-builder/discount-builder"

const admin = "/admin/promotions"

function toPromotion(discount: Discount) {
  const target =
    discount.type === "amount_off_order" ? "order" : discount.type === "free_shipping" ? "shipping_methods" : "items"
  return {
    code: discount.code,
    type: discount.type === "buy_x_get_y" ? "buyget" : "standard",
    is_automatic: discount.method === "automatic",
    status: discount.status === "draft" ? "draft" : "active",
    application_method: {
      type: discount.type === "free_shipping" ? "percentage" : discount.valueType === "percentage" ? "percentage" : "fixed",
      value: discount.type === "free_shipping" ? 100 : discount.value,
      currency_code: discount.currencyCode.toLowerCase(),
      target_type: target,
      allocation: discount.type === "amount_off_products" ? "each" : "across",
      target_rules:
        discount.appliesTo.type === "products"
          ? [{ attribute: "items.product.id", operator: "in", values: discount.appliesTo.ids }]
          : [],
    },
    campaign: {
      name: discount.code || discount.title,
      campaign_identifier: discount.code || discount.title,
      starts_at: discount.startsAt,
      ends_at: discount.endsAt,
      budget: discount.usage.limitTotal ? { type: "usage", limit: discount.usage.total } : undefined,
    },
  }
}

export function MedusaDiscount({ discount, products }: { discount: Discount; products: Product[] }) {
  return (
    <DiscountBuilder
      discount={discount}
      products={products}
      onCheckCode={async (code, { signal }) => {
        const response = await fetch(`${admin}?code=${encodeURIComponent(code)}&limit=1`, { signal, credentials: "include" })
        const { count } = await response.json()
        return { available: count === 0, message: `${code} is already a promotion` }
      }}
      onSave={async (next) => {
        const response = await fetch(discount.id ? `${admin}/${discount.id}` : admin, {
          method: "POST",
          credentials: "include",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify(toPromotion(next)),
        })
        if (response.ok) return
        const { message } = await response.json()
        if (/code/i.test(message)) return { code: message }
        throw new Error(message)
      }}
    />
  )
}

Anatomía

Las partes que compones, de fuera hacia dentro.

ParteDescripción
DiscountBuilderThe whole page: pinned header with the title, status badge and actions, the offer and rules cards, the preview rail (a collapsible strip under the header on phones) and the save bar.
TypeChooserThe “Create a discount” dialog with four types and number-key shortcuts. A bottom sheet on phones.
DiscountPreviewThe summary sentence, status line, sample checkout with a qualifies toggle, warnings and, for saved discounts, performance.
DiscountStatusBadgeDraft, Scheduled, Active, Ends in 2 days, Expired or Limit reached.
applyDiscount, sampleCartPure helpers that apply a discount to a cart and build a cart that qualifies, or just misses.
describeDiscount, discountWarnings, qualifyMessagePlain-English clauses, risk warnings and the storefront message for a cart that doesn’t qualify.
validateDiscount, suggestCode, generateCode, cleanCode, nextCodeSpecific field errors (drafts skip missing values), code suggestions such as SAVE15 and readable random codes.
schedulePresets, getDiscountStatus, fromZonedDateTime, toZonedDateTimeEnd-date presets in the store’s time zone, status at a moment in time and time zone conversion.

DiscountBuilder

Also takes div props. Fills its parent’s height; only the content scrolls.

PropTipoPredeterminado
discountThe saved discount, or createDiscount() for a new one. New discounts start now as drafts. The form edits a draft of it and adopts new values when clean.
Discount–
onSaveCalled with a cleaned discount. A live save resolves “now” to the current time. Return field errors to show them in place, or throw to show the message in the save bar.
(discount: Discount, { intent: "draft" | "live" }) => Promise<DiscountErrors | void>–
onCheckCodeChecks that a code is free, 400 ms after typing stops. A taken code offers the next free variant, such as WELCOME11.
(code: string, { signal }) => Promise<boolean | { available: boolean; message?: string }>–
productsProducts for the pickers and the sample checkout, in the shared catalog shape.
Product[]–
collectionsCollections with an optional count, image and productIds. productIds lets the preview build carts for collection discounts.
DiscountCollection[][]
segmentsCustomer segments with an optional customer count.
DiscountSegment[][]
customersCustomers with a name and email.
DiscountCustomer[][]
statsShown as Performance for saved discounts only.
{ uses: number; sales: Money; orders?: number; lastUsedAt?: string }–
existingDiscountsOther live discounts, used to word the Combines with examples (“e.g. FREESHIP can apply on top”).
{ code?: string; title?: string; type: DiscountType }[]–
showTypePickerShow the type dialog for new discounts. Turn it off when the type comes from the route.
booleantrue
defaultTypePickerOpenOpen the type dialog when a new discount mounts.
booleantrue
sampleProductIdsProducts to prefer in the sample checkout.
string[]–
sampleShippingShipping rate in the sample checkout.
Money | null$12.00
timeZoneThe store’s time zone for dates, presets and the schedule note.
string"UTC"
localeFormats money, dates and counts.
string"en-US"
nowFixes the clock, for tests and screenshots.
Date | number–
breadcrumbShown above the title, such as a link back to Discounts.
ReactNode–
onDuplicate, onDeactivateAdds Duplicate and End now to the overflow menu of saved discounts.
(discount: Discount) => void–
onChange, onDiscardDraft changes and discards.
(discount: Discount) => void, () => void–
generateCodeReplaces the built-in random code (WORD-XXXX).
() => string–
getProductImageReturns a thumbnail URL for pickers and the checkout.
(image: ProductImage) => string–
actionsRefImperative actions, such as nudging the save bar when someone tries to leave with unsaved changes.
Ref<{ save, discard, nudge, isDirty, openTypePicker }>–
labelsButton and save bar text.
Partial<DiscountBuilderLabels>–

DiscountPreview

The rail on its own, for a list’s side panel.

PropTipoPredeterminado
discountThe discount to preview.
Discount–
productsUsed to build the sample cart.
Product[]–
statusFrom getDiscountStatus.
DiscountStatusInfo | null–
onClauseCalled when a sentence clause is clicked, to jump to its control.
(id) => void–

applyDiscount

applyDiscount(discount, cart, { collections }) returns line discounts, subtotal, discount and shipping totals, total, qualifies and a reason when it doesn’t.

PropTipoPredeterminado

sampleCart

sampleCart(discount, products, { qualifies, collections, preferredIds, shipping }) returns a cart that crosses the minimum or just misses it, or null when every cart qualifies.

PropTipoPredeterminado
KeyAcción
⌘SCreates or saves the discount. Ctrl+S on Windows and Linux. Works while typing.
⌘⇧SSaves a new discount as a draft. Ctrl+Shift+S on Windows and Linux.
⌘GGenerates a new code and focuses the code field. Ctrl+G on Windows and Linux.
1234In the type dialog, picks a type.
↑↓Moves between types in the dialog and between rule rows.
TabIn an empty code field, accepts the suggested code, such as SAVE15.
EscCloses the open dialog, picker or menu, or collapses the rule row you’re in.
  • Every sentence token is a real input or button with a full name, such as “Percentage off” or “Applies to, Ring Vase and Glass Carafe”. Errors are linked with aria-describedby and marked aria-invalid.
  • Errors show when a field is left, not while typing, and clear on the keystroke that fixes them. A failed save moves focus to the first invalid control and opens its rule row.
  • Rule rows are buttons with aria-expanded and aria-controls. Their value text is real text, so the collapsed card reads as a summary.
  • Code availability, generated codes and save results are announced in polite live regions. The save bar’s errors use role=alert through IslandBar.
  • The checkout result line is a status region, and color is never the only signal: savings and failures have icons and words.
  • Inputs are 16px on touch screens, touch targets are at least 44px and all motion has a reduced-motion variant.

Construido con

Los componentes gratuitos de HextaUI con los que está hecho Discount Builder. Cada uno se instala por separado.

Código

13 archivos, añadidos a components/blocks/discount-builder.