---
name: promo-code
title: Promo Code
url: https://ui.frontic.com/components/sections/promo-code.md
tier: sections
install: npx @frontic/ui add promo-code
files:
  - components/sections/promo-code/PromoCode.vue
  - components/sections/promo-code/PromoCodeField.vue
  - components/sections/promo-code/index.ts
  - components/sections/promo-code/types.ts
registryDependencies:
  - alert
  - badge
  - card
  - cart
  - field
  - input-group
  - price
  - separator
dependencies:
  - "@lucide/vue"
---

# Promo Code

Voucher card with a code field and apply button, removable chips for applied promo codes and gift cards, a confirmation notice, and the order total after deductions.

## Install

```sh
npx @frontic/ui add promo-code
```

Built from: [Alert](https://ui.frontic.com/components/ui/alert.md), [Badge](https://ui.frontic.com/components/ui/badge.md), [Card](https://ui.frontic.com/components/ui/card.md), [Cart](https://ui.frontic.com/lib/cart.md), [Field](https://ui.frontic.com/components/ui/field.md), [Input Group](https://ui.frontic.com/components/ui/input-group.md), [Price](https://ui.frontic.com/components/ui/price.md), [Separator](https://ui.frontic.com/components/ui/separator.md)

Used by: [Checkout Page](https://ui.frontic.com/components/templates/checkout-page.md)

npm packages: `@lucide/vue`

## Examples

```vue
<script setup lang="ts">
import { computed, ref } from 'vue'
import { PromoCode, type AppliedDiscount } from '@/components/sections/promo-code'

// Blizzard Defender, €126.00
const subtotal = { amount: 12600, currency: 'EUR' }
const known: Record<string, AppliedDiscount> = {
  WELCOME10: { id: 'WELCOME10', kind: 'promo', label: 'WELCOME10', amount: Math.round(subtotal.amount / 10) },
  'GC-5530-1194': { id: 'GC-5530-1194', kind: 'gift-card', label: 'Gift card ••• 4821', lineLabel: 'Gift card', amount: 5000 },
}

const code = ref('GC-5530-1194')
const applied = ref<AppliedDiscount[]>([known.WELCOME10!, known['GC-5530-1194']!])
const notice = computed(() =>
  applied.value.some((entry) => entry.id === 'WELCOME10') ? { title: 'WELCOME10 applied', description: '10% off your first order, thanks for subscribing to our newsletter.' } : undefined,
)

function apply(value: string) {
  const entry = known[value.toUpperCase()]
  if (entry && !applied.value.some((a) => a.id === entry.id)) applied.value = [...applied.value, entry]
}

function remove(entry: AppliedDiscount) {
  applied.value = applied.value.filter((a) => a.id !== entry.id)
}
</script>

<template>
  <div class="mx-auto w-full max-w-md">
    <PromoCode
      v-model="code"
      :subtotal="subtotal"
      :applied="applied"
      :notice="notice"
      description="Have a voucher or gift card? Apply it before you pay."
      hint="One promo code per order. Gift cards can be combined."
      @apply="apply"
      @remove="remove"
    />
  </div>
</template>
```

## Anatomy

```vue
<PromoCode>
  <PromoCodeField />
</PromoCode>
```

## API reference

### PromoCode

Lets the shopper apply promo codes and shows the discounts they take off the order.

Styling: [`hint`](https://ui.frontic.com/DESIGN.md#hint); [`heading`](https://ui.frontic.com/DESIGN.md#heading). Change them there and every part that extends them follows.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `subtotal` (required) | `PriceType` |  | The order before codes: `amount` in minor units, with its currency and precision. |
| `applied` | `AppliedDiscount[]` | `[]` | The codes and gift cards on the order, each shown as a removable chip and a summary row. |
| `notice` | `PromoNotice` |  | A success message under the code field, e.g. after a code was applied. |
| `title` | `string` | `'Promo code'` | Shown as the card's title. |
| `description` | `string` |  | Shown under the title. |
| `codeLabel` | `string` | `'Code'` | Shown above the code field. |
| `placeholder` | `string` | `'Enter a code'` | Shown in the empty code field. |
| `applyLabel` | `string` | `'Apply'` | Shown on the button that applies the entered code. |
| `hint` | `string` |  | Shown under the code field, e.g. which codes can be combined. |
| `removeLabel` | `((label: string) => string)`: `(label: string): string` | ``Remove ${label}`` | Screen-reader label of the button that removes a code from the order. |
| `subtotalLabel` | `string` | `'Subtotal'` | Shown beside the order amount before codes. |
| `totalLabel` | `string` | `'To pay'` | Shown beside the amount left to pay after all codes. |
| `minusSign` | `string` | `MINUS_SIGN` | Put before each amount a code takes off. |
| `locale` | `string` |  | BCP 47 locale the amounts are formatted in, e.g. `de-DE`; the ConfigProvider's when left out. |
| `surface` | `'raised' \| 'bare'` | `'raised'` | Bare drops the edge, shadow and inset, for a section placed inside another Card. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |
| `modelValue` | `string` | `''` | The code typed into the field. |

| Event | Payload | Description |
| --- | --- | --- |
| `update:modelValue` | `value: string` | Fires with the new value when `modelValue` changes; `v-model` binds it. |
| `remove` | `code: AppliedDiscount` | Fires with the applied code when the shopper removes it. |
| `apply` | `code: string` | Fires with the trimmed code when the shopper applies a non-empty promo code. |

| Data attribute | Values | Description |
| --- | --- | --- |
| `data-surface` | `'raised' \| 'bare'` | Carries the `surface` prop. |

### PromoCodeField

The promo code field with its hint, the applied codes as removable chips and an optional notice; PromoCode's body, for placing inside another summary.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `applied` | `AppliedDiscount[]` | `[]` | The codes and gift cards on the order, each shown as a removable chip. |
| `notice` | `PromoNotice` |  | A success message under the chips, e.g. after a code was applied. |
| `codeLabel` | `string` | `'Code'` | Shown above the code field. |
| `placeholder` | `string` | `'Enter a code'` | Shown in the empty code field. |
| `applyLabel` | `string` | `'Apply'` | Shown on the button that applies the entered code. |
| `hint` | `string` |  | Shown under the code field, e.g. which codes can be combined. |
| `removeLabel` | `((label: string) => string)`: `(label: string): string` | ``Remove ${label}`` | Screen-reader label of the button that removes a code from the order. |
| `modelValue` | `string` | `''` | The code typed into the field. |

| Event | Payload | Description |
| --- | --- | --- |
| `update:modelValue` | `value: string` | Fires with the new value when `modelValue` changes; `v-model` binds it. |
| `remove` | `code: AppliedDiscount` | Fires with the applied code when the shopper removes it. |
| `apply` | `code: string` | Fires with the trimmed code when the shopper applies a non-empty promo code. |

## Exports

```ts
import { type AppliedDiscount, type PromoNotice } from '@/components/sections/promo-code'
```

- `AppliedDiscount`
- `PromoNotice`: below

```ts
interface PromoNotice {
  title: string
  description?: string
}
```

## Styling

| Part | Recipe |
| --- | --- |
| `hint` | [`hint`](https://ui.frontic.com/DESIGN.md#hint) |
| `heading` | [`heading`](https://ui.frontic.com/DESIGN.md#heading) |

## Source

`components/sections/promo-code/PromoCode.vue`

```vue
<script lang="ts">
/** Lets the shopper apply promo codes and shows the discounts they take off the order. */
export default {}
</script>

<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { PriceType } from '@library/components/ui/price'
import type { AppliedDiscount, PromoNotice } from './types'
import { discounted, MINUS_SIGN } from '@library/lib/cart'
import { computed } from 'vue'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@library/components/ui/card'
import { Price } from '@library/components/ui/price'
import { Separator } from '@library/components/ui/separator'
import PromoCodeField from './PromoCodeField.vue'

const props = withDefaults(
  defineProps<{
    /** The order before codes: `amount` in minor units, with its currency and precision. */
    subtotal: PriceType
    /** The codes and gift cards on the order, each shown as a removable chip and a summary row. */
    applied?: AppliedDiscount[]
    /** A success message under the code field, e.g. after a code was applied. */
    notice?: PromoNotice
    /** Shown as the card's title. */
    title?: string
    /** Shown under the title. */
    description?: string
    /** Shown above the code field. */
    codeLabel?: string
    /** Shown in the empty code field. */
    placeholder?: string
    /** Shown on the button that applies the entered code. */
    applyLabel?: string
    /** Shown under the code field, e.g. which codes can be combined. */
    hint?: string
    /** Screen-reader label of the button that removes a code from the order. */
    removeLabel?: (label: string) => string
    /** Shown beside the order amount before codes. */
    subtotalLabel?: string
    /** Shown beside the amount left to pay after all codes. */
    totalLabel?: string
    /** Put before each amount a code takes off. */
    minusSign?: string
    /** BCP 47 locale the amounts are formatted in, e.g. `de-DE`; the ConfigProvider's when left out. */
    locale?: string
    /** Bare drops the edge, shadow and inset, for a section placed inside another Card. */
    surface?: 'raised' | 'bare'
    class?: HTMLAttributes['class']
  }>(),
  {
    surface: 'raised',
    applied: () => [],
    title: 'Promo code',
    codeLabel: 'Code',
    placeholder: 'Enter a code',
    applyLabel: 'Apply',
    removeLabel: (label: string) => `Remove ${label}`,
    subtotalLabel: 'Subtotal',
    totalLabel: 'To pay',
    minusSign: MINUS_SIGN,
  },
)
const emit = defineEmits<{
  /** Fires with the trimmed code when the shopper applies a non-empty promo code. */
  apply: [code: string]
  /** Fires with the applied code when the shopper removes it. */
  remove: [code: AppliedDiscount]
}>()
/** The code typed into the field. */
const code = defineModel<string>({ default: '' })

const money = (amount: number): PriceType => ({ ...props.subtotal, amount })
const total = computed(() => discounted(props.subtotal, props.applied))
</script>

<template>
  <Card :surface="surface" :class="props.class">
    <CardHeader>
      <CardTitle>{{ title }}</CardTitle>
      <CardDescription v-if="description">{{ description }}</CardDescription>
    </CardHeader>
    <CardContent>
      <PromoCodeField
        v-model="code"
        :applied="applied"
        :notice="notice"
        :code-label="codeLabel"
        :placeholder="placeholder"
        :apply-label="applyLabel"
        :hint="hint"
        :remove-label="removeLabel"
        @apply="emit('apply', $event)"
        @remove="emit('remove', $event)"
      />
    </CardContent>
    <CardFooter class="flex-col items-stretch gap-3">
      <Separator />
      <div class="flex flex-col gap-2">
        <div class="flex items-center justify-between">
          <span data-slot="hint" class="fui-hint">{{ subtotalLabel }}</span>
          <Price align="end" :price="subtotal" size="sm" :locale="locale" />
        </div>
        <div v-for="entry in applied" :key="entry.id" class="flex items-center justify-between">
          <span data-slot="hint" class="fui-hint">{{ entry.lineLabel ?? entry.label }}</span>
          <span class="flex items-center">{{ minusSign }}<Price align="end" :price="money(entry.amount)" size="sm" :locale="locale" /></span>
        </div>
      </div>
      <Separator />
      <div class="flex items-center justify-between">
        <span data-slot="heading" class="fui-heading">{{ totalLabel }}</span>
        <Price align="end" :price="total" size="lg" :locale="locale" />
      </div>
    </CardFooter>
  </Card>
</template>
```

`components/sections/promo-code/PromoCodeField.vue`

```vue
<script lang="ts">
/** The promo code field with its hint, the applied codes as removable chips and an optional notice; PromoCode's body, for placing inside another summary. */
export default {}
</script>

<script setup lang="ts">
import { TicketPercentIcon, TagIcon, GiftIcon, XIcon } from '@lucide/vue'

import type { AppliedDiscount, PromoNotice } from './types'
import { useId } from 'vue'
import { Alert, AlertDescription, AlertTitle } from '@library/components/ui/alert'
import { Badge } from '@library/components/ui/badge'
import { Field, FieldDescription, FieldGroup, FieldLabel } from '@library/components/ui/field'
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@library/components/ui/input-group'

withDefaults(
  defineProps<{
    /** The codes and gift cards on the order, each shown as a removable chip. */
    applied?: AppliedDiscount[]
    /** A success message under the chips, e.g. after a code was applied. */
    notice?: PromoNotice
    /** Shown above the code field. */
    codeLabel?: string
    /** Shown in the empty code field. */
    placeholder?: string
    /** Shown on the button that applies the entered code. */
    applyLabel?: string
    /** Shown under the code field, e.g. which codes can be combined. */
    hint?: string
    /** Screen-reader label of the button that removes a code from the order. */
    removeLabel?: (label: string) => string
  }>(),
  {
    applied: () => [],
    codeLabel: 'Code',
    placeholder: 'Enter a code',
    applyLabel: 'Apply',
    removeLabel: (label: string) => `Remove ${label}`,
  },
)
const emit = defineEmits<{
  /** Fires with the trimmed code when the shopper applies a non-empty promo code. */
  apply: [code: string]
  /** Fires with the applied code when the shopper removes it. */
  remove: [code: AppliedDiscount]
}>()
/** The code typed into the field. */
const code = defineModel<string>({ default: '' })

const id = useId()

function apply() {
  const value = code.value.trim()
  if (value) emit('apply', value)
}
</script>

<template>
  <FieldGroup>
    <Field>
      <FieldLabel :for="id">{{ codeLabel }}</FieldLabel>
      <InputGroup>
        <InputGroupAddon>
          <TicketPercentIcon data-icon />
        </InputGroupAddon>
        <InputGroupInput :id="id" v-model="code" :placeholder="placeholder" @keydown.enter.prevent="apply" />
        <InputGroupAddon align="inline-end">
          <InputGroupButton variant="outline" color="neutral" @click="apply">{{ applyLabel }}</InputGroupButton>
        </InputGroupAddon>
      </InputGroup>
      <FieldDescription v-if="hint">{{ hint }}</FieldDescription>
    </Field>
    <div v-if="applied.length || notice" class="flex flex-col gap-3">
      <TransitionGroup v-if="applied.length" tag="div" class="flex flex-wrap items-center gap-2" leave-active-class="transition-opacity duration-150" leave-to-class="opacity-0">
        <Badge v-for="entry in applied" :key="entry.id" :variant="entry.kind === 'promo' ? 'promo' : 'primary'">
          <TagIcon v-if="entry.kind === 'promo'" data-icon />
          <GiftIcon v-else data-icon />
          {{ entry.label }}
          <button type="button" :aria-label="removeLabel(entry.lineLabel ?? entry.label)" class="flex items-center" @click="emit('remove', entry)">
            <XIcon data-icon />
          </button>
        </Badge>
      </TransitionGroup>
      <Alert v-if="notice" variant="success">
        <AlertTitle>{{ notice.title }}</AlertTitle>
        <AlertDescription v-if="notice.description">{{ notice.description }}</AlertDescription>
      </Alert>
    </div>
  </FieldGroup>
</template>
```

`components/sections/promo-code/types.ts`

```ts
export type { AppliedDiscount } from '@library/lib/cart'

export interface PromoNotice {
  title: string
  description?: string
}
```
