From 383a5bc6b45d22559210aab5e1f723910d79b112 Mon Sep 17 00:00:00 2001 From: Horacio Daniel Ros Date: Tue, 21 Jul 2026 23:49:16 -0300 Subject: [PATCH] feat: implement full-stack ratings system for appointments with user submission and pending ratings dashboard integration --- .../Ratings/Ratings.Adapter.Mongoose.ts | 63 +++ .../src/Models/Ratings/Ratings.Interface.ts | 70 +++ server/src/Models/Ratings/Ratings.ts | 237 +++++++++ server/src/api/Ratings/Ratings.Controller.ts | 47 ++ server/src/api/Ratings/Ratings.Service.ts | 15 + txclient/src/Models/Ratings.model.ts | 32 ++ txclient/src/Models/Server.Error.model.ts | 18 +- txclient/src/Services/Api.Service.ts | 40 +- txclient/src/Services/Ratings.Service.ts | 52 ++ .../PendingRatingsBanner.module.css | 121 +++++ .../PendingRatingsBanner.tsx | 115 +++++ txclient/src/app/landing/dashboard/page.tsx | 2 + .../src/app/landing/my-appointments/page.tsx | 3 + .../landing/pending-ratings/page.module.css | 456 ++++++++++++++++++ .../src/app/landing/pending-ratings/page.tsx | 250 ++++++++++ .../components/Appointment/Appointment.tsx | 2 + txclient/src/app/page.tsx | 2 + 17 files changed, 1505 insertions(+), 20 deletions(-) create mode 100644 server/src/Models/Ratings/Ratings.Adapter.Mongoose.ts create mode 100644 server/src/Models/Ratings/Ratings.Interface.ts create mode 100644 server/src/Models/Ratings/Ratings.ts create mode 100644 server/src/api/Ratings/Ratings.Controller.ts create mode 100644 server/src/api/Ratings/Ratings.Service.ts create mode 100644 txclient/src/Models/Ratings.model.ts create mode 100644 txclient/src/Services/Ratings.Service.ts create mode 100644 txclient/src/app/components/PendingRatingsBanner/PendingRatingsBanner.module.css create mode 100644 txclient/src/app/components/PendingRatingsBanner/PendingRatingsBanner.tsx create mode 100644 txclient/src/app/landing/pending-ratings/page.module.css create mode 100644 txclient/src/app/landing/pending-ratings/page.tsx diff --git a/server/src/Models/Ratings/Ratings.Adapter.Mongoose.ts b/server/src/Models/Ratings/Ratings.Adapter.Mongoose.ts new file mode 100644 index 0000000..84eebb0 --- /dev/null +++ b/server/src/Models/Ratings/Ratings.Adapter.Mongoose.ts @@ -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, Document { + _id: Types.ObjectId; +} + +export class RatingsAdapterMongoose implements IRatingsAdapter { + schema: Schema; + ratingsList: Model; + + 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) || model("Rating", this.schema); + } + + public async create(data: Omit): Promise { + const rating = await this.ratingsList.create(data); + return this.mapRating(rating); + } + + public async find(filters: FindRatingsParams): Promise { + const ratings = await this.ratingsList.find(filters).exec(); + return ratings.map((rating) => this.mapRating(rating)); + } + + public async findOne(filters: FindRatingsParams): Promise { + 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, + }; + } +} diff --git a/server/src/Models/Ratings/Ratings.Interface.ts b/server/src/Models/Ratings/Ratings.Interface.ts new file mode 100644 index 0000000..2cf3414 --- /dev/null +++ b/server/src/Models/Ratings/Ratings.Interface.ts @@ -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): Promise; + find(filters: FindRatingsParams): Promise; + findOne(filters: FindRatingsParams): Promise; +} + +export interface IRatingsManager { + ratings: IRatingsAdapter; + createRating(data: CreateRatingParams): Promise; + pendingByUser(data: PendingRatingsByUserParams): Promise; +} diff --git a/server/src/Models/Ratings/Ratings.ts b/server/src/Models/Ratings/Ratings.ts new file mode 100644 index 0000000..f8cb55d --- /dev/null +++ b/server/src/Models/Ratings/Ratings.ts @@ -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 { + await this.validateSessionUser(data.sessionUser); + this.validateScore(data.score); + this.validateTargetType(data.targetType); + const comment = isNull(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 { + 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(); + 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(userEmployee.firstName, ""), isNull(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 { + 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; diff --git a/server/src/api/Ratings/Ratings.Controller.ts b/server/src/api/Ratings/Ratings.Controller.ts new file mode 100644 index 0000000..1c41310 --- /dev/null +++ b/server/src/api/Ratings/Ratings.Controller.ts @@ -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(500, "Ha ocurrido un error") + @SuccessResponse(200, "Calificación creada con éxito") + @Post() + public async createRating(@Body() requestBody: CreateRatingParams): Promise { + 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(500, "Ha ocurrido un error") + @SuccessResponse(200, "Busqueda completada con exito") + @Post() + public async pendingByUser( + @Body() requestBody: PendingRatingsByUserParams + ): Promise { + 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); + } + } +} diff --git a/server/src/api/Ratings/Ratings.Service.ts b/server/src/api/Ratings/Ratings.Service.ts new file mode 100644 index 0000000..b894a9f --- /dev/null +++ b/server/src/api/Ratings/Ratings.Service.ts @@ -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 { + await connect(`${process.env.DATABASE_CONNECTION}`); + return await RatingsList.createRating(data); + } + + public async pendingByUser(data: PendingRatingsByUserParams): Promise { + await connect(`${process.env.DATABASE_CONNECTION}`); + return await RatingsList.pendingByUser(data); + } +} diff --git a/txclient/src/Models/Ratings.model.ts b/txclient/src/Models/Ratings.model.ts new file mode 100644 index 0000000..e700e2e --- /dev/null +++ b/txclient/src/Models/Ratings.model.ts @@ -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; +} diff --git a/txclient/src/Models/Server.Error.model.ts b/txclient/src/Models/Server.Error.model.ts index c495155..40e76f3 100644 --- a/txclient/src/Models/Server.Error.model.ts +++ b/txclient/src/Models/Server.Error.model.ts @@ -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, + }); } diff --git a/txclient/src/Services/Api.Service.ts b/txclient/src/Services/Api.Service.ts index 233fb5e..a793392 100644 --- a/txclient/src/Services/Api.Service.ts +++ b/txclient/src/Services/Api.Service.ts @@ -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)); }); }); } diff --git a/txclient/src/Services/Ratings.Service.ts b/txclient/src/Services/Ratings.Service.ts new file mode 100644 index 0000000..ed67316 --- /dev/null +++ b/txclient/src/Services/Ratings.Service.ts @@ -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 => { + return new Promise((resolve, reject) => { + schemaPendingRatingsByUser + .validate(data, { abortEarly: true }) + .then(() => { + resolve(ApiRequest.post("ratings/pending-by-user", data)); + }) + .catch((error) => { + reject(new ApiError(500, error.message)); + }); + }); +}; + +export const createRating = async (data: CreateRatingParams): Promise => { + return new Promise((resolve, reject) => { + schemaCreateRating + .validate(data, { abortEarly: true }) + .then(() => { + resolve(ApiRequest.post("ratings/create", data)); + }) + .catch((error) => { + reject(new ApiError(500, error.message)); + }); + }); +}; diff --git a/txclient/src/app/components/PendingRatingsBanner/PendingRatingsBanner.module.css b/txclient/src/app/components/PendingRatingsBanner/PendingRatingsBanner.module.css new file mode 100644 index 0000000..39032ee --- /dev/null +++ b/txclient/src/app/components/PendingRatingsBanner/PendingRatingsBanner.module.css @@ -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); + } +} diff --git a/txclient/src/app/components/PendingRatingsBanner/PendingRatingsBanner.tsx b/txclient/src/app/components/PendingRatingsBanner/PendingRatingsBanner.tsx new file mode 100644 index 0000000..22c3cdf --- /dev/null +++ b/txclient/src/app/components/PendingRatingsBanner/PendingRatingsBanner.tsx @@ -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 = ( + <> + +
+ Calificaciones pendientes +

Tenés experiencias pendientes para calificar

+

Tu opinión ayuda a mejorar las recomendaciones.

+
+ + Calificar ahora + + + ); + + if (variant === "banner") { + return isVisible ?
{content}
: null; + } + + return ( + + {isVisible && ( + + {content} + + )} + + ); +} diff --git a/txclient/src/app/landing/dashboard/page.tsx b/txclient/src/app/landing/dashboard/page.tsx index 14ec65a..a244e4f 100644 --- a/txclient/src/app/landing/dashboard/page.tsx +++ b/txclient/src/app/landing/dashboard/page.tsx @@ -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 && (
No tiene una suscripción activa.
)} + {displayedPlan && } {displayedPlan && (
diff --git a/txclient/src/app/landing/my-appointments/page.tsx b/txclient/src/app/landing/my-appointments/page.tsx index 27ab580..777dbc1 100644 --- a/txclient/src/app/landing/my-appointments/page.tsx +++ b/txclient/src/app/landing/my-appointments/page.tsx @@ -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() { + + {collaboratorAppointments.length > 0 ? ( <> diff --git a/txclient/src/app/landing/pending-ratings/page.module.css b/txclient/src/app/landing/pending-ratings/page.module.css new file mode 100644 index 0000000..0b3b87c --- /dev/null +++ b/txclient/src/app/landing/pending-ratings/page.module.css @@ -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; + } +} diff --git a/txclient/src/app/landing/pending-ratings/page.tsx b/txclient/src/app/landing/pending-ratings/page.tsx new file mode 100644 index 0000000..7859bde --- /dev/null +++ b/txclient/src/app/landing/pending-ratings/page.tsx @@ -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([]); + const [scores, setScores] = useState>({}); + const [comments, setComments] = useState>({}); + 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 ( + + + +
+
+
+ Calificaciones pendientes +

Contanos cómo fue tu experiencia

+

+ Tus comentarios ayudan a mejorar las recomendaciones y a que otras personas elijan con más + confianza. +

+
+ +
+ Tu aporte + {isLoading ? "..." : pendingRatings.length} + {pendingRatings.length === 1 ? "experiencia pendiente" : "experiencias pendientes"} +
+ +
+
+ {isLoading ? "..." : pendingRatings.length} + Pendientes +
+
+ 2 min + Promedio +
+
+
+ +
+
+
+ Experiencias por calificar +

Turnos recientes

+

Elegí una puntuación, sumá un comentario si querés y enviá cada experiencia por separado.

+
+ {!isLoading && pendingRatings.length > 0 && ( + {pendingRatings.length} pendientes + )} +
+ + {isLoading ? ( +
+
...
+

Cargando tus experiencias...

+

Estamos buscando las calificaciones que tenés pendientes.

+
+ ) : pendingRatings.length === 0 ? ( +
+
OK
+

No tenés calificaciones pendientes

+

Cuando completes un turno, vas a poder compartir tu opinión desde acá.

+ + + +
+ ) : ( +
+ {pendingRatings.map((item) => { + const itemKey = `${item.targetType}:${item.targetId}`; + return ( +
+
+
+
{targetTypeIcon(item.targetType)}
+
+ {targetTypeLabel(item.targetType)} +

{item.targetName}

+
+
+ {dayjs(item.appointmentDate).format("D MMM")} +
+
+ Turno realizado + {item.serviceName} + {dayjs(item.appointmentDate).format("dddd D [de] MMMM [de] YYYY")} +
+ + Tu calificación + + setScores((prev) => ({ ...prev, [itemKey]: value || 0 })) + } + size="large" + /> + {scores[itemKey] ? `${scores[itemKey]} de 5` : "Tocá las estrellas"} + + + 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}`} + /> +
+ + + +
+
+ ); + })} +
+ )} +
+
+
+ ); +} diff --git a/txclient/src/app/landing/service/[sid]/components/Appointment/Appointment.tsx b/txclient/src/app/landing/service/[sid]/components/Appointment/Appointment.tsx index 57bdd27..3ac8e33 100644 --- a/txclient/src/app/landing/service/[sid]/components/Appointment/Appointment.tsx +++ b/txclient/src/app/landing/service/[sid]/components/Appointment/Appointment.tsx @@ -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 && (
+ +
);