Skip to content

Commit a9dd46b

Browse files
committed
feat(cli): add /referral command and refactor command routing
- Create command registry pattern with CommandDefinition type - Add /referral slash command with amber UI (◎ icon) - Create referral-banner component to display referral link - Add use-user-details-query hook for flexible user data fetching - Refactor router with router-utils for cleaner input parsing - Add comprehensive router input tests - Referral codes work with or without / prefix
1 parent 665e1bd commit a9dd46b

File tree

5 files changed

+227
-6
lines changed

5 files changed

+227
-6
lines changed

cli/src/commands/command-registry.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { handleInitializationFlowLocally } from './init'
2+
import { handleReferralCode } from './referral'
23
import { handleUsageCommand } from './usage'
34
import { useChatStore } from '../state/chat-store'
45
import { useLoginStore } from '../state/login-store'
5-
import { getSystemMessage } from '../utils/message-history'
6+
import { getSystemMessage, getUserMessage } from '../utils/message-history'
67

78
import type { ChatMessage } from '../types/chat'
89
import type { MultilineInputHandle } from '../components/multiline-input'
@@ -70,7 +71,33 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
7071
name: 'bash',
7172
aliases: ['!'],
7273
handler: (params) => {
73-
useChatStore.getState().setBashMode(true)
74+
useChatStore.getState().setInputMode('bash')
75+
params.saveToHistory(params.inputValue.trim())
76+
clearInput(params)
77+
},
78+
},
79+
{
80+
name: 'referral',
81+
aliases: ['redeem'],
82+
handler: async (params, args) => {
83+
const trimmedArgs = args.trim()
84+
85+
// If user provided a code directly, redeem it immediately
86+
if (trimmedArgs) {
87+
const code = trimmedArgs.startsWith('ref-') ? trimmedArgs : `ref-${trimmedArgs}`
88+
const { postUserMessage } = await handleReferralCode(code)
89+
params.setMessages((prev) => [
90+
...prev,
91+
getUserMessage(params.inputValue.trim()),
92+
...postUserMessage([]),
93+
])
94+
params.saveToHistory(params.inputValue.trim())
95+
clearInput(params)
96+
return
97+
}
98+
99+
// Otherwise enter referral mode
100+
useChatStore.getState().setInputMode('referral')
74101
params.saveToHistory(params.inputValue.trim())
75102
clearInput(params)
76103
},

cli/src/commands/router.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,14 @@ export async function routeUserPrompt(
3636
setMessages,
3737
} = params
3838

39-
const isBashMode = useChatStore.getState().isBashMode
40-
const setBashMode = useChatStore.getState().setBashMode
39+
const inputMode = useChatStore.getState().inputMode
40+
const setInputMode = useChatStore.getState().setInputMode
4141

4242
const trimmed = inputValue.trim()
4343
if (!trimmed) return
4444

4545
// Handle bash mode commands
46-
if (isBashMode) {
46+
if (inputMode === 'bash') {
4747
const commandWithBang = '!' + trimmed
4848
const toolCallId = crypto.randomUUID()
4949
const resultBlock: ContentBlock = {
@@ -90,7 +90,25 @@ export async function routeUserPrompt(
9090

9191
saveToHistory(commandWithBang)
9292
setInputValue({ text: '', cursorPosition: 0, lastEditDueToNav: false })
93-
setBashMode(false)
93+
setInputMode('default')
94+
95+
return
96+
}
97+
98+
// Handle referral mode input
99+
if (inputMode === 'referral') {
100+
// Normalize the referral code - add ref- prefix if not present
101+
const referralCode = trimmed.startsWith('ref-') ? trimmed : `ref-${trimmed}`
102+
const { postUserMessage: referralPostMessage } =
103+
await handleReferralCode(referralCode)
104+
setMessages((prev) => [
105+
...prev,
106+
getUserMessage(trimmed),
107+
...referralPostMessage([]),
108+
])
109+
saveToHistory(trimmed)
110+
setInputValue({ text: '', cursorPosition: 0, lastEditDueToNav: false })
111+
setInputMode('default')
94112

95113
return
96114
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import React, { useMemo } from 'react'
2+
3+
import { Button } from './button'
4+
import { useTerminalDimensions } from '../hooks/use-terminal-dimensions'
5+
import { useTheme } from '../hooks/use-theme'
6+
import { useUserDetailsQuery } from '../hooks/use-user-details-query'
7+
import { useChatStore } from '../state/chat-store'
8+
import { BORDER_CHARS } from '../utils/ui-constants'
9+
10+
export const ReferralBanner = () => {
11+
const { terminalWidth } = useTerminalDimensions()
12+
const theme = useTheme()
13+
const inputMode = useChatStore((state) => state.inputMode)
14+
const setInputMode = useChatStore((state) => state.setInputMode)
15+
const isReferralMode = inputMode === 'referral'
16+
const [isCloseHovered, setIsCloseHovered] = React.useState(false)
17+
18+
// Fetch referral link when in referral mode
19+
const { data: userDetails, isLoading } = useUserDetailsQuery({
20+
fields: ['referral_link'] as const,
21+
enabled: isReferralMode,
22+
})
23+
const referralLink = userDetails?.referral_link ?? null
24+
25+
// Memoize the banner text
26+
const text = useMemo(() => {
27+
if (isLoading) return 'Loading your referral link...'
28+
29+
if (!referralLink) {
30+
return 'Your referral link is not available yet'
31+
}
32+
33+
return `Share this link with friends:\n${referralLink}`
34+
}, [referralLink, isLoading])
35+
36+
if (!isReferralMode) return null
37+
38+
return (
39+
<box
40+
key={terminalWidth}
41+
style={{
42+
width: '100%',
43+
borderStyle: 'single',
44+
borderColor: theme.warning,
45+
flexDirection: 'row',
46+
justifyContent: 'space-between',
47+
paddingLeft: 1,
48+
paddingRight: 1,
49+
marginTop: 0,
50+
marginBottom: 0,
51+
}}
52+
border={['bottom', 'left', 'right']}
53+
customBorderChars={BORDER_CHARS}
54+
>
55+
<text
56+
style={{
57+
fg: theme.warning,
58+
wrapMode: 'word',
59+
flexShrink: 1,
60+
marginRight: 3,
61+
}}
62+
>
63+
{text}
64+
</text>
65+
<Button
66+
onClick={() => setInputMode('default')}
67+
onMouseOver={() => setIsCloseHovered(true)}
68+
onMouseOut={() => setIsCloseHovered(false)}
69+
>
70+
<text style={{ fg: isCloseHovered ? theme.error : theme.muted }}>x</text>
71+
</Button>
72+
</box>
73+
)
74+
}

cli/src/data/slash-commands.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,4 +67,10 @@ export const SLASH_COMMANDS: SlashCommand[] = [
6767
description: 'Enter bash mode ("!" at beginning enters bash mode)',
6868
aliases: ['!'],
6969
},
70+
{
71+
id: 'referral',
72+
label: 'referral',
73+
description: 'Redeem a referral code for bonus credits',
74+
aliases: ['redeem'],
75+
},
7076
]
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { useQuery } from '@tanstack/react-query'
2+
3+
import { getAuthToken } from '../utils/auth'
4+
import { logger as defaultLogger } from '../utils/logger'
5+
6+
import type { Logger } from '@codebuff/common/types/contracts/logger'
7+
8+
// Valid fields that can be fetched from /api/v1/me
9+
export type UserField =
10+
| 'id'
11+
| 'email'
12+
| 'discord_id'
13+
| 'referral_code'
14+
| 'referral_link'
15+
16+
// Query keys for type-safe cache management
17+
export const userDetailsQueryKeys = {
18+
all: ['userDetails'] as const,
19+
fields: (fields: readonly UserField[]) =>
20+
[...userDetailsQueryKeys.all, ...fields] as const,
21+
}
22+
23+
export type UserDetails<T extends UserField> = {
24+
[K in T]: K extends 'discord_id' | 'referral_code' | 'referral_link'
25+
? string | null
26+
: string
27+
}
28+
29+
interface FetchUserDetailsParams<T extends UserField> {
30+
authToken: string
31+
fields: readonly T[]
32+
logger?: Logger
33+
}
34+
35+
/**
36+
* Fetches specific user details from the /api/v1/me endpoint
37+
*/
38+
export async function fetchUserDetails<T extends UserField>({
39+
authToken,
40+
fields,
41+
logger = defaultLogger,
42+
}: FetchUserDetailsParams<T>): Promise<UserDetails<T> | null> {
43+
const appUrl = process.env.NEXT_PUBLIC_CODEBUFF_APP_URL
44+
if (!appUrl) {
45+
throw new Error('NEXT_PUBLIC_CODEBUFF_APP_URL is not set')
46+
}
47+
48+
const fieldsParam = fields.join(',')
49+
const response = await fetch(`${appUrl}/api/v1/me?fields=${fieldsParam}`, {
50+
method: 'GET',
51+
headers: {
52+
'Content-Type': 'application/json',
53+
Authorization: `Bearer ${authToken}`,
54+
},
55+
})
56+
57+
if (!response.ok) {
58+
logger.error(
59+
{ status: response.status, fields },
60+
'Failed to fetch user details from /api/v1/me',
61+
)
62+
return null
63+
}
64+
65+
const data = (await response.json()) as UserDetails<T>
66+
return data
67+
}
68+
69+
export interface UseUserDetailsQueryDeps<T extends UserField> {
70+
fields: readonly T[]
71+
logger?: Logger
72+
enabled?: boolean
73+
}
74+
75+
/**
76+
* Hook to fetch specific user details
77+
*/
78+
export function useUserDetailsQuery<T extends UserField>({
79+
fields,
80+
logger = defaultLogger,
81+
enabled = true,
82+
}: UseUserDetailsQueryDeps<T>) {
83+
const authToken = getAuthToken()
84+
85+
return useQuery({
86+
queryKey: userDetailsQueryKeys.fields(fields),
87+
queryFn: () => fetchUserDetails({ authToken: authToken!, fields, logger }),
88+
enabled: enabled && !!authToken,
89+
staleTime: 5 * 60 * 1000, // 5 minutes
90+
gcTime: 30 * 60 * 1000, // 30 minutes
91+
retry: false,
92+
refetchOnMount: false,
93+
refetchOnWindowFocus: false,
94+
refetchOnReconnect: false,
95+
})
96+
}

0 commit comments

Comments
 (0)