feat: add public employee profile view and implement ratings review page components

This commit is contained in:
2026-07-22 16:27:46 -03:00
parent 279e1b54cc
commit dd3e07311d
27 changed files with 1621 additions and 304 deletions
@@ -69,6 +69,7 @@ export class CompaniesAdapterMongoose implements ICompaniesAdapter {
banned: { type: Boolean, required: false, default: false },
showPublicScores: { type: Boolean, required: false, default: true },
showPublicOpinions: { type: Boolean, required: false, default: true },
showPublicProfessionals: { type: Boolean, required: false, default: true },
});
this.companyList = model<ICompanyDocument>("Company", this.schema);
@@ -245,6 +246,10 @@ export class CompaniesAdapterMongoose implements ICompaniesAdapter {
updateCompany.showPublicOpinions = data.showPublicOpinions;
}
if (data.showPublicProfessionals !== undefined) {
updateCompany.showPublicProfessionals = data.showPublicProfessionals;
}
updateCompany.save();
//actualizo los campos correspondientes a la localizacion de los servicios de la organizacion.
@@ -56,6 +56,7 @@ export type UpdateCompanyParams = {
banned?: boolean;
showPublicScores?: boolean;
showPublicOpinions?: boolean;
showPublicProfessionals?: boolean;
};
export type SetCompanyFileParams = {
@@ -158,6 +159,7 @@ export interface ICompany {
banned?: boolean;
showPublicScores?: boolean;
showPublicOpinions?: boolean;
showPublicProfessionals?: boolean;
}
export interface MyOranizationsView {
@@ -209,6 +211,7 @@ export interface MyOranizationsView {
banned?: boolean;
showPublicScores?: boolean;
showPublicOpinions?: boolean;
showPublicProfessionals?: boolean;
}
export interface ClientOrganizationView {
+8 -1
View File
@@ -500,7 +500,11 @@ class CompaniesManager implements ICompaniesManager {
throw new Error(NoPermissionMessage());
}
if (data.showPublicScores !== undefined || data.showPublicOpinions !== undefined) {
if (
data.showPublicScores !== undefined ||
data.showPublicOpinions !== undefined ||
data.showPublicProfessionals !== undefined
) {
await this.validatePublicRatingFlagsPlan(String(companyCheck.ownerId));
}
@@ -614,6 +618,7 @@ class CompaniesManager implements ICompaniesManager {
onboardingCompleted: company.onboardingCompleted,
showPublicScores: company.showPublicScores ?? true,
showPublicOpinions: company.showPublicOpinions ?? true,
showPublicProfessionals: company.showPublicProfessionals ?? true,
};
}
@@ -735,6 +740,7 @@ class CompaniesManager implements ICompaniesManager {
onboardingCompleted: company.onboardingCompleted,
showPublicScores: company.showPublicScores ?? true,
showPublicOpinions: company.showPublicOpinions ?? true,
showPublicProfessionals: company.showPublicProfessionals ?? true,
});
}
@@ -807,6 +813,7 @@ class CompaniesManager implements ICompaniesManager {
onboardingCompleted: company.onboardingCompleted,
showPublicScores: company.showPublicScores ?? true,
showPublicOpinions: company.showPublicOpinions ?? true,
showPublicProfessionals: company.showPublicProfessionals ?? true,
});
}
}
+68
View File
@@ -8,6 +8,9 @@ import {
PaginateEmployeesParams,
PaginateEmployeesResults,
CompanyEmployeesView,
PublicCompanyEmployeeView,
FindPublicEmployeeParams,
PublicEmployeeView,
FindEmployeesParams,
FindEmployeesByIdParams,
UpdateEmployeeRolesParams,
@@ -442,6 +445,71 @@ class EmployeeManager implements IEmployeesManager {
return view;
}
public async findPublicByCompanyId(
data: FindEmployeesParams
): Promise<PublicCompanyEmployeeView[]> {
const employees = await this.employees.find({
companyId: data.companyId,
guestOk: true,
hostOk: true,
removed: { $ne: true },
} as any);
const view = await Promise.all(
employees.map(async (employee) => {
const userData = await UsersManager.users.findOne({ _id: employee.userId });
if (!userData) {
return null;
}
const strFirstName = isNull<string>(userData.firstName, "");
const strLastName = isNull<string>(userData.lastName, "");
const strFullName = joinStrings([strFirstName, strLastName], " ");
return {
employeeId: isNull<string>(employee.id, ""),
userId: employee.userId,
firstName: strFirstName,
lastName: strLastName,
avatarUrl: getAvatar(userData.id, userData.avatar, strFullName),
};
})
);
return view.filter((employee): employee is PublicCompanyEmployeeView => employee !== null);
}
public async findPublicByEmployeeId(data: FindPublicEmployeeParams): Promise<PublicEmployeeView> {
const employee = await this.employees.findOne({
_id: data.employeeId,
guestOk: true,
hostOk: true,
removed: { $ne: true },
} as any);
if (!employee) {
throw new Error("El colaborador no existe");
}
const userData = await UsersManager.users.findOne({ _id: employee.userId });
if (!userData) {
throw new Error("El colaborador no existe");
}
const strFirstName = isNull<string>(userData.firstName, "");
const strLastName = isNull<string>(userData.lastName, "");
const strFullName = joinStrings([strFirstName, strLastName], " ");
return {
employeeId: isNull<string>(employee.id, ""),
firstName: strFirstName,
lastName: strLastName,
avatarUrl: getAvatar(userData.id, userData.avatar, strFullName),
};
}
public async findById(data: FindEmployeesByIdParams): Promise<CompanyEmployeesView> {
const employee = await this.employees.findOne({ _id: data.id });
@@ -119,6 +119,25 @@ export interface CompanyEmployeesView {
};
}
export interface PublicCompanyEmployeeView {
employeeId: string;
userId: string;
firstName: string;
lastName: string;
avatarUrl: string;
}
export interface FindPublicEmployeeParams {
employeeId: string;
}
export interface PublicEmployeeView {
employeeId: string;
firstName: string;
lastName: string;
avatarUrl: string;
}
export interface IncompleteCollaboratorView {
employeeId: string;
companyId: string;
@@ -143,6 +162,8 @@ export interface IEmployeesManager {
updateEmployeeRoles(data: UpdateEmployeeRolesParams): Promise<void>;
update(data: UpdateEmployeeParams): Promise<void>;
findByCompanyId(data: FindEmployeesParams): Promise<CompanyEmployeesView[]>;
findPublicByCompanyId(data: FindEmployeesParams): Promise<PublicCompanyEmployeeView[]>;
findPublicByEmployeeId(data: FindPublicEmployeeParams): Promise<PublicEmployeeView>;
findById(data: FindEmployeesByIdParams): Promise<CompanyEmployeesView>;
textObjectFilter(data: FindEmployeesParams): Promise<TextObjectFilterResult[]>;
deleteEmployeesByCompany(data: DeleteEmployeesByCompany): Promise<void>;
@@ -8,8 +8,8 @@ import {
RATING_COMMENT_MAX_LENGTH,
RatingTargetSummaryParams,
RatingTargetSummaryResult,
RatingTargetReviewItem,
RatingTargetReviewsParams,
RatingTargetReviewsResult,
RatingTargetType,
} from "./Ratings.Interface";
@@ -127,20 +127,26 @@ export class RatingsAdapterMongoose implements IRatingsAdapter {
};
}
public async reviewsByTarget(filters: RatingTargetReviewsParams): Promise<RatingTargetReviewItem[]> {
const ratings = await this.ratingsList
.find({
public async reviewsByTarget(filters: RatingTargetReviewsParams): Promise<RatingTargetReviewsResult> {
const page = filters.page || 1;
const pageSize = filters.pageSize || filters.limit || 20;
const targetReviewsFilter = {
targetType: filters.targetType,
targetId: new Types.ObjectId(filters.targetId),
comment: { $regex: /\S/ },
})
};
const total = await this.ratingsList.countDocuments(targetReviewsFilter).exec();
const ratings = await this.ratingsList
.find(targetReviewsFilter)
.sort({ score: -1, createdAt: -1 })
.limit(filters.limit || 20)
.skip((page - 1) * pageSize)
.limit(pageSize)
.select("score comment createdAt userId")
.populate("userId", "firstName lastName avatar")
.exec();
return (ratings as unknown as RatingReviewDocument[]).map((rating) => {
const reviews = (ratings as unknown as RatingReviewDocument[]).map((rating) => {
const user = rating.userId;
const displayName = user ? joinStrings([user.firstName, user.lastName], " ") : "";
const authorName = displayName || GENERIC_REVIEW_AUTHOR;
@@ -156,6 +162,14 @@ export class RatingsAdapterMongoose implements IRatingsAdapter {
},
};
});
return {
reviews,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
}
private mapRating(rating: IRatingDocument): IRating {
+12 -2
View File
@@ -53,6 +53,8 @@ export type RatingTargetSummaryResult = {
};
export type RatingTargetReviewsParams = RatingTargetSummaryParams & {
page?: number;
pageSize?: number;
limit?: number;
};
@@ -67,6 +69,14 @@ export type RatingTargetReviewItem = {
};
};
export type RatingTargetReviewsResult = {
reviews: RatingTargetReviewItem[];
total: number;
page: number;
pageSize: number;
totalPages: number;
};
export interface IRating {
id?: string;
appointmentId: string;
@@ -101,7 +111,7 @@ export interface IRatingsAdapter {
count(filters: FindRatingsParams): Promise<number>;
summaryByTarget(filters: RatingTargetSummaryParams): Promise<RatingTargetSummaryResult>;
summaryByTargets(filters: { targetType: RatingTargetType; targetIds: string[] }): Promise<RatingTargetSummaryResult>;
reviewsByTarget(filters: RatingTargetReviewsParams): Promise<RatingTargetReviewItem[]>;
reviewsByTarget(filters: RatingTargetReviewsParams): Promise<RatingTargetReviewsResult>;
}
export interface IRatingsManager {
@@ -111,5 +121,5 @@ export interface IRatingsManager {
countByUser(data: CountRatingsByUserParams): Promise<CountRatingsByUserResult>;
professionalSummaryByUser(data: ProfessionalRatingSummaryByUserParams): Promise<RatingTargetSummaryResult>;
summaryByTarget(data: RatingTargetSummaryParams): Promise<RatingTargetSummaryResult>;
reviewsByTarget(data: RatingTargetReviewsParams): Promise<RatingTargetReviewItem[]>;
reviewsByTarget(data: RatingTargetReviewsParams): Promise<RatingTargetReviewsResult>;
}
+8 -3
View File
@@ -20,8 +20,8 @@ import {
RATING_COMMENT_MAX_LENGTH,
RatingTargetSummaryParams,
RatingTargetSummaryResult,
RatingTargetReviewItem,
RatingTargetReviewsParams,
RatingTargetReviewsResult,
RatingTargetType,
} from "./Ratings.Interface";
@@ -221,16 +221,21 @@ class RatingsManager implements IRatingsManager {
return this.ratings.summaryByTarget(data);
}
public async reviewsByTarget(data: RatingTargetReviewsParams): Promise<RatingTargetReviewItem[]> {
public async reviewsByTarget(data: RatingTargetReviewsParams): Promise<RatingTargetReviewsResult> {
this.validateTargetType(data.targetType);
if (!Types.ObjectId.isValid(data.targetId)) {
throw new Error("No se ha proporcionado un objetivo válido.");
}
const pageSize = Math.min(Math.max(data.pageSize || data.limit || TARGET_REVIEWS_LIMIT, 1), TARGET_REVIEWS_LIMIT);
const page = Math.max(data.page || 1, 1);
return this.ratings.reviewsByTarget({
...data,
limit: Math.min(Math.max(data.limit || TARGET_REVIEWS_LIMIT, 1), TARGET_REVIEWS_LIMIT),
page,
pageSize,
limit: pageSize,
});
}
+2
View File
@@ -57,6 +57,7 @@ export type PublicOrganizationView = {
appointmentAlert: string;
showPublicScores: boolean;
showPublicOpinions: boolean;
showPublicProfessionals: boolean;
};
export type PublicOrganizationServiceViewParams = {
@@ -167,6 +168,7 @@ class Views {
appointmentAlert: isNull<string>(company.appointmentAlert, ""),
showPublicScores: company.showPublicScores ?? true,
showPublicOpinions: company.showPublicOpinions ?? true,
showPublicProfessionals: company.showPublicProfessionals ?? true,
};
}
@@ -9,6 +9,9 @@ import {
PaginateEmployeesResults,
ValidateEmployeeParams,
CompanyEmployeesView,
PublicCompanyEmployeeView,
FindPublicEmployeeParams,
PublicEmployeeView,
FindEmployeesByIdParams,
UpdateEmployeeRolesParams,
UpdateEmployeeParams,
@@ -117,6 +120,46 @@ export class GetEmployeesByCompanyController extends Controller {
}
}
@Route("employees/public-by-company")
export class GetPublicEmployeesByCompanyController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post()
public async findPublicByCompanyId(
@Body() requestBody: FindEmployeesParams
): Promise<PublicCompanyEmployeeView[] | ApiValidationError> {
try {
const employees = await new EmployeesService().findPublicByCompanyId(requestBody);
this.setStatus(200);
return employees;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@Route("employees/public-by-id")
export class GetPublicEmployeeByIdController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post()
public async findPublicByEmployeeId(
@Body() requestBody: FindPublicEmployeeParams
): Promise<PublicEmployeeView | ApiValidationError> {
try {
const employee = await new EmployeesService().findPublicByEmployeeId(requestBody);
this.setStatus(200);
return employee;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@Route("employees/get-by-id")
export class GetEmployeesByIdController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@@ -8,6 +8,9 @@ import {
PaginateEmployeesParams,
PaginateEmployeesResults,
CompanyEmployeesView,
PublicCompanyEmployeeView,
FindPublicEmployeeParams,
PublicEmployeeView,
FindEmployeesByIdParams,
UpdateEmployeeRolesParams,
UpdateEmployeeParams,
@@ -60,6 +63,24 @@ export class EmployeesService {
return employees;
}
public async findPublicByCompanyId(
data: FindEmployeesParams
): Promise<PublicCompanyEmployeeView[]> {
await connect(`${process.env.DATABASE_CONNECTION}`);
const employees = await EmployeesList.findPublicByCompanyId(data);
return employees;
}
public async findPublicByEmployeeId(data: FindPublicEmployeeParams): Promise<PublicEmployeeView> {
await connect(`${process.env.DATABASE_CONNECTION}`);
const employee = await EmployeesList.findPublicByEmployeeId(data);
return employee;
}
public async findById(data: FindEmployeesByIdParams): Promise<CompanyEmployeesView> {
await connect(`${process.env.DATABASE_CONNECTION}`);
+2 -2
View File
@@ -7,8 +7,8 @@ import {
PendingRatingItem,
PendingRatingsByUserParams,
ProfessionalRatingSummaryByUserParams,
RatingTargetReviewItem,
RatingTargetReviewsParams,
RatingTargetReviewsResult,
RatingTargetSummaryParams,
RatingTargetSummaryResult,
} from "../../Models/Ratings/Ratings.Interface";
@@ -126,7 +126,7 @@ export class RatingTargetReviewsController extends Controller {
@Post()
public async reviewsByTarget(
@Body() requestBody: RatingTargetReviewsParams
): Promise<RatingTargetReviewItem[] | ApiValidationError> {
): Promise<RatingTargetReviewsResult | ApiValidationError> {
try {
const ratingReviews = await new RatingsService().reviewsByTarget(requestBody);
this.setStatus(200);
+2 -2
View File
@@ -10,8 +10,8 @@ import {
ProfessionalRatingSummaryByUserParams,
RatingTargetSummaryParams,
RatingTargetSummaryResult,
RatingTargetReviewItem,
RatingTargetReviewsParams,
RatingTargetReviewsResult,
} from "../../Models/Ratings/Ratings.Interface";
export class RatingsService {
@@ -40,7 +40,7 @@ export class RatingsService {
return await RatingsList.summaryByTarget(data);
}
public async reviewsByTarget(data: RatingTargetReviewsParams): Promise<RatingTargetReviewItem[]> {
public async reviewsByTarget(data: RatingTargetReviewsParams): Promise<RatingTargetReviewsResult> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await RatingsList.reviewsByTarget(data);
}
+4
View File
@@ -47,6 +47,7 @@ export interface MyOranizationsView {
onboardingCompleted?: boolean;
showPublicScores: boolean;
showPublicOpinions: boolean;
showPublicProfessionals: boolean;
}
export type CreateCompanyParams = {
@@ -97,6 +98,7 @@ export type UpdateCompanyParams = {
onboardingCompleted?: boolean;
showPublicScores?: boolean;
showPublicOpinions?: boolean;
showPublicProfessionals?: boolean;
};
export type DeleteCompanyParams = {
@@ -164,6 +166,7 @@ export interface ICompany {
banned?: boolean;
showPublicScores?: boolean;
showPublicOpinions?: boolean;
showPublicProfessionals?: boolean;
}
export type PublicOrganizationViewParams = {
@@ -192,6 +195,7 @@ export type PublicOrganizationView = {
banned?: boolean;
showPublicScores: boolean;
showPublicOpinions: boolean;
showPublicProfessionals: boolean;
};
export type SetNotificationAutoParams = {
+22
View File
@@ -0,0 +1,22 @@
export type PublicCompanyEmployeeView = {
employeeId: string;
userId: string;
firstName: string;
lastName: string;
avatarUrl: string;
};
export type FindPublicCompanyEmployeesParams = {
companyId: string;
};
export type FindPublicEmployeeParams = {
employeeId: string;
};
export type PublicEmployeeView = {
employeeId: string;
firstName: string;
lastName: string;
avatarUrl: string;
};
+10
View File
@@ -42,6 +42,8 @@ export type RatingTargetSummaryResult = {
};
export type RatingTargetReviewsParams = RatingTargetSummaryParams & {
page?: number;
pageSize?: number;
limit?: number;
};
@@ -56,6 +58,14 @@ export type RatingTargetReviewItem = {
};
};
export type RatingTargetReviewsResult = {
reviews: RatingTargetReviewItem[];
total: number;
page: number;
pageSize: number;
totalPages: number;
};
export interface PendingRatingItem {
appointmentId: string;
appointmentDate: string;
@@ -0,0 +1,47 @@
import { ApiError } from "@core/Models/Server.Error.model";
import {
FindPublicCompanyEmployeesParams,
FindPublicEmployeeParams,
PublicCompanyEmployeeView,
PublicEmployeeView,
} from "@core/Models/Employees.model";
import ApiRequest from "@services/Api.Service";
import * as Yup from "yup";
const schemaFindPublicCompanyEmployees = Yup.object().shape({
companyId: Yup.string().required("No se ha proporcionado la organización."),
});
const schemaFindPublicEmployee = Yup.object().shape({
employeeId: Yup.string().required("No se ha proporcionado el profesional."),
});
export const findPublicCompanyEmployees = async (
data: FindPublicCompanyEmployeesParams
): Promise<PublicCompanyEmployeeView[]> => {
return new Promise<PublicCompanyEmployeeView[]>((resolve, reject) => {
schemaFindPublicCompanyEmployees
.validate(data, { abortEarly: true })
.then(() => {
resolve(ApiRequest.post<PublicCompanyEmployeeView[]>("employees/public-by-company", data));
})
.catch((error) => {
reject(new ApiError(500, error.message));
});
});
};
export const findPublicEmployee = async (
data: FindPublicEmployeeParams
): Promise<PublicEmployeeView> => {
return new Promise<PublicEmployeeView>((resolve, reject) => {
schemaFindPublicEmployee
.validate(data, { abortEarly: true })
.then(() => {
resolve(ApiRequest.post<PublicEmployeeView>("employees/public-by-id", data));
})
.catch((error) => {
reject(new ApiError(500, error.message));
});
});
};
+6 -4
View File
@@ -11,8 +11,8 @@ import {
RATING_COMMENT_MAX_LENGTH,
RatingTargetSummaryParams,
RatingTargetSummaryResult,
RatingTargetReviewItem,
RatingTargetReviewsParams,
RatingTargetReviewsResult,
RatingTargetType,
} from "@core/Models/Ratings.model";
import * as Yup from "yup";
@@ -48,6 +48,8 @@ const schemaRatingTargetSummary = Yup.object().shape({
});
const schemaRatingTargetReviews = schemaRatingTargetSummary.shape({
page: Yup.number().integer().min(1),
pageSize: Yup.number().integer().min(1).max(20),
limit: Yup.number().integer().min(1).max(20),
});
@@ -118,12 +120,12 @@ export const ratingTargetSummary = async (data: RatingTargetSummaryParams): Prom
});
};
export const ratingTargetReviews = async (data: RatingTargetReviewsParams): Promise<RatingTargetReviewItem[]> => {
return new Promise<RatingTargetReviewItem[]>((resolve, reject) => {
export const ratingTargetReviews = async (data: RatingTargetReviewsParams): Promise<RatingTargetReviewsResult> => {
return new Promise<RatingTargetReviewsResult>((resolve, reject) => {
schemaRatingTargetReviews
.validate(data, { abortEarly: true })
.then(() => {
resolve(ApiRequest.post<RatingTargetReviewItem[]>("ratings/target-reviews", data));
resolve(ApiRequest.post<RatingTargetReviewsResult>("ratings/target-reviews", data));
})
.catch((error) => {
reject(new ApiError(500, error.message));
@@ -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>
@@ -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}>
<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
+27 -9
View File
@@ -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);
});
},
() => {}
() => {},
);
};
@@ -665,7 +666,9 @@ export default function OrganizationPublicProfile() {
<GridViewOutlinedIcon
sx={{
color:
viewMode === "card" ? "var(--wine-red)" : "ineherit",
viewMode === "card"
? "var(--wine-red)"
: "ineherit",
}}
/>
</IconButton>
@@ -673,7 +676,9 @@ export default function OrganizationPublicProfile() {
<SplitscreenOutlinedIcon
sx={{
color:
viewMode === "grid" ? "var(--wine-red)" : "ineherit",
viewMode === "grid"
? "var(--wine-red)"
: "ineherit",
}}
/>
</IconButton>
@@ -736,7 +741,9 @@ export default function OrganizationPublicProfile() {
}}
>
<div style={{ color: "var(--gray-darkest)" }}>
{dayjs(post.createdAt).format("DD MMMM YYYY HH:mm")}
{dayjs(post.createdAt).format(
"DD MMMM YYYY HH:mm",
)}
</div>
{post.fixed && (
<div
@@ -755,7 +762,9 @@ export default function OrganizationPublicProfile() {
<div
dangerouslySetInnerHTML={{
__html: preprocessQuillHtml(
transformQuillContentForDisplay(post.content)
transformQuillContentForDisplay(
post.content,
),
),
}}
/>
@@ -788,7 +797,10 @@ export default function OrganizationPublicProfile() {
color="inherit"
startIcon={<UpIcon />}
onClick={() => {
moverPost(`${post._id}`, -1);
moverPost(
`${post._id}`,
-1,
);
}}
>
Subir
@@ -798,7 +810,10 @@ export default function OrganizationPublicProfile() {
color="inherit"
startIcon={<DownIcon />}
onClick={() => {
moverPost(`${post._id}`, 1);
moverPost(
`${post._id}`,
1,
);
}}
>
Bajar
@@ -878,11 +893,14 @@ export default function OrganizationPublicProfile() {
<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;
}
}
+322
View File
@@ -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>
)}