feat: implement plan upgrade logic with prorated pricing and MercadoPago integration
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user