TONS code - stripe integration
This commit is contained in:
@@ -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)
|
||||
|
||||
Generated
+23
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user