refactor wip
This commit is contained in:
		
							
								
								
									
										365
									
								
								portal/REFACTORING_IMPLEMENTATION_PLAN.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										365
									
								
								portal/REFACTORING_IMPLEMENTATION_PLAN.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,365 @@ | ||||
| # Resident Registration Refactoring Implementation Plan | ||||
|  | ||||
| ## Overview | ||||
| This document outlines the detailed implementation plan for refactoring the resident registration components into reusable generic components. | ||||
|  | ||||
| ## Phase 1: Generic Components Implementation | ||||
|  | ||||
| ### Directory Structure | ||||
| ``` | ||||
| portal/src/components/ | ||||
| ├── common/                    # New generic components | ||||
| │   ├── forms/ | ||||
| │   │   ├── multi_step_form.rs | ||||
| │   │   ├── step_validator.rs | ||||
| │   │   ├── validation_result.rs | ||||
| │   │   └── mod.rs | ||||
| │   ├── payments/ | ||||
| │   │   ├── stripe_provider.rs | ||||
| │   │   ├── stripe_payment_form.rs | ||||
| │   │   ├── payment_intent.rs | ||||
| │   │   └── mod.rs | ||||
| │   ├── ui/ | ||||
| │   │   ├── progress_indicator.rs | ||||
| │   │   ├── validation_toast.rs | ||||
| │   │   ├── loading_spinner.rs | ||||
| │   │   └── mod.rs | ||||
| │   └── mod.rs | ||||
| ├── resident_registration/     # Existing (to be refactored) | ||||
| │   ├── simple_resident_wizard.rs | ||||
| │   ├── step_payment_stripe.rs | ||||
| │   ├── simple_step_info.rs | ||||
| │   ├── residence_card.rs | ||||
| │   └── mod.rs | ||||
| └── mod.rs | ||||
| ``` | ||||
|  | ||||
| ## Component Specifications | ||||
|  | ||||
| ### 1. MultiStepForm (`common/forms/multi_step_form.rs`) | ||||
|  | ||||
| #### Core Traits | ||||
| ```rust | ||||
| pub trait FormStep<T: Clone + PartialEq> { | ||||
|     fn render(&self, ctx: &Context<MultiStepForm<T>>, data: &T) -> Html; | ||||
|     fn get_title(&self) -> &'static str; | ||||
|     fn get_description(&self) -> &'static str; | ||||
|     fn get_icon(&self) -> &'static str; | ||||
| } | ||||
|  | ||||
| pub trait StepValidator<T> { | ||||
|     fn validate(&self, data: &T) -> ValidationResult; | ||||
| } | ||||
| ``` | ||||
|  | ||||
| #### MultiStepForm Component | ||||
| ```rust | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct MultiStepFormProps<T: Clone + PartialEq + 'static> { | ||||
|     pub form_data: T, | ||||
|     pub on_form_change: Callback<T>, | ||||
|     pub on_complete: Callback<T>, | ||||
|     pub on_cancel: Option<Callback<()>>, | ||||
|     pub steps: Vec<Box<dyn FormStep<T>>>, | ||||
|     pub validators: HashMap<usize, Box<dyn StepValidator<T>>>, | ||||
|     pub show_progress: bool, | ||||
|     pub allow_skip_validation: bool, | ||||
| } | ||||
|  | ||||
| pub enum MultiStepFormMsg<T> { | ||||
|     NextStep, | ||||
|     PrevStep, | ||||
|     GoToStep(usize), | ||||
|     UpdateFormData(T), | ||||
|     Complete, | ||||
|     Cancel, | ||||
|     HideValidationToast, | ||||
| } | ||||
|  | ||||
| pub struct MultiStepForm<T: Clone + PartialEq> { | ||||
|     current_step: usize, | ||||
|     form_data: T, | ||||
|     validation_errors: Vec<String>, | ||||
|     show_validation_toast: bool, | ||||
|     processing: bool, | ||||
| } | ||||
| ``` | ||||
|  | ||||
| #### Key Features | ||||
| - Generic over form data type `T` | ||||
| - Dynamic step registration via props | ||||
| - Validation per step | ||||
| - Progress indicator | ||||
| - Navigation controls | ||||
| - Error handling and display | ||||
|  | ||||
| ### 2. StripeProvider (`common/payments/stripe_provider.rs`) | ||||
|  | ||||
| #### Core Traits | ||||
| ```rust | ||||
| pub trait PaymentIntentCreator { | ||||
|     type FormData; | ||||
|      | ||||
|     fn create_payment_intent(&self, data: &Self::FormData) -> Result<PaymentIntentRequest, String>; | ||||
|     fn get_amount(&self, data: &Self::FormData) -> f64; | ||||
|     fn get_description(&self, data: &Self::FormData) -> String; | ||||
|     fn get_metadata(&self, data: &Self::FormData) -> HashMap<String, String>; | ||||
| } | ||||
| ``` | ||||
|  | ||||
| #### StripeProvider Component | ||||
| ```rust | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct StripeProviderProps<T: Clone + PartialEq + 'static> { | ||||
|     pub form_data: T, | ||||
|     pub payment_creator: Box<dyn PaymentIntentCreator<FormData = T>>, | ||||
|     pub on_payment_complete: Callback<T>, | ||||
|     pub on_payment_error: Callback<String>, | ||||
|     pub endpoint_url: String, | ||||
|     pub auto_create_intent: bool, | ||||
| } | ||||
|  | ||||
| pub enum StripeProviderMsg { | ||||
|     CreatePaymentIntent, | ||||
|     PaymentIntentCreated(String), | ||||
|     PaymentIntentError(String), | ||||
|     ProcessPayment, | ||||
|     PaymentComplete, | ||||
|     PaymentError(String), | ||||
| } | ||||
|  | ||||
| pub struct StripeProvider<T: Clone + PartialEq> { | ||||
|     client_secret: Option<String>, | ||||
|     processing_payment: bool, | ||||
|     processing_intent: bool, | ||||
|     error: Option<String>, | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ### 3. StripePaymentForm (`common/payments/stripe_payment_form.rs`) | ||||
|  | ||||
| #### Component Structure | ||||
| ```rust | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct StripePaymentFormProps { | ||||
|     pub client_secret: Option<String>, | ||||
|     pub amount: f64, | ||||
|     pub currency: String, | ||||
|     pub description: String, | ||||
|     pub processing: bool, | ||||
|     pub on_payment_complete: Callback<()>, | ||||
|     pub on_payment_error: Callback<String>, | ||||
|     pub show_amount: bool, | ||||
|     pub custom_button_text: Option<String>, | ||||
| } | ||||
|  | ||||
| pub struct StripePaymentForm { | ||||
|     elements_initialized: bool, | ||||
|     payment_error: Option<String>, | ||||
| } | ||||
| ``` | ||||
|  | ||||
| #### Key Features | ||||
| - Stripe Elements integration | ||||
| - Customizable payment button | ||||
| - Amount display | ||||
| - Error handling | ||||
| - Loading states | ||||
|  | ||||
| ### 4. UI Components | ||||
|  | ||||
| #### ProgressIndicator (`common/ui/progress_indicator.rs`) | ||||
| ```rust | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct ProgressIndicatorProps { | ||||
|     pub current_step: usize, | ||||
|     pub total_steps: usize, | ||||
|     pub step_titles: Vec<String>, | ||||
|     pub completed_steps: Vec<usize>, | ||||
|     pub show_step_numbers: bool, | ||||
|     pub show_step_titles: bool, | ||||
|     pub variant: ProgressVariant, | ||||
| } | ||||
|  | ||||
| #[derive(Clone, PartialEq)] | ||||
| pub enum ProgressVariant { | ||||
|     Dots, | ||||
|     Line, | ||||
|     Steps, | ||||
| } | ||||
| ``` | ||||
|  | ||||
| #### ValidationToast (`common/ui/validation_toast.rs`) | ||||
| ```rust | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct ValidationToastProps { | ||||
|     pub errors: Vec<String>, | ||||
|     pub show: bool, | ||||
|     pub on_close: Callback<()>, | ||||
|     pub auto_hide_duration: Option<u32>, | ||||
|     pub toast_type: ToastType, | ||||
| } | ||||
|  | ||||
| #[derive(Clone, PartialEq)] | ||||
| pub enum ToastType { | ||||
|     Error, | ||||
|     Warning, | ||||
|     Info, | ||||
|     Success, | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ## Implementation Steps | ||||
|  | ||||
| ### Step 1: Create Base Structure | ||||
| 1. Create `portal/src/components/common/` directory | ||||
| 2. Create module files (`mod.rs`) for each subdirectory | ||||
| 3. Update main components `mod.rs` to include common module | ||||
|  | ||||
| ### Step 2: Implement Core Traits and Types | ||||
| 1. Create `validation_result.rs` with `ValidationResult` type | ||||
| 2. Create `payment_intent.rs` with payment-related types | ||||
| 3. Implement base traits in respective modules | ||||
|  | ||||
| ### Step 3: Implement MultiStepForm | ||||
| 1. Create the generic `MultiStepForm` component | ||||
| 2. Implement step navigation logic | ||||
| 3. Add validation integration | ||||
| 4. Create progress indicator integration | ||||
|  | ||||
| ### Step 4: Implement Stripe Components | ||||
| 1. Create `StripeProvider` for payment intent management | ||||
| 2. Create `StripePaymentForm` for payment processing | ||||
| 3. Integrate with existing JavaScript Stripe functions | ||||
| 4. Add error handling and loading states | ||||
|  | ||||
| ### Step 5: Implement UI Components | ||||
| 1. Create `ProgressIndicator` component | ||||
| 2. Create `ValidationToast` component | ||||
| 3. Create `LoadingSpinner` component | ||||
| 4. Style components to match existing design | ||||
|  | ||||
| ### Step 6: Integration Testing | ||||
| 1. Create example usage in a test component | ||||
| 2. Verify all components work independently | ||||
| 3. Test component composition | ||||
| 4. Ensure TypeScript/JavaScript integration works | ||||
|  | ||||
| ## Phase 2: Refactor Resident Registration | ||||
|  | ||||
| ### Step 1: Create Specific Implementations | ||||
| 1. Create `ResidentFormStep` implementations | ||||
| 2. Create `ResidentStepValidator` implementations | ||||
| 3. Create `ResidentPaymentIntentCreator` implementation | ||||
|  | ||||
| ### Step 2: Replace Existing Components | ||||
| 1. Replace `SimpleResidentWizard` with `MultiStepForm` + specific steps | ||||
| 2. Replace `StepPaymentStripe` with `StripeProvider` + `StripePaymentForm` | ||||
| 3. Update `SimpleStepInfo` to work with new architecture | ||||
| 4. Keep `ResidenceCard` as-is (already reusable) | ||||
|  | ||||
| ### Step 3: Update Integration | ||||
| 1. Update parent components to use new architecture | ||||
| 2. Ensure all callbacks and data flow work correctly | ||||
| 3. Test complete registration flow | ||||
| 4. Verify Stripe integration still works | ||||
|  | ||||
| ### Step 4: Cleanup | ||||
| 1. Remove old components once new ones are proven | ||||
| 2. Update imports throughout the codebase | ||||
| 3. Update documentation | ||||
|  | ||||
| ## Testing Strategy | ||||
|  | ||||
| ### Unit Testing | ||||
| - Test each generic component independently | ||||
| - Test trait implementations | ||||
| - Test validation logic | ||||
| - Test payment intent creation | ||||
|  | ||||
| ### Integration Testing | ||||
| - Test complete form flow | ||||
| - Test payment processing | ||||
| - Test error scenarios | ||||
| - Test navigation and validation | ||||
|  | ||||
| ### Functionality Preservation | ||||
| - Ensure all existing features work exactly the same | ||||
| - Test edge cases and error conditions | ||||
| - Verify UI/UX remains consistent | ||||
| - Test browser compatibility | ||||
|  | ||||
| ## Success Criteria | ||||
|  | ||||
| ### Generic Components | ||||
| - ✅ Components are truly reusable across different form types | ||||
| - ✅ Type-safe implementation with proper Rust patterns | ||||
| - ✅ Clean separation of concerns | ||||
| - ✅ Easy to test and maintain | ||||
| - ✅ Well-documented with examples | ||||
|  | ||||
| ### Resident Registration | ||||
| - ✅ All existing functionality preserved | ||||
| - ✅ Same user experience | ||||
| - ✅ Same validation behavior | ||||
| - ✅ Same payment flow | ||||
| - ✅ Same error handling | ||||
|  | ||||
| ### Code Quality | ||||
| - ✅ Reduced code duplication | ||||
| - ✅ Better separation of concerns | ||||
| - ✅ More maintainable architecture | ||||
| - ✅ Easier to add new form types | ||||
| - ✅ Easier to modify payment logic | ||||
|  | ||||
| ## Future Extensibility | ||||
|  | ||||
| ### Additional Form Types | ||||
| The generic components should easily support: | ||||
| - Company registration forms | ||||
| - Service subscription forms | ||||
| - Profile update forms | ||||
| - Settings forms | ||||
|  | ||||
| ### Additional Payment Providers | ||||
| The payment architecture should allow: | ||||
| - PayPal integration | ||||
| - Cryptocurrency payments | ||||
| - Bank transfer payments | ||||
| - Multiple payment methods per form | ||||
|  | ||||
| ### Additional UI Variants | ||||
| The UI components should support: | ||||
| - Different themes | ||||
| - Mobile-optimized layouts | ||||
| - Accessibility features | ||||
| - Internationalization | ||||
|  | ||||
| ## Risk Mitigation | ||||
|  | ||||
| ### Breaking Changes | ||||
| - Keep old components until new ones are fully tested | ||||
| - Implement feature flags for gradual rollout | ||||
| - Maintain backward compatibility during transition | ||||
|  | ||||
| ### Performance | ||||
| - Ensure generic components don't add significant overhead | ||||
| - Optimize re-renders with proper memoization | ||||
| - Test with large forms and complex validation | ||||
|  | ||||
| ### Complexity | ||||
| - Start with minimal viable implementation | ||||
| - Add features incrementally | ||||
| - Document usage patterns clearly | ||||
| - Provide migration guides | ||||
|  | ||||
| ## Next Steps | ||||
|  | ||||
| 1. **Review and Approve Plan** - Get stakeholder approval for this approach | ||||
| 2. **Switch to Code Mode** - Begin implementation of generic components | ||||
| 3. **Iterative Development** - Implement and test each component separately | ||||
| 4. **Integration Testing** - Test components together before refactoring existing code | ||||
| 5. **Gradual Migration** - Replace existing components one at a time | ||||
| 6. **Documentation** - Create usage examples and migration guides | ||||
|  | ||||
| This plan ensures a systematic approach to creating reusable components while preserving all existing functionality. | ||||
							
								
								
									
										9
									
								
								portal/src/components/common/forms/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								portal/src/components/common/forms/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| //! Generic form components for multi-step forms and validation | ||||
|  | ||||
| pub mod multi_step_form; | ||||
| pub mod step_validator; | ||||
| pub mod validation_result; | ||||
|  | ||||
| pub use multi_step_form::{MultiStepForm, FormStep}; | ||||
| pub use step_validator::StepValidator; | ||||
| pub use validation_result::ValidationResult; | ||||
							
								
								
									
										384
									
								
								portal/src/components/common/forms/multi_step_form.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										384
									
								
								portal/src/components/common/forms/multi_step_form.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,384 @@ | ||||
| //! Generic multi-step form component | ||||
|  | ||||
| use yew::prelude::*; | ||||
| use gloo::timers::callback::Timeout; | ||||
| use std::collections::HashMap; | ||||
| use std::rc::Rc; | ||||
|  | ||||
| use super::{StepValidator, ValidationResult}; | ||||
|  | ||||
| /// Trait for defining form steps | ||||
| pub trait FormStep<T: Clone + PartialEq + 'static> { | ||||
|     /// Render the step content | ||||
|     fn render(&self, ctx: &Context<MultiStepForm<T>>, data: &T) -> Html; | ||||
|      | ||||
|     /// Get the step title | ||||
|     fn get_title(&self) -> &'static str; | ||||
|      | ||||
|     /// Get the step description | ||||
|     fn get_description(&self) -> &'static str; | ||||
|      | ||||
|     /// Get the step icon (Bootstrap icon class) | ||||
|     fn get_icon(&self) -> &'static str; | ||||
|      | ||||
|     /// Whether this step can be skipped (optional) | ||||
|     fn can_skip(&self) -> bool { | ||||
|         false | ||||
|     } | ||||
|      | ||||
|     /// Whether this step should show navigation buttons (optional) | ||||
|     fn show_navigation(&self) -> bool { | ||||
|         true | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Properties for MultiStepForm component | ||||
| #[derive(Properties)] | ||||
| pub struct MultiStepFormProps<T: Clone + PartialEq + 'static> { | ||||
|     /// Current form data | ||||
|     pub form_data: T, | ||||
|      | ||||
|     /// Callback when form data changes | ||||
|     pub on_form_change: Callback<T>, | ||||
|      | ||||
|     /// Callback when form is completed | ||||
|     pub on_complete: Callback<T>, | ||||
|      | ||||
|     /// Optional callback when form is cancelled | ||||
|     #[prop_or_default] | ||||
|     pub on_cancel: Option<Callback<()>>, | ||||
|      | ||||
|     /// Form steps | ||||
|     pub steps: Vec<Rc<dyn FormStep<T>>>, | ||||
|      | ||||
|     /// Step validators (step index -> validator) | ||||
|     #[prop_or_default] | ||||
|     pub validators: HashMap<usize, Rc<dyn StepValidator<T>>>, | ||||
|      | ||||
|     /// Whether to show progress indicator | ||||
|     #[prop_or(true)] | ||||
|     pub show_progress: bool, | ||||
|      | ||||
|     /// Whether to allow skipping validation for testing | ||||
|     #[prop_or(false)] | ||||
|     pub allow_skip_validation: bool, | ||||
|      | ||||
|      | ||||
|     /// Auto-hide validation toast duration in milliseconds | ||||
|     #[prop_or(5000)] | ||||
|     pub validation_toast_duration: u32, | ||||
| } | ||||
|  | ||||
| impl<T: Clone + PartialEq + 'static> PartialEq for MultiStepFormProps<T> { | ||||
|     fn eq(&self, other: &Self) -> bool { | ||||
|         self.form_data == other.form_data | ||||
|             && self.steps.len() == other.steps.len() | ||||
|             && self.show_progress == other.show_progress | ||||
|             && self.allow_skip_validation == other.allow_skip_validation | ||||
|             && self.validation_toast_duration == other.validation_toast_duration | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Messages for MultiStepForm component | ||||
| pub enum MultiStepFormMsg<T> { | ||||
|     NextStep, | ||||
|     PrevStep, | ||||
|     GoToStep(usize), | ||||
|     UpdateFormData(T), | ||||
|     Complete, | ||||
|     Cancel, | ||||
|     HideValidationToast, | ||||
| } | ||||
|  | ||||
| /// MultiStepForm component state | ||||
| pub struct MultiStepForm<T: Clone + PartialEq> { | ||||
|     current_step: usize, | ||||
|     form_data: T, | ||||
|     validation_errors: Vec<String>, | ||||
|     show_validation_toast: bool, | ||||
|     processing: bool, | ||||
| } | ||||
|  | ||||
| impl<T: Clone + PartialEq + 'static> Component for MultiStepForm<T> { | ||||
|     type Message = MultiStepFormMsg<T>; | ||||
|     type Properties = MultiStepFormProps<T>; | ||||
|  | ||||
|     fn create(ctx: &Context<Self>) -> Self { | ||||
|         Self { | ||||
|             current_step: 0, | ||||
|             form_data: ctx.props().form_data.clone(), | ||||
|             validation_errors: Vec::new(), | ||||
|             show_validation_toast: false, | ||||
|             processing: false, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool { | ||||
|         match msg { | ||||
|             MultiStepFormMsg::NextStep => { | ||||
|                 // Validate current step unless skipping is allowed | ||||
|                 if !ctx.props().allow_skip_validation { | ||||
|                     if let Some(validator) = ctx.props().validators.get(&self.current_step) { | ||||
|                         let validation_result = validator.validate(&self.form_data); | ||||
|                         if !validation_result.is_valid() { | ||||
|                             self.validation_errors = validation_result.errors().to_vec(); | ||||
|                             self.show_validation_toast = true; | ||||
|                             self.auto_hide_validation_toast(ctx); | ||||
|                             return true; | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 // Move to next step if not at the end | ||||
|                 if self.current_step < ctx.props().steps.len() - 1 { | ||||
|                     self.current_step += 1; | ||||
|                     true | ||||
|                 } else { | ||||
|                     // At the last step, complete the form | ||||
|                     ctx.link().send_message(MultiStepFormMsg::Complete); | ||||
|                     false | ||||
|                 } | ||||
|             } | ||||
|             MultiStepFormMsg::PrevStep => { | ||||
|                 if self.current_step > 0 { | ||||
|                     self.current_step -= 1; | ||||
|                     true | ||||
|                 } else { | ||||
|                     false | ||||
|                 } | ||||
|             } | ||||
|             MultiStepFormMsg::GoToStep(step) => { | ||||
|                 if step < ctx.props().steps.len() { | ||||
|                     self.current_step = step; | ||||
|                     true | ||||
|                 } else { | ||||
|                     false | ||||
|                 } | ||||
|             } | ||||
|             MultiStepFormMsg::UpdateFormData(new_data) => { | ||||
|                 self.form_data = new_data.clone(); | ||||
|                 ctx.props().on_form_change.emit(new_data); | ||||
|                 true | ||||
|             } | ||||
|             MultiStepFormMsg::Complete => { | ||||
|                 self.processing = true; | ||||
|                 ctx.props().on_complete.emit(self.form_data.clone()); | ||||
|                 true | ||||
|             } | ||||
|             MultiStepFormMsg::Cancel => { | ||||
|                 if let Some(on_cancel) = &ctx.props().on_cancel { | ||||
|                     on_cancel.emit(()); | ||||
|                 } | ||||
|                 false | ||||
|             } | ||||
|             MultiStepFormMsg::HideValidationToast => { | ||||
|                 self.show_validation_toast = false; | ||||
|                 true | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn changed(&mut self, ctx: &Context<Self>, _old_props: &Self::Properties) -> bool { | ||||
|         // Update form data if it changed from parent | ||||
|         if self.form_data != ctx.props().form_data { | ||||
|             self.form_data = ctx.props().form_data.clone(); | ||||
|             true | ||||
|         } else { | ||||
|             false | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn view(&self, ctx: &Context<Self>) -> Html { | ||||
|         html! { | ||||
|             <div class="h-100 d-flex flex-column"> | ||||
|                 {if ctx.props().show_progress { | ||||
|                     self.render_progress_indicator(ctx) | ||||
|                 } else { | ||||
|                     html! {} | ||||
|                 }} | ||||
|                  | ||||
|                 <form class="flex-grow-1 overflow-auto"> | ||||
|                     {self.render_current_step(ctx)} | ||||
|                 </form> | ||||
|  | ||||
|                 {self.render_navigation(ctx)} | ||||
|  | ||||
|                 {if self.show_validation_toast { | ||||
|                     self.render_validation_toast(ctx) | ||||
|                 } else { | ||||
|                     html! {} | ||||
|                 }} | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl<T: Clone + PartialEq + 'static> MultiStepForm<T> { | ||||
|     fn render_current_step(&self, ctx: &Context<Self>) -> Html { | ||||
|         if let Some(step) = ctx.props().steps.get(self.current_step) { | ||||
|             step.render(ctx, &self.form_data) | ||||
|         } else { | ||||
|             html! { <div class="alert alert-danger">{"Invalid step"}</div> } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn render_progress_indicator(&self, ctx: &Context<Self>) -> Html { | ||||
|         let total_steps = ctx.props().steps.len(); | ||||
|          | ||||
|         html! { | ||||
|             <div class="mb-3"> | ||||
|                 <div class="d-flex align-items-center justify-content-center"> | ||||
|                     {for (0..total_steps).map(|step_index| { | ||||
|                         let is_current = step_index == self.current_step; | ||||
|                         let is_completed = step_index < self.current_step; | ||||
|                         let step_class = if is_current { | ||||
|                             "bg-primary text-white" | ||||
|                         } else if is_completed { | ||||
|                             "bg-success text-white" | ||||
|                         } else { | ||||
|                             "bg-white text-muted border" | ||||
|                         }; | ||||
|                          | ||||
|                         html! { | ||||
|                             <div class="d-flex align-items-center"> | ||||
|                                 <div class={format!("rounded-circle d-flex align-items-center justify-content-center {} fw-bold", step_class)} | ||||
|                                      style="width: 28px; height: 28px; font-size: 12px;"> | ||||
|                                     {if is_completed { | ||||
|                                         html! { <i class="bi bi-check"></i> } | ||||
|                                     } else { | ||||
|                                         html! { {step_index + 1} } | ||||
|                                     }} | ||||
|                                 </div> | ||||
|                                 {if step_index < total_steps - 1 { | ||||
|                                     html! { | ||||
|                                         <div class={format!("mx-1 {}", if is_completed { "bg-success" } else { "bg-secondary" })} | ||||
|                                              style="height: 2px; width: 24px;"></div> | ||||
|                                     } | ||||
|                                 } else { | ||||
|                                     html! {} | ||||
|                                 }} | ||||
|                             </div> | ||||
|                         } | ||||
|                     })} | ||||
|                 </div> | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn render_navigation(&self, ctx: &Context<Self>) -> Html { | ||||
|         let current_step_obj = ctx.props().steps.get(self.current_step); | ||||
|         let show_nav = current_step_obj.map(|s| s.show_navigation()).unwrap_or(true); | ||||
|          | ||||
|         if !show_nav { | ||||
|             return html! {}; | ||||
|         } | ||||
|  | ||||
|         let link = ctx.link(); | ||||
|         let is_last_step = self.current_step >= ctx.props().steps.len() - 1; | ||||
|  | ||||
|         html! { | ||||
|             <div class="card-footer"> | ||||
|                 <div class="d-flex justify-content-between align-items-center"> | ||||
|                     // Previous button | ||||
|                     <div style="width: 120px;"> | ||||
|                         {if self.current_step > 0 { | ||||
|                             html! { | ||||
|                                 <button | ||||
|                                     type="button" | ||||
|                                     class="btn btn-outline-secondary" | ||||
|                                     onclick={link.callback(|_| MultiStepFormMsg::PrevStep)} | ||||
|                                     disabled={self.processing} | ||||
|                                 > | ||||
|                                     <i class="bi bi-arrow-left me-1"></i>{"Previous"} | ||||
|                                 </button> | ||||
|                             } | ||||
|                         } else if ctx.props().on_cancel.is_some() { | ||||
|                             html! { | ||||
|                                 <button | ||||
|                                     type="button" | ||||
|                                     class="btn btn-outline-secondary" | ||||
|                                     onclick={link.callback(|_| MultiStepFormMsg::Cancel)} | ||||
|                                     disabled={self.processing} | ||||
|                                 > | ||||
|                                     {"Cancel"} | ||||
|                                 </button> | ||||
|                             } | ||||
|                         } else { | ||||
|                             html! {} | ||||
|                         }} | ||||
|                     </div> | ||||
|  | ||||
|                     // Step info (center) | ||||
|                     <div class="text-center"> | ||||
|                         {if let Some(step) = current_step_obj { | ||||
|                             html! { | ||||
|                                 <div> | ||||
|                                     <h6 class="mb-0">{step.get_title()}</h6> | ||||
|                                     <small class="text-muted">{step.get_description()}</small> | ||||
|                                 </div> | ||||
|                             } | ||||
|                         } else { | ||||
|                             html! {} | ||||
|                         }} | ||||
|                     </div> | ||||
|  | ||||
|                     // Next/Complete button | ||||
|                     <div style="width: 150px;" class="text-end"> | ||||
|                         <button | ||||
|                             type="button" | ||||
|                             class={if is_last_step { "btn btn-success" } else { "btn btn-primary" }} | ||||
|                             onclick={link.callback(|_| MultiStepFormMsg::NextStep)} | ||||
|                             disabled={self.processing} | ||||
|                         > | ||||
|                             {if is_last_step { | ||||
|                                 html! { <>{"Complete"}<i class="bi bi-check ms-1"></i></> } | ||||
|                             } else { | ||||
|                                 html! { <>{"Next"}<i class="bi bi-arrow-right ms-1"></i></> } | ||||
|                             }} | ||||
|                         </button> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn render_validation_toast(&self, ctx: &Context<Self>) -> Html { | ||||
|         let link = ctx.link(); | ||||
|         let close_toast = link.callback(|_| MultiStepFormMsg::HideValidationToast); | ||||
|  | ||||
|         html! { | ||||
|             <div class="position-fixed bottom-0 start-50 translate-middle-x mb-3" style="z-index: 1055; max-width: 500px;"> | ||||
|                 <div class="toast show" role="alert" aria-live="assertive" aria-atomic="true"> | ||||
|                     <div class="toast-header bg-warning text-dark"> | ||||
|                         <i class="bi bi-exclamation-triangle me-2"></i> | ||||
|                         <strong class="me-auto">{"Validation Error"}</strong> | ||||
|                         <button type="button" class="btn-close" onclick={close_toast} aria-label="Close"></button> | ||||
|                     </div> | ||||
|                     <div class="toast-body"> | ||||
|                         <div class="mb-2"> | ||||
|                             <strong>{"Please fix the following issues:"}</strong> | ||||
|                         </div> | ||||
|                         <ul class="list-unstyled mb-0"> | ||||
|                             {for self.validation_errors.iter().map(|error| { | ||||
|                                 html! { | ||||
|                                     <li class="mb-1"> | ||||
|                                         <i class="bi bi-dot text-danger me-1"></i>{error} | ||||
|                                     </li> | ||||
|                                 } | ||||
|                             })} | ||||
|                         </ul> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn auto_hide_validation_toast(&self, ctx: &Context<Self>) { | ||||
|         let link = ctx.link().clone(); | ||||
|         let duration = ctx.props().validation_toast_duration; | ||||
|          | ||||
|         Timeout::new(duration, move || { | ||||
|             link.send_message(MultiStepFormMsg::HideValidationToast); | ||||
|         }).forget(); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										58
									
								
								portal/src/components/common/forms/step_validator.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								portal/src/components/common/forms/step_validator.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,58 @@ | ||||
| //! Step validation trait for multi-step forms | ||||
|  | ||||
| use super::ValidationResult; | ||||
|  | ||||
| /// Trait for validating form data at specific steps | ||||
| pub trait StepValidator<T> { | ||||
|     /// Validate the form data for this step | ||||
|     fn validate(&self, data: &T) -> ValidationResult; | ||||
|      | ||||
|     /// Get the step number this validator is for (optional, for debugging) | ||||
|     fn step_number(&self) -> Option<usize> { | ||||
|         None | ||||
|     } | ||||
|      | ||||
|     /// Get a description of what this validator checks (optional, for debugging) | ||||
|     fn description(&self) -> Option<&'static str> { | ||||
|         None | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// A simple validator that always passes (useful for steps with no validation) | ||||
| pub struct NoOpValidator; | ||||
|  | ||||
| impl<T> StepValidator<T> for NoOpValidator { | ||||
|     fn validate(&self, _data: &T) -> ValidationResult { | ||||
|         ValidationResult::valid() | ||||
|     } | ||||
|      | ||||
|     fn description(&self) -> Option<&'static str> { | ||||
|         Some("No validation required") | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// A validator that combines multiple validators | ||||
| pub struct CompositeValidator<T> { | ||||
|     validators: Vec<Box<dyn StepValidator<T>>>, | ||||
| } | ||||
|  | ||||
| impl<T> CompositeValidator<T> { | ||||
|     pub fn new(validators: Vec<Box<dyn StepValidator<T>>>) -> Self { | ||||
|         Self { validators } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl<T> StepValidator<T> for CompositeValidator<T> { | ||||
|     fn validate(&self, data: &T) -> ValidationResult { | ||||
|         let results: Vec<ValidationResult> = self.validators | ||||
|             .iter() | ||||
|             .map(|validator| validator.validate(data)) | ||||
|             .collect(); | ||||
|          | ||||
|         ValidationResult::combine(results) | ||||
|     } | ||||
|      | ||||
|     fn description(&self) -> Option<&'static str> { | ||||
|         Some("Composite validator") | ||||
|     } | ||||
| } | ||||
							
								
								
									
										69
									
								
								portal/src/components/common/forms/validation_result.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								portal/src/components/common/forms/validation_result.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,69 @@ | ||||
| //! Validation result types for form validation | ||||
|  | ||||
| /// Result of form validation containing success status and error messages | ||||
| #[derive(Clone, PartialEq, Debug)] | ||||
| pub struct ValidationResult { | ||||
|     pub is_valid: bool, | ||||
|     pub errors: Vec<String>, | ||||
| } | ||||
|  | ||||
| impl ValidationResult { | ||||
|     /// Create a successful validation result | ||||
|     pub fn valid() -> Self { | ||||
|         Self { | ||||
|             is_valid: true, | ||||
|             errors: Vec::new(), | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     /// Create a failed validation result with error messages | ||||
|     pub fn invalid(errors: Vec<String>) -> Self { | ||||
|         Self { | ||||
|             is_valid: false, | ||||
|             errors, | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     /// Create a failed validation result with a single error message | ||||
|     pub fn invalid_single(error: String) -> Self { | ||||
|         Self { | ||||
|             is_valid: false, | ||||
|             errors: vec![error], | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     /// Check if validation passed | ||||
|     pub fn is_valid(&self) -> bool { | ||||
|         self.is_valid | ||||
|     } | ||||
|      | ||||
|     /// Get all error messages | ||||
|     pub fn errors(&self) -> &[String] { | ||||
|         &self.errors | ||||
|     } | ||||
|      | ||||
|     /// Combine multiple validation results | ||||
|     pub fn combine(results: Vec<ValidationResult>) -> Self { | ||||
|         let mut all_errors = Vec::new(); | ||||
|         let mut all_valid = true; | ||||
|          | ||||
|         for result in results { | ||||
|             if !result.is_valid { | ||||
|                 all_valid = false; | ||||
|                 all_errors.extend(result.errors); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         if all_valid { | ||||
|             Self::valid() | ||||
|         } else { | ||||
|             Self::invalid(all_errors) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl Default for ValidationResult { | ||||
|     fn default() -> Self { | ||||
|         Self::valid() | ||||
|     } | ||||
| } | ||||
							
								
								
									
										10
									
								
								portal/src/components/common/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								portal/src/components/common/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| //! Common reusable components for forms, payments, and UI elements | ||||
|  | ||||
| pub mod forms; | ||||
| pub mod payments; | ||||
| pub mod ui; | ||||
|  | ||||
| // Re-export commonly used items | ||||
| pub use forms::{MultiStepForm, FormStep, StepValidator, ValidationResult}; | ||||
| pub use payments::{StripeProvider, StripePaymentForm, PaymentIntentCreator}; | ||||
| pub use ui::{ProgressIndicator, ValidationToast, LoadingSpinner}; | ||||
							
								
								
									
										9
									
								
								portal/src/components/common/payments/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								portal/src/components/common/payments/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| //! Generic payment components for Stripe and other payment providers | ||||
|  | ||||
| pub mod stripe_provider; | ||||
| pub mod stripe_payment_form; | ||||
| pub mod payment_intent; | ||||
|  | ||||
| pub use stripe_provider::{StripeProvider, PaymentIntentCreator}; | ||||
| pub use stripe_payment_form::StripePaymentForm; | ||||
| pub use payment_intent::{PaymentIntentRequest, PaymentIntentResponse, PaymentMetadata}; | ||||
							
								
								
									
										143
									
								
								portal/src/components/common/payments/payment_intent.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										143
									
								
								portal/src/components/common/payments/payment_intent.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,143 @@ | ||||
| //! Payment intent types and utilities | ||||
|  | ||||
| use std::collections::HashMap; | ||||
| use serde::{Deserialize, Serialize}; | ||||
|  | ||||
| /// Request data for creating a payment intent | ||||
| #[derive(Clone, Debug, Serialize, Deserialize)] | ||||
| pub struct PaymentIntentRequest { | ||||
|     /// Amount in the smallest currency unit (e.g., cents for USD) | ||||
|     pub amount: u64, | ||||
|      | ||||
|     /// Currency code (e.g., "usd", "eur") | ||||
|     pub currency: String, | ||||
|      | ||||
|     /// Description of the payment | ||||
|     pub description: String, | ||||
|      | ||||
|     /// Additional metadata for the payment | ||||
|     pub metadata: PaymentMetadata, | ||||
|      | ||||
|     /// Payment method types to allow | ||||
|     #[serde(default)] | ||||
|     pub payment_method_types: Vec<String>, | ||||
| } | ||||
|  | ||||
| impl PaymentIntentRequest { | ||||
|     /// Create a new payment intent request | ||||
|     pub fn new(amount: f64, currency: &str, description: &str) -> Self { | ||||
|         Self { | ||||
|             amount: (amount * 100.0) as u64, // Convert to cents | ||||
|             currency: currency.to_lowercase(), | ||||
|             description: description.to_string(), | ||||
|             metadata: PaymentMetadata::default(), | ||||
|             payment_method_types: vec!["card".to_string()], | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     /// Set metadata for the payment intent | ||||
|     pub fn with_metadata(mut self, metadata: PaymentMetadata) -> Self { | ||||
|         self.metadata = metadata; | ||||
|         self | ||||
|     } | ||||
|      | ||||
|     /// Add a metadata field | ||||
|     pub fn add_metadata(mut self, key: &str, value: &str) -> Self { | ||||
|         self.metadata.custom_fields.insert(key.to_string(), value.to_string()); | ||||
|         self | ||||
|     } | ||||
|      | ||||
|     /// Set payment method types | ||||
|     pub fn with_payment_methods(mut self, methods: Vec<String>) -> Self { | ||||
|         self.payment_method_types = methods; | ||||
|         self | ||||
|     } | ||||
|      | ||||
|     /// Get amount as a float (in main currency units) | ||||
|     pub fn amount_as_float(&self) -> f64 { | ||||
|         self.amount as f64 / 100.0 | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Metadata associated with a payment intent | ||||
| #[derive(Clone, Debug, Serialize, Deserialize)] | ||||
| pub struct PaymentMetadata { | ||||
|     /// Type of payment (e.g., "resident_registration", "company_registration") | ||||
|     pub payment_type: String, | ||||
|      | ||||
|     /// Customer information | ||||
|     pub customer_name: Option<String>, | ||||
|     pub customer_email: Option<String>, | ||||
|     pub customer_id: Option<String>, | ||||
|      | ||||
|     /// Additional custom fields | ||||
|     pub custom_fields: HashMap<String, String>, | ||||
| } | ||||
|  | ||||
| impl Default for PaymentMetadata { | ||||
|     fn default() -> Self { | ||||
|         Self { | ||||
|             payment_type: "generic".to_string(), | ||||
|             customer_name: None, | ||||
|             customer_email: None, | ||||
|             customer_id: None, | ||||
|             custom_fields: HashMap::new(), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl PaymentMetadata { | ||||
|     /// Create new payment metadata with a specific type | ||||
|     pub fn new(payment_type: &str) -> Self { | ||||
|         Self { | ||||
|             payment_type: payment_type.to_string(), | ||||
|             ..Default::default() | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     /// Set customer information | ||||
|     pub fn with_customer(mut self, name: Option<String>, email: Option<String>, id: Option<String>) -> Self { | ||||
|         self.customer_name = name; | ||||
|         self.customer_email = email; | ||||
|         self.customer_id = id; | ||||
|         self | ||||
|     } | ||||
|      | ||||
|     /// Add a custom field | ||||
|     pub fn add_field(mut self, key: &str, value: &str) -> Self { | ||||
|         self.custom_fields.insert(key.to_string(), value.to_string()); | ||||
|         self | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Response from payment intent creation | ||||
| #[derive(Clone, Debug, Deserialize)] | ||||
| pub struct PaymentIntentResponse { | ||||
|     /// The payment intent ID | ||||
|     pub id: String, | ||||
|      | ||||
|     /// Client secret for frontend use | ||||
|     pub client_secret: String, | ||||
|      | ||||
|     /// Amount of the payment intent | ||||
|     pub amount: u64, | ||||
|      | ||||
|     /// Currency of the payment intent | ||||
|     pub currency: String, | ||||
|      | ||||
|     /// Status of the payment intent | ||||
|     pub status: String, | ||||
| } | ||||
|  | ||||
| /// Error response from payment intent creation | ||||
| #[derive(Clone, Debug, Deserialize)] | ||||
| pub struct PaymentIntentError { | ||||
|     /// Error type | ||||
|     pub error_type: String, | ||||
|      | ||||
|     /// Error message | ||||
|     pub message: String, | ||||
|      | ||||
|     /// Error code (optional) | ||||
|     pub code: Option<String>, | ||||
| } | ||||
							
								
								
									
										309
									
								
								portal/src/components/common/payments/stripe_payment_form.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										309
									
								
								portal/src/components/common/payments/stripe_payment_form.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,309 @@ | ||||
| //! Generic Stripe payment form component | ||||
|  | ||||
| use yew::prelude::*; | ||||
| use wasm_bindgen::prelude::*; | ||||
| use wasm_bindgen_futures::spawn_local; | ||||
| use web_sys::console; | ||||
|  | ||||
| use super::stripe_provider::{use_stripe, StripeContext}; | ||||
|  | ||||
| #[wasm_bindgen] | ||||
| extern "C" { | ||||
|     #[wasm_bindgen(js_namespace = window)] | ||||
|     fn initializeStripeElements(client_secret: &str); | ||||
|      | ||||
|     #[wasm_bindgen(js_namespace = window)] | ||||
|     fn confirmStripePayment(client_secret: &str) -> js_sys::Promise; | ||||
| } | ||||
|  | ||||
| /// Properties for StripePaymentForm component | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct StripePaymentFormProps { | ||||
|     /// Amount to display (for UI purposes) | ||||
|     pub amount: f64, | ||||
|      | ||||
|     /// Currency code (e.g., "USD", "EUR") | ||||
|     #[prop_or("USD".to_string())] | ||||
|     pub currency: String, | ||||
|      | ||||
|     /// Payment description | ||||
|     #[prop_or("Payment".to_string())] | ||||
|     pub description: String, | ||||
|      | ||||
|     /// Whether to show the amount in the UI | ||||
|     #[prop_or(true)] | ||||
|     pub show_amount: bool, | ||||
|      | ||||
|     /// Custom button text | ||||
|     #[prop_or_default] | ||||
|     pub button_text: Option<AttrValue>, | ||||
|      | ||||
|     /// Whether the payment is currently processing | ||||
|     #[prop_or(false)] | ||||
|     pub processing: bool, | ||||
|      | ||||
|     /// Callback when payment is completed successfully | ||||
|     pub on_payment_complete: Callback<()>, | ||||
|      | ||||
|     /// Callback when payment fails | ||||
|     pub on_payment_error: Callback<String>, | ||||
| } | ||||
|  | ||||
| /// Messages for StripePaymentForm component | ||||
| pub enum StripePaymentFormMsg { | ||||
|     ProcessPayment, | ||||
|     PaymentComplete, | ||||
|     PaymentError(String), | ||||
| } | ||||
|  | ||||
| /// StripePaymentForm component state | ||||
| pub struct StripePaymentForm { | ||||
|     elements_initialized: bool, | ||||
|     payment_error: Option<String>, | ||||
|     stripe_context: Option<StripeContext>, | ||||
| } | ||||
|  | ||||
| impl Component for StripePaymentForm { | ||||
|     type Message = StripePaymentFormMsg; | ||||
|     type Properties = StripePaymentFormProps; | ||||
|  | ||||
|     fn create(_ctx: &Context<Self>) -> Self { | ||||
|         Self { | ||||
|             elements_initialized: false, | ||||
|             payment_error: None, | ||||
|             stripe_context: None, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool { | ||||
|         match msg { | ||||
|             StripePaymentFormMsg::ProcessPayment => { | ||||
|                 if let Some(stripe_ctx) = &self.stripe_context { | ||||
|                     if let Some(client_secret) = &stripe_ctx.client_secret { | ||||
|                         console::log_1(&"🔄 Processing payment with Stripe...".into()); | ||||
|                         self.process_payment(ctx, client_secret.clone()); | ||||
|                     } else { | ||||
|                         console::log_1(&"❌ No client secret available for payment".into()); | ||||
|                         self.payment_error = Some("Payment not ready. Please try again.".to_string()); | ||||
|                         ctx.props().on_payment_error.emit("Payment not ready. Please try again.".to_string()); | ||||
|                     } | ||||
|                 } else { | ||||
|                     console::log_1(&"❌ No Stripe context available".into()); | ||||
|                     self.payment_error = Some("Stripe not initialized. Please refresh the page.".to_string()); | ||||
|                     ctx.props().on_payment_error.emit("Stripe not initialized. Please refresh the page.".to_string()); | ||||
|                 } | ||||
|                 true | ||||
|             } | ||||
|             StripePaymentFormMsg::PaymentComplete => { | ||||
|                 console::log_1(&"✅ Payment completed successfully".into()); | ||||
|                 self.payment_error = None; | ||||
|                 ctx.props().on_payment_complete.emit(()); | ||||
|                 true | ||||
|             } | ||||
|             StripePaymentFormMsg::PaymentError(error) => { | ||||
|                 console::log_1(&format!("❌ Payment failed: {}", error).into()); | ||||
|                 self.payment_error = Some(error.clone()); | ||||
|                 ctx.props().on_payment_error.emit(error); | ||||
|                 true | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn changed(&mut self, _ctx: &Context<Self>, _old_props: &Self::Properties) -> bool { | ||||
|         // Context will be updated via view method | ||||
|         true | ||||
|     } | ||||
|  | ||||
|     fn rendered(&mut self, _ctx: &Context<Self>, first_render: bool) { | ||||
|         if first_render { | ||||
|             // Stripe context will be handled in view method | ||||
|             // Initialize Stripe Elements if needed | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn view(&self, ctx: &Context<Self>) -> Html { | ||||
|         // Get Stripe context from Yew context (not hook) | ||||
|         let stripe_ctx = ctx.link().context::<StripeContext>(Callback::noop()).map(|(ctx, _)| ctx); | ||||
|         let has_client_secret = stripe_ctx.as_ref() | ||||
|             .and_then(|ctx| ctx.client_secret.as_ref()) | ||||
|             .is_some(); | ||||
|         let creating_intent = stripe_ctx.as_ref() | ||||
|             .map(|ctx| ctx.creating_intent) | ||||
|             .unwrap_or(false); | ||||
|         let stripe_error = stripe_ctx.as_ref() | ||||
|             .and_then(|ctx| ctx.error.as_ref()); | ||||
|  | ||||
|         let can_process_payment = has_client_secret && !ctx.props().processing && !creating_intent; | ||||
|  | ||||
|         html! { | ||||
|             <div class="card"> | ||||
|                 {self.render_header(ctx)} | ||||
|                  | ||||
|                 <div class="card-body"> | ||||
|                     {if ctx.props().show_amount { | ||||
|                         self.render_amount_display(ctx) | ||||
|                     } else { | ||||
|                         html! {} | ||||
|                     }} | ||||
|                      | ||||
|                     {self.render_payment_element(ctx, has_client_secret, creating_intent)} | ||||
|                      | ||||
|                     {if can_process_payment { | ||||
|                         self.render_payment_button(ctx) | ||||
|                     } else { | ||||
|                         html! {} | ||||
|                     }} | ||||
|                      | ||||
|                     {self.render_errors(ctx, stripe_error)} | ||||
|                 </div> | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl StripePaymentForm { | ||||
|     fn render_header(&self, ctx: &Context<Self>) -> Html { | ||||
|         html! { | ||||
|             <div class="card-header" style="background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); border-bottom: 1px solid #e0e0e0;"> | ||||
|                 <h6 class="mb-0 text-dark" style="font-size: 0.85rem; font-weight: 600;"> | ||||
|                     <i class="bi bi-shield-check me-2" style="color: #6c757d;"></i> | ||||
|                     {"Secure Payment Processing"} | ||||
|                 </h6> | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn render_amount_display(&self, ctx: &Context<Self>) -> Html { | ||||
|         html! { | ||||
|             <div class="mb-3 p-3 rounded" style="background: #f8f9fa; border: 1px solid #e0e0e0;"> | ||||
|                 <div class="d-flex align-items-center justify-content-between"> | ||||
|                     <div> | ||||
|                         <div class="text-muted" style="font-size: 0.75rem; font-weight: 500;"> | ||||
|                             {&ctx.props().description} | ||||
|                         </div> | ||||
|                         <h6 class="mb-0" style="color: #495057; font-weight: 600;"> | ||||
|                             {format!("${:.2}", ctx.props().amount)} | ||||
|                         </h6> | ||||
|                         <small class="text-muted" style="font-size: 0.7rem;"> | ||||
|                             {format!("Amount in {}", ctx.props().currency)} | ||||
|                         </small> | ||||
|                     </div> | ||||
|                     <i class="bi bi-credit-card" style="font-size: 1.25rem; color: #6c757d;"></i> | ||||
|                 </div> | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn render_payment_element(&self, _ctx: &Context<Self>, has_client_secret: bool, creating_intent: bool) -> Html { | ||||
|         html! { | ||||
|             <div id="payment-element" style="min-height: 40px; padding: 10px; border: 1px solid #e0e0e0; border-radius: 8px; background-color: #ffffff;"> | ||||
|                 {if creating_intent { | ||||
|                     html! { | ||||
|                         <div class="text-center py-4"> | ||||
|                             <div class="spinner-border text-secondary mb-3" role="status" style="width: 1.5rem; height: 1.5rem;"> | ||||
|                                 <span class="visually-hidden">{"Loading..."}</span> | ||||
|                             </div> | ||||
|                             <p class="text-muted" style="font-size: 0.85rem;">{"Preparing payment form..."}</p> | ||||
|                         </div> | ||||
|                     } | ||||
|                 } else if !has_client_secret { | ||||
|                     html! { | ||||
|                         <div class="text-center py-4"> | ||||
|                             <div class="spinner-border text-secondary mb-3" role="status" style="width: 1.5rem; height: 1.5rem;"> | ||||
|                                 <span class="visually-hidden">{"Loading..."}</span> | ||||
|                             </div> | ||||
|                             <p class="text-muted" style="font-size: 0.85rem;">{"Initializing payment..."}</p> | ||||
|                         </div> | ||||
|                     } | ||||
|                 } else { | ||||
|                     html! { | ||||
|                         // Stripe Elements will be mounted here by JavaScript | ||||
|                     } | ||||
|                 }} | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn render_payment_button(&self, ctx: &Context<Self>) -> Html { | ||||
|         let link = ctx.link(); | ||||
|         let default_text = format!("Complete Payment - ${:.2}", ctx.props().amount); | ||||
|         let button_text = ctx.props().button_text | ||||
|             .as_ref() | ||||
|             .map(|t| t.as_str()) | ||||
|             .unwrap_or(&default_text); | ||||
|  | ||||
|         html! { | ||||
|             <div class="d-grid mt-3"> | ||||
|                 <button | ||||
|                     type="button" | ||||
|                     class="btn" | ||||
|                     onclick={link.callback(|_| StripePaymentFormMsg::ProcessPayment)} | ||||
|                     disabled={ctx.props().processing} | ||||
|                     style="background: linear-gradient(135deg, #495057 0%, #6c757d 100%); border: none; color: white; padding: 0.75rem; border-radius: 8px; font-size: 0.9rem; font-weight: 500; transition: all 0.2s ease;" | ||||
|                 > | ||||
|                     {if ctx.props().processing { | ||||
|                         html! { | ||||
|                             <> | ||||
|                                 <span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span> | ||||
|                                 {"Processing..."} | ||||
|                             </> | ||||
|                         } | ||||
|                     } else { | ||||
|                         html! { | ||||
|                             <> | ||||
|                                 <i class="bi bi-credit-card me-2"></i> | ||||
|                                 {button_text} | ||||
|                             </> | ||||
|                         } | ||||
|                     }} | ||||
|                 </button> | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn render_errors(&self, _ctx: &Context<Self>, stripe_error: Option<&String>) -> Html { | ||||
|         let error_to_show = self.payment_error.as_ref().or(stripe_error); | ||||
|          | ||||
|         if let Some(error) = error_to_show { | ||||
|             html! { | ||||
|                 <div id="payment-errors" class="alert alert-danger mt-3" style="border-radius: 6px; font-size: 0.85rem;"> | ||||
|                     <i class="bi bi-exclamation-triangle me-2"></i> | ||||
|                     <strong>{"Payment Error: "}</strong>{error} | ||||
|                 </div> | ||||
|             } | ||||
|         } else { | ||||
|             html! { | ||||
|                 <div id="payment-errors" class="alert alert-danger mt-3" style="display: none;"></div> | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn process_payment(&self, ctx: &Context<Self>, client_secret: String) { | ||||
|         let link = ctx.link().clone(); | ||||
|          | ||||
|         spawn_local(async move { | ||||
|             match Self::confirm_payment(&client_secret).await { | ||||
|                 Ok(_) => { | ||||
|                     link.send_message(StripePaymentFormMsg::PaymentComplete); | ||||
|                 } | ||||
|                 Err(e) => { | ||||
|                     link.send_message(StripePaymentFormMsg::PaymentError(e)); | ||||
|                 } | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     async fn confirm_payment(client_secret: &str) -> Result<(), String> { | ||||
|         use wasm_bindgen_futures::JsFuture; | ||||
|          | ||||
|         console::log_1(&"🔄 Confirming payment with Stripe...".into()); | ||||
|          | ||||
|         // Call JavaScript function to confirm payment | ||||
|         let promise = confirmStripePayment(client_secret); | ||||
|         JsFuture::from(promise).await | ||||
|             .map_err(|e| format!("Payment confirmation failed: {:?}", e))?; | ||||
|          | ||||
|         console::log_1(&"✅ Payment confirmed successfully".into()); | ||||
|         Ok(()) | ||||
|     } | ||||
| } | ||||
							
								
								
									
										247
									
								
								portal/src/components/common/payments/stripe_provider.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										247
									
								
								portal/src/components/common/payments/stripe_provider.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,247 @@ | ||||
| //! Generic Stripe payment provider component | ||||
|  | ||||
| use yew::prelude::*; | ||||
| use wasm_bindgen_futures::spawn_local; | ||||
| use web_sys::{console, Request, RequestInit, RequestMode, Response}; | ||||
| use wasm_bindgen::prelude::*; | ||||
| use wasm_bindgen_futures::JsFuture; | ||||
| use serde_json::json; | ||||
| use std::rc::Rc; | ||||
|  | ||||
| use super::{PaymentIntentRequest, PaymentIntentResponse, PaymentMetadata}; | ||||
|  | ||||
| /// Trait for creating payment intents from form data | ||||
| pub trait PaymentIntentCreator<T> { | ||||
|     /// Create a payment intent request from form data | ||||
|     fn create_payment_intent(&self, data: &T) -> Result<PaymentIntentRequest, String>; | ||||
|      | ||||
|     /// Get the endpoint URL for payment intent creation | ||||
|     fn get_endpoint_url(&self) -> String; | ||||
|      | ||||
|     /// Get additional headers for the request (optional) | ||||
|     fn get_headers(&self) -> Vec<(String, String)> { | ||||
|         vec![("Content-Type".to_string(), "application/json".to_string())] | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Properties for StripeProvider component | ||||
| #[derive(Properties)] | ||||
| pub struct StripeProviderProps<T: Clone + PartialEq + 'static> { | ||||
|     /// Form data to create payment intent from | ||||
|     pub form_data: T, | ||||
|      | ||||
|     /// Payment intent creator implementation | ||||
|     pub payment_creator: Rc<dyn PaymentIntentCreator<T>>, | ||||
|      | ||||
|     /// Callback when payment intent is created successfully | ||||
|     pub on_intent_created: Callback<String>, | ||||
|      | ||||
|     /// Callback when payment intent creation fails | ||||
|     pub on_intent_error: Callback<String>, | ||||
|      | ||||
|     /// Whether to automatically create payment intent on mount | ||||
|     #[prop_or(true)] | ||||
|     pub auto_create_intent: bool, | ||||
|      | ||||
|     /// Whether to recreate intent when form data changes | ||||
|     #[prop_or(true)] | ||||
|     pub recreate_on_change: bool, | ||||
|      | ||||
|     /// Children components (typically StripePaymentForm) | ||||
|     pub children: Children, | ||||
| } | ||||
|  | ||||
| impl<T: Clone + PartialEq + 'static> PartialEq for StripeProviderProps<T> { | ||||
|     fn eq(&self, other: &Self) -> bool { | ||||
|         self.form_data == other.form_data | ||||
|             && self.auto_create_intent == other.auto_create_intent | ||||
|             && self.recreate_on_change == other.recreate_on_change | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Messages for StripeProvider component | ||||
| pub enum StripeProviderMsg { | ||||
|     CreatePaymentIntent, | ||||
|     PaymentIntentCreated(String), | ||||
|     PaymentIntentError(String), | ||||
| } | ||||
|  | ||||
| /// StripeProvider component state | ||||
| pub struct StripeProvider<T: Clone + PartialEq> { | ||||
|     client_secret: Option<String>, | ||||
|     creating_intent: bool, | ||||
|     error: Option<String>, | ||||
|     _phantom: std::marker::PhantomData<T>, | ||||
| } | ||||
|  | ||||
| impl<T: Clone + PartialEq + 'static> Component for StripeProvider<T> { | ||||
|     type Message = StripeProviderMsg; | ||||
|     type Properties = StripeProviderProps<T>; | ||||
|  | ||||
|     fn create(ctx: &Context<Self>) -> Self { | ||||
|         let mut component = Self { | ||||
|             client_secret: None, | ||||
|             creating_intent: false, | ||||
|             error: None, | ||||
|             _phantom: std::marker::PhantomData, | ||||
|         }; | ||||
|  | ||||
|         // Auto-create payment intent if enabled | ||||
|         if ctx.props().auto_create_intent { | ||||
|             ctx.link().send_message(StripeProviderMsg::CreatePaymentIntent); | ||||
|         } | ||||
|  | ||||
|         component | ||||
|     } | ||||
|  | ||||
|     fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool { | ||||
|         match msg { | ||||
|             StripeProviderMsg::CreatePaymentIntent => { | ||||
|                 self.creating_intent = true; | ||||
|                 self.error = None; | ||||
|                 self.create_payment_intent(ctx); | ||||
|                 true | ||||
|             } | ||||
|             StripeProviderMsg::PaymentIntentCreated(client_secret) => { | ||||
|                 self.creating_intent = false; | ||||
|                 self.client_secret = Some(client_secret.clone()); | ||||
|                 self.error = None; | ||||
|                 ctx.props().on_intent_created.emit(client_secret); | ||||
|                 true | ||||
|             } | ||||
|             StripeProviderMsg::PaymentIntentError(error) => { | ||||
|                 self.creating_intent = false; | ||||
|                 self.error = Some(error.clone()); | ||||
|                 self.client_secret = None; | ||||
|                 ctx.props().on_intent_error.emit(error); | ||||
|                 true | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn changed(&mut self, ctx: &Context<Self>, old_props: &Self::Properties) -> bool { | ||||
|         // Recreate payment intent if form data changed and recreate_on_change is enabled | ||||
|         if ctx.props().recreate_on_change  | ||||
|             && ctx.props().form_data != old_props.form_data  | ||||
|             && !self.creating_intent { | ||||
|             ctx.link().send_message(StripeProviderMsg::CreatePaymentIntent); | ||||
|         } | ||||
|         false | ||||
|     } | ||||
|  | ||||
|     fn view(&self, ctx: &Context<Self>) -> Html { | ||||
|         // Create context for children | ||||
|         let stripe_context = StripeContext { | ||||
|             client_secret: self.client_secret.clone(), | ||||
|             creating_intent: self.creating_intent, | ||||
|             error: self.error.clone(), | ||||
|             create_intent: ctx.link().callback(|_| StripeProviderMsg::CreatePaymentIntent), | ||||
|         }; | ||||
|  | ||||
|         html! { | ||||
|             <ContextProvider<StripeContext> context={stripe_context}> | ||||
|                 {for ctx.props().children.iter()} | ||||
|             </ContextProvider<StripeContext>> | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl<T: Clone + PartialEq + 'static> StripeProvider<T> { | ||||
|     fn create_payment_intent(&self, ctx: &Context<Self>) { | ||||
|         let link = ctx.link().clone(); | ||||
|         let payment_creator = ctx.props().payment_creator.clone(); | ||||
|         let form_data = ctx.props().form_data.clone(); | ||||
|  | ||||
|         spawn_local(async move { | ||||
|             match Self::create_intent_async(payment_creator, form_data).await { | ||||
|                 Ok(client_secret) => { | ||||
|                     link.send_message(StripeProviderMsg::PaymentIntentCreated(client_secret)); | ||||
|                 } | ||||
|                 Err(error) => { | ||||
|                     link.send_message(StripeProviderMsg::PaymentIntentError(error)); | ||||
|                 } | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     async fn create_intent_async( | ||||
|         payment_creator: Rc<dyn PaymentIntentCreator<T>>, | ||||
|         form_data: T, | ||||
|     ) -> Result<String, String> { | ||||
|         console::log_1(&"🔧 Creating payment intent...".into()); | ||||
|  | ||||
|         // Create payment intent request | ||||
|         let payment_request = payment_creator.create_payment_intent(&form_data) | ||||
|             .map_err(|e| format!("Failed to create payment request: {}", e))?; | ||||
|  | ||||
|         console::log_1(&format!("💳 Payment request: amount=${:.2}, currency={}",  | ||||
|             payment_request.amount_as_float(), payment_request.currency).into()); | ||||
|  | ||||
|         // Prepare request | ||||
|         let mut opts = RequestInit::new(); | ||||
|         opts.method("POST"); | ||||
|         opts.mode(RequestMode::Cors); | ||||
|  | ||||
|         // Set headers | ||||
|         let headers = js_sys::Map::new(); | ||||
|         for (key, value) in payment_creator.get_headers() { | ||||
|             headers.set(&key.into(), &value.into()); | ||||
|         } | ||||
|         opts.headers(&headers); | ||||
|  | ||||
|         // Set body | ||||
|         let body = serde_json::to_string(&payment_request) | ||||
|             .map_err(|e| format!("Failed to serialize payment request: {}", e))?; | ||||
|         opts.body(Some(&JsValue::from_str(&body))); | ||||
|  | ||||
|         // Create request | ||||
|         let request = Request::new_with_str_and_init( | ||||
|             &payment_creator.get_endpoint_url(), | ||||
|             &opts, | ||||
|         ).map_err(|e| format!("Failed to create request: {:?}", e))?; | ||||
|  | ||||
|         // Make the request | ||||
|         let window = web_sys::window().unwrap(); | ||||
|         let resp_value = JsFuture::from(window.fetch_with_request(&request)).await | ||||
|             .map_err(|e| format!("Network request failed: {:?}", e))?; | ||||
|  | ||||
|         let resp: Response = resp_value.dyn_into().unwrap(); | ||||
|  | ||||
|         if !resp.ok() { | ||||
|             let status = resp.status(); | ||||
|             let error_msg = format!("Server error: HTTP {}", status); | ||||
|             console::log_1(&format!("❌ {}", error_msg).into()); | ||||
|             return Err(error_msg); | ||||
|         } | ||||
|  | ||||
|         // Parse response | ||||
|         let json_value = JsFuture::from(resp.json().unwrap()).await | ||||
|             .map_err(|e| format!("Failed to parse response: {:?}", e))?; | ||||
|  | ||||
|         // Extract client secret from response | ||||
|         let response_obj = js_sys::Object::from(json_value); | ||||
|         let client_secret_value = js_sys::Reflect::get(&response_obj, &"client_secret".into()) | ||||
|             .map_err(|e| format!("No client_secret in response: {:?}", e))?; | ||||
|  | ||||
|         let client_secret = client_secret_value.as_string() | ||||
|             .ok_or_else(|| "Invalid client secret received from server".to_string())?; | ||||
|  | ||||
|         console::log_1(&"✅ Payment intent created successfully".into()); | ||||
|         Ok(client_secret) | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Context provided to child components | ||||
| #[derive(Clone, PartialEq)] | ||||
| pub struct StripeContext { | ||||
|     pub client_secret: Option<String>, | ||||
|     pub creating_intent: bool, | ||||
|     pub error: Option<String>, | ||||
|     pub create_intent: Callback<()>, | ||||
| } | ||||
|  | ||||
| /// Hook to use Stripe context | ||||
| #[hook] | ||||
| pub fn use_stripe() -> Option<StripeContext> { | ||||
|     use_context::<StripeContext>() | ||||
| } | ||||
							
								
								
									
										184
									
								
								portal/src/components/common/ui/loading_spinner.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										184
									
								
								portal/src/components/common/ui/loading_spinner.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,184 @@ | ||||
| //! Generic loading spinner component | ||||
|  | ||||
| use yew::prelude::*; | ||||
|  | ||||
| /// Size options for the loading spinner | ||||
| #[derive(Clone, PartialEq)] | ||||
| pub enum SpinnerSize { | ||||
|     Small, | ||||
|     Medium, | ||||
|     Large, | ||||
| } | ||||
|  | ||||
| impl SpinnerSize { | ||||
|     pub fn get_class(&self) -> &'static str { | ||||
|         match self { | ||||
|             SpinnerSize::Small => "spinner-border-sm", | ||||
|             SpinnerSize::Medium => "", | ||||
|             SpinnerSize::Large => "spinner-border-lg", | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn get_style(&self) -> &'static str { | ||||
|         match self { | ||||
|             SpinnerSize::Small => "width: 1rem; height: 1rem;", | ||||
|             SpinnerSize::Medium => "width: 1.5rem; height: 1.5rem;", | ||||
|             SpinnerSize::Large => "width: 2rem; height: 2rem;", | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Color options for the loading spinner | ||||
| #[derive(Clone, PartialEq)] | ||||
| pub enum SpinnerColor { | ||||
|     Primary, | ||||
|     Secondary, | ||||
|     Success, | ||||
|     Danger, | ||||
|     Warning, | ||||
|     Info, | ||||
|     Light, | ||||
|     Dark, | ||||
| } | ||||
|  | ||||
| impl SpinnerColor { | ||||
|     pub fn get_class(&self) -> &'static str { | ||||
|         match self { | ||||
|             SpinnerColor::Primary => "text-primary", | ||||
|             SpinnerColor::Secondary => "text-secondary", | ||||
|             SpinnerColor::Success => "text-success", | ||||
|             SpinnerColor::Danger => "text-danger", | ||||
|             SpinnerColor::Warning => "text-warning", | ||||
|             SpinnerColor::Info => "text-info", | ||||
|             SpinnerColor::Light => "text-light", | ||||
|             SpinnerColor::Dark => "text-dark", | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Properties for LoadingSpinner component | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct LoadingSpinnerProps { | ||||
|     /// Size of the spinner | ||||
|     #[prop_or(SpinnerSize::Medium)] | ||||
|     pub size: SpinnerSize, | ||||
|      | ||||
|     /// Color of the spinner | ||||
|     #[prop_or(SpinnerColor::Primary)] | ||||
|     pub color: SpinnerColor, | ||||
|      | ||||
|     /// Loading message to display | ||||
|     #[prop_or_default] | ||||
|     pub message: Option<AttrValue>, | ||||
|      | ||||
|     /// Whether to center the spinner | ||||
|     #[prop_or(true)] | ||||
|     pub centered: bool, | ||||
|      | ||||
|     /// Custom CSS class for container | ||||
|     #[prop_or_default] | ||||
|     pub container_class: Option<AttrValue>, | ||||
|      | ||||
|     /// Whether to show as inline spinner | ||||
|     #[prop_or(false)] | ||||
|     pub inline: bool, | ||||
| } | ||||
|  | ||||
| /// LoadingSpinner component | ||||
| #[function_component(LoadingSpinner)] | ||||
| pub fn loading_spinner(props: &LoadingSpinnerProps) -> Html { | ||||
|     let container_class = if props.inline { | ||||
|         "d-inline-flex align-items-center" | ||||
|     } else if props.centered { | ||||
|         "d-flex flex-column align-items-center justify-content-center" | ||||
|     } else { | ||||
|         "d-flex align-items-center" | ||||
|     }; | ||||
|  | ||||
|     let final_container_class = if let Some(custom_class) = &props.container_class { | ||||
|         format!("{} {}", container_class, custom_class.as_str()) | ||||
|     } else { | ||||
|         container_class.to_string() | ||||
|     }; | ||||
|  | ||||
|     let spinner_classes = format!( | ||||
|         "spinner-border {} {}", | ||||
|         props.size.get_class(), | ||||
|         props.color.get_class() | ||||
|     ); | ||||
|  | ||||
|     html! { | ||||
|         <div class={final_container_class}> | ||||
|             <div  | ||||
|                 class={spinner_classes} | ||||
|                 style={props.size.get_style()} | ||||
|                 role="status" | ||||
|                 aria-hidden="true" | ||||
|             > | ||||
|                 <span class="visually-hidden">{"Loading..."}</span> | ||||
|             </div> | ||||
|              | ||||
|             {if let Some(message) = &props.message { | ||||
|                 let message_class = if props.inline { | ||||
|                     "ms-2" | ||||
|                 } else { | ||||
|                     "mt-2" | ||||
|                 }; | ||||
|                  | ||||
|                 html! { | ||||
|                     <div class={message_class}> | ||||
|                         {message.as_str()} | ||||
|                     </div> | ||||
|                 } | ||||
|             } else { | ||||
|                 html! {} | ||||
|             }} | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Convenience component for common loading scenarios | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct LoadingOverlayProps { | ||||
|     /// Loading message | ||||
|     #[prop_or("Loading...".to_string())] | ||||
|     pub message: String, | ||||
|      | ||||
|     /// Whether the overlay is visible | ||||
|     #[prop_or(true)] | ||||
|     pub show: bool, | ||||
|      | ||||
|     /// Background opacity (0.0 to 1.0) | ||||
|     #[prop_or(0.8)] | ||||
|     pub opacity: f64, | ||||
|      | ||||
|     /// Spinner color | ||||
|     #[prop_or(SpinnerColor::Primary)] | ||||
|     pub spinner_color: SpinnerColor, | ||||
| } | ||||
|  | ||||
| /// LoadingOverlay component for full-screen loading | ||||
| #[function_component(LoadingOverlay)] | ||||
| pub fn loading_overlay(props: &LoadingOverlayProps) -> Html { | ||||
|     if !props.show { | ||||
|         return html! {}; | ||||
|     } | ||||
|  | ||||
|     let background_style = format!( | ||||
|         "position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(255, 255, 255, {}); z-index: 9999;", | ||||
|         props.opacity | ||||
|     ); | ||||
|  | ||||
|     html! { | ||||
|         <div style={background_style}> | ||||
|             <div class="d-flex flex-column align-items-center justify-content-center h-100"> | ||||
|                 <LoadingSpinner  | ||||
|                     size={SpinnerSize::Large} | ||||
|                     color={props.spinner_color.clone()} | ||||
|                     message={props.message.clone()} | ||||
|                     centered={true} | ||||
|                 /> | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
							
								
								
									
										9
									
								
								portal/src/components/common/ui/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								portal/src/components/common/ui/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| //! Generic UI components for forms and user interactions | ||||
|  | ||||
| pub mod progress_indicator; | ||||
| pub mod validation_toast; | ||||
| pub mod loading_spinner; | ||||
|  | ||||
| pub use progress_indicator::{ProgressIndicator, ProgressVariant}; | ||||
| pub use validation_toast::{ValidationToast, ToastType}; | ||||
| pub use loading_spinner::LoadingSpinner; | ||||
							
								
								
									
										307
									
								
								portal/src/components/common/ui/progress_indicator.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										307
									
								
								portal/src/components/common/ui/progress_indicator.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,307 @@ | ||||
| //! Generic progress indicator component for multi-step processes | ||||
|  | ||||
| use yew::prelude::*; | ||||
|  | ||||
| /// Variant of progress indicator display | ||||
| #[derive(Clone, PartialEq)] | ||||
| pub enum ProgressVariant { | ||||
|     /// Circular dots with connecting lines | ||||
|     Dots, | ||||
|     /// Linear progress bar | ||||
|     Line, | ||||
|     /// Step-by-step with titles | ||||
|     Steps, | ||||
| } | ||||
|  | ||||
| /// Properties for ProgressIndicator component | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct ProgressIndicatorProps { | ||||
|     /// Current active step (0-based) | ||||
|     pub current_step: usize, | ||||
|      | ||||
|     /// Total number of steps | ||||
|     pub total_steps: usize, | ||||
|      | ||||
|     /// Step titles (optional) | ||||
|     #[prop_or_default] | ||||
|     pub step_titles: Vec<String>, | ||||
|      | ||||
|     /// Completed steps (optional, defaults to all steps before current) | ||||
|     #[prop_or_default] | ||||
|     pub completed_steps: Option<Vec<usize>>, | ||||
|      | ||||
|     /// Whether to show step numbers | ||||
|     #[prop_or(true)] | ||||
|     pub show_step_numbers: bool, | ||||
|      | ||||
|     /// Whether to show step titles | ||||
|     #[prop_or(false)] | ||||
|     pub show_step_titles: bool, | ||||
|      | ||||
|     /// Display variant | ||||
|     #[prop_or(ProgressVariant::Dots)] | ||||
|     pub variant: ProgressVariant, | ||||
|      | ||||
|      | ||||
|     /// Size of the progress indicator | ||||
|     #[prop_or(ProgressSize::Medium)] | ||||
|     pub size: ProgressSize, | ||||
|      | ||||
|     /// Color scheme | ||||
|     #[prop_or(ProgressColor::Primary)] | ||||
|     pub color: ProgressColor, | ||||
| } | ||||
|  | ||||
| /// Size options for progress indicator | ||||
| #[derive(Clone, PartialEq)] | ||||
| pub enum ProgressSize { | ||||
|     Small, | ||||
|     Medium, | ||||
|     Large, | ||||
| } | ||||
|  | ||||
| impl ProgressSize { | ||||
|     pub fn get_step_size(&self) -> &'static str { | ||||
|         match self { | ||||
|             ProgressSize::Small => "width: 24px; height: 24px; font-size: 10px;", | ||||
|             ProgressSize::Medium => "width: 28px; height: 28px; font-size: 12px;", | ||||
|             ProgressSize::Large => "width: 32px; height: 32px; font-size: 14px;", | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn get_connector_width(&self) -> &'static str { | ||||
|         match self { | ||||
|             ProgressSize::Small => "20px", | ||||
|             ProgressSize::Medium => "24px", | ||||
|             ProgressSize::Large => "28px", | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Color scheme options | ||||
| #[derive(Clone, PartialEq)] | ||||
| pub enum ProgressColor { | ||||
|     Primary, | ||||
|     Success, | ||||
|     Info, | ||||
|     Warning, | ||||
|     Secondary, | ||||
| } | ||||
|  | ||||
| impl ProgressColor { | ||||
|     pub fn get_active_class(&self) -> &'static str { | ||||
|         match self { | ||||
|             ProgressColor::Primary => "bg-primary text-white", | ||||
|             ProgressColor::Success => "bg-success text-white", | ||||
|             ProgressColor::Info => "bg-info text-white", | ||||
|             ProgressColor::Warning => "bg-warning text-dark", | ||||
|             ProgressColor::Secondary => "bg-secondary text-white", | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn get_completed_class(&self) -> &'static str { | ||||
|         match self { | ||||
|             ProgressColor::Primary => "bg-primary text-white", | ||||
|             ProgressColor::Success => "bg-success text-white", | ||||
|             ProgressColor::Info => "bg-info text-white", | ||||
|             ProgressColor::Warning => "bg-warning text-dark", | ||||
|             ProgressColor::Secondary => "bg-success text-white", // Completed is always success | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn get_connector_color(&self) -> &'static str { | ||||
|         match self { | ||||
|             ProgressColor::Primary => "bg-primary", | ||||
|             ProgressColor::Success => "bg-success", | ||||
|             ProgressColor::Info => "bg-info", | ||||
|             ProgressColor::Warning => "bg-warning", | ||||
|             ProgressColor::Secondary => "bg-success", | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// ProgressIndicator component | ||||
| #[function_component(ProgressIndicator)] | ||||
| pub fn progress_indicator(props: &ProgressIndicatorProps) -> Html { | ||||
|     // Determine completed steps | ||||
|     let completed_steps: Vec<usize> = props.completed_steps | ||||
|         .as_ref() | ||||
|         .cloned() | ||||
|         .unwrap_or_else(|| (0..props.current_step).collect()); | ||||
|  | ||||
|     match props.variant { | ||||
|         ProgressVariant::Dots => render_dots_variant(props, &completed_steps), | ||||
|         ProgressVariant::Line => render_line_variant(props, &completed_steps), | ||||
|         ProgressVariant::Steps => render_steps_variant(props, &completed_steps), | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn render_dots_variant( | ||||
|     props: &ProgressIndicatorProps, | ||||
|     completed_steps: &[usize], | ||||
| ) -> Html { | ||||
|     html! { | ||||
|         <div class="mb-3"> | ||||
|             <div class="d-flex align-items-center justify-content-center"> | ||||
|                 {for (0..props.total_steps).map(|step_index| { | ||||
|                     let is_current = step_index == props.current_step; | ||||
|                     let is_completed = completed_steps.contains(&step_index); | ||||
|                      | ||||
|                     let step_class = if is_current { | ||||
|                         props.color.get_active_class() | ||||
|                     } else if is_completed { | ||||
|                         props.color.get_completed_class() | ||||
|                     } else { | ||||
|                         "bg-white text-muted border" | ||||
|                     }; | ||||
|                      | ||||
|                     html! { | ||||
|                         <div class="d-flex align-items-center"> | ||||
|                             <div  | ||||
|                                 class={format!("rounded-circle d-flex align-items-center justify-content-center {} fw-bold", step_class)} | ||||
|                                 style={props.size.get_step_size()} | ||||
|                             > | ||||
|                                 {if is_completed && !is_current { | ||||
|                                     html! { <i class="bi bi-check"></i> } | ||||
|                                 } else if props.show_step_numbers { | ||||
|                                     html! { {step_index + 1} } | ||||
|                                 } else { | ||||
|                                     html! {} | ||||
|                                 }} | ||||
|                             </div> | ||||
|                              | ||||
|                             {if step_index < props.total_steps - 1 { | ||||
|                                 let connector_class = if is_completed { | ||||
|                                     props.color.get_connector_color() | ||||
|                                 } else { | ||||
|                                     "bg-secondary" | ||||
|                                 }; | ||||
|                                  | ||||
|                                 html! { | ||||
|                                     <div  | ||||
|                                         class={format!("mx-1 {}", connector_class)} | ||||
|                                         style={format!("height: 2px; width: {};", props.size.get_connector_width())} | ||||
|                                     ></div> | ||||
|                                 } | ||||
|                             } else { | ||||
|                                 html! {} | ||||
|                             }} | ||||
|                         </div> | ||||
|                     } | ||||
|                 })} | ||||
|             </div> | ||||
|              | ||||
|             {if props.show_step_titles && !props.step_titles.is_empty() { | ||||
|                 html! { | ||||
|                     <div class="d-flex justify-content-between mt-2"> | ||||
|                         {for props.step_titles.iter().enumerate().map(|(index, title)| { | ||||
|                             let is_current = index == props.current_step; | ||||
|                             let is_completed = completed_steps.contains(&index); | ||||
|                              | ||||
|                             let title_class = if is_current { | ||||
|                                 "fw-bold text-primary" | ||||
|                             } else if is_completed { | ||||
|                                 "text-success" | ||||
|                             } else { | ||||
|                                 "text-muted" | ||||
|                             }; | ||||
|                              | ||||
|                             html! { | ||||
|                                 <small class={format!("text-center {}", title_class)} style="font-size: 0.75rem;"> | ||||
|                                     {title} | ||||
|                                 </small> | ||||
|                             } | ||||
|                         })} | ||||
|                     </div> | ||||
|                 } | ||||
|             } else { | ||||
|                 html! {} | ||||
|             }} | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn render_line_variant( | ||||
|     props: &ProgressIndicatorProps, | ||||
|     _completed_steps: &[usize], | ||||
| ) -> Html { | ||||
|     let progress_percentage = if props.total_steps > 0 { | ||||
|         ((props.current_step + 1) as f64 / props.total_steps as f64 * 100.0).min(100.0) | ||||
|     } else { | ||||
|         0.0 | ||||
|     }; | ||||
|  | ||||
|     html! { | ||||
|         <div class="mb-3"> | ||||
|             <div class="progress" style="height: 8px;"> | ||||
|                 <div  | ||||
|                     class={format!("progress-bar {}", props.color.get_active_class())} | ||||
|                     role="progressbar" | ||||
|                     style={format!("width: {}%", progress_percentage)} | ||||
|                     aria-valuenow={progress_percentage.to_string()} | ||||
|                     aria-valuemin="0" | ||||
|                     aria-valuemax="100" | ||||
|                 ></div> | ||||
|             </div> | ||||
|              | ||||
|             <div class="d-flex justify-content-between mt-1"> | ||||
|                 <small class="text-muted"> | ||||
|                     {format!("Step {} of {}", props.current_step + 1, props.total_steps)} | ||||
|                 </small> | ||||
|                 <small class="text-muted"> | ||||
|                     {format!("{:.0}% Complete", progress_percentage)} | ||||
|                 </small> | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn render_steps_variant( | ||||
|     props: &ProgressIndicatorProps, | ||||
|     completed_steps: &[usize], | ||||
| ) -> Html { | ||||
|     html! { | ||||
|         <div class="mb-3"> | ||||
|             <div class="row"> | ||||
|                 {for (0..props.total_steps).map(|step_index| { | ||||
|                     let is_current = step_index == props.current_step; | ||||
|                     let is_completed = completed_steps.contains(&step_index); | ||||
|                      | ||||
|                     let default_title = format!("Step {}", step_index + 1); | ||||
|                     let step_title = props.step_titles.get(step_index) | ||||
|                         .map(|s| s.as_str()) | ||||
|                         .unwrap_or(&default_title); | ||||
|                      | ||||
|                     let card_class = if is_current { | ||||
|                         "border-primary bg-light" | ||||
|                     } else if is_completed { | ||||
|                         "border-success" | ||||
|                     } else { | ||||
|                         "border-secondary" | ||||
|                     }; | ||||
|                      | ||||
|                     let icon_class = if is_completed { | ||||
|                         "bi-check-circle-fill text-success" | ||||
|                     } else if is_current { | ||||
|                         "bi-arrow-right-circle-fill text-primary" | ||||
|                     } else { | ||||
|                         "bi-circle text-muted" | ||||
|                     }; | ||||
|                      | ||||
|                     html! { | ||||
|                         <div class="col"> | ||||
|                             <div class={format!("card h-100 {}", card_class)} style="border-width: 2px;"> | ||||
|                                 <div class="card-body text-center p-2"> | ||||
|                                     <i class={format!("bi {} mb-1", icon_class)} style="font-size: 1.5rem;"></i> | ||||
|                                     <h6 class="card-title mb-0" style="font-size: 0.8rem;"> | ||||
|                                         {step_title} | ||||
|                                     </h6> | ||||
|                                 </div> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                     } | ||||
|                 })} | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
							
								
								
									
										215
									
								
								portal/src/components/common/ui/validation_toast.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										215
									
								
								portal/src/components/common/ui/validation_toast.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,215 @@ | ||||
| //! Generic validation toast component for displaying errors and messages | ||||
|  | ||||
| use yew::prelude::*; | ||||
| use gloo::timers::callback::Timeout; | ||||
|  | ||||
| /// Type of toast message | ||||
| #[derive(Clone, PartialEq)] | ||||
| pub enum ToastType { | ||||
|     Error, | ||||
|     Warning, | ||||
|     Info, | ||||
|     Success, | ||||
| } | ||||
|  | ||||
| impl ToastType { | ||||
|     pub fn get_header_class(&self) -> &'static str { | ||||
|         match self { | ||||
|             ToastType::Error => "bg-danger text-white", | ||||
|             ToastType::Warning => "bg-warning text-dark", | ||||
|             ToastType::Info => "bg-info text-white", | ||||
|             ToastType::Success => "bg-success text-white", | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn get_icon(&self) -> &'static str { | ||||
|         match self { | ||||
|             ToastType::Error => "bi-x-circle", | ||||
|             ToastType::Warning => "bi-exclamation-triangle", | ||||
|             ToastType::Info => "bi-info-circle", | ||||
|             ToastType::Success => "bi-check-circle", | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn get_title(&self) -> &'static str { | ||||
|         match self { | ||||
|             ToastType::Error => "Error", | ||||
|             ToastType::Warning => "Warning", | ||||
|             ToastType::Info => "Information", | ||||
|             ToastType::Success => "Success", | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Properties for ValidationToast component | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct ValidationToastProps { | ||||
|     /// List of messages to display | ||||
|     pub messages: Vec<String>, | ||||
|      | ||||
|     /// Whether the toast is visible | ||||
|     pub show: bool, | ||||
|      | ||||
|     /// Callback when toast is closed | ||||
|     pub on_close: Callback<()>, | ||||
|      | ||||
|     /// Type of toast (determines styling) | ||||
|     #[prop_or(ToastType::Error)] | ||||
|     pub toast_type: ToastType, | ||||
|      | ||||
|     /// Auto-hide duration in milliseconds (None = no auto-hide) | ||||
|     #[prop_or_default] | ||||
|     pub auto_hide_duration: Option<u32>, | ||||
|      | ||||
|     /// Custom title for the toast | ||||
|     #[prop_or_default] | ||||
|     pub title: Option<AttrValue>, | ||||
|      | ||||
|     /// Position of the toast | ||||
|     #[prop_or(ToastPosition::BottomCenter)] | ||||
|     pub position: ToastPosition, | ||||
|      | ||||
|     /// Maximum width of the toast | ||||
|     #[prop_or("500px".to_string())] | ||||
|     pub max_width: String, | ||||
| } | ||||
|  | ||||
| /// Position options for the toast | ||||
| #[derive(Clone, PartialEq)] | ||||
| pub enum ToastPosition { | ||||
|     TopLeft, | ||||
|     TopCenter, | ||||
|     TopRight, | ||||
|     BottomLeft, | ||||
|     BottomCenter, | ||||
|     BottomRight, | ||||
| } | ||||
|  | ||||
| impl ToastPosition { | ||||
|     pub fn get_position_class(&self) -> &'static str { | ||||
|         match self { | ||||
|             ToastPosition::TopLeft => "position-fixed top-0 start-0 m-3", | ||||
|             ToastPosition::TopCenter => "position-fixed top-0 start-50 translate-middle-x mt-3", | ||||
|             ToastPosition::TopRight => "position-fixed top-0 end-0 m-3", | ||||
|             ToastPosition::BottomLeft => "position-fixed bottom-0 start-0 m-3", | ||||
|             ToastPosition::BottomCenter => "position-fixed bottom-0 start-50 translate-middle-x mb-3", | ||||
|             ToastPosition::BottomRight => "position-fixed bottom-0 end-0 m-3", | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Messages for ValidationToast component | ||||
| pub enum ValidationToastMsg { | ||||
|     Close, | ||||
|     AutoHide, | ||||
| } | ||||
|  | ||||
| /// ValidationToast component state | ||||
| pub struct ValidationToast { | ||||
|     _auto_hide_timeout: Option<Timeout>, | ||||
| } | ||||
|  | ||||
| impl Component for ValidationToast { | ||||
|     type Message = ValidationToastMsg; | ||||
|     type Properties = ValidationToastProps; | ||||
|  | ||||
|     fn create(ctx: &Context<Self>) -> Self { | ||||
|         let mut component = Self { | ||||
|             _auto_hide_timeout: None, | ||||
|         }; | ||||
|  | ||||
|         // Set up auto-hide if enabled | ||||
|         if ctx.props().show { | ||||
|             component.setup_auto_hide(ctx); | ||||
|         } | ||||
|  | ||||
|         component | ||||
|     } | ||||
|  | ||||
|     fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool { | ||||
|         match msg { | ||||
|             ValidationToastMsg::Close | ValidationToastMsg::AutoHide => { | ||||
|                 ctx.props().on_close.emit(()); | ||||
|                 false | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn changed(&mut self, ctx: &Context<Self>, old_props: &Self::Properties) -> bool { | ||||
|         // Set up auto-hide if toast became visible | ||||
|         if ctx.props().show && !old_props.show { | ||||
|             self.setup_auto_hide(ctx); | ||||
|         } | ||||
|         true | ||||
|     } | ||||
|  | ||||
|     fn view(&self, ctx: &Context<Self>) -> Html { | ||||
|         if !ctx.props().show || ctx.props().messages.is_empty() { | ||||
|             return html! {}; | ||||
|         } | ||||
|  | ||||
|         let link = ctx.link(); | ||||
|         let close_callback = link.callback(|_| ValidationToastMsg::Close); | ||||
|          | ||||
|         let position_class = ctx.props().position.get_position_class(); | ||||
|         let header_class = ctx.props().toast_type.get_header_class(); | ||||
|         let icon_class = ctx.props().toast_type.get_icon(); | ||||
|         let title = ctx.props().title | ||||
|             .as_ref() | ||||
|             .map(|t| t.as_str()) | ||||
|             .unwrap_or(ctx.props().toast_type.get_title()); | ||||
|  | ||||
|         html! { | ||||
|             <div class={position_class} style={format!("z-index: 1055; max-width: {};", ctx.props().max_width)}> | ||||
|                 <div class="toast show" role="alert" aria-live="assertive" aria-atomic="true"> | ||||
|                     <div class={format!("toast-header {}", header_class)}> | ||||
|                         <i class={format!("bi {} me-2", icon_class)}></i> | ||||
|                         <strong class="me-auto">{title}</strong> | ||||
|                         <button  | ||||
|                             type="button"  | ||||
|                             class="btn-close"  | ||||
|                             onclick={close_callback}  | ||||
|                             aria-label="Close" | ||||
|                         ></button> | ||||
|                     </div> | ||||
|                     <div class="toast-body"> | ||||
|                         {if ctx.props().messages.len() == 1 { | ||||
|                             html! { | ||||
|                                 <div>{&ctx.props().messages[0]}</div> | ||||
|                             } | ||||
|                         } else { | ||||
|                             html! { | ||||
|                                 <> | ||||
|                                     <div class="mb-2"> | ||||
|                                         <strong>{"Please address the following:"}</strong> | ||||
|                                     </div> | ||||
|                                     <ul class="list-unstyled mb-0"> | ||||
|                                         {for ctx.props().messages.iter().map(|message| { | ||||
|                                             html! { | ||||
|                                                 <li class="mb-1"> | ||||
|                                                     <i class="bi bi-dot text-danger me-1"></i> | ||||
|                                                     {message} | ||||
|                                                 </li> | ||||
|                                             } | ||||
|                                         })} | ||||
|                                     </ul> | ||||
|                                 </> | ||||
|                             } | ||||
|                         }} | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl ValidationToast { | ||||
|     fn setup_auto_hide(&mut self, ctx: &Context<Self>) { | ||||
|         if let Some(duration) = ctx.props().auto_hide_duration { | ||||
|             let link = ctx.link().clone(); | ||||
|             self._auto_hide_timeout = Some(Timeout::new(duration, move || { | ||||
|                 link.send_message(ValidationToastMsg::AutoHide); | ||||
|             })); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,7 +1,9 @@ | ||||
| pub mod common; | ||||
| pub mod entities; | ||||
| pub mod resident_landing_overlay; | ||||
| pub mod portal_home; | ||||
|  | ||||
| pub use common::*; | ||||
| pub use entities::*; | ||||
| pub use resident_landing_overlay::*; | ||||
| pub use portal_home::PortalHome; | ||||
		Reference in New Issue
	
	Block a user