diff --git a/client/src/pages/onboarding/vv/CheckoutAddress.vue b/client/src/pages/onboarding/vv/CheckoutAddress.vue index 6dc7676b..952d96d0 100644 --- a/client/src/pages/onboarding/vv/CheckoutAddress.vue +++ b/client/src/pages/onboarding/vv/CheckoutAddress.vue @@ -12,6 +12,8 @@ import { getVVCourseName } from "./composables"; import ItToggleSwitch from "@/components/ui/ItToggleSwitch.vue"; import DatatransCembraDeviceFingerprint from "@/components/onboarding/DatatransCembraDeviceFingerprint.vue"; import { getLocalSessionKey } from "@/statistics"; +import log from "loglevel"; +import { normalizeSwissPhoneNumber, validatePhoneNumber } from "@/utils/phone"; const props = defineProps({ courseType: { @@ -126,6 +128,14 @@ function validateAddress() { formErrors.value.personal.push(t("a.Land")); } + if (address.value.phone_number) { + const normalizedPhoneNumber = normalizeSwissPhoneNumber(address.value.phone_number); + log.debug("normalizedPhoneNumber", normalizedPhoneNumber); + if (!validatePhoneNumber(normalizedPhoneNumber)) { + formErrors.value.personal.push(t("a.Telefonnummer hat das falsche Format")); + } + } + if (withCembraInvoice.value) { if (!address.value.phone_number) { formErrors.value.personal.push(t("a.Telefonnummer")); @@ -164,7 +174,8 @@ function validateAddress() { } async function saveAddress() { - const { country_code, organisation_country_code, ...profileData } = address.value; + const { country_code, organisation_country_code, phone_number, ...profileData } = + address.value; const typedProfileData: Partial = { ...profileData }; typedProfileData.country = countries.value.find( @@ -173,6 +184,7 @@ async function saveAddress() { typedProfileData.organisation_country = countries.value.find( (c) => c.country_code === organisation_country_code ); + typedProfileData.phone_number = normalizeSwissPhoneNumber(phone_number); await user.updateUserProfile(typedProfileData); } @@ -192,6 +204,8 @@ const executePayment = async () => { // anyway, so it seems fine to do it here. const fullHost = `${window.location.protocol}//${window.location.host}`; + address.value.phone_number = normalizeSwissPhoneNumber(address.value.phone_number); + itPost("/api/shop/vv/checkout/", { redirect_url: fullHost, address: address.value, @@ -343,9 +357,7 @@ const executePayment = async () => { data-cy="continue-pay" @click="executePayment" > - + diff --git a/client/src/utils/__tests__/phone.spec.ts b/client/src/utils/__tests__/phone.spec.ts new file mode 100644 index 00000000..62aef4cb --- /dev/null +++ b/client/src/utils/__tests__/phone.spec.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { normalizeSwissPhoneNumber, validatePhoneNumber } from "../phone"; + +describe("normalizeSwissPhoneNumber", () => { + it("should normalize a Swiss phone number", () => { + expect(normalizeSwissPhoneNumber("079 123 45 67")).toBe("+41791234567"); + expect(normalizeSwissPhoneNumber("00 41 79 123 45 67")).toBe("+41791234567"); + expect(normalizeSwissPhoneNumber("+41 (0)79 201 85 86")).toBe("+41792018586"); + expect(normalizeSwissPhoneNumber("+41 79 201 85 86")).toBe("+41792018586"); + }); + + it("should normalize remove spaces and special chars from foreign numbers", () => { + expect(normalizeSwissPhoneNumber("+49 30 12345678")).toBe("+493012345678"); + expect(normalizeSwissPhoneNumber("+49-30-12345678")).toBe("+493012345678"); + + expect(normalizeSwissPhoneNumber("+33 1 23 45 67 89")).toBe("+33123456789"); + expect(normalizeSwissPhoneNumber("+33-1-23-45-67-89")).toBe("+33123456789"); + + expect(normalizeSwissPhoneNumber("+39 06 12345678")).toBe("+390612345678"); + expect(normalizeSwissPhoneNumber("+43 1 2345678")).toBe("+4312345678"); + expect(normalizeSwissPhoneNumber("+423 234 5678")).toBe("+4232345678"); + }); +}); + +describe("validatePhoneNumber", () => { + it("should validate a Swiss phone number", () => { + expect(validatePhoneNumber("079 123 45 67")).toBe(true); + expect(validatePhoneNumber("00 41 79 123 45 67")).toBe(true); + expect(validatePhoneNumber("+41 (0)79 201 85 86")).toBe(true); + expect(validatePhoneNumber("+41 79 201 85 86")).toBe(true); + expect(validatePhoneNumber("026 418 01 31")).toBe(true); + + expect(validatePhoneNumber("+41 79 201 85 86 8")).toBe(false); + expect(validatePhoneNumber("079 201 85 8")).toBe(false); + expect(validatePhoneNumber("aaa aaa aaa aaa")).toBe(false); + }); + + it("should validate a foreign phone number", () => { + expect(validatePhoneNumber("+49 30 12345678")).toBe(true); + expect(validatePhoneNumber("+49 30 1234567")).toBe(false); + expect(validatePhoneNumber("+49 30 123456789")).toBe(false); + + expect(validatePhoneNumber("+33 1 23 45 67 89")).toBe(true); + expect(validatePhoneNumber("+33 1 23 45 67 89 9")).toBe(false); + + expect(validatePhoneNumber("+39 06 12345678")).toBe(true); + expect(validatePhoneNumber("+39 06 1234567")).toBe(false); + + expect(validatePhoneNumber("+43 1 2345678")).toBe(true); + expect(validatePhoneNumber("+43 1 23456789")).toBe(false); + + expect(validatePhoneNumber("+423 235 09 09")).toBe(true); + expect(validatePhoneNumber("+423 235 09 09 8")).toBe(false); + + expect(validatePhoneNumber("+354 123 4567")).toBe(true); + expect(validatePhoneNumber("+55 12 34567 8901")).toBe(true); + }); +}); diff --git a/client/src/utils/phone.ts b/client/src/utils/phone.ts new file mode 100644 index 00000000..cb03196a --- /dev/null +++ b/client/src/utils/phone.ts @@ -0,0 +1,69 @@ +export function normalizeSwissPhoneNumber(input: string) { + return input + .replace(/\s+/g, "") + .replace("(0)", "") + .replaceAll("-", "") + .replaceAll("/", "") + .replaceAll("(", "") + .replaceAll(")", "") + .replace(/^0041/, "+41") + .replace(/^\+410/, "+41") + .replace(/^0/, "+41"); +} + +export function validatePhoneNumber(input: string) { + const normalized = normalizeSwissPhoneNumber(input); + + if ( + !normalized.startsWith("+") || + isNaN(Number(normalized.slice(1))) || + normalized[1] === "0" + ) { + // phone number can only start with a + and must be followed by numbers + return false; + } + + if (["+41", "+43", "+49", "+39", "+33", "+42"].includes(normalized.slice(0, 3))) { + if ( + // Swiss and French phone numbers + (normalized.startsWith("+41") || normalized.startsWith("+33")) && + normalized.length === 12 + ) { + return true; + } else if ( + // German and Italian phone numbers + (normalized.startsWith("+49") || normalized.startsWith("+39")) && + normalized.length === 13 + ) { + return true; + } else if ( + // Austrian and Liechtenstein phone numbers + (normalized.startsWith("+43") || normalized.startsWith("+423")) && + normalized.length === 11 + ) { + return true; + } + return false; + } + + // every other country + if (normalized.length >= 10 || normalized.length <= 13) { + return true; + } + + return false; +} + +export function displaySwissPhoneNumber(input: string) { + if (input && input.length === 12 && input.startsWith("+41")) { + input = input.replace("+41", "0"); + let result = ""; + result += input.substring(0, 3) + " "; + result += input.substring(3, 6) + " "; + result += input.substring(6, 8) + " "; + result += input.substring(8); + return result; + } + + return input; +} diff --git a/cypress/e2e/checkout-vv/checkout.cy.js b/cypress/e2e/checkout-vv/checkout.cy.js index 8a480d85..4a396792 100644 --- a/cypress/e2e/checkout-vv/checkout.cy.js +++ b/cypress/e2e/checkout-vv/checkout.cy.js @@ -126,7 +126,7 @@ describe("checkout.cy.js", () => { cy.get("#postal-code").type("1719"); cy.get("#city").type("Zumholz"); - cy.get("#phone").type("+41 79 201 85 86"); + cy.get("#phone").type("079 201 85 86"); cy.get("#birth-date").type("1982-06-09"); cy.get('[data-cy="continue-pay"]').click(); @@ -141,7 +141,7 @@ describe("checkout.cy.js", () => { expect(ci.postal_code).to.equal("1719"); expect(ci.city).to.equal("Zumholz"); expect(ci.country).to.equal("CH"); - expect(ci.phone_number).to.equal("+41 79 201 85 86"); + expect(ci.phone_number).to.equal("+41792018586"); expect(ci.birth_date).to.equal("1982-06-09"); expect(ci.cembra_invoice).to.be.true;