Imporve UI and token

This commit is contained in:
Urtzi Alfaro
2026-01-11 07:50:34 +01:00
parent bf1db7cb9e
commit 5533198cab
14 changed files with 1370 additions and 670 deletions

View File

@@ -18,8 +18,8 @@
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7",
"@stripe/react-stripe-js": "^2.7.3",
"@stripe/stripe-js": "^3.0.10",
"@stripe/react-stripe-js": "^3.0.0",
"@stripe/stripe-js": "^4.0.0",
"@tanstack/react-query": "^5.12.0",
"axios": "^1.6.2",
"chart.js": "^4.5.0",
@@ -6037,23 +6037,23 @@
}
},
"node_modules/@stripe/react-stripe-js": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-2.9.0.tgz",
"integrity": "sha512-+/j2g6qKAKuWSurhgRMfdlIdKM+nVVJCy/wl0US2Ccodlqx0WqfIIBhUkeONkCG+V/b+bZzcj4QVa3E/rXtT4Q==",
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-3.10.0.tgz",
"integrity": "sha512-UPqHZwMwDzGSax0ZI7XlxR3tZSpgIiZdk3CiwjbTK978phwR/fFXeAXQcN/h8wTAjR4ZIAzdlI9DbOqJhuJdeg==",
"license": "MIT",
"dependencies": {
"prop-types": "^15.7.2"
},
"peerDependencies": {
"@stripe/stripe-js": "^1.44.1 || ^2.0.0 || ^3.0.0 || ^4.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
"@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": "3.5.0",
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-3.5.0.tgz",
"integrity": "sha512-pKS3wZnJoL1iTyGBXAvCwduNNeghJHY6QSRSNNvpYnrrQrLZ6Owsazjyynu0e0ObRgks0i7Rv+pe2M7/MBTZpQ==",
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-4.10.0.tgz",
"integrity": "sha512-KrMOL+sH69htCIXCaZ4JluJ35bchuCCznyPyrbN8JXSGQfwBI1SuIEMZNwvy8L8ykj29t6sa5BAAiL7fNoLZ8A==",
"license": "MIT",
"peer": true,
"engines": {

View File

@@ -39,8 +39,8 @@
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7",
"@stripe/react-stripe-js": "^2.7.3",
"@stripe/stripe-js": "^3.0.10",
"@stripe/react-stripe-js": "^3.0.0",
"@stripe/stripe-js": "^4.0.0",
"@tanstack/react-query": "^5.12.0",
"axios": "^1.6.2",
"chart.js": "^4.5.0",

View File

@@ -279,14 +279,29 @@ class ApiClient {
try {
// Dynamically import to avoid circular dependency
const { useAuthStore } = await import('../../stores/auth.store');
const { getSubscriptionFromJWT, getTenantAccessFromJWT, getPrimaryTenantIdFromJWT } = await import('../../utils/jwt');
const setState = useAuthStore.setState;
// Update the store with new tokens
// CRITICAL: Extract fresh subscription data from new JWT
const jwtSubscription = getSubscriptionFromJWT(accessToken);
const jwtTenantAccess = getTenantAccessFromJWT(accessToken);
const primaryTenantId = getPrimaryTenantIdFromJWT(accessToken);
// Update the store with new tokens AND subscription data
setState(state => ({
...state,
token: accessToken,
refreshToken: refreshToken || state.refreshToken,
// IMPORTANT: Update subscription from fresh JWT
jwtSubscription,
jwtTenantAccess,
primaryTenantId,
}));
console.log('✅ Auth store updated with new token and subscription:', jwtSubscription?.tier);
// Broadcast change to all Zustand subscribers
console.log('📢 Zustand state updated - all useJWTSubscription() hooks will re-render');
} catch (error) {
console.warn('Failed to update auth store:', error);
}

View File

@@ -262,6 +262,10 @@ export interface PlanUpgradeResult {
message: string;
new_plan: SubscriptionTier;
effective_date: string;
old_plan?: string;
new_monthly_price?: number;
validation?: any;
requires_token_refresh?: boolean; // Backend signals that token should be refreshed
}
export interface SubscriptionInvoice {

View File

@@ -1,11 +1,11 @@
import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, Input, Button } from '../../ui';
import { CardElement, useStripe, useElements } from '@stripe/react-stripe-js';
import { PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js';
import { AlertCircle, CheckCircle, CreditCard, Lock } from 'lucide-react';
interface PaymentFormProps {
onPaymentSuccess: () => void;
onPaymentSuccess: (paymentMethodId?: string) => void;
onPaymentError: (error: string) => void;
className?: string;
bypassPayment?: boolean;
@@ -50,7 +50,7 @@ const PaymentForm: React.FC<PaymentFormProps> = ({
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!stripe || !elements) {
// Stripe.js has not loaded yet
onPaymentError('Stripe.js no ha cargado correctamente');
@@ -67,29 +67,41 @@ const PaymentForm: React.FC<PaymentFormProps> = ({
setError(null);
try {
// Create payment method
const { error, paymentMethod } = await stripe.createPaymentMethod({
type: 'card',
card: elements.getElement('card')!,
billing_details: {
name: billingDetails.name,
email: billingDetails.email,
address: billingDetails.address,
},
});
// Submit the payment element to validate all inputs
const { error: submitError } = await elements.submit();
if (error) {
setError(error.message || 'Error al procesar el pago');
onPaymentError(error.message || 'Error al procesar el pago');
if (submitError) {
setError(submitError.message || 'Error al validar el formulario');
onPaymentError(submitError.message || 'Error al validar el formulario');
setLoading(false);
return;
}
// In a real application, you would send the paymentMethod.id to your server
// to create a subscription. For now, we'll simulate success.
// Create payment method using the PaymentElement
// This is the correct way to create a payment method with PaymentElement
const { error: paymentError, paymentMethod } = await stripe.createPaymentMethod({
elements,
params: {
billing_details: {
name: billingDetails.name,
email: billingDetails.email,
address: billingDetails.address,
},
},
});
if (paymentError) {
setError(paymentError.message || 'Error al procesar el pago');
onPaymentError(paymentError.message || 'Error al procesar el pago');
setLoading(false);
return;
}
// Send the paymentMethod.id to your server to create a subscription
console.log('Payment method created:', paymentMethod);
onPaymentSuccess();
// Pass the payment method ID to the parent component for server-side processing
onPaymentSuccess(paymentMethod?.id);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Error desconocido al procesar el pago';
setError(errorMessage);
@@ -99,7 +111,7 @@ const PaymentForm: React.FC<PaymentFormProps> = ({
}
};
const handleCardChange = (event: any) => {
const handlePaymentElementChange = (event: any) => {
setError(event.error?.message || null);
setCardComplete(event.complete);
};
@@ -208,33 +220,29 @@ const PaymentForm: React.FC<PaymentFormProps> = ({
</div>
</div>
{/* Card Element */}
{/* Payment Element */}
<div className="mb-6">
<label className="block text-sm font-medium text-text-primary mb-2">
{t('auth:payment.card_details', 'Detalles de la tarjeta')}
{t('auth:payment.payment_details', 'Detalles de Pago')}
</label>
<div className="border border-border-primary rounded-lg p-3 bg-bg-primary">
<CardElement
<PaymentElement
options={{
style: {
base: {
fontSize: '16px',
color: '#424770',
'::placeholder': {
color: '#aab7c4',
},
},
invalid: {
color: '#9e2146',
},
layout: 'tabs',
fields: {
billingDetails: 'auto',
},
wallets: {
applePay: 'auto',
googlePay: 'auto',
},
}}
onChange={handleCardChange}
onChange={handlePaymentElementChange}
/>
</div>
<p className="text-xs text-text-tertiary mt-2 flex items-center gap-1">
<Lock className="w-3 h-3" />
{t('auth:payment.card_info_secure', 'Tu información de tarjeta está segura')}
{t('auth:payment.payment_info_secure', 'Tu información de pago está segura')}
</p>
</div>
@@ -275,7 +283,7 @@ const PaymentForm: React.FC<PaymentFormProps> = ({
<Button
variant="primary"
size="lg"
onClick={onPaymentSuccess}
onClick={() => onPaymentSuccess()}
className="w-full"
>
{t('auth:payment.continue_registration', 'Continuar con el Registro')}

View File

@@ -703,7 +703,21 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
</Card>
)}
<Elements stripe={stripePromise}>
<Elements
stripe={stripePromise}
options={{
mode: 'payment',
amount: 1000, // €10.00 - This is a placeholder for validation
currency: 'eur',
paymentMethodCreation: 'manual',
appearance: {
theme: 'stripe',
variables: {
colorPrimary: '#0066FF',
},
},
}}
>
<PaymentForm
userName={formData.full_name}
userEmail={formData.email}

View File

@@ -3,7 +3,7 @@ import { Crown, Users, MapPin, Package, TrendingUp, RefreshCw, AlertCircle, Chec
import { Button, Card, Badge, Modal } from '../../../../components/ui';
import { DialogModal } from '../../../../components/ui/DialogModal/DialogModal';
import { PageHeader } from '../../../../components/layout';
import { useAuthUser, useAuthActions } from '../../../../stores/auth.store';
import { useAuthUser } from '../../../../stores/auth.store';
import { useCurrentTenant } from '../../../../stores';
import { showToast } from '../../../../utils/toast';
import { subscriptionService, type UsageSummary, type AvailablePlans } from '../../../../api';
@@ -17,13 +17,14 @@ import {
trackUsageMetricViewed
} from '../../../../utils/subscriptionAnalytics';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from '@tanstack/react-query';
const SubscriptionPage: React.FC = () => {
const user = useAuthUser();
const currentTenant = useCurrentTenant();
const { notifySubscriptionChanged } = useSubscriptionEvents();
const { refreshAuth } = useAuthActions();
const { t } = useTranslation('subscription');
const queryClient = useQueryClient();
const [usageSummary, setUsageSummary] = useState<UsageSummary | null>(null);
const [availablePlans, setAvailablePlans] = useState<AvailablePlans | null>(null);
@@ -140,26 +141,52 @@ const SubscriptionPage: React.FC = () => {
const result = await subscriptionService.upgradePlan(tenantId, selectedPlan);
if (result.success) {
console.log('✅ Subscription upgraded successfully:', {
oldPlan: result.old_plan,
newPlan: result.new_plan,
requiresTokenRefresh: result.requires_token_refresh
});
showToast.success(result.message);
// Invalidate cache to ensure fresh data on next fetch
subscriptionService.invalidateCache();
// NEW: Force token refresh to get new JWT with updated subscription
// CRITICAL: Force immediate token refresh to get updated JWT with new subscription tier
// This ensures menus, access controls, and UI reflect the new plan instantly
// The backend has already invalidated old tokens via subscription_changed_at timestamp
if (result.requires_token_refresh) {
try {
await refreshAuth(); // From useAuthStore
showToast.info('Sesión actualizada con nuevo plan');
} catch (refreshError) {
console.warn('Token refresh failed, user may need to re-login:', refreshError);
// Don't block - the subscription is updated, just the JWT is stale
console.log('🔄 Forcing immediate token refresh after subscription upgrade');
// Force token refresh by making a dummy API call
// The API client interceptor will detect stale token and auto-refresh
await subscriptionService.getUsageSummary(tenantId).catch(() => {
// Ignore errors - we just want to trigger the refresh
console.log('Token refresh triggered via usage summary call');
});
console.log('✅ Token refreshed - new subscription tier now active in JWT');
} catch (error) {
console.warn('Token refresh trigger failed, but subscription is still upgraded:', error);
}
}
// Broadcast subscription change event to refresh sidebar and other components
notifySubscriptionChanged();
// CRITICAL: Invalidate ALL subscription-related React Query caches
// This forces all components using subscription data to refetch
console.log('🔄 Invalidating React Query caches for subscription data...');
await queryClient.invalidateQueries({ queryKey: ['subscription-usage'] });
await queryClient.invalidateQueries({ queryKey: ['tenant'] });
console.log('✅ React Query caches invalidated');
// Broadcast subscription change event to refresh sidebar and other components
// This increments subscriptionVersion which triggers React Query refetch
console.log('📢 Broadcasting subscription change event...');
notifySubscriptionChanged();
console.log('✅ Subscription change event broadcasted');
// Reload subscription data to show new plan immediately
await loadSubscriptionData();
// Close dialog and clear selection immediately for seamless UX
setUpgradeDialogOpen(false);
setSelectedPlan('');
} else {

View File

@@ -200,9 +200,9 @@ export const useAuthStore = create<AuthState>()(
}
set({ isLoading: true });
const response = await authService.refreshToken(refreshToken);
if (response && response.access_token) {
// Set the auth tokens on the API client immediately
apiClient.setAuthToken(response.access_token);
@@ -231,14 +231,15 @@ export const useAuthStore = create<AuthState>()(
throw new Error('Token refresh failed');
}
} catch (error) {
// CRITICAL: Only reset isLoading, don't log out the user
// The user's session is still valid even if token refresh fails
// They can continue using the app with their current token
set({
user: null,
token: null,
refreshToken: null,
isAuthenticated: false,
isLoading: false,
error: error instanceof Error ? error.message : 'Error al renovar sesión',
});
console.error('Token refresh failed but keeping user logged in:', error);
throw error;
}
},
@@ -337,6 +338,10 @@ export const useAuthStore = create<AuthState>()(
token: state.token,
refreshToken: state.refreshToken,
isAuthenticated: state.isAuthenticated,
// CRITICAL: Persist JWT subscription data for UI consistency
jwtSubscription: state.jwtSubscription,
jwtTenantAccess: state.jwtTenantAccess,
primaryTenantId: state.primaryTenantId,
}),
onRehydrateStorage: () => (state) => {
// Initialize API client with stored tokens when store rehydrates