From 8d9a03cd23f80571009e5512f669e545a3c2b7ce Mon Sep 17 00:00:00 2001 From: Horacio Daniel Ros Date: Fri, 17 Jul 2026 21:28:41 -0300 Subject: [PATCH] feat: plan management --- .codegraph/.gitignore | 5 + .../PlanPayments.Adapter.Mongoose.ts | 36 ++ .../PlanPayments/PlanPayments.interface.ts | 7 + .../src/Models/PlanPayments/PlanPayments.ts | 27 + .../PlanSubscriptions.Adapter.Mongoose.ts | 34 +- .../PlanSubscriptions.interface.ts | 11 + .../PlanSubscriptions/PlanSubscriptons.ts | 113 +++- .../MercadoPago/MercadoPagoWebhook.Service.ts | 73 ++- txclient/src/app/components/Button/Button.tsx | 2 + .../FinancialWidget.module.css | 29 + .../FinancialWidget/FinancialWidget.tsx | 38 +- .../components/Location/Location.module.css | 8 + .../PendingRepeatsCard/PendingRepeatsCard.tsx | 33 +- .../PendingRepeatsCard/style.module.css | 52 +- .../PlanMetricsWidget.module.css | 24 + .../PlanMetricsWidget/PlanMetricsWidget.tsx | 50 +- .../app/landing/current-plan/page.module.css | 541 ++++++++++++++++++ .../src/app/landing/current-plan/page.tsx | 293 ++++++++++ txclient/src/app/landing/dashboard/page.tsx | 58 +- .../app/landing/dashboard/style.module.css | 16 + .../landing/subscription/[planid]/page.tsx | 36 +- 21 files changed, 1355 insertions(+), 131 deletions(-) create mode 100644 .codegraph/.gitignore create mode 100644 txclient/src/app/landing/current-plan/page.module.css create mode 100644 txclient/src/app/landing/current-plan/page.tsx diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore new file mode 100644 index 0000000..d20c0fe --- /dev/null +++ b/.codegraph/.gitignore @@ -0,0 +1,5 @@ +# CodeGraph data files — local to each machine, not for committing. +# Ignore everything in .codegraph/ except this file itself, so transient +# files (the database, daemon.pid, sockets, logs) never show up in git. +* +!.gitignore diff --git a/server/src/Models/PlanPayments/PlanPayments.Adapter.Mongoose.ts b/server/src/Models/PlanPayments/PlanPayments.Adapter.Mongoose.ts index e69de29..6539cd7 100644 --- a/server/src/Models/PlanPayments/PlanPayments.Adapter.Mongoose.ts +++ b/server/src/Models/PlanPayments/PlanPayments.Adapter.Mongoose.ts @@ -0,0 +1,36 @@ +import { Document, Model, Schema, model } from "mongoose"; +import { + CreatePlanPaymentParams, + FindPlanPaymentsParams, + IPlanPayment, + IPlanPaymentsAdapter, +} from "./PlanPayments.interface"; + +export interface IPlanPaymentDocument extends Omit, Document {} + +export class PlanPaymentsAdapterMongoose implements IPlanPaymentsAdapter { + schema: Schema; + planPaymentList: Model; + + constructor() { + this.schema = new Schema({ + userId: { type: Schema.Types.ObjectId, required: true, ref: "User" }, + subscriptionId: { type: Schema.Types.ObjectId, required: true, ref: "PlanSuscription" }, + amount: { type: Number, required: true, default: 0 }, + paymentDate: { type: Date, required: true, default: Date.now }, + paymentMethod: { type: String, required: false }, + status: { type: String, required: true }, + transactionId: { type: String, required: false }, + }); + + this.planPaymentList = model("PlanPayment", this.schema); + } + + public async find(filters: FindPlanPaymentsParams): Promise { + return this.planPaymentList.find(filters).exec(); + } + + public async create(data: CreatePlanPaymentParams): Promise { + return this.planPaymentList.create(data); + } +} diff --git a/server/src/Models/PlanPayments/PlanPayments.interface.ts b/server/src/Models/PlanPayments/PlanPayments.interface.ts index 10aaada..fa69f8f 100644 --- a/server/src/Models/PlanPayments/PlanPayments.interface.ts +++ b/server/src/Models/PlanPayments/PlanPayments.interface.ts @@ -1,7 +1,12 @@ export type FindPlanPaymentsParams = { _id?: string; + userId?: string; + subscriptionId?: string; + transactionId?: string; }; +export type CreatePlanPaymentParams = Omit; + export interface IPlanPayment { id?: string; userId: string; // Referencia al ID del usuario @@ -15,9 +20,11 @@ export interface IPlanPayment { export interface IPlanPaymentsAdapter { find(filters: FindPlanPaymentsParams): Promise; + create(data: CreatePlanPaymentParams): Promise; } export interface IPlanPaymentsManager { planPayments: IPlanPaymentsAdapter; find(data: FindPlanPaymentsParams): Promise; + create(data: CreatePlanPaymentParams): Promise; } diff --git a/server/src/Models/PlanPayments/PlanPayments.ts b/server/src/Models/PlanPayments/PlanPayments.ts index e69de29..fae6018 100644 --- a/server/src/Models/PlanPayments/PlanPayments.ts +++ b/server/src/Models/PlanPayments/PlanPayments.ts @@ -0,0 +1,27 @@ +import { PlanPaymentsAdapterMongoose } from "./PlanPayments.Adapter.Mongoose"; +import { + CreatePlanPaymentParams, + FindPlanPaymentsParams, + IPlanPayment, + IPlanPaymentsManager, +} from "./PlanPayments.interface"; + +class PlanPaymentsManager implements IPlanPaymentsManager { + planPayments: PlanPaymentsAdapterMongoose; + + constructor() { + this.planPayments = new PlanPaymentsAdapterMongoose(); + } + + public async find(data: FindPlanPaymentsParams): Promise { + return this.planPayments.find(data); + } + + public async create(data: CreatePlanPaymentParams): Promise { + return this.planPayments.create(data); + } +} + +const PlanPaymentsList = new PlanPaymentsManager(); + +export default PlanPaymentsList; diff --git a/server/src/Models/PlanSubscriptions/PlanSubscriptions.Adapter.Mongoose.ts b/server/src/Models/PlanSubscriptions/PlanSubscriptions.Adapter.Mongoose.ts index 4a93013..718af36 100644 --- a/server/src/Models/PlanSubscriptions/PlanSubscriptions.Adapter.Mongoose.ts +++ b/server/src/Models/PlanSubscriptions/PlanSubscriptions.Adapter.Mongoose.ts @@ -38,6 +38,10 @@ export class PlanSuscriptionsAdapterMongoose implements IPlanSuscriptionsAdapter mpDateCreated: { type: Date, required: false }, mpInitPoint: { type: String, required: false }, mpPreferenceId: { type: String, required: false }, + billingMonths: { type: Number, required: false }, + pendingPaymentInitPoint: { type: String, required: false }, + pendingPaymentPreferenceId: { type: String, required: false }, + pendingPaymentBillingMonths: { type: Number, required: false }, }); this.planSuscriptionList = model("PlanSuscription", this.schema); @@ -54,9 +58,21 @@ export class PlanSuscriptionsAdapterMongoose implements IPlanSuscriptionsAdapter public async findOne( filters: FindPlanSuscripcionsParams ): Promise { - let subscription = await this.planSuscriptionList - .findOne({ userId: filters.sessionUser }) - .exec(); + const searchCriteria: Record = {}; + + if (filters.sessionUser) { + searchCriteria.userId = filters.sessionUser; + } + + if (filters.mpPreferenceId) { + searchCriteria.mpPreferenceId = filters.mpPreferenceId; + } + + if (filters.mpStatus) { + searchCriteria.mpStatus = filters.mpStatus; + } + + let subscription = await this.planSuscriptionList.findOne(searchCriteria).exec(); if (!subscription) { return null; @@ -77,17 +93,7 @@ export class PlanSuscriptionsAdapterMongoose implements IPlanSuscriptionsAdapter await subscription.save(); return subscription; } - // Without subscriptions, we wait for the webhook to update the status. - // If it's expired and not updated by webhook, we just cancel it. - if (subscription.mpStatus === MP_SUBS_STATUS.PENDING) { - subscription.mpPreferenceId = ""; - subscription.isActive = false; - subscription.autoRenew = false; - subscription.mpStatus = MP_SUBS_STATUS.CANCELLED; - subscription.mpInitPoint = ""; - await subscription.save(); - return subscription; - } + // Pending Checkout Preferences do not consume paid time; webhook decides the final status. } } } catch (e) { diff --git a/server/src/Models/PlanSubscriptions/PlanSubscriptions.interface.ts b/server/src/Models/PlanSubscriptions/PlanSubscriptions.interface.ts index 607db69..b117b3b 100644 --- a/server/src/Models/PlanSubscriptions/PlanSubscriptions.interface.ts +++ b/server/src/Models/PlanSubscriptions/PlanSubscriptions.interface.ts @@ -3,6 +3,8 @@ import { IPlanSuscriptionDocument } from "./PlanSubscriptions.Adapter.Mongoose"; export type FindPlanSuscripcionsParams = { sessionUser: string; + mpPreferenceId?: string; + mpStatus?: string; }; export type CreatePlanSuscriptionParams = { @@ -11,6 +13,7 @@ export type CreatePlanSuscriptionParams = { mpPlanId: string; startDate: Date; endDate: Date; + isActive?: boolean; autoRenew: boolean; mpPayerId?: number; mpPayerEmail?: string; @@ -18,6 +21,10 @@ export type CreatePlanSuscriptionParams = { mpDateCreated?: Date; mpInitPoint?: string; mpPreferenceId?: string; + billingMonths?: number; + pendingPaymentInitPoint?: string; + pendingPaymentPreferenceId?: string; + pendingPaymentBillingMonths?: number; }; export type CancellPlanSuscriptionParams = { @@ -88,6 +95,10 @@ export interface IPlanSuscription { mpDateCreated: Date; // Fecha de creación de la suscripción en MercadoPago. mpInitPoint: string; // URL de inicio de la suscripción en MercadoPago. mpPreferenceId: string; // ID de la preferencia en MercadoPago. + billingMonths?: number; // Cantidad de meses comprados para pagos únicos. + pendingPaymentInitPoint?: string; // URL de pago pendiente para extender el plan actual. + pendingPaymentPreferenceId?: string; // ID de preferencia pendiente para extender el plan actual. + pendingPaymentBillingMonths?: number; // Meses pendientes de acreditar al aprobarse la extensión. } export interface ISubscriptionInfo { diff --git a/server/src/Models/PlanSubscriptions/PlanSubscriptons.ts b/server/src/Models/PlanSubscriptions/PlanSubscriptons.ts index fc5474a..48a2ddf 100644 --- a/server/src/Models/PlanSubscriptions/PlanSubscriptons.ts +++ b/server/src/Models/PlanSubscriptions/PlanSubscriptons.ts @@ -42,6 +42,10 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager { this.planSuscriptions = new PlanSuscriptionsAdapterMongoose(); } + private shouldDowngradeExpiredSubscription(subscription: IPlanSuscriptionDocument): boolean { + return subscription.mpStatus === MP_SUBS_STATUS.AUTHORIZED || subscription.mpStatus === MP_SUBS_STATUS.CANCELLED; + } + public async getInitPoint( data: GetSubscriptionInitPointParams ): Promise { @@ -135,8 +139,9 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager { planId: data.planId, mpPlanId: data.planId, startDate: subscriptionStartDate.toDate(), - endDate: subscriptionStartDate.clone().add(data.months || 1, "months").toDate(), - autoRenew: true, + endDate: subscriptionStartDate.clone().add(10, "years").toDate(), + autoRenew: false, + mpStatus: "", mpInitPoint: "/landing/dashboard", }); } else if (plan.price > 0) { @@ -168,7 +173,10 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager { if (subscription) { const subscriptionStartDate = dayjs(new Date()); - subscription.endDate = subscriptionStartDate.clone().add(data.months || 1, "months").toDate(); + subscription.startDate = subscriptionStartDate.toDate(); + subscription.endDate = subscriptionStartDate.clone().add(10, "years").toDate(); + subscription.autoRenew = false; + subscription.mpStatus = ""; subscription.mpInitPoint = dashboardUrl || ""; await subscription.save(); } @@ -227,7 +235,7 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager { } try { - const subscriptionStartDate = dayjs(new Date()); + const preferenceDate = dayjs(new Date()); let discount = 0; if (data.months === 3) discount = plan.discount3Months || 0; @@ -237,6 +245,21 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager { const totalPrice = plan.price * data.months; const finalPrice = totalPrice - (totalPrice * (discount / 100)); + const currentSubscription = await this.planSuscriptions.findOne({ + sessionUser: data.sessionUser, + }); + + if ( + currentSubscription && + String(currentSubscription.planId) === String(data.planId) && + (currentSubscription.mpStatus === MP_SUBS_STATUS.AUTHORIZED || + currentSubscription.mpStatus === MP_SUBS_STATUS.CANCELLED) && + currentSubscription.pendingPaymentPreferenceId && + currentSubscription.pendingPaymentInitPoint + ) { + return currentSubscription; + } + const response = await axios.post( "https://api.mercadopago.com/checkout/preferences", { @@ -270,20 +293,48 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager { const subscriptionData = response.data; - this.planSuscriptions.delete({ sessionUser: data.sessionUser }); + if ( + currentSubscription && + String(currentSubscription.planId) === String(data.planId) && + (currentSubscription.mpStatus === MP_SUBS_STATUS.AUTHORIZED || + currentSubscription.mpStatus === MP_SUBS_STATUS.CANCELLED) + ) { + currentSubscription.pendingPaymentInitPoint = subscriptionData.init_point; + currentSubscription.pendingPaymentPreferenceId = subscriptionData.id; + currentSubscription.pendingPaymentBillingMonths = data.months; + currentSubscription.mpInitPoint = subscriptionData.init_point; + await currentSubscription.save(); + + await NotificationsManager.sendSystemNotification({ + userId: String(data.sessionUser), + subject: "Se ha creado una nueva extensión de suscripción", + message: `Se ha creado una nueva extensión para el plan ${plan.name}.`, + type: NotificationType.BILLING, + code: String(currentSubscription._id) + }); + + return currentSubscription; + } + + await this.planSuscriptions.delete({ sessionUser: data.sessionUser }); const subscription = await this.planSuscriptions.create({ userId: data.sessionUser, planId: data.planId, mpPlanId: data.planId, - startDate: subscriptionStartDate.toDate(), - endDate: subscriptionStartDate.clone().add(data.months, "months").toDate(), + startDate: preferenceDate.toDate(), + endDate: preferenceDate.toDate(), + isActive: false, autoRenew: false, mpPayerEmail: sessionUser.mpPayerEmail, mpStatus: MP_SUBS_STATUS.PENDING, mpDateCreated: new Date(), mpInitPoint: subscriptionData.init_point, mpPreferenceId: subscriptionData.id, + billingMonths: data.months, + pendingPaymentInitPoint: "", + pendingPaymentPreferenceId: "", + pendingPaymentBillingMonths: undefined, }); await NotificationsManager.sendSystemNotification({ @@ -404,8 +455,15 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager { subscription.planId = freePlan.id; subscription.mpStatus = ""; subscription.isActive = true; + subscription.autoRenew = false; subscription.mpInitPoint = ""; subscription.mpPreferenceId = ""; + subscription.billingMonths = undefined; + subscription.pendingPaymentInitPoint = ""; + subscription.pendingPaymentPreferenceId = ""; + subscription.pendingPaymentBillingMonths = undefined; + subscription.startDate = new Date(); + subscription.endDate = dayjs(new Date()).add(10, "years").toDate(); await subscription.save(); // console.log("plan gratuito guardado:", freePlan.id); @@ -449,12 +507,10 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager { const dateNow = dayjs(new Date()).endOf("day"); const dateEnd = dayjs(subscription.endDate); - if (dateNow.isSameOrAfter(dateEnd)) { - if (subscription.mpStatus === MP_SUBS_STATUS.CANCELLED) { - this.toFreePlan({ - sessionUser: data.sessionUser, - }); - } + if (dateNow.isSameOrAfter(dateEnd) && this.shouldDowngradeExpiredSubscription(subscription)) { + await this.toFreePlan({ + sessionUser: data.sessionUser, + }); } return await this.planSuscriptions.findOne(data); @@ -472,23 +528,24 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager { const dateNow = dayjs(new Date()).endOf("day"); const dateEnd = dayjs(subscription.endDate); - if (dateNow.isSameOrAfter(dateEnd)) { - //Esta condicion era la culpable de que todo el tiempo se queria aplicar el free plan. - //Ya que la fecha de finalizacion queda guardada en la base de datos a pesar de que ya no se use. - if ( - subscription.mpStatus !== MP_SUBS_STATUS.AUTHORIZED && - subscription.mpStatus !== "" - ) { - this.toFreePlan({ - sessionUser: data.sessionUser, - }); - } + if (dateNow.isSameOrAfter(dateEnd) && this.shouldDowngradeExpiredSubscription(subscription)) { + await this.toFreePlan({ + sessionUser: data.sessionUser, + }); + return this.getSubscriptionByUser(data); } - const plan = await PlansList.plans.findOne({ + let plan = await PlansList.findOne({ _id: subscription.planId, }); + if (!subscription.isActive || subscription.mpStatus === MP_SUBS_STATUS.PENDING) { + const freePlan = await PlansList.findOne({ price: 0 }); + if (freePlan) { + plan = freePlan; + } + } + if (!plan) { throw new Error("No se ha encontrado el plan"); } @@ -514,6 +571,10 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager { } const plan = subscriptionInfo.plan; + if (!subscriptionInfo.isActive) { + return false; + } + if (!plan) { throw new Error("No se encontro el plan de la suscripcion"); } @@ -553,7 +614,7 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager { if (!subscriptionInfo) { throw new Error("No se encontro una suscripcion activa"); } - return subscriptionInfo.isActive; + return subscriptionInfo.isActive && subscriptionInfo.mpStatus !== MP_SUBS_STATUS.PENDING; } } diff --git a/server/src/api/MercadoPago/MercadoPagoWebhook.Service.ts b/server/src/api/MercadoPago/MercadoPagoWebhook.Service.ts index df9c78f..6848593 100644 --- a/server/src/api/MercadoPago/MercadoPagoWebhook.Service.ts +++ b/server/src/api/MercadoPago/MercadoPagoWebhook.Service.ts @@ -2,6 +2,8 @@ import axios from "axios"; import PlanSubscriptionsList from "../../Models/PlanSubscriptions/PlanSubscriptons"; import { MP_SUBS_STATUS } from "../../Models/PlanSubscriptions/PlanSubscriptions.Adapter.Mongoose"; import { connect } from "mongoose"; +import dayjs from "dayjs"; +import PlanPaymentsList from "../../Models/PlanPayments/PlanPayments"; export class MercadoPagoWebhookService { public async handleWebhook(body: any): Promise { @@ -27,28 +29,75 @@ export class MercadoPagoWebhookService { const paymentData = paymentResponse.data; const status = paymentData.status; // approved, pending, rejected, etc. const userId = paymentData.external_reference; // We passed this when creating preference + const preferenceId = paymentData.preference_id || paymentData.order?.id; + + let subscription = await PlanSubscriptionsList.planSuscriptions.findOne({ + sessionUser: userId, + mpPreferenceId: preferenceId, + mpStatus: MP_SUBS_STATUS.PENDING, + }); + + if (!subscription && userId && preferenceId) { + subscription = await PlanSubscriptionsList.planSuscriptions.planSuscriptionList.findOne({ + userId, + pendingPaymentPreferenceId: preferenceId, + }).exec(); + } if (userId && status === "approved") { - // Update subscription status in DB - const subscription = await PlanSubscriptionsList.planSuscriptions.findOne({ - sessionUser: userId, - }); - if (subscription) { + const approvedAt = paymentData.date_approved ? new Date(paymentData.date_approved) : new Date(); + const billingMonths = subscription.pendingPaymentBillingMonths || subscription.billingMonths || 1; + const isExtensionPayment = subscription.pendingPaymentPreferenceId === preferenceId; + const periodStart = isExtensionPayment && dayjs(subscription.endDate).isAfter(approvedAt) + ? subscription.endDate + : approvedAt; + subscription.mpStatus = MP_SUBS_STATUS.AUTHORIZED; subscription.isActive = true; - // Assuming endDate is correctly set for the months purchased when preference was created + subscription.autoRenew = false; + subscription.startDate = isExtensionPayment ? subscription.startDate : approvedAt; + subscription.endDate = dayjs(periodStart).add(billingMonths, "months").toDate(); + subscription.mpPayerId = paymentData.payer?.id || subscription.mpPayerId; + subscription.mpPayerEmail = paymentData.payer?.email || subscription.mpPayerEmail; + subscription.pendingPaymentInitPoint = ""; + subscription.pendingPaymentPreferenceId = ""; + subscription.pendingPaymentBillingMonths = undefined; await subscription.save(); + + await PlanPaymentsList.create({ + userId, + subscriptionId: String(subscription._id), + amount: paymentData.transaction_amount || 0, + paymentDate: approvedAt, + paymentMethod: paymentData.payment_method_id || paymentData.payment_type_id || "mercadopago", + status: "completed", + transactionId: String(paymentData.id), + }); } } else if (userId && status === "rejected") { - const subscription = await PlanSubscriptionsList.planSuscriptions.findOne({ - sessionUser: userId, - }); + if (subscription) { + const isExtensionPayment = subscription.pendingPaymentPreferenceId === preferenceId; - if (subscription && subscription.mpStatus !== MP_SUBS_STATUS.AUTHORIZED) { - subscription.mpStatus = MP_SUBS_STATUS.CANCELLED; - subscription.isActive = false; + if (isExtensionPayment) { + subscription.pendingPaymentInitPoint = ""; + subscription.pendingPaymentPreferenceId = ""; + subscription.pendingPaymentBillingMonths = undefined; + } else { + subscription.mpStatus = MP_SUBS_STATUS.CANCELLED; + subscription.isActive = false; + } await subscription.save(); + + await PlanPaymentsList.create({ + userId, + subscriptionId: String(subscription._id), + amount: paymentData.transaction_amount || 0, + paymentDate: new Date(), + paymentMethod: paymentData.payment_method_id || paymentData.payment_type_id || "mercadopago", + status: "failed", + transactionId: String(paymentData.id), + }); } } } catch (error) { diff --git a/txclient/src/app/components/Button/Button.tsx b/txclient/src/app/components/Button/Button.tsx index 2867193..83eb7d0 100644 --- a/txclient/src/app/components/Button/Button.tsx +++ b/txclient/src/app/components/Button/Button.tsx @@ -12,6 +12,7 @@ interface ButtonProps { style?: React.CSSProperties; width?: ButtonWidth; color?: ButtonColor; + disabled?: boolean; onClick?: (e: React.MouseEvent) => void; } @@ -52,6 +53,7 @@ export default function Button(props: ButtonProps): React.ReactElement { id={props.name} value={props.text} onClick={props.onClick} + disabled={props.disabled} style={props.style} /> diff --git a/txclient/src/app/components/FinancialWidget/FinancialWidget.module.css b/txclient/src/app/components/FinancialWidget/FinancialWidget.module.css index 52e5639..501a628 100644 --- a/txclient/src/app/components/FinancialWidget/FinancialWidget.module.css +++ b/txclient/src/app/components/FinancialWidget/FinancialWidget.module.css @@ -18,6 +18,19 @@ gap: 15px; } +.headerButton { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + width: 100%; + padding: 0; + border: 0; + background: transparent; + cursor: pointer; + text-align: left; +} + .title { font-size: 20px; font-weight: 700; @@ -25,6 +38,22 @@ margin: 0; } +.toggleIcon { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 32px; + height: 32px; + border-radius: 999px; + background: var(--white); + color: var(--green-darkestX1); + border: 1px solid var(--green-darkestX1); + font-size: 22px; + font-weight: 800; + line-height: 1; +} + .filters { display: flex; gap: 10px; diff --git a/txclient/src/app/components/FinancialWidget/FinancialWidget.tsx b/txclient/src/app/components/FinancialWidget/FinancialWidget.tsx index d5a5460..382b118 100644 --- a/txclient/src/app/components/FinancialWidget/FinancialWidget.tsx +++ b/txclient/src/app/components/FinancialWidget/FinancialWidget.tsx @@ -23,6 +23,7 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati const [financials, setFinancials] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [isOpen, setIsOpen] = useState(true); useEffect(() => { if (selectedOrgId) { @@ -80,15 +81,18 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati if (error) { return (
-
+
-
-

