diff --git a/src/components/AskAsh/AiGenerateButton.tsx b/src/components/AskAsh/AiGenerateButton.tsx new file mode 100644 index 0000000..1b161a0 --- /dev/null +++ b/src/components/AskAsh/AiGenerateButton.tsx @@ -0,0 +1,332 @@ +import { + Component, + Show, + createSignal, + createMemo, + splitProps, + JSX +} from 'solid-js'; +import { useAiCredits, CREDIT_COSTS, GenerateRequest } from '../../hooks/useAiCredits'; + +// Props interface +export interface AiGenerateButtonProps { + /** The type of content to generate */ + type: 'job_description' | 'interview_question' | 'resume_review'; + /** Context data for generation (e.g., job title, company, etc.) */ + context: Record; + /** Callback when generation succeeds */ + onSuccess: (content: string) => void; + /** Callback when generation fails */ + onError?: (error: Error) => void; + /** Custom button text */ + buttonText?: string; + /** Additional CSS classes */ + class?: string; + /** Disabled state */ + disabled?: boolean; + /** Whether to show the cost badge */ + showCost?: boolean; + /** Custom icon element */ + icon?: JSX.Element; +} + +// Loading spinner component +const LoadingSpinner: Component = () => ( + + + + +); + +// Sparkles icon for AI button +const SparklesIcon: Component = () => ( + + + +); + +// Credit icon +const CreditIcon: Component = () => ( + + + +); + +// Warning icon +const WarningIcon: Component = () => ( + + + +); + +export const AiGenerateButton: Component = (props) => { + // Split props + const [local, rest] = splitProps(props, [ + 'type', + 'context', + 'onSuccess', + 'onError', + 'buttonText', + 'class', + 'disabled', + 'showCost', + 'icon', + ]); + + // Get credit hook + const credits = useAiCredits(); + + // Local state for showing insufficient credits warning + const [showWarning, setShowWarning] = createSignal(false); + + // Compute cost for this operation + const cost = createMemo(() => CREDIT_COSTS[local.type] ?? 1); + + // Check if user has enough credits + const hasEnoughCredits = createMemo(() => credits.hasEnoughCredits(cost())); + + // Determine button state + const isDisabled = createMemo(() => + local.disabled || credits.isGenerating() || credits.isLoading() + ); + + // Handle button click + const handleClick = async () => { + // Clear previous errors + credits.clearErrors(); + setShowWarning(false); + + // Check if user has enough credits + if (!hasEnoughCredits()) { + setShowWarning(true); + return; + } + + try { + const request: GenerateRequest = { + type: local.type, + context: local.context, + }; + + const response = await credits.generate(request); + + if (response) { + local.onSuccess(response.content); + } + } catch (err) { + const error = err instanceof Error ? err : new Error('Generation failed'); + + if (local.onError) { + local.onError(error); + } + + // Show warning for insufficient credits + if (error.message.toLowerCase().includes('insufficient')) { + setShowWarning(true); + } + } + }; + + // Handle buy credits click + const handleBuyCredits = () => { + // Emit event to open purchase modal + // Parent component should listen for this event + const event = new CustomEvent('open-credit-purchase-modal', { + detail: { requiredCredits: cost() }, + bubbles: true, + }); + document.dispatchEvent(event); + + // Hide the warning + setShowWarning(false); + }; + + return ( +
+ {/* Main button container */} +
+ {/* AI Generate Button */} + + + {/* Credit meter badge */} +
0 + ? 'bg-green-100 text-green-800 border border-green-200' + : 'bg-red-100 text-red-800 border border-red-200' + } + ${credits.isLoading() && 'opacity-70'} + `} + title="Available AI credits" + > + + + + {credits.credits()} credit{credits.credits() !== 1 ? 's' : ''} + + +
+
+ + {/* Insufficient credits warning */} + +
+ +
+

+ Insufficient credits +

+

+ You need {cost()} credit{cost() > 1 ? 's' : ''} to generate this content. + You currently have {credits.credits()}. +

+
+ +
+
+ + {/* Error message display */} + +
+ + + + {credits.generationError()} + +
+
+ + {/* Low credits indicator (optional) */} + 0 && credits.credits() < 3 && !showWarning()}> +

+ + Low on credits. Only {credits.credits()} remaining. +

+
+
+ ); +}; + +export default AiGenerateButton; diff --git a/src/components/AskAsh/AskAshModal.tsx b/src/components/AskAsh/AskAshModal.tsx new file mode 100644 index 0000000..c2d65fe --- /dev/null +++ b/src/components/AskAsh/AskAshModal.tsx @@ -0,0 +1,601 @@ +import { + Component, + Show, + createSignal, + createMemo, + onMount, + onCleanup, + batch, + JSX +} from 'solid-js'; +import { useAiCredits, CREDIT_COSTS, PurchaseResponse } from '../../hooks/useAiCredits'; + +// Props interface +export interface AskAshModalProps { + /** Whether the modal is open */ + isOpen: boolean; + /** Callback when modal is closed */ + onClose: () => void; + /** Minimum credits needed (optional) */ + requiredCredits?: number; + /** Callback when purchase is successful */ + onPurchaseSuccess?: (response: PurchaseResponse) => void; + /** Callback when purchase fails */ + onPurchaseError?: (error: Error) => void; +} + +// Credit package options +interface CreditPackage { + id: string; + credits: number; + price: number; + label: string; + popular?: boolean; +} + +const CREDIT_PACKAGES: CreditPackage[] = [ + { id: 'small', credits: 10, price: 5, label: 'Starter' }, + { id: 'medium', credits: 50, price: 20, label: 'Pro', popular: true }, + { id: 'large', credits: 200, price: 60, label: 'Enterprise' }, +]; + +// Payment method options +const PAYMENT_METHODS = [ + { id: 'card', label: 'Credit/Debit Card', icon: '💳' }, + { id: 'tracecoin', label: 'Tracecoin Balance', icon: '🪙' }, +]; + +// Close icon +const CloseIcon: Component = () => ( + + + +); + +// Check icon +const CheckIcon: Component = () => ( + + + +); + +// Sparkles icon +const SparklesIcon: Component = () => ( + + + +); + +// Loading spinner +const LoadingSpinner: Component<{ size?: 'sm' | 'md' | 'lg' }> = (props) => { + const sizeClass = { + sm: 'h-4 w-4', + md: 'h-5 w-5', + lg: 'h-8 w-8', + }[props.size || 'md']; + + return ( + + + + + ); +}; + +export const AskAshModal: Component = (props) => { + // Get credit hook + const credits = useAiCredits(); + + // Local state + const [selectedPackage, setSelectedPackage] = createSignal(CREDIT_PACKAGES[1]); + const [selectedPayment, setSelectedPayment] = createSignal('card'); + const [step, setStep] = createSignal<'select' | 'confirm' | 'success'>('select'); + const [purchaseResult, setPurchaseResult] = createSignal(null); + + // Compute if user needs specific amount + const requiredAmount = createMemo(() => props.requiredCredits ?? 1); + + // Compute current credit cost per operation + const operationsEstimate = createMemo(() => { + return Math.floor(selectedPackage().credits / requiredAmount()); + }); + + // Handle escape key to close modal + onMount(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape' && props.isOpen && !credits.isPurchasing()) { + handleClose(); + } + }; + + document.addEventListener('keydown', handleEscape); + + onCleanup(() => { + document.removeEventListener('keydown', handleEscape); + }); + }); + + // Reset state when modal opens + const resetState = () => { + batch(() => { + setSelectedPackage(CREDIT_PACKAGES[1]); + setSelectedPayment('card'); + setStep('select'); + setPurchaseResult(null); + credits.clearErrors(); + }); + }; + + // Handle close + const handleClose = () => { + if (!credits.isPurchasing()) { + props.onClose(); + } + }; + + // Handle backdrop click + const handleBackdropClick = (e: MouseEvent) => { + if (e.target === e.currentTarget) { + handleClose(); + } + }; + + // Handle package selection + const handleSelectPackage = (pkg: CreditPackage) => { + setSelectedPackage(pkg); + }; + + // Handle continue to confirmation + const handleContinue = () => { + setStep('confirm'); + }; + + // Handle purchase + const handlePurchase = async () => { + try { + const response = await credits.purchase({ + amount: selectedPackage().credits, + payment_method: selectedPayment(), + }); + + if (response) { + setPurchaseResult(response); + setStep('success'); + + if (props.onPurchaseSuccess) { + props.onPurchaseSuccess(response); + } + } + } catch (err) { + const error = err instanceof Error ? err : new Error('Purchase failed'); + + if (props.onPurchaseError) { + props.onPurchaseError(error); + } + } + }; + + // Handle done after success + const handleDone = () => { + resetState(); + props.onClose(); + }; + + // Handle buy more after success + const handleBuyMore = () => { + setStep('select'); + setPurchaseResult(null); + }; + + // Component for package card + const PackageCard: Component<{ pkg: CreditPackage }> = (pkgProps) => { + const isSelected = createMemo(() => selectedPackage().id === pkgProps.pkg.id); + + return ( + + ); + }; + + // Component for payment method + const PaymentMethod: Component<{ method: typeof PAYMENT_METHODS[0] }> = (payProps) => { + const isSelected = createMemo(() => selectedPayment() === payProps.method.id); + + return ( + + ); + }; + + return ( + + {/* Modal backdrop */} +
+ {/* Modal content */} + +
+
+ ); +}; + +// CSS animations +const style = ` + @keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } + } + + @keyframes slideIn { + from { + opacity: 0; + transform: scale(0.95) translateY(-10px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } + } +`; + +// Inject styles +if (typeof document !== 'undefined') { + const styleEl = document.createElement('style'); + styleEl.textContent = style; + document.head.appendChild(styleEl); +} + +export default AskAshModal; diff --git a/src/components/AskAsh/index.ts b/src/components/AskAsh/index.ts new file mode 100644 index 0000000..7641131 --- /dev/null +++ b/src/components/AskAsh/index.ts @@ -0,0 +1,25 @@ +// Ask Ash AI Credit System Components +// SolidJS + TypeScript frontend components for AI generation with credit system + +// Components +export { AiGenerateButton, type AiGenerateButtonProps } from './AiGenerateButton'; +export { AskAshModal, type AskAshModalProps } from './AskAshModal'; + +// Hooks +export { + useAiCredits, + type UseAiCreditsReturn, + generateContent, + purchaseCredits, + CREDIT_COSTS, +} from '../hooks/useAiCredits'; + +// Types +export type { + CreditBalance, + GenerateRequest, + GenerateResponse, + PurchaseRequest, + PurchaseResponse, + ApiError, +} from '../hooks/useAiCredits'; diff --git a/src/hooks/useAiCredits.ts b/src/hooks/useAiCredits.ts new file mode 100644 index 0000000..f84811d --- /dev/null +++ b/src/hooks/useAiCredits.ts @@ -0,0 +1,257 @@ +import { createSignal, createResource, createEffect, Accessor } from 'solid-js'; + +// API base URL - can be configured via environment +const API_BASE = import.meta.env.VITE_API_BASE_URL || ''; + +// Types +export interface CreditBalance { + credits: number; + tracecoin_balance: number; +} + +export interface GenerateRequest { + type: 'job_description' | 'interview_question' | 'resume_review'; + context: Record; +} + +export interface GenerateResponse { + content: string; + credits_used: number; + remaining_credits: number; +} + +export interface PurchaseRequest { + amount: number; + payment_method: string; +} + +export interface PurchaseResponse { + success: boolean; + credits_added: number; + new_balance: number; + transaction_id: string; +} + +export interface ApiError { + code: string; + message: string; + details?: Record; +} + +// Credit costs for different operations +export const CREDIT_COSTS: Record = { + job_description: 1, + interview_question: 1, + resume_review: 2, +}; + +// Fetch credit balance from API +async function fetchCredits(): Promise { + const response = await fetch(`${API_BASE}/api/ai/credits`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + }); + + if (!response.ok) { + const error: ApiError = await response.json().catch(() => ({ + code: 'FETCH_ERROR', + message: `Failed to fetch credits: ${response.statusText}`, + })); + throw new Error(error.message); + } + + return response.json(); +} + +// Generate AI content +export async function generateContent( + request: GenerateRequest +): Promise { + const response = await fetch(`${API_BASE}/api/ai/generate`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify(request), + }); + + if (!response.ok) { + const error: ApiError = await response.json().catch(() => ({ + code: 'GENERATE_ERROR', + message: `Failed to generate content: ${response.statusText}`, + })); + + if (response.status === 402) { + throw new Error('Insufficient credits. Please purchase more credits to continue.'); + } + + throw new Error(error.message); + } + + return response.json(); +} + +// Purchase credits +export async function purchaseCredits( + request: PurchaseRequest +): Promise { + const response = await fetch(`${API_BASE}/api/ai/credits/purchase`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify(request), + }); + + if (!response.ok) { + const error: ApiError = await response.json().catch(() => ({ + code: 'PURCHASE_ERROR', + message: `Failed to purchase credits: ${response.statusText}`, + })); + throw new Error(error.message); + } + + return response.json(); +} + +// Main hook for credit management +export interface UseAiCreditsReturn { + // Credit data + credits: Accessor; + tracecoinBalance: Accessor; + creditResource: ReturnType>; + + // Loading states + isLoading: Accessor; + isGenerating: Accessor; + isPurchasing: Accessor; + + // Error states + error: Accessor; + generationError: Accessor; + purchaseError: Accessor; + + // Actions + refetchCredits: () => void; + generate: (request: GenerateRequest) => Promise; + purchase: (request: PurchaseRequest) => Promise; + clearErrors: () => void; + hasEnoughCredits: (cost?: number) => boolean; +} + +export function useAiCredits(): UseAiCreditsReturn { + // Resource for fetching credits + const [creditResource, { refetch: refetchCredits }] = createResource( + fetchCredits, + { + initialValue: { credits: 0, tracecoin_balance: 0 }, + } + ); + + // Loading states + const [isGenerating, setIsGenerating] = createSignal(false); + const [isPurchasing, setIsPurchasing] = createSignal(false); + + // Error states + const [error, setError] = createSignal(null); + const [generationError, setGenerationError] = createSignal(null); + const [purchaseError, setPurchaseError] = createSignal(null); + + // Derived signals for credit data + const credits = () => creditResource()?.credits ?? 0; + const tracecoinBalance = () => creditResource()?.tracecoin_balance ?? 0; + const isLoading = () => creditResource.loading; + + // Clear all errors + const clearErrors = () => { + setError(null); + setGenerationError(null); + setPurchaseError(null); + }; + + // Check if user has enough credits + const hasEnoughCredits = (cost: number = 1): boolean => { + return credits() >= cost; + }; + + // Generate AI content with error handling + const generate = async ( + request: GenerateRequest + ): Promise => { + setIsGenerating(true); + setGenerationError(null); + + try { + const response = await generateContent(request); + // Refetch credits to get updated balance + refetchCredits(); + return response; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to generate content'; + setGenerationError(message); + throw err; + } finally { + setIsGenerating(false); + } + }; + + // Purchase credits with error handling + const purchase = async ( + request: PurchaseRequest + ): Promise => { + setIsPurchasing(true); + setPurchaseError(null); + + try { + const response = await purchaseCredits(request); + // Refetch credits to get updated balance + refetchCredits(); + return response; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to purchase credits'; + setPurchaseError(message); + throw err; + } finally { + setIsPurchasing(false); + } + }; + + // Auto-clear resource errors + createEffect(() => { + if (creditResource.error) { + const err = creditResource.error as Error; + setError(err.message || 'Failed to load credits'); + } + }); + + return { + // Credit data + credits, + tracecoinBalance, + creditResource, + + // Loading states + isLoading, + isGenerating, + isPurchasing, + + // Error states + error, + generationError, + purchaseError, + + // Actions + refetchCredits, + generate, + purchase, + clearErrors, + hasEnoughCredits, + }; +} + +export default useAiCredits;