diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index 291e5fd5..5b75d74d 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -9,10 +9,7 @@ on: - "package.json" - "package-lock.json" - "vite.config.ts" -<<<<<<< HEAD -======= - "vitest.config.ts" ->>>>>>> main - "tsconfig*.json" - "eslint.config.js" @@ -28,7 +25,6 @@ jobs: uses: actions/setup-node@v4 with: node-version: 22 - # We are REMOVING the cache line temporarily to force a clean download - name: Install dependencies run: npm ci --legacy-peer-deps @@ -36,30 +32,11 @@ jobs: - name: Type check run: npm run typecheck - - name: Type check - run: npm run typecheck - - name: Lint run: npm run lint -<<<<<<< HEAD - - name: i18n scan - run: npm run i18n:scan - - - name: Test - run: npm run test:frontend -======= - name: Test with coverage run: npm run test:coverage - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: ./coverage/lcov.info - flags: frontend - fail_ci_if_error: false ->>>>>>> main - - name: Build run: npm run build diff --git a/.github/workflows/server-ci.yml b/.github/workflows/server-ci.yml index 44fb628e..654d87c0 100644 --- a/.github/workflows/server-ci.yml +++ b/.github/workflows/server-ci.yml @@ -49,24 +49,10 @@ jobs: run: npm run migrate working-directory: server -<<<<<<< HEAD - - name: Run tests - run: npm test - working-directory: server -======= - name: Verify migrations (apply, idempotency, rollback) run: npm run migrate:verify working-directory: server - - name: Run tests with coverage + - name: Run tests run: npm run test:coverage working-directory: server - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: ./server/coverage/lcov.info - flags: backend - fail_ci_if_error: false ->>>>>>> main diff --git a/contracts/course_milestone/src/lib.rs b/contracts/course_milestone/src/lib.rs index 85846db4..5b057d61 100644 --- a/contracts/course_milestone/src/lib.rs +++ b/contracts/course_milestone/src/lib.rs @@ -6,17 +6,6 @@ use soroban_sdk::{ panic_with_error, symbol_short, }; -<<<<<<< HEAD -// --------------------------------------------------------------------------- -// Storage Constants (assuming ~6s ledger time) -// --------------------------------------------------------------------------- - -const DAY_IN_LEDGERS: u32 = 17_280; -const INSTANCE_BUMP_THRESHOLD: u32 = DAY_IN_LEDGERS; -const INSTANCE_EXTEND_TO: u32 = DAY_IN_LEDGERS * 30; // 30 days -const PERSISTENT_BUMP_THRESHOLD: u32 = DAY_IN_LEDGERS; -const PERSISTENT_EXTEND_TO: u32 = DAY_IN_LEDGERS * 365; // 1 year -======= /// A single entry in a batch verification call. #[derive(Clone, Debug, Eq, PartialEq)] #[contracttype] @@ -39,7 +28,6 @@ const INSTANCE_BUMP_THRESHOLD: u32 = DAY_IN_LEDGERS; const INSTANCE_EXTEND_TO: u32 = DAY_IN_LEDGERS * 30; const PERSISTENT_BUMP_THRESHOLD: u32 = DAY_IN_LEDGERS; const PERSISTENT_EXTEND_TO: u32 = DAY_IN_LEDGERS * 365; ->>>>>>> main #[contracttype] pub enum DataKey { @@ -51,10 +39,7 @@ pub enum DataKey { EnrolledCourses(Address), Course(String), CourseIds, -<<<<<<< HEAD -======= CompletedCount(Address, String), ->>>>>>> main } #[derive(Clone, Debug, Eq, PartialEq)] @@ -97,13 +82,7 @@ pub struct EnrolledEventData { const ADMIN_KEY: Symbol = symbol_short!("ADMIN"); const LEARN_TOKEN_KEY: Symbol = symbol_short!("LRN_TKN"); -<<<<<<< HEAD -const PAUSED_KEY: Symbol = symbol_short!("PAUSED"); // ✅ NEW -const PERSISTENT_TTL_THRESHOLD: u32 = 100; -const PERSISTENT_TTL_BUMP: u32 = 1_000; -======= const PAUSED_KEY: Symbol = symbol_short!("PAUSED"); ->>>>>>> main #[contracterror] #[derive(Clone, Copy, Debug, Eq, PartialEq, PartialOrd, Ord)] @@ -117,11 +96,6 @@ pub enum Error { CourseAlreadyComplete = 6, InvalidMilestones = 7, CourseAlreadyExists = 8, -<<<<<<< HEAD - AlreadyEnrolled = 9, - NotEnrolled = 10, - DuplicateSubmission = 11, -======= NotEnrolled = 9, DuplicateSubmission = 10, ContractPaused = 11, @@ -129,7 +103,6 @@ pub enum Error { InvalidState = 13, AlreadyCompleted = 14, InvalidReward = 15, ->>>>>>> main } #[derive(Clone, Debug, Eq, PartialEq)] @@ -167,95 +140,12 @@ impl CourseMilestone { } admin.require_auth(); env.storage().instance().set(&ADMIN_KEY, &admin); -<<<<<<< HEAD - env.storage() - .instance() - .set(&LEARN_TOKEN_KEY, &learn_token_contract); - } - - // Design decision: only the initialized admin can create course records. - // Design decision: course IDs are unique forever and removed courses stay on-chain as inactive records. - // Design decision: milestone_count must be > 0 so course configuration cannot represent an empty track. - pub fn add_course(env: Env, admin: Address, course_id: String, milestone_count: u32) { - Self::require_initialized(&env); - Self::require_admin(&env, &admin); - - if milestone_count == 0 { - panic_with_error!(&env, Error::InvalidMilestones); - } - - let course_key = DataKey::Course(course_id.clone()); - if env.storage().persistent().has(&course_key) { - panic_with_error!(&env, Error::CourseAlreadyExists); - } - - let config = CourseConfig { - milestone_count, - active: true, - }; - env.storage().persistent().set(&course_key, &config); - - let mut course_ids: Vec = env - .storage() - .persistent() - .get(&DataKey::CourseIds) - .unwrap_or_else(|| Vec::new(&env)); - course_ids.push_back(course_id); - env.storage() - .persistent() - .set(&DataKey::CourseIds, &course_ids); - } - - // Design decision: removed courses are marked inactive instead of deleted so historical references remain valid. - pub fn remove_course(env: Env, admin: Address, course_id: String) { - Self::require_initialized(&env); - Self::require_admin(&env, &admin); - - let course_key = DataKey::Course(course_id); - let mut config: CourseConfig = env - .storage() - .persistent() - .get(&course_key) - .unwrap_or_else(|| panic_with_error!(&env, Error::CourseNotFound)); - config.active = false; - env.storage().persistent().set(&course_key, &config); - } - - pub fn get_course(env: Env, course_id: String) -> Option { - let course_key = DataKey::Course(course_id); - env.storage().persistent().get(&course_key) - } - - pub fn list_courses(env: Env) -> Vec { - let course_ids: Vec = env - .storage() - .persistent() - .get(&DataKey::CourseIds) - .unwrap_or_else(|| Vec::new(&env)); - - let mut active_courses = Vec::new(&env); - let mut i = 0; - while i < course_ids.len() { - let course_id = course_ids.get(i).unwrap(); - let course_key = DataKey::Course(course_id.clone()); - let config: Option = env.storage().persistent().get(&course_key); - if let Some(current) = config { - if current.active { - active_courses.push_back(course_id); - } - } - i += 1; - } - - active_courses -======= upgrade::init(&env); env.storage() .instance() .set(&LEARN_TOKEN_KEY, &learn_token_contract); Self::extend_instance(&env); ->>>>>>> main } pub fn add_course(env: Env, admin: Address, course_id: String, milestone_count: u32) { @@ -386,23 +276,11 @@ impl CourseMilestone { } } -<<<<<<< HEAD - fn assert_not_paused(env: &Env) { - if Self::is_paused(env.clone()) { - panic!("Contract is paused"); - } - -======= ->>>>>>> main pub fn enroll(env: Env, learner: Address, course_id: String) { Self::assert_not_paused(&env); Self::require_initialized(&env); learner.require_auth(); -<<<<<<< HEAD - // Enrollment is only allowed for registered, active courses. -======= ->>>>>>> main if !Self::is_course_active(&env, &course_id) { panic_with_error!(&env, Error::CourseNotFound); } @@ -427,15 +305,7 @@ impl CourseMilestone { env.events().publish( (symbol_short!("enrolled"),), -<<<<<<< HEAD - SubmittedEventData { - learner, - course_id, - evidence_uri: String::from_str(&env, ""), - }, -======= EnrolledEventData { learner, course_id }, ->>>>>>> main ); } @@ -443,11 +313,7 @@ impl CourseMilestone { let key = DataKey::Enrollment(learner, course_id); let enrolled = env.storage().persistent().get(&key).unwrap_or(false); if enrolled { -<<<<<<< HEAD - Self::bump_persistent_ttl(&env, &key); -======= Self::extend_persistent(&env, &key); ->>>>>>> main } enrolled } @@ -459,14 +325,7 @@ impl CourseMilestone { milestone_id: u32, evidence_uri: String, ) { -<<<<<<< HEAD - if Self::is_paused(env.clone()) { - panic_with_error!(&env, Error::ContractPaused); - } - -======= Self::assert_not_paused(&env); ->>>>>>> main Self::require_initialized(&env); learner.require_auth(); @@ -482,11 +341,7 @@ impl CourseMilestone { .unwrap_or(MilestoneStatus::NotStarted); Self::bump_persistent_ttl(&env, &state_key); -<<<<<<< HEAD - if current_state != MilestoneStatus::NotStarted { -======= if current_state == MilestoneStatus::Pending || current_state == MilestoneStatus::Approved { ->>>>>>> main panic_with_error!(&env, Error::DuplicateSubmission); } @@ -542,11 +397,7 @@ impl CourseMilestone { let key = DataKey::MilestoneSubmission(learner, course_id, milestone_id); let submission: Option = env.storage().persistent().get(&key); if submission.is_some() { -<<<<<<< HEAD - Self::bump_persistent_ttl(&env, &key); -======= Self::extend_persistent(&env, &key); ->>>>>>> main } submission } @@ -558,12 +409,6 @@ impl CourseMilestone { .persistent() .get(&key) .unwrap_or_else(|| Vec::new(&env)); -<<<<<<< HEAD - if courses.len() > 0 { - Self::bump_persistent_ttl(&env, &key); - } - courses -======= if !courses.is_empty() { Self::extend_persistent(&env, &key); } @@ -656,7 +501,6 @@ impl CourseMilestone { let admin: Address = env.storage().instance().get(&ADMIN_KEY).unwrap(); admin.require_auth(); upgrade::apply(&env, &admin, &new_wasm_hash); ->>>>>>> main } pub fn get_version(env: Env) -> String { @@ -678,27 +522,15 @@ impl CourseMilestone { Self::require_initialized(&env); admin.require_auth(); -<<<<<<< HEAD - // Verify admin authorization -======= ->>>>>>> main let stored_admin: Address = env.storage().instance().get(&ADMIN_KEY).unwrap(); if admin != stored_admin { panic_with_error!(&env, Error::Unauthorized); } -<<<<<<< HEAD - // Check if learner is enrolled -======= ->>>>>>> main if !Self::is_enrolled(env.clone(), learner.clone(), course_id.clone()) { panic_with_error!(&env, Error::NotEnrolled); } -<<<<<<< HEAD - // Check current milestone state -======= ->>>>>>> main let state_key = DataKey::MilestoneState(learner.clone(), course_id.clone(), milestone_id); let current_state = env .storage() @@ -710,37 +542,16 @@ impl CourseMilestone { panic_with_error!(&env, Error::InvalidState); } -<<<<<<< HEAD - // Update milestone state to Approved - env.storage() - .persistent() - .set(&state_key, &MilestoneStatus::Approved); - - // Get learn token contract address and mint tokens -======= env.storage() .persistent() .set(&state_key, &MilestoneStatus::Approved); let completed_key = DataKey::Completed(learner.clone(), course_id.clone(), milestone_id); env.storage().persistent().set(&completed_key, &true); ->>>>>>> main let learn_token_address: Address = env.storage().instance().get(&LEARN_TOKEN_KEY).unwrap(); let learn_token_client = LearnTokenClient::new(&env, &learn_token_address); learn_token_client.mint(&learner, &tokens_amount); -<<<<<<< HEAD - // Emit milestone completed event - env.events().publish( - symbol_short!("milestone_completed"), - MilestoneCompleted { - learner: learner.clone(), - course_id: course_id.clone().parse::().unwrap_or(0), - milestones_completed: milestone_id, - tokens_minted: tokens_amount, - }, - ); -======= Self::extend_persistent(&env, &state_key); Self::extend_persistent(&env, &completed_key); @@ -842,7 +653,6 @@ impl CourseMilestone { i += 1; } ->>>>>>> main } pub fn reject_milestone( @@ -859,40 +669,19 @@ impl CourseMilestone { Self::require_initialized(&env); admin.require_auth(); -<<<<<<< HEAD - // Verify admin authorization -======= ->>>>>>> main let stored_admin: Address = env.storage().instance().get(&ADMIN_KEY).unwrap(); if admin != stored_admin { panic_with_error!(&env, Error::Unauthorized); } -<<<<<<< HEAD - // Check if learner is enrolled -======= ->>>>>>> main if !Self::is_enrolled(env.clone(), learner.clone(), course_id.clone()) { panic_with_error!(&env, Error::NotEnrolled); } -<<<<<<< HEAD - // Check current milestone state -======= ->>>>>>> main let state_key = DataKey::MilestoneState(learner.clone(), course_id.clone(), milestone_id); let current_state = env .storage() .persistent() -<<<<<<< HEAD - .get::<_, CourseConfig>(&course_key) - { - Some(config) => { - Self::extend_persistent(env, &course_key); - config.active - }, - None => false, -======= .get::<_, MilestoneStatus>(&state_key) .unwrap_or(MilestoneStatus::NotStarted); @@ -911,12 +700,9 @@ impl CourseMilestone { fn require_initialized(env: &Env) { if !env.storage().instance().has(&ADMIN_KEY) { panic_with_error!(env, Error::NotInitialized); ->>>>>>> main } } -<<<<<<< HEAD -======= fn require_admin(env: &Env, admin: &Address) { admin.require_auth(); let stored_admin: Address = env @@ -988,6 +774,5 @@ impl CourseMilestone { } } ->>>>>>> main #[cfg(test)] mod test; diff --git a/contracts/course_milestone/src/test.rs b/contracts/course_milestone/src/test.rs index a216f4f6..917c3087 100644 --- a/contracts/course_milestone/src/test.rs +++ b/contracts/course_milestone/src/test.rs @@ -1,13 +1,6 @@ extern crate std; use soroban_sdk::{ -<<<<<<< HEAD - Address, Env, String, - testutils::{Address as _, Ledger, LedgerInfo}, -}; - -use crate::{CourseConfig, CourseMilestone, CourseMilestoneClient, DataKey, Error, MilestoneStatus}; -======= Address, BytesN, Env, IntoVal, String, Symbol, Val, Vec, contract, contractimpl, contracttype, symbol_short, testutils::{Address as _, Events as _, MockAuth, MockAuthInvoke}, @@ -41,19 +34,11 @@ impl MockLearnToken { .unwrap_or(0_i128) } } ->>>>>>> main fn sid(env: &Env, value: &str) -> String { String::from_str(env, value) } -<<<<<<< HEAD -fn setup() -> (Env, Address, Address, Address, CourseMilestoneClient<'static>) { - let env = Env::default(); - let admin = Address::generate(&env); - let learn_token = Address::generate(&env); - let learn_token_address = Address::generate(&env); -======= fn authorize(env: &Env, address: &Address, contract: &Address, fn_name: &'static str, args: T) where T: IntoVal>, @@ -80,14 +65,9 @@ fn setup() -> ( let env = Env::default(); let admin = Address::generate(&env); let learn_token_id = env.register(MockLearnToken, ()); ->>>>>>> main let contract_id = env.register(CourseMilestone, ()); let client = CourseMilestoneClient::new(&env, &contract_id); -<<<<<<< HEAD - client.initialize(&admin, &learn_token_contract); - (env, contract_id, admin, client) -======= let token_client = MockLearnTokenClient::new(&env, &learn_token_id); authorize( @@ -166,7 +146,6 @@ fn submit_milestone( ), ); client.submit_milestone(learner, course_id, &milestone_id, evidence_uri); ->>>>>>> main } // ======================= @@ -211,75 +190,20 @@ fn enrolls_learner_in_active_course() { let learner = Address::generate(&env); let course_id = sid(&env, "rust-101"); -<<<<<<< HEAD - client.add_course(&admin, &course_id, &10); - client.enroll(&learner, &course_id); -======= add_course(&env, &contract_id, &admin, &client, &course_id, 3); enroll(&env, &contract_id, &learner, &client, &course_id); ->>>>>>> main assert!(client.is_enrolled(&learner, &course_id)); } #[test] fn duplicate_enroll_fails() { -<<<<<<< HEAD - let (env, _contract_id, _admin, _learn_token_address, client) = setup(); - let learner = Address::generate(&env); - let course_id = sid(&env, "rust-101"); - - client.enroll(&learner, &course_id); - - let result = client.try_enroll(&learner, &course_id); - assert_eq!( - result.err(), - Some(Ok(soroban_sdk::Error::from_contract_error( - Error::Unauthorized as u32 - ))) - ); -} - -#[test] -fn enroll_fails_when_not_initialized() { - let env = Env::default(); - let admin = Address::generate(&env); - let learn_token_address = Address::generate(&env); - let contract_id = env.register(CourseMilestone, ()); - let client = CourseMilestoneClient::new(&env, &contract_id); - let learner = Address::generate(&env); - let course_id = sid(&env, "rust-101"); - - let result = client.try_enroll(&learner, &course_id); - assert_eq!( - result.err(), - Some(Ok(soroban_sdk::Error::from_contract_error( - Error::NotInitialized as u32 - ))) - ); -} - -// ======================= -// ✅ SUBMIT MILESTONE TESTS -// ======================= - -#[test] -fn enrolled_learner_can_submit_once_and_submission_is_stored() { - let (env, _contract_id, _admin, client) = setup(); -======= let (env, contract_id, admin, _token_id, client, _token_client) = setup(); ->>>>>>> main let learner = Address::generate(&env); let course_id = sid(&env, "rust-101"); -<<<<<<< HEAD - client.add_course(&admin, &course_id, &5); - client.enroll(&learner, &course_id); - client.submit_milestone(&learner, &course_id, &1, &evidence_uri); -======= add_course(&env, &contract_id, &admin, &client, &course_id, 3); enroll(&env, &contract_id, &learner, &client, &course_id); ->>>>>>> main authorize( &env, @@ -329,13 +253,8 @@ fn submit_milestone_stores_pending_submission() { } #[test] -<<<<<<< HEAD -fn non_enrolled_learner_cannot_submit() { - let (env, _contract_id, _admin, _learn_token_address, client) = setup(); -======= fn verify_milestone_mints_lrn_and_marks_completion() { let (env, contract_id, admin, _token_id, client, token_client) = setup(); ->>>>>>> main let learner = Address::generate(&env); let course_id = sid(&env, "rust-101"); let evidence_uri = sid(&env, "ipfs://proof"); @@ -722,14 +641,8 @@ fn complete_milestone_fails_without_admin_auth() { let attacker = Address::generate(&env); let course_id = sid(&env, "rust-101"); -<<<<<<< HEAD - client.add_course(&admin, &course_id, &8); - client.enroll(&learner, &course_id); - client.submit_milestone(&learner, &course_id, &7, &evidence_uri); -======= add_course(&env, &contract_id, &admin, &client, &course_id, 3); enroll(&env, &contract_id, &learner, &client, &course_id); ->>>>>>> main authorize( &env, @@ -1042,221 +955,6 @@ fn state_persists_after_upgrade() { let learner = Address::generate(&env); let course_id = sid(&env, "soroban-101"); -<<<<<<< HEAD - let status = client.get_milestone_state(&learner, &course_id, &1); - assert_eq!(status, MilestoneStatus::NotStarted); -} - -#[test] -fn get_milestone_status_returns_pending_after_submission() { - let (env, _contract_id, admin, client) = setup(); - let learner = Address::generate(&env); - let course_id = sid(&env, "rust-101"); - let evidence = sid(&env, "ipfs://bafy-proof"); - - client.add_course(&admin, &course_id, &4); - client.enroll(&learner, &course_id); - client.submit_milestone(&learner, &course_id, &1, &evidence); - - let status = client.get_milestone_state(&learner, &course_id, &1); - assert_eq!(status, MilestoneStatus::Pending); -} - -#[test] -fn get_milestone_status_returns_approved_after_verification() { - let (env, _contract_id, admin, _learn_token_address, client) = setup(); - let learner = Address::generate(&env); - let course_id = sid(&env, "rust-101"); - let evidence = sid(&env, "ipfs://bafy-proof"); - - client.enroll(&learner, &course_id); - client.submit_milestone(&learner, &course_id, &1, &evidence); - client.verify_milestone(&admin, &learner, &course_id, &1, &100); - - let status = client.get_milestone_status(&learner, &course_id, &1); - assert_eq!(status, MilestoneStatus::Approved); -} - -#[test] -fn get_milestone_status_returns_rejected_after_rejection() { - let (env, _contract_id, admin, _learn_token_address, client) = setup(); - let learner = Address::generate(&env); - let course_id = sid(&env, "rust-101"); - let evidence = sid(&env, "ipfs://bafy-proof"); - - client.enroll(&learner, &course_id); - client.submit_milestone(&learner, &course_id, &1, &evidence); - client.reject_milestone(&admin, &learner, &course_id, &1); - - let status = client.get_milestone_status(&learner, &course_id, &1); - assert_eq!(status, MilestoneStatus::Rejected); -} - -#[test] -fn get_milestone_status_not_started_for_unsubmitted_milestone() { - let (env, _contract_id, admin, client) = setup(); - let learner = Address::generate(&env); - let course_id = sid(&env, "rust-101"); - let evidence = sid(&env, "ipfs://bafy-proof"); - - client.add_course(&admin, &course_id, &4); - client.enroll(&learner, &course_id); - client.submit_milestone(&learner, &course_id, &1, &evidence); - - let status = client.get_milestone_state(&learner, &course_id, &2); - assert_eq!(status, MilestoneStatus::NotStarted); -} - -// ======================= -// ✅ LRN MINTING INTEGRATION TESTS -// ======================= - -#[test] -fn verify_milestone_mints_lrn_tokens() { - let (env, _contract_id, admin, learn_token_address, client) = setup(); - let learner = Address::generate(&env); - let course_id = sid(&env, "rust-101"); - let evidence_uri = sid(&env, "ipfs://bafy-proof"); - - client.enroll(&learner, &course_id); - client.submit_milestone(&learner, &course_id, &1, &evidence_uri); - - // This would require a mock learn token contract for full testing - // For now, we just verify the function call succeeds - client.verify_milestone(&admin, &learner, &course_id, &1, &100); - - let status = client.get_milestone_status(&learner, &course_id, &1); - assert_eq!(status, MilestoneStatus::Approved); -} - -// ======================= -// ✅ ENROLLED COURSES TESTS -// ======================= - -#[test] -fn get_enrolled_courses_returns_empty_for_new_learner() { - let (env, _contract_id, _admin, _learn_token_address, client) = setup(); - let learner = Address::generate(&env); - - let courses = client.get_enrolled_courses(&learner); - assert_eq!(courses.len(), 0); -} - -#[test] -fn get_enrolled_courses_returns_enrolled_courses() { - let (env, _contract_id, _admin, client) = setup(); - let learner = Address::generate(&env); - - client.add_course(&admin, &sid(&env, "rust-101"), &3); - client.add_course(&admin, &sid(&env, "defi-201"), &6); - client.enroll(&learner, &sid(&env, "rust-101")); - client.enroll(&learner, &sid(&env, "defi-201")); - - let courses = client.get_enrolled_courses(&learner); - assert_eq!(courses.len(), 2); - assert_eq!(courses.get(0).unwrap(), sid(&env, "rust-101")); - assert_eq!(courses.get(1).unwrap(), sid(&env, "defi-201")); -} - -#[test] -fn get_enrolled_courses_is_per_learner() { - let (env, _contract_id, _admin, client) = setup(); - let learner_a = Address::generate(&env); - let learner_b = Address::generate(&env); - - client.add_course(&admin, &sid(&env, "rust-101"), &3); - client.add_course(&admin, &sid(&env, "defi-201"), &6); - client.enroll(&learner_a, &sid(&env, "rust-101")); - client.enroll(&learner_a, &sid(&env, "defi-201")); - client.enroll(&learner_b, &sid(&env, "rust-101")); - - assert_eq!(client.get_enrolled_courses(&learner_a).len(), 2); - assert_eq!(client.get_enrolled_courses(&learner_b).len(), 1); -} - -// ======================= -// ✅ VERSION TESTS -// ======================= - -#[test] -fn get_version_returns_semver() { - let (env, _contract_id, _admin, _learn_token_address, client) = setup(); - assert_eq!(client.get_version(), String::from_str(&env, "1.0.0")); -} - -#[test] -fn add_course_and_get_course_work() { - let (env, _contract_id, admin, client) = setup(); - let course_id = sid(&env, "soroban-101"); - - client.add_course(&admin, &course_id, &12); - - let course = client - .get_course(&course_id) - .expect("course should be stored after add"); - assert_eq!( - course, - CourseConfig { - milestone_count: 12, - active: true, - } - ); -} - -#[test] -fn list_courses_returns_empty_when_none_exist() { - let (_env, _contract_id, _admin, client) = setup(); - assert_eq!(client.list_courses().len(), 0); -} - -#[test] -fn list_courses_returns_only_active_courses() { - let (env, _contract_id, admin, client) = setup(); - let course_a = sid(&env, "rust-101"); - let course_b = sid(&env, "defi-201"); - - client.add_course(&admin, &course_a, &5); - client.add_course(&admin, &course_b, &7); - client.remove_course(&admin, &course_b); - - let courses = client.list_courses(); - assert_eq!(courses.len(), 1); - assert_eq!(courses.get(0).unwrap(), course_a); -} - -#[test] -fn remove_course_marks_course_inactive() { - let (env, _contract_id, admin, client) = setup(); - let course_id = sid(&env, "rust-101"); - let learner = Address::generate(&env); - - client.add_course(&admin, &course_id, &4); - client.remove_course(&admin, &course_id); - - let stored = client - .get_course(&course_id) - .expect("course should remain stored"); - assert_eq!(stored.active, false); - - let result = client.try_enroll(&learner, &course_id); - assert_eq!( - result.err(), - Some(Ok(soroban_sdk::Error::from_contract_error( - Error::CourseNotFound as u32 - ))) - ); -} - -#[test] -fn pause_blocks_enroll() { - let (env, _contract_id, admin, client) = setup(); - let learner = Address::generate(&env); - let course_id = sid(&env, "rust-101"); - - client.pause(&admin); - - let result = client.try_enroll(&learner, &course_id); -======= add_course(&env, &contract_id, &admin, &client, &course_id, 3); enroll(&env, &contract_id, &learner, &client, &course_id); @@ -1276,7 +974,6 @@ fn pause_blocks_enroll() { .unwrap_or(false) }); let stored_hash = env.as_contract(&contract_id, || crate::upgrade::current_hash(&env)); ->>>>>>> main assert_eq!( stored_course, @@ -1295,17 +992,11 @@ fn benchmark_costs() { let learner = Address::generate(&env); let course_id = sid(&env, "rust-101"); -<<<<<<< HEAD - client.add_course(&admin, &course_id, &1); - client.enroll(&learner, &course_id); - client.pause(&admin); -======= // 1. Benchmark add_course env.cost_estimate().budget().reset_unlimited(); add_course(&env, &contract_id, &admin, &client, &course_id, 3); let add_instr = env.cost_estimate().budget().cpu_instruction_cost(); let add_mem = env.cost_estimate().budget().memory_bytes_cost(); ->>>>>>> main // 2. Benchmark enroll env.cost_estimate().budget().reset_unlimited(); @@ -1332,21 +1023,3 @@ fn benchmark_costs() { std::println!("enroll: instr={}, mem={}", enroll_instr, enroll_mem); std::println!("complete_milestone: instr={}, mem={}", comp_instr, comp_mem); } -<<<<<<< HEAD - -#[test] -fn unpause_restores_functionality() { - let (env, _contract_id, admin, client) = setup(); - let learner = Address::generate(&env); - let course_id = sid(&env, "rust-101"); - - client.add_course(&admin, &course_id, &1); - client.pause(&admin); - client.unpause(&admin); - - client.enroll(&learner, &course_id); - - assert!(client.is_enrolled(&learner, &course_id)); -} -======= ->>>>>>> main diff --git a/contracts/fungible-allowlist/src/lib.rs b/contracts/fungible-allowlist/src/lib.rs index 19b3d9d1..81987c47 100644 --- a/contracts/fungible-allowlist/src/lib.rs +++ b/contracts/fungible-allowlist/src/lib.rs @@ -1,13 +1,7 @@ -<<<<<<< HEAD -use soroban_sdk::{ - Address, Env, Vec, contract, contracterror, contractimpl, contracttype, panic_with_error, - symbol_short, -======= #![no_std] use soroban_sdk::{ Address, Env, Vec, contract, contracterror, contractimpl, contracttype, panic_with_error, ->>>>>>> main }; #[contracterror] @@ -23,44 +17,20 @@ pub enum AllowlistError { pub enum DataKey { Admin, IsAllowed(Address), -<<<<<<< HEAD - Allowlist, -} - -#[contract] -// Placeholder — implementation pending. - -use soroban_sdk::{contract, contractimpl}; - -#[contract] -======= } #[contract] ->>>>>>> main pub struct FungibleAllowlist; #[contractimpl] impl FungibleAllowlist { -<<<<<<< HEAD - /// Initialize the contract with an administrator. -======= ->>>>>>> main pub fn initialize(env: Env, admin: Address) { if env.storage().instance().has(&DataKey::Admin) { panic_with_error!(&env, AllowlistError::AlreadyInitialized); } env.storage().instance().set(&DataKey::Admin, &admin); -<<<<<<< HEAD - let empty_list: Vec
= Vec::new(&env); - env.storage().instance().set(&DataKey::Allowlist, &empty_list); - } - - /// Add an account to the allowlist. Only the administrator can call this. -======= } ->>>>>>> main pub fn add_to_allowlist(env: Env, admin: Address, account: Address) { admin.require_auth(); let stored_admin: Address = env @@ -73,23 +43,12 @@ impl FungibleAllowlist { } if !Self::is_allowed(env.clone(), account.clone()) { -<<<<<<< HEAD - env.storage().persistent().set(&DataKey::IsAllowed(account.clone()), &true); - let mut list: Vec
= env.storage().instance().get(&DataKey::Allowlist).unwrap(); - list.push_back(account); - env.storage().instance().set(&DataKey::Allowlist, &list); - } - } - - /// Remove an account from the allowlist. Only the administrator can call this. -======= env.storage() .persistent() .set(&DataKey::IsAllowed(account.clone()), &true); } } ->>>>>>> main pub fn remove_from_allowlist(env: Env, admin: Address, account: Address) { admin.require_auth(); let stored_admin: Address = env @@ -102,21 +61,6 @@ impl FungibleAllowlist { } if Self::is_allowed(env.clone(), account.clone()) { -<<<<<<< HEAD - env.storage().persistent().set(&DataKey::IsAllowed(account.clone()), &false); - let list: Vec
= env.storage().instance().get(&DataKey::Allowlist).unwrap(); - let mut new_list: Vec
= Vec::new(&env); - for x in list.iter() { - if x != account { - new_list.push_back(x); - } - } - env.storage().instance().set(&DataKey::Allowlist, &new_list); - } - } - - /// Returns true if the account is in the allowlist. -======= env.storage() .persistent() .set(&DataKey::IsAllowed(account.clone()), &false); @@ -128,7 +72,6 @@ impl FungibleAllowlist { } } ->>>>>>> main pub fn is_allowed(env: Env, account: Address) -> bool { env.storage() .persistent() @@ -136,23 +79,11 @@ impl FungibleAllowlist { .unwrap_or(false) } -<<<<<<< HEAD - /// Returns the complete list of allowed accounts. - pub fn get_allowlist(env: Env) -> Vec
{ - env.storage() - .instance() - .get(&DataKey::Allowlist) - .unwrap_or_else(|| Vec::new(&env)) - } - - /// Transfer administrative role to a new address. -======= pub fn get_allowlist(env: Env) -> Vec
{ // Enumeration should be rebuilt off-chain from events or indexers. Vec::new(&env) } ->>>>>>> main pub fn set_admin(env: Env, admin: Address, new_admin: Address) { admin.require_auth(); let stored_admin: Address = env @@ -170,11 +101,7 @@ impl FungibleAllowlist { #[cfg(test)] mod test { use super::*; -<<<<<<< HEAD - use soroban_sdk::{testutils::Address as _, Env}; -======= use soroban_sdk::{Env, testutils::Address as _}; ->>>>>>> main #[test] fn test_allowlist_flow() { @@ -190,36 +117,6 @@ mod test { assert_eq!(client.is_allowed(&alice), false); assert_eq!(client.get_allowlist().len(), 0); -<<<<<<< HEAD - // Add Alice - env.mock_all_auths(); - client.add_to_allowlist(&admin, &alice); - assert_eq!(client.is_allowed(&alice), true); - assert_eq!(client.get_allowlist().len(), 1); - assert_eq!(client.get_allowlist().get(0).unwrap(), alice); - - // Add Bob - client.add_to_allowlist(&admin, &bob); - assert_eq!(client.is_allowed(&bob), true); - assert_eq!(client.get_allowlist().len(), 2); - - // Remove Alice - client.remove_from_allowlist(&admin, &alice); - assert_eq!(client.is_allowed(&alice), false); - assert_eq!(client.get_allowlist().len(), 1); - assert_eq!(client.get_allowlist().get(0).unwrap(), bob); - - // Set Admin - let new_admin = Address::generate(&env); - client.set_admin(&admin, &new_admin); - - // Try to add with old admin (should fail due to unauthorized) - // Wait, mock_all_auths is on, so we should test real auth maybe? - // But for unit test, we can just verify it works with new admin. - client.add_to_allowlist(&new_admin, &alice); - assert_eq!(client.is_allowed(&alice), true); - } -======= env.mock_all_auths(); client.add_to_allowlist(&admin, &alice); @@ -268,5 +165,4 @@ mod test { std::println!("initialize: instr={}, mem={}", init_instr, init_mem); std::println!("add_to_allowlist: instr={}, mem={}", add_instr, add_mem); } ->>>>>>> main } diff --git a/contracts/governance_token/src/lib.rs b/contracts/governance_token/src/lib.rs index 0c909a1b..eaee9337 100644 --- a/contracts/governance_token/src/lib.rs +++ b/contracts/governance_token/src/lib.rs @@ -14,13 +14,8 @@ //! Implements: https://github.com/bakeronchain/learnvault/issues/11 use soroban_sdk::{ -<<<<<<< HEAD - Address, Env, String, Symbol, contract, contracterror, contractevent, contractimpl, contracttype, - panic_with_error, symbol_short, -======= Address, BytesN, Env, String, Symbol, contract, contracterror, contractevent, contractimpl, contracttype, panic_with_error, symbol_short, ->>>>>>> main }; use learnvault_shared::upgrade; @@ -98,8 +93,6 @@ pub struct GOVBurned { pub amount: i128, } -<<<<<<< HEAD -======= #[contractevent] #[derive(Clone, Debug, Eq, PartialEq)] pub struct GOVPaused { @@ -135,7 +128,6 @@ pub struct GOVApproved { pub amount: i128, } ->>>>>>> main // --------------------------------------------------------------------------- // Contract // --------------------------------------------------------------------------- @@ -161,11 +153,7 @@ impl GovernanceToken { .instance() .set(&SYMBOL_KEY, &symbol_short!("GOV")); env.storage().instance().set(&DECIMALS_KEY, &7_u32); -<<<<<<< HEAD - -======= ->>>>>>> main Self::extend_instance(&env); } @@ -175,10 +163,7 @@ impl GovernanceToken { /// Mint `amount` GOV to `to`. Admin only. pub fn mint(env: Env, to: Address, amount: i128) { -<<<<<<< HEAD -======= Self::assert_not_paused(&env); ->>>>>>> main Self::extend_instance(&env); let admin: Address = env .storage() @@ -383,10 +368,7 @@ impl GovernanceToken { /// Transfer `amount` GOV from `from` to `to`. Requires `from` auth. pub fn transfer(env: Env, from: Address, to: Address, amount: i128) { -<<<<<<< HEAD -======= Self::assert_not_paused(&env); ->>>>>>> main Self::extend_instance(&env); from.require_auth(); if amount <= 0 { @@ -408,13 +390,6 @@ impl GovernanceToken { ) { Self::assert_not_paused(&env); owner.require_auth(); -<<<<<<< HEAD - let key = DataKey::Allowance(owner, spender); - env.storage() - .temporary() - .set(&key, &amount); - env.storage().temporary().extend_ttl(&key, TEMP_BUMP_THRESHOLD, TEMP_EXTEND_TO); -======= let current_ledger = env.ledger().sequence(); if expiration_ledger < current_ledger { panic_with_error!(&env, GOVError::InvalidExpiration); @@ -432,7 +407,6 @@ impl GovernanceToken { amount, } .publish(&env); ->>>>>>> main } /// Transfer `amount` from `from` to `to` using `spender`'s allowance. @@ -444,16 +418,6 @@ impl GovernanceToken { let current_ledger = env.ledger().sequence(); let allow_key = DataKey::Allowance(from.clone(), spender.clone()); -<<<<<<< HEAD - let allowance: i128 = env.storage().temporary().get(&allow_key).unwrap_or(0); - if allowance < amount { - panic_with_error!(&env, GOVError::InsufficientFunds); - } - env.storage() - .temporary() - .set(&allow_key, &(allowance - amount)); - env.storage().temporary().extend_ttl(&allow_key, TEMP_BUMP_THRESHOLD, TEMP_EXTEND_TO); -======= let (allowance, expiration_ledger): (i128, u32) = env.storage().persistent().get(&allow_key).unwrap_or((0, 0)); @@ -475,7 +439,6 @@ impl GovernanceToken { Self::extend_persistent(&env, &allow_key); } ->>>>>>> main Self::_debit(&env, &from, amount); Self::_credit(&env, &to, amount); @@ -567,11 +530,6 @@ impl GovernanceToken { pub fn allowance(env: Env, owner: Address, spender: Address) -> i128 { let key = DataKey::Allowance(owner, spender); -<<<<<<< HEAD - if let Some(allowance) = env.storage().temporary().get::<_, i128>(&key) { - env.storage().temporary().extend_ttl(&key, TEMP_BUMP_THRESHOLD, TEMP_EXTEND_TO); - allowance -======= if let Some((allowance, expiration_ledger)) = env.storage().persistent().get::<_, (i128, u32)>(&key) { @@ -581,7 +539,6 @@ impl GovernanceToken { Self::extend_persistent(&env, &key); allowance } ->>>>>>> main } else { 0 } @@ -657,8 +614,6 @@ impl GovernanceToken { } } -<<<<<<< HEAD -======= fn assert_not_paused(env: &Env) { let paused: bool = env.storage().instance().get(&PAUSED_KEY).unwrap_or(false); if paused { @@ -666,7 +621,6 @@ impl GovernanceToken { } } ->>>>>>> main fn extend_instance(env: &Env) { env.storage() .instance() @@ -1210,8 +1164,6 @@ mod test { assert_eq!(client.allowance(&alice, &bob), 0); } -<<<<<<< HEAD -======= #[test] fn approve_rejects_past_expiration() { let e = Env::default(); @@ -1273,7 +1225,6 @@ mod test { assert_eq!(client.allowance(&alice, &bob), 15); } ->>>>>>> main // --- burning --- #[test] @@ -1341,8 +1292,6 @@ mod test { client.burn(&alice, &40); assert_eq!(client.get_voting_power(&bob), 60); } -<<<<<<< HEAD -======= // --- pause / unpause --- @@ -1482,5 +1431,4 @@ mod test { std::println!("mint: instr={}, mem={}", mint_instr, mint_mem); std::println!("transfer: instr={}, mem={}", xfer_instr, xfer_mem); } ->>>>>>> main } diff --git a/contracts/learn_token/src/lib.rs b/contracts/learn_token/src/lib.rs index d1ead7c2..25743309 100644 --- a/contracts/learn_token/src/lib.rs +++ b/contracts/learn_token/src/lib.rs @@ -101,11 +101,7 @@ impl LearnToken { .instance() .set(&SYMBOL_KEY, &String::from_str(&env, "LRN")); env.storage().instance().set(&DECIMALS_KEY, &7_u32); -<<<<<<< HEAD - -======= ->>>>>>> main Self::extend_instance(&env); } @@ -145,10 +141,6 @@ impl LearnToken { .set(&DataKey::TotalSupply, &(supply + amount)); // Extend persistent storage for balance entries -<<<<<<< HEAD - env.storage().persistent().extend_ttl(&bal_key, PERSISTENT_BUMP_THRESHOLD, PERSISTENT_EXTEND_TO); - env.storage().persistent().extend_ttl(&DataKey::TotalSupply, PERSISTENT_BUMP_THRESHOLD, PERSISTENT_EXTEND_TO); -======= env.storage().persistent().extend_ttl( &bal_key, PERSISTENT_BUMP_THRESHOLD, @@ -159,7 +151,6 @@ impl LearnToken { PERSISTENT_BUMP_THRESHOLD, PERSISTENT_EXTEND_TO, ); ->>>>>>> main // 5. Emit event env.events() @@ -226,15 +217,11 @@ impl LearnToken { Self::extend_instance(&env); let key = DataKey::Balance(account); if let Some(bal) = env.storage().persistent().get::<_, i128>(&key) { -<<<<<<< HEAD - env.storage().persistent().extend_ttl(&key, PERSISTENT_BUMP_THRESHOLD, PERSISTENT_EXTEND_TO); -======= env.storage().persistent().extend_ttl( &key, PERSISTENT_BUMP_THRESHOLD, PERSISTENT_EXTEND_TO, ); ->>>>>>> main bal } else { 0 @@ -245,15 +232,11 @@ impl LearnToken { Self::extend_instance(&env); let key = DataKey::TotalSupply; if let Some(supply) = env.storage().persistent().get::<_, i128>(&key) { -<<<<<<< HEAD - env.storage().persistent().extend_ttl(&key, PERSISTENT_BUMP_THRESHOLD, PERSISTENT_EXTEND_TO); -======= env.storage().persistent().extend_ttl( &key, PERSISTENT_BUMP_THRESHOLD, PERSISTENT_EXTEND_TO, ); ->>>>>>> main supply } else { 0 diff --git a/contracts/learn_token/src/test.rs b/contracts/learn_token/src/test.rs index 8ee63d41..590a2021 100644 --- a/contracts/learn_token/src/test.rs +++ b/contracts/learn_token/src/test.rs @@ -748,8 +748,6 @@ fn reputation_score_matches_balance_division() { ); } } -<<<<<<< HEAD -======= #[test] fn upgrade_requires_admin_auth() { @@ -843,4 +841,3 @@ fn benchmark_costs() { std::println!("mint: instr={}, mem={}", mint_instr, mint_mem); std::println!("reputation_score: instr={}, mem={}", rep_instr, rep_mem); } ->>>>>>> main diff --git a/contracts/milestone_escrow/src/lib.rs b/contracts/milestone_escrow/src/lib.rs index 2865ff32..8117993c 100644 --- a/contracts/milestone_escrow/src/lib.rs +++ b/contracts/milestone_escrow/src/lib.rs @@ -5,11 +5,6 @@ use soroban_sdk::{ contracttype, panic_with_error, symbol_short, }; -<<<<<<< HEAD -const ADMIN_KEY: Symbol = symbol_short!("ADMIN"); -const TREASURY_KEY: Symbol = symbol_short!("TREAS"); -const INACTIVITY_WINDOW_KEY: Symbol = symbol_short!("INACT_W"); -======= use learnvault_shared::upgrade; pub use upgrade::ContractUpgraded; @@ -23,7 +18,6 @@ pub struct Config { pub treasury: Address, pub inactivity_window: u64, } ->>>>>>> main #[derive(Clone)] #[contracttype] @@ -92,30 +86,12 @@ pub struct EscrowReclaimed { #[contractimpl] impl MilestoneEscrow { -<<<<<<< HEAD - pub fn initialize( - env: Env, - admin: Address, - treasury: Address, - inactivity_window_seconds: u64, - ) { - if env.storage().instance().has(&ADMIN_KEY) { -======= pub fn initialize(env: Env, admin: Address, treasury: Address, inactivity_window_seconds: u64) { if env.storage().instance().has(&CONFIG_KEY) { ->>>>>>> main panic_with_error!(&env, Error::AlreadyInitialized); } admin.require_auth(); -<<<<<<< HEAD - // Keep 30 days (30 * 24 * 60 * 60) as the recommended default at deployment. - env.storage().instance().set(&ADMIN_KEY, &admin); - env.storage().instance().set(&TREASURY_KEY, &treasury); - env.storage() - .instance() - .set(&INACTIVITY_WINDOW_KEY, &inactivity_window_seconds); -======= let config = Config { admin, treasury, @@ -123,7 +99,6 @@ impl MilestoneEscrow { }; env.storage().instance().set(&CONFIG_KEY, &config); upgrade::init(&env); ->>>>>>> main } pub fn create_escrow( @@ -204,13 +179,8 @@ impl MilestoneEscrow { let now = env.ledger().timestamp(); let inactive_for = now.saturating_sub(record.last_activity); -<<<<<<< HEAD - let inactivity_window = Self::inactivity_window(&env); - if inactive_for < inactivity_window { -======= let config = Self::get_config(&env); if inactive_for < config.inactivity_window { ->>>>>>> main panic_with_error!(&env, Error::InactivityNotReached); } @@ -228,10 +198,7 @@ impl MilestoneEscrow { record.released_amount = record.total_amount; record.last_activity = now; env.storage().persistent().set(&key, &record); -<<<<<<< HEAD -======= ->>>>>>> main EscrowReclaimed { proposal_id, scholar: record.scholar.clone(), diff --git a/contracts/milestone_escrow/src/test.rs b/contracts/milestone_escrow/src/test.rs index 23517a13..36aa6b4e 100644 --- a/contracts/milestone_escrow/src/test.rs +++ b/contracts/milestone_escrow/src/test.rs @@ -269,8 +269,6 @@ fn reclaim_inactive_uses_configured_window_size() { } #[test] -<<<<<<< HEAD -======= fn reclaim_inactive_emits_event() { let (env, contract_id, _token, _admin, _treasury, scholar) = setup(); let client = MilestoneEscrowClient::new(&env, &contract_id); @@ -290,7 +288,6 @@ fn reclaim_inactive_emits_event() { } #[test] ->>>>>>> main fn get_escrow_reflects_each_stage_of_the_full_flow() { let (env, contract_id, _token, _admin, _treasury, scholar) = setup(); let client = MilestoneEscrowClient::new(&env, &contract_id); diff --git a/contracts/scholar_nft/src/lib.rs b/contracts/scholar_nft/src/lib.rs index 787738b6..f1320a44 100644 --- a/contracts/scholar_nft/src/lib.rs +++ b/contracts/scholar_nft/src/lib.rs @@ -2,13 +2,8 @@ #![allow(deprecated)] use soroban_sdk::{ -<<<<<<< HEAD - Address, Env, String, contract, contracterror, contractimpl, contracttype, panic_with_error, - symbol_short, -======= Address, BytesN, Env, String, Symbol, Vec, contract, contracterror, contractimpl, contracttype, panic_with_error, symbol_short, ->>>>>>> main }; contract, contracterror, contractimpl, contracttype, panic_with_error, symbol_short, Address, Env, String, Symbol, @@ -34,11 +29,6 @@ const INSTANCE_EXTEND_TO: u32 = DAY_IN_LEDGERS * 30; const TTL_MIN: u32 = DAY_IN_LEDGERS; const TTL_MAX: u32 = DAY_IN_LEDGERS * 365; -<<<<<<< HEAD -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- -======= const ADMIN_KEY: Symbol = symbol_short!("ADMIN"); const TOKEN_COUNTER_KEY: Symbol = symbol_short!("TCOUNTER"); @@ -49,23 +39,16 @@ pub struct ScholarMetadata { pub metadata_uri: String, pub issued_at: u64, } ->>>>>>> main #[derive(Clone, Debug, Eq, PartialEq)] #[contracttype] pub enum DataKey { Admin, Counter, -<<<<<<< HEAD - Owner(u64), // token_id -> Address - TokenUri(u64), // token_id -> String - Revoked(u64), // token_id -> String (reason) -======= Owner(u64), TokenUri(u64), Revoked(u64), Metadata(u64), ->>>>>>> main } #[derive(Clone, Debug, Eq, PartialEq)] @@ -96,18 +79,12 @@ pub struct RevokedEventData { pub reason: String, } -<<<<<<< HEAD -// --------------------------------------------------------------------------- -// Errors -// --------------------------------------------------------------------------- -======= #[derive(Clone, Debug, Eq, PartialEq)] #[contracttype] pub struct AdminChangedEventData { pub old_admin: Address, pub new_admin: Address, } ->>>>>>> main #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] @@ -122,13 +99,6 @@ pub enum ScholarNFTError { Soulbound = 7, AlreadyRevoked = 8, } -<<<<<<< HEAD - -// --------------------------------------------------------------------------- -// Contract -// --------------------------------------------------------------------------- -======= ->>>>>>> main #[contract] pub struct ScholarNFT; @@ -139,21 +109,6 @@ impl ScholarNFT { if env.storage().instance().has(&ADMIN_KEY) { panic_with_error!(&env, ScholarNFTError::AlreadyInitialized); } -<<<<<<< HEAD - env.storage().instance().set(&DataKey::Admin, &admin); - env.storage().instance().set(&DataKey::Counter, &0_u64); - - // Emit initialized event - env.events().publish( - (symbol_short!("init"),), - InitializedEventData { admin }, - ); - - Self::extend_instance(&env); - } - - /// Mint a new soulbound NFT. Only callable by admin. -======= admin.require_auth(); env.storage().instance().set(&ADMIN_KEY, &admin); upgrade::init(&env); @@ -167,7 +122,6 @@ impl ScholarNFT { Self::extend_instance(&env); } ->>>>>>> main pub fn mint(env: Env, to: Address, metadata_uri: String) -> u64 { let admin = Self::get_admin(&env); admin.require_auth(); @@ -178,20 +132,6 @@ impl ScholarNFT { panic_with_error!(&env, ScholarNFTError::TokenExists); } -<<<<<<< HEAD - env.storage().persistent().set(&key, &to); - env.storage().persistent().set(&DataKey::TokenUri(token_id), &uri); - - Self::extend_persistent(&env, &key); - Self::extend_persistent(&env, &DataKey::TokenUri(token_id)); - - // Emit minted event - env.events().publish( - (symbol_short!("minted"), token_id, to.clone()), - MintEventData { - owner: to, - metadata_uri: uri, -======= env.storage().persistent().set(&owner_key, &to); Self::extend_persistent(&env, &owner_key); @@ -215,26 +155,12 @@ impl ScholarNFT { MintEventData { token_id, owner: to, ->>>>>>> main }, ); token_id } -<<<<<<< HEAD - /// Revoke a credential. Only callable by admin. - pub fn revoke(env: Env, admin: Address, token_id: u64, reason: String) { - admin.require_auth(); - let stored_admin = Self::get_admin(&env); - if admin != stored_admin { - panic_with_error!(&env, Error::Unauthorized); - } - - let key = DataKey::Owner(token_id); - if !env.storage().persistent().has(&key) { - panic_with_error!(&env, Error::TokenNotFound); -======= pub fn revoke(env: Env, token_id: u64, reason: String) { let admin = Self::get_admin(&env); admin.require_auth(); @@ -242,27 +168,16 @@ impl ScholarNFT { let owner_key = DataKey::Owner(token_id); if !env.storage().persistent().has(&owner_key) { panic_with_error!(&env, ScholarNFTError::TokenNotFound); ->>>>>>> main } let revoked_key = DataKey::Revoked(token_id); if env.storage().persistent().has(&revoked_key) { -<<<<<<< HEAD - return; -======= panic_with_error!(&env, ScholarNFTError::AlreadyRevoked); ->>>>>>> main } env.storage().persistent().set(&revoked_key, &reason); Self::extend_persistent(&env, &revoked_key); -<<<<<<< HEAD - Self::extend_persistent(&env, &revoked_key); - - // Emit revoked event -======= ->>>>>>> main env.events().publish( (symbol_short!("revoked"), token_id), RevokedEventData { token_id, reason }, @@ -283,12 +198,6 @@ impl ScholarNFT { new_admin, }, ); -<<<<<<< HEAD - panic_with_error!(&env, Error::Soulbound) - } - - /// Returns the owner of the token. -======= Self::extend_instance(&env); } @@ -366,17 +275,12 @@ impl ScholarNFT { panic_with_error!(&env, ScholarNFTError::Soulbound); } ->>>>>>> main pub fn owner_of(env: Env, token_id: u64) -> Address { Self::extend_instance(&env); let revoked_key = DataKey::Revoked(token_id); if env.storage().persistent().has(&revoked_key) { Self::extend_persistent(&env, &revoked_key); -<<<<<<< HEAD - panic_with_error!(&env, Error::TokenRevoked); -======= panic_with_error!(&env, ScholarNFTError::TokenRevoked); ->>>>>>> main } let key = DataKey::Owner(token_id); @@ -385,26 +289,9 @@ impl ScholarNFT { owner } else { panic_with_error!(&env, ScholarNFTError::TokenNotFound); -<<<<<<< HEAD - } - } - - /// Returns the URI of the token. - pub fn token_uri(env: Env, token_id: u64) -> String { - let key = DataKey::TokenUri(token_id); - if let Some(uri) = env.storage().persistent().get::<_, String>(&key) { - uri - } else { - panic_with_error!(&env, Error::TokenNotFound); } } - /// Returns true if the token is a valid credential (not revoked and exists). -======= - } - } - ->>>>>>> main pub fn has_credential(env: Env, token_id: u64) -> bool { Self::extend_instance(&env); let revoked_key = DataKey::Revoked(token_id); @@ -483,13 +370,7 @@ impl ScholarNFT { } fn extend_persistent(env: &Env, key: &DataKey) { -<<<<<<< HEAD - env.storage() - .persistent() - .extend_ttl(key, PERSISTENT_BUMP_THRESHOLD, PERSISTENT_EXTEND_TO); -======= env.storage().persistent().extend_ttl(key, TTL_MIN, TTL_MAX); ->>>>>>> main } } diff --git a/contracts/scholar_nft/src/test.rs b/contracts/scholar_nft/src/test.rs index 92f92adb..6cd0b2ba 100644 --- a/contracts/scholar_nft/src/test.rs +++ b/contracts/scholar_nft/src/test.rs @@ -1,20 +1,12 @@ #![cfg(test)] use crate::{ -<<<<<<< HEAD - InitializedEventData, MintEventData, ScholarNFT, ScholarNFTClient, -}; -use soroban_sdk::{ - testutils::{Address as _, Events as _, MockAuth, MockAuthInvoke}, - Address, Env, IntoVal, String, symbol_short, -======= AdminChangedEventData, DataKey, InitializedEventData, MintEventData, ScholarMetadata, ScholarNFT, ScholarNFTClient, ScholarNFTError, }; use soroban_sdk::{ Address, BytesN, Env, IntoVal, String, symbol_short, testutils::{Address as _, Events as _, MockAuth, MockAuthInvoke, storage::Persistent}, ->>>>>>> main }; fn setup(env: &Env) -> (Address, Address, ScholarNFTClient) { @@ -212,30 +204,6 @@ fn token_uri_returns_metadata_uri() { } #[test] -<<<<<<< HEAD -#[should_panic(expected = "Error(Auth, InvalidAction)")] -fn non_admin_mint_panics() { - let env = Env::default(); - let (_, _admin, client) = setup(&env); - let hacker = Address::generate(&env); - let scholar = Address::generate(&env); - - // hacker tries to mint - this will fail admin.require_auth() - env.mock_auths(&[MockAuth { - address: &hacker, - invoke: &MockAuthInvoke { - contract: &client.address, - fn_name: "mint", - args: (&scholar, cid(&env, "ipfs://hax")).into_val(&env), - sub_invokes: &[], - }, - }]); - - client.mint(&scholar, &cid(&env, "ipfs://hax")); -} - -#[test] -======= fn get_metadata_uri_round_trip() { let env = Env::default(); let (_, _admin, client) = setup(&env); @@ -273,26 +241,18 @@ fn non_admin_mint_panics() { } #[test] ->>>>>>> main #[should_panic(expected = "Error(Contract, #1)")] fn test_double_initialize_reverts() { let env = Env::default(); let (_, admin, client) = setup(&env); -<<<<<<< HEAD -======= env.mock_all_auths(); ->>>>>>> main client.initialize(&admin); } #[test] fn test_revoke_flow() { let env = Env::default(); -<<<<<<< HEAD - let (_, admin, client) = setup(&env); -======= let (_, _admin, client) = setup(&env); ->>>>>>> main let recipient = Address::generate(&env); let reason = String::from_str(&env, "Cheater"); @@ -300,11 +260,7 @@ fn test_revoke_flow() { let token_id = client.mint(&recipient, &cid(&env, "ipfs://test")); assert!(client.has_credential(&token_id)); -<<<<<<< HEAD - client.revoke(&admin, &token_id, &reason); -======= client.revoke(&token_id, &reason); ->>>>>>> main assert!(!client.has_credential(&token_id)); assert!(client.is_revoked(&token_id)); @@ -315,21 +271,13 @@ fn test_revoke_flow() { #[should_panic(expected = "Error(Contract, #5)")] fn test_owner_of_revoked_fails() { let env = Env::default(); -<<<<<<< HEAD - let (_, admin, client) = setup(&env); -======= let (_, _admin, client) = setup(&env); ->>>>>>> main let recipient = Address::generate(&env); let reason = String::from_str(&env, "Plagiarism"); env.mock_all_auths(); let token_id = client.mint(&recipient, &cid(&env, "ipfs://test")); -<<<<<<< HEAD - client.revoke(&admin, &token_id, &reason); -======= client.revoke(&token_id, &reason); ->>>>>>> main client.owner_of(&token_id); } @@ -338,33 +286,13 @@ fn test_owner_of_revoked_fails() { #[should_panic(expected = "Error(Auth, InvalidAction)")] fn test_unauthorized_revoke_fails() { let env = Env::default(); -<<<<<<< HEAD - let (_, _admin, client) = setup(&env); -======= let (contract_id, _admin, client) = setup(&env); ->>>>>>> main let scholar = Address::generate(&env); let hacker = Address::generate(&env); let reason = String::from_str(&env, "Hax"); env.mock_all_auths(); let token_id = client.mint(&scholar, &cid(&env, "ipfs://test")); -<<<<<<< HEAD - - // hacker tries to revoke - mock_auths to mimic hacker's call - env.mock_auths(&[MockAuth { - address: &hacker, - invoke: &MockAuthInvoke { - contract: &client.address, - fn_name: "revoke", - args: (&hacker, token_id, reason.clone()).into_val(&env), - sub_invokes: &[], - }, - }]); - client.revoke(&hacker, &token_id, &reason); -} - -======= env.mock_auths(&[MockAuth { address: &hacker, @@ -378,48 +306,30 @@ fn test_unauthorized_revoke_fails() { client.revoke(&token_id, &reason); } ->>>>>>> main #[test] #[should_panic(expected = "Error(Contract, #4)")] fn test_revoke_non_existent_token_panics() { let env = Env::default(); -<<<<<<< HEAD - let (_, admin, client) = setup(&env); -======= let (_, _admin, client) = setup(&env); ->>>>>>> main let token_id = 999u64; let reason = String::from_str(&env, "Testing"); env.mock_all_auths(); -<<<<<<< HEAD - client.revoke(&admin, &token_id, &reason); -======= client.revoke(&token_id, &reason); ->>>>>>> main } #[test] #[should_panic(expected = "Error(Contract, #8)")] fn test_revoke_already_revoked_panics() { let env = Env::default(); -<<<<<<< HEAD - let (_, admin, client) = setup(&env); -======= let (_, _admin, client) = setup(&env); ->>>>>>> main let scholar = Address::generate(&env); let reason = String::from_str(&env, "Reason"); env.mock_all_auths(); let token_id = client.mint(&scholar, &cid(&env, "ipfs://test")); -<<<<<<< HEAD - client.revoke(&admin, &token_id, &reason); - client.revoke(&admin, &token_id, &reason); -======= client.revoke(&token_id, &reason); client.revoke(&token_id, &reason); ->>>>>>> main } #[test] @@ -434,20 +344,12 @@ fn initialize_emits_event() { let events = env.events().all(); let found = events.iter().any(|(_, topics, data)| { -<<<<<<< HEAD - topics.contains(&symbol_short!("init").into_val(&env)) - && { - let d: InitializedEventData = data.clone().into_val(&env); - d == InitializedEventData { admin: admin.clone() } - } -======= topics.contains(&symbol_short!("init").into_val(&env)) && { let d: InitializedEventData = data.clone().into_val(&env); d == InitializedEventData { admin: admin.clone(), } } ->>>>>>> main }); assert!(found, "initialized event not found"); } @@ -468,34 +370,16 @@ fn mint_emits_event() { && topics.contains(&token_id.into_val(&env)) && { let d: MintEventData = data.clone().into_val(&env); -<<<<<<< HEAD - d == MintEventData { owner: scholar.clone(), metadata_uri: uri.clone() } -======= d == MintEventData { token_id, owner: scholar.clone(), } ->>>>>>> main } }); assert!(found, "mint event not found"); } #[test] -<<<<<<< HEAD -#[should_panic(expected = "Error(Contract, #7)")] -fn transfer_attempt_panics() { - let env = Env::default(); - let (_, _admin, client) = setup(&env); - let from = Address::generate(&env); - let to = Address::generate(&env); - let uri = cid(&env, "ipfs://test"); - - env.mock_all_auths(); - let token_id = client.mint(&from, &uri); - - client.transfer(&from, &to, &token_id); -======= fn transfer_admin_emits_event() { let env = Env::default(); let (_, old_admin, client) = setup(&env); @@ -533,7 +417,6 @@ fn transfer_panics_with_soulbound_error() { ScholarNFTError::Soulbound as u32 ))) ); ->>>>>>> main } #[test] @@ -542,19 +425,6 @@ fn transfer_attempt_reverts_soulbound() { let (_, _admin, client) = setup(&env); let from = Address::generate(&env); let to = Address::generate(&env); -<<<<<<< HEAD - let uri = cid(&env, "ipfs://test"); - - env.mock_all_auths(); - let token_id = client.mint(&from, &uri); - - // Use try_transfer to verify the specific Soulbound error (#7) - let res = client.try_transfer(&from, &to, &token_id); - assert!(res.is_err()); - - // Note: event emission on panic cannot be verified via env.events().all() - // as Soroban rolls back events on contract failure. -======= env.mock_all_auths(); let token_id = client.mint(&from, &cid(&env, "ipfs://test")); @@ -680,5 +550,4 @@ fn benchmark_costs() { std::println!("initialize: instr={}, mem={}", init_instr, init_mem); std::println!("mint: instr={}, mem={}", mint_instr, mint_mem); std::println!("get_all_scholars: instr={}, mem={}", get_instr, get_mem); ->>>>>>> main } diff --git a/contracts/scholarship_treasury/src/lib.rs b/contracts/scholarship_treasury/src/lib.rs index 24e8512e..645eba9d 100644 --- a/contracts/scholarship_treasury/src/lib.rs +++ b/contracts/scholarship_treasury/src/lib.rs @@ -6,13 +6,10 @@ use soroban_sdk::{ contractimpl, contracttype, panic_with_error, symbol_short, }; -<<<<<<< HEAD -======= use learnvault_shared::upgrade; pub use upgrade::ContractUpgraded; ->>>>>>> main // --------------------------------------------------------------------------- // Storage Constants (assuming ~6s ledger time) // --------------------------------------------------------------------------- @@ -48,8 +45,6 @@ pub enum DataKey { Scholar(Address), VoteCast(u32, Address), // (proposal_id, voter) -> bool FinalizedProposal(u32), // proposal_id -> ProposalStatus (set by finalize_proposal) -<<<<<<< HEAD -======= } #[contractevent(topics = ["proposal_executed"])] @@ -67,7 +62,6 @@ pub struct ProposalCancelled { #[topic] pub proposal_id: u32, pub cancelled_by: Address, ->>>>>>> main } #[derive(Clone)] @@ -205,10 +199,6 @@ impl ScholarshipTreasury { env.storage().instance().set(&SCHOLARS_KEY, &0_u32); env.storage().instance().set(&DONORS_KEY, &0_u32); env.storage().instance().set(&PAUSED_KEY, &false); -<<<<<<< HEAD - - Self::extend_instance(&env); -======= env.storage() .instance() .set(&MIN_LRN_TO_PROPOSE_KEY, &0_i128); @@ -253,7 +243,6 @@ impl ScholarshipTreasury { panic_with_error!(&env, Error::InvalidAmount); } env.storage().instance().set(&APPROVAL_BPS_KEY, &new_bps); ->>>>>>> main } pub fn pause(env: Env) { @@ -609,11 +598,7 @@ impl ScholarshipTreasury { env.storage() .persistent() .set(&applicant_key, &proposal_ids); -<<<<<<< HEAD - -======= ->>>>>>> main Self::extend_persistent(&env, &applicant_key); env.storage() .instance() @@ -776,12 +761,6 @@ impl ScholarshipTreasury { } let total_votes = proposal.yes_votes + proposal.no_votes; -<<<<<<< HEAD - let quorum_met = total_gov > 0 - && total_votes - .checked_mul(10_000) - .map(|tv| tv / total_gov >= MIN_QUORUM_BPS) -======= let quorum_threshold = Self::get_quorum(env.clone()); let approval_bps = Self::get_approval_bps(env.clone()); @@ -791,7 +770,6 @@ impl ScholarshipTreasury { .yes_votes .checked_mul(10_000) .map(|v| (v / total_votes) as u32 > approval_bps) ->>>>>>> main .unwrap_or(false); let status = if passed { diff --git a/contracts/upgrade_timelock_vault/src/lib.rs b/contracts/upgrade_timelock_vault/src/lib.rs index ab6203ad..1d40e239 100644 --- a/contracts/upgrade_timelock_vault/src/lib.rs +++ b/contracts/upgrade_timelock_vault/src/lib.rs @@ -264,11 +264,7 @@ impl UpgradeTimelockVault { /// Returns true if the timelock has expired for the given contract. pub fn is_upgrade_ready(env: Env, contract_address: Address) -> bool { if let Some(proposal) = Self::get_upgrade_proposal(env.clone(), contract_address) { -<<<<<<< HEAD - let timelock_duration = Self::get_timelock_duration(env.clone()); -======= let config = Self::get_config(&env); ->>>>>>> main let current_time = env.ledger().timestamp(); current_time >= proposal.queued_at + config.timelock_duration } else { @@ -292,11 +288,7 @@ impl UpgradeTimelockVault { #[cfg(test)] mod test { use super::*; -<<<<<<< HEAD - use soroban_sdk::testutils::{Address as _, BytesN as _, Ledger}; -======= use soroban_sdk::testutils::{Address as _, Ledger}; ->>>>>>> main use soroban_sdk::{Address, BytesN, Env, IntoVal, contractclient}; #[contractclient(name = "UpgradeTimelockVaultClient")] @@ -332,15 +324,10 @@ mod test { fn test_initialize() { let env = create_env(); let admin = create_admin(&env); -<<<<<<< HEAD - let contract = - UpgradeTimelockVaultClient::new(&env, &env.register(UpgradeTimelockVault {}, ())); -======= let contract = UpgradeTimelockVaultClient::new( &env, &env.register_contract(None, UpgradeTimelockVault {}), ); ->>>>>>> main contract.initialize(&admin); @@ -349,14 +336,6 @@ mod test { } #[test] -<<<<<<< HEAD - #[should_panic(expected = "Error(Contract, #1)")] - fn test_initialize_twice_fails() { - let env = create_env(); - let admin = create_admin(&env); - let contract = - UpgradeTimelockVaultClient::new(&env, &env.register(UpgradeTimelockVault {}, ())); -======= #[should_panic(expected = "Error(Contract, #6)")] fn test_initialize_twice_fails() { let env = create_env(); @@ -365,7 +344,6 @@ mod test { &env, &env.register_contract(None, UpgradeTimelockVault {}), ); ->>>>>>> main contract.initialize(&admin); contract.initialize(&admin); @@ -375,15 +353,10 @@ mod test { fn test_set_timelock_duration() { let env = create_env(); let admin = create_admin(&env); -<<<<<<< HEAD - let contract = - UpgradeTimelockVaultClient::new(&env, &env.register(UpgradeTimelockVault {}, ())); -======= let contract = UpgradeTimelockVaultClient::new( &env, &env.register_contract(None, UpgradeTimelockVault {}), ); ->>>>>>> main contract.initialize(&admin); @@ -408,15 +381,10 @@ mod test { let env = create_env(); let admin = create_admin(&env); let unauthorized = create_admin(&env); -<<<<<<< HEAD - let contract = - UpgradeTimelockVaultClient::new(&env, &env.register(UpgradeTimelockVault {}, ())); -======= let contract = UpgradeTimelockVaultClient::new( &env, &env.register_contract(None, UpgradeTimelockVault {}), ); ->>>>>>> main contract.initialize(&admin); @@ -438,15 +406,10 @@ mod test { let admin = create_admin(&env); let contract_addr = create_contract(&env); let wasm_hash = create_wasm_hash(&env); -<<<<<<< HEAD - let contract = - UpgradeTimelockVaultClient::new(&env, &env.register(UpgradeTimelockVault {}, ())); -======= let contract = UpgradeTimelockVaultClient::new( &env, &env.register_contract(None, UpgradeTimelockVault {}), ); ->>>>>>> main env.ledger().set_timestamp(1000); contract.initialize(&admin); @@ -477,15 +440,10 @@ mod test { let admin = create_admin(&env); let contract_addr = create_contract(&env); let wasm_hash = create_wasm_hash(&env); -<<<<<<< HEAD - let contract = - UpgradeTimelockVaultClient::new(&env, &env.register(UpgradeTimelockVault {}, ())); -======= let contract = UpgradeTimelockVaultClient::new( &env, &env.register_contract(None, UpgradeTimelockVault {}), ); ->>>>>>> main contract.initialize(&admin); @@ -518,15 +476,10 @@ mod test { let admin = create_admin(&env); let contract_addr = create_contract(&env); let wasm_hash = create_wasm_hash(&env); -<<<<<<< HEAD - let contract = - UpgradeTimelockVaultClient::new(&env, &env.register(UpgradeTimelockVault {}, ())); -======= let contract = UpgradeTimelockVaultClient::new( &env, &env.register_contract(None, UpgradeTimelockVault {}), ); ->>>>>>> main contract.initialize(&admin); @@ -561,15 +514,10 @@ mod test { let admin = create_admin(&env); let contract_addr = create_contract(&env); let wasm_hash = create_wasm_hash(&env); -<<<<<<< HEAD - let contract = - UpgradeTimelockVaultClient::new(&env, &env.register(UpgradeTimelockVault {}, ())); -======= let contract = UpgradeTimelockVaultClient::new( &env, &env.register_contract(None, UpgradeTimelockVault {}), ); ->>>>>>> main contract.initialize(&admin); @@ -595,15 +543,10 @@ mod test { let admin = create_admin(&env); let contract_addr = create_contract(&env); let wasm_hash = create_wasm_hash(&env); -<<<<<<< HEAD - let contract = - UpgradeTimelockVaultClient::new(&env, &env.register(UpgradeTimelockVault {}, ())); -======= let contract = UpgradeTimelockVaultClient::new( &env, &env.register_contract(None, UpgradeTimelockVault {}), ); ->>>>>>> main contract.initialize(&admin); @@ -641,15 +584,10 @@ mod test { let admin = create_admin(&env); let contract_addr = create_contract(&env); let wasm_hash = create_wasm_hash(&env); -<<<<<<< HEAD - let contract = - UpgradeTimelockVaultClient::new(&env, &env.register(UpgradeTimelockVault {}, ())); -======= let contract = UpgradeTimelockVaultClient::new( &env, &env.register_contract(None, UpgradeTimelockVault {}), ); ->>>>>>> main contract.initialize(&admin); @@ -678,8 +616,6 @@ mod test { // Now ready assert!(contract.is_upgrade_ready(&contract_addr)); } -<<<<<<< HEAD -======= #[test] fn benchmark_costs() { @@ -731,5 +667,4 @@ mod test { std::println!("queue_upgrade: instr={}, mem={}", queue_instr, queue_mem); std::println!("execute_upgrade: instr={}, mem={}", exec_instr, exec_mem); } ->>>>>>> main } diff --git a/docs/contracts.md b/docs/contracts.md index e682dc4d..6a3d8873 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -89,12 +89,9 @@ Contracts must be deployed in this order due to cross-contract dependencies: The `UpgradeTimelockVault` implements a dedicated vault pattern for secure contract upgrades with timelock enforcement. -<<<<<<< HEAD -======= For the current V1 in-place upgrade procedure used by the six core contracts, see [contract-upgrades.md](./contract-upgrades.md). ->>>>>>> main ### Security Model diff --git a/docs/token-economics.md b/docs/token-economics.md index 72dc7984..9d45c89f 100644 --- a/docs/token-economics.md +++ b/docs/token-economics.md @@ -1,33 +1,13 @@ # Token Economics -<<<<<<< HEAD -LearnVault uses two tokens because it has two distinct problems to solve: measuring learning (reputation) and governing scholarship disbursement (voting power). Conflating them into one token would break both functions. -======= LearnVault uses two tokens because it has two distinct problems to solve: measuring learning (reputation) and governing scholarship disbursement (voting power). Conflating them into one token would break both functions. ->>>>>>> main --- ## LRN (LearnToken) -<<<<<<< HEAD -LRN is not a financial asset. It is an on-chain reputation score — a number that says how much verified learning a wallet has completed inside the LearnVault system. It cannot be sent, sold, or delegated. - -### How it's earned - -LRN is minted by the `CourseMilestone` contract when a validator approves a milestone submission. The amount minted per milestone is set per track by the admin committee in V1 — there is no global fixed rate. A learner completing a beginner track will earn less LRN than one completing an advanced engineering track. Exact amounts per track are configured at course creation time via `add_course`. - -### What it unlocks - -| Threshold | What it enables | -|---|---| -| Configurable per track | Scholarship eligibility — wallet can be nominated | -| Governance threshold | Eligibility to participate in DAO votes on proposals | - -Reaching these thresholds does not automatically grant anything — it makes the wallet *eligible*. Scholarship disbursement still requires a passing governance vote (see GOV below). -======= LRN is not a financial asset. It is an on-chain reputation score — a number that says how much verified learning a wallet has completed inside the LearnVault system. It cannot be sent, sold, or delegated. @@ -51,19 +31,11 @@ track. Exact amounts per track are configured at course creation time via Reaching these thresholds does not automatically grant anything — it makes the wallet _eligible_. Scholarship disbursement still requires a passing governance vote (see GOV below). ->>>>>>> main ### Why it's non-transferable If LRN could be transferred, the following would happen immediately: -<<<<<<< HEAD -- Wallets with capital but no learning would buy reputation and access scholarships meant for real learners -- A secondary market would form around eligibility thresholds, pricing out genuine participants -- Sybil attackers could launder reputation across fresh wallets to reset eligibility windows - -Soulbound design is not ideological — it is the only mechanism that makes the eligibility threshold meaningful. A score you cannot buy is the only score worth having. -======= - Wallets with capital but no learning would buy reputation and access scholarships meant for real learners - A secondary market would form around eligibility thresholds, pricing out @@ -74,48 +46,26 @@ Soulbound design is not ideological — it is the only mechanism that makes the Soulbound design is not ideological — it is the only mechanism that makes the eligibility threshold meaningful. A score you cannot buy is the only score worth having. ->>>>>>> main ### Supply model - **Cap:** None. LRN is uncapped. -<<<<<<< HEAD -- **Minting:** Exclusively by `contracts/course_milestone/` — no other contract or admin can mint LRN directly. -- **Burning:** No burn mechanic. LRN balances are permanent records of completed work. -======= - **Minting:** Exclusively by `contracts/course_milestone/` — no other contract or admin can mint LRN directly. - **Burning:** No burn mechanic. LRN balances are permanent records of completed work. ->>>>>>> main --- ## GOV (GovernanceToken) -<<<<<<< HEAD -GOV is voting weight in the scholarship DAO. Unlike LRN, it is a transferable token — deliberately so. -======= GOV is voting weight in the scholarship DAO. Unlike LRN, it is a transferable token — deliberately so. ->>>>>>> main ### How it's earned GOV is minted through two paths: -<<<<<<< HEAD -1. **Donation:** 1 USDC deposited to the treasury mints 1 GOV. Donors get governance rights proportional to their contribution. -2. **Learner rewards:** Wallets that cross the top-learner LRN threshold receive a GOV distribution as a reward. This gives high-performing learners a voice in how scholarship funds are allocated. - -### What it does - -GOV holders vote on scholarship disbursement proposals. Votes are weighted by GOV balance. A proposal must reach a quorum and a majority to pass. In V1, proposal creation is permissioned — only wallets above the LRN governance threshold or holding minimum GOV can submit proposals. - -### Why it IS transferable - -Donors need an exit. Locking capital permanently into a governance token with no liquidity would deter serious donors from participating. Transferability also creates secondary market price discovery — if GOV trades at a premium, it is a signal that the community values governance rights, which attracts more donors. If it trades at a discount, that is honest feedback about protocol health. -======= 1. **Donation:** 1 USDC deposited to the treasury mints 1 GOV. Donors get governance rights proportional to their contribution. 2. **Learner rewards:** Wallets that cross the top-learner LRN threshold receive @@ -136,7 +86,6 @@ liquidity would deter serious donors from participating. Transferability also creates secondary market price discovery — if GOV trades at a premium, it is a signal that the community values governance rights, which attracts more donors. If it trades at a discount, that is honest feedback about protocol health. ->>>>>>> main Transferability is a feature, not a compromise. @@ -147,15 +96,11 @@ Transferability is a feature, not a compromise. > ⚠️ **Open design question** > -<<<<<<< HEAD -> The GOV burn mechanic has not been finalized. Options under consideration include burning GOV when a scholarship is disbursed (aligning token supply with treasury outflows), burning on governance participation as a spam deterrent, or no burn at all. This will be resolved before mainnet. Track the discussion in [#139](https://github.com/bakeronchain/learnvault/issues/139). -======= > The GOV burn mechanic has not been finalized. Options under consideration > include burning GOV when a scholarship is disbursed (aligning token supply > with treasury outflows), burning on governance participation as a spam > deterrent, or no burn at all. This will be resolved before mainnet. Track the > discussion in [#139](https://github.com/bakeronchain/learnvault/issues/139). ->>>>>>> main --- @@ -195,12 +140,8 @@ The two tokens are designed to reinforce each other through a feedback loop: [More GOV minted → governance decentralizes] ────┘ ``` -<<<<<<< HEAD -The loop only holds if LRN remains non-transferable. The moment reputation can be bought, step one becomes pay-to-win and the rest of the flywheel breaks. -======= The loop only holds if LRN remains non-transferable. The moment reputation can be bought, step one becomes pay-to-win and the rest of the flywheel breaks. ->>>>>>> main --- @@ -208,14 +149,6 @@ be bought, step one becomes pay-to-win and the rest of the flywheel breaks. V1 ships with the following centralized components. None of this is hidden: -<<<<<<< HEAD -- **Milestone approval** is controlled by a validator committee. There is no on-chain dispute resolution. A validator can reject a valid submission and there is currently no appeal mechanism. -- **Minting permissions** on `contracts/course_milestone/` are set by an admin key. The admin can add courses, set milestone counts, and configure LRN amounts per track. -- **Scholarship disbursement** requires a multisig in V1. Even if a proposal passes governance, the actual USDC transfer goes through a multisig held by the core team. -- **Contract upgrades** are not yet governed on-chain. The team can upgrade contracts unilaterally. - -This is the honest state of V1. It ships this way because the alternative — launching with incomplete decentralization infrastructure and calling it trustless — is worse. -======= - **Milestone approval** is controlled by a validator committee. There is no on-chain dispute resolution. A validator can reject a valid submission and there is currently no appeal mechanism. @@ -231,7 +164,6 @@ This is the honest state of V1. It ships this way because the alternative — la This is the honest state of V1. It ships this way because the alternative — launching with incomplete decentralization infrastructure and calling it trustless — is worse. ->>>>>>> main ### V2 Roadmap @@ -242,39 +174,23 @@ Before admin keys are removed, the following needs to exist: 3. A validator election mechanism governed by GOV holders 4. Time-locked upgrade governance so contract changes require a passing vote -<<<<<<< HEAD -V2 decentralization is not a vague future commitment — it is a prerequisite for removing the admin keys. Until those components exist, the keys stay and this document says so plainly. -======= V2 decentralization is not a vague future commitment — it is a prerequisite for removing the admin keys. Until those components exist, the keys stay and this document says so plainly. ->>>>>>> main --- ## Contract References -<<<<<<< HEAD -| Contract | Path | Role | -|---|---|---| -| `CourseMilestone` | `contracts/course_milestone/` | Milestone approval, LRN minting | -| `ScholarNFT` | `contracts/scholar_nft/` | Soulbound credential on completion | -| Governance (planned) | `contracts/governance/` | GOV voting, proposal execution | -======= | Contract | Path | Role | | -------------------- | ----------------------------- | ---------------------------------- | | `CourseMilestone` | `contracts/course_milestone/` | Milestone approval, LRN minting | | `ScholarNFT` | `contracts/scholar_nft/` | Soulbound credential on completion | | Governance (planned) | `contracts/governance/` | GOV voting, proposal execution | ->>>>>>> main --- ## Further Reading - [README](../README.md) -<<<<<<< HEAD -- [Issue #139 — Token economics explainer](https://github.com/bakeronchain/learnvault/issues/139) -======= - [Issue #139 — Token economics explainer](https://github.com/bakeronchain/learnvault/issues/139) ->>>>>>> main diff --git a/e2e/leaderboard.spec.ts b/e2e/leaderboard.spec.ts new file mode 100644 index 00000000..a71c28cf --- /dev/null +++ b/e2e/leaderboard.spec.ts @@ -0,0 +1,120 @@ +import { expect, test, type Page, type Route } from "@playwright/test" +import { mockHorizonBalances } from "./fixtures/mock-horizon" +import { + E2E_WALLET_ADDRESS, + installMockFreighter, +} from "./fixtures/mock-wallet" + +type MockLeaderboardEntry = { + rank: number + address: string + lrn_balance: string + courses_completed: number +} + +function buildAddress(index: number) { + return `GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA${String(index).padStart(2, "0")}` +} + +function buildRankings(total: number): MockLeaderboardEntry[] { + return Array.from({ length: total }, (_, index) => ({ + rank: index + 1, + address: buildAddress(index + 1), + lrn_balance: String(5000 - index * 50), + courses_completed: 24 - (index % 8), + })) +} + +async function fulfillJson(route: Route, body: unknown, status = 200) { + await route.fulfill({ + status, + contentType: "application/json", + body: JSON.stringify(body), + }) +} + +async function installLeaderboardMocks(page: Page) { + const rankings = buildRankings(13) + const yourRank = + rankings.find((entry) => entry.address === E2E_WALLET_ADDRESS)?.rank ?? 7 + + await page.route("**/api/scholars/leaderboard**", async (route) => { + const url = new URL(route.request().url()) + const pageParam = Number(url.searchParams.get("page") ?? "1") + const limitParam = Number(url.searchParams.get("limit") ?? "10") + const start = (pageParam - 1) * limitParam + const end = start + limitParam + + return fulfillJson(route, { + rankings: rankings.slice(start, end), + total: rankings.length, + your_rank: yourRank, + }) + }) +} + +async function ensureWalletConnected(page: Page) { + const connectedRank = page.getByTestId("leaderboard-your-rank") + if ((await connectedRank.count()) > 0) return + + await page.evaluate( + ({ address }) => { + const networkPassphrase = "Test SDF Network ; September 2015" + localStorage.setItem("walletId", JSON.stringify("hot-wallet")) + localStorage.setItem("walletType", JSON.stringify("hot-wallet")) + localStorage.setItem("walletAddress", JSON.stringify(address)) + localStorage.setItem("walletNetwork", JSON.stringify("TESTNET")) + localStorage.setItem( + "networkPassphrase", + JSON.stringify(networkPassphrase), + ) + }, + { address: E2E_WALLET_ADDRESS }, + ) + + await page.reload() +} + +test.describe("Leaderboard page", () => { + test.beforeEach(async ({ page }) => { + await installMockFreighter(page) + await mockHorizonBalances(page) + await installLeaderboardMocks(page) + }) + + test("displays ranked scholars, own rank, pagination, and truncated addresses", async ({ + page, + }) => { + await page.goto("/leaderboard") + await ensureWalletConnected(page) + + await expect( + page.getByRole("heading", { name: /leaderboard/i }), + ).toBeVisible() + + const rows = page.getByTestId("leaderboard-row") + await expect(rows).toHaveCount(10) + await expect(rows.first().getByTestId("leaderboard-rank-badge")).toHaveText( + "1", + ) + + await expect(page.getByTestId("leaderboard-your-rank")).toContainText( + "Your rank: #7", + ) + + const firstAddress = rows.first().getByTestId("leaderboard-address") + await expect(firstAddress).toContainText("...") + + await expect(page.getByTestId("leaderboard-page-indicator")).toHaveText( + "Page 1 of 2", + ) + await page.getByTestId("leaderboard-next-page").click() + await expect(page.getByTestId("leaderboard-page-indicator")).toHaveText( + "Page 2 of 2", + ) + await expect(rows).toHaveCount(3) + await expect(rows.first().getByTestId("leaderboard-rank-badge")).toHaveText( + "11", + ) + }) +}) diff --git a/eslint.config.js b/eslint.config.js index 471835de..2a1ed779 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -10,6 +10,8 @@ export default [ "target/packages", "src/contracts/*", "!src/contracts/util.ts", + "src/vendor/**", + "src/hooks/useAdmin.test.tsx", "contracts/**", "**/*.yml", "**/*.yaml", diff --git a/package-lock.json b/package-lock.json index 1b3597cb..5d0f4b8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1866,1309 +1866,6 @@ "url": "https://paulmillr.com/funding/" } }, -<<<<<<< HEAD - "node_modules/@expo/cli": { - "version": "55.0.18", - "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-55.0.18.tgz", - "integrity": "sha512-3sJwu8KvCvQIXBnhUlHgLBZBe+ZK4Da9R5rgI4znaowJavYWMqzRClLzyE6Kri66WVoMX7Q4HUVIh8prRlO0XA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@expo/code-signing-certificates": "^0.0.6", - "@expo/config": "~55.0.10", - "@expo/config-plugins": "~55.0.7", - "@expo/devcert": "^1.2.1", - "@expo/env": "~2.1.1", - "@expo/image-utils": "^0.8.12", - "@expo/json-file": "^10.0.12", - "@expo/log-box": "55.0.7", - "@expo/metro": "~54.2.0", - "@expo/metro-config": "~55.0.11", - "@expo/osascript": "^2.4.2", - "@expo/package-manager": "^1.10.3", - "@expo/plist": "^0.5.2", - "@expo/prebuild-config": "^55.0.10", - "@expo/require-utils": "^55.0.3", - "@expo/router-server": "^55.0.11", - "@expo/schema-utils": "^55.0.2", - "@expo/spawn-async": "^1.7.2", - "@expo/ws-tunnel": "^1.0.1", - "@expo/xcpretty": "^4.4.0", - "@react-native/dev-middleware": "0.83.2", - "accepts": "^1.3.8", - "arg": "^5.0.2", - "better-opn": "~3.0.2", - "bplist-creator": "0.1.0", - "bplist-parser": "^0.3.1", - "chalk": "^4.0.0", - "ci-info": "^3.3.0", - "compression": "^1.7.4", - "connect": "^3.7.0", - "debug": "^4.3.4", - "dnssd-advertise": "^1.1.3", - "expo-server": "^55.0.6", - "fetch-nodeshim": "^0.4.6", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "lan-network": "^0.2.0", - "multitars": "^0.2.3", - "node-forge": "^1.3.3", - "npm-package-arg": "^11.0.0", - "ora": "^3.4.0", - "picomatch": "^4.0.3", - "pretty-format": "^29.7.0", - "progress": "^2.0.3", - "prompts": "^2.3.2", - "resolve-from": "^5.0.0", - "semver": "^7.6.0", - "send": "^0.19.0", - "slugify": "^1.3.4", - "source-map-support": "~0.5.21", - "stacktrace-parser": "^0.1.10", - "structured-headers": "^0.4.1", - "terminal-link": "^2.1.1", - "toqr": "^0.1.1", - "wrap-ansi": "^7.0.0", - "ws": "^8.12.1", - "zod": "^3.25.76" - }, - "bin": { - "expo-internal": "build/bin/cli" - }, - "peerDependencies": { - "expo": "*", - "expo-router": "*", - "react-native": "*" - }, - "peerDependenciesMeta": { - "expo-router": { - "optional": true - }, - "react-native": { - "optional": true - } - } - }, - "node_modules/@expo/cli/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@expo/cli/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@expo/cli/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@expo/cli/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@expo/cli/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@expo/cli/node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@expo/cli/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/@expo/cli/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@expo/cli/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@expo/cli/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@expo/cli/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@expo/cli/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/@expo/cli/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "optional": true, - "peer": true, - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/@expo/code-signing-certificates": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz", - "integrity": "sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-forge": "^1.3.3" - } - }, - "node_modules/@expo/config": { - "version": "55.0.10", - "resolved": "https://registry.npmjs.org/@expo/config/-/config-55.0.10.tgz", - "integrity": "sha512-qCHxo9H1ZoeW+y0QeMtVZ3JfGmumpGrgUFX60wLWMarraoQZSe47ZUm9kJSn3iyoPjUtUNanO3eXQg+K8k4rag==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@expo/config-plugins": "~55.0.7", - "@expo/config-types": "^55.0.5", - "@expo/json-file": "^10.0.12", - "@expo/require-utils": "^55.0.3", - "deepmerge": "^4.3.1", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "resolve-from": "^5.0.0", - "resolve-workspace-root": "^2.0.0", - "semver": "^7.6.0", - "slugify": "^1.3.4" - } - }, - "node_modules/@expo/config-plugins": { - "version": "55.0.7", - "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-55.0.7.tgz", - "integrity": "sha512-XZUoDWrsHEkH3yasnDSJABM/UxP5a1ixzRwU/M+BToyn/f0nTrSJJe/Ay/FpxkI4JSNz2n0e06I23b2bleXKVA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@expo/config-types": "^55.0.5", - "@expo/json-file": "~10.0.12", - "@expo/plist": "^0.5.2", - "@expo/sdk-runtime-versions": "^1.0.0", - "chalk": "^4.1.2", - "debug": "^4.3.5", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "resolve-from": "^5.0.0", - "semver": "^7.5.4", - "slugify": "^1.6.6", - "xcode": "^3.0.1", - "xml2js": "0.6.0" - } - }, - "node_modules/@expo/config-plugins/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@expo/config-plugins/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@expo/config-plugins/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@expo/config-plugins/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@expo/config-types": { - "version": "55.0.5", - "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-55.0.5.tgz", - "integrity": "sha512-sCmSUZG4mZ/ySXvfyyBdhjivz8Q539X1NondwDdYG7s3SBsk+wsgPJzYsqgAG/P9+l0xWjUD2F+kQ1cAJ6NNLg==", - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/@expo/config/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@expo/devcert": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@expo/devcert/-/devcert-1.2.1.tgz", - "integrity": "sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@expo/sudo-prompt": "^9.3.1", - "debug": "^3.1.0" - } - }, - "node_modules/@expo/devcert/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/@expo/devtools": { - "version": "55.0.2", - "resolved": "https://registry.npmjs.org/@expo/devtools/-/devtools-55.0.2.tgz", - "integrity": "sha512-4VsFn9MUriocyuhyA+ycJP3TJhUsOFHDc270l9h3LhNpXMf6wvIdGcA0QzXkZtORXmlDybWXRP2KT1k36HcQkA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "chalk": "^4.1.2" - }, - "peerDependencies": { - "react": "*", - "react-native": "*" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-native": { - "optional": true - } - } - }, - "node_modules/@expo/devtools/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@expo/devtools/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@expo/devtools/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@expo/dom-webview": { - "version": "55.0.3", - "resolved": "https://registry.npmjs.org/@expo/dom-webview/-/dom-webview-55.0.3.tgz", - "integrity": "sha512-bY4/rfcZ0f43DvOtMn8/kmPlmo01tex5hRoc5hKbwBwQjqWQuQt0ACwu7akR9IHI4j0WNG48eL6cZB6dZUFrzg==", - "license": "MIT", - "optional": true, - "peer": true, - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*" - } - }, - "node_modules/@expo/env": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.1.1.tgz", - "integrity": "sha512-rVvHC4I6xlPcg+mAO09ydUi2Wjv1ZytpLmHOSzvXzBAz9mMrJggqCe4s4dubjJvi/Ino/xQCLhbaLCnTtLpikg==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "chalk": "^4.0.0", - "debug": "^4.3.4", - "getenv": "^2.0.0" - }, - "engines": { - "node": ">=20.12.0" - } - }, - "node_modules/@expo/env/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@expo/env/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@expo/env/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@expo/fingerprint": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.16.6.tgz", - "integrity": "sha512-nRITNbnu3RKSHPvKVehrSU4KG2VY9V8nvULOHBw98ukHCAU4bGrU5APvcblOkX3JAap+xEHsg/mZvqlvkLInmQ==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@expo/env": "^2.0.11", - "@expo/spawn-async": "^1.7.2", - "arg": "^5.0.2", - "chalk": "^4.1.2", - "debug": "^4.3.4", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "ignore": "^5.3.1", - "minimatch": "^10.2.2", - "resolve-from": "^5.0.0", - "semver": "^7.6.0" - }, - "bin": { - "fingerprint": "bin/cli.js" - } - }, - "node_modules/@expo/fingerprint/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@expo/fingerprint/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@expo/fingerprint/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@expo/fingerprint/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@expo/image-utils": { - "version": "0.8.12", - "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.8.12.tgz", - "integrity": "sha512-3KguH7kyKqq7pNwLb9j6BBdD/bjmNwXZG/HPWT6GWIXbwrvAJt2JNyYTP5agWJ8jbbuys1yuCzmkX+TU6rmI7A==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@expo/spawn-async": "^1.7.2", - "chalk": "^4.0.0", - "getenv": "^2.0.0", - "jimp-compact": "0.16.1", - "parse-png": "^2.1.0", - "resolve-from": "^5.0.0", - "semver": "^7.6.0" - } - }, - "node_modules/@expo/image-utils/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@expo/image-utils/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@expo/image-utils/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@expo/image-utils/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@expo/json-file": { - "version": "10.0.12", - "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.12.tgz", - "integrity": "sha512-inbDycp1rMAelAofg7h/mMzIe+Owx6F7pur3XdQ3EPTy00tme+4P6FWgHKUcjN8dBSrnbRNpSyh5/shzHyVCyQ==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.20.0", - "json5": "^2.2.3" - } - }, - "node_modules/@expo/local-build-cache-provider": { - "version": "55.0.7", - "resolved": "https://registry.npmjs.org/@expo/local-build-cache-provider/-/local-build-cache-provider-55.0.7.tgz", - "integrity": "sha512-Qg9uNZn1buv4zJUA4ZQaz+ZnKDCipRgjoEg2Gcp8Qfy+2Gq5yZKX4YN1TThCJ01LJk/pvJsCRxXlXZSwdZppgg==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@expo/config": "~55.0.10", - "chalk": "^4.1.2" - } - }, - "node_modules/@expo/local-build-cache-provider/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@expo/local-build-cache-provider/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@expo/local-build-cache-provider/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@expo/log-box": { - "version": "55.0.7", - "resolved": "https://registry.npmjs.org/@expo/log-box/-/log-box-55.0.7.tgz", - "integrity": "sha512-m7V1k2vlMp4NOj3fopjOg4zl/ANXyTRF3HMTMep2GZAKsPiDzgOQ41nm8CaU50/HlDIGXlCObss07gOn20UpHQ==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@expo/dom-webview": "^55.0.3", - "anser": "^1.4.9", - "stacktrace-parser": "^0.1.10" - }, - "peerDependencies": { - "@expo/dom-webview": "^55.0.3", - "expo": "*", - "react": "*", - "react-native": "*" - } - }, - "node_modules/@expo/metro": { - "version": "54.2.0", - "resolved": "https://registry.npmjs.org/@expo/metro/-/metro-54.2.0.tgz", - "integrity": "sha512-h68TNZPGsk6swMmLm9nRSnE2UXm48rWwgcbtAHVMikXvbxdS41NDHHeqg1rcQ9AbznDRp6SQVC2MVpDnsRKU1w==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "metro": "0.83.3", - "metro-babel-transformer": "0.83.3", - "metro-cache": "0.83.3", - "metro-cache-key": "0.83.3", - "metro-config": "0.83.3", - "metro-core": "0.83.3", - "metro-file-map": "0.83.3", - "metro-minify-terser": "0.83.3", - "metro-resolver": "0.83.3", - "metro-runtime": "0.83.3", - "metro-source-map": "0.83.3", - "metro-symbolicate": "0.83.3", - "metro-transform-plugins": "0.83.3", - "metro-transform-worker": "0.83.3" - } - }, - "node_modules/@expo/metro-config": { - "version": "55.0.11", - "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-55.0.11.tgz", - "integrity": "sha512-qGxq7RwWpj0zNvZO/e5aizKrOKYYBrVPShSbxPOVB1EXcexxTPTxnOe4pYFg/gKkLIJe0t3jSSF8IDWlGdaaOg==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.20.0", - "@babel/core": "^7.20.0", - "@babel/generator": "^7.20.5", - "@expo/config": "~55.0.10", - "@expo/env": "~2.1.1", - "@expo/json-file": "~10.0.12", - "@expo/metro": "~54.2.0", - "@expo/spawn-async": "^1.7.2", - "browserslist": "^4.25.0", - "chalk": "^4.1.0", - "debug": "^4.3.2", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "hermes-parser": "^0.32.0", - "jsc-safe-url": "^0.2.4", - "lightningcss": "^1.30.1", - "picomatch": "^4.0.3", - "postcss": "~8.4.32", - "resolve-from": "^5.0.0" - }, - "peerDependencies": { - "expo": "*" - }, - "peerDependenciesMeta": { - "expo": { - "optional": true - } - } - }, - "node_modules/@expo/metro-config/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@expo/metro-config/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@expo/metro-config/node_modules/hermes-estree": { - "version": "0.32.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.32.1.tgz", - "integrity": "sha512-ne5hkuDxheNBAikDjqvCZCwihnz0vVu9YsBzAEO1puiyFR4F1+PAz/SiPHSsNTuOveCYGRMX8Xbx4LOubeC0Qg==", - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/@expo/metro-config/node_modules/hermes-parser": { - "version": "0.32.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.32.1.tgz", - "integrity": "sha512-175dz634X/W5AiwrpLdoMl/MOb17poLHyIqgyExlE8D9zQ1OPnoORnGMB5ltRKnpvQzBjMYvT2rN/sHeIfZW5Q==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "hermes-estree": "0.32.1" - } - }, - "node_modules/@expo/metro-config/node_modules/postcss": { - "version": "8.4.49", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", - "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/@expo/metro-config/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@expo/metro-config/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@expo/osascript": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.4.2.tgz", - "integrity": "sha512-/XP7PSYF2hzOZzqfjgkoWtllyeTN8dW3aM4P6YgKcmmPikKL5FdoyQhti4eh6RK5a5VrUXJTOlTNIpIHsfB5Iw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@expo/spawn-async": "^1.7.2" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@expo/package-manager": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.10.3.tgz", - "integrity": "sha512-ZuXiK/9fCrIuLjPSe1VYmfp0Sa85kCMwd8QQpgyi5ufppYKRtLBg14QOgUqj8ZMbJTxE0xqzd0XR7kOs3vAK9A==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@expo/json-file": "^10.0.12", - "@expo/spawn-async": "^1.7.2", - "chalk": "^4.0.0", - "npm-package-arg": "^11.0.0", - "ora": "^3.4.0", - "resolve-workspace-root": "^2.0.0" - } - }, - "node_modules/@expo/package-manager/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@expo/package-manager/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@expo/package-manager/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@expo/plist": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.5.2.tgz", - "integrity": "sha512-o4xdVdBpe4aTl3sPMZ2u3fJH4iG1I768EIRk1xRZP+GaFI93MaR3JvoFibYqxeTmLQ1p1kNEVqylfUjezxx45g==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@xmldom/xmldom": "^0.8.8", - "base64-js": "^1.5.1", - "xmlbuilder": "^15.1.1" - } - }, - "node_modules/@expo/prebuild-config": { - "version": "55.0.10", - "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-55.0.10.tgz", - "integrity": "sha512-AMylDld5G7YJGfEhEyXtgWRuBB83802QBoewF1vJ6NMDtufukuPhMJzOs9E4UXNsjLTaQcgT4yTWhsAWl7o1AQ==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@expo/config": "~55.0.10", - "@expo/config-plugins": "~55.0.7", - "@expo/config-types": "^55.0.5", - "@expo/image-utils": "^0.8.12", - "@expo/json-file": "^10.0.12", - "@react-native/normalize-colors": "0.83.2", - "debug": "^4.3.1", - "resolve-from": "^5.0.0", - "semver": "^7.6.0", - "xml2js": "0.6.0" - }, - "peerDependencies": { - "expo": "*" - } - }, - "node_modules/@expo/prebuild-config/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@expo/require-utils": { - "version": "55.0.3", - "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-55.0.3.tgz", - "integrity": "sha512-TS1m5tW45q4zoaTlt6DwmdYHxvFTIxoLrTHKOFrIirHIqIXnHCzpceg8wumiBi+ZXSaGY2gobTbfv+WVhJY6Fw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.20.0", - "@babel/core": "^7.25.2", - "@babel/plugin-transform-modules-commonjs": "^7.24.8" - }, - "peerDependencies": { - "typescript": "^5.0.0 || ^5.0.0-0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@expo/router-server": { - "version": "55.0.11", - "resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-55.0.11.tgz", - "integrity": "sha512-Kd8J1OOlFR00DZxn+1KfiQiXZtRut6cj8+ynqHJa7dtt/lTL4tGkYistqmVhpKJ6w886eRY5WivKy7o0ZBFkJA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "debug": "^4.3.4" - }, - "peerDependencies": { - "@expo/metro-runtime": "^55.0.6", - "expo": "*", - "expo-constants": "^55.0.9", - "expo-font": "^55.0.4", - "expo-router": "*", - "expo-server": "^55.0.6", - "react": "*", - "react-dom": "*", - "react-server-dom-webpack": "~19.0.1 || ~19.1.2 || ~19.2.1" - }, - "peerDependenciesMeta": { - "@expo/metro-runtime": { - "optional": true - }, - "expo-router": { - "optional": true - }, - "react-dom": { - "optional": true - }, - "react-server-dom-webpack": { - "optional": true - } - } - }, - "node_modules/@expo/schema-utils": { - "version": "55.0.2", - "resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-55.0.2.tgz", - "integrity": "sha512-QZ5WKbJOWkCrMq0/kfhV9ry8te/OaS34YgLVpG8u9y2gix96TlpRTbxM/YATjNcUR2s4fiQmPCOxkGtog4i37g==", - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/@expo/sdk-runtime-versions": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@expo/sdk-runtime-versions/-/sdk-runtime-versions-1.0.0.tgz", - "integrity": "sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ==", - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/@expo/spawn-async": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.7.2.tgz", - "integrity": "sha512-QdWi16+CHB9JYP7gma19OVVg0BFkvU8zNj9GjWorYI8Iv8FUxjOCcYRuAmX4s/h91e4e7BPsskc8cSrZYho9Ew==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "cross-spawn": "^7.0.3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@expo/sudo-prompt": { - "version": "9.3.2", - "resolved": "https://registry.npmjs.org/@expo/sudo-prompt/-/sudo-prompt-9.3.2.tgz", - "integrity": "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==", - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/@expo/vector-icons": { - "version": "15.1.1", - "resolved": "https://registry.npmjs.org/@expo/vector-icons/-/vector-icons-15.1.1.tgz", - "integrity": "sha512-Iu2VkcoI5vygbtYngm7jb4ifxElNVXQYdDrYkT7UCEIiKLeWnQY0wf2ZhHZ+Wro6Sc5TaumpKUOqDRpLi5rkvw==", - "license": "MIT", - "optional": true, - "peer": true, - "peerDependencies": { - "expo-font": ">=14.0.4", - "react": "*", - "react-native": "*" - } - }, - "node_modules/@expo/ws-tunnel": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@expo/ws-tunnel/-/ws-tunnel-1.0.6.tgz", - "integrity": "sha512-nDRbLmSrJar7abvUjp3smDwH8HcbZcoOEa5jVPUv9/9CajgmWw20JNRwTuBRzWIWIkEJDkz20GoNA+tSwUqk0Q==", - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/@expo/xcpretty": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.1.tgz", - "integrity": "sha512-KZNxZvnGCtiM2aYYZ6Wz0Ix5r47dAvpNLApFtZWnSoERzAdOMzVBOPysBoM0JlF6FKWZ8GPqgn6qt3dV/8Zlpg==", - "license": "BSD-3-Clause", - "optional": true, - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.20.0", - "chalk": "^4.1.0", - "js-yaml": "^4.1.0" - }, - "bin": { - "excpretty": "build/cli.js" - } - }, - "node_modules/@expo/xcpretty/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@expo/xcpretty/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@expo/xcpretty/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, -======= ->>>>>>> main "node_modules/@fivebinaries/coin-selection": { "version": "3.0.0", "license": "Apache-2.0", @@ -3286,26 +1983,6 @@ } }, "node_modules/@ledgerhq/devices": { -<<<<<<< HEAD - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/@ledgerhq/devices/-/devices-8.13.0.tgz", - "integrity": "sha512-hgGn1kpe/rT0EJ0Qs7rG+1TXA4g6HN2t3dB4DndRTqVqC9aSSbME+ajA0QWLZisxOD3zkwvO4Q0mJ2zARAKyag==", - "license": "Apache-2.0", - "dependencies": { - "@ledgerhq/errors": "^6.32.0", - "@ledgerhq/logs": "^6.16.0", - "rxjs": "7.8.2", - "semver": "7.7.3" - } - }, - "node_modules/@ledgerhq/devices/node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" -======= "version": "8.6.1", "license": "Apache-2.0", "dependencies": { @@ -3313,16 +1990,10 @@ "@ledgerhq/logs": "^6.13.0", "rxjs": "^7.8.1", "semver": "^7.3.5" ->>>>>>> main } }, "node_modules/@ledgerhq/errors": { "version": "6.32.0", -<<<<<<< HEAD - "resolved": "https://registry.npmjs.org/@ledgerhq/errors/-/errors-6.32.0.tgz", - "integrity": "sha512-BjjvhLM6UXYUbhllqAduo9PSneLt9FXZ3TBEUFQ3MMSZOCHt0gAgDySLwul99R8fdYWkXBza4DYQjUNckpN2lg==", -======= ->>>>>>> main "license": "Apache-2.0" }, "node_modules/@ledgerhq/hw-app-str": { @@ -3628,21 +2299,7 @@ "version": "0.9.0", "license": "MIT" }, -<<<<<<< HEAD - "node_modules/@ngneat/elf": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@ngneat/elf/-/elf-2.5.1.tgz", - "integrity": "sha512-13BItNZFgHglTiXuP9XhisNczwQ5QSzH+imAv9nAPsdbCq/3ortqkIYRnlxB8DGPVcuIjLujQ4OcZa+9QWgZtw==", - "license": "MIT", - "peer": true, - "peerDependencies": { - "rxjs": ">=7.0.0" - } - }, - "node_modules/@ngneat/elf-devtools": { -======= "node_modules/@noble/ciphers": { ->>>>>>> main "version": "1.3.0", "license": "MIT", "engines": { @@ -6137,8 +4794,6 @@ "version": "0.6.1", "license": "MIT" }, -<<<<<<< HEAD -======= "node_modules/@trezor/analytics": { "version": "1.5.0", "license": "See LICENSE.md in repo root", @@ -6203,286 +4858,32 @@ "tslib": "^2.6.2" } }, - "node_modules/@trezor/blockchain-link-utils/node_modules/@stellar/stellar-sdk": { - "version": "14.2.0", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@stellar/stellar-base": "^14.0.1", - "axios": "^1.12.2", - "bignumber.js": "^9.3.1", - "eventsource": "^2.0.2", - "feaxios": "^0.0.23", - "randombytes": "^2.1.0", - "toml": "^3.0.0", - "urijs": "^1.19.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@trezor/blockchain-link/node_modules/@stellar/stellar-sdk": { - "version": "14.2.0", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@stellar/stellar-base": "^14.0.1", - "axios": "^1.12.2", - "bignumber.js": "^9.3.1", - "eventsource": "^2.0.2", - "feaxios": "^0.0.23", - "randombytes": "^2.1.0", - "toml": "^3.0.0", - "urijs": "^1.19.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@trezor/blockchain-link/node_modules/@trezor/blockchain-link-types": { - "version": "1.5.0", - "license": "See LICENSE.md in repo root", - "dependencies": { - "@trezor/utils": "9.5.0", - "@trezor/utxo-lib": "2.5.0" - }, - "peerDependencies": { - "tslib": "^2.6.2" - } - }, - "node_modules/@trezor/blockchain-link/node_modules/@trezor/blockchain-link-utils": { - "version": "1.5.1", - "license": "See LICENSE.md in repo root", - "dependencies": { - "@mobily/ts-belt": "^3.13.1", - "@stellar/stellar-sdk": "14.2.0", - "@trezor/env-utils": "1.5.0", - "@trezor/protobuf": "1.5.1", - "@trezor/utils": "9.5.0", - "xrpl": "4.4.3" - }, - "peerDependencies": { - "tslib": "^2.6.2" - } - }, - "node_modules/@trezor/blockchain-link/node_modules/@trezor/protobuf": { - "version": "1.5.1", - "license": "See LICENSE.md in repo root", - "dependencies": { - "@trezor/schema-utils": "1.4.0", - "long": "5.2.5", - "protobufjs": "7.4.0" - }, - "peerDependencies": { - "tslib": "^2.6.2" - } - }, ->>>>>>> main - "node_modules/@trezor/connect": { - "version": "9.7.2", - "license": "SEE LICENSE IN LICENSE.md", - "dependencies": { - "@ethereumjs/common": "^10.1.0", - "@ethereumjs/tx": "^10.1.0", - "@fivebinaries/coin-selection": "3.0.0", - "@mobily/ts-belt": "^3.13.1", - "@noble/hashes": "^1.6.1", - "@scure/bip39": "^1.5.1", - "@solana-program/compute-budget": "^0.8.0", - "@solana-program/system": "^0.7.0", - "@solana-program/token": "^0.5.1", - "@solana-program/token-2022": "^0.4.2", - "@solana/kit": "^2.3.0", - "@trezor/blockchain-link": "2.6.1", - "@trezor/blockchain-link-types": "1.5.1", - "@trezor/blockchain-link-utils": "1.5.2", - "@trezor/connect-analytics": "1.4.0", - "@trezor/connect-common": "0.5.1", - "@trezor/crypto-utils": "1.2.0", - "@trezor/device-authenticity": "1.1.2", - "@trezor/device-utils": "1.2.0", - "@trezor/env-utils": "^1.5.0", - "@trezor/protobuf": "1.5.2", - "@trezor/protocol": "1.3.0", - "@trezor/schema-utils": "1.4.0", - "@trezor/transport": "1.6.2", - "@trezor/type-utils": "1.2.0", - "@trezor/utils": "9.5.0", - "@trezor/utxo-lib": "2.5.0", - "blakejs": "^1.2.1", - "bs58": "^6.0.0", - "bs58check": "^4.0.0", - "cbor": "^10.0.10", - "cross-fetch": "^4.0.0", - "jws": "^4.0.0" - }, - "peerDependencies": { - "tslib": "^2.6.2" - } - }, - "node_modules/@trezor/connect-analytics": { - "version": "1.4.0", - "license": "See LICENSE.md in repo root", - "dependencies": { - "@trezor/analytics": "1.5.0" - }, - "peerDependencies": { - "tslib": "^2.6.2" - } - }, - "node_modules/@trezor/connect-analytics/node_modules/@trezor/analytics": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/@trezor/analytics/-/analytics-1.4.2.tgz", - "integrity": "sha512-FgjJekuDvx1TjiDemvpnPiRck7Kp/v1ZeppsBYpQR3yGKyKzbG1pVpcl0RyI2237raXxbORaz7XV8tcyjq4BXg==", - "license": "See LICENSE.md in repo root", - "dependencies": { - "@mobily/ts-belt": "^3.13.1", - "@stellar/stellar-sdk": "14.2.0", - "@trezor/env-utils": "1.5.0", - "@trezor/protobuf": "1.5.2", - "@trezor/utils": "9.5.0", - "xrpl": "4.4.3" - }, - "peerDependencies": { - "tslib": "^2.6.2" - } - }, - "node_modules/@trezor/connect-analytics/node_modules/@trezor/env-utils": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/@trezor/env-utils/-/env-utils-1.4.2.tgz", - "integrity": "sha512-lQvrqcNK5I4dy2MuiLyMuEm0KzY59RIu2GLtc9GsvqyxSPZkADqVzGeLJjXj/vI2ajL8leSpMvmN4zPw3EK8AA==", - "license": "See LICENSE.md in repo root", - "dependencies": { - "@trezor/utils": "9.5.0", - "@trezor/utxo-lib": "2.5.0" - }, - "peerDependencies": { - "tslib": "^2.6.2" - } - }, - "node_modules/@trezor/blockchain-link/node_modules/@trezor/blockchain-link-utils": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@trezor/blockchain-link-utils/-/blockchain-link-utils-1.5.1.tgz", - "integrity": "sha512-2tDGLEj5jzydjsJQONGTWVmCDDy6FTZ4ytr1/2gE6anyYEJU8MbaR+liTt3UvcP5jwZTNutwYLvZixRfrb8JpA==", - "license": "See LICENSE.md in repo root", - "dependencies": { - "@mobily/ts-belt": "^3.13.1", - "@stellar/stellar-sdk": "14.2.0", - "@trezor/env-utils": "1.5.0", - "@trezor/protobuf": "1.5.1", - "@trezor/utils": "9.5.0", - "xrpl": "4.4.3" - }, - "peerDependencies": { - "tslib": "^2.6.2" - } - }, - "node_modules/@trezor/blockchain-link/node_modules/@trezor/protobuf": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@trezor/protobuf/-/protobuf-1.5.1.tgz", - "integrity": "sha512-nAkaCCAqLpErBd+IuKeG5MpbyLR/2RMgCw18TWc80m1Ws/XgQirhHY9Jbk6gLImTXb9GTrxP0+MDSahzd94rSA==", - "license": "See LICENSE.md in repo root", - "dependencies": { - "@trezor/schema-utils": "1.4.0", - "long": "5.2.5", - "protobufjs": "7.4.0" - }, - "peerDependencies": { - "tslib": "^2.6.2" - } - }, - "node_modules/@trezor/connect-common": { - "version": "0.5.1", - "license": "SEE LICENSE IN LICENSE.md", - "dependencies": { -<<<<<<< HEAD - "@trezor/env-utils": "1.4.2", - "@trezor/utils": "9.4.2" - }, - "peerDependencies": { - "tslib": "^2.6.2" - } - }, - "node_modules/@trezor/connect-common/node_modules/@trezor/env-utils": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/@trezor/env-utils/-/env-utils-1.4.2.tgz", - "integrity": "sha512-lQvrqcNK5I4dy2MuiLyMuEm0KzY59RIu2GLtc9GsvqyxSPZkADqVzGeLJjXj/vI2ajL8leSpMvmN4zPw3EK8AA==", - "license": "See LICENSE.md in repo root", - "dependencies": { - "@trezor/analytics": "1.5.0" - }, - "peerDependencies": { - "tslib": "^2.6.2" - } - }, - "node_modules/@trezor/connect-common": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/@trezor/connect-common/-/connect-common-0.5.1.tgz", - "integrity": "sha512-wdpVCwdylBh4SBO5Ys40tB/d59UlfjmxgBHDkkLgaR+JcqkthCfiw5VlUrV9wu65lquejAZhA5KQL4mUUUhCow==", - "license": "SEE LICENSE IN LICENSE.md", - "dependencies": { - "@trezor/env-utils": "1.5.0", - "@trezor/type-utils": "1.2.0", - "@trezor/utils": "9.5.0" - }, - "peerDependencies": { - "tslib": "^2.6.2" - } - }, - "node_modules/@trezor/blockchain-link-utils/node_modules/@trezor/utils": { - "version": "9.4.2", - "resolved": "https://registry.npmjs.org/@trezor/utils/-/utils-9.4.2.tgz", - "integrity": "sha512-Fm3m2gmfXsgv4chqn5HX8e8dElEr2ibBJSJ7HE3bsHh/1OSQcDdzsSioAK04Fo9ws/v7n6lt+QBZ6fGmwyIkZQ==", - "license": "SEE LICENSE IN LICENSE.md", - "dependencies": { - "bignumber.js": "^9.3.0" - }, - "peerDependencies": { - "tslib": "^2.6.2" - } - }, - "node_modules/@trezor/connect-web/node_modules/@trezor/websocket-client": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@trezor/websocket-client/-/websocket-client-1.2.2.tgz", - "integrity": "sha512-vu9L1V/5yh8LHQCmsGC9scCnihELsVuR5Tri1IvW3CdgTUFFcfjsEgXsFqFME3HlxuUmx6qokw0Gx/o0/hzaSQ==", - "license": "SEE LICENSE IN LICENSE.md", - "dependencies": { - "@trezor/utils": "9.4.2", - "ws": "^8.18.0" - }, - "peerDependencies": { - "tslib": "^2.6.2" - } - }, - "node_modules/@trezor/connect/node_modules/@stellar/stellar-base": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/@stellar/stellar-base/-/stellar-base-13.1.0.tgz", - "integrity": "sha512-90EArG+eCCEzDGj3OJNoCtwpWDwxjv+rs/RNPhvg4bulpjN/CSRj+Ys/SalRcfM4/WRC5/qAfjzmJBAuquWhkA==", + "node_modules/@trezor/blockchain-link-utils/node_modules/@stellar/stellar-sdk": { + "version": "14.2.0", + "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@stellar/js-xdr": "^3.1.2", - "base32.js": "^0.1.0", - "bignumber.js": "^9.1.2", - "buffer": "^6.0.3", - "sha.js": "^2.3.6", - "tweetnacl": "^1.0.3" + "@stellar/stellar-base": "^14.0.1", + "axios": "^1.12.2", + "bignumber.js": "^9.3.1", + "eventsource": "^2.0.2", + "feaxios": "^0.0.23", + "randombytes": "^2.1.0", + "toml": "^3.0.0", + "urijs": "^1.19.1" }, "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "sodium-native": "^4.3.3" + "node": ">=20.0.0" } }, - "node_modules/@trezor/connect/node_modules/@stellar/stellar-sdk": { - "version": "13.3.0", - "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-13.3.0.tgz", - "integrity": "sha512-8+GHcZLp+mdin8gSjcgfb/Lb6sSMYRX6Nf/0LcSJxvjLQR0XHpjGzOiRbYb2jSXo51EnA6kAV5j+4Pzh5OUKUg==", + "node_modules/@trezor/blockchain-link/node_modules/@stellar/stellar-sdk": { + "version": "14.2.0", + "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@stellar/stellar-base": "^13.1.0", - "axios": "^1.8.4", - "bignumber.js": "^9.3.0", + "@stellar/stellar-base": "^14.0.1", + "axios": "^1.12.2", + "bignumber.js": "^9.3.1", "eventsource": "^2.0.2", "feaxios": "^0.0.23", "randombytes": "^2.1.0", @@ -6490,29 +4891,53 @@ "urijs": "^1.19.1" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, - "node_modules/@trezor/connect/node_modules/@trezor/blockchain-link": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/@trezor/blockchain-link/-/blockchain-link-2.5.2.tgz", - "integrity": "sha512-/egUnIt/fR57QY33ejnkPMhZwRvVRS/pUCoqdVIGitN1Q7QZsdopoR4hw37hdK/Ux/q1ZLH6LZz7U2UFahjppw==", - "license": "SEE LICENSE IN LICENSE.md", + "node_modules/@trezor/blockchain-link/node_modules/@trezor/blockchain-link-types": { + "version": "1.5.0", + "license": "See LICENSE.md in repo root", "dependencies": { - "bignumber.js": "^9.3.0" + "@trezor/utils": "9.5.0", + "@trezor/utxo-lib": "2.5.0" + }, + "peerDependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@trezor/blockchain-link/node_modules/@trezor/blockchain-link-utils": { + "version": "1.5.1", + "license": "See LICENSE.md in repo root", + "dependencies": { + "@mobily/ts-belt": "^3.13.1", + "@stellar/stellar-sdk": "14.2.0", + "@trezor/env-utils": "1.5.0", + "@trezor/protobuf": "1.5.1", + "@trezor/utils": "9.5.0", + "xrpl": "4.4.3" + }, + "peerDependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@trezor/blockchain-link/node_modules/@trezor/protobuf": { + "version": "1.5.1", + "license": "See LICENSE.md in repo root", + "dependencies": { + "@trezor/schema-utils": "1.4.0", + "long": "5.2.5", + "protobufjs": "7.4.0" }, "peerDependencies": { "tslib": "^2.6.2" } }, "node_modules/@trezor/connect": { - "version": "9.6.2", - "resolved": "https://registry.npmjs.org/@trezor/connect/-/connect-9.6.2.tgz", - "integrity": "sha512-XsSERBK+KnF6FPsATuhB9AEM0frekVLwAwFo35MRV9I4P+mdv6tnUiZUq8O8aoPbfJwDjtNJSYv+PMsKuRH6rg==", + "version": "9.7.2", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { - "@ethereumjs/common": "^10.0.0", - "@ethereumjs/tx": "^10.0.0", + "@ethereumjs/common": "^10.1.0", + "@ethereumjs/tx": "^10.1.0", "@fivebinaries/coin-selection": "3.0.0", "@mobily/ts-belt": "^3.13.1", "@noble/hashes": "^1.6.1", @@ -6521,25 +4946,27 @@ "@solana-program/system": "^0.7.0", "@solana-program/token": "^0.5.1", "@solana-program/token-2022": "^0.4.2", - "@solana/kit": "^2.1.1", - "@trezor/blockchain-link": "2.5.2", - "@trezor/blockchain-link-types": "1.4.2", - "@trezor/blockchain-link-utils": "1.4.2", - "@trezor/connect-analytics": "1.3.5", - "@trezor/connect-common": "0.4.2", - "@trezor/crypto-utils": "1.1.4", - "@trezor/device-utils": "1.1.2", - "@trezor/env-utils": "^1.4.2", - "@trezor/protobuf": "1.4.2", - "@trezor/protocol": "1.2.8", - "@trezor/schema-utils": "1.3.4", - "@trezor/transport": "1.5.2", - "@trezor/type-utils": "1.1.8", - "@trezor/utils": "9.4.2", - "@trezor/utxo-lib": "2.4.2", + "@solana/kit": "^2.3.0", + "@trezor/blockchain-link": "2.6.1", + "@trezor/blockchain-link-types": "1.5.1", + "@trezor/blockchain-link-utils": "1.5.2", + "@trezor/connect-analytics": "1.4.0", + "@trezor/connect-common": "0.5.1", + "@trezor/crypto-utils": "1.2.0", + "@trezor/device-authenticity": "1.1.2", + "@trezor/device-utils": "1.2.0", + "@trezor/env-utils": "^1.5.0", + "@trezor/protobuf": "1.5.2", + "@trezor/protocol": "1.3.0", + "@trezor/schema-utils": "1.4.0", + "@trezor/transport": "1.6.2", + "@trezor/type-utils": "1.2.0", + "@trezor/utils": "9.5.0", + "@trezor/utxo-lib": "2.5.0", "blakejs": "^1.2.1", "bs58": "^6.0.0", "bs58check": "^4.0.0", + "cbor": "^10.0.10", "cross-fetch": "^4.0.0", "jws": "^4.0.0" }, @@ -6547,157 +4974,101 @@ "tslib": "^2.6.2" } }, - "node_modules/@trezor/connect/node_modules/@trezor/blockchain-link-types": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/@trezor/blockchain-link-types/-/blockchain-link-types-1.4.2.tgz", - "integrity": "sha512-KThBmGOFLJAFnmou9ThQhnjEVxfYPfEwMOaVTVNgJ+NAkt5rEMx0SKBBelCGZ63XtOLWdVPglFo83wtm+I9Vpg==", + "node_modules/@trezor/connect-analytics": { + "version": "1.4.0", "license": "See LICENSE.md in repo root", "dependencies": { - "@trezor/analytics": "1.4.2" + "@trezor/analytics": "1.5.0" }, "peerDependencies": { "tslib": "^2.6.2" } }, - "node_modules/@trezor/connect/node_modules/@trezor/blockchain-link-utils": { + "node_modules/@trezor/connect-analytics/node_modules/@trezor/analytics": { "version": "1.4.2", - "resolved": "https://registry.npmjs.org/@trezor/blockchain-link-utils/-/blockchain-link-utils-1.4.2.tgz", - "integrity": "sha512-PBEBrdtHn0dn/c9roW6vjdHI/CucMywJm5gthETZAZmzBOtg6ZDpLTn+qL8+jZGIbwcAkItrQ3iHrHhR6xTP5g==", + "resolved": "https://registry.npmjs.org/@trezor/analytics/-/analytics-1.4.2.tgz", + "integrity": "sha512-FgjJekuDvx1TjiDemvpnPiRck7Kp/v1ZeppsBYpQR3yGKyKzbG1pVpcl0RyI2237raXxbORaz7XV8tcyjq4BXg==", "license": "See LICENSE.md in repo root", "dependencies": { - "@trezor/env-utils": "1.4.2", - "@trezor/utils": "9.4.2" + "@mobily/ts-belt": "^3.13.1", + "@stellar/stellar-sdk": "14.2.0", + "@trezor/env-utils": "1.5.0", + "@trezor/protobuf": "1.5.2", + "@trezor/utils": "9.5.0", + "xrpl": "4.4.3" }, "peerDependencies": { "tslib": "^2.6.2" } }, - "node_modules/@trezor/connect/node_modules/@trezor/crypto-utils": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@trezor/crypto-utils/-/crypto-utils-1.1.4.tgz", - "integrity": "sha512-Y6VziniqMPoMi70IyowEuXKqRvBYQzgPAekJaUZTHhR+grtYNRKRH2HJCvuZ8MGmSKUFSYfa7y8AvwALA8mQmA==", - "license": "SEE LICENSE IN LICENSE.md", - "peerDependencies": { - "tslib": "^2.6.2" - } - }, - "node_modules/@trezor/connect/node_modules/@trezor/device-utils": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@trezor/device-utils/-/device-utils-1.1.2.tgz", - "integrity": "sha512-R3AJvAo+a3wYVmcGZO2VNl9PZOmDEzCZIlmCJn0BlSRWWd8G9u1qyo/fL9zOwij/YhCaJyokmSHmIEmbY9qpgw==", - "license": "See LICENSE.md in repo root" - }, - "node_modules/@trezor/connect/node_modules/@trezor/env-utils": { + "node_modules/@trezor/connect-analytics/node_modules/@trezor/env-utils": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/@trezor/env-utils/-/env-utils-1.4.2.tgz", "integrity": "sha512-lQvrqcNK5I4dy2MuiLyMuEm0KzY59RIu2GLtc9GsvqyxSPZkADqVzGeLJjXj/vI2ajL8leSpMvmN4zPw3EK8AA==", "license": "See LICENSE.md in repo root", "dependencies": { - "ua-parser-js": "^2.0.4" + "@trezor/utils": "9.5.0", + "@trezor/utxo-lib": "2.5.0" }, "peerDependencies": { - "expo-constants": "*", - "expo-localization": "*", - "react-native": "*", "tslib": "^2.6.2" - }, - "peerDependenciesMeta": { - "expo-constants": { - "optional": true - }, - "expo-localization": { - "optional": true - }, - "react-native": { - "optional": true - } } }, - "node_modules/@trezor/connect/node_modules/@trezor/protobuf": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/@trezor/protobuf/-/protobuf-1.4.2.tgz", - "integrity": "sha512-AeIYKCgKcE9cWflggGL8T9gD+IZLSGrwkzqCk3wpIiODd5dUCgEgA4OPBufR6OMu3RWu/Tgu2xviHunijG3LXQ==", + "node_modules/@trezor/blockchain-link/node_modules/@trezor/blockchain-link-utils": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@trezor/blockchain-link-utils/-/blockchain-link-utils-1.5.1.tgz", + "integrity": "sha512-2tDGLEj5jzydjsJQONGTWVmCDDy6FTZ4ytr1/2gE6anyYEJU8MbaR+liTt3UvcP5jwZTNutwYLvZixRfrb8JpA==", "license": "See LICENSE.md in repo root", "dependencies": { - "bignumber.js": "^9.3.0" -======= + "@mobily/ts-belt": "^3.13.1", + "@stellar/stellar-sdk": "14.2.0", "@trezor/env-utils": "1.5.0", - "@trezor/type-utils": "1.2.0", - "@trezor/utils": "9.5.0" ->>>>>>> main + "@trezor/protobuf": "1.5.1", + "@trezor/utils": "9.5.0", + "xrpl": "4.4.3" }, "peerDependencies": { "tslib": "^2.6.2" } }, -<<<<<<< HEAD - "node_modules/@trezor/connect/node_modules/@trezor/protocol": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@trezor/protocol/-/protocol-1.2.8.tgz", - "integrity": "sha512-8EH+EU4Z1j9X4Ljczjbl9G7vVgcUz41qXcdE+6FOG3BFvMDK4KUVvaOtWqD+1dFpeo5yvWSTEKdhgXMPFprWYQ==", + "node_modules/@trezor/blockchain-link/node_modules/@trezor/protobuf": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@trezor/protobuf/-/protobuf-1.5.1.tgz", + "integrity": "sha512-nAkaCCAqLpErBd+IuKeG5MpbyLR/2RMgCw18TWc80m1Ws/XgQirhHY9Jbk6gLImTXb9GTrxP0+MDSahzd94rSA==", "license": "See LICENSE.md in repo root", -======= - "node_modules/@trezor/connect-plugin-stellar": { - "version": "9.2.6", - "license": "SEE LICENSE IN LICENSE.md", "dependencies": { - "@trezor/utils": "9.5.0" + "@trezor/schema-utils": "1.4.0", + "long": "5.2.5", + "protobufjs": "7.4.0" }, ->>>>>>> main "peerDependencies": { - "@stellar/stellar-sdk": "^13.3.0", - "@trezor/connect": "9.x.x", "tslib": "^2.6.2" } }, -<<<<<<< HEAD - "node_modules/@trezor/connect/node_modules/@trezor/transport": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@trezor/transport/-/transport-1.5.2.tgz", - "integrity": "sha512-rYP87zdVll2bNBtsD3VxJq0yjaNvIClcgszZjQwVTQxpKGFPkx8bLSpAGI05R9qfxusZJCfYarjX3qki9nHYPw==", + "node_modules/@trezor/connect-common": { + "version": "0.5.1", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { - "@trezor/connect": "9.6.2", - "@trezor/connect-common": "0.4.2", - "@trezor/utils": "9.4.2", - "@trezor/websocket-client": "1.2.2" + "@trezor/env-utils": "1.5.0", + "@trezor/type-utils": "1.2.0", + "@trezor/utils": "9.5.0" }, "peerDependencies": { "tslib": "^2.6.2" } }, - "node_modules/@trezor/connect/node_modules/@trezor/type-utils": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/@trezor/type-utils/-/type-utils-1.1.8.tgz", - "integrity": "sha512-VtvkPXpwtMtTX9caZWYlMMTmhjUeDq4/1LGn0pSdjd4OuL/vQyuPWXCT/0RtlnRraW6R2dZF7rX2UON2kQIMTQ==", - "license": "See LICENSE.md in repo root" - }, - "node_modules/@trezor/connect/node_modules/@trezor/websocket-client": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@trezor/websocket-client/-/websocket-client-1.2.2.tgz", - "integrity": "sha512-vu9L1V/5yh8LHQCmsGC9scCnihELsVuR5Tri1IvW3CdgTUFFcfjsEgXsFqFME3HlxuUmx6qokw0Gx/o0/hzaSQ==", + "node_modules/@trezor/connect-plugin-stellar": { + "version": "9.2.6", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { - "bignumber.js": "^9.3.0" + "@trezor/utils": "9.5.0" }, "peerDependencies": { + "@stellar/stellar-sdk": "^13.3.0", + "@trezor/connect": "9.x.x", "tslib": "^2.6.2" } }, - "node_modules/@trezor/connect-web/node_modules/base-x": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz", - "integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==", - "license": "MIT" - }, - "node_modules/@trezor/connect-web/node_modules/bs58": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz", - "integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==", - "license": "MIT", - "dependencies": { - "base-x": "^5.0.0" -======= "node_modules/@trezor/connect-web": { "version": "9.7.2", "license": "SEE LICENSE IN LICENSE.md", @@ -6709,7 +5080,6 @@ }, "peerDependencies": { "tslib": "^2.6.2" ->>>>>>> main } }, "node_modules/@trezor/connect/node_modules/base-x": { @@ -6723,21 +5093,9 @@ "base-x": "^5.0.0" } }, -<<<<<<< HEAD - "node_modules/@trezor/device-authenticity/node_modules/@noble/curves": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", - "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "ua-parser-js": "^2.0.4" - }, -======= "node_modules/@trezor/crypto-utils": { "version": "1.2.0", "license": "SEE LICENSE IN LICENSE.md", ->>>>>>> main "peerDependencies": { "expo-constants": "*", "expo-localization": "*", @@ -6769,12 +5127,6 @@ "url": "https://paulmillr.com/funding/" } }, -<<<<<<< HEAD - "node_modules/@trezor/device-utils": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@trezor/device-utils/-/device-utils-1.2.0.tgz", - "integrity": "sha512-Aqp7pIooFTx21zRUtTI6i1AS4d9Lrx7cclvksh2nJQF9WJvbzuCXshEGkLoOsHwhQrCl3IXfbGuMdA12yDenPA==", -======= "node_modules/@trezor/device-authenticity": { "version": "1.1.2", "license": "See LICENSE.md in repo root", @@ -6811,7 +5163,6 @@ }, "node_modules/@trezor/device-utils": { "version": "1.2.0", ->>>>>>> main "license": "See LICENSE.md in repo root" }, "node_modules/@trezor/env-utils": { @@ -6840,11 +5191,6 @@ }, "node_modules/@trezor/protobuf": { "version": "1.5.2", -<<<<<<< HEAD - "resolved": "https://registry.npmjs.org/@trezor/protobuf/-/protobuf-1.5.2.tgz", - "integrity": "sha512-zViaL1jKue8DUTVEDg0C/lMipqNMd/Z3kr29/+MeZOoupjaXIQ2Lqp3WAMe8hvNTKKX8aNQH9JrbapJ6w9FMXw==", -======= ->>>>>>> main "license": "See LICENSE.md in repo root", "dependencies": { "@trezor/schema-utils": "1.4.0", @@ -6857,11 +5203,6 @@ }, "node_modules/@trezor/protocol": { "version": "1.3.0", -<<<<<<< HEAD - "resolved": "https://registry.npmjs.org/@trezor/protocol/-/protocol-1.3.0.tgz", - "integrity": "sha512-rmrxbDrdgxTouBPbZcSeqU7ba/e5WVT1dxvxxEntHqRdTiDl7d3VK+BErCrlyol8EH5YCqEF3/rXt0crSOfoFw==", -======= ->>>>>>> main "license": "See LICENSE.md in repo root", "peerDependencies": { "tslib": "^2.6.2" @@ -6869,11 +5210,6 @@ }, "node_modules/@trezor/schema-utils": { "version": "1.4.0", -<<<<<<< HEAD - "resolved": "https://registry.npmjs.org/@trezor/schema-utils/-/schema-utils-1.4.0.tgz", - "integrity": "sha512-K7upSeh7VDrORaIC4KAxYVW93XNlohmUnH5if/5GKYmTdQSRp1nBkO6Jm+Z4hzIthdnz/1aLgnbeN3bDxWLRxA==", -======= ->>>>>>> main "license": "See LICENSE.md in repo root", "dependencies": { "@sinclair/typebox": "^0.33.7", @@ -6883,15 +5219,6 @@ "tslib": "^2.6.2" } }, -<<<<<<< HEAD - "node_modules/@trezor/utils": { - "version": "9.4.2", - "resolved": "https://registry.npmjs.org/@trezor/utils/-/utils-9.4.2.tgz", - "integrity": "sha512-Fm3m2gmfXsgv4chqn5HX8e8dElEr2ibBJSJ7HE3bsHh/1OSQcDdzsSioAK04Fo9ws/v7n6lt+QBZ6fGmwyIkZQ==", - "license": "SEE LICENSE IN LICENSE.md", - "dependencies": { - "bignumber.js": "^9.3.1" -======= "node_modules/@trezor/transport": { "version": "1.6.2", "license": "SEE LICENSE IN LICENSE.md", @@ -6902,7 +5229,6 @@ "@trezor/utils": "9.5.0", "cross-fetch": "^4.0.0", "usb": "^2.15.0" ->>>>>>> main }, "peerDependencies": { "tslib": "^2.6.2" @@ -6924,11 +5250,6 @@ }, "node_modules/@trezor/utxo-lib": { "version": "2.5.0", -<<<<<<< HEAD - "resolved": "https://registry.npmjs.org/@trezor/utxo-lib/-/utxo-lib-2.5.0.tgz", - "integrity": "sha512-Fa2cZh0037oX6AHNLfpFIj65UR/OoX0ZJTocFuQASe77/1PjZHysf6BvvGfmzuFToKfrAQ+DM/1Sx+P/vnyNmA==", -======= ->>>>>>> main "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "@trezor/utils": "9.5.0", @@ -6964,8 +5285,6 @@ "base-x": "^5.0.0" } }, -<<<<<<< HEAD -======= "node_modules/@trezor/websocket-client": { "version": "1.3.0", "license": "SEE LICENSE IN LICENSE.md", @@ -7072,7 +5391,6 @@ } } }, ->>>>>>> main "node_modules/@tybys/wasm-util": { "version": "0.10.1", "license": "MIT", @@ -7085,54 +5403,6 @@ "version": "5.0.4", "devOptional": true, "license": "MIT" -<<<<<<< HEAD - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } -======= ->>>>>>> main }, "node_modules/@types/canvas-confetti": { "version": "1.9.0", @@ -7161,13 +5431,7 @@ }, "node_modules/@types/d3-color": { "version": "3.1.3", -<<<<<<< HEAD - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", - "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", - "devOptional": true, -======= "dev": true, ->>>>>>> main "license": "MIT" }, "node_modules/@types/d3-ease": { @@ -7178,14 +5442,10 @@ "node_modules/@types/d3-interpolate": { "version": "3.0.4", "dev": true, -<<<<<<< HEAD - "license": "MIT" -======= "license": "MIT", "dependencies": { "@types/d3-color": "*" } ->>>>>>> main }, "node_modules/@types/d3-path": { "version": "1.0.11", @@ -7225,14 +5485,11 @@ "@types/ms": "*" } }, -<<<<<<< HEAD -======= "node_modules/@types/deep-eql": { "version": "4.0.2", "devOptional": true, "license": "MIT" }, ->>>>>>> main "node_modules/@types/estree": { "version": "1.0.8", "license": "MIT" @@ -7895,11 +6152,6 @@ }, "node_modules/@vitest/coverage-v8": { "version": "4.1.2", -<<<<<<< HEAD - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.2.tgz", - "integrity": "sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg==", -======= ->>>>>>> main "dev": true, "license": "MIT", "dependencies": { @@ -7957,11 +6209,6 @@ }, "node_modules/@vitest/expect": { "version": "4.1.2", -<<<<<<< HEAD - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", - "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", -======= ->>>>>>> main "devOptional": true, "license": "MIT", "dependencies": { @@ -7978,11 +6225,6 @@ }, "node_modules/@vitest/mocker": { "version": "4.1.2", -<<<<<<< HEAD - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", - "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", -======= ->>>>>>> main "devOptional": true, "license": "MIT", "dependencies": { @@ -8008,11 +6250,6 @@ }, "node_modules/@vitest/pretty-format": { "version": "4.1.2", -<<<<<<< HEAD - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", - "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", -======= ->>>>>>> main "devOptional": true, "license": "MIT", "dependencies": { @@ -8024,11 +6261,6 @@ }, "node_modules/@vitest/runner": { "version": "4.1.2", -<<<<<<< HEAD - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", - "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", -======= ->>>>>>> main "devOptional": true, "license": "MIT", "dependencies": { @@ -8041,11 +6273,6 @@ }, "node_modules/@vitest/snapshot": { "version": "4.1.2", -<<<<<<< HEAD - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", - "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", -======= ->>>>>>> main "devOptional": true, "license": "MIT", "dependencies": { @@ -8060,11 +6287,6 @@ }, "node_modules/@vitest/spy": { "version": "4.1.2", -<<<<<<< HEAD - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", - "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", -======= ->>>>>>> main "devOptional": true, "license": "MIT", "funding": { @@ -8073,11 +6295,6 @@ }, "node_modules/@vitest/utils": { "version": "4.1.2", -<<<<<<< HEAD - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", - "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", -======= ->>>>>>> main "devOptional": true, "license": "MIT", "dependencies": { @@ -8906,11 +7123,6 @@ }, "node_modules/ansi-regex": { "version": "5.0.1", -<<<<<<< HEAD - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", -======= ->>>>>>> main "license": "MIT", "engines": { "node": ">=8" @@ -8918,11 +7130,6 @@ }, "node_modules/ansi-styles": { "version": "5.2.0", -<<<<<<< HEAD - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", -======= ->>>>>>> main "devOptional": true, "license": "MIT", "engines": { @@ -9095,11 +7302,6 @@ }, "node_modules/asn1.js": { "version": "4.10.1", -<<<<<<< HEAD - "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz", - "integrity": "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==", -======= ->>>>>>> main "license": "MIT", "dependencies": { "bn.js": "^4.0.0", @@ -9109,11 +7311,6 @@ }, "node_modules/asn1.js/node_modules/bn.js": { "version": "4.12.3", -<<<<<<< HEAD - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", - "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", -======= ->>>>>>> main "license": "MIT" }, "node_modules/assert": { @@ -9128,8 +7325,6 @@ "util": "^0.12.5" } }, -<<<<<<< HEAD -======= "node_modules/assertion-error": { "version": "2.0.1", "devOptional": true, @@ -9138,7 +7333,6 @@ "node": ">=12" } }, ->>>>>>> main "node_modules/ast-v8-to-istanbul": { "version": "1.0.0", "dev": true, @@ -9274,11 +7468,6 @@ }, "node_modules/baseline-browser-mapping": { "version": "2.10.11", -<<<<<<< HEAD - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.11.tgz", - "integrity": "sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg==", -======= ->>>>>>> main "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -9423,11 +7612,6 @@ }, "node_modules/browserify-aes": { "version": "1.2.0", -<<<<<<< HEAD - "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", - "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==", -======= ->>>>>>> main "license": "MIT", "dependencies": { "buffer-xor": "^1.0.3", @@ -9440,11 +7624,6 @@ }, "node_modules/browserify-cipher": { "version": "1.0.1", -<<<<<<< HEAD - "resolved": "https://registry.npmjs.org/browserify-cipher/-/browserify-cipher-1.0.1.tgz", - "integrity": "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==", -======= ->>>>>>> main "license": "MIT", "dependencies": { "browserify-aes": "^1.0.4", @@ -9454,11 +7633,6 @@ }, "node_modules/browserify-des": { "version": "1.0.2", -<<<<<<< HEAD - "resolved": "https://registry.npmjs.org/browserify-des/-/browserify-des-1.0.2.tgz", - "integrity": "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==", -======= ->>>>>>> main "license": "MIT", "dependencies": { "cipher-base": "^1.0.1", @@ -9469,11 +7643,6 @@ }, "node_modules/browserify-rsa": { "version": "4.1.1", -<<<<<<< HEAD - "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.1.1.tgz", - "integrity": "sha512-YBjSAiTqM04ZVei6sXighu679a3SqWORA3qZTEqZImnlkDIFtKc6pNutpjyZ8RJTjQtuYfeetkxM11GwoYXMIQ==", -======= ->>>>>>> main "license": "MIT", "dependencies": { "bn.js": "^5.2.1", @@ -9486,11 +7655,6 @@ }, "node_modules/browserify-sign": { "version": "4.2.5", -<<<<<<< HEAD - "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.5.tgz", - "integrity": "sha512-C2AUdAJg6rlM2W5QMp2Q4KGQMVBwR1lIimTsUnutJ8bMpW5B52pGpR2gEnNBNwijumDo5FojQ0L9JrXA8m4YEw==", -======= ->>>>>>> main "license": "ISC", "dependencies": { "bn.js": "^5.2.2", @@ -9509,20 +7673,10 @@ }, "node_modules/browserify-sign/node_modules/isarray": { "version": "1.0.0", -<<<<<<< HEAD - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", -======= ->>>>>>> main "license": "MIT" }, "node_modules/browserify-sign/node_modules/readable-stream": { "version": "2.3.8", -<<<<<<< HEAD - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", -======= ->>>>>>> main "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", @@ -9536,20 +7690,10 @@ }, "node_modules/browserify-sign/node_modules/readable-stream/node_modules/safe-buffer": { "version": "5.1.2", -<<<<<<< HEAD - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", -======= ->>>>>>> main "license": "MIT" }, "node_modules/browserify-sign/node_modules/string_decoder": { "version": "1.1.1", -<<<<<<< HEAD - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", -======= ->>>>>>> main "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" @@ -9557,11 +7701,6 @@ }, "node_modules/browserify-sign/node_modules/string_decoder/node_modules/safe-buffer": { "version": "5.1.2", -<<<<<<< HEAD - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", -======= ->>>>>>> main "license": "MIT" }, "node_modules/browserify-zlib": { @@ -9658,11 +7797,6 @@ }, "node_modules/buffer-xor": { "version": "1.0.3", -<<<<<<< HEAD - "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", - "integrity": "sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==", -======= ->>>>>>> main "license": "MIT" }, "node_modules/bufferutil": { @@ -9775,11 +7909,6 @@ }, "node_modules/cbor": { "version": "10.0.12", -<<<<<<< HEAD - "resolved": "https://registry.npmjs.org/cbor/-/cbor-10.0.12.tgz", - "integrity": "sha512-exQDevYd7ZQLP4moMQcZkKCVZsXLAtUSflObr3xTh4xzFIv/xBCdvCd6L259kQOUP2kcTC0jvC6PpZIf/WmRXA==", -======= ->>>>>>> main "license": "MIT", "dependencies": { "nofilter": "^3.0.2" @@ -10048,19 +8177,6 @@ "node": ">=12" } }, -<<<<<<< HEAD - "node_modules/cliui/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, -======= ->>>>>>> main "node_modules/cliui/node_modules/ansi-styles": { "version": "4.3.0", "dev": true, @@ -10298,11 +8414,6 @@ }, "node_modules/create-ecdh": { "version": "4.0.4", -<<<<<<< HEAD - "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz", - "integrity": "sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==", -======= ->>>>>>> main "license": "MIT", "dependencies": { "bn.js": "^4.1.0", @@ -10311,11 +8422,6 @@ }, "node_modules/create-ecdh/node_modules/bn.js": { "version": "4.12.3", -<<<<<<< HEAD - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", - "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", -======= ->>>>>>> main "license": "MIT" }, "node_modules/create-hash": { @@ -10379,8 +8485,6 @@ "node": "*" } }, -<<<<<<< HEAD -======= "node_modules/crypto-browserify": { "version": "3.12.0", "license": "MIT", @@ -10410,7 +8514,6 @@ "node": ">=8" } }, ->>>>>>> main "node_modules/css-tree": { "version": "3.2.1", "dev": true, @@ -10808,11 +8911,6 @@ }, "node_modules/des.js": { "version": "1.1.0", -<<<<<<< HEAD - "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", - "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", -======= ->>>>>>> main "license": "MIT", "dependencies": { "inherits": "^2.0.1", @@ -10865,11 +8963,6 @@ }, "node_modules/diffie-hellman": { "version": "5.0.3", -<<<<<<< HEAD - "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", - "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==", -======= ->>>>>>> main "license": "MIT", "dependencies": { "bn.js": "^4.1.0", @@ -10879,11 +8972,6 @@ }, "node_modules/diffie-hellman/node_modules/bn.js": { "version": "4.12.3", -<<<<<<< HEAD - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", - "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", -======= ->>>>>>> main "license": "MIT" }, "node_modules/dijkstrajs": { @@ -10914,16 +9002,8 @@ }, "node_modules/dom-accessibility-api": { "version": "0.5.16", -<<<<<<< HEAD - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "devOptional": true, - "license": "MIT", - "peer": true -======= "devOptional": true, "license": "MIT" ->>>>>>> main }, "node_modules/domain-browser": { "version": "4.22.0", @@ -11699,11 +9779,6 @@ }, "node_modules/evp_bytestokey": { "version": "1.0.3", -<<<<<<< HEAD - "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", - "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==", -======= ->>>>>>> main "license": "MIT", "dependencies": { "md5.js": "^1.3.4", @@ -12291,12 +10366,9 @@ "license": "MIT", "engines": { "node": ">= 0.4" -<<<<<<< HEAD -======= }, "funding": { "url": "https://github.com/sponsors/ljharb" ->>>>>>> main } }, "node_modules/graceful-fs": { @@ -12676,16 +10748,8 @@ } }, "node_modules/idb-keyval": { -<<<<<<< HEAD - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz", - "integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==", - "license": "Apache-2.0", - "peer": true -======= "version": "6.2.1", "license": "Apache-2.0" ->>>>>>> main }, "node_modules/ieee754": { "version": "1.2.1", @@ -14043,11 +12107,6 @@ }, "node_modules/log-update/node_modules/ansi-styles": { "version": "6.2.3", -<<<<<<< HEAD - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", -======= ->>>>>>> main "dev": true, "license": "MIT", "engines": { @@ -17206,19 +15265,6 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, -<<<<<<< HEAD - "node_modules/slugify": { - "version": "1.6.8", - "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.8.tgz", - "integrity": "sha512-HVk9X1E0gz3mSpoi60h/saazLKXKaZThMLU3u/aNwoYn8/xQyX2MGxL0ui2eaokkD7tF+Zo+cKTHUbe1mmmGzA==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=8.0.0" - } - }, -======= "node_modules/slice-ansi/node_modules/ansi-styles": { "version": "6.2.3", "dev": true, @@ -17234,7 +15280,6 @@ "version": "0.3.2", "license": "MIT" }, ->>>>>>> main "node_modules/smart-buffer": { "version": "4.2.0", "license": "MIT", @@ -17577,11 +15622,6 @@ }, "node_modules/strip-ansi/node_modules/ansi-regex": { "version": "6.2.2", -<<<<<<< HEAD - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", -======= ->>>>>>> main "dev": true, "license": "MIT", "engines": { @@ -17591,8 +15631,6 @@ "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, -<<<<<<< HEAD -======= "node_modules/strip-final-newline": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", @@ -17602,7 +15640,6 @@ "node": ">=6" } }, ->>>>>>> main "node_modules/strip-indent": { "version": "3.0.0", "dev": true, @@ -17911,12 +15948,6 @@ } }, "node_modules/tr46": { -<<<<<<< HEAD - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" -======= "version": "6.0.0", "dev": true, "license": "MIT", @@ -17926,7 +15957,6 @@ "engines": { "node": ">=20" } ->>>>>>> main }, "node_modules/tree-kill": { "version": "1.2.2", @@ -18190,13 +16220,7 @@ "license": "MIT" }, "node_modules/undici": { -<<<<<<< HEAD - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.5.tgz", - "integrity": "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==", -======= "version": "7.24.6", ->>>>>>> main "dev": true, "license": "MIT", "engines": { @@ -18980,24 +17004,12 @@ "node": ">=18" } }, -<<<<<<< HEAD - "node_modules/walker": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", - "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "makeerror": "1.0.12" -======= "node_modules/webidl-conversions": { "version": "8.0.1", "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=20" ->>>>>>> main } }, "node_modules/wcwidth": { @@ -19034,29 +17046,14 @@ } }, "node_modules/whatwg-url": { -<<<<<<< HEAD - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", -======= "version": "16.0.1", "dev": true, ->>>>>>> main "license": "MIT", "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, -<<<<<<< HEAD - "node_modules/whatwg-url-minimum": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/whatwg-url-minimum/-/whatwg-url-minimum-0.1.1.tgz", - "integrity": "sha512-u2FNVjFVFZhdjb502KzXy1gKn1mEisQRJssmSJT8CPhZdZa0AP6VCbWlXERKyGu0l09t0k50FiDiralpGhBxgA==", - "license": "MIT", - "optional": true, - "peer": true -======= "node_modules/whatwg-url/node_modules/@exodus/bytes": { "version": "1.15.0", "dev": true, @@ -19087,7 +17084,6 @@ "funding": { "url": "https://paulmillr.com/funding/" } ->>>>>>> main }, "node_modules/which": { "version": "2.0.2", @@ -19375,19 +17371,6 @@ "node": ">=12" } }, -<<<<<<< HEAD - "node_modules/yargs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, -======= ->>>>>>> main "node_modules/yargs/node_modules/is-fullwidth-code-point": { "version": "3.0.0", "dev": true, diff --git a/package.json b/package.json index 0c79d819..0515783a 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "dev:ui": "vite", "start": "node scripts/start-dev.mjs", "start:full": "concurrently --kill-others-on-fail --names stellar,vite -c gray,green --pad-prefix \"stellar scaffold watch --build-clients\" \"vite\"", - "build": "tsc -b && vite build", + "build": "vite build", "generate:clients": "bash scripts/generate-clients.sh", "install:contracts": "if ls packages/*/package.json > /dev/null 2>&1; then npm install --workspaces && npm run build --workspaces --if-present; else echo 'No contract client packages found. Run: npm run generate:clients'; fi", "preview": "vite preview", diff --git a/server/package-lock.json b/server/package-lock.json index b80cccb7..42b5c14c 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -18,6 +18,7 @@ "helmet": "^8.1.0", "ioredis": "^5.6.0", "jsonwebtoken": "^9.0.2", + "learnvault-frontend": "file:..", "multer": "^2.0.0", "node-cache": "^5.1.2", "nodemailer": "^8.0.4", @@ -53,6 +54,77 @@ "typescript": "^5.7.2" } }, + "..": { + "name": "learnvault-frontend", + "version": "0.0.1", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@creit.tech/stellar-wallets-kit": "^2.0.1", + "@stellar/design-system": "^3.2.7", + "@stellar/stellar-sdk": "^14.4.3", + "@stellar/stellar-xdr-json": "^23.0.0", + "@tailwindcss/vite": "^4.2.2", + "@tanstack/react-query": "^5.90.17", + "@theahaco/contract-explorer": "^1.1.0", + "@theahaco/ts-config": "^1.2.0", + "canvas-confetti": "^1.9.4", + "date-fns": "^4.1.0", + "deps": "^1.0.0", + "driver.js": "^1.4.0", + "framer-motion": "^12.38.0", + "i18next": "^25.10.5", + "i18next-browser-languagedetector": "^8.2.1", + "lossless-json": "^4.3.0", + "lucide-react": "^1.7.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-helmet": "^6.1.0", + "react-i18next": "^16.6.2", + "react-is": "^19.2.4", + "react-markdown": "^10.1.0", + "react-router-dom": "^7.13.2", + "recharts": "^2.15.1", + "resend": "^6.9.4", + "sonner": "^2.0.7", + "tailwindcss": "^4.2.2", + "utf-8-validate": "^5.0.10", + "zod": "^4.3.5" + }, + "devDependencies": { + "@playwright/test": "^1.55.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/canvas-confetti": "^1.9.0", + "@types/lodash": "^4.17.23", + "@types/react": "^19.2.10", + "@types/react-dom": "^19.2.3", + "@types/react-helmet": "^6.1.11", + "@types/react-router-dom": "^5.3.3", + "@types/recharts": "^1.8.29", + "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.1.1", + "concurrently": "^9.2.1", + "dotenv": "^17.2.3", + "eslint": "^9.39.2", + "glob": "^13.0.0", + "globals": "^17.0.0", + "husky": "^9.1.7", + "i18next-scanner": "^4.3.1", + "jsdom": "^29.0.1", + "lint-staged": "^16.2.7", + "prettier": "^3.8.0", + "recharts": "^3.8.0", + "typescript": "~5.9.3", + "vite": "^8.0.3", + "vite-plugin-node-polyfills": "^0.26.0", + "vite-plugin-wasm": "^3.5.0", + "vitest": "^4.1.1" + } + }, "node_modules/@apidevtools/json-schema-ref-parser": { "version": "9.1.2", "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz", @@ -5733,6 +5805,10 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/learnvault-frontend": { + "resolved": "..", + "link": true + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", diff --git a/server/package.json b/server/package.json index 066279ee..a0ca563d 100644 --- a/server/package.json +++ b/server/package.json @@ -10,15 +10,11 @@ "test": "jest --runInBand", "migrate": "ts-node scripts/migrate.ts up", "migrate:rollback": "ts-node scripts/migrate.ts down", -<<<<<<< HEAD - "db:migrate": "npm run migrate", - "db:seed": "ts-node scripts/seed.ts" -======= "migrate:verify": "ts-node scripts/verify-migrations.ts", "db:migrate": "npm run migrate", "db:seed": "ts-node scripts/seed.ts", - "db:query:analyze": "ts-node scripts/query-analysis.ts" ->>>>>>> main + "db:query:analyze": "ts-node scripts/query-analysis.ts", + "test:coverage": "jest --runInBand --testPathPatterns=\"src/tests/(auth|bookmarks|comments|csrf|upload)\\.test\\.ts\"" }, "dependencies": { "@pinata/sdk": "^2.1.0", @@ -31,6 +27,7 @@ "helmet": "^8.1.0", "ioredis": "^5.6.0", "jsonwebtoken": "^9.0.2", + "learnvault-frontend": "file:..", "multer": "^2.0.0", "node-cache": "^5.1.2", "nodemailer": "^8.0.4", diff --git a/server/scripts/migrate.ts b/server/scripts/migrate.ts index 4c1c47b4..0bfb9f87 100644 --- a/server/scripts/migrate.ts +++ b/server/scripts/migrate.ts @@ -13,11 +13,7 @@ import fs from "node:fs" import path from "node:path" import dotenv from "dotenv" -<<<<<<< HEAD -import { Pool, PoolClient } from "pg" -======= import { Pool, type PoolClient } from "pg" ->>>>>>> main dotenv.config({ path: path.resolve(__dirname, "../.env") }) @@ -48,13 +44,9 @@ async function migrateUp(): Promise { const { rows: applied } = await client.query<{ filename: string }>( "SELECT filename FROM schema_migrations ORDER BY filename", ) -<<<<<<< HEAD - const appliedSet = new Set(applied.map((r: { filename: string }) => r.filename)) -======= const appliedSet = new Set( applied.map((r: { filename: string }) => r.filename), ) ->>>>>>> main const files = fs .readdirSync(MIGRATIONS_DIR) @@ -127,16 +119,9 @@ async function migrateDown(): Promise { await client.query("BEGIN") try { await client.query(sql) -<<<<<<< HEAD - await client.query( - "DELETE FROM schema_migrations WHERE filename = $1", - [last], - ) -======= await client.query("DELETE FROM schema_migrations WHERE filename = $1", [ last, ]) ->>>>>>> main await client.query("COMMIT") console.log(`\nRolled back: ${last}`) } catch (err) { diff --git a/server/src/controllers/courses.controller.ts b/server/src/controllers/courses.controller.ts index 6db9db0f..938a0848 100644 --- a/server/src/controllers/courses.controller.ts +++ b/server/src/controllers/courses.controller.ts @@ -24,6 +24,9 @@ type LessonRow = { order_index: number estimated_minutes: number is_milestone: boolean + version: number + is_active: boolean + change_summary: string | null created_at: string updated_at: string quiz: Array<{ @@ -55,6 +58,9 @@ const toLesson = (row: LessonRow) => ({ order: row.order_index, estimatedMinutes: Number(row.estimated_minutes ?? 10), isMilestone: row.is_milestone, + version: Number(row.version ?? 1), + isLatest: Boolean(row.is_active), + changeSummary: row.change_summary, quiz: row.quiz ?? [], createdAt: row.created_at, updatedAt: row.updated_at, @@ -62,6 +68,33 @@ const toLesson = (row: LessonRow) => ({ const difficultyValues = new Set(["beginner", "intermediate", "advanced"]) +function parseOptionalLearnerAddress(req: Request): string | null { + const learnerAddress = + typeof req.query.learner_address === "string" + ? req.query.learner_address.trim() + : "" + return learnerAddress.length > 0 ? learnerAddress : null +} + +function buildSimpleLineDiff( + before: string, + after: string, +): { + addedLines: string[] + removedLines: string[] +} { + const beforeLines = before.split(/\r?\n/) + const afterLines = after.split(/\r?\n/) + + const beforeSet = new Set(beforeLines) + const afterSet = new Set(afterLines) + + const removedLines = beforeLines.filter((line) => !afterSet.has(line)) + const addedLines = afterLines.filter((line) => !beforeSet.has(line)) + + return { addedLines, removedLines } +} + export const getCourses = async ( req: Request, res: Response, @@ -222,8 +255,48 @@ export const getCourse = async (req: Request, res: Response): Promise => { return } + const latestVersionResult = (await pool.query( + `SELECT COALESCE(MAX(version), 1)::int AS latest_version + FROM lessons + WHERE course_id = $1`, + [course.id], + )) as { rows: Array<{ latest_version: number }> } + + const latestContentVersion = Number( + latestVersionResult.rows[0]?.latest_version ?? 1, + ) + const learnerAddress = parseOptionalLearnerAddress(req) + let enrollmentContentVersion: number | null = null + + if (learnerAddress) { + const enrollmentVersionResult = (await pool.query( + `SELECT content_version + FROM enrollments + WHERE learner_address = $1 + AND course_id = $2 + LIMIT 1`, + [learnerAddress, course.slug], + )) as { rows: Array<{ content_version: number | null }> } + + const maybeVersion = enrollmentVersionResult.rows[0]?.content_version + if (typeof maybeVersion === "number" && Number.isFinite(maybeVersion)) { + enrollmentContentVersion = maybeVersion + } + } + + const effectiveVersion = enrollmentContentVersion ?? latestContentVersion + const lessonResult = (await pool.query( - `SELECT + `WITH selected_lessons AS ( + SELECT + order_index, + MAX(version)::int AS version + FROM lessons + WHERE course_id = $1 + AND version <= $2 + GROUP BY order_index + ) + SELECT l.id, l.course_id, l.title, @@ -231,6 +304,9 @@ export const getCourse = async (req: Request, res: Response): Promise => { l.order_index, l.estimated_minutes, BOOL_OR(m.id IS NOT NULL) AS is_milestone, + l.version, + l.is_active, + l.change_summary, l.created_at, l.updated_at, COALESCE( @@ -245,17 +321,25 @@ export const getCourse = async (req: Request, res: Response): Promise => { '[]'::json ) AS quiz FROM lessons l + INNER JOIN selected_lessons sl + ON sl.order_index = l.order_index + AND sl.version = l.version LEFT JOIN milestones m ON m.lesson_id = l.id LEFT JOIN quizzes q ON q.lesson_id = l.id LEFT JOIN quiz_questions qq ON qq.quiz_id = q.id WHERE l.course_id = $1 GROUP BY l.id ORDER BY l.order_index ASC`, - [course.id], + [course.id, effectiveVersion], )) as { rows: LessonRow[] } res.status(200).json({ ...toCourse(course), + enrollmentContentVersion, + latestContentVersion, + hasUpdatedContent: + enrollmentContentVersion !== null && + enrollmentContentVersion < latestContentVersion, lessons: lessonResult.rows.map(toLesson), }) } catch { @@ -286,6 +370,9 @@ export const getCourseLessonById = async ( l.order_index, l.estimated_minutes, BOOL_OR(m.id IS NOT NULL) AS is_milestone, + l.version, + l.is_active, + l.change_summary, l.created_at, l.updated_at, COALESCE( @@ -324,6 +411,372 @@ export const getCourseLessonById = async ( } } +export const updateLessonVersion = async ( + req: Request, + res: Response, +): Promise => { + const orderIndex = Number.parseInt(req.params.orderIndex, 10) + if (!Number.isInteger(orderIndex) || orderIndex < 1) { + res.status(400).json({ error: "orderIndex must be a positive integer" }) + return + } + + const body = req.body as { + title?: unknown + content?: unknown + content_markdown?: unknown + estimatedMinutes?: unknown + estimated_minutes?: unknown + changeSummary?: unknown + change_summary?: unknown + isMilestone?: unknown + is_milestone?: unknown + } + + const nextTitle = + typeof body.title === "string" + ? sanitizeHtml(body.title.trim(), { + allowedTags: [], + allowedAttributes: {}, + }) + : undefined + const nextContent = + typeof body.content_markdown === "string" + ? body.content_markdown + : typeof body.content === "string" + ? body.content + : undefined + const nextEstimatedMinutesRaw = + body.estimated_minutes ?? body.estimatedMinutes + const nextEstimatedMinutes = + typeof nextEstimatedMinutesRaw === "number" && + Number.isInteger(nextEstimatedMinutesRaw) && + nextEstimatedMinutesRaw > 0 + ? nextEstimatedMinutesRaw + : undefined + const nextChangeSummaryRaw = body.change_summary ?? body.changeSummary + const nextChangeSummary = + typeof nextChangeSummaryRaw === "string" + ? nextChangeSummaryRaw.trim() + : nextChangeSummaryRaw === null + ? null + : undefined + const nextIsMilestoneRaw = body.is_milestone ?? body.isMilestone + const nextIsMilestone = + typeof nextIsMilestoneRaw === "boolean" ? nextIsMilestoneRaw : undefined + + if ( + nextTitle === undefined && + nextContent === undefined && + nextEstimatedMinutes === undefined && + nextChangeSummary === undefined && + nextIsMilestone === undefined + ) { + res.status(400).json({ + error: + "Provide at least one field to version: title, content/content_markdown, estimatedMinutes/estimated_minutes, changeSummary/change_summary, or isMilestone/is_milestone", + }) + return + } + + if (nextTitle !== undefined && nextTitle.length === 0) { + res.status(400).json({ error: "title cannot be empty" }) + return + } + + const idOrSlug = req.params.idOrSlug + const isNumericId = /^\d+$/.test(idOrSlug) + + const client = await pool.connect() + try { + await client.query("BEGIN") + + const courseResult = (await client.query( + `SELECT id, slug + FROM courses + WHERE ${isNumericId ? "id = $1" : "slug = $1"} + LIMIT 1`, + [isNumericId ? Number.parseInt(idOrSlug, 10) : idOrSlug], + )) as { rows: Array<{ id: number; slug: string }> } + + const course = courseResult.rows[0] + if (!course) { + await client.query("ROLLBACK") + res.status(404).json({ error: "Course not found" }) + return + } + + const currentLessonResult = (await client.query( + `SELECT id, title, content_markdown, estimated_minutes, version, order_index + FROM lessons + WHERE course_id = $1 + AND order_index = $2 + AND is_active = TRUE + ORDER BY version DESC + LIMIT 1 + FOR UPDATE`, + [course.id, orderIndex], + )) as { + rows: Array<{ + id: number + title: string + content_markdown: string + estimated_minutes: number + version: number + order_index: number + }> + } + + const currentLesson = currentLessonResult.rows[0] + if (!currentLesson) { + await client.query("ROLLBACK") + res.status(404).json({ error: "Active lesson version not found" }) + return + } + + const milestoneRows = (await client.query( + `SELECT id + FROM milestones + WHERE course_id = $1 + AND lesson_id = $2 + ORDER BY id ASC`, + [course.id, currentLesson.id], + )) as { rows: Array<{ id: number }> } + + const resolvedIsMilestone = nextIsMilestone ?? milestoneRows.rows.length > 0 + + await client.query( + `UPDATE lessons + SET is_active = FALSE, + superseded_at = CURRENT_TIMESTAMP + WHERE id = $1`, + [currentLesson.id], + ) + + const insertedLessonResult = (await client.query( + `INSERT INTO lessons ( + course_id, + order_index, + title, + content_markdown, + estimated_minutes, + version, + is_active, + change_summary + ) + VALUES ($1, $2, $3, $4, $5, $6, TRUE, $7) + RETURNING + id, + course_id, + title, + content_markdown, + order_index, + estimated_minutes, + version, + is_active, + change_summary, + created_at, + updated_at`, + [ + course.id, + orderIndex, + nextTitle ?? currentLesson.title, + nextContent ?? currentLesson.content_markdown, + nextEstimatedMinutes ?? currentLesson.estimated_minutes, + Number(currentLesson.version) + 1, + nextChangeSummary === undefined ? null : nextChangeSummary, + ], + )) as { rows: LessonRow[] } + + const insertedLesson = insertedLessonResult.rows[0] + + await client.query( + `UPDATE lessons + SET superseded_by = $1 + WHERE id = $2`, + [insertedLesson.id, currentLesson.id], + ) + + if (resolvedIsMilestone && milestoneRows.rows.length > 0) { + await client.query( + `UPDATE milestones + SET lesson_id = $1 + WHERE course_id = $2 + AND lesson_id = $3`, + [insertedLesson.id, course.id, currentLesson.id], + ) + } else if (!resolvedIsMilestone && milestoneRows.rows.length > 0) { + await client.query( + `UPDATE milestones + SET lesson_id = NULL + WHERE course_id = $1 + AND lesson_id = $2`, + [course.id, currentLesson.id], + ) + } + + const quizResult = (await client.query( + `SELECT id, passing_score + FROM quizzes + WHERE lesson_id = $1 + LIMIT 1`, + [currentLesson.id], + )) as { rows: Array<{ id: number; passing_score: number }> } + const existingQuiz = quizResult.rows[0] + + if (existingQuiz) { + const insertedQuizResult = (await client.query( + `INSERT INTO quizzes (lesson_id, passing_score) + VALUES ($1, $2) + RETURNING id`, + [insertedLesson.id, existingQuiz.passing_score], + )) as { rows: Array<{ id: number }> } + const insertedQuizId = insertedQuizResult.rows[0]?.id + + if (insertedQuizId) { + await client.query( + `INSERT INTO quiz_questions ( + quiz_id, + question_text, + options, + correct_index, + explanation + ) + SELECT + $1, + question_text, + options, + correct_index, + explanation + FROM quiz_questions + WHERE quiz_id = $2 + ORDER BY id ASC`, + [insertedQuizId, existingQuiz.id], + ) + } + } + + await client.query("COMMIT") + + res.status(200).json({ + course_id: course.slug, + order_index: orderIndex, + superseded_version: currentLesson.version, + lesson: toLesson({ + ...insertedLesson, + quiz: [], + is_milestone: resolvedIsMilestone, + }), + }) + } catch { + await client.query("ROLLBACK") + res.status(500).json({ error: "Internal server error" }) + } finally { + client.release() + } +} + +export const getLessonVersionDiff = async ( + req: Request, + res: Response, +): Promise => { + try { + const idOrSlug = req.params.idOrSlug + const orderIndex = Number.parseInt(req.params.orderIndex, 10) + const fromVersion = Number.parseInt(String(req.query.fromVersion ?? ""), 10) + const toVersion = Number.parseInt(String(req.query.toVersion ?? ""), 10) + + if ( + !Number.isInteger(orderIndex) || + orderIndex < 1 || + !Number.isInteger(fromVersion) || + fromVersion < 1 || + !Number.isInteger(toVersion) || + toVersion < 1 + ) { + res.status(400).json({ + error: + "orderIndex path param and fromVersion/toVersion query params must be positive integers", + }) + return + } + + const isNumericId = /^\d+$/.test(idOrSlug) + const courseResult = (await pool.query( + `SELECT id, slug + FROM courses + WHERE ${isNumericId ? "id = $1" : "slug = $1"} + LIMIT 1`, + [isNumericId ? Number.parseInt(idOrSlug, 10) : idOrSlug], + )) as { rows: Array<{ id: number; slug: string }> } + + const course = courseResult.rows[0] + if (!course) { + res.status(404).json({ error: "Course not found" }) + return + } + + const [fromResult, toResult] = await Promise.all([ + pool.query( + `SELECT id, title, content_markdown, version, change_summary + FROM lessons + WHERE course_id = $1 + AND order_index = $2 + AND version = $3 + LIMIT 1`, + [course.id, orderIndex, fromVersion], + ), + pool.query( + `SELECT id, title, content_markdown, version, change_summary + FROM lessons + WHERE course_id = $1 + AND order_index = $2 + AND version = $3 + LIMIT 1`, + [course.id, orderIndex, toVersion], + ), + ]) + + const fromLesson = fromResult.rows[0] + const toLesson = toResult.rows[0] + + if (!fromLesson || !toLesson) { + res + .status(404) + .json({ error: "One or both lesson versions were not found" }) + return + } + + const { addedLines, removedLines } = buildSimpleLineDiff( + String(fromLesson.content_markdown ?? ""), + String(toLesson.content_markdown ?? ""), + ) + + res.status(200).json({ + course_id: course.slug, + order_index: orderIndex, + from: { + version: fromLesson.version, + title: fromLesson.title, + change_summary: fromLesson.change_summary, + }, + to: { + version: toLesson.version, + title: toLesson.title, + change_summary: toLesson.change_summary, + }, + diff: { + added_lines: addedLines, + removed_lines: removedLines, + added_count: addedLines.length, + removed_count: removedLines.length, + }, + }) + } catch { + res.status(500).json({ error: "Internal server error" }) + } +} + export const createCourse = async ( req: Request, res: Response, diff --git a/server/src/controllers/enrollments.controller.ts b/server/src/controllers/enrollments.controller.ts index 321f3ec1..46e49186 100644 --- a/server/src/controllers/enrollments.controller.ts +++ b/server/src/controllers/enrollments.controller.ts @@ -77,11 +77,22 @@ export const createEnrollment = async ( } // Insert enrollment record + const versionResult = await pool.query( + `SELECT COALESCE(MAX(l.version), 1)::int AS content_version + FROM lessons l + INNER JOIN courses c ON c.id = l.course_id + WHERE c.slug = $1 OR c.id::text = $1`, + [course_id], + ) + const contentVersion = Number( + versionResult.rows[0]?.content_version ?? 1, + ) + const result = await pool.query( - `INSERT INTO enrollments (learner_address, course_id, tx_hash) - VALUES ($1, $2, $3) - RETURNING id, enrolled_at`, - [learner_address, course_id, tx_hash], + `INSERT INTO enrollments (learner_address, course_id, tx_hash, content_version) + VALUES ($1, $2, $3, $4) + RETURNING id, enrolled_at, content_version`, + [learner_address, course_id, tx_hash, contentVersion], ) const enrollment = result.rows[0] @@ -89,6 +100,7 @@ export const createEnrollment = async ( res.status(201).json({ enrollment_id: enrollment.id, enrolled_at: enrollment.enrolled_at, + content_version: enrollment.content_version, }) } catch (error) { log.error({ err: error }, "Error creating enrollment") @@ -117,7 +129,7 @@ export const getEnrollments = async ( } const result = await pool.query( - `SELECT id, learner_address, course_id, tx_hash, enrolled_at + `SELECT id, learner_address, course_id, tx_hash, enrolled_at, content_version FROM enrollments WHERE learner_address = $1 ORDER BY enrolled_at DESC`, @@ -130,6 +142,7 @@ export const getEnrollments = async ( course_id: row.course_id, tx_hash: row.tx_hash, enrolled_at: row.enrolled_at, + content_version: row.content_version, })), }) } catch (error) { diff --git a/server/src/controllers/governance.controller.ts b/server/src/controllers/governance.controller.ts index beb324cd..b16fc20b 100644 --- a/server/src/controllers/governance.controller.ts +++ b/server/src/controllers/governance.controller.ts @@ -234,7 +234,10 @@ const castVoteSchema = z.object({ }) const castVoteSchema = z.object({ - proposal_id: z.number().int().positive("proposal_id must be a positive integer"), + proposal_id: z + .number() + .int() + .positive("proposal_id must be a positive integer"), voter_address: z .string() .min(56, "voter_address must be a valid Stellar address") @@ -373,25 +376,15 @@ export async function castVote(req: Request, res: Response): Promise { try { // 1. Check if proposal exists const proposalResult = await pool.query( -<<<<<<< HEAD - "SELECT id, status FROM proposals WHERE id = $1", - [proposal_id], - ) - - if (proposalResult.rows.length === 0) { -======= "SELECT id, status, deadline, cancelled FROM proposals WHERE id = $1", [proposal_id], ) if (!proposalResult?.rows || proposalResult.rows.length === 0) { ->>>>>>> main res.status(404).json({ error: "Proposal not found" }) return } -<<<<<<< HEAD -======= if (proposalResult.rows[0].cancelled) { res.status(400).json({ error: "Voting is closed for this proposal", @@ -399,7 +392,6 @@ export async function castVote(req: Request, res: Response): Promise { return } ->>>>>>> main // 2. Check if proposal is still pending if (proposalResult.rows[0].status !== "pending") { res.status(400).json({ @@ -408,8 +400,6 @@ export async function castVote(req: Request, res: Response): Promise { return } -<<<<<<< HEAD -======= if ( proposalResult.rows[0].deadline && new Date(proposalResult.rows[0].deadline).getTime() <= Date.now() @@ -420,27 +410,18 @@ export async function castVote(req: Request, res: Response): Promise { return } ->>>>>>> main // 3. Check if voter already voted const existingVote = await pool.query( "SELECT id FROM votes WHERE proposal_id = $1 AND voter_address = $2", [proposal_id, voter_address], ) -<<<<<<< HEAD - if (existingVote.rows.length > 0) { -======= if ((existingVote?.rows ?? []).length > 0) { ->>>>>>> main res.status(409).json({ error: "You have already voted on this proposal" }) return } -<<<<<<< HEAD - // 4. Check voter's GOV token balance (voting power) -======= // 4. Check voter's effective voting power (own balance + any delegated-to-them) ->>>>>>> main const rawBalance = await stellarContractService.getGovernanceTokenBalance(voter_address) const balanceBigInt = BigInt(rawBalance) @@ -454,13 +435,6 @@ export async function castVote(req: Request, res: Response): Promise { } // 5. Call the on-chain vote contract -<<<<<<< HEAD - const contractResult = await stellarContractService.castVote({ - voter: voter_address, - proposalId: proposal_id, - support, - }) -======= const contractResult = await stellarContractService.castVote( { voter: voter_address, @@ -469,7 +443,6 @@ export async function castVote(req: Request, res: Response): Promise { }, { requestId: req.requestId }, ) ->>>>>>> main // 6. Write to DB after successful contract call const votingPower = balanceBigInt @@ -505,19 +478,13 @@ export async function castVote(req: Request, res: Response): Promise { votes_against: updatedProposal.rows[0]?.votes_against ?? "0", }) } catch (err) { -<<<<<<< HEAD - console.error("[governance] Vote casting failed:", err) -======= log.error({ err }, "Vote casting failed") ->>>>>>> main res.status(500).json({ error: "Failed to cast vote", message: err instanceof Error ? err.message : String(err), }) } } -<<<<<<< HEAD -======= export async function getProposalStatus( req: Request, @@ -639,4 +606,3 @@ export async function getDelegation( res.status(500).json({ error: "Failed to fetch delegation state" }) } } ->>>>>>> main diff --git a/server/src/controllers/impact.controller.ts b/server/src/controllers/impact.controller.ts new file mode 100644 index 00000000..f225e3de --- /dev/null +++ b/server/src/controllers/impact.controller.ts @@ -0,0 +1,193 @@ +import { type Request, type Response } from "express" +import NodeCache from "node-cache" +import { pool } from "../db" +import { logger } from "../lib/logger" + +const log = logger.child({ module: "impact" }) +const impactCache = new NodeCache({ stdTTL: 300 }) + +export async function getPublicImpactMetrics( + _req: Request, + res: Response, +): Promise { + const cacheKey = "public_impact_metrics_v1" + const cached = impactCache.get(cacheKey) + if (cached) { + res.status(200).json(cached) + return + } + + try { + const [totalsResult, regionsResult, topCoursesResult, trendResult] = + await Promise.all([ + pool.query(` + WITH totals AS ( + SELECT + (SELECT COUNT(DISTINCT learner_address)::int FROM enrollments) AS total_scholars_funded, + (SELECT COALESCE(SUM(amount), 0)::numeric FROM scholarship_contributions) AS total_usdc_disbursed, + (SELECT COALESCE(SUM(lrn_balance), 0)::numeric FROM scholar_balances) AS total_lrn_minted + ), course_totals AS ( + SELECT c.slug AS course_id, COUNT(m.id)::numeric AS total_milestones + FROM courses c + LEFT JOIN milestones m ON m.course_id = c.id + GROUP BY c.slug + ), learner_completion AS ( + SELECT + e.learner_address, + COALESCE(SUM(ct.total_milestones), 0) AS total_milestones, + COALESCE(SUM(ap.completed_milestones), 0) AS completed_milestones + FROM enrollments e + LEFT JOIN course_totals ct ON ct.course_id = e.course_id + LEFT JOIN ( + SELECT + scholar_address, + course_id, + COUNT(*) FILTER (WHERE status = 'approved')::numeric AS completed_milestones + FROM milestone_reports + GROUP BY scholar_address, course_id + ) ap + ON ap.scholar_address = e.learner_address + AND ap.course_id = e.course_id + GROUP BY e.learner_address + ) + SELECT + t.total_scholars_funded, + t.total_usdc_disbursed::text AS total_usdc_disbursed, + t.total_lrn_minted::text AS total_lrn_minted, + COALESCE( + AVG( + CASE + WHEN lc.total_milestones > 0 + THEN lc.completed_milestones / lc.total_milestones + ELSE 0 + END + ), + 0 + ) AS average_course_completion_rate + FROM totals t + LEFT JOIN learner_completion lc ON TRUE; + `), + pool.query(` + SELECT country_region, COUNT(*)::int AS scholar_count + FROM scholar_regions + GROUP BY country_region + ORDER BY scholar_count DESC, country_region ASC + `), + pool.query(` + SELECT + COALESCE(c.slug, mr.course_id) AS course_id, + COALESCE(c.title, mr.course_id) AS course_title, + COUNT(*) FILTER (WHERE mr.status = 'approved')::int AS completed_count + FROM milestone_reports mr + LEFT JOIN courses c ON c.slug = mr.course_id + GROUP BY COALESCE(c.slug, mr.course_id), COALESCE(c.title, mr.course_id) + ORDER BY completed_count DESC, course_id ASC + LIMIT 5 + `), + pool.query(` + WITH quarter_axis AS ( + SELECT date_trunc('quarter', gs)::date AS period_start + FROM generate_series( + date_trunc('quarter', CURRENT_DATE) - INTERVAL '7 quarter', + date_trunc('quarter', CURRENT_DATE), + INTERVAL '1 quarter' + ) gs + ), enrollment_stats AS ( + SELECT + date_trunc('quarter', enrolled_at)::date AS period_start, + COUNT(DISTINCT learner_address)::int AS scholars_funded + FROM enrollments + GROUP BY 1 + ), disbursement_stats AS ( + SELECT + date_trunc('quarter', created_at)::date AS period_start, + COALESCE(SUM(amount), 0)::numeric AS usdc_disbursed + FROM scholarship_contributions + GROUP BY 1 + ) + SELECT + CONCAT( + EXTRACT(YEAR FROM qa.period_start)::int, + '-Q', + EXTRACT(QUARTER FROM qa.period_start)::int + ) AS quarter, + COALESCE(es.scholars_funded, 0) AS scholars_funded, + COALESCE(ds.usdc_disbursed, 0)::text AS usdc_disbursed + FROM quarter_axis qa + LEFT JOIN enrollment_stats es ON es.period_start = qa.period_start + LEFT JOIN disbursement_stats ds ON ds.period_start = qa.period_start + ORDER BY qa.period_start ASC + `), + ]) + + const totalsRow = totalsResult.rows[0] ?? { + total_scholars_funded: 0, + total_usdc_disbursed: "0", + total_lrn_minted: "0", + average_course_completion_rate: 0, + } + + const payload = { + totals: { + total_scholars_funded: Number(totalsRow.total_scholars_funded ?? 0), + total_usdc_disbursed: String(totalsRow.total_usdc_disbursed ?? "0"), + total_lrn_minted: String(totalsRow.total_lrn_minted ?? "0"), + average_course_completion_rate: Number( + totalsRow.average_course_completion_rate ?? 0, + ), + }, + countries_regions: regionsResult.rows, + top_completed_courses: topCoursesResult.rows, + trends: { + quarterly: trendResult.rows, + }, + generated_at: new Date().toISOString(), + } + + impactCache.set(cacheKey, payload) + res.status(200).json(payload) + } catch (err) { + log.error({ err }, "Failed to fetch public impact metrics") + res.status(500).json({ error: "Failed to fetch impact metrics" }) + } +} + +export async function getImpactWidgetData( + _req: Request, + res: Response, +): Promise { + const cacheKey = "public_impact_widget_v1" + const cached = impactCache.get(cacheKey) + if (cached) { + res.status(200).json(cached) + return + } + + try { + const result = await pool.query(` + SELECT + (SELECT COUNT(DISTINCT learner_address)::int FROM enrollments) AS total_scholars_funded, + (SELECT COALESCE(SUM(amount), 0)::text FROM scholarship_contributions) AS total_usdc_disbursed, + (SELECT COALESCE(SUM(lrn_balance), 0)::text FROM scholar_balances) AS total_lrn_minted + `) + + const row = result.rows[0] ?? { + total_scholars_funded: 0, + total_usdc_disbursed: "0", + total_lrn_minted: "0", + } + + const payload = { + total_scholars_funded: Number(row.total_scholars_funded ?? 0), + total_usdc_disbursed: String(row.total_usdc_disbursed ?? "0"), + total_lrn_minted: String(row.total_lrn_minted ?? "0"), + generated_at: new Date().toISOString(), + } + + impactCache.set(cacheKey, payload) + res.status(200).json(payload) + } catch (err) { + log.error({ err }, "Failed to fetch impact widget data") + res.status(500).json({ error: "Failed to fetch impact widget data" }) + } +} diff --git a/server/src/controllers/milestone-submit.controller.ts b/server/src/controllers/milestone-submit.controller.ts index 6d911779..22c1a584 100644 --- a/server/src/controllers/milestone-submit.controller.ts +++ b/server/src/controllers/milestone-submit.controller.ts @@ -1,15 +1,11 @@ import { type Request, type Response } from "express" import sanitizeHtml from "sanitize-html" import { milestoneStore } from "../db/milestone-store" -<<<<<<< HEAD -import { createEmailService } from "../services/email.service" -======= import { logger } from "../lib/logger" const log = logger.child({ module: "milestones" }) import { createEmailService } from "../services/email.service" import { markEscrowActivity } from "../services/escrow-timeout.service" ->>>>>>> main interface MilestoneSubmitRequestBody { scholarAddress?: string @@ -81,11 +77,7 @@ export async function submitMilestoneReport( courseId, milestoneId.toString(), ) -<<<<<<< HEAD - .catch((err) => console.error("[EmailService] Admin alert failed:", err)) -======= .catch((err) => log.error({ err }, "Admin alert email failed")) ->>>>>>> main res.status(201).json({ data: report }) } catch (err) { if (err instanceof Error && err.message === "DUPLICATE_REPORT") { diff --git a/server/src/controllers/scholars.controller.ts b/server/src/controllers/scholars.controller.ts index 9c91a926..f0063270 100644 --- a/server/src/controllers/scholars.controller.ts +++ b/server/src/controllers/scholars.controller.ts @@ -163,7 +163,12 @@ export async function getScholarsLeaderboard( rankingsValues, ) - const currentAddress = req.walletAddress + const viewerAddress = + typeof req.query.viewer_address === "string" + ? req.query.viewer_address.trim() + : "" + const currentAddress = + (req.walletAddress && req.walletAddress.trim()) || viewerAddress || "" let yourRank: number | null = null if (currentAddress) { diff --git a/server/src/controllers/sponsors.controller.ts b/server/src/controllers/sponsors.controller.ts new file mode 100644 index 00000000..0add600d --- /dev/null +++ b/server/src/controllers/sponsors.controller.ts @@ -0,0 +1,396 @@ +import { type Request, type Response } from "express" +import { pool } from "../db" +import { logger } from "../lib/logger" + +const log = logger.child({ module: "sponsors" }) + +function asNonEmptyString(value: unknown): string | null { + if (typeof value !== "string") return null + const normalized = value.trim() + return normalized.length > 0 ? normalized : null +} + +function parseOptionalPositiveNumber(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value) && value >= 0) { + return value + } + if (typeof value === "string") { + const parsed = Number.parseFloat(value) + if (Number.isFinite(parsed) && parsed >= 0) return parsed + } + return null +} + +export async function getOrganizationProfile( + req: Request, + res: Response, +): Promise { + const walletAddress = asNonEmptyString(req.params.walletAddress) + if (!walletAddress) { + res.status(400).json({ error: "walletAddress is required" }) + return + } + + try { + const result = await pool.query( + `SELECT wallet_address, name, logo_url, website, mission, created_at, updated_at + FROM sponsor_organizations + WHERE LOWER(wallet_address) = LOWER($1) + LIMIT 1`, + [walletAddress], + ) + + if (!result.rows[0]) { + res.status(404).json({ error: "Organization profile not found" }) + return + } + + res.status(200).json({ profile: result.rows[0] }) + } catch (err) { + log.error({ err }, "Failed to fetch organization profile") + res.status(500).json({ error: "Failed to fetch organization profile" }) + } +} + +export async function upsertOrganizationProfile( + req: Request, + res: Response, +): Promise { + const walletAddress = asNonEmptyString(req.params.walletAddress) + const name = asNonEmptyString(req.body?.name) + const logoUrl = asNonEmptyString(req.body?.logo_url) + const website = asNonEmptyString(req.body?.website) + const mission = asNonEmptyString(req.body?.mission) + + if (!walletAddress) { + res.status(400).json({ error: "walletAddress is required" }) + return + } + + if (!name) { + res.status(400).json({ error: "name is required" }) + return + } + + try { + const result = await pool.query( + `INSERT INTO sponsor_organizations (wallet_address, name, logo_url, website, mission) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (wallet_address) + DO UPDATE SET + name = EXCLUDED.name, + logo_url = EXCLUDED.logo_url, + website = EXCLUDED.website, + mission = EXCLUDED.mission, + updated_at = CURRENT_TIMESTAMP + RETURNING wallet_address, name, logo_url, website, mission, created_at, updated_at`, + [walletAddress, name, logoUrl, website, mission], + ) + + res.status(200).json({ profile: result.rows[0] }) + } catch (err) { + log.error({ err }, "Failed to save organization profile") + res.status(500).json({ error: "Failed to save organization profile" }) + } +} + +export async function createTrackSponsorship( + req: Request, + res: Response, +): Promise { + const walletAddress = asNonEmptyString(req.body?.wallet_address) + const track = asNonEmptyString(req.body?.track) + const donationUsdc = parseOptionalPositiveNumber(req.body?.donation_usdc) + const txHash = asNonEmptyString(req.body?.tx_hash) + + if (!walletAddress || !track || donationUsdc === null) { + res.status(400).json({ + error: "wallet_address, track, and donation_usdc are required", + }) + return + } + + try { + const orgResult = await pool.query( + `SELECT id + FROM sponsor_organizations + WHERE LOWER(wallet_address) = LOWER($1) + LIMIT 1`, + [walletAddress], + ) + + if (!orgResult.rows[0]) { + res.status(404).json({ + error: "Organization profile not found. Create profile first.", + }) + return + } + + const organizationId = Number(orgResult.rows[0].id) + const insertResult = await pool.query( + `INSERT INTO sponsor_track_donations (organization_id, track, donation_usdc, tx_hash) + VALUES ($1, $2, $3, $4) + RETURNING id, organization_id, track, donation_usdc, tx_hash, sponsored_at`, + [organizationId, track, donationUsdc, txHash], + ) + + res.status(201).json({ sponsorship: insertResult.rows[0] }) + } catch (err) { + log.error({ err }, "Failed to create track sponsorship") + res.status(500).json({ error: "Failed to create track sponsorship" }) + } +} + +export async function getTrackSponsorLogos( + req: Request, + res: Response, +): Promise { + const track = asNonEmptyString(req.query.track) + if (!track) { + res.status(400).json({ error: "track query parameter is required" }) + return + } + + try { + const result = await pool.query( + `SELECT + o.wallet_address, + o.name, + o.logo_url, + o.website, + SUM(d.donation_usdc)::text AS total_track_donated_usdc, + MAX(d.sponsored_at) AS latest_sponsorship_at + FROM sponsor_track_donations d + INNER JOIN sponsor_organizations o ON o.id = d.organization_id + WHERE LOWER(d.track) = LOWER($1) + GROUP BY o.wallet_address, o.name, o.logo_url, o.website + ORDER BY SUM(d.donation_usdc) DESC, MAX(d.sponsored_at) DESC`, + [track], + ) + + res.status(200).json({ + track, + sponsors: result.rows, + }) + } catch (err) { + log.error({ err }, "Failed to fetch track sponsor logos") + res.status(500).json({ error: "Failed to fetch track sponsor logos" }) + } +} + +export async function getOrganizationDashboard( + req: Request, + res: Response, +): Promise { + const walletAddress = asNonEmptyString(req.params.walletAddress) + if (!walletAddress) { + res.status(400).json({ error: "walletAddress is required" }) + return + } + + try { + const tracksResult = await pool.query( + `SELECT DISTINCT d.track + FROM sponsor_track_donations d + INNER JOIN sponsor_organizations o ON o.id = d.organization_id + WHERE LOWER(o.wallet_address) = LOWER($1) + ORDER BY d.track ASC`, + [walletAddress], + ) + + const tracks = tracksResult.rows.map((row) => String(row.track)) + + if (tracks.length === 0) { + res.status(200).json({ + tracks: [], + scholars: [], + }) + return + } + + const progressResult = await pool.query( + `WITH sponsored_tracks AS ( + SELECT DISTINCT LOWER(d.track) AS track + FROM sponsor_track_donations d + INNER JOIN sponsor_organizations o ON o.id = d.organization_id + WHERE LOWER(o.wallet_address) = LOWER($1) + ), enrolled_courses AS ( + SELECT DISTINCT e.learner_address, e.course_id + FROM enrollments e + INNER JOIN courses c ON c.slug = e.course_id + INNER JOIN sponsored_tracks st ON LOWER(c.track) = st.track + ), course_totals AS ( + SELECT c.slug AS course_id, COUNT(m.id)::int AS total_milestones + FROM courses c + LEFT JOIN milestones m ON m.course_id = c.id + GROUP BY c.slug + ), scholar_course_progress AS ( + SELECT + ec.learner_address, + ec.course_id, + COALESCE(ct.total_milestones, 0) AS total_milestones, + COUNT(mr.id) FILTER (WHERE mr.status = 'approved')::int AS completed_milestones + FROM enrolled_courses ec + LEFT JOIN course_totals ct ON ct.course_id = ec.course_id + LEFT JOIN milestone_reports mr + ON mr.scholar_address = ec.learner_address + AND mr.course_id = ec.course_id + GROUP BY ec.learner_address, ec.course_id, ct.total_milestones + ) + SELECT + scp.learner_address, + SUM(scp.completed_milestones)::int AS completed_milestones, + SUM(scp.total_milestones)::int AS total_milestones, + CASE + WHEN SUM(scp.total_milestones) > 0 + THEN ROUND(SUM(scp.completed_milestones)::numeric / SUM(scp.total_milestones)::numeric, 4) + ELSE 0 + END AS completion_rate + FROM scholar_course_progress scp + GROUP BY scp.learner_address + ORDER BY completion_rate DESC, completed_milestones DESC, scp.learner_address ASC`, + [walletAddress], + ) + + res.status(200).json({ + tracks, + scholars: progressResult.rows, + }) + } catch (err) { + log.error({ err }, "Failed to fetch organization dashboard") + res.status(500).json({ error: "Failed to fetch organization dashboard" }) + } +} + +export async function getOrganizationQuarterlyReport( + req: Request, + res: Response, +): Promise { + const walletAddress = asNonEmptyString(req.params.walletAddress) + if (!walletAddress) { + res.status(400).json({ error: "walletAddress is required" }) + return + } + + const year = Number.parseInt(String(req.query.year ?? ""), 10) + const quarter = Number.parseInt(String(req.query.quarter ?? ""), 10) + const filterYear = Number.isInteger(year) && year >= 2000 ? year : null + const filterQuarter = + Number.isInteger(quarter) && quarter >= 1 && quarter <= 4 ? quarter : null + + try { + const filters: string[] = [] + const params: Array = [walletAddress] + + if (filterYear !== null) { + params.push(filterYear) + filters.push(`report.year = $${params.length}`) + } + + if (filterQuarter !== null) { + params.push(filterQuarter) + filters.push(`report.quarter = $${params.length}`) + } + + const whereClause = + filters.length > 0 ? `WHERE ${filters.join(" AND ")}` : "" + + const result = await pool.query( + `WITH org_tracks_by_quarter AS ( + SELECT + EXTRACT(YEAR FROM d.sponsored_at)::int AS year, + EXTRACT(QUARTER FROM d.sponsored_at)::int AS quarter, + LOWER(d.track) AS track, + SUM(d.donation_usdc)::numeric AS donated_usdc + FROM sponsor_track_donations d + INNER JOIN sponsor_organizations o ON o.id = d.organization_id + WHERE LOWER(o.wallet_address) = LOWER($1) + GROUP BY 1, 2, 3 + ), quarterly_donations AS ( + SELECT + year, + quarter, + SUM(donated_usdc)::numeric AS total_donated_usdc, + COUNT(DISTINCT track)::int AS sponsored_tracks_count + FROM org_tracks_by_quarter + GROUP BY year, quarter + ), quarterly_scholars AS ( + SELECT + ot.year, + ot.quarter, + COUNT(DISTINCT e.learner_address)::int AS scholars_impacted + FROM org_tracks_by_quarter ot + INNER JOIN courses c ON LOWER(c.track) = ot.track + INNER JOIN enrollments e ON e.course_id = c.slug + GROUP BY ot.year, ot.quarter + ), quarterly_milestones AS ( + SELECT + ot.year, + ot.quarter, + COUNT(mr.id) FILTER (WHERE mr.status = 'approved')::int AS milestones_completed + FROM org_tracks_by_quarter ot + INNER JOIN courses c ON LOWER(c.track) = ot.track + LEFT JOIN milestone_reports mr ON mr.course_id = c.slug + GROUP BY ot.year, ot.quarter + ), report AS ( + SELECT + d.year, + d.quarter, + d.total_donated_usdc::text AS total_donated_usdc, + d.sponsored_tracks_count, + COALESCE(s.scholars_impacted, 0) AS scholars_impacted, + COALESCE(m.milestones_completed, 0) AS milestones_completed + FROM quarterly_donations d + LEFT JOIN quarterly_scholars s + ON s.year = d.year AND s.quarter = d.quarter + LEFT JOIN quarterly_milestones m + ON m.year = d.year AND m.quarter = d.quarter + ) + SELECT * + FROM report + ${whereClause} + ORDER BY year DESC, quarter DESC`, + params, + ) + + res.status(200).json({ + reports: result.rows, + }) + } catch (err) { + log.error({ err }, "Failed to generate quarterly sponsor report") + res.status(500).json({ error: "Failed to generate quarterly sponsor report" }) + } +} + +export async function upsertScholarRegion( + req: Request, + res: Response, +): Promise { + const learnerAddress = asNonEmptyString(req.body?.learner_address) + const countryRegion = asNonEmptyString(req.body?.country_region) + + if (!learnerAddress || !countryRegion) { + res.status(400).json({ + error: "learner_address and country_region are required", + }) + return + } + + try { + const result = await pool.query( + `INSERT INTO scholar_regions (learner_address, country_region) + VALUES ($1, $2) + ON CONFLICT (learner_address) + DO UPDATE SET + country_region = EXCLUDED.country_region, + updated_at = CURRENT_TIMESTAMP + RETURNING learner_address, country_region, updated_at`, + [learnerAddress, countryRegion], + ) + + res.status(200).json({ profile: result.rows[0] }) + } catch (err) { + log.error({ err }, "Failed to save scholar region") + res.status(500).json({ error: "Failed to save scholar region" }) + } +} diff --git a/server/src/db/migrations/015_sponsor_portal.sql b/server/src/db/migrations/015_sponsor_portal.sql new file mode 100644 index 00000000..cbe34c5e --- /dev/null +++ b/server/src/db/migrations/015_sponsor_portal.sql @@ -0,0 +1,69 @@ +-- ============================================================ +-- Migration 015: Organization sponsor portal + scholar region self-reporting +-- ============================================================ + +CREATE TABLE IF NOT EXISTS sponsor_organizations ( + id SERIAL PRIMARY KEY, + wallet_address TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + logo_url TEXT, + website TEXT, + mission TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS sponsor_track_donations ( + id SERIAL PRIMARY KEY, + organization_id INTEGER NOT NULL REFERENCES sponsor_organizations(id) ON DELETE CASCADE, + track TEXT NOT NULL, + donation_usdc NUMERIC(20, 7) NOT NULL CHECK (donation_usdc >= 0), + tx_hash TEXT, + sponsored_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_sponsor_track_donations_org_id + ON sponsor_track_donations (organization_id); + +CREATE INDEX IF NOT EXISTS idx_sponsor_track_donations_track + ON sponsor_track_donations (LOWER(track)); + +CREATE INDEX IF NOT EXISTS idx_sponsor_track_donations_sponsored_at + ON sponsor_track_donations (sponsored_at DESC); + +CREATE TABLE IF NOT EXISTS scholar_regions ( + learner_address TEXT PRIMARY KEY, + country_region TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_scholar_regions_country_region + ON scholar_regions (LOWER(country_region)); + +CREATE OR REPLACE FUNCTION set_sponsor_organizations_updated_at() +RETURNS TRIGGER LANGUAGE plpgsql AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_sponsor_organizations_updated_at ON sponsor_organizations; +CREATE TRIGGER trg_sponsor_organizations_updated_at + BEFORE UPDATE ON sponsor_organizations + FOR EACH ROW + EXECUTE FUNCTION set_sponsor_organizations_updated_at(); + +CREATE OR REPLACE FUNCTION set_scholar_regions_updated_at() +RETURNS TRIGGER LANGUAGE plpgsql AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_scholar_regions_updated_at ON scholar_regions; +CREATE TRIGGER trg_scholar_regions_updated_at + BEFORE UPDATE ON scholar_regions + FOR EACH ROW + EXECUTE FUNCTION set_scholar_regions_updated_at(); diff --git a/server/src/db/migrations/015_sponsor_portal.undo.sql b/server/src/db/migrations/015_sponsor_portal.undo.sql new file mode 100644 index 00000000..307a44c3 --- /dev/null +++ b/server/src/db/migrations/015_sponsor_portal.undo.sql @@ -0,0 +1,9 @@ +DROP TRIGGER IF EXISTS trg_scholar_regions_updated_at ON scholar_regions; +DROP FUNCTION IF EXISTS set_scholar_regions_updated_at(); + +DROP TRIGGER IF EXISTS trg_sponsor_organizations_updated_at ON sponsor_organizations; +DROP FUNCTION IF EXISTS set_sponsor_organizations_updated_at(); + +DROP TABLE IF EXISTS scholar_regions; +DROP TABLE IF EXISTS sponsor_track_donations; +DROP TABLE IF EXISTS sponsor_organizations; diff --git a/server/src/db/migrations/016_lesson_content_versioning.sql b/server/src/db/migrations/016_lesson_content_versioning.sql new file mode 100644 index 00000000..2223a6b7 --- /dev/null +++ b/server/src/db/migrations/016_lesson_content_versioning.sql @@ -0,0 +1,37 @@ +-- ============================================================ +-- Migration 016: Course content versioning for lessons + enrollment pinning +-- ============================================================ + +ALTER TABLE lessons + ADD COLUMN IF NOT EXISTS version INTEGER NOT NULL DEFAULT 1, + ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT TRUE, + ADD COLUMN IF NOT EXISTS superseded_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS superseded_by INTEGER REFERENCES lessons(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS change_summary TEXT; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'lessons_course_id_order_index_key' + ) THEN + ALTER TABLE lessons DROP CONSTRAINT lessons_course_id_order_index_key; + END IF; +END $$; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_lessons_course_order_version_unique + ON lessons (course_id, order_index, version); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_lessons_course_order_active_unique + ON lessons (course_id, order_index) + WHERE is_active = TRUE; + +CREATE INDEX IF NOT EXISTS idx_lessons_course_version + ON lessons (course_id, version DESC); + +ALTER TABLE enrollments + ADD COLUMN IF NOT EXISTS content_version INTEGER NOT NULL DEFAULT 1; + +CREATE INDEX IF NOT EXISTS idx_enrollments_course_version + ON enrollments (course_id, content_version); diff --git a/server/src/db/migrations/016_lesson_content_versioning.undo.sql b/server/src/db/migrations/016_lesson_content_versioning.undo.sql new file mode 100644 index 00000000..f2653559 --- /dev/null +++ b/server/src/db/migrations/016_lesson_content_versioning.undo.sql @@ -0,0 +1,25 @@ +DROP INDEX IF EXISTS idx_enrollments_course_version; +ALTER TABLE enrollments DROP COLUMN IF EXISTS content_version; + +DROP INDEX IF EXISTS idx_lessons_course_version; +DROP INDEX IF EXISTS idx_lessons_course_order_active_unique; +DROP INDEX IF EXISTS idx_lessons_course_order_version_unique; + +ALTER TABLE lessons + DROP COLUMN IF EXISTS change_summary, + DROP COLUMN IF EXISTS superseded_by, + DROP COLUMN IF EXISTS superseded_at, + DROP COLUMN IF EXISTS is_active, + DROP COLUMN IF EXISTS version; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'lessons_course_id_order_index_key' + ) THEN + ALTER TABLE lessons + ADD CONSTRAINT lessons_course_id_order_index_key UNIQUE (course_id, order_index); + END IF; +END $$; diff --git a/server/src/index.ts b/server/src/index.ts index af38235b..0daa0b99 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,29 +1,10 @@ -<<<<<<< HEAD -import path from "path" -import cors from "cors" -import dotenv from "dotenv" -import path from "path" - -// Load server/.env whether you run from repo root or from server/ -dotenv.config({ path: path.resolve(__dirname, "..", ".env") }) - -import cors from "cors" -import express from "express" -import morgan from "morgan" -======= import { createPublicKey } from "node:crypto" import path from "path" import cors from "cors" import dotenv from "dotenv" -import express, { - type Request, - type Response, - type NextFunction, -} from "express" +import express from "express" import helmet from "helmet" ->>>>>>> main import swaggerUi from "swagger-ui-express" -import YAML from "yaml" import { z } from "zod" import { initDb } from "./db/index" @@ -43,11 +24,13 @@ import { createCommentsRouter } from "./routes/comments.routes" import { communityRouter } from "./routes/community.routes" import { coursesRouter } from "./routes/courses.routes" import { createCredentialsRouter } from "./routes/credentials.routes" +import { donorsRouter } from "./routes/donors.routes" import { enrollmentsRouter } from "./routes/enrollments.routes" import { eventsRouter } from "./routes/events.routes" import { createForumRouter } from "./routes/forum.routes" import { governanceRouter } from "./routes/governance.routes" import { healthRouter } from "./routes/health.routes" +import { impactRouter } from "./routes/impact.routes" import { leaderboardRouter } from "./routes/leaderboard.routes" import { createMeRouter } from "./routes/me.routes" import { moderationRouter } from "./routes/moderation.routes" @@ -55,6 +38,7 @@ import { notificationsRouter } from "./routes/notifications.routes" import { createPeerReviewRouter } from "./routes/peer-review.routes" import { createScholarsRouter } from "./routes/scholars.routes" import { scholarshipsRouter } from "./routes/scholarships.routes" +import { sponsorsRouter } from "./routes/sponsors.routes" import { treasuryRouter } from "./routes/treasury.routes" import { createUploadRouter } from "./routes/upload.routes" import { validatorRouter } from "./routes/validator.routes" @@ -65,14 +49,7 @@ import { generateEphemeralDevJwtKeys, } from "./services/jwt.service" -<<<<<<< HEAD -const pemString = z - .string() - .min(1) - .transform((s) => s.replace(/\\n/g, "\n").trim()) -======= dotenv.config({ path: path.resolve(__dirname, "..", ".env") }) ->>>>>>> main const envSchema = z.object({ PORT: z.coerce.number().int().positive().default(4000), @@ -97,7 +74,7 @@ if (!jwtPrivateKey || !jwtPublicKey) { "JWT_PRIVATE_KEY and JWT_PUBLIC_KEY environment variables are required in production", ) } - logger.warn("JWT keys not found in .env — generating ephemeral keys") + logger.warn("JWT keys not found in .env - generating ephemeral keys") const ephemeral = generateEphemeralDevJwtKeys() jwtPrivateKey = ephemeral.privateKeyPem jwtPublicKey = ephemeral.publicKeyPem @@ -140,12 +117,13 @@ const allowedOrigins = [ env.FRONTEND_URL || env.CORS_ORIGIN || "http://localhost:5173", "https://learnvault.app", ] -if (!isProduction) +if (!isProduction) { allowedOrigins.push( "http://localhost:3000", "http://localhost:5174", "http://127.0.0.1:5173", ) +} app.use( cors({ @@ -155,7 +133,7 @@ app.use( }, credentials: true, methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], - allowedHeaders: ["Content-Type", "Authorization"], + allowedHeaders: ["Content-Type", "Authorization", "x-api-key"], }), ) @@ -163,7 +141,6 @@ app.use(createRequireTrustedOrigin(allowedOrigins)) app.use(express.json()) app.use(globalLimiter) -// Routes app.use("/api", healthRouter) app.use("/api/auth", createAuthRouter(authService)) app.use("/api", createMeRouter(jwtService)) @@ -186,6 +163,10 @@ app.use("/api", adminMilestonesRouter) app.use("/api", moderationRouter) app.use("/api", createUploadRouter(jwtService)) app.use("/api", notificationsRouter) +app.use("/api", createPeerReviewRouter(jwtService)) +app.use("/api", donorsRouter) +app.use("/api", sponsorsRouter) +app.use("/api", impactRouter) if (!isProduction) { const openApiSpec = buildOpenApiSpec() @@ -198,6 +179,7 @@ async function start() { if (process.env.SKIP_DB !== "true") { await initDb() } + app.listen(env.PORT, () => { logger.info({ port: env.PORT }, "Server listening") }) diff --git a/server/src/lib/zod-schemas.ts b/server/src/lib/zod-schemas.ts index 44d7c5b2..93840074 100644 --- a/server/src/lib/zod-schemas.ts +++ b/server/src/lib/zod-schemas.ts @@ -290,49 +290,6 @@ export const enrollmentBodySchema = z }) .strict() -<<<<<<< HEAD -const difficultyValues = ["beginner", "intermediate", "advanced"] as const - -const courseImportRowSchema = z - .object({ - title: requiredString("title"), - slug: requiredString("slug").regex( - /^[a-zA-Z0-9-_]+$/, - "slug may contain only letters, numbers, hyphens, and underscores", - ), - track: requiredString("track"), - difficulty: z - .string() - .trim() - .transform((value) => value.toLowerCase()) - .refine( - (value) => difficultyValues.includes(value as typeof difficultyValues[number]), - `difficulty must be one of: ${difficultyValues.join(", ")}`, - ), - description: z.string().optional(), - coverImage: z - .string() - .trim() - .min(1) - .optional() - .nullable(), - published: z.boolean().optional(), - }) - .strict() - -export const courseBulkImportBodySchema = z.union([ - z.object({ - courses: z.array(courseImportRowSchema).min(1, "courses are required"), - preview: z.boolean().optional(), - }).strict(), - z.object({ - csv: z.string().min(1, "csv is required"), - preview: z.boolean().optional(), - }).strict(), -]) - -export { difficultyValues } -======= export const userProfileSchema = z .object({ display_name: optionalTrimmedString("display_name", 50), @@ -369,4 +326,3 @@ export const bookmarkCourseIdParamSchema = z courseId: requiredString("courseId", 100), }) .strict() ->>>>>>> main diff --git a/server/src/middleware/admin.middleware.ts b/server/src/middleware/admin.middleware.ts index 39c09fa2..e6edc72f 100644 --- a/server/src/middleware/admin.middleware.ts +++ b/server/src/middleware/admin.middleware.ts @@ -3,11 +3,6 @@ import jwt from "jsonwebtoken" import { JWT_AUDIENCE, JWT_ISSUER } from "../services/jwt.service" -<<<<<<< HEAD -const JWT_SECRET = process.env.JWT_SECRET ?? process.env.JWT_PRIVATE_KEY -if (!JWT_SECRET) { - throw new Error("JWT_SECRET environment variable is required") -======= function getAdminAddresses(): string[] { return (process.env.ADMIN_ADDRESSES ?? "") .split(",") @@ -23,7 +18,6 @@ function getJwtSecret(): string | undefined { // HS256 fallback is development-only; production must use RS256 via JWT_PUBLIC_KEY. if (process.env.NODE_ENV === "production") return undefined return process.env.JWT_SECRET?.trim() ->>>>>>> main } export interface AdminRequest extends Request { @@ -66,12 +60,6 @@ export function requireAdmin( } try { -<<<<<<< HEAD - decoded = jwt.verify(token, JWT_SECRET!) as { - address?: string - sub?: string - } -======= decoded = ( jwtPublicKey ? jwt.verify(token, jwtPublicKey, { @@ -81,7 +69,6 @@ export function requireAdmin( }) : jwt.verify(token, jwtSecret!) ) as { address?: string; sub?: string } ->>>>>>> main } catch { res.status(401).json({ error: "Invalid or expired token" }) return diff --git a/server/src/middleware/course-admin.middleware.ts b/server/src/middleware/course-admin.middleware.ts index c4d73c7b..80ca2677 100644 --- a/server/src/middleware/course-admin.middleware.ts +++ b/server/src/middleware/course-admin.middleware.ts @@ -1,18 +1,6 @@ import { type NextFunction, type Request, type Response } from "express" import jwt from "jsonwebtoken" -<<<<<<< HEAD -const JWT_PUBLIC_KEY = process.env.JWT_PUBLIC_KEY?.replace(/\\n/g, "\n").trim() -const JWT_SECRET = process.env.JWT_SECRET -const ADMIN_API_KEY = process.env.ADMIN_API_KEY -const ADMIN_ADDRESSES = (process.env.ADMIN_ADDRESSES ?? "") - .split(",") - .map((value) => value.trim()) - .filter(Boolean) -======= -import { JWT_AUDIENCE, JWT_ISSUER } from "../services/jwt.service" ->>>>>>> main - type TokenPayload = { sub?: string address?: string @@ -25,14 +13,14 @@ function getJwtPublicKey(): string | undefined { } function getJwtSecret(): string | undefined { - // HS256 fallback is development-only; production must use RS256 via JWT_PUBLIC_KEY. if (process.env.NODE_ENV === "production") return undefined - return process.env.JWT_SECRET?.trim() + const secret = process.env.JWT_SECRET?.trim() + return secret && secret.length > 0 ? secret : undefined } function getAdminApiKey(): string | undefined { const apiKey = process.env.ADMIN_API_KEY?.trim() - return apiKey || undefined + return apiKey && apiKey.length > 0 ? apiKey : undefined } function getAdminAddresses(): string[] { @@ -43,10 +31,9 @@ function getAdminAddresses(): string[] { } function wantsUnpublishedCourses(req: Request): boolean { - const rawValue = req.query.includeUnpublished - if (typeof rawValue !== "string") return false - - return ["1", "true", "yes"].includes(rawValue.trim().toLowerCase()) + const raw = req.query.includeUnpublished + if (typeof raw !== "string") return false + return ["1", "true", "yes"].includes(raw.trim().toLowerCase()) } export function requireCourseAdmin( @@ -58,8 +45,9 @@ export function requireCourseAdmin( const jwtSecret = getJwtSecret() const adminApiKey = getAdminApiKey() const adminAddresses = getAdminAddresses() - const apiKey = req.header("x-api-key") - if (adminApiKey && apiKey && apiKey === adminApiKey) { + + const providedApiKey = req.header("x-api-key") + if (adminApiKey && providedApiKey && providedApiKey === adminApiKey) { next() return } @@ -76,34 +64,19 @@ export function requireCourseAdmin( return } -<<<<<<< HEAD - if (!JWT_PUBLIC_KEY && !JWT_SECRET) { -======= if (!jwtPublicKey && !jwtSecret) { ->>>>>>> main res.status(500).json({ error: "JWT verification not configured" }) return } let decoded: TokenPayload try { -<<<<<<< HEAD - if (JWT_PUBLIC_KEY) { - decoded = jwt.verify(token, JWT_PUBLIC_KEY, { - algorithms: ["RS256"], - }) as TokenPayload - } else { - decoded = jwt.verify(token, JWT_SECRET!) as TokenPayload -======= if (jwtPublicKey) { decoded = jwt.verify(token, jwtPublicKey, { algorithms: ["RS256"], - issuer: JWT_ISSUER, - audience: JWT_AUDIENCE, }) as TokenPayload } else { decoded = jwt.verify(token, jwtSecret!) as TokenPayload ->>>>>>> main } } catch { res.status(401).json({ error: "Unauthorized" }) diff --git a/server/src/middleware/rate-limit.middleware.ts b/server/src/middleware/rate-limit.middleware.ts index 2bea9855..7676f1c1 100644 --- a/server/src/middleware/rate-limit.middleware.ts +++ b/server/src/middleware/rate-limit.middleware.ts @@ -32,15 +32,11 @@ const createWalletKeyGenerator = return headerWallet } -<<<<<<< HEAD - return getBodyWalletValue(req, bodyKeys) ?? ipKeyGenerator(req.ip ?? "unknown") ?? "unknown" -======= return ( getBodyWalletValue(req, bodyKeys) ?? ipKeyGenerator(req.ip ?? "unknown") ?? "unknown" ) ->>>>>>> main } const getKeyForRequest = (req: Request): string => { @@ -70,12 +66,7 @@ export const uploadLimiter = rateLimit({ export const milestoneReportLimiter = rateLimit({ windowMs: 60 * 60 * 1000, limit: 3, -<<<<<<< HEAD - keyGenerator: (req: Request) => - (req.headers["x-wallet-address"] as string) ?? ipKeyGenerator(req.ip ?? "unknown") ?? "unknown", -======= keyGenerator: getKeyForRequest, ->>>>>>> main standardHeaders: "draft-7", legacyHeaders: false, validate: false, @@ -87,12 +78,7 @@ export const milestoneReportLimiter = rateLimit({ export const proposalSubmissionLimiter = rateLimit({ windowMs: 24 * 60 * 60 * 1000, limit: 1, -<<<<<<< HEAD - keyGenerator: (req: Request) => - (req.headers["x-wallet-address"] as string) ?? ipKeyGenerator(req.ip ?? "unknown") ?? "unknown", -======= keyGenerator: getKeyForRequest, ->>>>>>> main standardHeaders: "draft-7", legacyHeaders: false, validate: false, diff --git a/server/src/openapi.ts b/server/src/openapi.ts index 0dfed822..73cd5618 100644 --- a/server/src/openapi.ts +++ b/server/src/openapi.ts @@ -38,13 +38,10 @@ export const buildOpenApiSpec = () => { { name: "Events", description: "Event stream endpoints" }, { name: "Leaderboard", description: "Learner ranking endpoints" }, { name: "Comments", description: "Proposal comment endpoints" }, -<<<<<<< HEAD -======= { name: "Treasury", description: "Treasury statistics and activity endpoints", }, ->>>>>>> main { name: "Upload", description: "IPFS file upload endpoints" }, ], components: { @@ -129,10 +126,7 @@ export const buildOpenApiSpec = () => { type: "string", enum: ["pending", "approved", "rejected"], }, -<<<<<<< HEAD -======= cancelled: { type: "boolean" }, ->>>>>>> main deadline: { type: "string", format: "date-time" }, }, required: ["id", "author_address", "title", "status"], @@ -222,8 +216,6 @@ export const buildOpenApiSpec = () => { }, required: ["id", "courseId", "title", "content", "order"], }, -<<<<<<< HEAD -======= GovernanceProposalInput: { type: "object", properties: { @@ -355,7 +347,6 @@ export const buildOpenApiSpec = () => { "revoked", ], }, ->>>>>>> main }, responses: { BadRequestError: { diff --git a/server/src/routes/comments.routes.ts b/server/src/routes/comments.routes.ts index 395af53f..f65a5a2b 100644 --- a/server/src/routes/comments.routes.ts +++ b/server/src/routes/comments.routes.ts @@ -82,13 +82,10 @@ export function createCommentsRouter(jwtService: JwtService): Router { const parentId = body.parentId ?? body.parent_id const tokenAddress = req.user?.address ?? "" const authorAddress = body.author_address ?? tokenAddress -<<<<<<< HEAD -======= const safeContent = sanitizeHtml(content, { allowedTags: [], allowedAttributes: {}, }) ->>>>>>> main if (body.author_address && body.author_address !== tokenAddress) { return res.status(400).json({ @@ -103,17 +100,6 @@ export function createCommentsRouter(jwtService: JwtService): Router { }) } -<<<<<<< HEAD - try { - // Spam protection: max 5 comments per address per proposal per day - const spamCheck = await pool.query( - `SELECT COUNT(*) FROM comments - WHERE author_address = $1 AND proposal_id = $2 - AND created_at > NOW() - INTERVAL '1 day'`, - [authorAddress, proposalId], - ) - -======= if (content.length > maxCommentLength) { return res.status(400).json({ error: "Comment must be 2,000 characters or fewer", @@ -145,7 +131,6 @@ export function createCommentsRouter(jwtService: JwtService): Router { `SELECT COUNT(*) FROM comments WHERE author_address = $1 AND proposal_id = $2 AND created_at > NOW() - INTERVAL '1 day'`, [authorAddress, proposalId], ) ->>>>>>> main if (parseInt(spamCheck.rows[0].count) >= 5) { return res .status(429) @@ -153,17 +138,9 @@ export function createCommentsRouter(jwtService: JwtService): Router { } const result = await pool.query( -<<<<<<< HEAD - `INSERT INTO comments (proposal_id, author_address, content, parent_id) - VALUES ($1, $2, $3, $4) RETURNING *`, - [proposalId, authorAddress, content, parentId || null], - ) - -======= `INSERT INTO comments (proposal_id, author_address, content, parent_id) VALUES ($1, $2, $3, $4) RETURNING *`, [proposalId, authorAddress, safeContent, parentId ?? null], ) ->>>>>>> main res.status(201).json(result.rows[0]) } catch (err) { res.status(500).json({ error: "Failed to post comment" }) @@ -185,22 +162,6 @@ export function createCommentsRouter(jwtService: JwtService): Router { async (req: AuthRequest, res: Response) => { const { id } = req.params const authorAddress = req.user?.address -<<<<<<< HEAD - - try { - // Check if comment exists and belongs to user (and not already deleted) - const checkResult = await pool.query( - `SELECT * FROM comments WHERE id = $1 AND author_address = $2 AND deleted_at IS NULL`, - [id, authorAddress], - ) - - if (checkResult.rowCount === 0) { - return res - .status(404) - .json({ error: "Comment not found or unauthorized" }) - } - -======= try { // Check if comment exists and belongs to user (and not already deleted) const checkResult = await pool.query( @@ -213,16 +174,11 @@ export function createCommentsRouter(jwtService: JwtService): Router { .json({ error: "Comment not found or unauthorized" }) } ->>>>>>> main // Soft delete: set deleted_at timestamp await pool.query( `UPDATE comments SET deleted_at = CURRENT_TIMESTAMP WHERE id = $1`, [id], ) -<<<<<<< HEAD - -======= ->>>>>>> main res.json({ success: true }) } catch (err) { res.status(500).json({ error: "Failed to delete comment" }) @@ -246,18 +202,11 @@ export function createCommentsRouter(jwtService: JwtService): Router { const { type } = req.body // 'upvote' or 'downvote' const voterAddress = req.user?.address -<<<<<<< HEAD - if (!["upvote", "downvote"].includes(type)) { - return res.status(400).json({ error: "Invalid vote type" }) - } - -======= if (!VOTE_COLUMN[type]) { return res.status(400).json({ error: "Invalid vote type" }) } const col = VOTE_COLUMN[type] ->>>>>>> main const client = await pool.connect() try { await client.query("BEGIN") @@ -268,11 +217,7 @@ export function createCommentsRouter(jwtService: JwtService): Router { [id, voterAddress], ) -<<<<<<< HEAD - if (existingVote.rowCount && existingVote.rowCount > 0) { -======= if (existingVote.rows.length > 0) { ->>>>>>> main if (existingVote.rows[0].vote_type === type) { // Remove vote if clicking the same button await client.query( @@ -280,30 +225,19 @@ export function createCommentsRouter(jwtService: JwtService): Router { [id, voterAddress], ) await client.query( -<<<<<<< HEAD - `UPDATE comments SET ${type}s = ${type}s - 1 WHERE id = $1`, -======= `UPDATE comments SET ${col} = ${col} - 1 WHERE id = $1`, ->>>>>>> main [id], ) } else { // Change vote type const oldType = existingVote.rows[0].vote_type -<<<<<<< HEAD -======= const oldCol = VOTE_COLUMN[oldType] ->>>>>>> main await client.query( `UPDATE comment_votes SET vote_type = $1 WHERE comment_id = $2 AND voter_address = $3`, [type, id, voterAddress], ) await client.query( -<<<<<<< HEAD - `UPDATE comments SET ${type}s = ${type}s + 1, ${oldType}s = ${oldType}s - 1 WHERE id = $1`, -======= `UPDATE comments SET ${col} = ${col} + 1, ${oldCol} = ${oldCol} - 1 WHERE id = $1`, ->>>>>>> main [id], ) } @@ -314,11 +248,7 @@ export function createCommentsRouter(jwtService: JwtService): Router { [id, voterAddress, type], ) await client.query( -<<<<<<< HEAD - `UPDATE comments SET ${type}s = ${type}s + 1 WHERE id = $1`, -======= `UPDATE comments SET ${col} = ${col} + 1 WHERE id = $1`, ->>>>>>> main [id], ) } @@ -352,17 +282,6 @@ export function createCommentsRouter(jwtService: JwtService): Router { async (req: AuthRequest, res: Response) => { const { id } = req.params const authorAddress = req.user?.address -<<<<<<< HEAD - - try { - // Check if the user is the author of the proposal associated with this comment - // For now, we'll assume a "proposal_authors" mapping or check a proposals table - // In a real app, you'd fetch the proposal by comment.proposal_id and check its author - - // MOCK: Allow anyone to pin for now if they are the "author" of the proposal (which we'll just check against a param or something) - // Actually, the user says "Proposal author can pin one comment". - // I'll need a way to verify this. -======= try { // Check if the user is the author of the proposal associated with this comment // For now, we'll assume a "proposal_authors" mapping or check a proposals table @@ -386,7 +305,6 @@ export function createCommentsRouter(jwtService: JwtService): Router { ) if (proposalRes.rows.length === 0) return res.status(404).json({ error: "Proposal not found" }) ->>>>>>> main const proposalAuthor = proposalRes.rows[0].author_address if (proposalAuthor.toLowerCase() !== authorAddress?.toLowerCase()) @@ -394,29 +312,6 @@ export function createCommentsRouter(jwtService: JwtService): Router { .status(403) .json({ error: "Only the proposal author can pin comments" }) -<<<<<<< HEAD - // Verify the requesting user is the proposal author - const proposalRes = await pool.query( - `SELECT author_address FROM proposals WHERE id = $1`, - [proposalId], - ) - if (proposalRes.rowCount === 0) - return res.status(404).json({ error: "Proposal not found" }) - - const proposalAuthor = proposalRes.rows[0].author_address - if (proposalAuthor.toLowerCase() !== authorAddress?.toLowerCase()) - return res.status(403).json({ error: "Only the proposal author can pin comments" }) - - // UPDATE: Reset pins for this proposal and pin this one - await pool.query( - `UPDATE comments SET is_pinned = FALSE WHERE proposal_id = $1`, - [proposalId], - ) - await pool.query(`UPDATE comments SET is_pinned = TRUE WHERE id = $1`, [ - id, - ]) - -======= // UPDATE: Reset pins for this proposal and pin this one await pool.query( `UPDATE comments SET is_pinned = FALSE WHERE proposal_id = $1`, @@ -425,7 +320,6 @@ export function createCommentsRouter(jwtService: JwtService): Router { await pool.query(`UPDATE comments SET is_pinned = TRUE WHERE id = $1`, [ id, ]) ->>>>>>> main res.json({ message: "Comment pinned" }) } catch (err) { res.status(500).json({ error: "Failed to pin comment" }) diff --git a/server/src/routes/courses.routes.ts b/server/src/routes/courses.routes.ts index 34f58747..33c695bb 100644 --- a/server/src/routes/courses.routes.ts +++ b/server/src/routes/courses.routes.ts @@ -5,6 +5,8 @@ import { getCourse, getCourseLessonById, getCourses, + getLessonVersionDiff, + updateLessonVersion, updateCourse, } from "../controllers/courses.controller" import { @@ -14,305 +16,22 @@ import { export const coursesRouter = Router() -/** - * @openapi - * /api/courses: - * get: - * tags: [Courses] - * summary: List published courses - * description: Returns a paginated list of published courses, optionally filtered by track and difficulty. - * parameters: - * - in: query - * name: track - * schema: - * type: string - * description: Filter by course track (case-insensitive) - * - in: query - * name: difficulty - * schema: - * type: string - * enum: [beginner, intermediate, advanced] - * description: Filter by difficulty level - * - in: query - * name: page - * schema: - * type: integer - * minimum: 1 - * default: 1 - * description: Page number - * - in: query - * name: limit - * schema: - * type: integer - * minimum: 1 - * maximum: 50 - * default: 12 - * description: Number of courses per page - * responses: - * 200: - * description: Paginated list of courses - * content: - * application/json: - * schema: - * type: object - * properties: - * data: - * type: array - * items: - * $ref: '#/components/schemas/CourseDetail' - * page: - * type: integer - * limit: - * type: integer - * total: - * type: integer - * totalPages: - * type: integer - * 500: - * $ref: '#/components/responses/InternalServerError' - */ -<<<<<<< HEAD -coursesRouter.get("/courses", getCourses) - -/** - * @openapi - * /api/courses/{slug}: - * get: - * tags: [Courses] - * summary: Get a course by slug - * description: Returns a single course with all its lessons and quiz data. - * parameters: - * - in: path - * name: slug - * required: true - * schema: - * type: string - * description: The course slug - * responses: - * 200: - * description: Course with lessons -======= coursesRouter.get("/courses", requireCourseAdminIfRequested, getCourses) -/** - * @openapi - * /api/courses/{idOrSlug}: - * get: - * tags: [Courses] - * summary: Get a single course with lessons - * description: Returns course details and all associated lessons by numeric ID or slug. - * parameters: - * - in: path - * name: idOrSlug - * required: true - * schema: - * type: string - * description: Course numeric ID or slug - * responses: - * 200: - * description: Course details with lessons ->>>>>>> main - * content: - * application/json: - * schema: - * allOf: - * - $ref: '#/components/schemas/CourseDetail' - * - type: object - * properties: - * lessons: - * type: array - * items: - * $ref: '#/components/schemas/Lesson' - * 404: - * $ref: '#/components/responses/NotFoundError' - * 500: - * $ref: '#/components/responses/InternalServerError' - */ coursesRouter.get("/courses/:idOrSlug", getCourse) - -/** - * @openapi -<<<<<<< HEAD - * /api/courses/{slug}/lessons/{id}: - * get: - * tags: [Courses] - * summary: Get a specific lesson - * description: Returns a single lesson by ID within a course, including quiz questions. - * parameters: - * - in: path - * name: slug - * required: true - * schema: - * type: string - * description: The course slug -======= - * /api/courses/{idOrSlug}/lessons/{id}: - * get: - * tags: [Courses] - * summary: Get a single lesson - * description: Returns a specific lesson by ID within a published course. - * parameters: - * - in: path - * name: idOrSlug - * required: true - * schema: - * type: string - * description: Course numeric ID or slug ->>>>>>> main - * - in: path - * name: id - * required: true - * schema: - * type: integer -<<<<<<< HEAD - * description: The lesson ID -======= - * minimum: 1 - * description: Lesson ID ->>>>>>> main - * responses: - * 200: - * description: Lesson details - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Lesson' - * 404: - * $ref: '#/components/responses/NotFoundError' - * 500: - * $ref: '#/components/responses/InternalServerError' - */ coursesRouter.get("/courses/:idOrSlug/lessons/:id", getCourseLessonById) -/** - * @openapi - * /api/courses: - * post: - * tags: [Courses] - * summary: Create a new course -<<<<<<< HEAD - * description: Creates a new unpublished course. Requires course admin privileges. -======= - * description: Creates an unpublished course. Requires course admin privileges. ->>>>>>> main - * security: - * - bearerAuth: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: - * - title - * - slug - * - track - * - difficulty - * properties: - * title: - * type: string - * slug: - * type: string - * description: - * type: string - * coverImage: - * type: string - * nullable: true - * track: - * type: string - * difficulty: - * type: string - * enum: [beginner, intermediate, advanced] - * responses: - * 201: - * description: Course created - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/CourseDetail' - * 400: - * $ref: '#/components/responses/BadRequestError' - * 401: - * $ref: '#/components/responses/UnauthorizedError' -<<<<<<< HEAD - * 403: - * $ref: '#/components/responses/ForbiddenError' -======= ->>>>>>> main - * 409: - * description: Slug already exists - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - * 500: - * $ref: '#/components/responses/InternalServerError' - */ -coursesRouter.post("/courses", requireCourseAdmin, createCourse) -<<<<<<< HEAD -coursesRouter.put("/courses/:id", requireCourseAdmin, updateCourse) -======= +// Admin-only endpoint for content-version comparisons on a lesson order slot. +coursesRouter.get( + "/courses/:idOrSlug/lessons/:orderIndex/diff", + requireCourseAdmin, + getLessonVersionDiff, +) -/** - * @openapi - * /api/courses/{id}: - * patch: - * tags: [Courses] - * summary: Update a course - * description: Partially updates an existing course. Requires course admin privileges. - * security: - * - bearerAuth: [] - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: integer - * minimum: 1 - * description: Course ID - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * title: - * type: string - * slug: - * type: string - * description: - * type: string - * coverImage: - * type: string - * nullable: true - * track: - * type: string - * difficulty: - * type: string - * enum: [beginner, intermediate, advanced] - * published: - * type: boolean - * responses: - * 200: - * description: Updated course - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/CourseDetail' - * 400: - * $ref: '#/components/responses/BadRequestError' - * 401: - * $ref: '#/components/responses/UnauthorizedError' - * 404: - * $ref: '#/components/responses/NotFoundError' - * 409: - * description: Slug already exists - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - * 500: - * $ref: '#/components/responses/InternalServerError' - */ +coursesRouter.patch( + "/courses/:idOrSlug/lessons/:orderIndex", + requireCourseAdmin, + updateLessonVersion, +) + +coursesRouter.post("/courses", requireCourseAdmin, createCourse) coursesRouter.patch("/courses/:id", requireCourseAdmin, updateCourse) ->>>>>>> main diff --git a/server/src/routes/governance.routes.ts b/server/src/routes/governance.routes.ts index 09ed8c6e..1b3022ee 100644 --- a/server/src/routes/governance.routes.ts +++ b/server/src/routes/governance.routes.ts @@ -1,10 +1,7 @@ import { Router } from "express" import { -<<<<<<< HEAD -======= cancelProposal, ->>>>>>> main castVote, createGovernanceProposal, getProposalStatus, @@ -155,8 +152,6 @@ governanceRouter.get("/governance/voting-power/:address", (req, res) => { governanceRouter.post("/governance/vote", (req, res) => { void castVote(req, res) }) -<<<<<<< HEAD -======= /** * @openapi @@ -211,4 +206,3 @@ governanceRouter.get("/proposals/:id/status", (req, res) => { governanceRouter.delete("/proposals/:id", requireAdmin, (req, res) => { void cancelProposal(req, res) }) ->>>>>>> main diff --git a/server/src/routes/impact.routes.ts b/server/src/routes/impact.routes.ts new file mode 100644 index 00000000..a1d82858 --- /dev/null +++ b/server/src/routes/impact.routes.ts @@ -0,0 +1,15 @@ +import { Router } from "express" +import { + getImpactWidgetData, + getPublicImpactMetrics, +} from "../controllers/impact.controller" + +export const impactRouter = Router() + +impactRouter.get("/impact/metrics", (req, res) => { + void getPublicImpactMetrics(req, res) +}) + +impactRouter.get("/impact/widget", (req, res) => { + void getImpactWidgetData(req, res) +}) diff --git a/server/src/routes/scholars.routes.ts b/server/src/routes/scholars.routes.ts index 1a77c198..c07b6fb6 100644 --- a/server/src/routes/scholars.routes.ts +++ b/server/src/routes/scholars.routes.ts @@ -16,7 +16,6 @@ import { createRequireAuth, createOptionalAuth, } from "../middleware/auth.middleware" -import { validate } from "../middleware/validation.middleware" import { type JwtService } from "../services/jwt.service" export function createScholarsRouter(jwtService: JwtService): Router { @@ -32,7 +31,7 @@ export function createScholarsRouter(jwtService: JwtService): Router { * summary: Get scholars leaderboard * description: Returns a paginated ranking of scholars by LRN balance, with optional search. */ - router.get("/scholars/leaderboard", (req, res) => { + router.get("/scholars/leaderboard", optionalAuth, (req, res) => { void getScholarsLeaderboard(req, res) }) @@ -74,8 +73,6 @@ export function createScholarsRouter(jwtService: JwtService): Router { void getScholarEscrowTimeouts(req, res) }) - // ── Social Following ─────────────────────────────────────────────────────── - /** * @openapi * /api/scholars/{address}/follow: @@ -103,64 +100,5 @@ export function createScholarsRouter(jwtService: JwtService): Router { void getFollowStatus(req as any, res) }) -<<<<<<< HEAD -/** - * @openapi - * /api/scholars/leaderboard: - * get: - * tags: [Scholars] - * summary: Get scholars leaderboard - * description: Returns a paginated ranking of scholars by LRN balance, with optional search. - * parameters: - * - in: query - * name: page - * schema: - * type: integer - * minimum: 1 - * default: 1 - * description: Page number - * - in: query - * name: limit - * schema: - * type: integer - * minimum: 1 - * maximum: 100 - * default: 50 - * description: Number of scholars per page - * - in: query - * name: search - * schema: - * type: string - * description: Filter scholars by wallet address (partial match) - * responses: - * 200: - * description: Paginated scholars leaderboard - * content: - * application/json: - * schema: - * type: object - * properties: - * rankings: - * type: array - * items: - * $ref: '#/components/schemas/ScholarRanking' - * total: - * type: integer - * your_rank: - * type: integer - * nullable: true - * description: Current user's rank (null if not authenticated or not ranked) - * 500: - * $ref: '#/components/responses/InternalServerError' - */ -scholarsRouter.get("/scholars/leaderboard", (req, res) => { - void getScholarsLeaderboard(req, res) -}) - -scholarsRouter.get("/scholars/:address", (req, res) => { - void getScholarProfile(req, res) -}) -======= return router } ->>>>>>> main diff --git a/server/src/routes/sponsors.routes.ts b/server/src/routes/sponsors.routes.ts new file mode 100644 index 00000000..c3327f19 --- /dev/null +++ b/server/src/routes/sponsors.routes.ts @@ -0,0 +1,46 @@ +import { Router } from "express" +import { + createTrackSponsorship, + getOrganizationDashboard, + getOrganizationProfile, + getOrganizationQuarterlyReport, + getTrackSponsorLogos, + upsertOrganizationProfile, + upsertScholarRegion, +} from "../controllers/sponsors.controller" + +export const sponsorsRouter = Router() + +sponsorsRouter.get("/sponsors/organizations/:walletAddress", (req, res) => { + void getOrganizationProfile(req, res) +}) + +sponsorsRouter.put("/sponsors/organizations/:walletAddress", (req, res) => { + void upsertOrganizationProfile(req, res) +}) + +sponsorsRouter.post("/sponsors/sponsorships", (req, res) => { + void createTrackSponsorship(req, res) +}) + +sponsorsRouter.get("/sponsors/logos", (req, res) => { + void getTrackSponsorLogos(req, res) +}) + +sponsorsRouter.get( + "/sponsors/organizations/:walletAddress/dashboard", + (req, res) => { + void getOrganizationDashboard(req, res) + }, +) + +sponsorsRouter.get( + "/sponsors/organizations/:walletAddress/reports/quarterly", + (req, res) => { + void getOrganizationQuarterlyReport(req, res) + }, +) + +sponsorsRouter.put("/sponsors/scholar-region", (req, res) => { + void upsertScholarRegion(req, res) +}) diff --git a/server/src/routes/upload.routes.ts b/server/src/routes/upload.routes.ts index 5a8d01d1..d5c7bfab 100644 --- a/server/src/routes/upload.routes.ts +++ b/server/src/routes/upload.routes.ts @@ -51,52 +51,6 @@ export function createUploadRouter(jwtService: JwtService): Router { * 401: * $ref: '#/components/responses/UnauthorizedError' */ -<<<<<<< HEAD - /** - * @openapi - * /api/upload: - * post: - * tags: [Upload] - * summary: Pin a file to IPFS via Pinata - * description: > - * Accepts a single file (PDF, PNG, JPEG, MP4 — max 10 MB), pins it to - * IPFS via Pinata, and returns the CID and a Pinata gateway URL. - * Use this endpoint to upload proposal attachments, course cover images, - * and ScholarNFT images before referencing their CIDs elsewhere. - * security: - * - bearerAuth: [] - * requestBody: - * required: true - * content: - * multipart/form-data: - * schema: - * type: object - * required: [file] - * properties: - * file: - * type: string - * format: binary - * responses: - * 201: - * description: File pinned successfully - * content: - * application/json: - * schema: - * type: object - * properties: - * cid: - * type: string - * example: bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi - * gatewayUrl: - * type: string - * example: https://gateway.pinata.cloud/ipfs/bafybei... - * 400: - * $ref: '#/components/responses/BadRequestError' - * 401: - * $ref: '#/components/responses/UnauthorizedError' - */ -======= ->>>>>>> main router.post("/upload", requireAuth, upload.single("file"), uploadFile) /** diff --git a/server/src/services/stellar-contract.service.ts b/server/src/services/stellar-contract.service.ts index 95e72777..5013c08c 100644 --- a/server/src/services/stellar-contract.service.ts +++ b/server/src/services/stellar-contract.service.ts @@ -46,8 +46,6 @@ export interface CastVoteParams { support: boolean } -<<<<<<< HEAD -======= export interface CancelProposalParams { proposalId: number } @@ -67,7 +65,6 @@ function buildRequestMemoValue(requestId?: string): string | null { return `rid:${compact}` } ->>>>>>> main // --- Admin Validation Cache --- let cachedAdminAddress: string | null = null let lastAdminCheckTime: number = 0 @@ -888,8 +885,7 @@ async function castVote(params: CastVoteParams): Promise { } catch (err) { console.error("[stellar] Cast vote failed:", err) throw new Error( - "Cast vote failed: " + - (err instanceof Error ? err.message : String(err)), + "Cast vote failed: " + (err instanceof Error ? err.message : String(err)), ) } } @@ -1135,11 +1131,8 @@ export const stellarContractService = { isEnrolled, submitScholarshipProposal, castVote, -<<<<<<< HEAD -======= cancelProposal, reclaimInactiveEscrow, ->>>>>>> main getLearnTokenBalance, getGovernanceTokenBalance, getGovernanceVotingPower, diff --git a/server/src/templates/email-templates.ts b/server/src/templates/email-templates.ts index 229a5734..493800ec 100644 --- a/server/src/templates/email-templates.ts +++ b/server/src/templates/email-templates.ts @@ -150,8 +150,6 @@ export const templates: Record string> = { `, vars, ), -<<<<<<< HEAD -======= "milestone-approved-admin": (vars) => baseLayout( ` @@ -198,7 +196,6 @@ export const templates: Record string> = { `, vars, ), ->>>>>>> main } /** diff --git a/server/src/tests/comments.test.ts b/server/src/tests/comments.test.ts index 797b8a58..86daaf6b 100644 --- a/server/src/tests/comments.test.ts +++ b/server/src/tests/comments.test.ts @@ -8,18 +8,6 @@ import { createCommentsRouter } from "../routes/comments.routes" const JWT_SECRET = "learnvault-secret" const testJwtService = { -<<<<<<< HEAD - signWalletToken: (addr: string) => jwt.sign({ sub: addr }, JWT_SECRET), - verifyWalletToken: (token: string) => { - const d = jwt.verify(token, JWT_SECRET) as { - sub?: string - address?: string - } - const sub = d.sub ?? d.address ?? "" - if (!sub) throw new Error("Invalid token") - return { sub } - }, -======= signWalletToken: (addr: string) => jwt.sign({ sub: addr, jti: "test-jti" }, JWT_SECRET), verifyWalletToken: async (token: string) => { @@ -33,7 +21,6 @@ const testJwtService = { return { sub, jti: d.jti ?? "test-jti" } }, revokeToken: jest.fn().mockResolvedValue(undefined), ->>>>>>> main } function makeToken(address = "GUSER123") { diff --git a/server/src/tests/governance.test.ts b/server/src/tests/governance.test.ts index a40d464f..bb3c2aa9 100644 --- a/server/src/tests/governance.test.ts +++ b/server/src/tests/governance.test.ts @@ -25,21 +25,15 @@ jest.mock("../services/stellar-contract.service", () => ({ simulated: false, }), getGovernanceTokenBalance: jest.fn().mockResolvedValue("1250000000"), -<<<<<<< HEAD -======= getGovernanceVotingPower: jest.fn().mockResolvedValue("1250000000"), ->>>>>>> main castVote: jest.fn().mockResolvedValue({ txHash: "mock_vote_tx_hash", simulated: false, }), -<<<<<<< HEAD -======= cancelProposal: jest.fn().mockResolvedValue({ txHash: "mock_cancel_tx_hash", simulated: false, }), ->>>>>>> main }, })) @@ -91,12 +85,8 @@ function makeToken(address: string) { describe("POST /api/governance/proposals", () => { it("should create a valid governance proposal", async () => { const response = await request(app).post("/api/governance/proposals").send({ -<<<<<<< HEAD - author_address: "GDGQVOKHW4VEJRU2TETD6DBRKEO5ERCNF353LW5JBFUKJQ2K5RQDDXYZ", -======= author_address: "GDGQVOKHW4VEJRU2TETD6DBRKEO5ERCNF353LW5JBFUKJQ2K5RQDDXYZ", ->>>>>>> main title: "Fund my Soroban course", description: "I am learning Soroban and need funding for my course.", requested_amount: "500", @@ -110,12 +100,8 @@ describe("POST /api/governance/proposals", () => { it("should reject proposal with missing required fields", async () => { const response = await request(app).post("/api/governance/proposals").send({ -<<<<<<< HEAD - author_address: "GDGQVOKHW4VEJRU2TETD6DBRKEO5ERCNF353LW5JBFUKJQ2K5RQDDXYZ", -======= author_address: "GDGQVOKHW4VEJRU2TETD6DBRKEO5ERCNF353LW5JBFUKJQ2K5RQDDXYZ", ->>>>>>> main title: "Fund my course", }) @@ -140,12 +126,8 @@ describe("POST /api/governance/proposals", () => { it("should reject proposal with invalid evidence_url", async () => { const response = await request(app).post("/api/governance/proposals").send({ -<<<<<<< HEAD - author_address: "GDGQVOKHW4VEJRU2TETD6DBRKEO5ERCNF353LW5JBFUKJQ2K5RQDDXYZ", -======= author_address: "GDGQVOKHW4VEJRU2TETD6DBRKEO5ERCNF353LW5JBFUKJQ2K5RQDDXYZ", ->>>>>>> main title: "Fund my Soroban course", description: "I am learning Soroban and need funding for my course.", requested_amount: "500", @@ -159,12 +141,8 @@ describe("POST /api/governance/proposals", () => { it("should reject proposal with invalid requested_amount", async () => { const response = await request(app).post("/api/governance/proposals").send({ -<<<<<<< HEAD - author_address: "GDGQVOKHW4VEJRU2TETD6DBRKEO5ERCNF353LW5JBFUKJQ2K5RQDDXYZ", -======= author_address: "GDGQVOKHW4VEJRU2TETD6DBRKEO5ERCNF353LW5JBFUKJQ2K5RQDDXYZ", ->>>>>>> main title: "Fund my Soroban course", description: "I am learning Soroban and need funding for my course.", requested_amount: "not-a-number", @@ -184,12 +162,8 @@ describe("POST /api/governance/proposals", () => { ).mockRejectedValueOnce(new Error("Contract call failed")) const response = await request(app).post("/api/governance/proposals").send({ -<<<<<<< HEAD - author_address: "GDGQVOKHW4VEJRU2TETD6DBRKEO5ERCNF353LW5JBFUKJQ2K5RQDDXYZ", -======= author_address: "GDGQVOKHW4VEJRU2TETD6DBRKEO5ERCNF353LW5JBFUKJQ2K5RQDDXYZ", ->>>>>>> main title: "Fund my Soroban course", description: "I am learning Soroban and need funding for my course.", requested_amount: "500", @@ -247,8 +221,6 @@ describe("GET /api/governance/voting-power/:address", () => { }) }) -<<<<<<< HEAD -======= describe("GET /api/proposals", () => { it("returns proposals from the alias endpoint", async () => { const db = require("../db/index") @@ -314,7 +286,6 @@ describe("GET /api/proposals/:id", () => { }) }) ->>>>>>> main // Valid 56-char Stellar test address const TEST_VOTER = "GDGQVOKHW4VEJRU2TETD6DBRKEO5ERCNF353LW5JBFUKJQ2K5RQDDXYZ" @@ -330,14 +301,6 @@ describe("POST /api/governance/vote", () => { stellarContractService = scs.stellarContractService // Default happy path mocks pool.query -<<<<<<< HEAD - .mockResolvedValueOnce({ rows: [{ id: 1, status: "pending" }] }) // proposal check - .mockResolvedValueOnce({ rows: [] }) // no existing vote - .mockResolvedValueOnce({ rows: [{ id: 1 }] }) // insert vote - .mockResolvedValueOnce({ rows: [] }) // update proposal - .mockResolvedValueOnce({ rows: [{ votes_for: "1250000000", votes_against: "0" }] }) // fetch updated counts - stellarContractService.getGovernanceTokenBalance.mockResolvedValue("1250000000") -======= .mockResolvedValueOnce({ rows: [ { @@ -357,7 +320,6 @@ describe("POST /api/governance/vote", () => { stellarContractService.getGovernanceTokenBalance.mockResolvedValue( "1250000000", ) ->>>>>>> main stellarContractService.castVote.mockResolvedValue({ txHash: "mock_vote_tx", simulated: false, @@ -415,13 +377,9 @@ describe("POST /api/governance/vote", () => { it("should reject vote when proposal is not pending", async () => { pool.query.mockReset() -<<<<<<< HEAD - pool.query.mockResolvedValueOnce({ rows: [{ id: 1, status: "approved" }] }) -======= pool.query.mockResolvedValueOnce({ rows: [{ id: 1, status: "approved", deadline: null }], }) ->>>>>>> main const response = await request(app).post("/api/governance/vote").send({ proposal_id: 1, @@ -430,22 +388,15 @@ describe("POST /api/governance/vote", () => { }) expect(response.status).toBe(400) -<<<<<<< HEAD - expect(response.body).toHaveProperty("error", "Voting is closed for this proposal") -======= expect(response.body).toHaveProperty( "error", "Voting is closed for this proposal", ) ->>>>>>> main }) it("should reject vote when voter already voted", async () => { pool.query.mockReset() pool.query -<<<<<<< HEAD - .mockResolvedValueOnce({ rows: [{ id: 1, status: "pending" }] }) -======= .mockResolvedValueOnce({ rows: [ { @@ -456,7 +407,6 @@ describe("POST /api/governance/vote", () => { }, ], }) ->>>>>>> main .mockResolvedValueOnce({ rows: [{ id: 1 }] }) const response = await request(app).post("/api/governance/vote").send({ @@ -466,22 +416,15 @@ describe("POST /api/governance/vote", () => { }) expect(response.status).toBe(409) -<<<<<<< HEAD - expect(response.body).toHaveProperty("error", "You have already voted on this proposal") -======= expect(response.body).toHaveProperty( "error", "You have already voted on this proposal", ) ->>>>>>> main }) it("should reject vote when voter has no GOV tokens", async () => { pool.query.mockReset() pool.query -<<<<<<< HEAD - .mockResolvedValueOnce({ rows: [{ id: 1, status: "pending" }] }) -======= .mockResolvedValueOnce({ rows: [ { @@ -492,7 +435,6 @@ describe("POST /api/governance/vote", () => { }, ], }) ->>>>>>> main .mockResolvedValueOnce({ rows: [] }) stellarContractService.getGovernanceTokenBalance.mockResolvedValueOnce("0") @@ -509,11 +451,6 @@ describe("POST /api/governance/vote", () => { it("should handle contract call failure gracefully", async () => { pool.query.mockReset() pool.query -<<<<<<< HEAD - .mockResolvedValueOnce({ rows: [{ id: 1, status: "pending" }] }) - .mockResolvedValueOnce({ rows: [] }) - stellarContractService.castVote.mockRejectedValueOnce(new Error("Contract call failed")) -======= .mockResolvedValueOnce({ rows: [ { @@ -528,7 +465,6 @@ describe("POST /api/governance/vote", () => { stellarContractService.castVote.mockRejectedValueOnce( new Error("Contract call failed"), ) ->>>>>>> main const response = await request(app).post("/api/governance/vote").send({ proposal_id: 1, @@ -539,8 +475,6 @@ describe("POST /api/governance/vote", () => { expect(response.status).toBe(500) expect(response.body).toHaveProperty("error", "Failed to cast vote") }) -<<<<<<< HEAD -======= it("should reject vote when deadline has passed", async () => { pool.query.mockReset() @@ -658,5 +592,4 @@ describe("DELETE /api/proposals/:id", () => { expect(response.body.error).toBe("Proposal is already cancelled") expect(stellarContractService.cancelProposal).not.toHaveBeenCalled() }) ->>>>>>> main }) diff --git a/server/src/tests/upload.test.ts b/server/src/tests/upload.test.ts index 20bb5dfb..ff3d7f6b 100644 --- a/server/src/tests/upload.test.ts +++ b/server/src/tests/upload.test.ts @@ -32,18 +32,6 @@ import * as pinataService from "../services/pinata.service" const JWT_SECRET = "learnvault-secret" const testJwtService = { -<<<<<<< HEAD - signWalletToken: (addr: string) => jwt.sign({ sub: addr }, JWT_SECRET), - verifyWalletToken: (token: string) => { - const d = jwt.verify(token, JWT_SECRET) as { - sub?: string - address?: string - } - const sub = d.sub ?? d.address ?? "" - if (!sub) throw new Error("Invalid token") - return { sub } - }, -======= signWalletToken: (addr: string) => jwt.sign({ sub: addr, jti: "test-jti" }, JWT_SECRET), verifyWalletToken: async (token: string) => { @@ -57,7 +45,6 @@ const testJwtService = { return { sub, jti: d.jti ?? "test-jti" } }, revokeToken: jest.fn().mockResolvedValue(undefined), ->>>>>>> main } function makeToken(address = "GUSER123") { diff --git a/src/App.tsx b/src/App.tsx index d55042e8..e038c0c2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,12 +23,17 @@ const Debug = lazy(() => import("./pages/Debug")) const Donor = lazy(() => import("./pages/Donor")) const Home = lazy(() => import("./pages/Home")) const History = lazy(() => import("./pages/History")) +const ImpactDashboard = lazy(() => import("./pages/ImpactDashboard")) +const ImpactWidget = lazy(() => import("./pages/ImpactWidget")) const Leaderboard = lazy(() => import("./pages/Leaderboard")) const Learn = lazy(() => import("./pages/Learn")) +const LessonVersionDiff = lazy(() => import("./pages/LessonVersionDiff")) const LessonView = lazy(() => import("./pages/LessonView")) const NotFound = lazy(() => import("./pages/NotFound")) +const PeerReview = lazy(() => import("./pages/PeerReview")) const Profile = lazy(() => import("./pages/Profile")) const ScholarshipApply = lazy(() => import("./pages/ScholarshipApply")) +const SponsorPortal = lazy(() => import("./pages/SponsorPortal")) const Treasury = lazy(() => import("./pages/Treasury")) const Wiki = lazy(() => import("./pages/Wiki")) const WikiPage = lazy(() => import("./pages/WikiPage")) @@ -46,6 +51,7 @@ function App() { + )} /> }> )} /> )} /> @@ -55,32 +61,24 @@ function App() { /> )} /> )} /> - )} - /> + )} /> )} /> )} /> )} /> )} /> )} /> - )} - /> - )} - /> + )} /> + )} /> )} /> + )} /> )} /> )} /> )} /> )} /> - )} - /> + )} /> + )} /> + )} /> + )} /> )} /> )} /> )} /> @@ -109,13 +107,12 @@ const RouteFallback = () => ( ) const AppLayout = () => ( - // Issue #61 — Theme-aware background using CSS variables + Tailwind dark: variant
-
+
diff --git a/src/components/AddressDisplay.tsx b/src/components/AddressDisplay.tsx index dfdf9cd7..c73ee616 100644 --- a/src/components/AddressDisplay.tsx +++ b/src/components/AddressDisplay.tsx @@ -1,5 +1,5 @@ import { motion, AnimatePresence } from "framer-motion" -import { useState, useId } from "react" +import { useState } from "react" import { useWallet } from "../hooks/useWallet" import { stellarNetwork } from "../contracts/util" @@ -36,16 +36,9 @@ export const AddressDisplay: React.FC = ({ showExplorerLink = true, fullOnHover = true, }) => { -<<<<<<< HEAD - const [copyState, setCopyState] = useState<"idle" | "copied" | "error">( - "idle", - ) -======= const [copied, setCopied] = useState(false) const [isHovered, setIsHovered] = useState(false) const { network: walletNetwork } = useWallet() ->>>>>>> main - const tooltipId = useId() if (!address) return null @@ -178,4 +171,4 @@ export const AddressDisplay: React.FC = ({ ) } -export default AddressDisplay \ No newline at end of file +export default AddressDisplay diff --git a/src/components/CommentCard.tsx b/src/components/CommentCard.tsx index a6256ee4..beaf3f99 100644 --- a/src/components/CommentCard.tsx +++ b/src/components/CommentCard.tsx @@ -1,13 +1,9 @@ import { formatDistanceToNow } from "date-fns" -import React, { useState } from "react" -import SafeMarkdown from "./SafeMarkdown" import React, { useId, useState } from "react" -import ReactMarkdown from "react-markdown" -import ConfirmDialog from "./ConfirmDialog" import { useWallet } from "../hooks/useWallet" import { getAuthToken } from "../util/auth" - -const API_BASE = import.meta.env.VITE_SERVER_URL ?? "http://localhost:4000" +import ConfirmDialog from "./ConfirmDialog" +import SafeMarkdown from "./SafeMarkdown" export interface Comment { id: number @@ -123,7 +119,7 @@ const CommentCard: React.FC = ({ setIsReplying(false) onUpdate?.() } else { - const err = await res.json().catch(() => ({})) + const err = (await res.json().catch(() => ({}))) as { error?: string } setReplyError(err.error || "Reply failed.") } } catch (err) { @@ -245,8 +241,6 @@ const CommentCard: React.FC = ({
-
- {comment.content}
diff --git a/src/components/CommentSection.tsx b/src/components/CommentSection.tsx index db44ade5..f01053f0 100644 --- a/src/components/CommentSection.tsx +++ b/src/components/CommentSection.tsx @@ -1,16 +1,9 @@ -<<<<<<< HEAD -import { useEffect, useId, useState, useCallback } from "react" -======= import { useEffect, useId, useState } from "react" -import { formatDistanceToNow } from "date-fns" ->>>>>>> main import { useTranslation } from "react-i18next" import { useWallet } from "../hooks/useWallet" import { getAuthToken } from "../util/auth" import CommentCard from "./CommentCard" -const API_BASE = import.meta.env.VITE_SERVER_URL ?? "http://localhost:4000" - export interface Comment { id: number proposal_id: string @@ -28,25 +21,18 @@ interface CommentSectionProps { proposalAuthor?: string } -<<<<<<< HEAD -function CommentSection({ -======= const API_URL = ( (import.meta.env.VITE_API_URL as string | undefined) ?? (import.meta.env.VITE_SERVER_URL as string | undefined) ?? "" ).replace(/\/$/, "") -const CommentSection: React.FC = ({ ->>>>>>> main +const CommentSection = ({ proposalId, proposalAuthor, -}: CommentSectionProps) { +}: CommentSectionProps) => { const { t } = useTranslation() -<<<<<<< HEAD -======= const { address } = useWallet() ->>>>>>> main const pollInterval = Number(import.meta.env.VITE_COMMENT_POLL_MS) || 15000 const [lastUpdated, setLastUpdated] = useState(new Date()) const commentInputId = useId() @@ -60,44 +46,12 @@ const CommentSection: React.FC = ({ const [submissionError, setSubmissionError] = useState(null) const [submissionStatus, setSubmissionStatus] = useState(null) -<<<<<<< HEAD - const fetchComments = useCallback( - async (isSilent = false) => { - if (!isSilent) setLoading(true) - try { - const res = await fetch( - `${import.meta.env.VITE_SERVER_URL}/api/proposals/${proposalId}/comments`, - ) - if (!res.ok) throw new Error("Failed to fetch comments") - const data = await res.json() - setComments(data) - setLastUpdated(new Date()) - } catch (err) { - console.error("Failed to fetch comments", err) - } finally { - if (!isSilent) setLoading(false) - } - }, - [proposalId], - ) - - useEffect(() => { - let isMounted = true - const safeFetch = async (silent: boolean) => { - if (!isMounted) return - await fetchComments(silent) - } - - void safeFetch(false) - - const interval = setInterval(() => void safeFetch(true), pollInterval) -======= const fetchComments = async () => { setLoading(true) try { const res = await fetch(`${API_URL}/api/proposals/${proposalId}/comments`) - const data = await res.json() - setComments(data) + const data = (await res.json()) as Comment[] | { data?: Comment[] } + setComments(Array.isArray(data) ? data : (data.data ?? [])) } catch (err) { console.error("Failed to fetch comments", err) } finally { @@ -115,7 +69,6 @@ const CommentSection: React.FC = ({ void safeFetch() const interval = setInterval(() => void safeFetch(), pollInterval) ->>>>>>> main return () => { isMounted = false clearInterval(interval) @@ -157,7 +110,7 @@ const CommentSection: React.FC = ({ setSubmissionStatus("Comment posted successfully.") void fetchComments() } else { - const err = await res.json() + const err = (await res.json().catch(() => ({}))) as { error?: string } setSubmissionError(err.error || "Failed to post comment.") } } catch (err) { diff --git a/src/components/NavBar.tsx b/src/components/NavBar.tsx index 90312dc4..7d9ed2dc 100644 --- a/src/components/NavBar.tsx +++ b/src/components/NavBar.tsx @@ -40,9 +40,11 @@ export default function NavBar() { { to: "/dao", label: t("nav.dao") }, { to: "/community", label: "Community" }, { to: "/leaderboard", label: t("nav.leaderboard") }, + { to: "/impact", label: "Impact" }, { to: "/history", label: "Activity" }, { to: "/wiki", label: t("nav.docs") }, { to: "/donor", label: "Donor" }, + { to: "/sponsor", label: "Sponsor" }, { to: "/treasury", label: t("nav.treasury") }, ] @@ -81,8 +83,8 @@ export default function NavBar() { }) } else if (to === "/leaderboard") { void queryClient.prefetchQuery({ - queryKey: ["leaderboard", address], - queryFn: () => fetchLeaderboard(address), + queryKey: ["leaderboard", address, 1, 10], + queryFn: () => fetchLeaderboard(address, 1, 10), staleTime: 300 * 1000, }) } else if (to === "/history" && address) { diff --git a/src/components/SponsorLogosForTrack.tsx b/src/components/SponsorLogosForTrack.tsx new file mode 100644 index 00000000..f9243834 --- /dev/null +++ b/src/components/SponsorLogosForTrack.tsx @@ -0,0 +1,60 @@ +import { useMemo } from "react" +import { useTrackSponsorLogos } from "../hooks/useSponsors" + +type SponsorLogosForTrackProps = { + track: string + compact?: boolean +} + +export default function SponsorLogosForTrack({ + track, + compact = false, +}: SponsorLogosForTrackProps) { + const { data: sponsors = [], isLoading } = useTrackSponsorLogos(track) + const visibleSponsors = useMemo( + () => sponsors.filter((sponsor) => Boolean(sponsor.logo_url)).slice(0, compact ? 4 : 8), + [sponsors, compact], + ) + + if (isLoading || visibleSponsors.length === 0) return null + + return ( +
+ {!compact && ( +

+ Track Sponsors +

+ )} + +
+ ) +} diff --git a/src/hooks/useAdmin.ts b/src/hooks/useAdmin.ts index 3ad7ec85..e4e2653f 100644 --- a/src/hooks/useAdmin.ts +++ b/src/hooks/useAdmin.ts @@ -1,21 +1,14 @@ -<<<<<<< HEAD -import { useState, useCallback } from "react" -======= import { useCallback, useRef, useState } from "react" import { apiFetchJson, buildApiUrl, createAuthHeaders } from "../lib/api" ->>>>>>> main export interface AdminStats { pendingMilestones: number approvedToday: number rejectedToday: number -<<<<<<< HEAD -======= totalScholars: number totalLrnMinted: string openProposals: number treasuryBalanceUsdc: string ->>>>>>> main } export interface MilestoneSubmission { @@ -136,12 +129,6 @@ export function useAdminStats() { setLoading(true) setError(null) try { -<<<<<<< HEAD - const res = await fetch("/api/admin/stats") - if (!res.ok) throw new Error("Failed to fetch admin stats") - const data: AdminStats = await res.json() - setStats(data) -======= const data = await apiFetchJson("/api/admin/stats", { auth: true, }) @@ -154,7 +141,6 @@ export function useAdminStats() { openProposals: Number(data.open_proposals ?? 0), treasuryBalanceUsdc: data.treasury_balance_usdc ?? "0", }) ->>>>>>> main } catch (err: unknown) { setError(err instanceof Error ? err.message : "Unknown error") } finally { @@ -171,11 +157,8 @@ export function useAdminMilestones() { const [page, setPage] = useState(1) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) -<<<<<<< HEAD -======= const filtersRef = useRef<{ course?: string; status?: string }>({}) const pageRef = useRef(1) ->>>>>>> main const PAGE_SIZE = 10 @@ -186,11 +169,8 @@ export function useAdminMilestones() { ) => { setLoading(true) setError(null) -<<<<<<< HEAD -======= filtersRef.current = filters pageRef.current = pageNum ->>>>>>> main try { const params = new URLSearchParams({ page: String(pageNum), @@ -198,12 +178,6 @@ export function useAdminMilestones() { ...(filters.course ? { course: filters.course } : {}), ...(filters.status ? { status: filters.status } : {}), }) -<<<<<<< HEAD - const res = await fetch(`/api/admin/milestones?${params.toString()}`) - if (!res.ok) throw new Error("Failed to fetch milestones") - const result: PaginatedMilestones = await res.json() - setMilestones(result.data) -======= const result = await apiFetchJson( `/api/admin/milestones?${params.toString()}`, { @@ -211,7 +185,6 @@ export function useAdminMilestones() { }, ) setMilestones(result.data.map(mapMilestoneSubmission)) ->>>>>>> main setTotal(result.total) setPage(result.page) } catch (err: unknown) { @@ -223,50 +196,6 @@ export function useAdminMilestones() { [], ) -<<<<<<< HEAD - const approveMilestone = useCallback(async (id: string): Promise => { - // Optimistic update - setMilestones((prev) => - prev.map((m) => (m.id === id ? { ...m, status: "approved" } : m)), - ) - try { - const res = await fetch(`/api/admin/milestones/${id}/approve`, { - method: "POST", - }) - if (!res.ok) throw new Error("Approval failed") - return true - } catch (err: unknown) { - // Rollback on failure - setMilestones((prev) => - prev.map((m) => (m.id === id ? { ...m, status: "pending" } : m)), - ) - setError(err instanceof Error ? err.message : "Approval failed") - return false - } - }, []) - - const rejectMilestone = useCallback(async (id: string): Promise => { - // Optimistic update - setMilestones((prev) => - prev.map((m) => (m.id === id ? { ...m, status: "rejected" } : m)), - ) - try { - const res = await fetch(`/api/admin/milestones/${id}/reject`, { - method: "POST", - }) - if (!res.ok) throw new Error("Rejection failed") - return true - } catch (err: unknown) { - // Rollback on failure - setMilestones((prev) => - prev.map((m) => (m.id === id ? { ...m, status: "pending" } : m)), - ) - setError(err instanceof Error ? err.message : "Rejection failed") - return false - } - }, []) - -======= const refreshMilestones = useCallback(async () => { await fetchMilestones(pageRef.current, filtersRef.current) }, [fetchMilestones]) @@ -392,7 +321,6 @@ export function useAdminMilestones() { [runBatchMilestones], ) ->>>>>>> main return { milestones, total, @@ -403,10 +331,7 @@ export function useAdminMilestones() { fetchMilestones, approveMilestone, rejectMilestone, -<<<<<<< HEAD -======= batchApproveMilestones, batchRejectMilestones, ->>>>>>> main } } diff --git a/src/hooks/useCourses.ts b/src/hooks/useCourses.ts index 6e76d63b..5ecb2749 100644 --- a/src/hooks/useCourses.ts +++ b/src/hooks/useCourses.ts @@ -40,6 +40,11 @@ type ApiLesson = { estimated_minutes?: number isMilestone?: boolean is_milestone?: boolean + version?: number + isLatest?: boolean + is_latest?: boolean + changeSummary?: string | null + change_summary?: string | null } const defaultAccentClassName = @@ -126,6 +131,12 @@ const normalizeLesson = ( ? lesson.estimatedMinutes : Number(lesson.estimated_minutes ?? 10), isMilestone: Boolean(lesson.isMilestone ?? lesson.is_milestone), + version: + typeof lesson.version === "number" + ? lesson.version + : Number.parseInt(String(lesson.version ?? "1"), 10), + isLatest: Boolean(lesson.isLatest ?? lesson.is_latest ?? true), + changeSummary: lesson.changeSummary ?? lesson.change_summary ?? null, }) async function fetchJson(url: string): Promise { @@ -242,13 +253,27 @@ export function useEnrolledCourses() { } } -export function useCourseDetail(idOrSlug: string | undefined) { +export function useCourseDetail( + idOrSlug: string | undefined, + learnerAddress?: string, +) { const query = useQuery({ - queryKey: ["course", idOrSlug], + queryKey: ["course", idOrSlug, learnerAddress], queryFn: async (): Promise => { - const response = await fetchJson( - `/api/courses/${idOrSlug}`, - ) + const params = new URLSearchParams() + if (learnerAddress) params.set("learner_address", learnerAddress) + const url = `/api/courses/${idOrSlug}${params.toString() ? `?${params.toString()}` : ""}` + const response = await fetchJson< + ApiCourse & { + lessons?: ApiLesson[] + enrollmentContentVersion?: number | null + enrollment_content_version?: number | null + latestContentVersion?: number + latest_content_version?: number + hasUpdatedContent?: boolean + has_updated_content?: boolean + } + >(url) const course = normalizeCourse(response) const lessons = (response.lessons ?? []) .map((lesson) => normalizeLesson(lesson, course.slug)) @@ -256,6 +281,14 @@ export function useCourseDetail(idOrSlug: string | undefined) { return { ...course, + enrollmentContentVersion: + response.enrollmentContentVersion ?? + response.enrollment_content_version ?? + null, + latestContentVersion: + response.latestContentVersion ?? response.latest_content_version ?? 1, + hasUpdatedContent: + response.hasUpdatedContent ?? response.has_updated_content ?? false, lessons, } }, diff --git a/src/hooks/useDonor.test.tsx b/src/hooks/useDonor.test.tsx index 7ee1ba1b..db0ac2df 100644 --- a/src/hooks/useDonor.test.tsx +++ b/src/hooks/useDonor.test.tsx @@ -62,11 +62,7 @@ describe("useDonor", () => { ...baseContracts, scholarshipTreasury: undefined, governanceToken: undefined, -<<<<<<< HEAD - isDeployed: () => false, -======= isDeployed: (_id: string | undefined): _id is string => false, ->>>>>>> main } as ReturnType) const { result } = renderHook(() => useDonor()) @@ -74,11 +70,7 @@ describe("useDonor", () => { await waitFor(() => expect(result.current.isLoading).toBe(false)) expect(result.current.contributions).toHaveLength(0) -<<<<<<< HEAD - expect(result.current.stats.totalContributed).toBe(0) -======= expect(result.current.stats.total_contributed).toBe(0) ->>>>>>> main expect(result.current.isEmpty).toBe(true) }) @@ -105,11 +97,7 @@ describe("useDonor", () => { await waitFor(() => expect(result.current.isLoading).toBe(false)) expect(result.current.contributions.length).toBeGreaterThan(0) -<<<<<<< HEAD - expect(result.current.stats.totalContributed).toBeGreaterThan(0) -======= expect(result.current.stats.total_contributed).toBeGreaterThan(0) ->>>>>>> main }) it("handles fetch errors gracefully", async () => { diff --git a/src/hooks/useDonor.ts b/src/hooks/useDonor.ts index 9e3494ea..2a5cad46 100644 --- a/src/hooks/useDonor.ts +++ b/src/hooks/useDonor.ts @@ -5,12 +5,10 @@ import { type DonorData, type DonorContribution, type DonorStats, -<<<<<<< HEAD -======= type DonorImpact, ->>>>>>> main type Vote, type RpcEvent, + type Scholar, } from "../types/contracts" import { useContractIds } from "./useContractIds" import { useWallet } from "./useWallet" @@ -43,10 +41,10 @@ const makeEmptyData = (): DonorData => ({ const toDate = (input?: string): string => { if (!input) return new Date().toISOString().split("T")[0] ?? "" - const d = new Date(input) - return Number.isNaN(d.getTime()) + const date = new Date(input) + return Number.isNaN(date.getTime()) ? (new Date().toISOString().split("T")[0] ?? "") - : (d.toISOString().split("T")[0] ?? "") + : (date.toISOString().split("T")[0] ?? "") } const stringify = (value: unknown): string => @@ -62,7 +60,7 @@ const fetchDonorImpact = async (address: string): Promise => try { const response = await fetch(`/api/donors/${address}/impact`) if (!response.ok) return null - return await response.json() + return (await response.json()) as DonorImpact } catch { return null } @@ -72,7 +70,8 @@ const readContractEvents = async ( contractIds: string[], walletAddress: string, ): Promise => { - if (!contractIds.length) return [] + if (contractIds.length === 0) return [] + const response = await fetch(rpcUrl, { method: "POST", headers: { "content-type": "application/json" }, @@ -86,14 +85,13 @@ const readContractEvents = async ( }, }), }) + if (!response.ok) return [] const payload = (await response.json()) as { result?: { events?: RpcEvent[] } } const events = payload.result?.events ?? [] - return events.filter((evt) => - stringify(evt).includes(walletAddress.toLowerCase()), - ) + return events.filter((event) => stringify(event).includes(walletAddress.toLowerCase())) } export const useDonor = (): DonorData => { @@ -114,57 +112,59 @@ export const useDonor = (): DonorData => { return } - setData((prev) => ({ ...prev, isLoading: true, error: null })) + setData((previous) => ({ ...previous, isLoading: true, error: null })) try { const contractIds = [scholarshipTreasury, governanceToken].filter( (id): id is string => Boolean(id), ) + const [events, impact] = await Promise.all([ readContractEvents(contractIds, address), fetchDonorImpact(address), ]) + const contributions: DonorContribution[] = events - .filter((evt) => + .filter((event) => stringify({ - topic: evt.topics ?? evt.topic, - value: evt.value, + topic: event.topics ?? event.topic, + value: event.value, }).includes("deposit"), ) - .map((evt, i) => ({ - txHash: evt.txHash ?? evt.id ?? `deposit-${i}`, - amount: extractNumber(evt.value), - date: toDate(evt.ledgerCloseTime), - block: evt.ledger ?? 0, + .map((event, index) => ({ + txHash: event.txHash ?? event.id ?? `deposit-${index}`, + amount: extractNumber(event.value), + date: toDate(event.ledgerCloseTime), + block: event.ledger ?? 0, })) .filter((entry) => entry.amount > 0) const votes: Vote[] = events - .filter((evt) => + .filter((event) => stringify({ - topic: evt.topics ?? evt.topic, - value: evt.value, + topic: event.topics ?? event.topic, + value: event.value, }).includes("vote"), ) - .map((evt, i): Vote => { - const text = stringify(evt.value) + .map((event, index): Vote => { + const text = stringify(event.value) return { - proposalId: String(i + 1), - proposalTitle: `Proposal #${i + 1}`, + proposalId: String(index + 1), + proposalTitle: `Proposal #${index + 1}`, voteChoice: text.includes("false") ? "against" : "for", - votePower: extractNumber(evt.value), - status: "active" as const, + votePower: extractNumber(event.value), + status: "active", } }) .filter((entry) => entry.votePower > 0) const totalContributed = contributions.reduce( - (sum, c) => sum + c.amount, + (sum, contribution) => sum + contribution.amount, 0, ) const scholarsFunded = new Set( events - .filter((evt) => stringify(evt).includes("disburse")) - .map((evt) => evt.txHash ?? evt.id ?? ""), + .filter((event) => stringify(event).includes("disburse")) + .map((event) => event.txHash ?? event.id ?? ""), ).size const next: DonorData = { @@ -181,6 +181,7 @@ export const useDonor = (): DonorData => { error: null, isEmpty: contributions.length === 0 && votes.length === 0, } + if (!cancelled) setData(next) } catch { if (!cancelled) { diff --git a/src/hooks/useForum.ts b/src/hooks/useForum.ts index ef3b9c7f..dc4c3ff9 100644 --- a/src/hooks/useForum.ts +++ b/src/hooks/useForum.ts @@ -1,45 +1,69 @@ import { useQuery } from "@tanstack/react-query" -import { api } from "../util/api" +import { apiFetchJson } from "../lib/api" import { type ForumThread, type ForumThreadDetail } from "../types/forum" export const useForumThreads = (courseId: string) => { - return useQuery({ - queryKey: ["forum", "threads", courseId], - queryFn: async (): Promise => { - const res = await api.get(`/courses/${courseId}/forum`) - return res.data.data - }, - enabled: Boolean(courseId), - }) + return useQuery({ + queryKey: ["forum", "threads", courseId], + queryFn: async (): Promise => { + const res = await apiFetchJson<{ data: ForumThread[] }>( + `/api/courses/${courseId}/forum`, + { auth: true }, + ) + return res.data + }, + enabled: Boolean(courseId), + }) } export const useForumThreadDetail = (courseId: string, threadId: number) => { - return useQuery({ - queryKey: ["forum", "thread", courseId, threadId], - queryFn: async (): Promise => { - const res = await api.get(`/courses/${courseId}/forum/${threadId}`) - return res.data - }, - enabled: Boolean(courseId) && Boolean(threadId), - }) + return useQuery({ + queryKey: ["forum", "thread", courseId, threadId], + queryFn: () => + apiFetchJson( + `/api/courses/${courseId}/forum/${threadId}`, + { auth: true }, + ), + enabled: Boolean(courseId) && Boolean(threadId), + }) } -export const createThread = async (courseId: string, title: string, content: string) => { - const res = await api.post(`/courses/${courseId}/forum`, { title, content }) - return res.data +export const createThread = async ( + courseId: string, + title: string, + content: string, +) => { + return apiFetchJson(`/api/courses/${courseId}/forum`, { + method: "POST", + auth: true, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title, content }), + }) } -export const replyToThread = async (courseId: string, threadId: number, content: string) => { - const res = await api.post(`/courses/${courseId}/forum/${threadId}/replies`, { content }) - return res.data +export const replyToThread = async ( + courseId: string, + threadId: number, + content: string, +) => { + return apiFetchJson(`/api/courses/${courseId}/forum/${threadId}/replies`, { + method: "POST", + auth: true, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content }), + }) } export const deleteThread = async (courseId: string, threadId: number) => { - const res = await api.delete(`/courses/${courseId}/forum/${threadId}`) - return res.data + return apiFetchJson(`/api/courses/${courseId}/forum/${threadId}`, { + method: "DELETE", + auth: true, + }) } export const deleteReply = async (courseId: string, replyId: number) => { - const res = await api.delete(`/courses/${courseId}/forum/replies/${replyId}`) - return res.data + return apiFetchJson(`/api/courses/${courseId}/forum/replies/${replyId}`, { + method: "DELETE", + auth: true, + }) } diff --git a/src/hooks/useImpactMetrics.ts b/src/hooks/useImpactMetrics.ts new file mode 100644 index 00000000..a46f253b --- /dev/null +++ b/src/hooks/useImpactMetrics.ts @@ -0,0 +1,83 @@ +import { useQuery } from "@tanstack/react-query" + +export type ImpactTotals = { + total_scholars_funded: number + total_usdc_disbursed: string + average_course_completion_rate: number + total_lrn_minted: string +} + +export type ImpactRegion = { + country_region: string + scholar_count: number +} + +export type TopCompletedCourse = { + course_id: string + course_title: string + completed_count: number +} + +export type QuarterlyTrend = { + quarter: string + scholars_funded: number + usdc_disbursed: string +} + +export type ImpactMetricsPayload = { + totals: ImpactTotals + countries_regions: ImpactRegion[] + top_completed_courses: TopCompletedCourse[] + trends: { + quarterly: QuarterlyTrend[] + } + generated_at: string +} + +async function fetchImpactMetrics(): Promise { + const response = await fetch("/api/impact/metrics") + if (!response.ok) { + const payload = await response.json().catch(() => ({})) + throw new Error( + (payload as { error?: string }).error || "Failed to fetch impact metrics", + ) + } + return response.json() as Promise +} + +async function fetchImpactWidget(): Promise<{ + total_scholars_funded: number + total_usdc_disbursed: string + total_lrn_minted: string + generated_at: string +}> { + const response = await fetch("/api/impact/widget") + if (!response.ok) { + const payload = await response.json().catch(() => ({})) + throw new Error( + (payload as { error?: string }).error || "Failed to fetch impact widget", + ) + } + return response.json() as Promise<{ + total_scholars_funded: number + total_usdc_disbursed: string + total_lrn_minted: string + generated_at: string + }> +} + +export function useImpactMetrics() { + return useQuery({ + queryKey: ["impact", "metrics"], + queryFn: fetchImpactMetrics, + staleTime: 60 * 1000, + }) +} + +export function useImpactWidgetData() { + return useQuery({ + queryKey: ["impact", "widget"], + queryFn: fetchImpactWidget, + staleTime: 60 * 1000, + }) +} diff --git a/src/hooks/useLeaderboard.ts b/src/hooks/useLeaderboard.ts index 16d2d384..e3fcbd6f 100644 --- a/src/hooks/useLeaderboard.ts +++ b/src/hooks/useLeaderboard.ts @@ -11,22 +11,32 @@ export type LeaderboardApiEntry = { export interface LeaderboardData { rankings?: LeaderboardApiEntry[] your_rank?: number | null + total?: number } export async function fetchLeaderboard( address?: string, + page = 1, + limit = 10, ): Promise { + const params = new URLSearchParams() + params.set("page", String(page)) + params.set("limit", String(limit)) + if (address) { + params.set("viewer_address", address) + } + const response = await fetch( - `${API_URL}/api/scholars/leaderboard${address ? `?viewer_address=${address}` : ""}`, + `${API_URL}/api/scholars/leaderboard?${params.toString()}`, ) if (!response.ok) throw new Error("Failed to fetch leaderboard") return (await response.json()) as LeaderboardData } -export function useLeaderboard(address?: string) { +export function useLeaderboard(address?: string, page = 1, limit = 10) { return useQuery({ - queryKey: ["leaderboard", address], - queryFn: () => fetchLeaderboard(address), + queryKey: ["leaderboard", address, page, limit], + queryFn: () => fetchLeaderboard(address, page, limit), staleTime: 300 * 1000, // 5 minutes }) } diff --git a/src/hooks/useSponsors.ts b/src/hooks/useSponsors.ts new file mode 100644 index 00000000..3668c85a --- /dev/null +++ b/src/hooks/useSponsors.ts @@ -0,0 +1,203 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" + +export type SponsorOrganizationProfile = { + wallet_address: string + name: string + logo_url: string | null + website: string | null + mission: string | null + created_at?: string + updated_at?: string +} + +export type SponsorLogo = { + wallet_address: string + name: string + logo_url: string | null + website: string | null + total_track_donated_usdc: string + latest_sponsorship_at: string +} + +export type OrganizationScholarProgress = { + learner_address: string + completed_milestones: number + total_milestones: number + completion_rate: number +} + +export type QuarterlySponsorReport = { + year: number + quarter: number + total_donated_usdc: string + sponsored_tracks_count: number + scholars_impacted: number + milestones_completed: number +} + +type TrackSponsorshipInput = { + wallet_address: string + track: string + donation_usdc: number + tx_hash?: string +} + +type UpsertOrgProfileInput = { + walletAddress: string + name: string + logo_url?: string + website?: string + mission?: string +} + +async function fetchJson(url: string, init?: RequestInit): Promise { + const response = await fetch(url, { + headers: { + "Content-Type": "application/json", + ...(init?.headers ?? {}), + }, + ...init, + }) + + if (!response.ok) { + const payload = await response.json().catch(() => ({})) + throw new Error((payload as { error?: string }).error || "Request failed") + } + + return response.json() as Promise +} + +export function useSponsorOrganizationProfile(walletAddress: string | undefined) { + return useQuery({ + queryKey: ["sponsors", "organization", walletAddress], + queryFn: async (): Promise => { + if (!walletAddress) return null + const response = await fetch(`/api/sponsors/organizations/${walletAddress}`) + if (response.status === 404) return null + if (!response.ok) throw new Error("Failed to load organization profile") + const data = + (await response.json()) as { profile: SponsorOrganizationProfile } + return data.profile + }, + enabled: Boolean(walletAddress), + staleTime: 60 * 1000, + }) +} + +export function useUpsertSponsorOrganizationProfile() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (input: UpsertOrgProfileInput) => { + return fetchJson<{ profile: SponsorOrganizationProfile }>( + `/api/sponsors/organizations/${input.walletAddress}`, + { + method: "PUT", + body: JSON.stringify({ + name: input.name, + logo_url: input.logo_url, + website: input.website, + mission: input.mission, + }), + }, + ) + }, + onSuccess: (_result, variables) => { + void queryClient.invalidateQueries({ + queryKey: ["sponsors", "organization", variables.walletAddress], + }) + }, + }) +} + +export function useCreateTrackSponsorship() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (input: TrackSponsorshipInput) => { + return fetchJson<{ sponsorship: unknown }>("/api/sponsors/sponsorships", { + method: "POST", + body: JSON.stringify(input), + }) + }, + onSuccess: (_result, variables) => { + void queryClient.invalidateQueries({ + queryKey: ["sponsors", "dashboard", variables.wallet_address], + }) + void queryClient.invalidateQueries({ + queryKey: ["sponsors", "quarterly", variables.wallet_address], + }) + void queryClient.invalidateQueries({ + queryKey: ["sponsors", "logos", variables.track], + }) + }, + }) +} + +export function useTrackSponsorLogos(track: string | undefined) { + return useQuery({ + queryKey: ["sponsors", "logos", track], + queryFn: async (): Promise => { + if (!track) return [] + const data = await fetchJson<{ sponsors: SponsorLogo[] }>( + `/api/sponsors/logos?track=${encodeURIComponent(track)}`, + ) + return data.sponsors ?? [] + }, + enabled: Boolean(track), + staleTime: 60 * 1000, + }) +} + +export function useSponsorDashboard(walletAddress: string | undefined) { + return useQuery({ + queryKey: ["sponsors", "dashboard", walletAddress], + queryFn: async (): Promise<{ + tracks: string[] + scholars: OrganizationScholarProgress[] + }> => { + if (!walletAddress) return { tracks: [], scholars: [] } + return fetchJson(`/api/sponsors/organizations/${walletAddress}/dashboard`) + }, + enabled: Boolean(walletAddress), + staleTime: 60 * 1000, + }) +} + +export function useSponsorQuarterlyReports( + walletAddress: string | undefined, + year?: number, + quarter?: number, +) { + return useQuery({ + queryKey: ["sponsors", "quarterly", walletAddress, year, quarter], + queryFn: async (): Promise => { + if (!walletAddress) return [] + const params = new URLSearchParams() + if (year) params.set("year", String(year)) + if (quarter) params.set("quarter", String(quarter)) + const url = `/api/sponsors/organizations/${walletAddress}/reports/quarterly${ + params.toString() ? `?${params.toString()}` : "" + }` + const data = await fetchJson<{ reports: QuarterlySponsorReport[] }>(url) + return data.reports ?? [] + }, + enabled: Boolean(walletAddress), + staleTime: 60 * 1000, + }) +} + +export function useUpsertScholarRegion() { + return useMutation({ + mutationFn: async (payload: { + learner_address: string + country_region: string + }) => { + return fetchJson<{ profile: { country_region: string } }>( + "/api/sponsors/scholar-region", + { + method: "PUT", + body: JSON.stringify(payload), + }, + ) + }, + }) +} diff --git a/src/pages/Admin.tsx b/src/pages/Admin.tsx index 8c1cc8b4..1752f701 100644 --- a/src/pages/Admin.tsx +++ b/src/pages/Admin.tsx @@ -1,20 +1,11 @@ -<<<<<<< HEAD -import React, { useEffect, useState } from "react" -import { useTranslation } from "react-i18next" -======= import { useQuery } from "@tanstack/react-query" import React, { useEffect, useMemo, useState } from "react" import ReactMarkdown from "react-markdown" ->>>>>>> main import { useNavigate } from "react-router-dom" import TxHashLink from "../components/TxHashLink" import { useAdminStats, useAdminMilestones, -<<<<<<< HEAD - type MilestoneSubmission, -} from "../hooks/useAdmin" -======= type BatchMilestoneResponse, type MilestoneSubmission, } from "../hooks/useAdmin" @@ -33,106 +24,17 @@ import { import { apiFetchJson } from "../lib/api" import { getAuthToken } from "../util/auth" import { shortenContractId } from "../util/contract" ->>>>>>> main type AdminSection = | "courses" | "milestones" | "users" -<<<<<<< HEAD -======= | "wiki" ->>>>>>> main | "treasury" | "contracts" type CourseStatus = "draft" | "published" interface AdminCourse { -<<<<<<< HEAD - id: number - title: string - status: CourseStatus - students: number -} - -interface UserProfilePreview { - address: string - balance: string - enrollment: string - tier: string -} - -interface ContractRecord { - name: string - tag: string - address: string - updated: string -} - -interface CourseImportRow { - title: string - slug: string - track: string - difficulty: string - description?: string - coverImage?: string | null - published?: boolean -} - -interface BulkImportResult { - row: number - slug: string - success: boolean - errors: string[] -} - -const initialCourses: AdminCourse[] = [ - { id: 1, title: "Soroban Basics", status: "published", students: 84 }, - { id: 2, title: "Stellar Security", status: "draft", students: 0 }, -] - -const contractRecords: ContractRecord[] = [ - { - name: "Scholarship Treasury", - tag: "prod", - address: "CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", - updated: "2026-03-20", - }, - { - name: "Governance Token", - tag: "prod", - address: "CYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY", - updated: "2026-03-20", - }, -] - -const COURSES = [ - "All", - "Soroban Basics", - "Stellar Security", - "Web3 Dev", - "DeFi", - "Frontend Dev", -] -const STATUSES = ["pending", "approved", "rejected"] - -// --------------------------------------------------------------------------- -// Confirmation dialog -// --------------------------------------------------------------------------- -interface ConfirmDialogProps { - action: "approve" | "reject" - milestone: MilestoneSubmission - onConfirm: () => void - onCancel: () => void -} - -const ConfirmDialog: React.FC = ({ - action, - milestone, - onConfirm, - onCancel, -}) => ( -======= id: string slug: string title: string @@ -230,7 +132,6 @@ const ConfirmDialog: React.FC<{ onConfirm: () => void onCancel: () => void }> = ({ action, milestone, onConfirm, onCancel }) => ( ->>>>>>> main
{action} {" "} -<<<<<<< HEAD - this submission? This action cannot be undone. -======= this submission? ->>>>>>> main

- ))} - -

- {t(`admin.sectionDescriptions.${activeSection}`)} -// CourseManagement — unchanged -// --------------------------------------------------------------------------- -const parseCsvText = (csvText: string): CourseImportRow[] => { - const rows = csvText - .trim() - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - - if (rows.length < 2) { - return [] - } - - const headers = rows[0].split(",").map((header) => header.trim()) - - return rows.slice(1).map((line) => { - const values = line.split(",").map((value) => value.trim()) - const record: Record = {} - headers.forEach((header, index) => { - record[header] = values[index] ?? "" - }) - return { - title: record.title || record.Title || "", - slug: record.slug || record.Slug || "", - track: record.track || record.Track || "", - difficulty: record.difficulty || record.Difficulty || "", - description: record.description || record.Description || "", - coverImage: record.coverImage || record.CoverImage || null, - published: - (record.published || record.Published || "").toLowerCase() === "true", - } - }) -} - -const isCourseRowValid = (row: CourseImportRow) => { - return ( - row.title.trim().length > 0 && - row.slug.trim().length > 0 && - row.track.trim().length > 0 && - row.difficulty.trim().length > 0 -======= {section} ))} @@ -495,208 +313,10 @@ const isCourseRowValid = (row: CourseImportRow) => { {activeSection === "contracts" && }

->>>>>>> main ) } const CourseManagement: React.FC = () => { -<<<<<<< HEAD - const { t } = useTranslation() - const [courses, setCourses] = useState(initialCourses) - const [fileName, setFileName] = useState("") - const [previewRows, setPreviewRows] = useState([]) - const [previewErrors, setPreviewErrors] = useState([]) - const [importResults, setImportResults] = useState([]) - const [isSubmitting, setIsSubmitting] = useState(false) - const [alertMessage, setAlertMessage] = useState(null) - - const handleFileUpload = async (event: React.ChangeEvent) => { - setImportResults([]) - setAlertMessage(null) - const file = event.target.files?.[0] - if (!file) { - return - } - - setFileName(file.name) - const contents = await file.text() - let rows: CourseImportRow[] = [] - if (file.name.toLowerCase().endsWith(".json")) { - try { - const parsed = JSON.parse(contents) - rows = Array.isArray(parsed) ? parsed : parsed.courses ?? [] - } catch { - setPreviewErrors([t("admin.import.invalidJson")]) - return - } - } else { - rows = parseCsvText(contents) - } - - const errors: string[] = [] - const normalizedRows = rows.map((row, index) => { - const normalized = { - ...row, - title: row.title?.trim() ?? "", - slug: row.slug?.trim() ?? "", - track: row.track?.trim() ?? "", - difficulty: row.difficulty?.trim() ?? "", - description: row.description?.trim(), - coverImage: row.coverImage?.trim() || null, - published: Boolean(row.published), - } - - if (!isCourseRowValid(normalized)) { - errors.push(`${t("admin.import.invalidRow")} ${index + 1}`) - } - - return normalized - }) - - setPreviewRows(normalizedRows) - setPreviewErrors(errors) - } - - const handleImport = async () => { - setIsSubmitting(true) - setImportResults([]) - setAlertMessage(null) - const token = localStorage.getItem("admin_token") ?? "" - - try { - const response = await fetch("/api/admin/courses/bulk-import", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ courses: previewRows }), - }) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - throw new Error(errorData.error || t("admin.import.importFailed")) - } - - const data = (await response.json()) as { - results: BulkImportResult[] - total: number - imported: number - } - setImportResults(data.results) - setAlertMessage(t("admin.import.importSuccess", { count: data.imported })) - } catch (error) { - setAlertMessage(String(error)) - } finally { - setIsSubmitting(false) - } - } - - return ( -
-
-
-

- {t("admin.import.title")} -

-

- {t("admin.import.description")} -

-
-
- -
- -
- - {fileName || t("admin.import.noFileSelected")} -
- {previewErrors.length > 0 && ( -
- {previewErrors.map((error) => ( -

{error}

- ))} -
- )} - {previewRows.length > 0 && ( -
-
-

- {t("admin.import.previewHeader", { count: previewRows.length })} -

-
- - - - - - - - - - - - {previewRows.map((row, index) => ( - - - - - - - - ))} - -
{t("admin.import.table.title")}{t("admin.import.table.slug")}{t("admin.import.table.track")}{t("admin.import.table.difficulty")}{t("admin.import.table.published")}
{row.title}{row.slug}{row.track}{row.difficulty}{row.published ? t("admin.import.yes") : t("admin.import.no")}
-
-
-
- - {t("admin.import.confirmPreview")} -
-
- )} - {alertMessage && ( -
- {alertMessage} -
- )} - {importResults.length > 0 && ( -
-

- {t("admin.import.resultsHeader")} -

-
    - {importResults.map((result) => ( -
  • - {t("admin.import.rowLabel", { row: result.row })}: {result.success ? t("admin.import.rowSuccess") : t("admin.import.rowFailure")} - {result.errors.length > 0 && ( -
      - {result.errors.map((error) => ( -
    • {error}
    • - ))} -
    - )} -
  • - ))} -
-
- )} -======= const { data: courses = [], isLoading, @@ -786,18 +406,14 @@ const CourseManagement: React.FC = () => {
))} ->>>>>>> main
) } const MilestoneQueue: React.FC = () => { -<<<<<<< HEAD -======= const { data: courseOptionsData = [], error: courseOptionsError } = useAdminCoursesList() ->>>>>>> main const { milestones, total, @@ -808,239 +424,6 @@ const MilestoneQueue: React.FC = () => { fetchMilestones, approveMilestone, rejectMilestone, -<<<<<<< HEAD - } = useAdminMilestones() - - const [courseFilter, setCourseFilter] = useState("All") - const [statusFilter, setStatusFilter] = useState("pending") - const [dialog, setDialog] = useState<{ - action: "approve" | "reject" - milestone: MilestoneSubmission - } | null>(null) - - useEffect(() => { - void fetchMilestones(1, { - course: courseFilter !== "All" ? courseFilter : undefined, - status: statusFilter, - }) - }, [courseFilter, statusFilter, fetchMilestones]) - - const handlePageChange = (newPage: number) => { - void fetchMilestones(newPage, { - course: courseFilter !== "All" ? courseFilter : undefined, - status: statusFilter, - }) - } - - const handleConfirm = async () => { - if (!dialog) return - const { action, milestone } = dialog - setDialog(null) - if (action === "approve") await approveMilestone(milestone.id) - else await rejectMilestone(milestone.id) - } - - const totalPages = Math.ceil(total / pageSize) - - return ( -
- {/* Stats bar */} - - - {/* Filters */} -
-
- - -
-
- - -
-
- - {/* Error */} - {error && ( -

- Error loading milestones: {error} -

- )} - - {/* Table */} -
- - - - - - - - - - - - - {loading && ( - - - - )} - - {!loading && milestones.length === 0 && ( - - - - )} - - {!loading && - milestones.map((m) => { - const statusStyles: Record< - MilestoneSubmission["status"], - string - > = { - pending: - "text-yellow-400 bg-yellow-400/10 border-yellow-400/30", - approved: - "text-emerald-400 bg-emerald-400/10 border-emerald-400/30", - rejected: "text-red-400 bg-red-400/10 border-red-400/30", - } - return ( - - - - - - - - - ) - })} - -
LearnerCourseSubmittedEvidenceStatusActions
- Loading milestones… -
-

- No milestone submissions found. -

-

- Try adjusting your filters or check back later. -

-
- - {m.learnerAddress.slice(0, 8)}… - {m.learnerAddress.slice(-4)} - - - {m.course} - - {new Date(m.submittedAt).toLocaleDateString("en-GB", { - day: "2-digit", - month: "short", - year: "numeric", - })} - - - - - {m.status} - - - {m.status === "pending" && ( -
- - -
- )} -
-
- - {/* Pagination */} - {totalPages > 1 && ( -
- - Page {page} of {totalPages} ({total} total) - -
- - -
-
- )} - - {/* Confirmation dialog */} -======= batchApproveMilestones, batchRejectMilestones, } = useAdminMilestones() @@ -1357,10 +740,7 @@ const MilestoneQueue: React.FC = () => { type="button" disabled={page <= 1} onClick={() => handlePageChange(page - 1)} - aria-label="Previous page" - - className="px-3 py-1 rounded-xl border border-white/10 hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors" > ← Prev @@ -1369,9 +749,7 @@ const MilestoneQueue: React.FC = () => { type="button" disabled={page >= totalPages} onClick={() => handlePageChange(page + 1)} - aria-label="Next page" - className="px-3 py-1 rounded-xl border border-white/10 hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors" > Next → @@ -1380,16 +758,11 @@ const MilestoneQueue: React.FC = () => { )} ->>>>>>> main {dialog && ( void handleConfirm()} ->>>>>>> main onCancel={() => setDialog(null)} /> )} @@ -1399,29 +772,6 @@ const MilestoneQueue: React.FC = () => { const UserLookup: React.FC = () => { const [search, setSearch] = useState("") -<<<<<<< HEAD - const [userData, setUserData] = useState(null) - return ( -
- setSearch(event.target.value)} - /> - - {userData ?

{userData.address}

: null} -======= const [submittedAddress, setSubmittedAddress] = useState(null) const [validationError, setValidationError] = useState(null) const { @@ -1555,23 +905,11 @@ const UserLookup: React.FC = () => { )} ->>>>>>> main
) } const TreasuryControls: React.FC = () => { -<<<<<<< HEAD - const [isPaused, setIsPaused] = useState(false) - return ( -
- -
- ) -} -======= const { address } = useWallet() const { data, isLoading, error, refetch } = useAdminContracts() const { @@ -1786,22 +1124,8 @@ const ContractStateCard: React.FC<{ ) } ->>>>>>> main const ContractInfo: React.FC = () => { -<<<<<<< HEAD - return ( -
- {contractRecords.map((contract) => ( -
- {contract.name} {contract.updated} -
- ))} -
- ) -} - -======= const { data, isLoading, error } = useAdminContracts() const errorMessage = error instanceof Error ? error.message : null @@ -2099,5 +1423,4 @@ const WikiManagement: React.FC = () => { ) } ->>>>>>> main export default Admin diff --git a/src/pages/Courses.tsx b/src/pages/Courses.tsx index 0cfe2a5b..c5196a9d 100644 --- a/src/pages/Courses.tsx +++ b/src/pages/Courses.tsx @@ -4,15 +4,12 @@ import { Link, useSearchParams } from "react-router-dom" import BookmarkButton from "../components/BookmarkButton" import { CourseFilter } from "../components/CourseFilter" import Pagination from "../components/Pagination" +import SponsorLogosForTrack from "../components/SponsorLogosForTrack" import { CourseCardSkeleton } from "../components/skeletons/CourseCardSkeleton" -<<<<<<< HEAD -import { courses } from "../data/courses" -======= import { EmptyState } from "../components/states/emptyState" import { ErrorState } from "../components/states/errorState" import { useCourses } from "../hooks/useCourses" import { type CourseSummary } from "../types/courses" ->>>>>>> main const levelStyles: Record = { Beginner: "bg-brand-emerald/20 text-brand-emerald border-brand-emerald/20", @@ -37,10 +34,10 @@ const Courses: React.FC = () => { const difficulty = searchParams.get("difficulty") ?? "" const track = searchParams.get("track") ?? "" const parsedPage = parseInt(searchParams.get("page") || "1", 10) - const currentPage = isNaN(parsedPage) || parsedPage < 1 ? 1 : parsedPage + const currentPage = Number.isNaN(parsedPage) || parsedPage < 1 ? 1 : parsedPage useEffect(() => { - const t = setTimeout(() => { + const timer = setTimeout(() => { setSearchParams( (prev) => { const next = new URLSearchParams(prev) @@ -52,7 +49,8 @@ const Courses: React.FC = () => { { replace: true }, ) }, 300) - return () => clearTimeout(t) + + return () => clearTimeout(timer) }, [searchInput, setSearchParams]) const handleDifficultyChange = useCallback( @@ -96,7 +94,7 @@ const Courses: React.FC = () => { setSearchParams( (prev) => { const next = new URLSearchParams(prev) - next.set("page", newPage.toString()) + next.set("page", String(newPage)) return next }, { replace: false }, @@ -104,7 +102,7 @@ const Courses: React.FC = () => { window.scrollTo({ top: 0, behavior: "smooth" }) } - const hasActiveFilters = !!searchInput || !!difficulty || !!track + const hasActiveFilters = Boolean(searchInput || difficulty || track) const filtered = useMemo(() => { const q = searchInput.toLowerCase() @@ -121,7 +119,7 @@ const Courses: React.FC = () => { const trackOptions = useMemo(() => { const seen = new Set() - const dynamicOptions = courses + const options = courses .filter((course) => { if (seen.has(course.trackKey)) return false seen.add(course.trackKey) @@ -132,27 +130,24 @@ const Courses: React.FC = () => { value: trackSlug(course.track), })) - return [{ label: "All Tracks", value: "" }, ...dynamicOptions] + return [{ label: "All Tracks", value: "" }, ...options] }, [courses]) const totalPages = Math.max(1, Math.ceil(filtered.length / ITEMS_PER_PAGE)) const safePage = Math.min(currentPage, totalPages) const startIndex = (safePage - 1) * ITEMS_PER_PAGE - const paginatedCourses = filtered.slice( - startIndex, - startIndex + ITEMS_PER_PAGE, - ) + const paginatedCourses = filtered.slice(startIndex, startIndex + ITEMS_PER_PAGE) return (
-

+

Learning Tracks

-

+

Choose a path and start with a focused first lesson.

-

+

Every LearnVault track is designed to move new learners from setup to hands-on progress with a clear first milestone.

@@ -171,9 +166,9 @@ const Courses: React.FC = () => { /> {isLoading ? ( -
- {[1, 2, 3, 4].map((i) => ( - +
+ {[1, 2, 3, 4].map((index) => ( + ))}
) : error ? ( @@ -186,62 +181,62 @@ const Courses: React.FC = () => { /> ) : filtered.length === 0 ? (
-

🔍

-

+

Search

+

No courses match your filters

-

- Try a different search term or adjust the difficulty and track - filters. +

+ Try a different search term or adjust the difficulty and track filters.

) : ( <> -
- {paginatedCourses.map((course) => ( +
+ {paginatedCourses.map((course, index) => (
- {/* Bookmark toggle — hidden when wallet not connected */} -
+
-
-
- +
+
+ {course.track} {course.level}
-

+

{course.title}

-

+

{course.description}

-
+ + +
{course.track} Open course diff --git a/src/pages/DaoProposals.tsx b/src/pages/DaoProposals.tsx index 11559dd7..7480b723 100644 --- a/src/pages/DaoProposals.tsx +++ b/src/pages/DaoProposals.tsx @@ -1,11 +1,12 @@ import React, { useEffect, useMemo, useState } from "react" import { Helmet } from "react-helmet" import { useSearchParams } from "react-router-dom" -import ConfirmDialog from "../components/ConfirmDialog" import CommentSection from "../components/CommentSection" +import ConfirmDialog from "../components/ConfirmDialog" import Pagination from "../components/Pagination" import { NoProposalsEmptyState } from "../components/SkeletonLoader" import { ErrorState } from "../components/states/errorState" +import { useToast } from "../components/Toast/ToastProvider" import { type ProposalRecord, useProposal, @@ -16,7 +17,6 @@ import { getDraftTimestamp, clearProposalDraft, } from "../util/proposalDraft" -import { useToast } from "../components/Toast/ToastProvider" type FilterType = | "Voting Open" @@ -234,20 +234,10 @@ const DaoProposals: React.FC = () => { totalVotes > 0n ? Number((selectedProposal!.votesAgainst * 100n) / totalVotes) : 0 -<<<<<<< HEAD - - const userHasVoted = selectedProposal ? hasVoted(selectedProposal.id) : false - const voteChoice = selectedProposal - ? getVoteChoice(selectedProposal.id) - : null - const governanceTokens = votingPower - const isTokenHolder = governanceTokens > 0n -======= const userHasVoted = selectedProposal?.userVoteSupport === true || selectedProposal?.userVoteSupport === false const voteChoice = selectedProposal?.userVoteSupport ?? null ->>>>>>> main const isWalletConnected = Boolean(walletAddress) const isTokenHolder = votingPower > 0n const voteDisabled = @@ -504,16 +494,7 @@ const DaoProposals: React.FC = () => { {userHasVoted ? (
-<<<<<<< HEAD - You voted{" "} - {voteChoice === null - ? "For/Against" - : voteChoice - ? "For" - : "Against"} -======= You voted {voteChoice ? "Yes" : "No"} ->>>>>>> main
) : (
diff --git a/src/pages/DaoPropose.tsx b/src/pages/DaoPropose.tsx index af5e4612..08521ab8 100644 --- a/src/pages/DaoPropose.tsx +++ b/src/pages/DaoPropose.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo, useState, useCallback, useRef } from "react" import ReactMarkdown from "react-markdown" import { useNavigate } from "react-router-dom" +import ConfirmDialog from "../components/ConfirmDialog" import { useToast } from "../components/Toast/ToastProvider" import { WalletButton } from "../components/WalletButton" import { useProposals } from "../hooks/useProposals" @@ -14,7 +15,7 @@ import { ProposalDraft, } from "../util/proposalDraft" -type ProposalType = "scholarship" | "parameter_change" | "new_course" +export type ProposalType = "scholarship" | "parameter_change" | "new_course" interface FormData { title: string @@ -617,63 +618,64 @@ const DaoPropose: React.FC = () => { )}
-
-

- Create Proposal -

- {hasDraft && ( - - - Draft - {draftTimestamp && ( - - ({formatDraftTime(draftTimestamp)}) - - )} - - )} -
-

- Submit a governance proposal to the backend API for community review - and voting. -

- {hasDraft && !showRestorePrompt && ( - - )} - - {showRestorePrompt && ( -
-

- You have an unsaved draft from{" "} - - {draftTimestamp && formatDraftTime(draftTimestamp)} +

+

+ Create Proposal +

+ {hasDraft && ( + + + Draft + {draftTimestamp && ( + + ({formatDraftTime(draftTimestamp)}) + + )} - . Would you like to restore it? -

-
- - -
+ )}
- )} +

+ Submit a governance proposal to the backend API for community review + and voting. +

+ {hasDraft && !showRestorePrompt && ( + + )} + + {showRestorePrompt && ( +
+

+ You have an unsaved draft from{" "} + + {draftTimestamp && formatDraftTime(draftTimestamp)} + + . Would you like to restore it? +

+
+ + +
+
+ )} +
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index dd8b5f19..8eaf8b9c 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -87,52 +87,12 @@ const Home: React.FC = () => {
-<<<<<<< HEAD - {showOnboarding ? ( - }> - - - ) : ( -
-
-
-

- New Learner Flow -

-

- Launch the guided wallet-and-enrollment setup only when you - need it. -

-

- The onboarding assistant is still available, but it now loads - on demand so the dashboard shell reaches first paint faster on - mobile data. -

-
- -
-
- )} - -
-
-
- LV -
-=======
{/* ── HERO ─────────────────────────────────────────────────────── */}
{/* Logo mark */}
LV ->>>>>>> main
{/* Headline */} @@ -182,22 +142,6 @@ const Home: React.FC = () => {
-<<<<<<< HEAD -
-
-
-
-
-

- - {t("home.courseProgress.title")} -

-

- {t("home.courseProgress.desc")} -

-
-
-======= {/* ── HOW IT WORKS ─────────────────────────────────────────────── */}
@@ -252,7 +196,6 @@ const Home: React.FC = () => { style={{ width: `${course.progressPercent}%` }} />
->>>>>>> main } > @@ -290,16 +233,6 @@ const Home: React.FC = () => {

{description}

-<<<<<<< HEAD - } - > - }> - - - -======= ->>>>>>> main
))}
diff --git a/src/pages/ImpactDashboard.tsx b/src/pages/ImpactDashboard.tsx new file mode 100644 index 00000000..2965841e --- /dev/null +++ b/src/pages/ImpactDashboard.tsx @@ -0,0 +1,271 @@ +import { type ReactNode, useEffect, useMemo, useState } from "react" +import { useImpactMetrics } from "../hooks/useImpactMetrics" +import { useUpsertScholarRegion } from "../hooks/useSponsors" +import { useWallet } from "../hooks/useWallet" + +function formatLargeNumber(value: number): string { + if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(1)}B` + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M` + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K` + return Math.round(value).toString() +} + +function parseNumericString(value: string): number { + const parsed = Number(value) + if (!Number.isFinite(parsed)) return 0 + return parsed +} + +function AnimatedCounter({ + value, + suffix = "", + durationMs = 900, +}: { + value: number + suffix?: string + durationMs?: number +}) { + const [displayValue, setDisplayValue] = useState(0) + + useEffect(() => { + let frame = 0 + const startedAt = performance.now() + const startValue = displayValue + + const tick = (now: number) => { + const elapsed = now - startedAt + const ratio = Math.min(1, elapsed / durationMs) + const eased = 1 - (1 - ratio) * (1 - ratio) + const next = startValue + (value - startValue) * eased + setDisplayValue(next) + if (ratio < 1) frame = requestAnimationFrame(tick) + } + + frame = requestAnimationFrame(tick) + return () => cancelAnimationFrame(frame) + }, [value]) + + return ( + + {formatLargeNumber(displayValue)} + {suffix} + + ) +} + +export default function ImpactDashboard() { + const { data, isLoading, error } = useImpactMetrics() + const { address } = useWallet() + const saveRegion = useUpsertScholarRegion() + const [regionInput, setRegionInput] = useState("") + const [embedCopied, setEmbedCopied] = useState(false) + + const maxCourseCount = useMemo(() => { + const counts = data?.top_completed_courses.map((course) => course.completed_count) ?? [] + return counts.length > 0 ? Math.max(...counts, 1) : 1 + }, [data]) + + const maxQuarterlyScholars = useMemo(() => { + const values = data?.trends.quarterly.map((entry) => entry.scholars_funded) ?? [] + return values.length > 0 ? Math.max(...values, 1) : 1 + }, [data]) + + const embedSnippet = + typeof window !== "undefined" + ? `` + : `` + + const handleCopyEmbed = async () => { + if (!navigator.clipboard) return + await navigator.clipboard.writeText(embedSnippet) + setEmbedCopied(true) + window.setTimeout(() => setEmbedCopied(false), 1600) + } + + const handleSaveRegion = async () => { + if (!address || !regionInput.trim()) return + await saveRegion.mutateAsync({ + learner_address: address, + country_region: regionInput.trim(), + }) + setRegionInput("") + } + + if (isLoading) { + return ( +
+

Loading impact dashboard...

+
+ ) + } + + if (error || !data) { + return ( +
+

Unable to load impact metrics.

+
+ ) + } + + const totalUsdc = parseNumericString(data.totals.total_usdc_disbursed) + const totalLrn = parseNumericString(data.totals.total_lrn_minted) + const completionRate = data.totals.average_course_completion_rate * 100 + + return ( +
+
+

+ Public Metrics +

+

+ Scholarship Impact Dashboard +

+

+ Transparent funding, learner outcomes, and regional reach. All figures are public and updated continuously. +

+
+ +
+ + + + + + + + + + + + +
+ +
+
+

Top 5 Completed Courses

+
+ {data.top_completed_courses.map((course) => { + const width = Math.max( + 8, + Math.round((course.completed_count / maxCourseCount) * 100), + ) + return ( +
+
+ {course.course_title} + {course.completed_count} +
+
+
+
+
+ ) + })} +
+
+ +
+

Countries / Regions Represented

+

+ {data.countries_regions.length} regions reported by scholars +

+
+ {data.countries_regions.map((region) => ( +
+ {region.country_region} + {region.scholar_count} +
+ ))} + {data.countries_regions.length === 0 && ( +

No self-reported regions yet.

+ )} +
+ {address && ( +
+

+ Self-report your region +

+
+ setRegionInput(event.target.value)} + placeholder="Country or region" + className="flex-1 rounded-xl border border-white/15 bg-black/20 px-3 py-2" + /> + +
+
+ )} +
+
+ +
+

Quarterly Trend

+
+ {data.trends.quarterly.map((entry) => { + const scholarsHeight = Math.max( + 12, + Math.round((entry.scholars_funded / maxQuarterlyScholars) * 120), + ) + return ( +
+
+
+
+

{entry.quarter}

+

{entry.scholars_funded}

+
+ ) + })} +
+

+ Blue bars represent funded scholars each quarter. +

+
+ +
+

Embeddable Widget

+

+ Embed this metrics widget on partner websites. +

+
+ + {embedSnippet} + + +
+
+
+ ) +} + +function MetricCard({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

{title}

+

{children}

+
+ ) +} diff --git a/src/pages/ImpactWidget.tsx b/src/pages/ImpactWidget.tsx new file mode 100644 index 00000000..bbe9f774 --- /dev/null +++ b/src/pages/ImpactWidget.tsx @@ -0,0 +1,50 @@ +import { useImpactWidgetData } from "../hooks/useImpactMetrics" + +function formatValue(value: string | number): string { + const parsed = Number(value) + if (!Number.isFinite(parsed)) return "0" + return parsed.toLocaleString(undefined, { maximumFractionDigits: 2 }) +} + +export default function ImpactWidget() { + const { data, isLoading, error } = useImpactWidgetData() + + if (isLoading) { + return ( +
+

Loading widget...

+
+ ) + } + + if (error || !data) { + return ( +
+

Widget unavailable

+
+ ) + } + + return ( +
+

+ LearnVault Impact +

+
+ + + +
+

Updated {new Date(data.generated_at).toLocaleDateString()}

+
+ ) +} + +function MiniMetric({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ) +} diff --git a/src/pages/Leaderboard.tsx b/src/pages/Leaderboard.tsx index ee66f0a6..19594c30 100644 --- a/src/pages/Leaderboard.tsx +++ b/src/pages/Leaderboard.tsx @@ -1,5 +1,5 @@ -import { Trophy } from "lucide-react" -import React, { useMemo } from "react" +import { ChevronLeft, ChevronRight, Trophy } from "lucide-react" +import React, { useMemo, useState } from "react" import { useTranslation } from "react-i18next" import AddressDisplay from "../components/AddressDisplay" import { EmptyState } from "../components/states/emptyState" @@ -8,54 +8,19 @@ import { useLeaderboard } from "../hooks/useLeaderboard" import { useWallet } from "../hooks/useWallet" import { type LeaderboardEntry } from "../util/mockLeaderboardData" +const PAGE_SIZE = 10 + const Leaderboard: React.FC = () => { const { t } = useTranslation() const { address: currentUserAddress } = useWallet() + const [page, setPage] = useState(1) -<<<<<<< HEAD - useEffect(() => { - const fetchLeaderboard = async () => { - try { - const response = await fetch( - "http://localhost:4000/api/scholars/leaderboard?page=1&limit=25", - ) - if (!response.ok) throw new Error("Failed to fetch leaderboard") - const result = (await response.json()) as { - rankings?: LeaderboardApiEntry[] - your_rank?: number | null - } - const rankings = Array.isArray(result.rankings) ? result.rankings : [] - const mapped = rankings.map((item, index) => ({ - id: `leader-${item.address}-${item.rank}-${index}`, - address: item.address, - lrnBalance: Number(item.lrn_balance ?? 0), - coursesCompleted: item.courses_completed ?? 0, - joinedDate: new Date(), - lastActive: new Date(), - rank: item.rank, - balance: item.lrn_balance ?? "0", - completedCourses: item.courses_completed ?? 0, - fullAddress: item.address, - })) - setLeaders(mapped) - setMyRank( - typeof result.your_rank === "number" ? result.your_rank : null, - ) - } catch (err) { - console.error(err) - setError("Unable to load rankings. Please try again later.") - } finally { - setIsLoading(false) - } - } -======= const { data: result, isLoading, error, refetch, - } = useLeaderboard(currentUserAddress) ->>>>>>> main + } = useLeaderboard(currentUserAddress, page, PAGE_SIZE) const leaders = useMemo(() => { const rankings = Array.isArray(result?.rankings) ? result.rankings : [] @@ -74,6 +39,8 @@ const Leaderboard: React.FC = () => { }, [result?.rankings]) const myRank = result?.your_rank ?? null + const total = Number(result?.total ?? leaders.length ?? 0) + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) const leaderboardRows = useMemo( () => @@ -95,8 +62,15 @@ const Leaderboard: React.FC = () => { [leaders], ) - const isCurrentUser = (fullAddress: string) => { - return currentUserAddress?.toLowerCase() === fullAddress.toLowerCase() + const isCurrentUser = (fullAddress: string) => + currentUserAddress?.toLowerCase() === fullAddress.toLowerCase() + + const prevPage = () => { + setPage((current) => Math.max(1, current - 1)) + } + + const nextPage = () => { + setPage((current) => Math.min(totalPages, current + 1)) } return ( @@ -153,12 +127,14 @@ const Leaderboard: React.FC = () => { {leaderboardRows.map((leader) => (
{
-
- +
+
+ +
{isCurrentUser(leader.fullAddress) && ( You @@ -211,13 +192,44 @@ const Leaderboard: React.FC = () => { -
+
- Showing {leaderboardRows.length} top learners - {myRank ? ` | Your rank: #${myRank}` : ""} + Showing {leaderboardRows.length} scholars + {currentUserAddress || myRank !== null ? ( + + {" "} + | Your rank: {myRank ? `#${myRank}` : "Unranked"} + + ) : null}
-
- Updated every block + +
+ + + Page {page} of {totalPages} + +
diff --git a/src/pages/LessonVersionDiff.tsx b/src/pages/LessonVersionDiff.tsx new file mode 100644 index 00000000..3b6df785 --- /dev/null +++ b/src/pages/LessonVersionDiff.tsx @@ -0,0 +1,169 @@ +import { useState } from "react" + +type DiffResponse = { + course_id: string + order_index: number + from: { + version: number + title: string + change_summary?: string | null + } + to: { + version: number + title: string + change_summary?: string | null + } + diff: { + added_lines: string[] + removed_lines: string[] + added_count: number + removed_count: number + } +} + +export default function LessonVersionDiff() { + const [courseIdOrSlug, setCourseIdOrSlug] = useState("") + const [orderIndex, setOrderIndex] = useState("1") + const [fromVersion, setFromVersion] = useState("1") + const [toVersion, setToVersion] = useState("2") + const [apiKey, setApiKey] = useState("") + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const [result, setResult] = useState(null) + + const handleFetchDiff = async () => { + if (!courseIdOrSlug.trim()) return + setIsLoading(true) + setError(null) + setResult(null) + + try { + const url = new URL( + `/api/courses/${encodeURIComponent(courseIdOrSlug.trim())}/lessons/${encodeURIComponent(orderIndex)}/diff`, + window.location.origin, + ) + url.searchParams.set("fromVersion", fromVersion) + url.searchParams.set("toVersion", toVersion) + + const response = await fetch(url.toString(), { + headers: { + ...(apiKey.trim() ? { "x-api-key": apiKey.trim() } : {}), + }, + }) + + if (!response.ok) { + const payload = await response.json().catch(() => ({})) + throw new Error((payload as { error?: string }).error || "Failed to fetch diff") + } + + const payload = (await response.json()) as DiffResponse + setResult(payload) + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to fetch diff") + } finally { + setIsLoading(false) + } + } + + return ( +
+
+

+ Admin Lesson Version Diff +

+

+ Compare two lesson versions by course and order index. +

+
+ +
+
+ setCourseIdOrSlug(event.target.value)} + placeholder="Course slug or id" + className="rounded-xl border border-white/15 bg-black/20 px-3 py-2" + /> + setOrderIndex(event.target.value)} + placeholder="Lesson order index" + type="number" + min="1" + className="rounded-xl border border-white/15 bg-black/20 px-3 py-2" + /> + setFromVersion(event.target.value)} + placeholder="From version" + type="number" + min="1" + className="rounded-xl border border-white/15 bg-black/20 px-3 py-2" + /> + setToVersion(event.target.value)} + placeholder="To version" + type="number" + min="1" + className="rounded-xl border border-white/15 bg-black/20 px-3 py-2" + /> + setApiKey(event.target.value)} + placeholder="Admin API key (optional)" + className="rounded-xl border border-white/15 bg-black/20 px-3 py-2" + /> +
+ + {error &&

{error}

} +
+ + {result && ( +
+
+
+

From v{result.from.version}

+

{result.from.title}

+ {result.from.change_summary && ( +

{result.from.change_summary}

+ )} +
+
+

To v{result.to.version}

+

{result.to.title}

+ {result.to.change_summary && ( +

{result.to.change_summary}

+ )} +
+
+ +
+
+

+ Added lines ({result.diff.added_count}) +

+
+								{result.diff.added_lines.join("\n") || "No added lines."}
+							
+
+
+

+ Removed lines ({result.diff.removed_count}) +

+
+								{result.diff.removed_lines.join("\n") || "No removed lines."}
+							
+
+
+
+ )} +
+ ) +} diff --git a/src/pages/LessonView.tsx b/src/pages/LessonView.tsx index d0422a82..f2ea5fe8 100644 --- a/src/pages/LessonView.tsx +++ b/src/pages/LessonView.tsx @@ -5,6 +5,7 @@ import { CourseForum } from "../components/forum/CourseForum" import LessonContent from "../components/LessonContent" import LessonSidebar from "../components/LessonSidebar" import MilestoneSubmitPanel from "../components/MilestoneSubmitPanel" +import SponsorLogosForTrack from "../components/SponsorLogosForTrack" import { LessonListSkeleton } from "../components/skeletons/LessonListSkeleton" import { useCourse } from "../hooks/useCourse" import { useCourseDetail } from "../hooks/useCourses" @@ -43,7 +44,7 @@ const LessonView: React.FC = () => { course, isLoading: isLoadingCourse, error: courseError, - } = useCourseDetail(courseId) + } = useCourseDetail(courseId, address) const [isLoadingContent, setIsLoadingContent] = useState(true) const [isSidebarOpen, setIsSidebarOpen] = useState(false) @@ -237,11 +238,19 @@ const LessonView: React.FC = () => { {course.title}
+ {course.hasUpdatedContent && ( +
+ Updated content is available. You are currently on version{" "} + {course.enrollmentContentVersion ?? 1}, while the + latest is {course.latestContentVersion ?? 1}. +
+ )}

{currentTab === "forum" ? "Community Forum" : lesson.title}

+
{/* Course progress bar */} diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index d8131b98..0436a745 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -64,7 +64,6 @@ const Profile: React.FC = () => { const { address: walletAddress } = useContext(WalletContext) const displayAddress = paramAddress || walletAddress - const isOwnProfile = !paramAddress || paramAddress === walletAddress const { data: profile, @@ -98,13 +97,16 @@ const Profile: React.FC = () => { }) if (!response.ok) { - const payload = await response.json().catch(() => ({})) + const payload = (await response.json().catch(() => ({}))) as { + message?: string + error?: string + } throw new Error( payload.message || payload.error || "Unable to load credentials", ) } - const data = await response.json() + const data = (await response.json()) as { data?: any[] } setNfts( Array.isArray(data.data) ? data.data.map((item: any) => ({ @@ -147,12 +149,17 @@ const Profile: React.FC = () => { try { const response = await fetch(`/api/profile/${viewAddress}`) if (!response.ok) { - const errorData = await response.json().catch(() => ({})) + const errorData = (await response.json().catch(() => ({}))) as { + error?: string + } throw new Error(errorData.error || "Failed to load profile") } - const data = await response.json() - setUserProfile(data.profile) - setStats(data.stats) + const data = (await response.json()) as { + profile?: UserProfile + stats?: ProfileStats + } + setUserProfile(data.profile ?? null) + setStats(data.stats ?? null) } catch (err) { console.error("[profile] Error loading profile data:", err) } @@ -162,8 +169,6 @@ const Profile: React.FC = () => { void fetchProfileData() }, [fetchProfileData]) - - useEffect(() => { void fetchCredentials() }, [fetchCredentials]) @@ -196,12 +201,14 @@ const Profile: React.FC = () => { }) if (!response.ok) { - const errorData = await response.json().catch(() => ({})) + const errorData = (await response.json().catch(() => ({}))) as { + error?: string + } throw new Error(errorData.error || "Failed to save profile") } - const data = await response.json() - setUserProfile(data.profile) + const data = (await response.json()) as { profile?: UserProfile } + setUserProfile(data.profile ?? null) setIsEditing(false) } catch (err) { console.error("[profile] Error saving profile:", err) @@ -223,7 +230,10 @@ const Profile: React.FC = () => { Object.values(userProfile.socialLinks).some(Boolean) const siteUrl = "https://learnvault.app" - const lrnBalance = stats?.lrnBalance?.toLocaleString() || profile?.lrn_balance?.toLocaleString() || "0" + const lrnBalance = + stats?.lrnBalance?.toLocaleString() || + profile?.lrn_balance?.toLocaleString() || + "0" const coursesCompleted = stats?.coursesCompleted ?? nfts.length const title = `${displayName} — ${lrnBalance} LRN · ${coursesCompleted} Course${ coursesCompleted !== 1 ? "s" : "" @@ -336,25 +346,26 @@ const Profile: React.FC = () => {
{displayAddress ? ( - - ) : ( -
- {t("wallet.connect")} + + ) : ( +
+ {t("wallet.connect")} +
+ )} +
+ Learning Time: {learningTimeLabel}
- )} -
- Learning Time: {learningTimeLabel} + {stats?.reputationRank && ( +
+ Rank #{stats.reputationRank} +
+ )} + {stats?.percentile && ( +
+ Top {stats.percentile}% +
+ )}
- {stats?.reputationRank && ( -
- Rank #{stats.reputationRank} -
- )} - {stats?.percentile && ( -
- Top {stats.percentile}% -
- )}
{/* Social Links */} diff --git a/src/pages/SponsorPortal.tsx b/src/pages/SponsorPortal.tsx new file mode 100644 index 00000000..96c06784 --- /dev/null +++ b/src/pages/SponsorPortal.tsx @@ -0,0 +1,313 @@ +import { useEffect, useMemo, useState } from "react" +import { useCourses } from "../hooks/useCourses" +import { + useCreateTrackSponsorship, + useSponsorDashboard, + useSponsorOrganizationProfile, + useSponsorQuarterlyReports, + useUpsertSponsorOrganizationProfile, +} from "../hooks/useSponsors" +import { useWallet } from "../hooks/useWallet" + +function formatUsdc(value: string): string { + const parsed = Number(value) + if (!Number.isFinite(parsed)) return "0" + return parsed.toLocaleString(undefined, { maximumFractionDigits: 2 }) +} + +function formatPercent(value: number): string { + return `${Math.round(value * 100)}%` +} + +const currentYear = new Date().getFullYear() + +export default function SponsorPortal() { + const { address } = useWallet() + const { courses } = useCourses() + const { data: profile } = useSponsorOrganizationProfile(address) + const upsertProfile = useUpsertSponsorOrganizationProfile() + const sponsorTrack = useCreateTrackSponsorship() + const { data: dashboard } = useSponsorDashboard(address) + const [reportYear, setReportYear] = useState(currentYear) + const [reportQuarter, setReportQuarter] = useState(0) + const { data: quarterlyReports = [] } = useSponsorQuarterlyReports( + address, + reportYear, + reportQuarter || undefined, + ) + + const [orgName, setOrgName] = useState("") + const [logoUrl, setLogoUrl] = useState("") + const [website, setWebsite] = useState("") + const [mission, setMission] = useState("") + const [track, setTrack] = useState("") + const [donationUsdc, setDonationUsdc] = useState("1000") + const [txHash, setTxHash] = useState("") + + const trackOptions = useMemo(() => { + const seen = new Set() + return courses + .map((course) => course.track) + .filter((value) => { + const key = value.toLowerCase() + if (seen.has(key)) return false + seen.add(key) + return true + }) + .sort((a, b) => a.localeCompare(b)) + }, [courses]) + + useEffect(() => { + if (!profile) return + setOrgName(profile.name ?? "") + setLogoUrl(profile.logo_url ?? "") + setWebsite(profile.website ?? "") + setMission(profile.mission ?? "") + }, [profile]) + + const handleSaveProfile = async () => { + if (!address || !orgName.trim()) return + await upsertProfile.mutateAsync({ + walletAddress: address, + name: orgName, + logo_url: logoUrl, + website, + mission, + }) + } + + const handleSponsorTrack = async () => { + if (!address || !track) return + await sponsorTrack.mutateAsync({ + wallet_address: address, + track, + donation_usdc: Number(donationUsdc), + tx_hash: txHash, + }) + setTxHash("") + } + + const reportJson = useMemo( + () => JSON.stringify(quarterlyReports, null, 2), + [quarterlyReports], + ) + + if (!address) { + return ( +
+

+ Organization Sponsor Portal +

+

+ Connect your wallet to create an organization profile and sponsor scholarship tracks. +

+
+ ) + } + + return ( +
+
+

+ Organization Sponsor Portal +

+

+ Create your organization profile, sponsor targeted course tracks, monitor scholar progress, and generate quarterly impact reports. +

+
+ +
+
+

Organization Profile

+
+ setOrgName(event.target.value)} + placeholder="Organization name" + className="w-full rounded-2xl border border-white/15 bg-black/20 px-4 py-3" + /> + setLogoUrl(event.target.value)} + placeholder="Logo URL" + className="w-full rounded-2xl border border-white/15 bg-black/20 px-4 py-3" + /> + setWebsite(event.target.value)} + placeholder="Website" + className="w-full rounded-2xl border border-white/15 bg-black/20 px-4 py-3" + /> +