From 84e62ee896acd6119ea845daa94d966bd32798c7 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Huu <2001230909@hufi.edu.vn> Date: Thu, 2 Jul 2026 14:25:59 +0700 Subject: [PATCH 01/14] feat: add product expansion suite --- Makefile | 2 +- admin-service/package-lock.json | 230 +- admin-service/src/App.tsx | 9 +- admin-service/src/lib/adminApi.ts | 25 +- admin-service/src/lib/aiQualityApi.test.ts | 37 + admin-service/src/lib/aiQualityApi.ts | 58 + admin-service/src/lib/contentAgentApi.test.ts | 34 + admin-service/src/lib/contentAgentApi.ts | 27 + admin-service/src/lib/i18n/en.ts | 9 + admin-service/src/lib/i18n/vi.ts | 9 + .../src/pages/AiQualityDashboardPage.tsx | 166 ++ .../src/pages/ContentQaQueuePage.tsx | 272 +++ admin-service/src/pages/ShopPage.tsx | 193 +- ai-service/api/core/groq_key_pool.py | 32 +- ai-service/api/routes/content_agent.py | 6 +- ai-service/api/routes/lexi_chat.py | 946 +------- .../api/services/embedding_service_v3.py | 40 +- ai-service/api/services/gemini_compat.py | 18 + .../api/services/handlers/gemini_handler.py | 100 +- ai-service/api/services/lexi_chat_service.py | 879 +++++++ ai-service/api/services/stt/metrics.py | 32 +- ai-service/api/services/stt/model_registry.py | 2 + .../api/services/stt/sentence_finalizer.py | 14 +- .../api/services/stt/transcript_normalizer.py | 74 +- ai-service/api/services/stt/voice_session.py | 68 +- ai-service/api/services/topic_llm_gateway.py | 113 +- .../services/trace_cag/benchmark/adaptive.py | 2 +- .../trace_cag/benchmark/qa_generation.py | 500 +++- .../services/trace_cag/benchmark/ranking.py | 81 +- .../api/services/trace_cag/cache_utils.py | 31 +- ai-service/api/services/trace_cag/generate.py | 660 +++++ .../api/services/trace_cag/l1_state_cache.py | 9 +- ai-service/api/services/trace_cag/nodes_v2.py | 1219 +--------- ai-service/api/services/trace_cag/retrieve.py | 502 ++++ ai-service/api/services/trace_cag/state.py | 3 +- ai-service/api/utils/languages.py | 19 + ai-service/pytest.ini | 5 + ai-service/service/__init__.py | 1 + .../service/tracecag_service/__init__.py | 17 + .../tracecag_service/adapters/__init__.py | 13 + .../tracecag_service/adapters/lexilingo.py | 60 + .../tracecag_service/adapters/memory.py | 187 ++ ai-service/service/tracecag_service/config.py | 69 + .../service/tracecag_service/core/__init__.py | 25 + .../tracecag_service/core/fingerprint.py | 215 ++ .../service/tracecag_service/core/scar_l1.py | 133 + ai-service/service/tracecag_service/ports.py | 25 + .../service/tracecag_service/runtime.py | 137 ++ .../service/tracecag_service/schemas.py | 166 ++ .../tests/benchmark/test_runtime_protocols.py | 43 +- .../test_tracecag_lexilingo_adapter.py | 63 + .../service/test_tracecag_memory_adapter.py | 88 + .../service/test_tracecag_service_runtime.py | 73 + ai-service/tests/stt/test_normalizer.py | 125 + ai-service/tests/stt/test_pipeline.py | 83 + ai-service/tests/stt/test_session_manager.py | 19 + ai-service/tests/stt/test_voice_session.py | 92 + .../tests/test_dependency_constraints.py | 6 +- .../tests/test_history_restore_endpoints.py | 40 +- ai-service/tests/test_lexi_chat_routes.py | 86 +- .../tests/test_lexi_session_management.py | 9 +- .../tests/test_tracecag_chat_integration.py | 132 +- ai-service/tests/test_translate_routes.py | 24 +- .../tests/trace_cag/test_benchmark_ircot.py | 129 + .../test_benchmark_model_strength.py | 38 + .../tests/trace_cag/test_cache_gate_l1.py | 112 +- .../test_l1_drift_probe_decisions.py | 2 +- .../tests/trace_cag/test_l1_state_cache.py | 4 +- .../trace_cag/test_native_language_hint.py | 30 + backend-service/.gitignore | 4 + .../versions/add_game_powerup_shop_items.py | 149 ++ .../backfill_is_verified_legacy_users.py | 46 + backend-service/app/check_missing.py | 54 - backend-service/app/core/config.py | 96 +- backend-service/app/core/exceptions.py | 2 +- .../app/core/sample_data_catalog.py | 468 ++++ backend-service/app/core/shop_catalog.py | 93 + backend-service/app/crud/course.py | 14 +- backend-service/app/crud/vocabulary.py | 30 +- backend-service/app/main.py | 8 +- backend-service/app/models/content.py | 35 +- backend-service/app/routes/admin.py | 2152 ----------------- .../app/routes/admin_gamification.py | 70 +- backend-service/app/routes/admin_system.py | 588 +---- backend-service/app/routes/ai_audit.py | 196 +- backend-service/app/routes/auth.py | 50 +- backend-service/app/routes/books.py | 218 +- backend-service/app/routes/game_scoring.py | 3 + backend-service/app/routes/games.py | 7 + backend-service/app/routes/podcasts.py | 132 +- backend-service/app/routes/vocabulary.py | 19 +- backend-service/app/routes/youtube.py | 8 + backend-service/app/schemas/content.py | 11 +- backend-service/app/schemas/course.py | 52 +- .../app/schemas/course_category.py | 8 +- backend-service/app/schemas/devices.py | 5 +- backend-service/app/schemas/gamification.py | 89 +- backend-service/app/schemas/proficiency.py | 5 +- backend-service/app/schemas/progress.py | 42 +- backend-service/app/schemas/response.py | 9 +- backend-service/app/schemas/user.py | 8 +- backend-service/app/schemas/vocabulary.py | 17 +- backend-service/app/services/__init__.py | 474 +--- .../services/achievement_checker_service.py | 456 ++++ .../app/services/admin_seed_service.py | 121 + .../app/services/api_cache_service.py | 89 +- .../services/content_agent_artifact_index.py | 250 ++ .../app/services/content_agent_validation.py | 829 +++---- .../app/services/item_effects_service.py | 82 +- .../notification_campaign/segmenter.py | 8 +- .../app/services/proficiency_service.py | 300 +-- backend-service/app/services/quota_manager.py | 61 +- .../app/services/reminder_service.py | 2 +- .../app/services/streak_service.py | 8 +- .../app/services/user_stats_service.py | 4 +- backend-service/app/services/xp_service.py | 5 +- backend-service/app/tasks/content_prefetch.py | 1 + backend-service/app/test_vocab_definitions.py | 18 - backend-service/requirements.txt | 3 +- backend-service/tests/conftest.py | 211 +- .../tests/crud/test_course_crud_queries.py | 19 + .../tests/test_achievement_system.py | 4 +- .../tests/test_admin_rbac_security.py | 2 +- backend-service/tests/test_admin_routes.py | 73 +- backend-service/tests/test_ai_audit_routes.py | 60 +- .../tests/test_api_cache_service.py | 57 + backend-service/tests/test_auth_routes.py | 658 ++++- backend-service/tests/test_books_routes.py | 55 +- .../tests/test_content_agent_validation.py | 57 + .../tests/test_course_categories_routes.py | 6 +- backend-service/tests/test_courses_routes.py | 4 +- backend-service/tests/test_devices_routes.py | 8 +- backend-service/tests/test_games_routes.py | 6 + .../tests/test_item_effects_game_powerups.py | 79 + .../tests/test_item_effects_service.py | 125 +- backend-service/tests/test_learning_routes.py | 6 +- backend-service/tests/test_podcasts_routes.py | 102 +- .../tests/test_proficiency_api_integration.py | 80 +- .../tests/test_proficiency_functional.py | 50 +- backend-service/tests/test_quota_manager.py | 89 + .../tests/test_schema_pydantic_v2_debt.py | 28 + backend-service/tests/test_shop_catalog.py | 25 +- backend-service/tests/test_streak_service.py | 42 + backend-service/tests/test_users_routes.py | 6 +- .../tests/test_vocabulary_routes.py | 23 +- backend-service/tests/test_xp_service.py | 74 + backend-service/tests/test_youtube_routes.py | 12 +- flutter-app/assets/i18n/en.json | 219 +- flutter-app/assets/i18n/vi.json | 219 +- .../lib/core/network/response_models.dart | 1 + .../background_sync_queue_service.dart | 60 + .../core/services/local_cache_service.dart | 25 + flutter-app/lib/core/widgets/game_icon.dart | 60 +- .../widgets/language_switcher_button.dart | 194 +- .../lib/core/widgets/premium_gate.dart | 16 +- .../features/auth/data/models/user_model.dart | 4 + .../repositories/auth_repository_impl.dart | 1 + .../auth/domain/entities/user_entity.dart | 4 + .../auth/presentation/pages/login_page.dart | 33 +- .../data/repositories/book_repository.dart | 24 +- .../presentation/providers/book_provider.dart | 76 +- .../data/models/educational_hints_model.dart | 44 + .../chat/data/models/story_model.dart | 123 +- .../chat/data/models/topic_session_model.dart | 48 + .../repositories/story_repository_impl.dart | 33 +- .../domain/entities/educational_hints.dart | 67 + .../features/chat/domain/entities/story.dart | 215 ++ .../chat/domain/entities/topic_session.dart | 131 + .../domain/repositories/story_repository.dart | 4 +- .../pages/story_selection_page.dart | 2 +- .../presentation/pages/topic_chat_page.dart | 4 +- .../providers/story_provider.dart | 18 +- .../utils/topic_icon_resolver.dart | 2 +- .../widgets/educational_hints_widgets.dart | 2 +- .../chat/presentation/widgets/topic_card.dart | 2 +- .../games/domain/entities/game_entities.dart | 21 + .../helpers/game_mistake_recorder.dart | 119 + .../screens/fill_blank_screen.dart | 590 +++-- .../screens/games_hub_screen.dart | 98 +- .../screens/grammar_quiz_screen.dart | 566 +++-- .../presentation/screens/hangman_screen.dart | 127 +- .../screens/matching_game_screen.dart | 86 +- .../screens/spelling_bee_screen.dart | 80 +- .../screens/word_scramble_screen.dart | 109 +- .../widgets/daily_challenge_card.dart | 4 +- .../widgets/daily_challenge_mapper.dart | 45 + .../widgets/game_powerup_tray.dart | 208 ++ .../gamification_remote_data_source.dart | 13 +- .../gamification_repository_impl.dart | 3 +- .../domain/entities/shop_item.dart | 27 + .../gamification/domain/entities/wallet.dart | 18 +- .../repositories/gamification_repository.dart | 4 +- .../providers/gamification_provider.dart | 24 +- .../screens/league_ceremony_screen.dart | 416 +++- .../presentation/widgets/shop_item_card.dart | 9 +- .../home/presentation/pages/home_page.dart | 1988 +-------------- .../presentation/pages/today_plan_page.dart | 234 ++ .../home_page/course_level_helpers.dart | 37 + .../home_page/enrolled_courses_section.dart | 297 +++ .../home_page/featured_courses_section.dart | 493 ++++ .../widgets/home_page/home_header.dart | 43 + .../home_page/home_skeleton_loading.dart | 72 + .../home_page/level_and_daily_goal_row.dart | 604 +++++ .../widgets/home_page/quick_actions_grid.dart | 223 ++ .../widgets/home_page/section_title.dart | 24 + .../home_page/streak_card_section.dart | 40 + .../widgets/home_page/today_plan_models.dart | 272 +++ .../home_page/today_plan_navigation.dart | 34 + .../widgets/home_page/today_plan_section.dart | 355 +++ .../datasources/proficiency_data_source.dart | 16 +- .../lib/features/level/di/level_di.dart | 9 +- .../pages/placement_test_page.dart | 760 ++++++ .../widgets/proficiency_card.dart | 37 +- .../datasources/lexi_chat_data_source.dart | 4 + .../lexi_chat_repository_impl.dart | 4 + .../repositories/lexi_chat_repository.dart | 2 + .../presentation/pages/lexi_chat_page.dart | 12 + .../providers/lexi_chat_provider.dart | 24 +- .../data/mistake_notebook_repository.dart | 96 + .../domain/mistake_notebook_entry.dart | 115 + .../pages/mistake_notebook_page.dart | 619 +++++ .../presentation/providers/news_provider.dart | 80 +- .../screens/news_quiz_screen.dart | 10 +- .../pages/offline_sync_center_page.dart | 576 +++++ .../data/repositories/podcast_repository.dart | 28 + .../screens/podcast_player_screen.dart | 13 +- .../presentation/pages/practice_lab_page.dart | 557 +++++ .../widgets/practice_lab_models.dart | 152 ++ .../widgets/practice_lab_navigation.dart | 31 + .../presentation/pages/profile_page.dart | 2021 +--------------- .../profile_page/admin_panel_tile.dart | 87 + .../profile_page/learning_stats_section.dart | 225 ++ .../profile_page/level_progress_card.dart | 94 + .../widgets/profile_page/profile_header.dart | 543 +++++ .../profile_page/quick_actions_row.dart | 131 + .../profile_page/recent_badges_section.dart | 275 +++ .../profile_page/social_stats_row.dart | 56 + .../profile_page/weekly_activity_section.dart | 710 ++++++ .../widgets/daily_challenges_widget.dart | 355 ++- .../presentation/pages/settings_page.dart | 199 +- .../vocabulary_remote_datasource.dart | 55 +- .../domain/entities/quiz_question_entity.dart | 37 + .../pages/vocab_library_page.dart | 83 + .../presentation/providers/quiz_provider.dart | 486 ++++ .../screens/quiz_review_screen.dart | 367 +++ .../widgets/daily_review_card.dart | 147 +- .../widgets/quiz_option_card.dart | 123 + .../widgets/word_of_day_card.dart | 6 +- .../features/vocabulary/vocabulary_di.dart | 9 + flutter-app/lib/main.dart | 16 + .../test/core/widgets/cefr_badge_test.dart | 14 +- .../auth/data/models/user_model_test.dart | 7 + .../chat/quick_save_chat_widgets_test.dart | 2 +- .../helpers/game_mistake_recorder_test.dart | 119 + .../screens/game_accessibility_test.dart | 174 +- .../screens/game_completion_test.dart | 191 +- .../screens/game_load_state_test.dart | 229 +- .../widgets/daily_challenge_mapper_test.dart | 60 + .../widgets/game_powerup_tray_test.dart | 270 +++ ...ification_provider_active_boosts_test.dart | 2 +- .../gamification_provider_powerups_test.dart | 167 ++ ...fication_provider_starter_reward_test.dart | 2 +- .../features/gamification/shop_item_test.dart | 18 + .../gamification/wallet_entity_test.dart | 34 + .../features/home/today_plan_models_test.dart | 115 + .../level/placement_test_payload_test.dart | 27 + .../providers/lexi_chat_provider_test.dart | 39 + .../mistake_notebook_repository_test.dart | 93 + .../news_provider_mistakes_test.dart | 62 + .../offline/offline_sync_snapshot_test.dart | 31 + .../practice/practice_lab_models_test.dart | 89 + .../streak_provider_milestone_test.dart | 70 +- .../vocabulary_remote_datasource_test.dart | 51 + .../providers/quiz_provider_test.dart | 257 ++ .../vocab_and_lexi_offline_smoke_test.dart | 7 +- 275 files changed, 26144 insertions(+), 12102 deletions(-) create mode 100644 admin-service/src/lib/aiQualityApi.test.ts create mode 100644 admin-service/src/lib/aiQualityApi.ts create mode 100644 admin-service/src/pages/AiQualityDashboardPage.tsx create mode 100644 admin-service/src/pages/ContentQaQueuePage.tsx create mode 100644 ai-service/api/services/gemini_compat.py create mode 100644 ai-service/api/services/lexi_chat_service.py create mode 100644 ai-service/api/services/trace_cag/generate.py create mode 100644 ai-service/api/services/trace_cag/retrieve.py create mode 100644 ai-service/api/utils/languages.py create mode 100644 ai-service/pytest.ini create mode 100644 ai-service/service/__init__.py create mode 100644 ai-service/service/tracecag_service/__init__.py create mode 100644 ai-service/service/tracecag_service/adapters/__init__.py create mode 100644 ai-service/service/tracecag_service/adapters/lexilingo.py create mode 100644 ai-service/service/tracecag_service/adapters/memory.py create mode 100644 ai-service/service/tracecag_service/config.py create mode 100644 ai-service/service/tracecag_service/core/__init__.py create mode 100644 ai-service/service/tracecag_service/core/fingerprint.py create mode 100644 ai-service/service/tracecag_service/core/scar_l1.py create mode 100644 ai-service/service/tracecag_service/ports.py create mode 100644 ai-service/service/tracecag_service/runtime.py create mode 100644 ai-service/service/tracecag_service/schemas.py create mode 100644 ai-service/tests/service/test_tracecag_lexilingo_adapter.py create mode 100644 ai-service/tests/service/test_tracecag_memory_adapter.py create mode 100644 ai-service/tests/service/test_tracecag_service_runtime.py create mode 100644 ai-service/tests/stt/test_normalizer.py create mode 100644 ai-service/tests/trace_cag/test_benchmark_ircot.py create mode 100644 ai-service/tests/trace_cag/test_benchmark_model_strength.py create mode 100644 ai-service/tests/trace_cag/test_native_language_hint.py create mode 100644 backend-service/alembic/versions/add_game_powerup_shop_items.py create mode 100644 backend-service/alembic/versions/backfill_is_verified_legacy_users.py delete mode 100644 backend-service/app/check_missing.py create mode 100644 backend-service/app/core/sample_data_catalog.py delete mode 100644 backend-service/app/routes/admin.py create mode 100644 backend-service/app/services/achievement_checker_service.py create mode 100644 backend-service/app/services/admin_seed_service.py create mode 100644 backend-service/app/services/content_agent_artifact_index.py delete mode 100644 backend-service/app/test_vocab_definitions.py create mode 100644 backend-service/tests/crud/test_course_crud_queries.py create mode 100644 backend-service/tests/test_api_cache_service.py create mode 100644 backend-service/tests/test_item_effects_game_powerups.py create mode 100644 backend-service/tests/test_quota_manager.py create mode 100644 backend-service/tests/test_schema_pydantic_v2_debt.py create mode 100644 backend-service/tests/test_streak_service.py create mode 100644 flutter-app/lib/features/chat/domain/entities/educational_hints.dart create mode 100644 flutter-app/lib/features/chat/domain/entities/story.dart create mode 100644 flutter-app/lib/features/chat/domain/entities/topic_session.dart create mode 100644 flutter-app/lib/features/games/presentation/helpers/game_mistake_recorder.dart create mode 100644 flutter-app/lib/features/games/presentation/widgets/daily_challenge_mapper.dart create mode 100644 flutter-app/lib/features/games/presentation/widgets/game_powerup_tray.dart create mode 100644 flutter-app/lib/features/home/presentation/pages/today_plan_page.dart create mode 100644 flutter-app/lib/features/home/presentation/widgets/home_page/course_level_helpers.dart create mode 100644 flutter-app/lib/features/home/presentation/widgets/home_page/enrolled_courses_section.dart create mode 100644 flutter-app/lib/features/home/presentation/widgets/home_page/featured_courses_section.dart create mode 100644 flutter-app/lib/features/home/presentation/widgets/home_page/home_header.dart create mode 100644 flutter-app/lib/features/home/presentation/widgets/home_page/home_skeleton_loading.dart create mode 100644 flutter-app/lib/features/home/presentation/widgets/home_page/level_and_daily_goal_row.dart create mode 100644 flutter-app/lib/features/home/presentation/widgets/home_page/quick_actions_grid.dart create mode 100644 flutter-app/lib/features/home/presentation/widgets/home_page/section_title.dart create mode 100644 flutter-app/lib/features/home/presentation/widgets/home_page/streak_card_section.dart create mode 100644 flutter-app/lib/features/home/presentation/widgets/home_page/today_plan_models.dart create mode 100644 flutter-app/lib/features/home/presentation/widgets/home_page/today_plan_navigation.dart create mode 100644 flutter-app/lib/features/home/presentation/widgets/home_page/today_plan_section.dart create mode 100644 flutter-app/lib/features/level/presentation/pages/placement_test_page.dart create mode 100644 flutter-app/lib/features/mistakes/data/mistake_notebook_repository.dart create mode 100644 flutter-app/lib/features/mistakes/domain/mistake_notebook_entry.dart create mode 100644 flutter-app/lib/features/mistakes/presentation/pages/mistake_notebook_page.dart create mode 100644 flutter-app/lib/features/offline/presentation/pages/offline_sync_center_page.dart create mode 100644 flutter-app/lib/features/practice/presentation/pages/practice_lab_page.dart create mode 100644 flutter-app/lib/features/practice/presentation/widgets/practice_lab_models.dart create mode 100644 flutter-app/lib/features/practice/presentation/widgets/practice_lab_navigation.dart create mode 100644 flutter-app/lib/features/profile/presentation/widgets/profile_page/admin_panel_tile.dart create mode 100644 flutter-app/lib/features/profile/presentation/widgets/profile_page/learning_stats_section.dart create mode 100644 flutter-app/lib/features/profile/presentation/widgets/profile_page/level_progress_card.dart create mode 100644 flutter-app/lib/features/profile/presentation/widgets/profile_page/profile_header.dart create mode 100644 flutter-app/lib/features/profile/presentation/widgets/profile_page/quick_actions_row.dart create mode 100644 flutter-app/lib/features/profile/presentation/widgets/profile_page/recent_badges_section.dart create mode 100644 flutter-app/lib/features/profile/presentation/widgets/profile_page/social_stats_row.dart create mode 100644 flutter-app/lib/features/profile/presentation/widgets/profile_page/weekly_activity_section.dart create mode 100644 flutter-app/lib/features/vocabulary/domain/entities/quiz_question_entity.dart create mode 100644 flutter-app/lib/features/vocabulary/presentation/providers/quiz_provider.dart create mode 100644 flutter-app/lib/features/vocabulary/presentation/screens/quiz_review_screen.dart create mode 100644 flutter-app/lib/features/vocabulary/presentation/widgets/quiz_option_card.dart create mode 100644 flutter-app/test/features/games/presentation/helpers/game_mistake_recorder_test.dart create mode 100644 flutter-app/test/features/games/presentation/widgets/daily_challenge_mapper_test.dart create mode 100644 flutter-app/test/features/games/presentation/widgets/game_powerup_tray_test.dart create mode 100644 flutter-app/test/features/gamification/gamification_provider_powerups_test.dart create mode 100644 flutter-app/test/features/gamification/wallet_entity_test.dart create mode 100644 flutter-app/test/features/home/today_plan_models_test.dart create mode 100644 flutter-app/test/features/level/placement_test_payload_test.dart create mode 100644 flutter-app/test/features/mistakes/mistake_notebook_repository_test.dart create mode 100644 flutter-app/test/features/news/presentation/providers/news_provider_mistakes_test.dart create mode 100644 flutter-app/test/features/offline/offline_sync_snapshot_test.dart create mode 100644 flutter-app/test/features/practice/practice_lab_models_test.dart create mode 100644 flutter-app/test/features/vocabulary/presentation/providers/quiz_provider_test.dart diff --git a/Makefile b/Makefile index 863ef5d0..a163b45c 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .DEFAULT_GOAL := help -APP_DIR := lexilingo_app +APP_DIR := flutter-app FLUTTER ?= flutter DART ?= dart diff --git a/admin-service/package-lock.json b/admin-service/package-lock.json index 2e8464cf..9ac11491 100644 --- a/admin-service/package-lock.json +++ b/admin-service/package-lock.json @@ -17,19 +17,19 @@ "dayjs": "^1.11.21", "dotenv": "^17.4.2", "gsap": "^3.15.0", - "lucide-react": "^1.18.0", + "lucide-react": "^1.21.0", "react": "^19.2.7", "react-dom": "^19.2.7", - "react-hook-form": "^7.79.0", - "react-router-dom": "^7.17.0", + "react-hook-form": "^7.80.0", + "react-router-dom": "^7.18.0", "recharts": "^3.8.1", "three": "^0.184.0", - "vercel": "^54.14.0", + "vercel": "^54.14.5", "zod": "^4.4.3", "zustand": "^5.0.14" }, "devDependencies": { - "@types/node": "^25.9.3", + "@types/node": "^25.9.4", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@types/three": "^0.184.1", @@ -2628,9 +2628,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.9.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", - "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", + "version": "25.9.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.4.tgz", + "integrity": "sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==", "dev": true, "license": "MIT", "dependencies": { @@ -2734,12 +2734,12 @@ } }, "node_modules/@vercel/backends": { - "version": "0.8.14", - "resolved": "https://registry.npmjs.org/@vercel/backends/-/backends-0.8.14.tgz", - "integrity": "sha512-jgepdZh7E4ameCUSt/b28ImFd9Bz1VX71lKwD/ThtZ7nGIwi5SJ9hwyURvH60c4rNW6nKxMD/iSOlcgQOfrK3A==", + "version": "0.8.18", + "resolved": "https://registry.npmjs.org/@vercel/backends/-/backends-0.8.18.tgz", + "integrity": "sha512-HMUwm923y8AxXKp1j1VqlWFmpN0gubeU4UDgskDjDeU7kHwYEfaZT9c2iOUAqhwmnrpp0mAEAM9d8PjtFJ4O/w==", "license": "Apache-2.0", "dependencies": { - "@vercel/build-utils": "13.30.0", + "@vercel/build-utils": "13.32.1", "@vercel/nft": "1.10.0", "execa": "3.2.0", "fs-extra": "11.1.0", @@ -2748,7 +2748,7 @@ "path-to-regexp": "8.3.0", "resolve.exports": "2.0.3", "rolldown": "1.0.0-rc.1", - "srvx": "0.8.9", + "srvx": "0.11.16", "tsx": "4.21.0", "zod": "3.22.4" } @@ -2779,9 +2779,9 @@ } }, "node_modules/@vercel/build-utils": { - "version": "13.30.0", - "resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-13.30.0.tgz", - "integrity": "sha512-fLIa8cpELsSoWbcxshaqegwlTCfKnbgsDmA6uIb+oA797xvSmYkPu5a781aRYCRdghgyOZF7NCSVgtZjHjunnQ==", + "version": "13.32.1", + "resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-13.32.1.tgz", + "integrity": "sha512-r8H6DZ1NLi2i4mY7YGOeb5b8mtGPaTtYe5ezYbLoV7nrjCSRMqHKKsAWOjPYDRKZ2Vf48OQB0OZ4lj9Cc5Vwhw==", "license": "Apache-2.0", "dependencies": { "@vercel/python-analysis": "0.11.1", @@ -2790,12 +2790,12 @@ } }, "node_modules/@vercel/cervel": { - "version": "0.1.22", - "resolved": "https://registry.npmjs.org/@vercel/cervel/-/cervel-0.1.22.tgz", - "integrity": "sha512-YbL2AosH3FaBLJZu4GYdVMkFv+5cbWwwSrRBTGN52odynQUudTUQfbc7rCzPyNQLdk2QC4w2IDYRD+nqgojhVg==", + "version": "0.1.26", + "resolved": "https://registry.npmjs.org/@vercel/cervel/-/cervel-0.1.26.tgz", + "integrity": "sha512-n2qeyZs96XbnBfKDsQ/8QPr12Mb8ZFMcxdQJFEvciWK9+nygrWKEEuC6akH3tK3Pw7IubQY07Yn3rMUXfhdLxA==", "license": "Apache-2.0", "dependencies": { - "@vercel/backends": "0.8.14" + "@vercel/backends": "0.8.18" }, "bin": { "cervel": "bin/cervel.mjs" @@ -2850,6 +2850,12 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/@vercel/container": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@vercel/container/-/container-0.0.2.tgz", + "integrity": "sha512-t7vIpTRejh7zoxxcqa9lNSwlzYqB/mOYo3cQjifnBvBPyrFI3OXdW9WCFl+y3hgOAY6cIUnNFeD3OHzAZf3cwA==", + "license": "Apache-2.0" + }, "node_modules/@vercel/detect-agent": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@vercel/detect-agent/-/detect-agent-1.2.3.tgz", @@ -2860,12 +2866,12 @@ } }, "node_modules/@vercel/elysia": { - "version": "0.1.93", - "resolved": "https://registry.npmjs.org/@vercel/elysia/-/elysia-0.1.93.tgz", - "integrity": "sha512-36yzZJVU9o7/CcH1TbqeGTRwBePDaCEzv1CYfS183HdLXzsD+pOjOkTqZOBJu7h9aHpJFCCau55Ed0xOQC2jhA==", + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@vercel/elysia/-/elysia-0.1.97.tgz", + "integrity": "sha512-ShEPurEH8Q+htrCBj0b8okmX5x92F8VosIKhHRpz51jb/TW5iUblgBhHkbJKMrSTXeUvV6KvzPr5nY2HrWgCAg==", "license": "Apache-2.0", "dependencies": { - "@vercel/node": "5.8.17", + "@vercel/node": "5.8.21", "@vercel/static-config": "3.4.0" } }, @@ -2876,14 +2882,14 @@ "license": "Apache-2.0" }, "node_modules/@vercel/express": { - "version": "0.1.105", - "resolved": "https://registry.npmjs.org/@vercel/express/-/express-0.1.105.tgz", - "integrity": "sha512-F+QRQxWWKdeztW4MKPkzXl/Ajc8j1qOffCp1tt8+YPaNiqZtxpn7FjIYRXHbfR1aepfaEkDRDD7o2qxmmaUU5w==", + "version": "0.1.109", + "resolved": "https://registry.npmjs.org/@vercel/express/-/express-0.1.109.tgz", + "integrity": "sha512-X/NMmEPoRjgcy8jqc+Op4L/r8g2wTKB/X7aNWJ9+/3aGIHlnwVU7Fp2R+tQUH2/yW0eGyGtwZWX81M1uZK3phg==", "license": "Apache-2.0", "dependencies": { - "@vercel/cervel": "0.1.22", + "@vercel/cervel": "0.1.26", "@vercel/nft": "1.10.0", - "@vercel/node": "5.8.17", + "@vercel/node": "5.8.21", "@vercel/static-config": "3.4.0", "fs-extra": "11.1.0", "path-to-regexp": "8.3.0", @@ -2901,12 +2907,12 @@ } }, "node_modules/@vercel/fastify": { - "version": "0.1.96", - "resolved": "https://registry.npmjs.org/@vercel/fastify/-/fastify-0.1.96.tgz", - "integrity": "sha512-4Uq6GVmi0ksQxIHp4FDUhgoz47uacxZm42XT04SK5gzX5v5UmZXWZPprZjkwwk/2gmP/rF2qKqJ0nu6OsI+yDw==", + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@vercel/fastify/-/fastify-0.1.100.tgz", + "integrity": "sha512-X2aTOnb630pi7e7a6pA4UtMbXaFXhuEwWfKMBtYmJEAwI2SxLG0DhL5byYIpd6/KENYH5M+PvsBJQOxMdGR3cg==", "license": "Apache-2.0", "dependencies": { - "@vercel/node": "5.8.17", + "@vercel/node": "5.8.21", "@vercel/static-config": "3.4.0" } }, @@ -3020,42 +3026,42 @@ } }, "node_modules/@vercel/gatsby-plugin-vercel-builder": { - "version": "2.2.19", - "resolved": "https://registry.npmjs.org/@vercel/gatsby-plugin-vercel-builder/-/gatsby-plugin-vercel-builder-2.2.19.tgz", - "integrity": "sha512-dA1ZWZHruRjFbLToqjcPHUyh5J7/Cl3qGFDge87ghAInuy/aXfalApzFbw61OIopGx0SSsIYi/8kbpJ+SbQ6ZQ==", + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/@vercel/gatsby-plugin-vercel-builder/-/gatsby-plugin-vercel-builder-2.2.23.tgz", + "integrity": "sha512-UxInx6QtZZQkjeoOK+fMBxavlS+GWm1PUWAvCvZpyCNO971JzdlGwW6zzSIeUsBrHB8SozPqnG1PMQisLY6TxQ==", "license": "Apache-2.0", "dependencies": { "@sinclair/typebox": "0.25.24", - "@vercel/build-utils": "13.30.0", + "@vercel/build-utils": "13.32.1", "esbuild": "0.27.0", "etag": "1.8.1", "fs-extra": "11.1.0" } }, "node_modules/@vercel/go": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/@vercel/go/-/go-3.9.0.tgz", - "integrity": "sha512-vGbEwckvtr9UXKDz3qEHUE7VHJGG/9VQLuScgHytksfZyX8dzvNPgEuBKBbhPaMwua8pUCZKQqNxFM3Dul+bLA==", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@vercel/go/-/go-3.10.1.tgz", + "integrity": "sha512-6i37zdMUS4PX1BWRHBCUqp5Ev0NbOrfrRVCwAzKj8YKh48LNu/Ye5r5NFI3JW7g40HkAC8wBMwZ7fRQPlWgBSg==", "license": "Apache-2.0" }, "node_modules/@vercel/h3": { - "version": "0.1.102", - "resolved": "https://registry.npmjs.org/@vercel/h3/-/h3-0.1.102.tgz", - "integrity": "sha512-UnHFLBemE2UfvQ+w2z2zxGcvyiShQ8eMdk3ag2sV71wbyatnXwmio8HOy//B2ZIEQNEid5gW2HjWOXLtrR+E2Q==", + "version": "0.1.106", + "resolved": "https://registry.npmjs.org/@vercel/h3/-/h3-0.1.106.tgz", + "integrity": "sha512-Sl9k42VQnlbPgtg0vQyUmhhQ0LmjSYhLklLKjd5pzD0GXITKbhPXa+0QwdnRtJo0Eb2kFzluZmypEF7KoNxW4g==", "license": "Apache-2.0", "dependencies": { - "@vercel/node": "5.8.17", + "@vercel/node": "5.8.21", "@vercel/static-config": "3.4.0" } }, "node_modules/@vercel/hono": { - "version": "0.2.96", - "resolved": "https://registry.npmjs.org/@vercel/hono/-/hono-0.2.96.tgz", - "integrity": "sha512-h4G0GSo5omhXS1iqimIcekcW7THEAnUGd6eioDNnSqwS3L0CGEvvTPUekJDyJcybsqnKslH1XTZA40f4b9MPHg==", + "version": "0.2.100", + "resolved": "https://registry.npmjs.org/@vercel/hono/-/hono-0.2.100.tgz", + "integrity": "sha512-AIVfTN2txUEj7yLEUdbDCfBLgQOe+jbnQUgWWFAI5nH0RAn4I3OtySp6xI3FlP+TOCOAe0hdsbTF+7bpDyG52A==", "license": "Apache-2.0", "dependencies": { "@vercel/nft": "1.10.0", - "@vercel/node": "5.8.17", + "@vercel/node": "5.8.21", "@vercel/static-config": "3.4.0", "fs-extra": "11.1.0", "path-to-regexp": "8.3.0", @@ -3083,29 +3089,29 @@ } }, "node_modules/@vercel/koa": { - "version": "0.1.76", - "resolved": "https://registry.npmjs.org/@vercel/koa/-/koa-0.1.76.tgz", - "integrity": "sha512-6MuBn5RM2nHKfHKeBqbs3aaFJlBFDhOZgpC1ubLQG2B/EvnAfljB7FPIKCl1SwANAHisX7vQACinMedCbFdi+Q==", + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@vercel/koa/-/koa-0.1.80.tgz", + "integrity": "sha512-Ul9G9P7Jy/vh0M8OH4A14WId96l2FLqwtUKnuuwKF4m1Fun8NvUw+5AM/xVwT0GQUNJsRJpVlTuGX+vhmgDFhA==", "license": "Apache-2.0", "dependencies": { - "@vercel/node": "5.8.17", + "@vercel/node": "5.8.21", "@vercel/static-config": "3.4.0" } }, "node_modules/@vercel/nestjs": { - "version": "0.2.97", - "resolved": "https://registry.npmjs.org/@vercel/nestjs/-/nestjs-0.2.97.tgz", - "integrity": "sha512-KM9uBmC+a10EPJgtm0B/vfGsTaUEzxKkihTyaQ4PhSUQHJOABTv6TBpGKtwIM5P1vMLKUg9R0A2SUvkKdaALvA==", + "version": "0.2.101", + "resolved": "https://registry.npmjs.org/@vercel/nestjs/-/nestjs-0.2.101.tgz", + "integrity": "sha512-twHcHBLJN97bK6z9NtGOzwrd9HrR/10e6FGGMCgckz3REAS4a4Us2dyS3IQennNbAT0Nfc1zdH/+trB5WIHUaw==", "license": "Apache-2.0", "dependencies": { - "@vercel/node": "5.8.17", + "@vercel/node": "5.8.21", "@vercel/static-config": "3.4.0" } }, "node_modules/@vercel/next": { - "version": "4.19.0", - "resolved": "https://registry.npmjs.org/@vercel/next/-/next-4.19.0.tgz", - "integrity": "sha512-9B7juRNydQ9MU7xT8OE254ymhVrNdcOKlXrZ4anem8YEa34KvXjLczQhZEPdvTz9JaCXpYiEEGmd/D3t2oJ+Uw==", + "version": "4.20.1", + "resolved": "https://registry.npmjs.org/@vercel/next/-/next-4.20.1.tgz", + "integrity": "sha512-MvZSuRQUGvd4W9rcztpbj+7cNzCFlxhjEFMF4gJbW635+ZP7tN44wEcr7bzVj1m8wi6lYlrs19qXr5vsJKlsmw==", "license": "Apache-2.0", "dependencies": { "@vercel/nft": "1.10.0" @@ -3138,16 +3144,16 @@ } }, "node_modules/@vercel/node": { - "version": "5.8.17", - "resolved": "https://registry.npmjs.org/@vercel/node/-/node-5.8.17.tgz", - "integrity": "sha512-n2DVzblqS43LTs4BV1iLfx8tjjrTegcSsyDgRzn70h6tQePYWjl+h0bU3X1stpITZtaYJSZYwVevuX1BJNZHHg==", + "version": "5.8.21", + "resolved": "https://registry.npmjs.org/@vercel/node/-/node-5.8.21.tgz", + "integrity": "sha512-h49+XWY9nPEu2atBkbs5izbzlOVx4RxwJQrgYUpcp6KG2Ib2WRQ81uUKCIevm4O0qwAo6w7mgNN11yp8XGWK7g==", "license": "Apache-2.0", "dependencies": { "@edge-runtime/node-utils": "2.3.0", "@edge-runtime/primitives": "4.1.0", "@edge-runtime/vm": "3.2.0", "@types/node": "20.11.0", - "@vercel/build-utils": "13.30.0", + "@vercel/build-utils": "13.32.1", "@vercel/error-utils": "2.2.0", "@vercel/nft": "1.10.0", "@vercel/static-config": "3.4.0", @@ -3264,9 +3270,9 @@ "license": "MIT" }, "node_modules/@vercel/python": { - "version": "6.45.1", - "resolved": "https://registry.npmjs.org/@vercel/python/-/python-6.45.1.tgz", - "integrity": "sha512-2ay12lt3Cu6xS6qiaKNNwALRKxI1AE3rtHDfFOP+BU4qJ2U5k7JmPxlDuU+CXKY+chQClQM2orgThJWeMoeQkw==", + "version": "6.47.2", + "resolved": "https://registry.npmjs.org/@vercel/python/-/python-6.47.2.tgz", + "integrity": "sha512-ayP1s8dfCtW4XmvUFDRdA7ETbkEbRWChfCv66FZNHWgMRKELxhsuO1OHPnK+7hwHwEjpxPqZyilRO64RBKlYfw==", "license": "Apache-2.0", "dependencies": { "@vercel/python-analysis": "0.11.1" @@ -3352,9 +3358,9 @@ "license": "MIT" }, "node_modules/@vercel/ruby": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@vercel/ruby/-/ruby-2.4.0.tgz", - "integrity": "sha512-YI7Amyf09hHZWOqDHgJO92XcKh6Pye8rrmJFhlP6euG3o6QjoZzJj7Z2WzjSrDRGMewzEK4uz2+CbNpNS7gLog==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@vercel/ruby/-/ruby-2.5.1.tgz", + "integrity": "sha512-e23M7pfORQyyxSUmeQ8PqEnyLJ4swhtNrnyctU5E02UCaUYFm5YAdNfdPYRP4It874H4Wyvm+LV8u7CPBrfcPA==", "license": "Apache-2.0" }, "node_modules/@vercel/rust": { @@ -3470,13 +3476,13 @@ } }, "node_modules/@vercel/static-build": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/@vercel/static-build/-/static-build-2.10.3.tgz", - "integrity": "sha512-+IipWidX6mL1Zck8Khw4wSXHPSgDfkapvcuWA9wSCmhnk2GL2Iuu+6vab+g8UPahv+QDUYm8Tphhadg6dICZnA==", + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/@vercel/static-build/-/static-build-2.11.3.tgz", + "integrity": "sha512-7twXLgeCbG9A0uiH9hnDmmYR5pn++0i3LCRpsO9hMyLwEGzkYxjgtA9blWBQk9dkVhDa4WGo1xGlyBKXT10dOw==", "license": "Apache-2.0", "dependencies": { "@vercel/gatsby-plugin-vercel-analytics": "1.0.11", - "@vercel/gatsby-plugin-vercel-builder": "2.2.19", + "@vercel/gatsby-plugin-vercel-builder": "2.2.23", "@vercel/static-config": "3.4.0", "ts-morph": "12.0.0" } @@ -4109,12 +4115,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/cookie-es": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-2.0.1.tgz", - "integrity": "sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA==", - "license": "MIT" - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5569,9 +5569,9 @@ } }, "node_modules/lucide-react": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.18.0.tgz", - "integrity": "sha512-LZDb7H/0YfM+RJncD0hDQRCAu+vSGODqpe35TuVI8EuXaRjkczbsx7p8dY4J87F/MUSj6bpYqeI8nw8qXaAdmA==", + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.21.0.tgz", + "integrity": "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -6242,9 +6242,9 @@ } }, "node_modules/react-hook-form": { - "version": "7.79.0", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.79.0.tgz", - "integrity": "sha512-mhYp/MTmXvzYX6AJcJVko0rktoIhhmRnEouObj4wF5i/tCttgJvnp1+9wRkpITZjDTqpo4IOSJqu0dBlPlV/Lw==", + "version": "7.80.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.80.0.tgz", + "integrity": "sha512-4P+fk6oXsxY+6xSj7Euhc2sumQD8zQqCuVHoJwoyp9EchP+IUW9OESB7uHFJOKsIBQ4MQqYE84INJFqUCYNoOg==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -6288,9 +6288,9 @@ } }, "node_modules/react-router": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz", - "integrity": "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz", + "integrity": "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -6310,12 +6310,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.17.0.tgz", - "integrity": "sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.0.tgz", + "integrity": "sha512-Fi0yY6kgtKae/Th2xibdWK0KSdYZ4B53Gyf6wRtomOKWgpNm7H7+DyfDhncdz9FKbpS+1jmDhg3F4WoGJ+yFOA==", "license": "MIT", "dependencies": { - "react-router": "7.17.0" + "react-router": "7.18.0" }, "engines": { "node": ">=20.0.0" @@ -6676,13 +6676,10 @@ } }, "node_modules/srvx": { - "version": "0.8.9", - "resolved": "https://registry.npmjs.org/srvx/-/srvx-0.8.9.tgz", - "integrity": "sha512-wYc3VLZHRzwYrWJhkEqkhLb31TI0SOkfYZDkUhXdp3NoCnNS0FqajiQszZZjfow/VYEuc6Q5sZh9nM6kPy2NBQ==", + "version": "0.11.16", + "resolved": "https://registry.npmjs.org/srvx/-/srvx-0.11.16.tgz", + "integrity": "sha512-bp07zRuycfTY43IjAvvTFnmnJi8ikW0VFiHwOhhYcVW/L4xQ1XY4PAd4Nuum1rsA17C39zL7x+CDhrn5AL32Rw==", "license": "MIT", - "dependencies": { - "cookie-es": "^2.0.0" - }, "bin": { "srvx": "bin/srvx.mjs" }, @@ -7530,36 +7527,37 @@ } }, "node_modules/vercel": { - "version": "54.14.2", - "resolved": "https://registry.npmjs.org/vercel/-/vercel-54.14.2.tgz", - "integrity": "sha512-XMUBq4mEYniGGIHTAIY7Egop120x6d2NLZ9wezk3032VfKRMsB3q2UYFiZduWETG/TwFSddlNJ6UI8jBskleog==", + "version": "54.17.3", + "resolved": "https://registry.npmjs.org/vercel/-/vercel-54.17.3.tgz", + "integrity": "sha512-HVmYSzxvG6kDp0pqKJCdzaRUsopDVx6VyuGEWIVuyRGZWs6Tw/eUZ2OVl+VRTGn5yGOLaCPpI5nVqnk1689MSg==", "license": "Apache-2.0", "dependencies": { - "@vercel/backends": "0.8.14", + "@vercel/backends": "0.8.18", "@vercel/blob": "2.4.0", - "@vercel/build-utils": "13.30.0", + "@vercel/build-utils": "13.32.1", "@vercel/cli-auth": "0.3.0", "@vercel/cli-config": "0.2.0", + "@vercel/container": "0.0.2", "@vercel/detect-agent": "1.2.3", - "@vercel/elysia": "0.1.93", - "@vercel/express": "0.1.105", - "@vercel/fastify": "0.1.96", + "@vercel/elysia": "0.1.97", + "@vercel/express": "0.1.109", + "@vercel/fastify": "0.1.100", "@vercel/fun": "1.3.0", - "@vercel/go": "3.9.0", - "@vercel/h3": "0.1.102", - "@vercel/hono": "0.2.96", + "@vercel/go": "3.10.1", + "@vercel/h3": "0.1.106", + "@vercel/hono": "0.2.100", "@vercel/hydrogen": "1.4.0", - "@vercel/koa": "0.1.76", - "@vercel/nestjs": "0.2.97", - "@vercel/next": "4.19.0", - "@vercel/node": "5.8.17", + "@vercel/koa": "0.1.80", + "@vercel/nestjs": "0.2.101", + "@vercel/next": "4.20.1", + "@vercel/node": "5.8.21", "@vercel/prepare-flags-definitions": "0.3.0", - "@vercel/python": "6.45.1", + "@vercel/python": "6.47.2", "@vercel/redwood": "2.5.0", "@vercel/remix-builder": "5.9.1", - "@vercel/ruby": "2.4.0", + "@vercel/ruby": "2.5.1", "@vercel/rust": "1.3.0", - "@vercel/static-build": "2.10.3", + "@vercel/static-build": "2.11.3", "chokidar": "4.0.0", "esbuild": "0.27.0", "form-data": "^4.0.0", diff --git a/admin-service/src/App.tsx b/admin-service/src/App.tsx index e08256e0..5c73ebb4 100644 --- a/admin-service/src/App.tsx +++ b/admin-service/src/App.tsx @@ -10,7 +10,8 @@ import { LayoutDashboard, Users, BookOpen, Layers, FileText, PenTool, BarChart3, Languages, Trophy, ShoppingBag, Megaphone, ScrollText, Activity, Settings, - Shield, Database, Bot, ArrowRight, ArrowLeft, MessageSquare, Swords, Bell + Shield, Database, Bot, ArrowRight, ArrowLeft, MessageSquare, Swords, Bell, + ClipboardCheck, Gauge } from "lucide-react"; // Lazy-loaded components @@ -31,9 +32,11 @@ const AdsPage = lazy(() => import("./pages/AdsPage").then(m => ({ default: m.Ads const LogsPage = lazy(() => import("./pages/LogsPage").then(m => ({ default: m.LogsPage }))); const MonitoringPage = lazy(() => import("./pages/MonitoringPage").then(m => ({ default: m.MonitoringPage }))); const ContentLabPage = lazy(() => import("./pages/ContentLabPage").then(m => ({ default: m.ContentLabPage }))); +const ContentQaQueuePage = lazy(() => import("./pages/ContentQaQueuePage").then(m => ({ default: m.ContentQaQueuePage }))); const DatabasePage = lazy(() => import("./pages/DatabasePage").then(m => ({ default: m.DatabasePage }))); const AiModelsPage = lazy(() => import("./pages/AiModelsPage").then(m => ({ default: m.AiModelsPage }))); const ContentAnalyticsPage = lazy(() => import("./pages/ContentAnalyticsPage").then(m => ({ default: m.ContentAnalyticsPage }))); +const AiQualityDashboardPage = lazy(() => import("./pages/AiQualityDashboardPage").then(m => ({ default: m.AiQualityDashboardPage }))); const SystemSettingsPage = lazy(() => import("./pages/SystemSettingsPage").then(m => ({ default: m.SystemSettingsPage }))); const AdminManagementPage = lazy(() => import("./pages/AdminManagementPage").then(m => ({ default: m.AdminManagementPage }))); const AiChatSettingsPage = lazy(() => import("./pages/AiChatSettingsPage").then(m => ({ default: m.AiChatSettingsPage }))); @@ -60,7 +63,9 @@ const AppRoutes = () => { { to: "/admin/lessons", label: t.nav.lessons, icon: }, { to: "/admin/topics", label: t.nav.topics, icon: }, { to: "/admin/content-lab", label: t.nav.grammarTest, icon: }, + { to: "/admin/content-qa", label: t.nav.contentQaQueue, icon: }, { to: "/admin/content-analytics", label: t.nav.contentAnalytics, icon: }, + { to: "/admin/ai-quality", label: t.nav.aiQuality, icon: }, { to: "/admin/vocabulary", label: t.nav.vocabulary, icon: }, { to: "/admin/achievements", label: t.nav.achievements, icon: }, { to: "/admin/shop", label: t.nav.shop, icon: }, @@ -104,7 +109,9 @@ const AppRoutes = () => { } /> } /> } /> + } /> } /> + } /> } /> } /> } /> diff --git a/admin-service/src/lib/adminApi.ts b/admin-service/src/lib/adminApi.ts index d30dbfc7..ab963405 100644 --- a/admin-service/src/lib/adminApi.ts +++ b/admin-service/src/lib/adminApi.ts @@ -280,6 +280,8 @@ export const uploadBadgeImage = async (file: File): Promise; + export type ShopItemType = { id: string; name: string; @@ -289,6 +291,7 @@ export type ShopItemType = { is_available: boolean; stock_quantity?: number | null; icon_url?: string | null; + effects?: ShopItemEffects | null; }; export const listShopItems = async (includeUnavailable = true) => @@ -303,17 +306,19 @@ export const createShopItem = async (params: { price_gems: number; is_available?: boolean; stock_quantity?: number; -}) => { - const url = new URL(`${ENV.backendUrl}/admin/shop`); - Object.entries(params).forEach(([k, v]) => { if (v !== undefined) url.searchParams.set(k, String(v)); }); - return apiFetch>(url.toString(), { method: "POST" }); -}; + icon_url?: string; + effects?: ShopItemEffects; +}) => + apiFetch>(`${ENV.backendUrl}/admin/shop`, { + method: "POST", + body: JSON.stringify(params), + }); -export const updateShopItem = async (id: string, params: Partial) => { - const url = new URL(`${ENV.backendUrl}/admin/shop/${id}`); - Object.entries(params).forEach(([k, v]) => { if (v !== undefined) url.searchParams.set(k, String(v)); }); - return apiFetch>(url.toString(), { method: "PUT" }); -}; +export const updateShopItem = async (id: string, params: Partial) => + apiFetch>(`${ENV.backendUrl}/admin/shop/${id}`, { + method: "PUT", + body: JSON.stringify(params), + }); export const deleteShopItem = async (id: string) => apiFetch>(`${ENV.backendUrl}/admin/shop/${id}`, { diff --git a/admin-service/src/lib/aiQualityApi.test.ts b/admin-service/src/lib/aiQualityApi.test.ts new file mode 100644 index 00000000..fce6263e --- /dev/null +++ b/admin-service/src/lib/aiQualityApi.test.ts @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { apiFetchMock } = vi.hoisted(() => ({ + apiFetchMock: vi.fn(), +})); + +vi.mock("./api", () => ({ + apiFetch: apiFetchMock, +})); + +vi.mock("./env", () => ({ + ENV: { + backendUrl: "https://backend.example/api/v1", + }, +})); + +import { getAiQualitySummary } from "./aiQualityApi"; + +describe("aiQualityApi", () => { + beforeEach(() => { + apiFetchMock.mockReset(); + apiFetchMock.mockResolvedValue({ + events: [], + summary: { total_events: 0 }, + total: 0, + source: "ai:audit:all", + }); + }); + + it("loads the backend AI audit quality summary with a bounded limit", async () => { + await getAiQualitySummary(250); + + expect(apiFetchMock).toHaveBeenCalledWith( + "https://backend.example/api/v1/ai-audit/quality-summary?limit=250", + ); + }); +}); diff --git a/admin-service/src/lib/aiQualityApi.ts b/admin-service/src/lib/aiQualityApi.ts new file mode 100644 index 00000000..0aae40f3 --- /dev/null +++ b/admin-service/src/lib/aiQualityApi.ts @@ -0,0 +1,58 @@ +import { apiFetch } from "./api"; +import { ENV } from "./env"; + +export type AiQualityEndpointBreakdown = { + endpoint: string; + total: number; + failures: number; + average_latency_ms: number; +}; + +export type AiQualityFailure = { + request_id?: string | null; + user_id?: string | null; + endpoint?: string | null; + status?: string | null; + latency_ms?: number | null; + received_at?: string | null; + error?: string | null; +}; + +export type AiQualitySummary = { + total_events: number; + success_count: number; + failure_count: number; + success_rate: number; + average_latency_ms: number; + p95_latency_ms: number; + lexi: { + events: number; + failures: number; + average_latency_ms: number; + }; + stt: { + failures: number; + }; + tts: { + failures: number; + }; + correction: { + events: number; + failures: number; + average_score: number; + }; + endpoint_breakdown: AiQualityEndpointBreakdown[]; + latest_failures: AiQualityFailure[]; +}; + +export type AiQualityResponse = { + events: unknown[]; + summary: AiQualitySummary; + total: number; + source: string; +}; + +export const getAiQualitySummary = (limit = 500) => + apiFetch( + `${ENV.backendUrl}/ai-audit/quality-summary?limit=${encodeURIComponent(limit)}`, + ); diff --git a/admin-service/src/lib/contentAgentApi.test.ts b/admin-service/src/lib/contentAgentApi.test.ts index 8b98c5df..8a75fff8 100644 --- a/admin-service/src/lib/contentAgentApi.test.ts +++ b/admin-service/src/lib/contentAgentApi.test.ts @@ -16,11 +16,13 @@ vi.mock("./env", () => ({ import { applyContentAgentJob, + buildContentQaQueue, cancelContentAgentJob, createContentAgentJob, getContentAgentJob, getContentAgentPreview, listContentAgentJobs, + listContentQaQueue, retryContentAgentJob, uploadContentAgentFile, } from "./contentAgentApi"; @@ -121,4 +123,36 @@ describe("contentAgentApi", () => { total: 4, }); }); + + it("builds a content QA queue from agent job statuses", () => { + const queue = buildContentQaQueue([ + { id: "ready", status: "preview_ready" }, + { id: "failed", status: "failed" }, + { id: "done", status: "completed" }, + { id: "running", status: "generating" }, + ] as any); + + expect(queue.total).toBe(4); + expect(queue.reviewable.map((job) => job.id)).toEqual(["ready"]); + expect(queue.failed.map((job) => job.id)).toEqual(["failed"]); + expect(queue.applied.map((job) => job.id)).toEqual(["done"]); + }); + + it("loads content QA queue through the jobs endpoint", async () => { + apiFetchMock.mockResolvedValueOnce({ + data: { + jobs: [ + { id: "job-1", status: "preview_ready" }, + { id: "job-2", status: "failed" }, + ], + total: 2, + }, + }); + + await expect(listContentQaQueue()).resolves.toMatchObject({ + reviewable: [{ id: "job-1" }], + failed: [{ id: "job-2" }], + total: 2, + }); + }); }); diff --git a/admin-service/src/lib/contentAgentApi.ts b/admin-service/src/lib/contentAgentApi.ts index 84961aa9..b9c918f5 100644 --- a/admin-service/src/lib/contentAgentApi.ts +++ b/admin-service/src/lib/contentAgentApi.ts @@ -201,6 +201,13 @@ export type ContentAgentJobList = { total: number; }; +export type ContentQaQueue = { + reviewable: ContentAgentJob[]; + failed: ContentAgentJob[]; + applied: ContentAgentJob[]; + total: number; +}; + type ApiEnvelope = { success?: boolean; data?: T; @@ -297,6 +304,26 @@ export const listContentAgentJobs = async (): Promise => { }; }; +export const buildContentQaQueue = ( + jobs: readonly ContentAgentJob[], +): ContentQaQueue => { + const reviewable = jobs.filter((job) => job.status === "preview_ready"); + const failed = jobs.filter((job) => job.status === "failed"); + const applied = jobs.filter((job) => job.status === "completed"); + + return { + reviewable, + failed, + applied, + total: jobs.length, + }; +}; + +export const listContentQaQueue = async (): Promise => { + const { jobs } = await listContentAgentJobs(); + return buildContentQaQueue(jobs); +}; + export const getContentAgentJob = async ( jobId: string, ): Promise => { diff --git a/admin-service/src/lib/i18n/en.ts b/admin-service/src/lib/i18n/en.ts index 7e7781a6..e423a89a 100644 --- a/admin-service/src/lib/i18n/en.ts +++ b/admin-service/src/lib/i18n/en.ts @@ -67,7 +67,9 @@ const en: Translations = { lessons: "Lessons", topics: "Topics", grammarTest: "Grammar & Tests", + contentQaQueue: "Content QA", contentAnalytics: "Content Analytics", + aiQuality: "AI Quality", vocabulary: "Vocabulary", achievements: "Achievements", shop: "Shop", @@ -479,6 +481,13 @@ const en: Translations = { stockQuantity: "Stock Quantity", unlimitedPlaceholder: "Leave blank = unlimited", updateFailed: "Update failed", + useTemplate: "Use Template", + templateApplied: "Template applied for this item type — adjust as needed before saving.", + iconUrl: "Icon URL", + iconUrlPlaceholder: "e.g. assets/icon-library/clock_freeze.png", + effectsLabel: "Effects (JSON)", + effectsHint: "e.g. {\"seconds\": 10} — determines how this item behaves in-game.", + effectsInvalidJson: "Effects must be valid JSON, e.g. {\"seconds\": 10}", }, // ============== Ads/Banner ============== diff --git a/admin-service/src/lib/i18n/vi.ts b/admin-service/src/lib/i18n/vi.ts index 9f7edc4d..5616bdd3 100644 --- a/admin-service/src/lib/i18n/vi.ts +++ b/admin-service/src/lib/i18n/vi.ts @@ -65,7 +65,9 @@ const vi = { lessons: "Bài học", topics: "Topic chat", grammarTest: "Ngữ pháp & Kiểm tra", + contentQaQueue: "QA nội dung", contentAnalytics: "Phân tích nội dung", + aiQuality: "Chất lượng AI", vocabulary: "Từ vựng", achievements: "Thành tựu", shop: "Cửa hàng", @@ -477,6 +479,13 @@ const vi = { stockQuantity: "Số lượng kho", unlimitedPlaceholder: "Để trống = vô hạn", updateFailed: "Cập nhật thất bại", + useTemplate: "Dùng mẫu", + templateApplied: "Đã điền mẫu cho loại item này — có thể chỉnh lại trước khi lưu.", + iconUrl: "URL icon", + iconUrlPlaceholder: "VD: assets/icon-library/clock_freeze.png", + effectsLabel: "Hiệu ứng (JSON)", + effectsHint: "VD: {\"seconds\": 10} — quyết định cách item này hoạt động trong game.", + effectsInvalidJson: "Hiệu ứng phải là JSON hợp lệ, ví dụ {\"seconds\": 10}", }, // ============== Ads/Banner ============== diff --git a/admin-service/src/pages/AiQualityDashboardPage.tsx b/admin-service/src/pages/AiQualityDashboardPage.tsx new file mode 100644 index 00000000..b58735d3 --- /dev/null +++ b/admin-service/src/pages/AiQualityDashboardPage.tsx @@ -0,0 +1,166 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { AlertTriangle, RefreshCw } from "lucide-react"; +import { DataTable } from "../components/DataTable"; +import { EmptyState } from "../components/EmptyState"; +import { SectionHeader } from "../components/SectionHeader"; +import { StatCard } from "../components/StatCard"; +import { StatusPill } from "../components/StatusPill"; +import { + getAiQualitySummary, + type AiQualityEndpointBreakdown, + type AiQualityFailure, + type AiQualityResponse, +} from "../lib/aiQualityApi"; + +const pctTone = (value: number): "success" | "warning" | "danger" => + value >= 95 ? "success" : value >= 85 ? "warning" : "danger"; + +export const AiQualityDashboardPage = () => { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + setData(await getAiQualitySummary(500)); + } catch (err: any) { + setError(err?.message || "Failed to load AI quality metrics"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const summary = data?.summary; + const hasEvents = Boolean(summary?.total_events); + + return ( +
+ + Refresh + + } + /> + + {error &&
{error}
} + {loading &&
Loading AI quality metrics...
} + + {!loading && !hasEvents ? ( + } + title="No AI audit events yet" + description="Metrics will appear after ai-service starts ingesting audit events into backend-service." + /> + ) : summary ? ( + <> +
+ + + + +
+ +
+ + + + +
+ +
+

Endpoint breakdown

+ {!summary.endpoint_breakdown.length ? ( + + ) : ( + + rows={summary.endpoint_breakdown} + columns={[ + { + header: "Endpoint", + render: (row) => {row.endpoint}, + }, + { + header: "Events", + render: (row) => {row.total}, + align: "center", + }, + { + header: "Failures", + render: (row) => ( + + ), + align: "center", + }, + { + header: "Avg latency", + render: (row) => {Math.round(row.average_latency_ms)} ms, + align: "right", + }, + ]} + /> + )} +
+ +
+

Latest failures

+ {!summary.latest_failures.length ? ( + + ) : ( + + rows={summary.latest_failures} + columns={[ + { + header: "Request", + render: (row) => ( +
+
{row.request_id || "unknown"}
+
{row.user_id || "unknown user"}
+
+ ), + }, + { + header: "Endpoint", + render: (row) => {row.endpoint || "unknown"}, + }, + { + header: "Status", + render: (row) => , + align: "center", + }, + { + header: "Latency", + render: (row) => {row.latency_ms ? `${Math.round(row.latency_ms)} ms` : "--"}, + align: "right", + }, + { + header: "Error", + render: (row) => {row.error || "No message"}, + }, + ]} + /> + )} +
+ +
+ + + {summary.failure_count} failed of {summary.total_events} audited events. + +
+ + ) : null} +
+ ); +}; diff --git a/admin-service/src/pages/ContentQaQueuePage.tsx b/admin-service/src/pages/ContentQaQueuePage.tsx new file mode 100644 index 00000000..539d18ae --- /dev/null +++ b/admin-service/src/pages/ContentQaQueuePage.tsx @@ -0,0 +1,272 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { CheckCircle, RefreshCw, RotateCcw, XCircle } from "lucide-react"; +import { EmptyState } from "../components/EmptyState"; +import { SectionHeader } from "../components/SectionHeader"; +import { StatCard } from "../components/StatCard"; +import { StatusPill } from "../components/StatusPill"; +import { + applyContentAgentJob, + cancelContentAgentJob, + getContentAgentPreview, + listContentQaQueue, + retryContentAgentJob, + type ContentAgentJob, + type ContentAgentPreview, + type ContentQaQueue, +} from "../lib/contentAgentApi"; + +const statusTone = (status: string): "success" | "warning" | "info" | "danger" | "neutral" => { + if (status === "preview_ready") return "warning"; + if (status === "completed") return "success"; + if (status === "failed") return "danger"; + return "info"; +}; + +const summarizePreview = (preview: ContentAgentPreview | null) => { + if (!preview) { + return { courses: 0, units: 0, lessons: 0, vocabulary: 0, exercises: 0 }; + } + + return preview.courses.reduce( + (acc, course) => { + acc.courses += 1; + acc.units += course.units.length; + course.units.forEach((unit) => { + acc.lessons += unit.lessons.length; + unit.lessons.forEach((lesson) => { + acc.vocabulary += lesson.vocabulary.length; + acc.exercises += lesson.exercises.length; + }); + }); + return acc; + }, + { courses: 0, units: 0, lessons: 0, vocabulary: 0, exercises: 0 }, + ); +}; + +const JobList = ({ + title, + jobs, + selectedId, + onSelect, +}: { + title: string; + jobs: ContentAgentJob[]; + selectedId?: string; + onSelect: (job: ContentAgentJob) => void; +}) => ( +
+

{title}

+ {!jobs.length ? ( + + ) : ( +
+ {jobs.map((job) => ( + + ))} +
+ )} +
+); + +export const ContentQaQueuePage = () => { + const [queue, setQueue] = useState(null); + const [selected, setSelected] = useState(null); + const [preview, setPreview] = useState(null); + const [loading, setLoading] = useState(false); + const [previewLoading, setPreviewLoading] = useState(false); + const [error, setError] = useState(null); + + const loadQueue = useCallback(async () => { + setLoading(true); + setError(null); + try { + const nextQueue = await listContentQaQueue(); + setQueue(nextQueue); + setSelected((current) => { + const jobs = [ + ...nextQueue.reviewable, + ...nextQueue.failed, + ...nextQueue.applied, + ]; + return ( + (current ? jobs.find((job) => job.id === current.id) : null) ?? + nextQueue.reviewable[0] ?? + nextQueue.failed[0] ?? + nextQueue.applied[0] ?? + null + ); + }); + } catch (err: any) { + setError(err?.message || "Failed to load content QA queue"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void loadQueue(); + }, [loadQueue]); + + useEffect(() => { + if (!selected) { + setPreview(null); + return; + } + + setPreviewLoading(true); + getContentAgentPreview(selected.id) + .then(setPreview) + .catch(() => setPreview(null)) + .finally(() => setPreviewLoading(false)); + }, [selected]); + + const summary = useMemo(() => summarizePreview(preview), [preview]); + const validationErrors = [ + ...(selected?.blocking_errors || []), + ...(preview?.quality.blocking_errors || []), + ]; + const validationWarnings = [ + ...(selected?.warnings || []), + ...(preview?.quality.warnings || []), + ]; + const createdCourseIds = + selected?.created_entity_ids.course_ids ?? + selected?.created_entity_ids.courses ?? + []; + + const runAction = async (action: "apply" | "cancel" | "retry") => { + if (!selected) return; + setError(null); + try { + if (action === "apply") await applyContentAgentJob(selected.id); + if (action === "cancel") await cancelContentAgentJob(selected.id); + if (action === "retry") await retryContentAgentJob(selected.id); + await loadQueue(); + } catch (err: any) { + setError(err?.message || `Failed to ${action} content job`); + } + }; + + return ( +
+ + Refresh + + } + /> + + {error &&
{error}
} + +
+ + + + +
+ +
+
+ + + +
+ +
+ {!selected ? ( + + ) : ( +
+
+
+

{selected.config.title_focus || "Generated content preview"}

+
{selected.id}
+
+ +
+ +
+ + + + +
+ +
+

Validation

+ {previewLoading &&
Loading preview...
} + {!validationErrors.length && !validationWarnings.length ? ( + + ) : ( +
+ {validationErrors.map((message, index) => ( +
{message}
+ ))} + {validationWarnings.map((message, index) => ( +
{message}
+ ))} +
+ )} +
+ +
+

Source policy

+ {!preview?.source_manifest?.length ? ( +
No source manifest attached.
+ ) : ( +
+ {preview.source_manifest.map((source, index) => ( +
+ {source.source_name} · {source.license_mode || "unknown"} · {source.record_count || 0} records +
+ ))} +
+ )} +
+ +
+

Apply history

+
Created courses: {createdCourseIds.length ? createdCourseIds.join(", ") : "none yet"}
+
Completed: {selected.completed_at ? new Date(selected.completed_at).toLocaleString() : "not completed"}
+
+ +
+ + + +
+
+ )} +
+
+
+ ); +}; diff --git a/admin-service/src/pages/ShopPage.tsx b/admin-service/src/pages/ShopPage.tsx index a63b4bb2..c3a58262 100644 --- a/admin-service/src/pages/ShopPage.tsx +++ b/admin-service/src/pages/ShopPage.tsx @@ -12,7 +12,75 @@ import { } from "../lib/adminApi"; import { useI18n } from "../lib/i18n"; -const ITEM_TYPES = ["streak_freeze", "double_xp", "hint_pack", "cosmetic", "power_up", "time_boost"]; +// Mirrors the item_type values actually seeded in backend-service/app/core/shop_catalog.py. +const ITEM_TYPES = [ + // Boosts + "streak_freeze", + "double_xp", + "hint_pack", + "heart_refill", + // Cosmetics + "avatar", + "theme", + // In-game power-ups + "time_freeze", + "extra_time", + "skip_token", + "reveal_hint", + "translate_hint", + "mistake_shield", + "extra_heart", + "lucky_clover", + "score_multiplier", + "pair_swap", +]; + +// Quick-create templates mirroring backend-service/app/core/shop_catalog.py, so +// picking an item_type fills in a sensible, already-working default instead of +// the admin having to know each item's expected effects payload shape by heart. +const ITEM_TYPE_TEMPLATES: Record< + string, + { name: string; description: string; price_gems: number; effects: Record } +> = { + streak_freeze: { name: "Streak Freeze", description: "Add one streak freeze", price_gems: 25, effects: { quantity: 1 } }, + double_xp: { name: "Double XP (1 hour)", description: "Earn double XP for 1 hour", price_gems: 25, effects: { duration_hours: 1, multiplier: 2 } }, + hint_pack: { name: "Hint Pack (5)", description: "Add 5 hints to your active lesson", price_gems: 12, effects: { quantity: 5 } }, + heart_refill: { name: "Heart Refill", description: "Restore your active lesson to 3 hearts", price_gems: 15, effects: { hearts: 3 } }, + avatar: { name: "New Avatar", description: "Exclusive profile avatar", price_gems: 50, effects: { avatar_url: "" } }, + theme: { name: "New Theme", description: "Unlockable app theme", price_gems: 50, effects: {} }, + time_freeze: { name: "Time Freeze", description: "Pause the countdown timer for 10 seconds in any timed game", price_gems: 10, effects: { seconds: 10 } }, + extra_time: { name: "Extra Time", description: "Add 20 seconds straight to the clock in any timed game", price_gems: 15, effects: { seconds: 20 } }, + skip_token: { name: "Skip Token", description: "Skip the current word or question with no penalty", price_gems: 12, effects: {} }, + reveal_hint: { name: "Magnifying Glass", description: "Free reveal: the next letter, or eliminate 2 wrong options", price_gems: 8, effects: { mode: "letter" } }, + translate_hint: { name: "Quick Translate", description: "Reveal the Vietnamese translation of the current word", price_gems: 8, effects: { mode: "translation" } }, + mistake_shield: { name: "Shield", description: "Negate the next wrong answer or life loss", price_gems: 18, effects: {} }, + extra_heart: { name: "Extra Heart", description: "Start Hangman with one extra life", price_gems: 15, effects: { lives: 1 } }, + lucky_clover: { name: "Lucky Clover", description: "30% chance to auto-correct your next wrong answer", price_gems: 20, effects: { chance: 0.3 } }, + score_multiplier: { name: "Score Multiplier", description: "Double your in-game score for the rest of this session", price_gems: 22, effects: { multiplier: 2 } }, + pair_swap: { name: "Pair Swap", description: "Undo one wrong match in Matching Game for a free retry", price_gems: 10, effects: {} }, +}; + +type ShopItemForm = { + name: string; + description: string; + item_type: string; + price_gems: number; + is_available: boolean; + stock_quantity: number | undefined; + icon_url: string; + effectsText: string; +}; + +const EMPTY_FORM: ShopItemForm = { + name: "", + description: "", + item_type: "streak_freeze", + price_gems: 50, + is_available: true, + stock_quantity: undefined, + icon_url: "", + effectsText: "{}", +}; export const ShopPage = () => { const { t } = useI18n(); @@ -24,20 +92,29 @@ export const ShopPage = () => { const [showForm, setShowForm] = useState(false); const [editingId, setEditingId] = useState(null); const [saving, setSaving] = useState(false); - const [form, setForm] = useState({ - name: "", - description: "", - item_type: "streak_freeze", - price_gems: 50, - is_available: true, - stock_quantity: undefined as number | undefined, - }); + const [effectsError, setEffectsError] = useState(null); + const [form, setForm] = useState(EMPTY_FORM); const resetForm = () => { - setForm({ name: "", description: "", item_type: "streak_freeze", price_gems: 50, is_available: true, stock_quantity: undefined }); + setForm(EMPTY_FORM); + setEffectsError(null); setEditingId(null); }; + const applyTemplate = (itemType: string) => { + const template = ITEM_TYPE_TEMPLATES[itemType]; + if (!template) return; + setForm((prev) => ({ + ...prev, + item_type: itemType, + name: template.name, + description: template.description, + price_gems: template.price_gems, + effectsText: JSON.stringify(template.effects, null, 2), + })); + setEffectsError(null); + }; + const loadItems = async () => { setLoading(true); setError(null); @@ -62,12 +139,25 @@ export const ShopPage = () => { price_gems: item.price_gems, is_available: item.is_available, stock_quantity: item.stock_quantity ?? undefined, + icon_url: item.icon_url ?? "", + effectsText: JSON.stringify(item.effects ?? {}, null, 2), }); + setEffectsError(null); setShowForm(true); }; const handleSave = async (e: React.FormEvent) => { e.preventDefault(); + + let effects: Record | undefined; + try { + effects = form.effectsText.trim() ? JSON.parse(form.effectsText) : {}; + } catch { + setEffectsError(t.shop.effectsInvalidJson); + return; + } + setEffectsError(null); + setSaving(true); setError(null); try { @@ -78,9 +168,20 @@ export const ShopPage = () => { price_gems: form.price_gems, is_available: form.is_available, stock_quantity: form.stock_quantity ?? undefined, + icon_url: form.icon_url || undefined, + effects, }); } else { - await createShopItem(form); + await createShopItem({ + name: form.name, + description: form.description, + item_type: form.item_type, + price_gems: form.price_gems, + is_available: form.is_available, + stock_quantity: form.stock_quantity, + icon_url: form.icon_url || undefined, + effects, + }); } resetForm(); setShowForm(false); @@ -197,9 +298,31 @@ export const ShopPage = () => { {/* Create/Edit Modal */} {showForm && (
setShowForm(false)}> -
e.stopPropagation()} style={{ maxWidth: 520 }}> +
e.stopPropagation()} style={{ maxWidth: 560 }}>

{editingId ? t.shop.editItem : t.shop.createNew}

+
+ + {!editingId && ( + + )} +