feat: Add Ask Ash AI credit components

This commit is contained in:
Ashwin Kumar Sivakumar 2026-05-29 20:47:15 +05:30
parent d21121cf0a
commit d888466f19
4 changed files with 1215 additions and 0 deletions

View 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;

View 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;

View 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
View 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;