refactor wip

This commit is contained in:
Timur Gordon 2025-06-28 16:40:54 +02:00
parent 21dcc4d97a
commit c1ea9483d7
15 changed files with 2320 additions and 0 deletions

View 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.

View 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;

View 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();
}
}

View 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")
}
}

View 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()
}
}

View 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};

View 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};

View 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>,
}

View 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(())
}
}

View 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>()
}

View 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>
}
}

View 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;

View 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>
}
}

View 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);
}));
}
}
}

View File

@ -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;