feat: Add Ask Ash AI credit components
This commit is contained in:
parent
d21121cf0a
commit
d888466f19
4 changed files with 1215 additions and 0 deletions
332
src/components/AskAsh/AiGenerateButton.tsx
Normal file
332
src/components/AskAsh/AiGenerateButton.tsx
Normal file
|
|
@ -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<string, unknown>;
|
||||
/** 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 = () => (
|
||||
<svg
|
||||
class="animate-spin h-5 w-5 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
/>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Sparkles icon for AI button
|
||||
const SparklesIcon: Component = () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 8a1 1 0 011 1v1h1a1 1 0 110 2H6v1a1 1 0 11-2 0v-1H3a1 1 0 110-2h1v-1a1 1 0 011-1zm7-8a1 1 0 011 1v1h1a1 1 0 110 2h-1v1a1 1 0 11-2 0V6h-1a1 1 0 110-2h1V3a1 1 0 011-1zm0 8a1 1 0 011 1v1h1a1 1 0 110 2h-1v1a1 1 0 11-2 0v-1h-1a1 1 0 110-2h1v-1a1 1 0 011-1z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Credit icon
|
||||
const CreditIcon: Component = () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v2H7a1 1 0 100 2h2v2a1 1 0 102 0v-2h2a1 1 0 100-2h-2V7z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Warning icon
|
||||
const WarningIcon: Component = () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 text-amber-500"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const AiGenerateButton: Component<AiGenerateButtonProps> = (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 (
|
||||
<div class={`flex flex-col gap-2 ${local.class || ''}`}>
|
||||
{/* Main button container */}
|
||||
<div class="flex items-center gap-2">
|
||||
{/* AI Generate Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={isDisabled()}
|
||||
class={`
|
||||
inline-flex items-center gap-2 px-4 py-2
|
||||
rounded-lg font-medium text-sm
|
||||
transition-colors duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-2
|
||||
${hasEnoughCredits()
|
||||
? 'bg-indigo-600 text-white hover:bg-indigo-700 focus:ring-indigo-500'
|
||||
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||
}
|
||||
${credits.isGenerating() && 'cursor-wait'}
|
||||
`}
|
||||
{...rest}
|
||||
>
|
||||
<Show
|
||||
when={!credits.isGenerating()}
|
||||
fallback={<LoadingSpinner />}
|
||||
>
|
||||
{local.icon ?? <SparklesIcon />}
|
||||
</Show>
|
||||
|
||||
<span>
|
||||
{credits.isGenerating()
|
||||
? 'Generating...'
|
||||
: (local.buttonText ?? 'Ask Ash')
|
||||
}
|
||||
</span>
|
||||
|
||||
{/* Cost indicator badge */}
|
||||
<Show when={local.showCost !== false}>
|
||||
<span
|
||||
class={`
|
||||
inline-flex items-center gap-1
|
||||
px-1.5 py-0.5 rounded text-xs font-medium
|
||||
${hasEnoughCredits()
|
||||
? 'bg-indigo-500/30 text-indigo-100'
|
||||
: 'bg-red-500/30 text-red-100'
|
||||
}
|
||||
`}
|
||||
title={`Costs ${cost()} credit${cost() > 1 ? 's' : ''}`}
|
||||
>
|
||||
<CreditIcon />
|
||||
-{cost()}
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
|
||||
{/* Credit meter badge */}
|
||||
<div
|
||||
class={`
|
||||
inline-flex items-center gap-1.5 px-3 py-2
|
||||
rounded-lg text-sm font-medium
|
||||
transition-colors duration-200
|
||||
${credits.credits() > 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"
|
||||
>
|
||||
<CreditIcon />
|
||||
<Show when={!credits.isLoading()} fallback="Loading...">
|
||||
<span>
|
||||
{credits.credits()} credit{credits.credits() !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Insufficient credits warning */}
|
||||
<Show when={showWarning()}>
|
||||
<div class="flex items-center gap-3 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<WarningIcon />
|
||||
<div class="flex-1">
|
||||
<p class="text-sm text-amber-800 font-medium">
|
||||
Insufficient credits
|
||||
</p>
|
||||
<p class="text-xs text-amber-700 mt-0.5">
|
||||
You need {cost()} credit{cost() > 1 ? 's' : ''} to generate this content.
|
||||
You currently have {credits.credits()}.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBuyCredits}
|
||||
class="
|
||||
inline-flex items-center px-3 py-1.5
|
||||
bg-amber-600 text-white text-sm font-medium
|
||||
rounded-md hover:bg-amber-700
|
||||
transition-colors duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-1
|
||||
"
|
||||
>
|
||||
Buy Credits
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Error message display */}
|
||||
<Show when={credits.generationError()}>
|
||||
<div class="flex items-center gap-2 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 flex-shrink-0"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span>{credits.generationError()}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => credits.clearErrors()}
|
||||
class="ml-auto text-red-600 hover:text-red-800"
|
||||
title="Dismiss"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Low credits indicator (optional) */}
|
||||
<Show when={credits.credits() > 0 && credits.credits() < 3 && !showWarning()}>
|
||||
<p class="text-xs text-amber-600 flex items-center gap-1">
|
||||
<WarningIcon />
|
||||
Low on credits. Only {credits.credits()} remaining.
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AiGenerateButton;
|
||||
601
src/components/AskAsh/AskAshModal.tsx
Normal file
601
src/components/AskAsh/AskAshModal.tsx
Normal file
|
|
@ -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 = () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Check icon
|
||||
const CheckIcon: Component = () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Sparkles icon
|
||||
const SparklesIcon: Component = () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-12 w-12"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// 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 (
|
||||
<svg
|
||||
class={`animate-spin ${sizeClass} text-current`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
/>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const AskAshModal: Component<AskAshModalProps> = (props) => {
|
||||
// Get credit hook
|
||||
const credits = useAiCredits();
|
||||
|
||||
// Local state
|
||||
const [selectedPackage, setSelectedPackage] = createSignal<CreditPackage>(CREDIT_PACKAGES[1]);
|
||||
const [selectedPayment, setSelectedPayment] = createSignal<string>('card');
|
||||
const [step, setStep] = createSignal<'select' | 'confirm' | 'success'>('select');
|
||||
const [purchaseResult, setPurchaseResult] = createSignal<PurchaseResponse | null>(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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelectPackage(pkgProps.pkg)}
|
||||
class={`
|
||||
relative p-4 rounded-xl border-2 text-left transition-all duration-200
|
||||
${isSelected()
|
||||
? 'border-indigo-600 bg-indigo-50'
|
||||
: 'border-gray-200 hover:border-indigo-300 hover:bg-gray-50'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{/* Popular badge */}
|
||||
<Show when={pkgProps.pkg.popular}>
|
||||
<span class="
|
||||
absolute -top-2 left-1/2 -translate-x-1/2
|
||||
px-2 py-0.5 bg-indigo-600 text-white text-xs font-medium rounded-full
|
||||
">
|
||||
Popular
|
||||
</span>
|
||||
</Show>
|
||||
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<span class="text-lg font-bold text-gray-900">
|
||||
{pkgProps.pkg.credits} Credits
|
||||
</span>
|
||||
<span class="text-2xl font-bold text-indigo-600 mt-1">
|
||||
${pkgProps.pkg.price}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500 mt-1">
|
||||
${(pkgProps.pkg.price / pkgProps.pkg.credits).toFixed(2)}/credit
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Selection indicator */}
|
||||
<Show when={isSelected()}>
|
||||
<div class="absolute top-2 right-2 text-indigo-600">
|
||||
<CheckIcon />
|
||||
</div>
|
||||
</Show>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// Component for payment method
|
||||
const PaymentMethod: Component<{ method: typeof PAYMENT_METHODS[0] }> = (payProps) => {
|
||||
const isSelected = createMemo(() => selectedPayment() === payProps.method.id);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedPayment(payProps.method.id)}
|
||||
class={`
|
||||
flex items-center gap-3 p-3 rounded-lg border transition-all duration-200
|
||||
${isSelected()
|
||||
? 'border-indigo-600 bg-indigo-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span class="text-2xl">{payProps.method.icon}</span>
|
||||
<div class="flex-1 text-left">
|
||||
<span class="font-medium text-gray-900">{payProps.method.label}</span>
|
||||
</div>
|
||||
<div class={`
|
||||
w-5 h-5 rounded-full border-2 flex items-center justify-center
|
||||
${isSelected() ? 'border-indigo-600' : 'border-gray-300'}
|
||||
`}>
|
||||
<Show when={isSelected()}>
|
||||
<div class="w-2.5 h-2.5 rounded-full bg-indigo-600" />
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Show when={props.isOpen}>
|
||||
{/* Modal backdrop */}
|
||||
<div
|
||||
class="
|
||||
fixed inset-0 z-50
|
||||
bg-black/50 backdrop-blur-sm
|
||||
flex items-center justify-center p-4
|
||||
animate-[fadeIn_0.2s_ease-out]
|
||||
"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
{/* Modal content */}
|
||||
<div
|
||||
class={`
|
||||
relative w-full max-w-lg max-h-[90vh]
|
||||
bg-white rounded-2xl shadow-2xl
|
||||
overflow-hidden
|
||||
animate-[slideIn_0.3s_ease-out]
|
||||
flex flex-col
|
||||
`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
>
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between p-6 border-b border-gray-100">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-indigo-100 rounded-lg text-indigo-600">
|
||||
<SparklesIcon />
|
||||
</div>
|
||||
<div>
|
||||
<h2 id="modal-title" class="text-xl font-bold text-gray-900">
|
||||
{step() === 'success' ? 'Purchase Complete!' : 'Buy Ask Ash Credits'}
|
||||
</h2>
|
||||
<Show when={step() !== 'success'}>
|
||||
<p class="text-sm text-gray-500">
|
||||
Power your AI-generated content
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={step() !== 'success' && !credits.isPurchasing()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
class="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
<Show when={credits.purchaseError()}>
|
||||
<div class="mx-6 mt-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800">
|
||||
<div class="flex items-start gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium">Purchase failed</p>
|
||||
<p class="text-xs mt-0.5">{credits.purchaseError()}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => credits.clearErrors()}
|
||||
class="text-red-600 hover:text-red-800"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Body */}
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
{/* Step 1: Select package */}
|
||||
<Show when={step() === 'select'}>
|
||||
<div class="space-y-6">
|
||||
{/* Current balance */}
|
||||
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-xl">
|
||||
<span class="text-sm text-gray-600">Current balance</span>
|
||||
<span class="font-medium text-gray-900">
|
||||
{credits.credits()} credits
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Package selection */}
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-900 mb-3">
|
||||
Select a credit package
|
||||
</h3>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
{CREDIT_PACKAGES.map((pkg) => (
|
||||
<PackageCard pkg={pkg} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment method */}
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-900 mb-3">
|
||||
Payment method
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
{PAYMENT_METHODS.map((method) => (
|
||||
<PaymentMethod method={method} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div class="p-4 bg-indigo-50 rounded-xl">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-indigo-900">Package</span>
|
||||
<span class="font-medium text-indigo-900">
|
||||
{selectedPackage().credits} credits
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm mt-2">
|
||||
<span class="text-indigo-900">Total</span>
|
||||
<span class="text-lg font-bold text-indigo-900">
|
||||
${selectedPackage().price.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-indigo-700 mt-2">
|
||||
Estimated {operationsEstimate()} job descriptions
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Step 2: Confirm */}
|
||||
<Show when={step() === 'confirm'}>
|
||||
<div class="space-y-6">
|
||||
<div class="text-center">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 bg-indigo-100 rounded-full text-indigo-600 mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M5 4a3 3 0 00-3 3v6a3 3 0 003 3h10a3 3 0 003-3V7a3 3 0 00-3-3H5zm-1 9v-1h5v2H5a1 1 0 01-1-1zm7 1h4a1 1 0 001-1v-1h-5v2zm0-4h5V8h-5v2zM9 8H4v2h5V8z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900">
|
||||
Confirm your purchase
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
You're about to purchase {selectedPackage().credits} credits
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<span class="text-gray-600">Credits</span>
|
||||
<span class="font-medium">{selectedPackage().credits}</span>
|
||||
</div>
|
||||
<div class="flex justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<span class="text-gray-600">Payment method</span>
|
||||
<span class="font-medium">
|
||||
{PAYMENT_METHODS.find(m => m.id === selectedPayment())?.label}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between p-3 bg-indigo-50 rounded-lg">
|
||||
<span class="text-indigo-900 font-medium">Total amount</span>
|
||||
<span class="text-lg font-bold text-indigo-900">
|
||||
${selectedPackage().price.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Step 3: Success */}
|
||||
<Show when={step() === 'success'}>
|
||||
<div class="text-center py-4">
|
||||
<div class="inline-flex items-center justify-center w-20 h-20 bg-green-100 rounded-full text-green-600 mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-medium text-gray-900">
|
||||
Credits added!
|
||||
</h3>
|
||||
<p class="text-gray-500 mt-2">
|
||||
You now have <strong>{purchaseResult()?.new_balance}</strong> credits available.
|
||||
</p>
|
||||
<Show when={purchaseResult()?.transaction_id}>
|
||||
<p class="text-xs text-gray-400 mt-4">
|
||||
Transaction ID: {purchaseResult()?.transaction_id}
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div class="p-6 border-t border-gray-100 bg-gray-50">
|
||||
<Show when={step() === 'select'}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleContinue}
|
||||
class="w-full py-3 px-4 bg-indigo-600 text-white font-medium rounded-xl hover:bg-indigo-700 transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<Show when={step() === 'confirm'}>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep('select')}
|
||||
disabled={credits.isPurchasing()}
|
||||
class="flex-1 py-3 px-4 bg-white text-gray-700 font-medium rounded-xl border border-gray-300 hover:bg-gray-50 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePurchase}
|
||||
disabled={credits.isPurchasing()}
|
||||
class="flex-1 py-3 px-4 bg-indigo-600 text-white font-medium rounded-xl hover:bg-indigo-700 transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Show when={credits.isPurchasing()} fallback="Pay Now">
|
||||
<LoadingSpinner size="sm" />
|
||||
Processing...
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={step() === 'success'}>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDone}
|
||||
class="flex-1 py-3 px-4 bg-indigo-600 text-white font-medium rounded-xl hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBuyMore}
|
||||
class="flex-1 py-3 px-4 bg-white text-gray-700 font-medium rounded-xl border border-gray-300 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Buy More
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
// 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;
|
||||
25
src/components/AskAsh/index.ts
Normal file
25
src/components/AskAsh/index.ts
Normal file
|
|
@ -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';
|
||||
257
src/hooks/useAiCredits.ts
Normal file
257
src/hooks/useAiCredits.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
// Credit costs for different operations
|
||||
export const CREDIT_COSTS: Record<string, number> = {
|
||||
job_description: 1,
|
||||
interview_question: 1,
|
||||
resume_review: 2,
|
||||
};
|
||||
|
||||
// Fetch credit balance from API
|
||||
async function fetchCredits(): Promise<CreditBalance> {
|
||||
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<GenerateResponse> {
|
||||
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<PurchaseResponse> {
|
||||
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<number>;
|
||||
tracecoinBalance: Accessor<number>;
|
||||
creditResource: ReturnType<typeof createResource<CreditBalance>>;
|
||||
|
||||
// Loading states
|
||||
isLoading: Accessor<boolean>;
|
||||
isGenerating: Accessor<boolean>;
|
||||
isPurchasing: Accessor<boolean>;
|
||||
|
||||
// Error states
|
||||
error: Accessor<string | null>;
|
||||
generationError: Accessor<string | null>;
|
||||
purchaseError: Accessor<string | null>;
|
||||
|
||||
// Actions
|
||||
refetchCredits: () => void;
|
||||
generate: (request: GenerateRequest) => Promise<GenerateResponse | null>;
|
||||
purchase: (request: PurchaseRequest) => Promise<PurchaseResponse | null>;
|
||||
clearErrors: () => void;
|
||||
hasEnoughCredits: (cost?: number) => boolean;
|
||||
}
|
||||
|
||||
export function useAiCredits(): UseAiCreditsReturn {
|
||||
// Resource for fetching credits
|
||||
const [creditResource, { refetch: refetchCredits }] = createResource<CreditBalance>(
|
||||
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<string | null>(null);
|
||||
const [generationError, setGenerationError] = createSignal<string | null>(null);
|
||||
const [purchaseError, setPurchaseError] = createSignal<string | null>(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<GenerateResponse | null> => {
|
||||
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<PurchaseResponse | null> => {
|
||||
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;
|
||||
Loading…
Add table
Reference in a new issue