first commit

This commit is contained in:
2026-07-16 20:48:43 -03:00
commit 5696cee264
1111 changed files with 322270 additions and 0 deletions
@@ -0,0 +1,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" }}>&nbsp;</div>
</div>
</AnimatedContainer>
);
}
+124
View File
@@ -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 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" }}>&nbsp;</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);
};
+412
View File
@@ -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));
});
});
};
+253
View File
@@ -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" }}>&nbsp;</div>
</div>
</AnimatedContainer>
);
}
+32
View File
@@ -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>
);
}
+6
View File
@@ -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>
);
}
+875
View File
@@ -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);
}
+132
View File
@@ -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}>, 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>
);
}
+122
View File
@@ -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" }}>&nbsp;</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>&nbsp;</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>&nbsp;</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",
}}
>
&nbsp;
</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 &#x1F3C6;"
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));
});
});
};
+200
View File
@@ -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&nbsp;
<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!&nbsp;
<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>
);
}
+164
View File
@@ -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" }}>&nbsp;</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;
}