diff --git a/webapp/package.json b/webapp/package.json index 35995564..f20fd5a9 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -51,6 +51,7 @@ "react-easy-crop": "^5.0.5", "react-hook-form": "^7.50.1", "react-icons": "^5.0.0", + "react-qrcode-logo": "^2.9.0", "sharp": "^0.33.2", "superjson": "^1.13.3", "tsx": "^4.7.0", diff --git a/webapp/public/images/cje-logo-white.svg b/webapp/public/images/cje-logo-white.svg new file mode 100644 index 00000000..64b68cac --- /dev/null +++ b/webapp/public/images/cje-logo-white.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/webapp/public/images/cje-logo.png b/webapp/public/images/cje-logo.png new file mode 100644 index 00000000..e3c4eef9 Binary files /dev/null and b/webapp/public/images/cje-logo.png differ diff --git a/webapp/public/images/landing/main.png b/webapp/public/images/landing/main.png new file mode 100644 index 00000000..c58b31df Binary files /dev/null and b/webapp/public/images/landing/main.png differ diff --git a/webapp/public/images/landing/map-mobile.png b/webapp/public/images/landing/map-mobile.png new file mode 100644 index 00000000..9a712e6c Binary files /dev/null and b/webapp/public/images/landing/map-mobile.png differ diff --git a/webapp/public/images/landing/map.png b/webapp/public/images/landing/map.png new file mode 100644 index 00000000..0de37dc3 Binary files /dev/null and b/webapp/public/images/landing/map.png differ diff --git a/webapp/public/images/landing/mobile-showcase.png b/webapp/public/images/landing/mobile-showcase.png new file mode 100644 index 00000000..3f5a4c7e Binary files /dev/null and b/webapp/public/images/landing/mobile-showcase.png differ diff --git a/webapp/public/images/landing/section-1.png b/webapp/public/images/landing/section-1.png new file mode 100644 index 00000000..c61f2fa1 Binary files /dev/null and b/webapp/public/images/landing/section-1.png differ diff --git a/webapp/public/images/landing/section-2.png b/webapp/public/images/landing/section-2.png new file mode 100644 index 00000000..b040ae5e Binary files /dev/null and b/webapp/public/images/landing/section-2.png differ diff --git a/webapp/public/images/landing/section-3.png b/webapp/public/images/landing/section-3.png new file mode 100644 index 00000000..1a1f61e2 Binary files /dev/null and b/webapp/public/images/landing/section-3.png differ diff --git a/webapp/public/images/landing/section-4.png b/webapp/public/images/landing/section-4.png new file mode 100644 index 00000000..c456a818 Binary files /dev/null and b/webapp/public/images/landing/section-4.png differ diff --git a/webapp/public/images/marianne-white.svg b/webapp/public/images/marianne-white.svg new file mode 100644 index 00000000..09974a9d --- /dev/null +++ b/webapp/public/images/marianne-white.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/webapp/src/components/forms/FormBlock.tsx b/webapp/src/components/forms/FormBlock.tsx index 5468630a..b2064a16 100644 --- a/webapp/src/components/forms/FormBlock.tsx +++ b/webapp/src/components/forms/FormBlock.tsx @@ -39,6 +39,7 @@ const FormBlock = ({ }} border="2px solid" borderColor={isSelected ? "blackLight" : "transparent"} + cursor="pointer" > {iconSrc && } ; - fieldError: FieldError | undefined; + field: FieldProps; + register: UseFormRegister; + fieldError: FieldError | undefined; + wrapperProps?: ChakraProps; + inputProps?: InputProps; } const FormInput = ({ - field: { name, kind, rules, label, placeholder, prefix }, - register, - fieldError, + field: { name, kind, rules, label, placeholder, prefix }, + register, + fieldError, + wrapperProps, + inputProps, }: Props) => { - return ( - - - {label && ( - - {label} - - )} - {fieldError?.message} - - ); + const { autoFocus = true, ...restInputProps } = inputProps || {}; + + return ( + + + {label && ( + + {label} + + )} + {fieldError?.message} + + ); }; export default FormInput; diff --git a/webapp/src/components/landing/FAQSectionAccordionItem.tsx b/webapp/src/components/landing/FAQSectionAccordionItem.tsx new file mode 100644 index 00000000..6855427b --- /dev/null +++ b/webapp/src/components/landing/FAQSectionAccordionItem.tsx @@ -0,0 +1,106 @@ +import { + AccordionButton, + AccordionItem, + AccordionPanel, + Box, + Icon, + IconButton, + Text, + useBreakpointValue, +} from "@chakra-ui/react"; +import { HiPlus, HiXMark } from "react-icons/hi2"; + +type FAQSectionAccordionItemProps = { + title: string; + content: string; + index: number; + currentIndex: number | null; + setCurrentIndex: React.Dispatch>; + total: number; +}; + +const FAQSectionAccordionItem = ({ + title, + content, + index, + currentIndex, + setCurrentIndex, + total, +}: FAQSectionAccordionItemProps) => { + const accordionBtnSize = useBreakpointValue({ + base: "sm", + lg: "md", + }); + + return ( + + {({ isExpanded }) => ( + + setCurrentIndex(!isExpanded ? index : null)} + pt={{ base: 2, lg: 8 }} + px={{ base: 4, lg: 8 }} + > + + {title} + + {!isExpanded ? ( + } + onClick={() => setCurrentIndex(index)} + colorScheme="gray" + px={{ lg: 0 }} + size={accordionBtnSize} + borderRadius="full" + aria-label="Ouvrir l'accordéon" + /> + ) : ( + } + onClick={() => setCurrentIndex(null)} + px={{ lg: 0 }} + size={accordionBtnSize} + borderRadius="full" + aria-label="Fermer l'accordéon" + /> + )} + + + + {content} + + + + )} + + ); +}; + +export default FAQSectionAccordionItem; diff --git a/webapp/src/components/landing/Footer.tsx b/webapp/src/components/landing/Footer.tsx new file mode 100644 index 00000000..7725ec94 --- /dev/null +++ b/webapp/src/components/landing/Footer.tsx @@ -0,0 +1,114 @@ +import { Box, Container, Divider, Flex, Image, Text } from "@chakra-ui/react"; +import { menuItems } from "./Header"; +import Link from "next/link"; + +const Footer = () => { + const handleIsEligibleClick = () => { + const element = document.querySelector(".phone-number-footer"); + if (element) (element as HTMLElement).focus(); + }; + + return ( + + + + + + + Logo marianne du gouvernement français + Logo de l'application Carte Jeune Engagé + + + Des remises exclusives pour les jeunes en +
+ insertion. +
+ Découvrez la Carte Jeune Engagé ! +
+ + + Vérifier mon éligibilité + + {menuItems.map((item) => ( + + + {item.title} + + + ))} + +
+ + + + CGU + + + Mentions légales + + + Politique de confidentialité + + +
+
+
+ ); +}; + +export default Footer; diff --git a/webapp/src/components/landing/Header.tsx b/webapp/src/components/landing/Header.tsx new file mode 100644 index 00000000..fb68753a --- /dev/null +++ b/webapp/src/components/landing/Header.tsx @@ -0,0 +1,176 @@ +import { + Box, + Button, + Collapse, + Flex, + Icon, + Portal, + Stack, + Text, + useBreakpointValue, + useDisclosure, +} from "@chakra-ui/react"; +import ChakraNextImage from "../ChakraNextImage"; +import { HiMiniBars3, HiXMark } from "react-icons/hi2"; +import Link from "next/link"; +import { useRef } from "react"; +import useActiveSection from "~/hooks/useActiveSection"; + +export const menuItems = [ + { title: "Qu'est-ce que c'est ?", slug: "what-is-it" }, + { title: "Qui peut en profiter ?", slug: "who-can-benefit" }, + { title: "Comment ça marche ?", slug: "how-does-it-work" }, + { title: "FAQ", slug: "faq" }, +]; + +const Header = () => { + const isDesktop = useBreakpointValue({ base: false, lg: true }); + + const { isOpen, onToggle } = useDisclosure(); + + const headerRef = useRef(null); + + const activeSection = useActiveSection( + menuItems.map((item) => `${item.slug}-section`) + ); + + const handleIsEligibleClick = () => { + const element = document.querySelector(".phone-number-cta"); + if (element) (element as HTMLElement).focus(); + onToggle(); + }; + + return ( + + + + + + + {!isDesktop ? ( + + + + + + {menuItems.map(({ slug, title }, index) => ( + + + + {title} + + + + ))} + + + + + + + + ) : ( + <> + + {menuItems.map(({ slug, title }) => ( + + + {title} + + + + ))} + + + + )} + + + ); +}; + +export default Header; diff --git a/webapp/src/components/landing/HowItWorkSectionCard.tsx b/webapp/src/components/landing/HowItWorkSectionCard.tsx new file mode 100644 index 00000000..5ef07da3 --- /dev/null +++ b/webapp/src/components/landing/HowItWorkSectionCard.tsx @@ -0,0 +1,44 @@ +import { Box, Center, Flex, Text } from "@chakra-ui/react"; + +type HowItWorksSectionCardProps = { + title: string; + description: string; + number: number; +}; + +const HowItWorksSectionCard = ({ + title, + description, + number, +}: HowItWorksSectionCardProps) => { + return ( + +
+ + {number} + +
+ + {title} + + + {description} + +
+ ); +}; + +export default HowItWorksSectionCard; diff --git a/webapp/src/components/landing/MapSectionCard.tsx b/webapp/src/components/landing/MapSectionCard.tsx new file mode 100644 index 00000000..ba13508b --- /dev/null +++ b/webapp/src/components/landing/MapSectionCard.tsx @@ -0,0 +1,34 @@ +import { Box, Flex, Icon, Text } from "@chakra-ui/react"; +import { IconType } from "react-icons/lib"; + +type MapSectionCardProps = { + text: string; + icon: IconType; +}; + +const MapSectionCard = ({ text, icon }: MapSectionCardProps) => { + return ( + + + + + + {text} + + + ); +}; + +export default MapSectionCard; diff --git a/webapp/src/components/landing/NotEligibleForm.tsx b/webapp/src/components/landing/NotEligibleForm.tsx new file mode 100644 index 00000000..e269ed82 --- /dev/null +++ b/webapp/src/components/landing/NotEligibleForm.tsx @@ -0,0 +1,205 @@ +import { + Box, + Button, + Center, + Divider, + Flex, + FormControl, + FormLabel, + Heading, + Icon, + Text, + useBreakpointValue, +} from "@chakra-ui/react"; +import { useState } from "react"; +import { Controller, SubmitHandler, useForm } from "react-hook-form"; +import { HiCheckCircle, HiFaceFrown, HiIdentification } from "react-icons/hi2"; +import FormInput from "../forms/FormInput"; +import FormBlock from "../forms/FormBlock"; + +type NeedingHelpForm = { + registered: string; + firstName: string; + lastName: string; + email: string; +}; + +const NotEligibleForm = () => { + const isDesktop = useBreakpointValue({ base: false, lg: true }); + + const [displayForm, setDisplayForm] = useState(false); + const [requestSent, setRequestSent] = useState(false); + + const { + handleSubmit, + register, + control, + formState: { errors }, + watch, + } = useForm({ + mode: "onSubmit", + }); + + const formValues = watch(); + + const onSubmit: SubmitHandler = (data) => { + console.log(data); + setRequestSent(true); + }; + + if (displayForm && requestSent) { + return ( + + + + Notre équipe traite votre demande au plus vite. + + + Nous avons bien reçu votre demande, nous allons vous envoyer un mail + pour vous aider à vous connecter à l’application. + + +
+ +
+
+ ); + } + + if (displayForm) { + return ( + + + + Nous allons vous aider à accéder à l’application. + + + Êtes-vous inscrit à la Mission locale de Sarcelles ? + + + ( + <> + + Oui + + + Non + + + )} + /> + + {formValues.registered === "no" && ( + + L’application est en phase d’expérimentation. Elle est réservée + aux jeunes inscrits en contrat d’engagement jeune à la Mission + locale de Sarcelles. + + )} + {formValues.registered === "yes" && ( + + + + Vos informations personnelles + + + + + + + + + + )} + +
+ +
+
+ ); + } + + return ( + + + + On dirait que vous n’êtes pas encore inscrit. + + + Vérifiez que votre numéro de téléphone correspond à celui avec lequel + votre conseiller à la Mission locale de Sarcelles vous a inscrit. + + + + Vous avez déjà été inscrit par votre conseiller ? + + setDisplayForm(true)} + > + J’ai besoin d’aide pour me connecter + + +
+ +
+
+ ); +}; + +export default NotEligibleForm; diff --git a/webapp/src/components/landing/PhoneNumberCTA.tsx b/webapp/src/components/landing/PhoneNumberCTA.tsx new file mode 100644 index 00000000..5555eb0a --- /dev/null +++ b/webapp/src/components/landing/PhoneNumberCTA.tsx @@ -0,0 +1,108 @@ +import { Button, Flex, Icon } from "@chakra-ui/react"; +import { ErrorOption, SubmitHandler, useForm } from "react-hook-form"; +import FormInput from "../forms/FormInput"; +import { frenchPhoneNumber } from "~/utils/tools"; +import { HiArrowRight } from "react-icons/hi2"; + +export type LoginForm = { + phone_number: string; +}; + +export type ComponentPhoneNumberKeys = + | "phone-number-cta" + | "phone-number-footer"; + +const PhoneNumberCTA = ({ + onSubmit, + currentKey, + setCurrentPhoneNumberKey, + error, + isLoadingOtp, +}: { + onSubmit: SubmitHandler; + currentKey: ComponentPhoneNumberKeys; + setCurrentPhoneNumberKey: React.Dispatch< + React.SetStateAction + >; + error: { + name: ComponentPhoneNumberKeys; + error: ErrorOption; + } | null; + isLoadingOtp: boolean; +}) => { + const { + handleSubmit, + register, + setError, + formState: { errors }, + } = useForm({ + mode: "onSubmit", + }); + + if (currentKey === error?.name && errors.phone_number === undefined) { + setError("phone_number", { + type: error.error.type, + message: error.error.message, + }); + } + + return ( + { + e.preventDefault(); + setCurrentPhoneNumberKey(currentKey); + handleSubmit(onSubmit)(); + }} + > + + + + ); +}; + +export default PhoneNumberCTA; diff --git a/webapp/src/components/landing/QRCode.tsx b/webapp/src/components/landing/QRCode.tsx new file mode 100644 index 00000000..034789a5 --- /dev/null +++ b/webapp/src/components/landing/QRCode.tsx @@ -0,0 +1,28 @@ +import { Box, ChakraProps } from "@chakra-ui/react"; +import { QRCode } from "react-qrcode-logo"; + +type QRCodeProps = { + value: string; + size?: number; + wrapperProps?: ChakraProps; +}; + +const QRCodeWrapper = ({ value, size, wrapperProps }: QRCodeProps) => { + return ( + + + + ); +}; + +export default QRCodeWrapper; diff --git a/webapp/src/components/landing/SimpleSection.tsx b/webapp/src/components/landing/SimpleSection.tsx new file mode 100644 index 00000000..c02ef3b8 --- /dev/null +++ b/webapp/src/components/landing/SimpleSection.tsx @@ -0,0 +1,37 @@ +import { Box, Flex, Heading, Image, Text } from "@chakra-ui/react"; + +type SimpleSectionProps = { + title: string; + description: string; + image: string; +}; + +const SimpleSection = ({ title, description, image }: SimpleSectionProps) => { + return ( + + + + + + + {title} + + + {description} + + + + ); +}; + +export default SimpleSection; diff --git a/webapp/src/components/modals/BaseModal.tsx b/webapp/src/components/modals/BaseModal.tsx index 779176ea..811c7523 100644 --- a/webapp/src/components/modals/BaseModal.tsx +++ b/webapp/src/components/modals/BaseModal.tsx @@ -1,55 +1,68 @@ import { - Icon, - IconButton, - Modal, - ModalBody, - ModalCloseButton, - ModalContent, - ModalHeader, - ModalOverlay, + Box, + Icon, + IconButton, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalHeader, + ModalOverlay, + useBreakpointValue, } from "@chakra-ui/react"; import { HiMiniXMark } from "react-icons/hi2"; type BaseModalProps = { - children: React.ReactNode; - onClose: () => void; - isOpen: boolean; - title?: string; - hideCloseBtn?: boolean; + children: React.ReactNode; + onClose: () => void; + isOpen: boolean; + title?: string; + hideCloseBtn?: boolean; }; const BaseModal = ({ - children, - onClose, - isOpen, - hideCloseBtn, - title, + children, + onClose, + isOpen, + hideCloseBtn, + title, }: BaseModalProps) => { - return ( - - - - {!hideCloseBtn && ( - } - onClick={onClose} - aria-label="Fermer la modal" - /> - )} - {title && ( - - {title} - - )} - {children} - - - ); + const isMobile = useBreakpointValue({ base: true, lg: false }); + + return ( + + + + {!hideCloseBtn && ( + } + onClick={onClose} + aria-label="Fermer la modal" + /> + )} + {title && ( + + {title} + + )} + {children} + + + ); }; export default BaseModal; diff --git a/webapp/src/components/theme/modal.ts b/webapp/src/components/theme/modal.ts new file mode 100644 index 00000000..8fe67a14 --- /dev/null +++ b/webapp/src/components/theme/modal.ts @@ -0,0 +1,28 @@ +import { modalAnatomy as parts } from "@chakra-ui/anatomy"; +import { useBreakpoint } from "@chakra-ui/react"; +import { + createMultiStyleConfigHelpers, + defineStyle, +} from "@chakra-ui/styled-system"; + +const { definePartsStyle, defineMultiStyleConfig } = + createMultiStyleConfigHelpers(parts.keys); + +const dialogPart = defineStyle({ + maxWidth: "calc(100% - 150px)", + paddingBottom: 0, + borderRadius: "3xl", +}); + +const bodyPart = defineStyle({ + px: 12, + pb: 12, +}); + +const sizes = { + desktop: definePartsStyle({ dialog: dialogPart, body: bodyPart }), +}; + +export const modalTheme = defineMultiStyleConfig({ + sizes, +}); diff --git a/webapp/src/hooks/useActiveSection.ts b/webapp/src/hooks/useActiveSection.ts new file mode 100644 index 00000000..14159ea0 --- /dev/null +++ b/webapp/src/hooks/useActiveSection.ts @@ -0,0 +1,31 @@ +import { useState, useEffect } from "react"; + +export default function useActiveSection(sectionIds: string[]) { + const [activeSection, setActiveSection] = useState(""); + + useEffect(() => { + const handleScroll = () => { + let currentSection = ""; + + sectionIds.forEach((id) => { + const section = document.getElementById(id); + const scrollPosition = + document.body.scrollTop + document.body.clientHeight / 2; // Adjust this to change when a section becomes "active" + if ( + section && + section.offsetTop <= scrollPosition && + section.offsetTop + section.offsetHeight + 150 > scrollPosition + ) { + currentSection = id; + } + }); + + setActiveSection(currentSection); + }; + + document.body.addEventListener("scroll", handleScroll); + return () => document.body.removeEventListener("scroll", handleScroll); + }, [sectionIds]); // Only re-run the effect if sectionIds changes + + return activeSection; +} diff --git a/webapp/src/layouts/DefaultLayout.tsx b/webapp/src/layouts/DefaultLayout.tsx index 2735a174..936f06cf 100644 --- a/webapp/src/layouts/DefaultLayout.tsx +++ b/webapp/src/layouts/DefaultLayout.tsx @@ -1,20 +1,23 @@ -import { Box, Container } from "@chakra-ui/react"; +import { Box, Container, Flex } from "@chakra-ui/react"; import Head from "next/head"; import { usePathname } from "next/navigation"; import { ReactNode, useEffect } from "react"; import BottomNavigation from "~/components/BottomNavigation"; +import Footer from "~/components/landing/Footer"; import { BeforeInstallPromptEvent, useAuth } from "~/providers/Auth"; export default function DefaultLayout({ children }: { children: ReactNode }) { const pathname = usePathname(); - const { setDeferredEvent, setShowing, user } = useAuth(); + const { setDeferredEvent, setShowing, user, isOtpGenerated } = useAuth(); + + const isLanding = pathname === "/" && !isOtpGenerated; const handleBeforeInstallPrompt = (event: Event) => { // Prevent the default behavior to keep the event available for later use event.preventDefault(); - // // Save the event for later use + // Save the event for later use setDeferredEvent(event as BeforeInstallPromptEvent); setShowing(true); @@ -57,14 +60,22 @@ export default function DefaultLayout({ children }: { children: ReactNode }) { /> - + {children} + {isLanding &&