Promo code

A field for a discount code with an Apply button; once a code is in use, it is shown with a button to take it off.

Install

If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.

npx ermineui init
pnpm dlx ermineui init
yarn dlx ermineui init
bunx ermineui init

Then add the component:

npx ermineui add promo-code
pnpm dlx ermineui add promo-code
yarn dlx ermineui add promo-code
bunx ermineui add promo-code

That copies 2 files into src/components/ui/promo-code/, and the 3 components it is built from. Files you already have are kept.

Use

import { PromoCode } from '@/components/ui/promo-code'
<script setup lang="ts">
import { PromoCode } from '@/components/ui/promo-code'
</script>

<template>
  <PromoCode />
</template>

In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island. The props are documented in the source below, on each one.

What gets copied

FileWhere it goes
PromoCode.vuesrc/components/ui/promo-code/PromoCode.vue
index.tssrc/components/ui/promo-code/index.ts

It is built from: Button, Field, Input. These are added with it.

The folders are the suggested ones. ermine.json says where yours are.

Source

This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.

PromoCode.vue 136 lines
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
import { Button } from '../button'
import { Field } from '../field'
import { Input } from '../input'

const props = withDefaults(
  defineProps<{
    /** The code in use. While set, it is shown with a button to take it off, in place of the field. */
    applied?: string
    /** Why the last code was turned down, e.g. "That code has expired." */
    error?: string
    /** Set while the code is being checked: the button shows a spinner and cannot be pressed twice. */
    loading?: boolean
    label?: string
    applyLabel?: string
  }>(),
  { label: 'Discount code', applyLabel: 'Apply' },
)

const emit = defineEmits<{
  /** A code was entered. Check it on your server, then set `applied` or `error`. */
  apply: [code: string]
  /** The code in use was taken off. */
  remove: []
}>()

const root = ref<HTMLElement | null>(null)
const code = ref('')

function submit() {
  const entered = code.value.trim()
  if (entered) emit('apply', entered)
}

// The field and the tag swap places, so focus would be lost with whichever one went: it follows to the other.
watch(
  () => props.applied,
  async (now, before) => {
    if (!now === !before || !root.value?.contains(document.activeElement)) return
    if (now) code.value = ''
    await nextTick()
    root.value?.querySelector<HTMLElement>(now ? '.er-promo-code-applied button' : 'input')?.focus()
  },
)
</script>

<template>
  <div ref="root" class="er-promo-code">
    <div v-if="applied" class="er-promo-code-applied">
      <span>
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 4h7l9 9-7 7-9-9ZM8 8v.01" /></svg>
        <span class="er-promo-code-hidden">{{ label }} in use: </span>{{ applied }}
      </span>
      <button type="button" @click="emit('remove')">Remove<span class="er-promo-code-hidden"> {{ label.toLowerCase() }} {{ applied }}</span></button>
    </div>
    <!-- novalidate: an empty field simply does nothing, and what is wrong with a code only your server can say. -->
    <form v-else novalidate @submit.prevent="submit">
      <Field v-slot="{ control }" :label="label" :error="error">
        <div class="er-promo-code-row">
          <Input v-bind="control" v-model="code" name="code" autocomplete="off" autocapitalize="characters" spellcheck="false" />
          <Button type="submit" variant="outline" :loading="loading">{{ applyLabel }}</Button>
        </div>
      </Field>
    </form>
  </div>
</template>

<style>
@layer components {
  .er-promo-code {
    min-width: 0;
  }

  .er-promo-code-row {
    display: flex;
    gap: 0.5rem;
  }

  .er-promo-code-applied {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.75rem;
    font-size: 0.875rem;
  }

  .er-promo-code-applied > span {
    display: inline-flex;
    align-items: center;
    gap: 0.375rem;
    padding: 0.125rem 0.5rem;
    border-radius: var(--er-radius-sm);
    background: var(--er-secondary);
    color: var(--er-secondary-foreground);
    font-weight: var(--er-weight-medium);
  }

  .er-promo-code-applied svg {
    width: 0.875rem;
    height: 0.875rem;
  }

  .er-promo-code-applied button {
    padding: 0;
    border: 0;
    border-radius: var(--er-radius-sm);
    background: transparent;
    color: var(--er-muted-foreground);
    font: inherit;
    font-size: 0.8125rem;
    text-decoration: underline;
    text-underline-offset: 0.2em;
    cursor: pointer;
  }

  .er-promo-code-applied button:hover {
    color: var(--er-foreground);
  }

  .er-promo-code-applied button:focus-visible {
    outline: 2px solid var(--er-ring);
    outline-offset: 2px;
  }

  .er-promo-code-hidden {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
}
</style>
index.ts 3 lines
/** Promo code — a field for a discount code with an Apply button; once a code is in use, it is shown with a button to take it off. */
export { default as PromoCode } from './PromoCode.vue'