initial commit
This commit is contained in:
		
							
								
								
									
										16
									
								
								portal/.env.example
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								portal/.env.example
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| # Stripe Configuration | ||||
| STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key_here | ||||
| STRIPE_SECRET_KEY=sk_test_your_secret_key_here | ||||
| STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here | ||||
|  | ||||
| # Server Configuration | ||||
| PORT=8080 | ||||
| HOST=127.0.0.1 | ||||
| RUST_LOG=info | ||||
|  | ||||
| # Database (if needed) | ||||
| DATABASE_URL=sqlite:./data/app.db | ||||
|  | ||||
| # Security | ||||
| JWT_SECRET=your_jwt_secret_here | ||||
| CORS_ORIGIN=http://127.0.0.1:8080 | ||||
							
								
								
									
										1518
									
								
								portal/Cargo.lock
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										1518
									
								
								portal/Cargo.lock
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										49
									
								
								portal/Cargo.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								portal/Cargo.toml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,49 @@ | ||||
| [package] | ||||
| name = "zanzibar-freezone-portal" | ||||
| version = "0.1.0" | ||||
| edition = "2021" | ||||
|  | ||||
| [lib] | ||||
| crate-type = ["cdylib"] | ||||
|  | ||||
| [dependencies] | ||||
| # Frontend (WASM) dependencies | ||||
| yew = { version = "0.21", features = ["csr"] } | ||||
| web-sys = { version = "0.3", features = [ | ||||
|     "console", | ||||
|     "Document", | ||||
|     "Element", | ||||
|     "HtmlElement", | ||||
|     "HtmlInputElement", | ||||
|     "HtmlSelectElement", | ||||
|     "HtmlTextAreaElement", | ||||
|     "HtmlFormElement", | ||||
|     "Location", | ||||
|     "Window", | ||||
|     "History", | ||||
|     "MouseEvent", | ||||
|     "Event", | ||||
|     "EventTarget", | ||||
|     "Storage", | ||||
|     "UrlSearchParams", | ||||
|     "Request", | ||||
|     "RequestInit", | ||||
|     "RequestMode", | ||||
|     "Response", | ||||
|     "Headers" | ||||
| ] } | ||||
| wasm-bindgen = "0.2" | ||||
| wasm-bindgen-futures = "0.4" | ||||
| js-sys = "0.3" | ||||
| log = "0.4" | ||||
| wasm-logger = "0.2" | ||||
| gloo = { version = "0.10", features = ["storage", "timers", "events"] } | ||||
| gloo-utils = "0.2" | ||||
| serde = { version = "1.0", features = ["derive"] } | ||||
| serde_json = "1.0" | ||||
| base64 = "0.21" | ||||
| uuid = { version = "1.0", features = ["v4", "js"] } | ||||
| chrono = { version = "0.4", features = ["serde", "wasm-bindgen"] } | ||||
|  | ||||
| [dev-dependencies] | ||||
| wasm-bindgen-test = "0.3" | ||||
							
								
								
									
										66
									
								
								portal/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								portal/README.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,66 @@ | ||||
| # Zanzibar Digital Freezone Portal | ||||
|  | ||||
| This is the entry portal for the Zanzibar Digital Freezone platform. It provides a streamlined registration and login interface for digital residents. | ||||
|  | ||||
| ## Features | ||||
|  | ||||
| - **Digital Resident Registration**: Complete multi-step registration process with KYC | ||||
| - **Stripe Payment Integration**: Secure payment processing for registration fees | ||||
| - **Responsive Design**: Works on desktop and mobile devices | ||||
| - **Real-time Validation**: Form validation and error handling | ||||
| - **Animated UI**: Smooth transitions and professional interface | ||||
| - **Fresh Start**: No form persistence - users start fresh each time for simplicity | ||||
|  | ||||
| ## What's Included | ||||
|  | ||||
| - Resident registration overlay with expandable form | ||||
| - Stripe Elements integration for secure payments | ||||
| - Form validation and error handling | ||||
| - Responsive Bootstrap-based design | ||||
| - WASM-based Yew frontend | ||||
|  | ||||
| ## What's Removed | ||||
|  | ||||
| This portal is a stripped-down version of the main platform that only includes: | ||||
| - Resident registration components | ||||
| - Stripe payment integration | ||||
| - Essential models and services | ||||
|  | ||||
| Removed components: | ||||
| - Company registration | ||||
| - Treasury dashboard | ||||
| - Accounting system | ||||
| - Business management features | ||||
| - Admin panels | ||||
| - Full platform navigation | ||||
|  | ||||
| ## Building and Running | ||||
|  | ||||
| ```bash | ||||
| # Install trunk if you haven't already | ||||
| cargo install trunk | ||||
|  | ||||
| # Build the WASM application | ||||
| trunk build | ||||
|  | ||||
| # Serve for development | ||||
| trunk serve | ||||
| ``` | ||||
|  | ||||
| ## Stripe Configuration | ||||
|  | ||||
| Update the Stripe publishable key in `index.html`: | ||||
|  | ||||
| ```javascript | ||||
| const STRIPE_PUBLISHABLE_KEY = 'pk_test_your_actual_key_here'; | ||||
| ``` | ||||
|  | ||||
| ## Server Integration | ||||
|  | ||||
| The portal expects a server running on `http://127.0.0.1:3001` with the following endpoints: | ||||
|  | ||||
| - `POST /resident/create-payment-intent` - Create payment intent for resident registration | ||||
|  | ||||
| ## Purpose | ||||
|  | ||||
| This portal serves as the entry point for new users who want to become digital residents. Once they complete registration, they can be redirected to the full platform. | ||||
							
								
								
									
										2
									
								
								portal/Trunk.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								portal/Trunk.toml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
| [build] | ||||
| target = "index.html" | ||||
							
								
								
									
										334
									
								
								portal/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										334
									
								
								portal/index.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,334 @@ | ||||
