feat: implement full-stack ratings system for appointments with user submission and pending ratings dashboard integration

This commit is contained in:
2026-07-21 23:49:16 -03:00
parent b26c3e58d1
commit 383a5bc6b4
17 changed files with 1505 additions and 20 deletions
@@ -0,0 +1,63 @@
import { Document, Model, Schema, Types, model, models } from "mongoose";
import { FindRatingsParams, IRating, IRatingsAdapter, RATING_COMMENT_MAX_LENGTH, RatingTargetType } from "./Ratings.Interface";
export interface IRatingDocument extends Omit<IRating, "id">, Document {
_id: Types.ObjectId;
}
export class RatingsAdapterMongoose implements IRatingsAdapter {
schema: Schema;
ratingsList: Model<IRatingDocument>;
constructor() {
this.schema = new Schema(
{
appointmentId: { type: Schema.Types.ObjectId, required: true, ref: "Appointment" },
userId: { type: Schema.Types.ObjectId, required: true, ref: "User" },
companyId: { type: Schema.Types.ObjectId, required: true, ref: "Companie" },
serviceId: { type: Schema.Types.ObjectId, required: true, ref: "Service" },
employeeId: { type: Schema.Types.ObjectId, required: true, ref: "Employee" },
targetType: { type: String, required: true, enum: Object.values(RatingTargetType) },
targetId: { type: Schema.Types.ObjectId, required: true },
score: { type: Number, required: true, min: 1, max: 5 },
comment: { type: String, required: false, default: "", maxlength: RATING_COMMENT_MAX_LENGTH },
},
{ timestamps: true }
);
this.schema.index({ userId: 1, targetType: 1, targetId: 1 }, { unique: true });
this.ratingsList = (models.Rating as Model<IRatingDocument>) || model<IRatingDocument>("Rating", this.schema);
}
public async create(data: Omit<IRating, "id" | "createdAt" | "updatedAt">): Promise<IRating> {
const rating = await this.ratingsList.create(data);
return this.mapRating(rating);
}
public async find(filters: FindRatingsParams): Promise<IRating[]> {
const ratings = await this.ratingsList.find(filters).exec();
return ratings.map((rating) => this.mapRating(rating));
}
public async findOne(filters: FindRatingsParams): Promise<IRating | null> {
const rating = await this.ratingsList.findOne(filters).exec();
return rating ? this.mapRating(rating) : null;
}
private mapRating(rating: IRatingDocument): IRating {
return {
id: rating._id.toString(),
appointmentId: rating.appointmentId.toString(),
userId: rating.userId.toString(),
companyId: rating.companyId.toString(),
serviceId: rating.serviceId.toString(),
employeeId: rating.employeeId.toString(),
targetType: rating.targetType,
targetId: rating.targetId.toString(),
score: rating.score,
comment: rating.comment,
createdAt: rating.createdAt,
updatedAt: rating.updatedAt,
};
}
}
@@ -0,0 +1,70 @@
export enum RatingTargetType {
COMPANY = "company",
SERVICE = "service",
EMPLOYEE = "employee",
}
export const RATING_COMMENT_MAX_LENGTH = 1000;
export type FindRatingsParams = {
_id?: string;
appointmentId?: string;
userId?: string;
companyId?: string;
serviceId?: string;
employeeId?: string;
targetType?: RatingTargetType;
targetId?: string | { $in: string[] };
};
export type CreateRatingParams = {
appointmentId: string;
targetType: RatingTargetType;
targetId: string;
score: number;
comment?: string;
sessionUser: string;
};
export type PendingRatingsByUserParams = {
sessionUser: string;
};
export interface IRating {
id?: string;
appointmentId: string;
userId: string;
companyId: string;
serviceId: string;
employeeId: string;
targetType: RatingTargetType;
targetId: string;
score: number;
comment?: string;
createdAt: Date;
updatedAt: Date;
}
export interface PendingRatingItem {
appointmentId: string;
appointmentDate: string;
targetType: RatingTargetType;
targetId: string;
targetName: string;
companyId: string;
serviceId: string;
employeeId: string;
serviceName: string;
}
export interface IRatingsAdapter {
create(data: Omit<IRating, "id" | "createdAt" | "updatedAt">): Promise<IRating>;
find(filters: FindRatingsParams): Promise<IRating[]>;
findOne(filters: FindRatingsParams): Promise<IRating | null>;
}
export interface IRatingsManager {
ratings: IRatingsAdapter;
createRating(data: CreateRatingParams): Promise<IRating>;
pendingByUser(data: PendingRatingsByUserParams): Promise<PendingRatingItem[]>;
}
+237
View File
@@ -0,0 +1,237 @@
import dayjs from "dayjs";
import { Types } from "mongoose";
import { isNull } from "../../helpers/IsNull";
import { joinStrings } from "../../helpers/String";
import AppointmentList from "../Appointments/Appointments";
import CompaniesManager from "../Companies/Companies";
import EmployeesList from "../Employees/Employee";
import ServicesManager from "../Services/Service";
import UsersManager from "../Users/Users";
import { RatingsAdapterMongoose } from "./Ratings.Adapter.Mongoose";
import {
CreateRatingParams,
IRating,
IRatingsManager,
PendingRatingItem,
PendingRatingsByUserParams,
RATING_COMMENT_MAX_LENGTH,
RatingTargetType,
} from "./Ratings.Interface";
const PENDING_RATINGS_APPOINTMENT_LIMIT = 50;
class RatingsManager implements IRatingsManager {
ratings: RatingsAdapterMongoose;
constructor() {
this.ratings = new RatingsAdapterMongoose();
}
public async createRating(data: CreateRatingParams): Promise<IRating> {
await this.validateSessionUser(data.sessionUser);
this.validateScore(data.score);
this.validateTargetType(data.targetType);
const comment = isNull<string>(data.comment, "").trim();
this.validateComment(comment);
const appointment = await AppointmentList.Appointments.AppointmentList.findOne({
_id: data.appointmentId,
userId: data.sessionUser,
});
if (!appointment) {
throw new Error("No se encontró un turno válido para calificar.");
}
if (!appointment.present || !dayjs(appointment.dateDay).isBefore(dayjs().startOf("day"))) {
throw new Error("Solo puedes calificar turnos pasados a los que asististe.");
}
const expectedTargetId = this.getTargetId({
targetType: data.targetType,
companyId: String(appointment.companyId),
serviceId: String(appointment.serviceId),
employeeId: String(appointment.employeeId),
});
if (String(data.targetId) !== expectedTargetId) {
throw new Error("La calificación no corresponde al turno seleccionado.");
}
const existingRating = await this.ratings.findOne({
userId: data.sessionUser,
targetType: data.targetType,
targetId: data.targetId,
});
if (existingRating) {
throw new Error("Ya calificaste este item.");
}
try {
return await this.ratings.create({
appointmentId: data.appointmentId,
userId: data.sessionUser,
companyId: String(appointment.companyId),
serviceId: String(appointment.serviceId),
employeeId: String(appointment.employeeId),
targetType: data.targetType,
targetId: data.targetId,
score: data.score,
comment,
});
} catch (error) {
if (this.isDuplicateRatingError(error)) {
throw new Error("Ya calificaste este item.");
}
throw error;
}
}
public async pendingByUser(data: PendingRatingsByUserParams): Promise<PendingRatingItem[]> {
await this.validateSessionUser(data.sessionUser);
const appointments = await AppointmentList.Appointments.AppointmentList.find({
userId: data.sessionUser,
present: true,
dateDay: { $lt: dayjs().startOf("day").toDate() },
})
.sort({ start: -1 })
.limit(PENDING_RATINGS_APPOINTMENT_LIMIT);
const pendingItems: PendingRatingItem[] = [];
const seenTargets = new Set<string>();
const candidateTargetIds = Array.from(
new Set(
appointments.flatMap((appointment) => [
String(appointment.companyId),
String(appointment.serviceId),
String(appointment.employeeId),
])
)
);
const candidateTargetObjectIds = this.toObjectIds(candidateTargetIds);
const existingRatings = await this.ratings.find({
userId: data.sessionUser,
targetId: { $in: candidateTargetIds },
});
const ratedTargets = new Set(
existingRatings.map((rating) => `${rating.targetType}:${String(rating.targetId)}`)
);
const companies = await CompaniesManager.companies.find({ _id: { $in: candidateTargetObjectIds } } as any);
const services = await ServicesManager.services.find({ _id: { $in: candidateTargetObjectIds } } as any);
const employees = await EmployeesList.employees.find({ _id: { $in: candidateTargetObjectIds } } as any);
const employeeUserIds = Array.from(new Set(employees.map((employee) => String(employee.userId))));
const employeeUsers = await UsersManager.users.find({ _id: { $in: this.toObjectIds(employeeUserIds) } } as any);
const companiesById = new Map(companies.map((company) => [String(company.id), company]));
const servicesById = new Map(services.map((service) => [String(service.id), service]));
const employeesById = new Map(employees.map((employee) => [String(employee.id), employee]));
const employeeUsersById = new Map(employeeUsers.map((user) => [String(user.id), user]));
for (const appointment of appointments) {
const company = companiesById.get(String(appointment.companyId));
const service = servicesById.get(String(appointment.serviceId));
const employee = employeesById.get(String(appointment.employeeId));
if (!company || !service || !employee) {
continue;
}
const userEmployee = employeeUsersById.get(String(employee.userId));
const employeeName = userEmployee
? joinStrings([isNull<string>(userEmployee.firstName, ""), isNull<string>(userEmployee.lastName, "")], " ")
: "Profesional";
const candidates = [
{ targetType: RatingTargetType.COMPANY, targetId: String(appointment.companyId), targetName: company.name },
{ targetType: RatingTargetType.SERVICE, targetId: String(appointment.serviceId), targetName: service.name },
{ targetType: RatingTargetType.EMPLOYEE, targetId: String(appointment.employeeId), targetName: employeeName },
];
for (const candidate of candidates) {
const seenKey = `${candidate.targetType}:${candidate.targetId}`;
if (seenTargets.has(seenKey)) {
continue;
}
if (ratedTargets.has(seenKey)) {
seenTargets.add(seenKey);
continue;
}
pendingItems.push({
appointmentId: String(appointment.id),
appointmentDate: dayjs(appointment.start).toISOString(),
targetType: candidate.targetType,
targetId: candidate.targetId,
targetName: candidate.targetName,
companyId: String(appointment.companyId),
serviceId: String(appointment.serviceId),
employeeId: String(appointment.employeeId),
serviceName: service.name,
});
seenTargets.add(seenKey);
}
}
return pendingItems;
}
private async validateSessionUser(sessionUser: string): Promise<void> {
const user = await UsersManager.users.findOne({ _id: sessionUser });
if (!user) {
throw new Error("No se ha encontrado el usuario o no tiene permisos para realizar esta acción");
}
}
private toObjectIds(ids: string[]): Types.ObjectId[] {
return ids.filter((id) => Types.ObjectId.isValid(id)).map((id) => new Types.ObjectId(id));
}
private validateScore(score: number): void {
if (!Number.isInteger(score) || score < 1 || score > 5) {
throw new Error("La calificación debe estar entre 1 y 5.");
}
}
private validateTargetType(targetType: RatingTargetType): void {
if (!Object.values(RatingTargetType).includes(targetType)) {
throw new Error("El tipo de calificación no es válido.");
}
}
private validateComment(comment: string): void {
if (comment.length > RATING_COMMENT_MAX_LENGTH) {
throw new Error(`El comentario no puede superar los ${RATING_COMMENT_MAX_LENGTH} caracteres.`);
}
}
private isDuplicateRatingError(error: unknown): boolean {
return Boolean(error && typeof error === "object" && "code" in error && error.code === 11000);
}
private getTargetId(data: {
targetType: RatingTargetType;
companyId: string;
serviceId: string;
employeeId: string;
}): string {
if (data.targetType === RatingTargetType.COMPANY) {
return data.companyId;
}
if (data.targetType === RatingTargetType.SERVICE) {
return data.serviceId;
}
return data.employeeId;
}
}
const RatingsList = new RatingsManager();
export default RatingsList;
@@ -0,0 +1,47 @@
import { Body, Controller, Middlewares, Post, Response, Route, SuccessResponse } from "tsoa";
import { ApiVoidResult } from "../../Models/Api.VoidResult.type";
import { CreateRatingParams, PendingRatingItem, PendingRatingsByUserParams } from "../../Models/Ratings/Ratings.Interface";
import { ApiValidationError } from "../../Models/Server.Error.model";
import { authenticateMiddleware } from "../../middleware/authentication";
import { rateLimiter } from "../../middleware/ratelimiter";
import { RatingsService } from "./Ratings.Service";
@Route("ratings/create")
@Middlewares([rateLimiter, authenticateMiddleware])
export class CreateRatingController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Calificación creada con éxito")
@Post()
public async createRating(@Body() requestBody: CreateRatingParams): Promise<ApiVoidResult | ApiValidationError> {
try {
await new RatingsService().createRating(requestBody);
this.setStatus(200);
return { message: "Done", success: true };
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@Route("ratings/pending-by-user")
@Middlewares([authenticateMiddleware])
export class PendingRatingsByUserController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Busqueda completada con exito")
@Post()
public async pendingByUser(
@Body() requestBody: PendingRatingsByUserParams
): Promise<PendingRatingItem[] | ApiValidationError> {
try {
const pendingRatings = await new RatingsService().pendingByUser(requestBody);
this.setStatus(200);
return pendingRatings;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
+15
View File
@@ -0,0 +1,15 @@
import { connect } from "mongoose";
import RatingsList from "../../Models/Ratings/Ratings";
import { CreateRatingParams, IRating, PendingRatingItem, PendingRatingsByUserParams } from "../../Models/Ratings/Ratings.Interface";
export class RatingsService {
public async createRating(data: CreateRatingParams): Promise<IRating> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await RatingsList.createRating(data);
}
public async pendingByUser(data: PendingRatingsByUserParams): Promise<PendingRatingItem[]> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await RatingsList.pendingByUser(data);
}
}
+32
View File
@@ -0,0 +1,32 @@
export enum RatingTargetType {
COMPANY = "company",
SERVICE = "service",
EMPLOYEE = "employee",
}
export const RATING_COMMENT_MAX_LENGTH = 1000;
export type CreateRatingParams = {
appointmentId: string;
targetType: RatingTargetType;
targetId: string;
score: number;
comment?: string;
sessionUser: string;
};
export type PendingRatingsByUserParams = {
sessionUser: string;
};
export interface PendingRatingItem {
appointmentId: string;
appointmentDate: string;
targetType: RatingTargetType;
targetId: string;
targetName: string;
companyId: string;
serviceId: string;
employeeId: string;
serviceName: string;
}
+16 -2
View File
@@ -13,12 +13,26 @@ export class ApiValidationError implements IServerError {
}
}
export class ApiError extends ApiValidationError {
export class ApiError extends Error implements IServerError {
readonly code: number;
readonly desc: string;
constructor(code: number, desc: string) {
super(code, desc);
const message = desc || "No pudimos completar la operación.";
super(message);
this.name = "ApiError";
this.code = code;
this.desc = message;
}
format = (): string => {
return `Atención: ${this.desc}`;
};
toJSON = () => ({
name: this.name,
code: this.code,
desc: this.desc,
message: this.message,
});
}
+22 -18
View File
@@ -3,6 +3,26 @@ import { ApiError } from "@models/Server.Error.model";
import axios from "@config/axios.config";
class ApiService {
private normalizeError(error: unknown): ApiError {
if (error && typeof error === "object" && "response" in error) {
const response = (error as { response?: { data?: any; status?: number } }).response;
const data = response?.data;
if (data) {
const msg = data.desc || data.message || JSON.stringify(data.details || data) || "No pudimos completar la operación.";
return new ApiError(data.code || response?.status || 500, msg);
}
return new ApiError(response?.status || 500, "No pudimos comunicarnos con el servidor.");
}
if (error instanceof Error) {
return new ApiError(500, error.message);
}
return new ApiError(500, "No pudimos comunicarnos con el servidor.");
}
/**
* Sends a HTTP POST request to the specified endpoint with the provided data
* and returns a promise that resolves to the response data.
@@ -19,14 +39,7 @@ class ApiService {
resolve(response.data);
})
.catch((error) => {
try {
const data = error.response.data;
const msg = data.desc || data.message || JSON.stringify(data.details || data);
reject(new ApiError(data.code || error.response.status || 500, msg));
} catch {
const errorMessage = process.env.VITE_API_UNAVAILABLE as string;
reject(new ApiError(500, errorMessage));
}
reject(this.normalizeError(error));
});
});
}
@@ -44,16 +57,7 @@ class ApiService {
resolve(response.data);
})
.catch((error) => {
try {
console.error("Full API Error:", error.response?.data);
const data = error.response.data;
const msg = data.desc || data.message || JSON.stringify(data.details || data);
reject(new ApiError(data.code || error.response.status || 500, msg));
} catch (e) {
console.error(e);
const errorMessage = process.env.VITE_API_UNAVAILABLE as string;
reject(new ApiError(500, errorMessage));
}
reject(this.normalizeError(error));
});
});
}
+52
View File
@@ -0,0 +1,52 @@
import ApiRequest from "@services/Api.Service";
import { ApiVoidResult } from "@core/Models/Api.VoidResult.type";
import { ApiError } from "@core/Models/Server.Error.model";
import {
CreateRatingParams,
PendingRatingItem,
PendingRatingsByUserParams,
RATING_COMMENT_MAX_LENGTH,
RatingTargetType,
} from "@core/Models/Ratings.model";
import * as Yup from "yup";
const schemaPendingRatingsByUser = Yup.object().shape({
sessionUser: Yup.string().required("Para ver tus calificaciones pendientes debes iniciar sesión."),
});
const schemaCreateRating = Yup.object().shape({
appointmentId: Yup.string().required("No se ha proporcionado el turno."),
targetType: Yup.string()
.oneOf(Object.values(RatingTargetType), "El tipo de calificación no es válido.")
.required("No se ha proporcionado el tipo de calificación."),
targetId: Yup.string().required("No se ha proporcionado el item a calificar."),
score: Yup.number().integer().min(1).max(5).required("Selecciona una calificación."),
comment: Yup.string().max(RATING_COMMENT_MAX_LENGTH, `El comentario no puede superar los ${RATING_COMMENT_MAX_LENGTH} caracteres.`),
sessionUser: Yup.string().required("Para calificar debes iniciar sesión."),
});
export const pendingRatingsByUser = async (data: PendingRatingsByUserParams): Promise<PendingRatingItem[]> => {
return new Promise<PendingRatingItem[]>((resolve, reject) => {
schemaPendingRatingsByUser
.validate(data, { abortEarly: true })
.then(() => {
resolve(ApiRequest.post<PendingRatingItem[]>("ratings/pending-by-user", data));
})
.catch((error) => {
reject(new ApiError(500, error.message));
});
});
};
export const createRating = async (data: CreateRatingParams): Promise<ApiVoidResult> => {
return new Promise<ApiVoidResult>((resolve, reject) => {
schemaCreateRating
.validate(data, { abortEarly: true })
.then(() => {
resolve(ApiRequest.post<ApiVoidResult>("ratings/create", data));
})
.catch((error) => {
reject(new ApiError(500, error.message));
});
});
};
@@ -0,0 +1,121 @@
.bannerContainer {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.5rem 2rem;
background: linear-gradient(135deg, var(--white) 0%, var(--white-dark) 100%);
border-radius: 20px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
border: 1px solid var(--wine-lighterX2);
margin: 2rem auto;
width: 100%;
max-width: 1000px;
gap: 1.5rem;
}
.popupContainer {
position: fixed;
bottom: 2rem;
right: 2rem;
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 1.5rem;
background: var(--white);
border-radius: 20px;
box-shadow: 0 10px 40px rgba(51, 0, 128, 0.15);
border: 1px solid var(--wine-lighterX2);
width: 100%;
max-width: 350px;
z-index: 1000;
gap: 1rem;
}
.closeButton {
position: absolute;
top: 10px;
right: 10px;
background: none;
border: none;
color: var(--gray-dark);
cursor: pointer;
padding: 5px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background 0.2s;
}
.closeButton:hover {
background: var(--white-dark);
color: var(--black);
}
.contentWrapper {
display: flex;
flex-direction: column;
gap: 0.45rem;
padding-right: 1.5rem;
}
.eyebrow {
color: var(--wine-red);
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.title {
font-size: 1.1rem;
font-weight: 700;
color: var(--wine-superdark);
margin: 0;
}
.description {
font-size: 0.95rem;
color: var(--gray-dark);
margin: 0;
line-height: 1.4;
}
.ctaButton {
display: inline-block;
padding: 10px 24px;
background: linear-gradient(135deg, var(--wine-red) 0%, var(--wine-dark) 100%);
color: var(--white);
border-radius: 12px;
font-weight: 700;
text-decoration: none;
font-size: 0.95rem;
transition: transform 0.2s ease, box-shadow 0.2s ease;
box-shadow: 0 4px 15px rgba(255, 42, 127, 0.3);
text-align: center;
white-space: nowrap;
}
.ctaButton:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(255, 42, 127, 0.4);
color: var(--white);
text-decoration: none;
}
@media (max-width: 768px) {
.bannerContainer {
flex-direction: column;
align-items: stretch;
text-align: left;
padding: 1.5rem;
}
.popupContainer {
bottom: 1rem;
right: 1rem;
left: 1rem;
max-width: calc(100% - 2rem);
}
}
@@ -0,0 +1,115 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { AnimatePresence, motion } from "framer-motion";
import { useSessionStore } from "@core/Store/Sesion.Store";
import { pendingRatingsByUser } from "@services/Ratings.Service";
import style from "./PendingRatingsBanner.module.css";
interface PendingRatingsBannerProps {
variant?: "banner" | "popup";
delay?: number;
className?: string;
}
export default function PendingRatingsBanner({
variant = "banner",
delay = 3000,
className = "",
}: PendingRatingsBannerProps) {
const SessionInfo = useSessionStore();
const [hasPendingRatings, setHasPendingRatings] = useState(false);
const [isVisible, setIsVisible] = useState(variant === "banner");
useEffect(() => {
let cancelled = false;
if (!SessionInfo.userId) {
setHasPendingRatings(false);
return;
}
pendingRatingsByUser({ sessionUser: SessionInfo.userId })
.then((ratings) => {
if (!cancelled) {
setHasPendingRatings(ratings.length > 0);
}
})
.catch((error) => {
if (!cancelled) {
setHasPendingRatings(false);
}
console.error(error);
});
return () => {
cancelled = true;
};
}, [SessionInfo.userId]);
useEffect(() => {
if (!hasPendingRatings) {
setIsVisible(false);
return;
}
if (variant === "banner") {
setIsVisible(true);
return;
}
const timer = setTimeout(() => {
setIsVisible(true);
}, delay);
return () => clearTimeout(timer);
}, [variant, delay, hasPendingRatings]);
if (!SessionInfo.userId || !hasPendingRatings) {
return null;
}
const containerClassName = [variant === "banner" ? style.bannerContainer : style.popupContainer, className]
.filter(Boolean)
.join(" ");
const content = (
<>
<button onClick={() => setIsVisible(false)} className={style.closeButton} aria-label="Cerrar">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
<div className={style.contentWrapper}>
<span className={style.eyebrow}>Calificaciones pendientes</span>
<h3 className={style.title}>Tenés experiencias pendientes para calificar</h3>
<p className={style.description}>Tu opinión ayuda a mejorar las recomendaciones.</p>
</div>
<Link href="/landing/pending-ratings" className={style.ctaButton}>
Calificar ahora
</Link>
</>
);
if (variant === "banner") {
return isVisible ? <div className={containerClassName}>{content}</div> : null;
}
return (
<AnimatePresence>
{isVisible && (
<motion.div
className={containerClassName}
initial={{ opacity: 0, y: 50, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 50, scale: 0.95 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
>
{content}
</motion.div>
)}
</AnimatePresence>
);
}
@@ -44,6 +44,7 @@ import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome";
import FinancialWidget from "@components/FinancialWidget/FinancialWidget";
import PlanMetricsWidget from "@components/PlanMetricsWidget/PlanMetricsWidget";
import PendingRepeatsCard from "@core/app/components/PendingRepeatsCard/PendingRepeatsCard";
import PendingRatingsBanner from "@components/PendingRatingsBanner/PendingRatingsBanner";
function DashboardContent() {
const { goTo } = useNavigation();
@@ -367,6 +368,7 @@ function DashboardContent() {
{SessionInfo.userId != "" && !displayedPlan && (
<div className="homeCentered">No tiene una suscripción activa.</div>
)}
{displayedPlan && <PendingRatingsBanner variant="banner" />}
{displayedPlan && (
<div className={style.dashboardGrid}>
@@ -18,6 +18,7 @@ import { ScrollToTop } from "@core/app/components/ScrollTop";
import PendingRepeatsCard from "@core/app/components/PendingRepeatsCard/PendingRepeatsCard";
import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider";
import EvangelizeBanner from "@components/EvangelizeBanner/EvangelizeBanner";
import PendingRatingsBanner from "@components/PendingRatingsBanner/PendingRatingsBanner";
import { Button, Tabs, Tab, Box } from "@mui/material";
import { ThemeProvider } from "@emotion/react";
import turnosXpressTheme from "@core/app/theme/turnosXpress";
@@ -295,6 +296,8 @@ export default function MyAppointmentsPage() {
<EvangelizeBanner />
<PendingRatingsBanner />
<Box sx={{ width: "100%", mt: 4 }}>
{collaboratorAppointments.length > 0 ? (
<>
@@ -0,0 +1,456 @@
.pageShell {
--ratings-purple: #330080;
--ratings-purple-soft: #6c34c2;
--ratings-orange: #ff7a2f;
--ratings-pink: #ff2a7f;
--ratings-ink: #24143d;
--ratings-muted: #695f76;
--ratings-line: rgba(97, 54, 148, 0.16);
--ratings-surface: rgba(255, 255, 255, 0.86);
box-sizing: border-box;
width: 100%;
max-width: 1120px;
margin: 0 auto;
padding: 2.5rem 1.25rem 3.5rem;
}
.hero {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
gap: 1.5rem;
overflow: hidden;
margin: 0 auto 1.25rem;
padding: 3rem;
border: 1px solid rgba(255, 255, 255, 0.72);
border-radius: 34px;
background:
radial-gradient(circle at 84% 6%, rgba(255, 122, 47, 0.3) 0, rgba(255, 122, 47, 0) 30%),
radial-gradient(circle at 8% 18%, rgba(255, 42, 127, 0.18) 0, rgba(255, 42, 127, 0) 32%),
linear-gradient(135deg, rgba(255, 255, 255, 0.96) 0%, rgba(253, 246, 255, 0.94) 46%, rgba(255, 244, 235, 0.96) 100%);
box-shadow: 0 24px 70px rgba(51, 0, 128, 0.16);
}
.hero::before {
content: "";
position: absolute;
inset: 1px;
pointer-events: none;
border-radius: 33px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.76), rgba(255, 255, 255, 0));
}
.heroContent,
.heroPanel,
.heroStats {
position: relative;
z-index: 1;
}
.hero h1 {
max-width: 710px;
margin: 0.45rem 0 0.85rem;
color: var(--ratings-ink);
font-size: clamp(2.45rem, 5.8vw, 4.7rem);
letter-spacing: -0.055em;
line-height: 0.96;
}
.hero p {
max-width: 640px;
margin: 0;
color: var(--ratings-muted);
font-size: 1.1rem;
line-height: 1.6;
}
.heroPanel {
align-self: stretch;
display: flex;
min-height: 230px;
flex-direction: column;
justify-content: flex-end;
padding: 1.35rem;
border: 1px solid rgba(255, 255, 255, 0.72);
border-radius: 26px;
background:
radial-gradient(circle at top right, rgba(255, 255, 255, 0.34), transparent 38%),
linear-gradient(145deg, var(--ratings-purple) 0%, var(--ratings-purple-soft) 56%, var(--ratings-pink) 100%);
color: var(--white);
box-shadow: 0 18px 40px rgba(51, 0, 128, 0.24);
}
.panelLabel {
align-self: flex-start;
padding: 0.42rem 0.7rem;
border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: 999px;
background: rgba(255, 255, 255, 0.16);
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.heroPanel strong {
margin-top: auto;
font-size: 4.25rem;
line-height: 0.95;
}
.heroPanel span:last-child {
color: rgba(255, 255, 255, 0.84);
font-weight: 700;
}
.heroStats {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.8rem;
}
.heroStats div {
padding: 1rem 1.1rem;
border: 1px solid rgba(255, 255, 255, 0.7);
border-radius: 20px;
background: rgba(255, 255, 255, 0.68);
backdrop-filter: blur(14px);
}
.heroStats strong {
display: block;
color: var(--ratings-purple);
font-size: 1.45rem;
line-height: 1;
}
.heroStats span {
display: block;
margin-top: 0.35rem;
color: var(--ratings-muted);
font-size: 0.82rem;
font-weight: 800;
letter-spacing: 0.035em;
text-transform: uppercase;
}
.contentCard {
padding: 1.6rem;
border: 1px solid rgba(255, 255, 255, 0.78);
border-radius: 30px;
background: rgba(255, 255, 255, 0.72);
box-shadow: 0 18px 55px rgba(51, 0, 128, 0.1);
backdrop-filter: blur(18px);
}
.sectionHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.35rem;
padding: 0.35rem 0.25rem 0;
}
.sectionHeader h2 {
margin: 0.25rem 0 0;
color: var(--ratings-ink);
font-size: 1.65rem;
letter-spacing: -0.025em;
}
.sectionHeader p {
max-width: 680px;
margin: 0.35rem 0 0;
color: var(--ratings-muted);
line-height: 1.45;
}
.eyebrow,
.targetLabel {
color: var(--ratings-pink);
font-size: 0.73rem;
font-weight: 900;
letter-spacing: 0.075em;
text-transform: uppercase;
}
.countPill,
.datePill {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
font-weight: 800;
white-space: nowrap;
}
.countPill {
padding: 0.58rem 0.9rem;
background: linear-gradient(135deg, rgba(255, 122, 47, 0.16), rgba(255, 42, 127, 0.14));
color: var(--ratings-purple);
font-size: 0.85rem;
}
.datePill {
min-width: 78px;
padding: 0.46rem 0.75rem;
border: 1px solid var(--ratings-line);
background: rgba(255, 255, 255, 0.74);
color: var(--ratings-purple);
font-size: 0.82rem;
}
.ratingList {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1.15rem;
}
.ratingCard {
position: relative;
display: flex;
min-height: 100%;
flex-direction: column;
gap: 1rem;
overflow: hidden;
padding: 1.25rem;
border: 1px solid var(--ratings-line);
border-radius: 26px;
background:
linear-gradient(var(--ratings-surface), var(--ratings-surface)) padding-box,
linear-gradient(140deg, rgba(255, 122, 47, 0.42), rgba(255, 42, 127, 0.2), rgba(51, 0, 128, 0.18)) border-box;
box-shadow: 0 18px 44px rgba(51, 0, 128, 0.1);
}
.ratingCard::after {
content: "";
position: absolute;
top: -80px;
right: -70px;
width: 180px;
height: 180px;
border-radius: 999px;
background: radial-gradient(circle, rgba(255, 122, 47, 0.18), transparent 68%);
pointer-events: none;
}
.cardTop {
position: relative;
z-index: 1;
display: flex;
justify-content: space-between;
gap: 1rem;
}
.cardIdentity {
display: flex;
min-width: 0;
align-items: center;
gap: 0.85rem;
}
.targetIcon,
.stateIcon {
display: grid;
place-items: center;
flex: 0 0 auto;
border-radius: 18px;
background: linear-gradient(135deg, var(--ratings-orange) 0%, var(--ratings-pink) 48%, var(--ratings-purple) 100%);
color: var(--white);
font-weight: 900;
box-shadow: 0 10px 24px rgba(255, 42, 127, 0.24);
}
.targetIcon {
width: 52px;
height: 52px;
font-size: 0.82rem;
letter-spacing: 0.04em;
}
.cardTop h3 {
margin: 0.18rem 0 0;
color: var(--ratings-ink);
font-size: 1.26rem;
line-height: 1.12;
letter-spacing: -0.02em;
}
.contextBox {
position: relative;
z-index: 1;
display: grid;
gap: 0.18rem;
padding: 0.9rem 1rem;
border: 1px solid rgba(51, 0, 128, 0.08);
border-radius: 18px;
background: rgba(255, 255, 255, 0.7);
}
.contextBox span,
.contextBox small {
color: var(--ratings-muted);
font-size: 0.82rem;
}
.contextBox strong {
color: var(--ratings-ink);
font-size: 1rem;
}
.ratingControl {
position: relative;
z-index: 1;
display: grid;
justify-items: center;
gap: 0.55rem;
padding: 1.15rem 1rem;
border: 1px solid rgba(255, 122, 47, 0.18);
border-radius: 22px;
background:
radial-gradient(circle at 50% 0%, rgba(255, 122, 47, 0.18), transparent 46%),
linear-gradient(135deg, rgba(255, 255, 255, 0.88), rgba(255, 247, 241, 0.78));
}
.ratingControl span {
color: var(--ratings-ink);
font-weight: 900;
}
.ratingControl :global(.MuiRating-root) {
font-size: 2.45rem;
color: #ffb23f;
filter: drop-shadow(0 6px 10px rgba(255, 122, 47, 0.18));
}
.ratingControl small {
color: var(--ratings-muted);
font-size: 0.82rem;
font-weight: 700;
}
.ratingCard :global(.MuiOutlinedInput-root) {
border-radius: 18px;
background: rgba(255, 255, 255, 0.76);
}
.ratingCard :global(.MuiFormHelperText-root) {
text-align: right;
}
.actions {
display: flex;
justify-content: flex-end;
margin-top: auto;
}
.actions button {
border-radius: 14px;
box-shadow: 0 10px 22px rgba(255, 42, 127, 0.22);
}
.stateCard {
display: flex;
min-height: 310px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.8rem;
padding: 2.5rem;
text-align: center;
border: 1px dashed rgba(97, 54, 148, 0.28);
border-radius: 26px;
background:
radial-gradient(circle at 50% 0%, rgba(255, 122, 47, 0.16), transparent 34%),
linear-gradient(135deg, rgba(255, 255, 255, 0.86), rgba(253, 246, 255, 0.82));
}
.stateIcon {
width: 58px;
height: 58px;
border-radius: 20px;
}
.stateCard h3 {
margin: 0;
color: var(--ratings-ink);
font-size: 1.35rem;
letter-spacing: -0.02em;
}
.stateCard p {
max-width: 460px;
margin: 0;
color: var(--ratings-muted);
line-height: 1.5;
}
@media (max-width: 900px) {
.hero,
.ratingList {
grid-template-columns: 1fr;
}
.heroPanel {
min-height: 170px;
}
}
@media (max-width: 768px) {
.pageShell {
padding: 5.75rem 0.9rem 2.25rem;
}
.hero,
.contentCard {
padding: 1.15rem;
border-radius: 26px;
}
.hero h1 {
font-size: clamp(2.25rem, 12vw, 3.25rem);
}
.heroStats {
grid-template-columns: 1fr;
}
.sectionHeader,
.cardTop {
flex-direction: column;
align-items: stretch;
}
.cardIdentity {
align-items: flex-start;
}
.datePill {
width: fit-content;
}
.ratingCard {
padding: 1rem;
border-radius: 22px;
}
.ratingControl :global(.MuiRating-root) {
font-size: 2.25rem;
}
.actions {
justify-content: stretch;
}
.actions button {
width: 100%;
}
}
@media (max-width: 420px) {
.ratingControl :global(.MuiRating-root) {
font-size: 2rem;
}
}
@@ -0,0 +1,250 @@
"use client";
import AnimatedContainer from "@components/AnimatedConainer/AnimatedContainer";
import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider";
import { ScrollToTop } from "@core/app/components/ScrollTop";
import { useNavigation } from "@core/app/hooks/goto";
import turnosXpressTheme from "@core/app/theme/turnosXpress";
import { useAlert } from "@core/Store/Alert.Store";
import { EVENT_TYPES, useEventHandlerStore } from "@core/Store/EventHandler.Store";
import { useSessionStore } from "@core/Store/Sesion.Store";
import { ThemeProvider } from "@emotion/react";
import { Box, Button, Rating, TextField } from "@mui/material";
import { useEffect, useState } from "react";
import dayjs from "dayjs";
import "dayjs/locale/es";
import { createRating, pendingRatingsByUser } from "@services/Ratings.Service";
import { PendingRatingItem, RATING_COMMENT_MAX_LENGTH, RatingTargetType } from "@core/Models/Ratings.model";
import styles from "./page.module.css";
dayjs.locale("es");
const targetTypeLabel = (targetType: RatingTargetType) => {
if (targetType === RatingTargetType.COMPANY) return "organización";
if (targetType === RatingTargetType.SERVICE) return "servicio";
return "profesional";
};
const targetTypeIcon = (targetType: RatingTargetType) => {
if (targetType === RatingTargetType.COMPANY) return "TX";
if (targetType === RatingTargetType.SERVICE) return "SV";
return "PR";
};
export default function PendingRatingsPage() {
const [pendingRatings, setPendingRatings] = useState<PendingRatingItem[]>([]);
const [scores, setScores] = useState<Record<string, number>>({});
const [comments, setComments] = useState<Record<string, string>>({});
const [submittingKey, setSubmittingKey] = useState("");
const [isLoading, setIsLoading] = useState(true);
const SessionInfo = useSessionStore();
const eventHandler = useEventHandlerStore();
const alert = useAlert();
const { goTo } = useNavigation();
const loadPendingRatings = () => {
if (!SessionInfo.userId) {
setPendingRatings([]);
setIsLoading(false);
return;
}
setIsLoading(true);
eventHandler.setEventType(EVENT_TYPES.LOADING);
pendingRatingsByUser({ sessionUser: SessionInfo.userId })
.then((res) => setPendingRatings(res))
.catch((error) => {
alert.showError(error.message || "No pudimos cargar tus calificaciones pendientes.");
console.error("Failed to load pending ratings", {
message: error?.message,
code: error?.code,
desc: error?.desc,
});
})
.finally(() => {
setIsLoading(false);
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
};
useEffect(() => {
loadPendingRatings();
}, [SessionInfo.userId]);
const submitRating = (item: PendingRatingItem) => {
const itemKey = `${item.targetType}:${item.targetId}`;
const score = scores[itemKey];
if (!score) {
alert.showError("Selecciona una puntuación para continuar.");
return;
}
setSubmittingKey(itemKey);
createRating({
appointmentId: item.appointmentId,
targetType: item.targetType,
targetId: item.targetId,
score,
comment: comments[itemKey] || "",
sessionUser: SessionInfo.userId,
})
.then(() => {
alert.showSuccess("Gracias por compartir tu calificación.");
setScores((prev) => {
const next = { ...prev };
delete next[itemKey];
return next;
});
setComments((prev) => {
const next = { ...prev };
delete next[itemKey];
return next;
});
loadPendingRatings();
})
.catch((error) => alert.showError(error.message))
.finally(() => setSubmittingKey(""));
};
return (
<AnimatedContainer
color="#aeaeae"
minSize={150}
maxSize={500}
items={10}
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
}}
>
<ScrollToTop />
<HeaderConfProvider />
<main className={styles.pageShell}>
<section className={styles.hero}>
<div className={styles.heroContent}>
<span className={styles.eyebrow}>Calificaciones pendientes</span>
<h1>Contanos cómo fue tu experiencia</h1>
<p>
Tus comentarios ayudan a mejorar las recomendaciones y a que otras personas elijan con más
confianza.
</p>
</div>
<div className={styles.heroPanel}>
<span className={styles.panelLabel}>Tu aporte</span>
<strong>{isLoading ? "..." : pendingRatings.length}</strong>
<span>{pendingRatings.length === 1 ? "experiencia pendiente" : "experiencias pendientes"}</span>
</div>
<div className={styles.heroStats} aria-label="Resumen de calificaciones pendientes">
<div>
<strong>{isLoading ? "..." : pendingRatings.length}</strong>
<span>Pendientes</span>
</div>
<div>
<strong>2 min</strong>
<span>Promedio</span>
</div>
</div>
</section>
<section className={styles.contentCard}>
<div className={styles.sectionHeader}>
<div>
<span className={styles.eyebrow}>Experiencias por calificar</span>
<h2>Turnos recientes</h2>
<p>Elegí una puntuación, sumá un comentario si querés y enviá cada experiencia por separado.</p>
</div>
{!isLoading && pendingRatings.length > 0 && (
<span className={styles.countPill}>{pendingRatings.length} pendientes</span>
)}
</div>
{isLoading ? (
<div className={styles.stateCard}>
<div className={styles.stateIcon}>...</div>
<h3>Cargando tus experiencias...</h3>
<p>Estamos buscando las calificaciones que tenés pendientes.</p>
</div>
) : pendingRatings.length === 0 ? (
<div className={styles.stateCard}>
<div className={styles.stateIcon}>OK</div>
<h3>No tenés calificaciones pendientes</h3>
<p>Cuando completes un turno, vas a poder compartir tu opinión desde acá.</p>
<ThemeProvider theme={turnosXpressTheme}>
<Button variant="contained" color="primary" onClick={() => goTo("/landing/my-appointments")}>
Volver a Mis Turnos
</Button>
</ThemeProvider>
</div>
) : (
<div className={styles.ratingList}>
{pendingRatings.map((item) => {
const itemKey = `${item.targetType}:${item.targetId}`;
return (
<article key={itemKey} className={styles.ratingCard}>
<div className={styles.cardTop}>
<div className={styles.cardIdentity}>
<div className={styles.targetIcon}>{targetTypeIcon(item.targetType)}</div>
<div>
<span className={styles.targetLabel}>{targetTypeLabel(item.targetType)}</span>
<h3>{item.targetName}</h3>
</div>
</div>
<span className={styles.datePill}>{dayjs(item.appointmentDate).format("D MMM")}</span>
</div>
<div className={styles.contextBox}>
<span>Turno realizado</span>
<strong>{item.serviceName}</strong>
<small>{dayjs(item.appointmentDate).format("dddd D [de] MMMM [de] YYYY")}</small>
</div>
<Box className={styles.ratingControl}>
<span>Tu calificación</span>
<Rating
name={`rating-${itemKey}`}
value={scores[itemKey] || 0}
onChange={(_, value) =>
setScores((prev) => ({ ...prev, [itemKey]: value || 0 }))
}
size="large"
/>
<small>{scores[itemKey] ? `${scores[itemKey]} de 5` : "Tocá las estrellas"}</small>
</Box>
<TextField
label="Comentario opcional"
value={comments[itemKey] || ""}
onChange={(event) =>
setComments((prev) => ({ ...prev, [itemKey]: event.target.value }))
}
multiline
minRows={2}
size="small"
fullWidth
inputProps={{ maxLength: RATING_COMMENT_MAX_LENGTH }}
helperText={`${(comments[itemKey] || "").length}/${RATING_COMMENT_MAX_LENGTH}`}
/>
<div className={styles.actions}>
<ThemeProvider theme={turnosXpressTheme}>
<Button
variant="contained"
color="primary"
disabled={submittingKey === itemKey}
onClick={() => submitRating(item)}
>
{submittingKey === itemKey ? "Enviando..." : "Enviar calificación"}
</Button>
</ThemeProvider>
</div>
</article>
);
})}
</div>
)}
</section>
</main>
</AnimatedContainer>
);
}
@@ -23,6 +23,7 @@ import { createAppointment, getAvailableDates } from "./Appointment.Service";
import { useAlert } from "@core/Store/Alert.Store";
import { useNavigation } from "@core/app/hooks/goto";
import MaterialAlert from "@mui/material/Alert";
import PendingRatingsBanner from "@components/PendingRatingsBanner/PendingRatingsBanner";
export interface AppointmentProps {
companyId: string;
@@ -200,6 +201,7 @@ export default function Appointment(props: AppointmentProps) {
)}
{SessionInfo.loged && (
<div style={{ marginLeft: "30px", marginRight: "30px", textAlign: "left" }}>
<PendingRatingsBanner />
<TextObjectFilter
placeholder="Seleccionar Profesional:"
content={employeeFilterContent}
+2
View File
@@ -6,6 +6,7 @@ import { HomeView } from "@core/Models/Views.model";
import Index from "./components/Home/Index/Index";
import { SystemNotificationsProvider } from "./components/SystemNotificationsProvider/SystemNotificationsProvider";
import EvangelizeBanner from "./components/EvangelizeBanner/EvangelizeBanner";
import PendingRatingsBanner from "./components/PendingRatingsBanner/PendingRatingsBanner";
export const dynamic = "force-dynamic"; // Fuerza la página a ser dinámica
@@ -29,6 +30,7 @@ export default async function Home() {
<Index homeData={homeData} />
<UserMenu />
<Alert />
<PendingRatingsBanner variant="popup" delay={2500} />
<EvangelizeBanner variant="popup" delay={4000} />
</div>
);