Add user delete process
This commit is contained in:
@@ -382,6 +382,22 @@ export class SubscriptionService {
|
||||
}> {
|
||||
return apiClient.get(`/subscriptions/${tenantId}/status`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get invoice history for a tenant
|
||||
*/
|
||||
async getInvoices(tenantId: string): Promise<Array<{
|
||||
id: string;
|
||||
date: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
status: string;
|
||||
description: string | null;
|
||||
invoice_pdf: string | null;
|
||||
hosted_invoice_url: string | null;
|
||||
}>> {
|
||||
return apiClient.get(`/subscriptions/${tenantId}/invoices`);
|
||||
}
|
||||
}
|
||||
|
||||
export const subscriptionService = new SubscriptionService();
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
import React, { forwardRef, useState, useEffect, useCallback } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, ThemeToggle } from '../../ui';
|
||||
import { CompactLanguageSelector } from '../../ui/LanguageSelector';
|
||||
import { getRegisterUrl, getLoginUrl } from '../../../utils/navigation';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
export interface PublicHeaderProps {
|
||||
className?: string;
|
||||
@@ -67,17 +68,113 @@ export const PublicHeader = forwardRef<PublicHeaderRef, PublicHeaderProps>(({
|
||||
const { t } = useTranslation();
|
||||
const headerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// State for mobile menu
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
// State for sticky header
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
|
||||
// State for active section
|
||||
const [activeSection, setActiveSection] = useState<string>('');
|
||||
|
||||
// Default navigation items
|
||||
const defaultNavItems = [
|
||||
// { id: 'features', label: 'Características', href: '#features', external: false },
|
||||
// { id: 'pricing', label: 'Precios', href: '#pricing', external: false },
|
||||
// { id: 'contact', label: 'Contacto', href: '#contact', external: false },
|
||||
];
|
||||
const defaultNavItems: Array<{id: string; label: string; href: string; external?: boolean}> = [];
|
||||
|
||||
const navItems = navigationItems.length > 0 ? navigationItems : defaultNavItems;
|
||||
|
||||
// Smooth scroll to section
|
||||
const scrollToSection = useCallback((href: string) => {
|
||||
if (href.startsWith('#')) {
|
||||
const element = document.querySelector(href);
|
||||
if (element) {
|
||||
const headerHeight = headerRef.current?.offsetHeight || 0;
|
||||
const elementPosition = element.getBoundingClientRect().top + window.pageYOffset;
|
||||
const offsetPosition = elementPosition - headerHeight - 20; // 20px additional offset
|
||||
|
||||
window.scrollTo({
|
||||
top: offsetPosition,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
|
||||
// Update URL hash
|
||||
window.history.pushState(null, '', href);
|
||||
|
||||
// Close mobile menu
|
||||
setIsMobileMenuOpen(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle scroll for sticky header
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setIsScrolled(window.scrollY > 20);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
// Active section detection with Intersection Observer
|
||||
useEffect(() => {
|
||||
const observerOptions = {
|
||||
rootMargin: '-100px 0px -66%',
|
||||
threshold: 0
|
||||
};
|
||||
|
||||
const observerCallback = (entries: IntersectionObserverEntry[]) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
const id = entry.target.getAttribute('id');
|
||||
if (id) {
|
||||
setActiveSection(id);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver(observerCallback, observerOptions);
|
||||
|
||||
// Observe all sections that are navigation targets
|
||||
navItems.forEach(item => {
|
||||
if (item.href.startsWith('#')) {
|
||||
const element = document.querySelector(item.href);
|
||||
if (element) {
|
||||
observer.observe(element);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [navItems]);
|
||||
|
||||
// Close mobile menu on ESC key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isMobileMenuOpen) {
|
||||
setIsMobileMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}, [isMobileMenuOpen]);
|
||||
|
||||
// Prevent body scroll when mobile menu is open
|
||||
useEffect(() => {
|
||||
if (isMobileMenuOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isMobileMenuOpen]);
|
||||
|
||||
// Scroll into view
|
||||
const scrollIntoView = React.useCallback(() => {
|
||||
const scrollIntoView = useCallback(() => {
|
||||
headerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}, []);
|
||||
|
||||
@@ -86,22 +183,57 @@ export const PublicHeader = forwardRef<PublicHeaderRef, PublicHeaderProps>(({
|
||||
scrollIntoView,
|
||||
}), [scrollIntoView]);
|
||||
|
||||
// Render navigation link
|
||||
const renderNavLink = (item: typeof navItems[0]) => {
|
||||
// Render navigation link with improved styles and active state
|
||||
const renderNavLink = (item: typeof navItems[0], isMobile = false) => {
|
||||
const isActive = activeSection === item.id || (item.href.startsWith('#') && item.href === `#${activeSection}`);
|
||||
|
||||
const linkContent = (
|
||||
<span className="text-sm font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors duration-200">
|
||||
<span className={clsx(
|
||||
"relative text-sm font-medium transition-all duration-200",
|
||||
isMobile ? "text-base py-1" : "",
|
||||
isActive
|
||||
? "text-[var(--color-primary)]"
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]",
|
||||
// Animated underline on hover
|
||||
!isMobile && "after:content-[''] after:absolute after:bottom-[-4px] after:left-0 after:w-0 after:h-[2px] after:bg-[var(--color-primary)] after:transition-all after:duration-300 hover:after:w-full",
|
||||
// Active state indicator
|
||||
isActive && !isMobile && "after:w-full"
|
||||
)}>
|
||||
{item.label}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (item.external || item.href.startsWith('http') || item.href.startsWith('#')) {
|
||||
if (item.href.startsWith('#')) {
|
||||
return (
|
||||
<a
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
scrollToSection(item.href);
|
||||
}}
|
||||
className={clsx(
|
||||
"focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:ring-offset-2 rounded-sm",
|
||||
isMobile && "block w-full py-3 px-4 hover:bg-[var(--bg-secondary)] transition-colors"
|
||||
)}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
>
|
||||
{linkContent}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.external || item.href.startsWith('http')) {
|
||||
return (
|
||||
<a
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
target={item.external ? '_blank' : undefined}
|
||||
rel={item.external ? 'noopener noreferrer' : undefined}
|
||||
className="hover:underline focus:outline-none focus:underline"
|
||||
className={clsx(
|
||||
"focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:ring-offset-2 rounded-sm",
|
||||
isMobile && "block w-full py-3 px-4 hover:bg-[var(--bg-secondary)] transition-colors"
|
||||
)}
|
||||
>
|
||||
{linkContent}
|
||||
</a>
|
||||
@@ -112,7 +244,10 @@ export const PublicHeader = forwardRef<PublicHeaderRef, PublicHeaderProps>(({
|
||||
<Link
|
||||
key={item.id}
|
||||
to={item.href}
|
||||
className="hover:underline focus:outline-none focus:underline"
|
||||
className={clsx(
|
||||
"focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:ring-offset-2 rounded-sm",
|
||||
isMobile && "block w-full py-3 px-4 hover:bg-[var(--bg-secondary)] transition-colors"
|
||||
)}
|
||||
>
|
||||
{linkContent}
|
||||
</Link>
|
||||
@@ -120,21 +255,43 @@ export const PublicHeader = forwardRef<PublicHeaderRef, PublicHeaderProps>(({
|
||||
};
|
||||
|
||||
return (
|
||||
<header
|
||||
ref={headerRef}
|
||||
className={clsx(
|
||||
'w-full',
|
||||
// Base styles
|
||||
variant === 'default' && 'bg-[var(--bg-primary)] border-b border-[var(--border-primary)] shadow-sm',
|
||||
variant === 'transparent' && 'bg-transparent',
|
||||
variant === 'minimal' && 'bg-[var(--bg-primary)]',
|
||||
className
|
||||
)}
|
||||
role="banner"
|
||||
aria-label="Navegación principal"
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center py-4 lg:py-6">
|
||||
<>
|
||||
{/* Skip to main content link for accessibility */}
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:top-4 focus:left-4 focus:px-4 focus:py-2 focus:bg-[var(--color-primary)] focus:text-white focus:rounded-md"
|
||||
>
|
||||
{t('common:header.skip_to_content', 'Saltar al contenido principal')}
|
||||
</a>
|
||||
|
||||
<header
|
||||
ref={headerRef}
|
||||
className={clsx(
|
||||
'w-full transition-all duration-300',
|
||||
// Sticky header
|
||||
'fixed top-0 left-0 right-0 z-40',
|
||||
// Base styles with scroll effect
|
||||
variant === 'default' && [
|
||||
isScrolled
|
||||
? 'bg-[var(--bg-primary)]/95 backdrop-blur-md border-b border-[var(--border-primary)] shadow-lg'
|
||||
: 'bg-[var(--bg-primary)] border-b border-[var(--border-primary)] shadow-sm'
|
||||
],
|
||||
variant === 'transparent' && [
|
||||
isScrolled
|
||||
? 'bg-[var(--bg-primary)]/95 backdrop-blur-md shadow-lg'
|
||||
: 'bg-transparent'
|
||||
],
|
||||
variant === 'minimal' && 'bg-[var(--bg-primary)]',
|
||||
className
|
||||
)}
|
||||
role="banner"
|
||||
aria-label="Navegación principal"
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className={clsx(
|
||||
"flex justify-between items-center transition-all duration-300",
|
||||
isScrolled ? "py-3 lg:py-4" : "py-4 lg:py-6"
|
||||
)}>
|
||||
{/* Logo and brand */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Link to="/" className="flex items-center gap-3 hover:opacity-80 transition-opacity">
|
||||
@@ -153,7 +310,7 @@ export const PublicHeader = forwardRef<PublicHeaderRef, PublicHeaderProps>(({
|
||||
|
||||
{/* Desktop navigation */}
|
||||
<nav className="hidden md:flex items-center space-x-8" role="navigation">
|
||||
{navItems.map(renderNavLink)}
|
||||
{navItems.map((item) => renderNavLink(item, false))}
|
||||
</nav>
|
||||
|
||||
{/* Right side actions */}
|
||||
@@ -212,66 +369,142 @@ export const PublicHeader = forwardRef<PublicHeaderRef, PublicHeaderProps>(({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="p-2"
|
||||
aria-label={t('common:header.open_menu')}
|
||||
onClick={() => {
|
||||
// TODO: Implement mobile menu
|
||||
console.log('Mobile menu toggle');
|
||||
}}
|
||||
className="p-2 min-h-[44px] min-w-[44px]"
|
||||
aria-label={isMobileMenuOpen ? t('common:header.close_menu', 'Cerrar menú') : t('common:header.open_menu', 'Abrir menú')}
|
||||
aria-expanded={isMobileMenuOpen}
|
||||
aria-controls="mobile-menu"
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
<svg
|
||||
className={clsx("w-6 h-6 transition-transform duration-300", isMobileMenuOpen && "rotate-90")}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
{isMobileMenuOpen ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
)}
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Mobile navigation */}
|
||||
<nav className="md:hidden pb-4" role="navigation">
|
||||
<div className="flex flex-col space-y-2">
|
||||
{navItems.map(item => (
|
||||
<div key={item.id} className="py-2 border-b border-[var(--border-primary)] last:border-b-0">
|
||||
{renderNavLink(item)}
|
||||
</div>
|
||||
))}
|
||||
{/* Spacer to prevent content from hiding under fixed header */}
|
||||
<div className={clsx(
|
||||
"transition-all duration-300",
|
||||
isScrolled ? "h-[72px] lg:h-[80px]" : "h-[80px] lg:h-[96px]"
|
||||
)} aria-hidden="true" />
|
||||
|
||||
{/* Mobile language selector */}
|
||||
{showLanguageSelector && (
|
||||
<div className="py-2 border-b border-[var(--border-primary)] sm:hidden">
|
||||
<div className="text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
{t('common:header.language')}
|
||||
</div>
|
||||
<CompactLanguageSelector className="w-full" />
|
||||
</div>
|
||||
{/* Mobile menu drawer */}
|
||||
{isMobileMenuOpen && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 md:hidden animate-in fade-in duration-200"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<div
|
||||
id="mobile-menu"
|
||||
className={clsx(
|
||||
"fixed top-0 right-0 bottom-0 w-[85%] max-w-sm bg-[var(--bg-primary)] shadow-2xl z-50 md:hidden",
|
||||
"animate-in slide-in-from-right duration-300",
|
||||
"flex flex-col"
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('common:header.mobile_menu', 'Menú de navegación móvil')}
|
||||
>
|
||||
{/* Drawer header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-[var(--border-primary)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-primary-dark)] rounded-lg flex items-center justify-center text-white font-bold text-sm">
|
||||
PI
|
||||
</div>
|
||||
<h2 className="text-lg font-bold text-[var(--text-primary)]">
|
||||
Panadería IA
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="p-2 min-h-[44px] min-w-[44px]"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
aria-label={t('common:header.close_menu', 'Cerrar menú')}
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Mobile auth buttons */}
|
||||
{/* Drawer content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Navigation items */}
|
||||
<nav className="py-4" role="navigation">
|
||||
<div className="flex flex-col">
|
||||
{navItems.map((item) => (
|
||||
<div key={item.id}>
|
||||
{renderNavLink(item, true)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Mobile language selector */}
|
||||
{showLanguageSelector && (
|
||||
<div className="px-4 py-4 border-t border-[var(--border-primary)]">
|
||||
<div className="text-sm font-medium text-[var(--text-secondary)] mb-3">
|
||||
{t('common:header.language', 'Idioma')}
|
||||
</div>
|
||||
<CompactLanguageSelector className="w-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Theme toggle for mobile */}
|
||||
{showThemeToggle && (
|
||||
<div className="px-4 py-4 border-t border-[var(--border-primary)]">
|
||||
<div className="text-sm font-medium text-[var(--text-secondary)] mb-3">
|
||||
{t('common:header.theme', 'Tema')}
|
||||
</div>
|
||||
<ThemeToggle variant="button" size="md" className="w-full" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Drawer footer with auth buttons */}
|
||||
{showAuthButtons && (
|
||||
<div className="flex flex-col gap-3 pt-4 sm:hidden">
|
||||
<Link to={getLoginUrl()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="md"
|
||||
className="w-full font-medium border border-[var(--border-primary)] hover:bg-[var(--bg-secondary)]"
|
||||
>
|
||||
{t('common:header.login')}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to={getRegisterUrl()}>
|
||||
<Button
|
||||
size="md"
|
||||
className="w-full bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary-dark)] hover:opacity-90 text-white font-semibold shadow-lg"
|
||||
>
|
||||
{t('common:header.start_free')}
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="p-4 border-t border-[var(--border-primary)] bg-[var(--bg-secondary)]">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Link to={getLoginUrl()} onClick={() => setIsMobileMenuOpen(false)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="w-full font-medium min-h-[48px]"
|
||||
>
|
||||
{t('common:header.login', 'Iniciar Sesión')}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to={getRegisterUrl()} onClick={() => setIsMobileMenuOpen(false)}>
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary-dark)] hover:opacity-90 text-white font-semibold shadow-lg min-h-[48px]"
|
||||
>
|
||||
{t('common:header.start_free', 'Comenzar Gratis')}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Crown, Users, MapPin, Package, TrendingUp, RefreshCw, AlertCircle, CheckCircle, ArrowRight, Star, ExternalLink, Download, CreditCard, X, Activity, Database, Zap, HardDrive, ShoppingCart, ChefHat } from 'lucide-react';
|
||||
import { Crown, Users, MapPin, Package, TrendingUp, RefreshCw, AlertCircle, CheckCircle, ArrowRight, Star, ExternalLink, Download, CreditCard, X, Activity, Database, Zap, HardDrive, ShoppingCart, ChefHat, Settings } from 'lucide-react';
|
||||
import { Button, Card, Badge, Modal } from '../../../../components/ui';
|
||||
import { DialogModal } from '../../../../components/ui/DialogModal/DialogModal';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
@@ -25,10 +25,12 @@ const SubscriptionPage: React.FC = () => {
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [invoices, setInvoices] = useState<any[]>([]);
|
||||
const [invoicesLoading, setInvoicesLoading] = useState(false);
|
||||
const [invoicesLoaded, setInvoicesLoaded] = useState(false);
|
||||
|
||||
// Load subscription data on component mount
|
||||
React.useEffect(() => {
|
||||
loadSubscriptionData();
|
||||
loadInvoices();
|
||||
}, []);
|
||||
|
||||
const loadSubscriptionData = async () => {
|
||||
@@ -220,33 +222,33 @@ const SubscriptionPage: React.FC = () => {
|
||||
const tenantId = currentTenant?.id || user?.tenant_id;
|
||||
|
||||
if (!tenantId) {
|
||||
showToast.error('No se encontró información del tenant');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setInvoicesLoading(true);
|
||||
// In a real implementation, this would call an API endpoint to get invoices
|
||||
// const invoices = await subscriptionService.getInvoices(tenantId);
|
||||
|
||||
// For now, we'll simulate some invoices
|
||||
setInvoices([
|
||||
{ id: 'inv_001', date: '2023-10-01', amount: 49.00, status: 'paid', description: 'Plan Starter Mensual' },
|
||||
{ id: 'inv_002', date: '2023-09-01', amount: 49.00, status: 'paid', description: 'Plan Starter Mensual' },
|
||||
{ id: 'inv_003', date: '2023-08-01', amount: 49.00, status: 'paid', description: 'Plan Starter Mensual' },
|
||||
]);
|
||||
const fetchedInvoices = await subscriptionService.getInvoices(tenantId);
|
||||
setInvoices(fetchedInvoices);
|
||||
setInvoicesLoaded(true);
|
||||
} catch (error) {
|
||||
console.error('Error loading invoices:', error);
|
||||
showToast.error('Error al cargar las facturas');
|
||||
// Don't show error toast on initial load, just log it
|
||||
if (invoicesLoaded) {
|
||||
showToast.error('Error al cargar las facturas');
|
||||
}
|
||||
} finally {
|
||||
setInvoicesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadInvoice = (invoiceId: string) => {
|
||||
// In a real implementation, this would download the actual invoice
|
||||
console.log(`Downloading invoice: ${invoiceId}`);
|
||||
showToast.info(`Descargando factura ${invoiceId}`);
|
||||
const handleDownloadInvoice = (invoice: any) => {
|
||||
if (invoice.invoice_pdf) {
|
||||
window.open(invoice.invoice_pdf, '_blank');
|
||||
} else if (invoice.hosted_invoice_url) {
|
||||
window.open(invoice.hosted_invoice_url, '_blank');
|
||||
} else {
|
||||
showToast.warning('No hay PDF disponible para esta factura');
|
||||
}
|
||||
};
|
||||
|
||||
const ProgressBar: React.FC<{ value: number; className?: string }> = ({ value, className = '' }) => {
|
||||
@@ -303,7 +305,7 @@ const SubscriptionPage: React.FC = () => {
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] flex items-center">
|
||||
<Crown className="w-5 h-5 mr-2 text-yellow-500" />
|
||||
Plan Actual: {usageSummary.plan}
|
||||
Plan Actual
|
||||
</h3>
|
||||
<Badge
|
||||
variant={usageSummary.status === 'active' ? 'success' : 'default'}
|
||||
@@ -313,52 +315,35 @@ const SubscriptionPage: React.FC = () => {
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-[var(--text-secondary)]">Precio Mensual</span>
|
||||
<span className="font-semibold text-[var(--text-primary)]">{subscriptionService.formatPrice(usageSummary.monthly_price)}</span>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-secondary)]">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-[var(--text-secondary)] mb-1">Plan</span>
|
||||
<span className="font-semibold text-[var(--text-primary)] text-lg capitalize">{usageSummary.plan}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-[var(--text-secondary)]">Próxima Facturación</span>
|
||||
<span className="font-medium text-[var(--text-primary)]">
|
||||
{new Date(usageSummary.next_billing_date).toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' })}
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-secondary)]">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-[var(--text-secondary)] mb-1">Precio Mensual</span>
|
||||
<span className="font-semibold text-[var(--text-primary)] text-lg">{subscriptionService.formatPrice(usageSummary.monthly_price)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-secondary)]">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-[var(--text-secondary)] mb-1">Próxima Facturación</span>
|
||||
<span className="font-medium text-[var(--text-primary)] text-lg">
|
||||
{new Date(usageSummary.next_billing_date).toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit', year: 'numeric' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-[var(--text-secondary)]">Usuarios</span>
|
||||
<span className="font-medium text-[var(--text-primary)]">
|
||||
{usageSummary.usage.users.current}/{usageSummary.usage.users.unlimited ? '∞' : usageSummary.usage.users.limit ?? 0}
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-secondary)]">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-[var(--text-secondary)] mb-1">Ciclo de Facturación</span>
|
||||
<span className="font-medium text-[var(--text-primary)] text-lg capitalize">
|
||||
{usageSummary.billing_cycle === 'monthly' ? 'Mensual' : 'Anual'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-[var(--text-secondary)]">Ubicaciones</span>
|
||||
<span className="font-medium text-[var(--text-primary)]">
|
||||
{usageSummary.usage.locations.current}/{usageSummary.usage.locations.unlimited ? '∞' : usageSummary.usage.locations.limit ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={() => window.open('https://billing.bakery.com', '_blank')} className="flex items-center gap-2">
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Portal de Facturación
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => console.log('Download invoice')} className="flex items-center gap-2">
|
||||
<Download className="w-4 h-4" />
|
||||
Descargar Facturas
|
||||
</Button>
|
||||
<Button variant="outline" onClick={loadSubscriptionData} className="flex items-center gap-2">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Actualizar
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -584,7 +569,7 @@ const SubscriptionPage: React.FC = () => {
|
||||
</Card>
|
||||
|
||||
{/* Available Plans */}
|
||||
<div>
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-6 text-[var(--text-primary)] flex items-center">
|
||||
<Crown className="w-5 h-5 mr-2 text-yellow-500" />
|
||||
Planes Disponibles
|
||||
@@ -595,7 +580,7 @@ const SubscriptionPage: React.FC = () => {
|
||||
onPlanSelect={handleUpgradeClick}
|
||||
showPilotBanner={false}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Invoices Section */}
|
||||
<Card className="p-6">
|
||||
@@ -604,18 +589,9 @@ const SubscriptionPage: React.FC = () => {
|
||||
<Download className="w-5 h-5 mr-2 text-blue-500" />
|
||||
Historial de Facturas
|
||||
</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={loadInvoices}
|
||||
disabled={invoicesLoading}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${invoicesLoading ? 'animate-spin' : ''}`} />
|
||||
{invoicesLoading ? 'Cargando...' : 'Actualizar'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{invoicesLoading ? (
|
||||
{invoicesLoading && !invoicesLoaded ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-[var(--color-primary)]" />
|
||||
@@ -624,42 +600,57 @@ const SubscriptionPage: React.FC = () => {
|
||||
</div>
|
||||
) : invoices.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-[var(--text-secondary)]">No hay facturas disponibles</p>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-full">
|
||||
<Download className="w-8 h-8 text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
<p className="text-[var(--text-secondary)]">No hay facturas disponibles</p>
|
||||
<p className="text-sm text-[var(--text-tertiary)]">Las facturas aparecerán aquí una vez realizados los pagos</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--border-color)]">
|
||||
<th className="text-left py-3 px-4 text-[var(--text-secondary)] font-medium">ID</th>
|
||||
<th className="text-left py-3 px-4 text-[var(--text-secondary)] font-medium">Fecha</th>
|
||||
<th className="text-left py-3 px-4 text-[var(--text-secondary)] font-medium">Descripción</th>
|
||||
<th className="text-left py-3 px-4 text-[var(--text-secondary)] font-medium">Monto</th>
|
||||
<th className="text-left py-3 px-4 text-[var(--text-secondary)] font-medium">Estado</th>
|
||||
<th className="text-left py-3 px-4 text-[var(--text-secondary)] font-medium">Acciones</th>
|
||||
<th className="text-left py-3 px-4 text-[var(--text-secondary)] font-medium text-sm">Fecha</th>
|
||||
<th className="text-left py-3 px-4 text-[var(--text-secondary)] font-medium text-sm">Descripción</th>
|
||||
<th className="text-right py-3 px-4 text-[var(--text-secondary)] font-medium text-sm">Monto</th>
|
||||
<th className="text-center py-3 px-4 text-[var(--text-secondary)] font-medium text-sm">Estado</th>
|
||||
<th className="text-center py-3 px-4 text-[var(--text-secondary)] font-medium text-sm">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invoices.map((invoice) => (
|
||||
<tr key={invoice.id} className="border-b border-[var(--border-color)] hover:bg-[var(--bg-secondary)]">
|
||||
<td className="py-3 px-4 text-[var(--text-primary)]">{invoice.id}</td>
|
||||
<td className="py-3 px-4 text-[var(--text-primary)]">{invoice.date}</td>
|
||||
<td className="py-3 px-4 text-[var(--text-primary)]">{invoice.description}</td>
|
||||
<td className="py-3 px-4 text-[var(--text-primary)]">{subscriptionService.formatPrice(invoice.amount)}</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge variant={invoice.status === 'paid' ? 'success' : 'default'}>
|
||||
{invoice.status === 'paid' ? 'Pagada' : 'Pendiente'}
|
||||
<tr key={invoice.id} className="border-b border-[var(--border-color)] hover:bg-[var(--bg-secondary)] transition-colors">
|
||||
<td className="py-3 px-4 text-[var(--text-primary)] font-medium">
|
||||
{new Date(invoice.date).toLocaleDateString('es-ES', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-[var(--text-primary)]">
|
||||
{invoice.description || 'Suscripción'}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-[var(--text-primary)] font-semibold text-right">
|
||||
{subscriptionService.formatPrice(invoice.amount)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-center">
|
||||
<Badge variant={invoice.status === 'paid' ? 'success' : invoice.status === 'open' ? 'warning' : 'default'}>
|
||||
{invoice.status === 'paid' ? 'Pagada' : invoice.status === 'open' ? 'Pendiente' : invoice.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<td className="py-3 px-4 text-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDownloadInvoice(invoice.id)}
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => handleDownloadInvoice(invoice)}
|
||||
disabled={!invoice.invoice_pdf && !invoice.hosted_invoice_url}
|
||||
className="flex items-center gap-2 mx-auto"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Descargar
|
||||
{invoice.invoice_pdf ? 'PDF' : 'Ver'}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -673,38 +664,73 @@ const SubscriptionPage: React.FC = () => {
|
||||
{/* Subscription Management */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-6 text-[var(--text-primary)] flex items-center">
|
||||
<CreditCard className="w-5 h-5 mr-2 text-red-500" />
|
||||
<Settings className="w-5 h-5 mr-2 text-purple-500" />
|
||||
Gestión de Suscripción
|
||||
</h3>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-[var(--text-primary)] mb-2">Cancelar Suscripción</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-4">
|
||||
Si cancelas tu suscripción, perderás acceso a las funcionalidades premium al final del período de facturación actual.
|
||||
</p>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={handleCancellationClick}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
Cancelar Suscripción
|
||||
</Button>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Payment Method Card */}
|
||||
<div className="p-6 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-secondary)] hover:border-[var(--color-primary)]/30 transition-colors">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 bg-blue-500/10 rounded-lg border border-blue-500/20 flex-shrink-0">
|
||||
<CreditCard className="w-6 h-6 text-blue-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-[var(--text-primary)] mb-2">Método de Pago</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-4 leading-relaxed">
|
||||
Actualiza tu información de pago para asegurar la continuidad de tu servicio sin interrupciones.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex items-center gap-2 w-full sm:w-auto"
|
||||
onClick={() => showToast.info('Función disponible próximamente')}
|
||||
>
|
||||
<CreditCard className="w-4 h-4" />
|
||||
Actualizar Método de Pago
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-[var(--text-primary)] mb-2">Método de Pago</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-4">
|
||||
Actualiza tu información de pago para asegurar la continuidad de tu servicio.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<CreditCard className="w-4 h-4" />
|
||||
Actualizar Método de Pago
|
||||
</Button>
|
||||
|
||||
{/* Cancel Subscription Card */}
|
||||
<div className="p-6 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-secondary)] hover:border-red-500/30 transition-colors">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 bg-red-500/10 rounded-lg border border-red-500/20 flex-shrink-0">
|
||||
<AlertCircle className="w-6 h-6 text-red-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-[var(--text-primary)] mb-2">Cancelar Suscripción</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-4 leading-relaxed">
|
||||
Si cancelas, mantendrás acceso de solo lectura hasta el final de tu período de facturación actual.
|
||||
</p>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={handleCancellationClick}
|
||||
className="flex items-center gap-2 w-full sm:w-auto"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
Cancelar Suscripción
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Info */}
|
||||
<div className="mt-6 p-4 bg-blue-500/5 border border-blue-500/20 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<Activity className="w-5 h-5 text-blue-500 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-[var(--text-primary)] font-medium mb-1">
|
||||
¿Necesitas ayuda?
|
||||
</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Si tienes preguntas sobre tu suscripción o necesitas asistencia, contacta a nuestro equipo de soporte en{' '}
|
||||
<a href="mailto:support@bakery-ia.com" className="text-blue-500 hover:underline">
|
||||
support@bakery-ia.com
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user