//! Inventory popup component. use leptos::prelude::*; use leptos::reactive::owner::LocalStorage; use uuid::Uuid; use chattyness_db::models::{InventoryItem, PropAcquisitionInfo}; #[cfg(feature = "hydrate")] use chattyness_db::ws_messages::ClientMessage; use super::modals::{GuestLockedOverlay, Modal}; use super::tabs::{Tab, TabBar}; use super::ws_client::WsSender; /// Inventory popup component. /// /// Shows a tabbed interface with: /// - My Inventory: User-owned props with drop functionality /// - Server: Public server-wide props /// - Realm: Public realm-specific props /// /// Props: /// - `open`: Signal controlling visibility /// - `on_close`: Callback when popup should close /// - `ws_sender`: WebSocket sender for dropping props /// - `realm_slug`: Current realm slug for fetching realm props /// - `is_guest`: Whether the current user is a guest (shows locked overlay) #[component] pub fn InventoryPopup( #[prop(into)] open: Signal, on_close: Callback<()>, ws_sender: StoredValue, LocalStorage>, #[prop(into)] realm_slug: Signal, /// Whether the current user is a guest. Guests see a locked overlay. #[prop(optional, into)] is_guest: Option>, ) -> impl IntoView { let is_guest = is_guest.unwrap_or_else(|| Signal::derive(|| false)); // Tab state let (active_tab, set_active_tab) = signal("my_inventory"); // My Inventory state let (items, set_items) = signal(Vec::::new()); let (loading, set_loading) = signal(false); let (error, set_error) = signal(Option::::None); let (selected_item, set_selected_item) = signal(Option::::None); let (dropping, set_dropping) = signal(false); // Server props state (with acquisition info for authenticated users) let (server_props, set_server_props) = signal(Vec::::new()); let (server_loading, set_server_loading) = signal(false); let (server_error, set_server_error) = signal(Option::::None); // Realm props state (with acquisition info for authenticated users) let (realm_props, set_realm_props) = signal(Vec::::new()); let (realm_loading, set_realm_loading) = signal(false); let (realm_error, set_realm_error) = signal(Option::::None); // Track if tabs have been loaded (to avoid re-fetching) let (my_inventory_loaded, set_my_inventory_loaded) = signal(false); let (server_loaded, set_server_loaded) = signal(false); let (realm_loaded, set_realm_loaded) = signal(false); // Trigger to refresh my inventory after acquisition let (inventory_refresh_trigger, set_inventory_refresh_trigger) = signal(0u32); // Fetch my inventory when popup opens or tab is selected #[cfg(feature = "hydrate")] { use gloo_net::http::Request; use leptos::task::spawn_local; Effect::new(move |_| { // Track refresh trigger to refetch after acquisition let _refresh = inventory_refresh_trigger.get(); if !open.get() { // Reset state when closing set_selected_item.set(None); set_my_inventory_loaded.set(false); set_server_loaded.set(false); set_realm_loaded.set(false); return; } // Only fetch if on my_inventory tab and not already loaded if active_tab.get() != "my_inventory" || my_inventory_loaded.get() { return; } set_loading.set(true); set_error.set(None); spawn_local(async move { let response = Request::get("/api/user/me/inventory").send().await; match response { Ok(resp) if resp.ok() => { if let Ok(data) = resp .json::() .await { set_items.set(data.items); set_my_inventory_loaded.set(true); } else { set_error.set(Some("Failed to parse inventory data".to_string())); } } Ok(resp) => { set_error.set(Some(format!("Failed to load inventory: {}", resp.status()))); } Err(e) => { set_error.set(Some(format!("Network error: {}", e))); } } set_loading.set(false); }); }); } // Fetch server props when server tab is selected // Uses status endpoint if authenticated (non-guest), otherwise basic endpoint #[cfg(feature = "hydrate")] { use gloo_net::http::Request; use leptos::task::spawn_local; Effect::new(move |_| { if !open.get() || active_tab.get() != "server" || server_loaded.get() { return; } set_server_loading.set(true); set_server_error.set(None); spawn_local(async move { // Single endpoint returns enriched data if authenticated let response = Request::get("/api/server/inventory").send().await; match response { Ok(resp) if resp.ok() => { if let Ok(data) = resp .json::() .await { set_server_props.set(data.props); set_server_loaded.set(true); } else { set_server_error .set(Some("Failed to parse server props".to_string())); } } Ok(resp) => { set_server_error.set(Some(format!( "Failed to load server props: {}", resp.status() ))); } Err(e) => { set_server_error.set(Some(format!("Network error: {}", e))); } } set_server_loading.set(false); }); }); } // Fetch realm props when realm tab is selected #[cfg(feature = "hydrate")] { use gloo_net::http::Request; use leptos::task::spawn_local; Effect::new(move |_| { if !open.get() || active_tab.get() != "realm" || realm_loaded.get() { return; } let slug = realm_slug.get(); if slug.is_empty() { set_realm_error.set(Some("No realm selected".to_string())); return; } set_realm_loading.set(true); set_realm_error.set(None); spawn_local(async move { // Single endpoint returns enriched data if authenticated let endpoint = format!("/api/realms/{}/inventory", slug); let response = Request::get(&endpoint).send().await; match response { Ok(resp) if resp.ok() => { if let Ok(data) = resp .json::() .await { set_realm_props.set(data.props); set_realm_loaded.set(true); } else { set_realm_error .set(Some("Failed to parse realm props".to_string())); } } Ok(resp) => { set_realm_error.set(Some(format!( "Failed to load realm props: {}", resp.status() ))); } Err(e) => { set_realm_error.set(Some(format!("Network error: {}", e))); } } set_realm_loading.set(false); }); }); } // Handle drop action via WebSocket #[cfg(feature = "hydrate")] let handle_drop = { move |item_id: Uuid| { set_dropping.set(true); // Send drop command via WebSocket ws_sender.with_value(|sender| { if let Some(send_fn) = sender { send_fn(ClientMessage::DropProp { inventory_item_id: item_id, }); // Optimistically remove from local list set_items.update(|items| { items.retain(|i| i.id != item_id); }); set_selected_item.set(None); } else { set_error.set(Some("Not connected to server".to_string())); } }); set_dropping.set(false); } }; #[cfg(not(feature = "hydrate"))] let handle_drop = |_item_id: Uuid| {}; view! {
// Tab bar // Tab content
// My Inventory tab // Server tab // Realm tab
// Guest locked overlay
} } /// My Inventory tab content with drop functionality. #[component] fn MyInventoryTab( #[prop(into)] items: Signal>, #[prop(into)] loading: Signal, #[prop(into)] error: Signal>, #[prop(into)] selected_item: Signal>, set_selected_item: WriteSignal>, #[prop(into)] dropping: Signal, #[prop(into)] on_drop: Callback, ) -> impl IntoView { view! { // Loading state

