Source: api.js

/**
 * @file Ajax api managing the chat on the yalst server<br/>
 * Created on 22.09.2014 for the yalst-trunk project.
 * @copyright (c) 2015 Visisoft OHG. All rights reserved.
 * @version 1.0
 * @module api
 *
 * @typedef {Object} Api
 * @property {Boolean} isISOCodeInstallation
 * @property {String} apiUrl
 * @property {Number} fileSizeLimit
 * @property {Number} clientCap
 * @property {Boolean} isAddressingInformally
 * @property {Boolean} isChatConfiguredForEmojis
 * @property {Number} noActivityChatPollingInterval
 * @property {api.configure} configure - Set up the live support server.
 * @property {api.apiAjaxPromise} apiAjaxPromise - basic method to perform the ajax request to the Chat Api
 * @property {api.hello} hello - Query the availability of the live support service
 * @property {function(Object): Object} mixinPermanentParameters
 * @property {function({getParameters:{}, postParameters: {}=, queryFragment: String=}): void} notifyApiAjax - polyfill navigator.sendBeacon analytics data push
 * @property {function(String): void} notifyLeave - Analytics data push
 * @property {function(String): Promise} leave
 * @property {function(): Promise} leaveStartForm
 * @property {function(): void} confirmPreChatPresence
 * @property {function(Object): String} getCoBrowsingUrl
 * @property {function(Number): Promise.<Api.OperatorInfo>} operatorInfo
 * @property {function(Object, Object=, String=): Bacon.EventStream} apiAjaxStream
 * @property {function(String, String?): String} getContactFormUrlForDepartmentIdAndLanguageNumber
 * @description Ajax api managing the chat on the yalst server
 */



