Showcase
Copy a payment form, ship it today
Real forms built with MPUI-KIT. Play with them, open the code, copy it into your app. Try a number like 6 77 12 34 56 or 6 99 12 34 56.
Langue des exemples
Payout account with a live card
The card previews the number as it is typed and switches to the operator the prefix belongs to. Validation and E.164 come from PhoneInput.
"use client"
import { useState, type FormEvent } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { useCountry, useLocale } from "@/components/mpui-kit/mpui-kit-provider"
import { PhoneInput } from "@/components/mpui-kit/phone-input"
import { formatNational, type PhoneValidation } from "@/lib/mpui-kit/phone"
const copy = {
fr: {
title: "Modifier le compte de paiement",
name: "Nom du titulaire",
namePlaceholder: "Nom et prénom",
nameError: "Indiquez le nom du titulaire.",
holder: "Titulaire",
cancel: "Annuler",
save: "Enregistrer",
saving: "Enregistrement…",
saved: "Compte enregistré.",
},
en: {
title: "Update payout account",
name: "Account holder",
namePlaceholder: "Full name",
nameError: "Enter the account holder's name.",
holder: "Holder",
cancel: "Cancel",
save: "Save changes",
saving: "Saving…",
saved: "Account saved.",
},
}
/**
* A payout form with a live wallet card. Typing a number switches to the
* operator it belongs to. Wrap your app in <MpuiKitProvider country={cm}>.
*/
export function WalletForm() {
const country = useCountry()
const locale = useLocale()
const t = copy[locale]
// Only operators that run a Mobile Money wallet.
const wallets = country.operators.filter((operator) => operator.mobileMoneyName)
const [operatorId, setOperatorId] = useState(wallets[0]?.id)
const [name, setName] = useState("")
const [phone, setPhone] = useState("")
const [details, setDetails] = useState<PhoneValidation | null>(null)
const [submitted, setSubmitted] = useState(false)
const [status, setStatus] = useState<"idle" | "saving" | "saved">("idle")
const wallet = wallets.find((operator) => operator.id === operatorId) ?? wallets[0]
const nameError = submitted && name.trim() === "" ? t.nameError : undefined
async function onSubmit(event: FormEvent) {
event.preventDefault()
setSubmitted(true)
if (name.trim() === "" || !details?.valid) return
setStatus("saving")
// Replace with a call to your API, sending details.e164.
await new Promise((resolve) => setTimeout(resolve, 900))
setStatus("saved")
}
function reset() {
setName("")
setPhone("")
setDetails(null)
setSubmitted(false)
setStatus("idle")
}
return (
<div className="mx-auto w-full max-w-md pt-16">
<form
noValidate
onSubmit={onSubmit}
className="bg-card text-card-foreground relative rounded-3xl border p-4 shadow-sm sm:p-6"
>
<div className="bg-muted relative mb-6 h-24 rounded-2xl">
<WalletCard
brand={wallet?.mobileMoneyName ?? ""}
color={wallet?.color ?? "#64748b"}
digits={details?.national ?? ""}
length={country.nationalNumberLength}
format={(digits) => formatNational(digits, country)}
callingCode={country.callingCode}
holder={name.trim() || t.holder}
/>
</div>
<h3 className="mb-4 text-lg font-medium tracking-tight sm:text-xl">{t.title}</h3>
<div role="radiogroup" aria-label={t.title} className="bg-muted mb-5 flex rounded-xl p-1">
{wallets.map((operator) => (
<label
key={operator.id}
className="text-muted-foreground has-[:checked]:bg-background has-[:checked]:text-foreground has-[:focus-visible]:ring-ring/50 flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition has-[:checked]:shadow-sm has-[:focus-visible]:ring-3"
>
<input
type="radio"
name="wallet"
value={operator.id}
checked={operator.id === wallet?.id}
onChange={() => setOperatorId(operator.id)}
className="sr-only"
/>
<span
aria-hidden="true"
className="size-2.5 rounded-full"
style={{ backgroundColor: operator.color }}
/>
{/* Short name on phones, so both options stay on one line. */}
<span className="sm:hidden">{operator.name}</span>
<span className="hidden sm:inline">{operator.mobileMoneyName}</span>
</label>
))}
</div>
<div className="space-y-4">
<div className="space-y-1.5">
<label htmlFor="wallet-name" className="text-sm font-medium">
{t.name}*
</label>
<Input
id="wallet-name"
autoComplete="name"
placeholder={t.namePlaceholder}
value={name}
onChange={(event) => setName(event.target.value)}
aria-invalid={nameError ? true : undefined}
aria-describedby={nameError ? "wallet-name-error" : undefined}
className="h-11 rounded-xl px-3.5"
/>
{nameError && (
<p id="wallet-name-error" className="text-destructive text-sm">
{nameError}
</p>
)}
</div>
<PhoneInput
value={phone}
operator={wallet?.id}
requireOperator
onChange={(next, result) => {
setPhone(next)
setDetails(result)
setStatus("idle")
// Follow the operator of the number being typed.
if (result.operator?.mobileMoneyName) setOperatorId(result.operator.id)
}}
classNames={{ input: "h-11 rounded-xl" }}
/>
</div>
<div className="mt-6 flex items-center justify-end gap-2 border-t pt-5">
<p className="mr-auto text-sm text-emerald-600 dark:text-emerald-400" aria-live="polite">
{status === "saved" ? t.saved : ""}
</p>
<Button type="button" variant="secondary" size="lg" onClick={reset}>
{t.cancel}
</Button>
<Button type="submit" size="lg" disabled={status === "saving"}>
{status === "saving" ? t.saving : t.save}
</Button>
</div>
</form>
</div>
)
}
interface WalletCardProps {
brand: string
color: string
/** National digits typed so far. */
digits: string
length: number
format: (digits: string) => string
callingCode: string
holder: string
}
/** A decorative card that previews the number as it is typed. */
function WalletCard({
brand,
color,
digits,
length,
format,
callingCode,
holder,
}: WalletCardProps) {
// Pad with zeros so the card always shows a full, grouped number, then dim the padding.
const formatted = format(digits.padEnd(length, "0").slice(0, length))
let seen = 0
return (
<div
aria-hidden="true"
className="absolute inset-x-3 -top-16 flex h-40 overflow-hidden rounded-2xl bg-neutral-950 text-white shadow-xl transition-colors sm:inset-x-6"
>
<div className="flex min-w-0 flex-1 flex-col justify-between p-4 sm:p-5">
<p className="truncate text-sm font-semibold tracking-wide italic">{brand}</p>
<p className="leading-tight whitespace-nowrap tabular-nums">
<span className="block text-xs text-white/50">+{callingCode}</span>
<span className="text-base tracking-wide sm:text-lg">
{[...formatted].map((char, index) => {
// A space takes the shade of the digit after it.
const padding = seen >= digits.length
if (/\d/.test(char)) seen++
return (
<span key={index} className={padding ? "text-white/30" : undefined}>
{char}
</span>
)
})}
</span>
</p>
<p className="truncate text-xs text-white/60">{holder}</p>
</div>
<svg
viewBox="0 0 200 160"
className="h-full w-20 shrink-0 sm:w-2/5"
preserveAspectRatio="xMidYMid slice"
>
<g fill="none" stroke={color} strokeLinecap="round" className="transition-[stroke]">
<path d="M-10 40 C 60 10, 90 90, 210 30" strokeWidth="26" />
<path d="M10 140 C 70 80, 140 170, 220 100" strokeWidth="20" />
<path d="M120 -20 C 100 50, 190 70, 170 180" strokeWidth="14" />
</g>
<g fill={color}>
<circle cx="60" cy="95" r="7" />
<circle cx="150" cy="40" r="4" />
<circle cx="95" cy="125" r="3" />
<circle cx="185" cy="140" r="5" />
</g>
</svg>
</div>
)
}
npx shadcn@latest add @mpui-kit/country-cm @mpui-kit/phone-inputSend money, wallet style
Tap a recent contact or type a number, then the amount on a keypad. The screen takes the operator's color, and Send leads to the approval prompt.
"use client"
import { ArrowLeft, Delete } from "lucide-react"
import { useState, type FormEvent } from "react"
import { Button } from "@/components/ui/button"
import { Currency } from "@/components/mpui-kit/currency"
import { useCountry, useLocale } from "@/components/mpui-kit/mpui-kit-provider"
import { PhoneInput } from "@/components/mpui-kit/phone-input"
import { UssdPrompt } from "@/components/mpui-kit/ussd-prompt"
import { validatePhone } from "@/lib/mpui-kit/phone"
const MAX_AMOUNT = 500_000
const APPROVAL_MS = 60_000
// Shown if the approval prompt does not reach the phone.
const USSD_CODES: Record<string, string> = { mtn: "*123#", orange: "#150#" }
const CONTACTS = [
{ name: "Awa", phone: "677123456", tint: "from-amber-300 to-orange-500" },
{ name: "Paul", phone: "699887766", tint: "from-sky-300 to-indigo-500" },
{ name: "Mireille", phone: "655443322", tint: "from-pink-300 to-rose-500" },
{ name: "Junior", phone: "681234567", tint: "from-emerald-300 to-teal-500" },
]
const KEYS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "000", "0", "back"] as const
const copy = {
fr: {
title: "Envoyer de l'argent",
balance: "Solde",
recent: "Récents",
to: "Destinataire",
amount: "Montant à envoyer",
free: "Sans frais entre abonnés",
send: "Envoyer",
erase: "Effacer",
needNumber: "Saisissez un numéro valide.",
needAmount: "Saisissez un montant.",
back: "Retour",
},
en: {
title: "Send money",
balance: "Balance",
recent: "Recent",
to: "Recipient",
amount: "Amount to send",
free: "No fees between subscribers",
send: "Send",
erase: "Delete",
needNumber: "Enter a valid number.",
needAmount: "Enter an amount.",
back: "Back",
},
}
/** A wallet-style transfer: pick a contact, tap the amount, approve on the phone. */
export function SendMoney() {
const country = useCountry()
const t = copy[useLocale()]
const [phone, setPhone] = useState("")
const [amount, setAmount] = useState(2500)
const [submitted, setSubmitted] = useState(false)
const [expiresAt, setExpiresAt] = useState<number | null>(null)
// Derived from the number, so picking a contact validates it too.
const details = validatePhone(phone, country, { requireOperator: true })
const operator = details.operator
const contact = CONTACTS.find((item) => item.phone === details.national)
const tint = operator?.color ?? "#94a3b8"
function press(key: (typeof KEYS)[number]) {
setAmount((current) => {
if (key === "back") return Math.floor(current / 10)
const next = Number(`${current || ""}${key}`)
return next > MAX_AMOUNT ? current : next
})
}
function onSubmit(event: FormEvent) {
event.preventDefault()
setSubmitted(true)
if (!details.valid || amount <= 0) return
// Replace with a call to your API. Show the prompt once it says "pending".
setExpiresAt(Date.now() + APPROVAL_MS)
}
if (expiresAt !== null && details.e164) {
return (
<div className="bg-card mx-auto w-full max-w-sm space-y-4 rounded-[2rem] border p-5 shadow-xl">
<Button variant="ghost" size="sm" onClick={() => setExpiresAt(null)}>
<ArrowLeft aria-hidden="true" /> {t.back}
</Button>
<p className="text-center text-4xl font-semibold tracking-tight">
<Currency amount={amount} />
</p>
<UssdPrompt
code={USSD_CODES[operator?.id ?? ""]}
phone={details.e164}
expiresAt={expiresAt}
durationMs={APPROVAL_MS}
onRetry={() => setExpiresAt(Date.now() + APPROVAL_MS)}
/>
</div>
)
}
return (
<form
noValidate
onSubmit={onSubmit}
className="bg-card text-card-foreground mx-auto w-full max-w-sm overflow-hidden rounded-[2rem] border shadow-xl"
>
<div className="space-y-5 p-5 pb-4">
<header className="flex flex-wrap items-center justify-between gap-2">
<h3 className="font-semibold">{t.title}</h3>
<span className="bg-muted rounded-full px-3 py-1 text-xs">
{t.balance} <Currency amount={48500} className="font-semibold" />
</span>
</header>
<div className="space-y-2">
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t.recent}
</p>
<div className="flex gap-3">
{CONTACTS.map((item) => (
<button
key={item.phone}
type="button"
aria-pressed={contact === item}
onClick={() => setPhone(item.phone)}
className="group focus-visible:ring-ring/50 flex w-14 flex-col items-center gap-1 rounded-xl outline-none focus-visible:ring-3"
>
<span
aria-hidden="true"
className={`grid size-12 place-items-center rounded-full bg-gradient-to-br text-sm font-semibold text-white ring-offset-2 ring-offset-[var(--card)] transition group-aria-pressed:ring-2 group-aria-pressed:ring-[var(--foreground)] ${item.tint}`}
>
{item.name[0]}
</span>
<span className="text-muted-foreground w-full truncate text-xs">{item.name}</span>
</button>
))}
</div>
</div>
<PhoneInput
label={t.to}
hint={false}
value={phone}
requireOperator
error={submitted && !details.valid ? t.needNumber : undefined}
onChange={(next) => setPhone(next)}
classNames={{ input: "h-11 rounded-xl" }}
/>
</div>
<div
className="relative px-5 py-6 text-center transition-colors"
style={{
background: `radial-gradient(60% 70% at 50% 50%, ${tint}40, transparent 100%)`,
}}
>
<p className="text-muted-foreground text-xs">{t.amount}</p>
<p
className="mt-1 text-5xl font-semibold tracking-tight tabular-nums"
aria-live="polite"
aria-atomic="true"
>
<Currency amount={amount} display="none" />
<span className="text-muted-foreground ml-2 text-lg font-medium">FCFA</span>
</p>
<p className="text-muted-foreground mt-2 text-xs">
{submitted && amount <= 0 ? (
<span className="text-destructive">{t.needAmount}</span>
) : (
t.free
)}
</p>
</div>
<div className="grid grid-cols-3 gap-2 px-5">
{KEYS.map((key) => (
<button
key={key}
type="button"
onClick={() => press(key)}
aria-label={key === "back" ? t.erase : undefined}
className="hover:bg-muted active:bg-muted focus-visible:ring-ring/50 grid h-12 place-items-center rounded-2xl text-xl font-medium transition outline-none focus-visible:ring-3 active:scale-95"
>
{key === "back" ? <Delete aria-hidden="true" className="size-5" /> : key}
</button>
))}
</div>
<div className="p-5">
<Button
type="submit"
size="lg"
className="h-12 w-full rounded-2xl text-base"
style={operator ? { backgroundColor: tint, color: "#0a0a0a" } : undefined}
>
{t.send} <Currency amount={amount} />
{contact ? ` · ${contact.name}` : ""}
</Button>
</div>
</form>
)
}
npx shadcn@latest add @mpui-kit/country-cm @mpui-kit/currency @mpui-kit/phone-input @mpui-kit/ussd-promptFood order checkout
A receipt-style order summary next to the full checkout block: choose a method, approve on the phone, get a receipt. Swap the two fake functions for calls to your API.
Paiement
"use client"
import { Currency } from "@/components/mpui-kit/currency"
import { MomoCheckout } from "@/components/mpui-kit/momo-checkout"
import { useLocale } from "@/components/mpui-kit/mpui-kit-provider"
import type { PayResult, PaymentRequest, StatusResult } from "@/hooks/mpui-kit/use-momo-checkout"
// A fake backend so the example runs on its own. Replace both functions with
// calls to your API, which talks to your Mobile Money provider.
const startedAt = new Map<string, number>()
async function onPay(request: PaymentRequest): Promise<PayResult> {
await new Promise((resolve) => setTimeout(resolve, 600))
const reference = `ORD-${Date.now().toString(36).toUpperCase()}`
if (request.methodId === "card" || request.methodId === "cash") {
return { status: "success", reference }
}
startedAt.set(reference, Date.now())
return {
status: "pending",
reference,
ussdCode: request.methodId === "orange" ? "#150#" : "*123#",
}
}
async function onCheckStatus(reference: string): Promise<StatusResult> {
// Pretend the customer approves on their phone after 4 seconds.
const approved = Date.now() - (startedAt.get(reference) ?? 0) > 4_000
return approved ? { status: "success" } : { status: "pending" }
}
const DELIVERY = 1000
const copy = {
fr: {
restaurant: "Chez Tantine",
area: "Bastos, Yaoundé · 25–35 min",
order: "Votre commande",
subtotal: "Sous-total",
delivery: "Livraison",
total: "Total",
pay: "Paiement",
receipt: "Commande",
items: [
{ emoji: "🍲", name: "Ndolé aux crevettes", qty: 2, price: 3000 },
{ emoji: "🍌", name: "Plantain mûr frit", qty: 1, price: 1000 },
{ emoji: "🥤", name: "Jus de foléré", qty: 2, price: 500 },
],
},
en: {
restaurant: "Chez Tantine",
area: "Bastos, Yaoundé · 25–35 min",
order: "Your order",
subtotal: "Subtotal",
delivery: "Delivery",
total: "Total",
pay: "Payment",
receipt: "Order",
items: [
{ emoji: "🍲", name: "Ndolé with shrimp", qty: 2, price: 3000 },
{ emoji: "🍌", name: "Fried ripe plantain", qty: 1, price: 1000 },
{ emoji: "🥤", name: "Hibiscus juice", qty: 2, price: 500 },
],
},
}
/** A food order: a receipt-style summary next to the full checkout block. */
export function OrderCheckout() {
const t = copy[useLocale()]
const subtotal = t.items.reduce((sum, item) => sum + item.qty * item.price, 0)
const total = subtotal + DELIVERY
return (
<div className="bg-card mx-auto grid w-full max-w-4xl overflow-hidden rounded-3xl border shadow-xl md:grid-cols-[0.85fr_1.15fr]">
<aside className="relative overflow-hidden bg-neutral-950 p-5 text-white sm:p-8">
<svg
aria-hidden="true"
viewBox="0 0 200 200"
className="pointer-events-none absolute -top-20 -right-20 size-52 opacity-90"
>
<g fill="none" stroke="#f59e0b" strokeLinecap="round">
<path d="M20 40 C 80 0, 120 90, 210 50" strokeWidth="22" />
<path d="M60 170 C 100 110, 160 190, 230 120" strokeWidth="14" />
</g>
<circle cx="70" cy="110" r="6" fill="#f59e0b" />
<circle cx="150" cy="95" r="3" fill="#f59e0b" />
</svg>
<div className="relative space-y-1">
<p className="text-lg font-semibold">{t.restaurant}</p>
<p className="text-sm text-white/60">{t.area}</p>
</div>
<h3 className="relative mt-8 mb-4 text-xs font-medium tracking-wide text-white/50 uppercase">
{t.order}
</h3>
<ul className="relative space-y-4">
{t.items.map((item) => (
<li key={item.name} className="flex items-center gap-3">
<span
aria-hidden="true"
className="grid size-11 shrink-0 place-items-center rounded-xl bg-white/10 text-xl"
>
{item.emoji}
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm leading-snug font-medium">{item.name}</span>
<span className="text-xs text-white/50">
{item.qty} × <Currency amount={item.price} />
</span>
</span>
<Currency amount={item.qty * item.price} className="text-sm tabular-nums" />
</li>
))}
</ul>
<dl className="relative mt-8 space-y-2 border-t border-dashed border-white/20 pt-5 text-sm">
<div className="flex justify-between text-white/60">
<dt>{t.subtotal}</dt>
<dd>
<Currency amount={subtotal} />
</dd>
</div>
<div className="flex justify-between text-white/60">
<dt>{t.delivery}</dt>
<dd>
<Currency amount={DELIVERY} />
</dd>
</div>
<div className="flex items-baseline justify-between pt-2">
<dt className="font-medium">{t.total}</dt>
<dd className="text-2xl font-semibold tracking-tight">
<Currency amount={total} />
</dd>
</div>
</dl>
</aside>
<div className="p-5 sm:p-8">
<MomoCheckout
// The outer card already draws the frame.
className="rounded-none border-0 bg-transparent p-0 sm:p-0"
title={t.pay}
amount={total}
receiptDetails={[{ label: t.receipt, value: t.restaurant }]}
timeoutMs={30_000}
pollIntervalMs={1_000}
onPay={onPay}
onCheckStatus={onCheckStatus}
/>
</div>
</div>
)
}
npx shadcn@latest add @mpui-kit/country-cm @mpui-kit/momo-checkoutBuilding blocks
Each component on its own, with the values it hands back to your code.
Currency
25 000 FCFA
PhoneInput
Saisissez votre numéro à 9 chiffres, sans l'indicatif du pays.
- e164
- —
- valid
- false
- issue
- —
PaymentMethodPicker
- methodId
- null
- e164
- —
- ready
- false
UssdPrompt
Dictionary
Every user-facing string goes through the FR/EN dictionary. Current language: FR.
- Payer 50 000 FCFA
- Validez le paiement sur votre téléphone
- Expire dans 1:30
- Paiement réussi.