"Loading inventory..."

// Error state

{move || error.get().unwrap_or_default()}

// Empty state

"Your inventory is empty"

"Collect props to see them here"

// Grid of items
} } />
// Selected item details and actions {move || { let item_id = selected_item.get()?; let item = items.get().into_iter().find(|i| i.id == item_id)?; let on_drop = on_drop.clone(); let is_dropping = dropping.get(); let is_droppable = item.is_droppable; Some(view! {

{item.prop_name.clone()}

{if item.is_transferable { "Transferable" } else { "Not transferable" }} {if item.is_portable { " \u{2022} Portable" } else { "" }} {if is_droppable { " \u{2022} Droppable" } else { " \u{2022} Essential" }}

// Drop button - disabled for non-droppable (essential) props // Transfer button (disabled for now)
}) }}
} } /// Acquisition props tab content with acquire functionality. #[component] fn AcquisitionPropsTab( #[prop(into)] props: Signal>, set_props: WriteSignal>, #[prop(into)] loading: Signal, #[prop(into)] error: Signal>, tab_name: &'static str, empty_message: &'static str, #[prop(into)] is_guest: Signal, /// Static endpoint for server props (e.g., "/api/server/inventory/request") #[prop(optional)] acquire_endpoint: Option<&'static str>, /// Whether this is a realm props tab (uses dynamic endpoint with slug) #[prop(optional, default = false)] acquire_endpoint_is_realm: bool, /// Realm slug for realm prop acquisition (required if acquire_endpoint_is_realm is true) #[prop(optional, into)] realm_slug: Signal, #[prop(into)] on_acquired: Callback<()>, ) -> impl IntoView { // Selected prop for showing details let (selected_prop, set_selected_prop) = signal(Option::::None); let (acquiring, set_acquiring) = signal(false); let (acquire_error, set_acquire_error) = signal(Option::::None); // Handle acquire action let acquire_endpoint_opt = acquire_endpoint.map(|s| s.to_string()); let do_acquire = Callback::new(move |prop_id: Uuid| { #[cfg(feature = "hydrate")] { set_acquiring.set(true); set_acquire_error.set(None); let endpoint = if acquire_endpoint_is_realm { let slug = realm_slug.get(); if slug.is_empty() { set_acquire_error.set(Some("No realm selected".to_string())); set_acquiring.set(false); return; } format!("/api/realms/{}/inventory/request", slug) } else { acquire_endpoint_opt.clone().unwrap_or_default() }; let on_acquired = on_acquired.clone(); leptos::task::spawn_local(async move { use gloo_net::http::Request; // Simple body with just prop_id - realm_id comes from URL for realm props let body = serde_json::json!({ "prop_id": prop_id }); let response = Request::post(&endpoint) .header("Content-Type", "application/json") .body(body.to_string()) .unwrap() .send() .await; match response { Ok(resp) if resp.ok() => { // Update local state to mark prop as owned set_props.update(|props| { if let Some(prop) = props.iter_mut().find(|p| p.id == prop_id) { prop.user_owns = true; } }); // Notify parent to refresh inventory on_acquired.run(()); } Ok(resp) => { // Try to parse error message from response if let Ok(error_json) = resp.json::().await { let error_msg = error_json .get("error") .and_then(|e| e.as_str()) .unwrap_or("Unknown error"); set_acquire_error.set(Some(error_msg.to_string())); } else { set_acquire_error.set(Some(format!( "Failed to acquire prop: {}", resp.status() ))); } } Err(e) => { set_acquire_error.set(Some(format!("Network error: {}", e))); } } set_acquiring.set(false); }); } }); view! { // Loading state

