From b148a72e36a35919342891f406d3c35ee9ee9399 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Tue, 9 Dec 2025 13:01:43 +0100 Subject: [PATCH] feat(referral): integrate referral system frontend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../apps/web/src/lib/api/referrals.ts | 149 ++++++++++++++++++ .../dashboard/WidgetContainer.svelte | 2 + .../dashboard/widgets/ReferralWidget.svelte | 145 +++++++++++++++++ .../apps/web/src/lib/stores/auth.svelte.ts | 28 +++- .../apps/web/src/lib/types/dashboard.ts | 10 ++ .../src/routes/(auth)/register/+page.svelte | 15 +- .../src/pages/RegisterPage.svelte | 139 +++++++++++++++- packages/shared-auth/src/core/authService.ts | 12 +- services/mana-core-auth/drizzle.config.ts | 2 +- 9 files changed, 492 insertions(+), 10 deletions(-) create mode 100644 apps/manacore/apps/web/src/lib/api/referrals.ts create mode 100644 apps/manacore/apps/web/src/lib/components/dashboard/widgets/ReferralWidget.svelte diff --git a/apps/manacore/apps/web/src/lib/api/referrals.ts b/apps/manacore/apps/web/src/lib/api/referrals.ts new file mode 100644 index 000000000..4b3b6ed59 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/api/referrals.ts @@ -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(endpoint: string, options: RequestInit = {}): Promise { + 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 { + return fetchWithAuth('/api/v1/referrals/stats'); + }, + + /** + * Get the user's referral code (creates one if doesn't exist) + */ + async getCode(): Promise { + return fetchWithAuth('/api/v1/referrals/code'); + }, + + /** + * Generate a new referral code + */ + async generateCode(): Promise { + return fetchWithAuth('/api/v1/referrals/code', { + method: 'POST', + }); + }, + + /** + * Get list of referrals made by the current user + */ + async getReferrals(limit = 50, offset = 0): Promise { + return fetchWithAuth(`/api/v1/referrals/list?limit=${limit}&offset=${offset}`); + }, + + /** + * Validate a referral code (public endpoint - for registration) + */ + async validateCode(code: string): Promise { + 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 { + try { + const link = this.getShareLink(code); + await navigator.clipboard.writeText(link); + return true; + } catch { + return false; + } + }, +}; diff --git a/apps/manacore/apps/web/src/lib/components/dashboard/WidgetContainer.svelte b/apps/manacore/apps/web/src/lib/components/dashboard/WidgetContainer.svelte index ce932ed91..ed01f03cb 100644 --- a/apps/manacore/apps/web/src/lib/components/dashboard/WidgetContainer.svelte +++ b/apps/manacore/apps/web/src/lib/components/dashboard/WidgetContainer.svelte @@ -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, diff --git a/apps/manacore/apps/web/src/lib/components/dashboard/widgets/ReferralWidget.svelte b/apps/manacore/apps/web/src/lib/components/dashboard/widgets/ReferralWidget.svelte new file mode 100644 index 000000000..ab0174e3a --- /dev/null +++ b/apps/manacore/apps/web/src/lib/components/dashboard/widgets/ReferralWidget.svelte @@ -0,0 +1,145 @@ + + +
+

+ 🤝 + {$_('dashboard.widgets.referral.title')} +

+ + {#if state === 'loading'} + + {:else if state === 'error'} + + {:else if stats && code} +
+ +
+
+ {$_('dashboard.widgets.referral.your_code')} +
+
+ {code.code} + +
+
+ + +
+
+
{stats.totalReferrals}
+
{$_('dashboard.widgets.referral.total')}
+
+
+
+{stats.totalCreditsEarned}
+
{$_('dashboard.widgets.referral.earned')}
+
+
+ + +
+ {$_('dashboard.widgets.referral.tier')} + + {getTierEmoji(stats.currentTier)} + {stats.currentTier.charAt(0).toUpperCase() + stats.currentTier.slice(1)} + +
+ + + {#if stats.tierProgress.nextTierAt > 0} +
+
+ {stats.tierProgress.current} / {stats.tierProgress.nextTierAt} + {stats.tierProgress.percentToNext}% +
+
+
+
+
+ {/if} +
+ {/if} +
diff --git a/apps/manacore/apps/web/src/lib/stores/auth.svelte.ts b/apps/manacore/apps/web/src/lib/stores/auth.svelte.ts index 126070f7b..617b41d21 100644 --- a/apps/manacore/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/manacore/apps/web/src/lib/stores/auth.svelte.ts @@ -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 */ diff --git a/apps/manacore/apps/web/src/lib/types/dashboard.ts b/apps/manacore/apps/web/src/lib/types/dashboard.ts index 364a5caaf..c3bd6113b 100644 --- a/apps/manacore/apps/web/src/lib/types/dashboard.ts +++ b/apps/manacore/apps/web/src/lib/types/dashboard.ts @@ -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', diff --git a/apps/manacore/apps/web/src/routes/(auth)/register/+page.svelte b/apps/manacore/apps/web/src/routes/(auth)/register/+page.svelte index 9ebf19621..71979b19e 100644 --- a/apps/manacore/apps/web/src/routes/(auth)/register/+page.svelte +++ b/apps/manacore/apps/web/src/routes/(auth)/register/+page.svelte @@ -1,12 +1,20 @@ @@ -15,6 +23,9 @@ logo={ManaCoreLogo} primaryColor="#6366f1" onSignUp={handleSignUp} + onValidateReferralCode={handleValidateReferralCode} + {initialReferralCode} + baseSignupCredits={25} {goto} successRedirect="/dashboard" loginPath="/login" diff --git a/packages/shared-auth-ui/src/pages/RegisterPage.svelte b/packages/shared-auth-ui/src/pages/RegisterPage.svelte index 4b80f787e..688337dd8 100644 --- a/packages/shared-auth-ui/src/pages/RegisterPage.svelte +++ b/packages/shared-auth-ui/src/pages/RegisterPage.svelte @@ -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; + /** Sign up function (with optional referral code) */ + onSignUp: (email: string, password: string, referralCode?: string) => Promise; /** Navigation function */ goto: (path: string) => void; /** Success redirect path */ @@ -78,6 +99,12 @@ headerControls?: Snippet; /** Translations for i18n support */ translations?: Partial; + /** Referral code validation function */ + onValidateReferralCode?: (code: string) => Promise; + /** 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(null); + let validatingReferral = $state(false); + let referralDebounceTimer: ReturnType | 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}

+ + {#if onValidateReferralCode} +
+
+ 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};" + /> + +
+ {#if validatingReferral} + + {t.referralCodeValidating} + + {:else if referralValidation?.valid} + ✓ {t.referralCodeValid} + {:else if referralValidation && !referralValidation.valid} + ✗ {t.referralCodeInvalid} + {/if} +
+
+ + {#if referralValidation?.valid && referralValidation.bonusCredits} +

+ +{referralValidation.bonusCredits} + {t.referralBonusCredits} + {#if referralValidation.referrerName} + (von {referralValidation.referrerName}) + {/if} +

+ {/if} +
+ {/if} +