From 8c44fcb9ac46ff8b635c60249df7acf0336b8b63 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 19 Apr 2026 16:40:37 -0700 Subject: [PATCH 1/5] Remove referrals feature while preserving existing referral data Strip the /refer-friends CLI command, web referral pages, affiliate sponsee routes, and one-time referral redemption API. Simplify the profile referrals section to a read-only list of who you referred. Keep DB schema, legacy monthly bonus grants, and the GrantType enum so historical referrals continue to pay out. Restore creator attribution for freebuff only: /get-started already accepts ?referrer= and persists it; /onboard fires a FREEBUFF_REFERRER_ATTRIBUTED PostHog event with \$set_once on first visit post-login. /onboard now redirects logged-out visitors to /get-started (or /login when an auth_code is present) and greets logged-in users with an optional "{Name} invited you to try Freebuff!" header. Fixes a pre-existing bug where auth_code was dropped when /onboard redirected unauthenticated users. Co-Authored-By: Claude Opus 4.7 --- cli/src/__tests__/helpers/mock-api-client.ts | 3 - cli/src/__tests__/referral-mode.test.ts | 547 ------------------ .../commands/__tests__/command-args.test.ts | 1 - .../commands/__tests__/router-input.test.ts | 102 ---- cli/src/commands/command-registry.ts | 39 -- cli/src/commands/referral.ts | 73 --- cli/src/commands/router-utils.ts | 62 -- cli/src/commands/router.ts | 68 --- cli/src/components/chat-input-bar.tsx | 5 - cli/src/components/input-mode-banner.tsx | 2 - cli/src/components/referral-banner.tsx | 122 ---- cli/src/data/slash-commands.ts | 8 - .../__tests__/use-user-details-query.test.ts | 23 - cli/src/hooks/use-chat-keyboard.ts | 2 +- cli/src/utils/__tests__/fetch-usage.test.ts | 3 - .../utils/__tests__/keyboard-actions.test.ts | 11 - cli/src/utils/codebuff-api.ts | 27 +- cli/src/utils/input-modes.ts | 11 - common/src/constants/analytics-events.ts | 4 +- common/src/constants/limits.ts | 8 - common/src/testing/fixtures/agent-runtime.ts | 1 - common/src/types/contracts/database.ts | 2 - common/src/util/referral.ts | 4 - .../web/src/app/api/auth/cli/code/route.ts | 7 +- freebuff/web/src/app/onboard/page.tsx | 25 +- .../web/src/components/login/login-card.tsx | 7 - .../src/components/sign-in/sign-in-button.tsx | 4 - web/knowledge.md | 16 - web/src/__tests__/e2e/redirects.spec.ts | 75 --- web/src/app/[sponsee]/page.tsx | 88 --- web/src/app/affiliates/actions.ts | 135 ----- web/src/app/affiliates/affiliates-client.tsx | 265 --------- web/src/app/affiliates/page.tsx | 130 ----- web/src/app/analytics.knowledge.md | 96 +-- web/src/app/api/auth/cli/code/route.ts | 7 +- web/src/app/api/referrals/[code]/route.ts | 57 -- .../api/referrals/__tests__/helpers.test.ts | 375 ------------ web/src/app/api/referrals/helpers.ts | 221 ------- web/src/app/api/referrals/route.ts | 79 --- web/src/app/api/user/profile/route.ts | 2 - web/src/app/api/v1/_helpers.ts | 1 - web/src/app/api/v1/me/__tests__/me.test.ts | 21 +- web/src/app/api/v1/me/_get.ts | 46 +- web/src/app/home-client.tsx | 41 -- web/src/app/onboard/page.tsx | 31 +- .../profile/components/referrals-section.tsx | 70 +-- web/src/app/referrals/[code]/page.tsx | 134 ----- web/src/components/login/login-card.tsx | 9 - web/src/components/navbar/user-dropdown.tsx | 5 +- .../onboard/onboard-client-wrapper.tsx | 72 --- .../components/onboard/onboarding-flow.tsx | 436 -------------- web/src/components/referral-redirect.tsx | 31 - .../referral/github-signin-button.tsx | 86 --- web/src/components/sign-in/sign-in-button.tsx | 22 - web/src/components/ui/banner.tsx | 92 --- web/src/db/user.ts | 1 - web/src/lib/server/referral.ts | 77 --- web/src/lib/stripe-utils.ts | 27 - web/src/types/user.ts | 1 - 59 files changed, 46 insertions(+), 3874 deletions(-) delete mode 100644 cli/src/__tests__/referral-mode.test.ts delete mode 100644 cli/src/commands/referral.ts delete mode 100644 cli/src/components/referral-banner.tsx delete mode 100644 common/src/util/referral.ts delete mode 100644 web/src/app/[sponsee]/page.tsx delete mode 100644 web/src/app/affiliates/actions.ts delete mode 100644 web/src/app/affiliates/affiliates-client.tsx delete mode 100644 web/src/app/affiliates/page.tsx delete mode 100644 web/src/app/api/referrals/[code]/route.ts delete mode 100644 web/src/app/api/referrals/__tests__/helpers.test.ts delete mode 100644 web/src/app/api/referrals/helpers.ts delete mode 100644 web/src/app/referrals/[code]/page.tsx delete mode 100644 web/src/components/onboard/onboard-client-wrapper.tsx delete mode 100644 web/src/components/onboard/onboarding-flow.tsx delete mode 100644 web/src/components/referral-redirect.tsx delete mode 100644 web/src/components/referral/github-signin-button.tsx delete mode 100644 web/src/components/ui/banner.tsx delete mode 100644 web/src/lib/server/referral.ts diff --git a/cli/src/__tests__/helpers/mock-api-client.ts b/cli/src/__tests__/helpers/mock-api-client.ts index 720fb68dc0..fbf4423be3 100644 --- a/cli/src/__tests__/helpers/mock-api-client.ts +++ b/cli/src/__tests__/helpers/mock-api-client.ts @@ -13,7 +13,6 @@ export interface MockApiClientOverrides { usage?: ReturnType loginCode?: ReturnType loginStatus?: ReturnType - referral?: ReturnType publish?: ReturnType logout?: ReturnType feedback?: ReturnType @@ -54,8 +53,6 @@ export const createMockApiClient = ( mock(defaultOkResponse)) as CodebuffApiClient['loginCode'], loginStatus: (overrides.loginStatus ?? mock(defaultOkResponse)) as CodebuffApiClient['loginStatus'], - referral: (overrides.referral ?? - mock(defaultOkResponse)) as CodebuffApiClient['referral'], publish: (overrides.publish ?? mock(defaultOkResponse)) as CodebuffApiClient['publish'], logout: (overrides.logout ?? diff --git a/cli/src/__tests__/referral-mode.test.ts b/cli/src/__tests__/referral-mode.test.ts deleted file mode 100644 index 09607f30f5..0000000000 --- a/cli/src/__tests__/referral-mode.test.ts +++ /dev/null @@ -1,547 +0,0 @@ -import { describe, test, expect, mock } from 'bun:test' - -import { getInputModeConfig } from '../utils/input-modes' - -import type { InputMode } from '../utils/input-modes' - -// Helper type for mock functions -type MockSetInputMode = (mode: InputMode) => void - -/** - * Tests for referral mode functionality in the CLI. - * - * Referral mode is entered when user types '/referral' or '/redeem' and allows entering referral codes. - * The '◎' icon is displayed in a warning-colored column. - * - * Key behaviors: - * 1. Entering referral mode via slash commands - * 2. Input validation (3-50 alphanumeric chars with dashes) - * 3. Backspace at cursor position 0 exits referral mode - * 4. Submission auto-prefixes 'ref-' if not present - * 5. UI state changes (icon, placeholder, colors) - */ - -describe('referral-mode', () => { - describe('entering referral mode', () => { - test('typing "/referral" enters referral mode', () => { - const setInputMode = mock((_mode) => {}) - const command = '/referral' - - // Simulate command processing - if (command === '/referral' || command === '/redeem') { - setInputMode('referral') - } - - expect(setInputMode).toHaveBeenCalledWith('referral') - }) - - test('typing "/redeem" also enters referral mode', () => { - const setInputMode = mock((_mode) => {}) - const command = '/redeem' as string - - if (command === '/referral' || command === '/redeem') { - setInputMode('referral') - } - - expect(setInputMode).toHaveBeenCalledWith('referral') - }) - - test('/referral with a code argument redeems immediately without entering mode', () => { - const setInputMode = mock((_mode) => {}) - const handleReferralCode = mock(async (_code: string) => {}) - const command = '/referral abc123' - - // Simulate handler logic - const args = command.slice('/referral'.length + 1).trim() - if (args) { - // Has arguments - redeem directly - handleReferralCode('ref-abc123') - } else { - // No arguments - enter mode - setInputMode('referral') - } - - expect(handleReferralCode).toHaveBeenCalledWith('ref-abc123') - expect(setInputMode).not.toHaveBeenCalled() - }) - }) - - describe('exiting referral mode', () => { - test('backspace at cursor position 0 exits referral mode', () => { - const setInputMode = mock((_mode) => {}) - - const inputMode = 'referral' as InputMode - const cursorPosition = 0 - const key = { name: 'backspace' } - - // Simulate exit logic - if ( - inputMode !== 'default' && - cursorPosition === 0 && - key.name === 'backspace' - ) { - setInputMode('default') - } - - expect(setInputMode).toHaveBeenCalledWith('default') - }) - - test('backspace at cursor position 0 with non-empty input DOES exit referral mode', () => { - const setInputMode = mock((_mode) => {}) - - const inputMode = 'referral' as InputMode - const cursorPosition = 0 - const key = { name: 'backspace' } - - if ( - inputMode !== 'default' && - cursorPosition === 0 && - key.name === 'backspace' - ) { - setInputMode('default') - } - - // Should exit even with input, because cursor is at position 0 - expect(setInputMode).toHaveBeenCalledWith('default') - }) - - test('backspace at cursor position > 0 does NOT exit referral mode', () => { - const setInputMode = mock((_mode) => {}) - - const inputMode = 'referral' as InputMode - const cursorPosition = 5 as number - const key = { name: 'backspace' } - - if ( - inputMode !== 'default' && - cursorPosition === 0 && - key.name === 'backspace' - ) { - setInputMode('default') - } - - // Should not exit because cursor is not at position 0 - expect(setInputMode).not.toHaveBeenCalled() - }) - - test('other keys at cursor position 0 do NOT exit referral mode', () => { - const setInputMode = mock((_mode) => {}) - - const inputMode = 'referral' as InputMode - const cursorPosition = 0 - const key = { name: 'a' } - - if ( - inputMode !== 'default' && - cursorPosition === 0 && - key.name === 'backspace' - ) { - setInputMode('default') - } - - // Should not exit because key is not backspace - expect(setInputMode).not.toHaveBeenCalled() - }) - }) - - describe('referral code validation', () => { - test('valid alphanumeric code passes validation', () => { - const code = 'abc123' - const pattern = /^[a-zA-Z0-9-]{3,50}$/ - - expect(pattern.test(code)).toBe(true) - }) - - test('valid code with dashes passes validation', () => { - const code = 'abc-123-xyz' - const pattern = /^[a-zA-Z0-9-]{3,50}$/ - - expect(pattern.test(code)).toBe(true) - }) - - test('minimum length (3 chars) passes validation', () => { - const code = 'abc' - const pattern = /^[a-zA-Z0-9-]{3,50}$/ - - expect(pattern.test(code)).toBe(true) - }) - - test('maximum length (50 chars) passes validation', () => { - const code = 'a'.repeat(50) - const pattern = /^[a-zA-Z0-9-]{3,50}$/ - - expect(pattern.test(code)).toBe(true) - }) - - test('too short (< 3 chars) fails validation', () => { - const code = 'ab' - const pattern = /^[a-zA-Z0-9-]{3,50}$/ - - expect(pattern.test(code)).toBe(false) - }) - - test('too long (> 50 chars) fails validation', () => { - const code = 'a'.repeat(51) - const pattern = /^[a-zA-Z0-9-]{3,50}$/ - - expect(pattern.test(code)).toBe(false) - }) - - test('special characters fail validation', () => { - const codes = ['abc@123', 'test!code', 'ref_123', 'code.com', 'test code'] - const pattern = /^[a-zA-Z0-9-]{3,50}$/ - - codes.forEach((code) => { - expect(pattern.test(code)).toBe(false) - }) - }) - - test('empty string fails validation', () => { - const code = '' - const pattern = /^[a-zA-Z0-9-]{3,50}$/ - - expect(pattern.test(code)).toBe(false) - }) - }) - - describe('referral code auto-prefixing', () => { - test('code without ref- prefix gets auto-prefixed', () => { - const userInput = 'abc123' - const referralCode = userInput.startsWith('ref-') - ? userInput - : `ref-${userInput}` - - expect(referralCode).toBe('ref-abc123') - }) - - test('code with ref- prefix stays unchanged', () => { - const userInput = 'ref-abc123' - const referralCode = userInput.startsWith('ref-') - ? userInput - : `ref-${userInput}` - - expect(referralCode).toBe('ref-abc123') - }) - - test('code with REF- (uppercase) gets normalized to lowercase prefix', () => { - const userInput = 'REF-abc123' - const userInputLower = userInput.toLowerCase() - // Normalize: case-insensitive prefix check, strip and re-add lowercase prefix - const referralCode = userInputLower.startsWith('ref-') - ? `ref-${userInput.slice(4)}` - : `ref-${userInput}` - - // Should strip REF- and re-add ref- to preserve the code portion - expect(referralCode).toBe('ref-abc123') - }) - - test('code with Ref- (mixed case) gets normalized to lowercase prefix', () => { - const userInput = 'Ref-XYZ789' - const userInputLower = userInput.toLowerCase() - const referralCode = userInputLower.startsWith('ref-') - ? `ref-${userInput.slice(4)}` - : `ref-${userInput}` - - expect(referralCode).toBe('ref-XYZ789') - }) - - test('code with rEf- (random case) gets normalized to lowercase prefix', () => { - const userInput = 'rEf-Code123' - const userInputLower = userInput.toLowerCase() - const referralCode = userInputLower.startsWith('ref-') - ? `ref-${userInput.slice(4)}` - : `ref-${userInput}` - - expect(referralCode).toBe('ref-Code123') - }) - - test('preserves code portion casing when normalizing prefix', () => { - // User typed "REF-ABC123" - should become "ref-ABC123", not "ref-abc123" - const userInput = 'REF-ABC123' - const userInputLower = userInput.toLowerCase() - const referralCode = userInputLower.startsWith('ref-') - ? `ref-${userInput.slice(4)}` - : `ref-${userInput}` - - expect(referralCode).toBe('ref-ABC123') - // Code portion should preserve original casing - expect(referralCode.slice(4)).toBe('ABC123') - }) - }) - - describe('referral mode input storage', () => { - test('input value is stored as-is without any prefix while in referral mode', () => { - const inputMode: InputMode = 'referral' - const inputValue = 'abc123' - - // The stored value should NOT have any prefix - expect(inputValue).toBe('abc123') - expect(inputValue).not.toContain('ref-') - expect(inputMode).toBe('referral') - }) - - test('user can type ref- prefix manually if desired', () => { - const inputMode: InputMode = 'referral' - const inputValue = 'ref-abc123' - - expect(inputValue).toBe('ref-abc123') - expect(inputMode).toBe('referral') - }) - }) - - describe('referral mode submission', () => { - test('submitting referral code adds ref- prefix if not present', () => { - const inputMode: InputMode = 'referral' - const trimmedInput = 'abc123' - - const referralCode = - inputMode === 'referral' - ? trimmedInput.startsWith('ref-') - ? trimmedInput - : `ref-${trimmedInput}` - : trimmedInput - - expect(referralCode).toBe('ref-abc123') - }) - - test('submitting referral code with ref- prefix keeps it', () => { - const inputMode: InputMode = 'referral' - const trimmedInput = 'ref-xyz789' - - const referralCode = - inputMode === 'referral' - ? trimmedInput.startsWith('ref-') - ? trimmedInput - : `ref-${trimmedInput}` - : trimmedInput - - expect(referralCode).toBe('ref-xyz789') - }) - - test('submission exits referral mode after processing', () => { - const setInputMode = mock((_mode) => {}) - - // After submission, referral mode should be exited - setInputMode('default') - - expect(setInputMode).toHaveBeenCalledWith('default') - }) - - test('invalid code shows error and exits referral mode', () => { - const setInputMode = mock((_mode) => {}) - const showError = mock((_msg: string) => {}) - const trimmedInput = 'ab' // Too short - const pattern = /^[a-zA-Z0-9-]{3,50}$/ - - if (!pattern.test(trimmedInput)) { - showError( - 'Invalid referral code format. Codes should be 3-50 alphanumeric characters.', - ) - setInputMode('default') - } - - expect(showError).toHaveBeenCalled() - expect(setInputMode).toHaveBeenCalledWith('default') - }) - }) - - describe('referral mode UI state', () => { - test('input mode is stored separately from input value', () => { - const state1 = { - inputMode: 'referral' as InputMode, - inputValue: 'abc123', - } - const state2 = { inputMode: 'default' as InputMode, inputValue: 'hello' } - - expect(state1.inputMode).toBe('referral') - expect(state1.inputValue).toBe('abc123') - - expect(state2.inputMode).toBe('default') - expect(state2.inputValue).toBe('hello') - }) - - test('input width is adjusted in referral mode for icon column', () => { - const referralConfig = getInputModeConfig('referral') - - expect(referralConfig.widthAdjustment).toBeGreaterThan(0) - }) - - test('input width is NOT adjusted when not in referral mode', () => { - const defaultConfig = getInputModeConfig('default') - - expect(defaultConfig.widthAdjustment).toBe(0) - }) - - test('placeholder changes in referral mode', () => { - const defaultConfig = getInputModeConfig('default') - const referralConfig = getInputModeConfig('referral') - - expect(referralConfig.placeholder).not.toBe(defaultConfig.placeholder) - }) - - test('referral mode has a placeholder', () => { - const referralConfig = getInputModeConfig('referral') - - expect(referralConfig.placeholder.length).toBeGreaterThan(0) - }) - - test('icon is displayed in referral mode', () => { - const referralConfig = getInputModeConfig('referral') - - expect(referralConfig.icon).not.toBeNull() - }) - - test('no icon is displayed in default mode', () => { - const defaultConfig = getInputModeConfig('default') - - expect(defaultConfig.icon).toBeNull() - }) - - test('border color changes to warning in referral mode', () => { - const referralConfig = getInputModeConfig('referral') - - expect(referralConfig.color).toBe('warning') - }) - - test('agent mode toggle is hidden in referral mode', () => { - const referralConfig = getInputModeConfig('referral') - - expect(referralConfig.showAgentModeToggle).toBe(false) - }) - - test('agent mode toggle is shown in default mode', () => { - const defaultConfig = getInputModeConfig('default') - - expect(defaultConfig.showAgentModeToggle).toBe(true) - }) - }) - - describe('edge cases', () => { - test('empty string is invalid referral code', () => { - const code = '' - const pattern = /^[a-zA-Z0-9-]{3,50}$/ - - expect(pattern.test(code)).toBe(false) - }) - - test('whitespace is trimmed before validation', () => { - const userInput = ' abc123 ' - const trimmed = userInput.trim() - const pattern = /^[a-zA-Z0-9-]{3,50}$/ - - expect(pattern.test(trimmed)).toBe(true) - }) - - test('only whitespace fails validation', () => { - const userInput = ' ' - const trimmed = userInput.trim() - const pattern = /^[a-zA-Z0-9-]{3,50}$/ - - expect(pattern.test(trimmed)).toBe(false) - }) - - test('mode can be entered, exited, and re-entered', () => { - let inputMode: InputMode = 'default' - - // Enter referral mode - inputMode = 'referral' - expect(inputMode).toBe('referral') - - // Exit referral mode - inputMode = 'default' - expect(inputMode).toBe('default') - - // Re-enter referral mode - inputMode = 'referral' - expect(inputMode).toBe('referral') - }) - - test('slash suggestions are disabled in referral mode', () => { - const referralConfig = getInputModeConfig('referral') - - expect(referralConfig.disableSlashSuggestions).toBe(true) - }) - }) - - describe('integration with command router', () => { - test('referral mode input is routed to handleReferralCode', () => { - const handleReferralCode = mock(async (_code: string) => {}) - const inputMode = 'referral' as InputMode - const trimmedInput = 'abc123' - - if (inputMode === 'referral') { - const referralCode = trimmedInput.startsWith('ref-') - ? trimmedInput - : `ref-${trimmedInput}` - handleReferralCode(referralCode) - } - - expect(handleReferralCode).toHaveBeenCalledWith('ref-abc123') - }) - - test('normal mode input is NOT routed to referral handler', () => { - const handleReferralCode = mock(async (_code: string) => {}) - const inputMode = 'default' as InputMode - const trimmedInput = 'abc123' - - if (inputMode === 'referral') { - handleReferralCode(`ref-${trimmedInput}`) - } - - expect(handleReferralCode).not.toHaveBeenCalled() - }) - - test('ref-XXXX input in default mode uses referral handler', () => { - const isReferralCode = (input: string) => { - return /^\/?ref-[a-zA-Z0-9-]{1,50}$/.test(input) - } - - const input1 = 'ref-abc123' - const input2 = '/ref-abc123' - const input3 = 'not-a-referral' - - expect(isReferralCode(input1)).toBe(true) - expect(isReferralCode(input2)).toBe(true) - expect(isReferralCode(input3)).toBe(false) - }) - }) - - describe('error handling', () => { - test('network error during redemption shows error message', async () => { - const showError = mock((_msg: string) => {}) - const handleReferralCode = mock(async (_code: string) => { - throw new Error('Network error') - }) - - try { - await handleReferralCode('ref-abc123') - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : 'Unknown error' - showError(`Error redeeming referral code: ${errorMessage}`) - } - - expect(showError).toHaveBeenCalledWith( - 'Error redeeming referral code: Network error', - ) - }) - - test('validation error prevents redemption attempt', () => { - const handleReferralCode = mock(async (_code: string) => {}) - const showError = mock((_msg: string) => {}) - const trimmedInput = '!@#' // Invalid characters - const pattern = /^[a-zA-Z0-9-]{3,50}$/ - - if (!pattern.test(trimmedInput)) { - showError( - 'Invalid referral code format. Codes should be 3-50 alphanumeric characters.', - ) - } else { - handleReferralCode(`ref-${trimmedInput}`) - } - - expect(showError).toHaveBeenCalled() - expect(handleReferralCode).not.toHaveBeenCalled() - }) - }) -}) diff --git a/cli/src/commands/__tests__/command-args.test.ts b/cli/src/commands/__tests__/command-args.test.ts index 63047c1230..f20a1d4810 100644 --- a/cli/src/commands/__tests__/command-args.test.ts +++ b/cli/src/commands/__tests__/command-args.test.ts @@ -176,7 +176,6 @@ describe('command factory pattern', () => { const expectedWithArgs = [ 'feedback', 'bash', - 'refer-friends', 'image', 'publish', 'new', diff --git a/cli/src/commands/__tests__/router-input.test.ts b/cli/src/commands/__tests__/router-input.test.ts index 653063abbc..c4589477b1 100644 --- a/cli/src/commands/__tests__/router-input.test.ts +++ b/cli/src/commands/__tests__/router-input.test.ts @@ -3,51 +3,12 @@ import { describe, test, expect } from 'bun:test' import { SLASH_COMMANDS } from '../../data/slash-commands' import { findCommand, COMMAND_REGISTRY } from '../command-registry' import { - normalizeInput, parseCommand, isSlashCommand, - isReferralCode, parseCommandInput, } from '../router-utils' describe('router-utils', () => { - describe('normalizeInput', () => { - test('strips leading slash from input', () => { - expect(normalizeInput('/help')).toBe('help') - expect(normalizeInput('/logout')).toBe('logout') - expect(normalizeInput('/ref-abc123')).toBe('ref-abc123') - }) - - test('preserves input without leading slash', () => { - expect(normalizeInput('help')).toBe('help') - expect(normalizeInput('ref-abc123')).toBe('ref-abc123') - expect(normalizeInput('some prompt text')).toBe('some prompt text') - }) - - test('handles empty string', () => { - expect(normalizeInput('')).toBe('') - }) - - test('handles only slash', () => { - expect(normalizeInput('/')).toBe('') - }) - - test('handles multiple slashes', () => { - expect(normalizeInput('//help')).toBe('/help') - expect(normalizeInput('///test')).toBe('//test') - }) - - test('preserves internal slashes', () => { - expect(normalizeInput('/path/to/file')).toBe('path/to/file') - expect(normalizeInput('path/to/file')).toBe('path/to/file') - }) - - test('preserves whitespace in input', () => { - expect(normalizeInput('/help me')).toBe('help me') - expect(normalizeInput('help me')).toBe('help me') - }) - }) - describe('isSlashCommand', () => { test('returns true for input starting with /', () => { expect(isSlashCommand('/help')).toBe(true) @@ -111,34 +72,6 @@ describe('router-utils', () => { }) }) - describe('isReferralCode', () => { - test('recognizes referral codes with slash prefix', () => { - expect(isReferralCode('/ref-abc123')).toBe(true) - expect(isReferralCode('/ref-XYZ')).toBe(true) - expect(isReferralCode('/ref-')).toBe(true) - }) - - test('recognizes referral codes without slash prefix', () => { - expect(isReferralCode('ref-abc123')).toBe(true) - expect(isReferralCode('ref-XYZ')).toBe(true) - expect(isReferralCode('ref-')).toBe(true) - }) - - test('rejects inputs that are not referral codes', () => { - expect(isReferralCode('reference')).toBe(false) - expect(isReferralCode('refund')).toBe(false) - expect(isReferralCode('/reference')).toBe(false) - expect(isReferralCode('ref abc')).toBe(false) - expect(isReferralCode('')).toBe(false) - }) - - test('is case-sensitive for ref- prefix', () => { - expect(isReferralCode('REF-abc')).toBe(false) - expect(isReferralCode('Ref-abc')).toBe(false) - expect(isReferralCode('/REF-abc')).toBe(false) - }) - }) - describe('parseCommandInput', () => { test('returns command info for exact slashless matches', () => { expect(parseCommandInput('init')).toEqual({ @@ -258,41 +191,6 @@ describe('router-utils', () => { } }) - describe('referral code detection with different input formats', () => { - const validCodes = [ - 'ref-abc123', - '/ref-abc123', - 'ref-TEST', - '/ref-TEST', - 'ref-12345', - '/ref-12345', - ] - - const invalidCodes = [ - 'reference', - '/reference', - 'refund-123', - '/refund-123', - 'REF-abc', - '/REF-abc', - 'ref abc', - '/ref abc', - '', - '/', - ] - - for (const code of validCodes) { - test(`recognizes "${code}" as valid referral code`, () => { - expect(isReferralCode(code)).toBe(true) - }) - } - - for (const code of invalidCodes) { - test(`rejects "${code}" as referral code`, () => { - expect(isReferralCode(code)).toBe(false) - }) - } - }) }) describe('command-registry', () => { diff --git a/cli/src/commands/command-registry.ts b/cli/src/commands/command-registry.ts index b44451f54a..8b6c431baf 100644 --- a/cli/src/commands/command-registry.ts +++ b/cli/src/commands/command-registry.ts @@ -8,9 +8,7 @@ import { useThemeStore } from '../hooks/use-theme' import { handleHelpCommand } from './help' import { handleImageCommand } from './image' import { handleInitializationFlowLocally } from './init' -import { handleReferralCode } from './referral' import { runBashCommand } from './router' -import { normalizeReferralCode } from './router-utils' import { handleUsageCommand } from './usage' import { WEBSITE_URL } from '../login/constants' import { useChatStore } from '../state/chat-store' @@ -169,7 +167,6 @@ const clearInput = (params: RouterParams) => { const FREEBUFF_REMOVED_COMMANDS = new Set([ 'ads:enable', 'ads:disable', - 'refer-friends', 'usage', 'subscribe', 'image', @@ -250,42 +247,6 @@ const ALL_COMMANDS: CommandDefinition[] = [ clearInput(params) }, }), - defineCommandWithArgs({ - name: 'refer-friends', - aliases: ['referral', 'redeem'], - handler: async (params, args) => { - const trimmedArgs = args.trim() - - // If user provided a code directly, redeem it immediately - if (trimmedArgs) { - const code = normalizeReferralCode(trimmedArgs) - try { - const { postUserMessage } = await handleReferralCode(code) - params.setMessages((prev) => [ - ...prev, - getUserMessage(params.inputValue.trim()), - ...postUserMessage([]), - ]) - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : 'Unknown error' - params.setMessages((prev) => [ - ...prev, - getUserMessage(params.inputValue.trim()), - getSystemMessage(`Error redeeming referral code: ${errorMessage}`), - ]) - } - params.saveToHistory(params.inputValue.trim()) - clearInput(params) - return - } - - // Otherwise enter referral mode - useChatStore.getState().setInputMode('referral') - params.saveToHistory(params.inputValue.trim()) - clearInput(params) - }, - }), defineCommand({ name: 'login', aliases: ['signin'], diff --git a/cli/src/commands/referral.ts b/cli/src/commands/referral.ts deleted file mode 100644 index 4f2067f0e8..0000000000 --- a/cli/src/commands/referral.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { env } from '@codebuff/common/env' -import { CREDITS_REFERRAL_BONUS } from '@codebuff/common/old-constants' - -import { getAuthToken } from '../utils/auth' -import { getApiClient, setApiClientAuthToken } from '../utils/codebuff-api' -import { logger } from '../utils/logger' -import { getSystemMessage } from '../utils/message-history' - -import type { PostUserMessageFn } from '../types/contracts/send-message' - -export async function handleReferralCode(referralCode: string): Promise<{ - postUserMessage: PostUserMessageFn -}> { - const authToken = getAuthToken() - - if (!authToken) { - const postUserMessage: PostUserMessageFn = (prev) => [ - ...prev, - getSystemMessage( - 'Please log in first to redeem a referral code. Use /login to authenticate.', - ), - ] - return { postUserMessage } - } - - setApiClientAuthToken(authToken) - const apiClient = getApiClient() - - try { - const response = await apiClient.referral({ referralCode }) - - if (!response.ok) { - const errorMessage = response.error ?? 'Failed to redeem referral code' - logger.error( - { - referralCode, - error: errorMessage, - }, - 'Error redeeming referral code', - ) - const postUserMessage: PostUserMessageFn = (prev) => [ - ...prev, - getSystemMessage(`Error: ${errorMessage}`), - ] - return { postUserMessage } - } - - const creditsRedeemed = - response.data?.credits_redeemed ?? CREDITS_REFERRAL_BONUS - const postUserMessage: PostUserMessageFn = (prev) => [ - ...prev, - getSystemMessage( - `🎉 Noice, you've earned an extra ${creditsRedeemed} credits!\n\n` + - `(pssst: you can also refer new users and earn ${CREDITS_REFERRAL_BONUS} credits for each referral at: ${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/referrals)`, - ), - ] - return { postUserMessage } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - logger.error( - { - referralCode, - error: errorMessage, - }, - 'Error redeeming referral code', - ) - const postUserMessage: PostUserMessageFn = (prev) => [ - ...prev, - getSystemMessage(`Error redeeming referral code: ${errorMessage}`), - ] - return { postUserMessage } - } -} diff --git a/cli/src/commands/router-utils.ts b/cli/src/commands/router-utils.ts index 02a3341c27..069b22304b 100644 --- a/cli/src/commands/router-utils.ts +++ b/cli/src/commands/router-utils.ts @@ -1,25 +1,11 @@ import { SLASHLESS_COMMAND_IDS } from '../data/slash-commands' -/** - * Normalize user input by stripping the leading slash if present. - * This is used for referral codes which work with or without the slash. - * - * @example - * normalizeInput('/help') // => 'help' - * normalizeInput('help') // => 'help' - * normalizeInput('/ref-abc123') // => 'ref-abc123' - */ -export function normalizeInput(input: string): string { - return input.startsWith('/') ? input.slice(1) : input -} - /** * Check if the input is a slash command (starts with '/'). * * @example * isSlashCommand('/help') // => true * isSlashCommand('help') // => false - * isSlashCommand('/ref-abc123') // => true */ export function isSlashCommand(input: string): boolean { return input.trim().startsWith('/') @@ -47,54 +33,6 @@ export function parseCommand(input: string): string { return firstWord.toLowerCase() } -/** - * Check if the input is a referral code (starts with 'ref-'). - * Works with or without the leading slash. - * - * @example - * isReferralCode('ref-abc123') // => true - * isReferralCode('/ref-abc123') // => true - * isReferralCode('reference') // => false - */ -export function isReferralCode(input: string): boolean { - const normalized = normalizeInput(input.trim()) - return normalized.startsWith('ref-') -} - -/** - * Extract the referral code from user input. - * Returns the normalized code without the leading slash. - * - * @example - * extractReferralCode('/ref-abc123') // => 'ref-abc123' - * extractReferralCode('ref-abc123') // => 'ref-abc123' - */ -export function extractReferralCode(input: string): string { - return normalizeInput(input.trim()) -} - -const REFERRAL_PREFIX = 'ref-' - -/** - * Normalize a referral code by ensuring it has the lowercase 'ref-' prefix. - * Handles case-insensitive prefix detection (REF-, Ref-, etc.) and preserves - * the original casing of the code portion. - * - * @example - * normalizeReferralCode('abc123') // => 'ref-abc123' - * normalizeReferralCode('ref-abc123') // => 'ref-abc123' - * normalizeReferralCode('REF-ABC123') // => 'ref-ABC123' - * normalizeReferralCode('Ref-XYZ') // => 'ref-XYZ' - */ -export function normalizeReferralCode(code: string): string { - const trimmed = code.trim() - const hasPrefix = trimmed.toLowerCase().startsWith(REFERRAL_PREFIX) - const codeWithoutPrefix = hasPrefix - ? trimmed.slice(REFERRAL_PREFIX.length) - : trimmed - return `${REFERRAL_PREFIX}${codeWithoutPrefix}` -} - /** * Result of parsing a command-like input. */ diff --git a/cli/src/commands/router.ts b/cli/src/commands/router.ts index b0c8b9915c..7a67988459 100644 --- a/cli/src/commands/router.ts +++ b/cli/src/commands/router.ts @@ -9,12 +9,8 @@ import { type RouterParams, type CommandResult, } from './command-registry' -import { handleReferralCode } from './referral' import { isSlashCommand, - isReferralCode, - extractReferralCode, - normalizeReferralCode, parseCommandInput, } from './router-utils' import { handleClaudeAuthCode } from '../components/claude-connect-banner' @@ -435,70 +431,6 @@ export async function routeUserPrompt( return } - // Handle referral mode input - if (inputMode === 'referral') { - // Validate the referral code (3-50 alphanumeric chars with optional dashes) - const codePattern = /^[a-zA-Z0-9-]{3,50}$/ - // Strip prefix if present for validation (case-insensitive) - const codeWithoutPrefix = trimmed.toLowerCase().startsWith('ref-') - ? trimmed.slice(4) - : trimmed - - if (!codePattern.test(codeWithoutPrefix)) { - setMessages((prev) => [ - ...prev, - getUserMessage(trimmed), - getSystemMessage( - 'Invalid referral code format. Codes should be 3-50 alphanumeric characters.', - ), - ]) - saveToHistory(trimmed) - setInputValue({ text: '', cursorPosition: 0, lastEditDueToNav: false }) - setInputMode('default') - return - } - - const referralCode = normalizeReferralCode(trimmed) - try { - const { postUserMessage: referralPostMessage } = - await handleReferralCode(referralCode) - setMessages((prev) => [ - ...prev, - getUserMessage(trimmed), - ...referralPostMessage([]), - ]) - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : 'Unknown error' - setMessages((prev) => [ - ...prev, - getUserMessage(trimmed), - getSystemMessage(`Error redeeming referral code: ${errorMessage}`), - ]) - } - saveToHistory(trimmed) - setInputValue({ text: '', cursorPosition: 0, lastEditDueToNav: false }) - setInputMode('default') - - return - } - - // Handle referral codes (ref-XXXX format) - // Works with or without leading slash: "ref-123" or "/ref-123" - if (isReferralCode(trimmed)) { - const referralCode = extractReferralCode(trimmed) - const { postUserMessage: referralPostMessage } = - await handleReferralCode(referralCode) - setMessages((prev) => [ - ...prev, - getUserMessage(trimmed), - ...referralPostMessage([]), - ]) - saveToHistory(trimmed) - setInputValue({ text: '', cursorPosition: 0, lastEditDueToNav: false }) - return - } - // Handle slash commands or configured slashless exact commands. const parsedCommand = parseCommandInput(trimmed) if (parsedCommand) { diff --git a/cli/src/components/chat-input-bar.tsx b/cli/src/components/chat-input-bar.tsx index 5241d558f2..cee0a296eb 100644 --- a/cli/src/components/chat-input-bar.tsx +++ b/cli/src/components/chat-input-bar.tsx @@ -199,11 +199,6 @@ export const ChatInputBar = ({ return } - // Referral mode: show only the referral banner (no input box) - if (inputMode === 'referral') { - return - } - // ChatGPT connect mode: show only the connect panel (no input box) if (inputMode === 'connect:chatgpt') { return diff --git a/cli/src/components/input-mode-banner.tsx b/cli/src/components/input-mode-banner.tsx index 66335245ba..be0d2df8ca 100644 --- a/cli/src/components/input-mode-banner.tsx +++ b/cli/src/components/input-mode-banner.tsx @@ -7,7 +7,6 @@ import { ChatGptConnectBanner } from './chatgpt-connect-banner' import { ClaudeConnectBanner } from './claude-connect-banner' import { HelpBanner } from './help-banner' import { PendingAttachmentsBanner } from './pending-attachments-banner' -import { ReferralBanner } from './referral-banner' import { SubscriptionLimitBanner } from './subscription-limit-banner' import { UsageBanner } from './usage-banner' import { useChatStore } from '../state/chat-store' @@ -28,7 +27,6 @@ const BANNER_REGISTRY: Record< default: () => , image: () => , ...(IS_FREEBUFF ? {} : { usage: ({ showTime }: { showTime: number }) => }), - ...(IS_FREEBUFF ? {} : { referral: () => }), help: () => , ...(CLAUDE_OAUTH_ENABLED && !IS_FREEBUFF ? { 'connect:claude': () => } diff --git a/cli/src/components/referral-banner.tsx b/cli/src/components/referral-banner.tsx deleted file mode 100644 index e46c0272e9..0000000000 --- a/cli/src/components/referral-banner.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { CREDITS_REFERRAL_BONUS } from '@codebuff/common/old-constants' -import { WEBSITE_URL } from '@codebuff/sdk' -import { useQuery } from '@tanstack/react-query' -import React, { useState } from 'react' - -import { BottomBanner } from './bottom-banner' -import { Button } from './button' -import { useChatStore } from '../state/chat-store' -import { useTheme } from '../hooks/use-theme' -import { useTimeout } from '../hooks/use-timeout' -import { getAuthToken } from '../utils/auth' -import { getApiClient } from '../utils/codebuff-api' -import { copyTextToClipboard } from '../utils/clipboard' -import { BORDER_CHARS } from '../utils/ui-constants' - -interface ReferralData { - referralCode: string - referrals: { id: string }[] - referralLimit: number -} - -export const ReferralBanner = () => { - const setInputMode = useChatStore((state) => state.setInputMode) - const theme = useTheme() - const [isHovered, setIsHovered] = useState(false) - const [isCopied, setIsCopied] = useState(false) - const { setTimeout } = useTimeout() - const authToken = getAuthToken() - - const { data: referralData } = useQuery({ - queryKey: ['referrals'], - queryFn: async () => { - const client = getApiClient() - const response = await client.get('/api/referrals', { - includeCookie: true, - }) - if (!response.ok) { - throw new Error(`Failed to fetch referral data: ${response.status}`) - } - return response.data! - }, - enabled: !!authToken, - staleTime: 5 * 60 * 1000, - retry: false, - }) - - const referralCode = referralData?.referralCode ?? null - const referralLink = referralCode ? `${WEBSITE_URL}/referrals/${referralCode}` : null - const referralCount = referralData?.referrals.length ?? null - const referralLimit = referralData?.referralLimit ?? null - - const handleCopy = async () => { - if (!referralLink) return - try { - await copyTextToClipboard(referralLink, { suppressGlobalMessage: true }) - setIsCopied(true) - setTimeout('reset-copied', () => setIsCopied(false), 2000) - } catch { - // Error is already logged and displayed by copyTextToClipboard - } - } - - const copyLabel = isCopied ? '✔ Copied!' : '⎘ Copy referral link' - - return ( - setInputMode('default')} - > - - - {`Share this link with friends and you'll both earn ${CREDITS_REFERRAL_BONUS} credits`} - - - {referralCount !== null && referralLimit !== null && ( - - {`You've referred ${referralCount}/${referralLimit} people`} - - )} - - {referralLink ? ( - - {referralLink} - - - - - ) : ( - Loading referral link... - )} - - - ) -} diff --git a/cli/src/data/slash-commands.ts b/cli/src/data/slash-commands.ts index 4550895846..bd67811d32 100644 --- a/cli/src/data/slash-commands.ts +++ b/cli/src/data/slash-commands.ts @@ -2,7 +2,6 @@ import { CHATGPT_OAUTH_ENABLED } from '@codebuff/common/constants/chatgpt-oauth' import { CLAUDE_OAUTH_ENABLED } from '@codebuff/common/constants/claude-oauth' import { AGENT_MODES, IS_FREEBUFF } from '../utils/constants' import { getChatGptOAuthStatus } from '../utils/chatgpt-oauth' -import { CREDITS_REFERRAL_BONUS } from '@codebuff/common/old-constants' import type { SkillsMap } from '@codebuff/common/types/skill' @@ -37,7 +36,6 @@ const FREEBUFF_REMOVED_COMMAND_IDS = new Set([ 'connect:claude', 'ads:enable', 'ads:disable', - 'refer-friends', 'usage', 'subscribe', 'agent:gpt-5', @@ -90,12 +88,6 @@ const ALL_SLASH_COMMANDS: SlashCommand[] = [ label: 'ads:disable', description: 'Disable contextual ads', }, - { - id: 'refer-friends', - label: 'refer-friends', - description: `Refer friends for ${CREDITS_REFERRAL_BONUS} bonus credits each`, - aliases: ['referral'], - }, { id: 'init', label: 'init', diff --git a/cli/src/hooks/__tests__/use-user-details-query.test.ts b/cli/src/hooks/__tests__/use-user-details-query.test.ts index 77530dc01d..1dcdaae4e5 100644 --- a/cli/src/hooks/__tests__/use-user-details-query.test.ts +++ b/cli/src/hooks/__tests__/use-user-details-query.test.ts @@ -162,29 +162,6 @@ describe('fetchUserDetails', () => { expect(result).toEqual(mockUserDetails) }) - test('returns null referral_code when not set', async () => { - const mockUserDetails = { - referral_code: null, - } - - const meMock = mock(() => - Promise.resolve({ - ok: true, - status: 200, - data: mockUserDetails, - }), - ) - const apiClient = createMockApiClient({ me: meMock }) - - const result = await fetchUserDetails({ - authToken: 'valid-token', - fields: ['referral_code'] as const, - logger: mockLogger, - apiClient, - }) - - expect(result?.referral_code).toBe(null) - }) }) describe('environment validation', () => { diff --git a/cli/src/hooks/use-chat-keyboard.ts b/cli/src/hooks/use-chat-keyboard.ts index a7ef9feb2f..a2cc87daf9 100644 --- a/cli/src/hooks/use-chat-keyboard.ts +++ b/cli/src/hooks/use-chat-keyboard.ts @@ -276,7 +276,7 @@ function dispatchAction( * Integrates priority-based action resolution with handlers. * * This hook handles: - * - Mode switching (bash, referral, etc.) + * - Mode switching (bash, etc.) * - Stream interruption * - Suggestion menu navigation (slash and mention menus) * - History navigation diff --git a/cli/src/utils/__tests__/fetch-usage.test.ts b/cli/src/utils/__tests__/fetch-usage.test.ts index d7a0c854c9..1b2e68f6e6 100644 --- a/cli/src/utils/__tests__/fetch-usage.test.ts +++ b/cli/src/utils/__tests__/fetch-usage.test.ts @@ -44,9 +44,6 @@ describe('fetchAndUpdateUsage (deprecated)', () => { loginStatus: mock(() => Promise.resolve({ ok: true, status: 200, data: {} }), ) as CodebuffApiClient['loginStatus'], - referral: mock(() => - Promise.resolve({ ok: true, status: 200, data: {} }), - ) as CodebuffApiClient['referral'], publish: mock(() => Promise.resolve({ ok: true, status: 200, data: {} }), ) as CodebuffApiClient['publish'], diff --git a/cli/src/utils/__tests__/keyboard-actions.test.ts b/cli/src/utils/__tests__/keyboard-actions.test.ts index 75332053dc..c518b47ea7 100644 --- a/cli/src/utils/__tests__/keyboard-actions.test.ts +++ b/cli/src/utils/__tests__/keyboard-actions.test.ts @@ -54,17 +54,6 @@ describe('resolveChatKeyboardAction', () => { }) }) - test('escape in referral mode exits mode even while streaming', () => { - const state: ChatKeyboardState = { - ...defaultState, - inputMode: 'referral', - isStreaming: true, - } - expect(resolveChatKeyboardAction(escapeKey, state)).toEqual({ - type: 'exit-input-mode', - }) - }) - test('escape in usage mode exits mode', () => { const state: ChatKeyboardState = { ...defaultState, diff --git a/cli/src/utils/codebuff-api.ts b/cli/src/utils/codebuff-api.ts index f4266af029..75a14c6598 100644 --- a/cli/src/utils/codebuff-api.ts +++ b/cli/src/utils/codebuff-api.ts @@ -20,10 +20,10 @@ export type ApiResponse = // ============================================================================ /** User fields that can be fetched from /api/v1/me */ -export type UserField = 'id' | 'email' | 'discord_id' | 'referral_code' +export type UserField = 'id' | 'email' | 'discord_id' export type UserDetails = { - [K in T]: K extends 'discord_id' | 'referral_code' ? string | null : string + [K in T]: K extends 'discord_id' ? string | null : string } export interface UsageRequest { @@ -58,15 +58,6 @@ export interface LoginStatusResponse { user?: Record } -export interface ReferralRequest { - referralCode: string -} - -export interface ReferralResponse { - credits_redeemed?: number - error?: string -} - export interface LogoutRequest { userId?: string fingerprintId?: string @@ -191,9 +182,6 @@ export interface CodebuffApiClient { req: LoginStatusRequest, ): Promise> - /** Redeem a referral code via /api/referrals */ - referral(req: ReferralRequest): Promise> - /** Publish agents via /api/agents/publish */ publish( data: Record[], @@ -496,17 +484,6 @@ export function createCodebuffApiClient( }) }, - referral(req: ReferralRequest): Promise> { - // Auth is sent via Authorization header (includeAuth defaults to true) - // Also include cookie for legacy web session support - return request( - 'POST', - '/api/referrals', - { referralCode: req.referralCode }, - { includeCookie: true }, - ) - }, - publish( data: Record[], allLocalAgentIds?: string[], diff --git a/cli/src/utils/input-modes.ts b/cli/src/utils/input-modes.ts index 3b96ded5bf..2c6d921948 100644 --- a/cli/src/utils/input-modes.ts +++ b/cli/src/utils/input-modes.ts @@ -12,7 +12,6 @@ export type InputMode = | 'plan' | 'review' | 'interview' - | 'referral' | 'usage' | 'image' | 'help' @@ -113,16 +112,6 @@ export const INPUT_MODE_CONFIGS: Record = { disableSlashSuggestions: true, blockKeyboardExit: false, }, - referral: { - icon: '◎', - label: null, - color: 'warning', - placeholder: 'have a code? enter it here', - widthAdjustment: 2, // 1 char + 1 padding - showAgentModeToggle: false, - disableSlashSuggestions: true, - blockKeyboardExit: false, - }, usage: { icon: null, label: null, diff --git a/common/src/constants/analytics-events.ts b/common/src/constants/analytics-events.ts index acbcd190e8..51660a9034 100644 --- a/common/src/constants/analytics-events.ts +++ b/common/src/constants/analytics-events.ts @@ -45,7 +45,6 @@ export enum AnalyticsEvent { // Web - Authentication AUTH_LOGIN_STARTED = 'auth.login_started', - AUTH_REFERRAL_GITHUB_LOGIN_STARTED = 'auth.referral_github_login_started', AUTH_LOGOUT_COMPLETED = 'auth.logout_completed', // Web - Cookie Consent @@ -87,7 +86,6 @@ export enum AnalyticsEvent { // Web - UI Components TOAST_SHOWN = 'toast.shown', - REFERRAL_BANNER_CLICKED = 'referral_banner.clicked', // Web - API AGENT_RUN_API_REQUEST = 'api.agent_run_request', @@ -147,7 +145,7 @@ export enum AnalyticsEvent { CHATGPT_OAUTH_RATE_LIMITED = 'sdk.chatgpt_oauth_rate_limited', CHATGPT_OAUTH_AUTH_ERROR = 'sdk.chatgpt_oauth_auth_error', - // Freebuff - Referral Attribution + // Freebuff - Creator Attribution FREEBUFF_REFERRER_ATTRIBUTED = 'freebuff.referrer_attributed', // Freebuff - Get Started Page diff --git a/common/src/constants/limits.ts b/common/src/constants/limits.ts index e887c16aa7..515eaa4adc 100644 --- a/common/src/constants/limits.ts +++ b/common/src/constants/limits.ts @@ -5,14 +5,6 @@ export const MAX_DATE = new Date(86399999999999) export const BILLING_PERIOD_DAYS = 30 export const SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60 // 30 days export const SESSION_TIME_WINDOW_MS = 30 * 60 * 1000 // 30 minutes - used for matching sessions created around fingerprint creation -// Referral credits disabled 2026-04-17: setting bonus to 0 stops new referral credit grants -// without removing the referral-tracking records. See scripts/opus-or-bleed.ts for the -// abuse pattern that motivated this (self-referral rings farming 1000 free credits per -// signup and burning them on Opus). Development focus is shifting to freebuff which has -// no credit system, so we don't need this growth lever going forward. -export const CREDITS_REFERRAL_BONUS = 0 -export const AFFILIATE_USER_REFFERAL_LIMIT = 500 - // Default number of free credits granted per cycle export const DEFAULT_FREE_CREDITS_GRANT = 500 diff --git a/common/src/testing/fixtures/agent-runtime.ts b/common/src/testing/fixtures/agent-runtime.ts index 75c555de86..f4d1430127 100644 --- a/common/src/testing/fixtures/agent-runtime.ts +++ b/common/src/testing/fixtures/agent-runtime.ts @@ -111,7 +111,6 @@ export const TEST_AGENT_RUNTIME_IMPL = Object.freeze({ id: 'test-user-id', email: 'test@example.com', discord_id: 'test-discord-id', - referral_code: 'ref-test-code', stripe_customer_id: null, banned: false, created_at: new Date('2024-01-01T00:00:00Z'), diff --git a/common/src/types/contracts/database.ts b/common/src/types/contracts/database.ts index d95ba17d84..88685c7205 100644 --- a/common/src/types/contracts/database.ts +++ b/common/src/types/contracts/database.ts @@ -5,7 +5,6 @@ type User = { id: string email: string discord_id: string | null - referral_code: string | null stripe_customer_id: string | null banned: boolean created_at: Date @@ -14,7 +13,6 @@ export const userColumns = [ 'id', 'email', 'discord_id', - 'referral_code', 'stripe_customer_id', 'banned', 'created_at', diff --git a/common/src/util/referral.ts b/common/src/util/referral.ts deleted file mode 100644 index 940ba4a10f..0000000000 --- a/common/src/util/referral.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { env } from '@codebuff/common/env' - -export const getReferralLink = (referralCode: string): string => - `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/referrals/${referralCode}` diff --git a/freebuff/web/src/app/api/auth/cli/code/route.ts b/freebuff/web/src/app/api/auth/cli/code/route.ts index 8dcbca2e5c..ac7ac073c6 100644 --- a/freebuff/web/src/app/api/auth/cli/code/route.ts +++ b/freebuff/web/src/app/api/auth/cli/code/route.ts @@ -11,7 +11,6 @@ import { logger } from '@/util/logger' export async function POST(req: Request) { const reqSchema = z.object({ fingerprintId: z.string(), - referralCode: z.string().optional(), }) const requestBody = await req.json() const result = reqSchema.safeParse(requestBody) @@ -19,7 +18,7 @@ export async function POST(req: Request) { return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) } - const { fingerprintId, referralCode } = result.data + const { fingerprintId } = result.data try { const expiresAt = Date.now() + 60 * 60 * 1000 // 1 hour @@ -54,9 +53,7 @@ export async function POST(req: Request) { ) } - const loginUrl = `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/login?auth_code=${fingerprintId}.${expiresAt}.${fingerprintHash}${ - referralCode ? `&referral_code=${referralCode}` : '' - }` + const loginUrl = `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/login?auth_code=${fingerprintId}.${expiresAt}.${fingerprintHash}` return NextResponse.json({ fingerprintId, diff --git a/freebuff/web/src/app/onboard/page.tsx b/freebuff/web/src/app/onboard/page.tsx index 558d715635..84cdf29455 100644 --- a/freebuff/web/src/app/onboard/page.tsx +++ b/freebuff/web/src/app/onboard/page.tsx @@ -23,10 +23,16 @@ import { } from '@/components/ui/card' import { logger } from '@/util/logger' +function normalizeReferrer(raw: string | undefined): string | null { + if (!raw) return null + const trimmed = raw.trim().slice(0, 50) + return trimmed || null +} + interface PageProps { searchParams?: Promise<{ auth_code?: string - referral_code?: string + referrer?: string }> } @@ -60,19 +66,28 @@ function StatusCard({ const Onboard = async ({ searchParams }: PageProps) => { const resolvedSearchParams = searchParams ? await searchParams : {} const authCode = resolvedSearchParams.auth_code - const referralCode = resolvedSearchParams.referral_code + const referrerName = normalizeReferrer(resolvedSearchParams.referrer) const session = await getServerSession(authOptions) const user = session?.user if (!user) { - return redirect('/login') + const params = new URLSearchParams() + if (authCode) params.set('auth_code', authCode) + if (referrerName) params.set('referrer', referrerName) + const query = params.toString() + const dest = authCode ? '/login' : '/get-started' + return redirect(query ? `${dest}?${query}` : dest) } if (!authCode) { return ( ) diff --git a/freebuff/web/src/components/login/login-card.tsx b/freebuff/web/src/components/login/login-card.tsx index a539ea44ff..1236ade938 100644 --- a/freebuff/web/src/components/login/login-card.tsx +++ b/freebuff/web/src/components/login/login-card.tsx @@ -20,13 +20,10 @@ export function LoginCard({ authCode }: { authCode?: string | null }) { const searchParams = useSearchParams() ?? new URLSearchParams() const handleContinueAsUser = () => { - const referralCode = searchParams.get('referral_code') let callbackUrl = '/' if (authCode) { callbackUrl = `/onboard?${searchParams.toString()}` - } else if (referralCode) { - callbackUrl = `/onboard?referral_code=${referralCode}` } window.location.href = callbackUrl @@ -34,14 +31,10 @@ export function LoginCard({ authCode }: { authCode?: string | null }) { const handleUseAnotherAccount = () => { const searchParamsString = searchParams.toString() - const referralCode = searchParams.get('referral_code') let callbackUrl = '/login' if (authCode) { callbackUrl = `/onboard?${searchParamsString}` - } else if (referralCode) { - callbackUrl = `/onboard?referral_code=${referralCode}` - localStorage.setItem('referral_code', referralCode) } signIn('github', { callbackUrl, prompt: 'login' }) diff --git a/freebuff/web/src/components/sign-in/sign-in-button.tsx b/freebuff/web/src/components/sign-in/sign-in-button.tsx index a2d652fa7c..e5c31c246a 100644 --- a/freebuff/web/src/components/sign-in/sign-in-button.tsx +++ b/freebuff/web/src/components/sign-in/sign-in-button.tsx @@ -28,13 +28,9 @@ export function SignInButton({ if (pathname === '/login') { const authCode = searchParams.get('auth_code') - const referralCode = searchParams.get('referral_code') if (authCode) { callbackUrl = `/onboard?${searchParams.toString()}` - } else if (referralCode) { - localStorage.setItem('referral_code', referralCode) - callbackUrl = `${window.location.origin}/onboard?referral_code=${referralCode}` } else { callbackUrl = '/' } diff --git a/web/knowledge.md b/web/knowledge.md index f1316ec790..63dff2da40 100644 --- a/web/knowledge.md +++ b/web/knowledge.md @@ -92,22 +92,6 @@ Key files: - Store user_id as property for internal reference - Track events with consistent naming: `category.event_name` -## Referral System - -### Workflow - -1. Users get unique referral codes upon account creation -2. Share referral links: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/redeem?referral_code=${referralCode}` -3. New users redeem codes during signup/onboarding -4. Both referrer and referred user receive `CREDITS_REFERRAL_BONUS` credits -5. Referrals tracked in database with limits - -### Key Components - -- `web/src/app/referrals/page.tsx`: Main referrals UI -- `web/src/app/api/referrals/route.ts`: API operations -- `web/src/app/onboard/page.tsx`: Referral code processing - ## Verifying Changes After changes, run type checking: diff --git a/web/src/__tests__/e2e/redirects.spec.ts b/web/src/__tests__/e2e/redirects.spec.ts index 7f119f5990..a2c2065d50 100644 --- a/web/src/__tests__/e2e/redirects.spec.ts +++ b/web/src/__tests__/e2e/redirects.spec.ts @@ -71,80 +71,5 @@ if (isBun) { }) }) - test.describe('Sponsee (affiliate link) redirect', () => { - test('shows error page for unknown sponsee', async ({ page }) => { - await page.goto('/unknown-sponsee-name-12345') - - // Should show the error message for unknown sponsee - await expect( - page.getByText("that link doesn't look right", { exact: false }), - ).toBeVisible() - await expect( - page.getByText('unknown-sponsee-name-12345', { exact: false }), - ).toBeVisible() - }) - - test('error page includes support email link', async ({ page }) => { - await page.goto('/nonexistent-referrer') - - // Should have a link to support email - const supportLink = page.locator('a[href^="mailto:"]') - await expect(supportLink).toBeVisible() - }) - - // Note: Testing the happy path (successful redirect with query param preservation) - // requires a valid sponsee in the database. This test documents the expected behavior - // and can be run against a seeded test database. - test.describe('with seeded database', { tag: '@seeded-db' }, () => { - test.skip( - () => !process.env.E2E_TEST_SPONSEE, - 'Requires E2E_TEST_SPONSEE env var with a valid sponsee handle', - ) - - test('preserves query parameters when redirecting to referral page', async ({ - request, - }) => { - const sponsee = process.env.E2E_TEST_SPONSEE! - const response = await request.get( - `/${sponsee}?utm_source=twitter&utm_campaign=test&custom=value`, - { - maxRedirects: 0, - }, - ) - - // Should redirect to /referrals/ - expect(response.status()).toBe(307) - const location = response.headers()['location'] - expect(location).toMatch(/^\/referrals\//) - - // Query params should be preserved - expect(location).toContain('utm_source=twitter') - expect(location).toContain('utm_campaign=test') - expect(location).toContain('custom=value') - - // Referrer param should be added - expect(location).toContain(`referrer=${sponsee}`) - }) - - test('referrer param overrides existing referrer in query', async ({ - request, - }) => { - const sponsee = process.env.E2E_TEST_SPONSEE! - const response = await request.get( - `/${sponsee}?referrer=should-be-overridden`, - { - maxRedirects: 0, - }, - ) - - expect(response.status()).toBe(307) - const location = response.headers()['location'] - - // The referrer should be the sponsee name, not the original value - expect(location).toContain(`referrer=${sponsee}`) - expect(location).not.toContain('should-be-overridden') - }) - }) - }) }) } diff --git a/web/src/app/[sponsee]/page.tsx b/web/src/app/[sponsee]/page.tsx deleted file mode 100644 index 2c74d14e5a..0000000000 --- a/web/src/app/[sponsee]/page.tsx +++ /dev/null @@ -1,88 +0,0 @@ -'use server' - -import { env } from '@codebuff/common/env' -import db from '@codebuff/internal/db' -import * as schema from '@codebuff/internal/db/schema' -import { eq } from 'drizzle-orm' -import Link from 'next/link' -import { redirect } from 'next/navigation' - -import type { Metadata } from 'next' - -import CardWithBeams from '@/components/card-with-beams' - -export const generateMetadata = async ({ - params, -}: { - params: Promise<{ sponsee: string }> -}): Promise => { - const { sponsee } = await params - return { - title: `${sponsee}'s Referral | Codebuff`, - } -} - -export default async function SponseePage({ - params, - searchParams, -}: { - params: Promise<{ sponsee: string }> - searchParams: Promise> -}) { - const { sponsee } = await params - const resolvedSearchParams = await searchParams - const sponseeName = sponsee.toLowerCase() - - const referralCode = await db - .select({ - referralCode: schema.user.referral_code, - }) - .from(schema.user) - .where(eq(schema.user.handle, sponseeName)) - .limit(1) - .then((result) => result[0]?.referralCode ?? null) - - if (!referralCode) { - return ( - -

- Please double-check the link you used or try contacting the person - who shared it. -

-

- You can also reach out to our support team at{' '} - - {env.NEXT_PUBLIC_SUPPORT_EMAIL} - - . -

- - } - /> - ) - } - - // Build query string preserving all incoming params and adding/overriding referrer - const queryParams = new URLSearchParams() - for (const [key, value] of Object.entries(resolvedSearchParams)) { - if (value !== undefined) { - if (Array.isArray(value)) { - for (const v of value) { - queryParams.append(key, v) - } - } else { - queryParams.set(key, value) - } - } - } - queryParams.set('referrer', sponseeName) - - redirect(`/referrals/${referralCode}?${queryParams.toString()}`) -} diff --git a/web/src/app/affiliates/actions.ts b/web/src/app/affiliates/actions.ts deleted file mode 100644 index d27c3d84b1..0000000000 --- a/web/src/app/affiliates/actions.ts +++ /dev/null @@ -1,135 +0,0 @@ -'use server' - -import { AFFILIATE_USER_REFFERAL_LIMIT } from '@codebuff/common/old-constants' -import db from '@codebuff/internal/db' -import * as schema from '@codebuff/internal/db/schema' -import { eq, and, ne } from 'drizzle-orm' -import { revalidatePath } from 'next/cache' -import { getServerSession } from 'next-auth' -import { z } from 'zod/v4' - -import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options' - -const RESERVED_HANDLES = [ - 'api', - 'docs', - 'hackathon', - 'login', - 'onboard', - 'payment-change', - 'payment-success', - 'pricing', - 'privacy-policy', - 'referrals', - 'subscription', - 'terms-of-service', - 'usage', - 'affiliates', - 'discord', - 'ingest', - 'admin', - 'auth', - 'user', - 'profile', - 'settings', - 'support', - 'help', - 'contact', - 'root', - 'codebuff', - 'manicode', - 'status', - 'healthz', -].map((h) => h.toLowerCase()) - -const HandleSchema = z - .string() - .min(3, 'Handle must be at least 3 characters long.') - .max(20, 'Handle cannot be longer than 20 characters.') - .regex( - /^[a-zA-Z0-9_]+$/, - 'Handle can only contain letters, numbers, and underscores.', - ) - .transform((str) => str.toLowerCase()) - .refine((handle) => !RESERVED_HANDLES.includes(handle), { - message: 'This handle is reserved and cannot be used.', - }) - -export interface SetHandleFormState { - message: string - success: boolean - fieldErrors?: { - handle?: string[] - } -} - -export async function setAffiliateHandleAction( - prevState: SetHandleFormState, - formData: FormData, -): Promise { - const session = await getServerSession(authOptions) - - if (!session?.user?.id) { - return { success: false, message: 'Authentication required.' } - } - - const userId = session.user.id - const handleResult = HandleSchema.safeParse(formData.get('handle')) - - if (!handleResult.success) { - const formErrors = handleResult.error.flatten().formErrors - const message = - formErrors.find((err) => err.includes('reserved')) || - formErrors[0] || - 'Invalid handle format.' - return { - success: false, - message: message, - fieldErrors: { handle: formErrors }, - } - } - - const desiredHandle = handleResult.data - - try { - const currentUser = await db.query.user.findFirst({ - where: eq(schema.user.id, userId), - columns: { handle: true }, - }) - - if (currentUser?.handle) { - return { success: false, message: 'You already have a handle set.' } - } - - const existingUser = await db.query.user.findFirst({ - where: and( - eq(schema.user.handle, desiredHandle), - ne(schema.user.id, userId), - ), - columns: { id: true }, - }) - - if (existingUser) { - return { - success: false, - message: `Handle "${desiredHandle}" is already taken. Please choose another.`, - fieldErrors: { handle: ['This handle is already taken.'] }, - } - } - - await db - .update(schema.user) - .set({ - handle: desiredHandle, - referral_limit: AFFILIATE_USER_REFFERAL_LIMIT, - }) - .where(eq(schema.user.id, userId)) - - revalidatePath('/affiliates') - - return { success: true, message: 'Handle set successfully!' } - } catch (error) { - console.error('Error setting affiliate handle:', error) - return { success: false, message: 'An unexpected error occurred.' } - } -} diff --git a/web/src/app/affiliates/affiliates-client.tsx b/web/src/app/affiliates/affiliates-client.tsx deleted file mode 100644 index 4eff1907ec..0000000000 --- a/web/src/app/affiliates/affiliates-client.tsx +++ /dev/null @@ -1,265 +0,0 @@ -'use client' - -import { env } from '@codebuff/common/env' -import { - CREDITS_REFERRAL_BONUS, - AFFILIATE_USER_REFFERAL_LIMIT, -} from '@codebuff/common/old-constants' -import Link from 'next/link' -import { useSession } from 'next-auth/react' -import React, { useEffect, useState, useCallback, useActionState } from 'react' - -import { setAffiliateHandleAction } from './actions' - -import type { SetHandleFormState } from './actions' - -import CardWithBeams from '@/components/card-with-beams' -import { SignInCardFooter } from '@/components/sign-in/sign-in-card-footer' -import { Button } from '@/components/ui/button' -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/components/ui/card' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { Skeleton } from '@/components/ui/skeleton' -import { useToast } from '@/components/ui/use-toast' - -function SubmitButton({ pending }: { pending: boolean }) { - return ( - - ) -} - -function SetHandleForm({ - onHandleSetSuccess, -}: { - onHandleSetSuccess: () => void -}) { - const { toast } = useToast() - const initialState: SetHandleFormState = { - message: '', - success: false, - fieldErrors: {}, - } - const [state, formAction, isPending] = useActionState( - setAffiliateHandleAction, - initialState, - ) - - useEffect(() => { - if (state.message) { - toast({ - title: state.success ? 'Success!' : 'Error', - description: state.message, - variant: state.success ? 'default' : 'destructive', - }) - if (state.success) { - onHandleSetSuccess() - } - } - }, [state, toast, onHandleSetSuccess]) - - return ( -
-
- -

