Skip to content

Vue 3

Provider, composables, embed props, and the Nuxt wiring that goes with them.

@wajub/vue gives you a provider, a CheckoutEmbed, seven field components and four composables. What every option does is documented on Display the checkout and Payment fields. This page covers what is specific to Vue.

@wajub/vue

Stable · GA

npm

Version
1.8.1
Runtime
Vue 3.3+

Covers

  • Payments
  • Payment Links

Not in this package: Billing, Transfers, Sync, Shield, Tax.

Install

@wajub/js is a peer dependency, so it goes on the same line. The package is ESM only.

npm install @wajub/vue @wajub/js

The provider

Wrap the checkout in one WajubProvider. It covers a whole subtree, so it belongs at the layout or page level rather than around each component.

layouts/checkout.vue
<script setup lang="ts">
import { WajubProvider } from '@wajub/vue';
</script>

<template>
  <WajubProvider>
    <slot />
  </WajubProvider>
</template>

Composables

The safe form
<script setup lang="ts">
import { useWajubOptional } from '@wajub/vue';

const props = defineProps<{ sessionId: string }>();
const runtime = useWajubOptional();

function upgrade() {
  runtime.value?.wajub.open({ sessionId: props.sessionId, onSuccess: onUpgraded });
}
</script>

<template>
  <button :disabled="!runtime" @click="upgrade">Upgrade plan</button>
</template>

The ref is null when setup runs and fills once Wajub.js has loaded. Read runtime.value in the script. The template unwraps it, so !runtime is enough there.

ComposableReturnsNotes
useWajub(){ wajub, Wajub, WajubError }Throws while loading, on failure, and with no runtime
useWajubOptional()A read-only ref of the same, null until the runtime has loadedSafe during SSR and while loading
useLoadWajub(){ runtime, loading, error, loadWajub }The first three are refs. loading and error let you render a spinner or a retry
useConfirmPayment(sessionId){ confirm, isConfirming }isConfirming is a ref. Submits a mounted field component. sessionId is a string, a ref or a getter

CheckoutEmbed

The hosted checkout inside your own layout. Its props are session-id, class, style, min-height, locale, appearance, layout, loading-text, show-loading, and the eleven callbacks.

pages/checkout.vue
<script setup lang="ts">
import { CheckoutEmbed } from '@wajub/vue';

defineProps<{ sessionId: string }>();
</script>

<template>
  <CheckoutEmbed
    :session-id="sessionId"
    layout="tabs"
    :min-height="480"
    @success="navigateTo('/order/complete')"
    @error="(error) => console.error(error.code, error.message)"
  />
</template>

Every callback is declared as a prop, so @success and :on-success are the same thing.

PropTypeDefaultWhat it does
session-idstringRequiredThe authorization_token from POST /payments
classstringnoneClass on the container element
styleRecord<string, string>noneInline styles on the container
min-heightnumber480Reserved height in pixels while the checkout loads

Props can change after mount, and a new session-id loads a new checkout, with no :key needed.

Payment fields

Seven components render one field group each: CardComponent, MobileMoneyComponent, WalletComponent, PaymentComponent, AddressComponent, BankTransferComponent and CryptoComponent. Every option and callback of ComponentConfig is a prop, plus the props below. ComponentEmbed is the same component with an explicit type, when the choice is made at runtime.

Your own layout, your own button
<script setup lang="ts">
import { shallowRef } from 'vue';
import type { ComponentInstance } from '@wajub/js';
import { AddressComponent, PaymentComponent, useConfirmPayment } from '@wajub/vue';

const props = defineProps<{ sessionId: string }>();
const { confirm, isConfirming } = useConfirmPayment(() => props.sessionId);
// shallowRef: the instance is the SDK's own object, never make it reactive.
const field = shallowRef<ComponentInstance | null>(null);

function pay() {
  if (field.value) confirm(field.value);
}
</script>

<template>
  <AddressComponent :session-id="sessionId" address-mode="shipping" />
  <PaymentComponent
    :session-id="sessionId"
    collect-address="shipping"
    collect-name
    @instance="(instance) => (field = instance)"
  />
  <button :disabled="isConfirming || !field" @click="pay">
    {{ isConfirming ? 'Processing…' : 'Pay now' }}
  </button>
</template>
PropTypeDefaultWhat it does
typeComponentTypeSet by the aliasOnly on ComponentEmbed
loader'skeleton' or 'none''skeleton''skeleton': the container holds the form's height from the first render, with a placeholder in your inputs' style. 'none': no placeholder
min-heightnumberUnsetThe container's minimum height in pixels. A min-height in style counts the same
ready-timeoutnumberUnsetMilliseconds to wait for the form. Past it, @load-error receives a WajubError with the code ready_timeout. Unset, no limit
@instance(instance | null) => voidnoneFires on mount and again with null on unmount
@ready(instance) => voidnoneFires once the field is interactive
@state-change({ state, method }) => voidnoneThe payment moved to another step, APPROVAL_REQUIRED while the payer approves on the phone for example
@resize({ height }) => voidnoneThe field took a new height, in pixels
@redirect({ url, method }) => voidnoneThe payment continues on the provider's own page, and your page is about to go there
:on-before-submit() => boolean | Promise<boolean>noneRuns before the charge, to place your order: false, or an error, charges nothing. Set it from the field's first render

Every handler is declared as a prop, so both spellings work: @state-change="fn" or :on-state-change="fn", and likewise @ready, @instance, @change, @success, @error, @load-error, @resize, @redirect and @method-change.

