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