diff --git a/docs/apm-state-manager.md b/docs/apm-state-manager.md new file mode 100644 index 00000000..f5d634f6 --- /dev/null +++ b/docs/apm-state-manager.md @@ -0,0 +1,393 @@ +# APM StateManager System + +## Overview + +The StateManager is a global state management system designed specifically for APM (Alternative Payment Methods) components. It provides a centralized way to manage stateful components that need to trigger re-renders when their state changes. + +## Problem it Solves + +Before StateManager, stateful components in the APM system had several issues: + +1. **Global State Pollution**: Components used module-level state variables that were shared between instances +2. **Manual DOM Manipulation**: State changes required manual DOM updates +3. **No Re-render Triggering**: Components couldn't trigger parent view re-renders +4. **Memory Leaks**: State persisted incorrectly across component lifecycles + +## Key Features + +- **✅ Stable Component IDs**: Uses first-render call order (React hooks pattern) +- **✅ Automatic Re-renders**: State changes trigger parent view updates +- **✅ Batched Updates**: Multiple setState calls in one frame = single re-render +- **✅ Lifecycle Management**: Automatic cleanup when views unmount +- **✅ Memory Efficient**: Prevents memory leaks with proper cleanup +- **✅ Type Safe**: Full TypeScript support with generic state types +- **✅ Position Independent**: Components maintain state when order changes + +## Architecture + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Component A │ │ Component B │ │ Component C │ +│ (phone-123) │ │ (otp-456) │ │ (qr-789) │ +└─────────┬───────┘ └─────────┬───────┘ └─────────┬───────┘ + │ │ │ + └──────────────────────┼──────────────────────┘ + │ + ┌────────────▼─────────────┐ + │ StateManager │ + │ (Singleton Instance) │ + └────────────┬─────────────┘ + │ + ┌────────────▼─────────────┐ + │ APMViewImpl │ + │ (Parent View) │ + │ forceUpdate() │ + └──────────────────────────┘ +``` + +## Basic Usage + + + +### 1. Use state in your component + +```typescript +export const Phone = ({ name, ...props }: PhoneProps) => { + // Simple state management - just pass initial state! + const { state, setState } = useComponentState({ + dialing_code: '', + value: '', + iso: '', + }); + + // Update state triggers automatic re-renders + const handleInputChange = (newValue: string) => { + setState(prevState => ({ + ...prevState, + value: newValue + })); + }; + + return input({ + value: state.value, + oninput: handleInputChange + }); +}; +``` + +### 2. Use Component in View + +```typescript +export class MyView extends APMViewImpl { + render() { + return div({ className: 'view' }, + Phone({ + name: 'user-phone', + dialing_codes: [...] + }) + ); + } +} +``` + +## API Reference + +### Core Functions + +#### `useComponentState(initialState: T)` + +Hook for components to use stateful behavior. Automatically generates unique component IDs and detects the current view context. + +**Parameters:** +- `initialState`: Initial state object (T) + +**Returns:** +```typescript +{ + state: T; + setState: (newState: T | ((prevState: T) => T)) => void; + watch: { + (callback: (state: T) => void): () => void; + (field: K, callback: (newValue: T[K]) => void): () => void; + }; +} +``` + +#### `generateComponentId(prefix?)` + +Generates a unique component ID. + +**Parameters:** +- `prefix`: Optional prefix for the ID (default: 'comp') + +**Returns:** Unique string ID + +#### `getStateManager()` + +Gets the singleton StateManager instance. + +**Returns:** StateManager instance + +#### `cleanupComponentState(id)` + +Manually clean up component state. + +**Parameters:** +- `id`: Component ID to clean up + +### StateManager Class + +#### `registerComponent(id, initialState, view?)` + +Register a component with the state manager. + +#### `getComponentState(id)` + +Get component state by ID. + +#### `updateComponentState(id, newState, forceUpdate?)` + +Update component state and trigger re-renders. + +#### `destroyComponent(id)` + +Remove component from state manager. + +#### `destroyViewComponents(view)` + +Clean up all components associated with a view. + +## Migration Guide + +### From Global State to StateManager + +**Before:** +```typescript +// Global state (problematic) +let phoneState = { + dialing_code: '', + value: '', + iso: '' +}; + +export const Phone = (props) => { + const handleChange = (newValue) => { + phoneState.value = newValue; + // Manual DOM manipulation required + updatePhoneDisplay(); + }; + + return input({ oninput: handleChange }); +}; +``` + +**After:** +```typescript +// StateManager (proper state management) +export const Phone = (props) => { + const { state, setState } = useComponentState({ + dialing_code: '', + value: '', + iso: '' + }); + + const handleChange = (newValue) => { + setState(prevState => ({ ...prevState, value: newValue })); + // Automatic re-render triggered + }; + + return input({ + value: state.value, + oninput: handleChange + }); +}; +``` + +### From Module-level State Stores + +**Before:** +```typescript +// Module-level state store +const qrStateStore: Record = {}; + +export const QR = ({ id, ...props }) => { + if (!qrStateStore[id]) { + qrStateStore[id] = { isDownloading: false }; + } + + const state = qrStateStore[id]; + + // Manual updates + const handleDownload = () => { + state.isDownloading = true; + updateQRDisplay(); // Manual DOM manipulation + }; +}; +``` + +**After:** +```typescript +// StateManager +export const QR = ({ id, ...props }) => { + const { state, setState } = useComponentState({ + isDownloading: false + }); + + const handleDownload = () => { + setState(prevState => ({ ...prevState, isDownloading: true })); + // Automatic re-render triggered + }; +}; +``` + +## Best Practices + +### 1. Use the Simple API + +```typescript +// ✅ Good - simple and automatic +const { state, setState } = useComponentState({ count: 0 }); + +// Component IDs are automatically generated and stable across re-renders +``` + +### 2. Define State Interface for Type Safety + +```typescript +// ✅ Good - type-safe state management +interface CounterState { + count: number; + isIncreasing: boolean; +} + +const { state, setState } = useComponentState({ + count: 0, + isIncreasing: true +}); +``` + +### 3. Use Functional State Updates + +```typescript +// ✅ Good - safe with concurrent updates +setState(prevState => ({ ...prevState, value: newValue })); + +// ❌ Bad - can overwrite concurrent updates +setState({ ...state, value: newValue }); +``` + +### 4. Keep State Simple and Predictable + +```typescript +// ✅ Good - simple state structure +const { state, setState } = useComponentState({ + value: '', + isValid: false, + errors: [] +}); + +// ❌ Avoid - overly complex nested state +const { state, setState } = useComponentState({ + form: { + fields: { + user: { + profile: { + details: { ... } + } + } + } + } +}); +``` + +**Note:** Cleanup is now automatic! The StateManager handles all cleanup when views unmount. + +## IE 11 Compatibility + +The StateManager is designed to work on Internet Explorer 11: + +- **No ES6+ Features**: No Proxy, Map, Set, async/await, etc. +- **Polyfill-free**: Works with native ES5 features +- **Traditional Loops**: Uses for loops instead of forEach/map where performance matters +- **Manual Array Management**: Uses splice() instead of modern array methods + +## Performance Considerations + +- **Shallow Equality**: State changes use shallow comparison to prevent unnecessary re-renders +- **Batch Updates**: Multiple state updates are batched using `requestAnimationFrame` (with IE 11 fallback) +- **Stable Component IDs**: Uses first-render call order (like React hooks) for maximum stability +- **Memory Management**: Automatic cleanup prevents memory leaks +- **Single Frame Updates**: All setState calls within a frame are batched into a single re-render +- **Subscription Model**: Components can subscribe to state changes for efficient updates + +### Batching System + +The StateManager implements a batching system similar to React's: + +```typescript +// These three setState calls happen in the same frame +setState({ count: 1 }); +setState({ count: 2 }); +setState({ count: 3 }); + +// Only triggers ONE re-render with final state: { count: 3 } +``` + +### Stable Component IDs + +Component IDs are generated based on the **call order during the first render**, then locked in for subsequent renders: + +```typescript +// First render: IDs assigned based on call order +{showHeader && Header()} // Gets ID: view-comp-0 (if rendered) +{Counter({ label: "Main" })} // Gets ID: view-comp-1 (always) +{showFooter && Footer()} // Gets ID: view-comp-2 (if rendered) + +// Subsequent renders: Same IDs reused regardless of conditional rendering +// Components maintain their state even when order changes +``` + +**Key Benefits:** +- **Render-stable**: IDs don't change between re-renders +- **Call-order based**: Uses the same approach as React hooks +- **Conditional-safe**: Components keep state when conditionally rendered +- **No call stack dependency**: Avoids issues with JavaScript engine optimizations + +## Troubleshooting + +### Component Not Re-rendering + +**Problem:** State changes but component doesn't re-render. + +**Solution:** The StateManager automatically handles view integration. If components aren't re-rendering, check that: +1. Your component is being used within a view that extends `APMViewImpl` +2. The view's `render()` method is being called properly +3. State updates are using functional updates: `setState(prevState => ({ ...prevState, newValue }))` + +### State Shared Between Components + +**Problem:** Multiple component instances share the same state. + +**Solution:** The StateManager automatically generates unique IDs for each component instance. This should not happen with the current system. If you encounter this issue: +1. Make sure you're using `useComponentState()` and not manually managing state +2. Check for any global variables that might be interfering +3. Verify components are rendered within proper view contexts + +### Memory Leaks + +**Problem:** State persists after component removal. + +**Solution:** StateManager automatically cleans up when views unmount. For manual cleanup: + +```typescript +cleanupComponentState(componentId); +``` + +### IE 11 Compatibility Issues + +**Problem:** StateManager doesn't work on IE 11. + +**Solution:** The StateManager is designed for IE 11 compatibility. If you encounter issues, check for: +- Modern JavaScript features in your component code +- Missing polyfills for other parts of your application +- Console errors that might indicate the root cause diff --git a/docs/apm-ui-system.md b/docs/apm-ui-system.md new file mode 100644 index 00000000..a44a37b2 --- /dev/null +++ b/docs/apm-ui-system.md @@ -0,0 +1,640 @@ +# APM UI System Architecture + +The APM (Alternative Payment Methods) UI system is a modern, component-based architecture built around three core concepts: **Page**, **View**, and **Elements**. This system provides a robust foundation for building interactive payment forms with state management, virtual DOM rendering, and component isolation. + +## Overview + +The APM UI system follows a hierarchical structure: + +``` +Page (Container & Orchestrator) + └── View (State Management & Rendering) + └── Elements (Virtual DOM Components) +``` + +## Core Components + +### 1. Page (`src/apm/Page.ts`) + +The **Page** serves as the main container and orchestrator for the entire UI system. It manages: + +- **Shadow DOM/iframe isolation** for style encapsulation +- **View lifecycle** (mounting, rendering, cleanup) +- **API integration** and request handling +- **State flow management** (SUCCESS, PENDING, NEXT_STEP_REQUIRED, etc.) + +#### Key Features: + +- **Adaptive rendering strategy**: Uses Shadow DOM when supported, falls back to iframe for older browsers +- **Font loading**: Automatically injects Google Fonts (Work Sans) for consistent typography +- **Error handling**: Provides critical failure management and user feedback +- **Theme integration**: Applies styles through the Theme system + +#### Example Usage: +```typescript +// Page manages the overall container and delegates to views +const page = new APMPageImpl(containerElement); +page.render(APMViewNextSteps, { elements, config }); +page.load(apiRequest); +``` + +### 2. View (`src/apm/views/View.ts`) + +The **View** is the "engine" of the UI system. It's a reusable base class (`APMViewImpl`) that provides: + +- **State-driven rendering** with efficient updates +- **Virtual DOM patching** for optimal performance +- **Component lifecycle management** +- **Error handling** with runtime proxies + +#### Key Features: + +- **Reactive state management**: Uses `setState()` with batched updates via `requestAnimationFrame` +- **Virtual DOM diffing**: Efficiently patches only changed DOM elements +- **Props and state separation**: Clean separation of external props and internal state +- **Style injection**: Automatic CSS injection into Shadow DOM/iframe +- **Ref system**: Provides direct DOM element access when needed + +#### State Management: +```typescript +class MyView extends APMViewImpl { + state = { count: 0 }; + + handleIncrement() { + this.setState({ count: this.state.count + 1 }); + } + + render() { + return div({}, `Count: ${this.state.count}`); + } +} +``` + +#### Component State Management with StateManager: +```typescript +const MyComponent = (props) => { + const { state, setState, watch } = useComponentState({ + value: '', + focusedIndex: 0 + }); + + // Watch for specific field changes + watch('focusedIndex', (newIndex) => { + console.log('Focus changed to index:', newIndex); + }); + + // Watch for any state changes + watch((state) => { + console.log('State changed:', state); + }); + + return input({ + value: state.value, + oninput: (_, value) => setState({ ...state, value }) + }); +}; +``` + +#### View Lifecycle: +1. **Construction**: Initialize props, state, and error handling +2. **Mount**: Create initial Virtual DOM and inject into real DOM +3. **Updates**: Process state changes and patch DOM differences +4. **Cleanup**: Remove event listeners and DOM elements + +### 3. Elements (`src/apm/elements/elements.ts`) + +The **Elements** system provides a lightweight Virtual DOM implementation inspired by React/Vue: + +- **Virtual DOM nodes (VNode)**: Lightweight representations of DOM elements +- **JSX-like syntax**: Functional approach to building UI trees +- **Type safety**: Full TypeScript support for HTML elements and props +- **Key-based reconciliation**: Efficient list rendering and updates + +#### Key Features: + +- **Tagged template functions**: Each HTML tag has a corresponding function (`div()`, `button()`, etc.) +- **Props handling**: Type-safe props with special handling for events, refs, and keys +- **Child processing**: Automatic flattening and normalization of nested children +- **Fragment support**: Group elements without wrapper divs + +#### Virtual DOM Structure: +```typescript +interface VNode { + type: Tag | '#text' | null; // Element type + props: Props; // Element properties + children: VNode[]; // Child elements + dom: Node | null; // Real DOM reference + key?: string; // Reconciliation key + value?: string; // Text content (for text nodes) +} +``` + +#### Element Creation: +```typescript +const { div, button, input } = elements; + +// Create elements with props and children +const myButton = button( + { + className: 'primary-btn', + onclick: handleClick, + ref: (el) => buttonRef = el + }, + 'Click me' +); + +// Nest elements naturally +const form = div({ className: 'form' }, + input({ type: 'text', name: 'username' }), + button({ type: 'submit' }, 'Submit') +); +``` + +## Built-in Form Components + +The APM system includes several specialized form components: + +### Input Component (`src/apm/elements/input.ts`) +Handles various input types with validation and styling: +```typescript +input({ + type: 'text', + name: 'email', + placeholder: 'Enter your email', + required: true, + oninput: handleEmailChange +}) +``` + +### Phone Component (`src/apm/elements/phone.ts`) +Specialized phone input with country code selection: +```typescript +phone({ + name: 'phone', + dialingCodes: countryDialingCodes, + value: phoneNumber, + onchange: handlePhoneChange +}) +``` + +### Select Component (`src/apm/elements/select.ts`) +Dropdown selection with options: +```typescript +select({ + name: 'country', + options: countryOptions, + value: selectedCountry, + onchange: handleCountryChange +}) +``` + +### Checkbox Component (`src/apm/elements/checkbox.ts`) +Boolean checkbox input: +```typescript +Checkbox({ + name: 'terms', + checked: agreedToTerms, + label: 'I agree to the terms and conditions', + onchange: handleTermsChange +}) +``` + +### OTP Component (`src/apm/elements/otp.ts`) +One-time password input with multiple fields: +```typescript +OTP({ + name: 'verification_code', + length: 6, + type: 'numeric', + label: 'Enter verification code', + onComplete: (name, otpValue) => { + console.log('OTP completed:', otpValue); + } +}) +``` + +### Copy Instruction Component (`src/apm/elements/copy-instruction.ts`) +Displays copyable payment instructions: +```typescript +copyInstruction({ + instruction: { + label: 'Reference Number', + value: 'REF123456789', + type: 'message' + } +}) +``` + +## UI Enhancement Features + +### Copy Container Grouping +The system automatically groups consecutive copy instructions with labels into containers for better visual organization: + +```typescript +// Consecutive instructions are automatically grouped +copyInstruction({ instruction: { label: 'Bank', value: 'Example Bank' } }), +copyInstruction({ instruction: { label: 'Account', value: '1234567890' } }), +// Creates a single copy-container div around both instructions +``` + +### Loader Component (`src/apm/elements/loader.ts`) +Shows loading states: +```typescript +loader({ + message: 'Processing payment...', + showSpinner: true +}) +``` + +### QR Code Component (`src/apm/elements/qr.ts`) +Displays QR codes for payment methods: +```typescript +qr({ + qrCodeData: paymentQRData, + size: 200 +}) +``` + +## Data Flow + +### 1. Initialization Flow +``` +User Container → Page.constructor() → Shadow DOM/iframe setup → Style injection +``` + +### 2. Rendering Flow +``` +Page.render() → View.constructor() → View.mount() → +Virtual DOM creation → Real DOM generation → DOM insertion +``` + +### 3. Update Flow +``` +User interaction → Event handler → setState() → +Batched update (RAF) → Virtual DOM diff → DOM patching +``` + +### 4. API Integration Flow +``` +Page.load() → API request → Response handling → +State determination → View selection → Re-render +``` + +## Event System + +The APM system uses a comprehensive event system aligned with mobile implementations: + +### Event Types +```typescript +interface APMEvents { + "initialised": never; // APM initialized + "start": never; // Ready for user input + "field-change": { parameter: {...} }; // Form field changed + "submit": { parameters: [...] }; // Form submitted + "submit-success": { additionalParametersExpected: boolean }; + "submit-error": { failure: {...} }; // Validation error + "payment-pending": never; // Waiting for confirmation + "pending-confirmed": never; // User confirmed action + "success": { trigger: 'user' | 'timeout' | 'immediate' }; + "failure": { failure: {...}, paymentState?: string }; + "request-cancel": never; // User requested cancellation + "*": { type: string, ...eventData }; // Universal event +} +``` + +### Event Usage +```typescript +const apm = client.apm.authorization(container, options); + +// Listen to specific events +apm.on('field-change', (data) => { + console.log('Field changed:', data.parameter); +}); + +// Universal event listener +apm.on('*', (event) => { + console.log(`Event: ${event.type}`, event); +}); + +apm.initialise(); +``` + +## API Integration + +### API Polling with Cancellation +The system includes automatic API polling with cancellation capabilities: + +```typescript +// API automatically polls for payment status +// Polling can be cancelled when user cancels payment +apm.on('request-cancel', () => { + // API polling is automatically cancelled + console.log('Payment cancelled by user'); +}); +``` + +### Request Handling +```typescript +// API handles different response states +API.initialise({ + onSuccess: (response) => { + // Handle success or pending states + }, + onError: (error) => { + // Handle validation errors + }, + onFailure: (error) => { + // Handle system failures + } +}); +``` + +## Advanced Features + +### Error Handling +The system includes comprehensive error handling: +- **Runtime error proxies** wrap view instances +- **Critical failure handling** with user-friendly messages +- **Graceful degradation** for unsupported browsers +- **Validation error display** integrated with form components + +### Performance Optimizations +- **Batched updates**: Multiple `setState` calls are batched into single renders +- **Virtual DOM diffing**: Only changed elements are updated in the real DOM +- **Key-based reconciliation**: Efficient list updates using element keys +- **RequestAnimationFrame**: Updates are scheduled for optimal performance + +### Browser Compatibility +- **Shadow DOM**: Modern browsers get isolated styling +- **iframe fallback**: Older browsers use iframe isolation +- **Polyfill support**: Includes necessary polyfills for older browsers + +### Form State Management +The system provides sophisticated form state management: +- **Field validation**: Real-time validation with error display +- **State persistence**: Form data persists across view changes +- **Event prevention**: Prevents spurious events during form operations + +## Example: Complete Payment Component + +```typescript +class PaymentFormView extends APMViewImpl<{}, { + amount: number; + loading: boolean; + email: string; + agreedToTerms: boolean; +}> { + state = { + amount: 0, + loading: false, + email: '', + agreedToTerms: false + }; + + styles() { + return ` + .payment-form { + padding: 20px; + max-width: 400px; + margin: 0 auto; + } + .form-group { + margin-bottom: 15px; + } + .submit-btn { + width: 100%; + padding: 12px; + background: #007bff; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + } + .submit-btn:disabled { + opacity: 0.6; + cursor: not-allowed; + } + `; + } + + handleEmailChange = (event: Event) => { + const value = (event.target as HTMLInputElement).value; + this.setState({ email: value }); + + // Emit field-change event + ContextImpl.context.events.emit('field-change', { + parameter: { key: 'email', value } + }); + } + + handleTermsChange = (event: Event) => { + const checked = (event.target as HTMLInputElement).checked; + this.setState({ agreedToTerms: checked }); + + ContextImpl.context.events.emit('field-change', { + parameter: { key: 'terms', value: checked } + }); + } + + handleSubmit = () => { + if (!this.state.email || !this.state.agreedToTerms) { + return; + } + + this.setState({ loading: true }); + + // Emit submit event + ContextImpl.context.events.emit('submit', { + parameters: [ + { key: 'email', value: this.state.email }, + { key: 'terms', value: this.state.agreedToTerms } + ] + }); + + // Process payment + API.sendFormData({ + email: this.state.email, + terms_accepted: this.state.agreedToTerms + })({ + onSuccess: (response) => { + this.setState({ loading: false }); + ContextImpl.context.events.emit('submit-success', { + additionalParametersExpected: !!response.elements + }); + }, + onError: (error) => { + this.setState({ loading: false }); + ContextImpl.context.events.emit('submit-error', { + failure: error.error + }); + } + }); + } + + render() { + const { div, input, button, label } = elements; + + return div({ className: 'payment-form' }, + div({ className: 'form-group' }, + label({ htmlFor: 'email' }, 'Email Address'), + input({ + id: 'email', + type: 'email', + value: this.state.email, + oninput: this.handleEmailChange, + placeholder: 'Enter your email', + required: true + }) + ), + + div({ className: 'form-group' }, + checkbox({ + name: 'terms', + checked: this.state.agreedToTerms, + onchange: this.handleTermsChange + }, 'I agree to the terms and conditions') + ), + + button({ + className: 'submit-btn', + onclick: this.handleSubmit, + disabled: this.state.loading || !this.state.email || !this.state.agreedToTerms + }, + this.state.loading ? 'Processing...' : 'Continue Payment' + ) + ); + } +} + +// Usage +const page = new APMPageImpl(document.getElementById('payment-container')); +page.render(PaymentFormView); +``` + +## File Structure + +``` +src/apm/ +├── Page.ts # Main page orchestrator +├── API.ts # API integration with polling +├── Context.ts # Global context management +├── Storage.ts # Persistent storage utilities +├── Theme.ts # Theme management +├── views/ +│ ├── View.ts # Base view class with state management +│ ├── Components.ts # Form component implementations +│ ├── Success.ts # Success view +│ ├── Error.ts # Error view +│ ├── Loading.ts # Loading view +│ ├── NextSteps.ts # Next steps view +│ ├── Pending.ts # Pending payment view +│ └── utils/ +│ ├── form.ts # Form utilities +│ ├── instructions.ts # Instruction utilities +│ └── render-elements.ts # Element rendering utilities +├── elements/ +│ ├── elements.ts # Virtual DOM implementation +│ ├── input.ts # Input component +│ ├── phone.ts # Phone input component +│ ├── select.ts # Select component +│ ├── checkbox.ts # Checkbox component +│ ├── otp.ts # OTP component +│ ├── copy-instruction.ts # Copy instruction component +│ ├── loader.ts # Loader component +│ ├── qr.ts # QR code component +│ ├── button.ts # Button component +│ ├── header.ts # Header component +│ └── tick.ts # Success tick component +├── events/ +│ ├── APMEventListener.ts # APM-specific events +│ └── EventListener.ts # Base event system +├── layouts/ +│ └── Main.ts # Main layout component +└── utils.ts # Utility functions +``` + +## Configuration + +The APM system supports nested configuration for better organization: + +```typescript +const apm = client.apm.authorization(container, { + gatewayConfigurationId: 'gway_conf_xxx', + invoiceId: 'iv_xxx', + + // Confirmation settings + confirmation: { + requiresAction: true, // Require user confirmation for pending + timeout: 900, // Timeout in seconds (15 minutes) + allowCancelation: true // Allow cancellation during confirmation + }, + + // Success screen settings + success: { + enabled: true, // Show success screen + requiresAction: false, // Auto-dismiss vs manual + autoDismissDuration: 3, // Auto-dismiss duration (seconds) + manualDismissDuration: 60 // Manual dismiss timeout (seconds) + }, + + // Pre-filled data + initialData: { + email: 'user@example.com' + }, + + // Theme customization + theme: { + palette: { + light: { + surface: { + button: { + primary: '#007bff', + secondary: '#6c757d', + hover: { + primary: '#0056b3', + secondary: '#545b62' + } + } + }, + text: { + default: '#000000', + label: '#707378' + } + }, + dark: { + surface: { + button: { + primary: '#007bff', + secondary: '#6c757d', + hover: { + primary: '#0056b3', + secondary: '#545b62' + } + } + }, + text: { + default: '#FFFFFF', + label: '#A7A9AF' + } + } + } + } +}); +``` + +**Note:** The theme system uses text colors from your theme configuration. For buttons, it automatically selects between your defined light and dark text colors based on background color luminance to ensure proper contrast. You can customize both background and text colors - the system intelligently chooses which text color to use for accessibility. + +## Benefits + +1. **Encapsulation**: Shadow DOM/iframe isolation prevents style conflicts +2. **Performance**: Virtual DOM ensures efficient updates +3. **Type Safety**: Full TypeScript support throughout the system +4. **Maintainability**: Clear separation of concerns between Page, View, and Elements +5. **Flexibility**: Easy to extend with new views and components +6. **Browser Support**: Graceful degradation for older browsers +7. **Developer Experience**: JSX-like syntax with full IDE support +8. **Mobile Alignment**: Event system aligned with mobile implementations +9. **Comprehensive Components**: Rich set of form components with built-in validation +10. **Robust API Integration**: Automatic polling with cancellation support + +This architecture provides a solid foundation for building complex, interactive payment interfaces while maintaining performance, reliability, and developer productivity across web and mobile platforms. \ No newline at end of file diff --git a/docs/migration-guide-native-apm-to-apm.md b/docs/migration-guide-native-apm-to-apm.md new file mode 100644 index 00000000..2ede46a6 --- /dev/null +++ b/docs/migration-guide-native-apm-to-apm.md @@ -0,0 +1,652 @@ +# Migration Guide: From setupNativeApm to apm.authorization/apm.tokenization + +This guide will help you migrate from the legacy `setupNativeApm` API to the new `apm.authorization` and `apm.tokenization` APIs. The new APIs provide better separation of concerns, improved error handling, and more flexible configuration options. + +## Overview of Changes + +The new APM system introduces several key improvements: + +- **Flow-specific methods**: Separate methods for authorization (payments) vs tokenization (saving payment methods) +- **Improved container handling**: Container is passed during initialization rather than mounting +- **Enhanced event system**: Instance-level events aligned with mobile implementation +- **Better configuration**: Nested configuration objects for success screens and confirmation settings +- **Explicit lifecycle management**: Clear initialization and cleanup methods + +## API Comparison + +### Legacy API (setupNativeApm) +```javascript +const nativeApm = client.setupNativeApm(config); +nativeApm.mount(container); +``` + +### New API (apm.authorization/tokenization) +```javascript +const apm = client.apm.authorization(container, options); +// or +const apm = client.apm.tokenization(container, options); +apm.initialise(); +``` + +## Step-by-Step Migration + +### 1. Choose the Appropriate Flow + +First, determine which flow you need: + +- **Use `apm.authorization`** for processing payments with invoices +- **Use `apm.tokenization`** for processing payments with already created customer tokens + +```javascript +// For payments (old setupNativeApm equivalent) +const apm = client.apm.authorization(container, options); + +// For tokenization (new capability) +const apm = client.apm.tokenization(container, options); +``` + +### 2. Update Method Signature + +**Before:** +```javascript +const nativeApm = client.setupNativeApm({ + gatewayConfigurationId: 'gway_conf_xxx', + invoiceId: 'iv_xxx', + returnUrl: 'https://example.com/return', + pollingMaxTimeout: 180 +}); + +nativeApm.mount('#container'); +``` + +**After:** +```javascript +// Authorization flow +const apm = client.apm.authorization('#container', { + gatewayConfigurationId: 'gway_conf_xxx', + invoiceId: 'iv_xxx', + confirmation: { + timeout: 900, // 15 minutes in seconds + }, +}); + +// Tokenization flow +const apm = client.apm.tokenization('#container', { + gatewayConfigurationId: 'gway_conf_xxx', + customerId: 'cust_xxx', + customerTokenId: 'tok_xxx', +}); + +apm.initialise(); +``` + +### 3. Update Configuration Options + +The configuration options have been restructured into nested objects: + +| Legacy Option | New Option | Notes | +|---------------|------------|-------| +| `returnUrl` | *(removed)* | Handled by invoice configuration | +| `pollingMaxTimeout` | `confirmation.timeout` | Default 900 seconds (15 minutes) | +| *(new)* | `confirmation.requiresAction` | Require user confirmation for pending (default: false) | +| *(new)* | `confirmation.allowCancelation` | Allow cancellation during confirmation (default: true) | +| *(new)* | `success.enabled` | Whether to show success screen (default: true) | +| *(new)* | `success.autoDismissDuration` | Duration when auto-dismissing (default: 3s) | +| *(new)* | `success.manualDismissDuration` | Duration when manual dismissal required (default: 60s) | +| *(new)* | `success.requiresAction` | Whether user must dismiss manually (default: false) | +| *(new)* | `allowCancelation` | Whether user can cancel payment (default: true) | +| *(new)* | `initialData` | Prefilled form data | +| *(new)* | `theme` | Theme configuration | + +### 4. Update Theming + +**Before:** +```javascript +const nativeApm = client.setupNativeApm(config); +nativeApm.setTheme({ + buttons: { + default: { + backgroundColor: 'green', + color: 'white' + } + } +}); +``` + +**After:** +```javascript +const apm = client.apm.authorization('#container', { + gatewayConfigurationId: 'gway_conf_xxx', + invoiceId: 'iv_xxx', + theme: { + palette: { + light: { + surface: { + button: { + primary: 'green' + } + }, + text: { + default: 'white' // Used for button text when background is dark enough + } + } + } + } +}); +``` + +**Note:** The new theme system uses text colors from the theme configuration. For buttons, it automatically selects between light and dark text colors based on the button's background color luminance to ensure proper contrast. You can customize both background colors and text colors in the theme. + +### 5. Update Data Prefilling + +**Before:** +```javascript +const nativeApm = client.setupNativeApm(config); +nativeApm.prefillData({ + email: 'john@doe.com' +}); +``` + +**After:** +```javascript +const apm = client.apm.authorization('#container', { + gatewayConfigurationId: 'gway_conf_xxx', + invoiceId: 'iv_xxx', + initialData: { + email: 'john@doe.com' + } +}); +``` + +### 6. Update Event Handling + +The event system has been completely redesigned and aligned with the mobile team implementation: + +**Before:** +```javascript +window.addEventListener('processout_native_apm_loading', (e) => { + console.log('Widget loading'); +}); + +window.addEventListener('processout_native_apm_ready', (e) => { + console.log('Widget ready'); +}); + +window.addEventListener('processout_native_apm_payment_init', (e) => { + console.log('Payment initialized'); +}); + +window.addEventListener('processout_native_apm_payment_success', (e) => { + console.log('Payment successful', e.detail); +}); + +window.addEventListener('processout_native_apm_payment_error', (e) => { + console.log('Payment error', e.detail); +}); +``` + +**After:** +```javascript +// New mobile-aligned event system +apm.on('initialised', () => { + console.log('APM initialized'); +}); + +apm.on('start', () => { + console.log('APM started, waiting for user input'); +}); + +apm.on('field-change', (data) => { + console.log('Field changed:', data.parameter); +}); + +apm.on('submit', (data) => { + console.log('Form submitted:', data.parameters); +}); + +apm.on('submit-success', (data) => { + console.log('Submit successful, additional input needed:', data.additionalParametersExpected); +}); + +apm.on('submit-error', (data) => { + console.log('Submit error:', data.failure); +}); + +apm.on('payment-pending', () => { + console.log('Payment pending, waiting for confirmation'); +}); + +apm.on('pending-confirmed', () => { + console.log('User confirmed pending payment action'); +}); + +apm.on('success', (data) => { + console.log('Payment successful, trigger:', data.trigger); + apm.cleanUp(); // Important: clean up when done +}); + +apm.on('failure', (data) => { + console.log('Payment failed:', data.failure); + if (data.paymentState) { + console.log('Payment state at failure:', data.paymentState); + } + apm.cleanUp(); // Important: clean up on failure +}); + +apm.on('request-cancel', () => { + console.log('User requested payment cancellation'); +}); + +// Universal event listener that fires for all events +apm.on('*', (event) => { + console.log('Event fired:', event.type, event); +}); +``` + +### 7. Add Proper Cleanup + +The new API requires explicit cleanup to prevent memory leaks: + +```javascript +apm.on('success', () => { + console.log('Payment completed successfully'); + apm.cleanUp(); // Clean up resources +}); + +apm.on('failure', () => { + console.log('Payment failed'); + apm.cleanUp(); // Clean up resources +}); + +// Also clean up if user navigates away or component unmounts +window.addEventListener('beforeunload', () => { + apm.cleanUp(); +}); +``` + +## Complete Migration Examples + +### Example 1: Basic Authorization Flow + +**Before (setupNativeApm):** +```javascript +document.addEventListener('DOMContentLoaded', function() { + const projectId = 'test-proj_xxx'; + const client = new ProcessOut.ProcessOut(projectId); + + const nativeApm = client.setupNativeApm({ + gatewayConfigurationId: 'gway_conf_xxx', + invoiceId: 'iv_xxx' + }); + + nativeApm.mount('#container'); + + window.addEventListener('processout_native_apm_payment_success', (e) => { + console.log('Payment successful!'); + window.location.href = e.detail.returnUrl; + }); + + window.addEventListener('processout_native_apm_payment_error', (e) => { + console.error('Payment failed:', e.detail); + }); +}); +``` + +**After (apm.authorization):** +```javascript +document.addEventListener('DOMContentLoaded', function() { + const projectId = 'test-proj_xxx'; + const client = new ProcessOut.ProcessOut(projectId); + + const apm = client.apm.authorization('#container', { + gatewayConfigurationId: 'gway_conf_xxx', + invoiceId: 'iv_xxx' + }); + + apm.on('success', (data) => { + console.log('Payment successful!', data); + apm.cleanUp(); + // Handle success (redirect handled by invoice configuration) + }); + + apm.on('failure', (data) => { + console.error('Payment failed:', data.failure); + apm.cleanUp(); + }); + + apm.on('submit-error', (data) => { + console.error('Validation error:', data.failure); + }); + + try { + apm.initialise(); + } catch (error) { + console.error('Failed to initialize APM:', error); + apm.cleanUp(); + } +}); +``` + +### Example 2: With Custom Configuration + +**Before:** +```javascript +const nativeApm = client.setupNativeApm({ + gatewayConfigurationId: 'gway_conf_xxx', + invoiceId: 'iv_xxx', + pollingMaxTimeout: 300 +}); + +nativeApm.setTheme({ + buttons: { + default: { + backgroundColor: '#007bff', + color: 'white', + fontWeight: 'bold' + } + } +}); + +nativeApm.prefillData({ + email: 'customer@example.com' +}); + +nativeApm.mount('#payment-container'); +``` + +**After:** +```javascript +const apm = client.apm.authorization('#payment-container', { + gatewayConfigurationId: 'gway_conf_xxx', + invoiceId: 'iv_xxx', + confirmation: { + timeout: 300 + }, + theme: { + palette: { + light: { + surface: { + button: { + primary: '#007bff' + } + }, + text: { + default: 'white' + } + } + } + }, + initialData: { + email: 'customer@example.com' + } +}); + +apm.on('success', () => { + apm.cleanUp(); +}); + +apm.on('failure', () => { + apm.cleanUp(); +}); + +apm.initialise(); +``` + +### Example 3: Tokenization Flow (New Capability) + +The tokenization flow is new and wasn't available with `setupNativeApm`: + +```javascript +const apm = client.apm.tokenization('#container', { + gatewayConfigurationId: 'gway_conf_xxx', + customerId: 'cust_xxx', + customerTokenId: 'tok_xxx' +}); + +apm.on('success', (data) => { + console.log('Payment method saved successfully!'); + apm.cleanUp(); +}); + +apm.on('failure', (data) => { + console.error('Tokenization failed:', data.failure); + apm.cleanUp(); +}); + +apm.initialise(); +``` + +## New Event System Details + +The new event system is aligned with the mobile team implementation and provides comprehensive lifecycle events: + +### Event Flow +1. **`initialised`** - Fired when APM is initialized +2. **`start`** - Fired when initial data is loaded and waiting for user input +3. **`field-change`** - Fired when user changes any form field +4. **`submit`** - Fired when form is submitted +5. **`submit-success`** - Fired when submission is successful +6. **`submit-error`** - Fired when submission fails with validation errors +7. **`payment-pending`** - Fired when payment is pending external confirmation +8. **`pending-confirmed`** - Fired when user confirms pending action +9. **`success`** - Fired when payment is successful (final event) +10. **`failure`** - Fired when payment fails (final event) +11. **`request-cancel`** - Fired when user requests cancellation + +### Universal Event Listener +The `*` event fires for all events with a unified structure: +```javascript +apm.on('*', (event) => { + console.log(`Event: ${event.type}`, event); +}); +``` + +## Migration Checklist + +Use this checklist to ensure you've completed all migration steps: + +- [ ] **Flow Selection**: Chosen between `apm.authorization` or `apm.tokenization` +- [ ] **Method Signature**: Updated from `setupNativeApm(config)` to `apm.authorization(container, options)` +- [ ] **Container Handling**: Moved container from `.mount()` to constructor +- [ ] **Configuration**: Updated to nested objects (`confirmation`, `success`) +- [ ] **Event Handlers**: Migrated from window events to instance events with new event names +- [ ] **Initialization**: Added explicit `.initialise()` call +- [ ] **Cleanup**: Added `.cleanUp()` calls in success/failure handlers +- [ ] **Error Handling**: Added try-catch around `.initialise()` +- [ ] **Testing**: Verified all functionality works with new API + +## Troubleshooting + +### Common Migration Issues + +#### 1. Container Not Found Error + +**Error Message:** +``` +Cannot read properties of null (reading 'appendChild') +TypeError: container is null +``` + +**Cause:** Container element doesn't exist when APM is created + +**Solution:** +```javascript +// ❌ Wrong - container might not exist yet +const apm = client.apm.authorization('#my-container', config); + +// ✅ Correct - ensure DOM is ready +document.addEventListener('DOMContentLoaded', () => { + const container = document.getElementById('my-container'); + if (!container) { + console.error('Container element not found'); + return; + } + const apm = client.apm.authorization(container, config); + apm.initialise(); +}); + +// ✅ Alternative - check container exists +const container = document.querySelector('#my-container'); +if (container) { + const apm = client.apm.authorization(container, config); + apm.initialise(); +} else { + console.error('Payment container not found'); +} +``` + +#### 2. Events Not Firing + +**Problem:** Old window events still in code, new events not working, or timing issues + +**Common causes:** + +1. **Using old window events:** +```javascript +// ❌ Old events won't work +window.addEventListener('processout_native_apm_payment_success', handler); + +// ✅ Use new instance events +apm.on('success', (data) => { + console.log('Payment successful:', data); + apm.cleanUp(); +}); +``` + +2. **Adding events after initialise() - missing early events:** +```javascript +// ❌ Wrong order - misses 'initialised' and 'start' events +const apm = client.apm.authorization(container, config); +apm.initialise(); +apm.on('initialised', handler); // This won't fire - already happened! +apm.on('start', handler); // This won't fire - already happened! + +// ✅ Correct order - add events before initialise() +const apm = client.apm.authorization(container, config); +apm.on('initialised', handler); +apm.on('start', handler); +apm.on('success', handler); +apm.initialise(); // Now events will fire properly +``` + +3. **Debug with universal listener:** +```javascript +// ✅ Add this first to see all events +apm.on('*', (event) => { + console.log('APM Event:', event.type, event); +}); +``` + +#### 3. Theme Not Applying + +**Problem:** Old theme structure doesn't work with new API + +**Solution:** +```javascript +// ❌ Old theme structure +theme: { + buttons: { + default: { backgroundColor: 'blue' } + } +} + +// ✅ New theme structure +theme: { + palette: { + light: { + surface: { + button: { + primary: 'blue' + } + } + } + } +} +``` + +**Note:** The payment widget may use Shadow DOM or iframe, so external CSS won't work. Always use the theme configuration for styling. + +### Runtime Issues + +#### 4. Widget Not Appearing + +**Common causes and solutions:** + +1. **Missing initialization:** +```javascript +// ❌ Forgot to call initialise() +const apm = client.apm.authorization(container, config); +apm.on('success', handler); +// Missing: apm.initialise(); + +// ✅ Always call initialise() +const apm = client.apm.authorization(container, config); +apm.on('success', handler); +apm.initialise(); +``` + +2. **Check browser console** for JavaScript errors and network issues + +#### 5. Network/API Errors + +**Common Error Messages:** +``` +Failed to fetch +CORS error +404 Not Found on /invoices/iv_xxx/apm-payment +``` + +**Solutions:** + +```javascript +// ✅ Verify correct gateway configuration ID format +gatewayConfigurationId: 'gway_conf_xxx' // Must start with 'gway_conf_' + +// ✅ Verify correct invoice ID format +invoiceId: 'iv_xxx' // Must start with 'iv_' + +// ✅ Check environment URLs +// Development: https://api.processout.com +// Production: https://api.processout.com +``` + +### Debugging Tips + +**Quick debugging checklist:** + +1. **Check browser console** for JavaScript errors +2. **Verify container element** exists and is accessible +3. **Test with minimal example** from this guide +4. **Check network tab** for failed API requests +5. **Verify API credentials** and gateway configuration + +**Simple debugging code:** +```javascript +// Add universal event listener to see what's happening +apm.on('*', (event) => { + console.log('APM Event:', event.type, event); +}); + +// Check if container exists +const container = document.querySelector('#payment-container'); +console.log('Container exists:', !!container); +``` + +**When reporting issues, include:** + +- Browser and version +- JavaScript framework and version (if any) +- Exact error messages from console +- Minimal code example that reproduces the issue + +## Conclusion + +The new APM API provides a more robust and flexible foundation for alternative payment methods. The key improvements include: + +- **Mobile-aligned event system** with comprehensive lifecycle events +- **Nested configuration** for better organization and clarity +- **Explicit lifecycle management** with initialization and cleanup +- **Separate flows** for authorization vs tokenization +- **Universal event listener** for comprehensive event monitoring + +The migration requires updating event handlers, configuration structure, and adding proper cleanup, but the improved architecture and mobile alignment make it worthwhile for long-term maintainability and feature parity across platforms. \ No newline at end of file diff --git a/examples/apm/index.html b/examples/apm/index.html index 7e05e62d..96b19e33 100644 --- a/examples/apm/index.html +++ b/examples/apm/index.html @@ -6,38 +6,154 @@ +
+
+ + + + + +
+
diff --git a/package.json b/package.json index 1b4071c8..c30c78d0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "processout.js", - "version": "1.1.4", + "version": "1.1.7", "description": "ProcessOut.js is a JavaScript library for ProcessOut's payment processing API.", "scripts": { "build:processout": "tsc -p src/processout && uglifyjs --compress --keep-fnames --ie8 dist/processout.js -o dist/processout.js", diff --git a/src/apm/API.ts b/src/apm/API.ts index cd63e6fb..419f64b7 100644 --- a/src/apm/API.ts +++ b/src/apm/API.ts @@ -1,10 +1,12 @@ module ProcessOut { export type FormFieldResponse = | { - type: "email" | "name" + type: "email" | "text" key: string label: string required: boolean + min_length?: number + max_length?: number } | { type: "phone" @@ -31,79 +33,102 @@ module ProcessOut { label: string required: boolean available_values: Array<{ - key: string; + value: string; label: string; preselected: boolean }> - } & {} - - export type FormFieldResult = - | { - type: "email" | "name" - key: string - label: string - required: boolean } | { - type: "phone" + type: 'boolean' key: string label: string required: boolean - dialing_codes: Array<{ - region_code: string; - value: string; - name: string - }> - } - | { - type: "otp" - key: string - label: string - max_length: number - min_length: number - required: true - subtype: "digits" | "alphanumeric" - } - | { - type: 'single-select' - key: string - label: string - required: boolean - available_values: Array<{ - key: string; - label: string; - preselected: boolean - }> } & {} + // Helper type to make IntelliSense more readable + type Prettify = { + [K in keyof T]: T[K] + } & {} + + // Single conditional with multiple branches - much cleaner + type TransformFormField = + T extends { type: "phone" } + ? T & { dialing_codes: Array<{ region_code: string; value: string; name: string }> } + : T + + export type FormFieldResult = Prettify> + export type FormData = { type: 'form', parameters: { parameter_definitions: Array } } + + export type InstructionData = { + type: 'instruction', + instruction: + | { + type: 'message', + value: string, + label?: string, + } + | { + type: 'barcode', + subtype: 'qr', + value: string, + } + } - export type APIElements = Array> + export type APIElements = Array | InstructionData> + export type PaymentContext = { + invoice: APIInvoice, + payment_method: { + display_name: string + gateway_name: string + logo: { + light_url: { + raster: string + vector: string + } + dark_url: { + raster: string + vector: string + } + } + } + } export type APIInvoice = { currency: string, amount: string } - export type APISuccessResponse = { + interface APIResponseBase { + elements?: APIElements + redirect?: { + hint: string, + url: string, + } + } + + export interface APISuccessBase extends APIResponseBase { success: true, - state: "SUCCESS" | 'NEXT_STEP_REQUIRED', - elements: APIElements - invoice: APIInvoice, - gateway: object + state: "SUCCESS" | 'NEXT_STEP_REQUIRED' | 'PENDING' | 'REDIRECT', + } + + export interface APIRedirectBase extends APIResponseBase { + success: true, + state: 'REDIRECT', + redirect: { + hint: string, + url: string, + } } - export type APIValidationResponse = { + export interface APIValidationBase extends APIResponseBase { success: false, state: "VALIDATION_ERROR", - elements: APIElements, - invoice: APIInvoice, - gateway: object, error: { code: string, message: string, @@ -113,7 +138,6 @@ module ProcessOut { }> } } - export type APIFailureResponse = { success: false, state: "FAILURE", @@ -123,61 +147,117 @@ module ProcessOut { } } - type NetworkSuccessResponse = { - success: true, - state: "PENDING" | "SUCCESS" | 'NEXT_STEP_REQUIRED' - elements: APIElements - invoice: APIInvoice, - gateway: object - } + export type AuthorizationSuccessResponse = APISuccessBase & PaymentContext; + export type AuthorizationRedirectResponse = APIRedirectBase & PaymentContext; + export type AuthorizationValidationResponse = APIValidationBase & PaymentContext; - type NetworkValidationResponse = { + // Tokenization-specific response types (no PaymentContext) + export type TokenizationSuccessResponse = APISuccessBase; + export type TokenizationValidationResponse = APIValidationBase; + + interface NetworkValidationBase extends APIResponseBase { success: false, error_type: string, message: string; - elements: APIElements - invoice: APIInvoice, - gateway: object invalid_fields: Array<{ name: string, message: string }> } + type AuthorizationNetworkSuccessResponse = APISuccessBase & PaymentContext; + type TokenizationNetworkSuccessResponse = APISuccessBase; + + type AuthorizationNetworkValidationResponse = NetworkValidationBase & PaymentContext; + type TokenizationNetworkValidationResponse = NetworkValidationBase; + + type NetworkErrorResponse = { success: false, error_type: string, message: string; } - export type NetworkResponse = - | NetworkSuccessResponse - | NetworkValidationResponse + export type AuthorizationNetworkResponse = + | AuthorizationNetworkSuccessResponse + | AuthorizationNetworkValidationResponse | NetworkErrorResponse - export type APIOptions = { + export type TokenizationNetworkResponse = + | TokenizationNetworkSuccessResponse + | TokenizationNetworkValidationResponse + | NetworkErrorResponse + + export type APIOptions = { initialTimestamp?: number, + serviceRetries?: number hasConfirmedPending?: boolean, + hasReturnedFirstPending?: boolean, onSuccess?: (data: D) => void, onFailure?: (data: APIFailureResponse) => void, - onError?: (data: APIValidationResponse) => void, + onError?: (data: V) => void, } export interface APIRequest{ - (options: APIOptions): void + (options: APIOptions< + AuthorizationSuccessResponse | TokenizationSuccessResponse | AuthorizationRedirectResponse, + AuthorizationValidationResponse | TokenizationValidationResponse + >): void } - const MIN_15 = 1000 * 60 * 15; + const TIMEOUT = 1000; + let INITIAL_MAX_RETRIES = 0 + let POLLING_TIMEOUT_ID: number | null = null + let POLLING_CANCELLED = false // Add cancellation flag + + const isErrorResponse = (data: AuthorizationNetworkResponse | TokenizationNetworkResponse): data is NetworkErrorResponse => { + const hasInvalidFields = 'invalid_fields' in data; + const hasErrorType = 'error_type' in data; + const hasState = 'state' in data; + return ( + (hasState && (data as any).state === 'FAILED') || + (data.success === false && !hasInvalidFields && !(hasErrorType && data.error_type.startsWith('request.validation.'))) + ) + } - const isErrorResponse = (data: NetworkResponse): data is NetworkErrorResponse => { - return data.success === false && (!('invalid_fields' in data) && !data.error_type.startsWith('request.validation.')) + const isValidationResponse = (data: AuthorizationNetworkResponse): data is AuthorizationNetworkValidationResponse => { + return data.success === false && ('invalid_fields' in data || data.error_type.startsWith('request.validation.')); } - const isValidationResponse = (data: NetworkResponse): data is NetworkValidationResponse => { + const isTokenizationValidationResponse = (data: TokenizationNetworkResponse): data is TokenizationNetworkValidationResponse => { return data.success === false && ('invalid_fields' in data || data.error_type.startsWith('request.validation.')); } - const handleError = (request: string, data: NetworkErrorResponse, options: APIOptions) => { + const handleError = (request: string, data: NetworkErrorResponse, options: APIOptions) => { + if (!data.error_type) { + const isMismatch = (data as any).state === 'FAILED' && (data as any).success === true; + let message = `${request} failed`; + + if (isMismatch) { + message = `${request} failed, state and success are mismatched.`; + } + + ContextImpl.context.logger.error({ + host: window?.location?.host ?? '', + fileName: 'API.ts', + lineNumber: 208, + message, + category: 'APM - API' + }) + + const defaultError = { + success: false as const, + state: 'FAILURE' as const, + error: { + code: data.error_type, + message: data.message + } + }; + + options.onFailure?.(defaultError); + return + } + switch (data.error_type) { case 'request.route-not-found': ContextImpl.context.logger.error({ @@ -187,14 +267,17 @@ module ProcessOut { message: `${request} failed as route does not exist`, category: 'APM - API' }) - options.onFailure?.({ - success: false, - state: 'FAILURE', + + const routeNotFoundError = { + success: false as const, + state: 'FAILURE' as const, error: { code: 'processout-js.request.route-not-found', message: 'We were unable to connect to our API. Please contact support if you think this is an error.', } - }) + }; + + options.onFailure?.(routeNotFoundError); break; default: { ContextImpl.context.logger.error({ @@ -204,14 +287,17 @@ module ProcessOut { message: `${request} failed because of an error: ${data.message}`, category: 'APM - API' }) - options.onFailure?.({ - success: false, - state: 'FAILURE', + + const defaultError = { + success: false as const, + state: 'FAILURE' as const, error: { code: data.error_type, message: data.message } - }) + }; + + options.onFailure?.(defaultError); break; } } @@ -221,49 +307,69 @@ module ProcessOut { export class APIImpl { private constructor() {} - public static initialise(options: APIOptions) { - return this.get(ContextImpl.context.gatewayConfigurationId, options) + public static initialise(options: APIOptions) { + const context = ContextImpl.context; + const flow = context.flow; + const source = flow === 'authorization' ? context.customerTokenId : undefined; + + return this.post({ + gateway_configuration_id: context.gatewayConfigurationId, + source + }, { + ...options, + hasReturnedFirstPending: false, + }) } - public static getCurrentStep(options: APIOptions) { - return this.get(options) + public static getCurrentStep(options: APIOptions) { + const context = ContextImpl.context; + const flow = context.flow; + const source = flow === 'authorization' ? context.customerTokenId : undefined; + + return this.post({ + gateway_configuration_id: context.gatewayConfigurationId, + source, + }, options) } + public static sendFormData = Record>(formData: F) { - return (options: APIOptions) => this.post({ - gateway_configuration_id: ContextImpl.context.gatewayConfigurationId, - submit_data: { - parameters: formData - } - }, options) + return (options: APIOptions) => { + const context = ContextImpl.context; + const data ={ + gateway_configuration_id: context.gatewayConfigurationId, + submit_data: { parameters: formData } + }; + + return this.post(data, options) + } } - private static get(path: string): void; - private static get(options: APIOptions): void; - private static get(path: string, options: APIOptions): void; - private static get( - pathOrOptions: string | APIOptions, - options: APIOptions = {} + private static get( + pathOrOptions: string | APIOptions = '', + options: APIOptions = {} ): void { this.makeRequest('GET', pathOrOptions, {}, options); } - - private static post = Record>(data: T, path: string): void; - private static post = Record, D extends APISuccessResponse = APISuccessResponse>(data: T, options: APIOptions): void; - private static post = Record, D extends APISuccessResponse = APISuccessResponse>(data: T, path: string, options: APIOptions): void; - private static post = Record, D extends APISuccessResponse = APISuccessResponse>(data: T, pathOrOptions: string | APIOptions, options: APIOptions = {}) { + private static post = Record>( + data: T, + pathOrOptions: string | APIOptions = '', + options: APIOptions = {} + ) { this.makeRequest('POST', pathOrOptions, data, options); } - private static makeRequest = Record, D extends APISuccessResponse = APISuccessResponse>( + private static makeRequest = Record>( method: 'GET' | 'POST' | 'PUT' | 'DELETE', - pathOrOptions: string | APIOptions, + pathOrOptions: string | APIOptions, data: T = {} as T, - options: APIOptions = {} + options: APIOptions = {} ): void { let path: string; - let internalOptions: APIOptions = { + let internalOptions: APIOptions = { initialTimestamp: Date.now(), + serviceRetries: 5, + hasReturnedFirstPending: !!storage.get('pending.startTime'), ...options, }; @@ -276,32 +382,62 @@ module ProcessOut { }; } - const endpoint = ['invoices', ContextImpl.context.invoiceId, 'apm-payment', path] - .filter(part => !!part) - .join('/'); + if (INITIAL_MAX_RETRIES === 0) { + INITIAL_MAX_RETRIES = internalOptions.serviceRetries; + } + + const context = ContextImpl.context; + + // Build endpoint based on flow type + let endpoint = context.flow === 'authorization' + ? ['invoices', context.invoiceId, 'apm-payment', path].filter(part => !!part).join('/') + : ['customers', context.customerId, 'apm-tokens', context.customerTokenId, 'tokenize'].join('/'); + if (context.customerTokenId && context.flow === 'authorization' && method === 'GET') { + endpoint += `?source=${context.customerTokenId}` + } + ContextImpl.context.poClient.apiRequest( method, endpoint, data, - (apiResponse: NetworkResponse, req) => { + (apiResponse: AuthorizationNetworkResponse | TokenizationNetworkResponse) => { if (isErrorResponse(apiResponse)) { + INITIAL_MAX_RETRIES = 0; + + // Clear polling timeout since we have an error + if (POLLING_TIMEOUT_ID) { + window.clearTimeout(POLLING_TIMEOUT_ID); + POLLING_TIMEOUT_ID = null; + } + handleError(`${method} ${endpoint}`, apiResponse, internalOptions); return; } - if (isValidationResponse(apiResponse)) { - const result = this.transformResponse(apiResponse); - internalOptions.onError({ - success: false, - state: 'VALIDATION_ERROR', + // Handle validation responses based on flow type + const isValidation = context.flow === 'authorization' + ? isValidationResponse(apiResponse as AuthorizationNetworkResponse) + : isTokenizationValidationResponse(apiResponse as TokenizationNetworkResponse); + + if (isValidation) { + INITIAL_MAX_RETRIES = 0; + + // Clear polling timeout since we have a validation error + if (POLLING_TIMEOUT_ID) { + window.clearTimeout(POLLING_TIMEOUT_ID); + POLLING_TIMEOUT_ID = null; + } + + const result = this.transformResponse(apiResponse as any); + const errorData = { + success: false as const, + state: 'VALIDATION_ERROR' as const, elements: result.elements, - gateway: result.gateway, - invoice: result.invoice, error: { code: 'processout-js.apm.validation-error', message: 'Validation error', - invalid_fields: (apiResponse as NetworkValidationResponse).invalid_fields || Object.keys((apiResponse as any).error.parameters).reduce((acc, name) => { + invalid_fields: (apiResponse as any).invalid_fields || Object.keys((apiResponse as any).error?.parameters || {}).reduce((acc, name) => { acc.push({ name, message: (apiResponse as any).error.parameters[name].detail @@ -309,59 +445,139 @@ module ProcessOut { return acc; }, []), } - }) + }; + + // Add all payment fields (PaymentContext + payment data) + const errorWithPaymentData = this.addPaymentFields(errorData, apiResponse); + internalOptions.onError?.(errorWithPaymentData as any); return; } if (apiResponse.state === 'PENDING') { + // Reset cancellation flag when we get a PENDING response - this is when we need to start/resume polling + POLLING_CANCELLED = false; + if (internalOptions.initialTimestamp) { const currentTimestamp = Date.now(); const elapsedTime = currentTimestamp - internalOptions.initialTimestamp; - if (elapsedTime > MIN_15) { - internalOptions.onFailure?.({ - success: false, - state: 'FAILURE', + if (elapsedTime > ContextImpl.context.confirmation.timeout * 1000) { + INITIAL_MAX_RETRIES = 0; + + // Clear polling timeout since we're timing out + if (POLLING_TIMEOUT_ID) { + window.clearTimeout(POLLING_TIMEOUT_ID); + POLLING_TIMEOUT_ID = null; + } + + const timeoutError = { + success: false as const, + state: 'FAILURE' as const, error: { code: 'processout-js.apm.polling-reached', message: 'Timeout reached while polling for APM payment status', }, - }); + }; + + // Include payment data in timeout error + const timeoutErrorWithPaymentData = this.addPaymentFields(timeoutError, apiResponse); + internalOptions.onFailure?.(timeoutErrorWithPaymentData); return; } } - if (apiResponse.elements) { + // Return on first PENDING response OR anytime there are elements + const shouldReturn = !internalOptions.hasReturnedFirstPending || apiResponse.elements; + + if (shouldReturn) { + if (!internalOptions.hasReturnedFirstPending) { + internalOptions.hasReturnedFirstPending = true; + } + internalOptions.onSuccess?.(this.transformResponse(apiResponse)); - if (ContextImpl.context.requirePendingConfirmation && !internalOptions.hasConfirmedPending) { + if (ContextImpl.context.confirmation.requiresAction && !storage.get('pending.startTime')) { + INITIAL_MAX_RETRIES = 0; return } } - setTimeout(() => { - this.makeRequest(method, path, data, internalOptions); - }, 1000); + // Continue polling in background (only if not cancelled) + if (!POLLING_CANCELLED) { + POLLING_TIMEOUT_ID = window.setTimeout(() => { + // Double-check cancellation before continuing + if (!POLLING_CANCELLED) { + internalOptions.serviceRetries = INITIAL_MAX_RETRIES + this.getCurrentStep(internalOptions); + } + }, TIMEOUT); + } + return; + } + + INITIAL_MAX_RETRIES = 0; + + // Clear polling timeout since we're done + if (POLLING_TIMEOUT_ID) { + window.clearTimeout(POLLING_TIMEOUT_ID); + POLLING_TIMEOUT_ID = null; + } + + if (apiResponse.state === 'SUCCESS' && !ContextImpl.context.success.enabled) { + storage.remove('pending.startTime') + ContextImpl.context.events.emit('success', { trigger: 'immediate' }); return; } + if (apiResponse.state === 'NEXT_STEP_REQUIRED' && apiResponse.redirect) { + internalOptions.onSuccess?.(this.transformResponse( + { + ...apiResponse, + state: 'REDIRECT', + } + )); + return + } + internalOptions.onSuccess?.(this.transformResponse(apiResponse)); return; }, - (req, e, errorCode) => { - internalOptions.onFailure?.({ - success: false, - state: 'FAILURE', + (req, _, errorCode) => { + if ((req.status === 0 || req.status > 500) && internalOptions.serviceRetries > 0) { + setTimeout(() => { + internalOptions.serviceRetries--; + this.makeRequest(method, pathOrOptions, data, internalOptions) + }, TIMEOUT * ((INITIAL_MAX_RETRIES - internalOptions.serviceRetries) + 1)); + return + } + + INITIAL_MAX_RETRIES = 0; + + // Clear polling timeout since we have a network error + if (POLLING_TIMEOUT_ID) { + window.clearTimeout(POLLING_TIMEOUT_ID); + POLLING_TIMEOUT_ID = null; + } + + const networkError = { + success: false as const, + state: 'FAILURE' as const, error: req.response || { code: errorCode || 'processout-js.internal-server-error', message: 'Internal server error. Please contact support.', }, - }); + }; + + // Include payment data in network error if available + const networkErrorWithPaymentData = req.response + ? this.addPaymentFields(networkError, req.response) + : networkError; + internalOptions.onFailure?.(networkErrorWithPaymentData); } ); } - private static transformResponse = (response: NetworkSuccessResponse): D => { - let result = response + private static transformResponse = (response: AuthorizationNetworkSuccessResponse | TokenizationNetworkSuccessResponse): D => { + let result = { ...response } as any if (result.elements) { result.elements = response.elements.map(element => { @@ -393,7 +609,25 @@ module ProcessOut { }) } - return result as D + return this.addPaymentFields(result, response) + } + + public static cancelPolling(): void { + POLLING_CANCELLED = true; // Set cancellation flag + if (POLLING_TIMEOUT_ID) { + window.clearTimeout(POLLING_TIMEOUT_ID); + POLLING_TIMEOUT_ID = null; + } + INITIAL_MAX_RETRIES = 0; + } + + public static addPaymentFields = (result: T, response: any): T => { + if ('invoice' in response && 'payment_method' in response) { + (result as any).invoice = response.invoice; + (result as any).payment_method = response.payment_method; + } + + return result } } } diff --git a/src/apm/Context.ts b/src/apm/Context.ts index 2211abed..9ef4adde 100644 --- a/src/apm/Context.ts +++ b/src/apm/Context.ts @@ -1,19 +1,44 @@ module ProcessOut { export type TokenizationFlowData = { flow: 'tokenization', - tokenizationId: string + customerId: string, + customerTokenId: string + invoiceId?: never } export type AuthorizationFlowData = { flow: 'authorization', - tokenizationId?: never + invoiceId: `iv_${string}` + customerId?: never, + customerTokenId?: string, } export type FlowData = { gatewayConfigurationId: `gway_conf_${string}` - invoiceId: `iv_${string}` - requirePendingConfirmation?: boolean - initialData?: Partial + initialData: Partial + /** Whether user can cancel the payment (default: true) */ + allowCancelation: boolean + /** Payment confirmation configuration */ + confirmation: { + /** Whether user action is required for pending payments (default: false) */ + requiresAction: boolean + /** Timeout in seconds to wait for payment confirmation (default: 900 e.g. 15 minutes) */ + timeout: number + /** Whether user can cancel the payment during confirmation (default: true) */ + allowCancelation?: boolean + } + + /** Success screen configuration */ + success: { + /** Whether to show success screen (default: true) */ + enabled: boolean + /** Duration in seconds when auto-dismissing (requiresAction: false) (default: 3) */ + autoDismissDuration: number + /** Duration in seconds when manual dismissal required (requiresAction: true) (default: 60) */ + manualDismissDuration: number + /** Whether user must take action to dismiss success screen (default: false) */ + requiresAction: boolean + } } export type TokenizationUserData = TokenizationFlowData & FlowData @@ -22,11 +47,12 @@ module ProcessOut { export type TokenizationUserOptions = Omit export type AuthorizationUserOptions = Omit - export type APMUserData = TokenizationUserData | AuthorizationUserData +export type APMUserData = TokenizationUserData | AuthorizationUserData - export type APMContext = APMUserData & { + export type APMContext = { } & APMUserData & { logger: { error(message: Omit[0], 'stack'>): void; + warn(message: Omit[0], 'stack'>): void; } events: APMEventsImpl, poClient: ProcessOut, diff --git a/src/apm/Page.ts b/src/apm/Page.ts index 2cb20b3a..1a3bb9ab 100644 --- a/src/apm/Page.ts +++ b/src/apm/Page.ts @@ -2,39 +2,79 @@ module ProcessOut { export interface APMPage { render(view: V, props?: ExtractViewProps): void load(request: APIRequest): void + loadScript(name: string, path: string, callback: (error?: Error) => void): void cleanUp(): void } export class APMPageImpl implements APMPage { - public wrapper: Element - private shadow: ShadowRoot | Document - private state: 'SUCCESS' | 'PENDING' | 'NEXT_STEP_REQUIRED' | 'VALIDATION_ERROR' | 'UNKNOWN' + private hostElement: Element; + private mainContentWrapper: HTMLDivElement | null = null; + private currentRoot: ShadowRoot | Document | null = null; + private currentView: APMView | null = null; + private isReady: boolean = false; + private pendingOperations: Array<() => void> = []; + private loadedScripts: Map = new Map(); + private loadingScripts: Map void>> = new Map(); + + private state: 'SUCCESS' | 'PENDING' | 'NEXT_STEP_REQUIRED' | 'REDIRECT' | 'VALIDATION_ERROR' | 'UNKNOWN' constructor(container: Element) { + this.hostElement = container; this.createWrapper(container) } render(View: V, props?: ExtractViewProps) { - this.setStylesheet(this.shadow) - const view = new View(this.wrapper, this.shadow, props) + if (!this.isReady) { + // Queue the render operation until the page is ready + this.pendingOperations.push(() => this.render(View, props)); + return; + } + + if (this.currentView) { + this.currentView.unmount() + } + + const view = new View(this.mainContentWrapper, this.currentRoot, props) view.mount() + this.currentView = view } - load(request: R) { + load(request: R, callback?: (err?: any, state?: string) => void) { + if (!this.isReady) { + // Queue the load operation until the page is ready + this.pendingOperations.push(() => this.load(request)); + return; + } + (request.bind(APIImpl) as APIRequest)({ - hasConfirmedPending: ContextImpl.context.requirePendingConfirmation + hasConfirmedPending: ContextImpl.context.confirmation.requiresAction ? this.state === "PENDING" : true, onSuccess: ({ elements, ...config }) => { this.state = config.state + callback?.(null, this.state); + + if (config.state === 'REDIRECT') { + ContextImpl.context.page.render(APMViewRedirect, { elements, config: config as APIRedirectBase & Partial }) + return + } - if (elements && elements.length > 0) { + if (config.state === 'NEXT_STEP_REQUIRED') { ContextImpl.context.page.render(APMViewNextSteps, { elements, config }) return } + + if (config.state === 'SUCCESS') { + ContextImpl.context.page.render(APMViewSuccess, { elements, config }) + } + + if (config.state === 'PENDING') { + ContextImpl.context.page.render(APMViewPending, { elements, config }) + } }, onError: ({ elements, ...config }) => { this.state = config.state + callback?.(config.error); ContextImpl.context.page.render(APMViewNextSteps, { elements, config }) }, onFailure: data => { @@ -52,12 +92,16 @@ module ProcessOut { code, message, }: { message: string, title: string, code?: string, }) { - ContextImpl.context.events.emit("critical-failure", { - code: code || 'processout-js.internal-error', - message, + ContextImpl.context.events.emit("failure", { + failure: { + code: code || 'processout-js.internal-error', + message: message || "An unexpected error occurred. We're working to fix this issue, please check back later or contact support if you need assistance.", + }, + paymentState: this.state }) + ContextImpl.context.page.render(APMViewError, { - title, + title: "Unable to connect", message: "An unexpected error occurred. We're working to fix this issue, please check back later or contact support if you need assistance.", hideRefresh: true }) @@ -68,12 +112,79 @@ module ProcessOut { } cleanUp() { - if (!this.wrapper) { + if (!this.hostElement.firstChild) { return } - this.wrapper.remove() - this.shadow.parentElement.shadowRoot.removeChild(this.shadow) + this.hostElement.removeChild(this.hostElement.firstChild); + } + + loadScript(name: string, path: string, callback?: (error?: Error) => void): void { + // Check if script is already loaded + if (this.loadedScripts.get(name)) { + callback?.(); + return; + } + + // Check if script is currently being loaded + if (this.loadingScripts.has(name)) { + const callbacks = this.loadingScripts.get(name)!; + + for (var i = 0; i < callbacks.length; i++) { + if (callbacks[i].toString() === callback.toString()) { + return; + } + } + + callbacks.push(callback); + return; + } + + // Check if script already exists in the document + if (document.querySelector(`script[src*="${name}"]`)) { + this.loadedScripts.set(name, true); + callback?.(); + return; + } + + // Initialize callback queue for this script + this.loadingScripts.set(name, [callback || (() => {})]); + + // Create and load the script + const script = document.createElement('script'); + script.src = path.startsWith('https://') ? path : ContextImpl.context.poClient.endpoint("js", path); + + script.onload = () => { + this.loadedScripts.set(name, true); + const callbacks = this.loadingScripts.get(name) || []; + this.loadingScripts.delete(name); + + // Call all waiting callbacks + for (var i = 0; i < callbacks.length; i++) { + callbacks[i](); + } + }; + + script.onerror = () => { + const error = new Error(`Failed to load script: ${name}`); + const callbacks = this.loadingScripts.get(name) || []; + this.loadingScripts.delete(name); + + // Call all waiting callbacks with error + for (var i = 0; i < callbacks.length; i++) { + callbacks[i](error); + } + }; + + // Determine where to append the script based on current context + const targetDocument = this.currentRoot instanceof Document ? this.currentRoot : document; + targetDocument.head.appendChild(script); + } + + private executePendingOperations() { + // Execute all queued operations now that the page is ready + const operations = this.pendingOperations.splice(0); // Clear the queue + operations.forEach(operation => operation()); } private _getDeepActiveElement(root) { @@ -102,64 +213,149 @@ module ProcessOut { private createWrapper(container: Element) { - if (this.wrapper) { - return; - } - - const supportsShadowDOM = (() => { - return !!(Element.prototype.attachShadow); - })(); + this.cleanUp() + // --- Determine if Shadow DOM is supported --- + const supportsShadowDOM = !!(Element.prototype.attachShadow); + // --- Create New Wrapper based on support --- if (!supportsShadowDOM) { + // Fallback: Use an iframe if Shadow DOM is not supported const iframe = document.createElement('iframe'); iframe.setAttribute('frameBorder', '0'); iframe.style.width = '100%'; iframe.style.height = '400px'; + iframe.title = 'Content Wrapper'; // Good practice for accessibility - container.appendChild(iframe); + container.appendChild(iframe); // Append iframe directly to the user's container - const doc = iframe.contentDocument ?? iframe.contentWindow?.document; + // Setup iframe content after it's loaded to avoid race conditions + const setupIframeContent = () => { + const doc = iframe.contentDocument ?? iframe.contentWindow?.document; - if (doc) { - if (!doc.body) { - doc.open(); - doc.write(``); - doc.close(); - } + if (doc) { + // Ensure the iframe has a basic HTML structure if it's not fully loaded + if (!doc.body) { + doc.open(); + doc.write(``); + doc.close(); + } + + // Inherit fonts from parent document instead of loading Work Sans + this.inheritFontsFromParent(doc); - doc.head.innerHTML = ''; + // Apply component-specific stylesheet within the iframe's document + this.setStylesheet(doc); - this.setStylesheet(doc); + // Create the main content wrapper div inside the iframe's body + this.mainContentWrapper = doc.createElement('div'); + this.mainContentWrapper.className = 'main'; + doc.body.appendChild(this.mainContentWrapper); - this.wrapper = doc.createElement('div'); - this.wrapper.className = 'main'; + this.currentRoot = doc; // In iframe case, this holds the iframe's Document - doc.body.appendChild(this.wrapper) + // Mark page as ready and execute any pending operations + this.isReady = true; + this.executePendingOperations(); + } + }; + + // Check if iframe is already loaded, otherwise wait for load event + if (iframe.contentDocument && iframe.contentDocument.readyState === 'complete') { + setupIframeContent(); + } else { + iframe.addEventListener('load', setupIframeContent, { once: true }); } - this.shadow = doc; return; } - document.head.innerHTML += ''; + // 1. Create a new host element for the Shadow DOM + // This element will be appended to the user-provided container. + const newShadowHost = document.createElement('div'); + container.appendChild(newShadowHost); // Append the host element to the user's container - const shadow = container.attachShadow({ mode: 'open' }) - this.setStylesheet(shadow) + // 2. Attach Shadow DOM to this newly created host element + const shadowRoot = newShadowHost.attachShadow({ mode: 'open' }); + this.currentRoot = shadowRoot; // Store reference to the ShadowRoot - this.wrapper = document.createElement("div") - this.wrapper.setAttribute('class', 'main') + // 3. Ensure Work Sans is loaded if no custom fonts are detected + this.ensureWorkSansLoaded(); - shadow.appendChild(this.wrapper) - - this.shadow = shadow; - } + // 4. Apply component-specific stylesheet within the Shadow DOM + this.setStylesheet(shadowRoot); + // 5. Create the main content wrapper div inside the Shadow DOM + this.mainContentWrapper = document.createElement("div"); + this.mainContentWrapper.setAttribute('class', 'main'); + shadowRoot.appendChild(this.mainContentWrapper); + // For Shadow DOM, mark as ready immediately since it's synchronous + this.isReady = true; + this.executePendingOperations(); + } private setStylesheet(shadow: ShadowRoot | Document) { const stylesheet = ThemeImpl.instance.createStyles(); injectStyleTag(shadow, stylesheet) } + + private inheritFontsFromParent(doc: Document) { + // Check if theme fontFamily was explicitly set by user + const hasCustomFonts = this.hasCustomFonts(); + + if (hasCustomFonts) { + // Copy all font-related stylesheets from parent document + const fontLinks = document.head.querySelectorAll('link[rel="stylesheet"], link[rel="preconnect"]'); + + fontLinks.forEach(link => { + const href = link.getAttribute('href'); + if (href && ( + href.includes('fonts.googleapis.com') || + href.includes('fonts.gstatic.com') || + href.includes('font') + )) { + const clonedLink = link.cloneNode(true) as HTMLLinkElement; + doc.head.appendChild(clonedLink); + } + }); + } else { + // Load default Work Sans font + if (!document.head.querySelector('link[href*="Work+Sans"]')) { + const fontLink1 = document.createElement('link'); fontLink1.rel = 'preconnect'; fontLink1.href = 'https://fonts.googleapis.com'; document.head.appendChild(fontLink1); + const fontLink2 = document.createElement('link'); fontLink2.rel = 'preconnect'; fontLink2.href = 'https://fonts.gstatic.com'; fontLink2.crossOrigin = 'anonymous'; document.head.appendChild(fontLink2); + const fontLink3 = document.createElement('link'); fontLink3.href = 'https://fonts.googleapis.com/css2?family=Work+Sans:wght@100..900&display=swap'; fontLink3.rel = 'stylesheet'; document.head.appendChild(fontLink3); + } + + // Copy Work Sans to iframe + const workSansLink = document.head.querySelector('link[href*="Work+Sans"]'); + if (workSansLink) { + const clonedLink = workSansLink.cloneNode(true) as HTMLLinkElement; + doc.head.appendChild(clonedLink); + } + } + } + + private hasCustomFonts(): boolean { + // Check if theme fontFamily was explicitly set by user + const themeFontFamily = ThemeImpl.instance.get('fontFamily'); + const defaultFontFamily = '"Work sans", Arial, sans-serif'; + + return themeFontFamily !== defaultFontFamily; + } + + private ensureWorkSansLoaded(): void { + // Check if theme fontFamily was explicitly set by user + const hasCustomFonts = this.hasCustomFonts(); + + if (!hasCustomFonts) { + // Load default Work Sans font if not already loaded + if (!document.head.querySelector('link[href*="Work+Sans"]')) { + const fontLink1 = document.createElement('link'); fontLink1.rel = 'preconnect'; fontLink1.href = 'https://fonts.googleapis.com'; document.head.appendChild(fontLink1); + const fontLink2 = document.createElement('link'); fontLink2.rel = 'preconnect'; fontLink2.href = 'https://fonts.gstatic.com'; fontLink2.crossOrigin = 'anonymous'; document.head.appendChild(fontLink2); + const fontLink3 = document.createElement('link'); fontLink3.href = 'https://fonts.googleapis.com/css2?family=Work+Sans:wght@100..900&display=swap'; fontLink3.rel = 'stylesheet'; document.head.appendChild(fontLink3); + } + } + } } } diff --git a/src/apm/StateManager.ts b/src/apm/StateManager.ts new file mode 100644 index 00000000..32c55540 --- /dev/null +++ b/src/apm/StateManager.ts @@ -0,0 +1,697 @@ +module ProcessOut { + /** + * Global State Manager for APM Components + * + * Provides a centralized state management system for stateful components + * that need to trigger re-renders when their state changes. + * + * Features: + * - IE 11 compatible (no modern ES6+ features) + * - Unique IDs for component instances + * - Integration with APMViewImpl for triggering re-renders + * - Component lifecycle management + * - State persistence across re-renders + */ + + // IE 11 compatible unique ID generator + let componentIdCounter = 0; + + export function generateComponentId(prefix = 'comp'): string { + return `${prefix}-${Date.now()}-${++componentIdCounter}`; + } + + interface ComponentState { + id: string; + data: any; + view: APMViewImpl | null; + subscriptions: Array<(state: any) => void>; + } + + interface StateManagerOptions { + // Optional cleanup callback when component is removed + onDestroy?: (id: string, state: any) => void; + } + + export class StateManager { + private static instance: StateManager | null = null; + private componentStates: { [id: string]: ComponentState } = {}; + private viewComponents: { [viewId: string]: string[] } = {}; // Maps view to component IDs + private options: StateManagerOptions; + + // Batching system for state updates (IE 11 compatible) + private hasPendingUpdates: boolean = false; + private activeView: APMViewImpl | null = null; // The single active view that needs re-rendering + private isBatchScheduled: boolean = false; + private pendingCallbacks: Array<() => void> = []; // Post-render callbacks + + private constructor(options: StateManagerOptions = {}) { + this.options = options; + } + + /** + * Singleton instance for global state management + */ + static getInstance(options?: StateManagerOptions): StateManager { + if (!StateManager.instance) { + StateManager.instance = new StateManager(options); + } + return StateManager.instance; + } + + /** + * Register a component with the state manager + * @param id - Unique component ID + * @param initialState - Initial state data + * @param view - Parent view instance (optional) + * @returns Component state object + */ + registerComponent(id: string, initialState: T, view?: APMViewImpl): ComponentState { + // If component already exists, return existing state + if (this.componentStates[id]) { + // Update the view reference if provided + if (view) { + this.componentStates[id].view = view; + this.linkComponentToView(id, view); + } + return this.componentStates[id]; + } + + // Create new component state + const componentState: ComponentState = { + id, + data: initialState, + view: view || null, + subscriptions: [] + }; + + this.componentStates[id] = componentState; + + // Link component to view if provided + if (view) { + this.linkComponentToView(id, view); + } + + return componentState; + } + + /** + * Get component state by ID + */ + getComponentState(id: string): T | null { + const component = this.componentStates[id]; + return component ? component.data : null; + } + + /** + * Update component state and schedule batched re-renders + * @param id - Component ID + * @param newState - New state data or updater function + * @param forceUpdate - Whether to force update even if state hasn't changed + */ + updateComponentState( + id: string, + newState: T | ((prevState: T) => T), + forceUpdate = false + ): void { + const component = this.componentStates[id]; + if (!component) { + console.warn(`Component with ID ${id} not found`); + return; + } + + // Calculate new state + const updatedState = typeof newState === 'function' + ? (newState as (prevState: T) => T)(component.data) + : newState; + + // Check if state actually changed (shallow comparison) + const stateChanged = forceUpdate || !this.shallowEqual(component.data, updatedState); + + if (stateChanged) { + component.data = updatedState; + + // Queue subscription notifications for after DOM update + this.pendingCallbacks.push(() => { + for (let i = 0; i < component.subscriptions.length; i++) { + try { + component.subscriptions[i](updatedState); + } catch (error) { + console.error('Error in state subscription:', error); + } + } + }); + + // Add to batch for re-rendering + this.hasPendingUpdates = true; + if (component.view) { + this.activeView = component.view; + } + + // Schedule batch processing if not already scheduled + this.scheduleBatchUpdate(); + } + } + + /** + * Schedule a batched update using requestAnimationFrame + * This ensures all state updates in a single frame are batched together + */ + private scheduleBatchUpdate(): void { + if (this.isBatchScheduled) { + return; // Already scheduled + } + + this.isBatchScheduled = true; + + // Use requestAnimationFrame to batch updates, with fallback for IE 11 + const scheduleFunction = (typeof requestAnimationFrame !== 'undefined') + ? requestAnimationFrame + : function(callback: () => void) { setTimeout(callback, 16); }; + + scheduleFunction(() => { + this.processBatchUpdate(); + }); + } + + /** + * Process all pending state updates and trigger view re-render + * Since there's only one active view, this is much simpler + */ + private processBatchUpdate(): void { + this.isBatchScheduled = false; + + // Check if there are any pending updates + if (!this.hasPendingUpdates) { + return; // Nothing to update + } + + // Clear pending updates + this.hasPendingUpdates = false; + + // Re-render the active view + if (this.activeView && typeof this.activeView.forceUpdate === 'function') { + try { + this.activeView.forceUpdate(); + } catch (error) { + console.error('Error during batched view update:', error); + } + } + + // Clear active view reference + this.activeView = null; + + // Process callbacks after DOM update using requestAnimationFrame + const callbacks = this.pendingCallbacks.slice(); + this.pendingCallbacks.length = 0; + + if (callbacks.length > 0) { + const scheduleFunction = (typeof requestAnimationFrame !== 'undefined') + ? requestAnimationFrame + : function(callback: () => void) { setTimeout(callback, 16); }; + + scheduleFunction(() => { + for (let i = 0; i < callbacks.length; i++) { + try { + callbacks[i](); + } catch (error) { + console.error('Error in post-render callback:', error); + } + } + }); + } + } + + /** + * Subscribe to component state changes + * @param id - Component ID + * @param callback - Callback function to call when state changes + * @returns Unsubscribe function + */ + subscribe(id: string, callback: (state: T) => void): () => void { + const component = this.componentStates[id]; + if (!component) { + console.warn(`Component with ID ${id} not found`); + return function() {}; + } + + component.subscriptions.push(callback); + + // Return unsubscribe function + return function() { + const index = component.subscriptions.indexOf(callback); + if (index > -1) { + component.subscriptions.splice(index, 1); + } + }; + } + + /** + * Watch for state changes (overloaded method) + * @param id - Component ID + * @param fieldOrCallback - Field name or callback function + * @param callback - Callback function (when watching a field) + * @returns Unsubscribe function + */ + watch(id: string, callback: (state: T) => void): () => void; + watch(id: string, field: K, callback: (newValue: T[K]) => void): () => void; + watch( + id: string, + fieldOrCallback: K | ((state: T) => void), + callback?: (newValue: T[K]) => void + ): () => void { + const component = this.componentStates[id]; + if (!component) { + console.warn(`Component with ID ${id} not found`); + return function() {}; + } + + // If callback is provided, we're watching a specific field + if (callback && typeof fieldOrCallback === 'string') { + const field = fieldOrCallback as K; + let prevValue = component.data[field]; + + const watcher = function(newState: T) { + const newValue = newState[field]; + if (newValue !== prevValue) { + callback(newValue); + prevValue = newValue; + } + }; + + component.subscriptions.push(watcher); + + // Return unsubscribe function + return function() { + const index = component.subscriptions.indexOf(watcher); + if (index > -1) { + component.subscriptions.splice(index, 1); + } + }; + } else { + // We're watching the entire state + const stateCallback = fieldOrCallback as (state: T) => void; + component.subscriptions.push(stateCallback); + + // Return unsubscribe function + return function() { + const index = component.subscriptions.indexOf(stateCallback); + if (index > -1) { + component.subscriptions.splice(index, 1); + } + }; + } + } + + /** + * Remove component from state manager + * @param id - Component ID + */ + destroyComponent(id: string): void { + const component = this.componentStates[id]; + if (!component) { + return; + } + + // Call destroy callback if provided + if (this.options.onDestroy) { + try { + this.options.onDestroy(id, component.data); + } catch (error) { + console.error('Error in destroy callback:', error); + } + } + + // Remove from view mapping + if (component.view) { + this.unlinkComponentFromView(id, component.view); + } + + // Clear subscriptions + component.subscriptions.length = 0; + + // Remove from state + delete this.componentStates[id]; + } + + /** + * Clean up all components associated with a view + * @param view - View instance + */ + destroyViewComponents(view: APMViewImpl): void { + const viewId = this.getViewId(view); + const componentIds = this.viewComponents[viewId]; + + if (componentIds) { + // Create a copy of the array to avoid modification during iteration + const idsToDestroy = componentIds.slice(); + for (let i = 0; i < idsToDestroy.length; i++) { + this.destroyComponent(idsToDestroy[i]); + } + } + + // Clean up component ID tracking for this view + delete viewComponentIds[viewId]; + } + + /** + * Get all component IDs for a view + * @param view - View instance + */ + getViewComponentIds(view: APMViewImpl): string[] { + const viewId = this.getViewId(view); + return this.viewComponents[viewId] || []; + } + + /** + * Link a component to a view for lifecycle management + */ + private linkComponentToView(componentId: string, view: APMViewImpl): void { + const viewId = this.getViewId(view); + + if (!this.viewComponents[viewId]) { + this.viewComponents[viewId] = []; + } + + // Add component ID if not already present + if (this.viewComponents[viewId].indexOf(componentId) === -1) { + this.viewComponents[viewId].push(componentId); + } + } + + /** + * Unlink a component from a view + */ + private unlinkComponentFromView(componentId: string, view: APMViewImpl): void { + const viewId = this.getViewId(view); + const componentIds = this.viewComponents[viewId]; + + if (componentIds) { + const index = componentIds.indexOf(componentId); + if (index > -1) { + componentIds.splice(index, 1); + } + + // Clean up empty view entry + if (componentIds.length === 0) { + delete this.viewComponents[viewId]; + } + } + } + + /** + * Get a unique ID for a view instance + */ + private getViewId(view: APMViewImpl): string { + return getViewId(view); + } + + /** + * IE 11 compatible shallow equality check + */ + private shallowEqual(obj1: any, obj2: any): boolean { + if (obj1 === obj2) { + return true; + } + + if (obj1 == null || obj2 == null) { + return false; + } + + if (typeof obj1 !== 'object' || typeof obj2 !== 'object') { + return false; + } + + const keys1 = Object.keys(obj1); + const keys2 = Object.keys(obj2); + + if (keys1.length !== keys2.length) { + return false; + } + + for (let i = 0; i < keys1.length; i++) { + const key = keys1[i]; + if (obj1[key] !== obj2[key]) { + return false; + } + } + + return true; + } + } + + /** + * View Context for automatic view detection + */ + interface ViewContext { + currentView: APMViewImpl | null; + componentCallOrder: number; + isFirstRender: boolean; + } + + let viewContext: ViewContext = { + currentView: null, + componentCallOrder: 0, + isFirstRender: true + }; + + // Track component IDs by view to ensure stability across renders + const viewComponentIds: { [viewId: string]: string[] } = {}; + + /** + * Set the current view context (called by views during render) + * @param view - Current view instance + */ + export function setCurrentViewContext(view: APMViewImpl | null): void { + const prevView = viewContext.currentView; + viewContext.currentView = view; + viewContext.componentCallOrder = 0; // Reset call order for new render + + // Reset collision counters for every render cycle + if (view) { + const viewId = getViewId(view); + viewCollisionCounters[viewId] = {}; + + // Check if this is the first render of this view + viewContext.isFirstRender = !viewComponentIds[viewId]; + if (viewContext.isFirstRender) { + viewComponentIds[viewId] = []; + } + } + } + + /** + * Get the current view context + */ + export function getCurrentViewContext(): ViewContext { + return viewContext; + } + + /** + * Generate stable component ID based on call order within render + * This ensures IDs remain stable across re-renders by using the same order + */ + function generateAutoComponentId(): string { + const context = getCurrentViewContext(); + + if (!context.currentView) { + // Fallback for components rendered outside of view context + return `no-view-comp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + } + + const viewId = getViewId(context.currentView); + const callPosition = context.componentCallOrder++; + + // On first render, generate and store new IDs + if (context.isFirstRender) { + const componentId = `${viewId}-comp-${callPosition}`; + viewComponentIds[viewId][callPosition] = componentId; + return componentId; + } + + // On subsequent renders, reuse the same IDs from first render + const existingComponentIds = viewComponentIds[viewId]; + if (existingComponentIds && existingComponentIds[callPosition]) { + return existingComponentIds[callPosition]; + } + + // Fallback for unexpected call order changes (shouldn't happen in normal usage) + console.warn(`Component call order changed in view ${viewId}. This may cause state loss.`); + const fallbackId = `${viewId}-comp-${callPosition}-fallback`; + existingComponentIds[callPosition] = fallbackId; + return fallbackId; + } + + /** + * Get view ID helper function (moved up for reuse) + */ + function getViewId(view: APMViewImpl): string { + // Use the view's container element as a unique identifier + if (view.container && view.container.id) { + return view.container.id; + } + + // Fallback: create a unique ID based on the view object + if (!(view as any).__stateManagerId) { + (view as any).__stateManagerId = generateComponentId('view'); + } + + return (view as any).__stateManagerId; + } + + // Track which component instances have claimed which collision numbers + const stableCollisionMap: { [baseHash: string]: Set } = {}; + + // Track collision counters per view for current render cycle + const viewCollisionCounters: { [viewId: string]: { [baseHash: string]: number } } = {}; + + /** + * Generate content-based component ID using props signature with stable collision detection + * @param signature - Component signature object + * @returns Hashed component ID that's stable across position changes + */ + function generateContentBasedComponentId(signature: Record): string { + const context = getCurrentViewContext(); + const viewId = getViewId(context.currentView); + + // Generate base hash from content only (position-independent) + const baseHash = simpleHash(JSON.stringify({ + __view: viewId, + ...signature + })); + + // Initialize tracking if needed + if (!viewCollisionCounters[viewId]) { + viewCollisionCounters[viewId] = {}; + } + if (!stableCollisionMap[baseHash]) { + stableCollisionMap[baseHash] = new Set(); + } + + const viewCounters = viewCollisionCounters[viewId]; + const existingIds = stableCollisionMap[baseHash]; + + // Check if this is the first occurrence in current render + if (viewCounters[baseHash] === undefined) { + viewCounters[baseHash] = 0; + + // If no collision numbers have been assigned yet, use base hash + if (existingIds.size === 0) { + existingIds.add(baseHash); + return baseHash; + } + + // Find the lowest available collision number + let collisionNum = 1; + while (existingIds.has(`${baseHash}-${collisionNum}`)) { + collisionNum++; + } + + const newId = `${baseHash}-${collisionNum}`; + existingIds.add(newId); + return newId; + } else { + // Subsequent occurrence in current render - increment counter + viewCounters[baseHash]++; + + // Find the next available collision number + let collisionNum = viewCounters[baseHash]; + let candidateId = collisionNum === 0 ? baseHash : `${baseHash}-${collisionNum}`; + + // If this collision number is already taken, find next available + while (existingIds.has(candidateId)) { + collisionNum++; + candidateId = `${baseHash}-${collisionNum}`; + } + + existingIds.add(candidateId); + return candidateId; + } + } + + /** + * Hook for components to use stateful behavior (simplified API) + * @param initialState - Initial state + * @param signature - Optional component signature for content-based ID + * @returns Object with state, setState, and watch functions + */ + export function useComponentState( + initialState: T, + signature?: Record + ): { + state: T; + setState: (newState: T | ((prevState: T) => T)) => void; + watch: { + (callback: (state: T) => void): () => void; + (field: K, callback: (newValue: T[K]) => void): () => void; + }; + } { + const stateManager = StateManager.getInstance(); + + // Generate component ID - use content-based if signature provided, fallback to call order + const componentId = signature + ? generateContentBasedComponentId(signature) + : generateAutoComponentId(); + + // Get current view from context + const currentView = getCurrentViewContext().currentView; + + // Register component if not already registered + stateManager.registerComponent(componentId, initialState, currentView); + + // Get current state + const currentState = stateManager.getComponentState(componentId) || initialState; + + // Create setState function + const setState = function(newState: T | ((prevState: T) => T)) { + stateManager.updateComponentState(componentId, newState); + }; + + // Create watch function with overloads + const watch = function( + fieldOrCallback: K | ((state: T) => void), + callback?: (newValue: T[K]) => void + ): () => void { + if (callback) { + return stateManager.watch(componentId, fieldOrCallback as K, callback); + } else { + return stateManager.watch(componentId, fieldOrCallback as (state: T) => void); + } + }; + + return { + state: currentState, + setState: setState, + watch: watch + }; + } + + /** + * Hook for components to subscribe to state changes + * @param id - Component ID + * @param callback - Callback function + * @returns Unsubscribe function + */ + export function useStateSubscription( + id: string, + callback: (state: T) => void + ): () => void { + const stateManager = StateManager.getInstance(); + return stateManager.subscribe(id, callback); + } + + /** + * Utility to get the global state manager instance + */ + export function getStateManager(): StateManager { + return StateManager.getInstance(); + } + + /** + * Utility to clean up component state + * @param id - Component ID + */ + export function cleanupComponentState(id: string): void { + const stateManager = StateManager.getInstance(); + stateManager.destroyComponent(id); + } +} \ No newline at end of file diff --git a/src/apm/Storage.ts b/src/apm/Storage.ts new file mode 100644 index 00000000..17b3738a --- /dev/null +++ b/src/apm/Storage.ts @@ -0,0 +1,49 @@ +module ProcessOut { + type StorageKey = + | 'pending.startTime' + class Storage { + private static instance: Storage; + + private constructor() {} + + public static getInstance(): Storage { + if (!Storage.instance) { + Storage.instance = new Storage(); + } + return Storage.instance; + } + + public set(key: StorageKey, value: V): void { + sessionStorage.setItem(this.getKey(key), JSON.stringify(value)); + } + + public get(key: StorageKey, defaultValue?: V): V { + const value = sessionStorage.getItem(this.getKey(key)); + + + if (value === null && defaultValue === undefined) { + return null as V; + } + + if (value === null) { + this.set(key, defaultValue); + return defaultValue; + } + + return JSON.parse(value); + } + + public remove(key: StorageKey): void { + sessionStorage.removeItem(this.getKey(key)); + } + + private getKey(key: string): string { + const { context } = ContextImpl; + const id = context.invoiceId || `${context.customerId}:${context.customerTokenId}`; + return `pojs.apm.${id}.${key}`; + } + + } + + export const storage = Storage.getInstance(); +} diff --git a/src/apm/Theme.ts b/src/apm/Theme.ts index bebec72a..2735bf87 100644 --- a/src/apm/Theme.ts +++ b/src/apm/Theme.ts @@ -2,6 +2,7 @@ module ProcessOut { interface Palette { background: string surface: { + success: string, button: { primary: string secondary: string @@ -20,6 +21,12 @@ module ProcessOut { input: { default: string, disabled: string, + hover: { + default: string, + } + } + toast: { + error: string, } } border: { @@ -28,12 +35,26 @@ module ProcessOut { errored: string, disabled: string, } + icon: { + tertiary: string, + disabled: string, + } + checkbox: { + default: string, + } + toast: { + error: string, + } } text: { default: string disabled: string label: string errored: string + secondary: string + toast: { + error: string, + } } shadow: { focus: string, @@ -42,6 +63,7 @@ module ProcessOut { } export interface ThemeOptions { + fontFamily: string palette: { light: Palette dark: Palette @@ -59,12 +81,15 @@ module ProcessOut { export class ThemeImpl implements Theme { static _instance: Theme; + static _mode: 'light' | 'dark' = 'light'; private theme: ThemeOptions = { + fontFamily: '"Work sans", Arial, sans-serif', palette: { dark: { background: "#26292F", surface: { + success: '#28DE6B', button: { primary: "#FFFFFF", secondary: "#555555", @@ -83,6 +108,12 @@ module ProcessOut { input: { default: '#26292F', disabled: '#2E3137', + hover: { + default: '#33353A', + }, + }, + toast: { + error: '#511511', } }, border: { @@ -90,14 +121,27 @@ module ProcessOut { default: '#484a50', errored: '#FF8888', disabled: '#2E3137', - + }, + icon: { + tertiary: '#707378', + disabled: '#585A5F', + }, + checkbox: { + default: '#56585C', + }, + toast: { + error: '#5E2724', } }, text: { default: '#FFFFFF', disabled: '#707378', label: '#A7A9AF', - errored: '#FF8888', + errored: '#FF7D6C', + secondary: '#C0C3C8', + toast: { + error: '#F5D9D9', + } }, shadow: { focus: '#63656b', @@ -107,6 +151,7 @@ module ProcessOut { light: { background: "#FFFFFF", surface: { + success: '#0C7434', button: { primary: "#000000", secondary: "#f1f1f1", @@ -125,6 +170,12 @@ module ProcessOut { input: { default: '#FFFFFF', disabled: '#f1f1f1', + hover: { + default: '#f5f5f5', + }, + }, + toast: { + error: '#FDE3DE', } }, border: { @@ -132,6 +183,16 @@ module ProcessOut { default: '#e3e3e3', errored: '#BE011B', disabled: '#f1f1f1', + }, + icon: { + tertiary: '#8A8D93', + disabled: '#C0C3C8', + }, + checkbox: { + default: '#C0C3C8', + }, + toast: { + error: '#EFD7D2', } }, text: { @@ -139,6 +200,10 @@ module ProcessOut { disabled: '#C0C3C8', label: '#707378', errored: '#BE011B', + secondary: '#585A5F', + toast: { + error: '#630407', + } }, shadow: { focus: '#b1b1b2', @@ -148,18 +213,142 @@ module ProcessOut { }, } - private constructor() {} + private static themeChangeCallbacks: Array<(mode: 'light' | 'dark') => void> = []; + + private constructor() { + // Initialize mode based on current color scheme + ThemeImpl.updateMode(); + + // Set up listener for color scheme changes + ThemeImpl.setupColorSchemeListener(); + } public static get instance(): Theme { - if (!this._instance) { - this._instance = new ThemeImpl(); + if (!ThemeImpl._instance) { + ThemeImpl._instance = new ThemeImpl(); } + return ThemeImpl._instance; + } + + public static get mode(): 'light' | 'dark' { + return this._mode; + } + + /** + * Register a callback to be called when theme mode changes + */ + public static onThemeChange(callback: (mode: 'light' | 'dark') => void): () => void { + const index = ThemeImpl.themeChangeCallbacks.indexOf(callback); + + if (index === -1) { + this.themeChangeCallbacks.push(callback); + } + + // Return cleanup function to remove the callback + return () => { + const index = this.themeChangeCallbacks.indexOf(callback); + if (index > -1) { + this.themeChangeCallbacks.splice(index, 1); + } + }; + } + + /** + * Update the mode based on current color scheme + */ + private static updateMode(): void { + const newMode = ThemeImpl.getCurrentColorScheme(); + if (ThemeImpl._mode !== newMode) { + ThemeImpl._mode = newMode; + ThemeImpl.triggerModeChange(); + } + } + + /** + * Set up listener for color scheme changes + */ + private static setupColorSchemeListener(): void { + if (typeof window === 'undefined' || !window.matchMedia) { + return; + } + + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + + const handleChange = (e: MediaQueryListEvent) => { + ThemeImpl.updateMode(); + }; - return this._instance; + mediaQuery.addEventListener('change', handleChange); + } + + /** + * Trigger mode change callbacks + */ + private static triggerModeChange(): void { + // Call all registered callbacks + ThemeImpl.themeChangeCallbacks.forEach(callback => { + try { + callback(ThemeImpl._mode); + } catch (error) { + console.error('Error in theme change callback:', error); + } + }); } public get

>(path?: P): PathValue { - return this.recursiveFind(path, this.theme) + return this.recursiveFind(path, this.theme); + } + + /** + * Get the current color scheme (light or dark) + */ + public static getCurrentColorScheme(): 'light' | 'dark' { + if (typeof window !== 'undefined' && window.matchMedia) { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } + return 'light'; // Default fallback + } + + /** + * Get a color value based on the current color scheme + */ + public static getColorForCurrentScheme(path: string): string { + const scheme = ThemeImpl.getCurrentColorScheme(); + const fullPath = `palette.${scheme}.${path}` as any; + const value = ThemeImpl.instance.get(fullPath); + return typeof value === 'string' ? value : '#000000'; + } + + /** + * Listen for color scheme changes and execute a callback + */ + public onColorSchemeChange(callback: (scheme: 'light' | 'dark') => void): () => void { + if (typeof window === 'undefined' || !window.matchMedia) { + return () => {}; // No-op cleanup function + } + + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + + const handleChange = (e: MediaQueryListEvent) => { + callback(e.matches ? 'dark' : 'light'); + }; + + mediaQuery.addEventListener('change', handleChange); + + // Return cleanup function + return () => { + mediaQuery.removeEventListener('change', handleChange); + }; + } + + /** + * Manually set the theme mode + */ + public setMode(mode: 'light' | 'dark'): void { + if (ThemeImpl._mode !== mode) { + ThemeImpl._mode = mode; + ThemeImpl.triggerModeChange(); + } } public getTextColor

>(path?: P): string { @@ -198,6 +387,56 @@ module ProcessOut { this.theme = this.deepMerge(this.theme, theme) } + private generateMarkdownSpacingRules(): string { + const headingTags = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] + const listTags = ['ul', 'ol'] + + // Generate heading-to-heading combinations + const headingRules = [] + for (let i = 0; i < headingTags.length; i++) { + for (let j = 0; j < headingTags.length; j++) { + headingRules.push(`.markdown ${headingTags[i]} + ${headingTags[j]}`) + } + } + + // Generate list-to-list combinations + const listRules = [] + for (let i = 0; i < listTags.length; i++) { + for (let j = 0; j < listTags.length; j++) { + listRules.push(`.markdown ${listTags[i]} + ${listTags[j]}`) + } + } + + return css` + /* Reset margins for all markdown elements */ + .markdown > * { + margin-top: 0; + margin-bottom: 0; + } + + /* Default spacing (32px) for all elements except the first one */ + .markdown > * + * { + margin-top: 32px; + } + + /* Same type elements: 16px spacing */ + /* Heading to heading */ + ${headingRules.join(', \n')} { + margin-top: 16px; + } + + /* Paragraph to paragraph */ + .markdown p + p { + margin-top: 16px; + } + + /* List to list */ + ${listRules.join(', \n')} { + margin-top: 16px; + } + `() + } + public createStyles() { const buttonVariants = Object.keys(ThemeImpl.instance.get("palette.light.surface.button")).reduce((acc, key) => { const color = key as keyof ThemeOptions['palette']['light']['surface']['button'] @@ -258,7 +497,7 @@ module ProcessOut { ${this.resetCss} .main { - font-family: "Work sans", Arial, sans-serif; + font-family: ${ThemeImpl.instance.get('fontFamily')}; container: main / inline-size; } @@ -267,8 +506,7 @@ module ProcessOut { flex-direction: column; width: 100%; min-height: 285px; - padding: 12px 20px; - gap: 16px; + padding: 32px 40px; color: ${ThemeImpl.instance.get('palette.light.text.default')}; background-color: ${ThemeImpl.instance.get('palette.light.background')}; @media (prefers-color-scheme: dark) { @@ -277,6 +515,25 @@ module ProcessOut { } } + .page > .container { + display: flex; + flex-direction: column; + gap: 16px; + } + + .page > .container > .buttons-container { + margin-top: 24px; + } + .page > .container > form + .buttons-container { + margin-top: 4px; + } + + .buttons-container { + display: flex; + flex-direction: column; + gap: 12px; + } + .loader { width: 30px; height: 30px; @@ -313,10 +570,10 @@ module ProcessOut { .empty-controls { display: grid; gap: 12px; - text-align: center; } .empty-controls.x3 { + text-align: center; grid-template-columns: repeat(3, 1fr); } @@ -344,7 +601,7 @@ module ProcessOut { transform: rotate(-90deg); } - .header-container { + .heading-container { width: 100%; display: flex; flex-direction: column; @@ -352,13 +609,13 @@ module ProcessOut { padding-top: 16px; } - .header { + .heading { font-weight: 600; font-size: 20px; line-height: 24px; } - .sub-header { + .sub-heading { font-weight: 400; font-size: 16px; line-height: 26px; @@ -376,6 +633,7 @@ module ProcessOut { font-family: inherit; width: 100%; display: inline-block; + text-wrap-mode: nowrap; appearance: none; cursor: pointer; font-weight: 500; @@ -383,6 +641,7 @@ module ProcessOut { outline: none; border-width: 2px; border-style: solid; + position: relative; } .button:focus { @@ -414,10 +673,17 @@ module ProcessOut { pointer-events: none; } + .button.loading .content { + opacity: 0; + } + .button.loading .loader { width: 16px; height: 16px; border-width: 2px; + position: absolute; + top: calc(50% - 8px); + left: calc(50% - 8px); } .button.sm { @@ -454,7 +720,7 @@ module ProcessOut { display: flex; width: 100%; background-color: ${ThemeImpl.instance.get('palette.light.surface.input.default')}; - border: 2px solid ${ThemeImpl.instance.get('palette.light.border.input.default')}; + border: 1.5px solid ${ThemeImpl.instance.get('palette.light.border.input.default')}; border-radius: 6px; height: 52px; position: relative; @@ -463,12 +729,13 @@ module ProcessOut { border-color: ${ThemeImpl.instance.get('palette.dark.border.input.default')}; } } - .field.focused { + .field.focused, .field:focus-within { border-color: ${ThemeImpl.instance.get('palette.light.text.default')}; @media (prefers-color-scheme: dark) { border-color: ${ThemeImpl.instance.get('palette.dark.text.default')}; } } + .field .label { font-family: inherit; position: absolute; @@ -484,6 +751,7 @@ module ProcessOut { color: ${ThemeImpl.instance.get('palette.dark.text.label')}; } } + .field.filled.has-label .label { font-size: 12px; line-height: 14px; @@ -532,9 +800,9 @@ module ProcessOut { pointer-events: none; } .field.errored:not(.disabled) { - border-color: ${ThemeImpl.instance.get('palette.light.text.errored')}; + border-color: ${ThemeImpl.instance.get('palette.light.border.input.errored')}; @media (prefers-color-scheme: dark) { - border-color: ${ThemeImpl.instance.get('palette.dark.text.errored')}; + border-color: ${ThemeImpl.instance.get('palette.dark.border.input.errored')}; } } .field.errored:not(.disabled) .label { @@ -589,14 +857,35 @@ module ProcessOut { top: 50%; transform: translateY(-50%); } + + .otp-container .otp-label { + margin-bottom: 12px; + display: inline-block; + } + .otp-container > div { + margin-bottom: 16px; + } + .otp { cursor: text; + position: relative; + display: inline-block; } .otp .input { width: 40px; float: left; - margin-left: 12px; + margin-left: 16px; + } + .otp input.hidden { + display: block !important; + position: absolute; + width: 100%; + z-index: 1; + background: transparent; + border: none; + outline: none; + height: 100%; } .otp .input:first-child { margin-left: 0; @@ -711,15 +1000,539 @@ module ProcessOut { } .error { - background-color: #FDE3DE; padding: 8px 12px; gap: 8px; border-radius: 6px; - border: 1px solid #efd7d2; - color: #630407; + border: 1px solid; font-weight: 500; font-size: 14px; line-height: 20px; + background-color: ${ThemeImpl.instance.get('palette.light.surface.toast.error')}; + border-color: ${ThemeImpl.instance.get('palette.light.border.toast.error')}; + color: ${ThemeImpl.instance.get('palette.light.text.toast.error')}; + + @media (prefers-color-scheme: dark) { + background-color: ${ThemeImpl.instance.get('palette.dark.surface.toast.error')}; + border-color: ${ThemeImpl.instance.get('palette.dark.border.toast.error')}; + color: ${ThemeImpl.instance.get('palette.dark.text.toast.error')}; + } + } + + .header { + display: flex; + gap: 8px; + justify-content: space-between; + align-items: center; + padding: 0 0 32px; + border-bottom: 1px solid #f1f1f1; + margin-bottom: 32px; + } + + .header .amount { + font-weight: 600; + font-size: 16px; + line-height: 20px; + } + + .markdown { + text-align: left; + } + + .markdown-skeleton { + display: flex; + flex-direction: column; + } + + .skeleton-line { + background: linear-gradient(90deg, rgba(0, 0, 0, 0.1) 25%, rgba(0, 0, 0, 0.05) 50%, rgba(0, 0, 0, 0.1) 75%); + background-size: 200% 100%; + animation: skeleton-pulse 1.5s ease-in-out infinite; + } + + @keyframes skeleton-pulse { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } + } + + .qr-code-container { + display: flex; + flex-direction: column; + gap: 12px; + align-items: center; + max-width: 70%; + margin: 0 auto; + padding: 8px 0; + } + + .qr-code { + display: flex; + justify-content: center; + align-items: center; + padding: 4px; + background-color: ${ThemeImpl.instance.get('palette.light.background')}; + @media (prefers-color-scheme: dark) { + background-color: ${ThemeImpl.instance.get('palette.dark.background')}; + } + } + + .qr-code img { + width: 100%; + height: 100%; + } + + .qr-skeleton { + position: relative; + display: flex; + flex-wrap: wrap; + align-content: space-around; + } + + .qr-dot { + height: 4px; + background-color: transparent; + display: flex; + justify-content: center; + align-items: center; + box-sizing: border-box; + } + + .qr-dot:before { + content: ''; + width: 4px; + height: 4px; + border-radius: 50%; + animation: dot-fade 1.5s ease-in-out infinite; + animation-delay: var(--animation-delay, 0s); + opacity: 0.3; + background-color: ${ThemeImpl.instance.get('palette.light.text.default')}; + @media (prefers-color-scheme: dark) { + background-color: ${ThemeImpl.instance.get('palette.dark.text.default')}; + } + } + + @keyframes dot-fade { + 0%, 100% { + opacity: 0.2; + transform: scale(0.8); + } + 50% { + opacity: 0.7; + transform: scale(1.2); + } + } + + .qr-square { + display: none; + } + + .qr-corner { + display: none; + } + + .qr-skeleton-block { + display: none; + } + + .qr-code .loader { + width: 15px; + height: 15px; + border-width: 2px; + } + + .qr-actions { + display: flex; + gap: 8px; + justify-content: center; + align-items: center; + } + + .markdown h1 { + font-family: inherit; + font-weight: 600; + font-size: 24px; + line-height: 32px; + } + + .markdown h2 { + font-family: inherit; + font-weight: 600; + font-size: 20px; + line-height: 24px; + } + + .markdown h3 { + font-family: inherit; + font-weight: 600; + font-size: 18px; + line-height: 22px; + } + + .markdown h4 { + font-family: inherit; + font-weight: 600; + font-size: 16px; + line-height: 20px; + } + + .markdown h5 { + font-family: inherit; + font-weight: 600; + font-size: 15px; + line-height: 18px; + } + + .markdown h6 { + font-family: inherit; + font-weight: 600; + font-size: 14px; + line-height: 20px; + } + + .markdown p { + font-family:inherit; + font-weight: 400; + font-size: 16px; + line-height: 26px; + } + + .markdown a { + font-family: inherit; + font-weight: 400; + font-size: 16px; + line-height: 26px; + text-decoration: underline; + color: ${ThemeImpl.instance.get('palette.light.text.default')}; + + @media (prefers-color-scheme: dark) { + color: ${ThemeImpl.instance.get('palette.dark.text.default')}; + } + } + + .markdown ul { + list-style-type: disc; + list-style-position: outside; + list-style-image: none; + padding-left: 20px; + } + + .markdown ol { + list-style-type: decimal; + list-style-position: outside; + list-style-image: none; + padding-left: 20px; + } + + .markdown li { + display: list-item; + text-align: match-parent; + line-height: 28px; + } + + .markdown blockquote { + padding: 4px 8px 4px 16px; + gap: 8px; + border-left: 3px solid; + font-weight: 400; + font-size: 16px; + line-height: 26px; + color: ${ThemeImpl.instance.get('palette.light.text.secondary')}; + border-color: ${ThemeImpl.instance.get('palette.light.border.input.default')}; + @media (prefers-color-scheme: dark) { + color: ${ThemeImpl.instance.get('palette.dark.text.secondary')}; + border-color: ${ThemeImpl.instance.get('palette.dark.border.input.default')}; + } + } + + .markdown strong { + font-weight: 500; + } + + .markdown em { + font-style: italic; + } + + ${this.generateMarkdownSpacingRules()} + + .tick { + position: relative; + width: 100%; + height: 100%; + z-index: 1; + transform-origin: center; + } + + .tick:before, .tick:after { + content: ""; + position: absolute; + transform-origin: bottom center; + width: 7%; + bottom: 24%; + border-radius: 100px; + background-color: ${ThemeImpl.instance.get('palette.light.background')}; + @media (prefers-color-scheme: dark) { + background-color: ${ThemeImpl.instance.get('palette.dark.background')}; + } + } + + .tick:before { + height: 33%; + transform: rotate(-35deg); + left: calc(50% - 4%); + bottom: 24%; + } + + .tick:after { + height: 57%; + transform: rotate(30deg); + left: calc(50% - 7%); + } + + .status-tick { + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + position: relative; + } + + .status-tick .tick { + border-radius: 50%; + } + + .status-tick .tick:before, .status-tick .tick:after { + display: none; + } + + .status-tick.pending .tick { + border: 2px solid; + border-color: ${ThemeImpl.instance.get('palette.light.border.icon.tertiary')}; + @media (prefers-color-scheme: dark) { + border-color: ${ThemeImpl.instance.get('palette.dark.border.icon.tertiary')}; + } + } + + .status-tick.idle .tick { + border: 2px solid; + border-color: ${ThemeImpl.instance.get('palette.light.border.icon.disabled')}; + @media (prefers-color-scheme: dark) { + border-color: ${ThemeImpl.instance.get('palette.dark.border.icon.disabled')}; + } + } + + .status-tick.completed .tick:before, .status-tick.completed .tick:after { + display: block; + } + + .status-tick.pending:before { + content: ""; + position: absolute; + width: 100%; + height: 100%; + border-radius: 200px; + z-index: 0; + animation: grow 2s ease-in-out infinite; + opacity: 0.20; + background-color: ${ThemeImpl.instance.get('palette.light.border.icon.tertiary')}; + @media (prefers-color-scheme: dark) { + background-color: ${ThemeImpl.instance.get('palette.dark.border.icon.tertiary')}; + } + } + .status-tick.pending:after { + content: ""; + position: absolute; + width: 100%; + height: 100%; + border-radius: 200px; + z-index: 0; + background-color: ${ThemeImpl.instance.get('palette.light.background')}; + @media (prefers-color-scheme: dark) { + background-color: ${ThemeImpl.instance.get('palette.dark.background')}; + } + } + + @keyframes grow { + 0% { + transform: scale(0.8); + opacity: 0.08; + } + 72% { + transform: scale(1.5); + opacity: 0.20; + } + 82% { + transform: scale(1.5); + opacity: 0.20; + } + 100% { + opacity: 0; + transform: scale(0.8); + } + } + + .status-tick.completed .tick { + background-color: ${ThemeImpl.instance.get('palette.light.surface.success')}; + @media (prefers-color-scheme: dark) { + background-color: ${ThemeImpl.instance.get('palette.dark.surface.success')}; + } + } + + .group { + display: flex; + flex-direction: column; + gap: 24px; + padding: 16px; + border-radius: 6px; + border: 1.5px solid; + border-color: ${ThemeImpl.instance.get('palette.light.border.input.default')}; + @media (prefers-color-scheme: dark) { + border-color: ${ThemeImpl.instance.get('palette.dark.border.input.default')}; + } + } + + .group > div { + position: relative; + } + + .group > div + div:before { + content: ''; + display: block; + position: absolute; + width: calc(100% + 20px); + height: 1px; + top: -12px; + left: -10px; + opacity: 0.5; + background-color: ${ThemeImpl.instance.get('palette.light.border.input.default')}; + @media (prefers-color-scheme: dark) { + background-color: ${ThemeImpl.instance.get('palette.dark.border.input.default')}; + } + } + + .group.group-boolean { + padding: 4px; + gap: 4px; + } + + .group.group-boolean > div + div:before { + display: none; + } + + .copy-instruction { + display: flex; + gap: 8px; + align-items: center; + justify-content: space-between; + } + + .copy-instruction .label { + font-weight: 500; + font-size: 12px; + line-height: 14px; + margin-bottom: 4px; + text-align: left + color: ${ThemeImpl.instance.get('palette.light.text.label')}; + @media (prefers-color-scheme: dark) { + color: ${ThemeImpl.instance.get('palette.dark.text.label')}; + } + } + + .copy-instruction .value { + font-weight: 500; + font-size: 15px; + line-height: 18px; + } + + .copy-instruction .copied-text { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + + .copy-instruction .button { + width: auto; + } + + .checkbox { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + padding: 16px 12px; + border-radius: 6px; + } + + .checkbox:hover { + background-color: ${ThemeImpl.instance.get('palette.light.surface.input.hover.default')}; + @media (prefers-color-scheme: dark) { + background-color: ${ThemeImpl.instance.get('palette.dark.surface.input.hover.default')}; + } + } + + .checkbox-input { + position: relative; + width: 16px; + height: 16px; + } + + .checkbox-input input { + opacity: 0; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + cursor: pointer; + z-index: 4; + } + + .checkbox-indicator { + width: 16px; + height: 16px; + border-radius: 4px; + border: 1px solid; + z-index: 3; + background-color: ${ThemeImpl.instance.get('palette.light.background')}; + border-color: ${ThemeImpl.instance.get('palette.light.border.checkbox.default')}; + @media (prefers-color-scheme: dark) { + background-color: ${ThemeImpl.instance.get('palette.dark.background')}; + border-color: ${ThemeImpl.instance.get('palette.dark.border.checkbox.default')}; + } + } + .checkbox-input input + .checkbox-indicator .status-tick { + display: none; + } + + .checkbox-input input:checked + .checkbox-indicator { + background-color: ${ThemeImpl.instance.get('palette.light.text.default')}; + border-color: ${ThemeImpl.instance.get('palette.light.text.default')}; + @media (prefers-color-scheme: dark) { + background-color: ${ThemeImpl.instance.get('palette.dark.text.default')}; + border-color: ${ThemeImpl.instance.get('palette.dark.text.default')}; + } + } + + .checkbox-input input:checked + .checkbox-indicator .status-tick { + display: block; + } + + .logo img[data-dark-src] { + content: var(--logo-src); + } + + .logo img[data-dark-src] { + --logo-src: url(attr(src url)); + } + + @media (prefers-color-scheme: dark) { + .logo img[data-dark-src] { + --logo-src: url(attr(data-dark-src url)); + } } `() } @@ -745,7 +1558,7 @@ module ProcessOut { if (!target[key]) Object.assign(target, { [key]: {} }); this.deepMerge(target[key], source[key]); } else { - Object.assign(target, { [key]: source[key] }); + Object.assign(target, { [key]: source[key] || target[key] }); } } } diff --git a/src/apm/elements/button.ts b/src/apm/elements/button.ts index b71c4928..14c21827 100644 --- a/src/apm/elements/button.ts +++ b/src/apm/elements/button.ts @@ -1,5 +1,5 @@ module ProcessOut { - const { button } = elements + const { button, div } = elements export interface ButtonProps extends Props<'button'> { variant?: 'primary' | 'secondary' | 'tertiary' | 'success' | 'danger' size?: 'sm' | 'md' | 'lg', @@ -8,7 +8,7 @@ module ProcessOut { export const Button = (first: ButtonProps | Child, ...children: Child[]) => { const { className, variant, size, loading, disabled, ...userProps } = isProps(first) ? first : {} - let rest = isProps(first) ? children : [first, ...children]; + let rest = [div({ className: "content" }, isProps(first) ? children : [first, ...children])]; if (loading) { rest = [Loader()] diff --git a/src/apm/elements/cancel-button.ts b/src/apm/elements/cancel-button.ts new file mode 100644 index 00000000..96c79ce3 --- /dev/null +++ b/src/apm/elements/cancel-button.ts @@ -0,0 +1,12 @@ +module ProcessOut { + + + export const CancelButton = ({ onClick, config }: { onClick?: () => void, config: APISuccessBase & Partial }) => { + const onCancelClick = () => { + onClick?.() + ContextImpl.context.events.emit('request-cancel') + ContextImpl.context.page.render(APMViewCancelRequest, { config }) + } + return Button({ onclick: onCancelClick, variant: 'secondary' }, 'Cancel') + } +} \ No newline at end of file diff --git a/src/apm/elements/checkbox.ts b/src/apm/elements/checkbox.ts new file mode 100644 index 00000000..aa6a0983 --- /dev/null +++ b/src/apm/elements/checkbox.ts @@ -0,0 +1,25 @@ +module ProcessOut { + const { div, input, label: labelEl } = elements + + export interface CheckboxProps { + label: string + name: string + checked?: boolean + onchange?: (key: string, value: boolean) => void + onblur?: (key: string, value: boolean) => void + } + + export const Checkbox = ({ label, name, checked, onchange, onblur }: CheckboxProps) => { + return labelEl({ className: 'checkbox', for: name }, + div({ className: 'checkbox-input' }, + input({ + type: 'checkbox', + name, + id: name, + }), + div({ className: 'checkbox-indicator' }, Tick()) + ), + div({ className: 'checkbox-label' }, label) + ) + } +} \ No newline at end of file diff --git a/src/apm/elements/copy-instruction.ts b/src/apm/elements/copy-instruction.ts new file mode 100644 index 00000000..fdf77fce --- /dev/null +++ b/src/apm/elements/copy-instruction.ts @@ -0,0 +1,139 @@ +module ProcessOut { + const { div, span } = elements + + // Persistent state store keyed by QR id to survive re-renders + const copyStateStore: Record = {}; + + // Function to clear QR state when component is removed + export const clearCopyState = (id: string): void => { + delete copyStateStore[id]; + }; + + // Function to clear all QR state + export const clearAllCopytate = (): void => { + Object.keys(copyStateStore).forEach(key => delete copyStateStore[key]); + }; + + export const CopyInstruction = ({ + instruction, + id = `copy-${Math.random().toString(36).substr(2, 9)}`, + }: { + instruction: InstructionData['instruction'] & { type: 'message' }, + id?: string + }) => { + let copyButtonRef: HTMLButtonElement | null = null; + let copyTextRef: HTMLSpanElement | null = null; + let copiedTextRef: HTMLSpanElement | null = null; + + if (!copyStateStore[id]) { + copyStateStore[id] = { + isCopying: false, + }; + } + + const state = copyStateStore[id]; + + const update = (): void => { + if (state.isCopying) { + copyButtonRef.disabled = true; + copiedTextRef.style.display = 'block'; + copyTextRef.style.opacity = '0'; + } else { + copyButtonRef.disabled = false; + copiedTextRef.style.display = 'none'; + copyTextRef.style.opacity = '1'; + } + } + + const onCopy = (): void => { + if (state.isCopying) { + return; + } + + // Update state to copying + state.isCopying = true; + update(); + + // Emit copy-to-clipboard event immediately (synchronously) + try { + ContextImpl.context.events.emit('copy-to-clipboard', { text: instruction.value }); + } catch (error) { + console.error("Failed to emit copy-to-clipboard event:", error); + } + + try { + // Use the modern Clipboard API if available + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(instruction.value).then(() => { + setTimeout(() => { + state.isCopying = false; + update(); + }, 1000); + }).catch((error) => { + console.error("Failed to copy text to clipboard:", error); + state.isCopying = false; + update(); + }); + } else { + // Fallback for older browsers + const textArea = document.createElement('textarea'); + textArea.value = instruction.value; + textArea.style.position = 'fixed'; + textArea.style.left = '-999999px'; + textArea.style.top = '-999999px'; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + try { + document.execCommand('copy'); + setTimeout(() => { + state.isCopying = false; + update(); + }, 1000); + } catch (err) { + console.error("Fallback copy failed:", err); + state.isCopying = false; + update(); + } finally { + document.body.removeChild(textArea); + } + } + } catch (error) { + console.error("Error copying QR code:", error); + state.isCopying = false; + update(); + } + }; + + + return div({ className: 'copy-instruction' }, + div({ className: 'content' }, + div({ className: 'label' }, instruction.label), + div({ className: 'value' }, instruction.value), + ), + Button({ + variant: "secondary", + size: "sm", + type: "button", + onclick: onCopy, + ref: (element: HTMLButtonElement | null) => { + copyButtonRef = element; + + if (copyButtonRef) { + update(); + } + } + }, + span({ ref: (element: HTMLSpanElement | null) => { + copyTextRef = element; + }}, "Copy code"), + span({ className: 'copied-text', ref: (element: HTMLSpanElement | null) => { + copiedTextRef = element; + }}, "Copied!") + ), + ) + } +} \ No newline at end of file diff --git a/src/apm/elements/elements.ts b/src/apm/elements/elements.ts index 0d5b9ef2..ce3f3251 100644 --- a/src/apm/elements/elements.ts +++ b/src/apm/elements/elements.ts @@ -1,139 +1,221 @@ module ProcessOut { - // VNode represents a Virtual DOM node. + /** + * Virtual DOM Node - The Core Data Structure + * + * A VNode is a lightweight JavaScript object that represents what the DOM should look like. + * It's like a blueprint that describes an element without actually creating it. + * + * Examples: + * • Text: { type: '#text', value: 'Hello' } + * • Element: { type: 'div', props: { className: 'container' }, children: [...] } + * • Fragment: { type: null, children: [...] } // Groups elements without wrapper + */ export interface VNode { - // 'type' is the HTML tag name, '#text' for text nodes, or null for DocumentFragment - type: Type | '#text' | null ; - // Props are conditionally typed: specific HTML props if 'Type' is an HTMLTag, - // otherwise an empty object as text/fragments don't have standard HTML element props. - props: Type extends Tag ? Props : object; - // Children can be any VNode, using 'any' to avoid circular type definitions. - children: VNode[]; - // Reference to the actual live DOM element (set during patching) - dom: Node | null; - // Optional key for child reconciliation in lists - key?: string | null; - // Specific for text VNodes (stores the string content) - value?: string; + type: Type | '#text' | null; // What kind of element: 'div', '#text', or null for fragments + props: Type extends Tag ? Props : object; // Element properties (className, onclick, etc.) + children: VNode[]; // Child elements (recursive structure) + dom: Node | null; // Reference to actual DOM node (set during rendering) + key?: string | null; // Unique identifier for efficient list updates + value?: string; // Text content (only for text nodes) } - // Props for an HTML element, allowing any extra properties but restricting 'style' and 'class' - export type Props = Partial & { - style?: never; // Disallow direct style attribute (use Tailwind or separate CSS) - class?: never; // Disallow direct class attribute (use className) - ref?: T extends Tag ? (node: HTMLElementTagNameMap[T] | null) => void : never; - key?: string; // Key for list reconciliation - [key: string]: any; // Allow other arbitrary properties + /** + * Type-Safe Props System + * + * This ensures you can only use valid HTML properties for each element type. + * For example, 'checked' only works on input elements, 'href' only on anchor tags. + * + * Special handling: + * • style/class are forbidden (use className and CSS-in-JS instead) + * • ref provides direct DOM access when needed + * • key enables efficient list rendering + */ + export type Props = Omit, 'style' | 'class'> & { + style?: Partial; + class?: never; // Use className instead for React compatibility + ref?: (node: HTMLElementTagNameMap[T] | null) => void; + key?: string; + [key: string]: any; }; - export type Tag = typeof TAGS[number]; // Union type of all allowed tags - + export type Tag = typeof TAGS[number]; export type Primitive = string | number | boolean; - // Child represents any valid child type for a VNode (primitive or another VNode) + /** + * Child Type System - Maximum Flexibility + * + * Children can be anything that makes sense in JSX: + * • Primitives: "Hello", 42, true + * • VNodes: div(), span(), etc. + * • Arrays: [item1, item2, item3] + * • Null/undefined: conditional rendering + * • Nested arrays: [[item1, item2], item3] (flattened automatically) + */ export type Child = Primitive | VNode | null | undefined | Child[]; - // Defines allowed HTML tags + /** + * Allowed HTML Elements + * + * We only support a curated list of HTML elements to: + * • Ensure type safety + * • Prevent XSS attacks + * • Keep bundle size reasonable + * • Focus on common use cases + */ const TAGS = [ - 'div','span','p','h1','h2','h3','h4','h5','h6', + 'div','span','p', 'em', 'strong', + 'h1','h2','h3','h4','h5','h6', 'a','button','input','label', 'form', - 'ul','ol','li','img', + 'ul','ol','li','img','picture','source', 'section','article','header','footer','nav','main', 'pre','code','textarea','select','option', ] as const; - - // Function type for creating a DocumentFragment VNode type GenerateFragment = (...children: Child[]) => VNode; - - // Argument types for tag generation functions (either props + children, or just children) export type GenerateTagArgs = [childOrProps: Props | Child, ...Child[]]; - // Overloaded function type for tag generation (e.g., div(props, ...) or div(...)) + /** + * Element Factory Function Interface + * + * Each HTML element gets a function that can be called in two ways: + * • div({ className: 'container' }, 'Hello') // With props + * • div('Hello', 'World') // Props-less + */ export interface GenerateTag { (props: Props, ...children: Child[]): VNode; (...children: Child[]): VNode; } - // The public API interface for the ProcessOut module type VanLite = { - fragment: GenerateFragment;// Index signature for dynamic tag functions + fragment: GenerateFragment; } & { [K in Tag]: GenerateTag }; /** - * Recursively flattens and processes children into an array of VNodes. - * @param rawChildren - Raw children passed to a tag function. - * @returns Processed children array. + * Child Processor - The Flattening Algorithm + * + * Takes any mix of children types and converts them to a flat array of VNodes. + * This is where the magic happens that lets you write natural JSX-like code. + * + * Transformations: + * • "Hello" → { type: '#text', value: 'Hello' } + * • [child1, child2] → [child1, child2] (flattened) + * • null/undefined/false → (skipped) + * • Already VNodes → (passed through) + * + * Example: ['Hello', [span('World'), null], 42] + * Result: [TextNode('Hello'), SpanNode('World'), TextNode('42')] */ function processChildren(rawChildren: Child[]): VNode[] { const children: VNode[] = []; for (let i = 0; i < rawChildren.length; i++) { const child = rawChildren[i]; + + // Skip falsy values (enables conditional rendering) if (child == null || child === false) { - // Skip null, undefined, false continue; } + // Flatten nested arrays recursively if (Array.isArray(child)) { - // Flatten child arrays recursively children.push(...processChildren(child)); continue; } - if (typeof child === 'object' && child !== null && (typeof (child as VNode).type === 'string' || (child as VNode).type === null)) { - // It's already a VNode, add directly - children.push(child as VNode); - continue; + // Detect existing VNodes (objects with VNode structure) + if (typeof child === 'object' && child !== null && 'type' in child && 'props' in child && 'children' in child) { + const vnode = child as VNode; + // Validate VNode type + if (typeof vnode.type === 'string' || vnode.type === '#text' || vnode.type === null) { + children.push(vnode); + continue; + } } - // It's a primitive (string, number, boolean), convert to VNode for consistent handling - children.push({ type: '#text', props: {}, children: [], dom: null, key: null, value: String(child) }); + // Convert primitives to text nodes + children.push({ + type: '#text', + props: {}, + children: [], + dom: null, + key: null, + value: String(child) + }); } return children; } /** - * Factory function that generates a function for creating a specific HTML element's Virtual DOM node. - * This function DOES NOT create actual DOM elements. It creates a plain JS object (VNode). - * @template T - * @param tag - The HTML tag name (e.g., 'div', 'button'). - * @returns A function that creates a VNode of the specified tag. + * Element Factory Generator - The Core API Builder + * + * This is the heart of the elements system. It creates the functions like div(), span(), etc. + * Each function follows the same pattern but is customized for a specific HTML element. + * + * The generated function: + * 1. Figures out if first argument is props or a child + * 2. Separates props from children + * 3. Processes children into VNodes + * 4. Returns a VNode object + * + * Example: makeTag('button') creates a function that makes button VNodes */ function makeTag(tag: T): GenerateTag { return ((...args: GenerateTagArgs): VNode => { - let props: Props = {}; + let props: Props = {} as Props; let childrenArgs: Child[] = args as Child[]; - // Determine if the first argument is a props object + // Smart argument detection: is first arg props or children? if (isProps(args[0])) { props = args[0]; childrenArgs = args.slice(1) as Child[]; } - // Create the Virtual DOM node object + // Extract key safely without mutating original props + const { key, ...propsWithoutKey } = props; + + // Build the Virtual DOM node return { type: tag, - props: props, + props: propsWithoutKey, children: processChildren(childrenArgs), - dom: null, // This will hold a reference to the actual DOM node after patching - key: props.key // Store key directly for easier access + dom: null, + key, }; - }) as GenerateTag; // Type assertion to match the overloaded interface + }) as GenerateTag; }; const api: Partial = {} as Partial; /** - * This loop iterates through all the HTML tags that we allow, as defined in the `TAGS` - * array. For each tag, it calls the `makeTag` factory to create an - * element-generating function (e.g., a function for creating `

`s). + * API Generation - Building the Element Functions + * + * This loop creates all the element functions: div(), span(), button(), etc. + * Each function is a specialized version of makeTag() for that element type. + * + * After this loop runs, you can call: + * • elements.div() to create div VNodes + * • elements.button() to create button VNodes + * • etc. */ for (let i = 0 as const; i < TAGS.length; i++) { const t = TAGS[i]; (api as any)[t] = makeTag(t); } + /** + * Fragment Factory - Grouping Without Wrappers + * + * Fragments let you group multiple elements without adding an extra DOM node. + * Useful when you need to return multiple elements from a component. + * + * Example: + * fragment( + * h1('Title'), + * p('Description') + * ) + * // Renders as:

Title

Description

(no wrapper div) + */ api.fragment = (...children: Child[]): VNode => ({ type: null, props: {}, @@ -145,39 +227,61 @@ module ProcessOut { export const elements = api as VanLite; /** - * A type guard to determine if the first argument to a tag function is a props object. + * Props Detection - Smart Argument Parsing + * + * Figures out if the first argument to an element function is a props object + * or the first child. This enables both calling styles: + * + * • div({ className: 'box' }, 'content') // Props first + * • div('content') // No props + * + * The challenge: distinguish between props and VNode children + * Solution: Props are plain objects, VNodes have specific properties */ export function isProps(item: GenerateTagArgs[0]): item is Props { - // A props object is a plain object, not a VNode (which has a 'type' property) - return item && typeof item === 'object' && item.constructor === Object && !('children' in item); + return item + && typeof item === 'object' + && item.constructor === Object + && !('children' in item); // VNodes have 'children', props don't }; /** - * A helper used in custom elements to merge props defined in the custom element - * with the props passed into it, handling class names and event listeners specially. - * @template T - * @param base - Base props. - * @param user - User-provided props. - * @returns Merged props. + * Advanced Props Merging - Component Composition + * + * Used for building reusable components that can accept user props + * while providing defaults. Handles tricky cases like event handlers + * and CSS classes that need special merging logic. + * + * Example: + * const Button = (userProps) => { + * const baseProps = { className: 'btn', onclick: logClick }; + * const merged = mergeProps(baseProps, userProps); + * return button(merged, 'Click me'); + * } + * + * Smart merging: + * • Classes: 'btn' + 'btn-primary' → 'btn btn-primary' + * • Events: both base and user handlers are called + * • Other props: user props override base props */ export function mergeProps(base: Props, user: Props = {} as Props): Props { const out: Props = { ...base, ...user }; - // Combine class names + // Combine CSS classes intelligently const classes = [ - base.className || (base as any).class, // Access 'class' if it was used + base.className || (base as any).class, user.className || (user as any).class, ].filter(Boolean); if (classes.length) (out as any).className = classes.join(" "); - // Merge event handlers: calling both base and user handlers + // Chain event handlers so both base and user handlers run for (const k in base) { if (k.startsWith("on") && typeof base[k] === "function" && typeof user[k] === "function") { const b = base[k] as EventListener; const u = user[k] as EventListener; (out as any)[k] = function (this: any, ...args: any[]) { - b.apply(this, args); - u.apply(this, args); + b.apply(this, args); // Base handler first + u.apply(this, args); // Then user handler }; } } diff --git a/src/apm/elements/header.ts b/src/apm/elements/header.ts index 95104e8f..4cc9af6d 100644 --- a/src/apm/elements/header.ts +++ b/src/apm/elements/header.ts @@ -1,5 +1,5 @@ module ProcessOut { - type HeaderTag = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' + type HeaderTag = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'label' type HeaderTagProps = Props type HeaderProps = HeaderTagProps & { tag: K @@ -8,14 +8,14 @@ module ProcessOut { export const Header = (...args: HeaderArgs) => { const first = args[0] const content: string = isProps(first) ? args[1] : first; - const props: HeaderTagProps = isProps(first) ? first : {} + const props: HeaderTagProps = isProps(first) ? first : {} as HeaderTagProps const tag: HeaderTag = props.tag || 'h1'; delete props.tag - const className = ["header", props.className].filter(Boolean) + const className = ["heading", props.className].filter(Boolean).join(' ') - const el = elements[tag]; + const el = elements[tag] as any; return el({ ...props, className }, content) } } diff --git a/src/apm/elements/input.ts b/src/apm/elements/input.ts index a4295b0f..0da4799e 100644 --- a/src/apm/elements/input.ts +++ b/src/apm/elements/input.ts @@ -5,16 +5,16 @@ module ProcessOut { name: string label?: string; errored?: boolean; - oninput?: (key: string, value: string) => void, - onblur?: (key: string, value: string) => void, + oninput?: FormFieldUpdate, + onblur?: FormFieldBlur, } export const Input = ({ name, className, label, disabled, errored, value, id, type, oninput, onblur, ...props }: InputProps) => { const classNames = [ "field input", - disabled && 'disabled', + disabled && !errored && 'disabled', label && 'has-label', - value && 'filled', + (value && value.toString().length > 0) && 'filled', errored && 'errored', className ].filter(Boolean).join(" ") @@ -30,6 +30,10 @@ module ProcessOut { const target = e.target as HTMLInputElement const value = target.value + if (!target.parentElement.classList.contains("focused")) { + target.parentElement.classList.add("focused") + } + if (label) { if (value.length === 0) { target.parentElement.classList.remove("filled") diff --git a/src/apm/elements/markdown.ts b/src/apm/elements/markdown.ts new file mode 100644 index 00000000..f882c089 --- /dev/null +++ b/src/apm/elements/markdown.ts @@ -0,0 +1,103 @@ +module ProcessOut { + const { div } = elements + + export interface MarkdownProps extends Props<'div'> { + content: string | string[]; + } + + export const Markdown = ({ content, className, ...props }: MarkdownProps) => { + const classNames = ["markdown", className].filter(Boolean).join(" ") + + // Process content to ensure proper paragraph breaks + const contentString = Array.isArray(content) + ? content.map(line => { + // Empty strings or strings with only whitespace should create paragraph breaks + if (line.trim() === '' || line.trim() === ' ') { + return '\n' // This will become double newline when joined + } + return line + }).join('\n') + : content + + // Create skeleton elements to show while loading + const createSkeleton = () => { + const skeletonContainer = document.createElement('div') + skeletonContainer.className = 'markdown-skeleton' + + // Create skeleton lines of varying widths + const lines = [90, 75, 85, 60, 80] // Percentages + lines.forEach(width => { + const line = document.createElement('div') + line.className = 'skeleton-line' + line.style.width = `${width}%` + line.style.height = '1em' + line.style.marginBottom = '0.5em' + line.style.backgroundColor = ThemeImpl.mode === 'light' + ? ThemeImpl.instance.get('palette.light.text.default') + : ThemeImpl.instance.get('palette.dark.text.default') + line.style.opacity = '0.1' + line.style.borderRadius = '4px' + line.style.animation = 'skeleton-pulse 1.5s ease-in-out infinite' + skeletonContainer.appendChild(line) + }) + + return skeletonContainer + } + + return div({ + className: classNames, + ref: (domElement: HTMLDivElement | null) => { + if (!domElement) return + + // Check if content has changed to prevent unnecessary re-renders + const contentHash = simpleHash(contentString) + const currentContentHash = domElement.getAttribute('data-content-hash') + if (currentContentHash === contentHash) { + return // Content hasn't changed, skip re-render + } + + // Store current content hash for comparison + domElement.setAttribute('data-content-hash', contentHash) + + // Show skeleton immediately + domElement.innerHTML = '' + domElement.appendChild(createSkeleton()) + + const renderMarkdown = () => { + try { + if (window.globalThis.showdown && window.globalThis.showdown.Converter) { + const converter = new window.globalThis.showdown.Converter() + + converter.setFlavor('github'); + converter.setOption('openLinksInNewWindow', true); + + const html = converter.makeHtml(contentString) + + // Replace skeleton with actual content + domElement.innerHTML = html + } else { + // Fallback to plain text if library not loaded + domElement.innerHTML = '' + domElement.textContent = contentString + } + } catch (error) { + console.error("Error rendering markdown:", error) + domElement.innerHTML = '' + domElement.textContent = contentString + } + } + + ContextImpl.context.page.loadScript("showdown", "/js/libraries/showdown.min.js", function(error) { + if (error) { + console.error("Failed to load markdown library:", error) + domElement.innerHTML = '' + domElement.textContent = contentString + } else { + renderMarkdown() + } + }) + }, + ...props + }) + } +} \ No newline at end of file diff --git a/src/apm/elements/otp.ts b/src/apm/elements/otp.ts index 08803d9c..2661cb7c 100644 --- a/src/apm/elements/otp.ts +++ b/src/apm/elements/otp.ts @@ -1,96 +1,154 @@ module ProcessOut { - const { div, label } = elements + const { div, label: labelEl, input } = elements export interface OTPProps { name: string; length: number; type?: 'text' | 'numeric'; + disabled?: boolean; + errored?: boolean; + value?: string; + label?: string; onComplete?: (key: string, otp: string) => void; } - const state = { - values: null, - focusedIndex: 0, - }; + export const OTP = ({ label, name, length, type = 'text', disabled, errored, onComplete, value }: OTPProps): VNode => { + const { state, setState, watch } = useComponentState({ + values: new Array(length).fill(''), + focusedIndex: 0, + isComplete: false, + }); - let inputRefs: HTMLInputElement[] = []; + // Watch for focusedIndex changes to handle focus + watch('focusedIndex', (newIndex) => { + const targetInput = inputRefs[newIndex]; + if (targetInput) { + targetInput.focus(); + } + }); - export const OTP = ({ name, length, type = 'text', onComplete }: OTPProps): VNode => { + let inputRefs: HTMLInputElement[] = []; + + // Check for completion - only call onComplete once per completion + const isCurrentlyComplete = state.values.every(v => v); + + if (isCurrentlyComplete && !value) { + onComplete?.(name, state.values.join('')); + } /** * Synchronizes the DOM to match the current state. This function is the single * source of truth for how the inputs should appear. */ - const update = (): void => { - state.values.forEach((value, index) => { - const input = inputRefs[index]; - if (!input) return; - - // Update the input's value from state. - input.value = value; - - // Update styling and attributes based on the single source of truth: the state object. - const elWrapper = input.parentElement; // Assuming Input component has a wrapper - - if (value) { - elWrapper?.classList.add('filled'); - } else { - elWrapper?.classList.remove('filled'); - } + const update = (newState: typeof state): void => { + const isComplete = newState.values.every(v => v); + let isCurrentlyComplete = newState.isComplete; + + if (onComplete && isComplete && !isCurrentlyComplete) { + isCurrentlyComplete = true; + onComplete(name, newState.values.join('')); + } else if (!isComplete && isCurrentlyComplete) { + isCurrentlyComplete = false; + } - // The `disabled` state is now handled by the declarative blueprint, but we can - // still manage classes here if needed. - if (index === state.focusedIndex) { - elWrapper?.classList.remove('disabled'); - input.removeAttribute('disabled'); - } else { - input.setAttribute('disabled', ''); - elWrapper?.classList.add('disabled'); - } + setState({ + ...newState, + isComplete: isCurrentlyComplete, }); + }; - // Set focus based on the state. - inputRefs[state.focusedIndex]?.focus(); + /** + * Handles paste events to allow full OTP codes to be pasted + */ + const handlePaste = (index: number, e: ClipboardEvent): void => { + e.preventDefault(); + const pastedText = e.clipboardData?.getData('text') || ''; + const currentValue = pastedText.trim(); + const isNumeric = type === 'numeric'; - // Check for completion. - if (onComplete && state.values.every(v => v)) { - onComplete(name, state.values.join('')); + // Handle pasting a full code + const cleaned = isNumeric ? currentValue.replace(/[^0-9]/g, '') : currentValue; + + if (cleaned.length === length) { + update({ + ...state, + values: cleaned.slice(0, length).split(''), + focusedIndex: length - 1, + }); + return; } }; /** - * Handles user input, including pasting, for single-character changes. + * Handles user input - single character or autocomplete */ const handleOnChange = (index: number, value: string): void => { const currentValue = value.trim(); const isNumeric = type === 'numeric'; - // -- SCENARIO 1: Handle pasting a full code -- + // Handle autocomplete/multiple characters (like SMS OTP autocomplete) if (currentValue.length === length) { const cleaned = isNumeric ? currentValue.replace(/[^0-9]/g, '') : currentValue; + + // If it's a full OTP code, distribute it across all inputs if (cleaned.length === length) { - state.values = cleaned.split(''); - state.focusedIndex = length - 1; - update(); + update({ + ...state, + values: cleaned.split(''), + focusedIndex: length - 1, + }); return; } + + // Handle partial autocomplete + const newValues = [...state.values]; + let newFocusedIndex = index; + + for (let i = 0; i < cleaned.length && index + i < length; i++) { + newValues[index + i] = cleaned[i]; + newFocusedIndex = index + i; + } + + // Move focus to next empty input or last filled input + if (newFocusedIndex < length - 1) { + newFocusedIndex++; + } + + update({ + ...state, + values: newValues, + focusedIndex: newFocusedIndex + }); + return; } + // Handle single character input const char = currentValue[0]; const isAllowed = isNumeric ? /^[0-9]$/.test(char) : true; + let newValues = [...state.values]; + let newFocusedIndex = state.focusedIndex; if (isAllowed && char) { - state.values[index] = char; + newValues[index] = char; // Move focus to the next input if this one is filled and not the last. if (index < length - 1) { - state.focusedIndex = index + 1; + newFocusedIndex = index + 1; } } else { // If the input is invalid or empty, we ensure the state reflects that. - // The update call will then reset the input's value to this empty string. - state.values[index] = ''; + newValues[index] = ''; } - update(); + const inputRef = inputRefs[index]; + + if (inputRef) { + inputRef.value = char; + } + + update({ + ...state, + values: newValues, + focusedIndex: newFocusedIndex + }); }; /** @@ -100,25 +158,28 @@ module ProcessOut { if (e.key !== 'Backspace') return; e.preventDefault(); - if (state.values[index]) { + let newValues = [...state.values]; + let newFocusedIndex = state.focusedIndex; + if (newValues[index]) { // If the current input has a value, just clear it and stay focused. - state.values[index] = ''; + newValues[index] = ''; } else if (index > 0) { // If the current input is already empty, move focus to the previous one. - state.focusedIndex = index - 1; - state.values[state.focusedIndex] = '' + newFocusedIndex = index - 1; + newValues[newFocusedIndex] = ''; } - update(); + update({ + ...state, + values: newValues, + focusedIndex: newFocusedIndex, + }); }; - const handleWrapperClick = (e: MouseEvent): void => { + const handleHiddenFocus = (e: FocusEvent): void => { e.preventDefault() - if ((e.target as HTMLElement).tagName !== 'INPUT') { - inputRefs[state.focusedIndex]?.focus(); - } + inputRefs[state.focusedIndex]?.focus(); }; - state.values = state.values || new Array(length).fill(''); inputRefs.length = 0; const inputs = new Array(length).fill(0).map((_, i) => { @@ -126,10 +187,13 @@ module ProcessOut { name: `${name}-${i + 1}`, oninput: (_, value: string) => handleOnChange(i, value), onkeydown: (e: KeyboardEvent) => handleKeyDown(i, e), - disabled: i !== state.focusedIndex, + onpaste: (e: ClipboardEvent) => handlePaste(i, e), + disabled: disabled || i !== state.focusedIndex, + errored: errored, value: state.values[i], id: `${name}-${i + 1}`, - type: "text", // Use 'text' to allow single char input, pattern for numbers + type: "text", // Use 'text' to allow input, pattern for numbers + maxlength: i === 0 ? undefined : 1, // First input allows autocomplete, others limited to 1 char pattern: type === "numeric" ? "\\d*" : undefined, autocomplete: i === 0 ? "one-time-code" : "off", inputMode: type === "numeric" ? "numeric" : undefined, @@ -142,6 +206,15 @@ module ProcessOut { }); // Return the final element tree. - return div(label({ className: 'otp', onclick: handleWrapperClick }, ...inputs)); + return div({ className: 'otp-container' }, + label ? Header({ title: label, tag: 'label', className: 'otp-label', htmlFor: name }, label) : null, + div( + labelEl( + { className: 'otp', htmlFor: name }, + ...inputs, + input({ className: 'hidden', type: 'text', name, id: name, tabindex: -1, onfocus: handleHiddenFocus }) + ) + ) + ); }; } diff --git a/src/apm/elements/phone.ts b/src/apm/elements/phone.ts index b4908b1a..02ea46a4 100644 --- a/src/apm/elements/phone.ts +++ b/src/apm/elements/phone.ts @@ -7,19 +7,13 @@ module ProcessOut { value: string, name: string, }> - oninput?: (key: string, value: { dialing_code: string, value: string }) => void, + oninput?: FormFieldUpdate, onblur?: (key: string, value: { dialing_code: string, value: string }) => void, value?: { dialing_code: string, value: string }, } const { div, label: labelEl, img, input, select, option } = elements - let state = { - dialing_code: '', - value: '', - iso: '' - } - let phoneRef: HTMLInputElement = null; let dialingCodesRef: HTMLSelectElement = null; let focusMethod = 'mouse'; @@ -50,11 +44,74 @@ module ProcessOut { return `${getDialingCode(dialingCode)}${getNumber(number)}` } + const parseCleanNumber = (currentValue: string, dialingCode: string, iso: string) => { + const phoneUtil = (window as any).libphonenumber.PhoneNumberUtil.getInstance(); + + try { + // Try to parse as international number first + const parsedNumber = phoneUtil.parseAndKeepRawInput(currentValue, iso); + return parsedNumber.getNationalNumber().toString(); + } catch (error) { + // Fallback to string manipulation if parsing fails + return currentValue.replace(dialingCode, '').replace(/ /g, '').replace(/^0/, ''); + } + } + export const Phone = ({ dialing_codes, name, oninput, onblur, disabled, label, errored, className, value, id, ...props }: PhoneProps) => { - state = value ? { ...value, iso: '' } : state + // Use StateManager for internal state management + const { state, setState } = useComponentState({ + dialing_code: value?.dialing_code || dialing_codes[0]?.value || '', + value: value?.value || '', + iso: '' + }); + + // Load libphonenumber and handle all state initialization in callback + ContextImpl.context.page.loadScript('libphonenumber', 'https://cdnjs.cloudflare.com/ajax/libs/google-libphonenumber/3.2.42/libphonenumber.min.js', () => { + let dialingCode = state.dialing_code || dialing_codes[0].value; + let phoneNumber = state.value || ''; + let iso = state.iso; + + // Set ISO code using libphonenumber + if (!iso) { + const phoneUtil = (window as any).libphonenumber?.PhoneNumberUtil?.getInstance(); + if (phoneUtil) { + try { + const number = phoneUtil.parseAndKeepRawInput(getFullNumber(dialingCode, phoneNumber), ''); + const regionCode = phoneUtil.getRegionCodeForNumber(number); + iso = regionCode || dialing_codes.find(item => item.value === dialingCode)?.region_code || ''; + } catch (error) { + // Fallback to manual lookup if parsing fails + iso = dialing_codes.find(item => item.value === state.dialing_code)?.region_code || ''; + } + } else { + // Fallback if libphonenumber not available + iso = dialing_codes.find(item => item.value === state.dialing_code)?.region_code || ''; + } + } + + // Update the UI if elements are available + if (phoneRef) { + phoneRef.value = getFullNumber(dialingCode, phoneNumber); + if (label) { + updateFilledState(phoneRef); + } + } + + if (dialingCodesRef) { + dialingCodesRef.value = iso; + } + + // Trigger callback to update form state if there's a value + if (value) { + oninput && oninput(name, state, true); + } - state.dialing_code = state.dialing_code || dialing_codes[0].value; - state.iso = state.iso || dialing_codes.find(item => item.value === state.dialing_code).region_code; + setState({ + dialing_code: dialingCode, + value: phoneNumber, + iso: iso + }); + }); const classNames = [ "field phone filled", @@ -66,13 +123,91 @@ module ProcessOut { const handleInputChange = e => { - const input = e.target as HTMLInputElement; - const dialingCode = state.dialing_code; - const numberStartIndex = dialingCode.length + 1; + const phoneUtil = (window as any).libphonenumber.PhoneNumberUtil.getInstance(); + const input = e.target as HTMLInputElement; const currentValue = input.value; const cursorPosition = input.selectionStart; + let dialingCode = state.dialing_code; + let phoneNumber = state.value; + let iso = state.iso; + + // Helper function to update state and UI when country is detected + const updateDetectedCountry = (detectedCountry, nationalNumber: string) => { + dialingCode = detectedCountry.dialingCode.value; + phoneNumber = nationalNumber; + iso = detectedCountry.region; + + // Update the input with formatted value + const formattedValue = getFullNumber(dialingCode, phoneNumber); + input.value = formattedValue; + + // Update flag image + const flagImg = input.parentElement.querySelector('img'); + if (flagImg) { + flagImg.src = `https://flagcdn.com/w80/${iso.toLowerCase()}.jpg`; + flagImg.alt = `Selected ${detectedCountry.dialingCode.name} dialing code`; + } + + // Update select value + if (dialingCodesRef) { + dialingCodesRef.value = iso; + } + + if (label) { + updateFilledState(input); + } + + // Trigger callback + oninput && oninput(name, { + dialing_code: dialingCode, + value: phoneNumber, + }); + + // Set cursor at end + input.setSelectionRange(formattedValue.length, formattedValue.length); + + setState({ + dialing_code: dialingCode, + value: phoneNumber, + iso: iso + }); + }; + + // First remove the current prefix if it exists to check what was actually pasted + let valueWithoutCurrentPrefix = currentValue; + if (currentValue.startsWith(dialingCode)) { + valueWithoutCurrentPrefix = currentValue.substring(dialingCode.length).trim(); + } + + // Check if user pasted/autocompleted a full international number (starts with +) + if (valueWithoutCurrentPrefix.startsWith('+')) { + try { + const parsedNumber = phoneUtil.parseAndKeepRawInput(valueWithoutCurrentPrefix, ''); + const countryCode = parsedNumber.getCountryCode(); + const nationalNumber = parsedNumber.getNationalNumber().toString(); + + // Find matching dialing code in our list + const matchingDialingCode = dialing_codes.find(code => + code.value === `+${countryCode}` + ); + + if (matchingDialingCode) { + const detectedCountry = { + dialingCode: matchingDialingCode, + region: matchingDialingCode.region_code + }; + updateDetectedCountry(detectedCountry, nationalNumber); + return; + } + } catch (error) { + + } + } + + const numberStartIndex = dialingCode.length + 1; + // --- 2. Calculate cursor's position within the numeric part --- // How many digits are to the left of the cursor, ignoring the prefix? const charsBeforeCursor = currentValue.substring(0, cursorPosition); @@ -82,8 +217,9 @@ module ProcessOut { const dialingCodeDigits = (dialingCode.match(/\d/g) || []).length; cursorPositionInDigits = Math.max(0, cursorPositionInDigits - dialingCodeDigits); - const allDigits = (currentValue.match(/\d/g) || []).join(''); - const cleanNumber = allDigits.substring(dialingCodeDigits); + // Use libphonenumber to properly parse the number + const cleanNumber = parseCleanNumber(currentValue, dialingCode, iso); + const formattedValue = getFullNumber(dialingCode, cleanNumber); let newCursorPosition = numberStartIndex; @@ -104,17 +240,31 @@ module ProcessOut { updateFilledState(input); } + if (currentValue.length < getDialingCode(dialingCode).length) { + phoneNumber = cleanNumber; dialingCodesRef.focus() dialingCodesRef.showPicker() + setState({ + dialing_code: dialingCode, + value: phoneNumber, + iso: iso + }); return } if (state.value !== cleanNumber) { - state.value = cleanNumber; - oninput && oninput(name, state); + phoneNumber = cleanNumber; + oninput && oninput(name, { + dialing_code: dialingCode, + value: phoneNumber, + }); } - + setState({ + dialing_code: dialingCode, + value: phoneNumber, + iso: iso + }); input.setSelectionRange(newCursorPosition, newCursorPosition); } @@ -149,13 +299,19 @@ module ProcessOut { const handleSelectChange = e => { const currentValue = (e.target as HTMLSelectElement).value; - const cleanNumber = state.value.replace(state.dialing_code, '').replace(/ /g, ''); - - state.dialing_code = dialing_codes.find(item => item.region_code === currentValue).value - state.iso = currentValue; - phoneRef.value = getFullNumber(state.dialing_code, cleanNumber); + const cleanNumber = parseCleanNumber(getFullNumber(state.dialing_code, state.value), state.dialing_code, state.iso); + + const newDialingCode = dialing_codes.find(item => item.region_code === currentValue).value; + + setState({ + dialing_code: newDialingCode, + iso: currentValue, + value: cleanNumber + }); + + phoneRef.value = getFullNumber(newDialingCode, cleanNumber); phoneRef.focus(); - oninput && oninput(name, state); + oninput && oninput(name, { dialing_code: newDialingCode, value: cleanNumber }); (e.target as HTMLSelectElement).parentElement.querySelector('img').src = `https://flagcdn.com/w80/${currentValue.toLowerCase()}.jpg`; } @@ -193,6 +349,10 @@ module ProcessOut { const handleMouseDown = () => { focusMethod = 'mouse'; } + + if (!state.dialing_code) { + return null + } return div( { @@ -205,8 +365,8 @@ module ProcessOut { { className: "dialing-code-label" }, img({ width: 22, - alt: `Selected ${dialing_codes.find(item => item.value === state.dialing_code).name} dialing code`, - src: `https://flagcdn.com/w80/${dialing_codes.find(item => item.value === state.dialing_code).region_code.toLowerCase()}.jpg`, + alt: `Selected ${state.iso} dialing code`, + src: `https://flagcdn.com/w80/${state.iso.toLowerCase()}.jpg`, }), ), div( diff --git a/src/apm/elements/qr.ts b/src/apm/elements/qr.ts new file mode 100644 index 00000000..2076a7b2 --- /dev/null +++ b/src/apm/elements/qr.ts @@ -0,0 +1,245 @@ +module ProcessOut { + const { div } = elements + + export interface QRProps extends Props<'div'> { + data: string; + size?: number; + errorMessage?: string; + downloadFilename?: string; + id?: string; + } + + // QR component state interface + interface QRComponentState { + isDownloading: boolean; + isCopying: boolean; + canvas: HTMLCanvasElement | null; + } + + export const QR = ({ + data, + size = 128, + errorMessage = "Failed to decode QR code", + downloadFilename = "qr-code.png", + id = `qr-${Math.random().toString(36).substr(2, 9)}`, + className, + ...props + }: QRProps) => { + if (!data) { + return div({ className: ["qr-error", className].filter(Boolean).join(" ") }, "No QR code data provided") + } + + // Use the new component state system + const { state, setState } = useComponentState({ + isDownloading: false, + isCopying: false, + canvas: null + }); + + + const classNames = ["qr-code-container", className].filter(Boolean).join(" ") + let downloadButtonRef: HTMLButtonElement | null = null; + let copyButtonRef: HTMLButtonElement | null = null; + + // Create QR skeleton loader + const createQRSkeleton = () => { + const skeletonContainer = document.createElement('div') + skeletonContainer.className = 'qr-skeleton' + skeletonContainer.style.width = `${size}px` + skeletonContainer.style.height = `${size}px` + + // Calculate dots based on size - approximately 1 dot per 20px + const dotsPerRow = Math.max(3, Math.floor(size / 20)) + const dotCount = dotsPerRow * dotsPerRow + const dotWidth = (100 / dotsPerRow) + '%' + + // Create subtle dot pattern + for (let i = 0; i < dotCount; i++) { + const dot = document.createElement('div') + dot.className = 'qr-dot' + dot.style.width = dotWidth + + // Sequential delay - wave effect from top-left to bottom-right + const row = Math.floor(i / dotsPerRow) + const col = i % dotsPerRow + const sequentialDelay = (row * dotsPerRow + col) * 0.05 // 50ms between each dot + dot.style.setProperty('--animation-delay', `${sequentialDelay}s`) + + skeletonContainer.appendChild(dot) + } + + return skeletonContainer + } + + /** + * Synchronizes the DOM to match the current state. This function is the single + * source of truth for how the buttons should appear. + */ + const update = (): void => { + // Update download button + if (downloadButtonRef) { + if (state.isDownloading) { + downloadButtonRef.disabled = true; + downloadButtonRef.classList.add('loading'); + // Create and append loader + const loader = document.createElement('div'); + loader.className = 'loader'; + downloadButtonRef.appendChild(loader); + } else { + // Only enable download button if canvas is available + downloadButtonRef.disabled = !state.canvas; + downloadButtonRef.classList.remove('loading'); + downloadButtonRef.querySelector('.loader')?.remove(); + } + } + + // Update copy button + if (copyButtonRef) { + if (state.isCopying) { + copyButtonRef.disabled = true; + copyButtonRef.querySelector('.content').textContent = 'Copied!'; + } else { + copyButtonRef.disabled = false; + copyButtonRef.querySelector('.content').textContent = 'Copy code'; + } + } + }; + + const downloadQR = (): void => { + if (!state.canvas || state.isDownloading) { + if (!state.canvas) { + console.error("QR code canvas not found for download") + } + return + } + + // Update state to loading + setState(prevState => ({ ...prevState, isDownloading: true })); + update(); + + try { + // Convert canvas to blob + state.canvas.toBlob((blob) => { + if (!blob) { + console.error("Failed to create blob from canvas"); + setState(prevState => ({ ...prevState, isDownloading: false })); + update(); + return; + } + + // Create download link + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = downloadFilename; + + // Trigger download + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + // Clean up + URL.revokeObjectURL(url); + + // Emit download-image event + ContextImpl.context.events.emit('download-image'); + + setTimeout(() => { + setState(prevState => ({ ...prevState, isDownloading: false })); + update(); + }, 1000); + + }, 'image/png'); + } catch (error) { + console.error("Error downloading QR code:", error); + setState(prevState => ({ ...prevState, isDownloading: false })); + update(); + } + }; + + return div({ + className: classNames, + ...props + }, [ + // QR Code Display + div({ + className: "qr-code", + ref: (domElement: HTMLDivElement | null) => { + if (!domElement) return + + // Check if QR data has changed to prevent unnecessary re-renders + const dataHash = simpleHash(data + size.toString()) // Include size in hash + const currentDataHash = domElement.getAttribute('data-qr-hash') + if (currentDataHash === dataHash) { + return // Data hasn't changed, skip re-render + } + + // Store current data hash for comparison + domElement.setAttribute('data-qr-hash', dataHash) + + // Show QR skeleton immediately + domElement.innerHTML = '' + const skeleton = createQRSkeleton() + domElement.appendChild(skeleton) + + const createQR = () => { + try { + // Decode the base64 value + const text = atob(data) + + if (window.globalThis.QRCode && text) { + // Clear skeleton and create QR code + domElement.innerHTML = '' + + new window.globalThis.QRCode(domElement, { + text, + width: size, + height: size, + colorDark: ThemeImpl.mode === 'light' ? ThemeImpl.instance.get('palette.light.text.default') : ThemeImpl.instance.get('palette.dark.text.default'), + colorLight: ThemeImpl.mode === 'light' ? ThemeImpl.instance.get('palette.light.background') : ThemeImpl.instance.get('palette.dark.background'), + }) + + // Store reference to the canvas in state + const canvas = domElement.querySelector('canvas') + if (canvas) { + setState(prevState => ({ ...prevState, canvas })); + } + update(); + } + } catch (error) { + domElement.innerHTML = '' + domElement.textContent = errorMessage + domElement.className = domElement.className + " qr-error" + } + } + + ContextImpl.context.page.loadScript("qrcode", "/js/libraries/qrcode.min.js", function(error) { + if (error) { + domElement.innerHTML = '' + domElement.textContent = "Failed to load QR code library" + domElement.className = domElement.className + " qr-error" + } else { + createQR() + } + }) + } + }), + + div({ className: "qr-actions" }, [ + Button({ + variant: "secondary", + size: "sm", + type: "button", + onclick: downloadQR, + ref: (element: HTMLButtonElement | null) => { + downloadButtonRef = element; + + if (downloadButtonRef) { + update(); + } + } + }, "Download image"), + ]) + ]) + } +} \ No newline at end of file diff --git a/src/apm/elements/select.ts b/src/apm/elements/select.ts index 788870d9..9b1a4cb5 100644 --- a/src/apm/elements/select.ts +++ b/src/apm/elements/select.ts @@ -3,7 +3,7 @@ module ProcessOut { name: string; label: string; options: Array<{ - key: string; + value: string; label: string; }> value?: string; @@ -45,7 +45,7 @@ module ProcessOut { } }, ...options.map(item => { - return option({ value: item.key, selected: item.key === value }, item.label) + return option({ value: item.value, selected: item.value === value }, item.label) }) ) diff --git a/src/apm/elements/status-tick.ts b/src/apm/elements/status-tick.ts new file mode 100644 index 00000000..11ea1064 --- /dev/null +++ b/src/apm/elements/status-tick.ts @@ -0,0 +1,12 @@ +module ProcessOut { + const { div } = elements; + export interface TickProps { + state?: 'pending' | 'completed' | 'idle' + } + + export const StatusTick = ({ state = 'idle' }: TickProps) => ( + div({ className: `status-tick ${state}` }, + Tick() + ) + ) +} \ No newline at end of file diff --git a/src/apm/elements/subheader.ts b/src/apm/elements/subheader.ts index 3fd8b3c5..94c74544 100644 --- a/src/apm/elements/subheader.ts +++ b/src/apm/elements/subheader.ts @@ -1,5 +1,5 @@ module ProcessOut { - type SubHeaderTag = 'h2' | 'h3' | 'h4' | 'h5' | 'h6' + type SubHeaderTag = 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'label' type SubHeaderTagProps = Props type SubHeaderProps = SubHeaderTagProps & { tag: K @@ -9,14 +9,14 @@ module ProcessOut { export const SubHeader = (...args: HeaderArgs) => { const first = args[0] const content: string = isProps(first) ? args[1] : first; - const props: SubHeaderTagProps = isProps(first) ? first : {} - const tag: SubHeaderTag = props.tag || 'h1'; + const props: SubHeaderTagProps = isProps(first) ? first : {} as SubHeaderTagProps + const tag: SubHeaderTag = props.tag || 'h2'; delete props.tag - const className = ["sub-header", props.className].filter(Boolean) + const className = ["sub-heading", props.className].filter(Boolean).join(' ') - const el = elements[tag]; + const el = elements[tag] as any; return el({ ...props, className }, content) } } diff --git a/src/apm/elements/tick.ts b/src/apm/elements/tick.ts new file mode 100644 index 00000000..14d6756e --- /dev/null +++ b/src/apm/elements/tick.ts @@ -0,0 +1,7 @@ +module ProcessOut { + const { div } = elements; + + export const Tick = () => ( + div({ className: "tick" }) + ) +} \ No newline at end of file diff --git a/src/apm/events/APMEventListener.ts b/src/apm/events/APMEventListener.ts index b2bd3166..b4447087 100644 --- a/src/apm/events/APMEventListener.ts +++ b/src/apm/events/APMEventListener.ts @@ -1,9 +1,77 @@ module ProcessOut { export interface APMEvents extends EventMap { - loading: never; - "success": never; - "error": { message: string; code: string }; - 'critical-failure': { message: string; code: string }; + // Initial event that is sent prior any other event + "initialised": never + + // Indicates that implementation successfully loaded initial portion of data and currently waiting for user + // to fulfil needed info + "start": never + + // This event is emitted when a user clicks the "Cancel payment" button, prompting the system to display a + // confirmation dialog. This event signifies the initiation of the cancellation confirmation process + "request-cancel": never + + // Event is sent when the user changes any editable value + "field-change": { + parameter: { key: string, value: FormState['values'][string] } + } + + // Event is sent just before sending user input, this is usually a result of a user action, e.g. button press + "submit": { + parameters: { key: string, value: FormState['values'][string] }[] + } + + // Sent in case parameters were submitted successfully. You could inspect the associated value to understand + // whether additional input is required + "submit-success": { + additionalParametersExpected: boolean + } + + // Sent in case parameters submission failed and if error is retriable, otherwise expect `did-fail` event + "submit-error": { + failure: { message: string; code: string } + } + + // Event is sent after all information is collected, and implementation is waiting for a PSP to confirm payment. + // You could check associated value `additionalActionExpected` to understand whether user needs + // to execute additional action(s) outside application, for example confirming operation in his/her banking app + // to finalize payment + "payment-pending": never + + // This event is triggered during the `PENDING` state when the user confirms that they have completed + // any required external action (if applicable). Once the event is triggered, the implementation + // proceeds with the actual completion confirmation process + "pending-confirmed": never + + "payment-cancelled": never + + // Event is sent after payment was confirmed to be completed. This is a final event + "success": { + trigger: 'user' | 'timeout' | 'immediate' + } + + // Event is sent in case unretryable error occurs. This is a final event + "failure": { + // Failure + failure: { message: string; code: string } + + // Indicates the payment state at the moment the failure occurred + // This provides additional context about where in the payment process the failure happened + paymentState?: string + } + + "copy-to-clipboard": { + text: string + } + + "download-image": never + + // Catch-all event that fires for every event with unified structure + "*": { + [K in keyof Omit]: APMEvents[K] extends never + ? { type: K } + : { type: K } & APMEvents[K] + }[keyof Omit] } export class APMEventsImpl extends EventListenerImpl { @@ -19,8 +87,11 @@ module ProcessOut { super.off(key, handler); } - emit(key: K, ...payload: APMEvents[K] extends never ? [] : [payload: APMEvents[K]] + emit>(key: K, ...payload: APMEvents[K] extends never ? [] : [payload: APMEvents[K]] ) { + if (key === '*') { + return; + } super.emit(key, ...payload); } } diff --git a/src/apm/events/EventListener.ts b/src/apm/events/EventListener.ts index 80f6f710..c8020b01 100644 --- a/src/apm/events/EventListener.ts +++ b/src/apm/events/EventListener.ts @@ -36,7 +36,16 @@ module ProcessOut { ...payload: M[K] extends never ? [] : [payload: M[K]] ) { const data = payload[0] as M[K]; // undefined for “no-payload” keys + // Emit to specific event handlers this.handlers[key]?.forEach(handler => (handler as any)(data)); + + // Emit to '*' handlers with unified structure + if (key !== '*' && this.handlers['*']) { + const unifiedData = data === undefined + ? { type: key } + : { type: key, ...data }; + this.handlers['*'].forEach(handler => (handler as any)(unifiedData)); + } } } } diff --git a/src/apm/index.ts b/src/apm/index.ts index b7c6fc8a..c6087eb3 100644 --- a/src/apm/index.ts +++ b/src/apm/index.ts @@ -21,11 +21,26 @@ module ProcessOut { } ContextImpl.instance.initialise({ + allowCancelation: true, ...data, + success: { + enabled: true, + requiresAction: false, + autoDismissDuration: 3, + manualDismissDuration: 60, + ...data.success, + }, + confirmation: { + requiresAction: false, + timeout: MIN_15 / 1000, + allowCancelation: true, + ...data.confirmation, + }, logger: { error: (options: Omit[0], 'stack'>) => { if (DEBUG === true) { console.error(options.message) + return; } logger.reportError({ @@ -33,11 +48,22 @@ module ProcessOut { stack: new Error().stack }); }, + warn: (options: Omit[0], 'stack'>) => { + if (DEBUG === true) { + console.warn(options.message) + return; + } + + logger.reportWarning({ + ...options, + stack: new Error().stack + }); + } }, events: new APMEventsImpl(), reload: () => { ContextImpl.context.page.render(APMViewLoading) - ContextImpl.context.page.load(APIImpl.initialise) + ContextImpl.context.page.load(APIImpl.getCurrentStep) }, page: new APMPageImpl(containerEl), poClient: poClient, @@ -45,8 +71,15 @@ module ProcessOut { } public initialise() { + ContextImpl.context.events.emit('initialised') ContextImpl.context.page.render(APMViewLoading) - ContextImpl.context.page.load(APIImpl.initialise) + + ContextImpl.context.page.load(APIImpl.initialise, (err) => { + ContextImpl.context.events.emit('start') + if (err) { + ContextImpl.context.events.emit('failure', { failure: { code: 'processout-js.internal-error', message: err.message } }) + } + }) } public cleanUp() { diff --git a/src/apm/layouts/Main.ts b/src/apm/layouts/Main.ts new file mode 100644 index 00000000..e534d7c9 --- /dev/null +++ b/src/apm/layouts/Main.ts @@ -0,0 +1,42 @@ +module ProcessOut { + const { div, img, picture, source } = elements + + interface MainProps extends Props<'div'> { + config?: Partial, + hideAmount?: boolean, + buttons: VNode | VNode[], + } + + export function Main({ config, hideAmount, buttons, ...props }: MainProps, ...children: VNode[]) { + return ( + page(props, + config?.payment_method + ? div({ className: 'header' }, + div({ className: 'logo' }, + picture({}, + source({ + media: '(prefers-color-scheme: dark)', + srcset: config.payment_method.logo.dark_url.raster + }), + img({ + src: config.payment_method.logo.light_url.raster, + alt: config.payment_method.display_name, + height: 34 + }) + ) + ), + !hideAmount && config.invoice ? div({ className: 'amount' }, + `Pay ${formatCurrency(config.invoice.amount, config.invoice.currency)}` + ) : null + ) + : null, + div({ className: 'container'}, + ...children, + buttons ? div({ className: 'buttons-container' }, + ...(Array.isArray(buttons) ? buttons : [buttons]) + ) : null + ), + ) + ) + } +} \ No newline at end of file diff --git a/src/apm/references.ts b/src/apm/references.ts index 1347936c..3dca5f09 100644 --- a/src/apm/references.ts +++ b/src/apm/references.ts @@ -5,24 +5,38 @@ /// /// /// +/// +/// /// /// /// /// /// +/// /// /// /// /// /// /// +/// +/// +/// +/// +/// +/// +/// /// /// /// /// /// /// -/// +/// +/// +/// /// +/// +/// /// /// diff --git a/src/apm/types.ts b/src/apm/types.ts index 15f3ef00..e933d75e 100644 --- a/src/apm/types.ts +++ b/src/apm/types.ts @@ -38,7 +38,11 @@ module ProcessOut { export type Container = string | Element export interface InitialData { - email: string + email: string, + phone_number: { + dialing_code: string, + value: string, + } } } diff --git a/src/apm/utils.ts b/src/apm/utils.ts index da0f8280..ae3be47f 100644 --- a/src/apm/utils.ts +++ b/src/apm/utils.ts @@ -1,6 +1,9 @@ module ProcessOut { export type PlainObject = object; - + export const SECOND_1 = 1000; + export const MIN_1 = SECOND_1 * 60; + export const MIN_15 = MIN_1 * 15; + export function formatCurrency(amount: string, currencyCode: string) { const formatter = new Intl.NumberFormat(navigator.language, { style: 'currency', @@ -10,9 +13,23 @@ module ProcessOut { return formatter.format(parseFloat(amount)); } + /** + * Simple hash function for content comparison (djb2 algorithm) + * @param str - String to hash + * @returns Short hash string in base36 format + */ + export function simpleHash(str: string): string { + let hash = 5381; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) + hash) + str.charCodeAt(i); + } + return (hash >>> 0).toString(36); // Convert to base36 for shorter string + } + function dedent(strings: TemplateStringsArray, ...values: unknown[]): string { const raw = String.raw(strings, ...values); // untouched text - const indent = raw.match(/^[ \t]*(?=\S)/m)[0].length; // leading spaces of first non-blank line + const match = raw.match(/^[ \t]*(?=\S)/m); + const indent = match ? match[0].length : 0; // handle empty strings const pattern = new RegExp(`^[ \\t]{0,${indent}}`, 'gm'); return raw.replace(pattern, '').trim(); // strip & trim } @@ -115,7 +132,6 @@ module ProcessOut { return originalValue.apply(receiver, args); } catch (error) { errorHandler(error); - throw 'The above error was thrown and handled, please review the above error message for more details.' } }; } diff --git a/src/apm/views/CancelRequest.ts b/src/apm/views/CancelRequest.ts new file mode 100644 index 00000000..4399f0dc --- /dev/null +++ b/src/apm/views/CancelRequest.ts @@ -0,0 +1,27 @@ +module ProcessOut { + const { div } = elements + + interface CancelRequestProps { + config: APISuccessBase & Partial, + } + + export class APMViewCancelRequest extends APMViewImpl { + onCancelClick() { + ContextImpl.context.events.emit('payment-cancelled') + } + onBackClick() { + ContextImpl.context.page.load(APIImpl.getCurrentStep) + } + + render() { + return Main({ config: this.props.config, hideAmount: true, buttons: [ + Button({ onclick: this.onBackClick.bind(this) }, 'Back to payment'), + Button({ onclick: this.onCancelClick.bind(this), variant: 'secondary' }, 'Cancel payment'), + ] }, + div({ className: 'cancel-request' }, + div({ className: 'cancel-request-message' }, 'Are you sure you want to cancel the payment?') + ), + ) + } + } +} \ No newline at end of file diff --git a/src/apm/views/Components.ts b/src/apm/views/Components.ts index 21ba23aa..1115de5b 100644 --- a/src/apm/views/Components.ts +++ b/src/apm/views/Components.ts @@ -58,11 +58,154 @@ module ProcessOut { div(), ), div({ className: 'empty-controls' }, - OTP({ name: 'otp', length: 6 }), - Phone({ label: 'Phone number (optional)', dialing_codes: [{ region_code: 'FR', name: 'France', value: '+33' }, { region_code: 'GB', name: 'United Kingdom', value: '+44' }, { region_code: 'PL', name: 'Poland', value: '+48' }] }), - Input({ name: 'full-name', label: 'Phone number', type: 'text', oninput: (key, value) => console.log(value) }), - Select({ label: 'Select', name: 'select', options: [{ key: '1', label: 'Option 1' }, { key: '2', label: 'Option 2' }] }) - ) + h2({ className: 'empty-subtitle' }, 'Form Elements'), + ...renderElements( + [{ + type: 'form', + parameters: { + parameter_definitions: [ + { type: 'otp', key: 'otp', label: 'One time password', required: true, max_length: 6, min_length: 6, subtype: 'digits' }, + { type: 'phone', key: 'phone', label: 'Phone', required: true, dialing_codes: [{ region_code: 'FR', name: 'France', value: '+33' }, { region_code: 'GB', name: 'United Kingdom', value: '+44' }, { region_code: 'PL', name: 'Poland', value: '+48' }] }, + { type: 'email', key: 'email', label: 'Email', required: true, min_length: 1, max_length: 100 }, + { type: 'text', key: 'text', label: 'Text', required: true, min_length: 1, max_length: 100 }, + { type: 'single-select', key: 'single-select', label: 'Single select', required: true, available_values: [{ value: '1', label: 'Option 1', preselected: false }, { value: '2', label: 'Option 2', preselected: false }] }, + { type: 'boolean', key: 'boolean', label: 'Boolean', required: true }, + { type: 'boolean', key: 'boolean2', label: 'Boolean 2', required: true }, + ] + } + }], + { + state: { + form: { + values: {}, + errors: {}, + touched: {}, + validation: {} + }, + loading: false + }, + setState: () => {}, + } + ), + ), + div({ className: 'empty-controls' }, + h2({ className: 'empty-subtitle' }, 'Instructions'), + ...renderElements( + [ + { + type: 'instruction', + instruction: { + type: 'message', + label: 'Copy code', + value: '123456' + } + }, + { + type: 'instruction', + instruction: { + type: 'message', + label: 'Copy code', + value: '123456' + } + }, + { + type: 'instruction', + instruction: { + type: 'message', + value: [ + '# Headers', + '# h1', + '## h2', + '### h3', + '#### h4', + '##### h5', + '###### h6', + 'Alternatively, for H1 and H2, an underline-ish style:', + 'Alt-H1', + '======', + 'Alt-H2', + '------', + '', + '## Emphasis', + 'Emphasis, aka italics, with *asterisks* or _underscores_.', + 'Strong emphasis, aka bold, with **asterisks** or __underscores__.', + 'Combined emphasis with **asterisks and _underscores_**.', + 'Strikethrough uses two tildes. ~~Scratch this.~~', + '', + '## Lists', + '1. First ordered list item', + '2. Another item', + ' * Unordered sub-list.', + '1. Actual numbers don\'t matter, just that it\'s a number', + '1. Ordered sub-list', + '4. And another item.', + '', + ' You can have properly indented paragraphs within list items. Notice the blank line above, and the leading spaces (at least one, but we\'ll use three here to also align the raw Markdown).', + '', + ' To have a line break without a paragraph, you will need to use two trailing spaces.', + ' Note that this line is separate, but within the same paragraph.', + ' (This is contrary to the typical GFM line break behaviour, where trailing spaces are not required.)', + '* Unordered list can use asterisks', + '- Or minuses', + '+ Or pluses', + '', + '## Links', + '[I\'m an inline-style link](https://www.google.com)', + '', + '[I\'m an inline-style link with title](https://www.google.com "Google\'s Homepage")', + '', + '[I\'m a reference-style link][Arbitrary case-insensitive reference text]', + '', + '[I\'m a relative reference to a repository file](../blob/master/LICENSE)', + '', + '[You can use numbers for reference-style link definitions][1]', + '', + '[I\'m a relative reference to a repository file](../blob/master/LICENSE)', + '', + '[You can use numbers for reference-style link definitions][1]', + '', + 'Or leave it empty and use the [link text itself].', + '', + 'URLs and URLs in angle brackets will automatically get turned into links. ', + 'http://www.example.com or and sometimes ', + 'example.com (but not on Github, for example).', + '', + 'Some text to show that the reference links can follow later.', + '', + '[arbitrary case-insensitive reference text]: https://www.mozilla.org', + '[1]: http://slashdot.org', + '[link text itself]: http://www.reddit.com', + '', + '## Images', + 'Here\'s our logo (hover to see the title text):', + '', + 'Inline-style: ![alt text](https://github.com/adam-p/markdown-here/raw/master/src/common/images/icon48.png "Logo Title Text 1")', + '', + 'Reference-style: ![alt text][logo]', + '', + '[logo]: https://github.com/adam-p/markdown-here/raw/master/src/common/images/icon48.png "Logo Title Text 2"', + '', + '## Blockquotes', + '> Blockquotes are very handy in email to emulate reply text.', + '> This line is part of the same quote.', + '', + 'Quote break.', + '', + '> This is a very long line that will still be quoted properly when it wraps. Oh boy let\'s keep writing to make sure this is long enough to actually wrap for everyone. Oh, you can *put* **Markdown** into a blockquote. ', + ].join('\n') + } + }, + { + type: 'instruction', + instruction: { + type: 'barcode', + subtype: 'qr', + value: '123456' + } + }, + ] + ), + ), ) } } diff --git a/src/apm/views/NextSteps.ts b/src/apm/views/NextSteps.ts index 967f8ea7..533c280e 100644 --- a/src/apm/views/NextSteps.ts +++ b/src/apm/views/NextSteps.ts @@ -1,20 +1,7 @@ module ProcessOut { export interface NextStepProps { elements: APIElements, - config: { - success: boolean - state: string - invoice: APIInvoice - gateway: object - error?: { - code: string - message: string - invalid_fields?: Array<{ - name: string - message: string - }> - } - } + config: (APISuccessBase | APIValidationBase) & Partial } export interface NextStepState { @@ -22,8 +9,11 @@ module ProcessOut { loading: boolean; } - const setFormState = (elements: NextStepProps['elements'], error: NextStepProps['config']['error'] | undefined): FormState => { - const forms = elements.filter(e => e.type === "form") + const { div } = elements + + const setFormState = (elements: NextStepProps['elements'], config: NextStepProps['config']): FormState | null => { + const error = 'error' in config ? config.error : undefined; + const forms = elements?.filter(e => e.type === "form") ?? [] if (forms.length === 0) { return null @@ -42,15 +32,37 @@ module ProcessOut { state.values = forms.reduce((acc, form) => { form.parameters.parameter_definitions.forEach(param => { - if (param.type === 'single-select') { - acc[param.key] = param.available_values.find(item => item.preselected)?.key || param.available_values[0].key + // Check for prefilled data from initialData + const initialData = ContextImpl.context.initialData; + const prefilledValue = initialData?.[param.key]; + + // If we have prefilled data, use it and exit early + if (prefilledValue) { + // Special handling for phone numbers - convert string to expected object format + if (param.type === 'phone' && typeof prefilledValue === 'string') { + acc[param.key] = { + dialing_code: param.dialing_codes[0].value, + value: prefilledValue, + }; + } else { + acc[param.key] = prefilledValue; + } + return; } - if (param.type === 'phone') { - acc[param.key] = { - dialing_code: param.dialing_codes[0].value, - value: '', - } + switch (param.type) { + case 'single-select': + acc[param.key] = param.available_values.find(item => item.preselected)?.value || param.available_values[0].value + break; + case 'phone': + acc[param.key] = { + dialing_code: param.dialing_codes[0].value, + value: '', + } + break; + default: + acc[param.key] = '' + break; } }) return acc; @@ -60,15 +72,13 @@ module ProcessOut { return { ...acc, ...form.parameters.parameter_definitions.reduce((acc, param) => { - if (typeof param.required === 'undefined') { - return acc; - } - return { ...acc, [param.key]: { email: param.type === "email", - required: param.required, + required: param.required ?? false, + minLength: 'min_length' in param ? param.min_length : undefined, + maxLength: 'max_length' in param ? param.max_length : undefined, } } }, {}) @@ -77,9 +87,9 @@ module ProcessOut { return state } - const setInitialState = (elements: APIElements, errors: any | undefined): NextStepState => { + const setInitialState = (elements: APIElements, config: NextStepProps['config']): NextStepState => { const state: NextStepState = { loading: false }; - const form = setFormState(elements, errors); + const form = setFormState(elements, config); if (form) { state.form = form; @@ -89,22 +99,42 @@ module ProcessOut { } export class APMViewNextSteps extends APMViewImpl { - state = setInitialState(this.props.elements, this.props.config.error) + state = setInitialState(this.props.elements, this.props.config) private handleSubmit() { const state = this.state - if (state.form && !validateForm(this.setState.bind(this))) { + if (state.form && !validateForm(state, this.setState.bind(this))) { return; } + if (state.form) { + const form = state.form; // Capture in local variable for type narrowing + ContextImpl.context.events.emit('submit', { parameters: Object.keys(form.values).map(key => ({ key, value: form.values[key] })) }) + } + this.setState({ loading: true }); - ContextImpl.context.page.load(APIImpl.sendFormData(state.form?.values ?? {})) + ContextImpl.context.page.load(APIImpl.sendFormData(state.form?.values ?? {}), (err, state) => { + if (err) { + ContextImpl.context.events.emit('submit-error', { failure: { code: err.code, message: err.message } }) + } else { + ContextImpl.context.events.emit('submit-success', { additionalParametersExpected: state === 'NEXT_STEP_REQUIRED' }) + } + }) } render() { - console.log(this.props); - return page( + const hasErrors = this.state.form?.errors ? + Object.keys(this.state.form.errors).some(key => this.state.form.errors[key]) : + false; + + return Main({ + config: this.props.config, + buttons: [ + Button({ onclick: this.handleSubmit.bind(this), disabled: hasErrors, loading: this.state.loading }, 'Continue'), + (ContextImpl.context.allowCancelation ? CancelButton({ config: this.props.config as APISuccessBase & Partial }) : null) + ] + }, ...renderElements( this.props.elements, { @@ -113,7 +143,6 @@ module ProcessOut { handleSubmit: this.handleSubmit.bind(this) } ), - Button({ onclick:this.handleSubmit.bind(this), loading: this.state.loading }, 'Continue'), ) } } diff --git a/src/apm/views/Pending.ts b/src/apm/views/Pending.ts new file mode 100644 index 00000000..975342a5 --- /dev/null +++ b/src/apm/views/Pending.ts @@ -0,0 +1,211 @@ +module ProcessOut { + interface PendingProps { + config: APISuccessBase & Partial, + elements?: APIElements + } + + const { div } = elements; + + export class APMViewPending extends APMViewImpl { + styles = css` + .steps { + display: flex; + flex-direction: column; + gap: 28px; + } + + .step { + display: flex; + gap: 12px; + position: relative; + } + + .step-status { + width: 24px; + height: 24px; + position: relative; + } + + .step::after { + content: ''; + position: absolute; + top: 24px; + bottom: -28px; + left: 13px; + transform: translateX(-50%); + width: 2px; + border-left: 2px dashed; + z-index: 2; + border-left-color: ${ThemeImpl.instance.get('palette.light.border.icon.tertiary')}; + @media (prefers-color-scheme: dark) { + border-left-color: ${ThemeImpl.instance.get('palette.dark.border.icon.tertiary')}; + } + } + + .step.completed::after { + border-left-color: ${ThemeImpl.instance.get('palette.light.surface.success')}; + @media (prefers-color-scheme: dark) { + border-left-color: ${ThemeImpl.instance.get('palette.dark.surface.success')}; + } + } + + .step:last-child::after { + display: none; + } + + .step-content { + display: flex; + flex-direction: column; + justify-content: center; + gap: 6px; + } + + .step-title { + font-weight: 500; + font-size: 15px; + line-height: 18px; + } + + .step-description { + font-weight: 500; + font-size: 12px; + line-height: 14px; + color: ${ThemeImpl.instance.get('palette.light.text.secondary')}; + @media (prefers-color-scheme: dark) { + color: ${ThemeImpl.instance.get('palette.dark.text.secondary')}; + } + } + ` + + state = { + countdown: this.calculateCountdown(), + } + + private intervalId: number | null = null + + private get confirmed(): boolean { + if (!ContextImpl.context.confirmation.requiresAction || !this.props.elements?.length) { + return true + } + + return !!storage.get('pending.startTime') + } + + private calculateCountdown(): number { + // If not confirmed yet, return full timeout + if (!this.confirmed) { + return ContextImpl.context.confirmation.timeout + } + + const startTime = storage.get('pending.startTime', Date.now()) + const elapsed = Math.floor((Date.now() - startTime) / 1000) + return Math.max(0, ContextImpl.context.confirmation.timeout - elapsed) + } + + componentDidMount() { + if (!this.confirmed) { + return; + } + + ContextImpl.context.events.emit('payment-pending') + this.startTimer() + } + + componentWillUnmount() { + if (this.intervalId) { + window.clearInterval(this.intervalId) + this.intervalId = null + } + // Clean up timer state + storage.remove('pending.startTime') + } + + private startTimer() { + // Get the original start time from storage, or use current time if not available + const originalStartTime = storage.get('pending.startTime', Date.now()) + + this.intervalId = window.setInterval(() => { + const elapsed = Math.floor((Date.now() - originalStartTime) / SECOND_1) + const remaining = Math.max(0, ContextImpl.context.confirmation.timeout - elapsed) + + this.setState(state => ({ + ...state, + countdown: remaining + })) + + if (remaining <= 0 && this.intervalId) { + window.clearInterval(this.intervalId) + this.intervalId = null + // Clean up timer state when timer completes + storage.remove('pending.startTime') + } + }, SECOND_1) + } + + formatCountdown(seconds: number): string { + const minutes = Math.floor(seconds / 60) + const remainingSeconds = seconds % 60 + const formattedSeconds = remainingSeconds < 10 ? `0${remainingSeconds}` : remainingSeconds.toString() + return `${minutes}:${formattedSeconds}` + } + + handleConfirmClick() { + const startTime = Date.now() + + // Store the start time for timer synchronization across refreshes + storage.set('pending.startTime', startTime) + + ContextImpl.context.events.emit('pending-confirmed') + ContextImpl.context.page.load(APIImpl.getCurrentStep) + + this.startTimer() + } + + handleCancelClick() { + APIImpl.cancelPolling() + } + + render() { + const confirmed = this.confirmed + const steps: Array<{ status: 'completed' | 'pending' | 'idle', title: string, description?: string, elements?: APIElements }> = [ + { + status: !confirmed ? 'pending' : 'completed', + title: !confirmed ? 'Waiting for payment' : 'Payment sent', + }, + { + status: !confirmed? 'idle' : 'pending', + title: 'Waiting for confirmation', + description: confirmed ? `Please wait up to ${this.formatCountdown(this.state.countdown)} minutes` : undefined, + elements: this.props?.elements + }, + ] + + return Main({ + config: this.props.config, + className: "pending-page", + buttons: [ + (!confirmed + ? Button({ onclick: this.handleConfirmClick.bind(this) }, 'I have sent the payment') + : null + ), + (ContextImpl.context.confirmation.allowCancelation + ? CancelButton({ onClick: this.handleCancelClick.bind(this), config: this.props.config }) + : null + ) + ] + }, + div({ className: "steps" }, + ...steps.map(step => div({ className: `step ${step.status}` }, + div({ className: "step-status" }, StatusTick({ state: step.status })), + div({ className: "step-content" }, + div({ className: "step-title" }, step.title), + step.description ? div({ className: "step-description" }, step.description) : null, + step.elements ? renderElements(step.elements) : null + ) + )) + ) + ) + } + } + } + \ No newline at end of file diff --git a/src/apm/views/Redirect.ts b/src/apm/views/Redirect.ts new file mode 100644 index 00000000..86545876 --- /dev/null +++ b/src/apm/views/Redirect.ts @@ -0,0 +1,51 @@ +module ProcessOut { + const { div } = elements; + interface RedirectProps { + config: APIRedirectBase & Partial, + elements?: APIElements + } + + export class APMViewRedirect extends APMViewImpl { + handleRedirectClick() { + ContextImpl.context.events.emit('redirect-initiated') + ContextImpl.context.poClient.handleAction( + this.props.config.redirect.url, + () => { + ContextImpl.context.events.emit('redirect-completed') + ContextImpl.context.page.load(APIImpl.getCurrentStep) + }, + (err) => { + ContextImpl.context.events.emit('failure', { + failure: { + message: err.message, + code: err.code + } + }) + } + ) + } + + render() { + const redirectLabel = `Pay ${formatCurrency(this.props.config.invoice.amount, this.props.config.invoice.currency)}`; + return ( + Main({ + config: this.props.config, + className: "redirect-page", + hideAmount: true, + buttons: [ + Button({ onclick: this.handleRedirectClick.bind(this) }, redirectLabel), + (ContextImpl.context.confirmation.allowCancelation + ? CancelButton({ config: this.props.config }) + : null + ) + ] + }, + div({ className: 'heading-container' }, + Header('Continue to payment'), + SubHeader('Click the button below to complete your payment'), + ) + ) + ) + } + } +} \ No newline at end of file diff --git a/src/apm/views/Success.ts b/src/apm/views/Success.ts index 6ca6204c..a4d1289e 100644 --- a/src/apm/views/Success.ts +++ b/src/apm/views/Success.ts @@ -1,15 +1,15 @@ module ProcessOut { + interface SuccessProps { + config: APISuccessBase & Partial, + elements?: APIElements + } + const { div } = elements; - const Tick = div({ className: "tick" }, - div({ className: "tick-icon" })) + export class APMViewSuccess extends APMViewImpl { + private timeoutSet = false - export class APMViewSuccess extends APMViewImpl<{ config: { invoice: { amount: string, currency: string }, gateway: object }}> { styles = css` - .success-page { - align-items: center; - } - .success-message { text-align: center; display: flex; @@ -17,94 +17,79 @@ module ProcessOut { align-items: center; width: 100%; } - .tick { + + .success-page .tick-container { width: 112px; height: 112px; display: flex; justify-content: center; align-items: center; + margin-bottom: 8px } - - .tick:before { - content: ""; - position: absolute; + .success-page .tick-background { width: 76px; height: 76px; - border-radius: 76px; - background-color: #e2f0e7; - z-index: 0; - animation: grow 1s ease-in-out infinite; } - .tick-icon { - position: relative; + .success-page .tick-container:before { + content: ""; + position: absolute; width: 76px; height: 76px; border-radius: 76px; - background-color: #119947; - z-index: 1; - transform-origin: center; - } - .tick-icon:before, .tick-icon:after { - content: ""; - position: absolute; - background-color: white; - transform-origin: bottom center; - width: 6px; - bottom: 20px; - border-radius: 6px; - } - .tick-icon:before { - height: 26px; - transform: rotate(-35deg); - left: calc(50% - 3px); - } - .tick-icon:after { - height: 43px; - transform: rotate(24deg); - left: calc(50% - 5px); - } - - @keyframes grow { - 0% { - transform: scale(0.8); - opacity: 1; - } - 80% { - transform: scale(1.5); - opacity: 1; - } - 85% { - transform: scale(1.5); - opacity: 1; - } - 86% { - opacity: 0; - transform: scale(1.5); - } - 100% { - opacity: 0; - transform: scale(0.8); + z-index: 0; + animation: grow 2s ease-in-out infinite; + background-color: ${ThemeImpl.instance.get('palette.light.surface.success')}; + @media (prefers-color-scheme: dark) { + background-color: ${ThemeImpl.instance.get('palette.dark.surface.success')}; } } + + .header-container { + display: flex; + flex-direction: column; + gap: 4px + } ` + private timeout = ContextImpl.context.success.requiresAction ? ContextImpl.context.success.manualDismissDuration : ContextImpl.context.success.autoDismissDuration + handleDoneClick() { - ContextImpl.context.events.emit('success'); + ContextImpl.context.events.emit('success', { trigger: 'user' }); } render() { - return page({ className: "success-page" }, + if (!this.props.config.invoice) { + ContextImpl.context.events.emit('success', { trigger: 'immediate' }); + return null + } + + if (!this.timeoutSet && this.timeout > 0) { + this.timeoutSet = true + setTimeout(() => { + ContextImpl.context.events.emit('success', { trigger: 'timeout' }); + }, this.timeout) + } + + return Main({ + config: this.props.config, + className: "success-page", + hideAmount: true, + buttons: ContextImpl.context.success.requiresAction + ? Button({ onclick: this.handleDoneClick.bind(this) }, 'Done') : null + }, div({ className: 'success-message' }, - Tick, + div({ className: 'tick-container' }, + div({ className: 'tick-background' }, + StatusTick({ state: 'completed' }), + ) + ), div({ className: "header-container" }, Header({ tag: 'h2' }, 'Payment approved!'), SubHeader({ tag: 'h3' }, `You paid ${formatCurrency(this.props.config.invoice.amount, this.props.config.invoice.currency)}`), ), ), - div({ className: "button-container" }, - Button({ onclick: this.handleDoneClick.bind(this) }, 'Done') - ) + ...(this.props.elements ? renderElements(this.props.elements) : []), ) } } diff --git a/src/apm/views/View.ts b/src/apm/views/View.ts index 586811a5..ce1c2bb3 100644 --- a/src/apm/views/View.ts +++ b/src/apm/views/View.ts @@ -1,6 +1,7 @@ module ProcessOut { export interface APMView

{ mount(): void + unmount(): void } export type APMViewConstructor

= new(container: Element, shadow: ShadowRoot | Document, props?: P) => APMView

@@ -10,41 +11,78 @@ module ProcessOut { export type SetState = (state: S | ((prevState: DeepReadonly) => S)) => void /** - * APMViewImpl is the "engine" of the UI system. It's a reusable base class that - * provides any view extending it with powerful, efficient, state-driven rendering, - * DOM patching, and style management. + * APMViewImpl - The Virtual DOM Engine + * + * This is the core of the UI system. It provides: + * • State-driven rendering with automatic batching + * • Virtual DOM with efficient diffing and patching + * • Isolated styling via Shadow DOM/iframe + * • Component lifecycle management + * • Error boundaries and debugging + * + * Flow: mount() -> render() -> patch DOM -> apply refs -> setState() -> batch updates -> render() -> patch DOM -> apply refs */ export class APMViewImpl

> implements APMView

{ readonly container: Element; readonly shadow: ShadowRoot | Document; protected props: P; - protected styles?: (() => CSSText); // Optional styles method + protected styles?: (() => CSSText); - protected state: DeepReadonly; // State is readonly externally + // State is always readonly to prevent direct mutations + protected state: DeepReadonly; - private _currentVDom: VNode | null = null; // Stores the last rendered Virtual DOM tree - private _pendingState: Partial | null = null; // Queues partial state updates + // Virtual DOM state - tracks what's currently rendered + private _currentVDom: VNode | null = null; + + // Update batching system - all setState calls are queued and processed together + private _pendingStateUpdates: Array) => S)> = []; private _isUpdateScheduled: boolean = false; + // Theme change listener cleanup function + private _themeChangeCleanup?: () => void; + constructor(container: Element, shadow: ShadowRoot | Document, props: P) { this.container = container; this.shadow = shadow; this.props = props; - this.state = {} as DeepReadonly; // Initialize with an empty object, expecting subclass to set it + this.state = {} as DeepReadonly; - // Wrap the instance in an error-handling proxy + // Wrap the entire instance in error handling - catches all method calls return createErrorHandlingProxy(this, this._handleRuntimeError.bind(this)); } - protected setState(partial: S | ((prevState: DeepReadonly) => S)): void { - // Merge current partial into pending. If 'partial' is a function, it applies to current pending state. - if (typeof partial === 'function') { - this._pendingState = (partial as (prevState: DeepReadonly) => S)(this._pendingState as DeepReadonly || this.state) as Partial; - } else { - this._pendingState = { ...(this._pendingState as S || this.state), ...partial } as Partial; + /** + * setState - React-style State Management + * + * Queues state updates to be processed in the next animation frame. + * Multiple calls are batched together for performance. + * + * @param state - New state object or function that receives previous state + */ + protected setState(state: S | ((prevState: DeepReadonly) => S)): void { + // Queue all updates in call order (functional and object updates mixed) + this._pendingStateUpdates.push(state); + + // Schedule processing if not already scheduled (batching) + if (!this._isUpdateScheduled) { + this._isUpdateScheduled = true; + requestAnimationFrame(() => this._processUpdateBatch()); } + } + + protected componentDidMount(): void { + } - // Schedule a single update if not already scheduled + protected componentWillUnmount(): void { + } + + /** + * Force Update - Immediate Re-render + * + * Triggers an immediate re-render without state changes. + * Used by stateful elements to update the parent view. + */ + public forceUpdate(): void { if (!this._isUpdateScheduled) { this._isUpdateScheduled = true; requestAnimationFrame(() => this._processUpdateBatch()); @@ -52,108 +90,162 @@ module ProcessOut { } /** - * Processes all queued state updates and triggers a single re-render. - * This method is called via requestAnimationFrame to batch updates. + * State Update Processor - The React Reconciliation Loop + * + * This is where the magic happens: + * 1. Apply all queued state updates sequentially + * 2. Skip render if state didn't actually change (optimization) + * 3. Generate new Virtual DOM tree + * 4. Patch real DOM to match Virtual DOM (minimal changes) + * 5. Update refs and styles + * 6. Clear StateManager change tracking for performance optimization */ private _processUpdateBatch(): void { - console.log('updating state', this._pendingState ) - // Reset scheduling flag this._isUpdateScheduled = false; - // If no pending state, nothing to do - if (this._pendingState === null) { + if (this._pendingStateUpdates.length === 0) { + // Force render even without state updates (for StateManager-triggered updates) + this.state = createReadonlyProxy(this.state as S); + this._applyStyles(); + + // Set view context for StateManager before rendering + setCurrentViewContext(this); + + // Generate new Virtual DOM tree based on new state + const newVDom = this.render.call(this); + + // Clear view context after rendering + setCurrentViewContext(null); + + // The heart of the system: patch real DOM to match Virtual DOM + this._patch(this.container, newVDom, this._currentVDom, this.container.firstChild); + + this._currentVDom = newVDom; + return; } - // Consolidate all pending state updates into a final new state - const finalPartialState = this._pendingState; - this._pendingState = null; // Clear pending state for the next cycle + // Apply all state updates in the exact order they were called + let newState = { ...this.state as S }; + + for (const update of this._pendingStateUpdates) { + if (typeof update === 'function') { + // Functional update: newState = prevState => newState + newState = update(createReadonlyProxy(newState)); + } else { + // Object update: newState = { ...prevState, ...update } + newState = { ...newState, ...update }; + } + } + this._pendingStateUpdates = []; const prevState = this.state; - const newState: S = { - ...(prevState as S), - ...(finalPartialState as S) - }; - // If the final new state is deeply equal to the previous state, skip re-render + // Performance optimization: skip expensive DOM operations if nothing changed if (isDeepEqual(prevState, newState)) { return; } - // Update the component's state, wrapping it in a readonly proxy this.state = createReadonlyProxy(newState); - - // Apply styles (in case state changes affect styles) this._applyStyles(); - // Generate the new desired Virtual DOM tree + + // Set view context for StateManager before rendering + setCurrentViewContext(this); + + // Generate new Virtual DOM tree based on new state const newVDom = this.render.call(this); - // Patch the actual DOM to reflect the changes from the old VDom to the new VDom - const newDomNode = this._patch(this.container, newVDom, this._currentVDom, this.container.firstChild); + // Clear view context after rendering + setCurrentViewContext(null); - // If the root node changed its actual DOM element, update the container's child - if (newDomNode && newDomNode !== this.container.firstChild) { - if (this.container.firstChild) { - this.container.replaceChild(newDomNode, this.container.firstChild); - } else { - this.container.appendChild(newDomNode); - } - } else if (!newDomNode && this.container.firstChild) { - this.container.removeChild(this.container.firstChild); - } + // The heart of the system: patch real DOM to match Virtual DOM + this._patch(this.container, newVDom, this._currentVDom, this.container.firstChild); - this._currentVDom = newVDom; // Store the new VDom tree for the next render cycle + this._currentVDom = newVDom; } /** - * The entry point for the component. Performs the initial, full render to the DOM. + * Component Mount - Initial Render + * + * Sets up the component and performs the first render. + * Uses the same patching system as updates for consistency. */ public mount(): void { - // Initialize state as a readonly proxy if it exists (for initial state in constructor) if (this.state) { this.state = createReadonlyProxy(this.state as S); } this._applyStyles(); - // Render the initial Virtual DOM tree + + this._themeChangeCleanup = ThemeImpl.onThemeChange(() => { + // Don't force update if an input is currently focused to prevent cursor position issues + const activeElement = document.activeElement; + const isInputFocused = activeElement && ( + activeElement.tagName === 'INPUT' || + activeElement.tagName === 'TEXTAREA' || + (activeElement as HTMLElement).contentEditable === 'true' + ); + + if (!isInputFocused) { + this.forceUpdate(); + } + }); + + // Set view context for StateManager before rendering + setCurrentViewContext(this); + const initialVDom = this.render.call(this); - if (!initialVDom) { - return; - } - // Clear existing children from the container before mounting - while (this.container.firstChild) { - this.container.removeChild(this.container.firstChild); - } + // Clear view context after rendering + setCurrentViewContext(null); - // Recursively create actual DOM elements from the initial Virtual DOM - const initialDomNode = this._createElement(initialVDom); - if (initialDomNode) { - this.container.appendChild(initialDomNode); - } + this._patch(this.container, initialVDom, null, this.container.firstChild); + + this._currentVDom = initialVDom; + + this.componentDidMount(); + } - this._currentVDom = initialVDom; // Store the initial VDom tree + public unmount(): void { + this.componentWillUnmount(); + + // Clean up theme change listener + if (this._themeChangeCleanup) { + this._themeChangeCleanup(); + this._themeChangeCleanup = undefined; + } + + // Clean up all components associated with this view + try { + const stateManager = getStateManager(); + stateManager.destroyViewComponents(this); + } catch (error) { + console.error('Error cleaning up view components:', error); + } } + /** - * To be implemented by the subclass. This method is expected to return the - * root HTMLElement of the component's view for the current state. + * Abstract Render Method + * + * Must be implemented by subclasses. Should return a Virtual DOM tree + * that represents the current state of the component. */ protected render(): VNode | null { - // This method must be overridden by subclasses this._defaultView(); return null; } /** - * The dedicated style manager. It calls the user-defined `styles` function - * and uses the provided `injectStyleTag` helper to apply CSS. + * Dynamic Style Injection + * + * Calls the component's styles() method (if defined) and injects + * the returned CSS into the Shadow DOM/iframe for isolation. */ private _applyStyles() { const raw = this.styles; + if (raw) { - // Calling raw with this context ensures that when the `styles` function is executed, - // `this` refers to the component instance, giving it access to `this.state` and `this.props`. const stylesheet = raw.call(this) if (typeof stylesheet !== 'string') { @@ -165,47 +257,53 @@ module ProcessOut { } /** - * Walks a newly created DOM tree and executes any `ref` callbacks. This ensures - * that components can get a reference to their live DOM nodes on initial mount. + * Ref System - Direct DOM Access + * + * Walks the DOM tree and calls ref callbacks with their corresponding + * DOM nodes. This allows components to get direct access to DOM elements + * when needed (focus, measurements, third-party integrations). */ private _applyRefs(node: Node, vNode: VNode): void { - // Only process HTMLElement nodes and valid VNodes that could have refs if (!(node instanceof HTMLElement) || typeof vNode !== 'object' || vNode === null) return; - const props = vNode.props; // Access props from the VNode + const props = vNode.props; if (props && typeof props.ref === 'function') { props.ref(node as any); } - // Recursively apply refs to children. - // Ensure vNode.children exists and is an array before iterating. + // Recursively apply refs to all children if (Array.isArray(vNode.children)) { for (let i = 0; i < vNode.children.length; i++) { const childVNode = vNode.children[i]; - // Only recurse if the child is a VNode object AND its DOM reference is set if (childVNode && childVNode.dom) { - this._applyRefs(childVNode.dom, childVNode); // Correct recursion with actual DOM node and VNode + this._applyRefs(childVNode.dom, childVNode); } } } } + /** + * Virtual DOM to Real DOM Converter + * + * Recursively creates real DOM nodes from Virtual DOM nodes. + * Handles text nodes, fragments, and regular elements. + */ private _createElement(vNode: VNode): Node | null { if (vNode == null) { return null; } - // Handle primitive VNodes (text nodes) + // Text nodes: "Hello World" becomes document.createTextNode("Hello World") if (vNode.type === '#text') { const textNode = document.createTextNode(vNode.value as string); - vNode.dom = textNode; // Store DOM reference on the VNode for primitives too + vNode.dom = textNode; return textNode; } - // If it's a DocumentFragment VNode (type is null) + // Document fragments: grouping multiple elements without a wrapper if (vNode.type === null) { const fragment = document.createDocumentFragment(); - vNode.dom = fragment; // Store DOM reference on the VNode + vNode.dom = fragment; if (Array.isArray(vNode.children)) { for (const childVNode of vNode.children) { const childDom = this._createElement(childVNode); @@ -217,14 +315,13 @@ module ProcessOut { return fragment; } - // Create the actual DOM element + // Regular elements: div, button, input, etc. const domElement = document.createElement(vNode.type); - vNode.dom = domElement; // Store DOM reference on the VNode + vNode.dom = domElement; - // Set properties/attributes on the newly created DOM element this._setProps(domElement, vNode.props); - // Recursively create and append children + // Recursively create children if (Array.isArray(vNode.children)) { for (const childVNode of vNode.children) { const childDom = this._createElement(childVNode); @@ -234,42 +331,77 @@ module ProcessOut { } } - this._applyRefs(domElement, vNode); // Apply refs for this element and its children + this._applyRefs(domElement, vNode); return domElement; } /** - * Sets properties/attributes on a newly created DOM element. - * This is called once during element creation, not for patching. - * @param element - The live DOM element. - * @param props - The properties from the VNode. + * Props Setter - Initial Element Configuration + * + * Sets properties and attributes on a DOM element from Virtual DOM props. + * Handles special cases like events (onclick), boolean attributes (disabled), + * and DOM properties vs HTML attributes. Skips undefined values and handles + * null values appropriately. */ private _setProps(element: HTMLElement, props: Props): void { for (const key in props) { - if (!Object.prototype.hasOwnProperty.call(props, key)) { + if (key === 'ref' || key === 'key') continue; + + const value = props[key]; + + // Skip undefined values entirely (they shouldn't set anything) + if (value === undefined) { continue; } - const value = (props as any)[key]; - if (value === null || value === undefined || key === 'ref' || key === 'key') { + // Event handlers: onclick, onchange, etc. + if (key.startsWith('on') && typeof value === 'function') { + const eventName = key.slice(2).toLowerCase(); + element.addEventListener(eventName, value); continue; } - const isEventHandler = key.startsWith('on') && typeof value === 'function'; - const attributeExistsAsProperty = key in element; + // Boolean attributes: disabled, checked, selected + if (typeof value === 'boolean') { + if (value) { + element.setAttribute(key, ''); + } + continue; + } - if (isEventHandler) { - element.addEventListener(key.slice(2).toLowerCase(), value as EventListener); - } else if (key === 'className' || key === 'class') { - element.className = value as string || ''; - } else if (key === 'value' || key === 'checked') { - (element as any)[key] = value; - } else if (attributeExistsAsProperty && !(element instanceof HTMLElement && key.startsWith('data-'))) { - try { - (element as any)[key] = value; - } catch (e) { - element.setAttribute(key, String(value)); + // Handle style objects: { width: '100px', height: '100px' } + if (key === 'style' && typeof value === 'object' && value !== null) { + for (const styleKey in value) { + const styleValue = (value as any)[styleKey]; + if (styleValue !== undefined) { + (element.style as any)[styleKey] = styleValue; + } } + continue; + } + + // Handle null values - they should remove/clear the property + if (value === null) { + if (key === 'style') { + // Clear all styles + element.style.cssText = ''; + } else if (key in element) { + // For DOM properties, set to empty string or appropriate default + const descriptor = Object.getOwnPropertyDescriptor(element, key) || + Object.getOwnPropertyDescriptor(Object.getPrototypeOf(element), key); + if (descriptor && descriptor.set) { + (element as any)[key] = ''; + } + } else { + // For attributes, remove them + element.removeAttribute(key); + } + continue; + } + + // Regular values: DOM properties vs HTML attributes + if (key in element) { + (element as any)[key] = value; } else { element.setAttribute(key, String(value)); } @@ -277,184 +409,284 @@ module ProcessOut { } /** - * Updates properties/attributes on an existing DOM element during patching. - * Removes old properties/attributes that are no longer present or have changed, - * and adds/updates new ones. Handles special cases like event listeners and `className`. + * Props Updater - Efficient Property Changes + * + * Updates only the properties that changed between old and new props. + * Properly handles undefined, null values and removes old properties. */ private _updateProps(element: HTMLElement, oldProps: Props, newProps: Props): void { - if (oldProps === newProps) return; // Optimization: If props object is identical + // Remove old properties that are no longer present or became undefined + for (const key in oldProps) { + if (key === 'ref' || key === 'key') continue; + + const newValue = newProps[key]; + + // Property was removed or became undefined + if (!(key in newProps) || newValue === undefined) { + if (key.startsWith('on') && typeof oldProps[key] === 'function') { + const eventName = key.slice(2).toLowerCase(); + element.removeEventListener(eventName, oldProps[key]); + } else if (key === 'style' && typeof oldProps[key] === 'object' && oldProps[key] !== null) { + // Clear all styles when style prop is removed + element.style.cssText = ''; + } else if (typeof oldProps[key] === 'boolean') { + element.removeAttribute(key); + } else if (key in element) { + // For DOM properties, set to empty string or appropriate default + const descriptor = Object.getOwnPropertyDescriptor(element, key) || + Object.getOwnPropertyDescriptor(Object.getPrototypeOf(element), key); + if (descriptor && descriptor.set) { + (element as any)[key] = ''; + } + } else { + element.removeAttribute(key); + } + } + } - const allKeys = new Set([...Object.keys(oldProps), ...Object.keys(newProps)]); + // Set new/updated properties + for (const key in newProps) { + if (key === 'ref' || key === 'key') continue; + + const oldValue = oldProps[key]; + const newValue = newProps[key]; - allKeys.forEach(key => { - const oldValue = (oldProps as any)[key]; - const newValue = (newProps as any)[key]; + // Skip if value hasn't changed + if (oldValue === newValue) continue; - // Skip 'key' as it's only for reconciliation, not a DOM prop - if (key === 'key') return; - // Special handling for the `ref` callback - if (key === 'ref') { - if (typeof newValue === 'function') { - (newValue as (node: HTMLElement | DocumentFragment | null) => void)(element); // Call ref with the live element + // Handle event listeners + if (key.startsWith('on') && typeof newValue === 'function') { + const eventName = key.slice(2).toLowerCase(); + + // Remove old listener if it exists + if (typeof oldValue === 'function') { + element.removeEventListener(eventName, oldValue); } - return; + element.addEventListener(eventName, newValue); + continue; } - // Optimization: If values are identical, no change needed - if (oldValue === newValue) return; + // Remove old event listener if new value is not a function + if (key.startsWith('on') && typeof oldValue === 'function' && typeof newValue !== 'function') { + const eventName = key.slice(2).toLowerCase(); + element.removeEventListener(eventName, oldValue); + continue; + } - // Special handling for 'className' or 'class' attribute - if (key === 'className' || key === 'class') { - if (element.className !== (newValue as string || '')) { - element.className = newValue as string || ''; + // Handle boolean attributes + if (typeof newValue === 'boolean') { + if (newValue) { + element.setAttribute(key, ''); + } else { + element.removeAttribute(key); } - return; + continue; } - // Special handling for input element properties like 'value' and 'checked' - if (key === 'value' || key === 'checked') { - if ((element as any)[key] !== newValue) { - (element as any)[key] = newValue; + // Handle style objects: { width: '100px', height: '100px' } + if (key === 'style' && typeof newValue === 'object' && newValue !== null) { + const oldStyle = (typeof oldValue === 'object' && oldValue !== null) ? oldValue : {}; + + // Remove old style properties that are no longer present + for (const styleKey in oldStyle) { + if (!(styleKey in newValue) || (newValue as any)[styleKey] === undefined) { + (element.style as any)[styleKey] = ''; + } } - return; + + // Set new/updated style properties + for (const styleKey in newValue) { + const newStyleValue = (newValue as any)[styleKey]; + const oldStyleValue = (oldStyle as any)[styleKey]; + + if (newStyleValue !== oldStyleValue && newStyleValue !== undefined) { + (element.style as any)[styleKey] = newStyleValue; + } + } + continue; } - // Handle event listeners (properties starting with 'on') - if (key.startsWith('on')) { - const eventName = key.slice(2).toLowerCase(); - if (oldValue && oldValue !== newValue) { - element.removeEventListener(eventName, oldValue as EventListener); - } - if (newValue && oldValue !== newValue) { - element.addEventListener(eventName, newValue as EventListener); + // Handle null values - they should clear/remove the property + if (newValue === null || newValue === undefined) { + if (key === 'style') { + // Clear all styles + element.style.cssText = ''; + } else if (key in element) { + // For DOM properties, set to empty string or appropriate default + const descriptor = Object.getOwnPropertyDescriptor(element, key) || + Object.getOwnPropertyDescriptor(Object.getPrototypeOf(element), key); + if (descriptor && descriptor.set) { + (element as any)[key] = ''; + } + } else { + // For attributes, remove them + element.removeAttribute(key); } - return; + continue; } - // For all other attributes/properties - if (newValue == null || newValue === false) { - element.removeAttribute(key); - } else { - // Attempt to set as a direct property first if applicable - if (key in element && !(element instanceof HTMLElement && key.startsWith('data-'))) { - try { - (element as any)[key] = newValue; - } catch (e) { - element.setAttribute(key, String(newValue)); + // Handle regular values: DOM properties vs HTML attributes + if (key in element) { + // Special handling for input value to preserve cursor position + if (key === 'value' && element instanceof HTMLInputElement) { + const oldValue = element.value; + const newValueStr = String(newValue); + + // Only update if the value actually changed + if (oldValue !== newValueStr) { + // If the input is focused, preserve cursor position + if (element === document.activeElement) { + const cursorPosition = element.selectionStart; + const cursorEnd = element.selectionEnd; + + element.value = newValueStr; + + // Preserve cursor position if it was within the old value + if (cursorPosition !== null && cursorEnd !== null) { + const newCursorPosition = Math.min(cursorPosition, newValueStr.length); + const newCursorEnd = Math.min(cursorEnd, newValueStr.length); + element.setSelectionRange(newCursorPosition, newCursorEnd); + } + } else { + // Input not focused, just update value + element.value = newValueStr; + } } } else { - element.setAttribute(key, String(newValue)); + (element as any)[key] = newValue; } + } else { + element.setAttribute(key, String(newValue)); } - }); + } } /** - * The core DOM patching algorithm. This function compares a new Virtual DOM node (`newVNode`) - * with the previous Virtual DOM node (`oldVNode`) and the corresponding live DOM element (`oldDomNode`). - * It then applies minimal changes to `oldDomNode` to make it match `newVNode`. - * - * @param parentDomNode - The parent live DOM element. - * @param newVNode - The newly generated desired Virtual DOM node. - * @param oldVNode - The previously rendered Virtual DOM node. - * @param oldDomNode - The actual live DOM node corresponding to oldVNode. - * @returns The updated or newly created live DOM node. + * Virtual DOM Patcher - The Reconciliation Algorithm + * + * This is the heart of the Virtual DOM system. It compares old and new + * Virtual DOM trees and makes minimal changes to the real DOM to match. + * + * The algorithm handles: + * • Node removal (newVNode is null) + * • Node creation (oldDomNode is null) + * • Node replacement (different types/keys) + * • Node updates (same type, update properties and children) + * + * All DOM operations include defensive checks to prevent errors. */ - private _patch(parentDomNode: Element, newVNode: VNode | null, oldVNode: VNode | null, oldDomNode: Node | null): Node | null { - // Case 1: Old node existed, new node is null/undefined (removal) + private _patch(parentDomNode: Element, newVNode: VNode | null, oldVNode: VNode | null, oldDomNode: Node | null): void { + // CASE 1: Remove node (component returned null or removed element) if (oldDomNode && (newVNode == null)) { - parentDomNode.removeChild(oldDomNode); - return null; + if (parentDomNode.isConnected && parentDomNode.contains(oldDomNode)) { + try { + parentDomNode.removeChild(oldDomNode); + } catch (e) { + console.warn('Failed to remove DOM node:', e); + } + } + return; } - // Case 2: New node exists, old node was null/undefined (initial creation/addition) - if (!oldDomNode && (newVNode != null)) { + // CASE 2: Create new node (initial render or new element added) + if (newVNode != null && (!oldVNode || !oldDomNode)) { const newDomNode = this._createElement(newVNode); - if (newDomNode) { - parentDomNode.appendChild(newDomNode); + if (newDomNode && parentDomNode.isConnected) { + try { + // If there's an old DOM node, replace it; otherwise append + if (oldDomNode && parentDomNode.contains(oldDomNode)) { + parentDomNode.replaceChild(newDomNode, oldDomNode); + } else { + parentDomNode.appendChild(newDomNode); + } + } catch (e) { + console.warn('Failed to create/replace DOM node:', e); + return; + } } - return newDomNode; + return; } - // At this point, oldDomNode, newVNode, and oldVNode are guaranteed to be non-null. - // Cast to non-null types for easier access. - const _newVNode = newVNode as VNode; - const _oldVNode = oldVNode as VNode; - const _oldDomNode = oldDomNode as Node; + // CASE 3: Nothing to do (both are null) + if (!newVNode && !oldVNode) { + return; + } - // Determine types correctly for VNodes - const oldVNodeType = _oldVNode.type; - const newVNodeType = _newVNode.type; + // CASE 4: Both virtual nodes exist - update existing node + if (newVNode && oldVNode && oldDomNode) { + const _newVNode = newVNode as VNode; + const _oldVNode = oldVNode as VNode; + const _oldDomNode = oldDomNode as Node; - const oldKey = _oldVNode.key; - const newKey = _newVNode.key; + const oldVNodeType = _oldVNode.type; + const newVNodeType = _newVNode.type; + const oldKey = _oldVNode.key; + const newKey = _newVNode.key; - // Handle text node value updates specifically, without full replacement - if (oldVNodeType === '#text' && newVNodeType === '#text') { - if (_oldDomNode.textContent !== _newVNode.value) { - _oldDomNode.textContent = _newVNode.value as string; + // CASE 4a: Update text content (common optimization) + if (oldVNodeType === '#text' && newVNodeType === '#text') { + if (_oldDomNode.textContent !== _newVNode.value) { + _oldDomNode.textContent = _newVNode.value as string; + } + _newVNode.dom = _oldDomNode; + return; } - _newVNode.dom = _oldDomNode; // Update DOM reference for the new VNode - return _oldDomNode; - } - // If types or keys differ, or if it's a type change (e.g., div to p) - if (oldVNodeType !== newVNodeType || oldKey !== newKey) { - const newDomNode = this._createElement(_newVNode); // Create actual DOM for new VNode - if (newDomNode) { - parentDomNode.replaceChild(newDomNode, _oldDomNode); - } else { // If newVNode results in null, but oldDomNode existed, remove it - parentDomNode.removeChild(_oldDomNode); + // CASE 4b: Replace node (different type or key - can't be updated) + if (oldVNodeType !== newVNodeType || oldKey !== newKey) { + const newDomNode = this._createElement(_newVNode); + if (newDomNode && parentDomNode.isConnected && parentDomNode.contains(_oldDomNode)) { + try { + parentDomNode.replaceChild(newDomNode, _oldDomNode); + } catch (e) { + console.warn('Failed to replace DOM node:', e); + return; + } + } + return; } - return newDomNode; - } - // Case 4: Both exist and are of the same type/key (update) - const targetDomNode = _oldDomNode as HTMLElement; // We will mutate the existing DOM node + // CASE 4c: Update existing node (same type and key) + const targetDomNode = _oldDomNode as HTMLElement; - // For element VNodes, update properties - this._updateProps(targetDomNode, _oldVNode.props, _newVNode.props); - _newVNode.dom = targetDomNode; // Update the VNode's DOM reference + // Update element properties (className, disabled, onclick, etc.) + this._updateProps(targetDomNode, _oldVNode.props, _newVNode.props); + _newVNode.dom = targetDomNode; - // Recursively patch children for element VNodes - // Ensure children arrays are handled - const oldHasChildren = _oldVNode.children.length > 0; - const newHasChildren = _newVNode.children.length > 0; + // Handle children updates + const oldHasChildren = _oldVNode.children && _oldVNode.children.length > 0; + const newHasChildren = _newVNode.children && _newVNode.children.length > 0; - if (oldHasChildren && !newHasChildren) { - while(targetDomNode.firstChild) { - targetDomNode.removeChild(targetDomNode.firstChild); - } - } - - if (!oldHasChildren && newHasChildren) { - while(targetDomNode.firstChild) { - targetDomNode.removeChild(targetDomNode.firstChild); - } - - for (const childVNode of _newVNode.children) { - const childDom = this._createElement(childVNode); - if (childDom) { - targetDomNode.appendChild(childDom); + if (oldHasChildren && !newHasChildren) { + // Remove all children + while(targetDomNode.firstChild) { + targetDomNode.removeChild(targetDomNode.firstChild); + } + } else if (!oldHasChildren && newHasChildren) { + // Add all children (replace any existing) + while(targetDomNode.firstChild) { + targetDomNode.removeChild(targetDomNode.firstChild); + } + const newChildren = _newVNode.children || []; + for (const childVNode of newChildren) { + const childDom = this._createElement(childVNode); + if (childDom) { + targetDomNode.appendChild(childDom); + } } + } else if (oldHasChildren && newHasChildren) { + // Complex case: diff and patch children + // Safety check: ensure children arrays are valid + const newChildren = _newVNode.children || []; + const oldChildren = _oldVNode.children || []; + this._patchChildren(targetDomNode, newChildren, oldChildren); } - } - if (oldHasChildren && newHasChildren) { - this._patchChildren(targetDomNode, _newVNode.children, _oldVNode.children); + this._applyRefs(targetDomNode, _newVNode); } - - // Re-apply refs for the element itself (children refs are handled recursively by _patchChildren) - this._applyRefs(targetDomNode, _newVNode); - - return targetDomNode; } - /** - * Retrieves the 'key' for a VNode. - * Keys are used for efficient child reconciliation. - */ private _getKey(vNode: VNode | null): string | null { if (vNode && vNode.type !== '#text') { return vNode.key || null; @@ -462,32 +694,24 @@ module ProcessOut { return null; } - /** - * Checks if two VNodes are considered "the same" for patching purposes, - * based on their type and key. - */ private _isSameVNode(a: VNode | null, b: VNode | null): boolean { - // Handle null/undefined cases if (!a || !b) { return a === b; } - // Compare by type first. if (a.type !== b.type) { return false; } - // If both are text nodes, compare values + // Text nodes are same if they have the same content if (a.type === '#text' && b.type === '#text') { return a.value === b.value; } + // Elements are same if they have the same type and key return this._getKey(a) === this._getKey(b); } - /** - * Creates a map of keys to their indices for a given range of VNodes. - */ private _createKeyMap(vNodes: VNode[], start: number, end: number): { [key: string]: number } { const map: { [key: string]: number } = {}; for (let i = start; i <= end; i++) { @@ -499,11 +723,24 @@ module ProcessOut { } /** - * The child reconciliation algorithm. This is a complex part of the patching process - * that efficiently updates, reorders, adds, and removes child nodes within a parent's - * live DOM. It compares virtual child lists and manipulates the actual DOM. + * Child Reconciliation Algorithm - The Most Complex Part + * + * This is based on React's reconciliation algorithm. It efficiently + * handles reordering, adding, and removing children with minimal DOM operations. + * + * The algorithm uses a "two-ended" approach: + * • Compare from both ends of the arrays simultaneously + * • Handle common cases (same start, same end, moved elements) + * • Use keys to efficiently handle complex reorderings + * • Minimize DOM manipulations (moves instead of delete+create) + * + * Example: + * Old: [A, B, C, D] + * New: [B, A, D, E] + * Result: Move B to start, keep A, remove C, keep D, add E */ private _patchChildren(parentDomNode: HTMLElement, newChildren: VNode[], oldChildren: VNode[]): void { + // Two-pointer approach: scan from both ends simultaneously let oldStartIndex = 0, newStartIndex = 0; let oldEndIndex = oldChildren.length - 1; let newEndIndex = newChildren.length - 1; @@ -515,22 +752,23 @@ module ProcessOut { let oldKeyMap: { [key: string]: number } | null = null; + // Main reconciliation loop while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) { - // Skip null old VNodes (which occur when a keyed node is moved or removed) - if (oldStartVNode == null) { + // Skip processed nodes (marked as undefined) + if (oldStartVNode == null || oldStartVNode === undefined) { oldStartVNode = oldChildren[++oldStartIndex]; continue; } - if (oldEndVNode == null) { + + if (oldEndVNode == null || oldEndVNode === undefined) { oldEndVNode = oldChildren[--oldEndIndex]; continue; } - // Get the corresponding live DOM nodes from the VNode.dom property const oldStartDomNode = oldStartVNode.dom; const oldEndDomNode = oldEndVNode.dom; - // If a DOM node reference is missing (e.g., node was already removed), skip this VNode + // Skip if DOM reference is missing if (!oldStartDomNode) { oldStartVNode = oldChildren[++oldStartIndex]; continue; @@ -540,8 +778,7 @@ module ProcessOut { continue; } - - // 1. Same VNode at the start (common case) + // OPTIMIZATION 1: Same element at start (most common case) if (this._isSameVNode(oldStartVNode, newStartVNode)) { this._patch(parentDomNode, newStartVNode, oldStartVNode, oldStartDomNode); oldStartVNode = oldChildren[++oldStartIndex]; @@ -549,7 +786,7 @@ module ProcessOut { continue; } - // 2. Same VNode at the end + // OPTIMIZATION 2: Same element at end if (this._isSameVNode(oldEndVNode, newEndVNode)) { this._patch(parentDomNode, newEndVNode, oldEndVNode, oldEndDomNode); oldEndVNode = oldChildren[--oldEndIndex]; @@ -557,51 +794,43 @@ module ProcessOut { continue; } - // 3. Old start VNode moved to new end position + // OPTIMIZATION 3: Element moved from start to end if (this._isSameVNode(oldStartVNode, newEndVNode)) { this._patch(parentDomNode, newEndVNode, oldStartVNode, oldStartDomNode); - // Move the actual DOM node - console.log('Old start VNode moved to new end position'); - // Defensive check before insertBefore to ensure oldStartDomNode is still a child + // Move DOM node to end position if (oldStartDomNode.parentNode === parentDomNode) { parentDomNode.insertBefore(oldStartDomNode, oldEndDomNode.nextSibling); - } else { - console.warn("Skipping insertBefore as oldStartDomNode is not a child of parentDomNode:", oldStartDomNode, parentDomNode); - // Fallback: If detached, just ensure it's removed and re-created later if needed by the algorithm } oldStartVNode = oldChildren[++oldStartIndex]; newEndVNode = newChildren[--newEndIndex]; continue; } - // 4. Old end VNode moved to new start position + // OPTIMIZATION 4: Element moved from end to start if (this._isSameVNode(oldEndVNode, newStartVNode)) { this._patch(parentDomNode, newStartVNode, oldEndVNode, oldEndDomNode); - // Move the actual DOM node - console.log('Old end VNode moved to new start position'); - // Defensive check before insertBefore + // Move DOM node to start position if (oldEndDomNode.parentNode === parentDomNode) { parentDomNode.insertBefore(oldEndDomNode, oldStartDomNode); - } else { - console.warn("Skipping insertBefore as oldEndDomNode is not a child of parentDomNode:", oldEndDomNode, parentDomNode); - // Fallback } oldEndVNode = oldChildren[--oldEndIndex]; newStartVNode = newChildren[++newStartIndex]; continue; } - // If no direct matches, use keys to find and move/create nodes + // GENERAL CASE: Use keys to find matching elements if (!oldKeyMap) { oldKeyMap = this._createKeyMap(oldChildren, oldStartIndex, oldEndIndex); } + const key = this._getKey(newStartVNode); const indexInOld = key != null ? oldKeyMap[key] : undefined; if (indexInOld == null) { - // newStartVNode not found in oldChildren (or no key), so it's a new node - const newDomNode = this._createElement(newStartVNode); // Create actual DOM for new node - // The reference node should be the first live, *unprocessed* DOM node at or after oldStartIndex + // New element - create and insert + const newDomNode = this._createElement(newStartVNode); + + // Find reference node for insertion let refDomNode: Node | null = null; for (let i = oldStartIndex; i <= oldEndIndex; i++) { const potentialRefVNode = oldChildren[i]; @@ -612,48 +841,41 @@ module ProcessOut { } if (newDomNode) { - console.trace(); // User's trace for this path - console.log('newStartVNode not found in oldChildren (or no key), so it\'s a new node'); parentDomNode.insertBefore(newDomNode, refDomNode); } } else { - // newStartVNode found in oldChildren, so it's a moved node + // Existing element - move and patch const nodeToMoveVNode = oldChildren[indexInOld]; - const nodeToMoveDom = nodeToMoveVNode!.dom; // Get the actual DOM node from its VNode. It must exist. + const nodeToMoveDom = nodeToMoveVNode!.dom; - this._patch(parentDomNode, newStartVNode, nodeToMoveVNode, nodeToMoveDom); // Patch the found node + this._patch(parentDomNode, newStartVNode, nodeToMoveVNode, nodeToMoveDom); - console.log('newStartVNode found in oldChildren, so it\'s a moved node'); - // Defensive check before insertBefore + // Move DOM node to correct position if (nodeToMoveDom && nodeToMoveDom.parentNode === parentDomNode) { parentDomNode.insertBefore(nodeToMoveDom, oldStartDomNode); - } else if (nodeToMoveDom) { - console.warn("Skipping insertBefore as nodeToMoveDom is not a child of parentDomNode or is null:", nodeToMoveDom, parentDomNode); - // If the node is somehow detached but exists, try to re-append if it's supposed to be here - // (This path might need more complex recovery depending on specific app logic) } - oldChildren[indexInOld] = null as any; // Mark the old VNode position as processed/removed + // Mark as processed + oldChildren[indexInOld] = undefined as any; } + newStartVNode = newChildren[++newStartIndex]; } - // After the main loop, handle remaining nodes (additions or removals) + // Handle remaining nodes if (oldStartIndex > oldEndIndex) { - // All old nodes processed, remaining new nodes are additions + // Add remaining new nodes for (let i = newStartIndex; i <= newEndIndex; i++) { const newDomNode = this._createElement(newChildren[i]); if (newDomNode) { - console.log('All old nodes processed, remaining new nodes are additions'); - parentDomNode.insertBefore(newDomNode, null); // Use null for appending at the end + parentDomNode.insertBefore(newDomNode, null); } } } else if (newStartIndex > newEndIndex) { - // All new nodes processed, remaining old nodes are removals + // Remove remaining old nodes for (let i = oldStartIndex; i <= oldEndIndex; i++) { const oldVNode = oldChildren[i]; - if (oldVNode != null) { - // Get the actual DOM node to remove from its VNode. + if (oldVNode != null && oldVNode !== undefined) { const oldDomNodeToRemove = oldVNode.dom; if (oldDomNodeToRemove && parentDomNode.contains(oldDomNodeToRemove)) { parentDomNode.removeChild(oldDomNodeToRemove); @@ -663,15 +885,18 @@ module ProcessOut { } } - /** - * Default view method, throws an error if `render()` is not implemented by a subclass. - * @private - */ private _defaultView(): void { throw new Error('Not implemented: render() method must be implemented by subclass.'); } + /** + * Error Handler - Component Error Boundaries + * + * Catches runtime errors and displays user-friendly error messages + * instead of breaking the entire application. + */ private _handleRuntimeError(err: any) { + console.log('err', err) if (err && err.name === 'UpdatedReadOnly') { ContextImpl.context.page.criticalFailure({ title: 'Failed to update view', @@ -679,7 +904,6 @@ module ProcessOut { }) return } - ContextImpl.context.page.criticalFailure({ title: 'An unexpected error occurred in the view', message: err.message, diff --git a/src/apm/views/utils/form.ts b/src/apm/views/utils/form.ts index c5baee63..096efe51 100644 --- a/src/apm/views/utils/form.ts +++ b/src/apm/views/utils/form.ts @@ -6,39 +6,64 @@ module ProcessOut { export interface FormState { touched: Record values: Record - validation: Record + validation: Record errors: Record } + export type FormFieldUpdate = (key: string, value: string | number | boolean | PhoneState, isInitial?: boolean) => void + export type FormFieldBlur = (key: string, value: string | number | boolean | PhoneState) => void + const { div, label, form } = elements const emailRegex = /^[\w\-\.+]+@([\w-]+\.)+[\w-]{2,4}$/ function validateField(state: NextStepState, key: string, value: string | number | boolean | PhoneState): string | undefined { const validation = state.form.validation[key] + if (!validation) { return } + const actualValue = isPlainObject(value) && 'value' in value ? value.value : value + switch (true) { case validation.required && - (typeof value === "undefined" || (typeof value === "string" && value.length === 0) || (isPlainObject(value) && 'value' in value && value.value.length === 0)): { + (typeof value === "undefined" || (typeof actualValue === "string" && actualValue.length === 0)): { return "Missing required value" } - case validation.email && typeof value === "string" && !value.match(emailRegex): { + case validation.email && typeof actualValue === "string" && (!actualValue || !actualValue.match(emailRegex)): { return "Missing valid email address" } + case validation.minLength && validation.maxLength && validation.minLength === validation.maxLength && (!actualValue || typeof actualValue === "string" && actualValue.length < validation.minLength): { + return `Must be exactly ${validation.minLength} characters` + } + case validation.minLength && typeof actualValue === "string" && (!actualValue || actualValue.length < validation.minLength): { + return `Must be at least ${validation.minLength} characters` + } + case validation.maxLength && typeof actualValue === "string" && actualValue.length > validation.maxLength: { + return `Must be no more than ${validation.maxLength} characters` + } } } - function updateField(setState: SetState) { - return function(key: string, value: string | number | boolean | PhoneState) { + + function updateField(setState: SetState): FormFieldUpdate { + return function(key, value, isInitial = false) { setState((prevState) => { - let errors = { ...prevState.form.errors } + // Check if the value has actually changed to prevent unnecessary re-renders + const currentValue = prevState.form.values[key]; + if (!isInitial && currentValue === value) { + return prevState; // No change, return the same state to prevent re-render + } + let errors = { ...prevState.form.errors } + console.log('updateField', key, value, isInitial) if (prevState.form.touched[key]) { delete errors[key] errors[key] = validateField(prevState, key, value) } + if (!isInitial) { + ContextImpl.context.events.emit('field-change', { parameter: { key, value } }) + } return { ...prevState, @@ -80,23 +105,21 @@ module ProcessOut { } } - export function validateForm(setState: SetState): boolean { - let successful = false; - - setState((prevState) => { - const touched = {} - const errors = Object.keys(prevState.form.values).reduce((acc, key) => { - touched[key] = true - const error = validateField(prevState, key, prevState.form.values[key]) - if (error) { - acc[key] = error; - } + export function validateForm(state: NextStepState, setState: SetState): boolean { + const touched = {} + const errors = Object.keys(state.form.validation).reduce((acc, key) => { + touched[key] = true + const error = validateField(state, key, state.form.values[key]) + if (error) { + acc[key] = error; + } - return acc - }, {}); + return acc + }, {}); - successful = isEmpty(errors) + const successful = isEmpty(errors) + setState((prevState) => { return { ...prevState, form: { @@ -110,69 +133,110 @@ module ProcessOut { return successful } - export function Form(props: FormData, state: NextStepState, setState: SetState, onSubmit: () => void) { - const fields = props.parameters.parameter_definitions.map((field) => { - const error = state.form.errors[field.key] - const value = state.form.values[field.key] - let input: VNode; - let labelHtmlFor = field.key; - - switch (field.type) { - case "otp": { - input = OTP({ - name: field.key, - length: field.min_length, - type: field.subtype === "digits" ? "numeric" : "text", - onComplete: updateField(setState), - }) - break; - } - case 'phone': { - labelHtmlFor = `${field.key}.value`; - input = Phone({ - name: field.key, - label: field.label, - dialing_codes: field.dialing_codes, - oninput: updateField(setState), - onblur: onBlur(setState), - errored: !!error, - disabled: state.loading, - value: value as PhoneState, - }); - break; - } - case "single-select": { - input = Select({ - name: field.key, - label: field.label, - value: value as string || field.available_values.find(item => item.preselected)?.key || '', - options: field.available_values, - onchange: updateField(setState), - onblur: onBlur(setState), - }) - break; - } - default: { - input = Input({ - type: field.type, - label: field.label, - name: field.key, - errored: !!error, - disabled: state.loading, - value: value as string, - oninput: updateField(setState), - onblur: onBlur(setState), - }) - } + // Extract form field rendering into a reusable function + const renderFormField = ( + field: FormFieldResult, + state: NextStepState, + setState: SetState + ): VNode => { + const error = state.form.errors[field.key] + const value = state.form.values[field.key] + let input: VNode; + let labelHtmlFor = field.key; + + switch (field.type) { + case "otp": { + input = OTP({ + name: field.key, + label: field.label, + length: field.min_length, + type: field.subtype === "digits" ? "numeric" : "text", + disabled: state.loading, + errored: !!error, + value: value as string, + onComplete: updateField(setState), + }) + break; + } + case 'phone': { + labelHtmlFor = `${field.key}.value`; + input = Phone({ + name: field.key, + label: field.label, + dialing_codes: field.dialing_codes, + oninput: updateField(setState), + onblur: onBlur(setState), + errored: !!error, + disabled: state.loading, + value: value as PhoneState, + }); + break; } + case "single-select": { + input = Select({ + name: field.key, + label: field.label, + value: value as string || field.available_values.find(item => item.preselected)?.value || '', + options: field.available_values, + errored: !!error, + disabled: state.loading, + onchange: updateField(setState), + onblur: onBlur(setState), + }) + break; + } + case 'boolean': { + input = Checkbox({ + name: field.key, + label: field.label, + checked: value as boolean, + onchange: updateField(setState), + onblur: onBlur(setState), + }) + break; + } + default: { + input = Input({ + type: field.type, + label: field.label, + name: field.key, + errored: !!error, + disabled: state.loading, + value: value as string, + oninput: updateField(setState), + onblur: onBlur(setState), + }) + } + } - return div({ className: "field-container" }, input, error ? label({ htmlFor: labelHtmlFor, className: "error" }, error) : null) - }) + return div({ className: "field-container" }, input, error ? label({ htmlFor: labelHtmlFor, className: "error" }, error) : null) + } + + // Grouping function for form fields + const getFormFieldGroupInfo = (field: FormFieldResult): { type: string, className?: string } | null => { + if (field.type === 'boolean') { + return { + type: 'boolean', + className: 'group-boolean' + } + } + + // Don't group other field types for now + return null + } + +export function Form(props: FormData, state: NextStepState, setState: SetState, onSubmit: () => void) { + const fields = createGroupedElements( + props.parameters.parameter_definitions, + getFormFieldGroupInfo, + (field) => renderFormField(field, state, setState) + ) return form({ className: "form", onsubmit: (e) => { e.preventDefault() + ContextImpl.context.events.emit('submit', { parameters: Object.keys(state.form.values).map(key => ({ key, value: state.form.values[key] })) }) onSubmit() } }, ...fields) diff --git a/src/apm/views/utils/instructions.ts b/src/apm/views/utils/instructions.ts new file mode 100644 index 00000000..06de5909 --- /dev/null +++ b/src/apm/views/utils/instructions.ts @@ -0,0 +1,21 @@ +module ProcessOut { + const { div } = elements + + export const Instruction = ({ instruction }: Omit) => { + switch (instruction.type) { + case "message": { + if (instruction.label) { + return CopyInstruction({ instruction }) + } + + return Markdown({ content: instruction.value }) + } + case "barcode": { + if (instruction.subtype === "qr") { + return QR({ data: instruction.value, size: 600 }) + } + return null + } + } + } +} \ No newline at end of file diff --git a/src/apm/views/utils/render-elements.ts b/src/apm/views/utils/render-elements.ts index 43785e19..925fd297 100644 --- a/src/apm/views/utils/render-elements.ts +++ b/src/apm/views/utils/render-elements.ts @@ -1,34 +1,133 @@ module ProcessOut { - const renderElement =

[number] = APIElements[number], S extends NextStepState = NextStepState>( - data: P & { - setState: (setter: S | ((prevState: DeepReadonly) => S)) => void - handleSubmit: () => void + const { div } = elements + + const renderElement = ( + data: APIElements[number] & { + setState?: (setter: NextStepState | ((prevState: DeepReadonly) => NextStepState)) => void + handleSubmit?: () => void }, - state: S, + state: NextStepState, ) => { switch (data.type) { case "form": { const { setState, handleSubmit, ...props } = data return Form(props, state, setState, handleSubmit) } + case "instruction": { + const { instruction } = data + return Instruction({ instruction }) + } default: { return null } } } - export const renderElements = (elements: APIElements, options: { - state: S, - setState: (setter: S | ((prevState: DeepReadonly) => S)) => void - handleSubmit: () => void + // Generic grouping function that can be reused anywhere + export const createGroupedElements = ( + items: T[], + getGroup: (item: T) => { type: string, className?: string } | null, + renderItem: (item: T) => VNode, + ): VNode[] => { + const result: VNode[] = [] + let currentGroup: VNode[] = [] + let inGroup = false + let currentGroupInfo: { type: string, className?: string } | null = null + const containerClassName = 'group' + + for (let i = 0; i < items.length; i++) { + const item = items[i] + const nextItem = items[i + 1] + + const groupInfo = getGroup(item); + const groupType = groupInfo?.type; + + const nextGroup = nextItem ? getGroup(nextItem) : null + const nextGroupType = nextGroup ? nextGroup.type : null + + const renderedElement = renderItem(item) + + if (!groupType) { + if (inGroup) { + const className = [containerClassName, currentGroupInfo.className].filter(Boolean).join(' ') + result.push(div({ className }, ...currentGroup)) + currentGroup = [] + inGroup = false + currentGroupInfo = null + } + + result.push(renderedElement) + continue + } + + if (!inGroup || currentGroupInfo?.type !== groupType) { + // Close any existing group if we're starting a different group type + if (inGroup) { + result.push(div({ className: containerClassName }, ...currentGroup)) + } + + // Start new group + inGroup = true + currentGroupInfo = groupInfo + currentGroup = [renderedElement] + } else { + // Add to existing group (same type) + currentGroup.push(renderedElement) + } + + // Close group if next element is different type or shouldn't be grouped + if (nextGroupType !== groupType) { + const className = [containerClassName, currentGroupInfo.className].filter(Boolean).join(' ') + result.push(div({ className }, ...currentGroup)) + currentGroup = [] + inGroup = false + currentGroupInfo = null + } + } + + // Close any remaining group + if (inGroup && currentGroup.length > 0) { + const className = [containerClassName, currentGroupInfo.className].filter(Boolean).join(' ') + result.push(div({ className }, ...currentGroup)) + } + + return result + } + + const getGroupInfo = ( + element: APIElements[number], + ): { type: string, className?: string } | null => { + if (element.type === 'instruction' && + element.instruction.type === 'message' && + element.instruction.label) { + return { + type: 'copy', + } + } + + return null + } + + export const renderElements = (elements: APIElements, options?: { + state?: NextStepState, + setState?: (setter: NextStepState | ((prevState: DeepReadonly) => NextStepState)) => void + handleSubmit?: () => void }): VNode[] => { - return elements.map(element => renderElement( - { - ...element, - setState: options.setState, - handleSubmit: options.handleSubmit, - }, - options.state - )) + if (!elements || !Array.isArray(elements)) { + return [] + } + + return createGroupedElements( + elements, + getGroupInfo, + (element) => renderElement( + { + ...element, + setState: options?.setState || (() => {}), + handleSubmit: options?.handleSubmit || (() => {}), + }, + options?.state || { loading: false } + ), + ) } } diff --git a/src/processout/processout.ts b/src/processout/processout.ts index 8a228d77..d27f121a 100644 --- a/src/processout/processout.ts +++ b/src/processout/processout.ts @@ -18,11 +18,11 @@ interface apiRequestOptions { */ module ProcessOut { export const TestModePrefix = "test-" - export const DEBUG: boolean = false + export const DEBUG: boolean = true // This is set during the build process based on the version from package.json export const SCRIPT_VERSION = undefined // This is set during development to point to the staging API - export const DEBUG_HOST = undefined + export const DEBUG_HOST = 'processout.ninja' /** * ProcessOut main class @@ -308,6 +308,9 @@ module ProcessOut { if (path.substring(0, 4) != "http" && path[0] != "/") path = this.endpoint("api", "/" + path) + const queryParams = path.includes('?') ? path.split('?')[1].split('&') : []; + path = path.includes('?') ? path.split('?')[0] : path + var headers = { "Content-Type": "application/json", "API-Version": this.apiVersion, @@ -330,27 +333,31 @@ module ProcessOut { // We need to hack our project ID in the URL itself so that // ProcessOut's load-balancers and routers can route the request // to the project's region - path += `?legacyrequest=true&project_id=${this.projectID}` + queryParams.push(`legacyrequest=true&project_id=${this.projectID}`); } // We also need to hack our request headers for legacy browsers to // work, but also for modern browsers with extensions playing with // headers (such as antiviruses) for (var k in headers) { - path += `&x-${k}=${headers[k]}` + queryParams.push(`x-${k}=${headers[k]}`) } for (var k in customHeaders) { - path += `&x-${k}=${customHeaders[k]}` + queryParams.push(`x-${k}=${customHeaders[k]}`); headers[`X-${k}`] = customHeaders[k] } if (method == "get") { - for (var key in data) path += `&${key}=${encodeURIComponent(data[key])}` + for (var key in data) { + queryParams.push(`${key}=${encodeURIComponent(data[key])}`); + } } var request = new XMLHttpRequest() if (window.XDomainRequest) request = new XDomainRequest() + + path += "?" + queryParams.join("&") request.open(method, path, true) // We still want to push the headers when we can @@ -473,27 +480,25 @@ module ProcessOut { } /** - * createTokenizationFlow creates an APM instance within the tokenization flow - * @param {Container} container - * @param {TokenizationUserOptions} options - * @return {APM} + * apm */ - public createTokenizationFlow(container: Container, options: TokenizationUserOptions) { - return new APMImpl(this, this.telemetryClient, container, { - ...options, - flow: 'tokenization', - }) + public get apm(){ + return { + tokenization: (container: Container, options: TokenizationUserOptions) => { + return new APMImpl(this, this.telemetryClient, container, { + ...options, + flow: 'tokenization', + }) + }, + authorization: (container: Container, options: AuthorizationUserOptions) => { + return new APMImpl(this, this.telemetryClient, container, { + ...options, + flow: 'authorization' + }) + } + } } - /** - * createAuthorizationFlow creates an APM instance within the authorization flow - * @param {Container} container - * @param {AuthorizationUserOptions} options - * @return {APM} - */ - public createAuthorizationFlow(container: Container, options: AuthorizationUserOptions) { - return new APMImpl(this, this.telemetryClient, container, { ...options, flow: 'authorization' }) - } /** * SetupDynamicCheckout creates a Dynamic Checkout instance diff --git a/src/processout/telemetry.ts b/src/processout/telemetry.ts index 4e437906..e2b32b55 100644 --- a/src/processout/telemetry.ts +++ b/src/processout/telemetry.ts @@ -24,7 +24,7 @@ module ProcessOut { type LogLevel = "error" | "warn" | "info" | "debug" - export class TelemetryClient { +export class TelemetryClient { protected processOutInstance: ProcessOut constructor(processOutInstance: ProcessOut) { @@ -39,7 +39,7 @@ module ProcessOut { return this.report(data, "warn") } - public report(data: TelemetryEventData, level: LogLevel) { + public report(data: TelemetryEventData, level: LogLevel) { if (!data) { return null }