diff --git a/package.json b/package.json index 277ff35c..403954e4 100644 --- a/package.json +++ b/package.json @@ -4,5 +4,8 @@ }, "scripts": { "prepare": "husky install" + }, + "dependencies": { + "@hookform/resolvers": "^3.9.0" } } diff --git a/webapp/src/components/forms/FormAutocompleteInput.tsx b/webapp/src/components/forms/FormAutocompleteInput.tsx index cdaa09f4..8824784a 100644 --- a/webapp/src/components/forms/FormAutocompleteInput.tsx +++ b/webapp/src/components/forms/FormAutocompleteInput.tsx @@ -34,7 +34,6 @@ interface Props { isLoading: boolean; control: Control; fieldError: FieldError | undefined; - handleSubmit: () => Promise; setIsInputFocused: Dispatch>; } @@ -46,7 +45,6 @@ const FormAutocompleteInput = ({ setError, clearErrors, isLoading, - handleSubmit, setIsInputFocused, }: Props) => { const { label, name } = field; @@ -86,6 +84,7 @@ const FormAutocompleteInput = ({ {label} + {fieldError?.message} Pas de résultats - {/* { - clearErrors(name); - setOptionsHistory([...optionsHistory, value]); - handleSubmit(); - }} - > - Valider quand même cette adresse - */} ); } @@ -236,7 +223,6 @@ const FormAutocompleteInput = ({ ); }} /> - {fieldError?.message} ); }; diff --git a/webapp/src/components/forms/FormBlock.tsx b/webapp/src/components/forms/FormBlock.tsx index 27d07129..9fd46bb7 100644 --- a/webapp/src/components/forms/FormBlock.tsx +++ b/webapp/src/components/forms/FormBlock.tsx @@ -1,21 +1,22 @@ import { Box, type ChakraProps, Checkbox, Flex, Text } from "@chakra-ui/react"; import Image, { ImageProps } from "next/image"; +import { FieldMetadata } from "~/utils/form/formHelpers"; type Props = { children: React.ReactNode; - variant?: "default" | "inline"; + kind: "checkbox" | "radio"; + variant?: FieldMetadata["variant"]; withCheckbox?: boolean; value: string; - currentValue: string | string[]; + currentValue: string | (string | undefined)[]; iconSrc?: string; onChange: (value: string | undefined) => void; wrapperProps?: ChakraProps; - iconProps?: Partial; - wrapperIconProps?: ChakraProps; }; const FormBlock = ({ children, + kind, variant = "default", withCheckbox, value, @@ -23,8 +24,6 @@ const FormBlock = ({ onChange, iconSrc, wrapperProps, - iconProps, - wrapperIconProps, }: Props) => { const isSelected = typeof currentValue === "string" @@ -59,8 +58,17 @@ const FormBlock = ({ {...wrapperProps} > {iconSrc && ( - - + + )} void }) => { + return ( + + + Veuillez accepter les conditions d'utilisation + + + + + + Conditions générales d’utilisation “Jeunes Engagés” + + + Les présentes conditions générales d’utilisation Jeunes Engagés + (dites « CGU Jeunes Engagés ») fixent le cadre juridique de + l’expérimentation de la Plateforme Carte Jeune Engagé et définissent + les conditions d’accès et d’utilisation des services par + l’Utilisateur. +
+ Toute utilisation de la Plateforme est subordonnée à l’acceptation + préalable et au respect intégral des présentes conditions générales + d’utilisation. +
+ + Article 1er - Définitions + + + + “L’Éditeur” : désigne la Fabrique Numérique des ministères sociaux + qui développe la Plateforme sous la supervision du Délégué + interministériel à la jeunesse. + + + “Entreprise Engagée” : désigne toute personne morale qui adhère à la + Plateforme et propose des offres commerciales aux Utilisateurs. + + + “Utilisateur” ou “Jeune Engagé” : désigne toute personne physique + qui dispose d’un compte sur la Plateforme. + + + “Plateforme” : désigne l’application web Carte Jeune Engagé qui + permet d'accéder au Service. + + + “Service” : désigne toutes les fonctionnalités offertes par la + Plateforme pour répondre à ses finalités. + + + Article 2 - Présentation de la plateforme + + + + La Plateforme a pour objectif de permettre aux jeunes inscrits dans + un parcours d’insertion éligible d’accéder à des biens et services + essentiels à tarif solidaire. +
+ Concrètement, la Plateforme propose aux Jeunes Engagés différentes + offres commerciales et réductions présentées par des Entreprises + Engagées. +
+ + Article 3 - Conditions d’accès + + + + L’Utilisateur de la Plateforme doit répondre aux conditions + suivantes : + + + avoir entre 18 et 25 ans ; + + être inscrit dans l’un des parcours d’insertion suivants : contrat + d’engagement jeune (CEJ), école de la 2ème chance, établissement + pour l’insertion dans l’emploi (EPIDE), service civique. + + + + L’accès est libre et gratuit à tout Utilisateur qui remplit les + conditions d’accès. La non-satisfaction de l’une des conditions + d’accès entraîne de plein droit et sans préavis la radiation de + l’Utilisateur. + + + Le présent contrat peut être résilié de plein droit en cas d’arrêt + du Service sans que le Jeune Engagé ne puisse prétendre à aucune + indemnisation d’aucune sorte. + + + Article 4 - Fonctionnalités + + + + 4.1 Création du compte + + + La création du compte nécessite de renseigner les informations + suivantes : + + + nom, + prénom, + âge, + numéro de téléphone, + adresse postale, + + photo récente pour les réductions en magasin ou l’accès aux salles + de sport. + + + + L’Utilisateur accède à son compte en renseignant son numéro de + téléphone. + + + 4.2 Utiliser un code de réduction + + + L’Utilisateur peut bénéficier des avantages de plusieurs manières : + via un code promo à usage unique, un code barre à usage unique à + scanner en caisse, via sa Carte Jeune Engagé à présenter uniquement + en magasin ou encore via des liens non indexés vers une offre + spéciale ou un lien vers une offre existante. L’offre est + automatiquement créditée au profit de l’Utilisateur. + + + Article 5 - Responsabilités + + + + 5.1 L’Éditeur de la Plateforme + + + Les sources des informations diffusées sur la Plateforme sont + réputées fiables mais la Plateforme ne garantit pas qu’elle soit + exempte de défauts, d’erreurs ou d’omissions. +
+ L'Éditeur se contente de mettre à disposition les offres + commerciales des Entreprises Engagées et ne peut en aucune + circonstance être tenu pour responsable des éventuels différends + entre l’Utilisateur et l’Entreprise Engagée. +
+ L’Éditeur ne peut voir sa responsabilité recherchée dans le cadre + des éventuels dysfonctionnements et dommages causés par les biens et + services proposés sur la Plateforme. +
+ L’Éditeur s’autorise à suspendre ou révoquer n’importe quel compte + et toutes les actions réalisées par ce biais, s’il estime que + l’usage réalisé du service porte préjudice à son image ou ne + correspond pas aux exigences de sécurité. +
+ L’Éditeur s’engage à la sécurisation de la Plateforme, notamment en + prenant toutes les mesures nécessaires permettant de garantir la + sécurité et la confidentialité des informations fournies. L’Éditeur + fournit les moyens nécessaires et raisonnables pour assurer un accès + continu, sans contrepartie financière, à la Plateforme. +
+ Il se réserve la liberté de faire évoluer, de modifier ou de + suspendre, sans préavis, la Plateforme pour des raisons de + maintenance ou pour tout autre motif jugé nécessaire. +
+ + 5.2 L’Utilisateur + + + L’utilisation du Service est personnelle, à ce titre l’Utilisateur + n’est pas autorisé à céder ou à permettre l’utilisation du Service + par un tiers. L’Utilisateur s’engage à fournir une photo pour + bénéficier d’avantages uniquement disponibles grâce à la Carte Jeune + Engagé. Il est rappelé que toute personne procédant à une fausse + déclaration pour elle-même ou pour autrui s’expose, notamment, aux + sanctions prévues à l’article 441-1 du code pénal, prévoyant des + peines pouvant aller jusqu’à trois ans d’emprisonnement et 45 000 + euros d’amende. L’Utilisateur s’engage à ne pas mettre en ligne de + contenus ou informations contraires aux dispositions légales et + réglementaires en vigueur. L’Utilisateur s’engage à communiquer des + données strictement nécessaires à sa demande. Il veille + particulièrement aux données sensibles notamment les données + relatives aux opinions philosophiques, politiques, syndicales et + religieuses. + + + Article 6 - Mise à jour des conditions générales d’utilisation + + + + Les termes des présentes CGU peuvent être amendés à tout moment, + sans préavis, en fonction des modifications apportées à la + Plateforme, de l’évolution de la législation ou pour tout autre + motif jugé nécessaire. Chaque modification donne lieu à une nouvelle + version qui est acceptée par l’Utilisateur. + +
+ + + Vous devez accepter pour continuer + + + +
+
+ ); +}; + +export default CGUAcceptContent; diff --git a/webapp/src/components/wrappers/OnBoardingStepsWrapper.tsx b/webapp/src/components/wrappers/OnBoardingStepsWrapper.tsx index 410d4cb8..b28acdec 100644 --- a/webapp/src/components/wrappers/OnBoardingStepsWrapper.tsx +++ b/webapp/src/components/wrappers/OnBoardingStepsWrapper.tsx @@ -8,11 +8,13 @@ import { HiChevronLeft } from "react-icons/hi2"; type OnBoardingStepsWrapperProps = { children: ReactNode; stepContext: { isFirstStep?: boolean; current: number; total: number }; + onBack?: () => void; }; const OnBoardingStepsWrapper = ({ children, stepContext: { isFirstStep, current, total }, + onBack, }: OnBoardingStepsWrapperProps) => { const router = useRouter(); @@ -31,7 +33,7 @@ const OnBoardingStepsWrapper = ({ h={5} minW="fit-content" isDisabled={!(current > 1 && !isFirstStep)} - onClick={() => router.back()} + onClick={() => (onBack ? onBack() : router.back())} cursor="pointer" position="absolute" left={4} diff --git a/webapp/src/pages/onboarding.tsx b/webapp/src/pages/onboarding.tsx deleted file mode 100644 index 26cb9ff3..00000000 --- a/webapp/src/pages/onboarding.tsx +++ /dev/null @@ -1,479 +0,0 @@ -import { - Box, - Button, - Center, - Flex, - Heading, - Icon, - Input, - SimpleGrid, - Text, -} from "@chakra-ui/react"; -import { useForm, type SubmitHandler, Controller } from "react-hook-form"; -import FormInput, { type FieldProps } from "~/components/forms/FormInput"; -import { useRouter } from "next/router"; -import { useEffect, useMemo, useState } from "react"; -import { HiArrowRight, HiCheck, HiCheckCircle } from "react-icons/hi2"; -import LoadingLoader from "~/components/LoadingLoader"; -import FormBlock from "~/components/forms/FormBlock"; -import OnBoardingStepsWrapper from "~/components/wrappers/OnBoardingStepsWrapper"; -import { signupSteps } from "./signup"; -import { api } from "~/utils/api"; -import { getCookie, setCookie } from "cookies-next"; -import { useAuth } from "~/providers/Auth"; - -type OnBoardingForm = { - cejFrom: string; - timeAtCEJ: string; - hasAJobIdea: string; - projectTitle?: string; - projectDescription?: string; - preferences: string[]; -}; - -export type OnBoardingFormStep = { - title: string; - description?: string; - field: FieldProps; - optional?: boolean; -}; - -export const onBoardingSteps = [ - { - title: - "Bienvenue parmi les “jeunes engagés” ! 🤝 \rQuelle est votre situation ?", - field: { - name: "cejFrom", - kind: "select", - label: "Quel établissement", - }, - }, - { - title: "Déjà une idée de projet professionnel en tête ?", - field: { - name: "hasAJobIdea", - kind: "select", - label: "Pense à une idée de formation ou métier", - }, - }, - { - title: - "Quel est votre projet et quelles réductions pourraient vous aider ?", - optional: true, - description: - "Nous ajoutons de nouvelles réductions régulièrement, dites-nous ce dont vous avez besoin", - field: { - name: "projectTitle", - kind: "text", - label: "Votre projet ? Electricien, tatoueur...", - }, - }, - { - title: "Quelles catégories vous intéressent le plus ?", - description: - "Nous allons vous présenter les meilleures réductions en fonction de vos préférences dans votre appli.", - field: { - name: "preferences", - kind: "select", - label: "Prénom", - }, - }, -] as const; - -export default function OnBoarding() { - const router = useRouter(); - - const { user, refetchUser, setShowNotificationModal } = useAuth(); - - const { onBoardingStep } = router.query as { - onBoardingStep: keyof OnBoardingForm | undefined; - }; - - const { mutateAsync: updateUser, isLoading: isLoadingUpdateUser } = - api.user.update.useMutation(); - - const [finishedOnBoarding, setFinishedOnBoarding] = useState(false); - const [currentOnBoardingStep, setCurrentOnBoardingStep] = - useState(null); - - const defaultValues = useMemo(() => { - return typeof window !== "undefined" - ? JSON.parse(localStorage.getItem("cje-onboarding-form") as string) - : {}; - }, [typeof window !== "undefined"]); - - const { - handleSubmit, - getValues, - watch, - control, - formState: { errors }, - register, - } = useForm({ - mode: "onBlur", - defaultValues, - }); - - const onSubmit: SubmitHandler = (data) => { - if (!currentOnBoardingStep) return; - const currentStepIndex = onBoardingSteps.findIndex( - (step) => step.field.name === currentOnBoardingStep.field.name - ); - if (currentStepIndex === onBoardingSteps.length - 1) { - const tmpData: any = data; - tmpData.preferences = data.preferences.filter(Boolean).map(Number); - updateUser(tmpData).then(() => { - const jwtToken = getCookie( - process.env.NEXT_PUBLIC_JWT_NAME ?? "cje-jwt" - ); - if (!jwtToken) return; - - fetch("/api/users/refresh-token", { - method: "POST", - credentials: "omit", - headers: { - Authorization: `Bearer ${jwtToken}`, - }, - }).then((req) => { - req.json().then((data) => { - setCookie( - process.env.NEXT_PUBLIC_JWT_NAME ?? "cje-jwt", - data.refreshedToken as string, - { expires: new Date((data.exp as number) * 1000) } - ); - refetchUser(); - - setFinishedOnBoarding(true); - }); - }); - }); - } else { - let nextStep = onBoardingSteps[currentStepIndex + 1]; - if (!nextStep) return; - if ( - nextStep.field.name === "projectTitle" && - formValues.hasAJobIdea === "no" - ) { - nextStep = onBoardingSteps[currentStepIndex + 2]; - } - if (!nextStep) return; - router.push({ query: { onBoardingStep: nextStep.field.name } }); - setCurrentOnBoardingStep(nextStep); - } - }; - - const formValues = watch(); - - const filteredPreferences = formValues.preferences?.filter(Boolean) || []; - - const { data: resultCategories } = api.category.getList.useQuery({ - page: 1, - perPage: 100, - sort: "createdAt", - }); - - const { data: categories } = resultCategories || {}; - - useEffect(() => { - localStorage.setItem( - "cje-onboarding-form", - JSON.stringify({ ...formValues }) - ); - }, [formValues]); - - useEffect(() => { - if (!onBoardingStep || typeof onBoardingStep !== "string") { - if (router.isReady) - router.replace({ - query: { onBoardingStep: onBoardingSteps[0].field.name }, - }); - return; - } - - const onBoardingStepNames = onBoardingSteps.map((step) => step.field.name); - - const tmpCurrentSignupStep = onBoardingSteps.find( - (step) => step.field.name === onBoardingStep - ); - - if ( - !onBoardingStepNames.includes(onBoardingStep as any) || - !tmpCurrentSignupStep - ) { - router.back(); - return; - } - - setCurrentOnBoardingStep(tmpCurrentSignupStep); - }, [onBoardingStep, router.isReady]); - - if (!currentOnBoardingStep) - return ( -
- -
- ); - - if (finishedOnBoarding) - return ( - -
- - - - Tout est bon {user?.firstName} ! - - - Vous allez maintenant pouvoir accéder à toutes les réductions - exclusives de la carte jeune engagé. - - -
- -
- ); - - const currentFieldValue = getValues( - currentOnBoardingStep.field.name as keyof OnBoardingForm - ); - - const displayStep = () => { - if (currentOnBoardingStep.field.name === "cejFrom") { - return ( - - ( - <> - - Je suis à France Travail (ex Pôle emploi) - - - Je suis à la Mission locale - - - Je suis en service civique - - - )} - /> - - ); - } - - if (currentOnBoardingStep.field.name === "hasAJobIdea") { - return ( - - ( - <> - - Oui - - - Non pas encore - - - )} - /> - - ); - } - - if (currentOnBoardingStep.field.name === "projectTitle") { - return ( - - - - - ); - } - - if (currentOnBoardingStep.field.name === "preferences") { - return ( - - - {filteredPreferences.length === 0 ? ( - "Sélectionnez au moins 3 thématiques" - ) : filteredPreferences.length > 0 && - filteredPreferences.length < 3 ? ( - `Sélectionnez encore ${ - 3 - filteredPreferences.length - } thématique${3 - filteredPreferences.length > 1 ? "s" : ""}` - ) : ( - <> - 3 sélectionnés - - - )} - - - {categories?.map((category, index) => ( - ( - = 3 && - currentFieldValue && - !currentFieldValue.includes(category.id.toString()) - ? () => {} - : onChange - } - > - {category.label} - - )} - /> - ))} - - - ); - } - }; - - return ( - step.field.name === currentOnBoardingStep.field.name - ) === 0, - current: - onBoardingSteps.findIndex( - (step) => step.field.name === currentOnBoardingStep.field.name - ) + - signupSteps.length + - 1, - total: onBoardingSteps.length + signupSteps.length, - }} - > - - - - - {currentOnBoardingStep?.title.split("\r").map((line, index) => ( - - {line} -
-
- ))} -
- {currentOnBoardingStep?.description && ( - - {currentOnBoardingStep.description} - - )} - - {displayStep()} - -
- - - {currentOnBoardingStep.optional && ( - - )} - -
-
-
- ); -} diff --git a/webapp/src/pages/signup.tsx b/webapp/src/pages/signup.tsx index d7abc3c0..0465f676 100644 --- a/webapp/src/pages/signup.tsx +++ b/webapp/src/pages/signup.tsx @@ -1,243 +1,176 @@ +import { Dispatch, SetStateAction, useEffect, useMemo, useState } from "react"; +import { + Controller, + FieldError, + FormProvider, + SubmitHandler, + useForm, + useFormContext, +} from "react-hook-form"; +import { SignupFormData, signupFormSchema } from "~/utils/form/formSchemas"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + FieldMetadata, + FormStep, + generateSteps, +} from "~/utils/form/formHelpers"; import { Box, Button, Center, - Divider, Flex, Heading, Icon, - ListItem, Text, - UnorderedList, } from "@chakra-ui/react"; -import { useForm, type SubmitHandler, Controller } from "react-hook-form"; -import FormInput, { type FieldProps } from "~/components/forms/FormInput"; -import { useRouter } from "next/router"; -import { useEffect, useMemo, useState } from "react"; -import { HiArrowRight } from "react-icons/hi2"; -import LoadingLoader from "~/components/LoadingLoader"; +import OnBoardingStepsWrapper from "~/components/wrappers/OnBoardingStepsWrapper"; +import FormInput from "~/components/forms/FormInput"; import FormBlock from "~/components/forms/FormBlock"; +import useDebounceValueWithState from "~/hooks/useDebounceCallbackWithPending"; import { useQuery } from "@tanstack/react-query"; -import OnBoardingStepsWrapper from "~/components/wrappers/OnBoardingStepsWrapper"; +import FormAutocompleteInput from "~/components/forms/FormAutocompleteInput"; +import { motion } from "framer-motion"; +import { HiArrowRight } from "react-icons/hi2"; +import Image from "next/image"; import { api } from "~/utils/api"; import { getCookie, setCookie } from "cookies-next"; -import useDebounceValueWithState from "~/hooks/useDebounceCallbackWithPending"; -import FormAutocompleteInput from "~/components/forms/FormAutocompleteInput"; import { useAuth } from "~/providers/Auth"; -import Image from "next/image"; +import { useRouter } from "next/router"; import { isIOS } from "~/utils/tools"; -import { chakra } from "@chakra-ui/react"; -import { motion, isValidMotionProp } from "framer-motion"; +import LoadingLoader from "~/components/LoadingLoader"; +import CGUAcceptContent from "~/components/signup/cguAcceptContent"; +import { useLocalStorage } from "usehooks-ts"; -const ChakraBox = chakra(motion.div, { - shouldForwardProp: isValidMotionProp, -}); +const FormField: React.FC<{ + field: FieldMetadata & { name: string; path: string[] }; + setIsAutocompleteInputFocused: Dispatch>; +}> = ({ field, setIsAutocompleteInputFocused }) => { + const { + control, + register, + setError, + clearErrors, + formState: { errors }, + watch, + } = useFormContext(); + const error = errors[field.name as keyof SignupFormData]; + const value = watch(field.name as keyof SignupFormData); -type SignUpForm = { - hasAcceptedCGU: boolean; - civility: "man" | "woman"; - firstName: string; - lastName: string; - birthDate: string; - userEmail: string; - address: string; - cejFrom: - | "serviceCivique" - | "ecole2ndeChance" - | "epide" - | "franceTravail" - | "missionLocale"; - preferences: string[]; -}; + switch (field.kind) { + case "text": + case "email": + case "date": + return ( + + ); + case "radio": + return ( + <> + ( + + {field.options?.map((option) => ( + + {option.label} + + ))} + + )} + /> + {error && ( + + {error.message} + + )} + + ); + case "checkbox": + return ( + + {field.options?.map((option, index) => ( + ( + + {option.label} + + )} + /> + ))} + + ); + case "autocomplete": + const [debouncedAddress, isDebouncePending] = useDebounceValueWithState( + value as string, + 500 + ); -export type SignUpFormStep = { - title: string | JSX.Element; - description?: string; - imageUrl?: string; - field: FieldProps; -}; + const { data: addressOptions, isLoading: isLoadingAddressOptions } = + useQuery( + ["getAddressOptions", debouncedAddress], + async () => { + const formatedDebouncedAddress = debouncedAddress.split(",")[0]; + const response = await fetch( + `https://geo.api.gouv.fr/communes?nom=${formatedDebouncedAddress}&codeDepartement=95&fields=departement&limit=5` + ); + const data = await response.json(); + return data.map( + (municipality: any) => + `${municipality.nom}, ${municipality.departement.nom}` + ) as string[]; + }, + { + enabled: !!debouncedAddress && debouncedAddress.length > 2, + } + ); -export const signupSteps = [ - { - title: "Quel est votre prénom ?", - description: - "Saisissez la même information que sur vos documents administratifs officiels.", - field: { - name: "firstName", - kind: "text", - label: "Prénom", - placeholder: "Votre prénom", - rules: { - required: "Ce champ est obligatoire", - minLength: { - value: 2, - message: "Votre prénom doit contenir au moins 2 caractères", - }, - maxLength: { - value: 50, - message: "Votre prénom ne peut pas contenir plus de 50 caractères", - }, - }, - }, - }, - { - title: "On peut vous appeler comment ?", - description: - "Saisissez la même information que sur vos documents administratifs officiels.", - field: { - name: "civility", - kind: "block", - label: "Civilité", - values: [ - { - value: "man", - label: "Monsieur", - }, - { - value: "woman", - label: "Madame", - }, - ], - rules: { - required: "Ce champ est obligatoire", - }, - }, - }, - { - title: "Quel est votre nom de famille ?", - description: - "Saisissez la même information que sur vos documents administratifs officiels.", - field: { - name: "lastName", - kind: "text", - label: "Nom de famille", - placeholder: "Votre nom de famille", - rules: { - required: "Ce champ est obligatoire", - minLength: { - value: 2, - message: "Votre nom doit contenir au moins 2 caractères", - }, - maxLength: { - value: 50, - message: "Votre nom ne peut pas contenir plus de 50 caractères", - }, - }, - }, - }, - { - title: "À quel organisme êtes-vous rattaché ?", - field: { - name: "cejFrom", - kind: "block", - values: [ - { - value: "serviceCivique", - label: "Je suis en Service Civique", - }, - { - value: "ecole2ndeChance", - label: "Je suis en école de la 2nde chance", - }, - { - value: "epide", - label: "Je suis en EPIDE", - }, - { - value: "franceTravail", - label: "Je suis à France travail", - }, - { - value: "missionLocale", - label: "Je suis à la Mission Locale", - }, - ], - label: "Quel établissement", - }, - }, - { - title: ( - - Votre adresse email - - (obligatoire) - - - ), - description: - "Votre adresse email vous servira à récupérer votre compte si il y a un problème avec votre n° de téléphone", - field: { - name: "userEmail", - kind: "email", - label: "Email", - placeholder: "Votre adresse email", - rules: { - required: "Ce champ est obligatoire", - pattern: { - value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i, - message: "Veuillez saisir une adresse email valide", - }, - }, - }, - }, - { - title: "Votre date de naissance", - description: - "L’application est réservé au 16-25 ans la date de naissance ne sera communiquée à personne", - field: { - name: "birthDate", - kind: "date", - label: "Date de naissance", - placeholder: "JJ/MM/AAAA", - rules: { - required: "Ce champ est obligatoire", - // rules to validate from 16 years old to 26 years old - validate: (value: string | number) => { - const brithDate = new Date(value); - const now = new Date(); - const age = now.getFullYear() - brithDate.getFullYear(); - const hasNot26Yet = - brithDate.getMonth() >= now.getMonth() && - (brithDate.getMonth() === now.getMonth() - ? brithDate.getDate() > now.getDate() - : true); - return age >= 16 && age <= 26 && (age !== 26 ? true : hasNot26Yet) - ? true - : "Vous devez avoir entre 16 et 26 ans pour vous inscrire"; - }, - }, - }, - }, - { - title: "Votre ville", - description: "Pour trouver les promotions proches de chez vous.", - imageUrl: "/images/onboarding/map-pin.png", - field: { - name: "address", - kind: "text", - label: "Nom de ma ville", - placeholder: "Chercher le nom de votre ville", - rules: { - required: "Ce champ est obligatoire", - }, - }, - }, - { - title: "Qu’est-ce qui vous intéresse le plus ?", - field: { - name: "preferences", - kind: "block", - label: "Préférences", - rules: { - required: "Ce champ est obligatoire", - }, - }, - }, -] as SignUpFormStep[]; + return ( + + ); + default: + return null; + } +}; -export default function Signup() { +const SignupPage: React.FC = () => { const router = useRouter(); - const { user, refetchUser, @@ -246,608 +179,272 @@ export default function Signup() { } = useAuth(); const { signupStep } = router.query as { - signupStep: keyof Omit | undefined; + signupStep: string | undefined; }; - const { mutateAsync: updateUser } = api.user.update.useMutation(); - - const [currentSignupStep, setCurrentSignupStep] = - useState(null); + const [hasAcceptedCGU, setHasAcceptedCGU] = useLocalStorage( + "cje-signup-cgu", + false + ); + const [isLoading, setIsLoading] = useState(true); + const [currentStep, setCurrentStep] = useState(0); + const [steps, setSteps] = useState(generateSteps(signupFormSchema)); const defaultValues = useMemo(() => { return typeof window !== "undefined" ? JSON.parse(localStorage.getItem("cje-signup-form") as string) - : {}; + : { preferences: [] }; }, [typeof window !== "undefined"]); - const { - handleSubmit, - register, - setValue, - getValues, - setError, - clearErrors, - watch, - control, - formState: { errors }, - } = useForm({ + const methods = useForm({ + resolver: zodResolver(signupFormSchema), mode: "onBlur", defaultValues, }); - const [isSubmitting, setIsSubmitting] = useState(false); + const { mutateAsync: updateUser } = api.user.update.useMutation(); + const { data: resultTags } = api.globals.tagsListOrdered.useQuery(); + const { data: tags } = resultTags || { data: [] }; + + const activeStep = steps[currentStep]; + const isActiveStepPreferences = activeStep.fields[0].name === "preferences"; + const formValues = methods.getValues(); + const [isAutocompleteInputFocused, setIsAutocompleteInputFocused] = useState(false); - const onSubmit: SubmitHandler = (data) => { - if (!currentSignupStep) return; - setIsSubmitting(true); - const currentStepIndex = signupSteps.findIndex( - (step) => step.field.name === currentSignupStep.field.name - ); - if (currentStepIndex === signupSteps.length - 1) { - localStorage.removeItem("cje-signup-form"); - updateUser({ - ...data, - preferences: data.preferences.filter(Boolean).map(Number), - }).then(() => { - const jwtToken = getCookie( - process.env.NEXT_PUBLIC_JWT_NAME ?? "cje-jwt" - ); - if (!jwtToken) return; + const onSubmit: SubmitHandler = (data) => { + updateUser({ + ...data, + preferences: data.preferences.filter(Boolean).map(Number), + }).then(() => { + const jwtToken = getCookie(process.env.NEXT_PUBLIC_JWT_NAME ?? "cje-jwt"); + if (!jwtToken) return; - fetch("/api/users/refresh-token", { - method: "POST", - credentials: "omit", - headers: { - Authorization: `Bearer ${jwtToken}`, - }, - }).then((req) => { - req.json().then((data) => { - setCookie( - process.env.NEXT_PUBLIC_JWT_NAME ?? "cje-jwt", - data.refreshedToken as string, - { expires: new Date((data.exp as number) * 1000) } - ); - refetchUser().then(() => { - setIsSubmitting(false); - router.push("/dashboard"); - if (!!user && !user.notification_status && !isIOS()) { - setShowNotificationModal(true); - } else { - setShowSplashScreenModal(true); - } - }); + fetch("/api/users/refresh-token", { + method: "POST", + credentials: "omit", + headers: { + Authorization: `Bearer ${jwtToken}`, + }, + }).then((req) => { + req.json().then((data) => { + setCookie( + process.env.NEXT_PUBLIC_JWT_NAME ?? "cje-jwt", + data.refreshedToken as string, + { expires: new Date((data.exp as number) * 1000) } + ); + refetchUser().then(() => { + localStorage.removeItem("cje-signup-cgu"); + localStorage.removeItem("cje-signup-form"); + router.push("/dashboard"); + if (!!user && !user.notification_status && !isIOS()) { + setShowNotificationModal(true); + } else { + setShowSplashScreenModal(true); + } }); }); }); - } else { - const nextStep = signupSteps[currentStepIndex + 1]; - if (!nextStep) return; - router.push({ query: { signupStep: nextStep.field.name } }); - setCurrentSignupStep(nextStep); - setIsSubmitting(false); - } + }); }; - const formValues = watch(); + const handleNextStep = () => { + localStorage.setItem( + "cje-signup-form", + JSON.stringify({ + ...methods.getValues(), + signupStepNumber: currentStep + 1, + }) + ); + router.replace({ query: { signupStep: currentStep + 1 } }); + setCurrentStep((prev) => prev + 1); + }; - const { data: resultTags } = api.globals.tagsListOrdered.useQuery(); - const { data: tags } = resultTags || { data: [] }; + const handleNext = async () => { + const currentFields = activeStep.fields + .filter((field) => field.kind !== "checkbox") + .map((field) => field.name); - const [debouncedAddress, isDebouncePending] = useDebounceValueWithState( - formValues.address, - 500 - ); + const isValid = + (await methods.trigger(currentFields as (keyof SignupFormData)[])) && + Object.keys(methods.formState.errors).filter((errorKey) => + currentFields.includes(errorKey) + ).length === 0; - const { data: addressOptions, isLoading: isLoadingAddressOptions } = useQuery( - ["getAddressOptions", debouncedAddress], - async () => { - const formatedDebouncedAddress = debouncedAddress.split(",")[0]; - const response = await fetch( - `https://geo.api.gouv.fr/communes?nom=${formatedDebouncedAddress}&codeDepartement=95&fields=departement&limit=5` - ); - const data = await response.json(); - return data.map( - (municipality: any) => - `${municipality.nom}, ${municipality.departement.nom}` - ) as string[]; - }, - { - enabled: !!debouncedAddress && debouncedAddress.length > 2, + if (isValid && currentStep < steps.length - 1) { + handleNextStep(); } - ); + }; + + const handlePrevious = () => { + if (currentStep > 0) { + router.replace({ query: { signupStep: currentStep - 1 } }); + setCurrentStep((prev) => prev - 1); + } + }; useEffect(() => { - const { address, ...tmpFormValues } = formValues; - localStorage.setItem( - "cje-signup-form", - JSON.stringify({ ...tmpFormValues, address: debouncedAddress }) - ); - }, [formValues, debouncedAddress]); + if (tags && tags.length > 0) { + signupFormSchema.shape.preferences.meta.options = tags.map((tag) => ({ + label: tag.label, + value: tag.id.toString(), + iconSrc: tag.icon.url as string, + })); + setSteps(generateSteps(signupFormSchema)); + } + }, [tags]); useEffect(() => { + const localStorageSignupStep = + JSON.parse(localStorage.getItem("cje-signup-form") as string) + ?.signupStepNumber ?? 0; + if (!signupStep || typeof signupStep !== "string") { - if (router.isReady) - router.replace({ query: { signupStep: signupSteps[0].field.name } }); + console.log("signupStep is not a string"); + if (router.isReady) { + router.replace({ + query: { signupStep: localStorageSignupStep }, + }); + setIsLoading(false); + } return; } - const signupStepNames = signupSteps.map((step) => step.field.name); + const signupStepNumber = parseInt(signupStep); - const tmpCurrentSignupStep = signupSteps.find( - (step) => step.field.name === signupStep - ); + if (signupStepNumber > localStorageSignupStep) { + router.replace({ query: { signupStep: localStorageSignupStep } }); + setIsLoading(false); + return; + } - if (!signupStepNames.includes(signupStep) || !tmpCurrentSignupStep) { + if (signupStepNumber < 0 || signupStepNumber >= steps.length) { router.back(); return; } - setCurrentSignupStep(tmpCurrentSignupStep); + setCurrentStep(signupStepNumber); + setIsLoading(false); }, [signupStep, router.isReady]); - if (!currentSignupStep) + if (isLoading) return ( -
- -
+ +
+ +
+
); - if (!formValues.hasAcceptedCGU) { - return ( - - - Veuillez accepter les conditions d'utilisation - - - - - - Conditions générales d’utilisation “Jeunes Engagés” - - - Les présentes conditions générales d’utilisation Jeunes Engagés - (dites « CGU Jeunes Engagés ») fixent le cadre juridique de - l’expérimentation de la Plateforme Carte Jeune Engagé et - définissent les conditions d’accès et d’utilisation des services - par l’Utilisateur. -
- Toute utilisation de la Plateforme est subordonnée à l’acceptation - préalable et au respect intégral des présentes conditions - générales d’utilisation. -
- - Article 1er - Définitions - - - - “L’Éditeur” : désigne la Fabrique Numérique des ministères sociaux - qui développe la Plateforme sous la supervision du Délégué - interministériel à la jeunesse. - - - “Entreprise Engagée” : désigne toute personne morale qui adhère à - la Plateforme et propose des offres commerciales aux Utilisateurs. - - - “Utilisateur” ou “Jeune Engagé” : désigne toute personne physique - qui dispose d’un compte sur la Plateforme. - - - “Plateforme” : désigne l’application web Carte Jeune Engagé qui - permet d'accéder au Service. - - - “Service” : désigne toutes les fonctionnalités offertes par la - Plateforme pour répondre à ses finalités. - - - Article 2 - Présentation de la plateforme - - - - La Plateforme a pour objectif de permettre aux jeunes inscrits - dans un parcours d’insertion éligible d’accéder à des biens et - services essentiels à tarif solidaire. -
- Concrètement, la Plateforme propose aux Jeunes Engagés différentes - offres commerciales et réductions présentées par des Entreprises - Engagées. -
- - Article 3 - Conditions d’accès - - - - L’Utilisateur de la Plateforme doit répondre aux conditions - suivantes : - - - avoir entre 18 et 25 ans ; - - être inscrit dans l’un des parcours d’insertion suivants : - contrat d’engagement jeune (CEJ), école de la 2ème chance, - établissement pour l’insertion dans l’emploi (EPIDE), service - civique. - - - - L’accès est libre et gratuit à tout Utilisateur qui remplit les - conditions d’accès. La non-satisfaction de l’une des conditions - d’accès entraîne de plein droit et sans préavis la radiation de - l’Utilisateur. - - - Le présent contrat peut être résilié de plein droit en cas d’arrêt - du Service sans que le Jeune Engagé ne puisse prétendre à aucune - indemnisation d’aucune sorte. - - - Article 4 - Fonctionnalités - - - - 4.1 Création du compte - - - La création du compte nécessite de renseigner les informations - suivantes : - - - nom, - prénom, - âge, - numéro de téléphone, - adresse postale, - - photo récente pour les réductions en magasin ou l’accès aux - salles de sport. - - - - L’Utilisateur accède à son compte en renseignant son numéro de - téléphone. - - - 4.2 Utiliser un code de réduction - - - L’Utilisateur peut bénéficier des avantages de plusieurs manières - : via un code promo à usage unique, un code barre à usage unique à - scanner en caisse, via sa Carte Jeune Engagé à présenter - uniquement en magasin ou encore via des liens non indexés vers une - offre spéciale ou un lien vers une offre existante. L’offre est - automatiquement créditée au profit de l’Utilisateur. - - - Article 5 - Responsabilités - - - - 5.1 L’Éditeur de la Plateforme - - - Les sources des informations diffusées sur la Plateforme sont - réputées fiables mais la Plateforme ne garantit pas qu’elle soit - exempte de défauts, d’erreurs ou d’omissions. -
- L'Éditeur se contente de mettre à disposition les offres - commerciales des Entreprises Engagées et ne peut en aucune - circonstance être tenu pour responsable des éventuels différends - entre l’Utilisateur et l’Entreprise Engagée. -
- L’Éditeur ne peut voir sa responsabilité recherchée dans le cadre - des éventuels dysfonctionnements et dommages causés par les biens - et services proposés sur la Plateforme. -
- L’Éditeur s’autorise à suspendre ou révoquer n’importe quel compte - et toutes les actions réalisées par ce biais, s’il estime que - l’usage réalisé du service porte préjudice à son image ou ne - correspond pas aux exigences de sécurité. -
- L’Éditeur s’engage à la sécurisation de la Plateforme, notamment - en prenant toutes les mesures nécessaires permettant de garantir - la sécurité et la confidentialité des informations fournies. - L’Éditeur fournit les moyens nécessaires et raisonnables pour - assurer un accès continu, sans contrepartie financière, à la - Plateforme. -
- Il se réserve la liberté de faire évoluer, de modifier ou de - suspendre, sans préavis, la Plateforme pour des raisons de - maintenance ou pour tout autre motif jugé nécessaire. -
- - 5.2 L’Utilisateur - - - L’utilisation du Service est personnelle, à ce titre l’Utilisateur - n’est pas autorisé à céder ou à permettre l’utilisation du Service - par un tiers. L’Utilisateur s’engage à fournir une photo pour - bénéficier d’avantages uniquement disponibles grâce à la Carte - Jeune Engagé. Il est rappelé que toute personne procédant à une - fausse déclaration pour elle-même ou pour autrui s’expose, - notamment, aux sanctions prévues à l’article 441-1 du code pénal, - prévoyant des peines pouvant aller jusqu’à trois ans - d’emprisonnement et 45 000 euros d’amende. L’Utilisateur s’engage - à ne pas mettre en ligne de contenus ou informations contraires - aux dispositions légales et réglementaires en vigueur. - L’Utilisateur s’engage à communiquer des données strictement - nécessaires à sa demande. Il veille particulièrement aux données - sensibles notamment les données relatives aux opinions - philosophiques, politiques, syndicales et religieuses. - - - Article 6 - Mise à jour des conditions générales d’utilisation - - - - Les termes des présentes CGU peuvent être amendés à tout moment, - sans préavis, en fonction des modifications apportées à la - Plateforme, de l’évolution de la législation ou pour tout autre - motif jugé nécessaire. Chaque modification donne lieu à une - nouvelle version qui est acceptée par l’Utilisateur. - -
- - - Vous devez accepter pour continuer - - - -
-
- ); - } - - const currentFieldValue = getValues( - currentSignupStep.field.name as keyof SignUpForm - ); + if (!hasAcceptedCGU) + return setHasAcceptedCGU(true)} />; return ( step.field.name === currentSignupStep.field.name - ) + 1, - total: signupSteps.length + 1, - }} + stepContext={{ current: currentStep + 1, total: steps.length }} + onBack={handlePrevious} > -
- + - - - {currentSignupStep.title} - - {!isAutocompleteInputFocused && ( - <> - - {currentSignupStep.description} - - {currentSignupStep.imageUrl && ( -
- {currentSignupStep.title -
- )} - - )} - - {(currentSignupStep.field.name === "civility" || - currentSignupStep.field.name === "cejFrom") && - currentSignupStep.field.values ? ( - - ( - <> - {(currentSignupStep.field.values ?? []).map((block) => ( - - {block.label} - - ))} - - )} - /> - - ) : currentSignupStep.field.name === "preferences" ? ( - - {tags.map((tag, index) => ( - ( - - {tag.label} - - )} - /> - ))} - - ) : currentSignupStep.field.name === "address" ? ( - - ] - } - handleSubmit={() => handleSubmit(onSubmit)()} - setIsInputFocused={setIsAutocompleteInputFocused} - /> - ) : ( - - ] - } - /> + + + + {activeStep.title} + + {!isAutocompleteInputFocused && ( + <> + + {activeStep.description} + + {activeStep.imageSrc && ( +
+ {activeStep.title +
+ )} + )} -
-
- + {activeStep.fields.map((field, index) => ( + + + + ))} +
+ + - - - -
+ +
+ + + ); -} +}; + +export default SignupPage; diff --git a/webapp/src/utils/form/formHelpers.ts b/webapp/src/utils/form/formHelpers.ts new file mode 100644 index 00000000..6bd3498e --- /dev/null +++ b/webapp/src/utils/form/formHelpers.ts @@ -0,0 +1,87 @@ +// types.ts +import { z } from "zod"; + +// Field metadata types +export type FieldMetadata = { + label?: string; + kind: "text" | "email" | "date" | "radio" | "checkbox" | "autocomplete"; + placeholder?: string; + options?: { label: string; value: string; iconSrc?: string }[]; + variant?: "default" | "inline"; + step?: number; // Which form step this field belongs to + stepTitle?: string; // Title for the step + stepDescription?: string; // Description for the step + stepImageSrc?: string; // Image for the step +}; + +export type FormStep = { + title: string; + description: string; + imageSrc: string; + fields: (FieldMetadata & { + name: string; + path: string[]; + })[]; +}[]; + +// Helper function to extract field metadata from schema +const extractFieldMetadata = (schema: z.ZodObject) => { + const fields: Record = {}; + + const traverse = (obj: any, path: string[] = []) => { + if (obj.meta) { + fields[path.join(".")] = { ...obj.meta, path }; + return; + } + + if (obj.shape) { + Object.entries(obj.shape).forEach(([key, value]: [string, any]) => { + traverse(value, [...path, key]); + }); + } + }; + + traverse(schema); + return fields; +}; + +// Generate steps configuration from schema +export const generateSteps = (schema: z.ZodObject): FormStep => { + const fields = extractFieldMetadata(schema); + const steps = Object.entries(fields).reduce( + (acc, [path, field]) => { + const step = field.step ?? 1; + if (!acc[step]) { + acc[step] = { + fields: [], + title: field.stepTitle || `Step ${step}`, + description: field.stepDescription || "", + imageSrc: field.stepImageSrc || "", + }; + } + acc[step].fields.push({ + ...field, + name: path, + }); + return acc; + }, + {} as Record< + number, + { + fields: (FieldMetadata & { name: string; path: string[] })[]; + title: string; + description: string; + imageSrc: string; + } + > + ); + + return Object.entries(steps) + .sort(([a], [b]) => Number(a) - Number(b)) + .map(([_, stepConfig]) => ({ + title: stepConfig.title, + description: stepConfig.description, + imageSrc: stepConfig.imageSrc, + fields: stepConfig.fields, + })); +}; diff --git a/webapp/src/utils/form/formSchemas.ts b/webapp/src/utils/form/formSchemas.ts new file mode 100644 index 00000000..3406e84d --- /dev/null +++ b/webapp/src/utils/form/formSchemas.ts @@ -0,0 +1,163 @@ +import { z } from "zod"; +import { FieldMetadata } from "./formHelpers"; + +const withMeta = ( + schema: T, + meta: FieldMetadata +): T & { meta: FieldMetadata } => { + (schema as any).meta = meta; + return schema as T & { meta: FieldMetadata }; +}; + +export const signupFormSchema = z.object({ + firstName: withMeta( + z + .string() + .min(2, "Votre prénom doit contenir au moins 2 caractères") + .max(50, "Votre prénom doit contenir au plus 50 caractères"), + { + step: 1, + stepTitle: "Quel est votre prénom ?", + stepDescription: + "Saisissez la même information que sur vos documents administratifs officiels.", + label: "Prénom", + kind: "text", + placeholder: "Votre prénom", + } + ), + civility: withMeta( + z.enum(["man", "woman"], { message: "Ce champ est obligatoire" }), + { + step: 2, + stepTitle: "Quel est votre nom de famille ?", + stepDescription: + "Saisissez la même information que sur vos documents administratifs officiels.", + label: "Email Address", + kind: "radio", + placeholder: "john@example.com", + options: [ + { label: "Homme", value: "man" }, + { label: "Femme", value: "woman" }, + ], + } + ), + lastName: withMeta( + z + .string({ required_error: "Ce champ est obligatoire" }) + .min(2, "Votre nom doit contenir au moins 2 caractères") + .max(50, "Votre nom doit contenir au plus 50 caractères"), + { + step: 2, + label: "Nom de Famille", + kind: "text", + placeholder: "Votre nom de famille", + } + ), + cejFrom: withMeta( + z.enum( + [ + "serviceCivique", + "ecole2ndeChance", + "epide", + "franceTravail", + "missionLocale", + ], + { message: "Ce champ est obligatoire" } + ), + { + step: 3, + stepTitle: "À quel organisme êtes-vous rattaché ?", + label: "Quel établissement", + kind: "radio", + variant: "inline", + options: [ + { + value: "serviceCivique", + label: "Je suis en Service Civique", + iconSrc: "/images/referent/serviceCivique.png", + }, + { + value: "ecole2ndeChance", + label: "Je suis en école de la 2nde chance", + iconSrc: "/images/referent/ecole2ndeChance.png", + }, + { + value: "epide", + label: "Je suis en EPIDE", + iconSrc: "/images/referent/epide.png", + }, + { + value: "franceTravail", + label: "Je suis à France travail", + iconSrc: "/images/referent/franceTravail.png", + }, + { + value: "missionLocale", + label: "Je suis à la Mission Locale", + iconSrc: "/images/referent/missionLocale.png", + }, + ], + } + ), + email: withMeta( + z + .string({ required_error: "Ce champ est obligatoire" }) + .email("Veuillez saisir une adresse email valide"), + { + step: 4, + stepTitle: "Votre adresse email", + stepDescription: + "Votre adresse email vous servira à récupérer votre compte si il y a un problème avec votre n° de téléphone", + label: "Votre adresse email", + kind: "email", + } + ), + birthDate: withMeta( + z + .string({ required_error: "Ce champ est obligatoire" }) + .date("Veuillez saisir une date de naissance valide") + .refine( + (value) => { + const brithDate = new Date(value); + const now = new Date(); + const age = now.getFullYear() - brithDate.getFullYear(); + const hasNot26Yet = + brithDate.getMonth() >= now.getMonth() && + (brithDate.getMonth() === now.getMonth() + ? brithDate.getDate() > now.getDate() + : true); + return age >= 16 && age <= 26 && (age !== 26 ? true : hasNot26Yet); + }, + { message: "Vous devez avoir entre 16 et 26 ans pour vous inscrire" } + ), + { + step: 5, + stepTitle: "Votre date de naissance", + stepDescription: + "L’application est réservé au 16-25 ans la date de naissance ne sera communiquée à personne", + label: "Date de naissance", + kind: "date", + } + ), + address: withMeta(z.string({ required_error: "Ce champ est obligatoire" }), { + step: 6, + stepTitle: "Votre ville", + stepDescription: "Pour trouver les promotions proches de chez vous.", + stepImageSrc: "/images/onboarding/map-pin.png", + label: "Nom de ma ville", + kind: "autocomplete", + placeholder: "Chercher le nom de votre ville", + }), + preferences: withMeta( + z.array(z.string().optional()).transform((prefs) => prefs.filter(Boolean)), + { + step: 7, + stepTitle: "Qu’est-ce qui vous intéresse le plus ?", + kind: "checkbox", + options: [], + variant: "inline", + } + ), +}); + +export type SignupFormData = z.infer; diff --git a/webapp/yarn.lock b/webapp/yarn.lock index bdbbd3dd..f36aff04 100644 --- a/webapp/yarn.lock +++ b/webapp/yarn.lock @@ -3330,6 +3330,15 @@ __metadata: languageName: node linkType: hard +"@hookform/resolvers@npm:^3.9.0": + version: 3.9.0 + resolution: "@hookform/resolvers@npm:3.9.0" + peerDependencies: + react-hook-form: ^7.0.0 + checksum: 10c0/0e0e55f63abbd212cf14abbd39afad1f9b6105d6b25ce827fc651b624ed2be467ebe9b186026e0f032062db59ce2370b14e9583b436ae2d057738bdd6f04356c + languageName: node + linkType: hard + "@httptoolkit/websocket-stream@npm:^6.0.1": version: 6.0.1 resolution: "@httptoolkit/websocket-stream@npm:6.0.1" @@ -14815,6 +14824,7 @@ __metadata: "@emotion/react": "npm:^11.11.1" "@emotion/styled": "npm:^11.11.0" "@gsap/react": "npm:^2.1.0" + "@hookform/resolvers": "npm:^3.9.0" "@payloadcms/bundler-webpack": "npm:^1.0.5" "@payloadcms/db-postgres": "npm:^0.7.0" "@payloadcms/next-payload": "npm:^0.1.11"