MediaWiki:FundraisingBanners/CoreJS-2018.js

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/* jshint maxerr: 600 */
/* MediaWiki:FundraisingBanners/CoreJS-2018.js
 * Core code for banner forms, with new inline error messages
 */

var frb = frb || {};

/**
 * Test for general ES6 and <dialog> support
 *
 * Checks for arrow functions, default parameters, NodeList.prototype.forEach(), <dialog> support
 * Should be roughly Chrome 51+, Firefox 98+, Edge 79+, Safari 15.4+
 * Based on https://gist.github.com/bendc/d7f3dbc83d0f65ca0433caf90378cd95
 * @return {boolean}
 */
frb.supportedBrowser = function() {
	try {
		new Function('(a = 0) => a');
		document.querySelectorAll('.frb').forEach(a => a);
		if ( typeof HTMLDialogElement === 'function' ) {
			return true;
		} else {
			return false;
		}
	}
	catch (err) {
		return false;
	}
}();

if ( !mw.centralNotice.adminUi ) { // T262693
	frb.loadedTime = Date.now();
	frb.didSelectAmount = false;
	frb.optinRequiredCountries =
		[ 'AR', 'AT', 'BE', 'BR', 'CL', 'CO', 'CZ', 'DK', 'ES', 'FR', 'GB', 'GR', 'HU', 'IE', 'IT', 'IL',
		  'LU', 'LV', 'MX', 'NL', 'NO', 'PE', 'PL', 'PT', 'RO', 'SE', 'SK', 'UA', 'UY' ];
	frb.optinRequired = frb.optinRequiredCountries.indexOf(mw.centralNotice.data.country) !== -1;
	frb.maxUSD = 25000;
	frb.reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}

// Keyboard shortcut to go from banner preview to editor - Ctrl+Shift+E
if ( mw.config.get('wgUserName') ) {
	if ( mw.config.get('wgUserName').match(/\(WMF\)/) ) {
		window.addEventListener('keydown', function(e) {
			if ( e.ctrlKey && e.shiftKey && e.keyCode === 69 ) {
				window.open( 'https://meta.wikimedia.org/wiki/Special:CentralNoticeBanners/Edit/' + mw.centralNotice.data.banner );
			}
		});
	}
}

/**
 * Main function to submit to paymentswiki
 *
 * @param  {Object} options
 * - method (required)
 * - submethod (optional)
 * - gateway (optional)
 * - skipValidation (optional boolean, for pp-usd. Not yet implemented.)
 * @param  {Boolean} isEndowment - deprecated, set frb.isEndowment instead
 */
