feat: add appointment notification sharing functionality via WhatsApp, email, and system clipboard

This commit is contained in:
2026-07-29 14:38:13 -03:00
parent 3d8911c093
commit 2a33f636a0
19 changed files with 679 additions and 5 deletions
+5
View File
@@ -0,0 +1,5 @@
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 3V15" stroke="#3f3a4d" stroke-width="2" stroke-linecap="round"/>
<path d="M7 10L12 15L17 10" stroke="#3f3a4d" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 19H19" stroke="#3f3a4d" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 376 B

Binary file not shown.
@@ -39,6 +39,13 @@ export type SendAppointmentNotificationParams = {
type: APPOINTMENT_NOTIFICATION_TYPE;
};
export type AppointmentNotificationPreviewResult = {
subject: string;
message: string;
emailMessage: string;
wapMessage: string;
};
export type DeleteAppointmentParams = {
id: string;
validation?: boolean;
@@ -1,6 +1,7 @@
import ApiRequest from "@services/Api.Service";
import {
ApplyAppointmentDiscountParams,
AppointmentNotificationPreviewResult,
AppointmentAdminByClientView,
AppointmentEventByClient,
DeleteAppointmentDiscountParams,
@@ -108,6 +109,23 @@ export const createImmediateNotificationJobs = async (
});
};
export const getAppointmentNotificationPreview = async (
data: SendAppointmentNotificationParams
): Promise<AppointmentNotificationPreviewResult> => {
return new Promise<AppointmentNotificationPreviewResult>((resolve, reject) => {
schemaSendWapNotification
.validate(data, { abortEarly: true })
.then(() => {
resolve(
ApiRequest.post<AppointmentNotificationPreviewResult>("appointments/notification-preview", 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."),
@@ -13,12 +13,14 @@ import {
deleteAppointment,
deleteAppointmentDiscount,
findAppointmentsAdminByClient,
getAppointmentNotificationPreview,
updateAppointment,
} from "../Appointments.Service";
import {
ApplyAppointmentDiscountParams,
APPOINTMENT_NOTIFICATION_TYPE,
AppointmentEventByClient,
AppointmentNotificationPreviewResult,
DeleteAppointmentDiscountParams,
UpdateAppointmentParams,
} from "@core/Models/Appointments.model";
@@ -27,6 +29,10 @@ import Textbox from "@core/app/components/Textbox/Textbox";
import { useMenuStore } from "@core/Store/Menu.Store";
import NotificationsActiveOutlinedIcon from "@mui/icons-material/NotificationsActiveOutlined";
import DeleteIcon from "@mui/icons-material/Delete";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import EmailIcon from "@mui/icons-material/Email";
import ShareIcon from "@mui/icons-material/Share";
import WhatsAppIcon from "@mui/icons-material/WhatsApp";
import Switch from "@mui/material/Switch";
import { ThemeProvider } from "@emotion/react";
@@ -91,6 +97,8 @@ export default function ClientAppointment() {
const [comments, setComments] = useState<string>("");
const [dialogDiscountVisible, setDialogDiscountVisible] = useState(false);
const [dialogShareVisible, setDialogShareVisible] = useState(false);
const [sharePreview, setSharePreview] = useState<AppointmentNotificationPreviewResult | undefined>(undefined);
const [discountsFilterContent] = useState<TextObjectModalContent>({
header: (
@@ -361,6 +369,14 @@ export default function ClientAppointment() {
icon: <NotificationsActiveOutlinedIcon />,
});
menu.add({
text: "Compartir",
onClick: () => {
openShareDialog();
},
icon: <ShareIcon />,
});
if (showQuitDiscount) {
menu.divider();
@@ -408,6 +424,66 @@ export default function ClientAppointment() {
});
};
const openShareDialog = () => {
if (!appointmentId) {
return;
}
setDialogShareVisible(true);
eventHandler.setEventType(EVENT_TYPES.LOADING);
getAppointmentNotificationPreview({
appointmentId: appointmentId.toString(),
type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER,
sessionUser: SessionUser.userId,
})
.then((preview) => {
setSharePreview(preview);
})
.catch((error) => {
alert.showError(error.format());
setDialogShareVisible(false);
})
.finally(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
};
const copyShareText = async () => {
if (!sharePreview) {
return;
}
await navigator.clipboard.writeText(sharePreview.wapMessage);
alert.showSuccess("Texto copiado al portapapeles");
};
const shareByWhatsApp = () => {
if (!sharePreview) {
return;
}
window.open(`https://wa.me/?text=${encodeURIComponent(sharePreview.wapMessage)}`, "_blank");
};
const shareByEmail = () => {
if (!sharePreview) {
return;
}
window.location.href = `mailto:?subject=${encodeURIComponent(sharePreview.subject)}&body=${encodeURIComponent(sharePreview.emailMessage)}`;
};
const nativeShare = async () => {
if (!sharePreview || !navigator.share) {
return;
}
await navigator.share({
title: sharePreview.subject,
text: sharePreview.wapMessage,
});
};
const applyDiscountHandler = () => {
if (!SessionUser.userId) {
return;
@@ -728,6 +804,88 @@ export default function ClientAppointment() {
</>
</DialogWindow>
<DialogWindow
visible={dialogShareVisible}
setVisible={setDialogShareVisible}
>
<>
<DialogContent
style={{
maxWidth: "520px",
minWidth: "300px",
margin: "auto",
}}
>
<h3 style={{ marginBottom: "10px" }}>Compartir recordatorio</h3>
<p style={{ fontSize: "14px", fontWeight: 500, marginBottom: "10px" }}>
Este texto usa el mismo contenido del recordatorio de notificaciones.
</p>
<textarea
readOnly
value={sharePreview?.wapMessage || ""}
style={{
width: "100%",
minHeight: "180px",
border: "1px solid var(--gray)",
borderRadius: "8px",
color: "var(--black)",
fontSize: "14px",
padding: "10px",
}}
/>
</DialogContent>
<DialogActions style={{ flexWrap: "wrap", gap: "8px" }}>
<ButtonMaterial
variant="contained"
color="primary"
startIcon={<WhatsAppIcon />}
disabled={!sharePreview}
onClick={shareByWhatsApp}
>
WhatsApp
</ButtonMaterial>
<ButtonMaterial
variant="contained"
color="primary"
startIcon={<EmailIcon />}
disabled={!sharePreview}
onClick={shareByEmail}
>
Email
</ButtonMaterial>
<ButtonMaterial
variant="contained"
color="primary"
startIcon={<ContentCopyIcon />}
disabled={!sharePreview}
onClick={copyShareText}
>
Copiar
</ButtonMaterial>
{typeof navigator !== "undefined" && !!navigator.share && (
<ButtonMaterial
variant="contained"
color="primary"
startIcon={<ShareIcon />}
disabled={!sharePreview}
onClick={nativeShare}
>
Compartir
</ButtonMaterial>
)}
<ButtonMaterial
variant="contained"
color="secondary"
onClick={() => {
setDialogShareVisible(false);
}}
>
Cerrar
</ButtonMaterial>
</DialogActions>
</>
</DialogWindow>
<DialogWindow
visible={dialogChangeServiceVisible}
setVisible={setDialogChangeServiceVisible}
@@ -159,6 +159,15 @@ export default function UserMenu(): React.ReactElement {
goTo(`/admin/org/profile/${orgId}/appointments/${targetDate}`);
};
const handleDownloadClick = () => {
toggleMenu();
window.location.href = "/download/tx-latest.apk";
};
const userBelongsToOrganization = () => {
return Object.keys(SessionInfo.organizationSubscriptions || {}).length > 0;
};
const getUserName = () => {
return SessionInfo.loged ? SessionInfo.userFullName : "Anonimo";
};
@@ -263,6 +272,15 @@ export default function UserMenu(): React.ReactElement {
iconAlign="left"
onClick={() => goTo("/messenger")}
/>
{userBelongsToOrganization() && (
<ButtonOption
text="Download"
icon="/download-icon.svg"
textAlign="left"
iconAlign="left"
onClick={handleDownloadClick}
/>
)}
</>
)}
{!SessionInfo.loged && (