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
@@ -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>
);
}