frb.submitForm = function( options, isEndowment ) {

	var url = new URL('https://payments.wikimedia.org/index.php/Special:GatewayChooser');
	var params = {};

	if ( !frb.validateForm( options ) ) {
		frb.extraData.validateError = 1; // Flag they had an error, even if fixed later
		return false; // Error, bail out of submitting
	}

	if ( frb.isDarkMode() ) {
		frb.extraData.darkMode = 1;
	}

	// Skip form chooser for Venmo
	if ( options.method === 'venmo' && options.gateway !== 'gravy' ) {
		url = new URL('https://payments.wikimedia.org/index.php/Special:BraintreeGateway');
	}

	// Form selection data
	params.payment_method = options.method;
	if ( options.submethod ) {
		params.payment_submethod = options.submethod;
	}
	if ( options.gateway ) {
		params.gateway = options.gateway;
	}
	if ( options.variant ) {
		params.variant = options.variant;
	}
	params.recurring = frb.getRecurring();

	if ( params.recurring && params.variant && params.variant.match( /monthlyConvert/ ) ) {
		// Post-payments monthly convert makes no sense if it's already recurring
		// Avoid things like T312905
		delete params.variant;
	}

	params.currency = frb.getCurrency(mw.centralNotice.data.country) || 'USD';

	params.uselang = mw.centralNotice.data.uselang || 'en';
	params.country = mw.centralNotice.data.country || 'XX';

	if ( params.uselang === 'pt' && params.country === 'BR' ) {
		params.uselang = 'pt-br';
	}
	if ( params.uselang === 'es' &&
		( params.country === 'AR' || params.country === 'CL' ||
		  params.country === 'CO' || params.country === 'MX' ||
		  params.country === 'PE' || params.country === 'UY' ||
		  params.country === 'US' )
	) {
		params.uselang = 'es-419';
	}

	// dLocal override for South Africa
	if ( params.payment_method === 'cc' && params.country === 'ZA' ) {
		params.gateway = 'astropay';
	}

	// Amount
	var amount = frb.getAmount();
	if ( $('#frb-ptf-checkbox').prop('checked') ) {
		amount = amount + frb.calculateFee(amount);
		frb.extraData.ptf = 1;
	}
	params.amount = amount;

	// Email optin
	if ( frb.optinRequired && $('input[name="opt_in"]').length > 0 ) {
		var opt_inValue = $('input[name="opt_in"]:checked').val();
		params.opt_in   = opt_inValue; // frb.validateForm() already checked it's 1 or 0
	}

	// Tracking info
	if ( isEndowment || frb.isEndowment ) {
		params.wmf_medium = 'endowment';
		params.appeal = 'EndowmentQuote';
	} else {
		params.wmf_medium = 'sitenotice';
	}
	params.wmf_campaign = mw.centralNotice.data.campaign || 'test';
	params.wmf_source   = frb.buildTrackingSource(params);

	frb.extraData.time = Math.round( (Date.now() - frb.loadedTime)/1000 );

	if ( !$.isEmptyObject( frb.extraData ) ) {
		params.wmf_key = frb.buildTrackingKey( frb.extraData );
	}

	// Link to Banner History if enabled
	var mixins = mw.centralNotice.getDataProperty( 'mixins' );
	if ( mixins && mixins.bannerHistoryLogger ) {
		params.bannerhistlog = mw.centralNotice.bannerHistoryLogger.id;
	}

	for ( var key of Object.keys( params ) ) {
		url.searchParams.set( key, params[key] );
	}

	// Set a cookie with current location so we can return here from TY page
	mw.loader.using( [ 'mediawiki.cookie', 'mediawiki.util' ] ).then( function () {
		// Exclude URL parameters like banner, but cope with paths like /w/index.php?title=Foo
		var returnToUrl = window.location.origin + mw.util.getUrl();
		mw.cookie.set(
			'fundraising_returnTo',
			returnToUrl,
			{ expires: 300, prefix: '', domain: '.wikipedia.org', secure: true }
		);
	});

	if ( mixins && mixins.bannerHistoryLogger ) {
		mw.centralNotice.bannerHistoryLogger.ensureLogSent().always(function() {
			frb.goToPayments( url );
		});
	} else {
		frb.goToPayments( url );
	}

};

frb.goToPayments = function( url ) {
	if ( window.top !== window.self ) {
		// banner is in a frame, open payments in a new tab
		window.open( url.toString() );
	} else {
		window.location.href = url.toString();
	}
};

/**
 * Check the form for errors.
 *
 * Called on submission, can also be called on input
 *
 * @param {object} options
 * @return {boolean} Whether form is error-free
 */
frb.validateForm = function( options ) {
	var error = false;

	/* Reset all errors */
	$('.frb-haserror').removeClass('frb-haserror');
	$('.frb-error').hide();

	if ( !options.method ) {
		error = true;
		$('.frb-methods').addClass('frb-haserror');
		$('.frb-error-method').show();
	}

	if ( !frb.validateAmount() ) {
		error = true;
	}

	/* Email optin */
	if ( frb.optinRequired && $('.frb-optin').is(':visible') ) {
		var opt_inValue = $('input[name="opt_in"]:checked').val();
		if ( opt_inValue !== '1' && opt_inValue !== '0' ) {
			$('.frb-optin').addClass('frb-haserror');
			$('.frb-error-optin').show();
			error = true;
		}
	}

	return !error;
};

/**
 * Check if selected amount is valid i.e. a positive number, between minimum and maximum.
 * If not, show an error and return false.
 */
