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
@@ -54,20 +54,23 @@ export default function Login(props: LoginProps) {
SessionToken.setToken(userSession.userToken);
SessionInfo.setLoginState("success");
alert.showSuccess(msg);
manageLoginActions(userSession);
};
const manageLoginActions = () => {
const toUrl = process.env.NEXT_PUBLIC_DEFAULT_PATH_AFTER_LOGIN || "/";
const manageLoginActions = (session: ISession = SessionInfo) => {
if (loginAction.action !== LOGIN_ACTIONS.NONE) {
goTo(loginAction.url);
loginAction.clear();
} else {
if (SessionInfo.subscription && SessionInfo.subscription.plan) {
const hasPlan = Boolean(session.subscription?.plan?.id);
const hasOrganization = Object.keys(session.organizationSubscriptions || {}).length > 0;
if (hasPlan && hasOrganization) {
goTo("/landing/dashboard");
return;
}
goTo(toUrl);
goTo("/landing/my-appointments");
}
};
@@ -106,6 +106,7 @@ export default function CurrentPlanPage() {
const plan = subscription?.plan;
const isPaidPlan = Boolean(plan && plan.price > 0);
const statusText = isPaidPlan ? getSubStatusText(subscription?.mpStatus) : "ACTIVO";
const canRenew = Boolean(
isOwner &&
isPaidPlan &&
@@ -155,7 +156,7 @@ export default function CurrentPlanPage() {
<h1 className={styles.title}>{plan?.name || "Cargando plan"}</h1>
<p className={styles.subtitle}>Gestioná la vigencia, límites y beneficios de tu suscripción.</p>
{subscription && (
<span className={styles.status}>{getSubStatusText(subscription.mpStatus)}</span>
<span className={styles.status}>{statusText}</span>
)}
</div>
{organizations.length > 1 && (
@@ -200,8 +201,8 @@ export default function CurrentPlanPage() {
</div>
<div className={styles.summaryItem}>
<span>Vencimiento</span>
<strong>{formatDate(subscription.endDate)}</strong>
<small>{isPaidPlan ? "Período vigente" : "Sin vencimiento pago"}</small>
<strong>{isPaidPlan ? formatDate(subscription.endDate) : "No tiene vencimiento"}</strong>
<small>{isPaidPlan ? "Período vigente" : "Plan gratuito activo"}</small>
{isPaidPlan && (
<button className={styles.inlineRenewLink} onClick={handleRenew}>
Extender plan
@@ -310,7 +311,7 @@ export default function CurrentPlanPage() {
text="Cambiar plan"
width="custom"
style={{ width: "100%", padding: "13px 0", borderRadius: "12px", fontWeight: "800" }}
onClick={() => goTo("/landing/pricing")}
onClick={() => goTo(`/landing/upgrade-plan?org=${selectedOrgId}`)}
/>
</div>
</div>
@@ -5,6 +5,8 @@ import {
CancellPlanSuscriptionParams,
GetSubscriptionInitPointParams,
GetSuscriptionInitPointResponse,
VerifyPendingPlanPaymentParams,
VerifyPendingPlanPaymentResponse,
} from "@models/Subscription.Model";
import { ApiError } from "@models/Server.Error.model";
import { AppointmentEventByClient } from "@core/Models/Appointments.model";
@@ -100,3 +102,9 @@ export const getSuscriptionInitPoint = async (
): Promise<GetSuscriptionInitPointResponse> => {
return ApiRequest.post<GetSuscriptionInitPointResponse>("suscriptions/get-init-point", data);
};
export const verifyPendingPlanPayment = async (
data: VerifyPendingPlanPaymentParams
): Promise<VerifyPendingPlanPaymentResponse> => {
return ApiRequest.post<VerifyPendingPlanPaymentResponse>("suscriptions/verify-pending-payment", data);
};
+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>
);
}
@@ -159,6 +159,306 @@
.buttonNotification { background-color: var(--red); }
.buttonMessenger { background-color: var(--orange); }
.pendingPaymentCard {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 26px 30px;
border-radius: 18px;
background: #fff8e8;
border: 1px solid rgba(255, 151, 36, 0.28);
box-shadow: 0 12px 28px rgba(255, 151, 36, 0.1);
}
.failedPaymentCard {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 26px 30px;
border-radius: 18px;
background: #f8fafc;
border: 1px solid rgba(100, 116, 139, 0.24);
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.06);
}
.pendingPaymentContent {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 760px;
}
.pendingPaymentEyebrow {
width: fit-content;
padding: 5px 11px;
border-radius: 999px;
background: rgba(255, 151, 36, 0.16);
color: #9a4b00;
font-size: 12px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.failedPaymentEyebrow {
width: fit-content;
padding: 5px 11px;
border-radius: 999px;
background: rgba(100, 116, 139, 0.12);
color: #475569;
font-size: 12px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.pendingPaymentContent h2 {
margin: 0;
color: var(--black);
font-size: clamp(22px, 3vw, 30px);
}
.pendingPaymentContent p {
margin: 0;
color: var(--gray-dark);
line-height: 1.55;
}
.pendingPaymentActions {
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: flex-end;
gap: 12px;
min-width: 260px;
}
.starterCard {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 32px;
border-radius: 20px;
background: linear-gradient(135deg, rgba(128, 0, 32, 0.96), rgba(58, 0, 120, 0.94));
color: var(--white);
box-shadow: 0 18px 45px rgba(58, 0, 120, 0.18);
}
.starterContent {
display: flex;
flex-direction: column;
gap: 10px;
max-width: 720px;
}
.starterEyebrow {
width: fit-content;
padding: 6px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.16);
font-size: 13px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.starterContent h2 {
margin: 0;
font-size: clamp(26px, 4vw, 38px);
line-height: 1.05;
}
.starterContent p {
margin: 0;
color: rgba(255, 255, 255, 0.86);
font-size: 16px;
line-height: 1.6;
}
.starterActions {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 230px;
}
.starterPrimaryButton,
.starterSecondaryButton {
border: 0;
border-radius: 14px;
padding: 14px 18px;
font-weight: 900;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.starterPrimaryButton:hover,
.starterSecondaryButton:hover {
transform: translateY(-2px);
}
.starterPrimaryButton {
background: var(--white);
color: var(--wine-red);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.16);
}
.starterSecondaryButton {
background: rgba(255, 255, 255, 0.14);
color: var(--white);
border: 1px solid rgba(255, 255, 255, 0.32);
}
.starterGuideGrid {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
}
.starterGuideCard {
min-height: 220px;
padding: 26px;
border-radius: 18px;
background: var(--white);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.06);
border: 1px solid rgba(128, 0, 32, 0.08);
}
.starterGuideNumber {
display: inline-flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 999px;
background: rgba(128, 0, 32, 0.1);
color: var(--wine-red);
font-weight: 900;
margin-bottom: 18px;
}
.starterGuideCard h3 {
margin: 0 0 10px;
color: var(--black);
font-size: 20px;
}
.starterGuideCard p {
margin: 0;
color: var(--gray-dark);
line-height: 1.55;
font-size: 15px;
}
.starterNextStepsPanel {
grid-column: 1 / -1;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 420px);
gap: 28px;
align-items: start;
padding: 34px;
border-radius: 20px;
background: linear-gradient(180deg, #ffffff 0%, #faf7fb 100%);
box-shadow: 0 12px 34px rgba(0, 0, 0, 0.055);
border: 1px solid rgba(128, 0, 32, 0.08);
}
.starterNextStepsPanel h3 {
margin: 14px 0 10px;
color: var(--black);
font-size: clamp(24px, 3vw, 34px);
}
.starterNextStepsPanel p {
margin: 0;
max-width: 680px;
color: var(--gray-dark);
line-height: 1.65;
}
.starterChecklist {
display: flex;
flex-direction: column;
gap: 14px;
margin: 0;
padding: 0;
list-style: none;
}
.starterChecklist li {
position: relative;
padding: 14px 16px 14px 44px;
border-radius: 14px;
background: var(--white);
color: var(--black);
font-weight: 700;
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.045);
}
.starterChecklist li::before {
content: "✓";
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%);
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 999px;
background: var(--green);
color: var(--white);
font-size: 12px;
font-weight: 900;
}
@media (max-width: 760px) {
.pendingPaymentCard {
align-items: stretch;
flex-direction: column;
padding: 22px;
}
.failedPaymentCard {
align-items: stretch;
flex-direction: column;
padding: 22px;
}
.pendingPaymentActions {
align-items: center;
justify-content: center;
min-width: 0;
}
.starterCard {
align-items: stretch;
flex-direction: column;
padding: 24px;
}
.starterActions {
min-width: 0;
}
.starterGuideGrid {
grid-template-columns: 1fr;
}
.starterNextStepsPanel {
grid-template-columns: 1fr;
padding: 24px;
}
}
.planMetricsBottonInfo {
display: flex;
flex-direction: column;
@@ -17,6 +17,7 @@ import useContainerHeight from "@core/app/hooks/ContainerHeight";
import { ScrollToTop } from "@core/app/components/ScrollTop";
import PendingRepeatsCard from "@core/app/components/PendingRepeatsCard/PendingRepeatsCard";
import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider";
import EvangelizeBanner from "@components/EvangelizeBanner/EvangelizeBanner";
import { Button, Tabs, Tab, Box } from "@mui/material";
import { ThemeProvider } from "@emotion/react";
import turnosXpressTheme from "@core/app/theme/turnosXpress";
@@ -53,7 +54,15 @@ function a11yProps(index: number) {
};
}
const AppointmentList = ({ appointments, containerHeight, emptyMessage, goTo, isProfessionalView }: { appointments: any, containerHeight: any, emptyMessage: string, goTo: any, isProfessionalView: boolean }) => {
type AppointmentListProps = {
appointments: AppointmentEventByClient[];
containerHeight: string;
emptyMessage: string;
goTo: (path: string) => void;
isProfessionalView: boolean;
};
const AppointmentList = ({ appointments, containerHeight, emptyMessage, goTo, isProfessionalView }: AppointmentListProps) => {
if (appointments.length === 0) {
return <ExploreCategories containerHeight={containerHeight} message={emptyMessage} />;
}
@@ -223,11 +232,13 @@ export default function MyAppointmentsPage() {
setTabValue(1);
}
})
.catch((error: Error | any) => {
if (error.format) {
.catch((error: unknown) => {
if (error && typeof error === "object" && "format" in error && typeof error.format === "function") {
alert.showError(error.format());
} else {
} else if (error instanceof Error) {
alert.showError(error.message);
} else {
alert.showError("No pudimos cargar tus turnos.");
}
console.error(error);
})
@@ -282,6 +293,8 @@ export default function MyAppointmentsPage() {
Mis Turnos
</h1>
<EvangelizeBanner />
<Box sx={{ width: "100%", mt: 4 }}>
{collaboratorAppointments.length > 0 ? (
<>
@@ -4,20 +4,41 @@ import Link from "next/link";
import { useSessionStore } from "@core/Store/Sesion.Store";
import style from "./style.module.css";
export default function CTABtn() {
type CTABtnProps = {
className?: string;
secondaryClassName?: string;
signedOutText?: string;
signedInText?: string;
showSecondaryWhenSignedOut?: boolean;
};
export default function CTABtn({
className = style.ctaBtn,
secondaryClassName,
signedOutText = "Crear mi cuenta gratis",
signedInText = "Ver planes",
showSecondaryWhenSignedOut = false,
}: CTABtnProps) {
const SessionInfo = useSessionStore();
if (SessionInfo.loged) {
return (
<Link href="/landing/pricing" className={style.ctaBtn}>
Ver planes
<Link href="/landing/pricing" className={className}>
{signedInText}
</Link>
);
}
return (
<Link href="/landing/signup" className={style.ctaBtn}>
Crear mi cuenta gratis
</Link>
<>
<Link href="/landing/signup" className={className}>
{signedOutText}
</Link>
{showSecondaryWhenSignedOut && secondaryClassName && (
<Link href="/landing/pricing" className={secondaryClassName}>
Ver Planes
</Link>
)}
</>
);
}
@@ -1,7 +1,6 @@
import { Metadata } from "next";
import AnimatedContainer from "@components/AnimatedConainer/AnimatedContainer";
import style from "./style.module.css";
import Link from "next/link";
import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider";
import { ScrollToTop } from "@core/app/components/ScrollTop";
import DefaultLoginAction from "@core/app/components/LoginAction/DefaultLoginAction";
@@ -42,12 +41,13 @@ export default function OfferAppointmentsPage() {
Olvidate del teléfono y los mensajes a deshora. Con nuestra plataforma, tus clientes pueden reservar turnos 24/7 de forma automática. Optimizá tu tiempo, reducí ausencias y llevá tu negocio al siguiente nivel.
</p>
<div className={style.btnGroup}>
<Link href="/landing/signup" className={style.primaryBtn}>
¡Empezá Gratis Hoy!
</Link>
<Link href="/landing/pricing" className={style.secondaryBtn}>
Ver Planes
</Link>
<CTABtn
className={style.primaryBtn}
secondaryClassName={style.secondaryBtn}
signedOutText="¡Empezá Gratis Hoy!"
signedInText="Ver planes"
showSecondaryWhenSignedOut
/>
</div>
</div>
@@ -86,7 +86,7 @@ export default function OfferAppointmentsPage() {
<div className={style.stepNumber}>1</div>
<div className={style.stepContent}>
<h3>Creá tu cuenta gratis</h3>
<p>Registrate en segundos. Podés probar la plataforma completa sin compromisos ni tarjetas de crédito. <Link href="/landing/signup">Registrate acá</Link>.</p>
<p>Registrate en segundos. Podés probar la plataforma completa sin compromisos ni tarjetas de crédito.</p>
</div>
</div>
@@ -128,4 +128,3 @@ export default function OfferAppointmentsPage() {
</AnimatedContainer>
);
}
@@ -0,0 +1,580 @@
.page {
width: 100%;
max-width: 1120px;
margin: 34px auto;
padding: 0 20px 40px;
}
.card {
position: relative;
overflow: hidden;
background: linear-gradient(180deg, var(--white) 0%, var(--white-dark) 100%);
border: 1px solid var(--white-darkest);
border-radius: 28px;
padding: 22px;
box-shadow: 0 24px 70px color-mix(in srgb, var(--wine-darkest) 18%, transparent);
display: flex;
flex-direction: column;
gap: 24px;
}
.hero {
position: relative;
overflow: hidden;
border-radius: 24px;
padding: clamp(24px, 4vw, 40px);
background:
radial-gradient(circle at 86% 10%, var(--yellow-light), transparent 34%),
linear-gradient(135deg, var(--wine-superdark) 0%, var(--wine-darkest) 44%, var(--wine-dark) 100%);
color: var(--white);
}
.heroGlow {
position: absolute;
right: -90px;
bottom: -120px;
width: 280px;
height: 280px;
border-radius: 999px;
background: var(--wine-lighterX2);
filter: blur(4px);
}
.header {
position: relative;
z-index: 1;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 28px;
flex-wrap: wrap;
}
.titleBlock {
max-width: 680px;
}
.eyebrow,
.currentPlanPanel span,
.prorationNotice span,
.planCard span,
.billingPanel > span,
.breakdownHeader span,
.invoiceSection > span,
.actionPanel span {
color: var(--wine-red);
font-size: 12px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.eyebrow {
color: var(--yellow);
}
.title {
margin: 10px 0;
color: var(--white);
font-size: clamp(34px, 6vw, 62px);
line-height: 0.95;
letter-spacing: -0.05em;
}
.subtitle {
max-width: 590px;
margin: 0;
color: var(--white-dark);
font-size: 16px;
line-height: 1.6;
}
.orgSelector {
display: flex;
flex-direction: column;
gap: 9px;
min-width: min(280px, 100%);
color: var(--white-dark);
font-size: 13px;
font-weight: 800;
}
.orgSelector select {
padding: 13px 14px;
border: 1px solid var(--wine-lighterX2);
border-radius: 14px;
background: var(--white);
color: var(--black);
font-size: 14px;
font-weight: 700;
outline: none;
box-shadow: 0 18px 34px color-mix(in srgb, var(--black-dark) 14%, transparent);
}
.message {
margin: 0;
color: var(--gray-dark);
line-height: 1.5;
}
.error {
margin: 0;
padding: 14px 16px;
border-radius: 16px;
background: var(--wine-lighterX2);
color: var(--wine-red);
font-weight: 800;
}
.currentPlanPanel {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 14px;
}
.currentPlanPanel > div,
.prorationNotice,
.billingPanel,
.breakdownPanel,
.actionPanel,
.emptyState {
border: 1px solid var(--wine-lighterX2);
border-radius: 22px;
background:
radial-gradient(circle at 100% 0%, var(--wine-lighterX2), transparent 30%),
linear-gradient(135deg, var(--white) 0%, var(--white-dark) 100%);
padding: 18px;
box-shadow: 0 14px 34px color-mix(in srgb, var(--wine-darkest) 9%, transparent);
}
.currentPlanPanel strong,
.actionPanel strong {
display: block;
margin-top: 8px;
color: var(--black);
font-size: clamp(22px, 3vw, 30px);
line-height: 1.1;
letter-spacing: -0.03em;
}
.currentPlanPanel small,
.actionPanel small {
display: block;
margin-top: 8px;
color: var(--gray-dark);
font-weight: 700;
}
.prorationNotice {
border-color: rgba(255, 151, 36, 0.28);
background: #fff8e8;
box-shadow: 0 12px 28px rgba(255, 151, 36, 0.1);
}
.prorationNotice span,
.prorationNotice p {
color: var(--gray-dark);
}
.prorationNotice span {
display: inline-flex;
width: fit-content;
padding: 5px 11px;
border-radius: 999px;
background: rgba(255, 151, 36, 0.16);
color: #9a4b00;
font-size: 12px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.prorationNotice p {
max-width: 760px;
margin: 8px 0 0;
line-height: 1.6;
font-weight: 700;
}
.planGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 16px;
}
.planCard {
display: flex;
flex-direction: column;
gap: 12px;
padding: 22px;
border: 1px solid var(--white-darkest);
border-radius: 24px;
background: var(--white);
color: inherit;
text-align: left;
cursor: pointer;
transition: transform 180ms ease, box-shadow 180ms ease, border-color 180ms ease;
}
.planCard:hover,
.planCardSelected {
transform: translateY(-2px);
border-color: var(--wine-red);
box-shadow: 0 18px 36px color-mix(in srgb, var(--wine-darkest) 14%, transparent);
}
.planCardSelected {
background: linear-gradient(135deg, var(--wine-lighterX2) 0%, var(--white) 100%);
}
.planCard h2,
.emptyState h2 {
margin: 0;
color: var(--black);
font-size: 28px;
letter-spacing: -0.04em;
}
.planCard p,
.emptyState p {
margin: 0;
color: var(--gray-dark);
line-height: 1.5;
font-weight: 700;
}
.planCard strong {
color: var(--wine-darkest);
font-size: 32px;
line-height: 1;
}
.planCard strong small {
color: var(--gray-dark);
font-size: 14px;
}
.planCard ul {
display: flex;
flex-direction: column;
gap: 10px;
margin: 4px 0 0;
padding: 0;
list-style: none;
}
.planCard li {
color: var(--black);
font-size: 14px;
font-weight: 800;
}
.planCard li::before {
content: "✓";
margin-right: 8px;
color: var(--wine-red);
font-weight: 900;
}
.billingPanel {
display: flex;
flex-direction: column;
gap: 14px;
}
.billingOptions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
}
.billingOption,
.billingOptionSelected {
padding: 14px;
border: 1px solid var(--white-darkest);
border-radius: 16px;
background: var(--white);
cursor: pointer;
text-align: left;
}
.billingOptionSelected {
border-color: var(--wine-red);
background: var(--wine-lighterX2);
}
.billingOption strong,
.billingOptionSelected strong,
.billingOption small,
.billingOptionSelected small {
display: block;
}
.billingOption strong,
.billingOptionSelected strong {
color: var(--black);
}
.billingOption small,
.billingOptionSelected small {
margin-top: 4px;
color: var(--gray-dark);
font-weight: 800;
}
.breakdownPanel {
display: flex;
flex-direction: column;
gap: 18px;
padding: 22px;
background:
radial-gradient(circle at 96% 0%, color-mix(in srgb, var(--yellow-light) 74%, transparent), transparent 27%),
linear-gradient(180deg, var(--white) 0%, color-mix(in srgb, var(--wine-lighterX2) 30%, var(--white)) 100%);
}
.breakdownHeader {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(220px, auto);
align-items: stretch;
gap: 18px;
padding-bottom: 18px;
border-bottom: 1px solid var(--white-darkest);
}
.breakdownHeader h2 {
margin: 8px 0 6px;
color: var(--black);
font-size: clamp(26px, 3.5vw, 38px);
line-height: 1.02;
letter-spacing: -0.045em;
}
.breakdownHeader p,
.planComparison p,
.finalAmountBox p,
.breakdownNote,
.breakdownFormula {
margin: 0;
color: var(--gray-dark);
line-height: 1.55;
font-weight: 750;
}
.breakdownTotalBadge {
display: flex;
flex-direction: column;
justify-content: center;
min-width: 220px;
padding: 18px;
border: 1px solid color-mix(in srgb, var(--yellow) 34%, var(--wine-lighterX2));
border-radius: 20px;
background:
radial-gradient(circle at 100% 0%, var(--yellow-light), transparent 42%),
linear-gradient(135deg, var(--wine-superdark), var(--wine-dark));
box-shadow: 0 16px 34px color-mix(in srgb, var(--wine-darkest) 18%, transparent);
text-align: right;
}
.breakdownTotalBadge small,
.finalAmountBox small,
.planComparison small,
.invoiceLines small {
color: var(--gray-dark);
font-size: 12px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.breakdownTotalBadge small {
color: var(--yellow);
}
.breakdownTotalBadge strong {
margin-top: 8px;
color: var(--white);
font-size: clamp(30px, 4vw, 46px);
line-height: 0.95;
letter-spacing: -0.05em;
}
.invoiceSection {
display: flex;
flex-direction: column;
gap: 12px;
}
.planComparison {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: stretch;
gap: 12px;
}
.planComparison > div:not(.planArrow),
.invoiceLines > div {
padding: 16px;
border: 1px solid var(--white-darkest);
border-radius: 18px;
background: color-mix(in srgb, var(--white) 88%, transparent);
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--white) 80%, transparent);
}
.planComparison strong,
.invoiceLines strong {
display: block;
margin-top: 8px;
color: var(--black);
font-size: 18px;
line-height: 1.2;
}
.planComparison p {
margin-top: 6px;
}
.planArrow {
display: grid;
place-items: center;
align-self: center;
width: 34px;
height: 34px;
border-radius: 999px;
background: var(--yellow);
color: var(--wine-darkest);
font-size: 18px;
font-weight: 900;
}
.invoiceLines {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 12px;
}
.discountLine {
background: color-mix(in srgb, var(--yellow-light) 34%, var(--white)) !important;
}
.discountLine strong {
color: var(--wine-red);
}
.breakdownFormula {
padding: 14px 16px;
border: 1px dashed color-mix(in srgb, var(--wine-red) 38%, var(--white-darkest));
border-radius: 16px;
background: color-mix(in srgb, var(--white) 64%, var(--yellow-light));
color: var(--wine-darkest);
}
.finalAmountBox {
padding: 20px;
border-radius: 22px;
background:
radial-gradient(circle at 92% 12%, var(--yellow-light), transparent 28%),
linear-gradient(135deg, var(--wine-superdark) 0%, var(--wine-darkest) 52%, var(--wine-dark) 100%);
box-shadow: 0 18px 38px color-mix(in srgb, var(--wine-darkest) 18%, transparent);
}
.finalAmountBox small {
color: var(--yellow);
}
.finalAmountBox strong {
display: block;
margin-top: 8px;
color: var(--white);
font-size: clamp(38px, 7vw, 62px);
line-height: 0.95;
letter-spacing: -0.06em;
}
.finalAmountBox p {
margin-top: 10px;
color: var(--white-dark);
}
.breakdownNote {
padding: 14px 16px;
border-left: 4px solid var(--yellow);
border-radius: 14px;
background: color-mix(in srgb, var(--white) 74%, var(--wine-lighterX2));
}
.actionPanel {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(260px, 420px);
gap: 22px;
align-items: center;
}
.actions {
display: grid;
grid-template-columns: 0.8fr 1.2fr;
gap: 12px;
}
.primaryButton,
.secondaryButton {
width: 100%;
padding: 14px 16px;
border-radius: 12px;
font-size: 15px;
font-weight: 900;
cursor: pointer;
}
.primaryButton {
border: 0;
background: linear-gradient(135deg, var(--wine-red), var(--wine-dark));
color: var(--white);
box-shadow: 0 12px 24px color-mix(in srgb, var(--wine-darkest) 20%, transparent);
}
.secondaryButton {
border: 1px solid var(--white-darkest);
background: var(--white);
color: var(--gray-dark);
}
.primaryButton:disabled,
.secondaryButton:disabled {
cursor: not-allowed;
opacity: 0.6;
}
@media (max-width: 760px) {
.page {
margin: 18px auto;
padding: 0 14px 28px;
}
.card {
padding: 14px;
border-radius: 22px;
}
.hero {
border-radius: 18px;
}
.actionPanel,
.actions,
.breakdownHeader {
grid-template-columns: 1fr;
}
.breakdownTotalBadge {
text-align: left;
}
.planComparison {
grid-template-columns: 1fr;
}
.planArrow {
transform: rotate(90deg);
justify-self: center;
}
}
@@ -0,0 +1,465 @@
"use client";
import ApiRequest from "@services/Api.Service";
import AnimatedContainer from "@core/app/components/AnimatedConainer/AnimatedContainer";
import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider";
import Secure from "@components/Secure/Secure";
import SuspenseLoading from "@core/app/components/SuspendeLoading/SuspenseLoading";
import { ScrollToTop } from "@core/app/components/ScrollTop";
import { useNavigation } from "@core/app/hooks/goto";
import { loadOrganizationsByUser } from "@core/app/admin/org/Org.Service";
import { DashboardMetrics, getCompanyMetrics } from "../dashboard/Dashboard.Service";
import { MyOranizationsView } from "@core/Models/Company.model";
import { IPlan } from "@core/Models/Plans.model";
import {
CreateMPPreApprovalParams,
ISubscriptionInfo,
MP_PRE_APPROVAL_STATUS,
MPPreApprovalResponse,
MP_SUBS_STATUS,
} from "@core/Models/Subscription.Model";
import { useAlert } from "@core/Store/Alert.Store";
import { useSessionStore } from "@core/Store/Sesion.Store";
import { ThemeProvider } from "@emotion/react";
import { formatPrice } from "@helpers/Numbers";
import turnosXpressTheme from "@core/app/theme/turnosXpress";
import dayjs from "dayjs";
import { Suspense, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import styles from "./page.module.css";
const billingOptions = [
{ months: 1, label: "1 mes" },
{ months: 3, label: "3 meses" },
{ months: 6, label: "6 meses" },
{ months: 12, label: "12 meses" },
];
const DAY_MS = 24 * 60 * 60 * 1000;
const formatDate = (date?: Date) => (date ? dayjs(date).format("DD/MM/YYYY") : "No disponible");
const getDiscount = (plan: IPlan, months: number) => {
if (months === 3) return plan.discount3Months || 0;
if (months === 6) return plan.discount6Months || 0;
if (months === 12) return plan.discount12Months || 0;
return 0;
};
const getPlanTotal = (plan: IPlan, months: number) => {
const discount = getDiscount(plan, months);
const total = plan.price * months;
return total - total * (discount / 100);
};
const roundPrice = (value: number) => Math.round(value * 100) / 100;
const getRemainingDays = (date?: Date) => {
if (!date) return 0;
return Math.max(0, Math.ceil((new Date(date).getTime() - new Date().getTime()) / DAY_MS));
};
function UpgradePlanContent() {
const searchParams = useSearchParams();
const requestedOrgId = searchParams.get("org") || "";
const { goTo } = useNavigation();
const alert = useAlert();
const SessionInfo = useSessionStore();
const [organizations, setOrganizations] = useState<MyOranizationsView[]>([]);
const [selectedOrgId, setSelectedOrgId] = useState("");
const [subscription, setSubscription] = useState<ISubscriptionInfo | null>(null);
const [metrics, setMetrics] = useState<DashboardMetrics | null>(null);
const [isOwner, setIsOwner] = useState(false);
const [plans, setPlans] = useState<IPlan[]>([]);
const [selectedPlanId, setSelectedPlanId] = useState("");
const [selectedMonths, setSelectedMonths] = useState(1);
const [loading, setLoading] = useState(true);
const [plansLoading, setPlansLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [pageError, setPageError] = useState("");
const [plansError, setPlansError] = useState("");
useEffect(() => {
if (!SessionInfo.userId) return;
setLoading(true);
loadOrganizationsByUser(SessionInfo.userId)
.then((orgs) => {
setOrganizations(orgs);
const requestedOrg = orgs.find((org) => org.id === requestedOrgId);
setSelectedOrgId(requestedOrg?.id || orgs[0]?.id || "");
setPageError(orgs.length === 0 ? "No pertenecés a ninguna organización." : "");
})
.catch((e) => {
const message = e.format ? e.format() : e.message;
setPageError(message || "No pudimos cargar tus organizaciones.");
})
.finally(() => setLoading(false));
}, [SessionInfo.userId, requestedOrgId]);
useEffect(() => {
setPlansLoading(true);
ApiRequest.post<IPlan[]>("plans/find", {})
.then((res) => {
setPlans(res);
setPlansError("");
})
.catch((e) => {
const message = e.format ? e.format() : e.message;
setPlansError(message || "No pudimos cargar los planes disponibles.");
})
.finally(() => setPlansLoading(false));
}, []);
useEffect(() => {
if (!selectedOrgId || !SessionInfo.userId) return;
setLoading(true);
setSubscription(null);
setMetrics(null);
getCompanyMetrics({ sessionUser: SessionInfo.userId, companyId: selectedOrgId })
.then((res) => {
setSubscription(res.subscription);
setMetrics(res.metrics);
setIsOwner(res.isOwner);
setPageError("");
})
.catch((e) => {
const message = e.format ? e.format() : e.message;
setPageError(message || "No pudimos cargar el detalle del plan actual.");
setIsOwner(false);
})
.finally(() => setLoading(false));
}, [selectedOrgId, SessionInfo.userId]);
const currentPlan = subscription?.plan;
const isCurrentPaid = Boolean(currentPlan && currentPlan.price > 0);
const availablePlans = useMemo(() => {
if (!currentPlan) return [];
return [...plans]
.filter((plan) => plan.active && plan.price > 0 && plan.price > currentPlan.price)
.sort((a, b) => a.price - b.price);
}, [currentPlan, plans]);
useEffect(() => {
const hasSelectedPlan = availablePlans.some((plan) => plan.id === selectedPlanId);
if (!hasSelectedPlan) setSelectedPlanId(availablePlans[0]?.id || "");
}, [availablePlans, selectedPlanId]);
const selectedPlan = availablePlans.find((plan) => plan.id === selectedPlanId) || null;
const selectedDiscount = selectedPlan ? getDiscount(selectedPlan, selectedMonths) : 0;
const selectedSubtotal = selectedPlan ? selectedPlan.price * selectedMonths : 0;
const selectedDiscountAmount = roundPrice(selectedSubtotal * (selectedDiscount / 100));
const selectedTotal = selectedPlan ? roundPrice(getPlanTotal(selectedPlan, selectedMonths)) : 0;
const remainingDays = isCurrentPaid ? getRemainingDays(subscription?.endDate) : 0;
const monthlyDifference = selectedPlan && currentPlan ? selectedPlan.price - currentPlan.price : 0;
const proratedAmount = isCurrentPaid ? roundPrice((monthlyDifference / 30) * remainingDays) : 0;
const canSubmit = Boolean(
selectedPlan?.id &&
SessionInfo.userId &&
isOwner &&
subscription?.isActive &&
subscription?.mpStatus !== MP_SUBS_STATUS.PENDING &&
!submitting
);
const effectiveMonths = isCurrentPaid ? 1 : selectedMonths;
const error = plansError || pageError;
const handleSubmit = () => {
if (!canSubmit || !selectedPlan?.id) {
if (!isOwner) alert.showWarning("Solo el dueño de la organización puede cambiar el plan.");
else if (subscription?.mpStatus === MP_SUBS_STATUS.PENDING) alert.showWarning("Ya tenés un pago pendiente. Finalizalo antes de generar otro cambio de plan.");
else alert.showWarning("Elegí un plan superior para continuar.");
return;
}
const preApprovalData: CreateMPPreApprovalParams = {
planId: selectedPlan.id,
sessionUser: SessionInfo.userId,
months: effectiveMonths,
};
setSubmitting(true);
ApiRequest.post<MPPreApprovalResponse>("suscriptions/preapproval", preApprovalData)
.then((response) => {
if (response.status === MP_PRE_APPROVAL_STATUS.READY_TO_START) {
goTo(response.init_point);
}
})
.catch((e) => {
alert.showError(e.format ? e.format() : "No pudimos iniciar el pago del cambio de plan.");
})
.finally(() => setSubmitting(false));
};
return (
<ThemeProvider theme={turnosXpressTheme}>
<ScrollToTop />
<HeaderConfProvider />
<Secure>
<AnimatedContainer
color="#aeaeae"
minSize={150}
maxSize={500}
items={10}
style={{ display: "flex", justifyContent: "center", alignItems: "center" }}
>
<main className={styles.page}>
<section className={styles.card}>
<div className={styles.hero}>
<div className={styles.heroGlow} />
<div className={styles.header}>
<div className={styles.titleBlock}>
<span className={styles.eyebrow}>Cambiar plan</span>
<h1 className={styles.title}>Elegí un plan superior</h1>
<p className={styles.subtitle}>
Solo te mostramos planes pagos con mayor capacidad que tu plan actual.
</p>
</div>
{organizations.length > 1 && (
<label className={styles.orgSelector}>
Organización
<select value={selectedOrgId} onChange={(e) => setSelectedOrgId(e.target.value)}>
{organizations.map((org) => (
<option key={org.id} value={org.id}>{org.name}</option>
))}
</select>
</label>
)}
</div>
</div>
{(loading || plansLoading) && <p className={styles.message}>Cargando opciones de cambio...</p>}
{error && !loading && !plansLoading && <p className={styles.error}>{error}</p>}
{subscription && currentPlan && !loading && !plansLoading && !error && (
<>
<div className={styles.currentPlanPanel}>
<div>
<span>Plan actual</span>
<strong>{currentPlan.name}</strong>
<small>{currentPlan.price > 0 ? `${formatPrice(currentPlan.price)} por mes` : "Plan gratis"}</small>
</div>
<div>
<span>Vencimiento</span>
<strong>{formatDate(subscription.endDate)}</strong>
<small>{metrics ? `${metrics.clientsCount} clientes cargados` : "Organización activa"}</small>
</div>
</div>
{isCurrentPaid && (
<div className={styles.prorationNotice}>
<span>Upgrade prorrateado</span>
<p>
Si subís a un plan superior, hoy pagás solo la diferencia proporcional por los días restantes. El vencimiento se mantiene y los nuevos límites se aplican cuando el pago quede aprobado.
</p>
</div>
)}
{!isOwner && <p className={styles.error}>Solo el dueño de la organización puede cambiar el plan.</p>}
{subscription.mpStatus === MP_SUBS_STATUS.PENDING && <p className={styles.error}>Ya tenés un pago pendiente. Finalizalo antes de generar otro cambio.</p>}
{availablePlans.length === 0 ? (
<div className={styles.emptyState}>
<h2>No hay planes superiores disponibles</h2>
<p>Tu plan actual ya está en el nivel más alto disponible para contratar.</p>
</div>
) : (
<div className={styles.planGrid}>
{availablePlans.map((plan) => {
const isSelected = selectedPlanId === plan.id;
return (
<button
key={plan.id}
className={`${styles.planCard} ${isSelected ? styles.planCardSelected : ""}`}
type="button"
onClick={() => setSelectedPlanId(plan.id || "")}
>
<span>{plan.featured ? "Recomendado" : "Plan superior"}</span>
<h2>{plan.name}</h2>
<p>{plan.description}</p>
<strong>{formatPrice(plan.price)} <small>/ mes</small></strong>
<ul>
{plan.features.slice(0, 4).map((feature) => <li key={feature}>{feature}</li>)}
</ul>
</button>
);
})}
</div>
)}
{selectedPlan && !isCurrentPaid && (
<div className={styles.billingPanel}>
<span>Duración del plan</span>
<div className={styles.billingOptions}>
{billingOptions.map((option) => {
const discount = getDiscount(selectedPlan, option.months);
return (
<button
key={option.months}
className={selectedMonths === option.months ? styles.billingOptionSelected : styles.billingOption}
type="button"
onClick={() => setSelectedMonths(option.months)}
>
<strong>{option.label}</strong>
<small>{discount > 0 ? `${discount}% OFF` : "Sin descuento"}</small>
</button>
);
})}
</div>
</div>
)}
{selectedPlan && (
<div className={styles.breakdownPanel}>
<div className={styles.breakdownHeader}>
<div>
<span>Detalle del importe</span>
<h2>Resumen de facturación</h2>
<p>
{isCurrentPaid
? "El importe se calcula sobre la diferencia entre planes y los días restantes del ciclo actual."
: "Importe final según el plan, duración y descuento seleccionados."}
</p>
</div>
</div>
{isCurrentPaid ? (
<>
<div className={styles.invoiceSection}>
<span>Contexto del cambio</span>
<div className={styles.planComparison}>
<div>
<small>Plan actual</small>
<strong>{currentPlan.name}</strong>
<p>{formatPrice(currentPlan.price)} / mes</p>
</div>
<div className={styles.planArrow}></div>
<div>
<small>Plan seleccionado</small>
<strong>{selectedPlan.name}</strong>
<p>{formatPrice(selectedPlan.price)} / mes</p>
</div>
</div>
</div>
<div className={styles.invoiceSection}>
<span>Cálculo proporcional</span>
<div className={styles.invoiceLines}>
<div>
<small>Diferencia mensual entre planes</small>
<strong>{formatPrice(monthlyDifference)} / mes</strong>
</div>
<div>
<small>Días restantes hasta el {formatDate(subscription.endDate)}</small>
<strong>{remainingDays} día{remainingDays === 1 ? "" : "s"}</strong>
</div>
<div>
<small>Importe proporcional</small>
<strong>{formatPrice(proratedAmount)}</strong>
</div>
</div>
<p className={styles.breakdownFormula}>
Se toma la diferencia mensual ({formatPrice(selectedPlan.price)} - {formatPrice(currentPlan.price)}) y se prorratea por 30 días × {remainingDays} día{remainingDays === 1 ? "" : "s"}.
</p>
</div>
<p className={styles.breakdownNote}>
TurnosXpress calcula este importe según las reglas de tu plan. El vencimiento no cambia: seguís con fecha {formatDate(subscription.endDate)}. Los nuevos límites se aplican cuando el pago quede aprobado.
</p>
</>
) : (
<>
<div className={styles.invoiceSection}>
<span>Contexto del cambio</span>
<div className={styles.planComparison}>
<div>
<small>Plan actual</small>
<strong>{currentPlan.name}</strong>
<p>Plan gratis</p>
</div>
<div className={styles.planArrow}></div>
<div>
<small>Plan seleccionado</small>
<strong>{selectedPlan.name}</strong>
<p>{formatPrice(selectedPlan.price)} / mes</p>
</div>
</div>
</div>
<div className={styles.invoiceSection}>
<span>Detalle de compra</span>
<div className={styles.invoiceLines}>
<div>
<small>Precio mensual</small>
<strong>{formatPrice(selectedPlan.price)}</strong>
</div>
<div>
<small>Duración seleccionada</small>
<strong>{selectedMonths} mes{selectedMonths === 1 ? "" : "es"}</strong>
</div>
<div>
<small>Subtotal</small>
<strong>{formatPrice(selectedSubtotal)}</strong>
</div>
<div className={styles.discountLine}>
<small>{selectedDiscount > 0 ? `Descuento ${selectedDiscount}%` : "Descuento"}</small>
<strong>{selectedDiscount > 0 ? `-${formatPrice(selectedDiscountAmount)}` : "No aplica"}</strong>
</div>
</div>
</div>
<p className={styles.breakdownNote}>
Este es el importe final definido por TurnosXpress. {selectedDiscount > 0
? `Incluye ${selectedDiscount}% de descuento por contratar ${selectedMonths} meses.`
: "No hay descuentos aplicados para esta duración."} El plan se activa cuando el pago quede aprobado.
</p>
</>
)}
</div>
)}
<div className={styles.actionPanel}>
<div>
<span>{isCurrentPaid ? "Total a pagar hoy" : "Total a pagar"}</span>
<strong>
{selectedPlan
? isCurrentPaid
? formatPrice(proratedAmount)
: formatPrice(selectedTotal)
: "Seleccioná un plan"}
</strong>
<small>
{isCurrentPaid
? "TurnosXpress cobra solo la diferencia proporcional restante."
: "Precio final según duración y descuentos disponibles."}
</small>
</div>
<div className={styles.actions}>
<button className={styles.secondaryButton} onClick={() => goTo("/landing/current-plan")} disabled={submitting}>
Volver
</button>
<button className={styles.primaryButton} onClick={handleSubmit} disabled={!canSubmit}>
{submitting ? "Generando pago..." : "Continuar al pago"}
</button>
</div>
</div>
</>
)}
</section>
</main>
</AnimatedContainer>
</Secure>
</ThemeProvider>
);
}
export default function UpgradePlanPage() {
return (
<Suspense fallback={<SuspenseLoading />}>
<UpgradePlanContent />
</Suspense>
);
}