feat: add public employee profile view and implement ratings review page components
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[]> {
|
||||
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({
|
||||
targetType: filters.targetType,
|
||||
targetId: new Types.ObjectId(filters.targetId),
|
||||
comment: { $regex: /\S/ },
|
||||
})
|
||||
.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 {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
.professionals {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
margin: 4px 0 30px;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin: 0 10px 5px;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
padding-bottom: 10px;
|
||||
color: #241f1b;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.header p {
|
||||
max-width: 620px;
|
||||
margin: 0 0 10px;
|
||||
color: #6d6258;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.scroller {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
width: 100%;
|
||||
padding: 0 10px 12px;
|
||||
overflow-x: auto;
|
||||
cursor: grab;
|
||||
scroll-padding-left: 10px;
|
||||
scroll-snap-type: x mandatory;
|
||||
scrollbar-width: thin;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex: 0 0 min(300px, calc(100vw - 40px));
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-height: 116px;
|
||||
padding: 20px;
|
||||
border: 1px solid rgba(45, 29, 21, 0.1);
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
box-shadow: 0 10px 24px rgba(45, 29, 21, 0.06);
|
||||
color: inherit;
|
||||
scroll-snap-align: start;
|
||||
text-decoration: none;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: rgba(116, 35, 59, 0.28);
|
||||
box-shadow: 0 14px 30px rgba(45, 29, 21, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.cardBody {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cardBody h3 {
|
||||
overflow: hidden;
|
||||
margin: 0 0 10px;
|
||||
color: #342d27;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rating {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stars {
|
||||
color: #e8a533;
|
||||
font-size: 1.25rem;
|
||||
letter-spacing: 0.06em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.score {
|
||||
color: #342d27;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.emptyRating {
|
||||
margin: 0;
|
||||
color: #8b8178;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.card {
|
||||
flex-basis: calc(100vw - 40px);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type PointerEvent, type ReactElement } from "react";
|
||||
import classNames from "classnames";
|
||||
import Link from "next/link";
|
||||
import Avatar from "@components/Avatar/Avatar";
|
||||
import { PublicCompanyEmployeeView } from "@core/Models/Employees.model";
|
||||
import { RatingTargetSummaryResult, RatingTargetType } from "@core/Models/Ratings.model";
|
||||
import { findPublicCompanyEmployees } from "@services/Employees.Service";
|
||||
import { ratingTargetSummary } from "@services/Ratings.Service";
|
||||
import style from "./OrganizationProfessionals.module.css";
|
||||
|
||||
interface OrganizationProfessionalsProps {
|
||||
companyId: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type ProfessionalWithRating = PublicCompanyEmployeeView & {
|
||||
rating: RatingTargetSummaryResult | null;
|
||||
};
|
||||
|
||||
const renderStars = (score: number): string => {
|
||||
const normalizedScore = Math.min(Math.max(Math.round(score), 1), 5);
|
||||
return "★".repeat(normalizedScore) + "☆".repeat(5 - normalizedScore);
|
||||
};
|
||||
|
||||
const formatScore = (score: number): string => score.toFixed(1);
|
||||
|
||||
const getFullName = (professional: PublicCompanyEmployeeView): string => {
|
||||
return [professional.firstName, professional.lastName].filter(Boolean).join(" ").trim();
|
||||
};
|
||||
|
||||
export default function OrganizationProfessionals({
|
||||
companyId,
|
||||
title = "Profesionales de la organización",
|
||||
subtitle = "Conocé a quienes atienden y sus opiniones.",
|
||||
className,
|
||||
}: OrganizationProfessionalsProps): ReactElement | null {
|
||||
const [professionals, setProfessionals] = useState<ProfessionalWithRating[]>([]);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
findPublicCompanyEmployees({ companyId })
|
||||
.then(async (employees) => {
|
||||
const employeesWithRatings = await Promise.all(
|
||||
employees.map(async (employee) => {
|
||||
try {
|
||||
const rating = await ratingTargetSummary({
|
||||
targetType: RatingTargetType.EMPLOYEE,
|
||||
targetId: employee.employeeId,
|
||||
});
|
||||
|
||||
return { ...employee, rating };
|
||||
} catch {
|
||||
return { ...employee, rating: null };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (active) {
|
||||
setProfessionals(employeesWithRatings);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) {
|
||||
setProfessionals([]);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [companyId]);
|
||||
|
||||
const handlePointerDown = () => {
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
||||
const scroller = scrollerRef.current;
|
||||
|
||||
if (!isDragging || !scroller) {
|
||||
return;
|
||||
}
|
||||
|
||||
scroller.scrollLeft -= event.movementX;
|
||||
};
|
||||
|
||||
const stopDragging = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
if (professionals.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={classNames(style.professionals, className)} aria-label="Profesionales">
|
||||
<div className={style.header}>
|
||||
<h2>{title}</h2>
|
||||
{subtitle && <p>{subtitle}</p>}
|
||||
</div>
|
||||
<div
|
||||
ref={scrollerRef}
|
||||
className={classNames(style.scroller, { [style.dragging]: isDragging })}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={stopDragging}
|
||||
onPointerLeave={stopDragging}
|
||||
>
|
||||
{professionals.map((professional) => {
|
||||
const fullName = getFullName(professional) || "Profesional";
|
||||
const hasRating = Boolean(professional.rating && professional.rating.totalCount > 0);
|
||||
|
||||
return (
|
||||
<Link
|
||||
className={style.card}
|
||||
href={`/landing/reviews?targetType=employee&targetId=${professional.employeeId}`}
|
||||
key={professional.employeeId}
|
||||
>
|
||||
<Avatar
|
||||
name={`professional-${professional.employeeId}`}
|
||||
src={professional.avatarUrl || fullName}
|
||||
alt={`Avatar de ${fullName}`}
|
||||
size="medium"
|
||||
border="none"
|
||||
/>
|
||||
<div className={style.cardBody}>
|
||||
<h3>{fullName}</h3>
|
||||
{hasRating ? (
|
||||
<div
|
||||
className={style.rating}
|
||||
aria-label={`${formatScore(professional.rating!.averageScore)} de 5 estrellas`}
|
||||
>
|
||||
<span className={style.stars} aria-hidden="true">
|
||||
{renderStars(professional.rating!.averageScore)}
|
||||
</span>
|
||||
<span className={style.score}>
|
||||
{formatScore(professional.rating!.averageScore)}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className={style.emptyRating}>Sin opiniones aún</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,13 @@
|
||||
margin: 0 10px 5px;
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
padding-bottom: 10px;
|
||||
@@ -17,6 +24,19 @@
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.viewMore {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 4px;
|
||||
color: var(--wine-red);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.viewMore:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.header p {
|
||||
max-width: 620px;
|
||||
margin: 0 0 10px;
|
||||
@@ -109,6 +129,10 @@
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.titleRow {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.reviewCard {
|
||||
flex-basis: calc(100vw - 40px);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState, type PointerEvent, type ReactElement } from "react";
|
||||
import classNames from "classnames";
|
||||
import Link from "next/link";
|
||||
import Avatar from "@components/Avatar/Avatar";
|
||||
import { RatingTargetReviewItem, RatingTargetType } from "@core/Models/Ratings.model";
|
||||
import { ratingTargetReviews } from "@services/Ratings.Service";
|
||||
@@ -58,9 +59,9 @@ export default function ReviewsCarousel({
|
||||
let active = true;
|
||||
|
||||
ratingTargetReviews({ targetType, targetId, limit })
|
||||
.then((targetReviews) => {
|
||||
.then((targetReviewsResult) => {
|
||||
if (active) {
|
||||
setReviews(sortReviewsByScore(targetReviews.filter((review) => review.comment.trim().length > 0)));
|
||||
setReviews(sortReviewsByScore(targetReviewsResult.reviews.filter((review) => review.comment.trim().length > 0)));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -99,7 +100,12 @@ export default function ReviewsCarousel({
|
||||
return (
|
||||
<section className={classNames(style.reviewsCarousel, className)} aria-label="Opiniones">
|
||||
<div className={style.header}>
|
||||
<h2>{title}</h2>
|
||||
<div className={style.titleRow}>
|
||||
<h2>{title}</h2>
|
||||
<Link className={style.viewMore} href={`/landing/reviews?targetType=${targetType}&targetId=${targetId}`}>
|
||||
Ver más
|
||||
</Link>
|
||||
</div>
|
||||
{subtitle && <p>{subtitle}</p>}
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -59,6 +59,7 @@ import { useParams } from "next/navigation";
|
||||
import dayjs from "dayjs";
|
||||
import { useConfirmStore } from "@core/Store/Confirm.Store";
|
||||
import ReviewsCarousel from "@core/app/components/ReviewsCarousel/ReviewsCarousel";
|
||||
import OrganizationProfessionals from "@core/app/components/OrganizationProfessionals/OrganizationProfessionals";
|
||||
import { RatingTargetType } from "@core/Models/Ratings.model";
|
||||
|
||||
enum VIEW {
|
||||
@@ -337,7 +338,7 @@ export default function OrganizationPublicProfile() {
|
||||
return response.data;
|
||||
}
|
||||
const newPosts = response.data.filter(
|
||||
(newPost) => !prevPosts.some((prevPost) => prevPost._id === newPost._id)
|
||||
(newPost) => !prevPosts.some((prevPost) => prevPost._id === newPost._id),
|
||||
);
|
||||
|
||||
return [...prevPosts, ...newPosts];
|
||||
@@ -453,7 +454,7 @@ export default function OrganizationPublicProfile() {
|
||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||
});
|
||||
},
|
||||
() => {}
|
||||
() => {},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -597,292 +598,309 @@ export default function OrganizationPublicProfile() {
|
||||
<div className={style.organizationProfileColumns}>
|
||||
<div className={style.organizationMap}>{LocationInfo(orgData)}</div>
|
||||
<div className={style.organizationMainColumn}>
|
||||
<div
|
||||
ref={refDivContent}
|
||||
style={{
|
||||
border: "1px solid var(--gray-light)",
|
||||
borderRadius: "10px",
|
||||
marginTop: "30px",
|
||||
width: "100%",
|
||||
marginBottom: "30px",
|
||||
backgroundColor: "var(--white)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={refDivContent}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
borderBottom: "1px solid var(--gray-light)",
|
||||
border: "1px solid var(--gray-light)",
|
||||
borderRadius: "10px",
|
||||
marginTop: "30px",
|
||||
width: "100%",
|
||||
marginBottom: "30px",
|
||||
backgroundColor: "var(--white)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "50px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "20px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={getHeaderItemStyle(VIEW.POSTS)}
|
||||
onClick={() => {
|
||||
setView(VIEW.POSTS);
|
||||
processAddButton(VIEW.POSTS);
|
||||
}}
|
||||
>
|
||||
Posts
|
||||
</div>
|
||||
<div
|
||||
style={getHeaderItemStyle(VIEW.SERVICES)}
|
||||
onClick={() => {
|
||||
setView(VIEW.SERVICES);
|
||||
processAddButton(VIEW.SERVICES);
|
||||
}}
|
||||
>
|
||||
Servicios
|
||||
</div>
|
||||
{checkShowInfo() && (
|
||||
<div
|
||||
style={getHeaderItemStyle(VIEW.INFO)}
|
||||
onClick={() => {
|
||||
setView(VIEW.INFO);
|
||||
processAddButton(VIEW.INFO);
|
||||
}}
|
||||
>
|
||||
Contacto
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{view === VIEW.SERVICES && (
|
||||
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<ButtonGroup
|
||||
variant="outlined"
|
||||
aria-label="Basic button group"
|
||||
sx={{ marginTop: "10px", marginRight: "30px" }}
|
||||
>
|
||||
<IconButton onClick={() => setViewMode("card")}>
|
||||
<GridViewOutlinedIcon
|
||||
sx={{
|
||||
color:
|
||||
viewMode === "card" ? "var(--wine-red)" : "ineherit",
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
<IconButton onClick={() => setViewMode("grid")}>
|
||||
<SplitscreenOutlinedIcon
|
||||
sx={{
|
||||
color:
|
||||
viewMode === "grid" ? "var(--wine-red)" : "ineherit",
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{view === VIEW.POSTS && (
|
||||
<div
|
||||
ref={refPostsContainer}
|
||||
style={{
|
||||
overflowY: "scroll",
|
||||
height: `${getContentHeight()}px`,
|
||||
}}
|
||||
>
|
||||
{showAddButton && (
|
||||
<div
|
||||
style={getAddButtonStyle()}
|
||||
onClick={() => {
|
||||
setEditorPostId("");
|
||||
setEditorContent("");
|
||||
setShowEditor(true);
|
||||
}}
|
||||
>
|
||||
<AddIcon sx={{ fontSize: "32px" }} />
|
||||
</div>
|
||||
)}
|
||||
{posts.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
backgroundImage: "url(/no-conversation.webp)",
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundPosition: "center center",
|
||||
backgroundSize: "50% auto",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
Nada por aquí
|
||||
</div>
|
||||
)}
|
||||
{posts.map((post: IPost) => {
|
||||
return (
|
||||
<div
|
||||
key={post._id}
|
||||
style={{
|
||||
borderBottom: "1px solid var(--gray-light)",
|
||||
paddingTop: "5px",
|
||||
paddingBottom: "10px",
|
||||
paddingLeft: "30px",
|
||||
paddingRight: "30px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div style={{ color: "var(--gray-darkest)" }}>
|
||||
{dayjs(post.createdAt).format("DD MMMM YYYY HH:mm")}
|
||||
</div>
|
||||
{post.fixed && (
|
||||
<div
|
||||
style={{
|
||||
color: "var(--gray-light)",
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<PinIcon fontSize="small" />
|
||||
<span>Anclado</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: preprocessQuillHtml(
|
||||
transformQuillContentForDisplay(post.content)
|
||||
),
|
||||
}}
|
||||
/>
|
||||
{checkPostButtons() && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "10px",
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
flexWrap: "wrap",
|
||||
color: "var(--gray-darkest)",
|
||||
}}
|
||||
>
|
||||
<ThemeProvider theme={turnosXpressTheme}>
|
||||
<MaterialButton
|
||||
color="inherit"
|
||||
startIcon={<ClipIcon />}
|
||||
onClick={() => {
|
||||
fijarPost(`${post._id}`);
|
||||
}}
|
||||
>
|
||||
{post.fixed ? "Liberar" : "Fijar"}
|
||||
</MaterialButton>
|
||||
|
||||
{post.fixed && (
|
||||
<>
|
||||
<MaterialButton
|
||||
color="inherit"
|
||||
startIcon={<UpIcon />}
|
||||
onClick={() => {
|
||||
moverPost(`${post._id}`, -1);
|
||||
}}
|
||||
>
|
||||
Subir
|
||||
</MaterialButton>
|
||||
|
||||
<MaterialButton
|
||||
color="inherit"
|
||||
startIcon={<DownIcon />}
|
||||
onClick={() => {
|
||||
moverPost(`${post._id}`, 1);
|
||||
}}
|
||||
>
|
||||
Bajar
|
||||
</MaterialButton>
|
||||
</>
|
||||
)}
|
||||
|
||||
<MaterialButton
|
||||
color="inherit"
|
||||
onClick={() => {
|
||||
editarPost(`${post._id}`);
|
||||
}}
|
||||
>
|
||||
<EditIcon />
|
||||
</MaterialButton>
|
||||
|
||||
<MaterialButton
|
||||
color="inherit"
|
||||
onClick={() => {
|
||||
eliminarPost(`${post._id}`);
|
||||
}}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</MaterialButton>
|
||||
</ThemeProvider>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{view === VIEW.SERVICES && (
|
||||
<div
|
||||
className={style.organizationServices}
|
||||
style={{
|
||||
overflowY: "scroll",
|
||||
height: `${getContentHeight()}px`,
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
borderBottom: "1px solid var(--gray-light)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: "0px 0px 40px 0px",
|
||||
height: "50px",
|
||||
display: "flex",
|
||||
flexDirection: viewMode === "card" ? "row" : "column",
|
||||
justifyContent: "start",
|
||||
alignItems: "center",
|
||||
gap: "20px",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
{orgData.services.data.map((service) => (
|
||||
<ProductItem
|
||||
product={service}
|
||||
key={service.id}
|
||||
width={260}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
style={getHeaderItemStyle(VIEW.POSTS)}
|
||||
onClick={() => {
|
||||
setView(VIEW.POSTS);
|
||||
processAddButton(VIEW.POSTS);
|
||||
}}
|
||||
>
|
||||
Posts
|
||||
</div>
|
||||
<div
|
||||
style={getHeaderItemStyle(VIEW.SERVICES)}
|
||||
onClick={() => {
|
||||
setView(VIEW.SERVICES);
|
||||
processAddButton(VIEW.SERVICES);
|
||||
}}
|
||||
>
|
||||
Servicios
|
||||
</div>
|
||||
{checkShowInfo() && (
|
||||
<div
|
||||
style={getHeaderItemStyle(VIEW.INFO)}
|
||||
onClick={() => {
|
||||
setView(VIEW.INFO);
|
||||
processAddButton(VIEW.INFO);
|
||||
}}
|
||||
>
|
||||
Contacto
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{view === VIEW.SERVICES && (
|
||||
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<ButtonGroup
|
||||
variant="outlined"
|
||||
aria-label="Basic button group"
|
||||
sx={{ marginTop: "10px", marginRight: "30px" }}
|
||||
>
|
||||
<IconButton onClick={() => setViewMode("card")}>
|
||||
<GridViewOutlinedIcon
|
||||
sx={{
|
||||
color:
|
||||
viewMode === "card"
|
||||
? "var(--wine-red)"
|
||||
: "ineherit",
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
<IconButton onClick={() => setViewMode("grid")}>
|
||||
<SplitscreenOutlinedIcon
|
||||
sx={{
|
||||
color:
|
||||
viewMode === "grid"
|
||||
? "var(--wine-red)"
|
||||
: "ineherit",
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{view === VIEW.INFO && checkShowInfo() && (
|
||||
<div
|
||||
className={style.organizationMoreInfo}
|
||||
style={{
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{LocationInfo(orgData)}
|
||||
<div>
|
||||
{view === VIEW.POSTS && (
|
||||
<div
|
||||
ref={refPostsContainer}
|
||||
style={{
|
||||
overflowY: "scroll",
|
||||
height: `${getContentHeight()}px`,
|
||||
}}
|
||||
>
|
||||
{showAddButton && (
|
||||
<div
|
||||
style={getAddButtonStyle()}
|
||||
onClick={() => {
|
||||
setEditorPostId("");
|
||||
setEditorContent("");
|
||||
setShowEditor(true);
|
||||
}}
|
||||
>
|
||||
<AddIcon sx={{ fontSize: "32px" }} />
|
||||
</div>
|
||||
)}
|
||||
{posts.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
backgroundImage: "url(/no-conversation.webp)",
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundPosition: "center center",
|
||||
backgroundSize: "50% auto",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
Nada por aquí
|
||||
</div>
|
||||
)}
|
||||
{posts.map((post: IPost) => {
|
||||
return (
|
||||
<div
|
||||
key={post._id}
|
||||
style={{
|
||||
borderBottom: "1px solid var(--gray-light)",
|
||||
paddingTop: "5px",
|
||||
paddingBottom: "10px",
|
||||
paddingLeft: "30px",
|
||||
paddingRight: "30px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div style={{ color: "var(--gray-darkest)" }}>
|
||||
{dayjs(post.createdAt).format(
|
||||
"DD MMMM YYYY HH:mm",
|
||||
)}
|
||||
</div>
|
||||
{post.fixed && (
|
||||
<div
|
||||
style={{
|
||||
color: "var(--gray-light)",
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<PinIcon fontSize="small" />
|
||||
<span>Anclado</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: preprocessQuillHtml(
|
||||
transformQuillContentForDisplay(
|
||||
post.content,
|
||||
),
|
||||
),
|
||||
}}
|
||||
/>
|
||||
{checkPostButtons() && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "10px",
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
flexWrap: "wrap",
|
||||
color: "var(--gray-darkest)",
|
||||
}}
|
||||
>
|
||||
<ThemeProvider theme={turnosXpressTheme}>
|
||||
<MaterialButton
|
||||
color="inherit"
|
||||
startIcon={<ClipIcon />}
|
||||
onClick={() => {
|
||||
fijarPost(`${post._id}`);
|
||||
}}
|
||||
>
|
||||
{post.fixed ? "Liberar" : "Fijar"}
|
||||
</MaterialButton>
|
||||
|
||||
{post.fixed && (
|
||||
<>
|
||||
<MaterialButton
|
||||
color="inherit"
|
||||
startIcon={<UpIcon />}
|
||||
onClick={() => {
|
||||
moverPost(
|
||||
`${post._id}`,
|
||||
-1,
|
||||
);
|
||||
}}
|
||||
>
|
||||
Subir
|
||||
</MaterialButton>
|
||||
|
||||
<MaterialButton
|
||||
color="inherit"
|
||||
startIcon={<DownIcon />}
|
||||
onClick={() => {
|
||||
moverPost(
|
||||
`${post._id}`,
|
||||
1,
|
||||
);
|
||||
}}
|
||||
>
|
||||
Bajar
|
||||
</MaterialButton>
|
||||
</>
|
||||
)}
|
||||
|
||||
<MaterialButton
|
||||
color="inherit"
|
||||
onClick={() => {
|
||||
editarPost(`${post._id}`);
|
||||
}}
|
||||
>
|
||||
<EditIcon />
|
||||
</MaterialButton>
|
||||
|
||||
<MaterialButton
|
||||
color="inherit"
|
||||
onClick={() => {
|
||||
eliminarPost(`${post._id}`);
|
||||
}}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</MaterialButton>
|
||||
</ThemeProvider>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{view === VIEW.SERVICES && (
|
||||
<div
|
||||
className={style.organizationServices}
|
||||
style={{
|
||||
overflowY: "scroll",
|
||||
height: `${getContentHeight()}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: "0px 0px 40px 0px",
|
||||
display: "flex",
|
||||
flexDirection: viewMode === "card" ? "row" : "column",
|
||||
justifyContent: "start",
|
||||
gap: "20px",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
{orgData.services.data.map((service) => (
|
||||
<ProductItem
|
||||
product={service}
|
||||
key={service.id}
|
||||
width={260}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{view === VIEW.INFO && checkShowInfo() && (
|
||||
<div
|
||||
className={style.organizationMoreInfo}
|
||||
style={{
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{LocationInfo(orgData)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{orgData.showPublicOpinions !== false && (
|
||||
<ReviewsCarousel
|
||||
targetType={RatingTargetType.COMPANY}
|
||||
targetId={orgData.id}
|
||||
title="Lo que dicen quienes ya visitaron esta organización"
|
||||
title={`Opiniones sobre ${orgData.name}`}
|
||||
subtitle="Opiniones reales de personas que reservaron turnos acá."
|
||||
className={style.organizationReviews}
|
||||
/>
|
||||
)}
|
||||
{orgData.showPublicProfessionals !== false && (
|
||||
<OrganizationProfessionals companyId={orgData.id} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
.page {
|
||||
width: min(1080px, calc(100% - 32px));
|
||||
min-height: 70vh;
|
||||
margin: 0 auto;
|
||||
padding: 44px 0 64px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: 92px minmax(0, 1fr);
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
margin-bottom: 28px;
|
||||
border: 1px solid rgba(127, 42, 255, 0.14);
|
||||
border-radius: 28px;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(255, 42, 127, 0.08), transparent 36%),
|
||||
linear-gradient(180deg, var(--white), var(--white-dark));
|
||||
box-shadow: 0 26px 70px rgba(16, 0, 39, 0.12);
|
||||
}
|
||||
|
||||
.heroRail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
padding: 24px 14px;
|
||||
border-radius: 26px 0 0 26px;
|
||||
background: linear-gradient(180deg, var(--wine-darkest), var(--wine-dark));
|
||||
color: var(--white);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.heroRail span:last-child {
|
||||
color: var(--yellow);
|
||||
writing-mode: vertical-rl;
|
||||
}
|
||||
|
||||
.heroContent {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(220px, 280px);
|
||||
align-items: center;
|
||||
gap: clamp(22px, 4vw, 44px);
|
||||
padding: clamp(26px, 5vw, 50px);
|
||||
}
|
||||
|
||||
.copyBlock {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 0 14px;
|
||||
padding: 7px 12px;
|
||||
border: 1px solid rgba(255, 42, 127, 0.18);
|
||||
border-radius: 999px;
|
||||
background: var(--yellow-light);
|
||||
color: var(--wine-red);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.targetLabel {
|
||||
display: inline-flex;
|
||||
margin: 0 0 10px;
|
||||
padding: 6px 11px;
|
||||
border: 1px solid rgba(127, 42, 255, 0.16);
|
||||
border-radius: 999px;
|
||||
background: var(--white);
|
||||
color: var(--wine-dark);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.professionalHero {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.professionalIdentity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: clamp(14px, 3vw, 24px);
|
||||
}
|
||||
|
||||
.targetAvatar {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
padding: 5px;
|
||||
border: 1px solid rgba(255, 42, 127, 0.2);
|
||||
border-radius: 50%;
|
||||
background: var(--white);
|
||||
box-shadow: 0 14px 32px rgba(16, 0, 39, 0.12);
|
||||
}
|
||||
|
||||
.targetTitleBlock {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.professionalText {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
max-width: 760px;
|
||||
margin: 0 0 12px;
|
||||
color: var(--black);
|
||||
font-size: clamp(2.05rem, 5vw, 3.75rem);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.055em;
|
||||
}
|
||||
|
||||
.professionalIdentity h1 {
|
||||
max-width: 600px;
|
||||
font-size: clamp(1.9rem, 4.3vw, 3.35rem);
|
||||
}
|
||||
|
||||
.professionalText p {
|
||||
max-width: 540px;
|
||||
font-size: clamp(0.98rem, 1.7vw, 1.12rem);
|
||||
line-height: 1.58;
|
||||
}
|
||||
|
||||
.hero p:last-child {
|
||||
max-width: 660px;
|
||||
margin: 0;
|
||||
color: var(--black-light);
|
||||
font-size: clamp(1.03rem, 2vw, 1.22rem);
|
||||
line-height: 1.62;
|
||||
}
|
||||
|
||||
.summaryCard {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 9px;
|
||||
min-height: 220px;
|
||||
padding: 24px;
|
||||
border: 1px solid rgba(127, 42, 255, 0.13);
|
||||
border-radius: 24px;
|
||||
background:
|
||||
linear-gradient(180deg, var(--white), rgba(253, 228, 84, 0.16));
|
||||
box-shadow: 0 18px 42px rgba(16, 0, 39, 0.1);
|
||||
}
|
||||
|
||||
.summaryLabel {
|
||||
color: var(--gray-dark);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.summaryScore {
|
||||
color: var(--wine-red);
|
||||
font-size: 4rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.06em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.summaryStars {
|
||||
color: #e8a533;
|
||||
font-size: 1.15rem;
|
||||
letter-spacing: 0.07em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.summaryText {
|
||||
color: var(--black-light);
|
||||
font-size: 0.92rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.state {
|
||||
padding: 24px;
|
||||
border: 1px solid rgba(45, 29, 21, 0.08);
|
||||
border-radius: 22px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: #4d443d;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.listHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.listHeader span {
|
||||
color: #8b8178;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.resultsCount {
|
||||
margin: 0;
|
||||
color: #6d6258;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.reviewsList {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.reviewCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
border: 1px solid rgba(45, 29, 21, 0.1);
|
||||
border-radius: 24px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(255, 252, 249, 0.94));
|
||||
box-shadow: 0 14px 32px rgba(45, 29, 21, 0.07);
|
||||
transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease;
|
||||
}
|
||||
|
||||
.reviewCard:hover {
|
||||
border-color: rgba(141, 24, 44, 0.18);
|
||||
box-shadow: 0 18px 42px rgba(45, 29, 21, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.rating {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stars {
|
||||
color: #e8a533;
|
||||
font-size: 1.35rem;
|
||||
letter-spacing: 0.06em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.score {
|
||||
color: #342d27;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.comment {
|
||||
margin: 0;
|
||||
color: #2f2924;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.66;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding-top: 4px;
|
||||
color: #8b8178;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.authorBlock {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.author {
|
||||
overflow: hidden;
|
||||
color: #342d27;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 32px;
|
||||
padding: 18px;
|
||||
border: 1px solid rgba(45, 29, 21, 0.08);
|
||||
border-radius: 22px;
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
color: #6d6258;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.page {
|
||||
width: min(100% - 20px, 960px);
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.heroRail {
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
padding: 14px 18px;
|
||||
border-radius: 26px 26px 0 0;
|
||||
}
|
||||
|
||||
.heroRail span:last-child {
|
||||
writing-mode: initial;
|
||||
}
|
||||
|
||||
.heroContent {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.summaryCard {
|
||||
max-width: none;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.professionalIdentity {
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.professionalHero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.professionalText {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.listHeader {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect, useState, type ChangeEvent, type ReactElement } from "react";
|
||||
import Avatar from "@components/Avatar/Avatar";
|
||||
import ApiRequest from "@services/Api.Service";
|
||||
import { PublicOrganizationView } from "@core/Models/Company.model";
|
||||
import { RatingTargetReviewItem, RatingTargetType } from "@core/Models/Ratings.model";
|
||||
import { PublicOrganizationServiceView } from "@core/Models/Service.model";
|
||||
import { findPublicEmployee } from "@services/Employees.Service";
|
||||
import { ratingTargetReviews, ratingTargetSummary } from "@services/Ratings.Service";
|
||||
import Pagination from "@mui/material/Pagination";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import style from "./page.module.css";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
const OBJECT_ID_PATTERN = /^[a-f\d]{24}$/i;
|
||||
|
||||
const formatReviewDate = (date: string): string => {
|
||||
return new Intl.DateTimeFormat("es-AR", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}).format(new Date(date));
|
||||
};
|
||||
|
||||
const renderStars = (score: number): string => {
|
||||
const normalizedScore = Math.min(Math.max(Math.round(score), 1), 5);
|
||||
return "★".repeat(normalizedScore) + "☆".repeat(5 - normalizedScore);
|
||||
};
|
||||
|
||||
const formatScore = (score: number): string => score.toFixed(1);
|
||||
|
||||
type TargetContext = {
|
||||
label: string;
|
||||
name: string;
|
||||
description: string;
|
||||
avatarUrl?: string;
|
||||
};
|
||||
|
||||
const TARGET_FALLBACKS: Record<RatingTargetType, TargetContext> = {
|
||||
[RatingTargetType.COMPANY]: {
|
||||
label: "Organización",
|
||||
name: "esta organización",
|
||||
description: "Opiniones sobre la experiencia general con la organización.",
|
||||
},
|
||||
[RatingTargetType.SERVICE]: {
|
||||
label: "Servicio",
|
||||
name: "este servicio",
|
||||
description: "Opiniones sobre un servicio específico reservado en TurnosXpress.",
|
||||
},
|
||||
[RatingTargetType.EMPLOYEE]: {
|
||||
label: "Profesional",
|
||||
name: "este profesional",
|
||||
description: "Opiniones sobre la atención recibida con este profesional.",
|
||||
},
|
||||
};
|
||||
|
||||
const isValidTargetType = (value: string | null): value is RatingTargetType => {
|
||||
return Boolean(value && Object.values(RatingTargetType).includes(value as RatingTargetType));
|
||||
};
|
||||
|
||||
const resolveTargetContext = async (targetType: RatingTargetType, targetId: string): Promise<TargetContext> => {
|
||||
const fallback = TARGET_FALLBACKS[targetType];
|
||||
|
||||
if (targetType === RatingTargetType.COMPANY) {
|
||||
const organization = await ApiRequest.post<PublicOrganizationView>("views/organization", { companyId: targetId });
|
||||
|
||||
return {
|
||||
...fallback,
|
||||
name: organization.name || fallback.name,
|
||||
description: organization.description || fallback.description,
|
||||
};
|
||||
}
|
||||
|
||||
if (targetType === RatingTargetType.SERVICE) {
|
||||
const view = await ApiRequest.post<PublicOrganizationServiceView>("views/service", { serviceId: targetId });
|
||||
|
||||
return {
|
||||
...fallback,
|
||||
name: view.service?.name || fallback.name,
|
||||
description: view.organization?.name ? `Servicio ofrecido por ${view.organization.name}.` : fallback.description,
|
||||
};
|
||||
}
|
||||
|
||||
if (targetType === RatingTargetType.EMPLOYEE) {
|
||||
const employee = await findPublicEmployee({ employeeId: targetId });
|
||||
const fullName = [employee.firstName, employee.lastName].filter(Boolean).join(" ").trim();
|
||||
|
||||
return {
|
||||
...fallback,
|
||||
name: fullName || fallback.name,
|
||||
avatarUrl: employee.avatarUrl,
|
||||
};
|
||||
}
|
||||
|
||||
return fallback;
|
||||
};
|
||||
|
||||
function ReviewsContent(): ReactElement {
|
||||
const searchParams = useSearchParams();
|
||||
const targetType = searchParams.get("targetType");
|
||||
const targetId = searchParams.get("targetId");
|
||||
|
||||
const [reviews, setReviews] = useState<RatingTargetReviewItem[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [totalRatings, setTotalRatings] = useState(0);
|
||||
const [averageScore, setAverageScore] = useState(0);
|
||||
const [targetContext, setTargetContext] = useState<TargetContext | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isContextLoading, setIsContextLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const hasValidTarget = isValidTargetType(targetType) && Boolean(targetId && OBJECT_ID_PATTERN.test(targetId));
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [targetType, targetId]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
if (!hasValidTarget) {
|
||||
setReviews([]);
|
||||
setTotalPages(0);
|
||||
setTotal(0);
|
||||
setTotalRatings(0);
|
||||
setAverageScore(0);
|
||||
setTargetContext(null);
|
||||
setError("No encontramos una referencia válida para mostrar opiniones.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setIsContextLoading(true);
|
||||
setError("");
|
||||
|
||||
const validTargetType = targetType as RatingTargetType;
|
||||
const validTargetId = targetId || "";
|
||||
|
||||
resolveTargetContext(validTargetType, validTargetId)
|
||||
.then((context) => {
|
||||
if (active) {
|
||||
setTargetContext(context);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) {
|
||||
setTargetContext(TARGET_FALLBACKS[validTargetType]);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) {
|
||||
setIsContextLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
ratingTargetSummary({ targetType: validTargetType, targetId: validTargetId })
|
||||
.then((summary) => {
|
||||
if (active) {
|
||||
setAverageScore(summary.averageScore);
|
||||
setTotalRatings(summary.totalCount);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) {
|
||||
setAverageScore(0);
|
||||
setTotalRatings(0);
|
||||
}
|
||||
});
|
||||
|
||||
ratingTargetReviews({ targetType: validTargetType, targetId: validTargetId, page, pageSize: PAGE_SIZE })
|
||||
.then((result) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
setReviews(result.reviews.filter((review) => review.comment.trim().length > 0));
|
||||
setTotalPages(result.totalPages);
|
||||
setTotal(result.total);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
setReviews([]);
|
||||
setTotalPages(0);
|
||||
setTotal(0);
|
||||
setError("No pudimos cargar las opiniones. Intentá nuevamente en unos minutos.");
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [hasValidTarget, page, targetId, targetType]);
|
||||
|
||||
const handlePageChange = (_event: ChangeEvent<unknown>, value: number) => {
|
||||
setPage(value);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
};
|
||||
|
||||
const visibleContext = targetContext || (isValidTargetType(targetType) ? TARGET_FALLBACKS[targetType] : null);
|
||||
const heroTitle = isContextLoading ? "Cargando contexto..." : visibleContext?.name || "Opiniones verificadas";
|
||||
const reviewLabel = totalRatings === 1 ? "opinión" : "opiniones";
|
||||
|
||||
return (
|
||||
<main className={style.page}>
|
||||
<section className={style.hero}>
|
||||
<div className={style.heroRail} aria-hidden="true">
|
||||
<span>TX</span>
|
||||
<span>Reviews</span>
|
||||
</div>
|
||||
<div className={style.heroContent}>
|
||||
<div className={style.copyBlock}>
|
||||
<p className={style.kicker}>Opiniones verificadas</p>
|
||||
{targetType === RatingTargetType.EMPLOYEE && visibleContext ? (
|
||||
<div className={style.professionalHero}>
|
||||
<div className={style.professionalIdentity}>
|
||||
<div className={style.targetAvatar}>
|
||||
<Avatar
|
||||
name="target-professional-avatar"
|
||||
src={visibleContext.avatarUrl || visibleContext.name}
|
||||
alt={`Avatar de ${visibleContext.name}`}
|
||||
size="large"
|
||||
border="none"
|
||||
/>
|
||||
</div>
|
||||
<div className={style.professionalText}>
|
||||
<span className={style.targetLabel}>{visibleContext.label}</span>
|
||||
<h1>{heroTitle}</h1>
|
||||
<p>{visibleContext.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={style.targetTitleBlock}>
|
||||
<span className={style.targetLabel}>{visibleContext?.label || "Opiniones"}</span>
|
||||
<h1>{heroTitle}</h1>
|
||||
<p>{visibleContext?.description || "Conocé experiencias reales de personas que ya reservaron."}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={style.summaryCard} aria-label="Resumen de opiniones">
|
||||
<span className={style.summaryLabel}>Promedio general</span>
|
||||
<span className={style.summaryScore}>{averageScore > 0 ? formatScore(averageScore) : "--"}</span>
|
||||
<span className={style.summaryStars} aria-hidden="true">
|
||||
{averageScore > 0 ? renderStars(averageScore) : "☆☆☆☆☆"}
|
||||
</span>
|
||||
<span className={style.summaryText}>{totalRatings} {reviewLabel} verificadas</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{isLoading && <p className={style.state}>Cargando opiniones...</p>}
|
||||
|
||||
{!isLoading && error && <p className={style.state}>{error}</p>}
|
||||
|
||||
{!isLoading && !error && reviews.length === 0 && (
|
||||
<p className={style.state}>Todavía no hay opiniones con comentarios para mostrar.</p>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && reviews.length > 0 && (
|
||||
<>
|
||||
<div className={style.listHeader}>
|
||||
<p className={style.resultsCount}>{total} opiniones con comentarios</p>
|
||||
<span>Ordenadas por calificación y fecha</span>
|
||||
</div>
|
||||
<div className={style.reviewsList}>
|
||||
{reviews.map((review) => (
|
||||
<article className={style.reviewCard} key={review.id}>
|
||||
<div className={style.rating} aria-label={`${formatScore(review.score)} de 5 estrellas`}>
|
||||
<span className={style.stars} aria-hidden="true">
|
||||
{renderStars(review.score)}
|
||||
</span>
|
||||
<span className={style.score}>{formatScore(review.score)}</span>
|
||||
</div>
|
||||
<p className={style.comment}>“{review.comment}”</p>
|
||||
<div className={style.footer}>
|
||||
<div className={style.authorBlock}>
|
||||
<Avatar
|
||||
name={`review-author-${review.id}`}
|
||||
src={review.author.avatarUrl || review.author.displayName}
|
||||
alt={`Avatar de ${review.author.displayName}`}
|
||||
size="small"
|
||||
border="none"
|
||||
/>
|
||||
<span className={style.author}>{review.author.displayName}</span>
|
||||
</div>
|
||||
<time dateTime={review.createdAt}>{formatReviewDate(review.createdAt)}</time>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className={style.pagination}>
|
||||
<Pagination count={totalPages} page={page} variant="outlined" onChange={handlePageChange} />
|
||||
<span>
|
||||
Página {page} de {totalPages}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ReviewsPage(): ReactElement {
|
||||
return (
|
||||
<Suspense fallback={<main className={style.page}>Cargando opiniones...</main>}>
|
||||
<ReviewsContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,7 @@ const GetServiceData = async (serviceId: string): Promise<PublicOrganizationServ
|
||||
"views/service",
|
||||
{
|
||||
serviceId: serviceId,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return organizationServiceData;
|
||||
@@ -42,9 +42,7 @@ export default async function OrganizationPublicProfile({ params }: Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{!organizationService.organization && !organizationService.service && (
|
||||
<ServicePrivate />
|
||||
)}
|
||||
{!organizationService.organization && !organizationService.service && <ServicePrivate />}
|
||||
{organizationService.organization && organizationService.service && (
|
||||
<AnimatedContainer
|
||||
color={organizationService.organization.headerColor}
|
||||
@@ -70,9 +68,7 @@ export default async function OrganizationPublicProfile({ params }: Props) {
|
||||
<Appointment
|
||||
serviceId={sid}
|
||||
companyId={organizationService.organization.id}
|
||||
appointmentAlert={
|
||||
organizationService.organization.appointmentAlert
|
||||
}
|
||||
appointmentAlert={organizationService.organization.appointmentAlert}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -86,10 +82,18 @@ export default async function OrganizationPublicProfile({ params }: Props) {
|
||||
<ReviewsCarousel
|
||||
targetType={RatingTargetType.SERVICE}
|
||||
targetId={sid}
|
||||
title="Lo que dicen quienes ya reservaron"
|
||||
title={`Opiniones sobre ${organizationService.service.name}`}
|
||||
subtitle="Comentarios de clientes que calificaron este servicio después de su turno."
|
||||
/>
|
||||
)}
|
||||
{organizationService.organization.showPublicOpinions !== false && (
|
||||
<ReviewsCarousel
|
||||
targetType={RatingTargetType.COMPANY}
|
||||
targetId={organizationService.organization.id}
|
||||
title="Opiniones sobre la organización"
|
||||
subtitle={`Comentarios de clientes que calificaron a ${organizationService.organization.name} después de su turno.`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</AnimatedContainer>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user