Blocks
Referral Program Block
Growth UIA copyable customer-facing growth loop with invite sharing, reward selection, milestone progress, pending referrals, and referral activity.
Marketing
Referral program
Copy this into account dashboards, ecommerce loyalty pages, SaaS workspaces, creator portals, or marketplace profiles where customers invite others and track reward progress.
1200px
<script setup>
import { computed, ref } from 'vue';
import { DomButton, DomNativeSelect, DomTextInput, DomToggle } from '@getdom/studio/vue';
const rewardOptions = [
{ label: 'GBP 25 account credit', value: 'credit' },
{ label: 'One free month', value: 'month' },
{ label: 'GBP 25 gift card', value: 'gift-card' },
];
const channelOptions = [
{ label: 'Email invite', value: 'email' },
{ label: 'Copy link', value: 'link' },
{ label: 'Team workspace', value: 'workspace' },
];
const referralSeed = [
{
id: 'ref-101',
name: 'Maya Chen',
email: 'maya@example.com',
status: 'qualified',
channel: 'Email',
detail: 'Paid first invoice on Jun 07',
reward: 25,
},
{
id: 'ref-102',
name: 'Nora Lee',
email: 'nora@example.com',
status: 'pending',
channel: 'Link',
detail: 'Trial started, payment pending',
reward: 0,
},
{
id: 'ref-103',
name: 'Owen Park',
email: 'owen@example.com',
status: 'invited',
channel: 'Workspace',
detail: 'Invite opened 2 hours ago',
reward: 0,
},
{
id: 'ref-104',
name: 'Priya Shah',
email: 'priya@example.com',
status: 'qualified',
channel: 'Email',
detail: 'Converted from starter plan',
reward: 25,
},
{
id: 'ref-105',
name: 'Sam Taylor',
email: 'sam@example.com',
status: 'qualified',
channel: 'Link',
detail: 'Qualified after team upgrade',
reward: 25,
},
];
const rewardCopy = {
credit: {
label: 'GBP 25 credit',
detail: 'Credit is applied to the next paid invoice when a friend qualifies.',
value: 25,
},
month: {
label: 'One free month',
detail: 'The free month is added after the referred workspace pays.',
value: 39,
},
'gift-card': {
label: 'GBP 25 gift card',
detail: 'Gift cards are sent after the referral clears fraud review.',
value: 25,
},
};
const milestones = [
{ count: 1, label: 'First reward' },
{ count: 3, label: 'GBP 75 earned' },
{ count: 5, label: 'Bonus reward' },
{ count: 10, label: 'VIP tier' },
];
const referrals = ref([...referralSeed]);
const selectedReward = ref('credit');
const selectedChannel = ref('email');
const friendEmail = ref('riley@example.com');
const copyState = ref('Copy invite link');
const autoNotify = ref(true);
const statusFilter = ref('all');
const inviteUrl = 'https://app.example.com/join?ref=ALEX-2048';
const qualifiedCount = computed(() => referrals.value.filter((referral) => referral.status === 'qualified').length);
const pendingCount = computed(() => referrals.value.filter((referral) => referral.status !== 'qualified').length);
const lifetimeReward = computed(() => referrals.value.reduce((total, referral) => total + referral.reward, 0));
const nextMilestone = computed(() => milestones.find((milestone) => milestone.count > qualifiedCount.value) || milestones[milestones.length - 1]);
const progressPercent = computed(() => Math.min(100, Math.round((qualifiedCount.value / nextMilestone.value.count) * 100)));
const reward = computed(() => rewardCopy[selectedReward.value]);
const filteredReferrals = computed(() => (
statusFilter.value === 'all'
? referrals.value
: referrals.value.filter((referral) => referral.status === statusFilter.value)
));
const statusOptions = computed(() => [
{ label: `All referrals (${referrals.value.length})`, value: 'all' },
{ label: `Qualified (${qualifiedCount.value})`, value: 'qualified' },
{ label: `Pending (${pendingCount.value})`, value: 'pending' },
{ label: 'Invited', value: 'invited' },
]);
const readinessChecks = computed(() => [
{ label: 'Invite link is active', passed: true },
{ label: 'Reward type selected', passed: Boolean(selectedReward.value) },
{ label: 'Friend email looks ready', passed: friendEmail.value.includes('@') },
{ label: 'Auto-notify enabled', passed: autoNotify.value },
]);
const canInvite = computed(() => readinessChecks.value.slice(0, 3).every((check) => check.passed));
function copyInvite() {
copyState.value = 'Link copied';
window.setTimeout(() => {
copyState.value = 'Copy invite link';
}, 1600);
}
function sendInvite() {
if (!canInvite.value) return;
const name = friendEmail.value.split('@')[0]
.split(/[._-]/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ') || 'New friend';
referrals.value = [
{
id: `ref-${Date.now()}`,
name,
email: friendEmail.value,
status: 'invited',
channel: channelOptions.find((option) => option.value === selectedChannel.value)?.label.replace(' invite', '') || 'Link',
detail: autoNotify.value ? 'Invite sent with reward reminder' : 'Invite created without reminder',
reward: 0,
},
...referrals.value,
];
friendEmail.value = '';
statusFilter.value = 'all';
}
function statusClass(status) {
if (status === 'qualified') return 'bg-success/15 text-success';
if (status === 'pending') return 'bg-warning/15 text-warning';
return 'bg-secondary text-muted-fg';
}
function money(value) {
return new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'GBP',
maximumFractionDigits: 0,
}).format(value);
}
</script>
<template>
<div class="w-full bg-[radial-gradient(circle_at_top_left,rgb(219_234_254),transparent_34%),linear-gradient(135deg,rgb(248_250_252),rgb(240_253_250))] px-3 py-4 text-fg dark:bg-[radial-gradient(circle_at_top_left,rgb(30_64_175),transparent_32%),linear-gradient(135deg,rgb(15_23_42),rgb(6_78_59))] sm:px-6 lg:px-8">
<section class="mx-auto grid max-w-6xl gap-4 lg:grid-cols-[minmax(0,1fr)_23rem]">
<div class="overflow-hidden rounded-lg border border-border bg-background shadow-2xl shadow-black/10">
<header class="relative overflow-hidden border-b border-border p-5 sm:p-7">
<div class="absolute inset-y-0 right-0 hidden w-1/2 bg-[linear-gradient(135deg,transparent,rgb(14_165_233_/_0.12))] sm:block" aria-hidden="true" />
<div class="relative flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
<div class="max-w-2xl">
<p class="text-xs font-semibold uppercase text-muted-fg">Referral program</p>
<h3 class="mt-2 text-3xl font-semibold sm:text-4xl">Invite friends. Earn rewards.</h3>
<p class="mt-3 text-sm leading-6 text-muted-fg sm:text-base">
Share your personal invite link, track every friend from invite to paid conversion, and unlock the next reward tier.
</p>
</div>
<div class="grid w-full gap-2 rounded-lg border border-border bg-background/90 p-4 shadow-sm backdrop-blur sm:max-w-xs">
<div class="flex items-center justify-between gap-3">
<span class="text-sm text-muted-fg">Lifetime rewards</span>
<span class="text-2xl font-semibold">{{ money(lifetimeReward) }}</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-secondary">
<div class="h-full rounded-full bg-primary transition-all" :style="{ width: `${progressPercent}%` }" />
</div>
<p class="text-xs leading-5 text-muted-fg">
{{ qualifiedCount }} qualified. {{ nextMilestone.count - qualifiedCount }} more to unlock {{ nextMilestone.label }}.
</p>
</div>
</div>
</header>
<div class="grid gap-5 p-4 sm:p-6">
<section class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div
v-for="milestone in milestones"
:key="milestone.count"
class="rounded-lg border p-4"
:class="qualifiedCount >= milestone.count ? 'border-primary bg-primary/10' : 'border-border bg-secondary/50'"
>
<div class="flex items-center justify-between gap-3">
<span class="grid size-9 place-items-center rounded-full bg-background text-sm font-bold">
{{ milestone.count }}
</span>
<span class="text-xs font-semibold uppercase text-muted-fg">Refs</span>
</div>
<p class="mt-4 font-semibold">{{ milestone.label }}</p>
<p class="mt-1 text-sm text-muted-fg">{{ qualifiedCount >= milestone.count ? 'Unlocked' : 'In progress' }}</p>
</div>
</section>
<section class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_18rem]">
<div class="rounded-lg border border-border bg-background p-4 sm:p-5">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<p class="text-xs font-semibold uppercase text-muted-fg">Share invite</p>
<h4 class="mt-1 text-xl font-semibold">Send a referral</h4>
</div>
<span class="w-fit rounded-full bg-success/15 px-3 py-1 text-xs font-semibold text-success">
{{ reward.label }}
</span>
</div>
<div class="mt-5 grid gap-4 lg:grid-cols-2">
<DomTextInput v-model="friendEmail" label="Friend email" type="email" autocomplete="email" />
<DomNativeSelect v-model="selectedChannel" label="Invite channel" :options="channelOptions" />
<DomNativeSelect v-model="selectedReward" label="Reward type" :options="rewardOptions" />
<label class="grid gap-1 text-sm">
<span class="font-medium text-fg">Invite link</span>
<span class="flex min-w-0 items-center gap-2 rounded-lg border border-border bg-secondary/60 px-3 py-2">
<span class="truncate text-muted-fg">{{ inviteUrl }}</span>
</span>
</label>
</div>
<div class="mt-5 flex flex-col gap-3 rounded-lg bg-secondary/60 p-4 sm:flex-row sm:items-center sm:justify-between">
<DomToggle v-model="autoNotify" label="Send reward reminder" description="Email a reminder when the friend starts a trial." />
<div class="flex shrink-0 gap-2">
<DomButton variant="secondary" @click="copyInvite">{{ copyState }}</DomButton>
<DomButton :disabled="!canInvite" @click="sendInvite">Send invite</DomButton>
</div>
</div>
</div>
<aside class="rounded-lg border border-border bg-background p-4 sm:p-5">
<p class="text-xs font-semibold uppercase text-muted-fg">Reward policy</p>
<h4 class="mt-1 text-lg font-semibold">{{ reward.label }}</h4>
<p class="mt-2 text-sm leading-6 text-muted-fg">{{ reward.detail }}</p>
<div class="mt-4 grid gap-2 text-sm">
<div
v-for="check in readinessChecks"
:key="check.label"
class="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2"
>
<span class="text-muted-fg">{{ check.label }}</span>
<span :class="check.passed ? 'text-success' : 'text-warning'" class="font-semibold">
{{ check.passed ? 'Ready' : 'Needed' }}
</span>
</div>
</div>
</aside>
</section>
</div>
</div>
<aside class="grid content-start gap-4">
<section class="rounded-lg border border-border bg-background p-4 shadow-xl shadow-black/10 sm:p-5">
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-xs font-semibold uppercase text-muted-fg">Referral status</p>
<h4 class="mt-1 text-xl font-semibold">{{ referrals.length }} friends invited</h4>
</div>
<span class="rounded-full bg-primary/10 px-3 py-1 text-xs font-semibold text-primary">
{{ pendingCount }} pending
</span>
</div>
<div class="mt-4">
<DomNativeSelect v-model="statusFilter" label="Filter referrals" :options="statusOptions" />
</div>
<div class="mt-4 grid gap-3">
<article
v-for="referral in filteredReferrals"
:key="referral.id"
class="rounded-lg border border-border bg-background p-3"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<h5 class="truncate font-semibold">{{ referral.name }}</h5>
<p class="truncate text-sm text-muted-fg">{{ referral.email }}</p>
</div>
<span class="shrink-0 rounded-full px-2.5 py-1 text-xs font-semibold capitalize" :class="statusClass(referral.status)">
{{ referral.status }}
</span>
</div>
<div class="mt-3 flex items-center justify-between gap-3 text-sm">
<span class="text-muted-fg">{{ referral.detail }}</span>
<span class="font-semibold">{{ referral.reward ? money(referral.reward) : referral.channel }}</span>
</div>
</article>
<p v-if="!filteredReferrals.length" class="rounded-lg border border-dashed border-border p-4 text-sm text-muted-fg">
No referrals match this status yet.
</p>
</div>
</section>
<section class="rounded-lg border border-border bg-background p-4 shadow-xl shadow-black/10 sm:p-5">
<p class="text-xs font-semibold uppercase text-muted-fg">Growth readout</p>
<div class="mt-4 grid gap-3 text-sm">
<div class="flex items-center justify-between gap-3">
<span class="text-muted-fg">Qualified referrals</span>
<span class="font-semibold">{{ qualifiedCount }}</span>
</div>
<div class="flex items-center justify-between gap-3">
<span class="text-muted-fg">Pending or invited</span>
<span class="font-semibold">{{ pendingCount }}</span>
</div>
<div class="flex items-center justify-between gap-3">
<span class="text-muted-fg">Current reward</span>
<span class="font-semibold">{{ money(reward.value) }}</span>
</div>
</div>
<div class="mt-4 rounded-lg bg-secondary/70 p-3 text-sm leading-6 text-muted-fg">
<p class="font-semibold text-fg">Qualification rule</p>
<p>Reward posts after the referred workspace pays its first invoice and passes duplicate-account review.</p>
</div>
</section>
</aside>
</section>
</div>
</template>
Integration
How to use this block
Use this block when a product needs a visible invite loop rather than a buried referral settings page. The layout makes the invite link, reward choice, milestone progress, pending friends, and next reward understandable at a glance.
- Replace
referralswith records from your growth, billing, or CRM service, including invitee identity, lifecycle status, qualification rules, and reward outcome. - Connect the copy and invite actions to your referral API so each share includes a signed customer code, attribution source, expiry, campaign, and fraud controls.
- Calculate rewards server-side after the referred account satisfies your rule, such as trial activation, first purchase, paid conversion, or retention through a billing period.
- Keep reward settings explicit so teams can offer account credit, free months, gift cards, marketplace balance, or product perks without changing the UI structure.
- Send referral events to analytics with source, channel, milestone, reward type, and qualified revenue so marketing can measure loop quality.
Data
Recommended referral payload
{
member: {
id: 'mem_118',
name: 'Alex Morgan',
referralCode: 'ALEX-2048',
inviteUrl: 'https://app.example.com/join?ref=ALEX-2048'
},
campaign: {
id: 'spring-growth-2026',
rewardType: 'account_credit',
rewardAmount: 25,
currency: 'GBP',
qualificationRule: 'referred_account_pays_first_invoice'
},
stats: {
qualified: 3,
pending: 2,
nextMilestone: 5,
lifetimeCredit: 75
},
referrals: [
{ id: 'ref_101', name: 'Maya Chen', email: 'maya@example.com', status: 'qualified', channel: 'Email', reward: 25 },
{ id: 'ref_102', name: 'Nora Lee', email: 'nora@example.com', status: 'pending', channel: 'Link', reward: 0 }
]
}Customization
Implementation notes
Reward rules
Keep qualification windows, duplicate-account checks, refund clawbacks, and payout timing in backend policy so customers see clear but enforceable progress.
Share tracking
Store channel, campaign, UTM values, referral code version, and invite source with each share so growth teams can measure what actually converts.
Future updates
Useful follow-ups include fraud review states, leaderboard variants, reward payout history, multi-sided marketplace rewards, and lifecycle email previews.