Acceso Restringido

-

{error}

-
+ {isOpen ? "−" : "+"} + + {isOpen && ( +
+

Acceso Restringido

+

{error}

+
+ )}
); } @@ -103,11 +107,16 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati return (
-
+ + + {isOpen && ( + <> +
-
-
+
-
+
Ingresos del Periodo @@ -145,9 +153,9 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati {financials && ( )} -
+
-
+
{!loading && chartData.length > 0 ? ( )} -
+
+ + )}
); } diff --git a/txclient/src/app/components/Location/Location.module.css b/txclient/src/app/components/Location/Location.module.css index 97f7c77..33c931d 100644 --- a/txclient/src/app/components/Location/Location.module.css +++ b/txclient/src/app/components/Location/Location.module.css @@ -6,7 +6,15 @@ height: 70px; overflow: hidden; z-index: 1; + pointer-events: none; } + +.locationContainer a, +.locationContainer button, +.locationContainer [role="button"] { + pointer-events: auto; +} + .locationTitle { display: flex; align-items: center; diff --git a/txclient/src/app/components/PendingRepeatsCard/PendingRepeatsCard.tsx b/txclient/src/app/components/PendingRepeatsCard/PendingRepeatsCard.tsx index 2ab3b3e..45b6411 100644 --- a/txclient/src/app/components/PendingRepeatsCard/PendingRepeatsCard.tsx +++ b/txclient/src/app/components/PendingRepeatsCard/PendingRepeatsCard.tsx @@ -24,6 +24,7 @@ const PendingRepeatsCard: React.FC = ({ companyId, empl const eventHandler = useEventHandlerStore(); const [pendingRepeats, setPendingRepeats] = useState([]); const [filterByMe, setFilterByMe] = useState(false); + const [isOpen, setIsOpen] = useState(false); const loadRepeats = () => { if (!SessionInfo.userId) return; @@ -79,12 +80,19 @@ const PendingRepeatsCard: React.FC = ({ companyId, empl return ( - - - Turnos recurrentes pendientes de creación (Semana Actual) - - {(allowEmployeeFilter && companyId) && ( - + + {isOpen && ( + + {(allowEmployeeFilter && companyId) && ( + e.stopPropagation()}> Solo mis turnos @@ -95,14 +103,15 @@ const PendingRepeatsCard: React.FC = ({ companyId, empl size="small" sx={{ padding: 0 }} /> - - )} - - {pendingRepeats.length === 0 ? ( + + )} + + )} + {isOpen && pendingRepeats.length === 0 ? ( No hay turnos recurrentes pendientes. - ) : ( + ) : isOpen ? (
{pendingRepeats.map(repeat => ( @@ -127,7 +136,7 @@ const PendingRepeatsCard: React.FC = ({ companyId, empl ))}
- )} + ) : null}
); diff --git a/txclient/src/app/components/PendingRepeatsCard/style.module.css b/txclient/src/app/components/PendingRepeatsCard/style.module.css index 2e513d0..c2df639 100644 --- a/txclient/src/app/components/PendingRepeatsCard/style.module.css +++ b/txclient/src/app/components/PendingRepeatsCard/style.module.css @@ -7,12 +7,62 @@ border-radius: 8px; } +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + width: 100%; + padding: 0; + border: 0; + background: transparent; + cursor: pointer; + text-align: left; +} + +.headerTitleWrap { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + .title { color: var(--wine-red); - margin-bottom: 16px !important; + margin-bottom: 0 !important; font-weight: 600 !important; } +.counter { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 28px; + height: 28px; + padding: 0 9px; + border-radius: 999px; + background: var(--wine-red); + color: var(--white); + font-size: 13px; + font-weight: 900; +} + +.toggleIcon { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 32px; + height: 32px; + border-radius: 999px; + background: var(--white); + color: var(--wine-red); + border: 1px solid var(--wine-red); + font-size: 22px; + font-weight: 800; + line-height: 1; +} + .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); diff --git a/txclient/src/app/components/PlanMetricsWidget/PlanMetricsWidget.module.css b/txclient/src/app/components/PlanMetricsWidget/PlanMetricsWidget.module.css index aa4c7dc..2f0a10d 100644 --- a/txclient/src/app/components/PlanMetricsWidget/PlanMetricsWidget.module.css +++ b/txclient/src/app/components/PlanMetricsWidget/PlanMetricsWidget.module.css @@ -143,3 +143,27 @@ font-weight: 600; color: #374151; } + +.renewalNotice { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; + border: 1px solid rgba(185, 28, 28, 0.18); + border-radius: 14px; + background: linear-gradient(135deg, rgba(185, 28, 28, 0.08), rgba(245, 158, 11, 0.08)); +} + +.renewalTitle { + margin: 0 0 6px; + font-size: 15px; + font-weight: 800; + color: #7f1d1d; +} + +.renewalText { + margin: 0; + font-size: 13px; + line-height: 1.45; + color: #374151; +} diff --git a/txclient/src/app/components/PlanMetricsWidget/PlanMetricsWidget.tsx b/txclient/src/app/components/PlanMetricsWidget/PlanMetricsWidget.tsx index fbfbf20..ad65ad9 100644 --- a/txclient/src/app/components/PlanMetricsWidget/PlanMetricsWidget.tsx +++ b/txclient/src/app/components/PlanMetricsWidget/PlanMetricsWidget.tsx @@ -24,6 +24,7 @@ export interface PlanMetricsWidgetProps { }; onUpgrade: () => void; onRenew?: () => void; + onViewDetails?: () => void; onCancel: () => void; isOwner?: boolean; } @@ -88,17 +89,29 @@ const MetricRow = ({ export default function PlanMetricsWidget({ subscription, metrics, - onUpgrade, - onCancel, + onRenew, isOwner = true, }: PlanMetricsWidgetProps) { const isCancelled = subscription.mpStatus === MP_SUBS_STATUS.CANCELLED; + const isPaidPlan = subscription.plan.price > 0; + const isRenewableStatus = subscription.mpStatus === MP_SUBS_STATUS.AUTHORIZED || isCancelled; + const expiresSoon = Boolean( + subscription.endDate && + dayjs(subscription.endDate).isAfter(dayjs()) && + dayjs(subscription.endDate).isBefore(dayjs().add(1, "month").add(1, "day")) + ); + const showRenewalNotice = isOwner && isPaidPlan && isRenewableStatus && expiresSoon && onRenew; return (

Métricas del Plan {subscription.plan.name}

Uso actual de tu suscripción + {subscription.endDate && subscription.plan.price > 0 && ( + + Vigente hasta el {dayjs(subscription.endDate).format("DD/MM/YYYY")} + + )}
@@ -134,35 +147,24 @@ export default function PlanMetricsWidget({ />
- {isOwner && ( -
+ {showRenewalNotice && ( +
+
+

Tu plan está por vencer

+

+ El plan {subscription.plan.name} vence el {dayjs(subscription.endDate).format("DD/MM/YYYY")}. Podés extenderlo ahora y el nuevo período se suma al vencimiento actual. +

+
)} +
); } diff --git a/txclient/src/app/landing/current-plan/page.module.css b/txclient/src/app/landing/current-plan/page.module.css new file mode 100644 index 0000000..73ea6fb --- /dev/null +++ b/txclient/src/app/landing/current-plan/page.module.css @@ -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; + } +} diff --git a/txclient/src/app/landing/current-plan/page.tsx b/txclient/src/app/landing/current-plan/page.tsx new file mode 100644 index 0000000..a437eff --- /dev/null +++ b/txclient/src/app/landing/current-plan/page.tsx @@ -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([]); + const [selectedOrgId, setSelectedOrgId] = useState(""); + const [subscription, setSubscription] = useState(null); + const [metrics, setMetrics] = useState(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 ( + + + + }> + + +
+
+
+
+
+
+ Plan actual +

{plan?.name || "Cargando plan"}

+

Gestioná la vigencia, límites y beneficios de tu suscripción.

+ {subscription && ( + {getSubStatusText(subscription.mpStatus)} + )} +
+ {organizations.length > 1 && ( + + )} +
+
+ + {loading &&

Cargando detalle del plan...

} + {error && !loading &&

{error}

} + + {subscription && plan && !loading && ( + <> +
+
+ +
+
+ Titular del plan + {SessionInfo.userFullName || "Usuario sin nombre"} + {SessionInfo.userEmail || "Email no disponible"} +
+
+ +
+
+ Inicio + {formatDate(subscription.startDate)} + Fecha de alta +
+
+ Vencimiento + {formatDate(subscription.endDate)} + {isPaidPlan ? "Período vigente" : "Sin vencimiento pago"} + {isPaidPlan && ( + + )} +
+
+ Precio + {plan.price > 0 ? formatPrice(plan.price) : "Gratis"} + {isPaidPlan ? "Por período contratado" : "Plan inicial"} +
+
+ + {!isPaidPlan && ( +

Estás usando el plan gratis. No tiene vencimiento pago.

+ )} + {isPaidPlan && !subscription.endDate && ( +

Este plan pago no informa una fecha de vencimiento.

+ )} + +
+
+ Beneficios incluidos +

Características

+
+ {plan.features.length > 0 ? ( +
    + {plan.features.map((feature) =>
  • {feature}
  • )} +
+ ) : ( +

Este plan no tiene características cargadas.

+ )} +
+ +
+
+ Capacidad del plan +

Límites principales

+
+
+ {getLimitCards(plan, metrics).map((item) => ( +
+ {item.label} + {formatLimit(item.limit)} +
+ ))} +
+ {metrics && ( +
+
+ Uso actual + Actividad real de tu organización +
+
+
+ Clientes + {metrics.clientsCount} +
+
+ Turnos + {metrics.appointmentsCount} +
+
+ Colaboradores + {metrics.employeesCount} +
+
+
+ )} +
+ +
+
+ Gestión del plan +

¿Querés ajustar tu suscripción?

+

Extendé el período actual si está disponible o compará opciones para cambiar de plan.

+
+
+
+
+ {!isOwner &&

Solo el dueño de la organización puede extender el plan.

} + + )} +
+
+
+
+
+
+ ); +} diff --git a/txclient/src/app/landing/dashboard/page.tsx b/txclient/src/app/landing/dashboard/page.tsx index 078f9af..082144d 100644 --- a/txclient/src/app/landing/dashboard/page.tsx +++ b/txclient/src/app/landing/dashboard/page.tsx @@ -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 ( @@ -261,29 +265,42 @@ export default function DashboardPage() {

Bienvenido, {SessionInfo.userFullName}

- {subscription.plan.name} - - {getSubStatusText(subscription.mpStatus)} - + {displayedSubscription?.plan.name || "Cargando plan..."} + {displayedSubscription && ( + + {getSubStatusText(displayedSubscription.mpStatus)} + + )}
- {subscription.endDate && subscription.plan.price > 0 && ( +
+
+ {displayedSubscription?.endDate && displayedSubscription.plan.price > 0 && ( - Vigente hasta el {dayjs(subscription.endDate).format("DD/MM/YYYY")} + Vigente hasta el {dayjs(displayedSubscription.endDate).format("DD/MM/YYYY")} )} + {displayedSubscription && isCompanyOwner && ( +
{subscription.mpStatus === MP_SUBS_STATUS.PENDING && ( - + {plan?.price === 0 ? ( - ) : ( - )} @@ -227,3 +239,11 @@ export default function SubscriptionAddPage() { ); } + +export default function SubscriptionAddPage() { + return ( + }> + + + ); +}