430 lines
12 KiB
Markdown
430 lines
12 KiB
Markdown
# ET-PAY-005: Componentes React Frontend
|
||
|
||
**Epic:** OQI-005 Pagos y Stripe
|
||
**Versión:** 1.0
|
||
**Fecha:** 2025-12-05
|
||
|
||
---
|
||
|
||
## 1. Arquitectura Frontend
|
||
|
||
```
|
||
Pages Components Store
|
||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||
│ PricingPage │───────►│ PricingCard │ │ │
|
||
└──────────────┘ └──────────────┘ │ paymentStore│
|
||
│ │
|
||
┌──────────────┐ ┌──────────────┐ └──────────────┘
|
||
│CheckoutPage │───────►│ PaymentForm │ ▲
|
||
└──────────────┘ └──────────────┘ │
|
||
│
|
||
┌──────────────┐ ┌──────────────┐ │
|
||
│ BillingPage │───────►│SubscriptionCard──────────────┘
|
||
└──────────────┘ │PaymentMethodList
|
||
└──────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## 2. Store con Zustand
|
||
|
||
```typescript
|
||
// src/stores/paymentStore.ts
|
||
|
||
import { create } from 'zustand';
|
||
import { paymentApi } from '../api/payment.api';
|
||
|
||
interface PaymentState {
|
||
payments: Payment[];
|
||
subscriptions: Subscription[];
|
||
paymentMethods: PaymentMethod[];
|
||
loading: boolean;
|
||
|
||
fetchPayments: () => Promise<void>;
|
||
fetchSubscriptions: () => Promise<void>;
|
||
fetchPaymentMethods: () => Promise<void>;
|
||
createPaymentIntent: (amount: number, pmId: string) => Promise<any>;
|
||
createSubscription: (priceId: string, pmId: string) => Promise<any>;
|
||
cancelSubscription: (id: string, immediate: boolean) => Promise<void>;
|
||
}
|
||
|
||
export const usePaymentStore = create<PaymentState>((set) => ({
|
||
payments: [],
|
||
subscriptions: [],
|
||
paymentMethods: [],
|
||
loading: false,
|
||
|
||
fetchPayments: async () => {
|
||
set({ loading: true });
|
||
const response = await paymentApi.getPayments();
|
||
set({ payments: response.data.payments, loading: false });
|
||
},
|
||
|
||
fetchSubscriptions: async () => {
|
||
const response = await subscriptionApi.list();
|
||
set({ subscriptions: response.data.subscriptions });
|
||
},
|
||
|
||
fetchPaymentMethods: async () => {
|
||
const response = await paymentApi.getPaymentMethods();
|
||
set({ paymentMethods: response.data.payment_methods });
|
||
},
|
||
|
||
createPaymentIntent: async (amount, pmId) => {
|
||
const response = await paymentApi.createPaymentIntent({
|
||
amount,
|
||
payment_method_id: pmId,
|
||
});
|
||
return response.data;
|
||
},
|
||
|
||
createSubscription: async (priceId, pmId) => {
|
||
const response = await subscriptionApi.create({
|
||
price_id: priceId,
|
||
payment_method_id: pmId,
|
||
});
|
||
return response.data;
|
||
},
|
||
|
||
cancelSubscription: async (id, immediate) => {
|
||
await subscriptionApi.cancel(id, immediate);
|
||
// Refresh subscriptions
|
||
await usePaymentStore.getState().fetchSubscriptions();
|
||
},
|
||
}));
|
||
```
|
||
|
||
---
|
||
|
||
## 3. Pricing Page
|
||
|
||
```typescript
|
||
// src/pages/payment/PricingPage.tsx
|
||
|
||
import React, { useState } from 'react';
|
||
import { PricingCard } from '../../components/payment/PricingCard';
|
||
import { CheckoutModal } from '../../components/payment/CheckoutModal';
|
||
|
||
const PLANS = [
|
||
{
|
||
id: 'basic',
|
||
name: 'Basic',
|
||
price: 29,
|
||
interval: 'month',
|
||
stripe_price_id: 'price_basic_monthly',
|
||
features: ['Feature 1', 'Feature 2', 'Feature 3'],
|
||
},
|
||
{
|
||
id: 'pro',
|
||
name: 'Pro',
|
||
price: 79,
|
||
interval: 'month',
|
||
stripe_price_id: 'price_pro_monthly',
|
||
features: ['All Basic', 'Feature 4', 'Feature 5', 'Priority Support'],
|
||
popular: true,
|
||
},
|
||
{
|
||
id: 'enterprise',
|
||
name: 'Enterprise',
|
||
price: 199,
|
||
interval: 'month',
|
||
stripe_price_id: 'price_enterprise_monthly',
|
||
features: ['All Pro', 'Custom ML Agents', 'Dedicated Support'],
|
||
},
|
||
];
|
||
|
||
export const PricingPage: React.FC = () => {
|
||
const [selectedPlan, setSelectedPlan] = useState<any>(null);
|
||
const [showCheckout, setShowCheckout] = useState(false);
|
||
|
||
const handleSelectPlan = (plan: any) => {
|
||
setSelectedPlan(plan);
|
||
setShowCheckout(true);
|
||
};
|
||
|
||
return (
|
||
<div className="pricing-page">
|
||
<header>
|
||
<h1>Choose Your Plan</h1>
|
||
<p>Start your AI trading journey today</p>
|
||
</header>
|
||
|
||
<div className="pricing-grid">
|
||
{PLANS.map((plan) => (
|
||
<PricingCard
|
||
key={plan.id}
|
||
plan={plan}
|
||
onSelect={() => handleSelectPlan(plan)}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{showCheckout && selectedPlan && (
|
||
<CheckoutModal
|
||
plan={selectedPlan}
|
||
onClose={() => setShowCheckout(false)}
|
||
onSuccess={() => {
|
||
setShowCheckout(false);
|
||
// Redirect to dashboard
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## 4. Pricing Card Component
|
||
|
||
```typescript
|
||
// src/components/payment/PricingCard.tsx
|
||
|
||
import React from 'react';
|
||
import './PricingCard.css';
|
||
|
||
interface PricingCardProps {
|
||
plan: {
|
||
name: string;
|
||
price: number;
|
||
interval: string;
|
||
features: string[];
|
||
popular?: boolean;
|
||
};
|
||
onSelect: () => void;
|
||
}
|
||
|
||
export const PricingCard: React.FC<PricingCardProps> = ({ plan, onSelect }) => {
|
||
return (
|
||
<div className={`pricing-card ${plan.popular ? 'popular' : ''}`}>
|
||
{plan.popular && <div className="badge">Most Popular</div>}
|
||
|
||
<h3>{plan.name}</h3>
|
||
|
||
<div className="price">
|
||
<span className="amount">${plan.price}</span>
|
||
<span className="interval">/{plan.interval}</span>
|
||
</div>
|
||
|
||
<ul className="features">
|
||
{plan.features.map((feature, idx) => (
|
||
<li key={idx}>
|
||
<span className="checkmark">✓</span> {feature}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
|
||
<button className="select-button" onClick={onSelect}>
|
||
Get Started
|
||
</button>
|
||
</div>
|
||
);
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## 5. Checkout Modal
|
||
|
||
```typescript
|
||
// src/components/payment/CheckoutModal.tsx
|
||
|
||
import React, { useState } from 'react';
|
||
import { Elements, CardElement, useStripe, useElements } from '@stripe/react-stripe-js';
|
||
import { loadStripe } from '@stripe/stripe-js';
|
||
import { usePaymentStore } from '../../stores/paymentStore';
|
||
|
||
const stripePromise = loadStripe(process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY!);
|
||
|
||
interface CheckoutModalProps {
|
||
plan: any;
|
||
onClose: () => void;
|
||
onSuccess: () => void;
|
||
}
|
||
|
||
const CheckoutForm: React.FC<CheckoutModalProps> = ({ plan, onClose, onSuccess }) => {
|
||
const stripe = useStripe();
|
||
const elements = useElements();
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const { createSubscription } = usePaymentStore();
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
|
||
if (!stripe || !elements) return;
|
||
|
||
setLoading(true);
|
||
setError(null);
|
||
|
||
try {
|
||
const cardElement = elements.getElement(CardElement);
|
||
if (!cardElement) throw new Error('Card element not found');
|
||
|
||
const { error: pmError, paymentMethod } = await stripe.createPaymentMethod({
|
||
type: 'card',
|
||
card: cardElement,
|
||
});
|
||
|
||
if (pmError) throw new Error(pmError.message);
|
||
|
||
const result = await createSubscription(plan.stripe_price_id, paymentMethod!.id);
|
||
|
||
if (result.client_secret) {
|
||
const { error: confirmError } = await stripe.confirmCardPayment(result.client_secret);
|
||
if (confirmError) throw new Error(confirmError.message);
|
||
}
|
||
|
||
onSuccess();
|
||
} catch (err: any) {
|
||
setError(err.message);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="checkout-modal">
|
||
<div className="modal-content">
|
||
<button className="close-button" onClick={onClose}>×</button>
|
||
|
||
<h2>Subscribe to {plan.name}</h2>
|
||
<p className="price">${plan.price}/{plan.interval}</p>
|
||
|
||
<form onSubmit={handleSubmit}>
|
||
<div className="form-group">
|
||
<label>Card Details</label>
|
||
<CardElement />
|
||
</div>
|
||
|
||
{error && <div className="error">{error}</div>}
|
||
|
||
<button type="submit" disabled={!stripe || loading}>
|
||
{loading ? 'Processing...' : 'Subscribe Now'}
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export const CheckoutModal: React.FC<CheckoutModalProps> = (props) => {
|
||
return (
|
||
<Elements stripe={stripePromise}>
|
||
<CheckoutForm {...props} />
|
||
</Elements>
|
||
);
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## 6. Billing Page
|
||
|
||
```typescript
|
||
// src/pages/payment/BillingPage.tsx
|
||
|
||
import React, { useEffect } from 'react';
|
||
import { usePaymentStore } from '../../stores/paymentStore';
|
||
import { SubscriptionCard } from '../../components/payment/SubscriptionCard';
|
||
import { PaymentMethodList } from '../../components/payment/PaymentMethodList';
|
||
import { InvoiceList } from '../../components/payment/InvoiceList';
|
||
|
||
export const BillingPage: React.FC = () => {
|
||
const {
|
||
subscriptions,
|
||
paymentMethods,
|
||
fetchSubscriptions,
|
||
fetchPaymentMethods,
|
||
} = usePaymentStore();
|
||
|
||
useEffect(() => {
|
||
fetchSubscriptions();
|
||
fetchPaymentMethods();
|
||
}, []);
|
||
|
||
return (
|
||
<div className="billing-page">
|
||
<h1>Billing & Subscriptions</h1>
|
||
|
||
<section className="subscriptions-section">
|
||
<h2>Active Subscriptions</h2>
|
||
{subscriptions.map((sub) => (
|
||
<SubscriptionCard key={sub.id} subscription={sub} />
|
||
))}
|
||
</section>
|
||
|
||
<section className="payment-methods-section">
|
||
<h2>Payment Methods</h2>
|
||
<PaymentMethodList methods={paymentMethods} />
|
||
</section>
|
||
|
||
<section className="invoices-section">
|
||
<h2>Invoices</h2>
|
||
<InvoiceList />
|
||
</section>
|
||
</div>
|
||
);
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## 7. Subscription Card
|
||
|
||
```typescript
|
||
// src/components/payment/SubscriptionCard.tsx
|
||
|
||
import React from 'react';
|
||
import { usePaymentStore } from '../../stores/paymentStore';
|
||
|
||
interface SubscriptionCardProps {
|
||
subscription: Subscription;
|
||
}
|
||
|
||
export const SubscriptionCard: React.FC<SubscriptionCardProps> = ({ subscription }) => {
|
||
const { cancelSubscription } = usePaymentStore();
|
||
|
||
const handleCancel = async () => {
|
||
if (confirm('Are you sure you want to cancel?')) {
|
||
await cancelSubscription(subscription.id, false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="subscription-card">
|
||
<div className="plan-info">
|
||
<h3>{subscription.plan_name}</h3>
|
||
<span className="status">{subscription.status}</span>
|
||
</div>
|
||
|
||
<div className="billing-info">
|
||
<p>
|
||
<strong>${subscription.amount}</strong> / {subscription.plan_interval}
|
||
</p>
|
||
<p className="next-billing">
|
||
Next billing: {new Date(subscription.current_period_end).toLocaleDateString()}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="actions">
|
||
{subscription.cancel_at_period_end ? (
|
||
<span className="canceling">Cancels at period end</span>
|
||
) : (
|
||
<button onClick={handleCancel}>Cancel Subscription</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## 8. Referencias
|
||
|
||
- React Stripe Elements
|
||
- Zustand State Management
|
||
- Stripe Checkout Best Practices
|