577 lines
22 KiB
Rust
577 lines
22 KiB
Rust
use actix_web::{web, HttpResponse, Result, http};
|
|
use tera::{Context, Tera};
|
|
use chrono::{Utc, Duration};
|
|
use serde::Deserialize;
|
|
use uuid::Uuid;
|
|
|
|
use crate::models::asset::{Asset, AssetType, AssetStatus};
|
|
use crate::models::marketplace::{Listing, ListingStatus, ListingType, Bid, BidStatus, MarketplaceStatistics};
|
|
use crate::controllers::asset::AssetController;
|
|
use crate::utils::render_template;
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct ListingForm {
|
|
pub title: String,
|
|
pub description: String,
|
|
pub asset_id: String,
|
|
pub price: f64,
|
|
pub currency: String,
|
|
pub listing_type: String,
|
|
pub duration_days: Option<u32>,
|
|
pub tags: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct BidForm {
|
|
pub amount: f64,
|
|
pub currency: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct PurchaseForm {
|
|
pub agree_to_terms: bool,
|
|
}
|
|
|
|
pub struct MarketplaceController;
|
|
|
|
impl MarketplaceController {
|
|
// Display the marketplace dashboard
|
|
pub async fn index(tmpl: web::Data<Tera>) -> Result<HttpResponse> {
|
|
let mut context = Context::new();
|
|
|
|
let listings = Self::get_mock_listings();
|
|
let stats = MarketplaceStatistics::new(&listings);
|
|
|
|
// Get featured listings (up to 4)
|
|
let featured_listings: Vec<&Listing> = listings.iter()
|
|
.filter(|l| l.featured && l.status == ListingStatus::Active)
|
|
.take(4)
|
|
.collect();
|
|
|
|
// Get recent listings (up to 8)
|
|
let mut recent_listings: Vec<&Listing> = listings.iter()
|
|
.filter(|l| l.status == ListingStatus::Active)
|
|
.collect();
|
|
|
|
// Sort by created_at (newest first)
|
|
recent_listings.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
|
let recent_listings = recent_listings.into_iter().take(8).collect::<Vec<_>>();
|
|
|
|
// Get recent sales (up to 5)
|
|
let mut recent_sales: Vec<&Listing> = listings.iter()
|
|
.filter(|l| l.status == ListingStatus::Sold)
|
|
.collect();
|
|
|
|
// Sort by sold_at (newest first)
|
|
recent_sales.sort_by(|a, b| {
|
|
let a_sold = a.sold_at.unwrap_or(a.created_at);
|
|
let b_sold = b.sold_at.unwrap_or(b.created_at);
|
|
b_sold.cmp(&a_sold)
|
|
});
|
|
let recent_sales = recent_sales.into_iter().take(5).collect::<Vec<_>>();
|
|
|
|
// Add data to context
|
|
context.insert("active_page", &"marketplace");
|
|
context.insert("stats", &stats);
|
|
context.insert("featured_listings", &featured_listings);
|
|
context.insert("recent_listings", &recent_listings);
|
|
context.insert("recent_sales", &recent_sales);
|
|
|
|
render_template(&tmpl, "marketplace/index.html", &context)
|
|
}
|
|
|
|
// Display all marketplace listings
|
|
pub async fn list_listings(tmpl: web::Data<Tera>) -> Result<HttpResponse> {
|
|
let mut context = Context::new();
|
|
|
|
let listings = Self::get_mock_listings();
|
|
|
|
// Filter active listings
|
|
let active_listings: Vec<&Listing> = listings.iter()
|
|
.filter(|l| l.status == ListingStatus::Active)
|
|
.collect();
|
|
|
|
context.insert("active_page", &"marketplace");
|
|
context.insert("listings", &active_listings);
|
|
context.insert("listing_types", &[
|
|
ListingType::FixedPrice.as_str(),
|
|
ListingType::Auction.as_str(),
|
|
ListingType::Exchange.as_str(),
|
|
]);
|
|
context.insert("asset_types", &[
|
|
AssetType::Token.as_str(),
|
|
AssetType::Artwork.as_str(),
|
|
AssetType::RealEstate.as_str(),
|
|
AssetType::IntellectualProperty.as_str(),
|
|
AssetType::Commodity.as_str(),
|
|
AssetType::Share.as_str(),
|
|
AssetType::Bond.as_str(),
|
|
AssetType::Other.as_str(),
|
|
]);
|
|
|
|
render_template(&tmpl, "marketplace/listings.html", &context)
|
|
}
|
|
|
|
// Display my listings
|
|
pub async fn my_listings(tmpl: web::Data<Tera>) -> Result<HttpResponse> {
|
|
let mut context = Context::new();
|
|
|
|
let listings = Self::get_mock_listings();
|
|
|
|
// Filter by current user (mock user ID)
|
|
let user_id = "user-123";
|
|
let my_listings: Vec<&Listing> = listings.iter()
|
|
.filter(|l| l.seller_id == user_id)
|
|
.collect();
|
|
|
|
context.insert("active_page", &"marketplace");
|
|
context.insert("listings", &my_listings);
|
|
|
|
render_template(&tmpl, "marketplace/my_listings.html", &context)
|
|
}
|
|
|
|
// Display listing details
|
|
pub async fn listing_detail(tmpl: web::Data<Tera>, path: web::Path<String>) -> Result<HttpResponse> {
|
|
let listing_id = path.into_inner();
|
|
let mut context = Context::new();
|
|
|
|
let listings = Self::get_mock_listings();
|
|
|
|
// Find the listing
|
|
let listing = listings.iter().find(|l| l.id == listing_id);
|
|
|
|
if let Some(listing) = listing {
|
|
// Get similar listings (same asset type, active)
|
|
let similar_listings: Vec<&Listing> = listings.iter()
|
|
.filter(|l| l.asset_type == listing.asset_type &&
|
|
l.status == ListingStatus::Active &&
|
|
l.id != listing.id)
|
|
.take(4)
|
|
.collect();
|
|
|
|
// Get highest bid amount and minimum bid for auction listings
|
|
let (highest_bid_amount, minimum_bid) = if listing.listing_type == ListingType::Auction {
|
|
if let Some(bid) = listing.highest_bid() {
|
|
(Some(bid.amount), bid.amount + 1.0)
|
|
} else {
|
|
(None, listing.price + 1.0)
|
|
}
|
|
} else {
|
|
(None, 0.0)
|
|
};
|
|
|
|
context.insert("active_page", &"marketplace");
|
|
context.insert("listing", listing);
|
|
context.insert("similar_listings", &similar_listings);
|
|
context.insert("highest_bid_amount", &highest_bid_amount);
|
|
context.insert("minimum_bid", &minimum_bid);
|
|
|
|
// Add current user info for bid/purchase forms
|
|
let user_id = "user-123";
|
|
let user_name = "Alice Hostly";
|
|
context.insert("user_id", &user_id);
|
|
context.insert("user_name", &user_name);
|
|
|
|
render_template(&tmpl, "marketplace/listing_detail.html", &context)
|
|
} else {
|
|
Ok(HttpResponse::NotFound().finish())
|
|
}
|
|
}
|
|
|
|
// Display create listing form
|
|
pub async fn create_listing_form(tmpl: web::Data<Tera>) -> Result<HttpResponse> {
|
|
let mut context = Context::new();
|
|
|
|
// Get user's assets for selection
|
|
let assets = AssetController::get_mock_assets();
|
|
let user_id = "user-123"; // Mock user ID
|
|
|
|
let user_assets: Vec<&Asset> = assets.iter()
|
|
.filter(|a| a.owner_id == user_id && a.status == AssetStatus::Active)
|
|
.collect();
|
|
|
|
context.insert("active_page", &"marketplace");
|
|
context.insert("assets", &user_assets);
|
|
context.insert("listing_types", &[
|
|
ListingType::FixedPrice.as_str(),
|
|
ListingType::Auction.as_str(),
|
|
ListingType::Exchange.as_str(),
|
|
]);
|
|
|
|
render_template(&tmpl, "marketplace/create_listing.html", &context)
|
|
}
|
|
|
|
// Create a new listing
|
|
pub async fn create_listing(
|
|
tmpl: web::Data<Tera>,
|
|
form: web::Form<ListingForm>,
|
|
) -> Result<HttpResponse> {
|
|
let form = form.into_inner();
|
|
|
|
// Get the asset details
|
|
let assets = AssetController::get_mock_assets();
|
|
let asset = assets.iter().find(|a| a.id == form.asset_id);
|
|
|
|
if let Some(asset) = asset {
|
|
// Process tags
|
|
let tags = match form.tags {
|
|
Some(tags_str) => tags_str.split(',')
|
|
.map(|s| s.trim().to_string())
|
|
.filter(|s| !s.is_empty())
|
|
.collect(),
|
|
None => Vec::new(),
|
|
};
|
|
|
|
// Calculate expiration date if provided
|
|
let expires_at = form.duration_days.map(|days| {
|
|
Utc::now() + Duration::days(days as i64)
|
|
});
|
|
|
|
// Parse listing type
|
|
let listing_type = match form.listing_type.as_str() {
|
|
"Fixed Price" => ListingType::FixedPrice,
|
|
"Auction" => ListingType::Auction,
|
|
"Exchange" => ListingType::Exchange,
|
|
_ => ListingType::FixedPrice,
|
|
};
|
|
|
|
// Mock user data
|
|
let user_id = "user-123";
|
|
let user_name = "Alice Hostly";
|
|
|
|
// Create the listing
|
|
let _listing = Listing::new(
|
|
form.title,
|
|
form.description,
|
|
asset.id.clone(),
|
|
asset.name.clone(),
|
|
asset.asset_type.clone(),
|
|
user_id.to_string(),
|
|
user_name.to_string(),
|
|
form.price,
|
|
form.currency,
|
|
listing_type,
|
|
expires_at,
|
|
tags,
|
|
asset.image_url.clone(),
|
|
);
|
|
|
|
// In a real application, we would save the listing to a database here
|
|
|
|
// Redirect to the marketplace
|
|
Ok(HttpResponse::SeeOther()
|
|
.insert_header((http::header::LOCATION, "/marketplace"))
|
|
.finish())
|
|
} else {
|
|
// Asset not found
|
|
let mut context = Context::new();
|
|
context.insert("active_page", &"marketplace");
|
|
context.insert("error", &"Asset not found");
|
|
|
|
render_template(&tmpl, "marketplace/create_listing.html", &context)
|
|
}
|
|
}
|
|
|
|
// Submit a bid on an auction listing
|
|
pub async fn submit_bid(
|
|
tmpl: web::Data<Tera>,
|
|
path: web::Path<String>,
|
|
form: web::Form<BidForm>,
|
|
) -> Result<HttpResponse> {
|
|
let listing_id = path.into_inner();
|
|
let form = form.into_inner();
|
|
|
|
// In a real application, we would:
|
|
// 1. Find the listing in the database
|
|
// 2. Validate the bid
|
|
// 3. Create the bid
|
|
// 4. Save it to the database
|
|
|
|
// For now, we'll just redirect back to the listing
|
|
Ok(HttpResponse::SeeOther()
|
|
.insert_header((http::header::LOCATION, format!("/marketplace/{}", listing_id)))
|
|
.finish())
|
|
}
|
|
|
|
// Purchase a fixed-price listing
|
|
pub async fn purchase_listing(
|
|
tmpl: web::Data<Tera>,
|
|
path: web::Path<String>,
|
|
form: web::Form<PurchaseForm>,
|
|
) -> Result<HttpResponse> {
|
|
let listing_id = path.into_inner();
|
|
let form = form.into_inner();
|
|
|
|
if !form.agree_to_terms {
|
|
// User must agree to terms
|
|
return Ok(HttpResponse::SeeOther()
|
|
.insert_header((http::header::LOCATION, format!("/marketplace/{}", listing_id)))
|
|
.finish());
|
|
}
|
|
|
|
// In a real application, we would:
|
|
// 1. Find the listing in the database
|
|
// 2. Validate the purchase
|
|
// 3. Process the transaction
|
|
// 4. Update the listing status
|
|
// 5. Transfer the asset
|
|
|
|
// For now, we'll just redirect to the marketplace
|
|
Ok(HttpResponse::SeeOther()
|
|
.insert_header((http::header::LOCATION, "/marketplace"))
|
|
.finish())
|
|
}
|
|
|
|
// Cancel a listing
|
|
pub async fn cancel_listing(
|
|
tmpl: web::Data<Tera>,
|
|
path: web::Path<String>,
|
|
) -> Result<HttpResponse> {
|
|
let _listing_id = path.into_inner();
|
|
|
|
// In a real application, we would:
|
|
// 1. Find the listing in the database
|
|
// 2. Validate that the current user is the seller
|
|
// 3. Update the listing status
|
|
|
|
// For now, we'll just redirect to my listings
|
|
Ok(HttpResponse::SeeOther()
|
|
.insert_header((http::header::LOCATION, "/marketplace/my"))
|
|
.finish())
|
|
}
|
|
|
|
// Generate mock listings for development
|
|
pub fn get_mock_listings() -> Vec<Listing> {
|
|
let assets = AssetController::get_mock_assets();
|
|
let mut listings = Vec::new();
|
|
|
|
// Mock user data
|
|
let user_ids = vec!["user-123", "user-456", "user-789"];
|
|
let user_names = vec!["Alice Hostly", "Ethan Cloudman", "Priya Servera"];
|
|
|
|
// Create some fixed price listings
|
|
for i in 0..6 {
|
|
let asset_index = i % assets.len();
|
|
let asset = &assets[asset_index];
|
|
let user_index = i % user_ids.len();
|
|
|
|
let price = match asset.asset_type {
|
|
AssetType::Token => 50.0 + (i as f64 * 10.0),
|
|
AssetType::Artwork => 500.0 + (i as f64 * 100.0),
|
|
AssetType::RealEstate => 50000.0 + (i as f64 * 10000.0),
|
|
AssetType::IntellectualProperty => 2000.0 + (i as f64 * 500.0),
|
|
AssetType::Commodity => 1000.0 + (i as f64 * 200.0),
|
|
AssetType::Share => 300.0 + (i as f64 * 50.0),
|
|
AssetType::Bond => 1500.0 + (i as f64 * 300.0),
|
|
AssetType::Other => 800.0 + (i as f64 * 150.0),
|
|
};
|
|
|
|
let mut listing = Listing::new(
|
|
format!("{} for Sale", asset.name),
|
|
format!("This is a great opportunity to own {}. {}", asset.name, asset.description),
|
|
asset.id.clone(),
|
|
asset.name.clone(),
|
|
asset.asset_type.clone(),
|
|
user_ids[user_index].to_string(),
|
|
user_names[user_index].to_string(),
|
|
price,
|
|
"USD".to_string(),
|
|
ListingType::FixedPrice,
|
|
Some(Utc::now() + Duration::days(30)),
|
|
vec!["digital".to_string(), "asset".to_string()],
|
|
asset.image_url.clone(),
|
|
);
|
|
|
|
// Make some listings featured
|
|
if i % 5 == 0 {
|
|
listing.set_featured(true);
|
|
}
|
|
|
|
listings.push(listing);
|
|
}
|
|
|
|
// Create some auction listings
|
|
for i in 0..4 {
|
|
let asset_index = (i + 6) % assets.len();
|
|
let asset = &assets[asset_index];
|
|
let user_index = i % user_ids.len();
|
|
|
|
let starting_price = match asset.asset_type {
|
|
AssetType::Token => 40.0 + (i as f64 * 5.0),
|
|
AssetType::Artwork => 400.0 + (i as f64 * 50.0),
|
|
AssetType::RealEstate => 40000.0 + (i as f64 * 5000.0),
|
|
AssetType::IntellectualProperty => 1500.0 + (i as f64 * 300.0),
|
|
AssetType::Commodity => 800.0 + (i as f64 * 100.0),
|
|
AssetType::Share => 250.0 + (i as f64 * 40.0),
|
|
AssetType::Bond => 1200.0 + (i as f64 * 250.0),
|
|
AssetType::Other => 600.0 + (i as f64 * 120.0),
|
|
};
|
|
|
|
let mut listing = Listing::new(
|
|
format!("Auction: {}", asset.name),
|
|
format!("Bid on this amazing {}. {}", asset.name, asset.description),
|
|
asset.id.clone(),
|
|
asset.name.clone(),
|
|
asset.asset_type.clone(),
|
|
user_ids[user_index].to_string(),
|
|
user_names[user_index].to_string(),
|
|
starting_price,
|
|
"USD".to_string(),
|
|
ListingType::Auction,
|
|
Some(Utc::now() + Duration::days(7)),
|
|
vec!["auction".to_string(), "bidding".to_string()],
|
|
asset.image_url.clone(),
|
|
);
|
|
|
|
// Add some bids to the auctions
|
|
let num_bids = 2 + (i % 3);
|
|
for j in 0..num_bids {
|
|
let bidder_index = (j + 1) % user_ids.len();
|
|
if bidder_index != user_index { // Ensure seller isn't bidding
|
|
let bid_amount = starting_price * (1.0 + (0.1 * (j + 1) as f64));
|
|
let _ = listing.add_bid(
|
|
user_ids[bidder_index].to_string(),
|
|
user_names[bidder_index].to_string(),
|
|
bid_amount,
|
|
"USD".to_string(),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Make some listings featured
|
|
if i % 3 == 0 {
|
|
listing.set_featured(true);
|
|
}
|
|
|
|
listings.push(listing);
|
|
}
|
|
|
|
// Create some exchange listings
|
|
for i in 0..3 {
|
|
let asset_index = (i + 10) % assets.len();
|
|
let asset = &assets[asset_index];
|
|
let user_index = i % user_ids.len();
|
|
|
|
let value = match asset.asset_type {
|
|
AssetType::Token => 60.0 + (i as f64 * 15.0),
|
|
AssetType::Artwork => 600.0 + (i as f64 * 150.0),
|
|
AssetType::RealEstate => 60000.0 + (i as f64 * 15000.0),
|
|
AssetType::IntellectualProperty => 2500.0 + (i as f64 * 600.0),
|
|
AssetType::Commodity => 1200.0 + (i as f64 * 300.0),
|
|
AssetType::Share => 350.0 + (i as f64 * 70.0),
|
|
AssetType::Bond => 1800.0 + (i as f64 * 350.0),
|
|
AssetType::Other => 1000.0 + (i as f64 * 200.0),
|
|
};
|
|
|
|
let listing = Listing::new(
|
|
format!("Trade: {}", asset.name),
|
|
format!("Looking to exchange {} for another asset of similar value. Interested in NFTs and tokens.", asset.name),
|
|
asset.id.clone(),
|
|
asset.name.clone(),
|
|
asset.asset_type.clone(),
|
|
user_ids[user_index].to_string(),
|
|
user_names[user_index].to_string(),
|
|
value, // Estimated value for exchange
|
|
"USD".to_string(),
|
|
ListingType::Exchange,
|
|
Some(Utc::now() + Duration::days(60)),
|
|
vec!["exchange".to_string(), "trade".to_string()],
|
|
asset.image_url.clone(),
|
|
);
|
|
|
|
listings.push(listing);
|
|
}
|
|
|
|
// Create some sold listings
|
|
for i in 0..5 {
|
|
let asset_index = (i + 13) % assets.len();
|
|
let asset = &assets[asset_index];
|
|
let seller_index = i % user_ids.len();
|
|
let buyer_index = (i + 1) % user_ids.len();
|
|
|
|
let price = match asset.asset_type {
|
|
AssetType::Token => 55.0 + (i as f64 * 12.0),
|
|
AssetType::Artwork => 550.0 + (i as f64 * 120.0),
|
|
AssetType::RealEstate => 55000.0 + (i as f64 * 12000.0),
|
|
AssetType::IntellectualProperty => 2200.0 + (i as f64 * 550.0),
|
|
AssetType::Commodity => 1100.0 + (i as f64 * 220.0),
|
|
AssetType::Share => 320.0 + (i as f64 * 60.0),
|
|
AssetType::Bond => 1650.0 + (i as f64 * 330.0),
|
|
AssetType::Other => 900.0 + (i as f64 * 180.0),
|
|
};
|
|
|
|
let sale_price = price * 0.95; // Slight discount on sale
|
|
|
|
let mut listing = Listing::new(
|
|
format!("{} - SOLD", asset.name),
|
|
format!("This {} was sold recently.", asset.name),
|
|
asset.id.clone(),
|
|
asset.name.clone(),
|
|
asset.asset_type.clone(),
|
|
user_ids[seller_index].to_string(),
|
|
user_names[seller_index].to_string(),
|
|
price,
|
|
"USD".to_string(),
|
|
ListingType::FixedPrice,
|
|
None,
|
|
vec!["sold".to_string()],
|
|
asset.image_url.clone(),
|
|
);
|
|
|
|
// Mark as sold
|
|
let _ = listing.mark_as_sold(
|
|
user_ids[buyer_index].to_string(),
|
|
user_names[buyer_index].to_string(),
|
|
sale_price,
|
|
);
|
|
|
|
// Set sold date to be sometime in the past
|
|
let days_ago = i as i64 + 1;
|
|
listing.sold_at = Some(Utc::now() - Duration::days(days_ago));
|
|
|
|
listings.push(listing);
|
|
}
|
|
|
|
// Create a few cancelled listings
|
|
for i in 0..2 {
|
|
let asset_index = (i + 18) % assets.len();
|
|
let asset = &assets[asset_index];
|
|
let user_index = i % user_ids.len();
|
|
|
|
let price = match asset.asset_type {
|
|
AssetType::Token => 45.0 + (i as f64 * 8.0),
|
|
AssetType::Artwork => 450.0 + (i as f64 * 80.0),
|
|
AssetType::RealEstate => 45000.0 + (i as f64 * 8000.0),
|
|
AssetType::IntellectualProperty => 1800.0 + (i as f64 * 400.0),
|
|
AssetType::Commodity => 900.0 + (i as f64 * 180.0),
|
|
AssetType::Share => 280.0 + (i as f64 * 45.0),
|
|
AssetType::Bond => 1350.0 + (i as f64 * 270.0),
|
|
AssetType::Other => 750.0 + (i as f64 * 150.0),
|
|
};
|
|
|
|
let mut listing = Listing::new(
|
|
format!("{} - Cancelled", asset.name),
|
|
format!("This listing for {} was cancelled.", asset.name),
|
|
asset.id.clone(),
|
|
asset.name.clone(),
|
|
asset.asset_type.clone(),
|
|
user_ids[user_index].to_string(),
|
|
user_names[user_index].to_string(),
|
|
price,
|
|
"USD".to_string(),
|
|
ListingType::FixedPrice,
|
|
None,
|
|
vec!["cancelled".to_string()],
|
|
asset.image_url.clone(),
|
|
);
|
|
|
|
// Cancel the listing
|
|
let _ = listing.cancel();
|
|
|
|
listings.push(listing);
|
|
}
|
|
|
|
listings
|
|
}
|
|
}
|