feat: implement full-stack ratings system for appointments with user submission and pending ratings dashboard integration

This commit is contained in:
2026-07-21 23:49:16 -03:00
parent b26c3e58d1
commit 383a5bc6b4
17 changed files with 1505 additions and 20 deletions
@@ -44,6 +44,7 @@ import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome";
import FinancialWidget from "@components/FinancialWidget/FinancialWidget";
import PlanMetricsWidget from "@components/PlanMetricsWidget/PlanMetricsWidget";
import PendingRepeatsCard from "@core/app/components/PendingRepeatsCard/PendingRepeatsCard";
import PendingRatingsBanner from "@components/PendingRatingsBanner/PendingRatingsBanner";
function DashboardContent() {
const { goTo } = useNavigation();
@@ -367,6 +368,7 @@ function DashboardContent() {
{SessionInfo.userId != "" && !displayedPlan && (
<div className="homeCentered">No tiene una suscripción activa.</div>
)}
{displayedPlan && <PendingRatingsBanner variant="banner" />}
{displayedPlan && (
<div className={style.dashboardGrid}>
@@ -18,6 +18,7 @@ import { ScrollToTop } from "@core/app/components/ScrollTop";
import PendingRepeatsCard from "@core/app/components/PendingRepeatsCard/PendingRepeatsCard";
import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider";
import EvangelizeBanner from "@components/EvangelizeBanner/EvangelizeBanner";
import PendingRatingsBanner from "@components/PendingRatingsBanner/PendingRatingsBanner";
import { Button, Tabs, Tab, Box } from "@mui/material";
import { ThemeProvider } from "@emotion/react";
import turnosXpressTheme from "@core/app/theme/turnosXpress";
@@ -295,6 +296,8 @@ export default function MyAppointmentsPage() {
<EvangelizeBanner />
<PendingRatingsBanner />
<Box sx={{ width: "100%", mt: 4 }}>
{collaboratorAppointments.length > 0 ? (
<>
@@ -0,0 +1,456 @@
.pageShell {
--ratings-purple: #330080;
--ratings-purple-soft: #6c34c2;
--ratings-orange: #ff7a2f;
--ratings-pink: #ff2a7f;
--ratings-ink: #24143d;
--ratings-muted: #695f76;
--ratings-line: rgba(97, 54, 148, 0.16);
--ratings-surface: rgba(255, 255, 255, 0.86);
box-sizing: border-box;
width: 100%;
max-width: 1120px;
margin: 0 auto;
padding: 2.5rem 1.25rem 3.5rem;
}
.hero {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
gap: 1.5rem;
overflow: hidden;
margin: 0 auto 1.25rem;
padding: 3rem;
border: 1px solid rgba(255, 255, 255, 0.72);
border-radius: 34px;
background:
radial-gradient(circle at 84% 6%, rgba(255, 122, 47, 0.3) 0, rgba(255, 122, 47, 0) 30%),
radial-gradient(circle at 8% 18%, rgba(255, 42, 127, 0.18) 0, rgba(255, 42, 127, 0) 32%),
linear-gradient(135deg, rgba(255, 255, 255, 0.96) 0%, rgba(253, 246, 255, 0.94) 46%, rgba(255, 244, 235, 0.96) 100%);
box-shadow: 0 24px 70px rgba(51, 0, 128, 0.16);
}
.hero::before {
content: "";
position: absolute;
inset: 1px;
pointer-events: none;
border-radius: 33px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.76), rgba(255, 255, 255, 0));
}
.heroContent,
.heroPanel,
.heroStats {
position: relative;
z-index: 1;
}
.hero h1 {
max-width: 710px;
margin: 0.45rem 0 0.85rem;
color: var(--ratings-ink);
font-size: clamp(2.45rem, 5.8vw, 4.7rem);
letter-spacing: -0.055em;
line-height: 0.96;
}
.hero p {
max-width: 640px;
margin: 0;
color: var(--ratings-muted);
font-size: 1.1rem;
line-height: 1.6;
}
.heroPanel {
align-self: stretch;
display: flex;
min-height: 230px;
flex-direction: column;
justify-content: flex-end;
padding: 1.35rem;
border: 1px solid rgba(255, 255, 255, 0.72);
border-radius: 26px;
background:
radial-gradient(circle at top right, rgba(255, 255, 255, 0.34), transparent 38%),
linear-gradient(145deg, var(--ratings-purple) 0%, var(--ratings-purple-soft) 56%, var(--ratings-pink) 100%);
color: var(--white);
box-shadow: 0 18px 40px rgba(51, 0, 128, 0.24);
}
.panelLabel {
align-self: flex-start;
padding: 0.42rem 0.7rem;
border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: 999px;
background: rgba(255, 255, 255, 0.16);
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.heroPanel strong {
margin-top: auto;
font-size: 4.25rem;
line-height: 0.95;
}
.heroPanel span:last-child {
color: rgba(255, 255, 255, 0.84);
font-weight: 700;
}
.heroStats {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.8rem;
}
.heroStats div {
padding: 1rem 1.1rem;
border: 1px solid rgba(255, 255, 255, 0.7);
border-radius: 20px;
background: rgba(255, 255, 255, 0.68);
backdrop-filter: blur(14px);
}
.heroStats strong {
display: block;
color: var(--ratings-purple);
font-size: 1.45rem;
line-height: 1;
}
.heroStats span {
display: block;
margin-top: 0.35rem;
color: var(--ratings-muted);
font-size: 0.82rem;
font-weight: 800;
letter-spacing: 0.035em;
text-transform: uppercase;
}
.contentCard {
padding: 1.6rem;
border: 1px solid rgba(255, 255, 255, 0.78);
border-radius: 30px;
background: rgba(255, 255, 255, 0.72);
box-shadow: 0 18px 55px rgba(51, 0, 128, 0.1);
backdrop-filter: blur(18px);
}
.sectionHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.35rem;
padding: 0.35rem 0.25rem 0;
}
.sectionHeader h2 {
margin: 0.25rem 0 0;
color: var(--ratings-ink);
font-size: 1.65rem;
letter-spacing: -0.025em;
}
.sectionHeader p {
max-width: 680px;
margin: 0.35rem 0 0;
color: var(--ratings-muted);
line-height: 1.45;
}
.eyebrow,
.targetLabel {
color: var(--ratings-pink);
font-size: 0.73rem;
font-weight: 900;
letter-spacing: 0.075em;
text-transform: uppercase;
}
.countPill,
.datePill {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
font-weight: 800;
white-space: nowrap;
}
.countPill {
padding: 0.58rem 0.9rem;
background: linear-gradient(135deg, rgba(255, 122, 47, 0.16), rgba(255, 42, 127, 0.14));
color: var(--ratings-purple);
font-size: 0.85rem;
}
.datePill {
min-width: 78px;
padding: 0.46rem 0.75rem;
border: 1px solid var(--ratings-line);
background: rgba(255, 255, 255, 0.74);
color: var(--ratings-purple);
font-size: 0.82rem;
}
.ratingList {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1.15rem;
}
.ratingCard {
position: relative;
display: flex;
min-height: 100%;
flex-direction: column;
gap: 1rem;
overflow: hidden;
padding: 1.25rem;
border: 1px solid var(--ratings-line);
border-radius: 26px;
background:
linear-gradient(var(--ratings-surface), var(--ratings-surface)) padding-box,
linear-gradient(140deg, rgba(255, 122, 47, 0.42), rgba(255, 42, 127, 0.2), rgba(51, 0, 128, 0.18)) border-box;
box-shadow: 0 18px 44px rgba(51, 0, 128, 0.1);
}
.ratingCard::after {
content: "";
position: absolute;
top: -80px;
right: -70px;
width: 180px;
height: 180px;
border-radius: 999px;
background: radial-gradient(circle, rgba(255, 122, 47, 0.18), transparent 68%);
pointer-events: none;
}
.cardTop {
position: relative;
z-index: 1;
display: flex;
justify-content: space-between;
gap: 1rem;
}
.cardIdentity {
display: flex;
min-width: 0;
align-items: center;
gap: 0.85rem;
}
.targetIcon,
.stateIcon {
display: grid;
place-items: center;
flex: 0 0 auto;
border-radius: 18px;
background: linear-gradient(135deg, var(--ratings-orange) 0%, var(--ratings-pink) 48%, var(--ratings-purple) 100%);
color: var(--white);
font-weight: 900;
box-shadow: 0 10px 24px rgba(255, 42, 127, 0.24);
}
.targetIcon {
width: 52px;
height: 52px;
font-size: 0.82rem;
letter-spacing: 0.04em;
}
.cardTop h3 {
margin: 0.18rem 0 0;
color: var(--ratings-ink);
font-size: 1.26rem;
line-height: 1.12;
letter-spacing: -0.02em;
}
.contextBox {
position: relative;
z-index: 1;
display: grid;
gap: 0.18rem;
padding: 0.9rem 1rem;
border: 1px solid rgba(51, 0, 128, 0.08);
border-radius: 18px;
background: rgba(255, 255, 255, 0.7);
}
.contextBox span,
.contextBox small {
color: var(--ratings-muted);
font-size: 0.82rem;
}
.contextBox strong {
color: var(--ratings-ink);
font-size: 1rem;
}
.ratingControl {
position: relative;
z-index: 1;
display: grid;
justify-items: center;
gap: 0.55rem;
padding: 1.15rem 1rem;
border: 1px solid rgba(255, 122, 47, 0.18);
border-radius: 22px;
background:
radial-gradient(circle at 50% 0%, rgba(255, 122, 47, 0.18), transparent 46%),
linear-gradient(135deg, rgba(255, 255, 255, 0.88), rgba(255, 247, 241, 0.78));
}
.ratingControl span {
color: var(--ratings-ink);
font-weight: 900;
}
.ratingControl :global(.MuiRating-root) {
font-size: 2.45rem;
color: #ffb23f;
filter: drop-shadow(0 6px 10px rgba(255, 122, 47, 0.18));
}
.ratingControl small {
color: var(--ratings-muted);
font-size: 0.82rem;
font-weight: 700;
}
.ratingCard :global(.MuiOutlinedInput-root) {
border-radius: 18px;
background: rgba(255, 255, 255, 0.76);
}
.ratingCard :global(.MuiFormHelperText-root) {
text-align: right;
}
.actions {
display: flex;
justify-content: flex-end;
margin-top: auto;
}
.actions button {
border-radius: 14px;
box-shadow: 0 10px 22px rgba(255, 42, 127, 0.22);
}
.stateCard {
display: flex;
min-height: 310px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.8rem;
padding: 2.5rem;
text-align: center;
border: 1px dashed rgba(97, 54, 148, 0.28);
border-radius: 26px;
background:
radial-gradient(circle at 50% 0%, rgba(255, 122, 47, 0.16), transparent 34%),
linear-gradient(135deg, rgba(255, 255, 255, 0.86), rgba(253, 246, 255, 0.82));
}
.stateIcon {
width: 58px;
height: 58px;
border-radius: 20px;
}
.stateCard h3 {
margin: 0;
color: var(--ratings-ink);
font-size: 1.35rem;
letter-spacing: -0.02em;
}
.stateCard p {
max-width: 460px;
margin: 0;
color: var(--ratings-muted);
line-height: 1.5;
}
@media (max-width: 900px) {
.hero,
.ratingList {
grid-template-columns: 1fr;
}
.heroPanel {
min-height: 170px;
}
}
@media (max-width: 768px) {
.pageShell {
padding: 5.75rem 0.9rem 2.25rem;
}
.hero,
.contentCard {
padding: 1.15rem;
border-radius: 26px;
}
.hero h1 {
font-size: clamp(2.25rem, 12vw, 3.25rem);
}
.heroStats {
grid-template-columns: 1fr;
}
.sectionHeader,
.cardTop {
flex-direction: column;
align-items: stretch;
}
.cardIdentity {
align-items: flex-start;
}
.datePill {
width: fit-content;
}
.ratingCard {
padding: 1rem;
border-radius: 22px;
}
.ratingControl :global(.MuiRating-root) {
font-size: 2.25rem;
}
.actions {
justify-content: stretch;
}
.actions button {
width: 100%;
}
}
@media (max-width: 420px) {
.ratingControl :global(.MuiRating-root) {
font-size: 2rem;
}
}
@@ -0,0 +1,250 @@
"use client";
import AnimatedContainer from "@components/AnimatedConainer/AnimatedContainer";
import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider";
import { ScrollToTop } from "@core/app/components/ScrollTop";
import { useNavigation } from "@core/app/hooks/goto";
import turnosXpressTheme from "@core/app/theme/turnosXpress";
import { useAlert } from "@core/Store/Alert.Store";
import { EVENT_TYPES, useEventHandlerStore } from "@core/Store/EventHandler.Store";
import { useSessionStore } from "@core/Store/Sesion.Store";
import { ThemeProvider } from "@emotion/react";
import { Box, Button, Rating, TextField } from "@mui/material";
import { useEffect, useState } from "react";
import dayjs from "dayjs";
import "dayjs/locale/es";
import { createRating, pendingRatingsByUser } from "@services/Ratings.Service";
import { PendingRatingItem, RATING_COMMENT_MAX_LENGTH, RatingTargetType } from "@core/Models/Ratings.model";
import styles from "./page.module.css";
dayjs.locale("es");
const targetTypeLabel = (targetType: RatingTargetType) => {
if (targetType === RatingTargetType.COMPANY) return "organización";
if (targetType === RatingTargetType.SERVICE) return "servicio";
return "profesional";
};
const targetTypeIcon = (targetType: RatingTargetType) => {
if (targetType === RatingTargetType.COMPANY) return "TX";
if (targetType === RatingTargetType.SERVICE) return "SV";
return "PR";
};
export default function PendingRatingsPage() {
const [pendingRatings, setPendingRatings] = useState<PendingRatingItem[]>([]);
const [scores, setScores] = useState<Record<string, number>>({});
const [comments, setComments] = useState<Record<string, string>>({});
const [submittingKey, setSubmittingKey] = useState("");
const [isLoading, setIsLoading] = useState(true);
const SessionInfo = useSessionStore();
const eventHandler = useEventHandlerStore();
const alert = useAlert();
const { goTo } = useNavigation();
const loadPendingRatings = () => {
if (!SessionInfo.userId) {
setPendingRatings([]);
setIsLoading(false);
return;
}
setIsLoading(true);
eventHandler.setEventType(EVENT_TYPES.LOADING);
pendingRatingsByUser({ sessionUser: SessionInfo.userId })
.then((res) => setPendingRatings(res))
.catch((error) => {
alert.showError(error.message || "No pudimos cargar tus calificaciones pendientes.");
console.error("Failed to load pending ratings", {
message: error?.message,
code: error?.code,
desc: error?.desc,
});
})
.finally(() => {
setIsLoading(false);
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
};
useEffect(() => {
loadPendingRatings();
}, [SessionInfo.userId]);
const submitRating = (item: PendingRatingItem) => {
const itemKey = `${item.targetType}:${item.targetId}`;
const score = scores[itemKey];
if (!score) {
alert.showError("Selecciona una puntuación para continuar.");
return;
}
setSubmittingKey(itemKey);
createRating({
appointmentId: item.appointmentId,
targetType: item.targetType,
targetId: item.targetId,
score,
comment: comments[itemKey] || "",
sessionUser: SessionInfo.userId,
})
.then(() => {
alert.showSuccess("Gracias por compartir tu calificación.");
setScores((prev) => {
const next = { ...prev };
delete next[itemKey];
return next;
});
setComments((prev) => {
const next = { ...prev };
delete next[itemKey];
return next;
});
loadPendingRatings();
})
.catch((error) => alert.showError(error.message))
.finally(() => setSubmittingKey(""));
};
return (
<AnimatedContainer
color="#aeaeae"
minSize={150}
maxSize={500}
items={10}
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
}}
>
<ScrollToTop />
<HeaderConfProvider />
<main className={styles.pageShell}>
<section className={styles.hero}>
<div className={styles.heroContent}>
<span className={styles.eyebrow}>Calificaciones pendientes</span>
<h1>Contanos cómo fue tu experiencia</h1>
<p>
Tus comentarios ayudan a mejorar las recomendaciones y a que otras personas elijan con más
confianza.
</p>
</div>
<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>
</div>
<div className={styles.heroStats} aria-label="Resumen de calificaciones pendientes">
<div>
<strong>{isLoading ? "..." : pendingRatings.length}</strong>
<span>Pendientes</span>
</div>
<div>
<strong>2 min</strong>
<span>Promedio</span>
</div>
</div>
</section>
<section className={styles.contentCard}>
<div className={styles.sectionHeader}>
<div>
<span className={styles.eyebrow}>Experiencias por calificar</span>
<h2>Turnos recientes</h2>
<p>Elegí una puntuación, sumá un comentario si querés y enviá cada experiencia por separado.</p>
</div>
{!isLoading && pendingRatings.length > 0 && (
<span className={styles.countPill}>{pendingRatings.length} pendientes</span>
)}
</div>
{isLoading ? (
<div className={styles.stateCard}>
<div className={styles.stateIcon}>...</div>
<h3>Cargando tus experiencias...</h3>
<p>Estamos buscando las calificaciones que tenés pendientes.</p>
</div>
) : pendingRatings.length === 0 ? (
<div className={styles.stateCard}>
<div className={styles.stateIcon}>OK</div>
<h3>No tenés calificaciones pendientes</h3>
<p>Cuando completes un turno, vas a poder compartir tu opinión desde acá.</p>
<ThemeProvider theme={turnosXpressTheme}>
<Button variant="contained" color="primary" onClick={() => goTo("/landing/my-appointments")}>
Volver a Mis Turnos
</Button>
</ThemeProvider>
</div>
) : (
<div className={styles.ratingList}>
{pendingRatings.map((item) => {
const itemKey = `${item.targetType}:${item.targetId}`;
return (
<article key={itemKey} className={styles.ratingCard}>
<div className={styles.cardTop}>
<div className={styles.cardIdentity}>
<div className={styles.targetIcon}>{targetTypeIcon(item.targetType)}</div>
<div>
<span className={styles.targetLabel}>{targetTypeLabel(item.targetType)}</span>
<h3>{item.targetName}</h3>
</div>
</div>
<span className={styles.datePill}>{dayjs(item.appointmentDate).format("D MMM")}</span>
</div>
<div className={styles.contextBox}>
<span>Turno realizado</span>
<strong>{item.serviceName}</strong>
<small>{dayjs(item.appointmentDate).format("dddd D [de] MMMM [de] YYYY")}</small>
</div>
<Box className={styles.ratingControl}>
<span>Tu calificación</span>
<Rating
name={`rating-${itemKey}`}
value={scores[itemKey] || 0}
onChange={(_, value) =>
setScores((prev) => ({ ...prev, [itemKey]: value || 0 }))
}
size="large"
/>
<small>{scores[itemKey] ? `${scores[itemKey]} de 5` : "Tocá las estrellas"}</small>
</Box>
<TextField
label="Comentario opcional"
value={comments[itemKey] || ""}
onChange={(event) =>
setComments((prev) => ({ ...prev, [itemKey]: event.target.value }))
}
multiline
minRows={2}
size="small"
fullWidth
inputProps={{ maxLength: RATING_COMMENT_MAX_LENGTH }}
helperText={`${(comments[itemKey] || "").length}/${RATING_COMMENT_MAX_LENGTH}`}
/>
<div className={styles.actions}>
<ThemeProvider theme={turnosXpressTheme}>
<Button
variant="contained"
color="primary"
disabled={submittingKey === itemKey}
onClick={() => submitRating(item)}
>
{submittingKey === itemKey ? "Enviando..." : "Enviar calificación"}
</Button>
</ThemeProvider>
</div>
</article>
);
})}
</div>
)}
</section>
</main>
</AnimatedContainer>
);
}
@@ -23,6 +23,7 @@ import { createAppointment, getAvailableDates } from "./Appointment.Service";
import { useAlert } from "@core/Store/Alert.Store";
import { useNavigation } from "@core/app/hooks/goto";
import MaterialAlert from "@mui/material/Alert";
import PendingRatingsBanner from "@components/PendingRatingsBanner/PendingRatingsBanner";
export interface AppointmentProps {
companyId: string;
@@ -200,6 +201,7 @@ export default function Appointment(props: AppointmentProps) {
)}
{SessionInfo.loged && (
<div style={{ marginLeft: "30px", marginRight: "30px", textAlign: "left" }}>
<PendingRatingsBanner />
<TextObjectFilter
placeholder="Seleccionar Profesional:"
content={employeeFilterContent}