parent
38cd727e48
commit
cfbb4f32ab
29 changed files with 543 additions and 314 deletions
@ -0,0 +1,39 @@ |
||||
import { AlertTriangle, type LucideIcon } from 'lucide-react'; |
||||
|
||||
export type BillingWarningProps = { |
||||
icon?: LucideIcon; |
||||
message: string; |
||||
onButtonClick?: () => void; |
||||
buttonText?: string; |
||||
isLoading?: boolean; |
||||
}; |
||||
|
||||
export function BillingWarning(props: BillingWarningProps) { |
||||
const { |
||||
message, |
||||
onButtonClick, |
||||
buttonText, |
||||
isLoading, |
||||
icon: Icon = AlertTriangle, |
||||
} = props; |
||||
|
||||
return ( |
||||
<div className="mb-6 flex items-center gap-2 rounded-lg border border-red-300 bg-red-50 p-4 text-sm text-red-600"> |
||||
<Icon className="h-5 w-5" /> |
||||
<span> |
||||
{message} |
||||
{buttonText && ( |
||||
<button |
||||
disabled={isLoading} |
||||
onClick={() => { |
||||
onButtonClick?.(); |
||||
}} |
||||
className="font-semibold underline underline-offset-4 disabled:cursor-not-allowed disabled:opacity-50 ml-0.5" |
||||
> |
||||
{buttonText} |
||||
</button> |
||||
)} |
||||
</span> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,75 @@ |
||||
import { PenSquare, RefreshCcw } from 'lucide-react'; |
||||
import { useRef, useState } from 'react'; |
||||
import { useOutsideClick } from '../../hooks/use-outside-click'; |
||||
import { cn } from '../../lib/classname'; |
||||
import { useIsPaidUser } from '../../queries/billing'; |
||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; |
||||
|
||||
type RegenerateOutlineProps = { |
||||
onRegenerateOutline: () => void; |
||||
}; |
||||
|
||||
export function RegenerateOutline(props: RegenerateOutlineProps) { |
||||
const { onRegenerateOutline } = props; |
||||
|
||||
const [isDropdownVisible, setIsDropdownVisible] = useState(false); |
||||
const [showUpgradeModal, setShowUpgradeModal] = useState(false); |
||||
const ref = useRef<HTMLDivElement>(null); |
||||
|
||||
const isPaidUser = useIsPaidUser(); |
||||
|
||||
useOutsideClick(ref, () => setIsDropdownVisible(false)); |
||||
|
||||
return ( |
||||
<> |
||||
{showUpgradeModal && ( |
||||
<UpgradeAccountModal |
||||
onClose={() => { |
||||
setShowUpgradeModal(false); |
||||
}} |
||||
/> |
||||
)} |
||||
|
||||
<div className="absolute right-3 top-3" ref={ref}> |
||||
<button |
||||
className={cn('text-gray-400 hover:text-black', { |
||||
'text-black': isDropdownVisible, |
||||
})} |
||||
onClick={() => setIsDropdownVisible(!isDropdownVisible)} |
||||
> |
||||
<PenSquare className="text-current" size={16} strokeWidth={2.5} /> |
||||
</button> |
||||
{isDropdownVisible && ( |
||||
<div className="absolute right-0 top-full min-w-[170px] overflow-hidden rounded-md border border-gray-200 bg-white"> |
||||
<button |
||||
onClick={() => { |
||||
if (!isPaidUser) { |
||||
setIsDropdownVisible(false); |
||||
setShowUpgradeModal(true); |
||||
} else { |
||||
onRegenerateOutline(); |
||||
} |
||||
}} |
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm text-gray-600 hover:bg-gray-100" |
||||
> |
||||
<RefreshCcw |
||||
size={16} |
||||
className="text-gray-400" |
||||
strokeWidth={2.5} |
||||
/> |
||||
Regenerate |
||||
</button> |
||||
<button className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm text-gray-600 hover:bg-gray-100"> |
||||
<PenSquare |
||||
size={16} |
||||
className="text-gray-400" |
||||
strokeWidth={2.5} |
||||
/> |
||||
Modify Prompt |
||||
</button> |
||||
</div> |
||||
)} |
||||
</div> |
||||
</> |
||||
); |
||||
} |
@ -0,0 +1,132 @@ |
||||
import { |
||||
generateAiCourseStructure, |
||||
readStream, |
||||
type AiCourse, |
||||
} from '../lib/ai'; |
||||
import { queryClient } from '../stores/query-client'; |
||||
import { getAiCourseLimitOptions } from '../queries/ai-course'; |
||||
|
||||
type GenerateCourseOptions = { |
||||
term: string; |
||||
difficulty: string; |
||||
isForce?: boolean; |
||||
onCourseIdChange?: (courseId: string) => void; |
||||
onCourseSlugChange?: (courseSlug: string) => void; |
||||
onCourseChange?: (course: AiCourse) => void; |
||||
onLoadingChange?: (isLoading: boolean) => void; |
||||
onError?: (error: string) => void; |
||||
}; |
||||
|
||||
export async function generateCourse(options: GenerateCourseOptions) { |
||||
const { |
||||
term, |
||||
difficulty, |
||||
onCourseIdChange, |
||||
onCourseSlugChange, |
||||
onCourseChange, |
||||
onLoadingChange, |
||||
onError, |
||||
isForce = false, |
||||
} = options; |
||||
|
||||
onLoadingChange?.(true); |
||||
onCourseChange?.({ |
||||
title: '', |
||||
modules: [], |
||||
difficulty: '', |
||||
}); |
||||
onError?.(''); |
||||
|
||||
try { |
||||
const response = await fetch( |
||||
`${import.meta.env.PUBLIC_API_URL}/v1-generate-ai-course`, |
||||
{ |
||||
method: 'POST', |
||||
headers: { |
||||
'Content-Type': 'application/json', |
||||
}, |
||||
body: JSON.stringify({ |
||||
keyword: term, |
||||
difficulty, |
||||
isForce, |
||||
}), |
||||
credentials: 'include', |
||||
}, |
||||
); |
||||
|
||||
if (!response.ok) { |
||||
const data = await response.json(); |
||||
console.error( |
||||
'Error generating course:', |
||||
data?.message || 'Something went wrong', |
||||
); |
||||
onLoadingChange?.(false); |
||||
onError?.(data?.message || 'Something went wrong'); |
||||
return; |
||||
} |
||||
|
||||
const reader = response.body?.getReader(); |
||||
|
||||
if (!reader) { |
||||
console.error('Failed to get reader from response'); |
||||
onError?.('Something went wrong'); |
||||
onLoadingChange?.(false); |
||||
return; |
||||
} |
||||
|
||||
const COURSE_ID_REGEX = new RegExp('@COURSEID:(\\w+)@'); |
||||
const COURSE_SLUG_REGEX = new RegExp(/@COURSESLUG:([\w-]+)@/); |
||||
|
||||
await readStream(reader, { |
||||
onStream: (result) => { |
||||
if (result.includes('@COURSEID') || result.includes('@COURSESLUG')) { |
||||
const courseIdMatch = result.match(COURSE_ID_REGEX); |
||||
const courseSlugMatch = result.match(COURSE_SLUG_REGEX); |
||||
const extractedCourseId = courseIdMatch?.[1] || ''; |
||||
const extractedCourseSlug = courseSlugMatch?.[1] || ''; |
||||
|
||||
if (extractedCourseSlug) { |
||||
window.history.replaceState( |
||||
{ |
||||
courseId: extractedCourseId, |
||||
courseSlug: extractedCourseSlug, |
||||
term, |
||||
difficulty, |
||||
}, |
||||
'', |
||||
`${origin}/ai-tutor/${extractedCourseSlug}`, |
||||
); |
||||
} |
||||
|
||||
result = result |
||||
.replace(COURSE_ID_REGEX, '') |
||||
.replace(COURSE_SLUG_REGEX, ''); |
||||
|
||||
onCourseIdChange?.(extractedCourseId); |
||||
onCourseSlugChange?.(extractedCourseSlug); |
||||
} |
||||
|
||||
try { |
||||
const aiCourse = generateAiCourseStructure(result); |
||||
onCourseChange?.({ |
||||
...aiCourse, |
||||
difficulty: difficulty || '', |
||||
}); |
||||
} catch (e) { |
||||
console.error('Error parsing streamed course content:', e); |
||||
} |
||||
}, |
||||
onStreamEnd: (result) => { |
||||
result = result |
||||
.replace(COURSE_ID_REGEX, '') |
||||
.replace(COURSE_SLUG_REGEX, ''); |
||||
onLoadingChange?.(false); |
||||
queryClient.invalidateQueries(getAiCourseLimitOptions()); |
||||
}, |
||||
}); |
||||
} catch (error: any) { |
||||
onError?.(error?.message || 'Something went wrong'); |
||||
console.error('Error in course generation:', error); |
||||
onLoadingChange?.(false); |
||||
} |
||||
} |
Loading…
Reference in new issue