Checkout

Contact, delivery address, delivery choice and card details in one checked form, beside a summary of the order.

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 block:

npx ermineui add checkout
pnpm dlx ermineui add checkout
yarn dlx ermineui add checkout
bunx ermineui add checkout

That copies 3 files into src/components/blocks/checkout/, and the 12 components it is built from. Files you already have are kept.

Use

import { Checkout } from '@/components/blocks/checkout'
<Checkout :items="items" :shipping-methods="methods" :tax="tax" :loading="paying" :error="paymentError" @submit="placeOrder">
  <!-- For real payments: your provider's own card fields, so card numbers never touch your pages. -->
  <template #payment><div id="card-element" /></template>
</Checkout>

In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.

What gets copied

FileWhere it goes
Checkout.vuesrc/components/blocks/checkout/Checkout.vue
index.tssrc/components/blocks/checkout/index.ts
sample-data.tssrc/components/blocks/checkout/sample-data.ts

It is built from: Alert, Button, Card, Cart line item, Field, Input, Masked input, Option card, Order summary, Price, Radio group, Select. 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.

Checkout.vue 265 lines
<script setup lang="ts">
import { computed, reactive, useId, useSlots } from 'vue'
import { Alert, AlertDescription } from '../../ui/alert'
import { Button } from '../../ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '../../ui/card'
import { CartLineItem } from '../../ui/cart-line-item'
import { Field, email, minLength, pattern, required, useValidation } from '../../ui/field'
import { Input } from '../../ui/input'
import { MaskedInput, masks } from '../../ui/masked-input'
import { OptionCard } from '../../ui/option-card'
import { OrderSummary } from '../../ui/order-summary'
import { formatMoney } from '../../ui/price'
import { RadioGroup } from '../../ui/radio-group'
import { Select, SelectContent, SelectItem, SelectTrigger } from '../../ui/select'
import {
  sampleCheckoutItems,
  sampleCountries,
  sampleShippingMethods,
  type CheckoutItem,
  type CheckoutShippingMethod,
  type CheckoutValues,
} from './sample-data'

// Everything shown comes in through these props. The defaults are made-up data from sample-data.ts.
const props = withDefaults(
  defineProps<{
    items?: readonly CheckoutItem[]
    shippingMethods?: readonly CheckoutShippingMethod[]
    countries?: readonly string[]
    /** Money taken off, as a positive number. */
    discount?: number
    discountLabel?: string
    tax?: number
    currency?: string
    locale?: string
    /** Set while the order is being placed: the button shows a spinner and cannot be pressed twice. */
    loading?: boolean
    /** What went wrong on the server, e.g. "Your card was declined." */
    error?: string
    /** Heading level of the section titles. */
    titleAs?: string
  }>(),
  {
    items: () => sampleCheckoutItems,
    shippingMethods: () => sampleShippingMethods,
    countries: () => sampleCountries,
    currency: 'USD',
    locale: 'en-US',
    titleAs: 'h3',
  },
)

const emit = defineEmits<{
  /** The form passed its checks. Send these to your server. */
  submit: [values: CheckoutValues]
}>()

const slots = useSlots()
const id = useId()

const values = reactive({
  email: '',
  name: '',
  address: '',
  address2: '',
  city: '',
  region: '',
  postcode: '',
  country: '',
  shippingMethod: props.shippingMethods[0]?.id ?? '',
  cardName: '',
  cardNumber: '',
  cardExpiry: '',
  cardCvc: '',
})

/** With the #payment slot, the card is your payment provider's business and its fields are not checked here. */
const ownCard = computed(() => !slots.payment)

const form = useValidation(values, {
  email: [required('Enter your email address.'), email()],
  name: [required('Enter your full name.')],
  address: [required('Enter your address.')],
  city: [required('Enter your town or city.')],
  postcode: [required('Enter your postcode.')],
  country: [required('Choose your country.')],
  cardName: [(value) => ownCard.value && required('Enter the name on the card.')(value)],
  cardNumber: [(value) => ownCard.value && (required('Enter the card number.')(value) || minLength(15, 'That card number is too short.')(value))],
  // Kept as four digits, month then year: 0927.
  cardExpiry: [(value) => ownCard.value && (required('Enter the expiry date.')(value) || pattern(/^(0[1-9]|1[0-2])\d\d$/, 'Enter the expiry date as month and year, such as 09/27.')(value))],
  cardCvc: [(value) => ownCard.value && (required('Enter the security code.')(value) || minLength(3, 'The security code has 3 or 4 digits.')(value))],
})

const subtotal = computed(() => props.items.reduce((sum, item) => sum + item.price * item.quantity, 0))
const shipping = computed(() => props.shippingMethods.find((method) => method.id === values.shippingMethod)?.price ?? 0)
const total = computed(() => Math.max(0, subtotal.value - (props.discount ?? 0) + shipping.value + (props.tax ?? 0)))
const money = (amount: number) => formatMoney(amount, props.currency, props.locale)

