feat: Implement course monetization with Mercado Pago, update roadmap status, and refine discussion service handlers.

This commit is contained in:
2026-02-15 13:54:01 -03:00
parent 34e72ae985
commit 4eb7ade407
5 changed files with 72 additions and 40 deletions
+2
View File
@@ -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
View File
@@ -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)
+8 -6
View File
@@ -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>
); );
} }
+13 -1
View File
@@ -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"