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