first commit

This commit is contained in:
2026-07-16 20:48:43 -03:00
commit 5696cee264
1111 changed files with 322270 additions and 0 deletions
@@ -0,0 +1,45 @@
"use client";
import turnosXpressTheme from "@core/app/theme/turnosXpress";
import { ThemeProvider } from "@emotion/react";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from "@mui/material/DialogTitle";
import React from "react";
export interface ActionDialogParams {
title: string;
message: string;
visible: boolean;
setVisible: (state: boolean) => void;
buttons: React.ReactNode[];
}
export default function ActionDialog(props: ActionDialogParams) {
const { title, message, visible, setVisible, buttons } = props;
const handleClose = () => {
setVisible(false);
};
return (
<>
<ThemeProvider theme={turnosXpressTheme}>
<Dialog
open={visible}
onClose={handleClose}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
>
<DialogTitle id="alert-dialog-title">{title}</DialogTitle>
<DialogContent>
<DialogContentText id="alert-dialog-description">
{message}
</DialogContentText>
</DialogContent>
<DialogActions>{buttons.map((button) => button)}</DialogActions>
</Dialog>
</ThemeProvider>
</>
);
}
@@ -0,0 +1,45 @@
.buttonAdd {
position: fixed;
display: flex;
align-items: center;
justify-content: center;
bottom: 75px;
right: 15px;
background-color: var(--wine-dark);
color: var(--white);
border-radius: 50%;
z-index: 2000;
cursor: pointer;
transition: width 0.25s ease-in-out, height 0.25s ease-in-out;
animation: buttonAddPulseShadow 1.5s ease-in-out infinite;
}
.buttonAddVisible {
width: 64px;
height: 64px;
}
.buttonAddVisible > svg {
display: flex;
opacity: 1;
}
.buttonAddHidden {
width: 0px;
height: 0px;
}
.buttonAddHidden svg {
display: none;
opacity: 0;
}
@keyframes buttonAddPulseShadow {
0%,
100% {
box-shadow: 0px 2px 12px -4px var(--wine-red);
}
50% {
box-shadow: 0px 2px 20px -2px var(--wine-red);
}
}
@@ -0,0 +1,44 @@
"use client";
import { ReactNode, useEffect, useState } from "react";
import style from "./Addable.module.css";
import { EVENT_TYPES, useEventHandlerStore } from "@store/EventHandler.Store";
import AddOutlinedIcon from "@mui/icons-material/AddOutlined";
import classNames from "classnames";
interface SaveableProps {
children?: ReactNode;
onClick?: () => void;
}
export default function Addable(props: SaveableProps): React.ReactElement {
const eventHandler = useEventHandlerStore();
const [buttonClassNames, setButtonClassNames] = useState(
classNames(style.buttonSave, style.buttonSaveHidden)
);
useEffect(() => {
if (eventHandler.eventType === EVENT_TYPES.NEED_ADD) {
setButtonClassNames(classNames(style.buttonAdd, style.buttonAddVisible));
} else {
setButtonClassNames(classNames(style.buttonAdd, style.buttonAddHidden));
}
}, [eventHandler.eventType]);
return (
<>
{props.children}
<div
className={buttonClassNames}
onClick={() => {
if (props.onClick) {
props.onClick();
return;
}
eventHandler.setEventType(EVENT_TYPES.ADD);
}}
>
<AddOutlinedIcon sx={{ fontSize: "32px" }} />
</div>
</>
);
}
@@ -0,0 +1,35 @@
"use client";
import Snackbar, { SnackbarCloseReason } from "@mui/material/Snackbar";
import MaterialAlert from "@mui/material/Alert";
import { useAlert } from "@store/Alert.Store";
export default function Alert() {
const alert = useAlert();
const handleClose = (event?: React.SyntheticEvent | Event, reason?: SnackbarCloseReason) => {
if (reason === "clickaway") {
return;
}
alert.setVisible(false);
};
return (
<>
<Snackbar
open={alert.visible}
autoHideDuration={alert.hiddingTimeout}
onClose={handleClose}
sx={{ zIndex: 11000 }}
>
<MaterialAlert
onClose={handleClose}
severity={alert.mode}
variant="filled"
sx={{ width: "100%", marginBottom: "60px" }}
>
{alert.message}
</MaterialAlert>
</Snackbar>
</>
);
}
@@ -0,0 +1,23 @@
.animatedContainer {
position: relative;
overflow: hidden;
width: 100%;
}
.dotsContainer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0; /* Los puntos quedan detrás */
pointer-events: none; /* Los puntos no interfieren con los clics */
}
.childrenContainer {
position: relative;
z-index: 1; /* El contenido queda al frente */
pointer-events: auto; /* Permite interactuar con el contenido */
isolation: isolate; /* Evitar interferencia con puntos animados */
width: 100%; /*esta propiedad es nueva y no se si puede causar conflictos. La agregue para que el contenido se expanda a todo el ancho disponible*/
}
@@ -0,0 +1,116 @@
"use client";
import React, { useRef, useState, useEffect } from "react";
import { motion } from "framer-motion";
import style from "./AnimatedContainer.module.css";
import classNames from "classnames";
export interface AnimatedContainerProps {
style?: React.CSSProperties;
items?: number;
color: string;
duration?: number;
minSize?: number; // Tamaño mínimo de los puntos
maxSize?: number; // Tamaño máximo de los puntos
children: React.ReactNode;
className?: classNames.Argument;
}
export default function AnimatedContainer(props: AnimatedContainerProps) {
const { items = 10, duration = 20, minSize = 40, maxSize = 150 } = props;
const containerRef = useRef<HTMLDivElement>(null);
const [containerSize, setContainerSize] = useState({ width: 200, height: 200 });
const [isClient, setIsClient] = useState(false);
const [dots, setDots] = useState([]);
const sizeArray = Array.from(
{ length: 4 },
(_, index) => minSize + (index * (maxSize - minSize)) / 3
);
useEffect(() => {
setIsClient(true);
const isMobile = window.matchMedia("(max-width: 768px)").matches;
let dotItems = items;
if (isMobile) {
dotItems = 2;
} else {
dotItems = 5;
}
setDots(Array.from({ length: dotItems }));
}, []);
useEffect(() => {
if (containerRef.current && isClient) {
const updateSize = () => {
setContainerSize({
width: containerRef.current?.offsetWidth || 200,
height: containerRef.current?.offsetHeight || 200,
});
};
updateSize();
window.addEventListener("resize", updateSize);
return () => window.removeEventListener("resize", updateSize);
}
}, [isClient]);
const getChildrenClassName = () => {
if (props.className) {
return classNames(props.className);
}
return classNames(style.childrenContainer);
};
if (!isClient) {
return null;
}
return (
<motion.div className={style.animatedContainer} style={props.style} ref={containerRef}>
<div className={style.dotsContainer}>
{dots.map((_, index) => (
<motion.div
key={index}
style={{
position: "absolute",
borderRadius: "50%",
backgroundImage: props.color
? `radial-gradient(circle, ${props.color} 0%, rgba(255, 255, 255, 0.3) 100%)`
: undefined,
}}
initial={{
width: `${minSize}px`,
height: `${minSize}px`,
top: Math.random() * (containerSize.height - minSize),
left: Math.random() * (containerSize.width - minSize),
opacity: 1,
}}
animate={{
width: sizeArray,
height: sizeArray,
top: [
Math.random() * (containerSize.height - minSize),
Math.random() * (containerSize.height - minSize),
Math.random() * (containerSize.height - minSize),
],
left: [
Math.random() * (containerSize.width - minSize),
Math.random() * (containerSize.width - minSize),
Math.random() * (containerSize.width - minSize),
],
opacity: [0, 0.15, 0.25, 0.35, 0.5, 0.6, 0.5, 0.35, 0.25, 0.15, 0],
}}
transition={{
duration: duration,
repeat: Infinity,
ease: "easeIn",
}}
/>
))}
</div>
{/* El contenido de los hijos está garantizado al frente */}
<div className={getChildrenClassName()}>{props.children}</div>
</motion.div>
);
}
@@ -0,0 +1,65 @@
import { formatDateLarge, formatTimeInterval } from "@core/helpers/format";
import { AppointmentEventByClient } from "@core/Models/Appointments.model";
import style from "./style.module.css";
import { formatPrice } from "@core/app/helpers/Numbers";
import { DiscountType } from "@core/Models/Discounts.model";
export interface AppointmentEventHeaderProps {
appointment: AppointmentEventByClient;
showPaymentInfo?: boolean;
}
export default function AppointmentEventHeader({
appointment,
showPaymentInfo = false,
}: AppointmentEventHeaderProps) {
const formatDiscount = (t: DiscountType, v: number) => {
if (t === DiscountType.PERCENTAGE) {
return `${v}%`;
} else if (t === DiscountType.FIXED) {
return formatPrice(v);
}
};
return (
<div className={style.appointmentHeader} style={{ backgroundColor: appointment.color }}>
<div>
{appointment.discountId && (
<div className={style.appointmentDiscount}>
{`DESCUENTO (${appointment.discountCode}): ${formatDiscount(
appointment.discountType,
appointment.discountValue
)}`}
</div>
)}
<h1>{formatDateLarge(appointment.appointmentDate)}</h1>
<h2>
{appointment.serviceName} - {appointment.collaboratorName}
</h2>
<div>{formatTimeInterval(appointment.startTime, appointment.endTime)}</div>
{showPaymentInfo && (
<>
<div>
<div>
<b>Total: {formatPrice(appointment.price)}</b>{" "}
{appointment.payment && (
<span className={style.appointmentPayed}>PAGADO</span>
)}
{!appointment.payment && (
<span className={style.appointmentNotPayed}>NO PAGADO</span>
)}
</div>
</div>
{appointment.clientCtaCteBalance > 0 && (
<div>
<div style={{ marginTop: "10px" }}>
<b>Cta Cte: <span className={style.appointmentPayed}>{formatPrice(appointment.clientCtaCteBalance)}</span></b>
</div>
</div>
)}
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,63 @@
.appointmentHeader {
display: flex;
flex-direction: column;
justify-content: center;
font-weight: 500;
padding: 15px;
border-radius: 5px;
text-transform: capitalize;
-webkit-box-shadow: 0px 2px 6px -4px var(--wine-superdark);
-moz-box-shadow: 0px 2px 6px -4px var(--wine-superdark);
box-shadow: 0px 2px 6px -4px var(--wine-superdark);
text-shadow: 1px 1px 1px var(--black);
color: var(--white);
}
.appointmentHeader h1 {
font-size: 17px;
}
.appointmentHeader h2 {
padding-top: 5px;
font-size: 15px;
}
.appointmentHeader div {
padding-top: 5px;
font-size: 14px;
}
.appointmentPayed {
background-color: var(--green-darkestX2);
color: var(--white);
padding: 5px 10px;
border-radius: 5px;
font-size: 14px;
max-height: 35px;
margin-left: 10px;
}
.appointmentNotPayed {
background-color: var(--error);
color: var(--white);
padding: 5px 10px;
border-radius: 5px;
font-size: 14px;
max-height: 35px;
margin-left: 10px;
}
.appointmentDiscount {
background-color: var(--green-darkest);
color: var(--white);
padding: 5px 10px;
border-radius: 5px;
font-size: 14px;
max-height: 35px;
margin-left: 0px;
transform: translate(-2px, 0px);
margin-top: 0px;
margin-bottom: 10px;
max-width: fit-content;
}
@@ -0,0 +1,38 @@
.avatarContainer {
border-radius: 100%;
background-color: white;
overflow: hidden;
}
.avatarContainer img {
width: 100%;
height: 100%;
}
.avatarBorderNone {
border: none;
}
.avatarBorderSolid {
border: solid 8px var(--wine-red);
}
.avatarLarge {
width: 110px;
height: 110px;
}
.avatarMedium {
width: 64px;
height: 64px;
}
.avatarSmall {
width: 48px;
height: 48px;
}
.avatarSuperSmall {
width: 32px;
height: 32px;
}
@@ -0,0 +1,98 @@
import classNames from "classnames";
import style from "./Avatar.module.css";
import ReactAvatar from "react-avatar";
export type AvatarSize = "super-small" | "small" | "medium" | "large";
export type AvatarBorder = "solid" | "none";
export interface AvatarProps {
name?: string;
src: string;
alt: string;
size?: AvatarSize;
border?: AvatarBorder;
style?: React.CSSProperties;
readOnly?: boolean;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
export default function Avatar(props: AvatarProps): React.ReactElement {
const { readOnly = true, size = "large", border = "solid" } = props;
const getClassNames = () => {
if (size === "small") {
return classNames(
style.avatarContainer,
style.avatarSmall,
border === "none" ? style.avatarBorderNone : style.avatarBorderSolid
);
} else if (size === "medium") {
return classNames(
style.avatarContainer,
style.avatarMedium,
border === "none" ? style.avatarBorderNone : style.avatarBorderSolid
);
} else if (size === "large") {
return classNames(
style.avatarContainer,
style.avatarLarge,
border === "none" ? style.avatarBorderNone : style.avatarBorderSolid
);
} else if (size === "super-small") {
return classNames(
style.avatarContainer,
style.avatarSuperSmall,
border === "none" ? style.avatarBorderNone : style.avatarBorderSolid
);
}
return classNames(
style.avatarContainer,
style.avatarLarge,
border === "none" ? style.avatarBorderNone : style.avatarBorderSolid
);
};
const getPixelSize = (size: AvatarSize): string => {
switch (size) {
case "super-small":
return "32";
case "small":
return "48";
case "medium":
return "64";
case "large":
return "96";
default:
return "32";
}
};
const isImageUrl = (url: string): boolean => {
return url.match(/\.(jpeg|jpg|gif|png|svg)$/) != null;
};
return (
<div className={getClassNames()} style={props.style}>
{!readOnly && (
<input
id={props.name}
name={props.name}
accept="image/*;capture=camera"
type="file"
onChange={props.onChange}
style={{ width: "100%", height: "100%", opacity: 0, position: "absolute" }}
/>
)}
{isImageUrl(props.src) && <img src={props.src} alt={props.alt} />}
{!isImageUrl(props.src) && (
<ReactAvatar
name={props.src}
size={getPixelSize(size)}
round={true}
textSizeRatio={2}
maxInitials={2}
/>
)}
</div>
);
}
@@ -0,0 +1,59 @@
"use client";
import classNames from "classnames";
import style from "./button.module.css";
export type ButtonWidth = "100%" | "80%" | "custom";
export type ButtonColor = "primary" | "secondary" | "plain" | "success" | "error" | "link";
interface ButtonProps {
name?: string;
type?: string;
text: string;
style?: React.CSSProperties;
width?: ButtonWidth;
color?: ButtonColor;
onClick?: (e: React.MouseEvent<HTMLInputElement>) => void;
}
export default function Button(props: ButtonProps): React.ReactElement {
const { width = "80%", color = "primary" } = props;
const classList: classNames.ArgumentArray = [style.button];
const getClassName = () => {
if (color === "primary") {
classList.push(style.buttonColorPrimary);
} else if (color === "secondary") {
classList.push(style.buttonColorSecondary);
} else if (color === "plain") {
classList.push(style.buttonColorPlain);
} else if (color === "success") {
classList.push(style.buttonColorSuccess);
} else if (color === "error") {
classList.push(style.buttonColorError);
} else if (color === "link") {
classList.push(style.buttonColorLink);
}
if (width === "100%") {
classList.push(style.buttonWidth100);
} else if (width === "80%") {
classList.push(style.buttonWidth80);
}
return classNames(classList);
};
return (
<>
<input
className={getClassName()}
type="button"
id={props.name}
value={props.text}
onClick={props.onClick}
style={props.style}
/>
</>
);
}
@@ -0,0 +1,58 @@
.button {
border: none;
border-radius: 10px;
padding: 10px;
height: 46px;
font-size: 16px;
font-weight: 700;
transition: filter 0.2s ease;
}
.buttonWidth100 {
width: 100%;
}
.buttonWidth80 {
width: 80%;
}
.buttonColorPrimary {
color: var(--white);
background: var(--wine-darkest);
background: linear-gradient(180deg, var(--wine-darkest) 0%, var(--wine-dark) 100%);
}
.buttonColorSecondary {
color: var(--black);
background: var(--wine-darkest);
background: linear-gradient(180deg, var(--white-darkest) 0%, var(--white-dark) 100%);
}
.buttonColorPlain {
color: var(--black);
}
.buttonColorSuccess {
color: var(--white);
background: var(--green);
background: linear-gradient(180deg, var(--green) 0%, var(--green-darkest) 100%);
}
.buttonColorError {
color: var(--white);
background: var(--red);
background: linear-gradient(180deg, var(--red) 0%, var(--red-darkest) 100%);
}
.buttonColorLink {
text-decoration: none;
font-size: 16px;
font-weight: 700;
color: var(--wine-red);
background: transparent;
}
.button:hover {
filter: brightness(1.3);
cursor: pointer;
}
@@ -0,0 +1,116 @@
import * as React from "react";
import Button from "@mui/material/Button";
import ButtonGroup from "@mui/material/ButtonGroup";
import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown";
import ClickAwayListener from "@mui/material/ClickAwayListener";
import Grow from "@mui/material/Grow";
import Paper from "@mui/material/Paper";
import Popper from "@mui/material/Popper";
import MenuItem from "@mui/material/MenuItem";
import MenuList from "@mui/material/MenuList";
import { ThemeProvider } from "@emotion/react";
import turnosXpressTheme from "@core/app/theme/turnosXpress";
export type ButtonMenuOption = {
text: string;
icon: React.ReactNode;
onClick: () => void;
};
export interface ButtonMenuProps {
options: ButtonMenuOption[];
}
export default function ButtonMenu({ options }: ButtonMenuProps) {
const [open, setOpen] = React.useState(false);
const anchorRef = React.useRef<HTMLDivElement>(null);
const [selectedIndex, setSelectedIndex] = React.useState(1);
const handleClick = () => {
console.info(`You clicked ${options[selectedIndex]}`);
};
const handleMenuItemClick = (
event: React.MouseEvent<HTMLLIElement, MouseEvent>,
index: number
) => {
setSelectedIndex(index);
setOpen(false);
};
const handleToggle = () => {
setOpen((prevOpen) => !prevOpen);
};
const handleClose = (event: Event) => {
if (anchorRef.current && anchorRef.current.contains(event.target as HTMLElement)) {
return;
}
setOpen(false);
};
return (
<React.Fragment>
<ThemeProvider theme={turnosXpressTheme}>
<ButtonGroup
variant="contained"
color="success"
ref={anchorRef}
aria-label="Button group with a nested menu"
>
<Button size="small" onClick={handleClick}>
{options[selectedIndex].icon}
</Button>
<Button
size="small"
aria-controls={open ? "split-button-menu" : undefined}
aria-expanded={open ? "true" : undefined}
aria-label="select merge strategy"
aria-haspopup="menu"
onClick={handleToggle}
>
<ArrowDropDownIcon />
</Button>
</ButtonGroup>
<Popper
sx={{ zIndex: 1 }}
open={open}
anchorEl={anchorRef.current}
role={undefined}
transition
disablePortal
>
{({ TransitionProps, placement }) => (
<Grow
{...TransitionProps}
style={{
transformOrigin:
placement === "bottom" ? "center top" : "center bottom",
}}
>
<Paper>
<ClickAwayListener onClickAway={handleClose}>
<MenuList id="split-button-menu" autoFocusItem>
{options.map((option, index) => (
<MenuItem
key={index}
disabled={index === 2}
selected={index === selectedIndex}
onClick={(event) =>
handleMenuItemClick(event, index)
}
>
{option.text}
</MenuItem>
))}
</MenuList>
</ClickAwayListener>
</Paper>
</Grow>
)}
</Popper>
</ThemeProvider>
</React.Fragment>
);
}
@@ -0,0 +1,73 @@
.buttonOptionIconMode {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
font-weight: 500;
color: var(--black);
background-color: var(--white-dark);
padding: 10px;
border-radius: 10px;
max-width: 150px;
}
.buttonOptionIconMode img {
width: 56px;
height: 56px;
}
.buttonOptionIconMode div {
}
.buttonOption {
text-decoration: none;
cursor: pointer;
border-radius: 10px;
padding: 18px;
transition: all 0.2s ease;
}
.buttonOptionStylePrimary {
border-bottom: solid 3px var(--white-dark);
background-color: var(--white-dark);
}
.buttonOptionStylePlain {
border-bottom: solid 3px var(--white);
background-color: var(--white);
}
.buttonOptionTextLeft {
text-align: left;
padding-left: 50px;
}
.buttonOptionTextRight {
text-align: right;
padding-right: 60px;
}
.buttonOptionIconRight {
background-repeat: no-repeat;
background-position: calc(100% - 20px) center;
}
.buttonOptionIconLeft {
background-repeat: no-repeat;
background-position: 20px center;
}
.buttonOption:hover {
filter: brightness(0.95);
}
.buttonOption:focus {
filter: brightness(0.95);
border-bottom: solid 3px var(--wine-red);
}
.buttonOption:active {
filter: brightness(0.95);
border-bottom: solid 3px var(--wine-red);
}
@@ -0,0 +1,56 @@
"use client";
import classNames from "classnames";
import style from "./ButtonOption.module.css";
export type ButtonOptionTextAlignTypes = "left" | "right";
export type ButtonOptionIconAlignTypes = "left" | "right";
export type ButtonStyleTypes = "primary" | "plain";
export type ButtonOptionMode = "icon" | "text";
interface ButtonOptionProps {
icon?: string;
text: string;
textAlign?: ButtonOptionTextAlignTypes;
iconAlign?: ButtonOptionIconAlignTypes;
buttonStyle?: ButtonStyleTypes;
buttonMode?: ButtonOptionMode;
onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;
}
export default function ButtonOption({
icon,
text,
textAlign = "left",
iconAlign = "right",
buttonStyle = "primary",
buttonMode = "text",
onClick,
}: ButtonOptionProps): React.ReactElement {
const styleClassName = classNames(
style.buttonOption,
buttonStyle === "primary" ? style.buttonOptionStylePrimary : style.buttonOptionStylePlain,
textAlign === "left" ? style.buttonOptionTextLeft : style.buttonOptionTextRight,
iconAlign === "left" ? style.buttonOptionIconLeft : style.buttonOptionIconRight
);
return (
<>
{buttonMode === "text" && (
<a
className={styleClassName}
type="button"
onClick={onClick}
style={{ backgroundImage: `url(${icon})`, color: "var(--black)" }}
>
{text}
</a>
)}
{buttonMode === "icon" && (
<a className={style.buttonOptionIconMode}>
<img src={icon} alt={text} />
<div>{text}</div>
</a>
)}
</>
);
}
@@ -0,0 +1,81 @@
"use client";
import style from "./style.module.css";
import { useEffect, useState } from "react";
import { ButtonGroup, IconButton } from "@mui/material";
import GridViewOutlinedIcon from "@mui/icons-material/GridViewOutlined";
import SplitscreenOutlinedIcon from "@mui/icons-material/SplitscreenOutlined";
import ButtonPanelItem from "./ButtonPanelItem";
import { ButtonPanelMode, useButtonPanelStore } from "@core/Store/ButtonPanel.Store";
export interface ButtonPanelItem {
icon: React.ReactNode;
text: string;
onClick: () => void;
}
export interface ButtonPanelProps {
items: ButtonPanelItem[];
title?: string;
}
export default function ButtonPanel(props: ButtonPanelProps): React.ReactElement {
const panelMode = useButtonPanelStore();
const [mode, setMode] = useState<ButtonPanelMode>("button");
const getClassesNames = () => {
if (mode === "icon") {
return style.buttonPanelIcon;
}
return style.buttonPanelButton;
};
useEffect(() => {
setMode(panelMode.mode);
}, [panelMode.mode]);
return (
<>
<div
style={{
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
}}
>
{props.title && <h3>{props.title}</h3>}
<ButtonGroup
variant="outlined"
aria-label="Basic button group"
sx={{
marginRight: "0px",
marginLeft: "auto",
}}
>
<IconButton onClick={() => panelMode.setMode("icon")}>
<GridViewOutlinedIcon
sx={{ color: mode === "icon" ? "var(--wine-red)" : "ineherit" }}
/>
</IconButton>
<IconButton onClick={() => panelMode.setMode("button")}>
<SplitscreenOutlinedIcon
sx={{ color: mode === "button" ? "var(--wine-red)" : "ineherit" }}
/>
</IconButton>
</ButtonGroup>
</div>
<div className={getClassesNames()}>
{props.items.map((item, index) => (
<ButtonPanelItem
key={index}
icon={item.icon}
text={item.text}
onClick={item.onClick}
mode={mode}
/>
))}
</div>
</>
);
}
@@ -0,0 +1,27 @@
import { ButtonPanelMode } from "@core/Store/ButtonPanel.Store";
import style from "./style.module.css";
export interface ButtonPanelItemProps {
icon: React.ReactNode;
text: string;
onClick: () => void;
mode?: ButtonPanelMode;
}
export default function ButtonPanelItem(props: ButtonPanelItemProps): React.ReactElement {
const getClassName = () => {
if (props.mode === "icon") {
return style.buttonPanelIconItem;
}
return style.buttonPanelButtonItem;
};
return (
<>
<a className={getClassName()} onClick={props.onClick}>
{props.icon}
<div>{props.text}</div>
</a>
</>
);
}
@@ -0,0 +1,96 @@
.buttonPanelIcon {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 0px;
flex-wrap: wrap;
width: 100%;
}
.buttonPanelIconItem {
margin: 5px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
width: 96px;
height: 96px;
border-radius: 6px !important;
color: var(--wine-dark);
cursor: pointer;
transition: all 0.2s ease;
}
.buttonPanelIconItem svg {
font-size: 32px;
}
.buttonPanelIconItem:hover {
filter: brightness(0.95);
background-color: var(--white-dark);
}
.buttonPanelIconItem:focus {
filter: brightness(0.95);
border-bottom: solid 3px var(--wine-red);
}
.buttonPanelIconItem:active {
filter: brightness(0.95);
border-bottom: solid 3px var(--wine-red);
}
.buttonPanelIconItem div {
color: var(--black);
font-weight: 700;
font-size: 15px;
text-align: center;
}
.buttonPanelButton {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
flex-wrap: nowrap;
}
.buttonPanelButtonItem {
display: flex;
margin-bottom: 12px;
flex-direction: row-reverse;
align-items: center;
justify-content: space-between;
color: var(--wine-dark);
width: 100%;
padding-left: 50px;
padding-right: 20px;
height: 60px;
text-decoration: none;
cursor: pointer;
border-radius: 10px;
transition: all 0.2s ease;
border-bottom: solid 3px var(--white-dark);
background-color: var(--white-dark);
}
.buttonPanelButtonItem:hover {
filter: brightness(0.95);
}
.buttonPanelButtonItem:focus {
filter: brightness(0.95);
border-bottom: solid 3px var(--wine-red);
}
.buttonPanelButtonItem:active {
filter: brightness(0.95);
border-bottom: solid 3px var(--wine-red);
}
.buttonPanelButtonItem div {
color: var(--black);
font-weight: 700;
font-size: 16px;
}
@@ -0,0 +1,25 @@
"use client";
import style from "./button-session.module.css";
interface ButtonSessionProps {
name: string;
type: string;
text: string;
style?: React.CSSProperties;
onClick?: (e: React.MouseEvent<HTMLInputElement>) => void;
}
export default function ButtonSession(props: ButtonSessionProps): React.ReactElement {
return (
<>
<input
className={style.buttonSession}
type="button"
id={props.name}
value={props.text}
style={props.style}
onClick={props.onClick}
/>
</>
);
}
@@ -0,0 +1,23 @@
.buttonSession {
border: none;
border-radius: 10px;
padding: 10px;
height: 46px;
width: 80%;
font-size: 14px;
font-weight: 700;
color: var(--black);
/* Combina imagen de fondo y gradiente en la misma propiedad */
background-image: url("/google-icon64.svg"),
linear-gradient(180deg, var(--white-dark) 0%, var(--white-dark) 100%);
background-repeat: no-repeat;
background-position: 20px center, 0 0;
background-size: 28px 28px, cover;
transition: filter 0.2s ease;
}
.buttonSession:hover {
filter: brightness(0.9);
cursor: pointer;
}
@@ -0,0 +1,217 @@
import { timeFormatter, timeRangeFormatter, TXCalendarEvent } from "@core/app/theme/scheduleView";
import { useEffect, useState, useRef } from "react";
import { DaySchedule } from "react-schedule-view";
import style from "../../theme/scheduleView.module.css";
import Avatar from "@components/Avatar/Avatar";
export interface CalendarRowViewProps {
daySchedules: DaySchedule<TXCalendarEvent>[];
handleEventClick: (event: TXCalendarEvent) => void;
onEventLongPress?: (event: TXCalendarEvent) => void;
}
type CalendarRowViewData = {
name: string;
events: TXCalendarEvent[];
};
// Custom component to manage long press state per item
function EventItem({
item,
onClick,
onLongPress,
}: {
item: TXCalendarEvent;
onClick: () => void;
onLongPress?: () => void;
}) {
const timerRef = useRef<NodeJS.Timeout | null>(null);
const isLongPress = useRef(false);
const isScrolling = useRef(false);
const startPos = useRef({ x: 0, y: 0 });
const [pressed, setPressed] = useState(false);
const getAvatar = (data: string | undefined) => {
let imageAvatar = "";
if (data) {
imageAvatar = data;
}
return imageAvatar;
};
const startPress = (e: React.TouchEvent<HTMLDivElement> | React.MouseEvent<HTMLDivElement>) => {
isLongPress.current = false;
isScrolling.current = false;
if ('touches' in e) {
startPos.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
}
setPressed(true);
timerRef.current = setTimeout(() => {
isLongPress.current = true;
setPressed(false);
if (window.navigator && window.navigator.vibrate) {
window.navigator.vibrate(50); // Haptic feedback
}
if (onLongPress) {
onLongPress();
}
}, 500);
};
const handleTouchMove = (e: React.TouchEvent<HTMLDivElement>) => {
if (!isScrolling.current) {
const moveX = e.touches[0].clientX;
const moveY = e.touches[0].clientY;
if (
Math.abs(moveX - startPos.current.x) > 10 ||
Math.abs(moveY - startPos.current.y) > 10
) {
isScrolling.current = true;
cancelPress();
}
}
};
const cancelPress = () => {
setPressed(false);
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
};
const handleClick = () => {
cancelPress();
if (!isLongPress.current && !isScrolling.current) {
onClick();
}
};
return (
<div
style={{
transform: pressed ? "scale(0.97)" : "scale(1)",
transition: "transform 100ms ease",
cursor: "pointer",
backgroundColor: item.color,
padding: "10px",
borderRadius: "5px",
color: "var(--white)",
fontWeight: "bold",
marginLeft: "10px",
marginRight: "10px",
marginBottom: "5px",
opacity: item.appointmentId ? 1 : 0.6,
userSelect: "none",
WebkitUserSelect: "none",
}}
onTouchStart={startPress}
onTouchEnd={handleClick}
onTouchMove={handleTouchMove}
onTouchCancel={cancelPress}
onMouseDown={startPress}
onMouseUp={handleClick}
onMouseLeave={cancelPress}
onContextMenu={(e) => e.preventDefault()} // Prevent context menu on long press
>
<div>
<div className={style.turnosXpressTitleContent}>
<Avatar
src={getAvatar(item.avatar)}
alt={item.title}
size="super-small"
border="none"
style={{ minWidth: "32px" }}
/>
<h1>{item.title}</h1>
</div>
<div className={style.turnosXpressTimeContent}>
{timeRangeFormatter(item.startTime, item.endTime)}
{item.payment && (
<span style={{ marginLeft: "5px", marginTop: "5px" }}>💰</span>
)}
</div>
</div>
</div>
);
}
export default function CalendarRowView(props: CalendarRowViewProps) {
const {
daySchedules = [
{
name: "",
events: [],
},
],
handleEventClick = () => {},
onEventLongPress,
} = props;
const [data, setData] = useState<CalendarRowViewData[]>([]);
const getTimeHeader = (event: TXCalendarEvent): string => {
return timeFormatter(event.startTime);
};
useEffect(() => {
const auxData: CalendarRowViewData[] = [];
for (const item of daySchedules[0].events) {
const time = getTimeHeader(item);
const check = auxData.find((x) => x.name === time);
if (check) {
check.events.push(item);
} else {
auxData.push({
name: time,
events: [item],
});
}
}
setData(auxData.sort((a, b) => a.name.localeCompare(b.name)));
}, [daySchedules]);
return (
<>
{data.map((event: CalendarRowViewData) => {
return (
<div
key={event.name}
style={{
display: "flex",
flexDirection: "row",
width: "100%",
marginLeft: "5px",
}}
>
<div
style={{
fontWeight: "bold",
color: "var(--gray-black)",
width: "80px",
}}
>
{event.name}
</div>
<div style={{ width: "100%" }}>
{event.events.map((item: TXCalendarEvent) => (
<EventItem
key={item.appointmentId || item.repeatId}
item={item}
onClick={() => handleEventClick(item)}
onLongPress={() => {
if (onEventLongPress && item.appointmentId) {
onEventLongPress(item);
}
}}
/>
))}
</div>
</div>
);
})}
</>
);
}
@@ -0,0 +1,10 @@
.centeredContentDefault {
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: none;
position: absolute;
top: 15px;
left: 40px;
right: 40px;
bottom: 0px;
}
@@ -0,0 +1,12 @@
import style from "./CenteredContent.module.css";
export interface CenteredContentProps {
children: React.ReactNode;
}
export default function CenteredContent(props: CenteredContentProps) {
return (
<>
<div className={style.centeredContentDefault}>{props.children}</div>
</>
);
}
@@ -0,0 +1,286 @@
"use client";
import { useEffect, useState } from "react";
import API from "@services/Api.Service";
import { useSessionStore } from "@core/Store/Sesion.Store";
import { useAlert } from "@store/Alert.Store";
export default function CheckPendingInvitations() {
const SessionInfo = useSessionStore();
const alert = useAlert();
const [isChecking, setIsChecking] = useState(false);
const [pendingOrg, setPendingOrg] = useState<any>(null);
const [isProcessing, setIsProcessing] = useState(false);
const checkInvitations = async () => {
if (!SessionInfo.userId || isChecking) return;
setIsChecking(true);
try {
const response = await API.post<any[]>("companies/get-by-user", { sessionUser: SessionInfo.userId });
const orgs = response || [];
// Buscar la primera organización donde la invitación esté pendiente de aceptación por parte del usuario
const foundOrg = orgs.find(org => org.guestOk === false);
if (foundOrg) {
setPendingOrg(foundOrg);
} else {
setPendingOrg(null);
}
} catch (error) {
console.error("Error comprobando invitaciones:", error);
} finally {
setIsChecking(false);
}
};
useEffect(() => {
if (SessionInfo.userId) {
checkInvitations();
}
}, [SessionInfo.userId]);
const handleAccept = async () => {
if (!pendingOrg || isProcessing) return;
setIsProcessing(true);
try {
await API.post("employees/validate-guest", {
employeeId: pendingOrg.employeeId,
sessionUser: SessionInfo.userId
});
alert.showSuccess("¡Invitación aceptada exitosamente!");
setPendingOrg(null);
setTimeout(() => checkInvitations(), 1000);
} catch (e: any) {
alert.showError(e.message || "Ocurrió un error al aceptar");
} finally {
setIsProcessing(false);
}
};
const handleReject = async () => {
if (!pendingOrg || isProcessing) return;
setIsProcessing(true);
try {
await API.post("employees/reject-guest", {
employeeId: pendingOrg.employeeId,
sessionUser: SessionInfo.userId
});
alert.showInfo("Invitación rechazada.");
setPendingOrg(null);
setTimeout(() => checkInvitations(), 1000);
} catch (e: any) {
alert.showError(e.message || "Ocurrió un error al rechazar");
} finally {
setIsProcessing(false);
}
};
const handleLater = () => {
setPendingOrg(null);
};
if (!pendingOrg) return <></>;
return (
<>
<style>{`
@keyframes slideUpFade {
from { opacity: 0; transform: translate(-50%, 20px) scale(0.95); }
to { opacity: 1; transform: translate(-50%, 0) scale(1); }
}
@keyframes floatParticle {
0% { transform: translateY(0) scale(1); opacity: 0; }
50% { opacity: 0.8; transform: translateY(-20px) scale(1.2); }
100% { transform: translateY(-40px) scale(0.8); opacity: 0; }
}
`}</style>
{/* Overlay */}
<div style={{
position: 'fixed',
top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: 'rgba(255, 255, 255, 0.4)',
backdropFilter: 'blur(8px)',
zIndex: 10000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
{/* Modal Container */}
<div style={{
position: 'absolute',
top: '30%',
left: '50%',
transform: 'translate(-50%, 0)',
width: '90%',
maxWidth: '420px',
backgroundColor: '#ffffff',
borderRadius: '24px',
boxShadow: '0 20px 40px rgba(0,0,0,0.1), 0 0 0 1px rgba(0,0,0,0.05), 0 0 40px rgba(255, 65, 108, 0.1)',
padding: '32px 24px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
animation: 'slideUpFade 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards',
overflow: 'hidden'
}}>
{/* Decorative Background Elements */}
<div style={{ position: 'absolute', top: '-50px', right: '-50px', width: '150px', height: '150px', background: 'radial-gradient(circle, var(--wine-lighterX2) 0%, transparent 70%)', borderRadius: '50%' }} />
<div style={{ position: 'absolute', bottom: '-50px', left: '-50px', width: '150px', height: '150px', background: 'radial-gradient(circle, var(--wine-lighterX2) 0%, transparent 70%)', borderRadius: '50%' }} />
{/* Particles */}
<div style={{ position: 'absolute', top: '20px', left: '20%', width: '8px', height: '8px', borderRadius: '50%', background: 'var(--wine-light)', animation: 'floatParticle 3s infinite ease-in-out' }} />
<div style={{ position: 'absolute', top: '40px', right: '20%', width: '6px', height: '6px', borderRadius: '50%', background: 'var(--wine-dark)', animation: 'floatParticle 4s infinite ease-in-out 1s' }} />
<div style={{ position: 'absolute', top: '60px', left: '80%', width: '10px', height: '10px', borderRadius: '50%', background: 'var(--wine-lighter)', animation: 'floatParticle 3.5s infinite ease-in-out 0.5s' }} />
{/* Icon */}
<div style={{
width: '64px',
height: '64px',
borderRadius: '16px',
background: 'linear-gradient(135deg, var(--wine-dark) 0%, var(--wine-light) 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '20px',
boxShadow: '0 8px 16px var(--wine-lighterX2)',
position: 'relative',
zIndex: 1
}}>
<span style={{ fontSize: '32px', color: '#fff' }}></span>
</div>
<h2 style={{
fontSize: '24px',
fontWeight: '700',
color: '#1a1a1a',
textAlign: 'center',
marginBottom: '12px',
position: 'relative',
zIndex: 1
}}>
¡Tienes una nueva invitación!
</h2>
<p style={{
fontSize: '15px',
color: '#666',
textAlign: 'center',
marginBottom: '24px',
lineHeight: '1.5',
position: 'relative',
zIndex: 1
}}>
Has sido invitado a formar parte del equipo de colaboradores en:
</p>
{/* Company Card */}
<div style={{
width: '100%',
padding: '16px',
backgroundColor: '#f8f9fa',
borderRadius: '12px',
border: '1px solid #eaeaea',
display: 'flex',
alignItems: 'center',
marginBottom: '32px',
position: 'relative',
zIndex: 1
}}>
<div style={{
width: '40px',
height: '40px',
borderRadius: '50%',
backgroundColor: '#e2e8f0',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginRight: '16px',
color: '#64748b',
fontWeight: 'bold',
fontSize: '16px'
}}>
{pendingOrg.name.charAt(0).toUpperCase()}
</div>
<div>
<div style={{ fontWeight: '600', color: '#1e293b', fontSize: '16px' }}>{pendingOrg.name}</div>
<div style={{ fontSize: '13px', color: '#64748b' }}>Nueva Oportunidad</div>
</div>
</div>
{/* Buttons */}
<div style={{ display: 'flex', flexDirection: 'column', width: '100%', gap: '12px', position: 'relative', zIndex: 1 }}>
<button
onClick={handleAccept}
disabled={isProcessing}
style={{
width: '100%',
padding: '14px',
borderRadius: '12px',
background: 'linear-gradient(135deg, var(--wine-dark) 0%, var(--wine-light) 100%)',
color: '#fff',
border: 'none',
fontWeight: '600',
fontSize: '16px',
cursor: isProcessing ? 'not-allowed' : 'pointer',
boxShadow: '0 4px 12px var(--wine-lighterX2)',
transition: 'transform 0.2s, box-shadow 0.2s',
opacity: isProcessing ? 0.8 : 1
}}
onMouseOver={e => e.currentTarget.style.transform = 'translateY(-2px)'}
onMouseOut={e => e.currentTarget.style.transform = 'translateY(0)'}
>
{isProcessing ? 'Procesando...' : 'Aceptar Invitación'}
</button>
<div style={{ display: 'flex', gap: '12px' }}>
<button
onClick={handleReject}
disabled={isProcessing}
style={{
flex: 1,
padding: '12px',
borderRadius: '12px',
backgroundColor: 'transparent',
color: '#64748b',
border: '1px solid #cbd5e1',
fontWeight: '500',
fontSize: '14px',
cursor: isProcessing ? 'not-allowed' : 'pointer',
transition: 'all 0.2s'
}}
onMouseOver={e => { e.currentTarget.style.color = 'var(--wine-dark)'; e.currentTarget.style.borderColor = 'var(--wine-dark)'; }}
onMouseOut={e => { e.currentTarget.style.color = '#64748b'; e.currentTarget.style.borderColor = '#cbd5e1'; }}
>
Rechazar
</button>
<button
onClick={handleLater}
disabled={isProcessing}
style={{
flex: 1,
padding: '12px',
borderRadius: '12px',
backgroundColor: '#f1f5f9',
color: '#475569',
border: 'none',
fontWeight: '500',
fontSize: '14px',
cursor: isProcessing ? 'not-allowed' : 'pointer',
transition: 'background-color 0.2s'
}}
onMouseOver={e => e.currentTarget.style.backgroundColor = '#e2e8f0'}
onMouseOut={e => e.currentTarget.style.backgroundColor = '#f1f5f9'}
>
Más tarde
</button>
</div>
</div>
</div>
</div>
</>
);
}
@@ -0,0 +1,36 @@
"use client";
import { useNavigation } from "@core/app/hooks/goto";
import { checkUserPhone } from "@core/Services/Session.Login";
import { useConfirmStore } from "@core/Store/Confirm.Store";
import { useSessionStore } from "@core/Store/Sesion.Store";
import { useEffect } from "react";
export default function CheckUserPhone() {
const confirm = useConfirmStore();
const SessionInfo = useSessionStore();
const { goTo } = useNavigation();
const checkIfPhoneIsSaved = () => {
if (!SessionInfo.userId) return;
checkUserPhone({
sessionUser: SessionInfo.userId,
}).then((response) => {
if (!response) {
confirm.show(
"Por favor, completa tu número de teléfono para que podamos informarte sobre cualquier novedad relacionada con tus reservas.",
() => {
goTo("/user/profile/edit");
},
() => {}
);
}
});
};
useEffect(() => {
checkIfPhoneIsSaved();
}, [SessionInfo.userId]);
return <></>;
}
@@ -0,0 +1,64 @@
.collaboratorItemContainer {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
background-color: var(--white-dark);
padding: 10px;
border-radius: 5px;
border-bottom: solid 3px var(--white-dark);
cursor: pointer;
}
.collaboratorItemContainer:hover {
border-bottom: solid 3px var(--wine-red);
}
.collaboratorItemContainer:active {
filter: brightness(0.95);
border-bottom: solid 3px var(--wine-red);
}
.collaboratorItemContainer img {
width: 64px;
width: 64px;
}
.collaboratorInfo {
padding-left: 10px;
}
.collaboratorItemContainer h1 {
font-size: 16px;
}
.collaboratorItemContainer h4 {
font-size: 12px;
font-weight: 300;
padding-bottom: 4px;
}
.collaboratorItemContainer ul {
list-style-type: none;
padding-left: 0;
display: flex;
}
.collaboratorItemContainer ul li {
margin-right: 15px;
font-size: 13px;
background-color: var(--white-dark);
padding-top: 2px;
padding-bottom: 2px;
padding-left: 5px;
padding-right: 5px;
border-radius: 4px;
filter: brightness(0.9);
}
.collaboratorPending {
background-color: var(--wine-red) !important;
color: var(--white) !important;
filter: none;
}
@@ -0,0 +1,51 @@
import { CompanyEmployeesView } from "@core/Models/Collaborators.model";
import style from "./CollaboratorItem.module.css";
import Avatar from "@components/Avatar/Avatar";
interface CollaboratorItemProps {
style?: React.CSSProperties;
data: CompanyEmployeesView;
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
}
export default function CollaboratorItem(props: CollaboratorItemProps): React.ReactElement {
const getRolName = (rol: string) => {
if (rol === "admin") {
return "Admin";
} else if (rol === "owner") {
return "Dueño";
} else if (rol === "employee") {
return "Colaborador";
}
return "Colaborador";
};
return (
<>
<div
className={style.collaboratorItemContainer}
style={props.style}
onClick={props.onClick}
>
<Avatar
name={props.data.fullName}
size="medium"
border="none"
src={props.data.avatar}
alt={props.data.fullName}
/>
<div className={style.collaboratorInfo}>
<h1>{props.data.fullName}</h1>
<h4>{props.data.email}</h4>
<ul>
{props.data.roles.map((rol) => (
<li key={"rol." + rol}>{getRolName(rol)}</li>
))}
{!props.data.fullOk && (
<li className={style.collaboratorPending}>Pendiente</li>
)}
</ul>
</div>
</div>
</>
);
}
@@ -0,0 +1,32 @@
.picker {
position: relative;
}
.swatch {
width: 60px;
height: 48px;
border-radius: 8px;
border: 3px solid #fff;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1), inset 0 0 0 1px rgba(0, 0, 0, 0.1);
cursor: pointer;
}
.popover {
position: absolute;
top: calc(100% + 2px);
left: 0;
border-radius: 9px;
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
z-index: 10000;
}
.pickerLabel {
color: var(--black);
font-size: 16px;
font-weight: 700;
padding-top: 10px;
padding-left: 5px;
line-height: 40px;
margin-right: 8px;
text-align: left;
}
@@ -0,0 +1,73 @@
import React, { useCallback, useRef, useState, useEffect } from "react";
import { HexColorPicker } from "react-colorful";
import style from "./ColorPicker.module.css";
interface useClickOutsideProps {
ref: React.RefObject<HTMLElement>;
handler: (e: MouseEvent) => void;
}
const useClickOutside = ({ ref, handler }: useClickOutsideProps) => {
useEffect(() => {
let startedInside = false;
let startedWhenMounted = false;
const listener = (event: MouseEvent) => {
// Do nothing if `mousedown` or `touchstart` started inside ref element
if (startedInside || !startedWhenMounted) return;
// Do nothing if clicking ref's element or descendent elements
if (!ref.current || ref.current.contains(event.target as Node)) return;
handler(event);
};
const validateEventStart = (event: MouseEvent | TouchEvent) => {
startedWhenMounted = !!ref.current;
startedInside = !!ref.current?.contains(event.target as Node);
};
document.addEventListener("mousedown", validateEventStart);
document.addEventListener("touchstart", validateEventStart);
document.addEventListener("click", listener);
return () => {
document.removeEventListener("mousedown", validateEventStart);
document.removeEventListener("touchstart", validateEventStart);
document.removeEventListener("click", listener);
};
}, [ref, handler]);
};
export const PopoverPicker = ({
color,
onChange,
placeholder,
}: {
color: string;
onChange: (color: string) => void;
placeholder?: string;
}) => {
const popover = useRef<HTMLDivElement>(null);
const [isOpen, toggle] = useState(false);
const close = useCallback(() => toggle(false), []);
useClickOutside({ ref: popover, handler: close });
return (
<>
{placeholder && <div className={style.pickerLabel}>{placeholder}</div>}
<div className={style.picker}>
<div
className={style.swatch}
style={{ backgroundColor: color }}
onClick={() => toggle(true)}
/>
{isOpen && (
<div className={style.popover} ref={popover}>
<HexColorPicker color={color} onChange={onChange} />
</div>
)}
</div>
</>
);
};
@@ -0,0 +1,58 @@
"use client";
import turnosXpressTheme from "@core/app/theme/turnosXpress";
import { ThemeProvider } from "@emotion/react";
import Button from "@mui/material/Button";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from "@mui/material/DialogTitle";
import { useConfirmStore } from "@store/Confirm.Store";
export default function ConfirmDialog() {
const confirm = useConfirmStore();
const handleCancell = () => {
confirm.close();
if (confirm.onCancel) {
confirm.onCancel();
}
};
const handleAgree = () => {
confirm.close();
if (confirm.onConfirm) {
confirm.onConfirm();
}
};
const handleClose = () => {
confirm.close();
};
return (
<>
<ThemeProvider theme={turnosXpressTheme}>
<Dialog
open={confirm.visible}
onClose={handleClose}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
>
<DialogTitle id="alert-dialog-title">{confirm.title}</DialogTitle>
<DialogContent>
<DialogContentText id="alert-dialog-description">
{confirm.message}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleCancell}>Cancelar</Button>
<Button onClick={handleAgree} autoFocus color="success" variant="contained">
Aceptar
</Button>
</DialogActions>
</Dialog>
</ThemeProvider>
</>
);
}
@@ -0,0 +1,14 @@
import React from "react";
import style from "./content.module.css";
interface ContentProps {
children: React.ReactNode;
}
export default function Content(props: ContentProps): React.ReactElement {
return (
<>
<div className={style.content}>{props.children}</div>
</>
);
}
@@ -0,0 +1,18 @@
.content {
position: fixed;
overflow-y: auto;
scrollbar-width: none;
overflow-x: hidden;
left: 0px;
top: 60px;
right: 0px;
bottom: 60px;
width: auto;
margin: 0px;
padding: 0px;
background: var(--wine-darkest);
}
.content::-webkit-scrollbar {
display: none;
}
@@ -0,0 +1,65 @@
.dataGrid {
position: relative;
width: 100%;
height: 100%;
border: solid 1px var(--gray-light);
border-radius: 7px;
overflow: hidden;
}
.dataGridHeader {
position: absolute;
left: 0px;
right: 0px;
top: 0px;
height: 40px;
border-bottom: solid 1px var(--gray-light);
display: flex;
flex-direction: row;
align-items: center;
}
.dataGridHeaderItem {
padding-left: 15px;
color: var(--black);
font-weight: 700;
font-size: 14px;
border-right: solid 1px var(--white-dark);
}
.dataGridContent {
position: absolute;
left: 0px;
right: 0px;
top: 40px;
bottom: 40px;
overflow-x: hidden;
overflow-y: auto;
}
.dataGridRow {
display: flex;
flex-direction: row;
align-items: flex-start;
cursor: pointer;
}
.dataGridRowItem {
padding-left: 0px;
display: flex;
flex-direction: column;
justify-content: center;
cursor: pointer;
overflow: hidden;
font-weight: 500;
font-size: 14px;
}
.dataGridFooter {
position: absolute;
left: 0px;
right: 0px;
bottom: 0px;
height: 40px;
border-top: solid 1px var(--gray-light);
}
@@ -0,0 +1,112 @@
import PaginationControls from "../PaginationControls/PaginationControls";
import style from "./DataGrid.module.css";
export type DataGridRow = {
id: string;
};
export type DataGridColumn<CustomDataGridRow extends DataGridRow = DataGridRow> = {
title: string;
field: string;
width: number | null;
render: (value: CustomDataGridRow) => React.ReactNode;
};
export interface DataGridProps<CustomDataGridRow extends DataGridRow = DataGridRow> {
columns: DataGridColumn<CustomDataGridRow>[];
data: CustomDataGridRow[];
footer?: boolean;
header?: boolean;
headerHeight?: number;
pagination?: boolean;
pageSize?: number;
pages?: number;
page?: number;
handleRefresh?: (page: number, pageSize: number) => void;
handleRowClick?: (row: CustomDataGridRow) => void;
}
export default function DataGrid<CustomDataGridRow extends DataGridRow = DataGridRow>(
props: DataGridProps<CustomDataGridRow>
) {
const {
columns,
data,
footer = true,
header = true,
pagination = false,
pageSize = 10,
pages = 1,
page = 1,
handleRefresh = () => {},
} = props;
const getColumnWidth = (column: DataGridColumn<CustomDataGridRow>): React.CSSProperties => {
let returnStyle: React.CSSProperties = {};
if (column.width) {
returnStyle = {
width: column.width + "px",
minWidth: column.width + "px",
};
} else {
returnStyle = {
width: "100%",
};
}
return returnStyle;
};
return (
<div className={style.dataGrid}>
{header && (
<div className={style.dataGridHeader}>
{columns.map((column) => (
<div
key={column.field}
style={getColumnWidth(column)}
className={style.dataGridHeaderItem}
>
{column.title ? column.title : "\u00A0"}
</div>
))}
</div>
)}
<div className={style.dataGridContent}>
{data.map((row, index) => (
<div
key={index}
className={style.dataGridRow}
onClick={() => {
if (!props.handleRowClick) {
return;
}
props.handleRowClick(row);
}}
>
{columns.map((column) => (
<span
key={column.field}
className={style.dataGridRowItem}
style={getColumnWidth(column)}
>
{column.render(row)}
</span>
))}
</div>
))}
</div>
{footer && (
<div className={style.dataGridFooter}>
{pagination && (
<PaginationControls
onChange={handleRefresh}
current={page}
size={pageSize}
max={pages}
/>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,33 @@
"use client";
import turnosXpressTheme from "@core/app/theme/turnosXpress";
import { ThemeProvider } from "@emotion/react";
import Dialog from "@mui/material/Dialog";
import React from "react";
export interface DialogWindowParams {
children: React.ReactNode;
visible: boolean;
setVisible: (state: boolean) => void;
}
export default function DialogWindow(props: DialogWindowParams) {
const { visible, setVisible, children } = props;
const handleClose = () => {
setVisible(false);
};
return (
<>
<ThemeProvider theme={turnosXpressTheme}>
<Dialog
open={visible}
onClose={handleClose}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
>
{children}
</Dialog>
</ThemeProvider>
</>
);
}
@@ -0,0 +1,111 @@
.bannerContainer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.5rem 2rem;
background: linear-gradient(135deg, var(--white) 0%, var(--white-dark) 100%);
border-radius: 20px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
border: 1px solid var(--white-darkest);
margin: 2rem auto;
width: 100%;
max-width: 1000px;
gap: 1.5rem;
}
.popupContainer {
position: fixed;
bottom: 2rem;
right: 2rem;
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 1.5rem;
background: var(--white);
border-radius: 20px;
box-shadow: 0 10px 40px rgba(51, 0, 128, 0.15);
border: 1px solid var(--wine-lighterX2);
width: 100%;
max-width: 350px;
z-index: 1000;
gap: 1rem;
}
.closeButton {
position: absolute;
top: 10px;
right: 10px;
background: none;
border: none;
color: var(--gray-dark);
cursor: pointer;
padding: 5px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background 0.2s;
}
.closeButton:hover {
background: var(--white-dark);
color: var(--black);
}
.contentWrapper {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.title {
font-size: 1.1rem;
font-weight: 700;
color: var(--wine-superdark);
margin: 0;
}
.description {
font-size: 0.95rem;
color: var(--gray-dark);
margin: 0;
line-height: 1.4;
}
.ctaButton {
display: inline-block;
padding: 10px 24px;
background: linear-gradient(135deg, var(--wine-red) 0%, var(--wine-dark) 100%);
color: var(--white);
border-radius: 12px;
font-weight: 700;
text-decoration: none;
font-size: 0.95rem;
transition: transform 0.2s ease, box-shadow 0.2s ease;
box-shadow: 0 4px 15px rgba(255, 42, 127, 0.3);
text-align: center;
white-space: nowrap;
}
.ctaButton:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(255, 42, 127, 0.4);
color: var(--white);
text-decoration: none;
}
/* Responsive adjustments for banner */
@media (max-width: 768px) {
.bannerContainer {
flex-direction: column;
text-align: center;
padding: 1.5rem;
}
.popupContainer {
bottom: 1rem;
right: 1rem;
left: 1rem;
max-width: calc(100% - 2rem);
}
}
@@ -0,0 +1,79 @@
"use client";
import React, { useState, useEffect } from "react";
import Link from "next/link";
import { motion, AnimatePresence } from "framer-motion";
import style from "./EvangelizeBanner.module.css";
import { useSessionStore } from "@core/Store/Sesion.Store";
interface EvangelizeBannerProps {
variant?: "banner" | "popup";
delay?: number; // Delay in milliseconds for popup
}
export default function EvangelizeBanner({ variant = "banner", delay = 3000 }: EvangelizeBannerProps) {
const SessionInfo = useSessionStore();
const [isVisible, setIsVisible] = useState(variant === "banner");
useEffect(() => {
if (variant === "popup") {
const timer = setTimeout(() => {
setIsVisible(true);
}, delay);
return () => clearTimeout(timer);
}
}, [variant, delay]);
// If the user has any organization subscriptions (even the free plan), do not show the banner
const hasPlan = Object.keys(SessionInfo.organizationSubscriptions || {}).length > 0;
if (hasPlan) return null;
const handleClose = () => {
setIsVisible(false);
};
const content = (
<>
<div className={style.contentWrapper}>
<h3 className={style.title}>¿Eres profesional o tienes un negocio?</h3>
<p className={style.description}>
Simplificá tu agenda y conseguí más clientes. también puedes ofrecer tus propios turnos a través de TurnosXpress.
</p>
</div>
<Link href="/landing/offer-appointments" className={style.ctaButton}>
Ver cómo funciona
</Link>
</>
);
if (variant === "banner") {
return (
<div className={style.bannerContainer}>
{content}
</div>
);
}
// Popup variant with framer-motion
return (
<AnimatePresence>
{isVisible && (
<motion.div
className={style.popupContainer}
initial={{ opacity: 0, y: 50, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 50, scale: 0.95 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
>
<button onClick={handleClose} className={style.closeButton} aria-label="Cerrar">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
{content}
</motion.div>
)}
</AnimatePresence>
);
}
@@ -0,0 +1,76 @@
"use client";
import { useNavigation } from "@core/app/hooks/goto";
import Button from "@core/app/components/Button/Button";
import { useEffect } from "react";
import SearchIcon from '@mui/icons-material/Search';
export interface ExploreCategoriesProps {
containerHeight?: string;
message?: string;
}
export default function ExploreCategories({ containerHeight, message }: ExploreCategoriesProps) {
const { goTo } = useNavigation();
return (
<div
style={{
minHeight: containerHeight || "60vh",
display: "flex",
justifyContent: "center",
alignItems: "center",
flexDirection: "column",
padding: "60px 20px",
textAlign: "center",
animation: "fadeIn 0.5s ease-out forwards",
}}
>
<div
style={{
width: "120px",
height: "120px",
borderRadius: "50%",
background: "rgba(225, 29, 72, 0.1)", /* Soft wine-red / coral */
display: "flex",
justifyContent: "center",
alignItems: "center",
marginBottom: "30px",
boxShadow: "0 0 30px rgba(225, 29, 72, 0.05)"
}}
>
<SearchIcon sx={{ fontSize: 60, color: "var(--wine-red)" }} />
</div>
{message && (
<h2 style={{
color: "var(--black)",
fontWeight: "800",
fontSize: "26px",
marginBottom: "15px",
maxWidth: "600px",
lineHeight: "1.3"
}}>
{message}
</h2>
)}
<p style={{
color: "var(--gray-dark)",
fontSize: "16px",
lineHeight: "1.6",
marginBottom: "40px",
maxWidth: "500px"
}}>
Explorá entre nuestras categorías y encontrá el servicio perfecto. ¡Hay miles de opciones esperando por vos!
</p>
<Button
text="Explorar categorías"
width="custom"
onClick={() => {
goTo("/landing/categories");
}}
/>
</div>
);
}
@@ -0,0 +1,86 @@
.filterVisible {
position: absolute;
top: 60px; /* Puedes ajustar esto según sea necesario */
bottom: 0px; /* Puedes ajustar esto según sea necesario */
left: 0px;
width: 80%;
max-width: 500px;
background-color: var(--white);
translate: 0%;
animation: translate 1s ease-in-out;
transition: 0.25s;
display: block;
z-index: 5000;
}
.filterHidden {
translate: -100%;
}
.filterBackgroundVisible {
position: absolute;
top: 0px;
bottom: 0px;
right: 0px;
left: 0px;
background-color: rgba(0, 0, 0, 0.1);
display: block;
z-index: 4900;
}
.filterBackgroundHidden {
display: none;
}
.filterHeader {
position: absolute;
top: 0px;
right: 0px;
height: 60px;
z-index: 10000;
}
.filterCloseButton {
cursor: pointer;
margin: 10px;
padding: 5px;
}
.filterTitle {
position: absolute;
top: 3px;
left: 20px;
right: 55px;
height: 40px;
font-weight: 700;
color: var(--black);
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
}
.filterOptionsContainer {
position: absolute;
overflow: auto;
top: 50px;
left: 20px;
right: 20px;
bottom: 75px;
display: flex;
flex-direction: column;
column-gap: 5px;
row-gap: 5px;
}
.filterFooter {
position: absolute;
bottom: 10px;
left: 10px;
right: 20px;
height: 60px;
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
}
@@ -0,0 +1,53 @@
"use client";
import { useFilterStore } from "@store/Filter.Store";
import style from "./Filter.module.css";
import CloseIcon from "@mui/icons-material/Close";
import Button from "../Button/Button";
export default function Filter(): React.ReactElement {
const menuState = useFilterStore();
const classData = menuState.visible ? style.filterVisible : style.filterHidden;
const classBackgroundData = menuState.visible
? style.filterBackgroundVisible
: style.filterBackgroundHidden;
const toggleMenu = () => {
menuState.setVisible(!menuState.visible);
};
const onFilterHandler = () => {
if (menuState.onFilter) {
menuState.onFilter();
}
toggleMenu();
};
return (
<>
<div className={classBackgroundData} onClick={toggleMenu}></div>
<div className={classData}>
<div className={style.filterHeader}>
<CloseIcon
fontSize="large"
className={style.filterCloseButton}
onClick={toggleMenu}
/>
</div>
<div className={style.filterTitle}>{menuState.title}</div>
<div className={style.filterOptionsContainer}>
{!menuState.filter && <>Sin filtro</>}
{menuState.filter && menuState.filter}
</div>
<div className={style.filterFooter}>
<Button
text="Filtrar"
color="primary"
width="custom"
onClick={onFilterHandler}
style={{ width: "100px" }}
/>
</div>
</div>
</>
);
}
@@ -0,0 +1,34 @@
.buttonFilter {
position: absolute;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
top: 5px;
right: -10px;
background-color: transparent;
color: var(--gray-dark);
border-radius: 50%;
cursor: pointer;
transition: width 0.25s ease-in-out, height 0.25s ease-in-out;
width: 52px;
height: 52px;
}
.buttonFilter > svg {
display: flex;
opacity: 1;
background: transparent;
animation: buttonFilterPulseShadow 1.5s ease-in-out infinite;
}
@keyframes buttonFilterPulseShadow {
0%,
100% {
filter: drop-shadow(0px 2px 1px var(--gray-light));
}
50% {
filter: drop-shadow(0px 2px 4px var(--gray-dark));
}
}
@@ -0,0 +1,27 @@
"use client";
import { ReactNode } from "react";
import style from "./Filterable.module.css";
import FilterAltOutlinedIcon from "@mui/icons-material/FilterAltOutlined";
import { useFilterStore } from "@core/Store/Filter.Store";
interface FilterableProps {
children?: ReactNode;
}
export default function Filterable(props: FilterableProps): React.ReactElement {
const filter = useFilterStore();
return (
<>
{props.children}
<div
className={style.buttonFilter}
onClick={() => {
filter.toggle();
}}
>
<FilterAltOutlinedIcon sx={{ fontSize: "32px" }} />
</div>
</>
);
}
@@ -0,0 +1,103 @@
.container {
background: #f4fbf4;
border-radius: 16px;
padding: 24px;
box-shadow: 0px 4px 15px rgba(0, 0, 0, 0.05);
border: 1px solid var(--green-darkestX1);
display: flex;
flex-direction: column;
gap: 20px;
width: 100%;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 15px;
}
.title {
font-size: 20px;
font-weight: 700;
color: var(--green-darkestX1);
margin: 0;
}
.filters {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.select {
padding: 8px 12px;
border-radius: 8px;
border: 1px solid #d1d5db;
background-color: #f9fafb;
font-size: 14px;
color: #374151;
outline: none;
cursor: pointer;
transition: border-color 0.2s ease;
}
.select:focus {
border-color: var(--wine-red, #6366f1);
}
.metricsRow {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.metricBox {
flex: 1;
min-width: 200px;
padding: 15px;
border-radius: 12px;
background-color: #e8f7ea;
border: 1px solid rgba(36, 169, 53, 0.2);
display: flex;
flex-direction: column;
}
.metricLabel {
font-size: 13px;
color: #6b7280;
font-weight: 600;
text-transform: uppercase;
}
.metricValue {
font-size: 28px;
font-weight: 800;
color: #111827;
margin-top: 5px;
}
.metricComparison {
font-size: 13px;
font-weight: 500;
margin-top: 5px;
}
.positive {
color: var(--green-darkest, #10b981);
}
.negative {
color: var(--error, #ef4444);
}
.neutral {
color: #6b7280;
}
.chartContainer {
width: 100%;
height: 350px;
margin-top: 20px;
}
@@ -0,0 +1,216 @@
"use client";
import React, { useEffect, useState } from "react";
import { LineChart } from "@mui/x-charts/LineChart";
import { MyOranizationsView } from "@core/Models/Company.model";
import { DashboardFinancialsResult, getDashboardFinancials } from "../../landing/dashboard/Dashboard.Service";
import { loadCollaboratorsByCompanyId } from "../../admin/(organization-profile)/org/profile/[id]/collaborators/Collaborators.Service";
import { CompanyEmployeesView } from "@models/Collaborators.model";
import UnpaidIncomeCard from "../UnpaidIncomeCard/UnpaidIncomeCard";
import styles from "./FinancialWidget.module.css";
import dayjs from "dayjs";
export interface FinancialWidgetProps {
sessionUser: string;
selectedOrgId: string;
organizationName?: string;
}
export default function FinancialWidget({ sessionUser, selectedOrgId, organizationName }: FinancialWidgetProps) {
const [selectedTimeframe, setSelectedTimeframe] = useState<"week" | "month" | "year">("month");
const [selectedEmployeeId, setSelectedEmployeeId] = useState<string>("");
const [collaborators, setCollaborators] = useState<CompanyEmployeesView[]>([]);
const [financials, setFinancials] = useState<DashboardFinancialsResult | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (selectedOrgId) {
loadCollaboratorsByCompanyId({ companyId: selectedOrgId }).then((res) => {
setCollaborators(res);
setSelectedEmployeeId(""); // reset employee filter when org changes
}).catch(console.error);
}
}, [selectedOrgId, sessionUser]);
useEffect(() => {
if (selectedOrgId) {
setLoading(true);
setError(null);
setFinancials(null);
getDashboardFinancials({
sessionUser,
companyId: selectedOrgId,
timeframe: selectedTimeframe,
employeeId: selectedEmployeeId || undefined
}).then((res) => {
setFinancials(res);
}).catch((err) => {
const errMsg = err.format ? err.format() : err.message;
console.warn("Acceso denegado a métricas financieras: ", errMsg);
setError("No tienes permisos de administrador para esta organización.");
}).finally(() => setLoading(false));
}
}, [selectedOrgId, selectedTimeframe, selectedEmployeeId, sessionUser]);
const formatCurrency = (val: number) => {
return new Intl.NumberFormat("es-AR", { style: "currency", currency: "ARS" }).format(val);
};
const renderComparison = () => {
if (!financials) return null;
const current = financials.currentPeriodTotal;
const previous = financials.previousPeriodTotal;
if (previous === 0) {
if (current > 0) return <span className={styles.positive}>+100% vs periodo anterior</span>;
return <span className={styles.neutral}>Sin datos previos</span>;
}
const diff = current - previous;
const percent = (diff / previous) * 100;
if (percent > 0) return <span className={styles.positive}>+{percent.toFixed(1)}% vs periodo anterior</span>;
if (percent < 0) return <span className={styles.negative}>{percent.toFixed(1)}% vs periodo anterior</span>;
return <span className={styles.neutral}>0% vs periodo anterior</span>;
};
if (!selectedOrgId) return null;
if (error) {
return (
<div className={styles.container}>
<div className={styles.header}>
<h3 className={styles.title}>
Rendimiento Financiero {organizationName ? `- ${organizationName}` : ''}
</h3>
</div>
<div style={{ padding: '40px 20px', textAlign: 'center', color: '#6b7280' }}>
<p style={{ fontWeight: '600', marginBottom: '8px', color: '#374151' }}>Acceso Restringido</p>
<p>{error}</p>
</div>
</div>
);
}
const chartData = financials?.currentPeriodData || [];
const xAxisData = chartData.map(d => {
// format label nicer depending on timeframe
if (selectedTimeframe === "year") return dayjs(d.date).format("MMM YYYY");
return dayjs(d.date).format("DD/MM");
});
const seriesData = chartData.map(d => d.amount);
return (
<div className={styles.container}>
<div className={styles.header}>
<h3 className={styles.title}>
Rendimiento Financiero {organizationName ? `- ${organizationName}` : ''}
</h3>
<div className={styles.filters}>
<select
className={styles.select}
value={selectedTimeframe}
onChange={e => setSelectedTimeframe(e.target.value as "week" | "month" | "year")}
>
<option value="week">Última Semana</option>
<option value="month">Último Mes</option>
<option value="year">Último Año</option>
</select>
<select
className={styles.select}
value={selectedEmployeeId}
onChange={e => setSelectedEmployeeId(e.target.value)}
>
<option value="">Todos los Profesionales</option>
{collaborators.map(c => (
<option key={c.id} value={c.id}>{c.fullName}</option>
))}
</select>
</div>
</div>
<div className={styles.metricsRow}>
<div className={styles.metricBox}>
<span className={styles.metricLabel}>Ingresos del Periodo</span>
<span className={styles.metricValue}>
{loading ? "..." : formatCurrency(financials?.currentPeriodTotal || 0)}
</span>
<div className={styles.metricComparison}>
{!loading && renderComparison()}
</div>
</div>
{financials && (
<UnpaidIncomeCard value={financials.unpaidToday} />
)}
</div>
<div className={styles.chartContainer}>
{!loading && chartData.length > 0 ? (
<LineChart
height={350}
xAxis={[{
scaleType: 'point',
data: xAxisData,
}]}
series={[
{
data: seriesData,
color: 'rgb(36, 169, 53)',
area: true,
showMark: true,
curve: 'monotoneX',
valueFormatter: (val: number | null) => val ? formatCurrency(val) : "0",
},
]}
grid={{ horizontal: true }}
margin={{ top: 20, bottom: 40, left: 70, right: 20 }}
sx={{
'.MuiLineElement-root': {
strokeWidth: 3,
},
'.MuiAreaElement-root': {
fill: 'url(#gradient)',
},
'.MuiChartsGrid-line': {
strokeDasharray: '5 5',
stroke: '#e5e7eb',
},
'.MuiChartsAxis-line': {
stroke: 'transparent',
},
'.MuiChartsAxis-tick': {
stroke: 'transparent',
},
'.MuiChartsAxis-tickLabel': {
fill: '#9ca3af',
fontFamily: 'inherit',
fontSize: '12px',
fontWeight: 500,
},
'.MuiMarkElement-root': {
stroke: 'rgb(36, 169, 53)',
strokeWidth: 2,
fill: '#ffffff',
scale: '1.2',
}
}}
>
<defs>
<linearGradient id="gradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="rgb(36, 169, 53)" stopOpacity={0.3}/>
<stop offset="95%" stopColor="rgb(36, 169, 53)" stopOpacity={0.0}/>
</linearGradient>
</defs>
</LineChart>
) : (
<div style={{display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%', color: '#9ca3af'}}>
{loading ? 'Cargando datos...' : 'No hay datos para este periodo'}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,16 @@
"use client";
import React from "react";
import style from "./footer.module.css";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
export default function Card(): React.ReactElement | null {
const bottomToolbar = useBottomToolbarStore();
if (!bottomToolbar.toolbar) {
return null;
}
return (
<div className={style.footer}>{bottomToolbar.toolbar}</div>
);
}
@@ -0,0 +1,13 @@
.footer {
position: fixed;
overflow: hidden;
z-index: 100;
left: 0px;
bottom: 0px;
right: 0px;
height: 60px;
width: auto;
margin: 0px;
display: flex;
background-color: var(--wine-dark);
}
@@ -0,0 +1,218 @@
"use client";
import Image from "next/image";
import style from "./header.module.css";
import MenuIcon from "@mui/icons-material/Menu";
import NotificationIcon from "@mui/icons-material/NotificationsNone";
import Badge from "@mui/material/Badge";
import { useUserMenuStore } from "@store/UserMenu.Store";
import { HEADER_MODES, HEADER_WIDGETS, useHeaderStore } from "@store/Header.Store";
import { useEffect, useState } from "react";
import useWindowSize from "@hooks/WindowSize";
import Textbox from "../Textbox/Textbox";
import { useNavigation } from "@core/app/hooks/goto";
import { usePathname } from "next/navigation";
import { NO_CATEGORY } from "@core/Models/Categories.type";
import { IconButton } from "@mui/material";
import { useSessionStore } from "@core/Store/Sesion.Store";
import { blurElementsOnLoad } from "@core/helpers/blur";
//import { useSessionStore, useSessionTokenStore } from "@core/Store/Sesion.Store";
export default function Header(): React.ReactElement {
const menuState = useUserMenuStore();
const headerState = useHeaderStore();
const SessionInfo = useSessionStore();
//const SessionToken = useSessionTokenStore();
const [showNotificationIcon, setShowNotificationIcon] = useState(false);
const [windowWidth, windowHeight] = useWindowSize();
const [filterText, setFilterText] = useState("");
const [publicFilterText, setPublicFilterText] = useState("");
const { goTo } = useNavigation();
const pathName = usePathname();
useEffect(() => {
blurElementsOnLoad();
headerState.setActiveWidget(HEADER_WIDGETS.PUBLIC_FIND);
}, [SessionInfo.userId]);
useEffect(() => {
if (!SessionInfo.loged) {
setShowNotificationIcon(false);
return;
}
setShowNotificationIcon(true);
}, [SessionInfo.loged]);
// useEffect(() => {
// console.log("notificationsCount ha cambiado:", headerState.notificationsCount);
// }, [headerState.notificationsCount]);
useEffect(() => {
if (windowWidth < 600) {
headerState.setState(HEADER_MODES.SMALL);
} else {
headerState.setState(HEADER_MODES.LARGE);
}
}, [windowWidth, windowHeight]);
useEffect(() => {
if (headerState.activeWidget === HEADER_WIDGETS.FIND) {
const txtObjectFilter = document.getElementById("txtObjectFilter");
if (txtObjectFilter) {
txtObjectFilter.focus();
}
} else if (headerState.activeWidget === HEADER_WIDGETS.PUBLIC_FIND) {
const txtPublicFilter = document.getElementById("txtPublicFilter");
if (txtPublicFilter) {
txtPublicFilter.focus();
}
}
}, [headerState.activeWidget]);
useEffect(() => {
setFilterText(headerState.findText);
}, [headerState.findText]);
useEffect(() => {
setPublicFilterText(headerState.publicFindText);
}, [headerState.publicFindText]);
const toggleMenu = () => {
menuState.setVisible(!menuState.visible);
};
const goHome = () => {
headerState.setActiveWidget(HEADER_WIDGETS.PUBLIC_FIND);
goTo("/");
};
const HeaderLogo = () => {
let logo = (
<Image
className={style.logoText}
src="/tx-logo-text.svg"
alt="TurnosXpress logo"
width={183}
height={25}
priority
onClick={goHome}
/>
);
if (headerState.state === HEADER_MODES.SMALL) {
logo = (
<Image
className={style.logoText}
src="/tx-logo-short.svg"
alt="TurnosXpress logo"
width={32}
height={25}
priority
onClick={goHome}
/>
);
}
return logo;
};
const publicSearch = () => {
if (pathName === "/landing/find") {
headerState.setPublicFindTrigger(true);
} else {
headerState.setPublicFindTrigger(true);
goTo("/landing/find");
}
};
return (
<>
<div className={style.header}>
<HeaderLogo />
{headerState.activeWidget === HEADER_WIDGETS.FIND && (
<div style={{ marginLeft: "30px", marginRight: "30px", width: "100%" }}>
<Textbox
name="txtObjectFilter"
type="text"
value={filterText}
placeholder=""
width="100%"
variant="transparent"
icon="search"
onChange={(
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
headerState.setFindText(e.target.value);
setFilterText(e.target.value);
}}
/>
</div>
)}
{headerState.activeWidget === HEADER_WIDGETS.PUBLIC_FIND && (
<div
style={{
marginLeft: "30px",
width: "100%",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<Textbox
name="txtPublicFilter"
type="text"
value={publicFilterText}
placeholder="Buscar servicios..."
width="80%"
style={{ maxWidth: "500px" }}
variant="search"
icon="search"
onChange={(
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
setPublicFilterText(e.target.value);
headerState.setPublicFindText(e.target.value);
}}
onKeyUp={(
e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
if (e.key === "Enter") {
//Si no estoy en la pagina de busqueda paso a NO_CATEGORY.
if (pathName !== "/landing/find") {
headerState.setPublicFindCategoryId(NO_CATEGORY);
//console.log("busco desde fuera");
} else {
//console.log("busco desde find");
}
//en caso de estar en la pagina de busqueda respeto la categoria seleccionada.
publicSearch();
}
}}
/>
</div>
)}
{headerState.activeWidget === HEADER_WIDGETS.NONE && (
<div style={{ width: "100%" }}>&nbsp;</div>
)}
{showNotificationIcon && (
<IconButton
sx={{ marginRight: "20px" }}
onClick={() => goTo("/user/profile/notifications")}
>
<Badge
color="warning"
variant={"standard"}
badgeContent={headerState.notificationsCount}
>
<NotificationIcon sx={{ color: "white" }} />
</Badge>
</IconButton>
)}
<MenuIcon sx={{ color: "white" }} onClick={toggleMenu} className={style.menuIcon} />
</div>
</>
);
}
@@ -0,0 +1,27 @@
.logoText {
cursor: pointer;
}
.headerSpacer {
height: 60px;
}
.header {
position: fixed;
overflow: hidden;
left: 0px;
top: 0px;
right: 0px;
padding-left: 40px;
padding-right: 40px;
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
z-index: 9000;
background-color: var(--wine-darkest);
}
.menuIcon {
cursor: pointer;
}
@@ -0,0 +1,12 @@
"use client";
import { useEffect } from "react";
import { HEADER_WIDGETS, useHeaderStore } from "@core/Store/Header.Store";
export default function HeaderConfProvider() {
const headerState = useHeaderStore();
useEffect(() => {
headerState.setActiveWidget(HEADER_WIDGETS.NONE);
}, []);
return null;
}
@@ -0,0 +1,65 @@
.caminosContainer {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding-left: 30px;
padding-right: 30px;
position: relative; /*esto evito que las animaciones pasen por encima*/
z-index: 2;
}
.caminosContainer h1 {
text-align: center;
color: var(--wine-red);
font-size: 1.8rem;
text-shadow: 1px 1px 1px var(--white);
}
.caminosContainer p {
margin-top: 20px;
margin-bottom: 20px;
text-align: center;
color: var(--gray-dark);
font-size: 1.2rem;
text-shadow: 1px 1px 1px var(--white);
max-width: 600px;
}
.caminosContainer ul {
padding: 0px;
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 20px;
flex-wrap: wrap;
justify-content: center;
}
.caminosContainer li {
list-style-type: none;
margin-top: 20px;
margin-bottom: 20px;
max-width: 40%;
border: solid 1px var(--gray-light);
border-radius: 8px;
padding: 20px;
min-width: 300px;
background-color: rgb(254, 227, 74);
cursor: pointer;
}
.caminosContainer li h3 {
font-size: 1.4rem;
color: var(--black);
text-shadow: 1px 1px 1px var(--white);
}
.caminosContainer li p {
margin-top: 10px;
font-size: 1rem;
font-weight: 400;
color: var(--black);
text-align: left;
text-shadow: 1px 1px 1px rgb(255, 241, 199);
}
@@ -0,0 +1,44 @@
"use client";
import { motion } from "motion/react";
import style from "./Caminos.module.css";
import { useNavigation } from "@core/app/hooks/goto";
export default function Caminos() {
const { goTo } = useNavigation();
return (
<div className={style.caminosContainer}>
<h1>Reservar u ofrecer turnos</h1>
<p>
Tu tiempo es valioso. Aquí lo hacemos simple para que encuentres justo lo que
necesitas. Explora nuestras opciones y elige cómo quieres usar nuestra plataforma
</p>
<div>
<ul>
<motion.li
whileHover={{ scale: 1.1, backgroundColor: "#fff480" }}
whileTap={{ scale: 0.95 }}
onClick={() => goTo("/landing/get-appointments")}
>
<h3>Quiero reservar</h3>
<p>
Encuentra profesionales capacitados para brindarte el mejor servicio.
Reserva tu turno, y nosotros te ayudaremos a encontrar a tu profesional.
</p>
</motion.li>
<motion.li
whileHover={{ scale: 1.1, backgroundColor: "#fff480" }}
whileTap={{ scale: 0.95 }}
onClick={() => goTo("/landing/offer-appointments")}
>
<h3>Quiero ofrecer turnos</h3>
<p>
Llega a más personas de forma simple y organizada. Publica tus
servicios, gestiona tus horarios y deja que nuevos clientes te
encuentren fácilmente.
</p>
</motion.li>
</ul>
</div>
</div>
);
}
@@ -0,0 +1,38 @@
"use client";
import { motion } from "motion/react";
import categories from "@core/Models/Categories.type";
import { useNavigation } from "@core/app/hooks/goto";
import style from "./style.module.css";
import { useHeaderStore } from "@core/Store/Header.Store";
export default function CategoriesExplorer() {
const headerState = useHeaderStore();
const { goTo } = useNavigation();
return (
<>
<div className={style.categoriesExplorer}>
<ul>
{categories.map((category) => (
<motion.li
key={category.id}
whileHover={{ scale: 1.1, backgroundColor: "#fff480" }}
whileTap={{ scale: 0.95 }}
onClick={() => {
headerState.setPublicFindCategoryId(category.id);
goTo("/landing/find");
}}
style={{
backgroundPosition: "left top",
backgroundRepeat: "no-repeat",
}}
>
<h3>{category.name}</h3>
<p>{category.description}</p>
</motion.li>
))}
</ul>
</div>
</>
);
}
@@ -0,0 +1,80 @@
.categoriesExplorer {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
padding-left: 30px;
padding-right: 30px;
position: relative; /*esto evito que las animaciones pasen por encima*/
z-index: 2;
}
.categoriesExplorer li h3 {
font-size: 1.4rem;
color: var(--black);
text-shadow: 1px 1px 1px var(--white);
}
.categoriesExplorer li p {
margin-top: 10px;
font-size: 1rem;
font-weight: 400;
color: var(--black);
text-align: left;
text-shadow: 1px 1px 1px rgb(255, 241, 199);
}
@media (max-width: 1099px) {
.categoriesExplorer ul {
margin-left: auto;
margin-right: auto;
padding: 0px;
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 20px;
width: 100%;
flex-wrap: wrap;
justify-content: flex-start;
}
.categoriesExplorer li {
list-style-type: none;
margin-top: 20px;
margin-bottom: 0px;
border: solid 1px var(--gray-light);
border-radius: 8px;
padding: 20px;
width: 100%;
background-color: rgb(254, 227, 74);
cursor: pointer;
}
}
@media (min-width: 1100px) {
.categoriesExplorer ul {
margin-left: auto;
margin-right: auto;
padding: 0px;
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 20px;
width: 1030px;
flex-wrap: wrap;
justify-content: flex-start;
}
.categoriesExplorer li {
list-style-type: none;
margin-top: 20px;
margin-bottom: 0px;
border: solid 1px var(--gray-light);
border-radius: 8px;
padding: 20px;
width: 500px;
background-color: rgb(254, 227, 74);
cursor: pointer;
}
}
@@ -0,0 +1,255 @@
import categories, { NO_CATEGORY } from "@core/Models/Categories.type";
import TextObjectFilter from "../../TextObjectFilter/TextObjectFilter";
import { useEffect, useState } from "react";
import { TextObjectFilterResult } from "@core/Models/TextObjectFilter.model";
import TextObjectFilterItem from "../../TextObjectFilterItem/TextObjectFilterItem";
import { TextObjectModalContent } from "@core/Store/TextObjectModal.Store";
import { useHeaderStore } from "@core/Store/Header.Store";
import TextFromTo from "../../TextFromTo/TextFromTo";
import Button from "../../Button/Button";
import { PaginateServicesParams } from "@core/Models/Service.model";
import classNames from "classnames";
import style from "./style.module.css";
import { IconButton } from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
export type FindFilterServiceParams = Omit<PaginateServicesParams, "page" | "limit">;
export interface FindFilterProps {
visible: boolean;
onFilter: () => void;
setFilterCompany: (companyId: string) => void;
setFilterCategory: (categoryId: string) => void;
setFilterLocation: (locationId: string) => void;
setFilterPriceFrom: (price: number | null) => void;
setFilterPriceTo: (price: number | null) => void;
setFilterDurationFrom: (duration: number | null) => void;
setFilterDurationTo: (duration: number | null) => void;
}
export default function Filters(props: FindFilterProps) {
const headerState = useHeaderStore();
const [priceFrom, setPriceFrom] = useState<string>("");
const [priceTo, setPriceTo] = useState<string>("");
const [duracionFrom, setDuracionFrom] = useState<string>("");
const [duracionTo, setDuracionTo] = useState<string>("");
const [visible, setVisible] = useState<boolean>(props.visible);
const [categoryFilterContent] = useState<TextObjectModalContent>({
header: (
<div>
<div style={{ fontWeight: "700", fontSize: "14px", paddingTop: "7px" }}>
Seleccionar Categoría:
</div>
<p style={{ fontWeight: "500", fontSize: "13px", paddingTop: "2px" }}>
Ingrese el nombre de la categoría para buscarla.
</p>
</div>
),
bottom: <div>&nbsp;</div>,
renderResult: (result: TextObjectFilterResult, index: number) => (
<TextObjectFilterItem key={index} data={result} />
),
});
const [categoryFilterData, setCategoryFilterData] = useState<TextObjectFilterResult[]>([]);
const [categoryId, setCategoryId] = useState<string>("");
const [locationFilterContent] = useState<TextObjectModalContent>({
header: (
<div>
<div style={{ fontWeight: "700", fontSize: "14px", paddingTop: "7px" }}>
Seleccionar Localidad:
</div>
<p style={{ fontWeight: "500", fontSize: "13px", paddingTop: "2px" }}>
Ingrese el nombre de la localidad para buscarla.
</p>
</div>
),
bottom: <div>&nbsp;</div>,
renderResult: (result: TextObjectFilterResult, index: number) => (
<TextObjectFilterItem key={index} data={result} />
),
});
//const [locationId, setLocationId] = useState<string>("");
const [organizationFilterContent] = useState<TextObjectModalContent>({
header: (
<div>
<div style={{ fontWeight: "700", fontSize: "14px", paddingTop: "7px" }}>
Seleccionar Organización:
</div>
<p style={{ fontWeight: "500", fontSize: "13px", paddingTop: "2px" }}>
Ingrese el nombre de la organización para buscarla.
</p>
</div>
),
bottom: <div>&nbsp;</div>,
renderResult: (result: TextObjectFilterResult, index: number) => (
<TextObjectFilterItem key={index} data={result} />
),
});
//const [organizationId, setOrganizationId] = useState<string>("");
const loadCategories = () => {
setCategoryFilterData(
categories.map((category) => ({
id: category.id.toString(),
name: category.name,
description: category.description,
}))
);
};
useEffect(() => {
loadCategories();
if (headerState.publicFindCategoryId !== NO_CATEGORY) {
setCategoryId(headerState.publicFindCategoryId.toString());
props.setFilterCategory(headerState.publicFindCategoryId.toString());
}
}, [categoryId]);
useEffect(() => {
if (headerState.publicFindCategoryId == NO_CATEGORY) {
setCategoryId("");
props.setFilterCategory("");
}
}, [headerState.publicFindCategoryId]);
useEffect(() => {
//la primera vez que carga emite la señal de busqueda para hacer el primer filtro
headerState.setPublicFindTrigger(true);
}, []);
useEffect(() => {
setVisible(props.visible);
}, [props.visible]);
const getContainerClasses = () => {
const items: classNames.ArgumentArray = [];
items.push("card-base");
items.push(style.filterContainer);
if (visible) {
items.push(style.filterContainerVisible);
} else {
items.push(style.filterContainerHidden);
}
return classNames(items);
};
return (
<div className={getContainerClasses()}>
<div style={{ paddingLeft: "5px" }}>
<div
style={{
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
}}
>
<h4>Filtros</h4>
{headerState.publicFindText && (
<div
style={{
backgroundColor: "var(--wine-dark)",
borderRadius: "5px",
padding: "5px",
marginTop: "5px",
display: "inline-block",
color: "var(--white)",
}}
>
{headerState.publicFindText}
</div>
)}
<IconButton
style={{ display: "none" }}
onClick={() => {
setVisible(false);
}}
>
<CloseIcon />
</IconButton>
</div>
</div>
<TextObjectFilter
placeholder="Organización"
content={organizationFilterContent}
endpoint="companies/filter-all"
onChange={(id) => {
//setOrganizationId(id);
props.setFilterCompany(id);
}}
/>
<TextObjectFilter
placeholder="Categoría"
content={categoryFilterContent}
data={categoryFilterData}
selectedId={categoryId}
onChange={(id) => {
//setCategoryId(id);
headerState.setPublicFindCategoryId(NO_CATEGORY);
props.setFilterCategory(id);
}}
/>
<TextObjectFilter
placeholder="Localidad"
content={locationFilterContent}
endpoint="location/localidades-filter-paginate"
onChange={(id) => {
//setLocationId(id);
props.setFilterLocation(id);
}}
/>
<TextFromTo
label="Precio ($)"
width="100%"
from={priceFrom}
to={priceTo}
onChangeFrom={(e) => {
setPriceFrom(e.target.value);
props.setFilterPriceFrom(parseFloat(e.target.value));
}}
onChangeTo={(e) => {
setPriceTo(e.target.value);
props.setFilterPriceTo(parseFloat(e.target.value));
}}
/>
<TextFromTo
label="Duración (min)"
width="100%"
from={duracionFrom}
to={duracionTo}
onChangeFrom={(e) => {
setDuracionFrom(e.target.value);
props.setFilterDurationFrom(parseInt(e.target.value));
}}
onChangeTo={(e) => {
setDuracionTo(e.target.value);
props.setFilterDurationTo(parseInt(e.target.value));
}}
/>
<Button
text="Filtrar"
width="100%"
onClick={() => {
headerState.setPublicFindTrigger(true);
props.onFilter();
}}
style={{ marginTop: "20px" }}
/>
</div>
);
}
@@ -0,0 +1,34 @@
.filterContainerVisible {
display: flex !important;
}
.filterContainerHidden {
display: none !important;
}
@media (max-width: 851px) {
.filterContainerVisible {
display: block !important;
}
.filterContainer {
position: absolute;
left: 20px;
top: 80px;
flex-direction: column;
min-width: 320px;
max-width: 320px;
margin-bottom: auto;
padding: 15px;
}
}
@media (min-width: 850px) {
.filterContainer {
flex-direction: column;
min-width: 320px;
max-width: 320px;
margin-bottom: auto;
padding: 15px;
}
}
@@ -0,0 +1,82 @@
@media (max-width: 700px) {
.footerContainer {
max-width: 1200px;
margin-left: auto;
margin-right: auto;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
}
.homeFooter h1 {
color: var(--white);
font-weight: 500;
font-size: 18px;
padding-top: 8px;
padding-bottom: 8px;
text-align: center;
}
.homeFooter a {
color: var(--white);
font-weight: 400;
font-size: 14px;
padding-bottom: 5px;
text-align: center;
}
}
@media (min-width: 701px) {
.footerContainer {
max-width: 1200px;
margin-left: auto;
margin-right: auto;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
}
.homeFooter h1 {
color: var(--white);
font-weight: 500;
font-size: 18px;
padding-bottom: 8px;
}
.homeFooter a {
color: var(--white);
font-weight: 400;
font-size: 14px;
padding-bottom: 5px;
}
}
.homeFooter {
margin-top: 0px;
background-color: var(--wine-darkest);
padding-left: 40px;
padding-right: 40px;
padding-top: 30px;
padding-bottom: 30px;
}
.footerLogoContainer {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin-bottom: 30px;
}
.copyContent {
color: var(--white);
margin-left: auto;
margin-right: auto;
text-align: center;
margin-top: 15px;
padding-top: 15px;
border-top: solid 1px var(--wine-light);
font-weight: 500;
}
@@ -0,0 +1,90 @@
"use client";
import Link from "next/link";
import style from "./Footer.module.css";
import { useHeaderStore } from "@core/Store/Header.Store";
import { NO_CATEGORY } from "@core/Models/Categories.type";
export default function Footer() {
const headerState = useHeaderStore();
return (
<>
<div id="tx.home.footer.container" className={style.homeFooter}>
<div className={style.footerContainer}>
<div className={style.footerLogoContainer} style={{ minWidth: "300" }}>
<img src="/tx-logo-short.svg" width="128" />
</div>
<div style={{ minWidth: "300" }}>
<h1>Secciones</h1>
<Link href="/" style={{ display: "block" }}>
Pagina principal
</Link>
<Link href="/landing/signup" style={{ display: "block" }}>
Registrarse
</Link>
<Link href="/landing/login" style={{ display: "block" }}>
Iniciar sesión
</Link>
<h1 style={{ marginTop: "5px" }}>Legales</h1>
<Link href="/landing/terms" style={{ display: "block" }}>
Términos y condiciones
</Link>
<Link href="/landing/privacy" style={{ display: "block" }}>
Política de privacidad
</Link>
<Link href="/landing/cookies" style={{ display: "block" }}>
Política de cookies
</Link>
</div>
<div style={{ minWidth: "300" }}>
<h1>Obtener turnos</h1>
<Link href="/landing/my-appointments" style={{ display: "block" }}>
Mis turnos
</Link>
<Link href="/landing/categories" style={{ display: "block" }}>
Explorar categorías
</Link>
<Link
href="/landing/find"
onClick={() => {
headerState.setPublicFindCategoryId(NO_CATEGORY);
}}
style={{ display: "block" }}
>
Explorar servicios
</Link>
<h1 style={{ marginTop: "5px" }}>Zona de usuarios</h1>
<Link href="/user/profile" style={{ display: "block" }}>
Perfil de usuario
</Link>
<Link href="/user/profile/notifications" style={{ display: "block" }}>
Notificaciones
</Link>
<Link href="/messenger" style={{ display: "block" }}>
Mensajes
</Link>
</div>
<div style={{ minWidth: "300" }}>
<h1>Ofrecer turnos</h1>
<Link href="/landing/offer-appointments" style={{ display: "block" }}>
Quiero ofrecer turnos
</Link>
<Link href="/landing/pricing" style={{ display: "block" }}>
Planes y precios
</Link>
<Link href="/admin/org" style={{ display: "block" }}>
Mis organizaciones
</Link>
<h1 style={{ marginTop: "5px" }}>Contacto</h1>
<Link href="mailto:info@turnosxpress.com.ar" style={{ display: "block" }}>
info@turnosxpress.com.ar
</Link>
</div>
</div>
<div className={style.copyContent}>
Todos los derechos reservados, © {new Date().getFullYear()} -
turnosxpress.com.ar
</div>
</div>
</>
);
}
@@ -0,0 +1,109 @@
"use client";
import { HomeView } from "@core/Models/Views.model";
import FrameSlider from "../Slider/Slider";
import ProductGallery from "../ProductsGallery/ProductGallery";
import AnimatedContainer from "../../AnimatedConainer/AnimatedContainer";
import Caminos from "../Caminos/Caminos";
import Footer from "../Footer/Footer";
import styles from "./page.module.css";
import { useNavigation } from "@core/app/hooks/goto";
import { useHeaderStore } from "@core/Store/Header.Store";
import { blurElementsOnLoad } from "@core/helpers/blur";
import { useEffect } from "react";
export interface IndexPageProps {
homeData: HomeView;
}
export default function Index(props: IndexPageProps): React.ReactElement {
const { homeData } = props;
const headerState = useHeaderStore();
const { goTo } = useNavigation();
useEffect(() => {
blurElementsOnLoad();
}, []);
return (
<div>
<div className="mainSectionWhite">
<FrameSlider />
</div>
{(homeData.health?.data.length > 0 || homeData.education?.data.length > 0) && (
<div className={styles.mainSection}>
<section className="homeCentered">
<ProductGallery title="Servicios destacados &#128293;" data={[]} />
<ProductGallery
title="Educacion &#x1F3C6;"
showViewMore={true}
viewMoreColor="var(--yellow)"
onViewMore={() => {
headerState.setPublicFindCategoryId(200);
goTo("/landing/find");
}}
data={homeData.education?.data}
/>
<ProductGallery
title="Salud y Bienestar &#128293;"
showViewMore={true}
onViewMore={() => {
headerState.setPublicFindCategoryId(100);
goTo("/landing/find");
}}
data={homeData.health?.data}
/>
</section>
</div>
)}
<AnimatedContainer
color="#ffee38"
minSize={150}
maxSize={500}
items={5}
className={styles.caminosSection}
>
<section className="homeCentered">
<Caminos />
</section>
</AnimatedContainer>
{(homeData.economy?.data.length > 0 ||
homeData.construction?.data.length > 0 ||
homeData.other?.data.length > 0) && (
<div className={styles.secondSection}>
<section className="homeCentered">
<ProductGallery
title="Otros Servicios &#128293;"
showViewMore={true}
onViewMore={() => {
headerState.setPublicFindCategoryId(50000);
goTo("/landing/find");
}}
data={homeData.other?.data}
/>
<ProductGallery
title="Construcción y Mantenimiento &#128293;"
showViewMore={true}
onViewMore={() => {
headerState.setPublicFindCategoryId(400);
goTo("/landing/find");
}}
data={homeData.construction?.data}
/>
<ProductGallery
title="Contabilidad &#x1F3C6;"
showViewMore={true}
onViewMore={() => {
headerState.setPublicFindCategoryId(300);
goTo("/landing/find");
}}
data={homeData.economy?.data}
/>
</section>
</div>
)}
<Footer />
</div>
);
}
@@ -0,0 +1,40 @@
.mainSection {
width: 100%;
background-color: white;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
background: var(--wine-darkest);
background: linear-gradient(180deg, var(--red) 0%, var(--white) 50%);
padding-bottom: 30px;
padding-top: 30px;
}
.caminosSection {
width: 100%;
background-color: white;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
background: var(--wine-darkest);
background: linear-gradient(180deg, var(--yellow) 0%, var(--white) 50%);
border-top: solid 1px rgb(255, 198, 55);
padding-bottom: 30px;
padding-top: 30px;
}
.secondSection {
width: 100%;
background-color: white;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
background: var(--wine-darkest);
background: linear-gradient(180deg, var(--orange) 0%, var(--white) 50%);
border-top: solid 1px rgb(255, 122, 55);
padding-bottom: 30px;
padding-top: 30px;
}
@@ -0,0 +1,477 @@
"use client";
import { useSessionStore, useSessionTokenStore } from "@store/Sesion.Store";
import { ApiError } from "@models/Server.Error.model";
import { ISession } from "@models/Session.model";
import EvangelizeBanner from "@components/EvangelizeBanner/EvangelizeBanner";
import {
login,
loginAndVerificate,
recoveryAccountWithCode,
sendRecoveryCode,
} from "@services/Session.Login";
import Checkbox from "@mui/material/Checkbox";
import Textbox from "@components/Textbox/Textbox";
import Button from "@components/Button/Button";
import ButtonGoogle from "./LoginGoogle";
import { GoogleOAuthProvider } from "@react-oauth/google";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import * as Yup from "yup";
import { useAlert } from "@store/Alert.Store";
import Link from "next/link";
import AnimatedContainer from "@components/AnimatedConainer/AnimatedContainer";
import { LOGIN_ACTIONS, useLoginActionStore } from "@core/Store/LoginAction.Store";
import HeaderConfProvider from "../../HeaderConfProvider/HeaderConfProvider";
import { EVENT_TYPES, useEventHandlerStore } from "@core/Store/EventHandler.Store";
import { ScrollToTop } from "@core/app/components/ScrollTop";
import { useNavigation } from "@core/app/hooks/goto";
interface LoginProps {
verification?: boolean;
recovery?: boolean;
recoveryCode?: boolean;
}
export default function Login(props: LoginProps) {
const { verification = false, recovery = false, recoveryCode = false } = props;
const router = useRouter();
const loginAction = useLoginActionStore();
const SessionInfo = useSessionStore();
const SessionToken = useSessionTokenStore();
const alert = useAlert();
const eventHandler = useEventHandlerStore();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [verificationCode, setVerificationCode] = useState("");
const [checkReadTerms, setCheckReadTerms] = useState(false);
const clientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "";
const { goTo } = useNavigation();
const createSession = (userSession: ISession, msg: string) => {
SessionInfo.setUser(userSession);
SessionToken.setToken(userSession.userToken);
SessionInfo.setLoginState("success");
alert.showSuccess(msg);
};
const manageLoginActions = () => {
const toUrl = process.env.NEXT_PUBLIC_DEFAULT_PATH_AFTER_LOGIN || "/";
if (loginAction.action !== LOGIN_ACTIONS.NONE) {
goTo(loginAction.url);
loginAction.clear();
} else {
if (SessionInfo.subscription && SessionInfo.subscription.plan) {
goTo("/landing/dashboard");
return;
}
goTo(toUrl);
}
};
const clearSession = (msg: string) => {
SessionInfo.clear();
SessionToken.setToken("");
alert.showError(msg);
SessionInfo.setLoginState("error");
};
const verificateLogin = () => {
const schema = Yup.object().shape({
verificationCode: Yup.string()
.length(6, "El código de verificacion debe tener 6 digitos")
.required("El código de verificacion es obligatorio."),
password: Yup.string()
.min(8, "La contraseña debe tener al menos 8 caracteres.")
.required("El campo de contraseña es obligatorio."),
email: Yup.string()
.email("Debe ingresar un correo electrónico válido.")
.required("El campo de correo electrónico es obligatorio."),
});
eventHandler.setEventType(EVENT_TYPES.LOADING);
schema
.validate({ email, password, verificationCode }, { abortEarly: true, strict: true })
.then(() => {
SessionInfo.setLoginState("started");
loginAndVerificate(email, password, verificationCode)
.then((userSession: ISession) => {
createSession(
userSession,
"Registro compleatado. Bienvenido a turnosXpress."
);
})
.catch((error) => {
clearSession(error.format());
})
.finally(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
})
.catch((error) => {
const e = error as Yup.ValidationError;
clearSession("Ha ocurrido un error. Por favor intentelo nuevamente. " + e.message);
})
.finally(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
};
const sendRecoveryMail = () => {
const schema = Yup.object().shape({
email: Yup.string()
.email("Debe ingresar un correo electrónico válido.")
.required("El campo de correo electrónico es obligatorio."),
});
eventHandler.setEventType(EVENT_TYPES.LOADING);
schema
.validate({ email }, { abortEarly: true, strict: true })
.then(() => {
SessionInfo.setLoginState("started");
sendRecoveryCode(email)
.then(() => {
alert.showSuccess(
"Se ha enviado un correo de recuperación a la cuenta indicada."
);
setTimeout(() => {
router.push("/landing/recover-account/verify");
}, 1000);
})
.catch((error) => {
clearSession(error.format());
})
.finally(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
})
.catch((error) => {
const e = error as Yup.ValidationError;
clearSession(e.message);
})
.finally(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
};
const recoveryAccount = () => {
const schema = Yup.object().shape({
email: Yup.string()
.email("Debe ingresar un correo electrónico válido.")
.required("El campo de correo electrónico es obligatorio."),
verificationCode: Yup.string()
.length(6, "El código de verificación debe tener 6 digitos")
.required("El código de verificacion es obligatorio."),
password: Yup.string()
.min(8, "La contraseña debe tener al menos 8 caracteres.")
.required("El campo de contraseña es obligatorio."),
});
eventHandler.setEventType(EVENT_TYPES.LOADING);
schema
.validate({ email, verificationCode, password }, { abortEarly: true, strict: true })
.then(() => {
recoveryAccountWithCode(email, password, verificationCode)
.then(() => {
alert.showSuccess("Se ha cambiado la contraseña de la cuenta indicada.");
setTimeout(() => {
goTo("/landing/login");
}, 1000);
})
.catch((error) => {
clearSession(error.format());
})
.finally(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
})
.catch((error) => {
const e = error as Yup.ValidationError;
clearSession(e.message);
})
.finally(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
};
const iniciarSesion = () => {
const schema = Yup.object().shape({
email: Yup.string()
.email("Debe ingresar un correo electrónico válido.")
.required("El campo de correo electrónico es obligatorio."),
password: Yup.string()
.min(8, "La contraseña debe tener al menos 8 caracteres.")
.required("El campo de contraseña es obligatorio."),
});
eventHandler.setEventType(EVENT_TYPES.LOADING);
schema
.validate({ email, password }, { abortEarly: false })
.then(() => {
SessionInfo.setLoginState("started");
login(email, password)
.then((userSession: ISession) => {
createSession(userSession, "Has iniciado sesión correctamente!");
})
.catch((error: ApiError) => {
clearSession(error.format());
})
.finally(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
})
.catch((error) => {
let errorDesc = "";
if (error instanceof Yup.ValidationError) {
const e: Yup.ValidationError = error as Yup.ValidationError;
errorDesc = e.errors.reduce((prev, curr) => prev + " " + curr, "");
} else {
errorDesc = "Ha ocurrido un error. Por favor intentelo nuevamente.";
}
clearSession(errorDesc);
})
.finally(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
};
useEffect(() => {
if (SessionInfo.loged) {
manageLoginActions();
}
}, [SessionInfo.loged]);
return (
<AnimatedContainer
color="#aeaeae"
minSize={150}
maxSize={500}
items={10}
style={{
minHeight: "100vh",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
background:
"linear-gradient(180deg, var(--wine-darkest) 0%, var(--wine-dark) 100%)",
}}
>
<ScrollToTop />
<div className="homeCentered">
<HeaderConfProvider />
<GoogleOAuthProvider clientId={clientId}>
<div
style={{
paddingLeft: "20px",
paddingRight: "20px",
translate: "0px -30px",
}}
>
<div className="card">
<div
style={{
textAlign: "center",
paddingBottom: "30px",
paddingTop: "20px",
position: "relative",
}}
>
{verification && (
<>
<h1 className="homeTitleHeader">Finalizar registro</h1>
<p>
Hemos enviado un correo electronico a tu cuenta con el
código de verificación para completar el registro.{" "}
<strong>Por favor revisa tu bandeja de entrada.</strong>
</p>
</>
)}
{recovery && (
<>
<h1 className="homeTitleHeader">Reestablecer clave</h1>
<p>
Te enviaremos un correo electrónico a la dirección
registrada con tu cuenta. Asegúrate de proporcionarnos
el mismo correo con el que te registraste.{" "}
<strong>
Revisa tu bandeja de entrada y, si no lo encuentras,
también la carpeta de spam. Luego, sigue las
instrucciones.
</strong>
</p>
</>
)}
{recoveryCode && (
<>
<h1 className="homeTitleHeader">Recuperar el acceso</h1>
<p>
Hemos enviado un correo electrónico a tu cuenta con el
código de verificación para completar el proceso de
recuperación.{" "}
<strong>
Ingresa tu email, la nueva clave y el código de
verificación que te enviamos.
</strong>
</p>
</>
)}
{!verification && !recovery && !recoveryCode && (
<h1 className="homeTitleHeader">Ingresar</h1>
)}
<Textbox
name="name"
placeholder="Email"
type="text"
value={email}
width="100%"
onChange={(e) => {
setEmail(e.target.value);
}}
onKeyUp={(e) => {
if (!verification && !recovery && !recoveryCode) {
if (e.key === "Enter") {
document.getElementById("password")?.focus();
}
}
}}
/>
{!recovery && (
<Textbox
name="password"
placeholder="Clave"
type="password"
value={password}
width="100%"
onChange={(e) => {
setPassword(e.target.value);
}}
onKeyUp={(e) => {
if (!verification && !recovery && !recoveryCode) {
if (e.key === "Enter") {
iniciarSesion();
}
}
}}
/>
)}
{(verification || recoveryCode) && (
<Textbox
placeholder="Código de verificaion"
type="text"
value={verificationCode}
width="100%"
onChange={(e) => {
setVerificationCode(e.target.value);
}}
/>
)}
{verification && (
<Button
name="btnLogin"
text="Verificar"
width="100%"
onClick={verificateLogin}
style={{ marginTop: "20px" }}
type="submit"
/>
)}
{recovery && (
<Button
name="btnLogin"
text="Enviar email"
width="100%"
onClick={sendRecoveryMail}
style={{ marginTop: "20px" }}
type="submit"
/>
)}
{recoveryCode && (
<Button
name="btnLogin"
text="Cambiar clave"
width="100%"
onClick={recoveryAccount}
style={{ marginTop: "20px" }}
type="submit"
/>
)}
{!verification && !recovery && !recoveryCode && (
<Button
name="btnLogin"
text="Ingresar"
width="100%"
onClick={iniciarSesion}
style={{ marginTop: "20px" }}
type="submit"
/>
)}
{!verification && !recovery && !recoveryCode && (
<div>
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "flex-start",
}}
>
<Checkbox
checked={checkReadTerms}
onClick={() => setCheckReadTerms(!checkReadTerms)}
/>
<div style={{ marginTop: "8px" }}>
Declaro que acepto los siguientes&nbsp;
<Link href="/landing/terms">
Términos y condiciones
</Link>{" "}
y{" "}
<Link href="/landing/privacy">
Políticas de privacidad
</Link>
</div>
</div>
<ButtonGoogle termsReaded={checkReadTerms} />
</div>
)}
{!recoveryCode && (
<p style={{ marginTop: "20px" }}>
No tienes una cuenta?&nbsp;
<Link href="/landing/signup">Registrate!</Link>
</p>
)}
{!recovery && !recoveryCode && (
<Link
href="/landing/recover-account"
style={{ display: "block", marginTop: "10px" }}
>
Olvide mi clave!
</Link>
)}
{recovery && !recoveryCode && (
<Link
href="/landing/recover-account/verify"
style={{ display: "block", marginTop: "10px" }}
>
Ya tengo el código de verificación!
</Link>
)}
</div>
</div>
</div>
</GoogleOAuthProvider>
</div>
<EvangelizeBanner variant="popup" delay={4000} />
</AnimatedContainer>
);
}
@@ -0,0 +1,94 @@
"use client";
import { useEffect } from "react";
import { useGoogleLogin } from "@react-oauth/google";
import { ISession } from "@models/Session.model";
import ButtonSession from "@components/ButtonSession/ButtonSession";
import { loginByAndroid, loginByGoogle } from "@services/Session.Login";
import { useSessionStore, useSessionTokenStore } from "@store/Sesion.Store";
import { useConfirmStore } from "@core/Store/Confirm.Store";
export interface LoginGoogleProps {
termsReaded: boolean;
}
export default function LoginGoogle(props: LoginGoogleProps) {
const { termsReaded = false } = props;
const SessionInfo = useSessionStore();
const SessionToken = useSessionTokenStore();
const confirm = useConfirmStore();
useEffect(() => {
// Add the event listener when the component mounts
window.addEventListener("androidTokenReceived", handleAndroidToken);
// Clean up the event listener when the component unmounts
return () => {
window.removeEventListener("androidTokenReceived", handleAndroidToken);
};
}, []);
const handleAndroidToken = (event: Event) => {
// Type assertion to access the 'detail' property of CustomEvent
const customEvent = event as CustomEvent<string>;
const token = customEvent.detail;
loginByAndroid(token)
.then((userSession: ISession) => {
SessionInfo.setUser(userSession);
SessionToken.setToken(userSession.userToken);
SessionInfo.setLoginState("success");
})
.catch(() => {
SessionInfo.clear();
SessionToken.setToken("");
SessionInfo.setLoginState("error");
});
};
const login = useGoogleLogin({
onSuccess: async (tokenResponse) => {
loginByGoogle(tokenResponse.access_token)
.then((userSession: ISession) => {
SessionInfo.setUser(userSession);
SessionToken.setToken(userSession.userToken);
SessionInfo.setLoginState("success");
})
.catch(() => {
SessionInfo.clear();
SessionToken.setToken("");
SessionInfo.setLoginState("error");
});
},
onError: (error) => {
console.error("Error durante la autenticación:", error);
},
scope: "profile",
});
return (
<ButtonSession
name="btnLoginGoogle"
text="Continuar con Google"
onClick={() => {
if (!termsReaded) {
confirm.show(
"Para acceder con google debe aceptar los términos y condiciones y las politicas de privacidad",
() => { },
() => { }
);
return;
}
if (window.Android && typeof window.Android.startGoogleLogin === "function") {
window.Android.startGoogleLogin();
return;
}
login();
}}
type="submit"
style={{ marginTop: "10px", width: "100%" }}
/>
);
}
@@ -0,0 +1,65 @@
@media (max-width: 800px) {
.organizationHeader {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin-left: 0px;
margin-right: 0px;
margin-top: 0px;
overflow: hidden;
}
.organizationHeader div {
border: solid 1px var(--gray-dark);
border-radius: 0px;
width: 100%;
height: 150px;
margin-left: 30px;
margin-right: 30px;
}
.organizationHeader h1 {
text-align: left;
padding-left: 15px;
padding-top: 15px;
}
.organizationHeader p {
text-align: left;
padding-left: 15px;
padding-top: 10px;
}
}
@media (min-width: 801px) {
.organizationHeader {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin-left: 30px;
margin-right: 30px;
margin-top: 30px;
overflow: hidden;
}
.organizationHeader div {
border: solid 1px var(--gray-dark);
border-radius: 5px;
width: 100%;
height: 150px;
}
.organizationHeader h1 {
text-align: left;
padding-left: 20px;
padding-top: 15px;
}
.organizationHeader p {
text-align: left;
padding-left: 20px;
padding-top: 10px;
}
}
@@ -0,0 +1,48 @@
"use client";
import { PublicOrganizationView } from "@core/Models/Company.model";
import style from "./OrganizationHeader.module.css";
import { useNavigation } from "@core/app/hooks/goto";
export interface OrganizationHeaderProps {
organization: PublicOrganizationView;
}
export default function OrganizationHeader(props: OrganizationHeaderProps) {
const { organization } = props;
const { goTo } = useNavigation();
return (
<div
className={style.organizationHeader}
onClick={() => goTo("/landing/org/" + organization.id)}
>
<div
style={{
...(organization.headerFile ? { backgroundImage: `url(${organization.headerFile})` } : {}),
backgroundColor: organization.headerColor || 'transparent',
backgroundSize: "cover",
backgroundRepeat: "no-repeat",
backgroundPosition: "center",
cursor: "pointer",
}}
>
<h1
style={{
color: organization.headerFontColor,
textShadow: `2px 2px 2px ${organization.headerFontShadowColor}`,
textTransform: "capitalize",
}}
>
{organization.name}
</h1>
<p
style={{
color: organization.headerFontColor,
textShadow: `2px 2px 2px ${organization.headerFontShadowColor}`,
}}
>
{organization.description}
</p>
</div>
</div>
);
}
@@ -0,0 +1,156 @@
"use client";
import React, { useRef, useEffect, useState } from "react";
import NextIcon from "@mui/icons-material/NavigateNext";
import PreviousIcon from "@mui/icons-material/NavigateBefore";
import { motion } from "framer-motion";
import ProductItem from "../../ProductItem/ProductItem";
import { PublicServiceView } from "@core/Models/Service.model";
import { IconButton } from "@mui/material";
export interface ProductSectionProps {
title: string;
data: PublicServiceView[];
showViewMore?: boolean;
viewMoreColor?: string;
showButtons?: boolean;
onViewMore?: () => void;
}
const ProductSection = (props: ProductSectionProps): React.ReactElement => {
const {
title,
data = [],
showViewMore = false,
viewMoreColor = "var(--wine-red)",
showButtons = false,
} = props;
const containerRef = useRef<HTMLDivElement | null>(null);
const [constraints, setConstraints] = useState({ left: 0, right: 0 });
const [isDraging, setIsDraging] = useState(false);
useEffect(() => {
const container = containerRef.current;
if (container) {
const containerWidth = container.offsetWidth;
const contentWidth = container.scrollWidth;
// Calcula las restricciones dinámicas
setConstraints({ left: -(contentWidth - containerWidth), right: 0 });
}
}, []);
const handleWheelScroll = (event: React.WheelEvent<HTMLDivElement>) => {
const container = event.currentTarget;
container.scrollLeft += event.deltaY;
return false;
};
const handleClickNext = () => {
const container = containerRef.current;
if (container) {
container.scrollLeft += 300;
}
};
const handleClickPrevious = () => {
const container = containerRef.current;
if (container) {
container.scrollLeft -= 300;
}
};
return (
<>
{data.length > 0 && (
<>
<div
style={{
fontSize: "1.5rem",
fontWeight: "bold",
marginTop: "25px",
marginBottom: "5px",
marginLeft: "10px",
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div style={{ paddingBottom: "10px" }}>{title}</div>
<div style={{ display: "flex", alignItems: "center" }}>
{showViewMore && (
<div
style={{
cursor: "pointer",
fontSize: "1rem",
color: viewMoreColor,
}}
onClick={() => {
if (props.onViewMore) props.onViewMore();
}}
>
Ver más
</div>
)}
{showButtons && (
<>
<IconButton
style={{ color: viewMoreColor }}
onClick={handleClickPrevious}
>
<PreviousIcon />
</IconButton>
<IconButton
style={{ color: viewMoreColor }}
onClick={handleClickNext}
>
<NextIcon />
</IconButton>
</>
)}
</div>
</div>
<div
ref={containerRef}
style={{
width: "100%",
overflow: "hidden",
paddingLeft: "10px",
paddingRight: "10px",
paddingTop: "0px",
paddingBottom: "10px",
}}
>
<motion.div
drag="x"
dragConstraints={constraints}
style={{
display: "flex",
gap: "20px",
padding: "0 0px",
cursor: "grab",
}}
onWheel={handleWheelScroll}
onDragStart={() => {
setIsDraging(true);
}}
onDragEnd={() => {
setIsDraging(false);
}}
>
{data.map((product) => (
<ProductItem
key={product.id}
product={product}
dragging={isDraging}
/>
))}
</motion.div>
</div>
</>
)}
</>
);
};
export default ProductSection;
@@ -0,0 +1,289 @@
@media (max-width: 800px) {
.slider {
position: relative;
width: 100%;
height: 550px;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-end;
/* border: solid 1px yellow; */
}
.buttonLeft {
display: none;
}
.buttonRight {
display: none;
}
.sliderFrameContainer {
position: absolute;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
color: white;
text-shadow: 0 0 5px black;
box-sizing: border-box;
background-size: contain;
background-repeat: no-repeat;
background-position: bottom right;
margin-left: 40px;
margin-right: 40px;
height: 100%;
/* border: solid 1px red; */
}
.sliderLeftData {
margin-bottom: 50px;
}
.sliderLeftData h1 {
font-size: 1.5rem;
text-align: left;
}
.sliderLeftData p {
font-size: 0.8rem;
text-align: left;
margin-top: 10px;
margin-bottom: 40px;
}
.sliderLeftData button {
padding: 10px 20px;
background-color: #ff6200;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
outline: none;
}
.sliderRightData {
display: flex;
width: 100%;
height: 100%;
}
.sliderRightData img {
max-width: 450px;
}
.sliderDots {
width: 12px;
height: 12px;
border-radius: 50%;
cursor: pointer;
}
}
@media (min-width: 801px) and (max-width: 1200px) {
.slider {
position: relative;
width: 100%;
max-width: 1100px;
height: 350px;
margin: auto;
border-radius: 10px;
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: flex-end;
}
.buttonLeft {
position: absolute;
left: 0px;
top: 50%;
z-index: 2;
transform: translateY(-50%);
background: rgba(0, 0, 0, 0.5);
color: white;
border: none;
border-radius: 50%;
width: 40px;
height: 40px;
cursor: pointer;
}
.buttonRight {
position: absolute;
right: -40;
top: 50%;
z-index: 2;
transform: translateY(-50%);
background: rgba(0, 0, 0, 0.5);
color: white;
border: none;
border-radius: 50%;
width: 40px;
height: 40px;
cursor: pointer;
}
.sliderFrameContainer {
position: absolute;
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-around;
align-items: flex-end;
color: white;
text-shadow: 0 0 5px black;
padding: 0 50px;
box-sizing: border-box;
background-size: contain;
background-repeat: no-repeat;
background-position: bottom 0px right 40px;
}
.sliderLeftData {
margin-bottom: 50px;
}
.sliderLeftData h1 {
font-size: 1.8rem;
text-align: left;
max-width: 40%;
}
.sliderLeftData p {
font-size: 0.9rem;
text-align: left;
max-width: 50%;
margin-top: 10px;
margin-bottom: 40px;
}
.sliderLeftData button {
padding: 10px 20px;
background-color: #ff6200;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
outline: none;
}
.sliderRightData {
display: flex;
}
.sliderRightData img {
max-width: 500px;
}
.sliderDots {
width: 12px;
height: 12px;
border-radius: 50%;
cursor: pointer;
}
}
@media (min-width: 1201px) {
.slider {
position: relative;
width: 100%;
max-width: 1100px;
height: 450px;
margin: auto;
border-radius: 10px;
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: flex-end;
}
.buttonLeft {
position: absolute;
left: -40px;
top: 50%;
z-index: 2;
transform: translateY(-50%);
background: rgba(0, 0, 0, 0.5);
color: white;
border: none;
border-radius: 50%;
width: 40px;
height: 40px;
cursor: pointer;
}
.buttonRight {
position: absolute;
right: -40;
top: 50%;
z-index: 2;
transform: translateY(-50%);
background: rgba(0, 0, 0, 0.5);
color: white;
border: none;
border-radius: 50%;
width: 40px;
height: 40px;
cursor: pointer;
}
.sliderFrameContainer {
position: absolute;
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-around;
align-items: flex-end;
color: white;
text-shadow: 0 0 5px black;
padding: 0 20px;
box-sizing: border-box;
background-size: contain;
background-repeat: no-repeat;
background-position: bottom 0px right 40px;
}
.sliderLeftData {
margin-bottom: 50px;
}
.sliderLeftData h1 {
font-size: 2.4rem;
text-align: left;
max-width: 40%;
}
.sliderLeftData p {
font-size: 1.2rem;
text-align: left;
max-width: 50%;
margin-top: 10px;
margin-bottom: 40px;
}
.sliderLeftData button {
padding: 10px 20px;
background-color: #ff6200;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
outline: none;
}
.sliderRightData {
display: flex;
}
.sliderRightData img {
max-width: 500px;
}
.sliderDots {
width: 12px;
height: 12px;
border-radius: 50%;
cursor: pointer;
}
}
@@ -0,0 +1,171 @@
"use client";
import React, { useState, useEffect } from "react";
import { motion, AnimatePresence } from "motion/react";
import ArrowBackIosIcon from "@mui/icons-material/ArrowBackIos";
import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos";
import style from "./Slider.module.css";
import AnimatedContainer from "../../AnimatedConainer/AnimatedContainer";
import { useHeaderStore } from "@core/Store/Header.Store";
import { useNavigation } from "@core/app/hooks/goto";
import { sliderFrames } from "@core/app/Services/SliderFrames";
const frames = sliderFrames;
const FrameSlider: React.FC = () => {
const headerState = useHeaderStore();
const { goTo } = useNavigation();
const [currentIndex, setCurrentIndex] = useState<number>(0);
const [direction, setDirection] = useState<number>(0);
const [intervalId, setIntervalId] = useState<NodeJS.Timeout>();
const changeFrame = (newDirection: number) => {
setDirection(newDirection);
setCurrentIndex((prevIndex) => (prevIndex + newDirection + frames.length) % frames.length);
};
const goToFrame = (index: number) => {
setDirection(index > currentIndex ? 1 : -1);
setCurrentIndex(index);
};
const variants = {
enter: (direction: number) => ({
x: direction > 0 ? 300 : -300,
opacity: 0,
}),
center: { x: 0, opacity: 1 },
exit: (direction: number) => ({
x: direction > 0 ? -300 : 300,
opacity: 0,
}),
};
useEffect(() => {
const interval = setInterval(() => {
changeFrame(1);
}, 5000);
setIntervalId(interval);
return () => clearInterval(interval);
}, []);
return (
<AnimatedContainer
className={style.slider}
color="#4d19ae"
minSize={150}
maxSize={500}
style={{
background:
"linear-gradient(180deg, var(--wine-darkest) 0%, var(--wine-dark) 100%)",
}}
>
<button
onClick={() => {
changeFrame(-1);
clearInterval(intervalId);
}}
className={style.buttonLeft}
aria-label="Anterior"
>
<ArrowBackIosIcon sx={{ marginLeft: "10px", marginTop: "4px" }} />
</button>
<button
onClick={() => {
changeFrame(1);
clearInterval(intervalId);
}}
className={style.buttonRight}
aria-label="Siguiente"
>
<ArrowForwardIosIcon sx={{ marginLeft: "2px", marginTop: "4px" }} />
</button>
<AnimatePresence initial={false} custom={direction}>
<motion.div
key={frames[currentIndex].id}
custom={direction}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: 0.8 }}
drag="x"
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.2}
onDragEnd={(event, info) => {
if (info.offset.x > 100) changeFrame(-1);
else if (info.offset.x < -100) changeFrame(1);
}}
className={style.sliderFrameContainer}
style={{
backgroundImage: `url(${frames[currentIndex].image})`,
}}
onClick={() => {
clearInterval(intervalId);
}}
onTouchStart={() => {
clearInterval(intervalId);
}}
>
<div className={style.sliderLeftData}>
<h1>{frames[currentIndex].title}</h1>
<p>{frames[currentIndex].description}</p>
{frames[currentIndex].buttonText && (
<div>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
animate={{
scale: [1, 1.1, 1],
transition: {
duration: 1,
repeat: Infinity,
repeatType: "loop",
},
}}
onClick={() => {
headerState.setPublicFindCategoryId(
frames[currentIndex].showCategoryId
);
goTo("/landing/find");
}}
>
{frames[currentIndex].buttonText}
</motion.button>
</div>
)}
</div>
<div className={style.sliderRightData}>&nbsp;</div>
</motion.div>
</AnimatePresence>
<div
style={{
position: "absolute",
bottom: 20,
left: "50%",
transform: "translateX(-50%)",
display: "flex",
gap: "10px",
}}
>
{frames.map((_, index) => (
<div
key={index}
onClick={() => goToFrame(index)}
style={{
backgroundColor:
currentIndex === index ? "white" : "rgba(255, 255, 255, 0.5)",
}}
className={style.sliderDots}
></div>
))}
</div>
</AnimatedContainer>
);
};
export default FrameSlider;
@@ -0,0 +1,24 @@
.imagePickerContainer {
border-radius: 5px;
background-color: var(--gray-light);
border: solid 1px var(--black);
overflow: hidden;
cursor: pointer;
overflow: hidden;
position: relative;
}
.imagePickerContainer img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
.imagePickerBorderNone {
border: none;
}
.imagePickerBorderSolid {
border: solid 8px var(--wine-red);
}
@@ -0,0 +1,121 @@
"use client";
import { useEffect, useState } from "react";
import classNames from "classnames";
import style from "./ImagePicker.module.css";
import { useAlert } from "@core/Store/Alert.Store";
export type ImagePickerBorder = "solid" | "none";
export interface ImagePickerProps {
name?: string;
src: string;
alt: string;
border?: ImagePickerBorder;
style?: React.CSSProperties;
readOnly?: boolean;
mbLimit?: number;
imageSizeSrc?: string;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
export default function ImagePicker(props: ImagePickerProps): React.ReactElement {
const { readOnly = true, border = "solid", mbLimit = 1, imageSizeSrc = "" } = props;
const [imageUrl, setImageUrl] = useState(props.src);
const alert = useAlert();
const getClassNames = () => {
return classNames(
style.imagePickerContainer,
border === "none" ? style.avatarBorderNone : style.avatarBorderSolid
);
};
const isImageUrl = (url: string): boolean => {
const extensionValidation = url.match(/\.(jpeg|jpg|gif|png|webp|)$/) != null;
if (url.indexOf("blob:") >= 0) {
return true;
}
return extensionValidation;
};
useEffect(() => {
setImageUrl(props.src);
}, [props.src]);
useEffect(() => {
console.log(imageUrl, isImageUrl(imageUrl));
}, [imageUrl]);
return (
<div className={getClassNames()} style={props.style}>
{!readOnly && (
<input
id={props.name}
name={props.name}
accept="image/*;capture=camera"
type="file"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const target = e.target;
if (!target.files) {
alert.showWarning("No se ha seleccionado un archivo de imagen");
return;
}
if (target.files && target.files.length > 0) {
const file = target.files[0];
if (!file.type.startsWith("image/")) {
alert.showWarning("El archivo seleccionado no es una imagen");
return;
}
}
const file = target.files[0];
const isUnder5MB = file.size <= mbLimit * 1024 * 1024;
if (!isUnder5MB) {
alert.showWarning(
`El archivo seleccionado debe ser menor de ${mbLimit} MB`
);
return;
}
setImageUrl(URL.createObjectURL(file));
if (props.onChange) {
props.onChange(e);
}
}}
style={{
width: "100%",
height: "100%",
opacity: 0,
position: "absolute",
cursor: "pointer",
zIndex: 10,
}}
/>
)}
{isImageUrl(imageUrl) && <img src={imageUrl} alt={props.alt} />}
{!isImageUrl(imageUrl) && isImageUrl(imageSizeSrc) && (
<div
style={{
position: "relative",
top: 0,
left: 0,
width: "100%",
height: "100%",
zIndex: 5,
backgroundImage: `url(${imageSizeSrc})`,
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
}}
>
&nbsp;
</div>
)}
</div>
);
}
@@ -0,0 +1,23 @@
.loading {
position: fixed;
align-items: center;
justify-content: center;
left: 0px;
right: 0px;
top: 0px;
bottom: 0px;
background-color: var(--loading-background);
background-image: url("/loading.gif");
background-repeat: no-repeat;
background-position: center;
background-size: 100px 100px;
z-index: 100000;
}
.loadingVisible {
display: flex;
}
.loadingHidden {
display: none;
}
@@ -0,0 +1,34 @@
"use client";
import classNames from "classnames";
import { useEffect, useState } from "react";
import { EVENT_TYPES, useEventHandlerStore } from "@store/EventHandler.Store";
import style from "./Loading.module.css";
export default function Loading() {
const eventHandler = useEventHandlerStore();
const [classesNames, setClassesNames] = useState(
classNames(style.loading, style.loadingHidden)
);
useEffect(() => {
switch (eventHandler.eventType) {
case EVENT_TYPES.SAVE:
setClassesNames(classNames(style.loading, style.loadingVisible));
break;
case EVENT_TYPES.FILTER:
setClassesNames(classNames(style.loading, style.loadingVisible));
break;
case EVENT_TYPES.LOADING:
setClassesNames(classNames(style.loading, style.loadingVisible));
break;
default:
setClassesNames(classNames(style.loading, style.loadingHidden));
break;
}
}, [eventHandler.eventType]);
return (
<>
<div className={classesNames}>&nbsp;</div>
</>
);
}
@@ -0,0 +1,23 @@
.locationContainer {
position: fixed;
background-color: var(--white);
left: 40px;
right: 0px;
height: 70px;
overflow: hidden;
z-index: 1;
}
.locationTitle {
display: flex;
align-items: center;
}
.locationTitle h1 {
font-size: 24px;
}
@media (max-width: 600px) {
.locationContainer {
left: 6px;
}
}
@@ -0,0 +1,66 @@
"use client";
import { useRouter } from "next/navigation";
import style from "./Location.module.css";
import { useLocationStore } from "@store/Location.Store";
import Breadcrumbs from "@mui/material/Breadcrumbs";
import Link from "@mui/material/Link";
import HomeIcon from "@mui/icons-material/Home";
export interface LocationProps {
bottomSeperation?: string;
}
export default function Location(props: LocationProps) {
const { bottomSeperation = "55px" } = props;
const router = useRouter();
const location = useLocationStore();
const goTo = (path: string) => {
router.push(path);
};
return (
<>
<div className={style.locationContainer}>
<div role="presentation">
<Breadcrumbs key={"breadcrumb"} aria-label="breadcrumb">
{location.path.slice(0, -1).map((item, index) => (
//si es el ultimo elemento no mostrar el icono
<Link
key={"link" + index}
underline="hover"
sx={{ display: "flex", alignItems: "center", cursor: "pointer" }}
color="inherit"
onClick={() => goTo(item.url)}
>
{index == 0 && (
<HomeIcon
key={"icon" + index}
sx={{ mr: 0.5 }}
fontSize="inherit"
/>
)}
{index != 0 && item.icon}
{item.title}
</Link>
))}
</Breadcrumbs>
</div>
<div className={style.locationTitle}>
{location.last()?.icon}
<h1>{location.last()?.title}</h1>
</div>
</div>
<div
style={{
height: bottomSeperation,
minHeight: bottomSeperation,
}}
>
&nbsp;
</div>
</>
);
}
@@ -0,0 +1,43 @@
"use client";
import { MapContainer, TileLayer, Marker, Popup } from "react-leaflet";
import L from "leaflet";
import "leaflet/dist/leaflet.css";
import { PublicOrganizationView } from "@core/Models/Company.model";
export interface LocationMapProps {
position: [number, number];
orgData: PublicOrganizationView;
width?: string;
height?: string;
}
export default function LocationMap(props: LocationMapProps) {
const { position } = props;
const defaultIcon = L.icon({
iconUrl: "/location.png",
iconSize: [32, 48], // Tamaño del icono
iconAnchor: [16, 48], // Punto de anclaje del icono
popupAnchor: [1, -34], // Punto del popup respecto al icono
shadowSize: [0, 0], // Tamaño de la sombra
});
return (
<MapContainer
center={position}
zoom={12}
style={{
width: props.width || "100%",
height: props.height || "250px",
borderRadius: "5px",
zIndex: 0
}}
>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
<Marker position={position} icon={defaultIcon}>
<Popup>
<h4>{props.orgData.name}</h4>
<br /> {props.orgData.description}
</Popup>
</Marker>
</MapContainer>
);
}
@@ -0,0 +1,12 @@
"use client";
import { LOGIN_ACTIONS, useLoginActionStore } from "@core/Store/LoginAction.Store";
import { useEffect } from "react";
export default function DefaultLoginAction() {
const loginAction = useLoginActionStore();
useEffect(() => {
loginAction.setLoginAction(LOGIN_ACTIONS.NONE);
loginAction.setUrl("/");
}, []);
return <></>;
}
+56
View File
@@ -0,0 +1,56 @@
import { ThemeProvider } from "@emotion/react";
import Divider from "@mui/material/Divider";
import MaterialMenu from "@mui/material/Menu";
import MenuList from "@mui/material/MenuList";
import MenuItem from "@mui/material/MenuItem";
import ListItemText from "@mui/material/ListItemText";
import ListItemIcon from "@mui/material/ListItemIcon";
import { useMenuStore } from "@core/Store/Menu.Store";
import turnosXpressTheme from "@core/app/theme/turnosXpress";
export default function Menu() {
const menu = useMenuStore();
const handleClose = () => {
menu.hide();
};
return (
menu.visible && (
<ThemeProvider theme={turnosXpressTheme}>
<MaterialMenu
id="tx-menu"
aria-labelledby="button-menu"
anchorEl={menu.anchorEl}
open={menu.visible}
onClose={handleClose}
anchorOrigin={{
vertical: "top",
horizontal: "right",
}}
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
>
<MenuList dense>
{menu.items.map((item, index) =>
item.divider ? (
<Divider key={`divider-${index}`} />
) : (
<MenuItem
key={item.text}
onClick={item.onClick}
sx={{ height: "40px" }}
>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText>{item.text}</ListItemText>
</MenuItem>
)
)}
</MenuList>
</MaterialMenu>
</ThemeProvider>
)
);
}
@@ -0,0 +1,45 @@
.buttonMessenger {
position: fixed;
display: flex;
align-items: center;
justify-content: center;
bottom: 400px;
right: 30px;
background-color: var(--wine-dark);
color: var(--white);
border-radius: 50%;
z-index: 2000;
cursor: pointer;
transition: width 0.25s ease-in-out, height 0.25s ease-in-out;
animation: buttonSabePulseShadow 1.5s ease-in-out infinite;
}
.buttonMessengerVisible {
width: 64px;
height: 64px;
}
.buttonMessengerVisible svg {
display: flex;
opacity: 1;
}
.buttonMessengerHidden {
width: 0px;
height: 0px;
}
.buttonMessengerHidden svg {
display: none;
opacity: 0;
}
@keyframes buttonSabePulseShadow {
0%,
100% {
box-shadow: 0px 2px 12px -4px var(--wine-red);
}
50% {
box-shadow: 0px 2px 20px -2px var(--wine-red);
}
}
@@ -0,0 +1,39 @@
"use client";
import { useEffect, useState } from "react";
import style from "./MessengerIcon.module.css";
import classNames from "classnames";
import Image from "next/image";
import { useNavigation } from "@core/app/hooks/goto";
import { useSessionStore } from "@core/Store/Sesion.Store";
export default function MessengerIcon(): React.ReactElement {
const SessionInfo = useSessionStore();
const { goTo } = useNavigation();
const [buttonClassNames] = useState(
classNames(style.buttonMessenger, style.buttonMessengerVisible)
);
const [hidden, setHidden] = useState(true);
useEffect(() => {
if (!SessionInfo.loged) {
setHidden(true);
return;
}
setHidden(true);
}, [SessionInfo.userId]);
return (
<>
{!hidden && (
<div
className={buttonClassNames}
onClick={() => {
goTo("/messenger");
}}
>
<Image src={"/tx-messenger-icon.svg"} alt="messenger" width={64} height={69} />
</div>
)}
</>
);
}
@@ -0,0 +1,76 @@
.card {
background: #ffffff;
border-radius: 16px;
padding: 24px;
width: 200px;
height: 160px;
display: flex;
flex-direction: column;
justify-content: space-between;
box-shadow: 0px 4px 15px rgba(0, 0, 0, 0.05);
border: 1px solid rgba(0, 0, 0, 0.04);
transition: transform 0.2s ease, box-shadow 0.2s ease;
position: relative;
overflow: hidden;
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0px 10px 25px rgba(0, 0, 0, 0.08);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
.title {
font-size: 14px;
font-weight: 600;
color: #6b7280;
margin: 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.valueContainer {
display: flex;
align-items: baseline;
gap: 8px;
}
.value {
font-size: 36px;
font-weight: 800;
color: #111827;
margin: 0;
line-height: 1;
}
.maxValue {
font-size: 14px;
font-weight: 500;
color: #9ca3af;
}
.progressContainer {
width: 100%;
height: 6px;
background-color: #f3f4f6;
border-radius: 10px;
overflow: hidden;
position: relative;
margin-top: 10px;
}
.progressBar {
height: 100%;
border-radius: 10px;
transition: width 1s cubic-bezier(0.4, 0, 0.2, 1);
}
.safe { background-color: var(--green-light, #10b981); }
.warning { background-color: var(--yellow, #f59e0b); }
.danger { background-color: var(--error, #ef4444); }
.infinite { background-color: var(--wine-red, #6366f1); }
@@ -0,0 +1,66 @@
"use client";
import React, { useEffect, useState } from "react";
import classNames from "classnames";
import styles from "./MetricGraph.module.css";
export interface MetricGraphProps {
value: number;
maxValue?: number;
title: string;
}
export default function MetricGraph(props: MetricGraphProps) {
const { maxValue = 100 } = props;
const [animatedWidth, setAnimatedWidth] = useState(0);
const isInfinite = maxValue === -1;
const formatValueMax = (value: number) => {
if (value === -1) {
return "∞";
}
return value;
};
const percentage = isInfinite ? 100 : Math.min(100, (props.value / maxValue) * 100);
let statusClass = styles.safe;
if (isInfinite) {
statusClass = styles.infinite;
} else {
if (percentage >= 100) {
statusClass = styles.danger;
} else if (percentage >= 80) {
statusClass = styles.warning;
}
}
useEffect(() => {
// Trigger animation after mount
const timer = setTimeout(() => {
setAnimatedWidth(percentage);
}, 100);
return () => clearTimeout(timer);
}, [percentage]);
return (
<div className={styles.card}>
<div className={styles.header}>
<h4 className={styles.title}>{props.title}</h4>
</div>
<div>
<div className={styles.valueContainer}>
<p className={styles.value}>{props.value}</p>
<p className={styles.maxValue}>/ {formatValueMax(maxValue)}</p>
</div>
<div className={styles.progressContainer}>
<div
className={classNames(styles.progressBar, statusClass)}
style={{ width: `${animatedWidth}%` }}
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,37 @@
.organizationContent {
position: absolute;
left: 0px;
right: 0px;
bottom: 0px;
background-color: white;
}
.organizationContentLarge {
top: 150px;
}
.organizationContentSmall {
top: 80px;
}
.organizationMargins {
position: absolute;
overflow-x: hidden;
scrollbar-width: none;
overflow-y: auto;
left: 40px;
right: 40px;
top: 10px;
bottom: 0px;
}
@media (max-width: 600px) {
.organizationMargins {
left: 6px;
right: 6px;
}
}
.organizationMargins::-webkit-scrollbar {
display: none;
}
@@ -0,0 +1,36 @@
import {
ORGANIZATION_HEADER_MODES,
useOrganizationHeaderStore,
} from "@core/Store/OrganizationHeader.Store";
import style from "./OrganizationContent.module.css";
import classNames from "classnames";
export interface OrganizationContentParams {
children: React.ReactNode;
}
export default function OrganizationContent(props: OrganizationContentParams) {
const organizationHeader = useOrganizationHeaderStore();
const getClassNames = () => {
const classList: classNames.ArgumentArray = [];
classList.push(style.organizationContent);
if (organizationHeader.state === ORGANIZATION_HEADER_MODES.LARGE) {
classList.push(style.organizationContentLarge);
} else if (organizationHeader.state === ORGANIZATION_HEADER_MODES.SMALL) {
classList.push(style.organizationContentSmall);
}
return classNames(classList);
};
return (
<>
<div className={getClassNames()}>
<div className={style.organizationMargins}>{props.children}</div>
</div>
</>
);
}
@@ -0,0 +1,224 @@
"use client";
import React, { useEffect, useState } from "react";
import style from "./orgheader.module.css";
import { MyOranizationsView } from "@core/Models/Company.model";
import IconButton from "@mui/material/IconButton";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import ResizeIcon from "@mui/icons-material/AspectRatio";
import PaletteIcon from "@mui/icons-material/Palette";
import { useMenuStore } from "@core/Store/Menu.Store";
import {
ORGANIZATION_HEADER_MODES,
useOrganizationHeaderStore,
} from "@store/OrganizationHeader.Store";
import SettingsIcon from "@mui/icons-material/Settings";
import classNames from "classnames";
import { useNavigation } from "@core/app/hooks/goto";
import ShareOutlinedIcon from "@mui/icons-material/ShareOutlined";
import Dialog from "@mui/material/Dialog";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
import CloseIcon from "@mui/icons-material/Close";
import { getSlug } from "@core/app/helpers/Slug";
import {
FacebookShareButton,
WhatsappShareButton,
TwitterShareButton,
EmailShareButton,
TelegramShareButton,
FacebookIcon,
WhatsappIcon as WhatsAppShareIcon,
TwitterIcon,
EmailIcon,
TelegramIcon
} from "react-share";
export interface OrganizationHeaderParams {
data: MyOranizationsView;
}
export default function OrganizationHeader(props: OrganizationHeaderParams): React.ReactElement {
const menu = useMenuStore();
const organizationHeader = useOrganizationHeaderStore();
const { goTo } = useNavigation();
const [headerColor, setHeaderColor] = useState(props.data.headerColor);
const [headerFontColor, setHeaderFontColor] = useState(props.data.headerFontColor);
const [headerFontShadowColor, setHeaderFontShadowColor] = useState(
props.data.headerFontShadowColor
);
const [showSharePopup, setShowSharePopup] = useState(false);
useEffect(() => {
menu.clear();
menu.add({
text: "Perzonalizar",
onClick: () => {
goTo("/admin/org/profile/" + props.data.id + "/customize");
},
icon: <PaletteIcon />,
});
menu.add({
text: "Compartir",
onClick: () => {
setShowSharePopup(true);
},
icon: <ShareOutlinedIcon />,
});
menu.divider();
menu.add({
text: "Configuración Avanzada",
onClick: () => {
goTo("/admin/org/profile/" + props.data.id + "/advanced");
},
icon: <SettingsIcon />,
});
}, []);
useEffect(() => {
setHeaderColor(organizationHeader.headerColor);
setHeaderFontColor(organizationHeader.headerFontColor);
setHeaderFontShadowColor(organizationHeader.headerFontShadowColor);
}, [
organizationHeader.headerColor,
organizationHeader.headerFontColor,
organizationHeader.headerFontShadowColor,
]);
const getClassNames = () => {
const classList: classNames.ArgumentArray = [];
classList.push(style.organizationHeader);
if (organizationHeader.state === ORGANIZATION_HEADER_MODES.LARGE) {
classList.push(style.organizationHeaderLarge);
} else if (organizationHeader.state === ORGANIZATION_HEADER_MODES.SMALL) {
classList.push(style.organizationHeaderSmall);
}
return classNames(classList);
};
const getToggleClassNames = () => {
const classList: classNames.ArgumentArray = [];
if (organizationHeader.state === ORGANIZATION_HEADER_MODES.LARGE) {
classList.push(style.buttonTogleSizeLarge);
} else if (organizationHeader.state === ORGANIZATION_HEADER_MODES.SMALL) {
classList.push(style.buttonTogleSizeSmall);
}
return classNames(classList);
};
return (
<>
<div
className={getClassNames()}
style={{
backgroundColor: headerColor || 'transparent',
...(props.data.headerFile ? { backgroundImage: `url(${props.data.headerFile})` } : {}),
}}
>
<div>
<h1
style={{
color: headerFontColor,
textShadow: `1px 1px 2px ${headerFontShadowColor}`,
textTransform: "capitalize",
}}
>
{props.data.name}
</h1>
<span
style={{
color: headerFontColor,
textShadow: `2px 2px 2px ${headerFontShadowColor}`,
}}
>
{props.data.description}
</span>
</div>
<div>&nbsp;</div>
</div>
<div className={style.buttonMenu}>
<IconButton
aria-label="more"
id="button-menu"
aria-controls={menu.visible ? "tx-menu" : undefined}
aria-expanded={menu.visible ? "true" : undefined}
aria-haspopup="true"
onClick={() => {
menu.setAnchorEl(document.getElementById("button-menu")!);
menu.show();
}}
sx={{
color: headerFontColor,
textShadow: `1px 1px 2px ${headerFontShadowColor}`,
opacity: 0.8,
}}
>
<MoreVertIcon />
</IconButton>
</div>
<div className={getToggleClassNames()}>
<IconButton
aria-label="more"
id="button-menu"
aria-controls={menu.visible ? "tx-menu" : undefined}
aria-expanded={menu.visible ? "true" : undefined}
aria-haspopup="true"
onClick={() => {
organizationHeader.toggleState();
}}
sx={{
color: headerFontColor,
textShadow: `1px 1px 2px ${headerFontShadowColor}`,
opacity: 0.8,
}}
>
<ResizeIcon />
</IconButton>
</div>
<Dialog open={showSharePopup} onClose={() => setShowSharePopup(false)} maxWidth="sm" fullWidth>
<DialogTitle sx={{ m: 0, p: 2, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontWeight: "bold" }}>Compartir Organización</span>
<IconButton
aria-label="close"
onClick={() => setShowSharePopup(false)}
sx={{ color: (theme) => theme.palette.grey[500] }}
>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent dividers>
<div style={{ textAlign: "center", marginBottom: "30px", fontSize: "16px", color: "var(--black-light)" }}>
Compartí el perfil de <strong>{props.data.name}</strong> para que tus clientes puedan sacar turnos fácilmente.
</div>
<div style={{ display: "flex", justifyContent: "center", flexDirection: "row", alignItems: "center", gap: "20px", paddingBottom: "20px" }}>
<WhatsappShareButton title={"Reserva tu turno en " + props.data.name} separator=":: " url={window.location.origin + "/" + getSlug(props.data.name)}>
<WhatsAppShareIcon size={54} round />
</WhatsappShareButton>
<FacebookShareButton title={"Reserva tu turno en " + props.data.name} url={window.location.origin + "/" + getSlug(props.data.name)}>
<FacebookIcon size={54} round />
</FacebookShareButton>
<EmailShareButton title={"Reserva tu turno en " + props.data.name} separator=":: " url={window.location.origin + "/" + getSlug(props.data.name)}>
<EmailIcon size={54} round />
</EmailShareButton>
<TelegramShareButton title={"Reserva tu turno en " + props.data.name} url={window.location.origin + "/" + getSlug(props.data.name)}>
<TelegramIcon size={54} round />
</TelegramShareButton>
<TwitterShareButton title={"Reserva tu turno en " + props.data.name} url={window.location.origin + "/" + getSlug(props.data.name)}>
<TwitterIcon size={54} round />
</TwitterShareButton>
</div>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,53 @@
.organizationHeader {
position: fixed;
left: 0px;
right: 0px;
top: 60px;
/*background-color: var(--no-organization-header);*/
display: flex;
align-items: flex-end;
justify-content: space-between;
padding-left: 40px;
padding-right: 40px;
padding-bottom: 5px;
border-bottom: solid 1px var(--no-organization-header-border);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
.organizationHeaderLarge {
height: 150px;
}
.organizationHeaderSmall {
height: 80px;
}
.organizationHeader h1 {
font-size: 20px;
/*text-shadow: 1px 1px 2px var(--white);*/
}
.organizationHeader span {
font-size: 14px;
/*text-shadow: 2px 2px 2px var(--white);*/
}
.buttonMenu {
position: absolute;
right: 5px;
top: 10px;
}
.buttonTogleSizeLarge {
position: absolute;
right: 5px;
top: 112px;
}
.buttonTogleSizeSmall {
position: absolute;
right: 45px;
top: 10px;
}
@@ -0,0 +1,15 @@
.paginationControls {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: 10px;
width: 100%;
}
.paginationLabel {
font-weight: 600;
font-size: 14px;
color: var(--gray-dark);
cursor: pointer;
}
@@ -0,0 +1,51 @@
import style from "./PaginationControls.module.css";
import IconButton from "@mui/material/IconButton";
import ArrowForwardIcon from "@mui/icons-material/ArrowForwardIos";
import ArrowBackwardIcon from "@mui/icons-material/ArrowBackIos";
import { useEffect } from "react";
export interface PaginationControlProps {
onChange?: (page: number, pageZize: number) => void;
current: number;
size: number;
max: number;
}
export default function PaginationControls(props: PaginationControlProps) {
const { current, size, max, onChange = () => {} } = props;
useEffect(() => {}, [current]);
const handlePrevious = () => {
let newPage = current - 1;
if (newPage < 1) {
newPage = 1;
return;
}
if (current > 1) {
onChange(newPage, size);
}
};
const handleNext = () => {
if (current < max) {
onChange(current + 1, size);
}
};
return (
<div className={style.paginationControls}>
<IconButton onClick={handlePrevious}>
<ArrowBackwardIcon sx={{ fontSize: 24 }} />
</IconButton>
<span className={style.paginationLabel}>
{current} / {max}
</span>
<IconButton onClick={handleNext}>
<ArrowForwardIcon sx={{ fontSize: 24 }} />
</IconButton>
</div>
);
}
@@ -0,0 +1,27 @@
import CreditCardIcon from "@mui/icons-material/CreditCardOutlined";
import DebitCardIcon from "@mui/icons-material/LocalAtmOutlined";
import CashIcon from "@mui/icons-material/AttachMoneyOutlined";
import BankTransferIcon from "@mui/icons-material/AccountBalanceOutlined";
import UserAccountIcon from "@mui/icons-material/WalletOutlined";
import OtherPaymentIcon from "@mui/icons-material/ShoppingCartOutlined";
import { CLIENT_ACCOUNT_PAYMENT_METHODS } from "@core/Models/ClientAccountMovements.model";
import { PAYMENT_METHODS_CLIENT_ACCOUNT } from "@core/Models/Payments.model";
export const getPaymentIcon = (paymentMethod: string): React.ReactNode => {
switch (paymentMethod) {
case CLIENT_ACCOUNT_PAYMENT_METHODS.CASH:
return <CashIcon color="success" />;
case CLIENT_ACCOUNT_PAYMENT_METHODS.BANK_TRANSFER:
return <BankTransferIcon color="success" />;
case CLIENT_ACCOUNT_PAYMENT_METHODS.CREDIT_CARD:
return <CreditCardIcon color="success" />;
case CLIENT_ACCOUNT_PAYMENT_METHODS.DEBIT_CARD:
return <DebitCardIcon color="success" />;
case CLIENT_ACCOUNT_PAYMENT_METHODS.OTHER:
return <OtherPaymentIcon color="success" />;
case PAYMENT_METHODS_CLIENT_ACCOUNT.CLIENT_ACCOUNT:
return <UserAccountIcon color="success" />;
default:
return <CashIcon />;
}
};
@@ -0,0 +1,71 @@
import CalendarIcon from "@mui/icons-material/CalendarMonthOutlined";
import PayIcon from "@mui/icons-material/CurrencyExchangeOutlined";
import DiscountsIcon from "@mui/icons-material/LocalOfferOutlined";
import ButtonMaterial from "@mui/material/Button";
export interface PaymentToolbarProps {
onMarkAsPayed?: () => void;
onGotoAppointments?: () => void;
onApplyDiscount?: () => void;
onQuitDiscount?: () => void;
}
const PaymentToolbar = (props: PaymentToolbarProps) => {
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
width: "100%",
height: "60px",
color: "var(--white)",
}}
>
{props.onGotoAppointments && (
<ButtonMaterial
variant="text"
startIcon={<CalendarIcon />}
sx={{ color: "var(--white)" }}
onClick={props.onGotoAppointments}
>
Turnos
</ButtonMaterial>
)}
{props.onMarkAsPayed && (
<ButtonMaterial
variant="text"
startIcon={<PayIcon />}
sx={{ color: "var(--white)" }}
onClick={props.onMarkAsPayed}
>
Pagar
</ButtonMaterial>
)}
{props.onApplyDiscount && (
<ButtonMaterial
variant="text"
startIcon={<DiscountsIcon />}
sx={{ color: "var(--white)" }}
onClick={props.onApplyDiscount}
>
Aplicar Descuento
</ButtonMaterial>
)}
{props.onQuitDiscount && (
<ButtonMaterial
variant="text"
startIcon={<DiscountsIcon />}
sx={{ color: "var(--white)" }}
onClick={props.onQuitDiscount}
>
Quitar Descuento
</ButtonMaterial>
)}
</div>
);
};
export default PaymentToolbar;
@@ -0,0 +1,136 @@
import React, { useEffect, useState } from "react";
import { PendingRepeatView } from "@core/Models/Repeats.model";
import { getPendingRepeatsCurrentWeek } from "../../admin/(client-profile)/org/[oid]/client/[clientId]/Client.Service";
import { createAppointment } from "../../admin/(organization-profile)/org/profile/[id]/appointments/[appdate]/Appointment.Service";
import { useSessionStore } from "@core/Store/Sesion.Store";
import { useAlert } from "@core/Store/Alert.Store";
import { EVENT_TYPES, useEventHandlerStore } from "@core/Store/EventHandler.Store";
import dayjs from "dayjs";
import Button from "../Button/Button";
import { ThemeProvider } from "@emotion/react";
import turnosXpressTheme from "@core/app/theme/turnosXpress";
import style from "./style.module.css";
import { Box, Typography, Card, CardContent, Checkbox } from "@mui/material";
interface PendingRepeatsCardProps {
companyId?: string;
employeeId?: string; // Optional filter
allowEmployeeFilter?: boolean;
}
const PendingRepeatsCard: React.FC<PendingRepeatsCardProps> = ({ companyId, employeeId, allowEmployeeFilter }) => {
const SessionInfo = useSessionStore();
const alert = useAlert();
const eventHandler = useEventHandlerStore();
const [pendingRepeats, setPendingRepeats] = useState<PendingRepeatView[]>([]);
const [filterByMe, setFilterByMe] = useState<boolean>(false);
const loadRepeats = () => {
if (!SessionInfo.userId) return;
const isGlobalView = !companyId;
const isFilterByMeActive = isGlobalView || (allowEmployeeFilter && filterByMe);
eventHandler.setEventType(EVENT_TYPES.LOADING);
getPendingRepeatsCurrentWeek({
companyId,
employeeId,
sessionUser: SessionInfo.userId,
filterBySessionUser: isFilterByMeActive
}).then(res => {
setPendingRepeats(res);
}).catch(err => {
alert.showError(err.message || "Error al cargar repeticiones pendientes");
}).finally(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
};
useEffect(() => {
loadRepeats();
}, [companyId, employeeId, SessionInfo.userId, filterByMe, allowEmployeeFilter]);
const handleCreateAppointment = (repeat: PendingRepeatView) => {
eventHandler.setEventType(EVENT_TYPES.LOADING);
createAppointment({
companyId: String(repeat.companyId),
serviceId: String(repeat.serviceId),
employeeId: String(repeat.employeeId),
clientId: String(repeat.clientId),
repeatId: repeat.id,
start: dayjs(repeat.pendingDate).toISOString(),
notification: false,
validation: false,
sessionUser: SessionInfo.userId,
}).then(() => {
alert.showSuccess("Turno creado con éxito");
loadRepeats(); // Refresh the list
}).catch(err => {
alert.showError(err.format ? err.format() : err.message);
}).finally(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
};
if (pendingRepeats.length === 0 && !filterByMe) {
return null;
}
return (
<ThemeProvider theme={turnosXpressTheme}>
<Box className={style.container}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6" className={style.title} sx={{ mb: 0 }}>
Turnos recurrentes pendientes de creación (Semana Actual)
</Typography>
{(allowEmployeeFilter && companyId) && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
Solo mis turnos
</Typography>
<Checkbox
checked={filterByMe}
onChange={(e) => setFilterByMe(e.target.checked)}
color="primary"
size="small"
sx={{ padding: 0 }}
/>
</Box>
)}
</Box>
{pendingRepeats.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary', p: 2 }}>
No hay turnos recurrentes pendientes.
</Typography>
) : (
<div className={style.grid}>
{pendingRepeats.map(repeat => (
<Card key={repeat.id} className={style.card}>
<CardContent>
<Typography variant="subtitle1" fontWeight="bold">
{repeat.clientName}
</Typography>
<Typography variant="body2" color="text.secondary">
{repeat.serviceName} con {repeat.employeeName}
</Typography>
<Typography variant="body2" sx={{ mt: 1, color: "var(--wine-red)", fontWeight: "bold" }}>
{dayjs(repeat.pendingDate).format("dddd DD/MM HH:mm")} hs.
</Typography>
<Box sx={{ mt: 2, display: "flex", justifyContent: "flex-end" }}>
<Button
text="Crear Turno"
color="primary"
onClick={() => handleCreateAppointment(repeat)}
/>
</Box>
</CardContent>
</Card>
))}
</div>
)}
</Box>
</ThemeProvider>
);
};
export default PendingRepeatsCard;
@@ -0,0 +1,26 @@
.container {
width: 100%;
margin-bottom: 24px;
padding: 16px;
background-color: #fff9fa;
border: 1px solid var(--wine-red);
border-radius: 8px;
}
.title {
color: var(--wine-red);
margin-bottom: 16px !important;
font-weight: 600 !important;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.card {
border-radius: 8px !important;
box-shadow: 0 2px 8px rgba(0,0,0,0.05) !important;
border: 1px solid #eaeaea;
}
@@ -0,0 +1,145 @@
.widgetContainer {
background: #ffffff;
border-radius: 16px;
padding: 24px;
box-shadow: 0px 4px 15px rgba(0, 0, 0, 0.05);
border: 1px solid rgba(0, 0, 0, 0.04);
display: flex;
flex-direction: column;
gap: 20px;
height: 100%;
}
.widgetHeader {
display: flex;
flex-direction: column;
gap: 4px;
}
.title {
font-size: 18px;
font-weight: 700;
color: #111827;
margin: 0;
}
.subtitle {
font-size: 14px;
color: #6b7280;
font-weight: 500;
}
.metricsList {
display: flex;
flex-direction: column;
gap: 16px;
flex-grow: 1;
}
.metricRow {
display: flex;
flex-direction: column;
gap: 8px;
}
.metricHeader {
display: flex;
justify-content: space-between;
align-items: center;
}
.metricTitleWrap {
display: flex;
align-items: center;
gap: 8px;
}
.metricIcon {
display: flex;
align-items: center;
justify-content: center;
color: var(--wine-red, #b91c1c);
background-color: rgba(185, 28, 28, 0.08);
padding: 6px;
border-radius: 8px;
}
.metricTitle {
font-size: 14px;
font-weight: 600;
color: #374151;
}
.metricValue {
display: flex;
align-items: baseline;
gap: 4px;
}
.currentValue {
font-size: 16px;
font-weight: 700;
color: #111827;
}
.maxValue {
font-size: 13px;
color: #9ca3af;
font-weight: 500;
}
.progressContainer {
width: 100%;
height: 6px;
background-color: #f3f4f6;
border-radius: 10px;
overflow: hidden;
position: relative;
}
.progressBar {
height: 100%;
border-radius: 10px;
transition: width 1s cubic-bezier(0.4, 0, 0.2, 1);
}
.safe { background-color: var(--green-light, #10b981); }
.warning { background-color: var(--yellow, #f59e0b); }
.danger { background-color: var(--error, #ef4444); }
.infinite { background-color: var(--wine-red, #6366f1); }
.actionsContainer {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 10px;
border-top: 1px solid #f3f4f6;
padding-top: 20px;
}
.statusFooter {
display: flex;
justify-content: center;
align-items: center;
}
.cancelledInfo {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.cancelledText {
margin: 0;
font-size: 13px;
font-weight: 600;
color: var(--error, #ef4444);
}
.validUntilText {
margin: 0;
font-size: 13px;
font-weight: 600;
color: #374151;
}
@@ -0,0 +1,168 @@
"use client";
import React, { useEffect, useState } from "react";
import classNames from "classnames";
import styles from "./PlanMetricsWidget.module.css";
import { ISubscriptionInfo } from "@core/Models/Subscription.Model";
import StorefrontOutlinedIcon from "@mui/icons-material/StorefrontOutlined";
import PeopleOutlineOutlinedIcon from "@mui/icons-material/PeopleOutlineOutlined";
import CalendarTodayOutlinedIcon from "@mui/icons-material/CalendarTodayOutlined";
import AssignmentIndOutlinedIcon from "@mui/icons-material/AssignmentIndOutlined";
import RepeatOutlinedIcon from "@mui/icons-material/RepeatOutlined";
import Button from "@core/app/components/Button/Button";
import { MP_SUBS_STATUS } from "@core/Models/Subscription.Model";
import dayjs from "dayjs";
export interface PlanMetricsWidgetProps {
subscription: ISubscriptionInfo;
metrics: {
organizationsCount: number;
employeesCount: number;
clientsCount: number;
appointmentsCount: number;
servicesCount: number;
repeatsCount: number;
};
onUpgrade: () => void;
onRenew?: () => void;
onCancel: () => void;
isOwner?: boolean;
}
const MetricRow = ({
icon,
title,
value,
maxValue,
}: {
icon: React.ReactNode;
title: string;
value: number;
maxValue: number;
}) => {
const [animatedWidth, setAnimatedWidth] = useState(0);
const isInfinite = maxValue === -1;
const formattedMax = isInfinite ? "∞" : maxValue;
const percentage = isInfinite ? 100 : Math.min(100, (value / maxValue) * 100);
let statusClass = styles.safe;
if (isInfinite) {
statusClass = styles.infinite;
} else {
if (percentage >= 100) {
statusClass = styles.danger;
} else if (percentage >= 80) {
statusClass = styles.warning;
}
}
useEffect(() => {
const timer = setTimeout(() => {
setAnimatedWidth(percentage);
}, 100);
return () => clearTimeout(timer);
}, [percentage]);
return (
<div className={styles.metricRow}>
<div className={styles.metricHeader}>
<div className={styles.metricTitleWrap}>
<span className={styles.metricIcon}>{icon}</span>
<span className={styles.metricTitle}>{title}</span>
</div>
<div className={styles.metricValue}>
<span className={styles.currentValue}>{value}</span>
<span className={styles.maxValue}>/ {formattedMax}</span>
</div>
</div>
<div className={styles.progressContainer}>
<div
className={classNames(styles.progressBar, statusClass)}
style={{ width: `${animatedWidth}%` }}
/>
</div>
</div>
);
};
export default function PlanMetricsWidget({
subscription,
metrics,
onUpgrade,
onCancel,
isOwner = true,
}: PlanMetricsWidgetProps) {
const isCancelled = subscription.mpStatus === MP_SUBS_STATUS.CANCELLED;
return (
<div className={styles.widgetContainer}>
<div className={styles.widgetHeader}>
<h3 className={styles.title}>Métricas del Plan {subscription.plan.name}</h3>
<span className={styles.subtitle}>Uso actual de tu suscripción</span>
</div>
<div className={styles.metricsList}>
<MetricRow
icon={<StorefrontOutlinedIcon fontSize="small" />}
title="Organizaciones"
value={metrics.organizationsCount}
maxValue={subscription.plan.limitOrganizations}
/>
<MetricRow
icon={<PeopleOutlineOutlinedIcon fontSize="small" />}
title="Clientes"
value={metrics.clientsCount}
maxValue={subscription.plan.limitClients}
/>
<MetricRow
icon={<CalendarTodayOutlinedIcon fontSize="small" />}
title="Turnos"
value={metrics.appointmentsCount}
maxValue={subscription.plan.limitAppointments}
/>
<MetricRow
icon={<AssignmentIndOutlinedIcon fontSize="small" />}
title="Colaboradores"
value={metrics.employeesCount}
maxValue={subscription.plan.limitEmployees}
/>
<MetricRow
icon={<RepeatOutlinedIcon fontSize="small" />}
title="Repeticiones"
value={metrics.repeatsCount || 0}
maxValue={subscription.plan.limitRepeats}
/>
</div>
{isOwner && (
<div className={styles.actionsContainer}>
<Button
color="primary"
text="Mejorar mi plan"
width="custom"
style={{ width: "100%", padding: "12px 0", borderRadius: "8px", fontWeight: "600" }}
onClick={onUpgrade}
/>
<div className={styles.statusFooter}>
{isCancelled ? (
<div className={styles.cancelledInfo}>
<p className={styles.cancelledText}>La suscripción ha sido cancelada</p>
<p className={styles.validUntilText}>
Vigente hasta el {dayjs(subscription.endDate).format("DD/MM/YYYY")}
</p>
</div>
) : (
<Button
color="link"
text="Cancelar subscripción"
onClick={onCancel}
width="custom"
style={{ margin: 0, padding: 0, fontSize: "13px", color: "#6b7280", minHeight: "auto" }}
/>
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,157 @@
"use client";
import { useNavigation } from "@hooks/goto";
import { IPlan } from "@core/Models/Plans.model";
import { formatPrice } from "@helpers/Numbers";
import { motion } from "motion/react";
import { ScrollToTop } from "@core/app/components/ScrollTop";
export interface PricingProps {
data: IPlan[];
}
interface PricingElementProps {
item: IPlan;
onClick?: (planId: string) => void;
}
const PricingElement = (props: PricingElementProps) => {
const { item } = props;
// Highlight ONLY if the API explicitly marks it as 'featured'
const highlight = item.featured === true;
// Dynamic styling based on whether it's the highlighted plan
const cardBg = highlight ? "linear-gradient(180deg, var(--wine-superdark) 0%, var(--wine-darkest) 100%)" : "var(--white)";
const textColor = highlight ? "var(--white)" : "var(--wine-superdark)";
const descColor = highlight ? "var(--white-darkest)" : "var(--gray-dark)";
const priceColor = highlight ? "var(--white)" : "var(--wine-darkest)";
const scale = highlight ? 1.05 : 1;
const shadow = highlight ? "0 20px 40px rgba(127, 42, 255, 0.4)" : "0 10px 30px rgba(0, 0, 0, 0.05)";
const border = highlight ? "2px solid var(--wine-red)" : "1px solid var(--white-darkest)";
return (
<motion.div
key={item.name}
whileHover={{ y: -10, boxShadow: highlight ? "0 25px 50px rgba(127, 42, 255, 0.6)" : "0 20px 40px rgba(0, 0, 0, 0.1)" }}
whileTap={{ scale: 0.98 }}
style={{
flex: "0 0 auto",
width: "320px",
background: cardBg,
borderRadius: "24px",
boxShadow: shadow,
textAlign: "left",
padding: "40px 30px",
margin: "20px",
border: border,
cursor: "pointer",
color: textColor,
display: "flex",
flexDirection: "column",
position: "relative",
transform: `scale(${scale})`,
zIndex: highlight ? 10 : 1,
}}
onClick={() => {
if (props.onClick) {
if (!item.id) return;
if (!item.active) return;
props.onClick(item.id);
}
}}
>
{highlight && (
<div style={{
position: "absolute",
top: "-2px",
left: "-2px",
right: "-2px",
background: "linear-gradient(90deg, var(--wine-red) 0%, #ff5e99 100%)",
color: "white",
padding: "8px",
fontSize: "0.85rem",
fontWeight: "800",
textAlign: "center",
textTransform: "uppercase",
letterSpacing: "2px",
borderTopLeftRadius: "24px",
borderTopRightRadius: "24px"
}}>
Más Elegido
</div>
)}
<h1 style={{ fontSize: "2rem", color: textColor, marginBottom: "10px", marginTop: highlight ? "15px" : "0", fontWeight: "800" }}>{item.name}</h1>
<p style={{ marginBottom: "25px", fontSize: "1rem", color: descColor, lineHeight: "1.5" }}>{item.description}</p>
<div style={{ margin: "10px 0 25px 0", borderBottom: `1px solid ${highlight ? "rgba(255,255,255,0.1)" : "var(--white-darkest)"}`, paddingBottom: "25px", display: "flex", alignItems: "baseline", flexWrap: "wrap", rowGap: "5px" }}>
<span style={{ fontWeight: "900", fontSize: "2.6rem", color: priceColor, lineHeight: "1" }}>{formatPrice(item.price)}</span>
<span style={{ fontSize: "1rem", color: descColor, marginLeft: "5px" }}>/ mes</span>
</div>
<ul style={{ marginTop: "10px", flexGrow: 1, padding: 0 }}>
{item.features.map((feature, idx) => (
<li
key={idx}
style={{
display: "flex",
alignItems: "flex-start",
marginBottom: "15px",
listStyle: "none",
fontSize: "0.95rem",
color: highlight ? "var(--white)" : "var(--black)",
}}
>
<svg style={{ minWidth: "20px", height: "20px", marginRight: "10px", color: "var(--green-darkestX1)", marginTop: "2px" }} fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="3" d="M5 13l4 4L19 7"></path></svg>
<span style={{ lineHeight: "1.4" }}>{feature}</span>
</li>
))}
</ul>
<div style={{ marginTop: "30px", width: "100%" }}>
{!item.active ? (
<div style={{ background: "var(--gray-lighter)", color: "var(--gray-dark)", padding: "15px", borderRadius: "12px", textAlign: "center", fontWeight: "bold" }}>NO DISPONIBLE</div>
) : (
<div style={{
background: highlight ? "var(--white)" : "var(--wine-darkest)",
color: highlight ? "var(--wine-darkest)" : "var(--white)",
padding: "16px",
borderRadius: "12px",
textAlign: "center",
fontWeight: "800",
fontSize: "1.1rem",
boxShadow: highlight ? "0 4px 15px rgba(255,255,255,0.2)" : "0 4px 15px rgba(51,0,128,0.3)",
transition: "all 0.3s ease",
width: "100%"
}}>
Comenzar Ahora
</div>
)}
</div>
</motion.div>
);
};
export default function Pricing(props: PricingProps) {
const { data } = props;
const { goTo } = useNavigation();
const henclePlanClick = (item: IPlan) => {
goTo("/landing/subscription/" + item.id);
};
return (
<div style={{ display: "flex", flexWrap: "wrap", justifyContent: "center", alignItems: "stretch", gap: "20px", marginTop: "20px" }}>
<ScrollToTop />
{data.map((item) => (
<PricingElement
key={item.name}
item={item}
onClick={() => {
henclePlanClick(item);
}}
/>
))}
</div>
);
}
@@ -0,0 +1,161 @@
"use client";
import { formatPrice } from "@core/app/helpers/Numbers";
import { useNavigation } from "@core/app/hooks/goto";
import { PublicServiceView } from "@models/Service.model";
import style from "./style.module.css";
import { getServiceImage } from "@core/helpers/getServiceImage";
export type FindResultsViewMode = "grid" | "card";
export interface ProductItemProps {
product: PublicServiceView;
width?: number | string;
viewMode?: FindResultsViewMode;
dragging?: boolean;
}
export default function ProductItem(props: ProductItemProps) {
const { width = 220 } = props;
const { product } = props;
const { viewMode = "card" } = props;
const { dragging = false } = props;
const { goTo } = useNavigation();
return (
<>
{viewMode === "card" && (
<div
key={product.id}
style={{
flex: "0 0 auto",
width: typeof width === "number" ? `${width}px` : width,
background: "white",
borderRadius: "10px",
boxShadow: "0 4px 4px rgba(0, 0, 0, 0.1)",
overflow: "hidden",
textAlign: "center",
padding: "10px",
border: "solid 1px rgba(0, 0, 0, 0.1)",
cursor: "pointer",
position: "relative",
}}
onClick={() => {
if (dragging) return;
goTo(`/landing/service/${product.id}`);
}}
>
<img
src={getServiceImage(product.image)}
alt={product.name}
draggable="false"
style={{
width: "100%",
height: "auto",
borderRadius: "10px",
}}
/>
<h3
style={{
fontSize: "1.2rem",
margin: "10px 0",
textTransform: "capitalize",
}}
>
{product.name}
</h3>
<p style={{ fontSize: "0.9rem", color: "#555" }}>{product.description}</p>
<span
style={{
fontWeight: "bold",
fontSize: "1.1rem",
color: "#ff6200",
}}
>
{formatPrice(product.price)}
</span>
{product.discountId && (
<span
style={{
backgroundColor: "var(--green-darkestX1)",
color: "white",
borderRadius: "5px",
padding: "2px 5px",
fontWeight: "bold",
marginLeft: "10px",
fontSize: "0.9rem",
}}
>
10% Off
</span>
)}
</div>
)}
{viewMode === "grid" && (
<div
key={product.id}
style={{
display: "flex",
flexDirection: "row",
alignItems: "flex-start",
width: "100%",
gap: "20px",
borderBottom: "solid 1px rgba(0, 0, 0, 0.1)",
cursor: "pointer",
}}
onClick={() => {
goTo(`/landing/service/${product.id}`);
}}
>
<img
src={getServiceImage(product.image)}
alt={product.name}
draggable="false"
className={style.itemImage}
style={{
height: "auto",
borderRadius: "10px",
margin: "10px",
}}
/>
<div>
<h3 className={style.itemTitle}>{product.name}</h3>
<p
className={style.itemDescription}
style={{ color: "#555", marginRight: "30px" }}
>
{product.description}
</p>
<span
style={{
fontWeight: "bold",
fontSize: "1.1rem",
color: "#ff6200",
}}
>
{formatPrice(product.price)}
</span>
{product.discountId && (
<span
style={{
backgroundColor: "var(--green-darkestX1)",
color: "white",
borderRadius: "5px",
padding: "2px 5px",
fontWeight: "bold",
maxWidth: "80px",
textAlign: "center",
marginLeft: "10px",
}}
>
10% Off
</span>
)}
</div>
</div>
)}
</>
);
}
@@ -0,0 +1,31 @@
@media (max-width: 851px) {
.itemTitle {
font-size: 1rem;
margin: 10px 0;
text-transform: capitalize;
}
.itemDescription {
font-size: 0.9rem;
}
.itemImage {
width: 120px;
}
}
@media (min-width: 850px) {
.itemTitle {
font-size: 1.2rem;
margin: 10px 0;
text-transform: capitalize;
}
.itemDescription {
font-size: 1.1rem;
}
.itemImage {
width: 150px;
}
}
@@ -0,0 +1,50 @@
import ApiRequest from "@services/Api.Service";
import * as Yup from "yup";
import { ApiError } from "@core/Models/Server.Error.model";
import { UploadFileResponse, UploadPostFileParams } from "@core/Models/Files.model";
const schemaUploadFile = Yup.object().shape({
file: Yup.mixed<File>()
.required("Debe seleccionar un archivo.")
.test("fileSize", "El archivo es demasiado grande (máx 5 MB).", (value) => {
return value && value.size <= 5 * 1024 * 1024; // 5 MB
})
.test("fileType", "Tipo de archivo no permitido.", (value) => {
return (
value && ["image/png", "image/jpeg", "image/webp", "image/gif"].includes(value.type)
);
}),
token: Yup.string().required("Para subir un archivo debe iniciar sesión."),
companyId: Yup.string().required("No es posible deteriminar la organización."),
sessionUser: Yup.string().required("Para crear un descuento debe iniciar sesión."),
});
export const uploadPostFile: (data: UploadPostFileParams) => Promise<UploadFileResponse> = async (
data
) => {
let yupErrors: Yup.ValidationError = new Yup.ValidationError("");
const validateData = async () => {
try {
await schemaUploadFile.validate(data, { abortEarly: true });
return true;
} catch (error) {
if (error instanceof Yup.ValidationError) {
yupErrors = error;
}
return false;
}
};
if (!(await validateData())) {
throw new ApiError(400, yupErrors.message);
}
const form = new FormData();
form.append("file", data.file);
form.append("sessionUser", data.sessionUser);
form.append("token", data.token);
form.append("companyId", data.companyId);
return ApiRequest.postFile<UploadFileResponse>("files/upload-post-file", form);
};
@@ -0,0 +1,264 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useState, useEffect, ComponentType, useRef, Ref } from "react";
import "react-quill-new/dist/quill.snow.css";
import "./quill.i18n.css";
import { uploadPostFile } from "./QuillEditor.Service";
import { useSessionStore } from "@core/Store/Sesion.Store";
import { useOrgDataStore } from "@core/Store/OrganizationData.Store";
interface QuillEditorProps {
value: string;
onChange: (value: string) => void;
}
const translations = {
bold: "Negrita",
italic: "Cursiva",
underline: "Subrayado",
strike: "Tachado",
blockquote: "Cita",
list: "Lista",
indent: "Sangría",
link: "Enlace",
image: "Imagen",
video: "Video",
clean: "Limpiar formato",
color: "Color de fuente",
background: "Color de fondo",
header: "Encabezado",
size: "Tamaño de fuente",
} as const;
interface QuillComponentProps {
theme: string;
value: string;
onChange: (value: string) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
modules: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ref?: Ref<any>;
}
const QuillEditor: React.FC<QuillEditorProps> = ({ value, onChange }) => {
const [Quill, setQuill] = useState<ComponentType<QuillComponentProps> | null>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const quillRef = useRef<any>(null); // Ref to Quill instance
const SessionInfo = useSessionStore();
const OrganizationData = useOrgDataStore();
useEffect(() => {
import("react-quill-new").then((mod) => {
setQuill(() => mod.default as unknown as ComponentType<QuillComponentProps>);
});
}, []);
useEffect(() => {
if (Quill) {
const timer = setTimeout(() => {
const toolbar = document.querySelector(".ql-toolbar");
if (toolbar) {
Object.keys(translations).forEach((format) => {
const elements = toolbar.querySelectorAll(`.ql-${format}`);
elements.forEach((el) => {
if (el instanceof HTMLElement) {
el.title = translations[format as keyof typeof translations];
}
});
});
}
}, 100);
return () => clearTimeout(timer);
}
}, [Quill]);
const imageHandler = () => {
const input = document.createElement("input");
input.setAttribute("type", "file");
input.setAttribute("accept", "image/*");
input.click();
input.onchange = async () => {
const file = input.files ? input.files[0] : null;
if (file) {
// Simulate upload to backend
const imageUrl = await new Promise<string>((resolve) => {
uploadPostFile({
sessionUser: SessionInfo.userId,
token: SessionInfo.userToken,
companyId: `${OrganizationData.orgData?.id}`,
file: file,
})
.then((response) => {
const host = process.env.NEXT_PUBLIC_API_URL || "";
resolve(host + "files/download/" + response.files[0].objectName);
})
.catch((error) => {
console.log("Error uploading image:", error);
resolve("");
});
});
const editor = quillRef.current.getEditor();
const range = editor.getSelection();
if (range) {
editor.insertEmbed(range.index, "image", imageUrl);
}
}
};
};
const videoHandler = () => {
const url = window.prompt("Introduce la URL del video (YouTube, Vimeo, etc.):");
if (url && quillRef.current) {
const editor = quillRef.current.getEditor();
const range = editor.getSelection();
if (range) {
// Verificar si es una URL de YouTube
let embedUrl = url;
// Convertir URLs de YouTube a formato embed si es necesario
if (url.includes('youtube.com/watch')) {
const videoId = url.split('v=')[1];
const ampersandPosition = videoId.indexOf('&');
if (ampersandPosition !== -1) {
embedUrl = `https://www.youtube.com/embed/${videoId.substring(0, ampersandPosition)}`;
} else {
embedUrl = `https://www.youtube.com/embed/${videoId}`;
}
} else if (url.includes('youtu.be/')) {
const videoId = url.split('youtu.be/')[1];
embedUrl = `https://www.youtube.com/embed/${videoId}`;
} else if (url.includes('vimeo.com/')) {
const videoId = url.split('vimeo.com/')[1];
// Si es una URL de Vimeo con subdirectorios
if (videoId.includes('/')) {
const parts = videoId.split('/');
embedUrl = `https://player.vimeo.com/video/${parts[parts.length - 1]}`;
} else {
embedUrl = `https://player.vimeo.com/video/${videoId}`;
}
}
// Insertar el video como iframe
editor.insertEmbed(range.index, "video", embedUrl);
// Mover el cursor después del video
editor.setSelection(range.index + 1);
}
}
};
const modules = {
toolbar: {
container: [
[{ header: "1" }, { header: "2" }],
[{ size: [] }],
["bold", "italic", "underline", "strike", "blockquote"],
[{ list: "ordered" }, { list: "bullet" }, { indent: "-1" }, { indent: "+1" }],
["link", "image", "video"],
["clean"],
[{ color: [] }, { background: [] }],
],
handlers: {
image: imageHandler,
video: videoHandler,
},
},
};
if (!Quill) {
return null;
}
return (
<div className="quill-wrapper" style={{ height: "100%", width: "100%" }}>
<style jsx global>{`
.quill-wrapper {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
}
.quill-wrapper .ql-tooltip {
z-index: 2; /* Higher than toolbar's z-index */
top: 42px !important; /* Push it down below the toolbar */
}
.quill-wrapper .ql-toolbar {
flex-shrink: 0;
border-bottom: 1px solid #ccc; /* Add a border for separation */
position: sticky; /* Make toolbar sticky */
top: 0; /* Stick to the top */
z-index: 1; /* Ensure it's above content */
background-color: white; /* Ensure background is solid */
}
.quill-wrapper .ql-container {
flex-grow: 1;
overflow-y: auto; /* Make the container scrollable */
display: flex;
flex-direction: column;
}
.quill-wrapper .ql-editor {
flex-grow: 1;
/* overflow-y: auto; This is now handled by ql-container */
}
/* Estilos para videos dentro del editor */
.quill-wrapper .ql-editor .ql-video {
display: block;
max-width: 100%;
margin: 16px 0;
}
.quill-wrapper .ql-editor .ql-video iframe {
width: 100%;
min-height: 300px;
border: 0;
border-radius: 8px;
}
/* Estilos para imágenes dentro del editor */
.quill-wrapper .ql-editor img {
max-width: 100%;
height: auto;
display: block;
margin: 16px 0;
border-radius: 8px;
}
/* Para mejor experiencia de edición, agregar un borde alrededor de elementos embebidos */
.quill-wrapper .ql-editor .ql-video,
.quill-wrapper .ql-editor img {
outline: 2px dashed transparent;
transition: outline-color 0.2s;
}
.quill-wrapper .ql-editor .ql-video:hover,
.quill-wrapper .ql-editor img:hover {
outline-color: #4d90fe;
}
/* Mostrar cursor de texto sobre elementos para indicar que se pueden editar/eliminar */
.quill-wrapper .ql-editor .ql-video::after,
.quill-wrapper .ql-editor img::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
cursor: text;
}
`}</style>
<Quill
ref={quillRef}
theme="snow"
value={value}
onChange={onChange}
modules={modules}
/>
</div>
);
};
export default QuillEditor;
@@ -0,0 +1,16 @@
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="small"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="small"]::before {
content: 'Pequeño';
}
.ql-snow .ql-picker.ql-size .ql-picker-label::before,
.ql-snow .ql-picker.ql-size .ql-picker-item::before {
content: 'Normal';
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="large"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="large"]::before {
content: 'Grande';
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="huge"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="huge"]::before {
content: 'Muy grande';
}
@@ -0,0 +1,45 @@
.buttonSave {
position: fixed;
display: flex;
align-items: center;
justify-content: center;
bottom: 75px;
right: 15px;
background-color: var(--wine-dark);
color: var(--white);
border-radius: 50%;
z-index: 2000;
cursor: pointer;
transition: width 0.25s ease-in-out, height 0.25s ease-in-out;
animation: buttonSabePulseShadow 1.5s ease-in-out infinite;
}
.buttonSaveVisible {
width: 64px;
height: 64px;
}
.buttonSaveVisible svg {
display: flex;
opacity: 1;
}
.buttonSaveHidden {
width: 0px;
height: 0px;
}
.buttonSaveHidden svg {
display: none;
opacity: 0;
}
@keyframes buttonSabePulseShadow {
0%,
100% {
box-shadow: 0px 2px 12px -4px var(--wine-red);
}
50% {
box-shadow: 0px 2px 20px -2px var(--wine-red);
}
}
@@ -0,0 +1,44 @@
"use client";
import { ReactNode, useEffect, useState } from "react";
import style from "./Saveable.module.css";
import { EVENT_TYPES, useEventHandlerStore } from "@store/EventHandler.Store";
import SaveIcon from "@mui/icons-material/Save";
import classNames from "classnames";
interface SaveableProps {
children?: ReactNode;
onClick?: () => void;
}
export default function Saveable(props: SaveableProps): React.ReactElement {
const eventHandler = useEventHandlerStore();
const [buttonClassNames, setButtonClassNames] = useState(
classNames(style.buttonSave, style.buttonSaveHidden)
);
useEffect(() => {
if (eventHandler.eventType === EVENT_TYPES.NEED_SAVE) {
setButtonClassNames(classNames(style.buttonSave, style.buttonSaveVisible));
} else {
setButtonClassNames(classNames(style.buttonSave, style.buttonSaveHidden));
}
}, [eventHandler.eventType]);
return (
<>
{props.children}
<div
className={buttonClassNames}
onClick={() => {
if (props.onClick) {
props.onClick();
return;
}
eventHandler.setEventType(EVENT_TYPES.SAVE);
}}
>
<SaveIcon />
</div>
</>
);
}

Some files were not shown because too many files have changed in this diff Show More