first commit
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import AnimatedContainer from "@components/AnimatedConainer/AnimatedContainer";
|
||||
import CategoriesExplorer from "@core/app/components/Home/CategoriesExplorer/page";
|
||||
import DefaultLoginAction from "@core/app/components/LoginAction/DefaultLoginAction";
|
||||
import { ScrollToTop } from "@core/app/components/ScrollTop";
|
||||
|
||||
export default function CategoriesPage() {
|
||||
return (
|
||||
<AnimatedContainer
|
||||
color="#aeaeae"
|
||||
minSize={150}
|
||||
maxSize={500}
|
||||
items={10}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<ScrollToTop />
|
||||
<DefaultLoginAction />
|
||||
<div className="homeCentered">
|
||||
<h1 className="homeTitleHeader">Explorar Categorías</h1>
|
||||
<p className="homeResumeHeader">
|
||||
Explorá las categorías de servicios disponibles y encontrá fácilmente lo que
|
||||
necesitás. Cada categoría agrupa servicios para que puedas sacar turnos de forma
|
||||
rápida y organizada. Además, una vez que ingreses en una categoría, podrás
|
||||
filtrar por tu ubicación para afinar la busqueda.
|
||||
</p>
|
||||
</div>
|
||||
<div className="homeCentered">
|
||||
<CategoriesExplorer />
|
||||
<div style={{ height: "100px" }}> </div>
|
||||
</div>
|
||||
</AnimatedContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import AnimatedContainer from "@components/AnimatedConainer/AnimatedContainer";
|
||||
import style from "./terms.module.css";
|
||||
import { ScrollToTop } from "@core/app/components/ScrollTop";
|
||||
import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider";
|
||||
import DefaultLoginAction from "@core/app/components/LoginAction/DefaultLoginAction";
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<AnimatedContainer
|
||||
color="#aeaeae"
|
||||
minSize={150}
|
||||
maxSize={500}
|
||||
items={10}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<ScrollToTop />
|
||||
<HeaderConfProvider />
|
||||
<DefaultLoginAction />
|
||||
<div className="homeCentered">
|
||||
<h1 className="homeTitleHeader">Política de Cookies</h1>
|
||||
<p className="homeResumeHeader">
|
||||
Este sitio web no utiliza cookies propias. Sin embargo, algunos servicios de
|
||||
terceros integrados en la plataforma pueden instalar cookies en su dispositivo.
|
||||
</p>
|
||||
</div>
|
||||
<div className="homeCentered">
|
||||
<h4 className={style.termTitle}>Información que recopilamos</h4>
|
||||
<p className={style.termText}>
|
||||
Servicios de terceros que pueden utilizar cookies:
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Google (Inicio de sesión):</strong> El botón Iniciar sesión con
|
||||
Google utiliza servicios de autenticación proporcionados por Google, los
|
||||
cuales pueden instalar cookies con fines de autenticación, seguridad,
|
||||
personalización y análisis. Estas cookies son gestionadas directamente
|
||||
por Google. Recomendamos consultar la Política de Privacidad de Google y
|
||||
su Política de Cookies para más información.
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<strong>Google Fonts:</strong> Utilizamos fuentes web proporcionadas por
|
||||
Google Fonts. Al cargar estas fuentes, su navegador se conecta a los
|
||||
servidores de Google, lo cual puede implicar la recopilación de datos
|
||||
como su dirección IP. Google puede utilizar esta información conforme a
|
||||
sus propias políticas de privacidad.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Material UI (íconos): </strong> Algunos íconos de Material UI se
|
||||
cargan desde servicios en línea, como Google o CDN externos. Esto puede
|
||||
implicar conexiones a servidores de terceros y la posibilidad de que se
|
||||
establezcan cookies o se registren ciertos datos técnicos.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Mapas (Leaflet):</strong> Utilizamos la librería Leaflet para
|
||||
mostrar mapas interactivos. Aunque Leaflet en sí no instala cookies, el
|
||||
proveedor de mapas (por ejemplo, OpenStreetMap, Mapbox o Google Maps)
|
||||
puede recopilar datos del usuario o establecer cookies al cargar el
|
||||
contenido del mapa.
|
||||
</li>
|
||||
</ul>
|
||||
</p>
|
||||
|
||||
<h4 className={style.termTitle}>Gestión de cookies</h4>
|
||||
<p className={style.termText}>
|
||||
Usted puede configurar su navegador para bloquear o eliminar las cookies de
|
||||
terceros. Esto puede afectar algunas funcionalidades del sitio, como el inicio
|
||||
de sesión o la correcta visualización de mapas e íconos. A continuación se
|
||||
indican enlaces con instrucciones para gestionar cookies en los navegadores más
|
||||
comunes:
|
||||
<ul>
|
||||
<li>
|
||||
Google Chrome:{" "}
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://support.google.com/chrome/answer/95647"
|
||||
>
|
||||
Configuración de cookies en Chrome
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
Mozilla Firefox:{" "}
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://support.mozilla.org/es/kb/Deshabilitar%20cookies%20de%20terceros"
|
||||
>
|
||||
Configuración de cookies en Firefox
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
Microsoft Edge::{" "}
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://support.microsoft.com/es-es/windows/administrar-cookies-en-microsoft-edge-ver-permitir-bloquear-eliminar-y-usar-168dab11-0753-043d-7c16-ede5947fc64d"
|
||||
>
|
||||
Configuración de cookies en Edge
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
Safari:{" "}
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://help.apple.com/safari/mac/10.0/es.lproj/sfri11471.html"
|
||||
>
|
||||
Configuración de cookies en Safari
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
Al continuar navegando en este sitio, usted acepta el uso de estos servicios de
|
||||
terceros y el posible uso de cookies asociado, conforme a sus respectivas
|
||||
políticas de privacidad.
|
||||
</p>
|
||||
|
||||
<p className={style.termText}></p>
|
||||
|
||||
<div style={{ height: "100px" }}> </div>
|
||||
</div>
|
||||
</AnimatedContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
.termText {
|
||||
padding-top: 10px;
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
text-align: justify;
|
||||
max-width: 1200px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.termText ul {
|
||||
padding-top: 10px;
|
||||
padding-left: 40px;
|
||||
}
|
||||
|
||||
.termText li {
|
||||
padding-bottom: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.termTitle {
|
||||
font-size: 20px;
|
||||
padding-top: 30px;
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
text-align: left;
|
||||
max-width: 1200px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import ApiRequest from "@services/Api.Service";
|
||||
import { ApiVoidResult } from "@models/Api.VoidResult.type";
|
||||
import * as Yup from "yup";
|
||||
import {
|
||||
CancellPlanSuscriptionParams,
|
||||
GetSubscriptionInitPointParams,
|
||||
GetSuscriptionInitPointResponse,
|
||||
} from "@models/Subscription.Model";
|
||||
import { ApiError } from "@models/Server.Error.model";
|
||||
import { AppointmentEventByClient } from "@core/Models/Appointments.model";
|
||||
|
||||
export interface GetDashboardSummaryParams {
|
||||
sessionUser: string;
|
||||
}
|
||||
|
||||
export interface DashboardMetrics {
|
||||
organizationsCount: number;
|
||||
employeesCount: number;
|
||||
clientsCount: number;
|
||||
appointmentsCount: number;
|
||||
servicesCount: number;
|
||||
repeatsCount: number;
|
||||
}
|
||||
|
||||
export interface DashboardSummaryResult {
|
||||
role: "ADMIN" | "EMPLOYEE" | "NONE";
|
||||
globalMetrics?: DashboardMetrics;
|
||||
personalAgenda: AppointmentEventByClient[];
|
||||
}
|
||||
|
||||
export const getDashboardSummary = async (
|
||||
data: GetDashboardSummaryParams
|
||||
): Promise<DashboardSummaryResult> => {
|
||||
return ApiRequest.post<DashboardSummaryResult>("dashboard/summary", data);
|
||||
};
|
||||
|
||||
export interface GetDashboardFinancialsParams {
|
||||
sessionUser: string;
|
||||
companyId: string;
|
||||
timeframe: "week" | "month" | "year";
|
||||
employeeId?: string;
|
||||
}
|
||||
|
||||
export interface IncomeDataPoint {
|
||||
date: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface DashboardFinancialsResult {
|
||||
currentPeriodTotal: number;
|
||||
previousPeriodTotal: number;
|
||||
currentPeriodData: IncomeDataPoint[];
|
||||
unpaidToday: number;
|
||||
}
|
||||
|
||||
export const getDashboardFinancials = async (
|
||||
data: GetDashboardFinancialsParams
|
||||
): Promise<DashboardFinancialsResult> => {
|
||||
return ApiRequest.post<DashboardFinancialsResult>("dashboard/financials", data);
|
||||
};
|
||||
|
||||
export interface GetCompanyMetricsParams {
|
||||
sessionUser: string;
|
||||
companyId: string;
|
||||
}
|
||||
|
||||
export interface CompanyMetricsResult {
|
||||
subscription: any;
|
||||
metrics: DashboardMetrics;
|
||||
isOwner: boolean;
|
||||
}
|
||||
|
||||
export const getCompanyMetrics = async (
|
||||
data: GetCompanyMetricsParams
|
||||
): Promise<CompanyMetricsResult> => {
|
||||
return ApiRequest.post<CompanyMetricsResult>("dashboard/company-metrics", data);
|
||||
};
|
||||
|
||||
const schemaCancellSubscripcion = Yup.object().shape({
|
||||
sessionUser: Yup.string().required("Para crear un turno debe iniciar sesión."),
|
||||
});
|
||||
|
||||
export const cancellSubscription = async (
|
||||
data: CancellPlanSuscriptionParams
|
||||
): Promise<ApiVoidResult> => {
|
||||
return new Promise<ApiVoidResult>((resolve, reject) => {
|
||||
schemaCancellSubscripcion
|
||||
.validate(data, { abortEarly: true })
|
||||
.then(() => {
|
||||
resolve(ApiRequest.post<ApiVoidResult>("suscriptions/cancell", data));
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(new ApiError(500, error.message));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const getSuscriptionInitPoint = async (
|
||||
data: GetSubscriptionInitPointParams
|
||||
): Promise<GetSuscriptionInitPointResponse> => {
|
||||
return ApiRequest.post<GetSuscriptionInitPointResponse>("suscriptions/get-init-point", data);
|
||||
};
|
||||
@@ -0,0 +1,412 @@
|
||||
"use client";
|
||||
import MetricGraph from "@components/MetricGraph/MetricGraph";
|
||||
import AnimatedContainer from "@core/app/components/AnimatedConainer/AnimatedContainer";
|
||||
import Button from "@core/app/components/Button/Button";
|
||||
import { useNavigation } from "@core/app/hooks/goto";
|
||||
import turnosXpressTheme from "@core/app/theme/turnosXpress";
|
||||
import { useConfirmStore } from "@core/Store/Confirm.Store";
|
||||
import { useSessionStore } from "@core/Store/Sesion.Store";
|
||||
import { ThemeProvider } from "@emotion/react";
|
||||
import SuspenseLoading from "@core/app/components/SuspendeLoading/SuspenseLoading";
|
||||
import Secure from "@components/Secure/Secure";
|
||||
import { useEffect, useState, Suspense } from "react";
|
||||
import {
|
||||
cancellSubscription,
|
||||
getSuscriptionInitPoint,
|
||||
getDashboardSummary,
|
||||
DashboardSummaryResult,
|
||||
getCompanyMetrics,
|
||||
DashboardMetrics
|
||||
} from "./Dashboard.Service";
|
||||
import { EVENT_TYPES, useEventHandlerStore } from "@core/Store/EventHandler.Store";
|
||||
import { useAlert } from "@core/Store/Alert.Store";
|
||||
import { loadOrganizationsByUser } from "@core/app/admin/org/Org.Service";
|
||||
import { MyOranizationsView } from "@core/Models/Company.model";
|
||||
import {
|
||||
getSubStatusText,
|
||||
ISubscriptionInfo,
|
||||
MP_SUBS_STATUS,
|
||||
} from "@core/Models/Subscription.Model";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import style from "./style.module.css";
|
||||
import { blurElementsOnLoad } from "@core/helpers/blur";
|
||||
import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider";
|
||||
import classNames from "classnames";
|
||||
import { ScrollToTop } from "@core/app/components/ScrollTop";
|
||||
import { isNull } from "@core/app/helpers/IsNull";
|
||||
|
||||
import StorefrontOutlinedIcon from "@mui/icons-material/StorefrontOutlined";
|
||||
import NotificationsNoneOutlinedIcon from "@mui/icons-material/NotificationsNoneOutlined";
|
||||
import ChatBubbleOutlineOutlinedIcon from "@mui/icons-material/ChatBubbleOutlineOutlined";
|
||||
import AddCircleOutlineOutlinedIcon from "@mui/icons-material/AddCircleOutlineOutlined";
|
||||
import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome";
|
||||
import FinancialWidget from "@components/FinancialWidget/FinancialWidget";
|
||||
import PlanMetricsWidget from "@components/PlanMetricsWidget/PlanMetricsWidget";
|
||||
import PendingRepeatsCard from "@core/app/components/PendingRepeatsCard/PendingRepeatsCard";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { goTo } = useNavigation();
|
||||
const SessionInfo = useSessionStore();
|
||||
const eventHandler = useEventHandlerStore();
|
||||
const alert = useAlert();
|
||||
const confirm = useConfirmStore();
|
||||
|
||||
const [metrics, setMetrics] = useState({
|
||||
organizationsCount: 0,
|
||||
employeesCount: 0,
|
||||
clientsCount: 0,
|
||||
appointmentsCount: 0,
|
||||
servicesCount: 0,
|
||||
repeatsCount: 0,
|
||||
});
|
||||
|
||||
const [showOnlyMine, setShowOnlyMine] = useState(false);
|
||||
|
||||
const [subscription, setSubscription] = useState<ISubscriptionInfo>({
|
||||
plan: {
|
||||
name: "Free",
|
||||
limitOrganizations: 1,
|
||||
limitEmployees: 1,
|
||||
limitServices: 1,
|
||||
limitAppointments: 1,
|
||||
limitClients: 1,
|
||||
limitRepeats: 1,
|
||||
description: "",
|
||||
features: [],
|
||||
code: "",
|
||||
price: 0,
|
||||
annualPrice: 0,
|
||||
id: "",
|
||||
mailNotifications: false,
|
||||
smsNotifications: false,
|
||||
wapNotifications: false,
|
||||
bot: false,
|
||||
active: false,
|
||||
dateLimit: false,
|
||||
payments: false,
|
||||
},
|
||||
id: "",
|
||||
startDate: undefined,
|
||||
endDate: undefined,
|
||||
isActive: false,
|
||||
autoRenew: false,
|
||||
mpStatus: "",
|
||||
mpDateCreated: undefined,
|
||||
});
|
||||
|
||||
const [dashboardData, setDashboardData] = useState<DashboardSummaryResult | null>(null);
|
||||
const [organizations, setOrganizations] = useState<MyOranizationsView[]>([]);
|
||||
const [selectedOrgId, setSelectedOrgId] = useState<string>("");
|
||||
|
||||
// Per-company states
|
||||
const [companyMetrics, setCompanyMetrics] = useState<DashboardMetrics | null>(null);
|
||||
const [companySubscription, setCompanySubscription] = useState<ISubscriptionInfo | null>(null);
|
||||
const [isCompanyOwner, setIsCompanyOwner] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (SessionInfo.userId && !SessionInfo.subscription) {
|
||||
goTo("/");
|
||||
}
|
||||
|
||||
blurElementsOnLoad();
|
||||
|
||||
if (SessionInfo.userId) {
|
||||
loadDashboardData();
|
||||
}
|
||||
}, [SessionInfo.userId]);
|
||||
|
||||
const loadDashboardData = async () => {
|
||||
try {
|
||||
const data = await getDashboardSummary({ sessionUser: SessionInfo.userId });
|
||||
setDashboardData(data);
|
||||
|
||||
const orgs = await loadOrganizationsByUser(SessionInfo.userId);
|
||||
setOrganizations(orgs);
|
||||
if (orgs.length > 0) {
|
||||
setSelectedOrgId(orgs[0].id);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error loading dashboard data", e);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedOrgId && SessionInfo.userId) {
|
||||
getCompanyMetrics({ sessionUser: SessionInfo.userId, companyId: selectedOrgId })
|
||||
.then(res => {
|
||||
setCompanyMetrics(res.metrics);
|
||||
setCompanySubscription(res.subscription);
|
||||
setIsCompanyOwner(res.isOwner);
|
||||
})
|
||||
.catch(e => {
|
||||
const errMsg = e.format ? e.format() : e.message;
|
||||
console.warn("Acceso denegado a métricas de la empresa: ", errMsg);
|
||||
setCompanyMetrics(null);
|
||||
setCompanySubscription(null);
|
||||
setIsCompanyOwner(false);
|
||||
});
|
||||
}
|
||||
}, [selectedOrgId, SessionInfo.userId]);
|
||||
|
||||
const getPaymentLink = () => {
|
||||
eventHandler.setEventType(EVENT_TYPES.LOADING);
|
||||
getSuscriptionInitPoint({
|
||||
sessionUser: SessionInfo.userId,
|
||||
})
|
||||
.then((response) => {
|
||||
goTo(response.init_point);
|
||||
})
|
||||
.catch((error) => {
|
||||
alert.showError(error.format());
|
||||
})
|
||||
.finally(() => {
|
||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||
});
|
||||
};
|
||||
|
||||
const cancelarSubscription = () => {
|
||||
confirm.show(
|
||||
"Estas a punto de cancelar tu suscripción. Al realizar esta acción dejaras de tener acceso a la información de tus organizaciones cuando se termine el periodo contratado. Puedes volver a suscribirte a cualquier otro plan y recuperarlo en el futuro. Estas seguro que quieres cancelar tu suscripcion?",
|
||||
() => {
|
||||
eventHandler.setEventType(EVENT_TYPES.LOADING);
|
||||
cancellSubscription({
|
||||
sessionUser: SessionInfo.userId,
|
||||
})
|
||||
.then(() => {
|
||||
SessionInfo.clearSubscription();
|
||||
setSubscription({
|
||||
...SessionInfo.subscription,
|
||||
mpStatus: MP_SUBS_STATUS.CANCELLED,
|
||||
});
|
||||
alert.showSuccess("La suscripción ha sido cancelada con éxito");
|
||||
})
|
||||
.catch((error) => {
|
||||
alert.showWarning(error.format());
|
||||
})
|
||||
.finally(() => {
|
||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||
});
|
||||
},
|
||||
() => {
|
||||
alert.showWarning("No se ha cancelado la suscripción");
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleNewAppointmentClick = () => {
|
||||
if (organizations.length === 0) {
|
||||
alert.showWarning("No perteneces a ninguna organización");
|
||||
return;
|
||||
}
|
||||
|
||||
if (organizations.length === 1) {
|
||||
const todayStr = dayjs().startOf('day').toISOString();
|
||||
goTo(`/admin/org/profile/${organizations[0].id}/appointments/${todayStr}/create`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mostrar listado de organizaciones para elegir usando alert / modal
|
||||
// Simplificado con un confirm adaptado o directamente yendo a mis organizaciones si no hay modal para esto.
|
||||
// Dado que turnosXpress usa confirm.show para preguntas, la mejor UI aquí sin crear un modal nuevo
|
||||
// es ir a /admin/org y pedirle que elija desde allí, o crear un modal custom.
|
||||
// Como pidió que se despliegue un listado, si tienen un componente modal, podríamos usarlo.
|
||||
// Por practicidad y reuso, voy a redirigirlo a Mis Organizaciones si son múltiples, pero en el comentario decia "desplegar un listado".
|
||||
// Voy a intentar redirigir a /admin/org y que elija ahí.
|
||||
alert.showWarning("Perteneces a múltiples organizaciones, selecciona una para crear el turno.");
|
||||
goTo("/admin/org");
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={turnosXpressTheme}>
|
||||
<ScrollToTop />
|
||||
<HeaderConfProvider />
|
||||
<Suspense fallback={<SuspenseLoading />}>
|
||||
<Secure>
|
||||
<AnimatedContainer
|
||||
color="#aeaeae"
|
||||
minSize={150}
|
||||
maxSize={500}
|
||||
items={10}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{SessionInfo.userId != "" && subscription.plan.name == "" && (
|
||||
<div className="homeCentered">No tiene una suscripción activa.</div>
|
||||
)}
|
||||
{subscription.plan.name != "" && (
|
||||
<div className={style.dashboardGrid}>
|
||||
|
||||
{/* Welcome Header */}
|
||||
<div className={style.welcomeCard}>
|
||||
{organizations.length > 1 && (
|
||||
<div style={{ position: "absolute", top: "20px", right: "20px", display: "flex", alignItems: "center", gap: "10px" }}>
|
||||
<span style={{ fontSize: "13px", fontWeight: "600", color: "#6b7280" }}>Organización:</span>
|
||||
<select
|
||||
value={selectedOrgId}
|
||||
onChange={e => setSelectedOrgId(e.target.value)}
|
||||
style={{ padding: "8px 12px", borderRadius: "8px", border: "1px solid #e5e7eb", fontSize: "14px", fontWeight: "500", backgroundColor: "#f9fafb", cursor: "pointer", outline: "none" }}
|
||||
>
|
||||
{organizations.map(org => (
|
||||
<option key={org.id} value={org.id}>{org.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className={style.welcomeContainer}>
|
||||
<div style={{display: 'flex', flexDirection: 'column', gap: '5px'}}>
|
||||
<h3>Bienvenido, {SessionInfo.userFullName}</h3>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<span style={{ fontWeight: 'bold' }}>{subscription.plan.name}</span>
|
||||
<span
|
||||
style={{
|
||||
backgroundColor:
|
||||
getSubStatusText(subscription.mpStatus) ===
|
||||
"ACTIVO"
|
||||
? "var(--sub-status-active)"
|
||||
: "var(--sub-status-inactive)",
|
||||
borderRadius: "12px",
|
||||
padding: "4px 10px",
|
||||
fontSize: "12px",
|
||||
color: "white",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
{getSubStatusText(subscription.mpStatus)}
|
||||
</span>
|
||||
</div>
|
||||
{subscription.endDate && subscription.plan.price > 0 && (
|
||||
<span style={{ fontSize: '14px', color: '#666' }}>
|
||||
Vigente hasta el {dayjs(subscription.endDate).format("DD/MM/YYYY")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{subscription.mpStatus === MP_SUBS_STATUS.PENDING && (
|
||||
<Button
|
||||
text="Ir a pagar"
|
||||
color="primary"
|
||||
style={{ marginTop: "10px" }}
|
||||
onClick={getPaymentLink}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={style.quickActions}>
|
||||
<div className={classNames(style.actionButton, style.buttonOrganizations)} onClick={() => goTo("/admin/org")}>
|
||||
<StorefrontOutlinedIcon /> Mis Organizaciones
|
||||
</div>
|
||||
<div className={classNames(style.actionButton, style.buttonNotification)} onClick={() => goTo("/user/profile/notifications")}>
|
||||
<NotificationsNoneOutlinedIcon /> Notificaciones
|
||||
</div>
|
||||
<div className={classNames(style.actionButton, style.buttonMessenger)} onClick={() => goTo("/messenger")}>
|
||||
<ChatBubbleOutlineOutlinedIcon /> Mensajes
|
||||
</div>
|
||||
<div className={classNames(style.actionButton, style.buttonOrganizations)} onClick={() => goTo("/admin/assistant")} style={{backgroundColor: "#800020", color: "white"}}>
|
||||
<AutoAwesomeIcon /> Asistente
|
||||
</div>
|
||||
<div className={classNames(style.actionButton)} style={{backgroundColor: "var(--wine-red)"}} onClick={handleNewAppointmentClick}>
|
||||
<AddCircleOutlineOutlinedIcon /> Nuevo Turno
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Left Column (Sidebar) */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
{/* Agenda del Día */}
|
||||
{dashboardData && dashboardData.role !== "NONE" && (
|
||||
<div className={style.agendaCard}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '15px' }}>
|
||||
<h3 style={{ margin: 0 }}>
|
||||
{showOnlyMine ? "Mi Agenda de Hoy" : "Agenda General de Hoy"}
|
||||
</h3>
|
||||
<label style={{ fontSize: '13px', display: 'flex', alignItems: 'center', gap: '5px', cursor: 'pointer', color: '#666' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showOnlyMine}
|
||||
onChange={(e) => setShowOnlyMine(e.target.checked)}
|
||||
/>
|
||||
Solo mis turnos
|
||||
</label>
|
||||
</div>
|
||||
<div className={style.agendaList}>
|
||||
{dashboardData.personalAgenda.filter(item => !showOnlyMine || item.collaboratorId === SessionInfo.userId).length === 0 && (
|
||||
<div style={{color: '#666', marginTop: '10px'}}>
|
||||
No hay turnos programados para hoy con estos filtros.
|
||||
</div>
|
||||
)}
|
||||
{dashboardData.personalAgenda
|
||||
.filter(item => !showOnlyMine || item.collaboratorId === SessionInfo.userId)
|
||||
.map(item => (
|
||||
<div key={item.id} className={style.agendaItem}>
|
||||
<span style={{fontWeight: 'bold'}}>{dayjs(item.appointmentDate).format("HH:mm")} - {item.clientName}</span>
|
||||
<span style={{fontSize: '14px', color: '#666'}}>{item.serviceName}</span>
|
||||
<span style={{fontSize: '12px', color: '#999', marginTop: '5px'}}>{item.collaboratorName}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plan Metrics (Only for ADMIN of the selected org) */}
|
||||
{companySubscription && companyMetrics && (
|
||||
<PlanMetricsWidget
|
||||
subscription={companySubscription}
|
||||
metrics={companyMetrics}
|
||||
isOwner={isCompanyOwner}
|
||||
onUpgrade={() => {
|
||||
const freePlan = companySubscription.plan.price === 0;
|
||||
|
||||
if (freePlan) {
|
||||
goTo("/landing/pricing");
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
companySubscription.mpStatus !==
|
||||
MP_SUBS_STATUS.CANCELLED
|
||||
) {
|
||||
confirm.show(
|
||||
"Para cambiarte a un plan superior, debes cancelar primero el plan actual.",
|
||||
() => {},
|
||||
() => {}
|
||||
);
|
||||
return;
|
||||
}
|
||||
goTo("/landing/pricing");
|
||||
}}
|
||||
onRenew={() => goTo("/landing/pricing")}
|
||||
onCancel={cancelarSubscription}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Column (Main Content) */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
{/* Rendimiento Financiero (Only for ADMIN) */}
|
||||
{dashboardData && dashboardData.role === "ADMIN" && (
|
||||
<FinancialWidget
|
||||
sessionUser={SessionInfo.userId}
|
||||
selectedOrgId={selectedOrgId}
|
||||
organizationName={organizations.find(o => o.id === selectedOrgId)?.name}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Repeats Pending Card */}
|
||||
{dashboardData && selectedOrgId && (
|
||||
<PendingRepeatsCard
|
||||
companyId={selectedOrgId}
|
||||
allowEmployeeFilter={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatedContainer>
|
||||
</Secure>
|
||||
</Suspense>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
.dashboardGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 30px auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
@media (min-width: 850px) {
|
||||
.dashboardGrid {
|
||||
grid-template-columns: 350px 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.welcomeCard {
|
||||
background-color: white;
|
||||
border-radius: 16px;
|
||||
padding: 30px;
|
||||
box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
grid-column: 1 / -1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.welcomeContainer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.metricsCard {
|
||||
background-color: white;
|
||||
border-radius: 16px;
|
||||
padding: 30px;
|
||||
box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.agendaCard {
|
||||
background-color: white;
|
||||
border-radius: 16px;
|
||||
padding: 30px;
|
||||
box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.agendaList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.agendaItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 15px;
|
||||
border-radius: 12px;
|
||||
background-color: #f9f9f9;
|
||||
border-left: 4px solid var(--wine-red);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.agendaItem:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.quickActions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 20px 10px;
|
||||
border-radius: 16px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.actionButton:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0px 6px 15px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.buttonOrganizations { background-color: var(--organization-card); }
|
||||
.buttonNotification { background-color: var(--red); }
|
||||
.buttonMessenger { background-color: var(--orange); }
|
||||
|
||||
.planMetricsBottonInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media (min-width: 850px) {
|
||||
.planMetricsBottonInfo {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ApiError } from "@core/Models/Server.Error.model";
|
||||
import { PaginateServicesParams, PublicServicesResults } from "@models/Service.model";
|
||||
import ApiRequest from "@services/Api.Service";
|
||||
import * as Yup from "yup";
|
||||
|
||||
const schemaFilterServicces = Yup.object().shape({
|
||||
priceFrom: Yup.number().optional().typeError("El precio debe ser un número."),
|
||||
priceTo: Yup.number().optional().typeError("El precio debe ser un número."),
|
||||
lengthFrom: Yup.number()
|
||||
.optional()
|
||||
.typeError("La duración debe ser un número expresado en minutos."),
|
||||
lengthTo: Yup.number()
|
||||
.optional()
|
||||
.typeError("La duración debe ser un número expresado en minutos."),
|
||||
});
|
||||
|
||||
export const searchPublicServices = async (
|
||||
data: PaginateServicesParams
|
||||
): Promise<PublicServicesResults> => {
|
||||
return new Promise<PublicServicesResults>((resolve, reject) => {
|
||||
schemaFilterServicces
|
||||
.validate(data, { abortEarly: true })
|
||||
.then(() => {
|
||||
resolve(ApiRequest.post<PublicServicesResults>("services/search", data));
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(new ApiError(500, error.message));
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,253 @@
|
||||
"use client";
|
||||
import AnimatedContainer from "@components/AnimatedConainer/AnimatedContainer";
|
||||
import ProductItem, { FindResultsViewMode } from "@core/app/components/ProductItem/ProductItem";
|
||||
import { PublicServicesResults } from "@models/Service.model";
|
||||
import { useEffect, useState } from "react";
|
||||
import { searchPublicServices } from "./Find.Service";
|
||||
import { HEADER_WIDGETS, useHeaderStore } from "@core/Store/Header.Store";
|
||||
import Filters from "@core/app/components/Home/Filters/Filters";
|
||||
import { useEventHandlerStore, EVENT_TYPES } from "@core/Store/EventHandler.Store";
|
||||
import { useAlert } from "@core/Store/Alert.Store";
|
||||
import { Breadcrumbs, ButtonGroup, IconButton } from "@mui/material";
|
||||
import Link from "next/link";
|
||||
import GridViewOutlinedIcon from "@mui/icons-material/GridViewOutlined";
|
||||
import SplitscreenOutlinedIcon from "@mui/icons-material/SplitscreenOutlined";
|
||||
import FilterAltIcon from "@mui/icons-material/FilterAlt";
|
||||
import Pagination from "@mui/material/Pagination";
|
||||
import useWindowSize from "@core/app/hooks/WindowSize";
|
||||
import { ScrollToTop } from "@core/app/components/ScrollTop";
|
||||
import DefaultLoginAction from "@core/app/components/LoginAction/DefaultLoginAction";
|
||||
|
||||
export default function FindPage() {
|
||||
const headerState = useHeaderStore();
|
||||
const eventHandler = useEventHandlerStore();
|
||||
const alert = useAlert();
|
||||
|
||||
const [windowWidth] = useWindowSize();
|
||||
|
||||
const [filterPage, setFilterPage] = useState<number>(1);
|
||||
const [filterCompany, setFilterCompany] = useState<string>("");
|
||||
const [filterCategory, setFilterCategory] = useState<string>("");
|
||||
const [filterLocation, setFilterLocation] = useState<string>("");
|
||||
const [filterPriceFrom, setFilterPriceFrom] = useState<number | null>(null);
|
||||
const [filterPriceTo, setFilterPriceTo] = useState<number | null>(null);
|
||||
const [filterDurationFrom, setFilterDurationFrom] = useState<number | null>(null);
|
||||
const [filterDurationTo, setFilterDurationTo] = useState<number | null>(null);
|
||||
|
||||
const [viewMode, setViewMode] = useState<FindResultsViewMode>("card");
|
||||
const [filterVisible, setFilterVisible] = useState<boolean>(true);
|
||||
|
||||
const [services, setServices] = useState<PublicServicesResults>({
|
||||
data: [],
|
||||
page: filterPage,
|
||||
pages: 0,
|
||||
});
|
||||
|
||||
const getFilter = () => {
|
||||
let filter = {};
|
||||
|
||||
if (filterCompany) {
|
||||
filter = { ...filter, ...{ companyId: filterCompany } };
|
||||
}
|
||||
|
||||
if (filterCategory) {
|
||||
filter = { ...filter, ...{ categoryId: parseInt(filterCategory) } };
|
||||
}
|
||||
|
||||
if (filterLocation) {
|
||||
filter = { ...filter, ...{ block: filterLocation } };
|
||||
}
|
||||
|
||||
if (filterPriceFrom) {
|
||||
filter = { ...filter, ...{ priceFrom: filterPriceFrom } };
|
||||
}
|
||||
|
||||
if (filterPriceTo) {
|
||||
filter = { ...filter, ...{ priceTo: filterPriceTo } };
|
||||
}
|
||||
|
||||
if (filterDurationFrom) {
|
||||
filter = { ...filter, ...{ lengthFrom: filterDurationFrom } };
|
||||
}
|
||||
|
||||
if (filterDurationTo) {
|
||||
filter = { ...filter, ...{ lengthTo: filterDurationTo } };
|
||||
}
|
||||
return filter;
|
||||
};
|
||||
|
||||
const cargarServicios = () => {
|
||||
eventHandler.setEventType(EVENT_TYPES.LOADING);
|
||||
|
||||
const data = getFilter();
|
||||
|
||||
searchPublicServices({
|
||||
...{
|
||||
query: headerState.publicFindText,
|
||||
page: filterPage,
|
||||
limit: 20,
|
||||
},
|
||||
...data,
|
||||
})
|
||||
.then((res) => {
|
||||
setServices(res);
|
||||
})
|
||||
.catch((error) => {
|
||||
alert.showError(error.format());
|
||||
})
|
||||
.finally(() => {
|
||||
headerState.setPublicFindTrigger(false);
|
||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (headerState.publicFindTrigger) {
|
||||
cargarServicios();
|
||||
}
|
||||
}, [
|
||||
headerState.publicFindTrigger,
|
||||
filterCompany,
|
||||
filterCategory,
|
||||
filterLocation,
|
||||
filterPriceFrom,
|
||||
filterPriceTo,
|
||||
filterDurationFrom,
|
||||
filterDurationTo,
|
||||
filterPage,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (windowWidth < 850) {
|
||||
setFilterVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setFilterVisible(true);
|
||||
}, [windowWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (windowWidth < 850) {
|
||||
setViewMode("grid");
|
||||
return;
|
||||
}
|
||||
|
||||
setViewMode("card");
|
||||
headerState.setActiveWidget(HEADER_WIDGETS.PUBLIC_FIND);
|
||||
}, []);
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<unknown>, value: number) => {
|
||||
setFilterPage(value);
|
||||
headerState.setPublicFindTrigger(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatedContainer
|
||||
color="#aeaeae"
|
||||
minSize={150}
|
||||
maxSize={500}
|
||||
items={10}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<ScrollToTop />
|
||||
<DefaultLoginAction />
|
||||
<div
|
||||
className="homeCentered"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
paddingLeft: "40px",
|
||||
}}
|
||||
>
|
||||
<Breadcrumbs
|
||||
aria-label="breadcrumb"
|
||||
sx={{
|
||||
"& .MuiBreadcrumbs-separator": {
|
||||
color: "inherit",
|
||||
},
|
||||
marginTop: "15px",
|
||||
}}
|
||||
>
|
||||
<Link href="/">Inicio</Link>
|
||||
<span>Buscar servicios</span>
|
||||
</Breadcrumbs>
|
||||
<ButtonGroup
|
||||
variant="outlined"
|
||||
aria-label="Basic button group"
|
||||
sx={{ marginTop: "10px", marginRight: "30px" }}
|
||||
>
|
||||
{windowWidth < 850 && (
|
||||
<IconButton onClick={() => setFilterVisible(!filterVisible)}>
|
||||
<FilterAltIcon
|
||||
sx={{ color: filterVisible ? "var(--wine-red)" : "ineherit" }}
|
||||
/>
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton onClick={() => setViewMode("card")}>
|
||||
<GridViewOutlinedIcon
|
||||
sx={{ color: viewMode === "card" ? "var(--wine-red)" : "ineherit" }}
|
||||
/>
|
||||
</IconButton>
|
||||
<IconButton onClick={() => setViewMode("grid")}>
|
||||
<SplitscreenOutlinedIcon
|
||||
sx={{ color: viewMode === "grid" ? "var(--wine-red)" : "ineherit" }}
|
||||
/>
|
||||
</IconButton>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
<div
|
||||
className="homeCentered"
|
||||
style={{
|
||||
paddingTop: "10px",
|
||||
paddingLeft: "37px",
|
||||
paddingRight: "0px",
|
||||
paddingBottom: "0px",
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
justifyContent: "start",
|
||||
gap: "20px",
|
||||
minHeight: "700px",
|
||||
}}
|
||||
>
|
||||
<Filters
|
||||
onFilter={cargarServicios}
|
||||
setFilterCompany={setFilterCompany}
|
||||
setFilterCategory={setFilterCategory}
|
||||
setFilterLocation={setFilterLocation}
|
||||
setFilterDurationFrom={setFilterDurationFrom}
|
||||
setFilterDurationTo={setFilterDurationTo}
|
||||
setFilterPriceFrom={setFilterPriceFrom}
|
||||
setFilterPriceTo={setFilterPriceTo}
|
||||
visible={filterVisible}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
padding: "0px 0px 40px 0px",
|
||||
display: "flex",
|
||||
flexDirection: viewMode === "card" ? "row" : "column",
|
||||
justifyContent: "start",
|
||||
gap: "20px",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
{services.data.map((service) => (
|
||||
<ProductItem
|
||||
product={service}
|
||||
key={service.id}
|
||||
width={260}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "center", marginBottom: "20px" }}>
|
||||
<Pagination count={services.pages} variant="outlined" onChange={handleChange} />
|
||||
</div>
|
||||
</AnimatedContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
import AnimatedContainer from "@components/AnimatedConainer/AnimatedContainer";
|
||||
import ExploreCategories from "@core/app/components/ExploreCategories/ExploreCategories";
|
||||
import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider";
|
||||
import useContainerHeight from "@core/app/hooks/ContainerHeight";
|
||||
import { ScrollToTop } from "@core/app/components/ScrollTop";
|
||||
import DefaultLoginAction from "@core/app/components/LoginAction/DefaultLoginAction";
|
||||
|
||||
export default function GetAppointmentsPage() {
|
||||
const containerHeight = useContainerHeight();
|
||||
|
||||
return (
|
||||
<AnimatedContainer
|
||||
color="#aeaeae"
|
||||
minSize={150}
|
||||
maxSize={500}
|
||||
items={10}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<ScrollToTop />
|
||||
<HeaderConfProvider />
|
||||
<DefaultLoginAction />
|
||||
<div className="homeCentered">
|
||||
<h1 className="homeTitleHeader">Quiero reservar turno</h1>
|
||||
<p className="homeResumeHeader">
|
||||
Reservar un turno es muy fácil. Solo tenés que explorar nuestras categorías y
|
||||
buscar el servicio más adecuado para vos. Una vez que ingreses al servicio,
|
||||
podrás realizar la reserva. Tené en cuenta que, para poder reservar, debés estar
|
||||
registrado en nuestro sitio web.
|
||||
</p>
|
||||
</div>
|
||||
<div className="homeCentered">
|
||||
<ExploreCategories containerHeight={containerHeight} />
|
||||
|
||||
<div style={{ height: "100px" }}> </div>
|
||||
</div>
|
||||
</AnimatedContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import Alert from "@components/Alert/Alert";
|
||||
import Confirm from "@components/Confirm/Confirm";
|
||||
import Header from "@components/Header/Header";
|
||||
import UserMenu from "@components/UserMenu/UserMenu";
|
||||
import Footer from "@components/Home/Footer/Footer";
|
||||
import Loading from "@components/Loading/Loading";
|
||||
import TextObjectModal from "../components/TextObjectFilter/TextObjectModal";
|
||||
import MessengerIcon from "../components/MessengerIcon/MessengerIcon";
|
||||
import CheckUserPhone from "../components/CheckUserPhone/CheckUserPhone";
|
||||
import CheckPendingInvitations from "../components/CheckPendingInvitations/CheckPendingInvitations";
|
||||
|
||||
export default function LandingLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<div>
|
||||
<Header />
|
||||
<div className="mainSectionWhite">{children}</div>
|
||||
<Footer />
|
||||
<UserMenu />
|
||||
<MessengerIcon />
|
||||
<Alert />
|
||||
<Confirm />
|
||||
<Loading />
|
||||
<TextObjectModal />
|
||||
<CheckUserPhone />
|
||||
<CheckPendingInvitations />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
"use client";
|
||||
import Login from "@components/Home/Login/Login";
|
||||
|
||||
export default function LoginPage() {
|
||||
return <Login verification={false} />;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
import Login from "@components/Home/Login/Login";
|
||||
import { EVENT_TYPES, useEventHandlerStore } from "@store/EventHandler.Store";
|
||||
import { useEffect } from "react";
|
||||
export default function LoginVerificationPage() {
|
||||
const eventHandler = useEventHandlerStore();
|
||||
|
||||
useEffect(() => {
|
||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||
}, []);
|
||||
return <Login verification={true} />;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import ApiRequest from "@services/Api.Service";
|
||||
import * as Yup from "yup";
|
||||
import { ApiError } from "@core/Models/Server.Error.model";
|
||||
import {
|
||||
AppointmentAdminByClientView,
|
||||
DeleteAppointmentParams,
|
||||
FindAppointmentsByUserParams,
|
||||
FindAppointmentsByCollaboratorParams,
|
||||
FindAppointmentsByUserPaginatedParams,
|
||||
FindAppointmentsByCollaboratorPaginatedParams,
|
||||
PaginatedAppointmentEventByClientResult,
|
||||
} from "@core/Models/Appointments.model";
|
||||
import { UserAppointmentView, UserAppointmentViewParams } from "@core/Models/Views.model";
|
||||
import { ApiVoidResult } from "@core/Models/Api.VoidResult.type";
|
||||
|
||||
const schemaCancellAppointment = Yup.object().shape({
|
||||
id: Yup.string().required("El turno no ha sido especificado."),
|
||||
validation: Yup.boolean().oneOf([true], "No es posible cancelar la reserva."),
|
||||
sessionUser: Yup.string().required("Para cancelar un turno debe iniciar sesión."),
|
||||
});
|
||||
|
||||
export const deleteAppointment = async (data: DeleteAppointmentParams): Promise<ApiVoidResult> => {
|
||||
return new Promise<ApiVoidResult>((resolve, reject) => {
|
||||
schemaCancellAppointment
|
||||
.validate(data, { abortEarly: true })
|
||||
.then(() => {
|
||||
resolve(ApiRequest.post<ApiVoidResult>("appointments/delete", data));
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(new ApiError(500, error.message));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const schemaFindAppointmentByUser = Yup.object().shape({
|
||||
sessionUser: Yup.string().required("Para crear un turno debe iniciar sesión."),
|
||||
});
|
||||
|
||||
export const findAppointmentByUser = async (
|
||||
data: FindAppointmentsByUserParams
|
||||
): Promise<AppointmentAdminByClientView> => {
|
||||
return new Promise<AppointmentAdminByClientView>((resolve, reject) => {
|
||||
schemaFindAppointmentByUser
|
||||
.validate(data, { abortEarly: true })
|
||||
.then(() => {
|
||||
resolve(
|
||||
ApiRequest.post<AppointmentAdminByClientView>("appointments/find-by-user", data)
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(new ApiError(500, error.message));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const findAppointmentByCollaborator = async (
|
||||
data: FindAppointmentsByCollaboratorParams
|
||||
): Promise<AppointmentAdminByClientView> => {
|
||||
return new Promise<AppointmentAdminByClientView>((resolve, reject) => {
|
||||
schemaFindAppointmentByUser
|
||||
.validate(data, { abortEarly: true })
|
||||
.then(() => {
|
||||
resolve(
|
||||
ApiRequest.post<AppointmentAdminByClientView>("appointments/find-by-collaborator", data)
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(new ApiError(500, error.message));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const schemaFindAppointmentByUserPaginated = Yup.object().shape({
|
||||
sessionUser: Yup.string().required("Para crear un turno debe iniciar sesión."),
|
||||
page: Yup.number().required(),
|
||||
limit: Yup.number().required(),
|
||||
});
|
||||
|
||||
export const findAppointmentByUserPaginated = async (
|
||||
data: FindAppointmentsByUserPaginatedParams
|
||||
): Promise<PaginatedAppointmentEventByClientResult> => {
|
||||
return new Promise<PaginatedAppointmentEventByClientResult>((resolve, reject) => {
|
||||
schemaFindAppointmentByUserPaginated
|
||||
.validate(data, { abortEarly: true })
|
||||
.then(() => {
|
||||
resolve(ApiRequest.post<PaginatedAppointmentEventByClientResult>("appointments/find-by-user-paginated", data));
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(new ApiError(500, error.message));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const schemaFindAppointmentByCollaboratorPaginated = Yup.object().shape({
|
||||
sessionUser: Yup.string().required("Para crear un turno debe iniciar sesión."),
|
||||
page: Yup.number().required(),
|
||||
limit: Yup.number().required(),
|
||||
});
|
||||
|
||||
export const findAppointmentByCollaboratorPaginated = async (
|
||||
data: FindAppointmentsByCollaboratorPaginatedParams
|
||||
): Promise<PaginatedAppointmentEventByClientResult> => {
|
||||
return new Promise<PaginatedAppointmentEventByClientResult>((resolve, reject) => {
|
||||
schemaFindAppointmentByCollaboratorPaginated
|
||||
.validate(data, { abortEarly: true })
|
||||
.then(() => {
|
||||
resolve(ApiRequest.post<PaginatedAppointmentEventByClientResult>("appointments/find-by-collaborator-paginated", data));
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(new ApiError(500, error.message));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const schemaViewAppointment = Yup.object().shape({
|
||||
appointmentId: Yup.string().required("No se ha proporcionado ningun turno."),
|
||||
sessionUser: Yup.string().required("Para visuzlizar el turno debe iniciar sesión."),
|
||||
});
|
||||
|
||||
export const viewAppointment = async (
|
||||
data: UserAppointmentViewParams
|
||||
): Promise<UserAppointmentView> => {
|
||||
return new Promise<UserAppointmentView>((resolve, reject) => {
|
||||
schemaViewAppointment
|
||||
.validate(data, { abortEarly: true })
|
||||
.then(() => {
|
||||
resolve(ApiRequest.post<UserAppointmentView>("views/appointment", data));
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(new ApiError(500, error.message));
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
.organizationContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
margin: 30px;
|
||||
}
|
||||
|
||||
@media (min-width: 801px) {
|
||||
.organizationContent {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.organizationServices {
|
||||
flex: 1.5;
|
||||
background: #ffffff;
|
||||
border-radius: 16px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.organizationServices h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.introText {
|
||||
font-size: 15px;
|
||||
color: #64748b;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.infoGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.infoGrid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.infoCard {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 15px;
|
||||
background: #f8fafc;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.infoCard:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.infoIconWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: #ffffff;
|
||||
border-radius: 50%;
|
||||
color: var(--primary);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.infoContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.infoLabel {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.infoValue {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.infoSubValue {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.organizationMap {
|
||||
flex: 1;
|
||||
background: #ffffff;
|
||||
border-radius: 16px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.organizationMap h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mapWrapper {
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
/* Extra specific border/radius for child maps if needed */
|
||||
}
|
||||
|
||||
.contactInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.contactRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.contactRow svg {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
"use client";
|
||||
import AnimatedContainer from "@components/AnimatedConainer/AnimatedContainer";
|
||||
import WhatsAppIcon from "@mui/icons-material/WhatsApp";
|
||||
import LocationOnIcon from "@mui/icons-material/LocationOn";
|
||||
import Button from "@core/app/components/Button/Button";
|
||||
import LocationMap from "@core/app/components/LocationMap/LocationMap";
|
||||
import EventIcon from "@mui/icons-material/Event";
|
||||
import LocalOfferIcon from "@mui/icons-material/LocalOffer";
|
||||
import PersonIcon from "@mui/icons-material/Person";
|
||||
import AttachMoneyIcon from "@mui/icons-material/AttachMoney";
|
||||
import { PublicOrganizationView } from "@core/Models/Company.model";
|
||||
import OrganizationHeader from "@core/app/components/Home/OrganizationHeader/OrganizationHeader";
|
||||
import { UserAppointmentView, UserAppointmentViewParams } from "@core/Models/Views.model";
|
||||
import { useEffect, useState } from "react";
|
||||
import { deleteAppointment, viewAppointment } from "../Appointments.Service";
|
||||
import { useAlert } from "@core/Store/Alert.Store";
|
||||
import { useParams } from "next/navigation";
|
||||
import { EVENT_TYPES, useEventHandlerStore } from "@core/Store/EventHandler.Store";
|
||||
import { useSessionStore } from "@core/Store/Sesion.Store";
|
||||
import style from "./page.module.css";
|
||||
import dayjs from "dayjs";
|
||||
import "dayjs/locale/es";
|
||||
import { formatPrice } from "@core/app/helpers/Numbers";
|
||||
import { DeleteAppointmentParams } from "@core/Models/Appointments.model";
|
||||
import { useConfirmStore } from "@core/Store/Confirm.Store";
|
||||
import { useNavigation } from "@core/app/hooks/goto";
|
||||
import { ScrollToTop } from "@core/app/components/ScrollTop";
|
||||
|
||||
dayjs.locale("es");
|
||||
|
||||
type UrlData = {
|
||||
aid: string;
|
||||
};
|
||||
|
||||
type PositionType = [number, number];
|
||||
|
||||
export default function OrganizationPublicProfile() {
|
||||
const data = useParams<UrlData>();
|
||||
const aid = data.aid;
|
||||
const [appointmentInfo, setAppointmentInfo] = useState<UserAppointmentView | null>(null);
|
||||
const [orgData, setOrgData] = useState<PublicOrganizationView | null>(null);
|
||||
const [position, setPosition] = useState<PositionType>([0, 0]);
|
||||
|
||||
const alert = useAlert();
|
||||
const confirm = useConfirmStore();
|
||||
const eventHandler = useEventHandlerStore();
|
||||
const SessionInfo = useSessionStore();
|
||||
const { goTo } = useNavigation();
|
||||
|
||||
const loadAppointmentData = async () => {
|
||||
const data: UserAppointmentViewParams = {
|
||||
appointmentId: aid,
|
||||
sessionUser: SessionInfo.userId,
|
||||
};
|
||||
|
||||
eventHandler.setEventType(EVENT_TYPES.LOADING);
|
||||
|
||||
viewAppointment(data)
|
||||
.then((response) => {
|
||||
setAppointmentInfo(response);
|
||||
setOrgData(response.organization);
|
||||
setPosition([response.organization.latitude, response.organization.longitude]);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
alert.showError(error.format());
|
||||
})
|
||||
.finally(() => {
|
||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancellAppointment = () => {
|
||||
confirm.show(
|
||||
"¿Está seguro que desea cancelar el turno?",
|
||||
() => {
|
||||
const data: DeleteAppointmentParams = {
|
||||
id: aid,
|
||||
validation: true,
|
||||
sessionUser: SessionInfo.userId,
|
||||
};
|
||||
eventHandler.setEventType(EVENT_TYPES.LOADING);
|
||||
deleteAppointment(data)
|
||||
.then(() => {
|
||||
alert.showSuccess("Turno cancelado correctamente.");
|
||||
setTimeout(() => {
|
||||
goTo("/landing/my-appointments");
|
||||
}, 2000);
|
||||
})
|
||||
.catch((error) => {
|
||||
alert.showError(error.format());
|
||||
})
|
||||
.finally(() => {
|
||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||
});
|
||||
},
|
||||
() => {}
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!SessionInfo.userId) {
|
||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||
return;
|
||||
}
|
||||
loadAppointmentData();
|
||||
}, [SessionInfo.userId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{appointmentInfo && orgData && (
|
||||
<AnimatedContainer
|
||||
color={orgData.headerColor}
|
||||
minSize={150}
|
||||
maxSize={500}
|
||||
items={10}
|
||||
style={{
|
||||
background: `linear-gradient(180deg, ${orgData.headerColor} 0%, var(--white) 50%)`,
|
||||
}}
|
||||
>
|
||||
<ScrollToTop />
|
||||
<div className="homeCentered">
|
||||
<OrganizationHeader organization={orgData} />
|
||||
<div className={style.organizationContent}>
|
||||
<div className={style.organizationServices}>
|
||||
<h1>Detalles de tu Reserva</h1>
|
||||
<p className={style.introText}>
|
||||
Bienvenido a los detalles de tu turno. Aquí encontrarás toda la
|
||||
información sobre tu reserva, así como opciones para cancelarla
|
||||
o ponerte en contacto directo con nosotros.
|
||||
</p>
|
||||
|
||||
<div className={style.infoGrid}>
|
||||
<div className={style.infoCard}>
|
||||
<div className={style.infoIconWrapper}>
|
||||
<EventIcon />
|
||||
</div>
|
||||
<div className={style.infoContent}>
|
||||
<span className={style.infoLabel}>Fecha y Hora</span>
|
||||
<span className={style.infoValue}>
|
||||
{dayjs(appointmentInfo.appointment.appointmentDate)
|
||||
.format("D [de] MMMM [de] YYYY - HH:mm [hs]")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style.infoCard}>
|
||||
<div className={style.infoIconWrapper}>
|
||||
<LocalOfferIcon />
|
||||
</div>
|
||||
<div className={style.infoContent}>
|
||||
<span className={style.infoLabel}>Servicio</span>
|
||||
<span className={style.infoValue}>
|
||||
{appointmentInfo.appointment.serviceName}
|
||||
</span>
|
||||
<span className={style.infoSubValue}>
|
||||
{appointmentInfo.appointment.serviceDescription}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style.infoCard}>
|
||||
<div className={style.infoIconWrapper}>
|
||||
<PersonIcon />
|
||||
</div>
|
||||
<div className={style.infoContent}>
|
||||
<span className={style.infoLabel}>Profesional</span>
|
||||
<span className={style.infoValue}>
|
||||
{appointmentInfo.appointment.collaboratorName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style.infoCard}>
|
||||
<div className={style.infoIconWrapper}>
|
||||
<AttachMoneyIcon />
|
||||
</div>
|
||||
<div className={style.infoContent}>
|
||||
<span className={style.infoLabel}>Precio</span>
|
||||
<span className={style.infoValue}>
|
||||
{formatPrice(appointmentInfo.appointment.price)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style.organizationMap}>
|
||||
<h2>Información Útil</h2>
|
||||
<div className={style.mapWrapper}>
|
||||
<LocationMap position={position} orgData={orgData} />
|
||||
</div>
|
||||
<div className={style.contactInfo}>
|
||||
<div className={style.contactRow}>
|
||||
<LocationOnIcon />
|
||||
<span>{orgData.address}</span>
|
||||
</div>
|
||||
<div className={style.contactRow}>
|
||||
<WhatsAppIcon />
|
||||
<span>{orgData.phone}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.actionButtons}>
|
||||
<Button
|
||||
color="primary"
|
||||
text="Cancelar turno"
|
||||
width="100%"
|
||||
onClick={handleCancellAppointment}
|
||||
/>
|
||||
<Button
|
||||
color="secondary"
|
||||
text="Enviar mensaje"
|
||||
width="100%"
|
||||
onClick={() => {
|
||||
goTo(`/landing/org/${orgData.id}/send-message`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedContainer>
|
||||
)}
|
||||
{appointmentInfo === null && <h1>Cargando...</h1>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
"use client";
|
||||
import AnimatedContainer from "@components/AnimatedConainer/AnimatedContainer";
|
||||
import { useNavigation } from "@core/app/hooks/goto";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
findAppointmentByUserPaginated,
|
||||
findAppointmentByCollaboratorPaginated,
|
||||
} from "./Appointments.Service";
|
||||
import { EVENT_TYPES, useEventHandlerStore } from "@core/Store/EventHandler.Store";
|
||||
import { useAlert } from "@core/Store/Alert.Store";
|
||||
import { AppointmentEventByClient } from "@core/Models/Appointments.model";
|
||||
import { useSessionStore } from "@core/Store/Sesion.Store";
|
||||
import dayjs from "dayjs";
|
||||
import "dayjs/locale/es";
|
||||
import ExploreCategories from "@core/app/components/ExploreCategories/ExploreCategories";
|
||||
import useContainerHeight from "@core/app/hooks/ContainerHeight";
|
||||
import { ScrollToTop } from "@core/app/components/ScrollTop";
|
||||
import PendingRepeatsCard from "@core/app/components/PendingRepeatsCard/PendingRepeatsCard";
|
||||
import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider";
|
||||
import { Button, Tabs, Tab, Box } from "@mui/material";
|
||||
import { ThemeProvider } from "@emotion/react";
|
||||
import turnosXpressTheme from "@core/app/theme/turnosXpress";
|
||||
|
||||
dayjs.locale("es");
|
||||
|
||||
interface TabPanelProps {
|
||||
children?: React.ReactNode;
|
||||
index: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
function CustomTabPanel(props: TabPanelProps) {
|
||||
const { children, value, index, ...other } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="tabpanel"
|
||||
hidden={value !== index}
|
||||
id={`appointments-tabpanel-${index}`}
|
||||
aria-labelledby={`appointments-tab-${index}`}
|
||||
style={{ width: "100%" }}
|
||||
{...other}
|
||||
>
|
||||
{value === index && <Box sx={{ p: 3, width: "100%" }}>{children}</Box>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function a11yProps(index: number) {
|
||||
return {
|
||||
id: `appointments-tab-${index}`,
|
||||
"aria-controls": `appointments-tabpanel-${index}`,
|
||||
};
|
||||
}
|
||||
|
||||
const AppointmentList = ({ appointments, containerHeight, emptyMessage, goTo, isProfessionalView }: { appointments: any, containerHeight: any, emptyMessage: string, goTo: any, isProfessionalView: boolean }) => {
|
||||
if (appointments.length === 0) {
|
||||
return <ExploreCategories containerHeight={containerHeight} message={emptyMessage} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "flex-start",
|
||||
marginTop: "20px",
|
||||
width: "100%",
|
||||
gap: "20px",
|
||||
marginBottom: "40px",
|
||||
minHeight: "50vh",
|
||||
}}
|
||||
>
|
||||
{appointments.map((item: AppointmentEventByClient, index: number) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "flex-start",
|
||||
width: "100%",
|
||||
gap: "20px",
|
||||
borderBottom: "solid 1px var(--gray-light)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "120px",
|
||||
minWidth: "120px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
borderRight: "solid 1px var(--gray-light)",
|
||||
color: "var(--black)",
|
||||
marginBottom: "20px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
fontSize: "60px",
|
||||
color: "var(--wine-red)",
|
||||
}}
|
||||
>
|
||||
{dayjs(item.appointmentDate).format("D")}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
fontSize: "20px",
|
||||
textTransform: "uppercase",
|
||||
color: "var(--wine-red)",
|
||||
}}
|
||||
>
|
||||
{dayjs(item.appointmentDate).format("MMMM").substring(0, 3)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
paddingTop: "15px",
|
||||
color: "var(--black)",
|
||||
cursor: "pointer",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<h1 style={{ fontSize: "24px" }}>{item.serviceName}</h1>
|
||||
<p>{item.serviceDescription}</p>
|
||||
<p
|
||||
style={{
|
||||
color: "var(--gray-dark)",
|
||||
fontSize: "14px",
|
||||
marginTop: "4px",
|
||||
marginBottom: "4px",
|
||||
}}
|
||||
>
|
||||
{isProfessionalView ? (
|
||||
<>
|
||||
Cliente:{" "}
|
||||
<strong style={{ color: "var(--black)" }}>{item.clientName}</strong>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Profesional:{" "}
|
||||
<strong style={{ color: "var(--black)" }}>{item.collaboratorName}</strong>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<p style={{ color: "var(--gray-dark)", fontWeight: "bold" }}>
|
||||
{dayjs(item.appointmentDate).format("HH:mm")} hs.
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
marginBottom: "20px",
|
||||
}}
|
||||
>
|
||||
<ThemeProvider theme={turnosXpressTheme}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
if (isProfessionalView) {
|
||||
goTo(
|
||||
`/admin/org/${item.companyId}/client/${item.clientId}/appointments/${item.id}`,
|
||||
);
|
||||
} else {
|
||||
goTo(`/landing/my-appointments/${item.id}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Ver turno
|
||||
</Button>
|
||||
</ThemeProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function MyAppointmentsPage() {
|
||||
const [clientAppointments, setClientAppointments] = useState<AppointmentEventByClient[]>([]);
|
||||
const [collaboratorAppointments, setCollaboratorAppointments] = useState<AppointmentEventByClient[]>([]);
|
||||
const [clientPage, setClientPage] = useState(1);
|
||||
const [clientHasMore, setClientHasMore] = useState(false);
|
||||
const [collaboratorPage, setCollaboratorPage] = useState(1);
|
||||
const [collaboratorHasMore, setCollaboratorHasMore] = useState(false);
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
const containerHeight = useContainerHeight();
|
||||
const eventHandler = useEventHandlerStore();
|
||||
const SessionInfo = useSessionStore();
|
||||
const alert = useAlert();
|
||||
const { goTo } = useNavigation();
|
||||
|
||||
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
|
||||
setTabValue(newValue);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!SessionInfo.userId) {
|
||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||
return;
|
||||
}
|
||||
eventHandler.setEventType(EVENT_TYPES.LOADING);
|
||||
|
||||
Promise.all([
|
||||
findAppointmentByUserPaginated({ sessionUser: SessionInfo.userId, page: 1, limit: 10 }),
|
||||
findAppointmentByCollaboratorPaginated({ sessionUser: SessionInfo.userId, page: 1, limit: 10 }),
|
||||
])
|
||||
.then(([resUser, resCollaborator]) => {
|
||||
setClientAppointments(resUser.data);
|
||||
setClientHasMore(resUser.page < resUser.pages);
|
||||
|
||||
setCollaboratorAppointments(resCollaborator.data);
|
||||
setCollaboratorHasMore(resCollaborator.page < resCollaborator.pages);
|
||||
|
||||
if (resUser.data.length === 0 && resCollaborator.data.length > 0) {
|
||||
setTabValue(1);
|
||||
}
|
||||
})
|
||||
.catch((error: Error | any) => {
|
||||
if (error.format) {
|
||||
alert.showError(error.format());
|
||||
} else {
|
||||
alert.showError(error.message);
|
||||
}
|
||||
console.error(error);
|
||||
})
|
||||
.finally(() => {
|
||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||
});
|
||||
}, [SessionInfo.userId]);
|
||||
|
||||
const handleLoadMoreClient = () => {
|
||||
const nextPage = clientPage + 1;
|
||||
eventHandler.setEventType(EVENT_TYPES.LOADING);
|
||||
findAppointmentByUserPaginated({ sessionUser: SessionInfo.userId, page: nextPage, limit: 10 })
|
||||
.then((res) => {
|
||||
setClientAppointments((prev) => [...prev, ...res.data]);
|
||||
setClientPage(nextPage);
|
||||
setClientHasMore(res.page < res.pages);
|
||||
})
|
||||
.catch((error) => alert.showError(error.message))
|
||||
.finally(() => eventHandler.setEventType(EVENT_TYPES.SLEEP));
|
||||
};
|
||||
|
||||
const handleLoadMoreCollaborator = () => {
|
||||
const nextPage = collaboratorPage + 1;
|
||||
eventHandler.setEventType(EVENT_TYPES.LOADING);
|
||||
findAppointmentByCollaboratorPaginated({ sessionUser: SessionInfo.userId, page: nextPage, limit: 10 })
|
||||
.then((res) => {
|
||||
setCollaboratorAppointments((prev) => [...prev, ...res.data]);
|
||||
setCollaboratorPage(nextPage);
|
||||
setCollaboratorHasMore(res.page < res.pages);
|
||||
})
|
||||
.catch((error) => alert.showError(error.message))
|
||||
.finally(() => eventHandler.setEventType(EVENT_TYPES.SLEEP));
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatedContainer
|
||||
color="#aeaeae"
|
||||
minSize={150}
|
||||
maxSize={500}
|
||||
items={10}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<ScrollToTop />
|
||||
<HeaderConfProvider />
|
||||
<div className="homeCentered" style={{ width: "100%", maxWidth: "1200px" }}>
|
||||
<h1 className="homeTitleHeader" style={{ textAlign: "center", width: "100%" }}>
|
||||
Mis Turnos
|
||||
</h1>
|
||||
|
||||
<Box sx={{ width: "100%", mt: 4 }}>
|
||||
{collaboratorAppointments.length > 0 ? (
|
||||
<>
|
||||
<Box sx={{ borderBottom: 1, borderColor: "divider" }}>
|
||||
<ThemeProvider theme={turnosXpressTheme}>
|
||||
<Tabs
|
||||
value={tabValue}
|
||||
onChange={handleTabChange}
|
||||
aria-label="appointments tabs"
|
||||
centered
|
||||
>
|
||||
<Tab label="Como Cliente" {...a11yProps(0)} />
|
||||
<Tab label="Como Profesional" {...a11yProps(1)} />
|
||||
</Tabs>
|
||||
</ThemeProvider>
|
||||
</Box>
|
||||
<CustomTabPanel value={tabValue} index={0}>
|
||||
<AppointmentList
|
||||
appointments={clientAppointments}
|
||||
containerHeight={containerHeight}
|
||||
emptyMessage="Aun no tienes ninguna reserva hecha como cliente."
|
||||
goTo={goTo}
|
||||
isProfessionalView={false}
|
||||
/>
|
||||
{clientHasMore && (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", mt: 3, mb: 3 }}>
|
||||
<ThemeProvider theme={turnosXpressTheme}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={handleLoadMoreClient}
|
||||
>
|
||||
Cargar Más
|
||||
</Button>
|
||||
</ThemeProvider>
|
||||
</Box>
|
||||
)}
|
||||
</CustomTabPanel>
|
||||
<CustomTabPanel value={tabValue} index={1}>
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 2 }}>
|
||||
<ThemeProvider theme={turnosXpressTheme}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => goTo("/landing/dashboard")}
|
||||
sx={{
|
||||
borderRadius: "8px",
|
||||
textTransform: "none",
|
||||
fontWeight: 600,
|
||||
px: 3,
|
||||
py: 1,
|
||||
boxShadow: "0 4px 12px rgba(225, 29, 72, 0.2)",
|
||||
"&:hover": {
|
||||
boxShadow: "0 6px 16px rgba(225, 29, 72, 0.3)",
|
||||
transform: "translateY(-2px)",
|
||||
},
|
||||
transition: "all 0.2s ease-in-out",
|
||||
}}
|
||||
>
|
||||
Ir a mi Dashboard
|
||||
</Button>
|
||||
</ThemeProvider>
|
||||
</Box>
|
||||
<PendingRepeatsCard />
|
||||
<AppointmentList
|
||||
appointments={collaboratorAppointments}
|
||||
containerHeight={containerHeight}
|
||||
emptyMessage="No tienes turnos asignados como profesional."
|
||||
goTo={goTo}
|
||||
isProfessionalView={true}
|
||||
/>
|
||||
{collaboratorHasMore && (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", mt: 3, mb: 3 }}>
|
||||
<ThemeProvider theme={turnosXpressTheme}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={handleLoadMoreCollaborator}
|
||||
>
|
||||
Cargar Más
|
||||
</Button>
|
||||
</ThemeProvider>
|
||||
</Box>
|
||||
)}
|
||||
</CustomTabPanel>
|
||||
</>
|
||||
) : (
|
||||
<Box sx={{ p: 3, width: "100%" }}>
|
||||
<AppointmentList
|
||||
appointments={clientAppointments}
|
||||
containerHeight={containerHeight}
|
||||
emptyMessage="Aun no tienes ninguna reserva hecha."
|
||||
goTo={goTo}
|
||||
isProfessionalView={false}
|
||||
/>
|
||||
{clientHasMore && (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", mt: 3, mb: 3 }}>
|
||||
<ThemeProvider theme={turnosXpressTheme}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={handleLoadMoreClient}
|
||||
>
|
||||
Cargar Más
|
||||
</Button>
|
||||
</ThemeProvider>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</div>
|
||||
</AnimatedContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useSessionStore } from "@core/Store/Sesion.Store";
|
||||
import style from "./style.module.css";
|
||||
|
||||
export default function CTABtn() {
|
||||
const SessionInfo = useSessionStore();
|
||||
|
||||
if (SessionInfo.loged) {
|
||||
return (
|
||||
<Link href="/landing/pricing" className={style.ctaBtn}>
|
||||
Ver planes
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href="/landing/signup" className={style.ctaBtn}>
|
||||
Crear mi cuenta gratis
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Metadata } from "next";
|
||||
import AnimatedContainer from "@components/AnimatedConainer/AnimatedContainer";
|
||||
import style from "./style.module.css";
|
||||
import Link from "next/link";
|
||||
import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider";
|
||||
import { ScrollToTop } from "@core/app/components/ScrollTop";
|
||||
import DefaultLoginAction from "@core/app/components/LoginAction/DefaultLoginAction";
|
||||
import CTABtn from "./CTABtn";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Ofrecé Turnos Online | Sistema de Gestión y Reservas - TurnosXpress",
|
||||
description: "Convertí más clientes con nuestro software de gestión de turnos. Automatizá tus reservas, organizá tu agenda y hace crecer tu negocio. ¡Creá tu cuenta gratis hoy!",
|
||||
keywords: ["gestión de turnos", "reservas online", "software de turnos", "agenda online", "turnos para profesionales", "SaaS turnos"],
|
||||
openGraph: {
|
||||
title: "Ofrecé Turnos Online | Sistema de Gestión - TurnosXpress",
|
||||
description: "Automatizá tus reservas y hace crecer tu negocio con nuestro software. ¡Empezá gratis!",
|
||||
type: "website",
|
||||
}
|
||||
};
|
||||
|
||||
export default function OfferAppointmentsPage() {
|
||||
return (
|
||||
<AnimatedContainer
|
||||
color="#aeaeae"
|
||||
minSize={150}
|
||||
maxSize={500}
|
||||
items={10}
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
background: "var(--white)",
|
||||
}}
|
||||
>
|
||||
<ScrollToTop />
|
||||
<HeaderConfProvider />
|
||||
<DefaultLoginAction />
|
||||
|
||||
{/* Hero Section */}
|
||||
<div className={style.heroSection}>
|
||||
<h1 className={style.heroTitle}>Multiplicá tus clientes, simplificá tus turnos</h1>
|
||||
<p className={style.heroSubtitle}>
|
||||
Olvidate del teléfono y los mensajes a deshora. Con nuestra plataforma, tus clientes pueden reservar turnos 24/7 de forma automática. Optimizá tu tiempo, reducí ausencias y llevá tu negocio al siguiente nivel.
|
||||
</p>
|
||||
<div className={style.btnGroup}>
|
||||
<Link href="/landing/signup" className={style.primaryBtn}>
|
||||
¡Empezá Gratis Hoy!
|
||||
</Link>
|
||||
<Link href="/landing/pricing" className={style.secondaryBtn}>
|
||||
Ver Planes
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features Section */}
|
||||
<div className={style.featuresSection}>
|
||||
<h2 className={style.sectionTitle}>Todo lo que necesitás para crecer</h2>
|
||||
<div className={style.featuresGrid}>
|
||||
<div className={style.featureCard}>
|
||||
<div className={style.featureIcon}>📅</div>
|
||||
<h3>Agenda Inteligente</h3>
|
||||
<p>Visualizá tus citas por día, semana o mes. Bloqueá horarios y manejá tu disponibilidad con un par de clics.</p>
|
||||
</div>
|
||||
<div className={style.featureCard}>
|
||||
<div className={style.featureIcon}>🔔</div>
|
||||
<h3>Recordatorios Automáticos</h3>
|
||||
<p>Reducí el ausentismo hasta un 80%. El sistema envía recordatorios automáticos a tus clientes antes de cada turno.</p>
|
||||
</div>
|
||||
<div className={style.featureCard}>
|
||||
<div className={style.featureIcon}>👥</div>
|
||||
<h3>Gestión de Colaboradores</h3>
|
||||
<p>Agregá a tu equipo de trabajo. Asignale a cada colaborador sus propios servicios, horarios y disponibilidad de forma individual.</p>
|
||||
</div>
|
||||
<div className={style.featureCard}>
|
||||
<div className={style.featureIcon}>🌐</div>
|
||||
<h3>Portal de Reservas Público</h3>
|
||||
<p>Mayor exposición. Tus servicios aparecerán en nuestro buscador público para que nuevos clientes te encuentren y reserven al instante.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Steps Section */}
|
||||
<div className={style.stepsSection}>
|
||||
<h2 className={style.sectionTitle}>Recibí turnos en 4 simples pasos</h2>
|
||||
|
||||
<div className={style.stepItem}>
|
||||
<div className={style.stepNumber}>1</div>
|
||||
<div className={style.stepContent}>
|
||||
<h3>Creá tu cuenta gratis</h3>
|
||||
<p>Registrate en segundos. Podés probar la plataforma completa sin compromisos ni tarjetas de crédito. <Link href="/landing/signup">Registrate acá</Link>.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style.stepItem}>
|
||||
<div className={style.stepNumber}>2</div>
|
||||
<div className={style.stepContent}>
|
||||
<h3>Configurá tu perfil</h3>
|
||||
<p>Personalizá tu espacio. Agregá tu logo, ubicación y una descripción atractiva para que los clientes te elijan al instante.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style.stepItem}>
|
||||
<div className={style.stepNumber}>3</div>
|
||||
<div className={style.stepContent}>
|
||||
<h3>Definí servicios y horarios</h3>
|
||||
<p>¿Qué ofrecés y cuándo estás disponible? Configurá la duración de tus turnos, pausas entre clientes y tus días laborales.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style.stepItem}>
|
||||
<div className={style.stepNumber}>4</div>
|
||||
<div className={style.stepContent}>
|
||||
<h3>¡Compartí tu enlace!</h3>
|
||||
<p>Te daremos un link único. Ponelo en tu biografía de Instagram, en WhatsApp o en tu web, y dejá que los turnos se agenden solos.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA Section */}
|
||||
<div className={style.ctaSection}>
|
||||
<h2 className={style.ctaTitle}>¿Listo para modernizar tu negocio?</h2>
|
||||
<p className={style.ctaSubtitle}>Unite a los profesionales que ya automatizaron su agenda y ganan más tiempo libre.</p>
|
||||
<CTABtn />
|
||||
<p style={{ marginTop: '20px', fontSize: '1rem', color: 'rgba(255, 255, 255, 0.85)' }}>
|
||||
¿Necesitás más información? Contactanos a <a href="mailto:info@turnosxpress.com.ar" style={{ color: '#fff', textDecoration: 'underline', fontWeight: '600' }}>info@turnosxpress.com.ar</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</AnimatedContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
/* Modern SaaS Landing Page Styles */
|
||||
|
||||
.heroSection {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: 3.5rem;
|
||||
line-height: 1.2;
|
||||
font-weight: 800;
|
||||
margin-bottom: 1.5rem;
|
||||
background: linear-gradient(135deg, var(--wine-darkest) 0%, var(--wine-red) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.heroSubtitle {
|
||||
font-size: 1.25rem;
|
||||
color: var(--gray-dark);
|
||||
margin-bottom: 3rem;
|
||||
max-width: 800px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.btnGroup {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.primaryBtn {
|
||||
padding: 14px 32px;
|
||||
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: 1.1rem;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
box-shadow: 0 4px 15px rgba(255, 42, 127, 0.3);
|
||||
}
|
||||
|
||||
.primaryBtn:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 25px rgba(255, 42, 127, 0.4);
|
||||
color: var(--white);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.secondaryBtn {
|
||||
padding: 12px 30px;
|
||||
background-color: transparent;
|
||||
color: var(--wine-dark);
|
||||
border: 2px solid var(--wine-dark);
|
||||
border-radius: 12px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
font-size: 1.1rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.secondaryBtn:hover {
|
||||
background-color: var(--wine-lighterX2);
|
||||
color: var(--wine-dark);
|
||||
text-decoration: none;
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.featuresSection {
|
||||
padding: 4rem 2rem;
|
||||
background: var(--white-dark);
|
||||
border-radius: 30px;
|
||||
margin: 2rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
text-align: center;
|
||||
font-size: 2.5rem;
|
||||
color: var(--wine-superdark);
|
||||
margin-bottom: 3rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.featuresGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 2rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.featureCard {
|
||||
background: var(--white);
|
||||
padding: 2.5rem 2rem;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.05);
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
border: 1px solid var(--white-darkest);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.featureCard:hover {
|
||||
transform: translateY(-10px);
|
||||
box-shadow: 0 20px 40px rgba(127, 42, 255, 0.1);
|
||||
border-color: var(--wine-lighter);
|
||||
}
|
||||
|
||||
.featureIcon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
background: var(--wine-lighterX2);
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 15px;
|
||||
color: var(--wine-dark);
|
||||
}
|
||||
|
||||
.featureCard h3 {
|
||||
font-size: 1.3rem;
|
||||
color: var(--wine-superdark);
|
||||
margin-bottom: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.featureCard p {
|
||||
color: var(--gray-dark);
|
||||
line-height: 1.6;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.stepsSection {
|
||||
padding: 5rem 2rem;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.stepItem {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
margin-bottom: 3rem;
|
||||
align-items: flex-start;
|
||||
background: var(--white);
|
||||
padding: 2rem;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.03);
|
||||
border: 1px solid var(--white-darkest);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.stepItem:hover {
|
||||
transform: translateX(10px);
|
||||
border-color: var(--wine-light);
|
||||
}
|
||||
|
||||
.stepNumber {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
flex-shrink: 0;
|
||||
background: linear-gradient(135deg, var(--wine-red) 0%, var(--wine-dark) 100%);
|
||||
color: var(--white);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
box-shadow: 0 4px 15px rgba(255, 42, 127, 0.3);
|
||||
}
|
||||
|
||||
.stepContent h3 {
|
||||
font-size: 1.4rem;
|
||||
color: var(--wine-superdark);
|
||||
margin-bottom: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.stepContent p {
|
||||
color: var(--gray-dark);
|
||||
line-height: 1.6;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.ctaSection {
|
||||
margin: 4rem 2rem;
|
||||
padding: 4rem 2rem;
|
||||
background: linear-gradient(135deg, var(--wine-superdark) 0%, var(--wine-darkest) 100%);
|
||||
border-radius: 30px;
|
||||
text-align: center;
|
||||
color: var(--white);
|
||||
box-shadow: 0 20px 40px rgba(51, 0, 128, 0.3);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ctaSection::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -10%;
|
||||
width: 50%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, var(--wine-light) 0%, transparent 70%);
|
||||
opacity: 0.15;
|
||||
transform: rotate(30deg);
|
||||
}
|
||||
|
||||
.ctaTitle {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
font-weight: 700;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.ctaSubtitle {
|
||||
font-size: 1.2rem;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 2.5rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.ctaBtn {
|
||||
padding: 16px 40px;
|
||||
background: var(--green-darkestX1);
|
||||
color: var(--white);
|
||||
border-radius: 15px;
|
||||
font-weight: 800;
|
||||
text-decoration: none;
|
||||
font-size: 1.2rem;
|
||||
display: inline-block;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
box-shadow: 0 8px 20px rgba(36, 169, 53, 0.4);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.ctaBtn:hover {
|
||||
transform: translateY(-3px) scale(1.02);
|
||||
box-shadow: 0 12px 30px rgba(36, 169, 53, 0.6);
|
||||
color: var(--white);
|
||||
text-decoration: none;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { PublicOrganizationView } from "@core/Models/Company.model";
|
||||
import { useOrgDataStore } from "@core/Store/OrganizationData.Store";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export type OrganizationDataProps = {
|
||||
orgData: PublicOrganizationView;
|
||||
};
|
||||
|
||||
export default function OrganizationDataConnector({ orgData }: OrganizationDataProps) {
|
||||
const OrganizationData = useOrgDataStore();
|
||||
useEffect(() => {
|
||||
OrganizationData.setData(orgData);
|
||||
}, [orgData]);
|
||||
return <></>;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import AnimatedContainer from "@components/AnimatedConainer/AnimatedContainer";
|
||||
import ApiServerService from "@services/Api.Server.Service";
|
||||
import { PublicOrganizationView } from "@core/Models/Company.model";
|
||||
import OrganizationHeader from "@core/app/components/Home/OrganizationHeader/OrganizationHeader";
|
||||
import OrganizationDataConnector from "./components/OrganizationData";
|
||||
|
||||
const GetOrganizationData = async (companyId: string): Promise<PublicOrganizationView> => {
|
||||
try {
|
||||
const organizationData = await ApiServerService.post<PublicOrganizationView>(
|
||||
"views/organization",
|
||||
{
|
||||
companyId: companyId,
|
||||
}
|
||||
);
|
||||
|
||||
return organizationData;
|
||||
} catch {
|
||||
return {} as PublicOrganizationView;
|
||||
}
|
||||
};
|
||||
|
||||
type Params = {
|
||||
oid: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
params: Promise<Params>; // `params` ahora es una Promesa
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export default async function OrganizationPublicProfile({ params, children }: Props) {
|
||||
const { oid } = await params;
|
||||
const orgData = await GetOrganizationData(oid);
|
||||
|
||||
return (
|
||||
<AnimatedContainer
|
||||
color={orgData.headerColor}
|
||||
minSize={150}
|
||||
maxSize={500}
|
||||
items={10}
|
||||
style={{
|
||||
background: `linear-gradient(180deg, ${orgData.headerColor} 0%, var(--white) 50%)`,
|
||||
}}
|
||||
>
|
||||
<div className="homeCentered">
|
||||
{orgData.id && (
|
||||
<>
|
||||
<OrganizationHeader organization={orgData} />
|
||||
<OrganizationDataConnector orgData={orgData} />
|
||||
<div>{children}</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!orgData.id && (
|
||||
<div
|
||||
style={{
|
||||
backgroundImage: `url(/no-org-slug.webp)`,
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundPosition: "center center",
|
||||
backgroundSize: "50% auto",
|
||||
width: "100%",
|
||||
height: "750px",
|
||||
color: "var(--wine-red)",
|
||||
fontWeight: "600",
|
||||
fontSize: "26px",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "flex-end",
|
||||
marginTop: "10px",
|
||||
marginBottom: "30px",
|
||||
textAlign: "center",
|
||||
paddingLeft: "20px",
|
||||
paddingRight: "20px",
|
||||
}}
|
||||
>
|
||||
<p style={{ padding: "20px" }}>
|
||||
La organización que estas buscando no existe
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AnimatedContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,875 @@
|
||||
"use client";
|
||||
import style from "./profile.module.css";
|
||||
//import ProductGallery from "@components/Home/ProductsGallery/ProductGallery";
|
||||
import WhatsAppIcon from "@mui/icons-material/WhatsApp";
|
||||
import LocationOnIcon from "@mui/icons-material/LocationOn";
|
||||
import LocationMap from "@core/app/components/LocationMap/LocationMap";
|
||||
import Button from "@core/app/components/Button/Button";
|
||||
import { useNavigation } from "@core/app/hooks/goto";
|
||||
import { useOrgDataStore } from "@core/Store/OrganizationData.Store";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { PublicOrganizationView } from "@core/Models/Company.model";
|
||||
import { blurElementsOnLoad } from "@core/helpers/blur";
|
||||
import { ButtonGroup, IconButton } from "@mui/material";
|
||||
import GridViewOutlinedIcon from "@mui/icons-material/GridViewOutlined";
|
||||
import SplitscreenOutlinedIcon from "@mui/icons-material/SplitscreenOutlined";
|
||||
import ProductItem, { FindResultsViewMode } from "@core/app/components/ProductItem/ProductItem";
|
||||
import {
|
||||
FacebookShareButton,
|
||||
EmailShareButton,
|
||||
WhatsappShareButton,
|
||||
TelegramShareButton,
|
||||
TwitterShareButton,
|
||||
FacebookIcon,
|
||||
WhatsappIcon as WhatsAppShareIcon,
|
||||
EmailIcon,
|
||||
TelegramIcon,
|
||||
TwitterIcon,
|
||||
} from "react-share";
|
||||
import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider";
|
||||
import useWindowSize from "@core/app/hooks/WindowSize";
|
||||
import AddIcon from "@mui/icons-material/AddOutlined";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import QuillEditor from "@core/app/components/QuillEditor/QuillEditor";
|
||||
import {
|
||||
deletePost,
|
||||
findPostsByOrganization,
|
||||
getPost,
|
||||
moveFixedPost,
|
||||
savePost,
|
||||
toggleFixedPost,
|
||||
} from "./post.service";
|
||||
import { useSessionStore } from "@core/Store/Sesion.Store";
|
||||
import { EVENT_TYPES, useEventHandlerStore } from "@core/Store/EventHandler.Store";
|
||||
import { useAlert } from "@core/Store/Alert.Store";
|
||||
import { IPost } from "@core/Models/Posts.model";
|
||||
import { preprocessQuillHtml, transformQuillContentForDisplay } from "@core/app/helpers/htmlSainitize";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import { ThemeProvider } from "@emotion/react";
|
||||
import MaterialButton from "@mui/material/Button";
|
||||
import ClipIcon from "@mui/icons-material/AttachFileRounded";
|
||||
import DeleteIcon from "@mui/icons-material/DeleteOutlineRounded";
|
||||
import UpIcon from "@mui/icons-material/ArrowUpwardRounded";
|
||||
import DownIcon from "@mui/icons-material/ArrowDownwardRounded";
|
||||
import PinIcon from "@mui/icons-material/PushPin";
|
||||
import EditIcon from "@mui/icons-material/AutoFixHigh";
|
||||
import turnosXpressTheme from "@core/app/theme/turnosXpress";
|
||||
import { useParams } from "next/navigation";
|
||||
import dayjs from "dayjs";
|
||||
import { useConfirmStore } from "@core/Store/Confirm.Store";
|
||||
|
||||
enum VIEW {
|
||||
POSTS = "posts",
|
||||
SERVICES = "services",
|
||||
OPINIONS = "opinions",
|
||||
INFO = "info",
|
||||
}
|
||||
|
||||
type UrlData = {
|
||||
oid: string;
|
||||
};
|
||||
|
||||
export default function OrganizationPublicProfile() {
|
||||
const data = useParams<UrlData>();
|
||||
const oid = data.oid;
|
||||
const OrganizationData = useOrgDataStore();
|
||||
const [position, setPosition] = useState<[number, number]>([0, 0]);
|
||||
const [orgData, setOrgData] = useState<PublicOrganizationView>();
|
||||
const [viewMode, setViewMode] = useState<FindResultsViewMode>("grid");
|
||||
const [shareTitle, setShareTitle] = useState<string>("");
|
||||
const [view, setView] = useState<VIEW>(VIEW.POSTS);
|
||||
const [wndWidth, wndHeight] = useWindowSize();
|
||||
const { goTo } = useNavigation();
|
||||
const refDivContent = useRef<HTMLDivElement>(null);
|
||||
const refPostsContainer = useRef<HTMLDivElement>(null);
|
||||
const [showAddButton, setShowAddButton] = useState<boolean>(false);
|
||||
const [showEditor, setShowEditor] = useState<boolean>(false);
|
||||
const [firstLoad, setFirstLoad] = useState<boolean>(true);
|
||||
|
||||
const [editorPostId, setEditorPostId] = useState<string>("");
|
||||
const [editorContent, setEditorContent] = useState("");
|
||||
|
||||
const [posts, setPosts] = useState<IPost[]>([]);
|
||||
const [authorizedUsers, setAuthorizedUsers] = useState<string[]>([]);
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
|
||||
const SessionInfo = useSessionStore();
|
||||
const eventHandler = useEventHandlerStore();
|
||||
const alert = useAlert();
|
||||
const confirm = useConfirmStore();
|
||||
const searchParams = useSearchParams();
|
||||
const tab = searchParams.get("tab") || "posts";
|
||||
|
||||
useEffect(() => {
|
||||
blurElementsOnLoad();
|
||||
|
||||
if (!tab) {
|
||||
setView(VIEW.POSTS);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (tab) {
|
||||
case "posts":
|
||||
setView(VIEW.POSTS);
|
||||
break;
|
||||
case "services":
|
||||
setView(VIEW.SERVICES);
|
||||
break;
|
||||
case "opinions":
|
||||
setView(VIEW.OPINIONS);
|
||||
break;
|
||||
case "info":
|
||||
setView(VIEW.INFO);
|
||||
break;
|
||||
default:
|
||||
setView(VIEW.POSTS);
|
||||
break;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => {
|
||||
processAddButton();
|
||||
}, 1000);
|
||||
});
|
||||
}, [SessionInfo, authorizedUsers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!OrganizationData.orgData) return;
|
||||
if (!oid) return; //if (!OrganizationData.orgData.id) return;
|
||||
setOrgData(OrganizationData.orgData);
|
||||
setPosition([OrganizationData.orgData.latitude, OrganizationData.orgData.longitude]);
|
||||
setShareTitle(OrganizationData.orgData.name + " - " + OrganizationData.orgData.description);
|
||||
setCurrentPage(1);
|
||||
setPosts([]);
|
||||
cargarPosts(OrganizationData.orgData.id, 1);
|
||||
}, [OrganizationData.orgData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (orgData && oid) {
|
||||
//if (orgData && orgData.id) {
|
||||
cargarPosts(orgData.id, currentPage);
|
||||
}
|
||||
}, [currentPage, orgData]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (refPostsContainer.current) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = refPostsContainer.current;
|
||||
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 50;
|
||||
if (isAtBottom) {
|
||||
setCurrentPage((prevPage) => prevPage + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const currentRef = refPostsContainer.current;
|
||||
|
||||
if (currentRef) {
|
||||
currentRef.addEventListener("scroll", handleScroll);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (currentRef) {
|
||||
currentRef.removeEventListener("scroll", handleScroll);
|
||||
}
|
||||
};
|
||||
}, [refPostsContainer.current, view, currentPage]);
|
||||
|
||||
const processAddButton = (currentView: VIEW = view) => {
|
||||
if (currentView != VIEW.POSTS) {
|
||||
setShowAddButton(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!SessionInfo.userId) {
|
||||
setShowAddButton(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!authorizedUsers.includes(SessionInfo.userId)) {
|
||||
setShowAddButton(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setShowAddButton(true);
|
||||
};
|
||||
|
||||
const checkPostButtons = (): boolean => {
|
||||
if (!SessionInfo.userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (authorizedUsers.includes(SessionInfo.userId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const checkShowInfo = (): boolean => {
|
||||
if (wndWidth < 800) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (view === VIEW.INFO) {
|
||||
setView(VIEW.POSTS);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const getContentHeight = () => {
|
||||
if (!refDivContent.current) return 0;
|
||||
|
||||
const containerSize = refDivContent.current.getClientRects().item(0);
|
||||
|
||||
if (!containerSize) return 0;
|
||||
|
||||
return wndHeight - containerSize.y - 90;
|
||||
};
|
||||
|
||||
const getHeaderItemStyle = (item: VIEW): React.CSSProperties => {
|
||||
const returnValue: React.CSSProperties = {};
|
||||
|
||||
returnValue.fontWeight = "bold";
|
||||
returnValue.color = "var(--wine-black)";
|
||||
returnValue.height = "100%";
|
||||
returnValue.display = "flex";
|
||||
returnValue.alignItems = "center";
|
||||
returnValue.padding = "20px";
|
||||
returnValue.cursor = "pointer";
|
||||
|
||||
if (item === view) {
|
||||
returnValue.borderBottom = "3px solid var(--wine-red)";
|
||||
}
|
||||
|
||||
return returnValue;
|
||||
};
|
||||
|
||||
const getAddButtonStyle = (): React.CSSProperties => {
|
||||
const returnValue: React.CSSProperties = {};
|
||||
const buttonWidth = 64;
|
||||
const buttonMargin = 40;
|
||||
|
||||
returnValue.position = "absolute";
|
||||
returnValue.backgroundColor = "var(--wine-dark)";
|
||||
returnValue.width = `${buttonWidth}px`;
|
||||
returnValue.height = `${buttonWidth}px`;
|
||||
returnValue.borderRadius = "50%";
|
||||
returnValue.display = "flex";
|
||||
returnValue.justifyContent = "center";
|
||||
returnValue.alignItems = "center";
|
||||
returnValue.cursor = "pointer";
|
||||
returnValue.color = "var(--white)";
|
||||
returnValue.zIndex = 10;
|
||||
returnValue.right = `${buttonMargin}px`;
|
||||
returnValue.bottom = `${buttonMargin}px`;
|
||||
|
||||
return returnValue;
|
||||
};
|
||||
|
||||
const getEditorStyle = (): React.CSSProperties => {
|
||||
const returnValue: React.CSSProperties = {};
|
||||
returnValue.position = "fixed";
|
||||
returnValue.backgroundColor = "var(--white)";
|
||||
returnValue.top = "60px";
|
||||
returnValue.left = "0px";
|
||||
returnValue.right = "0px";
|
||||
returnValue.bottom = "0px";
|
||||
returnValue.color = "var(--black)";
|
||||
returnValue.zIndex = 9999;
|
||||
returnValue.display = "flex";
|
||||
returnValue.flexDirection = "column";
|
||||
return returnValue;
|
||||
};
|
||||
|
||||
const crearPost = (content: string) => {
|
||||
setShowEditor(false);
|
||||
|
||||
eventHandler.setEventType(EVENT_TYPES.LOADING);
|
||||
|
||||
savePost({
|
||||
content: content,
|
||||
companyId: orgData!.id,
|
||||
postId: editorPostId ? editorPostId : undefined,
|
||||
sessionUser: SessionInfo.userId,
|
||||
})
|
||||
.then(() => {
|
||||
alert.showSuccess("Post creado");
|
||||
// After creating a post, reset and reload the first page
|
||||
setCurrentPage(1);
|
||||
setPosts([]);
|
||||
if (orgData) {
|
||||
cargarPosts(orgData.id, 1);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
alert.showError(error.format());
|
||||
})
|
||||
.finally(() => {
|
||||
setEditorContent("");
|
||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||
});
|
||||
};
|
||||
|
||||
const cargarPosts = (companyId: string, page: number) => {
|
||||
if (!companyId) {
|
||||
return;
|
||||
}
|
||||
|
||||
findPostsByOrganization({
|
||||
companyId: companyId,
|
||||
page: page,
|
||||
limit: 10,
|
||||
sessionUser: SessionInfo.userId,
|
||||
})
|
||||
.then((response) => {
|
||||
setAuthorizedUsers(response.authorizedUsers);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
setPosts((prevPosts) => {
|
||||
if (page === 1) {
|
||||
return response.data;
|
||||
}
|
||||
const newPosts = response.data.filter(
|
||||
(newPost) => !prevPosts.some((prevPost) => prevPost._id === newPost._id)
|
||||
);
|
||||
|
||||
return [...prevPosts, ...newPosts];
|
||||
});
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
processAddButton();
|
||||
});
|
||||
});
|
||||
|
||||
if (firstLoad) {
|
||||
if (view === VIEW.POSTS && response.data.length == 0) {
|
||||
requestAnimationFrame(() => {
|
||||
setView(VIEW.SERVICES);
|
||||
processAddButton();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setFirstLoad(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error.format());
|
||||
alert.showError(error.format());
|
||||
});
|
||||
};
|
||||
|
||||
const fijarPost = (postId: string) => {
|
||||
if (!orgData) {
|
||||
alert.showError("No se pudo determinar la organización.");
|
||||
return;
|
||||
}
|
||||
|
||||
eventHandler.setEventType(EVENT_TYPES.LOADING);
|
||||
|
||||
toggleFixedPost({
|
||||
companyId: orgData.id,
|
||||
postId: postId,
|
||||
sessionUser: SessionInfo.userId,
|
||||
})
|
||||
.then(() => {
|
||||
alert.showSuccess("Post actualizado");
|
||||
// After creating a post, reset and reload the first page
|
||||
setCurrentPage(1);
|
||||
setPosts([]);
|
||||
if (orgData) {
|
||||
cargarPosts(oid, 1); //cargarPosts(orgData.id, 1);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
alert.showError(error.format());
|
||||
})
|
||||
.finally(() => {
|
||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||
});
|
||||
};
|
||||
|
||||
const moverPost = (postId: string, moveLength: number) => {
|
||||
if (!orgData) {
|
||||
alert.showError("No se pudo determinar la organización.");
|
||||
return;
|
||||
}
|
||||
|
||||
eventHandler.setEventType(EVENT_TYPES.LOADING);
|
||||
|
||||
moveFixedPost({
|
||||
companyId: orgData.id,
|
||||
postId: postId,
|
||||
moveLength: moveLength,
|
||||
sessionUser: SessionInfo.userId,
|
||||
})
|
||||
.then(() => {
|
||||
alert.showSuccess("Post actualizado");
|
||||
// After creating a post, reset and reload the first page
|
||||
setCurrentPage(1);
|
||||
setPosts([]);
|
||||
if (orgData) {
|
||||
cargarPosts(oid, 1); //cargarPosts(orgData.id, 1);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
alert.showError(error.format());
|
||||
})
|
||||
.finally(() => {
|
||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||
});
|
||||
};
|
||||
|
||||
const eliminarPost = (postId: string) => {
|
||||
if (!orgData) {
|
||||
alert.showError("No se pudo determinar la organización.");
|
||||
return;
|
||||
}
|
||||
|
||||
confirm.show(
|
||||
"¿Estás seguro de eliminar el post?",
|
||||
() => {
|
||||
eventHandler.setEventType(EVENT_TYPES.LOADING);
|
||||
|
||||
deletePost({
|
||||
companyId: orgData.id,
|
||||
id: postId,
|
||||
sessionUser: SessionInfo.userId,
|
||||
})
|
||||
.then(() => {
|
||||
setPosts((prevPosts) => prevPosts.filter((post) => post._id !== postId));
|
||||
alert.showSuccess("Post eliminado");
|
||||
})
|
||||
.catch((error) => {
|
||||
alert.showError(error.format());
|
||||
})
|
||||
.finally(() => {
|
||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||
});
|
||||
},
|
||||
() => {}
|
||||
);
|
||||
};
|
||||
|
||||
const editarPost = (postId: string) => {
|
||||
getPost({
|
||||
id: postId,
|
||||
})
|
||||
.then((postData) => {
|
||||
setEditorPostId(postId);
|
||||
setEditorContent(postData.content);
|
||||
setShowEditor(true);
|
||||
})
|
||||
.catch((error) => {
|
||||
alert.showError(error.format());
|
||||
});
|
||||
};
|
||||
|
||||
const LocationInfo = (data: PublicOrganizationView | null) => {
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2>Información Útil</h2>
|
||||
<LocationMap position={position} orgData={data} />
|
||||
<ul>
|
||||
<li>
|
||||
<LocationOnIcon />
|
||||
{data.address}
|
||||
</li>
|
||||
<li>
|
||||
<WhatsAppIcon />
|
||||
{data.phone}
|
||||
</li>
|
||||
</ul>
|
||||
<Button
|
||||
color="primary"
|
||||
text="Enviar mensaje"
|
||||
width="100%"
|
||||
onClick={() => {
|
||||
goTo(`/landing/org/${data.id}/send-message`);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
}}
|
||||
>
|
||||
<WhatsappShareButton title={shareTitle} separator=":: " url={window.location.href}>
|
||||
<WhatsAppShareIcon size={32} round />
|
||||
</WhatsappShareButton>
|
||||
<FacebookShareButton title={shareTitle} url={window.location.href}>
|
||||
<FacebookIcon size={32} round />
|
||||
</FacebookShareButton>
|
||||
<EmailShareButton title={shareTitle} separator=":: " url={window.location.href}>
|
||||
<EmailIcon size={32} round />
|
||||
</EmailShareButton>
|
||||
<TelegramShareButton title={shareTitle} url={window.location.href}>
|
||||
<TelegramIcon size={32} round />
|
||||
</TelegramShareButton>
|
||||
<TwitterShareButton title={shareTitle} url={window.location.href}>
|
||||
<TwitterIcon size={32} round />
|
||||
</TwitterShareButton>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeaderConfProvider />
|
||||
{orgData && (
|
||||
<div className={style.organizationContent}>
|
||||
{showEditor && (
|
||||
<div style={getEditorStyle()}>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
height: "50px",
|
||||
backgroundColor: "#f0f0f0",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingLeft: " 20px",
|
||||
borderBottom: "1px solid #ccc",
|
||||
}}
|
||||
>
|
||||
<h2>Qué estas pensando?</h2>
|
||||
|
||||
<IconButton
|
||||
color="inherit"
|
||||
style={{ width: "64px", height: "64px" }}
|
||||
onClick={() => {
|
||||
setShowEditor(false);
|
||||
}}
|
||||
>
|
||||
<CloseIcon sx={{ ml: 1, mr: 1 }} />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
{/* Content (Editor) */}
|
||||
<div
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
<QuillEditor value={editorContent} onChange={setEditorContent} />
|
||||
</div>
|
||||
|
||||
{/* Bottom Bar */}
|
||||
<div
|
||||
style={{
|
||||
height: "60px",
|
||||
backgroundColor: "#f0f0f0",
|
||||
borderTop: "1px solid #ccc",
|
||||
padding: "0 20px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
text={editorPostId ? "Actualizar" : "Publicar"}
|
||||
width="100%"
|
||||
onClick={() => {
|
||||
setShowEditor(false);
|
||||
crearPost(editorContent);
|
||||
}}
|
||||
color="success"
|
||||
></Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={style.organizationMap}>{LocationInfo(orgData)}</div>
|
||||
<div
|
||||
ref={refDivContent}
|
||||
style={{
|
||||
border: "1px solid var(--gray-light)",
|
||||
borderRadius: "10px",
|
||||
marginTop: "30px",
|
||||
width: "100%",
|
||||
marginBottom: "30px",
|
||||
backgroundColor: "var(--white)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
borderBottom: "1px solid var(--gray-light)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "50px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "20px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={getHeaderItemStyle(VIEW.POSTS)}
|
||||
onClick={() => {
|
||||
setView(VIEW.POSTS);
|
||||
processAddButton(VIEW.POSTS);
|
||||
}}
|
||||
>
|
||||
Posts
|
||||
</div>
|
||||
<div
|
||||
style={getHeaderItemStyle(VIEW.SERVICES)}
|
||||
onClick={() => {
|
||||
setView(VIEW.SERVICES);
|
||||
processAddButton(VIEW.SERVICES);
|
||||
}}
|
||||
>
|
||||
Servicios
|
||||
</div>
|
||||
{checkShowInfo() && (
|
||||
<div
|
||||
style={getHeaderItemStyle(VIEW.INFO)}
|
||||
onClick={() => {
|
||||
setView(VIEW.INFO);
|
||||
processAddButton(VIEW.INFO);
|
||||
}}
|
||||
>
|
||||
Contacto
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{view === VIEW.SERVICES && (
|
||||
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<ButtonGroup
|
||||
variant="outlined"
|
||||
aria-label="Basic button group"
|
||||
sx={{ marginTop: "10px", marginRight: "30px" }}
|
||||
>
|
||||
<IconButton onClick={() => setViewMode("card")}>
|
||||
<GridViewOutlinedIcon
|
||||
sx={{
|
||||
color:
|
||||
viewMode === "card" ? "var(--wine-red)" : "ineherit",
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
<IconButton onClick={() => setViewMode("grid")}>
|
||||
<SplitscreenOutlinedIcon
|
||||
sx={{
|
||||
color:
|
||||
viewMode === "grid" ? "var(--wine-red)" : "ineherit",
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{view === VIEW.POSTS && (
|
||||
<div
|
||||
ref={refPostsContainer}
|
||||
style={{
|
||||
overflowY: "scroll",
|
||||
height: `${getContentHeight()}px`,
|
||||
}}
|
||||
>
|
||||
{showAddButton && (
|
||||
<div
|
||||
style={getAddButtonStyle()}
|
||||
onClick={() => {
|
||||
setEditorPostId("");
|
||||
setEditorContent("");
|
||||
setShowEditor(true);
|
||||
}}
|
||||
>
|
||||
<AddIcon sx={{ fontSize: "32px" }} />
|
||||
</div>
|
||||
)}
|
||||
{posts.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
backgroundImage: "url(/no-conversation.webp)",
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundPosition: "center center",
|
||||
backgroundSize: "50% auto",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
Nada por aquí
|
||||
</div>
|
||||
)}
|
||||
{posts.map((post: IPost) => {
|
||||
return (
|
||||
<div
|
||||
key={post._id}
|
||||
style={{
|
||||
borderBottom: "1px solid var(--gray-light)",
|
||||
paddingTop: "5px",
|
||||
paddingBottom: "10px",
|
||||
paddingLeft: "30px",
|
||||
paddingRight: "30px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div style={{ color: "var(--gray-darkest)" }}>
|
||||
{dayjs(post.createdAt).format("DD MMMM YYYY HH:mm")}
|
||||
</div>
|
||||
{post.fixed && (
|
||||
<div
|
||||
style={{
|
||||
color: "var(--gray-light)",
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<PinIcon fontSize="small" />
|
||||
<span>Anclado</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: preprocessQuillHtml(
|
||||
transformQuillContentForDisplay(post.content)
|
||||
),
|
||||
}}
|
||||
/>
|
||||
{checkPostButtons() && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "10px",
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
flexWrap: "wrap",
|
||||
color: "var(--gray-darkest)",
|
||||
}}
|
||||
>
|
||||
<ThemeProvider theme={turnosXpressTheme}>
|
||||
<MaterialButton
|
||||
color="inherit"
|
||||
startIcon={<ClipIcon />}
|
||||
onClick={() => {
|
||||
fijarPost(`${post._id}`);
|
||||
}}
|
||||
>
|
||||
{post.fixed ? "Liberar" : "Fijar"}
|
||||
</MaterialButton>
|
||||
|
||||
{post.fixed && (
|
||||
<>
|
||||
<MaterialButton
|
||||
color="inherit"
|
||||
startIcon={<UpIcon />}
|
||||
onClick={() => {
|
||||
moverPost(`${post._id}`, -1);
|
||||
}}
|
||||
>
|
||||
Subir
|
||||
</MaterialButton>
|
||||
|
||||
<MaterialButton
|
||||
color="inherit"
|
||||
startIcon={<DownIcon />}
|
||||
onClick={() => {
|
||||
moverPost(`${post._id}`, 1);
|
||||
}}
|
||||
>
|
||||
Bajar
|
||||
</MaterialButton>
|
||||
</>
|
||||
)}
|
||||
|
||||
<MaterialButton
|
||||
color="inherit"
|
||||
onClick={() => {
|
||||
editarPost(`${post._id}`);
|
||||
}}
|
||||
>
|
||||
<EditIcon />
|
||||
</MaterialButton>
|
||||
|
||||
<MaterialButton
|
||||
color="inherit"
|
||||
onClick={() => {
|
||||
eliminarPost(`${post._id}`);
|
||||
}}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</MaterialButton>
|
||||
</ThemeProvider>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{view === VIEW.SERVICES && (
|
||||
<div
|
||||
className={style.organizationServices}
|
||||
style={{
|
||||
overflowY: "scroll",
|
||||
height: `${getContentHeight()}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: "0px 0px 40px 0px",
|
||||
display: "flex",
|
||||
flexDirection: viewMode === "card" ? "row" : "column",
|
||||
justifyContent: "start",
|
||||
gap: "20px",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
{orgData.services.data.map((service) => (
|
||||
<ProductItem
|
||||
product={service}
|
||||
key={service.id}
|
||||
width={260}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{view === VIEW.INFO && checkShowInfo() && (
|
||||
<div
|
||||
className={style.organizationMoreInfo}
|
||||
style={{
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{LocationInfo(orgData)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import ApiRequest from "@services/Api.Service";
|
||||
import * as Yup from "yup";
|
||||
import { ApiError } from "@core/Models/Server.Error.model";
|
||||
import {
|
||||
DeletePostParams,
|
||||
GetPostParams,
|
||||
IPost,
|
||||
PaginatePostsParams,
|
||||
PaginatePostsResults,
|
||||
SavePostParams,
|
||||
} from "@core/Models/Posts.model";
|
||||
import { FixCompanyPostParams, MoveCompanyFixedPostParams } from "@core/Models/Company.model";
|
||||
|
||||
const schemaPostSave = Yup.object().shape({
|
||||
postId: Yup.string().optional(),
|
||||
companyId: Yup.string().required("No es posible deteriminar la organización."),
|
||||
content: Yup.string().required("El post esta vacío."),
|
||||
fixed: Yup.boolean().optional(),
|
||||
fixedOrder: Yup.number().optional(),
|
||||
sessionUser: Yup.string().required("Para crear un descuento debe iniciar sesión."),
|
||||
});
|
||||
|
||||
export const savePost = async (data: SavePostParams): Promise<IPost> => {
|
||||
let yupErrors: Yup.ValidationError = new Yup.ValidationError("");
|
||||
|
||||
const validateData = async () => {
|
||||
try {
|
||||
await schemaPostSave.validate(data, { abortEarly: true });
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof Yup.ValidationError) {
|
||||
yupErrors = error as Yup.ValidationError;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if ((await validateData()) == false) {
|
||||
throw new ApiError(400, yupErrors.message);
|
||||
}
|
||||
|
||||
return ApiRequest.post<IPost>("posts/save", data);
|
||||
};
|
||||
|
||||
export const findPostsByOrganization = async (
|
||||
data: PaginatePostsParams
|
||||
): Promise<PaginatePostsResults> => {
|
||||
return ApiRequest.post<PaginatePostsResults>("posts/paginate", data);
|
||||
};
|
||||
|
||||
export const getPost = async (
|
||||
data: GetPostParams
|
||||
): Promise<IPost> => {
|
||||
return ApiRequest.post<IPost>("posts/get-post", data);
|
||||
};
|
||||
|
||||
export const deletePost = async (data: DeletePostParams): Promise<void> => {
|
||||
return ApiRequest.post<void>("posts/delete", data);
|
||||
};
|
||||
|
||||
export const toggleFixedPost = async (data: FixCompanyPostParams): Promise<void> => {
|
||||
return ApiRequest.post<void>("companies/toggle-fixed-post", data);
|
||||
};
|
||||
|
||||
export const moveFixedPost = async (data: MoveCompanyFixedPostParams): Promise<void> => {
|
||||
return ApiRequest.post<void>("companies/move-fixed-post", data);
|
||||
};
|
||||
@@ -0,0 +1,139 @@
|
||||
@media (max-width: 800px) {
|
||||
.organizationContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
margin-left: 30px;
|
||||
margin-right: 30px;
|
||||
gap: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.organizationMap {
|
||||
border: solid 1px var(--gray-light);
|
||||
border-radius: 10px;
|
||||
background-color: white;
|
||||
padding: 30px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
margin-top: 30px;
|
||||
margin-bottom: 30px;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.organizationMap ul {
|
||||
list-style-type: none;
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.organizationMap li {
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
color: (--black);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.organizationMoreInfo {
|
||||
background-color: white;
|
||||
padding: 30px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
margin-top: 0px;
|
||||
margin-bottom: 30px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.organizationMoreInfo ul {
|
||||
list-style-type: none;
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.organizationMoreInfo li {
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
color: (--black);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.organizationServices {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 801px) {
|
||||
.organizationContent {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
/*margin-top: 30px;*/
|
||||
margin-left: 30px;
|
||||
margin-right: 30px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.organizationMap {
|
||||
border: solid 1px var(--gray-light);
|
||||
border-radius: 10px;
|
||||
background-color: white;
|
||||
padding: 30px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
margin-top: 30px;
|
||||
margin-bottom: 30px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.organizationMap ul {
|
||||
list-style-type: none;
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.organizationMap li {
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
color: (--black);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.organizationServices {
|
||||
margin-bottom: 30px;
|
||||
padding-right: 10px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import style from "./style.module.css";
|
||||
import Textbox from "@core/app/components/Textbox/Textbox";
|
||||
import Button from "@core/app/components/Button/Button";
|
||||
import { useOrgDataStore } from "@core/Store/OrganizationData.Store";
|
||||
import { PublicOrganizationView } from "@core/Models/Company.model";
|
||||
import { useEffect, useState } from "react";
|
||||
import { sendMessage } from "@core/app/messenger/Messages.service";
|
||||
import { MESSAGE_CONVERSATION_TYPES, SendMessageParams } from "@core/Models/Messages.model";
|
||||
import { useSessionStore } from "@core/Store/Sesion.Store";
|
||||
import { useAlert } from "@core/Store/Alert.Store";
|
||||
|
||||
export default function OrganizationPublicProfile() {
|
||||
const SessionInfo = useSessionStore();
|
||||
const alert = useAlert();
|
||||
const OrganizationData = useOrgDataStore();
|
||||
const [orgData, setOrgData] = useState<PublicOrganizationView>();
|
||||
const [comments, setComments] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!OrganizationData.orgData) return;
|
||||
setOrgData(OrganizationData.orgData);
|
||||
}, [OrganizationData.orgData]);
|
||||
|
||||
const send = () => {
|
||||
if (!orgData) return;
|
||||
|
||||
const conversationType = MESSAGE_CONVERSATION_TYPES.USER_TO_ORG;
|
||||
|
||||
const sendMessageData: SendMessageParams = {
|
||||
conversationId: undefined,
|
||||
type: conversationType,
|
||||
userIdFrom: SessionInfo.userId,
|
||||
userIdTo: undefined,
|
||||
companyId: orgData.id,
|
||||
message: comments,
|
||||
sessionUser: SessionInfo.userId,
|
||||
};
|
||||
|
||||
sendMessage(sendMessageData)
|
||||
.then(() => {
|
||||
alert.showSuccess("Mensaje enviado");
|
||||
setComments("");
|
||||
})
|
||||
.catch((error) => {
|
||||
alert.showError(error);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{orgData && (
|
||||
<div className="homeCentered">
|
||||
<div className={style.organizationContent}>
|
||||
<h1 className="homeTitleHeader">Contactate con nosotros</h1>
|
||||
<p className="homeResumeHeader">
|
||||
¿Tienes preguntas, sugerencias o necesitas más información? Estás en el
|
||||
lugar indicado. Aquí puedes ponerte en contacto directamente con nuestro
|
||||
equipo.{" "}
|
||||
<b>
|
||||
Para enviar tu mensaje, es necesario estar registrado en el sitio.{" "}
|
||||
</b>
|
||||
Completa el formulario y te responderemos lo antes posible. ¡Será un
|
||||
placer ayudarte!
|
||||
</p>
|
||||
<Textbox
|
||||
type="comments"
|
||||
placeholder="Escribe tu mensaje"
|
||||
value={comments}
|
||||
width="100%"
|
||||
style={{ height: "200px" }}
|
||||
onChange={(e) => setComments(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
color="primary"
|
||||
text="Enviar"
|
||||
width="100%"
|
||||
style={{ marginTop: "20px", marginBottom: "20px" }}
|
||||
onClick={send}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
@media (max-width: 800px) {
|
||||
.organizationContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
margin-left: 30px;
|
||||
margin-right: 30px;
|
||||
gap: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 801px) {
|
||||
.organizationContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
/*margin-top: 30px;*/
|
||||
margin-left: 30px;
|
||||
margin-right: 30px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export default function OrganizationPublicAppointmentsCreate() {
|
||||
return (
|
||||
<>
|
||||
<h1>Pagina para sacar turno en una organizacion</h1>
|
||||
<p>sacar turno.</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
/* Modern SaaS Pricing Styles */
|
||||
|
||||
.heroSection {
|
||||
text-align: center;
|
||||
padding: 5rem 2rem 3rem;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: 3.5rem;
|
||||
line-height: 1.2;
|
||||
font-weight: 800;
|
||||
margin-bottom: 1.5rem;
|
||||
background: linear-gradient(135deg, var(--wine-darkest) 0%, var(--wine-red) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.heroSubtitle {
|
||||
font-size: 1.25rem;
|
||||
color: var(--gray-dark);
|
||||
margin-bottom: 3rem;
|
||||
max-width: 800px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.btnGroup {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.primaryBtn {
|
||||
padding: 14px 32px;
|
||||
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: 1.1rem;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
box-shadow: 0 4px 15px rgba(255, 42, 127, 0.3);
|
||||
}
|
||||
|
||||
.primaryBtn:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 25px rgba(255, 42, 127, 0.4);
|
||||
color: var(--white);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.pricingSection {
|
||||
padding: 4rem 2rem;
|
||||
background: var(--white-dark);
|
||||
border-radius: 30px;
|
||||
margin: 2rem auto;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
/* All Plans Include Section */
|
||||
.featuresSection {
|
||||
padding: 4rem 2rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
border-top: 1px solid var(--white-darkest);
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
text-align: center;
|
||||
font-size: 2.5rem;
|
||||
color: var(--wine-superdark);
|
||||
font-weight: 800;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.featuresGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.featureItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
background: var(--white);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.03);
|
||||
border: 1px solid var(--white-darkest);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.featureItem:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.featureIcon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: rgba(127, 42, 255, 0.1);
|
||||
color: var(--wine-darkest);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.featureTitle {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--wine-superdark);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.featureText {
|
||||
color: var(--gray-dark);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* FAQ Section */
|
||||
.faqSection {
|
||||
padding: 5rem 2rem;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.faqItem {
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid var(--white-darkest);
|
||||
}
|
||||
|
||||
.faqQuestion {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--wine-superdark);
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.faqQuestion::before {
|
||||
content: "?";
|
||||
display: inline-block;
|
||||
background: var(--wine-darkest);
|
||||
color: white;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
text-align: center;
|
||||
border-radius: 50%;
|
||||
margin-right: 10px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.faqAnswer {
|
||||
color: var(--gray-dark);
|
||||
line-height: 1.6;
|
||||
padding-left: 34px;
|
||||
}
|
||||
|
||||
/* Bottom CTA */
|
||||
.ctaSection {
|
||||
background: linear-gradient(135deg, var(--wine-superdark) 0%, var(--wine-darkest) 100%);
|
||||
padding: 5rem 2rem;
|
||||
text-align: center;
|
||||
color: white;
|
||||
border-radius: 24px;
|
||||
margin: 0 2rem 5rem 2rem;
|
||||
box-shadow: 0 20px 40px rgba(51,0,128,0.3);
|
||||
}
|
||||
|
||||
.ctaTitle {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.ctaText {
|
||||
font-size: 1.2rem;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 2rem;
|
||||
max-width: 600px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.ctaButton {
|
||||
background: white;
|
||||
color: var(--wine-darkest);
|
||||
padding: 1rem 3rem;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 800;
|
||||
border-radius: 50px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 10px 20px rgba(0,0,0,0.1);
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.ctaButton:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 15px 25px rgba(0,0,0,0.2);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import Pricing from "@components/PricingElement/PricingElement";
|
||||
import AnimatedContainer from "@core/app/components/AnimatedConainer/AnimatedContainer";
|
||||
import { IPlan } from "@core/Models/Plans.model";
|
||||
import style from "./page.module.css";
|
||||
import Link from "next/link";
|
||||
import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider";
|
||||
import { ScrollToTop } from "@core/app/components/ScrollTop";
|
||||
import DefaultLoginAction from "@core/app/components/LoginAction/DefaultLoginAction";
|
||||
|
||||
export const dynamic = "force-dynamic"; // Fuerza la página a ser dinámica
|
||||
|
||||
const GetPlanData = async (): Promise<IPlan[]> => {
|
||||
try {
|
||||
const url = process.env.NEXT_PUBLIC_API_URL;
|
||||
const res = await fetch(`${url}plans/find`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
cache: "no-store",
|
||||
});
|
||||
const pricingList: IPlan[] = await res.json();
|
||||
|
||||
return pricingList;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export default async function PricingPage() {
|
||||
const pricingList = await GetPlanData();
|
||||
|
||||
return (
|
||||
<AnimatedContainer
|
||||
color="#aeaeae"
|
||||
minSize={150}
|
||||
maxSize={500}
|
||||
items={10}
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
background: "var(--white)",
|
||||
}}
|
||||
>
|
||||
<ScrollToTop />
|
||||
<HeaderConfProvider />
|
||||
<DefaultLoginAction />
|
||||
|
||||
<div className={style.heroSection}>
|
||||
<h1 className={style.heroTitle}>Elegí el plan perfecto para vos</h1>
|
||||
<p className={style.heroSubtitle}>
|
||||
Unite a las organizaciones que ya confían en TurnosXpress. Optimizá tu agenda, conecta con más personas y hace crecer tu negocio sin complicaciones. Transparente, sin comisiones ocultas y podés cancelar cuando quieras.
|
||||
</p>
|
||||
<div className={style.btnGroup}>
|
||||
<Link href="#planes" className={style.primaryBtn}>
|
||||
Elegir un Plan
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="planes" className={style.pricingSection}>
|
||||
<Pricing data={pricingList} />
|
||||
</div>
|
||||
|
||||
{/* All Plans Include Section */}
|
||||
<div className={style.featuresSection}>
|
||||
<h2 className={style.sectionTitle}>Todos los planes incluyen</h2>
|
||||
<div className={style.featuresGrid}>
|
||||
<div className={style.featureItem}>
|
||||
<div className={style.featureIcon}>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="24" height="24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8V7z"></path></svg>
|
||||
</div>
|
||||
<h3 className={style.featureTitle}>Máxima Privacidad</h3>
|
||||
<p className={style.featureText}>Toda la información viaja protegida y tus datos y los de tus clientes se mantienen 100% seguros y confidenciales.</p>
|
||||
</div>
|
||||
<div className={style.featureItem}>
|
||||
<div className={style.featureIcon}>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="24" height="24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
|
||||
</div>
|
||||
<h3 className={style.featureTitle}>Configuración Rápida</h3>
|
||||
<p className={style.featureText}>Empieza a recibir turnos en menos de 5 minutos, sin conocimientos técnicos.</p>
|
||||
</div>
|
||||
<div className={style.featureItem}>
|
||||
<div className={style.featureIcon}>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="24" height="24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M18.364 5.636l-3.536 3.536m0 5.656l3.536 3.536M9.172 9.172L5.636 5.636m3.536 9.192l-3.536 3.536M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-5 0a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
|
||||
</div>
|
||||
<h3 className={style.featureTitle}>Soporte Dedicado</h3>
|
||||
<p className={style.featureText}>Nuestro equipo de atención está siempre dispuesto a ayudarte a crecer.</p>
|
||||
</div>
|
||||
<div className={style.featureItem}>
|
||||
<div className={style.featureIcon}>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="24" height="24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||
</div>
|
||||
<h3 className={style.featureTitle}>Acceso Global</h3>
|
||||
<p className={style.featureText}>Gestiona tu negocio desde cualquier dispositivo, en cualquier lugar.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FAQ Section */}
|
||||
<div className={style.faqSection}>
|
||||
<h2 className={style.sectionTitle}>Preguntas Frecuentes</h2>
|
||||
<div className={style.faqItem}>
|
||||
<h3 className={style.faqQuestion}>¿Puedo cambiar de plan en cualquier momento?</h3>
|
||||
<p className={style.faqAnswer}>Sí, puedes subir o bajar de plan en cualquier momento. Si mejoras tu plan, el cambio será inmediato. Si lo reduces, el cambio aplicará en el próximo ciclo de facturación.</p>
|
||||
</div>
|
||||
<div className={style.faqItem}>
|
||||
<h3 className={style.faqQuestion}>¿Hay algún costo de instalación?</h3>
|
||||
<p className={style.faqAnswer}>No, en TurnosXpress no cobramos comisiones de instalación, mantenimiento ni tarifas ocultas. El precio que ves es el que pagas.</p>
|
||||
</div>
|
||||
<div className={style.faqItem}>
|
||||
<h3 className={style.faqQuestion}>¿Qué pasa si supero el límite de turnos de mi plan?</h3>
|
||||
<p className={style.faqAnswer}>No te dejaremos sin servicio. Te notificaremos para que puedas evaluar subir a un plan superior que se adapte mejor al volumen de tu negocio.</p>
|
||||
</div>
|
||||
<div className={style.faqItem}>
|
||||
<h3 className={style.faqQuestion}>¿Qué medios de pago aceptan?</h3>
|
||||
<p className={style.faqAnswer}>Procesamos todos nuestros pagos a través de la pasarela segura de Mercado Pago. Esto significa que puedes abonar de forma transparente usando tarjetas de crédito, débito, dinero en cuenta o cualquier medio de pago que la plataforma tenga habilitado en Argentina.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom CTA Section */}
|
||||
<div className={style.ctaSection}>
|
||||
<h2 className={style.ctaTitle}>¿Listo para revolucionar tu agenda?</h2>
|
||||
<p className={style.ctaText}>No pierdas más tiempo gestionando turnos manualmente. Empieza a ofrecer una experiencia premium a tus clientes desde hoy mismo.</p>
|
||||
<Link href="#planes" className={style.ctaButton} style={{ textDecoration: "none", display: "inline-block" }}>
|
||||
Elegir un Plan
|
||||
</Link>
|
||||
</div>
|
||||
</AnimatedContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import AnimatedContainer from "@components/AnimatedConainer/AnimatedContainer";
|
||||
import style from "./terms.module.css";
|
||||
import Link from "next/link";
|
||||
import { ScrollToTop } from "@core/app/components/ScrollTop";
|
||||
import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider";
|
||||
import DefaultLoginAction from "@core/app/components/LoginAction/DefaultLoginAction";
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<AnimatedContainer
|
||||
color="#aeaeae"
|
||||
minSize={150}
|
||||
maxSize={500}
|
||||
items={10}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<ScrollToTop />
|
||||
<HeaderConfProvider />
|
||||
<DefaultLoginAction />
|
||||
<div className="homeCentered">
|
||||
<h1 className="homeTitleHeader">Política de Privacidad</h1>
|
||||
<p className="homeResumeHeader">
|
||||
En turnosxpress valoramos la privacidad de nuestros usuarios y nos comprometemos
|
||||
a proteger la información personal que nos proporcionan. Esta política describe
|
||||
cómo recopilamos, usamos, almacenamos y compartimos sus datos personales.
|
||||
</p>
|
||||
</div>
|
||||
<div className="homeCentered">
|
||||
<h4 className={style.termTitle}>Información que recopilamos</h4>
|
||||
<p className={style.termText}>Podemos recopilar los siguientes datos personales:</p>
|
||||
<ul className={style.termTextList}>
|
||||
<li>Nombre completo</li>
|
||||
<li>Correo electrónico</li>
|
||||
<li>Número de teléfono</li>
|
||||
<li>Imagen de perfil</li>
|
||||
<li>Ubicación donde se brinda el servicio (en caso de Prestadores)</li>
|
||||
<li>Otra información que el usuario decida proporcionar voluntariamente</li>
|
||||
</ul>
|
||||
|
||||
<h4 className={style.termTitle}>Finalidad del uso de los datos</h4>
|
||||
<p className={style.termText}>Los datos personales se utilizan para:</p>
|
||||
<ul className={style.termTextList}>
|
||||
<li>Permitir la creación de cuentas y perfiles de usuario</li>
|
||||
<li>Facilitar la comunicación entre Clientes y Prestadores</li>
|
||||
<li>
|
||||
Mostrar el correo electrónico y el teléfono del Cliente al Prestador cuando
|
||||
se realiza una reserva
|
||||
</li>
|
||||
<li>Mejorar la experiencia de usuario y ofrecer soporte técnico</li>
|
||||
<li>
|
||||
Enviar notificaciones relacionadas con reservas o actividades relevantes (si
|
||||
el usuario lo acepta)
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h4 className={style.termTitle}>Acceso a la información</h4>
|
||||
<p className={style.termText}>
|
||||
La información de contacto del Cliente (email y teléfono) será visible
|
||||
únicamente para el Prestador al momento de efectuarse una reserva. No
|
||||
compartimos ni vendemos datos personales a terceros, salvo por requerimiento
|
||||
legal o judicial.
|
||||
</p>
|
||||
|
||||
<h4 className={style.termTitle}>Seguridad de los datos</h4>
|
||||
<p className={style.termText}>
|
||||
Aplicamos medidas técnicas y organizativas razonables para proteger los datos
|
||||
personales contra el acceso no autorizado, pérdida o alteración. No obstante,
|
||||
ningún sistema es completamente seguro.
|
||||
</p>
|
||||
|
||||
<h4 className={style.termTitle}>Derechos de los usuarios</h4>
|
||||
<p className={style.termText}>Los usuarios pueden:</p>
|
||||
<ul className={style.termTextList}>
|
||||
<li>Solicitar acceso, rectificación o eliminación de sus datos personales</li>
|
||||
<li>Limitar el uso o solicitar la portabilidad de los datos</li>
|
||||
<li>
|
||||
Revocar su consentimiento en cualquier momento, sin efectos retroactivos
|
||||
</li>
|
||||
</ul>
|
||||
<p className={style.termText}>
|
||||
Para ejercer estos derechos, pueden contactarnos a través de{" "}
|
||||
<strong>info@turnosxpress.com.ar</strong>.
|
||||
</p>
|
||||
|
||||
<h4 className={style.termTitle}>Cookies</h4>
|
||||
<p className={style.termText}>
|
||||
Utilizamos cookies para mejorar el funcionamiento de la Plataforma y la
|
||||
experiencia del usuario. Al utilizar nuestros servicios, usted acepta el uso de
|
||||
cookies de acuerdo con nuestra{" "}
|
||||
<Link href="/landing/cookies">Política de Cookies</Link>.
|
||||
</p>
|
||||
|
||||
<h4 className={style.termTitle}>Conservación de datos</h4>
|
||||
<p className={style.termText}>
|
||||
Conservamos los datos mientras la cuenta esté activa o sea necesario para los
|
||||
fines mencionados. El usuario puede solicitar la eliminación de su cuenta y
|
||||
datos en cualquier momento.
|
||||
</p>
|
||||
|
||||
<h4 className={style.termTitle}>Modificaciones</h4>
|
||||
<p className={style.termText}>
|
||||
Esta política puede ser modificada en cualquier momento. Las modificaciones
|
||||
serán publicadas en la Plataforma y entrarán en vigencia de forma inmediata. El
|
||||
uso continuado del sitio implica la aceptación de los cambios.
|
||||
</p>
|
||||
|
||||
<h4 className={style.termTitle}>Jurisdicción</h4>
|
||||
<p className={style.termText}>
|
||||
Esta política se rige por las leyes de <strong>Argentina</strong>. Cualquier
|
||||
disputa será resuelta por los tribunales competentes de la
|
||||
<strong> provincia de Buenos Aires, Argentina</strong>.
|
||||
</p>
|
||||
<div style={{ height: "100px" }}> </div>
|
||||
</div>
|
||||
</AnimatedContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
.termText {
|
||||
padding-top: 10px;
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
text-align: justify;
|
||||
max-width: 1200px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.termTextList {
|
||||
padding-top: 10px;
|
||||
padding-left: 50px;
|
||||
}
|
||||
|
||||
.termTextList li {
|
||||
padding-bottom: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.termTitle {
|
||||
font-size: 20px;
|
||||
padding-top: 30px;
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
text-align: left;
|
||||
max-width: 1200px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import Login from "@core/app/components/Home/Login/Login";
|
||||
|
||||
export default function RegisterPage() {
|
||||
return <Login recovery={true} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import Login from "@core/app/components/Home/Login/Login";
|
||||
|
||||
export default function RegisterPage() {
|
||||
return <Login recoveryCode={true} />;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import ApiRequest from "@services/Api.Service";
|
||||
import {
|
||||
IAppointment,
|
||||
CreateAppointmentParams,
|
||||
GetAvailableDatesParams,
|
||||
GetAvailableDatesResult,
|
||||
} from "@models/Appointments.model";
|
||||
|
||||
import * as Yup from "yup";
|
||||
import { ApiError } from "@core/Models/Server.Error.model";
|
||||
|
||||
const schemaAppointmentAdd = Yup.object().shape({
|
||||
companyId: Yup.string().required("Debe seleccionar una organización."),
|
||||
serviceId: Yup.string().required("Debe seleccionar un servicio."),
|
||||
employeeId: Yup.string().required("Debe seleccionar un colaborador."),
|
||||
userId: Yup.string().required("Para crear un turno debe iniciar sesión."),
|
||||
start: Yup.string().required("Debe seleccionar una fecha."),
|
||||
sessionUser: Yup.string().required("Para crear un turno debe iniciar sesión."),
|
||||
});
|
||||
|
||||
const schemaGetAvailableDates = Yup.object().shape({
|
||||
companyId: Yup.string().required("Debe seleccionar una organización."),
|
||||
serviceId: Yup.string().required("Debe seleccionar un servicio."),
|
||||
employeeId: Yup.string().required("Debe seleccionar un colaborador."),
|
||||
length: Yup.number().max(60).min(1).required("No se ha especificado un periodo de busqueda."),
|
||||
sessionUser: Yup.string().required("Para crear un turno debe iniciar sesión."),
|
||||
});
|
||||
|
||||
export const createAppointment = async (data: CreateAppointmentParams): Promise<IAppointment> => {
|
||||
return new Promise<IAppointment>((resolve, reject) => {
|
||||
schemaAppointmentAdd
|
||||
.validate(data, { abortEarly: true })
|
||||
.then(() => {
|
||||
resolve(ApiRequest.post<IAppointment>("appointments/create", data));
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(new ApiError(500, error.message));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const getAvailableDates = async (
|
||||
data: GetAvailableDatesParams
|
||||
): Promise<GetAvailableDatesResult> => {
|
||||
return new Promise<GetAvailableDatesResult>((resolve, reject) => {
|
||||
schemaGetAvailableDates
|
||||
.validate(data, { abortEarly: true })
|
||||
.then(() => {
|
||||
resolve(
|
||||
ApiRequest.post<GetAvailableDatesResult>(
|
||||
"appointments/get-available-dates",
|
||||
data
|
||||
)
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(new ApiError(500, error.message));
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
.appointmentTitle {
|
||||
margin-top: 20px;
|
||||
color: var(--wine-red);
|
||||
font-size: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.appointmentResume {
|
||||
padding-left: 30px;
|
||||
padding-right: 30px;
|
||||
color: var(--gray-dark);
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
"use client";
|
||||
import TextObjectFilter from "@components/TextObjectFilter/TextObjectFilter";
|
||||
import TextObjectFilterItem from "@components/TextObjectFilterItem/TextObjectFilterItem";
|
||||
import { FindEmployeesByServiceParams } from "@models/Collaborators.model";
|
||||
import { TextObjectFilterResult } from "@models/TextObjectFilter.model";
|
||||
import { EVENT_TYPES, useEventHandlerStore } from "@store/EventHandler.Store";
|
||||
import { TextObjectModalContent } from "@store/TextObjectModal.Store";
|
||||
import { useEffect, useState } from "react";
|
||||
import filterObjects from "@components/TextObjectFilter/Filter.Service";
|
||||
import AppointmentsCalendar from "../Calendar/Calendar";
|
||||
import style from "./Appointment.module.css";
|
||||
import Button from "@core/app/components/Button/Button";
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
CreateAppointmentParams,
|
||||
FindAppointmentSchedulesParams,
|
||||
GetAvailableDatesResult,
|
||||
} from "@core/Models/Appointments.model";
|
||||
import { useSessionStore } from "@core/Store/Sesion.Store";
|
||||
import Link from "next/link";
|
||||
import { LOGIN_ACTIONS, useLoginActionStore } from "@core/Store/LoginAction.Store";
|
||||
import { createAppointment, getAvailableDates } from "./Appointment.Service";
|
||||
import { useAlert } from "@core/Store/Alert.Store";
|
||||
import { useNavigation } from "@core/app/hooks/goto";
|
||||
import MaterialAlert from "@mui/material/Alert";
|
||||
|
||||
export interface AppointmentProps {
|
||||
companyId: string;
|
||||
serviceId: string;
|
||||
appointmentAlert?: string;
|
||||
}
|
||||
|
||||
export default function Appointment(props: AppointmentProps) {
|
||||
const { serviceId, companyId, appointmentAlert = "" } = props;
|
||||
const eventHandler = useEventHandlerStore();
|
||||
const SessionInfo = useSessionStore();
|
||||
const loginAction = useLoginActionStore();
|
||||
const alert = useAlert();
|
||||
const { goTo } = useNavigation();
|
||||
|
||||
const [selectedDate, setSelectedDate] = useState<dayjs.Dayjs>(dayjs());
|
||||
const [availableDates, setAvailableDates] = useState<GetAvailableDatesResult>({
|
||||
availableDates: [],
|
||||
inRangeDates: [],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loginAction.setLoginAction(LOGIN_ACTIONS.APPOINTMENT_CREATE);
|
||||
loginAction.setUrl("/landing/service/" + serviceId);
|
||||
}, []);
|
||||
|
||||
const [employeeFilterContent] = useState<TextObjectModalContent>({
|
||||
header: (
|
||||
<div>
|
||||
<div style={{ fontWeight: "700", fontSize: "14px", paddingTop: "7px" }}>
|
||||
Seleccionar un profesional:
|
||||
</div>
|
||||
<p style={{ fontWeight: "500", fontSize: "13px", paddingTop: "2px" }}>
|
||||
Ingrese el nombre del profesional para buscarlo.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
bottom: <div> </div>,
|
||||
renderResult: (result: TextObjectFilterResult, index: number) => (
|
||||
<TextObjectFilterItem key={index} data={result} />
|
||||
),
|
||||
});
|
||||
|
||||
const [employeeFilterData, setEmployeeFilterData] = useState<TextObjectFilterResult[]>([]);
|
||||
const [employeeId, setEmployeeId] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||
const filter: FindEmployeesByServiceParams = {
|
||||
serviceId,
|
||||
};
|
||||
filterObjects<FindEmployeesByServiceParams>("employees/employees-by-service", filter).then(
|
||||
(res) => {
|
||||
setEmployeeFilterData(res);
|
||||
}
|
||||
);
|
||||
}, [serviceId]);
|
||||
|
||||
//---------------------------------
|
||||
|
||||
const [scheduleFilterContent] = useState<TextObjectModalContent>({
|
||||
header: (
|
||||
<div>
|
||||
<div style={{ fontWeight: "700", fontSize: "14px", paddingTop: "7px" }}>
|
||||
Seleccionar un horario:
|
||||
</div>
|
||||
<p style={{ fontWeight: "500", fontSize: "13px", paddingTop: "2px" }}>
|
||||
Ingrese el horario para buscarlo.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
bottom: <div> </div>,
|
||||
renderResult: (result: TextObjectFilterResult, index: number) => (
|
||||
<TextObjectFilterItem key={index} data={result} />
|
||||
),
|
||||
});
|
||||
|
||||
const [scheduleFilterData, setScheduleFilterData] = useState<TextObjectFilterResult[]>([]);
|
||||
const [scheduleId, setScheduleId] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const filter: FindAppointmentSchedulesParams = {
|
||||
companyId: companyId,
|
||||
serviceId: serviceId,
|
||||
employeeId: employeeId,
|
||||
dateDay: selectedDate.toISOString(),
|
||||
};
|
||||
|
||||
if (!employeeId) {
|
||||
return;
|
||||
}
|
||||
eventHandler.setEventType(EVENT_TYPES.LOADING);
|
||||
filterObjects<FindAppointmentSchedulesParams>("appointments/get-schedules", filter).then(
|
||||
(res) => {
|
||||
//eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||
setScheduleFilterData(res);
|
||||
|
||||
getAvailableDates({
|
||||
companyId: companyId,
|
||||
serviceId: serviceId,
|
||||
employeeId: employeeId,
|
||||
length: 60,
|
||||
sessionUser: SessionInfo.userId,
|
||||
})
|
||||
.then((result) => {
|
||||
setAvailableDates(result);
|
||||
})
|
||||
.catch((error) => {
|
||||
setAvailableDates({ availableDates: [], inRangeDates: [] });
|
||||
alert.showError(error.format());
|
||||
})
|
||||
.finally(() => {
|
||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||
});
|
||||
}
|
||||
);
|
||||
}, [serviceId, employeeId, selectedDate]);
|
||||
|
||||
const onReservarClick = () => {
|
||||
const data: CreateAppointmentParams = {
|
||||
companyId: companyId,
|
||||
serviceId: serviceId,
|
||||
employeeId: employeeId,
|
||||
userId: SessionInfo.userId,
|
||||
start: scheduleId,
|
||||
sessionUser: SessionInfo.userId,
|
||||
validation: true,
|
||||
};
|
||||
|
||||
eventHandler.setEventType(EVENT_TYPES.LOADING);
|
||||
|
||||
createAppointment(data)
|
||||
.then(() => {
|
||||
alert.showSuccess("Turno reservado con éxito");
|
||||
setTimeout(() => {
|
||||
goTo("/");
|
||||
}, 1000);
|
||||
})
|
||||
.catch((error) => {
|
||||
alert.showError(error.format());
|
||||
})
|
||||
.finally(() => {
|
||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className={style.appointmentTitle}>Reservar turno</h1>
|
||||
<p className={style.appointmentResume}>
|
||||
<b>Bienvenido/a.</b> Para realizar una reserva, primero seleccione un profesional de
|
||||
la lista y luego elija una fecha y un horario disponibles. En caso de no haber
|
||||
turnos disponibles, el sistema se lo informará. No dude en contactarnos ante
|
||||
cualquier duda o inconveniente.
|
||||
</p>
|
||||
{!SessionInfo.loged && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
color: "var(--black)",
|
||||
fontSize: "16px",
|
||||
fontWeight: "600",
|
||||
marginTop: "20px",
|
||||
marginBottom: "20px",
|
||||
paddingLeft: "30px",
|
||||
paddingRight: "30px",
|
||||
}}
|
||||
>
|
||||
Para poder reservar un turno tenes que estar registrado en turnosxpress y
|
||||
haber iniciado sesión. Si ya tienes una cuenta puedes{" "}
|
||||
<Link href="/landing/login">Iniciar sesión aquí</Link> o en caso de no estar
|
||||
registrado puedes <Link href="/landing/signup">Registrarte aquí</Link>.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{SessionInfo.loged && (
|
||||
<div style={{ marginLeft: "30px", marginRight: "30px", textAlign: "left" }}>
|
||||
<TextObjectFilter
|
||||
placeholder="Seleccionar Profesional:"
|
||||
content={employeeFilterContent}
|
||||
data={employeeFilterData}
|
||||
onChange={(id) => {
|
||||
setEmployeeId(id);
|
||||
setScheduleFilterData([]);
|
||||
setScheduleId("");
|
||||
}}
|
||||
/>
|
||||
{employeeId != "" && (<AppointmentsCalendar
|
||||
availableDates={availableDates}
|
||||
onChange={(selDate) => {
|
||||
setSelectedDate(selDate);
|
||||
setScheduleFilterData([]);
|
||||
setScheduleId("");
|
||||
}}
|
||||
/>)}
|
||||
|
||||
{employeeId && scheduleFilterData.length > 0 && (
|
||||
<TextObjectFilter
|
||||
placeholder="Seleccionar Horario:"
|
||||
content={scheduleFilterContent}
|
||||
data={scheduleFilterData}
|
||||
onChange={(id) => {
|
||||
setScheduleId(id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{employeeId && scheduleFilterData.length == 0 && (
|
||||
<div
|
||||
style={{
|
||||
color: "var(--white)",
|
||||
backgroundColor: "var(--error)",
|
||||
borderRadius: "10px",
|
||||
fontSize: "16px",
|
||||
fontWeight: "600",
|
||||
marginTop: "20px",
|
||||
padding: "15px",
|
||||
}}
|
||||
>
|
||||
<p>
|
||||
No hay horarios dispobibles para el día seleccionado. Intente
|
||||
seleccionar otro día u otro profesional para este servicio.
|
||||
</p>
|
||||
<p style={{ marginTop: "10px" }}>
|
||||
En caso de no aparecer horarios disponibles, es probable que la
|
||||
organización aun no haya habilitado las reservas en el periodo que
|
||||
se encuentra seleccionado.
|
||||
</p>
|
||||
<p style={{ marginTop: "10px" }}>
|
||||
Ante cualquier inconveniente por favor comuniquese con nosotros.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{appointmentAlert && (
|
||||
<MaterialAlert severity="info" sx={{ marginTop: "20px" }}>
|
||||
<div dangerouslySetInnerHTML={{ __html: appointmentAlert }}></div>
|
||||
</MaterialAlert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
text="Reservar"
|
||||
color="primary"
|
||||
width="100%"
|
||||
onClick={() => {
|
||||
onReservarClick();
|
||||
}}
|
||||
style={{ marginTop: "20px", marginBottom: "20px" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { GetAvailableDatesResult } from "@core/Models/Appointments.model";
|
||||
import dayjs from "dayjs";
|
||||
import "dayjs/locale/es";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { useState } from "react";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.locale("es"); // Configurar idioma global
|
||||
|
||||
export interface AppointmentsCalendarProps {
|
||||
onChange: (date: dayjs.Dayjs) => void;
|
||||
availableDates: GetAvailableDatesResult;
|
||||
}
|
||||
|
||||
export default function AppointmentsCalendar(props: AppointmentsCalendarProps) {
|
||||
const { availableDates = { availableDates: [], inRangeDates: [] } } = props;
|
||||
const dateNow = dayjs(new Date());
|
||||
const firstWeekDay = dateNow.startOf("week");
|
||||
const days = [0, 1, 2, 3, 4, 5, 6];
|
||||
const weeks = [0, 1, 2, 3, 4];
|
||||
const [selectedDate, setSelectedDate] = useState(dayjs(new Date()).startOf("day"));
|
||||
|
||||
const isNow = (week: number, day: number): boolean => {
|
||||
return dateNow.startOf("day").isSame(firstWeekDay.add(week * 7 + day, "day").startOf("day"));
|
||||
};
|
||||
|
||||
const isSelected = (week: number, day: number): boolean => {
|
||||
return selectedDate.startOf("day").isSame(firstWeekDay.add(week * 7 + day, "day").startOf("day"));
|
||||
};
|
||||
|
||||
const isBefore = (week: number, day: number): boolean => {
|
||||
return firstWeekDay.add(week * 7 + day, "day").isBefore(dateNow.startOf("day"));
|
||||
};
|
||||
|
||||
const isHighlighted = (week: number, day: number): boolean => {
|
||||
const currentDay = firstWeekDay
|
||||
.clone()
|
||||
.utc()
|
||||
.add(week * 7 + day, "day")
|
||||
.startOf("day");
|
||||
|
||||
for (const item of availableDates.availableDates) {
|
||||
const itemTest = dayjs.utc(item).startOf("day");
|
||||
|
||||
if (currentDay.isSame(itemTest)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const getBackgroundColor = (week: number, day: number): string => {
|
||||
if (isSelected(week, day)) {
|
||||
return "var(--wine-light)";
|
||||
} else if (isHighlighted(week, day)) {
|
||||
return "var(--wine-lighterX2)";
|
||||
}
|
||||
|
||||
return "transparent";
|
||||
};
|
||||
|
||||
const isSameMonth = (week: number, day: number): boolean => {
|
||||
const currentDay = firstWeekDay
|
||||
.clone()
|
||||
.utc()
|
||||
.add(week * 7 + day, "day")
|
||||
.startOf("day");
|
||||
const currentMonth = dayjs().month();
|
||||
|
||||
if (currentDay.month() != currentMonth) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
marginTop: "30px",
|
||||
marginBottom: "10px",
|
||||
paddingLeft: "5px",
|
||||
color: "var(--black)",
|
||||
fontSize: "16px",
|
||||
fontWeight: "700",
|
||||
}}
|
||||
>
|
||||
Seleccionar fecha:
|
||||
</div>
|
||||
{/*<div style={{ paddingLeft: "10px", marginBottom: "10px" }}>
|
||||
<div style={{ display: "flex", flexDirection: "row", alignItems: "center" }}>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "var(--wine-lighterX2)",
|
||||
width: "20px",
|
||||
height: "20px",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
>
|
||||
|
||||
</div>
|
||||
<span style={{ paddingLeft: "5px" }}>Habilitado para reservas</span>
|
||||
</div>
|
||||
</div>*/}
|
||||
<div
|
||||
style={{
|
||||
border: "solid 1px var(--gray-dark)",
|
||||
borderRadius: "7px",
|
||||
background: "linear-gradient(180deg, var(--white-dark) 0%, var(--white) 30%)",
|
||||
paddingTop: "10px",
|
||||
paddingBottom: "10px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: "700",
|
||||
fontSize: "18px",
|
||||
textAlign: "center",
|
||||
paddingLeft: "20px",
|
||||
paddingBottom: "10px",
|
||||
textTransform: "capitalize",
|
||||
borderBottom: "solid 1px var(--gray-light)",
|
||||
marginBottom: "5px",
|
||||
}}
|
||||
>
|
||||
{selectedDate.format("DD") +
|
||||
" de " +
|
||||
selectedDate.format("MMMM") +
|
||||
" " +
|
||||
selectedDate.format("YYYY")}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
textTransform: "uppercase",
|
||||
height: "40px",
|
||||
paddingBottom: "5px",
|
||||
borderBottom: "solid 1px var(--gray-light)",
|
||||
}}
|
||||
>
|
||||
{days.map((day) => {
|
||||
return (
|
||||
<div
|
||||
key={day}
|
||||
style={{
|
||||
textAlign: "center",
|
||||
margin: "auto",
|
||||
fontWeight: "600",
|
||||
fontSize: "14px",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{dayjs(firstWeekDay.add(day, "day")).format("dd")}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
{weeks.map((week) => {
|
||||
return (
|
||||
<div
|
||||
key={week}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
textTransform: "uppercase",
|
||||
marginTop: "0px",
|
||||
height: "60px",
|
||||
padding: "5px",
|
||||
}}
|
||||
>
|
||||
{days.map((day) => {
|
||||
return (
|
||||
<div
|
||||
key={day}
|
||||
style={{
|
||||
textAlign: "center",
|
||||
margin: "auto",
|
||||
fontWeight: isSelected(week, day)
|
||||
? "700"
|
||||
: isNow(week, day)
|
||||
? "700"
|
||||
: isSameMonth(week, day)
|
||||
? "400"
|
||||
: "600",
|
||||
fontSize: isNow(week, day) ? "16px" : "14px",
|
||||
width: "100%",
|
||||
color: isBefore(week, day)
|
||||
? "var(--gray-light)"
|
||||
: isNow(week, day)
|
||||
? "var(--wine-red)"
|
||||
: isSelected(week, day)
|
||||
? "var(--white)"
|
||||
: isSameMonth(week, day)
|
||||
? "var(--gray-darkest)"
|
||||
: "var(--black)",
|
||||
cursor: "pointer",
|
||||
backgroundColor: getBackgroundColor(week, day),
|
||||
height: "60px",
|
||||
lineHeight: "60px",
|
||||
borderRadius: "0px",
|
||||
border: isNow(week, day)
|
||||
? "solid 1px var(--wine-light)"
|
||||
: "none",
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isBefore(week, day)) return;
|
||||
const auxDate = firstWeekDay
|
||||
.add(week * 7 + day, "day")
|
||||
.startOf("day");
|
||||
setSelectedDate(auxDate);
|
||||
props.onChange(auxDate);
|
||||
}}
|
||||
>
|
||||
{dayjs(firstWeekDay.add(week * 7 + day, "day")).format("DD")}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import AnimatedContainer from "@components/AnimatedConainer/AnimatedContainer";
|
||||
import style from "./profile.module.css";
|
||||
import ApiServerService from "@services/Api.Server.Service";
|
||||
import { PublicOrganizationServiceView } from "@core/Models/Service.model";
|
||||
import ProductItem from "@core/app/components/ProductItem/ProductItem";
|
||||
import Appointment from "./components/Appointment/Appointment";
|
||||
import OrganizationHeader from "@components/Home/OrganizationHeader/OrganizationHeader";
|
||||
import ProductGallery from "@core/app/components/Home/ProductsGallery/ProductGallery";
|
||||
import { ScrollToTop } from "@core/app/components/ScrollTop";
|
||||
import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider";
|
||||
import ServicePrivate from "@core/app/components/ServicePrivate/ServicePrivate";
|
||||
import EvangelizeBanner from "@components/EvangelizeBanner/EvangelizeBanner";
|
||||
|
||||
const GetServiceData = async (serviceId: string): Promise<PublicOrganizationServiceView> => {
|
||||
try {
|
||||
const organizationServiceData = await ApiServerService.post<PublicOrganizationServiceView>(
|
||||
"views/service",
|
||||
{
|
||||
serviceId: serviceId,
|
||||
}
|
||||
);
|
||||
|
||||
return organizationServiceData;
|
||||
} catch {
|
||||
return {} as PublicOrganizationServiceView;
|
||||
}
|
||||
};
|
||||
|
||||
type Params = {
|
||||
sid: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
params: Promise<Params>;
|
||||
};
|
||||
|
||||
export default async function OrganizationPublicProfile({ params }: Props) {
|
||||
const { sid } = await params;
|
||||
const organizationService = await GetServiceData(sid);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!organizationService.organization && !organizationService.service && (
|
||||
<ServicePrivate />
|
||||
)}
|
||||
{organizationService.organization && organizationService.service && (
|
||||
<AnimatedContainer
|
||||
color={organizationService.organization.headerColor}
|
||||
minSize={150}
|
||||
maxSize={500}
|
||||
items={10}
|
||||
style={{
|
||||
background: `linear-gradient(180deg, ${organizationService.organization.headerColor} 0%, var(--white) 50%)`,
|
||||
}}
|
||||
>
|
||||
<ScrollToTop />
|
||||
<HeaderConfProvider />
|
||||
<div className="homeCentered">
|
||||
<OrganizationHeader organization={organizationService.organization} />
|
||||
<div className={style.organizationContent}>
|
||||
<div className={style.serviceSide}>
|
||||
<div className={style.stickyWrapper}>
|
||||
<ProductItem product={organizationService.service} width="100%" />
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.bookingSide}>
|
||||
<div className={style.appointmentWrapper}>
|
||||
<Appointment
|
||||
serviceId={sid}
|
||||
companyId={organizationService.organization.id}
|
||||
appointmentAlert={
|
||||
organizationService.organization.appointmentAlert
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<EvangelizeBanner variant="banner" />
|
||||
<ProductGallery
|
||||
title="Otros Servicios 🏆"
|
||||
data={organizationService.organization.services.data}
|
||||
/>
|
||||
</div>
|
||||
</AnimatedContainer>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
.organizationContent {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 30px;
|
||||
margin: 30px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (min-width: 801px) {
|
||||
.organizationContent {
|
||||
grid-template-columns: 350px 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.serviceSide {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stickyWrapper {
|
||||
position: sticky;
|
||||
top: 30px;
|
||||
}
|
||||
|
||||
.bookingSide {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.appointmentWrapper {
|
||||
background: #ffffff;
|
||||
border-radius: 16px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.04);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.appointmentWrapper {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import ApiRequest from "@services/Api.Service";
|
||||
import { ApiVoidResult } from "@models/Api.VoidResult.type";
|
||||
import * as Yup from "yup";
|
||||
import { SignUpParams } from "@models/User.model";
|
||||
import { ApiError } from "@models/Server.Error.model";
|
||||
|
||||
const schemaSignUpUser = Yup.object().shape({
|
||||
firstName: Yup.string().required("El nombre es obligatorio."),
|
||||
lastName: Yup.string().required("El apellido es obligatorio."),
|
||||
email: Yup.string()
|
||||
.required("El E-mail es obligatorio.")
|
||||
.email("Debe introducir un E-mail valido."),
|
||||
password: Yup.string()
|
||||
.matches(/^[a-zA-Z0-9]+$/, "La contraseña debe contener caracteres alfanuméricos")
|
||||
.required("La contraseña es obligatoria.")
|
||||
.min(8, "La contraseña debe tener 8 caracteres como minimo."),
|
||||
phoneAreaCode: Yup.number()
|
||||
.required("Debe introducir el código de área.")
|
||||
.typeError("El código de área debe ser un número."),
|
||||
phoneNumber: Yup.number()
|
||||
.required("Debe introducir el número de teléfono.")
|
||||
.typeError("El número de teléfono debe ser un número."),
|
||||
phoneCountryCode: Yup.number().optional().typeError("El código de pais debe ser un número."),
|
||||
});
|
||||
|
||||
export const signUp = async (data: SignUpParams): Promise<ApiVoidResult> => {
|
||||
return new Promise<ApiVoidResult>((resolve, reject) => {
|
||||
schemaSignUpUser
|
||||
.validate(data, { abortEarly: true })
|
||||
.then(() => {
|
||||
resolve(ApiRequest.post<ApiVoidResult>("users/signup", data));
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(new ApiError(500, error.message));
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
import Button from "@components/Button/Button";
|
||||
import Textbox from "@components/Textbox/Textbox";
|
||||
import TextPhone from "@components/TextPhone/TextPhone";
|
||||
import { SignUpParams } from "@core/Models/User.model";
|
||||
import { useState } from "react";
|
||||
import Checkbox from "@mui/material/Checkbox";
|
||||
import Link from "next/link";
|
||||
import AnimatedContainer from "@components/AnimatedConainer/AnimatedContainer";
|
||||
import { signUp } from "./Signup.Service";
|
||||
import { useAlert } from "@core/Store/Alert.Store";
|
||||
import {
|
||||
EVENT_TYPES,
|
||||
useEventHandlerStore,
|
||||
} from "@core/Store/EventHandler.Store";
|
||||
import { useNavigation } from "@hooks/goto";
|
||||
import { useConfirmStore } from "@core/Store/Confirm.Store";
|
||||
import { validatePhone } from "@core/helpers/validatePhone";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const alert = useAlert();
|
||||
const confirm = useConfirmStore();
|
||||
const eventHandler = useEventHandlerStore();
|
||||
const { goTo } = useNavigation();
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [firstName, setFirstName] = useState("");
|
||||
const [lastName, setLastName] = useState("");
|
||||
const [areaCode, setAreaCode] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [checkReadTerms, setCheckReadTerms] = useState(false);
|
||||
|
||||
const registerUser = () => {
|
||||
const validationPhoneResult = validatePhone(areaCode, phone);
|
||||
|
||||
if (!validationPhoneResult.isValid) {
|
||||
confirm.show(
|
||||
validationPhoneResult.message,
|
||||
() => {},
|
||||
() => {}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const data: SignUpParams = {
|
||||
email: email,
|
||||
password: password,
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
phoneAreaCode: areaCode,
|
||||
phoneNumber: phone,
|
||||
};
|
||||
|
||||
if (!checkReadTerms) {
|
||||
confirm.show(
|
||||
"Para registrarte debes aceptar los términos y condiciones y las politicas de privacidad",
|
||||
() => {},
|
||||
() => {}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
eventHandler.setEventType(EVENT_TYPES.LOADING);
|
||||
|
||||
signUp(data)
|
||||
.then(() => {
|
||||
alert.showSuccess(
|
||||
"Registro exitoso. Hemos enviado un correo electronico para activar tu cuenta."
|
||||
);
|
||||
setTimeout(() => {
|
||||
goTo("/landing/login/verification");
|
||||
}, 2000);
|
||||
})
|
||||
.catch((error) => {
|
||||
alert.showError(error.format());
|
||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatedContainer
|
||||
color="#aeaeae"
|
||||
minSize={150}
|
||||
maxSize={500}
|
||||
items={10}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
background:
|
||||
"linear-gradient(180deg, var(--wine-darkest) 0%, var(--wine-dark) 100%)",
|
||||
}}
|
||||
>
|
||||
<div className="homeCentered">
|
||||
<div style={{ paddingLeft: "20px", paddingRight: "20px" }}>
|
||||
<div className="card">
|
||||
<h1 className="homeTitleHeader">
|
||||
Registrate Gratis
|
||||
</h1>
|
||||
<Textbox
|
||||
placeholder="Email"
|
||||
type="text"
|
||||
value={email}
|
||||
width="100%"
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<Textbox
|
||||
placeholder="Clave"
|
||||
type="password"
|
||||
value={password}
|
||||
width="100%"
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<Textbox
|
||||
placeholder="Nombre"
|
||||
type="text"
|
||||
value={firstName}
|
||||
width="100%"
|
||||
onChange={(e) => {
|
||||
setFirstName(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<Textbox
|
||||
placeholder="Apellido"
|
||||
type="text"
|
||||
value={lastName}
|
||||
width="100%"
|
||||
onChange={(e) => {
|
||||
setLastName(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<TextPhone
|
||||
label="Teléfono"
|
||||
width="100%"
|
||||
areaCode={areaCode}
|
||||
number={phone}
|
||||
onChangeArea={(
|
||||
e: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
setAreaCode(e.target.value);
|
||||
}}
|
||||
onChangePhone={(
|
||||
e: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
setPhone(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
<Button
|
||||
name="btnLogin"
|
||||
text="Registrarme"
|
||||
width="100%"
|
||||
onClick={registerUser}
|
||||
style={{ marginTop: "20px" }}
|
||||
type="submit"
|
||||
/>
|
||||
<div style={{ marginTop: "10px" }}>
|
||||
Ya tengo una cuenta!
|
||||
<Link href="/landing/login">
|
||||
Iniciar Sesión
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
.formContainer {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
margin-top: 30px;
|
||||
margin-bottom: 30px;
|
||||
background-color: var(--white);
|
||||
-webkit-box-shadow: 0px 2px 24px -4px var(--gray-dark);
|
||||
-moz-box-shadow: 0px 2px 24px -4px var(--gray-dark);
|
||||
box-shadow: 0px 2px 24px -4px var(--gray-dark);
|
||||
padding-left: 50px;
|
||||
padding-right: 50px;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 30px;
|
||||
max-width: 600px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
.container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: calc(100vh - 80px);
|
||||
padding: 2rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
max-width: 1000px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.leftPanel {
|
||||
flex: 1;
|
||||
background: linear-gradient(135deg, var(--wine-darkest) 0%, var(--wine-red) 100%);
|
||||
color: var(--white);
|
||||
padding: 3rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.rightPanel {
|
||||
flex: 1.2;
|
||||
padding: 3rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 900;
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.1rem;
|
||||
opacity: 0.9;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.featureList {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.featureItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 1.05rem;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.monthsContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.monthOption {
|
||||
border: 1.5px solid var(--white-darkest);
|
||||
border-radius: 16px;
|
||||
padding: 1.2rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: var(--white);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.monthOption:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.05);
|
||||
border-color: var(--wine-light);
|
||||
}
|
||||
|
||||
.monthOptionSelected {
|
||||
border-color: var(--wine-dark);
|
||||
background-color: var(--wine-lighterX2);
|
||||
box-shadow: 0 4px 15px rgba(127, 42, 255, 0.1);
|
||||
}
|
||||
|
||||
.monthLabel {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--wine-superdark);
|
||||
}
|
||||
|
||||
.discountBadge {
|
||||
margin-left: 10px;
|
||||
background-color: var(--green-darkestX1);
|
||||
color: var(--white);
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.monthPrice {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 800;
|
||||
color: var(--wine-darkest);
|
||||
}
|
||||
|
||||
.totalSection {
|
||||
margin-top: auto;
|
||||
border-top: 1px solid var(--white-darkest);
|
||||
padding-top: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.totalRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.totalLabel {
|
||||
font-size: 1.1rem;
|
||||
color: var(--gray-dark);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.totalValue {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 900;
|
||||
color: var(--wine-superdark);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btnSecondary {
|
||||
flex: 1;
|
||||
background-color: transparent;
|
||||
color: var(--gray-dark);
|
||||
border: 1px solid var(--white-darkest);
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btnSecondary:hover {
|
||||
background-color: var(--white-dark);
|
||||
color: var(--wine-superdark);
|
||||
}
|
||||
|
||||
.btnPrimary {
|
||||
flex: 2;
|
||||
background: linear-gradient(135deg, var(--wine-red) 0%, var(--wine-dark) 100%);
|
||||
color: var(--white);
|
||||
border: none;
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 10px 20px rgba(127, 42, 255, 0.25);
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btnPrimary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 15px 25px rgba(127, 42, 255, 0.35);
|
||||
}
|
||||
|
||||
.btnFree {
|
||||
background: linear-gradient(135deg, var(--green-darkestX1) 0%, var(--green-darkest) 100%);
|
||||
box-shadow: 0 10px 20px rgba(40, 167, 69, 0.25);
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btnFree:hover {
|
||||
box-shadow: 0 15px 25px rgba(40, 167, 69, 0.35);
|
||||
}
|
||||
|
||||
.freeIconWrapper {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: var(--wine-lighterX2);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--wine-red);
|
||||
}
|
||||
|
||||
/* Responsiveness */
|
||||
@media (max-width: 900px) {
|
||||
.card {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.leftPanel, .rightPanel {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
"use client";
|
||||
import ApiRequest from "@services/Api.Service";
|
||||
import AnimatedContainer from "@core/app/components/AnimatedConainer/AnimatedContainer";
|
||||
import SecurePage from "@core/app/components/Secure/Secure";
|
||||
import { LOGIN_ACTIONS } from "@core/Store/LoginAction.Store";
|
||||
import { useSessionStore } from "@core/Store/Sesion.Store";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect, useState, Suspense } from "react";
|
||||
import {
|
||||
CreateMPPreApprovalParams,
|
||||
MP_PRE_APPROVAL_STATUS,
|
||||
MPPreApprovalResponse,
|
||||
} from "@core/Models/Subscription.Model";
|
||||
import { useAlert } from "@core/Store/Alert.Store";
|
||||
import { EVENT_TYPES, useEventHandlerStore } from "@core/Store/EventHandler.Store";
|
||||
import { useNavigation } from "@core/app/hooks/goto";
|
||||
import SuspenseLoading from "@core/app/components/SuspendeLoading/SuspenseLoading";
|
||||
import { IPlan } from "@core/Models/Plans.model";
|
||||
import styles from "./page.module.css";
|
||||
|
||||
type UrlData = {
|
||||
planid: string;
|
||||
};
|
||||
|
||||
export default function SubscriptionAddPage() {
|
||||
const data = useParams<UrlData>();
|
||||
const id = data.planid;
|
||||
const SessionInfo = useSessionStore();
|
||||
const alert = useAlert();
|
||||
const eventHandler = useEventHandlerStore();
|
||||
const { goTo } = useNavigation();
|
||||
|
||||
const [plan, setPlan] = useState<IPlan | null>(null);
|
||||
const [selectedMonths, setSelectedMonths] = useState<number>(1);
|
||||
const [finalPrice, setFinalPrice] = useState<number>(0);
|
||||
|
||||
const getPlanInfo = () => {
|
||||
eventHandler.setEventType(EVENT_TYPES.LOADING);
|
||||
ApiRequest.post<IPlan[]>("plans/find", { _id: id })
|
||||
.then((res) => {
|
||||
if (res && res.length > 0) {
|
||||
setPlan(res[0]);
|
||||
setFinalPrice(res[0].price);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
alert.showError("No se pudo cargar la información del plan");
|
||||
})
|
||||
.finally(() => {
|
||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
getPlanInfo();
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (plan) {
|
||||
let discount = 0;
|
||||
if (selectedMonths === 3) discount = plan.discount3Months || 0;
|
||||
else if (selectedMonths === 6) discount = plan.discount6Months || 0;
|
||||
else if (selectedMonths === 12) discount = plan.discount12Months || 0;
|
||||
|
||||
const total = plan.price * selectedMonths;
|
||||
const final = total - (total * (discount / 100));
|
||||
setFinalPrice(final);
|
||||
}
|
||||
}, [selectedMonths, plan]);
|
||||
|
||||
const getLink = () => {
|
||||
if (SessionInfo.userId && SessionInfo.loged) {
|
||||
const preApprovalData: CreateMPPreApprovalParams = {
|
||||
planId: id,
|
||||
sessionUser: SessionInfo.userId,
|
||||
months: selectedMonths,
|
||||
};
|
||||
|
||||
eventHandler.setEventType(EVENT_TYPES.LOADING);
|
||||
|
||||
ApiRequest.post<MPPreApprovalResponse>("suscriptions/preapproval", preApprovalData)
|
||||
.then(async (response) => {
|
||||
if (response.status === MP_PRE_APPROVAL_STATUS.READY_TO_START) {
|
||||
if (plan?.price === 0) {
|
||||
// Actualizamos la sesión manualmente para evitar la "race condition" con los sockets
|
||||
// Esto asegura que el estado de Zustand tenga la nueva suscripción antes de ir al dashboard
|
||||
await SessionInfo.checkSession();
|
||||
}
|
||||
goTo(response.init_point);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
alert.showError(error.format ? error.format() : "Ocurrió un error");
|
||||
})
|
||||
.finally(() => {
|
||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Suspense fallback={<SuspenseLoading />}>
|
||||
<SecurePage loginAction={LOGIN_ACTIONS.PLAN_SUBSCRIPTION}>
|
||||
<AnimatedContainer
|
||||
color="#aeaeae"
|
||||
minSize={150}
|
||||
maxSize={500}
|
||||
items={10}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: "20px",
|
||||
}}
|
||||
>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.card}>
|
||||
|
||||
<>
|
||||
<div className={styles.leftPanel}>
|
||||
<div style={{ marginBottom: "2rem" }}>
|
||||
<span style={{ background: "rgba(255,255,255,0.2)", padding: "5px 12px", borderRadius: "20px", fontSize: "0.85rem", fontWeight: "bold", textTransform: "uppercase", letterSpacing: "1px" }}>
|
||||
Resumen de contratación
|
||||
</span>
|
||||
</div>
|
||||
<h1 className={styles.title}>{plan?.name}</h1>
|
||||
<p className={styles.subtitle}>
|
||||
{plan?.price === 0
|
||||
? "Activa tu plan de forma instantánea. Sin tarjetas de crédito, sin complicaciones."
|
||||
: "Selecciona la duración de tu plan. ¡Aprovechá los descuentos exclusivos pagando por más meses por adelantado!"}
|
||||
</p>
|
||||
|
||||
{plan?.features && (
|
||||
<ul className={styles.featureList}>
|
||||
{plan.features.slice(0, 4).map((feature, idx) => (
|
||||
<li key={idx} className={styles.featureItem}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#4ade80" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
{plan.features.length > 4 && (
|
||||
<li className={styles.featureItem} style={{ opacity: 0.7, fontStyle: 'italic' }}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="16"></line><line x1="8" y1="12" x2="16" y2="12"></line></svg>
|
||||
Y mucho más...
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.rightPanel}>
|
||||
<div className={styles.monthsContainer}>
|
||||
{plan?.price === 0 ? (
|
||||
<div className={`${styles.monthOption} ${styles.monthOptionSelected}`} style={{ cursor: 'default' }}>
|
||||
<div>
|
||||
<span className={styles.monthLabel}>Suscripción Base</span>
|
||||
<span className={styles.discountBadge} style={{ backgroundColor: 'var(--wine-dark)' }}>
|
||||
Gratis por siempre
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.monthPrice}>
|
||||
$0
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
[
|
||||
{ months: 1, label: '1 Mes', discount: 0 },
|
||||
{ months: 3, label: '3 Meses', discount: plan?.discount3Months || 0 },
|
||||
{ months: 6, label: '6 Meses', discount: plan?.discount6Months || 0 },
|
||||
{ months: 12, label: '12 Meses (1 Año)', discount: plan?.discount12Months || 0 },
|
||||
].map((option) => (
|
||||
<div
|
||||
key={option.months}
|
||||
className={`${styles.monthOption} ${selectedMonths === option.months ? styles.monthOptionSelected : ''}`}
|
||||
onClick={() => setSelectedMonths(option.months)}
|
||||
>
|
||||
<div>
|
||||
<span className={styles.monthLabel}>{option.label}</span>
|
||||
{option.discount > 0 && (
|
||||
<span className={styles.discountBadge}>
|
||||
{option.discount}% OFF
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.monthPrice}>
|
||||
${plan ? ((plan.price * option.months) * (1 - option.discount / 100)).toLocaleString('es-AR') : '0'}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.totalSection}>
|
||||
<div className={styles.totalRow}>
|
||||
<span className={styles.totalLabel}>Total a pagar hoy:</span>
|
||||
<span className={styles.totalValue}>
|
||||
${plan?.price === 0 ? "0" : finalPrice.toLocaleString('es-AR')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.actionButtons}>
|
||||
<button className={styles.btnSecondary} onClick={() => goTo("/landing/pricing")}>
|
||||
Volver
|
||||
</button>
|
||||
|
||||
{plan?.price === 0 ? (
|
||||
<button className={`${styles.btnPrimary} ${styles.btnFree}`} style={{ margin: 0 }} onClick={getLink}>
|
||||
Activar Gratis Ahora
|
||||
</button>
|
||||
) : (
|
||||
<button className={styles.btnPrimary} onClick={getLink}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="1" y="4" width="22" height="16" rx="2" ry="2"></rect><line x1="1" y1="10" x2="23" y2="10"></line></svg>
|
||||
Pagar con MercadoPago
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedContainer>
|
||||
</SecurePage>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import AnimatedContainer from "@components/AnimatedConainer/AnimatedContainer";
|
||||
import style from "./terms.module.css";
|
||||
import { ScrollToTop } from "@core/app/components/ScrollTop";
|
||||
import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider";
|
||||
import DefaultLoginAction from "@core/app/components/LoginAction/DefaultLoginAction";
|
||||
|
||||
export default function TermsPage() {
|
||||
return (
|
||||
<AnimatedContainer
|
||||
color="#aeaeae"
|
||||
minSize={150}
|
||||
maxSize={500}
|
||||
items={10}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<ScrollToTop />
|
||||
<HeaderConfProvider />
|
||||
<DefaultLoginAction />
|
||||
<div className="homeCentered">
|
||||
<h1 className="homeTitleHeader">Términos y condiciones</h1>
|
||||
<p className="homeResumeHeader">
|
||||
Este documento establece los términos y condiciones que regulan el uso del sitio
|
||||
web turnosxpress.com.ar, en adelante “la Plataforma”, tanto para los usuarios
|
||||
que ofrecen servicios mediante planes de suscripción (en adelante,
|
||||
“Prestadores”) como para quienes realizan reservas de dichos servicios (en
|
||||
adelante, “Clientes”). Al utilizar la Plataforma, todos los usuarios aceptan
|
||||
expresa y voluntariamente el cumplimiento de esta Política de Uso.
|
||||
</p>
|
||||
</div>
|
||||
<div className="homeCentered">
|
||||
<h4 className={style.termTitle}>Responsabilidad sobre el Contenido</h4>
|
||||
<h6 className={style.termSubTitle}>Contenido Generado por Prestadores</h6>
|
||||
<p className={style.termText}>
|
||||
Los Prestadores son exclusivamente responsables por toda la información que
|
||||
publican en la Plataforma, incluyendo, pero no limitado a: nombre del servicio,
|
||||
descripciones, imágenes, tarifas, disponibilidad horaria y ubicación.
|
||||
turnosxpress no verifica, aprueba, ni garantiza la veracidad, legalidad o
|
||||
idoneidad del contenido publicado por los Prestadores.
|
||||
</p>
|
||||
<h6 className={style.termSubTitle}>Exoneración de Responsabilidad</h6>
|
||||
<p className={style.termText}>
|
||||
La Plataforma actúa únicamente como intermediario tecnológico y no participa en
|
||||
la prestación efectiva de los servicios ofrecidos ni en las transacciones entre
|
||||
Prestadores y Clientes. Por tanto, no asumimos ninguna responsabilidad por
|
||||
daños, perjuicios, pérdidas, fraudes, incumplimientos contractuales o conflictos
|
||||
que pudieran surgir entre Prestadores y Clientes como consecuencia del uso de la
|
||||
Plataforma.
|
||||
</p>
|
||||
|
||||
<h4 className={style.termTitle}>Obligaciones y Reglas para Prestadores</h4>
|
||||
<p className={style.termText}>Los Prestadores se comprometen a:</p>
|
||||
<ul className={style.termTextList}>
|
||||
<li>
|
||||
Publicar únicamente información veraz, precisa, actualizada y legal. No
|
||||
incluir contenido ofensivo, difamatorio, discriminatorio, sexualmente
|
||||
explícito, violento, engañoso, fraudulento o que infrinja derechos de
|
||||
terceros.
|
||||
</li>
|
||||
<li>
|
||||
Contar con los permisos legales, licencias y habilitaciones necesarios para
|
||||
ofrecer el servicio publicado. No subir imágenes o contenido protegido por
|
||||
derechos de autor sin autorización expresa del titular.
|
||||
</li>
|
||||
<li>
|
||||
No utilizar la Plataforma para actividades ilegales, fraudulentas o
|
||||
contrarias a la moral y buenas costumbres. Mantener actualizada la
|
||||
disponibilidad y demás condiciones del servicio publicado.
|
||||
</li>
|
||||
</ul>
|
||||
<h4 className={style.termTitle}>
|
||||
Envío de Mensajes por WhatsApp y Responsabilidad sobre Servicios de Terceros
|
||||
</h4>
|
||||
<p className={style.termText}>
|
||||
El servicio de envío de mensajes mediante WhatsApp ofrecido por la Plataforma es
|
||||
una herramienta auxiliar que utiliza tecnologías y canales de terceros
|
||||
(incluyendo, pero no limitado a, Meta Platforms Inc. y sus subsidiarias).
|
||||
</p>
|
||||
<p className={style.termText}>
|
||||
turnosxpress no tiene ninguna relación comercial ni asociación con Meta,
|
||||
WhatsApp u otras plataformas externas. El uso de esta funcionalidad implica que
|
||||
el Prestador acepta la responsabilidad exclusiva por los contenidos enviados,
|
||||
así como por cualquier consecuencia derivada de su utilización.
|
||||
</p>
|
||||
<p className={style.termText}>
|
||||
En particular, turnosxpress no garantiza que el uso de este servicio no genere
|
||||
sanciones, bloqueos o restricciones por parte de WhatsApp, Meta u otras
|
||||
plataformas. Ante cualquier suspensión, bloqueo o cancelación de cuentas por
|
||||
parte de dichos servicios de terceros, el usuario reconoce y acepta que
|
||||
turnosxpress no será responsable por pérdidas económicas, perjuicios,
|
||||
imposibilidad de uso del servicio ni por el reembolso de pagos de suscripción ya
|
||||
realizados.
|
||||
</p>
|
||||
|
||||
<p className={style.termText}>
|
||||
Los usuarios se comprometen a utilizar la funcionalidad de mensajería conforme a
|
||||
las políticas de uso de WhatsApp y demás normativas aplicables. turnosxpress
|
||||
podrá suspender o limitar esta funcionalidad sin previo aviso en caso de
|
||||
detectar abusos, envíos masivos no autorizados (spam), reclamos de terceros o
|
||||
cualquier uso contrario a la ley.
|
||||
</p>
|
||||
<p className={style.termText}>
|
||||
turnosxpress se reserva el derecho de editar, suspender o eliminar sin previo
|
||||
aviso cualquier contenido que, a nuestro exclusivo criterio, infrinja esta
|
||||
política o resulte inapropiado para la comunidad.
|
||||
</p>
|
||||
|
||||
<h4 className={style.termTitle}>Obligaciones y Reglas para Clientes</h4>
|
||||
<p className={style.termText}>Los Clientes se comprometen a:</p>
|
||||
<ul className={style.termTextList}>
|
||||
<li>Utilizar la Plataforma de forma responsable y conforme a la ley.</li>
|
||||
<li>
|
||||
No hacer uso indebido del sistema de reservas (por ejemplo, generar reservas
|
||||
falsas o sin intención de asistencia).
|
||||
</li>
|
||||
<li>
|
||||
No acosar, amenazar ni establecer contacto indebido con los Prestadores por
|
||||
fuera de la Plataforma, salvo que el Prestador lo autorice expresamente.
|
||||
</li>
|
||||
<li>
|
||||
Reportar cualquier irregularidad, contenido inapropiado o actividad
|
||||
sospechosa detectada en la Plataforma.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h4 className={style.termTitle}>Moderación y Sanciones</h4>
|
||||
<p className={style.termText}>
|
||||
La Plataforma podrá bloquear, suspender o eliminar cuentas de usuario y/o su
|
||||
contenido, de forma temporal o permanente, cuando detecte infracciones a esta
|
||||
política, sin derecho a reembolso de pagos realizados, y sin perjuicio de
|
||||
iniciar acciones legales si correspondiera.
|
||||
</p>
|
||||
|
||||
<h4 className={style.termTitle}>Limitación de Garantías</h4>
|
||||
<p className={style.termText}>
|
||||
La Plataforma no garantiza la disponibilidad ininterrumpida de los servicios, la
|
||||
calidad de los servicios ofrecidos por los Prestadores ni la satisfacción del
|
||||
Cliente con el servicio recibido. Todos los acuerdos entre Prestadores y
|
||||
Clientes se celebran bajo su exclusiva responsabilidad.
|
||||
</p>
|
||||
|
||||
<h4 className={style.termTitle}>Modificaciones</h4>
|
||||
<p className={style.termText}>
|
||||
Nos reservamos el derecho de modificar en cualquier momento esta Política de
|
||||
Uso. Las modificaciones serán comunicadas mediante su publicación en la
|
||||
Plataforma y entrarán en vigor a partir de su publicación. El uso continuado de
|
||||
la Plataforma implica la aceptación de las nuevas condiciones.
|
||||
</p>
|
||||
|
||||
<h4 className={style.termTitle}>Jurisdicción</h4>
|
||||
<p className={style.termText}>
|
||||
Esta Política se rige por las leyes de Argentina. Cualquier controversia será
|
||||
resuelta por los tribunales comerciales de la provincia de Buenos Aires,
|
||||
Argentina, con renuncia expresa a cualquier otro fuero o jurisdicción.
|
||||
</p>
|
||||
<div style={{ height: "100px" }}> </div>
|
||||
</div>
|
||||
</AnimatedContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
.termText {
|
||||
padding-top: 10px;
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
text-align: justify;
|
||||
max-width: 1200px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.termTextList {
|
||||
padding-top: 10px;
|
||||
padding-left: 50px;
|
||||
}
|
||||
|
||||
.termTextList li {
|
||||
padding-bottom: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.termTitle {
|
||||
font-size: 20px;
|
||||
padding-top: 30px;
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
text-align: left;
|
||||
max-width: 1200px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.termSubTitle {
|
||||
font-size: 16px;
|
||||
padding-top: 20px;
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
text-align: left;
|
||||
max-width: 1200px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
Reference in New Issue
Block a user