feat: add subscription UI, plan-based nav gating, and client subscription page
- Add plan field to UserInfo shared type - Subscription API client and React Query hooks - Client subscription page with status + payment history - Sidebar navigation filtered by tenant plan features - Subscription link added to navigation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -77,6 +77,7 @@ export async function register(data: RegisterRequest): Promise<LoginResponse> {
|
|||||||
tenantId: tenant.id,
|
tenantId: tenant.id,
|
||||||
tenantName: tenant.nombre,
|
tenantName: tenant.nombre,
|
||||||
tenantRfc: tenant.rfc,
|
tenantRfc: tenant.rfc,
|
||||||
|
plan: tenant.plan,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -140,6 +141,7 @@ export async function login(data: LoginRequest): Promise<LoginResponse> {
|
|||||||
tenantId: user.tenantId,
|
tenantId: user.tenantId,
|
||||||
tenantName: user.tenant.nombre,
|
tenantName: user.tenant.nombre,
|
||||||
tenantRfc: user.tenant.rfc,
|
tenantRfc: user.tenant.rfc,
|
||||||
|
plan: user.tenant.plan,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
127
apps/web/app/(dashboard)/configuracion/suscripcion/page.tsx
Normal file
127
apps/web/app/(dashboard)/configuracion/suscripcion/page.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Header } from '@/components/layouts/header';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { useSubscription, usePaymentHistory } from '@/lib/hooks/use-subscription';
|
||||||
|
import { CreditCard, Calendar, CheckCircle, AlertCircle, Clock, XCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
const statusConfig: Record<string, { label: string; color: string; icon: typeof CheckCircle }> = {
|
||||||
|
authorized: { label: 'Activa', color: 'text-green-600 bg-green-50', icon: CheckCircle },
|
||||||
|
pending: { label: 'Pendiente', color: 'text-yellow-600 bg-yellow-50', icon: Clock },
|
||||||
|
paused: { label: 'Pausada', color: 'text-orange-600 bg-orange-50', icon: AlertCircle },
|
||||||
|
cancelled: { label: 'Cancelada', color: 'text-red-600 bg-red-50', icon: XCircle },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SuscripcionPage() {
|
||||||
|
const { user } = useAuthStore();
|
||||||
|
const { data: subscription, isLoading } = useSubscription(user?.tenantId);
|
||||||
|
const { data: payments } = usePaymentHistory(user?.tenantId);
|
||||||
|
|
||||||
|
const status = statusConfig[subscription?.status || ''] || statusConfig.pending;
|
||||||
|
const StatusIcon = status.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header title="Suscripción" />
|
||||||
|
<main className="p-6 space-y-6">
|
||||||
|
{/* Subscription Status */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<CreditCard className="h-5 w-5" />
|
||||||
|
Estado de Suscripción
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="animate-pulse space-y-4">
|
||||||
|
<div className="h-4 bg-muted rounded w-1/3" />
|
||||||
|
<div className="h-4 bg-muted rounded w-1/2" />
|
||||||
|
</div>
|
||||||
|
) : subscription ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Plan</p>
|
||||||
|
<p className="text-lg font-semibold capitalize">{subscription.plan}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Estado</p>
|
||||||
|
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-sm font-medium ${status.color}`}>
|
||||||
|
<StatusIcon className="h-4 w-4" />
|
||||||
|
{status.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Monto Mensual</p>
|
||||||
|
<p className="text-lg font-semibold">
|
||||||
|
${Number(subscription.amount).toLocaleString('es-MX')} MXN
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground">No se encontró información de suscripción.</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Payment History */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Calendar className="h-5 w-5" />
|
||||||
|
Historial de Pagos
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{payments && payments.length > 0 ? (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="text-left py-2 px-3 font-medium text-muted-foreground">Fecha</th>
|
||||||
|
<th className="text-left py-2 px-3 font-medium text-muted-foreground">Monto</th>
|
||||||
|
<th className="text-left py-2 px-3 font-medium text-muted-foreground">Estado</th>
|
||||||
|
<th className="text-left py-2 px-3 font-medium text-muted-foreground">Método</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{payments.map((payment) => (
|
||||||
|
<tr key={payment.id} className="border-b last:border-0">
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
{new Date(payment.createdAt).toLocaleDateString('es-MX')}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
${Number(payment.amount).toLocaleString('es-MX')}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
|
||||||
|
payment.status === 'approved'
|
||||||
|
? 'bg-green-50 text-green-700'
|
||||||
|
: payment.status === 'rejected'
|
||||||
|
? 'bg-red-50 text-red-700'
|
||||||
|
: 'bg-yellow-50 text-yellow-700'
|
||||||
|
}`}>
|
||||||
|
{payment.status === 'approved' ? 'Aprobado' :
|
||||||
|
payment.status === 'rejected' ? 'Rechazado' : 'Pendiente'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-muted-foreground">
|
||||||
|
{payment.paymentMethod || '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground text-center py-4">
|
||||||
|
No hay pagos registrados aún.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,23 +16,33 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
Building2,
|
Building2,
|
||||||
UserCog,
|
UserCog,
|
||||||
|
CreditCard,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { logout } from '@/lib/api/auth';
|
import { logout } from '@/lib/api/auth';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { hasFeature, type Plan } from '@horux/shared';
|
||||||
|
|
||||||
const navigation = [
|
interface NavItem {
|
||||||
|
name: string;
|
||||||
|
href: string;
|
||||||
|
icon: typeof LayoutDashboard;
|
||||||
|
feature?: string; // Required plan feature — hidden if tenant's plan lacks it
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigation: NavItem[] = [
|
||||||
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
||||||
{ name: 'CFDI', href: '/cfdi', icon: FileText },
|
{ name: 'CFDI', href: '/cfdi', icon: FileText },
|
||||||
{ name: 'Impuestos', href: '/impuestos', icon: Calculator },
|
{ name: 'Impuestos', href: '/impuestos', icon: Calculator },
|
||||||
{ name: 'Reportes', href: '/reportes', icon: BarChart3 },
|
{ name: 'Reportes', href: '/reportes', icon: BarChart3, feature: 'reportes' },
|
||||||
{ name: 'Calendario', href: '/calendario', icon: Calendar },
|
{ name: 'Calendario', href: '/calendario', icon: Calendar, feature: 'calendario' },
|
||||||
{ name: 'Alertas', href: '/alertas', icon: Bell },
|
{ name: 'Alertas', href: '/alertas', icon: Bell, feature: 'alertas' },
|
||||||
{ name: 'Usuarios', href: '/usuarios', icon: Users },
|
{ name: 'Usuarios', href: '/usuarios', icon: Users },
|
||||||
|
{ name: 'Suscripción', href: '/configuracion/suscripcion', icon: CreditCard },
|
||||||
{ name: 'Configuracion', href: '/configuracion', icon: Settings },
|
{ name: 'Configuracion', href: '/configuracion', icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
const adminNavigation = [
|
const adminNavigation: NavItem[] = [
|
||||||
{ name: 'Clientes', href: '/clientes', icon: Building2 },
|
{ name: 'Clientes', href: '/clientes', icon: Building2 },
|
||||||
{ name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog },
|
{ name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog },
|
||||||
];
|
];
|
||||||
@@ -53,9 +63,15 @@ export function Sidebar() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Filter navigation based on tenant plan features
|
||||||
|
const plan = (user?.plan || 'starter') as Plan;
|
||||||
|
const filteredNav = navigation.filter(
|
||||||
|
(item) => !item.feature || hasFeature(plan, item.feature)
|
||||||
|
);
|
||||||
|
|
||||||
const allNavigation = user?.role === 'admin'
|
const allNavigation = user?.role === 'admin'
|
||||||
? [...navigation.slice(0, -1), ...adminNavigation, navigation[navigation.length - 1]]
|
? [...filteredNav.slice(0, -1), ...adminNavigation, filteredNav[filteredNav.length - 1]]
|
||||||
: navigation;
|
: filteredNav;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="fixed left-0 top-0 z-40 h-screen w-64 border-r bg-card">
|
<aside className="fixed left-0 top-0 z-40 h-screen w-64 border-r bg-card">
|
||||||
|
|||||||
44
apps/web/lib/api/subscription.ts
Normal file
44
apps/web/lib/api/subscription.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { apiClient } from './client';
|
||||||
|
|
||||||
|
export interface Subscription {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
plan: string;
|
||||||
|
status: string;
|
||||||
|
amount: string;
|
||||||
|
frequency: string;
|
||||||
|
mpPreapprovalId: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Payment {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
subscriptionId: string | null;
|
||||||
|
mpPaymentId: string | null;
|
||||||
|
amount: string;
|
||||||
|
status: string;
|
||||||
|
paymentMethod: string | null;
|
||||||
|
paidAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSubscription(tenantId: string): Promise<Subscription> {
|
||||||
|
const response = await apiClient.get<Subscription>(`/subscriptions/${tenantId}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generatePaymentLink(tenantId: string): Promise<{ paymentUrl: string }> {
|
||||||
|
const response = await apiClient.post<{ paymentUrl: string }>(`/subscriptions/${tenantId}/generate-link`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markAsPaid(tenantId: string, amount: number): Promise<Payment> {
|
||||||
|
const response = await apiClient.post<Payment>(`/subscriptions/${tenantId}/mark-paid`, { amount });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPaymentHistory(tenantId: string): Promise<Payment[]> {
|
||||||
|
const response = await apiClient.get<Payment[]>(`/subscriptions/${tenantId}/payments`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
40
apps/web/lib/hooks/use-subscription.ts
Normal file
40
apps/web/lib/hooks/use-subscription.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import * as subscriptionApi from '../api/subscription';
|
||||||
|
|
||||||
|
export function useSubscription(tenantId: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['subscription', tenantId],
|
||||||
|
queryFn: () => subscriptionApi.getSubscription(tenantId!),
|
||||||
|
enabled: !!tenantId,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePaymentHistory(tenantId: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['payments', tenantId],
|
||||||
|
queryFn: () => subscriptionApi.getPaymentHistory(tenantId!),
|
||||||
|
enabled: !!tenantId,
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGeneratePaymentLink() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (tenantId: string) => subscriptionApi.generatePaymentLink(tenantId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMarkAsPaid() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ tenantId, amount }: { tenantId: string; amount: number }) =>
|
||||||
|
subscriptionApi.markAsPaid(tenantId, amount),
|
||||||
|
onSuccess: (_, { tenantId }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['subscription', tenantId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['payments', tenantId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ export interface UserInfo {
|
|||||||
tenantId: string;
|
tenantId: string;
|
||||||
tenantName: string;
|
tenantName: string;
|
||||||
tenantRfc: string;
|
tenantRfc: string;
|
||||||
|
plan: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JWTPayload {
|
export interface JWTPayload {
|
||||||
|
|||||||
Reference in New Issue
Block a user