From 9330c1e0b36b4ee807fc911bd948d69bb53caf17 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Mon, 24 Mar 2025 12:26:52 +0100 Subject: [PATCH 01/11] feat(project): implement funding and milestone management features - Add funding and milestone functionalities to the BoundlessContract. - Introduce helper functions to check if funding and voting periods have ended. - Implement methods for approving, rejecting, and releasing milestones. - Enhance project struct with new fields for milestone approvals and releases. - Update error handling for various project states including funding and milestone operations. - Add tests for funding operations, milestone approvals, and refunds to ensure functionality and correctness. --- contracts/boundless_contract/src/contract.rs | 277 +++++++++++++++++- contracts/boundless_contract/src/error.rs | 12 + contracts/boundless_contract/src/storage.rs | 17 ++ .../src/tests/create_project_test.rs | 12 +- .../src/tests/init_upgrade_test.rs | 40 ++- .../src/tests/milestone_funding_test.rs | 203 +++++++++++++ contracts/boundless_contract/src/tests/mod.rs | 1 + .../src/tests/vote_project_test.rs | 1 + 8 files changed, 553 insertions(+), 10 deletions(-) create mode 100644 contracts/boundless_contract/src/tests/milestone_funding_test.rs diff --git a/contracts/boundless_contract/src/contract.rs b/contracts/boundless_contract/src/contract.rs index dc39b581..c1d1e752 100644 --- a/contracts/boundless_contract/src/contract.rs +++ b/contracts/boundless_contract/src/contract.rs @@ -1,9 +1,10 @@ -use soroban_sdk::{contract, contractimpl, symbol_short, Address, BytesN, Env, String, Vec}; +use soroban_sdk::{contract, contractimpl, symbol_short, Address, BytesN, Env, String, Vec, token}; use crate::{ error::ProjectError, storage::{ - get_admin, get_version, is_initialized, project_exists, read_project, set_admin, set_initialized, set_version, update_project, write_project, ContractDataKey, Project + get_admin, get_version, is_initialized, project_exists, read_project, set_admin, set_initialized, set_version, update_project, write_project, ContractDataKey, Project, + FUNDING_PERIOD_LEDGERS, VOTING_PERIOD_LEDGERS, }, }; @@ -52,6 +53,16 @@ impl BoundlessContract { .unwrap_or(1) } + // Helper function to check if funding period has ended + fn is_funding_period_ended(env: &Env, project: &Project) -> bool { + env.ledger().timestamp() > project.funding_deadline + } + + // Helper function to check if voting period has ended + fn is_voting_period_ended(env: &Env, project: &Project) -> bool { + env.ledger().timestamp() > project.voting_deadline + } + pub fn create_project( env: Env, project_id: String, @@ -67,6 +78,10 @@ impl BoundlessContract { return Err(ProjectError::AlreadyExists); } + let current_timestamp = env.ledger().timestamp(); + let funding_deadline = current_timestamp + FUNDING_PERIOD_LEDGERS as u64; + let voting_deadline = funding_deadline + VOTING_PERIOD_LEDGERS as u64; + let project = Project { project_id: project_id.clone(), creator: creator.clone(), @@ -80,7 +95,12 @@ impl BoundlessContract { validated: false, is_successful: false, is_closed: false, - created_at: env.ledger().timestamp(), + created_at: current_timestamp, + funding_deadline, + voting_deadline, + milestone_approvals: Vec::new(&env), + milestone_releases: Vec::new(&env), + refund_processed: false, }; write_project(&env, project)?; @@ -99,6 +119,7 @@ impl BoundlessContract { creator: Address, new_metadata_uri: String, ) -> Result<(), ProjectError> { + creator.require_auth(); let mut project = read_project(&env, &project_id)?; if project.creator != creator { @@ -126,6 +147,7 @@ impl BoundlessContract { creator: Address, new_milestone_count: u32, ) -> Result<(), ProjectError> { + creator.require_auth(); let mut project = read_project(&env, &project_id)?; if project.creator != creator { @@ -154,6 +176,7 @@ impl BoundlessContract { caller: Address, new_milestone_count: u32, ) -> Result<(), ProjectError> { + caller.require_auth(); let mut project = read_project(&env, &project_id)?; if project.creator != caller { @@ -181,6 +204,7 @@ impl BoundlessContract { project_id: String, creator: Address, ) -> Result<(), ProjectError> { + creator.require_auth(); let mut project = read_project(&env, &project_id)?; if project.creator != creator { @@ -208,6 +232,7 @@ impl BoundlessContract { voter: Address, vote_value: i32, ) -> Result<(), ProjectError> { + voter.require_auth(); let mut project = read_project(&env, &project_id)?; if vote_value != 1 && vote_value != -1 { @@ -230,6 +255,7 @@ impl BoundlessContract { } pub fn withdraw_vote(env: Env, project_id: String, voter: Address) -> Result<(), ProjectError> { + voter.require_auth(); let mut project = read_project(&env, &project_id)?; let index = project.votes.iter().position(|(addr, _)| addr == voter); @@ -265,4 +291,249 @@ impl BoundlessContract { Err(ProjectError::NotVoted) } + + pub fn release_milestone( + env: Env, + project_id: String, + milestone_number: u32, + admin: Address, + ) -> Result<(), ProjectError> { + // Verify admin authorization + admin.require_auth(); + if admin != get_admin(&env) { + return Err(ProjectError::Unauthorized); + } + + let mut project = read_project(&env, &project_id)?; + + // Validate milestone number + if milestone_number >= project.milestone_count { + return Err(ProjectError::InvalidMilestoneNumber); + } + + // Check if milestone is already completed + if project.milestone_releases.iter().any(|release| release.0 == milestone_number) { + return Err(ProjectError::MilestoneAlreadyCompleted); + } + + // Check if milestone is approved + if !project.milestone_approvals.iter().any(|approval| approval.0 == milestone_number && approval.1) { + return Err(ProjectError::MilestoneNotApproved); + } + + // Calculate milestone release amount + let milestone_amount = project.funding_target / project.milestone_count as u64; + + // Update project state + project.milestone_releases.push_back((milestone_number, milestone_amount)); + project.current_milestone = milestone_number + 1; + + // If all milestones are completed, mark project as successful + if project.current_milestone == project.milestone_count { + project.is_successful = true; + } + + update_project(&env, &project)?; + + // Emit milestone release event + env.events().publish( + (symbol_short!("milestone"), symbol_short!("released")), + (project_id, milestone_number, milestone_amount), + ); + + Ok(()) + } + + pub fn approve_milestone( + env: Env, + project_id: String, + milestone_number: u32, + admin: Address, + ) -> Result<(), ProjectError> { + // Verify admin authorization + admin.require_auth(); + if admin != get_admin(&env) { + return Err(ProjectError::Unauthorized); + } + + let mut project = read_project(&env, &project_id)?; + + // Validate milestone number + if milestone_number >= project.milestone_count { + return Err(ProjectError::InvalidMilestoneNumber); + } + + // Check if milestone is already approved + if project.milestone_approvals.iter().any(|approval| approval.0 == milestone_number) { + return Err(ProjectError::MilestoneAlreadyCompleted); + } + + // Add milestone approval + project.milestone_approvals.push_back((milestone_number, true)); + update_project(&env, &project)?; + + // Emit milestone approval event + env.events().publish( + (symbol_short!("milestone"), symbol_short!("approved")), + (project_id, milestone_number), + ); + + Ok(()) + } + + pub fn reject_milestone( + env: Env, + project_id: String, + milestone_number: u32, + admin: Address, + ) -> Result<(), ProjectError> { + // Verify admin authorization + admin.require_auth(); + if admin != get_admin(&env) { + return Err(ProjectError::Unauthorized); + } + + let mut project = read_project(&env, &project_id)?; + + // Validate milestone number + if milestone_number >= project.milestone_count { + return Err(ProjectError::InvalidMilestoneNumber); + } + + // Check if milestone is already approved or rejected + if project.milestone_approvals.iter().any(|approval| approval.0 == milestone_number) { + return Err(ProjectError::MilestoneAlreadyCompleted); + } + + // Add milestone rejection + project.milestone_approvals.push_back((milestone_number, false)); + update_project(&env, &project)?; + + // Emit milestone rejection event + env.events().publish( + (symbol_short!("milestone"), symbol_short!("rejected")), + (project_id, milestone_number), + ); + + Ok(()) + } + + pub fn fund_project( + env: Env, + project_id: String, + amount: i128, + funder: Address, + token_contract: Address, + ) -> Result<(), ProjectError> { + funder.require_auth(); + + if amount <= 0 { + return Err(ProjectError::InvalidAmount); + } + + let mut project = read_project(&env, &project_id)?; + + if project.is_closed { + return Err(ProjectError::ProjectClosed); + } + + if env.ledger().timestamp() > project.funding_deadline { + return Err(ProjectError::FundingDeadlinePassed); + } + + if project.total_funded >= project.funding_target { + return Err(ProjectError::FundingTargetReached); + } + + let remaining = project.funding_target - project.total_funded; + let contribution = if amount > remaining as i128 { + remaining as i128 + } else { + amount + }; + + // let token_client = token::StellarAssetClient::new(&env, &token_contract); + + // For funding, we need to clawback from the funder and mint to the contract + token::Client::new(&env, &token_contract).transfer(&funder, &env.current_contract_address(), &contribution); + // token_client.clawback(&funder, &contribution); + // token_client.mint(&env.current_contract_address(), &contribution); + + project.total_funded += contribution as u64; + project.backers.push_back((funder.clone(), contribution as u64)); + update_project(&env, &project)?; + + env.events().publish( + (symbol_short!("project"), symbol_short!("funded")), + (project_id, funder, contribution), + ); + + Ok(()) + } + + pub fn refund(env: Env, project_id: String, token_contract: Address) -> Result<(), ProjectError> { + let mut project = read_project(&env, &project_id)?; + + // Check if project has failed (funding target not met by deadline) + if project.total_funded >= project.funding_target || env.ledger().timestamp() < project.funding_deadline { + return Err(ProjectError::ProjectNotFailed); + } + + // Check if refund has already been processed + if project.refund_processed { + return Err(ProjectError::RefundAlreadyProcessed); + } + + // Check if there are funds to refund + if project.total_funded == 0 { + return Err(ProjectError::NoFundsToRefund); + } + + let token_client = token::Client::new(&env, &token_contract); + + // Process refunds for all backers + for (backer, amount) in project.backers.iter() { + // For refunds, we need to transfer from the contract back to the backer + token_client.transfer(&env.current_contract_address(), &backer, &(amount as i128)); + } + + // Mark refund as processed + project.refund_processed = true; + project.is_closed = true; + update_project(&env, &project)?; + + // Emit refund event + env.events().publish( + (symbol_short!("project"), symbol_short!("refunded")), + (project_id, project.total_funded), + ); + + Ok(()) + } + + pub fn get_project_funding(env: Env, project_id: String) -> Result<(u64, u64), ProjectError> { + let project = read_project(&env, &project_id)?; + Ok((project.total_funded, project.funding_target)) + } + + pub fn get_backer_contribution( + env: Env, + project_id: String, + backer: Address, + ) -> Result { + let project = read_project(&env, &project_id)?; + + for (addr, amount) in project.backers.iter() { + if addr == backer { + return Ok(amount); + } + } + + Ok(0) + } + + // pub fn get_project_backers(env: Env, project_id: String) -> Result, ProjectError> { + // let project = read_project(&env, &project_id)?; + // Ok(project.backers.to_vec()) + // } } diff --git a/contracts/boundless_contract/src/error.rs b/contracts/boundless_contract/src/error.rs index 38ab6fb5..b7f162ca 100644 --- a/contracts/boundless_contract/src/error.rs +++ b/contracts/boundless_contract/src/error.rs @@ -17,4 +17,16 @@ pub enum ProjectError { AlreadyInitialized = 100, NotInitialized = 101, UpgradeFailed = 400, + InvalidMilestoneNumber = 11, + MilestoneAlreadyCompleted = 12, + MilestoneNotApproved = 13, + NoFundsToRefund = 14, + ProjectNotFailed = 15, + RefundAlreadyProcessed = 16, + InvalidAmount = 17, + ProjectAlreadyFunded = 18, + FundingTargetReached = 19, + FundingDeadlinePassed = 20, + InsufficientFunds = 21, + InvalidFunder = 22, } diff --git a/contracts/boundless_contract/src/storage.rs b/contracts/boundless_contract/src/storage.rs index b59c0212..252e0664 100644 --- a/contracts/boundless_contract/src/storage.rs +++ b/contracts/boundless_contract/src/storage.rs @@ -9,6 +9,18 @@ pub(crate) const PROJECTS_BUMP_AMOUNT: u32 = 30 * DAY_IN_LEDGERS; #[allow(dead_code)] pub(crate) const PROJECTS_LIFETIME_THRESHOLD: u32 = PROJECTS_BUMP_AMOUNT - DAY_IN_LEDGERS; +// Fixed periods in days +#[allow(dead_code)] +pub(crate) const FUNDING_PERIOD_DAYS: u32 = 30; +#[allow(dead_code)] +pub(crate) const VOTING_PERIOD_DAYS: u32 = 30; + +// Convert days to ledgers +#[allow(dead_code)] +pub(crate) const FUNDING_PERIOD_LEDGERS: u32 = FUNDING_PERIOD_DAYS * DAY_IN_LEDGERS; +#[allow(dead_code)] +pub(crate) const VOTING_PERIOD_LEDGERS: u32 = VOTING_PERIOD_DAYS * DAY_IN_LEDGERS; + // Contract version and initialization storage #[contracttype] pub enum ContractDataKey { @@ -42,6 +54,11 @@ pub struct Project { pub is_successful: bool, pub is_closed: bool, pub created_at: u64, + pub milestone_approvals: Vec<(u32, bool)>, + pub milestone_releases: Vec<(u32, u64)>, + pub refund_processed: bool, + pub funding_deadline: u64, + pub voting_deadline: u64, } // Enum for Project Storage Keys diff --git a/contracts/boundless_contract/src/tests/create_project_test.rs b/contracts/boundless_contract/src/tests/create_project_test.rs index 358a56fc..f15ce857 100644 --- a/contracts/boundless_contract/src/tests/create_project_test.rs +++ b/contracts/boundless_contract/src/tests/create_project_test.rs @@ -23,6 +23,7 @@ fn test_create_project_success() { &metadata_uri, &funding_target, &milestone_count, + ); let project = client.get_project(&project_id); @@ -69,12 +70,13 @@ fn test_update_project_metadata_success() { let env = Env::default(); let contract_id = env.register(BoundlessContract, ()); let client = BoundlessContractClient::new(&env, &contract_id); - + let creator = Address::generate(&env); let project_id = String::from_str(&env, "test_project"); let metadata_uri = String::from_str(&env, "ipfs://example-metadata"); let new_metadata_uri = String::from_str(&env, "ipfs://new-metadata"); - + + env.mock_all_auths(); client.create_project(&project_id, &creator, &metadata_uri, &1000, &5); client.update_project_metadata(&project_id, &creator, &new_metadata_uri); @@ -94,6 +96,7 @@ fn test_update_project_metadata_wrong_creator_fails() { let other_user = Address::generate(&env); let project_id = String::from_str(&env, "test_project"); let metadata_uri = String::from_str(&env, "ipfs://example-metadata"); + env.mock_all_auths(); client.create_project(&project_id, &creator, &metadata_uri, &1000, &5); client.update_project_metadata( @@ -112,7 +115,7 @@ fn test_modify_milestone_success() { let creator = Address::generate(&env); let project_id = String::from_str(&env, "test_project"); let metadata_uri = String::from_str(&env, "ipfs://example-metadata"); - + env.mock_all_auths(); client.create_project(&project_id, &creator, &metadata_uri, &1000, &4); client.modify_milestone(&project_id, &creator, &10); @@ -133,7 +136,7 @@ fn test_modify_milestone_wrong_caller_fails() { let other_user = Address::generate(&env); let project_id = String::from_str(&env, "test_project"); let metadata_uri = String::from_str(&env, "ipfs://example-metadata"); - + env.mock_all_auths(); client.create_project(&project_id, &creator, &metadata_uri, &1000, &5); client.modify_milestone(&project_id, &other_user, &10); } @@ -149,6 +152,7 @@ fn test_close_project_wrong_caller_fails() { let other_user = Address::generate(&env); let project_id = String::from_str(&env, "test_project"); let metadata_uri = String::from_str(&env, "ipfs://example-metadata"); + env.mock_all_auths(); client.create_project(&project_id, &creator, &metadata_uri, &1000, &5); client.close_project(&project_id, &other_user); diff --git a/contracts/boundless_contract/src/tests/init_upgrade_test.rs b/contracts/boundless_contract/src/tests/init_upgrade_test.rs index 741ff652..567a06a2 100644 --- a/contracts/boundless_contract/src/tests/init_upgrade_test.rs +++ b/contracts/boundless_contract/src/tests/init_upgrade_test.rs @@ -3,8 +3,9 @@ use crate::{ contract::{BoundlessContract, BoundlessContractClient}, error::ProjectError, + storage::{FUNDING_PERIOD_LEDGERS, VOTING_PERIOD_LEDGERS}, }; -use soroban_sdk::{testutils::Address as _, Address, Env}; +use soroban_sdk::{testutils::Address as _, Address, Env, String}; #[test] fn test_initialization() { @@ -27,7 +28,6 @@ fn test_initialization() { #[test] #[should_panic(expected = "Error(Contract, #100)")] - fn test_cannot_reinitialize() { let env = Env::default(); env.mock_all_auths(); @@ -42,10 +42,44 @@ fn test_cannot_reinitialize() { // Attempt second initialization let result = client.initialize(&admin); - // Verify the error is AlreadyInitialized } +#[test] +fn test_project_deadlines() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(BoundlessContract, ()); + let client = BoundlessContractClient::new(&env, &contract_id); + + // Initialize contract + let admin = Address::generate(&env); + client.initialize(&admin); + + // Create a project + let creator = Address::generate(&env); + let project_id = String::from_str(&env, "test_project"); + let metadata_uri = String::from_str(&env, "ipfs://example-metadata"); + + client.create_project(&project_id, &creator, &metadata_uri, &1000, &5); + + // Get project details + let project = client.get_project(&project_id); + + // Verify funding deadline is set correctly (30 days from creation) + assert_eq!( + project.funding_deadline, + project.created_at + FUNDING_PERIOD_LEDGERS as u64 + ); + + // Verify voting deadline is set correctly (30 days after funding deadline) + assert_eq!( + project.voting_deadline, + project.funding_deadline + VOTING_PERIOD_LEDGERS as u64 + ); +} + // #[test] // fn test_unauthorized_upgrade() { // let env = Env::default(); diff --git a/contracts/boundless_contract/src/tests/milestone_funding_test.rs b/contracts/boundless_contract/src/tests/milestone_funding_test.rs new file mode 100644 index 00000000..06eebdfd --- /dev/null +++ b/contracts/boundless_contract/src/tests/milestone_funding_test.rs @@ -0,0 +1,203 @@ +#![cfg(test)] + +use crate::{ + contract::{BoundlessContract, BoundlessContractClient}, + error::ProjectError, +}; +use soroban_sdk::{testutils::{Address as _, Ledger}, token::{self, TokenClient}, Address, Env, String}; +use token::StellarAssetClient as TokenAdminClient; + +fn create_token_contract<'a>(e: &Env, admin: &Address) -> (TokenClient<'a>, TokenAdminClient<'a>) { + let sac = e.register_stellar_asset_contract_v2(admin.clone()); + ( + token::Client::new(e, &sac.address()), + token::StellarAssetClient::new(e, &sac.address()), + ) +} +// #[test] +// fn test_milestone_operations() { +// let env = Env::default(); +// env.mock_all_auths(); + +// // Initialize contract +// let contract_id = env.register_contract(None, BoundlessContract); +// let client = BoundlessContractClient::new(&env, &contract_id); + +// // Setup addresses +// let admin = Address::generate(&env); +// let creator = Address::generate(&env); +// let project_id = String::from_str(&env, "test_project"); +// let metadata_uri = String::from_str(&env, "ipfs://example-metadata"); + +// // Initialize contract +// client.initialize(&admin); + +// // Create project +// client.create_project(&project_id, &creator, &metadata_uri, &1000, &2); + +// // Test approve milestone +// client.approve_milestone(&project_id, &0, &admin); +// let project = client.get_project(&project_id); +// assert!(project.milestone_approvals.iter().any(|(num, approved)| *num == 0 && *approved)); + +// // Test reject milestone +// client.reject_milestone(&project_id, &1, &admin); +// let project = client.get_project(&project_id); +// assert!(project.milestone_approvals.iter().any(|(num, approved)| *num == 1 && !*approved)); + +// // Test release milestone +// client.release_milestone(&project_id, &0, &admin); +// let project = client.get_project(&project_id); +// assert!(project.milestone_releases.iter().any(|(num, _)| *num == 0)); +// assert_eq!(project.current_milestone, 1); +// } + +struct FundingTest<'a> { + env: Env, + admin: Address, + creator: Address, + funder: Address, + token_contract: Address, + token_client: TokenClient<'a>, + token_admin_client: TokenAdminClient<'a>, + contract_client: BoundlessContractClient<'a>, + project_id: String, + metadata_uri: String, +} + +impl<'a> FundingTest<'a> { + fn setup() -> Self { + let env = Env::default(); + env.mock_all_auths(); + + // Setup addresses + let admin = Address::generate(&env); + let creator = Address::generate(&env); + let funder = Address::generate(&env); + let project_id = String::from_str(&env, "test_project"); + let metadata_uri = String::from_str(&env, "ipfs://example-metadata"); + + // Initialize token contract and client + let (token_client, token_admin_client) = create_token_contract(&env, &admin); + let token_contract = token_client.address.clone(); + + // Mint tokens to funder + token_admin_client.mint(&funder, &3000); + + // Initialize contract + let contract_id = env.register(BoundlessContract, ()); + let contract_client = BoundlessContractClient::new(&env, &contract_id); + contract_client.initialize(&admin); + + // Create project + contract_client.create_project(&project_id, &creator, &metadata_uri, &1000, &2); + + // Set up token permissions for the contract + token_admin_client.set_authorized(&contract_id, &true); + + FundingTest { + env, + admin, + creator, + funder, + token_contract, + token_client, + token_admin_client, + contract_client, + project_id, + metadata_uri, + } + } +} + +#[test] +fn test_funding_operations() { + let test = FundingTest::setup(); + + // Test initial funding + test.contract_client.fund_project(&test.project_id, &500, &test.funder, &test.token_contract); + + // Test get project funding + let (total_funded, target) = test.contract_client.get_project_funding(&test.project_id); + assert_eq!(total_funded, 500); + assert_eq!(target, 1000); + + // Test get backer contribution + let contribution = test.contract_client.get_backer_contribution(&test.project_id, &test.funder); + assert_eq!(contribution, 500); + + // // Test funding target reached + test.contract_client.fund_project(&test.project_id, &600, &test.funder, &test.token_contract); + let (total_funded, _) = test.contract_client.get_project_funding(&test.project_id); + assert_eq!(total_funded, 1000); // Should be capped at funding target + +} + +#[test] +fn test_refund() { + let test = FundingTest::setup(); + + // Fund project with some amount + test.contract_client.fund_project(&test.project_id, &500, &test.funder, &test.token_contract); + + // Fast forward past funding deadline + let before = test.env.ledger().timestamp(); + let after = before + 31 * 17280; // 31 days in ledgers + test.env.ledger().set_timestamp(after); + + // Test refund + test.contract_client.refund(&test.project_id, &test.token_contract); + + // Verify project state + let project = test.contract_client.get_project(&test.project_id); + assert!(project.refund_processed); + assert!(project.is_closed); + + // Verify funder received their tokens back + let funder_balance = test.token_client.balance(&test.funder); + assert_eq!(funder_balance, 3000); // Should have original balance back +} + +#[test] +#[should_panic(expected = "Error(Contract, #5)")] +fn test_unauthorized_milestone_approval() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, BoundlessContract); + let client = BoundlessContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let creator = Address::generate(&env); + let non_admin = Address::generate(&env); + let project_id = String::from_str(&env, "test_project"); + let metadata_uri = String::from_str(&env, "ipfs://example-metadata"); + + client.initialize(&admin); + client.create_project(&project_id, &creator, &metadata_uri, &1000, &2); + + // Try to approve milestone with non-admin + client.approve_milestone(&project_id, &0, &non_admin); +} + +#[test] +#[should_panic(expected = "Error(Contract, #5)")] +fn test_unauthorized_milestone_release() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, BoundlessContract); + let client = BoundlessContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let creator = Address::generate(&env); + let non_admin = Address::generate(&env); + let project_id = String::from_str(&env, "test_project"); + let metadata_uri = String::from_str(&env, "ipfs://example-metadata"); + + client.initialize(&admin); + client.create_project(&project_id, &creator, &metadata_uri, &1000, &2); + + // Try to release milestone with non-admin + client.release_milestone(&project_id, &0, &non_admin); +} diff --git a/contracts/boundless_contract/src/tests/mod.rs b/contracts/boundless_contract/src/tests/mod.rs index e21a7c91..182b7f3c 100644 --- a/contracts/boundless_contract/src/tests/mod.rs +++ b/contracts/boundless_contract/src/tests/mod.rs @@ -3,3 +3,4 @@ mod create_project_test; #[cfg(test)] mod vote_project_test; mod init_upgrade_test; +mod milestone_funding_test; diff --git a/contracts/boundless_contract/src/tests/vote_project_test.rs b/contracts/boundless_contract/src/tests/vote_project_test.rs index 1bd68375..15591b34 100644 --- a/contracts/boundless_contract/src/tests/vote_project_test.rs +++ b/contracts/boundless_contract/src/tests/vote_project_test.rs @@ -16,6 +16,7 @@ fn test_voting() { let project_id = String::from_str(&env, "test_project"); let metadata_uri = String::from_str(&env, "ipfs://example-metadata"); + env.mock_all_auths(); client.create_project(&project_id, &creator, &metadata_uri, &1000, &5); assert_eq!(client.has_voted(&project_id, &voter), false); From 9f050310430a13e5c820c03f684ff94835cdd71f Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Mon, 24 Mar 2025 12:33:15 +0100 Subject: [PATCH 02/11] refactor: clean up comments and improve code readability in contract and test files - Remove unnecessary comments in the BoundlessContract implementation to enhance clarity. - Update test files to streamline initialization and project deadline checks. - Adjust comments in milestone funding tests for better understanding of test cases. --- contracts/boundless_contract/src/contract.rs | 25 ------------------- .../src/tests/init_upgrade_test.rs | 15 ----------- .../src/tests/milestone_funding_test.rs | 5 ++-- 3 files changed, 2 insertions(+), 43 deletions(-) diff --git a/contracts/boundless_contract/src/contract.rs b/contracts/boundless_contract/src/contract.rs index c1d1e752..24120a3c 100644 --- a/contracts/boundless_contract/src/contract.rs +++ b/contracts/boundless_contract/src/contract.rs @@ -298,7 +298,6 @@ impl BoundlessContract { milestone_number: u32, admin: Address, ) -> Result<(), ProjectError> { - // Verify admin authorization admin.require_auth(); if admin != get_admin(&env) { return Err(ProjectError::Unauthorized); @@ -306,36 +305,29 @@ impl BoundlessContract { let mut project = read_project(&env, &project_id)?; - // Validate milestone number if milestone_number >= project.milestone_count { return Err(ProjectError::InvalidMilestoneNumber); } - // Check if milestone is already completed if project.milestone_releases.iter().any(|release| release.0 == milestone_number) { return Err(ProjectError::MilestoneAlreadyCompleted); } - // Check if milestone is approved if !project.milestone_approvals.iter().any(|approval| approval.0 == milestone_number && approval.1) { return Err(ProjectError::MilestoneNotApproved); } - // Calculate milestone release amount let milestone_amount = project.funding_target / project.milestone_count as u64; - // Update project state project.milestone_releases.push_back((milestone_number, milestone_amount)); project.current_milestone = milestone_number + 1; - // If all milestones are completed, mark project as successful if project.current_milestone == project.milestone_count { project.is_successful = true; } update_project(&env, &project)?; - // Emit milestone release event env.events().publish( (symbol_short!("milestone"), symbol_short!("released")), (project_id, milestone_number, milestone_amount), @@ -350,7 +342,6 @@ impl BoundlessContract { milestone_number: u32, admin: Address, ) -> Result<(), ProjectError> { - // Verify admin authorization admin.require_auth(); if admin != get_admin(&env) { return Err(ProjectError::Unauthorized); @@ -358,21 +349,17 @@ impl BoundlessContract { let mut project = read_project(&env, &project_id)?; - // Validate milestone number if milestone_number >= project.milestone_count { return Err(ProjectError::InvalidMilestoneNumber); } - // Check if milestone is already approved if project.milestone_approvals.iter().any(|approval| approval.0 == milestone_number) { return Err(ProjectError::MilestoneAlreadyCompleted); } - // Add milestone approval project.milestone_approvals.push_back((milestone_number, true)); update_project(&env, &project)?; - // Emit milestone approval event env.events().publish( (symbol_short!("milestone"), symbol_short!("approved")), (project_id, milestone_number), @@ -387,7 +374,6 @@ impl BoundlessContract { milestone_number: u32, admin: Address, ) -> Result<(), ProjectError> { - // Verify admin authorization admin.require_auth(); if admin != get_admin(&env) { return Err(ProjectError::Unauthorized); @@ -395,21 +381,17 @@ impl BoundlessContract { let mut project = read_project(&env, &project_id)?; - // Validate milestone number if milestone_number >= project.milestone_count { return Err(ProjectError::InvalidMilestoneNumber); } - // Check if milestone is already approved or rejected if project.milestone_approvals.iter().any(|approval| approval.0 == milestone_number) { return Err(ProjectError::MilestoneAlreadyCompleted); } - // Add milestone rejection project.milestone_approvals.push_back((milestone_number, false)); update_project(&env, &project)?; - // Emit milestone rejection event env.events().publish( (symbol_short!("milestone"), symbol_short!("rejected")), (project_id, milestone_number), @@ -454,7 +436,6 @@ impl BoundlessContract { // let token_client = token::StellarAssetClient::new(&env, &token_contract); - // For funding, we need to clawback from the funder and mint to the contract token::Client::new(&env, &token_contract).transfer(&funder, &env.current_contract_address(), &contribution); // token_client.clawback(&funder, &contribution); // token_client.mint(&env.current_contract_address(), &contribution); @@ -474,35 +455,29 @@ impl BoundlessContract { pub fn refund(env: Env, project_id: String, token_contract: Address) -> Result<(), ProjectError> { let mut project = read_project(&env, &project_id)?; - // Check if project has failed (funding target not met by deadline) if project.total_funded >= project.funding_target || env.ledger().timestamp() < project.funding_deadline { return Err(ProjectError::ProjectNotFailed); } - // Check if refund has already been processed if project.refund_processed { return Err(ProjectError::RefundAlreadyProcessed); } - // Check if there are funds to refund if project.total_funded == 0 { return Err(ProjectError::NoFundsToRefund); } let token_client = token::Client::new(&env, &token_contract); - // Process refunds for all backers for (backer, amount) in project.backers.iter() { // For refunds, we need to transfer from the contract back to the backer token_client.transfer(&env.current_contract_address(), &backer, &(amount as i128)); } - // Mark refund as processed project.refund_processed = true; project.is_closed = true; update_project(&env, &project)?; - // Emit refund event env.events().publish( (symbol_short!("project"), symbol_short!("refunded")), (project_id, project.total_funded), diff --git a/contracts/boundless_contract/src/tests/init_upgrade_test.rs b/contracts/boundless_contract/src/tests/init_upgrade_test.rs index 567a06a2..1ff59258 100644 --- a/contracts/boundless_contract/src/tests/init_upgrade_test.rs +++ b/contracts/boundless_contract/src/tests/init_upgrade_test.rs @@ -15,14 +15,11 @@ fn test_initialization() { let contract_id = env.register(BoundlessContract, ()); let client = BoundlessContractClient::new(&env, &contract_id); - // Test initialization let admin = Address::generate(&env); client.initialize(&admin); - // Verify version is 1 after initialization assert_eq!(1, client.get_version()); - // Verify admin is set correctly assert_eq!(admin, client.get_admin()); } @@ -35,14 +32,11 @@ fn test_cannot_reinitialize() { let contract_id = env.register(BoundlessContract, ()); let client = BoundlessContractClient::new(&env, &contract_id); - // First initialization let admin = Address::generate(&env); client.initialize(&admin); - // Attempt second initialization let result = client.initialize(&admin); - // Verify the error is AlreadyInitialized } #[test] @@ -53,27 +47,22 @@ fn test_project_deadlines() { let contract_id = env.register(BoundlessContract, ()); let client = BoundlessContractClient::new(&env, &contract_id); - // Initialize contract let admin = Address::generate(&env); client.initialize(&admin); - // Create a project let creator = Address::generate(&env); let project_id = String::from_str(&env, "test_project"); let metadata_uri = String::from_str(&env, "ipfs://example-metadata"); client.create_project(&project_id, &creator, &metadata_uri, &1000, &5); - // Get project details let project = client.get_project(&project_id); - // Verify funding deadline is set correctly (30 days from creation) assert_eq!( project.funding_deadline, project.created_at + FUNDING_PERIOD_LEDGERS as u64 ); - // Verify voting deadline is set correctly (30 days after funding deadline) assert_eq!( project.voting_deadline, project.funding_deadline + VOTING_PERIOD_LEDGERS as u64 @@ -88,19 +77,15 @@ fn test_project_deadlines() { // let contract_id = env.register_contract(None, BoundlessContract); // let client = BoundlessContractClient::new(&env, &contract_id); -// // Initialize with admin // let admin = Address::generate(&env); // client.initialize(&admin); -// // Try to upgrade with non-admin address // let non_admin = Address::generate(&env); -// // Switch to non-admin context // env.as_contract(&non_admin, || { // let result = client.upgrade(&[0u8; 32]); // Dummy WASM hash // assert!(result.is_err()); -// // Verify the error is Unauthorized // let err: ProjectError = result.err().unwrap().try_into().unwrap(); // assert_eq!(err, ProjectError::Unauthorized); // }); diff --git a/contracts/boundless_contract/src/tests/milestone_funding_test.rs b/contracts/boundless_contract/src/tests/milestone_funding_test.rs index 06eebdfd..3d354d31 100644 --- a/contracts/boundless_contract/src/tests/milestone_funding_test.rs +++ b/contracts/boundless_contract/src/tests/milestone_funding_test.rs @@ -114,7 +114,6 @@ impl<'a> FundingTest<'a> { fn test_funding_operations() { let test = FundingTest::setup(); - // Test initial funding test.contract_client.fund_project(&test.project_id, &500, &test.funder, &test.token_contract); // Test get project funding @@ -164,7 +163,7 @@ fn test_unauthorized_milestone_approval() { let env = Env::default(); env.mock_all_auths(); - let contract_id = env.register_contract(None, BoundlessContract); + let contract_id = env.register(BoundlessContract, ()); let client = BoundlessContractClient::new(&env, &contract_id); let admin = Address::generate(&env); @@ -186,7 +185,7 @@ fn test_unauthorized_milestone_release() { let env = Env::default(); env.mock_all_auths(); - let contract_id = env.register_contract(None, BoundlessContract); + let contract_id = env.register(BoundlessContract, ()); let client = BoundlessContractClient::new(&env, &contract_id); let admin = Address::generate(&env); From a85fd4322de0bed97b94adb15223b5bd2b27cbf0 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Wed, 26 Mar 2025 07:58:56 +0100 Subject: [PATCH 03/11] docs: add contract function documentation file --- docs/contract_fn.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/contract_fn.md diff --git a/docs/contract_fn.md b/docs/contract_fn.md new file mode 100644 index 00000000..e69de29b From f99bb6efc0d580c137108f1d1fb595cf8a4a9c00 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Fri, 28 Mar 2025 17:49:32 +0100 Subject: [PATCH 04/11] fix(tests): remove duplicate init_upgrade_test module import feat(contracts): add util.ts with default RPC URL and network passphrase --- contracts/boundless_contract/src/tests/mod.rs | 1 - src/contracts/util.ts | 6 ++++++ 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 src/contracts/util.ts diff --git a/contracts/boundless_contract/src/tests/mod.rs b/contracts/boundless_contract/src/tests/mod.rs index 7410bbc6..783055d6 100644 --- a/contracts/boundless_contract/src/tests/mod.rs +++ b/contracts/boundless_contract/src/tests/mod.rs @@ -3,5 +3,4 @@ mod create_project_test; mod init_upgrade_test; #[cfg(test)] mod vote_project_test; -mod init_upgrade_test; mod milestone_funding_test; diff --git a/src/contracts/util.ts b/src/contracts/util.ts new file mode 100644 index 00000000..642466b0 --- /dev/null +++ b/src/contracts/util.ts @@ -0,0 +1,6 @@ +export const rpcUrl = + process.env.PUBLIC_STELLAR_RPC_URL ?? + "https://soroban-testnet.stellar.org:443"; +export const networkPassphrase = + process.env.PUBLIC_STELLAR_NETWORK_PASSPHRASE ?? + "Test SDF Network ; September 2015"; From 5b4ca42eca68c1257b5756b43f343cc88c25c254 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Fri, 18 Apr 2025 17:09:23 +0100 Subject: [PATCH 05/11] modifed deploy --- .github/workflows/deploy.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 65c814c4..0085c0d4 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -36,6 +36,12 @@ jobs: PUBLIC_STELLAR_RPC_URL: https://soroban-testnet.stellar.org/ run: | npm ci + STELLAR_NETWORK=$STELLAR_NETWORK \ + STELLAR_ACCOUNT=$STELLAR_ACCOUNT \ + STELLAR_SECRET_KEY=$STELLAR_SECRET_KEY \ + SOROBAN_SECRET_KEY=$SOROBAN_SECRET_KEY \ + PUBLIC_STELLAR_NETWORK_PASSPHRASE=$PUBLIC_STELLAR_NETWORK_PASSPHRASE \ + PUBLIC_STELLAR_RPC_URL=$PUBLIC_STELLAR_RPC_URL \ npm run init - name: Build Next.js app From 1df38ce6b5e821b23dfb76ba3d2fef223bc86df4 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Fri, 18 Apr 2025 17:36:49 +0100 Subject: [PATCH 06/11] feat: enhance project management with milestones and wallet connection features - Added milestone management capabilities, including creation, updates, and attachments. - Implemented wallet connection protection for project actions, ensuring users are prompted to connect their wallets when necessary. - Updated user profile handling to include new fields and improved error handling in API routes. - Refactored components for better organization and user experience, including profile display and edit forms. - Introduced new UI components for wallet connection and project forms, enhancing the overall user interface. - Updated dependencies and improved code readability across various files. --- app/(dashboard)/profile/page.tsx | 150 +++- app/(dashboard)/projects/[id]/page.tsx | 6 +- .../[id]/viewer/project-viewer-page.tsx | 637 ++++++++-------- .../projects/[id]/viewer/viewer-comments.tsx | 398 +++++----- .../projects/[id]/viewer/viewer-funding.tsx | 126 ++-- .../[id]/viewer/viewer-milestones.tsx | 343 +++++---- .../[id]/viewer/viewer-project-status.tsx | 94 +-- .../[id]/viewer/viewer-team-preview.tsx | 91 ++- .../projects/[id]/viewer/viewer-team.tsx | 183 +++-- .../projects/[id]/viewer/viewer-voting.tsx | 244 +++--- .../projects/edit/[id]/milestone-form.tsx | 174 +++++ .../projects/edit/[id]/milestone-tracker.tsx | 181 +++-- app/(dashboard)/projects/edit/[id]/page.tsx | 56 +- app/(dashboard)/projects/new/page.tsx | 11 +- app/api/auth/register/route.ts | 1 + .../[milestoneId]/attachments/route.ts | 155 ++++ .../[id]/milestones/[milestoneId]/route.ts | 229 ++++++ app/api/projects/[id]/milestones/route.ts | 124 ++++ app/api/user/profile/route.ts | 25 +- app/api/user/settings/route.ts | 108 +-- app/connect-wallet/page.tsx | 95 +++ components/connect-wallet.tsx | 12 +- .../dashboard/profile/ProfileDisplay.tsx | 154 ++-- .../dashboard/profile/ProfileEditForm.tsx | 92 +-- .../examples/wallet-protected-action.tsx | 46 ++ components/project-form-wrapper.tsx | 16 + components/project-form.tsx | 374 ++++++---- components/ui/calendar.tsx | 76 ++ components/wallet-connection-modal.tsx | 92 +++ components/with-wallet-protection.tsx | 79 ++ hooks/useProjectAuth.ts | 178 +++-- lib/actions/settings.ts | 38 +- lib/actions/user.ts | 88 +-- lib/wallet-actions.ts | 70 ++ lib/wallet-utils.ts | 48 ++ package-lock.json | 701 ++++++++++++++++-- package.json | 13 +- .../migrations/20250408232837_n/migration.sql | 77 ++ prisma/migrations/migration_lock.toml | 2 +- prisma/schema.prisma | 34 + prisma/seed.ts | 6 +- store/useWalletStore.ts | 3 + store/userStore.ts | 137 ++-- types/user.ts | 12 + 44 files changed, 4034 insertions(+), 1745 deletions(-) create mode 100644 app/(dashboard)/projects/edit/[id]/milestone-form.tsx create mode 100644 app/api/projects/[id]/milestones/[milestoneId]/attachments/route.ts create mode 100644 app/api/projects/[id]/milestones/[milestoneId]/route.ts create mode 100644 app/api/projects/[id]/milestones/route.ts create mode 100644 app/connect-wallet/page.tsx create mode 100644 components/examples/wallet-protected-action.tsx create mode 100644 components/project-form-wrapper.tsx create mode 100644 components/ui/calendar.tsx create mode 100644 components/wallet-connection-modal.tsx create mode 100644 components/with-wallet-protection.tsx create mode 100644 lib/wallet-actions.ts create mode 100644 lib/wallet-utils.ts create mode 100644 prisma/migrations/20250408232837_n/migration.sql create mode 100644 types/user.ts diff --git a/app/(dashboard)/profile/page.tsx b/app/(dashboard)/profile/page.tsx index cb45da15..e6c8010e 100644 --- a/app/(dashboard)/profile/page.tsx +++ b/app/(dashboard)/profile/page.tsx @@ -2,49 +2,157 @@ import ProfileDisplay from "@/components/dashboard/profile/ProfileDisplay"; import ProfileEditForm from "@/components/dashboard/profile/ProfileEditForm"; +import { Card } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import type { UserProfile } from "@/types/user"; import axios from "axios"; import React, { useState, useEffect } from "react"; export default function ProfilePage() { - const [userData, setUserData] = useState<{ - username: string; - displayName: string; - bio?: string; - twitter?: string; - linkedIn?: string; - } | null>(null); + const [userData, setUserData] = useState(null); const [isEditing, setIsEditing] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); useEffect(() => { async function fetchUserData() { try { + setIsLoading(true); + setError(null); const res = await axios.get("/api/user/profile"); setUserData(res.data); } catch (error) { console.error("Error fetching user data:", error); + setError("Failed to load profile data. Please try again later."); + } finally { + setIsLoading(false); } } fetchUserData(); }, []); + if (isLoading) { + return ( +
+ +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ ); + } + + if (error) { + return ( +
+ +
+
+ + Error Icon + + +
+

+ Error Loading Profile +

+

{error}

+ +
+
+
+ ); + } + if (!userData) { - return
Loading...
; + return ( +
+ +
+
+ + Profile Icon + + +
+

+ No Profile Found +

+

+ We couldn't find your profile information. +

+
+
+
+ ); } return ( -
- {isEditing ? ( - { - setUserData(updatedData); - setIsEditing(false); - }} - onCancel={() => setIsEditing(false)} - /> - ) : ( - setIsEditing(true)} /> - )} +
+
+

