feat: implement comprehensive notification engine with policies, preferences, and automated dispatching services

This commit is contained in:
2026-07-21 18:53:11 -03:00
parent 40090cdec5
commit 10ca449f88
82 changed files with 10249 additions and 171 deletions
@@ -30,6 +30,7 @@ export type UpdateAppointmentParams = {
export enum APPOINTMENT_NOTIFICATION_TYPE {
"CREATION" = "creation",
"REMINDER" = "reminder",
"CANCELLATION" = "cancellation",
}
export type SendAppointmentNotificationParams = {
@@ -41,6 +42,7 @@ export type SendAppointmentNotificationParams = {
export type DeleteAppointmentParams = {
id: string;
validation?: boolean;
notification?: boolean;
sessionUser: string;
};
+6
View File
@@ -39,8 +39,10 @@ export interface MyOranizationsView {
appointmentAlert: string;
templateWapNotifId: string;
templateWapAltaId: string;
templateWapCancellationId: string;
templateEmailNotifId: string;
templateEmailAltaId: string;
templateEmailCancellationId: string;
onboardingStep?: number;
onboardingCompleted?: boolean;
}
@@ -85,8 +87,10 @@ export type UpdateCompanyParams = {
sessionUser: string;
templateWapNotifId?: string;
templateWapAltaId?: string;
templateWapCancellationId?: string;
templateEmailNotifId?: string;
templateEmailAltaId?: string;
templateEmailCancellationId?: string;
onboardingStep?: number;
onboardingCompleted?: boolean;
};
@@ -146,8 +150,10 @@ export interface ICompany {
appointmentAlert?: string;
templateWapNotifId?: string;
templateWapAltaId?: string;
templateWapCancellationId?: string;
templateEmailNotifId?: string;
templateEmailAltaId?: string;
templateEmailCancellationId?: string;
onboardingStep?: number;
onboardingCompleted?: boolean;
fixedPostIds?: Array<string>;
@@ -0,0 +1,63 @@
export type NotificationChannel = "whatsapp" | "email" | "system";
export interface QuietHours {
from: string; // "HH:mm"
to: string; // "HH:mm"
}
export interface ReminderRule {
offset: number; // minutes before appointment
enabled: boolean;
}
export interface CompanyNotificationPolicy {
id?: string;
companyId: string;
defaultChannels: NotificationChannel[];
mutedChannels?: NotificationChannel[];
timezone: string;
quietHours?: QuietHours;
reminderRules?: ReminderRule[];
createdAt: Date;
updatedAt: Date;
}
export interface ClientNotificationPreferences {
id?: string;
userId: string;
preferredChannels: NotificationChannel[];
mutedChannels: NotificationChannel[];
createdAt: Date;
updatedAt: Date;
}
export interface ClientCompanyNotificationOverride {
id?: string;
clientId: string;
companyId: string;
preferredChannels: NotificationChannel[];
mutedChannels: NotificationChannel[];
createdAt: Date;
updatedAt: Date;
}
// API params (match backend UpsertCompanyPolicyParams)
export interface UpsertCompanyPolicyParams {
defaultChannels?: NotificationChannel[];
timezone?: string;
quietHours?: QuietHours;
reminderRules?: ReminderRule[];
}
export interface UpsertClientPreferencesParams {
preferredChannels?: NotificationChannel[];
mutedChannels?: NotificationChannel[];
}
export interface UpsertClientCompanyOverrideParams {
preferredChannels?: NotificationChannel[];
mutedChannels?: NotificationChannel[];
}
// Store mode
export type NotificationFormMode = "company-policy" | "client-override" | "client-preferences";
@@ -0,0 +1,44 @@
import * as Yup from "yup";
const validChannels = ["whatsapp", "email", "system"] as const;
export const companyPolicySchema = Yup.object().shape({
defaultChannels: Yup.array()
.of(Yup.string().oneOf(validChannels))
.min(1, "Select at least one channel")
.required("Channels are required"),
timezone: Yup.string().required("Timezone is required"),
quietHours: Yup.object()
.shape({
from: Yup.string().matches(
/^([01]\d|2[0-3]):([0-5]\d)$/,
"Invalid time format (HH:mm)"
),
to: Yup.string().matches(
/^([01]\d|2[0-3]):([0-5]\d)$/,
"Invalid time format (HH:mm)"
)
})
.optional(),
reminderRules: Yup.array()
.of(
Yup.object().shape({
offset: Yup.number()
.min(5, "Minimum 5 minutes")
.max(10080, "Maximum 7 days (10080 minutes)")
.required("Offset is required"),
enabled: Yup.boolean().required()
})
)
.max(5, "Maximum 5 reminder rules")
.optional()
});
export const clientPrefsSchema = Yup.object().shape({
preferredChannels: Yup.array()
.of(Yup.string().oneOf(validChannels))
.optional(),
mutedChannels: Yup.array()
.of(Yup.string().oneOf(validChannels))
.optional()
});
+2
View File
@@ -1,8 +1,10 @@
export enum TemplateTypes {
WAP_NEW_APPOINTMENT = "wap-new-appointment",
WAP_REMINDER = "wap-reminder",
WAP_CANCELLATION = "wap-cancellation",
EMAIL_NEW_APPOINTMENT = "email-new-appointment",
EMAIL_REMINDER = "email-reminder",
EMAIL_CANCELLATION = "email-cancellation",
}
export const refactorFields = (text: string) => {
@@ -0,0 +1,27 @@
import ApiRequest from "@services/Api.Service";
import {
CompanyNotificationPolicy,
ClientNotificationPreferences,
ClientCompanyNotificationOverride,
UpsertCompanyPolicyParams,
UpsertClientPreferencesParams,
UpsertClientCompanyOverrideParams
} from "@models/NotificationPreferences.model";
export const getCompanyPolicy = (companyId: string, sessionUser: string): Promise<CompanyNotificationPolicy> =>
ApiRequest.post<CompanyNotificationPolicy>("notifications/policy/find", { companyId, sessionUser });
export const upsertCompanyPolicy = (companyId: string, sessionUser: string, data: UpsertCompanyPolicyParams): Promise<CompanyNotificationPolicy> =>
ApiRequest.post<CompanyNotificationPolicy>("notifications/policy/save", { companyId, sessionUser, ...data });
export const getClientPreferences = (userId: string, sessionUser: string): Promise<ClientNotificationPreferences> =>
ApiRequest.post<ClientNotificationPreferences>("notifications/preferences/find", { clientId: userId, sessionUser });
export const upsertClientPreferences = (userId: string, sessionUser: string, data: UpsertClientPreferencesParams): Promise<ClientNotificationPreferences> =>
ApiRequest.post<ClientNotificationPreferences>("notifications/preferences/save", { clientId: userId, sessionUser, ...data });
export const getClientCompanyOverride = (clientId: string, companyId: string, sessionUser: string): Promise<ClientCompanyNotificationOverride> =>
ApiRequest.post<ClientCompanyNotificationOverride>("notifications/override/find", { clientId, companyId, sessionUser });
export const upsertClientCompanyOverride = (clientId: string, companyId: string, sessionUser: string, data: UpsertClientCompanyOverrideParams): Promise<ClientCompanyNotificationOverride> =>
ApiRequest.post<ClientCompanyNotificationOverride>("notifications/override/save", { clientId, companyId, sessionUser, ...data });
+16
View File
@@ -1,12 +1,15 @@
import { create } from "zustand";
import type { ReactNode } from "react";
interface IConfirmStore {
visible: boolean;
message: string;
content?: ReactNode;
title: string;
onConfirm: () => void;
onCancel: () => void;
show: (message: string, onConfirm: () => void, onCancel: () => void) => void;
showContent: (message: string, content: ReactNode, onConfirm: () => void, onCancel: () => void) => void;
close: () => void;
clear: () => void;
}
@@ -16,6 +19,7 @@ export type ConfirmState = IConfirmStore;
const useConfirmStore = create<ConfirmState>()((set) => ({
visible: false,
message: "",
content: undefined,
title: "",
onConfirm: () => {},
onCancel: () => {},
@@ -23,6 +27,16 @@ const useConfirmStore = create<ConfirmState>()((set) => ({
set(() => ({
visible: true,
message: message,
content: undefined,
onConfirm: onConfirm,
onCancel: onCancel,
title: "Atención",
})),
showContent: (message: string, content: ReactNode, onConfirm: () => void, onCancel: () => void) =>
set(() => ({
visible: true,
message: message,
content: content,
onConfirm: onConfirm,
onCancel: onCancel,
title: "Atención",
@@ -31,6 +45,7 @@ const useConfirmStore = create<ConfirmState>()((set) => ({
set(() => ({
visible: false,
message: "",
content: undefined,
onConfirm: () => {},
onCancel: () => {},
title: "",
@@ -39,6 +54,7 @@ const useConfirmStore = create<ConfirmState>()((set) => ({
set(() => ({
visible: false,
message: "",
content: undefined,
onConfirm: () => {},
onCancel: () => {},
title: "",
@@ -0,0 +1,75 @@
import { create } from "zustand";
import {
NotificationFormMode,
CompanyNotificationPolicy,
ClientNotificationPreferences,
ClientCompanyNotificationOverride
} from "@models/NotificationPreferences.model";
interface NotificationPreferencesState {
mode: NotificationFormMode;
companyId: string;
clientId: string;
companyPolicy: CompanyNotificationPolicy | null;
clientPreferences: ClientNotificationPreferences | null;
clientOverride: ClientCompanyNotificationOverride | null;
formData: {
defaultChannels: string[];
preferredChannels: string[];
mutedChannels: string[];
timezone: string;
quietHours: { from: string; to: string } | null;
reminderRules: { offset: number; enabled: boolean }[];
};
isDirty: boolean;
loading: boolean;
setMode: (mode: NotificationFormMode) => void;
setCompanyId: (companyId: string) => void;
setClientId: (clientId: string) => void;
setCompanyPolicy: (policy: CompanyNotificationPolicy | null) => void;
setClientPreferences: (preferences: ClientNotificationPreferences | null) => void;
setClientOverride: (override: ClientCompanyNotificationOverride | null) => void;
updateFormData: (data: Partial<NotificationPreferencesState["formData"]>) => void;
setDirty: (dirty: boolean) => void;
setLoading: (loading: boolean) => void;
resetForm: () => void;
}
const initialFormData = {
defaultChannels: [],
preferredChannels: [],
mutedChannels: [],
timezone: "America/Argentina/Buenos_Aires",
quietHours: null,
reminderRules: []
};
export const useNotificationPreferencesStore = create<NotificationPreferencesState>()((set) => ({
mode: "company-policy",
companyId: "",
clientId: "",
companyPolicy: null,
clientPreferences: null,
clientOverride: null,
formData: { ...initialFormData },
isDirty: false,
loading: false,
setMode: (mode) => set({ mode }),
setCompanyId: (companyId) => set({ companyId }),
setClientId: (clientId) => set({ clientId }),
setCompanyPolicy: (policy) => set({ companyPolicy: policy }),
setClientPreferences: (preferences) => set({ clientPreferences: preferences }),
setClientOverride: (override) => set({ clientOverride: override }),
updateFormData: (data) =>
set((state) => ({
formData: { ...state.formData, ...data },
isDirty: true
})),
setDirty: (dirty) => set({ isDirty: dirty }),
setLoading: (loading) => set({ loading }),
resetForm: () =>
set({
formData: { ...initialFormData },
isDirty: false
})
}));
@@ -91,6 +91,23 @@ export const sendEmailNotification = async (
});
};
export const createImmediateNotificationJobs = async (
data: SendAppointmentNotificationParams
): Promise<ApiVoidResult> => {
return new Promise<ApiVoidResult>((resolve, reject) => {
schemaSendWapNotification
.validate(data, { abortEarly: true })
.then(() => {
resolve(
ApiRequest.post<ApiVoidResult>("appointments/create-immediate-notification-jobs", data)
);
})
.catch((error) => {
reject(new ApiError(500, error.message));
});
});
};
const schemaApplyDiscount = Yup.object().shape({
appointmentId: Yup.string().required("Debe seleccionar un turno."),
companyId: Yup.string().required("Organización no seleccionada."),
@@ -9,11 +9,10 @@ import { useParams } from "next/navigation";
import { useEffect, useState } from "react";
import {
applyAppointmentDiscount,
createImmediateNotificationJobs,
deleteAppointment,
deleteAppointmentDiscount,
findAppointmentsAdminByClient,
sendEmailNotification,
sendWapNotification,
updateAppointment,
} from "../Appointments.Service";
import {
@@ -26,8 +25,7 @@ import {
import { ChangeServiceParams } from "@models/Appointments.model";
import Textbox from "@core/app/components/Textbox/Textbox";
import { useMenuStore } from "@core/Store/Menu.Store";
import WhatsAppIcon from "@mui/icons-material/WhatsApp";
import EmailOutlinedIcon from "@mui/icons-material/EmailOutlined";
import NotificationsActiveOutlinedIcon from "@mui/icons-material/NotificationsActiveOutlined";
import DeleteIcon from "@mui/icons-material/Delete";
import Switch from "@mui/material/Switch";
@@ -54,6 +52,8 @@ import filterObjects from "@components/TextObjectFilter/Filter.Service";
import DialogWindow from "@core/app/components/DialogWindow/DialogWindow";
import { DialogActions, DialogContent } from "@mui/material";
import Checkbox from "@mui/material/Checkbox";
import FormControlLabel from "@mui/material/FormControlLabel";
import { TextObjectFilterResult } from "@core/Models/TextObjectFilter.model";
import { FindDiscountsParams } from "@core/Models/Discounts.model";
import DiscountsIcon from "@mui/icons-material/LocalOfferOutlined";
@@ -312,71 +312,19 @@ export default function ClientAppointment() {
menu.clear();
menu.add({
text: "Enviar recordatorio por Whatsapp",
text: "Enviar notificación de alta",
onClick: () => {
if (!appointmentId) {
return;
}
eventHandler.setEventType(EVENT_TYPES.LOADING);
sendWapNotification({
appointmentId: appointmentId.toString(),
type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER,
sessionUser: SessionUser.userId,
})
.then(() => {
alert.showSuccess("La notificatión fue enviada con éxito");
})
.catch((error) => {
alert.showError(error.format());
})
.finally(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
},
icon: <WhatsAppIcon />,
});
menu.add({
text: "Enviar recordatorio por Email",
onClick: () => {
if (!appointmentId) {
return;
}
eventHandler.setEventType(EVENT_TYPES.LOADING);
sendEmailNotification({
appointmentId: appointmentId.toString(),
type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER,
sessionUser: SessionUser.userId,
})
.then(() => {
alert.showSuccess("La notificatión fue enviada con éxito");
})
.catch((error) => {
alert.showError(error.format());
})
.finally(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
},
icon: <EmailOutlinedIcon />,
});
menu.divider();
menu.add({
text: "Enviar alta de turno por Whatsapp",
onClick: () => {
if (!appointmentId) {
return;
}
eventHandler.setEventType(EVENT_TYPES.LOADING);
sendWapNotification({
createImmediateNotificationJobs({
appointmentId: appointmentId.toString(),
type: APPOINTMENT_NOTIFICATION_TYPE.CREATION,
sessionUser: SessionUser.userId,
})
.then(() => {
alert.showSuccess("La notificatión fue enviada con éxito");
alert.showSuccess("La notificación fue encolada con éxito");
})
.catch((error) => {
alert.showError(error.format());
@@ -385,23 +333,23 @@ export default function ClientAppointment() {
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
},
icon: <WhatsAppIcon />,
icon: <NotificationsActiveOutlinedIcon />,
});
menu.add({
text: "Enviar alta de turno por Email",
text: "Enviar recordatorio",
onClick: () => {
if (!appointmentId) {
return;
}
eventHandler.setEventType(EVENT_TYPES.LOADING);
sendEmailNotification({
createImmediateNotificationJobs({
appointmentId: appointmentId.toString(),
type: APPOINTMENT_NOTIFICATION_TYPE.CREATION,
type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER,
sessionUser: SessionUser.userId,
})
.then(() => {
alert.showSuccess("La notificatión fue enviada con éxito");
alert.showSuccess("La notificación fue encolada con éxito");
})
.catch((error) => {
alert.showError(error.format());
@@ -410,7 +358,7 @@ export default function ClientAppointment() {
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
},
icon: <EmailOutlinedIcon />,
icon: <NotificationsActiveOutlinedIcon />,
});
if (showQuitDiscount) {
@@ -593,12 +541,25 @@ export default function ClientAppointment() {
return;
}
confirm.show(
let sendNotification = false;
confirm.showContent(
"Estas seguro que queres cancelar el turno?",
<FormControlLabel
control={
<Checkbox
onChange={(event) => {
sendNotification = event.target.checked;
}}
/>
}
label="Enviar notificación al cliente"
/>,
() => {
eventHandler.setEventType(EVENT_TYPES.LOADING);
deleteAppointment({
id: appointmentId?.toString(),
notification: sendNotification,
sessionUser: SessionUser.userId,
})
.then(() => {
@@ -0,0 +1,85 @@
.container {
margin-top: 32px;
display: flex;
flex-direction: column;
gap: 10px;
width: min(100%, 900px);
margin-left: auto;
margin-right: auto;
}
.channels {
display: flex;
flex-direction: column;
gap: 10px;
}
.card {
display: grid;
grid-template-columns: minmax(220px, 1fr) minmax(360px, 1fr);
gap: 16px;
align-items: center;
padding: 16px;
border-radius: 10px;
border: 1px solid var(--border-color);
background-color: var(--bg-primary);
}
.channelTitle {
color: var(--text-primary);
font-weight: 700;
font-size: 16px;
}
.channelDescription {
color: var(--text-secondary);
font-size: 13px;
margin-top: 4px;
}
.options {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.optionButton {
cursor: pointer;
width: 100%;
padding: 10px 14px;
border-radius: 999px;
border: 1px solid var(--border-color);
background-color: var(--bg-primary);
color: var(--text-secondary);
font-weight: 500;
font-size: 14px;
transition: all 0.2s ease;
}
.optionButtonActive {
border-color: var(--primary-color);
background-color: rgba(25, 118, 210, 0.12);
color: var(--primary-color);
font-weight: 700;
}
@media (max-width: 760px) {
.card {
grid-template-columns: 1fr;
gap: 12px;
}
}
@media (max-width: 480px) {
.container {
margin-top: 24px;
}
.card {
padding: 14px;
}
.options {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,82 @@
"use client";
import { useParams } from "next/navigation";
import { useEffect } from "react";
import Saveable from "@components/Saveable/Saveable";
import { useNotificationPreferencesStore } from "@store/NotificationPreferences.Store";
import { useSessionStore } from "@store/Sesion.Store";
import { useEventHandlerStore, EVENT_TYPES } from "@store/EventHandler.Store";
import { useAlert } from "@store/Alert.Store";
import { getClientCompanyOverride, upsertClientCompanyOverride } from "@services/Notifications.Service";
import { NotificationChannel } from "@models/NotificationPreferences.model";
type UrlData = {
oid: string;
clientId: string;
};
export default function NotificationsLayout({ children }: { children: React.ReactNode }) {
const data = useParams<UrlData>();
const oid = data.oid;
const clientId = data.clientId;
const store = useNotificationPreferencesStore();
const SessionInfo = useSessionStore();
const eventHandler = useEventHandlerStore();
const alert = useAlert();
useEffect(() => {
if (!SessionInfo.userId || !oid || !clientId) {
return;
}
store.setCompanyId(oid);
store.setClientId(clientId);
store.setMode("client-override");
eventHandler.setEventType(EVENT_TYPES.LOADING);
getClientCompanyOverride(clientId, oid, SessionInfo.userId)
.then((override) => {
store.setClientOverride(override);
store.updateFormData({
preferredChannels: override.preferredChannels || [],
mutedChannels: override.mutedChannels || []
});
store.setDirty(false);
})
.catch((error) => {
alert.showError(error.format());
})
.finally(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
}, [SessionInfo.userId, oid, clientId]);
const handleSave = () => {
eventHandler.setEventType(EVENT_TYPES.LOADING);
upsertClientCompanyOverride(clientId, oid, SessionInfo.userId, {
preferredChannels: store.formData.preferredChannels as NotificationChannel[],
mutedChannels: store.formData.mutedChannels as NotificationChannel[]
})
.then((override) => {
store.setClientOverride(override);
store.setDirty(false);
eventHandler.setEventType(EVENT_TYPES.SLEEP);
})
.catch((error) => {
alert.showError(error.format());
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
};
useEffect(() => {
if (store.isDirty) {
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
}
}, [store.isDirty]);
return (
<Saveable onClick={handleSave}>
{children}
</Saveable>
);
}
@@ -0,0 +1,139 @@
"use client";
import { useParams } from "next/navigation";
import { useEffect } from "react";
import Location from "@components/Location/Location";
import { useLocationStore } from "@store/Location.Store";
import { useNotificationPreferencesStore } from "@store/NotificationPreferences.Store";
import { NotificationChannel } from "@models/NotificationPreferences.model";
import StorefrontOutlinedIcon from "@mui/icons-material/StorefrontOutlined";
import ClientsIcon from "@mui/icons-material/AccountBoxOutlined";
import NotificationsIcon from "@mui/icons-material/NotificationsOutlined";
import MaterialAlert from "@mui/material/Alert";
import styles from "./NotificationsPage.module.css";
type UrlData = {
oid: string;
clientId: string;
};
const CHANNELS: { key: NotificationChannel; label: string }[] = [
{ key: "whatsapp", label: "WhatsApp" },
{ key: "email", label: "Email" },
{ key: "system", label: "Sistema" }
];
type ChannelPreference = "default" | "preferred" | "muted";
export default function NotificationsPage() {
const data = useParams<UrlData>();
const oid = data.oid;
const clientId = data.clientId;
const location = useLocationStore();
const store = useNotificationPreferencesStore();
useEffect(() => {
location.clear();
location.add({
url: "/admin/org",
title: "Organizaciones",
icon: null
});
location.add({
url: "/admin/org/profile/" + oid,
title: "Organización",
icon: <StorefrontOutlinedIcon sx={{ mr: 0.5 }} fontSize="inherit" />
});
location.add({
url: "/admin/org/" + oid + "/client/" + clientId + "/profile",
title: "Cliente",
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />
});
location.add({
url: "",
title: "Notificaciones",
icon: <NotificationsIcon sx={{ fontSize: "32px", mr: "10px" }} />
});
}, [oid, clientId]);
const getChannelPreference = (channel: NotificationChannel): ChannelPreference => {
if (store.formData.mutedChannels.includes(channel)) {
return "muted";
}
if (store.formData.preferredChannels.includes(channel)) {
return "preferred";
}
return "default";
};
const selectPreference = (channel: NotificationChannel, preference: ChannelPreference) => {
store.updateFormData({
preferredChannels:
preference === "preferred"
? [...store.formData.preferredChannels.filter((c) => c !== channel), channel]
: store.formData.preferredChannels.filter((c) => c !== channel),
mutedChannels:
preference === "muted"
? [...store.formData.mutedChannels.filter((c) => c !== channel), channel]
: store.formData.mutedChannels.filter((c) => c !== channel)
});
};
return (
<>
<Location />
<div className={styles.container}>
<MaterialAlert severity="info" sx={{ marginBottom: "0" }}>
<b>Preferencias del cliente:</b> Configure cómo esta organización debe comunicarse con este cliente. Si deja un canal en estándar, se usará la política general de la organización.
</MaterialAlert>
<div className={styles.channels}>
{CHANNELS.map((channel) => {
const selectedPreference = getChannelPreference(channel.key);
return (
<div key={channel.key} className={styles.card}>
<div>
<div className={styles.channelTitle}>{channel.label}</div>
<div className={styles.channelDescription}>
{selectedPreference === "preferred" && "Priorizar este canal para el cliente."}
{selectedPreference === "muted" && "No enviar notificaciones por este canal."}
{selectedPreference === "default" && "Usar la configuración general de la organización."}
</div>
</div>
<div className={styles.options}>
{[
{ key: "default" as ChannelPreference, label: "Estándar" },
{ key: "preferred" as ChannelPreference, label: "Preferido" },
{ key: "muted" as ChannelPreference, label: "Silenciado" }
].map((option) => {
const isSelected = selectedPreference === option.key;
return (
<button
key={option.key}
type="button"
onClick={() => selectPreference(channel.key, option.key)}
className={`${styles.optionButton} ${isSelected ? styles.optionButtonActive : ""}`}
>
{option.label}
</button>
);
})}
</div>
</div>
);
})}
</div>
</div>
<div style={{ marginBottom: "40px" }}>&nbsp;</div>
</>
);
}
@@ -3,6 +3,7 @@ import ButtonOption from "@components/ButtonOption/ButtonOption";
import Location from "@components/Location/Location";
import StorefrontOutlinedIcon from "@mui/icons-material/StorefrontOutlined";
import ClientsIcon from "@mui/icons-material/AccountBoxOutlined";
import NotificationsIcon from "@mui/icons-material/Notifications";
import { useLocationStore } from "@store/Location.Store";
import { useNavigation } from "@hooks/goto";
import { useParams } from "next/navigation";
@@ -120,6 +121,19 @@ export default function ClientProfile() {
)
}
/>
<ButtonOption
text="Notificaciones"
icon="/notifications-icon.svg"
onClick={() =>
goTo(
"/admin/org/" +
oid +
"/client/" +
clientId +
"/notifications"
)
}
/>
</div>
</>
);
@@ -148,6 +148,10 @@ export default function OrganizationEdit() {
TextObjectFilterResult[]
>([]);
const [templateWapAltaId, setTemplateWapAltaId] = useState<string>("");
const [templateWapCancellationFilterData, setTemplateWapCancellationFilterData] = useState<
TextObjectFilterResult[]
>([]);
const [templateWapCancellationId, setTemplateWapCancellationId] = useState<string>("");
const loadTemplatesCombos = () => {
if (!SessionInfo.userId) return;
@@ -168,6 +172,7 @@ export default function OrganizationEdit() {
};
filterObjects<FindTemplatesParams>("templates/filter-all", filter).then((res) => {
setTemplateWapAltaFilterData(res);
setTemplateWapCancellationFilterData(res);
setCombosLoaded(true);
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
@@ -222,6 +227,7 @@ export default function OrganizationEdit() {
requestAnimationFrame(() => {
setTemplateWapNotifId(response.templateWapNotifId);
setTemplateWapAltaId(response.templateWapAltaId);
setTemplateWapCancellationId(response.templateWapCancellationId);
});
})
.catch((error) => {
@@ -263,6 +269,7 @@ export default function OrganizationEdit() {
phoneNumber: phone,
templateWapNotifId: templateWapNotifId === "" ? undefined : templateWapNotifId,
templateWapAltaId: templateWapAltaId === "" ? undefined : templateWapAltaId,
templateWapCancellationId: templateWapCancellationId === "" ? undefined : templateWapCancellationId,
};
const schema = Yup.object().shape({
@@ -317,8 +324,10 @@ export default function OrganizationEdit() {
.positive("El Plazo Mínimo de Cancelación debe ser un número positivo."),
templateWapNotifId: Yup.string().optional().default(""),
templateWapAltaId: Yup.string().optional().default(""),
templateWapCancellationId: Yup.string().optional().default(""),
templateEmailNotifId: Yup.string().optional().default(""),
templateEmailAltaId: Yup.string().optional().default(""),
templateEmailCancellationId: Yup.string().optional().default(""),
});
schema
@@ -603,6 +612,17 @@ export default function OrganizationEdit() {
selectedId={templateWapAltaId}
/>
<TextObjectFilter
placeholder="Cancelación de Reservas"
content={templateWapAltaFilterContent}
data={templateWapCancellationFilterData}
onChange={(id) => {
setTemplateWapCancellationId(id);
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
}}
selectedId={templateWapCancellationId}
/>
<div style={{ marginBottom: "10px" }}>&nbsp;</div>
</>
);
@@ -0,0 +1,83 @@
"use client";
import { useParams } from "next/navigation";
import { useEffect } from "react";
import Saveable from "@components/Saveable/Saveable";
import { useNotificationPreferencesStore } from "@store/NotificationPreferences.Store";
import { useSessionStore } from "@store/Sesion.Store";
import { useEventHandlerStore, EVENT_TYPES } from "@store/EventHandler.Store";
import { useAlert } from "@store/Alert.Store";
import { getCompanyPolicy, upsertCompanyPolicy } from "@services/Notifications.Service";
import { NotificationChannel } from "@models/NotificationPreferences.model";
type UrlData = {
id: string;
};
export default function NotificationsLayout({ children }: { children: React.ReactNode }) {
const data = useParams<UrlData>();
const id = data.id;
const store = useNotificationPreferencesStore();
const SessionInfo = useSessionStore();
const eventHandler = useEventHandlerStore();
const alert = useAlert();
useEffect(() => {
if (!SessionInfo.userId || !id) {
return;
}
store.setCompanyId(id);
store.setMode("company-policy");
eventHandler.setEventType(EVENT_TYPES.LOADING);
getCompanyPolicy(id, SessionInfo.userId)
.then((policy) => {
store.setCompanyPolicy(policy);
store.updateFormData({
defaultChannels: policy.defaultChannels || [],
mutedChannels: policy.mutedChannels || [],
quietHours: policy.quietHours || null,
reminderRules: policy.reminderRules || []
});
store.setDirty(false);
})
.catch((error) => {
alert.showError(error.format());
})
.finally(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
}, [SessionInfo.userId, id]);
const handleSave = () => {
eventHandler.setEventType(EVENT_TYPES.LOADING);
upsertCompanyPolicy(id, SessionInfo.userId, {
defaultChannels: store.formData.defaultChannels as NotificationChannel[],
timezone: "America/Argentina/Buenos_Aires",
quietHours: store.formData.quietHours || undefined,
reminderRules: store.formData.reminderRules
})
.then((policy) => {
store.setCompanyPolicy(policy);
store.setDirty(false);
eventHandler.setEventType(EVENT_TYPES.SLEEP);
})
.catch((error) => {
alert.showError(error.format());
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
};
useEffect(() => {
if (store.isDirty) {
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
}
}, [store.isDirty]);
return (
<Saveable onClick={handleSave}>
{children}
</Saveable>
);
}
@@ -0,0 +1,215 @@
"use client";
import { useParams } from "next/navigation";
import { useEffect } from "react";
import Location from "@components/Location/Location";
import TextTime from "@components/TextTime/TextTime";
import { useLocationStore } from "@store/Location.Store";
import { useNotificationPreferencesStore } from "@store/NotificationPreferences.Store";
import { useEventHandlerStore, EVENT_TYPES } from "@store/EventHandler.Store";
import { NotificationChannel } from "@models/NotificationPreferences.model";
import Switch from "@core/app/components/Switch/Switch";
import StorefrontOutlinedIcon from "@mui/icons-material/StorefrontOutlined";
import NotificationsIcon from "@mui/icons-material/NotificationsOutlined";
import MaterialAlert from "@mui/material/Alert";
import dayjs from "dayjs";
type UrlData = {
id: string;
};
const CHANNELS: { key: NotificationChannel; label: string }[] = [
{ key: "whatsapp", label: "WhatsApp" },
{ key: "email", label: "Email" },
{ key: "system", label: "Sistema" }
];
const REMINDER_PRESETS: { minutes: number; label: string }[] = [
{ minutes: 2, label: "2 minutos antes" },
{ minutes: 5, label: "5 minutos antes" },
{ minutes: 10, label: "10 minutos antes" }
];
export default function NotificationsPage() {
const data = useParams<UrlData>();
const id = data.id;
const location = useLocationStore();
const store = useNotificationPreferencesStore();
const eventHandler = useEventHandlerStore();
useEffect(() => {
location.clear();
location.add({
url: "/admin/org",
title: "Organizaciones",
icon: null
});
location.add({
url: "/admin/org/profile/" + id,
title: "Perfil",
icon: <StorefrontOutlinedIcon sx={{ mr: 0.5 }} fontSize="inherit" />
});
location.add({
url: "/admin/org/profile/" + id + "/notifications",
title: "Notificaciones",
icon: <NotificationsIcon sx={{ fontSize: "32px", mr: "10px" }} />
});
}, [id]);
const toggleChannel = (channel: NotificationChannel) => {
const current = store.formData.defaultChannels;
const updated = current.includes(channel)
? current.filter((c) => c !== channel)
: [...current, channel];
store.updateFormData({ defaultChannels: updated });
};
const updateQuietHours = (field: "from" | "to", value: string) => {
const current = store.formData.quietHours || { from: "00:00", to: "00:00" };
store.updateFormData({
quietHours: { ...current, [field]: value }
});
};
const quietHourValue = (value: string | undefined, fallback: string) => {
const [hour, minute] = (value || fallback).split(":").map(Number);
return dayjs(new Date()).hour(hour).minute(minute || 0).second(0).millisecond(0);
};
const toggleQuietHours = () => {
store.updateFormData({
quietHours: store.formData.quietHours ? null : { from: "22:00", to: "08:00" }
});
};
const toggleReminder = (minutes: number) => {
const exists = store.formData.reminderRules.some((r) => r.offset === minutes);
if (exists) {
const updated = store.formData.reminderRules.filter((r) => r.offset !== minutes);
store.updateFormData({ reminderRules: updated });
} else {
store.updateFormData({
reminderRules: [...store.formData.reminderRules, { offset: minutes, enabled: true }]
});
}
};
return (
<>
<Location />
<div style={{ marginTop: "20px" }}>
{/* Channel Toggles Section */}
<MaterialAlert severity="info" sx={{ marginBottom: "20px" }}>
<b>Canales de notificación:</b> Seleccione los canales por los cuales se enviarán las notificaciones automáticas.
</MaterialAlert>
<div style={{ display: "flex", flexDirection: "column", gap: "8px", marginBottom: "30px" }}>
{CHANNELS.map((channel) => (
<div
key={channel.key}
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "12px 16px",
borderRadius: "4px",
border: "1px solid var(--border-color)",
backgroundColor: "var(--bg-primary)"
}}
>
<span style={{ color: "var(--text-primary)", fontWeight: "500" }}>{channel.label}</span>
<Switch
checked={store.formData.defaultChannels.includes(channel.key)}
onChange={() => toggleChannel(channel.key)}
/>
</div>
))}
</div>
{/* Quiet Hours Section */}
<MaterialAlert severity="info" sx={{ marginBottom: "20px" }}>
<b>Horario silencioso:</b> Las notificaciones no se enviarán durante este rango horario.
</MaterialAlert>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "12px 16px",
borderRadius: "8px",
border: "1px solid var(--border-color)",
backgroundColor: "var(--bg-primary)",
marginBottom: "16px"
}}
>
<span style={{ color: "var(--text-primary)", fontWeight: "500" }}>
Activar horario silencioso
</span>
<Switch
checked={Boolean(store.formData.quietHours)}
onChange={toggleQuietHours}
/>
</div>
{store.formData.quietHours && (
<div style={{ display: "flex", gap: "16px", alignItems: "flex-end", marginBottom: "30px" }}>
<div style={{ flex: 1 }}>
<TextTime
placeholder="Desde"
width="100%"
value={quietHourValue(store.formData.quietHours.from, "22:00")}
onChange={(value) => updateQuietHours("from", value.format("HH:mm"))}
/>
</div>
<div style={{ flex: 1 }}>
<TextTime
placeholder="Hasta"
width="100%"
value={quietHourValue(store.formData.quietHours.to, "08:00")}
onChange={(value) => updateQuietHours("to", value.format("HH:mm"))}
/>
</div>
</div>
)}
{/* Reminder Rules Section */}
<MaterialAlert severity="info" sx={{ marginBottom: "20px" }}>
<b>Reglas de recordatorio:</b> Active los momentos en los que desea enviar recordatorios antes del turno.
</MaterialAlert>
<div style={{ display: "flex", flexWrap: "wrap", gap: "10px" }}>
{REMINDER_PRESETS.map((preset) => {
const isActive = store.formData.reminderRules.some((r) => r.offset === preset.minutes);
return (
<button
key={preset.minutes}
type="button"
onClick={() => toggleReminder(preset.minutes)}
style={{
cursor: "pointer",
padding: "10px 16px",
borderRadius: "999px",
border: `1px solid ${isActive ? "var(--primary-color)" : "var(--border-color)"}`,
backgroundColor: isActive ? "rgba(25, 118, 210, 0.12)" : "var(--bg-primary)",
color: isActive ? "var(--primary-color)" : "var(--text-secondary)",
fontWeight: isActive ? "600" : "500",
fontSize: "14px",
transition: "all 0.2s ease"
}}
>
{preset.label}
</button>
);
})}
</div>
</div>
<div style={{ marginBottom: "40px" }}>&nbsp;</div>
</>
);
}
@@ -20,6 +20,7 @@ import CashIcon from "@mui/icons-material/AccountBalanceOutlined";
import DiscountsIcon from "@mui/icons-material/LocalOfferOutlined";
import TemplatesIcon from "@mui/icons-material/SpeakerNotesOutlined";
import AssistanceIcon from "@mui/icons-material/HeadsetMicOutlined";
import NotificationsOutlinedIcon from "@mui/icons-material/NotificationsOutlined";
import ShareOutlinedIcon from "@mui/icons-material/ShareOutlined";
import Dialog from "@mui/material/Dialog";
import DialogContent from "@mui/material/DialogContent";
@@ -88,6 +89,11 @@ export default function OrganizationProfile() {
text: "WhatsApp",
onClick: () => goTo("/admin/org/profile/" + id + "/wap"),
},
{
icon: <NotificationsOutlinedIcon />,
text: "Notificaciones",
onClick: () => goTo("/admin/org/profile/" + id + "/notifications"),
},
{
icon: <CategoryOutlinedIcon />,
text: "Servicios",
@@ -197,7 +197,7 @@ export default function TemplateCreate() {
eventHandler.setEventType(EVENT_TYPES.LOADING);
getDefaultTemplate({
companyId: id,
type: type,
type: typeRef.current,
sessionUser: SessionInfo.userId,
})
.then((res) => {
@@ -267,6 +267,7 @@ export default function TemplateCreate() {
data={[
{ id: TemplateTypes.WAP_REMINDER, text: "WhatsApp - Recordatorio" },
{ id: TemplateTypes.WAP_NEW_APPOINTMENT, text: "WhatsApp - Nueva reserva" },
{ id: TemplateTypes.WAP_CANCELLATION, text: "WhatsApp - Cancelación" },
]}
setSelectedValue={(value) => {
setType(value as TemplateTypes);
@@ -37,10 +37,14 @@ export default function TemplatesGrid() {
return "WhatsApp - Nueva reserva";
case TemplateTypes.WAP_REMINDER:
return "WhatsApp - Recordatorio";
case TemplateTypes.WAP_CANCELLATION:
return "WhatsApp - Cancelación";
case TemplateTypes.EMAIL_NEW_APPOINTMENT:
return "Email - Nueva reserva";
case TemplateTypes.EMAIL_REMINDER:
return "Email - Recordatorio";
case TemplateTypes.EMAIL_CANCELLATION:
return "Email - Cancelación";
}
};
@@ -44,6 +44,7 @@ export default function ConfirmDialog() {
<DialogContentText id="alert-dialog-description">
{confirm.message}
</DialogContentText>
{confirm.content}
</DialogContent>
<DialogActions>
<Button onClick={handleCancell}>Cancelar</Button>
@@ -12,6 +12,7 @@ import {
useOrganizationHeaderStore,
} from "@store/OrganizationHeader.Store";
import SettingsIcon from "@mui/icons-material/Settings";
import NotificationsIcon from "@mui/icons-material/Notifications";
import classNames from "classnames";
import { useNavigation } from "@core/app/hooks/goto";
@@ -68,6 +69,14 @@ export default function OrganizationHeader(props: OrganizationHeaderParams): Rea
icon: <ShareOutlinedIcon />,
});
menu.add({
text: "Notificaciones",
onClick: () => {
goTo("/admin/org/profile/" + props.data.id + "/notifications");
},
icon: <NotificationsIcon />,
});
menu.divider();
menu.add({
@@ -77,6 +77,7 @@ export default function OrganizationPublicProfile() {
const data: DeleteAppointmentParams = {
id: aid,
validation: true,
notification: true,
sessionUser: SessionInfo.userId,
};
eventHandler.setEventType(EVENT_TYPES.LOADING);
@@ -0,0 +1,101 @@
.container {
margin-top: 32px;
display: flex;
flex-direction: column;
gap: 10px;
width: min(100%, 900px);
margin-left: auto;
margin-right: auto;
}
.channels {
display: flex;
flex-direction: column;
gap: 10px;
}
.card {
display: grid;
grid-template-columns: minmax(220px, 1fr) minmax(360px, 1fr);
gap: 16px;
align-items: center;
padding: 16px;
border-radius: 10px;
border: 1px solid var(--border-color);
background-color: var(--bg-primary);
}
.channelTitle {
color: var(--text-primary);
font-weight: 700;
font-size: 16px;
}
.channelDescription {
color: var(--text-secondary);
font-size: 13px;
margin-top: 4px;
}
.options {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.optionButton {
cursor: pointer;
width: 100%;
padding: 10px 14px;
border-radius: 999px;
border: 1px solid var(--border-color);
background-color: var(--bg-primary);
color: var(--text-secondary);
font-weight: 500;
font-size: 14px;
transition: all 0.2s ease;
}
.optionButtonActive {
border-color: var(--primary-color);
background-color: rgba(25, 118, 210, 0.12);
color: var(--primary-color);
font-weight: 700;
}
.saveButton {
align-self: flex-end;
padding: 12px 24px;
border-radius: 8px;
border: none;
background-color: var(--primary-color);
color: white;
cursor: pointer;
font-weight: bold;
font-size: 16px;
}
@media (max-width: 760px) {
.card {
grid-template-columns: 1fr;
gap: 12px;
}
}
@media (max-width: 480px) {
.container {
margin-top: 24px;
}
.card {
padding: 14px;
}
.options {
grid-template-columns: 1fr;
}
.saveButton {
width: 100%;
}
}
@@ -0,0 +1,7 @@
"use client";
import Saveable from "@components/Saveable/Saveable";
export default function NotificationsPreferencesLayout({ children }: { children: React.ReactNode }) {
return <Saveable>{children}</Saveable>;
}
@@ -0,0 +1,181 @@
"use client";
import { useEffect } from "react";
import Location from "@components/Location/Location";
import { useLocationStore } from "@store/Location.Store";
import { useNotificationPreferencesStore } from "@store/NotificationPreferences.Store";
import { useSessionStore } from "@store/Sesion.Store";
import { useEventHandlerStore, EVENT_TYPES } from "@store/EventHandler.Store";
import { useAlert } from "@store/Alert.Store";
import { getClientPreferences, upsertClientPreferences } from "@services/Notifications.Service";
import { NotificationChannel } from "@models/NotificationPreferences.model";
import AccountCircleOutlinedIcon from "@mui/icons-material/AccountCircleOutlined";
import NotificationsIcon from "@mui/icons-material/NotificationsOutlined";
import MaterialAlert from "@mui/material/Alert";
import styles from "./NotificationsPreferencesPage.module.css";
const CHANNELS: { key: NotificationChannel; label: string }[] = [
{ key: "whatsapp", label: "WhatsApp" },
{ key: "email", label: "Email" },
{ key: "system", label: "Sistema" }
];
type ChannelPreference = "default" | "preferred" | "muted";
export default function NotificationsPreferencesPage() {
const location = useLocationStore();
const store = useNotificationPreferencesStore();
const SessionInfo = useSessionStore();
const eventHandler = useEventHandlerStore();
const alert = useAlert();
useEffect(() => {
location.clear();
location.add({
url: "/user/profile",
title: "Perfil de usuario",
icon: <AccountCircleOutlinedIcon sx={{ fontSize: "32px", mr: "10px" }} />
});
location.add({
url: "",
title: "Preferencias de notificación",
icon: <NotificationsIcon sx={{ fontSize: "32px", mr: "10px" }} />
});
}, []);
useEffect(() => {
if (!SessionInfo.userId) {
return;
}
store.setClientId(SessionInfo.userId);
store.setMode("client-preferences");
eventHandler.setEventType(EVENT_TYPES.LOADING);
getClientPreferences(SessionInfo.userId, SessionInfo.userId)
.then((preferences) => {
store.setClientPreferences(preferences);
store.updateFormData({
preferredChannels: preferences.preferredChannels || [],
mutedChannels: preferences.mutedChannels || []
});
store.setDirty(false);
})
.catch((error) => {
alert.showError(error.format());
})
.finally(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
}, [SessionInfo.userId]);
const getChannelPreference = (channel: NotificationChannel): ChannelPreference => {
if (store.formData.mutedChannels.includes(channel)) {
return "muted";
}
if (store.formData.preferredChannels.includes(channel)) {
return "preferred";
}
return "default";
};
const selectPreference = (channel: NotificationChannel, preference: ChannelPreference) => {
store.updateFormData({
preferredChannels:
preference === "preferred"
? [...store.formData.preferredChannels.filter((c) => c !== channel), channel]
: store.formData.preferredChannels.filter((c) => c !== channel),
mutedChannels:
preference === "muted"
? [...store.formData.mutedChannels.filter((c) => c !== channel), channel]
: store.formData.mutedChannels.filter((c) => c !== channel)
});
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
};
const handleSave = () => {
eventHandler.setEventType(EVENT_TYPES.LOADING);
upsertClientPreferences(SessionInfo.userId, SessionInfo.userId, {
preferredChannels: store.formData.preferredChannels as NotificationChannel[],
mutedChannels: store.formData.mutedChannels as NotificationChannel[]
})
.then((preferences) => {
store.setClientPreferences(preferences);
store.setDirty(false);
})
.catch((error) => {
alert.showError(error.format());
})
.finally(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
};
useEffect(() => {
if (eventHandler.eventType === EVENT_TYPES.SAVE) {
handleSave();
}
}, [eventHandler.eventType]);
return (
<>
<Location />
<div className={styles.container}>
<MaterialAlert severity="info" sx={{ marginBottom: "0" }}>
<b>Preferencias globales:</b> Configure cómo prefiere recibir notificaciones. Si deja un canal en estándar, cada organización podrá usar su configuración general.
</MaterialAlert>
<div className={styles.channels}>
{CHANNELS.map((channel) => {
const selectedPreference = getChannelPreference(channel.key);
return (
<div key={channel.key} className={styles.card}>
<div>
<div className={styles.channelTitle}>{channel.label}</div>
<div className={styles.channelDescription}>
{selectedPreference === "preferred" && "Priorizar este canal para tus notificaciones."}
{selectedPreference === "muted" && "No recibir notificaciones por este canal."}
{selectedPreference === "default" && "Usar la configuración estándar de cada organización."}
</div>
</div>
<div className={styles.options}>
{[
{ key: "default" as ChannelPreference, label: "Estándar" },
{ key: "preferred" as ChannelPreference, label: "Preferido" },
{ key: "muted" as ChannelPreference, label: "Silenciado" }
].map((option) => {
const isSelected = selectedPreference === option.key;
return (
<button
key={option.key}
type="button"
onClick={() => selectPreference(channel.key, option.key)}
className={`${styles.optionButton} ${isSelected ? styles.optionButtonActive : ""}`}
>
{option.label}
</button>
);
})}
</div>
</div>
);
})}
</div>
{store.isDirty && (
<button onClick={handleSave} className={styles.saveButton}>
Guardar preferencias
</button>
)}
</div>
<div style={{ marginBottom: "40px" }}>&nbsp;</div>
</>
);
}
+6
View File
@@ -5,6 +5,7 @@ import Location from "@components/Location/Location";
import { useLocationStore } from "@store/Location.Store";
import { useEffect } from "react";
import AccountCircleOutlinedIcon from "@mui/icons-material/AccountCircleOutlined";
import NotificationsIcon from "@mui/icons-material/Notifications";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
export default function Profile() {
@@ -52,6 +53,11 @@ export default function Profile() {
icon="/notifications-icon.svg"
onClick={() => goTo("/user/profile/notifications")}
/>
<ButtonOption
text="Preferencias de notificación"
icon="/notifications-icon.svg"
onClick={() => goTo("/user/profile/notifications-preferences")}
/>
<ButtonOption
text="Avanzado"
icon="/services-icon.svg"