TONS code - stripe integration

This commit is contained in:
2025-09-20 16:37:37 +02:00
parent 7a868d7f14
commit e75424aac0
34 changed files with 5273 additions and 8 deletions
+1
View File
@@ -5,6 +5,7 @@ const nextConfig = {
env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'https://candivista.com',
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY || 'pk_test_your_publishable_key_here',
},
// Only add rewrites for production (Docker)
+23
View File
@@ -9,6 +9,8 @@
"version": "0.1.0",
"dependencies": {
"@hookform/resolvers": "^5.2.1",
"@stripe/react-stripe-js": "^4.0.2",
"@stripe/stripe-js": "^7.9.0",
"axios": "^1.11.0",
"next": "15.5.2",
"next-themes": "^0.4.6",
@@ -668,6 +670,27 @@
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="
},
"node_modules/@stripe/react-stripe-js": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-4.0.2.tgz",
"integrity": "sha512-l2wau+8/LOlHl+Sz8wQ1oDuLJvyw51nQCsu6/ljT6smqzTszcMHifjAJoXlnMfcou3+jK/kQyVe04u/ufyTXgg==",
"dependencies": {
"prop-types": "^15.7.2"
},
"peerDependencies": {
"@stripe/stripe-js": ">=1.44.1 <8.0.0",
"react": ">=16.8.0 <20.0.0",
"react-dom": ">=16.8.0 <20.0.0"
}
},
"node_modules/@stripe/stripe-js": {
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.9.0.tgz",
"integrity": "sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==",
"engines": {
"node": ">=12.16"
}
},
"node_modules/@swagger-api/apidom-ast": {
"version": "1.0.0-beta.48",
"resolved": "https://registry.npmjs.org/@swagger-api/apidom-ast/-/apidom-ast-1.0.0-beta.48.tgz",
+2
View File
@@ -9,6 +9,8 @@
},
"dependencies": {
"@hookform/resolvers": "^5.2.1",
"@stripe/react-stripe-js": "^4.0.2",
"@stripe/stripe-js": "^7.9.0",
"axios": "^1.11.0",
"next": "15.5.2",
"next-themes": "^0.4.6",
+4 -1
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "next-themes";
import StripeProvider from "../components/StripeProvider";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -29,7 +30,9 @@ export default function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-white dark:bg-gray-900 text-gray-900 dark:text-white`}
>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
<StripeProvider>
{children}
</StripeProvider>
</ThemeProvider>
</body>
</html>
+144
View File
@@ -0,0 +1,144 @@
"use client";
import { useEffect, useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
function PaymentFailedContent() {
const router = useRouter();
const searchParams = useSearchParams();
const [errorMessage, setErrorMessage] = useState<string>("");
const [loading, setLoading] = useState(true);
useEffect(() => {
// Get error message from URL params
const error = searchParams.get('error');
if (error) {
setErrorMessage(decodeURIComponent(error));
}
setLoading(false);
}, [searchParams]);
const handleRetryPayment = () => {
router.push('/dashboard');
};
const handleGoToDashboard = () => {
router.push('/dashboard');
};
const handleContactSupport = () => {
// This could open a support modal or redirect to support page
window.open('mailto:support@candivista.com?subject=Payment Issue', '_blank');
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<div className="w-8 h-8 border-2 border-red-600 border-t-transparent rounded-full animate-spin"></div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center p-4">
<div className="max-w-md w-full bg-white dark:bg-gray-800 rounded-lg shadow-xl p-8 text-center">
{/* Error Icon */}
<div className="mx-auto flex items-center justify-center h-16 w-16 rounded-full bg-red-100 dark:bg-red-900/20 mb-6">
<svg className="h-8 w-8 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
{/* Error Message */}
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Payment Failed
</h1>
<p className="text-gray-600 dark:text-gray-400 mb-6">
We couldn't process your payment. Don't worry, no charges were made to your account.
</p>
{/* Error Details */}
{errorMessage && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-6">
<h3 className="font-medium text-red-800 dark:text-red-200 mb-2">
Error Details
</h3>
<p className="text-sm text-red-700 dark:text-red-300">
{errorMessage}
</p>
</div>
)}
{/* Common Solutions */}
<div className="bg-yellow-50 dark:bg-yellow-900/20 rounded-lg p-4 mb-6">
<h3 className="font-medium text-yellow-800 dark:text-yellow-200 mb-2">
Common Solutions
</h3>
<ul className="text-sm text-yellow-700 dark:text-yellow-300 space-y-1 text-left">
<li> Check your payment method details</li>
<li> Ensure you have sufficient funds</li>
<li> Try a different payment method</li>
<li> Contact your bank if the issue persists</li>
</ul>
</div>
{/* Action Buttons */}
<div className="space-y-3">
<button
onClick={handleRetryPayment}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Try Again
</button>
<button
onClick={handleGoToDashboard}
className="w-full px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
Go to Dashboard
</button>
</div>
{/* Support Section */}
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-600">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
Still having trouble? We're here to help!
</p>
<div className="space-y-2">
<button
onClick={handleContactSupport}
className="w-full px-4 py-2 bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300 rounded-lg hover:bg-green-200 dark:hover:bg-green-900/30 transition-colors"
>
Contact Support
</button>
<Link
href="/docs"
className="block w-full px-4 py-2 text-blue-600 dark:text-blue-400 hover:underline text-sm"
>
View Help Documentation
</Link>
</div>
</div>
{/* Additional Info */}
<div className="mt-6 text-xs text-gray-500 dark:text-gray-400">
<p>
If you continue to experience issues, please contact our support team with the error details above.
</p>
</div>
</div>
</div>
);
}
export default function PaymentFailedPage() {
return (
<Suspense fallback={
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<div className="w-8 h-8 border-2 border-red-600 border-t-transparent rounded-full animate-spin"></div>
</div>
}>
<PaymentFailedContent />
</Suspense>
);
}
+143
View File
@@ -0,0 +1,143 @@
"use client";
import { useEffect, useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
function PaymentSuccessContent() {
const router = useRouter();
const searchParams = useSearchParams();
const [paymentData, setPaymentData] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Get payment data from URL params or localStorage
const paymentIntentId = searchParams.get('payment_intent');
const paymentIntentClientSecret = searchParams.get('payment_intent_client_secret');
if (paymentIntentId) {
// Payment was successful, get the data from localStorage or API
const storedData = localStorage.getItem('lastPaymentSuccess');
if (storedData) {
setPaymentData(JSON.parse(storedData));
localStorage.removeItem('lastPaymentSuccess');
}
}
setLoading(false);
}, [searchParams]);
const handleGoToDashboard = () => {
router.push('/dashboard');
};
const handleBuyMoreTokens = () => {
router.push('/dashboard');
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center p-4">
<div className="max-w-md w-full bg-white dark:bg-gray-800 rounded-lg shadow-xl p-8 text-center">
{/* Success Icon */}
<div className="mx-auto flex items-center justify-center h-16 w-16 rounded-full bg-green-100 dark:bg-green-900/20 mb-6">
<svg className="h-8 w-8 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
{/* Success Message */}
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Payment Successful!
</h1>
<p className="text-gray-600 dark:text-gray-400 mb-6">
Your tokens have been added to your account and are ready to use.
</p>
{/* Payment Details */}
{paymentData && (
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 mb-6">
<h3 className="font-medium text-gray-900 dark:text-white mb-3">
Payment Details
</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Tokens Purchased:</span>
<span className="text-gray-900 dark:text-white">
{paymentData.tokensAllocated || 'N/A'}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Amount Paid:</span>
<span className="text-gray-900 dark:text-white">
{paymentData.amount?.toFixed(2) || 'N/A'}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Status:</span>
<span className="text-green-600 dark:text-green-400 font-medium">
Completed
</span>
</div>
</div>
</div>
)}
{/* Next Steps */}
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 mb-6">
<h3 className="font-medium text-blue-900 dark:text-blue-200 mb-2">
What's Next?
</h3>
<ul className="text-sm text-blue-800 dark:text-blue-300 space-y-1 text-left">
<li> Your tokens are now active and ready to use</li>
<li> Create job postings to start interviewing candidates</li>
<li> Generate interview links and share with candidates</li>
<li> View detailed interview reports and analytics</li>
</ul>
</div>
{/* Action Buttons */}
<div className="space-y-3">
<button
onClick={handleGoToDashboard}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Go to Dashboard
</button>
<button
onClick={handleBuyMoreTokens}
className="w-full px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
Buy More Tokens
</button>
</div>
{/* Support Link */}
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-600">
<p className="text-xs text-gray-500 dark:text-gray-400">
Need help? <Link href="/docs" className="text-blue-600 dark:text-blue-400 hover:underline">Contact Support</Link>
</p>
</div>
</div>
</div>
);
}
export default function PaymentSuccessPage() {
return (
<Suspense fallback={
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
</div>
}>
<PaymentSuccessContent />
</Suspense>
);
}
@@ -0,0 +1,273 @@
"use client";
import { useState, useEffect } from "react";
import axios from "axios";
import { PricingService, PricingCalculation, TokenPackage } from "../services/PricingService";
// Remove duplicate interface - using from PricingService
interface CustomTokenCalculatorProps {
onCalculationChange: (calculation: PricingCalculation) => void;
onPackageSelect: (packageId: string) => void;
selectedPackageId?: string;
}
export default function CustomTokenCalculator({
onCalculationChange,
onPackageSelect,
selectedPackageId
}: CustomTokenCalculatorProps) {
const [quantity, setQuantity] = useState(1);
const [packages, setPackages] = useState<TokenPackage[]>([]);
const [calculation, setCalculation] = useState<PricingCalculation | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchPackages();
}, []);
useEffect(() => {
if (quantity > 0) {
calculatePrice();
}
}, [quantity, selectedPackageId]);
const fetchPackages = async () => {
try {
const token = localStorage.getItem("token");
const response = await axios.get(
`${process.env.NEXT_PUBLIC_API_URL}/rest/admin/token-packages`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (response.data.success) {
setPackages(response.data.packages.filter((pkg: TokenPackage) => pkg.is_active));
}
} catch (err) {
console.error("Failed to fetch packages:", err);
}
};
const calculatePrice = async () => {
if (quantity <= 0) return;
setLoading(true);
setError(null);
try {
// Validate quantity first
const validation = PricingService.validateQuantity(quantity);
if (!validation.isValid) {
setError(validation.error || "Invalid quantity");
return;
}
// Calculate price using PricingService
const calc = PricingService.calculatePrice(quantity, packages, selectedPackageId);
setCalculation(calc);
onCalculationChange(calc);
} catch (err: any) {
setError(err.message || "Failed to calculate price");
} finally {
setLoading(false);
}
};
const handleQuantityChange = (newQuantity: number) => {
if (newQuantity >= 1 && newQuantity <= 1000) {
setQuantity(newQuantity);
}
};
const handlePackageSelect = (packageId: string) => {
onPackageSelect(packageId);
};
const getBestPackageForQuantity = (qty: number) => {
return packages
.filter(pkg => pkg.quantity <= qty)
.sort((a, b) => b.quantity - a.quantity)[0];
};
const getRecommendedPackage = () => {
if (quantity <= 1) return null;
return getBestPackageForQuantity(quantity);
};
const recommendedPackage = getRecommendedPackage();
return (
<div className="space-y-6">
{/* Quantity Input */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Number of Tokens
</label>
<div className="flex items-center space-x-3">
<button
onClick={() => handleQuantityChange(quantity - 1)}
disabled={quantity <= 1}
className="w-10 h-10 rounded-lg border border-gray-300 dark:border-gray-600 flex items-center justify-center hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
</svg>
</button>
<input
type="number"
value={quantity}
onChange={(e) => handleQuantityChange(parseInt(e.target.value) || 1)}
min="1"
max="1000"
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-center"
/>
<button
onClick={() => handleQuantityChange(quantity + 1)}
disabled={quantity >= 1000}
className="w-10 h-10 rounded-lg border border-gray-300 dark:border-gray-600 flex items-center justify-center hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</button>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Choose between 1 and 1000 tokens
</p>
</div>
{/* Package Selection */}
{packages.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Choose Package (Optional)
</label>
<div className="space-y-2">
<button
onClick={() => handlePackageSelect('')}
className={`w-full p-3 rounded-lg border text-left transition-colors ${
!selectedPackageId
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'
}`}
>
<div className="font-medium">Custom Amount</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{quantity} token{quantity > 1 ? 's' : ''} at 5.00 each
</div>
</button>
{packages.map((pkg) => (
<button
key={pkg.id}
onClick={() => handlePackageSelect(pkg.id)}
className={`w-full p-3 rounded-lg border text-left transition-colors ${
selectedPackageId === pkg.id
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'
}`}
>
<div className="flex items-center justify-between">
<div>
<div className="font-medium">{pkg.name}</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{pkg.description}
</div>
</div>
<div className="text-right">
<div className="font-medium">{pkg.total_price.toFixed(2)}</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{pkg.discount_percentage}% off
</div>
</div>
</div>
</button>
))}
</div>
</div>
)}
{/* Price Calculation */}
{calculation && (
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
<h3 className="font-medium text-gray-900 dark:text-white mb-3">Price Breakdown</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">
{quantity} token{quantity > 1 ? 's' : ''} × {PricingService.formatPrice(PricingService.BASE_PRICE_PER_TOKEN)}
</span>
<span className="text-gray-900 dark:text-white">
{PricingService.formatPrice(calculation.basePrice)}
</span>
</div>
{calculation.savings > 0 && (
<>
<div className="flex justify-between text-green-600 dark:text-green-400">
<span>Discount ({calculation.discountPercentage}%)</span>
<span>-{PricingService.formatPrice(calculation.savings)}</span>
</div>
{calculation.packageName && (
<div className="text-xs text-gray-500 dark:text-gray-400">
Applied from {calculation.packageName} package
</div>
)}
</>
)}
<div className="border-t border-gray-200 dark:border-gray-600 pt-2 flex justify-between font-medium text-lg">
<span className="text-gray-900 dark:text-white">Total</span>
<span className="text-gray-900 dark:text-white">
{PricingService.formatPrice(calculation.finalPrice)}
</span>
</div>
</div>
{calculation.savings > 0 && (
<div className="mt-3 p-2 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div className="text-sm text-green-700 dark:text-green-300">
🎉 You save {PricingService.formatPrice(calculation.savings)} with this package!
</div>
</div>
)}
</div>
)}
{/* Recommendation */}
{recommendedPackage && !selectedPackageId && quantity > 1 && (
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="flex items-start space-x-2">
<div className="text-blue-500 mt-0.5">💡</div>
<div>
<div className="text-sm font-medium text-blue-800 dark:text-blue-200">
Recommended Package
</div>
<div className="text-sm text-blue-700 dark:text-blue-300">
Consider the <strong>{recommendedPackage.name}</strong> package for better value.
You'll get {recommendedPackage.quantity} tokens with {recommendedPackage.discount_percentage}% discount.
</div>
</div>
</div>
</div>
)}
{/* Loading State */}
{loading && (
<div className="flex items-center justify-center py-4">
<div className="w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
</div>
)}
{/* Error State */}
{error && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
</div>
);
}
+138
View File
@@ -0,0 +1,138 @@
"use client";
import { useState } from "react";
import { ErrorService } from "../services/ErrorService";
interface ErrorDisplayProps {
error: any;
onRetry?: () => void;
onDismiss?: () => void;
showDetails?: boolean;
className?: string;
}
export default function ErrorDisplay({
error,
onRetry,
onDismiss,
showDetails = false,
className = ""
}: ErrorDisplayProps) {
const [showFullDetails, setShowFullDetails] = useState(false);
if (!error) return null;
const errorInfo = ErrorService.formatError(error);
const severity = ErrorService.getErrorSeverity(error);
const retryable = ErrorService.isRetryable(error);
const getSeverityStyles = () => {
switch (severity) {
case 'low':
return 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800 text-yellow-800 dark:text-yellow-200';
case 'medium':
return 'bg-orange-50 dark:bg-orange-900/20 border-orange-200 dark:border-orange-800 text-orange-800 dark:text-orange-200';
case 'high':
return 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800 text-red-800 dark:text-red-200';
case 'critical':
return 'bg-red-100 dark:bg-red-900/30 border-red-300 dark:border-red-700 text-red-900 dark:text-red-100';
default:
return 'bg-gray-50 dark:bg-gray-900/20 border-gray-200 dark:border-gray-800 text-gray-800 dark:text-gray-200';
}
};
const getSeverityIcon = () => {
switch (severity) {
case 'low':
return (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="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" clipRule="evenodd" />
</svg>
);
case 'medium':
return (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
);
case 'high':
case 'critical':
return (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="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" clipRule="evenodd" />
</svg>
);
default:
return (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
);
}
};
return (
<div className={`rounded-lg border p-4 ${getSeverityStyles()} ${className}`}>
<div className="flex items-start">
<div className="flex-shrink-0 mr-3">
{getSeverityIcon()}
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium mb-1">
{errorInfo.title}
</h3>
<p className="text-sm mb-3">
{errorInfo.message}
</p>
{showDetails && (
<div className="mb-3">
<button
onClick={() => setShowFullDetails(!showFullDetails)}
className="text-xs underline hover:no-underline"
>
{showFullDetails ? 'Hide' : 'Show'} technical details
</button>
{showFullDetails && (
<div className="mt-2 p-2 bg-black/5 dark:bg-white/5 rounded text-xs font-mono">
<pre className="whitespace-pre-wrap">
{JSON.stringify(error, null, 2)}
</pre>
</div>
)}
</div>
)}
<div className="flex items-center justify-between">
<div className="text-xs opacity-75">
{errorInfo.action}
</div>
<div className="flex items-center space-x-2">
{retryable && onRetry && (
<button
onClick={onRetry}
className="text-xs px-3 py-1 bg-white/20 dark:bg-black/20 rounded hover:bg-white/30 dark:hover:bg-black/30 transition-colors"
>
Try Again
</button>
)}
{onDismiss && (
<button
onClick={onDismiss}
className="text-xs px-3 py-1 bg-white/20 dark:bg-black/20 rounded hover:bg-white/30 dark:hover:bg-black/30 transition-colors"
>
Dismiss
</button>
)}
</div>
</div>
</div>
</div>
</div>
);
}
+44
View File
@@ -3,6 +3,8 @@
import { useState, useEffect } from "react";
import axios from "axios";
import ThemeToggle from "./ThemeToggle";
import TokenPurchaseFlow from "./TokenPurchaseFlow";
import PaymentHistory from "./PaymentHistory";
interface User {
first_name: string;
@@ -27,6 +29,8 @@ interface HeaderProps {
export default function Header({ title, user, onLogout }: HeaderProps) {
const [tokenSummary, setTokenSummary] = useState<TokenSummary | null>(null);
const [loadingTokens, setLoadingTokens] = useState(false);
const [showPurchaseModal, setShowPurchaseModal] = useState(false);
const [showPaymentHistory, setShowPaymentHistory] = useState(false);
useEffect(() => {
if (user && user.role === 'recruiter') {
@@ -74,6 +78,14 @@ export default function Header({ title, user, onLogout }: HeaderProps) {
}
};
const handlePurchaseSuccess = (paymentData: any) => {
console.log('Payment successful:', paymentData);
// Refresh token summary
fetchTokenSummary();
// Dispatch event for other components
window.dispatchEvent(new CustomEvent('tokensUpdated'));
};
return (
<header className="bg-white dark:bg-gray-900 shadow-sm border-b border-gray-200 dark:border-gray-700 px-6 py-4">
<div className="flex items-center justify-between">
@@ -98,6 +110,25 @@ export default function Header({ title, user, onLogout }: HeaderProps) {
{tokenSummary.total_available} tokens
</span>
</div>
{/* Buy Tokens Button */}
<button
onClick={() => setShowPurchaseModal(true)}
className="px-3 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors"
>
Buy Tokens
</button>
{/* Payment History Button */}
<button
onClick={() => setShowPaymentHistory(true)}
className="p-1.5 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
title="Payment History"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</button>
{/* Token Usage Progress */}
<div className="flex items-center space-x-2">
@@ -177,6 +208,19 @@ export default function Header({ title, user, onLogout }: HeaderProps) {
</div>
</div>
</div>
{/* Token Purchase Modal */}
<TokenPurchaseFlow
isOpen={showPurchaseModal}
onClose={() => setShowPurchaseModal(false)}
onSuccess={handlePurchaseSuccess}
/>
{/* Payment History Modal */}
<PaymentHistory
isOpen={showPaymentHistory}
onClose={() => setShowPaymentHistory(false)}
/>
</header>
);
}
+208
View File
@@ -0,0 +1,208 @@
"use client";
interface LoadingSpinnerProps {
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export function LoadingSpinner({ size = 'md', className = '' }: LoadingSpinnerProps) {
const sizeClasses = {
sm: 'w-4 h-4',
md: 'w-6 h-6',
lg: 'w-8 h-8'
};
return (
<div className={`${sizeClasses[size]} ${className}`}>
<div className="w-full h-full border-2 border-gray-300 dark:border-gray-600 border-t-blue-600 rounded-full animate-spin"></div>
</div>
);
}
interface LoadingDotsProps {
className?: string;
}
export function LoadingDots({ className = '' }: LoadingDotsProps) {
return (
<div className={`flex space-x-1 ${className}`}>
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
</div>
);
}
interface LoadingCardProps {
title?: string;
description?: string;
className?: string;
}
export function LoadingCard({ title = 'Loading...', description, className = '' }: LoadingCardProps) {
return (
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 ${className}`}>
<div className="flex items-center justify-center mb-4">
<LoadingSpinner size="lg" />
</div>
<div className="text-center">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
{title}
</h3>
{description && (
<p className="text-sm text-gray-600 dark:text-gray-400">
{description}
</p>
)}
</div>
</div>
);
}
interface LoadingOverlayProps {
isVisible: boolean;
message?: string;
className?: string;
}
export function LoadingOverlay({ isVisible, message = 'Loading...', className = '' }: LoadingOverlayProps) {
if (!isVisible) return null;
return (
<div className={`fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 ${className}`}>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 max-w-sm w-full mx-4">
<div className="flex items-center justify-center mb-4">
<LoadingSpinner size="lg" />
</div>
<div className="text-center">
<p className="text-gray-900 dark:text-white font-medium">
{message}
</p>
</div>
</div>
</div>
);
}
interface ProgressBarProps {
progress: number; // 0-100
className?: string;
showPercentage?: boolean;
}
export function ProgressBar({ progress, className = '', showPercentage = true }: ProgressBarProps) {
const clampedProgress = Math.max(0, Math.min(100, progress));
return (
<div className={`w-full ${className}`}>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Progress
</span>
{showPercentage && (
<span className="text-sm text-gray-500 dark:text-gray-400">
{Math.round(clampedProgress)}%
</span>
)}
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300 ease-out"
style={{ width: `${clampedProgress}%` }}
></div>
</div>
</div>
);
}
interface SkeletonProps {
className?: string;
lines?: number;
}
export function Skeleton({ className = '', lines = 1 }: SkeletonProps) {
return (
<div className={`animate-pulse ${className}`}>
{Array.from({ length: lines }).map((_, index) => (
<div
key={index}
className="h-4 bg-gray-200 dark:bg-gray-700 rounded mb-2"
style={{ width: `${Math.random() * 40 + 60}%` }}
></div>
))}
</div>
);
}
interface LoadingButtonProps {
isLoading: boolean;
children: React.ReactNode;
loadingText?: string;
disabled?: boolean;
className?: string;
onClick?: () => void;
type?: 'button' | 'submit' | 'reset';
}
export function LoadingButton({
isLoading,
children,
loadingText = 'Loading...',
disabled = false,
className = '',
onClick,
type = 'button'
}: LoadingButtonProps) {
return (
<button
type={type}
onClick={onClick}
disabled={disabled || isLoading}
className={`relative ${className} ${(disabled || isLoading) ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<LoadingSpinner size="sm" className="mr-2" />
<span>{loadingText}</span>
</div>
)}
<span className={isLoading ? 'opacity-0' : ''}>
{children}
</span>
</button>
);
}
interface LoadingTableProps {
rows?: number;
columns?: number;
className?: string;
}
export function LoadingTable({ rows = 5, columns = 4, className = '' }: LoadingTableProps) {
return (
<div className={`overflow-hidden ${className}`}>
<div className="animate-pulse">
{/* Header */}
<div className="grid gap-4 mb-4" style={{ gridTemplateColumns: `repeat(${columns}, 1fr)` }}>
{Array.from({ length: columns }).map((_, index) => (
<div key={index} className="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
))}
</div>
{/* Rows */}
{Array.from({ length: rows }).map((_, rowIndex) => (
<div key={rowIndex} className="grid gap-4 mb-3" style={{ gridTemplateColumns: `repeat(${columns}, 1fr)` }}>
{Array.from({ length: columns }).map((_, colIndex) => (
<div
key={colIndex}
className="h-3 bg-gray-200 dark:bg-gray-700 rounded"
style={{ width: `${Math.random() * 40 + 60}%` }}
></div>
))}
</div>
))}
</div>
</div>
);
}
+231
View File
@@ -0,0 +1,231 @@
"use client";
import { useState, useEffect } from "react";
import axios from "axios";
interface PaymentRecord {
id: string;
amount: number;
currency: string;
status: string;
payment_flow_type: string;
custom_quantity?: number;
applied_discount_percentage?: number;
package_name?: string;
created_at: string;
paid_at?: string;
refunded_amount?: number;
refund_reason?: string;
}
interface PaymentHistoryProps {
isOpen: boolean;
onClose: () => void;
}
export default function PaymentHistory({ isOpen, onClose }: PaymentHistoryProps) {
const [payments, setPayments] = useState<PaymentRecord[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (isOpen) {
fetchPaymentHistory();
}
}, [isOpen]);
const fetchPaymentHistory = async () => {
try {
setLoading(true);
const token = localStorage.getItem("token");
const response = await axios.get(
`${process.env.NEXT_PUBLIC_API_URL}/rest/payments/history`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (response.data.success) {
setPayments(response.data.payments);
} else {
setError("Failed to load payment history");
}
} catch (err: any) {
setError(err.response?.data?.message || "Failed to load payment history");
} finally {
setLoading(false);
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'paid':
return 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300';
case 'pending':
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300';
case 'failed':
return 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-300';
case 'cancelled':
return 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-300';
case 'refunded':
return 'bg-purple-100 text-purple-800 dark:bg-purple-900/20 dark:text-purple-300';
default:
return 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-300';
}
};
const getPaymentMethodIcon = (flowType: string) => {
switch (flowType) {
case 'card':
return '💳';
case 'ideal':
return '🏦';
case 'bank_transfer':
return '🏛️';
default:
return '💰';
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
const formatCurrency = (amount: number, currency: string) => {
return new Intl.NumberFormat('en-EU', {
style: 'currency',
currency: currency.toUpperCase()
}).format(amount);
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
Payment History
</h2>
<p className="text-gray-600 dark:text-gray-400 mt-1">
View all your token purchases and payments
</p>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Content */}
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
</div>
) : error ? (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-red-600 dark:text-red-400">{error}</p>
</div>
) : payments.length === 0 ? (
<div className="text-center py-12">
<div className="text-gray-400 dark:text-gray-500 mb-4">
<svg className="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
No payments yet
</h3>
<p className="text-gray-600 dark:text-gray-400">
Your payment history will appear here once you make your first purchase.
</p>
</div>
) : (
<div className="space-y-4">
{payments.map((payment) => (
<div
key={payment.id}
className="border border-gray-200 dark:border-gray-600 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="text-2xl">
{getPaymentMethodIcon(payment.payment_flow_type)}
</div>
<div>
<div className="font-medium text-gray-900 dark:text-white">
{payment.package_name || `${payment.custom_quantity} Token${payment.custom_quantity && payment.custom_quantity > 1 ? 's' : ''}`}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{formatDate(payment.created_at)}
{payment.paid_at && (
<span className="ml-2">
Paid {formatDate(payment.paid_at)}
</span>
)}
</div>
{payment.applied_discount_percentage && payment.applied_discount_percentage > 0 && (
<div className="text-xs text-green-600 dark:text-green-400 mt-1">
{payment.applied_discount_percentage}% discount applied
</div>
)}
</div>
</div>
<div className="text-right">
<div className="font-bold text-lg text-gray-900 dark:text-white">
{formatCurrency(payment.amount, payment.currency)}
</div>
<div className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(payment.status)}`}>
{payment.status.charAt(0).toUpperCase() + payment.status.slice(1)}
</div>
{payment.refunded_amount && (
<div className="text-xs text-purple-600 dark:text-purple-400 mt-1">
Refunded: {formatCurrency(payment.refunded_amount, payment.currency)}
</div>
)}
</div>
</div>
{payment.refund_reason && (
<div className="mt-3 p-2 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
<div className="text-xs text-purple-700 dark:text-purple-300">
<strong>Refund reason:</strong> {payment.refund_reason}
</div>
</div>
)}
</div>
))}
</div>
)}
{/* Footer */}
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-600">
<div className="flex justify-between items-center">
<div className="text-sm text-gray-600 dark:text-gray-400">
{payments.length} payment{payments.length !== 1 ? 's' : ''} total
</div>
<button
onClick={onClose}
className="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600"
>
Close
</button>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,160 @@
"use client";
import { useState } from "react";
interface PaymentMethodSelectorProps {
selectedMethod: 'card' | 'ideal' | 'bank_transfer';
onMethodChange: (method: 'card' | 'ideal' | 'bank_transfer') => void;
countryCode?: string;
disabled?: boolean;
}
export default function PaymentMethodSelector({
selectedMethod,
onMethodChange,
countryCode,
disabled = false
}: PaymentMethodSelectorProps) {
const [idealBanks] = useState([
{ id: 'abn_amro', name: 'ABN AMRO', logo: '🏦' },
{ id: 'asn_bank', name: 'ASN Bank', logo: '🏛️' },
{ id: 'bunq', name: 'bunq', logo: '📱' },
{ id: 'handelsbanken', name: 'Handelsbanken', logo: '🏦' },
{ id: 'ing', name: 'ING', logo: '🦁' },
{ id: 'knab', name: 'Knab', logo: '💚' },
{ id: 'rabobank', name: 'Rabobank', logo: '🐄' },
{ id: 'regiobank', name: 'RegioBank', logo: '🏦' },
{ id: 'revolut', name: 'Revolut', logo: '💳' },
{ id: 'sns_bank', name: 'SNS Bank', logo: '🏦' },
{ id: 'triodos_bank', name: 'Triodos Bank', logo: '🌱' },
{ id: 'van_lanschot', name: 'Van Lanschot', logo: '🏦' },
]);
const getAvailableMethods = () => {
const methods: Array<{
id: 'card' | 'ideal' | 'bank_transfer';
name: string;
description: string;
icon: string;
available: boolean;
}> = [
{
id: 'card',
name: 'Credit/Debit Card',
description: 'Visa, Mastercard, American Express',
icon: '💳',
available: true
}
];
if (countryCode === 'NL') {
methods.push({
id: 'ideal',
name: 'iDEAL',
description: 'Direct bank transfer (Netherlands)',
icon: '🏦',
available: true
});
}
if (['DE', 'FR', 'ES', 'IT', 'NL'].includes(countryCode || '')) {
methods.push({
id: 'bank_transfer',
name: 'SEPA Direct Debit',
description: 'European bank transfer',
icon: '🏛️',
available: true
});
}
return methods;
};
const availableMethods = getAvailableMethods();
return (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Choose Payment Method
</label>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{availableMethods.map((method) => (
<button
key={method.id}
type="button"
onClick={() => onMethodChange(method.id)}
disabled={disabled || !method.available}
className={`p-4 rounded-lg border text-left transition-all duration-200 ${
selectedMethod === method.id
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 ring-2 ring-blue-500 ring-opacity-50'
: method.available
? 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500 hover:bg-gray-50 dark:hover:bg-gray-700'
: 'border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-gray-400 dark:text-gray-500 cursor-not-allowed'
}`}
>
<div className="flex items-center space-x-3">
<div className="text-2xl">{method.icon}</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm">{method.name}</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{method.description}
</div>
</div>
{selectedMethod === method.id && (
<div className="text-blue-500">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="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" clipRule="evenodd" />
</svg>
</div>
)}
</div>
</button>
))}
</div>
</div>
{/* iDEAL Bank Selection */}
{selectedMethod === 'ideal' && (
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Select Your Bank
</label>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
{idealBanks.map((bank) => (
<button
key={bank.id}
type="button"
className="p-3 rounded-lg border border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-center"
>
<div className="text-lg mb-1">{bank.logo}</div>
<div className="text-xs font-medium text-gray-700 dark:text-gray-300">
{bank.name}
</div>
</button>
))}
</div>
</div>
)}
{/* Payment Method Info */}
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-3">
<div className="flex items-start space-x-2">
<div className="text-blue-500 mt-0.5">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
</div>
<div className="text-sm text-blue-700 dark:text-blue-300">
<div className="font-medium mb-1">Secure Payment</div>
<div className="text-xs">
{selectedMethod === 'card' && 'Your card details are encrypted and processed securely by Stripe.'}
{selectedMethod === 'ideal' && 'You will be redirected to your bank for secure authentication.'}
{selectedMethod === 'bank_transfer' && 'You will be redirected to your bank to authorize the payment.'}
</div>
</div>
</div>
</div>
</div>
);
}
+275
View File
@@ -0,0 +1,275 @@
"use client";
import { useState, useEffect } from "react";
import { useStripe, useElements, PaymentElement } from '@stripe/react-stripe-js';
import axios from "axios";
import PaymentMethodSelector from "./PaymentMethodSelector";
import ErrorDisplay from "./ErrorDisplay";
import { LoadingSpinner, LoadingButton } from "./LoadingStates";
import { ErrorService } from "../services/ErrorService";
interface PaymentModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: (paymentData: any) => void;
tokenQuantity: number;
packageId?: string;
packageName?: string;
calculation: {
basePrice: number;
discountPercentage: number;
finalPrice: number;
savings: number;
};
}
export default function PaymentModal({
isOpen,
onClose,
onSuccess,
tokenQuantity,
packageId,
packageName,
calculation
}: PaymentModalProps) {
const stripe = useStripe();
const elements = useElements();
const [paymentMethod, setPaymentMethod] = useState<'card' | 'ideal' | 'bank_transfer'>('card');
const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState<any>(null);
const [clientSecret, setClientSecret] = useState<string | null>(null);
const [paymentIntentId, setPaymentIntentId] = useState<string | null>(null);
const [availableMethods, setAvailableMethods] = useState<string[]>([]);
useEffect(() => {
if (isOpen && !clientSecret) {
fetchAvailableMethods();
createPaymentIntent();
}
}, [isOpen]);
const fetchAvailableMethods = async () => {
try {
const token = localStorage.getItem("token");
const response = await axios.get(
`${process.env.NEXT_PUBLIC_API_URL}/rest/payments/methods`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (response.data.success) {
setAvailableMethods(response.data.paymentMethods);
}
} catch (err) {
console.error("Failed to fetch payment methods:", err);
}
};
const createPaymentIntent = async () => {
try {
const token = localStorage.getItem("token");
if (!token) {
setError("Authentication required");
return;
}
const response = await axios.post(
`${process.env.NEXT_PUBLIC_API_URL}/rest/payments/create-intent`,
{
packageId,
customQuantity: tokenQuantity,
paymentFlowType: paymentMethod,
},
{
headers: { Authorization: `Bearer ${token}` }
}
);
if (response.data.success) {
setClientSecret(response.data.paymentIntent.client_secret);
setPaymentIntentId(response.data.paymentIntent.id);
setError(null);
} else {
setError(new Error("Failed to create payment intent"));
}
} catch (err: any) {
setError(err);
}
};
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!stripe || !elements || !clientSecret) {
return;
}
setIsProcessing(true);
setError(null);
try {
const { error, paymentIntent } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: `${window.location.origin}/payment/success`,
},
redirect: 'if_required',
});
if (error) {
setError(error);
} else if (paymentIntent.status === 'succeeded') {
// Confirm payment on backend
await confirmPayment(paymentIntent.id);
onSuccess({
paymentIntent,
tokensAllocated: tokenQuantity,
amount: calculation.finalPrice,
});
onClose();
}
} catch (err: any) {
setError(err);
} finally {
setIsProcessing(false);
}
};
const confirmPayment = async (paymentIntentId: string) => {
try {
const token = localStorage.getItem("token");
await axios.post(
`${process.env.NEXT_PUBLIC_API_URL}/rest/payments/confirm`,
{ paymentIntentId },
{
headers: { Authorization: `Bearer ${token}` }
}
);
} catch (err) {
console.error("Failed to confirm payment:", err);
}
};
const handleClose = () => {
if (!isProcessing) {
setError(null);
setClientSecret(null);
setPaymentIntentId(null);
onClose();
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
Complete Payment
</h2>
<button
onClick={handleClose}
disabled={isProcessing}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-50"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Order Summary */}
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 mb-6">
<h3 className="font-medium text-gray-900 dark:text-white mb-3">Order Summary</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">
{packageName || `${tokenQuantity} Token${tokenQuantity > 1 ? 's' : ''}`}
</span>
<span className="text-gray-900 dark:text-white">
{calculation.finalPrice.toFixed(2)}
</span>
</div>
{calculation.savings > 0 && (
<div className="flex justify-between text-green-600 dark:text-green-400">
<span>Discount ({calculation.discountPercentage}%)</span>
<span>-{calculation.savings.toFixed(2)}</span>
</div>
)}
<div className="border-t border-gray-200 dark:border-gray-600 pt-2 flex justify-between font-medium">
<span className="text-gray-900 dark:text-white">Total</span>
<span className="text-gray-900 dark:text-white">
{calculation.finalPrice.toFixed(2)}
</span>
</div>
</div>
</div>
{/* Payment Method Selection */}
<div className="mb-6">
<PaymentMethodSelector
selectedMethod={paymentMethod}
onMethodChange={setPaymentMethod}
countryCode="NL" // This could be dynamic based on user location
disabled={isProcessing}
/>
</div>
{/* Payment Form */}
{clientSecret && (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="p-4 border border-gray-200 dark:border-gray-600 rounded-lg">
<PaymentElement
options={{
layout: 'tabs',
}}
/>
</div>
{error && (
<ErrorDisplay
error={error}
onRetry={() => {
setError(null);
createPaymentIntent();
}}
onDismiss={() => setError(null)}
showDetails={true}
/>
)}
<div className="flex space-x-3">
<button
type="button"
onClick={handleClose}
disabled={isProcessing}
className="flex-1 px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50"
>
Cancel
</button>
<LoadingButton
type="submit"
isLoading={isProcessing}
loadingText="Processing..."
disabled={!stripe}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Pay {calculation.finalPrice.toFixed(2)}
</LoadingButton>
</div>
</form>
)}
{!clientSecret && !error && (
<div className="flex items-center justify-center py-8">
<LoadingSpinner size="lg" />
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,99 @@
"use client";
import { PurchaseStep } from "../services/PurchaseFlowService";
interface PurchaseFlowProgressProps {
steps: PurchaseStep[];
currentStep: number;
className?: string;
}
export default function PurchaseFlowProgress({
steps,
currentStep,
className = ""
}: PurchaseFlowProgressProps) {
return (
<div className={`w-full ${className}`}>
<div className="flex items-center justify-between">
{steps.map((step, index) => (
<div key={step.id} className="flex items-center">
{/* Step Circle */}
<div className="flex items-center justify-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium transition-colors ${
step.completed
? 'bg-green-500 text-white'
: step.current
? 'bg-blue-500 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
}`}
>
{step.completed ? (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="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" clipRule="evenodd" />
</svg>
) : (
index + 1
)}
</div>
</div>
{/* Step Content */}
<div className="ml-3 min-w-0 flex-1">
<div className="flex items-center">
<h3
className={`text-sm font-medium ${
step.current
? 'text-blue-600 dark:text-blue-400'
: step.completed
? 'text-green-600 dark:text-green-400'
: 'text-gray-500 dark:text-gray-400'
}`}
>
{step.title}
</h3>
{step.error && (
<div className="ml-2">
<svg className="w-4 h-4 text-red-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</div>
)}
</div>
<p
className={`text-xs mt-1 ${
step.current
? 'text-blue-500 dark:text-blue-300'
: step.completed
? 'text-green-500 dark:text-green-300'
: 'text-gray-400 dark:text-gray-500'
}`}
>
{step.description}
</p>
{step.error && (
<p className="text-xs text-red-500 dark:text-red-400 mt-1">
{step.error}
</p>
)}
</div>
{/* Connector Line */}
{index < steps.length - 1 && (
<div className="flex-1 mx-4">
<div
className={`h-0.5 ${
step.completed
? 'bg-green-500'
: 'bg-gray-200 dark:bg-gray-700'
}`}
></div>
</div>
)}
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,19 @@
"use client";
import { loadStripe } from '@stripe/stripe-js';
import { Elements } from '@stripe/react-stripe-js';
import { ReactNode } from 'react';
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
interface StripeProviderProps {
children: ReactNode;
}
export default function StripeProvider({ children }: StripeProviderProps) {
return (
<Elements stripe={stripePromise}>
{children}
</Elements>
);
}
@@ -0,0 +1,278 @@
"use client";
import { useState, useEffect } from "react";
import StripeProvider from "./StripeProvider";
import PaymentModal from "./PaymentModal";
import CustomTokenCalculator from "./CustomTokenCalculator";
import PurchaseFlowProgress from "./PurchaseFlowProgress";
import { LoadingCard } from "./LoadingStates";
import { PurchaseFlowService, PurchaseFlowState } from "../services/PurchaseFlowService";
import { PricingCalculation } from "../services/PricingService";
import axios from "axios";
interface TokenPackage {
id: string;
name: string;
description: string;
quantity: number;
price_per_token: number;
total_price: number;
discount_percentage: number;
is_popular: boolean;
is_active: boolean;
}
// Remove duplicate interface - using from PricingService
interface TokenPurchaseFlowProps {
isOpen: boolean;
onClose: () => void;
onSuccess: (data: any) => void;
}
export default function TokenPurchaseFlow({
isOpen,
onClose,
onSuccess
}: TokenPurchaseFlowProps) {
const [packages, setPackages] = useState<TokenPackage[]>([]);
const [selectedPackageId, setSelectedPackageId] = useState<string>('');
const [calculation, setCalculation] = useState<PricingCalculation | null>(null);
const [showPaymentModal, setShowPaymentModal] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [flowState, setFlowState] = useState<PurchaseFlowState>(PurchaseFlowService.initializeFlow());
useEffect(() => {
if (isOpen) {
fetchPackages();
}
}, [isOpen]);
const fetchPackages = async () => {
try {
setLoading(true);
const token = localStorage.getItem("token");
const response = await axios.get(
`${process.env.NEXT_PUBLIC_API_URL}/rest/admin/token-packages`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (response.data.success) {
setPackages(response.data.packages.filter((pkg: TokenPackage) => pkg.is_active));
}
} catch (err: any) {
setError(err.response?.data?.message || "Failed to load packages");
} finally {
setLoading(false);
}
};
const handleCalculationChange = (calc: PricingCalculation) => {
setCalculation(calc);
setFlowState(prev => PurchaseFlowService.setCalculation(prev, calc));
};
const handlePackageSelect = (packageId: string) => {
setSelectedPackageId(packageId);
};
const handlePurchase = () => {
if (calculation && calculation.finalPrice > 0) {
setShowPaymentModal(true);
}
};
const handlePaymentSuccess = (paymentData: any) => {
setShowPaymentModal(false);
onSuccess(paymentData);
onClose();
};
const handleClose = () => {
setSelectedPackageId('');
setCalculation(null);
setError(null);
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
Buy Interview Tokens
</h2>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Purchase tokens to conduct AI-powered interviews
</p>
</div>
<button
onClick={handleClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Progress Indicator */}
<div className="mb-8">
<PurchaseFlowProgress
steps={flowState.steps}
currentStep={flowState.currentStep}
/>
</div>
{loading ? (
<LoadingCard
title="Loading packages..."
description="Please wait while we load available token packages"
/>
) : error ? (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-red-600 dark:text-red-400">{error}</p>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Left Column - Calculator */}
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Choose Your Tokens
</h3>
<CustomTokenCalculator
onCalculationChange={handleCalculationChange}
onPackageSelect={handlePackageSelect}
selectedPackageId={selectedPackageId}
/>
</div>
{/* Right Column - Packages & Summary */}
<div className="space-y-6">
{/* Popular Packages */}
{packages.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Popular Packages
</h3>
<div className="space-y-3">
{packages
.filter(pkg => pkg.is_popular)
.map((pkg) => (
<div
key={pkg.id}
className={`p-4 rounded-lg border cursor-pointer transition-colors ${
selectedPackageId === pkg.id
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'
}`}
onClick={() => handlePackageSelect(pkg.id)}
>
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-gray-900 dark:text-white">
{pkg.name}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{pkg.description}
</div>
<div className="text-sm text-green-600 dark:text-green-400 mt-1">
{pkg.discount_percentage}% discount
</div>
</div>
<div className="text-right">
<div className="font-bold text-lg text-gray-900 dark:text-white">
{pkg.total_price.toFixed(2)}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{pkg.price_per_token.toFixed(2)} per token
</div>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Purchase Summary */}
{calculation && (
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
<h3 className="font-medium text-gray-900 dark:text-white mb-3">
Purchase Summary
</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">
{calculation.packageName || `${calculation.quantity} Token${calculation.quantity > 1 ? 's' : ''}`}
</span>
<span className="text-gray-900 dark:text-white">
{calculation.finalPrice.toFixed(2)}
</span>
</div>
{calculation.savings > 0 && (
<div className="flex justify-between text-green-600 dark:text-green-400">
<span>Discount ({calculation.discountPercentage}%)</span>
<span>-{calculation.savings.toFixed(2)}</span>
</div>
)}
<div className="border-t border-gray-200 dark:border-gray-600 pt-2 flex justify-between font-medium text-lg">
<span className="text-gray-900 dark:text-white">Total</span>
<span className="text-gray-900 dark:text-white">
{calculation.finalPrice.toFixed(2)}
</span>
</div>
</div>
<button
onClick={handlePurchase}
disabled={!calculation || calculation.finalPrice <= 0}
className="w-full mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Proceed to Payment
</button>
</div>
)}
{/* Features */}
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
<h3 className="font-medium text-blue-900 dark:text-blue-200 mb-2">
What you get:
</h3>
<ul className="text-sm text-blue-800 dark:text-blue-300 space-y-1">
<li> AI-powered interview questions</li>
<li> Real-time candidate evaluation</li>
<li> Detailed interview reports</li>
<li> No expiration date</li>
<li> Instant activation</li>
</ul>
</div>
</div>
</div>
)}
{/* Payment Modal */}
{showPaymentModal && calculation && (
<StripeProvider>
<PaymentModal
isOpen={showPaymentModal}
onClose={() => setShowPaymentModal(false)}
onSuccess={handlePaymentSuccess}
tokenQuantity={calculation.quantity}
packageId={calculation.packageId}
packageName={calculation.packageName}
calculation={calculation}
/>
</StripeProvider>
)}
</div>
</div>
</div>
);
}
+13
View File
@@ -6,6 +6,19 @@ export { default as Layout } from './Layout';
export { default as CreateJobModal } from './CreateJobModal';
export { default as ThemeToggle } from './ThemeToggle';
// Payment components
export { default as StripeProvider } from './StripeProvider';
export { default as PaymentModal } from './PaymentModal';
export { default as TokenPurchaseFlow } from './TokenPurchaseFlow';
export { default as PaymentHistory } from './PaymentHistory';
export { default as CustomTokenCalculator } from './CustomTokenCalculator';
export { default as PaymentMethodSelector } from './PaymentMethodSelector';
export { default as PurchaseFlowProgress } from './PurchaseFlowProgress';
export { default as ErrorDisplay } from './ErrorDisplay';
// Loading and UI components
export * from './LoadingStates';
// Landing page components
export { default as AnimatedCounter } from './AnimatedCounter';
export { default as FeatureCard } from './FeatureCard';
+336
View File
@@ -0,0 +1,336 @@
export interface ErrorInfo {
code: string;
message: string;
details?: string;
action?: string;
retryable: boolean;
category: 'payment' | 'network' | 'validation' | 'auth' | 'system';
}
export class ErrorService {
private static readonly ERROR_MESSAGES: Record<string, ErrorInfo> = {
// Payment Errors
'PAYMENT_FAILED': {
code: 'PAYMENT_FAILED',
message: 'Payment could not be processed',
details: 'There was an issue processing your payment. Please try again or use a different payment method.',
action: 'Try again with a different payment method',
retryable: true,
category: 'payment'
},
'INSUFFICIENT_FUNDS': {
code: 'INSUFFICIENT_FUNDS',
message: 'Insufficient funds',
details: 'Your payment method does not have enough funds to complete this transaction.',
action: 'Check your account balance or use a different payment method',
retryable: true,
category: 'payment'
},
'CARD_DECLINED': {
code: 'CARD_DECLINED',
message: 'Card was declined',
details: 'Your card was declined by your bank. This could be due to insufficient funds or security restrictions.',
action: 'Contact your bank or try a different card',
retryable: true,
category: 'payment'
},
'EXPIRED_CARD': {
code: 'EXPIRED_CARD',
message: 'Card has expired',
details: 'The card you are trying to use has expired.',
action: 'Use a different card or update your card information',
retryable: true,
category: 'payment'
},
'INVALID_CVC': {
code: 'INVALID_CVC',
message: 'Invalid security code',
details: 'The security code (CVC) you entered is incorrect.',
action: 'Check and re-enter your security code',
retryable: true,
category: 'payment'
},
'PROCESSING_ERROR': {
code: 'PROCESSING_ERROR',
message: 'Payment processing error',
details: 'An unexpected error occurred while processing your payment.',
action: 'Please try again in a few moments',
retryable: true,
category: 'payment'
},
// Network Errors
'NETWORK_ERROR': {
code: 'NETWORK_ERROR',
message: 'Connection error',
details: 'Unable to connect to our servers. Please check your internet connection.',
action: 'Check your internet connection and try again',
retryable: true,
category: 'network'
},
'TIMEOUT': {
code: 'TIMEOUT',
message: 'Request timed out',
details: 'The request took too long to complete. This might be due to a slow connection.',
action: 'Try again with a better connection',
retryable: true,
category: 'network'
},
'SERVER_ERROR': {
code: 'SERVER_ERROR',
message: 'Server error',
details: 'Our servers are experiencing issues. Please try again later.',
action: 'Try again in a few minutes',
retryable: true,
category: 'network'
},
// Validation Errors
'INVALID_QUANTITY': {
code: 'INVALID_QUANTITY',
message: 'Invalid token quantity',
details: 'The number of tokens you entered is not valid.',
action: 'Enter a number between 1 and 1000',
retryable: false,
category: 'validation'
},
'INVALID_AMOUNT': {
code: 'INVALID_AMOUNT',
message: 'Invalid amount',
details: 'The payment amount is not valid.',
action: 'Please refresh the page and try again',
retryable: false,
category: 'validation'
},
'MISSING_REQUIRED_FIELD': {
code: 'MISSING_REQUIRED_FIELD',
message: 'Required information missing',
details: 'Please fill in all required fields.',
action: 'Complete all required fields and try again',
retryable: false,
category: 'validation'
},
// Authentication Errors
'UNAUTHORIZED': {
code: 'UNAUTHORIZED',
message: 'Authentication required',
details: 'You need to be logged in to make a purchase.',
action: 'Please log in and try again',
retryable: false,
category: 'auth'
},
'TOKEN_EXPIRED': {
code: 'TOKEN_EXPIRED',
message: 'Session expired',
details: 'Your session has expired. Please log in again.',
action: 'Please log in again',
retryable: false,
category: 'auth'
},
'INSUFFICIENT_PERMISSIONS': {
code: 'INSUFFICIENT_PERMISSIONS',
message: 'Access denied',
details: 'You do not have permission to perform this action.',
action: 'Contact support if you believe this is an error',
retryable: false,
category: 'auth'
},
// System Errors
'STRIPE_ERROR': {
code: 'STRIPE_ERROR',
message: 'Payment system error',
details: 'There was an issue with our payment processor.',
action: 'Please try again or contact support',
retryable: true,
category: 'system'
},
'UNKNOWN_ERROR': {
code: 'UNKNOWN_ERROR',
message: 'Something went wrong',
details: 'An unexpected error occurred. Please try again.',
action: 'Try again or contact support if the problem persists',
retryable: true,
category: 'system'
}
};
/**
* Parse an error and return structured error information
*/
static parseError(error: any): ErrorInfo {
// Handle Axios errors
if (error.response) {
const status = error.response.status;
const data = error.response.data;
// Try to get error code from response
if (data?.error) {
const errorCode = data.error.toUpperCase();
if (this.ERROR_MESSAGES[errorCode]) {
return this.ERROR_MESSAGES[errorCode];
}
}
// Handle HTTP status codes
switch (status) {
case 400:
return this.ERROR_MESSAGES['INVALID_AMOUNT'];
case 401:
return this.ERROR_MESSAGES['UNAUTHORIZED'];
case 403:
return this.ERROR_MESSAGES['INSUFFICIENT_PERMISSIONS'];
case 408:
return this.ERROR_MESSAGES['TIMEOUT'];
case 500:
return this.ERROR_MESSAGES['SERVER_ERROR'];
default:
return this.ERROR_MESSAGES['UNKNOWN_ERROR'];
}
}
// Handle network errors
if (error.request) {
return this.ERROR_MESSAGES['NETWORK_ERROR'];
}
// Handle Stripe errors
if (error.type) {
switch (error.type) {
case 'card_error':
return this.ERROR_MESSAGES['CARD_DECLINED'];
case 'validation_error':
return this.ERROR_MESSAGES['INVALID_AMOUNT'];
case 'api_error':
return this.ERROR_MESSAGES['STRIPE_ERROR'];
default:
return this.ERROR_MESSAGES['STRIPE_ERROR'];
}
}
// Handle validation errors
if (error.message) {
const message = error.message.toLowerCase();
if (message.includes('quantity')) {
return this.ERROR_MESSAGES['INVALID_QUANTITY'];
}
if (message.includes('amount')) {
return this.ERROR_MESSAGES['INVALID_AMOUNT'];
}
if (message.includes('required')) {
return this.ERROR_MESSAGES['MISSING_REQUIRED_FIELD'];
}
}
// Default to unknown error
return this.ERROR_MESSAGES['UNKNOWN_ERROR'];
}
/**
* Get user-friendly error message
*/
static getErrorMessage(error: any): string {
const errorInfo = this.parseError(error);
return errorInfo.message;
}
/**
* Get detailed error information
*/
static getErrorDetails(error: any): string {
const errorInfo = this.parseError(error);
return errorInfo.details || errorInfo.message;
}
/**
* Get suggested action for error
*/
static getSuggestedAction(error: any): string {
const errorInfo = this.parseError(error);
return errorInfo.action || 'Please try again';
}
/**
* Check if error is retryable
*/
static isRetryable(error: any): boolean {
const errorInfo = this.parseError(error);
return errorInfo.retryable;
}
/**
* Get error category
*/
static getErrorCategory(error: any): string {
const errorInfo = this.parseError(error);
return errorInfo.category;
}
/**
* Format error for display
*/
static formatError(error: any): {
title: string;
message: string;
action: string;
retryable: boolean;
category: string;
} {
const errorInfo = this.parseError(error);
return {
title: errorInfo.message,
message: errorInfo.details || errorInfo.message,
action: errorInfo.action || 'Please try again',
retryable: errorInfo.retryable,
category: errorInfo.category
};
}
/**
* Get retry delay based on error type
*/
static getRetryDelay(error: any): number {
const category = this.getErrorCategory(error);
switch (category) {
case 'network':
return 2000; // 2 seconds
case 'payment':
return 5000; // 5 seconds
case 'system':
return 10000; // 10 seconds
default:
return 3000; // 3 seconds
}
}
/**
* Check if error should be logged
*/
static shouldLogError(error: any): boolean {
const category = this.getErrorCategory(error);
return category !== 'validation' && category !== 'auth';
}
/**
* Get error severity level
*/
static getErrorSeverity(error: any): 'low' | 'medium' | 'high' | 'critical' {
const category = this.getErrorCategory(error);
switch (category) {
case 'validation':
return 'low';
case 'auth':
return 'medium';
case 'payment':
return 'high';
case 'network':
case 'system':
return 'critical';
default:
return 'medium';
}
}
}
+329
View File
@@ -0,0 +1,329 @@
export interface TokenPackage {
id: string;
name: string;
description: string;
quantity: number;
price_per_token: number;
total_price: number;
discount_percentage: number;
is_popular: boolean;
is_active: boolean;
}
export interface PricingCalculation {
quantity: number;
basePrice: number;
discountPercentage: number;
finalPrice: number;
savings: number;
packageId?: string;
packageName?: string;
isCustomQuantity: boolean;
recommendedPackage?: TokenPackage;
}
export interface PricingTier {
minQuantity: number;
maxQuantity: number;
discountPercentage: number;
name: string;
description: string;
}
export class PricingService {
public static readonly BASE_PRICE_PER_TOKEN = 5.00;
public static readonly CURRENCY = 'EUR';
// Define pricing tiers for volume discounts
private static readonly PRICING_TIERS: PricingTier[] = [
{
minQuantity: 1,
maxQuantity: 4,
discountPercentage: 0,
name: 'Individual',
description: 'Perfect for testing'
},
{
minQuantity: 5,
maxQuantity: 9,
discountPercentage: 10,
name: 'Starter',
description: 'Small recruitment needs'
},
{
minQuantity: 10,
maxQuantity: 24,
discountPercentage: 20,
name: 'Professional',
description: 'Regular recruiters'
},
{
minQuantity: 25,
maxQuantity: 49,
discountPercentage: 30,
name: 'Business',
description: 'Growing teams'
},
{
minQuantity: 50,
maxQuantity: 99,
discountPercentage: 40,
name: 'Enterprise',
description: 'Large organizations'
},
{
minQuantity: 100,
maxQuantity: Infinity,
discountPercentage: 50,
name: 'Corporate',
description: 'Enterprise scale'
}
];
/**
* Calculate the best price for a given quantity of tokens
*/
static calculatePrice(
quantity: number,
packages: TokenPackage[],
selectedPackageId?: string
): PricingCalculation {
if (quantity <= 0) {
return this.getEmptyCalculation();
}
// If a specific package is selected, use its pricing
if (selectedPackageId) {
const selectedPackage = packages.find(pkg => pkg.id === selectedPackageId);
if (selectedPackage) {
return this.calculatePackagePrice(quantity, selectedPackage);
}
}
// Find the best package for the given quantity
const bestPackage = this.findBestPackage(quantity, packages);
if (bestPackage) {
return this.calculatePackagePrice(quantity, bestPackage);
}
// If no package is suitable, use tier-based pricing
return this.calculateTierBasedPrice(quantity);
}
/**
* Calculate price using a specific package
*/
private static calculatePackagePrice(quantity: number, pkg: TokenPackage): PricingCalculation {
const basePrice = quantity * this.BASE_PRICE_PER_TOKEN;
const packagePrice = quantity * pkg.price_per_token;
const discountAmount = (packagePrice * pkg.discount_percentage) / 100;
const finalPrice = packagePrice - discountAmount;
const savings = basePrice - finalPrice;
return {
quantity,
basePrice,
discountPercentage: pkg.discount_percentage,
finalPrice,
savings,
packageId: pkg.id,
packageName: pkg.name,
isCustomQuantity: quantity !== pkg.quantity,
};
}
/**
* Calculate price using tier-based discounts
*/
private static calculateTierBasedPrice(quantity: number): PricingCalculation {
const basePrice = quantity * this.BASE_PRICE_PER_TOKEN;
const tier = this.getTierForQuantity(quantity);
const discountAmount = (basePrice * tier.discountPercentage) / 100;
const finalPrice = basePrice - discountAmount;
const savings = discountAmount;
return {
quantity,
basePrice,
discountPercentage: tier.discountPercentage,
finalPrice,
savings,
isCustomQuantity: true,
};
}
/**
* Find the best package for a given quantity
*/
private static findBestPackage(quantity: number, packages: TokenPackage[]): TokenPackage | null {
const suitablePackages = packages
.filter(pkg => pkg.is_active && quantity >= pkg.quantity)
.sort((a, b) => b.quantity - a.quantity); // Sort by quantity descending
if (suitablePackages.length === 0) {
return null;
}
// Find the package that gives the best price
let bestPackage = null;
let bestPrice = quantity * this.BASE_PRICE_PER_TOKEN;
for (const pkg of suitablePackages) {
const packagePrice = quantity * pkg.price_per_token;
const discountAmount = (packagePrice * pkg.discount_percentage) / 100;
const finalPrice = packagePrice - discountAmount;
if (finalPrice < bestPrice) {
bestPackage = pkg;
bestPrice = finalPrice;
}
}
return bestPackage;
}
/**
* Get the pricing tier for a given quantity
*/
private static getTierForQuantity(quantity: number): PricingTier {
return this.PRICING_TIERS.find(tier =>
quantity >= tier.minQuantity && quantity <= tier.maxQuantity
) || this.PRICING_TIERS[0];
}
/**
* Get all available pricing tiers
*/
static getPricingTiers(): PricingTier[] {
return this.PRICING_TIERS;
}
/**
* Get recommended packages for a given quantity
*/
static getRecommendedPackages(quantity: number, packages: TokenPackage[]): TokenPackage[] {
return packages
.filter(pkg => pkg.is_active && pkg.quantity <= quantity)
.sort((a, b) => b.quantity - a.quantity)
.slice(0, 3); // Return top 3 recommendations
}
/**
* Calculate savings compared to individual token price
*/
static calculateSavings(quantity: number, finalPrice: number): {
absolute: number;
percentage: number;
} {
const individualPrice = quantity * this.BASE_PRICE_PER_TOKEN;
const absolute = individualPrice - finalPrice;
const percentage = individualPrice > 0 ? (absolute / individualPrice) * 100 : 0;
return {
absolute: Math.max(0, absolute),
percentage: Math.max(0, percentage)
};
}
/**
* Format price for display
*/
static formatPrice(amount: number): string {
return new Intl.NumberFormat('en-EU', {
style: 'currency',
currency: this.CURRENCY
}).format(amount);
}
/**
* Get price per token for a given calculation
*/
static getPricePerToken(calculation: PricingCalculation): number {
return calculation.quantity > 0 ? calculation.finalPrice / calculation.quantity : 0;
}
/**
* Compare two pricing calculations
*/
static compareCalculations(a: PricingCalculation, b: PricingCalculation): {
better: 'a' | 'b' | 'equal';
savings: number;
} {
const savings = a.finalPrice - b.finalPrice;
if (Math.abs(savings) < 0.01) {
return { better: 'equal', savings: 0 };
}
return {
better: savings < 0 ? 'a' : 'b',
savings: Math.abs(savings)
};
}
/**
* Get empty calculation for error states
*/
private static getEmptyCalculation(): PricingCalculation {
return {
quantity: 0,
basePrice: 0,
discountPercentage: 0,
finalPrice: 0,
savings: 0,
isCustomQuantity: false,
};
}
/**
* Validate quantity constraints
*/
static validateQuantity(quantity: number): {
isValid: boolean;
error?: string;
} {
if (quantity <= 0) {
return { isValid: false, error: 'Quantity must be greater than 0' };
}
if (quantity > 1000) {
return { isValid: false, error: 'Maximum quantity is 1000 tokens' };
}
if (!Number.isInteger(quantity)) {
return { isValid: false, error: 'Quantity must be a whole number' };
}
return { isValid: true };
}
/**
* Get pricing summary for display
*/
static getPricingSummary(calculation: PricingCalculation): {
title: string;
subtitle: string;
highlights: string[];
} {
const highlights: string[] = [];
if (calculation.savings > 0) {
highlights.push(`Save ${this.formatPrice(calculation.savings)}`);
}
if (calculation.discountPercentage > 0) {
highlights.push(`${calculation.discountPercentage}% discount`);
}
if (calculation.packageName) {
highlights.push(`From ${calculation.packageName} package`);
}
return {
title: `${calculation.quantity} Token${calculation.quantity > 1 ? 's' : ''}`,
subtitle: calculation.isCustomQuantity ? 'Custom quantity' : 'Package deal',
highlights
};
}
}
@@ -0,0 +1,321 @@
import { PricingCalculation } from './PricingService';
export interface PurchaseStep {
id: string;
title: string;
description: string;
completed: boolean;
current: boolean;
error?: string;
}
export interface PurchaseFlowState {
currentStep: number;
steps: PurchaseStep[];
calculation: PricingCalculation | null;
paymentIntentId: string | null;
clientSecret: string | null;
error: any;
isProcessing: boolean;
}
export class PurchaseFlowService {
private static readonly STEPS: Omit<PurchaseStep, 'completed' | 'current' | 'error'>[] = [
{
id: 'quantity-selection',
title: 'Select Tokens',
description: 'Choose the number of tokens you want to purchase'
},
{
id: 'pricing-calculation',
title: 'Calculate Price',
description: 'Review pricing and available discounts'
},
{
id: 'payment-method',
title: 'Payment Method',
description: 'Select your preferred payment method'
},
{
id: 'payment-processing',
title: 'Process Payment',
description: 'Complete your payment securely'
},
{
id: 'confirmation',
title: 'Confirmation',
description: 'Payment successful and tokens allocated'
}
];
/**
* Initialize the purchase flow
*/
static initializeFlow(): PurchaseFlowState {
const steps = this.STEPS.map((step, index) => ({
...step,
completed: false,
current: index === 0,
error: undefined
}));
return {
currentStep: 0,
steps,
calculation: null,
paymentIntentId: null,
clientSecret: null,
error: null,
isProcessing: false
};
}
/**
* Move to the next step
*/
static nextStep(state: PurchaseFlowState): PurchaseFlowState {
const newState = { ...state };
if (newState.currentStep < newState.steps.length - 1) {
// Mark current step as completed
newState.steps[newState.currentStep] = {
...newState.steps[newState.currentStep],
completed: true,
current: false
};
// Move to next step
newState.currentStep += 1;
newState.steps[newState.currentStep] = {
...newState.steps[newState.currentStep],
current: true
};
}
return newState;
}
/**
* Move to the previous step
*/
static previousStep(state: PurchaseFlowState): PurchaseFlowState {
const newState = { ...state };
if (newState.currentStep > 0) {
// Mark current step as not current
newState.steps[newState.currentStep] = {
...newState.steps[newState.currentStep],
current: false
};
// Move to previous step
newState.currentStep -= 1;
newState.steps[newState.currentStep] = {
...newState.steps[newState.currentStep],
current: true,
completed: false // Allow editing previous step
};
}
return newState;
}
/**
* Jump to a specific step
*/
static goToStep(state: PurchaseFlowState, stepIndex: number): PurchaseFlowState {
const newState = { ...state };
if (stepIndex >= 0 && stepIndex < newState.steps.length) {
// Reset all steps
newState.steps = newState.steps.map((step, index) => ({
...step,
completed: index < stepIndex,
current: index === stepIndex,
error: undefined
}));
newState.currentStep = stepIndex;
}
return newState;
}
/**
* Set error for current step
*/
static setStepError(state: PurchaseFlowState, error: any): PurchaseFlowState {
const newState = { ...state };
newState.steps[newState.currentStep] = {
...newState.steps[newState.currentStep],
error: error?.message || 'An error occurred'
};
newState.error = error;
return newState;
}
/**
* Clear error for current step
*/
static clearStepError(state: PurchaseFlowState): PurchaseFlowState {
const newState = { ...state };
newState.steps[newState.currentStep] = {
...newState.steps[newState.currentStep],
error: undefined
};
newState.error = null;
return newState;
}
/**
* Set processing state
*/
static setProcessing(state: PurchaseFlowState, isProcessing: boolean): PurchaseFlowState {
return {
...state,
isProcessing
};
}
/**
* Set calculation data
*/
static setCalculation(state: PurchaseFlowState, calculation: PricingCalculation): PurchaseFlowState {
return {
...state,
calculation
};
}
/**
* Set payment intent data
*/
static setPaymentIntent(state: PurchaseFlowState, paymentIntentId: string, clientSecret: string): PurchaseFlowState {
return {
...state,
paymentIntentId,
clientSecret
};
}
/**
* Complete the purchase flow
*/
static completeFlow(state: PurchaseFlowState): PurchaseFlowState {
const newState = { ...state };
// Mark all steps as completed
newState.steps = newState.steps.map(step => ({
...step,
completed: true,
current: false
}));
newState.isProcessing = false;
newState.error = null;
return newState;
}
/**
* Reset the flow to initial state
*/
static resetFlow(): PurchaseFlowState {
return this.initializeFlow();
}
/**
* Get current step info
*/
static getCurrentStep(state: PurchaseFlowState): PurchaseStep | null {
return state.steps[state.currentStep] || null;
}
/**
* Check if flow can proceed to next step
*/
static canProceed(state: PurchaseFlowState): boolean {
const currentStep = this.getCurrentStep(state);
if (!currentStep) return false;
switch (currentStep.id) {
case 'quantity-selection':
return state.calculation !== null;
case 'pricing-calculation':
return state.calculation !== null;
case 'payment-method':
return true; // Payment method selection is always valid
case 'payment-processing':
return state.clientSecret !== null;
case 'confirmation':
return true; // Confirmation step is always valid
default:
return false;
}
}
/**
* Get progress percentage
*/
static getProgress(state: PurchaseFlowState): number {
const completedSteps = state.steps.filter(step => step.completed).length;
return (completedSteps / state.steps.length) * 100;
}
/**
* Check if flow is completed
*/
static isCompleted(state: PurchaseFlowState): boolean {
return state.steps.every(step => step.completed);
}
/**
* Get step by ID
*/
static getStepById(state: PurchaseFlowState, stepId: string): PurchaseStep | null {
return state.steps.find(step => step.id === stepId) || null;
}
/**
* Validate current step
*/
static validateCurrentStep(state: PurchaseFlowState): {
isValid: boolean;
error?: string;
} {
const currentStep = this.getCurrentStep(state);
if (!currentStep) {
return { isValid: false, error: 'Invalid step' };
}
switch (currentStep.id) {
case 'quantity-selection':
if (!state.calculation) {
return { isValid: false, error: 'Please select a quantity' };
}
break;
case 'pricing-calculation':
if (!state.calculation) {
return { isValid: false, error: 'Please calculate pricing first' };
}
break;
case 'payment-method':
// Payment method selection is always valid
break;
case 'payment-processing':
if (!state.clientSecret) {
return { isValid: false, error: 'Payment intent not created' };
}
break;
case 'confirmation':
// Confirmation step is always valid
break;
}
return { isValid: true };
}
}