{format!("Loading {} props...", tab_name.to_lowercase())}

// Error state

{move || error.get().unwrap_or_default()}

// Acquire error state

{move || acquire_error.get().unwrap_or_default()}

// Empty state

{empty_message}

"Public props will appear here when available"

// Grid of props
// Ownership badge } } />
// Selected prop details with acquire button {move || { let prop_id = selected_prop.get()?; let prop = props.get().into_iter().find(|p| p.id == prop_id)?; let guest = is_guest.get(); let is_acquiring = acquiring.get(); // Determine button state let (button_text, button_class, button_disabled, button_title) = if guest { ("Sign in to Acquire", "bg-gray-600 text-gray-400 cursor-not-allowed", true, "Guests cannot acquire props") } else if prop.user_owns { ("Already Owned", "bg-green-700 text-white cursor-default", true, "You already own this prop") } else if prop.is_claimed && prop.is_unique { ("Claimed", "bg-red-700 text-white cursor-not-allowed", true, "This unique prop has been claimed by another user") } else if !prop.is_available { ("Unavailable", "bg-gray-600 text-gray-400 cursor-not-allowed", true, "This prop is not currently available") } else if is_acquiring { ("Acquiring...", "bg-blue-600 text-white opacity-50", true, "") } else { ("Acquire", "bg-blue-600 hover:bg-blue-700 text-white", false, "Add this prop to your inventory") }; let prop_name = prop.name.clone(); let prop_description = prop.description.clone(); Some(view! {

{prop_name}

{prop_description.map(|desc| view! {

{desc}

})}
}) }}
} }