From da3c3abb52a7180edc03e6ecac58617a575aba8c Mon Sep 17 00:00:00 2001 From: Horacio Daniel Ros Date: Wed, 22 Jul 2026 12:13:50 -0300 Subject: [PATCH] feat: implement rating summary service methods and integrate rating display components into the organization header and pending ratings page --- .../Ratings/Ratings.Adapter.Mongoose.ts | 116 +++++++++++++- .../src/Models/Ratings/Ratings.Interface.ts | 45 ++++++ server/src/Models/Ratings/Ratings.ts | 54 +++++++ server/src/api/Ratings/Ratings.Controller.ts | 99 +++++++++++- server/src/api/Ratings/Ratings.Service.ts | 34 +++- server/src/middleware/ratelimiter.ts | 34 ++++ txclient/src/Models/Ratings.model.ts | 37 +++++ txclient/src/Services/Ratings.Service.ts | 80 ++++++++++ .../org/profile/[id]/services/page.tsx | 2 + .../OrganizationHeader.module.css | 95 ++++++++++- .../OrganizationHeader/OrganizationHeader.tsx | 42 +++++ .../components/ProductItem/ProductItem.tsx | 49 +++--- .../components/ProductItem/style.module.css | 48 ++++++ .../ReviewsCarousel.module.css | 120 ++++++++++++++ .../ReviewsCarousel/ReviewsCarousel.tsx | 130 ++++++++++++++++ .../SelectableServiceItem.module.css | 17 ++ .../SelectableServiceItem.tsx | 6 +- .../ServiceItem/ServiceItem.module.css | 1 + .../components/ServiceItem/ServiceItem.tsx | 3 + .../ServiceRatingSummary.module.css | 82 ++++++++++ .../ServiceRatingSummary.tsx | 79 ++++++++++ .../components/UserMenu/UserMenu.module.css | 21 +++ .../src/app/components/UserMenu/UserMenu.tsx | 30 ++++ txclient/src/app/landing/org/[oid]/page.tsx | 147 ++++++++++-------- .../app/landing/org/[oid]/profile.module.css | 41 ++++- .../src/app/landing/pending-ratings/page.tsx | 18 ++- .../src/app/landing/service/[sid]/page.tsx | 8 + 27 files changed, 1334 insertions(+), 104 deletions(-) create mode 100644 txclient/src/app/components/ReviewsCarousel/ReviewsCarousel.module.css create mode 100644 txclient/src/app/components/ReviewsCarousel/ReviewsCarousel.tsx create mode 100644 txclient/src/app/components/ServiceRatingSummary/ServiceRatingSummary.module.css create mode 100644 txclient/src/app/components/ServiceRatingSummary/ServiceRatingSummary.tsx diff --git a/server/src/Models/Ratings/Ratings.Adapter.Mongoose.ts b/server/src/Models/Ratings/Ratings.Adapter.Mongoose.ts index 84eebb0..a8ed983 100644 --- a/server/src/Models/Ratings/Ratings.Adapter.Mongoose.ts +++ b/server/src/Models/Ratings/Ratings.Adapter.Mongoose.ts @@ -1,10 +1,33 @@ import { Document, Model, Schema, Types, model, models } from "mongoose"; -import { FindRatingsParams, IRating, IRatingsAdapter, RATING_COMMENT_MAX_LENGTH, RatingTargetType } from "./Ratings.Interface"; +import getAvatar from "../../helpers/getAvatar"; +import { joinStrings } from "../../helpers/String"; +import { + FindRatingsParams, + IRating, + IRatingsAdapter, + RATING_COMMENT_MAX_LENGTH, + RatingTargetSummaryParams, + RatingTargetSummaryResult, + RatingTargetReviewItem, + RatingTargetReviewsParams, + RatingTargetType, +} from "./Ratings.Interface"; export interface IRatingDocument extends Omit, Document { _id: Types.ObjectId; } +type RatingReviewDocument = IRatingDocument & { + userId?: { + _id: Types.ObjectId; + firstName?: string; + lastName?: string; + avatar?: string; + }; +}; + +const GENERIC_REVIEW_AUTHOR = "Cliente verificado"; + export class RatingsAdapterMongoose implements IRatingsAdapter { schema: Schema; ratingsList: Model; @@ -44,6 +67,97 @@ export class RatingsAdapterMongoose implements IRatingsAdapter { return rating ? this.mapRating(rating) : null; } + public async count(filters: FindRatingsParams): Promise { + return this.ratingsList.countDocuments(filters).exec(); + } + + public async summaryByTarget(filters: RatingTargetSummaryParams): Promise { + const [summary] = await this.ratingsList + .aggregate<{ averageScore: number; totalCount: number }>([ + { + $match: { + targetType: filters.targetType, + targetId: new Types.ObjectId(filters.targetId), + }, + }, + { + $group: { + _id: null, + averageScore: { $avg: "$score" }, + totalCount: { $sum: 1 }, + }, + }, + ]) + .exec(); + + return { + averageScore: summary?.averageScore || 0, + totalCount: summary?.totalCount || 0, + }; + } + + public async summaryByTargets(filters: { targetType: RatingTargetType; targetIds: string[] }): Promise { + const targetIds = filters.targetIds.filter((targetId) => Types.ObjectId.isValid(targetId)); + + if (targetIds.length === 0) { + return { averageScore: 0, totalCount: 0 }; + } + + const [summary] = await this.ratingsList + .aggregate<{ averageScore: number; totalCount: number }>([ + { + $match: { + targetType: filters.targetType, + targetId: { $in: targetIds.map((targetId) => new Types.ObjectId(targetId)) }, + }, + }, + { + $group: { + _id: null, + averageScore: { $avg: "$score" }, + totalCount: { $sum: 1 }, + }, + }, + ]) + .exec(); + + return { + averageScore: summary?.averageScore || 0, + totalCount: summary?.totalCount || 0, + }; + } + + public async reviewsByTarget(filters: RatingTargetReviewsParams): Promise { + const ratings = await this.ratingsList + .find({ + targetType: filters.targetType, + targetId: new Types.ObjectId(filters.targetId), + comment: { $regex: /\S/ }, + }) + .sort({ createdAt: -1 }) + .limit(filters.limit || 20) + .select("score comment createdAt userId") + .populate("userId", "firstName lastName avatar") + .exec(); + + return (ratings as unknown as RatingReviewDocument[]).map((rating) => { + const user = rating.userId; + const displayName = user ? joinStrings([user.firstName, user.lastName], " ") : ""; + const authorName = displayName || GENERIC_REVIEW_AUTHOR; + + return { + id: rating._id.toString(), + score: rating.score, + comment: rating.comment || "", + createdAt: rating.createdAt, + author: { + displayName: authorName, + avatarUrl: user ? getAvatar(user._id.toString(), user.avatar, "") : "", + }, + }; + }); + } + private mapRating(rating: IRatingDocument): IRating { return { id: rating._id.toString(), diff --git a/server/src/Models/Ratings/Ratings.Interface.ts b/server/src/Models/Ratings/Ratings.Interface.ts index 2cf3414..cc40644 100644 --- a/server/src/Models/Ratings/Ratings.Interface.ts +++ b/server/src/Models/Ratings/Ratings.Interface.ts @@ -30,6 +30,43 @@ export type PendingRatingsByUserParams = { sessionUser: string; }; +export type CountRatingsByUserParams = { + sessionUser: string; +}; + +export type CountRatingsByUserResult = { + count: number; +}; + +export type ProfessionalRatingSummaryByUserParams = { + sessionUser: string; +}; + +export type RatingTargetSummaryParams = { + targetType: RatingTargetType; + targetId: string; +}; + +export type RatingTargetSummaryResult = { + averageScore: number; + totalCount: number; +}; + +export type RatingTargetReviewsParams = RatingTargetSummaryParams & { + limit?: number; +}; + +export type RatingTargetReviewItem = { + id: string; + score: number; + comment: string; + createdAt: Date; + author: { + displayName: string; + avatarUrl: string; + }; +}; + export interface IRating { id?: string; appointmentId: string; @@ -61,10 +98,18 @@ export interface IRatingsAdapter { create(data: Omit): Promise; find(filters: FindRatingsParams): Promise; findOne(filters: FindRatingsParams): Promise; + count(filters: FindRatingsParams): Promise; + summaryByTarget(filters: RatingTargetSummaryParams): Promise; + summaryByTargets(filters: { targetType: RatingTargetType; targetIds: string[] }): Promise; + reviewsByTarget(filters: RatingTargetReviewsParams): Promise; } export interface IRatingsManager { ratings: IRatingsAdapter; createRating(data: CreateRatingParams): Promise; pendingByUser(data: PendingRatingsByUserParams): Promise; + countByUser(data: CountRatingsByUserParams): Promise; + professionalSummaryByUser(data: ProfessionalRatingSummaryByUserParams): Promise; + summaryByTarget(data: RatingTargetSummaryParams): Promise; + reviewsByTarget(data: RatingTargetReviewsParams): Promise; } diff --git a/server/src/Models/Ratings/Ratings.ts b/server/src/Models/Ratings/Ratings.ts index f8cb55d..2061521 100644 --- a/server/src/Models/Ratings/Ratings.ts +++ b/server/src/Models/Ratings/Ratings.ts @@ -9,16 +9,24 @@ import ServicesManager from "../Services/Service"; import UsersManager from "../Users/Users"; import { RatingsAdapterMongoose } from "./Ratings.Adapter.Mongoose"; import { + CountRatingsByUserParams, + CountRatingsByUserResult, CreateRatingParams, IRating, IRatingsManager, PendingRatingItem, PendingRatingsByUserParams, + ProfessionalRatingSummaryByUserParams, RATING_COMMENT_MAX_LENGTH, + RatingTargetSummaryParams, + RatingTargetSummaryResult, + RatingTargetReviewItem, + RatingTargetReviewsParams, RatingTargetType, } from "./Ratings.Interface"; const PENDING_RATINGS_APPOINTMENT_LIMIT = 50; +const TARGET_REVIEWS_LIMIT = 20; class RatingsManager implements IRatingsManager { ratings: RatingsAdapterMongoose; @@ -180,6 +188,52 @@ class RatingsManager implements IRatingsManager { return pendingItems; } + public async countByUser(data: CountRatingsByUserParams): Promise { + await this.validateSessionUser(data.sessionUser); + + return { + count: await this.ratings.count({ userId: data.sessionUser }), + }; + } + + public async professionalSummaryByUser(data: ProfessionalRatingSummaryByUserParams): Promise { + await this.validateSessionUser(data.sessionUser); + + const employees = await EmployeesList.employees.find({ + userId: data.sessionUser, + removed: { $ne: true }, + } as any); + const employeeIds = employees.map((employee) => String(employee.id)); + + return this.ratings.summaryByTargets({ + targetType: RatingTargetType.EMPLOYEE, + targetIds: employeeIds, + }); + } + + public async summaryByTarget(data: RatingTargetSummaryParams): Promise { + this.validateTargetType(data.targetType); + + if (!Types.ObjectId.isValid(data.targetId)) { + throw new Error("No se ha proporcionado un objetivo válido."); + } + + return this.ratings.summaryByTarget(data); + } + + public async reviewsByTarget(data: RatingTargetReviewsParams): Promise { + this.validateTargetType(data.targetType); + + if (!Types.ObjectId.isValid(data.targetId)) { + throw new Error("No se ha proporcionado un objetivo válido."); + } + + return this.ratings.reviewsByTarget({ + ...data, + limit: Math.min(Math.max(data.limit || TARGET_REVIEWS_LIMIT, 1), TARGET_REVIEWS_LIMIT), + }); + } + private async validateSessionUser(sessionUser: string): Promise { const user = await UsersManager.users.findOne({ _id: sessionUser }); diff --git a/server/src/api/Ratings/Ratings.Controller.ts b/server/src/api/Ratings/Ratings.Controller.ts index 1c41310..5dc6722 100644 --- a/server/src/api/Ratings/Ratings.Controller.ts +++ b/server/src/api/Ratings/Ratings.Controller.ts @@ -1,13 +1,24 @@ 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 { + CountRatingsByUserParams, + CountRatingsByUserResult, + CreateRatingParams, + PendingRatingItem, + PendingRatingsByUserParams, + ProfessionalRatingSummaryByUserParams, + RatingTargetReviewItem, + RatingTargetReviewsParams, + RatingTargetSummaryParams, + RatingTargetSummaryResult, +} from "../../Models/Ratings/Ratings.Interface"; import { ApiValidationError } from "../../Models/Server.Error.model"; import { authenticateMiddleware } from "../../middleware/authentication"; -import { rateLimiter } from "../../middleware/ratelimiter"; +import { ratingRateLimiter } from "../../middleware/ratelimiter"; import { RatingsService } from "./Ratings.Service"; @Route("ratings/create") -@Middlewares([rateLimiter, authenticateMiddleware]) +@Middlewares([ratingRateLimiter, authenticateMiddleware]) export class CreateRatingController extends Controller { @Response(500, "Ha ocurrido un error") @SuccessResponse(200, "Calificación creada con éxito") @@ -45,3 +56,85 @@ export class PendingRatingsByUserController extends Controller { } } } + +@Route("ratings/count-by-user") +@Middlewares([authenticateMiddleware]) +export class CountRatingsByUserController extends Controller { + @Response(500, "Ha ocurrido un error") + @SuccessResponse(200, "Busqueda completada con exito") + @Post() + public async countByUser( + @Body() requestBody: CountRatingsByUserParams + ): Promise { + try { + const ratingsCount = await new RatingsService().countByUser(requestBody); + this.setStatus(200); + return ratingsCount; + } catch (e) { + const errorOccurred: Error = e as Error; + this.setStatus(500); + return new ApiValidationError(500, errorOccurred.message); + } + } +} + +@Route("ratings/professional-summary-by-user") +@Middlewares([authenticateMiddleware]) +export class ProfessionalRatingSummaryByUserController extends Controller { + @Response(500, "Ha ocurrido un error") + @SuccessResponse(200, "Busqueda completada con exito") + @Post() + public async professionalSummaryByUser( + @Body() requestBody: ProfessionalRatingSummaryByUserParams + ): Promise { + try { + const ratingSummary = await new RatingsService().professionalSummaryByUser(requestBody); + this.setStatus(200); + return ratingSummary; + } catch (e) { + const errorOccurred: Error = e as Error; + this.setStatus(500); + return new ApiValidationError(500, errorOccurred.message); + } + } +} + +@Route("ratings/target-summary") +export class RatingTargetSummaryController extends Controller { + @Response(500, "Ha ocurrido un error") + @SuccessResponse(200, "Busqueda completada con exito") + @Post() + public async summaryByTarget( + @Body() requestBody: RatingTargetSummaryParams + ): Promise { + try { + const ratingSummary = await new RatingsService().summaryByTarget(requestBody); + this.setStatus(200); + return ratingSummary; + } catch (e) { + const errorOccurred: Error = e as Error; + this.setStatus(500); + return new ApiValidationError(500, errorOccurred.message); + } + } +} + +@Route("ratings/target-reviews") +export class RatingTargetReviewsController extends Controller { + @Response(500, "Ha ocurrido un error") + @SuccessResponse(200, "Busqueda completada con exito") + @Post() + public async reviewsByTarget( + @Body() requestBody: RatingTargetReviewsParams + ): Promise { + try { + const ratingReviews = await new RatingsService().reviewsByTarget(requestBody); + this.setStatus(200); + return ratingReviews; + } 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 index b894a9f..571899a 100644 --- a/server/src/api/Ratings/Ratings.Service.ts +++ b/server/src/api/Ratings/Ratings.Service.ts @@ -1,6 +1,18 @@ import { connect } from "mongoose"; import RatingsList from "../../Models/Ratings/Ratings"; -import { CreateRatingParams, IRating, PendingRatingItem, PendingRatingsByUserParams } from "../../Models/Ratings/Ratings.Interface"; +import { + CountRatingsByUserParams, + CountRatingsByUserResult, + CreateRatingParams, + IRating, + PendingRatingItem, + PendingRatingsByUserParams, + ProfessionalRatingSummaryByUserParams, + RatingTargetSummaryParams, + RatingTargetSummaryResult, + RatingTargetReviewItem, + RatingTargetReviewsParams, +} from "../../Models/Ratings/Ratings.Interface"; export class RatingsService { public async createRating(data: CreateRatingParams): Promise { @@ -12,4 +24,24 @@ export class RatingsService { await connect(`${process.env.DATABASE_CONNECTION}`); return await RatingsList.pendingByUser(data); } + + public async countByUser(data: CountRatingsByUserParams): Promise { + await connect(`${process.env.DATABASE_CONNECTION}`); + return await RatingsList.countByUser(data); + } + + public async professionalSummaryByUser(data: ProfessionalRatingSummaryByUserParams): Promise { + await connect(`${process.env.DATABASE_CONNECTION}`); + return await RatingsList.professionalSummaryByUser(data); + } + + public async summaryByTarget(data: RatingTargetSummaryParams): Promise { + await connect(`${process.env.DATABASE_CONNECTION}`); + return await RatingsList.summaryByTarget(data); + } + + public async reviewsByTarget(data: RatingTargetReviewsParams): Promise { + await connect(`${process.env.DATABASE_CONNECTION}`); + return await RatingsList.reviewsByTarget(data); + } } diff --git a/server/src/middleware/ratelimiter.ts b/server/src/middleware/ratelimiter.ts index 3652dcb..92f57ae 100644 --- a/server/src/middleware/ratelimiter.ts +++ b/server/src/middleware/ratelimiter.ts @@ -2,6 +2,10 @@ import { Request, Response, NextFunction } from "express"; import { ApiValidationError } from "../Models/Server.Error.model"; const ipLastRequest = new Map(); +const ratingIpRequests = new Map(); + +const RATING_RATE_LIMIT_WINDOW_MS = 60000; +const RATING_RATE_LIMIT_MAX_REQUESTS = 20; // Limpia las IPs cada hora setInterval(() => { @@ -10,6 +14,14 @@ setInterval(() => { ipLastRequest.forEach((time, ip) => { if (now - time > timeLimit) ipLastRequest.delete(ip); }); + ratingIpRequests.forEach((times, ip) => { + const recentTimes = times.filter((time) => now - time <= RATING_RATE_LIMIT_WINDOW_MS); + if (recentTimes.length === 0) { + ratingIpRequests.delete(ip); + return; + } + ratingIpRequests.set(ip, recentTimes); + }); }, 3600000); export async function rateLimiter( @@ -31,3 +43,25 @@ export async function rateLimiter( ipLastRequest.set(ip, currentTime); next(); } + +export async function ratingRateLimiter( + request: Request, + response: Response, + next: NextFunction +): Promise { + const ip = request.ip; + const currentTime = Date.now(); + const windowStart = currentTime - RATING_RATE_LIMIT_WINDOW_MS; + const recentRequests = (ratingIpRequests.get(ip) || []).filter((time) => time > windowStart); + + if (recentRequests.length >= RATING_RATE_LIMIT_MAX_REQUESTS) { + response + .status(429) + .json(new ApiValidationError(429, "Demasiadas solicitudes. Por favor espere!")); + return; + } + + recentRequests.push(currentTime); + ratingIpRequests.set(ip, recentRequests); + next(); +} diff --git a/txclient/src/Models/Ratings.model.ts b/txclient/src/Models/Ratings.model.ts index e700e2e..b10840b 100644 --- a/txclient/src/Models/Ratings.model.ts +++ b/txclient/src/Models/Ratings.model.ts @@ -19,6 +19,43 @@ export type PendingRatingsByUserParams = { sessionUser: string; }; +export type CountRatingsByUserParams = { + sessionUser: string; +}; + +export type CountRatingsByUserResult = { + count: number; +}; + +export type ProfessionalRatingSummaryByUserParams = { + sessionUser: string; +}; + +export type RatingTargetSummaryParams = { + targetType: RatingTargetType; + targetId: string; +}; + +export type RatingTargetSummaryResult = { + averageScore: number; + totalCount: number; +}; + +export type RatingTargetReviewsParams = RatingTargetSummaryParams & { + limit?: number; +}; + +export type RatingTargetReviewItem = { + id: string; + score: number; + comment: string; + createdAt: string; + author: { + displayName: string; + avatarUrl: string; + }; +}; + export interface PendingRatingItem { appointmentId: string; appointmentDate: string; diff --git a/txclient/src/Services/Ratings.Service.ts b/txclient/src/Services/Ratings.Service.ts index ed67316..fe04a4e 100644 --- a/txclient/src/Services/Ratings.Service.ts +++ b/txclient/src/Services/Ratings.Service.ts @@ -2,10 +2,17 @@ import ApiRequest from "@services/Api.Service"; import { ApiVoidResult } from "@core/Models/Api.VoidResult.type"; import { ApiError } from "@core/Models/Server.Error.model"; import { + CountRatingsByUserParams, + CountRatingsByUserResult, CreateRatingParams, PendingRatingItem, PendingRatingsByUserParams, + ProfessionalRatingSummaryByUserParams, RATING_COMMENT_MAX_LENGTH, + RatingTargetSummaryParams, + RatingTargetSummaryResult, + RatingTargetReviewItem, + RatingTargetReviewsParams, RatingTargetType, } from "@core/Models/Ratings.model"; import * as Yup from "yup"; @@ -14,6 +21,14 @@ const schemaPendingRatingsByUser = Yup.object().shape({ sessionUser: Yup.string().required("Para ver tus calificaciones pendientes debes iniciar sesión."), }); +const schemaCountRatingsByUser = Yup.object().shape({ + sessionUser: Yup.string().required("Para ver tus opiniones realizadas debes iniciar sesión."), +}); + +const schemaProfessionalRatingSummaryByUser = Yup.object().shape({ + sessionUser: Yup.string().required("Para ver tu puntaje profesional debes iniciar sesión."), +}); + const schemaCreateRating = Yup.object().shape({ appointmentId: Yup.string().required("No se ha proporcionado el turno."), targetType: Yup.string() @@ -25,6 +40,17 @@ const schemaCreateRating = Yup.object().shape({ sessionUser: Yup.string().required("Para calificar debes iniciar sesión."), }); +const schemaRatingTargetSummary = Yup.object().shape({ + 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 calificado."), +}); + +const schemaRatingTargetReviews = schemaRatingTargetSummary.shape({ + limit: Yup.number().integer().min(1).max(20), +}); + export const pendingRatingsByUser = async (data: PendingRatingsByUserParams): Promise => { return new Promise((resolve, reject) => { schemaPendingRatingsByUser @@ -38,6 +64,34 @@ export const pendingRatingsByUser = async (data: PendingRatingsByUserParams): Pr }); }; +export const countRatingsByUser = async (data: CountRatingsByUserParams): Promise => { + return new Promise((resolve, reject) => { + schemaCountRatingsByUser + .validate(data, { abortEarly: true }) + .then(() => { + resolve(ApiRequest.post("ratings/count-by-user", data)); + }) + .catch((error) => { + reject(new ApiError(500, error.message)); + }); + }); +}; + +export const professionalRatingSummaryByUser = async ( + data: ProfessionalRatingSummaryByUserParams +): Promise => { + return new Promise((resolve, reject) => { + schemaProfessionalRatingSummaryByUser + .validate(data, { abortEarly: true }) + .then(() => { + resolve(ApiRequest.post("ratings/professional-summary-by-user", data)); + }) + .catch((error) => { + reject(new ApiError(500, error.message)); + }); + }); +}; + export const createRating = async (data: CreateRatingParams): Promise => { return new Promise((resolve, reject) => { schemaCreateRating @@ -50,3 +104,29 @@ export const createRating = async (data: CreateRatingParams): Promise => { + return new Promise((resolve, reject) => { + schemaRatingTargetSummary + .validate(data, { abortEarly: true }) + .then(() => { + resolve(ApiRequest.post("ratings/target-summary", data)); + }) + .catch((error) => { + reject(new ApiError(500, error.message)); + }); + }); +}; + +export const ratingTargetReviews = async (data: RatingTargetReviewsParams): Promise => { + return new Promise((resolve, reject) => { + schemaRatingTargetReviews + .validate(data, { abortEarly: true }) + .then(() => { + resolve(ApiRequest.post("ratings/target-reviews", data)); + }) + .catch((error) => { + reject(new ApiError(500, error.message)); + }); + }); +}; diff --git a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/services/page.tsx b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/services/page.tsx index 09f3b4a..2721aab 100644 --- a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/services/page.tsx +++ b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/services/page.tsx @@ -13,6 +13,7 @@ import { useNavigation } from "@hooks/goto"; import { useAlert } from "@core/Store/Alert.Store"; import currency from "currency.js"; import { getServiceImage } from "@core/helpers/getServiceImage"; +import ServiceRatingSummary from "@components/ServiceRatingSummary/ServiceRatingSummary"; import ShareOutlinedIcon from "@mui/icons-material/ShareOutlined"; import Dialog from "@mui/material/Dialog"; @@ -113,6 +114,7 @@ export default function Org() {

{data.name}

{data.description}

+
{formatPrice(data.price)} diff --git a/txclient/src/app/components/Home/OrganizationHeader/OrganizationHeader.module.css b/txclient/src/app/components/Home/OrganizationHeader/OrganizationHeader.module.css index e6aa23b..512b883 100644 --- a/txclient/src/app/components/Home/OrganizationHeader/OrganizationHeader.module.css +++ b/txclient/src/app/components/Home/OrganizationHeader/OrganizationHeader.module.css @@ -10,7 +10,8 @@ overflow: hidden; } - .organizationHeader div { + .organizationHeaderBanner { + position: relative; border: solid 1px var(--gray-dark); border-radius: 0px; width: 100%; @@ -30,6 +31,51 @@ padding-left: 15px; padding-top: 10px; } + + .organizationRatingSummary { + position: absolute; + right: 12px; + bottom: 12px; + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + max-width: calc(100% - 24px); + padding: 8px 10px; + border: 1px solid rgba(255, 255, 255, 0.42); + border-radius: 18px; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.84), rgba(255, 255, 255, 0.62)); + box-shadow: 0px 10px 26px rgba(0, 0, 0, 0.22); + color: var(--wine-black); + white-space: nowrap; + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + } + + .organizationRatingStars :global(.MuiRating-iconFilled), + .organizationRatingStars :global(.MuiRating-iconHover) { + color: #f5b83b; + filter: drop-shadow(0px 1px 1px rgba(82, 43, 0, 0.3)); + } + + .organizationRatingText { + display: flex; + flex-direction: column; + gap: 1px; + line-height: 1; + } + + .organizationRatingText strong { + font-size: 15px; + font-weight: 800; + letter-spacing: -0.02em; + } + + .organizationRatingText span { + color: rgba(35, 26, 31, 0.72); + font-size: 11px; + font-weight: 700; + } } @media (min-width: 801px) { @@ -44,7 +90,8 @@ overflow: hidden; } - .organizationHeader div { + .organizationHeaderBanner { + position: relative; border: solid 1px var(--gray-dark); border-radius: 5px; width: 100%; @@ -62,4 +109,48 @@ padding-left: 20px; padding-top: 10px; } + + .organizationRatingSummary { + position: absolute; + right: 24px; + bottom: 18px; + display: flex; + flex-direction: row; + align-items: center; + gap: 10px; + padding: 11px 15px 11px 14px; + border: 1px solid rgba(255, 255, 255, 0.42); + border-radius: 20px; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.84), rgba(255, 255, 255, 0.6)); + box-shadow: 0px 14px 34px rgba(0, 0, 0, 0.22); + color: var(--wine-black); + white-space: nowrap; + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + } + + .organizationRatingStars :global(.MuiRating-iconFilled), + .organizationRatingStars :global(.MuiRating-iconHover) { + color: #f5b83b; + filter: drop-shadow(0px 1px 1px rgba(82, 43, 0, 0.32)); + } + + .organizationRatingText { + display: flex; + flex-direction: column; + gap: 2px; + line-height: 1; + } + + .organizationRatingText strong { + font-size: 17px; + font-weight: 800; + letter-spacing: -0.03em; + } + + .organizationRatingText span { + color: rgba(35, 26, 31, 0.72); + font-size: 12px; + font-weight: 700; + } } diff --git a/txclient/src/app/components/Home/OrganizationHeader/OrganizationHeader.tsx b/txclient/src/app/components/Home/OrganizationHeader/OrganizationHeader.tsx index 76da62b..094daf9 100644 --- a/txclient/src/app/components/Home/OrganizationHeader/OrganizationHeader.tsx +++ b/txclient/src/app/components/Home/OrganizationHeader/OrganizationHeader.tsx @@ -2,6 +2,10 @@ import { PublicOrganizationView } from "@core/Models/Company.model"; import style from "./OrganizationHeader.module.css"; import { useNavigation } from "@core/app/hooks/goto"; +import { Rating } from "@mui/material"; +import { RatingTargetSummaryResult, RatingTargetType } from "@core/Models/Ratings.model"; +import { ratingTargetSummary } from "@services/Ratings.Service"; +import { useEffect, useState } from "react"; export interface OrganizationHeaderProps { organization: PublicOrganizationView; @@ -10,12 +14,33 @@ export interface OrganizationHeaderProps { export default function OrganizationHeader(props: OrganizationHeaderProps) { const { organization } = props; const { goTo } = useNavigation(); + const [ratingSummary, setRatingSummary] = useState({ averageScore: 0, totalCount: 0 }); + + useEffect(() => { + if (!organization.id) { + setRatingSummary({ averageScore: 0, totalCount: 0 }); + return; + } + + ratingTargetSummary({ + targetType: RatingTargetType.COMPANY, + targetId: organization.id, + }) + .then((response) => { + setRatingSummary(response); + }) + .catch(() => { + setRatingSummary({ averageScore: 0, totalCount: 0 }); + }); + }, [organization.id]); + return (
goTo("/landing/org/" + organization.id)} >
{organization.description}

+ {ratingSummary.totalCount > 0 && ( +
+ +
+ {ratingSummary.averageScore.toFixed(1)} + + {ratingSummary.totalCount} {ratingSummary.totalCount === 1 ? "opinión" : "opiniones"} + +
+
+ )}
); diff --git a/txclient/src/app/components/ProductItem/ProductItem.tsx b/txclient/src/app/components/ProductItem/ProductItem.tsx index c78320c..ade9fc1 100644 --- a/txclient/src/app/components/ProductItem/ProductItem.tsx +++ b/txclient/src/app/components/ProductItem/ProductItem.tsx @@ -5,6 +5,7 @@ import { PublicServiceView } from "@models/Service.model"; import style from "./style.module.css"; import { getServiceImage } from "@core/helpers/getServiceImage"; +import ServiceRatingSummary from "@components/ServiceRatingSummary/ServiceRatingSummary"; export type FindResultsViewMode = "grid" | "card"; @@ -45,25 +46,28 @@ export default function ProductItem(props: ProductItemProps) { goTo(`/landing/service/${product.id}`); }} > - {product.name} -

- {product.name} -

+
+ {product.name} + +
+
+

{product.name}

+

{product.description}

-
-

{product.name}

+
+
+

{product.name}

+ +

:not(img) { + position: absolute; + right: 8px; + bottom: 8px; +} + +.cardHeader { + margin: 10px 0; + text-align: center; +} + +.cardTitle { + font-size: 1.2rem; + margin: 0; + text-transform: capitalize; + overflow-wrap: anywhere; +} + +.itemContent { + flex: 1; + min-width: 0; +} + +.itemHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin: 10px 30px 0 0; +} + +.itemHeader .itemTitle { + margin: 0; +} + @media (max-width: 851px) { .itemTitle { font-size: 1rem; @@ -5,6 +45,10 @@ text-transform: capitalize; } + .itemHeader .itemTitle { + margin: 0; + } + .itemDescription { font-size: 0.9rem; } @@ -21,6 +65,10 @@ text-transform: capitalize; } + .itemHeader .itemTitle { + margin: 0; + } + .itemDescription { font-size: 1.1rem; } diff --git a/txclient/src/app/components/ReviewsCarousel/ReviewsCarousel.module.css b/txclient/src/app/components/ReviewsCarousel/ReviewsCarousel.module.css new file mode 100644 index 0000000..4a370ad --- /dev/null +++ b/txclient/src/app/components/ReviewsCarousel/ReviewsCarousel.module.css @@ -0,0 +1,120 @@ +.reviewsCarousel { + box-sizing: border-box; + width: 100%; + margin: 25px 0 22px; +} + +.header { + margin: 0 10px 5px; +} + +.header h2 { + margin: 0; + padding-bottom: 10px; + color: #241f1b; + font-size: 1.5rem; + font-weight: 700; + line-height: 1.25; +} + +.header p { + max-width: 620px; + margin: 0 0 10px; + color: #6d6258; + font-size: 0.95rem; + line-height: 1.45; +} + +.scroller { + display: flex; + gap: 20px; + width: 100%; + padding: 0 10px 12px; + overflow-x: auto; + cursor: grab; + scroll-padding-left: 10px; + scroll-snap-type: x mandatory; + scrollbar-width: thin; + user-select: none; +} + +.dragging { + cursor: grabbing; +} + +.reviewCard { + display: flex; + flex: 0 0 min(360px, calc(100vw - 40px)); + flex-direction: column; + gap: 13px; + min-height: 176px; + padding: 20px; + border: 1px solid rgba(45, 29, 21, 0.1); + border-radius: 18px; + background: rgba(255, 255, 255, 0.94); + box-shadow: 0 10px 24px rgba(45, 29, 21, 0.06); + scroll-snap-align: start; +} + +.rating { + display: flex; + align-items: center; + gap: 10px; +} + +.stars { + color: #e8a533; + font-size: 1.35rem; + letter-spacing: 0.06em; + line-height: 1; +} + +.score { + color: #342d27; + font-size: 0.95rem; + font-weight: 700; + line-height: 1; +} + +.comment { + flex: 1; + margin: 0; + color: #2f2924; + font-size: 0.98rem; + line-height: 1.58; +} + +.footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + color: #8b8178; + font-size: 0.86rem; +} + +.authorBlock { + display: flex; + min-width: 0; + align-items: center; + gap: 10px; +} + +.author { + overflow: hidden; + color: #342d27; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (max-width: 700px) { + .reviewCard { + flex-basis: calc(100vw - 40px); + } + + .footer { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/txclient/src/app/components/ReviewsCarousel/ReviewsCarousel.tsx b/txclient/src/app/components/ReviewsCarousel/ReviewsCarousel.tsx new file mode 100644 index 0000000..30f3f16 --- /dev/null +++ b/txclient/src/app/components/ReviewsCarousel/ReviewsCarousel.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { useEffect, useRef, useState, type PointerEvent, type ReactElement } from "react"; +import classNames from "classnames"; +import Avatar from "@components/Avatar/Avatar"; +import { RatingTargetReviewItem, RatingTargetType } from "@core/Models/Ratings.model"; +import { ratingTargetReviews } from "@services/Ratings.Service"; +import style from "./ReviewsCarousel.module.css"; + +interface ReviewsCarouselProps { + targetType: RatingTargetType; + targetId: string; + title?: string; + subtitle?: string; + className?: string; + limit?: number; +} + +const DEFAULT_LIMIT = 20; + +const formatReviewDate = (date: string): string => { + return new Intl.DateTimeFormat("es-AR", { + month: "long", + year: "numeric", + }).format(new Date(date)); +}; + +const renderStars = (score: number): string => { + const normalizedScore = Math.min(Math.max(Math.round(score), 1), 5); + return "★".repeat(normalizedScore) + "☆".repeat(5 - normalizedScore); +}; + +const formatScore = (score: number): string => score.toFixed(1); + +export default function ReviewsCarousel({ + targetType, + targetId, + title = "Lo que dicen quienes ya reservaron", + subtitle, + className, + limit = DEFAULT_LIMIT, +}: ReviewsCarouselProps): ReactElement | null { + const [reviews, setReviews] = useState([]); + const [isDragging, setIsDragging] = useState(false); + const scrollerRef = useRef(null); + + useEffect(() => { + let active = true; + + ratingTargetReviews({ targetType, targetId, limit }) + .then((targetReviews) => { + if (active) { + setReviews(targetReviews.filter((review) => review.comment.trim().length > 0)); + } + }) + .catch(() => { + if (active) { + setReviews([]); + } + }); + + return () => { + active = false; + }; + }, [targetType, targetId, limit]); + + const handlePointerDown = () => { + setIsDragging(true); + }; + + const handlePointerMove = (event: PointerEvent) => { + const scroller = scrollerRef.current; + + if (!isDragging || !scroller) { + return; + } + + scroller.scrollLeft -= event.movementX; + }; + + const stopDragging = () => { + setIsDragging(false); + }; + + if (reviews.length === 0) { + return null; + } + + return ( +

+
+

{title}

+ {subtitle &&

{subtitle}

} +
+
+ {reviews.map((review) => ( +
+
+ + {formatScore(review.score)} +
+

“{review.comment}”

+
+
+ + {review.author.displayName} +
+ +
+
+ ))} +
+
+ ); +} diff --git a/txclient/src/app/components/SelectebleServiceItem/SelectableServiceItem.module.css b/txclient/src/app/components/SelectebleServiceItem/SelectableServiceItem.module.css index aca82c1..48f76a6 100644 --- a/txclient/src/app/components/SelectebleServiceItem/SelectableServiceItem.module.css +++ b/txclient/src/app/components/SelectebleServiceItem/SelectableServiceItem.module.css @@ -63,6 +63,15 @@ font-size: 16px; font-weight: 700; text-align: left; + margin: 0; +} + +.serviceHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; } .selectableServiceItem p { @@ -78,3 +87,11 @@ font-weight: 700; text-align: right; } + +.selectableServiceItem > div { + margin-top: 10px; +} + +.selectableServiceItem > .serviceHeader { + margin-top: 0; +} diff --git a/txclient/src/app/components/SelectebleServiceItem/SelectableServiceItem.tsx b/txclient/src/app/components/SelectebleServiceItem/SelectableServiceItem.tsx index b0c8ab0..2662fcc 100644 --- a/txclient/src/app/components/SelectebleServiceItem/SelectableServiceItem.tsx +++ b/txclient/src/app/components/SelectebleServiceItem/SelectableServiceItem.tsx @@ -4,6 +4,7 @@ import style from "./SelectableServiceItem.module.css"; import { formatPrice } from "@helpers/Numbers"; import { useState } from "react"; import classNames from "classnames"; +import ServiceRatingSummary from "@components/ServiceRatingSummary/ServiceRatingSummary"; export interface SelectableServiceItemProps { data: CollaboratorServiceItems; @@ -39,7 +40,10 @@ export default function SelectableServiceItem( return ( <>
-

{props.data.name}

+
+

{props.data.name}

+ +

{props.data.description}

{formatPrice(props.data.price)}
diff --git a/txclient/src/app/components/ServiceItem/ServiceItem.module.css b/txclient/src/app/components/ServiceItem/ServiceItem.module.css index 760007e..9b2cfb6 100644 --- a/txclient/src/app/components/ServiceItem/ServiceItem.module.css +++ b/txclient/src/app/components/ServiceItem/ServiceItem.module.css @@ -56,6 +56,7 @@ margin-top: auto; border-top: 1px solid #f0f0f0; padding-top: 12px; + margin-top: 12px; } .collaboratorInfo { diff --git a/txclient/src/app/components/ServiceItem/ServiceItem.tsx b/txclient/src/app/components/ServiceItem/ServiceItem.tsx index e0f8607..155449f 100644 --- a/txclient/src/app/components/ServiceItem/ServiceItem.tsx +++ b/txclient/src/app/components/ServiceItem/ServiceItem.tsx @@ -6,6 +6,7 @@ import dayjs from "dayjs"; import "dayjs/locale/es"; import { testContrast } from "@core/app/theme/scheduleView"; import { formatDateLarge, formatTimeInterval } from "@core/helpers/format"; +import ServiceRatingSummary from "@components/ServiceRatingSummary/ServiceRatingSummary"; dayjs.locale("es"); @@ -36,6 +37,8 @@ export default function ServiceItem(props: ServiceItemProps): React.ReactElement
{props.data.serviceDescription}
)} + +
({ averageScore: 0, totalCount: 0 }); + + useEffect(() => { + if (!serviceId) { + setRatingSummary({ averageScore: 0, totalCount: 0 }); + return; + } + + let isMounted = true; + + ratingTargetSummary({ + targetType: RatingTargetType.SERVICE, + targetId: serviceId, + }) + .then((response) => { + if (isMounted) { + setRatingSummary(response); + } + }) + .catch(() => { + if (isMounted) { + setRatingSummary({ averageScore: 0, totalCount: 0 }); + } + }); + + return () => { + isMounted = false; + }; + }, [serviceId]); + + if (ratingSummary.totalCount <= 0) { + return null; + } + + if (displayMode === "compact") { + return ( +
+ + {ratingSummary.averageScore.toFixed(1)} +
+ ); + } + + return ( +
+ +
+ {ratingSummary.averageScore.toFixed(1)} + {showOpinionCount && ( + + {ratingSummary.totalCount} {ratingSummary.totalCount === 1 ? "opinión" : "opiniones"} + + )} +
+
+ ); +} diff --git a/txclient/src/app/components/UserMenu/UserMenu.module.css b/txclient/src/app/components/UserMenu/UserMenu.module.css index a041b2a..e06ec63 100644 --- a/txclient/src/app/components/UserMenu/UserMenu.module.css +++ b/txclient/src/app/components/UserMenu/UserMenu.module.css @@ -96,6 +96,27 @@ font-weight: 500; } +.professionalRating { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + margin-top: 8px; + color: #f59e0b; +} + +.professionalRating strong { + font-size: 15px; + letter-spacing: 1px; + line-height: 1; +} + +.professionalRating small { + color: #4b5563; + font-size: 12px; + font-weight: 700; +} + .userMenuOptionsContainer { flex-grow: 1; overflow-y: auto; diff --git a/txclient/src/app/components/UserMenu/UserMenu.tsx b/txclient/src/app/components/UserMenu/UserMenu.tsx index 0a14cf1..6ba9da3 100644 --- a/txclient/src/app/components/UserMenu/UserMenu.tsx +++ b/txclient/src/app/components/UserMenu/UserMenu.tsx @@ -18,6 +18,8 @@ import dayjs from "dayjs"; import { loadOrganizationsByUser } from "@core/app/admin/org/Org.Service"; import { MyOranizationsView } from "@core/Models/Company.model"; import SelectOrganizationPopup from "./SelectOrganizationPopup"; +import { professionalRatingSummaryByUser } from "@core/Services/Ratings.Service"; +import { RatingTargetSummaryResult } from "@core/Models/Ratings.model"; export default function UserMenu(): React.ReactElement { const router = useRouter(); @@ -33,6 +35,10 @@ export default function UserMenu(): React.ReactElement { const [clientSocket, setClientSocket] = useState(undefined); const [showOrgPopup, setShowOrgPopup] = useState(false); const [userOrgs, setUserOrgs] = useState([]); + const [professionalRating, setProfessionalRating] = useState({ + averageScore: 0, + totalCount: 0, + }); useEffect(() => { try { //TODO: Comprobar si se arregla el problema... @@ -100,6 +106,17 @@ export default function UserMenu(): React.ReactElement { }; }, [clientSocket]); + useEffect(() => { + if (!SessionInfo.loged || !SessionInfo.userId) { + setProfessionalRating({ averageScore: 0, totalCount: 0 }); + return; + } + + professionalRatingSummaryByUser({ sessionUser: SessionInfo.userId }) + .then(setProfessionalRating) + .catch(() => setProfessionalRating({ averageScore: 0, totalCount: 0 })); + }, [SessionInfo.loged, SessionInfo.userId]); + const toggleMenu = () => { menuState.setVisible(!menuState.visible); }; @@ -157,6 +174,11 @@ export default function UserMenu(): React.ReactElement { return "/anonimus-avatar.svg"; }; + const getRatingStars = (score: number) => { + const roundedScore = Math.round(score); + return Array.from({ length: 5 }, (_, index) => (index < roundedScore ? "★" : "☆")).join(""); + }; + return ( <>
@@ -173,6 +195,14 @@ export default function UserMenu(): React.ReactElement {

{getUserName()}

{getUserEmail()} + {professionalRating.totalCount > 0 && ( +
+ {getRatingStars(professionalRating.averageScore)} + + {professionalRating.averageScore.toFixed(1)} ({professionalRating.totalCount}) + +
+ )}
diff --git a/txclient/src/app/landing/org/[oid]/page.tsx b/txclient/src/app/landing/org/[oid]/page.tsx index 6b96afa..045f0a9 100644 --- a/txclient/src/app/landing/org/[oid]/page.tsx +++ b/txclient/src/app/landing/org/[oid]/page.tsx @@ -58,6 +58,8 @@ import turnosXpressTheme from "@core/app/theme/turnosXpress"; import { useParams } from "next/navigation"; import dayjs from "dayjs"; import { useConfirmStore } from "@core/Store/Confirm.Store"; +import ReviewsCarousel from "@core/app/components/ReviewsCarousel/ReviewsCarousel"; +import { RatingTargetType } from "@core/Models/Ratings.model"; enum VIEW { POSTS = "posts", @@ -592,92 +594,94 @@ export default function OrganizationPublicProfile() {
)} -
{LocationInfo(orgData)}
-
+
+
{LocationInfo(orgData)}
+
{ - setView(VIEW.POSTS); - processAddButton(VIEW.POSTS); + style={{ + height: "50px", + display: "flex", + alignItems: "center", + gap: "20px", }} > - Posts -
-
{ - setView(VIEW.SERVICES); - processAddButton(VIEW.SERVICES); - }} - > - Servicios -
- {checkShowInfo() && (
{ - setView(VIEW.INFO); - processAddButton(VIEW.INFO); + setView(VIEW.POSTS); + processAddButton(VIEW.POSTS); }} > - Contacto + Posts +
+
{ + setView(VIEW.SERVICES); + processAddButton(VIEW.SERVICES); + }} + > + Servicios +
+ {checkShowInfo() && ( +
{ + setView(VIEW.INFO); + processAddButton(VIEW.INFO); + }} + > + Contacto +
+ )} +
+ {view === VIEW.SERVICES && ( +
+ + setViewMode("card")}> + + + setViewMode("grid")}> + + +
)}
- {view === VIEW.SERVICES && ( -
- - setViewMode("card")}> - - - setViewMode("grid")}> - - - -
- )} -
-
+
{view === VIEW.POSTS && (
)}
+
+
+
)} diff --git a/txclient/src/app/landing/org/[oid]/profile.module.css b/txclient/src/app/landing/org/[oid]/profile.module.css index 11e9874..186984d 100644 --- a/txclient/src/app/landing/org/[oid]/profile.module.css +++ b/txclient/src/app/landing/org/[oid]/profile.module.css @@ -1,5 +1,6 @@ @media (max-width: 800px) { .organizationContent { + position: relative; display: flex; flex-direction: column; align-items: flex-start; @@ -7,7 +8,14 @@ margin-left: 30px; margin-right: 30px; gap: 20px; - overflow: hidden; + overflow: visible; + } + + .organizationProfileColumns { + display: flex; + flex-direction: column; + gap: 20px; + width: 100%; } .organizationMap { @@ -80,6 +88,14 @@ margin-bottom: 10px; } + .organizationMainColumn { + width: 100%; + } + + .organizationReviews { + margin-bottom: 30px; + } + .organizationServices { margin-bottom: 30px; } @@ -87,15 +103,29 @@ @media (min-width: 801px) { .organizationContent { + position: relative; display: flex; - flex-direction: row; - align-items: flex-start; + flex-direction: column; + align-items: stretch; /*margin-top: 30px;*/ margin-left: 30px; margin-right: 30px; gap: 20px; } + .organizationProfileColumns { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 20px; + width: 100%; + } + + .organizationMainColumn { + width: 100%; + min-width: 0; + } + .organizationMap { border: solid 1px var(--gray-light); border-radius: 10px; @@ -136,4 +166,9 @@ width: 100%; overflow: hidden; } + + .organizationReviews { + margin-bottom: 30px; + width: 100%; + } } diff --git a/txclient/src/app/landing/pending-ratings/page.tsx b/txclient/src/app/landing/pending-ratings/page.tsx index 7859bde..7ca8d70 100644 --- a/txclient/src/app/landing/pending-ratings/page.tsx +++ b/txclient/src/app/landing/pending-ratings/page.tsx @@ -13,7 +13,7 @@ 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 { countRatingsByUser, createRating, pendingRatingsByUser } from "@services/Ratings.Service"; import { PendingRatingItem, RATING_COMMENT_MAX_LENGTH, RatingTargetType } from "@core/Models/Ratings.model"; import styles from "./page.module.css"; @@ -37,6 +37,7 @@ export default function PendingRatingsPage() { const [comments, setComments] = useState>({}); const [submittingKey, setSubmittingKey] = useState(""); const [isLoading, setIsLoading] = useState(true); + const [createdRatingsCount, setCreatedRatingsCount] = useState(0); const SessionInfo = useSessionStore(); const eventHandler = useEventHandlerStore(); const alert = useAlert(); @@ -45,14 +46,21 @@ export default function PendingRatingsPage() { const loadPendingRatings = () => { if (!SessionInfo.userId) { setPendingRatings([]); + setCreatedRatingsCount(0); setIsLoading(false); return; } setIsLoading(true); eventHandler.setEventType(EVENT_TYPES.LOADING); - pendingRatingsByUser({ sessionUser: SessionInfo.userId }) - .then((res) => setPendingRatings(res)) + Promise.all([ + pendingRatingsByUser({ sessionUser: SessionInfo.userId }), + countRatingsByUser({ sessionUser: SessionInfo.userId }), + ]) + .then(([pendingRatingsResponse, createdRatingsResponse]) => { + setPendingRatings(pendingRatingsResponse); + setCreatedRatingsCount(createdRatingsResponse.count); + }) .catch((error) => { alert.showError(error.message || "No pudimos cargar tus calificaciones pendientes."); console.error("Failed to load pending ratings", { @@ -135,8 +143,8 @@ export default function PendingRatingsPage() {
Tu aporte - {isLoading ? "..." : pendingRatings.length} - {pendingRatings.length === 1 ? "experiencia pendiente" : "experiencias pendientes"} + {isLoading ? "..." : createdRatingsCount} + {createdRatingsCount === 1 ? "opinión realizada" : "opiniones realizadas"}
diff --git a/txclient/src/app/landing/service/[sid]/page.tsx b/txclient/src/app/landing/service/[sid]/page.tsx index eb3c536..ea9d5f6 100644 --- a/txclient/src/app/landing/service/[sid]/page.tsx +++ b/txclient/src/app/landing/service/[sid]/page.tsx @@ -10,6 +10,8 @@ import { ScrollToTop } from "@core/app/components/ScrollTop"; import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider"; import ServicePrivate from "@core/app/components/ServicePrivate/ServicePrivate"; import EvangelizeBanner from "@components/EvangelizeBanner/EvangelizeBanner"; +import ReviewsCarousel from "@components/ReviewsCarousel/ReviewsCarousel"; +import { RatingTargetType } from "@core/Models/Ratings.model"; const GetServiceData = async (serviceId: string): Promise => { try { @@ -80,6 +82,12 @@ export default async function OrganizationPublicProfile({ params }: Props) { title="Otros Servicios 🏆" data={organizationService.organization.services.data} /> +
)}