Skip to content

Commit 7617b6f

Browse files
committed
feat: lab edition of measures form
1 parent 2968874 commit 7617b6f

11 files changed

Lines changed: 273 additions & 172 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { IMeasure } from "../../types";
2+
import { LivingLabMeasureForm } from "./form/LivingLabMeasureForm";
3+
4+
type LivingLabMeasuresProps = {
5+
livingLabId: number;
6+
title: string;
7+
measures?: IMeasure[];
8+
className?: string;
9+
};
10+
11+
export function LivingLabMeasures({
12+
livingLabId,
13+
measures = [],
14+
title = "Measures",
15+
className = "",
16+
}: LivingLabMeasuresProps) {
17+
return (
18+
<div
19+
className={`flex flex-col gap-4 items-start my-4 mx-auto ${className}`}
20+
>
21+
<div className="flex-1 grid grid-cols-1 gap-4">
22+
<h4 className="text-center">{title}</h4>
23+
<div className="grid grid-cols-2 lg:grid-cols-2 mx-1 lg:mx-4 gap-4">
24+
{measures.map((m) => (
25+
<LivingLabMeasureForm
26+
livingLabId={livingLabId}
27+
key={m.name}
28+
measure={m}
29+
disabled={true}
30+
/>
31+
))}
32+
</div>
33+
</div>
34+
</div>
35+
);
36+
}

src/components/react/MobilityMeasures.tsx

Lines changed: 10 additions & 144 deletions
Original file line numberDiff line numberDiff line change
@@ -1,153 +1,20 @@
11
import { getUrl } from "../../lib/helpers";
2+
import type { IMeasure } from "../../types";
23
import { InfoCard } from "./ui/InfoCard";
34

4-
type Measure = {
5-
title: string;
6-
description?: string;
7-
imageUrl?: string;
8-
href?: string;
9-
className?: string;
10-
};
11-
125
type MobilityMeasuresProps = {
13-
pushMeasures?: Measure[];
14-
pullMeasures?: Measure[];
6+
pushMeasures?: IMeasure[];
7+
pullMeasures?: IMeasure[];
158
className?: string;
169
hideDescription?: boolean;
1710
cols?: 2 | 4;
1811
};
1912