frb.validateAmount = function() {

	var amount = frb.getAmount(),
		currency  = frb.getCurrency( mw.centralNotice.data.country ),
		minAmount = frb.amounts.minimums[ currency ],
		maxAmount = Math.round( frb.maxUSD * minAmount );
		// Math.round to account for floating point math errors: https://phabricator.wikimedia.org/T246262

	if ( amount === null || isNaN(amount) || amount <= 0 || amount < minAmount ) {
		$('fieldset.frb-amounts').addClass('frb-haserror');
		$('.frb-error-bigamount').hide();
		$('.frb-error-smallamount').show();
		return false;
	} else if ( amount > Math.round( maxAmount ) ) {
		$('fieldset.frb-amounts').addClass('frb-haserror');
		$('.frb-error-bigamount').show();
		return false;
	} else {
		$('fieldset.frb-amounts').removeClass('frb-haserror');
		$('.frb-error-smallamount, .frb-error-bigamount').hide();
		return true;
	}
};

/**
 * Build the wmf_source for analytics.
 *
 * Own function so it can be overriden for weird tests
 *
 * @param  {Object} params
 * @return {string} wmf_source
 */
frb.buildTrackingSource = function(params) {

	var wmf_source;
	var fullDottedPaymentMethod = params.payment_method;
	if ( params.recurring ) {
		fullDottedPaymentMethod = 'r' + fullDottedPaymentMethod;
	}
	if ( params.payment_submethod ) {
		fullDottedPaymentMethod = fullDottedPaymentMethod + '.' + params.payment_submethod;
	}

	wmf_source = mw.centralNotice.data.banner;

	// Keeping opt-in in wmf_source for safety for now
	// Eventually remove it, or move to wmf_key?
	if ( params.opt_in ) {
		wmf_source += '_optIn' + params.opt_in;
	}

	wmf_source += '.no-LP.' + fullDottedPaymentMethod;

	return wmf_source;
};

/**
 * Build a string for wmf_key from extra tracking data
 *
 * @param  {Object} data
 * @return {string} wmf_key
 */
frb.buildTrackingKey = function(data) {
	var dataArray = [];
	for (var key in data) {
		if (data.hasOwnProperty(key)) {
			dataArray.push( key + '_' + data[key] );
		}
	}
	return dataArray.join('~');
};

/**
 * Determine if we should show recurring choice on step 2
 *
 * NOTE 2023-12-07: we don't currently use this for step 2, since there are no
 *	banners where users select method before frequency. However it is used by
 *	frb.shouldShowMonthlyConvert()
 *
 * @param  {Object} options     Including method and optional gateway
 * @param  {String} country
 * @return {boolean}
 */
frb.shouldShowRecurring = function( options, country ) {

	if ( frb.isEndowment ) {
		return false;
	}
	if ( frb.noRecurringCountries.indexOf( country ) !== -1 ) { // Defined in LocalizeJS-2017.js
		return false;
	}
	if ( options.method === undefined ) {
		return true; // Show if a method hasn't been selected yet
	}
	if ( [ 'cc', 'venmo', 'apple', 'google' ].indexOf( options.method ) !== -1 ) {
		return true;
	}
	if ( options.method === 'paypal' ) {
		if ( [ 'AR', 'BR', 'CL', 'CO', 'MX', 'PE', 'UY' ].includes( country ) ) {
			return false;
		} else {
			return true;
		}
	}
	// Adyen iDEAL
	if ( options.submethod === 'rtbt_ideal' ) {
		return true;
	}
	// SEPA
	if ( options.submethod === 'sepadirectdebit' ) {
		return true;
	}
	if ( options.submethod === 'upi' || options.submethod === 'paytmwallet' ) {
		return true;
	}
	return false;
};

/* Is recurring method selected? This function can be overriden for different forms */
frb.getRecurring = function() {
	// Can't use simple form.frequency.value, doesn't work in IE
	var selected = $('#frb-form input[name="frequency"]:checked').val();
	return selected === 'monthly';
};

