From 551ee4bc0af079c5cbb2b4f6df359e1fd043f6bb Mon Sep 17 00:00:00 2001 From: lmaubert-pass <182489475+lmaubert-pass@users.noreply.github.com> Date: Tue, 21 Jan 2025 18:11:11 +0100 Subject: [PATCH 1/2] (PC-33496)[API] feat: add endpoint to check eligibility status for collective --- api/src/pcapi/core/educational/api/dms.py | 7 +++ api/src/pcapi/core/educational/commands.py | 7 +-- api/src/pcapi/core/offerers/api.py | 19 +++++++ api/src/pcapi/routes/pro/offerers.py | 57 +++++++++++++++++++ .../serialization/offerers_serialize.py | 12 ++++ .../pro/get_offerer_eligibility_test.py | 54 ++++++++++++++++++ pro/src/apiClient/v1/index.ts | 1 + .../models/OffererEligibilityResponseModel.ts | 11 ++++ 8 files changed, 163 insertions(+), 5 deletions(-) create mode 100644 api/tests/routes/pro/get_offerer_eligibility_test.py create mode 100644 pro/src/apiClient/v1/models/OffererEligibilityResponseModel.ts diff --git a/api/src/pcapi/core/educational/api/dms.py b/api/src/pcapi/core/educational/api/dms.py index a6ce47a89eb..8f6eb9bdc02 100644 --- a/api/src/pcapi/core/educational/api/dms.py +++ b/api/src/pcapi/core/educational/api/dms.py @@ -20,6 +20,13 @@ ] +def import_dms_applications_for_all_eac_procedures(ignore_previous: bool = False) -> None: + """import dms applications for all eac procedures""" + procedures = [proc for proc in EAC_DS_PROCEDURES if proc > 0] + for procedure_number in procedures: + import_dms_applications(procedure_number=procedure_number, ignore_previous=ignore_previous) + + def import_dms_applications(procedure_number: int, ignore_previous: bool = False) -> None: """import dms applications for eac status""" previous_import = _get_previous_import(procedure_number) diff --git a/api/src/pcapi/core/educational/commands.py b/api/src/pcapi/core/educational/commands.py index 7216bf43bbb..5f022b91458 100644 --- a/api/src/pcapi/core/educational/commands.py +++ b/api/src/pcapi/core/educational/commands.py @@ -8,8 +8,7 @@ from pcapi.core.educational import repository as educational_repository from pcapi.core.educational.api import booking as educational_api_booking import pcapi.core.educational.api.adage as adage_api -from pcapi.core.educational.api.dms import EAC_DS_PROCEDURES -from pcapi.core.educational.api.dms import import_dms_applications +from pcapi.core.educational.api.dms import import_dms_applications_for_all_eac_procedures import pcapi.core.educational.api.institution as institution_api import pcapi.core.educational.api.playlists as playlists_api import pcapi.core.educational.models as educational_models @@ -153,9 +152,7 @@ def handle_pending_collective_booking_j3() -> None: @log_cron_with_transaction def import_eac_dms_application(ignore_previous: bool = False) -> None: """Import procedures from dms.""" - procedures = [proc for proc in EAC_DS_PROCEDURES if proc > 0] - for procedure_number in procedures: - import_dms_applications(procedure_number=procedure_number, ignore_previous=ignore_previous) + import_dms_applications_for_all_eac_procedures(ignore_previous=ignore_previous) @blueprint.cli.command("notify_reimburse_collective_booking") diff --git a/api/src/pcapi/core/offerers/api.py b/api/src/pcapi/core/offerers/api.py index 78d4068f990..943aac77738 100644 --- a/api/src/pcapi/core/offerers/api.py +++ b/api/src/pcapi/core/offerers/api.py @@ -36,6 +36,8 @@ from pcapi.core.educational import exceptions as educational_exceptions from pcapi.core.educational import models as educational_models from pcapi.core.educational import repository as educational_repository +from pcapi.core.educational.api import dms as dms_api +import pcapi.core.educational.api.adage as adage_api import pcapi.core.educational.api.address as educational_address_api from pcapi.core.external import zendesk_sell from pcapi.core.external.attributes import api as external_attributes_api @@ -3084,3 +3086,20 @@ def update_offerer_address(offerer_address_id: int, address_id: int, label: str # We shouldn't enp up here, but if so log the exception so we can investigate db.session.rollback() logger.exception(exc) + + +def synchronize_from_adage_and_check_registration(offerer_id: int) -> bool: + # FIXME: to be modified when adage sync cron frequency is updated + since_date = datetime.utcnow() - timedelta(days=1) + adage_api.synchronize_adage_ids_on_venues(debug=False, since_date=since_date) + return offerers_repository.offerer_has_venue_with_adage_id(offerer_id) + + +def synchronize_from_ds_and_check_application(offerer_id: int) -> bool: + dms_api.import_dms_applications_for_all_eac_procedures(ignore_previous=False) + query = ( + db.session.query(offerers_models.Venue) + .filter(offerers_models.Venue.managingOffererId == offerer_id) + .filter(offerers_models.Venue.collectiveDmsApplications.any()) + ) + return db.session.query(query.exists()).scalar() diff --git a/api/src/pcapi/routes/pro/offerers.py b/api/src/pcapi/routes/pro/offerers.py index cdd9e6ceb1f..f6acae1179c 100644 --- a/api/src/pcapi/routes/pro/offerers.py +++ b/api/src/pcapi/routes/pro/offerers.py @@ -10,6 +10,7 @@ from pcapi.connectors.big_query.queries.offerer_stats import DAILY_CONSULT_PER_OFFERER_LAST_180_DAYS_TABLE from pcapi.connectors.big_query.queries.offerer_stats import TOP_3_MOST_CONSULTED_OFFERS_LAST_30_DAYS_TABLE from pcapi.connectors.entreprise import sirene +import pcapi.core.educational.exceptions as educational_exceptions import pcapi.core.finance.api as finance_api import pcapi.core.finance.exceptions as finance_exceptions import pcapi.core.finance.repository as finance_repository @@ -25,6 +26,7 @@ from pcapi.routes.apis import private_api from pcapi.routes.serialization import offerers_serialize from pcapi.serialization.decorator import spectree_serialize +from pcapi.utils import requests from pcapi.utils.rest import check_user_has_access_to_offerer from . import blueprint @@ -326,3 +328,58 @@ def get_offerer_headline_offer( if not offerer_headline_offer: raise ResourceNotFoundError() return offerers_serialize.OffererHeadLineOfferResponseModel.from_orm(offerer_headline_offer) + + +@private_api.route("/offerers//eligibility", methods=["GET"]) +@login_required +@atomic() +@spectree_serialize( + response_model=offerers_serialize.OffererEligibilityResponseModel, + api=blueprint.pro_private_schema, + on_success_status=200, +) +def get_offerer_eligibility( + offerer_id: int, +) -> offerers_serialize.OffererEligibilityResponseModel: + check_user_has_access_to_offerer(current_user, offerer_id) + + try: + has_adage_id = api.synchronize_from_adage_and_check_registration(offerer_id) + if has_adage_id: + return offerers_serialize.OffererEligibilityResponseModel( + offerer_id=offerer_id, + has_adage_id=has_adage_id, + has_ds_application=None, + is_onboarded=True, + ) + except (educational_exceptions.AdageException, requests.exceptions.RequestException) as exception: + has_adage_id = None + logger.error( + "Error while checking Adage status", + extra={ + "offerer_id": offerer_id, + "error": str(exception), + }, + ) + + try: + has_ds_application = api.synchronize_from_ds_and_check_application(offerer_id) + except Exception as exception: # pylint: disable=broad-exception-caught + has_ds_application = None + logger.error( + "Error while checking Adage status", + extra={ + "offerer_id": offerer_id, + "error": str(exception), + }, + ) + + if has_adage_id is None and has_ds_application is None: + raise ApiErrors(errors={"eligibility": ["Le statut de la structure n'a pas pu être vérifié"]}, status_code=400) + + return offerers_serialize.OffererEligibilityResponseModel( + offerer_id=offerer_id, + has_adage_id=has_adage_id, + has_ds_application=has_ds_application, + is_onboarded=has_adage_id or has_ds_application, + ) diff --git a/api/src/pcapi/routes/serialization/offerers_serialize.py b/api/src/pcapi/routes/serialization/offerers_serialize.py index 38a97a9c56f..6bce532eb2f 100644 --- a/api/src/pcapi/routes/serialization/offerers_serialize.py +++ b/api/src/pcapi/routes/serialization/offerers_serialize.py @@ -20,6 +20,7 @@ from pcapi.routes.serialization.address_serialize import AddressResponseModel from pcapi.routes.serialization.venues_serialize import BannerMetaModel from pcapi.routes.serialization.venues_serialize import DMSApplicationForEAC +from pcapi.serialization.utils import to_camel import pcapi.utils.date as date_utils from pcapi.utils.email import sanitize_email @@ -431,3 +432,14 @@ class OffererHeadLineOfferResponseModel(BaseModel): class Config: orm_mode = True + + +class OffererEligibilityResponseModel(BaseModel): + offerer_id: int + has_adage_id: bool | None + has_ds_application: bool | None + is_onboarded: bool | None + + class Config: + allow_population_by_field_name = True + alias_generator = to_camel diff --git a/api/tests/routes/pro/get_offerer_eligibility_test.py b/api/tests/routes/pro/get_offerer_eligibility_test.py new file mode 100644 index 00000000000..fceaf074972 --- /dev/null +++ b/api/tests/routes/pro/get_offerer_eligibility_test.py @@ -0,0 +1,54 @@ +import pytest + +from pcapi.core import testing +import pcapi.core.educational.factories as collective_factories +from pcapi.core.offerers import factories as offerers_factories +from pcapi.core.testing import assert_num_queries +from pcapi.core.users import factories as users_factories + + +pytestmark = pytest.mark.usefixtures("db_session") + + +class Return200Test: + + @pytest.mark.parametrize( + "adage_id,collective_ds_application,is_onboarded", + [ + (None, None, False), + ("1", None, True), + (None, "1", True), + ("1", "1", True), + ], + ) + def test_get_offerer_eligibility_success(self, client, adage_id, collective_ds_application, is_onboarded): + pro = users_factories.ProFactory() + offerer = offerers_factories.OffererFactory() + offerers_factories.UserOffererFactory(user=pro, offerer=offerer) + + venue = offerers_factories.VenueFactory(managingOfferer=offerer, adageId=adage_id) + if collective_ds_application is not None: + collective_factories.CollectiveDmsApplicationFactory(venue=venue, procedure=collective_ds_application) + + offerer_id = offerer.id + client = client.with_session_auth(pro.email) + response = client.get(f"/offerers/{offerer_id}/eligibility") + assert response.status_code == 200 + assert response.json.get("isOnboarded") is is_onboarded + assert adage_id is None or response.json.get("hasAdageId") + if adage_id is None: + assert collective_ds_application is None or response.json.get("hasDsApplication") + + +class Return400Test: + num_queries = testing.AUTHENTICATION_QUERIES + num_queries += 1 # check user_offerer + num_queries += 1 # rollback (atomic) + + def test_access_by_unauthorized_pro_user(self, client): + pro = users_factories.ProFactory() + client = client.with_session_auth(email=pro.email) + offerer_id = 0 + with assert_num_queries(self.num_queries): + response = client.get(f"/offerers/{offerer_id}/eligibility") + assert response.status_code == 403 diff --git a/pro/src/apiClient/v1/index.ts b/pro/src/apiClient/v1/index.ts index 372e421357e..371a89c6bb8 100644 --- a/pro/src/apiClient/v1/index.ts +++ b/pro/src/apiClient/v1/index.ts @@ -178,6 +178,7 @@ export { OfferAddressType } from './models/OfferAddressType'; export { OfferContactFormEnum } from './models/OfferContactFormEnum'; export type { OfferDomain } from './models/OfferDomain'; export type { OffererApiKey } from './models/OffererApiKey'; +export type { OffererEligibilityResponseModel } from './models/OffererEligibilityResponseModel'; export type { OffererHeadLineOfferResponseModel } from './models/OffererHeadLineOfferResponseModel'; export { OffererMemberStatus } from './models/OffererMemberStatus'; export type { OffererStatsDataModel } from './models/OffererStatsDataModel'; diff --git a/pro/src/apiClient/v1/models/OffererEligibilityResponseModel.ts b/pro/src/apiClient/v1/models/OffererEligibilityResponseModel.ts new file mode 100644 index 00000000000..a91b7ee10c4 --- /dev/null +++ b/pro/src/apiClient/v1/models/OffererEligibilityResponseModel.ts @@ -0,0 +1,11 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type OffererEligibilityResponseModel = { + hasAdageId?: boolean | null; + hasDsApplication?: boolean | null; + isOnboarded?: boolean | null; + offererId: number; +}; + From c3b2f144ce75b5d142b4ebdc59707f029fa275d4 Mon Sep 17 00:00:00 2001 From: lmaubert-pass <182489475+lmaubert-pass@users.noreply.github.com> Date: Tue, 21 Jan 2025 18:12:56 +0100 Subject: [PATCH 2/2] (PC-33496)[PRO] feat: add onboarding modal and check eligibility status for collective --- .../apiClient/v1/services/DefaultService.ts | 24 +++- .../OnboardingOffersChoice.spec.tsx | 17 ++- .../OnboardingOffersChoice.tsx | 58 +++++--- .../OnboardingCollectiveModal.module.scss | 70 ++++++++++ .../OnboardingCollectiveModal.spec.tsx | 107 ++++++++++++++ .../OnboardingCollectiveModal.tsx | 131 ++++++++++++++++++ .../assets/acceptation.svg | 30 ++++ .../assets/calendrier.svg | 49 +++++++ .../assets/creation_offre.svg | 51 +++++++ .../assets/depot_dossier.svg | 29 ++++ 10 files changed, 542 insertions(+), 24 deletions(-) create mode 100644 pro/src/components/OnboardingOffersChoice/components/OnboardingCollectiveModal/OnboardingCollectiveModal.module.scss create mode 100644 pro/src/components/OnboardingOffersChoice/components/OnboardingCollectiveModal/OnboardingCollectiveModal.spec.tsx create mode 100644 pro/src/components/OnboardingOffersChoice/components/OnboardingCollectiveModal/OnboardingCollectiveModal.tsx create mode 100644 pro/src/components/OnboardingOffersChoice/components/OnboardingCollectiveModal/assets/acceptation.svg create mode 100644 pro/src/components/OnboardingOffersChoice/components/OnboardingCollectiveModal/assets/calendrier.svg create mode 100644 pro/src/components/OnboardingOffersChoice/components/OnboardingCollectiveModal/assets/creation_offre.svg create mode 100644 pro/src/components/OnboardingOffersChoice/components/OnboardingCollectiveModal/assets/depot_dossier.svg diff --git a/pro/src/apiClient/v1/services/DefaultService.ts b/pro/src/apiClient/v1/services/DefaultService.ts index 28ad71dc5fa..63f35f7b3c4 100644 --- a/pro/src/apiClient/v1/services/DefaultService.ts +++ b/pro/src/apiClient/v1/services/DefaultService.ts @@ -73,6 +73,7 @@ import type { ListVenueProviderResponse } from '../models/ListVenueProviderRespo import type { LoginUserBodyModel } from '../models/LoginUserBodyModel'; import type { NewPasswordBodyModel } from '../models/NewPasswordBodyModel'; import type { OffererHeadLineOfferResponseModel } from '../models/OffererHeadLineOfferResponseModel'; +import type { OffererEligibilityResponseModel } from '../models/OffererEligibilityResponseModel'; import type { OffererStatsResponseModel } from '../models/OffererStatsResponseModel'; import type { OfferStatus } from '../models/OfferStatus'; import type { PatchAllOffersActiveStatusBodyModel } from '../models/PatchAllOffersActiveStatusBodyModel'; @@ -1449,7 +1450,28 @@ export class DefaultService { 403: `Forbidden`, 422: `Unprocessable Entity`, }, - }); + }) + } + /** + * get_offerer_eligibility + * @param offererId + * @returns OffererEligibilityResponseModel OK + * @throws ApiError + */ + public getOffererEligibility( + offererId: number + ): CancelablePromise { + return this.httpRequest.request({ + method: 'GET', + url: '/offerers/{offerer_id}/eligibility', + path: { + offerer_id: offererId, + }, + errors: { + 403: `Forbidden`, + 422: `Unprocessable Entity`, + }, + }) } /** * get_offerer_headline_offer diff --git a/pro/src/components/OnboardingOffersChoice/OnboardingOffersChoice.spec.tsx b/pro/src/components/OnboardingOffersChoice/OnboardingOffersChoice.spec.tsx index d8395469655..f56f69f196c 100644 --- a/pro/src/components/OnboardingOffersChoice/OnboardingOffersChoice.spec.tsx +++ b/pro/src/components/OnboardingOffersChoice/OnboardingOffersChoice.spec.tsx @@ -1,4 +1,5 @@ import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { axe } from 'vitest-axe' import { renderWithProviders } from 'commons/utils/renderWithProviders' @@ -7,7 +8,11 @@ import { OnboardingOffersChoice } from './OnboardingOffersChoice' describe('OnboardingOffersChoice Component', () => { beforeEach(() => { - renderWithProviders() + renderWithProviders(, { + storeOverrides: { + offerer: { selectedOffererId: 1, offererNames: [], isOnboarded: false, }, + }, + }) }) it('should pass axe accessibility tests', async () => { @@ -39,4 +44,14 @@ describe('OnboardingOffersChoice Component', () => { const secondCardButton = screen.getAllByText('Commencer')[1] expect(secondCardButton).toBeInTheDocument() }) + + it('displays the onboarding collective modal when the second button is clicked', async () => { + await userEvent.click( + screen.getByTitle('Commencer la création d’offre sur ADAGE') + ) + + expect( + await screen.findByTestId('onboarding-collective-modal') + ).toBeInTheDocument() + }) }) diff --git a/pro/src/components/OnboardingOffersChoice/OnboardingOffersChoice.tsx b/pro/src/components/OnboardingOffersChoice/OnboardingOffersChoice.tsx index bdc6b0de664..00694a3876f 100644 --- a/pro/src/components/OnboardingOffersChoice/OnboardingOffersChoice.tsx +++ b/pro/src/components/OnboardingOffersChoice/OnboardingOffersChoice.tsx @@ -1,22 +1,23 @@ -import { ReactNode } from 'react' +import { ReactNode, useState } from 'react' +import { Dialog } from 'components/Dialog/Dialog' import { Button } from 'ui-kit/Button/Button' import { ButtonLink } from 'ui-kit/Button/ButtonLink' import { ButtonVariant } from 'ui-kit/Button/types' import collective from './assets/collective.jpeg' import individuelle from './assets/individuelle.jpeg' +import { OnboardingCollectiveModal } from './components/OnboardingCollectiveModal/OnboardingCollectiveModal' import styles from './OnboardingOffersChoice.module.scss' interface CardProps { imageSrc: string title: string children: ReactNode - buttonTitle: string - to?: string + actions: ReactNode } -const Card = ({ imageSrc, title, children, buttonTitle, to }: CardProps) => { +const Card = ({ imageSrc, title, children, actions }: CardProps) => { return (
@@ -25,34 +26,29 @@ const Card = ({ imageSrc, title, children, buttonTitle, to }: CardProps) => {

{title}

{children}

-
- {to ? ( - - Commencer - - ) : ( - - )} -
+
{actions}
) } export const OnboardingOffersChoice = () => { + const [showModal, setShowModal] = useState(false) + return (
+ Commencer + + } > Vos offres seront visibles par{' '} @@ -64,7 +60,25 @@ export const OnboardingOffersChoice = () => { setShowModal(false)} + hideIcon={true} + trigger={ + + } + open={showModal} + > + + + } > Vos offres seront visibles par{' '} diff --git a/pro/src/components/OnboardingOffersChoice/components/OnboardingCollectiveModal/OnboardingCollectiveModal.module.scss b/pro/src/components/OnboardingOffersChoice/components/OnboardingCollectiveModal/OnboardingCollectiveModal.module.scss new file mode 100644 index 00000000000..3b3767638a9 --- /dev/null +++ b/pro/src/components/OnboardingOffersChoice/components/OnboardingCollectiveModal/OnboardingCollectiveModal.module.scss @@ -0,0 +1,70 @@ +@use "styles/mixins/_fonts.scss" as fonts; +@use "styles/mixins/_rem.scss" as rem; +@use "styles/mixins/_a11y.scss" as a11y; +@use "styles/mixins/_size.scss" as size; + +.onboarding-collective-modal { + max-width: rem.torem(910px); + padding: rem.torem(16px); +} + +.onboarding-collective-title { + @include fonts.title1; + + margin-bottom: rem.torem(16px); +} + +.onboarding-collective-text { + @include fonts.body; + + line-height: var(--line-height-m); + margin-bottom: rem.torem(32px); +} + +.onboarding-collective-steps { + display: grid; + grid-template-columns: 1fr; + justify-items: center; + gap: rem.torem(16px); + margin-bottom: rem.torem(46px); + + @media (min-width: size.$tablet) { + grid-template-columns: repeat(2, rem.torem(177px)); + justify-content: center; + gap: rem.torem(32px); + } + + @media (min-width: size.$laptop) { + grid-template-columns: repeat(4, rem.torem(177px)); + gap: rem.torem(32px); + } +} + +.onboarding-collective-step { + max-width: rem.torem(177px); + + &-icon { + width: rem.torem(116px); + aspect-ratio: 1/1; + } + + &-text { + @include fonts.body-s; + + line-height: var(--line-height-s); + } +} + +.onboarding-collective-actions { + display: inline-flex; + flex-direction: column; + align-items: center; +} + +.onboarding-collective-button { + margin-bottom: rem.torem(24px); +} + +.error-message { + color: var(--color-error); +} diff --git a/pro/src/components/OnboardingOffersChoice/components/OnboardingCollectiveModal/OnboardingCollectiveModal.spec.tsx b/pro/src/components/OnboardingOffersChoice/components/OnboardingCollectiveModal/OnboardingCollectiveModal.spec.tsx new file mode 100644 index 00000000000..1b43fff6752 --- /dev/null +++ b/pro/src/components/OnboardingOffersChoice/components/OnboardingCollectiveModal/OnboardingCollectiveModal.spec.tsx @@ -0,0 +1,107 @@ +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import * as router from 'react-router-dom' +import { axe } from 'vitest-axe' + +import { api } from 'apiClient/api' +import { sharedCurrentUserFactory } from 'commons/utils/factories/storeFactories' +import { + renderWithProviders, + RenderWithProvidersOptions, +} from 'commons/utils/renderWithProviders' + +import { OnboardingCollectiveModal } from './OnboardingCollectiveModal' + +const renderOnboardingCollectiveModal = ( + options?: RenderWithProvidersOptions +) => { + return renderWithProviders(, { + storeOverrides: { + user: { currentUser: sharedCurrentUserFactory() }, + offerer: { selectedOffererId: 1, offererNames: [], isOnboarded: false, }, + }, + user: sharedCurrentUserFactory(), + ...options, + }) +} + +describe('', () => { + it('should render correctly', async () => { + renderOnboardingCollectiveModal() + + expect( + await screen.findByRole('heading', { name: /Quelles sont les étapes ?/ }) + ).toBeInTheDocument() + + expect( + await screen.findByRole('link', { name: /Déposer un dossier/ }) + ).toBeInTheDocument() + + expect( + await screen.findByRole('button', { name: /J’ai déposé un dossier/ }) + ).toBeInTheDocument() + }) + + it('should not have accessibility violations', async () => { + const { container } = renderOnboardingCollectiveModal() + + expect(await axe(container)).toHaveNoViolations() + }) + + describe('API calls', () => { + vi.mock('react-router-dom', async () => ({ + ...(await vi.importActual('react-router-dom')), + useNavigate: vi.fn(), + })) + + vi.mock('apiClient/api', () => ({ + api: { + getOffererEligibility: vi.fn(), + }, + })) + + it('should request the API when clicking on "J’ai déposé un dossier"', async () => { + renderOnboardingCollectiveModal() + + await userEvent.click( + await screen.findByRole('button', { name: /J’ai déposé un dossier/ }) + ) + + expect(api.getOffererEligibility).toHaveBeenCalledOnce() + }) + + it('should redirect to the homepage if user is onboarded', async () => { + const mockNavigate = vi.fn() + vi.spyOn(router, 'useNavigate').mockReturnValue(mockNavigate) + vi.spyOn(api, 'getOffererEligibility').mockResolvedValue({ + offererId: 1, + isOnboarded: true, + }) + + renderOnboardingCollectiveModal() + + await userEvent.click( + await screen.findByRole('button', { name: /J’ai déposé un dossier/ }) + ) + + expect(mockNavigate).toHaveBeenCalledWith('/accueil') + }) + + it('should show an error message if user is not onboarded', async () => { + vi.spyOn(api, 'getOffererEligibility').mockResolvedValue({ + offererId: 1, + isOnboarded: false, + }) + + renderOnboardingCollectiveModal() + + await userEvent.click( + await screen.findByRole('button', { name: /J’ai déposé un dossier/ }) + ) + + expect( + await screen.findByText('Un problème est survenu, veuillez réessayer') + ).toBeInTheDocument() + }) + }) +}) diff --git a/pro/src/components/OnboardingOffersChoice/components/OnboardingCollectiveModal/OnboardingCollectiveModal.tsx b/pro/src/components/OnboardingOffersChoice/components/OnboardingCollectiveModal/OnboardingCollectiveModal.tsx new file mode 100644 index 00000000000..f82f038995d --- /dev/null +++ b/pro/src/components/OnboardingOffersChoice/components/OnboardingCollectiveModal/OnboardingCollectiveModal.tsx @@ -0,0 +1,131 @@ +import cn from 'classnames' +import { useState } from 'react' +import { useSelector } from 'react-redux' +import { useNavigate } from 'react-router-dom' + +import { api } from 'apiClient/api' +import { useNotification } from 'commons/hooks/useNotification' +import { selectCurrentOffererId } from 'commons/store/offerer/selectors' +import fullNextIcon from 'icons/full-next.svg' +import { Button } from 'ui-kit/Button/Button' +import { ButtonLink } from 'ui-kit/Button/ButtonLink' +import { ButtonVariant } from 'ui-kit/Button/types' +import { Spinner } from 'ui-kit/Spinner/Spinner' + +import acceptationIcon from './assets/acceptation.svg' +import calendarIcon from './assets/calendrier.svg' +import offerCreationIcon from './assets/creation_offre.svg' +import fileSubmissionIcon from './assets/depot_dossier.svg' +import styles from './OnboardingCollectiveModal.module.scss' + +interface OnboardingCollectiveModalProps { + className?: string +} + +export const OnboardingCollectiveModal = ({ + className, +}: OnboardingCollectiveModalProps): JSX.Element => { + const [error, setError] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const currentOffererId = useSelector(selectCurrentOffererId) + const navigate = useNavigate() + const notify = useNotification() + + if (currentOffererId === null) { + return + } + + const checkEligibility = async () => { + try { + setError(false) + setIsLoading(true) + const eligibility = await api.getOffererEligibility(currentOffererId) + if (eligibility.isOnboarded) { + notify.success('Example message : Bravo ! Vous avez été activé !') + return navigate('/accueil') + } + // In any other case, it's an error + setIsLoading(false) + setError(true) + } catch { + setIsLoading(false) + setError(true) + } + } + + return ( +
+

+ Quelles sont les étapes ? +

+

+ Pour continuer, vous devez compléter un dossier qui sera examiné par les + services d’État pour vérifier votre éligibilité au dispositif pass + Culture. +

+
+ + + + +
+
+ + Déposer un dossier + + +
+ {error && ( +
+ Un problème est survenu, veuillez réessayer +
+ )} +
+ ) +} + +function ModalStep({ icon, text }: { icon: string; text: string }) { + return ( +
+ +

{text}

+
+ ) +} diff --git a/pro/src/components/OnboardingOffersChoice/components/OnboardingCollectiveModal/assets/acceptation.svg b/pro/src/components/OnboardingOffersChoice/components/OnboardingCollectiveModal/assets/acceptation.svg new file mode 100644 index 00000000000..63515b9488f --- /dev/null +++ b/pro/src/components/OnboardingOffersChoice/components/OnboardingCollectiveModal/assets/acceptation.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pro/src/components/OnboardingOffersChoice/components/OnboardingCollectiveModal/assets/calendrier.svg b/pro/src/components/OnboardingOffersChoice/components/OnboardingCollectiveModal/assets/calendrier.svg new file mode 100644 index 00000000000..22e1524e5b4 --- /dev/null +++ b/pro/src/components/OnboardingOffersChoice/components/OnboardingCollectiveModal/assets/calendrier.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pro/src/components/OnboardingOffersChoice/components/OnboardingCollectiveModal/assets/creation_offre.svg b/pro/src/components/OnboardingOffersChoice/components/OnboardingCollectiveModal/assets/creation_offre.svg new file mode 100644 index 00000000000..381828c6653 --- /dev/null +++ b/pro/src/components/OnboardingOffersChoice/components/OnboardingCollectiveModal/assets/creation_offre.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pro/src/components/OnboardingOffersChoice/components/OnboardingCollectiveModal/assets/depot_dossier.svg b/pro/src/components/OnboardingOffersChoice/components/OnboardingCollectiveModal/assets/depot_dossier.svg new file mode 100644 index 00000000000..42289a5848a --- /dev/null +++ b/pro/src/components/OnboardingOffersChoice/components/OnboardingCollectiveModal/assets/depot_dossier.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file