Order summary

Subtotal, discount, shipping and tax added up to a total, with room for the items and a checkout button.

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 order-summary
pnpm dlx ermineui add order-summary
yarn dlx ermineui add order-summary
bunx ermineui add order-summary

That copies 2 files into src/components/ui/order-summary/, and the 1 component it is built from. Files you already have are kept.

Use

import { OrderSummary } from '@/components/ui/order-summary'
<RadioGroup v-model="delivery" aria-label="Delivery">
  <OptionCard value="standard" title="Standard" description="3 to 5 working days"><template #end>Free</template></OptionCard>
  <OptionCard value="express" title="Express" description="1 to 2 working days"><template #end>$12.00</template></OptionCard>
</RadioGroup>

<CartLineItem v-model:quantity="item.quantity" :title="item.title" :image="item.image" options="Sand / 350 ml" :price="24" :max="6" @remove="remove(item)" />

<OrderSummary :subtotal="144" :discount="10" discount-label="SAVE10" :shipping="0" :tax="11.52">
  <template #footer><Button size="lg">Checkout</Button></template>
</OrderSummary>

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
OrderSummary.vuesrc/components/ui/order-summary/OrderSummary.vue
index.tssrc/components/ui/order-summary/index.ts

It is built from: Price. 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.

OrderSummary.vue 114 lines
<script setup lang="ts">
import { computed } from 'vue'
import { formatMoney } from '../price'

const props = withDefaults(
  defineProps<{
    /** The items added up, before anything else. Amounts are in whole units of the currency. */
    subtotal: number
    /** Money taken off, as a positive number. */
    discount?: number
    /** What the discount is, e.g. the code that was used. */
    discountLabel?: string
    /** 0 is written as "Free". Leave out until it is known. */
    shipping?: number
    /** Shown in place of the shipping cost while it is not known. */
    shippingPending?: string
    tax?: number
    /** Worked out from the lines above when left out. */
    total?: number
    currency?: string
    locale?: string
  }>(),
  { discountLabel: 'Discount', shippingPending: 'Calculated at checkout', currency: 'USD', locale: 'en-US' },
)

const money = (amount: number) => formatMoney(amount, props.currency, props.locale)
const sum = computed(() => props.total ?? Math.max(0, props.subtotal - (props.discount ?? 0) + (props.shipping ?? 0) + (props.tax ?? 0)))
</script>

<template>
  <div class="er-order-summary">
    <!-- The items themselves, a discount code field, or anything else that belongs above the sums. -->
    <slot />
    <dl>
      <div>
        <dt>Subtotal</dt>
        <dd>{{ money(subtotal) }}</dd>
      </div>
      <div v-if="discount" data-tone="discount">
        <dt>{{ discountLabel }}</dt>
        <dd>−{{ money(discount) }}</dd>
      </div>
      <div>
        <dt>Shipping</dt>
        <dd :data-pending="shipping === undefined ? '' : undefined">{{ shipping === undefined ? shippingPending : shipping === 0 ? 'Free' : money(shipping) }}</dd>
      </div>
      <div v-if="tax !== undefined">
        <dt>Tax</dt>
        <dd>{{ money(tax) }}</dd>
      </div>
      <div class="er-order-summary-total">
        <dt>Total</dt>
        <dd>{{ money(sum) }}</dd>
      </div>
    </dl>
    <!-- The button that moves on, and any small print. -->
    <slot name="footer" />
  </div>
</template>

<style>
@layer components {
  .er-order-summary {
    display: grid;
    gap: 1rem;
    min-width: 0;
  }

  .er-order-summary dl {
    display: grid;
    gap: 0.5rem;
    margin: 0;
    font-size: 0.875rem;
  }

  .er-order-summary dl > div {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 1rem;
  }

  .er-order-summary dt {
    color: var(--er-muted-foreground);
  }

  .er-order-summary dd {
    margin: 0;
    font-variant-numeric: tabular-nums;
    text-align: right;
  }

  .er-order-summary dd[data-pending] {
    color: var(--er-muted-foreground);
  }

  .er-order-summary [data-tone='discount'] dd {
    color: var(--er-success);
  }

  .er-order-summary .er-order-summary-total {
    margin-top: 0.25rem;
    padding-top: 0.75rem;
    border-top: 1px solid var(--er-border);
    font-size: 1rem;
    font-weight: var(--er-weight-semibold);
  }

  .er-order-summary-total dt {
    color: var(--er-foreground);
  }
}
</style>
index.ts 3 lines
/** Order summary — subtotal, discount, shipping and tax added up to a total, with room for the items and a checkout button. */
export { default as OrderSummary } from './OrderSummary.vue'