Skip to content

useSubscription ​

The plans, the billing cycle and the usage bars behind the pricing and profile pages. Demo state, shaped so a real billing provider can replace it.

Open in demo

Import ​

ts
import {
  acceptedCards,
  annualPrice,
  ANNUAL_DISCOUNT,
  formatCadence,
  formatPrice,
  paymentMethod,
  useSubscription,
} from '@shared/composables/useSubscription'

Signature ​

ts
function useSubscription(): {
  plans: Ref<Plan[]>
  currentPlan: ComputedRef<Plan>
  currentPlanId: Ref<string>
  billingCycle: Ref<BillingCycle>
  usage: { seatsUsed: number, seats: number, storageUsedGb: number, storageGb: number }
  seatPercent: ComputedRef<number>
  storagePercent: ComputedRef<number>
  currentPrice: ComputedRef<string>
  currentCadence: ComputedRef<string>
  selectPlan: (id: string) => void
  addPlan: (plan: Plan) => void
}

BillingCycle is 'monthly' | 'annually'.

Returns ​

MemberTypeDescription
plansRef<Plan[]>The four shipped tiers, plus anything added.
currentPlanComputedRef<Plan>The selected plan, falling back to the first.
currentPlanIdRef<string>The id. Starts at 'starter'.
billingCycleRef<BillingCycle>Monthly or annually.
usageobjectSeats and storage, used and available.
seatPercent / storagePercentComputedRef<number>Rounded percentages for the bars.
currentPriceComputedRef<string>The headline figure in the selected cycle.
currentCadenceComputedRef<string>The line under it.
selectPlan(id) => voidSwitches plan. An unknown id is ignored.
addPlan(plan) => voidAppends a tier.

Plan ​

KeyTypeDescription
idstringUnique.
namestringA translation key or a literal.
priceMonthlynumber | nullUSD per month. null means quoted rather than priced.
blurbstringThe line under the name.
featuresstring[]The bullet list.
highlightbooleanDrawn as the recommended tier. One per list.
contactSalesbooleanGets "Contact sales" instead of an upgrade action.

Helpers ​

ExportTypeDescription
ANNUAL_DISCOUNTnumber0.2. The "Save 20%" badge reads from it.
annualPrice(plan)number | nullThe monthly price with the discount applied, to a whole dollar.
formatPrice(plan, cycle)string$49, Free or Custom.
formatCadence(plan, cycle)stringThe line under the price. Empty for a free plan.
paymentMethodobjectThe card on file: Visa, 4242, expiring 08/27.
acceptedCardsreadonly string[]visa, mastercard, amex, paypal.

Example ​

vue
<script setup lang="ts">
import { formatCadence, formatPrice, useSubscription } from '@shared/composables/useSubscription'

const { plans, currentPlan, billingCycle, seatPercent, selectPlan } = useSubscription()
</script>

<template>
  <div class="space-y-4">
    <button type="button" @click="billingCycle = billingCycle === 'monthly' ? 'annually' : 'monthly'">
      Billed {{ billingCycle }}
    </button>

    <div class="grid gap-3 md:grid-cols-4">
      <article v-for="plan in plans" :key="plan.id">
        <h3>{{ plan.name }}</h3>
        <p>{{ formatPrice(plan, billingCycle) }}</p>
        <p v-if="formatCadence(plan, billingCycle)">
          {{ formatCadence(plan, billingCycle) }}
        </p>
        <button
          type="button"
          :disabled="plan.id === currentPlan.id"
          @click="selectPlan(plan.id)"
        >
          {{ plan.contactSales ? 'Contact sales' : 'Choose' }}
        </button>
      </article>
    </div>

    <p>{{ seatPercent }}% of seats used</p>
  </div>
</template>

Notes ​

  • Prices are numbers, not strings, because the annual figure has to be the discount. A "Save 20%" badge beside a hand-written annual price is a claim any reader with a calculator can falsify.
  • Every figure is fixed. A usage bar that moves on each reload reads as noise rather than data, and the limits belong to the seeded plan, so the bars agree with the plan named beside them.
  • The seeded plan is one tier below the recommended one, deliberately. Seed it on the recommended tier and the first render has every cheaper tier reading "Downgrade", the recommended card disabled, and the word "Upgrade" nowhere on the page.
  • The card is 4242, the universal test number. Anyone who recognizes it knows immediately that no billing provider is wired up; anyone who does not simply reads a plausible card.
  • plans is a ref rather than a constant because whoever runs the app may be the one selling the plans, so the page can add a tier. Added tiers are demo state and a reload restores the originals.
  • A free plan gets no cadence line at all. "Free forever" is a promise the demo cannot make, and Free already says what the figure needs to.
  • It is a module-scope singleton, so the profile card and the plans page cannot show different plans.