-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add widget-login and widget onboarding schema, move FormField t…
…o a separate component
- Loading branch information
Showing
13 changed files
with
506 additions
and
185 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}, | ||
}; | ||
} | ||
}; |
Oops, something went wrong.