466 lines
27 KiB
TypeScript
466 lines
27 KiB
TypeScript
"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>
|
||
);
|
||
}
|