/* Return amount selected */
frb.getAmount = function() {
	var form = document.getElementById('frb-form');
	var amount = null;
	frb.extraData.otherAmt = 0;

	// If there are some amount radio buttons, then look for the checked one
	if (form.amount) {
		for (var i = 0; i < form.amount.length; i++) {
			if (form.amount[i].checked) {
				amount = form.amount[i].value;
			}
		}
	}

	// Check the "other" amount box
	if (form.otherAmount.value !== '') {
		var otherAmount = form.otherAmount.value;
		otherAmount = otherAmount.replace(/[,.](\d)$/, ':$10');
		otherAmount = otherAmount.replace(/[,.](\d)(\d)$/, ':$1$2');
		otherAmount = otherAmount.replace(/[$£€¥,.]/g, '');
		otherAmount = otherAmount.replace(/:/, '.');
		amount = otherAmount;
		frb.extraData.otherAmt = 1;
	}

	amount = parseFloat(amount);

	if ( isNaN(amount) ) {
		return 0;
	} else {
		return amount;
	}

};

/* Localize the amount errors. Call when initialising banner. */
frb.localizeErrors = function() {
	var currency  = frb.getCurrency( mw.centralNotice.data.country ),
		language = mw.centralNotice.data.uselang,
		minAmount = frb.amounts.minimums[ currency ],
		maxAmount = Math.round( frb.maxUSD * minAmount );
		// Math.round to account for floating point math errors: https://phabricator.wikimedia.org/T246262

	$('.frb-error-smallamount').text( function( index, oldText ) {
		return oldText.replace( '$1', frb.formatCurrency(currency, minAmount, language)  );
	});

	$('.frb-error-bigamount').text( function( index, oldText ) {
		// We cannot accept donations greater than $1 $2 through our website. Please contact our major gifts staff at $3.
		return oldText.replace( '$1', maxAmount )
					  .replace( '$2', currency )
					  .replace( '$3', 'benefactors@wikimedia.org' );
	});
};

/**
 * Shared code for amount input handling
 */
frb.initAmountOptions = function() {

	// Reset "Other" input if user clicks a preset amount
	$('#frb-form [id^=frb-amt-ps]').click(function() {
		$('#frb-amt-other-input').val('');
	});

	// Track if they selected and then later changed amount
	var checkAmountChange = function(e) {
		if ( frb.didSelectAmount ) {
			frb.extraData.changedAmt = 1;
		}
		// check if amount radio button is selected OR there is a value in the other amount
		if ( $('.frb-amounts input[type="radio"]:checked').val() !== 'Other' || $('#frb-amt-other-input').val().length > 0 ) {
			frb.didSelectAmount = true;
		}
		return;
	};

	$('.frb-amounts input[type="radio"]').on('change', checkAmountChange);
	$('#frb-amt-other-input').on('focusout', checkAmountChange);

	// Block typing non-numerics in input field, otherwise Safari allows them and then chokes
	// https://phabricator.wikimedia.org/T118741, https://phabricator.wikimedia.org/T173431
	var blockNonNumeric = function(e) {
		// Allow special keys in Firefox
		if ((e.code == 'ArrowLeft') || (e.code == 'ArrowRight') ||
			(e.code == 'ArrowUp') || (e.code == 'ArrowDown') ||
			(e.code == 'Delete') || (e.code == 'Backspace')) {
			return;
		}
		var chr = String.fromCharCode(e.which);
		if ("0123456789., ".indexOf(chr) === -1) {
			return false;
		}
	};
	$('#frb-amt-other-input').on('keypress', blockNonNumeric);
	$('#frb-amt-monthly-other-input').on('keypress', blockNonNumeric);

};

/**
 * Calculate approximate transaction fee on given amount
 *
 * @param  {number} amount
 * @return {number}        Rounded to 2 decimal places
 */
frb.calculateFee = function(amount) {
	var currency = frb.getCurrency(mw.centralNotice.data.country),
		feeMultiplier = 0.04,
		feeMinimum = frb.amounts.feeMinimums[currency] || 0.35,
		feeAmount = amount * feeMultiplier;

	if ( feeAmount < feeMinimum ) {
	  feeAmount = feeMinimum;
	}
	return parseFloat(feeAmount.toFixed(2));
};

