feat: plan management

This commit is contained in:
2026-07-17 21:28:41 -03:00
parent d6a4aab52b
commit 8d9a03cd23
21 changed files with 1355 additions and 131 deletions
@@ -0,0 +1,541 @@
.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: 650px;
}
.eyebrow {
color: var(--yellow);
font-size: 12px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.sectionHeader span {
color: var(--wine-red);
font-size: 12px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.actionEyebrow {
color: var(--wine-red);
font-size: 12px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.title {
margin: 10px 0 10px;
color: var(--white);
font-size: clamp(34px, 6vw, 62px);
line-height: 0.95;
letter-spacing: -0.05em;
}
.subtitle {
max-width: 560px;
margin: 0 0 18px;
color: var(--white-dark);
font-size: 16px;
line-height: 1.6;
}
.status {
display: inline-flex;
align-items: center;
color: var(--wine-darkest);
background: linear-gradient(135deg, var(--yellow), var(--wine-lighter));
border-radius: 999px;
padding: 7px 14px;
font-size: 12px;
font-weight: 900;
box-shadow: 0 10px 24px color-mix(in srgb, var(--black-dark) 18%, transparent);
}
.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);
}
.summaryGrid,
.limitsGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 14px;
}
.ownerCard {
position: relative;
overflow: hidden;
display: flex;
align-items: center;
gap: 16px;
padding: 18px;
border: 1px solid var(--wine-lighterX2);
border-radius: 24px;
background:
radial-gradient(circle at 100% 0%, var(--wine-lighterX2), transparent 32%),
linear-gradient(135deg, var(--white) 0%, var(--white-dark) 100%);
box-shadow: 0 14px 34px color-mix(in srgb, var(--wine-darkest) 9%, transparent);
}
.ownerCard::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 5px;
background: linear-gradient(180deg, var(--wine-red), var(--wine-dark));
}
.ownerAvatarWrap {
display: grid;
place-items: center;
width: 76px;
height: 76px;
border-radius: 999px;
background: linear-gradient(135deg, var(--wine-red), var(--wine-dark));
box-shadow: 0 14px 28px color-mix(in srgb, var(--wine-darkest) 18%, transparent);
}
.ownerInfo {
display: flex;
flex-direction: column;
min-width: 0;
}
.ownerInfo span {
color: var(--wine-red);
font-size: 12px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.11em;
}
.ownerInfo strong {
color: var(--black);
font-size: clamp(22px, 3vw, 30px);
line-height: 1.1;
letter-spacing: -0.03em;
}
.ownerInfo small {
margin-top: 4px;
color: var(--gray-dark);
font-size: 14px;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.summaryItem,
.limitItem {
position: relative;
overflow: hidden;
background: var(--white);
border: 1px solid var(--white-darkest);
border-radius: 20px;
padding: 18px;
box-shadow: 0 14px 36px color-mix(in srgb, var(--wine-darkest) 10%, transparent);
transition: transform 180ms ease, box-shadow 180ms ease, border-color 180ms ease;
}
.summaryItem:hover,
.limitItem:hover {
transform: translateY(-2px);
border-color: var(--wine-lighter);
box-shadow: 0 18px 34px color-mix(in srgb, var(--wine-darkest) 12%, transparent);
}
.summaryItem::before,
.limitItem::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 4px;
background: linear-gradient(180deg, var(--wine-red), var(--wine-dark));
}
.summaryItem span,
.limitItem span {
display: block;
margin-bottom: 9px;
color: var(--gray-dark);
font-size: 12px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.summaryItem strong,
.limitItem strong {
display: block;
color: var(--black);
font-size: 24px;
line-height: 1.1;
}
.summaryItem small,
.limitItem small {
display: block;
margin-top: 9px;
color: var(--gray-darkest);
font-size: 13px;
font-weight: 700;
}
.inlineRenewLink {
display: inline-flex;
align-items: center;
width: fit-content;
margin-top: 12px;
padding: 0;
border: 0;
background: transparent;
color: var(--wine-red);
font-size: 14px;
font-weight: 900;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 4px;
}
.inlineRenewLink:hover {
color: var(--wine-dark);
}
.notice {
margin: 0;
padding: 15px 18px;
border: 1px solid var(--wine-lighterX2);
border-radius: 18px;
background: var(--wine-lighterX2);
color: var(--wine-darkest);
font-weight: 800;
}
.section {
display: flex;
flex-direction: column;
gap: 14px;
padding: 4px 2px;
}
.sectionHeader {
display: flex;
flex-direction: column;
gap: 6px;
}
.sectionHeader h2,
.actionPanel h2 {
margin: 0;
color: var(--black);
font-size: clamp(22px, 3vw, 30px);
letter-spacing: -0.03em;
}
.features {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 12px;
margin: 0;
padding: 0;
list-style: none;
}
.features li {
position: relative;
overflow: hidden;
display: flex;
align-items: center;
gap: 12px;
min-height: 68px;
padding: 14px 16px 14px 54px;
border: 1px solid var(--wine-lighterX2);
border-radius: 18px;
background:
radial-gradient(circle at 100% 0%, var(--wine-lighterX2), transparent 34%),
linear-gradient(135deg, var(--white) 0%, var(--white-dark) 100%);
color: var(--black);
font-size: 14px;
font-weight: 800;
line-height: 1.25;
box-shadow: 0 12px 28px color-mix(in srgb, var(--wine-darkest) 8%, transparent);
transition: transform 180ms ease, box-shadow 180ms ease, border-color 180ms ease;
}
.features li::before {
content: "✓";
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%);
display: grid;
place-items: center;
width: 26px;
height: 26px;
border-radius: 999px;
background: linear-gradient(135deg, var(--wine-red), var(--wine-dark));
color: var(--white);
font-size: 15px;
font-weight: 900;
box-shadow: 0 8px 18px color-mix(in srgb, var(--wine-darkest) 18%, transparent);
}
.features li::after {
content: "";
position: absolute;
inset: auto 14px 10px 54px;
height: 3px;
border-radius: 999px;
background: linear-gradient(90deg, var(--wine-red), var(--wine-lighter));
opacity: 0.45;
}
.features li:hover {
transform: translateY(-2px);
border-color: var(--wine-lighter);
box-shadow: 0 18px 34px color-mix(in srgb, var(--wine-darkest) 12%, transparent);
}
.message {
margin: 0;
color: var(--gray-dark);
line-height: 1.5;
}
.usageShowcase {
position: relative;
overflow: hidden;
display: grid;
grid-template-columns: minmax(190px, 0.8fr) minmax(0, 1.2fr);
gap: 18px;
align-items: stretch;
padding: 18px;
border: 1px solid var(--wine-lighterX2);
border-radius: 24px;
background:
radial-gradient(circle at 8% 20%, var(--yellow-light), transparent 30%),
linear-gradient(135deg, var(--wine-superdark) 0%, var(--wine-darkest) 45%, var(--wine-dark) 100%);
box-shadow: 0 18px 42px color-mix(in srgb, var(--wine-darkest) 16%, transparent);
}
.usageShowcase::after {
content: "";
position: absolute;
right: -70px;
top: -90px;
width: 220px;
height: 220px;
border-radius: 999px;
background: var(--wine-lighterX2);
pointer-events: none;
}
.usageIntro,
.usageStats {
position: relative;
z-index: 1;
}
.usageIntro {
display: flex;
flex-direction: column;
justify-content: center;
gap: 8px;
}
.usageIntro span,
.usageStat span {
color: var(--yellow);
font-size: 12px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.11em;
}
.usageIntro strong {
max-width: 260px;
color: var(--white);
font-size: clamp(22px, 3vw, 32px);
line-height: 1;
letter-spacing: -0.04em;
}
.usageStats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.usageStat {
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 128px;
padding: 16px;
border: 1px solid var(--wine-lighterX2);
border-radius: 20px;
background: color-mix(in srgb, var(--white) 12%, transparent);
backdrop-filter: blur(10px);
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--white) 24%, transparent);
}
.usageStat strong {
color: var(--white);
font-size: clamp(34px, 5vw, 54px);
line-height: 0.9;
letter-spacing: -0.07em;
}
.error {
margin: 0;
padding: 14px 16px;
border-radius: 16px;
background: var(--wine-lighterX2);
color: var(--wine-red);
font-weight: 800;
}
.actionPanel {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(260px, 360px);
gap: 22px;
align-items: center;
padding: clamp(20px, 3vw, 28px);
border: 1px solid var(--wine-lighterX2);
border-radius: 24px;
background:
radial-gradient(circle at 92% 18%, var(--yellow-light), transparent 28%),
linear-gradient(135deg, var(--wine-lighterX2) 0%, var(--white-dark) 52%, var(--white) 100%);
box-shadow: inset 0 1px 0 var(--white), 0 18px 40px color-mix(in srgb, var(--wine-darkest) 10%, transparent);
}
.actionPanel p {
margin: 10px 0 0;
color: var(--gray-dark);
line-height: 1.6;
}
.actions {
display: grid;
gap: 12px;
}
@media (max-width: 760px) {
.page {
margin: 18px auto;
padding: 0 14px 28px;
}
.card {
padding: 14px;
border-radius: 22px;
}
.hero {
border-radius: 18px;
}
.header,
.actionPanel {
grid-template-columns: 1fr;
}
.actionPanel {
display: flex;
flex-direction: column;
align-items: stretch;
}
.usageShowcase,
.usageStats {
grid-template-columns: 1fr;
}
.usageStat {
min-height: 104px;
}
.ownerCard {
align-items: flex-start;
}
.ownerAvatarWrap {
width: 64px;
height: 64px;
flex: 0 0 auto;
}
}
@@ -0,0 +1,293 @@
"use client";
import AnimatedContainer from "@core/app/components/AnimatedConainer/AnimatedContainer";
import Avatar from "@core/app/components/Avatar/Avatar";
import Button from "@core/app/components/Button/Button";
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 { MyOranizationsView } from "@core/Models/Company.model";
import { getSubStatusText, ISubscriptionInfo, 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, useState } from "react";
import { DashboardMetrics, getCompanyMetrics } from "../dashboard/Dashboard.Service";
import styles from "./page.module.css";
const formatLimit = (value: number) => (value === -1 ? "Ilimitado" : value.toString());
const formatDate = (date?: Date) => (date ? dayjs(date).format("DD/MM/YYYY") : "No disponible");
const getLimitCards = (plan: ISubscriptionInfo["plan"], metrics: DashboardMetrics | null) => [
{ label: "Organizaciones", limit: plan.limitOrganizations, current: metrics?.organizationsCount },
{ label: "Colaboradores", limit: plan.limitEmployees, current: metrics?.employeesCount },
{ label: "Servicios", limit: plan.limitServices, current: metrics?.servicesCount },
{ label: "Turnos", limit: plan.limitAppointments, current: metrics?.appointmentsCount },
{ label: "Clientes", limit: plan.limitClients, current: metrics?.clientsCount },
{ label: "Repeticiones", limit: plan.limitRepeats, current: metrics?.repeatsCount },
];
export default function CurrentPlanPage() {
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 [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
if (!SessionInfo.userId) {
return;
}
setLoading(true);
loadOrganizationsByUser(SessionInfo.userId)
.then((orgs) => {
setOrganizations(orgs);
setSelectedOrgId(orgs[0]?.id || "");
setError(orgs.length === 0 ? "No pertenecés a ninguna organización." : "");
})
.catch((e) => {
const message = e.format ? e.format() : e.message;
setError(message || "No pudimos cargar tus organizaciones.");
})
.finally(() => setLoading(false));
}, [SessionInfo.userId]);
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);
setError("");
})
.catch((e) => {
const message = e.format ? e.format() : e.message;
setError(message || "No pudimos cargar el detalle del plan.");
setIsOwner(false);
})
.finally(() => setLoading(false));
}, [selectedOrgId, SessionInfo.userId]);
const plan = subscription?.plan;
const isPaidPlan = Boolean(plan && plan.price > 0);
const canRenew = Boolean(
isOwner &&
isPaidPlan &&
plan?.id &&
subscription?.isActive &&
subscription?.mpStatus !== MP_SUBS_STATUS.PENDING
);
const getRenewBlockReason = () => {
if (!isOwner) return "Solo el dueño de la organización puede extender el plan.";
if (!isPaidPlan) return "El plan gratuito no requiere extensión de meses pagos.";
if (!plan?.id) return "No pudimos identificar el plan actual para extenderlo.";
if (!subscription?.isActive) return "La suscripción no está activa. Cambiá de plan o contactá soporte.";
if (subscription?.mpStatus === MP_SUBS_STATUS.PENDING) return "Ya tenés un pago pendiente para este plan. Finalizá ese pago antes de generar otro.";
return "No es posible extender este plan en este momento.";
};
const handleRenew = () => {
if (!canRenew) {
alert.showWarning(getRenewBlockReason());
return;
}
goTo(`/landing/subscription/${plan?.id}?renew=1`);
};
return (
<ThemeProvider theme={turnosXpressTheme}>
<ScrollToTop />
<HeaderConfProvider />
<Suspense fallback={<SuspenseLoading />}>
<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}>Plan actual</span>
<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>
)}
</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 && <p className={styles.message}>Cargando detalle del plan...</p>}
{error && !loading && <p className={styles.error}>{error}</p>}
{subscription && plan && !loading && (
<>
<div className={styles.ownerCard}>
<div className={styles.ownerAvatarWrap}>
<Avatar
src={SessionInfo.avatar || SessionInfo.userFullName || SessionInfo.userEmail}
alt={SessionInfo.userFullName || "Usuario"}
size="medium"
border="none"
/>
</div>
<div className={styles.ownerInfo}>
<span>Titular del plan</span>
<strong>{SessionInfo.userFullName || "Usuario sin nombre"}</strong>
<small>{SessionInfo.userEmail || "Email no disponible"}</small>
</div>
</div>
<div className={styles.summaryGrid}>
<div className={styles.summaryItem}>
<span>Inicio</span>
<strong>{formatDate(subscription.startDate)}</strong>
<small>Fecha de alta</small>
</div>
<div className={styles.summaryItem}>
<span>Vencimiento</span>
<strong>{formatDate(subscription.endDate)}</strong>
<small>{isPaidPlan ? "Período vigente" : "Sin vencimiento pago"}</small>
{isPaidPlan && (
<button className={styles.inlineRenewLink} onClick={handleRenew}>
Extender plan
</button>
)}
</div>
<div className={styles.summaryItem}>
<span>Precio</span>
<strong>{plan.price > 0 ? formatPrice(plan.price) : "Gratis"}</strong>
<small>{isPaidPlan ? "Por período contratado" : "Plan inicial"}</small>
</div>
</div>
{!isPaidPlan && (
<p className={styles.notice}>Estás usando el plan gratis. No tiene vencimiento pago.</p>
)}
{isPaidPlan && !subscription.endDate && (
<p className={styles.notice}>Este plan pago no informa una fecha de vencimiento.</p>
)}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<span>Beneficios incluidos</span>
<h2>Características</h2>
</div>
{plan.features.length > 0 ? (
<ul className={styles.features}>
{plan.features.map((feature) => <li key={feature}>{feature}</li>)}
</ul>
) : (
<p className={styles.message}>Este plan no tiene características cargadas.</p>
)}
</div>
<div className={styles.section}>
<div className={styles.sectionHeader}>
<span>Capacidad del plan</span>
<h2>Límites principales</h2>
</div>
<div className={styles.limitsGrid}>
{getLimitCards(plan, metrics).map((item) => (
<div className={styles.limitItem} key={item.label}>
<span>{item.label}</span>
<strong>{formatLimit(item.limit)}</strong>
</div>
))}
</div>
{metrics && (
<div className={styles.usageShowcase}>
<div className={styles.usageIntro}>
<span>Uso actual</span>
<strong>Actividad real de tu organización</strong>
</div>
<div className={styles.usageStats}>
<div className={styles.usageStat}>
<span>Clientes</span>
<strong>{metrics.clientsCount}</strong>
</div>
<div className={styles.usageStat}>
<span>Turnos</span>
<strong>{metrics.appointmentsCount}</strong>
</div>
<div className={styles.usageStat}>
<span>Colaboradores</span>
<strong>{metrics.employeesCount}</strong>
</div>
</div>
</div>
)}
</div>
<div className={styles.actionPanel}>
<div>
<span className={styles.actionEyebrow}>Gestión del plan</span>
<h2>¿Querés ajustar tu suscripción?</h2>
<p>Extendé el período actual si está disponible o compará opciones para cambiar de plan.</p>
</div>
<div className={styles.actions}>
<Button
color="primary"
text="Extender plan"
width="custom"
style={{ width: "100%", padding: "13px 0", borderRadius: "12px", fontWeight: "700" }}
onClick={handleRenew}
/>
<Button
color="link"
text="Cambiar plan"
width="custom"
style={{ width: "100%", padding: "13px 0", borderRadius: "12px", fontWeight: "800" }}
onClick={() => goTo("/landing/pricing")}
/>
</div>
</div>
{!isOwner && <p className={styles.message}>Solo el dueño de la organización puede extender el plan.</p>}
</>
)}
</section>
</main>
</AnimatedContainer>
</Secure>
</Suspense>
</ThemeProvider>
);
}
+38 -20
View File
@@ -133,6 +133,8 @@ export default function DashboardPage() {
useEffect(() => {
if (selectedOrgId && SessionInfo.userId) {
setCompanySubscription(null);
setCompanyMetrics(null);
getCompanyMetrics({ sessionUser: SessionInfo.userId, companyId: selectedOrgId })
.then(res => {
setCompanyMetrics(res.metrics);
@@ -217,6 +219,8 @@ export default function DashboardPage() {
goTo("/admin/org");
};
const displayedSubscription = companySubscription;
return (
<ThemeProvider theme={turnosXpressTheme}>
<ScrollToTop />
@@ -261,29 +265,42 @@ 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' }}>{subscription.plan.name}</span>
<span
style={{
backgroundColor:
getSubStatusText(subscription.mpStatus) ===
"ACTIVO"
? "var(--sub-status-active)"
: "var(--sub-status-inactive)",
borderRadius: "12px",
padding: "4px 10px",
fontSize: "12px",
color: "white",
fontWeight: "bold",
}}
>
{getSubStatusText(subscription.mpStatus)}
</span>
<span style={{ fontWeight: 'bold' }}>{displayedSubscription?.plan.name || "Cargando plan..."}</span>
{displayedSubscription && (
<span
style={{
backgroundColor:
getSubStatusText(displayedSubscription.mpStatus) ===
"ACTIVO"
? "var(--sub-status-active)"
: "var(--sub-status-inactive)",
borderRadius: "12px",
padding: "4px 10px",
fontSize: "12px",
color: "white",
fontWeight: "bold",
}}
>
{getSubStatusText(displayedSubscription.mpStatus)}
</span>
)}
</div>
{subscription.endDate && subscription.plan.price > 0 && (
</div>
<div className={style.planHeaderActions}>
{displayedSubscription?.endDate && displayedSubscription.plan.price > 0 && (
<span style={{ fontSize: '14px', color: '#666' }}>
Vigente hasta el {dayjs(subscription.endDate).format("DD/MM/YYYY")}
Vigente hasta el {dayjs(displayedSubscription.endDate).format("DD/MM/YYYY")}
</span>
)}
{displayedSubscription && isCompanyOwner && (
<Button
color="link"
text="Ver detalle del plan"
width="custom"
style={{ margin: 0, padding: 0, fontSize: "14px", fontWeight: "700", color: "var(--wine-red)", minHeight: "auto", width: "fit-content" }}
onClick={() => goTo("/landing/current-plan")}
/>
)}
</div>
{subscription.mpStatus === MP_SUBS_STATUS.PENDING && (
<Button
@@ -377,7 +394,8 @@ export default function DashboardPage() {
}
goTo("/landing/pricing");
}}
onRenew={() => goTo("/landing/pricing")}
onRenew={() => goTo(`/landing/subscription/${companySubscription.plan.id}?renew=1`)}
onViewDetails={() => goTo("/landing/current-plan")}
onCancel={cancelarSubscription}
/>
)}
@@ -35,6 +35,22 @@
width: 100%;
}
.planHeaderActions {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
width: 100%;
}
@media (min-width: 850px) {
.planHeaderActions {
align-items: flex-end;
margin-left: auto;
width: auto;
}
}
.metricsCard {
background-color: white;
border-radius: 16px;
@@ -5,6 +5,7 @@ import SecurePage from "@core/app/components/Secure/Secure";
import { LOGIN_ACTIONS } from "@core/Store/LoginAction.Store";
import { useSessionStore } from "@core/Store/Sesion.Store";
import { useParams } from "next/navigation";
import { useSearchParams } from "next/navigation";
import { useEffect, useState, Suspense } from "react";
import {
CreateMPPreApprovalParams,
@@ -22,9 +23,11 @@ type UrlData = {
planid: string;
};
export default function SubscriptionAddPage() {
function SubscriptionAddContent() {
const data = useParams<UrlData>();
const searchParams = useSearchParams();
const id = data.planid;
const isRenewal = searchParams.get("renew") === "1";
const SessionInfo = useSessionStore();
const alert = useAlert();
const eventHandler = useEventHandlerStore();
@@ -33,6 +36,7 @@ export default function SubscriptionAddPage() {
const [plan, setPlan] = useState<IPlan | null>(null);
const [selectedMonths, setSelectedMonths] = useState<number>(1);
const [finalPrice, setFinalPrice] = useState<number>(0);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const getPlanInfo = () => {
eventHandler.setEventType(EVENT_TYPES.LOADING);
@@ -71,6 +75,10 @@ export default function SubscriptionAddPage() {
}, [selectedMonths, plan]);
const getLink = () => {
if (isSubmitting) {
return;
}
if (SessionInfo.userId && SessionInfo.loged) {
const preApprovalData: CreateMPPreApprovalParams = {
planId: id,
@@ -79,6 +87,7 @@ export default function SubscriptionAddPage() {
};
eventHandler.setEventType(EVENT_TYPES.LOADING);
setIsSubmitting(true);
ApiRequest.post<MPPreApprovalResponse>("suscriptions/preapproval", preApprovalData)
.then(async (response) => {
@@ -95,6 +104,7 @@ export default function SubscriptionAddPage() {
alert.showError(error.format ? error.format() : "Ocurrió un error");
})
.finally(() => {
setIsSubmitting(false);
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
}
@@ -123,14 +133,16 @@ export default function SubscriptionAddPage() {
<div className={styles.leftPanel}>
<div style={{ marginBottom: "2rem" }}>
<span style={{ background: "rgba(255,255,255,0.2)", padding: "5px 12px", borderRadius: "20px", fontSize: "0.85rem", fontWeight: "bold", textTransform: "uppercase", letterSpacing: "1px" }}>
Resumen de contratación
{isRenewal ? "Resumen de renovación" : "Resumen de contratación"}
</span>
</div>
<h1 className={styles.title}>{plan?.name}</h1>
<p className={styles.subtitle}>
{plan?.price === 0
? "Activa tu plan de forma instantánea. Sin tarjetas de crédito, sin complicaciones."
: "Selecciona la duración de tu plan. ¡Aprovechá los descuentos exclusivos pagando por más meses por adelantado!"}
: isRenewal
? "Elegí cuántos meses querés sumar. El nuevo período se agrega al vencimiento actual cuando se apruebe el pago."
: "Selecciona la duración de tu plan. ¡Aprovechá los descuentos exclusivos pagando por más meses por adelantado!"}
</p>
{plan?.features && (
@@ -202,18 +214,18 @@ export default function SubscriptionAddPage() {
</div>
<div className={styles.actionButtons}>
<button className={styles.btnSecondary} onClick={() => goTo("/landing/pricing")}>
<button className={styles.btnSecondary} onClick={() => goTo("/landing/pricing")} disabled={isSubmitting}>
Volver
</button>
{plan?.price === 0 ? (
<button className={`${styles.btnPrimary} ${styles.btnFree}`} style={{ margin: 0 }} onClick={getLink}>
<button className={`${styles.btnPrimary} ${styles.btnFree}`} style={{ margin: 0 }} onClick={getLink} disabled={isSubmitting}>
Activar Gratis Ahora
</button>
) : (
<button className={styles.btnPrimary} onClick={getLink}>
<button className={styles.btnPrimary} onClick={getLink} disabled={isSubmitting}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="1" y="4" width="22" height="16" rx="2" ry="2"></rect><line x1="1" y1="10" x2="23" y2="10"></line></svg>
Pagar con MercadoPago
{isSubmitting ? "Generando pago..." : isRenewal ? "Extender con MercadoPago" : "Pagar con MercadoPago"}
</button>
)}
</div>
@@ -227,3 +239,11 @@ export default function SubscriptionAddPage() {
</Suspense>
);
}
export default function SubscriptionAddPage() {
return (
<Suspense fallback={<SuspenseLoading />}>
<SubscriptionAddContent />
</Suspense>
);
}