feat: add showPublicScores and showPublicOpinions flags to services with plan-based access control

This commit is contained in:
2026-07-22 14:05:39 -03:00
parent da3c3abb52
commit 279e1b54cc
19 changed files with 391 additions and 43 deletions
@@ -67,6 +67,8 @@ export class CompaniesAdapterMongoose implements ICompaniesAdapter {
fixedPostIds: { type: Array, required: false }, fixedPostIds: { type: Array, required: false },
banned: { type: Boolean, required: false, default: false }, banned: { type: Boolean, required: false, default: false },
showPublicScores: { type: Boolean, required: false, default: true },
showPublicOpinions: { type: Boolean, required: false, default: true },
}); });
this.companyList = model<ICompanyDocument>("Company", this.schema); this.companyList = model<ICompanyDocument>("Company", this.schema);
@@ -235,6 +237,14 @@ export class CompaniesAdapterMongoose implements ICompaniesAdapter {
updateCompany.banned = data.banned; updateCompany.banned = data.banned;
} }
if (data.showPublicScores !== undefined) {
updateCompany.showPublicScores = data.showPublicScores;
}
if (data.showPublicOpinions !== undefined) {
updateCompany.showPublicOpinions = data.showPublicOpinions;
}
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.
@@ -54,6 +54,8 @@ export type UpdateCompanyParams = {
onboardingStep?: number; onboardingStep?: number;
onboardingCompleted?: boolean; onboardingCompleted?: boolean;
banned?: boolean; banned?: boolean;
showPublicScores?: boolean;
showPublicOpinions?: boolean;
}; };
export type SetCompanyFileParams = { export type SetCompanyFileParams = {
@@ -154,6 +156,8 @@ export interface ICompany {
onboardingCompleted?: boolean; onboardingCompleted?: boolean;
fixedPostIds?: Array<string>; fixedPostIds?: Array<string>;
banned?: boolean; banned?: boolean;
showPublicScores?: boolean;
showPublicOpinions?: boolean;
} }
export interface MyOranizationsView { export interface MyOranizationsView {
@@ -203,6 +207,8 @@ export interface MyOranizationsView {
onboardingStep?: number; onboardingStep?: number;
onboardingCompleted?: boolean; onboardingCompleted?: boolean;
banned?: boolean; banned?: boolean;
showPublicScores?: boolean;
showPublicOpinions?: boolean;
} }
export interface ClientOrganizationView { export interface ClientOrganizationView {
+21
View File
@@ -67,6 +67,17 @@ class CompaniesManager implements ICompaniesManager {
this.companies = new CompaniesAdapterMongoose(); this.companies = new CompaniesAdapterMongoose();
} }
private async validatePublicRatingFlagsPlan(ownerId: string): Promise<void> {
const { default: PlanSubscriptionsList } = await import("../PlanSubscriptions/PlanSubscriptons");
const subscription = await PlanSubscriptionsList.getSubscriptionByUser({
sessionUser: ownerId,
});
if (!subscription || subscription.plan.price === 0) {
throw new Error("Esta configuración está disponible solo para planes pagos.");
}
}
public async toggleFixedPost(data: FixCompanyPostParams): Promise<void> { public async toggleFixedPost(data: FixCompanyPostParams): Promise<void> {
await validateSessionUser({ await validateSessionUser({
sessionUser: data.sessionUser, sessionUser: data.sessionUser,
@@ -489,6 +500,10 @@ class CompaniesManager implements ICompaniesManager {
throw new Error(NoPermissionMessage()); throw new Error(NoPermissionMessage());
} }
if (data.showPublicScores !== undefined || data.showPublicOpinions !== undefined) {
await this.validatePublicRatingFlagsPlan(String(companyCheck.ownerId));
}
let updateData = { let updateData = {
...data, ...data,
}; };
@@ -597,6 +612,8 @@ class CompaniesManager implements ICompaniesManager {
templateEmailAltaId: isNull<string>(company.templateEmailAltaId, ""), templateEmailAltaId: isNull<string>(company.templateEmailAltaId, ""),
onboardingStep: company.onboardingStep, onboardingStep: company.onboardingStep,
onboardingCompleted: company.onboardingCompleted, onboardingCompleted: company.onboardingCompleted,
showPublicScores: company.showPublicScores ?? true,
showPublicOpinions: company.showPublicOpinions ?? true,
}; };
} }
@@ -716,6 +733,8 @@ class CompaniesManager implements ICompaniesManager {
templateEmailAltaId: isNull<string>(company.templateEmailAltaId, ""), templateEmailAltaId: isNull<string>(company.templateEmailAltaId, ""),
onboardingStep: company.onboardingStep, onboardingStep: company.onboardingStep,
onboardingCompleted: company.onboardingCompleted, onboardingCompleted: company.onboardingCompleted,
showPublicScores: company.showPublicScores ?? true,
showPublicOpinions: company.showPublicOpinions ?? true,
}); });
} }
@@ -786,6 +805,8 @@ class CompaniesManager implements ICompaniesManager {
templateEmailAltaId: isNull<string>(company.templateEmailAltaId, ""), templateEmailAltaId: isNull<string>(company.templateEmailAltaId, ""),
onboardingStep: company.onboardingStep, onboardingStep: company.onboardingStep,
onboardingCompleted: company.onboardingCompleted, onboardingCompleted: company.onboardingCompleted,
showPublicScores: company.showPublicScores ?? true,
showPublicOpinions: company.showPublicOpinions ?? true,
}); });
} }
} }
@@ -134,7 +134,7 @@ export class RatingsAdapterMongoose implements IRatingsAdapter {
targetId: new Types.ObjectId(filters.targetId), targetId: new Types.ObjectId(filters.targetId),
comment: { $regex: /\S/ }, comment: { $regex: /\S/ },
}) })
.sort({ createdAt: -1 }) .sort({ score: -1, createdAt: -1 })
.limit(filters.limit || 20) .limit(filters.limit || 20)
.select("score comment createdAt userId") .select("score comment createdAt userId")
.populate("userId", "firstName lastName avatar") .populate("userId", "firstName lastName avatar")
@@ -42,6 +42,8 @@ export class ServicesAdapterMongoose implements IServicesAdapter {
longitude: { type: Number, required: false, default: 0 }, longitude: { type: Number, required: false, default: 0 },
published: { type: String, required: false }, published: { type: String, required: false },
banned: { type: Boolean, required: false, default: false }, banned: { type: Boolean, required: false, default: false },
showPublicScores: { type: Boolean, required: false, default: true },
showPublicOpinions: { type: Boolean, required: false, default: true },
}); });
this.serviceList = model<IServiceDocument>("Service", this.schema); this.serviceList = model<IServiceDocument>("Service", this.schema);
@@ -93,6 +95,8 @@ export class ServicesAdapterMongoose implements IServicesAdapter {
if (data.latitude) updateService.latitude = data.latitude; if (data.latitude) updateService.latitude = data.latitude;
if (data.longitude) updateService.longitude = data.longitude; if (data.longitude) updateService.longitude = data.longitude;
if (data.banned !== undefined) updateService.banned = data.banned; if (data.banned !== undefined) updateService.banned = data.banned;
if (data.showPublicScores !== undefined) updateService.showPublicScores = data.showPublicScores;
if (data.showPublicOpinions !== undefined) updateService.showPublicOpinions = data.showPublicOpinions;
await updateService.save(); await updateService.save();
} }
@@ -57,6 +57,8 @@ export type CreateServiceParams = {
latitude?: number; latitude?: number;
longitude?: number; longitude?: number;
sessionUser: string; sessionUser: string;
showPublicScores?: boolean;
showPublicOpinions?: boolean;
}; };
export type UpdateServiceParams = { export type UpdateServiceParams = {
@@ -81,6 +83,8 @@ export type UpdateServiceParams = {
longitude?: number; longitude?: number;
sessionUser: string; sessionUser: string;
banned?: boolean; banned?: boolean;
showPublicScores?: boolean;
showPublicOpinions?: boolean;
}; };
export type PaginatePublicServicesParams = FindServicesParams & { export type PaginatePublicServicesParams = FindServicesParams & {
@@ -155,6 +159,8 @@ export interface IService {
published?: SERVICE_PUBLISHED_STATUS; published?: SERVICE_PUBLISHED_STATUS;
discountId?: string; discountId?: string;
banned?: boolean; banned?: boolean;
showPublicScores?: boolean;
showPublicOpinions?: boolean;
} }
export interface CompanyServiceView { export interface CompanyServiceView {
@@ -176,6 +182,8 @@ export interface CompanyServiceView {
fontShadowColor: string; fontShadowColor: string;
published: SERVICE_PUBLISHED_STATUS; published: SERVICE_PUBLISHED_STATUS;
banned?: boolean; banned?: boolean;
showPublicScores: boolean;
showPublicOpinions: boolean;
} }
export interface PublicServiceView { export interface PublicServiceView {
@@ -199,6 +207,8 @@ export interface PublicServiceView {
fontColor: string; fontColor: string;
fontShadowColor: string; fontShadowColor: string;
banned?: boolean; banned?: boolean;
showPublicScores: boolean;
showPublicOpinions: boolean;
} }
export interface FindServicesByCompanyParams { export interface FindServicesByCompanyParams {
+25
View File
@@ -46,6 +46,17 @@ class ServiceManager implements IServicesManager {
this.services = new ServicesAdapterMongoose(); this.services = new ServicesAdapterMongoose();
} }
private async validatePublicRatingFlagsPlan(ownerId: string): Promise<void> {
const { default: PlanSubscriptionsList } = await import("../PlanSubscriptions/PlanSubscriptons");
const subscription = await PlanSubscriptionsList.getSubscriptionByUser({
sessionUser: ownerId,
});
if (!subscription || subscription.plan.price === 0) {
throw new Error("Esta configuración está disponible solo para planes pagos.");
}
}
public async setPublishedStatus(data: SetPublishedStatusParams): Promise<void> { public async setPublishedStatus(data: SetPublishedStatusParams): Promise<void> {
const sessionUser = await UsersManager.users.findOne({ const sessionUser = await UsersManager.users.findOne({
_id: data.sessionUser, _id: data.sessionUser,
@@ -145,6 +156,10 @@ class ServiceManager implements IServicesManager {
throw new Error("Ha alcanzado el limite de servicios permitidos de acuerdo a su plan."); throw new Error("Ha alcanzado el limite de servicios permitidos de acuerdo a su plan.");
} }
if (data.showPublicScores !== undefined || data.showPublicOpinions !== undefined) {
await this.validatePublicRatingFlagsPlan(String(companyCheck.ownerId));
}
const newService = await this.services.create({ const newService = await this.services.create({
...data, ...data,
...{ ...{
@@ -190,6 +205,10 @@ class ServiceManager implements IServicesManager {
throw new Error(NoPermissionMessage()); throw new Error(NoPermissionMessage());
} }
if (data.showPublicScores !== undefined || data.showPublicOpinions !== undefined) {
await this.validatePublicRatingFlagsPlan(String(companyCheck.ownerId));
}
await this.services.update(data); await this.services.update(data);
} }
@@ -307,6 +326,8 @@ class ServiceManager implements IServicesManager {
service.published, service.published,
SERVICE_PUBLISHED_STATUS.PRIVATE SERVICE_PUBLISHED_STATUS.PRIVATE
), ),
showPublicScores: service.showPublicScores ?? true,
showPublicOpinions: service.showPublicOpinions ?? true,
}); });
} }
@@ -363,6 +384,8 @@ class ServiceManager implements IServicesManager {
fontColor: isNull<string>(service.fontColor, defFontColor), fontColor: isNull<string>(service.fontColor, defFontColor),
fontShadowColor: isNull<string>(service.fontShadowColor, defFontShadowColor), fontShadowColor: isNull<string>(service.fontShadowColor, defFontShadowColor),
published: isNull<SERVICE_PUBLISHED_STATUS>(service.published, SERVICE_PUBLISHED_STATUS.PRIVATE), published: isNull<SERVICE_PUBLISHED_STATUS>(service.published, SERVICE_PUBLISHED_STATUS.PRIVATE),
showPublicScores: service.showPublicScores ?? true,
showPublicOpinions: service.showPublicOpinions ?? true,
}; };
} }
@@ -486,6 +509,8 @@ class ServiceManager implements IServicesManager {
color: isNull<string>(service.color, defColor), color: isNull<string>(service.color, defColor),
fontColor: isNull<string>(service.fontColor, defFontColor), fontColor: isNull<string>(service.fontColor, defFontColor),
fontShadowColor: isNull<string>(service.fontShadowColor, defFontShadowColor), fontShadowColor: isNull<string>(service.fontShadowColor, defFontShadowColor),
showPublicScores: service.showPublicScores ?? true,
showPublicOpinions: service.showPublicOpinions ?? true,
}); });
} }
+4
View File
@@ -55,6 +55,8 @@ export type PublicOrganizationView = {
latitude: number; latitude: number;
longitude: number; longitude: number;
appointmentAlert: string; appointmentAlert: string;
showPublicScores: boolean;
showPublicOpinions: boolean;
}; };
export type PublicOrganizationServiceViewParams = { export type PublicOrganizationServiceViewParams = {
@@ -163,6 +165,8 @@ class Views {
services, services,
employees: services, employees: services,
appointmentAlert: isNull<string>(company.appointmentAlert, ""), appointmentAlert: isNull<string>(company.appointmentAlert, ""),
showPublicScores: company.showPublicScores ?? true,
showPublicOpinions: company.showPublicOpinions ?? true,
}; };
} }
+8
View File
@@ -45,6 +45,8 @@ export interface MyOranizationsView {
templateEmailCancellationId: string; templateEmailCancellationId: string;
onboardingStep?: number; onboardingStep?: number;
onboardingCompleted?: boolean; onboardingCompleted?: boolean;
showPublicScores: boolean;
showPublicOpinions: boolean;
} }
export type CreateCompanyParams = { export type CreateCompanyParams = {
@@ -93,6 +95,8 @@ export type UpdateCompanyParams = {
templateEmailCancellationId?: string; templateEmailCancellationId?: string;
onboardingStep?: number; onboardingStep?: number;
onboardingCompleted?: boolean; onboardingCompleted?: boolean;
showPublicScores?: boolean;
showPublicOpinions?: boolean;
}; };
export type DeleteCompanyParams = { export type DeleteCompanyParams = {
@@ -158,6 +162,8 @@ export interface ICompany {
onboardingCompleted?: boolean; onboardingCompleted?: boolean;
fixedPostIds?: Array<string>; fixedPostIds?: Array<string>;
banned?: boolean; banned?: boolean;
showPublicScores?: boolean;
showPublicOpinions?: boolean;
} }
export type PublicOrganizationViewParams = { export type PublicOrganizationViewParams = {
@@ -184,6 +190,8 @@ export type PublicOrganizationView = {
longitude: number; longitude: number;
appointmentAlert: string; appointmentAlert: string;
banned?: boolean; banned?: boolean;
showPublicScores: boolean;
showPublicOpinions: boolean;
}; };
export type SetNotificationAutoParams = { export type SetNotificationAutoParams = {
+10
View File
@@ -20,6 +20,8 @@ export type CreateServiceParams = {
originalPrice: number; originalPrice: number;
limit: number; limit: number;
sessionUser: string; sessionUser: string;
showPublicScores?: boolean;
showPublicOpinions?: boolean;
}; };
export type UpdateServiceParams = { export type UpdateServiceParams = {
@@ -36,6 +38,8 @@ export type UpdateServiceParams = {
fontColor?: string; fontColor?: string;
fontShadowColor?: string; fontShadowColor?: string;
sessionUser: string; sessionUser: string;
showPublicScores?: boolean;
showPublicOpinions?: boolean;
}; };
export type PaginateServicesParams = { export type PaginateServicesParams = {
@@ -110,6 +114,8 @@ export interface PublicServiceView {
fontColor: string; fontColor: string;
fontShadowColor: string; fontShadowColor: string;
banned?: boolean; banned?: boolean;
showPublicScores: boolean;
showPublicOpinions: boolean;
} }
export interface PublicOrganizationServiceView { export interface PublicOrganizationServiceView {
@@ -132,6 +138,8 @@ export interface IService {
fontColor: string; fontColor: string;
fontShadowColor: string; fontShadowColor: string;
banned?: boolean; banned?: boolean;
showPublicScores?: boolean;
showPublicOpinions?: boolean;
} }
export interface CompanyServiceView { export interface CompanyServiceView {
@@ -153,4 +161,6 @@ export interface CompanyServiceView {
fontShadowColor: string; fontShadowColor: string;
published: SERVICE_PUBLISHED_STATUS; published: SERVICE_PUBLISHED_STATUS;
banned?: boolean; banned?: boolean;
showPublicScores: boolean;
showPublicOpinions: boolean;
} }
@@ -11,12 +11,14 @@ import OrgConfigIcon from "@mui/icons-material/SettingsOutlined";
import Textbox from "@components/Textbox/Textbox"; import Textbox from "@components/Textbox/Textbox";
import TextPhone from "@components/TextPhone/TextPhone"; import TextPhone from "@components/TextPhone/TextPhone";
import MaterialAlert from "@mui/material/Alert"; import MaterialAlert from "@mui/material/Alert";
import Checkbox from "@mui/material/Checkbox";
import * as Yup from "yup"; import * as Yup from "yup";
import { import {
loadOrganizationsById, loadOrganizationsById,
savePublishedStatus, savePublishedStatus,
updateOrganizationsById, updateOrganizationsById,
} from "../Org.Service"; } from "../Org.Service";
import { loadServicesByCompany, updateService } from "../services/Services.Service";
import { import {
COMPANY_PUBLISHED_STATUS, COMPANY_PUBLISHED_STATUS,
MyOranizationsView, MyOranizationsView,
@@ -35,6 +37,7 @@ import { useConfirmStore } from "@core/Store/Confirm.Store";
import Switch from "@core/app/components/Switch/Switch"; import Switch from "@core/app/components/Switch/Switch";
import { FindTemplatesParams } from "@core/Models/Templates.model"; import { FindTemplatesParams } from "@core/Models/Templates.model";
import filterObjects from "@core/app/components/TextObjectFilter/Filter.Service"; import filterObjects from "@core/app/components/TextObjectFilter/Filter.Service";
import { CompanyServiceView } from "@models/Service.model";
type UrlData = { type UrlData = {
id: string; id: string;
@@ -57,6 +60,13 @@ export default function OrganizationEdit() {
const [cancellationTime, setCancellationTime] = useState(""); const [cancellationTime, setCancellationTime] = useState("");
const [appointmentAlert, setAppointmentAlert] = useState(""); const [appointmentAlert, setAppointmentAlert] = useState("");
const [orgSlug, setOrgSlug] = useState(""); const [orgSlug, setOrgSlug] = useState("");
const [showPublicScores, setShowPublicScores] = useState(true);
const [showPublicOpinions, setShowPublicOpinions] = useState(true);
const [servicesShowPublicScores, setServicesShowPublicScores] = useState(true);
const [servicesShowPublicOpinions, setServicesShowPublicOpinions] = useState(true);
const [originalServicesShowPublicScores, setOriginalServicesShowPublicScores] = useState(true);
const [originalServicesShowPublicOpinions, setOriginalServicesShowPublicOpinions] = useState(true);
const [services, setServices] = useState<CompanyServiceView[]>([]);
const [orgPublished, setOrgPublished] = useState<COMPANY_PUBLISHED_STATUS | undefined>( const [orgPublished, setOrgPublished] = useState<COMPANY_PUBLISHED_STATUS | undefined>(
undefined undefined
@@ -65,6 +75,8 @@ export default function OrganizationEdit() {
const [savePublishedStatusAvailable, setSavePublishedStatusAvailable] = useState(false); const [savePublishedStatusAvailable, setSavePublishedStatusAvailable] = useState(false);
const [combosLoaded, setCombosLoaded] = useState<boolean>(false); const [combosLoaded, setCombosLoaded] = useState<boolean>(false);
const ownerSubscription = SessionInfo.organizationSubscriptions[id];
const canEditPublicRatingFlags = (ownerSubscription?.plan.price || 0) > 0;
const [categoryFilterContent] = useState<TextObjectModalContent>({ const [categoryFilterContent] = useState<TextObjectModalContent>({
header: ( header: (
@@ -210,28 +222,48 @@ export default function OrganizationEdit() {
useEffect(() => { useEffect(() => {
if (combosLoaded) { if (combosLoaded) {
eventHandler.setEventType(EVENT_TYPES.LOADING); eventHandler.setEventType(EVENT_TYPES.LOADING);
loadOrganizationsById({ id: id, sessionUser: SessionInfo.userId }) Promise.all([
.then((response: MyOranizationsView) => { loadOrganizationsById({ id: id, sessionUser: SessionInfo.userId }),
setOrgName(response.name); loadServicesByCompany(id),
setOrgDescription(response.description); ])
setHeatMapFraction(response.heatMapFraction.toString()); .then((response) => {
setAppointmentTime(response.appointmentTime.toString()); const [organization, loadedServices] = response;
setCancellationTime(response.cancellationTime.toString());
setAppointmentAlert(response.appointmentAlert); setOrgName(organization.name);
setAreaCode(response.phoneAreaCode); setOrgDescription(organization.description);
setPhone(response.phoneNumber); setHeatMapFraction(organization.heatMapFraction.toString());
setCategoryId(response.categoryId.toString()); setAppointmentTime(organization.appointmentTime.toString());
setOrgSlug(getSlug(response.name)); setCancellationTime(organization.cancellationTime.toString());
setOrgPublished(response.published); setAppointmentAlert(organization.appointmentAlert);
setAreaCode(organization.phoneAreaCode);
setPhone(organization.phoneNumber);
setCategoryId(organization.categoryId.toString());
setOrgSlug(getSlug(organization.name));
setOrgPublished(organization.published);
setShowPublicScores(organization.showPublicScores !== undefined ? organization.showPublicScores : true);
setShowPublicOpinions(organization.showPublicOpinions !== undefined ? organization.showPublicOpinions : true);
setServices(loadedServices);
setServicesShowPublicScores(
loadedServices.every((service) => service.showPublicScores !== false)
);
setServicesShowPublicOpinions(
loadedServices.every((service) => service.showPublicOpinions !== false)
);
setOriginalServicesShowPublicScores(
loadedServices.every((service) => service.showPublicScores !== false)
);
setOriginalServicesShowPublicOpinions(
loadedServices.every((service) => service.showPublicOpinions !== false)
);
requestAnimationFrame(() => { requestAnimationFrame(() => {
setTemplateWapNotifId(response.templateWapNotifId); setTemplateWapNotifId(organization.templateWapNotifId);
setTemplateWapAltaId(response.templateWapAltaId); setTemplateWapAltaId(organization.templateWapAltaId);
setTemplateWapCancellationId(response.templateWapCancellationId); setTemplateWapCancellationId(organization.templateWapCancellationId);
}); });
}) })
.catch((error) => { .catch((error: ApiError | Error) => {
alert.showError(error.format()); alert.showError(error instanceof ApiError ? error.format() : error.message);
}) })
.finally(() => { .finally(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP); eventHandler.setEventType(EVENT_TYPES.SLEEP);
@@ -270,6 +302,12 @@ 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
? {
showPublicScores,
showPublicOpinions,
}
: {}),
}; };
const schema = Yup.object().shape({ const schema = Yup.object().shape({
@@ -335,10 +373,61 @@ export default function OrganizationEdit() {
.then(() => { .then(() => {
updateOrganizationsById(data) updateOrganizationsById(data)
.then(() => { .then(() => {
alert.showSuccess("Organizacion actualizada!"); const servicesShowPublicScoresChanged =
originalServicesShowPublicScores !== servicesShowPublicScores;
const servicesShowPublicOpinionsChanged =
originalServicesShowPublicOpinions !== servicesShowPublicOpinions;
const changedServices =
canEditPublicRatingFlags &&
(servicesShowPublicScoresChanged || servicesShowPublicOpinionsChanged)
? services
: [];
return Promise.all(
changedServices.map((service) =>
updateService({
id: service.id,
companyId: id,
name: service.name,
description: service.description,
originalPrice: service.originalPrice,
limit: service.limit,
length: service.length,
color: service.color,
fontColor: service.fontColor,
fontShadowColor: service.fontShadowColor,
sessionUser: SessionInfo.userId,
showPublicScores: servicesShowPublicScoresChanged
? servicesShowPublicScores
: service.showPublicScores !== undefined
? service.showPublicScores
: true,
showPublicOpinions: servicesShowPublicOpinionsChanged
? servicesShowPublicOpinions
: service.showPublicOpinions !== undefined
? service.showPublicOpinions
: true,
}) })
.catch((error: ApiError) => { )
alert.showError(error.format()); ).then(() => {
const updatedServices = services.map((service) => ({
...service,
showPublicScores: servicesShowPublicScoresChanged
? servicesShowPublicScores
: service.showPublicScores,
showPublicOpinions: servicesShowPublicOpinionsChanged
? servicesShowPublicOpinions
: service.showPublicOpinions,
}));
setServices(updatedServices);
setOriginalServicesShowPublicScores(servicesShowPublicScores);
setOriginalServicesShowPublicOpinions(servicesShowPublicOpinions);
alert.showSuccess("Organizacion actualizada!");
});
})
.catch((error: ApiError | Error) => {
alert.showError(error instanceof ApiError ? error.format() : error.message);
}) })
.finally(() => { .finally(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP); eventHandler.setEventType(EVENT_TYPES.SLEEP);
@@ -484,6 +573,83 @@ 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.
</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}
onChange={(event) => {
setShowPublicScores(event.target.checked);
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
}}
/>
Mostrar puntuaciones públicas
</label>
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}>
<Checkbox
checked={showPublicOpinions}
disabled={!canEditPublicRatingFlags}
onChange={(event) => {
setShowPublicOpinions(event.target.checked);
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
}}
/>
Mostrar opiniones públicas
</label>
{!canEditPublicRatingFlags && (
<span style={{ color: "var(--gray-darkest)", fontSize: "13px" }}>
Disponible para planes pagos.
</span>
)}
</div>
{services.length > 0 && (
<>
<MaterialAlert severity="info" sx={{ marginTop: "20px" }}>
<b>Visibilidad pública de servicios:</b> Configurá si se muestran puntuaciones y opiniones en todos los servicios. Disponible para planes pagos.
</MaterialAlert>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "10px",
marginTop: "10px",
}}
>
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}>
<Checkbox
checked={servicesShowPublicScores}
disabled={!canEditPublicRatingFlags}
onChange={(event) => {
setServicesShowPublicScores(event.target.checked);
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
}}
/>
Mostrar puntuaciones públicas de los servicios
</label>
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}>
<Checkbox
checked={servicesShowPublicOpinions}
disabled={!canEditPublicRatingFlags}
onChange={(event) => {
setServicesShowPublicOpinions(event.target.checked);
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
}}
/>
Mostrar opiniones públicas de los servicios
</label>
{!canEditPublicRatingFlags && (
<span style={{ color: "var(--gray-darkest)", fontSize: "13px" }}>
Para cambiar la visibilidad pública de los servicios necesitás un plan pago.
</span>
)}
</div>
</>
)}
<TextObjectFilter <TextObjectFilter
placeholder="Categoría" placeholder="Categoría"
content={categoryFilterContent} content={categoryFilterContent}
@@ -72,6 +72,8 @@ export default function OrganizationService() {
const [serviceColor, setServiceColor] = useState(""); const [serviceColor, setServiceColor] = useState("");
const [serviceFontColor, setServiceFontColor] = useState(""); const [serviceFontColor, setServiceFontColor] = useState("");
const [serviceFontShadowColor, setServiceFontShadowColor] = useState(""); const [serviceFontShadowColor, setServiceFontShadowColor] = useState("");
const [showPublicScores, setShowPublicScores] = useState(true);
const [showPublicOpinions, setShowPublicOpinions] = useState(true);
const [serviceDiscountType, setServiceDiscountType] = useState<string | undefined>(undefined); const [serviceDiscountType, setServiceDiscountType] = useState<string | undefined>(undefined);
const [serviceDiscountValue, setServiceDiscountValue] = useState<number | undefined>(undefined); const [serviceDiscountValue, setServiceDiscountValue] = useState<number | undefined>(undefined);
@@ -116,6 +118,8 @@ export default function OrganizationService() {
const [discountsFilterData, setDiscountsFilterData] = useState<TextObjectFilterResult[]>([]); const [discountsFilterData, setDiscountsFilterData] = useState<TextObjectFilterResult[]>([]);
const [discountId, setDiscountId] = useState<string>(""); const [discountId, setDiscountId] = useState<string>("");
const ownerSubscription = SessionInfo.organizationSubscriptions[id];
const canEditPublicRatingFlags = (ownerSubscription?.plan.price || 0) > 0;
useEffect(() => { useEffect(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP); eventHandler.setEventType(EVENT_TYPES.SLEEP);
@@ -200,6 +204,12 @@ export default function OrganizationService() {
limit: parseInt(serviceLimit), limit: parseInt(serviceLimit),
length: parseInt(serviceLength), length: parseInt(serviceLength),
sessionUser: SessionInfo.userId, sessionUser: SessionInfo.userId,
...(canEditPublicRatingFlags
? {
showPublicScores,
showPublicOpinions,
}
: {}),
}) })
.then(() => { .then(() => {
alert.showSuccess("Servicio creado"); alert.showSuccess("Servicio creado");
@@ -225,6 +235,12 @@ export default function OrganizationService() {
fontColor: serviceFontColor, fontColor: serviceFontColor,
fontShadowColor: serviceFontShadowColor, fontShadowColor: serviceFontShadowColor,
sessionUser: SessionInfo.userId, sessionUser: SessionInfo.userId,
...(canEditPublicRatingFlags
? {
showPublicScores,
showPublicOpinions,
}
: {}),
}) })
.then(() => { .then(() => {
alert.showSuccess("Servicio Actualizado"); alert.showSuccess("Servicio Actualizado");
@@ -255,6 +271,8 @@ export default function OrganizationService() {
setServiceDiscountType(response.discountType); setServiceDiscountType(response.discountType);
setServiceDiscountValue(response.discountValue); setServiceDiscountValue(response.discountValue);
setServiceDiscountName(response.discountName); setServiceDiscountName(response.discountName);
setShowPublicScores(response.showPublicScores !== undefined ? response.showPublicScores : true);
setShowPublicOpinions(response.showPublicOpinions !== undefined ? response.showPublicOpinions : true);
}) })
.catch(() => {}) .catch(() => {})
.finally(() => { .finally(() => {
@@ -486,6 +504,39 @@ export default function OrganizationService() {
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE); eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
}} }}
/> />
<MaterialAlert severity="info" sx={{ marginTop: "20px" }}>
<b>Visibilidad pública:</b> Elegí si querés mostrar puntuaciones y opiniones de este servicio en páginas públicas. Disponible para planes pagos.
</MaterialAlert>
<div style={{ display: "flex", flexDirection: "column", gap: "10px", marginTop: "10px" }}>
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}>
<Switch
checked={showPublicScores}
disabled={!canEditPublicRatingFlags}
onChange={(checked) => {
setShowPublicScores(checked);
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
}}
/>
Mostrar puntuaciones públicas
</label>
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}>
<Switch
checked={showPublicOpinions}
disabled={!canEditPublicRatingFlags}
onChange={(checked) => {
setShowPublicOpinions(checked);
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
}}
/>
Mostrar opiniones públicas
</label>
{!canEditPublicRatingFlags && (
<span style={{ color: "var(--gray-darkest)", fontSize: "13px" }}>
Disponible para planes pagos.
</span>
)}
</div>
<Textbox <Textbox
name="servicePrice" name="servicePrice"
placeholder="Precio del servicio" placeholder="Precio del servicio"
@@ -17,6 +17,11 @@ export default function OrganizationHeader(props: OrganizationHeaderProps) {
const [ratingSummary, setRatingSummary] = useState<RatingTargetSummaryResult>({ averageScore: 0, totalCount: 0 }); const [ratingSummary, setRatingSummary] = useState<RatingTargetSummaryResult>({ averageScore: 0, totalCount: 0 });
useEffect(() => { useEffect(() => {
if (organization.showPublicScores === false) {
setRatingSummary({ averageScore: 0, totalCount: 0 });
return;
}
if (!organization.id) { if (!organization.id) {
setRatingSummary({ averageScore: 0, totalCount: 0 }); setRatingSummary({ averageScore: 0, totalCount: 0 });
return; return;
@@ -32,7 +37,7 @@ export default function OrganizationHeader(props: OrganizationHeaderProps) {
.catch(() => { .catch(() => {
setRatingSummary({ averageScore: 0, totalCount: 0 }); setRatingSummary({ averageScore: 0, totalCount: 0 });
}); });
}, [organization.id]); }, [organization.id, organization.showPublicScores]);
return ( return (
<div <div
@@ -67,7 +72,7 @@ export default function OrganizationHeader(props: OrganizationHeaderProps) {
> >
{organization.description} {organization.description}
</p> </p>
{ratingSummary.totalCount > 0 && ( {organization.showPublicScores !== false && ratingSummary.totalCount > 0 && (
<div className={style.organizationRatingSummary}> <div className={style.organizationRatingSummary}>
<Rating <Rating
className={style.organizationRatingStars} className={style.organizationRatingStars}
@@ -63,6 +63,7 @@ export default function ProductItem(props: ProductItemProps) {
variant="card" variant="card"
displayMode="compact" displayMode="compact"
showOpinionCount={false} showOpinionCount={false}
enabled={product.showPublicScores !== false}
/> />
</div> </div>
<div className={style.cardHeader}> <div className={style.cardHeader}>
@@ -126,7 +127,12 @@ export default function ProductItem(props: ProductItemProps) {
<div className={style.itemContent}> <div className={style.itemContent}>
<div className={style.itemHeader}> <div className={style.itemHeader}>
<h3 className={style.itemTitle}>{product.name}</h3> <h3 className={style.itemTitle}>{product.name}</h3>
<ServiceRatingSummary serviceId={product.id} variant="row" showOpinionCount={false} /> <ServiceRatingSummary
serviceId={product.id}
variant="row"
showOpinionCount={false}
enabled={product.showPublicScores !== false}
/>
</div> </div>
<p <p
@@ -32,6 +32,16 @@ const renderStars = (score: number): string => {
const formatScore = (score: number): string => score.toFixed(1); const formatScore = (score: number): string => score.toFixed(1);
const sortReviewsByScore = (reviews: RatingTargetReviewItem[]): RatingTargetReviewItem[] => {
return [...reviews].sort((firstReview, secondReview) => {
if (secondReview.score !== firstReview.score) {
return secondReview.score - firstReview.score;
}
return new Date(secondReview.createdAt).getTime() - new Date(firstReview.createdAt).getTime();
});
};
export default function ReviewsCarousel({ export default function ReviewsCarousel({
targetType, targetType,
targetId, targetId,
@@ -50,7 +60,7 @@ export default function ReviewsCarousel({
ratingTargetReviews({ targetType, targetId, limit }) ratingTargetReviews({ targetType, targetId, limit })
.then((targetReviews) => { .then((targetReviews) => {
if (active) { if (active) {
setReviews(targetReviews.filter((review) => review.comment.trim().length > 0)); setReviews(sortReviewsByScore(targetReviews.filter((review) => review.comment.trim().length > 0)));
} }
}) })
.catch(() => { .catch(() => {
@@ -10,13 +10,19 @@ export interface ServiceRatingSummaryProps {
variant?: "card" | "row"; variant?: "card" | "row";
displayMode?: "default" | "compact"; displayMode?: "default" | "compact";
showOpinionCount?: boolean; showOpinionCount?: boolean;
enabled?: boolean;
} }
export default function ServiceRatingSummary(props: ServiceRatingSummaryProps): React.ReactElement | null { export default function ServiceRatingSummary(props: ServiceRatingSummaryProps): React.ReactElement | null {
const { serviceId, variant = "card", displayMode = "default", showOpinionCount = true } = props; const { serviceId, variant = "card", displayMode = "default", showOpinionCount = true, enabled = true } = props;
const [ratingSummary, setRatingSummary] = useState<RatingTargetSummaryResult>({ averageScore: 0, totalCount: 0 }); const [ratingSummary, setRatingSummary] = useState<RatingTargetSummaryResult>({ averageScore: 0, totalCount: 0 });
useEffect(() => { useEffect(() => {
if (!enabled) {
setRatingSummary({ averageScore: 0, totalCount: 0 });
return;
}
if (!serviceId) { if (!serviceId) {
setRatingSummary({ averageScore: 0, totalCount: 0 }); setRatingSummary({ averageScore: 0, totalCount: 0 });
return; return;
@@ -42,9 +48,9 @@ export default function ServiceRatingSummary(props: ServiceRatingSummaryProps):
return () => { return () => {
isMounted = false; isMounted = false;
}; };
}, [serviceId]); }, [serviceId, enabled]);
if (ratingSummary.totalCount <= 0) { if (!enabled || ratingSummary.totalCount <= 0) {
return null; return null;
} }
@@ -5,13 +5,15 @@ import MuiSwitch from "@mui/material/Switch";
interface SwitchProps { interface SwitchProps {
checked: boolean; checked: boolean;
onChange: (checked: boolean) => void; onChange: (checked: boolean) => void;
disabled?: boolean;
} }
export default function Switch({ checked, onChange }: SwitchProps) { export default function Switch({ checked, onChange, disabled = false }: SwitchProps) {
return ( return (
<ThemeProvider theme={turnosXpressTheme}> <ThemeProvider theme={turnosXpressTheme}>
<MuiSwitch <MuiSwitch
checked={checked} checked={checked}
disabled={disabled}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => { onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
onChange(event.target.checked); onChange(event.target.checked);
}} }}
@@ -874,6 +874,7 @@ export default function OrganizationPublicProfile() {
</div> </div>
</div> </div>
</div> </div>
{orgData.showPublicOpinions !== false && (
<ReviewsCarousel <ReviewsCarousel
targetType={RatingTargetType.COMPANY} targetType={RatingTargetType.COMPANY}
targetId={orgData.id} targetId={orgData.id}
@@ -881,6 +882,7 @@ export default function OrganizationPublicProfile() {
subtitle="Opiniones reales de personas que reservaron turnos acá." subtitle="Opiniones reales de personas que reservaron turnos acá."
className={style.organizationReviews} className={style.organizationReviews}
/> />
)}
</div> </div>
)} )}
</> </>
@@ -82,12 +82,14 @@ export default async function OrganizationPublicProfile({ params }: Props) {
title="Otros Servicios &#x1F3C6;" title="Otros Servicios &#x1F3C6;"
data={organizationService.organization.services.data} data={organizationService.organization.services.data}
/> />
{organizationService.service.showPublicOpinions !== false && (
<ReviewsCarousel <ReviewsCarousel
targetType={RatingTargetType.SERVICE} targetType={RatingTargetType.SERVICE}
targetId={sid} targetId={sid}
title="Lo que dicen quienes ya reservaron" title="Lo que dicen quienes ya reservaron"
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."
/> />
)}
</div> </div>
</AnimatedContainer> </AnimatedContainer>
)} )}