From c1ea9483d7a30661da81044adf55164002ca15e3 Mon Sep 17 00:00:00 2001 From: Timur Gordon <31495328+timurgordon@users.noreply.github.com> Date: Sat, 28 Jun 2025 16:40:54 +0200 Subject: [PATCH] refactor wip --- portal/REFACTORING_IMPLEMENTATION_PLAN.md | 365 +++++++++++++++++ portal/src/components/common/forms/mod.rs | 9 + .../common/forms/multi_step_form.rs | 384 ++++++++++++++++++ .../components/common/forms/step_validator.rs | 58 +++ .../common/forms/validation_result.rs | 69 ++++ portal/src/components/common/mod.rs | 10 + portal/src/components/common/payments/mod.rs | 9 + .../common/payments/payment_intent.rs | 143 +++++++ .../common/payments/stripe_payment_form.rs | 309 ++++++++++++++ .../common/payments/stripe_provider.rs | 247 +++++++++++ .../components/common/ui/loading_spinner.rs | 184 +++++++++ portal/src/components/common/ui/mod.rs | 9 + .../common/ui/progress_indicator.rs | 307 ++++++++++++++ .../components/common/ui/validation_toast.rs | 215 ++++++++++ portal/src/components/mod.rs | 2 + 15 files changed, 2320 insertions(+) create mode 100644 portal/REFACTORING_IMPLEMENTATION_PLAN.md create mode 100644 portal/src/components/common/forms/mod.rs create mode 100644 portal/src/components/common/forms/multi_step_form.rs create mode 100644 portal/src/components/common/forms/step_validator.rs create mode 100644 portal/src/components/common/forms/validation_result.rs create mode 100644 portal/src/components/common/mod.rs create mode 100644 portal/src/components/common/payments/mod.rs create mode 100644 portal/src/components/common/payments/payment_intent.rs create mode 100644 portal/src/components/common/payments/stripe_payment_form.rs create mode 100644 portal/src/components/common/payments/stripe_provider.rs create mode 100644 portal/src/components/common/ui/loading_spinner.rs create mode 100644 portal/src/components/common/ui/mod.rs create mode 100644 portal/src/components/common/ui/progress_indicator.rs create mode 100644 portal/src/components/common/ui/validation_toast.rs diff --git a/portal/REFACTORING_IMPLEMENTATION_PLAN.md b/portal/REFACTORING_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..4833eae --- /dev/null +++ b/portal/REFACTORING_IMPLEMENTATION_PLAN.md @@ -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 { + fn render(&self, ctx: &Context>, data: &T) -> Html; + fn get_title(&self) -> &'static str; + fn get_description(&self) -> &'static str; + fn get_icon(&self) -> &'static str; +} + +pub trait StepValidator { + fn validate(&self, data: &T) -> ValidationResult; +} +``` + +#### MultiStepForm Component +```rust +#[derive(Properties, PartialEq)] +pub struct MultiStepFormProps { + pub form_data: T, + pub on_form_change: Callback, + pub on_complete: Callback, + pub on_cancel: Option>, + pub steps: Vec>>, + pub validators: HashMap>>, + pub show_progress: bool, + pub allow_skip_validation: bool, +} + +pub enum MultiStepFormMsg { + NextStep, + PrevStep, + GoToStep(usize), + UpdateFormData(T), + Complete, + Cancel, + HideValidationToast, +} + +pub struct MultiStepForm { + current_step: usize, + form_data: T, + validation_errors: Vec, + 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; + fn get_amount(&self, data: &Self::FormData) -> f64; + fn get_description(&self, data: &Self::FormData) -> String; + fn get_metadata(&self, data: &Self::FormData) -> HashMap; +} +``` + +#### StripeProvider Component +```rust +#[derive(Properties, PartialEq)] +pub struct StripeProviderProps { + pub form_data: T, + pub payment_creator: Box>, + pub on_payment_complete: Callback, + pub on_payment_error: Callback, + pub endpoint_url: String, + pub auto_create_intent: bool, +} + +pub enum StripeProviderMsg { + CreatePaymentIntent, + PaymentIntentCreated(String), + PaymentIntentError(String), + ProcessPayment, + PaymentComplete, + PaymentError(String), +} + +pub struct StripeProvider { + client_secret: Option, + processing_payment: bool, + processing_intent: bool, + error: Option, +} +``` + +### 3. StripePaymentForm (`common/payments/stripe_payment_form.rs`) + +#### Component Structure +```rust +#[derive(Properties, PartialEq)] +pub struct StripePaymentFormProps { + pub client_secret: Option, + pub amount: f64, + pub currency: String, + pub description: String, + pub processing: bool, + pub on_payment_complete: Callback<()>, + pub on_payment_error: Callback, + pub show_amount: bool, + pub custom_button_text: Option, +} + +pub struct StripePaymentForm { + elements_initialized: bool, + payment_error: Option, +} +``` + +#### 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, + pub completed_steps: Vec, + 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, + pub show: bool, + pub on_close: Callback<()>, + pub auto_hide_duration: Option, + 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. \ No newline at end of file diff --git a/portal/src/components/common/forms/mod.rs b/portal/src/components/common/forms/mod.rs new file mode 100644 index 0000000..af95d1d --- /dev/null +++ b/portal/src/components/common/forms/mod.rs @@ -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; \ No newline at end of file diff --git a/portal/src/components/common/forms/multi_step_form.rs b/portal/src/components/common/forms/multi_step_form.rs new file mode 100644 index 0000000..d953953 --- /dev/null +++ b/portal/src/components/common/forms/multi_step_form.rs @@ -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 { + /// Render the step content + fn render(&self, ctx: &Context>, 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 { + /// Current form data + pub form_data: T, + + /// Callback when form data changes + pub on_form_change: Callback, + + /// Callback when form is completed + pub on_complete: Callback, + + /// Optional callback when form is cancelled + #[prop_or_default] + pub on_cancel: Option>, + + /// Form steps + pub steps: Vec>>, + + /// Step validators (step index -> validator) + #[prop_or_default] + pub validators: HashMap>>, + + /// 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 PartialEq for MultiStepFormProps { + 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 { + NextStep, + PrevStep, + GoToStep(usize), + UpdateFormData(T), + Complete, + Cancel, + HideValidationToast, +} + +/// MultiStepForm component state +pub struct MultiStepForm { + current_step: usize, + form_data: T, + validation_errors: Vec, + show_validation_toast: bool, + processing: bool, +} + +impl Component for MultiStepForm { + type Message = MultiStepFormMsg; + type Properties = MultiStepFormProps; + + fn create(ctx: &Context) -> 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, 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, _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) -> Html { + html! { +
+ {if ctx.props().show_progress { + self.render_progress_indicator(ctx) + } else { + html! {} + }} + +
+ {self.render_current_step(ctx)} +
+ + {self.render_navigation(ctx)} + + {if self.show_validation_toast { + self.render_validation_toast(ctx) + } else { + html! {} + }} +
+ } + } +} + +impl MultiStepForm { + fn render_current_step(&self, ctx: &Context) -> Html { + if let Some(step) = ctx.props().steps.get(self.current_step) { + step.render(ctx, &self.form_data) + } else { + html! {
{"Invalid step"}
} + } + } + + fn render_progress_indicator(&self, ctx: &Context) -> Html { + let total_steps = ctx.props().steps.len(); + + html! { +
+
+ {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! { +
+
+ {if is_completed { + html! { } + } else { + html! { {step_index + 1} } + }} +
+ {if step_index < total_steps - 1 { + html! { +
+ } + } else { + html! {} + }} +
+ } + })} +
+
+ } + } + + fn render_navigation(&self, ctx: &Context) -> 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! { + + } + } + + fn render_validation_toast(&self, ctx: &Context) -> Html { + let link = ctx.link(); + let close_toast = link.callback(|_| MultiStepFormMsg::HideValidationToast); + + html! { +
+ +
+ } + } + + fn auto_hide_validation_toast(&self, ctx: &Context) { + let link = ctx.link().clone(); + let duration = ctx.props().validation_toast_duration; + + Timeout::new(duration, move || { + link.send_message(MultiStepFormMsg::HideValidationToast); + }).forget(); + } +} \ No newline at end of file diff --git a/portal/src/components/common/forms/step_validator.rs b/portal/src/components/common/forms/step_validator.rs new file mode 100644 index 0000000..d9fd7eb --- /dev/null +++ b/portal/src/components/common/forms/step_validator.rs @@ -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 { + /// 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 { + 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 StepValidator 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 { + validators: Vec>>, +} + +impl CompositeValidator { + pub fn new(validators: Vec>>) -> Self { + Self { validators } + } +} + +impl StepValidator for CompositeValidator { + fn validate(&self, data: &T) -> ValidationResult { + let results: Vec = self.validators + .iter() + .map(|validator| validator.validate(data)) + .collect(); + + ValidationResult::combine(results) + } + + fn description(&self) -> Option<&'static str> { + Some("Composite validator") + } +} \ No newline at end of file diff --git a/portal/src/components/common/forms/validation_result.rs b/portal/src/components/common/forms/validation_result.rs new file mode 100644 index 0000000..67936ca --- /dev/null +++ b/portal/src/components/common/forms/validation_result.rs @@ -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, +} + +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) -> 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) -> 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() + } +} \ No newline at end of file diff --git a/portal/src/components/common/mod.rs b/portal/src/components/common/mod.rs new file mode 100644 index 0000000..b7710e8 --- /dev/null +++ b/portal/src/components/common/mod.rs @@ -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}; \ No newline at end of file diff --git a/portal/src/components/common/payments/mod.rs b/portal/src/components/common/payments/mod.rs new file mode 100644 index 0000000..3b4710d --- /dev/null +++ b/portal/src/components/common/payments/mod.rs @@ -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}; \ No newline at end of file diff --git a/portal/src/components/common/payments/payment_intent.rs b/portal/src/components/common/payments/payment_intent.rs new file mode 100644 index 0000000..3f5c7f6 --- /dev/null +++ b/portal/src/components/common/payments/payment_intent.rs @@ -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, +} + +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) -> 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, + pub customer_email: Option, + pub customer_id: Option, + + /// Additional custom fields + pub custom_fields: HashMap, +} + +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, email: Option, id: Option) -> 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, +} \ No newline at end of file diff --git a/portal/src/components/common/payments/stripe_payment_form.rs b/portal/src/components/common/payments/stripe_payment_form.rs new file mode 100644 index 0000000..3e75e87 --- /dev/null +++ b/portal/src/components/common/payments/stripe_payment_form.rs @@ -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, + + /// 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, +} + +/// Messages for StripePaymentForm component +pub enum StripePaymentFormMsg { + ProcessPayment, + PaymentComplete, + PaymentError(String), +} + +/// StripePaymentForm component state +pub struct StripePaymentForm { + elements_initialized: bool, + payment_error: Option, + stripe_context: Option, +} + +impl Component for StripePaymentForm { + type Message = StripePaymentFormMsg; + type Properties = StripePaymentFormProps; + + fn create(_ctx: &Context) -> Self { + Self { + elements_initialized: false, + payment_error: None, + stripe_context: None, + } + } + + fn update(&mut self, ctx: &Context, 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, _old_props: &Self::Properties) -> bool { + // Context will be updated via view method + true + } + + fn rendered(&mut self, _ctx: &Context, first_render: bool) { + if first_render { + // Stripe context will be handled in view method + // Initialize Stripe Elements if needed + } + } + + fn view(&self, ctx: &Context) -> Html { + // Get Stripe context from Yew context (not hook) + let stripe_ctx = ctx.link().context::(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! { +
+ {self.render_header(ctx)} + +
+ {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)} +
+
+ } + } +} + +impl StripePaymentForm { + fn render_header(&self, ctx: &Context) -> Html { + html! { +
+
+ + {"Secure Payment Processing"} +
+
+ } + } + + fn render_amount_display(&self, ctx: &Context) -> Html { + html! { +
+
+
+
+ {&ctx.props().description} +
+
+ {format!("${:.2}", ctx.props().amount)} +
+ + {format!("Amount in {}", ctx.props().currency)} + +
+ +
+
+ } + } + + fn render_payment_element(&self, _ctx: &Context, has_client_secret: bool, creating_intent: bool) -> Html { + html! { +
+ {if creating_intent { + html! { +
+
+ {"Loading..."} +
+

{"Preparing payment form..."}

+
+ } + } else if !has_client_secret { + html! { +
+
+ {"Loading..."} +
+

{"Initializing payment..."}

+
+ } + } else { + html! { + // Stripe Elements will be mounted here by JavaScript + } + }} +
+ } + } + + fn render_payment_button(&self, ctx: &Context) -> 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! { +
+ +
+ } + } + + fn render_errors(&self, _ctx: &Context, 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! { +
+ + {"Payment Error: "}{error} +
+ } + } else { + html! { + + } + } + } + + fn process_payment(&self, ctx: &Context, 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(()) + } +} \ No newline at end of file diff --git a/portal/src/components/common/payments/stripe_provider.rs b/portal/src/components/common/payments/stripe_provider.rs new file mode 100644 index 0000000..79b4a42 --- /dev/null +++ b/portal/src/components/common/payments/stripe_provider.rs @@ -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 { + /// Create a payment intent request from form data + fn create_payment_intent(&self, data: &T) -> Result; + + /// 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 { + /// Form data to create payment intent from + pub form_data: T, + + /// Payment intent creator implementation + pub payment_creator: Rc>, + + /// Callback when payment intent is created successfully + pub on_intent_created: Callback, + + /// Callback when payment intent creation fails + pub on_intent_error: Callback, + + /// 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 PartialEq for StripeProviderProps { + 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 { + client_secret: Option, + creating_intent: bool, + error: Option, + _phantom: std::marker::PhantomData, +} + +impl Component for StripeProvider { + type Message = StripeProviderMsg; + type Properties = StripeProviderProps; + + fn create(ctx: &Context) -> 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, 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, 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) -> 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! { + context={stripe_context}> + {for ctx.props().children.iter()} + > + } + } +} + +impl StripeProvider { + fn create_payment_intent(&self, ctx: &Context) { + 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>, + form_data: T, + ) -> Result { + 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, + pub creating_intent: bool, + pub error: Option, + pub create_intent: Callback<()>, +} + +/// Hook to use Stripe context +#[hook] +pub fn use_stripe() -> Option { + use_context::() +} \ No newline at end of file diff --git a/portal/src/components/common/ui/loading_spinner.rs b/portal/src/components/common/ui/loading_spinner.rs new file mode 100644 index 0000000..4117bf6 --- /dev/null +++ b/portal/src/components/common/ui/loading_spinner.rs @@ -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, + + /// Whether to center the spinner + #[prop_or(true)] + pub centered: bool, + + /// Custom CSS class for container + #[prop_or_default] + pub container_class: Option, + + /// 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! { +
+ + + {if let Some(message) = &props.message { + let message_class = if props.inline { + "ms-2" + } else { + "mt-2" + }; + + html! { +
+ {message.as_str()} +
+ } + } else { + html! {} + }} +
+ } +} + +/// 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! { +
+
+ +
+
+ } +} \ No newline at end of file diff --git a/portal/src/components/common/ui/mod.rs b/portal/src/components/common/ui/mod.rs new file mode 100644 index 0000000..dfcf7ca --- /dev/null +++ b/portal/src/components/common/ui/mod.rs @@ -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; \ No newline at end of file diff --git a/portal/src/components/common/ui/progress_indicator.rs b/portal/src/components/common/ui/progress_indicator.rs new file mode 100644 index 0000000..43af711 --- /dev/null +++ b/portal/src/components/common/ui/progress_indicator.rs @@ -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, + + /// Completed steps (optional, defaults to all steps before current) + #[prop_or_default] + pub completed_steps: Option>, + + /// 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 = 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! { +
+
+ {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! { +
+
+ {if is_completed && !is_current { + html! { } + } else if props.show_step_numbers { + html! { {step_index + 1} } + } else { + html! {} + }} +
+ + {if step_index < props.total_steps - 1 { + let connector_class = if is_completed { + props.color.get_connector_color() + } else { + "bg-secondary" + }; + + html! { +
+ } + } else { + html! {} + }} +
+ } + })} +
+ + {if props.show_step_titles && !props.step_titles.is_empty() { + html! { +
+ {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! { + + {title} + + } + })} +
+ } + } else { + html! {} + }} +
+ } +} + +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! { +
+
+
+
+ +
+ + {format!("Step {} of {}", props.current_step + 1, props.total_steps)} + + + {format!("{:.0}% Complete", progress_percentage)} + +
+
+ } +} + +fn render_steps_variant( + props: &ProgressIndicatorProps, + completed_steps: &[usize], +) -> Html { + html! { +
+
+ {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! { +
+
+
+ +
+ {step_title} +
+
+
+
+ } + })} +
+
+ } +} \ No newline at end of file diff --git a/portal/src/components/common/ui/validation_toast.rs b/portal/src/components/common/ui/validation_toast.rs new file mode 100644 index 0000000..2c7fbd9 --- /dev/null +++ b/portal/src/components/common/ui/validation_toast.rs @@ -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, + + /// 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, + + /// Custom title for the toast + #[prop_or_default] + pub title: Option, + + /// 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, +} + +impl Component for ValidationToast { + type Message = ValidationToastMsg; + type Properties = ValidationToastProps; + + fn create(ctx: &Context) -> 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, msg: Self::Message) -> bool { + match msg { + ValidationToastMsg::Close | ValidationToastMsg::AutoHide => { + ctx.props().on_close.emit(()); + false + } + } + } + + fn changed(&mut self, ctx: &Context, 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) -> 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! { +
+ +
+ } + } +} + +impl ValidationToast { + fn setup_auto_hide(&mut self, ctx: &Context) { + 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); + })); + } + } +} \ No newline at end of file diff --git a/portal/src/components/mod.rs b/portal/src/components/mod.rs index 5c03a1d..4d329cf 100644 --- a/portal/src/components/mod.rs +++ b/portal/src/components/mod.rs @@ -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; \ No newline at end of file