20-
const defaultPush: Measure[] = [
21-
{
22-
title: "Congestion Charges",
23-
description:
24-
"Fees imposed on vehicles entering designated city areas to reduce traffic congestion.",
25-
imageUrl: "/icons/ticket.svg",
26-
},
27-
{
28-
title: "Parking Charges",
29-
description:
30-
"Increased parking fees to discourage long-term car use and promote modal shift.",
31-
imageUrl: "/icons/parking_sign.svg",
32-
},
33-
{
34-
title: "Restricted Parking",
35-
description:
36-
"Reductions in available public parking to limit car access, especially in central zones.",
37-
imageUrl: "/icons/no_cars.svg",
38-
},
39-
{
40-
title: "Limited Traffic Zone / Pedestrianisation",
41-
description:
42-
"Car-free or low-emission zones that prioritize pedestrians, cyclists, and shared mobility.",
43-
imageUrl: "/icons/pedestrian.svg",
44-
},
45-
{
46-
title: "Parking Supply Management",
47-
description:
48-
"Policies that reduce or restructure parking supply to manage demand and encourage alternatives.",
49-
imageUrl: "/icons/parking_sign.svg",
50-
},
51-
{
52-
title: "Speed Limits",
53-
description:
54-
"Lowering vehicle speed limits to improve safety and promote non-motorized travel.",
55-
imageUrl: "/icons/30sign.svg",
56-
},
57-
];
58-
59-
const defaultPull: Measure[] = [
60-
{
61-
title: "Mobility Hubs",
62-
description:
63-
"Centralized locations where multiple transport modes (e.g., bikes, buses, taxis) are integrated.",
64-
imageUrl: "/icons/cityz_zones.svg",
65-
},
66-
{
67-
title: "Scheduling Integration in MaaS",
68-
description:
69-
"Coordinated schedules across transport services to streamline connections.",
70-
imageUrl: "/icons/time_table.svg",
71-
},
72-
{
73-
title: "Ticketing Integration in MaaS",
74-
description:
75-
"Unified or digital ticketing across services to simplify travel.",
76-
imageUrl: "/icons/mobile.svg",
77-
},
78-
{
79-
title: "Improved NSM Infrastructure",
80-
description:
81-
"New infrastructure like bike lanes, “park & ride” stations to support non-car travel.",
82-
imageUrl: "/icons/bike_lane.svg",
83-
},
84-
{
85-
title: "On-Demand Vehicle Sharing",
86-
description:
87-
"Access to shared vehicles (cars, bikes, e-scooters) on demand via apps or stations.",
88-
imageUrl: "/icons/mobile.svg",
89-
},
90-
{
91-
title: "Nudging / Gamification",
92-
description:
93-
"Use of rewards or games to encourage use of shared or sustainable transport.",
94-
imageUrl: "/icons/kid1.svg",
95-
},
96-
{
97-
title: "Dynamic Pricing of NSM",
98-
description:
99-
"Flexible pricing (e.g., cheaper off-peak rides) to balance demand and promote use.",
100-
imageUrl: "/icons/ticket.svg",
101-
},
102-
{
103-
title: "Improved Information Availability",
104-
description:
105-
"Real-time data on vehicle availability and travel options via digital platforms.",
106-
imageUrl: "/icons/mobile.svg",
107-
},
108-
{
109-
title: "New NSM Services Introduced",
110-
description:
111-
"Launch of bike-sharing, scooter-sharing, or other new transport options.",
112-
imageUrl: "/icons/e-scooter.svg",
113-
},
114-
{
115-
title: "New PT Infrastructure / Lines",
116-
description:
117-
"Deployment of new public transport lines like electric buses or tram extensions.",
118-
imageUrl: "/icons/metro_tunnel.svg",
119-
},
120-
{
121-
title: "Streets Retrofitting / Priority Lanes",
122-
description:
123-
"Street redesign for better walking/cycling and priority lanes for buses or shared modes.",
124-
imageUrl: "/icons/cityz_zones.svg",
125-
},
126-
{
127-
title: "Dedicated Parking for Carsharing / Micromobility",
128-
description:
129-
"Reserved spaces to support the use of shared cars or micromobility vehicles.",
130-
imageUrl: "/icons/bycicle_parking.svg",
131-
},
132-
{
133-
title: "Widening PT Geographical Area",
134-
description:
135-
"Extending public transport coverage to reach underserved zones.",
136-
imageUrl: "/icons/metro_map.svg",
137-
},
138-
{
139-
title: "Central PT Planning Program",
140-
description:
141-
"Comprehensive public transport network planning to enhance coordination and efficiency.",
142-
imageUrl: "/icons/cityz_zones.svg",
143-
},
144-
];
145-
14613
type MeasuresSectionProps = {
14714
heading: string;
14815
smallText: string;
14916
paragraph: string;
150-
measures: Measure[];
17+
measures: IMeasure[];
15118
hideDescription?: boolean;
15219
cols?: 2 | 4;
15320
};
@@ -175,12 +42,11 @@ function MeasuresSection({
17542
<div className={GRID_CLASS[cols]}>
17643
{measures.map((m) => (
17744
<InfoCard
178-
key={m.title}
179-
title={m.title}
45+
key={m.name}
46+
title={m.name}
18047
description={hideDescription ? "" : m.description}
181-
imageUrl={getUrl(m.imageUrl)}
182-
href={m.href}
183-
className={m.className}
48+
imageUrl={getUrl(m.image_url)}
49+
href="#"
18450
/>
18551
))}
18652
</div>
@@ -189,8 +55,8 @@ function MeasuresSection({
18955
}
19056

19157
export function MobilityMeasures({
192-
pushMeasures = defaultPush,
193-
pullMeasures = defaultPull,
58+
pushMeasures = [],
59+
pullMeasures = [],
19460
className = "",
19561
hideDescription = false,
19662
cols = 2,
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { useState } from "react";
2+
import type { IMeasure } from "../../../types";
3+
import { getUrl } from "../../../lib/helpers";
4+
5+
type Props = {
6+
livingLabId: number;
7+
measure: IMeasure;
8+
initialChecked?: boolean;
9+
disabled?: boolean;
10+
onToggle?: (m: IMeasure, checked: boolean) => void;
11+
className?: string;
12+
};
13+
14+
export function LivingLabMeasureForm({
15+
livingLabId,
16+
measure,
17+
initialChecked = false,
18+
disabled = true,
19+
onToggle,
20+
className = "",
21+
}: Props) {
22+
const [checked, setChecked] = useState<boolean>(initialChecked);
23+
24+
function handleSelect(next: boolean) {
25+
// Intentionally left empty for backend call — to be implemented.
26+
// Will be called when user toggles checkbox or clicks the card.
27+
//api call POST /living-labs/${livingLabId}/projects/${measure.id} with body {selected: next}
28+
//backend will handle create or delete based on 'next' value
29+
}
30+
31+
function toggle(e?: React.MouseEvent) {
32+
// Prevent double handlers if coming from checkbox change
33+
const next = !checked;
34+
setChecked(next);
35+
handleSelect(next);
36+
if (onToggle) onToggle(measure, next);
37+
}
38+
39+
const bgClass = checked ? "bg-success" : "bg-light";
40+
41+
return (
42+
<div
43+
className={`relative flex flex-col items-center space-x-2 rounded-lg border border-gray-300 px-2 py-2 shadow-xs focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-indigo-600 hover:border-gray-400 ${bgClass} ${className}`}
44+
aria-label={measure.name}
45+
onClick={toggle}
46+
>
47+
<div className="shrink-0">
48+
{measure.image_url ? (
49+
<img
50+
alt={measure.name}
51+
src={getUrl(measure.image_url as string)}
52+
className="h-10 w-10"
53+
/>
54+
) : null}
55+
</div>
56+
57+
<div className="min-w-0 flex-1 gap-y-1 text-center">
58+
<span aria-hidden="true" className="absolute inset-0" />
59+
<span className="font-semibold text-sm">{measure.name}</span>
60+
<br />
61+
{measure.description ? (
62+
<small className="mt-0 leading-0">{measure.description}</small>
63+
) : null}
64+
</div>
65+
66+
<div className="absolute top-1 right-1">
67+
<input
68+
type="checkbox"
69+
checked={checked}
70+
onChange={(e) => {
71+
// Stop propagation so outer click handler isn't double-invoked
72+
e.stopPropagation();
73+
const next = e.target.checked;
74+
setChecked(next);
75+
handleSelect(next);
76+
if (onToggle) onToggle(measure, next);
77+
}}
78+
disabled={disabled}
79+
aria-label={`select-${measure.id}`}
80+
/>
81+
</div>
82+
</div>
83+
);
84+
}

src/components/react/form/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export * from "./LivingLabForm";
33
export * from "./LivingLabTransportModeForm";
44
export * from "./LivingLabKpiResultForm";
55
export * from "./BeforeAndAfterDates";
6+
export * from "./LivingLabMeasureForm";

src/components/react/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ export * from "./KpiTypeBadge";
1212
export * from "./TransportTypeBadge";
1313
export * from "./LivingLabsMapSection";
1414
export * from "./MapViewer";
15+
export * from "./LivingLabMeasures";

src/lib/api-client/ApiClient.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type {
66
IKpi,
77
IIKpiResultBeforeAfter,
88
ILivingLabPopulated,
9-
Measure,
9+
IMeasure,
1010
ITransportMode,
1111
ITransportModeLivingLab,
1212
ILivingLab,
@@ -71,7 +71,7 @@ export default class ApiClient {
7171
return 0;
7272
});
7373
const populatedMeasures = lab?.measures?.map((measure) => {
74-
const measureData = measures.find((m) => m.id === measure.id) as Measure;
74+
const measureData = measures.find((m) => m.id === measure.id) as IMeasure;
7575
return { ...measure, ...measureData };
7676
});
7777

@@ -112,7 +112,7 @@ export default class ApiClient {
112112
return this.populateLivingLabData(lab);
113113
}
114114

115-
async getMeasures(): Promise<Measure[]> {
115+
async getMeasures(): Promise<IMeasure[]> {
116116
//return this.get(`/measures`);
117117

118118
return measures;

0 commit comments

Comments
 (0)