feat: add public employee profile view and implement ratings review page components
This commit is contained in:
+114
@@ -0,0 +1,114 @@
|
||||
.professionals {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
margin: 4px 0 30px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex: 0 0 min(300px, calc(100vw - 40px));
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-height: 116px;
|
||||
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);
|
||||
color: inherit;
|
||||
scroll-snap-align: start;
|
||||
text-decoration: none;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: rgba(116, 35, 59, 0.28);
|
||||
box-shadow: 0 14px 30px rgba(45, 29, 21, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.cardBody {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cardBody h3 {
|
||||
overflow: hidden;
|
||||
margin: 0 0 10px;
|
||||
color: #342d27;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rating {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stars {
|
||||
color: #e8a533;
|
||||
font-size: 1.25rem;
|
||||
letter-spacing: 0.06em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.score {
|
||||
color: #342d27;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.emptyRating {
|
||||
margin: 0;
|
||||
color: #8b8178;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.card {
|
||||
flex-basis: calc(100vw - 40px);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type PointerEvent, type ReactElement } from "react";
|
||||
import classNames from "classnames";
|
||||
import Link from "next/link";
|
||||
import Avatar from "@components/Avatar/Avatar";
|
||||
import { PublicCompanyEmployeeView } from "@core/Models/Employees.model";
|
||||
import { RatingTargetSummaryResult, RatingTargetType } from "@core/Models/Ratings.model";
|
||||
import { findPublicCompanyEmployees } from "@services/Employees.Service";
|
||||
import { ratingTargetSummary } from "@services/Ratings.Service";
|
||||
import style from "./OrganizationProfessionals.module.css";
|
||||
|
||||
interface OrganizationProfessionalsProps {
|
||||
companyId: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type ProfessionalWithRating = PublicCompanyEmployeeView & {
|
||||
rating: RatingTargetSummaryResult | null;
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
const getFullName = (professional: PublicCompanyEmployeeView): string => {
|
||||
return [professional.firstName, professional.lastName].filter(Boolean).join(" ").trim();
|
||||
};
|
||||
|
||||
export default function OrganizationProfessionals({
|
||||
companyId,
|
||||
title = "Profesionales de la organización",
|
||||
subtitle = "Conocé a quienes atienden y sus opiniones.",
|
||||
className,
|
||||
}: OrganizationProfessionalsProps): ReactElement | null {
|
||||
const [professionals, setProfessionals] = useState<ProfessionalWithRating[]>([]);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
findPublicCompanyEmployees({ companyId })
|
||||
.then(async (employees) => {
|
||||
const employeesWithRatings = await Promise.all(
|
||||
employees.map(async (employee) => {
|
||||
try {
|
||||
const rating = await ratingTargetSummary({
|
||||
targetType: RatingTargetType.EMPLOYEE,
|
||||
targetId: employee.employeeId,
|
||||
});
|
||||
|
||||
return { ...employee, rating };
|
||||
} catch {
|
||||
return { ...employee, rating: null };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (active) {
|
||||
setProfessionals(employeesWithRatings);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) {
|
||||
setProfessionals([]);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [companyId]);
|
||||
|
||||
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 (professionals.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={classNames(style.professionals, className)} aria-label="Profesionales">
|
||||
<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}
|
||||
>
|
||||
{professionals.map((professional) => {
|
||||
const fullName = getFullName(professional) || "Profesional";
|
||||
const hasRating = Boolean(professional.rating && professional.rating.totalCount > 0);
|
||||
|
||||
return (
|
||||
<Link
|
||||
className={style.card}
|
||||
href={`/landing/reviews?targetType=employee&targetId=${professional.employeeId}`}
|
||||
key={professional.employeeId}
|
||||
>
|
||||
<Avatar
|
||||
name={`professional-${professional.employeeId}`}
|
||||
src={professional.avatarUrl || fullName}
|
||||
alt={`Avatar de ${fullName}`}
|
||||
size="medium"
|
||||
border="none"
|
||||
/>
|
||||
<div className={style.cardBody}>
|
||||
<h3>{fullName}</h3>
|
||||
{hasRating ? (
|
||||
<div
|
||||
className={style.rating}
|
||||
aria-label={`${formatScore(professional.rating!.averageScore)} de 5 estrellas`}
|
||||
>
|
||||
<span className={style.stars} aria-hidden="true">
|
||||
{renderStars(professional.rating!.averageScore)}
|
||||
</span>
|
||||
<span className={style.score}>
|
||||
{formatScore(professional.rating!.averageScore)}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className={style.emptyRating}>Sin opiniones aún</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,13 @@
|
||||
margin: 0 10px 5px;
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
padding-bottom: 10px;
|
||||
@@ -17,6 +24,19 @@
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.viewMore {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 4px;
|
||||
color: var(--wine-red);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.viewMore:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.header p {
|
||||
max-width: 620px;
|
||||
margin: 0 0 10px;
|
||||
@@ -109,6 +129,10 @@
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.titleRow {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.reviewCard {
|
||||
flex-basis: calc(100vw - 40px);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState, type PointerEvent, type ReactElement } from "react";
|
||||
import classNames from "classnames";
|
||||
import Link from "next/link";
|
||||
import Avatar from "@components/Avatar/Avatar";
|
||||
import { RatingTargetReviewItem, RatingTargetType } from "@core/Models/Ratings.model";
|
||||
import { ratingTargetReviews } from "@services/Ratings.Service";
|
||||
@@ -58,9 +59,9 @@ export default function ReviewsCarousel({
|
||||
let active = true;
|
||||
|
||||
ratingTargetReviews({ targetType, targetId, limit })
|
||||
.then((targetReviews) => {
|
||||
.then((targetReviewsResult) => {
|
||||
if (active) {
|
||||
setReviews(sortReviewsByScore(targetReviews.filter((review) => review.comment.trim().length > 0)));
|
||||
setReviews(sortReviewsByScore(targetReviewsResult.reviews.filter((review) => review.comment.trim().length > 0)));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -99,7 +100,12 @@ export default function ReviewsCarousel({
|
||||
return (
|
||||
<section className={classNames(style.reviewsCarousel, className)} aria-label="Opiniones">
|
||||
<div className={style.header}>
|
||||
<h2>{title}</h2>
|
||||
<div className={style.titleRow}>
|
||||
<h2>{title}</h2>
|
||||
<Link className={style.viewMore} href={`/landing/reviews?targetType=${targetType}&targetId=${targetId}`}>
|
||||
Ver más
|
||||
</Link>
|
||||
</div>
|
||||
{subtitle && <p>{subtitle}</p>}
|
||||
</div>
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user