first commit
This commit is contained in:
@@ -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. Tú 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%" }}> </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> </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> </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> </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 🔥" data={[]} />
|
||||
<ProductGallery
|
||||
title="Educacion 🏆"
|
||||
showViewMore={true}
|
||||
viewMoreColor="var(--yellow)"
|
||||
onViewMore={() => {
|
||||
headerState.setPublicFindCategoryId(200);
|
||||
goTo("/landing/find");
|
||||
}}
|
||||
data={homeData.education?.data}
|
||||
/>
|
||||
<ProductGallery
|
||||
title="Salud y Bienestar 🔥"
|
||||
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 🔥"
|
||||
showViewMore={true}
|
||||
onViewMore={() => {
|
||||
headerState.setPublicFindCategoryId(50000);
|
||||
goTo("/landing/find");
|
||||
}}
|
||||
data={homeData.other?.data}
|
||||
/>
|
||||
<ProductGallery
|
||||
title="Construcción y Mantenimiento 🔥"
|
||||
showViewMore={true}
|
||||
onViewMore={() => {
|
||||
headerState.setPublicFindCategoryId(400);
|
||||
goTo("/landing/find");
|
||||
}}
|
||||
data={homeData.construction?.data}
|
||||
/>
|
||||
<ProductGallery
|
||||
title="Contabilidad 🏆"
|
||||
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
|
||||
<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?
|
||||
<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}> </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",
|
||||
}}
|
||||
>
|
||||
|
||||
</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}> </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,
|
||||
}}
|
||||
>
|
||||
|
||||
</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 <></>;
|
||||
}
|
||||
@@ -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> </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
Reference in New Issue
Block a user