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"

View file

@ -17,6 +17,12 @@
backToLogin: string;
showPassword: string;
hidePassword: string;
// Referral
referralCodePlaceholder: string;
referralCodeValid: string;
referralCodeInvalid: string;
referralCodeValidating: string;
referralBonusCredits: string;
// Error messages
emailRequired: string;
passwordRequired: string;
@ -29,6 +35,14 @@
accountCreated: string;
}
/** Referral code validation result */
interface ReferralValidation {
valid: boolean;
referrerName?: string;
bonusCredits?: number;
error?: string;
}
/** Default English translations */
const defaultTranslations: RegisterTranslations = {
title: 'Create Account',
@ -42,6 +56,13 @@
backToLogin: 'Back to Login',
showPassword: 'Show password',
hidePassword: 'Hide password',
// Referral
referralCodePlaceholder: 'Referral Code (optional)',
referralCodeValid: 'Valid code!',
referralCodeInvalid: 'Invalid code',
referralCodeValidating: 'Checking...',
referralBonusCredits: 'bonus credits',
// Error messages
emailRequired: 'Email is required',
passwordRequired: 'Password is required',
confirmPasswordRequired: 'Please confirm your password',
@ -60,8 +81,8 @@
logo: Component<{ size?: number; color?: string }>;
/** Primary color (hex) */
primaryColor: string;
/** Sign up function */
onSignUp: (email: string, password: string) => Promise<AuthResult>;
/** Sign up function (with optional referral code) */
onSignUp: (email: string, password: string, referralCode?: string) => Promise<AuthResult>;
/** Navigation function */
goto: (path: string) => void;
/** Success redirect path */
@ -78,6 +99,12 @@
headerControls?: Snippet;
/** Translations for i18n support */
translations?: Partial<RegisterTranslations>;
/** Referral code validation function */
onValidateReferralCode?: (code: string) => Promise<ReferralValidation>;
/** Initial referral code (e.g., from URL) */
initialReferralCode?: string;
/** Base signup credits (shown to user) */
baseSignupCredits?: number;
}
let {
@ -93,6 +120,9 @@
appSlider,
headerControls,
translations = {},
onValidateReferralCode,
initialReferralCode = '',
baseSignupCredits = 25,
}: Props = $props();
// Merge provided translations with defaults
@ -108,6 +138,17 @@
let showPassword = $state(false);
let showConfirmPassword = $state(false);
// Referral state
let referralCode = $state(initialReferralCode);
let referralValidation = $state<ReferralValidation | null>(null);
let validatingReferral = $state(false);
let referralDebounceTimer: ReturnType<typeof setTimeout> | null = null;
// Calculate total credits (base + bonus)
let totalCredits = $derived(
baseSignupCredits + (referralValidation?.valid ? referralValidation.bonusCredits || 0 : 0)
);
// Theme state - can be toggled manually, defaults to system preference
let userThemePreference = $state<'light' | 'dark' | null>(null);
let systemIsDark = $state(
@ -139,6 +180,44 @@
}
}
// Referral code validation
async function validateReferralCode(code: string) {
if (!code || code.length < 3 || !onValidateReferralCode) {
referralValidation = null;
return;
}
validatingReferral = true;
try {
const result = await onValidateReferralCode(code);
referralValidation = result;
} catch {
referralValidation = { valid: false, error: 'Validation failed' };
} finally {
validatingReferral = false;
}
}
function handleReferralCodeChange(value: string) {
referralCode = value.toUpperCase().replace(/[^A-Z0-9]/g, '');
// Clear previous timer
if (referralDebounceTimer) {
clearTimeout(referralDebounceTimer);
}
// Reset validation if empty
if (!referralCode) {
referralValidation = null;
return;
}
// Debounce validation
referralDebounceTimer = setTimeout(() => {
validateReferralCode(referralCode);
}, 500);
}
// Password validation
let passwordRequirements = $derived.by(() => {
if (!password) {
@ -212,7 +291,9 @@
return;
}
const result = await onSignUp(email, password);
// Pass referral code if valid
const validReferralCode = referralValidation?.valid ? referralCode : undefined;
const result = await onSignUp(email, password, validReferralCode);
loading = false;
@ -415,6 +496,58 @@
{t.passwordRequirements}
</p>
<!-- Referral Code Input -->
{#if onValidateReferralCode}
<div class="mb-4">
<div class="relative">
<input
type="text"
value={referralCode}
oninput={(e) => handleReferralCodeChange((e.target as HTMLInputElement).value)}
placeholder={t.referralCodePlaceholder}
maxlength={12}
class="h-14 w-full rounded-xl border px-4 pr-24 text-lg transition-colors focus:outline-none focus:ring-2 uppercase tracking-wider"
style="background-color: {isDark
? 'rgba(0, 0, 0, 0.2)'
: 'rgba(255, 255, 255, 0.8)'}; border-color: {referralValidation?.valid
? '#22c55e'
: referralValidation && !referralValidation.valid
? '#ef4444'
: isDark
? 'rgba(255, 255, 255, 0.2)'
: 'rgba(0, 0, 0, 0.1)'}; color: {isDark
? '#ffffff'
: '#000000'}; --tw-ring-color: {primaryColor};"
/>
<!-- Validation indicator -->
<div class="absolute inset-y-0 right-0 flex items-center pr-4">
{#if validatingReferral}
<span
class="text-sm"
style="color: {isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.6)'};"
>
{t.referralCodeValidating}
</span>
{:else if referralValidation?.valid}
<span class="text-sm text-green-500">{t.referralCodeValid}</span>
{:else if referralValidation && !referralValidation.valid}
<span class="text-sm text-red-500">{t.referralCodeInvalid}</span>
{/if}
</div>
</div>
<!-- Bonus info -->
{#if referralValidation?.valid && referralValidation.bonusCredits}
<p class="mt-2 text-sm text-green-500">
+{referralValidation.bonusCredits}
{t.referralBonusCredits}
{#if referralValidation.referrerName}
(von {referralValidation.referrerName})
{/if}
</p>
{/if}
</div>
{/if}
<button
type="submit"
disabled={loading}

View file

@ -104,13 +104,21 @@ export function createAuthService(config: AuthServiceConfig) {
/**
* 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): Promise<AuthResult> {
async signUp(email: string, password: string, referralCode?: string): Promise<AuthResult> {
try {
const body: Record<string, string> = { email, password };
if (referralCode) {
body.referralCode = referralCode;
}
const response = await fetch(`${baseUrl}${endpoints.signUp}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
body: JSON.stringify(body),
});
if (!response.ok) {

View file

@ -7,7 +7,7 @@ export default defineConfig({
dbCredentials: {
url: process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/manacore',
},
schemaFilter: ['auth', 'credits', 'public'],
schemaFilter: ['auth', 'credits', 'referrals', 'public'],
verbose: true,
strict: true,
});