diff --git a/frontend/app/(views)/(website)/vehicles/layout.tsx b/frontend/app/(views)/(website)/vehicles/layout.tsx
new file mode 100644
index 00000000..b44b0744
--- /dev/null
+++ b/frontend/app/(views)/(website)/vehicles/layout.tsx
@@ -0,0 +1,13 @@
+/* * */
+
+import { VehiclesListContextProvider } from '@/contexts/VehiclesList.context';
+
+/* * */
+
+export default function Layout({ children }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/app/(views)/(website)/vehicles/page.tsx b/frontend/app/(views)/(website)/vehicles/page.tsx
new file mode 100644
index 00000000..640a2633
--- /dev/null
+++ b/frontend/app/(views)/(website)/vehicles/page.tsx
@@ -0,0 +1,9 @@
+/* * */
+
+import { VehiclesList } from '@/components/vehicles/VehiclesList';
+
+/* * */
+
+export default function Page() {
+ return ;
+}
diff --git a/frontend/components/layout/Grid/index.tsx b/frontend/components/layout/Grid/index.tsx
index 9387b584..cd64be57 100644
--- a/frontend/components/layout/Grid/index.tsx
+++ b/frontend/components/layout/Grid/index.tsx
@@ -1,11 +1,10 @@
/* * */
-
import styles from './styles.module.css';
-
/* * */
interface Props {
children?: React.ReactNode
+ classname?: string
columns?: 'a' | 'aab' | 'ab' | 'abb' | 'abc' | 'abcd'
hAlign?: 'center' | 'end' | 'start'
vAlign?: 'center' | 'end' | 'start'
@@ -14,9 +13,9 @@ interface Props {
/* * */
-export function Grid({ children, columns = 'a', hAlign = 'start', vAlign = 'start', withGap }: Props) {
+export function Grid({ children, classname, columns = 'a', hAlign = 'start', vAlign = 'start', withGap }: Props) {
return (
-
+
{children}
);
diff --git a/frontend/components/lines/LinesDetailMetricsService/index.tsx b/frontend/components/lines/LinesDetailMetricsService/index.tsx
index 633ce0d8..7f9ae4f4 100644
--- a/frontend/components/lines/LinesDetailMetricsService/index.tsx
+++ b/frontend/components/lines/LinesDetailMetricsService/index.tsx
@@ -62,7 +62,7 @@ export function LinesDetailMetricsService() {
...service,
fail_trip_count: service.total_trip_count - service.pass_trip_count,
operational_date: DateTime.fromFormat(service.operational_date, 'yyyyMMdd').toFormat('ccc, dd LLL yyyy', { locale: 'pt-PT' }),
- pass_trip_percentage: (service.pass_trip_percentage * 100).toFixed(2),
+ pass_trip_percentage: Math.round(((service.pass_trip_percentage * 100) + Number.EPSILON) * 100) / 100,
}));
}, [last15DaysService]);
diff --git a/frontend/components/map/MapViewStyleVehicles/index.tsx b/frontend/components/map/MapViewStyleVehicles/index.tsx
index 9dffd14b..e4e3e2f5 100644
--- a/frontend/components/map/MapViewStyleVehicles/index.tsx
+++ b/frontend/components/map/MapViewStyleVehicles/index.tsx
@@ -12,7 +12,7 @@ import styles from './styles.module.css';
/* * */
export const MapViewStyleVehiclesPrimaryLayerId = 'default-layer-vehicles-regular';
-export const MapViewStyleVehiclesInteractiveLayerId = '';
+export const MapViewStyleVehiclesInteractiveLayerId = 'default-layer-vehicles-regular';
/* * */
diff --git a/frontend/components/vehicles/VehicleListInfoBlock/index.tsx b/frontend/components/vehicles/VehicleListInfoBlock/index.tsx
new file mode 100644
index 00000000..b48bb544
--- /dev/null
+++ b/frontend/components/vehicles/VehicleListInfoBlock/index.tsx
@@ -0,0 +1,19 @@
+/* * */
+
+import { useTranslations } from 'next-intl';
+
+/* * */
+export function VehiclesListInfoBlock() {
+ //
+ // A. Setup variables
+
+ const t = useTranslations('vehicles.VehiclesListInfoBlock');
+
+ //
+ // B. Render components
+ return (
+ <>
+
{t('heading')}
+ >
+ );
+}
diff --git a/frontend/components/vehicles/VehicleListMapBadge/index.tsx b/frontend/components/vehicles/VehicleListMapBadge/index.tsx
new file mode 100644
index 00000000..a2d74a9a
--- /dev/null
+++ b/frontend/components/vehicles/VehicleListMapBadge/index.tsx
@@ -0,0 +1,39 @@
+'use client';
+
+/* * */
+
+import type { Line } from '@carrismetropolitana/api-types/network';
+
+import classNames from 'classnames/bind';
+
+import styles from './styles.module.css';
+
+/* * */
+
+interface Props {
+ color?: string
+ lineData?: Line
+ shortName?: string
+ textColor?: string
+}
+
+/* * */
+
+const cx = classNames.bind(styles);
+
+/* * */
+
+export function VehicleListMapBadge({ lineData }: Props) {
+ // A. Render components
+ return (
+ <>
+
+ {lineData?.short_name || '• • •'}
+
+
+
{lineData?.long_name}
+
+ >
+ );
+ //
+}
diff --git a/frontend/components/vehicles/VehicleListMapBadge/styles.module.css b/frontend/components/vehicles/VehicleListMapBadge/styles.module.css
new file mode 100644
index 00000000..0c6b2b0b
--- /dev/null
+++ b/frontend/components/vehicles/VehicleListMapBadge/styles.module.css
@@ -0,0 +1,38 @@
+/* * */
+/* BADGE */
+
+.badge {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 999px;
+ color: var(--color-system-background-100);
+ background-color: var(--color-system-text-200);
+ font-weight: var(--font-weight-extrabold);
+ letter-spacing: 1px;
+ line-height: 1;
+ position: relative;
+
+}
+
+.badge.md {
+ font-size: 16px;
+ min-width: 65px;
+ max-width: 65px;
+ min-height: 26px;
+ max-height: 26px;
+ text-align: center;
+ vertical-align: middle;
+ display: flex;
+
+}
+
+/* * */
+
+/* LINE HEADER */
+.line_name{
+ color: var(--color-system-text-100);
+ font-size: var(--font-size-subtitle);
+ font-weight: var(--font-weight-extrabold);
+}
+/* * */
\ No newline at end of file
diff --git a/frontend/components/vehicles/VehiclesList/index.tsx b/frontend/components/vehicles/VehiclesList/index.tsx
new file mode 100644
index 00000000..972933d0
--- /dev/null
+++ b/frontend/components/vehicles/VehiclesList/index.tsx
@@ -0,0 +1,53 @@
+'use client';
+
+/* * */
+
+import { Section } from '@/components/layout/Section';
+import { Surface } from '@/components/layout/Surface';
+import VehiclesListMap from '@/components/vehicles/VehiclesListMap';
+import VehiclesListToolbar from '@/components/vehicles/VehiclesListToolbar';
+import { useTranslations } from 'next-intl';
+
+import { VehiclesListInfoBlock } from '../VehicleListInfoBlock';
+import styles from './styles.module.css';
+
+/* * */
+
+export function VehiclesList() {
+ //
+
+ //
+ // A. Setup variables
+
+ const t = useTranslations('vehicles.VehiclesList');
+
+ //
+ // B. Render components
+
+ return (
+ <>
+
+
+
+ {/*
+
+ */}
+
+
+
+
+
+ >
+ );
+
+ //
+}
diff --git a/frontend/components/vehicles/VehiclesList/styles.module.css b/frontend/components/vehicles/VehiclesList/styles.module.css
new file mode 100644
index 00000000..e03b0734
--- /dev/null
+++ b/frontend/components/vehicles/VehiclesList/styles.module.css
@@ -0,0 +1,38 @@
+/* * */
+/* CONTAINER */
+
+.container {
+ display: grid;
+ grid-template:
+ "a b" minmax(100vh, auto) / 1fr 1fr;
+ align-items: flex-start;
+ justify-content: flex-start;
+ width: 100%;
+}
+
+@media (width < 1000px) {
+ .container {
+ grid-template:
+ "b" 50vh [b_]
+ "a" auto [a_] / 1fr;
+ }
+}
+
+/* * */
+/* MAP WRAPPER */
+
+.mapWrapper {
+ position: sticky;
+ top: var(--size-height-header);
+ grid-area: a;
+ height: 100%;
+ max-height: calc(100vh - var(--size-height-header));
+ border-right: 1px solid var(--color-system-border-100);
+}
+
+@media (width < 1000px) {
+ .mapWrapper {
+ position: static;
+ border: none;
+ }
+}
\ No newline at end of file
diff --git a/frontend/components/vehicles/VehiclesListMap/index.tsx b/frontend/components/vehicles/VehiclesListMap/index.tsx
new file mode 100644
index 00000000..fbc65134
--- /dev/null
+++ b/frontend/components/vehicles/VehiclesListMap/index.tsx
@@ -0,0 +1,115 @@
+'use client';
+
+import { MapView } from '@/components/map/MapView';
+import { MapViewStylePath } from '@/components/map/MapViewStylePath';
+import {
+ MapViewStyleVehicles,
+ MapViewStyleVehiclesInteractiveLayerId,
+ MapViewStyleVehiclesPrimaryLayerId,
+} from '@/components/map/MapViewStyleVehicles';
+import { transformStopDataIntoGeoJsonFeature, useStopsContext } from '@/contexts/Stops.context';
+import { useVehiclesContext } from '@/contexts/Vehicles.context';
+import { useVehiclesListContext } from '@/contexts/VehiclesList.context';
+import { getBaseGeoJsonFeatureCollection } from '@/utils/map.utils';
+import { Routes } from '@/utils/routes';
+import { Pattern } from '@carrismetropolitana/api-types/network';
+import { Feature, FeatureCollection, GeoJsonProperties, Geometry } from 'geojson';
+import { DateTime } from 'luxon';
+import { useMemo, useState } from 'react';
+
+export default function Component() {
+ // A. Setup variables
+ const vehiclesListContext = useVehiclesListContext();
+ const vehiclesContext = useVehiclesContext();
+ const stopsContext = useStopsContext();
+
+ const [pattern, setPattern] = useState
(undefined);
+ const [activePathShapeGeoJson, setActivePathShapeGeoJson] = useState | FeatureCollection | undefined>(undefined);
+
+ const selectedVehicleFromList = vehiclesListContext.data.selected;
+ const selectedVehicle = selectedVehicleFromList && vehiclesContext.data.vehicles.find(vehicle => vehicle.id === selectedVehicleFromList.id);
+
+ // B. Fetch Data
+
+ const findTodaysDate = () => DateTime.now().toFormat('yyyyLLdd');
+
+ const fetchPattern = useMemo(async () => {
+ if (!selectedVehicle) return [];
+ const date = await findTodaysDate();
+ const patternId = vehiclesListContext.data.selected?.pattern_id;
+
+ if (patternId) {
+ const actualPattern = await fetch(`${Routes.API}/patterns/${patternId}`).then(res => res.json());
+ const isAvailable = actualPattern.filter(item => item.valid_on.includes(date));
+
+ setPattern(isAvailable);
+
+ return isAvailable;
+ }
+ }, [vehiclesListContext.data.selected]);
+
+ const fetchShape = useMemo(async () => {
+ if (!pattern) return [];
+ const color = pattern.map(item => item.color.toString());
+ const shape = await fetch(`${Routes.API}/shapes/${pattern[0].shape_id}`).then(res => res.json());
+
+ shape.geojson.properties = { ...shape.geojson.properties, color: color[0] };
+
+ setActivePathShapeGeoJson(shape.geojson);
+
+ return shape.geojson;
+ }, [pattern]);
+
+ const activeVehiclesGeoJson = useMemo(() => {
+ if (vehiclesListContext.data.filtered && vehiclesListContext.data.filtered.length > 0) {
+ const features: Feature[] = [];
+ vehiclesListContext.data.filtered.forEach((vehicle) => {
+ const fc = vehiclesContext.actions.getVehicleByIdGeoJsonFC(vehicle.id);
+ if (fc && fc.features && fc.features.length > 0) {
+ features.push(fc.features[0]);
+ }
+ });
+ return { features, type: 'FeatureCollection' as const };
+ }
+ else {
+ return vehiclesContext.actions.getAllVehiclesGeoJsonFC();
+ }
+ }, [vehiclesListContext.data.filtered, vehiclesContext.data.vehicles]);
+
+ const activePathWaypointsGeoJson = useMemo(() => {
+ if (!pattern) return;
+ const collection = getBaseGeoJsonFeatureCollection();
+ pattern.map(pattern => pattern.path.forEach((pathStop) => {
+ const stopData = stopsContext.actions.getStopById(pathStop.stop_id);
+ if (!stopData) return;
+ const result = transformStopDataIntoGeoJsonFeature(stopData);
+ result.properties = {
+ ...result.properties,
+ color: pattern.color,
+ text_color: pattern.text_color,
+ };
+ collection.features.push(result);
+ }));
+ return collection;
+ }, [pattern, vehiclesContext.data.vehicles]);
+
+ // C. Handle actions
+ function handleLayerClick(event) {
+ if (event.features.length === 0) {
+ setActivePathShapeGeoJson(undefined);
+ setPattern(undefined);
+ vehiclesListContext.actions.updateSelectedVehicle('');
+ }
+ if (event.features.length !== 0 && event.features[0].source === 'default-source-vehicles') {
+ vehiclesListContext.actions.updateSelectedVehicle(event.features[0].properties.id);
+ }
+ }
+ // D. Render component
+ return (
+
+
+
+
+ );
+ //
+}
diff --git a/frontend/components/vehicles/VehiclesListMapDetails/index.tsx b/frontend/components/vehicles/VehiclesListMapDetails/index.tsx
new file mode 100644
index 00000000..9d07e1f9
--- /dev/null
+++ b/frontend/components/vehicles/VehiclesListMapDetails/index.tsx
@@ -0,0 +1,73 @@
+import { Section } from '@/components/layout/Section';
+import { VehicleListMapBadge } from '@/components/vehicles/VehicleListMapBadge';
+import { Table } from '@mantine/core';
+import {
+ IconBike,
+ IconBikeOff,
+ IconWheelchair,
+ IconWheelchairOff,
+} from '@tabler/icons-react';
+
+import styles from './styles.module.css';
+
+export function VehicleListMapDetails({ lineData, selectedVehicle }) {
+ const {
+ bikes_allowed,
+ capacity_seated = 'Não Definido',
+ capacity_standing = 'Não Definido',
+ capacity_total = 'Não Definido',
+ current_status = 'Não Definido',
+ emission_class = 'Não Definido',
+ id = 'Não Definido',
+ license_plate = 'Não Definido',
+ make = 'Não Definido',
+ model = 'Não Definido',
+ propulsion = 'Não Definido',
+ wheelchair_accessible,
+ } = selectedVehicle;
+
+ const rows = [
+ { label: 'ID', value: id },
+ { label: 'Lugares Sentados', value: capacity_seated },
+ { label: 'Lugares em pé', value: capacity_standing },
+ { label: 'Capacidade Total', value: capacity_total },
+ { label: 'Marca', value: make },
+ { label: 'Modelo', value: model },
+ { label: 'Propulsão', value: propulsion },
+ { label: 'Emission Class', value: emission_class },
+ { label: 'Estado Atual', value: current_status },
+ ];
+
+ return (
+
+
+
+
+
+ {bikes_allowed ?
:
}
+ {wheelchair_accessible ?
:
}
+
{license_plate}
+
+
+
+
+
+
+ Campo
+ Valor
+
+
+
+ {rows.map(row => (
+
+ {row.label}
+ {row.value}
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/frontend/components/vehicles/VehiclesListMapDetails/styles.module.css b/frontend/components/vehicles/VehiclesListMapDetails/styles.module.css
new file mode 100644
index 00000000..485648ee
--- /dev/null
+++ b/frontend/components/vehicles/VehiclesListMapDetails/styles.module.css
@@ -0,0 +1,47 @@
+/* ICON LIST */
+.iconList {
+ display: flex;
+ flex-direction: row !important;
+ justify-content: center;
+ padding: 10px;
+ gap: 10px;
+}
+
+/* LICENSE PLATE */
+.license_plate {
+ border: 2px solid var(--color-system-text-200);
+ font-size: var(--font-size-text);
+ padding: 2px;
+ border-radius: 10px;
+}
+/* * */
+/* DATA WRAPPER */
+.dataWrapper{
+ margin: 0 auto;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ width: 100%;
+}
+
+/* TABLE WRAPPER */
+.tableWrapper{
+ padding: 10px;
+ width: 100%;
+}
+/* * */
+/* TABLE DATA */
+.tableHeaders{
+ font-size: var(--font-size-title);
+ font-weight: var(--font-weight-bold);
+}
+
+.rowLabel{
+ font-size: var(--font-size-subtitle);
+ font-weight: var(--font-weight-semibold);
+}
+.rowValue{
+ font-size: var(--font-size-text);
+ font-weight: var(--font-weight-text);
+}
+/* * */
\ No newline at end of file
diff --git a/frontend/components/vehicles/VehiclesListToolbar/index.tsx b/frontend/components/vehicles/VehiclesListToolbar/index.tsx
new file mode 100644
index 00000000..98f7a630
--- /dev/null
+++ b/frontend/components/vehicles/VehiclesListToolbar/index.tsx
@@ -0,0 +1,143 @@
+'use client';
+
+/* * */
+
+import { ExpandToggle } from '@/components/common/ExpandToggle';
+import { FoundItemsCounter } from '@/components/common/FoundItemsCounter';
+import { Grid } from '@/components/layout/Grid';
+import { NoDataLabel } from '@/components/layout/NoDataLabel';
+import { Section } from '@/components/layout/Section';
+import { useLinesContext } from '@/contexts/Lines.context';
+import { useVehiclesContext } from '@/contexts/Vehicles.context';
+import { useVehiclesListContext } from '@/contexts/VehiclesList.context';
+import { MultiSelect, Select, TextInput } from '@mantine/core';
+import { IconArrowLoopRight, IconBike, IconGasStation, IconTriangle, IconUser, IconWheelchair } from '@tabler/icons-react';
+import { useTranslations } from 'next-intl';
+
+import { VehicleListMapDetails } from '../VehiclesListMapDetails';
+import styles from './styles.module.css';
+
+/* * */
+
+export default function Component() {
+ // A. Setup variables
+
+ const t = useTranslations('vehicles.VehiclesListToolbar');
+
+ const vehiclesContext = useVehiclesContext();
+ const LinesContext = useLinesContext();
+ const vehiclesListContext = useVehiclesListContext();
+
+ const selectedVehicleFromList = vehiclesListContext.data.selected;
+ const selectedVehicle = selectedVehicleFromList && vehiclesContext.data.vehicles.find(vehicle => vehicle.id === selectedVehicleFromList.id);
+
+ const lineData = LinesContext.actions.getLineDataById(selectedVehicle?.line_id || '');
+
+ //
+
+ // B. Handle Actions
+ const handleTextInputChange = ({ currentTarget }) => {
+ vehiclesListContext.actions.updateFilterBySearch(currentTarget.value);
+ };
+
+ const handleBikesAllowedInputChange = (option: string) => {
+ vehiclesListContext.actions.updateFilterByIsBikeAllowed(option);
+ };
+
+ const handleReducedMobilityChange = (option: string) => {
+ vehiclesListContext.actions.updateFilterByWheelchair(option);
+ };
+
+ const handleAgencyIdChange = (option: string[]) => {
+ vehiclesListContext.actions.updateFilterByAgency(option);
+ };
+
+ const handleMakeAndModelChange = (option: string[]) => {
+ vehiclesListContext.actions.updateFilterByMakeAndModel(option);
+ };
+
+ const handlePropulsionChange = (option: string[]) => {
+ vehiclesListContext.actions.updateFilterByPropulsion(option);
+ };
+
+ //
+ // C. Render components
+ return (
+
+
+ } onChange={handleTextInputChange} placeholder={t('filter_by.search')} type="search" value={vehiclesListContext.filters.by_search} />
+ ({ label: p.name, value: p.name })) || []}
+ leftSection={}
+ onChange={handlePropulsionChange}
+ placeholder={t('filter_by.propulsion')}
+ radius="sm"
+ value={vehiclesListContext.filters.by_propulsion?.split(' ') || []}
+ clearable
+ searchable
+ />
+
+
+
+
+ }
+ onChange={handleReducedMobilityChange}
+ placeholder={t('filter_by.wheel_chair')}
+ radius="sm"
+ value={vehiclesListContext.filters.by_isWheelchairAcessible}
+ data={[{ label: 'Não', value: 'false' },
+ { label: 'Sim', value: 'true' }]}
+ clearable
+ searchable
+ />
+ }
+ onChange={handleBikesAllowedInputChange}
+ placeholder={t('filter_by.bicycle')}
+ radius="sm"
+ value={vehiclesListContext.filters.by_isBicicleAllowed}
+ data={[{ label: 'Não', value: 'false' },
+ { label: 'Sim', value: 'true' }]}
+ clearable
+ searchable
+ />
+ ({ label: a.name, value: a.agency_id.toString() })) || []}
+ leftSection={}
+ onChange={handleAgencyIdChange}
+ placeholder={t('filter_by.operator')}
+ radius="sm"
+ value={vehiclesListContext.filters.by_agency?.split(' ') || []}
+ clearable
+ searchable
+ />
+ }
+ onChange={handleMakeAndModelChange}
+ placeholder={t('filter_by.make_model')}
+ radius="sm"
+ value={vehiclesListContext.filters.by_makeAndModel?.split(',') || []}
+ data={
+ vehiclesListContext.data?.makes_and_models?.map(make => ({
+ group: make.name,
+ items: make.models.map(model => ({
+ label: model.name,
+ value: `${make.name}-${model.name}`,
+ })),
+ })) || []
+ }
+ clearable
+ searchable
+ />
+
+
+
+
+ {!selectedVehicle && (
)}
+ {selectedVehicle && ()}
+
+ );
+
+ //
+}
diff --git a/frontend/components/vehicles/VehiclesListToolbar/styles.module.css b/frontend/components/vehicles/VehiclesListToolbar/styles.module.css
new file mode 100644
index 00000000..1314f635
--- /dev/null
+++ b/frontend/components/vehicles/VehiclesListToolbar/styles.module.css
@@ -0,0 +1,9 @@
+/* NO DATA CONTAINER*/
+.noDataContainer{
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ align-self: center;
+ height: 100%;
+}
\ No newline at end of file
diff --git a/frontend/contexts/Vehicles.context.tsx b/frontend/contexts/Vehicles.context.tsx
index a297dc15..9503480b 100644
--- a/frontend/contexts/Vehicles.context.tsx
+++ b/frontend/contexts/Vehicles.context.tsx
@@ -2,7 +2,7 @@
/* * */
-import type { Vehicle } from '@/types/vehicles.types';
+import type { Vehicle } from '@carrismetropolitana/api-types/vehicles';
import { getBaseGeoJsonFeatureCollection } from '@/utils/map.utils';
import { Routes } from '@/utils/routes';
@@ -14,8 +14,11 @@ import useSWR from 'swr';
interface VehiclesContextState {
actions: {
+ getAllVehicles: () => undefined | Vehicle[]
+ getAllVehiclesGeoJsonFC: () => GeoJSON.FeatureCollection | undefined
getVehicleById: (vehicleId: string) => undefined | Vehicle
getVehicleByIdGeoJsonFC: (vehicleId: string) => GeoJSON.FeatureCollection | undefined
+ // getVehicleBySearch: (searchData: string) => null | string
getVehiclesByLineId: (lineId: string) => Vehicle[]
getVehiclesByLineIdGeoJsonFC: (lineId: string) => GeoJSON.FeatureCollection | undefined
getVehiclesByPatternId: (patternId: string) => Vehicle[]
@@ -24,6 +27,7 @@ interface VehiclesContextState {
getVehiclesByTripIdGeoJsonFC: (tripId: string) => GeoJSON.FeatureCollection | undefined
}
data: {
+ filtered: Vehicle[]
vehicles: Vehicle[]
}
flags: {
@@ -55,7 +59,7 @@ export const VehiclesContextProvider = ({ children }) => {
const allVehiclesData = useMemo(() => {
const now = DateTime.now().toSeconds();
- return fetchedVehiclesData?.filter((vehicle: Vehicle) => vehicle.timestamp > now - 180) || [];
+ return fetchedVehiclesData?.filter((vehicle: Vehicle) => vehicle.timestamp ?? 0 > now - 180) || [];
}, [fetchedVehiclesData]);
//
@@ -73,6 +77,16 @@ export const VehiclesContextProvider = ({ children }) => {
return collection;
};
+ const getAllVehicles = (): undefined | Vehicle[] => {
+ return allVehiclesData;
+ };
+
+ const getAllVehiclesGeoJsonFC = (): GeoJSON.FeatureCollection | undefined => {
+ const collection = getBaseGeoJsonFeatureCollection();
+ allVehiclesData.forEach(vehicle => collection.features.push(transformVehicleDataIntoGeoJsonFeature(vehicle)));
+ return collection;
+ };
+
const getVehiclesByLineId = (lineId: string): Vehicle[] => {
return allVehiclesData?.filter(vehicle => vehicle.line_id === lineId) || [];
};
@@ -109,13 +123,27 @@ export const VehiclesContextProvider = ({ children }) => {
return collection;
};
+ // const getVehicleBySearch = (searchData: string): Vehicle[] => {
+ // return allVehiclesData?.filter();
+ // };
+ // const getVehicleBySearchFC = (seachData: string): void => {
+ // const vehicle = getVehicleBySearch(seachData);
+ // if (!vehicle) return;
+ // const collection = getBaseGeoJsonFeatureCollection();
+ // collection.features.push(transformVehicleDataIntoGeoJsonFeature(vehicle));
+ // return collection;
+ // };
+
//
// C. Define context value
const contextValue: VehiclesContextState = {
actions: {
+ getAllVehicles,
+ getAllVehiclesGeoJsonFC,
getVehicleById,
getVehicleByIdGeoJsonFC,
+ // getVehicleBySearch,
getVehiclesByLineId,
getVehiclesByLineIdGeoJsonFC,
getVehiclesByPatternId,
@@ -124,6 +152,7 @@ export const VehiclesContextProvider = ({ children }) => {
getVehiclesByTripIdGeoJsonFC,
},
data: {
+ filtered: allVehiclesData || [],
vehicles: allVehiclesData || [],
},
flags: {
@@ -148,14 +177,14 @@ export const VehiclesContextProvider = ({ children }) => {
function transformVehicleDataIntoGeoJsonFeature(vehicleData: Vehicle): GeoJSON.Feature {
return {
geometry: {
- coordinates: [vehicleData.lon, vehicleData.lat],
+ coordinates: [vehicleData.lon || 0, vehicleData.lat || 0],
type: 'Point',
},
properties: {
bearing: vehicleData.bearing,
block_id: vehicleData.block_id,
current_status: vehicleData.current_status,
- delay: Math.floor(Date.now() / 1000) - vehicleData.timestamp,
+ delay: Math.floor(Date.now() / 1000) - (vehicleData.timestamp || 0),
id: vehicleData.id,
line_id: vehicleData.line_id,
pattern_id: vehicleData.id,
@@ -165,7 +194,7 @@ function transformVehicleDataIntoGeoJsonFeature(vehicleData: Vehicle): GeoJSON.F
speed: vehicleData.speed,
stop_id: vehicleData.stop_id,
timestamp: vehicleData.timestamp,
- timeString: new Date(vehicleData.timestamp * 1000).toLocaleString(),
+ timeString: new Date((vehicleData.timestamp || 0) * 1000).toLocaleString(),
trip_id: vehicleData.trip_id,
},
type: 'Feature',
diff --git a/frontend/contexts/VehiclesList.context.tsx b/frontend/contexts/VehiclesList.context.tsx
new file mode 100644
index 00000000..b6df8719
--- /dev/null
+++ b/frontend/contexts/VehiclesList.context.tsx
@@ -0,0 +1,301 @@
+'use client';
+/* * */
+import { Routes } from '@/utils/routes';
+import { Vehicle } from '@carrismetropolitana/api-types/vehicles';
+import { useQueryState } from 'nuqs';
+import { createContext, useContext, useEffect, useState } from 'react';
+import useSWR from 'swr';
+/* * */
+
+interface VehiclesListContextState {
+ actions: {
+ updateFilterByAgency: (agency: string[]) => void
+ updateFilterByIsBikeAllowed: (isBikeAllowed: string) => void
+ updateFilterByMakeAndModel: (makeAndModel: string[]) => void
+ updateFilterByPropulsion: (propulsion: string[]) => void
+ updateFilterBySearch: (search: string) => void
+ updateFilterByWheelchair: (isWheelchairAcessible: string) => void
+ updateSelectedVehicle: (vehicleId: string) => void
+ }
+ data: {
+ agencys: null | { agency_id: number, name: string }[]
+ filtered: Vehicle[]
+ makes_and_models: null | { id: number, models: { id: number, name: string }[], name: string }[]
+ propulsions: null | { id: number, name: string }[]
+ raw: Vehicle[]
+ selected: null | Vehicle
+ }
+ filters: {
+ by_agency: null | string
+ by_isBicicleAllowed: null | string
+ by_isWheelchairAcessible: null | string
+ by_makeAndModel: null | string
+ by_propulsion: null | string
+ by_search: string
+ selected_vehicle: null | string
+ }
+ flags: {
+ is_loading: boolean
+ }
+}
+
+const VehiclesListContext = createContext(undefined);
+
+export function useVehiclesListContext() {
+ const context = useContext(VehiclesListContext);
+ if (!context) {
+ throw new Error('useVehiclesListContext must be used within a VehiclesListContext');
+ }
+ return context;
+}
+
+export const VehiclesListContextProvider = ({ children }) => {
+ //
+
+ //
+ // A. Setup variables
+ const [dataFilteredState, setDataFilteredState] = useState([]);
+ const [dataSelectedState, setDataSelectedState] = useState(null);
+
+ const [allAgencys, setAllAgencys] = useState<{ agency_id: number, name: string }[]>([]);
+ const [allMakesAndModels, setAllMakesAndModels] = useState<{ id: number, models: { id: number, name: string }[], name: string }[]>([]);
+ const [allPropulsions, setAllPropulsions] = useState([]);
+
+ const [filterByWheelchairAccesibleState, setWheelchairAccesibleState] = useQueryState('isWheelchair', { clearOnDefault: true });
+ const [filterByAgencyState, setFilterByAgencyState] = useQueryState('agency', { clearOnDefault: true });
+ const [filterByBicycleAllowedState, setByBicycleAllowedState] = useQueryState('isBikeAllowed', { clearOnDefault: true });
+ const [filterByMakeAndModelState, setFilterByMakeAndModelState] = useQueryState('makeModel', { clearOnDefault: true });
+ const [filterBySearchState, setFilterBySearchState] = useQueryState('search', { clearOnDefault: true, defaultValue: '' });
+ const [filterByPropulsionState, setFilterByPropulsion] = useQueryState('propulsion', { clearOnDefault: true });
+
+ //
+ // B. Fetch data
+ const { data: allVehicleData, isLoading: allVehiclesLoading } = useSWR(`${Routes.API}/vehicles`, { refreshInterval: 30000 });
+
+ //
+ // C. Transform data
+ const applyFiltersToData = () => {
+ let filterResult = allVehicleData || [];
+
+ if (filterByBicycleAllowedState) {
+ filterResult = filterResult.filter(item => item.bikes_allowed?.toString() === filterByBicycleAllowedState);
+ }
+
+ if (filterByWheelchairAccesibleState) {
+ filterResult = filterResult.filter(item => item.wheelchair_accessible?.toString() === filterByWheelchairAccesibleState);
+ }
+
+ if (filterByPropulsionState) {
+ const propulsionValues = filterByPropulsionState.split(' ').filter(Boolean);
+ filterResult = filterResult.filter(item => item.propulsion && propulsionValues.includes(item.propulsion));
+ }
+
+ if (filterByAgencyState) {
+ const agencyValues = filterByAgencyState.split(' ').filter(Boolean);
+ filterResult = filterResult.filter(item => agencyValues.includes(item.agency_id.toString()));
+ }
+
+ if (filterBySearchState) {
+ filterResult = filterResult.filter(item => item.license_plate?.toLowerCase().includes(filterBySearchState.toLowerCase()));
+ }
+
+ if (filterByMakeAndModelState && filterByMakeAndModelState.trim() !== '') {
+ const makeModelValues = filterByMakeAndModelState.split(',').filter(Boolean);
+ filterResult = filterResult.filter((item) => {
+ return makeModelValues.some((val) => {
+ const [makeFilter, modelFilter] = val.split('-').map(s => s.trim().toLowerCase());
+ const itemMake = item.make?.toLowerCase() || '';
+ const itemModel = item.model?.toLowerCase() || '';
+ return itemMake.includes(makeFilter) && itemModel.includes(modelFilter);
+ });
+ });
+ }
+ return filterResult;
+ };
+
+ const getAllMakesAndModels = () => {
+ if (!allVehicleData) return [];
+ const makesMap = new Map();
+ let makeIdCounter = 1;
+ let modelIdCounter = 1;
+ allVehicleData.forEach((vehicle) => {
+ if (vehicle.make !== undefined && vehicle.model !== undefined) {
+ const makeName = vehicle.make;
+ const modelName = vehicle.model;
+ if (!makesMap.has(makeName)) {
+ makesMap.set(makeName, {
+ id: makeIdCounter,
+ models: [],
+ name: makeName,
+ });
+ makeIdCounter++;
+ }
+ const makeObj = makesMap.get(makeName);
+ if (!makeObj.models.some(model => model.name === modelName)) {
+ makeObj.models.push({ id: modelIdCounter, name: modelName });
+ modelIdCounter++;
+ }
+ }
+ });
+ const makes_and_models = Array.from(makesMap.values());
+ setAllMakesAndModels(makes_and_models);
+ return makes_and_models;
+ };
+
+ const getAllAgencys = () => {
+ const agencysMap = new Map();
+ allVehicleData?.forEach((vehicle) => {
+ if (vehicle.agency_id !== undefined) {
+ const agency_Id = vehicle.agency_id;
+ const agencyOverrides = {
+ 41: 'Viação Alvorada',
+ 42: 'Rodoviária de Lisboa (RL)',
+ 43: 'Transportes Sul do Tejo (TST)',
+ 44: 'Alsa Todi',
+ };
+
+ if (!agencysMap.has(agency_Id)) {
+ agencysMap.set(agency_Id, { agency_id: agency_Id, name: agencyOverrides[agency_Id] });
+ }
+ }
+ });
+ const agencys = Array.from(agencysMap.values());
+ setAllAgencys(agencys);
+ return agencys;
+ };
+
+ const getAllPropulsion = () => {
+ if (!allVehicleData) return [];
+ const propulsionsMap = new Map();
+ let idCounter = 1;
+ allVehicleData.forEach((vehicle) => {
+ if (vehicle.propulsion !== undefined) {
+ const propulsionType = vehicle.propulsion;
+ if (!propulsionsMap.has(propulsionType)) {
+ propulsionsMap.set(propulsionType, { id: idCounter, name: propulsionType });
+ idCounter++;
+ }
+ }
+ });
+ const propulsions = Array.from(propulsionsMap.values());
+ setAllPropulsions(propulsions);
+ return propulsions;
+ };
+
+ useEffect(() => {
+ const filteredVehicles = applyFiltersToData();
+ setDataFilteredState(filteredVehicles || []);
+ }, [
+ filterBySearchState,
+ filterByAgencyState,
+ filterByBicycleAllowedState,
+ filterByMakeAndModelState,
+ filterByPropulsionState,
+ filterByWheelchairAccesibleState,
+ allVehicleData,
+ ]);
+
+ useEffect(() => {
+ if (!allVehicleData && !allVehiclesLoading) return;
+ getAllAgencys();
+ getAllMakesAndModels();
+ getAllPropulsion();
+ }, [allVehicleData]);
+
+ //
+ // D. Handle actions
+
+ const updateFilterBySearch = (search: string) => {
+ setFilterBySearchState(search);
+ };
+
+ const updateFilterByAgency = (agency: string[]) => {
+ if (agency.length !== 0) {
+ setFilterByAgencyState(agency.join(' '));
+ }
+ else {
+ setFilterByAgencyState(null);
+ }
+ };
+
+ const updateFilterByIsBikeAllowed = (isBikeAllowed: string) => {
+ setByBicycleAllowedState(isBikeAllowed);
+ };
+
+ const updateFilterByWheelchair = (isWheelchair: string) => {
+ setWheelchairAccesibleState(isWheelchair);
+ };
+
+ const updateFilterByMakeAndModel = (makeAndModel: string[]) => {
+ if (makeAndModel.length === 0) {
+ setFilterByMakeAndModelState(null);
+ }
+ else {
+ setFilterByMakeAndModelState(makeAndModel.join(','));
+ }
+ };
+
+ const updateFilterByPropulsion = (propulsionOptions: string[]) => {
+ if (propulsionOptions.length === 0) {
+ setFilterByPropulsion(null);
+ }
+ else {
+ setFilterByPropulsion(propulsionOptions.join(' ').trim());
+ }
+ };
+
+ const updateSelectedVehicle = (vehicleId: string) => {
+ if (!allVehicleData) return;
+ const foundVehicleData = allVehicleData.filter(item => item.id === vehicleId) || null;
+
+ if (foundVehicleData) {
+ setDataSelectedState(foundVehicleData[0] || null);
+ }
+ };
+
+ //
+ // E. Define context value
+
+ const contextValue: VehiclesListContextState = {
+ actions: {
+ updateFilterByAgency,
+ updateFilterByIsBikeAllowed,
+ updateFilterByMakeAndModel,
+ updateFilterByPropulsion,
+ updateFilterBySearch,
+ updateFilterByWheelchair,
+ updateSelectedVehicle,
+ },
+ data: {
+ agencys: allAgencys || [],
+ filtered: dataFilteredState,
+ makes_and_models: allMakesAndModels || [],
+ propulsions: allPropulsions || [],
+ raw: allVehicleData || [],
+ selected: dataSelectedState,
+ },
+ filters: {
+ by_agency: filterByAgencyState,
+ by_isBicicleAllowed: filterByBicycleAllowedState,
+ by_isWheelchairAcessible: filterByWheelchairAccesibleState,
+ by_makeAndModel: filterByMakeAndModelState,
+ by_propulsion: filterByPropulsionState,
+ by_search: filterBySearchState,
+ selected_vehicle: dataSelectedState?.id || null,
+ },
+ flags: {
+ is_loading: allVehiclesLoading,
+ },
+ };
+
+ //
+ // F. Render components
+
+ return (
+
+ {children}
+
+ );
+
+ //
+};
diff --git a/frontend/i18n/translations/en.json b/frontend/i18n/translations/en.json
index 6cf64c57..3db6e752 100644
--- a/frontend/i18n/translations/en.json
+++ b/frontend/i18n/translations/en.json
@@ -1320,7 +1320,8 @@
"drivers": "Apply to drive with us",
"metrics": "Our live operation",
"news": "News",
- "open-data": "Open Data"
+ "open-data": "Open Data",
+ "vehicles": "Vehicles"
}
},
"schedules": {
@@ -1534,5 +1535,26 @@
},
"utils": {
"number": "{value, number}"
+ },
+ "vehicles": {
+ "VehiclesList": {
+ "heading": "Veículos",
+ "subheading": "The pass navegante municipal Familia allows that each family pays at most the value of 2 municipal navegante pass indenpendently of the number of elemnts of the family. It is valid in all the companies of the public means of transport, in all of the 18 municipalities of the Lisbon metropolitan area."
+ },
+ "VehiclesListInfoBox": {
+ "heading": "Vehicle Information"
+ },
+ "VehiclesListToolbar": {
+ "filter_by": {
+ "bicycle": "Permite Bicicletas",
+ "make_model": "Make and Model",
+ "operator": "Agency",
+ "propulsion": "Fuel Type",
+ "search": "Search by license plate",
+ "wheel_chair": "Reduced Mobility"
+ },
+ "found_items_counter": "{count, plural, =0 {No vehicle found.} one {Found # vehicle} other {Found # vehicles}}",
+ "select_vehicle": "Select a vehicle"
+ }
}
}
diff --git a/frontend/i18n/translations/pt.json b/frontend/i18n/translations/pt.json
index be5ee9e6..c56ce3ec 100644
--- a/frontend/i18n/translations/pt.json
+++ b/frontend/i18n/translations/pt.json
@@ -1321,7 +1321,8 @@
"drivers": "Recrutamento de Motoristas",
"metrics": "A nosssa operação ao vivo",
"news": "Notícias",
- "open-data": "Dados Abertos"
+ "open-data": "Dados Abertos",
+ "vehicles": "Veículos"
}
},
"schedules": {
@@ -1535,5 +1536,27 @@
},
"utils": {
"number": "{value, number}"
+ },
+ "vehicles": {
+ "VehiclesList": {
+ "heading": "Veículos",
+ "subheading": "O passe navegante municipal Família permite que cada agregado familiar pague no máximo o valor de 2 passes navegante municipal, independentemente do número de elementos do agregado familiar. É válido em todas as empresas de serviço público de transporte regular de passageiros, em todos os 18 municípios da Área Metropolitana de Lisboa."
+ },
+ "VehiclesListInfoBlock": {
+ "heading": "Informação do Veículo"
+ },
+ "VehiclesListToolbar": {
+ "filter_by": {
+ "bicycle": "Permite Bicicletas",
+ "make_model": "Marca e Modelo",
+ "operator": "Operador",
+ "propulsion": "Combustível",
+ "search": "Pesquise por matrícula",
+ "wheel_chair": "Mobilidade Reduzida"
+
+ },
+ "found_items_counter": "{count, plural, =0 {Nenhum Veículo encontrado.} one {Encontrado # veículo} other {Encontrados # veículos}}",
+ "select_vehicle": "Selecione um veículo"
+ }
}
}
diff --git a/frontend/package.json b/frontend/package.json
index 912ccc45..3d798706 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -10,18 +10,18 @@
"start": "next start"
},
"dependencies": {
- "@mantine/carousel": "7.16.2",
- "@mantine/charts": "7.16.2",
- "@mantine/core": "7.16.2",
- "@mantine/dates": "7.16.2",
- "@mantine/form": "7.16.2",
- "@mantine/hooks": "7.16.2",
- "@mantine/modals": "7.16.2",
- "@mantine/notifications": "7.16.2",
+ "@mantine/carousel": "7.16.3",
+ "@mantine/charts": "7.16.3",
+ "@mantine/core": "7.16.3",
+ "@mantine/dates": "7.16.3",
+ "@mantine/form": "7.16.3",
+ "@mantine/hooks": "7.16.3",
+ "@mantine/modals": "7.16.3",
+ "@mantine/notifications": "7.16.3",
"@rajesh896/broprint.js": "2.1.1",
- "@tabler/icons-react": "3.29.0",
+ "@tabler/icons-react": "3.30.0",
"@turf/turf": "7.2.0",
- "@vis.gl/react-maplibre": "8.0.0",
+ "@vis.gl/react-maplibre": "8.0.1",
"classnames": "2.5.1",
"dayjs": "1.11.13",
"embla-carousel-autoplay": "8.5.2",
@@ -41,13 +41,13 @@
"react-viewport-list": "7.1.2",
"recharts": "2.15.1",
"sharp": "0.33.5",
- "swr": "2.3.0",
- "ua-parser-js": "2.0.0",
+ "swr": "2.3.2",
+ "ua-parser-js": "2.0.2",
"ukkonen": "2.1.0",
"uuid": "11.0.5"
},
"devDependencies": {
- "@carrismetropolitana/api-types": "20250131.1502.30",
+ "@carrismetropolitana/api-types": "20250205.1903.50",
"@carrismetropolitana/eslint": "20250128.1601.13",
"@types/geojson": "7946.0.16",
"@types/jsonwebtoken": "9.0.8",
diff --git a/frontend/settings/navigation.settings.tsx b/frontend/settings/navigation.settings.tsx
index 463ab55d..bf537912 100644
--- a/frontend/settings/navigation.settings.tsx
+++ b/frontend/settings/navigation.settings.tsx
@@ -3,7 +3,7 @@
import type { NavigationGroup } from '@/types/navigation.types';
import { RoutesFooter, RoutesPricing, RoutesSchedule, RoutesSupport } from '@/utils/routes';
-import { IconAlertTriangle, IconArrowLoopRight, IconBellSchool, IconBuildingStore, IconBusStop, IconChartBar, IconCreditCardPay, IconDirections, IconHelpHexagon, IconHomeSpark, IconMapQuestion, IconMessages, IconNews, IconPrompt, IconTicket, IconUmbrella, IconUserHeart } from '@tabler/icons-react';
+import { IconAlertTriangle, IconArrowLoopRight, IconBellSchool, IconBuildingStore, IconBus, IconBusStop, IconChartBar, IconCreditCardPay, IconDirections, IconHelpHexagon, IconHomeSpark, IconMapQuestion, IconMessages, IconNews, IconPrompt, IconTicket, IconUmbrella, IconUserHeart } from '@tabler/icons-react';
/* * */
@@ -47,6 +47,7 @@ export const mainNavigationGroup: NavigationGroup[] = [
{ _id: 'open-data', href: '/open-data', icon: },
{ _id: 'drivers', href: '/drivers', icon: , target: '_blank' },
{ _id: 'about', href: '/about', icon: },
+ { _id: 'vehicles', href: '/vehicles', icon: },
],
},
diff --git a/frontend/themes/_default/default.theme.js b/frontend/themes/_default/default.theme.js
index 494faff4..3b1dcd79 100644
--- a/frontend/themes/_default/default.theme.js
+++ b/frontend/themes/_default/default.theme.js
@@ -19,12 +19,13 @@ import '@/themes/_default/styles/wordpress.css';
import AccordionOverride from '@/themes/_default/overrides/Accordion.module.css';
import ButtonOverride from '@/themes/_default/overrides/Button.module.css';
import DatePickerInputOverride from '@/themes/_default/overrides/DatePickerInput.module.css';
+import MultiSelectOverride from '@/themes/_default/overrides/MultiSelect.module.css';
import SegmentedControlOverride from '@/themes/_default/overrides/SegmentedControl.module.css';
import SelectOverride from '@/themes/_default/overrides/Select.module.css';
import SkeletonOverride from '@/themes/_default/overrides/Skeleton.module.css';
import TextInputOverride from '@/themes/_default/overrides/TextInput.module.css';
import combineClasses from '@/utils/combineClasses';
-import { Accordion, Button, createTheme, SegmentedControl, Select, Skeleton, TextInput } from '@mantine/core';
+import { Accordion, Button, createTheme, MultiSelect, SegmentedControl, Select, Skeleton, TextInput } from '@mantine/core';
import { DatePickerInput } from '@mantine/dates';
import { IconCaretLeftFilled } from '@tabler/icons-react';
@@ -88,6 +89,18 @@ export default createTheme({
},
}),
+ MultiSelect: MultiSelect.extend({
+ classNames: () => {
+ let defaultClasses = {
+ dropdown: MultiSelectOverride.dropdown,
+ input: MultiSelectOverride.input,
+ option: MultiSelectOverride.option,
+ section: MultiSelectOverride.section,
+ wrapper: MultiSelectOverride.wrapper,
+ };
+ return defaultClasses;
+ } }),
+
SegmentedControl: SegmentedControl.extend({
classNames: (_, props) => {
let defaultClasses = {
diff --git a/frontend/themes/_default/overrides/MultiSelect.module.css b/frontend/themes/_default/overrides/MultiSelect.module.css
new file mode 100644
index 00000000..ad5f50a5
--- /dev/null
+++ b/frontend/themes/_default/overrides/MultiSelect.module.css
@@ -0,0 +1,84 @@
+/* * */
+/* INPUT */
+
+.input {
+ height: auto;
+ max-height: none;
+ padding: var(--size-spacing-15);
+ font-size: 18px;
+ font-weight: var(--font-weight-medium);
+ line-height: 1;
+ cursor: pointer;
+ background-color: var(--color-system-background-100);
+ border: 1px solid var(--color-system-border-200);
+ border-radius: 5px;
+}
+
+.input::placeholder {
+ color: var(--color-system-text-300);
+}
+
+.wrapper[data-with-left-section="true"] .input {
+ padding-left: calc(var(--size-spacing-15) + 33px);
+}
+
+.wrapper[data-with-right-section="true"] .input {
+ padding-right: calc(var(--size-spacing-15) + 30px);
+}
+
+/* * */
+/* SECTION */
+
+.section {
+ display: flex;
+ width: auto;
+ padding: var(--size-spacing-15);
+ color: var(--color-system-text-300);
+}
+
+.section[data-position="left"] {
+ pointer-events: none;
+}
+
+.section.variantWhite {
+ color: var(--color-system-text-300);
+}
+
+/* * */
+/* DROPDOWN */
+
+.dropdown {
+ padding: 6px var(--size-spacing-5);
+ overflow: hidden;
+ color: var(--color-system-text-300);
+ background-color: var(--color-system-background-100);
+ border: 1px solid var(--color-system-border-200);
+ border-radius: 5px;
+ box-shadow: 0 0 20px 0 rgb(0 0 0 / 5%);
+}
+
+/* * */
+/* OPTION */
+
+.option {
+ padding: var(--size-spacing-10);
+ font-size: 16px;
+ font-weight: var(--font-weight-medium);
+ color: var(--color-system-text-200);
+ border-radius: 3px;
+}
+
+.option:hover {
+ color: var(--color-system-text-100);
+ background-color: var(--color-system-background-200);
+}
+
+.option[data-combobox-selected="true"],
+.option[data-combobox-active="true"] {
+ color: var(--color-system-text-100);
+ background-color: var(--color-system-border-100);
+}
+
+.option[data-checked="true"] {
+ color: var(--color-system-text-100);
+}
\ No newline at end of file