useConfirmPayment takes the session as a string, a ref or a getter such as () => props.sessionId. Keep a component instance in a shallowRef, never in a reactive proxy.

Mobile Money and Card in your own checkout

Your checkout keeps its own form, its method choice and its Pay button, and each method shows only its inputs, in your page's style. The steps, the server calls and the reason for each option are on Payment fields. Here, your server has prepared the payment with deferred: true when the checkout opened, and handed the page its authorization_token and authorization_url.

PaymentStep.vue, rendered inside <WajubProvider>
<script setup lang="ts">
import { computed, ref, shallowRef } from 'vue';
import {
  CardComponent,
  MobileMoneyComponent,
  useConfirmPayment,
  type ComponentInstance,
  type ComponentStateChangeEvent,
} from '@wajub/vue';

const props = defineProps<{
  sessionId: string; // the prepared payment's authorization_token, from your server
  hostedUrl: string; // its authorization_url, for the fallback
}>();

// The payment is under way, or waits on the payer: nothing on the page may change.
const RUNNING = [
  'PROCESSING', 'VERIFYING', 'PENDING', 'OTP_REQUIRED', 'PIN_REQUIRED', 'PHONE_REQUIRED',
  'BIRTHDAY_REQUIRED', 'ADDRESS_REQUIRED', '3DS_REQUIRED', 'USSD_REQUIRED', 'APPROVAL_REQUIRED',
];

const method = ref<'mobileMoney' | 'card'>('mobileMoney');
const component = shallowRef<ComponentInstance | null>(null);
const complete = ref(false);
const step = ref('INITIATED');
const fallback = ref(false);
const { confirm, isConfirming } = useConfirmPayment(() => props.sessionId);
const locked = computed(() => isConfirming.value || RUNNING.includes(step.value));

// Your server checks the cart, places the order and activates the payment.
async function placeOrder() {
  const response = await fetch('/checkout/place-order', { method: 'POST' });
  return response.ok; // false: nothing is charged
}

function onInstance(instance: ComponentInstance | null) {
  component.value = instance;
  complete.value = false;
}

function onStateChange(event: ComponentStateChangeEvent) {
  step.value = event.state;
}

function onLoadError(error: { code?: unknown }) {
  if (error.code === 'ready_timeout') fallback.value = true;
}

async function pay() {
  if (fallback.value) {
    // A prepared payment is charged only once activated: place the order first.
    if (await placeOrder()) window.location.assign(props.hostedUrl);
    return;
  }
  if (!component.value) return;
  try {
    await confirm(component.value);
    window.location.assign('/thanks');
  } catch {
    // The form shows the reason, and the payer can try again in it.
  }
}
</script>

<template>
  <fieldset :disabled="locked">
    <label><input v-model="method" type="radio" value="mobileMoney" /> Mobile Money</label>
    <label><input v-model="method" type="radio" value="card" /> Card</label>
  </fieldset>

  <!-- loader="skeleton" is the default: the box keeps its height while the form loads. -->
  <p v-if="fallback">You will finish on Wajub's secure payment page.</p>
  <component
    :is="method === 'card' ? CardComponent : MobileMoneyComponent"
    v-else
    :session-id="sessionId"
    variant="fields"
    :appearance="{ inherit: true }"
    :ready-timeout="10000"
    :fields="method === 'card'
      ? { customer: 'external' }
      : { customer: 'external', country: 'CM', operator: 'detect' }"
    :on-before-submit="placeOrder"
    @instance="onInstance"
    @change="(event) => (complete = Boolean(event.complete))"
    @state-change="onStateChange"
    @load-error="onLoadError"
  />

  <p v-if="step === 'APPROVAL_REQUIRED'" role="status">Approve the payment on your phone.</p>
  <button type="button" :disabled="locked || (!fallback && !complete)" @click="pay">Pay</button>
</template>

The fields show a declined payment themselves, and the payer tries again in them. When they do not come up within ready-timeout, the Pay button places the order and then sends the payer to Wajub's page: a prepared payment is charged only once activated. appearance.inherit reads a text input of your page, its label, your main button and the background behind the fields, so render your own form's fields before this step, or name them with :appearance="{ inherit: { input: '#email', button: '#pay' } }". Mark the order paid on your server, from the payment.succeeded webhook, never from the browser.

Nuxt

The session is created on your server. Nothing in this package needs a secret key, and nothing should ever receive one.

server/api/checkout/session.post.ts
import { Wajub } from '@wajub/node';

const wajub = new Wajub({ secretKey: process.env.WAJUB_API_KEY! });

export default defineEventHandler(async (event) => {
  const { cartId } = await readBody(event);

  const payment = await wajub.payments.create(
    {
      amount: 25000,
      currency: 'XAF',
      description: `Cart ${cartId}`,
      customer: { email: 'buyer@example.com' },
      callback: `${process.env.NUXT_PUBLIC_APP_URL}/order/return`,
      metadata: { cart_id: cartId },
    },
    { idempotencyKey: `cart-${cartId}` },
  );

  return { sessionId: payment.authorization_token };
});

Three more things Nuxt asks for:

  • <ClientOnly> is not needed: on the server the composables return null, and the fields mount in the browser.
  • Put WajubProvider in a layout, so that it stays mounted while the payer moves through the checkout flow.
  • Fetch the session from server/api/ and pass the token down as a prop.

What the package exports

The exports are listed in the SDK reference, the types the package adds in TypeScript.

What did you think of this content?