feat: Implement course monetization with Mercado Pago, update roadmap status, and refine discussion service handlers.
This commit is contained in:
@@ -37,6 +37,7 @@ El proyecto ha sido optimizado para reducir la complejidad de la infraestructura
|
|||||||
- **Efficient Docker Builds**: Imágenes de contenedor optimizadas para desarrollo rápido y despliegue ligero.
|
- **Efficient Docker Builds**: Imágenes de contenedor optimizadas para desarrollo rápido y despliegue ligero.
|
||||||
- **Discussion Forums**: Sistema completo de foros por curso con hilos de discusión, respuestas anidadas, votación, moderación por instructores y suscripciones.
|
- **Discussion Forums**: Sistema completo de foros por curso con hilos de discusión, respuestas anidadas, votación, moderación por instructores y suscripciones.
|
||||||
- **Split Authentication Flow**: Flujos de autenticación diferenciados para usuarios personales (email/password) y empresas (dominio corporativo).
|
- **Split Authentication Flow**: Flujos de autenticación diferenciados para usuarios personales (email/password) y empresas (dominio corporativo).
|
||||||
|
- **Course Monetization**: Integración con Mercado Pago para venta de cursos, con inscripciones automáticas y paneles de precios para instructores.
|
||||||
|
|
||||||
## Requisitos del Sistema
|
## Requisitos del Sistema
|
||||||
|
|
||||||
@@ -581,6 +582,7 @@ Obtiene una lista de todas las organizaciones registradas.
|
|||||||
- **Discussion Forums**: Complete forum system with threaded replies, voting, instructor moderation, and subscriptions.
|
- **Discussion Forums**: Complete forum system with threaded replies, voting, instructor moderation, and subscriptions.
|
||||||
- **Course Announcements**: Instructor-to-student communication system with automatic notifications and pinning functionality.
|
- **Course Announcements**: Instructor-to-student communication system with automatic notifications and pinning functionality.
|
||||||
- **Split Authentication**: Separate login flows for personal users and enterprise organizations with SSO support.
|
- **Split Authentication**: Separate login flows for personal users and enterprise organizations with SSO support.
|
||||||
|
- **Mercado Pago Monetization**: Integrated payment gateway with automatic course unlocking and transaction tracking.
|
||||||
|
|
||||||
## 📄 Licencia
|
## 📄 Licencia
|
||||||
Este proyecto es código abierto y está disponible bajo los términos de la licencia especificada en el repositorio.
|
Este proyecto es código abierto y está disponible bajo los términos de la licencia especificada en el repositorio.
|
||||||
+9
-8
@@ -192,11 +192,13 @@
|
|||||||
- [ ] **Bookmarks**: Sistema de favoritos para lecciones importantes.
|
- [ ] **Bookmarks**: Sistema de favoritos para lecciones importantes.
|
||||||
- [ ] **Progress Dashboard**: Gráficos de progreso temporal y predicción de finalización.
|
- [ ] **Progress Dashboard**: Gráficos de progreso temporal y predicción de finalización.
|
||||||
|
|
||||||
## Fase 18: Monetización y Estandarización (Nuevas Sugerencias)
|
## Fase 18: Monetización y Estandarización ✅
|
||||||
- [ ] **E-Commerce & Monetización**:
|
- [x] **E-Commerce & Monetización**: (Completado)
|
||||||
- [ ] Integración con Mercado Pago y Stripe.
|
- [x] Integración con Mercado Pago (Preferencia de pago y Webhooks).
|
||||||
- [ ] Sistema de precios por curso y organización.
|
- [x] Sistema de precios y moneda por curso.
|
||||||
- [ ] Dashboard de transacciones y conciliación.
|
- [x] Inscripción automática tras pago exitoso.
|
||||||
|
- [x] Verificación de seguridad de acceso a lecciones basada en inscripción.
|
||||||
|
- [x] Dashboard de transacciones básico en base de datos.
|
||||||
- [ ] **Interoperabilidad**:
|
- [ ] **Interoperabilidad**:
|
||||||
- [ ] Implementación de LTI 1.3 (Tool Provider).
|
- [ ] Implementación de LTI 1.3 (Tool Provider).
|
||||||
- [ ] Conectividad con LMS externos (Moodle/Canvas).
|
- [ ] Conectividad con LMS externos (Moodle/Canvas).
|
||||||
@@ -213,10 +215,9 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Estado Actual**: La plataforma cuenta con un motor de IA avanzado, gestión multi-tenant completa, tutoría inteligente con memoria histórica, una **interfaz 100% responsiva**, flujos de autenticación diferenciados, **sistema de foros de discusión funcional** y **gestión de anuncios del curso con notificaciones automáticas**.
|
**Estado Actual**: La plataforma cuenta con un motor de IA avanzado, gestión multi-tenant completa, tutoría inteligente con memoria histórica, una **interfaz 100% responsiva**, flujos de autenticación diferenciados, **sistema de foros de discusión funcional**, **gestión de anuncios** y **monetización integrada con Mercado Pago**.
|
||||||
|
|
||||||
**Próximas Prioridades**:
|
**Próximas Prioridades**:
|
||||||
1. **Monetización Core**: Preparación de infraestructura para pagos y precios.
|
1. **LTI 1.3 Research**: Bases para la interoperabilidad con LMS externos.
|
||||||
2. **LTI 1.3 Research**: Bases para la interoperabilidad.
|
|
||||||
3. **Course Wiki**: Espacio colaborativo para documentación de cursos.
|
3. **Course Wiki**: Espacio colaborativo para documentación de cursos.
|
||||||
4. **Student Notes**: Anotaciones personales exportables.
|
4. **Student Notes**: Anotaciones personales exportables.
|
||||||
|
|||||||
@@ -5,10 +5,7 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use common::auth::Claims;
|
use common::auth::Claims;
|
||||||
use common::middleware::Org;
|
use common::middleware::Org;
|
||||||
use common::models::{
|
use common::models::{DiscussionPost, DiscussionThread, PostWithAuthor, ThreadWithAuthor};
|
||||||
DiscussionThread, DiscussionPost,
|
|
||||||
ThreadWithAuthor, PostWithAuthor,
|
|
||||||
};
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -63,12 +60,12 @@ pub async fn list_threads(
|
|||||||
FROM discussion_threads t
|
FROM discussion_threads t
|
||||||
LEFT JOIN users u ON t.author_id = u.id
|
LEFT JOIN users u ON t.author_id = u.id
|
||||||
LEFT JOIN discussion_posts p ON t.id = p.thread_id
|
LEFT JOIN discussion_posts p ON t.id = p.thread_id
|
||||||
WHERE t.course_id = $1 AND t.organization_id = $2"
|
WHERE t.course_id = $1 AND t.organization_id = $2",
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut bind_count = 2;
|
let mut bind_count = 2;
|
||||||
|
|
||||||
if let Some(lesson_id) = params.lesson_id {
|
if let Some(_lesson_id) = params.lesson_id {
|
||||||
bind_count += 1;
|
bind_count += 1;
|
||||||
query.push_str(&format!(" AND t.lesson_id = ${}", bind_count));
|
query.push_str(&format!(" AND t.lesson_id = ${}", bind_count));
|
||||||
}
|
}
|
||||||
@@ -80,7 +77,9 @@ pub async fn list_threads(
|
|||||||
query.push_str(&format!(" AND t.author_id = ${}", bind_count));
|
query.push_str(&format!(" AND t.author_id = ${}", bind_count));
|
||||||
}
|
}
|
||||||
"unanswered" => {
|
"unanswered" => {
|
||||||
query.push_str(" AND NOT EXISTS (SELECT 1 FROM discussion_posts WHERE thread_id = t.id)");
|
query.push_str(
|
||||||
|
" AND NOT EXISTS (SELECT 1 FROM discussion_posts WHERE thread_id = t.id)",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
"resolved" => {
|
"resolved" => {
|
||||||
query.push_str(" AND EXISTS (SELECT 1 FROM discussion_posts WHERE thread_id = t.id AND is_endorsed = true)");
|
query.push_str(" AND EXISTS (SELECT 1 FROM discussion_posts WHERE thread_id = t.id AND is_endorsed = true)");
|
||||||
@@ -146,7 +145,7 @@ pub async fn create_thread(
|
|||||||
let _ = sqlx::query(
|
let _ = sqlx::query(
|
||||||
"INSERT INTO discussion_subscriptions (organization_id, thread_id, user_id)
|
"INSERT INTO discussion_subscriptions (organization_id, thread_id, user_id)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3)
|
||||||
ON CONFLICT DO NOTHING"
|
ON CONFLICT DO NOTHING",
|
||||||
)
|
)
|
||||||
.bind(org_ctx.id)
|
.bind(org_ctx.id)
|
||||||
.bind(thread.id)
|
.bind(thread.id)
|
||||||
@@ -181,7 +180,7 @@ pub async fn get_thread_detail(
|
|||||||
LEFT JOIN users u ON t.author_id = u.id
|
LEFT JOIN users u ON t.author_id = u.id
|
||||||
LEFT JOIN discussion_posts p ON t.id = p.thread_id
|
LEFT JOIN discussion_posts p ON t.id = p.thread_id
|
||||||
WHERE t.id = $1 AND t.organization_id = $2
|
WHERE t.id = $1 AND t.organization_id = $2
|
||||||
GROUP BY t.id, u.full_name, u.avatar_url"
|
GROUP BY t.id, u.full_name, u.avatar_url",
|
||||||
)
|
)
|
||||||
.bind(thread_id)
|
.bind(thread_id)
|
||||||
.bind(org_ctx.id)
|
.bind(org_ctx.id)
|
||||||
@@ -205,7 +204,13 @@ fn get_thread_posts_recursive<'a>(
|
|||||||
parent_id: Option<Uuid>,
|
parent_id: Option<Uuid>,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
org_id: Uuid,
|
org_id: Uuid,
|
||||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Vec<PostWithAuthor>, (StatusCode, String)>> + Send + 'a>> {
|
) -> std::pin::Pin<
|
||||||
|
Box<
|
||||||
|
dyn std::future::Future<Output = Result<Vec<PostWithAuthor>, (StatusCode, String)>>
|
||||||
|
+ Send
|
||||||
|
+ 'a,
|
||||||
|
>,
|
||||||
|
> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
let parent_filter = match parent_id {
|
let parent_filter = match parent_id {
|
||||||
Some(_) => "parent_post_id = $2",
|
Some(_) => "parent_post_id = $2",
|
||||||
@@ -226,8 +231,7 @@ fn get_thread_posts_recursive<'a>(
|
|||||||
parent_filter
|
parent_filter
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut sql_query = sqlx::query_as::<_, PostWithAuthor>(&query)
|
let mut sql_query = sqlx::query_as::<_, PostWithAuthor>(&query).bind(thread_id);
|
||||||
.bind(thread_id);
|
|
||||||
|
|
||||||
if let Some(pid) = parent_id {
|
if let Some(pid) = parent_id {
|
||||||
sql_query = sql_query.bind(pid);
|
sql_query = sql_query.bind(pid);
|
||||||
@@ -242,7 +246,8 @@ fn get_thread_posts_recursive<'a>(
|
|||||||
|
|
||||||
// Recursively fetch replies for each post
|
// Recursively fetch replies for each post
|
||||||
for post in &mut posts {
|
for post in &mut posts {
|
||||||
post.replies = get_thread_posts_recursive(pool, thread_id, Some(post.id), user_id, org_id).await?;
|
post.replies =
|
||||||
|
get_thread_posts_recursive(pool, thread_id, Some(post.id), user_id, org_id).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(posts)
|
Ok(posts)
|
||||||
@@ -263,7 +268,10 @@ pub async fn pin_thread(
|
|||||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?;
|
.map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?;
|
||||||
|
|
||||||
if user.0 != "instructor" && user.0 != "admin" {
|
if user.0 != "instructor" && user.0 != "admin" {
|
||||||
return Err((StatusCode::FORBIDDEN, "Only instructors can pin threads".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Only instructors can pin threads".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlx::query("UPDATE discussion_threads SET is_pinned = NOT is_pinned WHERE id = $1 AND organization_id = $2")
|
sqlx::query("UPDATE discussion_threads SET is_pinned = NOT is_pinned WHERE id = $1 AND organization_id = $2")
|
||||||
@@ -290,7 +298,10 @@ pub async fn lock_thread(
|
|||||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?;
|
.map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?;
|
||||||
|
|
||||||
if user.0 != "instructor" && user.0 != "admin" {
|
if user.0 != "instructor" && user.0 != "admin" {
|
||||||
return Err((StatusCode::FORBIDDEN, "Only instructors can lock threads".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Only instructors can lock threads".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlx::query("UPDATE discussion_threads SET is_locked = NOT is_locked WHERE id = $1 AND organization_id = $2")
|
sqlx::query("UPDATE discussion_threads SET is_locked = NOT is_locked WHERE id = $1 AND organization_id = $2")
|
||||||
@@ -313,7 +324,8 @@ pub async fn create_post(
|
|||||||
Json(payload): Json<CreatePostPayload>,
|
Json(payload): Json<CreatePostPayload>,
|
||||||
) -> Result<Json<DiscussionPost>, (StatusCode, String)> {
|
) -> Result<Json<DiscussionPost>, (StatusCode, String)> {
|
||||||
// Check if thread is locked
|
// Check if thread is locked
|
||||||
let thread = sqlx::query_as::<_, (bool,)>("SELECT is_locked FROM discussion_threads WHERE id = $1")
|
let thread =
|
||||||
|
sqlx::query_as::<_, (bool,)>("SELECT is_locked FROM discussion_threads WHERE id = $1")
|
||||||
.bind(thread_id)
|
.bind(thread_id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
@@ -356,7 +368,10 @@ pub async fn endorse_post(
|
|||||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?;
|
.map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?;
|
||||||
|
|
||||||
if user.0 != "instructor" && user.0 != "admin" {
|
if user.0 != "instructor" && user.0 != "admin" {
|
||||||
return Err((StatusCode::FORBIDDEN, "Only instructors can endorse posts".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Only instructors can endorse posts".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlx::query("UPDATE discussion_posts SET is_endorsed = NOT is_endorsed WHERE id = $1 AND organization_id = $2")
|
sqlx::query("UPDATE discussion_posts SET is_endorsed = NOT is_endorsed WHERE id = $1 AND organization_id = $2")
|
||||||
@@ -385,7 +400,7 @@ pub async fn vote_post(
|
|||||||
"INSERT INTO discussion_votes (organization_id, post_id, user_id, vote_type)
|
"INSERT INTO discussion_votes (organization_id, post_id, user_id, vote_type)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
ON CONFLICT (post_id, user_id)
|
ON CONFLICT (post_id, user_id)
|
||||||
DO UPDATE SET vote_type = EXCLUDED.vote_type"
|
DO UPDATE SET vote_type = EXCLUDED.vote_type",
|
||||||
)
|
)
|
||||||
.bind(org_ctx.id)
|
.bind(org_ctx.id)
|
||||||
.bind(post_id)
|
.bind(post_id)
|
||||||
@@ -397,7 +412,7 @@ pub async fn vote_post(
|
|||||||
|
|
||||||
// Recalculate upvotes
|
// Recalculate upvotes
|
||||||
let upvote_count: i64 = sqlx::query_scalar(
|
let upvote_count: i64 = sqlx::query_scalar(
|
||||||
"SELECT COUNT(*) FROM discussion_votes WHERE post_id = $1 AND vote_type = 'upvote'"
|
"SELECT COUNT(*) FROM discussion_votes WHERE post_id = $1 AND vote_type = 'upvote'",
|
||||||
)
|
)
|
||||||
.bind(post_id)
|
.bind(post_id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
@@ -425,7 +440,7 @@ pub async fn subscribe_thread(
|
|||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO discussion_subscriptions (organization_id, thread_id, user_id)
|
"INSERT INTO discussion_subscriptions (organization_id, thread_id, user_id)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3)
|
||||||
ON CONFLICT DO NOTHING"
|
ON CONFLICT DO NOTHING",
|
||||||
)
|
)
|
||||||
.bind(org_ctx.id)
|
.bind(org_ctx.id)
|
||||||
.bind(thread_id)
|
.bind(thread_id)
|
||||||
@@ -445,7 +460,7 @@ pub async fn unsubscribe_thread(
|
|||||||
) -> Result<StatusCode, (StatusCode, String)> {
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"DELETE FROM discussion_subscriptions
|
"DELETE FROM discussion_subscriptions
|
||||||
WHERE thread_id = $1 AND user_id = $2 AND organization_id = $3"
|
WHERE thread_id = $1 AND user_id = $2 AND organization_id = $3",
|
||||||
)
|
)
|
||||||
.bind(thread_id)
|
.bind(thread_id)
|
||||||
.bind(claims.sub)
|
.bind(claims.sub)
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
|
|||||||
if (!courseData) return <div className="text-center py-20 text-gray-500">Curso no encontrado.</div>;
|
if (!courseData) return <div className="text-center py-20 text-gray-500">Curso no encontrado.</div>;
|
||||||
|
|
||||||
const getStatusIcon = (lessonId: string, isGraded: boolean, allowRetry: boolean) => {
|
const getStatusIcon = (lessonId: string, isGraded: boolean, allowRetry: boolean) => {
|
||||||
const grade = userGrades.find(g => g.lesson_id === lessonId);
|
const grade = userGrades.find((g: UserGrade) => g.lesson_id === lessonId);
|
||||||
if (!grade) {
|
if (!grade) {
|
||||||
return <Circle size={18} className="text-white/20" />;
|
return <Circle size={18} className="text-white/20" />;
|
||||||
}
|
}
|
||||||
@@ -125,7 +125,9 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-4 mb-10">
|
<div className="flex flex-wrap items-center gap-4 mb-10">
|
||||||
<div className={`flex items-center gap-2 px-4 py-2 rounded-full border text-xs font-bold uppercase tracking-widest ${courseData.pacing_mode === 'instructor_led' ? 'bg-purple-500/10 border-purple-500/30 text-purple-400' : 'bg-blue-500/10 border-blue-500/30 text-blue-400'
|
<div className={`flex items-center gap-2 px-4 py-2 rounded-full border text-xs font-bold uppercase tracking-widest ${courseData.pacing_mode === 'instructor_led'
|
||||||
|
? 'bg-purple-500/10 border-purple-500/30 text-purple-400'
|
||||||
|
: 'bg-blue-500/10 border-blue-500/30 text-blue-400'
|
||||||
}`}>
|
}`}>
|
||||||
{courseData.pacing_mode === 'instructor_led' ? <Clock size={14} /> : <Info size={14} />}
|
{courseData.pacing_mode === 'instructor_led' ? <Clock size={14} /> : <Info size={14} />}
|
||||||
{courseData.pacing_mode === 'instructor_led' ? 'Dirigido por un Instructor' : 'A tu Ritmo'}
|
{courseData.pacing_mode === 'instructor_led' ? 'Dirigido por un Instructor' : 'A tu Ritmo'}
|
||||||
@@ -210,7 +212,7 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
|
|||||||
<div className="h-3 w-2/3 bg-white/10 rounded"></div>
|
<div className="h-3 w-2/3 bg-white/10 rounded"></div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
recommendations.map((rec, i) => (
|
recommendations.map((rec: Recommendation, i: number) => (
|
||||||
<div key={i} className="glass-card border-white/5 hover:border-purple-500/30 transition-all p-6 group">
|
<div key={i} className="glass-card border-white/5 hover:border-purple-500/30 transition-all p-6 group">
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -251,7 +253,7 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-12">
|
<div className="space-y-12">
|
||||||
{courseData.modules.map((module, idx) => (
|
{courseData.modules.map((module: Module, idx: number) => (
|
||||||
<div key={module.id} className="relative">
|
<div key={module.id} className="relative">
|
||||||
<div className="flex items-center gap-4 mb-6">
|
<div className="flex items-center gap-4 mb-6">
|
||||||
<div className="w-10 h-10 rounded-xl glass border-blue-500/20 bg-blue-500/10 flex items-center justify-center">
|
<div className="w-10 h-10 rounded-xl glass border-blue-500/20 bg-blue-500/10 flex items-center justify-center">
|
||||||
@@ -261,7 +263,7 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 pl-14">
|
<div className="grid gap-3 pl-14">
|
||||||
{module.lessons.map((lesson) => (
|
{module.lessons.map((lesson: any) => (
|
||||||
isEnrolled ? (
|
isEnrolled ? (
|
||||||
<Link key={lesson.id} href={`/courses/${params.id}/lessons/${lesson.id}`}>
|
<Link key={lesson.id} href={`/courses/${params.id}/lessons/${lesson.id}`}>
|
||||||
<div className="glass-card !p-4 group hover:bg-white/10 border-white/5 active:scale-[0.99] transition-all">
|
<div className="glass-card !p-4 group hover:bg-white/10 border-white/5 active:scale-[0.99] transition-all">
|
||||||
@@ -328,6 +330,6 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
|
|||||||
<div className="mt-20">
|
<div className="mt-20">
|
||||||
<DiscussionBoard courseId={params.id} />
|
<DiscussionBoard courseId={params.id} />
|
||||||
</div>
|
</div>
|
||||||
</div >
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+13
-1
@@ -593,6 +593,7 @@
|
|||||||
"version": "18.3.27",
|
"version": "18.3.27",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
||||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -660,6 +661,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.0.tgz",
|
||||||
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
|
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.50.0",
|
"@typescript-eslint/scope-manager": "8.50.0",
|
||||||
"@typescript-eslint/types": "8.50.0",
|
"@typescript-eslint/types": "8.50.0",
|
||||||
@@ -1133,6 +1135,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -2165,6 +2168,7 @@
|
|||||||
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
|
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
|
||||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.6.1",
|
"@eslint-community/regexpp": "^4.6.1",
|
||||||
@@ -2327,6 +2331,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
|
||||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.9",
|
"array-includes": "^3.1.9",
|
||||||
@@ -3738,6 +3743,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
@@ -5073,6 +5079,7 @@
|
|||||||
"url": "https://github.com/sponsors/ai"
|
"url": "https://github.com/sponsors/ai"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -5278,6 +5285,7 @@
|
|||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
},
|
},
|
||||||
@@ -5289,6 +5297,7 @@
|
|||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"loose-envify": "^1.1.0",
|
||||||
"scheduler": "^0.23.2"
|
"scheduler": "^0.23.2"
|
||||||
@@ -5375,7 +5384,8 @@
|
|||||||
"node_modules/redux": {
|
"node_modules/redux": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/reflect.getprototypeof": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
@@ -6262,6 +6272,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -6439,6 +6450,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|||||||
Reference in New Issue
Block a user