frb.updateFeeDisplay = function() {
	var currency = frb.getCurrency(mw.centralNotice.data.country),
		language = mw.centralNotice.data.uselang,
		amount, feeAmount, totalAmount;

	amount = frb.getAmount();
	feeAmount = frb.calculateFee(amount);
	if ( $('#frb-ptf-checkbox').prop('checked') ) {
		totalAmount = amount + feeAmount;
	} else {
		totalAmount = amount;
	}

	var feeAmountFormatted = frb.formatCurrency(currency, feeAmount, language);
	$('.frb-ptf-fee').text(feeAmountFormatted);

	var totalAmountFormatted = frb.formatCurrency(currency, totalAmount, language);
	$('.frb-ptf-total').text(totalAmountFormatted);

	$('.frb-ptf').slideDown( frb.reduceMotion ? 0 : 400 );
};

/**
 * Custom hide cookie function
 *
 * Purposely sets only for this domain.
 * CentralNotice builtin method seems buggy - see T270401
 *
 * @param {string} reason Reason to store in the hide cookie
 * @param {number} duration Cookie duration, in seconds
 */
frb.altSetHideCookie = function ( reason, duration ) {

	mw.loader.using( 'mediawiki.cookie' ).then( function () {

		var cookieName = 'centralnotice_hide_fundraising',
			date = new Date(),
			hideData = {
				v: 1,
				created: Math.floor( date.getTime() / 1000 ),
				reason: reason
			};

		// Re-use the same date object to set the cookie's expiry time
		date.setSeconds( date.getSeconds() + duration );

		mw.cookie.set(
			cookieName,
			JSON.stringify( hideData ),
			{ expires: date, path: '/', domain: 'wikipedia.org', prefix: '' }
		);

	});

};

frb.showDonateLinkTooltip = function ( content ) {
	try {
		mw.loader.using( [ 'oojs-ui-core' ] ).done( function () {

			let $donateLink = $( '#pt-sitesupport-2 a, #pt-sitesupport a, #n-sitesupport a, #p-donation a' );
			$donateLink.attr( 'href', ( i, oldUrl ) => {
				let url = new URL( oldUrl, 'https://donate.wikimedia.org' ); // base needed because some links are protocol relative
				url.searchParams.delete( 'utm_source' ); // Until we have updated sidebar links
				url.searchParams.set( 'wmf_source', 'tooltipOnBannerClose' );
				return url.toString();
			});

			let popupOptions = {
				$content: $( '<p>' + content + '</p>' ),
				padded: true,
				autoclose: true,
				align: 'forwards',
				autoFlip: false
			};

			if ( document.querySelector( '#p-donation a' ) ) {
				// Minerva
				popupOptions.$floatableContainer = $( '.navigation-drawer' );
				popupOptions.position = 'below';
			} else if ( $( '#pt-sitesupport-2 a:visible' ).length > 0 ) {
				// Vector 2022 user tools
				popupOptions.$floatableContainer = $( '#pt-sitesupport-2 a' );
				popupOptions.position = 'below';
			} else if ( document.querySelector( '#pt-sitesupport a' ) ) {
				// Vector 2022 user tools collapsed in menu
				popupOptions.$floatableContainer = $( '#vector-user-links-dropdown' );
				popupOptions.position = 'below';
			} else if ( document.querySelector( '#vector-main-menu-dropdown #n-sitesupport a') ) {
				// Vector 2022 main menu (only when logged in, so mostly here for testing)
				popupOptions.$floatableContainer = $( '#vector-main-menu-dropdown' );
				popupOptions.position = 'below';
			} else if ( document.querySelector( '#n-sitesupport a' ) ) {
				// Legacy Vector (sidebar)
				popupOptions.$floatableContainer = $( '#n-sitesupport a' );
				popupOptions.position = 'after';
			}

			let popup = new OO.ui.PopupWidget( popupOptions );

			popup.$element.css('z-index', 5); // Fix so it shows above header
			$( document.body ).append( popup.$element );
			popup.toggle( true );

			setTimeout( () => {
				popup.$element.fadeOut( frb.fadeDuration );
			}, 5000 );
		} );
	} catch (e) {
		console.log('Problem showing banner close tooltip');
	}
};

