Skip to content

Commit

Permalink
feat: add widget-login and widget onboarding schema, move FormField t…
Browse files Browse the repository at this point in the history
…o a separate component
  • Loading branch information
HoreKk committed Oct 29, 2024
1 parent ca1f5a4 commit dc62c23
Show file tree
Hide file tree
Showing 13 changed files with 506 additions and 185 deletions.
4 changes: 2 additions & 2 deletions webapp/src/components/forms/FormAutocompleteInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { FieldProps } from "./FormInput";
import { Dispatch, SetStateAction, useRef, useState } from "react";

interface Props {
field: FieldProps;
field: FieldProps & { autoFocus?: boolean };
options: string[] | undefined;
setError: any;
clearErrors: any;
Expand Down Expand Up @@ -158,7 +158,7 @@ const FormAutocompleteInput = ({
}
px={5}
py={8}
autoFocus
autoFocus={field.autoFocus}
onChange={(e: any) => {
onChange(e.target.value);
if (!options?.includes(e.target.value)) {
Expand Down
151 changes: 151 additions & 0 deletions webapp/src/components/forms/FormField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { Flex, Text } from "@chakra-ui/react";
import { Dispatch, SetStateAction } from "react";
import {
Controller,
FieldError,
FieldValues,
Path,
useFormContext,
} from "react-hook-form";
import useDebounceValueWithState from "~/hooks/useDebounceCallbackWithPending";
import { FieldMetadata } from "~/utils/form/formHelpers";
import FormAutocompleteInput from "./FormAutocompleteInput";
import FormBlock from "./FormBlock";
import FormInput from "./FormInput";
import { useQuery } from "@tanstack/react-query";

const FormField = <TFormData extends FieldValues>({
field,
setIsAutocompleteInputFocused,
}: {
field: FieldMetadata & { name: Path<TFormData>; path: string[] };
setIsAutocompleteInputFocused?: Dispatch<SetStateAction<boolean>>;
}) => {
const {
control,
register,
setError,
clearErrors,
formState: { errors },
watch,
} = useFormContext<TFormData>();
const error = errors[field.name];
const value = watch(field.name);

switch (field.kind) {
case "text":
case "email":
case "date":
return (
<FormInput
key={field.name}
register={register}
field={field}
fieldError={error as FieldError}
inputProps={{ autoFocus: field.autoFocus }}
/>
);
case "radio":
return (
<>
<Controller
control={control}
name={field.name}
render={({ field: { onChange, value } }) => (
<Flex
gap={4}
flexDir={field.variant == "inline" ? "column" : "row"}
>
{field.options?.map((option) => (
<FormBlock
key={`${field.name}-${option.value}`}
value={option.value}
kind="radio"
currentValue={value}
variant={field.variant}
iconSrc={option.iconSrc}
onChange={onChange}
>
{option.label}
</FormBlock>
))}
</Flex>
)}
/>
{error && (
<Text color="red" fontSize="sm" mt={2}>
{error.message as string}
</Text>
)}
</>
);
case "checkbox":
return (
<Flex flexDir="column" alignItems="center" w="full" gap={2} pb={32}>
{field.options?.map((option, index) => (
<Controller
key={option.value}
control={control}
name={`preferences.${index}` as Path<TFormData>}
render={({ field: { onChange } }) => (
<FormBlock
value={option.value}
currentValue={value}
kind="checkbox"
variant={field.variant}
iconSrc={option.iconSrc}
onChange={onChange}
withCheckbox
>
{option.label}
</FormBlock>
)}
/>
))}
</Flex>
);
case "autocomplete":
if (!setIsAutocompleteInputFocused) return null;

const [debouncedAddress, isDebouncePending] = useDebounceValueWithState(
value as string,
500
);

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,
}
);

return (
<FormAutocompleteInput
control={control}
options={addressOptions}
setError={setError}
clearErrors={clearErrors}
isLoading={isLoadingAddressOptions || isDebouncePending}
field={field}
fieldError={error as FieldError}
setIsInputFocused={setIsAutocompleteInputFocused}
/>
);
default:
return null;
}
};

export default FormField;
5 changes: 3 additions & 2 deletions webapp/src/components/forms/FormInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export type FieldProps = {
name: string;
kind: HTMLInputTypeAttribute | "block";
placeholder?: string;
autocomplete?: string;
prefix?: string;
values?: { value: string; label: string }[];
rules?: {
Expand All @@ -39,7 +40,7 @@ interface Props {
}

const FormInput = ({
field: { name, kind, rules, label, placeholder, prefix },
field: { name, kind, rules, label, placeholder, prefix, autocomplete },
register,
fieldError,
wrapperProps,
Expand Down Expand Up @@ -81,7 +82,7 @@ const FormInput = ({
borderRadius={16}
border="none"
errorBorderColor="transparent"
autoComplete="off"
autoComplete={autocomplete || "off"}
backgroundColor={!fieldError ? "bgGray" : "errorLight"}
pr={5}
pl={prefix ? 12 : 5}
Expand Down
1 change: 1 addition & 0 deletions webapp/src/components/landing/PhoneNumberCTA.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { push } from "@socialgouv/matomo-next";

export type LoginForm = {
phone_number: string;
user_email?: string;
};

const PhoneNumberCTA = ({
Expand Down
22 changes: 12 additions & 10 deletions webapp/src/components/wrappers/OnBoardingStepsWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,16 +48,18 @@ const OnBoardingStepsWrapper = ({
left: 50,
}}
/>
<Box bgColor="cje-gray.300" borderRadius="xl" w="30%" h="6px">
<Box
as={motion.div}
layout
h="6px"
w={`${(current / total) * 100}%`}
borderRadius="xl"
bgColor="primary"
/>
</Box>
{total > 1 && (
<Box bgColor="cje-gray.300" borderRadius="xl" w="30%" h="6px">
<Box
as={motion.div}
layout
h="6px"
w={`${(current / total) * 100}%`}
borderRadius="xl"
bgColor="primary"
/>
</Box>
)}
</Flex>
{children}
</Flex>
Expand Down
156 changes: 156 additions & 0 deletions webapp/src/pages/login-widget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { Box, Button, Flex, Heading } from "@chakra-ui/react";
import { zodResolver } from "@hookform/resolvers/zod";
import { GetServerSideProps } from "next";
import { useState } from "react";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import FormField from "~/components/forms/FormField";
import LoginOtpContent from "~/components/landing/LoginOtpContent";
import { LoginForm } from "~/components/landing/PhoneNumberCTA";
import LoginWrapper from "~/components/wrappers/LoginWrapper";
import { useAuth } from "~/providers/Auth";
import { api } from "~/utils/api";
import { generateSteps } from "~/utils/form/formHelpers";
import {
LoginWidgetFormData,
loginWidgetSchema,
} from "~/utils/form/formSchemas";
import jwt from "jsonwebtoken";
import { ZWidgetToken } from "~/server/types";
import { decryptData } from "~/utils/tools";

type HomeLoginWidgetProps = {
cej_id: string;
};

export default function HomeLoginWidget({ cej_id }: HomeLoginWidgetProps) {
const { isOtpGenerated, setIsOtpGenerated } = useAuth();

const methods = useForm<LoginWidgetFormData>({
resolver: zodResolver(loginWidgetSchema),
});

const [currentStep] = generateSteps(loginWidgetSchema);

const onSubmit: SubmitHandler<LoginWidgetFormData> = (data) => {
handleGenerateOtp({
phone_number: data.phoneNumber,
user_email: data.userEmail,
});
};

const [otpKind, setOtpKind] = useState<"otp" | "email">();
const [currentPhoneNumber, setCurrentPhoneNumber] = useState<string>("");

const { mutate: generateOtp, isLoading: isLoadingOtp } =
api.user.generateOTP.useMutation({
onSuccess: (data) => {
setIsOtpGenerated(true);
setOtpKind(data.kind);
},
onError: async ({ data }) => {
if (data?.httpStatus === 401) {
methods.setError("phoneNumber", {
type: "conflict",
message:
"Votre numéro de téléphone n'est pas autorisé à accéder à l'application",
});
} else {
methods.setError("phoneNumber", {
type: "internal",
message: "Erreur coté serveur, veuillez contacter le support",
});
}
},
});

const handleGenerateOtp: SubmitHandler<LoginForm> = async (values) => {
setCurrentPhoneNumber(values.phone_number);
generateOtp({ ...values, cej_id });
};

if (isOtpGenerated && otpKind)
return (
<LoginWrapper
onBack={() => {
setIsOtpGenerated(false);
setOtpKind(undefined);
}}
>
<Box mt={otpKind === "otp" ? 8 : 12}>
<LoginOtpContent
otpKind={otpKind}
currentPhoneNumber={currentPhoneNumber}
handleGenerateOtp={handleGenerateOtp}
/>
</Box>
</LoginWrapper>
);

return (
<LoginWrapper>
<FormProvider {...methods}>
<Flex
as="form"
onSubmit={methods.handleSubmit(onSubmit)}
flexDir="column"
mt={8}
>
<Heading as="h1" size="lg" fontWeight="extrabold" textAlign="center">
Créez votre compte pour débloquer votre code
</Heading>
<Box mt={12}>
{currentStep.fields.map((field, index) => (
<Box key={field.name} mt={index !== 0 ? 8 : 0}>
<FormField
field={field}
setIsAutocompleteInputFocused={() => {}}
/>
</Box>
))}
</Box>
<Button mt={24} type="submit" isLoading={isLoadingOtp}>
Suivant
</Button>
</Flex>
</FormProvider>
</LoginWrapper>
);
}

export const getServerSideProps: GetServerSideProps = async (context) => {
try {
let { widgetToken } = context.query;
if (!widgetToken)
widgetToken =
context.req.cookies[process.env.NEXT_PUBLIC_WIDGET_TOKEN_NAME!];

if (!widgetToken || typeof widgetToken !== "string") {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}

const decoded = jwt.verify(widgetToken, process.env.WIDGET_SECRET_JWT!);
const tokenObject = ZWidgetToken.parse(decoded);
const cejUserId = decryptData(
tokenObject.user_id,
process.env.WIDGET_SECRET_DATA_ENCRYPTION!
);

return {
props: {
cej_id: cejUserId,
},
};
} catch (error) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
};
Loading

0 comments on commit dc62c23

Please sign in to comment.