feat: Refactor carousel component and add new event card

- Updated ClientCarousel to accept props for dynamic image rendering.
- Changed CarouselProps interface to export for better type usage.
- Enhanced Carousel component to handle default images and improved styling.
- Introduced EventoCard component for displaying event details with dynamic data.
- Added MarkdownRenderer for rendering markdown content in EventoCard.
- Removed old Formulario component and replaced it with FormularioRegistro for better structure.
- Implemented prefetching logic in FormularioRegistro to auto-fill user data based on account info.
- Created PrefetchAbiertaCorta component for handling short answer questions with pre-filled data.
- Added useGetApi hook for simplified API data fetching.
- Updated plantillas data to include validation types for new form fields.
- Introduced new types for event and questionnaire handling in TypeScript.
- Enhanced styles for better UI/UX, including carousel controls and required field indicators.
This commit is contained in:
miguel
2025-06-16 18:25:12 -06:00
parent e275b33131
commit f00bd8ac5e
21 changed files with 2110 additions and 599 deletions
+10 -2
View File
@@ -1,7 +1,15 @@
import type { NextConfig } from "next";
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
/* config options here */
images: {
remotePatterns: [
{
protocol: 'http',
hostname: 'localhost',
port: '4200',
},
],
},
};
export default nextConfig;
+1175 -4
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -20,6 +20,7 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hot-toast": "^2.5.2",
"react-markdown": "^10.1.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 790 KiB

