/*! * Younium Self Service Embedded SDK * Version: 3.0.0 * (c) 2025 Younium * Released under the MIT License */ (function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : typeof define === 'function' && define.amd ? define(['exports'], factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.YouniumEmbedded = {})); })(this, (function (exports) { 'use strict'; /** * SDK Configuration and Defaults */ const DEFAULT_THEME = { primaryColor: '#DB7013', fontFamily: 'Roboto, sans-serif', borderRadius: '8px', buttonStyle: 'rounded', colorMode: 'light', }; const DEFAULT_CONFIG = { apiEndpoint: 'https://api.selfservice.younium.com', debug: false, locale: 'en-US', currency: 'USD', refreshThreshold: 300, // 5 minutes before expiry retryAttempts: 3, retryDelay: 1000, theme: DEFAULT_THEME, }; const COMPONENT_TYPES = { INVOICE_LIST: 'invoice-list', ACCOUNT_INFO: 'account-info', SUBSCRIPTION_LIST: 'subscription-list', }; const SDK_VERSION = '3.0.0'; /** * API Client for Self Service API communication */ class ApiClient { constructor(config) { this.token = null; this.config = config; } setToken(token) { this.token = token; } getToken() { return this.token; } updateConfig(config) { this.config = { ...this.config, ...config }; } /** * Make an API call with retry logic and error handling */ async call(options) { const { method, endpoint, body, retryCount = 0 } = options; const url = `${this.config.apiEndpoint}${endpoint}`; const fetchOptions = { method, headers: { 'Authorization': `Bearer ${this.token}`, 'Content-Type': 'application/json', 'X-Younium-Embedded': 'true', }, }; if (body) { fetchOptions.body = JSON.stringify(body); } try { if (this.config.debug) { console.log('[YouniumSDK] API call:', method, url, body); } const response = await fetch(url, fetchOptions); if (!response.ok) { await this.handleErrorResponse(response); } const data = await response.json(); if (this.config.debug) { console.log('[YouniumSDK] API response:', data); } return data; } catch (error) { // Retry logic with exponential backoff if (retryCount < this.config.retryAttempts && this.shouldRetry(error)) { const delay = this.config.retryDelay * Math.pow(2, retryCount); if (this.config.debug) { console.log(`[YouniumSDK] Retrying API call in ${delay}ms (attempt ${retryCount + 1})`); } await this.sleep(delay); return this.call({ ...options, retryCount: retryCount + 1 }); } throw error; } } async handleErrorResponse(response) { if (response.status === 401) { // Token expired if (this.config.onTokenExpired) { this.config.onTokenExpired(); } throw new Error('Token expired'); } if (response.status === 403) { try { const errorData = await response.json(); if (errorData.errorCode === 'INSUFFICIENT_SCOPE') { throw new Error(`Access denied: ${errorData.errorMessage}`); } } catch { // If we can't parse the error, fall back to generic message } throw new Error('Access denied. Please check your API credentials and permissions.'); } throw new Error(`API error: ${response.status} ${response.statusText}`); } shouldRetry(error) { // Don't retry auth errors or permission errors if (error instanceof Error) { const message = error.message.toLowerCase(); if (message.includes('token expired') || message.includes('access denied') || message.includes('401') || message.includes('403')) { return false; } } return true; } sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } } /** * JWT Token Management */ class TokenManager { constructor(config) { this.tokenExpiry = null; this.refreshTimer = null; this.config = config; } updateConfig(config) { this.config = config; } /** * Parse a JWT token and extract expiry time */ parseToken(token) { try { const parts = token.split('.'); if (parts.length !== 3) { return { expiry: null, payload: null }; } const payload = JSON.parse(atob(parts[1])); const expiry = payload.exp ? payload.exp * 1000 : null; return { expiry, payload }; } catch (e) { console.error('[YouniumSDK] Failed to parse token:', e); return { expiry: null, payload: null }; } } /** * Set the current token and set up refresh timer */ setToken(token) { const { expiry } = this.parseToken(token); this.tokenExpiry = expiry; if (this.config.debug && expiry) { console.log('[YouniumSDK] Token set, expires at:', new Date(expiry)); } // Clear existing timer this.clearRefreshTimer(); // Set up new refresh timer this.setupRefreshTimer(); } /** * Check if the token is expired or about to expire */ isTokenExpired() { if (!this.tokenExpiry) { return false; // Can't determine, assume valid } const now = Date.now(); const buffer = this.config.refreshThreshold * 1000; return now >= (this.tokenExpiry - buffer); } /** * Get the token expiry timestamp */ getExpiry() { return this.tokenExpiry; } /** * Set up automatic token refresh before expiry */ setupRefreshTimer() { if (!this.tokenExpiry || !this.config.onTokenExpired) { return; } const now = Date.now(); const refreshTime = this.tokenExpiry - (this.config.refreshThreshold * 1000); const delay = refreshTime - now; if (delay > 0) { this.refreshTimer = setTimeout(() => { if (this.config.debug) { console.log('[YouniumSDK] Token about to expire, requesting refresh'); } if (this.config.onTokenExpired) { this.config.onTokenExpired(); } }, delay); } } /** * Clear the refresh timer */ clearRefreshTimer() { if (this.refreshTimer) { clearTimeout(this.refreshTimer); this.refreshTimer = null; } } /** * Clean up resources */ destroy() { this.clearRefreshTimer(); this.tokenExpiry = null; } } /** * Dynamic Style Generation */ const STYLE_ID = 'yem-styles'; /** * Get button border radius based on style */ function getButtonRadius(theme) { switch (theme.buttonStyle) { case 'pill': return '9999px'; case 'square': return '0px'; default: return theme.borderRadius; } } /** * Generate CSS styles based on theme configuration */ function generateStyles(theme) { const isDark = theme.colorMode === 'dark'; const buttonRadius = getButtonRadius(theme); return ` .yem-component.yem-component { font-family: ${theme.fontFamily}; color: ${isDark ? '#ffffff' : '#1a1a1a'} !important; line-height: 1.5; background: ${isDark ? '#1a1a1a' : '#ffffff'} !important; border-radius: ${theme.borderRadius}; box-shadow: 0 1px 3px rgba(0, 0, 0, ${isDark ? '0.3' : '0.1'}); overflow: hidden; } .yem-loading { display: flex; align-items: center; justify-content: center; padding: 40px; color: ${isDark ? '#999' : '#666'}; } .yem-spinner { width: 24px; height: 24px; border: 3px solid ${isDark ? '#333' : '#f3f3f3'}; border-top: 3px solid ${theme.primaryColor}; border-radius: 50%; animation: yem-spin 1s linear infinite; margin-right: 12px; } @keyframes yem-spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } .yem-error { padding: 20px; background-color: ${isDark ? '#4a1c1c' : '#fee'}; border-left: 4px solid ${isDark ? '#ff6b6b' : '#f44336'}; color: ${isDark ? '#ff6b6b' : '#c00'}; border-radius: ${theme.borderRadius}; } .yem-error-title { font-weight: 600; margin-bottom: 8px; } .yem-component .yem-header, .yem-header.yem-header { padding: 20px; border-bottom: 1px solid ${isDark ? '#333' : '#e0e0e0'}; position: relative; min-height: 60px; background-color: ${isDark ? '#1a1a1a' : '#ffffff'} !important; } .yem-component .yem-title, .yem-title.yem-title { font-size: 20px; font-weight: 600; color: ${isDark ? '#ffffff' : '#1a1a1a'} !important; margin: 0; opacity: 1 !important; } .yem-subtitle { font-size: 14px; color: ${isDark ? '#999999' : '#666666'} !important; margin: 0 !important; white-space: nowrap; flex-shrink: 0; opacity: 1 !important; } .yem-component .yem-content, .yem-content.yem-content { padding: 20px; background-color: ${isDark ? '#1a1a1a' : '#ffffff'} !important; } .yem-component .yem-search-bar, .yem-search-bar.yem-search-bar { margin-bottom: 20px; display: flex; gap: 10px; } .yem-component .yem-content .yem-search-bar input.yem-search-input, .yem-component input.yem-search-input, input.yem-search-input.yem-search-input { flex: 1; padding: 8px 12px; border: 1px solid ${isDark ? '#444' : '#ddd'} !important; border-radius: ${theme.borderRadius}; background: ${isDark ? '#2a2a2a' : '#fff'} !important; background-color: ${isDark ? '#2a2a2a' : '#fff'} !important; color: ${isDark ? '#fff' : '#333'} !important; font-family: ${theme.fontFamily}; } .yem-table { width: 100%; border-collapse: collapse; } .yem-table th { background-color: ${isDark ? '#2a2a2a' : '#f5f5f5'} !important; padding: 12px; text-align: left; font-weight: 600; font-size: 14px; color: ${isDark ? '#cccccc' : '#666666'} !important; border-bottom: 2px solid ${isDark ? '#444' : '#e0e0e0'}; opacity: 1 !important; } .yem-table td { padding: 12px; border-bottom: 1px solid ${isDark ? '#333' : '#f0f0f0'}; font-size: 14px; color: ${isDark ? '#dddddd' : '#333333'} !important; opacity: 1 !important; } .yem-table tbody tr:hover { background-color: ${isDark ? '#2a2a2a' : '#f9f9f9'}; } .yem-status { display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 12px; font-weight: 500; text-transform: uppercase; } .yem-status-paid, .yem-status-active { background-color: ${isDark ? '#1a4d2e' : '#d4edda'}; color: ${isDark ? '#4ade80' : '#155724'}; } .yem-status-posted, .yem-status-pending { background-color: ${isDark ? '#4d4a1a' : '#fff3cd'}; color: ${isDark ? '#fbbf24' : '#856404'}; } .yem-status-draft { background-color: ${isDark ? '#1a3d4d' : '#d1ecf1'}; color: ${isDark ? '#60a5fa' : '#0c5460'}; } .yem-status-overdue, .yem-status-cancelled, .yem-status-expired { background-color: ${isDark ? '#4d1a1a' : '#f8d7da'}; color: ${isDark ? '#f87171' : '#721c24'}; } .yem-button { display: inline-block; padding: 6px 12px; background-color: ${theme.primaryColor}; color: white; text-decoration: none; border-radius: ${buttonRadius}; font-size: 14px; border: none; cursor: pointer; transition: all 0.2s; font-family: ${theme.fontFamily}; } .yem-button:hover { opacity: 0.9; transform: translateY(-1px); } .yem-button:disabled { opacity: 0.5; cursor: not-allowed; transform: none; } .yem-button-secondary { background-color: ${isDark ? '#4a5568' : '#6c757d'}; } .yem-button-secondary:hover { background-color: ${isDark ? '#2d3748' : '#545b62'}; } .yem-empty { padding: 60px 20px; text-align: center; color: ${isDark ? '#999' : '#999'}; } .yem-empty-icon { font-size: 48px; margin-bottom: 16px; opacity: 0.3; } .yem-pagination { display: flex; justify-content: space-between; align-items: center; padding: 20px; border-top: 1px solid ${isDark ? '#333' : '#e0e0e0'}; } .yem-pagination-info { color: ${isDark ? '#999' : '#666'}; font-size: 14px; } .yem-pagination-controls { display: flex; gap: 8px; } .yem-info-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 20px; } .yem-info-item { padding: 16px; background: ${isDark ? '#2a2a2a' : '#f9f9f9'} !important; border-radius: ${theme.borderRadius}; } .yem-info-label { font-size: 12px; color: ${isDark ? '#999999' : '#666666'} !important; text-transform: uppercase; margin-bottom: 4px; opacity: 1 !important; } .yem-info-value { font-size: 16px; color: ${isDark ? '#ffffff' : '#1a1a1a'} !important; font-weight: 500; opacity: 1 !important; } .yem-section { margin-bottom: 24px; } .yem-component .yem-section-title, .yem-section-title.yem-section-title { font-size: 16px; font-weight: 600; color: ${isDark ? '#ffffff' : '#1a1a1a'} !important; margin: 0 0 12px 0; opacity: 1 !important; } .yem-info-item-inline { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .yem-edit-btn { background: transparent; border: 1px solid ${theme.primaryColor}; color: ${theme.primaryColor}; padding: 6px 12px; border-radius: ${theme.borderRadius}; font-size: 12px; cursor: pointer; font-family: ${theme.fontFamily}; transition: all 0.2s ease; white-space: nowrap; } .yem-edit-btn:hover { background: ${theme.primaryColor}; color: white; } .yem-form-group { margin-bottom: 16px; } .yem-form-group-full { grid-column: 1 / -1; } .yem-form-label { display: block; font-size: 12px; font-weight: 500; color: ${isDark ? '#999999' : '#666666'} !important; margin-bottom: 4px; opacity: 1 !important; } .yem-component .yem-content input.yem-form-input, .yem-component input.yem-form-input, input.yem-form-input.yem-form-input { width: 100%; padding: 8px 12px; border: 1px solid ${isDark ? '#444' : '#ddd'} !important; border-radius: ${theme.borderRadius}; background: ${isDark ? '#2a2a2a' : '#fff'} !important; background-color: ${isDark ? '#2a2a2a' : '#fff'} !important; color: ${isDark ? '#fff' : '#333'} !important; font-family: ${theme.fontFamily}; font-size: 14px; box-sizing: border-box; } .yem-component .yem-content input.yem-form-input:focus, .yem-component input.yem-form-input:focus, input.yem-form-input.yem-form-input:focus { outline: none; border-color: ${theme.primaryColor} !important; box-shadow: 0 0 0 2px ${theme.primaryColor}20; } .yem-form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; } .yem-form-actions { display: flex; gap: 8px; margin-top: 16px; } .yem-btn { padding: 8px 16px; border-radius: ${buttonRadius}; font-family: ${theme.fontFamily}; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s ease; border: none; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; gap: 8px; } .yem-btn-primary { background: ${theme.primaryColor}; color: white; } .yem-btn-primary:hover { opacity: 0.9; } .yem-btn-secondary { background: transparent; color: ${isDark ? '#999' : '#666'}; border: 1px solid ${isDark ? '#444' : '#ddd'}; } .yem-btn-secondary:hover { background: ${isDark ? '#333' : '#f5f5f5'}; } .yem-message { padding: 12px 16px; border-radius: ${theme.borderRadius}; margin-top: 16px; font-size: 14px; } .yem-message-success { background: ${isDark ? '#1a4d3a' : '#d4edda'}; color: ${isDark ? '#4caf50' : '#155724'}; border-left: 4px solid ${isDark ? '#4caf50' : '#4caf50'}; } .yem-message-error { background: ${isDark ? '#4a1c1c' : '#f8d7da'}; color: ${isDark ? '#ff6b6b' : '#721c24'}; border-left: 4px solid ${isDark ? '#ff6b6b' : '#f44336'}; } .yem-message-info { background: ${isDark ? '#1a3a4a' : '#d1ecf1'}; color: ${isDark ? '#29b6f6' : '#0c5460'}; border-left: 4px solid ${isDark ? '#29b6f6' : '#2196f3'}; } /* Subscription-specific styles */ .yem-subscription-card { border: 1px solid ${isDark ? '#333' : '#e5e5e5'}; border-radius: ${theme.borderRadius}; margin-bottom: 24px; overflow: hidden; background: ${isDark ? '#1f1f1f' : '#ffffff'}; } .yem-subscription-header { padding: 20px 24px; display: flex; justify-content: space-between; align-items: flex-start; border-bottom: none; } .yem-subscription-title { font-size: 20px; font-weight: 600; color: ${isDark ? '#ffffff' : '#1a1a1a'} !important; margin: 0; } .yem-subscription-meta { font-size: 14px; color: ${isDark ? '#999' : '#666'}; margin-top: 4px; } /* Products section */ .yem-subscription-products { padding: 0 24px 20px; } .yem-products-header { font-size: 14px; font-weight: 500; color: ${isDark ? '#999' : '#666'}; margin-bottom: 12px; } .yem-products-list { border: 1px solid ${isDark ? '#333' : '#e5e5e5'}; border-radius: ${theme.borderRadius}; overflow: hidden; } .yem-product-line { padding: 16px; border-bottom: 1px solid ${isDark ? '#333' : '#e5e5e5'}; cursor: pointer; transition: background-color 0.15s; background: ${isDark ? '#1f1f1f' : '#ffffff'}; } .yem-product-line:last-child { border-bottom: none; } .yem-product-line:hover { background: ${isDark ? '#2a2a2a' : '#f9f9f9'}; } .yem-product-line.yem-expanded { background: ${isDark ? '#252525' : '#f5f5f5'}; } .yem-product-summary { display: flex; justify-content: space-between; align-items: center; } .yem-product-info { display: flex; align-items: baseline; gap: 12px; } .yem-product-name { font-weight: 500; font-size: 15px; color: ${isDark ? '#ffffff' : '#1a1a1a'}; } .yem-charge-type { font-size: 13px; color: ${isDark ? '#888' : '#999'}; } .yem-product-price { text-align: right; } .yem-price-amount { font-size: 16px; font-weight: 600; color: ${isDark ? '#ffffff' : '#1a1a1a'}; } .yem-price-usage { font-size: 13px; font-style: italic; color: ${isDark ? '#888' : '#666'}; } /* Line item details (expanded) */ .yem-line-details { margin-top: 16px; padding-top: 16px; border-top: 1px solid ${isDark ? '#333' : '#e5e5e5'}; display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 16px; } .yem-line-detail { font-size: 13px; } .yem-line-detail-label { color: ${isDark ? '#999' : '#666'}; margin-bottom: 4px; font-size: 12px; } .yem-line-detail-value { font-weight: 500; color: ${isDark ? '#fff' : '#1a1a1a'}; } .yem-line-actions { margin-top: 16px; display: flex; gap: 8px; justify-content: flex-end; } /* Modal styles */ .yem-modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; z-index: 10000; } .yem-modal { background: ${isDark ? '#1a1a1a' : '#ffffff'}; border-radius: ${theme.borderRadius}; box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3); max-width: 500px; width: 90%; max-height: 90vh; overflow: auto; } .yem-modal-header { padding: 20px; border-bottom: 1px solid ${isDark ? '#333' : '#e0e0e0'}; } .yem-modal-title { font-size: 18px; font-weight: 600; color: ${isDark ? '#ffffff' : '#1a1a1a'}; margin: 0; } .yem-modal-body { padding: 20px; } .yem-modal-footer { padding: 16px 20px; border-top: 1px solid ${isDark ? '#333' : '#e0e0e0'}; display: flex; gap: 8px; justify-content: flex-end; } /* Price calculation preview */ .yem-price-preview { background: ${isDark ? '#2a2a2a' : '#f9f9f9'}; border-radius: ${theme.borderRadius}; padding: 16px; margin: 16px 0; } .yem-price-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid ${isDark ? '#333' : '#e0e0e0'}; } .yem-price-row:last-child { border-bottom: none; font-weight: 600; } .yem-price-label { color: ${isDark ? '#999' : '#666'}; } .yem-price-value { color: ${isDark ? '#fff' : '#1a1a1a'}; } .yem-price-positive { color: ${isDark ? '#4ade80' : '#155724'}; } .yem-price-negative { color: ${isDark ? '#f87171' : '#721c24'}; } /* Confirmation modal styles */ .yem-confirm-intro { color: ${isDark ? '#999' : '#6b7280'}; margin-bottom: 16px; } .yem-confirm-details { background: ${isDark ? '#2a2a2a' : '#f9fafb'}; border: 1px solid ${isDark ? '#333' : '#e5e7eb'}; border-radius: ${theme.borderRadius}; padding: 16px; margin-bottom: 16px; } .yem-confirm-product { font-weight: 600; color: ${isDark ? '#fff' : '#111827'}; margin: 0 0 4px 0; } .yem-confirm-subscription { font-size: 12px; color: ${isDark ? '#666' : '#9ca3af'}; margin: 0 0 16px 0; } .yem-confirm-changes { padding-top: 12px; border-top: 1px solid ${isDark ? '#444' : '#e5e7eb'}; } .yem-confirm-row { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; font-size: 14px; color: ${isDark ? '#999' : '#6b7280'}; } .yem-confirm-value { font-weight: 500; color: ${isDark ? '#fff' : '#111827'}; } .yem-confirm-pricing { margin-top: 12px; padding-top: 12px; border-top: 1px solid ${isDark ? '#444' : '#e5e7eb'}; } .yem-confirm-total { font-weight: 600; font-size: 15px; padding-top: 12px; margin-top: 8px; border-top: 1px solid ${isDark ? '#444' : '#e5e7eb'}; } .yem-confirm-note { font-size: 13px; color: ${isDark ? '#666' : '#6b7280'}; margin-top: 16px; } /* Add-on product list styles */ .yem-addon-intro { font-size: 14px; color: ${isDark ? '#999' : '#6b7280'}; margin-bottom: 12px; } .yem-addon-list { display: flex; flex-direction: column; gap: 8px; } .yem-addon-card { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border: 1px solid ${isDark ? '#333' : '#e5e7eb'}; border-radius: ${theme.borderRadius}; cursor: pointer; transition: all 0.15s ease; } .yem-addon-card:hover { background: ${isDark ? '#2a2a2a' : '#f0f7ff'}; border-color: ${theme.primaryColor}; } .yem-addon-card-content { display: flex; flex-direction: column; gap: 2px; } .yem-addon-name { font-weight: 500; font-size: 14px; color: ${isDark ? '#fff' : '#111827'}; } .yem-addon-charges { font-size: 12px; color: ${isDark ? '#666' : '#9ca3af'}; } .yem-addon-arrow { font-size: 20px; color: ${isDark ? '#444' : '#d1d5db'}; font-style: normal; } .yem-back-btn { background: none; border: none; color: ${theme.primaryColor}; font-size: 14px; cursor: pointer; padding: 0; margin-bottom: 16px; display: flex; align-items: center; gap: 4px; } .yem-back-btn:hover { text-decoration: underline; } .yem-selected-product { margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid ${isDark ? '#333' : '#e5e7eb'}; } .yem-selected-product-name { font-size: 16px; font-weight: 600; color: ${isDark ? '#fff' : '#111827'}; margin: 0 0 4px 0; } .yem-selected-product-desc { font-size: 13px; color: ${isDark ? '#999' : '#6b7280'}; margin: 0; } /* Addon confirmation modal charge rows */ .yem-confirm-charges { margin-top: 12px; padding-top: 12px; border-top: 1px solid ${isDark ? '#333' : '#e5e7eb'}; } .yem-confirm-charge-row { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; border-bottom: 1px solid ${isDark ? '#333' : '#f3f4f6'}; } .yem-confirm-charge-row:last-child { border-bottom: none; } .yem-confirm-charge-info { display: flex; align-items: baseline; gap: 8px; } .yem-confirm-charge-name { font-size: 14px; color: ${isDark ? '#fff' : '#111827'}; } .yem-confirm-charge-qty { font-size: 12px; color: ${isDark ? '#999' : '#6b7280'}; } .yem-confirm-charge-price { font-size: 14px; font-weight: 500; color: ${isDark ? '#fff' : '#111827'}; } .yem-confirm-total { display: flex; justify-content: space-between; margin-top: 12px; padding-top: 12px; border-top: 1px solid ${isDark ? '#444' : '#e5e7eb'}; font-weight: 600; font-size: 15px; color: ${isDark ? '#fff' : '#111827'}; } .yem-confirm-charge-type { font-size: 12px; color: ${isDark ? '#888' : '#6b7280'}; display: block; margin-top: 2px; } /* Addon price in product list */ .yem-addon-price { font-size: 13px; color: ${isDark ? '#999' : '#6b7280'}; } .yem-addon-meta { font-size: 12px; color: ${isDark ? '#888' : '#9ca3af'}; } /* Plan info in charge config */ .yem-plan-info { font-size: 13px; color: ${isDark ? '#999' : '#6b7280'}; margin: 4px 0 0 0; } /* Charge configuration layout */ .yem-charge-config-list { border: 1px solid ${isDark ? '#333' : '#e5e7eb'}; border-radius: ${theme.borderRadius}; overflow: hidden; margin-top: 16px; } .yem-charge-config-header { display: grid; grid-template-columns: 1fr 100px 80px 100px; gap: 12px; padding: 10px 16px; background: ${isDark ? '#2a2a2a' : '#f9fafb'}; border-bottom: 1px solid ${isDark ? '#333' : '#e5e7eb'}; font-size: 12px; font-weight: 600; color: ${isDark ? '#999' : '#6b7280'}; text-transform: uppercase; } .yem-charge-config-row { display: grid; grid-template-columns: 1fr 100px 80px 100px; gap: 12px; padding: 12px 16px; align-items: center; border-bottom: 1px solid ${isDark ? '#333' : '#f3f4f6'}; } .yem-charge-config-row:last-child { border-bottom: none; } .yem-charge-config-info { display: flex; flex-direction: column; } .yem-charge-config-name { font-weight: 500; font-size: 14px; color: ${isDark ? '#fff' : '#111827'}; } .yem-charge-config-type { font-size: 12px; color: ${isDark ? '#888' : '#6b7280'}; } .yem-charge-config-price { font-size: 13px; color: ${isDark ? '#999' : '#6b7280'}; } .yem-charge-config-qty { display: flex; align-items: center; } .yem-qty-input { width: 70px !important; padding: 6px 8px !important; text-align: center; } .yem-charge-config-total { font-weight: 600; font-size: 14px; color: ${isDark ? '#fff' : '#111827'}; text-align: right; } .yem-charge-config-footer { display: flex; justify-content: space-between; padding: 12px 16px; background: ${isDark ? '#2a2a2a' : '#f9fafb'}; border-top: 1px solid ${isDark ? '#333' : '#e5e7eb'}; font-weight: 600; font-size: 15px; color: ${isDark ? '#fff' : '#111827'}; } .yem-empty-state { text-align: center; padding: 32px 16px; color: ${isDark ? '#888' : '#6b7280'}; } .yem-empty-state-hint { font-size: 0.75rem; color: ${isDark ? '#666' : '#9ca3af'}; margin-top: 8px; } `; } /** * Inject styles into the document head */ function injectStyles(theme) { let styleElement = document.getElementById(STYLE_ID); if (styleElement) { // Update existing styles styleElement.textContent = generateStyles(theme); } else { // Create new style element styleElement = document.createElement('style'); styleElement.id = STYLE_ID; styleElement.textContent = generateStyles(theme); document.head.appendChild(styleElement); } } /** * Remove injected styles */ function removeStyles() { const styleElement = document.getElementById(STYLE_ID); if (styleElement) { styleElement.remove(); } } /** * UI Utility Functions */ /** * Sanitize HTML to prevent XSS attacks * Escapes special HTML characters in user-controlled data */ function sanitizeHtml(str) { if (str === null || str === undefined) { return ''; } return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** * Format a date according to a format string * Supports: YYYY, MM, DD */ function formatDate(dateString, format = 'YYYY-MM-DD') { if (!dateString) return ''; const date = new Date(dateString); if (isNaN(date.getTime())) return ''; const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return format .replace('YYYY', String(year)) .replace('MM', month) .replace('DD', day); } /** * Format a number as currency */ function formatCurrency(amount, currency, locale = 'en-US') { return new Intl.NumberFormat(locale, { style: 'currency', currency: currency, }).format(amount); } /** * Generate a unique ID for components */ function generateId(prefix = 'yem') { return `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } /** * Common UI Renderers */ /** * Render loading state */ function renderLoading(container, message = 'Loading...') { container.innerHTML = `
${sanitizeHtml(message)}
`; } /** * Render error state */ function renderError(container, error) { const message = error instanceof Error ? error.message : error; container.innerHTML = `
Error
${sanitizeHtml(message)}
`; } /** * Render empty state */ function renderEmpty(container, message, icon = '📭') { container.innerHTML = `
${icon}
${sanitizeHtml(message)}
`; } /** * Render pagination controls */ function renderPagination(componentId, currentPage, totalPages) { if (totalPages <= 1) return ''; return `
Page ${currentPage} of ${totalPages}
`; } /** * Render a modal overlay */ function renderModal(id, title, content, footer) { return `

${sanitizeHtml(title)}

${content}
`; } /** * Show a message in a component */ function showMessage(componentId, message, type) { const messageEl = document.getElementById(`${componentId}-message`); if (messageEl) { messageEl.textContent = message; messageEl.className = `yem-message yem-message-${type}`; messageEl.style.display = 'block'; } } /** * Hide a message in a component */ function hideMessage(componentId) { const messageEl = document.getElementById(`${componentId}-message`); if (messageEl) { messageEl.style.display = 'none'; } } /** * Base Component Class * All components extend this base class */ class BaseComponent { constructor(config, apiClient, containerId, options = {}) { this.config = config; this.apiClient = apiClient; const container = document.getElementById(containerId); if (!container) { throw new Error(`Container element with id '${containerId}' not found`); } this.instance = { id: generateId(`yem-${this.getComponentType()}`), type: this.getComponentType(), container, options, data: null, currentPage: 1, }; } /** * Get the component instance */ getInstance() { return this.instance; } /** * Initialize and render the component */ async render() { this.renderLoading(); try { this.instance.data = await this.fetchData(); this.renderContent(); return this.instance.id; } catch (error) { this.renderError(error instanceof Error ? error : new Error(String(error))); throw error; } } /** * Re-render with current data */ refresh() { if (this.instance.data) { this.renderContent(); } } /** * Reload data and re-render */ async reload() { this.renderLoading(); try { this.instance.data = await this.fetchData(); this.renderContent(); } catch (error) { this.renderError(error instanceof Error ? error : new Error(String(error))); } } /** * Render loading state */ renderLoading(message = 'Loading...') { renderLoading(this.instance.container, message); } /** * Render error state */ renderError(error) { renderError(this.instance.container, error); } /** * Render empty state */ renderEmpty(message, icon) { renderEmpty(this.instance.container, message, icon); } /** * Clean up the component */ destroy() { this.instance.container.innerHTML = ''; } /** * Update options and re-render */ updateOptions(options) { this.instance.options = { ...this.instance.options, ...options }; } /** * Log debug message if debug mode is enabled */ log(...args) { if (this.config.debug) { console.log(`[YouniumSDK:${this.getComponentType()}]`, ...args); } } } /** * Invoice List Component */ class InvoiceListComponent extends BaseComponent { constructor(config, apiClient, containerId, options = {}) { super(config, apiClient, containerId, options); this.options = { pageSize: options.pageSize ?? 10, showSearch: options.showSearch ?? true, showPaymentStatus: options.showPaymentStatus ?? true, allowPdfDownload: options.allowPdfDownload ?? true, dateFormat: options.dateFormat ?? 'YYYY-MM-DD', sortBy: options.sortBy ?? 'InvoiceDate', sortDescending: options.sortDescending ?? true, }; } getComponentType() { return 'invoice-list'; } async fetchData() { const response = await this.apiClient.call({ method: 'POST', endpoint: '/invoices/list', body: { pageNumber: this.instance.currentPage, pageSize: this.options.pageSize, sortBy: this.options.sortBy, sortDescending: this.options.sortDescending, searchText: this.instance.searchText || '', }, }); return { invoices: response.invoices || [], totalCount: response.totalCount || 0, totalPages: Math.ceil((response.totalCount || 0) / (this.options.pageSize || 10)), currentPage: this.instance.currentPage, }; } renderContent() { const data = this.instance.data; if (!data?.invoices || data.invoices.length === 0) { this.renderEmpty('No invoices found', '📄'); return; } const componentId = this.instance.id; const safeSearchText = sanitizeHtml(this.instance.searchText || ''); const html = `

Invoices

${this.options.showSearch ? this.renderSearchBar(componentId, safeSearchText) : ''} ${this.options.showPaymentStatus ? '' : ''} ${this.options.allowPdfDownload ? '' : ''} ${data.invoices.map(invoice => this.renderInvoiceRow(invoice)).join('')}
Invoice # Date Due Date AmountStatusActions
${renderPagination(componentId, data.currentPage, data.totalPages)}
`; this.instance.container.innerHTML = html; } renderSearchBar(componentId, searchText) { return ` `; } renderInvoiceRow(invoice) { const safeInvoiceNumber = sanitizeHtml(invoice.invoiceNumber); const safeStatus = sanitizeHtml(invoice.status); const safeStatusLower = sanitizeHtml(invoice.status?.toLowerCase()); const safeId = sanitizeHtml(invoice.id); return ` ${safeInvoiceNumber} ${formatDate(invoice.invoiceDate, this.options.dateFormat)} ${formatDate(invoice.dueDate, this.options.dateFormat)} ${formatCurrency(invoice.totalAmount, invoice.currency, this.config.locale)} ${this.options.showPaymentStatus ? ` ${safeStatus} ` : ''} ${this.options.allowPdfDownload ? ` ` : ''} `; } /** * Search invoices */ async search(searchText) { this.instance.searchText = searchText; this.instance.currentPage = 1; await this.reload(); } /** * Clear search */ async clearSearch() { this.instance.searchText = ''; this.instance.currentPage = 1; await this.reload(); } /** * Change page */ async changePage(page) { this.instance.currentPage = page; await this.reload(); } /** * Download invoice PDF */ async downloadInvoice(invoiceId) { try { const response = await this.apiClient.call({ method: 'GET', endpoint: `/invoices/${invoiceId}/pdf-url`, }); if (response?.url) { window.open(response.url, '_blank'); } else { console.error('[YouniumSDK] No PDF URL provided in response'); alert('Failed to get PDF URL. Please try again.'); } } catch (error) { console.error('[YouniumSDK] Failed to download invoice:', error); alert('Failed to download invoice. Please try again.'); } } } /** * Account Information Component */ class AccountInfoComponent extends BaseComponent { constructor(config, apiClient, containerId, options = {}) { super(config, apiClient, containerId, options); this.options = { allowEdit: options.allowEdit ?? false, showDetails: options.showDetails ?? true, }; this.componentId = generateId('account'); } getComponentType() { return 'account-info'; } async fetchData() { const response = await this.apiClient.call({ method: 'GET', endpoint: '/account', }); return { account: response }; } renderContent() { const data = this.instance.data; if (!data?.account) { this.renderError(new Error('Account information not available')); return; } const account = data.account; const id = this.componentId; const allowEdit = this.options.allowEdit; const showDetails = this.options.showDetails; // Sanitize all user-controlled data const safeName = sanitizeHtml(account.name); const safeAccountNumber = sanitizeHtml(account.accountNumber); const safeEmail = sanitizeHtml(account.invoiceEmail); const safeStreet = sanitizeHtml(account.invoiceAddress?.street); const safeCity = sanitizeHtml(account.invoiceAddress?.city); const safePostalCode = sanitizeHtml(account.invoiceAddress?.postalCode); const safeState = sanitizeHtml(account.invoiceAddress?.state); const safeCountry = sanitizeHtml(account.invoiceAddress?.country); const html = `

Account Information

Account Details

Account Name
${safeName}
Account Number
${safeAccountNumber}
${showDetails ? `

Invoice Email

${safeEmail || 'Not set'} ${allowEdit ? ` ` : ''}

Invoice Address

${safeStreet ? `
${safeStreet}
${safePostalCode} ${safeCity}${safeState ? ', ' + safeState : ''}
${safeCountry}
` : 'No address set'}
${allowEdit ? ` ` : ''}
` : ''}
`; this.instance.container.innerHTML = html; } /** * Start editing email */ startEditEmail() { const displayEl = document.getElementById(`${this.componentId}-email-display`); const editEl = document.getElementById(`${this.componentId}-email-edit`); if (displayEl) displayEl.style.display = 'none'; if (editEl) editEl.style.display = 'block'; hideMessage(this.componentId); } /** * Cancel editing email */ cancelEditEmail() { const displayEl = document.getElementById(`${this.componentId}-email-display`); const editEl = document.getElementById(`${this.componentId}-email-edit`); if (displayEl) displayEl.style.display = 'block'; if (editEl) editEl.style.display = 'none'; hideMessage(this.componentId); } /** * Save email */ async saveEmail() { const emailInput = document.getElementById(`${this.componentId}-email-input`); const newEmail = emailInput?.value.trim(); if (!newEmail) { showMessage(this.componentId, 'Email address is required', 'error'); return; } const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(newEmail)) { showMessage(this.componentId, 'Please enter a valid email address', 'error'); return; } try { showMessage(this.componentId, 'Saving...', 'info'); await this.apiClient.call({ method: 'PUT', endpoint: '/account', body: { invoiceEmail: newEmail }, }); // Update local data const data = this.instance.data; if (data?.account) { data.account.invoiceEmail = newEmail; } // Update display const displayValue = document.querySelector(`#${this.componentId}-email-display .yem-info-value`); if (displayValue) { displayValue.textContent = newEmail || 'Not set'; } this.cancelEditEmail(); showMessage(this.componentId, 'Email updated successfully!', 'success'); setTimeout(() => hideMessage(this.componentId), 3000); } catch (error) { console.error('[YouniumSDK] Error updating email:', error); showMessage(this.componentId, 'Failed to update email. Please try again.', 'error'); } } /** * Start editing address */ startEditAddress() { const displayEl = document.getElementById(`${this.componentId}-address-display`); const editEl = document.getElementById(`${this.componentId}-address-edit`); if (displayEl) displayEl.style.display = 'none'; if (editEl) editEl.style.display = 'block'; hideMessage(this.componentId); } /** * Cancel editing address */ cancelEditAddress() { const displayEl = document.getElementById(`${this.componentId}-address-display`); const editEl = document.getElementById(`${this.componentId}-address-edit`); if (displayEl) displayEl.style.display = 'block'; if (editEl) editEl.style.display = 'none'; hideMessage(this.componentId); } /** * Save address */ async saveAddress() { const getInputValue = (id) => { const input = document.getElementById(`${this.componentId}-${id}`); return input?.value.trim() || ''; }; const addressLine1 = getInputValue('address-line1'); const city = getInputValue('address-city'); const zipCode = getInputValue('address-zip'); const state = getInputValue('address-state'); const country = getInputValue('address-country'); if (!addressLine1 || !city || !country) { showMessage(this.componentId, 'Street address, city, and country are required', 'error'); return; } try { showMessage(this.componentId, 'Saving...', 'info'); const addressData = { street: addressLine1, city: city, postalCode: zipCode, state: state || undefined, country: country, }; await this.apiClient.call({ method: 'PUT', endpoint: '/account', body: { invoiceAddress: addressData }, }); // Update local data const data = this.instance.data; if (data?.account) { data.account.invoiceAddress = addressData; } // Update display const displayValue = document.querySelector(`#${this.componentId}-address-display .yem-info-value`); if (displayValue) { const safeStreet = sanitizeHtml(addressData.street); const safePostalCode = sanitizeHtml(addressData.postalCode); const safeCity = sanitizeHtml(addressData.city); const safeState = sanitizeHtml(addressData.state); const safeCountry = sanitizeHtml(addressData.country); displayValue.innerHTML = addressData.street ? `
${safeStreet}
${safePostalCode} ${safeCity}${safeState ? ', ' + safeState : ''}
${safeCountry}
` : 'No address set'; } this.cancelEditAddress(); showMessage(this.componentId, 'Address updated successfully!', 'success'); setTimeout(() => hideMessage(this.componentId), 3000); } catch (error) { console.error('[YouniumSDK] Error updating address:', error); showMessage(this.componentId, 'Failed to update address. Please try again.', 'error'); } } /** * Get the internal component ID (for static method binding) */ getComponentId() { return this.componentId; } } /** * Subscription List Component * Mirrors the Portal's subscription management functionality */ class SubscriptionListComponent extends BaseComponent { constructor(config, apiClient, containerId, options = {}) { super(config, apiClient, containerId, options); this.options = { allowQuantityEdit: options.allowQuantityEdit ?? true, allowAddons: options.allowAddons ?? true, showPriceDetails: options.showPriceDetails ?? true, expandByDefault: options.expandByDefault ?? false, }; this.componentId = generateId('subscription'); this.expandedSubscriptions = new Set(); this.expandedLines = new Set(); this.editState = null; this.addonState = null; } /** * Calculate effective date for subscription changes. * Mirrors Portal logic: if subscription hasn't started yet, use start date; otherwise use today. * * IMPORTANT: We extract just the date portion (YYYY-MM-DD) to avoid timezone issues. * The API expects dates without time components. */ calculateEffectiveDate(subscriptionStartDate) { // Extract just the date portion from the subscription start date (YYYY-MM-DD) // This avoids timezone conversion issues const startDateStr = subscriptionStartDate.split('T')[0]; // e.g., "2026-01-01" const startDate = new Date(startDateStr + 'T00:00:00Z'); // Parse as UTC const now = new Date(); const todayStr = now.toISOString().split('T')[0]; // e.g., "2025-12-11" const todayDate = new Date(todayStr + 'T00:00:00Z'); // Today at midnight UTC console.log('[YouniumSDK] calculateEffectiveDate:', { subscriptionStartDate, startDateStr, todayStr, startDateIsInFuture: startDate > todayDate, }); if (startDate > todayDate) { // Subscription hasn't started yet - use its start date console.log('[YouniumSDK] Using subscription start date as effective date:', startDateStr); return startDateStr + 'T00:00:00Z'; } else { // Subscription has already started - use current date console.log('[YouniumSDK] Using today as effective date:', todayStr); return todayStr + 'T00:00:00Z'; } } getComponentType() { return 'subscription-list'; } async fetchData() { // Fetch product configurations first (to know what to filter by) let enabledProductIds = new Set(); try { const productConfigs = await this.apiClient.call({ method: 'GET', endpoint: '/configuration/products/configurations', }); enabledProductIds = new Set(Object.keys(productConfigs)); this.log('Loaded product configurations:', enabledProductIds.size, 'enabled products'); } catch (e) { this.log('Failed to fetch product configurations, showing all subscriptions:', e); // If we can't get configs, we'll show all subscriptions (fallback behavior) } // Fetch subscriptions const response = await this.apiClient.call({ method: 'POST', endpoint: '/subscriptions/list', body: { pageNumber: 1, pageSize: 50, status: 'Active', }, }); // Fetch available addons let availableAddons = []; if (this.options.allowAddons) { try { availableAddons = await this.apiClient.call({ method: 'GET', endpoint: '/subscriptions/available-addons', }); } catch (e) { this.log('Failed to fetch available addons:', e); } } let subscriptions = response.subscriptions || []; // Filter subscriptions by configured products (like the Portal does) if (enabledProductIds.size > 0) { subscriptions = this.filterSubscriptionsByConfiguration(subscriptions, enabledProductIds); } else if (enabledProductIds.size === 0) { // No products configured - show nothing (matches Portal behavior) this.log('No products configured - hiding all subscriptions'); subscriptions = []; } // Sort subscriptions by subscription number (highest first for consistency) subscriptions.sort((a, b) => { // Extract numeric part from subscription number (e.g., "O-000058" -> 58) const numA = parseInt(a.subscriptionNumber?.replace(/\D/g, '') || '0', 10); const numB = parseInt(b.subscriptionNumber?.replace(/\D/g, '') || '0', 10); return numB - numA; // Descending order (highest first) }); // Auto-expand all subscriptions if configured if (this.options.expandByDefault && subscriptions.length > 0) { for (const sub of subscriptions) { this.expandedSubscriptions.add(sub.id); } } return { subscriptions, totalCount: subscriptions.length, availableAddons, }; } /** * Filter subscriptions to only show those with configured products * Mirrors the Portal's FilterSubscriptionsByConfiguration logic */ filterSubscriptionsByConfiguration(subscriptions, enabledProductIds) { const originalCount = subscriptions.length; // Filter lines within each subscription to only show enabled products for (const subscription of subscriptions) { const originalLineCount = subscription.lines?.length || 0; if (subscription.lines) { subscription.lines = subscription.lines.filter(line => { // Check if the line's catalog product ID is in the enabled list const catalogProductId = line.catalogProductId; if (!catalogProductId) { this.log(`Line ${line.id} has no catalogProductId - excluding`); return false; } return enabledProductIds.has(catalogProductId); }); if (originalLineCount !== subscription.lines.length) { this.log(`Subscription ${subscription.subscriptionNumber}: Filtered lines from ${originalLineCount} to ${subscription.lines.length}`); } } } // Filter out subscriptions with no remaining lines const filteredSubscriptions = subscriptions.filter(s => s.lines && s.lines.length > 0); if (originalCount !== filteredSubscriptions.length) { this.log(`Filtered subscriptions: ${originalCount} → ${filteredSubscriptions.length}`); } return filteredSubscriptions; } renderContent() { const data = this.instance.data; if (!data?.subscriptions || data.subscriptions.length === 0) { this.renderEmpty('No subscriptions found', '📋'); return; } const id = this.componentId; const html = `

Subscriptions

${data.totalCount} subscription${data.totalCount !== 1 ? 's' : ''}

${data.subscriptions.map(sub => this.renderSubscription(sub, data.availableAddons)).join('')}
`; this.instance.container.innerHTML = html; } renderSubscription(subscription, availableAddons) { const safeName = sanitizeHtml(subscription.subscriptionNumber || 'Subscription'); const safeStatus = sanitizeHtml(subscription.status); const safeStatusLower = subscription.status?.toLowerCase() || ''; const id = this.componentId; const isExpanded = this.expandedSubscriptions.has(subscription.id); const startDate = formatDate(subscription.startDate, 'DD/MM/YYYY'); const endDate = subscription.endDate ? formatDate(subscription.endDate, 'DD/MM/YYYY') : 'ongoing'; return `

Subscription ${safeName}

${safeStatus}

${startDate} to ${endDate}

${this.options.allowAddons && availableAddons.length > 0 ? ` ` : ''}
${isExpanded ? this.renderSubscriptionProducts(subscription) : ''}
`; } renderSubscriptionProducts(subscription) { if (!subscription.lines || subscription.lines.length === 0) { return ''; } return `
Products
${subscription.lines.map(line => this.renderProductLine(subscription, line)).join('')}
`; } renderProductLine(subscription, line) { const isExpanded = this.expandedLines.has(line.id); const id = this.componentId; const safeName = sanitizeHtml(line.productName); const chargeTypeLabel = this.getChargeTypeLabel(line.chargeType); const showPriceDetails = this.options.showPriceDetails; // Format price display - only show if showPriceDetails is true let priceDisplay = ''; if (showPriceDetails) { if (line.chargeType === 'Usage' || line.chargeType === 'Measured') { priceDisplay = 'Based on usage'; } else { priceDisplay = `${formatCurrency(line.totalAmount, subscription.currency, this.config.locale)}`; } } return `
${safeName} ${chargeTypeLabel}
${showPriceDetails ? `
${priceDisplay}
` : ''}
${isExpanded ? this.renderLineDetails(subscription, line, this.isEditableCharge(line)) : ''}
`; } getChargeTypeLabel(chargeType) { switch (chargeType) { case 'Recurring': return 'Recurring charge'; case 'OneOff': case 'OneTime': return 'One-time charge'; case 'Usage': case 'Measured': return 'Usage charge'; default: return chargeType || ''; } } /** * Determines if a charge line should show the Edit button * Only Recurring charges with Quantity price model are editable * Excludes: OneTime, Usage, Measured charges and Tiered/Volume/FlatFee pricing models */ isEditableCharge(line) { if (!this.options.allowQuantityEdit) return false; if (!line.isQuantityBased) return false; // Must be Recurring charge type if (line.chargeType !== 'Recurring') return false; // Must be Quantity price model (not Tiered, Volume, FlatFee, etc.) const priceModel = line.priceModel?.toLowerCase(); if (priceModel !== 'quantity') return false; return true; } renderLineDetails(subscription, line, isEditable) { const id = this.componentId; const details = []; const showPriceDetails = this.options.showPriceDetails; // Price period (only show if showPriceDetails is true) if (showPriceDetails && line.pricePeriod) { details.push(`
Price Period
${sanitizeHtml(line.pricePeriod)}
`); } // Billing period (only show if showPriceDetails is true) if (showPriceDetails && line.billingPeriod) { details.push(`
Billing
${sanitizeHtml(line.billingPeriod)}
`); } // Quantity (for quantity-based charges) - always show if (line.isQuantityBased && line.chargeType !== 'Usage' && line.chargeType !== 'Measured') { details.push(`
Quantity
${line.quantity}${line.unitOfMeasure ? ` ${sanitizeHtml(line.unitOfMeasure)}` : ''}
`); } // Unit price (only show if showPriceDetails is true, for non-tiered, non-usage charges) if (showPriceDetails && !line.isTiered && line.chargeType !== 'Usage' && line.chargeType !== 'Measured' && line.unitPrice > 0) { details.push(`
Unit Price
${formatCurrency(line.unitPrice, subscription.currency, this.config.locale)}${line.unitOfMeasure ? ` / ${sanitizeHtml(line.unitOfMeasure)}` : ''}
`); } // Actions const actions = []; // View Tiers button (only show if showPriceDetails is true) if (showPriceDetails && line.isTiered && line.priceTiers && line.priceTiers.length > 0) { actions.push(` `); } if (isEditable) { actions.push(` `); } return `
${details.join('')}
${actions.length > 0 ? `
${actions.join('')}
` : ''} `; } // ========================================================================= // Public Methods // ========================================================================= getComponentId() { return this.componentId; } toggleSubscription(subscriptionId) { if (this.expandedSubscriptions.has(subscriptionId)) { this.expandedSubscriptions.delete(subscriptionId); } else { this.expandedSubscriptions.add(subscriptionId); } this.renderContent(); } toggleLine(lineId) { if (this.expandedLines.has(lineId)) { this.expandedLines.delete(lineId); } else { this.expandedLines.add(lineId); } this.renderContent(); } showTieredPricing(lineId) { const data = this.instance.data; let targetLine = null; let targetSub = null; for (const sub of data.subscriptions) { for (const line of sub.lines) { if (line.id === lineId) { targetLine = line; targetSub = sub; break; } } if (targetLine) break; } if (!targetLine || !targetLine.priceTiers || !targetSub) return; const content = ` ${targetLine.priceTiers.some(t => t.flatPrice) ? '' : ''} ${targetLine.priceTiers.map((tier) => ` ${tier.flatPrice !== undefined ? `` : ''} `).join('')}
From To Unit PriceFlat Price
${tier.fromQuantity} ${tier.toQuantity ?? '∞'} ${formatCurrency(tier.unitPrice, targetSub.currency, this.config.locale)}${formatCurrency(tier.flatPrice, targetSub.currency, this.config.locale)}
`; const modal = renderModal('tiered-pricing', `Price Tiers - ${sanitizeHtml(targetLine.productName)}`, content, ``); document.body.insertAdjacentHTML('beforeend', modal); } showEditQuantityModal(subscriptionId, lineId) { const data = this.instance.data; let targetLine = null; let targetSub = null; for (const sub of data.subscriptions) { if (sub.id === subscriptionId) { targetSub = sub; for (const line of sub.lines) { if (line.id === lineId) { targetLine = line; break; } } break; } } if (!targetLine || !targetSub) return; this.editState = { subscriptionId, lineId, orderChargeId: targetLine.youniumChargeId, // Order charge ID for Younium API calls catalogChargeId: targetLine.catalogChargeId || targetLine.youniumChargeId, // Catalog charge ID for config lookup currentQuantity: targetLine.quantity, newQuantity: targetLine.quantity, priceCalculation: null, subscriptionStartDate: targetSub.startDate, // Store for effective date calculation }; const id = this.componentId; const content = `
${sanitizeHtml(targetLine.productName)}
${targetLine.quantity}${targetLine.unitOfMeasure ? ` ${sanitizeHtml(targetLine.unitOfMeasure)}` : ''}
`; const modal = renderModal('edit-quantity', 'Edit Quantity', content, ` `); document.body.insertAdjacentHTML('beforeend', modal); } async calculatePriceChange() { if (!this.editState) return; const id = this.componentId; const input = document.getElementById(`${id}-edit-quantity`); const newQuantity = parseFloat(input?.value || '0'); if (newQuantity === this.editState.currentQuantity) { const preview = document.getElementById(`${id}-price-preview`); if (preview) preview.innerHTML = ''; // Hide the Review Change button when quantity is unchanged const reviewBtn = document.getElementById(`${id}-review-btn`); if (reviewBtn) { reviewBtn.style.display = 'none'; } return; } this.editState.newQuantity = newQuantity; try { const preview = document.getElementById(`${id}-price-preview`); // Find the subscription and line from our cached data const data = this.instance.data; const subscription = data?.subscriptions.find(s => s.id === this.editState?.subscriptionId); const line = subscription?.lines.find(l => l.id === this.editState?.lineId); if (!subscription || !line) { if (preview) { preview.innerHTML = `
Could not find subscription data
`; } return; } // Calculate price locally (like the Portal does) const currentPrice = this.calculatePriceForQuantity(line, this.editState.currentQuantity); const newPrice = this.calculatePriceForQuantity(line, newQuantity); const priceDifference = newPrice - currentPrice; const percentageChange = currentPrice > 0 ? (priceDifference / currentPrice) * 100 : 0; const result = { success: true, currentMonthlyPrice: currentPrice, newMonthlyPrice: newPrice, priceDifference: priceDifference, priceDifferencePercentage: percentageChange, effectiveDate: new Date().toISOString(), currency: subscription.currency, }; this.editState.priceCalculation = result; if (preview) { const diffClass = result.priceDifference >= 0 ? 'yem-price-positive' : 'yem-price-negative'; const currency = result.currency || 'USD'; preview.innerHTML = `
Current Monthly ${formatCurrency(result.currentMonthlyPrice, currency, this.config.locale)}
New Monthly ${formatCurrency(result.newMonthlyPrice, currency, this.config.locale)}
Difference ${result.priceDifference >= 0 ? '+' : ''}${formatCurrency(result.priceDifference, currency, this.config.locale)}
`; } // Show the Review Change button when quantity has changed const reviewBtn = document.getElementById(`${id}-review-btn`); if (reviewBtn) { reviewBtn.style.display = ''; } } catch (error) { const preview = document.getElementById(`${id}-price-preview`); if (preview) { preview.innerHTML = `
Failed to calculate price change
`; } } } /** * Calculate price for a given quantity, handling both simple and tiered pricing * Mirrors Portal's CalculatePriceForQuantity method */ calculatePriceForQuantity(line, quantity) { if (!line.isTiered || !line.priceTiers || line.priceTiers.length === 0) { // Simple per-unit pricing return line.unitPrice * quantity; } // Volume pricing (all quantity at one tier price) const orderedTiers = [...line.priceTiers].sort((a, b) => a.fromQuantity - b.fromQuantity); for (const tier of orderedTiers) { if (quantity >= tier.fromQuantity) { if (tier.toQuantity === undefined || tier.toQuantity === null || quantity <= tier.toQuantity) { // Quantity falls in this tier - use this tier's price return quantity * tier.unitPrice; } } } // Fallback to unit price if no tier matches return line.unitPrice * quantity; } /** * Show confirmation modal before submitting quantity change */ showConfirmQuantityChange() { if (!this.editState || !this.editState.priceCalculation) return; const id = this.componentId; const data = this.instance.data; const subscription = data?.subscriptions.find(s => s.id === this.editState?.subscriptionId); const line = subscription?.lines.find(l => l.id === this.editState?.lineId); if (!subscription || !line) return; const result = this.editState.priceCalculation; const diffClass = result.priceDifference >= 0 ? 'yem-price-positive' : 'yem-price-negative'; const currency = result.currency || 'USD'; // Close the edit modal const editOverlay = document.getElementById('edit-quantity-overlay'); editOverlay?.remove(); const content = `

You are about to change the quantity for:

${sanitizeHtml(line.chargeName || line.productName)}

Subscription: ${sanitizeHtml(subscription.subscriptionNumber)}

Current Quantity: ${this.editState.currentQuantity}${line.unitOfMeasure ? ` ${sanitizeHtml(line.unitOfMeasure)}` : ''}
New Quantity: ${this.editState.newQuantity}${line.unitOfMeasure ? ` ${sanitizeHtml(line.unitOfMeasure)}` : ''}
Current Monthly: ${formatCurrency(result.currentMonthlyPrice, currency, this.config.locale)}
New Monthly: ${formatCurrency(result.newMonthlyPrice, currency, this.config.locale)}
Price Difference: ${result.priceDifference >= 0 ? '+' : ''}${formatCurrency(result.priceDifference, currency, this.config.locale)}

This change will take effect on your next billing cycle.

`; const modal = renderModal('confirm-quantity', 'Confirm Quantity Change', content, ` `); document.body.insertAdjacentHTML('beforeend', modal); } /** * Cancel confirmation and go back to edit modal */ cancelConfirmQuantityChange() { const confirmOverlay = document.getElementById('confirm-quantity-overlay'); confirmOverlay?.remove(); // Re-open the edit modal with current state if (this.editState) { this.showEditQuantityModal(this.editState.subscriptionId, this.editState.lineId); } } async submitQuantityChange() { if (!this.editState) return; const id = this.componentId; const submitBtn = document.getElementById(`${id}-confirm-btn`); if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'Submitting...'; } try { // Calculate effective date: use subscription start date if it hasn't started yet, otherwise use today const effectiveDate = this.calculateEffectiveDate(this.editState.subscriptionStartDate); const response = await this.apiClient.call({ method: 'POST', endpoint: '/subscriptions/request-change', body: { subscriptionId: this.editState.subscriptionId, changeType: 'QuantityChange', effectiveDate, quantityChanges: [{ catalogChargeId: this.editState.catalogChargeId, orderChargeId: this.editState.orderChargeId, newQuantity: this.editState.newQuantity, }], }, }); // Check both success flag and status (API may return success:true but status:'Failed') if (response.success && response.status !== 'Failed') { // Close modal and reload data silently (no loading spinner) const confirmOverlay = document.getElementById('confirm-quantity-overlay'); confirmOverlay?.remove(); this.editState = null; await this.silentReload(); } else { const errorEl = document.getElementById(`${id}-confirm-error`); if (errorEl) { errorEl.innerHTML = `
${sanitizeHtml(response.errorMessage || 'Failed to submit change')}
`; } if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Confirm Change'; } } } catch (error) { const errorEl = document.getElementById(`${id}-confirm-error`); if (errorEl) { const errorMessage = this.getErrorMessage(error, 'Failed to submit change request'); errorEl.innerHTML = `
${sanitizeHtml(errorMessage)}
`; } if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Confirm Change'; } } } /** * Extract user-friendly error message from an error */ getErrorMessage(error, defaultMessage) { if (error instanceof Error) { // Check for permission-related errors if (error.message.includes('Access denied') || error.message.includes('403')) { return 'Insufficient permissions. The API credentials do not have write access to subscriptions.'; } if (error.message.includes('Token expired') || error.message.includes('401')) { return 'Your session has expired. Please refresh the page and try again.'; } // Return the error message if it's meaningful if (error.message && !error.message.startsWith('API error:')) { return error.message; } } return defaultMessage; } /** * Reload data without showing loading spinner */ async silentReload() { try { this.instance.data = await this.fetchData(); this.renderContent(); } catch (error) { // On error, fall back to normal reload with loading state await this.reload(); } } /** * Get distinct charge plans from an addon product */ getDistinctChargePlans(addon) { const plansMap = new Map(); for (const charge of addon.charges) { if (charge.chargePlanId && !plansMap.has(charge.chargePlanId)) { plansMap.set(charge.chargePlanId, charge.chargePlanName || charge.chargePlanId); } } return Array.from(plansMap.entries()).map(([id, name]) => ({ chargePlanId: id, chargePlanName: name, })); } /** * Check if addon has multiple charge plans */ hasMultipleChargePlans(addon) { return this.getDistinctChargePlans(addon).length > 1; } /** * Get visible charges for current addon state (filtered by selected plan and currency) */ getVisibleCharges(addon, chargePlanId, currency) { let charges = addon.charges; // If product has charge plan info if (addon.charges.some(c => c.chargePlanId)) { if (!chargePlanId) { // Has charge plans but none selected - show nothing return []; } // Filter to selected charge plan charges = charges.filter(c => c.chargePlanId === chargePlanId); } // Filter to charges with pricing in subscription currency return charges.filter(c => { const price = c.pricesByCurrency?.[currency]; return price !== undefined; }); } /** * Get price for a charge in a specific currency */ getChargePrice(charge, currency) { return charge.pricesByCurrency?.[currency] ?? charge.price ?? 0; } /** * Check if an addon product has valid pricing for a given currency * - Multi-plan products: Show if ANY plan has all charges with pricing * - Single-plan products (including multi-charge): ALL charges must have pricing */ hasValidPricingForCurrency(addon, currency) { const hasMultiplePlans = this.hasMultipleChargePlans(addon); if (hasMultiplePlans) { // For multi-plan products: Show if ANY plan has all charges with pricing const chargePlans = this.getDistinctChargePlans(addon); return chargePlans.some(cp => { const planCharges = addon.charges.filter(c => c.chargePlanId === cp.chargePlanId); return planCharges.length > 0 && planCharges.every(c => c.pricesByCurrency?.[currency] !== undefined); }); } else { // For single-plan products (including multi-charge): ALL charges must have pricing // This prevents showing products where only some charges have the currency return addon.charges.length > 0 && addon.charges.every(c => c.pricesByCurrency?.[currency] !== undefined); } } showAddAddonModal(subscriptionId) { const data = this.instance.data; const subscription = data.subscriptions.find(s => s.id === subscriptionId); if (!data.availableAddons || data.availableAddons.length === 0 || !subscription) { return; } this.addonState = { subscriptionId, selectedProduct: null, selectedChargePlanId: null, quantities: {}, subscriptionStartDate: subscription.startDate, // Store for effective date calculation }; const id = this.componentId; const currency = subscription.currency; // CRITICAL: Filter addons based on currency availability // - Multi-plan products: Show if ANY plan has all charges with pricing // - Single-plan products (including multi-charge): ALL charges must have pricing const currencyFilteredAddons = data.availableAddons.filter(addon => this.hasValidPricingForCurrency(addon, currency)); if (currencyFilteredAddons.length === 0) { // No products available for this currency - show empty state const content = `

No products available for ${sanitizeHtml(currency)} subscriptions

Products must have pricing configured in ${sanitizeHtml(currency)}

`; const modal = renderModal('add-addon', 'Add Product', content, ``); document.body.insertAdjacentHTML('beforeend', modal); return; } // Build product list with price info const productCards = currencyFilteredAddons.map(addon => { const hasMultiplePlans = this.hasMultipleChargePlans(addon); let priceInfo = ''; if (hasMultiplePlans) { priceInfo = `Multiple plans available`; } else { // Calculate total price for single-plan product const totalPrice = addon.charges .filter(c => c.pricesByCurrency?.[currency] !== undefined) .reduce((sum, c) => sum + this.getChargePrice(c, currency), 0); if (totalPrice > 0) { priceInfo = `${formatCurrency(totalPrice, currency, this.config.locale)}`; } } return `
${sanitizeHtml(addon.productName)} ${priceInfo}
`; }).join(''); const content = `

Select a product to add:

${productCards}
`; const modal = renderModal('add-addon', 'Add Product', content, ` `); document.body.insertAdjacentHTML('beforeend', modal); } selectAddon(productId) { const data = this.instance.data; const addon = data.availableAddons.find(a => a.productId === productId); const subscription = data.subscriptions.find(s => s.id === this.addonState?.subscriptionId); if (!addon || !this.addonState || !subscription) { return; } this.addonState.selectedProduct = addon; this.addonState.selectedChargePlanId = null; this.addonState.quantities = {}; const id = this.componentId; // Hide the product list const listEl = document.getElementById(`${id}-addon-list`); const introEl = document.querySelector('.yem-addon-intro'); if (listEl) listEl.style.display = 'none'; if (introEl) introEl.style.display = 'none'; // Show back button const backBtn = document.getElementById(`${id}-addon-back`); if (backBtn) backBtn.style.display = 'inline-block'; // Check if product has multiple charge plans const chargePlans = this.getDistinctChargePlans(addon); if (chargePlans.length > 1) { // Show charge plan selection this.showChargePlanSelection(addon, subscription.currency, chargePlans); } else if (chargePlans.length === 1) { // Auto-select single charge plan this.addonState.selectedChargePlanId = chargePlans[0].chargePlanId; this.showChargeConfiguration(addon, subscription.currency); } else { // No charge plan info - show all charges this.showChargeConfiguration(addon, subscription.currency); } } /** * Show charge plan selection for multi-plan products */ showChargePlanSelection(addon, currency, chargePlans) { const id = this.componentId; const plansEl = document.getElementById(`${id}-addon-plans`); const configEl = document.getElementById(`${id}-addon-config`); if (configEl) configEl.style.display = 'none'; if (plansEl) { // Filter plans to only those with charges that have pricing in subscription currency const availablePlans = chargePlans.filter(cp => { const planCharges = addon.charges.filter(c => c.chargePlanId === cp.chargePlanId); return planCharges.length > 0 && planCharges.every(c => c.pricesByCurrency?.[currency] !== undefined); }); if (availablePlans.length === 0) { plansEl.innerHTML = `

${sanitizeHtml(addon.productName)}

No plans available for ${sanitizeHtml(currency)} subscriptions

`; } else { const planCards = availablePlans.map(cp => { const planCharges = addon.charges.filter(c => c.chargePlanId === cp.chargePlanId); const totalPrice = planCharges.reduce((sum, c) => sum + this.getChargePrice(c, currency), 0); return `
${sanitizeHtml(cp.chargePlanName)} ${planCharges.length} charge${planCharges.length !== 1 ? 's' : ''} • ${formatCurrency(totalPrice, currency, this.config.locale)}
`; }).join(''); plansEl.innerHTML = `

${sanitizeHtml(addon.productName)}

${addon.description ? `

${sanitizeHtml(addon.description)}

` : ''}

Select a plan:

${planCards}
`; } plansEl.style.display = 'block'; } // Disable submit button until charge plan is selected const submitBtn = document.getElementById(`${id}-addon-submit`); if (submitBtn) submitBtn.disabled = true; } /** * Select a charge plan for multi-plan product */ selectChargePlan(chargePlanId) { if (!this.addonState?.selectedProduct) return; const data = this.instance.data; const subscription = data.subscriptions.find(s => s.id === this.addonState?.subscriptionId); if (!subscription) return; this.addonState.selectedChargePlanId = chargePlanId; this.addonState.quantities = {}; const id = this.componentId; // Hide plans section const plansEl = document.getElementById(`${id}-addon-plans`); if (plansEl) plansEl.style.display = 'none'; // Show charge configuration this.showChargeConfiguration(this.addonState.selectedProduct, subscription.currency); } /** * Show charge configuration (quantities and prices) */ showChargeConfiguration(addon, currency) { const id = this.componentId; const configEl = document.getElementById(`${id}-addon-config`); if (!configEl || !this.addonState) return; const visibleCharges = this.getVisibleCharges(addon, this.addonState.selectedChargePlanId, currency); // Initialize quantities for (const charge of visibleCharges) { if (charge.isQuantityBased) { this.addonState.quantities[charge.chargeId] = charge.minQuantity || 1; } else { this.addonState.quantities[charge.chargeId] = 1; } } // Get selected plan name if applicable let planInfo = ''; if (this.addonState.selectedChargePlanId) { const plans = this.getDistinctChargePlans(addon); const selectedPlan = plans.find(p => p.chargePlanId === this.addonState?.selectedChargePlanId); if (selectedPlan) { planInfo = `

Plan: ${sanitizeHtml(selectedPlan.chargePlanName)}

`; } } // Build charge rows with prices let totalPrice = 0; const chargeRows = visibleCharges.map(charge => { const unitPrice = this.getChargePrice(charge, currency); const qty = this.addonState?.quantities[charge.chargeId] || 1; const lineTotal = unitPrice * qty; totalPrice += lineTotal; if (charge.isQuantityBased) { return `
${sanitizeHtml(charge.chargeName)} ${sanitizeHtml(charge.chargeType)}
${formatCurrency(unitPrice, currency, this.config.locale)}/unit
${formatCurrency(lineTotal, currency, this.config.locale)}
`; } else { return `
${sanitizeHtml(charge.chargeName)} ${sanitizeHtml(charge.chargeType)}
${formatCurrency(unitPrice, currency, this.config.locale)}
`; } }).join(''); configEl.innerHTML = `

${sanitizeHtml(addon.productName)}

${planInfo}
Charge Price Qty Total
${chargeRows}
`; configEl.style.display = 'block'; // Enable submit button const submitBtn = document.getElementById(`${id}-addon-submit`); if (submitBtn) submitBtn.disabled = false; } /** * Update addon quantity and recalculate totals */ updateAddonQuantity(chargeId) { if (!this.addonState?.selectedProduct) return; const data = this.instance.data; const subscription = data.subscriptions.find(s => s.id === this.addonState?.subscriptionId); if (!subscription) return; const id = this.componentId; const currency = subscription.currency; const addon = this.addonState.selectedProduct; // Get new quantity from input const input = document.getElementById(`${id}-qty-${chargeId}`); const newQty = parseFloat(input?.value || '1'); this.addonState.quantities[chargeId] = newQty; // Update line total const charge = addon.charges.find(c => c.chargeId === chargeId); if (charge) { const unitPrice = this.getChargePrice(charge, currency); const lineTotal = unitPrice * newQty; const lineTotalEl = document.getElementById(`${id}-total-${chargeId}`); if (lineTotalEl) { lineTotalEl.textContent = formatCurrency(lineTotal, currency, this.config.locale); } } // Recalculate grand total const visibleCharges = this.getVisibleCharges(addon, this.addonState.selectedChargePlanId, currency); let grandTotal = 0; for (const c of visibleCharges) { const qty = this.addonState.quantities[c.chargeId] || 1; grandTotal += this.getChargePrice(c, currency) * qty; } const totalEl = document.getElementById(`${id}-addon-total`); if (totalEl) { totalEl.textContent = formatCurrency(grandTotal, currency, this.config.locale); } } /** * Handle back button in addon modal */ addonBack() { if (!this.addonState) return; const id = this.componentId; if (this.addonState.selectedChargePlanId && this.addonState.selectedProduct) { // Back from charge config to charge plan selection const hasMultiplePlans = this.hasMultipleChargePlans(this.addonState.selectedProduct); if (hasMultiplePlans) { this.addonState.selectedChargePlanId = null; this.addonState.quantities = {}; const configEl = document.getElementById(`${id}-addon-config`); const plansEl = document.getElementById(`${id}-addon-plans`); if (configEl) configEl.style.display = 'none'; if (plansEl) plansEl.style.display = 'block'; const submitBtn = document.getElementById(`${id}-addon-submit`); if (submitBtn) submitBtn.disabled = true; return; } } // Back to product list this.backToAddonList(); } /** * Show confirmation modal for addon */ showConfirmAddonModal() { if (!this.addonState?.selectedProduct) return; const id = this.componentId; const data = this.instance.data; const subscription = data.subscriptions.find(s => s.id === this.addonState?.subscriptionId); const addon = this.addonState.selectedProduct; if (!subscription) return; const currency = subscription.currency; // Collect quantities from inputs (update state) const visibleCharges = this.getVisibleCharges(addon, this.addonState.selectedChargePlanId, currency); for (const charge of visibleCharges) { if (charge.isQuantityBased) { const input = document.getElementById(`${id}-qty-${charge.chargeId}`); this.addonState.quantities[charge.chargeId] = parseFloat(input?.value || '1'); } else { this.addonState.quantities[charge.chargeId] = 1; } } // Get plan name if multi-plan product let planInfo = ''; if (this.addonState.selectedChargePlanId) { const plans = this.getDistinctChargePlans(addon); const selectedPlan = plans.find(p => p.chargePlanId === this.addonState?.selectedChargePlanId); if (selectedPlan) { planInfo = `

Plan: ${sanitizeHtml(selectedPlan.chargePlanName)}

`; } } // Calculate total price using visible charges only let totalPrice = 0; const chargeRows = visibleCharges.map(charge => { const qty = this.addonState?.quantities[charge.chargeId] || 1; const unitPrice = this.getChargePrice(charge, currency); const price = unitPrice * qty; totalPrice += price; return `
${sanitizeHtml(charge.chargeName)} ${sanitizeHtml(charge.chargeType)} ${charge.isQuantityBased ? `Qty: ${qty}` : ''}
${formatCurrency(price, currency, this.config.locale)}
`; }).join(''); const content = `

Confirm Product Addition

You are about to add the following product to your subscription:

${sanitizeHtml(addon.productName)}

Subscription: ${sanitizeHtml(subscription.subscriptionNumber)}

${planInfo}
${chargeRows}
Total ${formatCurrency(totalPrice, currency, this.config.locale)}

This product will be added to your subscription immediately.

`; const modal = renderModal('confirm-addon', 'Confirm Product Addition', content, ` `); document.body.insertAdjacentHTML('beforeend', modal); } /** * Go back from confirmation to addon config */ backToAddonConfig() { // Close confirm modal, the add-addon modal is still there const confirmOverlay = document.getElementById('confirm-addon-overlay'); confirmOverlay?.remove(); } /** * Go back to addon product list */ backToAddonList() { if (!this.addonState) return; const id = this.componentId; // Clear selection this.addonState.selectedProduct = null; this.addonState.selectedChargePlanId = null; this.addonState.quantities = {}; // Show product list, hide configuration and plans const listEl = document.getElementById(`${id}-addon-list`); const introEl = document.querySelector('.yem-addon-intro'); const configEl = document.getElementById(`${id}-addon-config`); const plansEl = document.getElementById(`${id}-addon-plans`); if (listEl) listEl.style.display = 'block'; if (introEl) introEl.style.display = 'block'; if (configEl) { configEl.style.display = 'none'; configEl.innerHTML = ''; } if (plansEl) { plansEl.style.display = 'none'; plansEl.innerHTML = ''; } // Hide back button, disable submit button const backBtn = document.getElementById(`${id}-addon-back`); const submitBtn = document.getElementById(`${id}-addon-submit`); if (backBtn) backBtn.style.display = 'none'; if (submitBtn) submitBtn.disabled = true; } async confirmAddon() { if (!this.addonState?.selectedProduct) return; const id = this.componentId; const submitBtn = document.getElementById(`${id}-addon-confirm-submit`); if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'Adding...'; } // Use quantities already collected in showConfirmAddonModal const chargeQuantities = this.addonState.quantities; // Build addon request with optional chargePlanId const addonRequest = { addonProductId: this.addonState.selectedProduct.productId, chargeQuantities, }; // Include chargePlanId if a plan was selected (for multi-plan products) if (this.addonState.selectedChargePlanId) { addonRequest.chargePlanId = this.addonState.selectedChargePlanId; } try { // Calculate effective date: use subscription start date if it hasn't started yet, otherwise use today const effectiveDate = this.calculateEffectiveDate(this.addonState.subscriptionStartDate); const response = await this.apiClient.call({ method: 'POST', endpoint: '/subscriptions/request-change', body: { subscriptionId: this.addonState.subscriptionId, changeType: 'AddAddon', effectiveDate, addonsToAdd: [addonRequest], }, }); // Check both success flag and status (API may return success:true but status:'Failed') if (response.success && response.status !== 'Failed') { // Close both modals and reload const confirmOverlay = document.getElementById('confirm-addon-overlay'); confirmOverlay?.remove(); const addonOverlay = document.getElementById('add-addon-overlay'); addonOverlay?.remove(); this.addonState = null; await this.silentReload(); } else { const errorEl = document.getElementById(`${id}-addon-confirm-error`); if (errorEl) { errorEl.innerHTML = `
${sanitizeHtml(response.errorMessage || 'Failed to add product')}
`; } if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Confirm'; } } } catch (error) { const errorEl = document.getElementById(`${id}-addon-confirm-error`); if (errorEl) { const errorMessage = this.getErrorMessage(error, 'Failed to add product'); errorEl.innerHTML = `
${sanitizeHtml(errorMessage)}
`; } if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Confirm'; } } } } /** * Main YouniumEmbedded SDK Class */ class YouniumEmbeddedSDK { constructor(configOrToken = { token: '' }) { // Support both simple token string and full config object const inputConfig = typeof configOrToken === 'string' ? { token: configOrToken } : configOrToken; // Merge config with defaults this.config = { ...DEFAULT_CONFIG, ...inputConfig, theme: { ...DEFAULT_THEME, ...(inputConfig.theme || {}), }, }; this.apiClient = new ApiClient(this.config); this.tokenManager = new TokenManager(this.config); this.components = new Map(); this.accountComponents = new Map(); this.subscriptionComponents = new Map(); this.initialized = false; this.styleInjected = false; if (this.config.token) { this.setToken(this.config.token); this.injectStyles(); this.initialized = true; if (this.config.debug) { console.log('[YouniumSDK] Initialized', { version: SDK_VERSION, config: this.config }); } } } /** * Static initialization method */ static init(configOrToken) { const instance = new YouniumEmbeddedSDK(configOrToken); // Store instance for static method access window.YouniumEmbedded._instance = instance; return instance; } /** * Get SDK version */ static getVersion() { return SDK_VERSION; } /** * Set or update the authentication token */ setToken(token) { this.config.token = token; this.apiClient.setToken(token); this.tokenManager.setToken(token); if (!this.initialized) { this.injectStyles(); this.initialized = true; } } /** * Inject component styles */ injectStyles() { if (this.styleInjected) { // Update existing styles injectStyles(this.config.theme); return; } injectStyles(this.config.theme); this.styleInjected = true; } /** * Render a component */ async renderComponent(componentType, config) { if (!this.initialized) { throw new Error('SDK not initialized. Provide token in constructor or call setToken().'); } const { containerId } = config; let component; switch (componentType) { case COMPONENT_TYPES.INVOICE_LIST: component = new InvoiceListComponent(this.config, this.apiClient, containerId, config.options); break; case COMPONENT_TYPES.ACCOUNT_INFO: component = new AccountInfoComponent(this.config, this.apiClient, containerId, config.options); // Store reference for static method binding const accountComp = component; this.accountComponents.set(accountComp.getComponentId(), accountComp); break; case COMPONENT_TYPES.SUBSCRIPTION_LIST: component = new SubscriptionListComponent(this.config, this.apiClient, containerId, config.options); // Store reference for static method binding const subComp = component; this.subscriptionComponents.set(subComp.getComponentId(), subComp); break; default: throw new Error(`Unknown component type: ${componentType}`); } const componentId = await component.render(); this.components.set(componentId, component); return componentId; } /** * Get a component by ID */ getComponent(componentId) { const component = this.components.get(componentId); return component?.getInstance(); } /** * Destroy a specific component */ destroyComponent(componentId) { const component = this.components.get(componentId); if (component) { component.destroy(); this.components.delete(componentId); // Clean up from specialized maps if (component instanceof AccountInfoComponent) { this.accountComponents.delete(component.getComponentId()); } if (component instanceof SubscriptionListComponent) { this.subscriptionComponents.delete(component.getComponentId()); } } } /** * Destroy all components and clean up */ destroy() { for (const [componentId] of this.components) { this.destroyComponent(componentId); } this.tokenManager.destroy(); removeStyles(); this.initialized = false; this.styleInjected = false; } // ========================================================================= // Static helper methods (exposed globally for onclick handlers) // ========================================================================= /** * Search invoices */ static async searchInvoices(componentId) { const sdk = YouniumEmbeddedSDK.getInstance(); const component = sdk?.components.get(componentId); if (component instanceof InvoiceListComponent) { const searchInput = document.getElementById(`search-input-${componentId}`); await component.search(searchInput?.value || ''); } } /** * Clear invoice search */ static async clearSearch(componentId) { const sdk = YouniumEmbeddedSDK.getInstance(); const component = sdk?.components.get(componentId); if (component instanceof InvoiceListComponent) { const searchInput = document.getElementById(`search-input-${componentId}`); if (searchInput) searchInput.value = ''; await component.clearSearch(); } } /** * Change page */ static async changePage(componentId, page) { const sdk = YouniumEmbeddedSDK.getInstance(); const component = sdk?.components.get(componentId); if (component instanceof InvoiceListComponent) { await component.changePage(page); } } /** * Download invoice */ static async downloadInvoice(invoiceId) { const sdk = YouniumEmbeddedSDK.getInstance(); // Find any invoice list component to use its download method for (const component of sdk?.components.values() || []) { if (component instanceof InvoiceListComponent) { await component.downloadInvoice(invoiceId); return; } } } // Account component methods static startEditEmail(componentId) { const sdk = YouniumEmbeddedSDK.getInstance(); sdk?.accountComponents.get(componentId)?.startEditEmail(); } static cancelEditEmail(componentId) { const sdk = YouniumEmbeddedSDK.getInstance(); sdk?.accountComponents.get(componentId)?.cancelEditEmail(); } static async saveEmail(componentId) { const sdk = YouniumEmbeddedSDK.getInstance(); await sdk?.accountComponents.get(componentId)?.saveEmail(); } static startEditAddress(componentId) { const sdk = YouniumEmbeddedSDK.getInstance(); sdk?.accountComponents.get(componentId)?.startEditAddress(); } static cancelEditAddress(componentId) { const sdk = YouniumEmbeddedSDK.getInstance(); sdk?.accountComponents.get(componentId)?.cancelEditAddress(); } static async saveAddress(componentId) { const sdk = YouniumEmbeddedSDK.getInstance(); await sdk?.accountComponents.get(componentId)?.saveAddress(); } // Subscription component methods static toggleSubscription(componentId, subscriptionId) { const sdk = YouniumEmbeddedSDK.getInstance(); sdk?.subscriptionComponents.get(componentId)?.toggleSubscription(subscriptionId); } static toggleLine(componentId, lineId) { const sdk = YouniumEmbeddedSDK.getInstance(); sdk?.subscriptionComponents.get(componentId)?.toggleLine(lineId); } static showTieredPricing(componentId, lineId) { const sdk = YouniumEmbeddedSDK.getInstance(); sdk?.subscriptionComponents.get(componentId)?.showTieredPricing(lineId); } static closeModal(modalId) { const overlay = document.getElementById(`${modalId}-overlay`); overlay?.remove(); } static showEditQuantityModal(componentId, subscriptionId, lineId) { const sdk = YouniumEmbeddedSDK.getInstance(); sdk?.subscriptionComponents.get(componentId)?.showEditQuantityModal(subscriptionId, lineId); } static async calculatePriceChange(componentId) { const sdk = YouniumEmbeddedSDK.getInstance(); await sdk?.subscriptionComponents.get(componentId)?.calculatePriceChange(); } static showConfirmQuantityChange(componentId) { const sdk = YouniumEmbeddedSDK.getInstance(); sdk?.subscriptionComponents.get(componentId)?.showConfirmQuantityChange(); } static cancelConfirmQuantityChange(componentId) { const sdk = YouniumEmbeddedSDK.getInstance(); sdk?.subscriptionComponents.get(componentId)?.cancelConfirmQuantityChange(); } static async submitQuantityChange(componentId) { const sdk = YouniumEmbeddedSDK.getInstance(); await sdk?.subscriptionComponents.get(componentId)?.submitQuantityChange(); } static showAddAddonModal(componentId, subscriptionId) { const sdk = YouniumEmbeddedSDK.getInstance(); sdk?.subscriptionComponents.get(componentId)?.showAddAddonModal(subscriptionId); } static selectAddon(componentId, productId) { const sdk = YouniumEmbeddedSDK.getInstance(); sdk?.subscriptionComponents.get(componentId)?.selectAddon(productId); } static selectChargePlan(componentId, chargePlanId) { const sdk = YouniumEmbeddedSDK.getInstance(); sdk?.subscriptionComponents.get(componentId)?.selectChargePlan(chargePlanId); } static updateAddonQuantity(componentId, chargeId) { const sdk = YouniumEmbeddedSDK.getInstance(); sdk?.subscriptionComponents.get(componentId)?.updateAddonQuantity(chargeId); } static addonBack(componentId) { const sdk = YouniumEmbeddedSDK.getInstance(); sdk?.subscriptionComponents.get(componentId)?.addonBack(); } static backToAddonList(componentId) { const sdk = YouniumEmbeddedSDK.getInstance(); sdk?.subscriptionComponents.get(componentId)?.backToAddonList(); } static showConfirmAddonModal(componentId) { const sdk = YouniumEmbeddedSDK.getInstance(); sdk?.subscriptionComponents.get(componentId)?.showConfirmAddonModal(); } static backToAddonConfig(componentId) { const sdk = YouniumEmbeddedSDK.getInstance(); sdk?.subscriptionComponents.get(componentId)?.backToAddonConfig(); } static async confirmAddon(componentId) { const sdk = YouniumEmbeddedSDK.getInstance(); await sdk?.subscriptionComponents.get(componentId)?.confirmAddon(); } // Message helpers static showMessage(componentId, message, type) { const messageEl = document.getElementById(`${componentId}-message`); if (messageEl) { messageEl.textContent = message; messageEl.className = `yem-message yem-message-${type}`; messageEl.style.display = 'block'; } } static hideMessage(componentId) { const messageEl = document.getElementById(`${componentId}-message`); if (messageEl) { messageEl.style.display = 'none'; } } /** * Get the current SDK instance */ static getInstance() { const global = window; return global.YouniumEmbedded?._instance || null; } } /** * Younium Self Service Embedded SDK * * @example * // Initialize with token * const sdk = YouniumEmbedded.init({ * token: 'your-jwt-token', * apiEndpoint: 'https://api.selfservice.younium.com', * theme: { * primaryColor: '#DB7013', * colorMode: 'light' * } * }); * * // Render components * sdk.renderComponent('invoice-list', { * containerId: 'invoice-container', * options: { pageSize: 10, showSearch: true } * }); */ // Create the SDK object with static methods bound const YouniumEmbedded = { // Constructor and initialization init: YouniumEmbeddedSDK.init.bind(YouniumEmbeddedSDK), getVersion: YouniumEmbeddedSDK.getVersion.bind(YouniumEmbeddedSDK), // Component types constant COMPONENT_TYPES, VERSION: SDK_VERSION, // Store for the active instance _instance: null, // Invoice list methods searchInvoices: YouniumEmbeddedSDK.searchInvoices.bind(YouniumEmbeddedSDK), clearSearch: YouniumEmbeddedSDK.clearSearch.bind(YouniumEmbeddedSDK), changePage: YouniumEmbeddedSDK.changePage.bind(YouniumEmbeddedSDK), downloadInvoice: YouniumEmbeddedSDK.downloadInvoice.bind(YouniumEmbeddedSDK), // Account info methods startEditEmail: YouniumEmbeddedSDK.startEditEmail.bind(YouniumEmbeddedSDK), cancelEditEmail: YouniumEmbeddedSDK.cancelEditEmail.bind(YouniumEmbeddedSDK), saveEmail: YouniumEmbeddedSDK.saveEmail.bind(YouniumEmbeddedSDK), startEditAddress: YouniumEmbeddedSDK.startEditAddress.bind(YouniumEmbeddedSDK), cancelEditAddress: YouniumEmbeddedSDK.cancelEditAddress.bind(YouniumEmbeddedSDK), saveAddress: YouniumEmbeddedSDK.saveAddress.bind(YouniumEmbeddedSDK), // Subscription methods toggleSubscription: YouniumEmbeddedSDK.toggleSubscription.bind(YouniumEmbeddedSDK), toggleLine: YouniumEmbeddedSDK.toggleLine.bind(YouniumEmbeddedSDK), showTieredPricing: YouniumEmbeddedSDK.showTieredPricing.bind(YouniumEmbeddedSDK), showEditQuantityModal: YouniumEmbeddedSDK.showEditQuantityModal.bind(YouniumEmbeddedSDK), calculatePriceChange: YouniumEmbeddedSDK.calculatePriceChange.bind(YouniumEmbeddedSDK), showConfirmQuantityChange: YouniumEmbeddedSDK.showConfirmQuantityChange.bind(YouniumEmbeddedSDK), cancelConfirmQuantityChange: YouniumEmbeddedSDK.cancelConfirmQuantityChange.bind(YouniumEmbeddedSDK), submitQuantityChange: YouniumEmbeddedSDK.submitQuantityChange.bind(YouniumEmbeddedSDK), showAddAddonModal: YouniumEmbeddedSDK.showAddAddonModal.bind(YouniumEmbeddedSDK), selectAddon: YouniumEmbeddedSDK.selectAddon.bind(YouniumEmbeddedSDK), selectChargePlan: YouniumEmbeddedSDK.selectChargePlan.bind(YouniumEmbeddedSDK), updateAddonQuantity: YouniumEmbeddedSDK.updateAddonQuantity.bind(YouniumEmbeddedSDK), addonBack: YouniumEmbeddedSDK.addonBack.bind(YouniumEmbeddedSDK), backToAddonList: YouniumEmbeddedSDK.backToAddonList.bind(YouniumEmbeddedSDK), showConfirmAddonModal: YouniumEmbeddedSDK.showConfirmAddonModal.bind(YouniumEmbeddedSDK), backToAddonConfig: YouniumEmbeddedSDK.backToAddonConfig.bind(YouniumEmbeddedSDK), confirmAddon: YouniumEmbeddedSDK.confirmAddon.bind(YouniumEmbeddedSDK), // Modal helpers closeModal: YouniumEmbeddedSDK.closeModal.bind(YouniumEmbeddedSDK), // Message helpers showMessage: YouniumEmbeddedSDK.showMessage.bind(YouniumEmbeddedSDK), hideMessage: YouniumEmbeddedSDK.hideMessage.bind(YouniumEmbeddedSDK), // SDK class for advanced usage YouniumEmbeddedSDK, }; exports.YouniumEmbeddedSDK = YouniumEmbeddedSDK; exports.default = YouniumEmbedded; Object.defineProperty(exports, '__esModule', { value: true }); // Flatten default export to top-level for direct access if (exports.default) { Object.keys(exports.default).forEach(function(key) { if (key !== 'default' && !(key in exports)) { exports[key] = exports.default[key]; } }); } })); //# sourceMappingURL=younium-embedded-sdk.js.map