feat: plan management
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Document, Model, Schema, model } from "mongoose";
|
||||
import {
|
||||
CreatePlanPaymentParams,
|
||||
FindPlanPaymentsParams,
|
||||
IPlanPayment,
|
||||
IPlanPaymentsAdapter,
|
||||
} from "./PlanPayments.interface";
|
||||
|
||||
export interface IPlanPaymentDocument extends Omit<IPlanPayment, "id">, Document {}
|
||||
|
||||
export class PlanPaymentsAdapterMongoose implements IPlanPaymentsAdapter {
|
||||
schema: Schema;
|
||||
planPaymentList: Model<IPlanPaymentDocument>;
|
||||
|
||||
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<IPlanPaymentDocument>("PlanPayment", this.schema);
|
||||
}
|
||||
|
||||
public async find(filters: FindPlanPaymentsParams): Promise<IPlanPayment[]> {
|
||||
return this.planPaymentList.find(filters).exec();
|
||||
}
|
||||
|
||||
public async create(data: CreatePlanPaymentParams): Promise<IPlanPayment> {
|
||||
return this.planPaymentList.create(data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
export type FindPlanPaymentsParams = {
|
||||
_id?: string;
|
||||
userId?: string;
|
||||
subscriptionId?: string;
|
||||
transactionId?: string;
|
||||
};
|
||||
|
||||
export type CreatePlanPaymentParams = Omit<IPlanPayment, "id">;
|
||||
|
||||
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<IPlanPayment[]>;
|
||||
create(data: CreatePlanPaymentParams): Promise<IPlanPayment>;
|
||||
}
|
||||
|
||||
export interface IPlanPaymentsManager {
|
||||
planPayments: IPlanPaymentsAdapter;
|
||||
find(data: FindPlanPaymentsParams): Promise<IPlanPayment[]>;
|
||||
create(data: CreatePlanPaymentParams): Promise<IPlanPayment>;
|
||||
}
|
||||
|
||||
@@ -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<IPlanPayment[]> {
|
||||
return this.planPayments.find(data);
|
||||
}
|
||||
|
||||
public async create(data: CreatePlanPaymentParams): Promise<IPlanPayment> {
|
||||
return this.planPayments.create(data);
|
||||
}
|
||||
}
|
||||
|
||||
const PlanPaymentsList = new PlanPaymentsManager();
|
||||
|
||||
export default PlanPaymentsList;
|
||||
|
||||
@@ -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<IPlanSuscriptionDocument>("PlanSuscription", this.schema);
|
||||
@@ -54,9 +58,21 @@ export class PlanSuscriptionsAdapterMongoose implements IPlanSuscriptionsAdapter
|
||||
public async findOne(
|
||||
filters: FindPlanSuscripcionsParams
|
||||
): Promise<IPlanSuscriptionDocument | null> {
|
||||
let subscription = await this.planSuscriptionList
|
||||
.findOne({ userId: filters.sessionUser })
|
||||
.exec();
|
||||
const searchCriteria: Record<string, unknown> = {};
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<GetSuscriptionInitPointResponse> {
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
@@ -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) {
|
||||
|
||||
@@ -12,6 +12,7 @@ interface ButtonProps {
|
||||
style?: React.CSSProperties;
|
||||
width?: ButtonWidth;
|
||||
color?: ButtonColor;
|
||||
disabled?: boolean;
|
||||
onClick?: (e: React.MouseEvent<HTMLInputElement>) => 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}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -23,6 +23,7 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati
|
||||
const [financials, setFinancials] = useState<DashboardFinancialsResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedOrgId) {
|
||||
@@ -80,15 +81,18 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<button className={styles.headerButton} type="button" onClick={() => setIsOpen((value) => !value)}>
|
||||
<h3 className={styles.title}>
|
||||
Rendimiento Financiero {organizationName ? `- ${organizationName}` : ''}
|
||||
</h3>
|
||||
</div>
|
||||
<div style={{ padding: '40px 20px', textAlign: 'center', color: '#6b7280' }}>
|
||||
<p style={{ fontWeight: '600', marginBottom: '8px', color: '#374151' }}>Acceso Restringido</p>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
<span className={styles.toggleIcon}>{isOpen ? "−" : "+"}</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div style={{ padding: '40px 20px', textAlign: 'center', color: '#6b7280' }}>
|
||||
<p style={{ fontWeight: '600', marginBottom: '8px', color: '#374151' }}>Acceso Restringido</p>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -103,11 +107,16 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<button className={styles.headerButton} type="button" onClick={() => setIsOpen((value) => !value)}>
|
||||
<h3 className={styles.title}>
|
||||
Rendimiento Financiero {organizationName ? `- ${organizationName}` : ''}
|
||||
</h3>
|
||||
<div className={styles.filters}>
|
||||
<span className={styles.toggleIcon}>{isOpen ? "−" : "+"}</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<>
|
||||
<div className={styles.filters}>
|
||||
<select
|
||||
className={styles.select}
|
||||
value={selectedTimeframe}
|
||||
@@ -128,10 +137,9 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati
|
||||
<option key={c.id} value={c.id}>{c.fullName}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.metricsRow}>
|
||||
<div className={styles.metricsRow}>
|
||||
<div className={styles.metricBox}>
|
||||
<span className={styles.metricLabel}>Ingresos del Periodo</span>
|
||||
<span className={styles.metricValue}>
|
||||
@@ -145,9 +153,9 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati
|
||||
{financials && (
|
||||
<UnpaidIncomeCard value={financials.unpaidToday} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.chartContainer}>
|
||||
<div className={styles.chartContainer}>
|
||||
{!loading && chartData.length > 0 ? (
|
||||
<LineChart
|
||||
height={350}
|
||||
@@ -210,7 +218,9 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati
|
||||
{loading ? 'Cargando datos...' : 'No hay datos para este periodo'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -24,6 +24,7 @@ const PendingRepeatsCard: React.FC<PendingRepeatsCardProps> = ({ companyId, empl
|
||||
const eventHandler = useEventHandlerStore();
|
||||
const [pendingRepeats, setPendingRepeats] = useState<PendingRepeatView[]>([]);
|
||||
const [filterByMe, setFilterByMe] = useState<boolean>(false);
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
|
||||
const loadRepeats = () => {
|
||||
if (!SessionInfo.userId) return;
|
||||
@@ -79,12 +80,19 @@ const PendingRepeatsCard: React.FC<PendingRepeatsCardProps> = ({ companyId, empl
|
||||
return (
|
||||
<ThemeProvider theme={turnosXpressTheme}>
|
||||
<Box className={style.container}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6" className={style.title} sx={{ mb: 0 }}>
|
||||
Turnos recurrentes pendientes de creación (Semana Actual)
|
||||
</Typography>
|
||||
{(allowEmployeeFilter && companyId) && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<button className={style.header} type="button" onClick={() => setIsOpen((value) => !value)}>
|
||||
<div className={style.headerTitleWrap}>
|
||||
<Typography variant="h6" className={style.title} sx={{ mb: 0 }}>
|
||||
Turnos recurrentes pendientes de creación (Semana Actual)
|
||||
</Typography>
|
||||
<span className={style.counter}>{pendingRepeats.length}</span>
|
||||
</div>
|
||||
<span className={style.toggleIcon}>{isOpen ? "−" : "+"}</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', mt: 1, mb: 2 }}>
|
||||
{(allowEmployeeFilter && companyId) && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }} onClick={(e) => e.stopPropagation()}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
Solo mis turnos
|
||||
</Typography>
|
||||
@@ -95,14 +103,15 @@ const PendingRepeatsCard: React.FC<PendingRepeatsCardProps> = ({ companyId, empl
|
||||
size="small"
|
||||
sx={{ padding: 0 }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{pendingRepeats.length === 0 ? (
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{isOpen && pendingRepeats.length === 0 ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', p: 2 }}>
|
||||
No hay turnos recurrentes pendientes.
|
||||
</Typography>
|
||||
) : (
|
||||
) : isOpen ? (
|
||||
<div className={style.grid}>
|
||||
{pendingRepeats.map(repeat => (
|
||||
<Card key={repeat.id} className={style.card}>
|
||||
@@ -127,7 +136,7 @@ const PendingRepeatsCard: React.FC<PendingRepeatsCardProps> = ({ companyId, empl
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</Box>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className={styles.widgetContainer}>
|
||||
<div className={styles.widgetHeader}>
|
||||
<h3 className={styles.title}>Métricas del Plan {subscription.plan.name}</h3>
|
||||
<span className={styles.subtitle}>Uso actual de tu suscripción</span>
|
||||
{subscription.endDate && subscription.plan.price > 0 && (
|
||||
<span className={styles.subtitle}>
|
||||
Vigente hasta el {dayjs(subscription.endDate).format("DD/MM/YYYY")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.metricsList}>
|
||||
@@ -134,35 +147,24 @@ export default function PlanMetricsWidget({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isOwner && (
|
||||
<div className={styles.actionsContainer}>
|
||||
{showRenewalNotice && (
|
||||
<div className={styles.renewalNotice}>
|
||||
<div>
|
||||
<p className={styles.renewalTitle}>Tu plan está por vencer</p>
|
||||
<p className={styles.renewalText}>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
color="primary"
|
||||
text="Mejorar mi plan"
|
||||
text="Extender plan"
|
||||
width="custom"
|
||||
style={{ width: "100%", padding: "12px 0", borderRadius: "8px", fontWeight: "600" }}
|
||||
onClick={onUpgrade}
|
||||
onClick={onRenew}
|
||||
/>
|
||||
<div className={styles.statusFooter}>
|
||||
{isCancelled ? (
|
||||
<div className={styles.cancelledInfo}>
|
||||
<p className={styles.cancelledText}>La suscripción ha sido cancelada</p>
|
||||
<p className={styles.validUntilText}>
|
||||
Vigente hasta el {dayjs(subscription.endDate).format("DD/MM/YYYY")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
color="link"
|
||||
text="Cancelar subscripción"
|
||||
onClick={onCancel}
|
||||
width="custom"
|
||||
style={{ margin: 0, padding: 0, fontSize: "13px", color: "#6b7280", minHeight: "auto" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user