@@ -7,8 +7,13 @@ import CreateEvento from '@/containers/create-evento';
import { plantillasDisponibles } from '@/data/plantillas';
import Image from 'next/image';
import CreateFormulario from '@/containers/create-formulario';
import Button from '@/components/button';
import axiosInstance from '@/utils/api-config';
import toast from 'react-hot-toast';
import { useRouter } from 'next/navigation';
export default function Page() {
const [eventoBanner, setEventoBanner] = useState<File | null>(null);
const [evento, setEvento] = useState<CreateEventoType>({
tipo_evento: '',
nombre_evento: '',
@@ -16,6 +21,7 @@ export default function Page() {
fecha_inicio: new Date(),
fecha_fin: new Date(),
});
const router = useRouter();
const [datosFormulario, setDatosFormulario] =
useState<FormularioCreacion | null>(null);
@@ -31,7 +37,7 @@ export default function Page() {
};
const handleBannerChange = (file: File | null) => {
console.log('Archivo seleccionado:', file);
setEventoBanner(file);
};
const handleSeleccionarPlantilla = (plantillaId: string) => {
@@ -44,6 +50,42 @@ export default function Page() {
}
};
const handleCrearEventoYFormulario = async () => {
try {
const createEvento = await axiosInstance.post('/evento', evento);
if (eventoBanner) {
const formData = new FormData();
formData.append('banner', eventoBanner);
const bannercreated = await axiosInstance.post(
`/evento/${createEvento.data.id_evento}/banner`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
);
console.log('Banner creado:', bannercreated.data);
}
const createFormulario = await axiosInstance.post('/cuestionario', {
id_evento: createEvento.data.id_evento,
...datosFormulario,
});
if (createFormulario.data.id_formulario) {
console.log('Formulario creado:', createFormulario.data);
}
router.push(`/administrador`)
} catch (error) {
toast.error(
'Error al crear el evento y el formulario. Por favor, inténtalo de nuevo.'
);
console.error('Error al crear evento y formulario:', error);
}
};
return (
<div className="container">
<div className="my-4">
@@ -108,6 +150,10 @@ export default function Page() {
onChange={(nuevo) => setDatosFormulario({ ...nuevo })}
/>
)}
<Button className="mb-4" onClick={handleCrearEventoYFormulario}>
Crear Evento y Formulario
</Button>
</div>
);
}
@@ -0,0 +1,48 @@
'use client';
import MarkdownRenderer from '@/components/markdown-render';
import FormularioRegistro from '@/containers/formulario/formulario-registro';
import { useGetApi } from '@/hooks/use-get-api';
import { GetEventoWithCuestionario } from '@/types/evento';
import Image from 'next/image';
import { useParams } from 'next/navigation';
import React from 'react';
type Params = {
nombre_evento: string;
id_evento: string;
id_cuestionario: string;
};
export default function Page() {
const params = useParams<Params>();
const { data, error } = useGetApi<GetEventoWithCuestionario>(
`/evento/${params.id_evento}/cuestionario/${params.id_cuestionario}`
);
if (error) {
return <div>Error al cargar el cuestionario: {error.message}</div>;
}
return (
<div className="container my-5">
<div className="text-center mb-4">
{data?.banner && (
<Image
src={`${process.env.NEXT_PUBLIC_API_URL}/banners/${data.banner}`}
alt={data?.nombre_evento || ''}
width={800}
height={400}
className="img-fluid rounded"
/>
)}
</div>
<h1>{data?.nombre_evento}</h1>
{data?.descripcion_evento && (
<MarkdownRenderer markdown={data?.descripcion_evento} />
)}
{data && (
<FormularioRegistro id_cuestionario={data?.cuestionario.id_cuestionario} />
)}
</div>
);
}
+26 -77
View File
@@ -1,89 +1,38 @@
'use client';
import ClientCarousel from '@/client-components/client-carousel';
import FormularioCard from '@/components/formulario-card';
import { GetCuestionario } from '@/types/cuestionario';
import { useEffect, useState } from 'react';
import EventoCard from '@/components/evento-card';
import { useGetApi } from '@/hooks/use-get-api';
import { GetEventoWithCuestionarios } from '@/types/evento';
import React from 'react';
export default function Page() {
const [eventos, setEventos] = useState<GetCuestionario[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function getEventos() {
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/cuestionario`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
}
);
if (!res.ok) {
throw new Error(`Error ${res.status}: ${res.statusText}`);
}
const data: GetCuestionario[] = await res.json();
setEventos(data);
} catch (err) {
console.error('Error en getEventos:', err);
setError('No se pudieron cargar los cuestionarios.');
} finally {
setLoading(false);
}
}
getEventos();
}, []);
function slugify(text: string): string {
return text
.toString()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
if (loading)
return <div className="text-center">Cargando cuestionarios...</div>;
if (error) return <div>{error}</div>;
const { loading, data } = useGetApi<GetEventoWithCuestionarios[]>(
'/evento/activos/cuestionarios'
);
return (
<>
<div>
<div className="mx-auto mb-5 mt-3 fade-in-down-bounce">
<ClientCarousel />
<ClientCarousel
images={data?.map((item) => ({
src: item.banner
? `${process.env.NEXT_PUBLIC_API_URL}/banners/${item.banner}`
: '/default-banner.jpg',
alt: item.nombre_evento,
}))}
/>
</div>
<div className="cards-1 mt-5">
<div className="container">
<div className="row">
{eventos.map((cuestionario, index) => {
const slug = slugify(cuestionario.nombre_form);
const url = `/registro/${slug}-${cuestionario.id_cuestionario}`;
const fadeClass = `delay-${(index % 5) + 1}`;
return (
<div
className={`col-md-6 col-lg-4 my-4 fade-in-up-bounce ${fadeClass}`}
key={cuestionario.id_cuestionario}
>
<FormularioCard
cuestionario={cuestionario}
link={url}
button_message="Registrarse"
/>
</div>
);
})}
</div>
{loading && <div>Cargando...</div>}
{data && (
<div className="row">
{data.map((item, key) => (
<div className="col-6 col-md-4" key={key}>
<EventoCard evento={item} />
</div>
))}
</div>
</div>
</>
)}
</div>
);
}
-80
View File
@@ -1,80 +0,0 @@
'use client';
import Button from '@/components/button';
import Formulario from '@/containers/formulario';
import { CuestionarioResponse } from '@/types/responder-formulario';
import axiosInstance from '@/utils/api-config';
import Image from 'next/image';
import { useParams, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react';
export default function Page() {
const router = useRouter();
const params = useParams<{ slug: string }>();
const slug = params?.slug;
const id_formulario = slug?.split('-').pop(); // Toma el último fragmento como ID
const [cuestionario, setCuestionario] = useState<CuestionarioResponse | null>(
null
);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const { data } = await axiosInstance.get<CuestionarioResponse>(
`/cuestionario/${id_formulario}/formulario`
);
if (data) {
console.log(data);
setCuestionario(data);
} else {
setError('Error fetching data');
}
} catch (error) {
console.error('Error fetching data:', error);
setError('Error de conexión');
} finally {
setLoading(false);
}
};
if (id_formulario) fetchData();
}, [id_formulario]);
if (loading) return <p>Cargando...</p>;
if (error) return <p className="text-danger">{error}</p>;
if (!cuestionario) return <p>No hay datos</p>;
return (
<div className="container py-4">
<Button icon="arrow-left" onClick={() => router.back()}>
Volver
</Button>
<div className="text-center mb-4 mt-2">
<Image
src={'/feriasex.png'}
width={1000}
height={300}
alt="Ejemplo de banner"
className="rounded-4 shadow-sm img-fluid"
style={{
objectFit: 'cover',
objectPosition: 'center',
height: 300,
}}
/>
</div>
<div className="px-lg-5 mx-lg-5">
<h2>{cuestionario.cuestionario.nombre_form}</h2>
<p>{cuestionario.cuestionario.descripcion}</p>
<Formulario
id_formulario={cuestionario.cuestionario.id_cuestionario}
secciones={cuestionario.cuestionario.secciones}
/>
</div>
</div>
);
}
+3 -2
View File
@@ -1,10 +1,11 @@
'use client';
import { CarouselProps } from '@/components/carousel';
import dynamic from 'next/dynamic';
import React from 'react';
const Carousel = dynamic(() => import('@/components/carousel'), {
ssr: false,
});
export default function ClientCarousel() {
return <Carousel />;
export default function ClientCarousel(props: CarouselProps) {
return <Carousel {...props} />;
}
+13 -13
View File
@@ -1,7 +1,7 @@
import Image from 'next/image';
import React from 'react';
interface CarouselProps {
export interface CarouselProps {
id?: string;
images?: {
src: string;
@@ -9,15 +9,9 @@ interface CarouselProps {
}[];
}
export default function Carousel({
id = 'carouselExample',
images = [
{
src: '/feriasex.png',
alt: 'Imagen de ejemplo',
},
],
}: CarouselProps) {
export default function Carousel({ id, images }: CarouselProps) {
console.log('Carousel images:', images);
if (!images || images.length === 0) {
return <div>No hay imágenes para mostrar</div>;
}
@@ -31,11 +25,17 @@ export default function Carousel({
className={`carousel-item ${index === 0 ? 'active' : ''}`}
>
<Image
src={img.src}
width={1600}
height={535}
src={img.src ? img.src : '/default-banner.png'}
width={1200}
height={400}
alt="Ejemplo de banner"
className="d-block w-100 rounded-3 img-fluid"
style={{
height: '400px',
objectFit: 'cover',
objectPosition: 'center',
maxHeight: '400px',
}}
/>
</div>
))}
+65
View File
@@ -0,0 +1,65 @@
import { GetEventoWithCuestionarios } from '@/types/evento';
import Image from 'next/image';
import Link from 'next/link';
import React, { useState } from 'react';
import MarkdownRenderer from './markdown-render';
export default function EventoCard({
evento,
}: {
evento: GetEventoWithCuestionarios;
}) {
const [verMas, setVerMas] = useState(false);
const descripcion = evento.descripcion_evento;
const limite = 100;
const descripcionRecortada =
descripcion.length > limite && !verMas
? `${descripcion.slice(0, limite)}...`
: descripcion;
return (
<div className="card card-blog d-flex flex-column justify-content-between">
<div>
<div className="card-image scale-hover-1">
<Link href={`/evento/${evento.id_evento}`}>
<Image
width={400}
height={300}
className="img-fluid"
src={`${process.env.NEXT_PUBLIC_API_URL}/banners/${evento.banner}`}
alt="Banner formulario"
/>
<div className="card-caption">{evento.nombre_evento}</div>
</Link>
<div className="ripple-cont"></div>
</div>
<div className="card-body">
<div className="card-description mb-0">
<MarkdownRenderer markdown={descripcionRecortada} />
{descripcion.length > limite && (
<button
onClick={() => setVerMas(!verMas)}
className="btn btn-link btn-sm p-0 ms-1 align-baseline"
style={{ fontSize: '0.875rem' }}
>
{verMas ? 'Ver menos' : 'Ver más'}
</button>
)}
</div>
{evento.cuestionarios.length > 0 &&
evento.cuestionarios.map((cuestionario) => (
<div key={cuestionario.id_cuestionario} className="">
<Link
href={`/evento/${evento.nombre_evento.split(' ').join('_')}/${evento.id_evento}/${cuestionario.id_cuestionario}`}
className="btn mt-2 w-100 btn-outline-primary"
>
{cuestionario.nombre_form}
</Link>
</div>
))}
</div>
</div>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import React from 'react';
import ReactMarkdown from 'react-markdown';
type MarkdownRendererProps = {
markdown: string;
};
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ markdown }) => (
<ReactMarkdown
components={{
p: ({ ...props }) => <p style={{ margin: 0 }} {...props} />,
}}
>
{markdown}
</ReactMarkdown>
);
export default MarkdownRenderer;
-416
View File
@@ -1,416 +0,0 @@
'use client';
import React, { useEffect, useState } from 'react';
import { CuestionarioSeccion } from '@/types/responder-formulario';
import RadioOptionGroup, { RadioOption } from '@/components/radio-option-group';
import Input from '@/components/input';
import Button from '@/components/button';
import { validarRespuesta } from '@/utils/validador';
import toast from 'react-hot-toast';
import axiosInstance from '@/utils/api-config';
import { useRouter } from 'next/navigation';
import { getAxiosError } from '@/utils/errors-utils';
interface GetAlumnoResponse {
id_ncuenta: number;
nombre: string;
apellidos: string;
carrera: string;
genero: 'M' | 'F';
}
export default function Formulario({
id_formulario,
secciones,
}: {
id_formulario: number;
secciones: CuestionarioSeccion[];
}) {
const router = useRouter();
const [esDeFES, setEsDeFES] = useState<boolean | null>(null);
const [cuenta, setCuenta] = useState('');
const [datosAuto, setDatosAuto] = useState<null | {
nombre: string;
apellidos: string;
correo: string;
genero: 'M' | 'F';
carrera: string;
}>(null);
const [respuestas, setRespuestas] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false); // New state for submission
const actualizarRespuesta = (idPregunta: string, valor: string) => {
setRespuestas((prev) => ({ ...prev, [idPregunta]: valor }));
};
useEffect(() => {
if (esDeFES && cuenta.length === 9) {
(async () => {
try {
setDatosAuto(null);
const { data } = await axiosInstance.get<GetAlumnoResponse>(
`/alumnos/${cuenta}`
);
console.log('Datos del alumno:', data);
if (data) {
setDatosAuto({
nombre: data.nombre.toString(),
apellidos: data.apellidos.toString(),
correo: data.id_ncuenta + '@pcpuma.acatlan.unam.mx',
genero: data.genero,
carrera: data.carrera,
});
}
} catch (err) {
const msg = getAxiosError(err);
toast.error(msg.message);
setDatosAuto(null);
}
})();
}
}, [cuenta, esDeFES]);
useEffect(() => {
if (esDeFES && datosAuto) {
const nuevasRespuestas: Record<string, string> = {};
secciones.forEach((seccion) => {
seccion.preguntas.forEach(({ pregunta }) => {
const id = pregunta.id_pregunta;
if (pregunta.validacion === 'nombre') {
nuevasRespuestas[`pregunta_${id}`] = pregunta.pregunta
.toLowerCase()
.includes('apellido')
? datosAuto.apellidos.toString()
: datosAuto.nombre.toString();
}
if (pregunta.validacion === 'correo') {
nuevasRespuestas[`pregunta_${id}`] = datosAuto.correo;
}
if (pregunta.validacion === 'cuenta_alumno') {
nuevasRespuestas[`pregunta_${id}`] = cuenta;
}
if (
pregunta.pregunta
.toLowerCase()
.includes('institución de procedencia')
) {
nuevasRespuestas[`pregunta_${id}`] = 'FES Acatlán';
}
if (pregunta.pregunta.toLowerCase().includes('carrera')) {
nuevasRespuestas[`pregunta_${id}`] = datosAuto.carrera;
}
});
});
setRespuestas((prev) => ({
...prev,
...nuevasRespuestas,
}));
}
}, [datosAuto, esDeFES, cuenta, secciones]);
// Encuentra preguntas por texto o validación
const todasPreguntas = secciones.flatMap((s) =>
s.preguntas.map((p) => p.pregunta)
);
const preguntaFES = todasPreguntas.find(
(p) => p.pregunta === '¿Eres parte de la comunidad de la FES Acatlán?'
);
const preguntaCuenta = todasPreguntas.find(
(p) => p.validacion === 'cuenta_alumno'
);
const preguntasRestantes = todasPreguntas.filter(
(p) =>
![preguntaFES?.id_pregunta, preguntaCuenta?.id_pregunta].includes(
p.id_pregunta
)
);
const respuestaFES = respuestas[`pregunta_${preguntaFES?.id_pregunta}`];
const formatearRespuestas = async () => {
if (!validarRespuestasObligatorias()) return;
const respuestasArray = Object.entries(respuestas).flatMap(
([key, valor]) => {
const idNumerico = Number(key.replace('pregunta_', ''));
const pregunta = secciones
.flatMap((s) => s.preguntas)
.find((p) => p.pregunta.id_pregunta === idNumerico);
const tipo = pregunta?.pregunta.tipo_pregunta.tipo_pregunta;
// Si es múltiple, convertir cada valor individual si corresponde
if (Array.isArray(valor)) {
return valor.map((v) => ({
id_pregunta: idNumerico,
valor: tipo === 'Cerrada' || tipo === 'Multiple' ? Number(v) : v,
}));
}
return [
{
id_pregunta: idNumerico,
valor: tipo === 'Cerrada' ? Number(valor) : valor,
},
];
}
);
const correoEntry = respuestasArray.find(
(r) =>
typeof r.valor === 'string' &&
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(r.valor)
);
const resultado = {
id_formulario: Number(id_formulario),
correo: correoEntry?.valor || 'correo@no-encontrado.com',
respuestas: respuestasArray,
fecha_envio: new Date().toISOString(),
};
console.log('Resultado a enviar:', resultado);
setIsSubmitting(true);
toast
.promise(
axiosInstance.post('/cuestionario-respondido/submit', resultado),
{
loading: 'Enviando formulario...',
success: 'Formulario enviado con éxito!',
error: 'Error al enviar el formulario.',
}
)
.then(() => router.push('/'))
.finally(() => setIsSubmitting(false));
};
const validarRespuestasObligatorias = (): boolean => {
const faltantes: number[] = [];
const errores: { id: number; mensaje: string }[] = [];
secciones.forEach((seccion) => {
seccion.preguntas.forEach(({ pregunta }) => {
const id = pregunta.id_pregunta;
const valor = respuestas[`pregunta_${id}`];
const tipo = pregunta.tipo_pregunta.tipo_pregunta;
const validacion = pregunta.validacion;
console.log('Validando pregunta:', pregunta.pregunta, ' ---------- ');
console.table({
id,
valor,
tipo,
validacion,
});
const respondida =
(tipo === 'Abierta' &&
typeof valor === 'string' &&
valor.trim() !== '') ||
(tipo === 'Cerrada' && valor !== undefined) ||
(tipo === 'Multiple' && Array.isArray(valor) && valor.length > 0);
if (!respondida && pregunta.obligatoria) {
faltantes.push(id);
}
if (tipo === 'Abierta' && validacion && typeof valor === 'string') {
const resultado = validarRespuesta(valor, validacion);
if (!resultado.valido) {
errores.push({ id, mensaje: resultado.mensaje });
}
}
});
});
if (faltantes.length > 0) {
console.log('Faltan preguntas obligatorias:', faltantes);
toast.error('Responde todas las preguntas obligatorias antes de enviar.');
return false;
}
if (errores.length > 0) {
toast.error(
`Corrige las respuestas con error de formato:\n${errores
.map((e) => `${e.mensaje}`)
.join('\n')}`
);
return false;
}
return true;
};
return (
<form className="container py-4">
{/* Pregunta inicial */}
{preguntaFES && (
<div className="mb-4">
<label className="form-label">{preguntaFES.pregunta}</label>
<RadioOptionGroup
name="comunidad_fes"
options={preguntaFES.opciones.map((op) => ({
label: op.opcion.opcion,
value: op.id_opcion,
}))}
selectedValue={respuestaFES ? Number(respuestaFES) : undefined}
onChange={(opt) => {
const id = opt.value;
const value = opt?.label ?? '';
setEsDeFES(value === 'Si'); // Se actualiza esDeFES con el texto
setCuenta('');
setDatosAuto(null);
actualizarRespuesta(
`pregunta_${preguntaFES.id_pregunta}`,
String(id)
);
}}
/>
</div>
)}
{/* Si dijo que sí, pedir número de cuenta */}
{esDeFES && preguntaCuenta && (
<Input
key={preguntaCuenta.id_pregunta}
label={preguntaCuenta.pregunta}
name={`pregunta_${preguntaCuenta.id_pregunta}`}
value={cuenta}
maxLength={10}
onChange={(e) => {
const value = e.target.value;
setCuenta(value);
actualizarRespuesta(
`pregunta_${preguntaCuenta.id_pregunta}`,
value
); // ✅ Guarda la respuesta
}}
/>
)}
{/* Si ya tiene datos o dijo que no es de la FES, mostrar el resto */}
{(esDeFES === false || (esDeFES && datosAuto)) &&
preguntasRestantes.map((p) => {
if (p.tipo_pregunta.tipo_pregunta === 'Abierta') {
let defaultValue = '';
let disabled = false;
if (esDeFES && datosAuto) {
if (p.validacion === 'nombre') {
defaultValue = p.pregunta.toLowerCase().includes('apellido')
? datosAuto.apellidos
: datosAuto.nombre;
disabled = true;
}
if (p.validacion === 'correo') {
defaultValue = datosAuto.correo;
disabled = true;
}
if (p.validacion === 'cuenta_alumno') {
defaultValue = cuenta;
disabled = true;
}
if (
p.pregunta.toLowerCase().includes('institución de procedencia')
) {
defaultValue = 'FES Acatlán';
disabled = true;
}
if (p.pregunta.toLowerCase().includes('carrera')) {
defaultValue = datosAuto.carrera;
disabled = true;
}
}
return (
<React.Fragment key={p.id_pregunta}>
<Input
label={p.pregunta}
name={`pregunta_${p.id_pregunta}`}
defaultValue={defaultValue}
disabled={disabled}
onChange={(e) => {
console.log('Respuesta:', e.target.value);
actualizarRespuesta(
`pregunta_${p.id_pregunta}`,
e.target.value
);
}}
/>
{p.validacion === 'correo' && (
<div className="alert alert-info">
Este es el correo donde enviaremos la validación de registro{' '}
</div>
)}
</React.Fragment>
);
}
if (p.tipo_pregunta.tipo_pregunta === 'Cerrada') {
const opciones: RadioOption<number>[] = p.opciones.map((op) => ({
label: op.opcion.opcion,
value: op.id_opcion,
}));
let respuestaActual = respuestas[`pregunta_${p.id_pregunta}`];
// Si es de FES y hay datos automáticos, intentar preseleccionar género
if (
esDeFES &&
datosAuto &&
!respuestaActual &&
p.pregunta.toLowerCase().includes('género')
) {
const generoTexto =
datosAuto.genero === 'M' ? 'Masculino' : 'Femenino';
const opcionGenero = p.opciones.find(
(op) =>
op.opcion.opcion.toLowerCase() === generoTexto.toLowerCase()
);
if (opcionGenero) {
respuestaActual = String(opcionGenero.id_opcion);
actualizarRespuesta(
`pregunta_${p.id_pregunta}`,
String(respuestaActual)
);
}
}
return (
<div key={p.id_pregunta}>
<label className="form-label">{p.pregunta}</label>
<RadioOptionGroup
name={`pregunta_${p.id_pregunta}`}
options={opciones}
selectedValue={
respuestaActual ? Number(respuestaActual) : undefined
}
onChange={(opt) =>
actualizarRespuesta(
`pregunta_${p.id_pregunta}`,
String(opt.value)
)
}
/>
</div>
);
}
return null;
})}
{/* Botón de enviar */}
<Button onClick={formatearRespuestas} disabled={isSubmitting}>
{isSubmitting ? 'Enviando...' : 'Enviar'}
</Button>
</form>
);
}
@@ -0,0 +1,471 @@
import React, { useState, useEffect } from 'react';
import MarkdownRenderer from '@/components/markdown-render';
import { useGetApi } from '@/hooks/use-get-api';
import { GetCuestionario } from '@/types/evento';
import SimpleInput from '@/components/input';
import RadioOptionGroup, { RadioOption } from '@/components/radio-option-group';
import axiosInstance from '@/utils/api-config';
import PrefetchAbiertaCorta from './prefetch-abierta-corta';
import Button from '@/components/button';
import toast from 'react-hot-toast';
import { useRouter } from 'next/navigation';
import { getAxiosError } from '@/utils/errors-utils';
type UsuarioData = {
nombre: string;
apellidos: string;
correo: string;
genero: 'M' | 'F';
carrera: string;
};
const confirmativeOption = ['Sí', 'Si', 'Si, claro', 'Claro que sí'];
const negativeOption = ['No', 'No, gracias', 'No, de momento'];
type RespuestaFormulario = Record<string, string | number>; // id_pregunta: respuesta
export default function FormularioRegistro({
id_cuestionario,
}: {
id_cuestionario: number;
}) {
// ------------------------
// Estados
// ------------------------
const [isComunidad, setIsComunidad] = useState<RadioOption<number>>();
const [respuestas, setRespuestas] = useState<RespuestaFormulario>({});
const [cuentaInfo, setCuentaInfo] = useState<UsuarioData | null>(null);
const [isSending, setIsSending] = useState(false);
const router = useRouter();
// ------------------------
// Carga del cuestionario
// ------------------------
const { data, error } = useGetApi<GetCuestionario>(
`/cuestionario/${id_cuestionario}/formulario`
);
// ------------------------
// Extracción de preguntas clave
// ------------------------
const preguntas = data?.cuestionario.secciones.flatMap((seccion) =>
seccion.preguntas.map((pregunta) => pregunta.pregunta)
);
const preguntaComunidad = preguntas?.find(
(pregunta) =>
pregunta.validacion === 'comunidad_alumno' ||
pregunta.validacion === 'comunidad_trabajador'
);
const preguntaCuenta = preguntas?.find(
(pregunta) =>
pregunta.validacion === 'cuenta_alumno' ||
pregunta.validacion === 'cuenta_trabajador'
);
// ------------------------
// Efecto: buscar información de cuenta
// ------------------------
useEffect(() => {
const cuenta = respuestas[preguntaCuenta?.id_pregunta ?? ''];
const esCuentaAlumno = preguntaCuenta?.validacion === 'cuenta_alumno';
const esCuentaTrabajador =
preguntaCuenta?.validacion === 'cuenta_trabajador';
const puedeBuscar =
cuenta &&
typeof cuenta === 'string' &&
((esCuentaAlumno && cuenta.length === 9) || esCuentaTrabajador);
const fetchCuentaInfo = async () => {
try {
const endpoint = esCuentaAlumno
? `/alumnos/${cuenta}`
: `/trabajadores/${cuenta}`;
console.log('Buscando información de cuenta:', endpoint);
// Usar datos fake para pruebas
setCuentaInfo({
nombre: 'Juan',
apellidos: 'Pérez',
correo: '421010301@pcpuma.acatlan.unam.mx',
genero: 'M',
carrera: 'Ingeniería',
});
} catch (error) {
console.error('Error al obtener datos de cuenta:', error);
setCuentaInfo(null);
}
};
if (puedeBuscar) {
fetchCuentaInfo();
} else {
setCuentaInfo(null);
}
}, [respuestas[preguntaCuenta?.id_pregunta ?? '']]);
// ------------------------
// Efecto: precargar respuestas si hay cuentaInfo
// ------------------------
useEffect(() => {
if (!cuentaInfo) {
if (preguntas) {
const idsPrefetch = preguntas
.filter((pregunta) =>
[
'nombre',
'apellidos',
'correo',
'institucion',
'carrera',
'genero',
].includes(pregunta.validacion)
)
.map((pregunta) => pregunta.id_pregunta);
setRespuestas((prev) => {
const nuevas = { ...prev };
idsPrefetch.forEach((id) => {
delete nuevas[id];
});
return nuevas;
});
}
return;
}
const nuevasRespuestas: RespuestaFormulario = {};
if (preguntas) {
for (const pregunta of preguntas) {
const id = pregunta.id_pregunta;
switch (pregunta.validacion) {
case 'nombre':
nuevasRespuestas[id] = cuentaInfo.nombre;
break;
case 'apellidos':
nuevasRespuestas[id] = cuentaInfo.apellidos;
break;
case 'correo':
nuevasRespuestas[id] = cuentaInfo.correo;
break;
case 'institucion':
nuevasRespuestas[id] = 'FES Acatlán';
break;
case 'carrera':
nuevasRespuestas[id] = cuentaInfo.carrera;
break;
case 'genero':
const generoTexto =
cuentaInfo.genero === 'M' ? 'Masculino' : 'Femenino';
const opcion = pregunta.opciones?.find(
(op) =>
op.opcion.opcion.toLowerCase() === generoTexto.toLowerCase()
);
if (opcion) {
nuevasRespuestas[id] = String(opcion.id_opcion);
}
break;
}
}
setRespuestas((prev) => ({
...prev,
...nuevasRespuestas,
}));
}
}, [cuentaInfo]);
// ------------------------
// Funciones auxiliares
// ------------------------
const actualizarRespuesta = (idPregunta: string, valor: string) => {
setRespuestas((prev) => ({ ...prev, [idPregunta]: valor }));
};
const handleOnSubmit = async () => {
setIsSending(true);
if (!data?.cuestionario.id_cuestionario) {
alert('No se ha cargado correctamente el formulario');
return;
}
const preguntasObligatorias =
data?.cuestionario.secciones.flatMap((seccion) =>
seccion.preguntas
.filter((pregunta) => pregunta.pregunta.obligatoria)
.map((pregunta) => pregunta.pregunta)
) || [];
const faltantes = preguntasObligatorias.filter(
(pregunta) =>
respuestas[pregunta.id_pregunta] === undefined ||
respuestas[pregunta.id_pregunta] === '' ||
respuestas[pregunta.id_pregunta] === null
);
if (faltantes.length > 0) {
toast.error('Por favor responde todas las preguntas obligatorias.');
return;
}
// Buscar un posible correo en las respuestas si no hay cuentaInfo
let correo = cuentaInfo?.correo;
if (!correo) {
// Buscar en las respuestas una que parezca correo
const correoRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
for (const valor of Object.values(respuestas)) {
if (typeof valor === 'string' && correoRegex.test(valor)) {
correo = valor;
break;
}
}
}
const payload = {
id_cuestionario: id_cuestionario,
correo: correo || '',
fecha_envio: new Date().toISOString(),
respuestas: Object.entries(respuestas).map(([id_pregunta, valor]) => ({
id_pregunta: Number(id_pregunta),
valor: isNaN(Number(valor)) ? valor : Number(valor),
})),
};
console.log('Payload para enviar:', payload);
try {
const res = await axiosInstance.post(
'/cuestionario-respondido/submit',
payload
);
toast.success(res.data.message || 'Formulario enviado correctamente');
} catch (error) {
console.log(getAxiosError(error));
toast.error('Hubo un error al enviar el formulario');
} finally {
// Redirigir al usuario después de enviar el formulario
router.push('/');
}
};
// ------------------------
// Condición para mostrar el formulario
// ------------------------
const mostrarFormularioRestante =
(isComunidad &&
confirmativeOption.includes(isComunidad.label) &&
!!cuentaInfo) ||
(isComunidad && negativeOption.includes(isComunidad.label));
if (error) {
console.error('Error al cargar el cuestionario:', error);
return <div>Error al cargar el cuestionario</div>;
}
return (
<div>
<hr />
<h2 className="text-xl font-bold">{data?.cuestionario.nombre_form}</h2>
{data?.cuestionario.descripcion && (
<MarkdownRenderer markdown={data.cuestionario.descripcion} />
)}
{preguntaComunidad && (
<div className="my-4">
<label
className={`form-label ${
preguntaComunidad.obligatoria ? 'required' : ''
}`}
>
{preguntaComunidad.pregunta}
</label>
<RadioOptionGroup
name="comunidad"
options={preguntaComunidad.opciones.map((op) => ({
label: op.opcion.opcion,
value: op.id_opcion,
}))}
selectedValue={isComunidad?.value}
onChange={(opt) => {
console.log('Opción comunidad seleccionada:', opt);
setIsComunidad(opt);
actualizarRespuesta(
preguntaComunidad.id_pregunta.toString(),
String(opt.value)
);
}}
/>
</div>
)}
{isComunidad &&
confirmativeOption.includes(isComunidad?.label) &&
preguntaCuenta && (
<div className="my-4">
<label
className={`form-label ${
preguntaCuenta.obligatoria ? 'required' : ''
}`}
>
{preguntaCuenta.pregunta}
</label>
<SimpleInput
type="text"
name="cuenta"
placeholder="Ingrese su cuenta"
maxLength={10}
value={respuestas[preguntaCuenta.id_pregunta] || ''}
onChange={(e) =>
setRespuestas((prev) => ({
...prev,
[preguntaCuenta.id_pregunta]: e.target.value,
}))
}
/>
</div>
)}
{mostrarFormularioRestante &&
data?.cuestionario.secciones.map((seccion, i) => (
<div key={i}>
{seccion.preguntas.map((pregunta) => {
// Evitar renderizar las preguntas ya manejadas
if (
pregunta.pregunta.id_pregunta ===
preguntaComunidad?.id_pregunta ||
pregunta.pregunta.id_pregunta === preguntaCuenta?.id_pregunta
)
return null;
if (
pregunta.pregunta.tipo_pregunta.tipo_pregunta ===
'Abierta (Respuesta corta)'
) {
return (
<PrefetchAbiertaCorta
key={pregunta.pregunta.id_pregunta}
obligatorio={pregunta.pregunta.obligatoria}
idPregunta={pregunta.pregunta.id_pregunta}
enunciado={pregunta.pregunta.pregunta}
validacion={pregunta.pregunta.validacion}
respuestas={respuestas}
cuentaInfo={cuentaInfo}
isComunidad={isComunidad?.label}
onChange={actualizarRespuesta}
/>
);
}
if (
pregunta.pregunta.tipo_pregunta.tipo_pregunta ===
'Abierta (Parrafo)'
) {
return (
<div key={pregunta.pregunta.id_pregunta} className="my-4">
<label
className={`form-label ${
pregunta.pregunta.obligatoria ? 'required' : ''
}`}
>
{pregunta.pregunta.pregunta}
</label>
<SimpleInput
type="textarea"
name={`respuesta_${pregunta.pregunta.id_pregunta}`}
placeholder="Ingrese una respuesta"
value={respuestas[pregunta.pregunta.id_pregunta] || ''}
onChange={(e) =>
setRespuestas((prev) => ({
...prev,
[pregunta.pregunta.id_pregunta]: e.target.value,
}))
}
/>
</div>
);
}
if (pregunta.pregunta.tipo_pregunta.tipo_pregunta === 'Cerrada') {
const opciones: RadioOption<number>[] =
pregunta.pregunta.opciones.map((op) => ({
label: op.opcion.opcion,
value: op.id_opcion,
}));
let respuestaActual =
respuestas[`${pregunta.pregunta.id_pregunta}`];
if (
confirmativeOption.includes(isComunidad?.label) &&
cuentaInfo &&
respuestaActual === undefined // solo si no ha respondido
) {
if (pregunta.pregunta.validacion === 'genero') {
const generoTexto =
cuentaInfo.genero === 'M' ? 'Masculino' : 'Femenino';
const opcionGenero = pregunta.pregunta.opciones.find(
(op) =>
op.opcion.opcion.toLowerCase() ===
generoTexto.toLowerCase()
);
if (opcionGenero) {
respuestaActual = String(opcionGenero.id_opcion);
}
}
}
return (
<div key={pregunta.pregunta.id_pregunta}>
<label
className={`form-label ${
pregunta.pregunta.obligatoria ? 'required' : ''
}`}
>
{pregunta.pregunta.pregunta}
</label>
<RadioOptionGroup
name={`pregunta_${pregunta.pregunta.id_pregunta}`}
options={opciones}
selectedValue={
respuestaActual ? Number(respuestaActual) : undefined
}
onChange={(opt) => {
actualizarRespuesta(
`${pregunta.pregunta.id_pregunta}`,
String(opt.value)
);
}}
/>
</div>
);
}
return (
<div key={pregunta.pregunta.id_pregunta} className="my-4">
<label
className={`form-label ${
pregunta.pregunta.obligatoria ? 'required' : ''
}`}
>
{pregunta.pregunta.pregunta}
</label>
</div>
);
})}
</div>
))}
<Button
className="btn btn-primary"
onClick={handleOnSubmit}
disabled={!mostrarFormularioRestante}
>
{isSending ? 'Enviando...' : 'Enviar Formulario'}
</Button>
</div>
);
}
@@ -0,0 +1,75 @@
import React from 'react';
import SimpleInput from '@/components/input';
type Props = {
idPregunta: number;
enunciado: string;
obligatorio?: boolean;
validacion?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
respuestas: Record<string, any>;
cuentaInfo: {
nombre: string;
apellidos: string;
correo: string;
genero: 'M' | 'F';
carrera: string;
} | null;
isComunidad: string | undefined;
onChange: (id: string, valor: string) => void;
};
const confirmativeOption = ['Sí', 'Si', 'Si, claro', 'Claro que sí'];
export default function PrefetchAbiertaCorta({
idPregunta,
enunciado,
validacion,
respuestas,
cuentaInfo,
isComunidad,
obligatorio,
onChange,
}: Props) {
let defaultValue = '';
let disabled = false;
if (confirmativeOption.includes(isComunidad ?? '') && cuentaInfo) {
if (validacion === 'nombre') {
defaultValue = cuentaInfo.nombre;
disabled = true;
}
if (validacion === 'apellidos') {
defaultValue = cuentaInfo.apellidos;
disabled = true;
}
if (validacion === 'correo') {
defaultValue = cuentaInfo.correo;
disabled = true;
}
if (validacion === 'institucion') {
defaultValue = 'FES Acatlán';
disabled = true;
}
if (validacion === 'carrera') {
defaultValue = cuentaInfo.carrera;
disabled = true;
}
}
return (
<div className="my-4">
<label className={`form-label ${obligatorio ? 'required' : ''}`}>
{enunciado}
</label>
<SimpleInput
type="text"
name={`respuesta_${idPregunta}`}
placeholder="Ingrese una respuesta"
value={respuestas[idPregunta] ?? defaultValue}
disabled={disabled}
onChange={(e) => onChange(idPregunta.toString(), e.target.value)}
/>
</div>
);
}
+8
View File
@@ -49,16 +49,19 @@ export const plantillasDisponibles: Plantilla[] = [
titulo: 'Nombre(s)',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: 'nombre'
},
{
titulo: 'Apellidos',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: 'apellidos'
},
{
titulo: 'Género',
tipo: 'Cerrada',
obligatoria: true,
validacion: 'genero',
opciones: [
{ valor: 'Masculino' },
{ valor: 'Femenino' },
@@ -111,16 +114,19 @@ export const plantillasDisponibles: Plantilla[] = [
titulo: 'Nombre(s)',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: 'nombre',
},
{
titulo: 'Apellidos',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: 'apellidos',
},
{
titulo: 'Género',
tipo: 'Cerrada',
obligatoria: true,
validacion: 'genero',
opciones: [
{ valor: 'Masculino' },
{ valor: 'Femenino' },
@@ -130,11 +136,13 @@ export const plantillasDisponibles: Plantilla[] = [
{
titulo: 'Institución de procedencia',
tipo: 'Abierta (Respuesta corta)',
validacion: 'institucion',
obligatoria: true,
},
{
titulo: 'Carrera',
tipo: 'Abierta (Respuesta corta)',
validacion: 'carrera',
obligatoria: true,
},
],
+31
View File
@@ -0,0 +1,31 @@
import axiosInstance from '@/utils/api-config';
import { getAxiosError } from '@/utils/errors-utils';
import { useEffect, useState } from 'react';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const useGetApi = <T,>(url: string, params?: Record<string, any>) => {
const [data, setData] = useState<T>();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<ErrorState | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
const res = await axiosInstance.get<T>(url, { params });
setData(res.data);
setError(null);
} catch (err) {
console.error(err);
const message = getAxiosError(err);
setError(message);
} finally {
setLoading(false);
}
};
fetchData();
}, [url, params]);
return { data, setData, loading, error };
};
+27 -2
View File
@@ -30,6 +30,12 @@ $theme-colors: map-merge($theme-colors, $custom-colors);
@import 'animation';
@import './global.scss';
.carousel-control-prev-icon,
.carousel-control-next-icon {
filter: invert(100%) sepia(100%) saturate(0%) hue-rotate(0deg)
brightness(200%);
}
//Add additional custom code here
@include media-breakpoint-up(lg) {
@@ -73,6 +79,7 @@ ul.list-unstyled li {
position: relative;
overflow: hidden;
margin: -1.5rem 1rem 0;
cursor: pointer;
@extend .rounded;
@extend .shadow;
@@ -83,7 +90,7 @@ ul.list-unstyled li {
left: 0;
width: 100%;
height: 80%; // ajusta según el tamaño deseado
background: linear-gradient(to top, rgba(0, 0, 0, 0.4), transparent);
background: linear-gradient(to top, rgba(0, 0, 0, 0.5), transparent);
pointer-events: none;
z-index: 1;
}
@@ -98,13 +105,20 @@ ul.list-unstyled li {
.card-caption {
position: absolute;
bottom: 15px;
bottom: 5px;
left: 15px;
color: #fff;
font-weight: bold;
font-size: 1.25rem;
text-shadow: 0 2px 5px rgba(33, 33, 33, 0.5);
z-index: 2;
}
.card-description {
> p {
margin: 0;
}
}
}
.table {
@@ -139,3 +153,14 @@ ul.list-unstyled li {
input {
--bs-body-bg: #fff;
}
.required {
position: relative;
&::after {
content: '*';
position: absolute;
color: red;
font-weight: bold;
margin-left: 10px;
}
}
+5 -1
View File
@@ -4,11 +4,15 @@ export type TiposValidacion =
| 'telefono'
| 'nombre'
| 'entero'
| 'apellidos'
| 'decimal'
| 'comunidad_alumno'
| 'cuenta_alumno'
| 'comunidad_trabajador'
| 'cuenta_trabajador';
| 'cuenta_trabajador'
| 'genero'
| 'carrera'
| 'institucion'
export type TipoPregunta =
| 'Abierta (Respuesta corta)'
+85
View File
@@ -0,0 +1,85 @@
import { TipoPregunta, TiposValidacion } from "./create-formulario";
export interface GetEvento {
id_evento: number;
nombre_evento: string;
descripcion_evento: string;
tipo_evento: string;
fecha_inicio: string; // formato ISO 8601
fecha_fin: string; // formato ISO 8601
// eslint-disable-next-line @typescript-eslint/no-explicit-any
asistencias: any | null;
banner: string | null;
}
export interface Cuestionario {
id_cuestionario: number;
nombre_form: string;
banner: string | null;
contador_secciones: number;
descripcion: string;
editable: boolean;
fecha_inicio: string; // formato ISO 8601
fecha_fin: string; // formato ISO 8601
id_tipo_cuestionario: number;
id_evento: number;
tipoCuestionario: {
id_tipo_cuestionario: number;
tipo_cuestionario: string;
};
}
export interface GetCuestionario {
tipo_cuestionario: {
id_tipo_cuestionario: number;
tipo_cuestionario: string;
};
evento: GetEvento;
cuestionario: CuestionarioWithSecciones;
}
export interface CuestionarioWithSecciones extends Cuestionario {
secciones: {
id_cuestionario_seccion: number;
posicion: number;
seccion: {
id_seccion: number;
titulo: string;
contador_pregunta: number;
descripcion: string;
};
preguntas: {
id_seccion_pregunta: number;
posicion: number;
pregunta: {
id_pregunta: number;
pregunta: string;
contador_opcion: number;
obligatoria: boolean;
id_tipo_pregunta: number;
validacion: TiposValidacion;
tipo_pregunta: {
id_tipo: number;
tipo_pregunta: TipoPregunta;
};
opciones: {
id_pregunta_opcion: number;
posicion: number;
id_opcion: number;
opcion: {
id_opcion: number;
opcion: string;
};
}[];
};
}[];
}[];
}
export interface GetEventoWithCuestionarios extends GetEvento {
cuestionarios: Cuestionario[];
}
export interface GetEventoWithCuestionario extends GetEvento {
cuestionario: Cuestionario;
}
+2 -1
View File
@@ -7,8 +7,9 @@ interface User {
carrera?: string;
}
interface ErrorState {
status: boolean;
message: string;
code: number;
}
}