diff --git a/packages/client/src/__testSetup__/node.ts b/packages/client/src/__testSetup__/node.ts index 02dcc101a..e48f9202d 100644 --- a/packages/client/src/__testSetup__/node.ts +++ b/packages/client/src/__testSetup__/node.ts @@ -5,6 +5,7 @@ import { Collection, OrganizationData, defaultEmailTemplates as emailTemplates, + defaultSMSTemplates as smsTemplates, } from "@eisbuk/shared"; import { adminDb, auth } from "./firestoreSetup"; @@ -48,6 +49,7 @@ export const setUpOrganization: SetUpOrganization = async ({ admins: [email], emailFrom, emailTemplates, + smsTemplates, ...additionalSetup, }; diff --git a/packages/client/src/__tests__/cloudFunctions.test.ts b/packages/client/src/__tests__/cloudFunctions.test.ts index 0062de185..7178c94de 100644 --- a/packages/client/src/__tests__/cloudFunctions.test.ts +++ b/packages/client/src/__tests__/cloudFunctions.test.ts @@ -8,13 +8,14 @@ import { describe, expect } from "vitest"; import { HTTPSErrors, BookingsErrors, - ClientEmailPayload, - EmailType, + ClientMessagePayload, + ClientMessageType, sanitizeCustomer, CustomerBase, Collection, Customer, DeliveryQueue, + ClientMessageMethod, } from "@eisbuk/shared"; import { CloudFunction } from "@eisbuk/shared/ui"; @@ -53,7 +54,7 @@ describe("Cloud functions", () => { async () => { const { organization } = await setUpOrganization({ doLogin: false }); const payload = { - type: EmailType.SendBookingsLink, + type: ClientMessageType.SendBookingsLink, organization, displayName: "displayName", bookingsLink: "bookingsLink", @@ -81,14 +82,12 @@ describe("Cloud functions", () => { }, }); const payload = { - type: EmailType.SendBookingsLink, + type: ClientMessageType.SendBookingsLink, organization, bookingsLink: "bookingsLink", - customer: { - name: saul.name, - surname: saul.surname, - email: saul.email, - }, + name: saul.name, + surname: saul.surname, + email: saul.email, }; await expect( httpsCallable(functions, CloudFunction.SendEmail)(payload) @@ -135,19 +134,20 @@ describe("Cloud functions", () => { expect(Boolean(bookingsSnap.data())).toEqual(true); }); - const payload: ClientEmailPayload[EmailType.SendCalendarFile] = { - type: EmailType.SendCalendarFile, + const payload: ClientMessagePayload< + ClientMessageMethod.Email, + ClientMessageType.SendCalendarFile + > = { + type: ClientMessageType.SendCalendarFile, organization, attachments: { filename: "icsFile.ics", content: "content", }, - customer: { - name: saul.name, - surname: saul.surname, - email: saul.email || "email@gmail.com", - secretKey: saul.secretKey, - }, + name: saul.name, + surname: saul.surname, + email: saul.email || "email@gmail.com", + secretKey: saul.secretKey, }; await expect( httpsCallable(functions, CloudFunction.SendEmail)(payload) @@ -163,7 +163,7 @@ describe("Cloud functions", () => { "should reject if no value for organziation provided", async () => { const payload = { - type: EmailType.SendBookingsLink, + type: ClientMessageType.SendBookingsLink, displayName: "displayName", bookingsLink: "string", customer: { @@ -181,7 +181,7 @@ describe("Cloud functions", () => { testWithEmulator("should reject if no recipient provided", async () => { const { organization } = await setUpOrganization(); const payload = { - type: EmailType.SendBookingsLink, + type: ClientMessageType.SendBookingsLink, organization, bookingsLink: "string", customer: { diff --git a/packages/client/src/__tests__/dataTriggers.test.ts b/packages/client/src/__tests__/dataTriggers.test.ts index 2e23d4422..92cd2b9de 100644 --- a/packages/client/src/__tests__/dataTriggers.test.ts +++ b/packages/client/src/__tests__/dataTriggers.test.ts @@ -12,7 +12,8 @@ import { SlotType, OrganizationData, sanitizeCustomer, - defaultEmailTemplates, + defaultEmailTemplates as emailTemplates, + defaultSMSTemplates as smsTemplates, } from "@eisbuk/shared"; import { saul, walt } from "@eisbuk/testing/customers"; @@ -398,10 +399,10 @@ describe("Cloud functions -> Data triggers ->", () => { location: "Albuquerque", admins: ["Gus Fring"], emailFrom: "gus@lospollos.hermanos", - emailTemplates: defaultEmailTemplates, + emailTemplates, emailNameFrom: "Gus", smsFrom: "Gus", - smsTemplate: "SMS Temp here", + smsTemplates, existingSecrets: ["authToken", "exampleSecret"], emailBcc: "gus@lospollos.hermanos", }; diff --git a/packages/client/src/__tests__/integrations.test.ts b/packages/client/src/__tests__/integrations.test.ts index 0f70dc5a1..003707df9 100644 --- a/packages/client/src/__tests__/integrations.test.ts +++ b/packages/client/src/__tests__/integrations.test.ts @@ -2,7 +2,12 @@ import { describe, expect, beforeEach, afterAll } from "vitest"; import { httpsCallable } from "@firebase/functions"; import { createJestSMTPServer } from "jest-smtp"; -import { ClientEmailPayload, CustomerFull, EmailType } from "@eisbuk/shared"; +import { + ClientMessageMethod, + ClientMessagePayload, + ClientMessageType, + CustomerFull, +} from "@eisbuk/shared"; import { CloudFunction } from "@eisbuk/shared/ui"; import { DeliveryStatus } from "@eisbuk/firestore-process-delivery"; @@ -52,10 +57,13 @@ describe.skip("Email sending and delivery", () => { done(); }; - const payload: ClientEmailPayload[EmailType.SendBookingsLink] = { - type: EmailType.SendBookingsLink, + const payload: ClientMessagePayload< + ClientMessageMethod.Email, + ClientMessageType.SendBookingsLink + > = { + type: ClientMessageType.SendBookingsLink, organization, - customer: saul as Required, + ...(saul as Required), bookingsLink: "https://eisbuk.it/saul", }; diff --git a/packages/client/src/__tests__/sendEmail.test.ts b/packages/client/src/__tests__/sendEmail.test.ts index 3802c80ec..0285f03b8 100644 --- a/packages/client/src/__tests__/sendEmail.test.ts +++ b/packages/client/src/__tests__/sendEmail.test.ts @@ -2,11 +2,12 @@ import { describe, expect, test } from "vitest"; import { httpsCallable } from "@firebase/functions"; import { - ClientEmailPayload, - EmailType, + ClientMessagePayload, + ClientMessageType, interpolateText, - defaultEmailTemplates, + defaultEmailTemplates as emailTemplates, EmailInterpolationValues, + ClientMessageMethod, } from "@eisbuk/shared"; import { CloudFunction } from "@eisbuk/shared/ui"; @@ -17,23 +18,32 @@ import { adminDb, functions } from "@/__testSetup__/firestoreSetup"; type SendEmailTest = | { - type: EmailType.SendBookingsLink; + type: ClientMessageType.SendBookingsLink; payload: Pick< - ClientEmailPayload[EmailType.SendBookingsLink], + ClientMessagePayload< + ClientMessageMethod.Email, + ClientMessageType.SendBookingsLink + >, "bookingsLink" >; } | { - type: EmailType.SendCalendarFile; + type: ClientMessageType.SendCalendarFile; payload: Pick< - ClientEmailPayload[EmailType.SendCalendarFile], + ClientMessagePayload< + ClientMessageMethod.Email, + ClientMessageType.SendCalendarFile + >, "attachments" >; } | { - type: EmailType.SendExtendedBookingsDate; + type: ClientMessageType.SendExtendedBookingsDate; payload: Pick< - ClientEmailPayload[EmailType.SendExtendedBookingsDate], + ClientMessagePayload< + ClientMessageMethod, + ClientMessageType.SendExtendedBookingsDate + >, "extendedBookingsDate" | "bookingsMonth" >; }; @@ -69,7 +79,7 @@ const runSendEmailTableTests = (tests: SendEmailTest[]) => { >( functions, CloudFunction.SendEmail - )({ type, ...payload, organization, customer: saul }); + )({ type, ...payload, organization, ...saul }); expect(success).toEqual(true); @@ -85,17 +95,17 @@ const runSendEmailTableTests = (tests: SendEmailTest[]) => { }; // In case of send bookings link, add bookings link specific interpolation values - if (type === EmailType.SendBookingsLink) { + if (type === ClientMessageType.SendBookingsLink) { interpolationValues.bookingsLink = payload.bookingsLink; } // In case of send calendar file, add calendar file specific interpolation values - if (type === EmailType.SendCalendarFile) { + if (type === ClientMessageType.SendCalendarFile) { interpolationValues.calendarFile = payload.attachments.filename; } // In case of send extended bookings date, add extended bookings date specific interpolation values - if (type === EmailType.SendExtendedBookingsDate) { + if (type === ClientMessageType.SendExtendedBookingsDate) { interpolationValues.bookingsMonth = payload.bookingsMonth; interpolationValues.extendedBookingsDate = payload.extendedBookingsDate; } @@ -111,11 +121,11 @@ const runSendEmailTableTests = (tests: SendEmailTest[]) => { // We're using default templates for each email type and checking against interpolation values // provided in the test payload subject: interpolateText( - defaultEmailTemplates[type].subject, + emailTemplates[type].subject, interpolationValues ), html: interpolateText( - defaultEmailTemplates[type].html, + emailTemplates[type].html, interpolationValues ), }), @@ -128,13 +138,13 @@ const runSendEmailTableTests = (tests: SendEmailTest[]) => { describe("SendEmail", () => runSendEmailTableTests([ { - type: EmailType.SendBookingsLink, + type: ClientMessageType.SendBookingsLink, payload: { bookingsLink: "https://eisbuk.com/bookings", }, }, { - type: EmailType.SendCalendarFile, + type: ClientMessageType.SendCalendarFile, payload: { attachments: { filename: "calendar.ics", @@ -143,7 +153,7 @@ describe("SendEmail", () => }, }, { - type: EmailType.SendExtendedBookingsDate, + type: ClientMessageType.SendExtendedBookingsDate, payload: { bookingsMonth: "2021-01", extendedBookingsDate: "2021-01-31", diff --git a/packages/client/src/__tests__/sendSMS.test.ts b/packages/client/src/__tests__/sendSMS.test.ts new file mode 100644 index 000000000..bc9cf854e --- /dev/null +++ b/packages/client/src/__tests__/sendSMS.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from "vitest"; +import { httpsCallable } from "@firebase/functions"; + +import { + ClientMessagePayload, + ClientMessageType, + interpolateText, + defaultSMSTemplates as smsTemplates, + EmailInterpolationValues, + ClientMessageMethod, + OrganizationData, +} from "@eisbuk/shared"; +import { CloudFunction } from "@eisbuk/shared/ui"; + +import { saul } from "@eisbuk/testing/customers"; + +import { setUpOrganization } from "@/__testSetup__/node"; +import { adminDb, functions } from "@/__testSetup__/firestoreSetup"; + +type SendSMSTest = + | { + type: ClientMessageType.SendBookingsLink; + payload: Pick< + ClientMessagePayload< + ClientMessageMethod.SMS, + ClientMessageType.SendBookingsLink + >, + "bookingsLink" + >; + } + | { + type: ClientMessageType.SendExtendedBookingsDate; + payload: Pick< + ClientMessagePayload< + ClientMessageMethod.SMS, + ClientMessageType.SendExtendedBookingsDate + >, + "extendedBookingsDate" | "bookingsMonth" + >; + }; + +/** + * Runs table tests from the payload passed in (for each email type) + * Unlike other table tests, the assertions are not in the payload, but rather + * handled internally, by constructing the interpolation values and checking + * against the default templates. + */ +const runSendEmailTableTests = (tests: SendSMSTest[]) => { + const setup = { + doLogin: true, + setSecrets: true, + additionalSetup: { + smsFrom: "Eisbuk", + } as OrganizationData, + }; + + tests.forEach(({ type, payload }) => { + test(`should construct an email of type ${type} and hand it over for delivery`, async () => { + const { organization } = await setUpOrganization(setup); + + const { + data: { success, deliveryDocumentPath }, + } = await httpsCallable< + unknown, + { + deliveryDocumentPath: string; + success: boolean; + } + >( + functions, + CloudFunction.SendSMS + )({ type, ...payload, organization, ...saul }); + + expect(success).toEqual(true); + + // Check the process document + const deliveryDocPath = deliveryDocumentPath; + const deliveryDoc = await adminDb.doc(deliveryDocPath).get(); + const deliveryDocData = deliveryDoc.data(); + + const interpolationValues: EmailInterpolationValues = { + name: saul.name, + surname: saul.surname, + organizationName: organization, + }; + + // In case of send bookings link, add bookings link specific interpolation values + if (type === ClientMessageType.SendBookingsLink) { + interpolationValues.bookingsLink = payload.bookingsLink; + } + + // In case of send extended bookings date, add extended bookings date specific interpolation values + if (type === ClientMessageType.SendExtendedBookingsDate) { + interpolationValues.bookingsMonth = payload.bookingsMonth; + interpolationValues.extendedBookingsDate = payload.extendedBookingsDate; + } + + expect(deliveryDocData).toEqual( + expect.objectContaining({ + payload: expect.objectContaining({ + to: saul.phone!, + message: interpolateText(smsTemplates[type], interpolationValues), + }), + }) + ); + }); + }); +}; + +describe("SendSMS", () => + runSendEmailTableTests([ + { + type: ClientMessageType.SendBookingsLink, + payload: { + bookingsLink: "https://eisbuk.com/bookings", + }, + }, + { + type: ClientMessageType.SendExtendedBookingsDate, + payload: { + bookingsMonth: "2021-01", + extendedBookingsDate: "2021-01-31", + }, + }, + ])); diff --git a/packages/client/src/enums/other.ts b/packages/client/src/enums/other.ts deleted file mode 100644 index ec38a7442..000000000 --- a/packages/client/src/enums/other.ts +++ /dev/null @@ -1,4 +0,0 @@ -export enum SendBookingLinkMethod { - SMS = "sms", - Email = "email", -} diff --git a/packages/client/src/features/modal/components/SendBookingsLinkDialog/SendBookingsLinkDialog.tsx b/packages/client/src/features/modal/components/SendBookingsLinkDialog/SendBookingsLinkDialog.tsx index 0809c3e19..ec0914712 100644 --- a/packages/client/src/features/modal/components/SendBookingsLinkDialog/SendBookingsLinkDialog.tsx +++ b/packages/client/src/features/modal/components/SendBookingsLinkDialog/SendBookingsLinkDialog.tsx @@ -1,18 +1,16 @@ import React from "react"; import { useDispatch } from "react-redux"; -import { Customer } from "@eisbuk/shared"; +import { Customer, ClientMessageMethod } from "@eisbuk/shared"; import { ActionDialog } from "@eisbuk/ui"; import i18n, { ActionButton } from "@eisbuk/translations"; import { BaseModalProps } from "../../types"; -import { SendBookingLinkMethod } from "@/enums/other"; - import { getBookingsLink, getDialogPrompt, sendBookingsLink } from "./utils"; type SendBookingsLinkProps = BaseModalProps & - Customer & { method: SendBookingLinkMethod }; + Customer & { method: ClientMessageMethod }; const SendBookingsLinkDialog: React.FC = ({ onClose, diff --git a/packages/client/src/features/modal/components/SendBookingsLinkDialog/__tests__/SendBookingsLinkDialog.test.tsx b/packages/client/src/features/modal/components/SendBookingsLinkDialog/__tests__/SendBookingsLinkDialog.test.tsx index e67c78337..5961d6da9 100644 --- a/packages/client/src/features/modal/components/SendBookingsLinkDialog/__tests__/SendBookingsLinkDialog.test.tsx +++ b/packages/client/src/features/modal/components/SendBookingsLinkDialog/__tests__/SendBookingsLinkDialog.test.tsx @@ -7,8 +7,7 @@ import React from "react"; import { screen, render } from "@testing-library/react"; import i18n, { ActionButton } from "@eisbuk/translations"; - -import { SendBookingLinkMethod } from "@/enums/other"; +import { ClientMessageMethod } from "@eisbuk/shared"; import SendBookingsLinkDialog from "../SendBookingsLinkDialog"; import * as utils from "../utils"; @@ -47,7 +46,7 @@ describe("SendBookingsLinkDialog", () => { {}} {...saul} - method={SendBookingLinkMethod.Email} + method={ClientMessageMethod.Email} onClose={mockOnClose} /> ); @@ -60,7 +59,7 @@ describe("SendBookingsLinkDialog", () => { {}} {...saul} - method={SendBookingLinkMethod.Email} + method={ClientMessageMethod.Email} onClose={mockOnClose} /> ); @@ -73,7 +72,7 @@ describe("SendBookingsLinkDialog", () => { {}} {...saul} - method={SendBookingLinkMethod.Email} + method={ClientMessageMethod.Email} onClose={mockOnClose} /> ); @@ -82,7 +81,7 @@ describe("SendBookingsLinkDialog", () => { expect(mockDispatch).toHaveBeenCalledWith( mockSendBookingsLink({ ...saul, - method: SendBookingLinkMethod.Email, + method: ClientMessageMethod.Email, bookingsLink: testBookingsLink, }) ); @@ -93,7 +92,7 @@ describe("SendBookingsLinkDialog", () => { {}} {...saul} - method={SendBookingLinkMethod.SMS} + method={ClientMessageMethod.SMS} onClose={mockOnClose} /> ); @@ -102,7 +101,7 @@ describe("SendBookingsLinkDialog", () => { expect(mockDispatch).toHaveBeenCalledWith( mockSendBookingsLink({ ...saul, - method: SendBookingLinkMethod.SMS, + method: ClientMessageMethod.SMS, bookingsLink: testBookingsLink, }) ); diff --git a/packages/client/src/features/modal/components/SendBookingsLinkDialog/__tests__/sendBookingsLinkDialogUtils.test.ts b/packages/client/src/features/modal/components/SendBookingsLinkDialog/__tests__/sendBookingsLinkDialogUtils.test.ts index 43f52e5c0..4bcbf17a9 100644 --- a/packages/client/src/features/modal/components/SendBookingsLinkDialog/__tests__/sendBookingsLinkDialogUtils.test.ts +++ b/packages/client/src/features/modal/components/SendBookingsLinkDialog/__tests__/sendBookingsLinkDialogUtils.test.ts @@ -1,14 +1,17 @@ import { describe, vi, expect, test, afterEach } from "vitest"; import { getFirestore as getClientFirestore } from "@firebase/firestore"; -import { EmailType, OrgSubCollection, SMSMessage } from "@eisbuk/shared"; +import { + ClientMessageType, + OrgSubCollection, + ClientMessageMethod, +} from "@eisbuk/shared"; import { CloudFunction, Routes } from "@eisbuk/shared/ui"; import i18n, { NotificationMessage, Prompt } from "@eisbuk/translations"; import { updateLocalDocuments } from "@eisbuk/react-redux-firebase-firestore"; import { getNewStore } from "@/store/createStore"; -import { SendBookingLinkMethod } from "@/enums/other"; import { NotifVariant } from "@/enums/store"; import { enqueueNotification } from "@/features/notifications/actions"; @@ -69,7 +72,7 @@ describe("Send bookings link dialog utils", () => { runGetDialogTableTests([ { name: "should display 'email' prompt for method = \"email\" when 'email' defined", - method: SendBookingLinkMethod.Email, + method: ClientMessageMethod.Email, email: testEmail, want: { title: i18n.t(Prompt.SendEmailTitle), @@ -79,7 +82,7 @@ describe("Send bookings link dialog utils", () => { }, { name: "should display 'sms' prompt for method = \"sms\" when 'phone' defined", - method: SendBookingLinkMethod.SMS, + method: ClientMessageMethod.SMS, phone: testPhone, want: { title: i18n.t(Prompt.SendSMSTitle), @@ -89,7 +92,7 @@ describe("Send bookings link dialog utils", () => { }, { name: "should display 'no-email' prompt and disable confirmation for method = \"email\" when 'email' undefined", - method: SendBookingLinkMethod.Email, + method: ClientMessageMethod.Email, want: { title: i18n.t(Prompt.NoEmailTitle), body: i18n.t(Prompt.NoEmailMessage), @@ -98,7 +101,7 @@ describe("Send bookings link dialog utils", () => { }, { name: "should display 'no-sms' prompt and disable confirmation for method = \"sms\" when 'phone' undefined", - method: SendBookingLinkMethod.SMS, + method: ClientMessageMethod.SMS, want: { title: i18n.t(Prompt.NoPhoneTitle), body: i18n.t(Prompt.NoPhoneMessage), @@ -128,7 +131,7 @@ describe("Send bookings link dialog utils", () => { async () => { const testThunk = sendBookingsLink({ ...saul, - method: SendBookingLinkMethod.Email, + method: ClientMessageMethod.Email, bookingsLink, }); await runThunk(testThunk, mockDispatch, getState, { getFirestore }); @@ -136,12 +139,10 @@ describe("Send bookings link dialog utils", () => { expect(mockSendMail).toHaveBeenCalledTimes(1); expect(mockSendMail).toHaveBeenCalledWith({ bookingsLink, - customer: { - email: saul.email, - name: saul.name, - surname: saul.surname, - }, - type: EmailType.SendBookingsLink, + email: saul.email, + name: saul.name, + surname: saul.surname, + type: ClientMessageType.SendBookingsLink, }); // check for success notification expect(mockDispatch).toHaveBeenCalledWith( @@ -158,7 +159,7 @@ describe("Send bookings link dialog utils", () => { async () => { const testThunk = sendBookingsLink({ ...saul, - method: SendBookingLinkMethod.SMS, + method: ClientMessageMethod.SMS, bookingsLink, }); await runThunk(testThunk, mockDispatch, getState, { @@ -166,15 +167,13 @@ describe("Send bookings link dialog utils", () => { }); // check results expect(mockSendSMS).toHaveBeenCalledTimes(1); - const sentSMS = mockSendSMS.mock.calls[0][0] as SMSMessage; - - expect(sentSMS.to).toEqual(saul.phone); - // we're not matching the complete html of message - // but are asserting that it contains important parts - expect(sentSMS.message.includes(bookingsLink)).toBeTruthy(); - expect(sentSMS.message.includes(saul.name)).toBeTruthy(); - // the sms should be clean, without markup - expect(sentSMS.message.includes("p>")).toBeFalsy(); + expect(mockSendSMS).toHaveBeenCalledWith({ + bookingsLink, + phone: saul.phone, + name: saul.name, + surname: saul.surname, + type: ClientMessageType.SendBookingsLink, + }); // check for success notification expect(mockDispatch).toHaveBeenCalledWith( @@ -196,7 +195,7 @@ describe("Send bookings link dialog utils", () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const testThunk = sendBookingsLink({ ...saul, - method: SendBookingLinkMethod.Email, + method: ClientMessageMethod.Email, bookingsLink, }); await runThunk(testThunk, mockDispatch, getState, { getFirestore }); diff --git a/packages/client/src/features/modal/components/SendBookingsLinkDialog/utils.ts b/packages/client/src/features/modal/components/SendBookingsLinkDialog/utils.ts index 95054d59b..1fa98e9c3 100644 --- a/packages/client/src/features/modal/components/SendBookingsLinkDialog/utils.ts +++ b/packages/client/src/features/modal/components/SendBookingsLinkDialog/utils.ts @@ -1,8 +1,8 @@ import { - ClientEmailPayload, + ClientMessagePayload, Customer, - EmailType, - SMSMessage, + ClientMessageType, + ClientMessageMethod, } from "@eisbuk/shared"; import { CloudFunction, Routes } from "@eisbuk/shared/ui"; import i18n, { NotificationMessage, Prompt } from "@eisbuk/translations"; @@ -11,18 +11,13 @@ import { createFunctionCaller } from "@/utils/firebase"; import { FirestoreThunk } from "@/types/store"; -import { SendBookingLinkMethod } from "@/enums/other"; import { NotifVariant } from "@/enums/store"; import { enqueueNotification } from "@/features/notifications/actions"; -import { getOrganization } from "@/lib/getters"; interface GetDialogPrompt { ( - payload: { method: SendBookingLinkMethod } & Pick< - Customer, - "email" | "phone" - > + payload: { method: ClientMessageMethod } & Pick ): { title: string; body: string; @@ -36,7 +31,7 @@ interface GetDialogPrompt { // eslint-disable-next-line consistent-return export const getDialogPrompt: GetDialogPrompt = (props) => { switch (props.method) { - case SendBookingLinkMethod.Email: + case ClientMessageMethod.Email: const { email } = props; if (!email) { return { @@ -51,7 +46,7 @@ export const getDialogPrompt: GetDialogPrompt = (props) => { disabled: false, }; - case SendBookingLinkMethod.SMS: + case ClientMessageMethod.SMS: const { phone } = props; if (!phone) { return { @@ -71,7 +66,7 @@ export const getDialogPrompt: GetDialogPrompt = (props) => { interface SendBookingsLink { ( payload: { - method: SendBookingLinkMethod; + method: ClientMessageMethod; bookingsLink: string; } & Customer ): FirestoreThunk; @@ -87,32 +82,40 @@ export const sendBookingsLink: SendBookingsLink = throw new Error(); } - const sms = `Ciao ${name}, - Ti inviamo un link per prenotare le tue prossime lezioni con ${getOrganization()}: - ${bookingsLink}`; - - const emailPayload: Omit< - ClientEmailPayload[EmailType.SendBookingsLink], - "organization" + const payloadBase: Omit< + ClientMessagePayload< + ClientMessageMethod, + ClientMessageType.SendBookingsLink + >, + "organization" | "email" | "phone" > = { - customer: { - name, - surname, - email: email!, - }, - type: EmailType.SendBookingsLink, + type: ClientMessageType.SendBookingsLink, + name, + surname, bookingsLink, }; const config = { - [SendBookingLinkMethod.Email]: { + [ClientMessageMethod.Email]: { handler: CloudFunction.SendEmail, - payload: emailPayload, + payload: { ...payloadBase, email } as Omit< + ClientMessagePayload< + ClientMessageMethod.Email, + ClientMessageType.SendBookingsLink + >, + "organization" + >, successMessage: i18n.t(NotificationMessage.EmailSent), }, - [SendBookingLinkMethod.SMS]: { + [ClientMessageMethod.SMS]: { handler: CloudFunction.SendSMS, - payload: { to: phone, message: sms } as SMSMessage, + payload: { ...payloadBase, phone } as Omit< + ClientMessagePayload< + ClientMessageMethod.SMS, + ClientMessageType.SendBookingsLink + >, + "organization" + >, successMessage: i18n.t(NotificationMessage.SMSSent), }, }; diff --git a/packages/client/src/pages/admin_preferences/Buttons.tsx b/packages/client/src/pages/admin_preferences/Buttons.tsx deleted file mode 100644 index fac0f92eb..000000000 --- a/packages/client/src/pages/admin_preferences/Buttons.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React, { MutableRefObject } from "react"; -import { useFormikContext } from "formik"; - -import { EmailType, EmailTypeButtons } from "@eisbuk/shared"; -import { useTranslation, EmailTemplateLabel } from "@eisbuk/translations"; -import { Button, ButtonColor } from "@eisbuk/ui"; - -import { capitalizeFirst, formatTemplateString } from "@/utils/helpers"; - -declare interface ButtonsProps { - buttons: EmailTypeButtons; - emailType: EmailType; - input: MutableRefObject; -} -const Buttons: React.FC = ({ buttons, emailType, input }) => { - const { setFieldValue } = useFormikContext(); - - const { t } = useTranslation(); - - const insertString = (buttonValue: string) => { - if (input.current) { - const [start, end] = [ - input.current.selectionStart, - input.current.selectionEnd, - ]; - const field = input.current.name.split(".")[1]; - - if (start !== null && end !== null) { - /** @TODO pass translated default click here messages?? */ - - const inputValue = formatTemplateString(buttonValue); - setFieldValue( - `emailTemplates[${emailType}].[${field}]`, - [ - input.current.value.slice(0, start), - inputValue, - input.current.value.slice(end), - ].join("") - ); - - input.current.focus(); - input.current.selectionStart = input.current.selectionEnd = start; - } - } - }; - - return ( -
- {Object.values(buttons[emailType]).map((button) => ( - - ))} -
- ); -}; - -export default Buttons; diff --git a/packages/client/src/pages/admin_preferences/EmailTemplate.tsx b/packages/client/src/pages/admin_preferences/EmailTemplate.tsx deleted file mode 100644 index 4a365292f..000000000 --- a/packages/client/src/pages/admin_preferences/EmailTemplate.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React, { MutableRefObject } from "react"; - -import { FormField, FormFieldVariant } from "@eisbuk/ui"; -import { useTranslation, EmailTemplateLabel } from "@eisbuk/translations"; - -export interface EmailTemplateFieldProps { - label: string; - input: MutableRefObject; -} - -const EmailTemplate: React.FC = ({ - input, - label, - ...rest -}) => { - const { t } = useTranslation(); - return ( -
- (input.current = e.target)} - {...rest} - /> - (input.current = e.target)} - {...rest} - /> -
- ); -}; - -export default EmailTemplate; diff --git a/packages/client/src/pages/admin_preferences/PreviewField.tsx b/packages/client/src/pages/admin_preferences/PreviewField.tsx deleted file mode 100644 index 0150ba200..000000000 --- a/packages/client/src/pages/admin_preferences/PreviewField.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import React from "react"; -import { useFormikContext } from "formik"; -import { EmailTemplate, interpolateText } from "@eisbuk/shared"; -import { FormField, FormFieldVariant } from "@eisbuk/ui"; -import { useTranslation, EmailTemplateLabel } from "@eisbuk/translations"; -import { replaceHTMLTags } from "@/utils/helpers"; - -export interface PreviewFieldProps { - template: EmailTemplate; - name: string; -} -const PreviewField: React.FC = ({ name, ...props }) => { - const { - values: { emailTemplates }, - setFieldValue, - } = useFormikContext(); - - const { t } = useTranslation(); - - const preveiwDefaults = { - organizationName: "Organization Name", - name: "Saul", - surname: "Goodman", - bookingsLink: "https://ice.it/saul", - bookingsMonth: "April", - extendedBookingsDate: "06/04", - icsFile: "icsFile.ics", - }; - const subject = replaceHTMLTags(emailTemplates[name].subject); - const html = replaceHTMLTags(emailTemplates[name].html); - React.useEffect(() => { - if (emailTemplates[name].subject.trim() !== "") { - setFieldValue( - `${name}-subject-preview`, - `${interpolateText(subject, preveiwDefaults)}` - ); - } - }, [emailTemplates[name].subject, setFieldValue, name]); - - React.useEffect(() => { - if (emailTemplates[name].html.trim() !== "") { - setFieldValue( - `${name}-html-preview`, - `${interpolateText(html, preveiwDefaults)}` - ); - } - }, [emailTemplates[name].html, setFieldValue, name]); - - return ( -
- - -
- ); -}; - -export default PreviewField; diff --git a/packages/client/src/pages/admin_preferences/TemplateBlock.tsx b/packages/client/src/pages/admin_preferences/TemplateBlock.tsx new file mode 100644 index 000000000..0a05097c2 --- /dev/null +++ b/packages/client/src/pages/admin_preferences/TemplateBlock.tsx @@ -0,0 +1,137 @@ +import React from "react"; +import { useFormikContext } from "formik"; + +import { useTranslation, MessageTemplateLabel } from "@eisbuk/translations"; +import { Button, ButtonColor } from "@eisbuk/ui"; +import { OrganizationData } from "@eisbuk/shared"; + +interface ButtonAttributes { + label: MessageTemplateLabel; + value: string; +} + +export type TemplateFieldsInterface = React.FC<{ + name: string; + input: React.MutableRefObject; +}>; +export type PreviewFieldsInterface = React.FC<{ + name: string; +}>; + +interface Props { + name: string; + buttons: ButtonAttributes[]; + // This is just a rich way of ensuring our type is in fact a + // subset of organization data field names + type: keyof Pick; + TemplateFields: TemplateFieldsInterface; + PreviewFields: PreviewFieldsInterface; +} + +const TemplateBlock: React.FC = ({ + name, + buttons, + type, + TemplateFields, + PreviewFields, +}) => { + const { t } = useTranslation(); + + const { setFieldValue } = useFormikContext(); + + const input = React.useRef(null); + + const insertValuePlaceholder = (buttonValue: string) => () => { + if (!input.current) return; + + const [start, end] = getInputSelection(input.current); + + const { name, value } = input.current; + + const inputValue = + // Format the placeholder value, add anchor tag to links, where aplicable + formatValuePlaceholder(buttonValue, type === "emailTemplates"); + + const updatedValue = stringInsert(value, start, end, inputValue); + + setFieldValue(name, updatedValue); + + input.current.focus(); + // Set selection to the end of the inserted value, after the field has been focused (hence the timeout) + const cursorPosition = start + inputValue.length; + const setSelection = () => + input.current?.setSelectionRange(cursorPosition, cursorPosition); + setTimeout(setSelection, 5); + }; + + return ( +
+

+ {t(MessageTemplateLabel[name])} +

+
+
+
+
+ {buttons.map(({ value, label }) => ( + + ))} +
+ +
+ +
+
+
+ +
+
+

+ {t(MessageTemplateLabel.Preview)} +

+ +
+
+
+
+ ); +}; + +const stringInsert = ( + string: string, + start: number, + end: number, + value: string +) => [string.slice(0, start), value, string.slice(end)].join(""); + +const getInputSelection = (input: HTMLInputElement) => [ + input.selectionStart || 0, + input.selectionEnd || 0, +]; + +const formatValuePlaceholder = (value: string, wrapLinks: boolean) => { + switch (value) { + case "icsFile": + if (wrapLinks) { + return 'Clicca qui per aggiungere le tue prenotazioni al tuo calendario'; + } + // eslint-disable-next-line no-fallthrough + case "bookingsLink": + if (wrapLinks) { + return 'Clicca qui per prenotare e gestire le tue lezioni'; + } + // eslint-disable-next-line no-fallthrough + default: + return `{{ ${value} }}`; + } +}; + +export default TemplateBlock; diff --git a/packages/client/src/pages/admin_preferences/data.ts b/packages/client/src/pages/admin_preferences/data.ts new file mode 100644 index 000000000..e9eaecc3e --- /dev/null +++ b/packages/client/src/pages/admin_preferences/data.ts @@ -0,0 +1,37 @@ +import { ClientMessageType } from "@eisbuk/shared"; +import { MessageTemplateLabel } from "@eisbuk/translations"; + +export const buttons = { + [ClientMessageType.SendBookingsLink]: [ + { label: MessageTemplateLabel.Name, value: "name" }, + { label: MessageTemplateLabel.Surname, value: "surname" }, + { label: MessageTemplateLabel.BookingsLink, value: "bookingsLink" }, + { label: MessageTemplateLabel.OrganizationName, value: "organizationName" }, + ], + [ClientMessageType.SendCalendarFile]: [ + { label: MessageTemplateLabel.Name, value: "name" }, + { label: MessageTemplateLabel.Surname, value: "surname" }, + { label: MessageTemplateLabel.CalendarFile, value: "calendarFile" }, + { label: MessageTemplateLabel.OrganizationName, value: "organizationName" }, + ], + [ClientMessageType.SendExtendedBookingsDate]: [ + { label: MessageTemplateLabel.Name, value: "name" }, + { label: MessageTemplateLabel.Surname, value: "surname" }, + { label: MessageTemplateLabel.BookingsMonth, value: "bookingsMonth" }, + { + label: MessageTemplateLabel.ExtendedBookingsDate, + value: "extendedBookingsDate", + }, + { label: MessageTemplateLabel.OrganizationName, value: "organizationName" }, + ], +}; + +export const previewValues = { + organizationName: "Organization Name", + name: "Saul", + surname: "Goodman", + bookingsLink: "https://ice.it/saul", + bookingsMonth: "April", + extendedBookingsDate: "06/04", + icsFile: "icsFile.ics", +}; diff --git a/packages/client/src/pages/admin_preferences/index.tsx b/packages/client/src/pages/admin_preferences/index.tsx index e8a3846a6..3d6bc0dce 100644 --- a/packages/client/src/pages/admin_preferences/index.tsx +++ b/packages/client/src/pages/admin_preferences/index.tsx @@ -3,11 +3,16 @@ import * as Yup from "yup"; import { useDispatch, useSelector } from "react-redux"; import { Formik, Form, FormikHelpers } from "formik"; -import { defaultEmailTemplates, OrganizationData } from "@eisbuk/shared"; +import { + defaultEmailTemplates as emailTemplates, + defaultSMSTemplates as smsTemplates, + OrganizationData, +} from "@eisbuk/shared"; import i18n, { ActionButton, ValidationMessage, useTranslation, + SettingsNavigationLabel, } from "@eisbuk/translations"; import { Button, @@ -29,6 +34,7 @@ import { isEmpty } from "@/utils/helpers"; import EmailTemplateSettings from "./views/EmailTemplateSettings"; import GeneralSettings from "./views/GeneralSettings"; +import SMSTemplateSettings from "./views/SMSTemplateSettings"; // #region validations const OrganizationValidation = Yup.object().shape({ @@ -40,18 +46,20 @@ const OrganizationValidation = Yup.object().shape({ // #endregion validations const OrganizationSettings: React.FC = () => { - enum Views { - EmailTemplates = "EmailTemplatesSection", + enum View { GeneralSettings = "GeneralSettings", + EmailTemplates = "EmailTemplates", + SMSTemplates = "SMSTemplates", } // Get appropriate view to render const viewsLookup = { - [Views.EmailTemplates]: EmailTemplateSettings, - [Views.GeneralSettings]: GeneralSettings, + [View.GeneralSettings]: GeneralSettings, + [View.EmailTemplates]: EmailTemplateSettings, + [View.SMSTemplates]: SMSTemplateSettings, }; const [view, setView] = useState( - Views.GeneralSettings + View.GeneralSettings ); const dispatch = useDispatch(); @@ -85,16 +93,23 @@ const OrganizationSettings: React.FC = () => { setView(Views.GeneralSettings)} - active={view === Views.GeneralSettings} + label={i18n.t(SettingsNavigationLabel.GeneralSettings)} + onClick={() => setView(View.GeneralSettings)} + active={view === View.GeneralSettings} /> setView(Views.EmailTemplates)} - active={view === Views.EmailTemplates} + label={i18n.t(SettingsNavigationLabel.EmailTemplates)} + onClick={() => setView(View.EmailTemplates)} + active={view === View.EmailTemplates} + /> + setView(View.SMSTemplates)} + active={view === View.SMSTemplates} /> ); @@ -108,6 +123,7 @@ const OrganizationSettings: React.FC = () => { > {({ isSubmitting, isValidating, handleReset }) => (