| @@ -302,11 +302,11 @@ | ||
| 302 | 302 | if ( ! buttonsAreEnabled || isRecurring ) { |
| 303 | 303 | return; |
| 304 | 304 | } |
| 305 | 305 | |
| 306 | - // Skip (and avoid the SDK wait) when Google Pay / Apple Pay were not enqueued | |
| 307 | - // server-side, e.g. via the frm_include_google_pay_apple_pay filter or non-SSL. | |
| 308 | - if ( ! frmPayPalVars.includeGooglePayApplePay ) { | |
| 306 | + // Skip (and avoid the SDK wait) when neither wallet was enqueued server-side, | |
| 307 | + // e.g. via the frm_include_google_pay_apple_pay filter or non-SSL. | |
| 308 | + if ( ! frmPayPalVars.includeGooglePay && ! frmPayPalVars.includeApplePay ) { | |
| 309 | 309 | return; |
| 310 | 310 | } |
| 311 | 311 | |
| 312 | 312 | // Resolve both eligibility checks in parallel so the combined wait is bounded by |
| @@ -311,12 +311,14 @@ | ||
| 311 | 311 | |
| 312 | 312 | // Resolve both eligibility checks in parallel so the combined wait is bounded by |
| 313 | 313 | // the slower of the two, then register them in a fixed order (Google Pay, then |
| 314 | 314 | // Apple Pay). Registration happens before the selector is built, so they render |
| 315 | - // together with the other methods. | |
| 315 | + // together with the other methods. A wallet turned off server-side, e.g. via | |
| 316 | + // frm_paypal_commerce_include_google_pay or frm_paypal_commerce_include_apple_pay, | |
| 317 | + // skips its check so it never waits on an SDK that was never enqueued. | |
| 316 | 318 | const [ googlePayEligible, applePayEligible ] = await Promise.all( [ |
| 317 | - resolveGooglePayEligibility(), | |
| 318 | - resolveApplePayEligibility() | |
| 319 | + frmPayPalVars.includeGooglePay ? resolveGooglePayEligibility() : false, | |
| 320 | + frmPayPalVars.includeApplePay ? resolveApplePayEligibility() : false | |
| 319 | 321 | ] ); |
| 320 | 322 | |
| 321 | 323 | if ( googlePayEligible ) { |
| 322 | 324 | registerMethod( 'google_pay', { |
| @@ -919,9 +921,9 @@ | ||
| 919 | 921 | * @return {google.payments.api.PaymentsClient} The payments client instance. |
| 920 | 922 | */ |
| 921 | 923 | function getGooglePaymentsClient() { |
| 922 | 924 | return new google.payments.api.PaymentsClient( { |
| 923 | - environment: 'TEST', | |
| 925 | + environment: frmPayPalVars.mode === 'test' ? 'TEST' : 'PRODUCTION', | |
| 924 | 926 | paymentDataCallbacks: { |
| 925 | 927 | onPaymentAuthorized |
| 926 | 928 | } |
| 927 | 929 | } ); |
| @@ -1567,9 +1569,13 @@ | ||
| 1567 | 1569 | return fallback; |
| 1568 | 1570 | } |
| 1569 | 1571 | |
| 1570 | 1572 | if ( 'string' === typeof err ) { |
| 1571 | - return parsePayPalErrorString( err ) || err; | |
| 1573 | + const parsed = parsePayPalErrorString( err ); | |
| 1574 | + if ( parsed ) { | |
| 1575 | + return parsed; | |
| 1576 | + } | |
| 1577 | + return mapPayPalErrorCode( err ) || err; | |
| 1572 | 1578 | } |
| 1573 | 1579 | |
| 1574 | 1580 | // PayPal SDK sometimes nests the payload under `err.data` or `err.response`. |
| 1575 | 1581 | const payloads = [ err, err.data, err.response ].filter( Boolean ); |
| @@ -1580,9 +1586,13 @@ | ||
| 1580 | 1586 | } |
| 1581 | 1587 | } |
| 1582 | 1588 | |
| 1583 | 1589 | if ( err.message ) { |
| 1584 | - return parsePayPalErrorString( err.message ) || err.message; | |
| 1590 | + const parsed = parsePayPalErrorString( err.message ); | |
| 1591 | + if ( parsed ) { | |
| 1592 | + return parsed; | |
| 1593 | + } | |
| 1594 | + return mapPayPalErrorCode( err.message ) || err.message; | |
| 1585 | 1595 | } |
| 1586 | 1596 | |
| 1587 | 1597 | return fallback; |
| 1588 | 1598 | } |
| @@ -1587,8 +1597,25 @@ | ||
| 1587 | 1597 | return fallback; |
| 1588 | 1598 | } |
| 1589 | 1599 | |
| 1590 | 1600 | /** |
| 1601 | + * Map PayPal error codes to user-friendly messages. | |
| 1602 | + * | |
| 1603 | + * @param {string} code The PayPal error code (e.g. INVALID_CVV). | |
| 1604 | + * @return {string} The user-friendly message, or empty string if not mapped. | |
| 1605 | + */ | |
| 1606 | + function mapPayPalErrorCode( code ) { | |
| 1607 | + const codeMap = { | |
| 1608 | + INVALID_CVV: 'Please enter a valid CVV code.', | |
| 1609 | + INVALID_CARD_NUMBER: 'Please enter a valid card number.', | |
| 1610 | + INVALID_EXPIRY: 'Please enter a valid expiry date.', | |
| 1611 | + }; | |
| 1612 | + | |
| 1613 | + const upperCode = code.toUpperCase(); | |
| 1614 | + return codeMap[ upperCode ] || ''; | |
| 1615 | + } | |
| 1616 | + | |
| 1617 | + /** | |
| 1591 | 1618 | * Extract the first `description` from a PayPal `details` array. |
| 1592 | 1619 | * |
| 1593 | 1620 | * @param {Array} details The PayPal error details array. |
| 1594 | 1621 | * @return {string} The description, or an empty string if none. |
| @@ -1752,21 +1779,13 @@ | ||
| 1752 | 1779 | if ( meta.name ) { |
| 1753 | 1780 | submitArgs.cardholderName = meta.name; |
| 1754 | 1781 | } |
| 1755 | 1782 | |
| 1756 | - /* | |
| 1757 | - TODO Add the billing address here as well. | |
| 1758 | - Stripe calls a window.frmProForm.addAddressMeta function. | |
| 1759 | - That's included in frmstrp.js though, so we need to add a script in Pro for PayPal as well. | |
| 1783 | + const billingAddress = getBillingAddress(); | |
| 1760 | 1784 | |
| 1761 | - billingAddress: { | |
| 1762 | - addressLine1: '555 Billing Ave', | |
| 1763 | - adminArea1: 'NY', | |
| 1764 | - adminArea2: 'New York', | |
| 1765 | - postalCode: '10001', | |
| 1766 | - countryCode: 'US' | |
| 1785 | + if ( billingAddress ) { | |
| 1786 | + submitArgs.billingAddress = billingAddress; | |
| 1767 | 1787 | } |
| 1768 | - */ | |
| 1769 | 1788 | |
| 1770 | 1789 | try { |
| 1771 | 1790 | await cardFieldsInstance.submit( submitArgs ); |
| 1772 | 1791 | } catch ( err ) { |
| @@ -1776,8 +1795,101 @@ | ||
| 1776 | 1795 | enableSubmit(); |
| 1777 | 1796 | } |
| 1778 | 1797 | reportErrorToServer( err, 'card_submit' ); |
| 1779 | 1798 | } |
| 1799 | + } | |
| 1800 | + | |
| 1801 | + /** | |
| 1802 | + * Build the billing address for the card fields from the address field mapped in the payment action. | |
| 1803 | + * | |
| 1804 | + * @since 6.34 | |
| 1805 | + * | |
| 1806 | + * @return {Object|null} Billing address for the card fields submit args, or null when no address is available. | |
| 1807 | + */ | |
| 1808 | + function getBillingAddress() { | |
| 1809 | + let addressID = ''; | |
| 1810 | + | |
| 1811 | + getPayPalSettings().forEach( function( setting ) { | |
| 1812 | + if ( setting.address ) { | |
| 1813 | + addressID = setting.address; | |
| 1814 | + } | |
| 1815 | + } ); | |
| 1816 | + | |
| 1817 | + if ( '' === addressID ) { | |
| 1818 | + return null; | |
| 1819 | + } | |
| 1820 | + | |
| 1821 | + let prefix = ''; | |
| 1822 | + let addressContainer = document.querySelector( `#frm_field_${ addressID }_container, .frm_field_${ addressID }_container` ); | |
| 1823 | + | |
| 1824 | + if ( ! addressContainer ) { | |
| 1825 | + const line1Input = document.querySelector( `input[name="item_meta[${ addressID }][line1]"]` ); | |
| 1826 | + if ( line1Input ) { | |
| 1827 | + prefix = `${ addressID }][`; | |
| 1828 | + addressContainer = line1Input.parentNode; | |
| 1829 | + } | |
| 1830 | + } | |
| 1831 | + | |
| 1832 | + if ( ! addressContainer ) { | |
| 1833 | + return null; | |
| 1834 | + } | |
| 1835 | + | |
| 1836 | + const getSubFieldValue = function( name ) { | |
| 1837 | + const input = addressContainer.querySelector( `input[name$="[${ prefix }${ name }]"], select[name$="[${ prefix }${ name }]"]` ); | |
| 1838 | + return input?.value ? input.value : ''; | |
| 1839 | + }; | |
| 1840 | + | |
| 1841 | + const subFieldMapping = { | |
| 1842 | + line1: 'addressLine1', | |
| 1843 | + line2: 'addressLine2', | |
| 1844 | + city: 'adminArea2', | |
| 1845 | + state: 'adminArea1', | |
| 1846 | + zip: 'postalCode' | |
| 1847 | + }; | |
| 1848 | + | |
| 1849 | + const billingAddress = {}; | |
| 1850 | + | |
| 1851 | + Object.keys( subFieldMapping ).forEach( function( name ) { | |
| 1852 | + const value = getSubFieldValue( name ); | |
| 1853 | + if ( value ) { | |
| 1854 | + billingAddress[ subFieldMapping[ name ] ] = value; | |
| 1855 | + } | |
| 1856 | + } ); | |
| 1857 | + | |
| 1858 | + if ( ! billingAddress.addressLine1 ) { | |
| 1859 | + return null; | |
| 1860 | + } | |
| 1861 | + | |
| 1862 | + const countryCode = getCountryCode( addressContainer, prefix ); | |
| 1863 | + | |
| 1864 | + if ( countryCode ) { | |
| 1865 | + billingAddress.countryCode = countryCode; | |
| 1866 | + } | |
| 1867 | + | |
| 1868 | + return billingAddress; | |
| 1869 | + } | |
| 1870 | + | |
| 1871 | + /** | |
| 1872 | + * Get the two letter country code for the filled address. | |
| 1873 | + * The country dropdown holds the code in a data-code attribute. | |
| 1874 | + * US type address fields have a state dropdown and no country field, so US is assumed for them. | |
| 1875 | + * | |
| 1876 | + * @since 6.34 | |
| 1877 | + * | |
| 1878 | + * @param {Element} addressContainer | |
| 1879 | + * @param {string} prefix | |
| 1880 | + * @return {string} Country code, or an empty string when the country is unknown. | |
| 1881 | + */ | |
| 1882 | + function getCountryCode( addressContainer, prefix ) { | |
| 1883 | + const countryDropdown = addressContainer.querySelector( `select[name$="[${ prefix }country]"]` ); | |
| 1884 | + | |
| 1885 | + if ( countryDropdown ) { | |
| 1886 | + const countryOption = countryDropdown.querySelector( `option[value="${ countryDropdown.value }"]` ); | |
| 1887 | + return countryOption?.getAttribute( 'data-code' ) || ''; | |
| 1888 | + } | |
| 1889 | + | |
| 1890 | + const stateDropdown = addressContainer.querySelector( `select[name$="[${ prefix }state]"]` ); | |
| 1891 | + return stateDropdown ? 'US' : ''; | |
| 1780 | 1892 | } |
| 1781 | 1893 | |
| 1782 | 1894 | // ---- Price / Pay Later ---- |
| 1783 | 1895 | |