feat(referral): integrate referral system frontend

- Add referral code input to RegisterPage with live validation
- Create ReferralWidget for dashboard with stats, code sharing, and tier display
- Extend authService.signUp to accept optional referralCode parameter
- Add validateReferralCode function to authStore
- Create referrals.ts API service for frontend
- Add 'referral' widget type to dashboard configuration
- Fix drizzle.config.ts to include 'referrals' schema filter

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-09 13:01:43 +01:00
parent 37039048f4
commit b148a72e36
9 changed files with 492 additions and 10 deletions

View file

@ -0,0 +1,149 @@
/**
* Referrals Service for ManaCore Web App
* Handles referral codes, stats, and referral tracking
*/
import { authStore } from '$lib/stores/auth.svelte';
const MANA_AUTH_URL = 'http://localhost:3001'; // TODO: Use PUBLIC_MANA_CORE_AUTH_URL from env
// Types
export interface ReferralStats {
totalReferrals: number;
activeReferrals: number;
pendingReferrals: number;
qualifiedReferrals: number;
totalCreditsEarned: number;
currentTier: 'bronze' | 'silver' | 'gold' | 'platinum';
tierProgress: {
current: number;
nextTierAt: number;
percentToNext: number;
};
}
export interface ReferralCode {
code: string;
createdAt: string;
usageCount: number;
maxUses?: number;
expiresAt?: string;
bonusCredits: number;
referrerBonus: number;
}
export interface Referral {
id: string;
referredUserId: string;
referredEmail?: string;
stage: 'registered' | 'activated' | 'qualified' | 'retained';
creditsEarned: number;
registeredAt: string;
activatedAt?: string;
qualifiedAt?: string;
retainedAt?: string;
}
export interface ReferralValidation {
valid: boolean;
referrerName?: string;
bonusCredits?: number;
error?: string;
}
// Helper function for authenticated requests
async function fetchWithAuth<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const token = await authStore.getAccessToken();
const response = await fetch(`${MANA_AUTH_URL}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Request failed' }));
throw new Error(error.message || `HTTP ${response.status}`);
}
return response.json();
}
// Referrals Service
export const referralsService = {
/**
* Get referral stats for the current user
*/
async getStats(): Promise<ReferralStats> {
return fetchWithAuth<ReferralStats>('/api/v1/referrals/stats');
},
/**
* Get the user's referral code (creates one if doesn't exist)
*/
async getCode(): Promise<ReferralCode> {
return fetchWithAuth<ReferralCode>('/api/v1/referrals/code');
},
/**
* Generate a new referral code
*/
async generateCode(): Promise<ReferralCode> {
return fetchWithAuth<ReferralCode>('/api/v1/referrals/code', {
method: 'POST',
});
},
/**
* Get list of referrals made by the current user
*/
async getReferrals(limit = 50, offset = 0): Promise<Referral[]> {
return fetchWithAuth<Referral[]>(`/api/v1/referrals/list?limit=${limit}&offset=${offset}`);
},
/**
* Validate a referral code (public endpoint - for registration)
*/
async validateCode(code: string): Promise<ReferralValidation> {
try {
const response = await fetch(`${MANA_AUTH_URL}/api/v1/referrals/validate/${code}`);
if (!response.ok) {
return { valid: false, error: 'Invalid code' };
}
const data = await response.json();
return {
valid: data.valid,
referrerName: data.referrerName,
bonusCredits: data.bonusCredits || 25,
error: data.error,
};
} catch {
return { valid: false, error: 'Validation failed' };
}
},
/**
* Get shareable referral link
*/
getShareLink(code: string): string {
// Use current origin or fallback
const baseUrl = typeof window !== 'undefined' ? window.location.origin : 'https://manacore.app';
return `${baseUrl}/register?ref=${code}`;
},
/**
* Copy referral link to clipboard
*/
async copyShareLink(code: string): Promise<boolean> {
try {
const link = this.getShareLink(code);
await navigator.clipboard.writeText(link);
return true;
} catch {
return false;
}
},
};

View file

@ -16,6 +16,7 @@
import CreditsWidget from './widgets/CreditsWidget.svelte';
import QuickActionsWidget from './widgets/QuickActionsWidget.svelte';
import TransactionsWidget from './widgets/TransactionsWidget.svelte';
import ReferralWidget from './widgets/ReferralWidget.svelte';
import TasksTodayWidget from './widgets/TasksTodayWidget.svelte';
import TasksUpcomingWidget from './widgets/TasksUpcomingWidget.svelte';
import CalendarEventsWidget from './widgets/CalendarEventsWidget.svelte';
@ -56,6 +57,7 @@
credits: CreditsWidget,
'quick-actions': QuickActionsWidget,
transactions: TransactionsWidget,
referral: ReferralWidget,
'tasks-today': TasksTodayWidget,
'tasks-upcoming': TasksUpcomingWidget,
'calendar-events': CalendarEventsWidget,

View file

@ -0,0 +1,145 @@
<script lang="ts">
/**
* ReferralWidget - Displays referral code, stats, and sharing options
*/
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { referralsService, type ReferralStats, type ReferralCode } from '$lib/api/referrals';
import WidgetSkeleton from '../WidgetSkeleton.svelte';
import WidgetError from '../WidgetError.svelte';
let state = $state<'loading' | 'success' | 'error'>('loading');
let stats = $state<ReferralStats | null>(null);
let code = $state<ReferralCode | null>(null);
let error = $state<string | null>(null);
let retrying = $state(false);
let copied = $state(false);
async function load() {
state = 'loading';
retrying = true;
try {
const [statsData, codeData] = await Promise.all([
referralsService.getStats(),
referralsService.getCode(),
]);
stats = statsData;
code = codeData;
state = 'success';
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load referral data';
state = 'error';
} finally {
retrying = false;
}
}
onMount(load);
async function copyCode() {
if (!code) return;
const success = await referralsService.copyShareLink(code.code);
if (success) {
copied = true;
setTimeout(() => (copied = false), 2000);
}
}
function getTierColor(tier: string): string {
switch (tier) {
case 'platinum':
return 'text-purple-500';
case 'gold':
return 'text-yellow-500';
case 'silver':
return 'text-gray-400';
default:
return 'text-amber-700';
}
}
function getTierEmoji(tier: string): string {
switch (tier) {
case 'platinum':
return '💎';
case 'gold':
return '🥇';
case 'silver':
return '🥈';
default:
return '🥉';
}
}
</script>
<div>
<h3 class="mb-3 flex items-center gap-2 text-lg font-semibold">
<span>🤝</span>
{$_('dashboard.widgets.referral.title')}
</h3>
{#if state === 'loading'}
<WidgetSkeleton lines={4} />
{:else if state === 'error'}
<WidgetError {error} onRetry={load} {retrying} />
{:else if stats && code}
<div class="space-y-4">
<!-- Referral Code -->
<div class="rounded-lg bg-primary/5 p-3">
<div class="mb-1 text-xs text-muted-foreground">
{$_('dashboard.widgets.referral.your_code')}
</div>
<div class="flex items-center justify-between">
<span class="font-mono text-xl font-bold tracking-wider">{code.code}</span>
<button
onclick={copyCode}
class="rounded-md px-3 py-1 text-sm font-medium transition-colors
{copied ? 'bg-green-500/20 text-green-600' : 'bg-primary/10 text-primary hover:bg-primary/20'}"
>
{copied ? '✓ Copied!' : 'Copy Link'}
</button>
</div>
</div>
<!-- Stats -->
<div class="grid grid-cols-2 gap-3">
<div class="rounded-lg bg-muted/50 p-2 text-center">
<div class="text-2xl font-bold">{stats.totalReferrals}</div>
<div class="text-xs text-muted-foreground">{$_('dashboard.widgets.referral.total')}</div>
</div>
<div class="rounded-lg bg-muted/50 p-2 text-center">
<div class="text-2xl font-bold text-green-500">+{stats.totalCreditsEarned}</div>
<div class="text-xs text-muted-foreground">{$_('dashboard.widgets.referral.earned')}</div>
</div>
</div>
<!-- Tier -->
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">{$_('dashboard.widgets.referral.tier')}</span>
<span class="font-medium {getTierColor(stats.currentTier)}">
{getTierEmoji(stats.currentTier)}
{stats.currentTier.charAt(0).toUpperCase() + stats.currentTier.slice(1)}
</span>
</div>
<!-- Progress Bar -->
{#if stats.tierProgress.nextTierAt > 0}
<div>
<div class="mb-1 flex justify-between text-xs text-muted-foreground">
<span>{stats.tierProgress.current} / {stats.tierProgress.nextTierAt}</span>
<span>{stats.tierProgress.percentToNext}%</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-muted">
<div
class="h-full bg-primary transition-all duration-300"
style="width: {stats.tierProgress.percentToNext}%"
></div>
</div>
</div>
{/if}
</div>
{/if}
</div>

View file

@ -121,15 +121,18 @@ export const authStore = {
/**
* Sign up with email and password
* @param email User email
* @param password User password
* @param referralCode Optional referral code for bonus credits
*/
async signUp(email: string, password: string) {
async signUp(email: string, password: string, referralCode?: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server', needsVerification: false };
}
try {
const result = await authService.signUp(email, password);
const result = await authService.signUp(email, password, referralCode);
if (!result.success) {
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
@ -149,6 +152,27 @@ export const authStore = {
}
},
/**
* Validate a referral code
*/
async validateReferralCode(code: string) {
try {
const response = await fetch(`${getAuthUrl()}/api/v1/referrals/validate/${code}`);
if (!response.ok) {
return { valid: false, error: 'Invalid code' };
}
const data = await response.json();
return {
valid: data.valid,
referrerName: data.referrerName,
bonusCredits: data.bonusCredits || 25,
error: data.error,
};
} catch {
return { valid: false, error: 'Validation failed' };
}
},
/**
* Sign out
*/

View file

@ -11,6 +11,7 @@ export type WidgetType =
| 'credits' // Credits balance from mana-core-auth
| 'quick-actions' // Quick action links
| 'transactions' // Recent credit transactions
| 'referral' // Referral code and stats
| 'tasks-today' // Todo API: today's tasks
| 'tasks-upcoming' // Todo API: upcoming 7 days
| 'calendar-events' // Calendar API: upcoming events
@ -146,6 +147,15 @@ export const WIDGET_REGISTRY: WidgetMeta[] = [
allowMultiple: false,
requiredBackend: 'mana-core-auth',
},
{
type: 'referral',
nameKey: 'dashboard.widgets.referral.title',
descriptionKey: 'dashboard.widgets.referral.description',
icon: '🤝',
defaultSize: 'medium',
allowMultiple: false,
requiredBackend: 'mana-core-auth',
},
{
type: 'tasks-today',
nameKey: 'dashboard.widgets.tasks_today.title',

View file

@ -1,12 +1,20 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { RegisterPage } from '@manacore/shared-auth-ui';
import { ManaCoreLogo } from '@manacore/shared-branding';
import AppSlider from '$lib/components/AppSlider.svelte';
import { authStore } from '$lib/stores/auth.svelte';
async function handleSignUp(email: string, password: string) {
return authStore.signUp(email, password);
// Get referral code from URL if present
let initialReferralCode = $derived($page.url.searchParams.get('ref') || '');
async function handleSignUp(email: string, password: string, referralCode?: string) {
return authStore.signUp(email, password, referralCode);
}
async function handleValidateReferralCode(code: string) {
return authStore.validateReferralCode(code);
}
</script>
@ -15,6 +23,9 @@
logo={ManaCoreLogo}
primaryColor="#6366f1"
onSignUp={handleSignUp}
onValidateReferralCode={handleValidateReferralCode}
{initialReferralCode}
baseSignupCredits={25}
{goto}
successRedirect="/dashboard"
loginPath="/login"