diff --git a/packages/attributes/package.json b/packages/attributes/package.json index 9d94b01e9..44c3f914e 100644 --- a/packages/attributes/package.json +++ b/packages/attributes/package.json @@ -78,7 +78,8 @@ "@finsweet/attributes-starrating": "workspace:*", "@finsweet/attributes-toc": "workspace:*", "@finsweet/attributes-utils": "workspace:*", - "@finsweet/attributes-videohls": "workspace:*" + "@finsweet/attributes-videohls": "workspace:*", + "@finsweet/attributes-whatsapp": "workspace:*" }, "devDependencies": { "@types/node": "^18.16.1", diff --git a/packages/attributes/src/load.ts b/packages/attributes/src/load.ts index f97957a84..01e2b87a0 100644 --- a/packages/attributes/src/load.ts +++ b/packages/attributes/src/load.ts @@ -182,6 +182,10 @@ export const loadAttribute = async (solution: FsAttributeKey) => { return import('@finsweet/attributes-videohls'); } + case 'whatsapp': { + return import('@finsweet/attributes-whatsapp'); + } + default: { throw `Finsweet Attribute "${solution}" is not supported.`; } diff --git a/packages/attributes/tests/whatsapp.spec.ts b/packages/attributes/tests/whatsapp.spec.ts new file mode 100644 index 000000000..e867fdd7f --- /dev/null +++ b/packages/attributes/tests/whatsapp.spec.ts @@ -0,0 +1,75 @@ +import { type ElementHandle, expect, test } from '@playwright/test'; + +import { waitAttributeLoaded } from './utils'; + +let whatsappButtons: ElementHandle[] = []; + +test.beforeEach(async ({ page }) => { + await page.goto('https://dev-attributes-whatsapp.webflow.io'); + await waitAttributeLoaded(page, 'whatsapp'); + + // Get all whatsapp buttons + whatsappButtons = (await page.$$('a[fs-whatsapp-element="button"]')) as ElementHandle[]; +}); + +test.describe('whatsapp', () => { + // Static attributes test + test('Validates static attributes for whatsapp anchor tags', async () => { + // Loop through all whatsapp buttons and validate the attributes + for (const button of whatsappButtons) { + const { phoneNumber, message } = await getAttributes(button); + + const expectedPhoneNumber = await button.getAttribute('fs-whatsapp-phone'); + const expectedMessage = await button.getAttribute('fs-whatsapp-message'); + + // Assert that the attributes are not empty + if (expectedPhoneNumber && expectedMessage) { + expect(phoneNumber).toBe(expectedPhoneNumber); + expect(message).toBe(expectedMessage); + } + } + }); + + // Dynamic attributes test + test('Validates dynamic attributes for whatsapp anchor tags', async () => { + // Loop through all whatsapp buttons and validate the attributes + for (const button of whatsappButtons) { + // Get the attributes from the whatsapp button + const { phoneNumber, message } = await getAttributes(button); + + const phoneElement: ElementHandle | null = await button.$('div[fs-whatsapp-element="phone"]'); + const messageElement: ElementHandle | null = await button.$('div[fs-whatsapp-element="message"]'); + + // Assert that the attributes are not empty + if (phoneElement && messageElement) { + const dynamicMessage = (await messageElement.textContent()) ?? ''; + expect(dynamicMessage).toBeTruthy(); + + const dynamicPhone = (await phoneElement.textContent()) ?? ''; + expect(dynamicPhone).toBeTruthy(); + + expect(dynamicPhone).toBe(phoneNumber); + expect(dynamicMessage).toBe(message); + } + } + }); +}); + +/** + * Helper function that gets the attributes from a whatsapp button + * @param button The whatsapp button + * @returns An object containing the phone number and message + */ +async function getAttributes(button: ElementHandle) { + const href = (await button.getAttribute('href')) ?? ''; + expect(href).toBeTruthy(); + + // assert that the href value is a valid whatsapp link + expect(href).toContain('https://wa.me/'); + + const url = new URL(href); + const [, phoneNumber] = url.pathname.split('/'); + const message = url.searchParams.get('text'); + + return { phoneNumber, message }; +} diff --git a/packages/utils/src/constants/attributes.ts b/packages/utils/src/constants/attributes.ts index a838ebd1d..252a382b7 100644 --- a/packages/utils/src/constants/attributes.ts +++ b/packages/utils/src/constants/attributes.ts @@ -95,3 +95,5 @@ export const TOC_ATTRIBUTE = 'toc'; export const READ_TIME_ATTRIBUTE = 'readtime'; export const VIDEO_HLS_ATTRIBUTE = 'videohls'; + +export const WHATSAPP_ATTRIBUTE = 'whatsapp'; diff --git a/packages/whatsapp/README.md b/packages/whatsapp/README.md new file mode 100644 index 000000000..d99ad88c2 --- /dev/null +++ b/packages/whatsapp/README.md @@ -0,0 +1,3 @@ +# `whatsapp` Attribute + +Adds a simple url to an anchor element that creates a new whatsapp chat in a new tab when clicked. diff --git a/packages/whatsapp/package.json b/packages/whatsapp/package.json new file mode 100644 index 000000000..76ea712dc --- /dev/null +++ b/packages/whatsapp/package.json @@ -0,0 +1,17 @@ +{ + "name": "@finsweet/attributes-whatsapp", + "version": "1.0.0", + "description": "Adds a simple url to an anchor element that creates a new whatsapp chat in a new tab when clicked.", + "private": true, + "type": "module", + "types": "src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "dependencies": { + "@finsweet/attributes-utils": "workspace:*" + } +} diff --git a/packages/whatsapp/src/factory.ts b/packages/whatsapp/src/factory.ts new file mode 100644 index 000000000..afbe4f63e --- /dev/null +++ b/packages/whatsapp/src/factory.ts @@ -0,0 +1,30 @@ +import { formatPhoneNumber, formatUrl, getAttribute, getInstanceIndex, queryElement, WHATSAPP_BASE_URL } from './utils'; + +/** + * Initialize a WhatsApp button element instance + * @param buttonElement The button element to initialize + * @returns The initialized button element + */ +export const initWhatsappInstance = (buttonElement: Element) => { + // static + let phone = getAttribute(buttonElement, 'phone'); + let message = getAttribute(buttonElement, 'message'); + + // dynamic + if (!message && !phone) { + const instanceIndex = getInstanceIndex(buttonElement); + // phone + phone = queryElement('phone', { instanceIndex, scope: buttonElement })?.textContent ?? ''; + // message + message = queryElement('message', { instanceIndex, scope: buttonElement })?.textContent ?? ''; + } + + if (!phone || !message) return; //todo: throw new Error('Missing phone or message attribute'); + + // format phone number and url + phone = formatPhoneNumber(phone); + const url = formatUrl(`${WHATSAPP_BASE_URL}/${phone}`, { text: message.trim() }); + + buttonElement.setAttribute('href', url); + buttonElement.setAttribute('target', '_blank'); +}; diff --git a/packages/whatsapp/src/index.ts b/packages/whatsapp/src/index.ts new file mode 100644 index 000000000..f9f07f0e7 --- /dev/null +++ b/packages/whatsapp/src/index.ts @@ -0,0 +1,3 @@ +export { version } from '../package.json'; +export { init } from './init'; +export { ELEMENTS, SETTINGS } from './utils'; diff --git a/packages/whatsapp/src/init.ts b/packages/whatsapp/src/init.ts new file mode 100644 index 000000000..bcb06f62f --- /dev/null +++ b/packages/whatsapp/src/init.ts @@ -0,0 +1,27 @@ +import { type FsAttributeInit, isNotEmpty, waitAttributeLoaded, waitWebflowReady } from '@finsweet/attributes-utils'; + +import { initWhatsappInstance } from './factory'; +import { queryAllElements } from './utils'; + +/** + * Inits the attribute. + */ +export const init: FsAttributeInit = async () => { + await waitWebflowReady(); + + // wait for cms to load if it's present + await waitAttributeLoaded('cmsload'); + + // Get all whatsapp buttons on the page + const buttonElements = queryAllElements('button'); + + // Create instances of each whatsapp button + buttonElements.map(initWhatsappInstance).filter(isNotEmpty); + + return { + result: buttonElements, + destroy() { + return; + }, + }; +}; diff --git a/packages/whatsapp/src/utils/constants.ts b/packages/whatsapp/src/utils/constants.ts new file mode 100644 index 000000000..0d133191f --- /dev/null +++ b/packages/whatsapp/src/utils/constants.ts @@ -0,0 +1,36 @@ +import { type AttributeElements, type AttributeSettings } from '@finsweet/attributes-utils'; + +export const ELEMENTS = [ + /** + * Defines a button element. + */ + 'button', + /** + * Defines a phone element + */ + 'phone', + /** + * Defines a message element. + */ + 'message', +] as const satisfies AttributeElements; + +export const SETTINGS = { + /** + * Defines the WhatsApp phone number. + */ + phone: { + key: 'phone', + }, + /** + * Defines the WhatsApp message. + */ + message: { + key: 'message', + }, +} as const satisfies AttributeSettings; + +/** + * Defines the WhatsApp base URL. + */ +export const WHATSAPP_BASE_URL = 'https://wa.me'; diff --git a/packages/whatsapp/src/utils/helpers.ts b/packages/whatsapp/src/utils/helpers.ts new file mode 100644 index 000000000..6a1b9a3e3 --- /dev/null +++ b/packages/whatsapp/src/utils/helpers.ts @@ -0,0 +1,23 @@ +/** + * Formats a url with the given params as query params + * @param url The url to format + * @param params The params to append to the url + * @returns The formatted url + */ +export const formatUrl = (url: string, params: { [key: string]: string }): string => { + const urlObject = new URL(url); + for (const [key, value] of Object.entries(params)) { + urlObject.searchParams.append(key, value); + } + + return urlObject.href; +}; + +/** + * Formats a phone number by removing all non-numeric characters and appending + + * @param phoneNumber The phone number to format + * @returns The formatted phone number + */ +export const formatPhoneNumber = (phoneNumber: string): string => { + return '+' + phoneNumber.replace(/[^0-9]/g, ''); +}; diff --git a/packages/whatsapp/src/utils/index.ts b/packages/whatsapp/src/utils/index.ts new file mode 100644 index 000000000..f36792e8d --- /dev/null +++ b/packages/whatsapp/src/utils/index.ts @@ -0,0 +1,4 @@ +export * from './constants'; +export * from './helpers'; +export * from './schema'; +export * from './selectors'; diff --git a/packages/whatsapp/src/utils/schema.ts b/packages/whatsapp/src/utils/schema.ts new file mode 100644 index 000000000..8d49e18b3 --- /dev/null +++ b/packages/whatsapp/src/utils/schema.ts @@ -0,0 +1,43 @@ +import type { Schema, SchemaSettings } from '@finsweet/attributes-utils'; + +import { ELEMENTS, SETTINGS } from '.'; + +const SCHEMA_SETTINGS: SchemaSettings = { + phone: { + ...SETTINGS.phone, + name: 'Phone', + description: 'The phone number', + type: 'text', + }, + message: { + ...SETTINGS.message, + name: 'Message', + description: 'The message to send', + type: 'text', + }, +}; + +export const schema: Schema = { + groups: [], + elements: [ + { + key: 'button', + name: 'Button', + description: 'A button to open WhatsApp', + allowedTypes: ['Link'], + settings: [SCHEMA_SETTINGS.phone, SCHEMA_SETTINGS.message], + }, + { + key: 'phone', + name: 'Phone', + description: 'A phone number to open WhatsApp', + allowedTypes: ['Block'], + }, + { + key: 'message', + name: 'Message', + description: 'A message to open WhatsApp', + allowedTypes: ['Block'], + }, + ], +}; diff --git a/packages/whatsapp/src/utils/selectors.ts b/packages/whatsapp/src/utils/selectors.ts new file mode 100644 index 000000000..673e7b1f2 --- /dev/null +++ b/packages/whatsapp/src/utils/selectors.ts @@ -0,0 +1,9 @@ +import { generateSelectors, WHATSAPP_ATTRIBUTE } from '@finsweet/attributes-utils'; + +import { ELEMENTS, SETTINGS } from '.'; + +export const { getAttribute, hasAttributeValue, queryAllElements, getInstanceIndex, queryElement } = generateSelectors( + WHATSAPP_ATTRIBUTE, + ELEMENTS, + SETTINGS +); diff --git a/packages/whatsapp/tsconfig.json b/packages/whatsapp/tsconfig.json new file mode 100644 index 000000000..4082f16a5 --- /dev/null +++ b/packages/whatsapp/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa5dff8a7..875e3326a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,4 +1,4 @@ -lockfileVersion: '6.1' +lockfileVersion: '6.0' settings: autoInstallPeers: true @@ -218,6 +218,9 @@ importers: '@finsweet/attributes-videohls': specifier: workspace:* version: link:../videohls + '@finsweet/attributes-whatsapp': + specifier: workspace:* + version: link:../whatsapp devDependencies: '@types/node': specifier: ^18.16.1 @@ -413,19 +416,6 @@ importers: specifier: workspace:* version: link:../utils - packages/docs: - dependencies: - '@finsweet/attributes-utils': - specifier: workspace:* - version: link:../utils - marked: - specifier: ^5.1.0 - version: 5.1.0 - devDependencies: - '@types/marked': - specifier: ^5.0.0 - version: 5.0.0 - packages/favcustom: dependencies: '@finsweet/attributes-utils': @@ -647,6 +637,12 @@ importers: specifier: ^1.4.5 version: 1.4.5 + packages/whatsapp: + dependencies: + '@finsweet/attributes-utils': + specifier: workspace:* + version: link:../utils + packages: /@babel/code-frame@7.21.4: @@ -1496,10 +1492,6 @@ packages: resolution: {integrity: sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==} dev: true - /@types/marked@5.0.0: - resolution: {integrity: sha512-YcZe50jhltsCq7rc9MNZC/4QB/OnA2Pd6hrOSTOFajtabN+38slqgDDCeE/0F83SjkKBQcsZUj7VLWR0H5cKRA==} - dev: true - /@types/minimist@1.2.2: resolution: {integrity: sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==} dev: true @@ -3009,12 +3001,6 @@ packages: engines: {node: '>=8'} dev: true - /marked@5.1.0: - resolution: {integrity: sha512-z3/nBe7aTI8JDszlYLk7dDVNpngjw0o1ZJtrA9kIfkkHcIF+xH7mO23aISl4WxP83elU+MFROgahqdpd05lMEQ==} - engines: {node: '>= 18'} - hasBin: true - dev: false - /meow@6.1.1: resolution: {integrity: sha512-3YffViIt2QWgTy6Pale5QpopX/IvU3LPL03jOTqp6pGj3VjesdO/U8CuHMKpnQr4shCNCM5fd5XFFvIIl6JBHg==} engines: {node: '>=8'}