feat: implement full-stack ratings system for appointments with user submission and pending ratings dashboard integration
This commit is contained in:
@@ -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[]>;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user