feat: implement plan upgrade logic with prorated pricing and MercadoPago integration

This commit is contained in:
2026-07-18 15:39:07 -03:00
parent f6e2bb8372
commit 8d74ae95c8
25 changed files with 2491 additions and 142 deletions
+274 -40
View File
@@ -9,14 +9,16 @@ import { useSessionStore } from "@core/Store/Sesion.Store";
import { ThemeProvider } from "@emotion/react";
import SuspenseLoading from "@core/app/components/SuspendeLoading/SuspenseLoading";
import Secure from "@components/Secure/Secure";
import { useEffect, useState, Suspense } from "react";
import { useEffect, useRef, useState, Suspense } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import {
cancellSubscription,
getSuscriptionInitPoint,
getDashboardSummary,
DashboardSummaryResult,
getCompanyMetrics,
DashboardMetrics
DashboardMetrics,
verifyPendingPlanPayment
} from "./Dashboard.Service";
import { EVENT_TYPES, useEventHandlerStore } from "@core/Store/EventHandler.Store";
import { useAlert } from "@core/Store/Alert.Store";
@@ -45,8 +47,11 @@ import FinancialWidget from "@components/FinancialWidget/FinancialWidget";
import PlanMetricsWidget from "@components/PlanMetricsWidget/PlanMetricsWidget";
import PendingRepeatsCard from "@core/app/components/PendingRepeatsCard/PendingRepeatsCard";
export default function DashboardPage() {
function DashboardContent() {
const { goTo } = useNavigation();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const SessionInfo = useSessionStore();
const eventHandler = useEventHandlerStore();
const alert = useAlert();
@@ -104,11 +109,29 @@ export default function DashboardPage() {
const [companyMetrics, setCompanyMetrics] = useState<DashboardMetrics | null>(null);
const [companySubscription, setCompanySubscription] = useState<ISubscriptionInfo | null>(null);
const [isCompanyOwner, setIsCompanyOwner] = useState<boolean>(false);
const [isVerifyingPayment, setIsVerifyingPayment] = useState(false);
const verifiedPaymentReturnKey = useRef<string | null>(null);
const getErrorMessage = (error: unknown): string => {
if (error && typeof error === "object" && "format" in error && typeof error.format === "function") {
return error.format();
}
if (error instanceof Error) {
return error.message;
}
return "Ha ocurrido un error inesperado";
};
useEffect(() => {
if (SessionInfo.userId && !SessionInfo.subscription) {
goTo("/");
}
if (SessionInfo.subscription?.plan?.id) {
setSubscription(SessionInfo.subscription);
}
blurElementsOnLoad();
@@ -134,25 +157,46 @@ export default function DashboardPage() {
useEffect(() => {
if (selectedOrgId && SessionInfo.userId) {
setCompanySubscription(null);
setCompanyMetrics(null);
getCompanyMetrics({ sessionUser: SessionInfo.userId, companyId: selectedOrgId })
.then(res => {
setCompanyMetrics(res.metrics);
setCompanySubscription(res.subscription);
setIsCompanyOwner(res.isOwner);
})
.catch(e => {
const errMsg = e.format ? e.format() : e.message;
console.warn("Acceso denegado a métricas de la empresa: ", errMsg);
setCompanyMetrics(null);
setCompanySubscription(null);
setIsCompanyOwner(false);
});
loadCompanyMetrics(selectedOrgId);
}
}, [selectedOrgId, SessionInfo.userId]);
const loadCompanyMetrics = async (companyId: string) => {
setCompanySubscription(null);
setCompanyMetrics(null);
try {
const res = await getCompanyMetrics({ sessionUser: SessionInfo.userId, companyId });
setCompanyMetrics(res.metrics);
setCompanySubscription(res.subscription);
setSubscription(res.subscription);
setIsCompanyOwner(res.isOwner);
if (res.isOwner) {
SessionInfo.setSubscription(res.subscription);
}
} catch (e) {
const errMsg = getErrorMessage(e);
console.warn("Acceso denegado a métricas de la empresa: ", errMsg);
setCompanyMetrics(null);
setCompanySubscription(null);
setIsCompanyOwner(false);
}
};
const refreshDashboardSubscriptionData = async () => {
await loadDashboardData();
if (selectedOrgId) {
await loadCompanyMetrics(selectedOrgId);
}
};
const getPaymentLink = () => {
const pendingPaymentInitPoint = displayedSubscription?.pendingPaymentInitPoint;
if (pendingPaymentInitPoint) {
goTo(pendingPaymentInitPoint);
return;
}
eventHandler.setEventType(EVENT_TYPES.LOADING);
getSuscriptionInitPoint({
sessionUser: SessionInfo.userId,
@@ -168,6 +212,56 @@ export default function DashboardPage() {
});
};
const handleVerifyPendingPayment = async () => {
setIsVerifyingPayment(true);
eventHandler.setEventType(EVENT_TYPES.LOADING);
try {
const response = await verifyPendingPlanPayment({ sessionUser: SessionInfo.userId });
if (response.approved) {
alert.showSuccess(response.message);
await refreshDashboardSubscriptionData();
return;
}
if (["rejected", "failed", "cancelled"].includes(response.status)) {
alert.showWarning(response.message);
await refreshDashboardSubscriptionData();
return;
}
alert.showWarning(response.message);
} catch (error) {
alert.showError(getErrorMessage(error));
} finally {
setIsVerifyingPayment(false);
eventHandler.setEventType(EVENT_TYPES.SLEEP);
}
};
useEffect(() => {
if (!SessionInfo.userId) return;
const hasPaymentReturnParams = [
"mp_return",
"status",
"collection_status",
"payment_id",
"preference_id",
"merchant_order_id",
].some((param) => searchParams.has(param));
if (!hasPaymentReturnParams) return;
const paymentReturnKey = searchParams.toString();
if (verifiedPaymentReturnKey.current === paymentReturnKey) return;
verifiedPaymentReturnKey.current = paymentReturnKey;
handleVerifyPendingPayment().finally(() => {
router.replace(pathname, { scroll: false });
});
}, [SessionInfo.userId, searchParams, pathname, router]);
const cancelarSubscription = () => {
confirm.show(
"Estas a punto de cancelar tu suscripción. Al realizar esta acción dejaras de tener acceso a la información de tus organizaciones cuando se termine el periodo contratado. Puedes volver a suscribirte a cualquier otro plan y recuperarlo en el futuro. Estas seguro que quieres cancelar tu suscripcion?",
@@ -220,7 +314,23 @@ export default function DashboardPage() {
goTo("/admin/org");
};
const displayedSubscription = companySubscription;
const isUsingSessionSubscriptionFallback = !companySubscription && Boolean(subscription.plan.id);
const displayedSubscription = companySubscription ?? (subscription.plan.id ? subscription : null);
const displayedPlan = displayedSubscription?.plan;
const displayedPlanStatusText = displayedPlan?.price === 0 ? "ACTIVO" : getSubStatusText(displayedSubscription?.mpStatus);
const hasPendingPaymentMetadata = Boolean(displayedSubscription?.pendingPaymentPreferenceId);
const hasRecentFailedPayment = Boolean(
displayedSubscription?.lastPaymentStatus &&
["rejected", "failed", "cancelled"].includes(displayedSubscription.lastPaymentStatus) &&
displayedSubscription.lastPaymentAt &&
dayjs().diff(dayjs(displayedSubscription.lastPaymentAt), "day") <= 14
);
const showPaymentAction = Boolean(
(displayedSubscription?.mpStatus === MP_SUBS_STATUS.PENDING || hasPendingPaymentMetadata) &&
(isCompanyOwner || isUsingSessionSubscriptionFallback)
);
const showFailedPaymentCard = Boolean(hasRecentFailedPayment && !showPaymentAction);
const showFreePlanStarterContent = Boolean(displayedPlan && displayedPlan.price === 0 && organizations.length === 0);
const agendaItems = dashboardData?.personalAgenda ?? [];
const filteredAgendaItems = agendaItems.filter(item => !showOnlyMine || item.collaboratorId === SessionInfo.userId);
@@ -238,14 +348,15 @@ export default function DashboardPage() {
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
justifyContent: "flex-start",
alignItems: "center",
minHeight: "calc(100vh - 60px)",
}}
>
{SessionInfo.userId != "" && subscription.plan.name == "" && (
{SessionInfo.userId != "" && !displayedPlan && (
<div className="homeCentered">No tiene una suscripción activa.</div>
)}
{subscription.plan.name != "" && (
{displayedPlan && (
<div className={style.dashboardGrid}>
{/* Welcome Header */}
@@ -267,15 +378,14 @@ export default function DashboardPage() {
<div style={{display: 'flex', flexDirection: 'column', gap: '5px'}}>
<h3>Bienvenido, {SessionInfo.userFullName}</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
<span style={{ fontWeight: 'bold' }}>{displayedSubscription?.plan.name || "Cargando plan..."}</span>
<span style={{ fontWeight: 'bold' }}>{displayedPlan.name}</span>
{displayedSubscription && (
<span
style={{
backgroundColor:
getSubStatusText(displayedSubscription.mpStatus) ===
"ACTIVO"
? "var(--sub-status-active)"
: "var(--sub-status-inactive)",
displayedPlanStatusText === "ACTIVO"
? "var(--sub-status-active)"
: "var(--sub-status-inactive)",
borderRadius: "12px",
padding: "4px 10px",
fontSize: "12px",
@@ -283,7 +393,7 @@ export default function DashboardPage() {
fontWeight: "bold",
}}
>
{getSubStatusText(displayedSubscription.mpStatus)}
{displayedPlanStatusText}
</span>
)}
{displayedSubscription && isCompanyOwner && (
@@ -297,14 +407,6 @@ export default function DashboardPage() {
)}
</div>
</div>
{subscription.mpStatus === MP_SUBS_STATUS.PENDING && (
<Button
text="Ir a pagar"
color="primary"
style={{ marginTop: "10px" }}
onClick={getPaymentLink}
/>
)}
</div>
<div className={style.quickActions}>
<div className={classNames(style.actionButton, style.buttonOrganizations)} onClick={() => goTo("/admin/org")}>
@@ -325,8 +427,132 @@ export default function DashboardPage() {
</div>
</div>
{showPaymentAction && (
<div className={style.pendingPaymentCard}>
<div className={style.pendingPaymentContent}>
<span className={style.pendingPaymentEyebrow}>Pago pendiente</span>
<h2>Tu plan está esperando confirmación de MercadoPago</h2>
<p>
Si ya pagaste, puede tardar unos minutos en actualizarse porque dependemos de la notificación de MercadoPago.
No te preocupes: podés volver al checkout si quedó incompleto o pedirnos que verifiquemos el pago ahora mismo.
</p>
</div>
<div className={style.pendingPaymentActions}>
<Button
text="Ir a pagar"
color="plain"
width="custom"
style={{
margin: 0,
width: "220px",
backgroundColor: "#9a4b00",
color: "white",
border: "1px solid #9a4b00",
}}
onClick={getPaymentLink}
/>
<Button
text={isVerifyingPayment ? "Verificando..." : "Verificar pago"}
color="plain"
width="custom"
style={{
margin: 0,
width: "220px",
backgroundColor: "#fff3d6",
color: "#9a4b00",
border: "1px solid rgba(154, 75, 0, 0.28)",
}}
onClick={handleVerifyPendingPayment}
disabled={isVerifyingPayment}
/>
</div>
</div>
)}
{showFailedPaymentCard && (
<div className={style.failedPaymentCard}>
<div className={style.pendingPaymentContent}>
<span className={style.failedPaymentEyebrow}>Pago no aprobado</span>
<h2>Tu pago no se aprobó y tu plan sigue igual</h2>
<p>
El intento de pago fue rechazado, falló o se canceló. No cambiamos tu plan actual ni la fecha de finalización.
Cuando quieras, podés volver a intentarlo desde los detalles del plan.
</p>
</div>
</div>
)}
{showFreePlanStarterContent && (
<div className={style.starterCard}>
<div className={style.starterContent}>
<span className={style.starterEyebrow}>Tu plan gratuito ya está activo</span>
<h2>Ahora podés empezar a ofrecer turnos</h2>
<p>
Todavía no pertenecés a ninguna organización. Podés crear tu propio negocio con el asistente,
o esperar una invitación para sumarte al equipo de una organización existente.
</p>
</div>
<div className={style.starterActions}>
<button className={style.starterPrimaryButton} onClick={() => goTo("/admin/assistant?flow=onboarding-org&action=new-org")}>
Crear mi organización
</button>
<button className={style.starterSecondaryButton} onClick={() => goTo("/landing/my-appointments")}>
Ver mis turnos
</button>
</div>
</div>
)}
{showFreePlanStarterContent && (
<div className={style.starterGuideGrid}>
<section className={style.starterGuideCard}>
<span className={style.starterGuideNumber}>1</span>
<h3>Creá tu espacio de trabajo</h3>
<p>
El asistente te guía paso a paso para cargar tu negocio, servicios, horarios y colaboradores.
Es el camino recomendado si querés empezar a recibir reservas.
</p>
</section>
<section className={style.starterGuideCard}>
<span className={style.starterGuideNumber}>2</span>
<h3>Compartí tu enlace público</h3>
<p>
Cuando termines la configuración vas a tener una página pública para que tus clientes reserven
turnos sin escribirte por WhatsApp a cada rato.
</p>
</section>
<section className={style.starterGuideCard}>
<span className={style.starterGuideNumber}>3</span>
<h3>También podés sumarte a un equipo</h3>
<p>
Si trabajás para una organización existente, ahora pueden invitarte y vas a poder gestionar tus
turnos desde esta misma cuenta.
</p>
</section>
</div>
)}
{showFreePlanStarterContent && (
<div className={style.starterNextStepsPanel}>
<div>
<span className={style.starterEyebrow}>Qué podés hacer con tu plan básico</span>
<h3>Tu cuenta ya está lista para operar</h3>
<p>
Usá este primer paso para preparar la base de tu negocio. Cuando tengas tu organización creada,
vas a poder administrar servicios, horarios, reservas e invitaciones desde el dashboard.
</p>
</div>
<ul className={style.starterChecklist}>
<li>Crear una organización para tu negocio o actividad profesional.</li>
<li>Configurar servicios y horarios disponibles.</li>
<li>Recibir invitaciones de otras organizaciones y formar parte de su equipo.</li>
<li>Seguir usando Mis turnos para tus reservas como cliente.</li>
</ul>
</div>
)}
{/* Left Column (Sidebar) */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
{!showFreePlanStarterContent && <div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
{/* Agenda del Día */}
{dashboardData && dashboardData.role !== "NONE" && (
<div className={style.agendaCard}>
@@ -409,10 +635,10 @@ export default function DashboardPage() {
onCancel={cancelarSubscription}
/>
)}
</div>
</div>}
{/* Right Column (Main Content) */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
{!showFreePlanStarterContent && <div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
{/* Rendimiento Financiero (Only for ADMIN) */}
{dashboardData && dashboardData.role === "ADMIN" && (
<FinancialWidget
@@ -429,7 +655,7 @@ export default function DashboardPage() {
allowEmployeeFilter={true}
/>
)}
</div>
</div>}
</div>
)}
</AnimatedContainer>
@@ -438,3 +664,11 @@ export default function DashboardPage() {
</ThemeProvider>
);
}
export default function DashboardPage() {
return (
<Suspense fallback={<SuspenseLoading />}>
<DashboardContent />
</Suspense>
);
}