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
@@ -0,0 +1,121 @@
.bannerContainer {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.5rem 2rem;
background: linear-gradient(135deg, var(--white) 0%, var(--white-dark) 100%);
border-radius: 20px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
border: 1px solid var(--wine-lighterX2);
margin: 2rem auto;
width: 100%;
max-width: 1000px;
gap: 1.5rem;
}
.popupContainer {
position: fixed;
bottom: 2rem;
right: 2rem;
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 1.5rem;
background: var(--white);
border-radius: 20px;
box-shadow: 0 10px 40px rgba(51, 0, 128, 0.15);
border: 1px solid var(--wine-lighterX2);
width: 100%;
max-width: 350px;
z-index: 1000;
gap: 1rem;
}
.closeButton {
position: absolute;
top: 10px;
right: 10px;
background: none;
border: none;
color: var(--gray-dark);
cursor: pointer;
padding: 5px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background 0.2s;
}
.closeButton:hover {
background: var(--white-dark);
color: var(--black);
}
.contentWrapper {
display: flex;
flex-direction: column;
gap: 0.45rem;
padding-right: 1.5rem;
}
.eyebrow {
color: var(--wine-red);
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.title {
font-size: 1.1rem;
font-weight: 700;
color: var(--wine-superdark);
margin: 0;
}
.description {
font-size: 0.95rem;
color: var(--gray-dark);
margin: 0;
line-height: 1.4;
}
.ctaButton {
display: inline-block;
padding: 10px 24px;
background: linear-gradient(135deg, var(--wine-red) 0%, var(--wine-dark) 100%);
color: var(--white);
border-radius: 12px;
font-weight: 700;
text-decoration: none;
font-size: 0.95rem;
transition: transform 0.2s ease, box-shadow 0.2s ease;
box-shadow: 0 4px 15px rgba(255, 42, 127, 0.3);
text-align: center;
white-space: nowrap;
}
.ctaButton:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(255, 42, 127, 0.4);
color: var(--white);
text-decoration: none;
}
@media (max-width: 768px) {
.bannerContainer {
flex-direction: column;
align-items: stretch;
text-align: left;
padding: 1.5rem;
}
.popupContainer {
bottom: 1rem;
right: 1rem;
left: 1rem;
max-width: calc(100% - 2rem);
}
}
@@ -0,0 +1,115 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { AnimatePresence, motion } from "framer-motion";
import { useSessionStore } from "@core/Store/Sesion.Store";
import { pendingRatingsByUser } from "@services/Ratings.Service";
import style from "./PendingRatingsBanner.module.css";
interface PendingRatingsBannerProps {
variant?: "banner" | "popup";
delay?: number;
className?: string;
}
export default function PendingRatingsBanner({
variant = "banner",
delay = 3000,
className = "",
}: PendingRatingsBannerProps) {
const SessionInfo = useSessionStore();
const [hasPendingRatings, setHasPendingRatings] = useState(false);
const [isVisible, setIsVisible] = useState(variant === "banner");
useEffect(() => {
let cancelled = false;
if (!SessionInfo.userId) {
setHasPendingRatings(false);
return;
}
pendingRatingsByUser({ sessionUser: SessionInfo.userId })
.then((ratings) => {
if (!cancelled) {
setHasPendingRatings(ratings.length > 0);
}
})
.catch((error) => {
if (!cancelled) {
setHasPendingRatings(false);
}
console.error(error);
});
return () => {
cancelled = true;
};
}, [SessionInfo.userId]);
useEffect(() => {
if (!hasPendingRatings) {
setIsVisible(false);
return;
}
if (variant === "banner") {
setIsVisible(true);
return;
}
const timer = setTimeout(() => {
setIsVisible(true);
}, delay);
return () => clearTimeout(timer);
}, [variant, delay, hasPendingRatings]);
if (!SessionInfo.userId || !hasPendingRatings) {
return null;
}
const containerClassName = [variant === "banner" ? style.bannerContainer : style.popupContainer, className]
.filter(Boolean)
.join(" ");
const content = (
<>
<button onClick={() => setIsVisible(false)} className={style.closeButton} aria-label="Cerrar">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
<div className={style.contentWrapper}>
<span className={style.eyebrow}>Calificaciones pendientes</span>
<h3 className={style.title}>Tenés experiencias pendientes para calificar</h3>
<p className={style.description}>Tu opinión ayuda a mejorar las recomendaciones.</p>
</div>
<Link href="/landing/pending-ratings" className={style.ctaButton}>
Calificar ahora
</Link>
</>
);
if (variant === "banner") {
return isVisible ? <div className={containerClassName}>{content}</div> : null;
}
return (
<AnimatePresence>
{isVisible && (
<motion.div
className={containerClassName}
initial={{ opacity: 0, y: 50, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 50, scale: 0.95 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
>
{content}
</motion.div>
)}
</AnimatePresence>
);
}
@@ -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}
+2
View File
@@ -6,6 +6,7 @@ import { HomeView } from "@core/Models/Views.model";
import Index from "./components/Home/Index/Index";
import { SystemNotificationsProvider } from "./components/SystemNotificationsProvider/SystemNotificationsProvider";
import EvangelizeBanner from "./components/EvangelizeBanner/EvangelizeBanner";
import PendingRatingsBanner from "./components/PendingRatingsBanner/PendingRatingsBanner";
export const dynamic = "force-dynamic"; // Fuerza la página a ser dinámica
@@ -29,6 +30,7 @@ export default async function Home() {
<Index homeData={homeData} />
<UserMenu />
<Alert />
<PendingRatingsBanner variant="popup" delay={2500} />
<EvangelizeBanner variant="popup" delay={4000} />
</div>
);