feat: implement rating summary service methods and integrate rating display components into the organization header and pending ratings page

This commit is contained in:
2026-07-22 12:13:50 -03:00
parent 383a5bc6b4
commit da3c3abb52
27 changed files with 1334 additions and 104 deletions
+37
View File
@@ -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;
+80
View File
@@ -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<PendingRatingItem[]> => {
return new Promise<PendingRatingItem[]>((resolve, reject) => {
schemaPendingRatingsByUser
@@ -38,6 +64,34 @@ export const pendingRatingsByUser = async (data: PendingRatingsByUserParams): Pr
});
};
export const countRatingsByUser = async (data: CountRatingsByUserParams): Promise<CountRatingsByUserResult> => {
return new Promise<CountRatingsByUserResult>((resolve, reject) => {
schemaCountRatingsByUser
.validate(data, { abortEarly: true })
.then(() => {
resolve(ApiRequest.post<CountRatingsByUserResult>("ratings/count-by-user", data));
})
.catch((error) => {
reject(new ApiError(500, error.message));
});
});
};
export const professionalRatingSummaryByUser = async (
data: ProfessionalRatingSummaryByUserParams
): Promise<RatingTargetSummaryResult> => {
return new Promise<RatingTargetSummaryResult>((resolve, reject) => {
schemaProfessionalRatingSummaryByUser
.validate(data, { abortEarly: true })
.then(() => {
resolve(ApiRequest.post<RatingTargetSummaryResult>("ratings/professional-summary-by-user", data));
})
.catch((error) => {
reject(new ApiError(500, error.message));
});
});
};
export const createRating = async (data: CreateRatingParams): Promise<ApiVoidResult> => {
return new Promise<ApiVoidResult>((resolve, reject) => {
schemaCreateRating
@@ -50,3 +104,29 @@ export const createRating = async (data: CreateRatingParams): Promise<ApiVoidRes
});
});
};
export const ratingTargetSummary = async (data: RatingTargetSummaryParams): Promise<RatingTargetSummaryResult> => {
return new Promise<RatingTargetSummaryResult>((resolve, reject) => {
schemaRatingTargetSummary
.validate(data, { abortEarly: true })
.then(() => {
resolve(ApiRequest.post<RatingTargetSummaryResult>("ratings/target-summary", data));
})
.catch((error) => {
reject(new ApiError(500, error.message));
});
});
};
export const ratingTargetReviews = async (data: RatingTargetReviewsParams): Promise<RatingTargetReviewItem[]> => {
return new Promise<RatingTargetReviewItem[]>((resolve, reject) => {
schemaRatingTargetReviews
.validate(data, { abortEarly: true })
.then(() => {
resolve(ApiRequest.post<RatingTargetReviewItem[]>("ratings/target-reviews", data));
})
.catch((error) => {
reject(new ApiError(500, error.message));
});
});
};
@@ -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() {
<div className={style.serviceInfo}>
<h1>{data.name}</h1>
<p>{data.description}</p>
<ServiceRatingSummary serviceId={data.id} variant="row" showOpinionCount={false} />
</div>
<div className={style.serviceActions}>
<span className={style.servicePrice}>{formatPrice(data.price)}</span>
@@ -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;
}
}
@@ -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<RatingTargetSummaryResult>({ 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 (
<div
className={style.organizationHeader}
onClick={() => goTo("/landing/org/" + organization.id)}
>
<div
className={style.organizationHeaderBanner}
style={{
...(organization.headerFile ? { backgroundImage: `url(${organization.headerFile})` } : {}),
backgroundColor: organization.headerColor || 'transparent',
@@ -42,6 +67,23 @@ export default function OrganizationHeader(props: OrganizationHeaderProps) {
>
{organization.description}
</p>
{ratingSummary.totalCount > 0 && (
<div className={style.organizationRatingSummary}>
<Rating
className={style.organizationRatingStars}
value={ratingSummary.averageScore}
precision={0.1}
readOnly
size="small"
/>
<div className={style.organizationRatingText}>
<strong>{ratingSummary.averageScore.toFixed(1)}</strong>
<span>
{ratingSummary.totalCount} {ratingSummary.totalCount === 1 ? "opinión" : "opiniones"}
</span>
</div>
</div>
)}
</div>
</div>
);
@@ -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}`);
}}
>
<img
src={getServiceImage(product.image)}
alt={product.name}
draggable="false"
style={{
width: "100%",
height: "auto",
borderRadius: "10px",
}}
/>
<h3
style={{
fontSize: "1.2rem",
margin: "10px 0",
textTransform: "capitalize",
}}
>
{product.name}
</h3>
<div className={style.cardImageWrap}>
<img
src={getServiceImage(product.image)}
alt={product.name}
draggable="false"
style={{
width: "100%",
height: "auto",
borderRadius: "10px",
display: "block",
}}
/>
<ServiceRatingSummary
serviceId={product.id}
variant="card"
displayMode="compact"
showOpinionCount={false}
/>
</div>
<div className={style.cardHeader}>
<h3 className={style.cardTitle}>{product.name}</h3>
</div>
<p style={{ fontSize: "0.9rem", color: "#555" }}>{product.description}</p>
<span
style={{
@@ -119,8 +123,11 @@ export default function ProductItem(props: ProductItemProps) {
margin: "10px",
}}
/>
<div>
<h3 className={style.itemTitle}>{product.name}</h3>
<div className={style.itemContent}>
<div className={style.itemHeader}>
<h3 className={style.itemTitle}>{product.name}</h3>
<ServiceRatingSummary serviceId={product.id} variant="row" showOpinionCount={false} />
</div>
<p
className={style.itemDescription}
@@ -1,3 +1,43 @@
.cardImageWrap {
position: relative;
}
.cardImageWrap > :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;
}
@@ -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;
}
}
@@ -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<RatingTargetReviewItem[]>([]);
const [isDragging, setIsDragging] = useState(false);
const scrollerRef = useRef<HTMLDivElement | null>(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<HTMLDivElement>) => {
const scroller = scrollerRef.current;
if (!isDragging || !scroller) {
return;
}
scroller.scrollLeft -= event.movementX;
};
const stopDragging = () => {
setIsDragging(false);
};
if (reviews.length === 0) {
return null;
}
return (
<section className={classNames(style.reviewsCarousel, className)} aria-label="Opiniones">
<div className={style.header}>
<h2>{title}</h2>
{subtitle && <p>{subtitle}</p>}
</div>
<div
ref={scrollerRef}
className={classNames(style.scroller, { [style.dragging]: isDragging })}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={stopDragging}
onPointerLeave={stopDragging}
>
{reviews.map((review) => (
<article className={style.reviewCard} key={review.id}>
<div className={style.rating} aria-label={`${formatScore(review.score)} de 5 estrellas`}>
<span className={style.stars} aria-hidden="true">
{renderStars(review.score)}
</span>
<span className={style.score}>{formatScore(review.score)}</span>
</div>
<p className={style.comment}>{review.comment}</p>
<div className={style.footer}>
<div className={style.authorBlock}>
<Avatar
name={`review-author-${review.id}`}
src={review.author.avatarUrl || review.author.displayName}
alt={`Avatar de ${review.author.displayName}`}
size="small"
border="none"
/>
<span className={style.author}>{review.author.displayName}</span>
</div>
<time dateTime={review.createdAt}>{formatReviewDate(review.createdAt)}</time>
</div>
</article>
))}
</div>
</section>
);
}
@@ -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;
}
@@ -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 (
<>
<div className={getClassesNames()} onClick={handleClick}>
<h1>{props.data.name}</h1>
<div className={style.serviceHeader}>
<h1>{props.data.name}</h1>
<ServiceRatingSummary serviceId={props.data.id} variant="row" showOpinionCount={false} />
</div>
<p>{props.data.description}</p>
<span>{formatPrice(props.data.price)}</span>
</div>
@@ -56,6 +56,7 @@
margin-top: auto;
border-top: 1px solid #f0f0f0;
padding-top: 12px;
margin-top: 12px;
}
.collaboratorInfo {
@@ -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
<div className={style.serviceDescription}>{props.data.serviceDescription}</div>
)}
<ServiceRatingSummary serviceId={props.data.serviceId} variant="card" />
<div className={style.cardFooter}>
<div className={style.collaboratorInfo}>
<Avatar
@@ -0,0 +1,82 @@
.serviceRatingSummary {
display: inline-flex;
align-items: center;
gap: 8px;
width: fit-content;
border: 1px solid rgba(255, 255, 255, 0.5);
border-radius: 18px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0.68));
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.12);
color: var(--wine-black);
white-space: nowrap;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.card {
margin-top: 10px;
padding: 5px 9px;
box-shadow: none;
}
.row {
padding: 5px 9px;
box-shadow: none;
}
.serviceRatingStars :global(.MuiRating-iconFilled),
.serviceRatingStars :global(.MuiRating-iconHover) {
color: #f5b83b;
filter: drop-shadow(0 1px 1px rgba(82, 43, 0, 0.26));
}
.card .serviceRatingStars :global(.MuiRating-iconFilled),
.card .serviceRatingStars :global(.MuiRating-iconHover),
.row .serviceRatingStars :global(.MuiRating-iconFilled),
.row .serviceRatingStars :global(.MuiRating-iconHover) {
filter: none;
}
.serviceRatingText {
display: flex;
flex-direction: column;
justify-content: center;
gap: 1px;
line-height: 1;
}
.serviceRatingText strong {
font-size: 14px;
font-weight: 800;
letter-spacing: -0.02em;
}
.serviceRatingText span {
color: rgba(35, 26, 31, 0.72);
font-size: 10px;
font-weight: 700;
}
.compact {
gap: 4px;
margin-top: 0;
padding: 3px 7px 3px 6px;
border: 1px solid rgba(255, 255, 255, 0.58);
background: rgba(255, 255, 255, 0.68);
box-shadow: none;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.compact strong {
font-size: 13px;
font-weight: 800;
letter-spacing: -0.02em;
line-height: 1;
}
.compactStar {
color: #f5b83b;
font-size: 20px;
line-height: 1;
}
@@ -0,0 +1,79 @@
"use client";
import { Rating } from "@mui/material";
import { RatingTargetSummaryResult, RatingTargetType } from "@core/Models/Ratings.model";
import { ratingTargetSummary } from "@services/Ratings.Service";
import { useEffect, useState } from "react";
import style from "./ServiceRatingSummary.module.css";
export interface ServiceRatingSummaryProps {
serviceId?: string;
variant?: "card" | "row";
displayMode?: "default" | "compact";
showOpinionCount?: boolean;
}
export default function ServiceRatingSummary(props: ServiceRatingSummaryProps): React.ReactElement | null {
const { serviceId, variant = "card", displayMode = "default", showOpinionCount = true } = props;
const [ratingSummary, setRatingSummary] = useState<RatingTargetSummaryResult>({ 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 (
<div className={`${style.serviceRatingSummary} ${style[variant]} ${style.compact}`}>
<span className={style.compactStar} aria-hidden="true"></span>
<strong>{ratingSummary.averageScore.toFixed(1)}</strong>
</div>
);
}
return (
<div className={`${style.serviceRatingSummary} ${style[variant]}`}>
<Rating
className={style.serviceRatingStars}
value={ratingSummary.averageScore}
precision={0.1}
readOnly
size="small"
/>
<div className={style.serviceRatingText}>
<strong>{ratingSummary.averageScore.toFixed(1)}</strong>
{showOpinionCount && (
<span>
{ratingSummary.totalCount} {ratingSummary.totalCount === 1 ? "opinión" : "opiniones"}
</span>
)}
</div>
</div>
);
}
@@ -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;
@@ -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<Socket | undefined>(undefined);
const [showOrgPopup, setShowOrgPopup] = useState(false);
const [userOrgs, setUserOrgs] = useState<MyOranizationsView[]>([]);
const [professionalRating, setProfessionalRating] = useState<RatingTargetSummaryResult>({
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 (
<>
<div className={classBackgroundData} onClick={toggleMenu}></div>
@@ -173,6 +195,14 @@ export default function UserMenu(): React.ReactElement {
<div>
<h1>{getUserName()}</h1>
<span>{getUserEmail()}</span>
{professionalRating.totalCount > 0 && (
<div className={style.professionalRating} aria-label={`Puntaje profesional ${professionalRating.averageScore.toFixed(1)} de 5`}>
<strong>{getRatingStars(professionalRating.averageScore)}</strong>
<small>
{professionalRating.averageScore.toFixed(1)} ({professionalRating.totalCount})
</small>
</div>
)}
</div>
</div>
<div className={style.userMenuOptionsContainer}>
+80 -67
View File
@@ -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() {
</div>
</div>
)}
<div className={style.organizationMap}>{LocationInfo(orgData)}</div>
<div
ref={refDivContent}
style={{
border: "1px solid var(--gray-light)",
borderRadius: "10px",
marginTop: "30px",
width: "100%",
marginBottom: "30px",
backgroundColor: "var(--white)",
}}
>
<div className={style.organizationProfileColumns}>
<div className={style.organizationMap}>{LocationInfo(orgData)}</div>
<div className={style.organizationMainColumn}>
<div
ref={refDivContent}
style={{
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
borderBottom: "1px solid var(--gray-light)",
border: "1px solid var(--gray-light)",
borderRadius: "10px",
marginTop: "30px",
width: "100%",
marginBottom: "30px",
backgroundColor: "var(--white)",
}}
>
<div
style={{
height: "50px",
display: "flex",
alignItems: "center",
gap: "20px",
flexDirection: "row",
justifyContent: "space-between",
borderBottom: "1px solid var(--gray-light)",
}}
>
<div
style={getHeaderItemStyle(VIEW.POSTS)}
onClick={() => {
setView(VIEW.POSTS);
processAddButton(VIEW.POSTS);
style={{
height: "50px",
display: "flex",
alignItems: "center",
gap: "20px",
}}
>
Posts
</div>
<div
style={getHeaderItemStyle(VIEW.SERVICES)}
onClick={() => {
setView(VIEW.SERVICES);
processAddButton(VIEW.SERVICES);
}}
>
Servicios
</div>
{checkShowInfo() && (
<div
style={getHeaderItemStyle(VIEW.INFO)}
style={getHeaderItemStyle(VIEW.POSTS)}
onClick={() => {
setView(VIEW.INFO);
processAddButton(VIEW.INFO);
setView(VIEW.POSTS);
processAddButton(VIEW.POSTS);
}}
>
Contacto
Posts
</div>
<div
style={getHeaderItemStyle(VIEW.SERVICES)}
onClick={() => {
setView(VIEW.SERVICES);
processAddButton(VIEW.SERVICES);
}}
>
Servicios
</div>
{checkShowInfo() && (
<div
style={getHeaderItemStyle(VIEW.INFO)}
onClick={() => {
setView(VIEW.INFO);
processAddButton(VIEW.INFO);
}}
>
Contacto
</div>
)}
</div>
{view === VIEW.SERVICES && (
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<ButtonGroup
variant="outlined"
aria-label="Basic button group"
sx={{ marginTop: "10px", marginRight: "30px" }}
>
<IconButton onClick={() => setViewMode("card")}>
<GridViewOutlinedIcon
sx={{
color:
viewMode === "card" ? "var(--wine-red)" : "ineherit",
}}
/>
</IconButton>
<IconButton onClick={() => setViewMode("grid")}>
<SplitscreenOutlinedIcon
sx={{
color:
viewMode === "grid" ? "var(--wine-red)" : "ineherit",
}}
/>
</IconButton>
</ButtonGroup>
</div>
)}
</div>
{view === VIEW.SERVICES && (
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<ButtonGroup
variant="outlined"
aria-label="Basic button group"
sx={{ marginTop: "10px", marginRight: "30px" }}
>
<IconButton onClick={() => setViewMode("card")}>
<GridViewOutlinedIcon
sx={{
color:
viewMode === "card" ? "var(--wine-red)" : "ineherit",
}}
/>
</IconButton>
<IconButton onClick={() => setViewMode("grid")}>
<SplitscreenOutlinedIcon
sx={{
color:
viewMode === "grid" ? "var(--wine-red)" : "ineherit",
}}
/>
</IconButton>
</ButtonGroup>
</div>
)}
</div>
<div>
<div>
{view === VIEW.POSTS && (
<div
ref={refPostsContainer}
@@ -867,7 +871,16 @@ export default function OrganizationPublicProfile() {
</div>
)}
</div>
</div>
</div>
</div>
<ReviewsCarousel
targetType={RatingTargetType.COMPANY}
targetId={orgData.id}
title="Lo que dicen quienes ya visitaron esta organización"
subtitle="Opiniones reales de personas que reservaron turnos acá."
className={style.organizationReviews}
/>
</div>
)}
</>
@@ -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%;
}
}
@@ -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<Record<string, string>>({});
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() {
<div className={styles.heroPanel}>
<span className={styles.panelLabel}>Tu aporte</span>
<strong>{isLoading ? "..." : pendingRatings.length}</strong>
<span>{pendingRatings.length === 1 ? "experiencia pendiente" : "experiencias pendientes"}</span>
<strong>{isLoading ? "..." : createdRatingsCount}</strong>
<span>{createdRatingsCount === 1 ? "opinión realizada" : "opiniones realizadas"}</span>
</div>
<div className={styles.heroStats} aria-label="Resumen de calificaciones pendientes">
@@ -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<PublicOrganizationServiceView> => {
try {
@@ -80,6 +82,12 @@ export default async function OrganizationPublicProfile({ params }: Props) {
title="Otros Servicios &#x1F3C6;"
data={organizationService.organization.services.data}
/>
<ReviewsCarousel
targetType={RatingTargetType.SERVICE}
targetId={sid}
title="Lo que dicen quienes ya reservaron"
subtitle="Comentarios de clientes que calificaron este servicio después de su turno."
/>
</div>
</AnimatedContainer>
)}