feat: add public employee profile view and implement ratings review page components
This commit is contained in:
@@ -62,6 +62,7 @@ export default function OrganizationEdit() {
|
||||
const [orgSlug, setOrgSlug] = useState("");
|
||||
const [showPublicScores, setShowPublicScores] = useState(true);
|
||||
const [showPublicOpinions, setShowPublicOpinions] = useState(true);
|
||||
const [showPublicProfessionals, setShowPublicProfessionals] = useState(true);
|
||||
const [servicesShowPublicScores, setServicesShowPublicScores] = useState(true);
|
||||
const [servicesShowPublicOpinions, setServicesShowPublicOpinions] = useState(true);
|
||||
const [originalServicesShowPublicScores, setOriginalServicesShowPublicScores] = useState(true);
|
||||
@@ -76,7 +77,7 @@ export default function OrganizationEdit() {
|
||||
|
||||
const [combosLoaded, setCombosLoaded] = useState<boolean>(false);
|
||||
const ownerSubscription = SessionInfo.organizationSubscriptions[id];
|
||||
const canEditPublicRatingFlags = (ownerSubscription?.plan.price || 0) > 0;
|
||||
const canEditPublicVisibilityFlags = (ownerSubscription?.plan.price || 0) > 0;
|
||||
|
||||
const [categoryFilterContent] = useState<TextObjectModalContent>({
|
||||
header: (
|
||||
@@ -242,6 +243,11 @@ export default function OrganizationEdit() {
|
||||
setOrgPublished(organization.published);
|
||||
setShowPublicScores(organization.showPublicScores !== undefined ? organization.showPublicScores : true);
|
||||
setShowPublicOpinions(organization.showPublicOpinions !== undefined ? organization.showPublicOpinions : true);
|
||||
setShowPublicProfessionals(
|
||||
organization.showPublicProfessionals !== undefined
|
||||
? organization.showPublicProfessionals
|
||||
: true
|
||||
);
|
||||
setServices(loadedServices);
|
||||
setServicesShowPublicScores(
|
||||
loadedServices.every((service) => service.showPublicScores !== false)
|
||||
@@ -302,10 +308,11 @@ export default function OrganizationEdit() {
|
||||
templateWapNotifId: templateWapNotifId === "" ? undefined : templateWapNotifId,
|
||||
templateWapAltaId: templateWapAltaId === "" ? undefined : templateWapAltaId,
|
||||
templateWapCancellationId: templateWapCancellationId === "" ? undefined : templateWapCancellationId,
|
||||
...(canEditPublicRatingFlags
|
||||
...(canEditPublicVisibilityFlags
|
||||
? {
|
||||
showPublicScores,
|
||||
showPublicOpinions,
|
||||
showPublicProfessionals,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
@@ -378,7 +385,7 @@ export default function OrganizationEdit() {
|
||||
const servicesShowPublicOpinionsChanged =
|
||||
originalServicesShowPublicOpinions !== servicesShowPublicOpinions;
|
||||
const changedServices =
|
||||
canEditPublicRatingFlags &&
|
||||
canEditPublicVisibilityFlags &&
|
||||
(servicesShowPublicScoresChanged || servicesShowPublicOpinionsChanged)
|
||||
? services
|
||||
: [];
|
||||
@@ -574,13 +581,18 @@ export default function OrganizationEdit() {
|
||||
/>
|
||||
|
||||
<MaterialAlert severity="info" sx={{ marginTop: "20px" }}>
|
||||
<b>Visibilidad pública:</b> Elegí si querés mostrar puntuaciones y opiniones en el perfil público y las páginas de reserva. Disponible para planes pagos.
|
||||
<b>Visibilidad pública:</b> Elegí si querés mostrar puntuaciones, opiniones y profesionales en el perfil público y las páginas de reserva. Disponible para planes pagos.
|
||||
</MaterialAlert>
|
||||
{!canEditPublicVisibilityFlags && (
|
||||
<MaterialAlert severity="warning" sx={{ marginTop: "10px" }}>
|
||||
Para modificar las opciones de visibilidad pública de opiniones, puntuaciones y profesionales de esta organización y sus servicios, el propietario de la organización necesita un plan pago.
|
||||
</MaterialAlert>
|
||||
)}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "10px", marginTop: "10px" }}>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}>
|
||||
<Checkbox
|
||||
checked={showPublicScores}
|
||||
disabled={!canEditPublicRatingFlags}
|
||||
disabled={!canEditPublicVisibilityFlags}
|
||||
onChange={(event) => {
|
||||
setShowPublicScores(event.target.checked);
|
||||
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
|
||||
@@ -591,7 +603,7 @@ export default function OrganizationEdit() {
|
||||
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}>
|
||||
<Checkbox
|
||||
checked={showPublicOpinions}
|
||||
disabled={!canEditPublicRatingFlags}
|
||||
disabled={!canEditPublicVisibilityFlags}
|
||||
onChange={(event) => {
|
||||
setShowPublicOpinions(event.target.checked);
|
||||
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
|
||||
@@ -599,7 +611,18 @@ export default function OrganizationEdit() {
|
||||
/>
|
||||
Mostrar opiniones públicas
|
||||
</label>
|
||||
{!canEditPublicRatingFlags && (
|
||||
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}>
|
||||
<Checkbox
|
||||
checked={showPublicProfessionals}
|
||||
disabled={!canEditPublicVisibilityFlags}
|
||||
onChange={(event) => {
|
||||
setShowPublicProfessionals(event.target.checked);
|
||||
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
|
||||
}}
|
||||
/>
|
||||
Mostrar profesionales en el perfil público
|
||||
</label>
|
||||
{!canEditPublicVisibilityFlags && (
|
||||
<span style={{ color: "var(--gray-darkest)", fontSize: "13px" }}>
|
||||
Disponible para planes pagos.
|
||||
</span>
|
||||
@@ -622,7 +645,7 @@ export default function OrganizationEdit() {
|
||||
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}>
|
||||
<Checkbox
|
||||
checked={servicesShowPublicScores}
|
||||
disabled={!canEditPublicRatingFlags}
|
||||
disabled={!canEditPublicVisibilityFlags}
|
||||
onChange={(event) => {
|
||||
setServicesShowPublicScores(event.target.checked);
|
||||
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
|
||||
@@ -633,7 +656,7 @@ export default function OrganizationEdit() {
|
||||
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}>
|
||||
<Checkbox
|
||||
checked={servicesShowPublicOpinions}
|
||||
disabled={!canEditPublicRatingFlags}
|
||||
disabled={!canEditPublicVisibilityFlags}
|
||||
onChange={(event) => {
|
||||
setServicesShowPublicOpinions(event.target.checked);
|
||||
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
|
||||
@@ -641,7 +664,7 @@ export default function OrganizationEdit() {
|
||||
/>
|
||||
Mostrar opiniones públicas de los servicios
|
||||
</label>
|
||||
{!canEditPublicRatingFlags && (
|
||||
{!canEditPublicVisibilityFlags && (
|
||||
<span style={{ color: "var(--gray-darkest)", fontSize: "13px" }}>
|
||||
Para cambiar la visibilidad pública de los servicios necesitás un plan pago.
|
||||
</span>
|
||||
|
||||
+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
|
||||
|
||||
@@ -59,6 +59,7 @@ 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 OrganizationProfessionals from "@core/app/components/OrganizationProfessionals/OrganizationProfessionals";
|
||||
import { RatingTargetType } from "@core/Models/Ratings.model";
|
||||
|
||||
enum VIEW {
|
||||
@@ -337,7 +338,7 @@ export default function OrganizationPublicProfile() {
|
||||
return response.data;
|
||||
}
|
||||
const newPosts = response.data.filter(
|
||||
(newPost) => !prevPosts.some((prevPost) => prevPost._id === newPost._id)
|
||||
(newPost) => !prevPosts.some((prevPost) => prevPost._id === newPost._id),
|
||||
);
|
||||
|
||||
return [...prevPosts, ...newPosts];
|
||||
@@ -453,7 +454,7 @@ export default function OrganizationPublicProfile() {
|
||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||
});
|
||||
},
|
||||
() => {}
|
||||
() => {},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -597,292 +598,309 @@ export default function OrganizationPublicProfile() {
|
||||
<div className={style.organizationProfileColumns}>
|
||||
<div className={style.organizationMap}>{LocationInfo(orgData)}</div>
|
||||
<div className={style.organizationMainColumn}>
|
||||
<div
|
||||
ref={refDivContent}
|
||||
style={{
|
||||
border: "1px solid var(--gray-light)",
|
||||
borderRadius: "10px",
|
||||
marginTop: "30px",
|
||||
width: "100%",
|
||||
marginBottom: "30px",
|
||||
backgroundColor: "var(--white)",
|
||||
}}
|
||||
>
|
||||
<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",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={getHeaderItemStyle(VIEW.POSTS)}
|
||||
onClick={() => {
|
||||
setView(VIEW.POSTS);
|
||||
processAddButton(VIEW.POSTS);
|
||||
}}
|
||||
>
|
||||
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>
|
||||
<div>
|
||||
{view === VIEW.POSTS && (
|
||||
<div
|
||||
ref={refPostsContainer}
|
||||
style={{
|
||||
overflowY: "scroll",
|
||||
height: `${getContentHeight()}px`,
|
||||
}}
|
||||
>
|
||||
{showAddButton && (
|
||||
<div
|
||||
style={getAddButtonStyle()}
|
||||
onClick={() => {
|
||||
setEditorPostId("");
|
||||
setEditorContent("");
|
||||
setShowEditor(true);
|
||||
}}
|
||||
>
|
||||
<AddIcon sx={{ fontSize: "32px" }} />
|
||||
</div>
|
||||
)}
|
||||
{posts.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
backgroundImage: "url(/no-conversation.webp)",
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundPosition: "center center",
|
||||
backgroundSize: "50% auto",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
Nada por aquí
|
||||
</div>
|
||||
)}
|
||||
{posts.map((post: IPost) => {
|
||||
return (
|
||||
<div
|
||||
key={post._id}
|
||||
style={{
|
||||
borderBottom: "1px solid var(--gray-light)",
|
||||
paddingTop: "5px",
|
||||
paddingBottom: "10px",
|
||||
paddingLeft: "30px",
|
||||
paddingRight: "30px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div style={{ color: "var(--gray-darkest)" }}>
|
||||
{dayjs(post.createdAt).format("DD MMMM YYYY HH:mm")}
|
||||
</div>
|
||||
{post.fixed && (
|
||||
<div
|
||||
style={{
|
||||
color: "var(--gray-light)",
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<PinIcon fontSize="small" />
|
||||
<span>Anclado</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: preprocessQuillHtml(
|
||||
transformQuillContentForDisplay(post.content)
|
||||
),
|
||||
}}
|
||||
/>
|
||||
{checkPostButtons() && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "10px",
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
flexWrap: "wrap",
|
||||
color: "var(--gray-darkest)",
|
||||
}}
|
||||
>
|
||||
<ThemeProvider theme={turnosXpressTheme}>
|
||||
<MaterialButton
|
||||
color="inherit"
|
||||
startIcon={<ClipIcon />}
|
||||
onClick={() => {
|
||||
fijarPost(`${post._id}`);
|
||||
}}
|
||||
>
|
||||
{post.fixed ? "Liberar" : "Fijar"}
|
||||
</MaterialButton>
|
||||
|
||||
{post.fixed && (
|
||||
<>
|
||||
<MaterialButton
|
||||
color="inherit"
|
||||
startIcon={<UpIcon />}
|
||||
onClick={() => {
|
||||
moverPost(`${post._id}`, -1);
|
||||
}}
|
||||
>
|
||||
Subir
|
||||
</MaterialButton>
|
||||
|
||||
<MaterialButton
|
||||
color="inherit"
|
||||
startIcon={<DownIcon />}
|
||||
onClick={() => {
|
||||
moverPost(`${post._id}`, 1);
|
||||
}}
|
||||
>
|
||||
Bajar
|
||||
</MaterialButton>
|
||||
</>
|
||||
)}
|
||||
|
||||
<MaterialButton
|
||||
color="inherit"
|
||||
onClick={() => {
|
||||
editarPost(`${post._id}`);
|
||||
}}
|
||||
>
|
||||
<EditIcon />
|
||||
</MaterialButton>
|
||||
|
||||
<MaterialButton
|
||||
color="inherit"
|
||||
onClick={() => {
|
||||
eliminarPost(`${post._id}`);
|
||||
}}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</MaterialButton>
|
||||
</ThemeProvider>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{view === VIEW.SERVICES && (
|
||||
<div
|
||||
className={style.organizationServices}
|
||||
style={{
|
||||
overflowY: "scroll",
|
||||
height: `${getContentHeight()}px`,
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
borderBottom: "1px solid var(--gray-light)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: "0px 0px 40px 0px",
|
||||
height: "50px",
|
||||
display: "flex",
|
||||
flexDirection: viewMode === "card" ? "row" : "column",
|
||||
justifyContent: "start",
|
||||
alignItems: "center",
|
||||
gap: "20px",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
{orgData.services.data.map((service) => (
|
||||
<ProductItem
|
||||
product={service}
|
||||
key={service.id}
|
||||
width={260}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
style={getHeaderItemStyle(VIEW.POSTS)}
|
||||
onClick={() => {
|
||||
setView(VIEW.POSTS);
|
||||
processAddButton(VIEW.POSTS);
|
||||
}}
|
||||
>
|
||||
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.INFO && checkShowInfo() && (
|
||||
<div
|
||||
className={style.organizationMoreInfo}
|
||||
style={{
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{LocationInfo(orgData)}
|
||||
<div>
|
||||
{view === VIEW.POSTS && (
|
||||
<div
|
||||
ref={refPostsContainer}
|
||||
style={{
|
||||
overflowY: "scroll",
|
||||
height: `${getContentHeight()}px`,
|
||||
}}
|
||||
>
|
||||
{showAddButton && (
|
||||
<div
|
||||
style={getAddButtonStyle()}
|
||||
onClick={() => {
|
||||
setEditorPostId("");
|
||||
setEditorContent("");
|
||||
setShowEditor(true);
|
||||
}}
|
||||
>
|
||||
<AddIcon sx={{ fontSize: "32px" }} />
|
||||
</div>
|
||||
)}
|
||||
{posts.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
backgroundImage: "url(/no-conversation.webp)",
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundPosition: "center center",
|
||||
backgroundSize: "50% auto",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
Nada por aquí
|
||||
</div>
|
||||
)}
|
||||
{posts.map((post: IPost) => {
|
||||
return (
|
||||
<div
|
||||
key={post._id}
|
||||
style={{
|
||||
borderBottom: "1px solid var(--gray-light)",
|
||||
paddingTop: "5px",
|
||||
paddingBottom: "10px",
|
||||
paddingLeft: "30px",
|
||||
paddingRight: "30px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div style={{ color: "var(--gray-darkest)" }}>
|
||||
{dayjs(post.createdAt).format(
|
||||
"DD MMMM YYYY HH:mm",
|
||||
)}
|
||||
</div>
|
||||
{post.fixed && (
|
||||
<div
|
||||
style={{
|
||||
color: "var(--gray-light)",
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<PinIcon fontSize="small" />
|
||||
<span>Anclado</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: preprocessQuillHtml(
|
||||
transformQuillContentForDisplay(
|
||||
post.content,
|
||||
),
|
||||
),
|
||||
}}
|
||||
/>
|
||||
{checkPostButtons() && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "10px",
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
flexWrap: "wrap",
|
||||
color: "var(--gray-darkest)",
|
||||
}}
|
||||
>
|
||||
<ThemeProvider theme={turnosXpressTheme}>
|
||||
<MaterialButton
|
||||
color="inherit"
|
||||
startIcon={<ClipIcon />}
|
||||
onClick={() => {
|
||||
fijarPost(`${post._id}`);
|
||||
}}
|
||||
>
|
||||
{post.fixed ? "Liberar" : "Fijar"}
|
||||
</MaterialButton>
|
||||
|
||||
{post.fixed && (
|
||||
<>
|
||||
<MaterialButton
|
||||
color="inherit"
|
||||
startIcon={<UpIcon />}
|
||||
onClick={() => {
|
||||
moverPost(
|
||||
`${post._id}`,
|
||||
-1,
|
||||
);
|
||||
}}
|
||||
>
|
||||
Subir
|
||||
</MaterialButton>
|
||||
|
||||
<MaterialButton
|
||||
color="inherit"
|
||||
startIcon={<DownIcon />}
|
||||
onClick={() => {
|
||||
moverPost(
|
||||
`${post._id}`,
|
||||
1,
|
||||
);
|
||||
}}
|
||||
>
|
||||
Bajar
|
||||
</MaterialButton>
|
||||
</>
|
||||
)}
|
||||
|
||||
<MaterialButton
|
||||
color="inherit"
|
||||
onClick={() => {
|
||||
editarPost(`${post._id}`);
|
||||
}}
|
||||
>
|
||||
<EditIcon />
|
||||
</MaterialButton>
|
||||
|
||||
<MaterialButton
|
||||
color="inherit"
|
||||
onClick={() => {
|
||||
eliminarPost(`${post._id}`);
|
||||
}}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</MaterialButton>
|
||||
</ThemeProvider>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{view === VIEW.SERVICES && (
|
||||
<div
|
||||
className={style.organizationServices}
|
||||
style={{
|
||||
overflowY: "scroll",
|
||||
height: `${getContentHeight()}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: "0px 0px 40px 0px",
|
||||
display: "flex",
|
||||
flexDirection: viewMode === "card" ? "row" : "column",
|
||||
justifyContent: "start",
|
||||
gap: "20px",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
{orgData.services.data.map((service) => (
|
||||
<ProductItem
|
||||
product={service}
|
||||
key={service.id}
|
||||
width={260}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{view === VIEW.INFO && checkShowInfo() && (
|
||||
<div
|
||||
className={style.organizationMoreInfo}
|
||||
style={{
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{LocationInfo(orgData)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{orgData.showPublicOpinions !== false && (
|
||||
<ReviewsCarousel
|
||||
targetType={RatingTargetType.COMPANY}
|
||||
targetId={orgData.id}
|
||||
title="Lo que dicen quienes ya visitaron esta organización"
|
||||
title={`Opiniones sobre ${orgData.name}`}
|
||||
subtitle="Opiniones reales de personas que reservaron turnos acá."
|
||||
className={style.organizationReviews}
|
||||
/>
|
||||
)}
|
||||
{orgData.showPublicProfessionals !== false && (
|
||||
<OrganizationProfessionals companyId={orgData.id} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
.page {
|
||||
width: min(1080px, calc(100% - 32px));
|
||||
min-height: 70vh;
|
||||
margin: 0 auto;
|
||||
padding: 44px 0 64px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: 92px minmax(0, 1fr);
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
margin-bottom: 28px;
|
||||
border: 1px solid rgba(127, 42, 255, 0.14);
|
||||
border-radius: 28px;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(255, 42, 127, 0.08), transparent 36%),
|
||||
linear-gradient(180deg, var(--white), var(--white-dark));
|
||||
box-shadow: 0 26px 70px rgba(16, 0, 39, 0.12);
|
||||
}
|
||||
|
||||
.heroRail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
padding: 24px 14px;
|
||||
border-radius: 26px 0 0 26px;
|
||||
background: linear-gradient(180deg, var(--wine-darkest), var(--wine-dark));
|
||||
color: var(--white);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.heroRail span:last-child {
|
||||
color: var(--yellow);
|
||||
writing-mode: vertical-rl;
|
||||
}
|
||||
|
||||
.heroContent {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(220px, 280px);
|
||||
align-items: center;
|
||||
gap: clamp(22px, 4vw, 44px);
|
||||
padding: clamp(26px, 5vw, 50px);
|
||||
}
|
||||
|
||||
.copyBlock {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 0 14px;
|
||||
padding: 7px 12px;
|
||||
border: 1px solid rgba(255, 42, 127, 0.18);
|
||||
border-radius: 999px;
|
||||
background: var(--yellow-light);
|
||||
color: var(--wine-red);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.targetLabel {
|
||||
display: inline-flex;
|
||||
margin: 0 0 10px;
|
||||
padding: 6px 11px;
|
||||
border: 1px solid rgba(127, 42, 255, 0.16);
|
||||
border-radius: 999px;
|
||||
background: var(--white);
|
||||
color: var(--wine-dark);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.professionalHero {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.professionalIdentity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: clamp(14px, 3vw, 24px);
|
||||
}
|
||||
|
||||
.targetAvatar {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
padding: 5px;
|
||||
border: 1px solid rgba(255, 42, 127, 0.2);
|
||||
border-radius: 50%;
|
||||
background: var(--white);
|
||||
box-shadow: 0 14px 32px rgba(16, 0, 39, 0.12);
|
||||
}
|
||||
|
||||
.targetTitleBlock {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.professionalText {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
max-width: 760px;
|
||||
margin: 0 0 12px;
|
||||
color: var(--black);
|
||||
font-size: clamp(2.05rem, 5vw, 3.75rem);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.055em;
|
||||
}
|
||||
|
||||
.professionalIdentity h1 {
|
||||
max-width: 600px;
|
||||
font-size: clamp(1.9rem, 4.3vw, 3.35rem);
|
||||
}
|
||||
|
||||
.professionalText p {
|
||||
max-width: 540px;
|
||||
font-size: clamp(0.98rem, 1.7vw, 1.12rem);
|
||||
line-height: 1.58;
|
||||
}
|
||||
|
||||
.hero p:last-child {
|
||||
max-width: 660px;
|
||||
margin: 0;
|
||||
color: var(--black-light);
|
||||
font-size: clamp(1.03rem, 2vw, 1.22rem);
|
||||
line-height: 1.62;
|
||||
}
|
||||
|
||||
.summaryCard {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 9px;
|
||||
min-height: 220px;
|
||||
padding: 24px;
|
||||
border: 1px solid rgba(127, 42, 255, 0.13);
|
||||
border-radius: 24px;
|
||||
background:
|
||||
linear-gradient(180deg, var(--white), rgba(253, 228, 84, 0.16));
|
||||
box-shadow: 0 18px 42px rgba(16, 0, 39, 0.1);
|
||||
}
|
||||
|
||||
.summaryLabel {
|
||||
color: var(--gray-dark);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.summaryScore {
|
||||
color: var(--wine-red);
|
||||
font-size: 4rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.06em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.summaryStars {
|
||||
color: #e8a533;
|
||||
font-size: 1.15rem;
|
||||
letter-spacing: 0.07em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.summaryText {
|
||||
color: var(--black-light);
|
||||
font-size: 0.92rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.state {
|
||||
padding: 24px;
|
||||
border: 1px solid rgba(45, 29, 21, 0.08);
|
||||
border-radius: 22px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: #4d443d;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.listHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.listHeader span {
|
||||
color: #8b8178;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.resultsCount {
|
||||
margin: 0;
|
||||
color: #6d6258;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.reviewsList {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.reviewCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
border: 1px solid rgba(45, 29, 21, 0.1);
|
||||
border-radius: 24px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(255, 252, 249, 0.94));
|
||||
box-shadow: 0 14px 32px rgba(45, 29, 21, 0.07);
|
||||
transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease;
|
||||
}
|
||||
|
||||
.reviewCard:hover {
|
||||
border-color: rgba(141, 24, 44, 0.18);
|
||||
box-shadow: 0 18px 42px rgba(45, 29, 21, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.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 {
|
||||
margin: 0;
|
||||
color: #2f2924;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.66;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding-top: 4px;
|
||||
color: #8b8178;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 32px;
|
||||
padding: 18px;
|
||||
border: 1px solid rgba(45, 29, 21, 0.08);
|
||||
border-radius: 22px;
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
color: #6d6258;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.page {
|
||||
width: min(100% - 20px, 960px);
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.heroRail {
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
padding: 14px 18px;
|
||||
border-radius: 26px 26px 0 0;
|
||||
}
|
||||
|
||||
.heroRail span:last-child {
|
||||
writing-mode: initial;
|
||||
}
|
||||
|
||||
.heroContent {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.summaryCard {
|
||||
max-width: none;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.professionalIdentity {
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.professionalHero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.professionalText {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.listHeader {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect, useState, type ChangeEvent, type ReactElement } from "react";
|
||||
import Avatar from "@components/Avatar/Avatar";
|
||||
import ApiRequest from "@services/Api.Service";
|
||||
import { PublicOrganizationView } from "@core/Models/Company.model";
|
||||
import { RatingTargetReviewItem, RatingTargetType } from "@core/Models/Ratings.model";
|
||||
import { PublicOrganizationServiceView } from "@core/Models/Service.model";
|
||||
import { findPublicEmployee } from "@services/Employees.Service";
|
||||
import { ratingTargetReviews, ratingTargetSummary } from "@services/Ratings.Service";
|
||||
import Pagination from "@mui/material/Pagination";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import style from "./page.module.css";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
const OBJECT_ID_PATTERN = /^[a-f\d]{24}$/i;
|
||||
|
||||
const formatReviewDate = (date: string): string => {
|
||||
return new Intl.DateTimeFormat("es-AR", {
|
||||
day: "numeric",
|
||||
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);
|
||||
|
||||
type TargetContext = {
|
||||
label: string;
|
||||
name: string;
|
||||
description: string;
|
||||
avatarUrl?: string;
|
||||
};
|
||||
|
||||
const TARGET_FALLBACKS: Record<RatingTargetType, TargetContext> = {
|
||||
[RatingTargetType.COMPANY]: {
|
||||
label: "Organización",
|
||||
name: "esta organización",
|
||||
description: "Opiniones sobre la experiencia general con la organización.",
|
||||
},
|
||||
[RatingTargetType.SERVICE]: {
|
||||
label: "Servicio",
|
||||
name: "este servicio",
|
||||
description: "Opiniones sobre un servicio específico reservado en TurnosXpress.",
|
||||
},
|
||||
[RatingTargetType.EMPLOYEE]: {
|
||||
label: "Profesional",
|
||||
name: "este profesional",
|
||||
description: "Opiniones sobre la atención recibida con este profesional.",
|
||||
},
|
||||
};
|
||||
|
||||
const isValidTargetType = (value: string | null): value is RatingTargetType => {
|
||||
return Boolean(value && Object.values(RatingTargetType).includes(value as RatingTargetType));
|
||||
};
|
||||
|
||||
const resolveTargetContext = async (targetType: RatingTargetType, targetId: string): Promise<TargetContext> => {
|
||||
const fallback = TARGET_FALLBACKS[targetType];
|
||||
|
||||
if (targetType === RatingTargetType.COMPANY) {
|
||||
const organization = await ApiRequest.post<PublicOrganizationView>("views/organization", { companyId: targetId });
|
||||
|
||||
return {
|
||||
...fallback,
|
||||
name: organization.name || fallback.name,
|
||||
description: organization.description || fallback.description,
|
||||
};
|
||||
}
|
||||
|
||||
if (targetType === RatingTargetType.SERVICE) {
|
||||
const view = await ApiRequest.post<PublicOrganizationServiceView>("views/service", { serviceId: targetId });
|
||||
|
||||
return {
|
||||
...fallback,
|
||||
name: view.service?.name || fallback.name,
|
||||
description: view.organization?.name ? `Servicio ofrecido por ${view.organization.name}.` : fallback.description,
|
||||
};
|
||||
}
|
||||
|
||||
if (targetType === RatingTargetType.EMPLOYEE) {
|
||||
const employee = await findPublicEmployee({ employeeId: targetId });
|
||||
const fullName = [employee.firstName, employee.lastName].filter(Boolean).join(" ").trim();
|
||||
|
||||
return {
|
||||
...fallback,
|
||||
name: fullName || fallback.name,
|
||||
avatarUrl: employee.avatarUrl,
|
||||
};
|
||||
}
|
||||
|
||||
return fallback;
|
||||
};
|
||||
|
||||
function ReviewsContent(): ReactElement {
|
||||
const searchParams = useSearchParams();
|
||||
const targetType = searchParams.get("targetType");
|
||||
const targetId = searchParams.get("targetId");
|
||||
|
||||
const [reviews, setReviews] = useState<RatingTargetReviewItem[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [totalRatings, setTotalRatings] = useState(0);
|
||||
const [averageScore, setAverageScore] = useState(0);
|
||||
const [targetContext, setTargetContext] = useState<TargetContext | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isContextLoading, setIsContextLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const hasValidTarget = isValidTargetType(targetType) && Boolean(targetId && OBJECT_ID_PATTERN.test(targetId));
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [targetType, targetId]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
if (!hasValidTarget) {
|
||||
setReviews([]);
|
||||
setTotalPages(0);
|
||||
setTotal(0);
|
||||
setTotalRatings(0);
|
||||
setAverageScore(0);
|
||||
setTargetContext(null);
|
||||
setError("No encontramos una referencia válida para mostrar opiniones.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setIsContextLoading(true);
|
||||
setError("");
|
||||
|
||||
const validTargetType = targetType as RatingTargetType;
|
||||
const validTargetId = targetId || "";
|
||||
|
||||
resolveTargetContext(validTargetType, validTargetId)
|
||||
.then((context) => {
|
||||
if (active) {
|
||||
setTargetContext(context);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) {
|
||||
setTargetContext(TARGET_FALLBACKS[validTargetType]);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) {
|
||||
setIsContextLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
ratingTargetSummary({ targetType: validTargetType, targetId: validTargetId })
|
||||
.then((summary) => {
|
||||
if (active) {
|
||||
setAverageScore(summary.averageScore);
|
||||
setTotalRatings(summary.totalCount);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) {
|
||||
setAverageScore(0);
|
||||
setTotalRatings(0);
|
||||
}
|
||||
});
|
||||
|
||||
ratingTargetReviews({ targetType: validTargetType, targetId: validTargetId, page, pageSize: PAGE_SIZE })
|
||||
.then((result) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
setReviews(result.reviews.filter((review) => review.comment.trim().length > 0));
|
||||
setTotalPages(result.totalPages);
|
||||
setTotal(result.total);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
setReviews([]);
|
||||
setTotalPages(0);
|
||||
setTotal(0);
|
||||
setError("No pudimos cargar las opiniones. Intentá nuevamente en unos minutos.");
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [hasValidTarget, page, targetId, targetType]);
|
||||
|
||||
const handlePageChange = (_event: ChangeEvent<unknown>, value: number) => {
|
||||
setPage(value);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
};
|
||||
|
||||
const visibleContext = targetContext || (isValidTargetType(targetType) ? TARGET_FALLBACKS[targetType] : null);
|
||||
const heroTitle = isContextLoading ? "Cargando contexto..." : visibleContext?.name || "Opiniones verificadas";
|
||||
const reviewLabel = totalRatings === 1 ? "opinión" : "opiniones";
|
||||
|
||||
return (
|
||||
<main className={style.page}>
|
||||
<section className={style.hero}>
|
||||
<div className={style.heroRail} aria-hidden="true">
|
||||
<span>TX</span>
|
||||
<span>Reviews</span>
|
||||
</div>
|
||||
<div className={style.heroContent}>
|
||||
<div className={style.copyBlock}>
|
||||
<p className={style.kicker}>Opiniones verificadas</p>
|
||||
{targetType === RatingTargetType.EMPLOYEE && visibleContext ? (
|
||||
<div className={style.professionalHero}>
|
||||
<div className={style.professionalIdentity}>
|
||||
<div className={style.targetAvatar}>
|
||||
<Avatar
|
||||
name="target-professional-avatar"
|
||||
src={visibleContext.avatarUrl || visibleContext.name}
|
||||
alt={`Avatar de ${visibleContext.name}`}
|
||||
size="large"
|
||||
border="none"
|
||||
/>
|
||||
</div>
|
||||
<div className={style.professionalText}>
|
||||
<span className={style.targetLabel}>{visibleContext.label}</span>
|
||||
<h1>{heroTitle}</h1>
|
||||
<p>{visibleContext.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={style.targetTitleBlock}>
|
||||
<span className={style.targetLabel}>{visibleContext?.label || "Opiniones"}</span>
|
||||
<h1>{heroTitle}</h1>
|
||||
<p>{visibleContext?.description || "Conocé experiencias reales de personas que ya reservaron."}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={style.summaryCard} aria-label="Resumen de opiniones">
|
||||
<span className={style.summaryLabel}>Promedio general</span>
|
||||
<span className={style.summaryScore}>{averageScore > 0 ? formatScore(averageScore) : "--"}</span>
|
||||
<span className={style.summaryStars} aria-hidden="true">
|
||||
{averageScore > 0 ? renderStars(averageScore) : "☆☆☆☆☆"}
|
||||
</span>
|
||||
<span className={style.summaryText}>{totalRatings} {reviewLabel} verificadas</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{isLoading && <p className={style.state}>Cargando opiniones...</p>}
|
||||
|
||||
{!isLoading && error && <p className={style.state}>{error}</p>}
|
||||
|
||||
{!isLoading && !error && reviews.length === 0 && (
|
||||
<p className={style.state}>Todavía no hay opiniones con comentarios para mostrar.</p>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && reviews.length > 0 && (
|
||||
<>
|
||||
<div className={style.listHeader}>
|
||||
<p className={style.resultsCount}>{total} opiniones con comentarios</p>
|
||||
<span>Ordenadas por calificación y fecha</span>
|
||||
</div>
|
||||
<div className={style.reviewsList}>
|
||||
{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>
|
||||
{totalPages > 1 && (
|
||||
<div className={style.pagination}>
|
||||
<Pagination count={totalPages} page={page} variant="outlined" onChange={handlePageChange} />
|
||||
<span>
|
||||
Página {page} de {totalPages}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ReviewsPage(): ReactElement {
|
||||
return (
|
||||
<Suspense fallback={<main className={style.page}>Cargando opiniones...</main>}>
|
||||
<ReviewsContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,7 @@ const GetServiceData = async (serviceId: string): Promise<PublicOrganizationServ
|
||||
"views/service",
|
||||
{
|
||||
serviceId: serviceId,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return organizationServiceData;
|
||||
@@ -42,9 +42,7 @@ export default async function OrganizationPublicProfile({ params }: Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{!organizationService.organization && !organizationService.service && (
|
||||
<ServicePrivate />
|
||||
)}
|
||||
{!organizationService.organization && !organizationService.service && <ServicePrivate />}
|
||||
{organizationService.organization && organizationService.service && (
|
||||
<AnimatedContainer
|
||||
color={organizationService.organization.headerColor}
|
||||
@@ -70,9 +68,7 @@ export default async function OrganizationPublicProfile({ params }: Props) {
|
||||
<Appointment
|
||||
serviceId={sid}
|
||||
companyId={organizationService.organization.id}
|
||||
appointmentAlert={
|
||||
organizationService.organization.appointmentAlert
|
||||
}
|
||||
appointmentAlert={organizationService.organization.appointmentAlert}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -86,10 +82,18 @@ export default async function OrganizationPublicProfile({ params }: Props) {
|
||||
<ReviewsCarousel
|
||||
targetType={RatingTargetType.SERVICE}
|
||||
targetId={sid}
|
||||
title="Lo que dicen quienes ya reservaron"
|
||||
title={`Opiniones sobre ${organizationService.service.name}`}
|
||||
subtitle="Comentarios de clientes que calificaron este servicio después de su turno."
|
||||
/>
|
||||
)}
|
||||
{organizationService.organization.showPublicOpinions !== false && (
|
||||
<ReviewsCarousel
|
||||
targetType={RatingTargetType.COMPANY}
|
||||
targetId={organizationService.organization.id}
|
||||
title="Opiniones sobre la organización"
|
||||
subtitle={`Comentarios de clientes que calificaron a ${organizationService.organization.name} después de su turno.`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</AnimatedContainer>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user