diff --git a/.changeset/few-deers-agree.md b/.changeset/few-deers-agree.md new file mode 100644 index 00000000..5503c53b --- /dev/null +++ b/.changeset/few-deers-agree.md @@ -0,0 +1,5 @@ +--- +"@razorpay/i18nify-js": minor +--- + +add new api for masked contact number in phone number module diff --git a/packages/i18nify-js/README.md b/packages/i18nify-js/README.md index 21b8923f..e1f5b317 100644 --- a/packages/i18nify-js/README.md +++ b/packages/i18nify-js/README.md @@ -96,26 +96,26 @@ console.log(convertToMinorUnit(50, { currency: 'GBP' })); // Converts 50 pounds ##### Examples -``` -console.log(formatNumber("1000.5", { currency: "USD" })); // $1,000.50 +```javascript +console.log(formatNumber('1000.5', { currency: 'USD' })); // $1,000.50 console.log( - formatNumber("1500", { - currency: "EUR", - locale: "fr-FR", + formatNumber('1500', { + currency: 'EUR', + locale: 'fr-FR', intlOptions: { - currencyDisplay: "code", + currencyDisplay: 'code', }, - }) + }), ); // 1 500,00 EUR console.log( - formatNumber("5000", { - currency: "JPY", + formatNumber('5000', { + currency: 'JPY', intlOptions: { - currencyDisplay: "narrowSymbol", + currencyDisplay: 'narrowSymbol', }, - }) + }), ); // ¥5,000 ``` @@ -125,7 +125,7 @@ console.log( ##### Examples -``` +```javascript console.log(getCurrencyList()); /* { AED: { symbol: 'د.إ', @@ -162,7 +162,7 @@ Picture this: it's like having a cool decoder ring for currency codes! 🔍💰 ##### Examples -``` +```javascript console.log(getCurrencySymbol('USD')); // $ console.log(getCurrencySymbol('UZS')); // so'm @@ -176,12 +176,12 @@ This slick function breaks down numbers into separate pieces using Intl.NumberFo ##### Examples -``` +```javascript console.log( formatNumberByParts(12345.67, { - currency: "USD", - locale: "en-US", - }) + currency: 'USD', + locale: 'en-US', + }), ); /* { "currency": "$", "integer": "12,345", @@ -218,9 +218,9 @@ console.log( console.log( formatNumberByParts(12345.67, { - currency: "XYZ", - locale: "en-US", - }) + currency: 'XYZ', + locale: 'en-US', + }), ); /* { "currency": "XYZ", "integer": "12,345", @@ -261,9 +261,9 @@ console.log( console.log( formatNumberByParts(12345.67, { - currency: "EUR", - locale: "fr-FR", - }) + currency: 'EUR', + locale: 'fr-FR', + }), ); /* { "integer": "12 345", "decimal": ",", @@ -304,9 +304,9 @@ console.log( console.log( formatNumberByParts(12345.67, { - currency: "JPY", - locale: "ja-JP", - }) + currency: 'JPY', + locale: 'ja-JP', + }), ); /* { "currency": "¥", "integer": "12,346", @@ -333,9 +333,9 @@ console.log( console.log( formatNumberByParts(12345.67, { - currency: "OMR", - locale: "ar-OM", - }) + currency: 'OMR', + locale: 'ar-OM', + }), ); /* { "integer": "١٢٬٣٤٥", "decimal": "٫", @@ -393,26 +393,26 @@ This module's your phone's best friend, handling all things phone number-related ##### Examples -``` ---> Basic Validation +```javascript +// Basic Validation console.log(isValidPhoneNumber('+14155552671')); // true ---> Specifying Country Code for Validation +// Specifying Country Code for Validation console.log(isValidPhoneNumber('0501234567', 'AE')); // true ---> Auto-Detecting Country Code +// Auto-Detecting Country Code console.log(isValidPhoneNumber('+447700900123')); // true ---> Handling Invalid Numbers +// Handling Invalid Numbers console.log(isValidPhoneNumber('123456789', 'US')); // false ---> Invalid Country Code +// Invalid Country Code console.log(isValidPhoneNumber('+123456789')); // false ---> Empty Phone Number +// Empty Phone Number console.log(isValidPhoneNumber('')); // false ---> Non-Standard Formatting +// Non-Standard Formatting console.log(isValidPhoneNumber('(555) 555-5555')); // true ``` @@ -422,26 +422,26 @@ console.log(isValidPhoneNumber('(555) 555-5555')); // true ##### Examples -``` ---> Basic Formatting +```javascript +// Basic Formatting console.log(formatPhoneNumber('+14155552671')); // '+1 415-555-2671' ---> Specifying Country Code for Formatting +// Specifying Country Code for Formatting console.log(formatPhoneNumber('0501234567', 'AE')); // '050 123 4567' ---> Auto-Detecting Country Code for Formatting +// Auto-Detecting Country Code for Formatting console.log(formatPhoneNumber('+447700900123')); // '+44 7700 900123' ---> Handling Invalid Numbers for Formatting +// Handling Invalid Numbers for Formatting console.log(formatPhoneNumber('123456789', 'US')); // '123456789' ---> Invalid Country Code for Formatting +// Invalid Country Code for Formatting console.log(formatPhoneNumber('+123456789')); // '+123456789' ---> Empty Phone Number +// Empty Phone Number console.log(formatPhoneNumber('')); // Throws an Error: 'Parameter `phoneNumber` is invalid!' ---> Non-Standard Formatting +// Non-Standard Formatting console.log(formatPhoneNumber('(555) 555-5555')); // '555 555 5555' ``` @@ -451,8 +451,8 @@ console.log(formatPhoneNumber('(555) 555-5555')); // '555 555 5555' ##### Examples -``` ---> Formatting a Phone Number +```javascript +// Formatting a Phone Number const phoneNumber = '+1 (555) 123-4567'; const parsedInfo = parsePhoneNumber(phoneNumber); console.log('Country Code:', parsedInfo.countryCode); // 'US' @@ -460,16 +460,18 @@ console.log('Formatted Number:', parsedInfo.formattedPhoneNumber); // '555-123-4 console.log('Dial Code:', parsedInfo.dialCode); // '+1' console.log('Format Template:', parsedInfo.formatTemplate); // 'xxx-xxx-xxxx' ---> Parsing a Phone Number with Specified Country Code +// Parsing a Phone Number with Specified Country Code const phoneNumber = '987654321'; // Phone number without country code const countryCode = 'IN'; // Specifying the country code (India) const parsedInfo = parsePhoneNumber(phoneNumber, countryCode); console.log('Country Code:', parsedInfo.countryCode); // 'IN' console.log('Formatted Number:', parsedInfo.formattedPhoneNumber); // '98-765-4321' -console.log('Dial Code:', parsedInfo.dialCode); '' -console.log('Format Template:', parsedInfo.formatTemplate); 'xxxx xxxxxx' +console.log('Dial Code:', parsedInfo.dialCode); +(''); +console.log('Format Template:', parsedInfo.formatTemplate); +('xxxx xxxxxx'); ---> Handling Invalid Phone Numbers +// Handling Invalid Phone Numbers try { const invalidPhoneNumber = ''; // Empty phone number // This will throw an error since the phone number is empty @@ -481,7 +483,7 @@ try { console.error('Error:', error.message); // 'Parameter `phoneNumber` is invalid!' } ---> Obtaining Format Information for a Country Code +// Obtaining Format Information for a Country Code const countryCode = 'JP'; // Country code for Japan // Get the format information without providing a phone number const parsedInfo = parsePhoneNumber('', countryCode); @@ -541,6 +543,90 @@ console.log(getDialCodeByCountryCode('BR')); // Outputs the dial code for Brazil console.log(getDialCodeByCountryCode('DE')); // Outputs the dial code for Germany (+49) ``` +#### getMaskedPhoneNumber(options) + +📞🔒 The getMaskedPhoneNumber function is a versatile tool designed to handle phone number formatting and masking based on the specific requirements of different countries. This function is ideal for applications that require the display of partially hidden phone numbers for security purposes or privacy concerns. It supports a wide range of configurations, including options to mask portions of the phone number, specify the number of digits to mask, and choose whether to mask digits from the beginning or end of the number. + +##### Examples + +```javascript +// Masking a U.S. phone number completely +console.log( + getMaskedPhoneNumber({ + countryCode: 'US', + phoneNumber: '2025550125', + withDialCode: true, + }), +); +// Output: +1 xxx-xxx-xxxx + +// Partially masking an Indian phone number, hiding the last 6 digits with maskingStyle: suffix +console.log( + getMaskedPhoneNumber({ + countryCode: 'IN', + phoneNumber: '9876543210', + maskingOptions: { + maskingStyle: 'suffix', + maskedDigitsCount: 6, + maskingChar: '*', + }, + withDialCode: true, + }), +); +// Output: +91 9876 ****** + +// Partially masking an Indian phone number, hiding the first 6 digits with maskingStyle: prefix +console.log( + getMaskedPhoneNumber({ + countryCode: 'IN', + phoneNumber: '9876543210', + maskingOptions: { + maskingStyle: 'prefix', + maskedDigitsCount: 6, + maskingChar: '*', + }, + withDialCode: true, + }), +); +// Output: +91 **** 543210 + +// Partially masking an Indian phone number, hiding the first 6 digits with maskingStyle: full +console.log( + getMaskedPhoneNumber({ + countryCode: 'IN', + phoneNumber: '9876543210', + maskingOptions: { + maskingStyle: 'full', + maskingChar: '*', + }, + withDialCode: true, + }), +); +// Output: +91 **** ****** + +// Partially masking an Indian phone number, hiding the first 6 digits with maskingStyle: alternate +console.log( + getMaskedPhoneNumber({ + countryCode: 'IN', + phoneNumber: '9876543210', + maskingOptions: { + maskingStyle: 'alternate', + maskingChar: '*', + }, + withDialCode: true, + }), +); +// Output: +91 9*7* 5*3*1* + +// Formatting and completely masking a phone number for Brazil without specifying a phone number +console.log( + getMaskedPhoneNumber({ + countryCode: 'BR', + }), +); +// Output: xx xxxx-xxxx +``` + ### Module 03: Geo Module 🌍 Dive into the digital atlas with the Geo Module 🌍, your ultimate toolkit for accessing geo contextual data from around the globe 🌐. Whether you're infusing your projects with national pride 🎉 or exploring different countries 🤔, this module is like a magic carpet ride 🧞‍♂️. With a range of functions at your disposal ✨, incorporating global data 🚩 into your app has never been easier. Let's explore these global gems 🌟: diff --git a/packages/i18nify-js/src/modules/phoneNumber/__tests__/getMaskedPhoneNumber.test.ts b/packages/i18nify-js/src/modules/phoneNumber/__tests__/getMaskedPhoneNumber.test.ts new file mode 100644 index 00000000..29aadd7d --- /dev/null +++ b/packages/i18nify-js/src/modules/phoneNumber/__tests__/getMaskedPhoneNumber.test.ts @@ -0,0 +1,260 @@ +import { CountryCodeType } from '../../types'; +import { getMaskedPhoneNumber, GetMaskedPhoneNumberOptions } from '../index'; +import { MaskingStyle } from '../constants'; + +describe('phoneNumber - getMaskedPhoneNumber', () => { + it('should throw error if no countryCode and phoneNumber are provided', () => { + expect(() => + getMaskedPhoneNumber({} as GetMaskedPhoneNumberOptions), + ).toThrow('Either countryCode or phoneNumber is mandatory.'); + }); + + it('should throw error for invalid country code when phone number is not provided', () => { + expect(() => + getMaskedPhoneNumber({ countryCode: 'ZZ' as CountryCodeType }), + ).toThrow('Parameter "countryCode" is invalid: ZZ'); + }); + + it('should handle invalid country code when phone number is provided', () => { + const options = { + countryCode: 'ZZ' as CountryCodeType, + phoneNumber: '7394926646', + }; + const expected = 'xxxxxxxxxx'; + expect(getMaskedPhoneNumber(options)).toEqual(expected); + }); + + it('should return full masked phone number with dial code', () => { + const options = { + countryCode: 'US', + phoneNumber: '+12345678901', + withDialCode: true, + maskingOptions: { maskingStyle: MaskingStyle.Full }, + }; + const expected = '+1 xxx-xxx-xxxx'; + expect( + getMaskedPhoneNumber(options as GetMaskedPhoneNumberOptions), + ).toEqual(expected); + }); + + it('should return phone number masked with prefix', () => { + const options = { + countryCode: 'US', + phoneNumber: '2345678901', + withDialCode: false, + maskingOptions: { + maskingStyle: MaskingStyle.Prefix, + maskedDigitsCount: 6, + }, + }; + const expected = 'xxx-xxx-8901'; + expect( + getMaskedPhoneNumber(options as GetMaskedPhoneNumberOptions), + ).toEqual(expected); + }); + + it('should return phone number masked with suffix', () => { + const options = { + countryCode: 'US', + phoneNumber: '2345678901', + withDialCode: false, + maskingOptions: { + maskingStyle: MaskingStyle.Suffix, + maskedDigitsCount: 6, + }, + }; + const expected = '234-5xx-xxxx'; + expect( + getMaskedPhoneNumber(options as GetMaskedPhoneNumberOptions), + ).toEqual(expected); + }); + + it('should handle alternate masking of digits', () => { + const options = { + countryCode: 'US', + phoneNumber: '2345678901', + withDialCode: false, + maskingOptions: { + maskingStyle: MaskingStyle.Alternate, + maskingChar: '*', + }, + }; + const expected = '2*4*6*8*0*'; + expect( + getMaskedPhoneNumber(options as GetMaskedPhoneNumberOptions), + ).toEqual(expected); + }); + + it('should handle masking with just countryCode', () => { + const options = { + countryCode: 'US', + withDialCode: true, + }; + const expected = '+1 xxx-xxx-xxxx'; + expect( + getMaskedPhoneNumber(options as GetMaskedPhoneNumberOptions), + ).toEqual(expected); + }); + + it('should return phone number with full masking and dial code when maskingChar is #', () => { + const options = { + countryCode: 'US', + phoneNumber: '2345678901', + withDialCode: true, + maskingOptions: { maskingStyle: MaskingStyle.Full, maskingChar: '#' }, + }; + const expected = '+1 ###-###-####'; + expect( + getMaskedPhoneNumber(options as GetMaskedPhoneNumberOptions), + ).toEqual(expected); + }); + + it('should return formatted phone number with complete masking when no masking options provided', () => { + const options = { + countryCode: 'US', + phoneNumber: '2345678901', + withDialCode: true, + }; + const expected = '+1 xxx-xxx-xxxx'; + expect( + getMaskedPhoneNumber(options as GetMaskedPhoneNumberOptions), + ).toEqual(expected); + }); + + it('should handle input with non-numeric characters in phoneNumber', () => { + const options = { + countryCode: 'US', + phoneNumber: '+1 (234) 567-8901', + withDialCode: false, + maskingOptions: { maskingStyle: MaskingStyle.Full, maskingChar: '*' }, + }; + const expected = '***-***-****'; + expect( + getMaskedPhoneNumber(options as GetMaskedPhoneNumberOptions), + ).toEqual(expected); + }); + + it('should perform complete masking if maskedDigitsCount is larger than phoneNumber length', () => { + const options = { + countryCode: 'US', + phoneNumber: '12345', + withDialCode: false, + maskingOptions: { + maskingStyle: MaskingStyle.Suffix, + maskedDigitsCount: 10, + }, + }; + const expected = 'xxx-xxx-xxxx'; + expect( + getMaskedPhoneNumber(options as GetMaskedPhoneNumberOptions), + ).toEqual(expected); + }); + + describe('should mask with just phone number (without dialcode) without countryCode', () => { + const phoneNumber = '7394926646'; + const testCases = [ + { + options: { + phoneNumber, + maskingOptions: { + maskingStyle: MaskingStyle.Prefix, + maskedDigitsCount: 4, + }, + }, + expected: 'xxxx926646', + description: 'Prefix style', + }, + { + options: { + phoneNumber, + maskingOptions: { + maskingStyle: MaskingStyle.Suffix, + maskedDigitsCount: 4, + }, + }, + expected: '739492xxxx', + description: 'Suffix style', + }, + { + options: { + phoneNumber, + maskingOptions: { + maskingStyle: MaskingStyle.Alternate, + }, + }, + expected: '7x9x9x6x4x', + description: 'Alternate style', + }, + { + options: { + phoneNumber, + maskingOptions: { + maskingStyle: MaskingStyle.Full, + }, + }, + expected: 'xxxxxxxxxx', + description: 'Full style', + }, + ]; + + test.each(testCases)('$description', ({ options, expected }) => { + expect( + getMaskedPhoneNumber(options as GetMaskedPhoneNumberOptions), + ).toEqual(expected); + }); + }); + + describe('should mask with just phone number (with dialcode) without countryCode', () => { + const phoneNumber = '+91 7394926646'; + const testCases = [ + { + options: { + phoneNumber, + maskingOptions: { + maskingStyle: MaskingStyle.Prefix, + maskedDigitsCount: 4, + }, + }, + expected: '+91 xxxx 926646', + description: 'Prefix style', + }, + { + options: { + phoneNumber, + maskingOptions: { + maskingStyle: MaskingStyle.Suffix, + maskedDigitsCount: 4, + }, + }, + expected: '+91 7394 92xxxx', + description: 'Suffix style', + }, + { + options: { + phoneNumber, + maskingOptions: { + maskingStyle: MaskingStyle.Alternate, + }, + }, + expected: '+91 7x9x9x6x4x', + description: 'Alternate style', + }, + { + options: { + phoneNumber, + maskingOptions: { + maskingStyle: MaskingStyle.Full, + }, + }, + expected: '+91 xxxx xxxxxx', + description: 'Full style', + }, + ]; + + test.each(testCases)('$description', ({ options, expected }) => { + expect( + getMaskedPhoneNumber(options as GetMaskedPhoneNumberOptions), + ).toEqual(expected); + }); + }); +}); diff --git a/packages/i18nify-js/src/modules/phoneNumber/__tests__/prefixMasking.test.ts b/packages/i18nify-js/src/modules/phoneNumber/__tests__/prefixMasking.test.ts new file mode 100644 index 00000000..1c94b147 --- /dev/null +++ b/packages/i18nify-js/src/modules/phoneNumber/__tests__/prefixMasking.test.ts @@ -0,0 +1,34 @@ +import { prefixMasking } from '../utils'; + +describe('phone number - utils - prefixMasking', () => { + test("replaces the last N x's with characters from the replacement string", () => { + expect(prefixMasking('xxxxxx', 'abc', 3)).toBe('xxxabc'); + }); + + test.each([ + ['no xs present', 'hello', 'a', 2, 'hello'], + ['replacement string shorter than count of xs', 'xxxxx', 'ab', 5, 'xxxab'], + ['replacement string longer than needed', 'xxxx', 'abcdef', 2, 'xxef'], + ['empty source string', '', 'abc', 3, ''], + ['empty replacement string', 'xxxx', '', 3, 'xxxx'], + ['count n is zero', 'xxxx', 'abc', 0, 'xxxx'], + ])('%s', (_, source, replacement, n, expected) => { + expect(prefixMasking(source, replacement, n)).toBe(expected); + }); + + test("replaces nothing when n is greater than the number of x's", () => { + expect(prefixMasking('xx', 'abc', 5)).toBe('bc'); + }); + + test('handles strings with mixed characters', () => { + expect(prefixMasking('ax1x2x3x', 'xyz', 3)).toBe('ax1x2y3z'); + }); + + test('negative n is treated as zero', () => { + expect(prefixMasking('xxxx', 'abc', -1)).toBe('xxxx'); + }); + + test('n larger than both strings only replaces up to the last available xs', () => { + expect(prefixMasking('xxyyxx', 'abc', 10)).toBe('xayybc'); + }); +}); diff --git a/packages/i18nify-js/src/modules/phoneNumber/__tests__/suffixMasking.test.ts b/packages/i18nify-js/src/modules/phoneNumber/__tests__/suffixMasking.test.ts new file mode 100644 index 00000000..4fea6a31 --- /dev/null +++ b/packages/i18nify-js/src/modules/phoneNumber/__tests__/suffixMasking.test.ts @@ -0,0 +1,40 @@ +import { suffixMasking } from '../utils'; + +describe('phone number - utils - suffixMasking', () => { + test("replaces the first N x's with characters from the replacement string", () => { + expect(suffixMasking('xxxxxx', 'abc', 3)).toBe('abcxxx'); + }); + + test.each([ + ['no xs present', 'hello', 'a', 2, 'hello'], + ['replacement string shorter than count of xs', 'xxxxx', 'ab', 5, 'abxxx'], + [ + 'replacement string longer than needed', + 'xxxx', + 'abcdef', + 2, + 'abxx'.slice(0, 4), + ], + ['empty source string', '', 'abc', 3, ''], + ['empty replacement string', 'xxxx', '', 3, 'xxxx'], + ['count n is zero', 'xxxx', 'abc', 0, 'xxxx'], + ])('%s', (_, source, replacement, n, expected) => { + expect(suffixMasking(source, replacement, n)).toBe(expected); + }); + + test("replaces nothing when n is greater than the number of x's", () => { + expect(suffixMasking('xx', 'abc', 5)).toBe('abcx'.slice(0, 2)); + }); + + test('handles strings with mixed characters', () => { + expect(suffixMasking('ax1x2x3x', 'xyz', 3)).toBe('ax1y2z3x'); + }); + + test('negative n is treated as zero', () => { + expect(suffixMasking('xxxx', 'abc', -1)).toBe('xxxx'); + }); + + test('n larger than both strings only replaces up to the shortest', () => { + expect(suffixMasking('xxyyxx', 'abc', 10)).toBe('abyycx'); + }); +}); diff --git a/packages/i18nify-js/src/modules/phoneNumber/constants.ts b/packages/i18nify-js/src/modules/phoneNumber/constants.ts new file mode 100644 index 00000000..b82d3e85 --- /dev/null +++ b/packages/i18nify-js/src/modules/phoneNumber/constants.ts @@ -0,0 +1,6 @@ +export enum MaskingStyle { + Full = 'full', + Prefix = 'prefix', + Suffix = 'suffix', + Alternate = 'alternate', +} diff --git a/packages/i18nify-js/src/modules/phoneNumber/getMaskedPhoneNumber.ts b/packages/i18nify-js/src/modules/phoneNumber/getMaskedPhoneNumber.ts new file mode 100644 index 00000000..ef53d721 --- /dev/null +++ b/packages/i18nify-js/src/modules/phoneNumber/getMaskedPhoneNumber.ts @@ -0,0 +1,115 @@ +import getDialCodeByCountryCode from './getDialCodeByCountryCode'; +import { withErrorBoundary } from '../../common/errorBoundary'; +import PHONE_FORMATTER_MAPPER from './data/phoneFormatterMapper.json'; +import { + cleanPhoneNumber, + detectCountryAndDialCodeFromPhone, + suffixMasking, + prefixMasking, + alternateMasking, +} from './utils'; +import { GetMaskedPhoneNumberOptions } from './types'; +import { MaskingStyle } from './constants'; + +/** + * Generates a masked phone number based on provided options. + * This function handles the complexity of different phone number formats and + * masking preferences such as complete masking or partial masking of digits. + * + * @param {GetMaskedPhoneNumberOptions} options - Options for generating the masked phone number. + * @param {CountryCodeType} options.countryCode - The country code associated with the phone number. + * @param {boolean} options.withDialCode - Determines if the dial code should be included in the masked number. + * @param {string} options.phoneNumber - The actual phone number to mask. + * @param {MaskingOptions} options.maskingOptions - Options to specify how the masking should be performed. + * @returns {string} The masked phone number formatted as per the specified options. + * @throws {Error} Throws an error if both countryCode and phoneNumber are empty or if other input validations fail. + */ +const getMaskedPhoneNumber = ({ + countryCode, + withDialCode = true, + phoneNumber, + maskingOptions = {}, +}: GetMaskedPhoneNumberOptions) => { + const { + maskingStyle = MaskingStyle.Full, + maskedDigitsCount = 0, + maskingChar = 'x', + } = maskingOptions; + + if (!countryCode && !phoneNumber) { + throw new Error('Either countryCode or phoneNumber is mandatory.'); + } + + let maskedContactNumber: string; + let dialCode: string; + + if (phoneNumber) { + // Clean the phone number to remove any non-numeric characters, except the leading '+' + let updatedPhoneNumber = phoneNumber; + updatedPhoneNumber = updatedPhoneNumber.toString(); + updatedPhoneNumber = cleanPhoneNumber(updatedPhoneNumber); + + // Detect the country code and dial code from the cleaned phone number + const countryData = detectCountryAndDialCodeFromPhone(updatedPhoneNumber); + const updatedCountryCode = countryCode || countryData.countryCode; + try { + dialCode = getDialCodeByCountryCode(updatedCountryCode); + } catch (error) { + dialCode = countryData.dialCode; + } + + // Extract the phone number without dial code + const phoneNumberWithoutDialCode = + updatedPhoneNumber[0] === '+' + ? updatedPhoneNumber.slice(dialCode.toString().length) + : updatedPhoneNumber; + + // Get the phone number formatting template based on the country code + let formattingTemplate = + PHONE_FORMATTER_MAPPER[updatedCountryCode] || + phoneNumber.replace(/\d/g, 'x'); + + switch (maskingStyle) { + case MaskingStyle.Alternate: + // Example: 7394926646 --> 7x9x9x6x4x + maskedContactNumber = alternateMasking(phoneNumberWithoutDialCode); + break; + case MaskingStyle.Prefix: + // Example: 7394926646 --> xxxx 926646 + maskedContactNumber = prefixMasking( + formattingTemplate, + String(phoneNumberWithoutDialCode), + phoneNumberWithoutDialCode.length - maskedDigitsCount, + ); + break; + case MaskingStyle.Suffix: + // Example: 7394926646 --> 7494 92xxxx + maskedContactNumber = suffixMasking( + formattingTemplate, + String(phoneNumberWithoutDialCode), + phoneNumberWithoutDialCode.length - maskedDigitsCount, + ); + break; + default: // Full Masking Condition + maskedContactNumber = formattingTemplate; + } + } else { + // Retrieve the phone number formatting template using the country code + maskedContactNumber = PHONE_FORMATTER_MAPPER[countryCode]; + if (!maskedContactNumber) { + throw new Error(`Parameter "countryCode" is invalid: ${countryCode}`); + } + dialCode = getDialCodeByCountryCode(countryCode); + } + + // Include the dial code in the masked phone number if requested + if (withDialCode) { + return `${dialCode} ${maskedContactNumber.replace(/x/g, maskingChar)}`.trim(); + } else { + return maskedContactNumber.trim().replace(/x/g, maskingChar); + } +}; + +export default withErrorBoundary( + getMaskedPhoneNumber, +); diff --git a/packages/i18nify-js/src/modules/phoneNumber/index.ts b/packages/i18nify-js/src/modules/phoneNumber/index.ts index ff84eed1..f3fd322f 100644 --- a/packages/i18nify-js/src/modules/phoneNumber/index.ts +++ b/packages/i18nify-js/src/modules/phoneNumber/index.ts @@ -3,3 +3,6 @@ export { default as formatPhoneNumber } from './formatPhoneNumber'; export { default as parsePhoneNumber } from './parsePhoneNumber'; export { default as getDialCodes } from './getDialCodes'; export { default as getDialCodeByCountryCode } from './getDialCodeByCountryCode'; +export { default as getMaskedPhoneNumber } from './getMaskedPhoneNumber'; +export type { GetMaskedPhoneNumberOptions } from './types'; +export { MaskingStyle } from './constants'; diff --git a/packages/i18nify-js/src/modules/phoneNumber/types.ts b/packages/i18nify-js/src/modules/phoneNumber/types.ts new file mode 100644 index 00000000..ece2bca6 --- /dev/null +++ b/packages/i18nify-js/src/modules/phoneNumber/types.ts @@ -0,0 +1,15 @@ +import { CountryCodeType } from '../..'; +import { MaskingStyle } from './constants'; + +export interface MaskingOptions { + maskingStyle?: MaskingStyle; + maskedDigitsCount?: number; + maskingChar?: string; +} + +export interface GetMaskedPhoneNumberOptions { + countryCode: CountryCodeType; + withDialCode?: boolean; + phoneNumber?: string; + maskingOptions?: MaskingOptions; +} diff --git a/packages/i18nify-js/src/modules/phoneNumber/utils.ts b/packages/i18nify-js/src/modules/phoneNumber/utils.ts index 6b760b16..bc6380de 100644 --- a/packages/i18nify-js/src/modules/phoneNumber/utils.ts +++ b/packages/i18nify-js/src/modules/phoneNumber/utils.ts @@ -75,3 +75,90 @@ export const cleanPhoneNumber = (phoneNumber: string) => { const cleanedPhoneNumber = phoneNumber.replace(regex, ''); return phoneNumber[0] === '+' ? `+${cleanedPhoneNumber}` : cleanedPhoneNumber; }; + +/** + * Replaces the first `n` occurrences of 'x' in a source string with the first `n` characters from a replacement string. + * + * @param source {string} - The original string where replacements are to be made. + * @param replacement {string} - The string from which replacement characters are taken. + * @param n {number} - The number of 'x' characters to replace (unmasked digit count). + * @returns {string} - The modified string after replacements. + */ +export const suffixMasking = ( + source: string, + replacement: string, + n: number, +): string => { + // Convert the source string into an array of characters for easy manipulation + let result: string[] = source.split(''); + let replaceIndex: number = 0; + let replacementsDone: number = 0; + + // Iterate over the result array to replace 'x' with characters from the replacement string + for (let i = 0; i < result.length && replacementsDone < n; i++) { + if (result[i] === 'x' && replaceIndex < replacement.length) { + result[i] = replacement[replaceIndex++]; + replacementsDone++; + } + } + + // Join the array back into a string and return the modified result + return result.join(''); +}; + +/** + * Replaces the last `n` occurrences of 'x' in a source string with the last `n` characters from a replacement string. + * + * @param source {string} - The original string where replacements are to be made. + * @param replacement {string} - The string from which replacement characters are taken. + * @param n {number} - The number of 'x' characters to replace from the end of the source string (unmasked digit count). + * @returns {string} - The modified string after replacements. + */ +export const prefixMasking = ( + source: string, + replacement: string, + n: number, +): string => { + // Convert the source string into an array of characters for easy manipulation + let result: string[] = source.split(''); + let replaceIndex: number = replacement.length - 1; + let replacementsDone: number = 0; + + // Iterate from the end of the source string + for (let i = result.length - 1; i >= 0 && replacementsDone < n; i--) { + if (result[i] === 'x' && replaceIndex >= 0) { + result[i] = replacement[replaceIndex--]; + replacementsDone++; + } + } + + // Join the array back into a string and return the modified result + return result.join(''); +}; + +/** + * Replaces every alternate digit of phone number with 'x' in phoneNumberWithoutDialCode. + * + * @param phoneNumberWithoutDialCode {number | string} - The original phone number without dial code where replacements are to be made. + * @returns {string} - The modified string after replacements. + */ +export const alternateMasking = ( + phoneNumberWithoutDialCode: number | string, +): string => { + return String(phoneNumberWithoutDialCode) + .trim() + .split('') + .reduce( + (acc: any, char: string) => { + if (/\d/.test(char)) { + acc.numericCount % 2 !== 0 + ? acc.result.push('x') + : acc.result.push(char); + acc.numericCount++; + } + return acc; + }, + { result: [], numericCount: 0 }, + ) + .result.join(''); +};