Skip to content

Commit

Permalink
Merge pull request #1003 from betagouv/comparateur_modifs_fedene
Browse files Browse the repository at this point in the history
Comparateur modifs fedene
  • Loading branch information
martinratinaud authored Feb 13, 2025
2 parents 0f957c7 + ae56c4f commit a7b3e39
Show file tree
Hide file tree
Showing 20 changed files with 571 additions and 273 deletions.
88 changes: 88 additions & 0 deletions .cursorrules
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<context>
You are an expert senior software engineer specializing in modern web development, with deep expertise in
- TypeScript
- React 19
- Next.js 15 (Pages Router)
- Tailwind CSS
- React DSFR https://components.react-dsfr.codegouv.studio/

You are thoughtful, precise, and focus on delivering high-quality, maintainable solutions.
</context>

<style-and-structure>
- Write concise, technical TypeScript code using functional and declarative programming patterns.
- Avoid classes; prefer iteration and modularization over code duplication.
- Use descriptive variable names with auxiliary verbs (e.g., "isLoading", "hasError").
- Structure files into: exported component, subcomponents, helpers, static content, and types.
</style-and-structure>

<naming-conventions>
- Use lowercase with dashes for directories (e.g., "components/auth-wizard").
- Favor named exports for components.
</naming-conventions>

<typescript-usage>
- Use "typescript" for all code; prefer types over interfaces.
- Avoid "enums"; use maps instead.
- Use functional components with TypeScript types.
</typescript-usage>

<syntax-and-formatting>
- Avoid unnecessary curly braces in conditionals; use concise syntax for simple statements.
- Write declarative syntax.
</syntax-and-formatting>

<ui-and-styling>
- When using DSFR components, Always create first a component in @/components/ui so that we can easily extend it
- Tailwind for components and styling.
- Use framer motion for animations.
- Implement responsive design with Tailwind CSS using a mobile-first approach.
</ui-and-styling>

<performance-optimization>
- Minimize "use client", "useEffect", and "useState";
- Use dynamic loading for non-critical components.
- Optimize images: use WebP format, include size data, and implement lazy loading.
</performance-optimization>

<database-querying-and-data-model-creation>
- Use kysely to query the database.
- For data models, read the "in src/server/db/kysely/database.ts" file.
</database-querying-and-data-model-creation>

<key-conventions>
- Use "useQueryState" for URL search parameter state management.
- Optimize how URLs (CLP, CLS, TSP).
- Whenever in need for an API GET request, use useFetch from @/hooks/useApi.
- Whenever in need for an API POST request, use usePost from @/hooks/useApi.
- Whenever in need for an API DELETE request, use useDelete from @/hooks/useApi.
</key-conventions>

<postgresql>
- Use valid PostgreSQL syntax with guillemet for table and column names.
</postgresql>

<next-15-and-react-19>
- Utilize React 19.
</next-15-and-react-19>

<creating-a-component>
- You always use "export default" for main component.
- You always use an object "props" as the first argument of your component

Example:

```tsx
export type MyComponentProps = {
prop1: string;
prop2: number;
};

const MyComponent: React.FC<MyComponentProps> = ({ prop1, prop2 }) => {
return <div>{props.prop1}</div>;
};

export default MyComponent;
```

</creating-a-component>
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@
"next-auth": "^4.24.5",
"next-nprogress-bar": "^2.3.15",
"next-sitemap": "^4.2.3",
"nodemailer": "^6.9.9",
"nodemailer": "^6.10.0",
"nuqs": "^1.17.7",
"p-limit": "3.1.0",
"papaparse": "^5.4.1",
Expand Down
220 changes: 130 additions & 90 deletions src/components/ComparateurPublicodes/ComparateurPublicodes.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { type DottedName } from '@betagouv/france-chaleur-urbaine-publicodes';
import { fr } from '@codegouvfr/react-dsfr';
import Alert from '@codegouvfr/react-dsfr/Alert';
import ToggleSwitch from '@codegouvfr/react-dsfr/ToggleSwitch';
import Drawer from '@mui/material/Drawer';
import { parseAsStringLiteral, useQueryState } from 'nuqs';
Expand All @@ -10,13 +9,18 @@ import AddressAutocomplete from '@/components/form/dsfr/AddressAutocompleteInput
import { FormProvider } from '@/components/form/publicodes/FormProvider';
import Label from '@/components/form/publicodes/Label';
import Accordion from '@/components/ui/Accordion';
import Alert from '@/components/ui/Alert';
import Box from '@/components/ui/Box';
import Button from '@/components/ui/Button';
import Heading from '@/components/ui/Heading';
import { FCUArrowIcon } from '@/components/ui/Icon';
import Link from '@/components/ui/Link';
import Notice from '@/components/ui/Notice';
import Text from '@/components/ui/Text';
import useEligibilityForm from '@/hooks/useEligibilityForm';
import { type LocationInfoResponse } from '@/pages/api/location-infos';
import { useServices } from '@/services';
import { type AddressDetail } from '@/types/HeatNetworksResponse';
import cx from '@/utils/cx';
import { postFetchJSON } from '@/utils/network';
import { slugify } from '@/utils/strings';
Expand Down Expand Up @@ -75,13 +79,14 @@ const ComparateurPublicodes: React.FC<ComparateurPublicodesProps> = ({
});