function submit() {
  if (!form.validate()) return
  const { cardName, cardNumber, cardExpiry, cardCvc, ...rest } = values
  emit('submit', ownCard.value ? { ...rest, card: { name: cardName, number: cardNumber, expiry: cardExpiry, cvc: cardCvc } } : rest)
}
</script>

<template>
  <div class="er-checkout">
    <div class="er-checkout-layout">
      <!-- novalidate: the messages come from the rules above, so they look and read the same in every browser. -->
      <form class="er-checkout-form" novalidate @submit.prevent="submit">
        <Alert v-if="error" variant="destructive">
          <AlertDescription>{{ error }}</AlertDescription>
        </Alert>

        <section class="er-checkout-section">
          <component :is="titleAs" class="er-checkout-title">Contact</component>
          <Field v-slot="{ control }" label="Email" hint="The receipt and delivery updates go here." required :error="form.errors.email">
            <Input v-bind="control" v-model="values.email" type="email" name="email" autocomplete="email" @blur="form.touch('email')" />
          </Field>
        </section>

        <section class="er-checkout-section">
          <component :is="titleAs" class="er-checkout-title">Delivery address</component>
          <Field v-slot="{ control }" label="Full name" required :error="form.errors.name">
            <Input v-bind="control" v-model="values.name" name="name" autocomplete="name" />
          </Field>
          <Field v-slot="{ control }" label="Address" required :error="form.errors.address">
            <Input v-bind="control" v-model="values.address" name="address" autocomplete="address-line1" />
          </Field>
          <Field v-slot="{ control }" label="Flat, suite or floor (optional)">
            <Input v-bind="control" v-model="values.address2" name="address2" autocomplete="address-line2" />
          </Field>
          <div class="er-checkout-row">
            <Field v-slot="{ control }" label="Town or city" required :error="form.errors.city">
              <Input v-bind="control" v-model="values.city" name="city" autocomplete="address-level2" />
            </Field>
            <Field v-slot="{ control }" label="State or region">
              <Input v-bind="control" v-model="values.region" name="region" autocomplete="address-level1" />
            </Field>
          </div>
          <div class="er-checkout-row">
            <Field v-slot="{ control }" label="Postcode" required :error="form.errors.postcode">
              <Input v-bind="control" v-model="values.postcode" name="postcode" autocomplete="postal-code" />
            </Field>
            <Field v-slot="{ control }" label="Country" required :error="form.errors.country">
              <Select v-model="values.country" name="country">
                <SelectTrigger v-bind="control" placeholder="Choose a country" />
                <SelectContent>
                  <SelectItem v-for="country in countries" :key="country" :value="country">{{ country }}</SelectItem>
                </SelectContent>
              </Select>
            </Field>
          </div>
        </section>

        <section class="er-checkout-section">
          <component :is="titleAs" :id="`${id}-delivery`" class="er-checkout-title">Delivery</component>
          <RadioGroup v-model="values.shippingMethod" name="shipping-method" :aria-labelledby="`${id}-delivery`">
            <OptionCard v-for="method in shippingMethods" :key="method.id" :value="method.id" :title="method.title" :description="method.description">
              <template #end>{{ method.price === 0 ? 'Free' : money(method.price) }}</template>
            </OptionCard>
          </RadioGroup>
        </section>

        <section class="er-checkout-section">
          <component :is="titleAs" class="er-checkout-title">Payment</component>
          <!-- For real payments, fill this slot with your payment provider's own card fields (Stripe Elements, Adyen
               and the like), so card numbers never pass through your pages or your server. The fields below are
               for prototypes and for providers that take the details from you. -->
          <slot name="payment">
            <Field v-slot="{ control }" label="Name on card" required :error="form.errors.cardName">
              <Input v-bind="control" v-model="values.cardName" name="cc-name" autocomplete="cc-name" />
            </Field>
            <Field v-slot="{ control }" label="Card number" required :error="form.errors.cardNumber">
              <MaskedInput v-bind="control" v-model="values.cardNumber" :mask="masks.card" name="cc-number" autocomplete="cc-number" placeholder="1234 1234 1234 1234" />
            </Field>
            <div class="er-checkout-row">
              <Field v-slot="{ control }" label="Expiry date" required :error="form.errors.cardExpiry">
                <MaskedInput v-bind="control" v-model="values.cardExpiry" :mask="masks.expiry" name="cc-exp" autocomplete="cc-exp" placeholder="MM/YY" />
              </Field>
              <Field v-slot="{ control }" label="Security code" required :error="form.errors.cardCvc">
                <MaskedInput v-bind="control" v-model="values.cardCvc" :mask="masks.cvc" name="cc-csc" autocomplete="cc-csc" placeholder="CVC" />
              </Field>
            </div>
          </slot>
        </section>

        <Button type="submit" size="lg" :loading="loading">{{ loading ? 'Placing order…' : `Pay ${money(total)}` }}</Button>
      </form>

      <Card class="er-checkout-summary">
        <CardHeader><CardTitle>Order summary</CardTitle></CardHeader>
        <CardContent>
          <OrderSummary :subtotal="subtotal" :discount="discount" :discount-label="discountLabel" :shipping="shipping" :tax="tax" :currency="currency" :locale="locale">
            <ul class="er-checkout-items">
              <li v-for="item in items" :key="item.id">
                <CartLineItem :title="item.title" :image="item.image" :options="item.options" :price="item.price" :quantity="item.quantity" :currency="currency" :locale="locale" readonly />
              </li>
            </ul>
          </OrderSummary>
        </CardContent>
      </Card>
    </div>
  </div>
