diff --git a/.changeset/nasty-teachers-flash.md b/.changeset/nasty-teachers-flash.md new file mode 100644 index 000000000..983a58fa2 --- /dev/null +++ b/.changeset/nasty-teachers-flash.md @@ -0,0 +1,5 @@ +--- +"io-services-cms-backoffice": minor +--- + +Added secondary CTA and improved CTA management for created/modified services diff --git a/apps/backoffice/mocks/data/backend-data.ts b/apps/backoffice/mocks/data/backend-data.ts index 2100e477c..efedd1fab 100644 --- a/apps/backoffice/mocks/data/backend-data.ts +++ b/apps/backoffice/mocks/data/backend-data.ts @@ -1,4 +1,5 @@ /* eslint-disable no-useless-escape */ +import { CTA_PREFIX_URL_SCHEMES } from "@/components/cta-manager/constants"; import { Cidr } from "@/generated/api/Cidr"; import { ManageKeyCIDRs } from "@/generated/api/ManageKeyCIDRs"; import { @@ -94,19 +95,29 @@ export const aMockServiceTopicsArray = [ export const aMockServiceCTASingle = `---\nit:\n cta_1: \n text: \"${faker.lorem.words( 2, -)}\"\n action: \"iohandledlink://${faker.internet.url()}\"\nen:\n cta_1: \n text: \"${faker.lorem.words( +)}\"\n action: \"${CTA_PREFIX_URL_SCHEMES.EXTERNAL}${faker.internet.url()}\"\nen:\n cta_1: \n text: \"${faker.lorem.words( 2, -)}\"\n action: \"iohandledlink://${faker.internet.url()}\"\n---`; +)}\"\n action: \"${CTA_PREFIX_URL_SCHEMES.EXTERNAL}${faker.internet.url()}\"\n---`; export const aMockServiceCTADouble = `---\nit:\n cta_1: \n text: \"${faker.lorem.words( 2, -)}\"\n action: \"iohandledlink://${faker.internet.url()}"\n cta_2: \n text: \"${faker.lorem.words( +)}\"\n action: \"${CTA_PREFIX_URL_SCHEMES.EXTERNAL}${faker.internet.url()}"\n cta_2: \n text: \"${faker.lorem.words( 2, -)}\"\n action: \"iohandledlink://${faker.internet.url()}\"\nen:\n cta_1: \n text: \"${faker.lorem.words( +)}\"\n action: \"${CTA_PREFIX_URL_SCHEMES.EXTERNAL}${faker.internet.url()}\"\nen:\n cta_1: \n text: \"${faker.lorem.words( 2, -)}\"\n action: \"iohandledlink://${faker.internet.url()}\"\n cta_2: \n text: \"${faker.lorem.words( +)}\"\n action: \"${CTA_PREFIX_URL_SCHEMES.EXTERNAL}${faker.internet.url()}\"\n cta_2: \n text: \"${faker.lorem.words( 2, -)}\"\n action: \"iohandledlink://${faker.internet.url()}\"\n---`; +)}\"\n action: \"${CTA_PREFIX_URL_SCHEMES.EXTERNAL}${faker.internet.url()}\"\n---`; + +export const aMockServiceCTADoubleDifferentLink = `---\nit:\n cta_1: \n text: \"${faker.lorem.words( + 2, +)}\"\n action: \"${CTA_PREFIX_URL_SCHEMES.INTERNAL}${faker.internet.url()}"\n cta_2: \n text: \"${faker.lorem.words( + 2, +)}\"\n action: \"${CTA_PREFIX_URL_SCHEMES.SSO}${faker.internet.url()}\"\nen:\n cta_1: \n text: \"${faker.lorem.words( + 2, +)}\"\n action: \"${CTA_PREFIX_URL_SCHEMES.INTERNAL}${faker.internet.url()}\"\n cta_2: \n text: \"${faker.lorem.words( + 2, +)}\"\n action: \"${CTA_PREFIX_URL_SCHEMES.SSO}${faker.internet.url()}\"\n---`; export const getMockServiceLifecycle = (serviceId?: string) => ({ authorized_cidrs: [ @@ -139,7 +150,7 @@ export const getMockServiceLifecycle = (serviceId?: string) => ({ category: faker.helpers.arrayElement(["SPECIAL", "STANDARD"]), cta: faker.helpers.arrayElement([ aMockServiceCTASingle, - aMockServiceCTADouble, + aMockServiceCTADoubleDifferentLink, undefined, ]), custom_special_flow: faker.lorem.slug(1), diff --git a/apps/backoffice/public/locales/en/common.json b/apps/backoffice/public/locales/en/common.json index a26ed13c1..90229099a 100644 --- a/apps/backoffice/public/locales/en/common.json +++ b/apps/backoffice/public/locales/en/common.json @@ -142,8 +142,20 @@ "description": "These features allow you to enrich the service with many additional parameters. Some of these require the signing of an ad hoc contract.", "arriving": "Arriving", "cta": { - "label": "Button with external link", - "description": "Add a button that takes you to a web page with more information about the service." + "label": "Button with link", + "description": "Add one or two buttons to the service tab to direct citizens to an external link, an internal link, or directly to a post-login area thanks to Single Sign-On.", + "addSecondaryButton": "add secondary button", + "removeSecondaryButton": "remove secondary button", + "form": { + "selectLabel": "Button type", + "externalLink": "External link to the app", + "singleSignOn": "Single Sign-on Link", + "internalLink": "Internal link within the app (deeplink)", + "placeholder": "", + "btnWithLink": { + "alertSingleSignOn": "To enable the button with links in Single Sign-On, you must have completed the integration within the institution's systems. For further information, please contact our [support](mailto:io‑service‑management@pagopa.it)" + } + } }, "fims": { "label": "FIMS Protocol", @@ -180,6 +192,7 @@ "url": { "helperText": "", "label": "External URL", + "labelInternal": "internal URL", "placeholder": "" } }, @@ -673,4 +686,4 @@ } }, "undefined": "Not defined" -} \ No newline at end of file +} diff --git a/apps/backoffice/public/locales/it/common.json b/apps/backoffice/public/locales/it/common.json index 82a37cc8f..589b9e0dd 100644 --- a/apps/backoffice/public/locales/it/common.json +++ b/apps/backoffice/public/locales/it/common.json @@ -142,8 +142,20 @@ "description": "Queste funzionalità ti permettono di arricchire il servizio con tanti parametri aggiuntivi. Alcuni di questi richiedono la firma di un contratto ad hoc.", "arriving": "In arrivo", "cta": { - "label": "Pulsante con link esterno", - "description": "Aggiungi un pulsante che rimanda ad una pagina web con più informazioni sul servizio." + "label": "Pulsante con link", + "description": "Aggiungi uno o due pulsanti alla scheda servizio, per inidirizzare il cittadino ad un link esterno, interno o direttamente ad un’area di post-login grazie al Single Sign-On.", + "addSecondaryButton": "aggiungi pulsante secondario", + "removeSecondaryButton": "rimuovi pulsante secondario", + "form": { + "selectLabel": "Tipo di pulsante", + "externalLink": "Link esterno all'app", + "singleSignOn": "Link in Single Sign-on", + "internalLink": "Link interno all'app (deeplink)", + "placeholder": "", + "btnWithLink": { + "alertSingleSignOn": "Per abilitare il pulsante con link in Single Sign-On, è necessario aver completato l'integrazione all'interno dei sistemi dell'ente. Per ulteriori informazioni, ti invitiamo a contattare il nostro [supporto](mailto:io‑service‑management@pagopa.it)" + } + } }, "fims": { "label": "Protocollo FIMS", @@ -180,6 +192,7 @@ "url": { "helperText": "", "label": "URL esterna", + "labelInternal": "URL interna", "placeholder": "" } }, @@ -673,4 +686,4 @@ } }, "undefined": "Non definito" -} \ No newline at end of file +} diff --git a/apps/backoffice/src/components/buttons/button-add-remove.tsx b/apps/backoffice/src/components/buttons/button-add-remove.tsx new file mode 100644 index 000000000..1ceb167da --- /dev/null +++ b/apps/backoffice/src/components/buttons/button-add-remove.tsx @@ -0,0 +1,43 @@ +import { AddCircleOutline, RemoveCircleOutline } from "@mui/icons-material"; +import { Button, Grid } from "@mui/material"; +import React from "react"; + +export interface ButtonAddRemoveRowProps { + addLabel: string; + isRemoveActionVisible?: boolean; + onAdd?: () => void; + onRemove?: () => void; + removeLabel: string; +} + +export const ButtonAddRemove: React.FC = ({ + addLabel, + isRemoveActionVisible = false, + onAdd, + onRemove, + removeLabel, +}) => ( + + {isRemoveActionVisible ? ( + + ) : ( + + )} + +); + +export default ButtonAddRemove; diff --git a/apps/backoffice/src/components/cta-manager/constants.ts b/apps/backoffice/src/components/cta-manager/constants.ts new file mode 100644 index 000000000..b831c7640 --- /dev/null +++ b/apps/backoffice/src/components/cta-manager/constants.ts @@ -0,0 +1,31 @@ +import type { TFunction } from "i18next"; + +export const CTA_PREFIX_URL_SCHEMES = { + EXTERNAL: "iohandledlink://", + INTERNAL: "ioit://", + SSO: "iosso://", +} as const; + +export const URL_PREFIX_REGEX = /^(iosso:\/\/|ioit:\/\/|iohandledlink:\/\/)/; + +// default cta +export const DEFAULT_CTA = { + text: "", + url: "", + urlPrefix: "", +} as const; + +export const CTA_KIND_SELECT_ITEMS = (t: TFunction) => [ + { + label: t("forms.service.extraConfig.cta.form.externalLink"), + value: CTA_PREFIX_URL_SCHEMES.EXTERNAL, + }, + { + label: t("forms.service.extraConfig.cta.form.singleSignOn"), + value: CTA_PREFIX_URL_SCHEMES.SSO, + }, + { + label: t("forms.service.extraConfig.cta.form.internalLink"), + value: CTA_PREFIX_URL_SCHEMES.INTERNAL, + }, +]; diff --git a/apps/backoffice/src/components/cta-manager/service-cta-manager.tsx b/apps/backoffice/src/components/cta-manager/service-cta-manager.tsx new file mode 100644 index 000000000..20e7f114e --- /dev/null +++ b/apps/backoffice/src/components/cta-manager/service-cta-manager.tsx @@ -0,0 +1,125 @@ +import { Banner } from "@/components/banner"; +import { FormStepSectionWrapper } from "@/components/forms"; +import { + SelectController, + TextFieldController, + UrlFieldController, +} from "@/components/forms/controllers"; +import { Link } from "@mui/icons-material"; +import { Box, Divider, Grid } from "@mui/material"; +import { useTranslation } from "next-i18next"; +import React from "react"; +import { useFormContext } from "react-hook-form"; + +import ButtonAddRemove from "../buttons/button-add-remove"; +import { CTA_KIND_SELECT_ITEMS, CTA_PREFIX_URL_SCHEMES } from "./constants"; + +export const ServiceCtaManager: React.FC = () => { + const { t } = useTranslation(); + const { setValue, watch } = useFormContext(); + + const cta2UrlPrefixValue = watch("metadata.cta.cta_2.urlPrefix"); + const isRemoveActionVisible = cta2UrlPrefixValue !== ""; + const selectItems = CTA_KIND_SELECT_ITEMS(t); + + const renderCtaSection = (slot: "cta_1" | "cta_2") => { + //let us observe the value of the select stored referring to the current slot + const kind = watch(`metadata.cta.${slot}.urlPrefix`); + const showAddRemove = !(isRemoveActionVisible && slot === "cta_1"); + + return ( + <> + + + + + + + + {kind === CTA_PREFIX_URL_SCHEMES.SSO && ( + + + + )} + + + + + + + + + {showAddRemove && ( + + )} + + + ); + }; + + const configureDefaultDataSecondaryCta = (isActionAddEnable: boolean) => { + setValue( + "metadata.cta.cta_2", + { + text: "", + url: "", + urlPrefix: isActionAddEnable ? CTA_PREFIX_URL_SCHEMES.EXTERNAL : "", + }, + { shouldDirty: true, shouldValidate: true }, + ); + }; + + const addSecondaryCta = () => configureDefaultDataSecondaryCta(true); + const removeSecondaryCta = () => configureDefaultDataSecondaryCta(false); + + return ( + + } + title={t("forms.service.extraConfig.cta.label")} + > + {renderCtaSection("cta_1")} + {cta2UrlPrefixValue !== "" ? ( + + + {renderCtaSection("cta_2")} + + ) : null} + + + ); +}; + +export default ServiceCtaManager; diff --git a/apps/backoffice/src/components/forms/controllers/url-field-controller.tsx b/apps/backoffice/src/components/forms/controllers/url-field-controller.tsx index 248ea243b..dc58a7912 100644 --- a/apps/backoffice/src/components/forms/controllers/url-field-controller.tsx +++ b/apps/backoffice/src/components/forms/controllers/url-field-controller.tsx @@ -6,11 +6,13 @@ import { useTranslation } from "next-i18next"; import { Controller, get, useFormContext } from "react-hook-form"; export type UrlFieldControllerProps = { + hideCheckUrl?: boolean; name: string; } & TextFieldProps; /** Controller for MUI `TextField` component enhanced with url type check and a "Try URL" action button. */ export function UrlFieldController({ + hideCheckUrl = false, name, ...props }: UrlFieldControllerProps) { @@ -49,17 +51,19 @@ export function UrlFieldController({ value={value} /> - - window.open(value, "_blank")} - size="large" - startIcon={} - > - {t("forms.testUrl")} - - + {!hideCheckUrl && ( + + window.open(value, "_blank")} + size="large" + startIcon={} + > + {t("forms.testUrl")} + + + )} )} /> diff --git a/apps/backoffice/src/components/services/__tests__/adapters.test.ts b/apps/backoffice/src/components/services/__tests__/adapters.test.ts index 6b97c602f..91e1160ab 100644 --- a/apps/backoffice/src/components/services/__tests__/adapters.test.ts +++ b/apps/backoffice/src/components/services/__tests__/adapters.test.ts @@ -7,6 +7,7 @@ import { fromServiceCreateUpdatePayloadToApiServicePayload, fromServiceLifecycleToServiceCreateUpdatePayload, } from "../adapters"; +import { CTA_PREFIX_URL_SCHEMES } from "../../cta-manager/constants"; const aValidServiceCreateUpdatePayload: ServiceCreateUpdatePayload = { name: "aServiceName", @@ -18,7 +19,14 @@ const aValidServiceCreateUpdatePayload: ServiceCreateUpdatePayload = { tos_url: "aTosUrl", privacy_url: "aPrivacyUrl", address: "anAddress", - cta: { text: "aCtaText", url: "iohandledlink://aCtaUrl" }, + cta: { + cta_1: { + urlPrefix: CTA_PREFIX_URL_SCHEMES.EXTERNAL, + text: "aCtaText1", + url: "aCtaUrl1", + }, + cta_2: { urlPrefix: "", text: "", url: "" }, + }, scope: "LOCAL", assistanceChannels: [ { type: "email", value: "aValidEmail" }, @@ -33,8 +41,16 @@ const aValidServiceCreateUpdatePayload: ServiceCreateUpdatePayload = { max_allowed_payment_amount: 0, }; -const aCtaResult = - '---\nit:\n cta_1: \n text: "aCtaText"\n action: "iohandledlink://aCtaUrl"\nen:\n cta_1: \n text: "aCtaText"\n action: "iohandledlink://aCtaUrl"\n---'; +const aCtaResult = `--- +it: + cta_1: + text: "aCtaText1" + action: "${CTA_PREFIX_URL_SCHEMES.EXTERNAL}aCtaUrl1" +en: + cta_1: + text: "aCtaText1" + action: "${CTA_PREFIX_URL_SCHEMES.EXTERNAL}aCtaUrl1" +---`; const anApiServicePayloadResult = { name: aValidServiceCreateUpdatePayload.name, diff --git a/apps/backoffice/src/components/services/__tests__/cta-validation.test.ts b/apps/backoffice/src/components/services/__tests__/cta-validation.test.ts new file mode 100644 index 000000000..e480ed8fb --- /dev/null +++ b/apps/backoffice/src/components/services/__tests__/cta-validation.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi } from "vitest"; +vi.mock("@pagopa/mui-italia/dist/theme/theme", () => ({ default: {} })); +vi.mock("@pagopa/mui-italia", () => ({ default: {} })); +import { getValidationSchema } from "../service-create-update/service-builder-step-3"; +import { CTA_PREFIX_URL_SCHEMES } from "../../cta-manager/constants"; + +const validation = (s: string) => s; + +describe("CTA validation", () => { + const schema = getValidationSchema(validation as any, null); + + it("should succeed if a valid frontend service payload with a single CTA is provided", () => { + const data = { + authorized_cidrs: [], + metadata: { + cta: { + cta_1: { + urlPrefix: CTA_PREFIX_URL_SCHEMES.EXTERNAL, + text: "Primary button", + url: "https://example.com/page", + }, + }, + }, + }; + + expect(() => schema.parse(data)).not.toThrow(); + }); + + it("should succeed if a valid frontend service payload with a double CTA is provided", () => { + const data = { + authorized_cidrs: [], + metadata: { + cta: { + cta_1: { + urlPrefix: CTA_PREFIX_URL_SCHEMES.EXTERNAL, + text: "Primary button", + url: "https://example.com/page", + }, + cta_2: { + urlPrefix: CTA_PREFIX_URL_SCHEMES.EXTERNAL, + text: "Secondary button", + url: "https://example.com/other", + }, + }, + }, + }; + + expect(() => schema.parse(data)).not.toThrow(); + }); + + it("should fail if a frontend service payload has cta_2 filled while cta_1 is empty", () => { + const data = { + authorized_cidrs: [], + metadata: { + cta: { + cta_1: { + urlPrefix: "", + text: "", + url: "", + }, + cta_2: { + urlPrefix: CTA_PREFIX_URL_SCHEMES.EXTERNAL, + text: "Secondary button", + url: "https://example.com/other", + }, + }, + }, + }; + + expect(() => schema.parse(data)).toThrow(); + }); + + it("should fail if a frontend service payload contains a CTA with an invalid URL", () => { + const data = { + authorized_cidrs: [], + metadata: { + cta: { + cta_1: { + urlPrefix: CTA_PREFIX_URL_SCHEMES.EXTERNAL, + text: "Primary button", + url: "notaurl", + }, + }, + }, + }; + + expect(() => schema.parse(data)).toThrow(); + }); + + it("should succeed if a valid frontend service payload without CTAs is provided", () => { + const data = { + authorized_cidrs: [], + metadata: { + cta: { + cta_1: { urlPrefix: "", text: "", url: "" }, + cta_2: { urlPrefix: "", text: "", url: "" }, + }, + }, + }; + + expect(() => schema.parse(data)).not.toThrow(); + }); +}); diff --git a/apps/backoffice/src/components/services/adapters.ts b/apps/backoffice/src/components/services/adapters.ts index 32166cdd5..02b65d653 100644 --- a/apps/backoffice/src/components/services/adapters.ts +++ b/apps/backoffice/src/components/services/adapters.ts @@ -10,6 +10,8 @@ import { AssistanceChannel, AssistanceChannelType, AssistanceChannelsMetadata, + Cta, + Ctas, Service, ServiceCreateUpdatePayload, } from "@/types/service"; @@ -17,6 +19,8 @@ import { NonEmptyString } from "@pagopa/ts-commons/lib/strings"; import { pipe } from "fp-ts/lib/function"; import _ from "lodash"; +import { DEFAULT_CTA, URL_PREFIX_REGEX } from "../cta-manager/constants"; + const adaptServiceCommonData = ( service: ServiceLifecycle | ServicePublication, ) => ({ @@ -118,7 +122,7 @@ export const fromServiceLifecycleToServiceCreateUpdatePayload = ( app_android: sl.metadata.app_android ?? "", app_ios: sl.metadata.app_ios ?? "", assistanceChannels: convertAssistanceChannelsObjToArray(sl.metadata), - cta: buildCtaObj(sl.metadata.cta), + cta: buildCtaObj(sl.metadata.cta ?? ""), group_id: sl.metadata.group?.id, privacy_url: sl.metadata.privacy_url ?? "", scope: sl.metadata.scope, @@ -140,7 +144,7 @@ const buildBaseServicePayload = max_allowed_payment_amount: s.max_allowed_payment_amount, metadata: { ...s.metadata, - cta: buildCtaString(s.metadata.cta), + cta: buildCtaStringForPayload(s.metadata.cta), ...assistanceChannels, }, require_secure_channel: s.require_secure_channel, @@ -176,22 +180,60 @@ const convertAssistanceChannelsObjToArray = ( } return channels; }; +const buildCtaStringForPayload = (ctaObj: Ctas): string | undefined => { + const text1 = ctaObj?.cta_1?.text?.trim() ?? ""; + if (text1 === "") return undefined; -const buildCtaString = (ctaObj: { text: string; url: string }) => { - if (ctaObj.text !== "" && ctaObj.url !== "") { - return `---\nit:\n cta_1: \n text: \"${ctaObj.text}\"\n action: \"${ctaObj.url}\"\nen:\n cta_1: \n text: \"${ctaObj.text}\"\n action: \"${ctaObj.url}\"\n---`; - } - return ""; + const hasCta2 = (ctaObj?.cta_2?.text?.trim() ?? "") !== ""; + + return hasCta2 + ? buildCtaString(ctaObj.cta_1, ctaObj.cta_2) + : buildCtaString(ctaObj.cta_1); }; -const buildCtaObj = (ctaString?: string) => { - if (NonEmptyString.is(ctaString)) { - return { - text: getCtaValueFromCtaString(ctaString, "text"), - url: getCtaValueFromCtaString(ctaString, "action"), - }; +const buildCtaString = ( + ctaObj: { text: string; url: string; urlPrefix: string }, + ctaObj2?: { text: string; url: string; urlPrefix: string }, +) => { + const cta_2 = ctaObj2?.text + ? ` cta_2: \n text: \"${ctaObj2.text}\"\n action: \"${ctaObj2.urlPrefix}${ctaObj2.url}\"\n` + : ``; + return `---\nit:\n cta_1: \n text: \"${ctaObj.text}\"\n action: \"${ctaObj.urlPrefix}${ctaObj.url}\"\n${cta_2}en:\n cta_1: \n text: \"${ctaObj.text}\"\n action: \"${ctaObj.urlPrefix}${ctaObj.url}\"\n${cta_2}---`; +}; + +const splitUrlPrefix = (urlValue: string) => { + const matchedObject = urlValue.match(URL_PREFIX_REGEX); + return matchedObject + ? { + url: urlValue.slice(matchedObject[0].length), + urlPrefix: matchedObject[1], + } + : { url: urlValue, urlPrefix: "" }; +}; + +const parseCta = (source: string): Cta => { + const text = getCtaValueFromCtaString(source, "text") ?? ""; + const action = getCtaValueFromCtaString(source, "action") ?? ""; + const { url, urlPrefix } = splitUrlPrefix(action); + return { text, url, urlPrefix }; +}; + +const buildCtaObj = (ctaString?: string): Ctas => { + if (!NonEmptyString.is(ctaString)) { + return { cta_1: { ...DEFAULT_CTA }, cta_2: { ...DEFAULT_CTA } }; } - return { text: "", url: "" }; + + // we take only italian block + const itBlock = ctaString.split("en:")[0] ?? ctaString; + + // check the word cta_2: if found return true ( so we know that we have cta_2 ) + const hasCta_2 = itBlock.includes("cta_2:"); + const cta2Block = hasCta_2 ? itBlock.split("cta_2:")[1] : ""; // take the source string on the right of cta_2 string from payload + + return { + cta_1: parseCta(itBlock), + cta_2: hasCta_2 ? parseCta(cta2Block) : { ...DEFAULT_CTA }, + }; }; const getCtaValueFromCtaString = ( @@ -209,7 +251,6 @@ const getCtaValueFromCtaString = ( } return ""; }; - /** * Remove empty/undefined properties from object.\ * Since the form using react-hook-form needs controlled inputs, all fields are initialized to an empty string. diff --git a/apps/backoffice/src/components/services/service-create-update/service-builder-step-3.tsx b/apps/backoffice/src/components/services/service-create-update/service-builder-step-3.tsx index 881548fab..c90fe2297 100644 --- a/apps/backoffice/src/components/services/service-create-update/service-builder-step-3.tsx +++ b/apps/backoffice/src/components/services/service-create-update/service-builder-step-3.tsx @@ -3,10 +3,11 @@ import { FormStepSectionWrapper } from "@/components/forms"; import { TextFieldArrayController } from "@/components/forms/controllers"; import { arrayOfIPv4CidrSchema, - getOptionalUrlSchema, + getUrlSchema, } from "@/components/forms/schemas"; import { getConfiguration } from "@/config"; import { Group } from "@/generated/api/Group"; +import { Cta } from "@/types/service"; import { isGroupRequired } from "@/utils/auth-util"; import { PinDrop } from "@mui/icons-material"; import { TFunction } from "i18next"; @@ -21,22 +22,70 @@ import { ServiceGroupSelector } from "./service-group-selector"; const { GROUP_APIKEY_ENABLED } = getConfiguration(); +const makeCtaBlock = (t: TFunction<"translation", undefined>) => { + const isUrl = (s: string) => getUrlSchema(t).safeParse(s).success; + + return ( + z + .object({ + text: z.string().trim().default(""), + url: z.string().trim().default(""), + urlPrefix: z.string().trim().default(""), + }) + // if the block is partially filled in, validate each field on its own path + .refine( + (v) => !(v.urlPrefix || v.text || v.url) || v.urlPrefix.length >= 2, + { + message: t("forms.errors.field.required"), + path: ["urlPrefix"], + }, + ) + .refine((v) => !(v.urlPrefix || v.text || v.url) || v.text.length >= 2, { + message: t("forms.errors.field.required"), + path: ["text"], + }) + .refine((v) => !(v.urlPrefix || v.text || v.url) || isUrl(v.url), { + message: t("forms.errors.field.url"), + path: ["url"], + }) + ); +}; + export const getValidationSchema = ( t: TFunction<"translation", undefined>, session: Session | null, -) => - z.object({ +) => { + const ctaSchema = z + .object({ + cta_1: makeCtaBlock(t), + cta_2: makeCtaBlock(t).optional(), + }) + .refine( + (v) => { + const empty = (ctaObject?: Cta) => + !ctaObject || + (!ctaObject.urlPrefix && !ctaObject.text && !ctaObject.url); + // ok if all CTA is empty + if (empty(v.cta_1) && empty(v.cta_2)) return true; + // else cta_1 can not to be empty + return !empty(v.cta_1); + }, + { + message: t("forms.errors.field.required"), + path: ["cta_1", "urlPrefix"], + }, + ); + + return z.object({ authorized_cidrs: arrayOfIPv4CidrSchema, metadata: z.object({ - cta: z.object({ - text: z.string(), - url: getOptionalUrlSchema(t), - }), + cta: ctaSchema.optional(), group_id: isGroupRequired(session, GROUP_APIKEY_ENABLED) ? z.string().min(1, { message: t("forms.errors.field.required") }) : z.string().optional(), }), }); +}; export interface ServiceBuilderStep3Props { groups?: readonly Group[]; diff --git a/apps/backoffice/src/components/services/service-create-update/service-create-update.tsx b/apps/backoffice/src/components/services/service-create-update/service-create-update.tsx index 9afaa11bd..db1e92cf7 100644 --- a/apps/backoffice/src/components/services/service-create-update/service-create-update.tsx +++ b/apps/backoffice/src/components/services/service-create-update/service-create-update.tsx @@ -88,8 +88,8 @@ export const ServiceCreateUpdate = ({ assistanceChannels: [{ type: "email", value: "" }], category: "", cta: { - text: "", - url: "", + cta_1: { text: "", url: "", urlPrefix: "" }, + cta_2: { text: "", url: "", urlPrefix: "" }, }, custom_special_flow: "", group_id: handleOperatorWithSingleGroup(), diff --git a/apps/backoffice/src/components/services/service-create-update/service-extra-configurator.tsx b/apps/backoffice/src/components/services/service-create-update/service-extra-configurator.tsx index 754ed575b..8a30e742d 100644 --- a/apps/backoffice/src/components/services/service-create-update/service-extra-configurator.tsx +++ b/apps/backoffice/src/components/services/service-create-update/service-extra-configurator.tsx @@ -1,17 +1,8 @@ +import ButtonAddRemove from "@/components/buttons/button-add-remove"; import { useDrawer } from "@/components/drawer-provider"; -import { FormStepSectionWrapper } from "@/components/forms"; -import { - TextFieldController, - UrlFieldController, -} from "@/components/forms/controllers"; -import { - AddCircleOutline, - Link, - RemoveCircleOutline, -} from "@mui/icons-material"; +import { Link } from "@mui/icons-material"; import { Box, - Button, Chip, List, ListItem, @@ -21,36 +12,46 @@ import { Stack, Typography, } from "@mui/material"; +import _ from "lodash"; import { useTranslation } from "next-i18next"; import { ReactNode, useEffect, useState } from "react"; import { useFormContext } from "react-hook-form"; +import { ServiceCtaManager } from "../../cta-manager/service-cta-manager"; + export type ServiceExtraConfigurationType = "cta" | "fims" | "idpay"; /** Configure extra parameters/configuration for a service _(i.e.: cta, fims protocol, idpay, ...)_ */ export const ServiceExtraConfigurator = () => { const { t } = useTranslation(); const { closeDrawer, openDrawer } = useDrawer(); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { formState, getValues, setValue } = useFormContext(); const [isCtaVisible, setIsCtaVisible] = useState(false); + const { clearErrors, getValues, setValue } = useFormContext(); + + const ctaExists = + !!getValues("metadata.cta") && + ["text", "url", "urlPrefix"].some( + (field) => + !!(getValues(`metadata.cta.cta_1.${field}` as any) ?? "").trim(), + ); const handleListItemClick = (type: ServiceExtraConfigurationType) => { - if (type === "cta") setIsCtaVisible(true); + if (type === "cta") { + setIsCtaVisible(true); + } closeDrawer(); }; - - const handleRemoveCta = () => { + const removeCtaConfiguration = () => { + setValue("metadata.cta.cta_1.urlPrefix", ""); + setValue("metadata.cta.cta_1.text", ""); + setValue("metadata.cta.cta_1.url", ""); + setValue("metadata.cta.cta_2.urlPrefix", ""); + setValue("metadata.cta.cta_2.text", ""); + setValue("metadata.cta.cta_2.url", ""); + clearErrors(["metadata.cta", "metadata.cta.cta_1", "metadata.cta.cta_2"]); setIsCtaVisible(false); - setValue("metadata.cta.text", ""); - setValue("metadata.cta.url", ""); }; - const areCtaUrlAndTextEmpty = () => - getValues(["metadata.cta.text", "metadata.cta.url"]).filter( - (value) => value.trim() === "", - ).length === 2; - const renderExtraConfigListItem = (options: { arriving?: boolean; description: string; @@ -127,50 +128,22 @@ export const ServiceExtraConfigurator = () => { openDrawer(content); }; - const renderCta = () => ( - } - key={1} - title={t("forms.service.extraConfig.cta.label")} - > - - - - - ); - useEffect(() => { - setIsCtaVisible(!areCtaUrlAndTextEmpty()); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + if (ctaExists) { + setIsCtaVisible(true); + } + }, [ctaExists]); return ( - {isCtaVisible ? renderCta() : null} - + {isCtaVisible ? : null} + ); }; diff --git a/apps/backoffice/src/types/service.ts b/apps/backoffice/src/types/service.ts index 7323d512d..2e0ae30f7 100644 --- a/apps/backoffice/src/types/service.ts +++ b/apps/backoffice/src/types/service.ts @@ -44,6 +44,7 @@ export interface ServiceMetadata { } export type AssistanceChannelType = "email" | "pec" | "phone" | "support_url"; + export interface AssistanceChannel { type: AssistanceChannelType; value: string; @@ -54,6 +55,16 @@ export interface AssistanceChannelsMetadata { phone?: string; support_url?: string; } +export interface Cta { + text: string; + url: string; + urlPrefix: string; +} + +export interface Ctas { + cta_1: Cta; + cta_2: Cta; +} export interface ServiceCreateUpdatePayloadMetadata { address: string; @@ -61,10 +72,7 @@ export interface ServiceCreateUpdatePayloadMetadata { app_ios: string; assistanceChannels: AssistanceChannel[]; category?: string; - cta: { - text: string; - url: string; - }; + cta: Ctas; custom_special_flow?: string; group_id?: string; privacy_url: string;