Files
turnosxpress/txclient/src/app/landing/upgrade-plan/page.tsx
T

466 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}