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 new file mode 100644 index 00000000..96b19e33 --- /dev/null +++ b/examples/apm/index.html @@ -0,0 +1,160 @@ + + + + + ProcessOut.js Native APM + + + +
+
+ + + + + +
+
+
+ + + + diff --git a/examples/apm/styles.css b/examples/apm/styles.css new file mode 100644 index 00000000..01329512 --- /dev/null +++ b/examples/apm/styles.css @@ -0,0 +1,18 @@ +body { + width: 100%; + font-family: Arial, sans-serif; + padding: 0; + margin: 0; + @media (prefers-color-scheme: dark) { + background-color: #26292f; + }) +} + +h1 { + margin-bottom: 50px; +} + +#apm-container { + max-width: 500px; + margin: 0 auto; +} diff --git a/index.html b/index.html index 7c8ad0fc..91e7b5d7 100644 --- a/index.html +++ b/index.html @@ -54,6 +54,9 @@

ProcessOut.js Development Environment

  • Native APM
  • +
  • + APM +
  • Modal
  • diff --git a/package.json b/package.json index a816f18b..7abc7874 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "processout.js", - "version": "1.0.16", + "version": "1.2.0", "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 new file mode 100644 index 00000000..419f64b7 --- /dev/null +++ b/src/apm/API.ts @@ -0,0 +1,633 @@ +module ProcessOut { + export type FormFieldResponse = + | { + type: "email" | "text" + key: string + label: string + required: boolean + min_length?: number + max_length?: number + } + | { + type: "phone" + key: string + label: string + required: boolean + dialing_codes: Array<{ + region_code: string; + value: 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<{ + value: string; + label: string; + preselected: boolean + }> + } + | { + type: 'boolean' + key: string + label: string + required: 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 | 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 + } + + interface APIResponseBase { + elements?: APIElements + redirect?: { + hint: string, + url: string, + } + } + + export interface APISuccessBase extends APIResponseBase { + success: true, + state: "SUCCESS" | 'NEXT_STEP_REQUIRED' | 'PENDING' | 'REDIRECT', + } + + export interface APIRedirectBase extends APIResponseBase { + success: true, + state: 'REDIRECT', + redirect: { + hint: string, + url: string, + } + } + + export interface APIValidationBase extends APIResponseBase { + success: false, + state: "VALIDATION_ERROR", + error: { + code: string, + message: string, + invalid_fields: Array<{ + name: string, + message: string + }> + } + } + export type APIFailureResponse = { + success: false, + state: "FAILURE", + error: { + code: string, + message: string, + } + } + + export type AuthorizationSuccessResponse = APISuccessBase & PaymentContext; + export type AuthorizationRedirectResponse = APIRedirectBase & PaymentContext; + export type AuthorizationValidationResponse = APIValidationBase & PaymentContext; + + // 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; + 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 AuthorizationNetworkResponse = + | AuthorizationNetworkSuccessResponse + | AuthorizationNetworkValidationResponse + | NetworkErrorResponse + + 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: V) => void, + } + + export interface APIRequest{ + (options: APIOptions< + AuthorizationSuccessResponse | TokenizationSuccessResponse | AuthorizationRedirectResponse, + AuthorizationValidationResponse | TokenizationValidationResponse + >): void + } + + 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 isValidationResponse = (data: AuthorizationNetworkResponse): data is AuthorizationNetworkValidationResponse => { + return data.success === false && ('invalid_fields' in data || data.error_type.startsWith('request.validation.')); + } + + 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) => { + 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({ + host: window?.location?.host ?? '', + fileName: 'API.ts', + lineNumber: 208, + message: `${request} failed as route does not exist`, + category: 'APM - API' + }) + + 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({ + host: window?.location?.host ?? '', + fileName: 'API.ts', + lineNumber: 208, + message: `${request} failed because of an error: ${data.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); + break; + } + } + return + } + + export class APIImpl { + private constructor() {} + + 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) { + 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) => { + const context = ContextImpl.context; + const data ={ + gateway_configuration_id: context.gatewayConfigurationId, + submit_data: { parameters: formData } + }; + + return this.post(data, options) + } + } + + private static get( + pathOrOptions: string | APIOptions = '', + options: APIOptions = {} + ): void { + this.makeRequest('GET', pathOrOptions, {}, options); + } + + private static post = Record>( + data: T, + pathOrOptions: string | APIOptions = '', + options: APIOptions = {} + ) { + this.makeRequest('POST', pathOrOptions, data, options); + } + + private static makeRequest = Record>( + method: 'GET' | 'POST' | 'PUT' | 'DELETE', + pathOrOptions: string | APIOptions, + data: T = {} as T, + options: APIOptions = {} + ): void { + let path: string; + let internalOptions: APIOptions = { + initialTimestamp: Date.now(), + serviceRetries: 5, + hasReturnedFirstPending: !!storage.get('pending.startTime'), + ...options, + }; + + if (typeof pathOrOptions === 'string') { + path = pathOrOptions; + } else { + internalOptions = { + ...internalOptions, + ...pathOrOptions, + }; + } + + 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: 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; + } + + // 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, + error: { + code: 'processout-js.apm.validation-error', + message: 'Validation error', + 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 + }) + 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 > 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; + } + } + + // 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.confirmation.requiresAction && !storage.get('pending.startTime')) { + INITIAL_MAX_RETRIES = 0; + return + } + } + + // 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, _, 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: AuthorizationNetworkSuccessResponse | TokenizationNetworkSuccessResponse): D => { + let result = { ...response } as any + + if (result.elements) { + result.elements = response.elements.map(element => { + if (element.type === 'form') { + const fields = element.parameters.parameter_definitions.map(field => { + if (field.type === 'phone') { + return { + ...field, + dialing_codes: field.dialing_codes + .map((codes) => ({ + ...codes, + name: COUNTRY_DICT[codes.region_code] || codes.region_code + })) + .sort((a, b) => { + if (a.name < b.name) { return -1; } + if (a.name > b.name) { return 1; } + return 0; + }) + } + } + + return field + }) + + element.parameters.parameter_definitions = fields + } + + return element + }) + } + + 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 new file mode 100644 index 00000000..9ef4adde --- /dev/null +++ b/src/apm/Context.ts @@ -0,0 +1,93 @@ +module ProcessOut { + export type TokenizationFlowData = { + flow: 'tokenization', + customerId: string, + customerTokenId: string + invoiceId?: never + } + + export type AuthorizationFlowData = { + flow: 'authorization', + invoiceId: `iv_${string}` + customerId?: never, + customerTokenId?: string, + } + + export type FlowData = { + gatewayConfigurationId: `gway_conf_${string}` + 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 + export type AuthorizationUserData = AuthorizationFlowData & FlowData + + export type TokenizationUserOptions = Omit + export type AuthorizationUserOptions = Omit + +export type APMUserData = TokenizationUserData | AuthorizationUserData + + export type APMContext = { } & APMUserData & { + logger: { + error(message: Omit[0], 'stack'>): void; + warn(message: Omit[0], 'stack'>): void; + } + events: APMEventsImpl, + poClient: ProcessOut, + page: APMPageImpl, + reload(): void + } + + interface Context { + initialise(context: APMContext): void + } + + export class ContextImpl implements Context { + static _instance: Context; + private static initialised = false + private static c = {} as APMContext + private constructor() {} + + public static get instance(): Context { + if (!this._instance) { + this._instance = new ContextImpl(); + } + + return this._instance; + } + + public static get context() { + if (!this.initialised) { + throw new Error('APM Context not initialised') + } + return this.c as APMContext + } + + public initialise(context: APMContext) { + ContextImpl.initialised = true; + ContextImpl.c = context + } + } +} diff --git a/src/apm/Page.ts b/src/apm/Page.ts new file mode 100644 index 00000000..25445ea5 --- /dev/null +++ b/src/apm/Page.ts @@ -0,0 +1,365 @@ +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 { + 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) { + 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, 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.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 (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 => { + this.criticalFailure({ + code: data.error.code, + message: data.error.message, + title: "Unable to connect", + }) + }, + }) + } + + criticalFailure({ + title, + code, + message, + }: { message: string, title: string, code?: string, }) { + 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: "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 + }) + } + + getActiveElement() { + return this._getDeepActiveElement(document); + } + + cleanUp() { + if (!this.hostElement.firstChild) { + return + } + + 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) { + const activeElement = root.activeElement; + + // 1. Check if the active element is an iframe + if (activeElement && activeElement.tagName === 'IFRAME') { + try { + const iframeDocument = activeElement.contentDocument || activeElement.contentWindow.document; + return this._getDeepActiveElement(iframeDocument); + } catch (e) { + return null; + } + } + + // 2. Check if the active element has an 'open' shadowRoot + // Note: 'closed' shadow roots are not accessible this way. + // IE11 does not natively support Shadow DOM, so this path is for modern browsers. + if (activeElement && activeElement.shadowRoot && activeElement.shadowRoot.mode === 'open') { + // Recursively call for the shadow root + return this._getDeepActiveElement(activeElement.shadowRoot); + } + + return activeElement; + } + + + private createWrapper(container: Element) { + 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); // Append iframe directly to the user's container + + // Setup iframe content after it's loaded to avoid race conditions + const setupIframeContent = () => { + const doc = iframe.contentDocument ?? iframe.contentWindow?.document; + + 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); + + // Apply component-specific stylesheet within the iframe's document + 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.currentRoot = doc; // In iframe case, this holds the iframe's Document + + // 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 }); + } + + return; + } + + // 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 + + // 2. Attach Shadow DOM to this newly created host element + const shadowRoot = newShadowHost.attachShadow({ mode: 'open' }); + this.currentRoot = shadowRoot; // Store reference to the ShadowRoot + + // 3. Ensure Work Sans is loaded if no custom fonts are detected + this.ensureWorkSansLoaded(); + + // 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) { + try { + const urlHost = new URL(href).host; + const allowedHosts = ['fonts.googleapis.com', 'fonts.gstatic.com']; + if (allowedHosts.indexOf(urlHost) !== -1 || href.indexOf('font') !== -1) { + const clonedLink = link.cloneNode(true) as HTMLLinkElement; + doc.head.appendChild(clonedLink); + } + } catch (e) { + console.error('Invalid URL in font link:', href, e); + } + } + }); + } 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..b0ac3dfa --- /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 generateUniqueId('no-view-comp'); + } + + 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 new file mode 100644 index 00000000..2735bf87 --- /dev/null +++ b/src/apm/Theme.ts @@ -0,0 +1,1575 @@ +module ProcessOut { + interface Palette { + background: string + surface: { + success: string, + button: { + primary: string + secondary: string + tertiary: string + success: string + danger: string + disabled: string + hover: { + primary: string + secondary: string + tertiary: string + success: string + danger: string + } + } + input: { + default: string, + disabled: string, + hover: { + default: string, + } + } + toast: { + error: string, + } + } + border: { + input: { + default: string, + 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, + l2: string + } + } + + export interface ThemeOptions { + fontFamily: string + palette: { + light: Palette + dark: Palette + } + } + + interface Theme { + get(): ThemeOptions + get

    >(path: P): PathValue + getTextColor

    >(path: P): string + update(theme: DeepPartial): void + + createStyles(): CSSText + } + + 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", + tertiary: "#464646", + success: '#BAD8B1', + danger: '#FF8888', + disabled: '#2E3137', + hover: { + primary: '#bfc3c7', + secondary: '#5b5b5b', + tertiary: '#555555', + success: '#1bd163', + danger: '#ff4e4f' + }, + }, + input: { + default: '#26292F', + disabled: '#2E3137', + hover: { + default: '#33353A', + }, + }, + toast: { + error: '#511511', + } + }, + border: { + input: { + 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: '#FF7D6C', + secondary: '#C0C3C8', + toast: { + error: '#F5D9D9', + } + }, + shadow: { + focus: '#63656b', + l2: '#353636', + } + }, + light: { + background: "#FFFFFF", + surface: { + success: '#0C7434', + button: { + primary: "#000000", + secondary: "#f1f1f1", + tertiary: "#FFFFFF", + success: '#16AC50', + danger: '#BE011B', + disabled: '#f3f3f3', + hover: { + primary: '#2E3137', + secondary: '#dfdfdf', + tertiary: '#eeeeee', + success: '#0e7434', + danger: '#870011', + }, + }, + input: { + default: '#FFFFFF', + disabled: '#f1f1f1', + hover: { + default: '#f5f5f5', + }, + }, + toast: { + error: '#FDE3DE', + } + }, + border: { + input: { + default: '#e3e3e3', + errored: '#BE011B', + disabled: '#f1f1f1', + }, + icon: { + tertiary: '#8A8D93', + disabled: '#C0C3C8', + }, + checkbox: { + default: '#C0C3C8', + }, + toast: { + error: '#EFD7D2', + } + }, + text: { + default: '#000000', + disabled: '#C0C3C8', + label: '#707378', + errored: '#BE011B', + secondary: '#585A5F', + toast: { + error: '#630407', + } + }, + shadow: { + focus: '#b1b1b2', + l2: '#b1b1b2', + } + } + }, + } + + 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 (!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(); + }; + + 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); + } + + /** + * 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 { + const color = this.get(path) + if (!color) { + return '#FFFFFF' + } + + const hexColor = this.recursiveFind(path, this.theme) + // 1. Remove the '#' if it's there + const sanitizedHex = hexColor.startsWith('#') ? hexColor.slice(1) : hexColor; + + // 2. Handle shorthand hex codes (e.g., "03F" -> "0033FF") + const fullHex = sanitizedHex.length === 3 + ? sanitizedHex.split('').map(char => char + char).join('') + : sanitizedHex; + + // 3. Parse the R, G, B values from the hex code + const r = parseInt(fullHex.substring(0, 2), 16); + const g = parseInt(fullHex.substring(2, 4), 16); + const b = parseInt(fullHex.substring(4, 6), 16); + + // 4. Calculate the perceived brightness using the WCAG formula (Luminance) + // This formula is weighted to account for human perception. We are more + // sensitive to green than red, and more sensitive to red than blue. + // Values range from 0 (black) to 255 (white). + const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b); + + // 5. Decide on the text color based on a luminance threshold. + // If the background is bright (luminance > 140), use black text. + // If the background is dark (luminance <= 139), use white text. + return luminance > 140 ? ThemeImpl.instance.get('palette.light.text.default') : ThemeImpl.instance.get('palette.dark.text.default') ; + } + + public update(theme: DeepPartial) { + 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'] + + if (color === 'hover' || color === 'disabled') { + return acc; + } + + acc += css` + .button.${color} { + background-color: ${ThemeImpl.instance.get(`palette.light.surface.button.${color}`)}; + color: ${ThemeImpl.instance.getTextColor(`palette.light.surface.button.${color}`)}; + + @media (prefers-color-scheme: dark) { + background-color: ${ThemeImpl.instance.get(`palette.dark.surface.button.${color}`)}; + color: ${ThemeImpl.instance.getTextColor(`palette.dark.surface.button.${color}`)}; + } + } + .button.${color}:not(.disabled):not(:hover):not(:focus) { + border-color: ${ThemeImpl.instance.get(`palette.light.surface.button.${color}`)}; + + @media (prefers-color-scheme: dark) { + border-color: ${ThemeImpl.instance.get(`palette.dark.surface.button.${color}`)}; + } + } + + .button.${color}:not(.disabled):not(:focus) { + border-color: ${ThemeImpl.instance.get(`palette.light.surface.button.hover.${color}`)}; + + @media (prefers-color-scheme: dark) { + border-color: ${ThemeImpl.instance.get(`palette.dark.surface.button.hover.${color}`)}; + } + } + + .button.${color} .loader { + border-color: ${ThemeImpl.instance.getTextColor(`palette.light.surface.button.${color}`)}; + + @media (prefers-color-scheme: dark) { + border-color: ${ThemeImpl.instance.getTextColor(`palette.dark.surface.button.${color}`)}; + } + } + + .button.${color}:not(.loading):not(.disabled):hover, .button.${color}:not(.loading):not(.disabled):focus { + background-color: ${ThemeImpl.instance.get(`palette.light.surface.button.hover.${color}`)}; + color: ${ThemeImpl.instance.getTextColor(`palette.light.surface.button.hover.${color}`)}; + + @media (prefers-color-scheme: dark) { + background-color: ${ThemeImpl.instance.get(`palette.dark.surface.button.hover.${color}`)}; + color: ${color === 'danger' ? ThemeImpl.instance.getTextColor('palette.light.text.default') : ThemeImpl.instance.getTextColor(`palette.dark.surface.button.hover.${color}`)}; + } + } + `() + + return acc; + }, '') + + return css` + ${this.resetCss} + + .main { + font-family: ${ThemeImpl.instance.get('fontFamily')}; + container: main / inline-size; + } + + .page { + display: flex; + flex-direction: column; + width: 100%; + min-height: 285px; + padding: 32px 40px; + color: ${ThemeImpl.instance.get('palette.light.text.default')}; + background-color: ${ThemeImpl.instance.get('palette.light.background')}; + @media (prefers-color-scheme: dark) { + color: ${ThemeImpl.instance.get('palette.dark.text.default')}; + background-color: ${ThemeImpl.instance.get('palette.dark.background')}; + } + } + + .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; + border: 3px solid ${ThemeImpl.instance.get('palette.light.text.default')}; + border-bottom-color: transparent !important; + border-radius: 50%; + display: inline-block; + box-sizing: border-box; + animation: rotation 1s linear infinite; + @media (prefers-color-scheme: dark) { + border-color: ${ThemeImpl.instance.get('palette.dark.text.default')}; + border-bottom-color: transparent; + }) + } + + @keyframes rotation { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } + } + + .empty-title { + text-align: center; + margin-top: 16px + } + + .empty-subtitle { + text-align: center; + } + + .empty-controls { + display: grid; + gap: 12px; + } + + .empty-controls.x3 { + text-align: center; + grid-template-columns: repeat(3, 1fr); + } + + .chevron { + display: inline-block; + width: 100%; + padding-top: 75%; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 6' fill='none'%3E%3Cpath d='M1 2L4 5L7 2' stroke='${encodeURIComponent(ThemeImpl.instance.get('palette.light.text.label'))}' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-size: contain; + background-repeat: no-repeat; + + transition: transform 0.2s ease-in-out; + @media (prefers-color-scheme: dark) { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 6' fill='none'%3E%3Cpath d='M1 2L4 5L7 2' stroke='${encodeURIComponent(ThemeImpl.instance.get('palette.dark.border.input.default'))}' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + } + } + .chevron.up { + transform: rotate(-180deg); + } + + .chevron.left { + transform: rotate(90deg); + } + .chevron.right { + transform: rotate(-90deg); + } + + .heading-container { + width: 100%; + display: flex; + flex-direction: column; + gap: 6px; + padding-top: 16px; + } + + .heading { + font-weight: 600; + font-size: 20px; + line-height: 24px; + } + + .sub-heading { + font-weight: 400; + font-size: 16px; + line-height: 26px; + } + + .button-container { + width: 100%; + display: flex; + flex-direction: column; + gap: 12px; + padding-top: 12px; + } + + .button { + font-family: inherit; + width: 100%; + display: inline-block; + text-wrap-mode: nowrap; + appearance: none; + cursor: pointer; + font-weight: 500; + border-radius: 6px; + outline: none; + border-width: 2px; + border-style: solid; + position: relative; + } + + .button:focus { + box-shadow: inset 0 0 0 1px ${ThemeImpl.instance.get('palette.light.background')}; + border-color: ${ThemeImpl.instance.get('palette.light.shadow.l2')};; + @media (prefers-color-scheme: dark) { + box-shadow: inset 0 0 0 1px ${ThemeImpl.instance.get('palette.dark.background')}; + border-color: ${ThemeImpl.instance.get('palette.light.shadow.l2')}; + } + } + + ${buttonVariants} + + .button.disabled, .button.disabled:hover { + cursor: not-allowed; + background-color: ${ThemeImpl.instance.get('palette.light.surface.button.disabled')}; + color: ${ThemeImpl.instance.get('palette.light.text.disabled')}; + border-color: ${ThemeImpl.instance.get('palette.light.surface.button.disabled')}; + + @media (prefers-color-scheme: dark) { + background-color: ${ThemeImpl.instance.get('palette.dark.surface.button.disabled')}; + color: ${ThemeImpl.instance.get('palette.dark.text.disabled')}; + border-color: ${ThemeImpl.instance.get('palette.dark.surface.button.disabled')}; + } + } + + .button.loading { + cursor: default; + 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 { + padding: 0 12px; + height: 32px; + font-size: 13px; + line-height: 16px; + } + .button.md { + padding: 0 16px; + height: 40px; + font-size: 14px; + line-height: 20px; + } + .button.lg { + padding: 0 24px; + height: 48px; + font-size: 15px; + line-height: 18px; + } + + .form { + display: flex; + flex-direction: column; + gap: 16px; + } + + .field-container { + display: flex; + flex-direction: column; + gap: 8px; + } + .field { + display: flex; + width: 100%; + background-color: ${ThemeImpl.instance.get('palette.light.surface.input.default')}; + border: 1.5px solid ${ThemeImpl.instance.get('palette.light.border.input.default')}; + border-radius: 6px; + height: 52px; + position: relative; + @media (prefers-color-scheme: dark) { + background-color: ${ThemeImpl.instance.get('palette.dark.surface.input.default')}; + border-color: ${ThemeImpl.instance.get('palette.dark.border.input.default')}; + } + } + .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; + top: 16px; + left: 14px; + font-weight: 500; + font-size: 15px; + line-height: 18px; + transition: font-size 0.1s ease-in-out, line-height 0.1s ease-in-out, top 0.1s ease-in-out; + color: ${ThemeImpl.instance.get('palette.light.text.label')}; + + @media (prefers-color-scheme: dark) { + color: ${ThemeImpl.instance.get('palette.dark.text.label')}; + } + } + + .field.filled.has-label .label { + font-size: 12px; + line-height: 14px; + top: 8px; + } + .field input { + font-family: inherit; + appearance: none; + background-color: transparent; + color: ${ThemeImpl.instance.get('palette.light.text.default')}; + border: none; + outline: none; + width: calc(100% + 4px); + font-weight: 500; + font-size: 15px; + line-height: 18px; + padding: 18px 16px 16px; + -webkit-background-clip: text; + -webkit-text-fill-color: ${ThemeImpl.instance.get('palette.light.text.default')}; + + position: absolute; + top: -2px; + left: -2px; + + @media (prefers-color-scheme: dark) { + color: ${ThemeImpl.instance.get('palette.dark.text.default')}; + -webkit-text-fill-color: ${ThemeImpl.instance.get('palette.dark.text.default')}; + } + } + .field.filled.has-label input { + padding: 25px 16px 10px; + } + .field.disabled { + border-color: ${ThemeImpl.instance.get('palette.light.border.input.disabled')}; + @media (prefers-color-scheme: dark) { + border-color: ${ThemeImpl.instance.get('palette.dark.border.input.disabled')}; + } + } + .field.disabled.filled { + background-color: ${ThemeImpl.instance.get('palette.light.surface.input.disabled')}; + @media (prefers-color-scheme: dark) { + background-color: ${ThemeImpl.instance.get('palette.dark.surface.input.disabled')}; + } + } + .field.disabled input { + pointer-events: none; + } + .field.errored:not(.disabled) { + border-color: ${ThemeImpl.instance.get('palette.light.border.input.errored')}; + @media (prefers-color-scheme: dark) { + border-color: ${ThemeImpl.instance.get('palette.dark.border.input.errored')}; + } + } + .field.errored:not(.disabled) .label { + color: ${ThemeImpl.instance.get('palette.light.text.errored')}; + @media (prefers-color-scheme: dark) { + color: ${ThemeImpl.instance.get('palette.dark.text.errored')}; + } + } + + .select-chevrons { + width: 5px; + height: 12px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 1px; + } + + .open .select-chevrons .chevron { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 6' fill='none'%3E%3Cpath d='M1 2L4 5L7 2' stroke='${encodeURIComponent(ThemeImpl.instance.get('palette.light.text.default'))}' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + @media (prefers-color-scheme: dark) { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 6' fill='none'%3E%3Cpath d='M1 2L4 5L7 2' stroke='${encodeURIComponent(ThemeImpl.instance.get('palette.dark.text.default'))}' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + } + } + + .select-chevrons.md { + width: 7px; + gap: 2px; + } + + .select select { + width: 100%; + border: 0; + outline: none; + background-color: transparent; + appearance: none; + padding: 16px 14px 0; + font-weight: 500; + font-size: 15px; + line-height: 18px; + font-family: inherit; + color: ${ThemeImpl.instance.get('palette.light.text.default')}; + @media (prefers-color-scheme: dark) { + color: ${ThemeImpl.instance.get('palette.dark.text.default')}; + } + } + + .select .select-chevrons { + position: absolute; + right: 16px; + 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: 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; + } + + .otp .input input { + text-align: center; + padding-left: 0; + padding-right: 0; + } + + .phone .dialing-code { + display: flex; + justify-content: end; + align-items: center; + gap: 10px; + width: 58px; + height: 26px; + position: absolute; + top: 11px; + right: 14px; + border-left: 2px solid ${ThemeImpl.instance.get('palette.light.border.input.default')};; + + @media (prefers-color-scheme: dark) { + border-color: ${ThemeImpl.instance.get('palette.dark.border.input.default')}; + } + } + .phone .dialing-code.open { + padding-right: 16px; + width: 76px; + height: 52px; + top: -2px; + right: -2px; + border-left: 2px solid ${ThemeImpl.instance.get('palette.light.text.default')}; + box-shadow: 0 0 0 3px ${ThemeImpl.instance.get('palette.light.shadow.focus')}; + border-top-right-radius: 6px; + border-bottom-right-radius: 6px; + + @media (prefers-color-scheme: dark) { + border-color: ${ThemeImpl.instance.get('palette.dark.text.default')}; + box-shadow: 0 0 0 3px ${ThemeImpl.instance.get('palette.dark.shadow.focus')}; + } + } + + .phone .dialing-code.open:before, + .phone .dialing-code.open:after { + content: ""; + display: block; + width: 3px; + height: 2px; + background-color: ${ThemeImpl.instance.get('palette.light.text.default')}; + position: absolute; + left: -5px; + @media (prefers-color-scheme: dark) { + background-color: ${ThemeImpl.instance.get('palette.dark.text.default')}; + } + } + .phone .dialing-code.open:before { + top: 0; + } + .phone .dialing-code.open:after { + bottom: 0; + } + + .phone .dialing-code-label { + overflow: hidden; + border-radius: 2px; + justify-content: center; + display: flex; + } + + .phone select { + width: 58px; + height: 26px; + position: absolute; + top: 11px; + right: 16px; + border: none; + text-align: right; + outline: none; + opacity: 0; + } + + @keyframes select-caret { + 0% { + text-decoration: none; + } + 50% { + text-decoration: underline; + } + 100% { + text-decoration: none; + } + } + + .phone select:focus { + animation: select-caret 1s infinite; + } + + .phone.errored:not(.disabled) .dialing-code.open { + border-color: ${ThemeImpl.instance.get('palette.light.text.errored')}; + @media (prefers-color-scheme: dark) { + border-color: ${ThemeImpl.instance.get('palette.dark.text.errored')}; + } + } + .phone.errored:not(.disabled) .dialing-code.open:before, .phone.errored:not(.disabled) .dialing-code.open:after { + background-color: ${ThemeImpl.instance.get('palette.light.text.errored')}; + + @media (prefers-color-scheme: dark) { + background-color: ${ThemeImpl.instance.get('palette.dark.text.errored')}; + } + } + + .error { + padding: 8px 12px; + gap: 8px; + border-radius: 6px; + 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)); + } + } + `() + } + + private recursiveFind(path: string, value: any) { + if (!path) { + return value + } + + const paths = path.split('.'); + const key = paths.shift() + + return this.recursiveFind(paths.join('.'), value[key]) + } + + private deepMerge(target: any, ...sources: any) { + if (!sources.length) return target; + const source = sources.shift(); + + if (isPlainObject(target) && isPlainObject(source)) { + for (const key in source) { + if (isPlainObject(source[key])) { + if (!target[key]) Object.assign(target, { [key]: {} }); + this.deepMerge(target[key], source[key]); + } else { + Object.assign(target, { [key]: source[key] || target[key] }); + } + } + } + + return this.deepMerge(target, ...sources); + } + + private get resetCss() { + return css` + a,abbr,acronym,address,applet,article,aside,audio,b,big,blockquote,body,canvas,caption,center,cite,code,dd,del,details,dfn,div,dl,dt,em,embed,fieldset,figcaption,figure,footer,form,h1,h2,h3,h4,h5,h6,header,hgroup,html,i,iframe,img,ins,kbd,label,legend,li,main,mark,menu,nav,object,ol,output,p,pre,q,ruby,s,samp,section,small,span,strike,strong,sub,summary,sup,table,tbody,td,tfoot,th,thead,time,tr,tt,u,ul,var,video{margin:0;padding:0;border:0;font-size:100%;font:inherit;vertical-align:baseline;box-sizing:border-box;}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section{display:block}[hidden]{display:none}body{line-height:1}menu,ol,ul{list-style:none}blockquote,q{quotes:none}blockquote:after,blockquote:before,q:after,q:before{content:'';content:none}table{border-collapse:collapse;border-spacing:0} + ` + } + } +} diff --git a/src/apm/elements/button.ts b/src/apm/elements/button.ts new file mode 100644 index 00000000..14c21827 --- /dev/null +++ b/src/apm/elements/button.ts @@ -0,0 +1,23 @@ +module ProcessOut { + const { button, div } = elements + export interface ButtonProps extends Props<'button'> { + variant?: 'primary' | 'secondary' | 'tertiary' | 'success' | 'danger' + size?: 'sm' | 'md' | 'lg', + loading?: boolean, + } + + export const Button = (first: ButtonProps | Child, ...children: Child[]) => { + const { className, variant, size, loading, disabled, ...userProps } = isProps(first) ? first : {} + let rest = [div({ className: "content" }, isProps(first) ? children : [first, ...children])]; + + if (loading) { + rest = [Loader()] + } + + const classNames = ["button", size ?? 'lg', variant ?? 'primary', loading && 'loading', disabled && 'disabled', className, ].filter(Boolean) + + const props = mergeProps<'button'>({ className: classNames.join(' '), disabled: disabled || loading}, userProps); + + return button(props, ...rest) + } +} 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..e8c87a71 --- /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 = generateUniqueId('copy'), + }: { + 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 new file mode 100644 index 00000000..ce3f3251 --- /dev/null +++ b/src/apm/elements/elements.ts @@ -0,0 +1,290 @@ +module ProcessOut { + /** + * 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: 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) + } + + /** + * 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]; + export type Primitive = string | number | boolean; + + /** + * 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[]; + + /** + * 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', 'em', 'strong', + 'h1','h2','h3','h4','h5','h6', + 'a','button','input','label', 'form', + 'ul','ol','li','img','picture','source', + 'section','article','header','footer','nav','main', + 'pre','code','textarea','select','option', + ] as const; + + type GenerateFragment = (...children: Child[]) => VNode; + export type GenerateTagArgs = [childOrProps: Props | Child, ...Child[]]; + + /** + * 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; + } + + type VanLite = { + fragment: GenerateFragment; + } & { [K in Tag]: GenerateTag }; + + /** + * 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) { + continue; + } + + // Flatten nested arrays recursively + if (Array.isArray(child)) { + children.push(...processChildren(child)); + 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; + } + } + + // Convert primitives to text nodes + children.push({ + type: '#text', + props: {}, + children: [], + dom: null, + key: null, + value: String(child) + }); + } + + return children; + } + + /** + * 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 = {} as Props; + let childrenArgs: Child[] = args as Child[]; + + // Smart argument detection: is first arg props or children? + if (isProps(args[0])) { + props = args[0]; + childrenArgs = args.slice(1) as Child[]; + } + + // Extract key safely without mutating original props + const { key, ...propsWithoutKey } = props; + + // Build the Virtual DOM node + return { + type: tag, + props: propsWithoutKey, + children: processChildren(childrenArgs), + dom: null, + key, + }; + }) as GenerateTag; + }; + + const api: Partial = {} as Partial; + + /** + * 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: {}, + children: processChildren(children), + dom: null, + key: null + }); + + export const elements = api as VanLite; + + /** + * 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 { + return item + && typeof item === 'object' + && item.constructor === Object + && !('children' in item); // VNodes have 'children', props don't + }; + + /** + * 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 CSS classes intelligently + const classes = [ + base.className || (base as any).class, + user.className || (user as any).class, + ].filter(Boolean); + if (classes.length) (out as any).className = classes.join(" "); + + // 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); // Base handler first + u.apply(this, args); // Then user handler + }; + } + } + return out; + } +} diff --git a/src/apm/elements/header.ts b/src/apm/elements/header.ts new file mode 100644 index 00000000..4cc9af6d --- /dev/null +++ b/src/apm/elements/header.ts @@ -0,0 +1,21 @@ +module ProcessOut { + type HeaderTag = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'label' + type HeaderTagProps = Props + type HeaderProps = HeaderTagProps & { + tag: K + } + type HeaderArgs = [HeaderProps | string, string?] + export const Header = (...args: HeaderArgs) => { + const first = args[0] + const content: string = isProps(first) ? args[1] : first; + const props: HeaderTagProps = isProps(first) ? first : {} as HeaderTagProps + const tag: HeaderTag = props.tag || 'h1'; + + delete props.tag + + const className = ["heading", props.className].filter(Boolean).join(' ') + + 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 new file mode 100644 index 00000000..0da4799e --- /dev/null +++ b/src/apm/elements/input.ts @@ -0,0 +1,67 @@ +module ProcessOut { + const { div, label: labelEl, input } = elements + + export interface InputProps extends Omit, 'oninput' | 'onblur' | 'name'> { + name: string + label?: string; + errored?: boolean; + oninput?: FormFieldUpdate, + onblur?: FormFieldBlur, + } + + export const Input = ({ name, className, label, disabled, errored, value, id, type, oninput, onblur, ...props }: InputProps) => { + const classNames = [ + "field input", + disabled && !errored && 'disabled', + label && 'has-label', + (value && value.toString().length > 0) && 'filled', + errored && 'errored', + className + ].filter(Boolean).join(" ") + + const el = input({ + type: type || "text", + autocomplete: "on", + name, + disabled, + value, + id: id || name, + oninput: (e) => { + 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") + } else { + target.parentElement.classList.add("filled") + } + } + + oninput && oninput(name, value) + }, + onblur: (e) => { + const target = e.target as HTMLInputElement + const value = target.value + + target.parentElement.classList.remove("focused") + onblur && onblur(name, value) + }, + onfocus: (e) => { + const target = e.target as HTMLInputElement + target.parentElement.classList.add("focused") + }, + ...props, + }) + + const children = [label && labelEl({ className: "label" }, label), el].filter(Boolean) + + return div({ + className: classNames, + }, ...children) + } +} diff --git a/src/apm/elements/loader.ts b/src/apm/elements/loader.ts new file mode 100644 index 00000000..e5fafada --- /dev/null +++ b/src/apm/elements/loader.ts @@ -0,0 +1,7 @@ +module ProcessOut { + const { div } = elements; + + export const Loader = () => ( + div({ className: "loader" }) + ) +} 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 new file mode 100644 index 00000000..2661cb7c --- /dev/null +++ b/src/apm/elements/otp.ts @@ -0,0 +1,220 @@ +module ProcessOut { + 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; + } + + 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, + }); + + // Watch for focusedIndex changes to handle focus + watch('focusedIndex', (newIndex) => { + const targetInput = inputRefs[newIndex]; + if (targetInput) { + targetInput.focus(); + } + }); + + 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 = (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; + } + + setState({ + ...newState, + isComplete: isCurrentlyComplete, + }); + }; + + /** + * 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'; + + // 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 - single character or autocomplete + */ + const handleOnChange = (index: number, value: string): void => { + const currentValue = value.trim(); + const isNumeric = type === 'numeric'; + + // 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) { + 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) { + newValues[index] = char; + // Move focus to the next input if this one is filled and not the last. + if (index < length - 1) { + newFocusedIndex = index + 1; + } + } else { + // If the input is invalid or empty, we ensure the state reflects that. + newValues[index] = ''; + } + + const inputRef = inputRefs[index]; + + if (inputRef) { + inputRef.value = char; + } + + update({ + ...state, + values: newValues, + focusedIndex: newFocusedIndex + }); + }; + + /** + * Handles backspace for clearing the current input or moving focus backward. + */ + const handleKeyDown = (index: number, e: KeyboardEvent): void => { + if (e.key !== 'Backspace') return; + + e.preventDefault(); + let newValues = [...state.values]; + let newFocusedIndex = state.focusedIndex; + if (newValues[index]) { + // If the current input has a value, just clear it and stay focused. + newValues[index] = ''; + } else if (index > 0) { + // If the current input is already empty, move focus to the previous one. + newFocusedIndex = index - 1; + newValues[newFocusedIndex] = ''; + } + update({ + ...state, + values: newValues, + focusedIndex: newFocusedIndex, + }); + }; + + const handleHiddenFocus = (e: FocusEvent): void => { + e.preventDefault() + inputRefs[state.focusedIndex]?.focus(); + }; + + inputRefs.length = 0; + + const inputs = new Array(length).fill(0).map((_, i) => { + return Input({ + name: `${name}-${i + 1}`, + oninput: (_, value: string) => handleOnChange(i, value), + onkeydown: (e: KeyboardEvent) => handleKeyDown(i, e), + 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 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, + ref: liveNode => { + if (liveNode) { + inputRefs[i] = liveNode + } + }, + }) + }); + + // Return the final element tree. + 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/page.ts b/src/apm/elements/page.ts new file mode 100644 index 00000000..abe3a0d7 --- /dev/null +++ b/src/apm/elements/page.ts @@ -0,0 +1,15 @@ +module ProcessOut { + const { div } = elements + + export const page: GenerateTag<'div'> = (...args: GenerateTagArgs<'div'>) => { + const first = args[0]; + const children = args.slice(1) as Child[]; + + const userProps = isProps(first) ? first : {} + const rest = isProps(first) ? children : [first, ...children]; + + const props = mergeProps<'div'>({ className: "page" }, userProps); + + return div(props, ...rest) + } +} diff --git a/src/apm/elements/phone.ts b/src/apm/elements/phone.ts new file mode 100644 index 00000000..02ea46a4 --- /dev/null +++ b/src/apm/elements/phone.ts @@ -0,0 +1,680 @@ +module ProcessOut { + export interface PhoneProps extends Omit, 'value' | 'oninput' | 'onblur'> { + label?: string; + errored?: boolean; + dialing_codes: Array<{ + region_code: string, + value: string, + name: string, + }> + 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 phoneRef: HTMLInputElement = null; + let dialingCodesRef: HTMLSelectElement = null; + let focusMethod = 'mouse'; + + const updateFilledState = (el: HTMLInputElement) => { + const value = el.value + + if (value.length === 0) { + el.parentElement.classList.remove("filled") + } else { + el.parentElement.classList.add("filled") + } + } + + const getDialingCode = (dialingCode: string) => { + return `${dialingCode} `; + } + + const getNumber = (number: string) => { + // This regex matches 3 digits (\d{3}) but only if they are followed by + // at least 2 more digits (?=\d{2,}). This prevents creating a final group of 1. + // '$1 ' adds a space after the matched group. + const regex = /(\d{3})(?=\d{2,})/g; + return number.replace(regex, '$1 '); + } + + const getFullNumber = (dialingCode: string, number: string) => { + 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) => { + // 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); + } + + setState({ + dialing_code: dialingCode, + value: phoneNumber, + iso: iso + }); + }); + + const classNames = [ + "field phone filled", + disabled && 'disabled', + label && 'has-label', + errored && 'errored', + className + ].filter(Boolean).join(" ") + + + const handleInputChange = e => { + 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); + let cursorPositionInDigits = (charsBeforeCursor.match(/\d/g) || []).length; + + // If the cursor is in the dialing code, its logical position in the number is 0. + const dialingCodeDigits = (dialingCode.match(/\d/g) || []).length; + cursorPositionInDigits = Math.max(0, cursorPositionInDigits - dialingCodeDigits); + + // Use libphonenumber to properly parse the number + const cleanNumber = parseCleanNumber(currentValue, dialingCode, iso); + + const formattedValue = getFullNumber(dialingCode, cleanNumber); + + let newCursorPosition = numberStartIndex; + let digitsCounted = 0; + + for (const char of formattedValue.substring(numberStartIndex)) { + if (digitsCounted === cursorPositionInDigits) { + break; + } + if (/\d/.test(char)) { + digitsCounted++; + } + newCursorPosition++; + } + + input.value = formattedValue; + if (label) { + 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) { + phoneNumber = cleanNumber; + oninput && oninput(name, { + dialing_code: dialingCode, + value: phoneNumber, + }); + } + setState({ + dialing_code: dialingCode, + value: phoneNumber, + iso: iso + }); + input.setSelectionRange(newCursorPosition, newCursorPosition); + } + + const handleInputClick = e => { + const target = e.target as HTMLInputElement + + if (target.selectionStart <= state.dialing_code.length && target.selectionEnd === target.selectionStart) { + dialingCodesRef.focus(); + dialingCodesRef.showPicker() + } + } + + const handleInputFocus = e => { + const target = e.target as HTMLInputElement + target.parentElement.classList.add("focused") + const value = target.value; + + if (focusMethod === 'keyboard') { + target.selectionStart = state.dialing_code.length + 1; + target.selectionEnd = value.length; + } + } + + const handleInputBlur = e => { + const target = e.target as HTMLInputElement + target.parentElement.classList.remove("focused") + + if (e.relatedTarget !== dialingCodesRef) { + onblur && onblur(name, state) + } + } + + const handleSelectChange = e => { + const currentValue = (e.target as HTMLSelectElement).value; + 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, { dialing_code: newDialingCode, value: cleanNumber }); + + (e.target as HTMLSelectElement).parentElement.querySelector('img').src = `https://flagcdn.com/w80/${currentValue.toLowerCase()}.jpg`; + } + + const handleSelectFocus = e => { + const target = e.target as HTMLSelectElement + target.parentElement.classList.add("focused") + target.parentElement.querySelector('.dialing-code').classList.add('open') + } + + const handleSelectBlur = e => { + const target = e.target as HTMLSelectElement + target.parentElement.classList.remove("focused") + target.parentElement.querySelector('.dialing-code').classList.remove('open') + if (e.relatedTarget !== phoneRef) { + onblur && onblur(name, state) + } + } + + const handleKeyDown = (e) => { + const isKeyTab = e.key === 'Tab' || e.keyCode === 9; + const isKeyArrowLeft = e.key === 'ArrowLeft' || e.keyCode === 37; + const isFocusedOnPhoneInput = ContextImpl.context.page.getActiveElement() === phoneRef; + const isSelectionOnDialingCode = phoneRef.selectionStart <= getDialingCode(state.dialing_code).length; + + if (isKeyTab) { + focusMethod = 'keyboard'; + } + + if (isKeyArrowLeft && isFocusedOnPhoneInput && isSelectionOnDialingCode) { + phoneRef.setSelectionRange(getDialingCode(state.dialing_code).length + 1, getDialingCode(state.dialing_code).length + 1); + } + } + + const handleMouseDown = () => { + focusMethod = 'mouse'; + } + + if (!state.dialing_code) { + return null + } + + return div( + { + className: classNames, + }, + label && labelEl({ className: "label", htmlFor: id || `${name}.value` }, label), + div( + { className: "dialing-code" }, + div( + { className: "dialing-code-label" }, + img({ + width: 22, + alt: `Selected ${state.iso} dialing code`, + src: `https://flagcdn.com/w80/${state.iso.toLowerCase()}.jpg`, + }), + ), + div( + { className: "select-chevrons" }, + div({ + className: "chevron up", + }), + div({ + className: "chevron down", + }), + ), + ), + input({ + type: "tel", + autocomplete: "tel", + value: getFullNumber(state.dialing_code, state.value), + inputMode: "tel", + name: `${name}.value`, + disabled, + id: id || `${name}.value`, + ref: el => (phoneRef = el), + oninput: handleInputChange, + onfocus: handleInputFocus, + onblur: handleInputBlur, + onclick: handleInputClick, + onmousedown: handleMouseDown, + onkeydown: handleKeyDown, + ...props, + }), + select( + { + name:`${name}.dialing_code`, + disabled, + 'aria-label': "Select country prefix", + ref: el => (dialingCodesRef = el), + onchange: handleSelectChange, + onfocus: handleSelectFocus, + onblur: handleSelectBlur, + }, + option( + { + value: "", + disabled: true, + }, + "Select number prefix", + ), + ...dialing_codes.map(({ name, value, region_code }) => + option( + { + value: region_code, + selected: state.iso === region_code, + }, + `${name} (${value})`, + ), + ), + ), + ) + } + + export const COUNTRY_DICT = { + "AF": "Afghanistan", + "AX": "Åland Islands", + "AL": "Albania", + "DZ": "Algeria", + "AS": "American Samoa", + "AD": "Andorra", + "AO": "Angola", + "AI": "Anguilla", + "AQ": "Antarctica", + "AG": "Antigua And Barbuda", + "AR": "Argentina", + "AM": "Armenia", + "AW": "Aruba", + "AU": "Australia", + "AT": "Austria", + "AZ": "Azerbaijan", + "BS": "Bahamas", + "BH": "Bahrain", + "BD": "Bangladesh", + "BB": "Barbados", + "BY": "Belarus", + "BE": "Belgium", + "BZ": "Belize", + "BJ": "Benin", + "BM": "Bermuda", + "BT": "Bhutan", + "BO": "Bolivia", + "BQ": "Bonaire, Sint Eustatius And Saba", + "BA": "Bosnia-herzegovina", + "BW": "Botswana", + "BV": "Bouvet Island", + "BR": "Brazil", + "IO": "British Indian Ocean Territory", + "VG": "British Virgin Islands", + "BN": "Brunei Darussalam", + "BG": "Bulgaria", + "BF": "Burkina Faso", + "BI": "Burundi", + "KH": "Cambodia", + "CM": "Cameroon", + "CA": "Canada", + "CV": "Cape Verde", + "KY": "Cayman Islands", + "CF": "Central African Republic", + "TD": "Chad", + "CL": "Chile", + "CN": "China", + "CX": "Christmas Island", + "CC": "Cocos (keeling) Islands", + "CO": "Colombia", + "KM": "Comoros", + "CG": "Congo (brazzaville)", + "CD": "Congo, The Democratic Republic", + "CK": "Cook Islands", + "CR": "Costa Rica", + "HR": "Croatia", + "CU": "Cuba", + "CW": "Curacao", + "CY": "Cyprus", + "CZ": "Czech Republic", + "DK": "Denmark", + "DJ": "Djibouti", + "DM": "Dominica", + "DO": "Dominican Republic", + "EC": "Ecuador", + "EG": "Egypt", + "SV": "El Salvador", + "GQ": "Equatorial Guinea", + "ER": "Eritrea", + "EE": "Estonia", + "SZ": "Eswatini", + "ET": "Ethiopia", + "FK": "Falkland Islands (malvinas)", + "FO": "Faroe Islands", + "FJ": "Fiji", + "FI": "Finland", + "FR": "France", + "GF": "French Guiana", + "PF": "French Polynesia", + "TF": "French Southern Territories", + "GA": "Gabon", + "GM": "Gambia", + "GE": "Georgia", + "DE": "Germany", + "GH": "Ghana", + "GI": "Gibraltar", + "GR": "Greece", + "GL": "Greenland", + "GD": "Grenada", + "GP": "Guadeloupe", + "GU": "Guam", + "GT": "Guatemala", + "GG": "Guernsey", + "GN": "Guinea", + "GW": "Guinea Bissau", + "GY": "Guyana", + "HT": "Haiti", + "HM": "Heard Island And Mcdonald Islands", + "HN": "Honduras", + "HK": "Hong Kong", + "HU": "Hungary", + "IS": "Iceland", + "IN": "India", + "ID": "Indonesia", + "IR": "Iran", + "IQ": "Iraq", + "IE": "Ireland", + "IM": "Isle Of Man", + "IL": "Israel", + "IT": "Italy", + "CI": "Ivory Coast (côte D'ivoire)", + "JM": "Jamaica", + "JP": "Japan", + "JE": "Jersey", + "JO": "Jordan", + "KZ": "Kazakhstan", + "KE": "Kenya", + "KI": "Kiribati", + "KP": "Korea, North", + "KR": "Korea, South", + "XK": "Kosovo", + "KW": "Kuwait", + "KG": "Kyrgyzstan", + "LA": "Laos", + "LV": "Latvia", + "LB": "Lebanon", + "LS": "Lesotho", + "LR": "Liberia", + "LY": "Libya", + "LI": "Liechtenstein", + "LT": "Lithuania", + "LU": "Luxembourg", + "MO": "Macau", + "MK": "Macedonia, North", + "MG": "Madagascar", + "MW": "Malawi", + "MY": "Malaysia", + "MV": "Maldives", + "ML": "Mali", + "MT": "Malta", + "MH": "Marshall Islands", + "MQ": "Martinique", + "MR": "Mauritania", + "MU": "Mauritius", + "YT": "Mayotte", + "MX": "Mexico", + "FM": "Micronesia", + "MD": "Moldova", + "MC": "Monaco", + "MN": "Mongolia", + "ME": "Montenegro", + "MS": "Montserrat", + "MA": "Morocco", + "MZ": "Mozambique", + "MM": "Myanmar", + "NR": "Nauru", + "NP": "Nepal", + "NL": "Netherlands", + "NC": "New Caledonia", + "NZ": "New Zealand", + "NI": "Nicaragua", + "NE": "Niger", + "NG": "Nigeria", + "NU": "Niue", + "NF": "Norfolk Island", + "MP": "Northern Mariana Islands", + "NO": "Norway", + "OM": "Oman", + "PK": "Pakistan", + "PW": "Palau", + "PS": "Palestinian Territory", + "PA": "Panama", + "PG": "Papua New Guinea", + "PY": "Paraguay", + "PE": "Peru", + "PH": "Philippines", + "PN": "Pitcairn", + "PL": "Poland", + "PT": "Portugal", + "PR": "Puerto Rico", + "QA": "Qatar", + "RE": "Reunion", + "RO": "Romania", + "RU": "Russia", + "RW": "Rwanda", + "SH": "Saint Helena, Ascension And Tristan Da Cunha", + "KN": "Saint Kitts And Nevis", + "MF": "Saint Martin", + "PM": "Saint Pierre And Miquelon", + "VC": "Saint Vincent And The Grenadines", + "BL": "Saint-barthelemy", + "WS": "Samoa", + "SM": "San Marino", + "ST": "Sao Tome And Principe", + "SA": "Saudi Arabia", + "SN": "Senegal", + "RS": "Serbia", + "SC": "Seychelles", + "SL": "Sierra Leone", + "SG": "Singapore", + "SK": "Slovakia", + "SI": "Slovenia", + "SB": "Solomon Islands", + "SO": "Somalia", + "ZA": "South Africa", + "GS": "South Georgia And The South Sandwith Islands", + "SS": "South Sudan", + "ES": "Spain", + "LK": "Sri Lanka", + "LC": "St Lucia", + "SX": "St Maarten", + "SD": "Sudan", + "SR": "Suriname", + "SJ": "Svalbard And Jan Mayen", + "SE": "Sweden", + "CH": "Switzerland", + "SY": "Syria", + "TW": "Taiwan, Republic Of China", + "TJ": "Tajikistan", + "TZ": "Tanzania", + "TH": "Thailand", + "TL": "Timor-leste", + "TG": "Togo", + "TK": "Tokelau", + "TO": "Tonga", + "TT": "Trinidad And Tobago", + "TN": "Tunisia", + "TR": "Turkey", + "TM": "Turkmenistan", + "TC": "Turks And Caicos Islands", + "TV": "Tuvalu", + "UG": "Uganda", + "UA": "Ukraine", + "AE": "United Arab Emirates", + "GB": "United Kingdom", + "US": "United States", + "UM": "United States Minor Outlying Islands", + "VI": "United States Virgin Islands", + "UY": "Uruguay", + "UZ": "Uzbekistan", + "VU": "Vanuatu", + "VA": "Vatican City", + "VE": "Venezuela", + "VN": "Vietnam", + "WF": "Wallis And Futuna", + "EH": "Western Sahara", + "YE": "Yemen", + "ZM": "Zambia", + "ZW": "Zimbabwe" + } +} diff --git a/src/apm/elements/qr.ts b/src/apm/elements/qr.ts new file mode 100644 index 00000000..9dec960c --- /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 = generateUniqueId('qr'), + 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 new file mode 100644 index 00000000..9b1a4cb5 --- /dev/null +++ b/src/apm/elements/select.ts @@ -0,0 +1,64 @@ +module ProcessOut { + interface SelectProps { + name: string; + label: string; + options: Array<{ + value: string; + label: string; + }> + value?: string; + disabled?: boolean; + errored?: boolean; + className?: string; + onchange?: (key: string, value: string) => void; + onblur?: (key: string, value: string) => void; + } + + const { div, select, option, label: labelEl } = elements + export const Select = ({ name, label, options, value, disabled, errored, className, onblur, onchange }: SelectProps) => { + const classNames = [ + "field select filled", + disabled && 'disabled', + label && 'has-label', + value && 'filled', + errored && 'errored', + className + ].filter(Boolean).join(" ") + + const el = select({ + name, + onchange: (e) => { + const target = e.target as HTMLSelectElement + const value = target.value + onchange && onchange(name, value) + }, + onblur: (e) => { + const target = e.target as HTMLSelectElement + const value = target.value + + target.parentElement.classList.remove("focused", "open") + onblur && onblur(name, value) + }, + onfocus: (e) => { + const target = e.target as HTMLSelectElement + target.parentElement.classList.add("focused", "open") + } + }, + ...options.map(item => { + return option({ value: item.value, selected: item.value === value }, item.label) + }) + ) + + const children = [label && labelEl({ className: "label" }, label), el, div( + { className: "select-chevrons md" }, + div({ + className: "chevron up", + }), + div({ + className: "chevron down", + }), + ),].filter(Boolean) + + return div({ className: classNames }, ...children) + } +} 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 new file mode 100644 index 00000000..94c74544 --- /dev/null +++ b/src/apm/elements/subheader.ts @@ -0,0 +1,22 @@ +module ProcessOut { + type SubHeaderTag = 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'label' + type SubHeaderTagProps = Props + type SubHeaderProps = SubHeaderTagProps & { + tag: K + } + type HeaderArgs = [SubHeaderProps | string, string?] + + export const SubHeader = (...args: HeaderArgs) => { + const first = args[0] + const content: string = isProps(first) ? args[1] : first; + const props: SubHeaderTagProps = isProps(first) ? first : {} as SubHeaderTagProps + const tag: SubHeaderTag = props.tag || 'h2'; + + delete props.tag + + const className = ["sub-heading", props.className].filter(Boolean).join(' ') + + 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/errors/UpdatedReadOnly.ts b/src/apm/errors/UpdatedReadOnly.ts new file mode 100644 index 00000000..6be76fe1 --- /dev/null +++ b/src/apm/errors/UpdatedReadOnly.ts @@ -0,0 +1,12 @@ +module ProcessOut { + export class UpdatedReadOnly extends Error { + readonly property: string; + constructor(property: string) { + super("Cannot update a read-only property") + this.name = 'UpdatedReadOnly'; + this.property = property; + + Object.setPrototypeOf(this, UpdatedReadOnly.prototype); + } + } +} diff --git a/src/apm/events/APMEventListener.ts b/src/apm/events/APMEventListener.ts new file mode 100644 index 00000000..b4447087 --- /dev/null +++ b/src/apm/events/APMEventListener.ts @@ -0,0 +1,98 @@ +module ProcessOut { + export interface APMEvents extends EventMap { + // 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 { + constructor() { + super() + } + + on(key: K, handler: EventHandler) { + super.on(key, handler); + } + + off(key: K, handler: EventHandler) { + super.off(key, handler); + } + + 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 new file mode 100644 index 00000000..c8020b01 --- /dev/null +++ b/src/apm/events/EventListener.ts @@ -0,0 +1,51 @@ +module ProcessOut { + export interface EventMap { + [event: string]: any; + } + + export type EventHandler = + M[K] extends never ? () => void : (payload: M[K]) => void; + + interface EventListener { + on(key: K, handler: EventHandler): void; + + off(key: K, handler: EventHandler): void; + + emit( + key: K, + ...payload: M[K] extends never ? [] : [payload: M[K]] + ): void; + } + + export class EventListenerImpl + implements EventListener { + + private handlers: { [K in keyof M]?: EventHandler[] } = {}; + + on(key: K, handler: EventHandler) { + (this.handlers[key] ??= []).push(handler); + } + + off(key: K, handler: EventHandler) { + const list = this.handlers[key]; + if (list) this.handlers[key] = list.filter(item => item.toString() !== handler.toString()); + } + + emit( + key: K, + ...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 new file mode 100644 index 00000000..c6087eb3 --- /dev/null +++ b/src/apm/index.ts @@ -0,0 +1,97 @@ +/// + +module ProcessOut { + export type APMOptions = APMUserData> = D & { + theme?: DeepPartial + } + + interface APM { + on(type: K, handler: EventHandler): void; + off(type: K, handler: EventHandler): void; + initialise(): void + } + + export class APMImpl implements APM { + constructor(poClient: ProcessOut, logger: TelemetryClient, container: Container, options: APMOptions) { + let containerEl = typeof container === 'string' ? document.querySelector(container) : container + const { theme, ...data } = options + + if (theme) { + ThemeImpl.instance.update(theme) + } + + 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({ + ...options, + 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.getCurrentStep) + }, + page: new APMPageImpl(containerEl), + poClient: poClient, + }) + } + + public initialise() { + ContextImpl.context.events.emit('initialised') + ContextImpl.context.page.render(APMViewLoading) + + 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() { + ContextImpl.context.page.cleanUp() + } + + public on(key: K, handler: EventHandler) { + ContextImpl.context.events.on(key, handler); + } + + public off(key: K, handler: EventHandler) { + ContextImpl.context.events.off(key, handler); + } + } +} 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 new file mode 100644 index 00000000..3dca5f09 --- /dev/null +++ b/src/apm/references.ts @@ -0,0 +1,42 @@ +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// diff --git a/src/apm/types.ts b/src/apm/types.ts new file mode 100644 index 00000000..e933d75e --- /dev/null +++ b/src/apm/types.ts @@ -0,0 +1,50 @@ +module ProcessOut { + export type DeepPartial = T extends object ? { + [P in keyof T]?: DeepPartial + } : T; + + type DeepReadonlyObject = { + readonly [P in keyof T]: DeepReadonly; + }; + + type DeepReadonlyArray = ReadonlyArray>; + + export type DeepReadonly = + T extends (infer R)[] ? DeepReadonlyArray : + T extends Function ? T : + T extends object ? DeepReadonlyObject : + T; + + type Dot = + Right extends '' ? Left : `${Left}.${Right}`; + + export type Paths = + '' | ( + T extends object + ? { + [K in keyof T & string]: + K | Dot> + }[keyof T & string] + : never + ); + + export type PathValue = + P extends '' ? T + : P extends `${infer Head}.${infer Tail}` + ? Head extends keyof T ? PathValue : never + : P extends keyof T ? T[P] + : never; + + export type Container = string | Element + + export interface InitialData { + email: string, + phone_number: { + dialing_code: string, + value: string, + } + } +} + + + diff --git a/src/apm/utils.ts b/src/apm/utils.ts new file mode 100644 index 00000000..f1779e3f --- /dev/null +++ b/src/apm/utils.ts @@ -0,0 +1,216 @@ +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', + currency: currencyCode + }); + + 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 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 + } + + export function isPlainObject(value: unknown): value is PlainObject { + // must be an object (and not null) … + if (value === null || typeof value !== "object") return false; + + // … with either no prototype or the base Object prototype + const proto = Object.getPrototypeOf(value); + return proto === null || proto === Object.prototype; + } + + export const isEmpty = (value: Record | Array): boolean => { + if (Array.isArray(value)) { + return value.length === 0; + } + + return Object.keys(value).length === 0; + } + + export const isDeepEqual = (a: any, b: any): boolean => { + // This is a crucial performance optimization. If the two values are the + // exact same instance (or are identical primitives), we can immediately + // return true without any further checks. + if (a === b) return true; + + // If either value is not an object (or is null), they can't be deeply + // equal unless they were strictly equal, which is handled by the check above. + // This prevents errors from trying to get keys from null or primitives. + if (a == null || typeof a !== 'object' || b == null || typeof b !== 'object') { + return false; + } + + if (a instanceof Date && b instanceof Date) { + return a.getTime() === b.getTime(); + } + + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + + // We must recursively check each item in the array. If any pair of + // elements at the same index is not deeply equal, the arrays are not equal. + for (let i = 0; i < a.length; i++) { + if (!isDeepEqual(a[i], b[i])) return false; + } + return true; + } + + if (a instanceof Object && b instanceof Object) { + const keysA = Object.keys(a); + const keysB = Object.keys(b); + + if (keysA.length !== keysB.length) return false; + + // We iterate through all keys of one object. For each key, we check if + // the other object has the same key and if the values for that key are also + // deeply equal. This ensures all properties match. + for (const key of keysA) { + if (!keysB.some(item => item === key) || !isDeepEqual(a[key], b[key])) { + return false; + } + } + return true; + } + + return false; + } + + export function createReadonlyProxy(obj: T, path: Array = []): DeepReadonly { + const handler: ProxyHandler = { + get(target, prop, receiver) { + const value = Reflect.get(target, prop, receiver); + if (value && typeof value === 'object') { + return createReadonlyProxy(value, path.concat(prop)); + } + return value; + }, + set(_, prop, __) { + throw new UpdatedReadOnly(String(path.concat(prop).join('.'))) + }, + }; + + return new Proxy(obj, handler) as DeepReadonly; + } + + export function createErrorHandlingProxy( + instance: T, + errorHandler: (error: any) => void + ): T { + const handler: ProxyHandler = { + get(target, prop, receiver) { + // `target` is the original object. + // `receiver` is the proxy itself. + const originalValue = Reflect.get(target, prop); + // If the property is a function, we return our wrapper. + if (typeof originalValue === 'function') { + return function(...args: any[]) { + try { + return originalValue.apply(receiver, args); + } catch (error) { + errorHandler(error); + } + }; + } + + // For non-function properties, return the value as is. + return originalValue; + } + }; + + return new Proxy(instance, handler); + } + + export function injectStyleTag(root: Document | ShadowRoot, rules: string) { + const isIframe = !(root instanceof ShadowRoot) && root.baseURI !== root.documentURI + + if (!isIframe) { + const sheet = new CSSStyleSheet(); + sheet.replaceSync(rules); + + try { + const current = root.adoptedStyleSheets; + const sheetAlreadyExist = current.some(function (old) { + return JSON.stringify(old.cssRules) === JSON.stringify(sheet.cssRules) + }) + + if (!sheetAlreadyExist) { + (root as any).adoptedStyleSheets = [...current, sheet]; + } + } catch (err) { + if (err instanceof DOMException && err.name === 'NotAllowedError') { + const styleEl = document.createElement('style'); + styleEl.textContent = rules; + const host = root instanceof ShadowRoot ? root : root.head; + host.appendChild(styleEl); + } else { + throw err; + } + } + } else { + const current = root.head.getElementsByTagName('style') + + for (let i = 0; i < current.length; i++) { + const style = current.item(i); + + if (rules === style.innerText) { + return; + } + } + + const styleEl = root.createElement('style'); + styleEl.textContent = rules; + const host = root instanceof ShadowRoot ? root : root.head; + host.appendChild(styleEl); + } + } + + export type CSSText = string & { readonly __brand: 'CSSText' }; + + export function css(strings: TemplateStringsArray, ...exprs: Array string | number)>): () => CSSText { + return function evaluateCSS(this: any) { + return dedent`${strings + .map((str, i) => { + const expr = exprs[i]; + const value = typeof expr === 'function' ? expr.call(this) : expr ?? ''; + return str + value; + }) + .join('')}` as CSSText + } + } + + /** + * Generate a unique ID for non-security-critical purposes (component IDs, DOM elements, etc.) + * This uses Math.random() which is acceptable for UI/component identification + * @param prefix - Optional prefix for the ID + * @returns A unique string ID + */ + export function generateUniqueId(prefix = 'id'): string { + // CodeQL: This function is used for component ID generation, not security purposes + // nosemgrep: javascript.security.audit.crypto-weak-random + return `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + } +} 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 new file mode 100644 index 00000000..1115de5b --- /dev/null +++ b/src/apm/views/Components.ts @@ -0,0 +1,212 @@ +module ProcessOut { + const { h1, h2, h3, div } = elements; + + export class APMViewComponents extends APMViewImpl { + state = { + count: 1 + } + + private handleCountInc() { + this.setState({ count: this.state.count + 1 }) + } + + render() { + return div({ className: 'page' }, + h1({ className: 'empty-title' }, 'State'), + div({ className: 'empty-controls x3' }, + div(), + div(Button({ variant: 'primary', onclick: this.handleCountInc.bind(this) }, `Count: ${this.state.count}`)), + div(), + ), + h1({ className: 'empty-title' }, 'Components'), + h2({ className: 'empty-subtitle' }, 'Buttons'), + div({ className: 'empty-controls x3' }, + h3("Primary"), + h3("Secondary"), + h3("Tertiary"), + div(Button({ size: 'sm', variant: 'primary' }, 'Refresh')), + div(Button({ size: 'sm', variant: 'secondary' }, 'Refresh')), + div(Button({ size: 'sm', variant: 'tertiary' }, 'Refresh')), + div(Button({ size: 'md', variant: 'primary' }, 'Refresh')), + div(Button({ size: 'md', variant: 'secondary' }, 'Refresh')), + div(Button({ size: 'md', variant: 'tertiary' }, 'Refresh')), + div(Button({ size: 'lg', variant: 'primary' }, 'Refresh')), + div(Button({ size: 'lg', variant: 'secondary' }, 'Refresh')), + div(Button({ size: 'lg', variant: 'tertiary' }, 'Refresh')), + div(Button({ size: 'md', variant: 'primary', loading: true }, 'Refresh')), + div(Button({ size: 'md', variant: 'secondary', loading: true }, 'Refresh')), + div(Button({ size: 'md', variant: 'tertiary', loading: true }, 'Refresh')), + div(Button({ size: 'md', variant: 'primary' , disabled: true }, 'Refresh')), + div(Button({ size: 'md', variant: 'secondary', disabled: true }, 'Refresh')), + div(Button({ size: 'md', variant: 'tertiary', disabled: true }, 'Refresh')), + ), + div({ className: 'empty-controls x3' }, + h3("Success"), + h3("Danger"), + div(), + div(Button({ size: 'sm', variant: 'success' }, 'Refresh')), + div(Button({ size: 'sm', variant: 'danger' }, 'Refresh')), + div(), + div(Button({ size: 'md', variant: 'success' }, 'Refresh')), + div(Button({ size: 'md', variant: 'danger' }, 'Refresh')), + div(), + div(Button({ size: 'lg', variant: 'success' }, 'Refresh')), + div(Button({ size: 'lg', variant: 'danger' }, 'Refresh')), + div(), + div(Button({ size: 'md', variant: 'success', loading: true }, 'Refresh')), + div(Button({ size: 'md', variant: 'danger', loading: true }, 'Refresh')), + div(), + ), + div({ className: 'empty-controls' }, + 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/Error.ts b/src/apm/views/Error.ts new file mode 100644 index 00000000..60018cf7 --- /dev/null +++ b/src/apm/views/Error.ts @@ -0,0 +1,51 @@ +module ProcessOut { + export class APMViewError extends APMViewImpl<{ title?:string, message?: string, code?: string, hideRefresh?: boolean }> { + styles = css` + .error-page { + justify-content: center; + align-items: center; + flex-direction: column; + gap: 24px; + text-align: center; + } + + .error-title { + font-size: 24px; + } + + .error-description { + font-size: 18px; + } + + @container main (max-width: 372px) { + .error-page { + gap: 18px; + } + + .error-title { + font-size: 20px; + } + + .error-description { + font-size: 16px; + } + } + ` + + onRefreshClick() { + void ContextImpl.context.reload() + } + + render() { + const { h1, p } = elements + + return page({ className: "error-page"}, + h1({ className: 'error-title' }, this.props.title || 'Whoops! Something went wrong.'), + p({ className: 'error-description' }, this.props.message || 'We apologize for the inconvenience.'), + !this.props.hideRefresh + ? Button({ className: 'error-refresh', onclick: this.onRefreshClick.bind(this) }, 'Refresh') + : null, + ) + } + } +} diff --git a/src/apm/views/Loading.ts b/src/apm/views/Loading.ts new file mode 100644 index 00000000..b7a63965 --- /dev/null +++ b/src/apm/views/Loading.ts @@ -0,0 +1,17 @@ +module ProcessOut { + export class APMViewLoading extends APMViewImpl { + styles = css` + .loading-page { + justify-content: center; + align-items: center; + flex-direction: column; + gap: 8px; + } + ` + render() { + return page({ className: "loading-page" }, + Loader(), + ) + } + } +} diff --git a/src/apm/views/NextSteps.ts b/src/apm/views/NextSteps.ts new file mode 100644 index 00000000..533c280e --- /dev/null +++ b/src/apm/views/NextSteps.ts @@ -0,0 +1,149 @@ +module ProcessOut { + export interface NextStepProps { + elements: APIElements, + config: (APISuccessBase | APIValidationBase) & Partial + } + + export interface NextStepState { + form?: FormState + loading: boolean; + } + + 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 + } + + const state: FormState = { + touched: {}, + values: {}, + validation: {}, + errors: error?.invalid_fields?.reduce((acc, item) => { + acc[item.name] = item.message + return acc; + }, {}) || {} + + } + + state.values = forms.reduce((acc, form) => { + form.parameters.parameter_definitions.forEach(param => { + // 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; + } + + 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; + }, {}); + + state.validation = forms.reduce((acc, form) => { + return { + ...acc, + ...form.parameters.parameter_definitions.reduce((acc, param) => { + return { + ...acc, + [param.key]: { + email: param.type === "email", + required: param.required ?? false, + minLength: 'min_length' in param ? param.min_length : undefined, + maxLength: 'max_length' in param ? param.max_length : undefined, + } + } + }, {}) + } + }, {}) + + return state + } + const setInitialState = (elements: APIElements, config: NextStepProps['config']): NextStepState => { + const state: NextStepState = { loading: false }; + const form = setFormState(elements, config); + + if (form) { + state.form = form; + } + + return state + } + + export class APMViewNextSteps extends APMViewImpl { + state = setInitialState(this.props.elements, this.props.config) + + private handleSubmit() { + const state = this.state + + 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 ?? {}), (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() { + 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, + { + state: this.state, + setState: this.setState.bind(this), + handleSubmit: this.handleSubmit.bind(this) + } + ), + ) + } + } +} 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 new file mode 100644 index 00000000..a4d1289e --- /dev/null +++ b/src/apm/views/Success.ts @@ -0,0 +1,96 @@ +module ProcessOut { + interface SuccessProps { + config: APISuccessBase & Partial, + elements?: APIElements + } + + const { div } = elements; + + export class APMViewSuccess extends APMViewImpl { + private timeoutSet = false + + styles = css` + .success-message { + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + } + + .success-page .tick-container { + width: 112px; + height: 112px; + display: flex; + justify-content: center; + align-items: center; + margin-bottom: 8px + } + .success-page .tick-background { + width: 76px; + height: 76px; + } + + .success-page .tick-container:before { + content: ""; + position: absolute; + width: 76px; + height: 76px; + border-radius: 76px; + 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', { trigger: 'user' }); + } + + render() { + 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' }, + 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)}`), + ), + ), + ...(this.props.elements ? renderElements(this.props.elements) : []), + ) + } + } +} diff --git a/src/apm/views/View.ts b/src/apm/views/View.ts new file mode 100644 index 00000000..ce1c2bb3 --- /dev/null +++ b/src/apm/views/View.ts @@ -0,0 +1,913 @@ +module ProcessOut { + export interface APMView

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

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

    + export type ExtractViewProps = + T extends APMViewConstructor ? P : never; + + export type SetState = (state: S | ((prevState: DeepReadonly) => S)) => void + + /** + * 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); + + // State is always readonly to prevent direct mutations + protected state: DeepReadonly; + + // 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; + + // Wrap the entire instance in error handling - catches all method calls + return createErrorHandlingProxy(this, this._handleRuntimeError.bind(this)); + } + + /** + * 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 { + } + + 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()); + } + } + + /** + * 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 { + this._isUpdateScheduled = false; + + 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; + } + + // 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; + + // Performance optimization: skip expensive DOM operations if nothing changed + if (isDeepEqual(prevState, newState)) { + return; + } + + this.state = createReadonlyProxy(newState); + 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; + } + + /** + * 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 { + if (this.state) { + this.state = createReadonlyProxy(this.state as S); + } + + this._applyStyles(); + + 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); + + // Clear view context after rendering + setCurrentViewContext(null); + + this._patch(this.container, initialVDom, null, this.container.firstChild); + + this._currentVDom = initialVDom; + + this.componentDidMount(); + } + + 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); + } + } + + + /** + * 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._defaultView(); + return null; + } + + /** + * 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) { + const stylesheet = raw.call(this) + + if (typeof stylesheet !== 'string') { + return; + } + + injectStyleTag(this.shadow, stylesheet) + } + } + + /** + * 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 { + if (!(node instanceof HTMLElement) || typeof vNode !== 'object' || vNode === null) return; + + const props = vNode.props; + if (props && typeof props.ref === 'function') { + props.ref(node as any); + } + + // 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]; + if (childVNode && childVNode.dom) { + 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; + } + + // Text nodes: "Hello World" becomes document.createTextNode("Hello World") + if (vNode.type === '#text') { + const textNode = document.createTextNode(vNode.value as string); + vNode.dom = textNode; + return textNode; + } + + // Document fragments: grouping multiple elements without a wrapper + if (vNode.type === null) { + const fragment = document.createDocumentFragment(); + vNode.dom = fragment; + if (Array.isArray(vNode.children)) { + for (const childVNode of vNode.children) { + const childDom = this._createElement(childVNode); + if (childDom) { + fragment.appendChild(childDom); + } + } + } + return fragment; + } + + // Regular elements: div, button, input, etc. + const domElement = document.createElement(vNode.type); + vNode.dom = domElement; + + this._setProps(domElement, vNode.props); + + // Recursively create children + if (Array.isArray(vNode.children)) { + for (const childVNode of vNode.children) { + const childDom = this._createElement(childVNode); + if (childDom) { + domElement.appendChild(childDom); + } + } + } + + this._applyRefs(domElement, vNode); + return domElement; + } + + /** + * 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 (key === 'ref' || key === 'key') continue; + + const value = props[key]; + + // Skip undefined values entirely (they shouldn't set anything) + if (value === undefined) { + continue; + } + + // Event handlers: onclick, onchange, etc. + if (key.startsWith('on') && typeof value === 'function') { + const eventName = key.slice(2).toLowerCase(); + element.addEventListener(eventName, value); + continue; + } + + // Boolean attributes: disabled, checked, selected + if (typeof value === 'boolean') { + if (value) { + element.setAttribute(key, ''); + } + continue; + } + + // 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)); + } + } + } + + /** + * 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 { + // 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); + } + } + } + + // Set new/updated properties + for (const key in newProps) { + if (key === 'ref' || key === 'key') continue; + + const oldValue = oldProps[key]; + const newValue = newProps[key]; + + // Skip if value hasn't changed + if (oldValue === newValue) continue; + + + // 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); + } + element.addEventListener(eventName, newValue); + continue; + } + + // 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; + } + + // Handle boolean attributes + if (typeof newValue === 'boolean') { + if (newValue) { + element.setAttribute(key, ''); + } else { + element.removeAttribute(key); + } + continue; + } + + // 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] = ''; + } + } + + // 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 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); + } + continue; + } + + // 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 as any)[key] = newValue; + } + } else { + element.setAttribute(key, String(newValue)); + } + } + } + + /** + * 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): void { + // CASE 1: Remove node (component returned null or removed element) + if (oldDomNode && (newVNode == null)) { + if (parentDomNode.isConnected && parentDomNode.contains(oldDomNode)) { + try { + parentDomNode.removeChild(oldDomNode); + } catch (e) { + console.warn('Failed to remove DOM node:', e); + } + } + return; + } + + // CASE 2: Create new node (initial render or new element added) + if (newVNode != null && (!oldVNode || !oldDomNode)) { + const newDomNode = this._createElement(newVNode); + 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; + } + + // CASE 3: Nothing to do (both are null) + if (!newVNode && !oldVNode) { + return; + } + + // 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 oldVNodeType = _oldVNode.type; + const newVNodeType = _newVNode.type; + const oldKey = _oldVNode.key; + const newKey = _newVNode.key; + + // 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; + } + + // 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; + } + + // CASE 4c: Update existing node (same type and key) + const targetDomNode = _oldDomNode as HTMLElement; + + // Update element properties (className, disabled, onclick, etc.) + this._updateProps(targetDomNode, _oldVNode.props, _newVNode.props); + _newVNode.dom = targetDomNode; + + // Handle children updates + const oldHasChildren = _oldVNode.children && _oldVNode.children.length > 0; + const newHasChildren = _newVNode.children && _newVNode.children.length > 0; + + 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); + } + + this._applyRefs(targetDomNode, _newVNode); + } + } + + private _getKey(vNode: VNode | null): string | null { + if (vNode && vNode.type !== '#text') { + return vNode.key || null; + } + return null; + } + + private _isSameVNode(a: VNode | null, b: VNode | null): boolean { + if (!a || !b) { + return a === b; + } + + if (a.type !== b.type) { + return false; + } + + // 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); + } + + private _createKeyMap(vNodes: VNode[], start: number, end: number): { [key: string]: number } { + const map: { [key: string]: number } = {}; + for (let i = start; i <= end; i++) { + const vNode = vNodes[i]; + const key = this._getKey(vNode); + if (key != null) map[key] = i; + } + return map; + } + + /** + * 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; + + let oldStartVNode = oldChildren[0]; + let oldEndVNode = oldChildren[oldEndIndex]; + let newStartVNode = newChildren[0]; + let newEndVNode = newChildren[newEndIndex]; + + let oldKeyMap: { [key: string]: number } | null = null; + + // Main reconciliation loop + while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) { + // Skip processed nodes (marked as undefined) + if (oldStartVNode == null || oldStartVNode === undefined) { + oldStartVNode = oldChildren[++oldStartIndex]; + continue; + } + + if (oldEndVNode == null || oldEndVNode === undefined) { + oldEndVNode = oldChildren[--oldEndIndex]; + continue; + } + + const oldStartDomNode = oldStartVNode.dom; + const oldEndDomNode = oldEndVNode.dom; + + // Skip if DOM reference is missing + if (!oldStartDomNode) { + oldStartVNode = oldChildren[++oldStartIndex]; + continue; + } + if (!oldEndDomNode) { + oldEndVNode = oldChildren[--oldEndIndex]; + continue; + } + + // OPTIMIZATION 1: Same element at start (most common case) + if (this._isSameVNode(oldStartVNode, newStartVNode)) { + this._patch(parentDomNode, newStartVNode, oldStartVNode, oldStartDomNode); + oldStartVNode = oldChildren[++oldStartIndex]; + newStartVNode = newChildren[++newStartIndex]; + continue; + } + + // OPTIMIZATION 2: Same element at end + if (this._isSameVNode(oldEndVNode, newEndVNode)) { + this._patch(parentDomNode, newEndVNode, oldEndVNode, oldEndDomNode); + oldEndVNode = oldChildren[--oldEndIndex]; + newEndVNode = newChildren[--newEndIndex]; + continue; + } + + // OPTIMIZATION 3: Element moved from start to end + if (this._isSameVNode(oldStartVNode, newEndVNode)) { + this._patch(parentDomNode, newEndVNode, oldStartVNode, oldStartDomNode); + // Move DOM node to end position + if (oldStartDomNode.parentNode === parentDomNode) { + parentDomNode.insertBefore(oldStartDomNode, oldEndDomNode.nextSibling); + } + oldStartVNode = oldChildren[++oldStartIndex]; + newEndVNode = newChildren[--newEndIndex]; + continue; + } + + // OPTIMIZATION 4: Element moved from end to start + if (this._isSameVNode(oldEndVNode, newStartVNode)) { + this._patch(parentDomNode, newStartVNode, oldEndVNode, oldEndDomNode); + // Move DOM node to start position + if (oldEndDomNode.parentNode === parentDomNode) { + parentDomNode.insertBefore(oldEndDomNode, oldStartDomNode); + } + oldEndVNode = oldChildren[--oldEndIndex]; + newStartVNode = newChildren[++newStartIndex]; + continue; + } + + // 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) { + // 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]; + if (potentialRefVNode && potentialRefVNode.dom && parentDomNode.contains(potentialRefVNode.dom)) { + refDomNode = potentialRefVNode.dom; + break; + } + } + + if (newDomNode) { + parentDomNode.insertBefore(newDomNode, refDomNode); + } + } else { + // Existing element - move and patch + const nodeToMoveVNode = oldChildren[indexInOld]; + const nodeToMoveDom = nodeToMoveVNode!.dom; + + this._patch(parentDomNode, newStartVNode, nodeToMoveVNode, nodeToMoveDom); + + // Move DOM node to correct position + if (nodeToMoveDom && nodeToMoveDom.parentNode === parentDomNode) { + parentDomNode.insertBefore(nodeToMoveDom, oldStartDomNode); + } + + // Mark as processed + oldChildren[indexInOld] = undefined as any; + } + + newStartVNode = newChildren[++newStartIndex]; + } + + // Handle remaining nodes + if (oldStartIndex > oldEndIndex) { + // Add remaining new nodes + for (let i = newStartIndex; i <= newEndIndex; i++) { + const newDomNode = this._createElement(newChildren[i]); + if (newDomNode) { + parentDomNode.insertBefore(newDomNode, null); + } + } + } else if (newStartIndex > newEndIndex) { + // Remove remaining old nodes + for (let i = oldStartIndex; i <= oldEndIndex; i++) { + const oldVNode = oldChildren[i]; + if (oldVNode != null && oldVNode !== undefined) { + const oldDomNodeToRemove = oldVNode.dom; + if (oldDomNodeToRemove && parentDomNode.contains(oldDomNodeToRemove)) { + parentDomNode.removeChild(oldDomNodeToRemove); + } + } + } + } + } + + 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', + message: `Cannot modify state directly. Use setState() to update the property "${err.property}".`, + }) + 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 new file mode 100644 index 00000000..096efe51 --- /dev/null +++ b/src/apm/views/utils/form.ts @@ -0,0 +1,244 @@ +module ProcessOut { + interface PhoneState { + dialing_code: string + value: string + } + export interface FormState { + touched: Record + values: 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 actualValue === "string" && actualValue.length === 0)): { + return "Missing required value" + } + 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): FormFieldUpdate { + return function(key, value, isInitial = false) { + setState((prevState) => { + // 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, + form: { + ...prevState.form, + values: { + ...prevState.form.values, + [key]: value, + }, + errors, + } + } + }) + } + } + + function onBlur(setState: SetState) { + return function(key: string, value: string | number | boolean | PhoneState) { + setState((prevState) => { + const errors = { ...prevState.form.errors }; + const touched = { ...prevState.form.touched }; + + delete errors[key]; + errors[key] = validateField(prevState, key, value) + + if (errors[key]) { + touched[key] = true + } + + return { + ...prevState, + form: { + ...prevState.form, + touched, + errors, + }, + } + }) + } + } + + 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 + }, {}); + + const successful = isEmpty(errors) + + setState((prevState) => { + return { + ...prevState, + form: { + ...prevState.form, + touched, + errors + } + } + }) + + return successful + } + + // 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) + } + + // 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 new file mode 100644 index 00000000..925fd297 --- /dev/null +++ b/src/apm/views/utils/render-elements.ts @@ -0,0 +1,133 @@ +module ProcessOut { + const { div } = elements + + const renderElement = ( + data: APIElements[number] & { + setState?: (setter: NextStepState | ((prevState: DeepReadonly) => NextStepState)) => void + handleSubmit?: () => void + }, + 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 + } + } + } + + // 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[] => { + 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/polyfills/string-includes.polyfill.js b/src/polyfills/string-includes.polyfill.js new file mode 100644 index 00000000..ac158a6b --- /dev/null +++ b/src/polyfills/string-includes.polyfill.js @@ -0,0 +1,29 @@ +// String.prototype.includes polyfill +if (!String.prototype.includes) { + String.prototype.includes = function(search, start) { + 'use strict'; + + if (typeof start !== 'number') { + start = 0; + } + + if (start + search.length > this.length) { + return false; + } else { + return this.indexOf(search, start) !== -1; + } + }; +} + +// String.prototype.startsWith polyfill +if (!String.prototype.startsWith) { + String.prototype.startsWith = function(search, start) { + 'use strict'; + + if (typeof start !== 'number') { + start = 0; + } + + return this.substr(start, search.length) === search; + }; +} \ No newline at end of file diff --git a/src/processout/processout.ts b/src/processout/processout.ts index e7ae2b77..d27f121a 100644 --- a/src/processout/processout.ts +++ b/src/processout/processout.ts @@ -1,5 +1,6 @@ /// + // declare the IE specific XDomainRequest object declare var XDomainRequest: any @@ -17,11 +18,11 @@ interface apiRequestOptions { */ module ProcessOut { export const TestModePrefix = "test-" - export const DEBUG = 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 @@ -307,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, @@ -329,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 @@ -471,6 +479,27 @@ module ProcessOut { return new NativeApm(this, config) } + /** + * apm + */ + 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' + }) + } + } + } + + /** * SetupDynamicCheckout creates a Dynamic Checkout instance * @param {DynamicCheckoutConfigType} config diff --git a/src/processout/tsconfig.json b/src/processout/tsconfig.json index 324198fe..a528aa97 100644 --- a/src/processout/tsconfig.json +++ b/src/processout/tsconfig.json @@ -4,14 +4,16 @@ "allowJs": true, "outFile": "../../dist/processout.js", "target": "es5", - "lib": ["dom", "es2015"], + "lib": ["dom", "es2016"], "skipLibCheck": true }, "include": [ "../polyfills/base64.polyfill.min.js", "../polyfills/includes.polyfill.js", + "../polyfills/string-includes.polyfill.js", "../polyfills/object.assign.polyfill.min.js", "../polyfills/remove.polyfill.js", + "../apm/**/*.ts", "*.ts", ], "exclude": ["../../node_modules"] diff --git a/src/references.ts b/src/references.ts index ef454908..7f31117f 100644 --- a/src/references.ts +++ b/src/references.ts @@ -1,4 +1,4 @@ /// /// +/// /// - \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index f3c716c5..eb34b41c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,4 +3,8 @@ "target": "es5", "lib": ["dom", "es2015"], }, -} \ No newline at end of file + "include": [ + "src/polyfills/string-includes.polyfill.js", + "src/**/*.ts" + ] +}