define(['Promise', 'lib/functionalContributions', 'dictionary', 'bacon', 'chatButtonParameters',
	'lib/jsUtilities', 'ramda'],
	/**
	 * @param Promise
	 * @param _fn
	 * @param dict
	 * @param {Bacon} Bacon
	 * @param {ChatButtonParameters} chatButtonParameters
	 * @param _JS
	 * @returns {{}}
	 */
	function(Promise, _fn, dict, Bacon, chatButtonParameters, _JS, R)
{
	const
		spaUrl = new URL(document.URL),
		IS_PRESET_BASED_INTEGRATION = /\bpreset_based_integration=(?!0)/.test(spaUrl.search),
		IS_EMBEDDED = /\bembedding=(?!0)/.test(spaUrl.search),
	   CLIENT_CAP = 2.0,
		IS_IOS = /iPod|iPad|iPhone.*?OS \d+/.test(navigator.userAgent);
	
	var
		siteCode,
		apiUrl,
		visitorId,
		permanentParameters = {
			clientcap: CLIENT_CAP,
			lang: chatButtonParameters.lang
		},
		clientConfiguration = {
			deviceInfo: "phone",
			appCapabilities: []
		},
		isInvited = _JS.wrapValue(false);

	var
		rejectUndefineds = reject(compose(eq(undefined), last)),
		rejectUndefinedValues = _fn.transformObject(rejectUndefineds);

	/** @type Api */
	var self = {
		apiUrl                       : '',
		fileSizeLimit                : 1.0e+6,
		isAddressingInformally       : false,
		isChatConfiguredForEmojis    : false,
		isISOCodeInstallation        : true,
		noActivityChatPollingInterval: 5000,
		clientCap                    : CLIENT_CAP
	};

	self.isISOCodeInstallation = false;

	self.mixinPermanentParameters = function(){ throw new Error("mis-scheduled call before configured.");};

	/**
	 * A promise which if resolved indicates that the api is ready to use by third-party custom JavaScript
	 * @type {Promise}
	 */
	self.isConfiguredPromise = new Promise(function(resolve)
	{
		/**
		 * @typedef {function} api.configure
		 * @param {Object} options
		 */

		/**
		 * Set up the live support server. This method is called before any other methods by the web application
		 * and will resolve the {@linkcode module:api.isConfiguredPromise}.
		 * @type {api.configure}
		 * @param {Object} options
		 * @param {string} options.siteNumber - The yalst site number (live support access number)
		 * @param {string} options.apiUrl - The full url of the remote chat api of yalst live support server. E.g. <tt>"https://your-server.de/yalst/chat.api.php"</tt>
		 * @param {string} [options.visitorId] - The tracking id for the visitor
		 * @param {Object} options.clientConfiguration - Identifies the device.
		 * @param {Bacon.Property} options.isInvited - observable of the invitation status
		 * Possible values: 'phone', 'tablet' or desktop
		 */
		self.configure = function (options)
		{
			siteCode = options.siteNumber;
			self.apiUrl = apiUrl = options.apiUrl;
			visitorId = options.visitorId;

			clientConfiguration = mixin(clientConfiguration, options.clientConfiguration);

			options.isInvited.onValue(isInvited.set);

			permanentParameters.site = options.siteNumber;
			self.mixinPermanentParameters = mixin(permanentParameters);

			resolve(true);
		};
	});

	self.getContactFormUrlForDepartmentIdAndLanguageNumber = function(departmentId, languageCode)
	{
		var scriptUrl = join('/', append('contact.php', _fn.initial(split('/', apiUrl))));
		var sameSchemeScriptUrl = scriptUrl.replace(/^https?:/, location.protocol);

		/// TODO: test mobile.offline.php$site=".$site."&offlineref=prechat/inchat"&dept=departmentId

		var urlParameters = {
			site: siteCode,
			cdept: departmentId,
			mobile : true,
			preset_based_integration: IS_PRESET_BASED_INTEGRATION ? 1 : 0
		};

		if (IS_EMBEDDED)
		{
			urlParameters.embedding = 1;
		}

		if (languageCode !== undefined)
		{
			urlParameters.newlang = languageCode;
		}

		return sameSchemeScriptUrl + "?" + $.param(urlParameters);
	};

	self.getCoBrowsingUrl = function(otherParameters)
	{
		var scriptUrl = join('/', append('cobrowse.php', _fn.initial(split('/', apiUrl))));
		var sameSchemeScriptUrl = scriptUrl.replace(/^https?:/, location.protocol);

		const parameters = mixin(
			{
				ycobro_site: siteCode,
				ycobro_role: 'vis'
			},
			otherParameters
		);

		return sameSchemeScriptUrl + "?" + $.param(parameters);
	};

	function create$apiXhr (getParameters, postParameters, queryFragment, $xhrOptions)
	{
		var isPostRequest = !isEmpty(keys(postParameters));

		var uri = apiUrl;

		if ($.param(getParameters))
		{
			uri += "?";
			uri += $.param(getParameters);

			if (queryFragment)
			{
				uri += "&";
				uri += queryFragment;
			}
		}
		else if (queryFragment)
		{
			uri += "?";
			uri += queryFragment;
		}

		return $.ajax(mixin({
			dataType: "json",
			url: uri ,
			type: isPostRequest ? "POST" : "GET",
			data: isPostRequest ? postParameters : undefined
		}, $xhrOptions || {}));
	}

	function processApiResult (json)
	{
		if (json.error)
		{
			var error = new Error(json.error + ' (#' + json.code + ')');
			error.code = json.code;

			throw error;
		}

		return json;
	}

	function throwAjaxError ($xhr)
	{
		throw new Error($xhr.statusText + ' (#' + $xhr.status + ')');
	}

	self.apiAjaxStream = function(getParameters, postParameters, queryFragment)
	{
		return Bacon.fromBinder(function(sink)
		{
			var $xhr = create$apiXhr(getParameters, postParameters, queryFragment);

			Promise.resolve($xhr)
				.then(processApiResult, throwAjaxError)
				.then(function(json)
				{
					sink(new Bacon.Next(json));
				},
				function(error)
				{
					/// do not treat cancelling ajax by the program as an error
					if (!error.message.match(/^abort/))
					{
						sink(new Bacon.Error(error.message));
					}
				}
			);

			return function cleanUp()
			{
				if ($xhr.readyState < 4) //  before "DONE"
				{
					$xhr.abort();
				}
			};
		});
	};

	/**
	 * @typedef {function} api.apiAjaxPromise
	 * @param {!Object} getParameters
	 * @param {Object} [postParameters]
	 * @param {string} [queryFragment] key value pairs in HTML-form encoded format
	 * @returns {Promise}
	 */

	/**
	 * basic method to perform the ajax request to the Chat Api
	 * @type {api.apiAjaxPromise}
	 * @param {!Object} getParameters
	 * @param {Object} [postParameters]
	 * @param {string} [queryFragment] key value pairs in HTML-form encoded format
	 * @param {Object=} $ajaxOptions i.e. {type: 'HEAD'}
	 * @returns {Promise}
	 */
	self.apiAjaxPromise = function(getParameters, postParameters, queryFragment, $ajaxOptions)
	{
		/// dead-lock is possible
		return self.isConfiguredPromise.then(function()
			{
				return Promise.resolve(create$apiXhr(
					getParameters,
					postParameters,
					queryFragment,
					$ajaxOptions
				))
				.then(processApiResult, throwAjaxError);
			}
		);
	};
	
	self.notifyApiAjax = function(options)
	{
		// exempt Mobile Safari since sendBeacon is buggy there: https://bugs.webkit.org/show_bug.cgi?id=188329
		if (navigator.sendBeacon && !IS_IOS)
		{
			const
				theGetParameters = options.getParameters || {},
				hasGetParameters = !R.isEmpty(theGetParameters),
				
				// w/o jQuery:
				// var searchParams = new URLSearchParams();
				// searchParams.append("key", "value");
				// searchParams.toString();
				
				requestUrlQueryPart =
					(hasGetParameters || options.queryFragment) ?
						'?'
						+ ( hasGetParameters ? $.param(theGetParameters) : '' )
						+ ( (options.queryFragment && hasGetParameters) ? '&' : '' )
						+ ( options.queryFragment || '' ) :
						"";
			
			navigator.sendBeacon(apiUrl + requestUrlQueryPart, $.param(options.postParameters || {}));
		}
		else
		{
			create$apiXhr(
				options.getParameters,
				options.postParameters,
				options.queryFragment,
				mixin(
					omit(['getParameters', 'postParameters', 'queryFragment', 'onError', 'onSuccess'], options),
					{
						async: false,
						type: "HEAD"
					}
				)
			);
		}
	};

	/**
	 * @typedef {function} api.hello
	 * @returns {Promise}
	 */

	 /**
	  * @typedef {Object} api.helloResponse
	  * @property {String} salutation
	  * @property {Number} filesizelimit
	  * @property {Number} refresh
	  * @property {Number} charlimit
	  * @property {Number} refresh
	  * @property {String} charset
	  * @property {String} cookiedomain
	  * @property {String} chat
	  * @property {Boolean} transcript
	  * @property {Boolean} rating
	  * @property {Boolean} contactform
	  * @property {String} title
	  * @property {String} welcome
	  * @property {String} welcome2
	  * @property {?String} offlinetext
	  * @property {Boolean} smileys
	  */

	/**
	 * Query the availability of the live support service
	 * @type {api.hello}
	 * @return {Promise<import("./api").HelloAPIResponse>}
	 * @example
	 * // loading the module
	 * require(['api'], function(serverApi)
	 * {
	 * 	serverApi.isConfiguredPromise.then(function()
	 * 	{
	 * 		serverApi.hello().then(function(data)
	 * 		{
		* 			switch (data.chat)
		* 			{
		* 				case "online":
		*					console.log("Chat available");
		*					break;
		*				case "busy":
		*					console.log("Agents are busy");
		*					break;
		*				case "offline":
		*					console.log("Try again later");
		*					break;
		*			}
		* 		},
		* 		function(error)
		* 		{
		* 			console.error(error.message);
		* 		});
		* 	});
		* });
	 */
	self.hello = function()
	{
		return self.apiAjaxPromise(self.mixinPermanentParameters(
			{
				cmd: "hello",
				random: Math.floor(Math.random() * 500)
			})
		).then(
			/**
			 *
			 * @param {import("./api").HelloAPIResponse} json
			 * @return {import("./api").HelloAPIResponse}
			 */
			function(json)
		{
			self.isISOCodeInstallation = json.charset === "ISO-8859-1";
			self.fileSizeLimit = json.filesizelimit;
			self.isAddressingInformally = json.salutation === "du";
			self.noActivityChatPollingInterval = json.refresh * 1000;
			self.isChatConfiguredForEmojis = json.smileys;

			return json;
		});
	};

	/**
	 * Retrieve the welcoming form for a particular department or the
	 * live support service in general
	 * @param {string} [department]
	 * @returns {Promise<import("./api").StartFormAPIResponse>}
	 */
	self.startForm = function(department)
	{
		var parameters = self.mixinPermanentParameters({cmd: "startform"});

		if (department) {
			parameters.dept = department;
		}
		
		if (IS_PRESET_BASED_INTEGRATION && globalLiveSupportProviderChatConfiguration.invitationType !== "no-invitation") {
			parameters.invite = 1;
		}
		
		parameters.preset_choose_dept = globalLiveSupportProviderChatConfiguration.shouldDepartmentBeSelectedByVisitor ? 1 : 0;

		return self.apiAjaxPromise(parameters);
	};

	/**
	 * @typedef {Object} api.startResponse
	 * @property {String} session
	 * @property {Number} chat
	 */

	/**
	 * Request a chat with optionally with a department at the
	 * configured live support service
	 * @param {Object} options
	 * @param {string} [options.department] - The service department id.
	 * @param {string} options.name - The chat name of the visitor.
	 * @param {string} [options.email] - The visitor's email if given.
	 * @param {string} [options.query] - The visitor's concern if given.
	 * @param {string} [options."custom[0]"] - The value of the <tt>custom[0]</tt> field
	 * in the welcoming form
	 * @param {string} [options.serializedForm]
	 * @returns {Promise<api.startResponse>}
	 */
	self.start = function(options)
	{
		const
			clientString = join(',', concat(clientConfiguration.appCapabilities,
				[IS_EMBEDDED ? "in-page" : "full-page", clientConfiguration.deviceInfo])),
			
			chatInitiationTypeParameterByType = {
				"auto-invite" : "auto",
				"invite": "active",
				"load-based-auto-invite": undefined,
				"no-invitation": undefined
			},

			chatInitiationType =
				IS_PRESET_BASED_INTEGRATION
					? chatInitiationTypeParameterByType[globalLiveSupportProviderChatConfiguration.invitationType]
					: isInvited.get() ? (chatButtonParameters.chatType || "active") : undefined,
		
			formSearchParameters = new URLSearchParams(options.serializedForm ? options.serializedForm : "");

		var fixedParameters = self.mixinPermanentParameters({
			cmd: "start",
			client: clientString ,
			colors: screen.colorDepth,
			resolution : screen.width + "*" + screen.height,
			time : (new Date()).toTimeString().match(/^\d\d:\d\d/)[0],
			ref: globalLiveSupportProviderChatConfiguration.referrer,
			ref2: globalLiveSupportProviderChatConfiguration.referrer2,

			/// Allerdings ist im Moment keine Unterscheidung zwischen Auto- und manueller Einladung möglich.
			/// Fehlerhafte Parameter sollten durch die Chat-Api berichtigt werden.
			chattype: chatInitiationType,
			visitorid: globalLiveSupportProviderChatConfiguration.visitorId,
			comment: globalLiveSupportProviderChatConfiguration.comment,
		});
		
		if (!formSearchParameters.get("name")) {
			fixedParameters.name = dict.get("Websurfer");
		}

		var apiCallParameters = rejectUndefinedValues(
			mixin(
				fixedParameters,
				options.parameters)
		);
		
		if (globalLiveSupportProviderChatConfiguration.department)
		{
			/// 'dept' and 'department' differ in that the former can be hidden in the
			/// department selection box
			apiCallParameters['dept'] = globalLiveSupportProviderChatConfiguration.department;
		}

		return self.apiAjaxPromise(apiCallParameters, undefined, options.serializedForm);
	};

	/**
	 * Reports the visitor entering a particular page or view
	 * @param {string} pageId
	 * @returns {Promise}
	 */
	self.enter = function(pageId)
	{
		if (!visitorId)
		{
			return Promise.resolve();
		}

		return self.apiAjaxPromise(self.mixinPermanentParameters({
			cmd:       "enter",
			page:      pageId,
			visitorid: visitorId
		}));
	};

	/**
	 * Reports the visitor entering the welcoming form page
	 * @type {function(this:undefined)|*}
	 * @returns {Promise}
	 */
	self.enterStartForm = self.enter.bind(undefined, "startform");

	/**
	 * Reports the visitor leaving a particular page or view.<br><br>
	 * This is may be done by a <em>SJAX (blocking)</em> network request. Therefore it
	 * is safe to call this method e.g. on <tt>pagehide</tt>
	 * @param {string} pageId
	 * @returns {undefined}
	 */
	self.notifyLeave = function(pageId)
	{
		if (!visitorId)
		{
			return;
		}
		
		const theGetParameters = {
			cmd:       "leave",
			page:      pageId,
			visitorid: visitorId
		};
		
		self.notifyApiAjax({
			getParameters: self.mixinPermanentParameters(theGetParameters)
		});
	};

	/**
	 * Reports the visitor leaving a particular page or view
	 * @param {string} pageId
	 * @returns {Promise}
	 */
	self.leave = function(pageId)
	{
		if (!visitorId)
		{
			return Promise.resolve();
		}

		return self.apiAjaxPromise(self.mixinPermanentParameters({
			cmd:       "leave",
			page:      pageId,
			visitorid: visitorId
		}));
	};

	/**
	 * Reports the visitor leaving the welcoming form page
	 * @type {function(this:undefined)|*}
	 * @returns {Promise}
	 */
	self.leaveStartForm = self.leave.bind(undefined, "startform");
	
	/**
	 * just touches the visitor's entry in the monitor table.
	 * No purpose which is not already covered by ENTER and LEAVE commands
	 */
	self.confirmPreChatPresence = function()
	{
		if (!visitorId)
		{
			return;
		}
		
		create$apiXhr(
			self.mixinPermanentParameters({
				cmd:       "prechat",
				visitorid: visitorId
			}),
			{},
			"",
			{ type: 'HEAD' }
		);
	};

	/**
	 * @typedef {Object} Api.OperatorInfo
	 * @property {String} picture
	 */

	/**
	 *
	 * @param {Number} operatorId
	 * @returns {Promise.<Api.OperatorInfo>}
	 */
	self.operatorInfo = function(operatorId)
	{
		return self.apiAjaxPromise(self.mixinPermanentParameters({
			cmd       : "picture",
			operatorid: operatorId
		}));
	};

	self.toJSON = function()
	{
		return {
			isISOCodeInstallation: self.isISOCodeInstallation,
			fileSizeLimit        : self.fileSizeLimit,
			siteCode             : siteCode,
			apiUrl               : apiUrl,
			visitorId            : visitorId
		};
	};

	self.initializeFromJson = function(json)
	{
		self.isISOCodeInstallation = json.isISOCodeInstallation;
		self.fileSizeLimit = json.fileSizeLimit;
		siteCode = json.siteCode;
		apiUrl = json.apiUrl;
		visitorId = json.visitorId;
	};


	return self;
});