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 }, banned: { type: Boolean, required: false, default: false },
showPublicScores: { type: Boolean, required: false, default: true }, showPublicScores: { type: Boolean, required: false, default: true },
showPublicOpinions: { 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); this.companyList = model<ICompanyDocument>("Company", this.schema);
@@ -245,6 +246,10 @@ export class CompaniesAdapterMongoose implements ICompaniesAdapter {
updateCompany.showPublicOpinions = data.showPublicOpinions; updateCompany.showPublicOpinions = data.showPublicOpinions;
} }
if (data.showPublicProfessionals !== undefined) {
updateCompany.showPublicProfessionals = data.showPublicProfessionals;
}
updateCompany.save(); updateCompany.save();
//actualizo los campos correspondientes a la localizacion de los servicios de la organizacion. //actualizo los campos correspondientes a la localizacion de los servicios de la organizacion.
@@ -56,6 +56,7 @@ export type UpdateCompanyParams = {
banned?: boolean; banned?: boolean;
showPublicScores?: boolean; showPublicScores?: boolean;
showPublicOpinions?: boolean; showPublicOpinions?: boolean;
showPublicProfessionals?: boolean;
}; };
export type SetCompanyFileParams = { export type SetCompanyFileParams = {
@@ -158,6 +159,7 @@ export interface ICompany {
banned?: boolean; banned?: boolean;
showPublicScores?: boolean; showPublicScores?: boolean;
showPublicOpinions?: boolean; showPublicOpinions?: boolean;
showPublicProfessionals?: boolean;
} }
export interface MyOranizationsView { export interface MyOranizationsView {
@@ -209,6 +211,7 @@ export interface MyOranizationsView {
banned?: boolean; banned?: boolean;
showPublicScores?: boolean; showPublicScores?: boolean;
showPublicOpinions?: boolean; showPublicOpinions?: boolean;
showPublicProfessionals?: boolean;
} }
export interface ClientOrganizationView { export interface ClientOrganizationView {
+8 -1
View File
@@ -500,7 +500,11 @@ class CompaniesManager implements ICompaniesManager {
throw new Error(NoPermissionMessage()); 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)); await this.validatePublicRatingFlagsPlan(String(companyCheck.ownerId));
} }
@@ -614,6 +618,7 @@ class CompaniesManager implements ICompaniesManager {
onboardingCompleted: company.onboardingCompleted, onboardingCompleted: company.onboardingCompleted,
showPublicScores: company.showPublicScores ?? true, showPublicScores: company.showPublicScores ?? true,
showPublicOpinions: company.showPublicOpinions ?? true, showPublicOpinions: company.showPublicOpinions ?? true,
showPublicProfessionals: company.showPublicProfessionals ?? true,
}; };
} }
@@ -735,6 +740,7 @@ class CompaniesManager implements ICompaniesManager {
onboardingCompleted: company.onboardingCompleted, onboardingCompleted: company.onboardingCompleted,
showPublicScores: company.showPublicScores ?? true, showPublicScores: company.showPublicScores ?? true,
showPublicOpinions: company.showPublicOpinions ?? true, showPublicOpinions: company.showPublicOpinions ?? true,
showPublicProfessionals: company.showPublicProfessionals ?? true,
}); });
} }
@@ -807,6 +813,7 @@ class CompaniesManager implements ICompaniesManager {
onboardingCompleted: company.onboardingCompleted, onboardingCompleted: company.onboardingCompleted,
showPublicScores: company.showPublicScores ?? true, showPublicScores: company.showPublicScores ?? true,
showPublicOpinions: company.showPublicOpinions ?? true, showPublicOpinions: company.showPublicOpinions ?? true,
showPublicProfessionals: company.showPublicProfessionals ?? true,
}); });
} }
} }
+68
View File
@@ -8,6 +8,9 @@ import {
PaginateEmployeesParams, PaginateEmployeesParams,
PaginateEmployeesResults, PaginateEmployeesResults,
CompanyEmployeesView, CompanyEmployeesView,
PublicCompanyEmployeeView,
FindPublicEmployeeParams,
PublicEmployeeView,
FindEmployeesParams, FindEmployeesParams,
FindEmployeesByIdParams, FindEmployeesByIdParams,
UpdateEmployeeRolesParams, UpdateEmployeeRolesParams,
@@ -442,6 +445,71 @@ class EmployeeManager implements IEmployeesManager {
return view; 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> { public async findById(data: FindEmployeesByIdParams): Promise<CompanyEmployeesView> {
const employee = await this.employees.findOne({ _id: data.id }); 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 { export interface IncompleteCollaboratorView {
employeeId: string; employeeId: string;
companyId: string; companyId: string;
@@ -143,6 +162,8 @@ export interface IEmployeesManager {
updateEmployeeRoles(data: UpdateEmployeeRolesParams): Promise<void>; updateEmployeeRoles(data: UpdateEmployeeRolesParams): Promise<void>;
update(data: UpdateEmployeeParams): Promise<void>; update(data: UpdateEmployeeParams): Promise<void>;
findByCompanyId(data: FindEmployeesParams): Promise<CompanyEmployeesView[]>; findByCompanyId(data: FindEmployeesParams): Promise<CompanyEmployeesView[]>;
findPublicByCompanyId(data: FindEmployeesParams): Promise<PublicCompanyEmployeeView[]>;
findPublicByEmployeeId(data: FindPublicEmployeeParams): Promise<PublicEmployeeView>;
findById(data: FindEmployeesByIdParams): Promise<CompanyEmployeesView>; findById(data: FindEmployeesByIdParams): Promise<CompanyEmployeesView>;
textObjectFilter(data: FindEmployeesParams): Promise<TextObjectFilterResult[]>; textObjectFilter(data: FindEmployeesParams): Promise<TextObjectFilterResult[]>;
deleteEmployeesByCompany(data: DeleteEmployeesByCompany): Promise<void>; deleteEmployeesByCompany(data: DeleteEmployeesByCompany): Promise<void>;
@@ -8,8 +8,8 @@ import {
RATING_COMMENT_MAX_LENGTH, RATING_COMMENT_MAX_LENGTH,
RatingTargetSummaryParams, RatingTargetSummaryParams,
RatingTargetSummaryResult, RatingTargetSummaryResult,
RatingTargetReviewItem,
RatingTargetReviewsParams, RatingTargetReviewsParams,
RatingTargetReviewsResult,
RatingTargetType, RatingTargetType,
} from "./Ratings.Interface"; } from "./Ratings.Interface";
@@ -127,20 +127,26 @@ export class RatingsAdapterMongoose implements IRatingsAdapter {
}; };
} }
public async reviewsByTarget(filters: RatingTargetReviewsParams): Promise<RatingTargetReviewItem[]> { 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 const ratings = await this.ratingsList
.find({ .find(targetReviewsFilter)
targetType: filters.targetType,
targetId: new Types.ObjectId(filters.targetId),
comment: { $regex: /\S/ },
})
.sort({ score: -1, createdAt: -1 }) .sort({ score: -1, createdAt: -1 })
.limit(filters.limit || 20) .skip((page - 1) * pageSize)
.limit(pageSize)
.select("score comment createdAt userId") .select("score comment createdAt userId")
.populate("userId", "firstName lastName avatar") .populate("userId", "firstName lastName avatar")
.exec(); .exec();
return (ratings as unknown as RatingReviewDocument[]).map((rating) => { const reviews = (ratings as unknown as RatingReviewDocument[]).map((rating) => {
const user = rating.userId; const user = rating.userId;
const displayName = user ? joinStrings([user.firstName, user.lastName], " ") : ""; const displayName = user ? joinStrings([user.firstName, user.lastName], " ") : "";
const authorName = displayName || GENERIC_REVIEW_AUTHOR; 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 { private mapRating(rating: IRatingDocument): IRating {
+12 -2
View File
@@ -53,6 +53,8 @@ export type RatingTargetSummaryResult = {
}; };
export type RatingTargetReviewsParams = RatingTargetSummaryParams & { export type RatingTargetReviewsParams = RatingTargetSummaryParams & {
page?: number;
pageSize?: number;
limit?: 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 { export interface IRating {
id?: string; id?: string;
appointmentId: string; appointmentId: string;
@@ -101,7 +111,7 @@ export interface IRatingsAdapter {
count(filters: FindRatingsParams): Promise<number>; count(filters: FindRatingsParams): Promise<number>;
summaryByTarget(filters: RatingTargetSummaryParams): Promise<RatingTargetSummaryResult>; summaryByTarget(filters: RatingTargetSummaryParams): Promise<RatingTargetSummaryResult>;
summaryByTargets(filters: { targetType: RatingTargetType; targetIds: string[] }): Promise<RatingTargetSummaryResult>; summaryByTargets(filters: { targetType: RatingTargetType; targetIds: string[] }): Promise<RatingTargetSummaryResult>;
reviewsByTarget(filters: RatingTargetReviewsParams): Promise<RatingTargetReviewItem[]>; reviewsByTarget(filters: RatingTargetReviewsParams): Promise<RatingTargetReviewsResult>;
} }
export interface IRatingsManager { export interface IRatingsManager {
@@ -111,5 +121,5 @@ export interface IRatingsManager {
countByUser(data: CountRatingsByUserParams): Promise<CountRatingsByUserResult>; countByUser(data: CountRatingsByUserParams): Promise<CountRatingsByUserResult>;
professionalSummaryByUser(data: ProfessionalRatingSummaryByUserParams): Promise<RatingTargetSummaryResult>; professionalSummaryByUser(data: ProfessionalRatingSummaryByUserParams): Promise<RatingTargetSummaryResult>;
summaryByTarget(data: RatingTargetSummaryParams): 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, RATING_COMMENT_MAX_LENGTH,
RatingTargetSummaryParams, RatingTargetSummaryParams,
RatingTargetSummaryResult, RatingTargetSummaryResult,
RatingTargetReviewItem,
RatingTargetReviewsParams, RatingTargetReviewsParams,
RatingTargetReviewsResult,
RatingTargetType, RatingTargetType,
} from "./Ratings.Interface"; } from "./Ratings.Interface";
@@ -221,16 +221,21 @@ class RatingsManager implements IRatingsManager {
return this.ratings.summaryByTarget(data); return this.ratings.summaryByTarget(data);
} }
public async reviewsByTarget(data: RatingTargetReviewsParams): Promise<RatingTargetReviewItem[]> { public async reviewsByTarget(data: RatingTargetReviewsParams): Promise<RatingTargetReviewsResult> {
this.validateTargetType(data.targetType); this.validateTargetType(data.targetType);
if (!Types.ObjectId.isValid(data.targetId)) { if (!Types.ObjectId.isValid(data.targetId)) {
throw new Error("No se ha proporcionado un objetivo válido."); 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({ return this.ratings.reviewsByTarget({
...data, ...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; appointmentAlert: string;
showPublicScores: boolean; showPublicScores: boolean;
showPublicOpinions: boolean; showPublicOpinions: boolean;
showPublicProfessionals: boolean;
}; };
export type PublicOrganizationServiceViewParams = { export type PublicOrganizationServiceViewParams = {
@@ -167,6 +168,7 @@ class Views {
appointmentAlert: isNull<string>(company.appointmentAlert, ""), appointmentAlert: isNull<string>(company.appointmentAlert, ""),
showPublicScores: company.showPublicScores ?? true, showPublicScores: company.showPublicScores ?? true,
showPublicOpinions: company.showPublicOpinions ?? true, showPublicOpinions: company.showPublicOpinions ?? true,
showPublicProfessionals: company.showPublicProfessionals ?? true,
}; };
} }
@@ -9,6 +9,9 @@ import {
PaginateEmployeesResults, PaginateEmployeesResults,
ValidateEmployeeParams, ValidateEmployeeParams,
CompanyEmployeesView, CompanyEmployeesView,
PublicCompanyEmployeeView,
FindPublicEmployeeParams,
PublicEmployeeView,
FindEmployeesByIdParams, FindEmployeesByIdParams,
UpdateEmployeeRolesParams, UpdateEmployeeRolesParams,
UpdateEmployeeParams, 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") @Route("employees/get-by-id")
export class GetEmployeesByIdController extends Controller { export class GetEmployeesByIdController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error") @Response<ApiValidationError>(500, "Ha ocurrido un error")
@@ -8,6 +8,9 @@ import {
PaginateEmployeesParams, PaginateEmployeesParams,
PaginateEmployeesResults, PaginateEmployeesResults,
CompanyEmployeesView, CompanyEmployeesView,
PublicCompanyEmployeeView,
FindPublicEmployeeParams,
PublicEmployeeView,
FindEmployeesByIdParams, FindEmployeesByIdParams,
UpdateEmployeeRolesParams, UpdateEmployeeRolesParams,
UpdateEmployeeParams, UpdateEmployeeParams,
@@ -60,6 +63,24 @@ export class EmployeesService {
return employees; 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> { public async findById(data: FindEmployeesByIdParams): Promise<CompanyEmployeesView> {
await connect(`${process.env.DATABASE_CONNECTION}`); await connect(`${process.env.DATABASE_CONNECTION}`);
+2 -2
View File
@@ -7,8 +7,8 @@ import {
PendingRatingItem, PendingRatingItem,
PendingRatingsByUserParams, PendingRatingsByUserParams,
ProfessionalRatingSummaryByUserParams, ProfessionalRatingSummaryByUserParams,
RatingTargetReviewItem,
RatingTargetReviewsParams, RatingTargetReviewsParams,
RatingTargetReviewsResult,
RatingTargetSummaryParams, RatingTargetSummaryParams,
RatingTargetSummaryResult, RatingTargetSummaryResult,
} from "../../Models/Ratings/Ratings.Interface"; } from "../../Models/Ratings/Ratings.Interface";
@@ -126,7 +126,7 @@ export class RatingTargetReviewsController extends Controller {
@Post() @Post()
public async reviewsByTarget( public async reviewsByTarget(
@Body() requestBody: RatingTargetReviewsParams @Body() requestBody: RatingTargetReviewsParams
): Promise<RatingTargetReviewItem[] | ApiValidationError> { ): Promise<RatingTargetReviewsResult | ApiValidationError> {
try { try {
const ratingReviews = await new RatingsService().reviewsByTarget(requestBody); const ratingReviews = await new RatingsService().reviewsByTarget(requestBody);
this.setStatus(200); this.setStatus(200);
+2 -2
View File
@@ -10,8 +10,8 @@ import {
ProfessionalRatingSummaryByUserParams, ProfessionalRatingSummaryByUserParams,
RatingTargetSummaryParams, RatingTargetSummaryParams,
RatingTargetSummaryResult, RatingTargetSummaryResult,
RatingTargetReviewItem,
RatingTargetReviewsParams, RatingTargetReviewsParams,
RatingTargetReviewsResult,
} from "../../Models/Ratings/Ratings.Interface"; } from "../../Models/Ratings/Ratings.Interface";
export class RatingsService { export class RatingsService {
@@ -40,7 +40,7 @@ export class RatingsService {
return await RatingsList.summaryByTarget(data); 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}`); await connect(`${process.env.DATABASE_CONNECTION}`);
return await RatingsList.reviewsByTarget(data); return await RatingsList.reviewsByTarget(data);
} }
+4
View File
@@ -47,6 +47,7 @@ export interface MyOranizationsView {
onboardingCompleted?: boolean; onboardingCompleted?: boolean;
showPublicScores: boolean; showPublicScores: boolean;
showPublicOpinions: boolean; showPublicOpinions: boolean;
showPublicProfessionals: boolean;
} }
export type CreateCompanyParams = { export type CreateCompanyParams = {
@@ -97,6 +98,7 @@ export type UpdateCompanyParams = {
onboardingCompleted?: boolean; onboardingCompleted?: boolean;
showPublicScores?: boolean; showPublicScores?: boolean;
showPublicOpinions?: boolean; showPublicOpinions?: boolean;
showPublicProfessionals?: boolean;
}; };
export type DeleteCompanyParams = { export type DeleteCompanyParams = {
@@ -164,6 +166,7 @@ export interface ICompany {
banned?: boolean; banned?: boolean;
showPublicScores?: boolean; showPublicScores?: boolean;
showPublicOpinions?: boolean; showPublicOpinions?: boolean;
showPublicProfessionals?: boolean;
} }
export type PublicOrganizationViewParams = { export type PublicOrganizationViewParams = {
@@ -192,6 +195,7 @@ export type PublicOrganizationView = {
banned?: boolean; banned?: boolean;
showPublicScores: boolean; showPublicScores: boolean;
showPublicOpinions: boolean; showPublicOpinions: boolean;
showPublicProfessionals: boolean;
}; };
export type SetNotificationAutoParams = { 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 & { export type RatingTargetReviewsParams = RatingTargetSummaryParams & {
page?: number;
pageSize?: number;
limit?: 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 { export interface PendingRatingItem {
appointmentId: string; appointmentId: string;
appointmentDate: 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, RATING_COMMENT_MAX_LENGTH,
RatingTargetSummaryParams, RatingTargetSummaryParams,
RatingTargetSummaryResult, RatingTargetSummaryResult,
RatingTargetReviewItem,
RatingTargetReviewsParams, RatingTargetReviewsParams,
RatingTargetReviewsResult,
RatingTargetType, RatingTargetType,
} from "@core/Models/Ratings.model"; } from "@core/Models/Ratings.model";
import * as Yup from "yup"; import * as Yup from "yup";
@@ -48,6 +48,8 @@ const schemaRatingTargetSummary = Yup.object().shape({
}); });
const schemaRatingTargetReviews = schemaRatingTargetSummary.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), 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[]> => { export const ratingTargetReviews = async (data: RatingTargetReviewsParams): Promise<RatingTargetReviewsResult> => {
return new Promise<RatingTargetReviewItem[]>((resolve, reject) => { return new Promise<RatingTargetReviewsResult>((resolve, reject) => {
schemaRatingTargetReviews schemaRatingTargetReviews
.validate(data, { abortEarly: true }) .validate(data, { abortEarly: true })
.then(() => { .then(() => {
resolve(ApiRequest.post<RatingTargetReviewItem[]>("ratings/target-reviews", data)); resolve(ApiRequest.post<RatingTargetReviewsResult>("ratings/target-reviews", data));
}) })
.catch((error) => { .catch((error) => {
reject(new ApiError(500, error.message)); reject(new ApiError(500, error.message));
@@ -62,6 +62,7 @@ export default function OrganizationEdit() {
const [orgSlug, setOrgSlug] = useState(""); const [orgSlug, setOrgSlug] = useState("");
const [showPublicScores, setShowPublicScores] = useState(true); const [showPublicScores, setShowPublicScores] = useState(true);
const [showPublicOpinions, setShowPublicOpinions] = useState(true); const [showPublicOpinions, setShowPublicOpinions] = useState(true);
const [showPublicProfessionals, setShowPublicProfessionals] = useState(true);
const [servicesShowPublicScores, setServicesShowPublicScores] = useState(true); const [servicesShowPublicScores, setServicesShowPublicScores] = useState(true);
const [servicesShowPublicOpinions, setServicesShowPublicOpinions] = useState(true); const [servicesShowPublicOpinions, setServicesShowPublicOpinions] = useState(true);
const [originalServicesShowPublicScores, setOriginalServicesShowPublicScores] = useState(true); const [originalServicesShowPublicScores, setOriginalServicesShowPublicScores] = useState(true);
@@ -76,7 +77,7 @@ export default function OrganizationEdit() {
const [combosLoaded, setCombosLoaded] = useState<boolean>(false); const [combosLoaded, setCombosLoaded] = useState<boolean>(false);
const ownerSubscription = SessionInfo.organizationSubscriptions[id]; const ownerSubscription = SessionInfo.organizationSubscriptions[id];
const canEditPublicRatingFlags = (ownerSubscription?.plan.price || 0) > 0; const canEditPublicVisibilityFlags = (ownerSubscription?.plan.price || 0) > 0;
const [categoryFilterContent] = useState<TextObjectModalContent>({ const [categoryFilterContent] = useState<TextObjectModalContent>({
header: ( header: (
@@ -242,6 +243,11 @@ export default function OrganizationEdit() {
setOrgPublished(organization.published); setOrgPublished(organization.published);
setShowPublicScores(organization.showPublicScores !== undefined ? organization.showPublicScores : true); setShowPublicScores(organization.showPublicScores !== undefined ? organization.showPublicScores : true);
setShowPublicOpinions(organization.showPublicOpinions !== undefined ? organization.showPublicOpinions : true); setShowPublicOpinions(organization.showPublicOpinions !== undefined ? organization.showPublicOpinions : true);
setShowPublicProfessionals(
organization.showPublicProfessionals !== undefined
? organization.showPublicProfessionals
: true
);
setServices(loadedServices); setServices(loadedServices);
setServicesShowPublicScores( setServicesShowPublicScores(
loadedServices.every((service) => service.showPublicScores !== false) loadedServices.every((service) => service.showPublicScores !== false)
@@ -302,10 +308,11 @@ export default function OrganizationEdit() {
templateWapNotifId: templateWapNotifId === "" ? undefined : templateWapNotifId, templateWapNotifId: templateWapNotifId === "" ? undefined : templateWapNotifId,
templateWapAltaId: templateWapAltaId === "" ? undefined : templateWapAltaId, templateWapAltaId: templateWapAltaId === "" ? undefined : templateWapAltaId,
templateWapCancellationId: templateWapCancellationId === "" ? undefined : templateWapCancellationId, templateWapCancellationId: templateWapCancellationId === "" ? undefined : templateWapCancellationId,
...(canEditPublicRatingFlags ...(canEditPublicVisibilityFlags
? { ? {
showPublicScores, showPublicScores,
showPublicOpinions, showPublicOpinions,
showPublicProfessionals,
} }
: {}), : {}),
}; };
@@ -378,7 +385,7 @@ export default function OrganizationEdit() {
const servicesShowPublicOpinionsChanged = const servicesShowPublicOpinionsChanged =
originalServicesShowPublicOpinions !== servicesShowPublicOpinions; originalServicesShowPublicOpinions !== servicesShowPublicOpinions;
const changedServices = const changedServices =
canEditPublicRatingFlags && canEditPublicVisibilityFlags &&
(servicesShowPublicScoresChanged || servicesShowPublicOpinionsChanged) (servicesShowPublicScoresChanged || servicesShowPublicOpinionsChanged)
? services ? services
: []; : [];
@@ -574,13 +581,18 @@ export default function OrganizationEdit() {
/> />
<MaterialAlert severity="info" sx={{ marginTop: "20px" }}> <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> </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" }}> <div style={{ display: "flex", flexDirection: "column", gap: "10px", marginTop: "10px" }}>
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}> <label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}>
<Checkbox <Checkbox
checked={showPublicScores} checked={showPublicScores}
disabled={!canEditPublicRatingFlags} disabled={!canEditPublicVisibilityFlags}
onChange={(event) => { onChange={(event) => {
setShowPublicScores(event.target.checked); setShowPublicScores(event.target.checked);
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE); eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
@@ -591,7 +603,7 @@ export default function OrganizationEdit() {
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}> <label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}>
<Checkbox <Checkbox
checked={showPublicOpinions} checked={showPublicOpinions}
disabled={!canEditPublicRatingFlags} disabled={!canEditPublicVisibilityFlags}
onChange={(event) => { onChange={(event) => {
setShowPublicOpinions(event.target.checked); setShowPublicOpinions(event.target.checked);
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE); eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
@@ -599,7 +611,18 @@ export default function OrganizationEdit() {
/> />
Mostrar opiniones públicas Mostrar opiniones públicas
</label> </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" }}> <span style={{ color: "var(--gray-darkest)", fontSize: "13px" }}>
Disponible para planes pagos. Disponible para planes pagos.
</span> </span>
@@ -622,7 +645,7 @@ export default function OrganizationEdit() {
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}> <label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}>
<Checkbox <Checkbox
checked={servicesShowPublicScores} checked={servicesShowPublicScores}
disabled={!canEditPublicRatingFlags} disabled={!canEditPublicVisibilityFlags}
onChange={(event) => { onChange={(event) => {
setServicesShowPublicScores(event.target.checked); setServicesShowPublicScores(event.target.checked);
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE); eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
@@ -633,7 +656,7 @@ export default function OrganizationEdit() {
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}> <label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}>
<Checkbox <Checkbox
checked={servicesShowPublicOpinions} checked={servicesShowPublicOpinions}
disabled={!canEditPublicRatingFlags} disabled={!canEditPublicVisibilityFlags}
onChange={(event) => { onChange={(event) => {
setServicesShowPublicOpinions(event.target.checked); setServicesShowPublicOpinions(event.target.checked);
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE); eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
@@ -641,7 +664,7 @@ export default function OrganizationEdit() {
/> />
Mostrar opiniones públicas de los servicios Mostrar opiniones públicas de los servicios
</label> </label>
{!canEditPublicRatingFlags && ( {!canEditPublicVisibilityFlags && (
<span style={{ color: "var(--gray-darkest)", fontSize: "13px" }}> <span style={{ color: "var(--gray-darkest)", fontSize: "13px" }}>
Para cambiar la visibilidad pública de los servicios necesitás un plan pago. Para cambiar la visibilidad pública de los servicios necesitás un plan pago.
</span> </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; margin: 0 10px 5px;
} }
.titleRow {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.header h2 { .header h2 {
margin: 0; margin: 0;
padding-bottom: 10px; padding-bottom: 10px;
@@ -17,6 +24,19 @@
line-height: 1.25; 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 { .header p {
max-width: 620px; max-width: 620px;
margin: 0 0 10px; margin: 0 0 10px;
@@ -109,6 +129,10 @@
} }
@media (max-width: 700px) { @media (max-width: 700px) {
.titleRow {
align-items: flex-start;
}
.reviewCard { .reviewCard {
flex-basis: calc(100vw - 40px); flex-basis: calc(100vw - 40px);
} }
@@ -2,6 +2,7 @@
import { useEffect, useRef, useState, type PointerEvent, type ReactElement } from "react"; import { useEffect, useRef, useState, type PointerEvent, type ReactElement } from "react";
import classNames from "classnames"; import classNames from "classnames";
import Link from "next/link";
import Avatar from "@components/Avatar/Avatar"; import Avatar from "@components/Avatar/Avatar";
import { RatingTargetReviewItem, RatingTargetType } from "@core/Models/Ratings.model"; import { RatingTargetReviewItem, RatingTargetType } from "@core/Models/Ratings.model";
import { ratingTargetReviews } from "@services/Ratings.Service"; import { ratingTargetReviews } from "@services/Ratings.Service";
@@ -58,9 +59,9 @@ export default function ReviewsCarousel({
let active = true; let active = true;
ratingTargetReviews({ targetType, targetId, limit }) ratingTargetReviews({ targetType, targetId, limit })
.then((targetReviews) => { .then((targetReviewsResult) => {
if (active) { if (active) {
setReviews(sortReviewsByScore(targetReviews.filter((review) => review.comment.trim().length > 0))); setReviews(sortReviewsByScore(targetReviewsResult.reviews.filter((review) => review.comment.trim().length > 0)));
} }
}) })
.catch(() => { .catch(() => {
@@ -99,7 +100,12 @@ export default function ReviewsCarousel({
return ( return (
<section className={classNames(style.reviewsCarousel, className)} aria-label="Opiniones"> <section className={classNames(style.reviewsCarousel, className)} aria-label="Opiniones">
<div className={style.header}> <div className={style.header}>
<h2>{title}</h2> <div className={style.titleRow}>
<h2>{title}</h2>
<Link className={style.viewMore} href={`/landing/reviews?targetType=${targetType}&targetId=${targetId}`}>
Ver más
</Link>
</div>
{subtitle && <p>{subtitle}</p>} {subtitle && <p>{subtitle}</p>}
</div> </div>
<div <div
+278 -260
View File
@@ -59,6 +59,7 @@ import { useParams } from "next/navigation";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useConfirmStore } from "@core/Store/Confirm.Store"; import { useConfirmStore } from "@core/Store/Confirm.Store";
import ReviewsCarousel from "@core/app/components/ReviewsCarousel/ReviewsCarousel"; import ReviewsCarousel from "@core/app/components/ReviewsCarousel/ReviewsCarousel";
import OrganizationProfessionals from "@core/app/components/OrganizationProfessionals/OrganizationProfessionals";
import { RatingTargetType } from "@core/Models/Ratings.model"; import { RatingTargetType } from "@core/Models/Ratings.model";
enum VIEW { enum VIEW {
@@ -337,7 +338,7 @@ export default function OrganizationPublicProfile() {
return response.data; return response.data;
} }
const newPosts = response.data.filter( const newPosts = response.data.filter(
(newPost) => !prevPosts.some((prevPost) => prevPost._id === newPost._id) (newPost) => !prevPosts.some((prevPost) => prevPost._id === newPost._id),
); );
return [...prevPosts, ...newPosts]; return [...prevPosts, ...newPosts];
@@ -453,7 +454,7 @@ export default function OrganizationPublicProfile() {
eventHandler.setEventType(EVENT_TYPES.SLEEP); eventHandler.setEventType(EVENT_TYPES.SLEEP);
}); });
}, },
() => {} () => {},
); );
}; };
@@ -597,292 +598,309 @@ export default function OrganizationPublicProfile() {
<div className={style.organizationProfileColumns}> <div className={style.organizationProfileColumns}>
<div className={style.organizationMap}>{LocationInfo(orgData)}</div> <div className={style.organizationMap}>{LocationInfo(orgData)}</div>
<div className={style.organizationMainColumn}> <div className={style.organizationMainColumn}>
<div
ref={refDivContent}
style={{
border: "1px solid var(--gray-light)",
borderRadius: "10px",
marginTop: "30px",
width: "100%",
marginBottom: "30px",
backgroundColor: "var(--white)",
}}
>
<div <div
ref={refDivContent}
style={{ style={{
display: "flex", border: "1px solid var(--gray-light)",
flexDirection: "row", borderRadius: "10px",
justifyContent: "space-between", marginTop: "30px",
borderBottom: "1px solid var(--gray-light)", width: "100%",
marginBottom: "30px",
backgroundColor: "var(--white)",
}} }}
> >
<div <div
style={{ style={{
height: "50px",
display: "flex", display: "flex",
alignItems: "center", flexDirection: "row",
gap: "20px", justifyContent: "space-between",
}} borderBottom: "1px solid var(--gray-light)",
>
<div
style={getHeaderItemStyle(VIEW.POSTS)}
onClick={() => {
setView(VIEW.POSTS);
processAddButton(VIEW.POSTS);
}}
>
Posts
</div>
<div
style={getHeaderItemStyle(VIEW.SERVICES)}
onClick={() => {
setView(VIEW.SERVICES);
processAddButton(VIEW.SERVICES);
}}
>
Servicios
</div>
{checkShowInfo() && (
<div
style={getHeaderItemStyle(VIEW.INFO)}
onClick={() => {
setView(VIEW.INFO);
processAddButton(VIEW.INFO);
}}
>
Contacto
</div>
)}
</div>
{view === VIEW.SERVICES && (
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<ButtonGroup
variant="outlined"
aria-label="Basic button group"
sx={{ marginTop: "10px", marginRight: "30px" }}
>
<IconButton onClick={() => setViewMode("card")}>
<GridViewOutlinedIcon
sx={{
color:
viewMode === "card" ? "var(--wine-red)" : "ineherit",
}}
/>
</IconButton>
<IconButton onClick={() => setViewMode("grid")}>
<SplitscreenOutlinedIcon
sx={{
color:
viewMode === "grid" ? "var(--wine-red)" : "ineherit",
}}
/>
</IconButton>
</ButtonGroup>
</div>
)}
</div>
<div>
{view === VIEW.POSTS && (
<div
ref={refPostsContainer}
style={{
overflowY: "scroll",
height: `${getContentHeight()}px`,
}}
>
{showAddButton && (
<div
style={getAddButtonStyle()}
onClick={() => {
setEditorPostId("");
setEditorContent("");
setShowEditor(true);
}}
>
<AddIcon sx={{ fontSize: "32px" }} />
</div>
)}
{posts.length === 0 && (
<div
style={{
textAlign: "center",
backgroundImage: "url(/no-conversation.webp)",
backgroundRepeat: "no-repeat",
backgroundPosition: "center center",
backgroundSize: "50% auto",
height: "100%",
}}
>
Nada por aquí
</div>
)}
{posts.map((post: IPost) => {
return (
<div
key={post._id}
style={{
borderBottom: "1px solid var(--gray-light)",
paddingTop: "5px",
paddingBottom: "10px",
paddingLeft: "30px",
paddingRight: "30px",
}}
>
<div
style={{
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
}}
>
<div style={{ color: "var(--gray-darkest)" }}>
{dayjs(post.createdAt).format("DD MMMM YYYY HH:mm")}
</div>
{post.fixed && (
<div
style={{
color: "var(--gray-light)",
display: "flex",
flexDirection: "row",
alignItems: "center",
}}
>
<PinIcon fontSize="small" />
<span>Anclado</span>
</div>
)}
</div>
<div
dangerouslySetInnerHTML={{
__html: preprocessQuillHtml(
transformQuillContentForDisplay(post.content)
),
}}
/>
{checkPostButtons() && (
<div
style={{
marginTop: "10px",
display: "flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-end",
flexWrap: "wrap",
color: "var(--gray-darkest)",
}}
>
<ThemeProvider theme={turnosXpressTheme}>
<MaterialButton
color="inherit"
startIcon={<ClipIcon />}
onClick={() => {
fijarPost(`${post._id}`);
}}
>
{post.fixed ? "Liberar" : "Fijar"}
</MaterialButton>
{post.fixed && (
<>
<MaterialButton
color="inherit"
startIcon={<UpIcon />}
onClick={() => {
moverPost(`${post._id}`, -1);
}}
>
Subir
</MaterialButton>
<MaterialButton
color="inherit"
startIcon={<DownIcon />}
onClick={() => {
moverPost(`${post._id}`, 1);
}}
>
Bajar
</MaterialButton>
</>
)}
<MaterialButton
color="inherit"
onClick={() => {
editarPost(`${post._id}`);
}}
>
<EditIcon />
</MaterialButton>
<MaterialButton
color="inherit"
onClick={() => {
eliminarPost(`${post._id}`);
}}
>
<DeleteIcon />
</MaterialButton>
</ThemeProvider>
</div>
)}
</div>
);
})}
</div>
)}
{view === VIEW.SERVICES && (
<div
className={style.organizationServices}
style={{
overflowY: "scroll",
height: `${getContentHeight()}px`,
}} }}
> >
<div <div
style={{ style={{
padding: "0px 0px 40px 0px", height: "50px",
display: "flex", display: "flex",
flexDirection: viewMode === "card" ? "row" : "column", alignItems: "center",
justifyContent: "start",
gap: "20px", gap: "20px",
flexWrap: "wrap",
}} }}
> >
{orgData.services.data.map((service) => ( <div
<ProductItem style={getHeaderItemStyle(VIEW.POSTS)}
product={service} onClick={() => {
key={service.id} setView(VIEW.POSTS);
width={260} processAddButton(VIEW.POSTS);
viewMode={viewMode} }}
/> >
))} Posts
</div>
<div
style={getHeaderItemStyle(VIEW.SERVICES)}
onClick={() => {
setView(VIEW.SERVICES);
processAddButton(VIEW.SERVICES);
}}
>
Servicios
</div>
{checkShowInfo() && (
<div
style={getHeaderItemStyle(VIEW.INFO)}
onClick={() => {
setView(VIEW.INFO);
processAddButton(VIEW.INFO);
}}
>
Contacto
</div>
)}
</div> </div>
{view === VIEW.SERVICES && (
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<ButtonGroup
variant="outlined"
aria-label="Basic button group"
sx={{ marginTop: "10px", marginRight: "30px" }}
>
<IconButton onClick={() => setViewMode("card")}>
<GridViewOutlinedIcon
sx={{
color:
viewMode === "card"
? "var(--wine-red)"
: "ineherit",
}}
/>
</IconButton>
<IconButton onClick={() => setViewMode("grid")}>
<SplitscreenOutlinedIcon
sx={{
color:
viewMode === "grid"
? "var(--wine-red)"
: "ineherit",
}}
/>
</IconButton>
</ButtonGroup>
</div>
)}
</div> </div>
)} <div>
{view === VIEW.INFO && checkShowInfo() && ( {view === VIEW.POSTS && (
<div <div
className={style.organizationMoreInfo} ref={refPostsContainer}
style={{ style={{
overflowY: "auto", overflowY: "scroll",
}} height: `${getContentHeight()}px`,
> }}
{LocationInfo(orgData)} >
{showAddButton && (
<div
style={getAddButtonStyle()}
onClick={() => {
setEditorPostId("");
setEditorContent("");
setShowEditor(true);
}}
>
<AddIcon sx={{ fontSize: "32px" }} />
</div>
)}
{posts.length === 0 && (
<div
style={{
textAlign: "center",
backgroundImage: "url(/no-conversation.webp)",
backgroundRepeat: "no-repeat",
backgroundPosition: "center center",
backgroundSize: "50% auto",
height: "100%",
}}
>
Nada por aquí
</div>
)}
{posts.map((post: IPost) => {
return (
<div
key={post._id}
style={{
borderBottom: "1px solid var(--gray-light)",
paddingTop: "5px",
paddingBottom: "10px",
paddingLeft: "30px",
paddingRight: "30px",
}}
>
<div
style={{
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
}}
>
<div style={{ color: "var(--gray-darkest)" }}>
{dayjs(post.createdAt).format(
"DD MMMM YYYY HH:mm",
)}
</div>
{post.fixed && (
<div
style={{
color: "var(--gray-light)",
display: "flex",
flexDirection: "row",
alignItems: "center",
}}
>
<PinIcon fontSize="small" />
<span>Anclado</span>
</div>
)}
</div>
<div
dangerouslySetInnerHTML={{
__html: preprocessQuillHtml(
transformQuillContentForDisplay(
post.content,
),
),
}}
/>
{checkPostButtons() && (
<div
style={{
marginTop: "10px",
display: "flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-end",
flexWrap: "wrap",
color: "var(--gray-darkest)",
}}
>
<ThemeProvider theme={turnosXpressTheme}>
<MaterialButton
color="inherit"
startIcon={<ClipIcon />}
onClick={() => {
fijarPost(`${post._id}`);
}}
>
{post.fixed ? "Liberar" : "Fijar"}
</MaterialButton>
{post.fixed && (
<>
<MaterialButton
color="inherit"
startIcon={<UpIcon />}
onClick={() => {
moverPost(
`${post._id}`,
-1,
);
}}
>
Subir
</MaterialButton>
<MaterialButton
color="inherit"
startIcon={<DownIcon />}
onClick={() => {
moverPost(
`${post._id}`,
1,
);
}}
>
Bajar
</MaterialButton>
</>
)}
<MaterialButton
color="inherit"
onClick={() => {
editarPost(`${post._id}`);
}}
>
<EditIcon />
</MaterialButton>
<MaterialButton
color="inherit"
onClick={() => {
eliminarPost(`${post._id}`);
}}
>
<DeleteIcon />
</MaterialButton>
</ThemeProvider>
</div>
)}
</div>
);
})}
</div>
)}
{view === VIEW.SERVICES && (
<div
className={style.organizationServices}
style={{
overflowY: "scroll",
height: `${getContentHeight()}px`,
}}
>
<div
style={{
padding: "0px 0px 40px 0px",
display: "flex",
flexDirection: viewMode === "card" ? "row" : "column",
justifyContent: "start",
gap: "20px",
flexWrap: "wrap",
}}
>
{orgData.services.data.map((service) => (
<ProductItem
product={service}
key={service.id}
width={260}
viewMode={viewMode}
/>
))}
</div>
</div>
)}
{view === VIEW.INFO && checkShowInfo() && (
<div
className={style.organizationMoreInfo}
style={{
overflowY: "auto",
}}
>
{LocationInfo(orgData)}
</div>
)}
</div> </div>
)} </div>
</div>
</div>
</div> </div>
</div> </div>
{orgData.showPublicOpinions !== false && ( {orgData.showPublicOpinions !== false && (
<ReviewsCarousel <ReviewsCarousel
targetType={RatingTargetType.COMPANY} targetType={RatingTargetType.COMPANY}
targetId={orgData.id} 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á." subtitle="Opiniones reales de personas que reservaron turnos acá."
className={style.organizationReviews} className={style.organizationReviews}
/> />
)} )}
{orgData.showPublicProfessionals !== false && (
<OrganizationProfessionals companyId={orgData.id} />
)}
</div> </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", "views/service",
{ {
serviceId: serviceId, serviceId: serviceId,
} },
); );
return organizationServiceData; return organizationServiceData;
@@ -42,9 +42,7 @@ export default async function OrganizationPublicProfile({ params }: Props) {
return ( return (
<> <>
{!organizationService.organization && !organizationService.service && ( {!organizationService.organization && !organizationService.service && <ServicePrivate />}
<ServicePrivate />
)}
{organizationService.organization && organizationService.service && ( {organizationService.organization && organizationService.service && (
<AnimatedContainer <AnimatedContainer
color={organizationService.organization.headerColor} color={organizationService.organization.headerColor}
@@ -70,9 +68,7 @@ export default async function OrganizationPublicProfile({ params }: Props) {
<Appointment <Appointment
serviceId={sid} serviceId={sid}
companyId={organizationService.organization.id} companyId={organizationService.organization.id}
appointmentAlert={ appointmentAlert={organizationService.organization.appointmentAlert}
organizationService.organization.appointmentAlert
}
/> />
</div> </div>
</div> </div>
@@ -86,10 +82,18 @@ export default async function OrganizationPublicProfile({ params }: Props) {
<ReviewsCarousel <ReviewsCarousel
targetType={RatingTargetType.SERVICE} targetType={RatingTargetType.SERVICE}
targetId={sid} 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." 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> </div>
</AnimatedContainer> </AnimatedContainer>
)} )}