| <!DOCTYPE html> | ||||
| <html lang="en"> | ||||
| <head> | ||||
|     <meta charset="utf-8" /> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1" /> | ||||
|     <title>Zanzibar Digital Freezone Portal</title> | ||||
|      | ||||
|     <!-- Bootstrap CSS --> | ||||
|     <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> | ||||
|      | ||||
|     <!-- Bootstrap Icons --> | ||||
|     <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet"> | ||||
|      | ||||
|     <!-- Custom CSS --> | ||||
|     <style> | ||||
|         /* Stripe Elements styling */ | ||||
|         #payment-element { | ||||
|             min-height: 40px; | ||||
|             padding: 10px; | ||||
|             border: 1px solid #dee2e6; | ||||
|             border-radius: 0.375rem; | ||||
|             background-color: #ffffff; | ||||
|         } | ||||
|  | ||||
|         .payment-ready { | ||||
|             border-color: #198754 !important; | ||||
|             border-width: 2px !important; | ||||
|             box-shadow: 0 0 0 0.2rem rgba(25, 135, 84, 0.25) !important; | ||||
|         } | ||||
|  | ||||
|         /* Loading state for payment form */ | ||||
|         .payment-loading { | ||||
|             opacity: 0.7; | ||||
|             pointer-events: none; | ||||
|         } | ||||
|  | ||||
|         /* Error display styling */ | ||||
|         #payment-errors { | ||||
|             margin-top: 1rem; | ||||
|             margin-bottom: 1rem; | ||||
|             display: none; | ||||
|         } | ||||
|  | ||||
|         /* Fade in animation for registration form */ | ||||
|         @keyframes fadeIn {  | ||||
|             from { opacity: 0; }  | ||||
|             to { opacity: 1; }  | ||||
|         } | ||||
|  | ||||
|         /* Transition animations */ | ||||
|         .transition-all { | ||||
|             transition: all 0.5s ease-in-out; | ||||
|         } | ||||
|     </style> | ||||
| </head> | ||||
| <body> | ||||
|     <div id="app"></div> | ||||
|      | ||||
|     <!-- Bootstrap JS --> | ||||
|     <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> | ||||
|      | ||||
|     <!-- Stripe JavaScript SDK --> | ||||
|     <script src="https://js.stripe.com/v3/"></script> | ||||
|      | ||||
|     <!-- Stripe Integration for Portal --> | ||||
|     <script> | ||||
|         let stripe; | ||||
|         let elements; | ||||
|         let paymentElement; | ||||
|  | ||||
|         // Stripe publishable key - replace with your actual key from Stripe Dashboard | ||||
|         const STRIPE_PUBLISHABLE_KEY = 'pk_test_51MCkZTC7LG8OeRdIcqmmoDkRwDObXSwYdChprMHJYoD2VRO8OCDBV5KtegLI0tLFXJo9yyvEXi7jzk1NAB5owj8i00DkYSaV9y'; | ||||
|  | ||||
|         // Initialize Stripe when the script loads | ||||
|         document.addEventListener('DOMContentLoaded', function() { | ||||
|             console.log('🔧 Zanzibar Portal Stripe integration loaded'); | ||||
|              | ||||
|             // Initialize Stripe | ||||
|             if (window.Stripe) { | ||||
|                 stripe = Stripe(STRIPE_PUBLISHABLE_KEY); | ||||
|                 console.log('✅ Stripe initialized for portal'); | ||||
|             } else { | ||||
|                 console.error('❌ Stripe.js not loaded'); | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         // Create payment intent on server (supports both company and resident registration) | ||||
|         window.createPaymentIntent = async function(formDataJson) { | ||||
|             console.log('💳 Creating payment intent...'); | ||||
|              | ||||
|             try { | ||||
|                 // Parse the JSON string from Rust | ||||
|                 let formData; | ||||
|                 if (typeof formDataJson === 'string') { | ||||
|                     formData = JSON.parse(formDataJson); | ||||
|                 } else { | ||||
|                     formData = formDataJson; | ||||
|                 } | ||||
|                  | ||||
|                 // Determine endpoint based on registration type | ||||
|                 const isResidentRegistration = formData.type === 'resident_registration'; | ||||
|                 const endpoint = isResidentRegistration  | ||||
|                     ? 'http://127.0.0.1:3001/resident/create-payment-intent' | ||||
|                     : 'http://127.0.0.1:3001/company/create-payment-intent'; | ||||
|                  | ||||
|                 console.log('📋 Registration type:', isResidentRegistration ? 'Resident' : 'Company'); | ||||
|                 console.log('🔧 Server endpoint:', endpoint); | ||||
|                  | ||||
|                 const response = await fetch(endpoint, { | ||||
|                     method: 'POST', | ||||
|                     headers: { | ||||
|                         'Content-Type': 'application/json', | ||||
|                     }, | ||||
|                     body: JSON.stringify(formData) | ||||
|                 }); | ||||
|  | ||||
|                 console.log('📡 Server response status:', response.status); | ||||
|  | ||||
|                 if (!response.ok) { | ||||
|                     const errorText = await response.text(); | ||||
|                     console.error('❌ Payment intent creation failed:', errorText); | ||||
|                      | ||||
|                     let errorData; | ||||
|                     try { | ||||
|                         errorData = JSON.parse(errorText); | ||||
|                     } catch (e) { | ||||
|                         errorData = { error: errorText }; | ||||
|                     } | ||||
|                      | ||||
|                     const errorMsg = errorData.error || 'Failed to create payment intent'; | ||||
|                     console.error('💥 Error details:', errorData); | ||||
|                     throw new Error(errorMsg); | ||||
|                 } | ||||
|  | ||||
|                 const responseData = await response.json(); | ||||
|                 console.log('✅ Payment intent created successfully'); | ||||
|                 console.log('🔑 Client secret received:', responseData.client_secret ? 'Yes' : 'No'); | ||||
|                  | ||||
|                 const { client_secret } = responseData; | ||||
|                 if (!client_secret) { | ||||
|                     throw new Error('No client secret received from server'); | ||||
|                 } | ||||
|  | ||||
|                 return client_secret; | ||||
|                  | ||||
|             } catch (error) { | ||||
|                 console.error('❌ Payment intent creation error:', error.message); | ||||
|                 console.error('🔧 Troubleshooting:'); | ||||
|                 console.error('   1. Check if server is running on port 3001'); | ||||
|                 console.error('   2. Verify Stripe API keys in .env file'); | ||||
|                 console.error('   3. Check server logs for detailed error info'); | ||||
|                 throw error; | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         // Initialize Stripe Elements with client secret | ||||
|         window.initializeStripeElements = async function(clientSecret) { | ||||
|             console.log('🔧 Initializing Stripe Elements...'); | ||||
|             console.log('🔑 Client secret format check:', clientSecret ? 'Valid' : 'Missing'); | ||||
|              | ||||
|             try { | ||||
|                 if (!stripe) { | ||||
|                     throw new Error('Stripe not initialized - check your publishable key'); | ||||
|                 } | ||||
|  | ||||
|                 // Create Elements instance with client secret | ||||
|                 elements = stripe.elements({ | ||||
|                     clientSecret: clientSecret, | ||||
|                     appearance: { | ||||
|                         theme: 'stripe', | ||||
|                         variables: { | ||||
|                             colorPrimary: '#0099FF', | ||||
|                             colorBackground: '#ffffff', | ||||
|                             colorText: '#30313d', | ||||
|                             colorDanger: '#df1b41', | ||||
|                             fontFamily: 'system-ui, sans-serif', | ||||
|                             spacingUnit: '4px', | ||||
|                             borderRadius: '6px', | ||||
|                         } | ||||
|                     } | ||||
|                 }); | ||||
|  | ||||
|                 // Clear the payment element container first | ||||
|                 const paymentElementDiv = document.getElementById('payment-element'); | ||||
|                 if (!paymentElementDiv) { | ||||
|                     throw new Error('Payment element container not found'); | ||||
|                 } | ||||
|                  | ||||
|                 paymentElementDiv.innerHTML = ''; | ||||
|  | ||||
|                 // Create and mount the Payment Element | ||||
|                 paymentElement = elements.create('payment'); | ||||
|                 paymentElement.mount('#payment-element'); | ||||
|  | ||||
|                 // Handle real-time validation errors from the Payment Element | ||||
|                 paymentElement.on('change', (event) => { | ||||
|                     const displayError = document.getElementById('payment-errors'); | ||||
|                     if (event.error) { | ||||
|                         displayError.textContent = event.error.message; | ||||
|                         displayError.style.display = 'block'; | ||||
|                         displayError.classList.remove('alert-success'); | ||||
|                         displayError.classList.add('alert-danger'); | ||||
|                     } else { | ||||
|                         displayError.style.display = 'none'; | ||||
|                     } | ||||
|                 }); | ||||
|  | ||||
|                 // Handle when the Payment Element is ready | ||||
|                 paymentElement.on('ready', () => { | ||||
|                     console.log('✅ Stripe Elements ready for payment'); | ||||
|  | ||||
|                     // Add a subtle success indicator | ||||
|                     const paymentCard = paymentElementDiv.closest('.card'); | ||||
|                     if (paymentCard) { | ||||
|                         paymentCard.style.borderColor = '#0099FF'; | ||||
|                         paymentCard.style.borderWidth = '2px'; | ||||
|                     } | ||||
|  | ||||
|                     // Update button text to show payment is ready | ||||
|                     const submitButton = document.getElementById('submit-payment'); | ||||
|                     const submitText = document.getElementById('submit-text'); | ||||
|                     if (submitButton && submitText) { | ||||
|                         submitButton.disabled = false; | ||||
|                         submitText.textContent = 'Complete Payment'; | ||||
|                         submitButton.classList.remove('btn-secondary'); | ||||
|                         submitButton.classList.add('btn-success'); | ||||
|                     } | ||||
|                 }); | ||||
|  | ||||
|                 console.log('✅ Stripe Elements initialized successfully'); | ||||
|                 return true; | ||||
|  | ||||
|             } catch (error) { | ||||
|                 console.error('❌ Error initializing Stripe Elements:', error); | ||||
|                  | ||||
|                 // Show helpful error message | ||||
|                 const errorElement = document.getElementById('payment-errors'); | ||||
|                 if (errorElement) { | ||||
|                     errorElement.innerHTML = ` | ||||
|                         <div class="alert alert-warning alert-dismissible" role="alert"> | ||||
|                             <i class="bi bi-exclamation-triangle me-2"></i> | ||||
|                             <strong>Stripe Setup Required:</strong> ${error.message || 'Failed to load payment form'}<br><br> | ||||
|                             <strong>Next Steps:</strong><br> | ||||
|                             1. Get your Stripe API keys from <a href="https://dashboard.stripe.com/apikeys" target="_blank">Stripe Dashboard</a><br> | ||||
|                             2. Replace the placeholder publishable key in the code<br> | ||||
|                             3. Set up a server to create payment intents<br><br> | ||||
|                             <small>The integration is complete - you just need real Stripe credentials!</small> | ||||
|                             <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> | ||||
|                         </div> | ||||
|                     `; | ||||
|                     errorElement.style.display = 'block'; | ||||
|                 } | ||||
|                 throw error; | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         // Confirm payment with Stripe | ||||
|         window.confirmStripePayment = async function(clientSecret) { | ||||
|             console.log('🔄 Confirming payment...'); | ||||
|              | ||||
|             try { | ||||
|                 // Ensure elements are ready before submitting | ||||
|                 if (!elements) { | ||||
|                     console.error('❌ Payment elements not initialized'); | ||||
|                     throw new Error('Payment form not ready. Please wait a moment and try again.'); | ||||
|                 } | ||||
|  | ||||
|                 console.log('🔄 Step 1: Submitting payment elements...'); | ||||
|  | ||||
|                 // Step 1: Submit the payment elements first (required by new Stripe API) | ||||
|                 const { error: submitError } = await elements.submit(); | ||||
|  | ||||
|                 if (submitError) { | ||||
|                     console.error('❌ Elements submit failed:', submitError); | ||||
|                     throw new Error(submitError.message || 'Payment form validation failed.'); | ||||
|                 } | ||||
|  | ||||
|                 console.log('✅ Step 1 complete: Elements submitted successfully'); | ||||
|                 console.log('🔄 Step 2: Confirming payment with Stripe...'); | ||||
|  | ||||
|                 // Step 2: Confirm payment with Stripe | ||||
|                 const { error, paymentIntent } = await stripe.confirmPayment({ | ||||
|                     elements, | ||||
|                     clientSecret: clientSecret, | ||||
|                     confirmParams: { | ||||
|                         return_url: `${window.location.origin}/success`, | ||||
|                     }, | ||||
|                     redirect: 'if_required' | ||||
|                 }); | ||||
|  | ||||
|                 if (error) { | ||||
|                     console.error('❌ Payment confirmation failed:', error); | ||||
|                     throw new Error(error.message); | ||||
|                 } | ||||
|  | ||||
|                 if (paymentIntent && paymentIntent.status === 'succeeded') { | ||||
|                     console.log('✅ Payment completed successfully!'); | ||||
|                     console.log('🆔 Payment Intent ID:', paymentIntent.id); | ||||
|                      | ||||
|                     return true; | ||||
|                 } else { | ||||
|                     console.error('❌ Unexpected payment status:', paymentIntent?.status); | ||||
|                     throw new Error('Payment processing failed. Please try again.'); | ||||
|                 } | ||||
|  | ||||
|             } catch (error) { | ||||
|                 console.error('❌ Payment confirmation error:', error.message); | ||||
|                 throw error; | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         console.log('✅ Zanzibar Portal Stripe integration ready'); | ||||
|         console.log('🏠 Portal supports resident registration with Stripe payments'); | ||||
|     </script> | ||||
|      | ||||
|     <!-- WASM Application --> | ||||
|     <script type="module"> | ||||
|         async function run() { | ||||
|             try { | ||||
|                 // Load the WASM module for the Yew application | ||||
|                 const init = await import('./pkg/zanzibar_freezone_portal.js'); | ||||
|                 await init.default(); | ||||
|                 console.log('✅ Zanzibar Digital Freezone Portal initialized'); | ||||
|                 console.log('🏠 Portal ready for resident registration'); | ||||
|             } catch (error) { | ||||
|                 console.error('❌ Failed to initialize WASM application:', error); | ||||
|                 console.error('🔧 Make sure to build the WASM module with: trunk build'); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         run(); | ||||
|     </script> | ||||
| </body> | ||||
| </html> | ||||
							
								
								
									
										93
									
								
								portal/src/app.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								portal/src/app.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,93 @@ | ||||
| use yew::prelude::*; | ||||
| use crate::components::{ResidentLandingOverlay, PortalHome}; | ||||
| use crate::models::company::{DigitalResident, DigitalResidentFormData}; | ||||
|  | ||||
| #[derive(Clone, Debug)] | ||||
| pub enum Msg { | ||||
|     ResidentSignIn(String, String), // private_key, unused | ||||
|     ResidentRegistrationComplete(DigitalResident), | ||||
| } | ||||
|  | ||||
| pub struct App { | ||||
|     is_logged_in: bool, | ||||
|     user_name: Option<String>, | ||||
|     registration_data: Option<DigitalResidentFormData>, | ||||
| } | ||||
|  | ||||
| impl Component for App { | ||||
|     type Message = Msg; | ||||
|     type Properties = (); | ||||
|  | ||||
|     fn create(_ctx: &Context<Self>) -> Self { | ||||
|         wasm_logger::init(wasm_logger::Config::default()); | ||||
|         log::info!("Starting Zanzibar Digital Freezone Portal"); | ||||
|  | ||||
|         Self { | ||||
|             is_logged_in: false, | ||||
|             user_name: None, | ||||
|             registration_data: None, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool { | ||||
|         match msg { | ||||
|             Msg::ResidentSignIn(email, _password) => { | ||||
|                 // Handle resident sign in - for now just log them in | ||||
|                 log::info!("Resident sign in attempt: {}", email); | ||||
|                 self.is_logged_in = true; | ||||
|                 self.user_name = Some(email); | ||||
|                 true | ||||
|             } | ||||
|             Msg::ResidentRegistrationComplete(resident) => { | ||||
|                 // Handle successful resident registration | ||||
|                 self.is_logged_in = true; | ||||
|                 self.user_name = Some(resident.full_name.clone()); | ||||
|                  | ||||
|                 // Convert DigitalResident to DigitalResidentFormData for the residence card | ||||
|                 self.registration_data = Some(DigitalResidentFormData { | ||||
|                     full_name: resident.full_name, | ||||
|                     email: resident.email, | ||||
|                     phone: resident.phone, | ||||
|                     date_of_birth: resident.date_of_birth, | ||||
|                     nationality: resident.nationality, | ||||
|                     passport_number: resident.passport_number, | ||||
|                     passport_expiry: resident.passport_expiry, | ||||
|                     current_address: resident.current_address, | ||||
|                     city: resident.city, | ||||
|                     country: resident.country, | ||||
|                     postal_code: resident.postal_code, | ||||
|                     occupation: resident.occupation, | ||||
|                     employer: resident.employer, | ||||
|                     annual_income: resident.annual_income, | ||||
|                     education_level: resident.education_level, | ||||
|                     public_key: resident.public_key, | ||||
|                     ..DigitalResidentFormData::default() | ||||
|                 }); | ||||
|                 true | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn view(&self, ctx: &Context<Self>) -> Html { | ||||
|         let link = ctx.link(); | ||||
|  | ||||
|         // If user is logged in, show the portal home page | ||||
|         if self.is_logged_in { | ||||
|             return html! { | ||||
|                 <PortalHome | ||||
|                     user_name={self.user_name.as_ref().unwrap_or(&"Digital Resident".to_string()).clone()} | ||||
|                     registration_data={self.registration_data.clone()} | ||||
|                 /> | ||||
|             }; | ||||
|         } | ||||
|  | ||||
|         // Show the registration/login overlay | ||||
|         html! { | ||||
|             <ResidentLandingOverlay | ||||
|                 on_registration_complete={link.callback(|resident| Msg::ResidentRegistrationComplete(resident))} | ||||
|                 on_sign_in={link.callback(|(private_key, _)| Msg::ResidentSignIn(private_key, "".to_string()))} | ||||
|                 on_close={None::<Callback<()>>} // No close button since this is the main portal | ||||
|             /> | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										3
									
								
								portal/src/components/entities/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								portal/src/components/entities/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| pub mod resident_registration; | ||||
|  | ||||
| pub use resident_registration::*; | ||||
| @@ -0,0 +1,9 @@ | ||||
| pub mod step_payment_stripe; | ||||
| pub mod simple_resident_wizard; | ||||
| pub mod simple_step_info; | ||||
| pub mod residence_card; | ||||
|  | ||||
| pub use step_payment_stripe::*; | ||||
| pub use simple_resident_wizard::*; | ||||
| pub use simple_step_info::*; | ||||
| pub use residence_card::*; | ||||
| @@ -0,0 +1,96 @@ | ||||
| use yew::prelude::*; | ||||
| use crate::models::company::DigitalResidentFormData; | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct ResidenceCardProps { | ||||
|     pub form_data: DigitalResidentFormData, | ||||
| } | ||||
|  | ||||
| #[function_component(ResidenceCard)] | ||||
| pub fn residence_card(props: &ResidenceCardProps) -> Html { | ||||
|     let form_data = &props.form_data; | ||||
|  | ||||
|     html! { | ||||
|         <div class="d-flex align-items-center justify-content-center h-100"> | ||||
|             <div class="residence-card"> | ||||
|                 <div class="card border-0 shadow-lg" style="width: 350px; background: white; border-radius: 15px;"> | ||||
|                     // Header with Zanzibar flag gradient | ||||
|                     <div style="background: linear-gradient(135deg, #0099FF 0%, #00CC66 100%); height: 80px; border-radius: 15px 15px 0 0; position: relative;"> | ||||
|                         <div class="position-absolute top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-between px-4"> | ||||
|                             <div> | ||||
|                                 <h6 class="mb-0 text-white" style="font-size: 0.9rem; font-weight: 600;">{"DIGITAL RESIDENT"}</h6> | ||||
|                                 <small class="text-white" style="opacity: 0.9; font-size: 0.75rem;">{"Zanzibar Digital Freezone"}</small> | ||||
|                             </div> | ||||
|                             <i class="bi bi-shield-check-fill text-white" style="font-size: 1.5rem; opacity: 0.9;"></i> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                      | ||||
|                     // Card body with white background | ||||
|                     <div class="card-body p-4" style="background: white; border-radius: 0 0 15px 15px;"> | ||||
|                         <div class="mb-3"> | ||||
|                             <div class="text-muted small" style="font-size: 0.7rem; font-weight: 500; letter-spacing: 0.5px;">{"FULL NAME"}</div> | ||||
|                             <div class="h5 mb-0 text-dark" style="font-weight: 600;"> | ||||
|                                 {if form_data.full_name.is_empty() { | ||||
|                                     "Your Name Here" | ||||
|                                 } else { | ||||
|                                     &form_data.full_name | ||||
|                                 }} | ||||
|                             </div> | ||||
|                         </div> | ||||
|                          | ||||
|                         <div class="mb-3"> | ||||
|                             <div class="text-muted small" style="font-size: 0.7rem; font-weight: 500; letter-spacing: 0.5px;">{"EMAIL"}</div> | ||||
|                             <div class="text-dark" style="font-size: 0.9rem;"> | ||||
|                                 {if form_data.email.is_empty() { | ||||
|                                     "your.email@example.com" | ||||
|                                 } else { | ||||
|                                     &form_data.email | ||||
|                                 }} | ||||
|                             </div> | ||||
|                         </div> | ||||
|                          | ||||
|                         <div class="mb-3"> | ||||
|                             <div class="text-muted small d-flex align-items-center" style="font-size: 0.7rem; font-weight: 500; letter-spacing: 0.5px;"> | ||||
|                                 <i class="bi bi-key me-1" style="font-size: 0.8rem;"></i> | ||||
|                                 {"PUBLIC KEY"} | ||||
|                             </div> | ||||
|                             <div class="text-dark" style="font-size: 0.7rem; font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; word-break: break-all; line-height: 1.3;"> | ||||
|                                 {if let Some(public_key) = &form_data.public_key { | ||||
|                                     format!("{}...", &public_key[..std::cmp::min(24, public_key.len())]) | ||||
|                                 } else { | ||||
|                                     "- - - - - - - - - - - - - - - -".to_string() | ||||
|                                 }} | ||||
|                             </div> | ||||
|                         </div> | ||||
|                          | ||||
|                         <div class="mb-3"> | ||||
|                             <div class="text-muted small" style="font-size: 0.7rem; font-weight: 500; letter-spacing: 0.5px;">{"RESIDENT SINCE"}</div> | ||||
|                             <div class="text-dark" style="font-size: 0.8rem;"> | ||||
|                                 {"2025"} | ||||
|                             </div> | ||||
|                         </div> | ||||
|                          | ||||
|                         <div class="d-flex justify-content-between align-items-end mb-3"> | ||||
|                             <div> | ||||
|                                 <div class="text-muted small" style="font-size: 0.7rem; font-weight: 500; letter-spacing: 0.5px;">{"RESIDENT ID"}</div> | ||||
|                                 <div class="text-dark" style="font-weight: 600;">{"ZDF-2025-****"}</div> | ||||
|                             </div> | ||||
|                             <div class="text-end"> | ||||
|                                 <div class="text-muted small" style="font-size: 0.7rem; font-weight: 500; letter-spacing: 0.5px;">{"STATUS"}</div> | ||||
|                                 <div class="badge" style="background: #ffc107; color: #212529; font-weight: 500;">{"PENDING"}</div> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                          | ||||
|                         // QR Code at bottom | ||||
|                         <div class="text-center border-top pt-3" style="border-color: #e9ecef !important;"> | ||||
|                             <div class="d-inline-block p-2 rounded" style="background: #f8f9fa;"> | ||||
|                                 <div style="width: 60px; height: 60px; background: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAgMCA2MCA2MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjYwIiBoZWlnaHQ9IjYwIiBmaWxsPSJ3aGl0ZSIvPgo8cmVjdCB4PSI0IiB5PSI0IiB3aWR0aD0iMTIiIGhlaWdodD0iMTIiIGZpbGw9ImJsYWNrIi8+CjxyZWN0IHg9IjIwIiB5PSI0IiB3aWR0aD0iNCIgaGVpZ2h0PSI0IiBmaWxsPSJibGFjayIvPgo8cmVjdCB4PSIyOCIgeT0iNCIgd2lkdGg9IjQiIGhlaWdodD0iNCIgZmlsbD0iYmxhY2siLz4KPHJlY3QgeD0iNDQiIHk9IjQiIHdpZHRoPSIxMiIgaGVpZ2h0PSIxMiIgZmlsbD0iYmxhY2siLz4KPHJlY3QgeD0iOCIgeT0iOCIgd2lkdGg9IjQiIGhlaWdodD0iNCIgZmlsbD0id2hpdGUiLz4KPHJlY3QgeD0iNDgiIHk9IjgiIHdpZHRoPSI0IiBoZWlnaHQ9IjQiIGZpbGw9IndoaXRlIi8+CjxyZWN0IHg9IjIwIiB5PSIxMiIgd2lkdGg9IjQiIGhlaWdodD0iNCIgZmlsbD0iYmxhY2siLz4KPHJlY3QgeD0iMzYiIHk9IjEyIiB3aWR0aD0iNCIgaGVpZ2h0PSI0IiBmaWxsPSJibGFjayIvPgo8cmVjdCB4PSI0IiB5PSIyMCIgd2lkdGg9IjQiIGhlaWdodD0iNCIgZmlsbD0iYmxhY2siLz4KPHJlY3QgeD0iMTIiIHk9IjIwIiB3aWR0aD0iNCIgaGVpZ2h0PSI0IiBmaWxsPSJibGFjayIvPgo8cmVjdCB4PSIyMCIgeT0iMjAiIHdpZHRoPSI4IiBoZWlnaHQ9IjgiIGZpbGw9ImJsYWNrIi8+CjxyZWN0IHg9IjM2IiB5PSIyMCIgd2lkdGg9IjQiIGhlaWdodD0iNCIgZmlsbD0iYmxhY2siLz4KPHJlY3QgeD0iNDQiIHk9IjIwIiB3aWR0aD0iNCIgaGVpZ2h0PSI0IiBmaWxsPSJibGFjayIvPgo8cmVjdCB4PSI1MiIgeT0iMjAiIHdpZHRoPSI0IiBoZWlnaHQ9IjQiIGZpbGw9ImJsYWNrIi8+CjxyZWN0IHg9IjI0IiB5PSIyNCIgd2lkdGg9IjQiIGhlaWdodD0iNCIgZmlsbD0id2hpdGUiLz4KPHJlY3QgeD0iNCIgeT0iMjgiIHdpZHRoPSI0IiBoZWlnaHQ9IjQiIGZpbGw9ImJsYWNrIi8+CjxyZWN0IHg9IjEyIiB5PSIyOCIgd2lkdGg9IjQiIGhlaWdodD0iNCIgZmlsbD0iYmxhY2siLz4KPHJlY3QgeD0iMzYiIHk9IjI4IiB3aWR0aD0iNCIgaGVpZ2h0PSI0IiBmaWxsPSJibGFjayIvPgo8cmVjdCB4PSI0NCIgeT0iMjgiIHdpZHRoPSI0IiBoZWlnaHQ9IjQiIGZpbGw9ImJsYWNrIi8+CjxyZWN0IHg9IjUyIiB5PSIyOCIgd2lkdGg9IjQiIGhlaWdodD0iNCIgZmlsbD0iYmxhY2siLz4KPHJlY3QgeD0iNCIgeT0iMzYiIHdpZHRoPSI0IiBoZWlnaHQ9IjQiIGZpbGw9ImJsYWNrIi8+CjxyZWN0IHg9IjEyIiB5PSIzNiIgd2lkdGg9IjQiIGhlaWdodD0iNCIgZmlsbD0iYmxhY2siLz4KPHJlY3QgeD0iMjAiIHk9IjM2IiB3aWR0aD0iNCIgaGVpZ2h0PSI0IiBmaWxsPSJibGFjayIvPgo8cmVjdCB4PSIyOCIgeT0iMzYiIHdpZHRoPSI0IiBoZWlnaHQ9IjQiIGZpbGw9ImJsYWNrIi8+CjxyZWN0IHg9IjM2IiB5PSIzNiIgd2lkdGg9IjQiIGhlaWdodD0iNCIgZmlsbD0iYmxhY2siLz4KPHJlY3QgeD0iNDQiIHk9IjM2IiB3aWR0aD0iNCIgaGVpZ2h0PSI0IiBmaWxsPSJibGFjayIvPgo8cmVjdCB4PSI1MiIgeT0iMzYiIHdpZHRoPSI0IiBoZWlnaHQ9IjQiIGZpbGw9ImJsYWNrIi8+CjxyZWN0IHg9IjQiIHk9IjQ0IiB3aWR0aD0iMTIiIGhlaWdodD0iMTIiIGZpbGw9ImJsYWNrIi8+CjxyZWN0IHg9IjIwIiB5PSI0NCIgd2lkdGg9IjQiIGhlaWdodD0iNCIgZmlsbD0iYmxhY2siLz4KPHJlY3QgeD0iMjgiIHk9IjQ0IiB3aWR0aD0iNCIgaGVpZ2h0PSI0IiBmaWxsPSJibGFjayIvPgo8cmVjdCB4PSI0NCIgeT0iNDQiIHdpZHRoPSIxMiIgaGVpZ2h0PSIxMiIgZmlsbD0iYmxhY2siLz4KPHJlY3QgeD0iOCIgeT0iNDgiIHdpZHRoPSI0IiBoZWlnaHQ9IjQiIGZpbGw9IndoaXRlIi8+CjxyZWN0IHg9IjIwIiB5PSI1MiIgd2lkdGg9IjQiIGhlaWdodD0iNCIgZmlsbD0iYmxhY2siLz4KPHJlY3QgeD0iNDgiIHk9IjQ4IiB3aWR0aD0iNCIgaGVpZ2h0PSI0IiBmaWxsPSJ3aGl0ZSIvPgo8L3N2Zz4K') no-repeat center; background-size: contain;"></div> | ||||
|                             </div> | ||||
|                             <div class="text-muted small mt-2" style="font-size: 0.7rem;">{"Scan to verify"}</div> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,601 @@ | ||||
| use yew::prelude::*; | ||||
| use gloo::timers::callback::Timeout; | ||||
| use wasm_bindgen::prelude::*; | ||||
| use wasm_bindgen_futures::spawn_local; | ||||
| use web_sys::{console, js_sys}; | ||||
| use serde_json::json; | ||||
| use crate::models::company::{DigitalResidentFormData, DigitalResident, ResidentPaymentPlan}; | ||||
| use crate::services::{ResidentService, ResidentRegistration, ResidentRegistrationStatus}; | ||||
| use super::{SimpleStepInfo, StepPaymentStripe}; | ||||
|  | ||||
| #[wasm_bindgen] | ||||
| extern "C" { | ||||
|     #[wasm_bindgen(js_namespace = window)] | ||||
|     fn createPaymentIntent(form_data: &JsValue) -> js_sys::Promise; | ||||
| } | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct SimpleResidentWizardProps { | ||||
|     pub on_registration_complete: Callback<DigitalResident>, | ||||
|     pub on_back_to_parent: Callback<()>, | ||||
|     #[prop_or_default] | ||||
|     pub success_resident_id: Option<u32>, | ||||
|     #[prop_or_default] | ||||
|     pub show_failure: bool, | ||||
| } | ||||
|  | ||||
| pub enum SimpleResidentWizardMsg { | ||||
|     NextStep, | ||||
|     PrevStep, | ||||
|     UpdateFormData(DigitalResidentFormData), | ||||
|     ProcessRegistration, | ||||
|     RegistrationComplete(DigitalResident), | ||||
|     RegistrationError(String), | ||||
|     HideValidationToast, | ||||
|     ProcessPayment, | ||||
|     PaymentPlanChanged(ResidentPaymentPlan), | ||||
|     ConfirmationChanged(bool), | ||||
|     CreatePaymentIntent, | ||||
|     PaymentIntentCreated(String), | ||||
|     PaymentIntentError(String), | ||||
| } | ||||
|  | ||||
| pub struct SimpleResidentWizard { | ||||
|     current_step: u8, | ||||
|     form_data: DigitalResidentFormData, | ||||
|     validation_errors: Vec<String>, | ||||
|     processing_registration: bool, | ||||
|     show_validation_toast: bool, | ||||
|     client_secret: Option<String>, | ||||
|     processing_payment: bool, | ||||
|     confirmation_checked: bool, | ||||
| } | ||||
|  | ||||
| impl Component for SimpleResidentWizard { | ||||
|     type Message = SimpleResidentWizardMsg; | ||||
|     type Properties = SimpleResidentWizardProps; | ||||
|  | ||||
|     fn create(ctx: &Context<Self>) -> Self { | ||||
|         // Determine initial step based on props - always start fresh for portal | ||||
|         let (form_data, current_step) = if ctx.props().success_resident_id.is_some() { | ||||
|             // Show success step | ||||
|             (DigitalResidentFormData::default(), 3) | ||||
|         } else if ctx.props().show_failure { | ||||
|             // Show failure, go back to payment step | ||||
|             (DigitalResidentFormData::default(), 2) | ||||
|         } else { | ||||
|             // Normal flow - always start from step 1 with fresh data | ||||
|             (DigitalResidentFormData::default(), 1) | ||||
|         }; | ||||
|  | ||||
|         Self { | ||||
|             current_step, | ||||
|             form_data, | ||||
|             validation_errors: Vec::new(), | ||||
|             processing_registration: false, | ||||
|             show_validation_toast: false, | ||||
|             client_secret: None, | ||||
|             processing_payment: false, | ||||
|             confirmation_checked: false, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool { | ||||
|         match msg { | ||||
|             SimpleResidentWizardMsg::NextStep => { | ||||
|                 // Validate current step | ||||
|                 let validation_result = ResidentService::validate_resident_step(&self.form_data, self.current_step); | ||||
|                 if !validation_result.is_valid { | ||||
|                     self.validation_errors = validation_result.errors; | ||||
|                     self.show_validation_toast = true; | ||||
|                      | ||||
|                     // Auto-hide toast after 5 seconds | ||||
|                     let link = ctx.link().clone(); | ||||
|                     Timeout::new(5000, move || { | ||||
|                         link.send_message(SimpleResidentWizardMsg::HideValidationToast); | ||||
|                     }).forget(); | ||||
|                      | ||||
|                     return true; | ||||
|                 } | ||||
|  | ||||
|                 if self.current_step < 3 { | ||||
|                     if self.current_step == 2 { | ||||
|                         // Process registration on final step | ||||
|                         ctx.link().send_message(SimpleResidentWizardMsg::ProcessRegistration); | ||||
|                     } else { | ||||
|                         self.current_step += 1; | ||||
|                         // If moving to payment step, create payment intent | ||||
|                         if self.current_step == 2 { | ||||
|                             ctx.link().send_message(SimpleResidentWizardMsg::CreatePaymentIntent); | ||||
|                         } | ||||
|                     } | ||||
|                     true | ||||
|                 } else { | ||||
|                     false | ||||
|                 } | ||||
|             } | ||||
|             SimpleResidentWizardMsg::PrevStep => { | ||||
|                 if self.current_step > 1 { | ||||
|                     self.current_step -= 1; | ||||
|                     true | ||||
|                 } else { | ||||
|                     false | ||||
|                 } | ||||
|             } | ||||
|             SimpleResidentWizardMsg::UpdateFormData(new_form_data) => { | ||||
|                 self.form_data = new_form_data; | ||||
|                 true | ||||
|             } | ||||
|             SimpleResidentWizardMsg::ProcessRegistration => { | ||||
|                 self.processing_registration = true; | ||||
|                  | ||||
|                 // Simulate registration processing | ||||
|                 let link = ctx.link().clone(); | ||||
|                 let form_data = self.form_data.clone(); | ||||
|                  | ||||
|                 Timeout::new(2000, move || { | ||||
|                     // Create resident and update registration status | ||||
|                     match ResidentService::create_resident_from_form(&form_data) { | ||||
|                         Ok(resident) => { | ||||
|                             // For portal, we don't need to save registration drafts | ||||
|                             // Just complete the registration process | ||||
|                             link.send_message(SimpleResidentWizardMsg::RegistrationComplete(resident)); | ||||
|                         } | ||||
|                         Err(error) => { | ||||
|                             link.send_message(SimpleResidentWizardMsg::RegistrationError(error)); | ||||
|                         } | ||||
|                     } | ||||
|                 }).forget(); | ||||
|                  | ||||
|                 true | ||||
|             } | ||||
|             SimpleResidentWizardMsg::RegistrationComplete(resident) => { | ||||
|                 self.processing_registration = false; | ||||
|                 // Move to success step | ||||
|                 self.current_step = 3; | ||||
|                 // Notify parent component | ||||
|                 ctx.props().on_registration_complete.emit(resident); | ||||
|                 true | ||||
|             } | ||||
|             SimpleResidentWizardMsg::RegistrationError(error) => { | ||||
|                 self.processing_registration = false; | ||||
|                 // Stay on payment step and show error | ||||
|                 self.validation_errors = vec![format!("Registration failed: {}", error)]; | ||||
|                 self.show_validation_toast = true; | ||||
|                  | ||||
|                 // Auto-hide toast after 5 seconds | ||||
|                 let link = ctx.link().clone(); | ||||
|                 Timeout::new(5000, move || { | ||||
|                     link.send_message(SimpleResidentWizardMsg::HideValidationToast); | ||||
|                 }).forget(); | ||||
|                  | ||||
|                 true | ||||
|             } | ||||
|             SimpleResidentWizardMsg::HideValidationToast => { | ||||
|                 self.show_validation_toast = false; | ||||
|                 true | ||||
|             } | ||||
|             SimpleResidentWizardMsg::ProcessPayment => { | ||||
|                 self.processing_payment = true; | ||||
|                 true | ||||
|             } | ||||
|             SimpleResidentWizardMsg::PaymentPlanChanged(plan) => { | ||||
|                 self.form_data.payment_plan = plan; | ||||
|                 self.client_secret = None; // Reset client secret when plan changes | ||||
|                 ctx.link().send_message(SimpleResidentWizardMsg::CreatePaymentIntent); | ||||
|                 true | ||||
|             } | ||||
|             SimpleResidentWizardMsg::ConfirmationChanged(checked) => { | ||||
|                 self.confirmation_checked = checked; | ||||
|                 true | ||||
|             } | ||||
|             SimpleResidentWizardMsg::CreatePaymentIntent => { | ||||
|                 console::log_1(&"🔧 Creating payment intent for resident registration...".into()); | ||||
|                 self.create_payment_intent(ctx); | ||||
|                 false | ||||
|             } | ||||
|             SimpleResidentWizardMsg::PaymentIntentCreated(client_secret) => { | ||||
|                 self.client_secret = Some(client_secret); | ||||
|                 true | ||||
|             } | ||||
|             SimpleResidentWizardMsg::PaymentIntentError(error) => { | ||||
|                 self.validation_errors = vec![format!("Payment setup failed: {}", error)]; | ||||
|                 self.show_validation_toast = true; | ||||
|                  | ||||
|                 // Auto-hide toast after 5 seconds | ||||
|                 let link = ctx.link().clone(); | ||||
|                 Timeout::new(5000, move || { | ||||
|                     link.send_message(SimpleResidentWizardMsg::HideValidationToast); | ||||
|                 }).forget(); | ||||
|                  | ||||
|                 true | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn view(&self, ctx: &Context<Self>) -> Html { | ||||
|         let (step_title, step_description, step_icon) = self.get_step_info(); | ||||
|  | ||||
|         html! { | ||||
|             <div class="h-100 d-flex flex-column"> | ||||
|                 <form class="flex-grow-1 overflow-auto"> | ||||
|                     {self.render_current_step(ctx)} | ||||
|                 </form> | ||||
|  | ||||
|                 {if self.current_step <= 2 { | ||||
|                     self.render_footer_navigation(ctx) | ||||
|                 } else { | ||||
|                     html! {} | ||||
|                 }} | ||||
|  | ||||
|                 {if self.show_validation_toast { | ||||
|                     self.render_validation_toast(ctx) | ||||
|                 } else { | ||||
|                     html! {} | ||||
|                 }} | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl SimpleResidentWizard { | ||||
|     fn render_current_step(&self, ctx: &Context<Self>) -> Html { | ||||
|         let link = ctx.link(); | ||||
|         let form_data = self.form_data.clone(); | ||||
|         let on_form_update = link.callback(SimpleResidentWizardMsg::UpdateFormData); | ||||
|  | ||||
|         match self.current_step { | ||||
|             1 => html! { | ||||
|                 <SimpleStepInfo | ||||
|                     form_data={form_data} | ||||
|                     on_change={on_form_update} | ||||
|                 /> | ||||
|             }, | ||||
|             2 => html! { | ||||
|                 <StepPaymentStripe | ||||
|                     form_data={form_data} | ||||
|                     client_secret={self.client_secret.clone()} | ||||
|                     processing_payment={self.processing_payment} | ||||
|                     on_process_payment={link.callback(|_| SimpleResidentWizardMsg::ProcessPayment)} | ||||
|                     on_payment_complete={link.callback(SimpleResidentWizardMsg::RegistrationComplete)} | ||||
|                     on_payment_error={link.callback(SimpleResidentWizardMsg::RegistrationError)} | ||||
|                     on_payment_plan_change={link.callback(SimpleResidentWizardMsg::PaymentPlanChanged)} | ||||
|                     on_confirmation_change={link.callback(SimpleResidentWizardMsg::ConfirmationChanged)} | ||||
|                 /> | ||||
|             }, | ||||
|             3 => { | ||||
|                 // Success step | ||||
|                 self.render_success_step(ctx) | ||||
|             }, | ||||
|             _ => html! { <div>{"Invalid step"}</div> } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn render_footer_navigation(&self, ctx: &Context<Self>) -> Html { | ||||
|         let link = ctx.link(); | ||||
|  | ||||
|         html! { | ||||
|             <div class="card-footer"> | ||||
|                 <div class="d-flex justify-content-between align-items-center"> | ||||
|                     // Previous button (left) | ||||
|                     <div style="width: 120px;"> | ||||
|                         {if self.current_step > 1 { | ||||
|                             html! { | ||||
|                                 <button | ||||
|                                     type="button" | ||||
|                                     class="btn btn-outline-secondary" | ||||
|                                     onclick={link.callback(|_| SimpleResidentWizardMsg::PrevStep)} | ||||
|                                     disabled={self.processing_registration} | ||||
|                                 > | ||||
|                                     <i class="bi bi-arrow-left me-1"></i>{"Previous"} | ||||
|                                 </button> | ||||
|                             } | ||||
|                         } else { | ||||
|                             html! {} | ||||
|                         }} | ||||
|                     </div> | ||||
|  | ||||
|                     // Step indicator (center) | ||||
|                     <div class="d-flex align-items-center"> | ||||
|                         {for (1..=2).map(|step| { | ||||
|                             let is_current = step == self.current_step; | ||||
|                             let is_completed = step < 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} } | ||||
|                                         }} | ||||
|                                     </div> | ||||
|                                     {if step < 2 { | ||||
|                                         html! { | ||||
|                                             <div class={format!("mx-1 {}", if is_completed { "bg-success" } else { "bg-secondary" })} | ||||
|                                                  style="height: 2px; width: 24px;"></div> | ||||
|                                         } | ||||
|                                     } else { | ||||
|                                         html! {} | ||||
|                                     }} | ||||
|                                 </div> | ||||
|                             } | ||||
|                         })} | ||||
|                     </div> | ||||
|  | ||||
|                     // Next/Register button (right) | ||||
|                     <div style="width: 150px;" class="text-end"> | ||||
|                         {if self.current_step < 2 { | ||||
|                             html! { | ||||
|                                 <button | ||||
|                                     type="button" | ||||
|                                     class="btn btn-success" | ||||
|                                     onclick={link.callback(|_| SimpleResidentWizardMsg::NextStep)} | ||||
|                                     disabled={self.processing_registration} | ||||
|                                 > | ||||
|                                     {"Next"}<i class="bi bi-arrow-right ms-1"></i> | ||||
|                                 </button> | ||||
|                             } | ||||
|                         } else if self.current_step == 2 { | ||||
|                             // Payment is handled by the StepPaymentStripe component itself | ||||
|                             // No button needed here as the payment component has its own payment button | ||||
|                             html! {} | ||||
|                         } else { | ||||
|                             html! {} | ||||
|                         }} | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn render_validation_toast(&self, ctx: &Context<Self>) -> Html { | ||||
|         let link = ctx.link(); | ||||
|         let close_toast = link.callback(|_| SimpleResidentWizardMsg::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">{"Required Fields Missing"}</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 complete all required fields to continue:"}</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 get_step_info(&self) -> (&'static str, &'static str, &'static str) { | ||||
|         match self.current_step { | ||||
|             1 => ( | ||||
|                 "Personal Information & KYC", | ||||
|                 "Provide your basic information and complete identity verification.", | ||||
|                 "bi-person-vcard" | ||||
|             ), | ||||
|             2 => ( | ||||
|                 "Payment Plan & Legal Agreements", | ||||
|                 "Choose your payment plan and review the legal agreements.", | ||||
|                 "bi-credit-card" | ||||
|             ), | ||||
|             3 => ( | ||||
|                 "Registration Complete", | ||||
|                 "Your digital resident registration has been successfully completed.", | ||||
|                 "bi-check-circle-fill" | ||||
|             ), | ||||
|             _ => ( | ||||
|                 "Digital Resident Registration", | ||||
|                 "Complete the registration process to become a digital resident.", | ||||
|                 "bi-person-plus" | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn create_payment_intent(&self, ctx: &Context<Self>) { | ||||
|         let link = ctx.link().clone(); | ||||
|         let form_data = self.form_data.clone(); | ||||
|          | ||||
|         spawn_local(async move { | ||||
|             match Self::setup_stripe_payment(form_data).await { | ||||
|                 Ok(client_secret) => { | ||||
|                     link.send_message(SimpleResidentWizardMsg::PaymentIntentCreated(client_secret)); | ||||
|                 } | ||||
|                 Err(e) => { | ||||
|                     link.send_message(SimpleResidentWizardMsg::PaymentIntentError(e)); | ||||
|                 } | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     async fn setup_stripe_payment(form_data: DigitalResidentFormData) -> Result<String, String> { | ||||
|         use wasm_bindgen_futures::JsFuture; | ||||
|         use web_sys::{Request, RequestInit, RequestMode, Response}; | ||||
|          | ||||
|         console::log_1(&"🔧 Setting up Stripe payment for resident registration".into()); | ||||
|         console::log_1(&format!("📋 Resident: {}", form_data.full_name).into()); | ||||
|         console::log_1(&format!("💳 Payment plan: {}", form_data.payment_plan.get_display_name()).into()); | ||||
|          | ||||
|         // Prepare form data for payment intent creation | ||||
|         let payment_data = json!({ | ||||
|             "resident_name": form_data.full_name, | ||||
|             "email": form_data.email, | ||||
|             "phone": form_data.phone, | ||||
|             "date_of_birth": form_data.date_of_birth, | ||||
|             "nationality": form_data.nationality, | ||||
|             "passport_number": form_data.passport_number, | ||||
|             "address": form_data.current_address, | ||||
|             "payment_plan": form_data.payment_plan.get_display_name(), | ||||
|             "amount": form_data.payment_plan.get_price(), | ||||
|             "type": "resident_registration" | ||||
|         }); | ||||
|          | ||||
|         console::log_1(&"📡 Calling server endpoint for resident payment intent creation".into()); | ||||
|          | ||||
|         // Create request to server endpoint | ||||
|         let mut opts = RequestInit::new(); | ||||
|         opts.method("POST"); | ||||
|         opts.mode(RequestMode::Cors); | ||||
|          | ||||
|         let headers = js_sys::Map::new(); | ||||
|         headers.set(&"Content-Type".into(), &"application/json".into()); | ||||
|         opts.headers(&headers); | ||||
|          | ||||
|         opts.body(Some(&JsValue::from_str(&payment_data.to_string()))); | ||||
|          | ||||
|         let request = Request::new_with_str_and_init( | ||||
|             "http://127.0.0.1:3001/resident/create-payment-intent", | ||||
|             &opts, | ||||
|         ).map_err(|e| { | ||||
|             let error_msg = format!("Failed to create request: {:?}", e); | ||||
|             console::log_1(&format!("❌ {}", error_msg).into()); | ||||
|             error_msg | ||||
|         })?; | ||||
|          | ||||
|         // Make the request | ||||
|         let window = web_sys::window().unwrap(); | ||||
|         let resp_value = JsFuture::from(window.fetch_with_request(&request)).await | ||||
|             .map_err(|e| { | ||||
|                 let error_msg = format!("Network request failed: {:?}", e); | ||||
|                 console::log_1(&format!("❌ {}", error_msg).into()); | ||||
|                 error_msg | ||||
|             })?; | ||||
|          | ||||
|         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| { | ||||
|                 let error_msg = format!("Failed to parse response: {:?}", e); | ||||
|                 console::log_1(&format!("❌ {}", error_msg).into()); | ||||
|                 error_msg | ||||
|             })?; | ||||
|          | ||||
|         // 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| { | ||||
|                 let error_msg = format!("No client_secret in response: {:?}", e); | ||||
|                 console::log_1(&format!("❌ {}", error_msg).into()); | ||||
|                 error_msg | ||||
|             })?; | ||||
|          | ||||
|         let client_secret = client_secret_value.as_string() | ||||
|             .ok_or_else(|| { | ||||
|                 let error_msg = "Invalid client secret received from server"; | ||||
|                 console::log_1(&format!("❌ {}", error_msg).into()); | ||||
|                 error_msg.to_string() | ||||
|             })?; | ||||
|          | ||||
|         console::log_1(&"✅ Payment intent created successfully".into()); | ||||
|         console::log_1(&format!("🔑 Client secret received: {}", if client_secret.len() > 10 { "Yes" } else { "No" }).into()); | ||||
|         Ok(client_secret) | ||||
|     } | ||||
|  | ||||
|     fn render_success_step(&self, ctx: &Context<Self>) -> Html { | ||||
|         let resident_id = ctx.props().success_resident_id.unwrap_or(1); | ||||
|  | ||||
|         html! { | ||||
|             <div class="text-center py-5"> | ||||
|                 <div class="mb-4"> | ||||
|                     <i class="bi bi-check-circle-fill text-success" style="font-size: 4rem;"></i> | ||||
|                 </div> | ||||
|                  | ||||
|                 <h2 class="text-success mb-3">{"Registration Successful!"}</h2> | ||||
|                 <p class="lead mb-4"> | ||||
|                     {"Your digital resident registration has been successfully submitted and is now pending approval."} | ||||
|                 </p> | ||||
|                  | ||||
|                 <div class="row justify-content-center"> | ||||
|                     <div class="col-md-8"> | ||||
|                         <div class="card border-success"> | ||||
|                             <div class="card-body"> | ||||
|                                 <h5 class="card-title text-success"> | ||||
|                                     <i class="bi bi-info-circle me-2"></i>{"What happens next?"} | ||||
|                                 </h5> | ||||
|                                 <div class="text-start"> | ||||
|                                     <div class="d-flex align-items-start mb-3"> | ||||
|                                         <div class="me-3"> | ||||
|                                             <span class="badge bg-success rounded-pill">{"1"}</span> | ||||
|                                         </div> | ||||
|                                         <div> | ||||
|                                             <strong>{"Identity Verification"}</strong> | ||||
|                                             <p class="mb-0 text-muted">{"Our team will verify your identity and submitted documents."}</p> | ||||
|                                         </div> | ||||
|                                     </div> | ||||
|                                      | ||||
|                                     <div class="d-flex align-items-start mb-3"> | ||||
|                                         <div class="me-3"> | ||||
|                                             <span class="badge bg-primary rounded-pill">{"2"}</span> | ||||
|                                         </div> | ||||
|                                         <div> | ||||
|                                             <strong>{"Background Check"}</strong> | ||||
|                                             <p class="mb-0 text-muted">{"We'll conduct necessary background checks and compliance verification."}</p> | ||||
|                                         </div> | ||||
|                                     </div> | ||||
|                                      | ||||
|                                     <div class="d-flex align-items-start mb-3"> | ||||
|                                         <div class="me-3"> | ||||
|                                             <span class="badge bg-info rounded-pill">{"3"}</span> | ||||
|                                         </div> | ||||
|                                         <div> | ||||
|                                             <strong>{"Approval & Activation"}</strong> | ||||
|                                             <p class="mb-0 text-muted">{"Once approved, your digital resident status will be activated and you'll gain access to selected services."}</p> | ||||
|                                         </div> | ||||
|                                     </div> | ||||
|                                 </div> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </div> | ||||
|                  | ||||
|                 <div class="mt-4"> | ||||
|                     <div class="d-flex justify-content-center"> | ||||
|                         <button | ||||
|                             class="btn btn-success btn-lg" | ||||
|                             onclick={ctx.props().on_back_to_parent.reform(|_| ())} | ||||
|                         > | ||||
|                             <i class="bi bi-list me-2"></i>{"View My Registrations"} | ||||
|                         </button> | ||||
|                     </div> | ||||
|                 </div> | ||||
|                  | ||||
|                 <div class="mt-4"> | ||||
|                     <div class="alert alert-info"> | ||||
|                         <i class="bi bi-envelope me-2"></i> | ||||
|                         {"You will receive email updates about your registration status. The approval process typically takes 3-5 business days."} | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -0,0 +1,293 @@ | ||||
| use yew::prelude::*; | ||||
| use web_sys::HtmlInputElement; | ||||
| use crate::models::company::DigitalResidentFormData; | ||||
| use super::ResidenceCard; | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct SimpleStepInfoProps { | ||||
|     pub form_data: DigitalResidentFormData, | ||||
|     pub on_change: Callback<DigitalResidentFormData>, | ||||
| } | ||||
|  | ||||
| #[function_component(SimpleStepInfo)] | ||||
| pub fn simple_step_info(props: &SimpleStepInfoProps) -> Html { | ||||
|     let form_data = props.form_data.clone(); | ||||
|     let on_change = props.on_change.clone(); | ||||
|     let show_private_key = use_state(|| false); | ||||
|     let kyc_completed = use_state(|| false); | ||||
|  | ||||
|     let on_input = { | ||||
|         let form_data = form_data.clone(); | ||||
|         let on_change = on_change.clone(); | ||||
|         Callback::from(move |e: InputEvent| { | ||||
|             let input: HtmlInputElement = e.target_unchecked_into(); | ||||
|             let field_name = input.name(); | ||||
|             let value = input.value(); | ||||
|              | ||||
|             let mut updated_data = form_data.clone(); | ||||
|             match field_name.as_str() { | ||||
|                 "full_name" => updated_data.full_name = value, | ||||
|                 "email" => updated_data.email = value, | ||||
|                 _ => {} | ||||
|             } | ||||
|             on_change.emit(updated_data); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_terms_change = { | ||||
|         let form_data = form_data.clone(); | ||||
|         let on_change = on_change.clone(); | ||||
|         Callback::from(move |_: Event| { | ||||
|             let mut updated_data = form_data.clone(); | ||||
|             updated_data.legal_agreements.terms = !updated_data.legal_agreements.terms; | ||||
|             on_change.emit(updated_data); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_kyc_click = { | ||||
|         let kyc_completed = kyc_completed.clone(); | ||||
|         Callback::from(move |_: MouseEvent| { | ||||
|             // Mock KYC completion | ||||
|             kyc_completed.set(true); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_generate_keys = { | ||||
|         let form_data = form_data.clone(); | ||||
|         let on_change = on_change.clone(); | ||||
|         let show_private_key = show_private_key.clone(); | ||||
|         Callback::from(move |_: MouseEvent| { | ||||
|             // Generate secp256k1 keypair (simplified for demo) | ||||
|             let private_key = generate_private_key(); | ||||
|             let public_key = generate_public_key(&private_key); | ||||
|              | ||||
|             let mut updated_data = form_data.clone(); | ||||
|             updated_data.public_key = Some(public_key); | ||||
|             updated_data.private_key = Some(private_key); | ||||
|             updated_data.private_key_shown = true; | ||||
|              | ||||
|             show_private_key.set(true); | ||||
|             on_change.emit(updated_data); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let copy_private_key = { | ||||
|         let private_key = form_data.private_key.clone(); | ||||
|         Callback::from(move |_: MouseEvent| { | ||||
|             if let Some(key) = &private_key { | ||||
|                 // Copy to clipboard using a simple approach | ||||
|                 web_sys::window() | ||||
|                     .unwrap() | ||||
|                     .alert_with_message(&format!("Private key copied! Please save it: {}", key)) | ||||
|                     .unwrap(); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     html! { | ||||
|         <> | ||||
|         <div class="row" style="padding: 2rem 1rem; height: 100%;"> | ||||
|             // Left side - Form inputs | ||||
|             <div class="col-md-6" style="display: flex; flex-direction: column; justify-content: center;"> | ||||
|                 <div class="mb-3"> | ||||
|                     <label for="full_name" class="form-label text-muted" style="font-size: 0.875rem; font-weight: 500;">{"Full Name"} <span class="text-danger">{"*"}</span></label> | ||||
|                     <input | ||||
|                         type="text" | ||||
|                         class="form-control" | ||||
|                         id="full_name" | ||||
|                         name="full_name" | ||||
|                         value={form_data.full_name.clone()} | ||||
|                         oninput={on_input.clone()} | ||||
|                         placeholder="Enter your full legal name" | ||||
|                         style="border: 1px solid #e0e0e0; border-radius: 8px; padding: 0.75rem; font-size: 0.9rem; transition: all 0.2s ease;" | ||||
|                         title="As it appears on your government-issued ID" | ||||
|                     /> | ||||
|                 </div> | ||||
|                  | ||||
|                 <div class="mb-4"> | ||||
|                     <label for="email" class="form-label text-muted" style="font-size: 0.875rem; font-weight: 500;">{"Email Address"} <span class="text-danger">{"*"}</span></label> | ||||
|                     <input | ||||
|                         type="email" | ||||
|                         class="form-control" | ||||
|                         id="email" | ||||
|                         name="email" | ||||
|                         value={form_data.email.clone()} | ||||
|                         oninput={on_input.clone()} | ||||
|                         placeholder="your.email@example.com" | ||||
|                         style="border: 1px solid #e0e0e0; border-radius: 8px; padding: 0.75rem; font-size: 0.9rem; transition: all 0.2s ease;" | ||||
|                         title="We'll use this to send you updates about your application" | ||||
|                     /> | ||||
|                 </div> | ||||
|                  | ||||
|                 <div class="mb-4"> | ||||
|                     <div class="card" style="border: 1px solid #e0e0e0; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); min-height: 280px;"> | ||||
|                         <div class="card-header d-flex justify-content-between align-items-center" style="background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); border-bottom: 1px solid #e0e0e0; border-radius: 12px 12px 0 0;"> | ||||
|                             <h6 class="mb-0 text-dark" style="font-size: 0.9rem; font-weight: 600;"> | ||||
|                                 <i class="bi bi-key me-2" style="color: #6c757d;"></i> | ||||
|                                 {"Digital Identity Keys"} | ||||
|                             </h6> | ||||
|                             {if form_data.public_key.is_some() { | ||||
|                                 html! { | ||||
|                                     <button | ||||
|                                         type="button" | ||||
|                                         class="btn btn-sm btn-outline-secondary" | ||||
|                                         onclick={&on_generate_keys} | ||||
|                                         style="padding: 0.25rem 0.5rem; border-radius: 4px;" | ||||
|                                         title="Generate new keys" | ||||
|                                     > | ||||
|                                         <i class="bi bi-arrow-clockwise" style="font-size: 0.8rem;"></i> | ||||
|                                     </button> | ||||
|                                 } | ||||
|                             } else { | ||||
|                                 html! {} | ||||
|                             }} | ||||
|                         </div> | ||||
|                         <div class="card-body d-flex flex-column" style="padding: 1.25rem; min-height: 240px;"> | ||||
|                             {if form_data.public_key.is_none() { | ||||
|                                 html! { | ||||
|                                     <> | ||||
|                                         <p class="text-muted mb-3" style="font-size: 0.85rem; line-height: 1.5;"> | ||||
|                                             {"Generate your unique cryptographic keys for secure digital identity. These keys will be used to authenticate your digital residence."} | ||||
|                                         </p> | ||||
|                                          | ||||
|                                         <div class="flex-grow-1 d-flex flex-column justify-content-center"> | ||||
|                                             <div class="mb-3"> | ||||
|                                                 <div class="form-check"> | ||||
|                                                     <input | ||||
|                                                         class="form-check-input" | ||||
|                                                         type="checkbox" | ||||
|                                                         id="terms_agreement" | ||||
|                                                         checked={form_data.legal_agreements.terms} | ||||
|                                                         onchange={on_terms_change} | ||||
|                                                         style="border-radius: 4px;" | ||||
|                                                     /> | ||||
|                                                     <label class="form-check-label text-muted" for="terms_agreement" style="font-size: 0.85rem;"> | ||||
|                                                         {"I agree to the "}<a href="#" class="text-decoration-none" style="color: #495057;">{"Terms of Service"}</a>{" and "}<a href="#" class="text-decoration-none" style="color: #495057;">{"Privacy Policy"}</a> <span class="text-danger">{"*"}</span> | ||||
|                                                     </label> | ||||
|                                                 </div> | ||||
|                                             </div> | ||||
|                                              | ||||
|                                             <div class="d-grid"> | ||||
|                                                 <button | ||||
|                                                     type="button" | ||||
|                                                     class="btn" | ||||
|                                                     onclick={&on_generate_keys} | ||||
|                                                     disabled={!form_data.legal_agreements.terms} | ||||
|                                                     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;" | ||||
|                                                 > | ||||
|                                                     <i class="bi bi-key me-2"></i> | ||||
|                                                     {"Generate Keys"} | ||||
|                                                 </button> | ||||
|                                             </div> | ||||
|                                         </div> | ||||
|                                     </> | ||||
|                                 } | ||||
|                             } else { | ||||
|                                 html! { | ||||
|                                     <> | ||||
|                                         {if *show_private_key && form_data.private_key.is_some() { | ||||
|                                             html! { | ||||
|                                                 <div class="mb-3"> | ||||
|                                                     <label class="form-label text-muted mb-2" style="font-size: 0.75rem; font-weight: 500;">{"Private Key"}</label> | ||||
|                                                     <div class="bg-dark text-light p-3 rounded position-relative" style="font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; font-size: 0.7rem; word-break: break-all; line-height: 1.4; border: 1px solid #495057;"> | ||||
|                                                         <div style="padding-right: 2.5rem;"> | ||||
|                                                             {form_data.private_key.as_ref().unwrap_or(&"".to_string())} | ||||
|                                                         </div> | ||||
|                                                         <button | ||||
|                                                             type="button" | ||||
|                                                             class="btn btn-sm position-absolute top-0 end-0 m-2" | ||||
|                                                             onclick={copy_private_key} | ||||
|                                                             style="background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); color: white; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.8rem;" | ||||
|                                                             title="Copy private key" | ||||
|                                                         > | ||||
|                                                             <i class="bi bi-copy" style="color: white; font-size: 0.9rem;"></i> | ||||
|                                                         </button> | ||||
|                                                     </div> | ||||
|                                                     <div class="alert alert-warning mt-2 py-2" style="border-radius: 6px; font-size: 0.75rem;"> | ||||
|                                                         <i class="bi bi-exclamation-triangle me-1"></i> | ||||
|                                                         <strong>{"Warning:"}</strong> {"Store this private key safely. You cannot recover it if lost!"} | ||||
|                                                     </div> | ||||
|                                                 </div> | ||||
|                                             } | ||||
|                                         } else { | ||||
|                                             html! {} | ||||
|                                         }} | ||||
|                                          | ||||
|                                         <div class="mb-3"> | ||||
|                                             <label class="form-label text-muted" style="font-size: 0.75rem; font-weight: 500;">{"Public Key"}</label> | ||||
|                                             <div class="form-control" style="background: #f8f9fa; border: 1px solid #e0e0e0; border-radius: 6px; font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; font-size: 0.7rem; word-break: break-all; min-height: 60px; line-height: 1.4; color: #495057;"> | ||||
|                                                 {form_data.public_key.as_ref().unwrap_or(&"".to_string())} | ||||
|                                             </div> | ||||
|                                         </div> | ||||
|                                     </> | ||||
|                                 } | ||||
|                             }} | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </div> | ||||
|                  | ||||
|                 <div class="mb-4"> | ||||
|                     <div class="d-grid"> | ||||
|                         {if *kyc_completed { | ||||
|                             html! { | ||||
|                                 <button | ||||
|                                     type="button" | ||||
|                                     class="btn btn-success" | ||||
|                                     disabled=true | ||||
|                                     style="padding: 0.75rem; border-radius: 8px; font-size: 0.9rem; font-weight: 500;" | ||||
|                                 > | ||||
|                                     <i class="bi bi-check-circle-fill me-2"></i> | ||||
|                                     {"KYC Verification Complete"} | ||||
|                                 </button> | ||||
|                             } | ||||
|                         } else { | ||||
|                             html! { | ||||
|                                 <button | ||||
|                                     type="button" | ||||
|                                     class="btn btn-outline-secondary" | ||||
|                                     onclick={on_kyc_click} | ||||
|                                     style="border: 1px solid #e0e0e0; color: #495057; padding: 0.75rem; border-radius: 8px; font-size: 0.9rem; font-weight: 500; transition: all 0.2s ease;" | ||||
|                                 > | ||||
|                                     <i class="bi bi-shield-check me-2"></i> | ||||
|                                     {"Complete KYC Verification"} | ||||
|                                 </button> | ||||
|                             } | ||||
|                         }} | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|              | ||||
|             // Right side - Residence card (vertically centered) | ||||
|             <div class="col-md-6 d-flex align-items-center justify-content-center"> | ||||
|                 <ResidenceCard form_data={form_data.clone()} /> | ||||
|             </div> | ||||
|         </div> | ||||
|          | ||||
|         </> | ||||
|     } | ||||
| } | ||||
|  | ||||
| // Simplified key generation functions (for demo purposes) | ||||
| fn generate_private_key() -> String { | ||||
|     // In a real implementation, this would use proper secp256k1 key generation | ||||
|     // For demo purposes, we'll generate a hex string | ||||
|     use js_sys::Math; | ||||
|     let mut key = String::new(); | ||||
|     for _ in 0..64 { | ||||
|         let digit = (Math::random() * 16.0) as u8; | ||||
|         key.push_str(&format!("{:x}", digit)); | ||||
|     } | ||||
|     key | ||||
| } | ||||
|  | ||||
| fn generate_public_key(private_key: &str) -> String { | ||||
|     // In a real implementation, this would derive the public key from the private key | ||||
|     // For demo purposes, we'll generate a different hex string | ||||
|     use js_sys::Math; | ||||
|     let mut key = String::from("04"); // Uncompressed public key prefix | ||||
|     for _ in 0..128 { | ||||
|         let digit = (Math::random() * 16.0) as u8; | ||||
|         key.push_str(&format!("{:x}", digit)); | ||||
|     } | ||||
|     key | ||||
| } | ||||
| @@ -0,0 +1,336 @@ | ||||
| use yew::prelude::*; | ||||
| use wasm_bindgen::prelude::*; | ||||
| use wasm_bindgen_futures::spawn_local; | ||||
| use web_sys::{window, console, js_sys}; | ||||
| use crate::models::company::{DigitalResidentFormData, DigitalResident, ResidentPaymentPlan}; | ||||
| use crate::services::ResidentService; | ||||
| use super::ResidenceCard; | ||||
|  | ||||
| #[wasm_bindgen] | ||||
| extern "C" { | ||||
|     #[wasm_bindgen(js_namespace = window)] | ||||
|     fn confirmStripePayment(client_secret: &str) -> js_sys::Promise; | ||||
|      | ||||
|     #[wasm_bindgen(js_namespace = window)] | ||||
|     fn initializeStripeElements(client_secret: &str); | ||||
| } | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct StepPaymentStripeProps { | ||||
|     pub form_data: DigitalResidentFormData, | ||||
|     pub client_secret: Option<String>, | ||||
|     pub processing_payment: bool, | ||||
|     pub on_process_payment: Callback<()>, | ||||
|     pub on_payment_complete: Callback<DigitalResident>, | ||||
|     pub on_payment_error: Callback<String>, | ||||
|     pub on_payment_plan_change: Callback<ResidentPaymentPlan>, | ||||
|     pub on_confirmation_change: Callback<bool>, | ||||
| } | ||||
|  | ||||
| pub enum StepPaymentStripeMsg { | ||||
|     ProcessPayment, | ||||
|     PaymentComplete, | ||||
|     PaymentError(String), | ||||
|     PaymentPlanChanged(ResidentPaymentPlan), | ||||
|     ToggleConfirmation, | ||||
| } | ||||
|  | ||||
| pub struct StepPaymentStripe { | ||||
|     form_data: DigitalResidentFormData, | ||||
|     payment_error: Option<String>, | ||||
|     selected_payment_plan: ResidentPaymentPlan, | ||||
|     confirmation_checked: bool, | ||||
| } | ||||
|  | ||||
| impl Component for StepPaymentStripe { | ||||
|     type Message = StepPaymentStripeMsg; | ||||
|     type Properties = StepPaymentStripeProps; | ||||
|  | ||||
|     fn create(ctx: &Context<Self>) -> Self { | ||||
|         Self { | ||||
|             form_data: ctx.props().form_data.clone(), | ||||
|             payment_error: None, | ||||
|             selected_payment_plan: ctx.props().form_data.payment_plan.clone(), | ||||
|             confirmation_checked: false, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool { | ||||
|         match msg { | ||||
|             StepPaymentStripeMsg::ProcessPayment => { | ||||
|                 if let Some(client_secret) = &ctx.props().client_secret { | ||||
|                     console::log_1(&"🔄 User clicked 'Complete Payment' - processing with Stripe".into()); | ||||
|                     self.process_stripe_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()); | ||||
|                 } | ||||
|                 return false; | ||||
|             } | ||||
|             StepPaymentStripeMsg::PaymentComplete => { | ||||
|                 console::log_1(&"✅ Payment completed successfully".into()); | ||||
|                 // Create resident from form data with current payment plan | ||||
|                 let mut updated_form_data = self.form_data.clone(); | ||||
|                 updated_form_data.payment_plan = self.selected_payment_plan.clone(); | ||||
|                  | ||||
|                 match ResidentService::create_resident_from_form(&updated_form_data) { | ||||
|                     Ok(resident) => { | ||||
|                         ctx.props().on_payment_complete.emit(resident); | ||||
|                     } | ||||
|                     Err(e) => { | ||||
|                         console::log_1(&format!("❌ Failed to create resident: {}", e).into()); | ||||
|                         ctx.props().on_payment_error.emit(format!("Failed to create resident: {}", e)); | ||||
|                     } | ||||
|                 } | ||||
|                 return false; | ||||
|             } | ||||
|             StepPaymentStripeMsg::PaymentError(error) => { | ||||
|                 console::log_1(&format!("❌ Payment failed: {}", error).into()); | ||||
|                 self.payment_error = Some(error.clone()); | ||||
|                 ctx.props().on_payment_error.emit(error); | ||||
|             } | ||||
|             StepPaymentStripeMsg::PaymentPlanChanged(plan) => { | ||||
|                 console::log_1(&format!("💳 Payment plan changed to: {}", plan.get_display_name()).into()); | ||||
|                 self.selected_payment_plan = plan.clone(); | ||||
|                 self.payment_error = None; // Clear any previous errors | ||||
|                  | ||||
|                 // Notify parent to create new payment intent | ||||
|                 ctx.props().on_payment_plan_change.emit(plan); | ||||
|                 return true; | ||||
|             } | ||||
|             StepPaymentStripeMsg::ToggleConfirmation => { | ||||
|                 self.confirmation_checked = !self.confirmation_checked; | ||||
|                 console::log_1(&format!("📋 Confirmation checkbox toggled: {}", self.confirmation_checked).into()); | ||||
|                 // Notify parent of confirmation state change | ||||
|                 ctx.props().on_confirmation_change.emit(self.confirmation_checked); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         true | ||||
|     } | ||||
|  | ||||
|     fn changed(&mut self, ctx: &Context<Self>, old_props: &Self::Properties) -> bool { | ||||
|         self.form_data = ctx.props().form_data.clone(); | ||||
|         // Update selected payment plan if it changed from parent | ||||
|         if self.selected_payment_plan != ctx.props().form_data.payment_plan { | ||||
|             self.selected_payment_plan = ctx.props().form_data.payment_plan.clone(); | ||||
|         } | ||||
|          | ||||
|         // Initialize Stripe Elements if client secret became available | ||||
|         if old_props.client_secret.is_none() && ctx.props().client_secret.is_some() { | ||||
|             if let Some(client_secret) = &ctx.props().client_secret { | ||||
|                 initializeStripeElements(client_secret); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         true | ||||
|     } | ||||
|  | ||||
|     fn rendered(&mut self, ctx: &Context<Self>, first_render: bool) { | ||||
|         if first_render { | ||||
|             // Initialize Stripe Elements if client secret is available | ||||
|             if let Some(client_secret) = &ctx.props().client_secret { | ||||
|                 initializeStripeElements(client_secret); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn view(&self, ctx: &Context<Self>) -> Html { | ||||
|         let link = ctx.link(); | ||||
|         let has_client_secret = ctx.props().client_secret.is_some(); | ||||
|         let can_process_payment = has_client_secret && !ctx.props().processing_payment && self.confirmation_checked; | ||||
|  | ||||
|         html! { | ||||
|             <div class="step-content" style="padding: 2rem 1rem; height: 100%;"> | ||||
|                 <div class="row h-100"> | ||||
|                     // Left side - Payment form | ||||
|                     <div class="col-md-6" style="display: flex; flex-direction: column; justify-content: center;"> | ||||
|                         // Payment Form with integrated fee display | ||||
|                         <div class="mb-4"> | ||||
|                             <h6 class="text-muted mb-3" style="font-size: 0.9rem; font-weight: 600;"> | ||||
|                                 {"Payment Information"} <span class="text-danger">{"*"}</span> | ||||
|                             </h6> | ||||
|                              | ||||
|                             <div class="card" id="payment-information-section" style="border: 1px solid #e0e0e0; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.08);"> | ||||
|                                 <div class="card-header" style="background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); border-bottom: 1px solid #e0e0e0; border-radius: 12px 12px 0 0;"> | ||||
|                                     <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> | ||||
|                                 <div class="card-body" style="padding: 1.25rem;"> | ||||
|                                     // Fee display at top of payment card | ||||
|                                     <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;">{"Digital Residence Fee"}</div> | ||||
|                                                 <h6 class="mb-0" style="color: #495057; font-weight: 600;">{"$2.00 / month"}</h6> | ||||
|                                                 <small class="text-muted" style="font-size: 0.7rem;">{"Monthly maintenance fee"}</small> | ||||
|                                             </div> | ||||
|                                             <i class="bi bi-calendar-month" style="font-size: 1.25rem; color: #6c757d;"></i> | ||||
|                                         </div> | ||||
|                                     </div> | ||||
|                                      | ||||
|                                     // Stripe Elements will be mounted here | ||||
|                                     <div id="payment-element" style="min-height: 40px; padding: 10px; border: 1px solid #e0e0e0; border-radius: 8px; background-color: #ffffff;"> | ||||
|                                         {if ctx.props().processing_payment { | ||||
|                                             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;">{"Processing payment..."}</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;">{"Preparing payment form..."}</p> | ||||
|                                                 </div> | ||||
|                                             } | ||||
|                                         } else { | ||||
|                                             html! {} | ||||
|                                         }} | ||||
|                                     </div> | ||||
|                                      | ||||
|                                     // Payment button | ||||
|                                     {if has_client_secret && !ctx.props().processing_payment { | ||||
|                                         html! { | ||||
|                                             <div class="d-grid mt-3"> | ||||
|                                                 <button | ||||
|                                                     type="button" | ||||
|                                                     class="btn" | ||||
|                                                     onclick={link.callback(|_| StepPaymentStripeMsg::ProcessPayment)} | ||||
|                                                     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;" | ||||
|                                                 > | ||||
|                                                     <i class="bi bi-credit-card me-2"></i> | ||||
|                                                     {"Complete Payment - $2.00"} | ||||
|                                                 </button> | ||||
|                                             </div> | ||||
|                                         } | ||||
|                                     } else { | ||||
|                                         html! {} | ||||
|                                     }} | ||||
|                                      | ||||
|                                     {if let Some(error) = &self.payment_error { | ||||
|                                         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> | ||||
|                                         } | ||||
|                                     }} | ||||
|                                 </div> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                     </div> | ||||
|  | ||||
|                     // Right side - Residence card (vertically centered) | ||||
|                     <div class="col-md-6 d-flex align-items-center justify-content-center"> | ||||
|                         <ResidenceCard form_data={self.form_data.clone()} /> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl StepPaymentStripe { | ||||
|     fn render_payment_plan_option(&self, ctx: &Context<Self>, plan: ResidentPaymentPlan, title: &str, description: &str, icon: &str) -> Html { | ||||
|         let link = ctx.link(); | ||||
|         let is_selected = self.selected_payment_plan == plan; | ||||
|         let card_style = if is_selected { | ||||
|             "border: 2px solid #495057; background: #f8f9fa;" | ||||
|         } else { | ||||
|             "border: 1px solid #e0e0e0; background: white;" | ||||
|         }; | ||||
|          | ||||
|         let on_select = link.callback(move |_| StepPaymentStripeMsg::PaymentPlanChanged(plan.clone())); | ||||
|          | ||||
|         // Get pricing for this plan | ||||
|         let price = plan.get_price(); | ||||
|          | ||||
|         html! { | ||||
|             <div class="col-12"> | ||||
|                 <div class="card mb-3" style={format!("cursor: pointer; border-radius: 8px; transition: all 0.2s ease; {}", card_style)} onclick={on_select}> | ||||
|                     <div class="card-body" style="padding: 1rem;"> | ||||
|                         <div class="d-flex align-items-center"> | ||||
|                             <i class={format!("bi {} me-3", icon)} style="font-size: 1.5rem; color: #6c757d;"></i> | ||||
|                             <div class="flex-grow-1"> | ||||
|                                 <h6 class="card-title mb-1" style="font-size: 0.9rem; font-weight: 600;">{title}</h6> | ||||
|                                 <p class="card-text text-muted mb-0" style="font-size: 0.75rem;">{description}</p> | ||||
|                                 <div class="mt-1"> | ||||
|                                     <span class="fw-bold" style="color: #495057; font-size: 0.9rem;">{format!("${:.2}", price)}</span> | ||||
|                                     {if plan == ResidentPaymentPlan::Yearly { | ||||
|                                         html! { | ||||
|                                             <span class="badge ms-2" style="background: #495057; color: white; font-size: 0.65rem;"> | ||||
|                                                 {"17% OFF"} | ||||
|                                             </span> | ||||
|                                         } | ||||
|                                     } else if plan == ResidentPaymentPlan::Lifetime { | ||||
|                                         html! { | ||||
|                                             <span class="badge ms-2" style="background: #ffc107; color: #212529; font-size: 0.65rem;"> | ||||
|                                                 {"BEST VALUE"} | ||||
|                                             </span> | ||||
|                                         } | ||||
|                                     } else { | ||||
|                                         html! {} | ||||
|                                     }} | ||||
|                                 </div> | ||||
|                             </div> | ||||
|                             <div class="text-end"> | ||||
|                                 {if is_selected { | ||||
|                                     html! { | ||||
|                                         <i class="bi bi-check-circle-fill" style="font-size: 1.25rem; color: #495057;"></i> | ||||
|                                     } | ||||
|                                 } else { | ||||
|                                     html! { | ||||
|                                         <i class="bi bi-circle text-muted" style="font-size: 1.25rem;"></i> | ||||
|                                     } | ||||
|                                 }} | ||||
|                             </div> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn process_stripe_payment(&mut self, ctx: &Context<Self>, client_secret: String) { | ||||
|         let link = ctx.link().clone(); | ||||
|          | ||||
|         // Trigger parent to show processing state | ||||
|         ctx.props().on_process_payment.emit(()); | ||||
|          | ||||
|         spawn_local(async move { | ||||
|             match Self::confirm_payment(&client_secret).await { | ||||
|                 Ok(_) => { | ||||
|                     link.send_message(StepPaymentStripeMsg::PaymentComplete); | ||||
|                 } | ||||
|                 Err(e) => { | ||||
|                     link.send_message(StepPaymentStripeMsg::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(()) | ||||
|     } | ||||
| } | ||||
							
								
								
									
										7
									
								
								portal/src/components/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								portal/src/components/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| pub mod entities; | ||||
| pub mod resident_landing_overlay; | ||||
| pub mod portal_home; | ||||
|  | ||||
| pub use entities::*; | ||||
| pub use resident_landing_overlay::*; | ||||
| pub use portal_home::PortalHome; | ||||
							
								
								
									
										253
									
								
								portal/src/components/portal_home.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										253
									
								
								portal/src/components/portal_home.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,253 @@ | ||||
| use yew::prelude::*; | ||||
| use crate::components::entities::resident_registration::ResidenceCard; | ||||
| use crate::models::company::DigitalResidentFormData; | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct PortalHomeProps { | ||||
|     pub user_name: String, | ||||
|     pub registration_data: Option<DigitalResidentFormData>, | ||||
| } | ||||
|  | ||||
| #[function_component(PortalHome)] | ||||
| pub fn portal_home(props: &PortalHomeProps) -> Html { | ||||
|     let weather_data = use_state(|| WeatherData::default()); | ||||
|      | ||||
|     // Mock weather data for Zanzibar (in a real app, this would be fetched from an API) | ||||
|     use_effect_with((), { | ||||
|         let weather_data = weather_data.clone(); | ||||
|         move |_| { | ||||
|             // Simulate API call with realistic Zanzibar weather | ||||
|             weather_data.set(WeatherData { | ||||
|                 temperature: 28, | ||||
|                 condition: "Partly Cloudy".to_string(), | ||||
|                 humidity: 75, | ||||
|                 wind_speed: 12, | ||||
|                 icon: "bi-cloud-sun".to_string(), | ||||
|             }); | ||||
|             || () | ||||
|         } | ||||
|     }); | ||||
|  | ||||
|     html! { | ||||
|         <> | ||||
|             <style> | ||||
|                 {r#" | ||||
|                 .portal-card { | ||||
|                     transition: all 0.3s ease; | ||||
|                     cursor: pointer; | ||||
|                     border: 1px solid #e9ecef; | ||||
|                     border-radius: 12px; | ||||
|                 } | ||||
|                 .portal-card:hover { | ||||
|                     transform: translateY(-3px); | ||||
|                     box-shadow: 0 8px 25px rgba(0,0,0,0.12); | ||||
|                     border-color: #dee2e6; | ||||
|                 } | ||||
|                 .gradient-accent { | ||||
|                     background: linear-gradient(135deg, #0099FF 0%, #00CC66 100%); | ||||
|                 } | ||||
|                 .weather-icon { | ||||
|                     font-size: 1.4rem; | ||||
|                 } | ||||
|                 .platform-icon { | ||||
|                     width: 56px; | ||||
|                     height: 56px; | ||||
|                     border-radius: 12px; | ||||
|                     display: flex; | ||||
|                     align-items: center; | ||||
|                     justify-content: center; | ||||
|                     font-size: 1.8rem; | ||||
|                     color: white; | ||||
|                 } | ||||
|                 .welcome-section { | ||||
|                     background: linear-gradient(135deg, rgba(0,153,255,0.05) 0%, rgba(0,204,102,0.05) 100%); | ||||
|                     border-radius: 16px; | ||||
|                     border: 1px solid rgba(0,153,255,0.1); | ||||
|                 } | ||||
|                 .user-button { | ||||
|                     background: #6c757d; | ||||
|                     border: none; | ||||
|                     border-radius: 25px; | ||||
|                     color: white; | ||||
|                     padding: 8px 20px; | ||||
|                     font-weight: 600; | ||||
|                     transition: all 0.2s ease; | ||||
|                 } | ||||
|                 .user-button:hover { | ||||
|                     transform: translateY(-1px); | ||||
|                     box-shadow: 0 4px 12px rgba(108,117,125,0.3); | ||||
|                     color: white; | ||||
|                     background: #5a6268; | ||||
|                 } | ||||
|                 "#} | ||||
|             </style> | ||||
|              | ||||
|             // Header | ||||
|             <header class="bg-white border-bottom shadow-sm"> | ||||
|                 <div class="container-fluid"> | ||||
|                     <div class="row align-items-center py-3"> | ||||
|                         <div class="col-md-6"> | ||||
|                             <div class="d-flex align-items-center"> | ||||
|                                 <div class="gradient-accent rounded-3 me-3" style="width: 42px; height: 42px; display: flex; align-items: center; justify-content: center;"> | ||||
|                                     <i class="bi bi-geo-alt-fill text-white" style="font-size: 1.2rem;"></i> | ||||
|                                 </div> | ||||
|                                 <div> | ||||
|                                     <h5 class="mb-0 fw-bold text-dark">{"Zanzibar Digital Freezone"}</h5> | ||||
|                                     <small class="text-muted">{"Portal"}</small> | ||||
|                                 </div> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                         <div class="col-md-6"> | ||||
|                             <div class="d-flex align-items-center justify-content-end"> | ||||
|                                 <div class="me-4 text-end"> | ||||
|                                     <div class="d-flex align-items-center justify-content-end"> | ||||
|                                         <i class={format!("bi {} me-2 weather-icon text-primary", weather_data.icon)}></i> | ||||
|                                         <span class="fw-semibold">{weather_data.temperature}{"°C"}</span> | ||||
|                                         <small class="text-muted ms-2">{&weather_data.condition}</small> | ||||
|                                     </div> | ||||
|                                     <small class="text-muted">{"Stone Town, Zanzibar"}</small> | ||||
|                                 </div> | ||||
|                                 <div class="vr me-3"></div> | ||||
|                                 <button class="btn user-button"> | ||||
|                                     <i class="bi bi-person-circle me-2"></i> | ||||
|                                     {&props.user_name} | ||||
|                                 </button> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </header> | ||||
|  | ||||
|             // Main Content | ||||
|             <main class="bg-light min-vh-100"> | ||||
|                 <div class="container-fluid py-5"> | ||||
|                     <div class="row align-items-start"> | ||||
|                         // Left Column: Welcome Section and Platform Cards | ||||
|                         <div class="col-lg-6 mb-4"> | ||||
|                             // Welcome Section | ||||
|                             <div class="welcome-section p-5 mb-4"> | ||||
|                                 <div class="mb-4"> | ||||
|                                     <h1 class="display-5 fw-bold text-dark mb-3"> | ||||
|                                         {"Welcome to your "} | ||||
|                                         <span class="text-primary">{"Digital Freezone"}</span> | ||||
|                                     </h1> | ||||
|                                     <p class="lead text-muted mb-4"> | ||||
|                                         {"You are now digitally present in Zanzibar. Access your residency services, "} | ||||
|                                         {"manage your digital identity, and explore the freezone ecosystem."} | ||||
|                                     </p> | ||||
|                                 </div> | ||||
|  | ||||
|                                 <div class="alert alert-info border-0 bg-info bg-opacity-10"> | ||||
|                                     <div class="d-flex align-items-center"> | ||||
|                                         <i class="bi bi-info-circle-fill text-info me-2"></i> | ||||
|                                         <small class="text-info mb-0"> | ||||
|                                             {"Your digital residency gives you access to all freezone services and platforms."} | ||||
|                                         </small> | ||||
|                                     </div> | ||||
|                                 </div> | ||||
|                             </div> | ||||
|  | ||||
|                             // Platform Cards | ||||
|                             <div class="d-flex flex-column gap-3"> | ||||
|                                 // Marketplace Card | ||||
|                                 <div class="card portal-card bg-white"> | ||||
|                                     <div class="card-body p-4"> | ||||
|                                         <div class="d-flex align-items-center"> | ||||
|                                             <div class="platform-icon me-3" style="background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%);"> | ||||
|                                                 <i class="bi bi-shop"></i> | ||||
|                                             </div> | ||||
|                                             <div class="flex-grow-1"> | ||||
|                                                 <h5 class="card-title fw-bold mb-1">{"Digital Marketplace"}</h5> | ||||
|                                                 <p class="card-text text-muted small mb-0"> | ||||
|                                                     {"Trade digital assets, services, and products within the freezone ecosystem"} | ||||
|                                                 </p> | ||||
|                                             </div> | ||||
|                                             <i class="bi bi-arrow-right text-muted" style="font-size: 1.2rem;"></i> | ||||
|                                         </div> | ||||
|                                     </div> | ||||
|                                 </div> | ||||
|  | ||||
|                                 // Platform Administration Card | ||||
|                                 <div class="card portal-card bg-white"> | ||||
|                                     <div class="card-body p-4"> | ||||
|                                         <div class="d-flex align-items-center"> | ||||
|                                             <div class="platform-icon me-3 gradient-accent"> | ||||
|                                                 <i class="bi bi-building"></i> | ||||
|                                             </div> | ||||
|                                             <div class="flex-grow-1"> | ||||
|                                                 <h5 class="card-title fw-bold mb-1">{"Freezone Platform"}</h5> | ||||
|                                                 <p class="card-text text-muted small mb-0"> | ||||
|                                                     {"Manage your digital residency, register companies, and access admin services"} | ||||
|                                                 </p> | ||||
|                                             </div> | ||||
|                                             <i class="bi bi-arrow-right text-muted" style="font-size: 1.2rem;"></i> | ||||
|                                         </div> | ||||
|                                     </div> | ||||
|                                 </div> | ||||
|  | ||||
|                                 // DeFi Platform Card | ||||
|                                 <div class="card portal-card bg-white"> | ||||
|                                     <div class="card-body p-4"> | ||||
|                                         <div class="d-flex align-items-center"> | ||||
|                                             <div class="platform-icon me-3" style="background: linear-gradient(135deg, #a29bfe 0%, #6c5ce7 100%);"> | ||||
|                                                 <i class="bi bi-currency-bitcoin"></i> | ||||
|                                             </div> | ||||
|                                             <div class="flex-grow-1"> | ||||
|                                                 <h5 class="card-title fw-bold mb-1">{"DeFi Platform"}</h5> | ||||
|                                                 <p class="card-text text-muted small mb-0"> | ||||
|                                                     {"Access decentralized finance services, trading, and blockchain tools"} | ||||
|                                                 </p> | ||||
|                                             </div> | ||||
|                                             <i class="bi bi-arrow-right text-muted" style="font-size: 1.2rem;"></i> | ||||
|                                         </div> | ||||
|                                     </div> | ||||
|                                 </div> | ||||
|                             </div> | ||||
|                         </div> | ||||
|  | ||||
|                         // Right Column: Residence Card | ||||
|                         <div class="col-lg-6 mb-4"> | ||||
|                             <div class="d-flex justify-content-center"> | ||||
|                                 <ResidenceCard | ||||
|                                     form_data={ | ||||
|                                         if let Some(data) = &props.registration_data { | ||||
|                                             data.clone() | ||||
|                                         } else { | ||||
|                                             DigitalResidentFormData { | ||||
|                                                 full_name: props.user_name.clone(), | ||||
|                                                 email: "resident@zanzibar-freezone.com".to_string(), | ||||
|                                                 public_key: Some("zdf1qxy2mlyjkjkpskpsw9fxtpugs450add72nyktmzqau...".to_string()), | ||||
|                                                 ..DigitalResidentFormData::default() | ||||
|                                             } | ||||
|                                         } | ||||
|                                     } | ||||
|                                 /> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </main> | ||||
|         </> | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Clone, PartialEq)] | ||||
| struct WeatherData { | ||||
|     temperature: i32, | ||||
|     condition: String, | ||||
|     humidity: i32, | ||||
|     wind_speed: i32, | ||||
|     icon: String, | ||||
| } | ||||
|  | ||||
| impl Default for WeatherData { | ||||
|     fn default() -> Self { | ||||
|         Self { | ||||
|             temperature: 25, | ||||
|             condition: "Loading...".to_string(), | ||||
|             humidity: 70, | ||||
|             wind_speed: 10, | ||||
|             icon: "bi-cloud".to_string(), | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										358
									
								
								portal/src/components/resident_landing_overlay.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										358
									
								
								portal/src/components/resident_landing_overlay.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,358 @@ | ||||
| use yew::prelude::*; | ||||
| use web_sys::HtmlInputElement; | ||||
| use crate::models::company::{DigitalResidentFormData, DigitalResident}; | ||||
| use crate::components::entities::resident_registration::SimpleResidentWizard; | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct ResidentLandingOverlayProps { | ||||
|     pub on_registration_complete: Callback<DigitalResident>, | ||||
|     pub on_sign_in: Callback<(String, String)>, // private_key, unused | ||||
|     pub on_close: Option<Callback<()>>, | ||||
| } | ||||
|  | ||||
| pub enum ResidentLandingMsg { | ||||
|     ShowSignIn, | ||||
|     ShowRegister, | ||||
|     UpdatePrivateKey(String), | ||||
|     SignIn, | ||||
|     StartRegistration, | ||||
|     RegistrationComplete(DigitalResident), | ||||
|     BackToLanding, | ||||
| } | ||||
|  | ||||
| pub struct ResidentLandingOverlay { | ||||
|     view_mode: ViewMode, | ||||
|     private_key: String, | ||||
|     show_registration_wizard: bool, | ||||
| } | ||||
|  | ||||
| #[derive(PartialEq)] | ||||
| enum ViewMode { | ||||
|     Landing, | ||||
|     SignIn, | ||||
|     Register, | ||||
| } | ||||
|  | ||||
| impl Component for ResidentLandingOverlay { | ||||
|     type Message = ResidentLandingMsg; | ||||
|     type Properties = ResidentLandingOverlayProps; | ||||
|  | ||||
|     fn create(_ctx: &Context<Self>) -> Self { | ||||
|         Self { | ||||
|             view_mode: ViewMode::SignIn, | ||||
|             private_key: String::new(), | ||||
|             show_registration_wizard: false, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool { | ||||
|         match msg { | ||||
|             ResidentLandingMsg::ShowSignIn => { | ||||
|                 self.view_mode = ViewMode::SignIn; | ||||
|                 self.show_registration_wizard = false; | ||||
|                 true | ||||
|             } | ||||
|             ResidentLandingMsg::ShowRegister => { | ||||
|                 self.view_mode = ViewMode::Register; | ||||
|                 self.show_registration_wizard = false; | ||||
|                 true | ||||
|             } | ||||
|             ResidentLandingMsg::UpdatePrivateKey(private_key) => { | ||||
|                 self.private_key = private_key; | ||||
|                 true | ||||
|             } | ||||
|             ResidentLandingMsg::SignIn => { | ||||
|                 // For now, use the private key as both email and password | ||||
|                 // In a real implementation, you'd derive the public key and look up the user | ||||
|                 ctx.props().on_sign_in.emit((self.private_key.clone(), "".to_string())); | ||||
|                 false | ||||
|             } | ||||
|             ResidentLandingMsg::StartRegistration => { | ||||
|                 self.view_mode = ViewMode::Register; | ||||
|                 self.show_registration_wizard = true; | ||||
|                 true | ||||
|             } | ||||
|             ResidentLandingMsg::RegistrationComplete(resident) => { | ||||
|                 ctx.props().on_registration_complete.emit(resident); | ||||
|                 false | ||||
|             } | ||||
|             ResidentLandingMsg::BackToLanding => { | ||||
|                 self.view_mode = ViewMode::Landing; | ||||
|                 self.show_registration_wizard = false; | ||||
|                 true | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn view(&self, ctx: &Context<Self>) -> Html { | ||||
|         html! { | ||||
|             <> | ||||
|                 <style> | ||||
|                     {"@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }"} | ||||
|                 </style> | ||||
|                 <div class="position-fixed top-0 start-0 w-100 h-100 d-flex" style="z-index: 9999; background: linear-gradient(135deg, #0099FF 0%, #00CC66 100%);"> | ||||
|                     {self.render_content(ctx)} | ||||
|                      | ||||
|                     // Close button (if callback provided) | ||||
|                     {if ctx.props().on_close.is_some() { | ||||
|                         html! { | ||||
|                             <button | ||||
|                                 class="btn btn-outline-light position-absolute top-0 end-0 m-3" | ||||
|                                 style="z-index: 10000;" | ||||
|                                 onclick={ctx.props().on_close.as_ref().unwrap().reform(|_| ())} | ||||
|                             > | ||||
|                                 <i class="bi bi-x-lg"></i> | ||||
|                             </button> | ||||
|                         } | ||||
|                     } else { | ||||
|                         html! {} | ||||
|                     }} | ||||
|                 </div> | ||||
|             </> | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl ResidentLandingOverlay { | ||||
|     fn render_content(&self, ctx: &Context<Self>) -> Html { | ||||
|         // Determine column sizes based on view mode | ||||
|         let (left_col_class, right_col_class) = match self.view_mode { | ||||
|             ViewMode::Register if self.show_registration_wizard => ("col-lg-4", "col-lg-8"), | ||||
|             _ => ("col-lg-7", "col-lg-5"), | ||||
|         }; | ||||
|  | ||||
|         html! { | ||||
|             <div class="container-fluid h-100"> | ||||
|                 <div class="row h-100"> | ||||
|                     // Left side - Branding and description (shrinks when registration is active) | ||||
|                     <div class={format!("{} d-flex align-items-center justify-content-center text-white p-5 transition-all", left_col_class)} | ||||
|                          style="transition: all 0.5s ease-in-out;"> | ||||
|                         <div class="text-center text-lg-start" style="max-width: 600px;"> | ||||
|                             <h1 class="display-4 fw-bold mb-4"> | ||||
|                                 {"Zanzibar Digital Freezone"} | ||||
|                             </h1> | ||||
|                             <h2 class="h3 mb-4 text-white-75"> | ||||
|                                 {"Your Portal to Digital Residency"} | ||||
|                             </h2> | ||||
|                             <p class="lead mb-4 text-white-75"> | ||||
|                                 {"An accelerator of digitalization where governance meets real-world digital asset trade. Participate in a regulated freezone that bridges traditional business with blockchain technology and decentralized finance."} | ||||
|                             </p> | ||||
|                              | ||||
|                             {if !self.show_registration_wizard { | ||||
|                                 html! { | ||||
|                                     <div class="row text-center mt-5"> | ||||
|                                         <div class="col-md-4 mb-3"> | ||||
|                                             <div class="bg-white bg-opacity-10 rounded-3 p-3"> | ||||
|                                                 <i class="bi bi-shield-check display-6 mb-2"></i> | ||||
|                                                 <h6 class="fw-bold">{"Secure Identity"}</h6> | ||||
|                                                 <small class="text-white-75">{"Blockchain-verified digital identity"}</small> | ||||
|                                             </div> | ||||
|                                         </div> | ||||
|                                         <div class="col-md-4 mb-3"> | ||||
|                                             <div class="bg-white bg-opacity-10 rounded-3 p-3"> | ||||
|                                                 <i class="bi bi-building display-6 mb-2"></i> | ||||
|                                                 <h6 class="fw-bold">{"Business Ready"}</h6> | ||||
|                                                 <small class="text-white-75">{"Register companies in minutes"}</small> | ||||
|                                             </div> | ||||
|                                         </div> | ||||
|                                         <div class="col-md-4 mb-3"> | ||||
|                                             <div class="bg-white bg-opacity-10 rounded-3 p-3"> | ||||
|                                                 <i class="bi bi-globe display-6 mb-2"></i> | ||||
|                                                 <h6 class="fw-bold">{"Global Access"}</h6> | ||||
|                                                 <small class="text-white-75">{"Worldwide financial services"}</small> | ||||
|                                             </div> | ||||
|                                         </div> | ||||
|                                     </div> | ||||
|                                 } | ||||
|                             } else { | ||||
|                                 html! {} | ||||
|                             }} | ||||
|                         </div> | ||||
|                     </div> | ||||
|                      | ||||
|                     // Right side - Sign in/Register form (expands when registration is active) | ||||
|                     <div class={format!("{} d-flex align-items-center justify-content-center bg-white", right_col_class)} | ||||
|                          style="transition: all 0.5s ease-in-out;"> | ||||
|                         <div class="w-100 h-100" style={if self.show_registration_wizard { "padding: 1rem;" } else { "max-width: 400px; padding: 2rem;" }}> | ||||
|                             {match self.view_mode { | ||||
|                                 ViewMode::Landing => self.render_landing_form(ctx), | ||||
|                                 ViewMode::SignIn => self.render_sign_in_form(ctx), | ||||
|                                 ViewMode::Register if self.show_registration_wizard => self.render_embedded_registration_wizard(ctx), | ||||
|                                 ViewMode::Register => self.render_register_form(ctx), | ||||
|                             }} | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn render_landing_form(&self, ctx: &Context<Self>) -> Html { | ||||
|         let link = ctx.link(); | ||||
|          | ||||
|         html! { | ||||
|             <div class="text-center"> | ||||
|                 <div class="mb-4"> | ||||
|                     <i class="bi bi-person-circle text-primary" style="font-size: 4rem;"></i> | ||||
|                 </div> | ||||
|                 <h3 class="mb-4">{"Welcome to ZDF"}</h3> | ||||
|                 <p class="text-muted mb-4"> | ||||
|                     {"Get started with your digital residency journey"} | ||||
|                 </p> | ||||
|                  | ||||
|                 <div class="d-grid gap-3"> | ||||
|                     <button | ||||
|                         class="btn btn-primary btn-lg" | ||||
|                         onclick={link.callback(|_| ResidentLandingMsg::StartRegistration)} | ||||
|                     > | ||||
|                         <i class="bi bi-person-plus me-2"></i> | ||||
|                         {"Become a Digital Resident"} | ||||
|                     </button> | ||||
|                      | ||||
|                     <button | ||||
|                         class="btn btn-outline-primary btn-lg" | ||||
|                         onclick={link.callback(|_| ResidentLandingMsg::ShowSignIn)} | ||||
|                     > | ||||
|                         <i class="bi bi-box-arrow-in-right me-2"></i> | ||||
|                         {"Sign In to Your Account"} | ||||
|                     </button> | ||||
|                 </div> | ||||
|                  | ||||
|                 <div class="mt-4 pt-4 border-top"> | ||||
|                     <small class="text-muted"> | ||||
|                         {"Already have an account? "} | ||||
|                         <a href="#" class="text-primary text-decoration-none" onclick={link.callback(|e: MouseEvent| { | ||||
|                             e.prevent_default(); | ||||
|                             ResidentLandingMsg::ShowSignIn | ||||
|                         })}> | ||||
|                             {"Sign in here"} | ||||
|                         </a> | ||||
|                     </small> | ||||
|                 </div> | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn render_sign_in_form(&self, ctx: &Context<Self>) -> Html { | ||||
|         let link = ctx.link(); | ||||
|          | ||||
|         let on_private_key_input = { | ||||
|             let link = link.clone(); | ||||
|             Callback::from(move |e: InputEvent| { | ||||
|                 let input: HtmlInputElement = e.target_unchecked_into(); | ||||
|                 link.send_message(ResidentLandingMsg::UpdatePrivateKey(input.value())); | ||||
|             }) | ||||
|         }; | ||||
|          | ||||
|         let on_submit = { | ||||
|             let link = link.clone(); | ||||
|             Callback::from(move |e: SubmitEvent| { | ||||
|                 e.prevent_default(); | ||||
|                 link.send_message(ResidentLandingMsg::SignIn); | ||||
|             }) | ||||
|         }; | ||||
|  | ||||
|         html! { | ||||
|             <div class="d-flex flex-column h-100 justify-content-center" style="max-width: 400px; margin: 0 auto; padding: 2rem;"> | ||||
|                 <div class="text-center mb-4"> | ||||
|                     <div class="mb-3"> | ||||
|                         <i class="bi bi-key" style="font-size: 3rem; color: #495057;"></i> | ||||
|                     </div> | ||||
|                     <h3 class="mb-2" style="color: #495057; font-weight: 600;">{"Welcome Back"}</h3> | ||||
|                     <p class="text-muted" style="font-size: 0.9rem;">{"Sign in with your private key"}</p> | ||||
|                 </div> | ||||
|                  | ||||
|                 <form onsubmit={on_submit}> | ||||
|                     <div class="mb-4"> | ||||
|                         <label for="signin-private-key" class="form-label text-muted" style="font-size: 0.875rem; font-weight: 500;">{"Private Key"}</label> | ||||
|                         <textarea | ||||
|                             class="form-control" | ||||
|                             id="signin-private-key" | ||||
|                             value={self.private_key.clone()} | ||||
|                             oninput={on_private_key_input} | ||||
|                             placeholder="Enter your private key..." | ||||
|                             rows="4" | ||||
|                             style="border: 1px solid #e0e0e0; border-radius: 8px; padding: 0.75rem; font-size: 0.8rem; font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; transition: all 0.2s ease; resize: vertical;" | ||||
|                             required={true} | ||||
|                         /> | ||||
|                         <small class="text-muted">{"Your private key is used to securely access your digital residency account."}</small> | ||||
|                     </div> | ||||
|                      | ||||
|                     <div class="d-grid mb-4"> | ||||
|                         <button type="submit" class="btn" 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;"> | ||||
|                             <i class="bi bi-key me-2"></i> | ||||
|                             {"Sign In"} | ||||
|                         </button> | ||||
|                     </div> | ||||
|                 </form> | ||||
|                  | ||||
|                 <div class="text-center mb-4"> | ||||
|                     <div class="position-relative"> | ||||
|                         <hr style="border-color: #e0e0e0;"/> | ||||
|                         <span class="position-absolute top-50 start-50 translate-middle bg-white px-3 text-muted" style="font-size: 0.8rem;">{"New to ZDF?"}</span> | ||||
|                     </div> | ||||
|                 </div> | ||||
|                  | ||||
|                 <div class="d-grid mb-3"> | ||||
|                     <button | ||||
|                         type="button" | ||||
|                         class="btn btn-outline-primary" | ||||
|                         onclick={link.callback(|_| ResidentLandingMsg::StartRegistration)} | ||||
|                         style="border: 1px solid #495057; color: #495057; padding: 0.75rem; border-radius: 8px; font-size: 0.9rem; font-weight: 500; transition: all 0.2s ease;" | ||||
|                     > | ||||
|                         <i class="bi bi-person-plus me-2"></i> | ||||
|                         {"Become a Digital Resident"} | ||||
|                     </button> | ||||
|                 </div> | ||||
|                  | ||||
|                 <div class="text-center mt-auto"> | ||||
|                     <small class="text-muted" style="font-size: 0.75rem;"> | ||||
|                         {"Lost your private key? "} | ||||
|                         <a href="#" class="text-decoration-none" style="color: #495057;">{"Recovery options"}</a> | ||||
|                     </small> | ||||
|                 </div> | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn render_register_form(&self, _ctx: &Context<Self>) -> Html { | ||||
|         // This form is no longer used - registration goes directly to the wizard | ||||
|         html! { | ||||
|             <div class="text-center"> | ||||
|                 <h3>{"Registration"}</h3> | ||||
|                 <p class="text-muted">{"Redirecting to registration wizard..."}</p> | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn render_embedded_registration_wizard(&self, ctx: &Context<Self>) -> Html { | ||||
|         let link = ctx.link(); | ||||
|          | ||||
|         html! { | ||||
|             <div class="h-100 d-flex flex-column"> | ||||
|                 // Header with back button (always visible) | ||||
|                 <div class="d-flex justify-content-between align-items-center p-3 border-bottom"> | ||||
|                     <h4 class="mb-0">{"Digital Resident Registration"}</h4> | ||||
|                     <button | ||||
|                         class="btn btn-outline-secondary btn-sm" | ||||
|                         onclick={link.callback(|_| ResidentLandingMsg::BackToLanding)} | ||||
|                     > | ||||
|                         <i class="bi bi-arrow-left me-1"></i>{"Back"} | ||||
|                     </button> | ||||
|                 </div> | ||||
|                  | ||||
|                 // Registration wizard content with fade-in animation | ||||
|                 <div class="flex-grow-1 overflow-auto" | ||||
|                      style="opacity: 0; animation: fadeIn 0.5s ease-in-out 0.25s forwards;"> | ||||
|                     <SimpleResidentWizard | ||||
|                         on_registration_complete={link.callback(ResidentLandingMsg::RegistrationComplete)} | ||||
|                         on_back_to_parent={link.callback(|_| ResidentLandingMsg::BackToLanding)} | ||||
|                         success_resident_id={None} | ||||
|                         show_failure={false} | ||||
|                     /> | ||||
|                 </div> | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
							
								
								
									
										16
									
								
								portal/src/lib.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								portal/src/lib.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| use wasm_bindgen::prelude::*; | ||||
|  | ||||
| mod app; | ||||
| mod components; | ||||
| mod models; | ||||
| mod services; | ||||
|  | ||||
| use app::App; | ||||
|  | ||||
| // This is the entry point for the web app | ||||
| #[wasm_bindgen(start)] | ||||
| pub fn run_app() { | ||||
|     wasm_logger::init(wasm_logger::Config::default()); | ||||
|     log::info!("Starting Zanzibar Digital Freezone Portal"); | ||||
|     yew::Renderer::<App>::new().render(); | ||||
| } | ||||
							
								
								
									
										747
									
								
								portal/src/models/company.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										747
									
								
								portal/src/models/company.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,747 @@ | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use std::collections::HashMap; | ||||
|  | ||||
| #[derive(Clone, PartialEq, Serialize, Deserialize, Debug)] | ||||
| pub struct Company { | ||||
|     pub id: u32, | ||||
|     pub name: String, | ||||
|     pub company_type: CompanyType, | ||||
|     pub status: CompanyStatus, | ||||
|     pub registration_number: String, | ||||
|     pub incorporation_date: String, | ||||
|     pub email: Option<String>, | ||||
|     pub phone: Option<String>, | ||||
|     pub website: Option<String>, | ||||
|     pub address: Option<String>, | ||||
|     pub industry: Option<String>, | ||||
|     pub description: Option<String>, | ||||
|     pub fiscal_year_end: Option<String>, | ||||
|     pub shareholders: Vec<Shareholder>, | ||||
| } | ||||
|  | ||||
| #[derive(Clone, Copy, PartialEq, Serialize, Deserialize, Debug)] | ||||
| pub enum CompanyType { | ||||
|     SingleFZC, | ||||
|     StartupFZC, | ||||
|     GrowthFZC, | ||||
|     GlobalFZC, | ||||
|     CooperativeFZC, | ||||
| } | ||||
|  | ||||
| impl CompanyType { | ||||
|     pub fn to_string(&self) -> String { | ||||
|         match self { | ||||
|             CompanyType::SingleFZC => "Single FZC".to_string(), | ||||
|             CompanyType::StartupFZC => "Startup FZC".to_string(), | ||||
|             CompanyType::GrowthFZC => "Growth FZC".to_string(), | ||||
|             CompanyType::GlobalFZC => "Global FZC".to_string(), | ||||
|             CompanyType::CooperativeFZC => "Cooperative FZC".to_string(), | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn from_string(s: &str) -> Option<Self> { | ||||
|         match s { | ||||
|             "Single FZC" => Some(CompanyType::SingleFZC), | ||||
|             "Startup FZC" => Some(CompanyType::StartupFZC), | ||||
|             "Growth FZC" => Some(CompanyType::GrowthFZC), | ||||
|             "Global FZC" => Some(CompanyType::GlobalFZC), | ||||
|             "Cooperative FZC" => Some(CompanyType::CooperativeFZC), | ||||
|             _ => None, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn get_pricing(&self) -> CompanyPricing { | ||||
|         match self { | ||||
|             CompanyType::SingleFZC => CompanyPricing { | ||||
|                 setup_fee: 20.0, | ||||
|                 monthly_fee: 20.0, | ||||
|                 max_shareholders: 1, | ||||
|                 features: vec![ | ||||
|                     "1 shareholder".to_string(), | ||||
|                     "Cannot issue digital assets".to_string(), | ||||
|                     "Can hold external shares".to_string(), | ||||
|                     "Connect to bank".to_string(), | ||||
|                     "Participate in ecosystem".to_string(), | ||||
|                 ], | ||||
|             }, | ||||
|             CompanyType::StartupFZC => CompanyPricing { | ||||
|                 setup_fee: 50.0, | ||||
|                 monthly_fee: 50.0, | ||||
|                 max_shareholders: 5, | ||||
|                 features: vec![ | ||||
|                     "Up to 5 shareholders".to_string(), | ||||
|                     "Can issue digital assets".to_string(), | ||||
|                     "Hold external shares".to_string(), | ||||
|                     "Connect to bank".to_string(), | ||||
|                 ], | ||||
|             }, | ||||
|             CompanyType::GrowthFZC => CompanyPricing { | ||||
|                 setup_fee: 100.0, | ||||
|                 monthly_fee: 100.0, | ||||
|                 max_shareholders: 20, | ||||
|                 features: vec![ | ||||
|                     "Up to 20 shareholders".to_string(), | ||||
|                     "Can issue digital assets".to_string(), | ||||
|                     "Hold external shares".to_string(), | ||||
|                     "Connect to bank".to_string(), | ||||
|                     "Hold physical assets".to_string(), | ||||
|                 ], | ||||
|             }, | ||||
|             CompanyType::GlobalFZC => CompanyPricing { | ||||
|                 setup_fee: 2000.0, | ||||
|                 monthly_fee: 200.0, | ||||
|                 max_shareholders: 999, | ||||
|                 features: vec![ | ||||
|                     "Unlimited shareholders".to_string(), | ||||
|                     "Can issue digital assets".to_string(), | ||||
|                     "Hold external shares".to_string(), | ||||
|                     "Connect to bank".to_string(), | ||||
|                     "Hold physical assets".to_string(), | ||||
|                 ], | ||||
|             }, | ||||
|             CompanyType::CooperativeFZC => CompanyPricing { | ||||
|                 setup_fee: 2000.0, | ||||
|                 monthly_fee: 200.0, | ||||
|                 max_shareholders: 999, | ||||
|                 features: vec![ | ||||
|                     "Unlimited members".to_string(), | ||||
|                     "Democratic governance".to_string(), | ||||
|                     "Collective decision-making".to_string(), | ||||
|                     "Equitable distribution".to_string(), | ||||
|                 ], | ||||
|             }, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn get_capabilities(&self) -> HashMap<String, bool> { | ||||
|         let mut capabilities = HashMap::new(); | ||||
|          | ||||
|         // All types have these basic capabilities | ||||
|         capabilities.insert("digital_assets".to_string(), true); | ||||
|         capabilities.insert("ecosystem".to_string(), true); | ||||
|         capabilities.insert("ai_dispute".to_string(), true); | ||||
|         capabilities.insert("digital_signing".to_string(), true); | ||||
|         capabilities.insert("external_shares".to_string(), true); | ||||
|         capabilities.insert("bank_account".to_string(), true); | ||||
|          | ||||
|         // Type-specific capabilities | ||||
|         match self { | ||||
|             CompanyType::SingleFZC => { | ||||
|                 capabilities.insert("issue_assets".to_string(), false); | ||||
|                 capabilities.insert("physical_assets".to_string(), false); | ||||
|                 capabilities.insert("democratic".to_string(), false); | ||||
|                 capabilities.insert("collective".to_string(), false); | ||||
|             }, | ||||
|             CompanyType::StartupFZC => { | ||||
|                 capabilities.insert("issue_assets".to_string(), true); | ||||
|                 capabilities.insert("physical_assets".to_string(), false); | ||||
|                 capabilities.insert("democratic".to_string(), false); | ||||
|                 capabilities.insert("collective".to_string(), false); | ||||
|             }, | ||||
|             CompanyType::GrowthFZC => { | ||||
|                 capabilities.insert("issue_assets".to_string(), true); | ||||
|                 capabilities.insert("physical_assets".to_string(), true); | ||||
|                 capabilities.insert("democratic".to_string(), false); | ||||
|                 capabilities.insert("collective".to_string(), false); | ||||
|             }, | ||||
|             CompanyType::GlobalFZC => { | ||||
|                 capabilities.insert("issue_assets".to_string(), true); | ||||
|                 capabilities.insert("physical_assets".to_string(), true); | ||||
|                 capabilities.insert("democratic".to_string(), false); | ||||
|                 capabilities.insert("collective".to_string(), false); | ||||
|             }, | ||||
|             CompanyType::CooperativeFZC => { | ||||
|                 capabilities.insert("issue_assets".to_string(), true); | ||||
|                 capabilities.insert("physical_assets".to_string(), true); | ||||
|                 capabilities.insert("democratic".to_string(), true); | ||||
|                 capabilities.insert("collective".to_string(), true); | ||||
|             }, | ||||
|         } | ||||
|          | ||||
|         capabilities | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Clone, PartialEq, Serialize, Deserialize, Debug)] | ||||
| pub enum CompanyStatus { | ||||
|     Active, | ||||
|     Inactive, | ||||
|     Suspended, | ||||
|     PendingPayment, | ||||
| } | ||||
|  | ||||
| impl CompanyStatus { | ||||
|     pub fn to_string(&self) -> String { | ||||
|         match self { | ||||
|             CompanyStatus::Active => "Active".to_string(), | ||||
|             CompanyStatus::Inactive => "Inactive".to_string(), | ||||
|             CompanyStatus::Suspended => "Suspended".to_string(), | ||||
|             CompanyStatus::PendingPayment => "Pending Payment".to_string(), | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn get_badge_class(&self) -> String { | ||||
|         match self { | ||||
|             CompanyStatus::Active => "badge bg-success".to_string(), | ||||
|             CompanyStatus::Inactive => "badge bg-secondary".to_string(), | ||||
|             CompanyStatus::Suspended => "badge bg-warning text-dark".to_string(), | ||||
|             CompanyStatus::PendingPayment => "badge bg-info".to_string(), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Clone, PartialEq, Serialize, Deserialize, Debug)] | ||||
| pub struct CompanyPricing { | ||||
|     pub setup_fee: f64, | ||||
|     pub monthly_fee: f64, | ||||
|     pub max_shareholders: u32, | ||||
|     pub features: Vec<String>, | ||||
| } | ||||
|  | ||||
| // Registration form data | ||||
| #[derive(Clone, PartialEq, Serialize, Deserialize, Debug)] | ||||
| pub struct CompanyFormData { | ||||
|     // Step 1: General Information | ||||
|     pub company_name: String, | ||||
|     pub company_email: String, | ||||
|     pub company_phone: String, | ||||
|     pub company_website: Option<String>, | ||||
|     pub company_address: String, | ||||
|     pub company_industry: Option<String>, | ||||
|     pub company_purpose: Option<String>, | ||||
|     pub fiscal_year_end: Option<String>, | ||||
|      | ||||
|     // Step 2: Company Type | ||||
|     pub company_type: CompanyType, | ||||
|      | ||||
|     // Step 3: Shareholders | ||||
|     pub shareholder_structure: ShareholderStructure, | ||||
|     pub shareholders: Vec<Shareholder>, | ||||
|      | ||||
|     // Step 4: Payment & Agreements | ||||
|     pub payment_plan: PaymentPlan, | ||||
|     pub legal_agreements: LegalAgreements, | ||||
| } | ||||
|  | ||||
| impl Default for CompanyFormData { | ||||
|     fn default() -> Self { | ||||
|         Self { | ||||
|             company_name: String::new(), | ||||
|             company_email: String::new(), | ||||
|             company_phone: String::new(), | ||||
|             company_website: None, | ||||
|             company_address: String::new(), | ||||
|             company_industry: None, | ||||
|             company_purpose: None, | ||||
|             fiscal_year_end: None, | ||||
|             company_type: CompanyType::StartupFZC, | ||||
|             shareholder_structure: ShareholderStructure::Equal, | ||||
|             shareholders: vec![Shareholder { | ||||
|                 name: String::new(), | ||||
|                 resident_id: String::new(), | ||||
|                 percentage: 100.0, | ||||
|             }], | ||||
|             payment_plan: PaymentPlan::Monthly, | ||||
|             legal_agreements: LegalAgreements::default(), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Clone, PartialEq, Serialize, Deserialize, Debug)] | ||||
| pub struct Shareholder { | ||||
|     pub name: String, | ||||
|     pub resident_id: String, | ||||
|     pub percentage: f64, | ||||
| } | ||||
|  | ||||
| #[derive(Clone, PartialEq, Serialize, Deserialize, Debug)] | ||||
| pub enum ShareholderStructure { | ||||
|     Equal, | ||||
|     Custom, | ||||
| } | ||||
|  | ||||
| impl ShareholderStructure { | ||||
|     pub fn to_string(&self) -> String { | ||||
|         match self { | ||||
|             ShareholderStructure::Equal => "equal".to_string(), | ||||
|             ShareholderStructure::Custom => "custom".to_string(), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Clone, Copy, PartialEq, Serialize, Deserialize, Debug)] | ||||
| pub enum PaymentPlan { | ||||
|     Monthly, | ||||
|     Yearly, | ||||
|     TwoYear, | ||||
| } | ||||
|  | ||||
| impl PaymentPlan { | ||||
|     pub fn to_string(&self) -> String { | ||||
|         match self { | ||||
|             PaymentPlan::Monthly => "monthly".to_string(), | ||||
|             PaymentPlan::Yearly => "yearly".to_string(), | ||||
|             PaymentPlan::TwoYear => "two_year".to_string(), | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn from_string(s: &str) -> Option<Self> { | ||||
|         match s { | ||||
|             "monthly" => Some(PaymentPlan::Monthly), | ||||
|             "yearly" => Some(PaymentPlan::Yearly), | ||||
|             "two_year" => Some(PaymentPlan::TwoYear), | ||||
|             _ => None, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn get_display_name(&self) -> String { | ||||
|         match self { | ||||
|             PaymentPlan::Monthly => "Monthly".to_string(), | ||||
|             PaymentPlan::Yearly => "Yearly".to_string(), | ||||
|             PaymentPlan::TwoYear => "2 Years".to_string(), | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn get_discount(&self) -> f64 { | ||||
|         match self { | ||||
|             PaymentPlan::Monthly => 1.0, | ||||
|             PaymentPlan::Yearly => 0.8, // 20% discount | ||||
|             PaymentPlan::TwoYear => 0.6, // 40% discount | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn get_badge_class(&self) -> Option<String> { | ||||
|         match self { | ||||
|             PaymentPlan::Monthly => None, | ||||
|             PaymentPlan::Yearly => Some("badge bg-success".to_string()), | ||||
|             PaymentPlan::TwoYear => Some("badge bg-warning".to_string()), | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn get_badge_text(&self) -> Option<String> { | ||||
|         match self { | ||||
|             PaymentPlan::Monthly => None, | ||||
|             PaymentPlan::Yearly => Some("20% OFF".to_string()), | ||||
|             PaymentPlan::TwoYear => Some("40% OFF".to_string()), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Clone, PartialEq, Serialize, Deserialize, Debug)] | ||||
| pub struct LegalAgreements { | ||||
|     pub terms: bool, | ||||
|     pub privacy: bool, | ||||
|     pub compliance: bool, | ||||
|     pub articles: bool, | ||||
|     pub final_agreement: bool, | ||||
| } | ||||
|  | ||||
| impl Default for LegalAgreements { | ||||
|     fn default() -> Self { | ||||
|         Self { | ||||
|             terms: false, | ||||
|             privacy: false, | ||||
|             compliance: false, | ||||
|             articles: false, | ||||
|             final_agreement: false, | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl LegalAgreements { | ||||
|     pub fn all_agreed(&self) -> bool { | ||||
|         self.terms && self.privacy && self.compliance && self.articles && self.final_agreement | ||||
|     } | ||||
|  | ||||
|     pub fn missing_agreements(&self) -> Vec<String> { | ||||
|         let mut missing = Vec::new(); | ||||
|          | ||||
|         if !self.terms { | ||||
|             missing.push("Terms of Service".to_string()); | ||||
|         } | ||||
|         if !self.privacy { | ||||
|             missing.push("Privacy Policy".to_string()); | ||||
|         } | ||||
|         if !self.compliance { | ||||
|             missing.push("Compliance Agreement".to_string()); | ||||
|         } | ||||
|         if !self.articles { | ||||
|             missing.push("Articles of Incorporation".to_string()); | ||||
|         } | ||||
|         if !self.final_agreement { | ||||
|             missing.push("Final Agreement".to_string()); | ||||
|         } | ||||
|          | ||||
|         missing | ||||
|     } | ||||
| } | ||||
|  | ||||
| // State management structures | ||||
| #[derive(Clone, PartialEq)] | ||||
| pub struct EntitiesState { | ||||
|     pub active_tab: ActiveTab, | ||||
|     pub companies: Vec<Company>, | ||||
|     pub registration_state: RegistrationState, | ||||
|     pub loading: bool, | ||||
|     pub error: Option<String>, | ||||
| } | ||||
|  | ||||
| impl Default for EntitiesState { | ||||
|     fn default() -> Self { | ||||
|         Self { | ||||
|             active_tab: ActiveTab::Companies, | ||||
|             companies: Vec::new(), | ||||
|             registration_state: RegistrationState::default(), | ||||
|             loading: false, | ||||
|             error: None, | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Clone, PartialEq)] | ||||
| pub struct RegistrationState { | ||||
|     pub current_step: u8, | ||||
|     pub form_data: CompanyFormData, | ||||
|     pub validation_errors: std::collections::HashMap<String, String>, | ||||
|     pub payment_intent: Option<String>, // Payment intent ID | ||||
|     pub auto_save_enabled: bool, | ||||
|     pub processing_payment: bool, | ||||
| } | ||||
|  | ||||
| impl Default for RegistrationState { | ||||
|     fn default() -> Self { | ||||
|         Self { | ||||
|             current_step: 1, | ||||
|             form_data: CompanyFormData::default(), | ||||
|             validation_errors: std::collections::HashMap::new(), | ||||
|             payment_intent: None, | ||||
|             auto_save_enabled: true, | ||||
|             processing_payment: false, | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Clone, PartialEq)] | ||||
| pub enum ActiveTab { | ||||
|     Companies, | ||||
|     RegisterCompany, | ||||
| } | ||||
|  | ||||
| impl ActiveTab { | ||||
|     pub fn to_string(&self) -> String { | ||||
|         match self { | ||||
|             ActiveTab::Companies => "Companies".to_string(), | ||||
|             ActiveTab::RegisterCompany => "Register Company".to_string(), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| // Payment-related structures | ||||
| #[derive(Clone, PartialEq, Serialize, Deserialize, Debug)] | ||||
| pub struct PaymentIntent { | ||||
|     pub id: String, | ||||
|     pub client_secret: String, | ||||
|     pub amount: f64, | ||||
|     pub currency: String, | ||||
|     pub status: String, | ||||
| } | ||||
|  | ||||
| // Validation result | ||||
| #[derive(Clone, PartialEq, Debug)] | ||||
| pub struct ValidationResult { | ||||
|     pub is_valid: bool, | ||||
|     pub errors: Vec<String>, | ||||
| } | ||||
|  | ||||
| impl ValidationResult { | ||||
|     pub fn valid() -> Self { | ||||
|         Self { | ||||
|             is_valid: true, | ||||
|             errors: Vec::new(), | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     pub fn invalid(errors: Vec<String>) -> Self { | ||||
|         Self { | ||||
|             is_valid: false, | ||||
|             errors, | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| // Digital Resident Registration Models | ||||
| #[derive(Clone, PartialEq, Serialize, Deserialize, Debug)] | ||||
| pub struct DigitalResidentFormData { | ||||
|     // Step 1: Personal Information | ||||
|     pub full_name: String, | ||||
|     pub email: String, | ||||
|     pub phone: String, | ||||
|     pub date_of_birth: String, | ||||
|     pub nationality: String, | ||||
|     pub passport_number: String, | ||||
|     pub passport_expiry: String, | ||||
|      | ||||
|     // Cryptographic Keys | ||||
|     pub public_key: Option<String>, | ||||
|     pub private_key: Option<String>, | ||||
|     pub private_key_shown: bool, // Track if private key has been shown | ||||
|      | ||||
|     // Step 2: Address Information | ||||
|     pub current_address: String, | ||||
|     pub city: String, | ||||
|     pub country: String, | ||||
|     pub postal_code: String, | ||||
|     pub permanent_address: Option<String>, | ||||
|      | ||||
|     // Step 3: Professional Information | ||||
|     pub occupation: String, | ||||
|     pub employer: Option<String>, | ||||
|     pub annual_income: Option<String>, | ||||
|     pub education_level: String, | ||||
|     pub skills: Vec<String>, | ||||
|      | ||||
|     // Step 4: Digital Services | ||||
|     pub requested_services: Vec<DigitalService>, | ||||
|     pub preferred_language: String, | ||||
|     pub communication_preferences: CommunicationPreferences, | ||||
|      | ||||
|     // Step 5: Payment & Agreements | ||||
|     pub payment_plan: ResidentPaymentPlan, | ||||
|     pub legal_agreements: LegalAgreements, | ||||
| } | ||||
|  | ||||
| impl Default for DigitalResidentFormData { | ||||
|     fn default() -> Self { | ||||
|         Self { | ||||
|             full_name: String::new(), | ||||
|             email: String::new(), | ||||
|             phone: String::new(), | ||||
|             date_of_birth: String::new(), | ||||
|             nationality: String::new(), | ||||
|             passport_number: String::new(), | ||||
|             passport_expiry: String::new(), | ||||
|             public_key: None, | ||||
|             private_key: None, | ||||
|             private_key_shown: false, | ||||
|             current_address: String::new(), | ||||
|             city: String::new(), | ||||
|             country: String::new(), | ||||
|             postal_code: String::new(), | ||||
|             permanent_address: None, | ||||
|             occupation: String::new(), | ||||
|             employer: None, | ||||
|             annual_income: None, | ||||
|             education_level: String::new(), | ||||
|             skills: Vec::new(), | ||||
|             requested_services: Vec::new(), | ||||
|             preferred_language: "English".to_string(), | ||||
|             communication_preferences: CommunicationPreferences::default(), | ||||
|             payment_plan: ResidentPaymentPlan::Monthly, | ||||
|             legal_agreements: LegalAgreements::default(), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Clone, PartialEq, Serialize, Deserialize, Debug)] | ||||
| pub enum DigitalService { | ||||
|     BankingAccess, | ||||
|     TaxFiling, | ||||
|     HealthcareAccess, | ||||
|     EducationServices, | ||||
|     BusinessLicensing, | ||||
|     PropertyServices, | ||||
|     LegalServices, | ||||
|     DigitalIdentity, | ||||
| } | ||||
|  | ||||
| impl DigitalService { | ||||
|     pub fn get_display_name(&self) -> &'static str { | ||||
|         match self { | ||||
|             DigitalService::BankingAccess => "Banking Access", | ||||
|             DigitalService::TaxFiling => "Tax Filing Services", | ||||
|             DigitalService::HealthcareAccess => "Healthcare Access", | ||||
|             DigitalService::EducationServices => "Education Services", | ||||
|             DigitalService::BusinessLicensing => "Business Licensing", | ||||
|             DigitalService::PropertyServices => "Property Services", | ||||
|             DigitalService::LegalServices => "Legal Services", | ||||
|             DigitalService::DigitalIdentity => "Digital Identity", | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn get_description(&self) -> &'static str { | ||||
|         match self { | ||||
|             DigitalService::BankingAccess => "Access to digital banking services and financial institutions", | ||||
|             DigitalService::TaxFiling => "Automated tax filing and compliance services", | ||||
|             DigitalService::HealthcareAccess => "Access to healthcare providers and medical services", | ||||
|             DigitalService::EducationServices => "Educational resources and certification programs", | ||||
|             DigitalService::BusinessLicensing => "Business registration and licensing services", | ||||
|             DigitalService::PropertyServices => "Property rental and purchase assistance", | ||||
|             DigitalService::LegalServices => "Legal consultation and document services", | ||||
|             DigitalService::DigitalIdentity => "Secure digital identity verification", | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn get_icon(&self) -> &'static str { | ||||
|         match self { | ||||
|             DigitalService::BankingAccess => "bi-bank", | ||||
|             DigitalService::TaxFiling => "bi-calculator", | ||||
|             DigitalService::HealthcareAccess => "bi-heart-pulse", | ||||
|             DigitalService::EducationServices => "bi-mortarboard", | ||||
|             DigitalService::BusinessLicensing => "bi-briefcase", | ||||
|             DigitalService::PropertyServices => "bi-house", | ||||
|             DigitalService::LegalServices => "bi-scales", | ||||
|             DigitalService::DigitalIdentity => "bi-person-badge", | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Clone, PartialEq, Serialize, Deserialize, Debug)] | ||||
| pub struct CommunicationPreferences { | ||||
|     pub email_notifications: bool, | ||||
|     pub sms_notifications: bool, | ||||
|     pub push_notifications: bool, | ||||
|     pub newsletter: bool, | ||||
| } | ||||
|  | ||||
| impl Default for CommunicationPreferences { | ||||
|     fn default() -> Self { | ||||
|         Self { | ||||
|             email_notifications: true, | ||||
|             sms_notifications: false, | ||||
|             push_notifications: true, | ||||
|             newsletter: false, | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Clone, Copy, PartialEq, Serialize, Deserialize, Debug)] | ||||
| pub enum ResidentPaymentPlan { | ||||
|     Monthly, | ||||
|     Yearly, | ||||
|     Lifetime, | ||||
| } | ||||
|  | ||||
| impl ResidentPaymentPlan { | ||||
|     pub fn get_display_name(&self) -> &'static str { | ||||
|         match self { | ||||
|             ResidentPaymentPlan::Monthly => "Monthly", | ||||
|             ResidentPaymentPlan::Yearly => "Yearly", | ||||
|             ResidentPaymentPlan::Lifetime => "Lifetime", | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn get_price(&self) -> f64 { | ||||
|         match self { | ||||
|             ResidentPaymentPlan::Monthly => 29.99, | ||||
|             ResidentPaymentPlan::Yearly => 299.99, // ~17% discount | ||||
|             ResidentPaymentPlan::Lifetime => 999.99, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn get_discount(&self) -> f64 { | ||||
|         match self { | ||||
|             ResidentPaymentPlan::Monthly => 1.0, | ||||
|             ResidentPaymentPlan::Yearly => 0.83, // 17% discount | ||||
|             ResidentPaymentPlan::Lifetime => 0.0, // Special pricing | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn get_description(&self) -> &'static str { | ||||
|         match self { | ||||
|             ResidentPaymentPlan::Monthly => "Pay monthly with full flexibility", | ||||
|             ResidentPaymentPlan::Yearly => "Save 17% with annual payment", | ||||
|             ResidentPaymentPlan::Lifetime => "One-time payment for lifetime access", | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Clone, PartialEq, Serialize, Deserialize, Debug)] | ||||
| pub struct DigitalResident { | ||||
|     pub id: u32, | ||||
|     pub full_name: String, | ||||
|     pub email: String, | ||||
|     pub phone: String, | ||||
|     pub date_of_birth: String, | ||||
|     pub nationality: String, | ||||
|     pub passport_number: String, | ||||
|     pub passport_expiry: String, | ||||
|     pub current_address: String, | ||||
|     pub city: String, | ||||
|     pub country: String, | ||||
|     pub postal_code: String, | ||||
|     pub occupation: String, | ||||
|     pub employer: Option<String>, | ||||
|     pub annual_income: Option<String>, | ||||
|     pub education_level: String, | ||||
|     pub selected_services: Vec<DigitalService>, | ||||
|     pub payment_plan: ResidentPaymentPlan, | ||||
|     pub registration_date: String, | ||||
|     pub status: ResidentStatus, | ||||
|     // KYC fields | ||||
|     pub kyc_documents_uploaded: bool, | ||||
|     pub kyc_status: KycStatus, | ||||
|     // Cryptographic Keys | ||||
|     pub public_key: Option<String>, | ||||
| } | ||||
|  | ||||
| #[derive(Clone, PartialEq, Serialize, Deserialize, Debug)] | ||||
| pub enum ResidentStatus { | ||||
|     Pending, | ||||
|     Active, | ||||
|     Suspended, | ||||
|     Expired, | ||||
| } | ||||
|  | ||||
| #[derive(Clone, PartialEq, Serialize, Deserialize, Debug)] | ||||
| pub enum KycStatus { | ||||
|     NotStarted, | ||||
|     DocumentsUploaded, | ||||
|     UnderReview, | ||||
|     Approved, | ||||
|     Rejected, | ||||
|     RequiresAdditionalInfo, | ||||
| } | ||||
|  | ||||
| impl KycStatus { | ||||
|     pub fn to_string(&self) -> String { | ||||
|         match self { | ||||
|             KycStatus::NotStarted => "Not Started".to_string(), | ||||
|             KycStatus::DocumentsUploaded => "Documents Uploaded".to_string(), | ||||
|             KycStatus::UnderReview => "Under Review".to_string(), | ||||
|             KycStatus::Approved => "Approved".to_string(), | ||||
|             KycStatus::Rejected => "Rejected".to_string(), | ||||
|             KycStatus::RequiresAdditionalInfo => "Requires Additional Info".to_string(), | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn get_badge_class(&self) -> String { | ||||
|         match self { | ||||
|             KycStatus::NotStarted => "badge bg-secondary".to_string(), | ||||
|             KycStatus::DocumentsUploaded => "badge bg-info".to_string(), | ||||
|             KycStatus::UnderReview => "badge bg-warning text-dark".to_string(), | ||||
|             KycStatus::Approved => "badge bg-success".to_string(), | ||||
|             KycStatus::Rejected => "badge bg-danger".to_string(), | ||||
|             KycStatus::RequiresAdditionalInfo => "badge bg-warning text-dark".to_string(), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl ResidentStatus { | ||||
|     pub fn to_string(&self) -> String { | ||||
|         match self { | ||||
|             ResidentStatus::Pending => "Pending".to_string(), | ||||
|             ResidentStatus::Active => "Active".to_string(), | ||||
|             ResidentStatus::Suspended => "Suspended".to_string(), | ||||
|             ResidentStatus::Expired => "Expired".to_string(), | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn get_badge_class(&self) -> String { | ||||
|         match self { | ||||
|             ResidentStatus::Pending => "badge bg-warning text-dark".to_string(), | ||||
|             ResidentStatus::Active => "badge bg-success".to_string(), | ||||
|             ResidentStatus::Suspended => "badge bg-danger".to_string(), | ||||
|             ResidentStatus::Expired => "badge bg-secondary".to_string(), | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										3
									
								
								portal/src/models/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								portal/src/models/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| pub mod company; | ||||
|  | ||||
| pub use company::*; | ||||
							
								
								
									
										3
									
								
								portal/src/services/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								portal/src/services/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| pub mod resident_service; | ||||
|  | ||||
| pub use resident_service::*; | ||||
							
								
								
									
										257
									
								
								portal/src/services/resident_service.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										257
									
								
								portal/src/services/resident_service.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,257 @@ | ||||
| use crate::models::company::{DigitalResident, DigitalResidentFormData, KycStatus}; | ||||
| use gloo::storage::{LocalStorage, Storage}; | ||||
|  | ||||
| const RESIDENTS_STORAGE_KEY: &str = "freezone_residents"; | ||||
| const RESIDENT_REGISTRATIONS_STORAGE_KEY: &str = "freezone_resident_registrations"; | ||||
| const RESIDENT_FORM_KEY: &str = "freezone_resident_registration_form"; | ||||
| const FORM_EXPIRY_HOURS: i64 = 24; | ||||
|  | ||||
| #[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)] | ||||
| pub struct ResidentRegistration { | ||||
|     pub id: u32, | ||||
|     pub full_name: String, | ||||
|     pub email: String, | ||||
|     pub status: ResidentRegistrationStatus, | ||||
|     pub created_at: String, | ||||
|     pub form_data: DigitalResidentFormData, | ||||
|     pub current_step: u8, | ||||
| } | ||||
|  | ||||
| #[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)] | ||||
| pub enum ResidentRegistrationStatus { | ||||
|     Draft, | ||||
|     PendingPayment, | ||||
|     PaymentFailed, | ||||
|     PendingApproval, | ||||
|     Approved, | ||||
|     Rejected, | ||||
| } | ||||
|  | ||||
| impl ResidentRegistrationStatus { | ||||
|     pub fn to_string(&self) -> &'static str { | ||||
|         match self { | ||||
|             ResidentRegistrationStatus::Draft => "Draft", | ||||
|             ResidentRegistrationStatus::PendingPayment => "Pending Payment", | ||||
|             ResidentRegistrationStatus::PaymentFailed => "Payment Failed", | ||||
|             ResidentRegistrationStatus::PendingApproval => "Pending Approval", | ||||
|             ResidentRegistrationStatus::Approved => "Approved", | ||||
|             ResidentRegistrationStatus::Rejected => "Rejected", | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     pub fn get_badge_class(&self) -> &'static str { | ||||
|         match self { | ||||
|             ResidentRegistrationStatus::Draft => "bg-secondary", | ||||
|             ResidentRegistrationStatus::PendingPayment => "bg-warning", | ||||
|             ResidentRegistrationStatus::PaymentFailed => "bg-danger", | ||||
|             ResidentRegistrationStatus::PendingApproval => "bg-info", | ||||
|             ResidentRegistrationStatus::Approved => "bg-success", | ||||
|             ResidentRegistrationStatus::Rejected => "bg-danger", | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| pub struct ResidentService; | ||||
|  | ||||
| impl ResidentService { | ||||
|     /// Get all residents from local storage | ||||
|     pub fn get_residents() -> Vec<DigitalResident> { | ||||
|         match LocalStorage::get::<Vec<DigitalResident>>(RESIDENTS_STORAGE_KEY) { | ||||
|             Ok(residents) => residents, | ||||
|             Err(_) => { | ||||
|                 // Initialize with empty list if not found | ||||
|                 let residents = Vec::new(); | ||||
|                 let _ = LocalStorage::set(RESIDENTS_STORAGE_KEY, &residents); | ||||
|                 residents | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Save residents to local storage | ||||
|     pub fn save_residents(residents: &[DigitalResident]) -> Result<(), String> { | ||||
|         LocalStorage::set(RESIDENTS_STORAGE_KEY, residents) | ||||
|             .map_err(|e| format!("Failed to save residents: {:?}", e)) | ||||
|     } | ||||
|  | ||||
|     /// Add a new resident | ||||
|     pub fn add_resident(mut resident: DigitalResident) -> Result<DigitalResident, String> { | ||||
|         let mut residents = Self::get_residents(); | ||||
|          | ||||
|         // Generate new ID | ||||
|         let max_id = residents.iter().map(|r| r.id).max().unwrap_or(0); | ||||
|         resident.id = max_id + 1; | ||||
|          | ||||
|         residents.push(resident.clone()); | ||||
|         Self::save_residents(&residents)?; | ||||
|          | ||||
|         Ok(resident) | ||||
|     } | ||||
|  | ||||
|     /// Get all resident registrations from local storage | ||||
|     pub fn get_resident_registrations() -> Vec<ResidentRegistration> { | ||||
|         match LocalStorage::get::<Vec<ResidentRegistration>>(RESIDENT_REGISTRATIONS_STORAGE_KEY) { | ||||
|             Ok(registrations) => registrations, | ||||
|             Err(_) => { | ||||
|                 // Initialize with empty list if not found | ||||
|                 let registrations = Vec::new(); | ||||
|                 let _ = LocalStorage::set(RESIDENT_REGISTRATIONS_STORAGE_KEY, ®istrations); | ||||
|                 registrations | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Save resident registrations to local storage | ||||
|     pub fn save_resident_registrations(registrations: &[ResidentRegistration]) -> Result<(), String> { | ||||
|         LocalStorage::set(RESIDENT_REGISTRATIONS_STORAGE_KEY, registrations) | ||||
|             .map_err(|e| format!("Failed to save resident registrations: {:?}", e)) | ||||
|     } | ||||
|  | ||||
|     /// Add or update a resident registration | ||||
|     pub fn save_resident_registration(mut registration: ResidentRegistration) -> Result<ResidentRegistration, String> { | ||||
|         let mut registrations = Self::get_resident_registrations(); | ||||
|          | ||||
|         if registration.id == 0 { | ||||
|             // Generate new ID for new registration | ||||
|             let max_id = registrations.iter().map(|r| r.id).max().unwrap_or(0); | ||||
|             registration.id = max_id + 1; | ||||
|             registrations.push(registration.clone()); | ||||
|         } else { | ||||
|             // Update existing registration | ||||
|             if let Some(existing) = registrations.iter_mut().find(|r| r.id == registration.id) { | ||||
|                 *existing = registration.clone(); | ||||
|             } else { | ||||
|                 return Err("Registration not found".to_string()); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         Self::save_resident_registrations(®istrations)?; | ||||
|         Ok(registration) | ||||
|     } | ||||
|  | ||||
|     /// Save registration form data with expiration | ||||
|     pub fn save_resident_registration_form(form_data: &DigitalResidentFormData, current_step: u8) -> Result<(), String> { | ||||
|         let now = js_sys::Date::now() as i64; | ||||
|         let expires_at = now + (FORM_EXPIRY_HOURS * 60 * 60 * 1000); | ||||
|          | ||||
|         let saved_form = SavedResidentRegistrationForm { | ||||
|             form_data: form_data.clone(), | ||||
|             current_step, | ||||
|             saved_at: now, | ||||
|             expires_at, | ||||
|         }; | ||||
|          | ||||
|         LocalStorage::set(RESIDENT_FORM_KEY, &saved_form) | ||||
|             .map_err(|e| format!("Failed to save form: {:?}", e)) | ||||
|     } | ||||
|  | ||||
|     /// Load registration form data if not expired | ||||
|     pub fn load_resident_registration_form() -> Option<(DigitalResidentFormData, u8)> { | ||||
|         match LocalStorage::get::<SavedResidentRegistrationForm>(RESIDENT_FORM_KEY) { | ||||
|             Ok(saved_form) => { | ||||
|                 let now = js_sys::Date::now() as i64; | ||||
|                 if now < saved_form.expires_at { | ||||
|                     Some((saved_form.form_data, saved_form.current_step)) | ||||
|                 } else { | ||||
|                     // Form expired, remove it | ||||
|                     let _ = LocalStorage::delete(RESIDENT_FORM_KEY); | ||||
|                     None | ||||
|                 } | ||||
|             } | ||||
|             Err(_) => None, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Clear saved registration form | ||||
|     pub fn clear_resident_registration_form() -> Result<(), String> { | ||||
|         LocalStorage::delete(RESIDENT_FORM_KEY); | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     /// Create a resident from form data | ||||
|     pub fn create_resident_from_form(form_data: &DigitalResidentFormData) -> Result<DigitalResident, String> { | ||||
|         let now = js_sys::Date::new_0(); | ||||
|         let registration_date = format!( | ||||
|             "{:04}-{:02}-{:02}", | ||||
|             now.get_full_year(), | ||||
|             now.get_month() + 1, | ||||
|             now.get_date() | ||||
|         ); | ||||
|          | ||||
|         let resident = DigitalResident { | ||||
|             id: 0, // Will be set by add_resident | ||||
|             full_name: form_data.full_name.clone(), | ||||
|             email: form_data.email.clone(), | ||||
|             phone: form_data.phone.clone(), | ||||
|             date_of_birth: form_data.date_of_birth.clone(), | ||||
|             nationality: form_data.nationality.clone(), | ||||
|             passport_number: form_data.passport_number.clone(), | ||||
|             passport_expiry: form_data.passport_expiry.clone(), | ||||
|             current_address: form_data.current_address.clone(), | ||||
|             city: form_data.city.clone(), | ||||
|             country: form_data.country.clone(), | ||||
|             postal_code: form_data.postal_code.clone(), | ||||
|             occupation: form_data.occupation.clone(), | ||||
|             employer: form_data.employer.clone(), | ||||
|             annual_income: form_data.annual_income.clone(), | ||||
|             education_level: form_data.education_level.clone(), | ||||
|             selected_services: form_data.requested_services.clone(), | ||||
|             payment_plan: form_data.payment_plan.clone(), | ||||
|             registration_date, | ||||
|             status: crate::models::company::ResidentStatus::Pending, | ||||
|             kyc_documents_uploaded: false, // Will be updated when documents are uploaded | ||||
|             kyc_status: KycStatus::NotStarted, | ||||
|             public_key: form_data.public_key.clone(), | ||||
|         }; | ||||
|          | ||||
|         Self::add_resident(resident) | ||||
|     } | ||||
|  | ||||
|     /// Validate form data for a specific step (simplified 2-step form) | ||||
|     pub fn validate_resident_step(form_data: &DigitalResidentFormData, step: u8) -> crate::models::ValidationResult { | ||||
|         let mut errors = Vec::new(); | ||||
|          | ||||
|         match step { | ||||
|             1 => { | ||||
|                 // Step 1: Personal Information & KYC (simplified - only name, email, and terms required) | ||||
|                 if form_data.full_name.trim().is_empty() { | ||||
|                     errors.push("Full name is required".to_string()); | ||||
|                 } | ||||
|                 if form_data.email.trim().is_empty() { | ||||
|                     errors.push("Email is required".to_string()); | ||||
|                 } else if !Self::is_valid_email(&form_data.email) { | ||||
|                     errors.push("Please enter a valid email address".to_string()); | ||||
|                 } | ||||
|                 if !form_data.legal_agreements.terms { | ||||
|                     errors.push("You must agree to the Terms of Service and Privacy Policy".to_string()); | ||||
|                 } | ||||
|                 // Note: KYC verification is handled separately via button click | ||||
|             } | ||||
|             2 => { | ||||
|                 // Step 2: Payment only (no additional agreements needed) | ||||
|                 // Payment validation will be handled by Stripe | ||||
|             } | ||||
|             _ => { | ||||
|                 errors.push("Invalid step".to_string()); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         if errors.is_empty() { | ||||
|             crate::models::ValidationResult { is_valid: true, errors: Vec::new() } | ||||
|         } else { | ||||
|             crate::models::ValidationResult { is_valid: false, errors } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Simple email validation | ||||
|     fn is_valid_email(email: &str) -> bool { | ||||
|         email.contains('@') && email.contains('.') && email.len() > 5 | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Clone, serde::Serialize, serde::Deserialize)] | ||||
| struct SavedResidentRegistrationForm { | ||||
|     form_data: DigitalResidentFormData, | ||||
|     current_step: u8, | ||||
|     saved_at: i64, | ||||
|     expires_at: i64, | ||||
| } | ||||
							
								
								
									
										599
									
								
								portal/static/css/main.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										599
									
								
								portal/static/css/main.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,599 @@ | ||||
| /* Zanzibar Digital Freezone - Main CSS */ | ||||
| /* Based on the original Actix MVC app styling */ | ||||
|  | ||||
| /* Custom CSS Variables for Very Soft Pastel Colors */ | ||||
| :root { | ||||
|     /* Very Muted Pastel Colors */ | ||||
|     --bs-primary: #d4e6f1; | ||||
|     --bs-primary-rgb: 212, 230, 241; | ||||
|     --bs-secondary: #e8eaed; | ||||
|     --bs-secondary-rgb: 232, 234, 237; | ||||
|     --bs-success: #d5f4e6; | ||||
|     --bs-success-rgb: 213, 244, 230; | ||||
|     --bs-info: #d6f0f7; | ||||
|     --bs-info-rgb: 214, 240, 247; | ||||
|     --bs-warning: #fef9e7; | ||||
|     --bs-warning-rgb: 254, 249, 231; | ||||
|     --bs-danger: #fdeaea; | ||||
|     --bs-danger-rgb: 253, 234, 234; | ||||
|      | ||||
|     /* Light theme colors */ | ||||
|     --bs-light: #f8f9fa; | ||||
|     --bs-dark: #343a40; | ||||
|      | ||||
|     /* Text colors - always black or white */ | ||||
|     --text-primary: #212529; | ||||
|     --text-secondary: #495057; | ||||
|     --text-muted: #6c757d; | ||||
| } | ||||
|  | ||||
| /* Dark theme variables */ | ||||
| [data-bs-theme="dark"] { | ||||
|     /* Very Muted Dark Pastels */ | ||||
|     --bs-primary: #2c3e50; | ||||
|     --bs-primary-rgb: 44, 62, 80; | ||||
|     --bs-secondary: #34495e; | ||||
|     --bs-secondary-rgb: 52, 73, 94; | ||||
|     --bs-success: #27ae60; | ||||
|     --bs-success-rgb: 39, 174, 96; | ||||
|     --bs-info: #3498db; | ||||
|     --bs-info-rgb: 52, 152, 219; | ||||
|     --bs-warning: #f39c12; | ||||
|     --bs-warning-rgb: 243, 156, 18; | ||||
|     --bs-danger: #e74c3c; | ||||
|     --bs-danger-rgb: 231, 76, 60; | ||||
|      | ||||
|     --text-primary: #ffffff; | ||||
|     --text-secondary: #adb5bd; | ||||
|     --text-muted: #6c757d; | ||||
| } | ||||
|  | ||||
| /* Global Styles */ | ||||
| * { | ||||
|     box-sizing: border-box; | ||||
| } | ||||
|  | ||||
| body { | ||||
|     padding-top: 50px; /* Height of the fixed header */ | ||||
|     margin: 0; | ||||
|     font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; | ||||
|     background-color: var(--bs-light); | ||||
|     color: var(--text-primary); | ||||
|     transition: background-color 0.3s ease, color 0.3s ease; | ||||
| } | ||||
|  | ||||
| /* Dark theme body */ | ||||
| [data-bs-theme="dark"] body { | ||||
|     background-color: #1a1d20; | ||||
|     color: var(--text-primary); | ||||
| } | ||||
|  | ||||
| /* Header Styles */ | ||||
| .header { | ||||
|     height: 50px; | ||||
|     position: fixed; | ||||
|     top: 0; | ||||
|     width: 100%; | ||||
|     z-index: 1030; | ||||
|     background-color: #212529 !important; | ||||
|     color: white; | ||||
| } | ||||
|  | ||||
| .header .container-fluid { | ||||
|     height: 100%; | ||||
| } | ||||
|  | ||||
| .header h5 { | ||||
|     margin: 0; | ||||
|     font-size: 1.1rem; | ||||
|     font-weight: 500; | ||||
| } | ||||
|  | ||||
| .header .navbar-toggler { | ||||
|     border: none; | ||||
|     padding: 0.25rem 0.5rem; | ||||
|     background: none; | ||||
| } | ||||
|  | ||||
| .header .navbar-toggler:focus { | ||||
|     box-shadow: none; | ||||
| } | ||||
|  | ||||
| .header .nav-link { | ||||
|     color: white !important; | ||||
|     text-decoration: none; | ||||
|     padding: 0.5rem 1rem; | ||||
| } | ||||
|  | ||||
| .header .nav-link:hover { | ||||
|     color: #adb5bd !important; | ||||
| } | ||||
|  | ||||
| .header .nav-link.active { | ||||
|     color: white !important; | ||||
|     font-weight: 600; | ||||
| } | ||||
|  | ||||
| .header .dropdown-menu { | ||||
|     border: 1px solid #dee2e6; | ||||
|     box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); | ||||
| } | ||||
|  | ||||
| /* Sidebar Styles */ | ||||
| .sidebar { | ||||
|     width: 240px; | ||||
|     position: fixed; | ||||
|     height: calc(100vh - 90px); /* Subtract header and footer height */ | ||||
|     top: 50px; /* Position below header */ | ||||
|     background-color: #f8f9fa; | ||||
|     border-right: 1px solid #dee2e6; | ||||
|     overflow-y: auto; | ||||
|     z-index: 1010; | ||||
|     transition: background-color 0.3s ease; | ||||
| } | ||||
|  | ||||
| /* Dark theme sidebar */ | ||||
| [data-bs-theme="dark"] .sidebar { | ||||
|     background-color: #1a1d20 !important; | ||||
|     border-right: 1px solid #495057; | ||||
| } | ||||
|  | ||||
| .sidebar .nav-link { | ||||
|     color: #495057; | ||||
|     text-decoration: none; | ||||
|     padding: 0.75rem 1rem; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     border-radius: 0; | ||||
|     transition: all 0.2s ease; | ||||
| } | ||||
|  | ||||
| .sidebar .nav-link:hover { | ||||
|     background-color: #e9ecef; | ||||
|     color: #212529; | ||||
| } | ||||
|  | ||||
| .sidebar .nav-link.active { | ||||
|     background-color: #e7f3ff; | ||||
|     color: #212529; | ||||
|     border-left: 4px solid #d4e6f1; | ||||
|     font-weight: 600; | ||||
| } | ||||
|  | ||||
| /* Dark theme sidebar nav links */ | ||||
| [data-bs-theme="dark"] .sidebar .nav-link { | ||||
|     color: #ffffff !important; | ||||
| } | ||||
|  | ||||
| [data-bs-theme="dark"] .sidebar .nav-link:hover { | ||||
|     background-color: #2d3339 !important; | ||||
|     color: #ffffff !important; | ||||
| } | ||||
|  | ||||
| [data-bs-theme="dark"] .sidebar .nav-link.active { | ||||
|     background-color: #34495e !important; | ||||
|     color: #ffffff !important; | ||||
|     border-left: 4px solid #2c3e50 !important; | ||||
| } | ||||
|  | ||||
| /* Dark theme sidebar cards */ | ||||
| [data-bs-theme="dark"] .sidebar .card { | ||||
|     background-color: #2d3339 !important; | ||||
|     border-color: #495057 !important; | ||||
|     color: #ffffff !important; | ||||
| } | ||||
|  | ||||
| [data-bs-theme="dark"] .sidebar .card.bg-white { | ||||
|     background-color: #2d3339 !important; | ||||
|     color: #ffffff !important; | ||||
| } | ||||
|  | ||||
| [data-bs-theme="dark"] .sidebar .card.bg-dark { | ||||
|     background-color: #34495e !important; | ||||
|     color: #ffffff !important; | ||||
| } | ||||
|  | ||||
| /* Dark theme sidebar card icons */ | ||||
| [data-bs-theme="dark"] .sidebar .bg-dark { | ||||
|     background-color: #ffffff !important; | ||||
|     color: #212529 !important; | ||||
| } | ||||
|  | ||||
| [data-bs-theme="dark"] .sidebar .bg-white { | ||||
|     background-color: #2d3339 !important; | ||||
|     color: #ffffff !important; | ||||
| } | ||||
|  | ||||
| /* Dark theme dividers */ | ||||
| [data-bs-theme="dark"] .sidebar hr { | ||||
|     border-color: #495057 !important; | ||||
|     opacity: 0.5; | ||||
| } | ||||
|  | ||||
| /* Dark theme text colors */ | ||||
| [data-bs-theme="dark"] .sidebar .text-muted { | ||||
|     color: #adb5bd !important; | ||||
| } | ||||
|  | ||||
| [data-bs-theme="dark"] .sidebar h6 { | ||||
|     color: #ffffff !important; | ||||
| } | ||||
|  | ||||
| [data-bs-theme="dark"] .sidebar small { | ||||
|     color: #adb5bd !important; | ||||
| } | ||||
|  | ||||
| .sidebar .nav-link i { | ||||
|     margin-right: 0.5rem; | ||||
|     width: 1.2rem; | ||||
|     text-align: center; | ||||
| } | ||||
|  | ||||
| /* Main Content Area */ | ||||
| .main-content { | ||||
|     margin-left: 240px; | ||||
|     min-height: calc(100vh - 90px); | ||||
|     padding: 1rem; | ||||
| } | ||||
|  | ||||
| /* Footer Styles */ | ||||
| .footer { | ||||
|     height: 40px; | ||||
|     line-height: 40px; | ||||
|     background-color: #212529 !important; | ||||
|     color: white; | ||||
|     position: relative; | ||||
|     margin-top: auto; | ||||
| } | ||||
|  | ||||
| .footer a { | ||||
|     color: white; | ||||
|     text-decoration: none; | ||||
| } | ||||
|  | ||||
| .footer a:hover { | ||||
|     color: #adb5bd; | ||||
| } | ||||
|  | ||||
| /* Feature Cards (Home Page) */ | ||||
| .compact-card { | ||||
|     max-height: 150px; | ||||
|     overflow-y: auto; | ||||
| } | ||||
|  | ||||
| .compact-card .card-body { | ||||
|     padding: 0.75rem; | ||||
| } | ||||
|  | ||||
| .compact-card .card-text { | ||||
|     font-size: 0.875rem; | ||||
|     line-height: 1.4; | ||||
|     margin-bottom: 0; | ||||
| } | ||||
|  | ||||
| .card-header { | ||||
|     padding: 0.5rem 0.75rem; | ||||
|     border-bottom: 1px solid rgba(0, 0, 0, 0.125); | ||||
| } | ||||
|  | ||||
| .card-header h6 { | ||||
|     margin: 0; | ||||
|     font-size: 0.875rem; | ||||
|     font-weight: 600; | ||||
| } | ||||
|  | ||||
| /* Toast Notifications */ | ||||
| .toast-container { | ||||
|     position: fixed; | ||||
|     top: 1rem; | ||||
|     right: 1rem; | ||||
|     z-index: 1055; | ||||
| } | ||||
|  | ||||
| .toast { | ||||
|     min-width: 300px; | ||||
|     margin-bottom: 0.5rem; | ||||
|     border: none; | ||||
|     box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); | ||||
| } | ||||
|  | ||||
| .toast-header { | ||||
|     border-bottom: 1px solid rgba(0, 0, 0, 0.125); | ||||
|     padding: 0.5rem 0.75rem; | ||||
| } | ||||
|  | ||||
| .toast-success .toast-header { | ||||
|     background-color: var(--bs-success); | ||||
|     color: var(--text-primary); | ||||
| } | ||||
|  | ||||
| .toast-error .toast-header { | ||||
|     background-color: var(--bs-danger); | ||||
|     color: var(--text-primary); | ||||
| } | ||||
|  | ||||
| .toast-warning .toast-header { | ||||
|     background-color: var(--bs-warning); | ||||
|     color: var(--text-primary); | ||||
| } | ||||
|  | ||||
| .toast-info .toast-header { | ||||
|     background-color: var(--bs-info); | ||||
|     color: var(--text-primary); | ||||
| } | ||||
|  | ||||
| .toast-body { | ||||
|     padding: 0.75rem; | ||||
|     background-color: white; | ||||
| } | ||||
|  | ||||
| /* Login Form Styles */ | ||||
| .login-container { | ||||
|     min-height: calc(100vh - 50px); | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|     background: linear-gradient(135deg, #0099FF 0%, #00CC66 100%); | ||||
| } | ||||
|  | ||||
| .login-card { | ||||
|     width: 100%; | ||||
|     max-width: 400px; | ||||
|     box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); | ||||
| } | ||||
|  | ||||
| .login-card .card-header { | ||||
|     background-color: var(--bs-primary); | ||||
|     color: var(--text-primary); | ||||
|     border-bottom: none; | ||||
| } | ||||
|  | ||||
| .login-card .form-control:focus { | ||||
|     border-color: var(--bs-primary); | ||||
|     box-shadow: 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.25); | ||||
| } | ||||
|  | ||||
| .login-card .btn-primary { | ||||
|     background-color: var(--bs-primary); | ||||
|     border-color: var(--bs-primary); | ||||
|     color: var(--text-primary); | ||||
| } | ||||
|  | ||||
| .login-card .btn-primary:hover { | ||||
|     background-color: rgba(var(--bs-primary-rgb), 0.8); | ||||
|     border-color: rgba(var(--bs-primary-rgb), 0.8); | ||||
|     color: var(--text-primary); | ||||
| } | ||||
|  | ||||
| /* Responsive Design */ | ||||
| @media (min-width: 768px) { | ||||
|     .sidebar { | ||||
|         width: 240px; | ||||
|         position: fixed; | ||||
|         height: calc(100vh - 90px); | ||||
|         top: 50px; | ||||
|     } | ||||
|     .main-content { | ||||
|         margin-left: 240px; | ||||
|         min-height: calc(100vh - 90px); | ||||
|     } | ||||
| } | ||||
|  | ||||
| @media (max-width: 767.98px) { | ||||
|     .sidebar { | ||||
|         width: 240px; | ||||
|         position: fixed; | ||||
|         height: calc(100vh - 90px); | ||||
|         top: 50px; | ||||
|         left: -240px; | ||||
|         transition: left 0.3s ease; | ||||
|         z-index: 1020; | ||||
|         box-shadow: 0.5rem 0 1rem rgba(0, 0, 0, 0.15); | ||||
|     } | ||||
|      | ||||
|     .sidebar.show { | ||||
|         left: 0; | ||||
|     } | ||||
|      | ||||
|     .main-content { | ||||
|         margin-left: 0; | ||||
|     } | ||||
|      | ||||
|     .header .d-md-flex { | ||||
|         display: none !important; | ||||
|     } | ||||
| } | ||||
|  | ||||
| /* Utility Classes */ | ||||
| .text-truncate { | ||||
|     overflow: hidden; | ||||
|     text-overflow: ellipsis; | ||||
|     white-space: nowrap; | ||||
| } | ||||
|  | ||||
| .shadow-sm { | ||||
|     box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075) !important; | ||||
| } | ||||
|  | ||||
| .border-start { | ||||
|     border-left: 1px solid #dee2e6 !important; | ||||
| } | ||||
|  | ||||
| .border-4 { | ||||
|     border-width: 4px !important; | ||||
| } | ||||
|  | ||||
| /* Loading States */ | ||||
| .loading { | ||||
|     opacity: 0.6; | ||||
|     pointer-events: none; | ||||
| } | ||||
|  | ||||
| .spinner-border-sm { | ||||
|     width: 1rem; | ||||
|     height: 1rem; | ||||
| } | ||||
|  | ||||
| /* Focus States for Accessibility */ | ||||
| .nav-link:focus, | ||||
| .btn:focus, | ||||
| .form-control:focus { | ||||
|     outline: 2px solid var(--bs-primary); | ||||
|     outline-offset: 2px; | ||||
| } | ||||
|  | ||||
| /* Button and component overrides for very muted pastel colors */ | ||||
| .btn-primary { | ||||
|     background-color: var(--bs-primary); | ||||
|     border-color: var(--bs-primary); | ||||
|     color: #212529; | ||||
| } | ||||
|  | ||||
| .btn-primary:hover { | ||||
|     background-color: #c3d9ed; | ||||
|     border-color: #c3d9ed; | ||||
|     color: #212529; | ||||
| } | ||||
|  | ||||
| .btn-outline-primary { | ||||
|     color: #212529; | ||||
|     border-color: var(--bs-primary); | ||||
| } | ||||
|  | ||||
| .btn-outline-primary:hover { | ||||
|     background-color: var(--bs-primary); | ||||
|     border-color: var(--bs-primary); | ||||
|     color: #212529; | ||||
| } | ||||
|  | ||||
| .btn-success { | ||||
|     background-color: var(--bs-success); | ||||
|     border-color: var(--bs-success); | ||||
|     color: #212529; | ||||
| } | ||||
|  | ||||
| .btn-success:hover { | ||||
|     background-color: #c8f0dd; | ||||
|     border-color: #c8f0dd; | ||||
|     color: #212529; | ||||
| } | ||||
|  | ||||
| /* Dark theme button overrides */ | ||||
| [data-bs-theme="dark"] .btn-primary { | ||||
|     background-color: var(--bs-primary); | ||||
|     border-color: var(--bs-primary); | ||||
|     color: #ffffff; | ||||
| } | ||||
|  | ||||
| [data-bs-theme="dark"] .btn-primary:hover { | ||||
|     background-color: #34495e; | ||||
|     border-color: #34495e; | ||||
|     color: #ffffff; | ||||
| } | ||||
|  | ||||
| [data-bs-theme="dark"] .btn-outline-primary { | ||||
|     color: #ffffff; | ||||
|     border-color: var(--bs-primary); | ||||
| } | ||||
|  | ||||
| [data-bs-theme="dark"] .btn-outline-primary:hover { | ||||
|     background-color: var(--bs-primary); | ||||
|     border-color: var(--bs-primary); | ||||
|     color: #ffffff; | ||||
| } | ||||
|  | ||||
| [data-bs-theme="dark"] .btn-success { | ||||
|     background-color: var(--bs-success); | ||||
|     border-color: var(--bs-success); | ||||
|     color: #ffffff; | ||||
| } | ||||
|  | ||||
| [data-bs-theme="dark"] .btn-success:hover { | ||||
|     background-color: #2ecc71; | ||||
|     border-color: #2ecc71; | ||||
|     color: #ffffff; | ||||
| } | ||||
|  | ||||
| /* Card styling improvements */ | ||||
| .card { | ||||
|     border: 1px solid #e9ecef; | ||||
|     background-color: #ffffff; | ||||
|     color: #212529; | ||||
| } | ||||
|  | ||||
| [data-bs-theme="dark"] .card { | ||||
|     background-color: #2d3339 !important; | ||||
|     border-color: #495057 !important; | ||||
|     color: #ffffff !important; | ||||
| } | ||||
|  | ||||
| /* Text color overrides - always black or white */ | ||||
| .text-primary { | ||||
|     color: #212529 !important; | ||||
| } | ||||
|  | ||||
| .text-secondary { | ||||
|     color: #495057 !important; | ||||
| } | ||||
|  | ||||
| .text-muted { | ||||
|     color: #6c757d !important; | ||||
| } | ||||
|  | ||||
| /* Dark theme text overrides */ | ||||
| [data-bs-theme="dark"] .text-primary { | ||||
|     color: #ffffff !important; | ||||
| } | ||||
|  | ||||
| [data-bs-theme="dark"] .text-secondary { | ||||
|     color: #adb5bd !important; | ||||
| } | ||||
|  | ||||
| [data-bs-theme="dark"] .text-muted { | ||||
|     color: #6c757d !important; | ||||
| } | ||||
|  | ||||
| /* Border color overrides */ | ||||
| .border-primary { | ||||
|     border-color: var(--bs-primary) !important; | ||||
| } | ||||
|  | ||||
| .border-success { | ||||
|     border-color: var(--bs-success) !important; | ||||
| } | ||||
|  | ||||
| /* Background color overrides */ | ||||
| .bg-primary { | ||||
|     background-color: var(--bs-primary) !important; | ||||
|     color: #212529 !important; | ||||
| } | ||||
|  | ||||
| .bg-success { | ||||
|     background-color: var(--bs-success) !important; | ||||
|     color: #212529 !important; | ||||
| } | ||||
|  | ||||
| /* Dark theme background overrides */ | ||||
| [data-bs-theme="dark"] .bg-primary { | ||||
|     background-color: var(--bs-primary) !important; | ||||
|     color: #ffffff !important; | ||||
| } | ||||
|  | ||||
| [data-bs-theme="dark"] .bg-success { | ||||
|     background-color: var(--bs-success) !important; | ||||
|     color: #ffffff !important; | ||||
| } | ||||
|  | ||||
| /* Print Styles */ | ||||
| @media print { | ||||
|     .header, | ||||
|     .sidebar, | ||||
|     .footer { | ||||
|         display: none !important; | ||||
|     } | ||||
|      | ||||
|     .main-content { | ||||
|         margin-left: 0 !important; | ||||
|         padding: 0 !important; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										399
									
								
								portal/static/js/stripe-integration.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										399
									
								
								portal/static/js/stripe-integration.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,399 @@ | ||||
| // Stripe Integration for Company Registration | ||||
| // This file handles the Stripe Elements integration for the Yew WASM application | ||||
|  | ||||
| let stripe; | ||||
| let elements; | ||||
| let paymentElement; | ||||
|  | ||||
| // Stripe publishable key - this should be set from the server or environment | ||||
| const STRIPE_PUBLISHABLE_KEY = 'pk_test_51234567890abcdef'; // Replace with actual key | ||||
|  | ||||
| // Initialize Stripe when the script loads | ||||
| document.addEventListener('DOMContentLoaded', function() { | ||||
|     console.log('🔧 Stripe integration script loaded'); | ||||
|      | ||||
|     // Initialize Stripe | ||||
|     if (window.Stripe) { | ||||
|         stripe = Stripe(STRIPE_PUBLISHABLE_KEY); | ||||
|         console.log('✅ Stripe initialized'); | ||||
|     } else { | ||||
|         console.error('❌ Stripe.js not loaded'); | ||||
|     } | ||||
| }); | ||||
|  | ||||
| // Initialize Stripe Elements with client secret | ||||
| window.initializeStripeElements = async function(clientSecret) { | ||||
|     console.log('🔧 Initializing Stripe Elements with client secret:', clientSecret); | ||||
|      | ||||
|     try { | ||||
|         if (!stripe) { | ||||
|             throw new Error('Stripe not initialized'); | ||||
|         } | ||||
|  | ||||
|         // Create Elements instance with client secret | ||||
|         elements = stripe.elements({ | ||||
|             clientSecret: clientSecret, | ||||
|             appearance: { | ||||
|                 theme: 'stripe', | ||||
|                 variables: { | ||||
|                     colorPrimary: '#198754', | ||||
|                     colorBackground: '#ffffff', | ||||
|                     colorText: '#30313d', | ||||
|                     colorDanger: '#df1b41', | ||||
|                     fontFamily: 'system-ui, sans-serif', | ||||
|                     spacingUnit: '4px', | ||||
|                     borderRadius: '6px', | ||||
|                 } | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         // Clear the payment element container first | ||||
|         const paymentElementDiv = document.getElementById('payment-element'); | ||||
|         if (!paymentElementDiv) { | ||||
|             throw new Error('Payment element container not found'); | ||||
|         } | ||||
|          | ||||
|         paymentElementDiv.innerHTML = ''; | ||||
|  | ||||
|         // Create and mount the Payment Element | ||||
|         paymentElement = elements.create('payment'); | ||||
|         paymentElement.mount('#payment-element'); | ||||
|  | ||||
|         // Handle real-time validation errors from the Payment Element | ||||
|         paymentElement.on('change', (event) => { | ||||
|             const displayError = document.getElementById('payment-errors'); | ||||
|             if (event.error) { | ||||
|                 displayError.textContent = event.error.message; | ||||
|                 displayError.style.display = 'block'; | ||||
|                 displayError.classList.remove('alert-success'); | ||||
|                 displayError.classList.add('alert-danger'); | ||||
|             } else { | ||||
|                 displayError.style.display = 'none'; | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         // Handle when the Payment Element is ready | ||||
|         paymentElement.on('ready', () => { | ||||
|             console.log('✅ Stripe Elements ready for payment'); | ||||
|  | ||||
|             // Add a subtle success indicator | ||||
|             const paymentCard = paymentElementDiv.closest('.card'); | ||||
|             if (paymentCard) { | ||||
|                 paymentCard.style.borderColor = '#198754'; | ||||
|                 paymentCard.style.borderWidth = '2px'; | ||||
|             } | ||||
|  | ||||
|             // Update button text to show payment is ready | ||||
|             const submitButton = document.getElementById('submit-payment'); | ||||
|             const submitText = document.getElementById('submit-text'); | ||||
|             if (submitButton && submitText) { | ||||
|                 submitButton.disabled = false; | ||||
|                 submitText.textContent = 'Complete Payment'; | ||||
|                 submitButton.classList.remove('btn-secondary'); | ||||
|                 submitButton.classList.add('btn-success'); | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         // Handle loading state | ||||
|         paymentElement.on('loaderstart', () => { | ||||
|             console.log('🔄 Stripe Elements loading...'); | ||||
|         }); | ||||
|  | ||||
|         paymentElement.on('loaderror', (event) => { | ||||
|             console.error('❌ Stripe Elements load error:', event.error); | ||||
|             showAdBlockerGuidance(event.error.message || 'Failed to load payment form'); | ||||
|         }); | ||||
|  | ||||
|         console.log('✅ Stripe Elements initialized successfully'); | ||||
|         return true; | ||||
|  | ||||
|     } catch (error) { | ||||
|         console.error('❌ Error initializing Stripe Elements:', error); | ||||
|          | ||||
|         // Check if this might be an ad blocker issue | ||||
|         const isAdBlockerError = error.message && ( | ||||
|             error.message.includes('blocked') || | ||||
|             error.message.includes('Failed to fetch') || | ||||
|             error.message.includes('ERR_BLOCKED_BY_CLIENT') || | ||||
|             error.message.includes('network') || | ||||
|             error.message.includes('CORS') | ||||
|         ); | ||||
|  | ||||
|         if (isAdBlockerError) { | ||||
|             showAdBlockerGuidance(error.message || 'Failed to load payment form'); | ||||
|         } else { | ||||
|             // Show generic error for non-ad-blocker issues | ||||
|             const errorElement = document.getElementById('payment-errors'); | ||||
|             if (errorElement) { | ||||
|                 errorElement.innerHTML = ` | ||||
|                     <div class="alert alert-danger alert-dismissible" role="alert"> | ||||
|                         <i class="bi bi-exclamation-triangle me-2"></i> | ||||
|                         <strong>Payment Form Error:</strong> ${error.message || 'Failed to load payment form'}<br><br> | ||||
|                         Please refresh the page and try again. If the problem persists, contact support. | ||||
|                         <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> | ||||
|                     </div> | ||||
|                 `; | ||||
|                 errorElement.style.display = 'block'; | ||||
|             } | ||||
|         } | ||||
|         throw error; | ||||
|     } | ||||
| }; | ||||
|  | ||||
| // Create payment intent on server | ||||
| window.createPaymentIntent = async function(formDataJson) { | ||||
|     console.log('💳 Creating payment intent...'); | ||||
|      | ||||
|     try { | ||||
|         // Parse the JSON string from Rust | ||||
|         let formData; | ||||
|         if (typeof formDataJson === 'string') { | ||||
|             formData = JSON.parse(formDataJson); | ||||
|         } else { | ||||
|             formData = formDataJson; | ||||
|         } | ||||
|          | ||||
|         console.log('Form data:', formData); | ||||
|          | ||||
|         const response = await fetch('/company/create-payment-intent', { | ||||
|             method: 'POST', | ||||
|             headers: { | ||||
|                 'Content-Type': 'application/json', | ||||
|             }, | ||||
|             body: JSON.stringify(formData) | ||||
|         }); | ||||
|  | ||||
|         if (!response.ok) { | ||||
|             const errorText = await response.text(); | ||||
|             console.error('Payment intent creation failed:', errorText); | ||||
|             let errorData; | ||||
|             try { | ||||
|                 errorData = JSON.parse(errorText); | ||||
|             } catch (e) { | ||||
|                 errorData = { error: errorText }; | ||||
|             } | ||||
|             throw new Error(errorData.error || 'Failed to create payment intent'); | ||||
|         } | ||||
|  | ||||
|         const responseData = await response.json(); | ||||
|         console.log('✅ Payment intent created:', responseData); | ||||
|          | ||||
|         const { client_secret } = responseData; | ||||
|         if (!client_secret) { | ||||
|             throw new Error('No client secret received from server'); | ||||
|         } | ||||
|  | ||||
|         return client_secret; | ||||
|     } catch (error) { | ||||
|         console.error('❌ Payment intent creation error:', error); | ||||
|         throw error; | ||||
|     } | ||||
| }; | ||||
|  | ||||
| // Confirm payment with Stripe | ||||
| window.confirmStripePayment = async function(clientSecret) { | ||||
|     console.log('🔄 Confirming payment...'); | ||||
|      | ||||
|     try { | ||||
|         // Ensure elements are ready before submitting | ||||
|         if (!elements) { | ||||
|             throw new Error('Payment form not ready. Please wait a moment and try again.'); | ||||
|         } | ||||
|  | ||||
|         console.log('🔄 Step 1: Submitting payment elements...'); | ||||
|  | ||||
|         // Step 1: Submit the payment elements first (required by new Stripe API) | ||||
|         const { error: submitError } = await elements.submit(); | ||||
|  | ||||
|         if (submitError) { | ||||
|             console.error('Elements submit failed:', submitError); | ||||
|             // Provide more specific error messages | ||||
|             if (submitError.type === 'validation_error') { | ||||
|                 throw new Error('Please check your payment details and try again.'); | ||||
|             } else if (submitError.type === 'card_error') { | ||||
|                 throw new Error(submitError.message || 'Card error. Please check your card details.'); | ||||
|             } else { | ||||
|                 throw new Error(submitError.message || 'Payment form validation failed.'); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         console.log('✅ Step 1 complete: Elements submitted successfully'); | ||||
|         console.log('🔄 Step 2: Confirming payment...'); | ||||
|  | ||||
|         // Step 2: Confirm payment with Stripe | ||||
|         const { error, paymentIntent } = await stripe.confirmPayment({ | ||||
|             elements, | ||||
|             clientSecret: clientSecret, | ||||
|             confirmParams: { | ||||
|                 return_url: `${window.location.origin}/company/payment-success`, | ||||
|             }, | ||||
|             redirect: 'if_required' // Handle success without redirect if possible | ||||
|         }); | ||||
|  | ||||
|         if (error) { | ||||
|             // Payment failed - redirect to failure page | ||||
|             console.error('Payment confirmation failed:', error); | ||||
|             window.location.href = `${window.location.origin}/company/payment-failure`; | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (paymentIntent && paymentIntent.status === 'succeeded') { | ||||
|             // Payment succeeded | ||||
|             console.log('✅ Payment completed successfully:', paymentIntent.id); | ||||
|  | ||||
|             // Clear saved form data since registration is complete | ||||
|             localStorage.removeItem('freezone_company_registration'); | ||||
|  | ||||
|             // Redirect to success page with payment details | ||||
|             window.location.href = `${window.location.origin}/company/payment-success?payment_intent=${paymentIntent.id}&payment_intent_client_secret=${clientSecret}`; | ||||
|             return true; | ||||
|         } else if (paymentIntent && paymentIntent.status === 'requires_action') { | ||||
|             // Payment requires additional authentication (3D Secure, etc.) | ||||
|             console.log('🔐 Payment requires additional authentication'); | ||||
|             // Stripe will handle the authentication flow automatically | ||||
|             return false; // Don't redirect yet | ||||
|         } else { | ||||
|             // Unexpected status - redirect to failure page | ||||
|             console.error('Unexpected payment status:', paymentIntent?.status); | ||||
|             window.location.href = `${window.location.origin}/company/payment-failure`; | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|     } catch (error) { | ||||
|         console.error('❌ Payment confirmation error:', error); | ||||
|         throw error; | ||||
|     } | ||||
| }; | ||||
|  | ||||
| // Show comprehensive ad blocker guidance | ||||
| function showAdBlockerGuidance(errorMessage) { | ||||
|     const errorElement = document.getElementById('payment-errors'); | ||||
|     if (!errorElement) return; | ||||
|  | ||||
|     // Detect browser type for specific instructions | ||||
|     const isChrome = /Chrome/.test(navigator.userAgent) && /Google Inc/.test(navigator.vendor); | ||||
|     const isFirefox = /Firefox/.test(navigator.userAgent); | ||||
|     const isSafari = /Safari/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent); | ||||
|     const isEdge = /Edg/.test(navigator.userAgent); | ||||
|  | ||||
|     let browserSpecificInstructions = ''; | ||||
|     if (isChrome) { | ||||
|         browserSpecificInstructions = ` | ||||
|             <strong>Chrome Instructions:</strong><br> | ||||
|             1. Click the shield icon 🛡️ in the address bar<br> | ||||
|             2. Select "Allow" for this site<br> | ||||
|             3. Or go to Settings → Privacy → Ad blockers<br> | ||||
|         `; | ||||
|     } else if (isFirefox) { | ||||
|         browserSpecificInstructions = ` | ||||
|             <strong>Firefox Instructions:</strong><br> | ||||
|             1. Click the shield icon 🛡️ in the address bar<br> | ||||
|             2. Turn off "Enhanced Tracking Protection" for this site<br> | ||||
|             3. Or disable uBlock Origin/AdBlock Plus temporarily<br> | ||||
|         `; | ||||
|     } else if (isSafari) { | ||||
|         browserSpecificInstructions = ` | ||||
|             <strong>Safari Instructions:</strong><br> | ||||
|             1. Go to Safari → Preferences → Extensions<br> | ||||
|             2. Temporarily disable ad blocking extensions<br> | ||||
|             3. Or add this site to your allowlist<br> | ||||
|         `; | ||||
|     } else if (isEdge) { | ||||
|         browserSpecificInstructions = ` | ||||
|             <strong>Edge Instructions:</strong><br> | ||||
|             1. Click the shield icon 🛡️ in the address bar<br> | ||||
|             2. Turn off tracking prevention for this site<br> | ||||
|             3. Or disable ad blocking extensions<br> | ||||
|         `; | ||||
|     } | ||||
|  | ||||
|     errorElement.innerHTML = ` | ||||
|         <div class="alert alert-warning alert-dismissible" role="alert"> | ||||
|             <div class="d-flex align-items-start"> | ||||
|                 <i class="bi bi-shield-exclamation fs-1 text-warning me-3 mt-1"></i> | ||||
|                 <div class="flex-grow-1"> | ||||
|                     <h5 class="alert-heading mb-3">🛡️ Ad Blocker Detected</h5> | ||||
|                     <p class="mb-3"><strong>Error:</strong> ${errorMessage}</p> | ||||
|  | ||||
|                     <p class="mb-3">Your ad blocker or privacy extension is preventing the secure payment form from loading. This is normal security behavior, but we need to process your payment securely through Stripe.</p> | ||||
|  | ||||
|                     <div class="row"> | ||||
|                         <div class="col-md-6"> | ||||
|                             <h6>🔧 Quick Fix:</h6> | ||||
|                             ${browserSpecificInstructions} | ||||
|                         </div> | ||||
|                         <div class="col-md-6"> | ||||
|                             <h6>🔒 Why This Happens:</h6> | ||||
|                             • Ad blockers block payment tracking<br> | ||||
|                             • Privacy extensions block third-party scripts<br> | ||||
|                             • This protects your privacy normally<br> | ||||
|                             • Stripe needs access for secure payments<br> | ||||
|                         </div> | ||||
|                     </div> | ||||
|  | ||||
|                     <div class="mt-3 p-3 rounded"> | ||||
|                         <h6>✅ Alternative Solutions:</h6> | ||||
|                         <div class="row"> | ||||
|                             <div class="col-md-4"> | ||||
|                                 <strong>1. Incognito/Private Mode</strong><br> | ||||
|                                 <small class="text-muted">Usually has fewer extensions</small> | ||||
|                             </div> | ||||
|                             <div class="col-md-4"> | ||||
|                                 <strong>2. Different Browser</strong><br> | ||||
|                                 <small class="text-muted">Try Chrome, Firefox, or Safari</small> | ||||
|                             </div> | ||||
|                             <div class="col-md-4"> | ||||
|                                 <strong>3. Mobile Device</strong><br> | ||||
|                                 <small class="text-muted">Often has fewer blockers</small> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                     </div> | ||||
|  | ||||
|                     <div class="mt-3 text-center"> | ||||
|                         <button type="button" class="btn btn-primary me-2" onclick="location.reload()"> | ||||
|                             <i class="bi bi-arrow-clockwise me-1"></i>Refresh & Try Again | ||||
|                         </button> | ||||
|                         <button type="button" class="btn btn-outline-secondary" onclick="showContactInfo()"> | ||||
|                             <i class="bi bi-headset me-1"></i>Contact Support | ||||
|                         </button> | ||||
|                     </div> | ||||
|  | ||||
|                     <div class="mt-2 text-center"> | ||||
|                         <small class="text-muted"> | ||||
|                             <i class="bi bi-shield-check me-1"></i> | ||||
|                             We use Stripe for secure payment processing. Your payment information is encrypted and safe. | ||||
|                         </small> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|             <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> | ||||
|         </div> | ||||
|     `; | ||||
|  | ||||
|     errorElement.style.display = 'block'; | ||||
|  | ||||
|     // Scroll to error | ||||
|     errorElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); | ||||
|  | ||||
|     // Add visual indication | ||||
|     const paymentCard = document.querySelector('#payment-information-section'); | ||||
|     if (paymentCard) { | ||||
|         paymentCard.style.borderColor = '#ffc107'; | ||||
|         paymentCard.style.borderWidth = '2px'; | ||||
|         paymentCard.classList.add('border-warning'); | ||||
|     } | ||||
| } | ||||
|  | ||||
| // Show contact information | ||||
| function showContactInfo() { | ||||
|     alert('Contact Support:\n\nEmail: support@hostbasket.com\nPhone: +1 (555) 123-4567\nLive Chat: Available 24/7\n\nPlease mention "Payment Form Loading Issue" when contacting us.'); | ||||
| } | ||||
|  | ||||
| // Export functions for use by Rust/WASM | ||||
| window.stripeIntegration = { | ||||
|     initializeElements: window.initializeStripeElements, | ||||
|     createPaymentIntent: window.createPaymentIntent, | ||||
|     confirmPayment: window.confirmStripePayment | ||||
| }; | ||||
|  | ||||
| console.log('✅ Stripe integration script ready'); | ||||
		Reference in New Issue
	
	Block a user