frb.showSidebarTooltip = frb.showDonateLinkTooltip; // Alias for old name

frb.isDarkMode = function() {
	let rootClasses = document.documentElement.classList,
		osDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
	return rootClasses.contains( 'skin-theme-clientpref-night' ) ||
		( rootClasses.contains( 'skin-theme-clientpref-os' ) && osDark );
};

/**
 * Determine if banner should be shown, and set correct data for impression logging
 *
 * @return {boolean} Show banner?
 */
frb.shouldShowBanner = function() {

	mw.centralNotice.bannerData.hideResult = false;

	/* Hide in unsupported browsers */
	if ( !frb.supportedBrowser ) {
		mw.centralNotice.bannerData.hideResult = true;
		mw.centralNotice.bannerData.hideReason = 'browser';
	}

	/* Hide outside main namespace (except Main Page, for sites where it isn't in main namespace) */
	if ( mw.config.get('wgNamespaceNumber') > 0 && !mw.config.get('wgIsMainPage') ) {
		mw.centralNotice.bannerData.hideResult = true;
		mw.centralNotice.bannerData.hideReason = 'namespace';
	}

	// Hide banner on sensitive articles
	// TODO - possibly add wgWikibaseItemId for multilingual support and resilience to moves?
	var hideTitles = [
		'Murder of Don Banfield',
		'Asian News International',
		'Asian News International vs. Wikimedia Foundation'
	];
	var pageTitle = mw.config.get('wgTitle');
	if (
		hideTitles.indexOf( pageTitle ) !== -1
	) {
		mw.centralNotice.bannerData.hideResult = true;
		mw.centralNotice.bannerData.hideReason = 'article';
	}

	/* Hide banner if on wrong site (desktop/mobile) in case wrong device settings were chosen */
	var bannerName = mw.centralNotice.data.banner,
		skin = mw.config.get('skin'),
		siteName = mw.config.get('wgSiteName');
	if (
		 ( bannerName.indexOf('_dsk_') !== -1 && skin === 'minerva' ) ||
		 ( bannerName.indexOf('_m_') !== -1 && skin !== 'minerva' ) ||
		 skin === 'wikimediaapiportal' || // workaround for T270308
		 siteName === 'Wikitech'
	) {
		mw.centralNotice.bannerData.hideResult = true;
		mw.centralNotice.bannerData.hideReason = 'other';
		console.warn('Hiding fundraising banner on wrong site (desktop/mobile)');
	}

	return !mw.centralNotice.bannerData.hideResult;

};

/* Debug function to highlight dynamically replaced elements */
frb.highlightReplacements = function() {
	$('.frb [class^="frb-replace"], .frb-ptf-fee, .frb-ptf-total, .frb-upsell-ask, frb-amt').css('background-color', '#fa0');
};

if ( !mw.centralNotice.adminUi ) { // T262693
	/**
	 * Provides alterImpressionData hook for CentralNotice
	 * This info will be sent back with Special:RecordImpression
	 * TODO: check if/when we can remove this (and RecordImpression)
	 */
	mediaWiki.centralNotice.bannerData.alterImpressionData = function( impressionData ) {
		// Returning true from this function indicates the banner was shown
		if (mediaWiki.centralNotice.bannerData.hideReason) {
			impressionData.reason = mediaWiki.centralNotice.bannerData.hideReason;
		}
		if (mediaWiki.centralNotice.bannerData.cookieCount) {
			impressionData.banner_count = mediaWiki.centralNotice.bannerData.cookieCount;
		}

		return !mediaWiki.centralNotice.bannerData.hideResult;
	};
}

/* End of MediaWiki:FundraisingBanners/CoreJS-2018.js */