Profile

+

+ Manage your profile information and preferences +

+
+ + {isEditing ? ( + { + setUserData(updatedData); + setIsEditing(false); + }} + onCancel={() => setIsEditing(false)} + /> + ) : ( + setIsEditing(true)} + /> + )} +
); } diff --git a/app/(dashboard)/projects/[id]/page.tsx b/app/(dashboard)/projects/[id]/page.tsx index c7214eca..dced248d 100644 --- a/app/(dashboard)/projects/[id]/page.tsx +++ b/app/(dashboard)/projects/[id]/page.tsx @@ -1,5 +1,5 @@ -import { ProjectViewerPage } from "./viewer/project-viewer-page" +import { ProjectViewerPage } from "./viewer/project-viewer-page"; export default function ProjectPage({ params }: { params: { id: string } }) { - return -} \ No newline at end of file + return ; +} diff --git a/app/(dashboard)/projects/[id]/viewer/project-viewer-page.tsx b/app/(dashboard)/projects/[id]/viewer/project-viewer-page.tsx index b6df963d..5e1ea066 100644 --- a/app/(dashboard)/projects/[id]/viewer/project-viewer-page.tsx +++ b/app/(dashboard)/projects/[id]/viewer/project-viewer-page.tsx @@ -1,338 +1,367 @@ -"use client" +"use client"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import type { Vote } from "@prisma/client" -import { Calendar, FileText, Heart, Share2, Users, Wallet } from "lucide-react" -import { useSession } from "next-auth/react" -import Image from "next/image" -import Link from "next/link" -import { useParams, useRouter } from "next/navigation" -import { useEffect, useState } from "react" -import { ViewerComments } from "./viewer-comments" -import { ViewerFundingHorizontal } from "./viewer-funding" -import { ViewerMilestones } from "./viewer-milestones" -import { ViewerProjectStatus } from "./viewer-project-status" -import { ViewerTeam } from "./viewer-team" -import { ViewerVoting } from "./viewer-voting" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import type { Vote } from "@prisma/client"; +import { Calendar, FileText, Heart, Share2, Users, Wallet } from "lucide-react"; +import { useSession } from "next-auth/react"; +import Image from "next/image"; +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { ViewerComments } from "./viewer-comments"; +import { ViewerFundingHorizontal } from "./viewer-funding"; +import { ViewerMilestones } from "./viewer-milestones"; +import { ViewerProjectStatus } from "./viewer-project-status"; +import { ViewerTeam } from "./viewer-team"; +import { ViewerVoting } from "./viewer-voting"; -type ValidationStatus = "PENDING" | "REJECTED" | "VALIDATED" +type ValidationStatus = "PENDING" | "REJECTED" | "VALIDATED"; type Project = { - id: string - userId: string - title: string - description: string - fundingGoal: number - category: string - bannerUrl: string | null - profileUrl: string | null - blockchainTx: string | null - ideaValidation: ValidationStatus - createdAt: string - user: { - id: string - name: string | null - image: string | null - } - votes: Vote[] - teamMembers: { - id: string - fullName: string - role: string - bio: string | null - profileImage: string | null - github: string | null - twitter: string | null - discord: string | null - linkedin: string | null - userId: string | null - }[] - _count: { - votes: number - teamMembers: number - } -} + id: string; + userId: string; + title: string; + description: string; + fundingGoal: number; + category: string; + bannerUrl: string | null; + profileUrl: string | null; + blockchainTx: string | null; + ideaValidation: ValidationStatus; + createdAt: string; + user: { + id: string; + name: string | null; + image: string | null; + }; + votes: Vote[]; + teamMembers: { + id: string; + fullName: string; + role: string; + bio: string | null; + profileImage: string | null; + github: string | null; + twitter: string | null; + discord: string | null; + linkedin: string | null; + userId: string | null; + }[]; + _count: { + votes: number; + teamMembers: number; + }; +}; export function ProjectViewerPage() { - const params = useParams() - const router = useRouter() - const { data: session } = useSession() - const [project, setProject] = useState(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) + const params = useParams(); + const router = useRouter(); + const { data: session } = useSession(); + const [project, setProject] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); - useEffect(() => { - async function fetchProject() { - try { - const id = params?.id as string - if (!id) return + useEffect(() => { + async function fetchProject() { + try { + const id = params?.id as string; + if (!id) return; - const response = await fetch(`/api/projects/${id}`) + const response = await fetch(`/api/projects/${id}`); - if (response.status === 404) { - router.push("/projects") - return - } + if (response.status === 404) { + router.push("/projects"); + return; + } - if (!response.ok) { - throw new Error("Failed to fetch project") - } + if (!response.ok) { + throw new Error("Failed to fetch project"); + } - const data = await response.json() - setProject(data) - } catch (err) { - setError(err instanceof Error ? err.message : "An error occurred") - console.error(err) - } finally { - setLoading(false) - } - } + const data = await response.json(); + setProject(data); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + console.error(err); + } finally { + setLoading(false); + } + } - fetchProject() - }, [params, router]) + fetchProject(); + }, [params, router]); - if (loading) { - return ( -
-
-
-

Loading project details...

-
-
- ) - } + if (loading) { + return ( +
+
+
+

Loading project details...

+
+
+ ); + } - if (error) { - return ( -
- - - Error Loading Project - - -

{error}

- -
-
-
- ) - } + if (error) { + return ( +
+ + + + Error Loading Project + + + +

{error}

+ +
+
+
+ ); + } - if (!project) { - return ( -
- - - Project Not Found - - - - - -
- ) - } + if (!project) { + return ( +
+ + + Project Not Found + + + + + +
+ ); + } - // Format date - const formatDate = (dateString: string) => { - return new Date(dateString).toLocaleDateString("en-US", { - year: "numeric", - month: "long", - day: "numeric", - }) - } + // Format date + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); + }; - return ( -
- {/* Navigation breadcrumb */} -
-
-
- - Projects - - / - {project.title} -
-
-
+ return ( +
+ {/* Navigation breadcrumb */} +
+
+
+ + Projects + + / + {project.title} +
+
+
- {/* Project Header */} -
-
-
- - - {project.title.substring(0, 2).toUpperCase()} - + {/* Project Header */} +
+
+
+ + + + {project.title.substring(0, 2).toUpperCase()} + + -
-
- {project.category} - - {project.ideaValidation} - -
+
+
+ {project.category} + + {project.ideaValidation} + +
-

{project.title}

+

{project.title}

-
-
- - {formatDate(project.createdAt)} -
-
- - {project._count.votes} Supporters -
-
- ${project.fundingGoal.toLocaleString()} Goal -
-
-
+
+
+ + {formatDate(project.createdAt)} +
+
+ + {project._count.votes} Supporters +
+
+ $ + {project.fundingGoal.toLocaleString()} Goal +
+
+
-
- - -
-
-
-
+
+ + +
+
+
+
- {/* Project Banner */} -
- {`${project.title} -
+ {/* Project Banner */} +
+ {`${project.title} +
- {/* Project Status */} -
- - - - - -
+ {/* Project Status */} +
+ + + + + +
- {/* Funding Status - Horizontal */} -
- -
+ {/* Funding Status - Horizontal */} +
+ +
- {/* Main Content */} -
-
- {/* Main Column */} -
- {/* About */} - - - About the Project - - -
-

{project.description}

-
+ {/* Main Content */} +
+
+ {/* Main Column */} +
+ {/* About */} + + + About the Project + + +
+

{project.description}

+
-
- - -
-
-
+
+ + +
+ + - {/* Tabs */} - - - Milestones - Team - Discussion - + {/* Tabs */} + + + Milestones + Team + Discussion + - - - + + + - - - + + + - - - - -
+ + + + +
- {/* Sidebar */} -
- {/* Creator */} - - - Project Creator - - -
- - - - {project.user.name ? project.user.name.substring(0, 2).toUpperCase() : "CR"} - - -
-

{project.user.name || "Anonymous"}

-

Project Creator

-
-
-
-
+ {/* Sidebar */} +
+ {/* Creator */} + + + Project Creator + + +
+ + + + {project.user.name + ? project.user.name.substring(0, 2).toUpperCase() + : "CR"} + + +
+

+ {project.user.name || "Anonymous"} +

+

+ Project Creator +

+
+
+
+
- {/* Voting */} - - - Community Voting - - - vote.userId === session?.user?.id)} - status={project.ideaValidation} - // compact={true} - /> - - -
-
-
-
- ) + {/* Voting */} + + + Community Voting + + + vote.userId === session?.user?.id, + )} + status={project.ideaValidation} + // compact={true} + /> + + +
+
+
+
+ ); } diff --git a/app/(dashboard)/projects/[id]/viewer/viewer-comments.tsx b/app/(dashboard)/projects/[id]/viewer/viewer-comments.tsx index 080e3336..6f47d6a2 100644 --- a/app/(dashboard)/projects/[id]/viewer/viewer-comments.tsx +++ b/app/(dashboard)/projects/[id]/viewer/viewer-comments.tsx @@ -1,189 +1,223 @@ -"use client" - -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" -import { Button } from "@/components/ui/button" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import { Separator } from "@/components/ui/separator" -import { Textarea } from "@/components/ui/textarea" -import { ThumbsDown, ThumbsUp } from "lucide-react" -import { useSession } from "next-auth/react" -import { useState } from "react" +"use client"; + +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { Textarea } from "@/components/ui/textarea"; +import { ThumbsDown, ThumbsUp } from "lucide-react"; +import { useSession } from "next-auth/react"; +import { useState } from "react"; type Comment = { - id: string - userId: string - userName: string - userImage: string | null - content: string - createdAt: string - likes: number - dislikes: number -} + id: string; + userId: string; + userName: string; + userImage: string | null; + content: string; + createdAt: string; + likes: number; + dislikes: number; +}; type ViewerCommentsProps = { - projectId: string -} + projectId: string; +}; export function ViewerComments({ projectId }: ViewerCommentsProps) { - const { data: session } = useSession() - const [comment, setComment] = useState("") - const [isSubmitting, setIsSubmitting] = useState(false) - - // In a real app, you would fetch comments from an API - const [comments, setComments] = useState([ - { - id: "1", - userId: "user1", - userName: "Alex Johnson", - userImage: null, - content: - "This project looks very promising! I especially like the focus on sustainability and community involvement.", - createdAt: "2024-03-15T14:23:00Z", - likes: 12, - dislikes: 1, - }, - { - id: "2", - userId: "user2", - userName: "Sam Rivera", - userImage: null, - content: - "I have a few questions about the technical implementation. Will this be compatible with existing systems?", - createdAt: "2024-03-14T09:45:00Z", - likes: 5, - dislikes: 0, - }, - { - id: "3", - userId: "user3", - userName: "Taylor Kim", - userImage: null, - content: - "I've been following similar projects in this space, and I think this one has a unique approach that could really work.", - createdAt: "2024-03-12T16:30:00Z", - likes: 8, - dislikes: 2, - }, - ]) - - const handleSubmitComment = async () => { - if (!comment.trim() || !session?.user) return - - setIsSubmitting(true) - - try { - // In a real app, you would make an API call here - // const response = await fetch(`/api/projects/${projectId}/comments`, { - // method: 'POST', - // body: JSON.stringify({ content: comment }), - // }) - - // Simulate API call - await new Promise((resolve) => setTimeout(resolve, 500)) - - const newComment: Comment = { - id: `temp-${Date.now()}`, - userId: session.user.id || "unknown", - userName: session.user.name || "Anonymous", - userImage: session.user.image || null, - content: comment, - createdAt: new Date().toISOString(), - likes: 0, - dislikes: 0, - } - - setComments((prev) => [newComment, ...prev]) - setComment("") - } catch (error) { - console.error("Failed to post comment:", error) - } finally { - setIsSubmitting(false) - } - } - - // Format date - const formatDate = (dateString: string) => { - const date = new Date(dateString) - const now = new Date() - const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000) - - if (diffInSeconds < 60) return `${diffInSeconds} seconds ago` - if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} minutes ago` - if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} hours ago` - if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)} days ago` - - return date.toLocaleDateString("en-US", { - year: "numeric", - month: "short", - day: "numeric", - }) - } - - return ( - - - Discussion - Join the conversation about this project - - - {session ? ( -
-