const [address, setAddress] = useQueryState('address');
const [addressDetail, setAddressDetail] = React.useState<AddressDetail>();
const [modesDeChauffage] = useQueryState('modes-de-chauffage');
const [lngLat, setLngLat] = React.useState<[number, number]>();
const [nearestReseauDeChaleur, setNearestReseauDeChaleur] = React.useState<LocationInfoResponse['nearestReseauDeChaleur']>();
const [addressError, setAddressError] = React.useState<boolean>(false);
const [nearestReseauDeFroid, setNearestReseauDeFroid] = React.useState<LocationInfoResponse['nearestReseauDeFroid']>();
const inclureLaClimatisation = engine.getField('Inclure la climatisation');

const { heatNetworkService } = useServices();
const advancedMode = displayMode === 'technicien';
const [selectedTabId, setSelectedTabId] = useQueryState(
'tabId',
Expand Down Expand Up @@ -112,20 +117,131 @@ const ComparateurPublicodes: React.FC<ComparateurPublicodesProps> = ({

const isAddressSelected = engine.getField('code département') !== undefined;

const results =
isAddressSelected && !!modesDeChauffage ? (
const displayResults = isAddressSelected && !!modesDeChauffage;

const { open: displayContactForm, EligibilityFormModal } = useEligibilityForm({
id: `eligibility-form-comparateur`,
address: {
address,
coordinates: lngLat,
addressDetails: addressDetail,
},
});

const results = displayResults ? (
<div className="p-2 lg:p-0">
{!displayResults && (
<Notice variant="info" className="mb-5">
{!isAddressSelected
? '1. Commencez par sélectionner une adresse'
: !modesDeChauffage
? '2. Maintenant, sélectionnez au moins un mode de chauffage'
: ''}
</Notice>
)}

{!loading && address && displayResults && (
<Alert size="sm" className="mb-5" variant={nearestReseauDeChaleur ? 'info' : 'warning'}>
{nearestReseauDeChaleur ? (
<>
Le réseau de chaleur{' '}
<Link
href={`/reseaux/${nearestReseauDeChaleur['Identifiant reseau']}?address=${encodeURIComponent(address as string)}`}
isExternal
>
<strong>{nearestReseauDeChaleur.nom_reseau}</strong>
</Link>{' '}
est à <strong>{nearestReseauDeChaleur.distance}m</strong> de votre adresse.
{!nearestReseauDeChaleur?.PM && (
<Text color="warning" my="1v" size="xs">
À noter qu’en l'absence de données tarifaires pour ce réseau, les simulations se basent sur le prix de la chaleur moyen
des réseaux français.
</Text>
)}
<p className="text-sm my-5">
Vous souhaitez recevoir des informations adaptées à votre bâtiment de la part du gestionnaire du réseau ? Nous assurons
votre mise en relation !
</p>
<div className="flex gap-5 items-center justify-end">
{lngLat && (
<Link
isExternal
href={`/carte?coord=${lngLat.join(',')}&zoom=17&address=${encodeURIComponent(address as string)}`}
className="fr-block"
>
<strong>Visualiser sur la carte</strong>
</Link>
)}
<Button onClick={displayContactForm} size="small">
Être mis en relation avec le gestionnaire
</Button>
</div>
</>
) : (
<>
<p className="text-sm">
Il n'y a pas de réseau de chaleur à proximité de l'adresse testée.{' '}
<strong>Les simulations se basent sur le réseau de chaleur français moyen.</strong>
</p>
<p className="text-sm my-5">Vous souhaitez faire connaître à la collectivité votre intérêt pour ce mode de chauffage ?</p>
<div className="flex gap-5 items-center justify-end">
<Button onClick={displayContactForm} size="small">
Laissez vos coordonnées
</Button>
</div>
</>
)}
</Alert>
)}
{!loading && inclureLaClimatisation && address && displayResults && (
<Alert size="sm" className="mb-5" variant={nearestReseauDeFroid ? 'info' : 'warning'}>
{nearestReseauDeFroid ? (
<>
Le réseau de froid{' '}
<Link
href={`/reseaux/${nearestReseauDeFroid['Identifiant reseau']}?address=${encodeURIComponent(address as string)}`}
isExternal
>
<strong>{nearestReseauDeFroid.nom_reseau}</strong>
</Link>{' '}
est à <strong>{nearestReseauDeFroid.distance}m</strong> de votre adresse.
<Text color="warning" my="1v" size="xs">
À noter qu’en l'absence de données tarifaires pour ce réseau, les simulations se basent sur le prix du froid moyen des
réseaux français.
</Text>
{lngLat && (
<div className="fr-text--xs">
<Link
isExternal
href={`/carte?coord=${lngLat.join(',')}&zoom=17&address=${encodeURIComponent(address as string)}`}
className="fr-block"
>
<strong>Visualiser sur la carte</strong>
</Link>
</div>
)}
</>
) : (
<>
En l'absence d'un <strong>réseau de froid</strong> à proximité, les simulations se basent sur le réseau de froid français
moyen
</>
)}
</Alert>
)}
<Graph
engine={engine}
advancedMode={advancedMode}
usedReseauDeChaleurLabel={nearestReseauDeChaleur?.nom_reseau || 'Valeur moyenne'}
captureImageName={`${new Date().getFullYear()}-${slugify(address)}`}
/>
) : (
<ResultsNotAvailable />
);

</div>
) : (
<ResultsNotAvailable />
);
return (
<>
<EligibilityFormModal />
<div className={cx(fr.cx('fr-container'), className)} {...props}>
<FormProvider engine={engine}>
<Section>
Expand Down Expand Up @@ -199,7 +315,11 @@ const ComparateurPublicodes: React.FC<ComparateurPublicodesProps> = ({
if (addressLabel !== address) {
setAddress(null);
}

const network = await heatNetworkService.findByCoords(selectedAddress);
setAddressDetail({
network,
geoAddress: selectedAddress,
});
const infos: LocationInfoResponse = await postFetchJSON('/api/location-infos', {
lon,
lat,
Expand Down Expand Up @@ -274,87 +394,7 @@ const ComparateurPublicodes: React.FC<ComparateurPublicodesProps> = ({
</Accordion>
)}
</Box>
<Results>
{!loading && address && (
<Alert
className="fr-text--sm fr-mb-2w"
description={
nearestReseauDeChaleur ? (
<>
Le réseau de chaleur{' '}
<Link
href={`/reseaux/${nearestReseauDeChaleur['Identifiant reseau']}?address=${encodeURIComponent(
address as string
)}`}
isExternal
>
<strong>{nearestReseauDeChaleur.nom_reseau}</strong>
</Link>{' '}
est à <strong>{nearestReseauDeChaleur.distance}m</strong> de votre adresse.
{!nearestReseauDeChaleur?.PM && (
<Text color="warning" my="1v" size="xs">
À noter qu’en l'absence de données tarifaires pour ce réseau, les simulations se basent sur le prix de la
chaleur moyen des réseaux français.
</Text>
)}
{lngLat && (
<div className="fr-text--xs">
<Link isExternal href={`/carte?coord=${lngLat.join(',')}&zoom=17`} className="fr-block">
<strong>Visualiser sur la carte</strong>
</Link>
</div>
)}
</>
) : (
<>
En l'absence d'un <strong>réseau de chaleur</strong> à proximité, les simulations se basent sur le réseau de
chaleur français moyen
</>
)
}
severity={nearestReseauDeChaleur ? 'info' : 'warning'}
small
/>
)}
{!loading && inclureLaClimatisation && address && (
<Alert
className="fr-text--sm fr-mb-2w"
description={
nearestReseauDeFroid ? (
<>
Le réseau de froid{' '}
<Link
href={`/reseaux/${nearestReseauDeFroid['Identifiant reseau']}?address=${encodeURIComponent(address as string)}`}
isExternal
>
<strong>{nearestReseauDeFroid.nom_reseau}</strong>
</Link>{' '}
est à <strong>{nearestReseauDeFroid.distance}m</strong> de votre adresse.
<Text color="warning" my="1v" size="xs">
À noter qu’en l'absence de données tarifaires pour ce réseau, les simulations se basent sur le prix du froid
moyen des réseaux français.
</Text>
{lngLat && (
<div className="fr-text--xs">
<Link isExternal href={`/carte?coord=${lngLat.join(',')}&zoom=17`} className="fr-block">
<strong>Visualiser sur la carte</strong>
</Link>
</div>
)}
</>
) : (
<>
En l'absence d'un <strong>réseau de froid</strong> à proximité, les simulations se basent sur le réseau de froid
français moyen
</>
)
}
severity={nearestReseauDeFroid ? 'info' : 'warning'}
small
/>
)}
{results}
</Results>
<Results>{results}</Results>
<FloatingButton onClick={() => setGraphDrawerOpen(true)} iconId="ri-arrow-up-fill">
Voir les résultats
</FloatingButton>
Expand Down
Loading

0 comments on commit a7b3e39

Please sign in to comment.