- This will be part of your referral link (e.g., - codebuff.com/your_unique_handle). -

-

- 3-20 chars. letters, numbers, underscores only. -

- - - {state.fieldErrors?.handle && ( -

- {state.fieldErrors.handle.join(', ')} -

- )} - {!state.success && state.message && !state.fieldErrors?.handle && ( -

{state.message}

- )} -
- - - ) -} - -export default function AffiliatesClient() { - const { status: sessionStatus } = useSession() - const [userProfile, setUserProfile] = useState< - { handle: string | null; referralCode: string | null } | undefined - >(undefined) - const [fetchError, setFetchError] = useState(null) - - const fetchUserProfile = useCallback(() => { - setFetchError(null) - fetch('/api/user/profile') - .then(async (res) => { - if (!res.ok) { - const errorData = await res.json().catch(() => ({})) - throw new Error( - errorData.error || `HTTP error! status: ${res.status}`, - ) - } - return res.json() - }) - .then((data) => { - setUserProfile({ - handle: data.handle ?? null, - referralCode: data.referral_code ?? null, - }) - }) - .catch((error) => { - console.error('Failed to fetch user profile:', error) - setFetchError(error.message || 'Failed to load profile data.') - setUserProfile({ handle: null, referralCode: null }) - }) - }, []) - - useEffect(() => { - if (sessionStatus === 'authenticated') { - fetchUserProfile() - } else if (sessionStatus === 'unauthenticated') { - setUserProfile({ handle: null, referralCode: null }) - } - }, [sessionStatus, fetchUserProfile]) - - if (sessionStatus === 'loading' || userProfile === undefined) { - return ( -
-
- - - - - - - - - - - -
-
- ) - } - - if (sessionStatus === 'unauthenticated') { - return ( - -

- Want to partner with Codebuff and earn rewards? Log in first! -

- - - } - /> - ) - } - - if (fetchError) { - return ( -
-
-

Error loading affiliate information: {fetchError}

-

Please try refreshing the page or contact support.

-
-
- ) - } - - const userHandle = userProfile?.handle - const _referralCode = userProfile?.referralCode - - return ( -
-
- - - - Codebuff Affiliate Program - - - Share Codebuff and earn credits! - - - - {userHandle === null && ( -
-

- Become an Affiliate -

-

- Generate your unique referral link, that grants you{' '} - {AFFILIATE_USER_REFFERAL_LIMIT.toLocaleString()} referrals for - your friends, colleagues, and followers. When they sign up - using your link, you'll both earn an extra{' '} - {CREDITS_REFERRAL_BONUS} credits! -

- - -
- )} - - {userHandle && ( -
-

- Your Affiliate Handle -

-

- Your affiliate handle is set to:{' '} - - {userHandle} - - . You can now refer up to{' '} - {AFFILIATE_USER_REFFERAL_LIMIT.toLocaleString()} new users! -

-

- Your referral link is:{' '} - {`${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/${userHandle}`} -

-
- )} - -

- Questions? Contact us at{' '} - - {env.NEXT_PUBLIC_SUPPORT_EMAIL} - - . -

-
-
-
-
- ) -} diff --git a/web/src/app/affiliates/page.tsx b/web/src/app/affiliates/page.tsx deleted file mode 100644 index f51ea2de8b..0000000000 --- a/web/src/app/affiliates/page.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { env } from '@codebuff/common/env' - -import AffiliatesClient from './affiliates-client' - -import type { Metadata } from 'next' - - -export async function generateMetadata(): Promise { - const canonicalUrl = `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/affiliates` - - const title = 'Affiliate Program – Earn Credits by Referring | Codebuff' - const description = - 'Join the Codebuff Affiliate Program. Share your unique referral link and earn credits when friends sign up. Both you and your referrals get bonus credits!' - - return { - title, - description, - alternates: { - canonical: canonicalUrl, - }, - openGraph: { - title, - description, - url: canonicalUrl, - type: 'website', - siteName: 'Codebuff', - images: '/opengraph-image.png', - }, - twitter: { - card: 'summary_large_image', - title, - description, - images: '/opengraph-image.png', - }, - keywords: [ - 'affiliate program', - 'referral program', - 'earn credits', - 'Codebuff affiliate', - 'Codebuff referral', - 'AI coding assistant affiliate', - ], - } -} - -// WebPage JSON-LD schema describing the affiliate program -function WebPageJsonLd() { - const jsonLd = { - '@context': 'https://schema.org', - '@type': 'WebPage', - name: 'Codebuff Affiliate Program', - description: - 'Join the Codebuff Affiliate Program. Share your unique referral link and earn credits when friends sign up.', - url: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/affiliates`, - mainEntity: { - '@type': 'Service', - name: 'Codebuff Affiliate Program', - description: - 'Referral program that rewards users with bonus credits for inviting new users to Codebuff.', - provider: { - '@type': 'Organization', - name: 'Codebuff', - url: env.NEXT_PUBLIC_CODEBUFF_APP_URL, - }, - serviceType: 'Affiliate/Referral Program', - areaServed: 'Worldwide', - offers: { - '@type': 'Offer', - price: '0', - priceCurrency: 'USD', - description: - 'Free to join. Earn bonus credits for both referrer and referee.', - }, - }, - isPartOf: { - '@type': 'WebSite', - name: 'Codebuff', - url: env.NEXT_PUBLIC_CODEBUFF_APP_URL, - }, - } - - return ( -