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