</template>

<style>
@layer components {
  .er-checkout {
    width: 100%;
    min-width: 0;
    /* Side by side once there is room for both; the block measures itself, not the screen. */
    container-type: inline-size;
  }

  .er-checkout-layout {
    display: grid;
    align-items: start;
    gap: 2rem;
  }

  .er-checkout-form {
    display: grid;
    gap: 2rem;
    min-width: 0;
  }

  .er-checkout-section {
    display: grid;
    gap: 1rem;
    min-width: 0;
  }

  .er-checkout-title {
    margin: 0;
    font-size: 1rem;
    font-weight: var(--er-weight-semibold);
    line-height: 1.5rem;
  }

  .er-checkout-row {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(100%, 9rem), 1fr));
    align-items: start;
    gap: 1rem;
  }

  .er-checkout-items {
    display: grid;
    gap: 1rem;
    margin: 0;
    padding: 0 0 1rem;
    border-bottom: 1px solid var(--er-border);
    list-style: none;
  }

  @container (min-width: 46rem) {
    .er-checkout-layout {
      grid-template-columns: minmax(0, 1fr) 21rem;
      gap: 2.5rem;
    }
  }
}
</style>
index.ts 4 lines
/** Checkout — contact, delivery address, delivery choice and card details in one checked form, beside a summary of the order. */
export { default as Checkout } from './Checkout.vue'
export * from './sample-data'
sample-data.ts 61 lines
/**
 * Made-up items and delivery choices so the checkout has something to show. Replace this file with
 * your own data, or pass your own through the props of Checkout.vue.
 */

export interface CheckoutItem {
  id: string
  title: string
  image?: string
  /** The variant that was chosen, e.g. "Sand / Large". */
  options?: string
  /** Price of one, in whole units of the currency. */
  price: number
  quantity: number
}

export interface CheckoutShippingMethod {
  id: string
  title: string
  /** How long it takes, or anything else worth knowing. */
  description?: string
  /** 0 is written as "Free". */
  price: number
}

/** What the form hands over once it has passed its checks. */
export interface CheckoutValues {
  email: string
  name: string
  address: string
  /** Flat, suite, floor. May be empty. */
  address2: string
  city: string
  region: string
  postcode: string
  country: string
  /** The id of the delivery choice. */
  shippingMethod: string
  /** Missing when the #payment slot is used: your payment provider then holds the card details. */
  card?: { name: string; number: string; expiry: string; cvc: string }
}

// Stand-in pictures drawn as SVG, so the block needs no image files and no network.
const picture = (background: string, shape: string) =>
  `data:image/svg+xml,${encodeURIComponent(
    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120"><rect width="120" height="120" fill="${background}"/><rect x="34" y="28" width="52" height="64" rx="10" fill="${shape}"/></svg>`,
  )}`

export const sampleCheckoutItems: CheckoutItem[] = [
  { id: 'mug-sand', title: 'Stoneware mug', options: 'Sand', price: 24, quantity: 2, image: picture('#efe7da', '#c9b79c') },
  { id: 'throw-moss', title: 'Wool throw', options: 'Moss / 130 × 180 cm', price: 96, quantity: 1, image: picture('#dde5d6', '#6f8a5b') },
]

export const sampleShippingMethods: CheckoutShippingMethod[] = [
  { id: 'standard', title: 'Standard', description: '3 to 5 working days', price: 0 },
  { id: 'express', title: 'Express', description: '1 to 2 working days', price: 12 },
  { id: 'pickup', title: 'Collect in store', description: 'Ready in about two hours', price: 0 },
]

export const sampleCountries = ['Australia', 'Canada', 'France', 'Germany', 'Japan', 'United Kingdom', 'United States']