mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:01:09 +02:00
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:
parent
37039048f4
commit
b148a72e36
9 changed files with 492 additions and 10 deletions
149
apps/manacore/apps/web/src/lib/api/referrals.ts
Normal file
149
apps/manacore/apps/web/src/lib/api/referrals.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue